aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--.eslintrc.cjs3
-rw-r--r--modules/fileicon/entry.go10
-rw-r--r--modules/fileicon/material.go3
-rw-r--r--modules/fileicon/material_test.go8
-rw-r--r--modules/git/commit.go3
-rw-r--r--modules/git/error.go16
-rw-r--r--modules/git/tree_blob_nogogit.go36
-rw-r--r--modules/git/tree_entry.go84
-rw-r--r--modules/git/tree_entry_common_test.go76
-rw-r--r--modules/git/tree_entry_gogit.go10
-rw-r--r--modules/git/tree_entry_mode.go4
-rw-r--r--modules/git/tree_entry_nogogit.go2
-rw-r--r--modules/git/tree_entry_test.go47
-rw-r--r--modules/git/tree_gogit.go3
-rw-r--r--modules/util/error.go4
-rw-r--r--options/locale/locale_en-US.ini3
-rw-r--r--options/locale/locale_ga-IE.ini1
-rw-r--r--options/locale/locale_zh-CN.ini66
-rw-r--r--package-lock.json13
-rw-r--r--package.json1
-rw-r--r--routers/web/repo/issue_view.go4
-rw-r--r--routers/web/repo/treelist.go3
-rw-r--r--routers/web/repo/view.go5
-rw-r--r--routers/web/repo/view_home.go25
-rw-r--r--routers/web/repo/view_readme.go61
-rw-r--r--services/repository/files/tree.go2
-rw-r--r--templates/repo/branch/list.tmpl12
-rw-r--r--templates/repo/issue/sidebar/wip_switch.tmpl2
-rw-r--r--templates/repo/issue/view_content/pull_merge_box.tmpl2
-rw-r--r--templates/repo/view_list.tmpl3
-rw-r--r--tests/integration/repo_test.go17
-rw-r--r--web_src/js/bootstrap.ts3
-rw-r--r--web_src/js/components/ViewFileTreeStore.ts3
-rw-r--r--web_src/js/features/common-button.ts28
-rw-r--r--web_src/js/features/comp/ConfirmModal.ts24
-rw-r--r--web_src/js/features/comp/EditorUpload.ts2
-rw-r--r--web_src/js/features/comp/LabelEdit.ts1
-rw-r--r--web_src/js/features/comp/SearchUserBox.ts2
-rw-r--r--web_src/js/features/dropzone.ts6
-rw-r--r--web_src/js/features/emoji.ts6
-rw-r--r--web_src/js/features/file-view.ts4
-rw-r--r--web_src/js/features/repo-editor.ts13
-rw-r--r--web_src/js/features/repo-issue-list.ts8
-rw-r--r--web_src/js/features/repo-issue.ts27
-rw-r--r--web_src/js/features/repo-new.ts2
-rw-r--r--web_src/js/features/repo-wiki.ts3
-rw-r--r--web_src/js/features/tribute.ts13
-rw-r--r--web_src/js/markup/html2markdown.ts8
-rw-r--r--web_src/js/markup/mermaid.ts3
-rw-r--r--web_src/js/modules/fomantic/modal.ts28
-rw-r--r--web_src/js/modules/tippy.ts3
-rw-r--r--web_src/js/modules/toast.ts2
-rw-r--r--web_src/js/svg.ts3
-rw-r--r--web_src/js/utils/dom.ts1
-rw-r--r--web_src/js/utils/html.test.ts8
-rw-r--r--web_src/js/utils/html.ts32
56 files changed, 453 insertions, 309 deletions
diff --git a/.eslintrc.cjs b/.eslintrc.cjs
index f9e1050240..57c6b19600 100644
--- a/.eslintrc.cjs
+++ b/.eslintrc.cjs
@@ -91,6 +91,7 @@ module.exports = {
plugins: ['@vitest/eslint-plugin'],
globals: vitestPlugin.environments.env.globals,
rules: {
+ 'github/unescaped-html-literal': [0],
'@vitest/consistent-test-filename': [0],
'@vitest/consistent-test-it': [0],
'@vitest/expect-expect': [0],
@@ -423,7 +424,7 @@ module.exports = {
'github/no-useless-passive': [2],
'github/prefer-observers': [2],
'github/require-passive-events': [2],
- 'github/unescaped-html-literal': [0],
+ 'github/unescaped-html-literal': [2],
'grouped-accessor-pairs': [2],
'guard-for-in': [0],
'id-blacklist': [0],
diff --git a/modules/fileicon/entry.go b/modules/fileicon/entry.go
index e4ded363e5..0326c2bfa8 100644
--- a/modules/fileicon/entry.go
+++ b/modules/fileicon/entry.go
@@ -6,17 +6,17 @@ package fileicon
import "code.gitea.io/gitea/modules/git"
type EntryInfo struct {
- FullName string
+ BaseName string
EntryMode git.EntryMode
SymlinkToMode git.EntryMode
IsOpen bool
}
-func EntryInfoFromGitTreeEntry(gitEntry *git.TreeEntry) *EntryInfo {
- ret := &EntryInfo{FullName: gitEntry.Name(), EntryMode: gitEntry.Mode()}
+func EntryInfoFromGitTreeEntry(commit *git.Commit, fullPath string, gitEntry *git.TreeEntry) *EntryInfo {
+ ret := &EntryInfo{BaseName: gitEntry.Name(), EntryMode: gitEntry.Mode()}
if gitEntry.IsLink() {
- if te, err := gitEntry.FollowLink(); err == nil && te.IsDir() {
- ret.SymlinkToMode = te.Mode()
+ if res, err := git.EntryFollowLink(commit, fullPath, gitEntry); err == nil && res.TargetEntry.IsDir() {
+ ret.SymlinkToMode = res.TargetEntry.Mode()
}
}
return ret
diff --git a/modules/fileicon/material.go b/modules/fileicon/material.go
index 449f527ee8..5361592d8a 100644
--- a/modules/fileicon/material.go
+++ b/modules/fileicon/material.go
@@ -5,7 +5,6 @@ package fileicon
import (
"html/template"
- "path"
"strings"
"sync"
@@ -134,7 +133,7 @@ func (m *MaterialIconProvider) FindIconName(entry *EntryInfo) string {
return "folder-git"
}
- fileNameLower := strings.ToLower(path.Base(entry.FullName))
+ fileNameLower := strings.ToLower(entry.BaseName)
if entry.EntryMode.IsDir() {
if s, ok := m.rules.FolderNames[fileNameLower]; ok {
return s
diff --git a/modules/fileicon/material_test.go b/modules/fileicon/material_test.go
index 68353d2189..d2a769eaac 100644
--- a/modules/fileicon/material_test.go
+++ b/modules/fileicon/material_test.go
@@ -20,8 +20,8 @@ func TestMain(m *testing.M) {
func TestFindIconName(t *testing.T) {
unittest.PrepareTestEnv(t)
p := fileicon.DefaultMaterialIconProvider()
- assert.Equal(t, "php", p.FindIconName(&fileicon.EntryInfo{FullName: "foo.php", EntryMode: git.EntryModeBlob}))
- assert.Equal(t, "php", p.FindIconName(&fileicon.EntryInfo{FullName: "foo.PHP", EntryMode: git.EntryModeBlob}))
- assert.Equal(t, "javascript", p.FindIconName(&fileicon.EntryInfo{FullName: "foo.js", EntryMode: git.EntryModeBlob}))
- assert.Equal(t, "visualstudio", p.FindIconName(&fileicon.EntryInfo{FullName: "foo.vba", EntryMode: git.EntryModeBlob}))
+ assert.Equal(t, "php", p.FindIconName(&fileicon.EntryInfo{BaseName: "foo.php", EntryMode: git.EntryModeBlob}))
+ assert.Equal(t, "php", p.FindIconName(&fileicon.EntryInfo{BaseName: "foo.PHP", EntryMode: git.EntryModeBlob}))
+ assert.Equal(t, "javascript", p.FindIconName(&fileicon.EntryInfo{BaseName: "foo.js", EntryMode: git.EntryModeBlob}))
+ assert.Equal(t, "visualstudio", p.FindIconName(&fileicon.EntryInfo{BaseName: "foo.vba", EntryMode: git.EntryModeBlob}))
}
diff --git a/modules/git/commit.go b/modules/git/commit.go
index 1c1648eb8b..ed4876e7b3 100644
--- a/modules/git/commit.go
+++ b/modules/git/commit.go
@@ -20,7 +20,8 @@ import (
// Commit represents a git commit.
type Commit struct {
- Tree
+ Tree // FIXME: bad design, this field can be nil if the commit is from "last commit cache"
+
ID ObjectID // The ID of this commit object
Author *Signature
Committer *Signature
diff --git a/modules/git/error.go b/modules/git/error.go
index 6c86d1b04d..7d131345d0 100644
--- a/modules/git/error.go
+++ b/modules/git/error.go
@@ -32,22 +32,6 @@ func (err ErrNotExist) Unwrap() error {
return util.ErrNotExist
}
-// ErrSymlinkUnresolved entry.FollowLink error
-type ErrSymlinkUnresolved struct {
- Name string
- Message string
-}
-
-func (err ErrSymlinkUnresolved) Error() string {
- return fmt.Sprintf("%s: %s", err.Name, err.Message)
-}
-
-// IsErrSymlinkUnresolved if some error is ErrSymlinkUnresolved
-func IsErrSymlinkUnresolved(err error) bool {
- _, ok := err.(ErrSymlinkUnresolved)
- return ok
-}
-
// ErrBranchNotExist represents a "BranchNotExist" kind of error.
type ErrBranchNotExist struct {
Name string
diff --git a/modules/git/tree_blob_nogogit.go b/modules/git/tree_blob_nogogit.go
index b7bcf40edd..b18d0fa05e 100644
--- a/modules/git/tree_blob_nogogit.go
+++ b/modules/git/tree_blob_nogogit.go
@@ -11,7 +11,7 @@ import (
)
// GetTreeEntryByPath get the tree entries according the sub dir
-func (t *Tree) GetTreeEntryByPath(relpath string) (*TreeEntry, error) {
+func (t *Tree) GetTreeEntryByPath(relpath string) (_ *TreeEntry, err error) {
if len(relpath) == 0 {
return &TreeEntry{
ptree: t,
@@ -21,27 +21,25 @@ func (t *Tree) GetTreeEntryByPath(relpath string) (*TreeEntry, error) {
}, nil
}
- // FIXME: This should probably use git cat-file --batch to be a bit more efficient
relpath = path.Clean(relpath)
parts := strings.Split(relpath, "/")
- var err error
+
tree := t
- for i, name := range parts {
- if i == len(parts)-1 {
- entries, err := tree.ListEntries()
- if err != nil {
- return nil, err
- }
- for _, v := range entries {
- if v.Name() == name {
- return v, nil
- }
- }
- } else {
- tree, err = tree.SubTree(name)
- if err != nil {
- return nil, err
- }
+ for _, name := range parts[:len(parts)-1] {
+ tree, err = tree.SubTree(name)
+ if err != nil {
+ return nil, err
+ }
+ }
+
+ name := parts[len(parts)-1]
+ entries, err := tree.ListEntries()
+ if err != nil {
+ return nil, err
+ }
+ for _, v := range entries {
+ if v.Name() == name {
+ return v, nil
}
}
return nil, ErrNotExist{"", relpath}
diff --git a/modules/git/tree_entry.go b/modules/git/tree_entry.go
index 57856d90ee..5099d8ee79 100644
--- a/modules/git/tree_entry.go
+++ b/modules/git/tree_entry.go
@@ -5,7 +5,7 @@
package git
import (
- "io"
+ "path"
"sort"
"strings"
@@ -24,77 +24,57 @@ func (te *TreeEntry) Type() string {
}
}
-// FollowLink returns the entry pointed to by a symlink
-func (te *TreeEntry) FollowLink() (*TreeEntry, error) {
+type EntryFollowResult struct {
+ SymlinkContent string
+ TargetFullPath string
+ TargetEntry *TreeEntry
+}
+
+func EntryFollowLink(commit *Commit, fullPath string, te *TreeEntry) (*EntryFollowResult, error) {
if !te.IsLink() {
- return nil, ErrSymlinkUnresolved{te.Name(), "not a symlink"}
+ return nil, util.ErrorWrap(util.ErrUnprocessableContent, "%q is not a symlink", fullPath)
}
- // read the link
- r, err := te.Blob().DataAsync()
- if err != nil {
- return nil, err
+ // git's filename max length is 4096, hopefully a link won't be longer than multiple of that
+ const maxSymlinkSize = 20 * 4096
+ if te.Blob().Size() > maxSymlinkSize {
+ return nil, util.ErrorWrap(util.ErrUnprocessableContent, "%q content exceeds symlink limit", fullPath)
}
- closed := false
- defer func() {
- if !closed {
- _ = r.Close()
- }
- }()
- buf := make([]byte, te.Size())
- _, err = io.ReadFull(r, buf)
+
+ link, err := te.Blob().GetBlobContent(maxSymlinkSize)
if err != nil {
return nil, err
}
- _ = r.Close()
- closed = true
-
- lnk := string(buf)
- t := te.ptree
-
- // traverse up directories
- for ; t != nil && strings.HasPrefix(lnk, "../"); lnk = lnk[3:] {
- t = t.ptree
+ if strings.HasPrefix(link, "/") {
+ // It's said that absolute path will be stored as is in Git
+ return &EntryFollowResult{SymlinkContent: link}, util.ErrorWrap(util.ErrUnprocessableContent, "%q is an absolute symlink", fullPath)
}
- if t == nil {
- return nil, ErrSymlinkUnresolved{te.Name(), "points outside of repo"}
- }
-
- target, err := t.GetTreeEntryByPath(lnk)
+ targetFullPath := path.Join(path.Dir(fullPath), link)
+ targetEntry, err := commit.GetTreeEntryByPath(targetFullPath)
if err != nil {
- if IsErrNotExist(err) {
- return nil, ErrSymlinkUnresolved{te.Name(), "broken link"}
- }
- return nil, err
+ return &EntryFollowResult{SymlinkContent: link}, err
}
- return target, nil
+ return &EntryFollowResult{SymlinkContent: link, TargetFullPath: targetFullPath, TargetEntry: targetEntry}, nil
}
-// FollowLinks returns the entry ultimately pointed to by a symlink
-func (te *TreeEntry) FollowLinks(optLimit ...int) (*TreeEntry, error) {
- if !te.IsLink() {
- return nil, ErrSymlinkUnresolved{te.Name(), "not a symlink"}
- }
+func EntryFollowLinks(commit *Commit, firstFullPath string, firstTreeEntry *TreeEntry, optLimit ...int) (res *EntryFollowResult, err error) {
limit := util.OptionalArg(optLimit, 10)
- entry := te
+ treeEntry, fullPath := firstTreeEntry, firstFullPath
for range limit {
- if !entry.IsLink() {
- break
- }
- next, err := entry.FollowLink()
+ res, err = EntryFollowLink(commit, fullPath, treeEntry)
if err != nil {
- return nil, err
+ return res, err
}
- if next.ID == entry.ID {
- return nil, ErrSymlinkUnresolved{entry.Name(), "recursive link"}
+ treeEntry, fullPath = res.TargetEntry, res.TargetFullPath
+ if !treeEntry.IsLink() {
+ break
}
- entry = next
}
- if entry.IsLink() {
- return nil, ErrSymlinkUnresolved{te.Name(), "too many levels of symbolic links"}
+ if treeEntry.IsLink() {
+ return res, util.ErrorWrap(util.ErrUnprocessableContent, "%q has too many links", firstFullPath)
}
- return entry, nil
+ return res, nil
}
// returns the Tree pointed to by this TreeEntry, or nil if this is not a tree
diff --git a/modules/git/tree_entry_common_test.go b/modules/git/tree_entry_common_test.go
new file mode 100644
index 0000000000..8b63bbb993
--- /dev/null
+++ b/modules/git/tree_entry_common_test.go
@@ -0,0 +1,76 @@
+// Copyright 2024 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package git
+
+import (
+ "testing"
+
+ "code.gitea.io/gitea/modules/util"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestFollowLink(t *testing.T) {
+ r, err := openRepositoryWithDefaultContext("tests/repos/repo1_bare")
+ require.NoError(t, err)
+ defer r.Close()
+
+ commit, err := r.GetCommit("37991dec2c8e592043f47155ce4808d4580f9123")
+ require.NoError(t, err)
+
+ // get the symlink
+ {
+ lnkFullPath := "foo/bar/link_to_hello"
+ lnk, err := commit.Tree.GetTreeEntryByPath("foo/bar/link_to_hello")
+ require.NoError(t, err)
+ assert.True(t, lnk.IsLink())
+
+ // should be able to dereference to target
+ res, err := EntryFollowLink(commit, lnkFullPath, lnk)
+ require.NoError(t, err)
+ assert.Equal(t, "hello", res.TargetEntry.Name())
+ assert.Equal(t, "foo/nar/hello", res.TargetFullPath)
+ assert.False(t, res.TargetEntry.IsLink())
+ assert.Equal(t, "b14df6442ea5a1b382985a6549b85d435376c351", res.TargetEntry.ID.String())
+ }
+
+ {
+ // should error when called on a normal file
+ entry, err := commit.Tree.GetTreeEntryByPath("file1.txt")
+ require.NoError(t, err)
+ res, err := EntryFollowLink(commit, "file1.txt", entry)
+ assert.ErrorIs(t, err, util.ErrUnprocessableContent)
+ assert.Nil(t, res)
+ }
+
+ {
+ // should error for broken links
+ entry, err := commit.Tree.GetTreeEntryByPath("foo/broken_link")
+ require.NoError(t, err)
+ assert.True(t, entry.IsLink())
+ res, err := EntryFollowLink(commit, "foo/broken_link", entry)
+ assert.ErrorIs(t, err, util.ErrNotExist)
+ assert.Equal(t, "nar/broken_link", res.SymlinkContent)
+ }
+
+ {
+ // should error for external links
+ entry, err := commit.Tree.GetTreeEntryByPath("foo/outside_repo")
+ require.NoError(t, err)
+ assert.True(t, entry.IsLink())
+ res, err := EntryFollowLink(commit, "foo/outside_repo", entry)
+ assert.ErrorIs(t, err, util.ErrNotExist)
+ assert.Equal(t, "../../outside_repo", res.SymlinkContent)
+ }
+
+ {
+ // testing fix for short link bug
+ entry, err := commit.Tree.GetTreeEntryByPath("foo/link_short")
+ require.NoError(t, err)
+ res, err := EntryFollowLink(commit, "foo/link_short", entry)
+ assert.ErrorIs(t, err, util.ErrNotExist)
+ assert.Equal(t, "a", res.SymlinkContent)
+ }
+}
diff --git a/modules/git/tree_entry_gogit.go b/modules/git/tree_entry_gogit.go
index eb9b012681..e6845f1c77 100644
--- a/modules/git/tree_entry_gogit.go
+++ b/modules/git/tree_entry_gogit.go
@@ -19,16 +19,12 @@ type TreeEntry struct {
gogitTreeEntry *object.TreeEntry
ptree *Tree
- size int64
- sized bool
- fullName string
+ size int64
+ sized bool
}
// Name returns the name of the entry
func (te *TreeEntry) Name() string {
- if te.fullName != "" {
- return te.fullName
- }
return te.gogitTreeEntry.Name
}
@@ -55,7 +51,7 @@ func (te *TreeEntry) Size() int64 {
return te.size
}
-// IsSubModule if the entry is a sub module
+// IsSubModule if the entry is a submodule
func (te *TreeEntry) IsSubModule() bool {
return te.gogitTreeEntry.Mode == filemode.Submodule
}
diff --git a/modules/git/tree_entry_mode.go b/modules/git/tree_entry_mode.go
index d815a8bc2e..f36c07bc2a 100644
--- a/modules/git/tree_entry_mode.go
+++ b/modules/git/tree_entry_mode.go
@@ -15,7 +15,7 @@ type EntryMode int
// one of these.
const (
// EntryModeNoEntry is possible if the file was added or removed in a commit. In the case of
- // added the base commit will not have the file in its tree so a mode of 0o000000 is used.
+ // when adding the base commit doesn't have the file in its tree, a mode of 0o000000 is used.
EntryModeNoEntry EntryMode = 0o000000
EntryModeBlob EntryMode = 0o100644
@@ -30,7 +30,7 @@ func (e EntryMode) String() string {
return strconv.FormatInt(int64(e), 8)
}
-// IsSubModule if the entry is a sub module
+// IsSubModule if the entry is a submodule
func (e EntryMode) IsSubModule() bool {
return e == EntryModeCommit
}
diff --git a/modules/git/tree_entry_nogogit.go b/modules/git/tree_entry_nogogit.go
index 38a768e3a6..8fad96cdf8 100644
--- a/modules/git/tree_entry_nogogit.go
+++ b/modules/git/tree_entry_nogogit.go
@@ -57,7 +57,7 @@ func (te *TreeEntry) Size() int64 {
return te.size
}
-// IsSubModule if the entry is a sub module
+// IsSubModule if the entry is a submodule
func (te *TreeEntry) IsSubModule() bool {
return te.entryMode.IsSubModule()
}
diff --git a/modules/git/tree_entry_test.go b/modules/git/tree_entry_test.go
index 30eee13669..9ca82675e0 100644
--- a/modules/git/tree_entry_test.go
+++ b/modules/git/tree_entry_test.go
@@ -53,50 +53,3 @@ func TestEntriesCustomSort(t *testing.T) {
assert.Equal(t, "bcd", entries[6].Name())
assert.Equal(t, "abc", entries[7].Name())
}
-
-func TestFollowLink(t *testing.T) {
- r, err := openRepositoryWithDefaultContext("tests/repos/repo1_bare")
- assert.NoError(t, err)
- defer r.Close()
-
- commit, err := r.GetCommit("37991dec2c8e592043f47155ce4808d4580f9123")
- assert.NoError(t, err)
-
- // get the symlink
- lnk, err := commit.Tree.GetTreeEntryByPath("foo/bar/link_to_hello")
- assert.NoError(t, err)
- assert.True(t, lnk.IsLink())
-
- // should be able to dereference to target
- target, err := lnk.FollowLink()
- assert.NoError(t, err)
- assert.Equal(t, "hello", target.Name())
- assert.False(t, target.IsLink())
- assert.Equal(t, "b14df6442ea5a1b382985a6549b85d435376c351", target.ID.String())
-
- // should error when called on normal file
- target, err = commit.Tree.GetTreeEntryByPath("file1.txt")
- assert.NoError(t, err)
- _, err = target.FollowLink()
- assert.EqualError(t, err, "file1.txt: not a symlink")
-
- // should error for broken links
- target, err = commit.Tree.GetTreeEntryByPath("foo/broken_link")
- assert.NoError(t, err)
- assert.True(t, target.IsLink())
- _, err = target.FollowLink()
- assert.EqualError(t, err, "broken_link: broken link")
-
- // should error for external links
- target, err = commit.Tree.GetTreeEntryByPath("foo/outside_repo")
- assert.NoError(t, err)
- assert.True(t, target.IsLink())
- _, err = target.FollowLink()
- assert.EqualError(t, err, "outside_repo: points outside of repo")
-
- // testing fix for short link bug
- target, err = commit.Tree.GetTreeEntryByPath("foo/link_short")
- assert.NoError(t, err)
- _, err = target.FollowLink()
- assert.EqualError(t, err, "link_short: broken link")
-}
diff --git a/modules/git/tree_gogit.go b/modules/git/tree_gogit.go
index 421b0ecb0f..272b018ffd 100644
--- a/modules/git/tree_gogit.go
+++ b/modules/git/tree_gogit.go
@@ -69,7 +69,7 @@ func (t *Tree) ListEntriesRecursiveWithSize() (Entries, error) {
seen := map[plumbing.Hash]bool{}
walker := object.NewTreeWalker(t.gogitTree, true, seen)
for {
- fullName, entry, err := walker.Next()
+ _, entry, err := walker.Next()
if err == io.EOF {
break
}
@@ -84,7 +84,6 @@ func (t *Tree) ListEntriesRecursiveWithSize() (Entries, error) {
ID: ParseGogitHash(entry.Hash),
gogitTreeEntry: &entry,
ptree: t,
- fullName: fullName,
}
entries = append(entries, convertedEntry)
}
diff --git a/modules/util/error.go b/modules/util/error.go
index 8e67d5a82f..6b2721618e 100644
--- a/modules/util/error.go
+++ b/modules/util/error.go
@@ -17,8 +17,8 @@ var (
ErrNotExist = errors.New("resource does not exist") // also implies HTTP 404
ErrAlreadyExist = errors.New("resource already exists") // also implies HTTP 409
- // ErrUnprocessableContent implies HTTP 422, syntax of the request content was correct,
- // but server was unable to process the contained instructions
+ // ErrUnprocessableContent implies HTTP 422, the syntax of the request content is correct,
+ // but the server is unable to process the contained instructions
ErrUnprocessableContent = errors.New("unprocessable content")
)
diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini
index 80bf0801e9..f979fc814d 100644
--- a/options/locale/locale_en-US.ini
+++ b/options/locale/locale_en-US.ini
@@ -2769,6 +2769,8 @@ branch.new_branch_from = Create new branch from "%s"
branch.renamed = Branch %s was renamed to %s.
branch.rename_default_or_protected_branch_error = Only admins can rename default or protected branches.
branch.rename_protected_branch_failed = This branch is protected by glob-based protection rules.
+branch.commits_divergence_from = Commits divergence: %[1]d behind and %[2]d ahead of %[3]s
+branch.commits_no_divergence = The same as branch %[1]s
tag.create_tag = Create tag %s
tag.create_tag_operation = Create tag
@@ -2782,6 +2784,7 @@ topic.done = Done
topic.count_prompt = You cannot select more than 25 topics
topic.format_prompt = Topics must start with a letter or number, can include dashes ('-') and dots ('.'), can be up to 35 characters long. Letters must be lowercase.
+find_file.follow_symlink= Follow this symlink to where it is pointing at
find_file.go_to_file = Go to file
find_file.no_matching = No matching file found
diff --git a/options/locale/locale_ga-IE.ini b/options/locale/locale_ga-IE.ini
index f2cfb93294..e8c90d059b 100644
--- a/options/locale/locale_ga-IE.ini
+++ b/options/locale/locale_ga-IE.ini
@@ -1969,6 +1969,7 @@ pulls.cmd_instruction_checkout_title=Seiceáil
pulls.cmd_instruction_checkout_desc=Ó stór tionscadail, seiceáil brainse nua agus déan tástáil ar na hathruithe.
pulls.cmd_instruction_merge_title=Cumaisc
pulls.cmd_instruction_merge_desc=Cumaisc na hathruithe agus nuashonrú ar Gitea.
+pulls.cmd_instruction_merge_warning=Rabhadh: Ní féidir iarratas tarraingthe cumaisc a dhéanamh leis an oibríocht seo mar nach bhfuil "autodetect manual merge" cumasaithe.
pulls.clear_merge_message=Glan an teachtaireacht chumaisc
pulls.clear_merge_message_hint=Má imrítear an teachtaireacht chumaisc ní bhainfear ach ábhar na teachtaireachta tiomanta agus coimeádfar leantóirí git ginte ar nós "Co-Authored-By …".
diff --git a/options/locale/locale_zh-CN.ini b/options/locale/locale_zh-CN.ini
index 548b0d18f1..96a6d518e2 100644
--- a/options/locale/locale_zh-CN.ini
+++ b/options/locale/locale_zh-CN.ini
@@ -420,8 +420,9 @@ remember_me=记住此设备
remember_me.compromised=登录令牌不再有效,因为它可能表明帐户已被破坏。请检查您的帐户是否有异常活动。
forgot_password_title=忘记密码
forgot_password=忘记密码?
-need_account=需要一个帐户?
-sign_up_now=还没账号?马上注册。
+need_account=需要一个帐户?
+sign_up_tip=您正在系统中注册第一个帐户,它拥有管理员权限。请仔细记住您的用户名和密码。 如果您忘记了用户名或密码,请参阅 Gitea 文档以恢复账户。
+sign_up_now=立即注册。
sign_up_successful=帐户创建成功。欢迎!
confirmation_mail_sent_prompt_ex=一封新的确认邮件已经发送到 <b>%s</b>。请在下一个 %s 中检查您的收件箱以完成注册流程。 如果您的注册邮箱地址不正确,您可以重新登录并更改它。
must_change_password=更新您的密码
@@ -485,7 +486,7 @@ sspi_auth_failed=SSPI 认证失败
password_pwned=此密码出现在 <a target="_blank" rel="noopener noreferrer" href="%s">被盗密码</a> 列表上并且曾经被公开。 请使用另一个密码再试一次。
password_pwned_err=无法完成对 HaveIBeenPwned 的请求
last_admin=您不能删除最后一个管理员。必须至少保留一个管理员。
-signin_passkey=使用密钥登录
+signin_passkey=使用通行密钥登录
back_to_sign_in=返回登录页面
[mail]
@@ -518,7 +519,7 @@ register_success=注册成功
issue_assigned.pull=@%[1]s 已将仓库 %[3]s 中的合并请求 %[2]s 指派给您
issue_assigned.issue=@%[1]s 已将仓库 %[3]s 中的工单 %[2]s 指派给您
-issue.x_mentioned_you=<b>@%s</b> 提到了您:
+issue.x_mentioned_you=<b>@%s</b> 提及了您:
issue.action.force_push=<b>%[1]s</b> 强制从 %[3]s 推送 <b>%[2]s</b> 至 [4]s。
issue.action.push_1=<b>@%[1]s</b> 推送了 %[3]d 个提交到 %[2]s
issue.action.push_n=<b>@%[1]s</b> 推送了 %[3]d 个提交到 %[2]s
@@ -838,7 +839,7 @@ ssh_desc=这些 SSH 公钥已经关联到您的账号。相应的私钥拥有完
principal_desc=这些 SSH 证书规则已关联到您的账号将允许完全访问您所有仓库。
gpg_desc=这些 GPG 公钥已经关联到您的账号。请妥善保管您的私钥因为他们将被用于认证提交。
ssh_helper=<strong>需要帮助?</strong> 请查看有关 <a href="%s">如何生成 SSH 密钥</a> 或 <a href="%s">常见 SSH 问题</a> 寻找答案。
-gpg_helper=<strong>需要帮助吗?</strong>看一看 GitHub <a href="%s">关于 GPG</a> 的指导。
+gpg_helper=<strong>需要帮助?</strong>看一看 GitHub <a href="%s">关于 GPG</a> 的指导。
add_new_key=增加 SSH 密钥
add_new_gpg_key=添加的 GPG 密钥
key_content_ssh_placeholder=以 'ssh-ed25519'、 'ssh-rsa'、 'ecdsa-sha2-nistp256'、'ecdsa-sha2-nistp384'、'ecdsa-sha2-nistp521'、 'sk-ecdsa-sha2-nistp256@openssh.com' 或 'sk-ssh-ed25519@openssh.com' 开头
@@ -1016,10 +1017,10 @@ delete_account_title=删除当前帐户
delete_account_desc=确实要永久删除此用户帐户吗?
email_notifications.enable=启用邮件通知
-email_notifications.onmention=只在被提到时邮件通知
+email_notifications.onmention=仅被提及时通知
email_notifications.disable=停用邮件通知
-email_notifications.submit=邮件通知设置
-email_notifications.andyourown=和您自己的通知
+email_notifications.submit=设置邮件通知
+email_notifications.andyourown=仅与您相关的通知
visibility=用户可见性
visibility.public=公开
@@ -1061,6 +1062,7 @@ fork_no_valid_owners=这个代码仓库无法被派生,因为没有有效的
fork.blocked_user=无法克隆仓库,因为您被仓库所有者屏蔽。
use_template=使用此模板
open_with_editor=用 %s 打开
+
download_zip=下载 ZIP
download_tar=下载 TAR.GZ
download_bundle=下载 BUNDLE
@@ -1070,12 +1072,12 @@ repo_desc=描述
repo_desc_helper=输入简要描述 (可选)
repo_no_desc=无详细信息
repo_lang=语言
-repo_gitignore_helper=选择 .gitignore 模板。
+repo_gitignore_helper=选择 .gitignore 模板
repo_gitignore_helper_desc=从常见语言的模板列表中选择忽略跟踪的文件。默认情况下,由开发或构建工具生成的特殊文件都包含在 .gitignore 中。
issue_labels=工单标签
issue_labels_helper=选择一个工单标签集
license=授权许可
-license_helper=选择授权许可文件。
+license_helper=选择授权许可文件
license_helper_desc=许可证说明了其他人可以和不可以用您的代码做什么。不确定哪一个适合您的项目?见 <a target="_blank" rel="noopener noreferrer" href="%s">选择一个许可证</a>
multiple_licenses=多许可证
object_format=对象格式
@@ -1228,6 +1230,7 @@ migrate.migrating_issues=迁移工单
migrate.migrating_pulls=迁移合并请求
migrate.cancel_migrating_title=取消迁移
migrate.cancel_migrating_confirm=您想要取消此次迁移吗?
+migration_status=迁移状态
mirror_from=镜像自地址
forked_from=派生自
@@ -1353,6 +1356,7 @@ editor.update=更新 %s
editor.delete=删除 %s
editor.patch=应用补丁
editor.patching=打补丁:
+editor.fail_to_apply_patch=无法应用补丁
editor.new_patch=新补丁
editor.commit_message_desc=添加一个可选的扩展描述...
editor.signoff_desc=在提交日志消息末尾添加签署人信息。
@@ -1372,6 +1376,7 @@ editor.branch_already_exists=此仓库已存在名为「%s」的分支。
editor.directory_is_a_file=目录名「%s」已作为文件名在此仓库中存在。
editor.file_is_a_symlink=`「%s」是一个符号链接,无法在 Web 编辑器中编辑`
editor.filename_is_a_directory=文件名「%s」已作为目录名在此仓库中存在。
+editor.file_modifying_no_longer_exists=正在修改的文件「%s」已不存在于此仓库。
editor.file_changed_while_editing=文件内容在您进行编辑时已经发生变动。<a target="_blank" rel="noopener noreferrer" href="%s">单击此处</a> 查看变动的具体内容,或者 <strong>再次提交</strong> 覆盖已发生的变动。
editor.file_already_exists=此仓库已经存在名为「%s」的文件。
editor.commit_id_not_matching=提交 ID 与您开始编辑时的 ID 不匹配。请提交到补丁分支然后合并。
@@ -1392,7 +1397,15 @@ editor.user_no_push_to_branch=用户不能推送到分支
editor.require_signed_commit=分支需要签名提交
editor.cherry_pick=拣选提交 %s 到:
editor.revert=将 %s 还原到:
+editor.failed_to_commit=提交更改失败。
+editor.failed_to_commit_summary=错误信息:
+editor.fork_create=派生仓库发起请求变更
+editor.fork_create_description=您不能直接编辑此仓库。您可以从此仓库派生,进行编辑并创建一个拉取请求。
+editor.fork_edit_description=您不能直接编辑此仓库。 更改将写入您的派生仓库 <b>%s</b>,以便您可以创建一个拉取请求。
+editor.fork_not_editable=你已经派生了这个仓库,但是你的分叉是不可编辑的。
+editor.fork_failed_to_push_branch=推送分支 %s 到仓库失败。
+editor.fork_branch_exists=分支 "%s" 已存在于您的派生仓库中,请选择一个新的分支名称。
commits.desc=浏览代码修改历史
commits.commits=次代码提交
@@ -1714,6 +1727,8 @@ issues.remove_time_estimate_at=删除预估时间 %s
issues.time_estimate_invalid=预计时间格式无效
issues.start_tracking_history=`开始工作 %s`
issues.tracker_auto_close=当此工单关闭时,自动停止计时器
+issues.stopwatch_already_stopped=此工单的计时器已经停止
+issues.stopwatch_already_created=此工单的计时器已经存在
issues.tracking_already_started=`您已经开始对 <a href="%s">另一个工单</a> 进行时间跟踪!`
issues.stop_tracking=停止计时器
issues.stop_tracking_history=工作 <b>%[1]s</b> 于 %[2]s 停止
@@ -1955,6 +1970,7 @@ pulls.cmd_instruction_checkout_title=检出
pulls.cmd_instruction_checkout_desc=从您的仓库中检出一个新的分支并测试变更。
pulls.cmd_instruction_merge_title=合并
pulls.cmd_instruction_merge_desc=合并变更并更新到 Gitea 上
+pulls.cmd_instruction_merge_warning=警告:此操作不能合并该合并请求,因为「自动检测手动合并」未启用
pulls.clear_merge_message=清除合并信息
pulls.clear_merge_message_hint=清除合并消息只会删除提交消息内容,并保留生成的 Git 附加内容,如「Co-Authored-By…」。
@@ -2150,6 +2166,7 @@ settings.collaboration.write=可写权限
settings.collaboration.read=可读权限
settings.collaboration.owner=所有者
settings.collaboration.undefined=未定义
+settings.collaboration.per_unit=单元权限
settings.hooks=Web 钩子
settings.githooks=管理 Git 钩子
settings.basic_settings=基本设置
@@ -2368,6 +2385,7 @@ settings.event_repository=仓库
settings.event_repository_desc=创建或删除仓库
settings.event_header_issue=工单事件
settings.event_issues=工单
+settings.event_issues_desc=工单已打开、已关闭、已重新打开或已编辑。
settings.event_issue_assign=工单已指派
settings.event_issue_assign_desc=工单已指派或取消指派。
settings.event_issue_label=工单增删标签
@@ -2378,6 +2396,7 @@ settings.event_issue_comment=工单评论
settings.event_issue_comment_desc=工单评论已创建、编辑或删除。
settings.event_header_pull_request=合并请求事件
settings.event_pull_request=合并请求
+settings.event_pull_request_desc=合并请求已打开、关闭、重新打开或编辑。
settings.event_pull_request_assign=合并请求已指派
settings.event_pull_request_assign_desc=合并请求已指派或取消指派。
settings.event_pull_request_label=合并请求增删标签
@@ -2395,6 +2414,8 @@ settings.event_pull_request_review_request_desc=合并请求评审已请求或
settings.event_pull_request_approvals=合并请求批准
settings.event_pull_request_merge=合并请求合并
settings.event_header_workflow=工作流程事件
+settings.event_workflow_run=工作流运行
+settings.event_workflow_run_desc=Gitea 工作流队列中、等待中、正在进行或已完成的任务。
settings.event_workflow_job=工作流任务
settings.event_workflow_job_desc=Gitea 工作流队列中、等待中、正在进行或已完成的任务。
settings.event_package=软件包
@@ -2773,7 +2794,7 @@ error.broken_git_hook=此仓库的 Git 钩子似乎已损坏。 请按照 <a tar
[graphs]
component_loading=正在加载 %s...
component_loading_failed=无法加载 %s
-component_loading_info=这可能需要一点…
+component_loading_info=这可能需要一点时间…
component_failed_to_load=意外的错误发生了。
code_frequency.what=代码频率
contributors.what=贡献
@@ -2802,6 +2823,7 @@ team_permission_desc=权限
team_unit_desc=允许访问仓库单元
team_unit_disabled=(已禁用)
+form.name_been_taken=组织名称「%s」已经被占用。
form.name_reserved=组织名称「%s」是保留的。
form.name_pattern_not_allowed=组织名中不允许使用「%s」格式。
form.create_org_not_allowed=此账号禁止创建组织
@@ -2824,12 +2846,27 @@ settings.visibility.private_shortname=私有
settings.update_settings=更新组织设置
settings.update_setting_success=组织设置已更新。
+settings.rename=修改组织名称
+settings.rename_desc=更改组织名称同时会更改组织的 URL 地址并释放旧的名称。
+settings.rename_success=组织 %[1]s 已成功重命名为 %[2]s。
+settings.rename_no_change=组织名称没有变化。
+settings.rename_new_org_name=新组织名称
+settings.rename_failed=由于内部错误,重命名组织失败
+settings.rename_notices_1=此操作 <strong>无法</strong> 被回滚。
+settings.rename_notices_2=在被人使用前,旧名称将会被重定向。
settings.update_avatar_success=组织头像已经更新。
settings.delete=删除组织
settings.delete_account=删除当前组织
-settings.delete_prompt=删除操作会永久清除该组织的信息,并且 <strong>不可恢复</strong>!
+settings.delete_prompt=删除操作会永久清除该组织的信息,并且 <strong>无法</strong> 恢复!
+settings.name_confirm=输入组织名称以确认:
+settings.delete_notices_1=此操作 <strong>无法</strong> 被回滚。
+settings.delete_notices_2=此操作将永久删除 <strong>%s</strong> 的所有<strong>仓库</strong>,包括 Git 数据、 工单、评论、百科和协作者的操作权限。
+settings.delete_notices_3=此操作将永久删除 <strong>%s</strong> 的所有 <strong>软件包</strong>。
+settings.delete_notices_4=此操作将永久删除 <strong>%s</strong> 的所有 <strong>项目</strong>。
settings.confirm_delete_account=确认删除组织
+settings.delete_failed=由于内部错误,删除组织失败
+settings.delete_successful=组织 <b>%s</b> 已成功删除。
settings.hooks_desc=在此处添加的 Web 钩子将会应用到该组织下的 <strong>所有仓库</strong>。
settings.labels_desc=添加能够被该组织下的 <strong>所有仓库</strong> 的工单使用的标签。
@@ -3720,8 +3757,8 @@ none=还没有密钥。
; These keys are also for "edit secret", the keys are kept as-is to avoid unnecessary re-translation
creation.description=组织描述
creation.name_placeholder=不区分大小写,仅限字母数字或下划线且不能以 GITEA_ 或 GITHUB_ 开头
-creation.value_placeholder=输入任何内容,开头和结尾的空白将会被忽略。
-creation.description_placeholder=输入简短描述(可选)。
+creation.value_placeholder=输入任何内容,开头和结尾的空白将会被忽略
+creation.description_placeholder=输入简短描述(可选)
save_success=密钥「%s」保存成功。
save_failed=密钥保存失败。
@@ -3806,6 +3843,7 @@ runs.no_runs=工作流尚未运行过。
runs.empty_commit_message=(空白的提交消息)
runs.expire_log_message=旧的日志已清除。
runs.delete=删除工作流运行
+runs.cancel=取消工作流运行
runs.delete.description=您确定要永久删除此工作流运行吗?此操作无法撤消。
runs.not_done=此工作流运行尚未完成。
runs.view_workflow_file=查看工作流文件
diff --git a/package-lock.json b/package-lock.json
index 132efb8635..8361199086 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -28,7 +28,6 @@
"dropzone": "6.0.0-beta.2",
"easymde": "2.20.0",
"esbuild-loader": "4.3.0",
- "escape-goat": "4.0.0",
"fast-glob": "3.3.3",
"htmx.org": "2.0.6",
"idiomorph": "0.7.3",
@@ -6563,18 +6562,6 @@
"node": ">=6"
}
},
- "node_modules/escape-goat": {
- "version": "4.0.0",
- "resolved": "https://registry.npmjs.org/escape-goat/-/escape-goat-4.0.0.tgz",
- "integrity": "sha512-2Sd4ShcWxbx6OY1IHyla/CVNwvg7XwZVoXZHcSu9w9SReNP1EzzD5T8NWKIR38fIqEns9kDWKUQTXXAmlDrdPg==",
- "license": "MIT",
- "engines": {
- "node": ">=12"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
"node_modules/escape-string-regexp": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
diff --git a/package.json b/package.json
index c8a48bb5d9..fc620bc986 100644
--- a/package.json
+++ b/package.json
@@ -27,7 +27,6 @@
"dropzone": "6.0.0-beta.2",
"easymde": "2.20.0",
"esbuild-loader": "4.3.0",
- "escape-goat": "4.0.0",
"fast-glob": "3.3.3",
"htmx.org": "2.0.6",
"idiomorph": "0.7.3",
diff --git a/routers/web/repo/issue_view.go b/routers/web/repo/issue_view.go
index 2897652d51..d4458ed19e 100644
--- a/routers/web/repo/issue_view.go
+++ b/routers/web/repo/issue_view.go
@@ -443,6 +443,10 @@ func ViewPullMergeBox(ctx *context.Context) {
preparePullViewPullInfo(ctx, issue)
preparePullViewReviewAndMerge(ctx, issue)
ctx.Data["PullMergeBoxReloading"] = issue.PullRequest.IsChecking()
+
+ // TODO: it should use a dedicated struct to render the pull merge box, to make sure all data is prepared correctly
+ ctx.Data["IsIssuePoster"] = ctx.IsSigned && issue.IsPoster(ctx.Doer.ID)
+ ctx.Data["HasIssuesOrPullsWritePermission"] = ctx.Repo.CanWriteIssuesOrPulls(issue.IsPull)
ctx.HTML(http.StatusOK, tplPullMergeBox)
}
diff --git a/routers/web/repo/treelist.go b/routers/web/repo/treelist.go
index 0248a0627b..7d7f5a1473 100644
--- a/routers/web/repo/treelist.go
+++ b/routers/web/repo/treelist.go
@@ -6,6 +6,7 @@ package repo
import (
"html/template"
"net/http"
+ "path"
"strings"
pull_model "code.gitea.io/gitea/models/pull"
@@ -111,7 +112,7 @@ func transformDiffTreeForWeb(renderedIconPool *fileicon.RenderedIconPool, diffTr
item := &WebDiffFileItem{FullName: file.HeadPath, DiffStatus: file.Status}
item.IsViewed = filesViewedState[item.FullName] == pull_model.Viewed
item.NameHash = git.HashFilePathForWebUI(item.FullName)
- item.FileIcon = fileicon.RenderEntryIconHTML(renderedIconPool, &fileicon.EntryInfo{FullName: file.HeadPath, EntryMode: file.HeadMode})
+ item.FileIcon = fileicon.RenderEntryIconHTML(renderedIconPool, &fileicon.EntryInfo{BaseName: path.Base(file.HeadPath), EntryMode: file.HeadMode})
switch file.HeadMode {
case git.EntryModeTree:
diff --git a/routers/web/repo/view.go b/routers/web/repo/view.go
index d9ff90568d..773919c054 100644
--- a/routers/web/repo/view.go
+++ b/routers/web/repo/view.go
@@ -12,6 +12,7 @@ import (
"io"
"net/http"
"net/url"
+ "path"
"strings"
"time"
@@ -260,7 +261,9 @@ func prepareDirectoryFileIcons(ctx *context.Context, files []git.CommitInfo) {
renderedIconPool := fileicon.NewRenderedIconPool()
fileIcons := map[string]template.HTML{}
for _, f := range files {
- fileIcons[f.Entry.Name()] = fileicon.RenderEntryIconHTML(renderedIconPool, fileicon.EntryInfoFromGitTreeEntry(f.Entry))
+ fullPath := path.Join(ctx.Repo.TreePath, f.Entry.Name())
+ entryInfo := fileicon.EntryInfoFromGitTreeEntry(ctx.Repo.Commit, fullPath, f.Entry)
+ fileIcons[f.Entry.Name()] = fileicon.RenderEntryIconHTML(renderedIconPool, entryInfo)
}
fileIcons[".."] = fileicon.RenderEntryIconHTML(renderedIconPool, fileicon.EntryInfoFolder())
ctx.Data["FileIcons"] = fileIcons
diff --git a/routers/web/repo/view_home.go b/routers/web/repo/view_home.go
index 8ed9179290..c7396d44e3 100644
--- a/routers/web/repo/view_home.go
+++ b/routers/web/repo/view_home.go
@@ -143,7 +143,7 @@ func prepareToRenderDirectory(ctx *context.Context) {
ctx.Data["Title"] = ctx.Tr("repo.file.title", ctx.Repo.Repository.Name+"/"+path.Base(ctx.Repo.TreePath), ctx.Repo.RefFullName.ShortName())
}
- subfolder, readmeFile, err := findReadmeFileInEntries(ctx, entries, true)
+ subfolder, readmeFile, err := findReadmeFileInEntries(ctx, ctx.Repo.TreePath, entries, true)
if err != nil {
ctx.ServerError("findReadmeFileInEntries", err)
return
@@ -377,8 +377,8 @@ func prepareHomeTreeSideBarSwitch(ctx *context.Context) {
func redirectSrcToRaw(ctx *context.Context) bool {
// GitHub redirects a tree path with "?raw=1" to the raw path
- // It is useful to embed some raw contents into markdown files,
- // then viewing the markdown in "src" path could embed the raw content correctly.
+ // It is useful to embed some raw contents into Markdown files,
+ // then viewing the Markdown in "src" path could embed the raw content correctly.
if ctx.Repo.TreePath != "" && ctx.FormBool("raw") {
ctx.Redirect(ctx.Repo.RepoLink + "/raw/" + ctx.Repo.RefTypeNameSubURL() + "/" + util.PathEscapeSegments(ctx.Repo.TreePath))
return true
@@ -386,6 +386,20 @@ func redirectSrcToRaw(ctx *context.Context) bool {
return false
}
+func redirectFollowSymlink(ctx *context.Context, treePathEntry *git.TreeEntry) bool {
+ if ctx.Repo.TreePath == "" || !ctx.FormBool("follow_symlink") {
+ return false
+ }
+ if treePathEntry.IsLink() {
+ if res, err := git.EntryFollowLinks(ctx.Repo.Commit, ctx.Repo.TreePath, treePathEntry); err == nil {
+ redirect := ctx.Repo.RepoLink + "/src/" + ctx.Repo.RefTypeNameSubURL() + "/" + util.PathEscapeSegments(res.TargetFullPath) + "?" + ctx.Req.URL.RawQuery
+ ctx.Redirect(redirect)
+ return true
+ } // else: don't handle the links we cannot resolve, so ignore the error
+ }
+ return false
+}
+
// Home render repository home page
func Home(ctx *context.Context) {
if handleRepoHomeFeed(ctx) {
@@ -394,6 +408,7 @@ func Home(ctx *context.Context) {
if redirectSrcToRaw(ctx) {
return
}
+
// Check whether the repo is viewable: not in migration, and the code unit should be enabled
// Ideally the "feed" logic should be after this, but old code did so, so keep it as-is.
checkHomeCodeViewable(ctx)
@@ -424,6 +439,10 @@ func Home(ctx *context.Context) {
return
}
+ if redirectFollowSymlink(ctx, entry) {
+ return
+ }
+
// prepare the tree path
var treeNames, paths []string
branchLink := ctx.Repo.RepoLink + "/src/" + ctx.Repo.RefTypeNameSubURL()
diff --git a/routers/web/repo/view_readme.go b/routers/web/repo/view_readme.go
index a34de06e8e..ba03febff3 100644
--- a/routers/web/repo/view_readme.go
+++ b/routers/web/repo/view_readme.go
@@ -32,15 +32,7 @@ import (
// entries == ctx.Repo.Commit.SubTree(ctx.Repo.TreePath).ListEntries()
//
// FIXME: There has to be a more efficient way of doing this
-func findReadmeFileInEntries(ctx *context.Context, entries []*git.TreeEntry, tryWellKnownDirs bool) (string, *git.TreeEntry, error) {
- // Create a list of extensions in priority order
- // 1. Markdown files - with and without localisation - e.g. README.en-us.md or README.md
- // 2. Txt files - e.g. README.txt
- // 3. No extension - e.g. README
- exts := append(localizedExtensions(".md", ctx.Locale.Language()), ".txt", "") // sorted by priority
- extCount := len(exts)
- readmeFiles := make([]*git.TreeEntry, extCount+1)
-
+func findReadmeFileInEntries(ctx *context.Context, parentDir string, entries []*git.TreeEntry, tryWellKnownDirs bool) (string, *git.TreeEntry, error) {
docsEntries := make([]*git.TreeEntry, 3) // (one of docs/, .gitea/ or .github/)
for _, entry := range entries {
if tryWellKnownDirs && entry.IsDir() {
@@ -62,16 +54,23 @@ func findReadmeFileInEntries(ctx *context.Context, entries []*git.TreeEntry, try
docsEntries[2] = entry
}
}
- continue
}
+ }
+
+ // Create a list of extensions in priority order
+ // 1. Markdown files - with and without localisation - e.g. README.en-us.md or README.md
+ // 2. Txt files - e.g. README.txt
+ // 3. No extension - e.g. README
+ exts := append(localizedExtensions(".md", ctx.Locale.Language()), ".txt", "") // sorted by priority
+ extCount := len(exts)
+ readmeFiles := make([]*git.TreeEntry, extCount+1)
+ for _, entry := range entries {
if i, ok := util.IsReadmeFileExtension(entry.Name(), exts...); ok {
- log.Debug("Potential readme file: %s", entry.Name())
+ fullPath := path.Join(parentDir, entry.Name())
if readmeFiles[i] == nil || base.NaturalSortLess(readmeFiles[i].Name(), entry.Blob().Name()) {
if entry.IsLink() {
- target, err := entry.FollowLinks()
- if err != nil && !git.IsErrSymlinkUnresolved(err) {
- return "", nil, err
- } else if target != nil && (target.IsExecutable() || target.IsRegular()) {
+ res, err := git.EntryFollowLinks(ctx.Repo.Commit, fullPath, entry)
+ if err == nil && (res.TargetEntry.IsExecutable() || res.TargetEntry.IsRegular()) {
readmeFiles[i] = entry
}
} else {
@@ -80,6 +79,7 @@ func findReadmeFileInEntries(ctx *context.Context, entries []*git.TreeEntry, try
}
}
}
+
var readmeFile *git.TreeEntry
for _, f := range readmeFiles {
if f != nil {
@@ -103,7 +103,7 @@ func findReadmeFileInEntries(ctx *context.Context, entries []*git.TreeEntry, try
return "", nil, err
}
- subfolder, readmeFile, err := findReadmeFileInEntries(ctx, childEntries, false)
+ subfolder, readmeFile, err := findReadmeFileInEntries(ctx, parentDir, childEntries, false)
if err != nil && !git.IsErrNotExist(err) {
return "", nil, err
}
@@ -139,22 +139,29 @@ func localizedExtensions(ext, languageCode string) (localizedExts []string) {
}
func prepareToRenderReadmeFile(ctx *context.Context, subfolder string, readmeFile *git.TreeEntry) {
- target := readmeFile
- if readmeFile != nil && readmeFile.IsLink() {
- target, _ = readmeFile.FollowLinks()
- }
- if target == nil {
- // if findReadmeFile() failed and/or gave us a broken symlink (which it shouldn't)
- // simply skip rendering the README
+ if readmeFile == nil {
return
}
+ readmeFullPath := path.Join(ctx.Repo.TreePath, subfolder, readmeFile.Name())
+ readmeTargetEntry := readmeFile
+ if readmeFile.IsLink() {
+ if res, err := git.EntryFollowLinks(ctx.Repo.Commit, readmeFullPath, readmeFile); err == nil {
+ readmeTargetEntry = res.TargetEntry
+ } else {
+ readmeTargetEntry = nil // if we cannot resolve the symlink, we cannot render the readme, ignore the error
+ }
+ }
+ if readmeTargetEntry == nil {
+ return // if no valid README entry found, skip rendering the README
+ }
+
ctx.Data["RawFileLink"] = ""
ctx.Data["ReadmeInList"] = path.Join(subfolder, readmeFile.Name()) // the relative path to the readme file to the current tree path
ctx.Data["ReadmeExist"] = true
ctx.Data["FileIsSymlink"] = readmeFile.IsLink()
- buf, dataRc, fInfo, err := getFileReader(ctx, ctx.Repo.Repository.ID, target.Blob())
+ buf, dataRc, fInfo, err := getFileReader(ctx, ctx.Repo.Repository.ID, readmeTargetEntry.Blob())
if err != nil {
ctx.ServerError("getFileReader", err)
return
@@ -162,7 +169,7 @@ func prepareToRenderReadmeFile(ctx *context.Context, subfolder string, readmeFil
defer dataRc.Close()
ctx.Data["FileIsText"] = fInfo.st.IsText()
- ctx.Data["FileTreePath"] = path.Join(ctx.Repo.TreePath, subfolder, readmeFile.Name())
+ ctx.Data["FileTreePath"] = readmeFullPath
ctx.Data["FileSize"] = fInfo.fileSize
ctx.Data["IsLFSFile"] = fInfo.isLFSFile()
@@ -189,10 +196,10 @@ func prepareToRenderReadmeFile(ctx *context.Context, subfolder string, readmeFil
rctx := renderhelper.NewRenderContextRepoFile(ctx, ctx.Repo.Repository, renderhelper.RepoFileOptions{
CurrentRefPath: ctx.Repo.RefTypeNameSubURL(),
- CurrentTreePath: path.Join(ctx.Repo.TreePath, subfolder),
+ CurrentTreePath: path.Dir(readmeFullPath),
}).
WithMarkupType(markupType).
- WithRelativePath(path.Join(ctx.Repo.TreePath, subfolder, readmeFile.Name())) // ctx.Repo.TreePath is the directory not the Readme so we must append the Readme filename (and path).
+ WithRelativePath(readmeFullPath)
ctx.Data["EscapeStatus"], ctx.Data["FileContent"], err = markupRender(ctx, rctx, rd)
if err != nil {
diff --git a/services/repository/files/tree.go b/services/repository/files/tree.go
index a3c3d20238..f2cbacbf1c 100644
--- a/services/repository/files/tree.go
+++ b/services/repository/files/tree.go
@@ -161,7 +161,7 @@ func newTreeViewNodeFromEntry(ctx context.Context, renderedIconPool *fileicon.Re
FullPath: path.Join(parentDir, entry.Name()),
}
- entryInfo := fileicon.EntryInfoFromGitTreeEntry(entry)
+ entryInfo := fileicon.EntryInfoFromGitTreeEntry(commit, node.FullPath, entry)
node.EntryIcon = fileicon.RenderEntryIconHTML(renderedIconPool, entryInfo)
if entryInfo.EntryMode.IsDir() {
entryInfo.IsOpen = true
diff --git a/templates/repo/branch/list.tmpl b/templates/repo/branch/list.tmpl
index fffe3a08cc..9e86641c6f 100644
--- a/templates/repo/branch/list.tmpl
+++ b/templates/repo/branch/list.tmpl
@@ -107,8 +107,14 @@
{{end}}
</td>
<td class="two wide ui">
- {{if and (not .DBBranch.IsDeleted) $.DefaultBranchBranch}}
- <div class="commit-divergence">
+ {{if and (not .DBBranch.IsDeleted) $.DefaultBranchBranch}}
+ {{$tooltipDivergence := ""}}
+ {{if or .CommitsBehind .CommitsAhead}}
+ {{$tooltipDivergence = ctx.Locale.Tr "repo.branch.commits_divergence_from" .CommitsBehind .CommitsAhead $.DefaultBranchBranch.DBBranch.Name}}
+ {{else}}
+ {{$tooltipDivergence = ctx.Locale.Tr "repo.branch.commits_no_divergence" $.DefaultBranchBranch.DBBranch.Name}}
+ {{end}}
+ <div class="commit-divergence" data-tooltip-content="{{$tooltipDivergence}}">
<div class="bar-group">
<div class="count count-behind">{{.CommitsBehind}}</div>
{{/* old code bears 0/0.0 = NaN output, so it might output invalid "width: NaNpx", it just works and doesn't caues any problem. */}}
@@ -119,7 +125,7 @@
<div class="bar bar-ahead" style="width: {{Eval 100 "*" .CommitsAhead "/" "(" .CommitsBehind "+" .CommitsAhead "+" 0.0 ")"}}%"></div>
</div>
</div>
- {{end}}
+ {{end}}
</td>
<td class="two wide tw-text-right">
{{if not .LatestPullRequest}}
diff --git a/templates/repo/issue/sidebar/wip_switch.tmpl b/templates/repo/issue/sidebar/wip_switch.tmpl
index b007399deb..8c40908f62 100644
--- a/templates/repo/issue/sidebar/wip_switch.tmpl
+++ b/templates/repo/issue/sidebar/wip_switch.tmpl
@@ -1,5 +1,5 @@
{{if and (or .HasIssuesOrPullsWritePermission .IsIssuePoster) (not .HasMerged) (not .Issue.IsClosed) (not .IsPullWorkInProgress)}}
- <a class="toggle-wip tw-block tw-mt-2" data-title="{{.Issue.Title}}" data-wip-prefix="{{index .PullRequestWorkInProgressPrefixes 0}}" data-update-url="{{.Issue.Link}}/title">
+ <a data-global-init="initPullRequestWipToggle" data-title="{{.Issue.Title}}" data-wip-prefix="{{index .PullRequestWorkInProgressPrefixes 0}}" data-update-url="{{.Issue.Link}}/title">
{{ctx.Locale.Tr "repo.pulls.still_in_progress"}} {{ctx.Locale.Tr "repo.pulls.add_prefix" (index .PullRequestWorkInProgressPrefixes 0)}}
</a>
{{end}}
diff --git a/templates/repo/issue/view_content/pull_merge_box.tmpl b/templates/repo/issue/view_content/pull_merge_box.tmpl
index 641520247d..46bcd3b8b3 100644
--- a/templates/repo/issue/view_content/pull_merge_box.tmpl
+++ b/templates/repo/issue/view_content/pull_merge_box.tmpl
@@ -95,7 +95,7 @@
{{ctx.Locale.Tr "repo.pulls.cannot_merge_work_in_progress"}}
</div>
{{if or .HasIssuesOrPullsWritePermission .IsIssuePoster}}
- <button class="ui compact button toggle-wip" data-title="{{.Issue.Title}}" data-wip-prefix="{{.WorkInProgressPrefix}}" data-update-url="{{.Issue.Link}}/title">
+ <button class="ui compact button" data-global-init="initPullRequestWipToggle" data-title="{{.Issue.Title}}" data-wip-prefix="{{.WorkInProgressPrefix}}" data-update-url="{{.Issue.Link}}/title">
{{ctx.Locale.Tr "repo.pulls.remove_prefix" .WorkInProgressPrefix}}
</button>
{{end}}
diff --git a/templates/repo/view_list.tmpl b/templates/repo/view_list.tmpl
index c8ee059e89..b655f735a3 100644
--- a/templates/repo/view_list.tmpl
+++ b/templates/repo/view_list.tmpl
@@ -41,6 +41,9 @@
</a>
{{else}}
<a class="entry-name" href="{{$.TreeLink}}/{{PathEscapeSegments $entry.Name}}" title="{{$entry.Name}}">{{$entry.Name}}</a>
+ {{if $entry.IsLink}}
+ <a class="entry-symbol-link flex-text-inline" data-tooltip-content title="{{ctx.Locale.Tr "repo.find_file.follow_symlink"}}" href="{{$.TreeLink}}/{{PathEscapeSegments $entry.Name}}?follow_symlink=1">{{svg "octicon-link" 12}}</a>
+ {{end}}
{{end}}
{{end}}
</div>
diff --git a/tests/integration/repo_test.go b/tests/integration/repo_test.go
index 028e8edb19..adfe07519f 100644
--- a/tests/integration/repo_test.go
+++ b/tests/integration/repo_test.go
@@ -27,6 +27,7 @@ import (
"github.com/PuerkitoBio/goquery"
"github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
)
func TestRepoView(t *testing.T) {
@@ -41,6 +42,7 @@ func TestRepoView(t *testing.T) {
t.Run("BlameFileInRepo", testBlameFileInRepo)
t.Run("ViewRepoDirectory", testViewRepoDirectory)
t.Run("ViewRepoDirectoryReadme", testViewRepoDirectoryReadme)
+ t.Run("ViewRepoSymlink", testViewRepoSymlink)
t.Run("MarkDownReadmeImage", testMarkDownReadmeImage)
t.Run("MarkDownReadmeImageSubfolder", testMarkDownReadmeImageSubfolder)
t.Run("GeneratedSourceLink", testGeneratedSourceLink)
@@ -412,6 +414,21 @@ func testViewRepoDirectoryReadme(t *testing.T) {
missing("symlink-loop", "/user2/readme-test/src/branch/symlink-loop/")
}
+func testViewRepoSymlink(t *testing.T) {
+ session := loginUser(t, "user2")
+ req := NewRequest(t, "GET", "/user2/readme-test/src/branch/symlink")
+ resp := session.MakeRequest(t, req, http.StatusOK)
+
+ htmlDoc := NewHTMLParser(t, resp.Body)
+ AssertHTMLElement(t, htmlDoc, ".entry-symbol-link", true)
+ followSymbolLinkHref := htmlDoc.Find(".entry-symbol-link").AttrOr("href", "")
+ require.Equal(t, "/user2/readme-test/src/branch/symlink/README.md?follow_symlink=1", followSymbolLinkHref)
+
+ req = NewRequest(t, "GET", followSymbolLinkHref)
+ resp = session.MakeRequest(t, req, http.StatusSeeOther)
+ assert.Equal(t, "/user2/readme-test/src/branch/symlink/some/other/path/awefulcake.txt?follow_symlink=1", resp.Header().Get("Location"))
+}
+
func testMarkDownReadmeImage(t *testing.T) {
defer tests.PrintCurrentTest(t)()
diff --git a/web_src/js/bootstrap.ts b/web_src/js/bootstrap.ts
index 9e41673b86..96a2759a23 100644
--- a/web_src/js/bootstrap.ts
+++ b/web_src/js/bootstrap.ts
@@ -2,6 +2,7 @@
// to make sure the error handler always works, we should never import `window.config`, because
// some user's custom template breaks it.
import type {Intent} from './types.ts';
+import {html} from './utils/html.ts';
// This sets up the URL prefix used in webpack's chunk loading.
// This file must be imported before any lazy-loading is being attempted.
@@ -23,7 +24,7 @@ export function showGlobalErrorMessage(msg: string, msgType: Intent = 'error') {
let msgDiv = msgContainer.querySelector<HTMLDivElement>(`.js-global-error[data-global-error-msg-compact="${msgCompact}"]`);
if (!msgDiv) {
const el = document.createElement('div');
- el.innerHTML = `<div class="ui container js-global-error tw-my-[--page-spacing]"><div class="ui ${msgType} message tw-text-center tw-whitespace-pre-line"></div></div>`;
+ el.innerHTML = html`<div class="ui container js-global-error tw-my-[--page-spacing]"><div class="ui ${msgType} message tw-text-center tw-whitespace-pre-line"></div></div>`;
msgDiv = el.childNodes[0] as HTMLDivElement;
}
// merge duplicated messages into "the message (count)" format
diff --git a/web_src/js/components/ViewFileTreeStore.ts b/web_src/js/components/ViewFileTreeStore.ts
index 13e2753c94..e2155bd58a 100644
--- a/web_src/js/components/ViewFileTreeStore.ts
+++ b/web_src/js/components/ViewFileTreeStore.ts
@@ -2,6 +2,7 @@ import {reactive} from 'vue';
import {GET} from '../modules/fetch.ts';
import {pathEscapeSegments} from '../utils/url.ts';
import {createElementFromHTML} from '../utils/dom.ts';
+import {html} from '../utils/html.ts';
export function createViewFileTreeStore(props: { repoLink: string, treePath: string, currentRefNameSubURL: string}) {
const store = reactive({
@@ -16,7 +17,7 @@ export function createViewFileTreeStore(props: { repoLink: string, treePath: str
if (!document.querySelector(`.global-svg-icon-pool #${svgId}`)) poolSvgs.push(svgContent);
}
if (poolSvgs.length) {
- const svgContainer = createElementFromHTML('<div class="global-svg-icon-pool tw-hidden"></div>');
+ const svgContainer = createElementFromHTML(html`<div class="global-svg-icon-pool tw-hidden"></div>`);
svgContainer.innerHTML = poolSvgs.join('');
document.body.append(svgContainer);
}
diff --git a/web_src/js/features/common-button.ts b/web_src/js/features/common-button.ts
index ae399e48b3..22a7890857 100644
--- a/web_src/js/features/common-button.ts
+++ b/web_src/js/features/common-button.ts
@@ -43,13 +43,16 @@ export function initGlobalDeleteButton(): void {
fomanticQuery(modal).modal({
closable: false,
- onApprove: async () => {
+ onApprove: () => {
// if `data-type="form"` exists, then submit the form by the selector provided by `data-form="..."`
if (btn.getAttribute('data-type') === 'form') {
const formSelector = btn.getAttribute('data-form');
const form = document.querySelector<HTMLFormElement>(formSelector);
if (!form) throw new Error(`no form named ${formSelector} found`);
+ modal.classList.add('is-loading'); // the form is not in the modal, so also add loading indicator to the modal
+ form.classList.add('is-loading');
form.submit();
+ return false; // prevent modal from closing automatically
}
// prepare an AJAX form by data attributes
@@ -62,12 +65,15 @@ export function initGlobalDeleteButton(): void {
postData.append('id', value);
}
}
-
- const response = await POST(btn.getAttribute('data-url'), {data: postData});
- if (response.ok) {
- const data = await response.json();
- window.location.href = data.redirect;
- }
+ (async () => {
+ const response = await POST(btn.getAttribute('data-url'), {data: postData});
+ if (response.ok) {
+ const data = await response.json();
+ window.location.href = data.redirect;
+ }
+ })();
+ modal.classList.add('is-loading'); // the request is in progress, so also add loading indicator to the modal
+ return false; // prevent modal from closing automatically
},
}).modal('show');
});
@@ -158,13 +164,7 @@ function onShowModalClick(el: HTMLElement, e: MouseEvent) {
}
}
- fomanticQuery(elModal).modal('setting', {
- onApprove: () => {
- // "form-fetch-action" can handle network errors gracefully,
- // so keep the modal dialog to make users can re-submit the form if anything wrong happens.
- if (elModal.querySelector('.form-fetch-action')) return false;
- },
- }).modal('show');
+ fomanticQuery(elModal).modal('show');
}
export function initGlobalButtons(): void {
diff --git a/web_src/js/features/comp/ConfirmModal.ts b/web_src/js/features/comp/ConfirmModal.ts
index 81ea09476b..97a73eace6 100644
--- a/web_src/js/features/comp/ConfirmModal.ts
+++ b/web_src/js/features/comp/ConfirmModal.ts
@@ -1,5 +1,5 @@
import {svg} from '../../svg.ts';
-import {htmlEscape} from 'escape-goat';
+import {html, htmlRaw} from '../../utils/html.ts';
import {createElementFromHTML} from '../../utils/dom.ts';
import {fomanticQuery} from '../../modules/fomantic/base.ts';
@@ -12,17 +12,17 @@ type ConfirmModalOptions = {
}
export function createConfirmModal({header = '', content = '', confirmButtonColor = 'primary'}:ConfirmModalOptions = {}): HTMLElement {
- const headerHtml = header ? `<div class="header">${htmlEscape(header)}</div>` : '';
- return createElementFromHTML(`
-<div class="ui g-modal-confirm modal">
- ${headerHtml}
- <div class="content">${htmlEscape(content)}</div>
- <div class="actions">
- <button class="ui cancel button">${svg('octicon-x')} ${htmlEscape(i18n.modal_cancel)}</button>
- <button class="ui ${confirmButtonColor} ok button">${svg('octicon-check')} ${htmlEscape(i18n.modal_confirm)}</button>
- </div>
-</div>
-`);
+ const headerHtml = header ? html`<div class="header">${header}</div>` : '';
+ return createElementFromHTML(html`
+ <div class="ui g-modal-confirm modal">
+ ${htmlRaw(headerHtml)}
+ <div class="content">${content}</div>
+ <div class="actions">
+ <button class="ui cancel button">${htmlRaw(svg('octicon-x'))} ${i18n.modal_cancel}</button>
+ <button class="ui ${confirmButtonColor} ok button">${htmlRaw(svg('octicon-check'))} ${i18n.modal_confirm}</button>
+ </div>
+ </div>
+ `.trim());
}
export function confirmModal(modal: HTMLElement | ConfirmModalOptions): Promise<boolean> {
diff --git a/web_src/js/features/comp/EditorUpload.ts b/web_src/js/features/comp/EditorUpload.ts
index bf9ce9bfb1..bf78f58daf 100644
--- a/web_src/js/features/comp/EditorUpload.ts
+++ b/web_src/js/features/comp/EditorUpload.ts
@@ -114,7 +114,7 @@ async function handleUploadFiles(editor: CodeMirrorEditor | TextareaEditor, drop
export function removeAttachmentLinksFromMarkdown(text: string, fileUuid: string) {
text = text.replace(new RegExp(`!?\\[([^\\]]+)\\]\\(/?attachments/${fileUuid}\\)`, 'g'), '');
- text = text.replace(new RegExp(`<img[^>]+src="/?attachments/${fileUuid}"[^>]*>`, 'g'), '');
+ text = text.replace(new RegExp(`[<]img[^>]+src="/?attachments/${fileUuid}"[^>]*>`, 'g'), '');
return text;
}
diff --git a/web_src/js/features/comp/LabelEdit.ts b/web_src/js/features/comp/LabelEdit.ts
index 141c5eecfe..423440129c 100644
--- a/web_src/js/features/comp/LabelEdit.ts
+++ b/web_src/js/features/comp/LabelEdit.ts
@@ -72,6 +72,7 @@ export function initCompLabelEdit(pageSelector: string) {
return false;
}
submitFormFetchAction(form);
+ return false;
},
}).modal('show');
};
diff --git a/web_src/js/features/comp/SearchUserBox.ts b/web_src/js/features/comp/SearchUserBox.ts
index 9fedb3ed24..4b13a2141f 100644
--- a/web_src/js/features/comp/SearchUserBox.ts
+++ b/web_src/js/features/comp/SearchUserBox.ts
@@ -1,4 +1,4 @@
-import {htmlEscape} from 'escape-goat';
+import {htmlEscape} from '../../utils/html.ts';
import {fomanticQuery} from '../../modules/fomantic/base.ts';
const {appSubUrl} = window.config;
diff --git a/web_src/js/features/dropzone.ts b/web_src/js/features/dropzone.ts
index b2ba7651c4..20f7ceb6c3 100644
--- a/web_src/js/features/dropzone.ts
+++ b/web_src/js/features/dropzone.ts
@@ -1,5 +1,5 @@
import {svg} from '../svg.ts';
-import {htmlEscape} from 'escape-goat';
+import {html} from '../utils/html.ts';
import {clippie} from 'clippie';
import {showTemporaryTooltip} from '../modules/tippy.ts';
import {GET, POST} from '../modules/fetch.ts';
@@ -33,14 +33,14 @@ export function generateMarkdownLinkForAttachment(file: Partial<CustomDropzoneFi
// Scale down images from HiDPI monitors. This uses the <img> tag because it's the only
// method to change image size in Markdown that is supported by all implementations.
// Make the image link relative to the repo path, then the final URL is "/sub-path/owner/repo/attachments/{uuid}"
- fileMarkdown = `<img width="${Math.round(width / dppx)}" alt="${htmlEscape(file.name)}" src="attachments/${htmlEscape(file.uuid)}">`;
+ fileMarkdown = html`<img width="${Math.round(width / dppx)}" alt="${file.name}" src="attachments/${file.uuid}">`;
} else {
// Markdown always renders the image with a relative path, so the final URL is "/sub-path/owner/repo/attachments/{uuid}"
// TODO: it should also use relative path for consistency, because absolute is ambiguous for "/sub-path/attachments" or "/attachments"
fileMarkdown = `![${file.name}](/attachments/${file.uuid})`;
}
} else if (isVideoFile(file)) {
- fileMarkdown = `<video src="attachments/${htmlEscape(file.uuid)}" title="${htmlEscape(file.name)}" controls></video>`;
+ fileMarkdown = html`<video src="attachments/${file.uuid}" title="${file.name}" controls></video>`;
}
return fileMarkdown;
}
diff --git a/web_src/js/features/emoji.ts b/web_src/js/features/emoji.ts
index 135620e51e..69afe491e2 100644
--- a/web_src/js/features/emoji.ts
+++ b/web_src/js/features/emoji.ts
@@ -1,4 +1,5 @@
import emojis from '../../../assets/emoji.json' with {type: 'json'};
+import {html} from '../utils/html.ts';
const {assetUrlPrefix, customEmojis} = window.config;
@@ -24,12 +25,11 @@ for (const key of emojiKeys) {
export function emojiHTML(name: string) {
let inner;
if (Object.hasOwn(customEmojis, name)) {
- inner = `<img alt=":${name}:" src="${assetUrlPrefix}/img/emoji/${name}.png">`;
+ inner = html`<img alt=":${name}:" src="${assetUrlPrefix}/img/emoji/${name}.png">`;
} else {
inner = emojiString(name);
}
-
- return `<span class="emoji" title=":${name}:">${inner}</span>`;
+ return html`<span class="emoji" title=":${name}:">${inner}</span>`;
}
// retrieve string for given emoji name
diff --git a/web_src/js/features/file-view.ts b/web_src/js/features/file-view.ts
index 867f946297..d803f53c0d 100644
--- a/web_src/js/features/file-view.ts
+++ b/web_src/js/features/file-view.ts
@@ -3,7 +3,7 @@ import {newRenderPlugin3DViewer} from '../render/plugins/3d-viewer.ts';
import {newRenderPluginPdfViewer} from '../render/plugins/pdf-viewer.ts';
import {registerGlobalInitFunc} from '../modules/observer.ts';
import {createElementFromHTML, showElem, toggleClass} from '../utils/dom.ts';
-import {htmlEscape} from 'escape-goat';
+import {html} from '../utils/html.ts';
import {basename} from '../utils.ts';
const plugins: FileRenderPlugin[] = [];
@@ -54,7 +54,7 @@ async function renderRawFileToContainer(container: HTMLElement, rawFileLink: str
container.replaceChildren(elViewRawPrompt);
if (errorMsg) {
- const elErrorMessage = createElementFromHTML(htmlEscape`<div class="ui error message">${errorMsg}</div>`);
+ const elErrorMessage = createElementFromHTML(html`<div class="ui error message">${errorMsg}</div>`);
elViewRawPrompt.insertAdjacentElement('afterbegin', elErrorMessage);
}
}
diff --git a/web_src/js/features/repo-editor.ts b/web_src/js/features/repo-editor.ts
index c6b5cccd54..f3ca13460c 100644
--- a/web_src/js/features/repo-editor.ts
+++ b/web_src/js/features/repo-editor.ts
@@ -1,4 +1,4 @@
-import {htmlEscape} from 'escape-goat';
+import {html, htmlRaw} from '../utils/html.ts';
import {createCodeEditor} from './codeeditor.ts';
import {hideElem, queryElems, showElem, createElementFromHTML} from '../utils/dom.ts';
import {attachRefIssueContextPopup} from './contextpopup.ts';
@@ -87,10 +87,10 @@ export function initRepoEditor() {
if (i < parts.length - 1) {
if (trimValue.length) {
const linkElement = createElementFromHTML(
- `<span class="section"><a href="#">${htmlEscape(value)}</a></span>`,
+ html`<span class="section"><a href="#">${value}</a></span>`,
);
const dividerElement = createElementFromHTML(
- `<div class="breadcrumb-divider">/</div>`,
+ html`<div class="breadcrumb-divider">/</div>`,
);
links.push(linkElement);
dividers.push(dividerElement);
@@ -113,7 +113,7 @@ export function initRepoEditor() {
if (!warningDiv) {
warningDiv = document.createElement('div');
warningDiv.classList.add('ui', 'warning', 'message', 'flash-message', 'flash-warning', 'space-related');
- warningDiv.innerHTML = '<p>File path contains leading or trailing whitespace.</p>';
+ warningDiv.innerHTML = html`<p>File path contains leading or trailing whitespace.</p>`;
// Add display 'block' because display is set to 'none' in formantic\build\semantic.css
warningDiv.style.display = 'block';
const inputContainer = document.querySelector('.repo-editor-header');
@@ -196,7 +196,8 @@ export function initRepoEditor() {
})();
}
-export function renderPreviewPanelContent(previewPanel: Element, content: string) {
- previewPanel.innerHTML = `<div class="render-content markup">${content}</div>`;
+export function renderPreviewPanelContent(previewPanel: Element, htmlContent: string) {
+ // the content is from the server, so it is safe to use innerHTML
+ previewPanel.innerHTML = html`<div class="render-content markup">${htmlRaw(htmlContent)}</div>`;
attachRefIssueContextPopup(previewPanel.querySelectorAll('p .ref-issue'));
}
diff --git a/web_src/js/features/repo-issue-list.ts b/web_src/js/features/repo-issue-list.ts
index 3ea5fb70c0..762fbf51bb 100644
--- a/web_src/js/features/repo-issue-list.ts
+++ b/web_src/js/features/repo-issue-list.ts
@@ -1,6 +1,6 @@
import {updateIssuesMeta} from './repo-common.ts';
import {toggleElem, queryElems, isElemVisible} from '../utils/dom.ts';
-import {htmlEscape} from 'escape-goat';
+import {html} from '../utils/html.ts';
import {confirmModal} from './comp/ConfirmModal.ts';
import {showErrorToast} from '../modules/toast.ts';
import {createSortable} from '../modules/sortable.ts';
@@ -138,10 +138,10 @@ function initDropdownUserRemoteSearch(el: Element) {
// the content is provided by backend IssuePosters handler
processedResults.length = 0;
for (const item of resp.results) {
- let html = `<img class="ui avatar tw-align-middle" src="${htmlEscape(item.avatar_link)}" aria-hidden="true" alt width="20" height="20"><span class="gt-ellipsis">${htmlEscape(item.username)}</span>`;
- if (item.full_name) html += `<span class="search-fullname tw-ml-2">${htmlEscape(item.full_name)}</span>`;
+ let nameHtml = html`<img class="ui avatar tw-align-middle" src="${item.avatar_link}" aria-hidden="true" alt width="20" height="20"><span class="gt-ellipsis">${item.username}</span>`;
+ if (item.full_name) nameHtml += html`<span class="search-fullname tw-ml-2">${item.full_name}</span>`;
if (selectedUsername.toLowerCase() === item.username.toLowerCase()) selectedUsername = item.username;
- processedResults.push({value: item.username, name: html});
+ processedResults.push({value: item.username, name: nameHtml});
}
resp.results = processedResults;
return resp;
diff --git a/web_src/js/features/repo-issue.ts b/web_src/js/features/repo-issue.ts
index bc7d4dee19..49e8fc40a2 100644
--- a/web_src/js/features/repo-issue.ts
+++ b/web_src/js/features/repo-issue.ts
@@ -1,4 +1,4 @@
-import {htmlEscape} from 'escape-goat';
+import {html, htmlEscape} from '../utils/html.ts';
import {createTippy, showTemporaryTooltip} from '../modules/tippy.ts';
import {
addDelegatedEventListener,
@@ -17,6 +17,7 @@ import {showErrorToast} from '../modules/toast.ts';
import {initRepoIssueSidebar} from './repo-issue-sidebar.ts';
import {fomanticQuery} from '../modules/fomantic/base.ts';
import {ignoreAreYouSure} from '../vendor/jquery.are-you-sure.ts';
+import {registerGlobalInitFunc} from '../modules/observer.ts';
const {appSubUrl} = window.config;
@@ -45,8 +46,7 @@ export function initRepoIssueSidebarDependency() {
if (String(issue.id) === currIssueId) continue;
filteredResponse.results.push({
value: issue.id,
- name: `<div class="gt-ellipsis">#${issue.number} ${htmlEscape(issue.title)}</div>
-<div class="text small tw-break-anywhere">${htmlEscape(issue.repository.full_name)}</div>`,
+ name: html`<div class="gt-ellipsis">#${issue.number} ${issue.title}</div><div class="text small tw-break-anywhere">${issue.repository.full_name}</div>`,
});
}
return filteredResponse;
@@ -416,25 +416,20 @@ export function initRepoIssueWipNewTitle() {
export function initRepoIssueWipToggle() {
// Toggle WIP for existing PR
- queryElems(document, '.toggle-wip', (el) => el.addEventListener('click', async (e) => {
+ registerGlobalInitFunc('initPullRequestWipToggle', (toggleWip) => toggleWip.addEventListener('click', async (e) => {
e.preventDefault();
- const toggleWip = el;
const title = toggleWip.getAttribute('data-title');
const wipPrefix = toggleWip.getAttribute('data-wip-prefix');
const updateUrl = toggleWip.getAttribute('data-update-url');
- try {
- const params = new URLSearchParams();
- params.append('title', title?.startsWith(wipPrefix) ? title.slice(wipPrefix.length).trim() : `${wipPrefix.trim()} ${title}`);
-
- const response = await POST(updateUrl, {data: params});
- if (!response.ok) {
- throw new Error('Failed to toggle WIP status');
- }
- window.location.reload();
- } catch (error) {
- console.error(error);
+ const params = new URLSearchParams();
+ params.append('title', title?.startsWith(wipPrefix) ? title.slice(wipPrefix.length).trim() : `${wipPrefix.trim()} ${title}`);
+ const response = await POST(updateUrl, {data: params});
+ if (!response.ok) {
+ showErrorToast(`Failed to toggle 'work in progress' status`);
+ return;
}
+ window.location.reload();
}));
}
diff --git a/web_src/js/features/repo-new.ts b/web_src/js/features/repo-new.ts
index 0e4d78872d..e2aa13f490 100644
--- a/web_src/js/features/repo-new.ts
+++ b/web_src/js/features/repo-new.ts
@@ -1,5 +1,5 @@
import {hideElem, querySingleVisibleElem, showElem, toggleElem} from '../utils/dom.ts';
-import {htmlEscape} from 'escape-goat';
+import {htmlEscape} from '../utils/html.ts';
import {fomanticQuery} from '../modules/fomantic/base.ts';
import {sanitizeRepoName} from './repo-common.ts';
diff --git a/web_src/js/features/repo-wiki.ts b/web_src/js/features/repo-wiki.ts
index f94d3ef3d1..6ae0947077 100644
--- a/web_src/js/features/repo-wiki.ts
+++ b/web_src/js/features/repo-wiki.ts
@@ -2,6 +2,7 @@ import {validateTextareaNonEmpty, initComboMarkdownEditor} from './comp/ComboMar
import {fomanticMobileScreen} from '../modules/fomantic.ts';
import {POST} from '../modules/fetch.ts';
import type {ComboMarkdownEditor} from './comp/ComboMarkdownEditor.ts';
+import {html, htmlRaw} from '../utils/html.ts';
async function initRepoWikiFormEditor() {
const editArea = document.querySelector<HTMLTextAreaElement>('.repository.wiki .combo-markdown-editor textarea');
@@ -30,7 +31,7 @@ async function initRepoWikiFormEditor() {
const response = await POST(editor.previewUrl, {data: formData});
const data = await response.text();
lastContent = newContent;
- previewTarget.innerHTML = `<div class="render-content markup ui segment">${data}</div>`;
+ previewTarget.innerHTML = html`<div class="render-content markup ui segment">${htmlRaw(data)}</div>`;
} catch (error) {
console.error('Error rendering preview:', error);
} finally {
diff --git a/web_src/js/features/tribute.ts b/web_src/js/features/tribute.ts
index cf98377ae7..43c21ebe6d 100644
--- a/web_src/js/features/tribute.ts
+++ b/web_src/js/features/tribute.ts
@@ -1,5 +1,5 @@
import {emojiKeys, emojiHTML, emojiString} from './emoji.ts';
-import {htmlEscape} from 'escape-goat';
+import {html, htmlRaw} from '../utils/html.ts';
type TributeItem = Record<string, any>;
@@ -26,17 +26,18 @@ export async function attachTribute(element: HTMLElement) {
return emojiString(item.original);
},
menuItemTemplate: (item: TributeItem) => {
- return `<div class="tribute-item">${emojiHTML(item.original)}<span>${htmlEscape(item.original)}</span></div>`;
+ return html`<div class="tribute-item">${htmlRaw(emojiHTML(item.original))}<span>${item.original}</span></div>`;
},
}, { // mentions
values: window.config.mentionValues ?? [],
requireLeadingSpace: true,
menuItemTemplate: (item: TributeItem) => {
- return `
+ const fullNameHtml = item.original.fullname && item.original.fullname !== '' ? html`<span class="fullname">${item.original.fullname}</span>` : '';
+ return html`
<div class="tribute-item">
- <img alt src="${htmlEscape(item.original.avatar)}" width="21" height="21"/>
- <span class="name">${htmlEscape(item.original.name)}</span>
- ${item.original.fullname && item.original.fullname !== '' ? `<span class="fullname">${htmlEscape(item.original.fullname)}</span>` : ''}
+ <img alt src="${item.original.avatar}" width="21" height="21"/>
+ <span class="name">${item.original.name}</span>
+ ${htmlRaw(fullNameHtml)}
</div>
`;
},
diff --git a/web_src/js/markup/html2markdown.ts b/web_src/js/markup/html2markdown.ts
index 8c2d2f8c86..5866d0d259 100644
--- a/web_src/js/markup/html2markdown.ts
+++ b/web_src/js/markup/html2markdown.ts
@@ -1,4 +1,4 @@
-import {htmlEscape} from 'escape-goat';
+import {html, htmlRaw} from '../utils/html.ts';
type Processor = (el: HTMLElement) => string | HTMLElement | void;
@@ -38,10 +38,10 @@ function prepareProcessors(ctx:ProcessorContext): Processors {
IMG(el: HTMLElement) {
const alt = el.getAttribute('alt') || 'image';
const src = el.getAttribute('src');
- const widthAttr = el.hasAttribute('width') ? ` width="${htmlEscape(el.getAttribute('width') || '')}"` : '';
- const heightAttr = el.hasAttribute('height') ? ` height="${htmlEscape(el.getAttribute('height') || '')}"` : '';
+ const widthAttr = el.hasAttribute('width') ? htmlRaw` width="${el.getAttribute('width') || ''}"` : '';
+ const heightAttr = el.hasAttribute('height') ? htmlRaw` height="${el.getAttribute('height') || ''}"` : '';
if (widthAttr || heightAttr) {
- return `<img alt="${htmlEscape(alt)}"${widthAttr}${heightAttr} src="${htmlEscape(src)}">`;
+ return html`<img alt="${alt}"${widthAttr}${heightAttr} src="${src}">`;
}
return `![${alt}](${src})`;
},
diff --git a/web_src/js/markup/mermaid.ts b/web_src/js/markup/mermaid.ts
index ac24b3bcba..33d9a1ed9b 100644
--- a/web_src/js/markup/mermaid.ts
+++ b/web_src/js/markup/mermaid.ts
@@ -2,6 +2,7 @@ import {isDarkTheme} from '../utils.ts';
import {makeCodeCopyButton} from './codecopy.ts';
import {displayError} from './common.ts';
import {queryElems} from '../utils/dom.ts';
+import {html, htmlRaw} from '../utils/html.ts';
const {mermaidMaxSourceCharacters} = window.config;
@@ -46,7 +47,7 @@ export async function initMarkupCodeMermaid(elMarkup: HTMLElement): Promise<void
const iframe = document.createElement('iframe');
iframe.classList.add('markup-content-iframe', 'tw-invisible');
- iframe.srcdoc = `<html><head><style>${iframeCss}</style></head><body>${svg}</body></html>`;
+ iframe.srcdoc = html`<html><head><style>${htmlRaw(iframeCss)}</style></head><body>${htmlRaw(svg)}</body></html>`;
const mermaidBlock = document.createElement('div');
mermaidBlock.classList.add('mermaid-block', 'is-loading', 'tw-hidden');
diff --git a/web_src/js/modules/fomantic/modal.ts b/web_src/js/modules/fomantic/modal.ts
index b07b941590..a96c7785e1 100644
--- a/web_src/js/modules/fomantic/modal.ts
+++ b/web_src/js/modules/fomantic/modal.ts
@@ -9,8 +9,9 @@ const fomanticModalFn = $.fn.modal;
export function initAriaModalPatch() {
if ($.fn.modal === ariaModalFn) throw new Error('initAriaModalPatch could only be called once');
$.fn.modal = ariaModalFn;
- $.fn.fomanticExt.onModalBeforeHidden = onModalBeforeHidden;
(ariaModalFn as FomanticInitFunction).settings = fomanticModalFn.settings;
+ $.fn.fomanticExt.onModalBeforeHidden = onModalBeforeHidden;
+ $.fn.modal.settings.onApprove = onModalApproveDefault;
}
// the patched `$.fn.modal` modal function
@@ -34,6 +35,29 @@ function ariaModalFn(this: any, ...args: Parameters<FomanticInitFunction>) {
function onModalBeforeHidden(this: any) {
const $modal = $(this);
const elModal = $modal[0];
- queryElems(elModal, 'form', (form: HTMLFormElement) => form.reset());
hideToastsFrom(elModal.closest('.ui.dimmer') ?? document.body);
+
+ // reset the form after the modal is hidden, after other modal events and handlers (e.g. "onApprove", form submit)
+ setTimeout(() => {
+ queryElems(elModal, 'form', (form: HTMLFormElement) => form.reset());
+ }, 0);
+}
+
+function onModalApproveDefault(this: any) {
+ const $modal = $(this);
+ const selectors = $modal.modal('setting', 'selector');
+ const elModal = $modal[0];
+ const elApprove = elModal.querySelector(selectors.approve);
+ const elForm = elApprove?.closest('form');
+ if (!elForm) return true; // no form, just allow closing the modal
+
+ // "form-fetch-action" can handle network errors gracefully,
+ // so keep the modal dialog to make users can re-submit the form if anything wrong happens.
+ if (elForm.matches('.form-fetch-action')) return false;
+
+ // There is an abuse for the "modal" + "form" combination, the "Approve" button is a traditional form submit button in the form.
+ // Then "approve" and "submit" occur at the same time, the modal will be closed immediately before the form is submitted.
+ // So here we prevent the modal from closing automatically by returning false, add the "is-loading" class to the form element.
+ elForm.classList.add('is-loading');
+ return false;
}
diff --git a/web_src/js/modules/tippy.ts b/web_src/js/modules/tippy.ts
index f7a4b3723b..2a1d998d76 100644
--- a/web_src/js/modules/tippy.ts
+++ b/web_src/js/modules/tippy.ts
@@ -2,6 +2,7 @@ import tippy, {followCursor} from 'tippy.js';
import {isDocumentFragmentOrElementNode} from '../utils/dom.ts';
import {formatDatetime} from '../utils/time.ts';
import type {Content, Instance, Placement, Props} from 'tippy.js';
+import {html} from '../utils/html.ts';
type TippyOpts = {
role?: string,
@@ -9,7 +10,7 @@ type TippyOpts = {
} & Partial<Props>;
const visibleInstances = new Set<Instance>();
-const arrowSvg = `<svg width="16" height="7"><path d="m0 7 8-7 8 7Z" class="tippy-svg-arrow-outer"/><path d="m0 8 8-7 8 7Z" class="tippy-svg-arrow-inner"/></svg>`;
+const arrowSvg = html`<svg width="16" height="7"><path d="m0 7 8-7 8 7Z" class="tippy-svg-arrow-outer"/><path d="m0 8 8-7 8 7Z" class="tippy-svg-arrow-inner"/></svg>`;
export function createTippy(target: Element, opts: TippyOpts = {}): Instance {
// the callback functions should be destructured from opts,
diff --git a/web_src/js/modules/toast.ts b/web_src/js/modules/toast.ts
index b0afc343c3..ed807a4977 100644
--- a/web_src/js/modules/toast.ts
+++ b/web_src/js/modules/toast.ts
@@ -1,4 +1,4 @@
-import {htmlEscape} from 'escape-goat';
+import {htmlEscape} from '../utils/html.ts';
import {svg} from '../svg.ts';
import {animateOnce, queryElems, showElem} from '../utils/dom.ts';
import Toastify from 'toastify-js'; // don't use "async import", because when network error occurs, the "async import" also fails and nothing is shown
diff --git a/web_src/js/svg.ts b/web_src/js/svg.ts
index 7b377e1ab4..50c9536f37 100644
--- a/web_src/js/svg.ts
+++ b/web_src/js/svg.ts
@@ -1,5 +1,6 @@
import {defineComponent, h, type PropType} from 'vue';
import {parseDom, serializeXml} from './utils.ts';
+import {html, htmlRaw} from './utils/html.ts';
import giteaDoubleChevronLeft from '../../public/assets/img/svg/gitea-double-chevron-left.svg';
import giteaDoubleChevronRight from '../../public/assets/img/svg/gitea-double-chevron-right.svg';
import giteaEmptyCheckbox from '../../public/assets/img/svg/gitea-empty-checkbox.svg';
@@ -220,7 +221,7 @@ export const SvgIcon = defineComponent({
const classes = Array.from(svgOuter.classList);
if (this.symbolId) {
classes.push('tw-hidden', 'svg-symbol-container');
- svgInnerHtml = `<symbol id="${this.symbolId}" viewBox="${attrs['^viewBox']}">${svgInnerHtml}</symbol>`;
+ svgInnerHtml = html`<symbol id="${this.symbolId}" viewBox="${attrs['^viewBox']}">${htmlRaw(svgInnerHtml)}</symbol>`;
}
// create VNode
return h('svg', {
diff --git a/web_src/js/utils/dom.ts b/web_src/js/utils/dom.ts
index 7ed0d73406..8b540cebb1 100644
--- a/web_src/js/utils/dom.ts
+++ b/web_src/js/utils/dom.ts
@@ -314,6 +314,7 @@ export function replaceTextareaSelection(textarea: HTMLTextAreaElement, text: st
export function createElementFromHTML<T extends HTMLElement>(htmlString: string): T {
htmlString = htmlString.trim();
// some tags like "tr" are special, it must use a correct parent container to create
+ // eslint-disable-next-line github/unescaped-html-literal -- FIXME: maybe we need to use other approaches to create elements from HTML, e.g. using DOMParser
if (htmlString.startsWith('<tr')) {
const container = document.createElement('table');
container.innerHTML = htmlString;
diff --git a/web_src/js/utils/html.test.ts b/web_src/js/utils/html.test.ts
new file mode 100644
index 0000000000..3028b7bb0a
--- /dev/null
+++ b/web_src/js/utils/html.test.ts
@@ -0,0 +1,8 @@
+import {html, htmlEscape, htmlRaw} from './html.ts';
+
+test('html', async () => {
+ expect(html`<a>${'<>&\'"'}</a>`).toBe(`<a>&lt;&gt;&amp;&#39;&quot;</a>`);
+ expect(html`<a>${htmlRaw('<img>')}</a>`).toBe(`<a><img></a>`);
+ expect(html`<a>${htmlRaw`<img ${'&'}>`}</a>`).toBe(`<a><img &amp;></a>`);
+ expect(htmlEscape(`<a></a>`)).toBe(`&lt;a&gt;&lt;/a&gt;`);
+});
diff --git a/web_src/js/utils/html.ts b/web_src/js/utils/html.ts
new file mode 100644
index 0000000000..22e5703c34
--- /dev/null
+++ b/web_src/js/utils/html.ts
@@ -0,0 +1,32 @@
+export function htmlEscape(s: string, ...args: Array<any>): string {
+ if (args.length !== 0) throw new Error('use html or htmlRaw instead of htmlEscape'); // check legacy usages
+ return s.replace(/&/g, '&amp;')
+ .replace(/"/g, '&quot;')
+ .replace(/'/g, '&#39;')
+ .replace(/</g, '&lt;')
+ .replace(/>/g, '&gt;');
+}
+
+class rawObject {
+ private readonly value: string;
+ constructor(v: string) { this.value = v }
+ toString(): string { return this.value }
+}
+
+export function html(tmpl: TemplateStringsArray, ...parts: Array<any>): string {
+ let output = tmpl[0];
+ for (let i = 0; i < parts.length; i++) {
+ const value = parts[i];
+ const valueEscaped = (value instanceof rawObject) ? value.toString() : htmlEscape(String(parts[i]));
+ output = output + valueEscaped + tmpl[i + 1];
+ }
+ return output;
+}
+
+export function htmlRaw(s: string|TemplateStringsArray, ...tmplParts: Array<any>): rawObject {
+ if (typeof s === 'string') {
+ if (tmplParts.length !== 0) throw new Error("either htmlRaw('str') or htmlRaw`tmpl`");
+ return new rawObject(s);
+ }
+ return new rawObject(html(s, ...tmplParts));
+}