diff options
| -rw-r--r-- | lume/src/blog/2024/earthly-docker.mdx | 115 | ||||
| -rw-r--r-- | lume/src/static/img/docker-graph.dot | 16 | ||||
| -rw-r--r-- | lume/src/static/img/docker-graph.svg | 109 | ||||
| -rw-r--r-- | lume/src/static/img/xesite-graph.dot | 44 | ||||
| -rw-r--r-- | lume/src/static/img/xesite-graph.svg | 186 |
5 files changed, 470 insertions, 0 deletions
diff --git a/lume/src/blog/2024/earthly-docker.mdx b/lume/src/blog/2024/earthly-docker.mdx new file mode 100644 index 0000000..fd965e8 --- /dev/null +++ b/lume/src/blog/2024/earthly-docker.mdx @@ -0,0 +1,115 @@ +--- +title: "Building a constellation of images with Earthly" +date: 2024-06-22 +desc: "What if building container images was actually a graph?" +tags: + - "earthly" + - "docker" + - "xesite" +hero: + ai: "Photo by Xe Iaso, iPhone 15 Pro Max" + file: nature-walk + prompt: "A film-emulated picture of a the sky on one side, and treetops on the other. There is heavy saturation and a slight hint of film grain." + social: true +--- + +Docker is the universal package format of the Internet. It allows you to ship an application and all of its dependencies in one unit so that you can run it without worrying about dependencies on the host machine breaking your app. It's quickly become the gold standard for how to package and deploy applications, and it's not hard to see why. + +However, the main way you build a Docker image is with the `docker build` command, which takes a `Dockerfile` in the directory you specify on the command line and then builds an image from that. This works great for single-component applications, or even facets of a larger monorepo, but it falls short when you have something like a monorepo written in Go that has multiple components that need to be built and then packaged into separate images. + +I have two big "monorepos" of side projects and the like that I want to deploy as Docker images. One is [my blog](https://github.com/Xe/site), and the other is my [/x/ experimental monorepo](https://github.com/Xe/x). Both of these projects have multiple components that need to be built and packaged into separate Docker images, and I've been struggling to find a way to do this [that was as good as the previous setup](/talks/2024/nix-docker-build/). + +When I was working with a coworker on something recently, I was pointed to [Earthly](https://docs.earthly.dev/). Earthly is a unique form of violence, it's effectively a bastard child of Make and Docker that was raised by a team of people who really care about developer ergonomics. The best way to think about Earthly is that it's a build system that just happens to execute every step in a container and you can fossilize artifacts or images out of the build process. + +Under the hood, Docker has started to use [BuildKit](https://docs.docker.com/build/buildkit/) to make images. This effectively transforms a Dockerfile into a graph of steps that can be executed in parallel. Consider this Dockerfile: + +```Dockerfile +FROM golang:1.22 AS builder +WORKDIR /src +COPY . . +RUN mkdir -p /app/bin && go build -o /app/bin/myapp ./cmd/myapp + +FROM nodejs AS frontend +WORKDIR /src +COPY . . +RUN npm install && npm run build + +FROM ubuntu:24.04 AS runner +WORKDIR /app +COPY --from=builder /app/bin/myapp /app/bin/myapp +COPY --from=frontend /src/build /app/static +CMD ["/app/bin/myapp"] +``` + +This effectively turns a build into a graph like this: + + + +The `builder` and `frontend` stages can be built in parallel, but the `runner` stage needs to wait until both of them are done before it can be built. This is a simple example, but it shows how you can have multiple components that need to be built and then packaged into a single image. + +What if you have multiple images though? That's where Earthly comes in. Earthly builds on top of BuildKit to allow you to define a series of targets that can be built in parallel, and then it builds them in the most efficient way possible. It's like Make, but for Docker images. + +## My blog's backend + +My blog is an unfortunately complicated project, it wasn't intended to be that way, it sorta organically grew this way after a decade or so. It's a Go project that requires on a few components: + +- The blog backend itself (really just something that sits there and serves the blog, occasionally rebuilding it when I push a new post) +- The Patreon token escrow service (a service that sits in front of the Patreon API and allows me to have a token that can be used to access the API without having to worry about it being revoked) +- A [few other components](/blog/2024/overengineering-preview-site/) in `/x/` that I'm not going to talk about here + +After breaking everything down into the components and inputs, I came up with the following flow: + + + +Going from left to right, the inputs are: + +- The source code tree (a checkout of the blog's repository) +- The [go:1.22-alpine](https://hub.docker.com/_/golang) image +- The [alpine:edge](https://hub.docker.com/_/alpine) image + +These are then passed through to pull and build the components and their dependencies. The `+patreon` and `+xesite` targets are the final images that are built from the components. The `+xesite` target is a bit weird in that we need to copy the [Iosevka Iaso](https://cdn.xeiaso.net/static/pkg/iosevka/specimen.html) font files and the [Dhall](https://dhall-lang.org/) binary into the image so that the blog can use them (it will panic at runtime if it can't find them). + +These two targets are then pushed to the [GitHub Container Registry](https://ghcr.io/) so that they can be pulled down and run on [Fly.io](https://fly.io/). + +<Conv name="Cadey" mood="coffee"> + At the time of writing, Fly.io is my employer. I'm using Fly.io to run my + blog. I'm not just shilling it for the sake of shilling it. I was a user + before I was an employee, and I'm still a user now that I'm an employee. It's + a great platform and I love it. If the platform wasn't great, I wouldn't be + using it. +</Conv> + +Oh, as a side note, when you're trying to build multiple images at once from CI, you need to make an `all` target or similar that depends on all of the images you want to build. This is because Earthly can only build one target at a time. + +```Dockerfile +all: + BUILD --platform=linux/amd64 +xesite + BUILD --platform=linux/amd64 +patreon-saasproxy +``` + +You can then chuck this into GitHub Actions: + +```yaml +- name: Build and push Docker image + id: build-and-push + run: | + earthly --ci --push +all +``` + +The [`--ci` flag](https://docs.earthly.dev/ci-integration/overview#earthly) sets some options that help Earthly work better in a CI environment. It's not strictly necessary, but it's probably a good idea to use it. + +## The impact + +The difference between these two flows is subtle but staggering. Building my blog's backend with the old flow could take up to 10 minutes. Building my blog's backend with Earthly takes tens of seconds. The old flow produced a 734 MB image with a bunch of extraneous dependencies (even though that should be mathematically impossible). The new flow shits out a 262 MB image that has only what is required to run the blog. + +Not to mention the developer ergonomics of using Earthly. With Earthly I can build **and push** my images in one Go. I don't even run into the Dockerfile landmine of forgetting to run `docker build -t` before running `docker push`. I can't tell you how many times I've done that and had to dig up the image reference from `docker images` to manually tag and push it. + +Earthly is exactly what I needed. I'm going to adopt it as my Docker image build system of choice. + +The only downside is them adding advertisements for their SaaS product in all of my build outputs: + +``` +🛰️ Reuse cache between CI runs with Earthly Satellites! 2-20X faster than without cache. Generous free tier https://cloud.earthly.dev +``` + +I get why they're doing this, it's really hard to make money off of developer tooling like this. Developers are both extremely well paid and notoriously cheap. I'm not going to fault them for trying to make money off of their product. I just wish I could turn it off. diff --git a/lume/src/static/img/docker-graph.dot b/lume/src/static/img/docker-graph.dot new file mode 100644 index 0000000..deb789b --- /dev/null +++ b/lume/src/static/img/docker-graph.dot @@ -0,0 +1,16 @@ +digraph { + rankdir=LR; + node [shape=box]; + src [label="./*", shape=ellipse]; + golang [label="golang:1.22", shape=ellipse]; + nodejs [label="nodejs", shape=ellipse]; + ubuntu [label="ubuntu:24.04", shape=ellipse]; + builder -> runner; + frontend -> runner; + src -> builder; + src -> frontend; + nodejs -> frontend; + golang -> builder; + ubuntu -> runner; + runner -> output; +} diff --git a/lume/src/static/img/docker-graph.svg b/lume/src/static/img/docker-graph.svg new file mode 100644 index 0000000..c2c273e --- /dev/null +++ b/lume/src/static/img/docker-graph.svg @@ -0,0 +1,109 @@ +<?xml version="1.0" encoding="UTF-8" standalone="no"?> +<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" + "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"> +<!-- Generated by graphviz version 2.40.1 (20161225.0304) + --> +<!-- Title: %0 Pages: 1 --> +<svg width="462pt" height="179pt" + viewBox="0.00 0.00 462.36 179.00" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"> +<g id="graph0" class="graph" transform="scale(1 1) rotate(0) translate(4 175)"> +<title>%0</title> +<polygon fill="#ffffff" stroke="transparent" points="-4,4 -4,-175 458.3568,-175 458.3568,4 -4,4"/> +<!-- src --> +<g id="node1" class="node"> +<title>src</title> +<ellipse fill="none" stroke="#000000" cx="56.9041" cy="-99" rx="27" ry="18"/> +<text text-anchor="middle" x="56.9041" y="-94.8" font-family="Times,serif" font-size="14.00" fill="#000000">./*</text> +</g> +<!-- builder --> +<g id="node5" class="node"> +<title>builder</title> +<polygon fill="none" stroke="#000000" points="239.74,-144 184.4251,-144 184.4251,-108 239.74,-108 239.74,-144"/> +<text text-anchor="middle" x="212.0825" y="-121.8" font-family="Times,serif" font-size="14.00" fill="#000000">builder</text> +</g> +<!-- src->builder --> +<g id="edge3" class="edge"> +<title>src->builder</title> +<path fill="none" stroke="#000000" d="M83.228,-103.5802C108.2573,-107.9351 146.1694,-114.5316 174.3985,-119.4432"/> +<polygon fill="#000000" stroke="#000000" points="173.8668,-122.9032 184.3187,-121.1693 175.0668,-116.0068 173.8668,-122.9032"/> +</g> +<!-- frontend --> +<g id="node7" class="node"> +<title>frontend</title> +<polygon fill="none" stroke="#000000" points="244.0104,-90 180.1547,-90 180.1547,-54 244.0104,-54 244.0104,-90"/> +<text text-anchor="middle" x="212.0825" y="-67.8" font-family="Times,serif" font-size="14.00" fill="#000000">frontend</text> +</g> +<!-- src->frontend --> +<g id="edge4" class="edge"> +<title>src->frontend</title> +<path fill="none" stroke="#000000" d="M83.228,-94.4198C106.9974,-90.2841 142.3852,-84.1269 170.0648,-79.3108"/> +<polygon fill="#000000" stroke="#000000" points="171.0036,-82.7001 180.2555,-77.5377 169.8036,-75.8037 171.0036,-82.7001"/> +</g> +<!-- golang --> +<g id="node2" class="node"> +<title>golang</title> +<ellipse fill="none" stroke="#000000" cx="56.9041" cy="-153" rx="56.8084" ry="18"/> +<text text-anchor="middle" x="56.9041" y="-148.8" font-family="Times,serif" font-size="14.00" fill="#000000">golang:1.22</text> +</g> +<!-- golang->builder --> +<g id="edge6" class="edge"> +<title>golang->builder</title> +<path fill="none" stroke="#000000" d="M106.8577,-144.3084C128.7379,-140.5014 153.9909,-136.1075 174.2729,-132.5786"/> +<polygon fill="#000000" stroke="#000000" points="175.1297,-135.9822 184.3817,-130.8198 173.9297,-129.0858 175.1297,-135.9822"/> +</g> +<!-- nodejs --> +<g id="node3" class="node"> +<title>nodejs</title> +<ellipse fill="none" stroke="#000000" cx="56.9041" cy="-45" rx="36.4975" ry="18"/> +<text text-anchor="middle" x="56.9041" y="-40.8" font-family="Times,serif" font-size="14.00" fill="#000000">nodejs</text> +</g> +<!-- nodejs->frontend --> +<g id="edge5" class="edge"> +<title>nodejs->frontend</title> +<path fill="none" stroke="#000000" d="M91.3432,-50.9922C114.5476,-55.0296 145.3019,-60.3806 169.9639,-64.6716"/> +<polygon fill="#000000" stroke="#000000" points="169.6686,-68.1728 180.1205,-66.4388 170.8685,-61.2764 169.6686,-68.1728"/> +</g> +<!-- ubuntu --> +<g id="node4" class="node"> +<title>ubuntu</title> +<ellipse fill="none" stroke="#000000" cx="212.0825" cy="-18" rx="62.0495" ry="18"/> +<text text-anchor="middle" x="212.0825" y="-13.8" font-family="Times,serif" font-size="14.00" fill="#000000">ubuntu:24.04</text> +</g> +<!-- runner --> +<g id="node6" class="node"> +<title>runner</title> +<polygon fill="none" stroke="#000000" points="364.3568,-90 310.3568,-90 310.3568,-54 364.3568,-54 364.3568,-90"/> +<text text-anchor="middle" x="337.3568" y="-67.8" font-family="Times,serif" font-size="14.00" fill="#000000">runner</text> +</g> +<!-- ubuntu->runner --> +<g id="edge7" class="edge"> +<title>ubuntu->runner</title> +<path fill="none" stroke="#000000" d="M246.9784,-33.042C263.6819,-40.2421 283.6838,-48.864 300.6196,-56.1642"/> +<polygon fill="#000000" stroke="#000000" points="299.62,-59.5447 310.1887,-60.289 302.3909,-53.1164 299.62,-59.5447"/> +</g> +<!-- builder->runner --> +<g id="edge1" class="edge"> +<title>builder->runner</title> +<path fill="none" stroke="#000000" d="M239.885,-114.0156C257.7679,-106.3071 281.2054,-96.2043 300.6159,-87.8373"/> +<polygon fill="#000000" stroke="#000000" points="302.2654,-90.9377 310.0632,-83.7651 299.4945,-84.5094 302.2654,-90.9377"/> +</g> +<!-- output --> +<g id="node8" class="node"> +<title>output</title> +<polygon fill="none" stroke="#000000" points="454.3568,-90 400.3568,-90 400.3568,-54 454.3568,-54 454.3568,-90"/> +<text text-anchor="middle" x="427.3568" y="-67.8" font-family="Times,serif" font-size="14.00" fill="#000000">output</text> +</g> +<!-- runner->output --> +<g id="edge8" class="edge"> +<title>runner->output</title> +<path fill="none" stroke="#000000" d="M364.3598,-72C372.3845,-72 381.3234,-72 389.8877,-72"/> +<polygon fill="#000000" stroke="#000000" points="390.0619,-75.5001 400.0619,-72 390.0618,-68.5001 390.0619,-75.5001"/> +</g> +<!-- frontend->runner --> +<g id="edge2" class="edge"> +<title>frontend->runner</title> +<path fill="none" stroke="#000000" d="M244.0187,-72C261.1074,-72 282.2465,-72 300.0837,-72"/> +<polygon fill="#000000" stroke="#000000" points="300.1489,-75.5001 310.1489,-72 300.1488,-68.5001 300.1489,-75.5001"/> +</g> +</g> +</svg> diff --git a/lume/src/static/img/xesite-graph.dot b/lume/src/static/img/xesite-graph.dot new file mode 100644 index 0000000..70b7a62 --- /dev/null +++ b/lume/src/static/img/xesite-graph.dot @@ -0,0 +1,44 @@ +digraph { + rankdir=LR; + node [shape=box]; + + subgraph cluster_0 { + label = "ghcr images"; + style=filled; + color=lightgrey; + ghcrxesite [label="xe/site/bin"]; + ghcrpatreon [label="xe/site/patreon"]; + } + + src [label="./*", shape=ellipse]; + golang [label="golang:1.22-alpine", shape=ellipse]; + alpine [label="alpine:edge", shape=ellipse]; + deps [label="+deps"]; + fonts [label="+fonts"]; + dhalljson [label="+dhall-json"]; + buildpatreon [label="+build-patreon"]; + patreon [label="+patreon"]; + buildxesite [label="+build-xesite"]; + xesite [label="+xesite"]; + + { rank=same; golang; alpine; src; } + { rank=same; deps; fonts; dhalljson; } + { rank=same; patreon; xesite; } + + src -> deps; + golang -> deps; + alpine -> fonts; + alpine -> dhalljson; + deps -> buildpatreon; + deps -> buildxesite; + src -> buildpatreon; + buildpatreon -> patreon; + src -> buildxesite; + buildxesite -> xesite; + alpine -> patreon; + patreon -> ghcrpatreon; + alpine -> xesite; + fonts -> xesite; + dhalljson -> xesite; + xesite -> ghcrxesite; +} diff --git a/lume/src/static/img/xesite-graph.svg b/lume/src/static/img/xesite-graph.svg new file mode 100644 index 0000000..254ff2e --- /dev/null +++ b/lume/src/static/img/xesite-graph.svg @@ -0,0 +1,186 @@ +<?xml version="1.0" encoding="UTF-8" standalone="no"?> +<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" + "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"> +<!-- Generated by graphviz version 2.40.1 (20161225.0304) + --> +<!-- Title: %0 Pages: 1 --> +<svg width="679pt" height="293pt" + viewBox="0.00 0.00 678.93 293.00" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"> +<g id="graph0" class="graph" transform="scale(1 1) rotate(0) translate(4 289)"> +<title>%0</title> +<polygon fill="#ffffff" stroke="transparent" points="-4,4 -4,-289 674.9278,-289 674.9278,4 -4,4"/> +<g id="clust1" class="cluster"> +<title>cluster_0</title> +<polygon fill="#d3d3d3" stroke="#d3d3d3" points="548.5112,-80 548.5112,-211 662.9278,-211 662.9278,-80 548.5112,-80"/> +<text text-anchor="middle" x="605.7195" y="-194.4" font-family="Times,serif" font-size="14.00" fill="#000000">ghcr images</text> +</g> +<!-- ghcrxesite --> +<g id="node1" class="node"> +<title>ghcrxesite</title> +<polygon fill="none" stroke="#000000" points="643.047,-124 568.392,-124 568.392,-88 643.047,-88 643.047,-124"/> +<text text-anchor="middle" x="605.7195" y="-101.8" font-family="Times,serif" font-size="14.00" fill="#000000">xe/site/bin</text> +</g> +<!-- ghcrpatreon --> +<g id="node2" class="node"> +<title>ghcrpatreon</title> +<polygon fill="none" stroke="#000000" points="655.137,-178 556.302,-178 556.302,-142 655.137,-142 655.137,-178"/> +<text text-anchor="middle" x="605.7195" y="-155.8" font-family="Times,serif" font-size="14.00" fill="#000000">xe/site/patreon</text> +</g> +<!-- src --> +<g id="node3" class="node"> +<title>src</title> +<ellipse fill="none" stroke="#000000" cx="83.7166" cy="-267" rx="27" ry="18"/> +<text text-anchor="middle" x="83.7166" y="-262.8" font-family="Times,serif" font-size="14.00" fill="#000000">./*</text> +</g> +<!-- deps --> +<g id="node6" class="node"> +<title>deps</title> +<polygon fill="none" stroke="#000000" points="270.3763,-262 216.3763,-262 216.3763,-226 270.3763,-226 270.3763,-262"/> +<text text-anchor="middle" x="243.3763" y="-239.8" font-family="Times,serif" font-size="14.00" fill="#000000">+deps</text> +</g> +<!-- src->deps --> +<g id="edge1" class="edge"> +<title>src->deps</title> +<path fill="none" stroke="#000000" d="M110.44,-263.1503C136.6097,-259.3804 176.7143,-253.6031 206.0314,-249.3798"/> +<polygon fill="#000000" stroke="#000000" points="206.8998,-252.7909 216.2986,-247.9007 205.9017,-245.8624 206.8998,-252.7909"/> +</g> +<!-- buildpatreon --> +<g id="node9" class="node"> +<title>buildpatreon</title> +<polygon fill="none" stroke="#000000" points="418.7951,-274 319.1605,-274 319.1605,-238 418.7951,-238 418.7951,-274"/> +<text text-anchor="middle" x="368.9778" y="-251.8" font-family="Times,serif" font-size="14.00" fill="#000000">+build-patreon</text> +</g> +<!-- src->buildpatreon --> +<g id="edge7" class="edge"> +<title>src->buildpatreon</title> +<path fill="none" stroke="#000000" d="M110.5738,-269.3504C148.9578,-272.2981 221.6832,-276.3559 283.3194,-271 291.6436,-270.2767 300.372,-269.165 308.9408,-267.8668"/> +<polygon fill="#000000" stroke="#000000" points="309.574,-271.31 318.8928,-266.2691 308.4644,-264.3985 309.574,-271.31"/> +</g> +<!-- buildxesite --> +<g id="node11" class="node"> +<title>buildxesite</title> +<polygon fill="none" stroke="#000000" points="413.9692,-220 323.9864,-220 323.9864,-184 413.9692,-184 413.9692,-220"/> +<text text-anchor="middle" x="368.9778" y="-197.8" font-family="Times,serif" font-size="14.00" fill="#000000">+build-xesite</text> +</g> +<!-- src->buildxesite --> +<g id="edge9" class="edge"> +<title>src->buildxesite</title> +<path fill="none" stroke="#000000" d="M109.2708,-260.5985C126.1919,-255.918 148.6447,-248.8511 167.4332,-240 184.6094,-231.9085 185.4676,-223.1425 203.4332,-217 238.791,-204.911 280.7333,-201.2138 313.5532,-200.512"/> +<polygon fill="#000000" stroke="#000000" points="313.9133,-204.0078 323.8686,-200.3825 313.8254,-197.0084 313.9133,-204.0078"/> +</g> +<!-- golang --> +<g id="node4" class="node"> +<title>golang</title> +<ellipse fill="none" stroke="#000000" cx="83.7166" cy="-213" rx="83.9338" ry="18"/> +<text text-anchor="middle" x="83.7166" y="-208.8" font-family="Times,serif" font-size="14.00" fill="#000000">golang:1.22-alpine</text> +</g> +<!-- golang->deps --> +<g id="edge2" class="edge"> +<title>golang->deps</title> +<path fill="none" stroke="#000000" d="M145.9024,-225.0742C166.2501,-229.0249 188.2076,-233.2883 206.1218,-236.7665"/> +<polygon fill="#000000" stroke="#000000" points="205.6755,-240.2452 216.1594,-238.7155 207.0098,-233.3735 205.6755,-240.2452"/> +</g> +<!-- alpine --> +<g id="node5" class="node"> +<title>alpine</title> +<ellipse fill="none" stroke="#000000" cx="83.7166" cy="-87" rx="55.5966" ry="18"/> +<text text-anchor="middle" x="83.7166" y="-82.8" font-family="Times,serif" font-size="14.00" fill="#000000">alpine:edge</text> +</g> +<!-- fonts --> +<g id="node7" class="node"> +<title>fonts</title> +<polygon fill="none" stroke="#000000" points="270.3763,-36 216.3763,-36 216.3763,0 270.3763,0 270.3763,-36"/> +<text text-anchor="middle" x="243.3763" y="-13.8" font-family="Times,serif" font-size="14.00" fill="#000000">+fonts</text> +</g> +<!-- alpine->fonts --> +<g id="edge3" class="edge"> +<title>alpine->fonts</title> +<path fill="none" stroke="#000000" d="M117.1865,-72.5353C143.4098,-61.2024 179.8468,-45.4555 206.8285,-33.7948"/> +<polygon fill="#000000" stroke="#000000" points="208.514,-36.8794 216.3049,-29.6994 205.737,-30.4537 208.514,-36.8794"/> +</g> +<!-- dhalljson --> +<g id="node8" class="node"> +<title>dhalljson</title> +<polygon fill="none" stroke="#000000" points="283.2626,-128 203.49,-128 203.49,-92 283.2626,-92 283.2626,-128"/> +<text text-anchor="middle" x="243.3763" y="-105.8" font-family="Times,serif" font-size="14.00" fill="#000000">+dhall-json</text> +</g> +<!-- alpine->dhalljson --> +<g id="edge4" class="edge"> +<title>alpine->dhalljson</title> +<path fill="none" stroke="#000000" d="M134.6729,-94.3406C153.3842,-97.0361 174.5414,-100.0839 193.185,-102.7696"/> +<polygon fill="#000000" stroke="#000000" points="192.8556,-106.2582 203.2525,-104.2199 193.8537,-99.3297 192.8556,-106.2582"/> +</g> +<!-- patreon --> +<g id="node10" class="node"> +<title>patreon</title> +<polygon fill="none" stroke="#000000" points="520.4488,-178 454.6986,-178 454.6986,-142 520.4488,-142 520.4488,-178"/> +<text text-anchor="middle" x="487.5737" y="-155.8" font-family="Times,serif" font-size="14.00" fill="#000000">+patreon</text> +</g> +<!-- alpine->patreon --> +<g id="edge11" class="edge"> +<title>alpine->patreon</title> +<path fill="none" stroke="#000000" d="M113.3312,-102.4634C137.032,-114.069 171.4767,-129.2406 203.4332,-137 287.0198,-157.2957 388.0747,-160.6347 444.3537,-160.6864"/> +<polygon fill="#000000" stroke="#000000" points="444.6698,-164.1857 454.6605,-160.6591 444.6512,-157.1857 444.6698,-164.1857"/> +</g> +<!-- xesite --> +<g id="node12" class="node"> +<title>xesite</title> +<polygon fill="none" stroke="#000000" points="515.6251,-124 459.5223,-124 459.5223,-88 515.6251,-88 515.6251,-124"/> +<text text-anchor="middle" x="487.5737" y="-101.8" font-family="Times,serif" font-size="14.00" fill="#000000">+xesite</text> +</g> +<!-- alpine->xesite --> +<g id="edge13" class="edge"> +<title>alpine->xesite</title> +<path fill="none" stroke="#000000" d="M139.1374,-84.7623C159.3681,-84.0473 182.4324,-83.3507 203.4332,-83 238.9332,-82.4071 247.8702,-81.0104 283.3194,-83 341.3325,-86.256 408.1787,-94.6747 449.1526,-100.3715"/> +<polygon fill="#000000" stroke="#000000" points="448.8514,-103.8635 459.2418,-101.7921 449.8275,-96.9319 448.8514,-103.8635"/> +</g> +<!-- deps->buildpatreon --> +<g id="edge5" class="edge"> +<title>deps->buildpatreon</title> +<path fill="none" stroke="#000000" d="M270.63,-246.6038C281.9563,-247.6859 295.5827,-248.9878 309.034,-250.273"/> +<polygon fill="#000000" stroke="#000000" points="308.9576,-253.7815 319.2452,-251.2485 309.6234,-246.8132 308.9576,-253.7815"/> +</g> +<!-- deps->buildxesite --> +<g id="edge6" class="edge"> +<title>deps->buildxesite</title> +<path fill="none" stroke="#000000" d="M270.63,-234.8866C283.2955,-230.6514 298.8374,-225.4543 313.7942,-220.4529"/> +<polygon fill="#000000" stroke="#000000" points="315.1285,-223.6973 323.5024,-217.2066 312.9086,-217.0586 315.1285,-223.6973"/> +</g> +<!-- fonts->xesite --> +<g id="edge14" class="edge"> +<title>fonts->xesite</title> +<path fill="none" stroke="#000000" d="M270.4481,-18.8026C305.9875,-20.7618 369.6083,-27.4313 418.6362,-50 434.837,-57.4576 450.4709,-69.6079 462.743,-80.688"/> +<polygon fill="#000000" stroke="#000000" points="460.6012,-83.4773 470.2942,-87.7547 465.3843,-78.3662 460.6012,-83.4773"/> +</g> +<!-- dhalljson->xesite --> +<g id="edge15" class="edge"> +<title>dhalljson->xesite</title> +<path fill="none" stroke="#000000" d="M283.4271,-109.344C329.2746,-108.593 403.8484,-107.3714 449.0473,-106.6311"/> +<polygon fill="#000000" stroke="#000000" points="449.3804,-110.1262 459.3217,-106.4628 449.2657,-103.1271 449.3804,-110.1262"/> +</g> +<!-- buildpatreon->patreon --> +<g id="edge8" class="edge"> +<title>buildpatreon->patreon</title> +<path fill="none" stroke="#000000" d="M405.274,-237.867C409.9012,-235.096 414.4705,-232.114 418.6362,-229 435.5292,-216.3717 452.303,-199.6979 465.0746,-185.9047"/> +<polygon fill="#000000" stroke="#000000" points="467.9689,-187.9238 472.1025,-178.1686 462.7877,-183.2169 467.9689,-187.9238"/> +</g> +<!-- patreon->ghcrpatreon --> +<g id="edge12" class="edge"> +<title>patreon->ghcrpatreon</title> +<path fill="none" stroke="#000000" d="M520.4839,-160C528.434,-160 537.1764,-160 545.9396,-160"/> +<polygon fill="#000000" stroke="#000000" points="546.1901,-163.5001 556.1901,-160 546.19,-156.5001 546.1901,-163.5001"/> +</g> +<!-- buildxesite->xesite --> +<g id="edge10" class="edge"> +<title>buildxesite->xesite</title> +<path fill="none" stroke="#000000" d="M391.2848,-183.9431C409.8847,-168.8871 436.569,-147.2868 457.1317,-130.6419"/> +<polygon fill="#000000" stroke="#000000" points="459.393,-133.3145 464.9635,-124.3023 454.9887,-127.8736 459.393,-133.3145"/> +</g> +<!-- xesite->ghcrxesite --> +<g id="edge16" class="edge"> +<title>xesite->ghcrxesite</title> +<path fill="none" stroke="#000000" d="M515.8726,-106C528.5072,-106 543.7476,-106 558.059,-106"/> +<polygon fill="#000000" stroke="#000000" points="558.2652,-109.5001 568.2652,-106 558.2651,-102.5001 558.2652,-109.5001"/> +</g> +</g> +</svg> |
