A container registry that uses the AT Protocol for manifest storage and S3 for blob storage. atcr.io
docker container atproto go
81
fork

Configure Feed

Select the types of activity you want to include in your feed.

add repo page editor. fix deleting all untagged actually deleting all untagged

+676 -75
+4
lexicons/io/atcr/repo/page.json
··· 26 26 "accept": ["image/png", "image/jpeg", "image/webp"], 27 27 "maxSize": 3000000 28 28 }, 29 + "userEdited": { 30 + "type": "boolean", 31 + "description": "Whether the description was manually edited by the user. When true, auto-population from manifest annotations is skipped on push." 32 + }, 29 33 "createdAt": { 30 34 "type": "string", 31 35 "format": "datetime",
+3
pkg/appview/db/migrations/0017_add_repo_page_user_edited.yaml
··· 1 + description: Add user_edited flag to repo_pages to prevent auto-overwrite of manually edited descriptions 2 + query: | 3 + ALTER TABLE repo_pages ADD COLUMN user_edited BOOLEAN NOT NULL DEFAULT 0;
+50 -22
pkg/appview/db/queries.go
··· 1356 1356 return tags, nil 1357 1357 } 1358 1358 1359 - // GetUntaggedTopLevelManifestDigests returns digests of top-level manifests that have no tags. 1359 + // GetAllUntaggedManifestDigests returns digests of all untagged manifests eligible for deletion. 1360 + // Returns children of untagged manifest lists first (bottom-up) so the handler can delete 1361 + // children before parents, avoiding orphaned manifests from cascade-deleted references. 1360 1362 // Uses the same filtering logic as GetTopLevelManifests (manifest lists + orphaned single-arch). 1361 - func GetUntaggedTopLevelManifestDigests(db DBTX, did, repository string) ([]string, error) { 1363 + func GetAllUntaggedManifestDigests(db DBTX, did, repository string) ([]string, error) { 1362 1364 rows, err := db.Query(` 1363 1365 WITH manifest_list_children AS ( 1364 1366 SELECT DISTINCT mr.digest 1365 1367 FROM manifest_references mr 1366 1368 JOIN manifests m ON mr.manifest_id = m.id 1367 1369 WHERE m.did = ? AND m.repository = ? 1370 + ), 1371 + untagged_top_level AS ( 1372 + SELECT m.id, m.digest, 1373 + CASE WHEN m.media_type LIKE '%index%' OR m.media_type LIKE '%manifest.list%' 1374 + THEN 1 ELSE 0 END as is_list 1375 + FROM manifests m 1376 + LEFT JOIN tags t ON m.digest = t.digest AND m.did = t.did AND m.repository = t.repository 1377 + WHERE m.did = ? AND m.repository = ? 1378 + AND ( 1379 + m.media_type LIKE '%index%' OR m.media_type LIKE '%manifest.list%' 1380 + OR 1381 + m.digest NOT IN (SELECT digest FROM manifest_list_children WHERE digest IS NOT NULL) 1382 + ) 1383 + GROUP BY m.id 1384 + HAVING COUNT(t.tag) = 0 1385 + ), 1386 + untagged_children AS ( 1387 + SELECT DISTINCT mr.digest 1388 + FROM untagged_top_level ul 1389 + JOIN manifest_references mr ON ul.id = mr.manifest_id 1390 + JOIN manifests child_m ON mr.digest = child_m.digest 1391 + AND child_m.did = ? AND child_m.repository = ? 1392 + LEFT JOIN tags ct ON child_m.digest = ct.digest 1393 + AND child_m.did = ct.did AND child_m.repository = ct.repository 1394 + WHERE ul.is_list = 1 AND ct.tag IS NULL 1395 + AND mr.digest NOT IN ( 1396 + SELECT mr2.digest FROM manifest_references mr2 1397 + JOIN manifests m2 ON mr2.manifest_id = m2.id 1398 + JOIN tags t2 ON m2.digest = t2.digest AND m2.did = t2.did AND m2.repository = t2.repository 1399 + WHERE m2.did = ? AND m2.repository = ? 1400 + ) 1368 1401 ) 1369 - SELECT m.digest 1370 - FROM manifests m 1371 - LEFT JOIN tags t ON m.digest = t.digest AND m.did = t.did AND m.repository = t.repository 1372 - WHERE m.did = ? AND m.repository = ? 1373 - AND ( 1374 - m.media_type LIKE '%index%' OR m.media_type LIKE '%manifest.list%' 1375 - OR 1376 - m.digest NOT IN (SELECT digest FROM manifest_list_children WHERE digest IS NOT NULL) 1377 - ) 1378 - GROUP BY m.id 1379 - HAVING COUNT(t.tag) = 0 1380 - `, did, repository, did, repository) 1402 + SELECT digest FROM untagged_children 1403 + UNION ALL 1404 + SELECT digest FROM untagged_top_level 1405 + `, did, repository, did, repository, did, repository, did, repository) 1381 1406 if err != nil { 1382 1407 return nil, err 1383 1408 } ··· 2074 2099 Repository string 2075 2100 Description string 2076 2101 AvatarCID string 2102 + UserEdited bool 2077 2103 CreatedAt time.Time 2078 2104 UpdatedAt time.Time 2079 2105 } 2080 2106 2081 2107 // UpsertRepoPage inserts or updates a repo page record 2082 - func UpsertRepoPage(db DBTX, did, repository, description, avatarCID string, createdAt, updatedAt time.Time) error { 2108 + func UpsertRepoPage(db DBTX, did, repository, description, avatarCID string, userEdited bool, createdAt, updatedAt time.Time) error { 2083 2109 _, err := db.Exec(` 2084 - INSERT INTO repo_pages (did, repository, description, avatar_cid, created_at, updated_at) 2085 - VALUES (?, ?, ?, ?, ?, ?) 2110 + INSERT INTO repo_pages (did, repository, description, avatar_cid, user_edited, created_at, updated_at) 2111 + VALUES (?, ?, ?, ?, ?, ?, ?) 2086 2112 ON CONFLICT(did, repository) DO UPDATE SET 2087 2113 description = excluded.description, 2088 2114 avatar_cid = excluded.avatar_cid, 2115 + user_edited = excluded.user_edited, 2089 2116 updated_at = excluded.updated_at 2090 2117 WHERE excluded.description IS NOT repo_pages.description 2091 2118 OR excluded.avatar_cid IS NOT repo_pages.avatar_cid 2092 - `, did, repository, description, avatarCID, createdAt, updatedAt) 2119 + OR excluded.user_edited IS NOT repo_pages.user_edited 2120 + `, did, repository, description, avatarCID, userEdited, createdAt, updatedAt) 2093 2121 return err 2094 2122 } 2095 2123 ··· 2097 2125 func GetRepoPage(db DBTX, did, repository string) (*RepoPage, error) { 2098 2126 var rp RepoPage 2099 2127 err := db.QueryRow(` 2100 - SELECT did, repository, description, avatar_cid, created_at, updated_at 2128 + SELECT did, repository, description, avatar_cid, user_edited, created_at, updated_at 2101 2129 FROM repo_pages 2102 2130 WHERE did = ? AND repository = ? 2103 - `, did, repository).Scan(&rp.DID, &rp.Repository, &rp.Description, &rp.AvatarCID, &rp.CreatedAt, &rp.UpdatedAt) 2131 + `, did, repository).Scan(&rp.DID, &rp.Repository, &rp.Description, &rp.AvatarCID, &rp.UserEdited, &rp.CreatedAt, &rp.UpdatedAt) 2104 2132 if err != nil { 2105 2133 return nil, err 2106 2134 } ··· 2118 2146 // GetRepoPagesByDID returns all repo pages for a DID 2119 2147 func GetRepoPagesByDID(db DBTX, did string) ([]RepoPage, error) { 2120 2148 rows, err := db.Query(` 2121 - SELECT did, repository, description, avatar_cid, created_at, updated_at 2149 + SELECT did, repository, description, avatar_cid, user_edited, created_at, updated_at 2122 2150 FROM repo_pages 2123 2151 WHERE did = ? 2124 2152 `, did) ··· 2130 2158 var pages []RepoPage 2131 2159 for rows.Next() { 2132 2160 var rp RepoPage 2133 - if err := rows.Scan(&rp.DID, &rp.Repository, &rp.Description, &rp.AvatarCID, &rp.CreatedAt, &rp.UpdatedAt); err != nil { 2161 + if err := rows.Scan(&rp.DID, &rp.Repository, &rp.Description, &rp.AvatarCID, &rp.UserEdited, &rp.CreatedAt, &rp.UpdatedAt); err != nil { 2134 2162 return nil, err 2135 2163 } 2136 2164 pages = append(pages, rp)
+155
pkg/appview/db/queries_test.go
··· 1376 1376 t.Error("Expected sha256:childdef to NOT be referenced for different user") 1377 1377 } 1378 1378 } 1379 + 1380 + func TestGetAllUntaggedManifestDigests(t *testing.T) { 1381 + db, err := InitDB(":memory:", LibsqlConfig{}) 1382 + if err != nil { 1383 + t.Fatalf("Failed to init database: %v", err) 1384 + } 1385 + defer db.Close() 1386 + 1387 + did := "did:plc:test123" 1388 + repo := "myapp" 1389 + now := time.Now() 1390 + 1391 + if err := UpsertUser(db, &User{ 1392 + DID: did, 1393 + Handle: "test.bsky.social", 1394 + PDSEndpoint: "https://test.pds.example.com", 1395 + LastSeen: now, 1396 + }); err != nil { 1397 + t.Fatalf("Failed to insert user: %v", err) 1398 + } 1399 + 1400 + indexType := "application/vnd.oci.image.index.v1+json" 1401 + manifestType := "application/vnd.oci.image.manifest.v1+json" 1402 + hold := "did:web:hold.example.com" 1403 + 1404 + insertManifest := func(t *testing.T, digest, mediaType string) int64 { 1405 + t.Helper() 1406 + id, err := InsertManifest(db, &Manifest{ 1407 + DID: did, Repository: repo, Digest: digest, 1408 + HoldEndpoint: hold, SchemaVersion: 2, MediaType: mediaType, 1409 + CreatedAt: now, 1410 + }) 1411 + if err != nil { 1412 + t.Fatalf("Failed to insert manifest %s: %v", digest, err) 1413 + } 1414 + return id 1415 + } 1416 + 1417 + insertRef := func(t *testing.T, parentID int64, childDigest string, idx int) { 1418 + t.Helper() 1419 + err := InsertManifestReference(db, &ManifestReference{ 1420 + ManifestID: parentID, 1421 + Digest: childDigest, 1422 + Size: 1000, 1423 + MediaType: manifestType, 1424 + PlatformArchitecture: "amd64", 1425 + PlatformOS: "linux", 1426 + ReferenceIndex: idx, 1427 + }) 1428 + if err != nil { 1429 + t.Fatalf("Failed to insert reference: %v", err) 1430 + } 1431 + } 1432 + 1433 + insertTag := func(t *testing.T, digest, tag string) { 1434 + t.Helper() 1435 + if err := UpsertTag(db, &Tag{ 1436 + DID: did, Repository: repo, Tag: tag, 1437 + Digest: digest, CreatedAt: now, 1438 + }); err != nil { 1439 + t.Fatalf("Failed to insert tag: %v", err) 1440 + } 1441 + } 1442 + 1443 + // Setup scenario: 1444 + // 1445 + // TAGGED index "sha256:tagged-index" -> tag "v1" 1446 + // children: sha256:tagged-child-amd64, sha256:shared-child-arm64 1447 + // 1448 + // UNTAGGED index "sha256:untagged-index" (no tag) 1449 + // children: sha256:untagged-child-amd64, sha256:shared-child-arm64 1450 + // 1451 + // UNTAGGED orphan single-arch "sha256:orphan-single" (no tag, no parent) 1452 + // 1453 + // TAGGED single-arch "sha256:tagged-single" -> tag "latest" 1454 + 1455 + // Tagged index + its children 1456 + taggedIndexID := insertManifest(t, "sha256:tagged-index", indexType) 1457 + insertManifest(t, "sha256:tagged-child-amd64", manifestType) 1458 + insertManifest(t, "sha256:shared-child-arm64", manifestType) 1459 + insertRef(t, taggedIndexID, "sha256:tagged-child-amd64", 0) 1460 + insertRef(t, taggedIndexID, "sha256:shared-child-arm64", 1) 1461 + insertTag(t, "sha256:tagged-index", "v1") 1462 + 1463 + // Untagged index + its children 1464 + untaggedIndexID := insertManifest(t, "sha256:untagged-index", indexType) 1465 + insertManifest(t, "sha256:untagged-child-amd64", manifestType) 1466 + // sha256:shared-child-arm64 already inserted, just add the reference 1467 + insertRef(t, untaggedIndexID, "sha256:untagged-child-amd64", 0) 1468 + insertRef(t, untaggedIndexID, "sha256:shared-child-arm64", 1) 1469 + 1470 + // Orphan single-arch (no parent, no tag) 1471 + insertManifest(t, "sha256:orphan-single", manifestType) 1472 + 1473 + // Tagged single-arch 1474 + insertManifest(t, "sha256:tagged-single", manifestType) 1475 + insertTag(t, "sha256:tagged-single", "latest") 1476 + 1477 + // Run the query 1478 + digests, err := GetAllUntaggedManifestDigests(db, did, repo) 1479 + if err != nil { 1480 + t.Fatalf("GetAllUntaggedManifestDigests error: %v", err) 1481 + } 1482 + 1483 + // Build sets for easy checking 1484 + digestSet := map[string]bool{} 1485 + for _, d := range digests { 1486 + digestSet[d] = true 1487 + } 1488 + 1489 + // Should include: untagged index, its exclusive child, and the orphan single 1490 + if !digestSet["sha256:untagged-index"] { 1491 + t.Error("Expected untagged-index to be included") 1492 + } 1493 + if !digestSet["sha256:untagged-child-amd64"] { 1494 + t.Error("Expected untagged-child-amd64 to be included") 1495 + } 1496 + if !digestSet["sha256:orphan-single"] { 1497 + t.Error("Expected orphan-single to be included") 1498 + } 1499 + 1500 + // Should NOT include: tagged index, tagged children, shared child (still referenced by tagged index), tagged single 1501 + if digestSet["sha256:tagged-index"] { 1502 + t.Error("Expected tagged-index to NOT be included") 1503 + } 1504 + if digestSet["sha256:tagged-child-amd64"] { 1505 + t.Error("Expected tagged-child-amd64 to NOT be included") 1506 + } 1507 + if digestSet["sha256:shared-child-arm64"] { 1508 + t.Error("Expected shared-child-arm64 to NOT be included (still referenced by tagged index)") 1509 + } 1510 + if digestSet["sha256:tagged-single"] { 1511 + t.Error("Expected tagged-single to NOT be included") 1512 + } 1513 + 1514 + // Verify ordering: children should come before their parent index 1515 + childIdx := -1 1516 + parentIdx := -1 1517 + for i, d := range digests { 1518 + if d == "sha256:untagged-child-amd64" { 1519 + childIdx = i 1520 + } 1521 + if d == "sha256:untagged-index" { 1522 + parentIdx = i 1523 + } 1524 + } 1525 + if childIdx >= 0 && parentIdx >= 0 && childIdx > parentIdx { 1526 + t.Errorf("Expected children before parents: child at index %d, parent at index %d", childIdx, parentIdx) 1527 + } 1528 + 1529 + // Verify total count: untagged-child-amd64, orphan-single, untagged-index = 3 1530 + if len(digests) != 3 { 1531 + t.Errorf("Expected 3 digests, got %d: %v", len(digests), digests) 1532 + } 1533 + }
+1
pkg/appview/db/schema.sql
··· 232 232 repository TEXT NOT NULL, 233 233 description TEXT, 234 234 avatar_cid TEXT, 235 + user_edited BOOLEAN NOT NULL DEFAULT 0, 235 236 created_at TIMESTAMP NOT NULL, 236 237 updated_at TIMESTAMP NOT NULL, 237 238 PRIMARY KEY(did, repository),
+1 -1
pkg/appview/handlers/images.go
··· 202 202 return 203 203 } 204 204 205 - digests, err := db.GetUntaggedTopLevelManifestDigests(h.DB, user.DID, req.Repo) 205 + digests, err := db.GetAllUntaggedManifestDigests(h.DB, user.DID, req.Repo) 206 206 if err != nil { 207 207 http.Error(w, fmt.Sprintf("Failed to query untagged manifests: %v", err), http.StatusInternalServerError) 208 208 return
+141
pkg/appview/handlers/repo_editor.go
··· 1 + package handlers 2 + 3 + import ( 4 + "encoding/json" 5 + "errors" 6 + "log/slog" 7 + "net/http" 8 + "time" 9 + 10 + "atcr.io/pkg/appview/db" 11 + "atcr.io/pkg/appview/middleware" 12 + "atcr.io/pkg/atproto" 13 + ) 14 + 15 + // SaveRepoPageHandler saves user-edited description to the PDS repo page record 16 + type SaveRepoPageHandler struct { 17 + BaseUIHandler 18 + } 19 + 20 + func (h *SaveRepoPageHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 21 + user := middleware.GetUser(r) 22 + if user == nil { 23 + http.Error(w, "Unauthorized", http.StatusUnauthorized) 24 + return 25 + } 26 + 27 + did := r.FormValue("did") 28 + repository := r.FormValue("repository") 29 + description := r.FormValue("description") 30 + 31 + if did == "" || repository == "" { 32 + http.Error(w, "Missing required fields", http.StatusBadRequest) 33 + return 34 + } 35 + 36 + // Verify ownership 37 + if user.DID != did { 38 + http.Error(w, "Forbidden", http.StatusForbidden) 39 + return 40 + } 41 + 42 + // Size limit 43 + if len(description) > 100*1024 { 44 + http.Error(w, "Description too large (max 100KB)", http.StatusBadRequest) 45 + return 46 + } 47 + 48 + pdsClient := atproto.NewClientWithSessionProvider(user.PDSEndpoint, user.DID, h.Refresher) 49 + 50 + // Fetch existing record to preserve avatar and createdAt 51 + var existingAvatar *atproto.ATProtoBlobRef 52 + var existingCreatedAt time.Time 53 + record, err := pdsClient.GetRecord(r.Context(), atproto.RepoPageCollection, repository) 54 + if err == nil { 55 + var existingRecord atproto.RepoPageRecord 56 + if jsonErr := json.Unmarshal(record.Value, &existingRecord); jsonErr == nil { 57 + existingAvatar = existingRecord.Avatar 58 + existingCreatedAt = existingRecord.CreatedAt 59 + } 60 + } else if !errors.Is(err, atproto.ErrRecordNotFound) { 61 + if handleOAuthError(r.Context(), h.Refresher, user.DID, err) { 62 + http.Error(w, "Authentication failed, please log in again", http.StatusUnauthorized) 63 + return 64 + } 65 + } 66 + 67 + // Create updated record 68 + repoPage := atproto.NewRepoPageRecord(repository, description, existingAvatar) 69 + if !existingCreatedAt.IsZero() { 70 + repoPage.CreatedAt = existingCreatedAt 71 + } 72 + 73 + // If description is empty, clear userEdited so auto-populate resumes 74 + repoPage.UserEdited = description != "" 75 + 76 + // Save to PDS 77 + _, err = pdsClient.PutRecord(r.Context(), atproto.RepoPageCollection, repository, repoPage) 78 + if err != nil { 79 + if handleOAuthError(r.Context(), h.Refresher, user.DID, err) { 80 + http.Error(w, "Authentication failed, please log in again", http.StatusUnauthorized) 81 + return 82 + } 83 + http.Error(w, "Failed to save description", http.StatusInternalServerError) 84 + return 85 + } 86 + 87 + // Update DB cache 88 + avatarCID := "" 89 + if existingAvatar != nil && existingAvatar.Ref.Link != "" { 90 + avatarCID = existingAvatar.Ref.Link 91 + } 92 + if err := db.UpsertRepoPage(h.DB, user.DID, repository, description, avatarCID, repoPage.UserEdited, repoPage.CreatedAt, repoPage.UpdatedAt); err != nil { 93 + slog.Warn("Failed to update repo page cache", "error", err) 94 + } 95 + 96 + // Return rendered HTML for HTMX swap 97 + if r.Header.Get("HX-Request") == "true" { 98 + w.Header().Set("Content-Type", "text/html; charset=utf-8") 99 + if description == "" { 100 + _, _ = w.Write([]byte(`<p class="text-base-content/60">No description available</p>`)) 101 + return 102 + } 103 + html, err := h.ReadmeFetcher.RenderMarkdown([]byte(description)) 104 + if err != nil { 105 + http.Error(w, "Failed to render markdown", http.StatusInternalServerError) 106 + return 107 + } 108 + _, _ = w.Write([]byte(html)) 109 + return 110 + } 111 + 112 + w.WriteHeader(http.StatusOK) 113 + } 114 + 115 + // PreviewMarkdownHandler renders markdown to HTML for the editor preview tab 116 + type PreviewMarkdownHandler struct { 117 + BaseUIHandler 118 + } 119 + 120 + func (h *PreviewMarkdownHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 121 + markdown := r.FormValue("markdown") 122 + if markdown == "" { 123 + w.Header().Set("Content-Type", "text/html; charset=utf-8") 124 + _, _ = w.Write([]byte(`<p class="text-base-content/60">Nothing to preview</p>`)) 125 + return 126 + } 127 + 128 + if len(markdown) > 100*1024 { 129 + http.Error(w, "Content too large", http.StatusBadRequest) 130 + return 131 + } 132 + 133 + html, err := h.ReadmeFetcher.RenderMarkdown([]byte(markdown)) 134 + if err != nil { 135 + http.Error(w, "Failed to render markdown", http.StatusInternalServerError) 136 + return 137 + } 138 + 139 + w.Header().Set("Content-Type", "text/html; charset=utf-8") 140 + _, _ = w.Write([]byte(html)) 141 + }
+36 -25
pkg/appview/handlers/repository.go
··· 122 122 123 123 // Fetch README content from repo page record or annotations 124 124 var readmeHTML template.HTML 125 + var rawDescription string 125 126 126 127 repoPage, err := db.GetRepoPage(h.ReadOnlyDB, owner.DID, repository) 127 128 if err == nil && repoPage != nil { ··· 129 130 repo.IconURL = atproto.BlobCDNURL(owner.DID, repoPage.AvatarCID) 130 131 } 131 132 if repoPage.Description != "" && h.ReadmeFetcher != nil { 133 + rawDescription = repoPage.Description 132 134 html, err := h.ReadmeFetcher.RenderMarkdown([]byte(repoPage.Description)) 133 135 if err != nil { 134 136 slog.Warn("Failed to render repo page description", "error", err) ··· 146 148 } 147 149 } 148 150 if readmeURL != "" { 149 - html, err := h.ReadmeFetcher.FetchAndRender(r.Context(), readmeURL) 150 - if err != nil { 151 - slog.Debug("Failed to fetch README from URL", "url", readmeURL, "error", err) 151 + // Fetch raw markdown for editor pre-fill, then render 152 + rawBytes, fetchErr := h.ReadmeFetcher.FetchRaw(r.Context(), readmeURL) 153 + if fetchErr != nil { 154 + slog.Debug("Failed to fetch README from URL", "url", readmeURL, "error", fetchErr) 152 155 } else { 153 - readmeHTML = template.HTML(html) 156 + rawDescription = string(rawBytes) 157 + html, renderErr := h.ReadmeFetcher.RenderMarkdown(rawBytes) 158 + if renderErr != nil { 159 + slog.Debug("Failed to render fetched README", "url", readmeURL, "error", renderErr) 160 + } else { 161 + readmeHTML = template.HTML(html) 162 + } 154 163 } 155 164 } 156 165 } ··· 181 190 182 191 data := struct { 183 192 PageData 184 - Meta *PageMeta 185 - Owner *db.User 186 - Repository *db.Repository 187 - LatestTag string 188 - StarCount int 189 - PullCount int 190 - IsStarred bool 191 - IsOwner bool 192 - ReadmeHTML template.HTML 193 - ArtifactType string 193 + Meta *PageMeta 194 + Owner *db.User 195 + Repository *db.Repository 196 + LatestTag string 197 + StarCount int 198 + PullCount int 199 + IsStarred bool 200 + IsOwner bool 201 + ReadmeHTML template.HTML 202 + RawDescription string 203 + ArtifactType string 194 204 }{ 195 - PageData: NewPageData(r, &h.BaseUIHandler), 196 - Meta: meta, 197 - Owner: owner, 198 - Repository: repo, 199 - LatestTag: latestTagName, 200 - StarCount: stats.StarCount, 201 - PullCount: stats.PullCount, 202 - IsStarred: isStarred, 203 - IsOwner: isOwner, 204 - ReadmeHTML: readmeHTML, 205 - ArtifactType: artifactType, 205 + PageData: NewPageData(r, &h.BaseUIHandler), 206 + Meta: meta, 207 + Owner: owner, 208 + Repository: repo, 209 + LatestTag: latestTagName, 210 + StarCount: stats.StarCount, 211 + PullCount: stats.PullCount, 212 + IsStarred: isStarred, 213 + IsOwner: isOwner, 214 + ReadmeHTML: readmeHTML, 215 + RawDescription: rawDescription, 216 + ArtifactType: artifactType, 206 217 } 207 218 208 219 if err := h.Templates.ExecuteTemplate(w, "repository", data); err != nil {
+3 -3
pkg/appview/jetstream/backfill.go
··· 635 635 } 636 636 637 637 for _, page := range repoPages { 638 - // Skip pages that already have a description 639 - if page.Description != "" { 638 + // Skip pages that were manually edited by the user or already have a description 639 + if page.UserEdited || page.Description != "" { 640 640 continue 641 641 } 642 642 ··· 668 668 } 669 669 670 670 // Always update database with the fetched content 671 - if err := db.UpsertRepoPage(b.db, did, page.Repository, description, page.AvatarCID, page.CreatedAt, time.Now()); err != nil { 671 + if err := db.UpsertRepoPage(b.db, did, page.Repository, description, page.AvatarCID, false, page.CreatedAt, time.Now()); err != nil { 672 672 slog.Warn("Failed to update repo page in database", "did", did, "repository", page.Repository, "error", err) 673 673 } else if !pdsUpdated { 674 674 slog.Info("Updated repo page in database (PDS not updated)", "did", did, "repository", page.Repository)
+1 -1
pkg/appview/jetstream/processor.go
··· 560 560 } 561 561 562 562 // Upsert to database 563 - return db.UpsertRepoPage(p.db, did, pageRecord.Repository, pageRecord.Description, avatarCID, pageRecord.CreatedAt, pageRecord.UpdatedAt) 563 + return db.UpsertRepoPage(p.db, did, pageRecord.Repository, pageRecord.Description, avatarCID, pageRecord.UserEdited, pageRecord.CreatedAt, pageRecord.UpdatedAt) 564 564 } 565 565 566 566 // ProcessIdentity handles identity change events (handle updates)
+8
pkg/appview/public/icons.svg
··· 6 6 <symbol id="arrow-down-to-line" viewBox="0 0 24 24"><path d="M12 17V3"/><path d="m6 11 6 6 6-6"/><path d="M19 21H5"/></symbol> 7 7 <symbol id="arrow-left" viewBox="0 0 24 24"><path d="m12 19-7-7 7-7"/><path d="M19 12H5"/></symbol> 8 8 <symbol id="arrow-right" viewBox="0 0 24 24"><path d="M5 12h14"/><path d="m12 5 7 7-7 7"/></symbol> 9 + <symbol id="bold" viewBox="0 0 24 24"><path d="M6 12h9a4 4 0 0 1 0 8H7a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1h7a4 4 0 0 1 0 8"/></symbol> 9 10 <symbol id="check" viewBox="0 0 24 24"><path d="M20 6 9 17l-5-5"/></symbol> 10 11 <symbol id="check-circle" viewBox="0 0 24 24"><path d="M21.801 10A10 10 0 1 1 17 3.335"/><path d="m9 11 3 3L22 4"/></symbol> 11 12 <symbol id="chevron-down" viewBox="0 0 24 24"><path d="m6 9 6 6 6-6"/></symbol> 12 13 <symbol id="chevron-left" viewBox="0 0 24 24"><path d="m15 18-6-6 6-6"/></symbol> 13 14 <symbol id="chevron-right" viewBox="0 0 24 24"><path d="m9 18 6-6-6-6"/></symbol> 14 15 <symbol id="circle-x" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="m15 9-6 6"/><path d="m9 9 6 6"/></symbol> 16 + <symbol id="code" viewBox="0 0 24 24"><path d="m16 18 6-6-6-6"/><path d="m8 6-6 6 6 6"/></symbol> 15 17 <symbol id="compass" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="m16.24 7.76-1.804 5.411a2 2 0 0 1-1.265 1.265L7.76 16.24l1.804-5.411a2 2 0 0 1 1.265-1.265z"/></symbol> 16 18 <symbol id="container" viewBox="0 0 24 24"><path d="M22 7.7c0-.6-.4-1.2-.8-1.5l-6.3-3.9a1.72 1.72 0 0 0-1.7 0l-10.3 6c-.5.2-.9.8-.9 1.4v6.6c0 .5.4 1.2.8 1.5l6.3 3.9a1.72 1.72 0 0 0 1.7 0l10.3-6c.5-.3.9-1 .9-1.5Z"/><path d="M10 21.9V14L2.1 9.1"/><path d="m10 14 11.9-6.9"/><path d="M14 19.8v-8.1"/><path d="M18 17.5V9.4"/></symbol> 17 19 <symbol id="copy" viewBox="0 0 24 24"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></symbol> ··· 25 27 <symbol id="git-merge" viewBox="0 0 24 24"><circle cx="18" cy="18" r="3"/><circle cx="6" cy="6" r="3"/><path d="M6 21V9a9 9 0 0 0 9 9"/></symbol> 26 28 <symbol id="github" viewBox="0 0 24 24"><path d="M15 22v-4a4.8 4.8 0 0 0-1-3.5c3 0 6-2 6-5.5.08-1.25-.27-2.48-1-3.5.28-1.15.28-2.35 0-3.5 0 0-1 0-3 1.5-2.64-.5-5.36-.5-8 0C6 2 5 2 5 2c-.3 1.15-.3 2.35 0 3.5A5.403 5.403 0 0 0 4 9c0 3.5 3 5.5 6 5.5-.39.49-.68 1.05-.85 1.65-.17.6-.22 1.23-.15 1.85v4"/><path d="M9 18c-4.51 2-5-2-7-2"/></symbol> 27 29 <symbol id="hard-drive" viewBox="0 0 24 24"><path d="M10 16h.01"/><path d="M2.212 11.577a2 2 0 0 0-.212.896V18a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-5.527a2 2 0 0 0-.212-.896L18.55 5.11A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/><path d="M21.946 12.013H2.054"/><path d="M6 16h.01"/></symbol> 30 + <symbol id="heading" viewBox="0 0 24 24"><path d="M6 12h12"/><path d="M6 20V4"/><path d="M18 20V4"/></symbol> 28 31 <symbol id="history" viewBox="0 0 24 24"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/><path d="M12 7v5l4 2"/></symbol> 32 + <symbol id="image" viewBox="0 0 24 24"><rect width="18" height="18" x="3" y="3" rx="2" ry="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"/></symbol> 29 33 <symbol id="info" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></symbol> 34 + <symbol id="italic" viewBox="0 0 24 24"><line x1="19" x2="10" y1="4" y2="4"/><line x1="14" x2="5" y1="20" y2="20"/><line x1="15" x2="9" y1="4" y2="20"/></symbol> 35 + <symbol id="link" viewBox="0 0 24 24"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></symbol> 36 + <symbol id="list" viewBox="0 0 24 24"><path d="M3 5h.01"/><path d="M3 12h.01"/><path d="M3 19h.01"/><path d="M8 5h13"/><path d="M8 12h13"/><path d="M8 19h13"/></symbol> 37 + <symbol id="list-ordered" viewBox="0 0 24 24"><path d="M11 5h10"/><path d="M11 12h10"/><path d="M11 19h10"/><path d="M4 4h1v5"/><path d="M4 9h2"/><path d="M6.5 20H3.4c0-1 2.6-1.925 2.6-3.5a1.5 1.5 0 0 0-2.6-1.02"/></symbol> 30 38 <symbol id="loader-2" viewBox="0 0 24 24"><path d="M21 12a9 9 0 1 1-6.219-8.56"/></symbol> 31 39 <symbol id="moon" viewBox="0 0 24 24"><path d="M20.985 12.486a9 9 0 1 1-9.473-9.472c.405-.022.617.46.402.803a6 6 0 0 0 8.268 8.268c.344-.215.825-.004.803.401"/></symbol> 32 40 <symbol id="pencil" viewBox="0 0 24 24"><path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z"/><path d="m15 5 4 4"/></symbol>
+4
pkg/appview/routes/routes.go
··· 177 177 r.Delete("/api/manifests/untagged", (&uihandlers.DeleteUntaggedManifestsHandler{BaseUIHandler: base}).ServeHTTP) 178 178 r.Post("/api/avatar", (&uihandlers.UploadAvatarHandler{BaseUIHandler: base}).ServeHTTP) 179 179 180 + // Repository page editing 181 + r.Post("/api/repo-page", (&uihandlers.SaveRepoPageHandler{BaseUIHandler: base}).ServeHTTP) 182 + r.Post("/api/repo-page/preview", (&uihandlers.PreviewMarkdownHandler{BaseUIHandler: base}).ServeHTTP) 183 + 180 184 // Webhook management 181 185 r.Get("/api/webhooks", (&uihandlers.WebhooksHandler{BaseUIHandler: base}).ServeHTTP) 182 186 r.Post("/api/webhooks", (&uihandlers.AddWebhookHandler{BaseUIHandler: base}).ServeHTTP)
+25 -16
pkg/appview/storage/manifest_store.go
··· 501 501 502 502 // ensureRepoPage creates or updates a repo page record in the user's PDS 503 503 // This syncs repository metadata from manifest annotations to the io.atcr.repo.page collection 504 - // Always updates the description on push (since users can't edit it via appview yet) 505 - // Preserves user's avatar if they've set one via the appview 504 + // Preserves user's avatar and skips description overwrite if user has manually edited it 506 505 func (s *ManifestStore) ensureRepoPage(ctx context.Context, manifestRecord *atproto.ManifestRecord) { 507 506 rkey := s.ctx.Repository 508 507 ··· 525 524 slog.Warn("Failed to check for existing repo page", "did", s.ctx.DID, "repository", s.ctx.Repository, "error", err) 526 525 } 527 526 528 - // Get annotations (may be nil if image has no OCI labels) 529 - annotations := manifestRecord.Annotations 530 - if annotations == nil { 531 - annotations = make(map[string]string) 532 - } 527 + // If user has manually edited the description, preserve it 528 + userEdited := existingRecord != nil && existingRecord.UserEdited 529 + var description string 530 + if userEdited { 531 + description = existingRecord.Description 532 + } else { 533 + // Get annotations (may be nil if image has no OCI labels) 534 + annotations := manifestRecord.Annotations 535 + if annotations == nil { 536 + annotations = make(map[string]string) 537 + } 533 538 534 - // Try to fetch README content from external sources 535 - // Priority: io.atcr.readme annotation > derived from org.opencontainers.image.source > org.opencontainers.image.description 536 - description := s.fetchReadmeContent(ctx, annotations) 539 + // Try to fetch README content from external sources 540 + // Priority: io.atcr.readme annotation > derived from org.opencontainers.image.source > org.opencontainers.image.description 541 + description = s.fetchReadmeContent(ctx, annotations) 537 542 538 - // If no README content could be fetched, fall back to description annotation 539 - if description == "" { 540 - description = annotations["org.opencontainers.image.description"] 543 + // If no README content could be fetched, fall back to description annotation 544 + if description == "" { 545 + description = annotations["org.opencontainers.image.description"] 546 + } 541 547 } 542 548 543 549 // Determine avatar: prefer new icon from annotations, otherwise keep existing 544 550 avatarRef := existingAvatarRef 545 - if iconURL := annotations["io.atcr.icon"]; iconURL != "" { 546 - if newAvatar := s.fetchAndUploadIcon(ctx, iconURL); newAvatar != nil { 547 - avatarRef = newAvatar 551 + if !userEdited && manifestRecord.Annotations != nil { 552 + if iconURL := manifestRecord.Annotations["io.atcr.icon"]; iconURL != "" { 553 + if newAvatar := s.fetchAndUploadIcon(ctx, iconURL); newAvatar != nil { 554 + avatarRef = newAvatar 555 + } 548 556 } 549 557 } 550 558 551 559 // Create/update repo page record with description and avatar 552 560 repoPage := atproto.NewRepoPageRecord(s.ctx.Repository, description, avatarRef) 561 + repoPage.UserEdited = userEdited 553 562 554 563 isUpdate := existingRecord != nil 555 564 action := "Creating"
+232 -7
pkg/appview/templates/pages/repository.html
··· 112 112 <!-- Tab Panels --> 113 113 <!-- Overview Panel --> 114 114 <div id="tab-overview" class="repo-panel"> 115 - {{ if .ReadmeHTML }} 116 - <div class="card bg-base-100 shadow-sm p-6 space-y-4 min-w-0"> 117 - <div class="prose prose-sm max-w-none"> 118 - {{ .ReadmeHTML }} 115 + <!-- View mode --> 116 + <div id="overview-view" class="card bg-base-100 shadow-sm p-6 space-y-4 min-w-0"> 117 + {{ if .IsOwner }} 118 + <div class="flex justify-end"> 119 + <button class="btn btn-sm btn-ghost gap-1" onclick="toggleOverviewEditor(true)"> 120 + {{ icon "pencil" "size-4" }} 121 + Edit 122 + </button> 123 + </div> 124 + {{ end }} 125 + <div id="overview-rendered" class="prose prose-sm max-w-none"> 126 + {{ if .ReadmeHTML }} 127 + {{ .ReadmeHTML }} 128 + {{ else }} 129 + <p class="text-base-content/60">No description available</p> 130 + {{ end }} 119 131 </div> 120 132 </div> 121 - {{ else }} 122 - <div class="card bg-base-100 shadow-sm p-6"> 123 - <p class="text-base-content/60">No description available</p> 133 + 134 + <!-- Edit mode (hidden, owner only) --> 135 + {{ if .IsOwner }} 136 + <div id="overview-edit" class="card bg-base-100 shadow-sm p-6 hidden"> 137 + <!-- Write/Preview tabs --> 138 + <div class="border-b border-base-300 mb-4"> 139 + <nav class="flex gap-0" role="tablist"> 140 + <button class="editor-tab px-4 py-2 text-sm font-medium border-b-2 border-primary text-primary" 141 + data-tab="write" onclick="switchEditorTab('write')"> 142 + Write 143 + </button> 144 + <button class="editor-tab px-4 py-2 text-sm font-medium border-b-2 border-transparent text-base-content/60" 145 + data-tab="preview" onclick="switchEditorTab('preview')"> 146 + Preview 147 + </button> 148 + </nav> 149 + </div> 150 + 151 + <!-- Write panel --> 152 + <div id="editor-write" class="editor-panel"> 153 + <!-- Toolbar --> 154 + <div class="flex flex-wrap gap-1 mb-2 p-1 bg-base-200 rounded-lg"> 155 + <button type="button" class="btn btn-ghost btn-xs" onclick="insertMd('heading')" title="Heading"> 156 + {{ icon "heading" "size-4" }} 157 + </button> 158 + <button type="button" class="btn btn-ghost btn-xs" onclick="insertMd('bold')" title="Bold"> 159 + {{ icon "bold" "size-4" }} 160 + </button> 161 + <button type="button" class="btn btn-ghost btn-xs" onclick="insertMd('italic')" title="Italic"> 162 + {{ icon "italic" "size-4" }} 163 + </button> 164 + <div class="divider divider-horizontal mx-0"></div> 165 + <button type="button" class="btn btn-ghost btn-xs" onclick="insertMd('link')" title="Link"> 166 + {{ icon "link" "size-4" }} 167 + </button> 168 + <button type="button" class="btn btn-ghost btn-xs" onclick="insertMd('image')" title="Image"> 169 + {{ icon "image" "size-4" }} 170 + </button> 171 + <div class="divider divider-horizontal mx-0"></div> 172 + <button type="button" class="btn btn-ghost btn-xs" onclick="insertMd('ul')" title="Bulleted list"> 173 + {{ icon "list" "size-4" }} 174 + </button> 175 + <button type="button" class="btn btn-ghost btn-xs" onclick="insertMd('ol')" title="Numbered list"> 176 + {{ icon "list-ordered" "size-4" }} 177 + </button> 178 + <button type="button" class="btn btn-ghost btn-xs" onclick="insertMd('code')" title="Code"> 179 + {{ icon "code" "size-4" }} 180 + </button> 181 + </div> 182 + <textarea id="md-editor" 183 + class="textarea textarea-bordered w-full font-mono text-sm leading-relaxed" 184 + rows="20" 185 + placeholder="Write your repository description in Markdown...">{{ .RawDescription }}</textarea> 186 + </div> 187 + 188 + <!-- Preview panel --> 189 + <div id="editor-preview" class="editor-panel hidden"> 190 + <div id="preview-content" class="prose prose-sm max-w-none min-h-[20rem] p-4 border border-base-300 rounded-lg"> 191 + <p class="text-base-content/60">Nothing to preview</p> 192 + </div> 193 + </div> 194 + 195 + <!-- Actions --> 196 + <div class="flex justify-end gap-2 mt-4"> 197 + <button class="btn btn-sm btn-ghost" onclick="toggleOverviewEditor(false)">Cancel</button> 198 + <button class="btn btn-sm btn-primary" id="save-overview-btn" onclick="saveOverview()">Save</button> 199 + </div> 124 200 </div> 201 + 202 + <script> 203 + (function() { 204 + var textarea = document.getElementById('md-editor'); 205 + if (!textarea) return; 206 + 207 + var ownerDID = {{ .Owner.DID }}; 208 + var repoName = {{ .Repository.Name }}; 209 + 210 + window.toggleOverviewEditor = function(show) { 211 + document.getElementById('overview-view').classList.toggle('hidden', show); 212 + document.getElementById('overview-edit').classList.toggle('hidden', !show); 213 + if (show) textarea.focus(); 214 + }; 215 + 216 + window.switchEditorTab = function(tab) { 217 + document.querySelectorAll('.editor-panel').forEach(function(p) { p.classList.add('hidden'); }); 218 + document.getElementById(tab === 'write' ? 'editor-write' : 'editor-preview').classList.remove('hidden'); 219 + 220 + document.querySelectorAll('.editor-tab').forEach(function(t) { 221 + var active = t.dataset.tab === tab; 222 + t.classList.toggle('border-primary', active); 223 + t.classList.toggle('text-primary', active); 224 + t.classList.toggle('border-transparent', !active); 225 + t.classList.toggle('text-base-content/60', !active); 226 + }); 227 + 228 + if (tab === 'preview') { 229 + var content = textarea.value; 230 + var previewEl = document.getElementById('preview-content'); 231 + if (!content.trim()) { 232 + previewEl.innerHTML = '<p class="text-base-content/60">Nothing to preview</p>'; 233 + return; 234 + } 235 + var form = new FormData(); 236 + form.append('markdown', content); 237 + fetch('/api/repo-page/preview', { method: 'POST', body: form }) 238 + .then(function(r) { return r.text(); }) 239 + .then(function(html) { previewEl.innerHTML = html; }); 240 + } 241 + }; 242 + 243 + window.insertMd = function(type) { 244 + var start = textarea.selectionStart; 245 + var end = textarea.selectionEnd; 246 + var selected = textarea.value.substring(start, end); 247 + var before = textarea.value.substring(0, start); 248 + var after = textarea.value.substring(end); 249 + var insert, cursorStart, cursorEnd; 250 + 251 + switch (type) { 252 + case 'heading': 253 + insert = '## ' + (selected || 'Heading'); 254 + cursorStart = start + 3; 255 + cursorEnd = start + insert.length; 256 + break; 257 + case 'bold': 258 + insert = '**' + (selected || 'bold text') + '**'; 259 + cursorStart = start + 2; 260 + cursorEnd = start + insert.length - 2; 261 + break; 262 + case 'italic': 263 + insert = '_' + (selected || 'italic text') + '_'; 264 + cursorStart = start + 1; 265 + cursorEnd = start + insert.length - 1; 266 + break; 267 + case 'link': 268 + insert = '[' + (selected || 'link text') + '](url)'; 269 + cursorStart = start + insert.length - 4; 270 + cursorEnd = start + insert.length - 1; 271 + break; 272 + case 'image': 273 + insert = '![' + (selected || 'alt text') + '](url)'; 274 + cursorStart = start + insert.length - 4; 275 + cursorEnd = start + insert.length - 1; 276 + break; 277 + case 'ul': 278 + insert = '- ' + (selected || 'list item'); 279 + cursorStart = start + 2; 280 + cursorEnd = start + insert.length; 281 + break; 282 + case 'ol': 283 + insert = '1. ' + (selected || 'list item'); 284 + cursorStart = start + 3; 285 + cursorEnd = start + insert.length; 286 + break; 287 + case 'code': 288 + if (selected && selected.indexOf('\n') !== -1) { 289 + insert = '```\n' + selected + '\n```'; 290 + cursorStart = start + 4; 291 + cursorEnd = start + 4 + selected.length; 292 + } else { 293 + insert = '`' + (selected || 'code') + '`'; 294 + cursorStart = start + 1; 295 + cursorEnd = start + insert.length - 1; 296 + } 297 + break; 298 + default: 299 + return; 300 + } 301 + 302 + textarea.value = before + insert + after; 303 + textarea.focus(); 304 + textarea.selectionStart = cursorStart; 305 + textarea.selectionEnd = cursorEnd; 306 + }; 307 + 308 + window.saveOverview = function() { 309 + var btn = document.getElementById('save-overview-btn'); 310 + btn.classList.add('btn-disabled'); 311 + btn.innerHTML = '<span class="loading loading-spinner loading-xs"></span> Saving...'; 312 + 313 + var form = new FormData(); 314 + form.append('did', ownerDID); 315 + form.append('repository', repoName); 316 + form.append('description', textarea.value); 317 + 318 + fetch('/api/repo-page', { 319 + method: 'POST', 320 + body: form, 321 + headers: { 'HX-Request': 'true' } 322 + }) 323 + .then(function(r) { 324 + if (!r.ok) return r.text().then(function(t) { throw new Error(t); }); 325 + return r.text(); 326 + }) 327 + .then(function(html) { 328 + document.getElementById('overview-rendered').innerHTML = html; 329 + toggleOverviewEditor(false); 330 + if (typeof showToast === 'function') showToast('Overview saved', 'success'); 331 + }) 332 + .catch(function(err) { 333 + if (typeof showToast === 'function') showToast(err.message || 'Failed to save', 'error'); 334 + }) 335 + .finally(function() { 336 + btn.classList.remove('btn-disabled'); 337 + btn.innerHTML = 'Save'; 338 + }); 339 + }; 340 + 341 + // Ctrl+S / Cmd+S to save 342 + textarea.addEventListener('keydown', function(e) { 343 + if ((e.ctrlKey || e.metaKey) && e.key === 's') { 344 + e.preventDefault(); 345 + saveOverview(); 346 + } 347 + }); 348 + })(); 349 + </script> 125 350 {{ end }} 126 351 </div> 127 352
+4
pkg/atproto/lexicon.go
··· 378 378 // Avatar is the repository avatar/icon blob reference 379 379 Avatar *ATProtoBlobRef `json:"avatar,omitempty"` 380 380 381 + // UserEdited indicates the description was manually edited by the user 382 + // When true, auto-population from manifest annotations is skipped on push 383 + UserEdited bool `json:"userEdited,omitempty"` 384 + 381 385 // CreatedAt timestamp 382 386 CreatedAt time.Time `json:"createdAt"` 383 387
+8
pkg/hold/admin/public/icons.svg
··· 6 6 <symbol id="arrow-down-to-line" viewBox="0 0 24 24"><path d="M12 17V3"/><path d="m6 11 6 6 6-6"/><path d="M19 21H5"/></symbol> 7 7 <symbol id="arrow-left" viewBox="0 0 24 24"><path d="m12 19-7-7 7-7"/><path d="M19 12H5"/></symbol> 8 8 <symbol id="arrow-right" viewBox="0 0 24 24"><path d="M5 12h14"/><path d="m12 5 7 7-7 7"/></symbol> 9 + <symbol id="bold" viewBox="0 0 24 24"><path d="M6 12h9a4 4 0 0 1 0 8H7a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1h7a4 4 0 0 1 0 8"/></symbol> 9 10 <symbol id="check" viewBox="0 0 24 24"><path d="M20 6 9 17l-5-5"/></symbol> 10 11 <symbol id="check-circle" viewBox="0 0 24 24"><path d="M21.801 10A10 10 0 1 1 17 3.335"/><path d="m9 11 3 3L22 4"/></symbol> 11 12 <symbol id="chevron-down" viewBox="0 0 24 24"><path d="m6 9 6 6 6-6"/></symbol> 12 13 <symbol id="chevron-left" viewBox="0 0 24 24"><path d="m15 18-6-6 6-6"/></symbol> 13 14 <symbol id="chevron-right" viewBox="0 0 24 24"><path d="m9 18 6-6-6-6"/></symbol> 14 15 <symbol id="circle-x" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="m15 9-6 6"/><path d="m9 9 6 6"/></symbol> 16 + <symbol id="code" viewBox="0 0 24 24"><path d="m16 18 6-6-6-6"/><path d="m8 6-6 6 6 6"/></symbol> 15 17 <symbol id="compass" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="m16.24 7.76-1.804 5.411a2 2 0 0 1-1.265 1.265L7.76 16.24l1.804-5.411a2 2 0 0 1 1.265-1.265z"/></symbol> 16 18 <symbol id="container" viewBox="0 0 24 24"><path d="M22 7.7c0-.6-.4-1.2-.8-1.5l-6.3-3.9a1.72 1.72 0 0 0-1.7 0l-10.3 6c-.5.2-.9.8-.9 1.4v6.6c0 .5.4 1.2.8 1.5l6.3 3.9a1.72 1.72 0 0 0 1.7 0l10.3-6c.5-.3.9-1 .9-1.5Z"/><path d="M10 21.9V14L2.1 9.1"/><path d="m10 14 11.9-6.9"/><path d="M14 19.8v-8.1"/><path d="M18 17.5V9.4"/></symbol> 17 19 <symbol id="copy" viewBox="0 0 24 24"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></symbol> ··· 25 27 <symbol id="git-merge" viewBox="0 0 24 24"><circle cx="18" cy="18" r="3"/><circle cx="6" cy="6" r="3"/><path d="M6 21V9a9 9 0 0 0 9 9"/></symbol> 26 28 <symbol id="github" viewBox="0 0 24 24"><path d="M15 22v-4a4.8 4.8 0 0 0-1-3.5c3 0 6-2 6-5.5.08-1.25-.27-2.48-1-3.5.28-1.15.28-2.35 0-3.5 0 0-1 0-3 1.5-2.64-.5-5.36-.5-8 0C6 2 5 2 5 2c-.3 1.15-.3 2.35 0 3.5A5.403 5.403 0 0 0 4 9c0 3.5 3 5.5 6 5.5-.39.49-.68 1.05-.85 1.65-.17.6-.22 1.23-.15 1.85v4"/><path d="M9 18c-4.51 2-5-2-7-2"/></symbol> 27 29 <symbol id="hard-drive" viewBox="0 0 24 24"><path d="M10 16h.01"/><path d="M2.212 11.577a2 2 0 0 0-.212.896V18a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-5.527a2 2 0 0 0-.212-.896L18.55 5.11A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/><path d="M21.946 12.013H2.054"/><path d="M6 16h.01"/></symbol> 30 + <symbol id="heading" viewBox="0 0 24 24"><path d="M6 12h12"/><path d="M6 20V4"/><path d="M18 20V4"/></symbol> 28 31 <symbol id="history" viewBox="0 0 24 24"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/><path d="M12 7v5l4 2"/></symbol> 32 + <symbol id="image" viewBox="0 0 24 24"><rect width="18" height="18" x="3" y="3" rx="2" ry="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"/></symbol> 29 33 <symbol id="info" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></symbol> 34 + <symbol id="italic" viewBox="0 0 24 24"><line x1="19" x2="10" y1="4" y2="4"/><line x1="14" x2="5" y1="20" y2="20"/><line x1="15" x2="9" y1="4" y2="20"/></symbol> 35 + <symbol id="link" viewBox="0 0 24 24"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></symbol> 36 + <symbol id="list" viewBox="0 0 24 24"><path d="M3 5h.01"/><path d="M3 12h.01"/><path d="M3 19h.01"/><path d="M8 5h13"/><path d="M8 12h13"/><path d="M8 19h13"/></symbol> 37 + <symbol id="list-ordered" viewBox="0 0 24 24"><path d="M11 5h10"/><path d="M11 12h10"/><path d="M11 19h10"/><path d="M4 4h1v5"/><path d="M4 9h2"/><path d="M6.5 20H3.4c0-1 2.6-1.925 2.6-3.5a1.5 1.5 0 0 0-2.6-1.02"/></symbol> 30 38 <symbol id="loader-2" viewBox="0 0 24 24"><path d="M21 12a9 9 0 1 1-6.219-8.56"/></symbol> 31 39 <symbol id="moon" viewBox="0 0 24 24"><path d="M20.985 12.486a9 9 0 1 1-9.473-9.472c.405-.022.617.46.402.803a6 6 0 0 0 8.268 8.268c.344-.215.825-.004.803.401"/></symbol> 32 40 <symbol id="pencil" viewBox="0 0 24 24"><path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z"/><path d="m15 5 4 4"/></symbol>