forked from tangled.org/core
this repo has no description

Compare changes

Choose any two refs to compare.

+1 -1
.air/appview.toml
··· 5 5 6 6 exclude_regex = [".*_templ.go"] 7 7 include_ext = ["go", "templ", "html", "css"] 8 - exclude_dir = ["target", "atrium"] 8 + exclude_dir = ["target", "atrium", "nix"]
+3
.gitignore
··· 15 15 .env 16 16 *.rdb 17 17 .envrc 18 + # Created if following hacking.md 19 + genjwks.out 20 + /nix/vm-data
+1 -1
appview/db/follow.go
··· 53 53 return err 54 54 } 55 55 56 - func GetFollowerFollowing(e Execer, did string) (int, int, error) { 56 + func GetFollowerFollowingCount(e Execer, did string) (int, int, error) { 57 57 followers, following := 0, 0 58 58 err := e.QueryRow( 59 59 `SELECT
-62
appview/db/migrations/20250305_113405.sql
··· 1 - -- Simplified SQLite Database Migration Script for Issues and Comments 2 - 3 - -- Migration for issues table 4 - CREATE TABLE issues_new ( 5 - id integer primary key autoincrement, 6 - owner_did text not null, 7 - repo_at text not null, 8 - issue_id integer not null, 9 - title text not null, 10 - body text not null, 11 - open integer not null default 1, 12 - created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 13 - issue_at text, 14 - unique(repo_at, issue_id), 15 - foreign key (repo_at) references repos(at_uri) on delete cascade 16 - ); 17 - 18 - -- Migrate data to new issues table 19 - INSERT INTO issues_new ( 20 - id, owner_did, repo_at, issue_id, 21 - title, body, open, created, issue_at 22 - ) 23 - SELECT 24 - id, owner_did, repo_at, issue_id, 25 - title, body, open, created, issue_at 26 - FROM issues; 27 - 28 - -- Drop old issues table 29 - DROP TABLE issues; 30 - 31 - -- Rename new issues table 32 - ALTER TABLE issues_new RENAME TO issues; 33 - 34 - -- Migration for comments table 35 - CREATE TABLE comments_new ( 36 - id integer primary key autoincrement, 37 - owner_did text not null, 38 - issue_id integer not null, 39 - repo_at text not null, 40 - comment_id integer not null, 41 - comment_at text not null, 42 - body text not null, 43 - created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 44 - unique(issue_id, comment_id), 45 - foreign key (repo_at, issue_id) references issues(repo_at, issue_id) on delete cascade 46 - ); 47 - 48 - -- Migrate data to new comments table 49 - INSERT INTO comments_new ( 50 - id, owner_did, issue_id, repo_at, 51 - comment_id, comment_at, body, created 52 - ) 53 - SELECT 54 - id, owner_did, issue_id, repo_at, 55 - comment_id, comment_at, body, created 56 - FROM comments; 57 - 58 - -- Drop old comments table 59 - DROP TABLE comments; 60 - 61 - -- Rename new comments table 62 - ALTER TABLE comments_new RENAME TO comments;
-66
appview/db/migrations/validate.sql
··· 1 - -- Validation Queries for Database Migration 2 - 3 - -- 1. Verify Issues Table Structure 4 - PRAGMA table_info(issues); 5 - 6 - -- 2. Verify Comments Table Structure 7 - PRAGMA table_info(comments); 8 - 9 - -- 3. Check Total Row Count Consistency 10 - SELECT 11 - 'Issues Row Count' AS check_type, 12 - (SELECT COUNT(*) FROM issues) AS row_count 13 - UNION ALL 14 - SELECT 15 - 'Comments Row Count' AS check_type, 16 - (SELECT COUNT(*) FROM comments) AS row_count; 17 - 18 - -- 4. Verify Unique Constraint on Issues 19 - SELECT 20 - repo_at, 21 - issue_id, 22 - COUNT(*) as duplicate_count 23 - FROM issues 24 - GROUP BY repo_at, issue_id 25 - HAVING duplicate_count > 1; 26 - 27 - -- 5. Verify Foreign Key Integrity for Comments 28 - SELECT 29 - 'Orphaned Comments' AS check_type, 30 - COUNT(*) AS orphaned_count 31 - FROM comments c 32 - LEFT JOIN issues i ON c.repo_at = i.repo_at AND c.issue_id = i.issue_id 33 - WHERE i.id IS NULL; 34 - 35 - -- 6. Check Foreign Key Constraint 36 - PRAGMA foreign_key_list(comments); 37 - 38 - -- 7. Sample Data Integrity Check 39 - SELECT 40 - 'Sample Issues' AS check_type, 41 - repo_at, 42 - issue_id, 43 - title, 44 - created 45 - FROM issues 46 - LIMIT 5; 47 - 48 - -- 8. Sample Comments Data Integrity Check 49 - SELECT 50 - 'Sample Comments' AS check_type, 51 - repo_at, 52 - issue_id, 53 - comment_id, 54 - body, 55 - created 56 - FROM comments 57 - LIMIT 5; 58 - 59 - -- 9. Verify Constraint on Comments (Issue ID and Comment ID Uniqueness) 60 - SELECT 61 - issue_id, 62 - comment_id, 63 - COUNT(*) as duplicate_count 64 - FROM comments 65 - GROUP BY issue_id, comment_id 66 - HAVING duplicate_count > 1;
+2 -2
appview/db/repos.go
··· 391 391 var description, spindle sql.NullString 392 392 393 393 row := e.QueryRow(` 394 - select did, name, knot, created, at_uri, description, spindle 394 + select did, name, knot, created, at_uri, description, spindle 395 395 from repos 396 396 where did = ? and name = ? 397 397 `, ··· 556 556 return err 557 557 } 558 558 559 - func UpdateSpindle(e Execer, repoAt, spindle string) error { 559 + func UpdateSpindle(e Execer, repoAt string, spindle *string) error { 560 560 _, err := e.Exec( 561 561 `update repos set spindle = ? where at_uri = ?`, spindle, repoAt) 562 562 return err
+1 -1
appview/db/timeline.go
··· 162 162 163 163 followStatMap := make(map[string]FollowStats) 164 164 for _, s := range subjects { 165 - followers, following, err := GetFollowerFollowing(e, s) 165 + followers, following, err := GetFollowerFollowingCount(e, s) 166 166 if err != nil { 167 167 return nil, err 168 168 }
+1 -1
appview/middleware/middleware.go
··· 183 183 id, err := mw.idResolver.ResolveIdent(req.Context(), didOrHandle) 184 184 if err != nil { 185 185 // invalid did or handle 186 - log.Println("failed to resolve did/handle:", err) 186 + log.Printf("failed to resolve did/handle '%s': %s\n", didOrHandle, err) 187 187 mw.pages.Error404(w) 188 188 return 189 189 }
+3
appview/pages/funcmap.go
··· 236 236 }, 237 237 "cssContentHash": CssContentHash, 238 238 "fileTree": filetree.FileTree, 239 + "pathEscape": func(s string) string { 240 + return url.PathEscape(s) 241 + }, 239 242 "pathUnescape": func(s string) string { 240 243 u, _ := url.PathUnescape(s) 241 244 return u
+13 -2
appview/pages/templates/layouts/topbar.html
··· 24 24 {{ end }} 25 25 26 26 {{ define "newButton" }} 27 - <details class="relative inline-block text-left"> 27 + <details class="relative inline-block text-left nav-dropdown"> 28 28 <summary class="btn-create py-0 cursor-pointer list-none flex items-center gap-2"> 29 29 {{ i "plus" "w-4 h-4" }} new 30 30 </summary> ··· 42 42 {{ end }} 43 43 44 44 {{ define "dropDown" }} 45 - <details class="relative inline-block text-left"> 45 + <details class="relative inline-block text-left nav-dropdown"> 46 46 <summary 47 47 class="cursor-pointer list-none flex items-center" 48 48 > ··· 66 66 </a> 67 67 </div> 68 68 </details> 69 + 70 + <script> 71 + document.addEventListener('click', function(event) { 72 + const dropdowns = document.querySelectorAll('.nav-dropdown'); 73 + dropdowns.forEach(function(dropdown) { 74 + if (!dropdown.contains(event.target)) { 75 + dropdown.removeAttribute('open'); 76 + } 77 + }); 78 + }); 79 + </script> 69 80 {{ end }}
+5 -3
appview/pages/templates/repo/empty.html
··· 32 32 <div class="py-6 w-fit flex flex-col gap-4"> 33 33 <p>This is an empty repository. To get started:</p> 34 34 {{ $bullet := "mx-2 text-xs bg-gray-200 dark:bg-gray-600 rounded-full size-5 flex items-center justify-center font-mono inline-flex align-middle" }} 35 - <p><span class="{{$bullet}}">1</span>Add a public key to your account from the <a href="/settings" class="underline">settings</a> page</p> 36 - <p><span class="{{$bullet}}">2</span>Configure your remote to <span class="font-mono p-1 rounded bg-gray-100 dark:bg-gray-700 ">git@{{ $knot }}:{{ .RepoInfo.OwnerHandle }}/{{ .RepoInfo.Name }}<span></p> 37 - <p><span class="{{$bullet}}">3</span>Push!</p> 35 + 36 + <p><span class="{{$bullet}}">1</span>First, generate a new <a href="https://git-scm.com/book/en/v2/Git-on-the-Server-Generating-Your-SSH-Public-Key" class="underline">SSH key pair</a>.</p> 37 + <p><span class="{{$bullet}}">2</span>Then add the public key to your account from the <a href="/settings" class="underline">settings</a> page.</p> 38 + <p><span class="{{$bullet}}">3</span>Configure your remote to <code>git@{{ $knot }}:{{ .RepoInfo.OwnerHandle }}/{{ .RepoInfo.Name }}</code></p> 39 + <p><span class="{{$bullet}}">4</span>Push!</p> 38 40 </div> 39 41 </div> 40 42 {{ else }}
+5 -1
appview/pages/templates/repo/pipelines/workflow.html
··· 19 19 20 20 {{ define "sidebar" }} 21 21 {{ $active := .Workflow }} 22 + 23 + {{ $activeTab := "bg-white dark:bg-gray-700 drop-shadow-sm" }} 24 + {{ $inactiveTab := "bg-gray-100 dark:bg-gray-800" }} 25 + 22 26 {{ with .Pipeline }} 23 27 {{ $id := .Id }} 24 28 <div class="sticky top-2 grid grid-cols-1 rounded border border-gray-200 dark:border-gray-700 divide-y divide-gray-200 dark:divide-gray-700"> 25 29 {{ range $name, $all := .Statuses }} 26 30 <a href="/{{ $.RepoInfo.FullName }}/pipelines/{{ $id }}/workflow/{{ $name }}" class="no-underline hover:no-underline hover:bg-gray-100/25 hover:dark:bg-gray-700/25"> 27 31 <div 28 - class="flex gap-2 items-center justify-between p-2 {{ if eq $name $active }}bg-gray-100/50 dark:bg-gray-700/50{{ end }}"> 32 + class="flex gap-2 items-center justify-between p-2 {{ if eq $name $active }} {{ $activeTab }} {{ else }} {{ $inactiveTab }} {{ end }}"> 29 33 {{ $lastStatus := $all.Latest }} 30 34 {{ $kind := $lastStatus.Status.String }} 31 35
+9 -4
appview/pages/templates/repo/settings/pipelines.html
··· 34 34 {{ else }} 35 35 <form hx-post="/{{ $.RepoInfo.FullName }}/settings/spindle" class="col-span-1 md:col-span-1 md:justify-self-end group flex gap-2 items-stretch"> 36 36 <select 37 - id="spindle" 37 + id="spindle" 38 38 name="spindle" 39 - required 39 + required 40 40 class="p-1 max-w-64 border border-gray-200 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-700"> 41 - <option value="" disabled> 41 + {{/* For some reason, we can't use an empty string in a <select> in all scenarios unless it is preceded by a disabled select?? No idea, could just be a Firefox thing? */}} 42 + <option value="[[none]]" class="py-1" {{ if not $.CurrentSpindle }}selected{{ end }}> 43 + {{ if not $.CurrentSpindle }} 42 44 Choose a spindle 45 + {{ else }} 46 + Disable pipelines 47 + {{ end }} 43 48 </option> 44 49 {{ range $.Spindles }} 45 50 <option value="{{ . }}" class="py-1" {{ if eq . $.CurrentSpindle }}selected{{ end }}> ··· 82 87 {{ end }} 83 88 84 89 {{ define "addSecretButton" }} 85 - <button 90 + <button 86 91 class="btn flex items-center gap-2" 87 92 popovertarget="add-secret-modal" 88 93 popovertargetaction="toggle">
-168
appview/pages/templates/repo/settings.html
··· 1 - {{ define "title" }}settings &middot; {{ .RepoInfo.FullName }}{{ end }} 2 - 3 - {{ define "repoContent" }} 4 - {{ template "collaboratorSettings" . }} 5 - {{ template "branchSettings" . }} 6 - {{ template "dangerZone" . }} 7 - {{ template "spindleSelector" . }} 8 - {{ template "spindleSecrets" . }} 9 - {{ end }} 10 - 11 - {{ define "collaboratorSettings" }} 12 - <header class="font-bold text-sm mb-4 uppercase dark:text-white"> 13 - Collaborators 14 - </header> 15 - 16 - <div id="collaborator-list" class="flex flex-col gap-2 mb-2"> 17 - {{ range .Collaborators }} 18 - <div id="collaborator" class="mb-2"> 19 - <a 20 - href="/{{ didOrHandle .Did .Handle }}" 21 - class="no-underline hover:underline text-black dark:text-white" 22 - > 23 - {{ didOrHandle .Did .Handle }} 24 - </a> 25 - <div> 26 - <span class="text-sm text-gray-500 dark:text-gray-400"> 27 - {{ .Role }} 28 - </span> 29 - </div> 30 - </div> 31 - {{ end }} 32 - </div> 33 - 34 - {{ if .RepoInfo.Roles.CollaboratorInviteAllowed }} 35 - <form 36 - hx-put="/{{ $.RepoInfo.FullName }}/settings/collaborator" 37 - class="group" 38 - > 39 - <label for="collaborator" class="dark:text-white"> 40 - add collaborator 41 - </label> 42 - <input 43 - type="text" 44 - id="collaborator" 45 - name="collaborator" 46 - required 47 - class="dark:bg-gray-700 dark:text-white" 48 - placeholder="enter did or handle"> 49 - <button class="btn my-2 flex gap-2 items-center dark:text-white dark:hover:bg-gray-700" type="text"> 50 - <span>add</span> 51 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 52 - </button> 53 - </form> 54 - {{ end }} 55 - {{ end }} 56 - 57 - {{ define "dangerZone" }} 58 - {{ if .RepoInfo.Roles.RepoDeleteAllowed }} 59 - <form 60 - hx-confirm="Are you sure you want to delete this repository?" 61 - hx-delete="/{{ $.RepoInfo.FullName }}/settings/delete" 62 - class="mt-6" 63 - hx-indicator="#delete-repo-spinner"> 64 - <label for="branch">delete repository</label> 65 - <button class="btn my-2 flex items-center" type="text"> 66 - <span>delete</span> 67 - <span id="delete-repo-spinner" class="group"> 68 - {{ i "loader-circle" "ml-2 w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 69 - </span> 70 - </button> 71 - <span> 72 - Deleting a repository is irreversible and permanent. 73 - </span> 74 - </form> 75 - {{ end }} 76 - {{ end }} 77 - 78 - {{ define "branchSettings" }} 79 - <form hx-put="/{{ $.RepoInfo.FullName }}/settings/branches/default" class="mt-6 group"> 80 - <label for="branch">default branch</label> 81 - <div class="flex gap-2 items-center"> 82 - <select id="branch" name="branch" required class="p-1 border border-gray-200 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-700"> 83 - <option value="" disabled selected > 84 - Choose a default branch 85 - </option> 86 - {{ range .Branches }} 87 - <option value="{{ .Name }}" class="py-1" {{ if .IsDefault }}selected{{ end }} > 88 - {{ .Name }} 89 - </option> 90 - {{ end }} 91 - </select> 92 - <button class="btn my-2 flex gap-2 items-center" type="submit"> 93 - <span>save</span> 94 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 95 - </button> 96 - </div> 97 - </form> 98 - {{ end }} 99 - 100 - {{ define "spindleSelector" }} 101 - {{ if .RepoInfo.Roles.IsOwner }} 102 - <form hx-post="/{{ $.RepoInfo.FullName }}/settings/spindle" class="mt-6 group" > 103 - <label for="spindle">spindle</label> 104 - <div class="flex gap-2 items-center"> 105 - <select id="spindle" name="spindle" required class="p-1 border border-gray-200 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-700"> 106 - <option value="" selected > 107 - None 108 - </option> 109 - {{ range .Spindles }} 110 - <option value="{{ . }}" class="py-1" {{ if eq . $.CurrentSpindle }}selected{{ end }}> 111 - {{ . }} 112 - </option> 113 - {{ end }} 114 - </select> 115 - <button class="btn my-2 flex gap-2 items-center" type="submit"> 116 - <span>save</span> 117 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 118 - </button> 119 - </div> 120 - </form> 121 - {{ end }} 122 - {{ end }} 123 - 124 - {{ define "spindleSecrets" }} 125 - {{ if $.CurrentSpindle }} 126 - <header class="font-bold text-sm mb-4 uppercase dark:text-white"> 127 - Secrets 128 - </header> 129 - 130 - <div id="secret-list" class="flex flex-col gap-2 mb-2"> 131 - {{ range $idx, $secret := .Secrets }} 132 - {{ with $secret }} 133 - <div id="secret-{{$idx}}" class="mb-2"> 134 - {{ .Key }} created on {{ .CreatedAt }} by {{ .CreatedBy }} 135 - </div> 136 - {{ end }} 137 - {{ end }} 138 - </div> 139 - <form 140 - hx-put="/{{ $.RepoInfo.FullName }}/settings/secrets" 141 - class="mt-6" 142 - hx-indicator="#add-secret-spinner"> 143 - <label for="key">secret key</label> 144 - <input 145 - type="text" 146 - id="key" 147 - name="key" 148 - required 149 - class="dark:bg-gray-700 dark:text-white" 150 - placeholder="SECRET_KEY" /> 151 - <label for="value">secret value</label> 152 - <input 153 - type="text" 154 - id="value" 155 - name="value" 156 - required 157 - class="dark:bg-gray-700 dark:text-white" 158 - placeholder="SECRET VALUE" /> 159 - 160 - <button class="btn my-2 flex items-center" type="text"> 161 - <span>add</span> 162 - <span id="add-secret-spinner" class="group"> 163 - {{ i "loader-circle" "ml-2 w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 164 - </span> 165 - </button> 166 - </form> 167 - {{ end }} 168 - {{ end }}
+8 -2
appview/pages/templates/repo/tags.html
··· 97 97 {{ $isPushAllowed := $root.RepoInfo.Roles.IsPushAllowed }} 98 98 {{ $artifacts := index $root.ArtifactMap $tag.Tag.Hash }} 99 99 100 - {{ if or (gt (len $artifacts) 0) $isPushAllowed }} 101 100 <h2 class="my-4 text-sm text-left text-gray-700 dark:text-gray-300 uppercase font-bold">artifacts</h2> 102 101 <div class="flex flex-col rounded border border-gray-200 dark:border-gray-700"> 103 102 {{ range $artifact := $artifacts }} 104 103 {{ $args := dict "LoggedInUser" $root.LoggedInUser "RepoInfo" $root.RepoInfo "Artifact" $artifact }} 105 104 {{ template "repo/fragments/artifact" $args }} 106 105 {{ end }} 106 + <div id="artifact-git-source" class="flex items-center justify-between p-2 border-b border-gray-200 dark:border-gray-700"> 107 + <div id="left-side" class="flex items-center gap-2 min-w-0 max-w-[60%]"> 108 + {{ i "archive" "w-4 h-4" }} 109 + <a href="/{{ $root.RepoInfo.FullName }}/archive/{{ pathEscape (print "refs/tags/" $tag.Name) }}" class="no-underline hover:no-underline"> 110 + Source code (.tar.gz) 111 + </a> 112 + </div> 113 + </div> 107 114 {{ if $isPushAllowed }} 108 115 {{ block "uploadArtifact" (list $root $tag) }} {{ end }} 109 116 {{ end }} 110 117 </div> 111 - {{ end }} 112 118 {{ end }} 113 119 114 120 {{ define "uploadArtifact" }}
+3 -2
appview/pages/templates/repo/tree.html
··· 61 61 62 62 {{ if .IsFile }} 63 63 {{ $icon = "file" }} 64 - {{ $iconStyle = "size-4" }} 64 + {{ $iconStyle = "flex-shrink-0 size-4" }} 65 65 {{ end }} 66 66 <a href="{{ $link }}" class="{{ $linkstyle }}"> 67 67 <div class="flex items-center gap-2"> 68 - {{ i $icon $iconStyle }}{{ .Name }} 68 + {{ i $icon $iconStyle }} 69 + <span class="truncate">{{ .Name }}</span> 69 70 </div> 70 71 </a> 71 72 </div>
+13 -10
appview/pages/templates/strings/string.html
··· 19 19 <div> 20 20 <a href="/strings/{{ $ownerId }}">{{ $ownerId }}</a> 21 21 <span class="select-none">/</span> 22 - <a href="/{{ $ownerId }}/{{ .String.Rkey }}" class="font-bold">{{ .String.Filename }}</a> 22 + <a href="/strings/{{ $ownerId }}/{{ .String.Rkey }}" class="font-bold">{{ .String.Filename }}</a> 23 23 </div> 24 24 {{ if and .LoggedInUser (eq .LoggedInUser.Did .String.Did) }} 25 25 <div class="flex gap-2 text-base"> ··· 44 44 </div> 45 45 {{ end }} 46 46 </div> 47 - <span class="flex items-center"> 47 + <span> 48 48 {{ with .String.Description }} 49 49 {{ . }} 50 - <span class="select-none px-1 md:px-2 [&:before]:content-['ยท']"></span> 51 - {{ end }} 52 - 53 - {{ with .String.Edited }} 54 - <span>edited {{ template "repo/fragments/shortTimeAgo" . }}</span> 55 - {{ else }} 56 - {{ template "repo/fragments/shortTimeAgo" .String.Created }} 57 50 {{ end }} 58 51 </span> 59 52 </section> 60 53 <section class="bg-white dark:bg-gray-800 px-6 py-4 rounded relative w-full dark:text-white"> 61 54 <div class="flex justify-between items-center text-gray-500 dark:text-gray-400 text-sm md:text-base pb-2 mb-3 text-base border-b border-gray-200 dark:border-gray-700"> 62 - <span>{{ .String.Filename }}</span> 55 + <span> 56 + {{ .String.Filename }} 57 + <span class="select-none px-1 md:px-2 [&:before]:content-['ยท']"></span> 58 + <span> 59 + {{ with .String.Edited }} 60 + edited {{ template "repo/fragments/shortTimeAgo" . }} 61 + {{ else }} 62 + {{ template "repo/fragments/shortTimeAgo" .String.Created }} 63 + {{ end }} 64 + </span> 65 + </span> 63 66 <div> 64 67 <span>{{ .Stats.LineCount }} lines</span> 65 68 <span class="select-none px-1 md:px-2 [&:before]:content-['ยท']"></span>
+7 -4
appview/pages/templates/user/fragments/profileCard.html
··· 7 7 </div> 8 8 </div> 9 9 <div class="col-span-2"> 10 - <p title="{{ didOrHandle .UserDid .UserHandle }}" 11 - class="text-lg font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap max-w-full"> 12 - {{ didOrHandle .UserDid .UserHandle }} 13 - </p> 10 + <div class="flex items-center flex-row flex-nowrap gap-2"> 11 + <p title="{{ didOrHandle .UserDid .UserHandle }}" 12 + class="text-lg font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap"> 13 + {{ didOrHandle .UserDid .UserHandle }} 14 + </p> 15 + <a href="/{{ didOrHandle .UserDid .UserHandle }}/feed.atom">{{ i "rss" "size-4" }}</a> 16 + </div> 14 17 15 18 <div class="md:hidden"> 16 19 {{ block "followerFollowing" (list .Followers .Following) }} {{ end }}
+5 -4
appview/pages/templates/user/fragments/repoCard.html
··· 28 28 {{ define "repoStats" }} 29 29 <div class="text-gray-400 pt-4 text-sm font-mono inline-flex gap-4 mt-auto"> 30 30 {{ with .Language }} 31 - <div class="flex gap-2 items-center text-sm"> 32 - <div class="size-2 rounded-full" style="background-color: {{ langColor . }};"></div> 33 - <span>{{ . }}</span> 34 - </div> 31 + <div class="flex gap-2 items-center text-sm"> 32 + <div class="size-2 rounded-full" 33 + style="background: radial-gradient(circle at 35% 35%, color-mix(in srgb, {{ langColor . }} 70%, white), {{ langColor . }} 30%, color-mix(in srgb, {{ langColor . }} 85%, black));"></div> 34 + <span>{{ . }}</span> 35 + </div> 35 36 {{ end }} 36 37 {{ with .StarCount }} 37 38 <div class="flex gap-1 items-center text-sm">
+1 -1
appview/pages/templates/user/signup.html
··· 42 42 </button> 43 43 </form> 44 44 <p class="text-sm text-gray-500"> 45 - Already have an account? <a href="/login" class="underline">Login to Tangled</a>. 45 + Already have an ATProto account? <a href="/login" class="underline">Login to Tangled</a>. 46 46 </p> 47 47 48 48 <p id="signup-msg" class="error w-full"></p>
+44 -15
appview/repo/repo.go
··· 81 81 } 82 82 } 83 83 84 + func (rp *Repo) DownloadArchive(w http.ResponseWriter, r *http.Request) { 85 + refParam := chi.URLParam(r, "ref") 86 + f, err := rp.repoResolver.Resolve(r) 87 + if err != nil { 88 + log.Println("failed to get repo and knot", err) 89 + return 90 + } 91 + 92 + var uri string 93 + if rp.config.Core.Dev { 94 + uri = "http" 95 + } else { 96 + uri = "https" 97 + } 98 + url := fmt.Sprintf("%s://%s/%s/%s/archive/%s.tar.gz", uri, f.Knot, f.OwnerDid(), f.RepoName, url.PathEscape(refParam)) 99 + 100 + http.Redirect(w, r, url, http.StatusFound) 101 + } 102 + 84 103 func (rp *Repo) RepoLog(w http.ResponseWriter, r *http.Request) { 85 104 f, err := rp.repoResolver.Resolve(r) 86 105 if err != nil { ··· 657 676 } 658 677 659 678 newSpindle := r.FormValue("spindle") 679 + removingSpindle := newSpindle == "[[none]]" // see pages/templates/repo/settings/pipelines.html for more info on why we use this value 660 680 client, err := rp.oauth.AuthorizedClient(r) 661 681 if err != nil { 662 682 fail("Failed to authorize. Try again later.", err) 663 683 return 664 684 } 665 685 666 - // ensure that this is a valid spindle for this user 667 - validSpindles, err := rp.enforcer.GetSpindlesForUser(user.Did) 668 - if err != nil { 669 - fail("Failed to find spindles. Try again later.", err) 670 - return 686 + if !removingSpindle { 687 + // ensure that this is a valid spindle for this user 688 + validSpindles, err := rp.enforcer.GetSpindlesForUser(user.Did) 689 + if err != nil { 690 + fail("Failed to find spindles. Try again later.", err) 691 + return 692 + } 693 + 694 + if !slices.Contains(validSpindles, newSpindle) { 695 + fail("Failed to configure spindle.", fmt.Errorf("%s is not a valid spindle: %q", newSpindle, validSpindles)) 696 + return 697 + } 671 698 } 672 699 673 - if !slices.Contains(validSpindles, newSpindle) { 674 - fail("Failed to configure spindle.", fmt.Errorf("%s is not a valid spindle: %q", newSpindle, validSpindles)) 675 - return 700 + spindlePtr := &newSpindle 701 + if removingSpindle { 702 + spindlePtr = nil 676 703 } 677 704 678 705 // optimistic update 679 - err = db.UpdateSpindle(rp.db, string(repoAt), newSpindle) 706 + err = db.UpdateSpindle(rp.db, string(repoAt), spindlePtr) 680 707 if err != nil { 681 708 fail("Failed to update spindle. Try again later.", err) 682 709 return ··· 699 726 Owner: user.Did, 700 727 CreatedAt: f.CreatedAt, 701 728 Description: &f.Description, 702 - Spindle: &newSpindle, 729 + Spindle: spindlePtr, 703 730 }, 704 731 }, 705 732 }) ··· 709 736 return 710 737 } 711 738 712 - // add this spindle to spindle stream 713 - rp.spindlestream.AddSource( 714 - context.Background(), 715 - eventconsumer.NewSpindleSource(newSpindle), 716 - ) 739 + if !removingSpindle { 740 + // add this spindle to spindle stream 741 + rp.spindlestream.AddSource( 742 + context.Background(), 743 + eventconsumer.NewSpindleSource(newSpindle), 744 + ) 745 + } 717 746 718 747 rp.pages.HxRefresh(w) 719 748 }
+4
appview/repo/router.go
··· 38 38 r.Get("/blob/{ref}/*", rp.RepoBlob) 39 39 r.Get("/raw/{ref}/*", rp.RepoBlobRaw) 40 40 41 + // intentionally doesn't use /* as this isn't 42 + // a file path 43 + r.Get("/archive/{ref}", rp.DownloadArchive) 44 + 41 45 r.Route("/fork", func(r chi.Router) { 42 46 r.Use(middleware.AuthMiddleware(rp.oauth)) 43 47 r.Get("/", rp.ForkRepo)
+114 -2
appview/state/profile.go
··· 1 1 package state 2 2 3 3 import ( 4 + "context" 4 5 "fmt" 5 6 "log" 6 7 "net/http" ··· 13 14 "github.com/bluesky-social/indigo/atproto/syntax" 14 15 lexutil "github.com/bluesky-social/indigo/lex/util" 15 16 "github.com/go-chi/chi/v5" 17 + "github.com/gorilla/feeds" 16 18 "tangled.sh/tangled.sh/core/api/tangled" 17 19 "tangled.sh/tangled.sh/core/appview/db" 18 20 "tangled.sh/tangled.sh/core/appview/pages" ··· 116 118 } 117 119 } 118 120 119 - followers, following, err := db.GetFollowerFollowing(s.db, ident.DID.String()) 121 + followers, following, err := db.GetFollowerFollowingCount(s.db, ident.DID.String()) 120 122 if err != nil { 121 123 log.Printf("getting follow stats repos for %s: %s", ident.DID.String(), err) 122 124 } ··· 184 186 followStatus = db.GetFollowStatus(s.db, loggedInUser.Did, ident.DID.String()) 185 187 } 186 188 187 - followers, following, err := db.GetFollowerFollowing(s.db, ident.DID.String()) 189 + followers, following, err := db.GetFollowerFollowingCount(s.db, ident.DID.String()) 188 190 if err != nil { 189 191 log.Printf("getting follow stats repos for %s: %s", ident.DID.String(), err) 190 192 } ··· 202 204 Following: following, 203 205 }, 204 206 }) 207 + } 208 + 209 + func (s *State) feedFromRequest(w http.ResponseWriter, r *http.Request) *feeds.Feed { 210 + ident, ok := r.Context().Value("resolvedId").(identity.Identity) 211 + if !ok { 212 + s.pages.Error404(w) 213 + return nil 214 + } 215 + 216 + feed, err := s.GetProfileFeed(r.Context(), ident.Handle.String(), ident.DID.String()) 217 + if err != nil { 218 + s.pages.Error500(w) 219 + return nil 220 + } 221 + 222 + return feed 223 + } 224 + 225 + func (s *State) AtomFeedPage(w http.ResponseWriter, r *http.Request) { 226 + feed := s.feedFromRequest(w, r) 227 + if feed == nil { 228 + return 229 + } 230 + 231 + atom, err := feed.ToAtom() 232 + if err != nil { 233 + s.pages.Error500(w) 234 + return 235 + } 236 + 237 + w.Header().Set("content-type", "application/atom+xml") 238 + w.Write([]byte(atom)) 239 + } 240 + 241 + func (s *State) GetProfileFeed(ctx context.Context, handle string, did string) (*feeds.Feed, error) { 242 + timeline, err := db.MakeProfileTimeline(s.db, did) 243 + if err != nil { 244 + return nil, err 245 + } 246 + 247 + author := &feeds.Author{ 248 + Name: fmt.Sprintf("@%s", handle), 249 + } 250 + feed := &feeds.Feed{ 251 + Title: fmt.Sprintf("timeline feed for %s", author.Name), 252 + Link: &feeds.Link{Href: fmt.Sprintf("%s/@%s", s.config.Core.AppviewHost, handle), Type: "text/html", Rel: "alternate"}, 253 + Items: make([]*feeds.Item, 0), 254 + Updated: time.UnixMilli(0), 255 + Author: author, 256 + } 257 + for _, byMonth := range timeline.ByMonth { 258 + for _, pull := range byMonth.PullEvents.Items { 259 + owner, err := s.idResolver.ResolveIdent(ctx, pull.Repo.Did) 260 + if err != nil { 261 + return nil, err 262 + } 263 + feed.Items = append(feed.Items, &feeds.Item{ 264 + Title: fmt.Sprintf("%s created pull request '%s' in @%s/%s", author.Name, pull.Title, owner.Handle, pull.Repo.Name), 265 + Link: &feeds.Link{Href: fmt.Sprintf("%s/@%s/%s/pulls/%d", s.config.Core.AppviewHost, owner.Handle, pull.Repo.Name, pull.PullId), Type: "text/html", Rel: "alternate"}, 266 + Created: pull.Created, 267 + Author: author, 268 + }) 269 + for _, submission := range pull.Submissions { 270 + feed.Items = append(feed.Items, &feeds.Item{ 271 + Title: fmt.Sprintf("%s submitted pull request '%s' (round #%d) in @%s/%s", author.Name, pull.Title, submission.RoundNumber, owner.Handle, pull.Repo.Name), 272 + Link: &feeds.Link{Href: fmt.Sprintf("%s/@%s/%s/pulls/%d", s.config.Core.AppviewHost, owner.Handle, pull.Repo.Name, pull.PullId), Type: "text/html", Rel: "alternate"}, 273 + Created: submission.Created, 274 + Author: author, 275 + }) 276 + } 277 + } 278 + for _, issue := range byMonth.IssueEvents.Items { 279 + owner, err := s.idResolver.ResolveIdent(ctx, issue.Metadata.Repo.Did) 280 + if err != nil { 281 + return nil, err 282 + } 283 + feed.Items = append(feed.Items, &feeds.Item{ 284 + Title: fmt.Sprintf("%s created issue '%s' in @%s/%s", author.Name, issue.Title, owner.Handle, issue.Metadata.Repo.Name), 285 + Link: &feeds.Link{Href: fmt.Sprintf("%s/@%s/%s/issues/%d", s.config.Core.AppviewHost, owner.Handle, issue.Metadata.Repo.Name, issue.IssueId), Type: "text/html", Rel: "alternate"}, 286 + Created: issue.Created, 287 + Author: author, 288 + }) 289 + } 290 + for _, repo := range byMonth.RepoEvents { 291 + var title string 292 + if repo.Source != nil { 293 + id, err := s.idResolver.ResolveIdent(ctx, repo.Source.Did) 294 + if err != nil { 295 + return nil, err 296 + } 297 + title = fmt.Sprintf("%s forked repository @%s/%s to '%s'", author.Name, id.Handle, repo.Source.Name, repo.Repo.Name) 298 + } else { 299 + title = fmt.Sprintf("%s created repository '%s'", author.Name, repo.Repo.Name) 300 + } 301 + feed.Items = append(feed.Items, &feeds.Item{ 302 + Title: title, 303 + Link: &feeds.Link{Href: fmt.Sprintf("%s/@%s/%s", s.config.Core.AppviewHost, handle, repo.Repo.Name), Type: "text/html", Rel: "alternate"}, 304 + Created: repo.Repo.Created, 305 + Author: author, 306 + }) 307 + } 308 + } 309 + slices.SortFunc(feed.Items, func(a *feeds.Item, b *feeds.Item) int { 310 + return int(b.Created.UnixMilli()) - int(a.Created.UnixMilli()) 311 + }) 312 + if len(feed.Items) > 0 { 313 + feed.Updated = feed.Items[0].Created 314 + } 315 + 316 + return feed, nil 205 317 } 206 318 207 319 func (s *State) UpdateProfileBio(w http.ResponseWriter, r *http.Request) {
+1
appview/state/router.go
··· 70 70 71 71 r.With(mw.ResolveIdent()).Route("/{user}", func(r chi.Router) { 72 72 r.Get("/", s.Profile) 73 + r.Get("/feed.atom", s.AtomFeedPage) 73 74 74 75 r.With(mw.ResolveRepo()).Route("/{repo}", func(r chi.Router) { 75 76 r.Use(mw.GoImport())
+6 -1
appview/strings/strings.go
··· 99 99 w.WriteHeader(http.StatusInternalServerError) 100 100 return 101 101 } 102 + if len(strings) < 1 { 103 + l.Error("string not found") 104 + s.Pages.Error404(w) 105 + return 106 + } 102 107 if len(strings) != 1 { 103 108 l.Error("incorrect number of records returned", "len(strings)", len(strings)) 104 109 w.WriteHeader(http.StatusInternalServerError) ··· 177 182 followStatus = db.GetFollowStatus(s.Db, loggedInUser.Did, id.DID.String()) 178 183 } 179 184 180 - followers, following, err := db.GetFollowerFollowing(s.Db, id.DID.String()) 185 + followers, following, err := db.GetFollowerFollowingCount(s.Db, id.DID.String()) 181 186 if err != nil { 182 187 l.Error("failed to get follow stats", "err", err) 183 188 }
+4
cmd/genjwks/main.go
··· 30 30 panic(err) 31 31 } 32 32 33 + if err := key.Set("use", "sig"); err != nil { 34 + panic(err) 35 + } 36 + 33 37 b, err := json.Marshal(key) 34 38 if err != nil { 35 39 panic(err)
+8 -15
docs/contributing.md
··· 115 115 If you're submitting a PR with multiple commits, make sure each one is 116 116 signed. 117 117 118 - For [jj](https://jj-vcs.github.io/jj/latest/) users, you can add this to 119 - your jj config: 118 + For [jj](https://jj-vcs.github.io/jj/latest/) users, you can run the following command 119 + to make it sign off commits in the tangled repo: 120 120 121 - ``` 122 - ui.should-sign-off = true 123 - ``` 124 - 125 - and to your `templates.draft_commit_description`, add the following `if` 126 - block: 127 - 128 - ``` 129 - if( 130 - config("ui.should-sign-off").as_boolean() && !description.contains("Signed-off-by: " ++ author.name()), 131 - "\nSigned-off-by: " ++ author.name() ++ " <" ++ author.email() ++ ">", 132 - ), 121 + ```shell 122 + # Safety check, should say "No matching config key..." 123 + jj config list templates.commit_trailers 124 + # The command below may need to be adjusted if the command above returned something. 125 + jj config set --repo templates.commit_trailers "format_signed_off_by_trailer(self)" 133 126 ``` 134 127 135 128 Refer to the [jj 136 - documentation](https://jj-vcs.github.io/jj/latest/config/#default-description) 129 + documentation](https://jj-vcs.github.io/jj/latest/config/#commit-trailers) 137 130 for more information.
+20 -15
docs/hacking.md
··· 56 56 `nixosConfiguration` to do so. 57 57 58 58 To begin, head to `http://localhost:3000/knots` in the browser 59 - and generate a knot secret. Replace the existing secret in 60 - `nix/vm.nix` (`KNOT_SERVER_SECRET`) with the newly generated 61 - secret. 59 + and create a knot with hostname `localhost:6000`. This will 60 + generate a knot secret. Set `$TANGLED_VM_KNOT_SECRET` to it, 61 + ideally in a `.envrc` with [direnv](https://direnv.net) so you 62 + don't lose it. 62 63 63 - You can now start a lightweight NixOS VM using 64 - `nixos-shell` like so: 64 + You will also need to set the `$TANGLED_VM_SPINDLE_OWNER` 65 + variable to some value. If you don't want to [set up a 66 + spindle](#running-a-spindle), you can use any placeholder 67 + value. 68 + 69 + You can now start a lightweight NixOS VM like so: 65 70 66 71 ```bash 67 - nix run .#vm 68 - # or nixos-shell --flake .#vm 72 + nix run --impure .#vm 69 73 70 - # hit Ctrl-a + c + q to exit the VM 74 + # type `poweroff` at the shell to exit the VM 71 75 ``` 72 76 73 77 This starts a knot on port 6000, a spindle on port 6555 ··· 91 95 92 96 ## running a spindle 93 97 94 - Be sure to change the `owner` field for the spindle in 95 - `nix/vm.nix` to your own DID. The above VM should already 96 - be running a spindle on `localhost:6555`. You can head to 97 - the spindle dashboard on `http://localhost:3000/spindles`, 98 - and register a spindle with hostname `localhost:6555`. It 99 - should instantly be verified. You can then configure each 100 - repository to use this spindle and run CI jobs. 98 + You will need to find out your DID by entering your login handle into 99 + <https://pdsls.dev/>. Set `$TANGLED_VM_SPINDLE_OWNER` to your DID. 100 + 101 + The above VM should already be running a spindle on `localhost:6555`. 102 + You can head to the spindle dashboard on `http://localhost:3000/spindles`, 103 + and register a spindle with hostname `localhost:6555`. It should instantly 104 + be verified. You can then configure each repository to use this spindle 105 + and run CI jobs. 101 106 102 107 Of interest when debugging spindles: 103 108
+10 -4
docs/knot-hosting.md
··· 2 2 3 3 So you want to run your own knot server? Great! Here are a few prerequisites: 4 4 5 - 1. A server of some kind (a VPS, a Raspberry Pi, etc.). Preferably running a Linux of some kind. 5 + 1. A server of some kind (a VPS, a Raspberry Pi, etc.). Preferably running a Linux distribution of some kind. 6 6 2. A (sub)domain name. People generally use `knot.example.com`. 7 7 3. A valid SSL certificate for your domain. 8 8 ··· 59 59 EOF 60 60 ``` 61 61 62 + Then, reload `sshd`: 63 + 64 + ``` 65 + sudo systemctl reload ssh 66 + ``` 67 + 62 68 Next, create the `git` user. We'll use the `git` user's home directory 63 69 to store repositories: 64 70 ··· 67 73 ``` 68 74 69 75 Create `/home/git/.knot.env` with the following, updating the values as 70 - necessary. The `KNOT_SERVER_SECRET` can be obtaind from the 71 - [/knots](/knots) page on Tangled. 76 + necessary. The `KNOT_SERVER_SECRET` can be obtained from the 77 + [/knots](https://tangled.sh/knots) page on Tangled. 72 78 73 79 ``` 74 80 KNOT_REPO_SCAN_PATH=/home/git ··· 123 129 knot domain. 124 130 125 131 You should now have a running knot server! You can finalize your registration by hitting the 126 - `initialize` button on the [/knots](/knots) page. 132 + `initialize` button on the [/knots](https://tangled.sh/knots) page. 127 133 128 134 ### custom paths 129 135
+3 -3
flake.lock
··· 26 26 ] 27 27 }, 28 28 "locked": { 29 - "lastModified": 1751702058, 30 - "narHash": "sha256-/GTdqFzFw/Y9DSNAfzvzyCMlKjUyRKMPO+apIuaTU4A=", 29 + "lastModified": 1754078208, 30 + "narHash": "sha256-YVoIFDCDpYuU3riaDEJ3xiGdPOtsx4sR5eTzHTytPV8=", 31 31 "owner": "nix-community", 32 32 "repo": "gomod2nix", 33 - "rev": "664ad7a2df4623037e315e4094346bff5c44e9ee", 33 + "rev": "7f963246a71626c7fc70b431a315c4388a0c95cf", 34 34 "type": "github" 35 35 }, 36 36 "original": {
+63 -21
flake.nix
··· 75 75 }; 76 76 genjwks = self.callPackage ./nix/pkgs/genjwks.nix {}; 77 77 lexgen = self.callPackage ./nix/pkgs/lexgen.nix {inherit indigo;}; 78 - appview = self.callPackage ./nix/pkgs/appview.nix { 78 + appview-static-files = self.callPackage ./nix/pkgs/appview-static-files.nix { 79 79 inherit htmx-src htmx-ws-src lucide-src inter-fonts-src ibm-plex-mono-src; 80 80 }; 81 + appview = self.callPackage ./nix/pkgs/appview.nix {}; 81 82 spindle = self.callPackage ./nix/pkgs/spindle.nix {}; 82 83 knot-unwrapped = self.callPackage ./nix/pkgs/knot-unwrapped.nix {}; 83 84 knot = self.callPackage ./nix/pkgs/knot.nix {}; ··· 93 94 staticPackages = mkPackageSet pkgs.pkgsStatic; 94 95 crossPackages = mkPackageSet pkgs.pkgsCross.gnu64.pkgsStatic; 95 96 in { 96 - appview = packages.appview; 97 - lexgen = packages.lexgen; 98 - knot = packages.knot; 99 - knot-unwrapped = packages.knot-unwrapped; 100 - spindle = packages.spindle; 101 - genjwks = packages.genjwks; 102 - sqlite-lib = packages.sqlite-lib; 97 + inherit (packages) appview appview-static-files lexgen genjwks spindle knot knot-unwrapped sqlite-lib; 103 98 104 99 pkgsStatic-appview = staticPackages.appview; 105 100 pkgsStatic-knot = staticPackages.knot; ··· 132 127 pkgs.tailwindcss 133 128 pkgs.nixos-shell 134 129 pkgs.redis 130 + pkgs.coreutils # for those of us who are on systems that use busybox (alpine) 135 131 packages'.lexgen 136 132 ]; 137 133 shellHook = '' 138 - mkdir -p appview/pages/static/{fonts,icons} 139 - cp -f ${htmx-src} appview/pages/static/htmx.min.js 140 - cp -f ${htmx-ws-src} appview/pages/static/htmx-ext-ws.min.js 141 - cp -rf ${lucide-src}/*.svg appview/pages/static/icons/ 142 - cp -f ${inter-fonts-src}/web/InterVariable*.woff2 appview/pages/static/fonts/ 143 - cp -f ${inter-fonts-src}/web/InterDisplay*.woff2 appview/pages/static/fonts/ 144 - cp -f ${ibm-plex-mono-src}/fonts/complete/woff2/IBMPlexMono-Regular.woff2 appview/pages/static/fonts/ 134 + mkdir -p appview/pages/static 135 + # no preserve is needed because watch-tailwind will want to be able to overwrite 136 + cp -fr --no-preserve=ownership ${packages'.appview-static-files}/* appview/pages/static 145 137 export TANGLED_OAUTH_JWKS="$(${packages'.genjwks}/bin/genjwks)" 146 138 ''; 147 139 env.CGO_ENABLED = 1; ··· 149 141 }); 150 142 apps = forAllSystems (system: let 151 143 pkgs = nixpkgsFor."${system}"; 144 + packages' = self.packages.${system}; 152 145 air-watcher = name: arg: 153 146 pkgs.writeShellScriptBin "run" 154 147 '' ··· 167 160 in { 168 161 watch-appview = { 169 162 type = "app"; 170 - program = ''${air-watcher "appview" ""}/bin/run''; 163 + program = toString (pkgs.writeShellScript "watch-appview" '' 164 + echo "copying static files to appview/pages/static..." 165 + ${pkgs.coreutils}/bin/cp -fr --no-preserve=ownership ${packages'.appview-static-files}/* appview/pages/static 166 + ${air-watcher "appview" ""}/bin/run 167 + ''); 171 168 }; 172 169 watch-knot = { 173 170 type = "app"; ··· 177 174 type = "app"; 178 175 program = ''${tailwind-watcher}/bin/run''; 179 176 }; 180 - vm = { 177 + vm = let 178 + guestSystem = 179 + if pkgs.stdenv.hostPlatform.isAarch64 180 + then "aarch64-linux" 181 + else "x86_64-linux"; 182 + in { 181 183 type = "app"; 182 - program = toString (pkgs.writeShellScript "vm" '' 183 - ${pkgs.nixos-shell}/bin/nixos-shell --flake .#vm 184 - ''); 184 + program = 185 + (pkgs.writeShellApplication { 186 + name = "launch-vm"; 187 + text = '' 188 + rootDir=$(jj --ignore-working-copy root || git rev-parse --show-toplevel) || (echo "error: can't find repo root?"; exit 1) 189 + cd "$rootDir" 190 + 191 + mkdir -p nix/vm-data/{knot,repos,spindle,spindle-logs} 192 + 193 + export TANGLED_VM_DATA_DIR="$rootDir/nix/vm-data" 194 + exec ${pkgs.lib.getExe 195 + (import ./nix/vm.nix { 196 + inherit nixpkgs self; 197 + system = guestSystem; 198 + hostSystem = system; 199 + }).config.system.build.vm} 200 + ''; 201 + }) 202 + + /bin/launch-vm; 185 203 }; 186 204 gomod2nix = { 187 205 type = "app"; ··· 189 207 ${gomod2nix.legacyPackages.${system}.gomod2nix}/bin/gomod2nix generate --outdir ./nix 190 208 ''); 191 209 }; 210 + lexgen = { 211 + type = "app"; 212 + program = 213 + (pkgs.writeShellApplication { 214 + name = "lexgen"; 215 + text = '' 216 + if ! command -v lexgen > /dev/null; then 217 + echo "error: must be executed from devshell" 218 + exit 1 219 + fi 220 + 221 + rootDir=$(jj --ignore-working-copy root || git rev-parse --show-toplevel) || (echo "error: can't find repo root?"; exit 1) 222 + cd "$rootDir" 223 + 224 + rm api/tangled/* 225 + lexgen --build-file lexicon-build-config.json lexicons 226 + sed -i.bak 's/\tutil/\/\/\tutil/' api/tangled/* 227 + ${pkgs.gotools}/bin/goimports -w api/tangled/* 228 + go run cmd/gen.go 229 + lexgen --build-file lexicon-build-config.json lexicons 230 + rm api/tangled/*.bak 231 + ''; 232 + }) 233 + + /bin/lexgen; 234 + }; 192 235 }); 193 236 194 237 nixosModules.appview = { ··· 218 261 219 262 services.tangled-spindle.package = lib.mkDefault self.packages.${pkgs.system}.spindle; 220 263 }; 221 - nixosConfigurations.vm = import ./nix/vm.nix {inherit self nixpkgs;}; 222 264 }; 223 265 }
+1
go.mod
··· 88 88 github.com/golang/mock v1.6.0 // indirect 89 89 github.com/google/go-querystring v1.1.0 // indirect 90 90 github.com/gorilla/css v1.0.1 // indirect 91 + github.com/gorilla/feeds v1.2.0 // indirect 91 92 github.com/gorilla/securecookie v1.1.2 // indirect 92 93 github.com/hashicorp/errwrap v1.1.0 // indirect 93 94 github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
+2
go.sum
··· 173 173 github.com/gopherjs/gopherjs v0.0.0-20190430165422-3e4dfb77656c/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= 174 174 github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= 175 175 github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= 176 + github.com/gorilla/feeds v1.2.0 h1:O6pBiXJ5JHhPvqy53NsjKOThq+dNFm8+DFrxBEdzSCc= 177 + github.com/gorilla/feeds v1.2.0/go.mod h1:WMib8uJP3BbY+X8Szd1rA5Pzhdfh+HCCAYT2z7Fza6Y= 176 178 github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= 177 179 github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= 178 180 github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ=
+12
input.css
··· 70 70 details summary::-webkit-details-marker { 71 71 display: none; 72 72 } 73 + 74 + code { 75 + @apply px-1 font-mono rounded bg-gray-100 dark:bg-gray-700; 76 + } 73 77 } 74 78 75 79 @layer components { ··· 102 106 display: inline; 103 107 margin: 0; 104 108 vertical-align: middle; 109 + } 110 + 111 + .prose input { 112 + @apply inline-block my-0 mb-1 mx-1; 113 + } 114 + 115 + .prose input[type="checkbox"] { 116 + @apply disabled:accent-blue-500 checked:accent-blue-500 disabled:checked:accent-blue-500; 105 117 } 106 118 } 107 119 @layer utilities {
+19 -12
knotserver/git/post_receive.go
··· 3 3 import ( 4 4 "bufio" 5 5 "context" 6 + "errors" 6 7 "fmt" 7 8 "io" 8 9 "strings" ··· 57 58 ByEmail map[string]int 58 59 } 59 60 60 - func (g *GitRepo) RefUpdateMeta(line PostReceiveLine) RefUpdateMeta { 61 + func (g *GitRepo) RefUpdateMeta(line PostReceiveLine) (RefUpdateMeta, error) { 62 + var errs error 63 + 61 64 commitCount, err := g.newCommitCount(line) 62 - if err != nil { 63 - // TODO: log this 64 - } 65 + errors.Join(errs, err) 65 66 66 67 isDefaultRef, err := g.isDefaultBranch(line) 67 - if err != nil { 68 - // TODO: log this 69 - } 68 + errors.Join(errs, err) 70 69 71 70 ctx, cancel := context.WithTimeout(context.Background(), time.Second*2) 72 71 defer cancel() 73 72 breakdown, err := g.AnalyzeLanguages(ctx) 74 - if err != nil { 75 - // TODO: log this 76 - } 73 + errors.Join(errs, err) 77 74 78 75 return RefUpdateMeta{ 79 76 CommitCount: commitCount, 80 77 IsDefaultRef: isDefaultRef, 81 78 LangBreakdown: breakdown, 82 - } 79 + }, errs 83 80 } 84 81 85 82 func (g *GitRepo) newCommitCount(line PostReceiveLine) (CommitCount, error) { ··· 95 92 args := []string{fmt.Sprintf("--max-count=%d", 100)} 96 93 97 94 if line.OldSha.IsZero() { 98 - // just git rev-list <newsha> 95 + // git rev-list <newsha> ^other-branches --not ^this-branch 99 96 args = append(args, line.NewSha.String()) 97 + 98 + branches, _ := g.Branches() 99 + for _, b := range branches { 100 + if !strings.Contains(line.Ref, b.Name) { 101 + args = append(args, fmt.Sprintf("^%s", b.Name)) 102 + } 103 + } 104 + 105 + args = append(args, "--not") 106 + args = append(args, fmt.Sprintf("^%s", line.Ref)) 100 107 } else { 101 108 // git rev-list <oldsha>..<newsha> 102 109 args = append(args, fmt.Sprintf("%s..%s", line.OldSha.String(), line.NewSha.String()))
+5 -2
knotserver/internal.go
··· 3 3 import ( 4 4 "context" 5 5 "encoding/json" 6 + "errors" 6 7 "fmt" 7 8 "log/slog" 8 9 "net/http" ··· 145 146 return fmt.Errorf("failed to open git repo at ref %s: %w", line.Ref, err) 146 147 } 147 148 148 - meta := gr.RefUpdateMeta(line) 149 + var errs error 150 + meta, err := gr.RefUpdateMeta(line) 151 + errors.Join(errs, err) 149 152 150 153 metaRecord := meta.AsRecord() 151 154 ··· 169 172 EventJson: string(eventJson), 170 173 } 171 174 172 - return h.db.InsertEvent(event, h.n) 175 + return errors.Join(errs, h.db.InsertEvent(event, h.n)) 173 176 } 174 177 175 178 func (h *InternalHandle) triggerPipeline(clientMsgs *[]string, line git.PostReceiveLine, gitUserDid, repoDid, repoName string, pushOptions PushOptions) error {
+11 -3
knotserver/routes.go
··· 361 361 362 362 ref := strings.TrimSuffix(file, ".tar.gz") 363 363 364 + unescapedRef, err := url.PathUnescape(ref) 365 + if err != nil { 366 + notFound(w) 367 + return 368 + } 369 + 370 + safeRefFilename := strings.ReplaceAll(plumbing.ReferenceName(unescapedRef).Short(), "/", "-") 371 + 364 372 // This allows the browser to use a proper name for the file when 365 373 // downloading 366 - filename := fmt.Sprintf("%s-%s.tar.gz", name, ref) 374 + filename := fmt.Sprintf("%s-%s.tar.gz", name, safeRefFilename) 367 375 setContentDisposition(w, filename) 368 376 setGZipMIME(w) 369 377 370 378 path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 371 - gr, err := git.Open(path, ref) 379 + gr, err := git.Open(path, unescapedRef) 372 380 if err != nil { 373 381 notFound(w) 374 382 return ··· 377 385 gw := gzip.NewWriter(w) 378 386 defer gw.Close() 379 387 380 - prefix := fmt.Sprintf("%s-%s", name, ref) 388 + prefix := fmt.Sprintf("%s-%s", name, safeRefFilename) 381 389 err = gr.WriteTar(gw, prefix) 382 390 if err != nil { 383 391 // once we start writing to the body we can't report error anymore
+3
nix/gomod2nix.toml
··· 181 181 [mod."github.com/gorilla/css"] 182 182 version = "v1.0.1" 183 183 hash = "sha256-6JwNHqlY2NpZ0pSQTyYPSpiNqjXOdFHqrUT10sv3y8A=" 184 + [mod."github.com/gorilla/feeds"] 185 + version = "v1.2.0" 186 + hash = "sha256-ptczizo27t6Bsq6rHJ4WiHmBRP54UC5yNfHghAqOBQk=" 184 187 [mod."github.com/gorilla/securecookie"] 185 188 version = "v1.1.2" 186 189 hash = "sha256-KeMHNM9emxX+N0WYiZsTii7n8sNsmjWwbnQ9SaJfTKE="
+14
nix/modules/appview.nix
··· 27 27 default = "00000000000000000000000000000000"; 28 28 description = "Cookie secret"; 29 29 }; 30 + environmentFile = mkOption { 31 + type = with types; nullOr path; 32 + default = null; 33 + example = "/etc/tangled-appview.env"; 34 + description = '' 35 + Additional environment file as defined in {manpage}`systemd.exec(5)`. 36 + 37 + Sensitive secrets such as {env}`TANGLED_COOKIE_SECRET` may be 38 + passed to the service without makeing them world readable in the 39 + nix store. 40 + 41 + ''; 42 + }; 30 43 }; 31 44 }; 32 45 ··· 39 52 ListenStream = "0.0.0.0:${toString cfg.port}"; 40 53 ExecStart = "${cfg.package}/bin/appview"; 41 54 Restart = "always"; 55 + EnvironmentFile = optional (cfg.environmentFile != null) cfg.environmentFile; 42 56 }; 43 57 44 58 environment = {
+27 -24
nix/modules/knot.nix
··· 126 126 cfg.package 127 127 ]; 128 128 129 - system.activationScripts.gitConfig = let 130 - setMotd = 131 - if cfg.motdFile != null && cfg.motd != null 132 - then throw "motdFile and motd cannot be both set" 133 - else '' 134 - ${optionalString (cfg.motdFile != null) "cat ${cfg.motdFile} > ${cfg.stateDir}/motd"} 135 - ${optionalString (cfg.motd != null) ''printf "${cfg.motd}" > ${cfg.stateDir}/motd''} 136 - ''; 137 - in '' 138 - mkdir -p "${cfg.repo.scanPath}" 139 - chown -R ${cfg.gitUser}:${cfg.gitUser} "${cfg.repo.scanPath}" 140 - 141 - mkdir -p "${cfg.stateDir}/.config/git" 142 - cat > "${cfg.stateDir}/.config/git/config" << EOF 143 - [user] 144 - name = Git User 145 - email = git@example.com 146 - [receive] 147 - advertisePushOptions = true 148 - EOF 149 - ${setMotd} 150 - chown -R ${cfg.gitUser}:${cfg.gitUser} "${cfg.stateDir}" 151 - ''; 152 - 153 129 users.users.${cfg.gitUser} = { 154 130 isSystemUser = true; 155 131 useDefaultShell = true; ··· 185 161 description = "knot service"; 186 162 after = ["network.target" "sshd.service"]; 187 163 wantedBy = ["multi-user.target"]; 164 + enableStrictShellChecks = true; 165 + 166 + preStart = let 167 + setMotd = 168 + if cfg.motdFile != null && cfg.motd != null 169 + then throw "motdFile and motd cannot be both set" 170 + else '' 171 + ${optionalString (cfg.motdFile != null) "cat ${cfg.motdFile} > ${cfg.stateDir}/motd"} 172 + ${optionalString (cfg.motd != null) ''printf "${cfg.motd}" > ${cfg.stateDir}/motd''} 173 + ''; 174 + in '' 175 + mkdir -p "${cfg.repo.scanPath}" 176 + chown -R ${cfg.gitUser}:${cfg.gitUser} "${cfg.repo.scanPath}" 177 + 178 + mkdir -p "${cfg.stateDir}/.config/git" 179 + cat > "${cfg.stateDir}/.config/git/config" << EOF 180 + [user] 181 + name = Git User 182 + email = git@example.com 183 + [receive] 184 + advertisePushOptions = true 185 + EOF 186 + ${setMotd} 187 + chown -R ${cfg.gitUser}:${cfg.gitUser} "${cfg.stateDir}" 188 + ''; 189 + 188 190 serviceConfig = { 189 191 User = cfg.gitUser; 192 + PermissionsStartOnly = true; 190 193 WorkingDirectory = cfg.stateDir; 191 194 Environment = [ 192 195 "KNOT_REPO_SCAN_PATH=${cfg.repo.scanPath}"
+29
nix/pkgs/appview-static-files.nix
··· 1 + { 2 + runCommandLocal, 3 + htmx-src, 4 + htmx-ws-src, 5 + lucide-src, 6 + inter-fonts-src, 7 + ibm-plex-mono-src, 8 + sqlite-lib, 9 + tailwindcss, 10 + src, 11 + }: 12 + runCommandLocal "appview-static-files" { 13 + # TOOD(winter): figure out why this is even required after 14 + # changing the libraries that the tailwindcss binary loads 15 + sandboxProfile = '' 16 + (allow file-read* (subpath "/System/Library/OpenSSL")) 17 + ''; 18 + } '' 19 + mkdir -p $out/{fonts,icons} && cd $out 20 + cp -f ${htmx-src} htmx.min.js 21 + cp -f ${htmx-ws-src} htmx-ext-ws.min.js 22 + cp -rf ${lucide-src}/*.svg icons/ 23 + cp -f ${inter-fonts-src}/web/InterVariable*.woff2 fonts/ 24 + cp -f ${inter-fonts-src}/web/InterDisplay*.woff2 fonts/ 25 + cp -f ${ibm-plex-mono-src}/fonts/complete/woff2/IBMPlexMono-Regular.woff2 fonts/ 26 + # tailwindcss -c $src/tailwind.config.js -i $src/input.css -o tw.css won't work 27 + # for whatever reason (produces broken css), so we are doing this instead 28 + cd ${src} && ${tailwindcss}/bin/tailwindcss -i input.css -o $out/tw.css 29 + ''
+3 -14
nix/pkgs/appview.nix
··· 1 1 { 2 2 buildGoApplication, 3 3 modules, 4 - htmx-src, 5 - htmx-ws-src, 6 - lucide-src, 7 - inter-fonts-src, 8 - ibm-plex-mono-src, 9 - tailwindcss, 4 + appview-static-files, 10 5 sqlite-lib, 11 6 src, 12 7 }: ··· 17 12 18 13 postUnpack = '' 19 14 pushd source 20 - mkdir -p appview/pages/static/{fonts,icons} 21 - cp -f ${htmx-src} appview/pages/static/htmx.min.js 22 - cp -f ${htmx-ws-src} appview/pages/static/htmx-ext-ws.min.js 23 - cp -rf ${lucide-src}/*.svg appview/pages/static/icons/ 24 - cp -f ${inter-fonts-src}/web/InterVariable*.woff2 appview/pages/static/fonts/ 25 - cp -f ${inter-fonts-src}/web/InterDisplay*.woff2 appview/pages/static/fonts/ 26 - cp -f ${ibm-plex-mono-src}/fonts/complete/woff2/IBMPlexMono-Regular.woff2 appview/pages/static/fonts/ 27 - ${tailwindcss}/bin/tailwindcss -i input.css -o appview/pages/static/tw.css 15 + mkdir -p appview/pages/static 16 + cp -frv ${appview-static-files}/* appview/pages/static 28 17 popd 29 18 ''; 30 19
+8 -3
nix/pkgs/genjwks.nix
··· 1 1 { 2 - src, 3 2 buildGoApplication, 4 3 modules, 5 4 }: 6 5 buildGoApplication { 7 6 pname = "genjwks"; 8 7 version = "0.1.0"; 9 - inherit src modules; 10 - subPackages = ["cmd/genjwks"]; 8 + src = ../../cmd/genjwks; 9 + postPatch = '' 10 + ln -s ${../../go.mod} ./go.mod 11 + ''; 12 + postInstall = '' 13 + mv $out/bin/core $out/bin/genjwks 14 + ''; 15 + inherit modules; 11 16 doCheck = false; 12 17 CGO_ENABLED = 0; 13 18 }
+119 -66
nix/vm.nix
··· 1 1 { 2 2 nixpkgs, 3 + system, 4 + hostSystem, 3 5 self, 4 - }: 5 - nixpkgs.lib.nixosSystem { 6 - system = "x86_64-linux"; 7 - modules = [ 8 - self.nixosModules.knot 9 - self.nixosModules.spindle 10 - ({ 11 - config, 12 - pkgs, 13 - ... 14 - }: { 15 - virtualisation = { 16 - memorySize = 2048; 17 - diskSize = 10 * 1024; 18 - cores = 2; 19 - forwardPorts = [ 20 - # ssh 21 - { 22 - from = "host"; 23 - host.port = 2222; 24 - guest.port = 22; 25 - } 26 - # knot 27 - { 28 - from = "host"; 29 - host.port = 6000; 30 - guest.port = 6000; 31 - } 32 - # spindle 33 - { 34 - from = "host"; 35 - host.port = 6555; 36 - guest.port = 6555; 37 - } 38 - ]; 39 - }; 40 - services.getty.autologinUser = "root"; 41 - environment.systemPackages = with pkgs; [curl vim git]; 42 - systemd.tmpfiles.rules = let 43 - u = config.services.tangled-knot.gitUser; 44 - g = config.services.tangled-knot.gitUser; 45 - in [ 46 - "d /var/lib/knot 0770 ${u} ${g} - -" # Create the directory first 47 - "f+ /var/lib/knot/secret 0660 ${u} ${g} - KNOT_SERVER_SECRET=168c426fa6d9829fcbe85c96bdf144e800fb9737d6ca87f21acc543b1aa3e440" 48 - ]; 49 - services.tangled-knot = { 50 - enable = true; 51 - motd = "Welcome to the development knot!\n"; 52 - server = { 53 - secretFile = "/var/lib/knot/secret"; 54 - hostname = "localhost:6000"; 55 - listenAddr = "0.0.0.0:6000"; 6 + }: let 7 + envVar = name: let 8 + var = builtins.getEnv name; 9 + in 10 + if var == "" 11 + then throw "\$${name} must be defined, see docs/hacking.md for more details" 12 + else var; 13 + in 14 + nixpkgs.lib.nixosSystem { 15 + inherit system; 16 + modules = [ 17 + self.nixosModules.knot 18 + self.nixosModules.spindle 19 + ({ 20 + lib, 21 + config, 22 + pkgs, 23 + ... 24 + }: { 25 + virtualisation.vmVariant.virtualisation = { 26 + host.pkgs = import nixpkgs {system = hostSystem;}; 27 + 28 + graphics = false; 29 + memorySize = 2048; 30 + diskSize = 10 * 1024; 31 + cores = 2; 32 + forwardPorts = [ 33 + # ssh 34 + { 35 + from = "host"; 36 + host.port = 2222; 37 + guest.port = 22; 38 + } 39 + # knot 40 + { 41 + from = "host"; 42 + host.port = 6000; 43 + guest.port = 6000; 44 + } 45 + # spindle 46 + { 47 + from = "host"; 48 + host.port = 6555; 49 + guest.port = 6555; 50 + } 51 + ]; 52 + sharedDirectories = { 53 + # We can't use the 9p mounts directly for most of these 54 + # as SQLite is incompatible with them. So instead we 55 + # mount the shared directories to a different location 56 + # and copy the contents around on service start/stop. 57 + knotData = { 58 + source = "$TANGLED_VM_DATA_DIR/knot"; 59 + target = "/mnt/knot-data"; 60 + }; 61 + spindleData = { 62 + source = "$TANGLED_VM_DATA_DIR/spindle"; 63 + target = "/mnt/spindle-data"; 64 + }; 65 + spindleLogs = { 66 + source = "$TANGLED_VM_DATA_DIR/spindle-logs"; 67 + target = "/var/log/spindle"; 68 + }; 69 + }; 70 + }; 71 + # This is fine because any and all ports that are forwarded to host are explicitly marked above, we don't need a separate guest firewall 72 + networking.firewall.enable = false; 73 + services.getty.autologinUser = "root"; 74 + environment.systemPackages = with pkgs; [curl vim git sqlite litecli]; 75 + services.tangled-knot = { 76 + enable = true; 77 + motd = "Welcome to the development knot!\n"; 78 + server = { 79 + secretFile = builtins.toFile "knot-secret" ("KNOT_SERVER_SECRET=" + (envVar "TANGLED_VM_KNOT_SECRET")); 80 + hostname = "localhost:6000"; 81 + listenAddr = "0.0.0.0:6000"; 82 + }; 83 + }; 84 + services.tangled-spindle = { 85 + enable = true; 86 + server = { 87 + owner = envVar "TANGLED_VM_SPINDLE_OWNER"; 88 + hostname = "localhost:6555"; 89 + listenAddr = "0.0.0.0:6555"; 90 + dev = true; 91 + secrets = { 92 + provider = "sqlite"; 93 + }; 94 + }; 95 + }; 96 + users = { 97 + # So we don't have to deal with permission clashing between 98 + # blank disk VMs and existing state 99 + users.${config.services.tangled-knot.gitUser}.uid = 666; 100 + groups.${config.services.tangled-knot.gitUser}.gid = 666; 101 + 102 + # TODO: separate spindle user 56 103 }; 57 - }; 58 - services.tangled-spindle = { 59 - enable = true; 60 - server = { 61 - owner = "did:plc:qfpnj4og54vl56wngdriaxug"; 62 - hostname = "localhost:6555"; 63 - listenAddr = "0.0.0.0:6555"; 64 - dev = true; 65 - secrets = { 66 - provider = "sqlite"; 104 + systemd.services = let 105 + mkDataSyncScripts = source: target: { 106 + enableStrictShellChecks = true; 107 + 108 + preStart = lib.mkBefore '' 109 + mkdir -p ${target} 110 + ${lib.getExe pkgs.rsync} -a ${source}/ ${target} 111 + ''; 112 + 113 + postStop = lib.mkAfter '' 114 + ${lib.getExe pkgs.rsync} -a ${target}/ ${source} 115 + ''; 116 + 117 + serviceConfig.PermissionsStartOnly = true; 67 118 }; 119 + in { 120 + knot = mkDataSyncScripts "/mnt/knot-data" config.services.tangled-knot.stateDir; 121 + spindle = mkDataSyncScripts "/mnt/spindle-data" (builtins.dirOf config.services.tangled-spindle.server.dbPath); 68 122 }; 69 - }; 70 - }) 71 - ]; 72 - } 123 + }) 124 + ]; 125 + }
+4
spindle/server.go
··· 242 242 return fmt.Errorf("no repo data found") 243 243 } 244 244 245 + if src.Key() != tpl.TriggerMetadata.Repo.Knot { 246 + return fmt.Errorf("repo knot does not match event source: %s != %s", src.Key(), tpl.TriggerMetadata.Repo.Knot) 247 + } 248 + 245 249 // filter by repos 246 250 _, err = s.db.GetRepo( 247 251 tpl.TriggerMetadata.Repo.Knot,
+6
tailwind.config.js
··· 40 40 color: colors.black, 41 41 "@apply font-normal text-black bg-gray-100 dark:bg-gray-900 dark:text-gray-300 dark:border-gray-700 dark:border": {}, 42 42 }, 43 + li: { 44 + "@apply inline-block w-full my-0 py-0": {}, 45 + }, 46 + "ul, ol": { 47 + "@apply my-1 py-0": {}, 48 + }, 43 49 code: { 44 50 "@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": {}, 45 51 },