Migrate from webpack to vite (#37002)

Replace webpack with Vite 8 as the frontend bundler. Frontend build is
around 3-4 times faster than before. Will work on all platforms
including riscv64 (via wasm).

`iife.js` is a classic render-blocking script in `<head>` (handles web
components/early DOM setup). `index.js` is loaded as a `type="module"`
script in the footer. All other JS chunks are also module scripts
(supported in all browsers since 2018).

Entry filenames are content-hashed (e.g. `index.C6Z2MRVQ.js`) and
resolved at runtime via the Vite manifest, eliminating the `?v=` cache
busting (which was unreliable in some scenarios like vscode dev build).

Replaces: https://github.com/go-gitea/gitea/pull/36896
Fixes: https://github.com/go-gitea/gitea/issues/17793
Signed-off-by: silverwind <me@silverwind.io>
Signed-off-by: wxiaoguang <wxiaoguang@gmail.com>
Co-authored-by: Claude (Opus 4.6) <noreply@anthropic.com>
Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
This commit is contained in:
silverwind
2026-03-29 12:24:30 +02:00
committed by GitHub
parent 6288c87181
commit 0ec66b5380
88 changed files with 1706 additions and 1727 deletions

View File

@@ -20,7 +20,6 @@
"customizations": { "customizations": {
"vscode": { "vscode": {
"settings": {}, "settings": {},
// same extensions as Gitpod, should match /.gitpod.yml
"extensions": [ "extensions": [
"editorconfig.editorconfig", "editorconfig.editorconfig",
"dbaeumer.vscode-eslint", "dbaeumer.vscode-eslint",

1
.github/labeler.yml vendored
View File

@@ -43,7 +43,6 @@ modifies/internal:
- ".editorconfig" - ".editorconfig"
- ".eslintrc.cjs" - ".eslintrc.cjs"
- ".golangci.yml" - ".golangci.yml"
- ".gitpod.yml"
- ".markdownlint.yaml" - ".markdownlint.yaml"
- ".spectral.yaml" - ".spectral.yaml"
- "stylelint.config.*" - "stylelint.config.*"

3
.gitignore vendored
View File

@@ -79,6 +79,7 @@ cpu.out
/yarn-error.log /yarn-error.log
/npm-debug.log* /npm-debug.log*
/.pnpm-store /.pnpm-store
/public/assets/.vite
/public/assets/js /public/assets/js
/public/assets/css /public/assets/css
/public/assets/fonts /public/assets/fonts
@@ -87,8 +88,6 @@ cpu.out
/VERSION /VERSION
/.air /.air
# Files and folders that were previously generated
/public/assets/img/webpack
# Snapcraft # Snapcraft
/gitea_a*.txt /gitea_a*.txt

View File

@@ -1,51 +0,0 @@
tasks:
- name: Setup
init: |
cp -r contrib/ide/vscode .vscode
make deps
make build
command: |
gp sync-done setup
exit 0
- name: Run backend
command: |
gp sync-await setup
# Get the URL and extract the domain
url=$(gp url 3000)
domain=$(echo $url | awk -F[/:] '{print $4}')
if [ -f custom/conf/app.ini ]; then
sed -i "s|^ROOT_URL =.*|ROOT_URL = ${url}/|" custom/conf/app.ini
sed -i "s|^DOMAIN =.*|DOMAIN = ${domain}|" custom/conf/app.ini
sed -i "s|^SSH_DOMAIN =.*|SSH_DOMAIN = ${domain}|" custom/conf/app.ini
sed -i "s|^NO_REPLY_ADDRESS =.*|SSH_DOMAIN = noreply.${domain}|" custom/conf/app.ini
else
mkdir -p custom/conf/
echo -e "[server]\nROOT_URL = ${url}/" > custom/conf/app.ini
echo -e "\n[database]\nDB_TYPE = sqlite3\nPATH = $GITPOD_REPO_ROOT/data/gitea.db" >> custom/conf/app.ini
fi
export TAGS="sqlite sqlite_unlock_notify"
make watch-backend
- name: Run frontend
command: |
gp sync-await setup
make watch-frontend
openMode: split-right
vscode:
extensions:
- editorconfig.editorconfig
- dbaeumer.vscode-eslint
- golang.go
- stylelint.vscode-stylelint
- DavidAnson.vscode-markdownlint
- Vue.volar
- ms-azuretools.vscode-docker
- vitest.explorer
- cweijan.vscode-database-client2
- GitHub.vscode-pull-request-github
ports:
- name: Gitea
port: 3000

View File

@@ -1,5 +1,5 @@
# syntax=docker/dockerfile:1 # syntax=docker/dockerfile:1
# Build frontend on the native platform to avoid QEMU-related issues with esbuild/webpack # Build frontend on the native platform to avoid QEMU-related issues with nodejs ecosystem
FROM --platform=$BUILDPLATFORM docker.io/library/golang:1.26-alpine3.23 AS frontend-build FROM --platform=$BUILDPLATFORM docker.io/library/golang:1.26-alpine3.23 AS frontend-build
RUN apk --no-cache add build-base git nodejs pnpm RUN apk --no-cache add build-base git nodejs pnpm
WORKDIR /src WORKDIR /src

View File

@@ -1,5 +1,5 @@
# syntax=docker/dockerfile:1 # syntax=docker/dockerfile:1
# Build frontend on the native platform to avoid QEMU-related issues with esbuild/webpack # Build frontend on the native platform to avoid QEMU-related issues with nodejs ecosystem
FROM --platform=$BUILDPLATFORM docker.io/library/golang:1.26-alpine3.23 AS frontend-build FROM --platform=$BUILDPLATFORM docker.io/library/golang:1.26-alpine3.23 AS frontend-build
RUN apk --no-cache add build-base git nodejs pnpm RUN apk --no-cache add build-base git nodejs pnpm
WORKDIR /src WORKDIR /src

View File

@@ -120,10 +120,10 @@ LINUX_ARCHS ?= linux/amd64,linux/386,linux/arm-5,linux/arm-6,linux/arm64,linux/r
GO_TEST_PACKAGES ?= $(filter-out $(shell $(GO) list code.gitea.io/gitea/models/migrations/...) code.gitea.io/gitea/tests/integration/migration-test code.gitea.io/gitea/tests code.gitea.io/gitea/tests/integration,$(shell $(GO) list ./... | grep -v /vendor/)) GO_TEST_PACKAGES ?= $(filter-out $(shell $(GO) list code.gitea.io/gitea/models/migrations/...) code.gitea.io/gitea/tests/integration/migration-test code.gitea.io/gitea/tests code.gitea.io/gitea/tests/integration,$(shell $(GO) list ./... | grep -v /vendor/))
MIGRATE_TEST_PACKAGES ?= $(shell $(GO) list code.gitea.io/gitea/models/migrations/...) MIGRATE_TEST_PACKAGES ?= $(shell $(GO) list code.gitea.io/gitea/models/migrations/...)
WEBPACK_SOURCES := $(shell find web_src/js web_src/css -type f) FRONTEND_SOURCES := $(shell find web_src/js web_src/css -type f)
WEBPACK_CONFIGS := webpack.config.ts tailwind.config.ts FRONTEND_CONFIGS := vite.config.ts tailwind.config.ts
WEBPACK_DEST := public/assets/js/index.js public/assets/css/index.css FRONTEND_DEST := public/assets/.vite/manifest.json
WEBPACK_DEST_ENTRIES := public/assets/js public/assets/css public/assets/fonts FRONTEND_DEST_ENTRIES := public/assets/js public/assets/css public/assets/fonts public/assets/.vite
BINDATA_DEST_WILDCARD := modules/migration/bindata.* modules/public/bindata.* modules/options/bindata.* modules/templates/bindata.* BINDATA_DEST_WILDCARD := modules/migration/bindata.* modules/public/bindata.* modules/options/bindata.* modules/templates/bindata.*
@@ -199,7 +199,7 @@ git-check:
.PHONY: clean-all .PHONY: clean-all
clean-all: clean ## delete backend, frontend and integration files clean-all: clean ## delete backend, frontend and integration files
rm -rf $(WEBPACK_DEST_ENTRIES) node_modules rm -rf $(FRONTEND_DEST_ENTRIES) node_modules
.PHONY: clean .PHONY: clean
clean: ## delete backend and integration files clean: ## delete backend and integration files
@@ -380,9 +380,8 @@ watch: ## watch everything and continuously rebuild
@bash tools/watch.sh @bash tools/watch.sh
.PHONY: watch-frontend .PHONY: watch-frontend
watch-frontend: node_modules ## watch frontend files and continuously rebuild watch-frontend: node_modules ## start vite dev server for frontend
@rm -rf $(WEBPACK_DEST_ENTRIES) NODE_ENV=development $(NODE_VARS) pnpm exec vite
NODE_ENV=development $(NODE_VARS) pnpm exec webpack --watch --progress
.PHONY: watch-backend .PHONY: watch-backend
watch-backend: ## watch backend files and continuously rebuild watch-backend: ## watch backend files and continuously rebuild
@@ -645,7 +644,7 @@ install: $(wildcard *.go)
build: frontend backend ## build everything build: frontend backend ## build everything
.PHONY: frontend .PHONY: frontend
frontend: $(WEBPACK_DEST) ## build frontend files frontend: $(FRONTEND_DEST) ## build frontend files
.PHONY: backend .PHONY: backend
backend: generate-backend $(EXECUTABLE) ## build backend files backend: generate-backend $(EXECUTABLE) ## build backend files
@@ -672,7 +671,7 @@ ifneq ($(and $(STATIC),$(findstring pam,$(TAGS))),)
endif endif
CGO_ENABLED="$(CGO_ENABLED)" CGO_CFLAGS="$(CGO_CFLAGS)" $(GO) build $(GOFLAGS) $(EXTRA_GOFLAGS) -tags '$(TAGS)' -ldflags '-s -w $(EXTLDFLAGS) $(LDFLAGS)' -o $@ CGO_ENABLED="$(CGO_ENABLED)" CGO_CFLAGS="$(CGO_CFLAGS)" $(GO) build $(GOFLAGS) $(EXTRA_GOFLAGS) -tags '$(TAGS)' -ldflags '-s -w $(EXTLDFLAGS) $(LDFLAGS)' -o $@
$(EXECUTABLE_E2E): $(GO_SOURCES) $(WEBPACK_DEST) $(EXECUTABLE_E2E): $(GO_SOURCES) $(FRONTEND_DEST)
CGO_ENABLED=1 $(GO) build $(GOFLAGS) $(EXTRA_GOFLAGS) -tags '$(TEST_TAGS)' -ldflags '-s -w $(EXTLDFLAGS) $(LDFLAGS)' -o $@ CGO_ENABLED=1 $(GO) build $(GOFLAGS) $(EXTRA_GOFLAGS) -tags '$(TEST_TAGS)' -ldflags '-s -w $(EXTLDFLAGS) $(LDFLAGS)' -o $@
.PHONY: release .PHONY: release
@@ -776,15 +775,15 @@ update-py: node_modules ## update py dependencies
uv sync uv sync
@touch .venv @touch .venv
.PHONY: webpack .PHONY: vite
webpack: $(WEBPACK_DEST) ## build webpack files vite: $(FRONTEND_DEST) ## build vite files
$(WEBPACK_DEST): $(WEBPACK_SOURCES) $(WEBPACK_CONFIGS) pnpm-lock.yaml $(FRONTEND_DEST): $(FRONTEND_SOURCES) $(FRONTEND_CONFIGS) pnpm-lock.yaml
@$(MAKE) -s node_modules @$(MAKE) -s node_modules
@rm -rf $(WEBPACK_DEST_ENTRIES) @rm -rf $(FRONTEND_DEST_ENTRIES)
@echo "Running webpack..." @echo "Running vite build..."
@BROWSERSLIST_IGNORE_OLD_DATA=true $(NODE_VARS) pnpm exec webpack @$(NODE_VARS) pnpm exec vite build
@touch $(WEBPACK_DEST) @touch $(FRONTEND_DEST)
.PHONY: svg .PHONY: svg
svg: node_modules ## build svg files svg: node_modules ## build svg files

View File

@@ -8,7 +8,6 @@
[![](https://www.codetriage.com/go-gitea/gitea/badges/users.svg)](https://www.codetriage.com/go-gitea/gitea "Help Contribute to Open Source") [![](https://www.codetriage.com/go-gitea/gitea/badges/users.svg)](https://www.codetriage.com/go-gitea/gitea "Help Contribute to Open Source")
[![](https://opencollective.com/gitea/tiers/backers/badge.svg?label=backers&color=brightgreen)](https://opencollective.com/gitea "Become a backer/sponsor of gitea") [![](https://opencollective.com/gitea/tiers/backers/badge.svg?label=backers&color=brightgreen)](https://opencollective.com/gitea "Become a backer/sponsor of gitea")
[![](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT "License: MIT") [![](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT "License: MIT")
[![Contribute with Gitpod](https://img.shields.io/badge/Contribute%20with-Gitpod-908a85?logo=gitpod&color=green)](https://gitpod.io/#https://github.com/go-gitea/gitea)
[![](https://badges.crowdin.net/gitea/localized.svg)](https://translate.gitea.com "Crowdin") [![](https://badges.crowdin.net/gitea/localized.svg)](https://translate.gitea.com "Crowdin")
[繁體中文](./README.zh-tw.md) | [简体中文](./README.zh-cn.md) [繁體中文](./README.zh-tw.md) | [简体中文](./README.zh-cn.md)

View File

@@ -8,7 +8,6 @@
[![](https://www.codetriage.com/go-gitea/gitea/badges/users.svg)](https://www.codetriage.com/go-gitea/gitea "Help Contribute to Open Source") [![](https://www.codetriage.com/go-gitea/gitea/badges/users.svg)](https://www.codetriage.com/go-gitea/gitea "Help Contribute to Open Source")
[![](https://opencollective.com/gitea/tiers/backers/badge.svg?label=backers&color=brightgreen)](https://opencollective.com/gitea "Become a backer/sponsor of gitea") [![](https://opencollective.com/gitea/tiers/backers/badge.svg?label=backers&color=brightgreen)](https://opencollective.com/gitea "Become a backer/sponsor of gitea")
[![](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT "License: MIT") [![](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT "License: MIT")
[![Contribute with Gitpod](https://img.shields.io/badge/Contribute%20with-Gitpod-908a85?logo=gitpod&color=green)](https://gitpod.io/#https://github.com/go-gitea/gitea)
[![](https://badges.crowdin.net/gitea/localized.svg)](https://translate.gitea.com "Crowdin") [![](https://badges.crowdin.net/gitea/localized.svg)](https://translate.gitea.com "Crowdin")
[English](./README.md) | [繁體中文](./README.zh-tw.md) [English](./README.md) | [繁體中文](./README.zh-tw.md)

View File

@@ -8,7 +8,6 @@
[![](https://www.codetriage.com/go-gitea/gitea/badges/users.svg)](https://www.codetriage.com/go-gitea/gitea "Help Contribute to Open Source") [![](https://www.codetriage.com/go-gitea/gitea/badges/users.svg)](https://www.codetriage.com/go-gitea/gitea "Help Contribute to Open Source")
[![](https://opencollective.com/gitea/tiers/backers/badge.svg?label=backers&color=brightgreen)](https://opencollective.com/gitea "Become a backer/sponsor of gitea") [![](https://opencollective.com/gitea/tiers/backers/badge.svg?label=backers&color=brightgreen)](https://opencollective.com/gitea "Become a backer/sponsor of gitea")
[![](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT "License: MIT") [![](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT "License: MIT")
[![Contribute with Gitpod](https://img.shields.io/badge/Contribute%20with-Gitpod-908a85?logo=gitpod&color=green)](https://gitpod.io/#https://github.com/go-gitea/gitea)
[![](https://badges.crowdin.net/gitea/localized.svg)](https://translate.gitea.com "Crowdin") [![](https://badges.crowdin.net/gitea/localized.svg)](https://translate.gitea.com "Crowdin")
[English](./README.md) | [简体中文](./README.zh-cn.md) [English](./README.md) | [简体中文](./README.zh-cn.md)

View File

@@ -572,7 +572,11 @@ export default defineConfig([
'no-restricted-exports': [0], 'no-restricted-exports': [0],
'no-restricted-globals': [2, ...restrictedGlobals], 'no-restricted-globals': [2, ...restrictedGlobals],
'no-restricted-properties': [2, ...restrictedProperties], 'no-restricted-properties': [2, ...restrictedProperties],
'no-restricted-imports': [0], 'no-restricted-imports': [2, {paths: [
{name: 'jquery', message: 'Use the global $ instead', allowTypeImports: true},
{name: 'htmx.org', message: 'Use the global htmx instead', allowTypeImports: true},
{name: 'idiomorph/htmx', message: 'Loaded in globals.ts', allowTypeImports: true},
]}],
'no-restricted-syntax': [2, 'WithStatement', 'ForInStatement', 'LabeledStatement', 'SequenceExpression'], 'no-restricted-syntax': [2, 'WithStatement', 'ForInStatement', 'LabeledStatement', 'SequenceExpression'],
'no-return-assign': [0], 'no-return-assign': [0],
'no-script-url': [2], 'no-script-url': [2],
@@ -1014,6 +1018,6 @@ export default defineConfig([
}, },
{ {
files: ['web_src/**/*'], files: ['web_src/**/*'],
languageOptions: {globals: {...globals.browser, ...globals.webpack}}, languageOptions: {globals: {...globals.browser, ...globals.jquery, htmx: false}},
}, },
]); ]);

View File

@@ -12,7 +12,12 @@ import (
func newHTTPServer(network, address, name string, handler http.Handler) (*Server, ServeFunction) { func newHTTPServer(network, address, name string, handler http.Handler) (*Server, ServeFunction) {
server := NewServer(network, address, name) server := NewServer(network, address, name)
protocols := http.Protocols{}
protocols.SetHTTP1(true)
protocols.SetHTTP2(true) // HTTP/2 can only be used when Gitea is configured to use TLS
protocols.SetUnencryptedHTTP2(true) // Allow HTTP/2 without TLS, in case Gitea is behind a reverse proxy
httpServer := http.Server{ httpServer := http.Server{
Protocols: &protocols,
Handler: handler, Handler: handler,
BaseContext: func(net.Listener) context.Context { return GetManager().HammerContext() }, BaseContext: func(net.Listener) context.Context { return GetManager().HammerContext() },
} }

View File

@@ -9,6 +9,7 @@ import (
"io" "io"
"code.gitea.io/gitea/modules/markup" "code.gitea.io/gitea/modules/markup"
"code.gitea.io/gitea/modules/public"
"code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/util"
) )
@@ -61,19 +62,17 @@ func (p *openAPIRenderer) Render(ctx *markup.RenderContext, input io.Reader, out
<html> <html>
<head> <head>
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="%s/assets/css/swagger.css?v=%s"> <link rel="stylesheet" href="%s">
</head> </head>
<body> <body>
<div id="swagger-ui"><textarea class="swagger-spec-content" data-spec-filename="%s">%s</textarea></div> <div id="swagger-ui"><textarea class="swagger-spec-content" data-spec-filename="%s">%s</textarea></div>
<script src="%s/assets/js/swagger.js?v=%s"></script> <script type="module" src="%s"></script>
</body> </body>
</html>`, </html>`,
setting.StaticURLPrefix, public.AssetURI("css/swagger.css"),
setting.AssetVersion,
html.EscapeString(ctx.RenderOptions.RelativePath), html.EscapeString(ctx.RenderOptions.RelativePath),
html.EscapeString(util.UnsafeBytesToString(content)), html.EscapeString(util.UnsafeBytesToString(content)),
setting.StaticURLPrefix, public.AssetURI("js/swagger.js"),
setting.AssetVersion,
)) ))
return err return err
} }

View File

@@ -16,6 +16,7 @@ import (
"code.gitea.io/gitea/modules/htmlutil" "code.gitea.io/gitea/modules/htmlutil"
"code.gitea.io/gitea/modules/markup/internal" "code.gitea.io/gitea/modules/markup/internal"
"code.gitea.io/gitea/modules/public"
"code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/typesniffer" "code.gitea.io/gitea/modules/typesniffer"
"code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/util"
@@ -237,10 +238,10 @@ func RenderWithRenderer(ctx *RenderContext, renderer Renderer, input io.Reader,
return renderIFrame(ctx, extOpts.ContentSandbox, output) return renderIFrame(ctx, extOpts.ContentSandbox, output)
} }
// else: this is a standalone page, fallthrough to the real rendering, and add extra JS/CSS // else: this is a standalone page, fallthrough to the real rendering, and add extra JS/CSS
extraStyleHref := setting.AppSubURL + "/assets/css/external-render-iframe.css" extraStyleHref := public.AssetURI("css/external-render-iframe.css")
extraScriptSrc := setting.AppSubURL + "/assets/js/external-render-iframe.js" extraScriptSrc := public.AssetURI("js/external-render-iframe.js")
// "<script>" must go before "<link>", to make Golang's http.DetectContentType() can still recognize the content as "text/html" // "<script>" must go before "<link>", to make Golang's http.DetectContentType() can still recognize the content as "text/html"
extraHeadHTML = htmlutil.HTMLFormat(`<script src="%s"></script><link rel="stylesheet" href="%s">`, extraScriptSrc, extraStyleHref) extraHeadHTML = htmlutil.HTMLFormat(`<script type="module" src="%s"></script><link rel="stylesheet" href="%s">`, extraScriptSrc, extraStyleHref)
} }
ctx.usedByRender = true ctx.usedByRender = true

156
modules/public/manifest.go Normal file
View File

@@ -0,0 +1,156 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package public
import (
"io"
"path"
"sync"
"sync/atomic"
"time"
"code.gitea.io/gitea/modules/json"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/setting"
)
type manifestEntry struct {
File string `json:"file"`
Name string `json:"name"`
IsEntry bool `json:"isEntry"`
CSS []string `json:"css"`
}
type manifestDataStruct struct {
paths map[string]string // unhashed path -> hashed path
names map[string]string // hashed path -> entry name
modTime int64
checkTime time.Time
}
var (
manifestData atomic.Pointer[manifestDataStruct]
manifestFS = sync.OnceValue(AssetFS)
)
const manifestPath = "assets/.vite/manifest.json"
func parseManifest(data []byte) (map[string]string, map[string]string) {
var manifest map[string]manifestEntry
if err := json.Unmarshal(data, &manifest); err != nil {
log.Error("Failed to parse frontend manifest: %v", err)
return nil, nil
}
paths := make(map[string]string)
names := make(map[string]string)
for _, entry := range manifest {
if !entry.IsEntry || entry.Name == "" {
continue
}
// Build unhashed key from file path: "js/index.js", "css/theme-gitea-dark.css"
dir := path.Dir(entry.File)
ext := path.Ext(entry.File)
key := dir + "/" + entry.Name + ext
paths[key] = entry.File
names[entry.File] = entry.Name
// Map associated CSS files, e.g. "css/index.css" -> "css/index.B3zrQPqD.css"
for _, css := range entry.CSS {
cssKey := path.Dir(css) + "/" + entry.Name + path.Ext(css)
paths[cssKey] = css
names[css] = entry.Name
}
}
return paths, names
}
func reloadManifest(existingData *manifestDataStruct) *manifestDataStruct {
now := time.Now()
data := existingData
if data != nil && now.Sub(data.checkTime) < time.Second {
// a single request triggers multiple calls to getHashedPath
// do not check the manifest file too frequently
return data
}
f, err := manifestFS().Open(manifestPath)
if err != nil {
log.Error("Failed to open frontend manifest: %v", err)
return data
}
defer f.Close()
fi, err := f.Stat()
if err != nil {
log.Error("Failed to stat frontend manifest: %v", err)
return data
}
needReload := data == nil || fi.ModTime().UnixNano() != data.modTime
if !needReload {
return data
}
manifestContent, err := io.ReadAll(f)
if err != nil {
log.Error("Failed to read frontend manifest: %v", err)
return data
}
return storeManifestFromBytes(manifestContent, fi.ModTime().UnixNano(), now)
}
func storeManifestFromBytes(manifestContent []byte, modTime int64, checkTime time.Time) *manifestDataStruct {
paths, names := parseManifest(manifestContent)
data := &manifestDataStruct{
paths: paths,
names: names,
modTime: modTime,
checkTime: checkTime,
}
manifestData.Store(data)
return data
}
func getManifestData() *manifestDataStruct {
data := manifestData.Load()
// In production the manifest is immutable (embedded in the binary).
// In dev mode, check if it changed on disk (for watch-frontend).
if data == nil || !setting.IsProd {
data = reloadManifest(data)
}
if data == nil {
data = &manifestDataStruct{}
}
return data
}
// getHashedPath resolves an unhashed asset path (origin path) to its content-hashed path from the frontend manifest.
// Example: getHashedPath("js/index.js") returns "js/index.C6Z2MRVQ.js"
// Falls back to returning the input path unchanged if the manifest is unavailable.
func getHashedPath(originPath string) string {
data := getManifestData()
if p, ok := data.paths[originPath]; ok {
return p
}
return originPath
}
// AssetURI returns the URI for a frontend asset.
// It may return a relative path or a full URL depending on the StaticURLPrefix setting.
// In Vite dev mode, known entry points are mapped to their source paths
// so the reverse proxy serves them from the Vite dev server.
// In production, it resolves the content-hashed path from the manifest.
func AssetURI(originPath string) string {
if src := viteDevSourceURL(originPath); src != "" {
return src
}
return setting.StaticURLPrefix + "/assets/" + getHashedPath(originPath)
}
// AssetNameFromHashedPath returns the asset entry name for a given hashed asset path.
// Example: returns "theme-gitea-dark" for "css/theme-gitea-dark.CyAaQnn5.css".
// Returns empty string if the path is not found in the manifest.
func AssetNameFromHashedPath(hashedPath string) string {
return getManifestData().names[hashedPath]
}

View File

@@ -0,0 +1,91 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package public
import (
"testing"
"time"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/test"
"github.com/stretchr/testify/assert"
)
func TestViteManifest(t *testing.T) {
defer test.MockVariableValue(&setting.IsProd, true)()
const testManifest = `{
"web_src/js/index.ts": {
"file": "js/index.C6Z2MRVQ.js",
"name": "index",
"src": "web_src/js/index.ts",
"isEntry": true,
"css": ["css/index.B3zrQPqD.css"]
},
"web_src/js/standalone/swagger.ts": {
"file": "js/swagger.SujiEmYM.js",
"name": "swagger",
"src": "web_src/js/standalone/swagger.ts",
"isEntry": true,
"css": ["css/swagger._-APWT_3.css"]
},
"web_src/css/themes/theme-gitea-dark.css": {
"file": "css/theme-gitea-dark.CyAaQnn5.css",
"name": "theme-gitea-dark",
"src": "web_src/css/themes/theme-gitea-dark.css",
"isEntry": true
},
"web_src/js/features/eventsource.sharedworker.ts": {
"file": "js/eventsource.sharedworker.Dug1twio.js",
"name": "eventsource.sharedworker",
"src": "web_src/js/features/eventsource.sharedworker.ts",
"isEntry": true
},
"_chunk.js": {
"file": "js/chunk.abc123.js",
"name": "chunk"
}
}`
t.Run("EmptyManifest", func(t *testing.T) {
storeManifestFromBytes([]byte(``), 0, time.Now())
assert.Equal(t, "/assets/js/index.js", AssetURI("js/index.js"))
assert.Equal(t, "/assets/css/theme-gitea-dark.css", AssetURI("css/theme-gitea-dark.css"))
assert.Equal(t, "", AssetNameFromHashedPath("css/no-such-file.css"))
})
t.Run("ParseManifest", func(t *testing.T) {
storeManifestFromBytes([]byte(testManifest), 0, time.Now())
paths, names := manifestData.Load().paths, manifestData.Load().names
// JS entries
assert.Equal(t, "js/index.C6Z2MRVQ.js", paths["js/index.js"])
assert.Equal(t, "js/swagger.SujiEmYM.js", paths["js/swagger.js"])
assert.Equal(t, "js/eventsource.sharedworker.Dug1twio.js", paths["js/eventsource.sharedworker.js"])
// Associated CSS from JS entries
assert.Equal(t, "css/index.B3zrQPqD.css", paths["css/index.css"])
assert.Equal(t, "css/swagger._-APWT_3.css", paths["css/swagger.css"])
// CSS-only entries
assert.Equal(t, "css/theme-gitea-dark.CyAaQnn5.css", paths["css/theme-gitea-dark.css"])
// Non-entry chunks should not be included
assert.Empty(t, paths["js/chunk.js"])
// Names: hashed path -> entry name
assert.Equal(t, "index", names["js/index.C6Z2MRVQ.js"])
assert.Equal(t, "index", names["css/index.B3zrQPqD.css"])
assert.Equal(t, "swagger", names["js/swagger.SujiEmYM.js"])
assert.Equal(t, "swagger", names["css/swagger._-APWT_3.css"])
assert.Equal(t, "theme-gitea-dark", names["css/theme-gitea-dark.CyAaQnn5.css"])
assert.Equal(t, "eventsource.sharedworker", names["js/eventsource.sharedworker.Dug1twio.js"])
// Test Asset related functions
assert.Equal(t, "/assets/js/index.C6Z2MRVQ.js", AssetURI("js/index.js"))
assert.Equal(t, "/assets/css/theme-gitea-dark.CyAaQnn5.css", AssetURI("css/theme-gitea-dark.css"))
assert.Equal(t, "theme-gitea-dark", AssetNameFromHashedPath("css/theme-gitea-dark.CyAaQnn5.css"))
})
}

168
modules/public/vitedev.go Normal file
View File

@@ -0,0 +1,168 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package public
import (
"net/http"
"net/http/httputil"
"net/url"
"os"
"path/filepath"
"strings"
"sync/atomic"
"time"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/web/routing"
)
const viteDevPortFile = "public/assets/.vite/dev-port"
var viteDevProxy atomic.Pointer[httputil.ReverseProxy]
func getViteDevProxy() *httputil.ReverseProxy {
if proxy := viteDevProxy.Load(); proxy != nil {
return proxy
}
portFile := filepath.Join(setting.StaticRootPath, viteDevPortFile)
data, err := os.ReadFile(portFile)
if err != nil {
return nil
}
port := strings.TrimSpace(string(data))
if port == "" {
return nil
}
target, err := url.Parse("http://localhost:" + port)
if err != nil {
log.Error("Failed to parse Vite dev server URL: %v", err)
return nil
}
// there is a strange error log (from Golang's HTTP package)
// 2026/03/28 19:50:13 modules/log/misc.go:72:(*loggerToWriter).Write() [I] Unsolicited response received on idle HTTP channel starting with "HTTP/1.1 400 Bad Request\r\n\r\n"; err=<nil>
// maybe it is caused by that the Vite dev server doesn't support keep-alive connections? or different keep-alive timeouts?
transport := &http.Transport{
IdleConnTimeout: 5 * time.Second,
ResponseHeaderTimeout: 5 * time.Second,
}
log.Info("Proxying Vite dev server requests to %s", target)
proxy := &httputil.ReverseProxy{
Transport: transport,
Rewrite: func(r *httputil.ProxyRequest) {
r.SetURL(target)
r.Out.Host = target.Host
},
ModifyResponse: func(resp *http.Response) error {
// add a header to indicate the Vite dev server port,
// make developers know that this request is proxied to Vite dev server and which port it is
resp.Header.Add("X-Gitea-Vite-Port", port)
return nil
},
ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) {
log.Error("Error proxying to Vite dev server: %v", err)
http.Error(w, "Error proxying to Vite dev server: "+err.Error(), http.StatusBadGateway)
},
}
viteDevProxy.Store(proxy)
return proxy
}
// ViteDevMiddleware proxies matching requests to the Vite dev server.
// It is registered as middleware in non-production mode and lazily discovers
// the Vite dev server port from the port file written by the viteDevServerPortPlugin.
// It is needed because there are container-based development, only Gitea web server's port is exposed.
func ViteDevMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(resp http.ResponseWriter, req *http.Request) {
if !isViteDevRequest(req) {
next.ServeHTTP(resp, req)
return
}
proxy := getViteDevProxy()
if proxy == nil {
next.ServeHTTP(resp, req)
return
}
routing.MarkLongPolling(resp, req)
proxy.ServeHTTP(resp, req)
})
}
// isViteDevMode returns true if the Vite dev server port file exists.
// In production mode, the result is cached after the first check.
func isViteDevMode() bool {
if setting.IsProd {
return false
}
portFile := filepath.Join(setting.StaticRootPath, viteDevPortFile)
_, err := os.Stat(portFile)
return err == nil
}
func viteDevSourceURL(name string) string {
if !isViteDevMode() {
return ""
}
if strings.HasPrefix(name, "css/theme-") {
// Only redirect built-in themes to Vite source; custom themes are served from custom/public/assets/css/
themeFile := strings.TrimPrefix(name, "css/")
srcPath := filepath.Join(setting.StaticRootPath, "web_src/css/themes", themeFile)
if _, err := os.Stat(srcPath); err == nil {
return setting.AppSubURL + "/web_src/css/themes/" + themeFile
}
return ""
}
if strings.HasPrefix(name, "css/") {
return setting.AppSubURL + "/web_src/" + name
}
if name == "js/eventsource.sharedworker.js" {
return setting.AppSubURL + "/web_src/js/features/eventsource.sharedworker.ts"
}
if name == "js/iife.js" {
return setting.AppSubURL + "/web_src/js/__vite_iife.js"
}
if name == "js/index.js" {
return setting.AppSubURL + "/web_src/js/index.ts"
}
return ""
}
// isViteDevRequest returns true if the request should be proxied to the Vite dev server.
// Ref: Vite source packages/vite/src/node/constants.ts and packages/vite/src/shared/constants.ts
func isViteDevRequest(req *http.Request) bool {
if req.Header.Get("Upgrade") == "websocket" {
wsProtocol := req.Header.Get("Sec-WebSocket-Protocol")
return wsProtocol == "vite-hmr" || wsProtocol == "vite-ping"
}
path := req.URL.Path
// vite internal requests
if strings.HasPrefix(path, "/@vite/") /* HMR client */ ||
strings.HasPrefix(path, "/@fs/") /* out-of-root file access, see vite.config.ts: fs.allow */ ||
strings.HasPrefix(path, "/@id/") /* virtual modules */ {
return true
}
// local source requests (VITE-DEV-SERVER-SECURITY: don't serve sensitive files outside the allowed paths)
if strings.HasPrefix(path, "/node_modules/") ||
strings.HasPrefix(path, "/public/assets/") ||
strings.HasPrefix(path, "/web_src/") {
return true
}
// Vite uses a path relative to project root and adds "?import" to non-JS/CSS asset imports:
// - {WebSite}/public/assets/... (e.g. SVG icons from "{RepoRoot}/public/assets/img/svg/")
// - {WebSite}/assets/emoji.json: it is an exception for the frontend assets, it is imported by JS code, but:
// - KEEP IN MIND: all static frontend assets are served from "{AssetFS}/assets" to "{WebSite}/assets" by Gitea Web Server
// - "{AssetFS}" is a layered filesystem from "{RepoRoot}/public" or embedded assets, and user's custom files in "{CustomPath}/public"
// - "{RepoRoot}/assets/emoji.json" just happens to have the dir name "assets", it is not related to frontend assets
// - BAD DESIGN: indeed it is a "conflicted and polluted name" sample
if path == "/assets/emoji.json" {
return true
}
return false
}

View File

@@ -72,9 +72,6 @@ var (
// It maps to ini:"LOCAL_ROOT_URL" in [server] // It maps to ini:"LOCAL_ROOT_URL" in [server]
LocalURL string LocalURL string
// AssetVersion holds an opaque value that is used for cache-busting assets
AssetVersion string
// appTempPathInternal is the temporary path for the app, it is only an internal variable // appTempPathInternal is the temporary path for the app, it is only an internal variable
// DO NOT use it directly, always use AppDataTempDir // DO NOT use it directly, always use AppDataTempDir
appTempPathInternal string appTempPathInternal string
@@ -317,8 +314,6 @@ func loadServerFrom(rootCfg ConfigProvider) {
} }
AbsoluteAssetURL = MakeAbsoluteAssetURL(appURL, StaticURLPrefix) AbsoluteAssetURL = MakeAbsoluteAssetURL(appURL, StaticURLPrefix)
AssetVersion = strings.ReplaceAll(AppVer, "+", "~") // make sure the version string is clear (no real escaping is needed)
manifestBytes := MakeManifestData(AppName, AppURL, AbsoluteAssetURL) manifestBytes := MakeManifestData(AppName, AppURL, AbsoluteAssetURL)
ManifestData = `application/json;base64,` + base64.StdEncoding.EncodeToString(manifestBytes) ManifestData = `application/json;base64,` + base64.StdEncoding.EncodeToString(manifestBytes)

View File

@@ -6,15 +6,18 @@ package templates
import ( import (
"fmt" "fmt"
"html"
"html/template" "html/template"
"net/url" "net/url"
"strconv" "strconv"
"strings" "strings"
"sync"
"time" "time"
"code.gitea.io/gitea/modules/base" "code.gitea.io/gitea/modules/base"
"code.gitea.io/gitea/modules/htmlutil" "code.gitea.io/gitea/modules/htmlutil"
"code.gitea.io/gitea/modules/markup" "code.gitea.io/gitea/modules/markup"
"code.gitea.io/gitea/modules/public"
"code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/svg" "code.gitea.io/gitea/modules/svg"
"code.gitea.io/gitea/modules/templates/eval" "code.gitea.io/gitea/modules/templates/eval"
@@ -68,6 +71,8 @@ func NewFuncMap() template.FuncMap {
return strconv.FormatInt(time.Since(startTime).Nanoseconds()/1e6, 10) + "ms" return strconv.FormatInt(time.Since(startTime).Nanoseconds()/1e6, 10) + "ms"
}, },
"AssetURI": public.AssetURI,
"ScriptImport": scriptImport,
// ----------------------------------------------------------------- // -----------------------------------------------------------------
// setting // setting
"AppName": func() string { "AppName": func() string {
@@ -92,9 +97,6 @@ func NewFuncMap() template.FuncMap {
"AppDomain": func() string { // documented in mail-templates.md "AppDomain": func() string { // documented in mail-templates.md
return setting.Domain return setting.Domain
}, },
"AssetVersion": func() string {
return setting.AssetVersion
},
"ShowFooterTemplateLoadTime": func() bool { "ShowFooterTemplateLoadTime": func() bool {
return setting.Other.ShowFooterTemplateLoadTime return setting.Other.ShowFooterTemplateLoadTime
}, },
@@ -303,3 +305,30 @@ func QueryBuild(a ...any) template.URL {
} }
return template.URL(s) return template.URL(s)
} }
var globalVars = sync.OnceValue(func() (ret struct {
scriptImportRemainingPart string
},
) {
// add onerror handler to alert users when the script fails to load:
// * for end users: there were many users reporting that "UI doesn't work", actually they made mistakes in their config
// * for developers: help them to remember to run "make watch-frontend" to build frontend assets
// the message will be directly put in the onerror JS code's string
onScriptErrorPrompt := `Please make sure the asset files can be accessed.`
if !setting.IsProd {
onScriptErrorPrompt += `\n\nFor development, run: make watch-frontend.`
}
onScriptErrorJS := fmt.Sprintf(`alert('Failed to load asset file from ' + this.src + '. %s')`, onScriptErrorPrompt)
ret.scriptImportRemainingPart = `onerror="` + html.EscapeString(onScriptErrorJS) + `"></script>`
return ret
})
func scriptImport(path string, typ ...string) template.HTML {
if len(typ) > 0 {
if typ[0] == "module" {
return template.HTML(`<script type="module" src="` + html.EscapeString(public.AssetURI(path)) + `" ` + globalVars().scriptImportRemainingPart)
}
panic("unsupported script type: " + typ[0])
}
return template.HTML(`<script src="` + html.EscapeString(public.AssetURI(path)) + `" ` + globalVars().scriptImportRemainingPart)
}

View File

@@ -4,7 +4,9 @@
package web package web
import ( import (
"bufio"
"fmt" "fmt"
"net"
"net/http" "net/http"
"reflect" "reflect"
@@ -47,6 +49,13 @@ func (r *responseWriter) WriteHeader(statusCode int) {
r.respWriter.WriteHeader(statusCode) r.respWriter.WriteHeader(statusCode)
} }
func (r *responseWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) {
if hj, ok := r.respWriter.(http.Hijacker); ok {
return hj.Hijack()
}
return nil, nil, http.ErrNotSupported
}
var ( var (
httpReqType = reflect.TypeFor[*http.Request]() httpReqType = reflect.TypeFor[*http.Request]()
respWriterType = reflect.TypeFor[http.ResponseWriter]() respWriterType = reflect.TypeFor[http.ResponseWriter]()

View File

@@ -8,6 +8,7 @@ import (
"net/http" "net/http"
"code.gitea.io/gitea/modules/gtprof" "code.gitea.io/gitea/modules/gtprof"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/reqctx" "code.gitea.io/gitea/modules/reqctx"
) )
@@ -40,6 +41,18 @@ func MarkLongPolling(resp http.ResponseWriter, req *http.Request) {
record.lock.Lock() record.lock.Lock()
record.isLongPolling = true record.isLongPolling = true
record.logLevel = log.TRACE
record.lock.Unlock()
}
func MarkLogLevelTrace(resp http.ResponseWriter, req *http.Request) {
record, ok := req.Context().Value(contextKey).(*requestRecord)
if !ok {
return
}
record.lock.Lock()
record.logLevel = log.TRACE
record.lock.Unlock() record.lock.Unlock()
} }

View File

@@ -5,7 +5,6 @@ package routing
import ( import (
"net/http" "net/http"
"strings"
"time" "time"
"code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/log"
@@ -36,17 +35,8 @@ var (
func logPrinter(logger log.Logger) func(trigger Event, record *requestRecord) { func logPrinter(logger log.Logger) func(trigger Event, record *requestRecord) {
const callerName = "HTTPRequest" const callerName = "HTTPRequest"
logTrace := func(fmt string, args ...any) { logRequest := func(level log.Level, fmt string, args ...any) {
logger.Log(2, &log.Event{Level: log.TRACE, Caller: callerName}, fmt, args...) logger.Log(2, &log.Event{Level: level, Caller: callerName}, fmt, args...)
}
logInfo := func(fmt string, args ...any) {
logger.Log(2, &log.Event{Level: log.INFO, Caller: callerName}, fmt, args...)
}
logWarn := func(fmt string, args ...any) {
logger.Log(2, &log.Event{Level: log.WARN, Caller: callerName}, fmt, args...)
}
logError := func(fmt string, args ...any) {
logger.Log(2, &log.Event{Level: log.ERROR, Caller: callerName}, fmt, args...)
} }
return func(trigger Event, record *requestRecord) { return func(trigger Event, record *requestRecord) {
if trigger == StartEvent { if trigger == StartEvent {
@@ -57,7 +47,7 @@ func logPrinter(logger log.Logger) func(trigger Event, record *requestRecord) {
} }
// when a request starts, we have no information about the handler function information, we only have the request path // when a request starts, we have no information about the handler function information, we only have the request path
req := record.request req := record.request
logTrace("router: %s %v %s for %s", startMessage, log.ColoredMethod(req.Method), req.RequestURI, req.RemoteAddr) logRequest(log.TRACE, "router: %s %v %s for %s", startMessage, log.ColoredMethod(req.Method), req.RequestURI, req.RemoteAddr)
return return
} }
@@ -73,12 +63,12 @@ func logPrinter(logger log.Logger) func(trigger Event, record *requestRecord) {
if trigger == StillExecutingEvent { if trigger == StillExecutingEvent {
message := slowMessage message := slowMessage
logf := logWarn logLevel := log.WARN
if isLongPolling { if isLongPolling {
logf = logInfo logLevel = log.INFO
message = pollingMessage message = pollingMessage
} }
logf("router: %s %v %s for %s, elapsed %v @ %s", logRequest(logLevel, "router: %s %v %s for %s, elapsed %v @ %s",
message, message,
log.ColoredMethod(req.Method), req.RequestURI, req.RemoteAddr, log.ColoredMethod(req.Method), req.RequestURI, req.RemoteAddr,
log.ColoredTime(time.Since(record.startTime)), log.ColoredTime(time.Since(record.startTime)),
@@ -88,7 +78,7 @@ func logPrinter(logger log.Logger) func(trigger Event, record *requestRecord) {
} }
if panicErr != nil { if panicErr != nil {
logWarn("router: %s %v %s for %s, panic in %v @ %s, err=%v", logRequest(log.WARN, "router: %s %v %s for %s, panic in %v @ %s, err=%v",
failedMessage, failedMessage,
log.ColoredMethod(req.Method), req.RequestURI, req.RemoteAddr, log.ColoredMethod(req.Method), req.RequestURI, req.RemoteAddr,
log.ColoredTime(time.Since(record.startTime)), log.ColoredTime(time.Since(record.startTime)),
@@ -102,21 +92,22 @@ func logPrinter(logger log.Logger) func(trigger Event, record *requestRecord) {
if v, ok := record.responseWriter.(types.ResponseStatusProvider); ok { if v, ok := record.responseWriter.(types.ResponseStatusProvider); ok {
status = v.WrittenStatus() status = v.WrittenStatus()
} }
logf := logInfo logLevel := record.logLevel
if logLevel == log.UNDEFINED {
logLevel = log.INFO
}
// lower the log level for some specific requests, in most cases these logs are not useful // lower the log level for some specific requests, in most cases these logs are not useful
if status > 0 && status < 400 && if status > 0 && status < 400 &&
strings.HasPrefix(req.RequestURI, "/assets/") /* static assets */ ||
req.RequestURI == "/user/events" /* Server-Sent Events (SSE) handler */ ||
req.RequestURI == "/api/actions/runner.v1.RunnerService/FetchTask" /* Actions Runner polling */ { req.RequestURI == "/api/actions/runner.v1.RunnerService/FetchTask" /* Actions Runner polling */ {
logf = logTrace logLevel = log.TRACE
} }
message := completedMessage message := completedMessage
if isUnknownHandler { if isUnknownHandler {
logf = logError logLevel = log.ERROR
message = unknownHandlerMessage message = unknownHandlerMessage
} }
logf("router: %s %v %s for %s, %v %v in %v @ %s", logRequest(logLevel, "router: %s %v %s for %s, %v %v in %v @ %s",
message, message,
log.ColoredMethod(req.Method), req.RequestURI, req.RemoteAddr, log.ColoredMethod(req.Method), req.RequestURI, req.RemoteAddr,
log.ColoredStatus(status), log.ColoredStatus(status, http.StatusText(status)), log.ColoredTime(time.Since(record.startTime)), log.ColoredStatus(status), log.ColoredStatus(status, http.StatusText(status)), log.ColoredTime(time.Since(record.startTime)),

View File

@@ -7,6 +7,8 @@ import (
"net/http" "net/http"
"sync" "sync"
"time" "time"
"code.gitea.io/gitea/modules/log"
) )
type requestRecord struct { type requestRecord struct {
@@ -23,6 +25,7 @@ type requestRecord struct {
// mutable fields // mutable fields
isLongPolling bool isLongPolling bool
logLevel log.Level
funcInfo *FuncInfo funcInfo *FuncInfo
panicError error panicError error
} }

View File

@@ -18,8 +18,7 @@
"@primer/octicons": "19.23.1", "@primer/octicons": "19.23.1",
"@resvg/resvg-wasm": "2.6.2", "@resvg/resvg-wasm": "2.6.2",
"@silverwind/vue3-calendar-heatmap": "2.1.1", "@silverwind/vue3-calendar-heatmap": "2.1.1",
"@techknowlogick/license-checker-webpack-plugin": "0.3.0", "@vitejs/plugin-vue": "6.0.5",
"add-asset-webpack-plugin": "3.1.1",
"ansi_up": "6.0.6", "ansi_up": "6.0.6",
"asciinema-player": "3.15.1", "asciinema-player": "3.15.1",
"chart.js": "4.5.1", "chart.js": "4.5.1",
@@ -29,25 +28,22 @@
"colord": "2.9.3", "colord": "2.9.3",
"compare-versions": "6.1.1", "compare-versions": "6.1.1",
"cropperjs": "1.6.2", "cropperjs": "1.6.2",
"css-loader": "7.1.4",
"dayjs": "1.11.20", "dayjs": "1.11.20",
"dropzone": "6.0.0-beta.2", "dropzone": "6.0.0-beta.2",
"easymde": "2.20.0", "easymde": "2.20.0",
"esbuild-loader": "4.4.2", "esbuild": "0.27.4",
"htmx.org": "2.0.8", "htmx.org": "2.0.8",
"idiomorph": "0.7.4", "idiomorph": "0.7.4",
"jquery": "4.0.0", "jquery": "4.0.0",
"js-yaml": "4.1.1", "js-yaml": "4.1.1",
"katex": "0.16.43", "katex": "0.16.43",
"mermaid": "11.13.0", "mermaid": "11.13.0",
"mini-css-extract-plugin": "2.10.2",
"monaco-editor": "0.55.1", "monaco-editor": "0.55.1",
"monaco-editor-webpack-plugin": "7.1.1",
"online-3d-viewer": "0.18.0", "online-3d-viewer": "0.18.0",
"pdfobject": "2.3.1", "pdfobject": "2.3.1",
"perfect-debounce": "2.1.0", "perfect-debounce": "2.1.0",
"postcss": "8.5.8", "postcss": "8.5.8",
"postcss-loader": "8.2.1", "rollup-plugin-license": "3.7.0",
"sortablejs": "1.15.7", "sortablejs": "1.15.7",
"swagger-ui-dist": "5.32.1", "swagger-ui-dist": "5.32.1",
"tailwindcss": "3.4.19", "tailwindcss": "3.4.19",
@@ -57,12 +53,11 @@
"tributejs": "5.1.3", "tributejs": "5.1.3",
"uint8-to-base64": "0.2.1", "uint8-to-base64": "0.2.1",
"vanilla-colorful": "0.7.2", "vanilla-colorful": "0.7.2",
"vite": "8.0.3",
"vite-string-plugin": "2.0.2",
"vue": "3.5.31", "vue": "3.5.31",
"vue-bar-graph": "2.2.0", "vue-bar-graph": "2.2.0",
"vue-chartjs": "5.3.3", "vue-chartjs": "5.3.3",
"vue-loader": "17.4.2",
"webpack": "5.105.4",
"webpack-cli": "7.0.2",
"wrap-ansi": "10.0.0" "wrap-ansi": "10.0.0"
}, },
"devDependencies": { "devDependencies": {
@@ -88,8 +83,8 @@
"eslint": "10.1.0", "eslint": "10.1.0",
"eslint-import-resolver-typescript": "4.4.4", "eslint-import-resolver-typescript": "4.4.4",
"eslint-plugin-array-func": "5.1.1", "eslint-plugin-array-func": "5.1.1",
"eslint-plugin-github": "6.0.0",
"eslint-plugin-de-morgan": "2.1.1", "eslint-plugin-de-morgan": "2.1.1",
"eslint-plugin-github": "6.0.0",
"eslint-plugin-import-x": "4.16.2", "eslint-plugin-import-x": "4.16.2",
"eslint-plugin-playwright": "2.10.1", "eslint-plugin-playwright": "2.10.1",
"eslint-plugin-regexp": "3.1.0", "eslint-plugin-regexp": "3.1.0",
@@ -115,7 +110,6 @@
"typescript": "5.9.3", "typescript": "5.9.3",
"typescript-eslint": "8.57.2", "typescript-eslint": "8.57.2",
"updates": "17.12.0", "updates": "17.12.0",
"vite-string-plugin": "2.0.2",
"vitest": "4.1.2", "vitest": "4.1.2",
"vue-tsc": "3.2.6" "vue-tsc": "3.2.6"
}, },

1211
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -259,8 +259,12 @@ func Routes() *web.Router {
// GetHead allows a HEAD request redirect to GET if HEAD method is not defined for that route // GetHead allows a HEAD request redirect to GET if HEAD method is not defined for that route
routes.BeforeRouting(chi_middleware.GetHead) routes.BeforeRouting(chi_middleware.GetHead)
if !setting.IsProd {
routes.BeforeRouting(public.ViteDevMiddleware)
}
routes.Head("/", misc.DummyOK) // for health check - doesn't need to be passed through gzip handler routes.Head("/", misc.DummyOK) // for health check - doesn't need to be passed through gzip handler
routes.Methods("GET, HEAD, OPTIONS", "/assets/*", optionsCorsHandler(), public.FileHandlerFunc()) routes.Methods("GET, HEAD, OPTIONS", "/assets/*", routing.MarkLogLevelTrace, optionsCorsHandler(), public.FileHandlerFunc())
routes.Methods("GET, HEAD", "/avatars/*", avatarStorageHandler(setting.Avatar.Storage, "avatars", storage.Avatars)) routes.Methods("GET, HEAD", "/avatars/*", avatarStorageHandler(setting.Avatar.Storage, "avatars", storage.Avatars))
routes.Methods("GET, HEAD", "/repo-avatars/*", avatarStorageHandler(setting.RepoAvatar.Storage, "repo-avatars", storage.RepoAvatars)) routes.Methods("GET, HEAD", "/repo-avatars/*", avatarStorageHandler(setting.RepoAvatar.Storage, "repo-avatars", storage.RepoAvatars))
routes.Methods("GET, HEAD", "/apple-touch-icon.png", misc.StaticRedirect("/assets/img/apple-touch-icon.png")) routes.Methods("GET, HEAD", "/apple-touch-icon.png", misc.StaticRedirect("/assets/img/apple-touch-icon.png"))

View File

@@ -4,6 +4,8 @@
package context package context
import ( import (
"bufio"
"net"
"net/http" "net/http"
web_types "code.gitea.io/gitea/modules/web/types" web_types "code.gitea.io/gitea/modules/web/types"
@@ -67,6 +69,15 @@ func (r *Response) WriteHeader(statusCode int) {
} }
} }
// Hijack implements http.Hijacker, delegating to the underlying ResponseWriter.
// This is needed for WebSocket upgrades through reverse proxies.
func (r *Response) Hijack() (net.Conn, *bufio.ReadWriter, error) {
if hj, ok := r.ResponseWriter.(http.Hijacker); ok {
return hj.Hijack()
}
return nil, nil, http.ErrNotSupported
}
// Flush flushes cached data // Flush flushes cached data
func (r *Response) Flush() { func (r *Response) Flush() {
if f, ok := r.ResponseWriter.(http.Flusher); ok { if f, ok := r.ResponseWriter.(http.Flusher); ok {

View File

@@ -109,9 +109,16 @@ func parseThemeMetaInfoToMap(cssContent string) map[string]string {
} }
func defaultThemeMetaInfoByFileName(fileName string) *ThemeMetaInfo { func defaultThemeMetaInfoByFileName(fileName string) *ThemeMetaInfo {
internalName := strings.TrimSuffix(strings.TrimPrefix(fileName, fileNamePrefix), fileNameSuffix)
// For built-in themes, the manifest knows the unhashed entry name (e.g. "theme-gitea-dark")
// which lets us correctly strip the content hash without guessing.
// Custom themes are not in the manifest and never have content hashes.
if name := public.AssetNameFromHashedPath("css/" + fileName); name != "" {
internalName = strings.TrimPrefix(name, fileNamePrefix)
}
themeInfo := &ThemeMetaInfo{ themeInfo := &ThemeMetaInfo{
FileName: fileName, FileName: fileName,
InternalName: strings.TrimSuffix(strings.TrimPrefix(fileName, fileNamePrefix), fileNameSuffix), InternalName: internalName,
} }
themeInfo.DisplayName = themeInfo.InternalName themeInfo.DisplayName = themeInfo.InternalName
return themeInfo return themeInfo

View File

@@ -9,6 +9,7 @@
</div> </div>
{{template "custom/body_outer_post" .}} {{template "custom/body_outer_post" .}}
{{template "base/footer_content" .}} {{template "base/footer_content" .}}
{{ScriptImport "js/index.js" "module"}}
{{template "custom/footer" .}} {{template "custom/footer" .}}
</body> </body>
</html> </html>

View File

@@ -9,7 +9,6 @@ If you introduce mistakes in it, Gitea JavaScript code wouldn't run correctly.
window.config = { window.config = {
appUrl: '{{AppUrl}}', appUrl: '{{AppUrl}}',
appSubUrl: '{{AppSubUrl}}', appSubUrl: '{{AppSubUrl}}',
assetVersionEncoded: encodeURIComponent('{{AssetVersion}}'), // will be used in URL construction directly
assetUrlPrefix: '{{AssetUrlPrefix}}', assetUrlPrefix: '{{AssetUrlPrefix}}',
runModeIsProd: {{.RunModeIsProd}}, runModeIsProd: {{.RunModeIsProd}},
customEmojis: {{CustomEmojis}}, customEmojis: {{CustomEmojis}},
@@ -17,6 +16,7 @@ If you introduce mistakes in it, Gitea JavaScript code wouldn't run correctly.
notificationSettings: {{NotificationSettings}}, {{/*a map provided by NewFuncMap in helper.go*/}} notificationSettings: {{NotificationSettings}}, {{/*a map provided by NewFuncMap in helper.go*/}}
enableTimeTracking: {{EnableTimetracking}}, enableTimeTracking: {{EnableTimetracking}},
mermaidMaxSourceCharacters: {{MermaidMaxSourceCharacters}}, mermaidMaxSourceCharacters: {{MermaidMaxSourceCharacters}},
sharedWorkerUri: '{{AssetURI "js/eventsource.sharedworker.js"}}',
{{/* this global i18n object should only contain general texts. for specialized texts, it should be provided inside the related modules by: (1) API response (2) HTML data-attribute (3) PageData */}} {{/* this global i18n object should only contain general texts. for specialized texts, it should be provided inside the related modules by: (1) API response (2) HTML data-attribute (3) PageData */}}
i18n: { i18n: {
copy_success: {{ctx.Locale.Tr "copy_success"}}, copy_success: {{ctx.Locale.Tr "copy_success"}},
@@ -31,4 +31,4 @@ If you introduce mistakes in it, Gitea JavaScript code wouldn't run correctly.
{{/* in case some pages don't render the pageData, we make sure it is an object to prevent null access */}} {{/* in case some pages don't render the pageData, we make sure it is an object to prevent null access */}}
window.config.pageData = window.config.pageData || {}; window.config.pageData = window.config.pageData || {};
</script> </script>
<script src="{{AssetUrlPrefix}}/js/index.js?v={{AssetVersion}}" onerror="alert('Failed to load asset files from ' + this.src + '. Please make sure the asset files can be accessed.')"></script> {{ScriptImport "js/iife.js"}}

View File

@@ -1,2 +1,2 @@
<link rel="stylesheet" href="{{AssetUrlPrefix}}/css/index.css?v={{AssetVersion}}"> <link rel="stylesheet" href="{{AssetURI "css/index.css"}}">
<link rel="stylesheet" href="{{AssetUrlPrefix}}/css/theme-{{ctx.CurrentWebTheme.InternalName | PathEscape}}.css?v={{AssetVersion}}"> <link rel="stylesheet" href="{{AssetURI (printf "css/theme-%s.css" (PathEscape ctx.CurrentWebTheme.InternalName))}}">

View File

@@ -1,3 +1,3 @@
{{/* TODO: the devtest.js is isolated from index.js, so no module is shared and many index.js functions do not work in devtest.ts */}} {{/* TODO: the devtest.js is isolated from index.js, so no module is shared and many index.js functions do not work in devtest.ts */}}
<script src="{{AssetUrlPrefix}}/js/devtest.js?v={{AssetVersion}}"></script> <script type="module" src="{{AssetURI "js/devtest.js"}}"></script>
{{template "base/footer" ctx.RootData}} {{template "base/footer" ctx.RootData}}

View File

@@ -1,3 +1,8 @@
{{template "base/head" ctx.RootData}} {{template "base/head" ctx.RootData}}
<link rel="stylesheet" href="{{AssetUrlPrefix}}/css/devtest.css?v={{AssetVersion}}"> <link rel="stylesheet" href="{{AssetURI "css/devtest.css"}}">
<script>
// must make sure the jQuery is globally loaded
// ref: https://github.com/go-gitea/gitea/issues/35923
if (!window.jQuery) alert('jQuery is missing, user custom plugins may not work');
</script>
{{template "base/alert" .}} {{template "base/alert" .}}

View File

@@ -1,5 +1,5 @@
{{/* This page should only depend the minimal template functions/variables, to avoid triggering new panics. {{/* This page should only depend the minimal template functions/variables, to avoid triggering new panics.
* base template functions: AppName, AssetUrlPrefix, AssetVersion, AppSubUrl * base template functions: AppName, AssetUrlPrefix, AssetURI, AppSubUrl
* ctx.Locale * ctx.Locale
* .Flash * .Flash
* .ErrorMsg * .ErrorMsg

View File

@@ -2,13 +2,13 @@
<html lang="en"> <html lang="en">
<head> <head>
<title>Gitea API</title> <title>Gitea API</title>
<link href="{{AssetUrlPrefix}}/css/swagger.css?v={{AssetVersion}}" rel="stylesheet"> <link href="{{AssetURI "css/swagger.css"}}" rel="stylesheet">
</head> </head>
<body> <body>
{{/* TODO: add Help & Glossary to help users understand the API, and explain some concepts like "Owner" */}} {{/* TODO: add Help & Glossary to help users understand the API, and explain some concepts like "Owner" */}}
<a class="swagger-back-link" href="{{AppSubUrl}}/">{{svg "octicon-reply"}}{{ctx.Locale.Tr "return_to_gitea"}}</a> <a class="swagger-back-link" href="{{AppSubUrl}}/">{{svg "octicon-reply"}}{{ctx.Locale.Tr "return_to_gitea"}}</a>
<div id="swagger-ui" data-source="{{AppSubUrl}}/swagger.{{.APIJSONVersion}}.json"></div> <div id="swagger-ui" data-source="{{AppSubUrl}}/swagger.{{.APIJSONVersion}}.json"></div>
<footer class="page-footer"></footer> <footer class="page-footer"></footer>
<script src="{{AssetUrlPrefix}}/js/swagger.js?v={{AssetVersion}}"></script> <script type="module" src="{{AssetURI "js/swagger.js"}}"></script>
</body> </body>
</html> </html>

View File

@@ -15,6 +15,7 @@ import (
"code.gitea.io/gitea/modules/charset" "code.gitea.io/gitea/modules/charset"
"code.gitea.io/gitea/modules/markup" "code.gitea.io/gitea/modules/markup"
"code.gitea.io/gitea/modules/markup/external" "code.gitea.io/gitea/modules/markup/external"
"code.gitea.io/gitea/modules/public"
"code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/test" "code.gitea.io/gitea/modules/test"
"code.gitea.io/gitea/tests" "code.gitea.io/gitea/tests"
@@ -107,7 +108,7 @@ func TestExternalMarkupRenderer(t *testing.T) {
// default sandbox in sub page response // default sandbox in sub page response
assert.Equal(t, "frame-src 'self'; sandbox allow-scripts allow-popups", respSub.Header().Get("Content-Security-Policy")) assert.Equal(t, "frame-src 'self'; sandbox allow-scripts allow-popups", respSub.Header().Get("Content-Security-Policy"))
// FIXME: actually here is a bug (legacy design problem), the "PostProcess" will escape "<script>" tag, but it indeed is the sanitizer's job // FIXME: actually here is a bug (legacy design problem), the "PostProcess" will escape "<script>" tag, but it indeed is the sanitizer's job
assert.Equal(t, `<script src="/assets/js/external-render-iframe.js"></script><link rel="stylesheet" href="/assets/css/external-render-iframe.css"><div><any attr="val">&lt;script&gt;&lt;/script&gt;</any></div>`, respSub.Body.String()) assert.Equal(t, `<script type="module" src="`+public.AssetURI("js/external-render-iframe.js")+`"></script><link rel="stylesheet" href="`+public.AssetURI("css/external-render-iframe.css")+`"><div><any attr="val">&lt;script&gt;&lt;/script&gt;</any></div>`, respSub.Body.String())
}) })
}) })
@@ -130,7 +131,7 @@ func TestExternalMarkupRenderer(t *testing.T) {
t.Run("HTMLContentWithExternalRenderIframeHelper", func(t *testing.T) { t.Run("HTMLContentWithExternalRenderIframeHelper", func(t *testing.T) {
req := NewRequest(t, "GET", "/user2/repo1/render/branch/master/html.no-sanitizer") req := NewRequest(t, "GET", "/user2/repo1/render/branch/master/html.no-sanitizer")
respSub := MakeRequest(t, req, http.StatusOK) respSub := MakeRequest(t, req, http.StatusOK)
assert.Equal(t, `<script src="/assets/js/external-render-iframe.js"></script><link rel="stylesheet" href="/assets/css/external-render-iframe.css"><script>foo("raw")</script>`, respSub.Body.String()) assert.Equal(t, `<script type="module" src="`+public.AssetURI("js/external-render-iframe.js")+`"></script><link rel="stylesheet" href="`+public.AssetURI("css/external-render-iframe.css")+`"><script>foo("raw")</script>`, respSub.Body.String())
assert.Equal(t, "frame-src 'self'", respSub.Header().Get("Content-Security-Policy")) assert.Equal(t, "frame-src 'self'", respSub.Header().Get("Content-Security-Policy"))
}) })
}) })

View File

@@ -45,7 +45,7 @@
"verbatimModuleSyntax": true, "verbatimModuleSyntax": true,
"types": [ "types": [
"node", "node",
"webpack/module", "vite/client",
"vitest/globals", "vitest/globals",
"./web_src/js/globals.d.ts", "./web_src/js/globals.d.ts",
"./types.d.ts", "./types.d.ts",

5
types.d.ts vendored
View File

@@ -1,8 +1,3 @@
declare module '@techknowlogick/license-checker-webpack-plugin' {
const plugin: any;
export = plugin;
}
declare module 'eslint-plugin-no-use-extend-native' { declare module 'eslint-plugin-no-use-extend-native' {
import type {Eslint} from 'eslint'; import type {Eslint} from 'eslint';
const plugin: Eslint.Plugin; const plugin: Eslint.Plugin;

332
vite.config.ts Normal file
View File

@@ -0,0 +1,332 @@
import {build, defineConfig} from 'vite';
import vuePlugin from '@vitejs/plugin-vue';
import {stringPlugin} from 'vite-string-plugin';
import {readFileSync, writeFileSync, unlinkSync, globSync} from 'node:fs';
import {join, parse} from 'node:path';
import {env} from 'node:process';
import tailwindcss from 'tailwindcss';
import tailwindConfig from './tailwind.config.ts';
import wrapAnsi from 'wrap-ansi';
import licensePlugin from 'rollup-plugin-license';
import type {InlineConfig, Plugin, Rolldown} from 'vite';
const isProduction = env.NODE_ENV !== 'development';
// ENABLE_SOURCEMAP accepts the following values:
// true - all sourcemaps enabled, the default in development
// reduced - sourcemaps only for index.js, the default in production
// false - all sourcemaps disabled
let enableSourcemap: string;
if ('ENABLE_SOURCEMAP' in env) {
enableSourcemap = ['true', 'false'].includes(env.ENABLE_SOURCEMAP!) ? env.ENABLE_SOURCEMAP! : 'reduced';
} else {
enableSourcemap = isProduction ? 'reduced' : 'true';
}
const outDir = join(import.meta.dirname, 'public/assets');
const themes: Record<string, string> = {};
for (const path of globSync('web_src/css/themes/*.css', {cwd: import.meta.dirname})) {
themes[parse(path).name] = join(import.meta.dirname, path);
}
const webComponents = new Set([
// our own, in web_src/js/webcomponents
'overflow-menu',
'origin-url',
'relative-time',
// from dependencies
'markdown-toolbar',
'text-expander',
]);
function formatLicenseText(licenseText: string) {
return wrapAnsi(licenseText || '', 80).trim();
}
const commonRolldownOptions: Rolldown.RolldownOptions = {
checks: {
eval: false, // htmx needs eval
pluginTimings: false,
},
};
function commonViteOpts({build, ...other}: InlineConfig): InlineConfig {
const {rolldownOptions, ...otherBuild} = build || {};
return {
base: './', // make all asset URLs relative, so it works in subdirectory deployments
configFile: false,
root: import.meta.dirname,
publicDir: false,
build: {
outDir,
emptyOutDir: false,
sourcemap: enableSourcemap !== 'false',
target: 'es2020',
minify: isProduction ? 'oxc' : false,
cssMinify: isProduction ? 'esbuild' : false,
chunkSizeWarningLimit: Infinity,
assetsInlineLimit: 32768,
reportCompressedSize: false,
rolldownOptions: {
...commonRolldownOptions,
...rolldownOptions,
},
...otherBuild,
},
...other,
};
}
const iifeEntry = join(import.meta.dirname, 'web_src/js/iife.ts');
function iifeBuildOpts({entryFileNames, write}: {entryFileNames: string, write?: boolean}) {
return commonViteOpts({
build: {
lib: {entry: iifeEntry, formats: ['iife'], name: 'iife'},
rolldownOptions: {output: {entryFileNames}},
...(write === false && {write: false}),
},
plugins: [stringPlugin()],
});
}
// Build iife.js as a blocking IIFE bundle. In dev mode, serves it from memory
// and rebuilds on file changes. In prod mode, writes to disk during closeBundle.
function iifePlugin(): Plugin {
let iifeCode = '';
let iifeMap = '';
const iifeModules = new Set<string>();
let isBuilding = false;
return {
name: 'iife',
async configureServer(server) {
const buildAndCache = async () => {
const result = await build(iifeBuildOpts({entryFileNames: 'js/iife.js', write: false}));
const output = (Array.isArray(result) ? result[0] : result) as Rolldown.RolldownOutput;
const chunk = output.output[0];
iifeCode = chunk.code.replace(/\/\/# sourceMappingURL=.*/, '//# sourceMappingURL=__vite_iife.js.map');
const mapAsset = output.output.find((o) => o.fileName.endsWith('.map'));
iifeMap = mapAsset && 'source' in mapAsset ? String(mapAsset.source) : '';
iifeModules.clear();
for (const id of Object.keys(chunk.modules)) iifeModules.add(id);
};
await buildAndCache();
let needsRebuild = false;
server.watcher.on('change', async (path) => {
if (!iifeModules.has(path)) return;
needsRebuild = true;
if (isBuilding) return;
isBuilding = true;
try {
do {
needsRebuild = false;
await buildAndCache();
} while (needsRebuild);
server.ws.send({type: 'full-reload'});
} finally {
isBuilding = false;
}
});
server.middlewares.use((req, res, next) => {
// "__vite_iife" is a virtual file in memory, serve it directly
const pathname = req.url!.split('?')[0];
if (pathname === '/web_src/js/__vite_iife.js') {
res.setHeader('Content-Type', 'application/javascript');
res.setHeader('Cache-Control', 'no-store');
res.end(iifeCode);
} else if (pathname === '/web_src/js/__vite_iife.js.map') {
res.setHeader('Content-Type', 'application/json');
res.setHeader('Cache-Control', 'no-store');
res.end(iifeMap);
} else {
next();
}
});
},
async closeBundle() {
for (const file of globSync('js/iife.*.js*', {cwd: outDir})) unlinkSync(join(outDir, file));
const result = await build(iifeBuildOpts({entryFileNames: 'js/iife.[hash:8].js'}));
const buildOutput = (Array.isArray(result) ? result[0] : result) as Rolldown.RolldownOutput;
const entry = buildOutput.output.find((o) => o.fileName.startsWith('js/iife.'));
if (!entry) throw new Error('IIFE build produced no output');
const manifestPath = join(outDir, '.vite', 'manifest.json');
writeFileSync(manifestPath, JSON.stringify({
...JSON.parse(readFileSync(manifestPath, 'utf8')),
'web_src/js/iife.ts': {file: entry.fileName, name: 'iife', isEntry: true},
}, null, 2));
},
};
}
// In reduced sourcemap mode, only keep sourcemaps for main files
function reducedSourcemapPlugin(): Plugin {
return {
name: 'reduced-sourcemap',
apply: 'build',
closeBundle() {
if (enableSourcemap !== 'reduced') return;
for (const file of globSync('{js,css}/*.map', {cwd: outDir})) {
if (!file.startsWith('js/index.') && !file.startsWith('js/iife.')) unlinkSync(join(outDir, file));
}
},
};
}
// Filter out legacy font formats from CSS, keeping only woff2
function filterCssUrlPlugin(): Plugin {
return {
name: 'filter-css-url',
enforce: 'pre',
transform(code, id) {
if (!id.endsWith('.css') || !id.includes('katex')) return null;
return code.replace(/,\s*url\([^)]*\.(?:woff|ttf)\)\s*format\("[^"]*"\)/gi, '');
},
};
}
const viteDevServerPort = Number(env.FRONTEND_DEV_SERVER_PORT) || 3001;
const viteDevPortFilePath = join(outDir, '.vite', 'dev-port');
// Write the Vite dev server's actual port to a file so the Go server can discover it for proxying.
function viteDevServerPortPlugin(): Plugin {
return {
name: 'vite-dev-server-port',
apply: 'serve',
configureServer(server) {
server.httpServer!.once('listening', () => {
const addr = server.httpServer!.address();
if (typeof addr === 'object' && addr) {
writeFileSync(viteDevPortFilePath, String(addr.port));
}
});
},
};
}
export default defineConfig(commonViteOpts({
appType: 'custom', // Go serves all HTML, disable Vite's HTML handling
clearScreen: false,
server: {
port: viteDevServerPort,
open: false,
host: '0.0.0.0',
strictPort: false,
fs: {
// VITE-DEV-SERVER-SECURITY: the dev server will be exposed to public by Gitea's web server, so we need to strictly limit the access
// Otherwise `/@fs/*` will be able to access any file (including app.ini which contains INTERNAL_TOKEN)
strict: true,
allow: [
'assets',
'node_modules',
'public',
'web_src',
// do not add any other directories here, unless you are absolutely sure it's safe to expose them to the public
],
},
headers: {
'Cache-Control': 'no-store', // prevent browser disk cache
},
warmup: {
clientFiles: [
// warmup the important entry points
'web_src/js/index.ts',
'web_src/css/index.css',
'web_src/css/themes/*.css',
],
},
},
build: {
modulePreload: false,
manifest: true,
rolldownOptions: {
input: {
index: join(import.meta.dirname, 'web_src/js/index.ts'),
swagger: join(import.meta.dirname, 'web_src/js/standalone/swagger.ts'),
'external-render-iframe': join(import.meta.dirname, 'web_src/js/standalone/external-render-iframe.ts'),
'eventsource.sharedworker': join(import.meta.dirname, 'web_src/js/features/eventsource.sharedworker.ts'),
...(!isProduction && {
devtest: join(import.meta.dirname, 'web_src/js/standalone/devtest.ts'),
}),
...themes,
},
output: {
entryFileNames: 'js/[name].[hash:8].js',
chunkFileNames: 'js/[name].[hash:8].js',
assetFileNames: ({names}) => {
const name = names[0];
if (name.endsWith('.css')) return 'css/[name].[hash:8].css';
if (/\.(ttf|woff2?)$/.test(name)) return 'fonts/[name].[hash:8].[ext]';
return '[name].[hash:8].[ext]';
},
},
},
},
worker: {
rolldownOptions: {
...commonRolldownOptions,
output: {
entryFileNames: 'js/[name].[hash:8].js',
},
},
},
css: {
transformer: 'postcss',
postcss: {
plugins: [
tailwindcss(tailwindConfig),
],
},
},
define: {
__VUE_OPTIONS_API__: true,
__VUE_PROD_DEVTOOLS__: false,
__VUE_PROD_HYDRATION_MISMATCH_DETAILS__: false,
},
plugins: [
iifePlugin(),
viteDevServerPortPlugin(),
reducedSourcemapPlugin(),
filterCssUrlPlugin(),
stringPlugin(),
vuePlugin({
template: {
compilerOptions: {
isCustomElement: (tag) => webComponents.has(tag),
},
},
}),
isProduction ? licensePlugin({
thirdParty: {
output: {
file: join(import.meta.dirname, 'public/assets/licenses.txt'),
template(deps) {
const line = '-'.repeat(80);
const goJson = readFileSync(join(import.meta.dirname, 'assets/go-licenses.json'), 'utf8');
const goModules = JSON.parse(goJson).map(({name, licenseText}: {name: string, licenseText: string}) => {
return {name, body: formatLicenseText(licenseText)};
});
const jsModules = deps.map((dep) => {
return {name: dep.name, version: dep.version, body: formatLicenseText(dep.licenseText ?? '')};
});
const modules = [...goModules, ...jsModules].sort((a, b) => a.name.localeCompare(b.name));
return modules.map(({name, version, body}: {name: string, version?: string, body: string}) => {
const title = version ? `${name}@${version}` : name;
return `${line}\n${title}\n${line}\n${body}`;
}).join('\n');
},
},
allow(dependency) {
if (dependency.name === 'khroma') return true; // MIT: https://github.com/fabiospampinato/khroma/pull/33
return /(Apache-2\.0|0BSD|BSD-2-Clause|BSD-3-Clause|MIT|ISC|CPAL-1\.0|Unlicense|EPL-1\.0|EPL-2\.0)/.test(dependency.license ?? '');
},
},
}) : {
name: 'dev-licenses-stub',
closeBundle() {
writeFileSync(join(outDir, 'licenses.txt'), 'Licenses are disabled during development');
},
},
],
}));

View File

@@ -538,6 +538,58 @@ strong.attention-caution, svg.attention-caution {
overflow-menu { overflow-menu {
border-bottom: 1px solid var(--color-secondary) !important; border-bottom: 1px solid var(--color-secondary) !important;
display: flex; display: flex;
position: relative;
}
overflow-menu .overflow-menu-popup {
position: absolute;
top: calc(100% + 8px);
right: 0;
z-index: 100;
background-color: var(--color-menu);
color: var(--color-text);
border: 1px solid var(--color-secondary);
border-radius: var(--border-radius);
box-shadow: 0 6px 18px var(--color-shadow);
padding: 4px 0;
}
overflow-menu .overflow-menu-popup::before,
overflow-menu .overflow-menu-popup::after {
content: "";
position: absolute;
right: 10px;
border: 8px solid transparent;
}
overflow-menu .overflow-menu-popup::before {
bottom: 100%;
border-bottom-color: var(--color-secondary);
}
overflow-menu .overflow-menu-popup::after {
bottom: calc(100% - 1px);
border-bottom-color: var(--color-menu);
}
overflow-menu .overflow-menu-popup > .item {
display: flex;
align-items: center;
padding: 9px 18px !important;
color: var(--color-text) !important;
background: transparent !important;
text-decoration: none;
gap: 10px;
width: 100%;
}
overflow-menu .overflow-menu-popup > .item:hover,
overflow-menu .overflow-menu-popup > .item:focus {
background: var(--color-hover) !important;
}
overflow-menu .overflow-menu-popup > .item.active {
background: var(--color-active) !important;
} }
overflow-menu .overflow-menu-items { overflow-menu .overflow-menu-items {

View File

@@ -1,82 +1,12 @@
// DO NOT IMPORT window.config HERE! // DO NOT IMPORT window.config HERE!
// 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 {showGlobalErrorMessage, processWindowErrorEvent} from './modules/errors.ts';
import {html} from './utils/html.ts';
// This sets up the URL prefix used in webpack's chunk loading. // A module should not be imported twice, otherwise there will be bugs when a module has its internal states.
// This file must be imported before any lazy-loading is being attempted. // A real example is "generateElemId" in "utils/dom.ts", if it is imported twice in different module scopes,
window.__webpack_public_path__ = `${window.config?.assetUrlPrefix ?? '/assets'}/`; // It will generate duplicate IDs (ps: don't try to use "random" to fix, it is just a real example to show the importance of "do not import a module twice")
if (!window._globalHandlerErrors?._inited) {
export function shouldIgnoreError(err: Error) {
const ignorePatterns: Array<RegExp> = [
// https://github.com/go-gitea/gitea/issues/30861
// https://github.com/microsoft/monaco-editor/issues/4496
// https://github.com/microsoft/monaco-editor/issues/4679
/\/assets\/js\/.*monaco/,
];
for (const pattern of ignorePatterns) {
if (pattern.test(err.stack ?? '')) return true;
}
return false;
}
export function showGlobalErrorMessage(msg: string, msgType: Intent = 'error') {
const msgContainer = document.querySelector('.page-content') ?? document.body;
if (!msgContainer) {
alert(`${msgType}: ${msg}`);
return;
}
const msgCompact = msg.replace(/\W/g, '').trim(); // compact the message to a data attribute to avoid too many duplicated messages
let msgDiv = msgContainer.querySelector<HTMLDivElement>(`.js-global-error[data-global-error-msg-compact="${msgCompact}"]`);
if (!msgDiv) {
const el = document.createElement('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
const msgCount = Number(msgDiv.getAttribute(`data-global-error-msg-count`)) + 1;
msgDiv.setAttribute(`data-global-error-msg-compact`, msgCompact);
msgDiv.setAttribute(`data-global-error-msg-count`, msgCount.toString());
msgDiv.querySelector('.ui.message')!.textContent = msg + (msgCount > 1 ? ` (${msgCount})` : '');
msgContainer.prepend(msgDiv);
}
function processWindowErrorEvent({error, reason, message, type, filename, lineno, colno}: ErrorEvent & PromiseRejectionEvent) {
const err = error ?? reason;
const assetBaseUrl = String(new URL(window.__webpack_public_path__, window.location.origin));
const {runModeIsProd} = window.config ?? {};
// `error` and `reason` are not guaranteed to be errors. If the value is falsy, it is likely a
// non-critical event from the browser. We log them but don't show them to users. Examples:
// - https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver#observation_errors
// - https://github.com/mozilla-mobile/firefox-ios/issues/10817
// - https://github.com/go-gitea/gitea/issues/20240
if (!err) {
if (message) console.error(new Error(message));
if (runModeIsProd) return;
}
if (err instanceof Error) {
// If the error stack trace does not include the base URL of our script assets, it likely came
// from a browser extension or inline script. Do not show such errors in production.
if (!err.stack?.includes(assetBaseUrl) && runModeIsProd) return;
// Ignore some known errors that are unable to fix
if (shouldIgnoreError(err)) return;
}
let msg = err?.message ?? message;
if (lineno) msg += ` (${filename} @ ${lineno}:${colno})`;
const dot = msg.endsWith('.') ? '' : '.';
const renderedType = type === 'unhandledrejection' ? 'promise rejection' : type;
showGlobalErrorMessage(`JavaScript ${renderedType}: ${msg}${dot} Open browser console to see more details.`);
}
function initGlobalErrorHandler() {
if (window._globalHandlerErrors?._inited) {
showGlobalErrorMessage(`The global error handler has been initialized, do not initialize it again`);
return;
}
if (!window.config) { if (!window.config) {
showGlobalErrorMessage(`Gitea JavaScript code couldn't run correctly, please check your custom templates`); showGlobalErrorMessage(`Gitea JavaScript code couldn't run correctly, please check your custom templates`);
} }
@@ -90,5 +20,3 @@ function initGlobalErrorHandler() {
// events directly // events directly
window._globalHandlerErrors = {_inited: true, push: (e: ErrorEvent & PromiseRejectionEvent) => processWindowErrorEvent(e)} as any; window._globalHandlerErrors = {_inited: true, push: (e: ErrorEvent & PromiseRejectionEvent) => processWindowErrorEvent(e)} as any;
} }
initGlobalErrorHandler();

View File

@@ -34,7 +34,7 @@ export async function initCaptcha() {
break; break;
} }
case 'm-captcha': { case 'm-captcha': {
const mCaptcha = await import(/* webpackChunkName: "mcaptcha-vanilla-glue" */'@mcaptcha/vanilla-glue'); const mCaptcha = await import('@mcaptcha/vanilla-glue');
// FIXME: the mCaptcha code is not right, it's a miracle that the wrong code could run // FIXME: the mCaptcha code is not right, it's a miracle that the wrong code could run
// * the "vanilla-glue" has some problems with es6 module. // * the "vanilla-glue" has some problems with es6 module.

View File

@@ -6,10 +6,10 @@ const {pageData} = window.config;
async function initInputCitationValue(citationCopyApa: HTMLButtonElement, citationCopyBibtex: HTMLButtonElement) { async function initInputCitationValue(citationCopyApa: HTMLButtonElement, citationCopyBibtex: HTMLButtonElement) {
const [{Cite, plugins}] = await Promise.all([ const [{Cite, plugins}] = await Promise.all([
import(/* webpackChunkName: "citation-js-core" */'@citation-js/core'), import('@citation-js/core'),
import(/* webpackChunkName: "citation-js-formats" */'@citation-js/plugin-software-formats'), import('@citation-js/plugin-software-formats'),
import(/* webpackChunkName: "citation-js-bibtex" */'@citation-js/plugin-bibtex'), import('@citation-js/plugin-bibtex'),
import(/* webpackChunkName: "citation-js-csl" */'@citation-js/plugin-csl'), import('@citation-js/plugin-csl'),
]); ]);
const citationFileContent = pageData.citationFileContent!; const citationFileContent = pageData.citationFileContent!;
const config = plugins.config.get('@bibtex'); const config = plugins.config.get('@bibtex');

View File

@@ -4,7 +4,7 @@ export async function initRepoCodeFrequency() {
const el = document.querySelector('#repo-code-frequency-chart'); const el = document.querySelector('#repo-code-frequency-chart');
if (!el) return; if (!el) return;
const {default: RepoCodeFrequency} = await import(/* webpackChunkName: "code-frequency-graph" */'../components/RepoCodeFrequency.vue'); const {default: RepoCodeFrequency} = await import('../components/RepoCodeFrequency.vue');
try { try {
const View = createApp(RepoCodeFrequency, { const View = createApp(RepoCodeFrequency, {
locale: { locale: {

View File

@@ -129,7 +129,7 @@ function updateTheme(monaco: Monaco): void {
type CreateMonacoOpts = MonacoOpts & {language?: string}; type CreateMonacoOpts = MonacoOpts & {language?: string};
export async function createMonaco(textarea: HTMLTextAreaElement, filename: string, opts: CreateMonacoOpts): Promise<{monaco: Monaco, editor: IStandaloneCodeEditor}> { export async function createMonaco(textarea: HTMLTextAreaElement, filename: string, opts: CreateMonacoOpts): Promise<{monaco: Monaco, editor: IStandaloneCodeEditor}> {
const monaco = await import(/* webpackChunkName: "monaco" */'monaco-editor'); const monaco = await import('../modules/monaco.ts');
initLanguages(monaco); initLanguages(monaco);
let {language, ...other} = opts; let {language, ...other} = opts;

View File

@@ -6,8 +6,8 @@ export async function initColorPickers() {
registerGlobalInitFunc('initColorPicker', async (el) => { registerGlobalInitFunc('initColorPicker', async (el) => {
if (!imported) { if (!imported) {
await Promise.all([ await Promise.all([
import(/* webpackChunkName: "colorpicker" */'vanilla-colorful/hex-color-picker.js'), import('vanilla-colorful/hex-color-picker.js'),
import(/* webpackChunkName: "colorpicker" */'../../css/features/colorpicker.css'), import('../../css/features/colorpicker.css'),
]); ]);
imported = true; imported = true;
} }

View File

@@ -1,5 +1,5 @@
import {GET, POST} from '../modules/fetch.ts'; import {GET, POST} from '../modules/fetch.ts';
import {showGlobalErrorMessage} from '../bootstrap.ts'; import {showGlobalErrorMessage} from '../modules/errors.ts';
import {fomanticQuery} from '../modules/fomantic/base.ts'; import {fomanticQuery} from '../modules/fomantic/base.ts';
import {addDelegatedEventListener, queryElems} from '../utils/dom.ts'; import {addDelegatedEventListener, queryElems} from '../utils/dom.ts';
import {registerGlobalInitFunc, registerGlobalSelectorFunc} from '../modules/observer.ts'; import {registerGlobalInitFunc, registerGlobalSelectorFunc} from '../modules/observer.ts';

View File

@@ -319,8 +319,8 @@ export class ComboMarkdownEditor {
async switchToEasyMDE() { async switchToEasyMDE() {
if (this.easyMDE) return; if (this.easyMDE) return;
const [{default: EasyMDE}] = await Promise.all([ const [{default: EasyMDE}] = await Promise.all([
import(/* webpackChunkName: "easymde" */'easymde'), import('easymde'),
import(/* webpackChunkName: "easymde" */'../../../css/easymde.css'), import('../../../css/easymde.css'),
]); ]);
const easyMDEOpt: EasyMDE.Options = { const easyMDEOpt: EasyMDE.Options = {
autoDownloadFontAwesome: false, autoDownloadFontAwesome: false,

View File

@@ -7,7 +7,7 @@ type CropperOpts = {
}; };
async function initCompCropper({container, fileInput, imageSource}: CropperOpts) { async function initCompCropper({container, fileInput, imageSource}: CropperOpts) {
const {default: Cropper} = await import(/* webpackChunkName: "cropperjs" */'cropperjs'); const {default: Cropper} = await import('cropperjs');
let currentFileName = ''; let currentFileName = '';
let currentFileLastModified = 0; let currentFileLastModified = 0;
const cropper = new Cropper(imageSource, { const cropper = new Cropper(imageSource, {

View File

@@ -4,7 +4,7 @@ export async function initRepoContributors() {
const el = document.querySelector('#repo-contributors-chart'); const el = document.querySelector('#repo-contributors-chart');
if (!el) return; if (!el) return;
const {default: RepoContributors} = await import(/* webpackChunkName: "contributors-graph" */'../components/RepoContributors.vue'); const {default: RepoContributors} = await import('../components/RepoContributors.vue');
try { try {
const View = createApp(RepoContributors, { const View = createApp(RepoContributors, {
repoLink: el.getAttribute('data-repo-link'), repoLink: el.getAttribute('data-repo-link'),

View File

@@ -19,8 +19,8 @@ export const DropzoneCustomEventUploadDone = 'dropzone-custom-upload-done';
async function createDropzone(el: HTMLElement, opts: DropzoneOptions) { async function createDropzone(el: HTMLElement, opts: DropzoneOptions) {
const [{default: Dropzone}] = await Promise.all([ const [{default: Dropzone}] = await Promise.all([
import(/* webpackChunkName: "dropzone" */'dropzone'), import('dropzone'),
import(/* webpackChunkName: "dropzone" */'dropzone/dist/dropzone.css'), import('dropzone/dist/dropzone.css'),
]); ]);
return new Dropzone(el, opts); return new Dropzone(el, opts);
} }

View File

@@ -45,7 +45,7 @@ export async function initHeatmap() {
noDataText: el.getAttribute('data-locale-no-contributions'), noDataText: el.getAttribute('data-locale-no-contributions'),
}; };
const {default: ActivityHeatmap} = await import(/* webpackChunkName: "ActivityHeatmap" */ '../components/ActivityHeatmap.vue'); const {default: ActivityHeatmap} = await import('../components/ActivityHeatmap.vue');
const View = createApp(ActivityHeatmap, {values, locale}); const View = createApp(ActivityHeatmap, {values, locale});
View.mount(el); View.mount(el);
el.classList.remove('is-loading'); el.classList.remove('is-loading');

View File

@@ -4,7 +4,7 @@ export async function initRepoRecentCommits() {
const el = document.querySelector('#repo-recent-commits-chart'); const el = document.querySelector('#repo-recent-commits-chart');
if (!el) return; if (!el) return;
const {default: RepoRecentCommits} = await import(/* webpackChunkName: "recent-commits-graph" */'../components/RepoRecentCommits.vue'); const {default: RepoRecentCommits} = await import('../components/RepoRecentCommits.vue');
try { try {
const View = createApp(RepoRecentCommits, { const View = createApp(RepoRecentCommits, {
locale: { locale: {

View File

@@ -69,7 +69,7 @@ export function filterRepoFilesWeighted(files: Array<string>, filter: string) {
export function initRepoFileSearch() { export function initRepoFileSearch() {
registerGlobalInitFunc('initRepoFileSearch', async (el) => { registerGlobalInitFunc('initRepoFileSearch', async (el) => {
const {default: RepoFileSearch} = await import(/* webpackChunkName: "RepoFileSearch" */ '../components/RepoFileSearch.vue'); const {default: RepoFileSearch} = await import('../components/RepoFileSearch.vue');
createApp(RepoFileSearch, { createApp(RepoFileSearch, {
repoLink: el.getAttribute('data-repo-link'), repoLink: el.getAttribute('data-repo-link'),
currentRefNameSubURL: el.getAttribute('data-current-ref-name-sub-url'), currentRefNameSubURL: el.getAttribute('data-current-ref-name-sub-url'),

View File

@@ -66,7 +66,7 @@ async function initRepoPullRequestMergeForm(box: HTMLElement) {
const el = box.querySelector('#pull-request-merge-form'); const el = box.querySelector('#pull-request-merge-form');
if (!el) return; if (!el) return;
const {default: PullRequestMergeForm} = await import(/* webpackChunkName: "PullRequestMergeForm" */ '../components/PullRequestMergeForm.vue'); const {default: PullRequestMergeForm} = await import('../components/PullRequestMergeForm.vue');
const view = createApp(PullRequestMergeForm); const view = createApp(PullRequestMergeForm);
view.mount(el); view.mount(el);
} }

View File

@@ -5,7 +5,7 @@ import type {TributeCollection} from 'tributejs';
import type {Mention} from '../types.ts'; import type {Mention} from '../types.ts';
export async function attachTribute(element: HTMLElement) { export async function attachTribute(element: HTMLElement) {
const {default: Tribute} = await import(/* webpackChunkName: "tribute" */'tributejs'); const {default: Tribute} = await import('tributejs');
const mentionsUrl = element.closest('[data-mentions-url]')?.getAttribute('data-mentions-url'); const mentionsUrl = element.closest('[data-mentions-url]')?.getAttribute('data-mentions-url');
const emojiCollection: TributeCollection<string> = { // emojis const emojiCollection: TributeCollection<string> = { // emojis

View File

@@ -22,8 +22,8 @@ interface Window {
config: { config: {
appUrl: string, appUrl: string,
appSubUrl: string, appSubUrl: string,
assetVersionEncoded: string,
assetUrlPrefix: string, assetUrlPrefix: string,
sharedWorkerUri: string,
runModeIsProd: boolean, runModeIsProd: boolean,
customEmojis: Record<string, string>, customEmojis: Record<string, string>,
pageData: Record<string, any> & { pageData: Record<string, any> & {
@@ -64,6 +64,10 @@ interface Window {
codeEditors: any[], // export editor for customization codeEditors: any[], // export editor for customization
localUserSettings: typeof import('./modules/user-settings.ts').localUserSettings, localUserSettings: typeof import('./modules/user-settings.ts').localUserSettings,
MonacoEnvironment?: {
getWorker: (workerId: string, label: string) => Worker,
},
// various captcha plugins // various captcha plugins
grecaptcha: any, grecaptcha: any,
turnstile: any, turnstile: any,
@@ -71,3 +75,8 @@ interface Window {
// do not add more properties here unless it is a must // do not add more properties here unless it is a must
} }
declare module '*?worker' {
const workerConstructor: new () => Worker;
export default workerConstructor;
}

View File

@@ -1,2 +1,16 @@
import jquery from 'jquery'; import jquery from 'jquery'; // eslint-disable-line no-restricted-imports
window.$ = window.jQuery = jquery; // only for Fomantic UI import htmx from 'htmx.org'; // eslint-disable-line no-restricted-imports
import 'idiomorph/htmx'; // eslint-disable-line no-restricted-imports
// Some users still use inline scripts and expect jQuery to be available globally.
// To avoid breaking existing users and custom plugins, import jQuery globally without ES module.
window.$ = window.jQuery = jquery;
// There is a bug in htmx, it incorrectly checks "readyState === 'complete'" when the DOM tree is ready and won't trigger DOMContentLoaded
// The bug makes htmx impossible to be loaded from an ES module: importing the htmx in onDomReady will make htmx skip its initialization.
// ref: https://github.com/bigskysoftware/htmx/pull/3365
window.htmx = htmx;
// https://htmx.org/reference/#config
htmx.config.requestClass = 'is-loading';
htmx.config.scrollIntoViewOnBoost = false;

View File

@@ -1,26 +0,0 @@
import htmx from 'htmx.org';
import 'idiomorph/htmx';
import type {HtmxResponseInfo} from 'htmx.org';
import {showErrorToast} from './modules/toast.ts';
type HtmxEvent = Event & {detail: HtmxResponseInfo};
export function initHtmx() {
window.htmx = htmx;
// https://htmx.org/reference/#config
htmx.config.requestClass = 'is-loading';
htmx.config.scrollIntoViewOnBoost = false;
// https://htmx.org/events/#htmx:sendError
document.body.addEventListener('htmx:sendError', (event: Partial<HtmxEvent>) => {
// TODO: add translations
showErrorToast(`Network error when calling ${event.detail!.requestConfig.path}`);
});
// https://htmx.org/events/#htmx:responseError
document.body.addEventListener('htmx:responseError', (event: Partial<HtmxEvent>) => {
// TODO: add translations
showErrorToast(`Error ${event.detail!.xhr.status} when calling ${event.detail!.requestConfig.path}`);
});
}

11
web_src/js/iife.ts Normal file
View File

@@ -0,0 +1,11 @@
// This file is the entry point for the code which should block the page rendering, it is compiled by our "iife" vite plugin
// bootstrap module must be the first one to be imported, it handles global errors
import './bootstrap.ts';
// many users expect to use jQuery in their custom scripts (https://docs.gitea.com/administration/customizing-gitea#example-plantuml)
// so load globals (including jQuery) as early as possible
import './globals.ts';
import './webcomponents/index.ts';
import './modules/user-settings.ts'; // templates also need to use localUserSettings in inline scripts

View File

@@ -1,175 +0,0 @@
import '../fomantic/build/fomantic.js';
import {initHtmx} from './htmx.ts';
import {initDashboardRepoList} from './features/dashboard.ts';
import {initGlobalCopyToClipboardListener} from './features/clipboard.ts';
import {initRepoGraphGit} from './features/repo-graph.ts';
import {initHeatmap} from './features/heatmap.ts';
import {initImageDiff} from './features/imagediff.ts';
import {initRepoMigration} from './features/repo-migration.ts';
import {initRepoProject} from './features/repo-projects.ts';
import {initTableSort} from './features/tablesort.ts';
import {initAdminUserListSearchForm} from './features/admin/users.ts';
import {initAdminConfigs} from './features/admin/config.ts';
import {initMarkupAnchors} from './markup/anchors.ts';
import {initNotificationCount} from './features/notification.ts';
import {initRepoIssueContentHistory} from './features/repo-issue-content.ts';
import {initStopwatch} from './features/stopwatch.ts';
import {initRepoFileSearch} from './features/repo-findfile.ts';
import {initMarkupContent} from './markup/content.ts';
import {initRepoFileView} from './features/file-view.ts';
import {initUserAuthOauth2, initUserCheckAppUrl} from './features/user-auth.ts';
import {initRepoPullRequestAllowMaintainerEdit, initRepoPullRequestReview, initRepoIssueSidebarDependency, initRepoIssueFilterItemLabel} from './features/repo-issue.ts';
import {initRepoEllipsisButton, initCommitStatuses} from './features/repo-commit.ts';
import {initRepoTopicBar} from './features/repo-home.ts';
import {initAdminCommon} from './features/admin/common.ts';
import {initRepoCodeView} from './features/repo-code.ts';
import {initSshKeyFormParser} from './features/sshkey-helper.ts';
import {initUserSettings} from './features/user-settings.ts';
import {initRepoActivityTopAuthorsChart, initRepoArchiveLinks} from './features/repo-common.ts';
import {initRepoMigrationStatusChecker} from './features/repo-migrate.ts';
import {initRepoDiffView} from './features/repo-diff.ts';
import {initOrgTeam} from './features/org-team.ts';
import {initUserAuthWebAuthn, initUserAuthWebAuthnRegister} from './features/user-auth-webauthn.ts';
import {initRepoReleaseNew} from './features/repo-release.ts';
import {initRepoEditor} from './features/repo-editor.ts';
import {initCompSearchUserBox} from './features/comp/SearchUserBox.ts';
import {initInstall} from './features/install.ts';
import {initCompWebHookEditor} from './features/comp/WebHookEditor.ts';
import {initRepoBranchButton} from './features/repo-branch.ts';
import {initCommonOrganization} from './features/common-organization.ts';
import {initRepoWikiForm} from './features/repo-wiki.ts';
import {initRepository, initBranchSelectorTabs} from './features/repo-legacy.ts';
import {initCopyContent} from './features/copycontent.ts';
import {initCaptcha} from './features/captcha.ts';
import {initRepositoryActionView} from './features/repo-actions.ts';
import {initGlobalTooltips} from './modules/tippy.ts';
import {initGiteaFomantic} from './modules/fomantic.ts';
import {initSubmitEventPolyfill} from './utils/dom.ts';
import {initRepoIssueList} from './features/repo-issue-list.ts';
import {initCommonIssueListQuickGoto} from './features/common-issue-list.ts';
import {initRepoContributors} from './features/contributors.ts';
import {initRepoCodeFrequency} from './features/code-frequency.ts';
import {initRepoRecentCommits} from './features/recent-commits.ts';
import {initRepoDiffCommitBranchesAndTags} from './features/repo-diff-commit.ts';
import {initGlobalSelectorObserver} from './modules/observer.ts';
import {initRepositorySearch} from './features/repo-search.ts';
import {initColorPickers} from './features/colorpicker.ts';
import {initAdminSelfCheck} from './features/admin/selfcheck.ts';
import {initOAuth2SettingsDisableCheckbox} from './features/oauth2-settings.ts';
import {initGlobalFetchAction} from './features/common-fetch-action.ts';
import {initCommmPageComponents, initGlobalComponent, initGlobalDropdown, initGlobalInput} from './features/common-page.ts';
import {initGlobalButtonClickOnEnter, initGlobalButtons, initGlobalDeleteButton} from './features/common-button.ts';
import {initGlobalComboMarkdownEditor, initGlobalEnterQuickSubmit, initGlobalFormDirtyLeaveConfirm} from './features/common-form.ts';
import {callInitFunctions} from './modules/init.ts';
import {initRepoViewFileTree} from './features/repo-view-file-tree.ts';
import {initActionsPermissionsForm} from './features/common-actions-permissions.ts';
import {initGlobalShortcut} from './modules/shortcut.ts';
const initStartTime = performance.now();
const initPerformanceTracer = callInitFunctions([
initHtmx,
initSubmitEventPolyfill,
initGiteaFomantic,
initGlobalComponent,
initGlobalDropdown,
initGlobalFetchAction,
initGlobalTooltips,
initGlobalButtonClickOnEnter,
initGlobalButtons,
initGlobalCopyToClipboardListener,
initGlobalEnterQuickSubmit,
initGlobalFormDirtyLeaveConfirm,
initGlobalComboMarkdownEditor,
initGlobalDeleteButton,
initGlobalInput,
initGlobalShortcut,
initCommonOrganization,
initCommonIssueListQuickGoto,
initCompSearchUserBox,
initCompWebHookEditor,
initInstall,
initCommmPageComponents,
initHeatmap,
initImageDiff,
initMarkupAnchors,
initMarkupContent,
initSshKeyFormParser,
initStopwatch,
initTableSort,
initRepoFileSearch,
initCopyContent,
initAdminCommon,
initAdminUserListSearchForm,
initAdminConfigs,
initAdminSelfCheck,
initDashboardRepoList,
initNotificationCount,
initOrgTeam,
initRepoActivityTopAuthorsChart,
initRepoArchiveLinks,
initRepoBranchButton,
initRepoCodeView,
initBranchSelectorTabs,
initRepoEllipsisButton,
initRepoDiffCommitBranchesAndTags,
initRepoEditor,
initRepoGraphGit,
initRepoIssueContentHistory,
initRepoIssueList,
initRepoIssueFilterItemLabel,
initRepoIssueSidebarDependency,
initRepoMigration,
initRepoMigrationStatusChecker,
initRepoProject,
initRepoPullRequestAllowMaintainerEdit,
initRepoPullRequestReview,
initRepoReleaseNew,
initRepoTopicBar,
initRepoViewFileTree,
initRepoWikiForm,
initRepository,
initRepositoryActionView,
initRepositorySearch,
initRepoContributors,
initRepoCodeFrequency,
initRepoRecentCommits,
initCommitStatuses,
initCaptcha,
initUserCheckAppUrl,
initUserAuthOauth2,
initUserAuthWebAuthn,
initUserAuthWebAuthnRegister,
initUserSettings,
initRepoDiffView,
initColorPickers,
initOAuth2SettingsDisableCheckbox,
initRepoFileView,
initActionsPermissionsForm,
]);
// it must be the last one, then the "querySelectorAll" only needs to be executed once for global init functions.
initGlobalSelectorObserver(initPerformanceTracer);
if (initPerformanceTracer) initPerformanceTracer.printResults();
const initDur = performance.now() - initStartTime;
if (initDur > 500) {
console.error(`slow init functions took ${initDur.toFixed(3)}ms`);
}
document.dispatchEvent(new CustomEvent('gitea:index-ready'));

View File

@@ -1,29 +1,188 @@
// bootstrap module must be the first one to be imported, it handles webpack lazy-loading and global errors import '../fomantic/build/fomantic.js';
import './bootstrap.ts'; import '../css/index.css';
import type {HtmxResponseInfo} from 'htmx.org';
import {showErrorToast} from './modules/toast.ts';
// many users expect to use jQuery in their custom scripts (https://docs.gitea.com/administration/customizing-gitea#example-plantuml) import {initDashboardRepoList} from './features/dashboard.ts';
// so load globals (including jQuery) as early as possible import {initGlobalCopyToClipboardListener} from './features/clipboard.ts';
import './globals.ts'; import {initRepoGraphGit} from './features/repo-graph.ts';
import {initHeatmap} from './features/heatmap.ts';
import {initImageDiff} from './features/imagediff.ts';
import {initRepoMigration} from './features/repo-migration.ts';
import {initRepoProject} from './features/repo-projects.ts';
import {initTableSort} from './features/tablesort.ts';
import {initAdminUserListSearchForm} from './features/admin/users.ts';
import {initAdminConfigs} from './features/admin/config.ts';
import {initMarkupAnchors} from './markup/anchors.ts';
import {initNotificationCount} from './features/notification.ts';
import {initRepoIssueContentHistory} from './features/repo-issue-content.ts';
import {initStopwatch} from './features/stopwatch.ts';
import {initRepoFileSearch} from './features/repo-findfile.ts';
import {initMarkupContent} from './markup/content.ts';
import {initRepoFileView} from './features/file-view.ts';
import {initUserAuthOauth2, initUserCheckAppUrl} from './features/user-auth.ts';
import {initRepoPullRequestAllowMaintainerEdit, initRepoPullRequestReview, initRepoIssueSidebarDependency, initRepoIssueFilterItemLabel} from './features/repo-issue.ts';
import {initRepoEllipsisButton, initCommitStatuses} from './features/repo-commit.ts';
import {initRepoTopicBar} from './features/repo-home.ts';
import {initAdminCommon} from './features/admin/common.ts';
import {initRepoCodeView} from './features/repo-code.ts';
import {initSshKeyFormParser} from './features/sshkey-helper.ts';
import {initUserSettings} from './features/user-settings.ts';
import {initRepoActivityTopAuthorsChart, initRepoArchiveLinks} from './features/repo-common.ts';
import {initRepoMigrationStatusChecker} from './features/repo-migrate.ts';
import {initRepoDiffView} from './features/repo-diff.ts';
import {initOrgTeam} from './features/org-team.ts';
import {initUserAuthWebAuthn, initUserAuthWebAuthnRegister} from './features/user-auth-webauthn.ts';
import {initRepoReleaseNew} from './features/repo-release.ts';
import {initRepoEditor} from './features/repo-editor.ts';
import {initCompSearchUserBox} from './features/comp/SearchUserBox.ts';
import {initInstall} from './features/install.ts';
import {initCompWebHookEditor} from './features/comp/WebHookEditor.ts';
import {initRepoBranchButton} from './features/repo-branch.ts';
import {initCommonOrganization} from './features/common-organization.ts';
import {initRepoWikiForm} from './features/repo-wiki.ts';
import {initRepository, initBranchSelectorTabs} from './features/repo-legacy.ts';
import {initCopyContent} from './features/copycontent.ts';
import {initCaptcha} from './features/captcha.ts';
import {initRepositoryActionView} from './features/repo-actions.ts';
import {initGlobalTooltips} from './modules/tippy.ts';
import {initGiteaFomantic} from './modules/fomantic.ts';
import {initSubmitEventPolyfill} from './utils/dom.ts';
import {initRepoIssueList} from './features/repo-issue-list.ts';
import {initCommonIssueListQuickGoto} from './features/common-issue-list.ts';
import {initRepoContributors} from './features/contributors.ts';
import {initRepoCodeFrequency} from './features/code-frequency.ts';
import {initRepoRecentCommits} from './features/recent-commits.ts';
import {initRepoDiffCommitBranchesAndTags} from './features/repo-diff-commit.ts';
import {initGlobalSelectorObserver} from './modules/observer.ts';
import {initRepositorySearch} from './features/repo-search.ts';
import {initColorPickers} from './features/colorpicker.ts';
import {initAdminSelfCheck} from './features/admin/selfcheck.ts';
import {initOAuth2SettingsDisableCheckbox} from './features/oauth2-settings.ts';
import {initGlobalFetchAction} from './features/common-fetch-action.ts';
import {initCommmPageComponents, initGlobalComponent, initGlobalDropdown, initGlobalInput} from './features/common-page.ts';
import {initGlobalButtonClickOnEnter, initGlobalButtons, initGlobalDeleteButton} from './features/common-button.ts';
import {initGlobalComboMarkdownEditor, initGlobalEnterQuickSubmit, initGlobalFormDirtyLeaveConfirm} from './features/common-form.ts';
import {callInitFunctions} from './modules/init.ts';
import {initRepoViewFileTree} from './features/repo-view-file-tree.ts';
import {initActionsPermissionsForm} from './features/common-actions-permissions.ts';
import {initGlobalShortcut} from './modules/shortcut.ts';
import './webcomponents/index.ts'; const initStartTime = performance.now();
import './modules/user-settings.ts'; // templates also need to use localUserSettings in inline scripts const initPerformanceTracer = callInitFunctions([
import {onDomReady} from './utils/dom.ts'; initSubmitEventPolyfill,
initGiteaFomantic,
// TODO: There is a bug in htmx, it incorrectly checks "readyState === 'complete'" when the DOM tree is ready and won't trigger DOMContentLoaded initGlobalComponent,
// Then importing the htmx in our onDomReady will make htmx skip its initialization. initGlobalDropdown,
// If the bug would be fixed (https://github.com/bigskysoftware/htmx/pull/3365), then we can only import htmx in "onDomReady" initGlobalFetchAction,
import 'htmx.org'; initGlobalTooltips,
initGlobalButtonClickOnEnter,
initGlobalButtons,
initGlobalCopyToClipboardListener,
initGlobalEnterQuickSubmit,
initGlobalFormDirtyLeaveConfirm,
initGlobalComboMarkdownEditor,
initGlobalDeleteButton,
initGlobalInput,
initGlobalShortcut,
onDomReady(async () => { initCommonOrganization,
// when navigate before the import complete, there will be an error from webpack chunk loader: initCommonIssueListQuickGoto,
// JavaScript promise rejection: Loading chunk index-domready failed.
try { initCompSearchUserBox,
await import(/* webpackChunkName: "index-domready" */'./index-domready.ts'); initCompWebHookEditor,
} catch (e) {
if (e.name === 'ChunkLoadError') { initInstall,
console.error('Error loading index-domready:', e);
} else { initCommmPageComponents,
throw e;
} initHeatmap,
} initImageDiff,
initMarkupAnchors,
initMarkupContent,
initSshKeyFormParser,
initStopwatch,
initTableSort,
initRepoFileSearch,
initCopyContent,
initAdminCommon,
initAdminUserListSearchForm,
initAdminConfigs,
initAdminSelfCheck,
initDashboardRepoList,
initNotificationCount,
initOrgTeam,
initRepoActivityTopAuthorsChart,
initRepoArchiveLinks,
initRepoBranchButton,
initRepoCodeView,
initBranchSelectorTabs,
initRepoEllipsisButton,
initRepoDiffCommitBranchesAndTags,
initRepoEditor,
initRepoGraphGit,
initRepoIssueContentHistory,
initRepoIssueList,
initRepoIssueFilterItemLabel,
initRepoIssueSidebarDependency,
initRepoMigration,
initRepoMigrationStatusChecker,
initRepoProject,
initRepoPullRequestAllowMaintainerEdit,
initRepoPullRequestReview,
initRepoReleaseNew,
initRepoTopicBar,
initRepoViewFileTree,
initRepoWikiForm,
initRepository,
initRepositoryActionView,
initRepositorySearch,
initRepoContributors,
initRepoCodeFrequency,
initRepoRecentCommits,
initCommitStatuses,
initCaptcha,
initUserCheckAppUrl,
initUserAuthOauth2,
initUserAuthWebAuthn,
initUserAuthWebAuthnRegister,
initUserSettings,
initRepoDiffView,
initColorPickers,
initOAuth2SettingsDisableCheckbox,
initRepoFileView,
initActionsPermissionsForm,
]);
// it must be the last one, then the "querySelectorAll" only needs to be executed once for global init functions.
initGlobalSelectorObserver(initPerformanceTracer);
if (initPerformanceTracer) initPerformanceTracer.printResults();
const initDur = performance.now() - initStartTime;
if (initDur > 500) {
console.error(`slow init functions took ${initDur.toFixed(3)}ms`);
}
// https://htmx.org/events/#htmx:sendError
type HtmxEvent = Event & {detail: HtmxResponseInfo};
document.body.addEventListener('htmx:sendError', (event) => {
// TODO: add translations
showErrorToast(`Network error when calling ${(event as HtmxEvent).detail.requestConfig.path}`);
}); });
// https://htmx.org/events/#htmx:responseError
document.body.addEventListener('htmx:responseError', (event) => {
// TODO: add translations
showErrorToast(`Error ${(event as HtmxEvent).detail.xhr.status} when calling ${(event as HtmxEvent).detail.requestConfig.path}`);
});
document.dispatchEvent(new CustomEvent('gitea:index-ready'));

View File

@@ -3,8 +3,8 @@ import {queryElems} from '../utils/dom.ts';
export async function initMarkupRenderAsciicast(elMarkup: HTMLElement): Promise<void> { export async function initMarkupRenderAsciicast(elMarkup: HTMLElement): Promise<void> {
queryElems(elMarkup, '.asciinema-player-container', async (el) => { queryElems(elMarkup, '.asciinema-player-container', async (el) => {
const [player] = await Promise.all([ const [player] = await Promise.all([
import(/* webpackChunkName: "asciinema-player" */'asciinema-player'), import('asciinema-player'),
import(/* webpackChunkName: "asciinema-player" */'asciinema-player/dist/bundle/asciinema-player.css'), import('asciinema-player/dist/bundle/asciinema-player.css'),
]); ]);
player.create(el.getAttribute('data-asciinema-player-src')!, el, { player.create(el.getAttribute('data-asciinema-player-src')!, el, {

View File

@@ -16,8 +16,8 @@ export async function initMarkupCodeMath(elMarkup: HTMLElement): Promise<void> {
// .markup code.language-math' // .markup code.language-math'
queryElems(elMarkup, 'code.language-math', async (el) => { queryElems(elMarkup, 'code.language-math', async (el) => {
const [{default: katex}] = await Promise.all([ const [{default: katex}] = await Promise.all([
import(/* webpackChunkName: "katex" */'katex'), import('katex'),
import(/* webpackChunkName: "katex" */'katex/dist/katex.css'), import('katex/dist/katex.css'),
]); ]);
const MAX_CHARS = 1000; const MAX_CHARS = 1000;

View File

@@ -72,8 +72,8 @@ export function sourceNeedsElk(source: string) {
} }
async function loadMermaid(needElkRender: boolean) { async function loadMermaid(needElkRender: boolean) {
const mermaidPromise = import(/* webpackChunkName: "mermaid" */'mermaid'); const mermaidPromise = import('mermaid');
const elkPromise = needElkRender ? import(/* webpackChunkName: "mermaid-layout-elk" */'@mermaid-js/layout-elk') : null; const elkPromise = needElkRender ? import('@mermaid-js/layout-elk') : null;
const results = await Promise.all([mermaidPromise, elkPromise]); const results = await Promise.all([mermaidPromise, elkPromise]);
return { return {
mermaid: results[0].default, mermaid: results[0].default,

View File

@@ -20,7 +20,7 @@ function showMarkupRefIssuePopup(e: MouseEvent | FocusEvent) {
const el = document.createElement('div'); const el = document.createElement('div');
const onShowAsync = async () => { const onShowAsync = async () => {
const {default: ContextPopup} = await import(/* webpackChunkName: "ContextPopup" */ '../components/ContextPopup.vue'); const {default: ContextPopup} = await import('../components/ContextPopup.vue');
const view = createApp(ContextPopup, { const view = createApp(ContextPopup, {
// backend: GetIssueInfo // backend: GetIssueInfo
loadIssueInfoUrl: `${window.config.appSubUrl}/${issuePathInfo.ownerName}/${issuePathInfo.repoName}/issues/${issuePathInfo.indexString}/info`, loadIssueInfoUrl: `${window.config.appSubUrl}/${issuePathInfo.ownerName}/${issuePathInfo.repoName}/issues/${issuePathInfo.indexString}/info`,

View File

@@ -1,4 +1,4 @@
import {showGlobalErrorMessage, shouldIgnoreError} from './bootstrap.ts'; import {showGlobalErrorMessage, shouldIgnoreError} from './errors.ts';
test('showGlobalErrorMessage', () => { test('showGlobalErrorMessage', () => {
document.body.innerHTML = '<div class="page-content"></div>'; document.body.innerHTML = '<div class="page-content"></div>';
@@ -13,9 +13,9 @@ test('showGlobalErrorMessage', () => {
test('shouldIgnoreError', () => { test('shouldIgnoreError', () => {
for (const url of [ for (const url of [
'https://gitea.test/assets/js/monaco.b359ef7e.js', 'https://gitea.test/assets/js/monaco.D14TzjS9.js',
'https://gitea.test/assets/js/monaco-editor.4a969118.worker.js', 'https://gitea.test/assets/js/editor.api2.BdhK7zNg.js',
'https://gitea.test/assets/js/vendors-node_modules_pnpm_monaco-editor_0_55_1_node_modules_monaco-editor_esm_vs_base_common_-e11c7c.966a028d.js', 'https://gitea.test/assets/js/editor.worker.BYgvyFya.js',
]) { ]) {
const err = new Error('test'); const err = new Error('test');
err.stack = `Error: test\n at ${url}:1:1`; err.stack = `Error: test\n at ${url}:1:1`;

View File

@@ -0,0 +1,67 @@
// keep this file lightweight, it's imported into IIFE chunk in bootstrap
import {html} from '../utils/html.ts';
import type {Intent} from '../types.ts';
export function showGlobalErrorMessage(msg: string, msgType: Intent = 'error') {
const msgContainer = document.querySelector('.page-content') ?? document.body;
if (!msgContainer) {
alert(`${msgType}: ${msg}`);
return;
}
const msgCompact = msg.replace(/\W/g, '').trim(); // compact the message to a data attribute to avoid too many duplicated messages
let msgDiv = msgContainer.querySelector<HTMLDivElement>(`.js-global-error[data-global-error-msg-compact="${msgCompact}"]`);
if (!msgDiv) {
const el = document.createElement('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
const msgCount = Number(msgDiv.getAttribute(`data-global-error-msg-count`)) + 1;
msgDiv.setAttribute(`data-global-error-msg-compact`, msgCompact);
msgDiv.setAttribute(`data-global-error-msg-count`, msgCount.toString());
msgDiv.querySelector('.ui.message')!.textContent = msg + (msgCount > 1 ? ` (${msgCount})` : '');
msgContainer.prepend(msgDiv);
}
export function shouldIgnoreError(err: Error) {
const ignorePatterns: Array<RegExp> = [
// https://github.com/go-gitea/gitea/issues/30861
// https://github.com/microsoft/monaco-editor/issues/4496
// https://github.com/microsoft/monaco-editor/issues/4679
/\/assets\/js\/.*(monaco|editor\.(api|worker))/,
];
for (const pattern of ignorePatterns) {
if (pattern.test(err.stack ?? '')) return true;
}
return false;
}
export function processWindowErrorEvent({error, reason, message, type, filename, lineno, colno}: ErrorEvent & PromiseRejectionEvent) {
const err = error ?? reason;
const assetBaseUrl = String(new URL(`${window.config?.assetUrlPrefix ?? '/assets'}/`, window.location.origin));
const {runModeIsProd} = window.config ?? {};
// `error` and `reason` are not guaranteed to be errors. If the value is falsy, it is likely a
// non-critical event from the browser. We log them but don't show them to users. Examples:
// - https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver#observation_errors
// - https://github.com/mozilla-mobile/firefox-ios/issues/10817
// - https://github.com/go-gitea/gitea/issues/20240
if (!err) {
if (message) console.error(new Error(message));
if (runModeIsProd) return;
}
if (err instanceof Error) {
// If the error stack trace does not include the base URL of our script assets, it likely came
// from a browser extension or inline script. Do not show such errors in production.
if (!err.stack?.includes(assetBaseUrl) && runModeIsProd) return;
// Ignore some known errors that are unable to fix
if (shouldIgnoreError(err)) return;
}
let msg = err?.message ?? message;
if (lineno) msg += ` (${filename} @ ${lineno}:${colno})`;
const dot = msg.endsWith('.') ? '' : '.';
const renderedType = type === 'unhandledrejection' ? 'promise rejection' : type;
showGlobalErrorMessage(`JavaScript ${renderedType}: ${msg}${dot} Open browser console to see more details.`);
}

View File

@@ -1,4 +1,3 @@
import $ from 'jquery';
import {initAriaCheckboxPatch} from './fomantic/checkbox.ts'; import {initAriaCheckboxPatch} from './fomantic/checkbox.ts';
import {initAriaFormFieldPatch} from './fomantic/form.ts'; import {initAriaFormFieldPatch} from './fomantic/form.ts';
import {initAriaDropdownPatch} from './fomantic/dropdown.ts'; import {initAriaDropdownPatch} from './fomantic/dropdown.ts';

View File

@@ -1,4 +1,3 @@
import $ from 'jquery';
import {generateElemId} from '../../utils/dom.ts'; import {generateElemId} from '../../utils/dom.ts';
export function linkLabelAndInput(label: Element, input: Element) { export function linkLabelAndInput(label: Element, input: Element) {

View File

@@ -1,4 +1,3 @@
import $ from 'jquery';
import {queryElemChildren} from '../../utils/dom.ts'; import {queryElemChildren} from '../../utils/dom.ts';
export function initFomanticDimmer() { export function initFomanticDimmer() {

View File

@@ -1,4 +1,3 @@
import $ from 'jquery';
import type {FomanticInitFunction} from '../../types.ts'; import type {FomanticInitFunction} from '../../types.ts';
import {generateElemId, queryElems} from '../../utils/dom.ts'; import {generateElemId, queryElems} from '../../utils/dom.ts';

View File

@@ -1,4 +1,3 @@
import $ from 'jquery';
import type {FomanticInitFunction} from '../../types.ts'; import type {FomanticInitFunction} from '../../types.ts';
import {queryElems} from '../../utils/dom.ts'; import {queryElems} from '../../utils/dom.ts';
import {hideToastsFrom} from '../toast.ts'; import {hideToastsFrom} from '../toast.ts';

View File

@@ -1,4 +1,3 @@
import $ from 'jquery';
import {queryElemSiblings} from '../../utils/dom.ts'; import {queryElemSiblings} from '../../utils/dom.ts';
export function initFomanticTab() { export function initFomanticTab() {

View File

@@ -1,5 +1,3 @@
import $ from 'jquery';
export function initFomanticTransition() { export function initFomanticTransition() {
const transitionNopBehaviors = new Set([ const transitionNopBehaviors = new Set([
'clear queue', 'stop', 'stop all', 'destroy', 'clear queue', 'stop', 'stop all', 'destroy',

View File

@@ -0,0 +1,17 @@
import editorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker';
import jsonWorker from 'monaco-editor/esm/vs/language/json/json.worker?worker';
import cssWorker from 'monaco-editor/esm/vs/language/css/css.worker?worker';
import htmlWorker from 'monaco-editor/esm/vs/language/html/html.worker?worker';
import tsWorker from 'monaco-editor/esm/vs/language/typescript/ts.worker?worker';
window.MonacoEnvironment = {
getWorker(_: string, label: string) {
if (label === 'json') return new jsonWorker();
if (label === 'css' || label === 'scss' || label === 'less') return new cssWorker();
if (label === 'html' || label === 'handlebars' || label === 'razor') return new htmlWorker();
if (label === 'typescript' || label === 'javascript') return new tsWorker();
return new editorWorker();
},
};
export * from 'monaco-editor';

View File

@@ -3,7 +3,7 @@ import type SortableType from 'sortablejs';
export async function createSortable(el: HTMLElement, opts: {handle?: string} & SortableOptions = {}): Promise<SortableType> { export async function createSortable(el: HTMLElement, opts: {handle?: string} & SortableOptions = {}): Promise<SortableType> {
// type reassigned because typescript derives the wrong type from this import // type reassigned because typescript derives the wrong type from this import
const {Sortable} = (await import(/* webpackChunkName: "sortablejs" */'sortablejs') as unknown as {Sortable: typeof SortableType}); const {Sortable} = (await import('sortablejs') as unknown as {Sortable: typeof SortableType});
return new Sortable(el, { return new Sortable(el, {
animation: 150, animation: 150,

View File

@@ -1,11 +1,11 @@
const {appSubUrl, assetVersionEncoded} = window.config; const {appSubUrl, sharedWorkerUri} = window.config;
export class UserEventsSharedWorker { export class UserEventsSharedWorker {
sharedWorker: SharedWorker; sharedWorker: SharedWorker;
// options can be either a string (the debug name of the worker) or an object of type WorkerOptions // options can be either a string (the debug name of the worker) or an object of type WorkerOptions
constructor(options?: string | WorkerOptions) { constructor(options?: string | WorkerOptions) {
const worker = new SharedWorker(`${window.__webpack_public_path__}js/eventsource.sharedworker.js?v=${assetVersionEncoded}`, options); const worker = new SharedWorker(sharedWorkerUri, options);
this.sharedWorker = worker; this.sharedWorker = worker;
worker.addEventListener('error', (event) => { worker.addEventListener('error', (event) => {
console.error('worker error', event); console.error('worker error', event);

View File

@@ -47,7 +47,7 @@ export function newRenderPlugin3DViewer(): FileRenderPlugin {
async render(container: HTMLElement, fileUrl: string): Promise<void> { async render(container: HTMLElement, fileUrl: string): Promise<void> {
// TODO: height and/or max-height? // TODO: height and/or max-height?
const OV = await import(/* webpackChunkName: "online-3d-viewer" */'online-3d-viewer'); const OV = await import('online-3d-viewer');
const viewer = new OV.EmbeddedViewer(container, { const viewer = new OV.EmbeddedViewer(container, {
backgroundColor: new OV.RGBAColor(59, 68, 76, 0), backgroundColor: new OV.RGBAColor(59, 68, 76, 0),
defaultColor: new OV.RGBColor(65, 131, 196), defaultColor: new OV.RGBColor(65, 131, 196),

View File

@@ -9,7 +9,7 @@ export function newRenderPluginPdfViewer(): FileRenderPlugin {
}, },
async render(container: HTMLElement, fileUrl: string): Promise<void> { async render(container: HTMLElement, fileUrl: string): Promise<void> {
const PDFObject = await import(/* webpackChunkName: "pdfobject" */'pdfobject'); const PDFObject = await import('pdfobject');
// TODO: the PDFObject library does not support dynamic height adjustment, // TODO: the PDFObject library does not support dynamic height adjustment,
container.style.height = `${window.innerHeight - 100}px`; container.style.height = `${window.innerHeight - 100}px`;
if (!PDFObject.default.embed(fileUrl, container)) { if (!PDFObject.default.embed(fileUrl, container)) {

View File

@@ -1,3 +1,4 @@
import '../../css/standalone/devtest.css';
import {showInfoToast, showWarningToast, showErrorToast, type Toast} from '../modules/toast.ts'; import {showInfoToast, showWarningToast, showErrorToast, type Toast} from '../modules/toast.ts';
type LevelMap = Record<string, (message: string) => Toast | null>; type LevelMap = Record<string, (message: string) => Toast | null>;

View File

@@ -11,6 +11,8 @@ RENDER_COMMAND = `echo '<div style="width: 100%; height: 2000px; border: 10px so
*/ */
import '../../css/standalone/external-render-iframe.css';
function mainExternalRenderIframe() { function mainExternalRenderIframe() {
const u = new URL(window.location.href); const u = new URL(window.location.href);
const iframeId = u.searchParams.get('gitea-iframe-id'); const iframeId = u.searchParams.get('gitea-iframe-id');

View File

@@ -1,7 +1,7 @@
import '../../css/standalone/swagger.css';
import SwaggerUI from 'swagger-ui-dist/swagger-ui-es-bundle.js'; import SwaggerUI from 'swagger-ui-dist/swagger-ui-es-bundle.js';
import 'swagger-ui-dist/swagger-ui.css'; import 'swagger-ui-dist/swagger-ui.css';
import {load as loadYaml} from 'js-yaml'; import {load as loadYaml} from 'js-yaml';
import {GET} from '../modules/fetch.ts';
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)'); const prefersDark = window.matchMedia('(prefers-color-scheme: dark)');
const apply = () => document.documentElement.classList.toggle('dark-mode', prefersDark.matches); const apply = () => document.documentElement.classList.toggle('dark-mode', prefersDark.matches);
@@ -13,7 +13,7 @@ window.addEventListener('load', async () => {
const url = elSwaggerUi.getAttribute('data-source')!; const url = elSwaggerUi.getAttribute('data-source')!;
let spec: any; let spec: any;
if (url) { if (url) {
const res = await GET(url); const res = await fetch(url); // eslint-disable-line no-restricted-globals
spec = await res.json(); spec = await res.json();
} else { } else {
const elSpecContent = elSwaggerUi.querySelector<HTMLTextAreaElement>('.swagger-spec-content')!; const elSpecContent = elSwaggerUi.querySelector<HTMLTextAreaElement>('.swagger-spec-content')!;

View File

@@ -2,7 +2,7 @@
// even if backend is in testing mode, frontend could be complied in production mode // even if backend is in testing mode, frontend could be complied in production mode
// so this function only checks if the frontend is in unit testing mode (usually from *.test.ts files) // so this function only checks if the frontend is in unit testing mode (usually from *.test.ts files)
export function isInFrontendUnitTest() { export function isInFrontendUnitTest() {
return import.meta.env.TEST === 'true'; return import.meta.env.MODE === 'test';
} }
/** strip common indentation from a string and trim it */ /** strip common indentation from a string and trim it */

View File

@@ -1,10 +1,20 @@
window.__webpack_public_path__ = ''; // Stub APIs not implemented by happy-dom but needed by dependencies
// XPathEvaluator is used by htmx at module evaluation time
// TODO: Remove after https://github.com/capricorn86/happy-dom/pull/2103 is released
if (!globalThis.XPathEvaluator) {
globalThis.XPathEvaluator = class {
createExpression() { return {evaluate: () => ({iterateNext: () => null})} }
} as any;
}
// Dynamic import so polyfills above are applied before htmx evaluates
await import('./globals.ts');
window.config = { window.config = {
appUrl: 'http://localhost:3000/', appUrl: 'http://localhost:3000/',
appSubUrl: '', appSubUrl: '',
assetVersionEncoded: '',
assetUrlPrefix: '', assetUrlPrefix: '',
sharedWorkerUri: '',
runModeIsProd: true, runModeIsProd: true,
customEmojis: {}, customEmojis: {},
pageData: {}, pageData: {},
@@ -13,3 +23,5 @@ window.config = {
mermaidMaxSourceCharacters: 5000, mermaidMaxSourceCharacters: 5000,
i18n: {}, i18n: {},
}; };
export {}; // mark as module for top-level await

View File

@@ -8,4 +8,4 @@ https://developer.mozilla.org/en-US/docs/Web/Web_Components
* These components are loaded in `<head>` (before DOM body) in a separate entry point, they need to be lightweight to not affect the page loading time too much. * These components are loaded in `<head>` (before DOM body) in a separate entry point, they need to be lightweight to not affect the page loading time too much.
* Do not import `svg.js` into a web component because that file is currently not tree-shakeable, import svg files individually insteat. * Do not import `svg.js` into a web component because that file is currently not tree-shakeable, import svg files individually insteat.
* All our components must be added to `webpack.config.js` so they work correctly in Vue. * All our components must be added to `vite.config.ts` so they work correctly in Vue.

View File

@@ -1,11 +1,10 @@
import {throttle} from 'throttle-debounce'; import {throttle} from 'throttle-debounce';
import {createTippy} from '../modules/tippy.ts'; import {addDelegatedEventListener, generateElemId, isDocumentFragmentOrElementNode} from '../utils/dom.ts';
import {addDelegatedEventListener, isDocumentFragmentOrElementNode} from '../utils/dom.ts';
import octiconKebabHorizontal from '../../../public/assets/img/svg/octicon-kebab-horizontal.svg'; import octiconKebabHorizontal from '../../../public/assets/img/svg/octicon-kebab-horizontal.svg';
window.customElements.define('overflow-menu', class extends HTMLElement { window.customElements.define('overflow-menu', class extends HTMLElement {
tippyContent: HTMLDivElement; popup: HTMLDivElement;
tippyItems: Array<HTMLElement>; overflowItems: Array<HTMLElement>;
button: HTMLButtonElement | null; button: HTMLButtonElement | null;
menuItemsEl: HTMLElement; menuItemsEl: HTMLElement;
resizeObserver: ResizeObserver; resizeObserver: ResizeObserver;
@@ -13,18 +12,42 @@ window.customElements.define('overflow-menu', class extends HTMLElement {
lastWidth: number; lastWidth: number;
updateButtonActivationState() { updateButtonActivationState() {
if (!this.button || !this.tippyContent) return; if (!this.button || !this.popup) return;
this.button.classList.toggle('active', Boolean(this.tippyContent.querySelector('.item.active'))); this.button.classList.toggle('active', Boolean(this.popup.querySelector('.item.active')));
} }
showPopup() {
if (!this.popup || this.popup.style.display !== 'none') return;
this.popup.style.display = '';
this.button!.setAttribute('aria-expanded', 'true');
setTimeout(() => this.popup.focus(), 0);
document.addEventListener('click', this.onClickOutside, true);
}
hidePopup() {
if (!this.popup || this.popup.style.display === 'none') return;
this.popup.style.display = 'none';
this.button?.setAttribute('aria-expanded', 'false');
document.removeEventListener('click', this.onClickOutside, true);
}
onClickOutside = (e: Event) => {
if (!this.popup?.contains(e.target as Node) && !this.button?.contains(e.target as Node)) {
this.hidePopup();
}
};
updateItems = throttle(100, () => { updateItems = throttle(100, () => {
if (!this.tippyContent) { if (!this.popup) {
const div = document.createElement('div'); const div = document.createElement('div');
div.classList.add('overflow-menu-popup');
div.setAttribute('role', 'menu');
div.tabIndex = -1; // for initial focus, programmatic focus only div.tabIndex = -1; // for initial focus, programmatic focus only
div.style.display = 'none';
div.addEventListener('keydown', (e) => { div.addEventListener('keydown', (e) => {
if (e.isComposing) return; if (e.isComposing) return;
if (e.key === 'Tab') { if (e.key === 'Tab') {
const items = this.tippyContent.querySelectorAll<HTMLElement>('[role="menuitem"]'); const items = this.popup.querySelectorAll<HTMLElement>('[role="menuitem"]');
if (e.shiftKey) { if (e.shiftKey) {
if (document.activeElement === items[0]) { if (document.activeElement === items[0]) {
e.preventDefault(); e.preventDefault();
@@ -39,7 +62,7 @@ window.customElements.define('overflow-menu', class extends HTMLElement {
} else if (e.key === 'Escape') { } else if (e.key === 'Escape') {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
this.button?._tippy.hide(); this.hidePopup();
this.button?.focus(); this.button?.focus();
} else if (e.key === ' ' || e.code === 'Enter') { } else if (e.key === ' ' || e.code === 'Enter') {
if (document.activeElement?.matches('[role="menuitem"]')) { if (document.activeElement?.matches('[role="menuitem"]')) {
@@ -48,20 +71,20 @@ window.customElements.define('overflow-menu', class extends HTMLElement {
(document.activeElement as HTMLElement).click(); (document.activeElement as HTMLElement).click();
} }
} else if (e.key === 'ArrowDown') { } else if (e.key === 'ArrowDown') {
if (document.activeElement?.matches('.tippy-target')) { if (document.activeElement === this.popup) {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
document.activeElement.querySelector<HTMLElement>('[role="menuitem"]:first-of-type')?.focus(); this.popup.querySelector<HTMLElement>('[role="menuitem"]:first-of-type')?.focus();
} else if (document.activeElement?.matches('[role="menuitem"]')) { } else if (document.activeElement?.matches('[role="menuitem"]')) {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
(document.activeElement.nextElementSibling as HTMLElement)?.focus(); (document.activeElement.nextElementSibling as HTMLElement)?.focus();
} }
} else if (e.key === 'ArrowUp') { } else if (e.key === 'ArrowUp') {
if (document.activeElement?.matches('.tippy-target')) { if (document.activeElement === this.popup) {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
document.activeElement.querySelector<HTMLElement>('[role="menuitem"]:last-of-type')?.focus(); this.popup.querySelector<HTMLElement>('[role="menuitem"]:last-of-type')?.focus();
} else if (document.activeElement?.matches('[role="menuitem"]')) { } else if (document.activeElement?.matches('[role="menuitem"]')) {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
@@ -69,16 +92,15 @@ window.customElements.define('overflow-menu', class extends HTMLElement {
} }
} }
}); });
div.classList.add('tippy-target'); this.handleItemClick(div, '.overflow-menu-popup > .item');
this.handleItemClick(div, '.tippy-target > .item'); this.popup = div;
this.tippyContent = div; } // end if: no popup and create a new one
} // end if: no tippyContent and create a new one
const itemFlexSpace = this.menuItemsEl.querySelector<HTMLSpanElement>('.item-flex-space'); const itemFlexSpace = this.menuItemsEl.querySelector<HTMLSpanElement>('.item-flex-space');
const itemOverFlowMenuButton = this.querySelector<HTMLButtonElement>('.overflow-menu-button'); const itemOverFlowMenuButton = this.querySelector<HTMLButtonElement>('.overflow-menu-button');
// move items in tippy back into the menu items for subsequent measurement // move items in popup back into the menu items for subsequent measurement
for (const item of this.tippyItems || []) { for (const item of this.overflowItems || []) {
if (!itemFlexSpace || item.getAttribute('data-after-flex-space')) { if (!itemFlexSpace || item.getAttribute('data-after-flex-space')) {
this.menuItemsEl.append(item); this.menuItemsEl.append(item);
} else { } else {
@@ -90,7 +112,7 @@ window.customElements.define('overflow-menu', class extends HTMLElement {
// flex space and overflow menu are excluded from measurement // flex space and overflow menu are excluded from measurement
itemFlexSpace?.style.setProperty('display', 'none', 'important'); itemFlexSpace?.style.setProperty('display', 'none', 'important');
itemOverFlowMenuButton?.style.setProperty('display', 'none', 'important'); itemOverFlowMenuButton?.style.setProperty('display', 'none', 'important');
this.tippyItems = []; this.overflowItems = [];
const menuRight = this.offsetLeft + this.offsetWidth; const menuRight = this.offsetLeft + this.offsetWidth;
const menuItems = this.menuItemsEl.querySelectorAll<HTMLElement>('.item, .item-flex-space'); const menuItems = this.menuItemsEl.querySelectorAll<HTMLElement>('.item, .item-flex-space');
let afterFlexSpace = false; let afterFlexSpace = false;
@@ -102,64 +124,64 @@ window.customElements.define('overflow-menu', class extends HTMLElement {
if (afterFlexSpace) item.setAttribute('data-after-flex-space', 'true'); if (afterFlexSpace) item.setAttribute('data-after-flex-space', 'true');
const itemRight = item.offsetLeft + item.offsetWidth; const itemRight = item.offsetLeft + item.offsetWidth;
if (menuRight - itemRight < 38) { // roughly the width of .overflow-menu-button with some extra space if (menuRight - itemRight < 38) { // roughly the width of .overflow-menu-button with some extra space
const onlyLastItem = idx === menuItems.length - 1 && this.tippyItems.length === 0; const onlyLastItem = idx === menuItems.length - 1 && this.overflowItems.length === 0;
const lastItemFit = onlyLastItem && menuRight - itemRight > 0; const lastItemFit = onlyLastItem && menuRight - itemRight > 0;
const moveToPopup = !onlyLastItem || !lastItemFit; const moveToPopup = !onlyLastItem || !lastItemFit;
if (moveToPopup) this.tippyItems.push(item); if (moveToPopup) this.overflowItems.push(item);
} }
} }
itemFlexSpace?.style.removeProperty('display'); itemFlexSpace?.style.removeProperty('display');
itemOverFlowMenuButton?.style.removeProperty('display'); itemOverFlowMenuButton?.style.removeProperty('display');
// if there are no overflown items, remove any previously created button // if there are no overflown items, remove any previously created button
if (!this.tippyItems?.length) { if (!this.overflowItems?.length) {
const btn = this.querySelector('.overflow-menu-button'); this.hidePopup();
btn?._tippy?.destroy(); this.button?.remove();
btn?.remove(); this.popup?.remove();
this.button = null; this.button = null;
return; return;
} }
// remove aria role from items that moved from tippy to menu // remove aria role from items that moved from popup to menu
for (const item of menuItems) { for (const item of menuItems) {
if (!this.tippyItems.includes(item)) { if (!this.overflowItems.includes(item)) {
item.removeAttribute('role'); item.removeAttribute('role');
} }
} }
// move all items that overflow into tippy // move all items that overflow into popup
for (const item of this.tippyItems) { for (const item of this.overflowItems) {
item.setAttribute('role', 'menuitem'); item.setAttribute('role', 'menuitem');
this.tippyContent.append(item); this.popup.append(item);
} }
// update existing tippy // update existing popup
if (this.button?._tippy) { if (this.button) {
this.button._tippy.setContent(this.tippyContent);
this.updateButtonActivationState(); this.updateButtonActivationState();
return; return;
} }
// create button initially // create button and attach popup
const popupId = generateElemId('overflow-popup-');
this.popup.id = popupId;
this.button = document.createElement('button'); this.button = document.createElement('button');
this.button.classList.add('overflow-menu-button'); this.button.classList.add('overflow-menu-button');
this.button.setAttribute('aria-label', window.config.i18n.more_items); this.button.setAttribute('aria-label', window.config.i18n.more_items);
this.button.setAttribute('aria-haspopup', 'true');
this.button.setAttribute('aria-expanded', 'false');
this.button.setAttribute('aria-controls', popupId);
this.button.innerHTML = octiconKebabHorizontal; this.button.innerHTML = octiconKebabHorizontal;
this.append(this.button); this.button.addEventListener('click', (e) => {
createTippy(this.button, { e.stopPropagation();
trigger: 'click', if (this.popup.style.display === 'none') {
hideOnClick: true, this.showPopup();
interactive: true, } else {
placement: 'bottom-end', this.hidePopup();
role: 'menu', }
theme: 'menu',
content: this.tippyContent,
onShow: () => { // FIXME: onShown doesn't work (never be called)
setTimeout(() => {
this.tippyContent.focus();
}, 0);
},
}); });
this.append(this.button);
this.append(this.popup);
this.updateButtonActivationState(); this.updateButtonActivationState();
}); });
@@ -202,7 +224,7 @@ window.customElements.define('overflow-menu', class extends HTMLElement {
handleItemClick(el: Element, selector: string) { handleItemClick(el: Element, selector: string) {
addDelegatedEventListener(el, 'click', selector, () => { addDelegatedEventListener(el, 'click', selector, () => {
this.button?._tippy?.hide(); this.hidePopup();
this.updateButtonActivationState(); this.updateButtonActivationState();
}); });
} }
@@ -239,5 +261,6 @@ window.customElements.define('overflow-menu', class extends HTMLElement {
disconnectedCallback() { disconnectedCallback() {
this.mutationObserver?.disconnect(); this.mutationObserver?.disconnect();
this.resizeObserver?.disconnect(); this.resizeObserver?.disconnect();
document.removeEventListener('click', this.onClickOutside, true);
} }
}); });

View File

@@ -1,268 +0,0 @@
import wrapAnsi from 'wrap-ansi';
import AddAssetPlugin from 'add-asset-webpack-plugin';
import LicenseCheckerWebpackPlugin from '@techknowlogick/license-checker-webpack-plugin';
import MiniCssExtractPlugin from 'mini-css-extract-plugin';
import MonacoWebpackPlugin from 'monaco-editor-webpack-plugin';
import {VueLoaderPlugin} from 'vue-loader';
import {EsbuildPlugin} from 'esbuild-loader';
import {parse} from 'node:path';
import webpack, {type Configuration, type EntryObject} from 'webpack';
import {fileURLToPath} from 'node:url';
import {readFileSync, globSync} from 'node:fs';
import {env} from 'node:process';
import tailwindcss from 'tailwindcss';
import tailwindConfig from './tailwind.config.ts';
const {SourceMapDevToolPlugin, DefinePlugin, EnvironmentPlugin} = webpack;
const formatLicenseText = (licenseText: string) => wrapAnsi(licenseText || '', 80).trim();
const themes: EntryObject = {};
for (const path of globSync('web_src/css/themes/*.css', {cwd: import.meta.dirname})) {
themes[parse(path).name] = [`./${path}`];
}
const isProduction = env.NODE_ENV !== 'development';
// ENABLE_SOURCEMAP accepts the following values:
// true - all enabled, the default in development
// reduced - minimal sourcemaps, the default in production
// false - all disabled
let sourceMaps;
if ('ENABLE_SOURCEMAP' in env) {
sourceMaps = ['true', 'false'].includes(env.ENABLE_SOURCEMAP || '') ? env.ENABLE_SOURCEMAP : 'reduced';
} else {
sourceMaps = isProduction ? 'reduced' : 'true';
}
// define which web components we use for Vue to not interpret them as Vue components
const webComponents = new Set([
// our own, in web_src/js/webcomponents
'overflow-menu',
'origin-url',
// from dependencies
'markdown-toolbar',
'relative-time',
'text-expander',
]);
const filterCssImport = (url: string, ...args: Array<any>) => {
const cssFile = args[1] || args[0]; // resourcePath is 2nd argument for url and 3rd for import
const importedFile = url.replace(/[?#].+/, '').toLowerCase();
if (cssFile.includes('fomantic')) {
if (importedFile.includes('brand-icons')) return false;
if (/(eot|ttf|otf|woff|svg)$/i.test(importedFile)) return false;
}
if (cssFile.includes('katex') && /(ttf|woff)$/i.test(importedFile)) {
return false;
}
return true;
};
export default {
mode: isProduction ? 'production' : 'development',
entry: {
index: [
fileURLToPath(new URL('web_src/js/index.ts', import.meta.url)),
fileURLToPath(new URL('web_src/css/index.css', import.meta.url)),
],
swagger: [
fileURLToPath(new URL('web_src/js/standalone/swagger.ts', import.meta.url)),
fileURLToPath(new URL('web_src/css/standalone/swagger.css', import.meta.url)),
],
'external-render-iframe': [
fileURLToPath(new URL('web_src/js/standalone/external-render-iframe.ts', import.meta.url)),
fileURLToPath(new URL('web_src/css/standalone/external-render-iframe.css', import.meta.url)),
],
'eventsource.sharedworker': [
fileURLToPath(new URL('web_src/js/features/eventsource.sharedworker.ts', import.meta.url)),
],
...(!isProduction && {
devtest: [
fileURLToPath(new URL('web_src/js/standalone/devtest.ts', import.meta.url)),
fileURLToPath(new URL('web_src/css/standalone/devtest.css', import.meta.url)),
],
}),
...themes,
},
devtool: false,
output: {
path: fileURLToPath(new URL('public/assets', import.meta.url)),
filename: 'js/[name].js',
chunkFilename: 'js/[name].[contenthash:8].js',
},
optimization: {
minimize: isProduction,
minimizer: [
new EsbuildPlugin({
target: 'es2020',
minify: true,
css: true,
legalComments: 'none',
}),
],
moduleIds: 'named',
chunkIds: 'named',
},
module: {
rules: [
{
test: /\.vue$/i,
exclude: /node_modules/,
loader: 'vue-loader',
options: {
compilerOptions: {
isCustomElement: (tag: string) => webComponents.has(tag),
},
},
},
{
test: /\.js$/i,
exclude: /node_modules/,
use: [
{
loader: 'esbuild-loader',
options: {
loader: 'js',
target: 'es2020',
},
},
],
},
{
test: /\.ts$/i,
exclude: /node_modules/,
use: [
{
loader: 'esbuild-loader',
options: {
loader: 'ts',
target: 'es2020',
},
},
],
},
{
test: /\.css$/i,
use: [
{
loader: MiniCssExtractPlugin.loader,
},
{
loader: 'css-loader',
options: {
sourceMap: sourceMaps === 'true',
url: {filter: filterCssImport},
import: {filter: filterCssImport},
importLoaders: 1,
},
},
{
loader: 'postcss-loader',
options: {
postcssOptions: {
plugins: [
tailwindcss(tailwindConfig),
],
},
},
},
],
},
{
test: /\.svg$/i,
include: fileURLToPath(new URL('public/assets/img/svg', import.meta.url)),
type: 'asset/source',
},
{
test: /\.(ttf|woff2?)$/i,
type: 'asset/resource',
generator: {
filename: 'fonts/[name].[contenthash:8][ext]',
},
},
],
},
plugins: [
new DefinePlugin({
__VUE_OPTIONS_API__: true, // at the moment, many Vue components still use the Vue Options API
__VUE_PROD_DEVTOOLS__: false, // do not enable devtools support in production
__VUE_PROD_HYDRATION_MISMATCH_DETAILS__: false, // https://github.com/vuejs/vue-cli/pull/7443
}),
// all environment variables used in bundled js via process.env must be declared here
new EnvironmentPlugin({
TEST: 'false',
}),
new VueLoaderPlugin(),
new MiniCssExtractPlugin({
filename: 'css/[name].css',
chunkFilename: 'css/[name].[contenthash:8].css',
}),
sourceMaps !== 'false' && new SourceMapDevToolPlugin({
filename: '[file].[contenthash:8].map',
...(sourceMaps === 'reduced' && {include: /^js\/index\.js$/}),
}),
new MonacoWebpackPlugin({
filename: 'js/monaco-[name].[contenthash:8].worker.js',
}),
isProduction ? new LicenseCheckerWebpackPlugin({
outputFilename: 'licenses.txt',
outputWriter: ({dependencies}: {dependencies: Array<Record<string, string>>}) => {
const line = '-'.repeat(80);
const goJson = readFileSync('assets/go-licenses.json', 'utf8');
const goModules = JSON.parse(goJson).map(({name, licenseText}: Record<string, string>) => {
return {name, body: formatLicenseText(licenseText)};
});
const jsModules = dependencies.map(({name, version, licenseName, licenseText}) => {
return {name, version, licenseName, body: formatLicenseText(licenseText)};
});
const modules = [...goModules, ...jsModules].sort((a, b) => a.name.localeCompare(b.name));
return modules.map(({name, version, licenseName, body}) => {
const title = licenseName ? `${name}@${version} - ${licenseName}` : name;
return `${line}\n${title}\n${line}\n${body}`;
}).join('\n');
},
override: {
'khroma@*': {licenseName: 'MIT'}, // https://github.com/fabiospampinato/khroma/pull/33
},
emitError: true,
allow: '(Apache-2.0 OR 0BSD OR BSD-2-Clause OR BSD-3-Clause OR MIT OR ISC OR CPAL-1.0 OR Unlicense OR EPL-1.0 OR EPL-2.0)',
}) : new AddAssetPlugin('licenses.txt', `Licenses are disabled during development`),
],
performance: {
hints: false,
maxEntrypointSize: Infinity,
maxAssetSize: Infinity,
},
resolve: {
symlinks: true,
modules: ['node_modules'],
},
watchOptions: {
ignored: [
'node_modules/**',
],
},
stats: {
assetsSort: 'name',
assetsSpace: Infinity,
cached: false,
cachedModules: false,
children: false,
chunkModules: false,
chunkOrigins: false,
chunksSort: 'name',
colors: true,
entrypoints: false,
groupAssetsByChunk: false,
groupAssetsByEmitStatus: false,
groupAssetsByInfo: false,
groupModulesByAttributes: false,
modules: false,
reasons: false,
runtimeModules: false,
},
} satisfies Configuration;