diff --git a/.dockerignore b/.dockerignore index 2a4142088908e..20e2758d3dbe6 100644 --- a/.dockerignore +++ b/.dockerignore @@ -78,7 +78,7 @@ cpu.out /public/js /public/css /public/fonts -/public/img/webpack +/public/img/bundled /vendor /web_src/fomantic/node_modules /web_src/fomantic/build/* diff --git a/.gitignore b/.gitignore index 6851be742c641..a2a4011ef3d9f 100644 --- a/.gitignore +++ b/.gitignore @@ -75,7 +75,7 @@ cpu.out /public/js /public/css /public/fonts -/public/img/webpack +/public/img/bundled /vendor /web_src/fomantic/node_modules /web_src/fomantic/build/* diff --git a/Makefile b/Makefile index 7de96f09fdd18..9eaa69537782c 100644 --- a/Makefile +++ b/Makefile @@ -114,10 +114,10 @@ GO_TEST_PACKAGES ?= $(filter-out $(shell $(GO) list code.gitea.io/gitea/models/m FOMANTIC_WORK_DIR := web_src/fomantic -WEBPACK_SOURCES := $(shell find web_src/js web_src/css -type f) -WEBPACK_CONFIGS := webpack.config.js -WEBPACK_DEST := public/js/index.js public/css/index.css -WEBPACK_DEST_ENTRIES := public/js public/css public/fonts public/img/webpack +VITE_SOURCES := $(shell find web_src/js web_src/css -type f) +VITE_CONFIGS := vite.config.js +VITE_DEST := public/js/index.js public/css/index.css +VITE_DEST_ENTRIES := public/js public/css public/fonts public/img/bundled BINDATA_DEST := modules/public/bindata.go modules/options/bindata.go modules/templates/bindata.go BINDATA_HASH := $(addsuffix .hash,$(BINDATA_DEST)) @@ -228,7 +228,7 @@ help: @echo " - test-e2e[\#TestSpecificName] test end to end using playwright" @echo " - update-js update js dependencies" @echo " - update-py update py dependencies" - @echo " - webpack build webpack files" + @echo " - vite build vite files" @echo " - svg build svg files" @echo " - fomantic build fomantic files" @echo " - generate run \"go generate\"" @@ -273,7 +273,7 @@ node-check: .PHONY: clean-all clean-all: clean - rm -rf $(WEBPACK_DEST_ENTRIES) node_modules + rm -rf $(VITE_DEST_ENTRIES) node_modules .PHONY: clean clean: @@ -434,8 +434,8 @@ watch: .PHONY: watch-frontend watch-frontend: node-check node_modules - @rm -rf $(WEBPACK_DEST_ENTRIES) - NODE_ENV=development npx webpack --watch --progress + @rm -rf $(VITE_DEST_ENTRIES) + NODE_ENV=development npx vite .PHONY: watch-backend watch-backend: go-check @@ -806,7 +806,7 @@ install: $(wildcard *.go) build: frontend backend .PHONY: frontend -frontend: $(WEBPACK_DEST) +frontend: $(VITE_DEST) .PHONY: backend backend: go-check generate-backend $(EXECUTABLE) @@ -952,14 +952,14 @@ fomantic: $(SED_INPLACE) -e 's/\r//g' $(FOMANTIC_WORK_DIR)/build/semantic.css $(FOMANTIC_WORK_DIR)/build/semantic.js rm -f $(FOMANTIC_WORK_DIR)/build/*.min.* -.PHONY: webpack -webpack: $(WEBPACK_DEST) +.PHONY: vite +vite: $(VITE_DEST) -$(WEBPACK_DEST): $(WEBPACK_SOURCES) $(WEBPACK_CONFIGS) package-lock.json +$(VITE_DEST): $(VITE_SOURCES) $(VITE_CONFIGS) package-lock.json @$(MAKE) -s node-check node_modules - rm -rf $(WEBPACK_DEST_ENTRIES) - npx webpack - @touch $(WEBPACK_DEST) + rm -rf $(VITE_DEST_ENTRIES) + npx vite build + @touch $(VITE_DEST) .PHONY: svg svg: node-check | node_modules diff --git a/modules/httplib/proxy.go b/modules/httplib/proxy.go new file mode 100644 index 0000000000000..b00f795bd3ba2 --- /dev/null +++ b/modules/httplib/proxy.go @@ -0,0 +1,42 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package httplib + +import ( + "crypto/tls" + "net/http" + "net/http/httputil" + "net/url" + "strings" + "time" +) + +// MakeReverseProxyHandler creates HTTP handler that reverse-proxies requests to destURL +func MakeReverseProxyHandler(destURL, path string, skipVerify bool) http.Handler { + url, _ := url.Parse(destURL) + + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + proxy := httputil.ReverseProxy{ + Director: func(req *http.Request) { + req.URL.Scheme = url.Scheme + req.URL.Host = url.Host + req.URL.Path = url.Path + strings.TrimPrefix(req.URL.Path, path) + }, + Transport: &http.Transport{ + MaxIdleConns: 5, + IdleConnTimeout: 5 * time.Second, + TLSHandshakeTimeout: 5 * time.Second, + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: skipVerify, + }, + }, + ErrorHandler: func(rw http.ResponseWriter, r *http.Request, err error) { + rw.Header().Set("Content-Type", "text/plain") + _, _ = rw.Write([]byte(`502 Bad Gateway`)) + rw.WriteHeader(http.StatusBadGateway) + }, + } + proxy.ServeHTTP(w, r) + }) +} diff --git a/modules/templates/helper.go b/modules/templates/helper.go index 2b918f42c099e..7b181da272e47 100644 --- a/modules/templates/helper.go +++ b/modules/templates/helper.go @@ -100,6 +100,9 @@ func NewFuncMap() template.FuncMap { "AppDomain": func() string { // documented in mail-templates.md return setting.Domain }, + "RunMode": func() string { + return setting.RunMode + }, "AssetVersion": func() string { return setting.AssetVersion }, diff --git a/modules/web/middleware/data.go b/modules/web/middleware/data.go index 9497dc02e23d3..fb6789de62d55 100644 --- a/modules/web/middleware/data.go +++ b/modules/web/middleware/data.go @@ -59,7 +59,5 @@ func CommonTemplateContextData() ContextData { "EnableSwagger": setting.API.EnableSwagger, "EnableOpenIDSignIn": setting.Service.EnableOpenIDSignIn, "PageStartTime": time.Now(), - - "RunModeIsProd": setting.IsProd, } } diff --git a/package-lock.json b/package-lock.json index 92e4087b08495..9d1c2545c9f3d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "@github/text-expander-element": "2.5.0", "@mcaptcha/vanilla-glue": "0.1.0-alpha-3", "@primer/octicons": "19.4.0", + "@vitejs/plugin-vue": "4.2.3", "@webcomponents/custom-elements": "1.6.0", "add-asset-webpack-plugin": "2.0.1", "ansi_up": "5.2.1", @@ -38,6 +39,7 @@ "monaco-editor-webpack-plugin": "7.0.1", "pdfobject": "2.2.12", "pretty-ms": "8.0.0", + "rollup-plugin-license": "3.0.1", "sortablejs": "1.15.0", "swagger-ui-dist": "5.1.0", "throttle-debounce": "5.0.0", @@ -46,6 +48,8 @@ "toastify-js": "1.12.0", "tributejs": "5.1.3", "uint8-to-base64": "0.2.0", + "vite": "4.4.3", + "vite-plugin-monaco-editor": "1.1.0", "vue": "3.3.4", "vue-bar-graph": "2.0.0", "vue-loader": "17.2.2", @@ -58,7 +62,6 @@ "@eslint-community/eslint-plugin-eslint-comments": "3.2.1", "@playwright/test": "1.35.1", "@stoplight/spectral-cli": "6.8.0", - "@vitejs/plugin-vue": "4.2.3", "eslint": "8.44.0", "eslint-plugin-array-func": "3.1.8", "eslint-plugin-custom-elements": "0.0.8", @@ -1921,7 +1924,6 @@ "version": "4.2.3", "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-4.2.3.tgz", "integrity": "sha512-R6JDUfiZbJA9cMiguQ7jxALsgiprjBeHL5ikpXfJCH62pPHtI+JdJ5xWj6Ev73yXSlYl86+blXn1kZHQ7uElxw==", - "dev": true, "engines": { "node": "^14.18.0 || >=16.0.0" }, @@ -3150,6 +3152,11 @@ "node": ">= 12.0.0" } }, + "node_modules/commenting": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/commenting/-/commenting-1.1.0.tgz", + "integrity": "sha512-YeNK4tavZwtH7jEgK1ZINXzLKm6DZdEMfsaaieOsCAN0S8vsY7UeuO3Q7d/M018EFgE+IeUAuBOKkFccBZsUZA==" + }, "node_modules/commondir": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", @@ -5247,7 +5254,6 @@ "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "dev": true, "hasInstallScript": true, "optional": true, "os": [ @@ -7890,6 +7896,17 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/mlly": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.4.0.tgz", @@ -7902,6 +7919,14 @@ "ufo": "^1.1.2" } }, + "node_modules/moment": { + "version": "2.29.4", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.29.4.tgz", + "integrity": "sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w==", + "engines": { + "node": "*" + } + }, "node_modules/monaco-editor": { "version": "0.40.0", "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.40.0.tgz", @@ -8289,6 +8314,17 @@ "node": ">= 8" } }, + "node_modules/package-name-regex": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/package-name-regex/-/package-name-regex-2.0.6.tgz", + "integrity": "sha512-gFL35q7kbE/zBaPA3UKhp2vSzcPYx2ecbYuwv1ucE9Il6IIgBDweBlH8D68UFGZic2MkllKa2KHCfC1IQBQUYA==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/dword-design" + } + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -9234,7 +9270,6 @@ "version": "2.79.1", "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.79.1.tgz", "integrity": "sha512-uKxbd0IhMZOhjAiD5oAFp7BqvkA4Dv47qpOCtaNvng4HBwdbWtdOh8f5nZNuk2rp51PMGk3bzfWu5oayNEuYnw==", - "dev": true, "bin": { "rollup": "dist/bin/rollup" }, @@ -9245,6 +9280,39 @@ "fsevents": "~2.3.2" } }, + "node_modules/rollup-plugin-license": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/rollup-plugin-license/-/rollup-plugin-license-3.0.1.tgz", + "integrity": "sha512-/lec6Y94Y3wMfTDeYTO/jSXII0GQ/XkDZCiqkMKxyU5D5nGPaxr/2JNYvAgYsoCYuOLGOanKDPjCCQiTT96p7A==", + "dependencies": { + "commenting": "~1.1.0", + "glob": "~7.2.0", + "lodash": "~4.17.21", + "magic-string": "~0.26.2", + "mkdirp": "~1.0.4", + "moment": "~2.29.3", + "package-name-regex": "~2.0.6", + "spdx-expression-validate": "~2.0.0", + "spdx-satisfies": "~5.0.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.0.0 || ^2.0.0 || ^3.0.0" + } + }, + "node_modules/rollup-plugin-license/node_modules/magic-string": { + "version": "0.26.7", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.26.7.tgz", + "integrity": "sha512-hX9XH3ziStPoPhJxLq1syWuZMxbDvGNbVchfrdCtanC7D13888bMFow61x8axrx+GfHLtVeAx2kxL7tTGRl+Ow==", + "dependencies": { + "sourcemap-codec": "^1.4.8" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/rrweb-cssom": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.6.0.tgz", @@ -9651,8 +9719,7 @@ "version": "1.4.8", "resolved": "https://registry.npmjs.org/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz", "integrity": "sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==", - "deprecated": "Please use @jridgewell/sourcemap-codec instead", - "dev": true + "deprecated": "Please use @jridgewell/sourcemap-codec instead" }, "node_modules/spdx-compare": { "version": "1.0.0", @@ -10648,13 +10715,12 @@ } }, "node_modules/vite": { - "version": "4.4.2", - "resolved": "https://registry.npmjs.org/vite/-/vite-4.4.2.tgz", - "integrity": "sha512-zUcsJN+UvdSyHhYa277UHhiJ3iq4hUBwHavOpsNUGsTgjBeoBlK8eDt+iT09pBq0h9/knhG/SPrZiM7cGmg7NA==", - "dev": true, + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-4.4.3.tgz", + "integrity": "sha512-IMnXQXXWgLi5brBQx/4WzDxdzW0X3pjO4nqFJAuNvwKtxzAmPzFE1wszW3VDpAGQJm3RZkm/brzRdyGsnwgJIA==", "dependencies": { "esbuild": "^0.18.10", - "postcss": "^8.4.24", + "postcss": "^8.4.25", "rollup": "^3.25.2" }, "bin": { @@ -10725,6 +10791,14 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/vite-plugin-monaco-editor": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/vite-plugin-monaco-editor/-/vite-plugin-monaco-editor-1.1.0.tgz", + "integrity": "sha512-IvtUqZotrRoVqwT0PBBDIZPNraya3BxN/bfcNfnxZ5rkJiGcNtO5eAOWWSgT7zullIAEqQwxMU83yL9J5k7gww==", + "peerDependencies": { + "monaco-editor": ">=0.33.0" + } + }, "node_modules/vite-string-plugin": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/vite-string-plugin/-/vite-string-plugin-1.1.0.tgz", @@ -10741,7 +10815,6 @@ "cpu": [ "arm" ], - "dev": true, "optional": true, "os": [ "android" @@ -10757,7 +10830,6 @@ "cpu": [ "arm64" ], - "dev": true, "optional": true, "os": [ "android" @@ -10773,7 +10845,6 @@ "cpu": [ "x64" ], - "dev": true, "optional": true, "os": [ "android" @@ -10789,7 +10860,6 @@ "cpu": [ "arm64" ], - "dev": true, "optional": true, "os": [ "darwin" @@ -10805,7 +10875,6 @@ "cpu": [ "x64" ], - "dev": true, "optional": true, "os": [ "darwin" @@ -10821,7 +10890,6 @@ "cpu": [ "arm64" ], - "dev": true, "optional": true, "os": [ "freebsd" @@ -10837,7 +10905,6 @@ "cpu": [ "x64" ], - "dev": true, "optional": true, "os": [ "freebsd" @@ -10853,7 +10920,6 @@ "cpu": [ "arm" ], - "dev": true, "optional": true, "os": [ "linux" @@ -10869,7 +10935,6 @@ "cpu": [ "arm64" ], - "dev": true, "optional": true, "os": [ "linux" @@ -10885,7 +10950,6 @@ "cpu": [ "ia32" ], - "dev": true, "optional": true, "os": [ "linux" @@ -10901,7 +10965,6 @@ "cpu": [ "loong64" ], - "dev": true, "optional": true, "os": [ "linux" @@ -10917,7 +10980,6 @@ "cpu": [ "mips64el" ], - "dev": true, "optional": true, "os": [ "linux" @@ -10933,7 +10995,6 @@ "cpu": [ "ppc64" ], - "dev": true, "optional": true, "os": [ "linux" @@ -10949,7 +11010,6 @@ "cpu": [ "riscv64" ], - "dev": true, "optional": true, "os": [ "linux" @@ -10965,7 +11025,6 @@ "cpu": [ "s390x" ], - "dev": true, "optional": true, "os": [ "linux" @@ -10981,7 +11040,6 @@ "cpu": [ "x64" ], - "dev": true, "optional": true, "os": [ "linux" @@ -10997,7 +11055,6 @@ "cpu": [ "x64" ], - "dev": true, "optional": true, "os": [ "netbsd" @@ -11013,7 +11070,6 @@ "cpu": [ "x64" ], - "dev": true, "optional": true, "os": [ "openbsd" @@ -11029,7 +11085,6 @@ "cpu": [ "x64" ], - "dev": true, "optional": true, "os": [ "sunos" @@ -11045,7 +11100,6 @@ "cpu": [ "arm64" ], - "dev": true, "optional": true, "os": [ "win32" @@ -11061,7 +11115,6 @@ "cpu": [ "ia32" ], - "dev": true, "optional": true, "os": [ "win32" @@ -11077,7 +11130,6 @@ "cpu": [ "x64" ], - "dev": true, "optional": true, "os": [ "win32" @@ -11090,7 +11142,6 @@ "version": "0.18.11", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.18.11.tgz", "integrity": "sha512-i8u6mQF0JKJUlGR3OdFLKldJQMMs8OqM9Cc3UCi9XXziJ9WERM5bfkHaEAy0YAvPRMgqSW55W7xYn84XtEFTtA==", - "dev": true, "hasInstallScript": true, "bin": { "esbuild": "bin/esbuild" @@ -11127,7 +11178,6 @@ "version": "3.26.2", "resolved": "https://registry.npmjs.org/rollup/-/rollup-3.26.2.tgz", "integrity": "sha512-6umBIGVz93er97pMgQO08LuH3m6PUb3jlDUUGFsNJB6VgTCUaDFpupf5JfU30529m/UKOgmiX+uY6Sx8cOYpLA==", - "dev": true, "bin": { "rollup": "dist/bin/rollup" }, diff --git a/package.json b/package.json index 10956595b6891..f7e49fbdd0a97 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "@github/text-expander-element": "2.5.0", "@mcaptcha/vanilla-glue": "0.1.0-alpha-3", "@primer/octicons": "19.4.0", + "@vitejs/plugin-vue": "4.2.3", "@webcomponents/custom-elements": "1.6.0", "add-asset-webpack-plugin": "2.0.1", "ansi_up": "5.2.1", @@ -37,6 +38,7 @@ "monaco-editor-webpack-plugin": "7.0.1", "pdfobject": "2.2.12", "pretty-ms": "8.0.0", + "rollup-plugin-license": "3.0.1", "sortablejs": "1.15.0", "swagger-ui-dist": "5.1.0", "throttle-debounce": "5.0.0", @@ -45,6 +47,8 @@ "toastify-js": "1.12.0", "tributejs": "5.1.3", "uint8-to-base64": "0.2.0", + "vite": "4.4.3", + "vite-plugin-monaco-editor": "1.1.0", "vue": "3.3.4", "vue-bar-graph": "2.0.0", "vue-loader": "17.2.2", @@ -57,7 +61,6 @@ "@eslint-community/eslint-plugin-eslint-comments": "3.2.1", "@playwright/test": "1.35.1", "@stoplight/spectral-cli": "6.8.0", - "@vitejs/plugin-vue": "4.2.3", "eslint": "8.44.0", "eslint-plugin-array-func": "3.1.8", "eslint-plugin-custom-elements": "0.0.8", diff --git a/routers/web/web.go b/routers/web/web.go index 4f5901c0ec661..5a221266f419e 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -6,10 +6,12 @@ package web import ( gocontext "context" "net/http" + "os" "code.gitea.io/gitea/models/perm" "code.gitea.io/gitea/models/unit" "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/httplib" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/metrics" "code.gitea.io/gitea/modules/public" @@ -108,7 +110,22 @@ func Routes() *web.Route { routes := web.NewRoute() routes.Head("/", misc.DummyOK) // for health check - doesn't need to be passed through gzip handler - routes.RouteMethods("/assets/*", "GET, HEAD", CorsHandler(), public.AssetsHandlerFunc("/assets/")) + + if setting.IsProd { // use assets from embed or filesystem + routes.RouteMethods("/assets/*", "GET, HEAD", CorsHandler(), public.AssetsHandlerFunc("/assets/")) + } else { // reverse-proxy asset requests to webpack-dev-server + // this environment variable is intentionally not loaded from ini config + // because webpack also needs to parse it from the environment + port := os.Getenv("GITEA_DEV_FRONTEND_PORT") + if port == "" { + port = "3001" + } + + routes.RouteMethods("/assets/*", "GET, HEAD", CorsHandler(), + httplib.MakeReverseProxyHandler("http://localhost:"+port, "/assets", true), + ) + } + routes.RouteMethods("/avatars/*", "GET, HEAD", storageHandler(setting.Avatar.Storage, "avatars", storage.Avatars)) routes.RouteMethods("/repo-avatars/*", "GET, HEAD", storageHandler(setting.RepoAvatar.Storage, "repo-avatars", storage.RepoAvatars)) routes.RouteMethods("/apple-touch-icon.png", "GET, HEAD", misc.StaticRedirect("/assets/img/apple-touch-icon.png")) diff --git a/templates/base/footer.tmpl b/templates/base/footer.tmpl index e3cac806a4d91..b1d4351fcc4f6 100644 --- a/templates/base/footer.tmpl +++ b/templates/base/footer.tmpl @@ -25,7 +25,16 @@ {{end}} {{end}} - + {{$prefix := ""}} + {{if eq RunMode "dev"}} + {{$prefix = "http://localhost:3001"}} + {{else}} + {{$prefix = AssetUrlPrefix}} + {{end}} + {{if eq RunMode "dev"}} + + {{end}} + {{template "custom/footer" .}} diff --git a/templates/base/head_script.tmpl b/templates/base/head_script.tmpl index c7477ff4c0f2e..b11245d439393 100644 --- a/templates/base/head_script.tmpl +++ b/templates/base/head_script.tmpl @@ -4,13 +4,14 @@ If you are customizing Gitea, please do not change this file. If you introduce mistakes in it, Gitea JavaScript code wouldn't run correctly. */}} - +{{$prefix := ""}} +{{if eq RunMode "dev"}} + {{$prefix = "http://localhost:3001"}} +{{else}} + {{$prefix = AssetUrlPrefix}} +{{end}} + diff --git a/templates/base/head_style.tmpl b/templates/base/head_style.tmpl index 7e8cba2aedde0..a1ed5fba5cbe7 100644 --- a/templates/base/head_style.tmpl +++ b/templates/base/head_style.tmpl @@ -1,8 +1,14 @@ - +{{$prefix := ""}} +{{if eq RunMode "dev"}} + {{$prefix = "http://localhost:3001"}} +{{else}} + {{$prefix = AssetUrlPrefix}} +{{end}} + {{if .IsSigned}} {{if ne .SignedUser.Theme "gitea"}} - + {{end}} {{else if ne DefaultTheme "gitea"}} - + {{end}} diff --git a/vite.config.js b/vite.config.js new file mode 100644 index 0000000000000..a55cdec72ca57 --- /dev/null +++ b/vite.config.js @@ -0,0 +1,145 @@ +import {defineConfig} from 'vite'; +import {stringPlugin} from 'vite-string-plugin'; +import vuePlugin from '@vitejs/plugin-vue'; +import licensePlugin from 'rollup-plugin-license'; +import {fileURLToPath} from 'node:url'; +import {parse, dirname, extname} from 'node:path'; +import {rmSync, mkdirSync, readFileSync} from 'node:fs'; +import {env} from 'node:process'; +import wrapAnsi from 'wrap-ansi'; +import fastGlob from 'fast-glob'; + +const outputDirs = [ + 'public/js', + 'public/css', + 'public/fonts', + 'public/img/bundled', +]; + +const glob = (pattern) => fastGlob.sync(pattern, { + cwd: dirname(fileURLToPath(new URL(import.meta.url))), + absolute: true, +}); + +const themes = {}; +for (const path of glob('web_src/css/themes/*.css')) { + themes[parse(path).name] = path; +} + +function formatLicenseText(licenseText) { + return wrapAnsi(licenseText || '', 80).trim(); +} + +function cleanDirsPlugin() { + return { + name: 'clean-dirs-plugin', + buildStart: () => { + for (const dir of outputDirs) { + rmSync(new URL(dir, import.meta.url), {recursive: true, force: true}); + mkdirSync(new URL(dir, import.meta.url), {recursive: true}); + } + } + }; +} + +export default defineConfig(({mode}) => { + const isProduction = mode !== 'development'; + + let sourceMapEnabled; + if ('ENABLE_SOURCEMAP' in env) { + sourceMapEnabled = env.ENABLE_SOURCEMAP === 'true'; + } else { + sourceMapEnabled = !isProduction; + } + + return { + root: dirname(fileURLToPath(new URL(import.meta.url))), + base: '/', + publicDir: false, + logLevel: 'info', + clearScreen: false, + appType: 'mpa', + open: false, + build: { + outDir: fileURLToPath(new URL('public', import.meta.url)), + emptyOutDir: false, + modulePreload: { + polyfill: false, + }, + rollupOptions: { + input: { + index: fileURLToPath(new URL('web_src/js/entry/index.js', import.meta.url)), + webcomponents: fileURLToPath(new URL('web_src/js/entry/webcomponents.js', import.meta.url)), + swagger: fileURLToPath(new URL('web_src/js/entry/swagger.js', import.meta.url)), + 'eventsource.sharedworker': fileURLToPath(new URL('web_src/js/entry/eventsource.sharedworker.js', import.meta.url)), + ...(!isProduction && { + devtest: fileURLToPath(new URL('web_src/js/entry/devtest.js', import.meta.url)), + }), + ...themes, + }, + output: { + entryFileNames: 'js/[name].js', + chunkFileNames: 'js/[name].[hash:8].js', + assetFileNames: ({name}) => { + name = name.split('?')[0]; + if (name === 'index.css') return `css/${name}`; + if (name.startsWith('theme')) return `css/${name}`; + if (/\.js$/i.test(name)) return `css/[name].[hash:8].js`; + if (/\.css$/i.test(name)) return `css/[name].[hash:8].css`; + if (/\.(ttf|woff2?)$/i.test(name)) return `fonts/[name].[hash:8]${extname(name)}`; + if (/\.png$/i.test(name)) return `img/bundled/[name].[hash:8]${extname(name)}`; + if (name === 'monaco') return 'js/[name].[hash:8].js'; + throw new Error(`Unable to match asset ${name} to path, please add it in vite.config.js`); + }, + }, + }, + minify: false, + target: 'modules', + chunkSizeWarningLimit: Infinity, + assetsInlineLimit: 32768, + reportCompressedSize: false, + sourcemap: sourceMapEnabled, + }, + css: { + transformer: 'lightningcss', + }, + esbuild: { + legalComments: 'none', + }, + experimental: { + renderBuiltUrl: () => ({relative: true}), + }, + server: { + port: parseInt(env.GITEA_DEV_FRONTEND_PORT) || 3001, + strictPort: true, + open: false, + }, + plugins: [ + cleanDirsPlugin(), + stringPlugin(), + vuePlugin(), + isProduction && licensePlugin({ + thirdParty: { + output: { + file: fileURLToPath(new URL('public/js/licenses.txt', import.meta.url)), + template(dependencies) { + const line = '-'.repeat(80); + const goJson = readFileSync('assets/go-licenses.json', 'utf8'); + const goModules = JSON.parse(goJson).map(({name, licenseText}) => { + return {name, body: formatLicenseText(licenseText)}; + }); + const jsModules = dependencies.map(({name, version, licenseText}) => { + return {name, version, body: formatLicenseText(licenseText)}; + }); + + const modules = [...goModules, ...jsModules].sort((a, b) => a.name.localeCompare(b.name)); + return modules.map(({name, version, body}) => { + return `${line}\n${name}@${version}\n${line}\n${body}`; + }).join('\n'); + }, + }, + }, + }), + ], + }; +}); diff --git a/web_src/js/bootstrap.js b/web_src/js/bootstrap.js index f0b020ce1cf62..3b3d527bedefe 100644 --- a/web_src/js/bootstrap.js +++ b/web_src/js/bootstrap.js @@ -1,11 +1,9 @@ -import {joinPaths} from './utils.js'; - // 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. // This sets up the URL prefix used in webpack's chunk loading. // This file must be imported before any lazy-loading is being attempted. -__webpack_public_path__ = joinPaths(window?.config?.assetUrlPrefix ?? '/', '/'); +// __webpack_public_path__ = joinPaths(window?.config?.assetUrlPrefix ?? '/', '/'); export function showGlobalErrorMessage(msg) { const pageContent = document.querySelector('.page-content'); @@ -40,7 +38,12 @@ function initGlobalErrorHandler() { processWindowErrorEvent(e); } // then, change _globalHandlerErrors to an object with push method, to process further error events directly - window._globalHandlerErrors = {'push': (e) => processWindowErrorEvent(e)}; + window._globalHandlerErrors = new Proxy([], { + set: function(_target, _property, value) { + processWindowErrorEvent(value) + return true; + } + }); } initGlobalErrorHandler(); diff --git a/web_src/js/entry/devtest.js b/web_src/js/entry/devtest.js new file mode 100644 index 0000000000000..eec7b3cf0872c --- /dev/null +++ b/web_src/js/entry/devtest.js @@ -0,0 +1,2 @@ +import '../standalone/devtest.js'; +import '../../css/standalone/devtest.css'; diff --git a/web_src/js/entry/eventsource.sharedworker.js b/web_src/js/entry/eventsource.sharedworker.js new file mode 100644 index 0000000000000..c6bd7894744c9 --- /dev/null +++ b/web_src/js/entry/eventsource.sharedworker.js @@ -0,0 +1 @@ +import '../features/eventsource.sharedworker.js'; diff --git a/web_src/js/entry/index.js b/web_src/js/entry/index.js new file mode 100644 index 0000000000000..6bed1303a2ae3 --- /dev/null +++ b/web_src/js/entry/index.js @@ -0,0 +1,6 @@ +import '../jquery.js'; +import '../../fomantic/build/semantic.js'; +import '../index.js'; +import 'easymde/dist/easymde.min.css'; +import '../../fomantic/build/semantic.css'; +import '../../css/index.css'; diff --git a/web_src/js/entry/swagger.js b/web_src/js/entry/swagger.js new file mode 100644 index 0000000000000..5b17717c091c5 --- /dev/null +++ b/web_src/js/entry/swagger.js @@ -0,0 +1,2 @@ +import '../standalone/swagger.js'; +import '../../css/standalone/swagger.css'; diff --git a/web_src/js/webcomponents/webcomponents.js b/web_src/js/entry/webcomponents.js similarity index 73% rename from web_src/js/webcomponents/webcomponents.js rename to web_src/js/entry/webcomponents.js index 123607282bc78..21340c43fec34 100644 --- a/web_src/js/webcomponents/webcomponents.js +++ b/web_src/js/entry/webcomponents.js @@ -1,3 +1,3 @@ import '@webcomponents/custom-elements'; // polyfill for some browsers like Pale Moon import '@github/relative-time-element'; -import './GiteaOriginUrl.js'; +import '../webcomponents/GiteaOriginUrl.js'; diff --git a/web_src/js/features/codeeditor.js b/web_src/js/features/codeeditor.js index 7dbbcd3dd62a9..4b4d9c37a2848 100644 --- a/web_src/js/features/codeeditor.js +++ b/web_src/js/features/codeeditor.js @@ -59,7 +59,7 @@ function exportEditor(editor) { } export async function createMonaco(textarea, filename, editorOpts) { - const monaco = await import(/* webpackChunkName: "monaco" */'monaco-editor'); + const monaco = await import('../modules/monaco.js'); initLanguages(monaco); let {language, ...other} = editorOpts; diff --git a/web_src/js/features/common-global.js b/web_src/js/features/common-global.js index 8ee5ce25bfc9e..4add7678a40ab 100644 --- a/web_src/js/features/common-global.js +++ b/web_src/js/features/common-global.js @@ -1,5 +1,5 @@ import $ from 'jquery'; -import 'jquery.are-you-sure'; +import '../vendor/ays.js'; import {createDropzone} from './dropzone.js'; import {initCompColorPicker} from './comp/ColorPicker.js'; import {showGlobalErrorMessage} from '../bootstrap.js'; diff --git a/web_src/js/features/notification.js b/web_src/js/features/notification.js index 4dcf02d2dca90..fca5016aba5ab 100644 --- a/web_src/js/features/notification.js +++ b/web_src/js/features/notification.js @@ -81,7 +81,7 @@ export function initNotificationCount() { if (notificationSettings.EventSourceUpdateTime > 0 && window.EventSource && window.SharedWorker) { // Try to connect to the event source via the shared worker first - const worker = new SharedWorker(`${__webpack_public_path__}js/eventsource.sharedworker.js?v=${assetVersionEncoded}`, 'notification-worker'); + const worker = new SharedWorker(`/assets/js/eventsource.sharedworker.js?v=${assetVersionEncoded}`, 'notification-worker'); worker.addEventListener('error', (event) => { console.error('worker error', event); }); diff --git a/web_src/js/features/stopwatch.js b/web_src/js/features/stopwatch.js index f43014fec5b7d..ab5a338f74192 100644 --- a/web_src/js/features/stopwatch.js +++ b/web_src/js/features/stopwatch.js @@ -43,7 +43,7 @@ export function initStopwatch() { // if the browser supports EventSource and SharedWorker, use it instead of the periodic poller if (notificationSettings.EventSourceUpdateTime > 0 && window.EventSource && window.SharedWorker) { // Try to connect to the event source via the shared worker first - const worker = new SharedWorker(`${__webpack_public_path__}js/eventsource.sharedworker.js?v=${assetVersionEncoded}`, 'notification-worker'); + const worker = new SharedWorker(`/assets/js/eventsource.sharedworker.js?v=${assetVersionEncoded}`, 'notification-worker'); worker.addEventListener('error', (event) => { console.error('worker error', event); }); diff --git a/web_src/js/modules/monaco.js b/web_src/js/modules/monaco.js new file mode 100644 index 0000000000000..9211c913906f0 --- /dev/null +++ b/web_src/js/modules/monaco.js @@ -0,0 +1,27 @@ +import * as monaco from 'monaco-editor/esm/vs/editor/editor.api.js'; +import 'monaco-editor/esm/vs/editor/editor.all.js'; +import editorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker&inline'; +import jsonWorker from 'monaco-editor/esm/vs/language/json/json.worker?worker&inline'; +import cssWorker from 'monaco-editor/esm/vs/language/css/css.worker?worker&inline'; +import htmlWorker from 'monaco-editor/esm/vs/language/html/html.worker?worker&inline'; +import tsWorker from 'monaco-editor/esm/vs/language/typescript/ts.worker?worker&inline'; + +window.MonacoEnvironment = { + getWorker(_, label) { + 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 default monaco; diff --git a/web_src/js/vendor/ays.js b/web_src/js/vendor/ays.js new file mode 100644 index 0000000000000..67711a0150733 --- /dev/null +++ b/web_src/js/vendor/ays.js @@ -0,0 +1,181 @@ +/* copy of https://github.com/codedance/jquery.AreYouSure/ made to work in strict mode */ +(function($) { + + $.fn.areYouSure = function(options) { + + var settings = $.extend( + { + 'message' : 'You have unsaved changes!', + 'dirtyClass' : 'dirty', + 'change' : null, + 'silent' : false, + 'addRemoveFieldsMarksDirty' : false, + 'fieldEvents' : 'change keyup propertychange input', + 'fieldSelector': ":input:not(input[type=submit]):not(input[type=button])" + }, options); + + var getValue = function($field) { + if ($field.hasClass('ays-ignore') + || $field.hasClass('aysIgnore') + || $field.attr('data-ays-ignore') + || $field.attr('name') === undefined) { + return null; + } + + if ($field.is(':disabled')) { + return 'ays-disabled'; + } + + var val; + var type = $field.attr('type'); + if ($field.is('select')) { + type = 'select'; + } + + switch (type) { + case 'checkbox': + case 'radio': + val = $field.is(':checked'); + break; + case 'select': + val = ''; + $field.find('option').each(function(o) { + var $option = $(this); + if ($option.is(':selected')) { + val += $option.val(); + } + }); + break; + default: + val = $field.val(); + } + + return val; + }; + + var storeOrigValue = function($field) { + $field.data('ays-orig', getValue($field)); + }; + + var checkForm = function(evt) { + + var isFieldDirty = function($field) { + var origValue = $field.data('ays-orig'); + if (undefined === origValue) { + return false; + } + return (getValue($field) != origValue); + }; + + var $form = ($(this).is('form')) + ? $(this) + : $(this).parents('form'); + + // Test on the target first as it's the most likely to be dirty + if (isFieldDirty($(evt.target))) { + setDirtyStatus($form, true); + return; + } + + const $fields = $form.find(settings.fieldSelector); + + if (settings.addRemoveFieldsMarksDirty) { + // Check if field count has changed + var origCount = $form.data("ays-orig-field-count"); + if (origCount != $fields.length) { + setDirtyStatus($form, true); + return; + } + } + + // Brute force - check each field + var isDirty = false; + $fields.each(function() { + var $field = $(this); + if (isFieldDirty($field)) { + isDirty = true; + return false; // break + } + }); + + setDirtyStatus($form, isDirty); + }; + + var initForm = function($form) { + var fields = $form.find(settings.fieldSelector); + $(fields).each(function() { storeOrigValue($(this)); }); + $(fields).unbind(settings.fieldEvents, checkForm); + $(fields).bind(settings.fieldEvents, checkForm); + $form.data("ays-orig-field-count", $(fields).length); + setDirtyStatus($form, false); + }; + + var setDirtyStatus = function($form, isDirty) { + var changed = isDirty != $form.hasClass(settings.dirtyClass); + $form.toggleClass(settings.dirtyClass, isDirty); + + // Fire change event if required + if (changed) { + if (settings.change) settings.change.call($form, $form); + + if (isDirty) $form.trigger('dirty.areYouSure', [$form]); + if (!isDirty) $form.trigger('clean.areYouSure', [$form]); + $form.trigger('change.areYouSure', [$form]); + } + }; + + var rescan = function() { + var $form = $(this); + var fields = $form.find(settings.fieldSelector); + $(fields).each(function() { + var $field = $(this); + if (!$field.data('ays-orig')) { + storeOrigValue($field); + $field.bind(settings.fieldEvents, checkForm); + } + }); + // Check for changes while we're here + $form.trigger('checkform.areYouSure'); + }; + + var reinitialize = function() { + initForm($(this)); + } + + if (!settings.silent && !window.aysUnloadSet) { + window.aysUnloadSet = true; + $(window).bind('beforeunload', function() { + const $dirtyForms = $("form").filter('.' + settings.dirtyClass); + if ($dirtyForms.length == 0) { + return; + } + // Prevent multiple prompts - seen on Chrome and IE + if (navigator.userAgent.toLowerCase().match(/msie|chrome/)) { + if (window.aysHasPrompted) { + return; + } + window.aysHasPrompted = true; + window.setTimeout(function() {window.aysHasPrompted = false;}, 900); + } + return settings.message; + }); + } + + return this.each(function(elem) { + if (!$(this).is('form')) { + return; + } + var $form = $(this); + + $form.submit(function() { + $form.removeClass(settings.dirtyClass); + }); + $form.bind('reset', function() { setDirtyStatus($form, false); }); + // Add a custom events + $form.bind('rescan.areYouSure', rescan); + $form.bind('reinitialize.areYouSure', reinitialize); + $form.bind('checkform.areYouSure', checkForm); + initForm($form); + }); + }; +})(jQuery);