Compare commits

...

41 Commits

Author SHA1 Message Date
badhezi
6d96ca45cf
Merge 23e534d723a12b8287f5257df972d968fafb5fb9 into dd1fd89185103958cb504e362df91fd2f7856939 2025-07-02 08:12:14 +03:00
GiteaBot
dd1fd89185 [skip ci] Updated translations via Crowdin 2025-07-02 00:37:55 +00:00
wxiaoguang
1d4ad5aa2b
Improve html escape (#34911)
drop "escape-goat"
2025-07-01 21:44:05 +08:00
Aaron Meese
35f0b5a3ec
Adds tooltip on branch commit counts (#34869)
Adds a tooltip to the commit counts when comparing branches, making it
easier for novice users to understand what the numbers mean.

Fixes #34867.

---------

Signed-off-by: wxiaoguang <wxiaoguang@gmail.com>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2025-07-01 19:14:32 +08:00
wxiaoguang
90f96c301e
Fix PR toggle WIP (#34920)
Fix #34919

---------

Signed-off-by: wxiaoguang <wxiaoguang@gmail.com>
2025-07-01 16:32:39 +08:00
wxiaoguang
6596b92140
Fix modal + form abuse (#34921)
See the comment. And due to the abuse, there is a regression: when the
modal is hidden, the form will be reset and it can't submit.

This PR fixes all problems: keep the modal with form open, and add
"loading" indicator.
2025-07-01 15:19:03 +08:00
badhezi
23e534d723
Merge branch 'main' into dev/hezi/add-raw-diff-patch 2025-06-02 15:35:07 +03:00
badhezi
a8ef5d5125 make head repo selection clearer 2025-06-02 11:53:01 +03:00
wxiaoguang
180386b0d6 FIXME: how to correctly choose the head repository? 2025-06-01 23:48:39 +08:00
wxiaoguang
e442ab15b1 revert unnecessary change 2025-06-01 23:30:44 +08:00
badhezi
f4a0fe369f
Merge branch 'main' into dev/hezi/add-raw-diff-patch 2025-05-31 12:23:20 +03:00
badhezi
b9102f532e remove redundant line 2025-05-30 13:17:16 +03:00
badhezi
688fc78e78 move revParse to testing code only 2025-05-30 13:05:52 +03:00
badhezi
2905d354fc add more test cases to cover different compare patterns 2025-05-30 12:58:16 +03:00
badhezi
e9fac73bcf cover all head ref format cases 2025-05-29 16:04:16 +03:00
badhezi
c80f0b9d46 fix lint and typos 2025-05-28 10:48:55 +03:00
badhezi
d784a0f25d add download links to raw diff and patch in diff box options dropdown 2025-05-28 10:48:55 +03:00
badhezi
3274b844c6 support tag and branch names with ends with .diff and .patch 2025-05-28 10:48:55 +03:00
badhezi
17c860e97d fix timezone adjustment in TestCompareRawDiffPatch 2025-05-28 10:48:55 +03:00
badhezi
a9c8ce990e formatting 2025-05-28 10:48:55 +03:00
badhezi
2bcb546ca3 Add tests and RevParse() function 2025-05-28 10:48:55 +03:00
badhezi
180c1b075e integration tests: WIP 2025-05-28 10:48:55 +03:00
badhezi
acb57e5031 lint, err handle 2025-05-28 10:48:55 +03:00
badhezi
f86ddd53f0 parse .raw and .diff optional compare parameters 2025-05-28 10:48:55 +03:00
badhezi
bfa2d101c4
Merge branch 'go-gitea:main' into main 2025-05-28 10:05:30 +03:00
badhezi
4d7ea0d931
Merge branch 'go-gitea:main' into main 2025-05-20 09:57:11 +03:00
badhezi
a3c29538f1
Merge branch 'go-gitea:main' into main 2025-05-13 22:52:08 +03:00
badhezi
016c2f3025
Merge branch 'go-gitea:main' into main 2025-05-12 16:39:04 +03:00
badhezi
c6acfc1491
Merge branch 'go-gitea:main' into main 2025-05-11 09:18:01 +03:00
badhezi
7f72fe9ef9
Merge branch 'go-gitea:main' into main 2025-05-02 11:07:53 +03:00
badhezi
4e2434b438
Merge branch 'go-gitea:main' into main 2025-04-29 15:15:26 +03:00
badhezi
b46d3141ca
Merge branch 'go-gitea:main' into main 2025-04-28 17:07:29 +03:00
badhezi
a7aaa79a64
Merge branch 'go-gitea:main' into main 2025-04-27 16:14:49 +03:00
badhezi
bcc4ade3e7
Merge branch 'go-gitea:main' into main 2025-04-22 08:19:13 +03:00
badhezi
104eecc710
Merge branch 'go-gitea:main' into main 2025-04-21 22:30:05 +03:00
badhezi
e11a3398b6
Merge branch 'go-gitea:main' into main 2025-04-20 20:33:21 +03:00
badhezi
ac251501d3
Merge branch 'go-gitea:main' into main 2025-04-16 10:34:28 +03:00
Giteabot
54d37d1e7b
Merge branch 'main' into main 2025-04-16 15:05:54 +08:00
Giteabot
5cc1bda7c9
Merge branch 'main' into main 2025-04-16 14:52:59 +08:00
badhezi
f2a2acf146
Merge branch 'main' into main 2025-04-15 20:07:40 +03:00
badhezi
8d799c236c use the correct context data for PR link template in issue card 2025-04-15 19:56:19 +03:00
39 changed files with 442 additions and 119 deletions

View File

@ -91,6 +91,7 @@ module.exports = {
plugins: ['@vitest/eslint-plugin'], plugins: ['@vitest/eslint-plugin'],
globals: vitestPlugin.environments.env.globals, globals: vitestPlugin.environments.env.globals,
rules: { rules: {
'github/unescaped-html-literal': [0],
'@vitest/consistent-test-filename': [0], '@vitest/consistent-test-filename': [0],
'@vitest/consistent-test-it': [0], '@vitest/consistent-test-it': [0],
'@vitest/expect-expect': [0], '@vitest/expect-expect': [0],
@ -423,7 +424,7 @@ module.exports = {
'github/no-useless-passive': [2], 'github/no-useless-passive': [2],
'github/prefer-observers': [2], 'github/prefer-observers': [2],
'github/require-passive-events': [2], 'github/require-passive-events': [2],
'github/unescaped-html-literal': [0], 'github/unescaped-html-literal': [2],
'grouped-accessor-pairs': [2], 'grouped-accessor-pairs': [2],
'guard-for-in': [0], 'guard-for-in': [0],
'id-blacklist': [0], 'id-blacklist': [0],

View File

@ -2769,6 +2769,8 @@ branch.new_branch_from = Create new branch from "%s"
branch.renamed = Branch %s was renamed to %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_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.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 = Create tag %s
tag.create_tag_operation = Create tag tag.create_tag_operation = Create tag

View File

@ -2782,6 +2782,7 @@ topic.done=Déanta
topic.count_prompt=Ní féidir leat níos mó ná 25 topaicí a roghnú topic.count_prompt=Ní féidir leat níos mó ná 25 topaicí a roghnú
topic.format_prompt=Ní mór do thopaicí tosú le litir nó uimhir, is féidir daiseanna ('-') agus poncanna ('.') a áireamh, a bheith suas le 35 carachtar ar fad. Ní mór litreacha a bheith i litreacha beaga. topic.format_prompt=Ní mór do thopaicí tosú le litir nó uimhir, is féidir daiseanna ('-') agus poncanna ('.') a áireamh, a bheith suas le 35 carachtar ar fad. Ní mór litreacha a bheith i litreacha beaga.
find_file.follow_symlink=Lean an nasc siombalach seo go dtí an áit a bhfuil sé ag pointeáil air
find_file.go_to_file=Téigh go dtí an comhad find_file.go_to_file=Téigh go dtí an comhad
find_file.no_matching=Níl aon chomhad meaitseála le fáil find_file.no_matching=Níl aon chomhad meaitseála le fáil

View File

@ -1562,8 +1562,8 @@ issues.filter_project=Planeamento
issues.filter_project_all=Todos os planeamentos issues.filter_project_all=Todos os planeamentos
issues.filter_project_none=Nenhum planeamento issues.filter_project_none=Nenhum planeamento
issues.filter_assignee=Encarregado issues.filter_assignee=Encarregado
issues.filter_assignee_no_assignee=Não atribuído issues.filter_assignee_no_assignee=Não atribuída
issues.filter_assignee_any_assignee=Atribuído a qualquer pessoa issues.filter_assignee_any_assignee=Atribuída a alguém
issues.filter_poster=Autor(a) issues.filter_poster=Autor(a)
issues.filter_user_placeholder=Procurar utilizadores issues.filter_user_placeholder=Procurar utilizadores
issues.filter_user_no_select=Todos os utilizadores issues.filter_user_no_select=Todos os utilizadores
@ -1969,6 +1969,7 @@ pulls.cmd_instruction_checkout_title=Checkout
pulls.cmd_instruction_checkout_desc=A partir do seu repositório, crie um novo ramo e teste nele as modificações. pulls.cmd_instruction_checkout_desc=A partir do seu repositório, crie um novo ramo e teste nele as modificações.
pulls.cmd_instruction_merge_title=Integrar pulls.cmd_instruction_merge_title=Integrar
pulls.cmd_instruction_merge_desc=Integrar as modificações e enviar para o Gitea. pulls.cmd_instruction_merge_desc=Integrar as modificações e enviar para o Gitea.
pulls.cmd_instruction_merge_warning=Aviso: Esta operação não pode executar pedidos de integração porque a opção "auto-identificar integração manual" não está habilitada.
pulls.clear_merge_message=Apagar mensagem de integração pulls.clear_merge_message=Apagar mensagem de integração
pulls.clear_merge_message_hint=Apagar a mensagem de integração apenas remove o conteúdo da mensagem de cometimento e mantém os rodapés do git, tais como "Co-Autorado-Por …". pulls.clear_merge_message_hint=Apagar a mensagem de integração apenas remove o conteúdo da mensagem de cometimento e mantém os rodapés do git, tais como "Co-Autorado-Por …".
@ -2781,6 +2782,7 @@ topic.done=Concluído
topic.count_prompt=Não pode escolher mais do que 25 tópicos topic.count_prompt=Não pode escolher mais do que 25 tópicos
topic.format_prompt=Os tópicos devem começar com uma letra ou um número, podem incluir traços ('-') ou pontos ('.') e podem ter até 35 caracteres. As letras têm que ser minúsculas. topic.format_prompt=Os tópicos devem começar com uma letra ou um número, podem incluir traços ('-') ou pontos ('.') e podem ter até 35 caracteres. As letras têm que ser minúsculas.
find_file.follow_symlink=Seguir esta ligação simbólica para onde ela está apontando
find_file.go_to_file=Ir para o ficheiro find_file.go_to_file=Ir para o ficheiro
find_file.no_matching=Não foi encontrado qualquer ficheiro correspondente find_file.no_matching=Não foi encontrado qualquer ficheiro correspondente

13
package-lock.json generated
View File

@ -28,7 +28,6 @@
"dropzone": "6.0.0-beta.2", "dropzone": "6.0.0-beta.2",
"easymde": "2.20.0", "easymde": "2.20.0",
"esbuild-loader": "4.3.0", "esbuild-loader": "4.3.0",
"escape-goat": "4.0.0",
"fast-glob": "3.3.3", "fast-glob": "3.3.3",
"htmx.org": "2.0.6", "htmx.org": "2.0.6",
"idiomorph": "0.7.3", "idiomorph": "0.7.3",
@ -6563,18 +6562,6 @@
"node": ">=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": { "node_modules/escape-string-regexp": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",

View File

@ -27,7 +27,6 @@
"dropzone": "6.0.0-beta.2", "dropzone": "6.0.0-beta.2",
"easymde": "2.20.0", "easymde": "2.20.0",
"esbuild-loader": "4.3.0", "esbuild-loader": "4.3.0",
"escape-goat": "4.0.0",
"fast-glob": "3.3.3", "fast-glob": "3.3.3",
"htmx.org": "2.0.6", "htmx.org": "2.0.6",
"idiomorph": "0.7.3", "idiomorph": "0.7.3",

View File

@ -18,4 +18,5 @@ type CompareInfo struct {
BaseBranch string BaseBranch string
HeadBranch string HeadBranch string
DirectComparison bool DirectComparison bool
RawDiffType git.RawDiffType
} }

View File

@ -221,13 +221,9 @@ func ParseCompareInfo(ctx *context.Context) *common.CompareInfo {
// base<-head: master...head:feature // base<-head: master...head:feature
// same repo: master...feature // same repo: master...feature
var ( var isSameRepo bool
isSameRepo bool
infoPath string
err error
)
infoPath = ctx.PathParam("*") infoPath := ctx.PathParam("*")
var infos []string var infos []string
if infoPath == "" { if infoPath == "" {
infos = []string{baseRepo.DefaultBranch, baseRepo.DefaultBranch} infos = []string{baseRepo.DefaultBranch, baseRepo.DefaultBranch}
@ -247,15 +243,17 @@ func ParseCompareInfo(ctx *context.Context) *common.CompareInfo {
ci.BaseBranch = infos[0] ci.BaseBranch = infos[0]
ctx.Data["BaseBranch"] = ci.BaseBranch ctx.Data["BaseBranch"] = ci.BaseBranch
// If there is no head repository, it means compare between same repository. var err error
// If there is no head repository, it means compare between the same repository.
headInfos := strings.Split(infos[1], ":") headInfos := strings.Split(infos[1], ":")
if len(headInfos) == 1 { if len(headInfos) == 1 { // {:headBranch} case, guaranteed baseRepo is headRepo
isSameRepo = true isSameRepo = true
ci.HeadUser = ctx.Repo.Owner ci.HeadUser = ctx.Repo.Owner
ci.HeadBranch = headInfos[0] ci.HeadBranch, ci.RawDiffType = parseRefForRawDiff(ctx, baseRepo, headInfos[0])
} else if len(headInfos) == 2 { } else if len(headInfos) == 2 { // {:headOwner}:{:headBranch} or {:headOwner}/{:headRepoName}:{:headBranch} case
headInfosSplit := strings.Split(headInfos[0], "/") headInfosSplit := strings.Split(headInfos[0], "/")
if len(headInfosSplit) == 1 { if len(headInfosSplit) == 1 { // {:headOwner}:{:headBranch} case, guaranteed baseRepo.Name is headRepo.Name
ci.HeadUser, err = user_model.GetUserByName(ctx, headInfos[0]) ci.HeadUser, err = user_model.GetUserByName(ctx, headInfos[0])
if err != nil { if err != nil {
if user_model.IsErrUserNotExist(err) { if user_model.IsErrUserNotExist(err) {
@ -265,12 +263,23 @@ func ParseCompareInfo(ctx *context.Context) *common.CompareInfo {
} }
return nil return nil
} }
ci.HeadBranch = headInfos[1]
headRepo, err := repo_model.GetRepositoryByOwnerAndName(ctx, ci.HeadUser.Name, baseRepo.Name)
if err != nil {
if repo_model.IsErrRepoNotExist(err) {
ctx.NotFound(nil)
} else {
ctx.ServerError("GetRepositoryByOwnerAndName", err)
}
return nil
}
ci.HeadBranch, ci.RawDiffType = parseRefForRawDiff(ctx, headRepo, headInfos[1])
isSameRepo = ci.HeadUser.ID == ctx.Repo.Owner.ID isSameRepo = ci.HeadUser.ID == ctx.Repo.Owner.ID
if isSameRepo { if isSameRepo { // not a fork
ci.HeadRepo = baseRepo ci.HeadRepo = baseRepo
} }
} else { } else { // {:headOwner}/{:headRepoName}:{:headBranch} case, across forks
ci.HeadRepo, err = repo_model.GetRepositoryByOwnerAndName(ctx, headInfosSplit[0], headInfosSplit[1]) ci.HeadRepo, err = repo_model.GetRepositoryByOwnerAndName(ctx, headInfosSplit[0], headInfosSplit[1])
if err != nil { if err != nil {
if repo_model.IsErrRepoNotExist(err) { if repo_model.IsErrRepoNotExist(err) {
@ -288,7 +297,7 @@ func ParseCompareInfo(ctx *context.Context) *common.CompareInfo {
} }
return nil return nil
} }
ci.HeadBranch = headInfos[1] ci.HeadBranch, ci.RawDiffType = parseRefForRawDiff(ctx, ci.HeadRepo, headInfos[1])
ci.HeadUser = ci.HeadRepo.Owner ci.HeadUser = ci.HeadRepo.Owner
isSameRepo = ci.HeadRepo.ID == ctx.Repo.Repository.ID isSameRepo = ci.HeadRepo.ID == ctx.Repo.Repository.ID
} }
@ -735,6 +744,7 @@ func CompareDiff(ctx *context.Context) {
return return
} }
ctx.Data["PageIsCompareDiff"] = true
ctx.Data["PullRequestWorkInProgressPrefixes"] = setting.Repository.PullRequest.WorkInProgressPrefixes ctx.Data["PullRequestWorkInProgressPrefixes"] = setting.Repository.PullRequest.WorkInProgressPrefixes
ctx.Data["DirectComparison"] = ci.DirectComparison ctx.Data["DirectComparison"] = ci.DirectComparison
ctx.Data["OtherCompareSeparator"] = ".." ctx.Data["OtherCompareSeparator"] = ".."
@ -749,6 +759,15 @@ func CompareDiff(ctx *context.Context) {
return return
} }
if ci.RawDiffType != "" {
err := git.GetRepoRawDiffForFile(ci.HeadGitRepo, ci.BaseBranch, ci.HeadBranch, ci.RawDiffType, "", ctx.Resp)
if err != nil {
ctx.ServerError("GetRepoRawDiffForFile", err)
return
}
return
}
baseTags, err := repo_model.GetTagNamesByRepoID(ctx, ctx.Repo.Repository.ID) baseTags, err := repo_model.GetTagNamesByRepoID(ctx, ctx.Repo.Repository.ID)
if err != nil { if err != nil {
ctx.ServerError("GetTagNamesByRepoID", err) ctx.ServerError("GetTagNamesByRepoID", err)
@ -990,3 +1009,20 @@ func getExcerptLines(commit *git.Commit, filePath string, idxLeft, idxRight, chu
} }
return diffLines, nil return diffLines, nil
} }
func parseRefForRawDiff(ctx *context.Context, refRepo *repo_model.Repository, refShortName string) (string, git.RawDiffType) {
if !strings.HasSuffix(refShortName, ".diff") && !strings.HasSuffix(refShortName, ".patch") {
return refShortName, ""
}
if gitrepo.IsBranchExist(ctx, refRepo, refShortName) || gitrepo.IsTagExist(ctx, refRepo, refShortName) {
return refShortName, ""
}
if s, ok := strings.CutSuffix(refShortName, ".diff"); ok {
return s, git.RawDiffNormal
} else if s, ok = strings.CutSuffix(refShortName, ".patch"); ok {
return s, git.RawDiffPatch
}
return refShortName, ""
}

View File

@ -443,6 +443,10 @@ func ViewPullMergeBox(ctx *context.Context) {
preparePullViewPullInfo(ctx, issue) preparePullViewPullInfo(ctx, issue)
preparePullViewReviewAndMerge(ctx, issue) preparePullViewReviewAndMerge(ctx, issue)
ctx.Data["PullMergeBoxReloading"] = issue.PullRequest.IsChecking() 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) ctx.HTML(http.StatusOK, tplPullMergeBox)
} }

View File

@ -107,8 +107,14 @@
{{end}} {{end}}
</td> </td>
<td class="two wide ui"> <td class="two wide ui">
{{if and (not .DBBranch.IsDeleted) $.DefaultBranchBranch}} {{if and (not .DBBranch.IsDeleted) $.DefaultBranchBranch}}
<div class="commit-divergence"> {{$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="bar-group">
<div class="count count-behind">{{.CommitsBehind}}</div> <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. */}} {{/* 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 class="bar bar-ahead" style="width: {{Eval 100 "*" .CommitsAhead "/" "(" .CommitsBehind "+" .CommitsAhead "+" 0.0 ")"}}%"></div>
</div> </div>
</div> </div>
{{end}} {{end}}
</td> </td>
<td class="two wide tw-text-right"> <td class="two wide tw-text-right">
{{if not .LatestPullRequest}} {{if not .LatestPullRequest}}

View File

@ -10,6 +10,9 @@
{{else if .Commit.ID.String}} {{else if .Commit.ID.String}}
<a class="item" href="{{$.RepoLink}}/commit/{{PathEscape .Commit.ID.String}}.patch" download="{{ShortSha .Commit.ID.String}}.patch">{{ctx.Locale.Tr "repo.diff.download_patch"}}</a> <a class="item" href="{{$.RepoLink}}/commit/{{PathEscape .Commit.ID.String}}.patch" download="{{ShortSha .Commit.ID.String}}.patch">{{ctx.Locale.Tr "repo.diff.download_patch"}}</a>
<a class="item" href="{{$.RepoLink}}/commit/{{PathEscape .Commit.ID.String}}.diff" download="{{ShortSha .Commit.ID.String}}.diff">{{ctx.Locale.Tr "repo.diff.download_diff"}}</a> <a class="item" href="{{$.RepoLink}}/commit/{{PathEscape .Commit.ID.String}}.diff" download="{{ShortSha .Commit.ID.String}}.diff">{{ctx.Locale.Tr "repo.diff.download_diff"}}</a>
{{else if $.PageIsCompareDiff}}
<a class="item" href="{{$.Link}}.patch" download="{{$.BaseBranch}}...{{$.HeadBranch}}.patch">{{ctx.Locale.Tr "repo.diff.download_patch"}}</a>
<a class="item" href="{{$.Link}}.diff" download="{{$.BaseBranch}}...{{$.HeadBranch}}.diff">{{ctx.Locale.Tr "repo.diff.download_diff"}}</a>
{{end}} {{end}}
<a id="expand-files-btn" class="item">{{ctx.Locale.Tr "repo.pulls.expand_files"}}</a> <a id="expand-files-btn" class="item">{{ctx.Locale.Tr "repo.pulls.expand_files"}}</a>
<a id="collapse-files-btn" class="item">{{ctx.Locale.Tr "repo.pulls.collapse_files"}}</a> <a id="collapse-files-btn" class="item">{{ctx.Locale.Tr "repo.pulls.collapse_files"}}</a>

View File

@ -1,5 +1,5 @@
{{if and (or .HasIssuesOrPullsWritePermission .IsIssuePoster) (not .HasMerged) (not .Issue.IsClosed) (not .IsPullWorkInProgress)}} {{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)}} {{ctx.Locale.Tr "repo.pulls.still_in_progress"}} {{ctx.Locale.Tr "repo.pulls.add_prefix" (index .PullRequestWorkInProgressPrefixes 0)}}
</a> </a>
{{end}} {{end}}

View File

@ -95,7 +95,7 @@
{{ctx.Locale.Tr "repo.pulls.cannot_merge_work_in_progress"}} {{ctx.Locale.Tr "repo.pulls.cannot_merge_work_in_progress"}}
</div> </div>
{{if or .HasIssuesOrPullsWritePermission .IsIssuePoster}} {{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}} {{ctx.Locale.Tr "repo.pulls.remove_prefix" .WorkInProgressPrefix}}
</button> </button>
{{end}} {{end}}

View File

@ -9,10 +9,13 @@ import (
"net/url" "net/url"
"strings" "strings"
"testing" "testing"
"time"
"code.gitea.io/gitea/models/db" "code.gitea.io/gitea/models/db"
"code.gitea.io/gitea/models/unittest" "code.gitea.io/gitea/models/unittest"
user_model "code.gitea.io/gitea/models/user" user_model "code.gitea.io/gitea/models/user"
git_module "code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/gitrepo"
"code.gitea.io/gitea/modules/test" "code.gitea.io/gitea/modules/test"
repo_service "code.gitea.io/gitea/services/repository" repo_service "code.gitea.io/gitea/services/repository"
"code.gitea.io/gitea/tests" "code.gitea.io/gitea/tests"
@ -158,3 +161,212 @@ func TestCompareCodeExpand(t *testing.T) {
} }
}) })
} }
func TestCompareRawDiffNormal(t *testing.T) {
onGiteaRun(t, func(t *testing.T, u *url.URL) {
user1 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
repo, err := repo_service.CreateRepositoryDirectly(db.DefaultContext, user1, user1, repo_service.CreateRepoOptions{
Name: "test_raw_diff",
Readme: "Default",
AutoInit: true,
DefaultBranch: "main",
}, true)
assert.NoError(t, err)
session := loginUser(t, user1.Name)
r, _ := gitrepo.OpenRepository(db.DefaultContext, repo)
oldRef, _ := r.GetBranchCommit(repo.DefaultBranch)
oldBlobRef, _ := revParse(r, oldRef.ID.String(), "README.md")
testEditFile(t, session, user1.Name, repo.Name, "main", "README.md", strings.Repeat("a\n", 2))
newRef, _ := r.GetBranchCommit(repo.DefaultBranch)
newBlobRef, _ := revParse(r, newRef.ID.String(), "README.md")
req := NewRequest(t, "GET", fmt.Sprintf("/user1/test_raw_diff/compare/%s...%s.diff", oldRef.ID.String(), newRef.ID.String()))
resp := session.MakeRequest(t, req, http.StatusOK)
expected := fmt.Sprintf(`diff --git a/README.md b/README.md
index %s..%s 100644
--- a/README.md
+++ b/README.md
@@ -1,2 +1,2 @@
-# test_raw_diff
-
+a
+a
`, oldBlobRef[:7], newBlobRef[:7])
assert.Equal(t, expected, resp.Body.String())
})
}
func TestCompareRawDiffPatch(t *testing.T) {
onGiteaRun(t, func(t *testing.T, u *url.URL) {
user1 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
repo, err := repo_service.CreateRepositoryDirectly(db.DefaultContext, user1, user1, repo_service.CreateRepoOptions{
Name: "test_raw_diff",
Readme: "Default",
AutoInit: true,
DefaultBranch: "main",
}, true)
assert.NoError(t, err)
session := loginUser(t, user1.Name)
r, _ := gitrepo.OpenRepository(db.DefaultContext, repo)
// Get the old commit and blob reference
oldRef, _ := r.GetBranchCommit(repo.DefaultBranch)
oldBlobRef, _ := revParse(r, oldRef.ID.String(), "README.md")
resp := testEditFile(t, session, user1.Name, repo.Name, "main", "README.md", strings.Repeat("a\n", 2))
newRef, _ := r.GetBranchCommit(repo.DefaultBranch)
newBlobRef, _ := revParse(r, newRef.ID.String(), "README.md")
// Get the last modified time from the response header
respTs, _ := time.Parse(time.RFC1123, resp.Result().Header.Get("Last-Modified"))
respTs = respTs.In(time.Local)
// Format the timestamp to match the expected format in the patch
customFormat := "Mon, 2 Jan 2006 15:04:05 -0700"
respTsStr := respTs.Format(customFormat)
req := NewRequest(t, "GET", fmt.Sprintf("/user1/test_raw_diff/compare/%s...%s.patch", oldRef.ID.String(), newRef.ID.String()))
resp = session.MakeRequest(t, req, http.StatusOK)
expected := fmt.Sprintf(`From %s Mon Sep 17 00:00:00 2001
From: User One <user1@example.com>
Date: %s
Subject: [PATCH] Update README.md
---
README.md | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/README.md b/README.md
index %s..%s 100644
--- a/README.md
+++ b/README.md
@@ -1,2 +1,2 @@
-# test_raw_diff
-
+a
+a
`, newRef.ID.String(), respTsStr, oldBlobRef[:7], newBlobRef[:7])
assert.Equal(t, expected, resp.Body.String())
})
}
func TestCompareRawDiffNormalSameOwnerDifferentRepo(t *testing.T) {
onGiteaRun(t, func(t *testing.T, u *url.URL) {
user1 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
repo, err := repo_service.CreateRepositoryDirectly(db.DefaultContext, user1, user1, repo_service.CreateRepoOptions{
Name: "test_raw_diff",
Readme: "Default",
AutoInit: true,
DefaultBranch: "main",
}, true)
assert.NoError(t, err)
session := loginUser(t, user1.Name)
headRepo, err := repo_service.CreateRepositoryDirectly(db.DefaultContext, user1, user1, repo_service.CreateRepoOptions{
Name: "test_raw_diff_head",
Readme: "Default",
AutoInit: true,
DefaultBranch: "main",
}, true)
assert.NoError(t, err)
r, _ := gitrepo.OpenRepository(db.DefaultContext, repo)
hr, _ := gitrepo.OpenRepository(db.DefaultContext, headRepo)
oldRef, _ := r.GetBranchCommit(repo.DefaultBranch)
oldBlobRef, _ := revParse(r, oldRef.ID.String(), "README.md")
testEditFile(t, session, user1.Name, headRepo.Name, "main", "README.md", strings.Repeat("a\n", 2))
newRef, _ := hr.GetBranchCommit(headRepo.DefaultBranch)
newBlobRef, _ := revParse(hr, newRef.ID.String(), "README.md")
req := NewRequest(t, "GET", fmt.Sprintf("/user1/test_raw_diff/compare/%s...%s/%s:%s.diff", oldRef.ID.String(), user1.LowerName, headRepo.LowerName, newRef.ID.String()))
resp := session.MakeRequest(t, req, http.StatusOK)
expected := fmt.Sprintf(`diff --git a/README.md b/README.md
index %s..%s 100644
--- a/README.md
+++ b/README.md
@@ -1,2 +1,2 @@
-# test_raw_diff
-
+a
+a
`, oldBlobRef[:7], newBlobRef[:7])
assert.Equal(t, expected, resp.Body.String())
})
}
func TestCompareRawDiffNormalAcrossForks(t *testing.T) {
onGiteaRun(t, func(t *testing.T, u *url.URL) {
user1 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
repo, err := repo_service.CreateRepositoryDirectly(db.DefaultContext, user1, user1, repo_service.CreateRepoOptions{
Name: "test_raw_diff",
Readme: "Default",
AutoInit: true,
DefaultBranch: "main",
}, true)
assert.NoError(t, err)
headRepo, err := repo_service.ForkRepository(db.DefaultContext, user2, user2, repo_service.ForkRepoOptions{
BaseRepo: repo,
Name: repo.Name,
Description: repo.Description,
SingleBranch: "",
})
assert.NoError(t, err)
session := loginUser(t, user2.Name)
r, _ := gitrepo.OpenRepository(db.DefaultContext, repo)
hr, _ := gitrepo.OpenRepository(db.DefaultContext, headRepo)
oldRef, _ := r.GetBranchCommit(repo.DefaultBranch)
oldBlobRef, _ := revParse(r, oldRef.ID.String(), "README.md")
testEditFile(t, session, user2.Name, headRepo.Name, "main", "README.md", strings.Repeat("a\n", 2))
newRef, _ := hr.GetBranchCommit(headRepo.DefaultBranch)
newBlobRef, _ := revParse(hr, newRef.ID.String(), "README.md")
session = loginUser(t, user1.Name)
req := NewRequest(t, "GET", fmt.Sprintf("/user1/test_raw_diff/compare/%s...%s:%s.diff", oldRef.ID.String(), user2.LowerName, newRef.ID.String()))
resp := session.MakeRequest(t, req, http.StatusOK)
expected := fmt.Sprintf(`diff --git a/README.md b/README.md
index %s..%s 100644
--- a/README.md
+++ b/README.md
@@ -1,2 +1,2 @@
-# test_raw_diff
-
+a
+a
`, oldBlobRef[:7], newBlobRef[:7])
assert.Equal(t, expected, resp.Body.String())
})
}
// helper function to use rev-parse
// revParse resolves a revision reference to other git-related objects
func revParse(repo *git_module.Repository, ref, file string) (string, error) {
stdout, _, err := git_module.NewCommand("rev-parse").
AddDynamicArguments(ref+":"+file).
RunStdString(repo.Ctx, &git_module.RunOpts{Dir: repo.Path})
if err != nil {
return "", err
}
return strings.TrimSpace(stdout), nil
}

View File

@ -2,6 +2,7 @@
// to make sure the error handler always works, we should never import `window.config`, because // to make sure the error handler always works, we should never import `window.config`, because
// some user's custom template breaks it. // some user's custom template breaks it.
import type {Intent} from './types.ts'; 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 sets up the URL prefix used in webpack's chunk loading.
// This file must be imported before any lazy-loading is being attempted. // 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}"]`); let msgDiv = msgContainer.querySelector<HTMLDivElement>(`.js-global-error[data-global-error-msg-compact="${msgCompact}"]`);
if (!msgDiv) { if (!msgDiv) {
const el = document.createElement('div'); 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; msgDiv = el.childNodes[0] as HTMLDivElement;
} }
// merge duplicated messages into "the message (count)" format // merge duplicated messages into "the message (count)" format

View File

@ -2,6 +2,7 @@ import {reactive} from 'vue';
import {GET} from '../modules/fetch.ts'; import {GET} from '../modules/fetch.ts';
import {pathEscapeSegments} from '../utils/url.ts'; import {pathEscapeSegments} from '../utils/url.ts';
import {createElementFromHTML} from '../utils/dom.ts'; import {createElementFromHTML} from '../utils/dom.ts';
import {html} from '../utils/html.ts';
export function createViewFileTreeStore(props: { repoLink: string, treePath: string, currentRefNameSubURL: string}) { export function createViewFileTreeStore(props: { repoLink: string, treePath: string, currentRefNameSubURL: string}) {
const store = reactive({ 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 (!document.querySelector(`.global-svg-icon-pool #${svgId}`)) poolSvgs.push(svgContent);
} }
if (poolSvgs.length) { 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(''); svgContainer.innerHTML = poolSvgs.join('');
document.body.append(svgContainer); document.body.append(svgContainer);
} }

View File

@ -43,13 +43,16 @@ export function initGlobalDeleteButton(): void {
fomanticQuery(modal).modal({ fomanticQuery(modal).modal({
closable: false, closable: false,
onApprove: async () => { onApprove: () => {
// if `data-type="form"` exists, then submit the form by the selector provided by `data-form="..."` // if `data-type="form"` exists, then submit the form by the selector provided by `data-form="..."`
if (btn.getAttribute('data-type') === 'form') { if (btn.getAttribute('data-type') === 'form') {
const formSelector = btn.getAttribute('data-form'); const formSelector = btn.getAttribute('data-form');
const form = document.querySelector<HTMLFormElement>(formSelector); const form = document.querySelector<HTMLFormElement>(formSelector);
if (!form) throw new Error(`no form named ${formSelector} found`); 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(); form.submit();
return false; // prevent modal from closing automatically
} }
// prepare an AJAX form by data attributes // prepare an AJAX form by data attributes
@ -62,12 +65,15 @@ export function initGlobalDeleteButton(): void {
postData.append('id', value); postData.append('id', value);
} }
} }
(async () => {
const response = await POST(btn.getAttribute('data-url'), {data: postData}); const response = await POST(btn.getAttribute('data-url'), {data: postData});
if (response.ok) { if (response.ok) {
const data = await response.json(); const data = await response.json();
window.location.href = data.redirect; 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'); }).modal('show');
}); });
@ -158,13 +164,7 @@ function onShowModalClick(el: HTMLElement, e: MouseEvent) {
} }
} }
fomanticQuery(elModal).modal('setting', { fomanticQuery(elModal).modal('show');
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');
} }
export function initGlobalButtons(): void { export function initGlobalButtons(): void {

View File

@ -1,5 +1,5 @@
import {svg} from '../../svg.ts'; import {svg} from '../../svg.ts';
import {htmlEscape} from 'escape-goat'; import {html, htmlRaw} from '../../utils/html.ts';
import {createElementFromHTML} from '../../utils/dom.ts'; import {createElementFromHTML} from '../../utils/dom.ts';
import {fomanticQuery} from '../../modules/fomantic/base.ts'; import {fomanticQuery} from '../../modules/fomantic/base.ts';
@ -12,17 +12,17 @@ type ConfirmModalOptions = {
} }
export function createConfirmModal({header = '', content = '', confirmButtonColor = 'primary'}:ConfirmModalOptions = {}): HTMLElement { export function createConfirmModal({header = '', content = '', confirmButtonColor = 'primary'}:ConfirmModalOptions = {}): HTMLElement {
const headerHtml = header ? `<div class="header">${htmlEscape(header)}</div>` : ''; const headerHtml = header ? html`<div class="header">${header}</div>` : '';
return createElementFromHTML(` return createElementFromHTML(html`
<div class="ui g-modal-confirm modal"> <div class="ui g-modal-confirm modal">
${headerHtml} ${htmlRaw(headerHtml)}
<div class="content">${htmlEscape(content)}</div> <div class="content">${content}</div>
<div class="actions"> <div class="actions">
<button class="ui cancel button">${svg('octicon-x')} ${htmlEscape(i18n.modal_cancel)}</button> <button class="ui cancel button">${htmlRaw(svg('octicon-x'))} ${i18n.modal_cancel}</button>
<button class="ui ${confirmButtonColor} ok button">${svg('octicon-check')} ${htmlEscape(i18n.modal_confirm)}</button> <button class="ui ${confirmButtonColor} ok button">${htmlRaw(svg('octicon-check'))} ${i18n.modal_confirm}</button>
</div> </div>
</div> </div>
`); `.trim());
} }
export function confirmModal(modal: HTMLElement | ConfirmModalOptions): Promise<boolean> { export function confirmModal(modal: HTMLElement | ConfirmModalOptions): Promise<boolean> {

View File

@ -114,7 +114,7 @@ async function handleUploadFiles(editor: CodeMirrorEditor | TextareaEditor, drop
export function removeAttachmentLinksFromMarkdown(text: string, fileUuid: string) { export function removeAttachmentLinksFromMarkdown(text: string, fileUuid: string) {
text = text.replace(new RegExp(`!?\\[([^\\]]+)\\]\\(/?attachments/${fileUuid}\\)`, 'g'), ''); 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; return text;
} }

View File

@ -72,6 +72,7 @@ export function initCompLabelEdit(pageSelector: string) {
return false; return false;
} }
submitFormFetchAction(form); submitFormFetchAction(form);
return false;
}, },
}).modal('show'); }).modal('show');
}; };

View File

@ -1,4 +1,4 @@
import {htmlEscape} from 'escape-goat'; import {htmlEscape} from '../../utils/html.ts';
import {fomanticQuery} from '../../modules/fomantic/base.ts'; import {fomanticQuery} from '../../modules/fomantic/base.ts';
const {appSubUrl} = window.config; const {appSubUrl} = window.config;

View File

@ -1,5 +1,5 @@
import {svg} from '../svg.ts'; import {svg} from '../svg.ts';
import {htmlEscape} from 'escape-goat'; import {html} from '../utils/html.ts';
import {clippie} from 'clippie'; import {clippie} from 'clippie';
import {showTemporaryTooltip} from '../modules/tippy.ts'; import {showTemporaryTooltip} from '../modules/tippy.ts';
import {GET, POST} from '../modules/fetch.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 // 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. // 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}" // 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 { } else {
// Markdown always renders the image with a relative path, so the final URL is "/sub-path/owner/repo/attachments/{uuid}" // 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" // 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})`; fileMarkdown = `![${file.name}](/attachments/${file.uuid})`;
} }
} else if (isVideoFile(file)) { } 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; return fileMarkdown;
} }

View File

@ -1,4 +1,5 @@
import emojis from '../../../assets/emoji.json' with {type: 'json'}; import emojis from '../../../assets/emoji.json' with {type: 'json'};
import {html} from '../utils/html.ts';
const {assetUrlPrefix, customEmojis} = window.config; const {assetUrlPrefix, customEmojis} = window.config;
@ -24,12 +25,11 @@ for (const key of emojiKeys) {
export function emojiHTML(name: string) { export function emojiHTML(name: string) {
let inner; let inner;
if (Object.hasOwn(customEmojis, name)) { 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 { } else {
inner = emojiString(name); inner = emojiString(name);
} }
return html`<span class="emoji" title=":${name}:">${inner}</span>`;
return `<span class="emoji" title=":${name}:">${inner}</span>`;
} }
// retrieve string for given emoji name // retrieve string for given emoji name

View File

@ -3,7 +3,7 @@ import {newRenderPlugin3DViewer} from '../render/plugins/3d-viewer.ts';
import {newRenderPluginPdfViewer} from '../render/plugins/pdf-viewer.ts'; import {newRenderPluginPdfViewer} from '../render/plugins/pdf-viewer.ts';
import {registerGlobalInitFunc} from '../modules/observer.ts'; import {registerGlobalInitFunc} from '../modules/observer.ts';
import {createElementFromHTML, showElem, toggleClass} from '../utils/dom.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'; import {basename} from '../utils.ts';
const plugins: FileRenderPlugin[] = []; const plugins: FileRenderPlugin[] = [];
@ -54,7 +54,7 @@ async function renderRawFileToContainer(container: HTMLElement, rawFileLink: str
container.replaceChildren(elViewRawPrompt); container.replaceChildren(elViewRawPrompt);
if (errorMsg) { 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); elViewRawPrompt.insertAdjacentElement('afterbegin', elErrorMessage);
} }
} }

View File

@ -1,4 +1,4 @@
import {htmlEscape} from 'escape-goat'; import {html, htmlRaw} from '../utils/html.ts';
import {createCodeEditor} from './codeeditor.ts'; import {createCodeEditor} from './codeeditor.ts';
import {hideElem, queryElems, showElem, createElementFromHTML} from '../utils/dom.ts'; import {hideElem, queryElems, showElem, createElementFromHTML} from '../utils/dom.ts';
import {attachRefIssueContextPopup} from './contextpopup.ts'; import {attachRefIssueContextPopup} from './contextpopup.ts';
@ -87,10 +87,10 @@ export function initRepoEditor() {
if (i < parts.length - 1) { if (i < parts.length - 1) {
if (trimValue.length) { if (trimValue.length) {
const linkElement = createElementFromHTML( const linkElement = createElementFromHTML(
`<span class="section"><a href="#">${htmlEscape(value)}</a></span>`, html`<span class="section"><a href="#">${value}</a></span>`,
); );
const dividerElement = createElementFromHTML( const dividerElement = createElementFromHTML(
`<div class="breadcrumb-divider">/</div>`, html`<div class="breadcrumb-divider">/</div>`,
); );
links.push(linkElement); links.push(linkElement);
dividers.push(dividerElement); dividers.push(dividerElement);
@ -113,7 +113,7 @@ export function initRepoEditor() {
if (!warningDiv) { if (!warningDiv) {
warningDiv = document.createElement('div'); warningDiv = document.createElement('div');
warningDiv.classList.add('ui', 'warning', 'message', 'flash-message', 'flash-warning', 'space-related'); 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 // Add display 'block' because display is set to 'none' in formantic\build\semantic.css
warningDiv.style.display = 'block'; warningDiv.style.display = 'block';
const inputContainer = document.querySelector('.repo-editor-header'); const inputContainer = document.querySelector('.repo-editor-header');
@ -196,7 +196,8 @@ export function initRepoEditor() {
})(); })();
} }
export function renderPreviewPanelContent(previewPanel: Element, content: string) { export function renderPreviewPanelContent(previewPanel: Element, htmlContent: string) {
previewPanel.innerHTML = `<div class="render-content markup">${content}</div>`; // 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')); attachRefIssueContextPopup(previewPanel.querySelectorAll('p .ref-issue'));
} }

View File

@ -1,6 +1,6 @@
import {updateIssuesMeta} from './repo-common.ts'; import {updateIssuesMeta} from './repo-common.ts';
import {toggleElem, queryElems, isElemVisible} from '../utils/dom.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 {confirmModal} from './comp/ConfirmModal.ts';
import {showErrorToast} from '../modules/toast.ts'; import {showErrorToast} from '../modules/toast.ts';
import {createSortable} from '../modules/sortable.ts'; import {createSortable} from '../modules/sortable.ts';
@ -138,10 +138,10 @@ function initDropdownUserRemoteSearch(el: Element) {
// the content is provided by backend IssuePosters handler // the content is provided by backend IssuePosters handler
processedResults.length = 0; processedResults.length = 0;
for (const item of resp.results) { 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>`; 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) html += `<span class="search-fullname tw-ml-2">${htmlEscape(item.full_name)}</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; 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; resp.results = processedResults;
return resp; return resp;

View File

@ -1,4 +1,4 @@
import {htmlEscape} from 'escape-goat'; import {html, htmlEscape} from '../utils/html.ts';
import {createTippy, showTemporaryTooltip} from '../modules/tippy.ts'; import {createTippy, showTemporaryTooltip} from '../modules/tippy.ts';
import { import {
addDelegatedEventListener, addDelegatedEventListener,
@ -17,6 +17,7 @@ import {showErrorToast} from '../modules/toast.ts';
import {initRepoIssueSidebar} from './repo-issue-sidebar.ts'; import {initRepoIssueSidebar} from './repo-issue-sidebar.ts';
import {fomanticQuery} from '../modules/fomantic/base.ts'; import {fomanticQuery} from '../modules/fomantic/base.ts';
import {ignoreAreYouSure} from '../vendor/jquery.are-you-sure.ts'; import {ignoreAreYouSure} from '../vendor/jquery.are-you-sure.ts';
import {registerGlobalInitFunc} from '../modules/observer.ts';
const {appSubUrl} = window.config; const {appSubUrl} = window.config;
@ -45,8 +46,7 @@ export function initRepoIssueSidebarDependency() {
if (String(issue.id) === currIssueId) continue; if (String(issue.id) === currIssueId) continue;
filteredResponse.results.push({ filteredResponse.results.push({
value: issue.id, value: issue.id,
name: `<div class="gt-ellipsis">#${issue.number} ${htmlEscape(issue.title)}</div> name: html`<div class="gt-ellipsis">#${issue.number} ${issue.title}</div><div class="text small tw-break-anywhere">${issue.repository.full_name}</div>`,
<div class="text small tw-break-anywhere">${htmlEscape(issue.repository.full_name)}</div>`,
}); });
} }
return filteredResponse; return filteredResponse;
@ -416,25 +416,20 @@ export function initRepoIssueWipNewTitle() {
export function initRepoIssueWipToggle() { export function initRepoIssueWipToggle() {
// Toggle WIP for existing PR // 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(); e.preventDefault();
const toggleWip = el;
const title = toggleWip.getAttribute('data-title'); const title = toggleWip.getAttribute('data-title');
const wipPrefix = toggleWip.getAttribute('data-wip-prefix'); const wipPrefix = toggleWip.getAttribute('data-wip-prefix');
const updateUrl = toggleWip.getAttribute('data-update-url'); const updateUrl = toggleWip.getAttribute('data-update-url');
try { const params = new URLSearchParams();
const params = new URLSearchParams(); params.append('title', title?.startsWith(wipPrefix) ? title.slice(wipPrefix.length).trim() : `${wipPrefix.trim()} ${title}`);
params.append('title', title?.startsWith(wipPrefix) ? title.slice(wipPrefix.length).trim() : `${wipPrefix.trim()} ${title}`); const response = await POST(updateUrl, {data: params});
if (!response.ok) {
const response = await POST(updateUrl, {data: params}); showErrorToast(`Failed to toggle 'work in progress' status`);
if (!response.ok) { return;
throw new Error('Failed to toggle WIP status');
}
window.location.reload();
} catch (error) {
console.error(error);
} }
window.location.reload();
})); }));
} }

View File

@ -1,5 +1,5 @@
import {hideElem, querySingleVisibleElem, showElem, toggleElem} from '../utils/dom.ts'; 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 {fomanticQuery} from '../modules/fomantic/base.ts';
import {sanitizeRepoName} from './repo-common.ts'; import {sanitizeRepoName} from './repo-common.ts';

View File

@ -2,6 +2,7 @@ import {validateTextareaNonEmpty, initComboMarkdownEditor} from './comp/ComboMar
import {fomanticMobileScreen} from '../modules/fomantic.ts'; import {fomanticMobileScreen} from '../modules/fomantic.ts';
import {POST} from '../modules/fetch.ts'; import {POST} from '../modules/fetch.ts';
import type {ComboMarkdownEditor} from './comp/ComboMarkdownEditor.ts'; import type {ComboMarkdownEditor} from './comp/ComboMarkdownEditor.ts';
import {html, htmlRaw} from '../utils/html.ts';
async function initRepoWikiFormEditor() { async function initRepoWikiFormEditor() {
const editArea = document.querySelector<HTMLTextAreaElement>('.repository.wiki .combo-markdown-editor textarea'); 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 response = await POST(editor.previewUrl, {data: formData});
const data = await response.text(); const data = await response.text();
lastContent = newContent; 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) { } catch (error) {
console.error('Error rendering preview:', error); console.error('Error rendering preview:', error);
} finally { } finally {

View File

@ -1,5 +1,5 @@
import {emojiKeys, emojiHTML, emojiString} from './emoji.ts'; import {emojiKeys, emojiHTML, emojiString} from './emoji.ts';
import {htmlEscape} from 'escape-goat'; import {html, htmlRaw} from '../utils/html.ts';
type TributeItem = Record<string, any>; type TributeItem = Record<string, any>;
@ -26,17 +26,18 @@ export async function attachTribute(element: HTMLElement) {
return emojiString(item.original); return emojiString(item.original);
}, },
menuItemTemplate: (item: TributeItem) => { 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 }, { // mentions
values: window.config.mentionValues ?? [], values: window.config.mentionValues ?? [],
requireLeadingSpace: true, requireLeadingSpace: true,
menuItemTemplate: (item: TributeItem) => { 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"> <div class="tribute-item">
<img alt src="${htmlEscape(item.original.avatar)}" width="21" height="21"/> <img alt src="${item.original.avatar}" width="21" height="21"/>
<span class="name">${htmlEscape(item.original.name)}</span> <span class="name">${item.original.name}</span>
${item.original.fullname && item.original.fullname !== '' ? `<span class="fullname">${htmlEscape(item.original.fullname)}</span>` : ''} ${htmlRaw(fullNameHtml)}
</div> </div>
`; `;
}, },

View File

@ -1,4 +1,4 @@
import {htmlEscape} from 'escape-goat'; import {html, htmlRaw} from '../utils/html.ts';
type Processor = (el: HTMLElement) => string | HTMLElement | void; type Processor = (el: HTMLElement) => string | HTMLElement | void;
@ -38,10 +38,10 @@ function prepareProcessors(ctx:ProcessorContext): Processors {
IMG(el: HTMLElement) { IMG(el: HTMLElement) {
const alt = el.getAttribute('alt') || 'image'; const alt = el.getAttribute('alt') || 'image';
const src = el.getAttribute('src'); const src = el.getAttribute('src');
const widthAttr = el.hasAttribute('width') ? ` width="${htmlEscape(el.getAttribute('width') || '')}"` : ''; const widthAttr = el.hasAttribute('width') ? htmlRaw` width="${el.getAttribute('width') || ''}"` : '';
const heightAttr = el.hasAttribute('height') ? ` height="${htmlEscape(el.getAttribute('height') || '')}"` : ''; const heightAttr = el.hasAttribute('height') ? htmlRaw` height="${el.getAttribute('height') || ''}"` : '';
if (widthAttr || heightAttr) { 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})`; return `![${alt}](${src})`;
}, },

View File

@ -2,6 +2,7 @@ import {isDarkTheme} from '../utils.ts';
import {makeCodeCopyButton} from './codecopy.ts'; import {makeCodeCopyButton} from './codecopy.ts';
import {displayError} from './common.ts'; import {displayError} from './common.ts';
import {queryElems} from '../utils/dom.ts'; import {queryElems} from '../utils/dom.ts';
import {html, htmlRaw} from '../utils/html.ts';
const {mermaidMaxSourceCharacters} = window.config; const {mermaidMaxSourceCharacters} = window.config;
@ -46,7 +47,7 @@ export async function initMarkupCodeMermaid(elMarkup: HTMLElement): Promise<void
const iframe = document.createElement('iframe'); const iframe = document.createElement('iframe');
iframe.classList.add('markup-content-iframe', 'tw-invisible'); 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'); const mermaidBlock = document.createElement('div');
mermaidBlock.classList.add('mermaid-block', 'is-loading', 'tw-hidden'); mermaidBlock.classList.add('mermaid-block', 'is-loading', 'tw-hidden');

View File

@ -9,8 +9,9 @@ const fomanticModalFn = $.fn.modal;
export function initAriaModalPatch() { export function initAriaModalPatch() {
if ($.fn.modal === ariaModalFn) throw new Error('initAriaModalPatch could only be called once'); if ($.fn.modal === ariaModalFn) throw new Error('initAriaModalPatch could only be called once');
$.fn.modal = ariaModalFn; $.fn.modal = ariaModalFn;
$.fn.fomanticExt.onModalBeforeHidden = onModalBeforeHidden;
(ariaModalFn as FomanticInitFunction).settings = fomanticModalFn.settings; (ariaModalFn as FomanticInitFunction).settings = fomanticModalFn.settings;
$.fn.fomanticExt.onModalBeforeHidden = onModalBeforeHidden;
$.fn.modal.settings.onApprove = onModalApproveDefault;
} }
// the patched `$.fn.modal` modal function // the patched `$.fn.modal` modal function
@ -34,6 +35,29 @@ function ariaModalFn(this: any, ...args: Parameters<FomanticInitFunction>) {
function onModalBeforeHidden(this: any) { function onModalBeforeHidden(this: any) {
const $modal = $(this); const $modal = $(this);
const elModal = $modal[0]; const elModal = $modal[0];
queryElems(elModal, 'form', (form: HTMLFormElement) => form.reset());
hideToastsFrom(elModal.closest('.ui.dimmer') ?? document.body); 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;
} }

View File

@ -2,6 +2,7 @@ import tippy, {followCursor} from 'tippy.js';
import {isDocumentFragmentOrElementNode} from '../utils/dom.ts'; import {isDocumentFragmentOrElementNode} from '../utils/dom.ts';
import {formatDatetime} from '../utils/time.ts'; import {formatDatetime} from '../utils/time.ts';
import type {Content, Instance, Placement, Props} from 'tippy.js'; import type {Content, Instance, Placement, Props} from 'tippy.js';
import {html} from '../utils/html.ts';
type TippyOpts = { type TippyOpts = {
role?: string, role?: string,
@ -9,7 +10,7 @@ type TippyOpts = {
} & Partial<Props>; } & Partial<Props>;
const visibleInstances = new Set<Instance>(); 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 { export function createTippy(target: Element, opts: TippyOpts = {}): Instance {
// the callback functions should be destructured from opts, // the callback functions should be destructured from opts,

View File

@ -1,4 +1,4 @@
import {htmlEscape} from 'escape-goat'; import {htmlEscape} from '../utils/html.ts';
import {svg} from '../svg.ts'; import {svg} from '../svg.ts';
import {animateOnce, queryElems, showElem} from '../utils/dom.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 import Toastify from 'toastify-js'; // don't use "async import", because when network error occurs, the "async import" also fails and nothing is shown

View File

@ -1,5 +1,6 @@
import {defineComponent, h, type PropType} from 'vue'; import {defineComponent, h, type PropType} from 'vue';
import {parseDom, serializeXml} from './utils.ts'; 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 giteaDoubleChevronLeft from '../../public/assets/img/svg/gitea-double-chevron-left.svg';
import giteaDoubleChevronRight from '../../public/assets/img/svg/gitea-double-chevron-right.svg'; import giteaDoubleChevronRight from '../../public/assets/img/svg/gitea-double-chevron-right.svg';
import giteaEmptyCheckbox from '../../public/assets/img/svg/gitea-empty-checkbox.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); const classes = Array.from(svgOuter.classList);
if (this.symbolId) { if (this.symbolId) {
classes.push('tw-hidden', 'svg-symbol-container'); 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 // create VNode
return h('svg', { return h('svg', {

View File

@ -314,6 +314,7 @@ export function replaceTextareaSelection(textarea: HTMLTextAreaElement, text: st
export function createElementFromHTML<T extends HTMLElement>(htmlString: string): T { export function createElementFromHTML<T extends HTMLElement>(htmlString: string): T {
htmlString = htmlString.trim(); htmlString = htmlString.trim();
// some tags like "tr" are special, it must use a correct parent container to create // 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')) { if (htmlString.startsWith('<tr')) {
const container = document.createElement('table'); const container = document.createElement('table');
container.innerHTML = htmlString; container.innerHTML = htmlString;

View File

@ -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;`);
});

32
web_src/js/utils/html.ts Normal file
View File

@ -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));
}