diff --git a/api/graphql/graph/repository.generated.go b/api/graphql/graph/repository.generated.go index 81e39832ce30d106fd54672bbf7e2a362f121fa8..dcc351c9e41708467edfcec9f83e295af18f1c75 100644 --- a/api/graphql/graph/repository.generated.go +++ b/api/graphql/graph/repository.generated.go @@ -19,6 +19,7 @@ import ( type RepositoryResolver interface { Name(ctx context.Context, obj *models.Repository) (*string, error) + LocalName(ctx context.Context, obj *models.Repository) (string, error) AllBugs(ctx context.Context, obj *models.Repository, after *string, before *string, first *int, last *int, query *string) (*models.BugConnection, error) Bug(ctx context.Context, obj *models.Repository, prefix string) (models.BugWrapper, error) AllIdentities(ctx context.Context, obj *models.Repository, after *string, before *string, first *int, last *int) (*models.IdentityConnection, error) @@ -450,6 +451,50 @@ func (ec *executionContext) fieldContext_Repository_name(_ context.Context, fiel return fc, nil } +func (ec *executionContext) _Repository_localName(ctx context.Context, field graphql.CollectedField, obj *models.Repository) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_Repository_localName(ctx, field) + if err != nil { + return graphql.Null + } + ctx = graphql.WithFieldContext(ctx, fc) + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) { + ctx = rctx // use context from middleware stack in children + return ec.resolvers.Repository().LocalName(rctx, obj) + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + if !graphql.HasFieldError(ctx, fc) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + res := resTmp.(string) + fc.Result = res + return ec.marshalNString2string(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_Repository_localName(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Repository", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type String does not have child fields") + }, + } + return fc, nil +} + func (ec *executionContext) _Repository_allBugs(ctx context.Context, field graphql.CollectedField, obj *models.Repository) (ret graphql.Marshaler) { fc, err := ec.fieldContext_Repository_allBugs(ctx, field) if err != nil { @@ -945,6 +990,8 @@ func (ec *executionContext) fieldContext_RepositoryConnection_nodes(_ context.Co switch field.Name { case "name": return ec.fieldContext_Repository_name(ctx, field) + case "localName": + return ec.fieldContext_Repository_localName(ctx, field) case "allBugs": return ec.fieldContext_Repository_allBugs(ctx, field) case "bug": @@ -1147,6 +1194,8 @@ func (ec *executionContext) fieldContext_RepositoryEdge_node(_ context.Context, switch field.Name { case "name": return ec.fieldContext_Repository_name(ctx, field) + case "localName": + return ec.fieldContext_Repository_localName(ctx, field) case "allBugs": return ec.fieldContext_Repository_allBugs(ctx, field) case "bug": @@ -1221,6 +1270,42 @@ func (ec *executionContext) _Repository(ctx context.Context, sel ast.SelectionSe continue } + out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) }) + case "localName": + field := field + + innerFunc := func(ctx context.Context, fs *graphql.FieldSet) (res graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + } + }() + res = ec._Repository_localName(ctx, field, obj) + if res == graphql.Null { + atomic.AddUint32(&fs.Invalids, 1) + } + return res + } + + if field.Deferrable != nil { + dfs, ok := deferred[field.Deferrable.Label] + di := 0 + if ok { + dfs.AddField(field) + di = len(dfs.Values) - 1 + } else { + dfs = graphql.NewFieldSet([]graphql.CollectedField{field}) + deferred[field.Deferrable.Label] = dfs + } + dfs.Concurrently(di, func(ctx context.Context) graphql.Marshaler { + return innerFunc(ctx, dfs) + }) + + // don't run the out.Concurrently() call below + out.Values[i] = graphql.Null + continue + } + out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) }) case "allBugs": field := field diff --git a/api/graphql/graph/root.generated.go b/api/graphql/graph/root.generated.go index 885d556efc903fddff6ced9d8427cda6548fbaf1..6320d8779370f5d145cd5a6b9e7b3a76f1c354f5 100644 --- a/api/graphql/graph/root.generated.go +++ b/api/graphql/graph/root.generated.go @@ -1112,6 +1112,8 @@ func (ec *executionContext) fieldContext_Query_repository(ctx context.Context, f switch field.Name { case "name": return ec.fieldContext_Repository_name(ctx, field) + case "localName": + return ec.fieldContext_Repository_localName(ctx, field) case "allBugs": return ec.fieldContext_Repository_allBugs(ctx, field) case "bug": diff --git a/api/graphql/graph/root_.generated.go b/api/graphql/graph/root_.generated.go index a6a5ef1939b78093be919c22390b9d332cf42578..b1efaede20aa290f4c550cb56f821f9c7791693c 100644 --- a/api/graphql/graph/root_.generated.go +++ b/api/graphql/graph/root_.generated.go @@ -385,6 +385,7 @@ type ComplexityRoot struct { AllIdentities func(childComplexity int, after *string, before *string, first *int, last *int) int Bug func(childComplexity int, prefix string) int Identity func(childComplexity int, prefix string) int + LocalName func(childComplexity int) int Name func(childComplexity int) int UserIdentity func(childComplexity int) int ValidLabels func(childComplexity int, after *string, before *string, first *int, last *int) int @@ -1856,6 +1857,13 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin return e.complexity.Repository.Identity(childComplexity, args["prefix"].(string)), true + case "Repository.localName": + if e.complexity.Repository.LocalName == nil { + break + } + + return e.complexity.Repository.LocalName(childComplexity), true + case "Repository.name": if e.complexity.Repository.Name == nil { break @@ -2728,6 +2736,9 @@ type OperationEdge { """The name of the repository. Null for the default (unnamed) repository.""" name: String + """The local directory name of the repository (basename only, no path).""" + localName: String! + """All the bugs""" allBugs( """Returns the elements in the list that come after the specified cursor.""" diff --git a/api/graphql/schema/repository.graphql b/api/graphql/schema/repository.graphql index f72ea69fd28c4ab3485f083c78dddc53a6e0c9e4..a50e8773adafb396b0494d08a255ab276de09ddd 100644 --- a/api/graphql/schema/repository.graphql +++ b/api/graphql/schema/repository.graphql @@ -2,6 +2,9 @@ type Repository { """The name of the repository. Null for the default (unnamed) repository.""" name: String + """The local directory name of the repository (basename only, no path).""" + localName: String! + """All the bugs""" allBugs( """Returns the elements in the list that come after the specified cursor.""" diff --git a/api/http/git_serve_handler.go b/api/http/git_serve_handler.go new file mode 100644 index 0000000000000000000000000000000000000000..dcd91ad02b2e7770b0cf959a4cac3218374549eb --- /dev/null +++ b/api/http/git_serve_handler.go @@ -0,0 +1,331 @@ +// Git smart HTTP handler — serves git clone and push using native git +// subprocesses (git-upload-pack / git-receive-pack --stateless-rpc). +// +// Security notes: +// - No shell is used; exec.Command receives explicit argument slices. +// - The subprocess environment is sanitised: variables that could redirect +// git's operations (GIT_DIR, GIT_EXEC_PATH, GIT_SSH, …) are stripped. +// - The repository path is resolved from our internal config, never from +// URL parameters or request body content. +// - Client stderr is captured and discarded; it is never forwarded to the +// HTTP response. +// +// Routes (registered on the /api/repos/{owner}/{repo} subrouter): +// +// GET /info/refs?service=git-{upload,receive}-pack → capability advertisement +// POST /git-upload-pack → fetch / clone +// POST /git-receive-pack → push (blocked in read-only mode) + +package http + +import ( + "bytes" + "compress/gzip" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "strings" + + pktline "github.com/go-git/go-git/v5/plumbing/format/pktline" + + "github.com/git-bug/git-bug/cache" +) + +// GitServeHandler exposes the repository over git's smart HTTP protocol. +type GitServeHandler struct { + mrc *cache.MultiRepoCache + readOnly bool +} + +func NewGitServeHandler(mrc *cache.MultiRepoCache, readOnly bool) *GitServeHandler { + return &GitServeHandler{mrc: mrc, readOnly: readOnly} +} + +// ServeInfoRefs handles GET /info/refs — the capability advertisement step. +// Runs `git {upload,receive}-pack --stateless-rpc --advertise-refs` and +// prepends the required PKT-LINE service header. +// For upload-pack the advertised refs are filtered to heads and tags only so +// that cloners do not inadvertently fetch git-bug internal objects. +func (h *GitServeHandler) ServeInfoRefs(w http.ResponseWriter, r *http.Request) { + service := r.URL.Query().Get("service") + if service != "git-upload-pack" && service != "git-receive-pack" { + http.Error(w, "unknown service", http.StatusForbidden) + return + } + if service == "git-receive-pack" && h.readOnly { + http.Error(w, "repository is read-only", http.StatusForbidden) + return + } + + repoPath, err := h.repoPathFor(r) + if err != nil { + http.Error(w, err.Error(), http.StatusNotFound) + return + } + + // "git-upload-pack" → "upload-pack", "git-receive-pack" → "receive-pack" + subCmd := strings.TrimPrefix(service, "git-") + + cmd := exec.CommandContext(r.Context(), + "git", subCmd, "--stateless-rpc", "--advertise-refs", repoPath) + cmd.Env = safeGitEnv() + + out, err := cmd.Output() + if err != nil { + http.Error(w, "git advertisement failed", http.StatusInternalServerError) + return + } + + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Content-Type", fmt.Sprintf("application/x-%s-advertisement", service)) + + // PKT-LINE service header required by the smart HTTP protocol. + enc := pktline.NewEncoder(w) + if err := enc.EncodeString(fmt.Sprintf("# service=%s\n", service)); err != nil { + return + } + if err := enc.Flush(); err != nil { + return + } + + // For upload-pack, filter out internal git-bug refs (refs/bugs/, + // refs/identities/, …) so cloners only receive source code objects. + if service == "git-upload-pack" { + _ = writeFilteredInfoRefs(w, out) + } else { + _, _ = w.Write(out) + } +} + +// ServeUploadPack handles POST /git-upload-pack — serves a fetch or clone. +// The request body is piped directly to `git upload-pack --stateless-rpc`. +func (h *GitServeHandler) ServeUploadPack(w http.ResponseWriter, r *http.Request) { + repoPath, err := h.repoPathFor(r) + if err != nil { + http.Error(w, err.Error(), http.StatusNotFound) + return + } + + body, err := requestBody(r) + if err != nil { + http.Error(w, "decompressing request: "+err.Error(), http.StatusBadRequest) + return + } + defer body.Close() + + cmd := exec.CommandContext(r.Context(), + "git", "upload-pack", "--stateless-rpc", repoPath) + cmd.Env = safeGitEnv() + cmd.Stdin = body + + w.Header().Set("Content-Type", "application/x-git-upload-pack-result") + w.Header().Set("Cache-Control", "no-cache") + cmd.Stdout = w + + var stderr bytes.Buffer + cmd.Stderr = &stderr + // Errors after this point can't change the HTTP status (headers already + // committed on first write), so we just return silently. + _ = cmd.Run() +} + +// ServeReceivePack handles POST /git-receive-pack — accepts a push. +// Before running git, the PKT-LINE ref-update commands are parsed so that the +// git-bug cache can be synchronised for any git-bug namespaces that were +// updated. +func (h *GitServeHandler) ServeReceivePack(w http.ResponseWriter, r *http.Request) { + if h.readOnly { + http.Error(w, "repository is read-only", http.StatusForbidden) + return + } + + repoPath, err := h.repoPathFor(r) + if err != nil { + http.Error(w, err.Error(), http.StatusNotFound) + return + } + + body, err := requestBody(r) + if err != nil { + http.Error(w, "decompressing request: "+err.Error(), http.StatusBadRequest) + return + } + defer body.Close() + + // Parse the PKT-LINE ref-update commands so we know which git-bug entities + // to resync after the push completes. The full request body is + // reconstructed (commands + flush + packfile) for git's stdin. + updatedRefs, fullBody, err := parseReceivePackCommands(body) + if err != nil { + http.Error(w, "parsing receive-pack request: "+err.Error(), http.StatusBadRequest) + return + } + + cmd := exec.CommandContext(r.Context(), + "git", "receive-pack", "--stateless-rpc", repoPath) + cmd.Env = safeGitEnv() + cmd.Stdin = fullBody + + w.Header().Set("Content-Type", "application/x-git-receive-pack-result") + w.Header().Set("Cache-Control", "no-cache") + cmd.Stdout = w + + var stderr bytes.Buffer + cmd.Stderr = &stderr + + if err := cmd.Run(); err != nil { + // Headers may already be committed; best-effort return. + return + } + + h.syncAfterPush(r, updatedRefs) +} + +// ── helpers ─────────────────────────────────────────────────────────────────── + +// repoPathFor returns the filesystem path of the repository referenced in the +// request URL variables. The path is always resolved from our internal +// MultiRepoCache configuration — it is never derived from request content. +func (h *GitServeHandler) repoPathFor(r *http.Request) (string, error) { + rc, err := repoFromPath(h.mrc, r) + if err != nil { + return "", err + } + return rc.GetPath(), nil +} + +// syncAfterPush updates the git-bug in-memory cache for any refs that were +// updated by the push. +func (h *GitServeHandler) syncAfterPush(r *http.Request, refs []string) { + if len(refs) == 0 { + return + } + rc, err := repoFromPath(h.mrc, r) + if err != nil { + return + } + _ = rc.SyncLocalRefs(refs) +} + +// writeFilteredInfoRefs re-encodes the raw PKT-LINE advertisement output from +// git, keeping only HEAD and refs/heads/* and refs/tags/*. The first line is +// always forwarded unchanged because it carries the server capability list +// (appended after a NUL byte). +func writeFilteredInfoRefs(w io.Writer, raw []byte) error { + scanner := pktline.NewScanner(bytes.NewReader(raw)) + enc := pktline.NewEncoder(w) + first := true + for scanner.Scan() { + b := scanner.Bytes() + if len(b) == 0 { // flush packet + return enc.Flush() + } + if first { + // First line always passes — it carries server capabilities. + first = false + if err := enc.Encode(b); err != nil { + return err + } + continue + } + // Lines are " \n"; strip the newline to get the ref name. + line := strings.TrimSuffix(string(b), "\n") + parts := strings.SplitN(line, " ", 2) + if len(parts) == 2 { + ref := parts[1] + if strings.HasPrefix(ref, "refs/heads/") || strings.HasPrefix(ref, "refs/tags/") { + if err := enc.Encode(b); err != nil { + return err + } + } + } + } + return scanner.Err() +} + +// parseReceivePackCommands reads the PKT-LINE ref-update command lines from the +// receive-pack request body (up to and including the flush packet), extracts +// the ref names, and returns an io.Reader that replays the full original body +// (commands + flush + packfile) for the git subprocess. +func parseReceivePackCommands(r io.Reader) (refs []string, full io.Reader, err error) { + // TeeReader mirrors everything consumed by the scanner into cmds, so we + // can replay it verbatim later. + var cmds bytes.Buffer + scanner := pktline.NewScanner(io.TeeReader(r, &cmds)) + for scanner.Scan() { + b := scanner.Bytes() + if len(b) == 0 { // flush — end of command list + break + } + // Command format: " \0" (first line) + // or " " (subsequent) + line := strings.TrimSuffix(string(b), "\n") + if i := strings.IndexByte(line, 0); i >= 0 { + line = line[:i] // strip NUL + capability list + } + parts := strings.SplitN(line, " ", 3) + if len(parts) == 3 { + refs = append(refs, parts[2]) + } + } + if err = scanner.Err(); err != nil { + return nil, nil, err + } + // cmds holds [commands + flush]; r holds the remaining packfile data. + return refs, io.MultiReader(&cmds, r), nil +} + +// requestBody returns the request body, transparently decompressing it when +// the client sent Content-Encoding: gzip (git does this by default). +func requestBody(r *http.Request) (io.ReadCloser, error) { + if r.Header.Get("Content-Encoding") == "gzip" { + gr, err := gzip.NewReader(r.Body) + if err != nil { + return nil, err + } + return gr, nil + } + return r.Body, nil +} + +// safeGitEnv returns a sanitised copy of the process environment for use with +// git subprocesses. Variables that could redirect git's operations to +// unintended paths or trigger credential prompts are removed. +func safeGitEnv() []string { + // These variables could redirect git internals to attacker-controlled + // paths or commands when the git-bug server process itself inherits a + // tainted environment. + blocked := map[string]bool{ + "GIT_DIR": true, + "GIT_WORK_TREE": true, + "GIT_INDEX_FILE": true, + "GIT_OBJECT_DIRECTORY": true, + "GIT_ALTERNATE_OBJECT_DIRECTORIES": true, + "GIT_EXEC_PATH": true, + "GIT_SSH": true, + "GIT_SSH_COMMAND": true, + "GIT_PROXY_COMMAND": true, + "GIT_ASKPASS": true, + "SSH_ASKPASS": true, + "GIT_TRACE": true, + "GIT_TRACE_PACKET": true, + "GIT_TRACE_PERFORMANCE": true, + } + parent := os.Environ() + safe := make([]string, 0, len(parent)+1) + for _, kv := range parent { + key := kv + if i := strings.IndexByte(kv, '='); i >= 0 { + key = kv[:i] + } + if !blocked[key] { + safe = append(safe, kv) + } + } + // Prevent git from blocking on a credential/passphrase prompt, which + // would hang the HTTP handler goroutine. + safe = append(safe, "GIT_TERMINAL_PROMPT=0") + return safe +} diff --git a/cache/repo_cache.go b/cache/repo_cache.go index c43cc0a1c7cd15066f699e0d0c3776f447954bab..8c69006955975da5326718165773fa57149a5867 100644 --- a/cache/repo_cache.go +++ b/cache/repo_cache.go @@ -41,6 +41,7 @@ type cacheMgmt interface { RegisterObserver(repoName string, observer Observer) UnregisterObserver(observer Observer) Close() error + SyncLocalRef(id entity.Id) error } // RepoCache is a cache for a Repository. This cache has multiple functions: diff --git a/cache/repo_cache_common.go b/cache/repo_cache_common.go index 5cb0bdfca6f93aee2dfc93fbd35c645d1a4a3a12..f6166a8a39d160a849958fbc5e56aeed2af6807a 100644 --- a/cache/repo_cache_common.go +++ b/cache/repo_cache_common.go @@ -1,6 +1,7 @@ package cache import ( + "strings" "sync" "github.com/pkg/errors" @@ -247,3 +248,22 @@ func (c *RepoCache) GetUserIdentityExcerpt() (*IdentityExcerpt, error) { func (c *RepoCache) IsUserIdentitySet() (bool, error) { return identity.IsUserIdentitySet(c.repo) } + +// SyncLocalRefs updates the cache for each ref that was updated externally +// (e.g. after a git push). Each ref is matched against the subcaches by +// namespace and the corresponding entity is re-read from git. +func (c *RepoCache) SyncLocalRefs(refs []string) error { + for _, ref := range refs { + id := entity.RefToId(ref) + for _, subcache := range c.subcaches { + ns := subcache.GetNamespace() + if strings.Contains(ref, "/"+ns+"/") { + if err := subcache.SyncLocalRef(id); err != nil { + return err + } + break + } + } + } + return nil +} diff --git a/cache/subcache.go b/cache/subcache.go index c9aa5f68444d632dd2d951119d90e0760eb54626..27b06890177247873d174d7d585dff4578eee516 100644 --- a/cache/subcache.go +++ b/cache/subcache.go @@ -613,6 +613,9 @@ func (sc *SubCache[EntityT, ExcerptT, CacheT]) MergeAll(remote string) <-chan en sc.cached[result.Id] = cached sc.mu.Unlock() sc.notifyObservers(EntityEventUpdated, result.Id) + + default: + // nothing } } @@ -677,6 +680,50 @@ func (sc *SubCache[EntityT, ExcerptT, CacheT]) updateExcerptAndIndex(id entity.I return sc.write() } +// SyncLocalRef re-reads the entity with the given id from git and updates the +// in-memory cache, search index, and on-disk excerpt cache. It is used to +// refresh an entity after its git ref was updated externally (e.g. by a push). +func (sc *SubCache[EntityT, ExcerptT, CacheT]) SyncLocalRef(id entity.Id) error { + sc.mu.Lock() + _, existed := sc.excerpts[id] + delete(sc.cached, id) + sc.lru.Remove(id) + sc.mu.Unlock() + + e, err := sc.actions.ReadWithResolver(sc.repo, sc.resolvers(), id) + if err != nil { + return err + } + + cached := sc.makeCached(e, sc.entityUpdated) + + sc.mu.Lock() + sc.excerpts[id] = sc.makeExcerpt(cached) + sc.cached[id] = cached + sc.lru.Add(id) + sc.mu.Unlock() + + sc.evictIfNeeded() + + index, err := sc.repo.GetIndex(sc.namespace) + if err != nil { + return err + } + if err = index.IndexOne(id.String(), sc.makeIndexData(cached)); err != nil { + return err + } + if err = sc.write(); err != nil { + return err + } + + if existed { + sc.notifyObservers(EntityEventUpdated, id) + } else { + sc.notifyObservers(EntityEventCreated, id) + } + return nil +} + // evictIfNeeded will evict an entity from the cache if needed func (sc *SubCache[EntityT, ExcerptT, CacheT]) evictIfNeeded() { sc.mu.Lock() diff --git a/commands/webui.go b/commands/webui.go index 75a1ddd0b2cf0cb13c52059cbb1b2192c0d6e216..94d6bb0c92a1f91a5330ea2cc1fd6563877783cd 100644 --- a/commands/webui.go +++ b/commands/webui.go @@ -197,6 +197,12 @@ func runWebUI(env *execenv.Env, opts webUIOptions) error { apiRepos.Path("/file/{hash}").Methods("GET").Handler(httpapi.NewGitFileHandler(mrc)) apiRepos.Path("/upload").Methods("POST").Handler(httpapi.NewGitUploadFileHandler(mrc)) + // Git smart HTTP — clone, fetch, push. + gitSrv := httpapi.NewGitServeHandler(mrc, opts.readOnly) + apiRepos.Path("/info/refs").Methods("GET").HandlerFunc(gitSrv.ServeInfoRefs) + apiRepos.Path("/git-upload-pack").Methods("POST").HandlerFunc(gitSrv.ServeUploadPack) + apiRepos.Path("/git-receive-pack").Methods("POST").HandlerFunc(gitSrv.ServeReceivePack) + router.PathPrefix("/").Handler(webui2.NewHandler()) srv := &http.Server{ diff --git a/webui2/package-lock.json b/webui2/package-lock.json index 82e5c1343c585fca8c64cc9b5989aca1d25456ee..5d3d0e1714eabc4f5f5d3721f3c1c91b5b514dd4 100644 --- a/webui2/package-lock.json +++ b/webui2/package-lock.json @@ -24,6 +24,12 @@ "react-dom": "^19.1.0", "react-markdown": "^9.0.1", "react-router-dom": "^6.28.0", + "rehype-autolink-headings": "^7.1.0", + "rehype-external-links": "^3.0.0", + "rehype-raw": "^7.0.0", + "rehype-sanitize": "^6.0.0", + "rehype-slug": "^6.0.0", + "remark-emoji": "^5.0.2", "remark-gfm": "^4.0.0", "tailwind-merge": "^2.5.5", "tailwindcss-animate": "^1.0.7" @@ -3444,6 +3450,18 @@ "win32" ] }, + "node_modules/@sindresorhus/is": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz", + "integrity": "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, "node_modules/@tailwindcss/typography": { "version": "0.5.19", "resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.19.tgz", @@ -4147,6 +4165,15 @@ "upper-case-first": "^2.0.2" } }, + "node_modules/char-regex": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz", + "integrity": "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/character-entities": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", @@ -4638,6 +4665,34 @@ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "dev": true }, + "node_modules/emojilib": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/emojilib/-/emojilib-2.4.0.tgz", + "integrity": "sha512-5U0rVMU5Y2n2+ykNLQqMoqklN9ICBT/KsvC1Gz6vqHbz2AXXGkG+Pm5rMWk/8Vjrr/mY9985Hi8DYzn1F09Nyw==", + "license": "MIT" + }, + "node_modules/emoticon": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/emoticon/-/emoticon-4.1.0.tgz", + "integrity": "sha512-VWZfnxqwNcc51hIy/sbOdEem6D+cVtpPzEEtVAFdaas30+1dgkyaOQ4sQ6Bp0tOMqWO1v+HQfYaoodOkdhK6SQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/error-ex": { "version": "1.3.4", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", @@ -4877,6 +4932,12 @@ "node": ">=6" } }, + "node_modules/github-slugger": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/github-slugger/-/github-slugger-2.0.0.tgz", + "integrity": "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==", + "license": "ISC" + }, "node_modules/glob-parent": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", @@ -5192,6 +5253,105 @@ "node": ">= 0.4" } }, + "node_modules/hast-util-from-parse5": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/hast-util-from-parse5/-/hast-util-from-parse5-8.0.3.tgz", + "integrity": "sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "devlop": "^1.0.0", + "hastscript": "^9.0.0", + "property-information": "^7.0.0", + "vfile": "^6.0.0", + "vfile-location": "^5.0.0", + "web-namespaces": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-heading-rank": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-heading-rank/-/hast-util-heading-rank-3.0.0.tgz", + "integrity": "sha512-EJKb8oMUXVHcWZTDepnr+WNbfnXKFNf9duMesmr4S8SXTJBJ9M4Yok08pu9vxdJwdlGRhVumk9mEhkEvKGifwA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-is-element": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-is-element/-/hast-util-is-element-3.0.0.tgz", + "integrity": "sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-parse-selector": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-4.0.0.tgz", + "integrity": "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-raw": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/hast-util-raw/-/hast-util-raw-9.1.0.tgz", + "integrity": "sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "@ungap/structured-clone": "^1.0.0", + "hast-util-from-parse5": "^8.0.0", + "hast-util-to-parse5": "^8.0.0", + "html-void-elements": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "parse5": "^7.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0", + "web-namespaces": "^2.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-sanitize": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/hast-util-sanitize/-/hast-util-sanitize-5.0.2.tgz", + "integrity": "sha512-3yTWghByc50aGS7JlGhk61SPenfE/p1oaFeNwkOOyrscaOkMGrcW9+Cy/QAIOBpZxP1yqDIzFMR0+Np0i0+usg==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@ungap/structured-clone": "^1.0.0", + "unist-util-position": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/hast-util-to-jsx-runtime": { "version": "2.3.6", "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", @@ -5218,6 +5378,38 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/hast-util-to-parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/hast-util-to-parse5/-/hast-util-to-parse5-8.0.1.tgz", + "integrity": "sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "web-namespaces": "^2.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-string": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/hast-util-to-string/-/hast-util-to-string-3.0.1.tgz", + "integrity": "sha512-XelQVTDWvqcl3axRfI0xSeoVKzyIFPwsAGSLIsKdJKQMXDYJS4WYrBNF/8J7RdhIcFI2BOHgAifggsvsxp/3+A==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/hast-util-whitespace": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", @@ -5230,6 +5422,23 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/hastscript": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/hastscript/-/hastscript-9.0.1.tgz", + "integrity": "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-parse-selector": "^4.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/header-case": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/header-case/-/header-case-2.0.4.tgz", @@ -5266,6 +5475,16 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/html-void-elements": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz", + "integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/http-proxy-agent": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", @@ -5448,6 +5667,18 @@ "node": ">=0.10.0" } }, + "node_modules/is-absolute-url": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/is-absolute-url/-/is-absolute-url-4.0.1.tgz", + "integrity": "sha512-/51/TKE88Lmm7Gc4/8btclNXWS+g50wXhYJq8HWIBAGUBnoAdRu1aXeh364t/O7wXDAcTJDP8PNuNKWUDWie+A==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-alphabetical": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", @@ -6865,6 +7096,21 @@ "node": ">=10.5.0" } }, + "node_modules/node-emoji": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/node-emoji/-/node-emoji-2.2.0.tgz", + "integrity": "sha512-Z3lTE9pLaJF47NyMhd4ww1yFTAP8YhYI8SleJiHzM46Fgpm5cnNzSl9XfzFNqbaz+VlJrIj3fXQ4DeN1Rjm6cw==", + "license": "MIT", + "dependencies": { + "@sindresorhus/is": "^4.6.0", + "char-regex": "^1.0.2", + "emojilib": "^2.4.0", + "skin-tone": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/node-fetch": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", @@ -7071,6 +7317,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/pascal-case": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/pascal-case/-/pascal-case-3.1.2.tgz", @@ -7566,6 +7824,104 @@ } } }, + "node_modules/rehype-autolink-headings": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/rehype-autolink-headings/-/rehype-autolink-headings-7.1.0.tgz", + "integrity": "sha512-rItO/pSdvnvsP4QRB1pmPiNHUskikqtPojZKJPPPAVx9Hj8i8TwMBhofrrAYRhYOOBZH9tgmG5lPqDLuIWPWmw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@ungap/structured-clone": "^1.0.0", + "hast-util-heading-rank": "^3.0.0", + "hast-util-is-element": "^3.0.0", + "unified": "^11.0.0", + "unist-util-visit": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rehype-external-links": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/rehype-external-links/-/rehype-external-links-3.0.0.tgz", + "integrity": "sha512-yp+e5N9V3C6bwBeAC4n796kc86M4gJCdlVhiMTxIrJG5UHDMh+PJANf9heqORJbt1nrCbDwIlAZKjANIaVBbvw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@ungap/structured-clone": "^1.0.0", + "hast-util-is-element": "^3.0.0", + "is-absolute-url": "^4.0.0", + "space-separated-tokens": "^2.0.0", + "unist-util-visit": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rehype-raw": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/rehype-raw/-/rehype-raw-7.0.0.tgz", + "integrity": "sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-raw": "^9.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rehype-sanitize": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/rehype-sanitize/-/rehype-sanitize-6.0.0.tgz", + "integrity": "sha512-CsnhKNsyI8Tub6L4sm5ZFsme4puGfc6pYylvXo1AeqaGbjOYyzNv3qZPwvs0oMJ39eryyeOdmxwUIo94IpEhqg==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-sanitize": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rehype-slug": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/rehype-slug/-/rehype-slug-6.0.0.tgz", + "integrity": "sha512-lWyvf/jwu+oS5+hL5eClVd3hNdmwM1kAC0BUvEGD19pajQMIzcNUd/k9GsfQ+FfECvX+JE+e9/btsKH0EjJT6A==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "github-slugger": "^2.0.0", + "hast-util-heading-rank": "^3.0.0", + "hast-util-to-string": "^3.0.0", + "unist-util-visit": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-emoji": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/remark-emoji/-/remark-emoji-5.0.2.tgz", + "integrity": "sha512-IyIqGELcyK5AVdLFafoiNww+Eaw/F+rGrNSXoKucjo95uL267zrddgxGM83GN1wFIb68pyDuAsY3m5t2Cav1pQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.4", + "emoticon": "^4.0.1", + "mdast-util-find-and-replace": "^3.0.1", + "node-emoji": "^2.1.3", + "unified": "^11.0.4" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/remark-gfm": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", @@ -7873,6 +8229,18 @@ "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", "dev": true }, + "node_modules/skin-tone": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/skin-tone/-/skin-tone-2.0.0.tgz", + "integrity": "sha512-kUMbT1oBJCpgrnKoSr0o6wPtvRWT9W9UKvGLwfJYO2WuahZRHOpEyL1ckyMGgMWh0UdpmaoFqKKD29WTomNEGA==", + "license": "MIT", + "dependencies": { + "unicode-emoji-modifier-base": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/slash": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", @@ -8358,6 +8726,15 @@ "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", "dev": true }, + "node_modules/unicode-emoji-modifier-base": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unicode-emoji-modifier-base/-/unicode-emoji-modifier-base-1.0.0.tgz", + "integrity": "sha512-yLSH4py7oFH3oG/9K+XWrz1pSi3dfUrWEnInbxMfArOfc1+33BlGPQtLsOYwvdMy11AwUBetYuaRxSPqgkq+8g==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/unified": { "version": "11.0.5", "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", @@ -8595,6 +8972,20 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/vfile-location": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/vfile-location/-/vfile-location-5.0.3.tgz", + "integrity": "sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/vfile-message": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", @@ -8720,6 +9111,16 @@ "defaults": "^1.0.3" } }, + "node_modules/web-namespaces": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz", + "integrity": "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/web-streams-polyfill": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", @@ -10967,6 +11368,11 @@ "dev": true, "optional": true }, + "@sindresorhus/is": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz", + "integrity": "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==" + }, "@tailwindcss/typography": { "version": "0.5.19", "resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.19.tgz", @@ -11469,6 +11875,11 @@ "upper-case-first": "^2.0.2" } }, + "char-regex": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz", + "integrity": "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==" + }, "character-entities": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", @@ -11815,6 +12226,21 @@ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "dev": true }, + "emojilib": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/emojilib/-/emojilib-2.4.0.tgz", + "integrity": "sha512-5U0rVMU5Y2n2+ykNLQqMoqklN9ICBT/KsvC1Gz6vqHbz2AXXGkG+Pm5rMWk/8Vjrr/mY9985Hi8DYzn1F09Nyw==" + }, + "emoticon": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/emoticon/-/emoticon-4.1.0.tgz", + "integrity": "sha512-VWZfnxqwNcc51hIy/sbOdEem6D+cVtpPzEEtVAFdaas30+1dgkyaOQ4sQ6Bp0tOMqWO1v+HQfYaoodOkdhK6SQ==" + }, + "entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==" + }, "error-ex": { "version": "1.3.4", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", @@ -11976,6 +12402,11 @@ "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==" }, + "github-slugger": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/github-slugger/-/github-slugger-2.0.0.tgz", + "integrity": "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==" + }, "glob-parent": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", @@ -12184,6 +12615,75 @@ "function-bind": "^1.1.2" } }, + "hast-util-from-parse5": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/hast-util-from-parse5/-/hast-util-from-parse5-8.0.3.tgz", + "integrity": "sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==", + "requires": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "devlop": "^1.0.0", + "hastscript": "^9.0.0", + "property-information": "^7.0.0", + "vfile": "^6.0.0", + "vfile-location": "^5.0.0", + "web-namespaces": "^2.0.0" + } + }, + "hast-util-heading-rank": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-heading-rank/-/hast-util-heading-rank-3.0.0.tgz", + "integrity": "sha512-EJKb8oMUXVHcWZTDepnr+WNbfnXKFNf9duMesmr4S8SXTJBJ9M4Yok08pu9vxdJwdlGRhVumk9mEhkEvKGifwA==", + "requires": { + "@types/hast": "^3.0.0" + } + }, + "hast-util-is-element": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-is-element/-/hast-util-is-element-3.0.0.tgz", + "integrity": "sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==", + "requires": { + "@types/hast": "^3.0.0" + } + }, + "hast-util-parse-selector": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-4.0.0.tgz", + "integrity": "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==", + "requires": { + "@types/hast": "^3.0.0" + } + }, + "hast-util-raw": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/hast-util-raw/-/hast-util-raw-9.1.0.tgz", + "integrity": "sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw==", + "requires": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "@ungap/structured-clone": "^1.0.0", + "hast-util-from-parse5": "^8.0.0", + "hast-util-to-parse5": "^8.0.0", + "html-void-elements": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "parse5": "^7.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0", + "web-namespaces": "^2.0.0", + "zwitch": "^2.0.0" + } + }, + "hast-util-sanitize": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/hast-util-sanitize/-/hast-util-sanitize-5.0.2.tgz", + "integrity": "sha512-3yTWghByc50aGS7JlGhk61SPenfE/p1oaFeNwkOOyrscaOkMGrcW9+Cy/QAIOBpZxP1yqDIzFMR0+Np0i0+usg==", + "requires": { + "@types/hast": "^3.0.0", + "@ungap/structured-clone": "^1.0.0", + "unist-util-position": "^5.0.0" + } + }, "hast-util-to-jsx-runtime": { "version": "2.3.6", "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", @@ -12206,6 +12706,28 @@ "vfile-message": "^4.0.0" } }, + "hast-util-to-parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/hast-util-to-parse5/-/hast-util-to-parse5-8.0.1.tgz", + "integrity": "sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA==", + "requires": { + "@types/hast": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "web-namespaces": "^2.0.0", + "zwitch": "^2.0.0" + } + }, + "hast-util-to-string": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/hast-util-to-string/-/hast-util-to-string-3.0.1.tgz", + "integrity": "sha512-XelQVTDWvqcl3axRfI0xSeoVKzyIFPwsAGSLIsKdJKQMXDYJS4WYrBNF/8J7RdhIcFI2BOHgAifggsvsxp/3+A==", + "requires": { + "@types/hast": "^3.0.0" + } + }, "hast-util-whitespace": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", @@ -12214,6 +12736,18 @@ "@types/hast": "^3.0.0" } }, + "hastscript": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/hastscript/-/hastscript-9.0.1.tgz", + "integrity": "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==", + "requires": { + "@types/hast": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-parse-selector": "^4.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0" + } + }, "header-case": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/header-case/-/header-case-2.0.4.tgz", @@ -12242,6 +12776,11 @@ "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", "integrity": "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==" }, + "html-void-elements": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz", + "integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==" + }, "http-proxy-agent": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", @@ -12372,6 +12911,11 @@ "is-windows": "^1.0.1" } }, + "is-absolute-url": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/is-absolute-url/-/is-absolute-url-4.0.1.tgz", + "integrity": "sha512-/51/TKE88Lmm7Gc4/8btclNXWS+g50wXhYJq8HWIBAGUBnoAdRu1aXeh364t/O7wXDAcTJDP8PNuNKWUDWie+A==" + }, "is-alphabetical": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", @@ -13288,6 +13832,17 @@ "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", "dev": true }, + "node-emoji": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/node-emoji/-/node-emoji-2.2.0.tgz", + "integrity": "sha512-Z3lTE9pLaJF47NyMhd4ww1yFTAP8YhYI8SleJiHzM46Fgpm5cnNzSl9XfzFNqbaz+VlJrIj3fXQ4DeN1Rjm6cw==", + "requires": { + "@sindresorhus/is": "^4.6.0", + "char-regex": "^1.0.2", + "emojilib": "^2.4.0", + "skin-tone": "^2.0.0" + } + }, "node-fetch": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", @@ -13436,6 +13991,14 @@ "lines-and-columns": "^1.1.6" } }, + "parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "requires": { + "entities": "^6.0.0" + } + }, "pascal-case": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/pascal-case/-/pascal-case-3.1.2.tgz", @@ -13714,6 +14277,75 @@ "integrity": "sha512-7kRDOuLHB87D/JESKxQoRwv4DzbIdwkAGQ7p6QKGdVlY1IZheUnVhlk/4UZlNUVxdAXpyxikE3URsG067ybVzw==", "requires": {} }, + "rehype-autolink-headings": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/rehype-autolink-headings/-/rehype-autolink-headings-7.1.0.tgz", + "integrity": "sha512-rItO/pSdvnvsP4QRB1pmPiNHUskikqtPojZKJPPPAVx9Hj8i8TwMBhofrrAYRhYOOBZH9tgmG5lPqDLuIWPWmw==", + "requires": { + "@types/hast": "^3.0.0", + "@ungap/structured-clone": "^1.0.0", + "hast-util-heading-rank": "^3.0.0", + "hast-util-is-element": "^3.0.0", + "unified": "^11.0.0", + "unist-util-visit": "^5.0.0" + } + }, + "rehype-external-links": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/rehype-external-links/-/rehype-external-links-3.0.0.tgz", + "integrity": "sha512-yp+e5N9V3C6bwBeAC4n796kc86M4gJCdlVhiMTxIrJG5UHDMh+PJANf9heqORJbt1nrCbDwIlAZKjANIaVBbvw==", + "requires": { + "@types/hast": "^3.0.0", + "@ungap/structured-clone": "^1.0.0", + "hast-util-is-element": "^3.0.0", + "is-absolute-url": "^4.0.0", + "space-separated-tokens": "^2.0.0", + "unist-util-visit": "^5.0.0" + } + }, + "rehype-raw": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/rehype-raw/-/rehype-raw-7.0.0.tgz", + "integrity": "sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==", + "requires": { + "@types/hast": "^3.0.0", + "hast-util-raw": "^9.0.0", + "vfile": "^6.0.0" + } + }, + "rehype-sanitize": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/rehype-sanitize/-/rehype-sanitize-6.0.0.tgz", + "integrity": "sha512-CsnhKNsyI8Tub6L4sm5ZFsme4puGfc6pYylvXo1AeqaGbjOYyzNv3qZPwvs0oMJ39eryyeOdmxwUIo94IpEhqg==", + "requires": { + "@types/hast": "^3.0.0", + "hast-util-sanitize": "^5.0.0" + } + }, + "rehype-slug": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/rehype-slug/-/rehype-slug-6.0.0.tgz", + "integrity": "sha512-lWyvf/jwu+oS5+hL5eClVd3hNdmwM1kAC0BUvEGD19pajQMIzcNUd/k9GsfQ+FfECvX+JE+e9/btsKH0EjJT6A==", + "requires": { + "@types/hast": "^3.0.0", + "github-slugger": "^2.0.0", + "hast-util-heading-rank": "^3.0.0", + "hast-util-to-string": "^3.0.0", + "unist-util-visit": "^5.0.0" + } + }, + "remark-emoji": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/remark-emoji/-/remark-emoji-5.0.2.tgz", + "integrity": "sha512-IyIqGELcyK5AVdLFafoiNww+Eaw/F+rGrNSXoKucjo95uL267zrddgxGM83GN1wFIb68pyDuAsY3m5t2Cav1pQ==", + "requires": { + "@types/mdast": "^4.0.4", + "emoticon": "^4.0.1", + "mdast-util-find-and-replace": "^3.0.1", + "node-emoji": "^2.1.3", + "unified": "^11.0.4" + } + }, "remark-gfm": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", @@ -13931,6 +14563,14 @@ "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", "dev": true }, + "skin-tone": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/skin-tone/-/skin-tone-2.0.0.tgz", + "integrity": "sha512-kUMbT1oBJCpgrnKoSr0o6wPtvRWT9W9UKvGLwfJYO2WuahZRHOpEyL1ckyMGgMWh0UdpmaoFqKKD29WTomNEGA==", + "requires": { + "unicode-emoji-modifier-base": "^1.0.0" + } + }, "slash": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", @@ -14293,6 +14933,11 @@ "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", "dev": true }, + "unicode-emoji-modifier-base": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unicode-emoji-modifier-base/-/unicode-emoji-modifier-base-1.0.0.tgz", + "integrity": "sha512-yLSH4py7oFH3oG/9K+XWrz1pSi3dfUrWEnInbxMfArOfc1+33BlGPQtLsOYwvdMy11AwUBetYuaRxSPqgkq+8g==" + }, "unified": { "version": "11.0.5", "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", @@ -14447,6 +15092,15 @@ "vfile-message": "^4.0.0" } }, + "vfile-location": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/vfile-location/-/vfile-location-5.0.3.tgz", + "integrity": "sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==", + "requires": { + "@types/unist": "^3.0.0", + "vfile": "^6.0.0" + } + }, "vfile-message": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", @@ -14495,6 +15149,11 @@ "defaults": "^1.0.3" } }, + "web-namespaces": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz", + "integrity": "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==" + }, "web-streams-polyfill": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", diff --git a/webui2/package.json b/webui2/package.json index bd18a3d03f4453205a0bdb6df744524b009fade2..2137bc51ca2df6d7ac6474517fcbfe2b3f062703 100644 --- a/webui2/package.json +++ b/webui2/package.json @@ -26,6 +26,12 @@ "react-dom": "^19.1.0", "react-markdown": "^9.0.1", "react-router-dom": "^6.28.0", + "rehype-autolink-headings": "^7.1.0", + "rehype-external-links": "^3.0.0", + "rehype-raw": "^7.0.0", + "rehype-sanitize": "^6.0.0", + "rehype-slug": "^6.0.0", + "remark-emoji": "^5.0.2", "remark-gfm": "^4.0.0", "tailwind-merge": "^2.5.5", "tailwindcss-animate": "^1.0.7" diff --git a/webui2/src/components/content/Markdown.tsx b/webui2/src/components/content/Markdown.tsx index c5117901cb75aa10f96d2b5012cbe508d45b55a8..43f05235cf3aca507085f42ee1b23f2bb70adbff 100644 --- a/webui2/src/components/content/Markdown.tsx +++ b/webui2/src/components/content/Markdown.tsx @@ -1,7 +1,32 @@ import ReactMarkdown from 'react-markdown' import remarkGfm from 'remark-gfm' +import remarkEmoji from 'remark-emoji' +import rehypeRaw from 'rehype-raw' +import rehypeSanitize, { defaultSchema } from 'rehype-sanitize' +import rehypeSlug from 'rehype-slug' +import rehypeAutolinkHeadings from 'rehype-autolink-headings' +import rehypeExternalLinks from 'rehype-external-links' import { cn } from '@/lib/utils' +// Sanitization schema: start from the safe default and allow a small set of +// presentational/structural HTML tags commonly found in READMEs. +// Script, style, iframe, object, embed and event-handler attributes are +// blocked by the default schema and remain blocked. +// rehype-autolink-headings injects with aria-hidden and class, so we +// allow those attributes on anchors. +const sanitizeSchema = { + ...defaultSchema, + tagNames: [ + ...(defaultSchema.tagNames ?? []), + 'details', 'summary', 'picture', 'source', + ], + attributes: { + ...defaultSchema.attributes, + a: [...(defaultSchema.attributes?.a ?? []), 'aria-hidden', 'class'], + '*': [...(defaultSchema.attributes?.['*'] ?? []), 'id'], + }, +} + interface MarkdownProps { content: string className?: string @@ -12,11 +37,19 @@ interface MarkdownProps { export function Markdown({ content, className }: MarkdownProps) { return ( diff --git a/webui2/src/index.css b/webui2/src/index.css index a4fd94d5c86e9861b8e354c82dd6d9c5be30336b..d06470cac6a28d7d6783d2f55debb8e110a57200 100644 --- a/webui2/src/index.css +++ b/webui2/src/index.css @@ -32,25 +32,25 @@ } .dark { - /* Dimmed dark — comfortable grey with blue-tinted accents. */ - --background: 220 13% 13%; - --foreground: 220 12% 84%; - --card: 220 13% 16%; - --card-foreground: 220 12% 84%; - --popover: 220 13% 16%; - --popover-foreground: 220 12% 84%; + /* Softer dark — background lifted slightly, text dimmed to reduce glare. */ + --background: 220 13% 15%; + --foreground: 220 10% 72%; + --card: 220 13% 18%; + --card-foreground: 220 10% 72%; + --popover: 220 13% 18%; + --popover-foreground: 220 10% 72%; --primary: 213 88% 62%; --primary-foreground: 220 20% 10%; - --secondary: 220 12% 22%; - --secondary-foreground: 220 12% 84%; - --muted: 220 12% 22%; - --muted-foreground: 220 8% 55%; - --accent: 220 20% 26%; + --secondary: 220 12% 24%; + --secondary-foreground: 220 10% 72%; + --muted: 220 12% 24%; + --muted-foreground: 220 8% 52%; + --accent: 220 20% 28%; --accent-foreground: 213 88% 72%; --destructive: 0 65% 50%; --destructive-foreground: 0 0% 98%; - --border: 220 12% 28%; - --input: 220 12% 28%; + --border: 220 12% 26%; + --input: 220 12% 26%; --ring: 213 88% 62%; } } diff --git a/webui2/src/pages/CodePage.tsx b/webui2/src/pages/CodePage.tsx index a649034ae8fbad2d7b31bebd948b3403472b0951..38a2e92926ba86d32ac42e679ed7b8d4244e3cc1 100644 --- a/webui2/src/pages/CodePage.tsx +++ b/webui2/src/pages/CodePage.tsx @@ -1,7 +1,7 @@ import { useState, useEffect } from 'react' import { useSearchParams } from 'react-router-dom' import { gql, useQuery } from '@apollo/client' -import { AlertCircle, GitCommit } from 'lucide-react' +import { AlertCircle, Check, Copy, GitCommit } from 'lucide-react' import { CodeBreadcrumb } from '@/components/code/CodeBreadcrumb' import { RefSelector } from '@/components/code/RefSelector' import { FileTree } from '@/components/code/FileTree' @@ -12,6 +12,7 @@ import { Button } from '@/components/ui/button' import { getRefs, getTree, getBlob } from '@/lib/gitApi' import type { GitRef, GitTreeEntry, GitBlob } from '@/lib/gitApi' import { useRepo } from '@/lib/repo' +import { Markdown } from '@/components/content/Markdown' const REPO_NAME_QUERY = gql` query RepoName($ref: String) { @@ -35,6 +36,7 @@ export function CodePage() { const [entries, setEntries] = useState([]) const [blob, setBlob] = useState(null) + const [readme, setReadme] = useState(null) const [contentLoading, setContentLoading] = useState(false) const currentRef = searchParams.get('ref') ?? '' @@ -67,11 +69,26 @@ export function CodePage() { setContentLoading(true) setEntries([]) setBlob(null) + setReadme(null) const load = viewMode === 'blob' ? getBlob(currentRef, currentPath).then((b) => setBlob(b)) - : getTree(currentRef, currentPath).then((e) => setEntries(e)) + : getTree(currentRef, currentPath).then((e) => { + setEntries(e) + const readmeEntry = e.find((entry) => + entry.type === 'blob' && + /^readme(\.md|\.txt|\.rst)?$/i.test(entry.name), + ) + if (readmeEntry) { + const readmePath = currentPath + ? `${currentPath}/${readmeEntry.name}` + : readmeEntry.name + getBlob(currentRef, readmePath) + .then((b) => !b.isBinary && setReadme(b.content)) + .catch(() => {/* best-effort */}) + } + }) load .catch((e: Error) => setError(e.message)) @@ -107,7 +124,17 @@ export function CodePage() { } const { data: repoData } = useQuery(REPO_NAME_QUERY, { variables: { ref: repo } }) - const repoName = repoData?.repository?.name ?? repo ?? 'git-bug' + const repoName = repoData?.repository?.name ?? repo ?? 'default-repo' + + const cloneUrl = `${window.location.origin}/api/repos/_/_` + const cloneCmd = `git clone ${cloneUrl} ${repoName}` + const [copied, setCopied] = useState(false) + function handleCopy() { + navigator.clipboard.writeText(cloneCmd).then(() => { + setCopied(true) + setTimeout(() => setCopied(false), 1500) + }) + } if (error) { return ( @@ -160,17 +187,38 @@ export function CodePage() { + {/* Clone command */} +
+ clone + {cloneCmd} + +
+ {/* Content */} {viewMode === 'commits' ? ( ) : viewMode === 'tree' || !blob ? ( - + <> + + {readme && ( +
+
+ README +
+
+ +
+
+ )} + ) : ( )}