Compare commits

..

21 Commits

Author SHA1 Message Date
Marvin Blum
9e4d8ac2bf Button font weight. 2024-06-12 21:43:00 +02:00
d9f065d060 Fixed responsive styling. 2024-05-30 10:59:03 +02:00
904bbd130a Added referrer to external links. 2024-05-29 14:30:33 +02:00
46859b3ed2 Typo. 2024-05-29 14:26:35 +02:00
c2c3b0000f Responsive. 2024-05-29 12:27:20 +02:00
e513fb67b5 About me. 2024-05-29 12:12:02 +02:00
82ea722a09 Finished blog. 2024-05-29 11:58:01 +02:00
1f3767a99e Blog articles and more styling. 2024-05-29 01:11:51 +02:00
c4f9ab96fc Added icon. 2024-05-29 00:39:11 +02:00
5965e07259 More styling, more content. 2024-05-29 00:28:11 +02:00
bebb2f08f6 Styling, elements. 2024-05-28 19:52:04 +02:00
a92da686c5 Hightlight color. 2024-05-28 17:15:26 +02:00
8952108bb5 Started design. 2024-05-28 17:11:43 +02:00
752aeb9082 Files 2024-05-28 16:16:15 +02:00
8e173b9797 Removed config from repo. 2024-05-28 16:15:44 +02:00
03cbe99dad Switched to Shifu. 2024-05-28 16:12:14 +02:00
54658e707d Blog article and small improvements. 2021-09-12 20:04:36 +02:00
1429a5f924 CV. 2021-09-12 19:27:19 +02:00
85d6c82ad3 Legal. 2021-09-12 19:07:48 +02:00
4d1df3b3ea Added font, styled left part of homepage. 2021-09-12 18:10:35 +02:00
86681f0875 Basic Oogway setup. 2021-09-12 17:11:52 +02:00
89 changed files with 2114 additions and 1961 deletions

4
.gitignore vendored
View File

@@ -1,3 +1 @@
.idea/ config.json
static/blog/
geodb/

View File

