diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json
index 4f82a5d8c6a..f8e5972af13 100644
--- a/.devcontainer/devcontainer.json
+++ b/.devcontainer/devcontainer.json
@@ -20,7 +20,6 @@
"customizations": {
"vscode": {
"settings": {},
- // same extensions as Gitpod, should match /.gitpod.yml
"extensions": [
"editorconfig.editorconfig",
"dbaeumer.vscode-eslint",
diff --git a/.github/labeler.yml b/.github/labeler.yml
index 68a0f30fd64..0f3c5080411 100644
--- a/.github/labeler.yml
+++ b/.github/labeler.yml
@@ -43,7 +43,6 @@ modifies/internal:
- ".editorconfig"
- ".eslintrc.cjs"
- ".golangci.yml"
- - ".gitpod.yml"
- ".markdownlint.yaml"
- ".spectral.yaml"
- "stylelint.config.*"
diff --git a/.gitignore b/.gitignore
index 45e8e9295fd..019ee94c7a3 100644
--- a/.gitignore
+++ b/.gitignore
@@ -79,6 +79,7 @@ cpu.out
/yarn-error.log
/npm-debug.log*
/.pnpm-store
+/public/assets/.vite
/public/assets/js
/public/assets/css
/public/assets/fonts
@@ -87,8 +88,6 @@ cpu.out
/VERSION
/.air
-# Files and folders that were previously generated
-/public/assets/img/webpack
# Snapcraft
/gitea_a*.txt
diff --git a/.gitpod.yml b/.gitpod.yml
deleted file mode 100644
index 8671edc47cc..00000000000
--- a/.gitpod.yml
+++ /dev/null
@@ -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
diff --git a/Dockerfile b/Dockerfile
index 9922cee9c41..323f06125fe 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,5 +1,5 @@
# 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
RUN apk --no-cache add build-base git nodejs pnpm
WORKDIR /src
diff --git a/Dockerfile.rootless b/Dockerfile.rootless
index a1742e3d51f..83c69cbd513 100644
--- a/Dockerfile.rootless
+++ b/Dockerfile.rootless
@@ -1,5 +1,5 @@
# 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
RUN apk --no-cache add build-base git nodejs pnpm
WORKDIR /src
diff --git a/Makefile b/Makefile
index 5ca1c0eda6d..824bfac10c6 100644
--- a/Makefile
+++ b/Makefile
@@ -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/))
MIGRATE_TEST_PACKAGES ?= $(shell $(GO) list code.gitea.io/gitea/models/migrations/...)
-WEBPACK_SOURCES := $(shell find web_src/js web_src/css -type f)
-WEBPACK_CONFIGS := webpack.config.ts tailwind.config.ts
-WEBPACK_DEST := public/assets/js/index.js public/assets/css/index.css
-WEBPACK_DEST_ENTRIES := public/assets/js public/assets/css public/assets/fonts
+FRONTEND_SOURCES := $(shell find web_src/js web_src/css -type f)
+FRONTEND_CONFIGS := vite.config.ts tailwind.config.ts
+FRONTEND_DEST := public/assets/.vite/manifest.json
+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.*
@@ -199,7 +199,7 @@ git-check:
.PHONY: clean-all
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
clean: ## delete backend and integration files
@@ -380,9 +380,8 @@ watch: ## watch everything and continuously rebuild
@bash tools/watch.sh
.PHONY: watch-frontend
-watch-frontend: node_modules ## watch frontend files and continuously rebuild
- @rm -rf $(WEBPACK_DEST_ENTRIES)
- NODE_ENV=development $(NODE_VARS) pnpm exec webpack --watch --progress
+watch-frontend: node_modules ## start vite dev server for frontend
+ NODE_ENV=development $(NODE_VARS) pnpm exec vite
.PHONY: watch-backend
watch-backend: ## watch backend files and continuously rebuild
@@ -645,7 +644,7 @@ install: $(wildcard *.go)
build: frontend backend ## build everything
.PHONY: frontend
-frontend: $(WEBPACK_DEST) ## build frontend files
+frontend: $(FRONTEND_DEST) ## build frontend files
.PHONY: backend
backend: generate-backend $(EXECUTABLE) ## build backend files
@@ -672,7 +671,7 @@ ifneq ($(and $(STATIC),$(findstring pam,$(TAGS))),)
endif
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 $@
.PHONY: release
@@ -776,15 +775,15 @@ update-py: node_modules ## update py dependencies
uv sync
@touch .venv
-.PHONY: webpack
-webpack: $(WEBPACK_DEST) ## build webpack files
+.PHONY: vite
+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
- @rm -rf $(WEBPACK_DEST_ENTRIES)
- @echo "Running webpack..."
- @BROWSERSLIST_IGNORE_OLD_DATA=true $(NODE_VARS) pnpm exec webpack
- @touch $(WEBPACK_DEST)
+ @rm -rf $(FRONTEND_DEST_ENTRIES)
+ @echo "Running vite build..."
+ @$(NODE_VARS) pnpm exec vite build
+ @touch $(FRONTEND_DEST)
.PHONY: svg
svg: node_modules ## build svg files
diff --git a/README.md b/README.md
index ed000971a75..7ebeac97be0 100644
--- a/README.md
+++ b/README.md
@@ -8,7 +8,6 @@
[](https://www.codetriage.com/go-gitea/gitea "Help Contribute to Open Source")
[](https://opencollective.com/gitea "Become a backer/sponsor of gitea")
[](https://opensource.org/licenses/MIT "License: MIT")
-[](https://gitpod.io/#https://github.com/go-gitea/gitea)
[](https://translate.gitea.com "Crowdin")
[繁體中文](./README.zh-tw.md) | [简体中文](./README.zh-cn.md)
diff --git a/README.zh-cn.md b/README.zh-cn.md
index 8d9531e8e4e..8ccacc0fea4 100644
--- a/README.zh-cn.md
+++ b/README.zh-cn.md
@@ -8,7 +8,6 @@
[](https://www.codetriage.com/go-gitea/gitea "Help Contribute to Open Source")
[](https://opencollective.com/gitea "Become a backer/sponsor of gitea")
[](https://opensource.org/licenses/MIT "License: MIT")
-[](https://gitpod.io/#https://github.com/go-gitea/gitea)
[](https://translate.gitea.com "Crowdin")
[English](./README.md) | [繁體中文](./README.zh-tw.md)
diff --git a/README.zh-tw.md b/README.zh-tw.md
index 875d31e28a8..4160fd0bd91 100644
--- a/README.zh-tw.md
+++ b/README.zh-tw.md
@@ -8,7 +8,6 @@
[](https://www.codetriage.com/go-gitea/gitea "Help Contribute to Open Source")
[](https://opencollective.com/gitea "Become a backer/sponsor of gitea")
[](https://opensource.org/licenses/MIT "License: MIT")
-[](https://gitpod.io/#https://github.com/go-gitea/gitea)
[](https://translate.gitea.com "Crowdin")
[English](./README.md) | [简体中文](./README.zh-cn.md)
diff --git a/eslint.config.ts b/eslint.config.ts
index 9f98adf8596..b52c8aad39a 100644
--- a/eslint.config.ts
+++ b/eslint.config.ts
@@ -572,7 +572,11 @@ export default defineConfig([
'no-restricted-exports': [0],
'no-restricted-globals': [2, ...restrictedGlobals],
'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-return-assign': [0],
'no-script-url': [2],
@@ -1014,6 +1018,6 @@ export default defineConfig([
},
{
files: ['web_src/**/*'],
- languageOptions: {globals: {...globals.browser, ...globals.webpack}},
+ languageOptions: {globals: {...globals.browser, ...globals.jquery, htmx: false}},
},
]);
diff --git a/modules/graceful/server_http.go b/modules/graceful/server_http.go
index 7c855ac64ec..77a2c3b6f83 100644
--- a/modules/graceful/server_http.go
+++ b/modules/graceful/server_http.go
@@ -12,7 +12,12 @@ import (
func newHTTPServer(network, address, name string, handler http.Handler) (*Server, ServeFunction) {
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{
+ Protocols: &protocols,
Handler: handler,
BaseContext: func(net.Listener) context.Context { return GetManager().HammerContext() },
}
diff --git a/modules/markup/external/openapi.go b/modules/markup/external/openapi.go
index ac5eae53ffe..de06e7dac70 100644
--- a/modules/markup/external/openapi.go
+++ b/modules/markup/external/openapi.go
@@ -9,6 +9,7 @@ import (
"io"
"code.gitea.io/gitea/modules/markup"
+ "code.gitea.io/gitea/modules/public"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/util"
)
@@ -61,19 +62,17 @@ func (p *openAPIRenderer) Render(ctx *markup.RenderContext, input io.Reader, out
-
+
-
+
`,
- setting.StaticURLPrefix,
- setting.AssetVersion,
+ public.AssetURI("css/swagger.css"),
html.EscapeString(ctx.RenderOptions.RelativePath),
html.EscapeString(util.UnsafeBytesToString(content)),
- setting.StaticURLPrefix,
- setting.AssetVersion,
+ public.AssetURI("js/swagger.js"),
))
return err
}
diff --git a/modules/markup/render.go b/modules/markup/render.go
index 5785dc5ad54..c0d44c72fcc 100644
--- a/modules/markup/render.go
+++ b/modules/markup/render.go
@@ -16,6 +16,7 @@ import (
"code.gitea.io/gitea/modules/htmlutil"
"code.gitea.io/gitea/modules/markup/internal"
+ "code.gitea.io/gitea/modules/public"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/typesniffer"
"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)
}
// 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"
- extraScriptSrc := setting.AppSubURL + "/assets/js/external-render-iframe.js"
+ extraStyleHref := public.AssetURI("css/external-render-iframe.css")
+ extraScriptSrc := public.AssetURI("js/external-render-iframe.js")
// "`, extraScriptSrc, extraStyleHref)
+ extraHeadHTML = htmlutil.HTMLFormat(``, extraScriptSrc, extraStyleHref)
}
ctx.usedByRender = true
diff --git a/modules/public/manifest.go b/modules/public/manifest.go
new file mode 100644
index 00000000000..77e89599672
--- /dev/null
+++ b/modules/public/manifest.go
@@ -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]
+}
diff --git a/modules/public/manifest_test.go b/modules/public/manifest_test.go
new file mode 100644
index 00000000000..20a2232cf38
--- /dev/null
+++ b/modules/public/manifest_test.go
@@ -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"))
+ })
+}
diff --git a/modules/public/vitedev.go b/modules/public/vitedev.go
new file mode 100644
index 00000000000..9c8da951fc1
--- /dev/null
+++ b/modules/public/vitedev.go
@@ -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=
+ // 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
+}
diff --git a/modules/setting/server.go b/modules/setting/server.go
index f0fbbce970a..1085e052a3e 100644
--- a/modules/setting/server.go
+++ b/modules/setting/server.go
@@ -72,9 +72,6 @@ var (
// It maps to ini:"LOCAL_ROOT_URL" in [server]
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
// DO NOT use it directly, always use AppDataTempDir
appTempPathInternal string
@@ -317,8 +314,6 @@ func loadServerFrom(rootCfg ConfigProvider) {
}
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)
ManifestData = `application/json;base64,` + base64.StdEncoding.EncodeToString(manifestBytes)
diff --git a/modules/templates/helper.go b/modules/templates/helper.go
index d2d4d364df0..3a5eb5904f7 100644
--- a/modules/templates/helper.go
+++ b/modules/templates/helper.go
@@ -6,15 +6,18 @@ package templates
import (
"fmt"
+ "html"
"html/template"
"net/url"
"strconv"
"strings"
+ "sync"
"time"
"code.gitea.io/gitea/modules/base"
"code.gitea.io/gitea/modules/htmlutil"
"code.gitea.io/gitea/modules/markup"
+ "code.gitea.io/gitea/modules/public"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/svg"
"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"
},
+ "AssetURI": public.AssetURI,
+ "ScriptImport": scriptImport,
// -----------------------------------------------------------------
// setting
"AppName": func() string {
@@ -92,9 +97,6 @@ func NewFuncMap() template.FuncMap {
"AppDomain": func() string { // documented in mail-templates.md
return setting.Domain
},
- "AssetVersion": func() string {
- return setting.AssetVersion
- },
"ShowFooterTemplateLoadTime": func() bool {
return setting.Other.ShowFooterTemplateLoadTime
},
@@ -303,3 +305,30 @@ func QueryBuild(a ...any) template.URL {
}
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) + `">`
+ return ret
+})
+
+func scriptImport(path string, typ ...string) template.HTML {
+ if len(typ) > 0 {
+ if typ[0] == "module" {
+ return template.HTML(`
-
+{{ScriptImport "js/iife.js"}}
diff --git a/templates/base/head_style.tmpl b/templates/base/head_style.tmpl
index b2fc033558c..15fa7ad730c 100644
--- a/templates/base/head_style.tmpl
+++ b/templates/base/head_style.tmpl
@@ -1,2 +1,2 @@
-
-
+
+
diff --git a/templates/devtest/devtest-footer.tmpl b/templates/devtest/devtest-footer.tmpl
index a1b3b86e5c4..868136e1948 100644
--- a/templates/devtest/devtest-footer.tmpl
+++ b/templates/devtest/devtest-footer.tmpl
@@ -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 */}}
-
+
{{template "base/footer" ctx.RootData}}
diff --git a/templates/devtest/devtest-header.tmpl b/templates/devtest/devtest-header.tmpl
index 0775dccc2d7..a7aebcb7dc8 100644
--- a/templates/devtest/devtest-header.tmpl
+++ b/templates/devtest/devtest-header.tmpl
@@ -1,3 +1,8 @@
{{template "base/head" ctx.RootData}}
-
+
+
{{template "base/alert" .}}
diff --git a/templates/status/500.tmpl b/templates/status/500.tmpl
index 424f590f84e..c230fadb169 100644
--- a/templates/status/500.tmpl
+++ b/templates/status/500.tmpl
@@ -1,5 +1,5 @@
{{/* 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
* .Flash
* .ErrorMsg
diff --git a/templates/swagger/ui.tmpl b/templates/swagger/ui.tmpl
index 4ff34728071..d53a6111764 100644
--- a/templates/swagger/ui.tmpl
+++ b/templates/swagger/ui.tmpl
@@ -2,13 +2,13 @@
Gitea API
-
+
{{/* TODO: add Help & Glossary to help users understand the API, and explain some concepts like "Owner" */}}
{{svg "octicon-reply"}}{{ctx.Locale.Tr "return_to_gitea"}}
-
+
diff --git a/tests/integration/markup_external_test.go b/tests/integration/markup_external_test.go
index 691ffcc62b5..3d9d7b39696 100644
--- a/tests/integration/markup_external_test.go
+++ b/tests/integration/markup_external_test.go
@@ -15,6 +15,7 @@ import (
"code.gitea.io/gitea/modules/charset"
"code.gitea.io/gitea/modules/markup"
"code.gitea.io/gitea/modules/markup/external"
+ "code.gitea.io/gitea/modules/public"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/test"
"code.gitea.io/gitea/tests"
@@ -107,7 +108,7 @@ func TestExternalMarkupRenderer(t *testing.T) {
// default sandbox in sub page response
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 "`, respSub.Body.String())
+ assert.Equal(t, ``, respSub.Body.String())
})
})
@@ -130,7 +131,7 @@ func TestExternalMarkupRenderer(t *testing.T) {
t.Run("HTMLContentWithExternalRenderIframeHelper", func(t *testing.T) {
req := NewRequest(t, "GET", "/user2/repo1/render/branch/master/html.no-sanitizer")
respSub := MakeRequest(t, req, http.StatusOK)
- assert.Equal(t, ``, respSub.Body.String())
+ assert.Equal(t, ``, respSub.Body.String())
assert.Equal(t, "frame-src 'self'", respSub.Header().Get("Content-Security-Policy"))
})
})
diff --git a/tsconfig.json b/tsconfig.json
index 9b978cf54ea..851bf13dc9c 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -45,7 +45,7 @@
"verbatimModuleSyntax": true,
"types": [
"node",
- "webpack/module",
+ "vite/client",
"vitest/globals",
"./web_src/js/globals.d.ts",
"./types.d.ts",
diff --git a/types.d.ts b/types.d.ts
index 59d6ecf149f..234bd267fe2 100644
--- a/types.d.ts
+++ b/types.d.ts
@@ -1,8 +1,3 @@
-declare module '@techknowlogick/license-checker-webpack-plugin' {
- const plugin: any;
- export = plugin;
-}
-
declare module 'eslint-plugin-no-use-extend-native' {
import type {Eslint} from 'eslint';
const plugin: Eslint.Plugin;
diff --git a/vite.config.ts b/vite.config.ts
new file mode 100644
index 00000000000..d2c7abac054
--- /dev/null
+++ b/vite.config.ts
@@ -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 = {};
+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();
+ 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');
+ },
+ },
+ ],
+}));
diff --git a/web_src/css/base.css b/web_src/css/base.css
index 60317887bad..b660e19ac4f 100644
--- a/web_src/css/base.css
+++ b/web_src/css/base.css
@@ -538,6 +538,58 @@ strong.attention-caution, svg.attention-caution {
overflow-menu {
border-bottom: 1px solid var(--color-secondary) !important;
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 {
diff --git a/web_src/js/bootstrap.ts b/web_src/js/bootstrap.ts
index ca38ac874e1..f88f4900637 100644
--- a/web_src/js/bootstrap.ts
+++ b/web_src/js/bootstrap.ts
@@ -1,82 +1,12 @@
// DO NOT IMPORT window.config HERE!
// to make sure the error handler always works, we should never import `window.config`, because
// some user's custom template breaks it.
-import type {Intent} from './types.ts';
-import {html} from './utils/html.ts';
+import {showGlobalErrorMessage, processWindowErrorEvent} from './modules/errors.ts';
-// This sets up the URL prefix used in webpack's chunk loading.
-// This file must be imported before any lazy-loading is being attempted.
-window.__webpack_public_path__ = `${window.config?.assetUrlPrefix ?? '/assets'}/`;
-
-export function shouldIgnoreError(err: Error) {
- const ignorePatterns: Array = [
- // 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(`.js-global-error[data-global-error-msg-compact="${msgCompact}"]`);
- if (!msgDiv) {
- const el = document.createElement('div');
- el.innerHTML = html``;
- 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;
- }
+// A module should not be imported twice, otherwise there will be bugs when a module has its internal states.
+// A real example is "generateElemId" in "utils/dom.ts", if it is imported twice in different module scopes,
+// 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) {
if (!window.config) {
showGlobalErrorMessage(`Gitea JavaScript code couldn't run correctly, please check your custom templates`);
}
@@ -90,5 +20,3 @@ function initGlobalErrorHandler() {
// events directly
window._globalHandlerErrors = {_inited: true, push: (e: ErrorEvent & PromiseRejectionEvent) => processWindowErrorEvent(e)} as any;
}
-
-initGlobalErrorHandler();
diff --git a/web_src/js/features/captcha.ts b/web_src/js/features/captcha.ts
index 01b50530265..08513fe6ba5 100644
--- a/web_src/js/features/captcha.ts
+++ b/web_src/js/features/captcha.ts
@@ -34,7 +34,7 @@ export async function initCaptcha() {
break;
}
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
// * the "vanilla-glue" has some problems with es6 module.
diff --git a/web_src/js/features/citation.ts b/web_src/js/features/citation.ts
index 6d30d816857..1abd960366e 100644
--- a/web_src/js/features/citation.ts
+++ b/web_src/js/features/citation.ts
@@ -6,10 +6,10 @@ const {pageData} = window.config;
async function initInputCitationValue(citationCopyApa: HTMLButtonElement, citationCopyBibtex: HTMLButtonElement) {
const [{Cite, plugins}] = await Promise.all([
- import(/* webpackChunkName: "citation-js-core" */'@citation-js/core'),
- import(/* webpackChunkName: "citation-js-formats" */'@citation-js/plugin-software-formats'),
- import(/* webpackChunkName: "citation-js-bibtex" */'@citation-js/plugin-bibtex'),
- import(/* webpackChunkName: "citation-js-csl" */'@citation-js/plugin-csl'),
+ import('@citation-js/core'),
+ import('@citation-js/plugin-software-formats'),
+ import('@citation-js/plugin-bibtex'),
+ import('@citation-js/plugin-csl'),
]);
const citationFileContent = pageData.citationFileContent!;
const config = plugins.config.get('@bibtex');
diff --git a/web_src/js/features/code-frequency.ts b/web_src/js/features/code-frequency.ts
index da7cd6b2c00..475379ac14a 100644
--- a/web_src/js/features/code-frequency.ts
+++ b/web_src/js/features/code-frequency.ts
@@ -4,7 +4,7 @@ export async function initRepoCodeFrequency() {
const el = document.querySelector('#repo-code-frequency-chart');
if (!el) return;
- const {default: RepoCodeFrequency} = await import(/* webpackChunkName: "code-frequency-graph" */'../components/RepoCodeFrequency.vue');
+ const {default: RepoCodeFrequency} = await import('../components/RepoCodeFrequency.vue');
try {
const View = createApp(RepoCodeFrequency, {
locale: {
diff --git a/web_src/js/features/codeeditor.ts b/web_src/js/features/codeeditor.ts
index dc3f2fad81b..58acf1494d5 100644
--- a/web_src/js/features/codeeditor.ts
+++ b/web_src/js/features/codeeditor.ts
@@ -129,7 +129,7 @@ function updateTheme(monaco: Monaco): void {
type CreateMonacoOpts = MonacoOpts & {language?: string};
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);
let {language, ...other} = opts;
diff --git a/web_src/js/features/colorpicker.ts b/web_src/js/features/colorpicker.ts
index face4ef228f..6a14774bfc4 100644
--- a/web_src/js/features/colorpicker.ts
+++ b/web_src/js/features/colorpicker.ts
@@ -6,8 +6,8 @@ export async function initColorPickers() {
registerGlobalInitFunc('initColorPicker', async (el) => {
if (!imported) {
await Promise.all([
- import(/* webpackChunkName: "colorpicker" */'vanilla-colorful/hex-color-picker.js'),
- import(/* webpackChunkName: "colorpicker" */'../../css/features/colorpicker.css'),
+ import('vanilla-colorful/hex-color-picker.js'),
+ import('../../css/features/colorpicker.css'),
]);
imported = true;
}
diff --git a/web_src/js/features/common-page.ts b/web_src/js/features/common-page.ts
index 36af0870899..fd37e307f76 100644
--- a/web_src/js/features/common-page.ts
+++ b/web_src/js/features/common-page.ts
@@ -1,5 +1,5 @@
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 {addDelegatedEventListener, queryElems} from '../utils/dom.ts';
import {registerGlobalInitFunc, registerGlobalSelectorFunc} from '../modules/observer.ts';
diff --git a/web_src/js/features/comp/ComboMarkdownEditor.ts b/web_src/js/features/comp/ComboMarkdownEditor.ts
index 5b470ea03d5..468f3fc5ca6 100644
--- a/web_src/js/features/comp/ComboMarkdownEditor.ts
+++ b/web_src/js/features/comp/ComboMarkdownEditor.ts
@@ -319,8 +319,8 @@ export class ComboMarkdownEditor {
async switchToEasyMDE() {
if (this.easyMDE) return;
const [{default: EasyMDE}] = await Promise.all([
- import(/* webpackChunkName: "easymde" */'easymde'),
- import(/* webpackChunkName: "easymde" */'../../../css/easymde.css'),
+ import('easymde'),
+ import('../../../css/easymde.css'),
]);
const easyMDEOpt: EasyMDE.Options = {
autoDownloadFontAwesome: false,
diff --git a/web_src/js/features/comp/Cropper.ts b/web_src/js/features/comp/Cropper.ts
index 9fd48697fa6..a36689bfc27 100644
--- a/web_src/js/features/comp/Cropper.ts
+++ b/web_src/js/features/comp/Cropper.ts
@@ -7,7 +7,7 @@ type 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 currentFileLastModified = 0;
const cropper = new Cropper(imageSource, {
diff --git a/web_src/js/features/contributors.ts b/web_src/js/features/contributors.ts
index 95fc81f5b36..d28d18eacc2 100644
--- a/web_src/js/features/contributors.ts
+++ b/web_src/js/features/contributors.ts
@@ -4,7 +4,7 @@ export async function initRepoContributors() {
const el = document.querySelector('#repo-contributors-chart');
if (!el) return;
- const {default: RepoContributors} = await import(/* webpackChunkName: "contributors-graph" */'../components/RepoContributors.vue');
+ const {default: RepoContributors} = await import('../components/RepoContributors.vue');
try {
const View = createApp(RepoContributors, {
repoLink: el.getAttribute('data-repo-link'),
diff --git a/web_src/js/features/dropzone.ts b/web_src/js/features/dropzone.ts
index fedcff2162b..55c0e3c7a50 100644
--- a/web_src/js/features/dropzone.ts
+++ b/web_src/js/features/dropzone.ts
@@ -19,8 +19,8 @@ export const DropzoneCustomEventUploadDone = 'dropzone-custom-upload-done';
async function createDropzone(el: HTMLElement, opts: DropzoneOptions) {
const [{default: Dropzone}] = await Promise.all([
- import(/* webpackChunkName: "dropzone" */'dropzone'),
- import(/* webpackChunkName: "dropzone" */'dropzone/dist/dropzone.css'),
+ import('dropzone'),
+ import('dropzone/dist/dropzone.css'),
]);
return new Dropzone(el, opts);
}
diff --git a/web_src/js/features/heatmap.ts b/web_src/js/features/heatmap.ts
index 95004096d8a..341e014bfc6 100644
--- a/web_src/js/features/heatmap.ts
+++ b/web_src/js/features/heatmap.ts
@@ -45,7 +45,7 @@ export async function initHeatmap() {
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});
View.mount(el);
el.classList.remove('is-loading');
diff --git a/web_src/js/features/recent-commits.ts b/web_src/js/features/recent-commits.ts
index b7f7c499873..6ad53a238c0 100644
--- a/web_src/js/features/recent-commits.ts
+++ b/web_src/js/features/recent-commits.ts
@@ -4,7 +4,7 @@ export async function initRepoRecentCommits() {
const el = document.querySelector('#repo-recent-commits-chart');
if (!el) return;
- const {default: RepoRecentCommits} = await import(/* webpackChunkName: "recent-commits-graph" */'../components/RepoRecentCommits.vue');
+ const {default: RepoRecentCommits} = await import('../components/RepoRecentCommits.vue');
try {
const View = createApp(RepoRecentCommits, {
locale: {
diff --git a/web_src/js/features/repo-findfile.ts b/web_src/js/features/repo-findfile.ts
index 8d306b2bab8..962f8b84c12 100644
--- a/web_src/js/features/repo-findfile.ts
+++ b/web_src/js/features/repo-findfile.ts
@@ -69,7 +69,7 @@ export function filterRepoFilesWeighted(files: Array, filter: string) {
export function initRepoFileSearch() {
registerGlobalInitFunc('initRepoFileSearch', async (el) => {
- const {default: RepoFileSearch} = await import(/* webpackChunkName: "RepoFileSearch" */ '../components/RepoFileSearch.vue');
+ const {default: RepoFileSearch} = await import('../components/RepoFileSearch.vue');
createApp(RepoFileSearch, {
repoLink: el.getAttribute('data-repo-link'),
currentRefNameSubURL: el.getAttribute('data-current-ref-name-sub-url'),
diff --git a/web_src/js/features/repo-issue-pull.ts b/web_src/js/features/repo-issue-pull.ts
index 093f484b42c..58dbf1790eb 100644
--- a/web_src/js/features/repo-issue-pull.ts
+++ b/web_src/js/features/repo-issue-pull.ts
@@ -66,7 +66,7 @@ async function initRepoPullRequestMergeForm(box: HTMLElement) {
const el = box.querySelector('#pull-request-merge-form');
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);
view.mount(el);
}
diff --git a/web_src/js/features/tribute.ts b/web_src/js/features/tribute.ts
index 1a011c33a19..462a925ab67 100644
--- a/web_src/js/features/tribute.ts
+++ b/web_src/js/features/tribute.ts
@@ -5,7 +5,7 @@ import type {TributeCollection} from 'tributejs';
import type {Mention} from '../types.ts';
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 emojiCollection: TributeCollection = { // emojis
diff --git a/web_src/js/globals.d.ts b/web_src/js/globals.d.ts
index f6e0a109b01..2a6f86b65ec 100644
--- a/web_src/js/globals.d.ts
+++ b/web_src/js/globals.d.ts
@@ -22,8 +22,8 @@ interface Window {
config: {
appUrl: string,
appSubUrl: string,
- assetVersionEncoded: string,
assetUrlPrefix: string,
+ sharedWorkerUri: string,
runModeIsProd: boolean,
customEmojis: Record,
pageData: Record & {
@@ -64,6 +64,10 @@ interface Window {
codeEditors: any[], // export editor for customization
localUserSettings: typeof import('./modules/user-settings.ts').localUserSettings,
+ MonacoEnvironment?: {
+ getWorker: (workerId: string, label: string) => Worker,
+ },
+
// various captcha plugins
grecaptcha: any,
turnstile: any,
@@ -71,3 +75,8 @@ interface Window {
// do not add more properties here unless it is a must
}
+
+declare module '*?worker' {
+ const workerConstructor: new () => Worker;
+ export default workerConstructor;
+}
diff --git a/web_src/js/globals.ts b/web_src/js/globals.ts
index 955515d2502..9cd66d8322b 100644
--- a/web_src/js/globals.ts
+++ b/web_src/js/globals.ts
@@ -1,2 +1,16 @@
-import jquery from 'jquery';
-window.$ = window.jQuery = jquery; // only for Fomantic UI
+import jquery from 'jquery'; // eslint-disable-line no-restricted-imports
+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;
diff --git a/web_src/js/htmx.ts b/web_src/js/htmx.ts
deleted file mode 100644
index acc3df1d81d..00000000000
--- a/web_src/js/htmx.ts
+++ /dev/null
@@ -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) => {
- // 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) => {
- // TODO: add translations
- showErrorToast(`Error ${event.detail!.xhr.status} when calling ${event.detail!.requestConfig.path}`);
- });
-}
diff --git a/web_src/js/iife.ts b/web_src/js/iife.ts
new file mode 100644
index 00000000000..218519c59a3
--- /dev/null
+++ b/web_src/js/iife.ts
@@ -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
diff --git a/web_src/js/index-domready.ts b/web_src/js/index-domready.ts
deleted file mode 100644
index 19a61b0e40f..00000000000
--- a/web_src/js/index-domready.ts
+++ /dev/null
@@ -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'));
diff --git a/web_src/js/index.ts b/web_src/js/index.ts
index 2de29f52b94..e0b4a3e521a 100644
--- a/web_src/js/index.ts
+++ b/web_src/js/index.ts
@@ -1,29 +1,188 @@
-// bootstrap module must be the first one to be imported, it handles webpack lazy-loading and global errors
-import './bootstrap.ts';
+import '../fomantic/build/fomantic.js';
+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)
-// so load globals (including jQuery) as early as possible
-import './globals.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';
-import './webcomponents/index.ts';
-import './modules/user-settings.ts'; // templates also need to use localUserSettings in inline scripts
-import {onDomReady} from './utils/dom.ts';
+const initStartTime = performance.now();
+const initPerformanceTracer = callInitFunctions([
+ 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
-// Then importing the htmx in our onDomReady will make htmx skip its initialization.
-// If the bug would be fixed (https://github.com/bigskysoftware/htmx/pull/3365), then we can only import htmx in "onDomReady"
-import 'htmx.org';
+ initGlobalComponent,
+ initGlobalDropdown,
+ initGlobalFetchAction,
+ initGlobalTooltips,
+ initGlobalButtonClickOnEnter,
+ initGlobalButtons,
+ initGlobalCopyToClipboardListener,
+ initGlobalEnterQuickSubmit,
+ initGlobalFormDirtyLeaveConfirm,
+ initGlobalComboMarkdownEditor,
+ initGlobalDeleteButton,
+ initGlobalInput,
+ initGlobalShortcut,
-onDomReady(async () => {
- // when navigate before the import complete, there will be an error from webpack chunk loader:
- // JavaScript promise rejection: Loading chunk index-domready failed.
- try {
- await import(/* webpackChunkName: "index-domready" */'./index-domready.ts');
- } catch (e) {
- if (e.name === 'ChunkLoadError') {
- console.error('Error loading index-domready:', e);
- } else {
- throw e;
- }
- }
+ 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`);
+}
+
+// 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'));
diff --git a/web_src/js/markup/asciicast.ts b/web_src/js/markup/asciicast.ts
index 4596327876c..90515e1363c 100644
--- a/web_src/js/markup/asciicast.ts
+++ b/web_src/js/markup/asciicast.ts
@@ -3,8 +3,8 @@ import {queryElems} from '../utils/dom.ts';
export async function initMarkupRenderAsciicast(elMarkup: HTMLElement): Promise {
queryElems(elMarkup, '.asciinema-player-container', async (el) => {
const [player] = await Promise.all([
- import(/* webpackChunkName: "asciinema-player" */'asciinema-player'),
- import(/* webpackChunkName: "asciinema-player" */'asciinema-player/dist/bundle/asciinema-player.css'),
+ import('asciinema-player'),
+ import('asciinema-player/dist/bundle/asciinema-player.css'),
]);
player.create(el.getAttribute('data-asciinema-player-src')!, el, {
diff --git a/web_src/js/markup/math.ts b/web_src/js/markup/math.ts
index bc118137a10..a3ee102ccde 100644
--- a/web_src/js/markup/math.ts
+++ b/web_src/js/markup/math.ts
@@ -16,8 +16,8 @@ export async function initMarkupCodeMath(elMarkup: HTMLElement): Promise {
// .markup code.language-math'
queryElems(elMarkup, 'code.language-math', async (el) => {
const [{default: katex}] = await Promise.all([
- import(/* webpackChunkName: "katex" */'katex'),
- import(/* webpackChunkName: "katex" */'katex/dist/katex.css'),
+ import('katex'),
+ import('katex/dist/katex.css'),
]);
const MAX_CHARS = 1000;
diff --git a/web_src/js/markup/mermaid.ts b/web_src/js/markup/mermaid.ts
index 5148ff377ca..aaf6da6805d 100644
--- a/web_src/js/markup/mermaid.ts
+++ b/web_src/js/markup/mermaid.ts
@@ -72,8 +72,8 @@ export function sourceNeedsElk(source: string) {
}
async function loadMermaid(needElkRender: boolean) {
- const mermaidPromise = import(/* webpackChunkName: "mermaid" */'mermaid');
- const elkPromise = needElkRender ? import(/* webpackChunkName: "mermaid-layout-elk" */'@mermaid-js/layout-elk') : null;
+ const mermaidPromise = import('mermaid');
+ const elkPromise = needElkRender ? import('@mermaid-js/layout-elk') : null;
const results = await Promise.all([mermaidPromise, elkPromise]);
return {
mermaid: results[0].default,
diff --git a/web_src/js/markup/refissue.ts b/web_src/js/markup/refissue.ts
index f2fcd24f39d..b17f452dd4d 100644
--- a/web_src/js/markup/refissue.ts
+++ b/web_src/js/markup/refissue.ts
@@ -20,7 +20,7 @@ function showMarkupRefIssuePopup(e: MouseEvent | FocusEvent) {
const el = document.createElement('div');
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, {
// backend: GetIssueInfo
loadIssueInfoUrl: `${window.config.appSubUrl}/${issuePathInfo.ownerName}/${issuePathInfo.repoName}/issues/${issuePathInfo.indexString}/info`,
diff --git a/web_src/js/bootstrap.test.ts b/web_src/js/modules/errors.test.ts
similarity index 70%
rename from web_src/js/bootstrap.test.ts
rename to web_src/js/modules/errors.test.ts
index 9d163ebbb83..c860a3f7cb1 100644
--- a/web_src/js/bootstrap.test.ts
+++ b/web_src/js/modules/errors.test.ts
@@ -1,4 +1,4 @@
-import {showGlobalErrorMessage, shouldIgnoreError} from './bootstrap.ts';
+import {showGlobalErrorMessage, shouldIgnoreError} from './errors.ts';
test('showGlobalErrorMessage', () => {
document.body.innerHTML = '';
@@ -13,9 +13,9 @@ test('showGlobalErrorMessage', () => {
test('shouldIgnoreError', () => {
for (const url of [
- 'https://gitea.test/assets/js/monaco.b359ef7e.js',
- 'https://gitea.test/assets/js/monaco-editor.4a969118.worker.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/monaco.D14TzjS9.js',
+ 'https://gitea.test/assets/js/editor.api2.BdhK7zNg.js',
+ 'https://gitea.test/assets/js/editor.worker.BYgvyFya.js',
]) {
const err = new Error('test');
err.stack = `Error: test\n at ${url}:1:1`;
diff --git a/web_src/js/modules/errors.ts b/web_src/js/modules/errors.ts
new file mode 100644
index 00000000000..3ec01b3eb7c
--- /dev/null
+++ b/web_src/js/modules/errors.ts
@@ -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(`.js-global-error[data-global-error-msg-compact="${msgCompact}"]`);
+ if (!msgDiv) {
+ const el = document.createElement('div');
+ el.innerHTML = html``;
+ 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 = [
+ // 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.`);
+}
diff --git a/web_src/js/modules/fomantic.ts b/web_src/js/modules/fomantic.ts
index 4b1dbc4f626..ee45f676ba4 100644
--- a/web_src/js/modules/fomantic.ts
+++ b/web_src/js/modules/fomantic.ts
@@ -1,4 +1,3 @@
-import $ from 'jquery';
import {initAriaCheckboxPatch} from './fomantic/checkbox.ts';
import {initAriaFormFieldPatch} from './fomantic/form.ts';
import {initAriaDropdownPatch} from './fomantic/dropdown.ts';
diff --git a/web_src/js/modules/fomantic/base.ts b/web_src/js/modules/fomantic/base.ts
index a227d8123a3..f3953e60cdd 100644
--- a/web_src/js/modules/fomantic/base.ts
+++ b/web_src/js/modules/fomantic/base.ts
@@ -1,4 +1,3 @@
-import $ from 'jquery';
import {generateElemId} from '../../utils/dom.ts';
export function linkLabelAndInput(label: Element, input: Element) {
diff --git a/web_src/js/modules/fomantic/dimmer.ts b/web_src/js/modules/fomantic/dimmer.ts
index cbdfac23cba..6782f0137d9 100644
--- a/web_src/js/modules/fomantic/dimmer.ts
+++ b/web_src/js/modules/fomantic/dimmer.ts
@@ -1,4 +1,3 @@
-import $ from 'jquery';
import {queryElemChildren} from '../../utils/dom.ts';
export function initFomanticDimmer() {
diff --git a/web_src/js/modules/fomantic/dropdown.ts b/web_src/js/modules/fomantic/dropdown.ts
index 7f7f3611beb..b98a5cf3f41 100644
--- a/web_src/js/modules/fomantic/dropdown.ts
+++ b/web_src/js/modules/fomantic/dropdown.ts
@@ -1,4 +1,3 @@
-import $ from 'jquery';
import type {FomanticInitFunction} from '../../types.ts';
import {generateElemId, queryElems} from '../../utils/dom.ts';
diff --git a/web_src/js/modules/fomantic/modal.ts b/web_src/js/modules/fomantic/modal.ts
index a96c7785e1a..1383692c985 100644
--- a/web_src/js/modules/fomantic/modal.ts
+++ b/web_src/js/modules/fomantic/modal.ts
@@ -1,4 +1,3 @@
-import $ from 'jquery';
import type {FomanticInitFunction} from '../../types.ts';
import {queryElems} from '../../utils/dom.ts';
import {hideToastsFrom} from '../toast.ts';
diff --git a/web_src/js/modules/fomantic/tab.ts b/web_src/js/modules/fomantic/tab.ts
index b9578c96375..4d1bd7e648d 100644
--- a/web_src/js/modules/fomantic/tab.ts
+++ b/web_src/js/modules/fomantic/tab.ts
@@ -1,4 +1,3 @@
-import $ from 'jquery';
import {queryElemSiblings} from '../../utils/dom.ts';
export function initFomanticTab() {
diff --git a/web_src/js/modules/fomantic/transition.ts b/web_src/js/modules/fomantic/transition.ts
index 52c407c9c0d..c4eb1d75e90 100644
--- a/web_src/js/modules/fomantic/transition.ts
+++ b/web_src/js/modules/fomantic/transition.ts
@@ -1,5 +1,3 @@
-import $ from 'jquery';
-
export function initFomanticTransition() {
const transitionNopBehaviors = new Set([
'clear queue', 'stop', 'stop all', 'destroy',
diff --git a/web_src/js/modules/monaco.ts b/web_src/js/modules/monaco.ts
new file mode 100644
index 00000000000..c8e1ff77655
--- /dev/null
+++ b/web_src/js/modules/monaco.ts
@@ -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';
diff --git a/web_src/js/modules/sortable.ts b/web_src/js/modules/sortable.ts
index f3515fcb8de..c49f36ba8be 100644
--- a/web_src/js/modules/sortable.ts
+++ b/web_src/js/modules/sortable.ts
@@ -3,7 +3,7 @@ import type SortableType from 'sortablejs';
export async function createSortable(el: HTMLElement, opts: {handle?: string} & SortableOptions = {}): Promise {
// 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, {
animation: 150,
diff --git a/web_src/js/modules/worker.ts b/web_src/js/modules/worker.ts
index b730e30bb2e..64c32fbe81b 100644
--- a/web_src/js/modules/worker.ts
+++ b/web_src/js/modules/worker.ts
@@ -1,11 +1,11 @@
-const {appSubUrl, assetVersionEncoded} = window.config;
+const {appSubUrl, sharedWorkerUri} = window.config;
export class UserEventsSharedWorker {
sharedWorker: SharedWorker;
// options can be either a string (the debug name of the worker) or an object of type 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;
worker.addEventListener('error', (event) => {
console.error('worker error', event);
diff --git a/web_src/js/render/plugins/3d-viewer.ts b/web_src/js/render/plugins/3d-viewer.ts
index 6f3ee15d265..f997790af69 100644
--- a/web_src/js/render/plugins/3d-viewer.ts
+++ b/web_src/js/render/plugins/3d-viewer.ts
@@ -47,7 +47,7 @@ export function newRenderPlugin3DViewer(): FileRenderPlugin {
async render(container: HTMLElement, fileUrl: string): Promise {
// 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, {
backgroundColor: new OV.RGBAColor(59, 68, 76, 0),
defaultColor: new OV.RGBColor(65, 131, 196),
diff --git a/web_src/js/render/plugins/pdf-viewer.ts b/web_src/js/render/plugins/pdf-viewer.ts
index 40623be0557..c7040e96ef1 100644
--- a/web_src/js/render/plugins/pdf-viewer.ts
+++ b/web_src/js/render/plugins/pdf-viewer.ts
@@ -9,7 +9,7 @@ export function newRenderPluginPdfViewer(): FileRenderPlugin {
},
async render(container: HTMLElement, fileUrl: string): Promise {
- const PDFObject = await import(/* webpackChunkName: "pdfobject" */'pdfobject');
+ const PDFObject = await import('pdfobject');
// TODO: the PDFObject library does not support dynamic height adjustment,
container.style.height = `${window.innerHeight - 100}px`;
if (!PDFObject.default.embed(fileUrl, container)) {
diff --git a/web_src/js/standalone/devtest.ts b/web_src/js/standalone/devtest.ts
index 39c41db0424..20ab163d1a2 100644
--- a/web_src/js/standalone/devtest.ts
+++ b/web_src/js/standalone/devtest.ts
@@ -1,3 +1,4 @@
+import '../../css/standalone/devtest.css';
import {showInfoToast, showWarningToast, showErrorToast, type Toast} from '../modules/toast.ts';
type LevelMap = Record Toast | null>;
diff --git a/web_src/js/standalone/external-render-iframe.ts b/web_src/js/standalone/external-render-iframe.ts
index f8ec070785a..3b489f8ee38 100644
--- a/web_src/js/standalone/external-render-iframe.ts
+++ b/web_src/js/standalone/external-render-iframe.ts
@@ -11,6 +11,8 @@ RENDER_COMMAND = `echo '('[role="menuitem"]');
if (e.shiftKey) {
if (document.activeElement === items[0]) {
e.preventDefault();
@@ -39,7 +62,7 @@ window.customElements.define('overflow-menu', class extends HTMLElement {
} else if (e.key === 'Escape') {
e.preventDefault();
e.stopPropagation();
- this.button?._tippy.hide();
+ this.hidePopup();
this.button?.focus();
} else if (e.key === ' ' || e.code === 'Enter') {
if (document.activeElement?.matches('[role="menuitem"]')) {
@@ -48,20 +71,20 @@ window.customElements.define('overflow-menu', class extends HTMLElement {
(document.activeElement as HTMLElement).click();
}
} else if (e.key === 'ArrowDown') {
- if (document.activeElement?.matches('.tippy-target')) {
+ if (document.activeElement === this.popup) {
e.preventDefault();
e.stopPropagation();
- document.activeElement.querySelector
('[role="menuitem"]:first-of-type')?.focus();
+ this.popup.querySelector('[role="menuitem"]:first-of-type')?.focus();
} else if (document.activeElement?.matches('[role="menuitem"]')) {
e.preventDefault();
e.stopPropagation();
(document.activeElement.nextElementSibling as HTMLElement)?.focus();
}
} else if (e.key === 'ArrowUp') {
- if (document.activeElement?.matches('.tippy-target')) {
+ if (document.activeElement === this.popup) {
e.preventDefault();
e.stopPropagation();
- document.activeElement.querySelector('[role="menuitem"]:last-of-type')?.focus();
+ this.popup.querySelector('[role="menuitem"]:last-of-type')?.focus();
} else if (document.activeElement?.matches('[role="menuitem"]')) {
e.preventDefault();
e.stopPropagation();
@@ -69,16 +92,15 @@ window.customElements.define('overflow-menu', class extends HTMLElement {
}
}
});
- div.classList.add('tippy-target');
- this.handleItemClick(div, '.tippy-target > .item');
- this.tippyContent = div;
- } // end if: no tippyContent and create a new one
+ this.handleItemClick(div, '.overflow-menu-popup > .item');
+ this.popup = div;
+ } // end if: no popup and create a new one
const itemFlexSpace = this.menuItemsEl.querySelector('.item-flex-space');
const itemOverFlowMenuButton = this.querySelector('.overflow-menu-button');
- // move items in tippy back into the menu items for subsequent measurement
- for (const item of this.tippyItems || []) {
+ // move items in popup back into the menu items for subsequent measurement
+ for (const item of this.overflowItems || []) {
if (!itemFlexSpace || item.getAttribute('data-after-flex-space')) {
this.menuItemsEl.append(item);
} else {
@@ -90,7 +112,7 @@ window.customElements.define('overflow-menu', class extends HTMLElement {
// flex space and overflow menu are excluded from measurement
itemFlexSpace?.style.setProperty('display', 'none', 'important');
itemOverFlowMenuButton?.style.setProperty('display', 'none', 'important');
- this.tippyItems = [];
+ this.overflowItems = [];
const menuRight = this.offsetLeft + this.offsetWidth;
const menuItems = this.menuItemsEl.querySelectorAll('.item, .item-flex-space');
let afterFlexSpace = false;
@@ -102,64 +124,64 @@ window.customElements.define('overflow-menu', class extends HTMLElement {
if (afterFlexSpace) item.setAttribute('data-after-flex-space', 'true');
const itemRight = item.offsetLeft + item.offsetWidth;
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 moveToPopup = !onlyLastItem || !lastItemFit;
- if (moveToPopup) this.tippyItems.push(item);
+ if (moveToPopup) this.overflowItems.push(item);
}
}
itemFlexSpace?.style.removeProperty('display');
itemOverFlowMenuButton?.style.removeProperty('display');
// if there are no overflown items, remove any previously created button
- if (!this.tippyItems?.length) {
- const btn = this.querySelector('.overflow-menu-button');
- btn?._tippy?.destroy();
- btn?.remove();
+ if (!this.overflowItems?.length) {
+ this.hidePopup();
+ this.button?.remove();
+ this.popup?.remove();
this.button = null;
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) {
- if (!this.tippyItems.includes(item)) {
+ if (!this.overflowItems.includes(item)) {
item.removeAttribute('role');
}
}
- // move all items that overflow into tippy
- for (const item of this.tippyItems) {
+ // move all items that overflow into popup
+ for (const item of this.overflowItems) {
item.setAttribute('role', 'menuitem');
- this.tippyContent.append(item);
+ this.popup.append(item);
}
- // update existing tippy
- if (this.button?._tippy) {
- this.button._tippy.setContent(this.tippyContent);
+ // update existing popup
+ if (this.button) {
this.updateButtonActivationState();
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.classList.add('overflow-menu-button');
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.append(this.button);
- createTippy(this.button, {
- trigger: 'click',
- hideOnClick: true,
- interactive: true,
- placement: 'bottom-end',
- role: 'menu',
- theme: 'menu',
- content: this.tippyContent,
- onShow: () => { // FIXME: onShown doesn't work (never be called)
- setTimeout(() => {
- this.tippyContent.focus();
- }, 0);
- },
+ this.button.addEventListener('click', (e) => {
+ e.stopPropagation();
+ if (this.popup.style.display === 'none') {
+ this.showPopup();
+ } else {
+ this.hidePopup();
+ }
});
+ this.append(this.button);
+ this.append(this.popup);
this.updateButtonActivationState();
});
@@ -202,7 +224,7 @@ window.customElements.define('overflow-menu', class extends HTMLElement {
handleItemClick(el: Element, selector: string) {
addDelegatedEventListener(el, 'click', selector, () => {
- this.button?._tippy?.hide();
+ this.hidePopup();
this.updateButtonActivationState();
});
}
@@ -239,5 +261,6 @@ window.customElements.define('overflow-menu', class extends HTMLElement {
disconnectedCallback() {
this.mutationObserver?.disconnect();
this.resizeObserver?.disconnect();
+ document.removeEventListener('click', this.onClickOutside, true);
}
});
diff --git a/webpack.config.ts b/webpack.config.ts
deleted file mode 100644
index e3ef996909d..00000000000
--- a/webpack.config.ts
+++ /dev/null
@@ -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) => {
- 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>}) => {
- const line = '-'.repeat(80);
- const goJson = readFileSync('assets/go-licenses.json', 'utf8');
- const goModules = JSON.parse(goJson).map(({name, licenseText}: Record) => {
- 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;