From 0ec66b538095a12d199dd4759f45843713defc94 Mon Sep 17 00:00:00 2001 From: silverwind Date: Sun, 29 Mar 2026 12:24:30 +0200 Subject: [PATCH] Migrate from webpack to vite (#37002) Replace webpack with Vite 8 as the frontend bundler. Frontend build is around 3-4 times faster than before. Will work on all platforms including riscv64 (via wasm). `iife.js` is a classic render-blocking script in `` (handles web components/early DOM setup). `index.js` is loaded as a `type="module"` script in the footer. All other JS chunks are also module scripts (supported in all browsers since 2018). Entry filenames are content-hashed (e.g. `index.C6Z2MRVQ.js`) and resolved at runtime via the Vite manifest, eliminating the `?v=` cache busting (which was unreliable in some scenarios like vscode dev build). Replaces: https://github.com/go-gitea/gitea/pull/36896 Fixes: https://github.com/go-gitea/gitea/issues/17793 Signed-off-by: silverwind Signed-off-by: wxiaoguang Co-authored-by: Claude (Opus 4.6) Co-authored-by: Lunny Xiao Co-authored-by: wxiaoguang --- .devcontainer/devcontainer.json | 1 - .github/labeler.yml | 1 - .gitignore | 3 +- .gitpod.yml | 51 - Dockerfile | 2 +- Dockerfile.rootless | 2 +- Makefile | 33 +- README.md | 1 - README.zh-cn.md | 1 - README.zh-tw.md | 1 - eslint.config.ts | 8 +- modules/graceful/server_http.go | 5 + modules/markup/external/openapi.go | 11 +- modules/markup/render.go | 7 +- modules/public/manifest.go | 156 +++ modules/public/manifest_test.go | 91 ++ modules/public/vitedev.go | 168 +++ modules/setting/server.go | 5 - modules/templates/helper.go | 35 +- modules/web/handler.go | 9 + modules/web/routing/context.go | 13 + modules/web/routing/logger.go | 37 +- modules/web/routing/requestrecord.go | 3 + package.json | 18 +- pnpm-lock.yaml | 1211 +++++------------ routers/web/web.go | 6 +- services/context/response.go | 11 + services/webtheme/webtheme.go | 9 +- templates/base/footer.tmpl | 1 + templates/base/head_script.tmpl | 4 +- templates/base/head_style.tmpl | 4 +- templates/devtest/devtest-footer.tmpl | 2 +- templates/devtest/devtest-header.tmpl | 7 +- templates/status/500.tmpl | 2 +- templates/swagger/ui.tmpl | 4 +- tests/integration/markup_external_test.go | 5 +- tsconfig.json | 2 +- types.d.ts | 5 - vite.config.ts | 332 +++++ web_src/css/base.css | 52 + web_src/js/bootstrap.ts | 82 +- web_src/js/features/captcha.ts | 2 +- web_src/js/features/citation.ts | 8 +- web_src/js/features/code-frequency.ts | 2 +- web_src/js/features/codeeditor.ts | 2 +- web_src/js/features/colorpicker.ts | 4 +- web_src/js/features/common-page.ts | 2 +- .../js/features/comp/ComboMarkdownEditor.ts | 4 +- web_src/js/features/comp/Cropper.ts | 2 +- web_src/js/features/contributors.ts | 2 +- web_src/js/features/dropzone.ts | 4 +- web_src/js/features/heatmap.ts | 2 +- web_src/js/features/recent-commits.ts | 2 +- web_src/js/features/repo-findfile.ts | 2 +- web_src/js/features/repo-issue-pull.ts | 2 +- web_src/js/features/tribute.ts | 2 +- web_src/js/globals.d.ts | 11 +- web_src/js/globals.ts | 18 +- web_src/js/htmx.ts | 26 - web_src/js/iife.ts | 11 + web_src/js/index-domready.ts | 175 --- web_src/js/index.ts | 207 ++- web_src/js/markup/asciicast.ts | 4 +- web_src/js/markup/math.ts | 4 +- web_src/js/markup/mermaid.ts | 4 +- web_src/js/markup/refissue.ts | 2 +- .../errors.test.ts} | 8 +- web_src/js/modules/errors.ts | 67 + web_src/js/modules/fomantic.ts | 1 - web_src/js/modules/fomantic/base.ts | 1 - web_src/js/modules/fomantic/dimmer.ts | 1 - web_src/js/modules/fomantic/dropdown.ts | 1 - web_src/js/modules/fomantic/modal.ts | 1 - web_src/js/modules/fomantic/tab.ts | 1 - web_src/js/modules/fomantic/transition.ts | 2 - web_src/js/modules/monaco.ts | 17 + web_src/js/modules/sortable.ts | 2 +- web_src/js/modules/worker.ts | 4 +- web_src/js/render/plugins/3d-viewer.ts | 2 +- web_src/js/render/plugins/pdf-viewer.ts | 2 +- web_src/js/standalone/devtest.ts | 1 + .../js/standalone/external-render-iframe.ts | 2 + web_src/js/standalone/swagger.ts | 4 +- web_src/js/utils/testhelper.ts | 2 +- web_src/js/vitest.setup.ts | 16 +- web_src/js/webcomponents/README.md | 2 +- web_src/js/webcomponents/overflow-menu.ts | 123 +- webpack.config.ts | 268 ---- 88 files changed, 1706 insertions(+), 1727 deletions(-) delete mode 100644 .gitpod.yml create mode 100644 modules/public/manifest.go create mode 100644 modules/public/manifest_test.go create mode 100644 modules/public/vitedev.go create mode 100644 vite.config.ts delete mode 100644 web_src/js/htmx.ts create mode 100644 web_src/js/iife.ts delete mode 100644 web_src/js/index-domready.ts rename web_src/js/{bootstrap.test.ts => modules/errors.test.ts} (70%) create mode 100644 web_src/js/modules/errors.ts create mode 100644 web_src/js/modules/monaco.ts delete mode 100644 webpack.config.ts 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/badges/users.svg)](https://www.codetriage.com/go-gitea/gitea "Help Contribute to Open Source") [![](https://opencollective.com/gitea/tiers/backers/badge.svg?label=backers&color=brightgreen)](https://opencollective.com/gitea "Become a backer/sponsor of gitea") [![](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT "License: MIT") -[![Contribute with Gitpod](https://img.shields.io/badge/Contribute%20with-Gitpod-908a85?logo=gitpod&color=green)](https://gitpod.io/#https://github.com/go-gitea/gitea) [![](https://badges.crowdin.net/gitea/localized.svg)](https://translate.gitea.com "Crowdin") [繁體中文](./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/badges/users.svg)](https://www.codetriage.com/go-gitea/gitea "Help Contribute to Open Source") [![](https://opencollective.com/gitea/tiers/backers/badge.svg?label=backers&color=brightgreen)](https://opencollective.com/gitea "Become a backer/sponsor of gitea") [![](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT "License: MIT") -[![Contribute with Gitpod](https://img.shields.io/badge/Contribute%20with-Gitpod-908a85?logo=gitpod&color=green)](https://gitpod.io/#https://github.com/go-gitea/gitea) [![](https://badges.crowdin.net/gitea/localized.svg)](https://translate.gitea.com "Crowdin") [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/badges/users.svg)](https://www.codetriage.com/go-gitea/gitea "Help Contribute to Open Source") [![](https://opencollective.com/gitea/tiers/backers/badge.svg?label=backers&color=brightgreen)](https://opencollective.com/gitea "Become a backer/sponsor of gitea") [![](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT "License: MIT") -[![Contribute with Gitpod](https://img.shields.io/badge/Contribute%20with-Gitpod-908a85?logo=gitpod&color=green)](https://gitpod.io/#https://github.com/go-gitea/gitea) [![](https://badges.crowdin.net/gitea/localized.svg)](https://translate.gitea.com "Crowdin") [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 "
<script></script>
`, respSub.Body.String()) + assert.Equal(t, `
<script></script>
`, 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;