A container registry that uses the AT Protocol for manifest storage and S3 for blob storage.
0
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>