A vibe coded tangled fork which supports pijul.

blog: move blog posts and templates here

Signed-off-by: Anirudh Oppiliappan <anirudh@tangled.org>

authored by

Anirudh Oppiliappan and committed by tangled.org 3092607e 1f39f869

+2020 -70
+174
blog/blog.go
··· 1 + package blog 2 + 3 + import ( 4 + "bytes" 5 + "cmp" 6 + "html/template" 7 + "io" 8 + "io/fs" 9 + "os" 10 + "slices" 11 + "strings" 12 + "time" 13 + 14 + "github.com/adrg/frontmatter" 15 + "github.com/gorilla/feeds" 16 + 17 + "tangled.org/core/appview/pages" 18 + "tangled.org/core/appview/pages/markup" 19 + textension "tangled.org/core/appview/pages/markup/extension" 20 + ) 21 + 22 + type Author struct { 23 + Name string `yaml:"name"` 24 + Email string `yaml:"email"` 25 + Handle string `yaml:"handle"` 26 + } 27 + 28 + type PostMeta struct { 29 + Slug string `yaml:"slug"` 30 + Title string `yaml:"title"` 31 + Subtitle string `yaml:"subtitle"` 32 + Date string `yaml:"date"` 33 + Authors []Author `yaml:"authors"` 34 + Image string `yaml:"image"` 35 + Draft bool `yaml:"draft"` 36 + } 37 + 38 + type Post struct { 39 + Meta PostMeta 40 + Body template.HTML 41 + } 42 + 43 + func (p Post) ParsedDate() time.Time { 44 + t, _ := time.Parse("2006-01-02", p.Meta.Date) 45 + return t 46 + } 47 + 48 + type indexParams struct { 49 + LoggedInUser any 50 + Posts []Post 51 + Featured []Post 52 + } 53 + 54 + type postParams struct { 55 + LoggedInUser any 56 + Post Post 57 + } 58 + 59 + // Posts parses and returns all non-draft posts sorted newest-first. 60 + func Posts(postsDir string) ([]Post, error) { 61 + return parsePosts(postsDir, false) 62 + } 63 + 64 + // AllPosts parses and returns all posts including drafts, sorted newest-first. 65 + func AllPosts(postsDir string) ([]Post, error) { 66 + return parsePosts(postsDir, true) 67 + } 68 + 69 + func parsePosts(postsDir string, includeDrafts bool) ([]Post, error) { 70 + fsys := os.DirFS(postsDir) 71 + 72 + entries, err := fs.ReadDir(fsys, ".") 73 + if err != nil { 74 + return nil, err 75 + } 76 + 77 + rctx := &markup.RenderContext{ 78 + RendererType: markup.RendererTypeDefault, 79 + Sanitizer: markup.NewSanitizer(), 80 + } 81 + var posts []Post 82 + for _, entry := range entries { 83 + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".md") { 84 + continue 85 + } 86 + 87 + data, err := fs.ReadFile(fsys, entry.Name()) 88 + if err != nil { 89 + return nil, err 90 + } 91 + 92 + var meta PostMeta 93 + rest, err := frontmatter.Parse(bytes.NewReader(data), &meta) 94 + if err != nil { 95 + return nil, err 96 + } 97 + 98 + if meta.Draft && !includeDrafts { 99 + continue 100 + } 101 + 102 + htmlStr := rctx.RenderMarkdownWith(string(rest), markup.NewMarkdownWith("", textension.Dashes)) 103 + sanitized := rctx.SanitizeDefault(htmlStr) 104 + 105 + posts = append(posts, Post{ 106 + Meta: meta, 107 + Body: template.HTML(sanitized), 108 + }) 109 + } 110 + 111 + slices.SortFunc(posts, func(a, b Post) int { 112 + return cmp.Compare(b.Meta.Date, a.Meta.Date) 113 + }) 114 + 115 + return posts, nil 116 + } 117 + 118 + func AtomFeed(posts []Post, baseURL string) (string, error) { 119 + feed := &feeds.Feed{ 120 + Title: "the tangled blog", 121 + Link: &feeds.Link{Href: baseURL}, 122 + Author: &feeds.Author{Name: "Tangled"}, 123 + Created: time.Now(), 124 + } 125 + 126 + for _, p := range posts { 127 + postURL := strings.TrimRight(baseURL, "/") + "/" + p.Meta.Slug 128 + 129 + var authorName string 130 + for i, a := range p.Meta.Authors { 131 + if i > 0 { 132 + authorName += " & " 133 + } 134 + authorName += a.Name 135 + } 136 + 137 + feed.Items = append(feed.Items, &feeds.Item{ 138 + Title: p.Meta.Title, 139 + Link: &feeds.Link{Href: postURL}, 140 + Description: p.Meta.Subtitle, 141 + Author: &feeds.Author{Name: authorName}, 142 + Created: p.ParsedDate(), 143 + }) 144 + } 145 + 146 + return feed.ToAtom() 147 + } 148 + 149 + // RenderIndex renders the blog index page to w. 150 + func RenderIndex(p *pages.Pages, templatesDir string, posts []Post, w io.Writer) error { 151 + tpl, err := p.ParseWith(os.DirFS(templatesDir), "index.html") 152 + if err != nil { 153 + return err 154 + } 155 + var featured []Post 156 + for _, post := range posts { 157 + if post.Meta.Image != "" { 158 + featured = append(featured, post) 159 + if len(featured) == 3 { 160 + break 161 + } 162 + } 163 + } 164 + return tpl.ExecuteTemplate(w, "layouts/base", indexParams{Posts: posts, Featured: featured}) 165 + } 166 + 167 + // RenderPost renders a single blog post page to w. 168 + func RenderPost(p *pages.Pages, templatesDir string, post Post, w io.Writer) error { 169 + tpl, err := p.ParseWith(os.DirFS(templatesDir), "post.html") 170 + if err != nil { 171 + return err 172 + } 173 + return tpl.ExecuteTemplate(w, "layouts/base", postParams{Post: post}) 174 + }
+12
blog/config.yaml
··· 1 + preBuild: 2 + - tailwindcss -i ../input.css -o static/tw.css 3 + title: the tangled blog 4 + # note the trailing slash! 5 + url: "https://blog.tangled.org/" 6 + description: "" 7 + author: 8 + name: "Anirudh Oppiliappan" 9 + email: "anirudh@tangled.sh" 10 + defaultTemplate: text.html 11 + extraTemplateDirs: 12 + - ../appview/pages/templates
+160
blog/posts/6-months.md
··· 1 + --- 2 + atroot: true 3 + template: 4 + slug: 6-months 5 + title: 6 months of Tangled 6 + subtitle: a quick recap, and notes on the future 7 + date: 2025-10-21 8 + image: https://assets.tangled.network/blog/6-months.png 9 + authors: 10 + - name: Anirudh 11 + email: anirudh@tangled.org 12 + handle: anirudh.fi 13 + - name: Akshay 14 + email: akshay@tangled.org 15 + handle: oppi.li 16 + draft: false 17 + --- 18 + 19 + Hello Tanglers! It's been over 6 months since we first announced 20 + Tangled, so we figured we'd do a quick retrospective of what we built so 21 + far and what's next. 22 + 23 + If you're new here, here's a quick overview: Tangled is a git hosting 24 + and collaboration platform built on top of the [AT 25 + Protocol](https://atproto.com). You can read a bit more about our 26 + architecture [here](/intro). 27 + 28 + ## new logo and mascot: dolly! 29 + 30 + Tangled finally has a logo! Designed by Akshay himself, Dolly is in 31 + reference to the first ever *cloned* mammal. For a full set of brand assets and guidelines, see our new [branding page](https://tangled.org/brand). 32 + 33 + ![logo with text](https://assets.tangled.network/blog/logo_with_text.jpeg) 34 + 35 + With that, let's recap the major platform improvements so far! 36 + 37 + ## pull requests: doubling down on jujutsu 38 + 39 + One of the first major features we built was our [pull requests 40 + system](/pulls), which follows a unique round-based submission & review 41 + approach. This was really fun to innovate on -- it remains one of 42 + Tangled's core differentiators, and one we plan to keep improving. 43 + 44 + In the same vein, we're the first ever code forge to support [stacking 45 + pull requests](/stacking) using Jujutsu! We're big fans of the tool and 46 + we use it everyday as we hack on 47 + [tangled.org/core](https://tangled.org/@tangled.org/core). 48 + 49 + Ultimately, we think PR-based collaboration should evolve beyond the 50 + traditional model, and we're excited to keep experimenting with new 51 + ideas that make code review and contribution easier! 52 + 53 + ## spindle 54 + 55 + CI was our most requested feature, and we spent a *lot* of time debating 56 + how to approach it. We considered integrating with existing platforms, 57 + but none were good fits. So we gave in to NIH and [built spindle 58 + ourselves](/ci)! This allowed us to go in on Nix using Nixery to build 59 + CI images on the fly and cache them. 60 + 61 + Spindle is still early but designed to be extensible and is AT-native. 62 + The current Docker/Nixery-based engine is limiting -- we plan to switch 63 + to micro VMs down the line to run full-fledged NixOS (and other base 64 + images). Meanwhile, if you've got ideas for other spindle backends 65 + (Kubernetes?!), we'd love to [hear from you](https://chat.tangled.org). 66 + 67 + ## XRPC APIs 68 + 69 + We introduced a complete migration of the knotserver to an 70 + [XRPC](https://atproto.com/specs/xrpc) API. Alongside this, we also 71 + decoupled the knot from the appview by getting rid of the registration 72 + secret, which was centralizing. Knots (and spindles) simply declare 73 + their owner, and any appview can verify ownership. Once we stabilize the 74 + [lexicon definitions](lexicons) for these XRPC calls, building clients 75 + for knots, or alternate implementations should become much simpler. 76 + 77 + [lexicons]: https://tangled.sh/@tangled.sh/core/tree/master/lexicons 78 + 79 + ## issues rework 80 + 81 + Issues got a major rework (and facelift) too! They are now threaded: 82 + top-level comments with replies. This makes Q/A style discussions much 83 + easier to follow! 84 + 85 + ![issue thread](https://assets.tangled.network/blog/issue-threading.webp) 86 + 87 + ## hosted PDS 88 + 89 + A complaint we often recieved was the need for a Bluesky account to use 90 + Tangled; and besides, we realised that the overlap between Bluesky users 91 + and possible Tangled users only goes so far -- we aim to be a generic 92 + code forge after all, AT just happens to be an implementation 93 + detail. 94 + 95 + To address this, we spun up the tngl.sh PDS hosted right here in 96 + Finland. The only way to get an account on this PDS is by [signing 97 + up](https://tangled.sh/signup). There's a lot we can do to improve this 98 + experience as a generic PDS host, but we're still working out details 99 + around that. 100 + 101 + ## labels 102 + 103 + You can easily categorize issues and pulls via labels! There is plenty 104 + of customization available: 105 + 106 + - labels can be basic, or they can have a key and value set, for example: 107 + `wontfix` or `priority/high` 108 + - labels can be constrained to a set of values: `priority: [high medium low]` 109 + - there can be multiple labels of a given type: `reviewed-by: @oppi.li`, 110 + `reviewed-by: @anirudh.fi` 111 + 112 + The options are endless! You can access them via your repo's settings page. 113 + 114 + <div class="flex justify-center items-center gap-2"> 115 + <figure class="w-full m-0 flex flex-col items-center"> 116 + <a href="https://assets.tangled.network/blog/labels_vignette.webp"> 117 + <img class="my-1 w-full h-auto cursor-pointer" src="https://assets.tangled.network/blog/labels_vignette.webp" alt="A set of labels applied to an issue."> 118 + </a> 119 + <figcaption class="text-center">A set of labels applied to an issue.</figcaption> 120 + </figure> 121 + 122 + <figure class="w-1/3 m-0 flex flex-col items-center"> 123 + <a href="https://assets.tangled.network/blog/new_label_modal.png"> 124 + <img class="my-1 w-full h-auto cursor-pointer" src="https://assets.tangled.network/blog/new_label_modal.png" alt="Create custom key-value type labels."> 125 + </a> 126 + <figcaption class="text-center">Create custom key-value type labels.</figcaption> 127 + </figure> 128 + </div> 129 + 130 + 131 + ## notifications 132 + 133 + In-app notifications now exist! You get notifications for a variety of events now: 134 + 135 + * new issues/pulls on your repos (also for collaborators) 136 + * comments on your issues/pulls (also for collaborators) 137 + * close/reopen (or merge) of issues/pulls 138 + * new stars 139 + * new follows 140 + 141 + All of this can be fine-tuned in [/settings/notifications](https://tangled.org/settings/notifications). 142 + 143 + ![notifications](https://assets.tangled.network/blog/notifications.png) 144 + 145 + 146 + ## the future 147 + 148 + We're working on a *lot* of exciting new things and possibly some big 149 + announcements to come. Be on the lookout for: 150 + 151 + * email notifications 152 + * preliminary support for issue and PR search 153 + * total "atprotation" [^1] -- the last two holdouts here are repo and pull records 154 + * total federation -- i.e. supporting third-party appviews by making it 155 + reproducible 156 + * achieve complete independence from Bluesky PBC by hosting our own relay 157 + 158 + That's all for now; we'll see you in the atmosphere! Meanwhile, if you'd like to contribute to projects on Tangled, make sure to check out the [good first issues page](https://tangled.org/goodfirstissues) to get started! 159 + 160 + [^1]: atprotation implies a two-way sync between the PDS and appview. Currently, pull requests and repositories are not ingested -- so writing/updating either records on your PDS will not show up on the appview.
+214
blog/posts/ci.md
··· 1 + --- 2 + atroot: true 3 + template: 4 + slug: ci 5 + title: introducing spindle 6 + subtitle: tangled's new CI runner is now generally available 7 + date: 2025-08-06 8 + authors: 9 + - name: Anirudh 10 + email: anirudh@tangled.sh 11 + handle: anirudh.fi 12 + - name: Akshay 13 + email: akshay@tangled.sh 14 + handle: oppi.li 15 + --- 16 + 17 + Since launching Tangled, continuous integration has 18 + consistently topped our feature request list. Today, CI is 19 + no longer a wishlist item, but a fully-featured reality. 20 + 21 + Meet **spindle**: Tangled's new CI runner built atop Nix and 22 + AT Protocol. In typical Tangled fashion we've been 23 + dogfooding spindle for a while now; this very blog post 24 + you're reading was [built and published using 25 + spindle](https://tangled.sh/@tangled.sh/site/pipelines/452/workflow/deploy.yaml). 26 + 27 + Tangled is a new social-enabled Git collaboration platform, 28 + [read our intro](/intro) for more about the project. 29 + 30 + ![spindle architecture](https://assets.tangled.network/blog/spindle-arch.png) 31 + 32 + ## how spindle works 33 + 34 + Spindle is designed around simplicity and the decentralized 35 + nature of the AT Protocol. In ingests "pipeline" records and 36 + emits job status updates. 37 + 38 + When you push code or open a pull request, the knot hosting 39 + your repository emits a pipeline event 40 + (`sh.tangled.pipeline`). Running as a dedicated service, 41 + spindle subscribes to these events via websocket connections 42 + to your knot. 43 + 44 + Once triggered, spindle reads your pipeline manifest, spins 45 + up the necessary execution environment (covered below), and 46 + runs your defined workflow steps. Throughout execution, it 47 + streams real-time logs and status updates 48 + (`sh.tangled.pipeline.status`) back through websockets, 49 + which the Tangled appview subscribes to for live updates. 50 + 51 + Over at the appview, these updates are ingested and stored, 52 + and logs are streamed live. 53 + 54 + ## spindle pipelines 55 + 56 + The pipeline manifest is defined in YAML, and should be 57 + relatively familiar to those that have used other CI 58 + solutions. Here's a minimal example: 59 + 60 + ```yaml 61 + # test.yaml 62 + 63 + when: 64 + - event: ["push", "pull_request"] 65 + branch: ["master"] 66 + 67 + dependencies: 68 + nixpkgs: 69 + - go 70 + 71 + steps: 72 + - name: run all tests 73 + environment: 74 + CGO_ENABLED: 1 75 + command: | 76 + go test -v ./... 77 + ``` 78 + 79 + You can read the [full manifest spec 80 + here](https://tangled.sh/@tangled.sh/core/blob/master/docs/spindle/pipeline.md), 81 + but the `dependencies` block is the real interesting bit. 82 + Dependencies for your workflow, like Go, Node.js, Python 83 + etc. can be pulled in from nixpkgs. 84 + [Nixpkgs](https://github.com/nixos/nixpkgs/) -- for the 85 + uninitiated -- is a vast collection of packages for the Nix 86 + package manager. Fortunately, you needn't know nor care 87 + about Nix to use it! Just head to https://search.nixos.org 88 + to find your package of choice (I'll bet 1€ that it's 89 + there[^1]), toss it in the list and run your build. The 90 + Nix-savvy of you lot will be happy to know that you can use 91 + custom registries too. 92 + 93 + [^1]: I mean, if it isn't there, it's nowhere. 94 + 95 + Workflow manifests are intentionally simple. We do not want 96 + to include a "marketplace" of workflows or complex job 97 + orchestration. The bulk of the work should be offloaded to a 98 + build system, and CI should be used simply for finishing 99 + touches. That being said, this is still the first revision 100 + for CI, there is a lot more on the roadmap! 101 + 102 + Let's take a look at how spindle executes workflow steps. 103 + 104 + ## workflow execution 105 + 106 + At present, the spindle "engine" supports just the Docker 107 + backend[^2]. Podman is known to work with the Docker socket 108 + feature enabled. Each step is run in a separate container, 109 + with the `/tangled/workspace` and `/nix` volumes persisted 110 + across steps. 111 + 112 + [^2]: Support for additional backends like Firecracker are 113 + planned. Contributions welcome! 114 + 115 + The container image is built using 116 + [Nixery](https://nixery.dev). Nixery is a nifty little tool 117 + that takes a path-separated set of Nix packages and returns 118 + an OCI image with each package in a separate layer. Try this 119 + in your terminal if you've got Docker installed: 120 + 121 + ``` 122 + docker run nixery.dev/bash/hello-go hello-go 123 + ``` 124 + 125 + This should output `Hello, world!`. This is running the 126 + [hello-go](https://search.nixos.org/packages?channel=25.05&show=hello-go) 127 + package from nixpkgs. 128 + 129 + Nixery is super handy since we can construct these images 130 + for CI environments on the fly, with all dependencies baked 131 + in, and the best part: caching for commonly used packages is 132 + free thanks to Docker (pre-existing layers get reused). We 133 + run a Nixery instance of our own at 134 + https://nixery.tangled.sh but you may override that if you 135 + choose to. 136 + 137 + ## debugging CI 138 + 139 + We understand that debugging CI can be the worst. There are 140 + two parts to this problem: 141 + 142 + - CI services often bring their own workflow definition 143 + formats and it can sometimes be difficult to know why the 144 + workflow won't run or why the workflow definition is 145 + incorrect 146 + - The CI job itself fails, but this has more to do with the 147 + build system of choice 148 + 149 + To mend the first problem: we are making use of git 150 + [push-options](https://git-scm.com/docs/git-push#Documentation/git-push.txt--ooption). 151 + When you push to a repository with an option like so: 152 + 153 + ``` 154 + git push origin master -o verbose-ci 155 + ``` 156 + 157 + The server runs a basic set of analysis rules on your 158 + workflow file, and reports any errors: 159 + 160 + ``` 161 + λ git push origin main -o verbose-ci 162 + . 163 + . 164 + . 165 + . 166 + remote: error: failed to parse workflow(s): 167 + remote: - at .tangled/workflows/fmt.yml: yaml: line 14: did not find expected key 168 + remote: 169 + remote: warning(s) on pipeline: 170 + remote: - at build.yml: workflow skipped: did not match trigger push 171 + ``` 172 + 173 + The analysis performed at the moment is quite basic (expect 174 + it to get better over time), but it is already quite useful 175 + to help debug workflows that don't trigger! 176 + 177 + ## pipeline secrets 178 + 179 + Secrets are a bit tricky since atproto has no notion of 180 + private data. Secrets are instead written directly from the 181 + appview to the spindle instance using [service 182 + auth](https://docs.bsky.app/docs/api/com-atproto-server-get-service-auth). 183 + In essence, the appview makes a signed request using the 184 + logged-in user's DID key; spindle verifies this signature by 185 + fetching the public key from the DID document. 186 + 187 + ![pipeline secrets](https://assets.tangled.network/blog/pipeline-secrets.png) 188 + 189 + The secrets themselves are stored in a secret manager. By 190 + default, this is the same sqlite database that spindle uses. 191 + This is *fine* for self-hosters. The hosted, flagship 192 + instance at https://spindle.tangled.sh however uses 193 + [OpenBao](https://openbao.org), an OSS fork of HashiCorp 194 + Vault. 195 + 196 + ## get started now 197 + 198 + You can run your own spindle instance pretty easily: the 199 + [spindle self-hosting 200 + guide](https://tangled.sh/@tangled.sh/core/blob/master/docs/spindle/hosting.md) 201 + should have you covered. Once done, head to your 202 + repository's settings tab and set it up! Doesn't work? Feel 203 + free to pop into [Discord](https://chat.tangled.sh) to get 204 + help -- we have a nice little crew that's always around to 205 + help. 206 + 207 + All Tangled users have access to our hosted spindle 208 + instance, free of charge[^3]. You don't have any more 209 + excuses to not migrate to Tangled now -- [get 210 + started](https://tangled.sh/login) with your AT Protocol 211 + account today. 212 + 213 + [^3]: We can't promise we won't charge for it at some point 214 + but there will always be a free tier.
+241
blog/posts/docs.md
··· 1 + --- 2 + atroot: true 3 + template: 4 + slug: docs 5 + title: we rolled our own documentation site 6 + subtitle: you don't need mintlify 7 + date: 2026-01-12 8 + authors: 9 + - name: Akshay 10 + email: akshay@tangled.org 11 + handle: oppi.li 12 + draft: false 13 + --- 14 + 15 + We recently organized our documentation and put it up on 16 + https://docs.tangled.org, using just pandoc. For several 17 + reasons, using pandoc to roll your own static sites is more 18 + than sufficient for small projects. 19 + 20 + ![docs.tangled.org](https://assets.tangled.network/blog/docs_homepage.png) 21 + 22 + ## requirements 23 + 24 + - Lives in [our 25 + monorepo](https://tangled.org/tangled.org/core). 26 + - No JS: a collection of pages containing just text 27 + should not require JS to view! 28 + - Searchability: in practice, documentation engines that 29 + come bundled with a search-engine have always been lack 30 + lustre. I tend to Ctrl+F or use an actual search engine in 31 + most scenarios. 32 + - Low complexity: building, testing, deploying should be 33 + easy. 34 + - Easy to style 35 + 36 + ## evaluating the ecosystem 37 + 38 + I took the time to evaluate several documentation engine 39 + solutions: 40 + 41 + - [Mintlify](https://www.mintlify.com/): It is quite obvious 42 + from their homepage that mintlify is performing an AI 43 + pivot for the sake of doing so. 44 + - [Docusaurus](https://docusaurus.io/): The generated 45 + documentation site is quite nice, but the value of pages 46 + being served as a full-blown React SPA is questionable. 47 + - [MkDocs](https://www.mkdocs.org/): Works great with JS 48 + disabled, however the table of contents needs to be 49 + maintained via `mkdocs.yml`, which can be quite tedious. 50 + - [MdBook](https://rust-lang.github.io/mdBook/index.html): 51 + As above, you need a `SUMMARY.md` file to control the 52 + table-of-contents. 53 + 54 + MkDocs and MdBook are still on my radar however, in case we 55 + need a bigger feature set. 56 + 57 + ## using pandoc 58 + 59 + [pandoc](https://pandoc.org/) is a wonderfully customizable 60 + markup converter. It provides a "chunkedhtml" output format, 61 + which is perfect for generating documentation sites. Without 62 + any customization, 63 + [this](https://pandoc.org/demo/example33/) is the generated 64 + output, for this [markdown file 65 + input](https://pandoc.org/demo/MANUAL.txt). 66 + 67 + - You get an autogenerated TOC based on the document layout 68 + - Each section is turned into a page of its own 69 + 70 + Massaging pandoc to work for us was quite straightforward: 71 + 72 + - I first combined all our individual markdown files into 73 + [one big 74 + `DOCS.md`](https://tangled.org/tangled.org/core/blob/master/docs/DOCS.md) 75 + file. 76 + - Modified the [default 77 + template](https://github.com/jgm/pandoc-templates/blob/master/default.chunkedhtml) 78 + to put the TOC on every page, to form a "sidebar", see 79 + [`docs/template.html`](https://tangled.org/tangled.org/core/blob/master/docs/template.html) 80 + - Inserted tailwind `prose` classes where necessary, such 81 + that markdown content is rendered the same way between 82 + `tangled.org` and `docs.tangled.org` 83 + 84 + Generating the docs is done with one pandoc command: 85 + 86 + ```bash 87 + pandoc docs/DOCS.md \ 88 + -o out/ \ 89 + -t chunkedhtml \ 90 + --variable toc \ 91 + --toc-depth=2 \ 92 + --css=docs/stylesheet.css \ 93 + --chunk-template="%i.html" \ 94 + --highlight-style=docs/highlight.theme \ 95 + --template=docs/template.html 96 + ``` 97 + 98 + ## avoiding javascript 99 + 100 + The "sidebar" style table-of-contents needs to be collapsed 101 + on mobile displays. Most of the engines I evaluated seem to 102 + require JS to collapse and expand the sidebar, with MkDocs 103 + being the outlier, it uses a checkbox with the 104 + [`:checked`](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Selectors/:checked) 105 + pseudo-class trick to avoid JS. 106 + 107 + The other ways to do this are: 108 + 109 + - Use `<details` and `<summary>`: this is definitely a 110 + "hack", clicking outside the sidebar does not collapse it. 111 + Using Ctrl+F or "Find in page" still works through the 112 + details tag though. 113 + - Use the new `popover` API: this seems like the perfect fit 114 + for a "sidebar" component. 115 + 116 + The bar at the top includes a button to trigger the popover: 117 + 118 + ```html 119 + <button popovertarget="toc-popover">Table of Contents</button> 120 + ``` 121 + 122 + And a `fixed` position div includes the TOC itself: 123 + 124 + ```html 125 + <div id="toc-popover" popover class="fixed top-0"> 126 + <ul> 127 + Quick Start 128 + <li>...</li> 129 + <li>...</li> 130 + <li>...</li> 131 + </ul> 132 + </div> 133 + ``` 134 + 135 + The TOC is scrollable independently and can be collapsed by 136 + clicking anywhere on the screen outside the sidebar. 137 + Searching for content in the page via "Find in page" does 138 + not show any results that are present in the popover 139 + however. The collapsible TOC is only available on smaller 140 + viewports, the TOC is not hidden on larger viewports. 141 + 142 + ## search 143 + 144 + There is no native search on the site for now. Taking 145 + inspiration from [https://htmx.org](https://htmx.org)'s search bar, our search 146 + bar also simply redirects to Google: 147 + 148 + ```html 149 + <form action="https://google.com/search"> 150 + <input type="hidden" name="q" value="+[inurl:https://docs.tangled.org]"> 151 + ... 152 + </form> 153 + ``` 154 + 155 + I mentioned earlier that Ctrl+F has typically worked better 156 + for me than, say, the search engine provided by Docusaurus. 157 + To that end, the same docs have been exported to a ["single 158 + page" format](https://docs.tangled.org/single-page.html), by 159 + just removing the `chunkedhtml` related options: 160 + 161 + ```diff 162 + pandoc docs/DOCS.md \ 163 + -o out/ \ 164 + - -t chunkedhtml \ 165 + --variable toc \ 166 + --toc-depth=2 \ 167 + --css=docs/stylesheet.css \ 168 + - --chunk-template="%i.html" \ 169 + --highlight-style=docs/highlight.theme \ 170 + --template=docs/template.html 171 + ``` 172 + 173 + With all the content on a single page, it is trivial to 174 + search through the entire site with the browser. If the docs 175 + do outgrow this, I will consider other options! 176 + 177 + ## building and deploying 178 + 179 + We use [nix](https://nixos.org) and 180 + [colmena](https://colmena.cli.rs/) to build and deploy all 181 + Tangled services. A nix derivation to [build the 182 + documentation](https://tangled.org/tangled.org/core/blob/master/nix/pkgs/docs.nix) 183 + site is written very easily with the `runCommandLocal` 184 + helper: 185 + 186 + ```nix 187 + runCommandLocal "docs" {} '' 188 + . 189 + . 190 + . 191 + ${pandoc}/bin/pandoc ${src}/docs/DOCS.md ... 192 + . 193 + . 194 + . 195 + '' 196 + ``` 197 + 198 + The NixOS machine is configured to serve the site [via 199 + nginx](https://tangled.org/tangled.org/infra/blob/master/hosts/nixery/services/nginx.nix#L7): 200 + 201 + ```nix 202 + services.nginx = { 203 + enable = true; 204 + virtualHosts = { 205 + "docs.tangled.org" = { 206 + root = "${tangled-pkgs.docs}"; 207 + locations."/" = { 208 + tryFiles = "$uri $uri/ =404"; 209 + index = "index.html"; 210 + }; 211 + }; 212 + }; 213 + }; 214 + ``` 215 + 216 + And deployed using `colmena`: 217 + 218 + ```bash 219 + nix run nixpkgs#colmena -- apply 220 + ``` 221 + 222 + To update the site, I first run: 223 + 224 + ```bash 225 + nix flake update tangled 226 + ``` 227 + 228 + Which bumps the `tangled` flake input, and thus 229 + `tangled-pkgs.docs`. The above `colmena` invocation applies 230 + the changes to the machine serving the site. 231 + 232 + ## notes 233 + 234 + Going homegrown has made it a lot easier to style the 235 + documentation site to match the main site. Unfortunately 236 + there are still a few discrepancies between pandoc's 237 + markdown rendering and 238 + [goldmark's](https://pkg.go.dev/github.com/yuin/goldmark/) 239 + markdown rendering (which is what we use in Tangled). We may 240 + yet roll our own SSG, 241 + [TigerStyle](https://tigerbeetle.com/blog/2025-02-27-why-we-designed-tigerbeetles-docs-from-scratch/)!
+64
blog/posts/intro.md
··· 1 + --- 2 + atroot: true 3 + template: 4 + slug: intro 5 + title: introducing tangled 6 + subtitle: a git collaboration platform, built on atproto 7 + date: 2025-03-02 8 + authors: 9 + - name: Anirudh 10 + email: anirudh@tangled.sh 11 + handle: anirudh.fi 12 + --- 13 + 14 + 15 + [Tangled](https://tangled.sh) is a new social-enabled Git collaboration 16 + platform, built on top of the [AT Protocol](https://atproto.com). We 17 + envision a place where developers have complete ownership of their code, 18 + open source communities can freely self-govern and most importantly, 19 + coding can be social and fun again. 20 + 21 + There are several models for decentralized code collaboration platforms, 22 + ranging from ActivityPub's (Forgejo) federated model, to Radicle's 23 + entirely P2P model. Our approach attempts to be the best of both worlds 24 + by adopting atproto -- a protocol for building decentralized social 25 + applications with a central identity. 26 + 27 + ![tangled architecture](https://assets.tangled.network/blog/arch.svg) 28 + 29 + Our approach to this is the idea of "knots". Knots are lightweight, 30 + headless servers that enable users to host Git repositories with ease. 31 + Knots are designed for either single or multi-tenant use which is 32 + perfect for self-hosting on a Raspberry Pi at home, or larger 33 + "community" servers. By default, Tangled provides managed knots where 34 + you can host your repositories for free. 35 + 36 + The [App View][appview] at [tangled.sh](https://tangled.sh) acts as a 37 + consolidated "view" into the whole network, allowing users to access, 38 + clone and contribute to repositories hosted across different knots -- 39 + completely seamlessly. 40 + 41 + Tangled is still in its infancy, and we're building out several of its 42 + core features as we [dogfood it ourselves][dogfood]. We developed these 43 + three tenets to guide our decisions: 44 + 45 + 1. Ownership of data 46 + 2. Low barrier to entry 47 + 3. No compromise on user-experience 48 + 49 + Collaborating on code isn't easy, and the tools and workflows we use 50 + should feel natural and stay out of the way. Tangled's architecture 51 + enables common workflows to work as you'd expect, all while remaining 52 + decentralized. 53 + 54 + We believe that atproto has greatly simplfied one of the hardest parts 55 + of social media: having your friends on it. Today, we're rolling out 56 + invite-only access to Tangled -- join us on IRC at `#tangled` on 57 + [libera.chat](https://libera.chat) and we'll get you set up. 58 + 59 + **Update**: Tangled is open to public, simply login at 60 + [tangled.sh/login](https://tangled.sh/login)! Have fun! 61 + 62 + [pds]: https://atproto.com/guides/glossary#pds-personal-data-server 63 + [appview]: https://docs.bsky.app/docs/advanced-guides/federation-architecture#app-views 64 + [dogfood]: https://tangled.sh/@tangled.sh/core
+195
blog/posts/pulls.md
··· 1 + --- 2 + atroot: true 3 + template: 4 + slug: pulls 5 + title: the lifecycle of a pull request 6 + subtitle: we shipped a bunch of PR features recently; here's how we built it 7 + date: 2025-04-16 8 + image: https://assets.tangled.network/blog/hidden-ref.png 9 + authors: 10 + - name: Anirudh 11 + email: anirudh@tangled.sh 12 + handle: anirudh.fi 13 + - name: Akshay 14 + email: akshay@tangled.sh 15 + handle: oppi.li 16 + draft: false 17 + --- 18 + 19 + We've spent the last couple of weeks building out a pull 20 + request system for Tangled, and today we want to lift the 21 + hood and show you how it works. 22 + 23 + If you're new to Tangled, [read our intro](/intro) for the 24 + full story! 25 + 26 + You have three options to contribute to a repository: 27 + 28 + - Paste a patch on the web UI 29 + - Compare two local branches (you'll see this only if you're a 30 + collaborator on the repo) 31 + - Compare across forks 32 + 33 + Whatever you choose, at the core of every PR is the patch. 34 + First, you write some code. Then, you run `git diff` to 35 + produce a patch and make everyone's lives easier, or push to 36 + a branch, and we generate it ourselves by comparing against 37 + the target. 38 + 39 + ## patch generation 40 + 41 + When you create a PR from a branch, we create a "patch" by 42 + calculating the difference between your branch and the 43 + target branch. Consider this scenario: 44 + 45 + <figure class="max-w-[550px] m-auto flex flex-col items-center justify-center"> 46 + <img class="h-auto max-w-full" src="https://assets.tangled.network/blog/merge-base.png"> 47 + <figcaption class="text-center"><code>A</code> is the merge-base for 48 + <code>feature</code> and <code>main</code>.</figcaption> 49 + </figure> 50 + 51 + Your `feature` branch has advanced 2 commits since you first 52 + branched out, but in the meanwhile, `main` has also advanced 53 + 2 commits. Doing a trivial `git diff feature main` will 54 + produce a confusing patch: 55 + 56 + - the patch will apply the changes from `X` and `Y` 57 + - the patch will **revert** the changes from `B` and `C` 58 + 59 + We obviously do not want the second part! To only show the 60 + changes added by `feature`, we have to identify the 61 + "merge-base": the nearest common ancestor of `feature` and 62 + `main`. 63 + 64 + 65 + In this case, `A` is the nearest common ancestor, and 66 + subsequently, the patch calculated will contain just `X` and 67 + `Y`. 68 + 69 + ### ref comparisons across forks 70 + 71 + The plumbing described above is easy to do across two 72 + branches, but what about forks? And what if they live on 73 + different servers altogether (as they can in Tangled!)? 74 + 75 + Here's the concept: since we already have all the necessary 76 + components to compare two local refs, why not simply 77 + "localize" the remote ref? 78 + 79 + In simpler terms, we instruct Git to fetch the target branch 80 + from the original repository and store it in your fork under 81 + a special name. This approach allows us to compare your 82 + changes against the most current version of the branch 83 + you're trying to contribute to, all while remaining within 84 + your fork. 85 + 86 + <figure class="max-w-[550px] m-auto flex flex-col items-center justify-center"> 87 + <img class="h-auto max-w-full" src="https://assets.tangled.network/blog/hidden-ref.png"> 88 + <figcaption class="text-center">Hidden tracking ref.</figcaption> 89 + </figure> 90 + 91 + We call this a "hidden tracking ref." When you create a pull 92 + request from a fork, we establish a refspec that tracks the 93 + remote branch, which we then use to generate a diff. A 94 + refspec is essentially a rule that tells Git how to map 95 + references between a remote and your local repository during 96 + fetch or push operations. 97 + 98 + For example, if your fork has a feature branch called 99 + `feature-1`, and you want to make a pull request to the 100 + `main` branch of the original repository, we fetch the 101 + remote `main` into a local hidden ref using a refspec like 102 + this: 103 + 104 + ``` 105 + +refs/heads/main:refs/hidden/feature-1/main 106 + ``` 107 + 108 + Since we already have a remote (`origin`, by default) to the 109 + original repository (remember, we cloned it earlier), we can 110 + use `fetch` with this refspec to bring the remote `main` 111 + branch into our local hidden ref. Each pull request gets its 112 + own hidden ref, hence the `refs/hidden/:localRef/:remoteRef` 113 + format. We keep this ref updated whenever you push new 114 + commits to your feature branch, ensuring that comparisons -- 115 + and any potential merge conflicts -- are always based on the 116 + latest state of the target branch. 117 + 118 + And just like earlier, we produce the patch by diffing your 119 + feature branch with the hidden tracking ref. Also, the entire pull 120 + request is stored as [an atproto record][atproto-record] and updated 121 + each time the patch changes. 122 + 123 + [atproto-record]: https://pdsls.dev/at://did:plc:qfpnj4og54vl56wngdriaxug/sh.tangled.repo.pull/3lmwniim2i722 124 + 125 + Neat, now that we have a patch; we can move on the hard 126 + part: code review. 127 + 128 + 129 + ## your patch does the rounds 130 + 131 + Tangled uses a "round-based" review format. Your initial 132 + submission starts "round 0". Once your submission receives 133 + scrutiny, you can address reviews and resubmit your patch. 134 + This resubmission starts "round 1". You keep whittling on 135 + your patch till it is good enough, and eventually merged (or 136 + closed if you are unlucky). 137 + 138 + <figure class="max-w-[700px] m-auto flex flex-col items-center justify-center"> 139 + <img class="h-auto max-w-full" src="https://assets.tangled.network/blog/patch-pr-main.png"> 140 + <figcaption class="text-center">A new pull request with a couple 141 + rounds of reviews.</figcaption> 142 + </figure> 143 + 144 + Rounds are a far superior to standard branch-based 145 + approaches: 146 + 147 + - Submissions are immutable: how many times have your 148 + reviews gone out-of-date because the author pushed commits 149 + _during_ your review? 150 + - Reviews are attached to submissions: at a glance, it is 151 + easy to tell which comment applies to which "version" of 152 + the pull-request 153 + - The author can choose when to resubmit! They can commit as 154 + much as they want to their branch, but a new round begins 155 + when they choose to hit "resubmit" 156 + - It is possible to "interdiff" and observe changes made 157 + across submissions (this is coming very soon to Tangled!) 158 + 159 + This [post by Mitchell 160 + Hashimoto](https://mitchellh.com/writing/github-changesets) 161 + goes into further detail on what can be achieved with 162 + round-based reviews. 163 + 164 + ## future plans 165 + 166 + To close off this post, we wanted to share some of our 167 + future plans for pull requests: 168 + 169 + * `format-patch` support: both for pasting in the UI and 170 + internally. This allows us to show commits in the PR page, 171 + and offer different merge strategies to choose from 172 + (squash, rebase, ...). 173 + **Update 2025-08-12**: We have format-patch support! 174 + 175 + * Gerrit-style `refs/for/main`: we're still hashing out the 176 + details but being able to push commits to a ref to 177 + "auto-create" a PR would be super handy! 178 + 179 + * Change ID support: This will allow us to group changes 180 + together and track them across multiple commits, and to 181 + provide "history" for each change. This works great with [Jujutsu][jj]. 182 + **Update 2025-08-12**: This has now landed: https://blog.tangled.org/stacking 183 + 184 + Join us on [Discord](https://chat.tangled.sh) or 185 + `#tangled` on libera.chat (the two are bridged, so we will 186 + never miss a message!). We are always available to help 187 + setup knots, listen to feedback on features, or even 188 + shepherd contributions! 189 + 190 + **Update 2025-08-12**: We move fast, and we now have jujutsu support, and an 191 + early in-house CI: https://blog.tangled.org/ci. You no longer need a Bluesky 192 + account to sign-up; head to https://tangled.sh/signup and sign up with your 193 + email! 194 + 195 + [jj]: https://jj-vcs.github.io/jj/latest/
+74
blog/posts/seed.md
··· 1 + --- 2 + atroot: true 3 + template: 4 + slug: seed 5 + title: announcing our €3,8M seed round 6 + subtitle: and more on what's next 7 + date: 2026-03-02 8 + image: https://assets.tangled.network/blog/seed.png 9 + authors: 10 + - name: Anirudh 11 + email: anirudh@tangled.org 12 + handle: anirudh.fi 13 + --- 14 + 15 + ![seed](https://assets.tangled.network/seed.png) 16 + 17 + Today, we're announcing our €3,8M ($4.5M) financing round led by 18 + [byFounders](https://byfounders.vc), with participation from [Bain 19 + Capital Crypto](https://baincapitalcrypto.com/), 20 + [Antler](https://antler.co), Thomas Dohmke (former GitHub CEO), Avery 21 + Pennarun (CEO of Tailscale), among other incredible angels. 22 + 23 + For the past year, we've been building Tangled from the ground up -- 24 + starting from first principles and asking ourselves what code 25 + collaboration should really look like. We made deliberate, 26 + [future-facing technology](https://anirudh.fi/future) choices. We chose 27 + to build on top of the AT Protocol as it helped us realize a federated, 28 + open network where users can own their code and social data. We shipped 29 + stacked PRs to enable more efficient contribution and review workflows. 30 + What started off as a side project, grew to over 7k+ users, who've 31 + created over 5k+ repositories. 32 + 33 + Our vision for Tangled has always been big: we want to build the best 34 + code forge ever, and become foundational infrastructure for the next 35 + generation of open source. Whatever that looks like: hundreds of devs 36 + building artisanal libraries, or one dev and a hundred agents building a 37 + micro-SaaS. 38 + 39 + And finding the right investors to help us acheive this vision wasn't 40 + something we took lightly. We spent months getting to know potential 41 + partners -- among which, byFounders stood out immediately. Like us, 42 + they're community-driven at their core, and their commitment to 43 + transparency runs deep -- you can see the very term sheet we signed on 44 + their website! With these shared fundamental values, we knew byFounders 45 + were the right people to have in our corner and we're incredibly excited 46 + to work with them. 47 + 48 + ## what's next 49 + 50 + We're heads down building. For 2026, expect to see: 51 + 52 + * a fully revamped CI (spindle v2!) built on micro VMs to allow for 53 + faster builds and more choice of build environments. Oh, and a proper 54 + Nix CI -- we know you want it. 55 + * protocol-level improvements across the board that'll unlock nifty 56 + things like repo migrations across knots, organizations, and more! 57 + * a customizable "mission control" dashboard for your active PRs, 58 + issues, and anything else you might want to track. 59 + * a migration tool to help you move off GitHub 60 + * all things search: code search, repo search, etc. 61 + * platform and infrastructure performance improvements & more global 62 + presence 63 + * Tangled CLI! 64 + 65 + If all this sounds exciting to you: we're growing our team! Shoot us 66 + [an email](mailto:team@tangled.org) telling us a bit about yourself and 67 + any past work that might be relevant, and what part of the roadmap 68 + interests you most. We can hire from (almost) anywhere. 69 + 70 + New to Tangled? [Get started here](https://docs.tangled.org/). Oh, and 71 + come hang on [Discord](https://chat.tangled.org)! 72 + 73 + A sincere thank you to everyone that helped us get here -- we're giddy 74 + about what's to come.
+351
blog/posts/stacking.md
··· 1 + --- 2 + atroot: true 3 + template: 4 + slug: stacking 5 + title: jujutsu on tangled 6 + subtitle: tangled now supports jujutsu change-ids! 7 + date: 2025-06-02 8 + image: https://assets.tangled.network/blog/interdiff_difference.jpeg 9 + authors: 10 + - name: Akshay 11 + email: akshay@tangled.sh 12 + handle: oppi.li 13 + draft: false 14 + --- 15 + 16 + Jujutsu is built around structuring your work into 17 + meaningful commits. Naturally, during code-review, you'd 18 + expect reviewers to be able to comment on individual 19 + commits, and also see the evolution of a commit over time, 20 + as reviews are addressed. We set out to natively support 21 + this model of code-review on Tangled. 22 + 23 + Tangled is a new social-enabled Git collaboration platform, 24 + [read our intro](/intro) for more about the project. 25 + 26 + For starters, I would like to contrast the two schools of 27 + code-review, the "diff-soup" model and the interdiff model. 28 + 29 + ## the diff-soup model 30 + 31 + When you create a PR on traditional code forges (GitHub 32 + specifically), the UX implicitly encourages you to address 33 + your code review by *adding commits* on top of your original 34 + set of changes: 35 + 36 + - GitHub's "Apply Suggestion" button directly commits the 37 + suggestion into your PR 38 + - GitHub only shows you the diff of all files at once by 39 + default 40 + - It is difficult to know what changed across force pushes 41 + 42 + Consider a hypothetical PR that adds 3 commits: 43 + 44 + ``` 45 + [c] implement new feature across the board (HEAD) 46 + | 47 + [b] introduce new feature 48 + | 49 + [a] some small refactor 50 + ``` 51 + 52 + And when only newly added commits are easy to review, this 53 + is what ends up happening: 54 + 55 + ``` 56 + [f] formatting & linting (HEAD) 57 + | 58 + [e] update name of new feature 59 + | 60 + [d] fix bug in refactor 61 + | 62 + [c] implement new feature across the board 63 + | 64 + [b] introduce new feature 65 + | 66 + [a] some small refactor 67 + ``` 68 + 69 + It is impossible to tell what addresses what at a glance, 70 + there is an implicit relation between each change: 71 + 72 + ``` 73 + [f] formatting & linting 74 + | 75 + [e] update name of new feature -------------. 76 + | | 77 + [d] fix bug in refactor -----------. | 78 + | | | 79 + [c] implement new feature across the board | 80 + | | | 81 + [b] introduce new feature <-----------------' 82 + | | 83 + [a] some small refactor <----------' 84 + ``` 85 + 86 + This has the downside of clobbering the output of `git 87 + blame` (if there is a bug in the new feature, you will first 88 + land on `e`, and upon digging further, you will land on 89 + `b`). This becomes incredibly tricky to navigate if reviews 90 + go on through multiple cycles. 91 + 92 + 93 + ## the interdiff model 94 + 95 + With jujutsu however, you have the tools at hand to 96 + fearlessly edit, split, squash and rework old commits (you 97 + can absolutely achieve this with git and interactive 98 + rebasing, but it is certainly not trivial). 99 + 100 + Let's try that again: 101 + 102 + ``` 103 + [c] implement new feature across the board (HEAD) 104 + | 105 + [b] introduce new feature 106 + | 107 + [a] some small refactor 108 + ``` 109 + 110 + To fix the bug in the refactor: 111 + 112 + ``` 113 + $ jj edit a 114 + Working copy (@) now at: [a] some small refactor 115 + 116 + $ # hack hack hack 117 + 118 + $ jj log -r a:: 119 + Rebased 2 descendant commits onto updated working copy 120 + [c] implement new feature across the board (HEAD) 121 + | 122 + [b] introduce new feature 123 + | 124 + [a] some small refactor 125 + ``` 126 + 127 + Jujutsu automatically rebases the descendants without having 128 + to lift a finger. Brilliant! You can repeat the same 129 + exercise for all review comments, and effectively, your 130 + PR will have evolved like so: 131 + 132 + ``` 133 + a -> b -> c initial attempt 134 + | | | 135 + v v v 136 + a' -> b' -> c' after first cycle of reviews 137 + ``` 138 + 139 + ## the catch 140 + 141 + If you use `git rebase`, you will know that it modifies 142 + history and therefore changes the commit SHA. How then, 143 + should one tell the difference between the "old" and "new" 144 + state of affairs? 145 + 146 + Tools like `git-range-diff` make use of a variety of 147 + text-based heuristics to roughly match `a` to `a'` and `b` 148 + to `b'` etc. 149 + 150 + Jujutsu however, works around this by assigning stable 151 + "change id"s to each change (which internally point to a git 152 + commit, if you use the git backing). If you edit a commit, 153 + its SHA changes, but its change-id remains the same. 154 + 155 + And this is the essence of our new stacked PRs feature! 156 + 157 + ## interdiff code review on tangled 158 + 159 + To really explain how this works, let's start with a [new 160 + codebase](https://tangled.sh/@oppi.li/stacking-demo/): 161 + 162 + ``` 163 + $ jj git init --colocate 164 + 165 + # -- initialize codebase -- 166 + 167 + $ jj log 168 + @ n set: introduce Set type main HEAD 1h 169 + ``` 170 + 171 + I have kicked things off by creating a new go module that 172 + adds a `HashSet` data structure. My first changeset 173 + introduces some basic set operations: 174 + 175 + ``` 176 + $ jj log 177 + @ so set: introduce set difference HEAD 178 + ├ sq set: introduce set intersection 179 + ├ mk set: introduce set union 180 + ├ my set: introduce basic set operations 181 + ~ 182 + 183 + $ jj git push -c @ 184 + Changes to push to origin: 185 + Add bookmark push-soqmukrvport to fc06362295bd 186 + ``` 187 + 188 + When submitting a pull request, select "Submit as stacked PRs": 189 + 190 + <figure class="max-w-[550px] m-auto flex flex-col items-center justify-center"> 191 + <a href="https://assets.tangled.network/blog/submit_stacked.jpeg"> 192 + <img class="my-1 h-auto max-w-full" src="https://assets.tangled.network/blog/submit_stacked.jpeg"> 193 + </a> 194 + <figcaption class="text-center">Submitting Stacked PRs</figcaption> 195 + </figure> 196 + 197 + This submits each change as an individual pull request: 198 + 199 + <figure class="max-w-[550px] m-auto flex flex-col items-center justify-center"> 200 + <a href="https://assets.tangled.network/blog/top_of_stack.jpeg"> 201 + <img class="my-1 h-auto max-w-full" src="https://assets.tangled.network/blog/top_of_stack.jpeg"> 202 + </a> 203 + <figcaption class="text-center">The "stack" is similar to Gerrit's relation chain</figcaption> 204 + </figure> 205 + 206 + After a while, I receive a couple of review comments, not on 207 + my entire submission, but rather, on each *individual 208 + change*. Additionally, the reviewer is happy with my first 209 + change, and has gone ahead and merged that: 210 + 211 + <div class="flex justify-center items-start gap-2"> 212 + <figure class="w-1/3 m-0 flex flex-col items-center"> 213 + <a href="https://assets.tangled.network/blog/basic_merged.jpeg"> 214 + <img class="my-1 w-full h-auto cursor-pointer" src="https://assets.tangled.network/blog/basic_merged.jpeg" alt="The first change has been merged"> 215 + </a> 216 + <figcaption class="text-center">The first change has been merged</figcaption> 217 + </figure> 218 + 219 + <figure class="w-1/3 m-0 flex flex-col items-center"> 220 + <a href="https://assets.tangled.network/blog/review_union.jpeg"> 221 + <img class="my-1 w-full h-auto cursor-pointer" src="https://assets.tangled.network/blog/review_union.jpeg" alt="A review on the set union implementation"> 222 + </a> 223 + <figcaption class="text-center">A review on the set union implementation</figcaption> 224 + </figure> 225 + 226 + <figure class="w-1/3 m-0 flex flex-col items-center"> 227 + <a href="https://assets.tangled.network/blog/review_difference.jpeg"> 228 + <img class="my-1 w-full h-auto cursor-pointer" src="https://assets.tangled.network/blog/review_difference.jpeg" alt="A review on the set difference implementation"> 229 + </a> 230 + <figcaption class="text-center">A review on the set difference implementation</figcaption> 231 + </figure> 232 + </div> 233 + 234 + Let us address the first review: 235 + 236 + > can you use the new `maps.Copy` api here? 237 + 238 + ``` 239 + $ jj log 240 + @ so set: introduce set difference push-soqmukrvport 241 + ├ sq set: introduce set intersection 242 + ├ mk set: introduce set union 243 + ├ my set: introduce basic set operations 244 + ~ 245 + 246 + # let's edit the implementation of `Union` 247 + $ jj edit mk 248 + 249 + # hack, hack, hack 250 + 251 + $ jj log 252 + Rebased 2 descendant commits onto updated working copy 253 + ├ so set: introduce set difference push-soqmukrvport* 254 + ├ sq set: introduce set intersection 255 + @ mk set: introduce set union 256 + ├ my set: introduce basic set operations 257 + ~ 258 + ``` 259 + 260 + Next, let us address the bug: 261 + 262 + > there is a logic bug here, the condition should be negated. 263 + 264 + ``` 265 + # let's edit the implementation of `Difference` 266 + $ jj edit so 267 + 268 + # hack, hack, hack 269 + ``` 270 + 271 + We are done addressing reviews: 272 + ``` 273 + $ jj git push 274 + Changes to push to origin: 275 + Move sideways bookmark push-soqmukrvport from fc06362295bd to dfe2750f6d40 276 + ``` 277 + 278 + Upon resubmitting the PR for review, Tangled is able to 279 + accurately trace the commit across rewrites, using jujutsu 280 + change-ids, and map it to the corresponding PR: 281 + 282 + <div class="flex justify-center items-start gap-2"> 283 + <figure class="w-1/2 m-0 flex flex-col items-center"> 284 + <a href="https://assets.tangled.network/blog/round_2_union.jpeg"> 285 + <img class="my-1 w-full h-auto" src="https://assets.tangled.network/blog/round_2_union.jpeg" alt="PR #2 advances to the next round"> 286 + </a> 287 + <figcaption class="text-center">PR #2 advances to the next round</figcaption> 288 + </figure> 289 + 290 + <figure class="w-1/2 m-0 flex flex-col items-center"> 291 + <a href="https://assets.tangled.network/blog/round_2_difference.jpeg"> 292 + <img class="my-1 w-full h-auto" src="https://assets.tangled.network/blog/round_2_difference.jpeg" alt="PR #4 advances to the next round"> 293 + </a> 294 + <figcaption class="text-center">PR #4 advances to the next round</figcaption> 295 + </figure> 296 + </div> 297 + 298 + Of note here are a few things: 299 + 300 + - The initial submission is still visible under `round #0` 301 + - By resubmitting, the round has simply advanced to `round 302 + #1` 303 + - There is a helpful "interdiff" button to look at the 304 + difference between the two submissions 305 + 306 + The individual diffs are still available, but most 307 + importantly, the reviewer can view the *evolution* of a 308 + change by hitting the interdiff button: 309 + 310 + <div class="flex justify-center items-start gap-2"> 311 + <figure class="w-1/2 m-0 flex flex-col items-center"> 312 + <a href="https://assets.tangled.network/blog/diff_1_difference.jpeg"> 313 + <img class="my-1 w-full h-auto" src="https://assets.tangled.network/blog/diff_1_difference.jpeg" alt="Diff from round #0"> 314 + </a> 315 + <figcaption class="text-center">Diff from round #0</figcaption> 316 + </figure> 317 + 318 + <figure class="w-1/2 m-0 flex flex-col items-center"> 319 + <a href="https://assets.tangled.network/blog/diff_2_difference.jpeg"> 320 + <img class="my-1 w-full h-auto" src="https://assets.tangled.network/blog/diff_2_difference.jpeg" alt="Diff from round #1"> 321 + </a> 322 + <figcaption class="text-center">Diff from round #1</figcaption> 323 + </figure> 324 + </div> 325 + 326 + <figure class="max-w-[550px] m-auto flex flex-col items-center justify-center"> 327 + <a href="https://assets.tangled.network/blog/interdiff_difference.jpeg"> 328 + <img class="my-1 w-full h-auto" src="https://assets.tangled.network/blog/interdiff_difference.jpeg" alt="Interdiff between round #0 and #1"> 329 + </a> 330 + <figcaption class="text-center">Interdiff between round #1 and #0</figcaption> 331 + </figure> 332 + 333 + Indeed, the logic bug has been addressed! 334 + 335 + ## start stacking today 336 + 337 + If you are a jujutsu user, you can enable this flag on more 338 + recent versions of jujutsu: 339 + 340 + ``` 341 + λ jj --version 342 + jj 0.29.0-8c7ca30074767257d75e3842581b61e764d022cf 343 + 344 + # -- in your config.toml file -- 345 + [git] 346 + write-change-id-header = true 347 + ``` 348 + 349 + This feature writes `change-id` headers directly into the 350 + git commit object, and is visible to code forges upon push, 351 + and allows you to stack your PRs on Tangled.
+17
blog/templates/fragments/footer.html
··· 1 + {{ define "blog/fragments/footer" }} 2 + <footer class="mt-12 w-full px-6 py-4 bg-white dark:bg-gray-800 border-t border-gray-100 dark:border-gray-700"> 3 + <div class="max-w-[90ch] mx-auto flex flex-wrap justify-center items-center gap-x-4 gap-y-2 text-sm text-gray-500 dark:text-gray-400"> 4 + <div class="flex items-center justify-center gap-x-2 order-last sm:order-first w-full sm:w-auto"> 5 + <a href="https://tangled.org" class="no-underline hover:no-underline flex items-center"> 6 + {{ template "fragments/dolly/logo" (dict "Classes" "size-5 text-gray-500 dark:text-gray-400") }} 7 + </a> 8 + <span>&copy; 2026 Tangled Labs Oy.</span> 9 + </div> 10 + <a href="https://docs.tangled.org" class="hover:text-gray-900 dark:hover:text-gray-200 hover:underline">docs</a> 11 + <a href="https://tangled.org/tangled.org/core" class="hover:text-gray-900 dark:hover:text-gray-200 hover:underline">source</a> 12 + <a href="https://chat.tangled.org" class="hover:text-gray-900 dark:hover:text-gray-200 hover:underline" target="_blank" rel="noopener noreferrer">discord</a> 13 + <a href="https://bsky.app/profile/tangled.org" class="hover:text-gray-900 dark:hover:text-gray-200 hover:underline" target="_blank" rel="noopener noreferrer">bluesky</a> 14 + <a href="/feed.xml" class="hover:text-gray-900 dark:hover:text-gray-200 hover:underline">feed</a> 15 + </div> 16 + </footer> 17 + {{ end }}
+76
blog/templates/index.html
··· 1 + {{ define "title" }}the tangled blog{{ end }} 2 + 3 + {{ define "extrameta" }} 4 + <meta name="description" content="The Tangled blog." /> 5 + <link rel="alternate" type="application/atom+xml" title="Atom" href="/feed.xml" /> 6 + {{ end }} 7 + 8 + <!-- overrides the default slate bg --> 9 + {{ define "bodyClasses" }}!bg-white dark:!bg-gray-900{{ end }} 10 + 11 + {{ define "topbarLayout" }} 12 + <header class="max-w-screen-xl mx-auto w-full" style="z-index: 20;"> 13 + {{ template "layouts/fragments/topbar" . }} 14 + </header> 15 + {{ end }} 16 + 17 + {{ define "content" }} 18 + <div class="max-w-screen-lg mx-auto w-full px-4 py-10"> 19 + 20 + <header class="mb-10 text-center"> 21 + <h1 class="text-3xl font-bold dark:text-white mb-2">the tangled blog</h1> 22 + <p class="text-gray-500 dark:text-gray-400">all the ropes and scaffolding</p> 23 + </header> 24 + 25 + {{ if .Featured }} 26 + <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6 mb-14"> 27 + {{ range .Featured }} 28 + <a href="/{{ .Meta.Slug }}" class="no-underline hover:no-underline group flex flex-col bg-white dark:bg-gray-800 rounded border border-gray-200 dark:border-gray-700 overflow-hidden hover:bg-gray-100/25 hover:dark:bg-gray-700/25 transition-colors"> 29 + <div class="aspect-[16/9] overflow-hidden bg-gray-100 dark:bg-gray-700"> 30 + <img src="{{ .Meta.Image }}" alt="{{ .Meta.Title }}" class="w-full h-full object-cover group-hover:scale-[1.02] transition-transform duration-300" /> 31 + </div> 32 + <div class="flex flex-col flex-1 px-5 py-4"> 33 + <div class="text-xs text-gray-400 dark:text-gray-500 mb-2"> 34 + {{ $date := .ParsedDate }}{{ $date.Format "Jan 2, 2006" }} 35 + {{ if .Meta.Draft }}<span class="text-red-500">[draft]</span>{{ end }} 36 + </div> 37 + <h2 class="font-bold text-gray-900 dark:text-white text-base leading-snug mb-1 group-hover:underline">{{ .Meta.Title }}</h2> 38 + <p class="text-sm text-gray-500 dark:text-gray-400 line-clamp-2 flex-1">{{ .Meta.Subtitle }}</p> 39 + <div class="flex items-center mt-4"> 40 + <div class="inline-flex items-center -space-x-2"> 41 + {{ range .Meta.Authors }} 42 + <img src="{{ tinyAvatar .Handle }}" class="size-6 rounded-full border border-gray-300 dark:border-gray-700" alt="{{ .Name }}" title="{{ .Name }}" /> 43 + {{ end }} 44 + </div> 45 + </div> 46 + </div> 47 + </a> 48 + {{ end }} 49 + </div> 50 + {{ end }} 51 + 52 + <div class="grid grid-cols-1 rounded border border-gray-200 dark:border-gray-700 divide-y divide-gray-200 dark:divide-gray-700"> 53 + {{ range .Posts }} 54 + <a href="/{{ .Meta.Slug }}" class="no-underline hover:no-underline group flex items-center justify-between gap-4 px-6 py-3 hover:bg-gray-100/25 hover:dark:bg-gray-700/25 transition-colors"> 55 + <div class="flex items-center gap-3 min-w-0"> 56 + <div class="inline-flex items-center -space-x-2 shrink-0"> 57 + {{ range .Meta.Authors }} 58 + <img src="{{ tinyAvatar .Handle }}" class="size-5 rounded-full border border-gray-300 dark:border-gray-700" alt="{{ .Name }}" title="{{ .Name }}" /> 59 + {{ end }} 60 + </div> 61 + <span class="font-medium text-gray-900 dark:text-white group-hover:underline truncate"> 62 + {{ .Meta.Title }} 63 + {{ if .Meta.Draft }}<span class="text-red-500 text-xs font-normal ml-1">[draft]</span>{{ end }} 64 + </span> 65 + </div> 66 + <div class="text-sm text-gray-400 dark:text-gray-500 shrink-0"> 67 + {{ $date := .ParsedDate }}{{ $date.Format "Jan 02, 2006" }} 68 + </div> 69 + </a> 70 + {{ end }} 71 + </div> 72 + 73 + </div> 74 + {{ end }} 75 + 76 + {{ define "footerLayout" }}{{ template "blog/fragments/footer" . }}{{ end }}
+68
blog/templates/post.html
··· 1 + {{ define "title" }}{{ .Post.Meta.Title }} — tangled blog{{ end }} 2 + 3 + {{ define "extrameta" }} 4 + <meta name="description" content="{{ .Post.Meta.Subtitle }}"/> 5 + <meta property="og:title" content="{{ .Post.Meta.Title }}" /> 6 + <meta property="og:description" content="{{ .Post.Meta.Subtitle }}" /> 7 + <meta property="og:url" content="https://blog.tangled.org/{{ .Post.Meta.Slug }}" /> 8 + {{ if .Post.Meta.Image }} 9 + <meta property="og:image" content="https://blog.tangled.org{{ .Post.Meta.Image }}" /> 10 + <meta property="og:image:width" content="1200" /> 11 + <meta property="og:image:height" content="630" /> 12 + <meta name="twitter:card" content="summary_large_image" /> 13 + <meta name="twitter:image" content="https://blog.tangled.org{{ .Post.Meta.Image }}" /> 14 + {{ end }} 15 + <meta name="twitter:title" content="{{ .Post.Meta.Title }}" /> 16 + <meta name="twitter:description" content="{{ .Post.Meta.Subtitle }}" /> 17 + <link rel="alternate" type="application/atom+xml" title="Atom" href="/feed.xml" /> 18 + {{ end }} 19 + 20 + {{ define "bodyClasses" }}!bg-white dark:!bg-gray-900{{ end }} 21 + 22 + {{ define "topbarLayout" }} 23 + <header class="max-w-screen-xl mx-auto w-full" style="z-index: 20;"> 24 + {{ template "layouts/fragments/topbar" . }} 25 + </header> 26 + {{ end }} 27 + 28 + {{ define "footerLayout" }}{{ template "blog/fragments/footer" . }}{{ end }} 29 + 30 + {{ define "content" }} 31 + <div class="max-w-[90ch] mx-auto w-full px-4 py-8"> 32 + <div class="prose dark:prose-invert w-full max-w-none"> 33 + 34 + <header class="not-prose mb-4"> 35 + {{ $authors := .Post.Meta.Authors }} 36 + <p class="mb-1 text-sm text-gray-600 dark:text-gray-400"> 37 + {{ $date := .Post.ParsedDate }} 38 + {{ $date.Format "02 Jan, 2006" }} 39 + </p> 40 + 41 + <h1 class="mb-0 text-2xl font-bold dark:text-white"> 42 + {{ .Post.Meta.Title }} 43 + {{ if .Post.Meta.Draft }}<span class="text-red-500 text-base font-normal">[draft]</span>{{ end }} 44 + </h1> 45 + <p class="italic mt-1 mb-3 text-lg text-gray-600 dark:text-gray-400">{{ .Post.Meta.Subtitle }}</p> 46 + 47 + <div class="flex items-center gap-3 not-prose"> 48 + <div class="inline-flex items-center -space-x-2"> 49 + {{ range $authors }} 50 + <img src="{{ tinyAvatar .Handle }}" class="size-7 rounded-full border border-gray-300 dark:border-gray-700" alt="{{ .Handle }}" title="{{ .Handle }}" /> 51 + {{ end }} 52 + </div> 53 + <div class="flex items-center gap-1 text-sm text-gray-700 dark:text-gray-300"> 54 + {{ range $i, $a := $authors }} 55 + {{ if gt $i 0 }}<span class="text-gray-400">&amp;</span>{{ end }} 56 + <a href="https://tangled.org/@{{ $a.Handle }}" class="hover:underline">{{ $a.Handle }}</a> 57 + {{ end }} 58 + </div> 59 + </div> 60 + </header> 61 + 62 + <article> 63 + {{ .Post.Body }} 64 + </article> 65 + 66 + </div> 67 + </div> 68 + {{ end }}
+91
blog/templates/text.html
··· 1 + {{ define "fragments/logotypeSmall" }} 2 + <span class="flex items-center gap-2"> 3 + {{ template "fragments/dolly/logo" "size-8 text-black dark:text-white" }} 4 + <span class="font-bold text-xl not-italic">tangled</span> 5 + <span class="font-normal not-italic text-xs rounded bg-gray-100 dark:bg-gray-700 px-1">alpha</span> 6 + </span> 7 + {{ end }} 8 + 9 + <!doctype html> 10 + <html lang="en"> 11 + <head> 12 + <meta charset="UTF-8" /> 13 + <meta name="viewport" content="width=device-width, initial-scale=1.0"/> 14 + <meta name="description" content="{{ index .Meta "subtitle" }}"/> 15 + <meta property="og:site_name" content="Tangled" /> 16 + <meta property="og:type" content="website" /> 17 + <meta property="og:title" content="{{ index .Meta "title" }}" /> 18 + <meta property="og:description" content="{{ index .Meta "subtitle" }}" /> 19 + <meta property="og:url" content="https://blog.tangled.org/{{ index .Meta "slug" }}" /> 20 + <meta property="og:image" content="https://blog.tangled.org{{ index .Meta "image" }}" /> 21 + <meta property="og:image:width" content="1200" /> 22 + <meta property="og:image:height" content="630" /> 23 + <meta name="twitter:card" content="summary_large_image" /> 24 + <meta name="twitter:title" content="{{ index .Meta "title" }}" /> 25 + <meta name="twitter:description" content="{{ index .Meta "subtitle" }}" /> 26 + <meta name="twitter:image" content="https://blog.tangled.org{{ index .Meta "image" }}" /> 27 + <link rel="alternate" type="application/atom+xml" title="Atom" href="/feed.xml" /> 28 + <link rel="icon" href="/static/logos/dolly.ico" sizes="48x48"/> 29 + <link rel="icon" href="/static/logos/dolly.svg" sizes="any" type="image/svg+xml"/> 30 + <link rel="apple-touch-icon" href="/static/logos/dolly.png"/> 31 + <link rel="preload" href="/static/fonts/InterVariable.woff2" as="font" type="font/woff2" crossorigin /> 32 + <link rel="stylesheet" href="/static/tw.css" type="text/css" /> 33 + <title>{{ index .Meta "title" }}</title> 34 + </head> 35 + <body class="bg-slate-100 dark:bg-gray-900 dark:text-white min-h-screen flex flex-col gap-4"> 36 + 37 + <header class="w-full drop-shadow-sm bg-white dark:bg-gray-800"> 38 + <nav class="mx-auto px-6 py-2"> 39 + <div class="flex justify-between items-center"> 40 + <a href="/" class="text-2xl no-underline hover:no-underline flex items-center gap-2"> 41 + {{ template "fragments/logotypeSmall" }} 42 + </a> 43 + </div> 44 + </nav> 45 + </header> 46 + 47 + <div class="flex-grow flex flex-col max-w-[75ch] mx-auto w-full px-1"> 48 + <main class="prose dark:prose-invert w-full max-w-none"> 49 + 50 + <header class="not-prose"> 51 + <p class="px-6 mb-0 text-sm text-gray-600 dark:text-gray-400"> 52 + {{ $dateStr := index .Meta "date" }} 53 + {{ $date := parsedate $dateStr }} 54 + {{ $date.Format "02 Jan, 2006" }} 55 + 56 + <span class="mx-2 select-none">&middot;</span> 57 + 58 + by 59 + {{ $authors := index .Meta "authors" }} 60 + {{ if eq (len $authors) 2 }} 61 + <a href="https://bsky.app/profile/{{ (index $authors 0).handle }}" class="no-underline">{{ (index $authors 0).name }}</a> 62 + &amp; 63 + <a href="https://bsky.app/profile/{{ (index $authors 1).handle }}" class="no-underline">{{ (index $authors 1).name }}</a> 64 + {{ else }} 65 + {{ range $authors }} 66 + <a href="https://bsky.app/profile/{{ .handle }}" class="no-underline">{{ .name }}</a> 67 + {{ end }} 68 + {{ end }} 69 + </p> 70 + 71 + {{ if index .Meta "draft" }} 72 + <h1 class="px-6 mb-0 text-2xl font-bold">{{ index .Meta "title" }} <span class="text-red-500">[draft]</span></h1> 73 + {{ else }} 74 + <h1 class="px-6 mb-0 text-2xl font-bold">{{ index .Meta "title" }}</h1> 75 + {{ end }} 76 + <p class="italic px-6 mt-1 mb-4 text-lg text-gray-600 dark:text-gray-400">{{ index .Meta "subtitle" }}</p> 77 + </header> 78 + 79 + <article class="bg-white dark:bg-gray-800 px-6 py-4 rounded drop-shadow-sm"> 80 + {{ .Body }} 81 + </article> 82 + 83 + </main> 84 + </div> 85 + 86 + <footer class="mt-12"> 87 + {{ template "layouts/fragments/footer" . }} 88 + </footer> 89 + 90 + </body> 91 + </html>
+193
cmd/blog/main.go
··· 1 + package main 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "io" 7 + "log/slog" 8 + "net/http" 9 + "os" 10 + "path/filepath" 11 + 12 + "tangled.org/core/appview/config" 13 + "tangled.org/core/appview/pages" 14 + "tangled.org/core/blog" 15 + "tangled.org/core/idresolver" 16 + tlog "tangled.org/core/log" 17 + ) 18 + 19 + const ( 20 + postsDir = "blog/posts" 21 + templatesDir = "blog/templates" 22 + ) 23 + 24 + func main() { 25 + if len(os.Args) < 2 { 26 + fmt.Fprintln(os.Stderr, "usage: blog <build|serve> [flags]") 27 + os.Exit(1) 28 + } 29 + 30 + ctx := context.Background() 31 + logger := tlog.New("blog") 32 + 33 + switch os.Args[1] { 34 + case "build": 35 + if err := runBuild(ctx, logger); err != nil { 36 + logger.Error("build failed", "err", err) 37 + os.Exit(1) 38 + } 39 + case "serve": 40 + addr := "0.0.0.0:3001" 41 + if len(os.Args) >= 3 { 42 + addr = os.Args[2] 43 + } 44 + if err := runServe(ctx, logger, addr); err != nil { 45 + logger.Error("serve failed", "err", err) 46 + os.Exit(1) 47 + } 48 + default: 49 + fmt.Fprintf(os.Stderr, "unknown subcommand: %s\n", os.Args[1]) 50 + os.Exit(1) 51 + } 52 + } 53 + 54 + func makePages(ctx context.Context, cfg *config.Config, logger *slog.Logger) (*pages.Pages, error) { 55 + resolver := idresolver.DefaultResolver(cfg.Plc.PLCURL) 56 + return pages.NewPages(cfg, resolver, nil, logger), nil 57 + } 58 + 59 + func runBuild(ctx context.Context, logger *slog.Logger) error { 60 + cfg, err := config.LoadConfig(ctx) 61 + if err != nil { 62 + cfg = &config.Config{} 63 + } 64 + 65 + p, err := makePages(ctx, cfg, logger) 66 + if err != nil { 67 + return fmt.Errorf("creating pages: %w", err) 68 + } 69 + 70 + posts, err := blog.Posts(postsDir) 71 + if err != nil { 72 + return fmt.Errorf("parsing posts: %w", err) 73 + } 74 + 75 + outDir := "build" 76 + if err := os.MkdirAll(outDir, 0755); err != nil { 77 + return err 78 + } 79 + 80 + // index 81 + if err := renderToFile(outDir, "index.html", func(w io.Writer) error { 82 + return blog.RenderIndex(p, templatesDir, posts, w) 83 + }); err != nil { 84 + return fmt.Errorf("rendering index: %w", err) 85 + } 86 + 87 + // posts — each at build/<slug>/index.html directly (no /blog/ prefix) 88 + for _, post := range posts { 89 + post := post 90 + postDir := filepath.Join(outDir, post.Meta.Slug) 91 + if err := os.MkdirAll(postDir, 0755); err != nil { 92 + return err 93 + } 94 + if err := renderToFile(postDir, "index.html", func(w io.Writer) error { 95 + return blog.RenderPost(p, templatesDir, post, w) 96 + }); err != nil { 97 + return fmt.Errorf("rendering post %s: %w", post.Meta.Slug, err) 98 + } 99 + } 100 + 101 + // atom feed — at build/feed.xml 102 + baseURL := "https://blog.tangled.org" 103 + atom, err := blog.AtomFeed(posts, baseURL) 104 + if err != nil { 105 + return fmt.Errorf("generating atom feed: %w", err) 106 + } 107 + if err := os.WriteFile(filepath.Join(outDir, "feed.xml"), []byte(atom), 0644); err != nil { 108 + return fmt.Errorf("writing feed: %w", err) 109 + } 110 + 111 + logger.Info("build complete", "dir", outDir) 112 + return nil 113 + } 114 + 115 + func runServe(ctx context.Context, logger *slog.Logger, addr string) error { 116 + cfg, err := config.LoadConfig(ctx) 117 + if err != nil { 118 + cfg = &config.Config{} 119 + } 120 + 121 + p, err := makePages(ctx, cfg, logger) 122 + if err != nil { 123 + return fmt.Errorf("creating pages: %w", err) 124 + } 125 + 126 + mux := http.NewServeMux() 127 + 128 + // index 129 + mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) { 130 + if r.URL.Path != "/" { 131 + http.NotFound(w, r) 132 + return 133 + } 134 + posts, err := blog.AllPosts(postsDir) 135 + if err != nil { 136 + http.Error(w, err.Error(), http.StatusInternalServerError) 137 + return 138 + } 139 + if err := blog.RenderIndex(p, templatesDir, posts, w); err != nil { 140 + logger.Error("render index", "err", err) 141 + } 142 + }) 143 + 144 + // individual posts directly at /<slug> 145 + mux.HandleFunc("GET /{slug}", func(w http.ResponseWriter, r *http.Request) { 146 + slug := r.PathValue("slug") 147 + posts, err := blog.AllPosts(postsDir) 148 + if err != nil { 149 + http.Error(w, err.Error(), http.StatusInternalServerError) 150 + return 151 + } 152 + for _, post := range posts { 153 + if post.Meta.Slug == slug { 154 + if err := blog.RenderPost(p, templatesDir, post, w); err != nil { 155 + logger.Error("render post", "err", err) 156 + } 157 + return 158 + } 159 + } 160 + http.NotFound(w, r) 161 + }) 162 + 163 + // atom feed at /feed.xml 164 + mux.HandleFunc("GET /feed.xml", func(w http.ResponseWriter, r *http.Request) { 165 + posts, err := blog.Posts(postsDir) 166 + if err != nil { 167 + http.Error(w, err.Error(), http.StatusInternalServerError) 168 + return 169 + } 170 + atom, err := blog.AtomFeed(posts, "https://blog.tangled.org") 171 + if err != nil { 172 + http.Error(w, err.Error(), http.StatusInternalServerError) 173 + return 174 + } 175 + w.Header().Set("Content-Type", "application/atom+xml") 176 + fmt.Fprint(w, atom) 177 + }) 178 + 179 + // appview static files (tw.css, fonts, icons, logos) 180 + mux.Handle("GET /static/", p.Static()) 181 + 182 + logger.Info("serving", "addr", addr) 183 + return http.ListenAndServe(addr, mux) 184 + } 185 + 186 + func renderToFile(dir, name string, fn func(io.Writer) error) error { 187 + f, err := os.Create(filepath.Join(dir, name)) 188 + if err != nil { 189 + return err 190 + } 191 + defer f.Close() 192 + return fn(f) 193 + }
+3
go.mod
··· 61 61 62 62 require ( 63 63 dario.cat/mergo v1.0.1 // indirect 64 + github.com/BurntSushi/toml v0.3.1 // indirect 64 65 github.com/Microsoft/go-winio v0.6.2 // indirect 65 66 github.com/ProtonMail/go-crypto v1.3.0 // indirect 66 67 github.com/RoaringBitmap/roaring/v2 v2.4.5 // indirect 68 + github.com/adrg/frontmatter v0.2.0 // indirect 67 69 github.com/alecthomas/repr v0.5.2 // indirect 68 70 github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect 69 71 github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 // indirect ··· 231 233 gopkg.in/fsnotify.v1 v1.4.7 // indirect 232 234 gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect 233 235 gopkg.in/warnings.v0 v0.1.2 // indirect 236 + gopkg.in/yaml.v2 v2.4.0 // indirect 234 237 gotest.tools/v3 v3.5.2 // indirect 235 238 lukechampine.com/blake3 v1.4.1 // indirect 236 239 )
+4
go.sum
··· 4 4 github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= 5 5 github.com/Blank-Xu/sql-adapter v1.1.1 h1:+g7QXU9sl/qT6Po97teMpf3GjAO0X9aFaqgSePXvYko= 6 6 github.com/Blank-Xu/sql-adapter v1.1.1/go.mod h1:o2g8EZhZ3TudnYEGDkoU+3jCTCgDgx1o/Ig5ajKkaLY= 7 + github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ= 7 8 github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= 8 9 github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= 9 10 github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= ··· 11 12 github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE= 12 13 github.com/RoaringBitmap/roaring/v2 v2.4.5 h1:uGrrMreGjvAtTBobc0g5IrW1D5ldxDQYe2JW2gggRdg= 13 14 github.com/RoaringBitmap/roaring/v2 v2.4.5/go.mod h1:FiJcsfkGje/nZBZgCu0ZxCPOKD/hVXDS2dXi7/eUFE0= 15 + github.com/adrg/frontmatter v0.2.0 h1:/DgnNe82o03riBd1S+ZDjd43wAmC6W35q67NHeLkPd4= 16 + github.com/adrg/frontmatter v0.2.0/go.mod h1:93rQCj3z3ZlwyxxpQioRKC1wDLto4aXHrbqIsnH9wmE= 14 17 github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= 15 18 github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= 16 19 github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= ··· 770 773 gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 771 774 gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 772 775 gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 776 + gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= 773 777 gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 774 778 gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 775 779 gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+83 -70
tailwind.config.js
··· 2 2 const colors = require("tailwindcss/colors"); 3 3 4 4 module.exports = { 5 - content: ["./appview/pages/templates/**/*.html", "./appview/pages/chroma.go", "./docs/*.html"], 6 - darkMode: "media", 7 - theme: { 8 - container: { 9 - padding: "2rem", 10 - center: true, 11 - screens: { 12 - sm: "500px", 13 - md: "600px", 14 - lg: "800px", 15 - xl: "1000px", 16 - "2xl": "1200px", 17 - }, 18 - }, 19 - extend: { 20 - fontFamily: { 21 - sans: ["InterVariable", "system-ui", "sans-serif", "ui-sans-serif"], 22 - mono: [ 23 - "IBMPlexMono", 24 - "ui-monospace", 25 - "SFMono-Regular", 26 - "Menlo", 27 - "Monaco", 28 - "Consolas", 29 - "Liberation Mono", 30 - "Courier New", 31 - "monospace", 32 - ], 33 - }, 34 - typography: { 35 - DEFAULT: { 36 - css: { 37 - maxWidth: "none", 38 - pre: { 39 - "@apply font-normal text-black bg-gray-50 dark:bg-gray-900 dark:text-gray-300 dark:border-gray-700 border": {}, 40 - }, 41 - code: { 42 - "@apply font-normal font-mono p-1 rounded text-black bg-gray-100 dark:bg-gray-900 dark:text-gray-300 dark:border-gray-700": {}, 43 - }, 44 - "code::before": { 45 - content: '""', 46 - }, 47 - "code::after": { 48 - content: '""', 49 - }, 50 - blockquote: { 51 - quotes: "none", 52 - }, 53 - 'h1, h2, h3, h4': { 54 - "@apply mt-4 mb-2": {} 55 - }, 56 - h1: { 57 - "@apply mt-3 pb-3 border-b border-gray-300 dark:border-gray-600": {} 58 - }, 59 - h2: { 60 - "@apply mt-3 pb-3 border-b border-gray-200 dark:border-gray-700": {} 61 - }, 62 - h3: { 63 - "@apply mt-2": {} 64 - }, 65 - }, 66 - }, 67 - }, 68 - gridTemplateColumns: { 69 - '14': 'repeat(14, minmax(0, 1fr))', 70 - '28': 'repeat(28, minmax(0, 1fr))', 71 - } 72 - }, 73 - }, 74 - plugins: [require("@tailwindcss/typography")], 5 + content: [ 6 + "./appview/pages/templates/**/*.html", 7 + "./appview/pages/chroma.go", 8 + "./docs/*.html", 9 + "./blog/templates/**/*.html", 10 + "./blog/posts/**/*.md", 11 + ], 12 + darkMode: "media", 13 + theme: { 14 + container: { 15 + padding: "2rem", 16 + center: true, 17 + screens: { 18 + sm: "500px", 19 + md: "600px", 20 + lg: "800px", 21 + xl: "1000px", 22 + "2xl": "1200px", 23 + }, 24 + }, 25 + extend: { 26 + fontFamily: { 27 + sans: ["InterVariable", "system-ui", "sans-serif", "ui-sans-serif"], 28 + mono: [ 29 + "IBMPlexMono", 30 + "ui-monospace", 31 + "SFMono-Regular", 32 + "Menlo", 33 + "Monaco", 34 + "Consolas", 35 + "Liberation Mono", 36 + "Courier New", 37 + "monospace", 38 + ], 39 + }, 40 + typography: { 41 + DEFAULT: { 42 + css: { 43 + maxWidth: "none", 44 + pre: { 45 + "@apply font-normal text-black bg-gray-50 dark:bg-gray-900 dark:text-gray-300 dark:border-gray-700 border": 46 + {}, 47 + }, 48 + code: { 49 + "@apply font-normal font-mono p-1 rounded text-black bg-gray-100 dark:bg-gray-900 dark:text-gray-300 dark:border-gray-700": 50 + {}, 51 + }, 52 + "code::before": { 53 + content: '""', 54 + }, 55 + "code::after": { 56 + content: '""', 57 + }, 58 + blockquote: { 59 + quotes: "none", 60 + }, 61 + "h1, h2, h3, h4": { 62 + "@apply mt-4 mb-2": {}, 63 + }, 64 + h1: { 65 + "@apply mt-3 pb-3 border-b border-gray-300 dark:border-gray-600": 66 + {}, 67 + }, 68 + h2: { 69 + "@apply mt-3 pb-3 border-b border-gray-200 dark:border-gray-700": 70 + {}, 71 + }, 72 + h3: { 73 + "@apply mt-2": {}, 74 + }, 75 + img: { 76 + "@apply rounded border border-gray-200 dark:border-gray-700": {}, 77 + }, 78 + }, 79 + }, 80 + }, 81 + gridTemplateColumns: { 82 + 14: "repeat(14, minmax(0, 1fr))", 83 + 28: "repeat(28, minmax(0, 1fr))", 84 + }, 85 + }, 86 + }, 87 + plugins: [require("@tailwindcss/typography")], 75 88 };