@@ -1,25 +0,0 @@
FROM golang AS build
COPY . /go/src/github.com/Kugelschieber/marvinblum
WORKDIR /go/src/github.com/Kugelschieber/marvinblum
RUN apt-get update && apt-get upgrade -y
ENV GOPATH=/go
ENV CGO_ENABLED=0
RUN go build -ldflags "-s -w" main.go
FROM alpine
RUN apk update && \
apk upgrade && \
apk add --no-cache && \
apk add ca-certificates && \
rm -rf /var/cache/apk/*
COPY --from=build /go/src/github.com/Kugelschieber/marvinblum /app
WORKDIR /app
# default config
ENV MB_LOGLEVEL=info
ENV MB_ALLOWED_ORIGINS=*
ENV MB_HOST=0.0.0.0:8888
EXPOSE 8888
CMD ["/app/main"]

21
LICENSE
View File

@@ -1,21 +0,0 @@
MIT License
Copyright (c) 2020 Marvin Blum
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.

View File

@@ -1,3 +1,7 @@
# marvinblum.de # marvinblum.de
My website. My [website](https://marvinblum.de) built with [Shifu](https://github.com/emvi/shifu).
## Running Locally
Copy `dev_config.json` to `config.json` and start Shifu using `shifu` inside the project directory.

1
assets/js/main.js Normal file
View File

@@ -0,0 +1 @@
console.log("Hi!");

35
assets/scss/_font.scss Normal file
View File

@@ -0,0 +1,35 @@
@font-face {
font-family: "Open Sans";
font-style: italic;
font-weight: normal;
font-stretch: 100%;
font-display: swap;
src: url("../font/OpenSans-Italic.ttf") format("truetype");
}
@font-face {
font-family: "Open Sans";
font-style: normal;
font-weight: normal;
font-stretch: 100%;
font-display: swap;
src: url("../font/OpenSans-Regular.ttf") format("truetype");
}
@font-face {
font-family: "Open Sans";
font-style: normal;
font-weight: bold;
font-stretch: 100%;
font-display: swap;
src: url("../font/OpenSans-Bold.ttf") format("truetype");
}
@font-face {
font-family: "Open Sans";
font-style: italic;
font-weight: bold;
font-stretch: 100%;
font-display: swap;
src: url("../font/OpenSans-BoldItalic.ttf") format("truetype");
}

299
assets/scss/main.scss Normal file
View File

@@ -0,0 +1,299 @@
@import "_font.scss";
$borderRadius: 8px;
$textColor: #0f0f0f;
$linkColor: #717171;
$lightGray: #e6e6e6;
$highlight: #c0d9f0;
* {
box-sizing: border-box;
font-family: "Open Sans", sans-serif;
font-size: 18px;
line-height: 2;
color: $textColor;
@media screen and (max-width: 890px) {
font-size: 16px;
}
}
body {
margin: 0;
padding: 0 0 160px 0;
}
h1, h2 {
font-size: 32px;
margin: 0 0 40px 0;
font-weight: normal;
a {
font-size: 32px;
@media screen and (max-width: 890px) {
font-size: 26px;
}
}
@media screen and (max-width: 890px) {
font-size: 26px;
margin: 0 0 20px 0;
}
}
h2 {
margin: 40px 0;
font-size: 26px;
@media screen and (max-width: 890px) {
font-size: 20px;
margin: 20px 0;
}
}
a {
text-decoration: none;
transition: all 0.3s;
color: $linkColor;
&:hover {
color: $textColor;
}
}
p, ul {
margin: 20px 0;
}
img {
display: block;
max-width: 80%;
margin: 40px auto;
}
pre {
max-width: 80%;
margin: 40px auto;
padding: 20px;
border-width: 1px 1px 1px 2px;
border-style: solid;
border-color: $highlight;
border-radius: $borderRadius;
overflow-x: auto;
font-size: 14px;
}
code {
font-family: 'Courier New', Courier, monospace;
}
hr {
margin: 40px 0;
height: 2px;
background: $lightGray;
border-width: 0;
}
blockquote {
margin: 40px 0 40px 40px;
padding: 10px 20px;
border-width: 0 0 0 2px;
border-style: solid;
border-color: $highlight;
}
nav {
padding: 80px 40px;
background: $highlight;
.content {
display: flex;
justify-content: space-between;
align-items: center;
max-width: 1000px;
margin: 0 auto;
ul {
display: flex;
gap: 20px;
margin: 0;
padding: 0;
li {
list-style: none;
margin: 0;
padding: 0;
a {
color: $textColor;
svg {
fill: $textColor;
transition: all 0.3s;
}
&:hover {
color: $linkColor;
svg {
fill: $linkColor;
}
}
}
}
@media screen and (max-width: 890px) {
gap: 10px;
}
@media screen and (max-width: 580px) {
flex-direction: column;
}
}
.contact {
display: inline-block;
padding: 8px 16px;
border-radius: $borderRadius;
border: 3px solid $textColor;
color: $textColor;
transition: all 0.3s;
font-weight: 600;
&:hover {
background: $textColor;
color: #fff;
}
@media screen and (max-width: 890px) {
padding: 4px 8px;
}
}
@media screen and (max-width: 890px) {
padding: 40px;
align-items: flex-start;
}
@media screen and (max-width: 420px) {
padding: 20px 0;
}
}
@media screen and (max-width: 890px) {
padding: 0;
}
@media screen and (max-width: 420px) {
padding: 20px;
}
}
section {
max-width: 1080px;
margin: 80px auto 0 auto;
padding: 0 40px;
@media screen and (max-width: 890px) {
margin: 40px auto 0 auto;
}
@media screen and (max-width: 420px) {
padding: 0 20px;
}
}
footer {
max-width: 1080px;
margin: 80px auto 0 auto;
padding: 0 40px;
.content {
max-width: 1000px;
margin: 0 auto;
border-style: solid;
border-width: 2px 0 0 0;
border-color: $lightGray;
padding: 40px 0;
}
@media screen and (max-width: 420px) {
padding: 0 20px;
}
}
.intro {
background: $highlight;
max-width: 100%;
padding: 0 40px 80px 40px;
margin: 0;
article {
max-width: 1000px;
margin: 0 auto;
display: flex;
gap: 40px;
justify-content: flex-start;
align-items: center;
h1 {
margin: 0;
}
img {
max-width: 196px;
max-height: 196px;
aspect-ratio: 1;
border-radius: $borderRadius;
@media screen and (max-width: 890px) {
max-width: 142px;
max-height: 142px;
}
@media screen and (max-width: 600px) {
margin: 0;
}
}
@media screen and (max-width: 600px) {
flex-direction: column;
align-items: flex-start;
}
}
@media screen and (max-width: 600px) {
padding: 0 40px 40px 40px;
}
@media screen and (max-width: 420px) {
padding: 0 20px 40px 20px;
}
}
.blog-entry {
margin-bottom: 0;
p {
margin: 0;
color: $linkColor;
}
h2 {
margin: 0;
}
a {
color: $textColor;
font-size: 26px;
&:hover {
color: $linkColor;
}
@media screen and (max-width: 890px) {
font-size: 20px;
}
}
}

View File

@@ -1,206 +0,0 @@
package blog
import (
"fmt"
"github.com/Kugelschieber/marvinblum/tpl"
emvi "github.com/emvi/api-go"
"github.com/emvi/logbuch"
"io/ioutil"
"net/http"
"os"
"path/filepath"
"regexp"
"sync"
"time"
)
const (
blogCacheTime = time.Minute * 15
blogFileCache = "static/blog"
maxLatestArticles = 3
)
var (
linkRegex = regexp.MustCompile(`(?iU)href="/read/([^"]+)"`)
attachmentRegex = regexp.MustCompile(`(?iU)(href|src)="([^"]+)/api/v1/content/([^"]+)"`)
attachmentURLRegex = regexp.MustCompile(`(?iU)(href|src)="([^"]+/api/v1/content/)([^"]+)"`)
)
type Blog struct {
client *emvi.Client
articles []emvi.Article // sorted by date published (descending)
articleMap map[string]emvi.Article // id -> article
articlesYear map[int][]emvi.Article // year -> articleMap
nextUpdate time.Time
cache *tpl.Cache
m sync.Mutex
}
func NewBlog(cache *tpl.Cache) *Blog {
logbuch.Info("Initializing blog")
b := &Blog{cache: cache}
b.client = emvi.NewClient(os.Getenv("MB_EMVI_CLIENT_ID"),
os.Getenv("MB_EMVI_CLIENT_SECRET"),
os.Getenv("MB_EMVI_ORGA"),
nil)
b.nextUpdate = time.Now().Add(blogCacheTime)
if err := os.MkdirAll(blogFileCache, 0755); err != nil {
logbuch.Error("Error creating blog file cache directory", logbuch.Fields{"err": err})
}
b.loadArticles()
return b
}
func (blog *Blog) GetArticle(id string) emvi.Article {
blog.refreshIfRequired()
return blog.articleMap[id]
}
func (blog *Blog) GetArticles() map[int][]emvi.Article {
blog.refreshIfRequired()
return blog.articlesYear
}
func (blog *Blog) GetLatestArticles() []emvi.Article {
blog.refreshIfRequired()
articles := make([]emvi.Article, 0, 3)
i := 1
for _, v := range blog.articles {
articles = append(articles, v)
i++
if i > maxLatestArticles {
break
}
}
return articles
}
func (blog *Blog) loadArticles() {
blog.m.Lock()
defer blog.m.Unlock()
logbuch.Info("Refreshing blog articleMap...")
articles, offset, count := make([]emvi.Article, 0), 0, 1
var err error
for count > 0 {
var results []emvi.Article
results, _, err = blog.client.FindArticles("", &emvi.ArticleFilter{
BaseSearch: emvi.BaseSearch{Offset: offset},
Tags: "blog",
SortPublished: emvi.SortDescending,
})
if err != nil {
logbuch.Error("Error loading blog article", logbuch.Fields{"err": err})
break
}
offset += len(results)
count = len(results)
for _, article := range results {
articles = append(articles, article)
}
}
if err == nil {
for i, article := range articles {
article.LatestArticleContent = blog.loadArticle(article)
articles[i] = article
}
blog.setArticles(articles)
}
blog.nextUpdate = time.Now().Add(blogCacheTime)
logbuch.Info("Done", logbuch.Fields{"count": len(articles)})
}
func (blog *Blog) loadArticle(article emvi.Article) *emvi.ArticleContent {
existingArticle := blog.articleMap[article.Id]
var content *emvi.ArticleContent
if len(existingArticle.Id) == 0 || !existingArticle.ModTime.Equal(article.ModTime) {
var err error
_, content, _, err = blog.client.GetArticle(article.Id, article.LatestArticleContent.LanguageId, 0)
if err != nil {
logbuch.Error("Error loading article", logbuch.Fields{"err": err, "id": article.Id})
return nil
}
blog.downloadAttachments(article.Id, content.Content)
content.Content = linkRegex.ReplaceAllString(content.Content, `href="/blog/$1"`)
content.Content = attachmentRegex.ReplaceAllString(content.Content, fmt.Sprintf(`$1="/static/blog/%s/$3"`, article.Id))
logbuch.Debug("Article loaded", logbuch.Fields{"id": article.Id})
} else {
content = existingArticle.LatestArticleContent
logbuch.Debug("Article up to date, skipping refreshing cache", logbuch.Fields{"id": article.Id})
}
return content
}
func (blog *Blog) downloadAttachments(id, content string) {
if _, err := os.Stat(filepath.Join(blogFileCache, id)); os.IsNotExist(err) {
if err := os.MkdirAll(filepath.Join(blogFileCache, id), 0755); err != nil {
logbuch.Error("Error creating article file cache directory", logbuch.Fields{"err": err, "id": id})
return
}
}
results := attachmentURLRegex.FindAllStringSubmatch(content, -1)
for _, attachment := range results {
if len(attachment) == 4 {
resp, err := http.Get(attachment[2] + attachment[3])
if err != nil {
logbuch.Error("Error downloading blog attachment", logbuch.Fields{"err": err, "id": id})
continue
}
data, err := ioutil.ReadAll(resp.Body)
if err != nil {
logbuch.Error("Error reading blog attachment body", logbuch.Fields{"err": err, "id": id})
continue
}
if err := resp.Body.Close(); err != nil {
logbuch.Error("Error closing response body on attachment download", logbuch.Fields{"err": err, "id": id})
}
if err := ioutil.WriteFile(filepath.Join(blogFileCache, id, attachment[3]), data, 0755); err != nil {
logbuch.Error("Error saving blog attachment on disk", logbuch.Fields{"err": err, "id": id})
}
}
}
}
func (blog *Blog) setArticles(articles []emvi.Article) {
blog.articles = articles
blog.articleMap = make(map[string]emvi.Article)
blog.articlesYear = make(map[int][]emvi.Article)
for _, article := range articles {
if blog.articlesYear[article.Published.Year()] == nil {
blog.articlesYear[article.Published.Year()] = make([]emvi.Article, 0)
}
blog.articlesYear[article.Published.Year()] = append(blog.articlesYear[article.Published.Year()], article)
blog.articleMap[article.Id] = article
}
}
func (blog *Blog) refreshIfRequired() {
if blog.nextUpdate.Before(time.Now()) {
blog.cache.Clear()
blog.loadArticles()
}
}

28
content/404.json Normal file
View File

@@ -0,0 +1,28 @@
{
"path": {
"en": "/404"
},
"sitemap": {
"priority": "0.1"
},
"content": {
"content": [
{"ref": "head"},
{"ref": "nav"},
{
"tpl": "text",
"data": {
"size": "h1"
},
"copy": {
"en": {
"headline": "404",
"text": "<p>Whoops — this page does not exist.</p>"
}
}
},
{"ref": "footer"},
{"ref": "end"}
]
}
}

97
content/blog.json Normal file
View File

@@ -0,0 +1,97 @@
{
"path": {
"en": "/blog"
},
"sitemap": {
"priority": "0.9"
},
"content": {
"content": [
{"ref": "head"},
{"ref": "nav"},
{
"tpl": "text",
"data": {
"size": "h1"
},
"copy": {
"en": {
"headline": "Blog"
}
}
},
{
"tpl": "blog_entry",
"copy": {
"en": {
"date": "22. October 2020",
"headline": "My Experience With Vue 3 and Typescript So Far",
"link": "/blog/my-experience-with-vue-3-and-typescript-so-far-bZ1DQzJdjK"
}
}
},
{
"tpl": "blog_entry",
"copy": {
"en": {
"date": "28. August 2020",
"headline": "Testing Database Transactions in Go",
"link": "/blog/testing-database-transactions-in-go-jEaOGXravM"
}
}
},
{
"tpl": "blog_entry",
"copy": {
"en": {
"date": "7. July 2020",
"headline": "Wildcard SSL Certificates on Kubernetes Using ACME DNS",
"link": "/blog/wildcard-ssl-certificates-on-kubernetes-using-acme-dns-0odQzebaLO"
}
}
},
{
"tpl": "blog_entry",
"copy": {
"en": {
"date": "7. July 2020",
"headline": "Golang: Transforming IDs to a User-Friendly Representation in Web Applications",
"link": "/blog/golang-transforming-ids-to-a-user-friendly-representation-in-web-applications-OxdzmRZ1Bl"
}
}
},
{
"tpl": "blog_entry",
"copy": {
"en": {
"date": "3. July 2020",
"headline": "A Quick Update on Pirsch",
"link": "/blog/a-quick-update-on-pirsch-me1VJzz1Xy"
}
}
},
{
"tpl": "blog_entry",
"copy": {
"en": {
"date": "22. June 2020",
"headline": "Server-Side Tracking Without Cookies In Go",
"link": "/blog/server-side-tracking-without-cookies-in-go-OxdzmGZ1Bl"
}
}
},
{
"tpl": "blog_entry",
"copy": {
"en": {
"date": "14. June 2020",
"headline": "How I Built My Website Using Emvi as a Headless CMS",
"link": "/blog/how-i-built-my-website-using-emvi-as-a-headless-cms-RGaqOqK18w"
}
}
},
{"ref": "footer"},
{"ref": "end"}
]
}
}

View File

@@ -0,0 +1,28 @@
{
"path": {
"en": "/blog/testing-database-transactions-in-go-jEaOGXravM"
},
"sitemap": {
"priority": "0.6"
},
"content": {
"content": [
{"ref": "head"},
{"ref": "nav"},
{
"tpl": "text",
"data": {
"size": "h1",
"markdown": "/static/blog/go-db-transactions.md"
},
"copy": {
"en": {
"headline": "Testing Database Transactions in Go"
}
}
},
{"ref": "footer"},
{"ref": "end"}
]
}
}

View File

@@ -0,0 +1,28 @@
{
"path": {
"en": "/blog/how-i-built-my-website-using-emvi-as-a-headless-cms-RGaqOqK18w"
},
"sitemap": {
"priority": "0.6"
},
"content": {
"content": [
{"ref": "head"},
{"ref": "nav"},
{
"tpl": "text",
"data": {
"size": "h1",
"markdown": "/static/blog/emvi-blog.md"
},
"copy": {
"en": {
"headline": "How I Built My Website Using Emvi as a Headless CMS"
}
}
},
{"ref": "footer"},
{"ref": "end"}
]
}
}

View File

@@ -0,0 +1,28 @@
{
"path": {
"en": "/blog/golang-transforming-ids-to-a-user-friendly-representation-in-web-applications-OxdzmRZ1Bl"
},
"sitemap": {
"priority": "0.6"
},
"content": {
"content": [
{"ref": "head"},
{"ref": "nav"},
{
"tpl": "text",
"data": {
"size": "h1",
"markdown": "/static/blog/golang-ids.md"
},
"copy": {
"en": {
"headline": "Golang: Transforming IDs to a User-Friendly Representation in Web Applications"
}
}
},
{"ref": "footer"},
{"ref": "end"}
]
}
}

View File

@@ -0,0 +1,28 @@
{
"path": {
"en": "/blog/a-quick-update-on-pirsch-me1VJzz1Xy"
},
"sitemap": {
"priority": "0.6"
},
"content": {
"content": [
{"ref": "head"},
{"ref": "nav"},
{
"tpl": "text",
"data": {
"size": "h1",
"markdown": "/static/blog/pirsch-update.md"
},
"copy": {
"en": {
"headline": "A Quick Update on Pirsch"
}
}
},
{"ref": "footer"},
{"ref": "end"}
]
}
}

View File

@@ -0,0 +1,28 @@
{
"path": {
"en": "/blog/server-side-tracking-without-cookies-in-go-OxdzmGZ1Bl"
},
"sitemap": {
"priority": "0.6"
},
"content": {
"content": [
{"ref": "head"},
{"ref": "nav"},
{
"tpl": "text",
"data": {
"size": "h1",
"markdown": "/static/blog/server-side-tracking.md"
},
"copy": {
"en": {
"headline": "Server-Side Tracking Without Cookies In Go"
}
}
},
{"ref": "footer"},
{"ref": "end"}
]
}
}

28
content/blog/vue3.json Normal file
View File

@@ -0,0 +1,28 @@
{
"path": {
"en": "/blog/my-experience-with-vue-3-and-typescript-so-far-bZ1DQzJdjK"
},
"sitemap": {
"priority": "0.6"
},
"content": {
"content": [
{"ref": "head"},
{"ref": "nav"},
{
"tpl": "text",
"data": {
"size": "h1",
"markdown": "/static/blog/vue3.md"
},
"copy": {
"en": {
"headline": "My Experience With Vue 3 and Typescript So Far"
}
}
},
{"ref": "footer"},
{"ref": "end"}
]
}
}

View File

@@ -0,0 +1,28 @@
{
"path": {
"en": "/blog/wildcard-ssl-certificates-on-kubernetes-using-acme-dns-0odQzebaLO"
},
"sitemap": {
"priority": "0.6"
},
"content": {
"content": [
{"ref": "head"},
{"ref": "nav"},
{
"tpl": "text",
"data": {
"size": "h1",
"markdown": "/static/blog/wildcard-ssl-certificates.md"
},
"copy": {
"en": {
"headline": "Wildcard SSL Certificates on Kubernetes Using ACME DNS"
}
}
},
{"ref": "footer"},
{"ref": "end"}
]
}
}

43
content/home.json Normal file
View File

@@ -0,0 +1,43 @@
{
"path": {
"en": "/"
},
"sitemap": {
"priority": "1.0"
},
"content": {
"content": [
{"ref": "head"},
{"ref": "nav"},
{
"tpl": "intro"
},
{
"tpl": "text",
"data": {
"size": "h2"
},
"copy": {
"en": {
"headline": "About",
"text": "<p>I started programming in PHP when I was about 11 years old. Later, I studied computer science, started my own company, and worked for a variety of clients developing websites, custom software, and distributed systems. My current project, Pirsch Analytics (a privacy-friendly web analytics tool), sits at ~$100k ARR.</p><p>My skill set includes:</p><ul><li>Go (Golang), JavaScript, PHP, SQL, and a few others I don't use regularly</li><li>HTML, CSS, Sass, VueJs, Node, and other web technologies</li><li>Linux (local and on servers), Docker, Kubernetes, HashiCorp Nomad, Hetzner, and other cloud technologies</li></ul><p>If you're interested, I'm available for hire (use the <strong>Contact me</strong> button above or take a look at our <a href='https://emvi.com?ref=marvinblum.de' target='_blank'>company website</a>).</p>"
}
}
},
{
"tpl": "text",
"data": {
"size": "h2"
},
"copy": {
"en": {
"headline": "Work Experience",
"text": "<ul><li>Pirsch Co-founder of a privacy-preserving, open-source web analytics SaaS</li><li>Emvi Co-founder</li><li>skalar marketing as a full stack developer</li><li>arvato systems GmbH as a Java developer</li><li>Some freelance work</li></ul>"
}
}
},
{"ref": "footer"},
{"ref": "end"}
]
}
}

45
content/legal.json Normal file
View File

@@ -0,0 +1,45 @@
{
"path": {
"en": "/legal"
},
"sitemap": {
"priority": "0.1"
},
"content": {
"content": [
{"ref": "head"},
{"ref": "nav"},
{
"tpl": "text",
"data": {
"size": "h1"
},
"copy": {
"en": {
"headline": "Legal"
}
}
},
{
"tpl": "text",
"copy": {
"en": {
"headline": "According to §5 TMG",
"text": "<p>Marvin Blum<br />Schulbusch 29<br />33378 Rheda-Wiedenbrück, Germany<br />marvin@marvinblum.de</p>"
}
}
},
{
"tpl": "text",
"copy": {
"en": {
"headline": "Cookie Policy",
"text": "<p>This page does not use cookies.</p>"
}
}
},
{"ref": "footer"},
{"ref": "end"}
]
}
}

3
content/refs/end.json Normal file
View File

@@ -0,0 +1,3 @@
{
"tpl": "end"
}

3
content/refs/footer.json Normal file
View File

@@ -0,0 +1,3 @@
{
"tpl": "footer"
}

11
content/refs/head.json Normal file
View File

@@ -0,0 +1,11 @@
{
"tpl": "head",
"copy": {
"en": {
"copyright": "Marvin Blum",
"author": "Marvin Blum",
"title": "Hi, I'm Marvin.",
"meta_description": "Hi, I'm Marvin, co-founder of Pirsch Analytics, software engineer, and open-source enthusiast."
}
}
}

3
content/refs/nav.json Normal file
View File

@@ -0,0 +1,3 @@
{
"tpl": "nav"
}

18
dev.sh
View File

@@ -1,18 +0,0 @@
#!/bin/bash
# This file is for local development only!
# It configures and starts the website for local development.
# The "secret" for the Emvi API can be shared, as it gives access to public content only.
export MB_LOGLEVEL=debug
export MB_ALLOWED_ORIGINS=*
export MB_HOST=localhost:8080
export MB_HOT_RELOAD=true
export MB_EMVI_CLIENT_ID=3fBBn144yvSF9R3dPC8l
export MB_EMVI_CLIENT_SECRET=
export MB_EMVI_ORGA=marvin
export MB_PIRSCH_CLIENT_ID=gEb3pvgxZvZzFRlOTdMgPtyLvNYgeVKe
export MB_PIRSCH_CLIENT_SECRET=E7UqJehmxgnVuw81oq6ZhJAx9vCHqMimCUFfil7UFgbGhgQVVINqU7JqHBgaUvHg
export MB_PIRSCH_HOSTNAME=marvinblum.de
go run main.go

39
dev_config.json Normal file
View File

@@ -0,0 +1,39 @@
{
"dev": true,
"server": {
"host": "localhost",
"port": 8080,
"shutdown_time": 30,
"write_timeout": 5,
"read_timeout": 5,
"hostname": "marvinblum.de",
"secure_cookies": false,
"cookie_domain_name": "marvinblum.de"
},
"content": {
"provider": "git",
"update_seconds": 30,
"repository": "https://github.com/Kugelschieber/marvinblum.git"
},
"cors": {
"origins": "*",
"loglevel": "info"
},
"sass": {
"entrypoint": "main.scss",
"dir": "assets/scss",
"watch": true,
"out": "static/web/main.css",
"out_source_map": "static/web/main.css.map"
},
"js": {
"entrypoint": "main.js",
"dir": "assets/js",
"watch": true,
"out": "static/web/main.min.js"
},
"analytics": {
"provider": "pirsch",
"client_secret": ""
}
}

View File

@@ -1,75 +0,0 @@
version: "3"
services:
traefik:
image: "traefik:v2.3"
container_name: traefik
restart: always
networks:
- traefik-internal
command:
- "--api.dashboard=true"
- "--providers.docker=true"
- "--providers.docker.exposedbydefault=false"
- "--providers.docker.network=marvinblum_traefik-internal"
- "--entrypoints.web.address=:80"
- "--entrypoints.websecure.address=:443"
- "--certificatesresolvers.tls-resolver.acme.httpchallenge=true"
- "--certificatesresolvers.tls-resolver.acme.httpchallenge.entrypoint=web"
- "--certificatesresolvers.tls-resolver.acme.email=marvin@marvinblum.de"
- "--certificatesresolvers.tls-resolver.acme.storage=/letsencrypt/acme.json"
ports:
- "80:80"
- "443:443"
- "8080:8080"
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- /root/marvinblum/letsencrypt:/letsencrypt
labels:
- "traefik.enable=true"
- "traefik.port=9999"
- "traefik.http.routers.traefik.entrypoints=websecure"
- "traefik.http.routers.traefik.rule=Host(`traefik.marvinblum.de`)"
- "traefik.http.routers.traefik.service=api@internal"
- "traefik.http.routers.traefik.tls.certresolver=tls-resolver"
- "traefik.http.routers.traefik.middlewares=traefik-auth"
- "traefik.http.middlewares.traefik-auth.basicauth.users=marvinblum:$$apr1$$u.IJozER$$DoY0zwzgAciDpPs4vZvxY/"
# Global redirection: http to https
- 'traefik.http.routers.http-catchall.rule=HostRegexp(`{host:(www\.)?.+}`)'
- "traefik.http.routers.http-catchall.entrypoints=web"
- "traefik.http.routers.http-catchall.middlewares=wwwtohttps"
# Global redirection: https (www.) to https
- 'traefik.http.routers.wwwsecure-catchall.rule=HostRegexp(`{host:(www\.).+}`)'
- "traefik.http.routers.wwwsecure-catchall.entrypoints=websecure"
- "traefik.http.routers.wwwsecure-catchall.tls=true"
- "traefik.http.routers.wwwsecure-catchall.middlewares=wwwtohttps"
# middleware: http(s)://(www.) to https://
- 'traefik.http.middlewares.wwwtohttps.redirectregex.regex=^https?://(?:www\.)?(.+)'
- 'traefik.http.middlewares.wwwtohttps.redirectregex.replacement=https://$${1}'
- 'traefik.http.middlewares.wwwtohttps.redirectregex.permanent=true'
marvinblum:
image: kugel/marvinblum
container_name: marvinblum
restart: always
depends_on:
- traefik
networks:
- traefik-internal
env_file:
- secrets.env
environment:
MB_EMVI_CLIENT_ID: 3fBBn144yvSF9R3dPC8l
MB_EMVI_ORGA: marvin
MB_PIRSCH_CLIENT_ID: mkiAzI2ZGjGBv8fpwh1A09fCJ8G1YFgx
MB_PIRSCH_HOSTNAME: marvinblum.de
labels:
- "traefik.enable=true"
- "traefik.port=8888"
- "traefik.http.routers.marvinblum.rule=Host(`marvinblum.de`) || Host(`www.marvinblum.de`)"
- "traefik.http.routers.marvinblum.entrypoints=websecure"
- "traefik.http.routers.marvinblum.tls=true"
- "traefik.http.routers.marvinblum.tls.certresolver=tls-resolver"
networks:
traefik-internal:
driver: bridge

18
go.mod
View File

@@ -1,18 +0,0 @@
module github.com/Kugelschieber/marvinblum
go 1.15
require (
github.com/NYTimes/gziphandler v1.1.1
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/emvi/api-go v0.2.2
github.com/emvi/logbuch v1.1.1
github.com/gorilla/mux v1.8.0
github.com/gosimple/slug v1.9.0
github.com/kr/pretty v0.1.0 // indirect
github.com/lib/pq v1.9.0
github.com/pirsch-analytics/pirsch-go-sdk v0.0.0-20201215183417-0e2a519a0dd1
github.com/rs/cors v1.7.0
github.com/stretchr/testify v1.6.1 // indirect
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 // indirect
)

33
go.sum
View File

@@ -1,33 +0,0 @@
github.com/NYTimes/gziphandler v1.1.1/go.mod h1:n/CVRwUEOgIxrgPvAQhUUr9oeUtvrhMomdKFjzJNB0c=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
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/emvi/api-go v0.2.2 h1:NrZNl0o0xAbgfK1dFsRt/BKLesHLVjC4OKXHKTZsIis=
github.com/emvi/api-go v0.2.2/go.mod h1:g9RdDC3s5ebCknAHQQ5PjoM2vRFSyyGoOUX3QkDKU+o=
github.com/emvi/logbuch v1.1.1/go.mod h1:J2Wgbr3BuSc1JO+D2MBVh6q3WPVSK5GzktwWz8pvkKw=
github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
github.com/gosimple/slug v1.9.0 h1:r5vDcYrFz9BmfIAMC829un9hq7hKM4cHUrsv36LbEqs=
github.com/gosimple/slug v1.9.0/go.mod h1:AMZ+sOVe65uByN3kgEyf9WEBKBCSS+dJjMX9x4vDJbg=
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/lib/pq v1.9.0 h1:L8nSXQQzAYByakOFMTwpjRoHsMJklur4Gi59b6VivR8=
github.com/lib/pq v1.9.0/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/pirsch-analytics/pirsch-go-sdk v0.0.0-20201215183417-0e2a519a0dd1 h1:yn6F902YFyEMfXQIARsTXLW42TQKuH+BrKk0MjQu/ps=
github.com/pirsch-analytics/pirsch-go-sdk v0.0.0-20201215183417-0e2a519a0dd1/go.mod h1:PF2vnJw8FYcXQe6OTPQQcGn8l/agkpl7T4YO9d2aPSE=
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/rainycape/unidecode v0.0.0-20150907023854-cb7f23ec59be h1:ta7tUOvsPHVHGom5hKW5VXNc2xZIkfCKP8iaqOyYtUQ=
github.com/rainycape/unidecode v0.0.0-20150907023854-cb7f23ec59be/go.mod h1:MIDFMn7db1kT65GmV94GzpX9Qdi7N/pQlwb+AN8wh+Q=
github.com/rs/cors v1.7.0/go.mod h1:gFx+x8UowdsKA9AchylcLynDq+nNFfI8FkUZdN/jGCU=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

201
main.go
View File

@@ -1,201 +0,0 @@
package main
import (
"context"
"github.com/Kugelschieber/marvinblum/blog"
"github.com/Kugelschieber/marvinblum/tpl"
"github.com/NYTimes/gziphandler"
emvi "github.com/emvi/api-go"
"github.com/emvi/logbuch"
"github.com/gorilla/mux"
_ "github.com/lib/pq"
"github.com/pirsch-analytics/pirsch-go-sdk"
"github.com/rs/cors"
"html/template"
"net/http"
"os"
"os/signal"
"strings"
"time"
)
const (
staticDir = "static"
staticDirPrefix = "/static/"
logTimeFormat = "2006-01-02_15:04:05"
envPrefix = "MB_"
shutdownTimeout = time.Second * 30
)
var (
client *pirsch.Client
tplCache *tpl.Cache
blogInstance *blog.Blog
)
func serveAbout() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
go hit(r)
tplCache.Render(w, "about.html", struct {
Articles []emvi.Article
}{
blogInstance.GetLatestArticles(),
})
}
}
func serveLegal() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
go hit(r)
tplCache.Render(w, "legal.html", nil)
}
}
func serveBlogPage() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
go hit(r)
tplCache.Render(w, "blog.html", struct {
Articles map[int][]emvi.Article
}{
blogInstance.GetArticles(),
})
}
}
func serveBlogArticle() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
slug := strings.Split(vars["slug"], "-")
if len(slug) == 0 {
http.Redirect(w, r, "/notfound", http.StatusFound)
return
}
article := blogInstance.GetArticle(slug[len(slug)-1])
if len(article.Id) == 0 {
http.Redirect(w, r, "/notfound", http.StatusFound)
return
}
// track the hit if the article was found, otherwise we don't care
go hit(r)
tplCache.RenderWithoutCache(w, "article.html", struct {
Title string
Content template.HTML
Published time.Time
}{
article.LatestArticleContent.Title,
template.HTML(article.LatestArticleContent.Content),
article.Published,
})
}
}
func serveTracking() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "https://marvinblum.pirsch.io/", http.StatusFound)
}
}
func serveNotFound() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
tplCache.Render(w, "notfound.html", nil)
}
}
func setupRouter() *mux.Router {
router := mux.NewRouter()
router.PathPrefix(staticDirPrefix).Handler(http.StripPrefix(staticDirPrefix, gziphandler.GzipHandler(http.FileServer(http.Dir(staticDir)))))
router.Handle("/blog/{slug}", serveBlogArticle())
router.Handle("/blog", serveBlogPage())
router.Handle("/legal", serveLegal())
router.Handle("/tracking", serveTracking())
router.Handle("/", serveAbout())
router.NotFoundHandler = serveNotFound()
return router
}
func configureLog() {
logbuch.SetFormatter(logbuch.NewFieldFormatter(logTimeFormat, "\t\t"))
logbuch.Info("Configure logging...")
level := strings.ToLower(os.Getenv("MB_LOGLEVEL"))
if level == "debug" {
logbuch.SetLevel(logbuch.LevelDebug)
} else if level == "info" {
logbuch.SetLevel(logbuch.LevelInfo)
} else {
logbuch.SetLevel(logbuch.LevelWarning)
}
}
func logEnvConfig() {
for _, e := range os.Environ() {
if strings.HasPrefix(e, envPrefix) {
pair := strings.Split(e, "=")
logbuch.Info(pair[0] + "=" + pair[1])
}
}
}
func configureCors(router *mux.Router) http.Handler {
logbuch.Info("Configuring CORS...")
origins := strings.Split(os.Getenv("MB_ALLOWED_ORIGINS"), ",")
c := cors.New(cors.Options{
AllowedOrigins: origins,
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE"},
AllowedHeaders: []string{"*"},
AllowCredentials: true,
Debug: strings.ToLower(os.Getenv("MB_CORS_LOGLEVEL")) == "debug",
})
return c.Handler(router)
}
func start(handler http.Handler) {
logbuch.Info("Starting server...")
var server http.Server
server.Handler = handler
server.Addr = os.Getenv("MB_HOST")
go func() {
sigint := make(chan os.Signal)
signal.Notify(sigint, os.Interrupt)
<-sigint
logbuch.Info("Shutting down server...")
ctx, _ := context.WithTimeout(context.Background(), shutdownTimeout)
if err := server.Shutdown(ctx); err != nil {
logbuch.Fatal("Error shutting down server gracefully", logbuch.Fields{"err": err})
}
}()
if err := server.ListenAndServe(); err != http.ErrServerClosed {
logbuch.Fatal("Error starting server", logbuch.Fields{"err": err})
}
logbuch.Info("Server shut down")
}
func hit(r *http.Request) {
if err := client.Hit(r); err != nil {
logbuch.Warn("Error sending page hit to pirsch", logbuch.Fields{"err": err})
}
}
func main() {
configureLog()
logEnvConfig()
client = pirsch.NewClient(os.Getenv("MB_PIRSCH_CLIENT_ID"),
os.Getenv("MB_PIRSCH_CLIENT_SECRET"),
os.Getenv("MB_PIRSCH_HOSTNAME"),
nil)
tplCache = tpl.NewCache()
blogInstance = blog.NewBlog(tplCache)
router := setupRouter()
corsConfig := configureCors(router)
start(corsConfig)
}

View File

@@ -1,2 +0,0 @@
MB_EMVI_CLIENT_SECRET=
MB_PIRSCH_CLIENT_SECRET=

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.9 KiB

126
static/blog/emvi-blog.md Normal file
View File

@@ -0,0 +1,126 @@
**Published on 14. June 2020**
Welcome to my blog! My name is Marvin, I'm a software engineer and entrepreneur. I write about programming, servers, my work and everything I'm interested in. In my first blog post, I would like to show you how I build my website and the tools I used. You already guessed that from the title I suppose.
You can find the full source code for my website on [GitHub](https://github.com/Kugelschieber/marvinblum). It's MIT licensed, so you can build your own on top of it or just reuse parts of the code.
Goals
-----
So first of all, here are the goals I set when I started:
* the page must be self hosted, I do like to have full control
* it must be fast and have a small footprint
* easy to deploy on cheap hardware
* I don't want to put too much thought and time into styling
* enable me to write articles without having to change the page itself, but don't require me to install (and update!) a full fledged CMS at the same time
The last point is probably the most important one to me. My page won't change very frequently and I don't want to maintain a CMS. I also don't want to write a template for any CMS out there, as that quickly gets out of hand and is not worth the effort. Static HTML won't do it neither, as the blog articles need to be updated as soon as I release a new one or change an existing one.
Lets go through the bullet points and the choices I made. The most interesting part is probably how I build the blog.
Server and Deployment
---------------------
For hosting, I chose [Hetzner](https://www.hetzner.com/) as a cloud provider. The Hetzner cloud offers virtual machines, block storage and networking (subnets, floating IPs, ...). There is an API too, which can be used to automate things.
My website is hosted on the smallest VM (CX11-CEPH) for 2,96 €/month, which is insanely cheap. It provides a single vCPU, 2GB RAM and 20GB storage. Which is sufficient for my simple page. I chose a CEPH machine, as this will store all data on block storage rather than on the machine itself, which decouples it from the hardware. In case of a hardware fault, Hetzner will boot up my server on a different machine and I won't have to do anything. I'm not sure if it assigns a different IP to the server in that case. For the OS I chose Ubuntu as I use that on my computer and I'm familiar with it.
The software running my page is a custom server I build using [Go](https://golang.org/) (golang), as it is an excellent programming language and offers high performance. I will go into more detail about the code in a second.
I use Docker and Compose to deploy my page. Both are well established tools to package and deploy software. These are the only tools I installed on the VM, so I just need to update the systems packages through _apt_ from time to time. Within the `docker-compose.yml` I added [Traefik](https://containo.us/traefik/) as a reverse proxy to schedule a SSL certificate from Letsencrypt.
Deploying my page is now as simple as building and pushing the Docker image, pulling it on the server and restarting the container. Of course you could automate that whole process so that the page updates itself, but again: I won't change the content frequently. So that's good enough.
Structure and Static Content
----------------------------
Lets taking a look at the directory structure:
* blog - code to load and cache blog articles
* static - static files (my picture, stylesheets, ...) and used to cache blog article attachments (more on that later)
* template - contains the HTML files to build the page
* tpl - code to load and build the page from the template files
The root directory contains the `main.go` to wire everything up and set up the router, as well as the `Dockerfile`, `docker-compose.yml` and the Go dependencies (`go.mod`). Everything within the `static` directory is served as static content on the `/static/...` route. Each page has it's own handler function which assembles the HTML using the template files.
Another point worth mentioning is gzip compression. I added the `gziphandler.GzipHandler` on the static route to compress files. The middleware is build by the New York Times and easy to integrate. You can check it out [here](https://github.com/nytimes/gziphandler).
Styling
-------
As I do like to keep things simple, I chose a micro CSS framework so that I don't have to bother with styling too much. Namely [concrete](https://concrete.style/), which I adjusted a bit, to narrow the layout and add a header with my picture. Apart from that I'm quite pleased with the look of it. As a bonus, it also switches to dark mode automatically if you set that in your (OS) preferences.
Templating
----------
To prevent writing the same HTML over and over again I made use of Go's template system. It's simple but powerful enough for most websites and you can extend it using function maps. Here is an example for the blog article page (the one you're looking at right now):
```
{{"{{"}}template "head.html"{{"}}"}}
{{"{{"}}template "menu.html"{{"}}"}}
<section>
<h1>{{"{{"}}.Title{{"}}"}}</h1>
<small>Published on {{"{{"}}format .Published "2. January 2006"{{"}}"}}</small>
{{"{{"}}.Content{{"}}"}}
</section>
{{"{{"}}template "end.html"{{"}}"}}
```
`head`, `menu` and `end` are reused on all pages.
I've added two functions to format dates and build the blog article slug from the title:
```
var funcMap = template.FuncMap{
"slug": slug.Make,
"format": func(t time.Time, layout string) string {
return t.Format(layout)
},
}
```
Blog
----
[Emvi](https://emvi.com?ref=marvinblum.de) offers an API which allows anyone to use it as a headless CMS. The main advantage of it is, that I can use its editor to write my blog articles, upload images/files and don't need to worry about hosting my own CMS. Apart from that I'm using Emvi for note taking and documentation anyways, so I can stay on the same platform.
To read articles, I make use of the [Go client library](https://github.com/emvi/api-go). It isn't complete yet, as Emvi is still in beta, but provides everything required to build a blog. On top of it I build my own type to cache articles and files and sort them into maps, which are rendered on my page later. You could just use the client to do all of that without caching, but to reduce latency and serve articles in case Emvi goes down for some reason, I thought that would be a good idea.
```
type Blog struct {
client *emvi.Client
articles map[string]emvi.Article // id -> article
articlesYear map[int][]emvi.Article // year -> articles
nextUpdate time.Time
}
```
The `client` is initialized with the client ID and secret I generated within Emvi, as well as the name of my organization. These are configured using environment variables, so that I can put them into the `docker-compose.yml`. `nextUpdate` is used to refresh the cache after some time. Articles and files will only be updated in case they have changed since the last time they have been accessed. The article content itself is cached in memory, files are stored on disk.
Articles are put into two different maps. The first one is used to access any article by ID. The ID is read from the slug within the URL to render an article. The second map groups all articles by year, which is used to display them on the blogs overview page.
Note that you need to set an article to "external" within Emvi to allow it to be read through the API. To prevent reading articles which do not belong to my blog, I filtered the results by the tag "blog" and sort them in descending order:
```
results, _, err = blog.client.FindArticles("", &emvi.ArticleFilter{
BaseSearch: emvi.BaseSearch{Offset: offset},
Tags: "blog",
SortPublished: emvi.SortDescending,
})
```
The offset is provided to read articles in a loop, as you can only read a fixed amount of results in one call. Afterwards, the content and files are read and cached for all results. I also added some regex to replace the paths within the content of each article to read images and files from my page instead of accessing Emvi.
And that's pretty much it. If you now visit my website, it will extract the ID from the URL, look up the cache, update it if required and return the result to you.
Conclusion
----------
Personal blogging is something I love about the internet and I now started my own blog. In terms of cost, running this page costs me 2,96 €/month for the server and 5$/month for Emvi (also I'm not paying for it as I'm the co-founder) plus something for the domain, which is insignificant. The solution I chose is fun and easy to implement, but certainly not suitable for non-programmers. I hope I can provide a plug and play solution in the future. It will most likely also use Emvi, as we are turning it into a platform for all sorts of different applications.
In case you would like to send me feedback or have a question, you can contact me by [mail](mailto:marvin@marvinblum.de) or on [Twitter](https://twitter.com/m5blum).

View File

@@ -0,0 +1,43 @@
**Published on 28. August 2020**
You can find a lot of articles about how to prevent deadlocks in Go, but most of them focus on concurrency patterns and synchronization tools like mutexes. While it is important to know some techniques to prevent them, a trap you can stumble across more easily without noticing, are database transaction deadlocks.
A transaction deadlock can occur when you start one or more transactions and run queries outside of transactions while they are still active. If you run too many transactions and queries at the same time, you might run out of database connections in the connection pool. Here is a simple example of that:
```
// We will ignore errors for this example,
// you should always check them of course.
tx, _ := db.Begin()
tx.Exec(`INSERT INTO "foo" ("a", "b") VALUES (4, 2)`)
// ...
db.Query(`SELECT * FROM "foo" WHERE "a" = $1 AND "b" = $2`, 4, 2)
// DEADLOCK
tx.Commit()
```
In this example, we create a new transaction and insert something to the database. Later on, we try to query the same result from the database. That the inserted row has not been committed yet, is not the actual issue, as you would receive no result in that case. The real issue here is that if you run this code concurrently, you might run out of connections. How many connections are opened to the database can be configured. As soon as your code reaches the `db.Query` the last connection might be occupied by the transaction and therefore blocks until a connection is available, which might never happen.
So how do you fix this? First of all, all queries should be run either outside or inside a transaction for a specific part of your code. Even if you run a transaction block and a non-transaction block concurrently, the non-transaction block will not be blocked by the transaction forever (but the non-transaction block might need to wait for the other part to finish). Additionally, you can use a linter or another tool to make sure all queries are run completely inside or outside of transactions.
I usually write integration tests against the database. If you do the same, you can configure the connection pool size to make sure the tests will only use a single connection. That way the tests will run into a deadlock should you have forgotten to use a transaction somewhere. You can easily configure that inside the `TestMain` function for a package.
```
func TestMain(m *testing.M) {
db.SetMaxOpenConns(1) // db is the *sql.DB created somewhere
}
```
I hope this helps you to prevent some nasty deadlock bugs. I found quite a few in a larger code base by limiting the connection pool. In production, you should use multiple connections to speed up things of course.
* * *
Would you like to see more? Read my blog articles on [Emvi](https://emvi.com/blog?ref=marvinblum.de), my project page on [GitHub](https://github.com/Kugelschieber) or send me a [mail](mailto:marvin@marvinblum.de).
This page uses [concrete](https://concrete.style/) for styling. Check it out!
This page does not use cookies. [Legal](https://marvinblum.de/legal)

187
static/blog/golang-ids.md Normal file
View File

@@ -0,0 +1,187 @@
**Published on 7. July 2020**
> _This post was originally published on Medium a while ago. I just added it to my blog for completeness._
![Gopher](/static/blog/hideid/gopher.png)
Most Golang web applications use persistency in some way or another. Usually, the connection between your application and the persistent layer is a technical identification value (ID), a number in most cases. IDs are useful to identify, connect and distinguish data records. Here is a typical example of a database model represented as a struct within Golang applications:
```
type Customer struct {
Id int64 `json:"id"`
Email string `json:"email"`
Username string `json:"username"`
}
```
This struct can easily be used to retrieve and store customers in a database as well as handling customer data within your business logic. What if we add a REST endpoint to show the customers data on a website?
```
router.HandleFunc("/customer", func(w http.ResponseWriter, r* http.Request) {
customer := findCustomer(r)
response, _ := json.Marshal(customer)
w.Write(response)
})
```
Calling this endpoint will return the customer object as JSON within the body:
```
{
"id": 123,
"email": "foo@bar.com",
"username":"foobar"
}
```
As you can see, we received the customer object as expected. There is the email address, the username and the ID, which can be used to perform certain actions, like updating the customers username with a PUT request. We modify our endpoint to do so:
```
router.HandleFunc("/customer", func(w http.ResponseWriter, r* http.Request) {
if r.Method == "GET" {
customer := findCustomer(r)
response, _ := json.Marshal(customer)
w.Write(response)
} else if r.Method == "PUT" {
request := struct {
UserId int64 `json:"id"`
Username string `json:"username"`
}{}
decoder := json.NewDecoder(r.Body)
if err := decoder.Decode(&request); err != nil {
w.WriteHeader(http.StatusBadRequest)
}
if err := updateCustomer(request.UserId, request.Username); err != nil {
w.WriteHeader(http.StatusBadRequest)
}
}
})
```
Our handler accepts two methods now: GET and PUT. GET will return the customer, just like before. PUT reads the body send with the request and passes the parameters to a function updating the customer. As you can see weve used the ID field to identify the customer. This is a nice and simple approach to identify the customer again. So, whats bad about all of this?
First of all: If your IDs are generated auto-incremented numbers, from a security standpoint, its fine to expose IDs to anyone.
On the other side: You probably dont want to show users long boring numbers, that are hard to remember. YouTube for example uses short strings to represent a video: ?v=hY7m5jjJ9mM. This representation does not only look better in the URL, but it also hides technical IDs within their system. Another reason might be, that you dont want to show how many records of an object exist if you use auto-incremented numbers starting at one. There are more reasons to hide technical IDs from your users, like splitting ID ranges, migrations, and so on. But I dont want to go into too much detail here.
Take a look at this nice article by John Topley why you shouldnt expose IDs to your users.
[
Database IDs Have No Place In URIs
https://johntopley.com/2008/08/19/database-ids-have-no-place-in-uris/
](https://johntopley.com/2008/08/19/database-ids-have-no-place-in-uris/)
At this point, it should be clear were looking for a simple and flexible solution for this issue. But how can we transform our IDs to a more user-friendly representation without changing too much of our existing code? The solution to this (as often in Golang): interfaces.
Instead of using int64 as our ID type, we can establish our own type and implement the interfaces needed to transform IDs into a different form. Since this article is about web applications, I assume there is (un-)marshaling to and from JSON, a database and business logic that deals with IDs. The approach Im about to show you works for all kinds of requirements.
First of all, we declare a custom ID type:
```
type ID int64
```
As you can see, this is a simple one liner. And actually just a fancy name for an int64. In our application we want this to be returned as a hash string to the user - representing the same number - but still be a number when dealing with it internally. We have to attach a few methods to make it work.
Lets beginn by satisfying the [Marshaler](https://golang.org/pkg/encoding/json/#Marshaler) and [Unmarshaler](https://golang.org/pkg/encoding/json/#Unmarshaler) interfaces of the standard library first:
```
// MarshalJSON implements the encoding json interface.
func (this ID) MarshalJSON() ([]byte, error) {
if this == 0 {
return json.Marshal(nil)
} result, err := hash.Encode(this) if err != nil {
return nil, err
} return json.Marshal(string(result))
}// UnmarshalJSON implements the encoding json interface.
func (this *ID) UnmarshalJSON(data []byte) error {
// convert null to 0
if strings.TrimSpace(string(data)) == "null" {
*this = 0
return nil
} // remove quotes
if len(data) >= 2 {
data = data[1 : len(data)-1]
} result, err := hash.Decode(data) if err != nil {
return err
} *this = ID(result)
return nil
}// remove quotes
if len(data) >= 2 {
data = data[1 : len(data)-1]
}result, err := hash.Decode(data)if err != nil {
return err
}*this = ID(result)
return nil
}
```
By adding these two methods, our ID type now translates to a hash string when it is marshalled and will be converted back to its integer representation when unmarshalled. Of course, in order for this to work, our hash function must be symmetric. You can use [HashIds](https://github.com/speps/go-hashids) for example.
Within the PUT endpoint, we can now replace the ID in the request object with our custom type:
```
request := struct {
UserId ID `json:"id"`
Username string `json:"username"`
}{}
```
Appart from that, you have to change the parameter in the updateCustomer function or cast it to an int64:
```
updateCustomer(int64(request.UserId), request.Username)
```
All thats left to do now, is implementing the [Scanner](https://golang.org/pkg/database/sql/#Scanner) and [Valuer](https://golang.org/pkg/database/sql/driver/#Valuer) interface to persist our custom ID type within databases:
```
// Scan implements the Scanner interface.
func (this *ID) Scan(value interface{}) error {
if value == nil {
*this = 0
return nil
}
id, ok := value.(int64)
if !ok {
return errors.New("unexpected type")
}
*this = ID(id)
return nil
}
// Value implements the driver Valuer interface.
func (this ID) Value() (driver.Value, error) {
return int64(this), nil
}
```
As you can see this is as simple as converting to int64, because the database driver expects all types to satisfy the [Value](https://golang.org/pkg/database/sql/driver/#Value) interface. We can now change the type of our customer ID to complete our changes:
```
type Customer struct {
Id ID `json:"id"`
Email string `json:"email"`
Username string `json:"username"`
}
```
And thats it! If you want to know more about how to implement this or just use it right away, you can visit the GitHub project, which implements all of the functionality Ive shown above. It uses HashIds, which Ive mentioned earlier, to transform the IDs to a nice and short hash representation.

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

View File

@@ -0,0 +1,25 @@
**Published on 3. July 2020**
Pirsch is a great success so far. At least that is what I would call it from looking at the [traffic](https://marvinblum.de/tracking) on my website and the stars on [GitHub](https://github.com/emvi/pirsch). A few people on [Hacker News](https://news.ycombinator.com/item?id=23668212) pointed out some details you should know in case you're using it.
> You can find a detailed article about server side tracking in Go [here](https://marvinblum.de/blog/server-side-tracking-without-cookies-in-go-OxdzmGZ1Bl).
Legal Stuff
-----------
While it is not possible to tell _who_ visited your website, it's still a good idea to mention tracking on your terms and conditions page (or whatever you call it). I'm not a lawyer, but the GDPR covers tracking methods that don't use cookies. You won't need a cookie banner (consent) as far as I can tell, because Pirsch does not collect personal data, which is one of the main goals I wanted to achieve, but as I said, I'm not a lawyer.
Fingerprinting
--------------
Another point that came up is how Pirsch generates fingerprints. The method is fine, it's just that it had one issue: the algorithm is open source and there is no randomness. Let me explain: if someone gets access to the fingerprints you generated, he could theoretically generate identical fingerprints for visitors to other websites, and therefore tell which websites a user visited by comparing them. I fixed this issue in release v1.1.0 by adding a salt you define. It should be set to something no one can guess and be treated like a password.
Filtering Bots
--------------
Additionally to the change above I extended the bot keyword list. It now includes everything that should not occur inside the User-Agent header. It now contains 365 entries, which should be enough to filter the most unwanted traffic.
Conclusion
----------
Thank you, everyone, for all the helpful feedback! I would love to hear if you're using it and how well it works for you. Just send me a mail using the button (Contact me) at the top.

View File

@@ -0,0 +1,114 @@
**Published on 22. June 2020**
I was looking for an alternative to Google Analytics to track visitors on a website. Analytics (and most of its competitors) provide detailed information and real-time data at the cost of privacy. Google can track you across sites using a bunch of different techniques and through their Chrome browser. Combined, this can be assembled to a detailed profile that can not only be used for tracking, but for marketing too.
I found some (open source) alternatives like [GoatCounter](https://www.goatcounter.com/), which anonymously collect data without invading the user's privacy. But all of the tools I found either rely on cookies, which the visitor needs to opt-in for or cost money for the server-side only tracking solution. While I'm willing to pay for good software, especially when it comes from a small team or just one developer, I was wondering if I could build something that I can integrate into my Go applications.
This post is about my solution called _Pirsch_, an open-source Go library that can be integrated into your applications to tracks visitors on the server-side, without setting cookies. I will write about the technique used and what the advantages and disadvantages are.
> TL;DR
> -----
>
> Invading the privacy of your website visitors is evil. Pirsch is a privacy-focused tracking library for Go that can be integrated into your applications. Check it out on [GitHub](https://github.com/emvi/pirsch)!
>
> You can find a live demo [here](https://marvinblum.de/tracking) on my website and the whole thing on GitHub as a [sample application](https://github.com/Kugelschieber/marvinblum).
What's With the Name?
---------------------
Pirsch is German and refers to a special kind of hunt: _the hunter carefully and quietly enters the area to be hunted, he stalks against the wind in order to get as close as possible to the prey without being noticed._
I found this quite fitting for a tracking library that cannot be blocked by the visitor. Even though it sounds a little sneaky. Here is the Gopher for it created by [Daniel](https://github.com/Motorschpocht).
![Gopher](/static/blog/servertracking/gopher.svg)
How Does It Work?
-----------------
I will go over each step in more detail later, but here is a high-level overview of how Pirsch tracks visitors.
![Network Flow](/static/blog/servertracking/network.svg)
Once someone visits your website, the HTTP handler calls Pirsch to store a new hit and goes on with whatever it intends to do. Pirsch will do its best to filter out bots, calculate a fingerprint, and stores the page hit. You can analyze the data and generate statistics from it later.
The process must be triggered manually by calling the `Hit` method and passing the `http.Request`. This enables you to decide which traffic is tracked and which is not. I'm usually just interested in page visits, so I'll add a call to Pirsch inside my page handlers. Resources are served on a different endpoint and won't be tracked that way.
### Fingerprinting
Fingerprinting is a technique to identify individual devices by combing some parameters. The parameters are typically things like the graphics card ID and other variables that are unique to a device. As we are interested in tracking website traffic, we won't have access to this kind of information. Instead, we can make use of the IP and HTTP protocol. Here are the parameters used by Pirsch to generate a fingerprint:
* the IP is the most obvious choice. It might change, as ISPs only have a limited pool of IPs available to them, but that shouldn't happen too frequently
* the User-Agent HTTP header contains information about the browser and device used by the visitor. It might not be filled though, but it usually is
To generate a unique fingerprint from this information, we can calculate a hash. Pirsch will add the current day to prevent tracking users across days and calculate an MD5 hash. I found this to be the fastest algorithm available in the Go standard library. This will also make the visitor anonymous at the same time as we do not store IPs or other identifiable information.
This method is called _passive_ fingerprinting, as we're only using data that we have access to anyways. The alternative is called _active_ fingerprinting, which makes use of JavaScript to collect additional information on the client-side and sends it to the backend. But as we're trying to build a privacy-focus tracking solution, passive fingerprinting is the way to go.
We will use the fingerprint later to count unique visitors.
### Filtering Bots
Filtering out bot traffic is hard, as there is no complete list of all bots and they won't send any special kind of information, like an _I'm a bot_ header. All we can do is to process the IP and the User-Agent header send and make some assumptions. Pirsch will look for terms often used by bots within the User-Agent header. Should it contain words like _bot_ or _crawler_ or an URL, the hit will be dropped. Filtering for IP ranges is not implemented (yet), but you can filter hits that are coming from popular IP ranges, like AWS.
### Hits
Each page request is stored as a _Hit_. A hit is a data point that can later be analyzed. Here is the definition of a hit:
```
// I removed some details to make it more readable for this blog post
type Hit struct {
Fingerprint string
Path string
URL string
Language string
UserAgent string
Ref string
Time time.Time
}
```
A hit contains the full request URL, the path extracted from the URL, the language, user-agent and reference passed by the client in their corresponding headers and the time the request was made.
### Analyze
Pirsch provides an _Analyzer_ that can be used to extract some basic statistics:
* total visitor count
* visitors by page on each day
* visitors by hour on each day
* languages used by visitors
* active visitors within a time frame
Most of these functions accept a filter to specify a time frame. The data can then be plotted like on my [tracking page](https://marvinblum.de/tracking).
![Charts](/static/blog/servertracking/charts.png)
To reduce the amount of data that needs to be processed the hits get aggregated each night and hits are cleaned up afterward.
Postgres is used as the storage backend at the moment as it is a fantastic open-source database and provides all features needed to read these statistics easily. You can extract more statistics, like the visitor page flow, from the database if you care.
### Tracking From JavaScript
While it is simple to integrate tracking into your backend, you might also want to have some way to track from your frontend as well, in case you're running a single page application for example. In that case, you can add an endpoint to your router and call it using Ajax. The path can manually be overwritten in Pirsch by calling _HitPage_ instead of _Hit_.
How Well Does It Work?
----------------------
As far as I can tell right now, it works pretty well. I still need to collect more sample data and a way to compare it to something like Google Analytics in order to make a more precise statement. Keep in mind that while Analytics and other tools provide more detailed statistics, like the location, age, gender, and so on, they can be blocked by tools like uBlock. Pirsch cannot be blocked by the client and therefore it can track visitors you won't even notice with a client-side solution.
Bots are probably the weak spot of Pirsch right now, as filtering for them requires adding a whole bunch of keywords to the filter list.
Another disadvantage of server-side tracking depending on your use-case might be that you cannot track your marketing campaigns. In case you're using Adsense for marketing, you can track how well your campaigns perform through Analytics. This won't work with Pirsch.
Conclusion
----------
Tracking on the server-side isn't too hard to archive and all in all, I think it's worth the effort. I hope you gained some insight into how you can use fingerprinting and Pirsch to your advantage. I will continue improving Pirsch and implement it into [Emvi](https://emvi.com?ref=marvinblum.de) and compare the output to Analytics. I might also add a user interface for Pirsch so that you can host it without integrating it into your application and without the need to generate the charts yourself. In case you would like to send me feedback, have a question, or would like to contribute you can contact me.

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 44 KiB

View File

@@ -0,0 +1,102 @@
<svg width="1244.51123" height="579.12561" xmlns="http://www.w3.org/2000/svg">
<desc/>
<g>
<title>background</title>
<rect fill="#ffffff" id="canvas_background" height="581.12561" width="1246.51123" y="-1" x="-1"/>
</g>
<g>
<title>Layer 1</title>
<path id="svg_1" fill="#000000" d="m1146.6,267.9c24.2,10.9 48,23.9 66.6,42.9c18.5,19 31.3,44.9 29.8,71.4c-1.3,23.7 -13.7,45.6 -29.7,63.1c-36.9,40.4 -91,60.2 -144.1,73.7c-114.4,29.1 -233,35.7 -350.9,42.3c-59.9,3.4 -119.8,6.7 -179.8,8.6c-53.6,1.7 -108,2.1 -159.9,-11.5c-21.9,-5.7 -44.8,-15.3 -56.1,-34.9c-6.3,-11 -8.2,-24.1 -8.3,-36.8c-0.6,-75.8 56.7,-140.4 120.5,-181.5c78.7,-50.7 172.5,-77.7 266.2,-76.7c-80.1,9.6 -161.5,23 -233.3,59.9c-71.8,37 -133.6,101.3 -148.9,180.6c-3.3,16.7 -4.2,35 3.9,49.9c9.2,17 28,26.2 46.3,32.3c53.8,17.8 111.7,17 168.4,15.1c50.6,-1.7 101.1,-4.1 151.7,-6.6c120.1,-6.1 240.8,-12.9 358.4,-38.4c64.3,-14 131.7,-36.4 171.9,-88.5c10,-13 18.1,-27.8 20.5,-44c3.9,-26.3 -7.9,-53.1 -26.1,-72.5c-18.2,-19.4 -42.1,-32.5 -66.3,-43.5c-52.2,-23.9 -107.8,-40.1 -164.6,-48.1c56.4,5.3 112,20 163.8,43.2"/>
<path id="svg_2" fill="#000000" d="m1131.9,355.4c-1.8,0.7 -4,0.7 -5.9,0c1.2,-2.3 3.8,-3.7 6.4,-3.6c0.9,1 0.6,2.8 -0.4,3.6"/>
<path id="svg_3" fill="#000000" d="m894,440.4c-0.4,-2 0.4,-4.7 2.4,-4.4c1.9,2.1 1.9,5.8 -0.2,7.9c-1.3,-0.6 -2.1,-2.1 -2.1,-3.5"/>
<path id="svg_4" fill="#000000" d="m247.4,430.7c2.7,0.5 4.7,3.3 4.2,5.9c-3.3,-1.6 -7.1,-2.3 -10.8,-1.9c0.4,-2.8 3.9,-4.8 6.5,-3.6"/>
<path id="svg_5" fill="#000000" d="m1130.6,360.6c0.9,3.8 0.1,8.1 -2.2,11.3c-0.6,0.8 -1.3,1.6 -2.2,1.9c0.1,-4.7 1.6,-9.4 4.4,-13.2"/>
<path id="svg_6" fill="#000000" d="m43.6,277.5c1.3,3 0.4,6.8 -2.1,8.9c-2.4,-5.2 -2.4,-11.4 -0.1,-16.7c1.7,2.2 2.6,4.9 2.6,7.7"/>
<path id="svg_7" fill="#000000" d="m683,340.3c-0.5,-3.3 0.4,-6.9 2.6,-9.5c2.1,5.2 1.4,11.5 -1.9,16.1c-1.4,-1.8 -1.9,-4.4 -1.2,-6.7"/>
<path id="svg_8" fill="#000000" d="m1036.7,370.7c-1.3,-2.5 0.6,-5.6 3.1,-6.9c2.4,-1.3 5.3,-1.5 8,-2.2c-0.4,3 -1.5,5.9 -3.3,8.3c-0.7,-0.7 -1.4,-1.4 -2.1,-2.1c-1.6,1.5 -3.6,2.5 -5.7,2.9"/>
<path id="svg_9" fill="#000000" d="m825.2,319.8c0.4,-5.1 1.1,-10.2 2,-15.2c0.2,-1.1 1.3,-1.9 2.4,-1.7c0.8,5.2 0.2,10.6 -1.7,15.5c-0.5,1.2 -2,2.4 -2.7,1.4"/>
<path id="svg_10" fill="#000000" d="m895.1,453.9c1.3,5.9 0.4,12.3 -2.3,17.7c-1,-6.7 -1.3,-13.5 -0.8,-20.3c1.3,-0.9 2.8,1 3.1,2.6"/>
<path id="svg_11" fill="#000000" d="m894.3,214.4c8,0.3 15.9,0.7 23.9,1c-7.9,1.4 -16.1,1 -23.9,-1"/>
<path id="svg_12" fill="#000000" d="m1112.8,366.3c0.9,3.5 0.1,7.4 -2.1,10.3c-2.1,-4.3 -2.6,-9.3 -1.4,-14c4.1,-2.4 9.6,-2.2 13.5,0.5c-3.3,1 -6.7,2.1 -10,3.2"/>
<path id="svg_13" fill="#000000" d="m1117,296.2c-0.8,4.7 -1.6,9.3 -2.3,14c-0.2,0.7 -0.3,1.4 -0.7,2c-0.7,0.8 -2,1.1 -3,0.5c0.8,-7.3 1.8,-14.6 3,-21.8c2.4,-0.8 3.4,2.9 3,5.3"/>
<path id="svg_14" fill="#000000" d="m1112,329.5c2.3,0.2 4.7,-0.4 6.6,-1.8c0.5,2.4 -0.9,5 -3.2,6c-2.2,1 -5.1,0.1 -6.5,-2c-1.5,-2.3 -1.1,-5.3 -0.5,-8c0.4,-2.5 2.3,-5.7 4.5,-4.6c-1,3.3 -1.4,6.9 -0.9,10.4"/>
<path id="svg_15" fill="#000000" d="m1068.9,376c-3,-2.3 -2.8,-7.4 -0.2,-10.2c2.6,-2.8 6.8,-3.6 10.6,-3.2c3.1,0.4 6.5,1.8 7.8,4.6c-2.4,1.4 -5.3,0.5 -8,-0.2c-2.6,-0.6 -6,-0.6 -7.4,1.7c-1.5,2.3 -0.4,6 -2.8,7.3"/>
<path id="svg_16" fill="#000000" d="m1132.6,372.3c-0.3,-3.5 1.1,-7.1 3.8,-9.1c2.8,-2.1 7,-2.2 9.6,0.1c2.6,2.3 2.9,6.8 0.3,9.1c-0.2,0.2 -0.5,0.4 -0.8,0.5c-1.4,0.3 -2.1,-1.7 -1.8,-3c0.4,-1.4 1,-3 0.1,-4.1c-4.4,0.5 -8.6,2.9 -11.2,6.5"/>
<path id="svg_17" fill="#000000" d="m99.9,278c-2,-3.1 -1.3,-7.5 0.9,-10.5c2.2,-3 5.6,-4.9 9.2,-6c1.3,-0.4 2.9,-0.7 4,0.1c1.2,0.8 1,3.1 -0.4,3.1c-6.9,0.3 -12.4,8 -10.5,14.6c-1,0.9 -2.5,-0.1 -3.2,-1.3"/>
<path id="svg_18" fill="#000000" d="m1030.6,375.5c-1.7,-3.3 -2.3,-7.1 -1.9,-10.8c-4,1.6 -7.3,4.8 -8.9,8.8c-1.6,-3.3 -0.8,-7.6 1.9,-10.1c2.7,-2.4 7,-2.9 10.1,-1.1c0.7,4.4 0.3,9 -1.2,13.2"/>
<path id="svg_19" fill="#000000" d="m58.1,270.6c-1.5,1.5 -3.3,2.6 -5.3,3.2c3.8,0.8 6.4,5.8 4.2,9.2c-2.2,3.3 -8.5,2.2 -9,-1.8c2.4,-0.2 4.7,-0.5 7,-0.8c-3.1,-2 -5.5,-5.3 -6.4,-8.9c3,-1.6 6.9,-3.1 9.5,-0.9"/>
<path id="svg_20" fill="#000000" d="m755.1,318.9c1.7,0 3.8,0.8 4,2.5c-4.9,-0.4 -9.9,1.9 -12.8,5.8c-2.9,4 -3.7,9.4 -1.9,14c-1.6,1.4 -3.9,-0.8 -4.5,-2.9c-2.5,-9.1 5.6,-19.6 15.2,-19.4"/>
<path id="svg_21" fill="#000000" d="m731.4,160.6c2.2,4.5 3.2,9.6 2.9,14.6c-4,-2 -6,-7.1 -4.4,-11.2c-4.5,2.7 -7.2,8 -6.7,13.2c-0.8,1 -2.4,0.5 -3,-0.5c-0.6,-1.1 -0.5,-2.4 -0.3,-3.6c0.4,-2.5 0.7,-5.1 1.1,-7.6c2.9,-2.6 6.5,-4.3 10.4,-4.9"/>
<path id="svg_22" fill="#000000" d="m815,326.4c2,1.1 2.4,4 1.5,6c-0.9,2.1 -2.9,3.6 -4.8,4.8c-2.5,1.4 -5.5,2.5 -8.2,1.5c-2.3,-2.3 -2.2,-6.4 -0.2,-9c1.9,-2.7 5.3,-4 8.6,-4c1.1,0 2.2,0.1 3.1,0.7m-10.3,9.2c2.2,0.3 4.6,-0.3 6.4,-1.7c1.6,-1.2 2.8,-2.9 3.8,-4.6c-4.3,-1.5 -9.5,1.8 -10.2,6.3"/>
<path id="svg_23" fill="#000000" d="m382.7,477.8c0.8,-1.3 1.8,-2.6 3.1,-3.4c3.6,-2.2 8.8,0.8 9.6,5c0.8,4.1 -1.9,8.4 -5.6,10.4c-1.4,0.8 -3.1,1.4 -4.7,1.1c-2.6,-0.5 -4.4,-3.1 -4.6,-5.7c-0.2,-2.6 0.9,-5.2 2.2,-7.4m1.2,8.8c4.3,0.2 8,-4.8 6.7,-8.9c-3.5,0.8 -6.3,4.1 -6.4,7.7"/>
<path id="svg_24" fill="#000000" d="m362.4,479.8c3.3,-3 7.5,-4.8 11.9,-5.2c3.6,5 2.5,11.8 1.2,17.9c-2.9,0.1 -4,-3.9 -3.4,-6.7c0.5,-2.9 1.4,-6.3 -0.8,-8.3c-2.5,3.5 -4.9,6.9 -7.4,10.3c-0.7,1 -1.8,2.1 -3,1.7c-1.2,-0.5 -1,-2.4 -0.6,-3.7c0.7,-2 1.4,-4 2.1,-6"/>
<path id="svg_25" fill="#000000" d="m690.7,338c-0.1,-7.7 0.5,-15.4 1.8,-23c0.7,-3.4 1.5,-7 3.8,-9.6c2,1.8 1.2,5 0.5,7.7c-2.9,10.6 -3.5,21.8 -1.8,32.7c-3.2,-0.4 -4.2,-4.6 -4.3,-7.8"/>
<path id="svg_26" fill="#000000" d="m701.3,179.3c-1.7,1 -2.8,-2 -2.5,-3.9c1.8,-11.9 3.5,-23.7 5.3,-35.5c0.8,-0.7 2.2,-0.1 2.7,0.9c0.4,1 0.3,2.1 0.1,3.2c-1.9,11.8 -3.7,23.6 -5.6,35.3"/>
<path id="svg_27" fill="#000000" d="m711.5,160.9c2.4,-0.9 5.2,1.3 5.9,3.8c0.6,2.6 -0.3,5.3 -1.3,7.7c-1.6,3.7 -5,7.8 -8.6,6.3c-2.6,-5 -2,-11.4 1.5,-15.8c0.7,-0.9 1.4,-1.7 2.5,-2m-2.3,15.2c3.5,-3.1 5.8,-7.5 6.5,-12.1c-5.4,0.6 -9,7.3 -6.5,12.1"/>
<path id="svg_28" fill="#000000" d="m859.9,427.4c1.1,-1.4 2.7,1 2.8,2.7c0.3,12.2 0.5,24.5 0.8,36.7c0,2.2 0.1,4.5 -0.9,6.5c-2.6,-3.9 -2.7,-8.8 -2.7,-13.5c0,-10.8 0,-21.6 0,-32.4"/>
<path id="svg_29" fill="#000000" d="m1031.6,326.3c-2.3,2 -6.3,0.3 -7.7,-2.5c-1.3,-2.7 -0.9,-6 -0.4,-9.1c0.7,-4.7 1.5,-9.5 2.2,-14.3c0.6,-4.1 1.3,-8.4 3.6,-11.8c1.7,1.5 1.5,4.2 1.1,6.5c-1.4,9.1 -2.7,18.1 -4.1,27.2c2.3,0.1 4.5,1.8 5.3,4"/>
<path id="svg_30" fill="#000000" d="m850.2,335.4c-2.7,-1.5 -5.5,-3.1 -7.1,-5.7c-1.6,-2.6 -1.5,-6.6 1.1,-8.3c1.5,-1 3.5,-0.9 5.4,-0.9c0.8,0.1 1.7,0.1 2.3,0.7c1.3,1.1 0.2,3.5 -1.4,4c-1.7,0.5 -3.4,-0.3 -5,-0.9c-1.2,1.7 0.5,4.1 2.3,5.2c1.9,1.1 4.2,1.9 5,3.8c1.1,2.5 -1,5.4 -3.5,6.2c-2.6,0.9 -5.3,0.3 -8,-0.3c-2,-0.5 -4.5,-1.6 -4.5,-3.6c4.5,-0.1 8.9,-0.2 13.4,-0.2"/>
<path id="svg_31" fill="#000000" d="m792.5,170.8c-1.7,-3.9 -2.8,-8.2 -3.4,-12.4c1.2,0.2 2.4,0.4 3.6,0.6c2.9,-8.9 12.5,-15.2 21.8,-14.2c0.2,1.1 0.3,2.2 0.5,3.3c-11.4,-0.6 -21.8,11 -20.1,22.3c-0.7,0.5 -1.6,0.6 -2.4,0.4"/>
<path id="svg_32" fill="#000000" d="m825.1,324.9c0.7,3.3 1.4,6.6 2.1,10c2.3,0.3 4.6,-0.5 6.3,-2c0.9,2.6 -1.4,5.6 -4.2,5.9c-2.8,0.2 -5.4,-1.9 -6.4,-4.5c-0.9,-2.6 -0.3,-5.6 1.1,-8.1c-2.6,-0.2 -5.1,-0.5 -7.6,-0.8c2,-2.9 5.6,-4.6 9.1,-4.5c4.1,-1 8.7,-0.3 12.2,2.1c-3.8,1.9 -8.3,2.6 -12.6,1.9"/>
<path id="svg_33" fill="#000000" d="m705.2,330.4c-1.7,0.8 -3.9,0.6 -5.4,-0.7c4.2,-0.4 6.6,-5.2 7.2,-9.4c0.7,-4.2 0.7,-8.9 3.2,-12.3c1.3,5.3 1.5,10.9 0.5,16.3c1.4,0.9 3,1.5 4.7,1.7c0.9,0.7 0.1,2.2 -1,2.6c-1.1,0.4 -2.3,0.2 -3.4,0.6c-5.6,1.9 -0.3,13.6 -6,15.1c-1,-4.6 -0.9,-9.4 0.2,-13.9"/>
<path id="svg_34" fill="#000000" d="m866.5,106.3c-2.8,-0.6 -5.7,-1.3 -8.6,-1.9c-2.5,-0.6 -5.3,-1.4 -6.2,-3.7c-1.4,-3.4 2.2,-6.6 5.3,-8.5c5.3,-3.1 10.6,-6.1 16.1,-8.9c2,1.1 -0.1,4 -2,5c-6,3.3 -11.9,6.6 -17.9,9.9c3.5,1.4 7,2.8 10.4,4.2c1.7,0.7 3.8,2.3 2.9,3.9"/>
<path id="svg_35" fill="#000000" d="m687.7,167.3c-0.9,5 -1.8,10.1 -2.7,15.2c-0.9,0.1 -1.9,0.3 -2.8,0.5c-1.3,-7.3 2.6,-14.3 3.5,-21.6c0.9,-7.4 -1.2,-15.3 1.5,-22.2c2.9,5.2 3.6,11.5 2.1,17.2c-0.4,1.7 1.1,3.4 2.8,3.8c1.7,0.4 3.4,-0.2 5.1,-0.7c0.7,0.7 0.9,1.8 0.5,2.8c-1.6,2.9 -7,0 -9.1,2.6c-0.5,0.6 -0.7,1.5 -0.9,2.4"/>
<path id="svg_36" fill="#000000" d="m128.8,133.5c-4.4,3.5 -8.7,7.1 -13.1,10.7c6.9,-0.3 14,2.2 19.1,6.8c-6.2,-0.9 -12.4,-1.8 -18.5,-2.7c-1.8,-0.3 -3.9,-0.8 -4.4,-2.5c-0.5,-1.7 0.8,-3.3 2,-4.4c5,-4.8 10.3,-9.2 16,-13c2,0.7 0.5,3.7 -1.1,5.1"/>
<path id="svg_37" fill="#000000" d="m933.3,329.7c-0.7,-0.5 -1.3,-1.4 -1.4,-2.3c6,-4.4 11.9,-8.8 17.8,-13.1c-5,-1.8 -10,-3.5 -15,-5.2c-0.9,-0.4 -1.9,-0.8 -2.4,-1.6c-0.5,-0.9 0,-2.3 1,-2.2c4.2,1 8.4,2 12.7,3c2.3,0.6 5.1,1.4 6,3.7c1.2,2.9 -1.3,5.9 -3.6,7.9c-4.6,3.8 -9.7,7.1 -15.1,9.8"/>
<path id="svg_38" fill="#000000" d="m730.8,332.3c-1.4,1.9 -2.8,3.8 -4.2,5.7c3.9,-1.7 8.3,-2.2 12.5,-1.3c-5.3,3.6 -11.5,5.8 -17.8,6.4c-2.9,0.2 -6.6,-0.5 -7.2,-3.4c-0.3,-1.3 0.3,-2.6 0.8,-3.8c1,-2.2 2.1,-4.5 3.9,-6.2c1.8,-1.6 4.6,-2.4 6.8,-1.2c1.2,0.7 2.6,2.1 3.8,1.3c0.4,0.8 0.9,1.7 1.4,2.5m-9.1,1c-1.4,0.8 -3,1.7 -3.3,3.3c-0.4,1.6 1.8,3.3 2.8,2.1c2.1,-2.1 4.1,-4.2 6.2,-6.2c-2,0.1 -4,0.2 -6,0.3"/>
<path id="svg_39" fill="#000000" d="m126.3,553.4c0.9,7.5 1.9,15.1 2.8,22.6c-3.3,-2.2 -5.3,-6.1 -5.3,-10.1c-1.1,-1.1 -2.2,1.3 -2.7,2.7c-1.3,3.5 -6.9,3 -9,-0.1c-2.1,-3.1 -1.7,-7.1 -1.2,-10.8c0.4,-2.4 2,-5.5 4.1,-4.5c-0.3,4.9 -0.2,9.9 0.3,14.8c3.2,-1.9 5.6,-4.9 6.8,-8.4c0.9,-2.5 1.6,-5.7 4.2,-6.2"/>
<path id="svg_40" fill="#000000" d="m598.8,109.6c2,-0.9 4.4,-2.4 4.1,-4.6c-5.7,-1.2 -11.1,-4.1 -15.4,-8.2c5.2,0 10.5,1.4 15,3.9c1.2,0.6 2.3,1.3 3,2.4c1.5,2.3 0.6,5.4 -1.2,7.4c-1.8,2 -4.4,3.1 -6.9,4.1c-3.6,1.5 -7.2,3 -10.8,4.5c-2.5,1 -6.3,1.3 -6.8,-1.3c6.3,-2.7 12.6,-5.5 19,-8.2"/>
<path id="svg_41" fill="#000000" d="m1088.5,396.5c-3.3,-1.2 -2.4,-6.1 -1.2,-9.4c2.1,-6.2 3,-12.8 2.5,-19.3c-0.2,-1.9 1.2,-4.8 2.6,-3.5c-1.5,1.9 -0.1,5.3 2.3,5.7c0.6,-3.1 5,-4.6 7.5,-2.7c2.6,1.9 2.8,6.1 0.7,8.4c-2.2,2.4 -6,2.8 -8.8,1.2c-1.8,6.5 -3.7,13.1 -5.6,19.6m9.9,-22.7c0.9,0.3 1.8,0.1 2.5,-0.4c-0.3,-1.7 -1.1,-3.4 -2.5,-4.6c-1.2,1.3 -2.1,2.9 -2.5,4.6c0.7,0.4 1.7,0.6 2.5,0.4"/>
<path id="svg_42" fill="#000000" d="m202.9,137.3c-7.2,3.6 -14.8,6.3 -22.7,8c-1.6,-1.3 0.7,-3.6 2.6,-4.3c6.3,-2.3 12.6,-4.6 18.9,-6.9c-6.8,-1.6 -13.7,-3.3 -20.5,-5c-1.7,-0.5 -3.9,-2 -2.9,-3.4c8.5,0.2 16.9,2.1 24.7,5.5c0,2 -0.1,4.1 -0.1,6.1"/>
<path id="svg_43" fill="#000000" d="m1157.7,342.4c1.8,4.8 1.8,10.2 -0.1,14.9c4.6,1.1 9.4,1.7 14.2,1.9c0.5,1.3 -0.9,2.7 -2.2,2.9c-1.4,0.2 -2.8,-0.4 -4.1,-0.7c-3,-0.7 -6.5,0.1 -8.6,2.4c-2.1,2.3 -2.4,6.1 -0.4,8.5c1.7,0 3.4,-1.1 4,-2.6c1.2,2.1 0.4,5.1 -1.8,6.3c-2.1,1.2 -5.1,0.3 -6.3,-1.8c-2,-3.6 1.4,-8.1 0.5,-12.1c-1.9,0.4 -3.9,-1.1 -4,-3c3.4,1 6,-3.2 6.4,-6.7c0.4,-3.4 0,-7.5 2.4,-10"/>
<path id="svg_44" fill="#000000" d="m525.7,468.3c0,-3.1 2.9,-5.2 5.6,-6.8c4.6,-2.6 9.4,-4.9 14.3,-6.9c2.1,-0.8 5.5,-0.8 5.6,1.4c-6.3,3 -12.6,5.9 -19,8.8c-2.5,1.2 -2,5.4 0.2,7.1c2.2,1.8 5.2,2.1 8,2.7c2.8,0.6 5.8,2 6.5,4.8c-6.2,0.5 -12.5,-1.4 -17.4,-5.1c-2,-1.5 -3.9,-3.5 -3.8,-6"/>
<path id="svg_45" fill="#000000" d="m979.1,443.6c-5,2.2 -10,4.5 -15.1,6.8c-1.9,0.8 -3.9,1.8 -4.9,3.6c-1.1,1.8 -0.4,4.7 1.6,5.1c4.5,1 8.9,1.9 13.4,2.9c2,0.4 4.6,1.7 3.9,3.7c-6.8,0.5 -13.8,-0.9 -19.9,-4c-1.5,-0.8 -3.1,-1.8 -3.7,-3.4c-1.4,-3.4 1.9,-6.7 5,-8.7c6,-3.7 12.3,-6.8 19,-9.1c0.2,1 0.5,2 0.7,3.1"/>
<path id="svg_46" fill="#000000" d="m193.6,483.7c5.6,2.1 11.1,4.2 16.7,6.3c2.4,0.9 5.4,3.1 4,5.3c-5.9,-1.5 -11.7,-3.2 -17.4,-5.2c-2.5,-0.8 -5.2,-2 -6,-4.5c-1.2,-3.5 2.1,-6.7 5.2,-8.7c4.3,-2.9 8.8,-5.5 13.5,-7.8c1.2,-0.6 2.5,-1.1 3.8,-0.8c1.3,0.3 2.3,2 1.4,3c-7.7,3 -14.8,7.2 -21.2,12.4"/>
<path id="svg_47" fill="#000000" d="m791.4,470.7c-2.3,1.5 -4.6,3 -7.2,3.6c-2.7,0.6 -5.7,0 -7.4,-2.1c-1.1,-1.4 -1.4,-3.2 -1.6,-4.9c-0.2,-4.1 1.1,-8.6 4.6,-10.6c3.6,-2.1 9.2,0.2 9.1,4.3c-2.4,-1.7 -6.1,-1.8 -8.3,0.1c-2.3,2 -2.9,5.8 -0.9,8.1c1.9,2.3 6.1,2.3 7.7,-0.2c2.8,-4.1 5.6,-8.1 8.4,-12.2c-1,6.7 -0.7,13.6 1,20.2c-3,-0.5 -5.4,-3.3 -5.4,-6.3"/>
<path id="svg_48" fill="#000000" d="m123,140.2c18.4,-1.6 36.8,-4 55.1,-6.9c3.5,-0.6 7.9,-1 10.1,1.9c-20,4.5 -40.4,7.1 -60.9,7.9c-2.1,0.1 -5,-1 -4.3,-2.9"/>
<path id="svg_49" fill="#000000" d="m936,314.4c2.4,-0.2 5.6,0.3 5.9,2.6c-20.3,2.6 -40.6,5.2 -60.8,7.8c-1,0.2 -2.1,0.3 -3,-0.2c-0.9,-0.5 -1.4,-1.9 -0.6,-2.6c0.3,-0.3 0.8,-0.4 1.3,-0.4c18.9,-3.2 38,-5.6 57.2,-7.2"/>
<path id="svg_50" fill="#000000" d="m106.5,570.5c-1.1,3.5 -4.1,6.4 -7.6,7.4c-2.3,0.7 -4.8,0.6 -7.1,-0.3c-5.4,-2 -8.5,-8.8 -6.5,-14.2c1.9,-5.5 8.6,-8.7 14.1,-6.9c5.5,1.9 8.8,8.5 7.1,14m-13.7,3.5c0.6,0.4 1.3,0.8 2.1,0.8c1.2,0.1 2.2,-0.6 3.2,-1.3c2.1,-1.5 4.2,-3.5 4.6,-6.1c0.8,-5 -6,-8.9 -10.2,-6.2c-4.2,2.8 -3.9,9.9 0.3,12.8"/>
<path id="svg_51" fill="#000000" d="m864.9,450.8c3.3,0.4 6.6,0.8 9.8,1.1c1.2,0.2 2.7,0.2 3.4,-0.7c0.6,-0.6 0.6,-1.5 0.6,-2.3c0.3,-8.6 0.5,-17.1 0.7,-25.6c1.1,-0.2 2.2,-0.4 3.2,-0.5c1.2,18.5 -0.3,37.3 -4.2,55.5c-2.2,-0.6 -2.4,-3.7 -2.1,-5.9c0.7,-5.3 1.4,-10.6 2,-15.9c-4.3,-0.8 -8.7,-1.6 -13,-2.5c-0.2,-1 -0.3,-2.1 -0.4,-3.2"/>
<path id="svg_52" fill="#000000" d="m983.2,223.7c-17.6,-2 -35.3,-4.1 -52.9,-6.1c-3.2,-0.4 -6.4,-0.7 -9.4,-1.5c20.9,0.2 41.9,2.7 62.3,7.6"/>
<path id="svg_53" fill="#000000" d="m732.1,203.7c0.4,4.4 0.3,8.8 0.3,13.2c-0.2,17.1 -0.4,34.1 -0.6,51.1c-0.1,2.8 -0.3,6.1 -2.4,8c-1.9,-5.1 -1.8,-10.7 -1.7,-16.2c0.5,-18.5 1,-37 1.5,-55.5c1,-0.2 1.9,-0.4 2.9,-0.6"/>
<path id="svg_54" fill="#000000" d="m19.3,255.9c4.1,7.4 5.6,16.2 4.4,24.6c2,1.1 3.9,-1.5 4.8,-3.6c2.8,-6.7 6.2,-13 10.4,-18.9c1.1,-1.6 3.7,-3.1 4.6,-1.4c-4,8.9 -8.6,17.4 -13.7,25.6c-1.5,2.5 -4.3,5.2 -6.7,3.6c-2.6,-9.7 -4.7,-19.5 -6.3,-29.4c0.7,-0.5 1.7,-0.7 2.5,-0.5"/>
<path id="svg_55" fill="#000000" d="m731.3,284.2c1.3,-6.1 4.1,-11.8 8.2,-16.5c1.3,0.8 0.9,2.9 0.4,4.4c-3.1,7.7 -6.2,15.5 -9.3,23.3c-0.1,0.4 -0.3,0.9 -0.7,1.2c-1.2,0.7 -2.4,-0.8 -2.9,-2.1c-4.3,-10.6 -8.6,-21.2 -12.9,-31.8c8.5,4.2 11.3,14.7 13.2,23.9c0.3,1.1 1.9,1.3 2.7,0.5c0.9,-0.7 1.1,-1.9 1.3,-2.9"/>
<path id="svg_56" fill="#000000" d="m1045.5,381.3c2,3 0.7,7.7 -2.6,9.3c-3.2,1.7 -7.6,0.1 -9.2,-3.2c-1.7,-3.3 -0.4,-7.6 2.2,-10.3c2.5,-2.8 6.1,-4.3 9.6,-5.5c3,-1 6.6,-2.7 6.5,-5.8c0,-2.6 1.8,-5.2 4.3,-5.8c2.5,-0.7 5.5,1 6,3.5c0.5,2.6 -1.8,5.4 -4.4,5.1c0.5,1.7 2.4,2.8 4.1,2.4c0.3,1.4 -1,2.6 -2.3,2.8c-1.4,0.3 -2.7,-0.1 -4,-0.5c-2.8,-0.6 -5.8,-0.6 -8.1,0.8c-2.4,1.5 -3.7,4.9 -2.1,7.2m-8.6,1.1c0,1 0,1.9 0,2.9c2,0.9 3.9,1.7 5.8,2.5c-0.4,-3.3 -0.8,-6.7 -1.2,-10c-2.2,0.6 -3.9,2.4 -4.6,4.6"/>
<path id="svg_57" fill="#000000" d="m762.4,134.7c1.5,1.6 1.2,4.1 0.7,6.2c-2.7,11.6 -8,22.6 -15.5,31.9c-2.1,2.6 -6.8,1.5 -8.4,-1.4c-1.6,-3 -0.6,-6.8 1.3,-9.5c1.3,-2 3.3,-3.7 5.7,-4.3c2.3,-0.5 5.1,0.5 6.2,2.6c3.3,-8.5 6.7,-17 10,-25.5m-17.6,34.5c3,-1.7 5,-5 5,-8.4c-4.7,-0.1 -8.8,5 -7.7,9.7c0.9,-0.4 1.9,-0.7 2.9,-1.1"/>
<path id="svg_58" fill="#000000" d="m1069.4,328c-3.4,-0.2 -6.4,-3 -6.8,-6.3c-2.7,3.4 -6.9,7.2 -10.8,5.3c-3,-1.5 -6.4,1.2 -9.7,1.3c-4.9,0 -8,-6.3 -6,-10.8c1.9,-4.5 7.1,-6.8 12,-6.8c-0.9,4.7 -9.5,5.6 -9,10.4c0.5,3.6 6,3.8 8.8,1.4c2.7,-2.4 4,-6.1 6.8,-8.4c-0.5,3.7 -1,7.4 -1.5,11.1c3.6,-3.1 7.2,-6.3 10.8,-9.4c1.8,4.1 3.6,8.1 5.4,12.2"/>
<path id="svg_59" fill="#000000" d="m87.8,534.3c1.7,1 0.9,3.5 0,5.2c-5.5,10.1 -11.1,20.2 -16.6,30.2c-2,3.8 -4.4,7.8 -8.4,9.4c1.5,-6.7 4.6,-13.1 9,-18.4c3.1,-3.8 0.8,-9.5 -1.6,-13.7c-2.5,-4.3 -5.2,-9.5 -2.8,-13.8c3.8,6 6.2,12.8 8.6,19.5c3.1,-6.7 7.1,-12.9 11.8,-18.4"/>
<path id="svg_60" fill="#000000" d="m72,265.5c0,-4.4 -0.1,-8.7 -0.1,-13.1c0.7,-1 2.5,-0.7 3.2,0.3c0.8,1 0.9,2.3 0.9,3.6c0.1,4.6 -0.1,9.2 -0.5,13.7c1.8,-0.3 3.7,-0.6 5.5,-1c4.9,-0.9 11.3,-1.1 13.6,3.3c1.7,3.5 -1,8.3 -4.8,8.6c-3.9,0.3 -7.3,-4.1 -6.1,-7.8c-3.3,-0.4 -7.2,0.3 -8.8,3.3c-1.5,2.9 1.9,7.4 4.7,5.7c0.9,2.4 -1.8,5.1 -4.4,4.7c-2.5,-0.3 -4.4,-2.9 -4.6,-5.4c-0.2,-2.6 0.8,-5.1 2.2,-7.4c-1.9,0 -3.7,-0.1 -5.5,-0.2c-0.3,4.1 -0.5,8.2 -0.8,12.3c-2,-3 -4.1,-6.3 -4.1,-9.9c0.1,-3.6 3.3,-7.3 6.8,-6.4c1.9,0.5 2.8,-2.3 2.8,-4.3"/>
<path id="svg_61" fill="#000000" d="m897.9,449.5c3.5,1.3 7.8,-1.4 8.2,-5.2c0.8,-8.4 2.7,-16.8 5.8,-24.8c2.1,0.9 2,4 1.5,6.3c-1.7,7.3 -3.3,14.6 -5,21.8c3.9,0.1 7.8,0.2 11.7,0.3c0.9,0 2.2,0.3 2.2,1.3c0,0.3 -0.2,0.5 -0.3,0.8c-1.7,2.6 -5.6,1.9 -8.7,1.6c-3,-0.3 -7.3,1.4 -6.5,4.4c0.6,2.4 -1.1,4.8 -1.1,7.3c0,2.8 2.4,5.4 5.2,5.6c2.7,0.1 5.4,-2.2 5.7,-5c1,-0.7 2.4,0.4 2.5,1.6c0,1.2 -0.8,2.2 -1.5,3.2c-1.4,1.6 -3,3.4 -5,4.1c-4.2,1.5 -8.8,-2.2 -9.9,-6.5c-1.2,-4.3 0.1,-8.8 1.4,-13.1c-2.6,0.6 -5.5,-1.1 -6.2,-3.7"/>
<path id="svg_62" fill="#000000" d="m203.5,482.8c1.2,-2.6 4.6,-3.3 7.5,-3.7c26.4,-3.1 52.9,-5.9 79.4,-8.2c2.3,-0.2 5.7,1.3 4.5,3.3c-27.3,2.8 -54.7,5.6 -82,8.5c-3.1,0.3 -6.3,0.6 -9.4,0.1"/>
<path id="svg_63" fill="#000000" d="m357.7,485.5c-3.6,-2.6 -8.3,-3.6 -12.7,-2.6c-3.3,0.7 -4.7,4.5 -5.9,7.7c-1.2,3.2 -3.8,6.8 -7.2,6c8.3,-16.9 16.5,-33.7 24.7,-50.6c0.7,0.7 1.4,1.5 2.1,2.3c-3.3,12 0.7,24.8 -1,37.2m-4.3,-6.7c1.2,-5.7 1,-11.7 -0.6,-17.4c-2.6,5.7 -5.1,11.4 -7.7,17.1c2.8,0.4 5.5,0.5 8.3,0.3"/>
<path id="svg_64" fill="#000000" d="m535.4,466.3c31.5,-5.6 63.5,-7.7 95.4,-6.2c1.9,0.1 4.4,0.9 4.1,2.8c-7.6,1.5 -15.4,1.1 -23.1,0.9c-23.1,-0.4 -46.2,1.4 -68.9,5.6c-3.1,0.6 -7.6,0 -7.5,-3.1"/>
<path id="svg_65" fill="#000000" d="m819.8,475.8c4.8,0.4 9.7,0.8 14.5,1.2c-3.3,2.3 -7.4,3.9 -11.3,3.1c-3.9,-0.8 -7.3,-4.9 -6.1,-8.8c5.6,-0.6 9.6,-7.5 7.3,-12.7c-3.8,6.8 -8.6,13 -14.2,18.4c-1.5,1.5 -4.2,1.1 -5.8,-0.3c-1.6,-1.4 -2.4,-3.5 -3,-5.5c-1.1,-4.2 -1.5,-8.5 -1.3,-12.8c0.3,-1 1.7,0 2.1,1c1.6,5 3.3,10.1 4.9,15.1c4,-4.6 7.9,-9.2 11.8,-13.8c2.1,-2.4 4.6,-5 7.8,-5c4.2,5.8 0.8,15.6 -6.2,17.4c-0.2,0.9 -0.3,1.8 -0.5,2.7"/>
<path id="svg_66" fill="#000000" d="m661.7,339.1c-0.1,-0.8 -0.2,-1.6 -0.7,-2.3c-1.1,-1.2 -2.9,-1.1 -4.6,-1.2c-1.6,0 -3.6,-1.1 -3.2,-2.6c2.6,-1.3 5.6,-1.7 8.4,-1.1c0.2,-4.4 0.4,-8.9 0.8,-13.3c-5.2,1.4 -10.6,2.2 -16,2.5c-0.2,-1 -0.4,-2 -0.6,-3.1c12.1,-2.9 24.3,-5.2 36.6,-6.9c2.5,-0.3 5.5,-0.4 6.9,1.6c-5.5,2.5 -11.6,3.8 -17.6,3.7c-3.1,-0.1 -5.5,2.7 -6.1,5.7c-0.6,2.9 0.2,6 1,8.9c3.8,-0.3 7.6,-0.7 11.3,-1c2,-0.1 5,1.1 3.9,2.8c-5.3,1.1 -10.7,2.2 -16,3.3c1.3,5 0.4,10.5 -2.4,14.8c-0.6,-3.9 -1.1,-7.9 -1.7,-11.8"/>
<path id="svg_67" fill="#000000" d="m783.5,305.3c4.3,-0.3 8.7,0.4 12.7,2.1c1.3,0.6 2.8,1.4 3.2,2.9c1.4,4.3 -7.3,6 -7.4,10.5c0,3.3 4.2,4.8 5.6,7.7c1.4,2.8 -0.4,6.3 -3,7.9c-2.6,1.6 -5.9,1.9 -8.9,2.1c-2,0.2 -4.1,0.3 -6,-0.5c-1.8,-0.8 -3.3,-2.9 -2.7,-4.8c2.6,-8.3 3.8,-17.1 3.5,-25.8c-0.1,-1.4 1.6,-2 3,-2.1m-2.4,29.1c4.9,1.4 10.4,0.1 14,-3.3c-2.2,-1.9 -4.5,-3.8 -5.8,-6.4c-1.4,-2.6 -1.5,-6.1 0.3,-8.3c1.8,-2.2 5.2,-2.8 6.6,-5.2c-3,-0.9 -6.1,-1.6 -9.2,-2.1c-0.5,-0.1 -1.1,-0.2 -1.5,0c-0.8,0.4 -1,1.4 -1.2,2.3c-1,7.7 -2.1,15.4 -3.2,23"/>
<path id="svg_68" fill="#000000" d="m778,165.1c0.3,-3.5 0.6,-6.9 0.8,-10.3c-4.4,2.2 -6.6,8.1 -4.7,12.7c4,1.7 9,0.8 12.1,-2.3c1.1,4 -4.2,7.1 -8.4,6.6c-4.2,-0.4 -8.2,-2.6 -12.4,-2c-2.2,0.4 -4.2,-1.5 -4.7,-3.7c-0.6,-2.1 0,-4.4 0.6,-6.5c2.5,-9.5 5.3,-18.9 8.3,-28.2c2.4,0.6 2.2,4.1 1.3,6.4c-2.1,6 -4.2,11.9 -6.3,17.8c-1.2,3.4 -2.3,7.4 -0.2,10.3c1.7,0 3.4,0 5.1,0c0.1,-4.1 1.5,-8 3.9,-11.2c0.9,-1.2 2,-2.3 3.4,-2.7c2.9,-0.6 5.5,2.5 5.4,5.5c-0.1,2.9 -2.1,5.5 -4.2,7.6"/>
<path id="svg_69" fill="#000000" d="m1121.8,313.9c2.1,0 4.3,0 6.3,-0.6c2.8,-1 5.1,-3.2 7.9,-4.3c2.8,-1.1 6.8,-0.6 7.8,2.3c0.5,1.7 -0.3,3.6 -1.3,5.1c-3.3,4.9 -9.4,7.7 -15.3,7.1c5,-3.7 9.9,-7.3 14.8,-10.9c-5,-0.8 -9.9,1.9 -14.3,4.5c-1.2,0.7 -2.5,1.5 -3.1,2.7c-1.6,2.8 0.9,6.4 3.8,7.5c3,1.1 6.3,0.6 9.5,0.7c3.2,0 6.7,1 8.2,3.9c-4.8,1.6 -10.1,0.5 -15,-0.8c-3.2,-0.8 -6.6,-1.8 -8.8,-4.3c-2.1,-2.6 -2.1,-7.1 0.9,-8.6c-4.6,-0.1 -9.2,-0.3 -13.8,-0.4c-2.8,-0.1 -6.6,-1.1 -6.5,-3.9c6.3,0 12.6,0 18.9,0"/>
<path id="svg_70" fill="#000000" d="m997.6,351.2c-1.6,1.6 -1.9,4.4 -0.6,6.3c1.2,1.9 3.9,2.7 6,1.8c4,-1.6 9.1,0.1 11.3,3.8c2.2,3.7 1.3,9 -2,11.7c-0.1,-3.8 -0.5,-7.7 -1.4,-11.4c-4.9,-1.2 -10.2,-0.9 -15,0.8c0.4,5.2 -0.4,10.4 -2.2,15.2c-2.3,-4.7 -2.4,-10.5 -0.3,-15.3c-3,-0.1 -6,-0.1 -9,-0.2c-0.5,0 -1,0 -1.5,-0.3c-1.7,-1 0,-3.8 1.9,-4c1.9,-0.3 3.8,0.7 5.8,0.4c3.3,-0.6 4.7,-5.6 2.2,-7.8c-3.7,-3.3 -9.7,1.1 -14.5,-0.3c-0.2,-1 -0.3,-2 -0.5,-2.9c10.7,-2.6 21.8,-3.6 32.9,-3.2c2.1,0.1 4.9,1 4.8,3.2c-4.1,-0.4 -8.2,-0.3 -12.2,0.1c-2.1,0.3 -4.2,0.7 -5.7,2.1"/>
<path id="svg_71" fill="#000000" d="m770.9,431.2c-3.4,0.1 -6.9,0.2 -10.1,1.5c-3.2,1.2 -6.1,3.8 -6.8,7.1c-1.1,6 4.6,10.7 9.2,14.7c3.4,3 6.5,6.5 7.9,10.9c1.4,4.3 0.7,9.5 -2.7,12.5c-2.6,2.4 -6.3,3 -9.8,3.4c-4.9,0.5 -11.4,-0.3 -12.6,-5.1c5.8,-0.3 11.9,3.7 17.2,1.2c4.9,-2.3 5.9,-9.2 3.7,-14c-2.3,-4.9 -6.8,-8.2 -11.2,-11.3c-5.6,-4 -7,-12.7 -3,-18.2c4,-5.5 12.8,-6.8 18.2,-2.7"/>
<path id="svg_72" fill="#000000" d="m1100.2,313.6c-2.4,5.9 -3.3,12.4 -2.5,18.6c-3.1,-1.4 -4.9,-5.2 -4.1,-8.6c-4.4,4.1 -10.6,6.2 -16.5,5.4c-3.9,-0.5 -4.3,-5.8 -3.8,-9.6c1.1,-7.3 2.2,-14.6 3.3,-22c0.5,-3.6 2,-8.2 5.7,-8.5c-2.9,12 -4.8,24.2 -5.6,36.6c2.3,0.8 4.1,-2.3 4.9,-4.7c1,-3.1 2.7,-6.1 5.5,-7.7c2.8,-1.6 6.9,-1.1 8.5,1.7c-5.6,0.4 -10.8,4.3 -12.6,9.6c1.7,1.1 3.8,-0.2 5.3,-1.4c4,-3.1 8,-6.2 11.9,-9.4"/>
<path id="svg_73" fill="#000000" d="m1055.7,65.5c3,5.9 2.8,13.2 -0.5,18.9c-3.3,5.7 -9.6,9.5 -16.2,9.8c-3.3,0.2 -6.7,-0.5 -9.3,-2.6c-3.3,-2.9 -4.5,-7.6 -4.2,-12c0.6,-9.4 7.8,-18.4 17.1,-20.2c3.3,-0.6 7,-0.3 9.8,1.8c1.4,1.1 2.5,2.6 3.3,4.3m-1.6,9.4c0.2,-3.4 -0.4,-7.1 -2.8,-9.5c-2.8,-2.8 -7.4,-3.1 -11,-1.6c-3.7,1.5 -6.4,4.5 -8.6,7.8c-1.9,2.8 -3.5,6.1 -3.2,9.5c0.5,6.6 8.4,10.8 14.7,8.9c6.3,-2 10.3,-8.5 10.9,-15.1"/>
<path id="svg_74" fill="#000000" d="m145.2,508.2c-9.5,-28.4 -21.6,-56 -36.1,-82.2c-1,-1.8 -0.7,-5.3 1.2,-4.6c17.8,30.4 31.9,63 42,96.7c-4,-1.4 -5.7,-5.9 -7.1,-9.9"/>
<path id="svg_75" fill="#000000" d="m949.2,92.6c-0.8,1.2 -2,1.9 -3.3,2.6c-6.5,3.7 -13.4,6.9 -20.4,9.5c-1,0 -1.6,-1.3 -1.3,-2.2c0.3,-1 1.3,-1.6 2.1,-2c4.8,-2.5 11.5,-2.7 13.9,-7.4c-25.8,2.4 -51.7,4.6 -77.7,6.7c-0.8,-1.1 -0.8,-2.6 -0.1,-3.7c22.5,-1.9 44.9,-3.8 67.4,-5.7c4.4,-0.4 9.3,-0.7 12.8,2c2.3,0.1 3.9,-2.8 2.8,-4.7c-0.9,-1.4 -2.7,-1.9 -4,-2.8c-1.4,-1 -2.2,-3.2 -0.8,-4.1c2.7,1 5.4,2.1 7.3,4.1c2,2 2.9,5.4 1.3,7.7"/>
<path id="svg_76" fill="#000000" d="m75.9,146.7c15.3,27.5 30.6,54.9 46,82.4c1.3,2.3 2.6,4.6 3.8,6.9c0.7,1.3 0.5,3.7 -0.8,3.2c-18.2,-28.5 -34.8,-57.9 -50,-88.1c-0.8,-1.7 -0.8,-4.5 1,-4.4"/>
<path id="svg_77" fill="#000000" d="m1055.4,405.1c0.7,14.8 0.4,29.7 -1,44.5c-0.1,1.3 -0.3,2.6 -1.1,3.6c-1.1,1.2 -3,1.3 -4.6,1.4c-23.6,0.4 -47.1,0.9 -70.6,1.4c-4.8,0.1 -9.7,0.2 -14.2,-1.4c-0.5,-1.3 1.4,-2 2.7,-2.1c26.8,-0.6 53.7,-1.2 80.5,-1.8c3.4,0 4.5,-4.6 4.5,-8c0.1,-10.5 0.2,-21 0.3,-31.5c0.1,-2.6 0.9,-6.1 3.5,-6.1"/>
<path id="svg_78" fill="#000000" d="m970.3,306.2c3.4,-9.7 11.2,-17.7 20.7,-21.2c2.4,-0.9 5,-1.5 7.3,-0.5c2.3,1 3.7,4.1 2.1,6.1c-3.8,-4.1 -10.6,-2.1 -15.1,1.2c-5.8,4.4 -10.1,10.7 -12.1,17.7c-0.8,2.8 -1.1,6 0.6,8.4c0.8,1 1.9,1.8 3.1,2.4c7.3,3.7 17.3,0.5 21.2,-6.7c1.3,-2.6 4.3,-4.2 7.2,-4c2.8,0.2 5.5,2.2 6.5,5c0.4,0.9 0.5,1.9 0.6,3c0.2,3.4 -0.7,6.8 -2.4,9.7c-0.8,-1.5 -1.6,-3 -2.4,-4.4c-2.4,0.6 -4.1,2.7 -6.1,4.2c-2,1.5 -5.1,2.3 -6.7,0.4c-4.6,-5.4 -13.9,-0.6 -20.2,-3.7c-6,-2.9 -6.5,-11.4 -4.3,-17.6m23.9,18.8c5.3,-0.7 10.2,-3.6 13.3,-7.9c0.7,-1 1,-2.8 -0.1,-3c-2.8,-1.4 -6.2,0.5 -8.1,3c-1.9,2.5 -2.9,5.6 -5.1,7.9"/>
<path id="svg_79" fill="#000000" d="m520.7,122c-9.1,-0.4 -18.2,-1.4 -27.2,-3.1c-0.2,-6.7 6,-11.8 11.6,-15.6c2.1,-1.5 4.8,-3 7.1,-1.9c-2.7,2.9 -5.5,5.8 -8.4,8.6c28.3,-1.8 56.7,-3.7 85,-5.6c2.2,-0.1 5.4,1.1 4.5,3.1c-20.2,1.9 -40.5,3.6 -60.8,4.9c-8.2,0.5 -16.4,1 -24.5,-0.1c-2.3,-0.3 -4.6,-0.7 -6.8,-0.2c-2.3,0.4 -4.5,1.9 -4.9,4.2c6,0.3 12.1,0.6 18.1,0.9c3.1,0.2 7.3,1.9 6.3,4.8"/>
<path id="svg_80" fill="#000000" d="m808.8,78.7c7.4,1.1 15.1,-0.1 21.9,-3.3c4.7,-2.2 9.4,-6.6 8.2,-11.6c-0.7,-2.9 -3.2,-4.9 -5.7,-6.4c-5.4,-3.4 -11.6,-5.4 -17.9,-5.9c-4.7,-0.3 -10.9,1.7 -10.7,6.3c1,19.5 0.7,39 -0.7,58.4c-2.4,-1.9 -2.6,-5.4 -2.6,-8.5c0,-16.7 -0.1,-33.5 -0.1,-50.2c0,-4.6 4.2,-8.3 8.7,-9.3c4.5,-1 9.1,0.1 13.6,1.3c4.1,1 8.3,2.2 11.9,4.6c3.6,2.3 6.5,6 6.9,10.3c0.6,5.5 -3.3,10.8 -8.1,13.6c-4.9,2.8 -10.6,3.6 -16.2,4.4c-3.7,0.5 -8.8,0 -9.2,-3.7"/>
<path id="svg_81" fill="#000000" d="m687.5,127.2c-1.2,0.9 -2.6,-0.8 -3,-2.2c-3,-9.4 -4.6,-19.3 -4.8,-29.2c-9.3,1.3 -18.5,2.6 -27.7,4c-2.6,0.4 -5.8,1.4 -5.9,4c-0.3,7.1 -0.7,14.1 -1,21.2c-0.1,2 -2,4.8 -3.3,3.3c0.6,-20.2 1.3,-40.4 1.9,-60.5c0.1,-3.4 0.3,-7.1 2.6,-9.6c0.7,12.7 0.9,25.4 0.8,38.1c10.6,-1.5 21.3,-3.1 32,-4.7c0.1,-10.1 0,-20.2 -0.3,-30.3c2.3,-1 3.7,2.6 3.7,5.1c-0.5,20.4 1.2,40.8 5,60.8"/>
<path id="svg_82" fill="#000000" d="m224.3,370.5c7.1,4.8 17.8,-4.2 24.9,0.7c-5.5,2.7 -11.5,4.3 -17.6,4.6c-3.2,0.2 -6.6,-0.1 -9.2,-1.8c-4.2,-2.9 -5.2,-8.6 -8.3,-12.6c-4.3,-5.5 -12.6,-7.1 -19.1,-4.3c-6.4,2.7 -10.9,9.1 -12.2,16c-0.8,3.9 0.7,9.4 4.7,9.1c20.2,-1.6 40.5,-1.7 60.8,-0.2c1.6,0.1 3.8,0.8 3.7,2.4c-1,1.3 -2.9,1.4 -4.5,1.4c-20,0 -39.9,0 -59.8,-0.1c-2,0 -4,0 -5.6,-1.1c-2.5,-1.7 -2.8,-5.3 -2.8,-8.3c-0.1,-7.1 -0.2,-14.1 -0.2,-21.2c-0.1,-2.6 0,-5.5 1.7,-7.6c4.6,2.6 -0.3,11.1 3.5,14.8c3.1,-6.2 9.7,-10.4 16.6,-10.6c6.9,-0.3 13.8,3.6 17.3,9.5c1.9,3.2 3,7.2 6.1,9.3"/>
<path id="svg_83" fill="#000000" d="m717.8,65.8c-1.3,0.1 -2.7,0.3 -3.6,1.3c-0.8,0.9 -0.9,2.2 -0.9,3.4c-0.6,14.9 -1.2,29.8 -1.8,44.7c-0.7,1 -2.5,0.6 -3.1,-0.5c-0.7,-1 -0.6,-2.3 -0.5,-3.6c0.8,-12.3 1.6,-24.7 2.5,-37.1c0.3,-4.5 -5.9,-6 -10.4,-5.4c-4.4,0.6 -10.1,1.2 -12.1,-2.8c32.7,-3.7 65.4,-7.4 98.2,-11.1c4.6,-0.5 9.8,-0.9 13.3,2.1c-11,1.4 -22.1,2.7 -33.1,4c2.2,17.5 2.6,35.1 1.2,52.7c-0.2,2.3 -2.3,5.5 -4,3.9c1.9,-18.4 1.7,-37.1 -0.6,-55.5c-15.1,1.2 -30.1,2.5 -45.1,3.9"/>
<path id="svg_84" fill="#000000" d="m11.6,245.8c32.8,-3.6 65.7,-7.2 98.5,-10.8c4,-0.5 8.8,-0.6 11.1,2.7c-36.8,4.4 -73.7,8.5 -110.6,12.3c-3.9,0.5 -9,0.1 -10.1,-3.7c-0.5,-1.5 0,-3.2 0.4,-4.8c9.3,-30.8 18.6,-61.7 27.8,-92.5c1.7,8.4 -0.8,17 -3.3,25.2c-6.8,22.6 -13.7,45.1 -20.5,67.7c-0.9,3 3.6,4.2 6.7,3.9"/>
<path id="svg_85" fill="#000000" d="m451,490.3c-1.9,-0.5 -3.7,-1.3 -5.7,-1.3c-2.7,0.1 -5.1,1.7 -7.5,2.8c-2.5,1.1 -5.8,1.5 -7.6,-0.4c-2.1,-2.2 -2.5,-7.1 -5.5,-6.5c4.4,-1.7 8.7,-3.3 13.1,-5c-3.4,-2.6 -8.5,-2.1 -12.2,0c-3.8,2.1 -6.6,5.6 -9.3,8.9c-6.9,8.7 -13.8,17.4 -20.8,26c-2,0 -1.5,-3.2 -0.3,-4.8c5.2,-7.4 10.7,-14.6 16.5,-21.6c-0.8,-2.6 -4.1,-4.1 -6.6,-2.9c-2.4,1.1 -5.4,-0.9 -6.2,-3.5c-0.8,-2.6 -0.1,-5.4 0.6,-8c1.7,-6.4 3.4,-12.9 5.1,-19.4c0.4,-1.2 1.1,-2.8 2.4,-2.7c1.7,0.1 1.8,2.5 1.4,4.2c-2.1,8 -4.2,16.1 -6.3,24.2c1.5,-0.9 2.9,-1.7 4.4,-2.6c1.2,2.3 2.4,4.7 4.6,6c2.2,1.3 5.6,1 6.7,-1.3c4,-8.5 17.4,-10.4 23.6,-3.3c1.6,1.7 4.5,0.4 6.2,-1.3c1.6,-1.6 3.7,-3.6 6,-2.9c1.4,4.3 -1.5,9.4 -5.8,10.5c3.5,1.2 7.3,2.4 11,1.6c3.6,-0.9 6.8,-4.8 5.5,-8.3c-1.7,-4.4 2.6,-9.3 7.3,-10.1c4.7,-0.7 9.3,1.3 13.6,3.2c-1.9,2.5 -5.7,2 -8.7,1.3c-3.1,-0.6 -7.2,-0.5 -8.3,2.5c-0.9,2.4 1,5 0.6,7.5c-0.2,1.9 -1.6,3.4 -3.2,4.5c-4.1,2.9 -9.6,4 -14.6,2.7m-16.2,-4.6c-1.8,-0.4 -3.7,1 -3.9,2.8c4.4,0.9 9.2,-0.6 12.3,-4c-2.6,-1.2 -5.9,-0.8 -8.2,1"/>
<path id="svg_86" fill="#000000" d="m206.3,426.6c0.3,-3.1 4.1,-4.9 7,-3.9c2.9,1 4.7,4 5.1,7c0.5,3.1 -0.2,6.1 -0.9,9.1c0.8,2.3 4.5,2 5.9,0c1.4,-2 1,-4.7 0.7,-7.1c-0.4,-2.4 -0.8,-4.8 -1.1,-7.2c-0.5,-3.3 -1,-6.8 0.6,-9.7c1.6,-2.9 6.1,-4.2 8.1,-1.6c0.8,6.2 1.5,12.5 2.2,18.8c0.3,2.3 0.1,5.3 -2.1,6c-1.4,-7.1 -2.3,-14.4 -2.9,-21.7c-0.9,-0.5 -1.9,-0.6 -2.9,-0.4c0,7.8 0.8,15.5 2.3,23.2c9.2,-1.2 18.5,-1.4 27.8,-0.8c0,1.7 -2.1,2.4 -3.8,2.6c-18.2,1.9 -36.4,3.9 -54.6,5.8c-3.1,0.3 -6.5,0.6 -9.1,-1.2c-3.5,-2.5 -3.8,-7.5 -3.7,-11.9c0.3,-9.6 0.5,-19.2 0.7,-28.9c0.1,-1.4 1.2,-3.6 2.4,-2.7c0.5,0.4 0.6,1 0.6,1.6c0.7,9 0.6,18.2 -0.3,27.2c-0.5,4.9 -0.6,11 3.7,13.2c0.4,-9.7 0.7,-19.5 1.1,-29.3c0,-1.8 0.2,-3.8 1.7,-4.8c1.7,-1.1 4.1,0 5.2,1.7c1,1.7 1,3.9 1,5.9c0,7.9 -0.1,15.9 -0.1,23.9c1.5,0.7 3.5,0.7 5,0c-0.1,-5 0,-9.9 0.4,-14.8m7.8,10.1c0.3,-4.4 0.3,-9.4 -2.9,-12.2c-1.4,5.1 -1.8,10.6 -1.2,15.9c0.8,0.9 2.4,0.6 3.1,-0.3c0.7,-1 0.9,-2.2 1,-3.4m-16,1.7c-0.2,-3.2 -0.4,-6.4 -0.5,-9.6c-2.8,4.2 -3.2,9.9 -1,14.5c1.3,-1.3 1.8,-3.2 1.3,-4.9m-3.2,-21c0.7,1.9 1.3,3.7 1.9,5.5c1.7,-2.7 1.9,-6.1 0.7,-9.1c-1.6,0.1 -2.8,2 -2.3,3.5"/>
<path id="svg_87" fill="#000000" d="m30.2,144.4c-5.7,-3.7 -10.4,-8.8 -14,-14.7c-7.3,-12.1 -9.4,-27.3 -5.6,-41c3.7,-13.6 13.2,-25.6 25.7,-32.3c4.5,-2.5 9.4,-4.3 14.5,-4.7c13.1,-1.2 25.8,6.8 33.8,17.3c13.5,17.4 15.9,43.4 3.7,61.7c-12.3,18.3 -39.5,25.6 -58.1,13.7m-6.4,-10c10.1,11.5 27.7,15.2 41.9,9.6c14.2,-5.6 24.4,-19.6 26.5,-34.7c2.1,-15.1 -3.5,-30.8 -13.9,-42c-4.7,-5.1 -10.7,-9.5 -17.5,-11.1c-13.4,-3.3 -27.3,4.5 -36.2,14.9c-5.2,6.1 -9.3,13.2 -11.1,20.9c-2.1,8.8 -1,18.1 1.8,26.7c1.9,5.7 4.6,11.2 8.5,15.7"/>
<path id="svg_88" fill="#000000" d="m679.9,496.8c-6.9,-1.5 -14.1,-4.1 -17.8,-10.1c-2.6,-4.3 -2.8,-9.7 -2.9,-14.7c-0.3,-12.4 -0.6,-24.8 -0.9,-37.2c-0.1,-3.7 -0.2,-7.4 1,-10.9c3.1,-9 13.3,-13.2 22.7,-14.8c8.5,-1.5 17.1,-1.9 25.7,-1c6,0.6 13.5,3.4 13.5,9.4c0.2,16.5 0.3,33 0.4,49.5c0.1,7.2 0,14.8 -3.8,21c-3.7,5.8 -10.5,9.3 -17.3,10.4c-6.9,1.1 -13.9,0 -20.6,-1.6m36.5,-29.3c0.9,2.4 -1.3,4.7 -3.5,6c-14.8,9.4 -35.8,7.4 -48.5,-4.5c-4.1,5.7 -1.7,14.2 3.6,18.8c5.3,4.6 12.6,6.1 19.6,6.8c5.1,0.6 10.3,0.8 15.2,-0.4c5,-1.3 9.8,-4.1 12.3,-8.6c2.1,-3.7 2.5,-8.1 2.7,-12.4c0.4,-7.5 0.3,-15 -0.2,-22.4c-0.5,1.7 -2.6,2.4 -4.4,2.8c-8.8,2.1 -17.7,4.2 -26.8,3.7c-9,-0.4 -18.2,-3.6 -24,-10.6c-0.4,8 -0.2,17.3 6.3,21.9c13.9,9.8 34.3,9.3 47.7,-1.1m-48.9,-20.4c5.9,5.5 14.5,6.7 22.6,6.5c8.5,-0.2 17,-1.7 25,-4.5c1,-0.3 2,-0.7 2.5,-1.5c0.6,-0.9 0.6,-2 0.6,-3.1c-0.4,-6.3 -0.7,-12.6 -1.1,-18.9c-12.3,4.2 -25.5,6 -38.5,5.3c-5.7,-0.3 -11.9,-1.4 -15.5,-5.8c-3.3,7.3 -1.5,16.6 4.4,22m-2.8,-23.2c15.8,4.6 33,4.2 48.4,-1.3c1.3,-0.4 2.7,-1 3.4,-2.1c1.2,-1.8 0.4,-4.3 -1.1,-5.7c-1.6,-1.4 -3.8,-1.9 -5.9,-2.3c-12.2,-2.2 -25,-1 -36.7,3.3c-3.8,1.4 -8.1,4 -8.1,8.1"/>
<path id="svg_89" fill="#000000" d="m145,521.3c-31.5,1.2 -63,2.4 -94.5,3.6c-2.2,0.1 -4.9,-0.1 -6,-1.9c-0.6,-1.2 -0.5,-2.6 -0.4,-3.9c3,-26.1 7.7,-52.1 14.2,-77.5c2.8,-10.9 5.3,-24.1 -2.6,-32c-14.4,-14.5 -11.1,-41.8 5.7,-53.4c16.8,-11.6 42.6,-5.9 53.8,11.1c11.3,17 7.3,42 -7.9,55.6c-11.5,10.3 -31.9,12.8 -41.1,0.4c-7.6,32.1 -13.8,64.6 -18.3,97.3c29.8,-1 59.6,-2.1 89.4,-3.2c3.2,-0.1 7.4,0.7 7.7,3.9m-56.5,-93.4c4.3,-0.5 8.4,-2.3 11.9,-4.7c10,-6.7 16.6,-18.1 17.5,-30c0.9,-12 -4,-24.2 -12.9,-32.3c-2,-1.9 -4.3,-3.5 -6.9,-4.6c-2.2,-0.9 -4.6,-1.3 -7,-1.6c-5.7,-0.7 -11.5,-1 -17.1,0.3c-8.1,1.9 -15.4,7.2 -19.7,14.4c-4.2,7.1 -5.4,16 -3.2,24.1c1.1,4.1 3,7.9 5.1,11.5c3.5,6.1 7.5,12 12.9,16.5c5.4,4.4 12.5,7.2 19.4,6.4"/>
<path id="svg_90" fill="#000000" d="m1030.2,17c-2.8,-4.2 -0.7,-10.3 3.3,-13.4c4,-3 9.4,-3.6 14.4,-3.6c4.8,0 8,5.3 8.5,10.1c0.6,4.8 -0.5,9.7 0.6,14.4c1.2,4.7 5.9,9.2 10.4,7.5c1.9,-0.7 3.3,-2.4 4.6,-4c5,-6.2 10,-12.3 15,-18.5c3.7,4.7 7.6,9.7 8.9,15.5c1.4,5.9 -0.3,12.9 -5.6,15.8c-3.7,2 -5.3,6.6 -5,10.8c1,12.3 15.6,19.4 27.8,17.6c5.8,2.4 4.1,11.7 -1,15.4c-5.2,3.6 -11.9,4.2 -17.4,7.3c-5.4,3.1 -9.1,11.4 -4.2,15.4c5.2,4.3 0,12.5 -5,16.9c-2.6,2.3 -6.8,4.5 -9.1,1.9c-3.2,-3.9 -9.4,-4.9 -13.6,-2.2c-4.3,2.6 -6.2,8.6 -4.1,13.2c1.1,2.5 -1.6,4.9 -4,6.2c-4.2,2.5 -8.7,5 -13.5,4.3c-4.8,-0.6 -9.1,-6.2 -6.6,-10.3c3.1,-5.2 -3.8,-11.8 -9.7,-10.5c-5.9,1.2 -9.7,7 -11.9,12.5c-4.2,-1.7 -8.6,-3.6 -11.5,-7c-3,-3.5 -4,-9 -1.1,-12.5c5.1,-6.2 1.3,-15.5 -2.7,-22.6c-1.2,-2.3 -3.8,-4.9 -6,-3.4c-3.9,2.8 -9.7,-0.7 -11,-5.3c-1.3,-4.6 0.3,-9.4 2,-13.9c0.7,-1.8 1.6,-3.8 3.5,-4.1c6.1,-0.7 11.6,-5.3 13.4,-11.2c1.8,-5.9 -0.2,-12.7 -4.8,-16.7c-2.6,-2.3 -0.3,-6.4 2,-9c5.1,-5.6 10.3,-11.2 15.4,-16.9c2.2,4.7 5.4,8.8 9.3,12c5.4,-1.9 11.9,-7.1 8.7,-11.7m23.9,117.9c0.5,-2.2 0.4,-4.4 0.9,-6.6c2.1,-10.3 18.3,-13.7 24.4,-5.1c5.2,-3.6 11,-10.2 7.3,-15.3c-3.9,-5.5 -2.5,-14.1 3.1,-17.9c3.6,-2.5 8.1,-2.9 12.3,-4.1c4.1,-1.1 8.6,-3.6 9.6,-7.8c1,-4.2 -4.2,-9.1 -7.6,-6.4c-4,3 -10,1.6 -13.6,-1.8c-3.7,-3.4 -5.4,-8.3 -6.9,-13.1c-2.3,-6.9 -3,-16.7 3.8,-19.2c3.9,-1.4 5.7,-6.2 5.1,-10.4c-0.6,-4.1 -3,-7.7 -5.4,-11.1c-4.4,3.3 -7.9,7.9 -10,13c-2.9,7.3 -13.7,8.9 -19.8,3.9c-6,-4.9 -7.2,-14.2 -4.3,-21.5c0.7,-1.9 -0.5,-4.1 -2.1,-5.4c-3.4,-2.9 -8.7,-3.2 -12.4,-0.7c-3.7,2.5 -5.4,7.5 -4.1,11.7c1.9,5.9 -1.9,13 -7.9,14.7c-5.9,1.7 -12.9,-2.4 -14.4,-8.4c-4.2,2.7 -8.5,5.6 -11,10c-2.4,4.3 -2.3,10.6 1.6,13.6c2.6,2 2,5.9 1.1,9c-1.2,4.6 -2.6,9.4 -5.5,13.1c-3,3.8 -8.1,6.3 -12.6,4.8c-0.2,5.7 -0.4,11.3 -0.6,17c3.7,-3.7 10.4,-2.5 14.2,1.1c3.8,3.6 5.4,8.9 6.6,14c1.7,7.2 2,16.4 -4.3,20.1c3.5,3 6.9,6 10.4,9c0.9,-8.2 10.2,-14.3 18.1,-11.9c7.8,2.5 12,12.8 8.1,20c6.3,2.4 14.3,-1.8 15.9,-8.3"/>
<path id="svg_91" fill="#000000" d="m387.1,52.7c9.2,-10.5 22.5,-17.4 36.4,-18.9c8,-0.9 16.7,0.1 23,5.3c8.3,6.9 9.8,19.8 5.8,30c-4.1,10.1 -12.5,17.8 -21.5,24c11.3,-0.6 23,0.4 33.3,5.2c10.2,4.8 18.9,14.1 20.7,25.4c2.7,17.3 -11.7,33.5 -28.1,39.6c-16.5,6.1 -34.6,4.8 -52.1,4.8c-43.4,-0.1 -86.3,7.9 -129.6,10.5c-8.1,0.5 -16.7,0.7 -23.7,-3.5c-7.9,-4.8 -11.7,-14.3 -13.9,-23.3c-2.6,-11.2 -3.4,-23.3 0.9,-33.9c4.3,-10.7 14.7,-19.4 26.1,-19c-6.3,-11.8 -10.3,-25.4 -8.6,-38.8c1.8,-13.4 10,-26.3 22.6,-31.2c12.6,-4.9 28.9,0.3 34.4,12.6c3.7,-13.2 13,-24.8 25.5,-30.2c12.5,-5.4 28,-3.9 38.6,4.6c10.6,8.6 15.3,24.2 10.2,36.8m-12.4,11.1c12.8,-9.1 13.6,-29.9 2.9,-41.3c-10.6,-11.4 -29.7,-12.8 -43,-4.6c-13.4,8.1 -20.8,24.1 -20.8,39.7c-1.5,-6.9 -3.2,-14.1 -7.9,-19.3c-8.8,-9.7 -25.7,-8.6 -35.3,0.4c-9.6,8.9 -12.4,23.3 -10.4,36.3c2,12.9 8.3,24.7 14.8,36.1c-4.3,-6 -12.6,-8.7 -19.6,-6.3c-8.1,2.8 -12.9,11.3 -14.9,19.7c-3.1,12.7 -1.3,26.5 4.9,38c2.5,4.6 5.9,9 10.6,11.1c4.8,2.1 11.2,1.2 14.1,-3.1c-1.3,3.3 4,4.9 7.5,4.5c46.9,-6.2 94.1,-9.9 141.4,-10.9c13.4,-0.3 27.2,-0.4 39.5,-5.7c12.4,-5.3 23,-17 22.5,-30.4c-0.6,-13.7 -12.5,-24.7 -25.6,-28.7c-13.1,-3.9 -27.1,-2.4 -40.7,-0.7c11.5,-6 23.5,-12.5 30.5,-23.5c7,-10.9 6.6,-27.7 -4.3,-34.7c-5.1,-3.3 -11.5,-3.9 -17.5,-3.1c-16.7,2.1 -30.4,14 -41.7,26.3c-2.2,2.4 -7.7,3.3 -7,0.2"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 32 KiB

290
static/blog/vue3.md Normal file
View File

@@ -0,0 +1,290 @@
**Published on 22. October 2020**
I recently started working on the user interface for [Pirsch](https://pirsch.io/?ref=marvinblum.de) and was very happy to hear that Vue 3 [has been officially released](https://news.vuejs.org/issues/186) and marked production-ready. While most of the other core libraries, like vue-router and vuex, are still in beta, I didn't want to build upon Vue 2. Don't get me wrong, Vue 2 is a great framework and stable, but I wasn't satisfied with my approach to building frontends anymore.
This article is about the transition to a new project setup, my first steps in Vue 3, and the experiences I made using it together with TypeScript. I will provide code samples and highlight a few features I found useful and refreshing.
Some Background
---------------
I started learning Vue back when they made the transition from version 1 to 2 and I quickly built my own setup, ignoring the default way of setting up a project through the vue-cli. This time, however, I wanted to just use what's there and not wrap my head around setting up stuff like webpack. Additionally, I wanted to try out TypeScript, something I have shied away from for a long time, mostly because I believed it would add an additional layer of abstraction on top of vanilla JavaScript, which seemed unnecessary to me. And as we recently started developing a new product called [Pirsch](https://pirsch.io/?ref=marvinblum.de), which has a fairly simple frontend, I took the opportunity to try out something new. As I'm a beginner with TypeScript, please let me know if you find anything odd or plain wrong.
Setup
-----
The best way to set up a new Vue 3 project is by installing and using the [vue-cli](https://cli.vuejs.org/).
![Setup](/static/blog/vue3/setup.png)
Run `vue create <name>` to set up a new project.
This command will generate a new project inside the `test-app` directory and create the basic structure. Note that you will have to select Vue 3 and TypeScript from the `Manually select features` option at the beginning, as it is still marked as experimental.
![Structure](/static/blog/vue3/structure.png)
Out-of-the-box project structure of a new Vue 3 project.
Nothing surprising so far, but what really astonished me was how well everything works out of the box. I used to have two commands, one for building the Vue app itself and one to compile the Sass files. With this new setup, I could just place the files inside the `public` directory, and they would be automatically compiled to CSS.
The only changes I made were removing the `assets` folder and adding a command to the `package.json` to rebuild when something changed (build is still used for the production release).
![Scripts](/static/blog/vue3/scripts.png)
A very lean `package.json`.
I use to embed my apps inside a custom Go server, to have control over configuration, headers, how files are served, easier deployment, and to add some functionality of course. By default, the `build` and `watch` commands will put the compiled files into the `dist` folder, present inside the root directory. The app itself is a subdirectory of the Go server.
![UI](/static/blog/vue3/ui.png)
Before, I just served the whole UI directory, but this time I had to select the directories under `dist` to make it work.
```
server.ServeStaticFiles(router, "/js/", "ui/dist/js")
server.ServeStaticFiles(router, "/css/", "ui/dist/css")
server.ServeStaticFiles(router, "/img/", "ui/dist/img")
server.ServeStaticFiles(router, "/fonts/", "ui/dist/fonts")
router.HandleFunc("/favicon.ico", func(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, "ui/dist/favicon.ico")
})
router.PathPrefix("/").HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, "ui/dist/index.html")
})
```
Note that each sub-directory of `ui/public` will create a directory inside `dist`, so you need to add it to the router in Go. `favicon` and `index.html` are the only special files I have so far. The index is served last, as it needs to be sent no matter what page the visitor is on. If someone visits `yourdomain.com/foo/bar` the server would otherwise try to find an index file inside `foo/bar`.
The Composition API
-------------------
You might have heard about the Composition API already. It's a new way to define the structure and behavior of a component, living alongside the _traditional_ way of defining a component using the object notation. I started out setting the goal to just use the Composition API, as the videos I've seen about it looked very promising. You can still use the traditional way to define your components, but so far, I'm very pleased with it. In case you plan to upgrade from Vue 2, you don't need to re-write everything. But if you start a new project, I would recommend you go ahead and use it right from the beginning.
I fell in love with it when I had to implement multiple dropdowns. Here is an example from Pirsch, when I built the menu and had to add four dropdowns, which are functionality-wise all the same.
![Composition](/static/blog/vue3/composition.jpeg)
An early version of Pirsch's menu.
There is a dropdown for the domain, the resources, the time frame, and on your account. Functionally they're all the same. You click on the menu entry and it opens up. If you click anywhere outside the dropdown, it will close. One way to approach this problem would be to create one component and reuse it everywhere, but in this case, the HTML structure is slightly different. With the new Composition API, you can outsource this problem into its own file and function and just use it inside the components you need it.
```
import {ref, Ref} from "vue";
// This defines which attributes and functions will be available to the component.
interface Dropdown {
dropdownElement: Ref<HTMLElement>
dropdown: Ref<boolean>
toggleDropdown(): void
}
// And this is the re-usable function which will be called from the components.
export function useDropdown(): Dropdown {
const dropdownElement = ref(document.createElement("div"));
const dropdown = ref(false);
function toggleDropdown() {
dropdown.value = !dropdown.value;
}
window.addEventListener("mouseup", e => {
const element = dropdownElement.value;
if(/* ... */) {
dropdown.value = false;
}
});
return {
dropdownElement,
dropdown,
toggleDropdown
};
}
```
As an example, this is the domain selection you can see on the screenshot above.
```
<template>
<div class="selection cursor-pointer" v-on:click="toggleDropdown" ref="dropdownElement">
<span>{{"{{"}}activeDomain.hostname{{"}}"}}</span>
<div class="dropdown" v-show="dropdown">
<div v-for="domain in domains"
:key="domain.id"
v-on:click="switchDomain(domain)">{{"{{"}}domain.hostname{{"}}"}}</div>
</div>
</div>
</template>
<script lang="ts">
import /* ... */;
export default defineComponent({
setup() {
/* ... */
return {
...useDropdown(),
/* ... */
};
}
});
</script>
```
All it takes is to add the function to the return statement of the setup function and boom! You can use the functionality inside the template. I have more examples like this, but I think you get the idea.
Component Structure
-------------------
Another major benefit of the Composition API is, that you can now structure the code the way you want it. A component might take up hundreds of lines, depending on the complexity of your app (which should not happen that easily anymore, thanks to the Composition API) and you had to separate the data, methods, and other parts in a certain way. Editing large components naturally included a lot of scrolling and not seeing the data you were working with inside a method for example. Now, however, you can define the data right above the function you're using it in and mix it up. So instead of having something like this.
```
<template>
<!-- lots of code -->
</template>
<script>
import /* ... */;
export default {
data() {
return {
foo: 42,
/* far away from each other! */
bar: ""
};
},
/* maybe even more code */
methods: {
methodA() {
this.foo++;
},
/* 500 lines of code */
methodB() {
this.bar = "Hello World!";
}
}
}
</script>
```
You can now keep it easier to read.
```
<template>
<!-- lots of code -->
</template>
<script lang="ts">
import /* ... */;
export default defineComponent({
setup() {
const foo = ref(42);
function methodA() {
foo.value++;
}
/* 500 lines of code */
const bar = ref("");
function methodB() {
bar.value = "Hello World!";
}
return {
foo,
methodA,
bar,
methodB
};
}
});
</script>
```
And you might not even need to expose all data to the template. Imagine `foo` just being used internally. You still would have had to define that in `data` to access it. Now, you can just use a regular variable inside `setup`.
Generics With Typescript
------------------------
Another moment I felt pretty good about my choice using TypeScript, was when I had to implement lists. Lists are often used to display data that would otherwise be in a table. They usually consist of "cards" in my apps, showing what it is and some additional fields and buttons to edit or remove them from the list.
![Access](/static/blog/vue3/access.png)
I know this doesn't look very nice at the moment...
As lists are used across the page, I didn't want to re-implement them over and over again. You probably can guess that I used the composition API to implement the behavior, but this time it had to be generic.
TypeScript strength is to... you know... check types. So we want to build a type save reusable function. As you can see above, it needs to support the `User` type, and there is the `Client` type too. To do that, you can make use of generics.
```
interface ListEntry {
id: number
}
interface List<T extends ListEntry> {
/* ... */
}
export function useList<T extends ListEntry>(): List<T> {
const entries = ref<T[]>([]);
const selectedEntry = ref<T>();
/* */
return {
entries,
selectedEntry,
/* ... */
};
}
```
The import part here is the `ListEntry` which defines an interface for all entities in my application. They all have an ID, which is used for the `:key` attribute in Vue and also to add and remove entries from the list. Here is how you would make use of it.
```
setup() {
const {entries, addEntry, removeEntry /* ... */} = useList<User>();
/* ... */
return {
entries,
addEntry,
removeEntry,
/* ... */
};
}
```
Templating
----------
The templating stayed mostly the same, but there are a few changes which made me enjoy Vue 3 even more. One that stood out to me was, that you no longer need to have a root element for all of your components. So defining the template of a component like this is fine.
```
<template>
<h2>Email</h2>
<form v-on:submit.prevent="save">
<FormInput label="Email Address" name="email" type="email" v-model="email" :err="validationError('email')" />
<FormSubmit value="Save" />
</form>
</template>
<script lang="ts">
/* ... */
</script>
```
This might not seem significant at first glance, but it sometimes got annoying in Vue 2 that you had to add a root element artificially to your component, even though it wasn't required for styling nor structure.
Conclusion
----------
There is a lot more I could talk about, like having a linter to keep your code clean, but I think this is enough for now. I might write a follow-up when I have more experience with Vue 3 and TypeScript. I refused to make the switch to what is probably considered a best practice for quite some time. If you're someone like me who needs to know how everything works, even the project setup, make sure you don't waste time doing that and spend it on building something useful instead.
In case you got inspired to try out Vue 3 now, you can read the [introduction](https://v3.vuejs.org/guide/migration/introduction.html#render-function), which shows the major and minor differences between Vue 2 and 3 far better than I could.

BIN
static/blog/vue3/access.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

BIN
static/blog/vue3/setup.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

BIN
static/blog/vue3/ui.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

View File

@@ -0,0 +1,250 @@
**Published on 7. July 2020**
> _This post was originally published on Medium a while ago. I just added it to my blog for completeness._
![Comic](/static/blog/wildcardssl/comic.png)
Image by [CommitStrip](http://www.commitstrip.com/en/2016/06/13/the-end-of-an-expensive-era/).
When building web applications on top of Kubernetes from a certain point on you will want to make them accessible to the public. Having an SSL certificate is crucial for today's web applications to ensure traffic send between your cluster and your clients is encrypted.
In this article, I will show you how to receive a [wildcard SSL certificate](https://en.wikipedia.org/wiki/Wildcard_certificate) from [Lets Encrypt](https://letsencrypt.org/) using a DNS01 challenge, cert-manager, and the [ACME DNS server](https://github.com/joohoi/acme-dns).
I will assume you have a cluster up and running with [Helm](https://helm.sh/) installed, exposing some sort of website or service through Ingress and basic knowledge about how to configure Kubernetes objects and Docker. This guide follows a top to bottom approach, which means we will start setting up Ingress first and add everything required to successfully obtain a certificate. Make sure you read completely through it before you start implementing your own solution.
What is a DNS01 challenge and ACME?
-----------------------------------
DNS01 is a certificate authority (CA) challenge method to prove that you are the owner of a specific domain. While using an HTTP01 challenge only proves that you are the owner of a part of a domain (the top-level domain or a subdomain), a DNS01 challenge looks up your DNS configuration to prove that you control the whole domain. This allows CAs, like Lets Encrypt, to issue wildcard certificates that are valid for all subdomains and your top-level domain. This is especially useful if subdomains are dynamically created like for team or project names in SaaS projects.
ACME is the Automatic Certificate Management Environment protocol and does exactly what it says. It significantly simplifies the process of obtaining SSL certificates and was specifically designed for Lets Encrypt. It exchanges JSON objects through HTTPS to validate a certificate request.
Kubernetes Ingress SSL certificate setup
----------------------------------------
We start simply by instructing Ingress to consume a secret that contains the certificate we will provide later on. To achieve that, we need to modify its YAML:
```
apiVersion: extensions/v1beta1
kind: Ingress
metadata:
name: my-ingress
annotations:
kubernetes.io/ingress.class: "nginx"
nginx.ingress.kubernetes.io/rewrite-target: /
nginx.ingress.kubernetes.io/ssl-redirect: "true"
nginx.ingress.kubernetes.io/force-ssl-redirect: "true"
spec:
tls:
- hosts:
- example.com
secretName: ingress-certificate-secret
rules:
# ...
```
By adding the _tls_ section we specified a secret to use for _example.com_. The secret does not exist yet, but will contain our certificates private and public key once it was obtained by cert-manager. The _ssl-redirect_ and _force-ssl-redirect_ annotations instruct Ingress to enforce SSL encryption on all requests. When clients try to reach your service or website through HTTP they will be redirected to HTTPS.
Setting up cert-manager
-----------------------
cert-manager is an add-on to automate the management of SSL certificates inside a Kubernetes cluster. It obtains certificates using various sources and ensures they stay valid. When a certificate gets close to its end of life, cert-manager will try to renew it. This is important since Lets Encrypt certificates are only valid for up to three months.
To get started with cert-manager we install it first. The easiest way to do that is through Helm (version 0.6 in this example, make sure you install the latest stable version):
```
kubectl apply -f https://raw.githubusercontent.com/jetstack/cert-manager/release-0.6/deploy/manifests/00-crds.yaml
helm install --name cert-manager --version v0.6.0 stable/cert-manager
```
The first command creates a few new Kubernetes custom resources, including _Issuer_ and _Certificate_, which we will configure in a moment.
The second command installs cert-manager and spins up a new pod responsible for issuing certificates and renewing them if required. You can install it for a specific namespace only by providing the namespace argument to Helm.
Next, we create a _Certificate_ object:
```
apiVersion: certmanager.k8s.io/v1alpha1
kind: Certificate
metadata:
name: my-certificate
spec:
secretName: ingress-certificate-secret
issuerRef:
name: issuer-letsencrypt
commonName: example.com
dnsNames:
- example.com
- "*.example.com"
acme:
config:
- dns01:
provider: acmedns
domains:
- example.com
- "*.example.com"
```
The kind is _Certificate_, one of cert-managers custom types we applied before. _secretName_ tells cert-manager the name of the secret to create when the certificate was successfully obtained. This must be identical to the secret name we configured for Ingress. We do not need to create it manually. _issuerRef_ references the _Issuer_ used to get the certificate which we will set up next. _commonName_ represents the server name protected by our SSL certificate and must be equal to the domain or else browsers will complain about it. _dnsNames_ is a list of domains the certificate is valid for. Since we are trying to get a wildcard certificate, the second entry contains an asterisk to mark it valid for all subdomains. The part in the _acme_ section tells cert-manager which challenge type and provider to use. cert-manager supports HTTP01 and DNS01 challenge types as well as a [bunch of different providers](https://cert-manager.readthedocs.io/en/latest/tasks/acme/index.html), including ACME DNS which we use in this article.
To set up the issuer talking to our ACME DNS server we create another object of kind _Issuer_:
```
apiVersion: certmanager.k8s.io/v1alpha1
kind: Issuer
metadata:
name: issuer-letsencrypt
spec:
acme:
server: https://acme-v02.api.letsencrypt.org/directory
email: yourname@example.com
privateKeySecretRef:
name: account-private-key-secret
dns01:
providers:
- name: acmedns
acmedns:
host: https://acme.example.com
accountSecretRef:
name: certmanager-secret
key: acmedns.json
```
The acme section tells cert-manager that this is an ACME issuer. The server, email and privateKeySecretRef are used when the issuer contacts Lets Encrypt to issue a certificate. If you like to check if that works before you get a production certificate, change the server URL to `https://acme-staging-v02.api.letsencrypt.org/directory` or else you might run into Lets Encrypts certificate quota. The privateKeySecretRef will be created by cert-manager and stores your Lets Encrypt private account key. The email meight be used by Lets Encrypt to contact you or send warnings when your certificate is about to expire.
The _dns01_ section describes which ACME server the issuer will contact. This is our private ACME DNS server we will set up next. Set _host_ to the domain pointing to your ACME DNS server. I recommend configuring a custom subdomain in your DNS settings, but you can use a static IP address as well. The _accountSecretRef_ is a Kubernetes secret and must be created manually. It must contain a JSON file with account information for the ACME server. In our case it looks like this:
```
{
"example.com": {
"username": "<username>",
"password": "<password>",
"fulldomain": "<generated>.acme.example.com",
"subdomain": "<generated>",
"allowfrom": []
},
"*.example.com": {
"username": "<username>",
"password": "<password>",
"fulldomain": "<generated>.acme.example.com",
"subdomain": "<generated>",
"allowfrom": []
}
}
```
As you can see it contains the domains we have specified, a username and password as well as two generated values for each entry. Usually, ACME DNS is an interactive system where you sign up and receive the CNAME information you need to create in your DNS settings via its REST API. Since cert-manager handles that for us, it needs to know the account information in advance. The required data will be provided by the ACME DNS server after we have set it up and registered an account. Once we have done that, you can put the JSON file into a new secret called _certmanager-secret_ with _acmedns.json_ as its key.
Setting up the ACME DNS server
------------------------------
We use Docker and Compose to set up our ACME DNS server. For this purpose, create a new virtual machine outside of the cluster and install Docker and Compose. It is necessary to allow incoming DNS requests on port 53, which would collide with Kubernete's own DNS service inside the cluster. Make sure you add a firewall rule to allow incoming traffic to reach your server too. Depending on your VM installation, port 53 might be blocked by systemd-resolved. In that case, change the listening interface in the [server configuration](https://github.com/joohoi/acme-dns#configuration) or disable port 53 for systemd-resolved (add line _DNSStubListener=no_ to _/etc/systemd/resolved.conf_). If you like to use Postgres instead of sqlite3, set it up on your VM or elsewhere or use an existing installation and make sure you can connect to it from your VM (I use a managed solution on Google Cloud).
To start ACME DNS through Compose, create a docker-compose.yml somewhere on your VM:
```
version: '3'
services:
acmedns:
restart: always
image: joohoi/acme-dns
ports:
- "443:443"
- "127.0.0.1:53:53"
- "127.0.0.1:53:53/udp"
- "80:80"
volumes:
- /acmedns:/etc/acme-dns:ro
```
This pulls the latest ACME DNS image and starts listening on port 443/80 for incoming request to the API, as well as port 53 for incoming TXT DNS requests required to fulfil DNS01 challenges. Note that we added a volume in read only mode to configure ACME DNS. Copy the configuration template from the [GitHub repository](https://github.com/joohoi/acme-dns) and edit it to fit your needs. It must be placed inside the _/acmedns_ directory on your server.
Here are some changes I made for the installation:
```
# listen to incoming traffic instead of localhost
listen = "0.0.0.0:53"
# the (sub-)domain we use for our server as well as the zone name
domain = "acme.example.com"
nsname = "acme.example.com"
# A record pointing to your VM as well as an
# NS record specifying that the server is responsible for all subdomains (e.g. foobar.acme.example.com)
records = [
"acme.exmaple.com. A <static IP of your VM>",
"acme.exmaple.com. NS acme.example.com.",
]
# listen on port 443 for encrypted API requests
port = "443"
# obtain a certificate from Let's Encrypt
tls = "letsencrypt"
```
Setting _tls_ to _letsencrypt_ ensures our ACME DNS server issues its own SSL certificate for the REST API so that cert-manager does not obtain certificates from an unsecure source.
Now that we have our server up and running, we create an account and get the information required for the _acmedns.json_ we have created earlier (use curl, Postman, … you name it):
```
POST acme.example.com/register
```
The response looks something like this:
```
{
"allowfrom": [],
"fulldomain": "8e5700ea-a4bf-41c7-8a77-e990661dcc6a.acme.example.com",
"password": "htB9mR9DYgcu9bX_afHF62erXaH2TS7bg9KW3F7Z",
"subdomain": "8e5700ea-a4bf-41c7-8a77-e990661dcc6a",
"username": "c36f50e8-4632-44f0-83fe-e070fef28a10"
}
```
Extract the values required to fill out the blanks we have left in the _acmedns.json_ and create the secret for it.
Configuring your DNS
--------------------
To allow Lets Encrypt to validate DNS01 challenges against our ACME DNS server we have to make some changes to our DNS configuration. For the ACME DNS server we create two new entries:
```
A auth <server-ip>
NS auth.example.com auth.example.com
```
This ensures DNS name resolution requests are handled by our ACME DNS server and marks it responsible for all subdomains of _auth.example.com_. When Lets Encrypt tries to validate that we are the owner of _example.com_, it will look up a TXT record for that domain. To make sure it will look for it on our ACME DNS server rather than our primary DNS server we create a CNAME record pointing to it:
```
CNAME _acme-challenge 312ecaf7-3ae5-40f3-8559-393e73659a96.auth.example.com.
```
Testing
-------
You should now have a nice green lock in your browser bar when visiting your website or service.
In case you dont, check the logs of your ACME DNS server, that it serves the DNS TXT record (use one of the online tools) and the status of the _Ingress_, _Issuer_ and _Certificate_ Kubernetes objects we have created. You can use the Docker and Kubernetes log command and inspect the created objects. For example, this is what the _Certificate_ and _Issuer_ objects should look like:
```
# check the certificate got issued and is not expired
kubectl describe certificate my-certificate
# output:
Status:
Conditions:
Last Transition Time: 2019-03-07T15:18:29Z
Message: Certificate is up to date and has not expired
Reason: Ready
Status: True
Type: Ready
# check the issuer can connect to ACME DNS and the account was registered
kubectl describe issuer issuer-letsencrypt
# output:
Status:
Acme:
Uri: https://acme-v02.api.letsencrypt.org/acme/acct/50103972
Conditions:
Last Transition Time: 2019-01-26T13:32:59Z
Message: The ACME account was registered with the ACME server
Reason: ACMEAccountRegistered
Status: True
Type: Ready
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 456 KiB

View File

@@ -1,281 +0,0 @@
/*! concrete.css v1.3.0 | MIT License | github.com/louismerlin/concrete.css/ */
/* The Basics */
html {
font-size: 62.5%;
box-sizing: border-box;
}
body {
font-size: 2rem;
font-weight: 400;
background: white;
color: #121212;
font-family: Helvetica, Arial, sans-serif;
}
*, ::after, ::before {
box-sizing: inherit;
}
a {
color: #121212;
}
blockquote, dl, figure, form, ol, p, pre, table, ul {
margin-bottom: 2.2rem;
}
img {
height: auto;
max-width: 100%;
}
/* A Cool Container */
.container {
margin: 0 auto;
max-width: 66rem;
padding: 0 1rem;
width: 100%;
position: relative;
}
/* The Button */
.button {
text-decoration: none;
}
button,
.button,
input[type="button"],
input[type="reset"],
input[type="submit"] {
display: inline-block;
vertical-align: middle;
border-radius: 0;
background: white;
line-height: 2.2rem;
font-size: 1.6rem;
color: #121212;
border: 0.3rem solid #121212;
padding: 0.4rem 1rem;
cursor: pointer;
}
button.filled,
.button.filled,
input[type="button"].filled,
input[type="reset"].filled,
input[type="submit"].filled {
color: white;
background: #121212;
}
.button,
button,
dd,
dt,
li,
input[type="button"],
input[type="reset"],
input[type="submit"] {
margin-bottom: 1.0rem;
}
/* The List */
ul {
list-style: square;
}
/* The Form */
fieldset {
border-width: 0;
padding: 0;
}
label, legend {
display: block;
font-weight: bold;
margin-bottom: .5rem;
}
input[type="email"],
input[type="number"],
input[type="password"],
input[type="search"],
input[type="tel"],
input[type="text"],
input[type="url"],
textarea,
select {
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
background-color: transparent;
border: 0.1rem solid #121212;
border-radius: 0;
box-shadow: none;
box-sizing: inherit;
padding: .6rem 1.0rem;
width: 100%;
}
fieldset, input, select, textarea {
margin-bottom: 1.5rem;
}
/* The Table */
table {
width: 100%;
border-spacing: 0;
}
td, th {
padding: 0.6rem 0;
}
td {
border-bottom: 0.1rem solid #121212;
}
th {
border-bottom: 0.3rem solid #121212;
text-align: left;
}
/* The Blockquote and the Code */
blockquote, pre {
border-left: 0.3rem solid #121212;
margin-left: 0;
margin-right: 0;
padding: 1rem 1.5rem;
overflow-y: hidden;
}
pre {
border: 0.1rem dotted #121212;
border-left: 0.3rem solid #121212;
}
pre > code {
padding: 1rem 1.4rem;
font-size: 1.6rem;
white-space: pre;
display: block;
}
/* The Progress Bar */
progress {
-moz-appearance: none;
-webkit-appearance: none;
border-radius: 0;
display: block;
height: 1rem;
border: 0.1rem solid #121212;
background: white;
color: #121212;
overflow: hidden;
padding: 0;
width: 100%;
}
progress::-webkit-progress-bar {
background-color: white;
}
progress::-webkit-progress-value {
background-color: #121212;
}
progress::-moz-progress-bar {
background-color: #121212;
}
progress::-ms-fill {
background-color: #121212;
}
/* The Break Line */
hr {
border: 0.2rem solid #121212;
border-bottom-width: 0.1rem;
}
/* Dark Mode */
@media (prefers-color-scheme: dark) {
body {
background: #121212;
color: white;
}
a {
color: white;
}
button,
.button,
input[type="button"],
input[type="reset"],
input[type="submit"] {
background: #121212;
color: white;
border-color: white;
}
button.filled,
.button.filled,
input[type="button"].filled,
input[type="reset"].filled,
input[type="submit"].filled {
color: #121212;
background: white;
}
input[type="email"],
input[type="number"],
input[type="password"],
input[type="search"],
input[type="tel"],
input[type="text"],
input[type="url"],
textarea,
select {
color: white;
border-color: white;
}
td {
border-bottom-color: white;
}
th {
border-bottom-color: white;
}
blockquote, pre {
border-left-color: white;
}
pre {
border-color: white;
border-left-color: white;
}
progress {
border-color: white;
background: #121212;
color: white;
}
progress::-webkit-progress-bar {
background-color: #121212;
}
progress::-webkit-progress-value {
background-color: white;
}
progress::-moz-progress-bar {
background-color: white;
}
progress::-ms-fill {
background-color: white;
}
hr {
border-color: white;
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

View File

@@ -1,9 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<browserconfig>
<msapplication>
<tile>
<square150x150logo src="/mstile-150x150.png"/>
<TileColor>#da532c</TileColor>
</tile>
</msapplication>
</browserconfig>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 567 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.8 KiB

View File

@@ -1,218 +0,0 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
width="460.000000pt" height="460.000000pt" viewBox="0 0 460.000000 460.000000"
preserveAspectRatio="xMidYMid meet">
<metadata>
Created by potrace 1.11, written by Peter Selinger 2001-2013
</metadata>
<g transform="translate(0.000000,460.000000) scale(0.100000,-0.100000)"
fill="#000000" stroke="none">
<path d="M2000 4199 c-80 -8 -163 -31 -185 -50 -5 -5 0 -9 14 -9 36 0 14 -18
-32 -25 -23 -4 -59 -18 -79 -31 -21 -13 -42 -24 -47 -24 -6 0 -16 -9 -24 -20
-7 -10 -32 -23 -56 -29 -48 -10 -91 -45 -91 -72 0 -10 -9 -21 -20 -24 -11 -3
-20 -15 -20 -26 0 -10 -6 -19 -13 -19 -15 0 -65 -40 -51 -40 5 0 -8 -11 -28
-23 l-37 -23 19 -29 c13 -20 16 -34 9 -46 -5 -10 -9 -27 -9 -38 0 -12 -4 -21
-10 -21 -5 0 -10 -7 -10 -17 0 -15 1 -15 18 0 10 10 24 17 31 17 17 0 -9 -46
-53 -93 -20 -21 -37 -48 -37 -60 -1 -12 -2 -33 -3 -47 -1 -14 -7 -25 -13 -25
-7 0 -13 -4 -12 -9 0 -5 -3 -12 -8 -15 -5 -3 -19 -23 -32 -43 -29 -49 -17 -70
15 -27 25 34 32 33 14 -2 -8 -13 -7 -19 0 -19 7 0 3 -12 -7 -27 -28 -41 -44
-83 -33 -83 9 0 4 -145 -5 -161 -13 -20 30 -293 45 -284 3 2 6 -15 7 -38 1
-23 4 -82 5 -132 2 -49 7 -97 10 -105 3 -8 14 -44 23 -80 19 -70 30 -90 51
-90 11 0 14 17 14 70 0 79 20 210 32 210 4 0 8 -5 8 -11 0 -27 75 -168 107
-201 90 -92 268 -126 391 -74 34 15 83 42 108 61 25 19 50 33 55 29 5 -3 9 0
9 7 0 7 3 10 6 6 8 -8 42 23 64 58 43 71 91 271 72 302 -4 6 -18 14 -31 18
-26 8 -81 59 -81 75 0 6 -5 10 -12 10 -6 0 -9 2 -6 5 7 7 -58 75 -72 75 -6 0
-22 7 -35 16 -14 9 -51 20 -82 24 -32 5 -99 14 -148 21 -101 14 -141 4 -160
-39 -5 -12 -17 -22 -27 -22 -25 0 -49 -11 -43 -20 3 -5 15 -6 28 -3 19 4 18 2
-5 -12 -16 -8 -28 -20 -28 -25 0 -6 -7 -10 -16 -10 -8 0 -13 4 -9 9 3 5 1 12
-4 15 -5 4 -8 -2 -7 -11 3 -19 -28 -42 -56 -43 -10 0 -18 6 -18 13 -4 122 -2
238 3 244 12 11 8 23 -7 23 -19 0 -29 30 -15 47 7 7 9 16 5 20 -3 3 2 15 11
25 14 15 15 21 4 27 -10 7 -10 13 -2 29 9 16 8 24 -4 36 -8 8 -11 17 -6 20 8
6 25 64 22 79 -1 5 2 6 7 3 4 -2 13 4 20 15 7 10 8 19 3 19 -6 0 -3 15 6 33
77 154 59 139 173 141 74 2 122 20 143 53 5 8 12 11 18 8 5 -4 9 -2 9 3 0 5
14 9 32 10 18 0 50 8 73 18 92 38 112 45 163 54 30 5 65 19 78 29 14 11 32 17
40 14 8 -3 14 -1 14 4 0 10 57 18 201 29 37 2 77 7 90 10 46 11 285 13 334 3
28 -6 61 -12 75 -15 93 -19 219 -123 268 -221 9 -18 19 -33 23 -33 4 0 10 -11
13 -24 11 -43 106 -121 106 -86 0 6 -4 10 -10 10 -15 0 -12 40 3 41 6 1 22 0
34 0 12 -1 26 5 31 15 12 22 4 54 -14 54 -9 0 -14 11 -14 30 0 17 -6 33 -14
36 -8 3 -17 16 -20 29 -3 13 -18 30 -34 37 -16 8 -26 21 -25 32 1 13 -2 15
-10 7 -18 -18 -27 -13 -27 15 0 14 -4 23 -9 19 -5 -3 -27 15 -48 40 -20 25
-43 43 -50 41 -6 -3 -27 13 -45 34 -18 21 -49 57 -68 79 -19 23 -39 41 -44 41
-5 0 -20 10 -33 22 -19 17 -23 19 -17 5 9 -24 -3 -21 -38 8 -43 36 -132 54
-371 75 -163 14 -292 32 -297 41 -9 14 -89 17 -180 8z m319 -51 c11 -11 12
-10 6 0 -6 11 -3 12 11 4 11 -5 47 -12 82 -16 35 -4 73 -11 85 -16 18 -7 14
-9 -20 -9 -30 -1 -43 -5 -43 -15 0 -8 -6 -17 -12 -19 -7 -3 25 -6 72 -7 141
-2 186 -10 141 -25 -12 -3 -21 -14 -20 -23 0 -11 3 -12 6 -4 7 17 23 15 23 -2
0 -10 -16 -17 -47 -21 -27 -3 -82 -10 -124 -16 -139 -20 -288 3 -264 40 6 11
-4 14 -35 12 -10 0 -11 1 -2 5 6 3 10 10 6 15 -4 8 69 15 117 11 12 -1 16 6
15 23 -1 23 -16 37 -16 15 0 -5 -7 -10 -15 -10 -8 0 -15 6 -15 14 0 16 -80 33
-99 21 -6 -3 -11 -2 -11 2 0 5 -12 8 -27 7 -16 0 -37 2 -48 5 -11 3 25 9 80
12 55 3 109 6 121 7 11 1 26 -4 33 -10z m291 -44 c0 -2 -9 -4 -20 -4 -11 0
-20 4 -20 9 0 5 9 7 20 4 11 -3 20 -7 20 -9z m-362 -1 c-10 -2 -26 -2 -35 0
-10 3 -2 5 17 5 19 0 27 -2 18 -5z m672 -88 c0 -5 -5 -3 -10 5 -5 8 -10 20
-10 25 0 6 5 3 10 -5 5 -8 10 -19 10 -25z m-230 5 c0 -5 -7 -10 -15 -10 -8 0
-15 5 -15 10 0 6 7 10 15 10 8 0 15 -4 15 -10z m-933 -1134 c-7 -17 22 -25
108 -33 76 -6 88 -17 40 -38 -31 -12 -40 -13 -47 -3 -4 8 -8 8 -8 2 0 -6 -36
-16 -81 -23 -65 -10 -86 -10 -110 1 -16 7 -29 16 -29 20 0 15 43 46 80 57 22
6 40 16 40 21 0 6 3 10 6 10 3 0 4 -6 1 -14z m-273 -72 l-49 -46 -3 -81 c-2
-45 1 -93 7 -107 9 -24 10 -24 11 10 5 81 11 106 32 122 22 16 23 15 45 -24
13 -22 23 -49 23 -59 0 -11 8 -20 18 -21 9 0 24 -1 32 -2 8 -1 16 -9 18 -19 2
-9 8 -17 14 -17 6 0 23 -14 38 -30 24 -26 34 -30 85 -31 45 -1 59 -5 62 -19 6
-22 140 -55 190 -45 l35 6 -21 -29 c-59 -84 -148 -126 -266 -126 -104 0 -168
25 -235 93 -67 68 -101 145 -108 249 -6 90 14 149 66 192 56 46 60 36 6 -16z
m-217 -36 c-3 -8 -6 -5 -6 6 -1 11 2 17 5 13 3 -3 4 -12 1 -19z m620 6 c12 -3
24 -12 27 -19 4 -11 -11 -14 -72 -12 -42 0 -88 -3 -102 -7 -22 -8 -23 -7 -11
8 23 28 106 44 158 30z m23 -114 c0 -5 -4 -10 -10 -10 -5 0 -10 5 -10 10 0 6
5 10 10 10 6 0 10 -4 10 -10z"/>
<path d="M2448 4193 c7 -3 16 -2 19 1 4 3 -2 6 -13 5 -11 0 -14 -3 -6 -6z"/>
<path d="M2523 4184 c3 -3 33 -12 67 -19 70 -15 72 -7 2 12 -52 13 -79 16 -69
7z"/>
<path d="M2674 4146 c23 -18 36 -20 36 -6 0 6 -4 10 -9 10 -5 0 -18 3 -28 6
-16 5 -16 4 1 -10z"/>
<path d="M2702 2976 c-19 -5 -40 -21 -50 -38 -9 -15 -24 -28 -32 -28 -9 0 -29
-13 -45 -30 -16 -16 -33 -30 -38 -30 -5 0 -37 -21 -70 -46 -50 -37 -65 -44
-86 -38 -34 10 -101 11 -101 2 0 -5 1 -8 3 -9 1 0 25 -6 52 -13 36 -9 53 -20
62 -37 14 -31 23 -109 12 -109 -5 0 -9 5 -9 12 0 6 -3 9 -6 5 -3 -3 -3 -19 1
-36 4 -21 13 -31 25 -31 10 0 22 -9 25 -20 9 -28 71 -119 107 -157 17 -17 58
-44 92 -59 52 -24 74 -29 146 -29 47 0 105 6 129 13 66 20 43 25 -30 6 -116
-30 -270 8 -341 83 -30 32 -103 171 -94 180 3 4 6 0 6 -7 0 -11 3 -11 11 -3
15 15 3 47 -16 40 -20 -8 -31 74 -16 124 30 101 200 177 411 184 91 3 112 1
159 -18 121 -49 167 -141 143 -284 -6 -37 -10 -68 -8 -70 2 -2 15 41 29 94 17
61 34 103 46 113 11 8 21 30 23 50 3 35 3 35 -57 51 -68 18 -151 54 -160 69
-3 5 -15 10 -25 10 -10 0 -25 7 -34 15 -8 8 -23 15 -33 16 -10 0 -13 3 -5 6 6
2 12 8 12 13 0 11 -49 19 -55 9 -5 -7 -23 -6 -125 4 -14 2 -40 -1 -58 -7z"/>
<path d="M3294 2929 c2 -25 9 -56 16 -70 13 -23 12 -24 -7 -6 -20 17 -20 16
-22 -40 -1 -36 -7 -60 -15 -65 -11 -6 -11 -17 1 -65 8 -32 14 -69 14 -83 0
-21 16 -131 34 -242 2 -15 1 -29 -4 -32 -8 -5 -13 -96 -6 -96 9 0 55 52 55 61
0 6 -4 8 -9 5 -4 -3 0 25 11 62 21 73 23 117 7 205 -11 61 -6 82 17 73 8 -3
14 1 15 12 1 9 5 4 10 -13 8 -30 8 -26 -4 65 -1 8 -7 24 -14 35 -6 11 -12 25
-13 30 -2 6 -4 15 -5 20 -12 40 -12 53 0 49 7 -3 14 5 17 20 3 14 1 23 -3 20
-5 -3 -9 1 -9 9 0 8 -12 23 -27 33 -15 9 -35 27 -45 38 -16 20 -17 20 -14 -25z
m50 -181 c-12 -19 -44 13 -44 44 l0 32 25 -34 c13 -18 22 -37 19 -42z"/>
<path d="M2640 2794 c-19 -7 -43 -13 -53 -13 -10 -1 -20 -8 -23 -16 -4 -8 -13
-15 -21 -15 -8 0 -11 -4 -8 -10 3 -5 2 -10 -2 -10 -5 0 -9 -16 -9 -35 0 -32 6
-40 28 -36 8 1 -4 -36 -22 -70 -19 -35 13 -52 118 -63 29 -3 32 -1 32 25 0 27
1 28 49 24 46 -4 51 -2 80 31 17 19 31 45 31 58 0 12 3 26 6 29 11 10 64 -26
64 -43 0 -11 11 -15 40 -16 47 -2 51 9 13 33 -16 10 -36 25 -46 34 -17 16
-102 40 -147 43 -27 1 -53 36 -32 43 6 3 12 9 12 14 0 13 -69 9 -110 -7z m13
-131 c2 -10 8 -26 12 -35 5 -14 2 -18 -13 -18 -27 0 -35 15 -28 46 7 28 22 32
29 7z m151 -4 c-8 -14 -24 -10 -24 6 0 9 6 12 15 9 8 -4 12 -10 9 -15z m-137
-101 c-3 -8 -6 -5 -6 6 -1 11 2 17 5 13 3 -3 4 -12 1 -19z"/>
<path d="M3502 2556 c-1 -14 2 -26 7 -26 5 0 8 -11 7 -24 -2 -14 1 -30 6 -38
5 -8 8 5 6 35 -2 55 -22 96 -26 53z"/>
<path d="M1225 2520 c-8 -14 4 -110 14 -110 12 0 13 108 2 114 -5 4 -13 2 -16
-4z"/>
<path d="M3115 2471 c-7 -23 -88 -109 -125 -133 -21 -14 -22 -16 -4 -12 42 11
158 143 141 161 -3 2 -8 -5 -12 -16z"/>
<path d="M1304 2298 c3 -10 1 -18 -4 -18 -6 0 -10 -9 -10 -20 0 -13 7 -20 19
-20 23 0 36 -16 21 -25 -12 -7 -4 -25 12 -25 15 0 3 52 -23 92 -11 18 -19 25
-15 16z"/>
<path d="M1955 2130 c3 -5 11 -10 16 -10 6 0 7 5 4 10 -3 6 -11 10 -16 10 -6
0 -7 -4 -4 -10z"/>
<path d="M2063 2104 c-7 -3 -13 -14 -13 -25 0 -10 -4 -19 -10 -19 -20 0 -9
-21 18 -33 15 -6 31 -17 35 -24 12 -19 30 -16 45 7 7 11 21 20 32 20 26 0 25
13 -4 45 -24 25 -77 40 -103 29z"/>
<path d="M2416 2081 c-3 -5 13 -11 37 -13 40 -5 55 -3 46 6 -11 9 -78 16 -83
7z"/>
<path d="M2033 1926 c4 -10 7 -20 7 -22 0 -2 7 -4 15 -4 8 0 15 -4 15 -10 0
-5 5 -10 11 -10 16 0 6 45 -11 45 -8 0 -21 5 -29 9 -11 7 -13 5 -8 -8z"/>
<path d="M1840 1840 c0 -19 3 -21 12 -12 9 9 9 15 0 24 -9 9 -12 7 -12 -12z"/>
<path d="M1790 1835 c-10 -12 -10 -15 4 -15 9 0 16 7 16 15 0 8 -2 15 -4 15
-2 0 -9 -7 -16 -15z"/>
<path d="M1895 1840 c-3 -5 3 -10 15 -10 12 0 18 5 15 10 -3 6 -10 10 -15 10
-5 0 -12 -4 -15 -10z"/>
<path d="M2110 1782 c0 -5 -6 -9 -12 -9 -7 0 -37 -5 -67 -12 -29 -6 -72 -9
-96 -5 -33 4 -44 2 -49 -11 -3 -10 -11 -13 -18 -9 -12 7 25 -38 65 -79 15 -16
21 -15 18 5 0 3 12 0 27 -7 35 -18 131 -35 137 -25 3 5 14 6 25 3 15 -4 20 0
20 14 0 13 8 19 25 21 14 1 25 -3 25 -8 0 -7 8 -6 23 1 12 7 51 14 87 15 36 2
101 5 145 8 48 2 82 0 86 -6 8 -12 119 0 119 13 0 5 -8 9 -17 10 -16 0 -43 5
-70 10 -4 1 -16 -1 -27 -6 -10 -4 -60 -3 -110 4 -51 6 -99 9 -108 6 -21 -8
-90 24 -81 38 3 6 1 7 -6 3 -6 -4 -18 0 -27 8 -9 9 -19 14 -24 11 -5 -3 -11
-1 -15 5 -7 12 -75 14 -75 2z"/>
<path d="M1573 1643 c-21 -8 -15 -30 10 -37 21 -7 21 -7 2 -15 -27 -11 -45 -3
-38 17 5 13 3 14 -7 5 -7 -6 -19 -9 -26 -6 -19 7 -18 -5 2 -24 9 -10 13 -25
10 -39 -3 -12 -2 -26 4 -29 5 -3 10 -13 10 -21 0 -15 40 -106 50 -114 4 -3 15
-21 26 -40 10 -19 21 -37 25 -40 16 -15 77 -125 70 -128 -11 -4 -1 -45 13 -54
5 -4 12 -22 14 -40 1 -18 12 -40 23 -50 22 -19 6 -31 -23 -17 -13 7 -15 5 -11
-11 6 -22 11 -24 32 -11 9 6 19 4 28 -4 15 -16 17 -45 3 -45 -7 0 -7 -6 0 -20
14 -26 34 -26 27 0 -3 11 -1 18 4 14 5 -3 9 -11 9 -18 0 -7 11 -23 25 -36 14
-13 25 -20 25 -16 0 4 10 1 23 -6 l22 -12 -25 -6 -25 -6 27 -6 c15 -3 25 -1
22 4 -3 5 23 6 62 2 50 -5 65 -4 60 4 -10 16 28 16 57 0 14 -7 72 -11 157 -11
164 1 195 5 246 32 21 12 43 21 47 21 12 0 81 32 87 41 3 3 19 15 35 25 17 11
51 41 75 68 25 27 53 57 63 67 9 11 17 23 17 27 0 5 12 19 26 33 38 36 65 84
58 104 -6 14 -3 16 14 11 17 -6 21 -4 16 8 -4 10 -1 16 7 16 7 0 10 3 7 6 -4
3 -18 -2 -32 -12 -25 -16 -28 -16 -46 1 -11 10 -23 15 -27 12 -3 -4 -1 -7 5
-7 6 0 16 -9 22 -20 9 -17 8 -19 -7 -13 -10 4 -29 17 -42 29 l-25 23 -11 -22
c-8 -17 -21 -23 -53 -25 -23 -2 -42 -8 -42 -13 0 -6 4 -8 9 -4 5 3 12 1 15 -4
11 -17 -10 -21 -30 -6 -26 19 -34 19 -28 -2 5 -17 5 -17 -9 0 -17 20 -35 23
-33 5 0 -7 -3 -13 -9 -13 -5 0 -10 8 -9 18 1 23 -17 22 -23 -3 -3 -11 -9 -17
-14 -14 -5 3 -9 -2 -9 -10 0 -9 9 -21 20 -28 23 -15 27 -40 5 -32 -8 4 -15 2
-15 -4 0 -6 -13 -24 -29 -40 -18 -17 -26 -33 -21 -38 5 -5 -4 -9 -23 -9 -24 0
-38 -7 -54 -29 -13 -17 -23 -36 -23 -44 0 -8 -5 -10 -12 -5 -7 4 -34 6 -61 5
-31 -1 -49 2 -51 10 -2 7 -7 22 -12 33 -6 18 -8 18 -11 3 -6 -24 -25 -23 -53
2 -16 14 -26 17 -30 9 -4 -6 -10 4 -14 23 -5 26 -9 30 -16 18 -8 -13 -10 -12
-10 3 0 9 -6 17 -12 18 -7 0 -28 20 -46 45 -26 36 -30 49 -22 64 5 10 10 24
10 30 0 15 -43 30 -51 18 -3 -5 -9 -3 -12 6 -5 11 0 16 14 16 29 0 16 15 -36
39 -45 21 -55 37 -34 58 13 13 -23 53 -63 71 -27 11 -32 20 -29 52 1 11 -9 15
-40 14 -25 0 -43 4 -46 13 -3 7 -9 10 -14 7 -6 -3 -17 0 -26 8 -12 9 -15 9 -9
1 4 -7 2 -13 -4 -13 -6 0 -8 14 -4 39 6 39 6 40 -40 62 -45 21 -48 21 -60 4
-12 -16 -13 -16 -19 -1 -7 16 -13 18 -34 9z m10 -87 c4 -10 1 -13 -8 -9 -8 3
-12 9 -9 14 7 12 11 11 17 -5z m37 -22 c0 -14 -4 -23 -9 -20 -5 3 -7 15 -4 26
7 28 13 25 13 -6z m1206 -299 c2 -10 5 -29 9 -42 3 -13 1 -23 -4 -23 -13 0
-17 8 -25 54 -5 28 -4 37 5 34 7 -2 13 -13 15 -23z m57 -15 c3 6 4 5 3 -2 -4
-18 -28 -16 -34 2 -4 13 -3 13 10 2 11 -9 18 -9 21 -2z m-242 -48 c-7 -2 -18
1 -23 6 -8 8 -4 9 13 5 13 -4 18 -8 10 -11z"/>
<path d="M2985 1340 c3 -6 -1 -13 -10 -16 -12 -5 -13 -9 -4 -14 6 -5 17 -3 24
3 13 13 9 37 -7 37 -5 0 -6 -5 -3 -10z"/>
<path d="M3117 1283 c-3 -5 -7 -35 -11 -68 -4 -33 -9 -82 -12 -108 l-6 -49 30
16 c65 34 201 7 236 -46 8 -13 13 -38 11 -58 -2 -19 -4 -41 -4 -47 -1 -7 -12
-13 -25 -13 -15 0 -26 -7 -29 -17 -4 -14 -5 -12 -6 5 -1 14 5 22 15 22 9 0 14
6 11 13 -2 6 -11 11 -21 9 -9 -2 -16 4 -16 13 0 18 -16 20 -24 3 -3 -7 -5 -1
-3 14 3 17 -4 36 -20 54 -12 15 -23 24 -23 19 0 -4 -6 -3 -12 1 -7 5 -35 9
-63 9 l-50 0 -9 -83 c-6 -53 -5 -100 1 -127 11 -47 9 -154 -2 -200 -15 -57
-78 -151 -149 -220 -99 -98 -201 -187 -234 -205 -15 -8 -41 -24 -57 -36 -42
-28 -150 -79 -212 -99 -38 -12 -70 -14 -125 -10 -83 7 -186 32 -208 50 -8 7
-28 13 -45 13 -16 0 -43 6 -60 13 -27 11 -54 17 -96 22 -8 1 -23 10 -33 20
-10 9 -21 17 -26 17 -14 0 -148 138 -155 159 -3 11 -15 23 -26 26 -17 6 -26
35 -20 68 1 4 -6 10 -14 13 -8 4 -15 12 -15 20 0 8 -5 14 -11 14 -6 0 -9 7 -5
15 3 8 1 15 -4 15 -6 0 -10 6 -10 14 0 8 -5 16 -12 18 -6 2 -13 15 -14 29 -2
13 -9 53 -15 89 -8 44 -10 129 -5 273 4 141 3 206 -4 202 -5 -3 -10 -12 -10
-19 0 -8 -6 -19 -13 -25 -14 -11 -37 -76 -43 -121 -5 -42 -6 -224 0 -240 5
-14 15 -90 22 -155 1 -16 3 -54 3 -83 1 -53 2 -54 85 -140 108 -112 178 -198
205 -250 12 -23 20 -42 17 -42 -5 0 -72 27 -111 45 -17 7 -51 23 -78 34 -26
11 -47 22 -47 24 0 2 26 2 58 1 l57 -3 -65 15 c-63 15 -132 42 -138 55 -1 3
-16 21 -32 39 -17 19 -30 40 -30 48 0 8 -12 29 -27 47 -16 18 -40 48 -56 66
-15 19 -36 45 -47 58 -11 13 -20 29 -20 36 0 7 -7 25 -15 42 -26 52 -14 220
16 239 5 3 9 12 9 20 0 14 7 28 89 193 23 46 40 85 38 88 -9 8 -101 -24 -167
-58 -74 -38 -197 -154 -254 -238 -20 -29 -36 -57 -36 -61 0 -4 -25 -24 -55
-45 -59 -39 -85 -77 -85 -123 0 -28 -6 -32 -107 -77 -60 -26 -171 -75 -248
-110 -77 -35 -180 -78 -230 -95 -50 -17 -117 -45 -150 -63 l-60 -32 -3 -117
-3 -118 625 0 624 0 16 35 c24 51 36 52 36 5 0 -29 4 -40 13 -37 6 2 12 16 11
30 -2 37 25 35 39 -3 l11 -30 613 0 c653 0 638 -1 696 50 16 14 57 45 91 70
34 25 90 65 124 90 34 25 85 61 113 81 27 20 98 81 156 135 124 115 219 179
312 209 21 6 20 5 -4 -32 -13 -21 -29 -43 -35 -50 -5 -7 -10 -15 -10 -19 0 -3
-9 -18 -20 -34 -11 -15 -16 -31 -12 -36 4 -5 3 -6 -2 -2 -8 7 -46 -32 -46 -47
0 -3 -13 -20 -30 -38 -16 -18 -30 -35 -30 -39 0 -4 -9 -10 -20 -13 -11 -3 -20
-12 -20 -19 0 -7 -11 -18 -25 -24 -14 -6 -25 -15 -25 -20 0 -6 -54 -51 -119
-97 -8 -5 -31 -27 -53 -48 -40 -39 -110 -71 -140 -63 -10 2 -18 0 -18 -6 0 -5
9 -12 20 -15 11 -3 18 -9 15 -13 -3 -5 -15 -6 -27 -2 -14 3 -19 2 -14 -6 5 -9
228 -12 872 -12 l864 0 0 123 0 122 -61 27 c-55 25 -61 30 -54 50 4 13 10 42
12 65 6 49 0 58 -114 191 -68 79 -106 132 -159 218 -39 64 -186 240 -236 282
-29 24 -75 55 -102 69 -65 33 -194 56 -271 48 -55 -6 -75 -1 -211 44 -121 41
-164 51 -217 51 -36 0 -68 -3 -70 -7z m253 -413 c0 -5 -7 -7 -15 -4 -11 5 -13
2 -9 -10 4 -11 1 -16 -10 -16 -14 0 -15 4 -6 20 11 20 40 28 40 10z"/>
<path d="M2450 1085 c0 -8 5 -15 10 -15 6 0 10 7 10 15 0 8 -4 15 -10 15 -5 0
-10 -7 -10 -15z"/>
<path d="M2705 61 c-3 -6 4 -9 15 -8 11 1 20 5 20 9 0 11 -28 10 -35 -1z"/>
<path d="M2760 60 c0 -5 5 -10 10 -10 6 0 10 5 10 10 0 6 -4 10 -10 10 -5 0
-10 -4 -10 -10z"/>
<path d="M2790 60 c0 -5 7 -10 16 -10 8 0 12 5 9 10 -3 6 -10 10 -16 10 -5 0
-9 -4 -9 -10z"/>
<path d="M2835 50 c3 -5 10 -10 16 -10 5 0 9 5 9 10 0 6 -7 10 -16 10 -8 0
-12 -4 -9 -10z"/>
<path d="M2835 10 c3 -5 8 -10 11 -10 2 0 4 5 4 10 0 6 -5 10 -11 10 -5 0 -7
-4 -4 -10z"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 14 KiB

View File

@@ -1,19 +0,0 @@
{
"name": "",
"short_name": "",
"icons": [
{
"src": "/android-chrome-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/android-chrome-384x384.png",
"sizes": "384x384",
"type": "image/png"
}
],
"theme_color": "#ffffff",
"background_color": "#ffffff",
"display": "standalone"
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
static/img/me.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

File diff suppressed because one or more lines are too long

349
static/normalize.css vendored
View File

@@ -1,349 +0,0 @@
/*! normalize.css v8.0.1 | MIT License | github.com/necolas/normalize.css */
/* Document
========================================================================== */
/**
* 1. Correct the line height in all browsers.
* 2. Prevent adjustments of font size after orientation changes in iOS.
*/
html {
line-height: 1.15; /* 1 */
-webkit-text-size-adjust: 100%; /* 2 */
}
/* Sections
========================================================================== */
/**
* Remove the margin in all browsers.
*/
body {
margin: 0;
}
/**
* Render the `main` element consistently in IE.
*/
main {
display: block;
}
/**
* Correct the font size and margin on `h1` elements within `section` and
* `article` contexts in Chrome, Firefox, and Safari.
*/
h1 {
font-size: 2em;
margin: 0.67em 0;
}
/* Grouping content
========================================================================== */
/**
* 1. Add the correct box sizing in Firefox.
* 2. Show the overflow in Edge and IE.
*/
hr {
box-sizing: content-box; /* 1 */
height: 0; /* 1 */
overflow: visible; /* 2 */
}
/**
* 1. Correct the inheritance and scaling of font size in all browsers.
* 2. Correct the odd `em` font sizing in all browsers.
*/
pre {
font-family: monospace, monospace; /* 1 */
font-size: 1em; /* 2 */
}
/* Text-level semantics
========================================================================== */
/**
* Remove the gray background on active links in IE 10.
*/
a {
background-color: transparent;
}
/**
* 1. Remove the bottom border in Chrome 57-
* 2. Add the correct text decoration in Chrome, Edge, IE, Opera, and Safari.
*/
abbr[title] {
border-bottom: none; /* 1 */
text-decoration: underline; /* 2 */
text-decoration: underline dotted; /* 2 */
}
/**
* Add the correct font weight in Chrome, Edge, and Safari.
*/
b,
strong {
font-weight: bolder;
}
/**
* 1. Correct the inheritance and scaling of font size in all browsers.
* 2. Correct the odd `em` font sizing in all browsers.
*/
code,
kbd,
samp {
font-family: monospace, monospace; /* 1 */
font-size: 1em; /* 2 */
}
/**
* Add the correct font size in all browsers.
*/
small {
font-size: 80%;
}
/**
* Prevent `sub` and `sup` elements from affecting the line height in
* all browsers.
*/
sub,
sup {
font-size: 75%;
line-height: 0;
position: relative;
vertical-align: baseline;
}
sub {
bottom: -0.25em;
}
sup {
top: -0.5em;
}
/* Embedded content
========================================================================== */
/**
* Remove the border on images inside links in IE 10.
*/
img {
border-style: none;
}
/* Forms
========================================================================== */
/**
* 1. Change the font styles in all browsers.
* 2. Remove the margin in Firefox and Safari.
*/
button,
input,
optgroup,
select,
textarea {
font-family: inherit; /* 1 */
font-size: 100%; /* 1 */
line-height: 1.15; /* 1 */
margin: 0; /* 2 */
}
/**
* Show the overflow in IE.
* 1. Show the overflow in Edge.
*/
button,
input { /* 1 */
overflow: visible;
}
/**
* Remove the inheritance of text transform in Edge, Firefox, and IE.
* 1. Remove the inheritance of text transform in Firefox.
*/
button,
select { /* 1 */
text-transform: none;
}
/**
* Correct the inability to style clickable types in iOS and Safari.
*/
button,
[type="button"],
[type="reset"],
[type="submit"] {
-webkit-appearance: button;
}
/**
* Remove the inner border and padding in Firefox.
*/
button::-moz-focus-inner,
[type="button"]::-moz-focus-inner,
[type="reset"]::-moz-focus-inner,
[type="submit"]::-moz-focus-inner {
border-style: none;
padding: 0;
}
/**
* Restore the focus styles unset by the previous rule.
*/
button:-moz-focusring,
[type="button"]:-moz-focusring,
[type="reset"]:-moz-focusring,
[type="submit"]:-moz-focusring {
outline: 1px dotted ButtonText;
}
/**
* Correct the padding in Firefox.
*/
fieldset {
padding: 0.35em 0.75em 0.625em;
}
/**
* 1. Correct the text wrapping in Edge and IE.
* 2. Correct the color inheritance from `fieldset` elements in IE.
* 3. Remove the padding so developers are not caught out when they zero out
* `fieldset` elements in all browsers.
*/
legend {
box-sizing: border-box; /* 1 */
color: inherit; /* 2 */
display: table; /* 1 */
max-width: 100%; /* 1 */
padding: 0; /* 3 */
white-space: normal; /* 1 */
}
/**
* Add the correct vertical alignment in Chrome, Firefox, and Opera.
*/
progress {
vertical-align: baseline;
}
/**
* Remove the default vertical scrollbar in IE 10+.
*/
textarea {
overflow: auto;
}
/**
* 1. Add the correct box sizing in IE 10.
* 2. Remove the padding in IE 10.
*/
[type="checkbox"],
[type="radio"] {
box-sizing: border-box; /* 1 */
padding: 0; /* 2 */
}
/**
* Correct the cursor style of increment and decrement buttons in Chrome.
*/
[type="number"]::-webkit-inner-spin-button,
[type="number"]::-webkit-outer-spin-button {
height: auto;
}
/**
* 1. Correct the odd appearance in Chrome and Safari.
* 2. Correct the outline style in Safari.
*/
[type="search"] {
-webkit-appearance: textfield; /* 1 */
outline-offset: -2px; /* 2 */
}
/**
* Remove the inner padding in Chrome and Safari on macOS.
*/
[type="search"]::-webkit-search-decoration {
-webkit-appearance: none;
}
/**
* 1. Correct the inability to style clickable types in iOS and Safari.
* 2. Change font properties to `inherit` in Safari.
*/
::-webkit-file-upload-button {
-webkit-appearance: button; /* 1 */
font: inherit; /* 2 */
}
/* Interactive
========================================================================== */
/*
* Add the correct display in Edge, IE 10+, and Firefox.
*/
details {
display: block;
}
/*
* Add the correct display in all browsers.
*/
summary {
display: list-item;
}
/* Misc
========================================================================== */
/**
* Add the correct display in IE 10+.
*/
template {
display: none;
}
/**
* Add the correct display in IE 10.
*/
[hidden] {
display: none;
}

View File

@@ -1,138 +0,0 @@
html, body {
margin: 0;
padding: 0;
}
body {
max-width: 700px;
padding: 100px 20px 400px 20px;
margin: 0 auto;
}
.title {
display: flex;
align-items: center;
margin-bottom: 10px;
}
.title div {
flex-grow: 1;
}
.title h1 {
font-size: 48px;
margin-bottom: 10px;
line-height: 1;
}
.title h2 {
font-weight: normal;
margin-top: 0;
line-height: 1;
}
.title img {
max-width: 100px;
max-height: 100px;
border-radius: 100%;
display: block;
margin-left: 20px;
}
.menu {
display: flex;
}
.menu .button {
margin-right: 10px;
}
.tracking {
width: 100%;
height: 300px;
}
.tracking-form {
display: flex;
align-items: center;
}
.tracking-form input {
margin: 3px 6px 3px 0;
}
.embed {
display: flex;
justify-content: center;
align-items: center;
text-decoration: none;
border: 1px dotted #121212;
padding: 1rem 1.5rem;
}
.embed .image {
max-width: 200px;
max-height: 200px;
}
.embed .info {
padding: 1rem 1.5rem;
}
.embed .info .title {
font-weight: bold;
margin: 0 0 10px 0;
}
.embed .info .url {
margin: 10px 0 0 0;
}
.break-line-anywhere {
line-break: anywhere;
}
input {
padding: 2px;
}
section {
margin: 80px 0;
}
p {
line-height: 1.5;
}
img {
display: block;
margin-left: auto;
margin-right: auto;
}
@media (max-width: 700px) {
body {
padding: 40px 20px 80px 20px;
}
.title h1 {
font-size: 36px;
}
.title h2 {
font-size: 24px;
}
.title img {
max-width: 72px;
max-height: 72px;
}
.menu {
display: block;
}
.tracking-form {
display: block;
}
}

BIN
static/web/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

1
static/web/main.css Normal file
View File

@@ -0,0 +1 @@
@font-face{font-family:"Open Sans";font-style:italic;font-weight:normal;font-stretch:100%;font-display:swap;src:url("../font/OpenSans-Italic.ttf") format("truetype")}@font-face{font-family:"Open Sans";font-style:normal;font-weight:normal;font-stretch:100%;font-display:swap;src:url("../font/OpenSans-Regular.ttf") format("truetype")}@font-face{font-family:"Open Sans";font-style:normal;font-weight:bold;font-stretch:100%;font-display:swap;src:url("../font/OpenSans-Bold.ttf") format("truetype")}@font-face{font-family:"Open Sans";font-style:italic;font-weight:bold;font-stretch:100%;font-display:swap;src:url("../font/OpenSans-BoldItalic.ttf") format("truetype")}*{box-sizing:border-box;font-family:"Open Sans",sans-serif;font-size:18px;line-height:2;color:#0f0f0f}@media screen and (max-width: 890px){*{font-size:16px}}body{margin:0;padding:0 0 160px 0}h1,h2{font-size:32px;margin:0 0 40px 0;font-weight:normal}h1 a,h2 a{font-size:32px}@media screen and (max-width: 890px){h1 a,h2 a{font-size:26px}}@media screen and (max-width: 890px){h1,h2{font-size:26px;margin:0 0 20px 0}}h2{margin:40px 0;font-size:26px}@media screen and (max-width: 890px){h2{font-size:20px;margin:20px 0}}a{text-decoration:none;transition:all .3s;color:#717171}a:hover{color:#0f0f0f}p,ul{margin:20px 0}img{display:block;max-width:80%;margin:40px auto}pre{max-width:80%;margin:40px auto;padding:20px;border-width:1px 1px 1px 2px;border-style:solid;border-color:#c0d9f0;border-radius:8px;overflow-x:auto;font-size:14px}code{font-family:"Courier New",Courier,monospace}hr{margin:40px 0;height:2px;background:#e6e6e6;border-width:0}blockquote{margin:40px 0 40px 40px;padding:10px 20px;border-width:0 0 0 2px;border-style:solid;border-color:#c0d9f0}nav{padding:80px 40px;background:#c0d9f0}nav .content{display:flex;justify-content:space-between;align-items:center;max-width:1000px;margin:0 auto}nav .content ul{display:flex;gap:20px;margin:0;padding:0}nav .content ul li{list-style:none;margin:0;padding:0}nav .content ul li a{color:#0f0f0f}nav .content ul li a svg{fill:#0f0f0f;transition:all .3s}nav .content ul li a:hover{color:#717171}nav .content ul li a:hover svg{fill:#717171}@media screen and (max-width: 890px){nav .content ul{gap:10px}}@media screen and (max-width: 580px){nav .content ul{flex-direction:column}}nav .content .contact{display:inline-block;padding:8px 16px;border-radius:8px;border:2px solid #0f0f0f;color:#0f0f0f;transition:all .3s}nav .content .contact:hover{background:#0f0f0f;color:#fff}@media screen and (max-width: 890px){nav .content .contact{padding:4px 8px}}@media screen and (max-width: 890px){nav .content{padding:40px;align-items:flex-start}}@media screen and (max-width: 420px){nav .content{padding:20px 0}}@media screen and (max-width: 890px){nav{padding:0}}@media screen and (max-width: 420px){nav{padding:20px}}section{max-width:1080px;margin:80px auto 0 auto;padding:0 40px}@media screen and (max-width: 890px){section{margin:40px auto 0 auto}}@media screen and (max-width: 420px){section{padding:0 20px}}footer{max-width:1080px;margin:80px auto 0 auto;padding:0 40px}footer .content{max-width:1000px;margin:0 auto;border-style:solid;border-width:2px 0 0 0;border-color:#e6e6e6;padding:40px 0}@media screen and (max-width: 420px){footer{padding:0 20px}}.intro{background:#c0d9f0;max-width:100%;padding:0 40px 80px 40px;margin:0}.intro article{max-width:1000px;margin:0 auto;display:flex;gap:40px;justify-content:flex-start;align-items:center}.intro article h1{margin:0}.intro article img{max-width:196px;max-height:196px;aspect-ratio:1;border-radius:8px}@media screen and (max-width: 890px){.intro article img{max-width:142px;max-height:142px}}@media screen and (max-width: 600px){.intro article img{margin:0}}@media screen and (max-width: 600px){.intro article{flex-direction:column;align-items:flex-start}}@media screen and (max-width: 600px){.intro{padding:0 40px 40px 40px}}@media screen and (max-width: 420px){.intro{padding:0 20px 40px 20px}}.blog-entry{margin-bottom:0}.blog-entry p{margin:0;color:#717171}.blog-entry h2{margin:0}.blog-entry a{color:#0f0f0f;font-size:26px}.blog-entry a:hover{color:#717171}@media screen and (max-width: 890px){.blog-entry a{font-size:20px}}/*# sourceMappingURL=main.css.map */

1
static/web/main.css.map Normal file
View File

@@ -0,0 +1 @@
{"version":3,"sourceRoot":"","sources":["../../assets/scss/_font.scss","../../assets/scss/main.scss"],"names":[],"mappings":"AAAA,WACI,wBACA,kBACA,mBACA,kBACA,kBACA,0DAGJ,WACI,wBACA,kBACA,mBACA,kBACA,kBACA,2DAGJ,WACI,wBACA,kBACA,iBACA,kBACA,kBACA,wDAGJ,WACI,wBACA,kBACA,iBACA,kBACA,kBACA,8DCzBJ,EACC,sBACA,mCACA,eACA,cACA,MAVW,QAYX,qCAPD,EAQE,gBAIF,KACC,SACA,oBAGD,MACC,eACA,kBACA,mBAEA,UACC,eAEA,qCAHD,UAIE,gBAIF,qCAbD,MAcE,eACA,mBAIF,GACC,cACA,eAEA,qCAJD,GAKE,eACA,eAIF,EACC,qBACA,mBACA,MArDW,QAuDX,QACC,MAzDU,QA6DZ,KACC,cAGD,IACC,cACA,cACA,iBAGD,IACC,cACA,iBACA,aACA,6BACA,mBACA,aA1EW,QA2EX,cA/Ec,IAgFd,gBACA,eAGD,KACC,4CAGD,GACC,cACA,WACA,WAxFW,QAyFX,eAGD,WACC,wBACA,kBACA,uBACA,mBACA,aAhGW,QAmGZ,IACC,kBACA,WArGW,QAuGX,aACC,aACA,8BACA,mBACA,iBACA,cAEA,gBACC,aACA,SACA,SACA,UAEA,mBACC,gBACA,SACA,UAEA,qBACC,MA7HO,QA+HP,yBACC,KAhIM,QAiIN,mBAGD,2BACC,MApIM,QAsIN,+BACC,KAvIK,QA6IT,qCA7BD,gBA8BE,UAGD,qCAjCD,gBAkCE,uBAIF,sBACC,qBACA,iBACA,cA3JY,IA4JZ,yBACA,MA5JS,QA6JT,mBAEA,4BACC,WAhKQ,QAiKR,WAGD,qCAbD,sBAcE,iBAIF,qCA/DD,aAgEE,aACA,wBAGD,qCApED,aAqEE,gBAIF,qCA7ED,IA8EE,WAGD,qCAjFD,IAkFE,cAIF,QACC,iBACA,wBACA,eAEA,qCALD,QAME,yBAGD,qCATD,QAUE,gBAIF,OACC,iBACA,wBACA,eAEA,gBACC,iBACA,cACA,mBACA,uBACA,aAlNU,QAmNV,eAGD,qCAdD,OAeE,gBAIF,OACC,WA3NW,QA4NX,eACA,yBACA,SAEA,eACC,iBACA,cACA,aACA,SACA,2BACA,mBAEA,kBACC,SAGD,mBACC,gBACA,iBACA,eACA,cApPY,IAsPZ,qCAND,mBAOE,gBACA,kBAGD,qCAXD,mBAYE,UAIF,qCA5BD,eA6BE,sBACA,wBAIF,qCAxCD,OAyCE,0BAGD,qCA5CD,OA6CE,0BAIF,YACC,gBAEA,cACC,SACA,MAlRU,QAqRX,eACC,SAGD,cACC,MA3RU,QA4RV,eAEA,oBACC,MA9RS,QAiSV,qCARD,cASE","file":"main.css"}

1
static/web/main.min.js vendored Normal file
View File

@@ -0,0 +1 @@
(()=>{console.log("Hi!");})();

View File

@@ -1,93 +0,0 @@
{{template "head.html"}}
<div class="title">
<div>
<h1>marvin blum</h1>
<h2>welcome to my website!</h2>
</div>
<img src="../static/avatar_100.jpg" alt="Marvin Blum" />
</div>
{{template "menu.html"}}
<section>
<h2>Who Am I?</h2>
<p>
I'm a full stack software engineer from Germany, open source and Linux enthusiast and co-founder of <a href="https://emvi.com/" target="_blank">Emvi</a>.
In love with Go, but fluent in a lot of programming languages.
</p>
</section>
<section>
<h2>Latest Blog Posts</h2>
{{range $article := .Articles}}
<p>
<small>{{format $article.Published "2. January 2006"}}</small>
<br />
<a href="/blog/{{slug $article.LatestArticleContent.Title}}-{{$article.Id}}">{{$article.LatestArticleContent.Title}}</a>
</p>
{{else}}
<p>There are no blog posts yet...</p>
{{end}}
<p>
<a href="/blog">View all</a>
</p>
</section>
<section>
<h2>Projects</h2>
<ul>
<li>
<a href="https://emvi.com/" target="_blank">Emvi</a> a note taking, collaboration and knowledge management tool for personal use and teams of any size
</li>
<li>
<a href="https://github.com/pirsch-analytics/pirsch" target="_blank">pirsch</a> a server side, no-cookie and privacy focused tracking library for Golang
</li>
<li>
<a href="https://github.com/emvi/logbuch" target="_blank">logbuch</a> a simple Golang logging library
</li>
<li>
<a href="https://github.com/emvi/null" target="_blank">null</a> a Golang library for nullable types supporting databases and json
</li>
<li>
<a href="https://github.com/emvi/hide" target="_blank">hide</a> a Golang library to obscure IDs on the API layer
</li>
<li>
<a href="https://github.com/assetto-corsa-web/accweb" target="_blank">accweb</a> a web interface to manage game servers
</li>
</ul>
</section>
<section>
<h2>Skills</h2>
<ul>
<li>Go (Golang)</li>
<li>JavaScript (Vue, Node)</li>
<li>HTML, CSS, Sass and all the web fuzz</li>
<li>Java</li>
<li>PHP</li>
<li>Linux</li>
<li>Docker</li>
<li>Kubernetes</li>
<li>... and more</li>
</ul>
</section>
<section>
<h2>Work</h2>
<ul>
<li>
<a href="https://pirsch.io/" target="_blank">Pirsch</a> co-founder of a privacy-friendly, open-source, web analytics SaaS and library
</li>
<li>
<a href="https://emvi.com/" target="_blank">Emvi</a> co-founder of a note taking, collaboration and knowledge management SaaS startup
</li>
<li>
<a href="https://skalar.marketing/" target="_blank">skalar marketing</a> as a full stack developer and web designer
</li>
<li>
<a href="https://www.bertelsmann.com/divisions/arvato/#st-1" target="_blank">arvato</a> as a Java developer
</li>
<li>
some freelancing from time to time
</li>
</ul>
</section>
{{template "end.html"}}

View File

@@ -1,10 +0,0 @@
{{template "head.html"}}
{{template "menu.html"}}
<section>
<h1>{{.Title}}</h1>
<small>Published on {{format .Published "2. January 2006"}}</small>
{{.Content}}
</section>
{{template "end.html"}}

View File

@@ -1,25 +0,0 @@
{{template "head.html"}}
{{template "menu.html"}}
<section>
<h1>Blog</h1>
</section>
{{range $year, $articles := .Articles}}
<section>
<h2>{{$year}}</h2>
{{range $article := $articles}}
<p>
<small>{{format $article.Published "2. January 2006"}}</small>
<br />
<a href="/blog/{{slug $article.LatestArticleContent.Title}}-{{$article.Id}}">{{$article.LatestArticleContent.Title}}</a>
</p>
{{end}}
</section>
{{else}}
<section>
<p>There are no blog posts yet...</p>
</section>
{{end}}
{{template "end.html"}}

View File

@@ -1,14 +0,0 @@
<hr />
<section>
<p>
Would you like to see more? Read my blog articles on <a href="https://emvi.com/blog" target="_blank">Emvi</a>, my project page on <a href="https://github.com/Kugelschieber" target="_blank">GitHub</a> or send me a <a href="mailto:marvin@marvinblum.de">mail</a>.
</p>
<p>
This page uses <a href="https://concrete.style/" target="_blank">concrete</a> for styling. Check it out!
</p>
<p>
This page does not use cookies. <a href="/legal">Legal</a>
</p>
</section>
</body>
</html>

View File

@@ -1,34 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<base href="/" />
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="copyright" content="Marvin Blum" />
<meta name="author" content="Marvin Blum" />
<meta name="title" content="Marvin Blum" />
<meta name="description" content="A full stack software engineer from Germany, open source and Linux enthusiast and co-founder of Emvi." />
<meta name="msapplication-TileColor" content="#000000" />
<meta name="theme-color" content="#000000" />
<meta name="twitter:card" content="profile" />
<meta name="twitter:site" content="@m5blum" />
<meta name="twitter:title" content="Marvin Blum" />
<meta name="twitter:description" content="A full stack software engineer from Germany, open source and Linux enthusiast and co-founder of Emvi." />
<meta name="twitter:image" content="https://marvinblum.de/avatar.png" />
<meta property="og:url" content="https://marvinblum.de/" />
<meta property="og:title" content="Marvin Blum" />
<meta property="og:description" content="A full stack software engineer from Germany, open source and Linux enthusiast and co-founder of Emvi." />
<meta property="og:image" content="https://marvinblum.de/avatar.png" />
<title>marvin blum</title>
<link rel="apple-touch-icon" sizes="180x180" href="../static/favicon/apple-touch-icon.png" />
<link rel="icon" type="image/png" sizes="32x32" href="../static/favicon/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="../static/favicon/favicon-16x16.png" />
<link rel="manifest" href="../static/favicon/site.webmanifest" />
<link rel="mask-icon" href="../static/favicon/safari-pinned-tab.svg" color="#5bbad5" />
<meta name="msapplication-TileColor" content="#da532c" />
<meta name="theme-color" content="#ffffff" />
<link rel="stylesheet" type="text/css" href="../static/normalize.css" />
<link rel="stylesheet" type="text/css" href="../static/concrete.css" />
<link rel="stylesheet" type="text/css" href="../static/style.css" />
</head>
<body>

View File

@@ -1,23 +0,0 @@
{{template "head.html"}}
{{template "menu.html"}}
<section>
<h1>Legal</h1>
</section>
<section>
<h2>According to §5 TMG</h2>
<p>
Marvin Blum<br />
Gerhard-Hauptmannstraße 3<br />
33378 Rheda-Wiedenbrück, Germany<br />
<a href="mailto:marvin@marvinblum.de">marvin@marvinblum.de</a>
</p>
</section>
<section>
<h2>Cookie Policy</h2>
<p>
This page does not use cookies.
</p>
</section>
{{template "end.html"}}

View File

@@ -1,7 +0,0 @@
<div class="menu">
<a href="/" class="button filled">About</a>
<a href="/blog" class="button filled">Blog</a>
<a href="mailto:marvin@marvinblum.de" class="button">Contact me</a>
<a href="https://github.com/Kugelschieber" target="_blank" class="button">GitHub</a>
<a href="https://twitter.com/m5blum" target="_blank" class="button">Twitter</a>
</div>

View File

@@ -1,12 +0,0 @@
{{template "head.html"}}
{{template "menu.html"}}
<section>
<h2>Page not found</h2>
<p>
Nothing to see here...<br />
<a href="/">Return home</a>
</p>
</section>
{{template "end.html"}}

8
tpl/blog_entry.html Normal file
View File

@@ -0,0 +1,8 @@
<section class="blog-entry">
<article>
<p>{{copy .Page .Content "date"}}</p>
<h2>
<a href="{{copy .Page .Content "link"}}">{{copy .Page .Content "headline"}}</a>
</h2>
</article>
</section>

2
tpl/end.html Normal file
View File

@@ -0,0 +1,2 @@
</body>
</html>

7
tpl/footer.html Normal file
View File

@@ -0,0 +1,7 @@
<footer>
<div class="content">
<p>Looking for more? Read my blog articles on <a href="https://pirsch.io/blog?ref=marvinblum.de" target="_blank">Pirsch Analytics</a> and <a href="https://emvi.com/blog?ref=marvinblum.de" target="_blank">Emvi</a>!</p>
<p>If you have a business proposal for me, please use the <strong>Contact me</strong> button at the top.</p>
<p>This page does not use cookies. <a href="/legal">Legal</a></p>
</div>
</footer>

25
tpl/head.html Normal file
View File

@@ -0,0 +1,25 @@
{{- $title := copy .Page .Content "title" -}}
<!DOCTYPE html>
<html lang="{{.Page.Language}}">
<head>
<base href="/" />
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="copyright" content="{{copy .Page .Content "copyright"}}" />
<meta name="author" content="{{copy .Page .Content "author"}}" />
<meta name="title" content="{{$title}}" />
<meta name="description" content="{{copy .Page .Content "meta_description"}}" />
<link rel="icon" type="image/png" href="/static/web/favicon.png">
<link rel="canonical" href="{{.Page.CanonicalLink}}" />
{{range $language, $path := .Page.Path}}
<link rel="alternate" hreflang="{{if eq $language "en"}}x-default{{else}}{{$language}}{{end}}" href="{{fmt "%s%s" hostname $path}}" />
{{end}}
<link rel="stylesheet" type="text/css" href="/static/web/main.css?v={{.CMS.LastUpdate}}" />
<script src="/static/web/main.min.js?v={{.CMS.LastUpdate}}"></script>
<title>{{$title}}</title>
</head>
<body>

6
tpl/intro.html Normal file
View File

@@ -0,0 +1,6 @@
<section class="intro">
<article>
<h1>Hi, I'm Marvin, co-founder of <a href="https://pirsch.io?ref=marvinblum.de" target="_blank">Pirsch Analytics</a> and <a href="https://emvi.com?ref=marvinblum.de" target="_blank">Emvi</a>, software engineer, and open-source enthusiast.</h1>
<img src="/static/img/me.jpg" alt="Picture" />
</article>
</section>

39
tpl/nav.html Normal file
View File

@@ -0,0 +1,39 @@
<nav>
<div class="content">
<ul>
<li>
<a href="/">Home</a>
</li>
<li>
<a href="/blog">Blog</a>
</li>
<li>
<a href="https://github.com/Kugelschieber" target="_blank">
GitHub
<svg xmlns="http://www.w3.org/2000/svg" x="0px" y="0px" width="14" height="14" viewBox="0 0 48 48">
<path d="M 40.960938 4.9804688 A 2.0002 2.0002 0 0 0 40.740234 5 L 28 5 A 2.0002 2.0002 0 1 0 28 9 L 36.171875 9 L 22.585938 22.585938 A 2.0002 2.0002 0 1 0 25.414062 25.414062 L 39 11.828125 L 39 20 A 2.0002 2.0002 0 1 0 43 20 L 43 7.2460938 A 2.0002 2.0002 0 0 0 40.960938 4.9804688 z M 12.5 8 C 8.3826878 8 5 11.382688 5 15.5 L 5 35.5 C 5 39.617312 8.3826878 43 12.5 43 L 32.5 43 C 36.617312 43 40 39.617312 40 35.5 L 40 26 A 2.0002 2.0002 0 1 0 36 26 L 36 35.5 C 36 37.446688 34.446688 39 32.5 39 L 12.5 39 C 10.553312 39 9 37.446688 9 35.5 L 9 15.5 C 9 13.553312 10.553312 12 12.5 12 L 22 12 A 2.0002 2.0002 0 1 0 22 8 L 12.5 8 z"></path>
</svg>
</a>
</li>
<li>
<a href="https://x.com/m5blum" target="_blank">
Twitter/X
<svg xmlns="http://www.w3.org/2000/svg" x="0px" y="0px" width="14" height="14" viewBox="0 0 48 48">
<path d="M 40.960938 4.9804688 A 2.0002 2.0002 0 0 0 40.740234 5 L 28 5 A 2.0002 2.0002 0 1 0 28 9 L 36.171875 9 L 22.585938 22.585938 A 2.0002 2.0002 0 1 0 25.414062 25.414062 L 39 11.828125 L 39 20 A 2.0002 2.0002 0 1 0 43 20 L 43 7.2460938 A 2.0002 2.0002 0 0 0 40.960938 4.9804688 z M 12.5 8 C 8.3826878 8 5 11.382688 5 15.5 L 5 35.5 C 5 39.617312 8.3826878 43 12.5 43 L 32.5 43 C 36.617312 43 40 39.617312 40 35.5 L 40 26 A 2.0002 2.0002 0 1 0 36 26 L 36 35.5 C 36 37.446688 34.446688 39 32.5 39 L 12.5 39 C 10.553312 39 9 37.446688 9 35.5 L 9 15.5 C 9 13.553312 10.553312 12 12.5 12 L 22 12 A 2.0002 2.0002 0 1 0 22 8 L 12.5 8 z"></path>
</svg>
</a>
</li>
<li>
<a href="https://social.anoxinon.de/@m5blum" target="_blank">
Mastodon
<svg xmlns="http://www.w3.org/2000/svg" x="0px" y="0px" width="14" height="14" viewBox="0 0 48 48">
<path d="M 40.960938 4.9804688 A 2.0002 2.0002 0 0 0 40.740234 5 L 28 5 A 2.0002 2.0002 0 1 0 28 9 L 36.171875 9 L 22.585938 22.585938 A 2.0002 2.0002 0 1 0 25.414062 25.414062 L 39 11.828125 L 39 20 A 2.0002 2.0002 0 1 0 43 20 L 43 7.2460938 A 2.0002 2.0002 0 0 0 40.960938 4.9804688 z M 12.5 8 C 8.3826878 8 5 11.382688 5 15.5 L 5 35.5 C 5 39.617312 8.3826878 43 12.5 43 L 32.5 43 C 36.617312 43 40 39.617312 40 35.5 L 40 26 A 2.0002 2.0002 0 1 0 36 26 L 36 35.5 C 36 37.446688 34.446688 39 32.5 39 L 12.5 39 C 10.553312 39 9 37.446688 9 35.5 L 9 15.5 C 9 13.553312 10.553312 12 12.5 12 L 22 12 A 2.0002 2.0002 0 1 0 22 8 L 12.5 8 z"></path>
</svg>
</a>
</li>
</ul>
<div>
<a href="mailto:marvin.blum@emvi.com" class="contact">Contact me</a>
</div>
</div>
</nav>

View File

@@ -1,119 +0,0 @@
package tpl
import (
"bytes"
"fmt"
"github.com/emvi/logbuch"
"github.com/gosimple/slug"
"html/template"
"net/http"
"os"
"sync"
"time"
)
const (
templateDir = "template/*"
)
type Cache struct {
tpl *template.Template
cache map[string][]byte
hotReload bool
m sync.RWMutex
}
func NewCache() *Cache {
cache := &Cache{
cache: make(map[string][]byte),
hotReload: os.Getenv("MB_HOT_RELOAD") == "true",
}
logbuch.Debug("Template cache hot reload", logbuch.Fields{"hot_reload": cache.hotReload})
cache.load()
return cache
}
func (cache *Cache) load() {
logbuch.Debug("Loading templates")
funcMap := template.FuncMap{
"slug": slug.Make,
"format": func(t time.Time, layout string) string { return t.Format(layout) },
"multiply": func(a, b float64) float64 { return a * b },
"divide": func(a, b float64) float64 { return a / b },
"round": func(f float64) string { return fmt.Sprintf("%.2f", f) },
"intRange": intRange,
"float64": func(i int) float64 { return float64(i) },
}
var err error
cache.tpl, err = template.New("").Funcs(funcMap).ParseGlob(templateDir)
if err != nil {
logbuch.Fatal("Error loading template", logbuch.Fields{"err": err})
}
logbuch.Debug("Templates loaded", logbuch.Fields{"hot_reload": cache.hotReload})
}
func (cache *Cache) Render(w http.ResponseWriter, name string, data interface{}) {
cache.m.RLock()
if cache.cache[name] == nil || cache.hotReload {
cache.m.RUnlock()
cache.m.Lock()
defer cache.m.Unlock()
logbuch.Debug("Rendering template", logbuch.Fields{"name": name})
if cache.hotReload {
logbuch.Debug("Reloading templates")
cache.load()
}
var buffer bytes.Buffer
if err := cache.tpl.ExecuteTemplate(&buffer, name, data); err != nil {
logbuch.Error("Error executing template", logbuch.Fields{"err": err, "name": name})
w.WriteHeader(http.StatusInternalServerError)
}
cache.cache[name] = buffer.Bytes()
} else {
cache.m.RUnlock()
}
if _, err := w.Write(cache.cache[name]); err != nil {
logbuch.Error("Error sending response to client", logbuch.Fields{"err": err, "template": name})
w.WriteHeader(http.StatusInternalServerError)
}
}
func (cache *Cache) RenderWithoutCache(w http.ResponseWriter, name string, data interface{}) {
if cache.hotReload {
logbuch.Debug("Reloading templates")
cache.load()
}
if err := cache.tpl.ExecuteTemplate(w, name, data); err != nil {
logbuch.Error("Error executing template", logbuch.Fields{"err": err, "name": name})
w.WriteHeader(http.StatusInternalServerError)
}
}
func (cache *Cache) Clear() {
cache.m.Lock()
defer cache.m.Unlock()
cache.cache = make(map[string][]byte)
}
func intRange(start, end int) []int {
if end-start < 0 {
return []int{}
}
r := make([]int, end-start)
for i := 0; i < end-start; i++ {
r[i] = start + i
}
return r
}

18
tpl/text.html Normal file
View File

@@ -0,0 +1,18 @@
{{$size := get .Content "size"}}
{{$md := get .Content "markdown"}}
<section>
{{if $size}}
{{html (fmt "<%s>" $size)}}
{{copy .Page .Content "headline"}}
{{html (fmt "</%s>" $size)}}
{{else}}
<h2>{{copy .Page .Content "headline"}}</h2>
{{end}}
{{if $md}}
{{markdown $md .}}
{{else}}
{{html (copy .Page .Content "text")}}
{{end}}
</section>