Migrate from webpack to vite (#37002)
Replace webpack with Vite 8 as the frontend bundler. Frontend build is around 3-4 times faster than before. Will work on all platforms including riscv64 (via wasm). `iife.js` is a classic render-blocking script in `<head>` (handles web components/early DOM setup). `index.js` is loaded as a `type="module"` script in the footer. All other JS chunks are also module scripts (supported in all browsers since 2018). Entry filenames are content-hashed (e.g. `index.C6Z2MRVQ.js`) and resolved at runtime via the Vite manifest, eliminating the `?v=` cache busting (which was unreliable in some scenarios like vscode dev build). Replaces: https://github.com/go-gitea/gitea/pull/36896 Fixes: https://github.com/go-gitea/gitea/issues/17793 Signed-off-by: silverwind <me@silverwind.io> Signed-off-by: wxiaoguang <wxiaoguang@gmail.com> Co-authored-by: Claude (Opus 4.6) <noreply@anthropic.com> Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com> Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
This commit is contained in:
@@ -20,7 +20,6 @@
|
|||||||
"customizations": {
|
"customizations": {
|
||||||
"vscode": {
|
"vscode": {
|
||||||
"settings": {},
|
"settings": {},
|
||||||
// same extensions as Gitpod, should match /.gitpod.yml
|
|
||||||
"extensions": [
|
"extensions": [
|
||||||
"editorconfig.editorconfig",
|
"editorconfig.editorconfig",
|
||||||
"dbaeumer.vscode-eslint",
|
"dbaeumer.vscode-eslint",
|
||||||
|
|||||||
1
.github/labeler.yml
vendored
1
.github/labeler.yml
vendored
@@ -43,7 +43,6 @@ modifies/internal:
|
|||||||
- ".editorconfig"
|
- ".editorconfig"
|
||||||
- ".eslintrc.cjs"
|
- ".eslintrc.cjs"
|
||||||
- ".golangci.yml"
|
- ".golangci.yml"
|
||||||
- ".gitpod.yml"
|
|
||||||
- ".markdownlint.yaml"
|
- ".markdownlint.yaml"
|
||||||
- ".spectral.yaml"
|
- ".spectral.yaml"
|
||||||
- "stylelint.config.*"
|
- "stylelint.config.*"
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -79,6 +79,7 @@ cpu.out
|
|||||||
/yarn-error.log
|
/yarn-error.log
|
||||||
/npm-debug.log*
|
/npm-debug.log*
|
||||||
/.pnpm-store
|
/.pnpm-store
|
||||||
|
/public/assets/.vite
|
||||||
/public/assets/js
|
/public/assets/js
|
||||||
/public/assets/css
|
/public/assets/css
|
||||||
/public/assets/fonts
|
/public/assets/fonts
|
||||||
@@ -87,8 +88,6 @@ cpu.out
|
|||||||
/VERSION
|
/VERSION
|
||||||
/.air
|
/.air
|
||||||
|
|
||||||
# Files and folders that were previously generated
|
|
||||||
/public/assets/img/webpack
|
|
||||||
|
|
||||||
# Snapcraft
|
# Snapcraft
|
||||||
/gitea_a*.txt
|
/gitea_a*.txt
|
||||||
|
|||||||
51
.gitpod.yml
51
.gitpod.yml
@@ -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
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
# syntax=docker/dockerfile:1
|
# syntax=docker/dockerfile:1
|
||||||
# Build frontend on the native platform to avoid QEMU-related issues with esbuild/webpack
|
# Build frontend on the native platform to avoid QEMU-related issues with nodejs ecosystem
|
||||||
FROM --platform=$BUILDPLATFORM docker.io/library/golang:1.26-alpine3.23 AS frontend-build
|
FROM --platform=$BUILDPLATFORM docker.io/library/golang:1.26-alpine3.23 AS frontend-build
|
||||||
RUN apk --no-cache add build-base git nodejs pnpm
|
RUN apk --no-cache add build-base git nodejs pnpm
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
# syntax=docker/dockerfile:1
|
# syntax=docker/dockerfile:1
|
||||||
# Build frontend on the native platform to avoid QEMU-related issues with esbuild/webpack
|
# Build frontend on the native platform to avoid QEMU-related issues with nodejs ecosystem
|
||||||
FROM --platform=$BUILDPLATFORM docker.io/library/golang:1.26-alpine3.23 AS frontend-build
|
FROM --platform=$BUILDPLATFORM docker.io/library/golang:1.26-alpine3.23 AS frontend-build
|
||||||
RUN apk --no-cache add build-base git nodejs pnpm
|
RUN apk --no-cache add build-base git nodejs pnpm
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
|
|||||||
33
Makefile
33
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/))
|
GO_TEST_PACKAGES ?= $(filter-out $(shell $(GO) list code.gitea.io/gitea/models/migrations/...) code.gitea.io/gitea/tests/integration/migration-test code.gitea.io/gitea/tests code.gitea.io/gitea/tests/integration,$(shell $(GO) list ./... | grep -v /vendor/))
|
||||||
MIGRATE_TEST_PACKAGES ?= $(shell $(GO) list code.gitea.io/gitea/models/migrations/...)
|
MIGRATE_TEST_PACKAGES ?= $(shell $(GO) list code.gitea.io/gitea/models/migrations/...)
|
||||||
|
|
||||||
WEBPACK_SOURCES := $(shell find web_src/js web_src/css -type f)
|
FRONTEND_SOURCES := $(shell find web_src/js web_src/css -type f)
|
||||||
WEBPACK_CONFIGS := webpack.config.ts tailwind.config.ts
|
FRONTEND_CONFIGS := vite.config.ts tailwind.config.ts
|
||||||
WEBPACK_DEST := public/assets/js/index.js public/assets/css/index.css
|
FRONTEND_DEST := public/assets/.vite/manifest.json
|
||||||
WEBPACK_DEST_ENTRIES := public/assets/js public/assets/css public/assets/fonts
|
FRONTEND_DEST_ENTRIES := public/assets/js public/assets/css public/assets/fonts public/assets/.vite
|
||||||
|
|
||||||
BINDATA_DEST_WILDCARD := modules/migration/bindata.* modules/public/bindata.* modules/options/bindata.* modules/templates/bindata.*
|
BINDATA_DEST_WILDCARD := modules/migration/bindata.* modules/public/bindata.* modules/options/bindata.* modules/templates/bindata.*
|
||||||
|
|
||||||
@@ -199,7 +199,7 @@ git-check:
|
|||||||
|
|
||||||
.PHONY: clean-all
|
.PHONY: clean-all
|
||||||
clean-all: clean ## delete backend, frontend and integration files
|
clean-all: clean ## delete backend, frontend and integration files
|
||||||
rm -rf $(WEBPACK_DEST_ENTRIES) node_modules
|
rm -rf $(FRONTEND_DEST_ENTRIES) node_modules
|
||||||
|
|
||||||
.PHONY: clean
|
.PHONY: clean
|
||||||
clean: ## delete backend and integration files
|
clean: ## delete backend and integration files
|
||||||
@@ -380,9 +380,8 @@ watch: ## watch everything and continuously rebuild
|
|||||||
@bash tools/watch.sh
|
@bash tools/watch.sh
|
||||||
|
|
||||||
.PHONY: watch-frontend
|
.PHONY: watch-frontend
|
||||||
watch-frontend: node_modules ## watch frontend files and continuously rebuild
|
watch-frontend: node_modules ## start vite dev server for frontend
|
||||||
@rm -rf $(WEBPACK_DEST_ENTRIES)
|
NODE_ENV=development $(NODE_VARS) pnpm exec vite
|
||||||
NODE_ENV=development $(NODE_VARS) pnpm exec webpack --watch --progress
|
|
||||||
|
|
||||||
.PHONY: watch-backend
|
.PHONY: watch-backend
|
||||||
watch-backend: ## watch backend files and continuously rebuild
|
watch-backend: ## watch backend files and continuously rebuild
|
||||||
@@ -645,7 +644,7 @@ install: $(wildcard *.go)
|
|||||||
build: frontend backend ## build everything
|
build: frontend backend ## build everything
|
||||||
|
|
||||||
.PHONY: frontend
|
.PHONY: frontend
|
||||||
frontend: $(WEBPACK_DEST) ## build frontend files
|
frontend: $(FRONTEND_DEST) ## build frontend files
|
||||||
|
|
||||||
.PHONY: backend
|
.PHONY: backend
|
||||||
backend: generate-backend $(EXECUTABLE) ## build backend files
|
backend: generate-backend $(EXECUTABLE) ## build backend files
|
||||||
@@ -672,7 +671,7 @@ ifneq ($(and $(STATIC),$(findstring pam,$(TAGS))),)
|
|||||||
endif
|
endif
|
||||||
CGO_ENABLED="$(CGO_ENABLED)" CGO_CFLAGS="$(CGO_CFLAGS)" $(GO) build $(GOFLAGS) $(EXTRA_GOFLAGS) -tags '$(TAGS)' -ldflags '-s -w $(EXTLDFLAGS) $(LDFLAGS)' -o $@
|
CGO_ENABLED="$(CGO_ENABLED)" CGO_CFLAGS="$(CGO_CFLAGS)" $(GO) build $(GOFLAGS) $(EXTRA_GOFLAGS) -tags '$(TAGS)' -ldflags '-s -w $(EXTLDFLAGS) $(LDFLAGS)' -o $@
|
||||||
|
|
||||||
$(EXECUTABLE_E2E): $(GO_SOURCES) $(WEBPACK_DEST)
|
$(EXECUTABLE_E2E): $(GO_SOURCES) $(FRONTEND_DEST)
|
||||||
CGO_ENABLED=1 $(GO) build $(GOFLAGS) $(EXTRA_GOFLAGS) -tags '$(TEST_TAGS)' -ldflags '-s -w $(EXTLDFLAGS) $(LDFLAGS)' -o $@
|
CGO_ENABLED=1 $(GO) build $(GOFLAGS) $(EXTRA_GOFLAGS) -tags '$(TEST_TAGS)' -ldflags '-s -w $(EXTLDFLAGS) $(LDFLAGS)' -o $@
|
||||||
|
|
||||||
.PHONY: release
|
.PHONY: release
|
||||||
@@ -776,15 +775,15 @@ update-py: node_modules ## update py dependencies
|
|||||||
uv sync
|
uv sync
|
||||||
@touch .venv
|
@touch .venv
|
||||||
|
|
||||||
.PHONY: webpack
|
.PHONY: vite
|
||||||
webpack: $(WEBPACK_DEST) ## build webpack files
|
vite: $(FRONTEND_DEST) ## build vite files
|
||||||
|
|
||||||
$(WEBPACK_DEST): $(WEBPACK_SOURCES) $(WEBPACK_CONFIGS) pnpm-lock.yaml
|
$(FRONTEND_DEST): $(FRONTEND_SOURCES) $(FRONTEND_CONFIGS) pnpm-lock.yaml
|
||||||
@$(MAKE) -s node_modules
|
@$(MAKE) -s node_modules
|
||||||
@rm -rf $(WEBPACK_DEST_ENTRIES)
|
@rm -rf $(FRONTEND_DEST_ENTRIES)
|
||||||
@echo "Running webpack..."
|
@echo "Running vite build..."
|
||||||
@BROWSERSLIST_IGNORE_OLD_DATA=true $(NODE_VARS) pnpm exec webpack
|
@$(NODE_VARS) pnpm exec vite build
|
||||||
@touch $(WEBPACK_DEST)
|
@touch $(FRONTEND_DEST)
|
||||||
|
|
||||||
.PHONY: svg
|
.PHONY: svg
|
||||||
svg: node_modules ## build svg files
|
svg: node_modules ## build svg files
|
||||||
|
|||||||
@@ -8,7 +8,6 @@
|
|||||||
[](https://www.codetriage.com/go-gitea/gitea "Help Contribute to Open Source")
|
[](https://www.codetriage.com/go-gitea/gitea "Help Contribute to Open Source")
|
||||||
[](https://opencollective.com/gitea "Become a backer/sponsor of gitea")
|
[](https://opencollective.com/gitea "Become a backer/sponsor of gitea")
|
||||||
[](https://opensource.org/licenses/MIT "License: MIT")
|
[](https://opensource.org/licenses/MIT "License: MIT")
|
||||||
[](https://gitpod.io/#https://github.com/go-gitea/gitea)
|
|
||||||
[](https://translate.gitea.com "Crowdin")
|
[](https://translate.gitea.com "Crowdin")
|
||||||
|
|
||||||
[繁體中文](./README.zh-tw.md) | [简体中文](./README.zh-cn.md)
|
[繁體中文](./README.zh-tw.md) | [简体中文](./README.zh-cn.md)
|
||||||
|
|||||||
@@ -8,7 +8,6 @@
|
|||||||
[](https://www.codetriage.com/go-gitea/gitea "Help Contribute to Open Source")
|
[](https://www.codetriage.com/go-gitea/gitea "Help Contribute to Open Source")
|
||||||
[](https://opencollective.com/gitea "Become a backer/sponsor of gitea")
|
[](https://opencollective.com/gitea "Become a backer/sponsor of gitea")
|
||||||
[](https://opensource.org/licenses/MIT "License: MIT")
|
[](https://opensource.org/licenses/MIT "License: MIT")
|
||||||
[](https://gitpod.io/#https://github.com/go-gitea/gitea)
|
|
||||||
[](https://translate.gitea.com "Crowdin")
|
[](https://translate.gitea.com "Crowdin")
|
||||||
|
|
||||||
[English](./README.md) | [繁體中文](./README.zh-tw.md)
|
[English](./README.md) | [繁體中文](./README.zh-tw.md)
|
||||||
|
|||||||
@@ -8,7 +8,6 @@
|
|||||||
[](https://www.codetriage.com/go-gitea/gitea "Help Contribute to Open Source")
|
[](https://www.codetriage.com/go-gitea/gitea "Help Contribute to Open Source")
|
||||||
[](https://opencollective.com/gitea "Become a backer/sponsor of gitea")
|
[](https://opencollective.com/gitea "Become a backer/sponsor of gitea")
|
||||||
[](https://opensource.org/licenses/MIT "License: MIT")
|
[](https://opensource.org/licenses/MIT "License: MIT")
|
||||||
[](https://gitpod.io/#https://github.com/go-gitea/gitea)
|
|
||||||
[](https://translate.gitea.com "Crowdin")
|
[](https://translate.gitea.com "Crowdin")
|
||||||
|
|
||||||
[English](./README.md) | [简体中文](./README.zh-cn.md)
|
[English](./README.md) | [简体中文](./README.zh-cn.md)
|
||||||
|
|||||||
@@ -572,7 +572,11 @@ export default defineConfig([
|
|||||||
'no-restricted-exports': [0],
|
'no-restricted-exports': [0],
|
||||||
'no-restricted-globals': [2, ...restrictedGlobals],
|
'no-restricted-globals': [2, ...restrictedGlobals],
|
||||||
'no-restricted-properties': [2, ...restrictedProperties],
|
'no-restricted-properties': [2, ...restrictedProperties],
|
||||||
'no-restricted-imports': [0],
|
'no-restricted-imports': [2, {paths: [
|
||||||
|
{name: 'jquery', message: 'Use the global $ instead', allowTypeImports: true},
|
||||||
|
{name: 'htmx.org', message: 'Use the global htmx instead', allowTypeImports: true},
|
||||||
|
{name: 'idiomorph/htmx', message: 'Loaded in globals.ts', allowTypeImports: true},
|
||||||
|
]}],
|
||||||
'no-restricted-syntax': [2, 'WithStatement', 'ForInStatement', 'LabeledStatement', 'SequenceExpression'],
|
'no-restricted-syntax': [2, 'WithStatement', 'ForInStatement', 'LabeledStatement', 'SequenceExpression'],
|
||||||
'no-return-assign': [0],
|
'no-return-assign': [0],
|
||||||
'no-script-url': [2],
|
'no-script-url': [2],
|
||||||
@@ -1014,6 +1018,6 @@ export default defineConfig([
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
files: ['web_src/**/*'],
|
files: ['web_src/**/*'],
|
||||||
languageOptions: {globals: {...globals.browser, ...globals.webpack}},
|
languageOptions: {globals: {...globals.browser, ...globals.jquery, htmx: false}},
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -12,7 +12,12 @@ import (
|
|||||||
|
|
||||||
func newHTTPServer(network, address, name string, handler http.Handler) (*Server, ServeFunction) {
|
func newHTTPServer(network, address, name string, handler http.Handler) (*Server, ServeFunction) {
|
||||||
server := NewServer(network, address, name)
|
server := NewServer(network, address, name)
|
||||||
|
protocols := http.Protocols{}
|
||||||
|
protocols.SetHTTP1(true)
|
||||||
|
protocols.SetHTTP2(true) // HTTP/2 can only be used when Gitea is configured to use TLS
|
||||||
|
protocols.SetUnencryptedHTTP2(true) // Allow HTTP/2 without TLS, in case Gitea is behind a reverse proxy
|
||||||
httpServer := http.Server{
|
httpServer := http.Server{
|
||||||
|
Protocols: &protocols,
|
||||||
Handler: handler,
|
Handler: handler,
|
||||||
BaseContext: func(net.Listener) context.Context { return GetManager().HammerContext() },
|
BaseContext: func(net.Listener) context.Context { return GetManager().HammerContext() },
|
||||||
}
|
}
|
||||||
|
|||||||
11
modules/markup/external/openapi.go
vendored
11
modules/markup/external/openapi.go
vendored
@@ -9,6 +9,7 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
|
|
||||||
"code.gitea.io/gitea/modules/markup"
|
"code.gitea.io/gitea/modules/markup"
|
||||||
|
"code.gitea.io/gitea/modules/public"
|
||||||
"code.gitea.io/gitea/modules/setting"
|
"code.gitea.io/gitea/modules/setting"
|
||||||
"code.gitea.io/gitea/modules/util"
|
"code.gitea.io/gitea/modules/util"
|
||||||
)
|
)
|
||||||
@@ -61,19 +62,17 @@ func (p *openAPIRenderer) Render(ctx *markup.RenderContext, input io.Reader, out
|
|||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<link rel="stylesheet" href="%s/assets/css/swagger.css?v=%s">
|
<link rel="stylesheet" href="%s">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="swagger-ui"><textarea class="swagger-spec-content" data-spec-filename="%s">%s</textarea></div>
|
<div id="swagger-ui"><textarea class="swagger-spec-content" data-spec-filename="%s">%s</textarea></div>
|
||||||
<script src="%s/assets/js/swagger.js?v=%s"></script>
|
<script type="module" src="%s"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>`,
|
</html>`,
|
||||||
setting.StaticURLPrefix,
|
public.AssetURI("css/swagger.css"),
|
||||||
setting.AssetVersion,
|
|
||||||
html.EscapeString(ctx.RenderOptions.RelativePath),
|
html.EscapeString(ctx.RenderOptions.RelativePath),
|
||||||
html.EscapeString(util.UnsafeBytesToString(content)),
|
html.EscapeString(util.UnsafeBytesToString(content)),
|
||||||
setting.StaticURLPrefix,
|
public.AssetURI("js/swagger.js"),
|
||||||
setting.AssetVersion,
|
|
||||||
))
|
))
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import (
|
|||||||
|
|
||||||
"code.gitea.io/gitea/modules/htmlutil"
|
"code.gitea.io/gitea/modules/htmlutil"
|
||||||
"code.gitea.io/gitea/modules/markup/internal"
|
"code.gitea.io/gitea/modules/markup/internal"
|
||||||
|
"code.gitea.io/gitea/modules/public"
|
||||||
"code.gitea.io/gitea/modules/setting"
|
"code.gitea.io/gitea/modules/setting"
|
||||||
"code.gitea.io/gitea/modules/typesniffer"
|
"code.gitea.io/gitea/modules/typesniffer"
|
||||||
"code.gitea.io/gitea/modules/util"
|
"code.gitea.io/gitea/modules/util"
|
||||||
@@ -237,10 +238,10 @@ func RenderWithRenderer(ctx *RenderContext, renderer Renderer, input io.Reader,
|
|||||||
return renderIFrame(ctx, extOpts.ContentSandbox, output)
|
return renderIFrame(ctx, extOpts.ContentSandbox, output)
|
||||||
}
|
}
|
||||||
// else: this is a standalone page, fallthrough to the real rendering, and add extra JS/CSS
|
// else: this is a standalone page, fallthrough to the real rendering, and add extra JS/CSS
|
||||||
extraStyleHref := setting.AppSubURL + "/assets/css/external-render-iframe.css"
|
extraStyleHref := public.AssetURI("css/external-render-iframe.css")
|
||||||
extraScriptSrc := setting.AppSubURL + "/assets/js/external-render-iframe.js"
|
extraScriptSrc := public.AssetURI("js/external-render-iframe.js")
|
||||||
// "<script>" must go before "<link>", to make Golang's http.DetectContentType() can still recognize the content as "text/html"
|
// "<script>" must go before "<link>", to make Golang's http.DetectContentType() can still recognize the content as "text/html"
|
||||||
extraHeadHTML = htmlutil.HTMLFormat(`<script src="%s"></script><link rel="stylesheet" href="%s">`, extraScriptSrc, extraStyleHref)
|
extraHeadHTML = htmlutil.HTMLFormat(`<script type="module" src="%s"></script><link rel="stylesheet" href="%s">`, extraScriptSrc, extraStyleHref)
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx.usedByRender = true
|
ctx.usedByRender = true
|
||||||
|
|||||||
156
modules/public/manifest.go
Normal file
156
modules/public/manifest.go
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package public
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"path"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"code.gitea.io/gitea/modules/json"
|
||||||
|
"code.gitea.io/gitea/modules/log"
|
||||||
|
"code.gitea.io/gitea/modules/setting"
|
||||||
|
)
|
||||||
|
|
||||||
|
type manifestEntry struct {
|
||||||
|
File string `json:"file"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
IsEntry bool `json:"isEntry"`
|
||||||
|
CSS []string `json:"css"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type manifestDataStruct struct {
|
||||||
|
paths map[string]string // unhashed path -> hashed path
|
||||||
|
names map[string]string // hashed path -> entry name
|
||||||
|
modTime int64
|
||||||
|
checkTime time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
manifestData atomic.Pointer[manifestDataStruct]
|
||||||
|
manifestFS = sync.OnceValue(AssetFS)
|
||||||
|
)
|
||||||
|
|
||||||
|
const manifestPath = "assets/.vite/manifest.json"
|
||||||
|
|
||||||
|
func parseManifest(data []byte) (map[string]string, map[string]string) {
|
||||||
|
var manifest map[string]manifestEntry
|
||||||
|
if err := json.Unmarshal(data, &manifest); err != nil {
|
||||||
|
log.Error("Failed to parse frontend manifest: %v", err)
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
paths := make(map[string]string)
|
||||||
|
names := make(map[string]string)
|
||||||
|
for _, entry := range manifest {
|
||||||
|
if !entry.IsEntry || entry.Name == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Build unhashed key from file path: "js/index.js", "css/theme-gitea-dark.css"
|
||||||
|
dir := path.Dir(entry.File)
|
||||||
|
ext := path.Ext(entry.File)
|
||||||
|
key := dir + "/" + entry.Name + ext
|
||||||
|
paths[key] = entry.File
|
||||||
|
names[entry.File] = entry.Name
|
||||||
|
// Map associated CSS files, e.g. "css/index.css" -> "css/index.B3zrQPqD.css"
|
||||||
|
for _, css := range entry.CSS {
|
||||||
|
cssKey := path.Dir(css) + "/" + entry.Name + path.Ext(css)
|
||||||
|
paths[cssKey] = css
|
||||||
|
names[css] = entry.Name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return paths, names
|
||||||
|
}
|
||||||
|
|
||||||
|
func reloadManifest(existingData *manifestDataStruct) *manifestDataStruct {
|
||||||
|
now := time.Now()
|
||||||
|
data := existingData
|
||||||
|
if data != nil && now.Sub(data.checkTime) < time.Second {
|
||||||
|
// a single request triggers multiple calls to getHashedPath
|
||||||
|
// do not check the manifest file too frequently
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
f, err := manifestFS().Open(manifestPath)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Failed to open frontend manifest: %v", err)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
fi, err := f.Stat()
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Failed to stat frontend manifest: %v", err)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
needReload := data == nil || fi.ModTime().UnixNano() != data.modTime
|
||||||
|
if !needReload {
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
manifestContent, err := io.ReadAll(f)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Failed to read frontend manifest: %v", err)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
return storeManifestFromBytes(manifestContent, fi.ModTime().UnixNano(), now)
|
||||||
|
}
|
||||||
|
|
||||||
|
func storeManifestFromBytes(manifestContent []byte, modTime int64, checkTime time.Time) *manifestDataStruct {
|
||||||
|
paths, names := parseManifest(manifestContent)
|
||||||
|
data := &manifestDataStruct{
|
||||||
|
paths: paths,
|
||||||
|
names: names,
|
||||||
|
modTime: modTime,
|
||||||
|
checkTime: checkTime,
|
||||||
|
}
|
||||||
|
manifestData.Store(data)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
func getManifestData() *manifestDataStruct {
|
||||||
|
data := manifestData.Load()
|
||||||
|
|
||||||
|
// In production the manifest is immutable (embedded in the binary).
|
||||||
|
// In dev mode, check if it changed on disk (for watch-frontend).
|
||||||
|
if data == nil || !setting.IsProd {
|
||||||
|
data = reloadManifest(data)
|
||||||
|
}
|
||||||
|
if data == nil {
|
||||||
|
data = &manifestDataStruct{}
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
// getHashedPath resolves an unhashed asset path (origin path) to its content-hashed path from the frontend manifest.
|
||||||
|
// Example: getHashedPath("js/index.js") returns "js/index.C6Z2MRVQ.js"
|
||||||
|
// Falls back to returning the input path unchanged if the manifest is unavailable.
|
||||||
|
func getHashedPath(originPath string) string {
|
||||||
|
data := getManifestData()
|
||||||
|
if p, ok := data.paths[originPath]; ok {
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
return originPath
|
||||||
|
}
|
||||||
|
|
||||||
|
// AssetURI returns the URI for a frontend asset.
|
||||||
|
// It may return a relative path or a full URL depending on the StaticURLPrefix setting.
|
||||||
|
// In Vite dev mode, known entry points are mapped to their source paths
|
||||||
|
// so the reverse proxy serves them from the Vite dev server.
|
||||||
|
// In production, it resolves the content-hashed path from the manifest.
|
||||||
|
func AssetURI(originPath string) string {
|
||||||
|
if src := viteDevSourceURL(originPath); src != "" {
|
||||||
|
return src
|
||||||
|
}
|
||||||
|
return setting.StaticURLPrefix + "/assets/" + getHashedPath(originPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AssetNameFromHashedPath returns the asset entry name for a given hashed asset path.
|
||||||
|
// Example: returns "theme-gitea-dark" for "css/theme-gitea-dark.CyAaQnn5.css".
|
||||||
|
// Returns empty string if the path is not found in the manifest.
|
||||||
|
func AssetNameFromHashedPath(hashedPath string) string {
|
||||||
|
return getManifestData().names[hashedPath]
|
||||||
|
}
|
||||||
91
modules/public/manifest_test.go
Normal file
91
modules/public/manifest_test.go
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package public
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"code.gitea.io/gitea/modules/setting"
|
||||||
|
"code.gitea.io/gitea/modules/test"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestViteManifest(t *testing.T) {
|
||||||
|
defer test.MockVariableValue(&setting.IsProd, true)()
|
||||||
|
|
||||||
|
const testManifest = `{
|
||||||
|
"web_src/js/index.ts": {
|
||||||
|
"file": "js/index.C6Z2MRVQ.js",
|
||||||
|
"name": "index",
|
||||||
|
"src": "web_src/js/index.ts",
|
||||||
|
"isEntry": true,
|
||||||
|
"css": ["css/index.B3zrQPqD.css"]
|
||||||
|
},
|
||||||
|
"web_src/js/standalone/swagger.ts": {
|
||||||
|
"file": "js/swagger.SujiEmYM.js",
|
||||||
|
"name": "swagger",
|
||||||
|
"src": "web_src/js/standalone/swagger.ts",
|
||||||
|
"isEntry": true,
|
||||||
|
"css": ["css/swagger._-APWT_3.css"]
|
||||||
|
},
|
||||||
|
"web_src/css/themes/theme-gitea-dark.css": {
|
||||||
|
"file": "css/theme-gitea-dark.CyAaQnn5.css",
|
||||||
|
"name": "theme-gitea-dark",
|
||||||
|
"src": "web_src/css/themes/theme-gitea-dark.css",
|
||||||
|
"isEntry": true
|
||||||
|
},
|
||||||
|
"web_src/js/features/eventsource.sharedworker.ts": {
|
||||||
|
"file": "js/eventsource.sharedworker.Dug1twio.js",
|
||||||
|
"name": "eventsource.sharedworker",
|
||||||
|
"src": "web_src/js/features/eventsource.sharedworker.ts",
|
||||||
|
"isEntry": true
|
||||||
|
},
|
||||||
|
"_chunk.js": {
|
||||||
|
"file": "js/chunk.abc123.js",
|
||||||
|
"name": "chunk"
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
|
||||||
|
t.Run("EmptyManifest", func(t *testing.T) {
|
||||||
|
storeManifestFromBytes([]byte(``), 0, time.Now())
|
||||||
|
assert.Equal(t, "/assets/js/index.js", AssetURI("js/index.js"))
|
||||||
|
assert.Equal(t, "/assets/css/theme-gitea-dark.css", AssetURI("css/theme-gitea-dark.css"))
|
||||||
|
assert.Equal(t, "", AssetNameFromHashedPath("css/no-such-file.css"))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ParseManifest", func(t *testing.T) {
|
||||||
|
storeManifestFromBytes([]byte(testManifest), 0, time.Now())
|
||||||
|
paths, names := manifestData.Load().paths, manifestData.Load().names
|
||||||
|
|
||||||
|
// JS entries
|
||||||
|
assert.Equal(t, "js/index.C6Z2MRVQ.js", paths["js/index.js"])
|
||||||
|
assert.Equal(t, "js/swagger.SujiEmYM.js", paths["js/swagger.js"])
|
||||||
|
assert.Equal(t, "js/eventsource.sharedworker.Dug1twio.js", paths["js/eventsource.sharedworker.js"])
|
||||||
|
|
||||||
|
// Associated CSS from JS entries
|
||||||
|
assert.Equal(t, "css/index.B3zrQPqD.css", paths["css/index.css"])
|
||||||
|
assert.Equal(t, "css/swagger._-APWT_3.css", paths["css/swagger.css"])
|
||||||
|
|
||||||
|
// CSS-only entries
|
||||||
|
assert.Equal(t, "css/theme-gitea-dark.CyAaQnn5.css", paths["css/theme-gitea-dark.css"])
|
||||||
|
|
||||||
|
// Non-entry chunks should not be included
|
||||||
|
assert.Empty(t, paths["js/chunk.js"])
|
||||||
|
|
||||||
|
// Names: hashed path -> entry name
|
||||||
|
assert.Equal(t, "index", names["js/index.C6Z2MRVQ.js"])
|
||||||
|
assert.Equal(t, "index", names["css/index.B3zrQPqD.css"])
|
||||||
|
assert.Equal(t, "swagger", names["js/swagger.SujiEmYM.js"])
|
||||||
|
assert.Equal(t, "swagger", names["css/swagger._-APWT_3.css"])
|
||||||
|
assert.Equal(t, "theme-gitea-dark", names["css/theme-gitea-dark.CyAaQnn5.css"])
|
||||||
|
assert.Equal(t, "eventsource.sharedworker", names["js/eventsource.sharedworker.Dug1twio.js"])
|
||||||
|
|
||||||
|
// Test Asset related functions
|
||||||
|
assert.Equal(t, "/assets/js/index.C6Z2MRVQ.js", AssetURI("js/index.js"))
|
||||||
|
assert.Equal(t, "/assets/css/theme-gitea-dark.CyAaQnn5.css", AssetURI("css/theme-gitea-dark.css"))
|
||||||
|
assert.Equal(t, "theme-gitea-dark", AssetNameFromHashedPath("css/theme-gitea-dark.CyAaQnn5.css"))
|
||||||
|
})
|
||||||
|
}
|
||||||
168
modules/public/vitedev.go
Normal file
168
modules/public/vitedev.go
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package public
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httputil"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"code.gitea.io/gitea/modules/log"
|
||||||
|
"code.gitea.io/gitea/modules/setting"
|
||||||
|
"code.gitea.io/gitea/modules/web/routing"
|
||||||
|
)
|
||||||
|
|
||||||
|
const viteDevPortFile = "public/assets/.vite/dev-port"
|
||||||
|
|
||||||
|
var viteDevProxy atomic.Pointer[httputil.ReverseProxy]
|
||||||
|
|
||||||
|
func getViteDevProxy() *httputil.ReverseProxy {
|
||||||
|
if proxy := viteDevProxy.Load(); proxy != nil {
|
||||||
|
return proxy
|
||||||
|
}
|
||||||
|
|
||||||
|
portFile := filepath.Join(setting.StaticRootPath, viteDevPortFile)
|
||||||
|
data, err := os.ReadFile(portFile)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
port := strings.TrimSpace(string(data))
|
||||||
|
if port == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
target, err := url.Parse("http://localhost:" + port)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Failed to parse Vite dev server URL: %v", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// there is a strange error log (from Golang's HTTP package)
|
||||||
|
// 2026/03/28 19:50:13 modules/log/misc.go:72:(*loggerToWriter).Write() [I] Unsolicited response received on idle HTTP channel starting with "HTTP/1.1 400 Bad Request\r\n\r\n"; err=<nil>
|
||||||
|
// maybe it is caused by that the Vite dev server doesn't support keep-alive connections? or different keep-alive timeouts?
|
||||||
|
transport := &http.Transport{
|
||||||
|
IdleConnTimeout: 5 * time.Second,
|
||||||
|
ResponseHeaderTimeout: 5 * time.Second,
|
||||||
|
}
|
||||||
|
log.Info("Proxying Vite dev server requests to %s", target)
|
||||||
|
proxy := &httputil.ReverseProxy{
|
||||||
|
Transport: transport,
|
||||||
|
Rewrite: func(r *httputil.ProxyRequest) {
|
||||||
|
r.SetURL(target)
|
||||||
|
r.Out.Host = target.Host
|
||||||
|
},
|
||||||
|
ModifyResponse: func(resp *http.Response) error {
|
||||||
|
// add a header to indicate the Vite dev server port,
|
||||||
|
// make developers know that this request is proxied to Vite dev server and which port it is
|
||||||
|
resp.Header.Add("X-Gitea-Vite-Port", port)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) {
|
||||||
|
log.Error("Error proxying to Vite dev server: %v", err)
|
||||||
|
http.Error(w, "Error proxying to Vite dev server: "+err.Error(), http.StatusBadGateway)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
viteDevProxy.Store(proxy)
|
||||||
|
return proxy
|
||||||
|
}
|
||||||
|
|
||||||
|
// ViteDevMiddleware proxies matching requests to the Vite dev server.
|
||||||
|
// It is registered as middleware in non-production mode and lazily discovers
|
||||||
|
// the Vite dev server port from the port file written by the viteDevServerPortPlugin.
|
||||||
|
// It is needed because there are container-based development, only Gitea web server's port is exposed.
|
||||||
|
func ViteDevMiddleware(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(resp http.ResponseWriter, req *http.Request) {
|
||||||
|
if !isViteDevRequest(req) {
|
||||||
|
next.ServeHTTP(resp, req)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
proxy := getViteDevProxy()
|
||||||
|
if proxy == nil {
|
||||||
|
next.ServeHTTP(resp, req)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
routing.MarkLongPolling(resp, req)
|
||||||
|
proxy.ServeHTTP(resp, req)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// isViteDevMode returns true if the Vite dev server port file exists.
|
||||||
|
// In production mode, the result is cached after the first check.
|
||||||
|
func isViteDevMode() bool {
|
||||||
|
if setting.IsProd {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
portFile := filepath.Join(setting.StaticRootPath, viteDevPortFile)
|
||||||
|
_, err := os.Stat(portFile)
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func viteDevSourceURL(name string) string {
|
||||||
|
if !isViteDevMode() {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(name, "css/theme-") {
|
||||||
|
// Only redirect built-in themes to Vite source; custom themes are served from custom/public/assets/css/
|
||||||
|
themeFile := strings.TrimPrefix(name, "css/")
|
||||||
|
srcPath := filepath.Join(setting.StaticRootPath, "web_src/css/themes", themeFile)
|
||||||
|
if _, err := os.Stat(srcPath); err == nil {
|
||||||
|
return setting.AppSubURL + "/web_src/css/themes/" + themeFile
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(name, "css/") {
|
||||||
|
return setting.AppSubURL + "/web_src/" + name
|
||||||
|
}
|
||||||
|
if name == "js/eventsource.sharedworker.js" {
|
||||||
|
return setting.AppSubURL + "/web_src/js/features/eventsource.sharedworker.ts"
|
||||||
|
}
|
||||||
|
if name == "js/iife.js" {
|
||||||
|
return setting.AppSubURL + "/web_src/js/__vite_iife.js"
|
||||||
|
}
|
||||||
|
if name == "js/index.js" {
|
||||||
|
return setting.AppSubURL + "/web_src/js/index.ts"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// isViteDevRequest returns true if the request should be proxied to the Vite dev server.
|
||||||
|
// Ref: Vite source packages/vite/src/node/constants.ts and packages/vite/src/shared/constants.ts
|
||||||
|
func isViteDevRequest(req *http.Request) bool {
|
||||||
|
if req.Header.Get("Upgrade") == "websocket" {
|
||||||
|
wsProtocol := req.Header.Get("Sec-WebSocket-Protocol")
|
||||||
|
return wsProtocol == "vite-hmr" || wsProtocol == "vite-ping"
|
||||||
|
}
|
||||||
|
path := req.URL.Path
|
||||||
|
|
||||||
|
// vite internal requests
|
||||||
|
if strings.HasPrefix(path, "/@vite/") /* HMR client */ ||
|
||||||
|
strings.HasPrefix(path, "/@fs/") /* out-of-root file access, see vite.config.ts: fs.allow */ ||
|
||||||
|
strings.HasPrefix(path, "/@id/") /* virtual modules */ {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// local source requests (VITE-DEV-SERVER-SECURITY: don't serve sensitive files outside the allowed paths)
|
||||||
|
if strings.HasPrefix(path, "/node_modules/") ||
|
||||||
|
strings.HasPrefix(path, "/public/assets/") ||
|
||||||
|
strings.HasPrefix(path, "/web_src/") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vite uses a path relative to project root and adds "?import" to non-JS/CSS asset imports:
|
||||||
|
// - {WebSite}/public/assets/... (e.g. SVG icons from "{RepoRoot}/public/assets/img/svg/")
|
||||||
|
// - {WebSite}/assets/emoji.json: it is an exception for the frontend assets, it is imported by JS code, but:
|
||||||
|
// - KEEP IN MIND: all static frontend assets are served from "{AssetFS}/assets" to "{WebSite}/assets" by Gitea Web Server
|
||||||
|
// - "{AssetFS}" is a layered filesystem from "{RepoRoot}/public" or embedded assets, and user's custom files in "{CustomPath}/public"
|
||||||
|
// - "{RepoRoot}/assets/emoji.json" just happens to have the dir name "assets", it is not related to frontend assets
|
||||||
|
// - BAD DESIGN: indeed it is a "conflicted and polluted name" sample
|
||||||
|
if path == "/assets/emoji.json" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -72,9 +72,6 @@ var (
|
|||||||
// It maps to ini:"LOCAL_ROOT_URL" in [server]
|
// It maps to ini:"LOCAL_ROOT_URL" in [server]
|
||||||
LocalURL string
|
LocalURL string
|
||||||
|
|
||||||
// AssetVersion holds an opaque value that is used for cache-busting assets
|
|
||||||
AssetVersion string
|
|
||||||
|
|
||||||
// appTempPathInternal is the temporary path for the app, it is only an internal variable
|
// appTempPathInternal is the temporary path for the app, it is only an internal variable
|
||||||
// DO NOT use it directly, always use AppDataTempDir
|
// DO NOT use it directly, always use AppDataTempDir
|
||||||
appTempPathInternal string
|
appTempPathInternal string
|
||||||
@@ -317,8 +314,6 @@ func loadServerFrom(rootCfg ConfigProvider) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
AbsoluteAssetURL = MakeAbsoluteAssetURL(appURL, StaticURLPrefix)
|
AbsoluteAssetURL = MakeAbsoluteAssetURL(appURL, StaticURLPrefix)
|
||||||
AssetVersion = strings.ReplaceAll(AppVer, "+", "~") // make sure the version string is clear (no real escaping is needed)
|
|
||||||
|
|
||||||
manifestBytes := MakeManifestData(AppName, AppURL, AbsoluteAssetURL)
|
manifestBytes := MakeManifestData(AppName, AppURL, AbsoluteAssetURL)
|
||||||
ManifestData = `application/json;base64,` + base64.StdEncoding.EncodeToString(manifestBytes)
|
ManifestData = `application/json;base64,` + base64.StdEncoding.EncodeToString(manifestBytes)
|
||||||
|
|
||||||
|
|||||||
@@ -6,15 +6,18 @@ package templates
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"html"
|
||||||
"html/template"
|
"html/template"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"code.gitea.io/gitea/modules/base"
|
"code.gitea.io/gitea/modules/base"
|
||||||
"code.gitea.io/gitea/modules/htmlutil"
|
"code.gitea.io/gitea/modules/htmlutil"
|
||||||
"code.gitea.io/gitea/modules/markup"
|
"code.gitea.io/gitea/modules/markup"
|
||||||
|
"code.gitea.io/gitea/modules/public"
|
||||||
"code.gitea.io/gitea/modules/setting"
|
"code.gitea.io/gitea/modules/setting"
|
||||||
"code.gitea.io/gitea/modules/svg"
|
"code.gitea.io/gitea/modules/svg"
|
||||||
"code.gitea.io/gitea/modules/templates/eval"
|
"code.gitea.io/gitea/modules/templates/eval"
|
||||||
@@ -68,6 +71,8 @@ func NewFuncMap() template.FuncMap {
|
|||||||
return strconv.FormatInt(time.Since(startTime).Nanoseconds()/1e6, 10) + "ms"
|
return strconv.FormatInt(time.Since(startTime).Nanoseconds()/1e6, 10) + "ms"
|
||||||
},
|
},
|
||||||
|
|
||||||
|
"AssetURI": public.AssetURI,
|
||||||
|
"ScriptImport": scriptImport,
|
||||||
// -----------------------------------------------------------------
|
// -----------------------------------------------------------------
|
||||||
// setting
|
// setting
|
||||||
"AppName": func() string {
|
"AppName": func() string {
|
||||||
@@ -92,9 +97,6 @@ func NewFuncMap() template.FuncMap {
|
|||||||
"AppDomain": func() string { // documented in mail-templates.md
|
"AppDomain": func() string { // documented in mail-templates.md
|
||||||
return setting.Domain
|
return setting.Domain
|
||||||
},
|
},
|
||||||
"AssetVersion": func() string {
|
|
||||||
return setting.AssetVersion
|
|
||||||
},
|
|
||||||
"ShowFooterTemplateLoadTime": func() bool {
|
"ShowFooterTemplateLoadTime": func() bool {
|
||||||
return setting.Other.ShowFooterTemplateLoadTime
|
return setting.Other.ShowFooterTemplateLoadTime
|
||||||
},
|
},
|
||||||
@@ -303,3 +305,30 @@ func QueryBuild(a ...any) template.URL {
|
|||||||
}
|
}
|
||||||
return template.URL(s)
|
return template.URL(s)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var globalVars = sync.OnceValue(func() (ret struct {
|
||||||
|
scriptImportRemainingPart string
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
// add onerror handler to alert users when the script fails to load:
|
||||||
|
// * for end users: there were many users reporting that "UI doesn't work", actually they made mistakes in their config
|
||||||
|
// * for developers: help them to remember to run "make watch-frontend" to build frontend assets
|
||||||
|
// the message will be directly put in the onerror JS code's string
|
||||||
|
onScriptErrorPrompt := `Please make sure the asset files can be accessed.`
|
||||||
|
if !setting.IsProd {
|
||||||
|
onScriptErrorPrompt += `\n\nFor development, run: make watch-frontend.`
|
||||||
|
}
|
||||||
|
onScriptErrorJS := fmt.Sprintf(`alert('Failed to load asset file from ' + this.src + '. %s')`, onScriptErrorPrompt)
|
||||||
|
ret.scriptImportRemainingPart = `onerror="` + html.EscapeString(onScriptErrorJS) + `"></script>`
|
||||||
|
return ret
|
||||||
|
})
|
||||||
|
|
||||||
|
func scriptImport(path string, typ ...string) template.HTML {
|
||||||
|
if len(typ) > 0 {
|
||||||
|
if typ[0] == "module" {
|
||||||
|
return template.HTML(`<script type="module" src="` + html.EscapeString(public.AssetURI(path)) + `" ` + globalVars().scriptImportRemainingPart)
|
||||||
|
}
|
||||||
|
panic("unsupported script type: " + typ[0])
|
||||||
|
}
|
||||||
|
return template.HTML(`<script src="` + html.EscapeString(public.AssetURI(path)) + `" ` + globalVars().scriptImportRemainingPart)
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,7 +4,9 @@
|
|||||||
package web
|
package web
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bufio"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"reflect"
|
"reflect"
|
||||||
|
|
||||||
@@ -47,6 +49,13 @@ func (r *responseWriter) WriteHeader(statusCode int) {
|
|||||||
r.respWriter.WriteHeader(statusCode)
|
r.respWriter.WriteHeader(statusCode)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *responseWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) {
|
||||||
|
if hj, ok := r.respWriter.(http.Hijacker); ok {
|
||||||
|
return hj.Hijack()
|
||||||
|
}
|
||||||
|
return nil, nil, http.ErrNotSupported
|
||||||
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
httpReqType = reflect.TypeFor[*http.Request]()
|
httpReqType = reflect.TypeFor[*http.Request]()
|
||||||
respWriterType = reflect.TypeFor[http.ResponseWriter]()
|
respWriterType = reflect.TypeFor[http.ResponseWriter]()
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"code.gitea.io/gitea/modules/gtprof"
|
"code.gitea.io/gitea/modules/gtprof"
|
||||||
|
"code.gitea.io/gitea/modules/log"
|
||||||
"code.gitea.io/gitea/modules/reqctx"
|
"code.gitea.io/gitea/modules/reqctx"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -40,6 +41,18 @@ func MarkLongPolling(resp http.ResponseWriter, req *http.Request) {
|
|||||||
|
|
||||||
record.lock.Lock()
|
record.lock.Lock()
|
||||||
record.isLongPolling = true
|
record.isLongPolling = true
|
||||||
|
record.logLevel = log.TRACE
|
||||||
|
record.lock.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func MarkLogLevelTrace(resp http.ResponseWriter, req *http.Request) {
|
||||||
|
record, ok := req.Context().Value(contextKey).(*requestRecord)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
record.lock.Lock()
|
||||||
|
record.logLevel = log.TRACE
|
||||||
record.lock.Unlock()
|
record.lock.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ package routing
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"code.gitea.io/gitea/modules/log"
|
"code.gitea.io/gitea/modules/log"
|
||||||
@@ -36,17 +35,8 @@ var (
|
|||||||
|
|
||||||
func logPrinter(logger log.Logger) func(trigger Event, record *requestRecord) {
|
func logPrinter(logger log.Logger) func(trigger Event, record *requestRecord) {
|
||||||
const callerName = "HTTPRequest"
|
const callerName = "HTTPRequest"
|
||||||
logTrace := func(fmt string, args ...any) {
|
logRequest := func(level log.Level, fmt string, args ...any) {
|
||||||
logger.Log(2, &log.Event{Level: log.TRACE, Caller: callerName}, fmt, args...)
|
logger.Log(2, &log.Event{Level: level, Caller: callerName}, fmt, args...)
|
||||||
}
|
|
||||||
logInfo := func(fmt string, args ...any) {
|
|
||||||
logger.Log(2, &log.Event{Level: log.INFO, Caller: callerName}, fmt, args...)
|
|
||||||
}
|
|
||||||
logWarn := func(fmt string, args ...any) {
|
|
||||||
logger.Log(2, &log.Event{Level: log.WARN, Caller: callerName}, fmt, args...)
|
|
||||||
}
|
|
||||||
logError := func(fmt string, args ...any) {
|
|
||||||
logger.Log(2, &log.Event{Level: log.ERROR, Caller: callerName}, fmt, args...)
|
|
||||||
}
|
}
|
||||||
return func(trigger Event, record *requestRecord) {
|
return func(trigger Event, record *requestRecord) {
|
||||||
if trigger == StartEvent {
|
if trigger == StartEvent {
|
||||||
@@ -57,7 +47,7 @@ func logPrinter(logger log.Logger) func(trigger Event, record *requestRecord) {
|
|||||||
}
|
}
|
||||||
// when a request starts, we have no information about the handler function information, we only have the request path
|
// when a request starts, we have no information about the handler function information, we only have the request path
|
||||||
req := record.request
|
req := record.request
|
||||||
logTrace("router: %s %v %s for %s", startMessage, log.ColoredMethod(req.Method), req.RequestURI, req.RemoteAddr)
|
logRequest(log.TRACE, "router: %s %v %s for %s", startMessage, log.ColoredMethod(req.Method), req.RequestURI, req.RemoteAddr)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,12 +63,12 @@ func logPrinter(logger log.Logger) func(trigger Event, record *requestRecord) {
|
|||||||
|
|
||||||
if trigger == StillExecutingEvent {
|
if trigger == StillExecutingEvent {
|
||||||
message := slowMessage
|
message := slowMessage
|
||||||
logf := logWarn
|
logLevel := log.WARN
|
||||||
if isLongPolling {
|
if isLongPolling {
|
||||||
logf = logInfo
|
logLevel = log.INFO
|
||||||
message = pollingMessage
|
message = pollingMessage
|
||||||
}
|
}
|
||||||
logf("router: %s %v %s for %s, elapsed %v @ %s",
|
logRequest(logLevel, "router: %s %v %s for %s, elapsed %v @ %s",
|
||||||
message,
|
message,
|
||||||
log.ColoredMethod(req.Method), req.RequestURI, req.RemoteAddr,
|
log.ColoredMethod(req.Method), req.RequestURI, req.RemoteAddr,
|
||||||
log.ColoredTime(time.Since(record.startTime)),
|
log.ColoredTime(time.Since(record.startTime)),
|
||||||
@@ -88,7 +78,7 @@ func logPrinter(logger log.Logger) func(trigger Event, record *requestRecord) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if panicErr != nil {
|
if panicErr != nil {
|
||||||
logWarn("router: %s %v %s for %s, panic in %v @ %s, err=%v",
|
logRequest(log.WARN, "router: %s %v %s for %s, panic in %v @ %s, err=%v",
|
||||||
failedMessage,
|
failedMessage,
|
||||||
log.ColoredMethod(req.Method), req.RequestURI, req.RemoteAddr,
|
log.ColoredMethod(req.Method), req.RequestURI, req.RemoteAddr,
|
||||||
log.ColoredTime(time.Since(record.startTime)),
|
log.ColoredTime(time.Since(record.startTime)),
|
||||||
@@ -102,21 +92,22 @@ func logPrinter(logger log.Logger) func(trigger Event, record *requestRecord) {
|
|||||||
if v, ok := record.responseWriter.(types.ResponseStatusProvider); ok {
|
if v, ok := record.responseWriter.(types.ResponseStatusProvider); ok {
|
||||||
status = v.WrittenStatus()
|
status = v.WrittenStatus()
|
||||||
}
|
}
|
||||||
logf := logInfo
|
logLevel := record.logLevel
|
||||||
|
if logLevel == log.UNDEFINED {
|
||||||
|
logLevel = log.INFO
|
||||||
|
}
|
||||||
// lower the log level for some specific requests, in most cases these logs are not useful
|
// lower the log level for some specific requests, in most cases these logs are not useful
|
||||||
if status > 0 && status < 400 &&
|
if status > 0 && status < 400 &&
|
||||||
strings.HasPrefix(req.RequestURI, "/assets/") /* static assets */ ||
|
|
||||||
req.RequestURI == "/user/events" /* Server-Sent Events (SSE) handler */ ||
|
|
||||||
req.RequestURI == "/api/actions/runner.v1.RunnerService/FetchTask" /* Actions Runner polling */ {
|
req.RequestURI == "/api/actions/runner.v1.RunnerService/FetchTask" /* Actions Runner polling */ {
|
||||||
logf = logTrace
|
logLevel = log.TRACE
|
||||||
}
|
}
|
||||||
message := completedMessage
|
message := completedMessage
|
||||||
if isUnknownHandler {
|
if isUnknownHandler {
|
||||||
logf = logError
|
logLevel = log.ERROR
|
||||||
message = unknownHandlerMessage
|
message = unknownHandlerMessage
|
||||||
}
|
}
|
||||||
|
|
||||||
logf("router: %s %v %s for %s, %v %v in %v @ %s",
|
logRequest(logLevel, "router: %s %v %s for %s, %v %v in %v @ %s",
|
||||||
message,
|
message,
|
||||||
log.ColoredMethod(req.Method), req.RequestURI, req.RemoteAddr,
|
log.ColoredMethod(req.Method), req.RequestURI, req.RemoteAddr,
|
||||||
log.ColoredStatus(status), log.ColoredStatus(status, http.StatusText(status)), log.ColoredTime(time.Since(record.startTime)),
|
log.ColoredStatus(status), log.ColoredStatus(status, http.StatusText(status)), log.ColoredTime(time.Since(record.startTime)),
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"code.gitea.io/gitea/modules/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
type requestRecord struct {
|
type requestRecord struct {
|
||||||
@@ -23,6 +25,7 @@ type requestRecord struct {
|
|||||||
|
|
||||||
// mutable fields
|
// mutable fields
|
||||||
isLongPolling bool
|
isLongPolling bool
|
||||||
|
logLevel log.Level
|
||||||
funcInfo *FuncInfo
|
funcInfo *FuncInfo
|
||||||
panicError error
|
panicError error
|
||||||
}
|
}
|
||||||
|
|||||||
18
package.json
18
package.json
@@ -18,8 +18,7 @@
|
|||||||
"@primer/octicons": "19.23.1",
|
"@primer/octicons": "19.23.1",
|
||||||
"@resvg/resvg-wasm": "2.6.2",
|
"@resvg/resvg-wasm": "2.6.2",
|
||||||
"@silverwind/vue3-calendar-heatmap": "2.1.1",
|
"@silverwind/vue3-calendar-heatmap": "2.1.1",
|
||||||
"@techknowlogick/license-checker-webpack-plugin": "0.3.0",
|
"@vitejs/plugin-vue": "6.0.5",
|
||||||
"add-asset-webpack-plugin": "3.1.1",
|
|
||||||
"ansi_up": "6.0.6",
|
"ansi_up": "6.0.6",
|
||||||
"asciinema-player": "3.15.1",
|
"asciinema-player": "3.15.1",
|
||||||
"chart.js": "4.5.1",
|
"chart.js": "4.5.1",
|
||||||
@@ -29,25 +28,22 @@
|
|||||||
"colord": "2.9.3",
|
"colord": "2.9.3",
|
||||||
"compare-versions": "6.1.1",
|
"compare-versions": "6.1.1",
|
||||||
"cropperjs": "1.6.2",
|
"cropperjs": "1.6.2",
|
||||||
"css-loader": "7.1.4",
|
|
||||||
"dayjs": "1.11.20",
|
"dayjs": "1.11.20",
|
||||||
"dropzone": "6.0.0-beta.2",
|
"dropzone": "6.0.0-beta.2",
|
||||||
"easymde": "2.20.0",
|
"easymde": "2.20.0",
|
||||||
"esbuild-loader": "4.4.2",
|
"esbuild": "0.27.4",
|
||||||
"htmx.org": "2.0.8",
|
"htmx.org": "2.0.8",
|
||||||
"idiomorph": "0.7.4",
|
"idiomorph": "0.7.4",
|
||||||
"jquery": "4.0.0",
|
"jquery": "4.0.0",
|
||||||
"js-yaml": "4.1.1",
|
"js-yaml": "4.1.1",
|
||||||
"katex": "0.16.43",
|
"katex": "0.16.43",
|
||||||
"mermaid": "11.13.0",
|
"mermaid": "11.13.0",
|
||||||
"mini-css-extract-plugin": "2.10.2",
|
|
||||||
"monaco-editor": "0.55.1",
|
"monaco-editor": "0.55.1",
|
||||||
"monaco-editor-webpack-plugin": "7.1.1",
|
|
||||||
"online-3d-viewer": "0.18.0",
|
"online-3d-viewer": "0.18.0",
|
||||||
"pdfobject": "2.3.1",
|
"pdfobject": "2.3.1",
|
||||||
"perfect-debounce": "2.1.0",
|
"perfect-debounce": "2.1.0",
|
||||||
"postcss": "8.5.8",
|
"postcss": "8.5.8",
|
||||||
"postcss-loader": "8.2.1",
|
"rollup-plugin-license": "3.7.0",
|
||||||
"sortablejs": "1.15.7",
|
"sortablejs": "1.15.7",
|
||||||
"swagger-ui-dist": "5.32.1",
|
"swagger-ui-dist": "5.32.1",
|
||||||
"tailwindcss": "3.4.19",
|
"tailwindcss": "3.4.19",
|
||||||
@@ -57,12 +53,11 @@
|
|||||||
"tributejs": "5.1.3",
|
"tributejs": "5.1.3",
|
||||||
"uint8-to-base64": "0.2.1",
|
"uint8-to-base64": "0.2.1",
|
||||||
"vanilla-colorful": "0.7.2",
|
"vanilla-colorful": "0.7.2",
|
||||||
|
"vite": "8.0.3",
|
||||||
|
"vite-string-plugin": "2.0.2",
|
||||||
"vue": "3.5.31",
|
"vue": "3.5.31",
|
||||||
"vue-bar-graph": "2.2.0",
|
"vue-bar-graph": "2.2.0",
|
||||||
"vue-chartjs": "5.3.3",
|
"vue-chartjs": "5.3.3",
|
||||||
"vue-loader": "17.4.2",
|
|
||||||
"webpack": "5.105.4",
|
|
||||||
"webpack-cli": "7.0.2",
|
|
||||||
"wrap-ansi": "10.0.0"
|
"wrap-ansi": "10.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -88,8 +83,8 @@
|
|||||||
"eslint": "10.1.0",
|
"eslint": "10.1.0",
|
||||||
"eslint-import-resolver-typescript": "4.4.4",
|
"eslint-import-resolver-typescript": "4.4.4",
|
||||||
"eslint-plugin-array-func": "5.1.1",
|
"eslint-plugin-array-func": "5.1.1",
|
||||||
"eslint-plugin-github": "6.0.0",
|
|
||||||
"eslint-plugin-de-morgan": "2.1.1",
|
"eslint-plugin-de-morgan": "2.1.1",
|
||||||
|
"eslint-plugin-github": "6.0.0",
|
||||||
"eslint-plugin-import-x": "4.16.2",
|
"eslint-plugin-import-x": "4.16.2",
|
||||||
"eslint-plugin-playwright": "2.10.1",
|
"eslint-plugin-playwright": "2.10.1",
|
||||||
"eslint-plugin-regexp": "3.1.0",
|
"eslint-plugin-regexp": "3.1.0",
|
||||||
@@ -115,7 +110,6 @@
|
|||||||
"typescript": "5.9.3",
|
"typescript": "5.9.3",
|
||||||
"typescript-eslint": "8.57.2",
|
"typescript-eslint": "8.57.2",
|
||||||
"updates": "17.12.0",
|
"updates": "17.12.0",
|
||||||
"vite-string-plugin": "2.0.2",
|
|
||||||
"vitest": "4.1.2",
|
"vitest": "4.1.2",
|
||||||
"vue-tsc": "3.2.6"
|
"vue-tsc": "3.2.6"
|
||||||
},
|
},
|
||||||
|
|||||||
1211
pnpm-lock.yaml
generated
1211
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -259,8 +259,12 @@ func Routes() *web.Router {
|
|||||||
// GetHead allows a HEAD request redirect to GET if HEAD method is not defined for that route
|
// GetHead allows a HEAD request redirect to GET if HEAD method is not defined for that route
|
||||||
routes.BeforeRouting(chi_middleware.GetHead)
|
routes.BeforeRouting(chi_middleware.GetHead)
|
||||||
|
|
||||||
|
if !setting.IsProd {
|
||||||
|
routes.BeforeRouting(public.ViteDevMiddleware)
|
||||||
|
}
|
||||||
|
|
||||||
routes.Head("/", misc.DummyOK) // for health check - doesn't need to be passed through gzip handler
|
routes.Head("/", misc.DummyOK) // for health check - doesn't need to be passed through gzip handler
|
||||||
routes.Methods("GET, HEAD, OPTIONS", "/assets/*", optionsCorsHandler(), public.FileHandlerFunc())
|
routes.Methods("GET, HEAD, OPTIONS", "/assets/*", routing.MarkLogLevelTrace, optionsCorsHandler(), public.FileHandlerFunc())
|
||||||
routes.Methods("GET, HEAD", "/avatars/*", avatarStorageHandler(setting.Avatar.Storage, "avatars", storage.Avatars))
|
routes.Methods("GET, HEAD", "/avatars/*", avatarStorageHandler(setting.Avatar.Storage, "avatars", storage.Avatars))
|
||||||
routes.Methods("GET, HEAD", "/repo-avatars/*", avatarStorageHandler(setting.RepoAvatar.Storage, "repo-avatars", storage.RepoAvatars))
|
routes.Methods("GET, HEAD", "/repo-avatars/*", avatarStorageHandler(setting.RepoAvatar.Storage, "repo-avatars", storage.RepoAvatars))
|
||||||
routes.Methods("GET, HEAD", "/apple-touch-icon.png", misc.StaticRedirect("/assets/img/apple-touch-icon.png"))
|
routes.Methods("GET, HEAD", "/apple-touch-icon.png", misc.StaticRedirect("/assets/img/apple-touch-icon.png"))
|
||||||
|
|||||||
@@ -4,6 +4,8 @@
|
|||||||
package context
|
package context
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bufio"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
web_types "code.gitea.io/gitea/modules/web/types"
|
web_types "code.gitea.io/gitea/modules/web/types"
|
||||||
@@ -67,6 +69,15 @@ func (r *Response) WriteHeader(statusCode int) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Hijack implements http.Hijacker, delegating to the underlying ResponseWriter.
|
||||||
|
// This is needed for WebSocket upgrades through reverse proxies.
|
||||||
|
func (r *Response) Hijack() (net.Conn, *bufio.ReadWriter, error) {
|
||||||
|
if hj, ok := r.ResponseWriter.(http.Hijacker); ok {
|
||||||
|
return hj.Hijack()
|
||||||
|
}
|
||||||
|
return nil, nil, http.ErrNotSupported
|
||||||
|
}
|
||||||
|
|
||||||
// Flush flushes cached data
|
// Flush flushes cached data
|
||||||
func (r *Response) Flush() {
|
func (r *Response) Flush() {
|
||||||
if f, ok := r.ResponseWriter.(http.Flusher); ok {
|
if f, ok := r.ResponseWriter.(http.Flusher); ok {
|
||||||
|
|||||||
@@ -109,9 +109,16 @@ func parseThemeMetaInfoToMap(cssContent string) map[string]string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func defaultThemeMetaInfoByFileName(fileName string) *ThemeMetaInfo {
|
func defaultThemeMetaInfoByFileName(fileName string) *ThemeMetaInfo {
|
||||||
|
internalName := strings.TrimSuffix(strings.TrimPrefix(fileName, fileNamePrefix), fileNameSuffix)
|
||||||
|
// For built-in themes, the manifest knows the unhashed entry name (e.g. "theme-gitea-dark")
|
||||||
|
// which lets us correctly strip the content hash without guessing.
|
||||||
|
// Custom themes are not in the manifest and never have content hashes.
|
||||||
|
if name := public.AssetNameFromHashedPath("css/" + fileName); name != "" {
|
||||||
|
internalName = strings.TrimPrefix(name, fileNamePrefix)
|
||||||
|
}
|
||||||
themeInfo := &ThemeMetaInfo{
|
themeInfo := &ThemeMetaInfo{
|
||||||
FileName: fileName,
|
FileName: fileName,
|
||||||
InternalName: strings.TrimSuffix(strings.TrimPrefix(fileName, fileNamePrefix), fileNameSuffix),
|
InternalName: internalName,
|
||||||
}
|
}
|
||||||
themeInfo.DisplayName = themeInfo.InternalName
|
themeInfo.DisplayName = themeInfo.InternalName
|
||||||
return themeInfo
|
return themeInfo
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{{template "custom/body_outer_post" .}}
|
{{template "custom/body_outer_post" .}}
|
||||||
{{template "base/footer_content" .}}
|
{{template "base/footer_content" .}}
|
||||||
|
{{ScriptImport "js/index.js" "module"}}
|
||||||
{{template "custom/footer" .}}
|
{{template "custom/footer" .}}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ If you introduce mistakes in it, Gitea JavaScript code wouldn't run correctly.
|
|||||||
window.config = {
|
window.config = {
|
||||||
appUrl: '{{AppUrl}}',
|
appUrl: '{{AppUrl}}',
|
||||||
appSubUrl: '{{AppSubUrl}}',
|
appSubUrl: '{{AppSubUrl}}',
|
||||||
assetVersionEncoded: encodeURIComponent('{{AssetVersion}}'), // will be used in URL construction directly
|
|
||||||
assetUrlPrefix: '{{AssetUrlPrefix}}',
|
assetUrlPrefix: '{{AssetUrlPrefix}}',
|
||||||
runModeIsProd: {{.RunModeIsProd}},
|
runModeIsProd: {{.RunModeIsProd}},
|
||||||
customEmojis: {{CustomEmojis}},
|
customEmojis: {{CustomEmojis}},
|
||||||
@@ -17,6 +16,7 @@ If you introduce mistakes in it, Gitea JavaScript code wouldn't run correctly.
|
|||||||
notificationSettings: {{NotificationSettings}}, {{/*a map provided by NewFuncMap in helper.go*/}}
|
notificationSettings: {{NotificationSettings}}, {{/*a map provided by NewFuncMap in helper.go*/}}
|
||||||
enableTimeTracking: {{EnableTimetracking}},
|
enableTimeTracking: {{EnableTimetracking}},
|
||||||
mermaidMaxSourceCharacters: {{MermaidMaxSourceCharacters}},
|
mermaidMaxSourceCharacters: {{MermaidMaxSourceCharacters}},
|
||||||
|
sharedWorkerUri: '{{AssetURI "js/eventsource.sharedworker.js"}}',
|
||||||
{{/* this global i18n object should only contain general texts. for specialized texts, it should be provided inside the related modules by: (1) API response (2) HTML data-attribute (3) PageData */}}
|
{{/* this global i18n object should only contain general texts. for specialized texts, it should be provided inside the related modules by: (1) API response (2) HTML data-attribute (3) PageData */}}
|
||||||
i18n: {
|
i18n: {
|
||||||
copy_success: {{ctx.Locale.Tr "copy_success"}},
|
copy_success: {{ctx.Locale.Tr "copy_success"}},
|
||||||
@@ -31,4 +31,4 @@ If you introduce mistakes in it, Gitea JavaScript code wouldn't run correctly.
|
|||||||
{{/* in case some pages don't render the pageData, we make sure it is an object to prevent null access */}}
|
{{/* in case some pages don't render the pageData, we make sure it is an object to prevent null access */}}
|
||||||
window.config.pageData = window.config.pageData || {};
|
window.config.pageData = window.config.pageData || {};
|
||||||
</script>
|
</script>
|
||||||
<script src="{{AssetUrlPrefix}}/js/index.js?v={{AssetVersion}}" onerror="alert('Failed to load asset files from ' + this.src + '. Please make sure the asset files can be accessed.')"></script>
|
{{ScriptImport "js/iife.js"}}
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
<link rel="stylesheet" href="{{AssetUrlPrefix}}/css/index.css?v={{AssetVersion}}">
|
<link rel="stylesheet" href="{{AssetURI "css/index.css"}}">
|
||||||
<link rel="stylesheet" href="{{AssetUrlPrefix}}/css/theme-{{ctx.CurrentWebTheme.InternalName | PathEscape}}.css?v={{AssetVersion}}">
|
<link rel="stylesheet" href="{{AssetURI (printf "css/theme-%s.css" (PathEscape ctx.CurrentWebTheme.InternalName))}}">
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
{{/* TODO: the devtest.js is isolated from index.js, so no module is shared and many index.js functions do not work in devtest.ts */}}
|
{{/* TODO: the devtest.js is isolated from index.js, so no module is shared and many index.js functions do not work in devtest.ts */}}
|
||||||
<script src="{{AssetUrlPrefix}}/js/devtest.js?v={{AssetVersion}}"></script>
|
<script type="module" src="{{AssetURI "js/devtest.js"}}"></script>
|
||||||
{{template "base/footer" ctx.RootData}}
|
{{template "base/footer" ctx.RootData}}
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
{{template "base/head" ctx.RootData}}
|
{{template "base/head" ctx.RootData}}
|
||||||
<link rel="stylesheet" href="{{AssetUrlPrefix}}/css/devtest.css?v={{AssetVersion}}">
|
<link rel="stylesheet" href="{{AssetURI "css/devtest.css"}}">
|
||||||
|
<script>
|
||||||
|
// must make sure the jQuery is globally loaded
|
||||||
|
// ref: https://github.com/go-gitea/gitea/issues/35923
|
||||||
|
if (!window.jQuery) alert('jQuery is missing, user custom plugins may not work');
|
||||||
|
</script>
|
||||||
{{template "base/alert" .}}
|
{{template "base/alert" .}}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{{/* This page should only depend the minimal template functions/variables, to avoid triggering new panics.
|
{{/* This page should only depend the minimal template functions/variables, to avoid triggering new panics.
|
||||||
* base template functions: AppName, AssetUrlPrefix, AssetVersion, AppSubUrl
|
* base template functions: AppName, AssetUrlPrefix, AssetURI, AppSubUrl
|
||||||
* ctx.Locale
|
* ctx.Locale
|
||||||
* .Flash
|
* .Flash
|
||||||
* .ErrorMsg
|
* .ErrorMsg
|
||||||
|
|||||||
@@ -2,13 +2,13 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<title>Gitea API</title>
|
<title>Gitea API</title>
|
||||||
<link href="{{AssetUrlPrefix}}/css/swagger.css?v={{AssetVersion}}" rel="stylesheet">
|
<link href="{{AssetURI "css/swagger.css"}}" rel="stylesheet">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
{{/* TODO: add Help & Glossary to help users understand the API, and explain some concepts like "Owner" */}}
|
{{/* TODO: add Help & Glossary to help users understand the API, and explain some concepts like "Owner" */}}
|
||||||
<a class="swagger-back-link" href="{{AppSubUrl}}/">{{svg "octicon-reply"}}{{ctx.Locale.Tr "return_to_gitea"}}</a>
|
<a class="swagger-back-link" href="{{AppSubUrl}}/">{{svg "octicon-reply"}}{{ctx.Locale.Tr "return_to_gitea"}}</a>
|
||||||
<div id="swagger-ui" data-source="{{AppSubUrl}}/swagger.{{.APIJSONVersion}}.json"></div>
|
<div id="swagger-ui" data-source="{{AppSubUrl}}/swagger.{{.APIJSONVersion}}.json"></div>
|
||||||
<footer class="page-footer"></footer>
|
<footer class="page-footer"></footer>
|
||||||
<script src="{{AssetUrlPrefix}}/js/swagger.js?v={{AssetVersion}}"></script>
|
<script type="module" src="{{AssetURI "js/swagger.js"}}"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
"code.gitea.io/gitea/modules/charset"
|
"code.gitea.io/gitea/modules/charset"
|
||||||
"code.gitea.io/gitea/modules/markup"
|
"code.gitea.io/gitea/modules/markup"
|
||||||
"code.gitea.io/gitea/modules/markup/external"
|
"code.gitea.io/gitea/modules/markup/external"
|
||||||
|
"code.gitea.io/gitea/modules/public"
|
||||||
"code.gitea.io/gitea/modules/setting"
|
"code.gitea.io/gitea/modules/setting"
|
||||||
"code.gitea.io/gitea/modules/test"
|
"code.gitea.io/gitea/modules/test"
|
||||||
"code.gitea.io/gitea/tests"
|
"code.gitea.io/gitea/tests"
|
||||||
@@ -107,7 +108,7 @@ func TestExternalMarkupRenderer(t *testing.T) {
|
|||||||
// default sandbox in sub page response
|
// default sandbox in sub page response
|
||||||
assert.Equal(t, "frame-src 'self'; sandbox allow-scripts allow-popups", respSub.Header().Get("Content-Security-Policy"))
|
assert.Equal(t, "frame-src 'self'; sandbox allow-scripts allow-popups", respSub.Header().Get("Content-Security-Policy"))
|
||||||
// FIXME: actually here is a bug (legacy design problem), the "PostProcess" will escape "<script>" tag, but it indeed is the sanitizer's job
|
// FIXME: actually here is a bug (legacy design problem), the "PostProcess" will escape "<script>" tag, but it indeed is the sanitizer's job
|
||||||
assert.Equal(t, `<script src="/assets/js/external-render-iframe.js"></script><link rel="stylesheet" href="/assets/css/external-render-iframe.css"><div><any attr="val"><script></script></any></div>`, respSub.Body.String())
|
assert.Equal(t, `<script type="module" src="`+public.AssetURI("js/external-render-iframe.js")+`"></script><link rel="stylesheet" href="`+public.AssetURI("css/external-render-iframe.css")+`"><div><any attr="val"><script></script></any></div>`, respSub.Body.String())
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -130,7 +131,7 @@ func TestExternalMarkupRenderer(t *testing.T) {
|
|||||||
t.Run("HTMLContentWithExternalRenderIframeHelper", func(t *testing.T) {
|
t.Run("HTMLContentWithExternalRenderIframeHelper", func(t *testing.T) {
|
||||||
req := NewRequest(t, "GET", "/user2/repo1/render/branch/master/html.no-sanitizer")
|
req := NewRequest(t, "GET", "/user2/repo1/render/branch/master/html.no-sanitizer")
|
||||||
respSub := MakeRequest(t, req, http.StatusOK)
|
respSub := MakeRequest(t, req, http.StatusOK)
|
||||||
assert.Equal(t, `<script src="/assets/js/external-render-iframe.js"></script><link rel="stylesheet" href="/assets/css/external-render-iframe.css"><script>foo("raw")</script>`, respSub.Body.String())
|
assert.Equal(t, `<script type="module" src="`+public.AssetURI("js/external-render-iframe.js")+`"></script><link rel="stylesheet" href="`+public.AssetURI("css/external-render-iframe.css")+`"><script>foo("raw")</script>`, respSub.Body.String())
|
||||||
assert.Equal(t, "frame-src 'self'", respSub.Header().Get("Content-Security-Policy"))
|
assert.Equal(t, "frame-src 'self'", respSub.Header().Get("Content-Security-Policy"))
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -45,7 +45,7 @@
|
|||||||
"verbatimModuleSyntax": true,
|
"verbatimModuleSyntax": true,
|
||||||
"types": [
|
"types": [
|
||||||
"node",
|
"node",
|
||||||
"webpack/module",
|
"vite/client",
|
||||||
"vitest/globals",
|
"vitest/globals",
|
||||||
"./web_src/js/globals.d.ts",
|
"./web_src/js/globals.d.ts",
|
||||||
"./types.d.ts",
|
"./types.d.ts",
|
||||||
|
|||||||
5
types.d.ts
vendored
5
types.d.ts
vendored
@@ -1,8 +1,3 @@
|
|||||||
declare module '@techknowlogick/license-checker-webpack-plugin' {
|
|
||||||
const plugin: any;
|
|
||||||
export = plugin;
|
|
||||||
}
|
|
||||||
|
|
||||||
declare module 'eslint-plugin-no-use-extend-native' {
|
declare module 'eslint-plugin-no-use-extend-native' {
|
||||||
import type {Eslint} from 'eslint';
|
import type {Eslint} from 'eslint';
|
||||||
const plugin: Eslint.Plugin;
|
const plugin: Eslint.Plugin;
|
||||||
|
|||||||
332
vite.config.ts
Normal file
332
vite.config.ts
Normal file
@@ -0,0 +1,332 @@
|
|||||||
|
import {build, defineConfig} from 'vite';
|
||||||
|
import vuePlugin from '@vitejs/plugin-vue';
|
||||||
|
import {stringPlugin} from 'vite-string-plugin';
|
||||||
|
import {readFileSync, writeFileSync, unlinkSync, globSync} from 'node:fs';
|
||||||
|
import {join, parse} from 'node:path';
|
||||||
|
import {env} from 'node:process';
|
||||||
|
import tailwindcss from 'tailwindcss';
|
||||||
|
import tailwindConfig from './tailwind.config.ts';
|
||||||
|
import wrapAnsi from 'wrap-ansi';
|
||||||
|
import licensePlugin from 'rollup-plugin-license';
|
||||||
|
import type {InlineConfig, Plugin, Rolldown} from 'vite';
|
||||||
|
|
||||||
|
const isProduction = env.NODE_ENV !== 'development';
|
||||||
|
|
||||||
|
// ENABLE_SOURCEMAP accepts the following values:
|
||||||
|
// true - all sourcemaps enabled, the default in development
|
||||||
|
// reduced - sourcemaps only for index.js, the default in production
|
||||||
|
// false - all sourcemaps disabled
|
||||||
|
let enableSourcemap: string;
|
||||||
|
if ('ENABLE_SOURCEMAP' in env) {
|
||||||
|
enableSourcemap = ['true', 'false'].includes(env.ENABLE_SOURCEMAP!) ? env.ENABLE_SOURCEMAP! : 'reduced';
|
||||||
|
} else {
|
||||||
|
enableSourcemap = isProduction ? 'reduced' : 'true';
|
||||||
|
}
|
||||||
|
const outDir = join(import.meta.dirname, 'public/assets');
|
||||||
|
|
||||||
|
const themes: Record<string, string> = {};
|
||||||
|
for (const path of globSync('web_src/css/themes/*.css', {cwd: import.meta.dirname})) {
|
||||||
|
themes[parse(path).name] = join(import.meta.dirname, path);
|
||||||
|
}
|
||||||
|
|
||||||
|
const webComponents = new Set([
|
||||||
|
// our own, in web_src/js/webcomponents
|
||||||
|
'overflow-menu',
|
||||||
|
'origin-url',
|
||||||
|
'relative-time',
|
||||||
|
// from dependencies
|
||||||
|
'markdown-toolbar',
|
||||||
|
'text-expander',
|
||||||
|
]);
|
||||||
|
|
||||||
|
function formatLicenseText(licenseText: string) {
|
||||||
|
return wrapAnsi(licenseText || '', 80).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
const commonRolldownOptions: Rolldown.RolldownOptions = {
|
||||||
|
checks: {
|
||||||
|
eval: false, // htmx needs eval
|
||||||
|
pluginTimings: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function commonViteOpts({build, ...other}: InlineConfig): InlineConfig {
|
||||||
|
const {rolldownOptions, ...otherBuild} = build || {};
|
||||||
|
return {
|
||||||
|
base: './', // make all asset URLs relative, so it works in subdirectory deployments
|
||||||
|
configFile: false,
|
||||||
|
root: import.meta.dirname,
|
||||||
|
publicDir: false,
|
||||||
|
build: {
|
||||||
|
outDir,
|
||||||
|
emptyOutDir: false,
|
||||||
|
sourcemap: enableSourcemap !== 'false',
|
||||||
|
target: 'es2020',
|
||||||
|
minify: isProduction ? 'oxc' : false,
|
||||||
|
cssMinify: isProduction ? 'esbuild' : false,
|
||||||
|
chunkSizeWarningLimit: Infinity,
|
||||||
|
assetsInlineLimit: 32768,
|
||||||
|
reportCompressedSize: false,
|
||||||
|
rolldownOptions: {
|
||||||
|
...commonRolldownOptions,
|
||||||
|
...rolldownOptions,
|
||||||
|
},
|
||||||
|
...otherBuild,
|
||||||
|
},
|
||||||
|
...other,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const iifeEntry = join(import.meta.dirname, 'web_src/js/iife.ts');
|
||||||
|
|
||||||
|
function iifeBuildOpts({entryFileNames, write}: {entryFileNames: string, write?: boolean}) {
|
||||||
|
return commonViteOpts({
|
||||||
|
build: {
|
||||||
|
lib: {entry: iifeEntry, formats: ['iife'], name: 'iife'},
|
||||||
|
rolldownOptions: {output: {entryFileNames}},
|
||||||
|
...(write === false && {write: false}),
|
||||||
|
},
|
||||||
|
plugins: [stringPlugin()],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build iife.js as a blocking IIFE bundle. In dev mode, serves it from memory
|
||||||
|
// and rebuilds on file changes. In prod mode, writes to disk during closeBundle.
|
||||||
|
function iifePlugin(): Plugin {
|
||||||
|
let iifeCode = '';
|
||||||
|
let iifeMap = '';
|
||||||
|
const iifeModules = new Set<string>();
|
||||||
|
let isBuilding = false;
|
||||||
|
return {
|
||||||
|
name: 'iife',
|
||||||
|
async configureServer(server) {
|
||||||
|
const buildAndCache = async () => {
|
||||||
|
const result = await build(iifeBuildOpts({entryFileNames: 'js/iife.js', write: false}));
|
||||||
|
const output = (Array.isArray(result) ? result[0] : result) as Rolldown.RolldownOutput;
|
||||||
|
const chunk = output.output[0];
|
||||||
|
iifeCode = chunk.code.replace(/\/\/# sourceMappingURL=.*/, '//# sourceMappingURL=__vite_iife.js.map');
|
||||||
|
const mapAsset = output.output.find((o) => o.fileName.endsWith('.map'));
|
||||||
|
iifeMap = mapAsset && 'source' in mapAsset ? String(mapAsset.source) : '';
|
||||||
|
iifeModules.clear();
|
||||||
|
for (const id of Object.keys(chunk.modules)) iifeModules.add(id);
|
||||||
|
};
|
||||||
|
await buildAndCache();
|
||||||
|
|
||||||
|
let needsRebuild = false;
|
||||||
|
server.watcher.on('change', async (path) => {
|
||||||
|
if (!iifeModules.has(path)) return;
|
||||||
|
needsRebuild = true;
|
||||||
|
if (isBuilding) return;
|
||||||
|
isBuilding = true;
|
||||||
|
try {
|
||||||
|
do {
|
||||||
|
needsRebuild = false;
|
||||||
|
await buildAndCache();
|
||||||
|
} while (needsRebuild);
|
||||||
|
server.ws.send({type: 'full-reload'});
|
||||||
|
} finally {
|
||||||
|
isBuilding = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
server.middlewares.use((req, res, next) => {
|
||||||
|
// "__vite_iife" is a virtual file in memory, serve it directly
|
||||||
|
const pathname = req.url!.split('?')[0];
|
||||||
|
if (pathname === '/web_src/js/__vite_iife.js') {
|
||||||
|
res.setHeader('Content-Type', 'application/javascript');
|
||||||
|
res.setHeader('Cache-Control', 'no-store');
|
||||||
|
res.end(iifeCode);
|
||||||
|
} else if (pathname === '/web_src/js/__vite_iife.js.map') {
|
||||||
|
res.setHeader('Content-Type', 'application/json');
|
||||||
|
res.setHeader('Cache-Control', 'no-store');
|
||||||
|
res.end(iifeMap);
|
||||||
|
} else {
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
async closeBundle() {
|
||||||
|
for (const file of globSync('js/iife.*.js*', {cwd: outDir})) unlinkSync(join(outDir, file));
|
||||||
|
const result = await build(iifeBuildOpts({entryFileNames: 'js/iife.[hash:8].js'}));
|
||||||
|
const buildOutput = (Array.isArray(result) ? result[0] : result) as Rolldown.RolldownOutput;
|
||||||
|
const entry = buildOutput.output.find((o) => o.fileName.startsWith('js/iife.'));
|
||||||
|
if (!entry) throw new Error('IIFE build produced no output');
|
||||||
|
const manifestPath = join(outDir, '.vite', 'manifest.json');
|
||||||
|
writeFileSync(manifestPath, JSON.stringify({
|
||||||
|
...JSON.parse(readFileSync(manifestPath, 'utf8')),
|
||||||
|
'web_src/js/iife.ts': {file: entry.fileName, name: 'iife', isEntry: true},
|
||||||
|
}, null, 2));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// In reduced sourcemap mode, only keep sourcemaps for main files
|
||||||
|
function reducedSourcemapPlugin(): Plugin {
|
||||||
|
return {
|
||||||
|
name: 'reduced-sourcemap',
|
||||||
|
apply: 'build',
|
||||||
|
closeBundle() {
|
||||||
|
if (enableSourcemap !== 'reduced') return;
|
||||||
|
for (const file of globSync('{js,css}/*.map', {cwd: outDir})) {
|
||||||
|
if (!file.startsWith('js/index.') && !file.startsWith('js/iife.')) unlinkSync(join(outDir, file));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter out legacy font formats from CSS, keeping only woff2
|
||||||
|
function filterCssUrlPlugin(): Plugin {
|
||||||
|
return {
|
||||||
|
name: 'filter-css-url',
|
||||||
|
enforce: 'pre',
|
||||||
|
transform(code, id) {
|
||||||
|
if (!id.endsWith('.css') || !id.includes('katex')) return null;
|
||||||
|
return code.replace(/,\s*url\([^)]*\.(?:woff|ttf)\)\s*format\("[^"]*"\)/gi, '');
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const viteDevServerPort = Number(env.FRONTEND_DEV_SERVER_PORT) || 3001;
|
||||||
|
const viteDevPortFilePath = join(outDir, '.vite', 'dev-port');
|
||||||
|
|
||||||
|
// Write the Vite dev server's actual port to a file so the Go server can discover it for proxying.
|
||||||
|
function viteDevServerPortPlugin(): Plugin {
|
||||||
|
return {
|
||||||
|
name: 'vite-dev-server-port',
|
||||||
|
apply: 'serve',
|
||||||
|
configureServer(server) {
|
||||||
|
server.httpServer!.once('listening', () => {
|
||||||
|
const addr = server.httpServer!.address();
|
||||||
|
if (typeof addr === 'object' && addr) {
|
||||||
|
writeFileSync(viteDevPortFilePath, String(addr.port));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defineConfig(commonViteOpts({
|
||||||
|
appType: 'custom', // Go serves all HTML, disable Vite's HTML handling
|
||||||
|
clearScreen: false,
|
||||||
|
server: {
|
||||||
|
port: viteDevServerPort,
|
||||||
|
open: false,
|
||||||
|
host: '0.0.0.0',
|
||||||
|
strictPort: false,
|
||||||
|
fs: {
|
||||||
|
// VITE-DEV-SERVER-SECURITY: the dev server will be exposed to public by Gitea's web server, so we need to strictly limit the access
|
||||||
|
// Otherwise `/@fs/*` will be able to access any file (including app.ini which contains INTERNAL_TOKEN)
|
||||||
|
strict: true,
|
||||||
|
allow: [
|
||||||
|
'assets',
|
||||||
|
'node_modules',
|
||||||
|
'public',
|
||||||
|
'web_src',
|
||||||
|
// do not add any other directories here, unless you are absolutely sure it's safe to expose them to the public
|
||||||
|
],
|
||||||
|
},
|
||||||
|
headers: {
|
||||||
|
'Cache-Control': 'no-store', // prevent browser disk cache
|
||||||
|
},
|
||||||
|
warmup: {
|
||||||
|
clientFiles: [
|
||||||
|
// warmup the important entry points
|
||||||
|
'web_src/js/index.ts',
|
||||||
|
'web_src/css/index.css',
|
||||||
|
'web_src/css/themes/*.css',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
modulePreload: false,
|
||||||
|
manifest: true,
|
||||||
|
rolldownOptions: {
|
||||||
|
input: {
|
||||||
|
index: join(import.meta.dirname, 'web_src/js/index.ts'),
|
||||||
|
swagger: join(import.meta.dirname, 'web_src/js/standalone/swagger.ts'),
|
||||||
|
'external-render-iframe': join(import.meta.dirname, 'web_src/js/standalone/external-render-iframe.ts'),
|
||||||
|
'eventsource.sharedworker': join(import.meta.dirname, 'web_src/js/features/eventsource.sharedworker.ts'),
|
||||||
|
...(!isProduction && {
|
||||||
|
devtest: join(import.meta.dirname, 'web_src/js/standalone/devtest.ts'),
|
||||||
|
}),
|
||||||
|
...themes,
|
||||||
|
},
|
||||||
|
output: {
|
||||||
|
entryFileNames: 'js/[name].[hash:8].js',
|
||||||
|
chunkFileNames: 'js/[name].[hash:8].js',
|
||||||
|
assetFileNames: ({names}) => {
|
||||||
|
const name = names[0];
|
||||||
|
if (name.endsWith('.css')) return 'css/[name].[hash:8].css';
|
||||||
|
if (/\.(ttf|woff2?)$/.test(name)) return 'fonts/[name].[hash:8].[ext]';
|
||||||
|
return '[name].[hash:8].[ext]';
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
worker: {
|
||||||
|
rolldownOptions: {
|
||||||
|
...commonRolldownOptions,
|
||||||
|
output: {
|
||||||
|
entryFileNames: 'js/[name].[hash:8].js',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
css: {
|
||||||
|
transformer: 'postcss',
|
||||||
|
postcss: {
|
||||||
|
plugins: [
|
||||||
|
tailwindcss(tailwindConfig),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
define: {
|
||||||
|
__VUE_OPTIONS_API__: true,
|
||||||
|
__VUE_PROD_DEVTOOLS__: false,
|
||||||
|
__VUE_PROD_HYDRATION_MISMATCH_DETAILS__: false,
|
||||||
|
},
|
||||||
|
plugins: [
|
||||||
|
iifePlugin(),
|
||||||
|
viteDevServerPortPlugin(),
|
||||||
|
reducedSourcemapPlugin(),
|
||||||
|
filterCssUrlPlugin(),
|
||||||
|
stringPlugin(),
|
||||||
|
vuePlugin({
|
||||||
|
template: {
|
||||||
|
compilerOptions: {
|
||||||
|
isCustomElement: (tag) => webComponents.has(tag),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
isProduction ? licensePlugin({
|
||||||
|
thirdParty: {
|
||||||
|
output: {
|
||||||
|
file: join(import.meta.dirname, 'public/assets/licenses.txt'),
|
||||||
|
template(deps) {
|
||||||
|
const line = '-'.repeat(80);
|
||||||
|
const goJson = readFileSync(join(import.meta.dirname, 'assets/go-licenses.json'), 'utf8');
|
||||||
|
const goModules = JSON.parse(goJson).map(({name, licenseText}: {name: string, licenseText: string}) => {
|
||||||
|
return {name, body: formatLicenseText(licenseText)};
|
||||||
|
});
|
||||||
|
const jsModules = deps.map((dep) => {
|
||||||
|
return {name: dep.name, version: dep.version, body: formatLicenseText(dep.licenseText ?? '')};
|
||||||
|
});
|
||||||
|
const modules = [...goModules, ...jsModules].sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
return modules.map(({name, version, body}: {name: string, version?: string, body: string}) => {
|
||||||
|
const title = version ? `${name}@${version}` : name;
|
||||||
|
return `${line}\n${title}\n${line}\n${body}`;
|
||||||
|
}).join('\n');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
allow(dependency) {
|
||||||
|
if (dependency.name === 'khroma') return true; // MIT: https://github.com/fabiospampinato/khroma/pull/33
|
||||||
|
return /(Apache-2\.0|0BSD|BSD-2-Clause|BSD-3-Clause|MIT|ISC|CPAL-1\.0|Unlicense|EPL-1\.0|EPL-2\.0)/.test(dependency.license ?? '');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}) : {
|
||||||
|
name: 'dev-licenses-stub',
|
||||||
|
closeBundle() {
|
||||||
|
writeFileSync(join(outDir, 'licenses.txt'), 'Licenses are disabled during development');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}));
|
||||||
@@ -538,6 +538,58 @@ strong.attention-caution, svg.attention-caution {
|
|||||||
overflow-menu {
|
overflow-menu {
|
||||||
border-bottom: 1px solid var(--color-secondary) !important;
|
border-bottom: 1px solid var(--color-secondary) !important;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
overflow-menu .overflow-menu-popup {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 8px);
|
||||||
|
right: 0;
|
||||||
|
z-index: 100;
|
||||||
|
background-color: var(--color-menu);
|
||||||
|
color: var(--color-text);
|
||||||
|
border: 1px solid var(--color-secondary);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
box-shadow: 0 6px 18px var(--color-shadow);
|
||||||
|
padding: 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
overflow-menu .overflow-menu-popup::before,
|
||||||
|
overflow-menu .overflow-menu-popup::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
right: 10px;
|
||||||
|
border: 8px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
overflow-menu .overflow-menu-popup::before {
|
||||||
|
bottom: 100%;
|
||||||
|
border-bottom-color: var(--color-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
overflow-menu .overflow-menu-popup::after {
|
||||||
|
bottom: calc(100% - 1px);
|
||||||
|
border-bottom-color: var(--color-menu);
|
||||||
|
}
|
||||||
|
|
||||||
|
overflow-menu .overflow-menu-popup > .item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 9px 18px !important;
|
||||||
|
color: var(--color-text) !important;
|
||||||
|
background: transparent !important;
|
||||||
|
text-decoration: none;
|
||||||
|
gap: 10px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
overflow-menu .overflow-menu-popup > .item:hover,
|
||||||
|
overflow-menu .overflow-menu-popup > .item:focus {
|
||||||
|
background: var(--color-hover) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
overflow-menu .overflow-menu-popup > .item.active {
|
||||||
|
background: var(--color-active) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
overflow-menu .overflow-menu-items {
|
overflow-menu .overflow-menu-items {
|
||||||
|
|||||||
@@ -1,82 +1,12 @@
|
|||||||
// DO NOT IMPORT window.config HERE!
|
// DO NOT IMPORT window.config HERE!
|
||||||
// to make sure the error handler always works, we should never import `window.config`, because
|
// to make sure the error handler always works, we should never import `window.config`, because
|
||||||
// some user's custom template breaks it.
|
// some user's custom template breaks it.
|
||||||
import type {Intent} from './types.ts';
|
import {showGlobalErrorMessage, processWindowErrorEvent} from './modules/errors.ts';
|
||||||
import {html} from './utils/html.ts';
|
|
||||||
|
|
||||||
// This sets up the URL prefix used in webpack's chunk loading.
|
// A module should not be imported twice, otherwise there will be bugs when a module has its internal states.
|
||||||
// This file must be imported before any lazy-loading is being attempted.
|
// A real example is "generateElemId" in "utils/dom.ts", if it is imported twice in different module scopes,
|
||||||
window.__webpack_public_path__ = `${window.config?.assetUrlPrefix ?? '/assets'}/`;
|
// It will generate duplicate IDs (ps: don't try to use "random" to fix, it is just a real example to show the importance of "do not import a module twice")
|
||||||
|
if (!window._globalHandlerErrors?._inited) {
|
||||||
export function shouldIgnoreError(err: Error) {
|
|
||||||
const ignorePatterns: Array<RegExp> = [
|
|
||||||
// https://github.com/go-gitea/gitea/issues/30861
|
|
||||||
// https://github.com/microsoft/monaco-editor/issues/4496
|
|
||||||
// https://github.com/microsoft/monaco-editor/issues/4679
|
|
||||||
/\/assets\/js\/.*monaco/,
|
|
||||||
];
|
|
||||||
for (const pattern of ignorePatterns) {
|
|
||||||
if (pattern.test(err.stack ?? '')) return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function showGlobalErrorMessage(msg: string, msgType: Intent = 'error') {
|
|
||||||
const msgContainer = document.querySelector('.page-content') ?? document.body;
|
|
||||||
if (!msgContainer) {
|
|
||||||
alert(`${msgType}: ${msg}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const msgCompact = msg.replace(/\W/g, '').trim(); // compact the message to a data attribute to avoid too many duplicated messages
|
|
||||||
let msgDiv = msgContainer.querySelector<HTMLDivElement>(`.js-global-error[data-global-error-msg-compact="${msgCompact}"]`);
|
|
||||||
if (!msgDiv) {
|
|
||||||
const el = document.createElement('div');
|
|
||||||
el.innerHTML = html`<div class="ui container js-global-error tw-my-[--page-spacing]"><div class="ui ${msgType} message tw-text-center tw-whitespace-pre-line"></div></div>`;
|
|
||||||
msgDiv = el.childNodes[0] as HTMLDivElement;
|
|
||||||
}
|
|
||||||
// merge duplicated messages into "the message (count)" format
|
|
||||||
const msgCount = Number(msgDiv.getAttribute(`data-global-error-msg-count`)) + 1;
|
|
||||||
msgDiv.setAttribute(`data-global-error-msg-compact`, msgCompact);
|
|
||||||
msgDiv.setAttribute(`data-global-error-msg-count`, msgCount.toString());
|
|
||||||
msgDiv.querySelector('.ui.message')!.textContent = msg + (msgCount > 1 ? ` (${msgCount})` : '');
|
|
||||||
msgContainer.prepend(msgDiv);
|
|
||||||
}
|
|
||||||
|
|
||||||
function processWindowErrorEvent({error, reason, message, type, filename, lineno, colno}: ErrorEvent & PromiseRejectionEvent) {
|
|
||||||
const err = error ?? reason;
|
|
||||||
const assetBaseUrl = String(new URL(window.__webpack_public_path__, window.location.origin));
|
|
||||||
const {runModeIsProd} = window.config ?? {};
|
|
||||||
|
|
||||||
// `error` and `reason` are not guaranteed to be errors. If the value is falsy, it is likely a
|
|
||||||
// non-critical event from the browser. We log them but don't show them to users. Examples:
|
|
||||||
// - https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver#observation_errors
|
|
||||||
// - https://github.com/mozilla-mobile/firefox-ios/issues/10817
|
|
||||||
// - https://github.com/go-gitea/gitea/issues/20240
|
|
||||||
if (!err) {
|
|
||||||
if (message) console.error(new Error(message));
|
|
||||||
if (runModeIsProd) return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (err instanceof Error) {
|
|
||||||
// If the error stack trace does not include the base URL of our script assets, it likely came
|
|
||||||
// from a browser extension or inline script. Do not show such errors in production.
|
|
||||||
if (!err.stack?.includes(assetBaseUrl) && runModeIsProd) return;
|
|
||||||
// Ignore some known errors that are unable to fix
|
|
||||||
if (shouldIgnoreError(err)) return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let msg = err?.message ?? message;
|
|
||||||
if (lineno) msg += ` (${filename} @ ${lineno}:${colno})`;
|
|
||||||
const dot = msg.endsWith('.') ? '' : '.';
|
|
||||||
const renderedType = type === 'unhandledrejection' ? 'promise rejection' : type;
|
|
||||||
showGlobalErrorMessage(`JavaScript ${renderedType}: ${msg}${dot} Open browser console to see more details.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
function initGlobalErrorHandler() {
|
|
||||||
if (window._globalHandlerErrors?._inited) {
|
|
||||||
showGlobalErrorMessage(`The global error handler has been initialized, do not initialize it again`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!window.config) {
|
if (!window.config) {
|
||||||
showGlobalErrorMessage(`Gitea JavaScript code couldn't run correctly, please check your custom templates`);
|
showGlobalErrorMessage(`Gitea JavaScript code couldn't run correctly, please check your custom templates`);
|
||||||
}
|
}
|
||||||
@@ -90,5 +20,3 @@ function initGlobalErrorHandler() {
|
|||||||
// events directly
|
// events directly
|
||||||
window._globalHandlerErrors = {_inited: true, push: (e: ErrorEvent & PromiseRejectionEvent) => processWindowErrorEvent(e)} as any;
|
window._globalHandlerErrors = {_inited: true, push: (e: ErrorEvent & PromiseRejectionEvent) => processWindowErrorEvent(e)} as any;
|
||||||
}
|
}
|
||||||
|
|
||||||
initGlobalErrorHandler();
|
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ export async function initCaptcha() {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'm-captcha': {
|
case 'm-captcha': {
|
||||||
const mCaptcha = await import(/* webpackChunkName: "mcaptcha-vanilla-glue" */'@mcaptcha/vanilla-glue');
|
const mCaptcha = await import('@mcaptcha/vanilla-glue');
|
||||||
|
|
||||||
// FIXME: the mCaptcha code is not right, it's a miracle that the wrong code could run
|
// FIXME: the mCaptcha code is not right, it's a miracle that the wrong code could run
|
||||||
// * the "vanilla-glue" has some problems with es6 module.
|
// * the "vanilla-glue" has some problems with es6 module.
|
||||||
|
|||||||
@@ -6,10 +6,10 @@ const {pageData} = window.config;
|
|||||||
|
|
||||||
async function initInputCitationValue(citationCopyApa: HTMLButtonElement, citationCopyBibtex: HTMLButtonElement) {
|
async function initInputCitationValue(citationCopyApa: HTMLButtonElement, citationCopyBibtex: HTMLButtonElement) {
|
||||||
const [{Cite, plugins}] = await Promise.all([
|
const [{Cite, plugins}] = await Promise.all([
|
||||||
import(/* webpackChunkName: "citation-js-core" */'@citation-js/core'),
|
import('@citation-js/core'),
|
||||||
import(/* webpackChunkName: "citation-js-formats" */'@citation-js/plugin-software-formats'),
|
import('@citation-js/plugin-software-formats'),
|
||||||
import(/* webpackChunkName: "citation-js-bibtex" */'@citation-js/plugin-bibtex'),
|
import('@citation-js/plugin-bibtex'),
|
||||||
import(/* webpackChunkName: "citation-js-csl" */'@citation-js/plugin-csl'),
|
import('@citation-js/plugin-csl'),
|
||||||
]);
|
]);
|
||||||
const citationFileContent = pageData.citationFileContent!;
|
const citationFileContent = pageData.citationFileContent!;
|
||||||
const config = plugins.config.get('@bibtex');
|
const config = plugins.config.get('@bibtex');
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ export async function initRepoCodeFrequency() {
|
|||||||
const el = document.querySelector('#repo-code-frequency-chart');
|
const el = document.querySelector('#repo-code-frequency-chart');
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
|
|
||||||
const {default: RepoCodeFrequency} = await import(/* webpackChunkName: "code-frequency-graph" */'../components/RepoCodeFrequency.vue');
|
const {default: RepoCodeFrequency} = await import('../components/RepoCodeFrequency.vue');
|
||||||
try {
|
try {
|
||||||
const View = createApp(RepoCodeFrequency, {
|
const View = createApp(RepoCodeFrequency, {
|
||||||
locale: {
|
locale: {
|
||||||
|
|||||||
@@ -129,7 +129,7 @@ function updateTheme(monaco: Monaco): void {
|
|||||||
type CreateMonacoOpts = MonacoOpts & {language?: string};
|
type CreateMonacoOpts = MonacoOpts & {language?: string};
|
||||||
|
|
||||||
export async function createMonaco(textarea: HTMLTextAreaElement, filename: string, opts: CreateMonacoOpts): Promise<{monaco: Monaco, editor: IStandaloneCodeEditor}> {
|
export async function createMonaco(textarea: HTMLTextAreaElement, filename: string, opts: CreateMonacoOpts): Promise<{monaco: Monaco, editor: IStandaloneCodeEditor}> {
|
||||||
const monaco = await import(/* webpackChunkName: "monaco" */'monaco-editor');
|
const monaco = await import('../modules/monaco.ts');
|
||||||
|
|
||||||
initLanguages(monaco);
|
initLanguages(monaco);
|
||||||
let {language, ...other} = opts;
|
let {language, ...other} = opts;
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ export async function initColorPickers() {
|
|||||||
registerGlobalInitFunc('initColorPicker', async (el) => {
|
registerGlobalInitFunc('initColorPicker', async (el) => {
|
||||||
if (!imported) {
|
if (!imported) {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
import(/* webpackChunkName: "colorpicker" */'vanilla-colorful/hex-color-picker.js'),
|
import('vanilla-colorful/hex-color-picker.js'),
|
||||||
import(/* webpackChunkName: "colorpicker" */'../../css/features/colorpicker.css'),
|
import('../../css/features/colorpicker.css'),
|
||||||
]);
|
]);
|
||||||
imported = true;
|
imported = true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import {GET, POST} from '../modules/fetch.ts';
|
import {GET, POST} from '../modules/fetch.ts';
|
||||||
import {showGlobalErrorMessage} from '../bootstrap.ts';
|
import {showGlobalErrorMessage} from '../modules/errors.ts';
|
||||||
import {fomanticQuery} from '../modules/fomantic/base.ts';
|
import {fomanticQuery} from '../modules/fomantic/base.ts';
|
||||||
import {addDelegatedEventListener, queryElems} from '../utils/dom.ts';
|
import {addDelegatedEventListener, queryElems} from '../utils/dom.ts';
|
||||||
import {registerGlobalInitFunc, registerGlobalSelectorFunc} from '../modules/observer.ts';
|
import {registerGlobalInitFunc, registerGlobalSelectorFunc} from '../modules/observer.ts';
|
||||||
|
|||||||
@@ -319,8 +319,8 @@ export class ComboMarkdownEditor {
|
|||||||
async switchToEasyMDE() {
|
async switchToEasyMDE() {
|
||||||
if (this.easyMDE) return;
|
if (this.easyMDE) return;
|
||||||
const [{default: EasyMDE}] = await Promise.all([
|
const [{default: EasyMDE}] = await Promise.all([
|
||||||
import(/* webpackChunkName: "easymde" */'easymde'),
|
import('easymde'),
|
||||||
import(/* webpackChunkName: "easymde" */'../../../css/easymde.css'),
|
import('../../../css/easymde.css'),
|
||||||
]);
|
]);
|
||||||
const easyMDEOpt: EasyMDE.Options = {
|
const easyMDEOpt: EasyMDE.Options = {
|
||||||
autoDownloadFontAwesome: false,
|
autoDownloadFontAwesome: false,
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ type CropperOpts = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
async function initCompCropper({container, fileInput, imageSource}: CropperOpts) {
|
async function initCompCropper({container, fileInput, imageSource}: CropperOpts) {
|
||||||
const {default: Cropper} = await import(/* webpackChunkName: "cropperjs" */'cropperjs');
|
const {default: Cropper} = await import('cropperjs');
|
||||||
let currentFileName = '';
|
let currentFileName = '';
|
||||||
let currentFileLastModified = 0;
|
let currentFileLastModified = 0;
|
||||||
const cropper = new Cropper(imageSource, {
|
const cropper = new Cropper(imageSource, {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ export async function initRepoContributors() {
|
|||||||
const el = document.querySelector('#repo-contributors-chart');
|
const el = document.querySelector('#repo-contributors-chart');
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
|
|
||||||
const {default: RepoContributors} = await import(/* webpackChunkName: "contributors-graph" */'../components/RepoContributors.vue');
|
const {default: RepoContributors} = await import('../components/RepoContributors.vue');
|
||||||
try {
|
try {
|
||||||
const View = createApp(RepoContributors, {
|
const View = createApp(RepoContributors, {
|
||||||
repoLink: el.getAttribute('data-repo-link'),
|
repoLink: el.getAttribute('data-repo-link'),
|
||||||
|
|||||||
@@ -19,8 +19,8 @@ export const DropzoneCustomEventUploadDone = 'dropzone-custom-upload-done';
|
|||||||
|
|
||||||
async function createDropzone(el: HTMLElement, opts: DropzoneOptions) {
|
async function createDropzone(el: HTMLElement, opts: DropzoneOptions) {
|
||||||
const [{default: Dropzone}] = await Promise.all([
|
const [{default: Dropzone}] = await Promise.all([
|
||||||
import(/* webpackChunkName: "dropzone" */'dropzone'),
|
import('dropzone'),
|
||||||
import(/* webpackChunkName: "dropzone" */'dropzone/dist/dropzone.css'),
|
import('dropzone/dist/dropzone.css'),
|
||||||
]);
|
]);
|
||||||
return new Dropzone(el, opts);
|
return new Dropzone(el, opts);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ export async function initHeatmap() {
|
|||||||
noDataText: el.getAttribute('data-locale-no-contributions'),
|
noDataText: el.getAttribute('data-locale-no-contributions'),
|
||||||
};
|
};
|
||||||
|
|
||||||
const {default: ActivityHeatmap} = await import(/* webpackChunkName: "ActivityHeatmap" */ '../components/ActivityHeatmap.vue');
|
const {default: ActivityHeatmap} = await import('../components/ActivityHeatmap.vue');
|
||||||
const View = createApp(ActivityHeatmap, {values, locale});
|
const View = createApp(ActivityHeatmap, {values, locale});
|
||||||
View.mount(el);
|
View.mount(el);
|
||||||
el.classList.remove('is-loading');
|
el.classList.remove('is-loading');
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ export async function initRepoRecentCommits() {
|
|||||||
const el = document.querySelector('#repo-recent-commits-chart');
|
const el = document.querySelector('#repo-recent-commits-chart');
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
|
|
||||||
const {default: RepoRecentCommits} = await import(/* webpackChunkName: "recent-commits-graph" */'../components/RepoRecentCommits.vue');
|
const {default: RepoRecentCommits} = await import('../components/RepoRecentCommits.vue');
|
||||||
try {
|
try {
|
||||||
const View = createApp(RepoRecentCommits, {
|
const View = createApp(RepoRecentCommits, {
|
||||||
locale: {
|
locale: {
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ export function filterRepoFilesWeighted(files: Array<string>, filter: string) {
|
|||||||
|
|
||||||
export function initRepoFileSearch() {
|
export function initRepoFileSearch() {
|
||||||
registerGlobalInitFunc('initRepoFileSearch', async (el) => {
|
registerGlobalInitFunc('initRepoFileSearch', async (el) => {
|
||||||
const {default: RepoFileSearch} = await import(/* webpackChunkName: "RepoFileSearch" */ '../components/RepoFileSearch.vue');
|
const {default: RepoFileSearch} = await import('../components/RepoFileSearch.vue');
|
||||||
createApp(RepoFileSearch, {
|
createApp(RepoFileSearch, {
|
||||||
repoLink: el.getAttribute('data-repo-link'),
|
repoLink: el.getAttribute('data-repo-link'),
|
||||||
currentRefNameSubURL: el.getAttribute('data-current-ref-name-sub-url'),
|
currentRefNameSubURL: el.getAttribute('data-current-ref-name-sub-url'),
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ async function initRepoPullRequestMergeForm(box: HTMLElement) {
|
|||||||
const el = box.querySelector('#pull-request-merge-form');
|
const el = box.querySelector('#pull-request-merge-form');
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
|
|
||||||
const {default: PullRequestMergeForm} = await import(/* webpackChunkName: "PullRequestMergeForm" */ '../components/PullRequestMergeForm.vue');
|
const {default: PullRequestMergeForm} = await import('../components/PullRequestMergeForm.vue');
|
||||||
const view = createApp(PullRequestMergeForm);
|
const view = createApp(PullRequestMergeForm);
|
||||||
view.mount(el);
|
view.mount(el);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import type {TributeCollection} from 'tributejs';
|
|||||||
import type {Mention} from '../types.ts';
|
import type {Mention} from '../types.ts';
|
||||||
|
|
||||||
export async function attachTribute(element: HTMLElement) {
|
export async function attachTribute(element: HTMLElement) {
|
||||||
const {default: Tribute} = await import(/* webpackChunkName: "tribute" */'tributejs');
|
const {default: Tribute} = await import('tributejs');
|
||||||
const mentionsUrl = element.closest('[data-mentions-url]')?.getAttribute('data-mentions-url');
|
const mentionsUrl = element.closest('[data-mentions-url]')?.getAttribute('data-mentions-url');
|
||||||
|
|
||||||
const emojiCollection: TributeCollection<string> = { // emojis
|
const emojiCollection: TributeCollection<string> = { // emojis
|
||||||
|
|||||||
11
web_src/js/globals.d.ts
vendored
11
web_src/js/globals.d.ts
vendored
@@ -22,8 +22,8 @@ interface Window {
|
|||||||
config: {
|
config: {
|
||||||
appUrl: string,
|
appUrl: string,
|
||||||
appSubUrl: string,
|
appSubUrl: string,
|
||||||
assetVersionEncoded: string,
|
|
||||||
assetUrlPrefix: string,
|
assetUrlPrefix: string,
|
||||||
|
sharedWorkerUri: string,
|
||||||
runModeIsProd: boolean,
|
runModeIsProd: boolean,
|
||||||
customEmojis: Record<string, string>,
|
customEmojis: Record<string, string>,
|
||||||
pageData: Record<string, any> & {
|
pageData: Record<string, any> & {
|
||||||
@@ -64,6 +64,10 @@ interface Window {
|
|||||||
codeEditors: any[], // export editor for customization
|
codeEditors: any[], // export editor for customization
|
||||||
localUserSettings: typeof import('./modules/user-settings.ts').localUserSettings,
|
localUserSettings: typeof import('./modules/user-settings.ts').localUserSettings,
|
||||||
|
|
||||||
|
MonacoEnvironment?: {
|
||||||
|
getWorker: (workerId: string, label: string) => Worker,
|
||||||
|
},
|
||||||
|
|
||||||
// various captcha plugins
|
// various captcha plugins
|
||||||
grecaptcha: any,
|
grecaptcha: any,
|
||||||
turnstile: any,
|
turnstile: any,
|
||||||
@@ -71,3 +75,8 @@ interface Window {
|
|||||||
|
|
||||||
// do not add more properties here unless it is a must
|
// do not add more properties here unless it is a must
|
||||||
}
|
}
|
||||||
|
|
||||||
|
declare module '*?worker' {
|
||||||
|
const workerConstructor: new () => Worker;
|
||||||
|
export default workerConstructor;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,2 +1,16 @@
|
|||||||
import jquery from 'jquery';
|
import jquery from 'jquery'; // eslint-disable-line no-restricted-imports
|
||||||
window.$ = window.jQuery = jquery; // only for Fomantic UI
|
import htmx from 'htmx.org'; // eslint-disable-line no-restricted-imports
|
||||||
|
import 'idiomorph/htmx'; // eslint-disable-line no-restricted-imports
|
||||||
|
|
||||||
|
// Some users still use inline scripts and expect jQuery to be available globally.
|
||||||
|
// To avoid breaking existing users and custom plugins, import jQuery globally without ES module.
|
||||||
|
window.$ = window.jQuery = jquery;
|
||||||
|
|
||||||
|
// There is a bug in htmx, it incorrectly checks "readyState === 'complete'" when the DOM tree is ready and won't trigger DOMContentLoaded
|
||||||
|
// The bug makes htmx impossible to be loaded from an ES module: importing the htmx in onDomReady will make htmx skip its initialization.
|
||||||
|
// ref: https://github.com/bigskysoftware/htmx/pull/3365
|
||||||
|
window.htmx = htmx;
|
||||||
|
|
||||||
|
// https://htmx.org/reference/#config
|
||||||
|
htmx.config.requestClass = 'is-loading';
|
||||||
|
htmx.config.scrollIntoViewOnBoost = false;
|
||||||
|
|||||||
@@ -1,26 +0,0 @@
|
|||||||
import htmx from 'htmx.org';
|
|
||||||
import 'idiomorph/htmx';
|
|
||||||
import type {HtmxResponseInfo} from 'htmx.org';
|
|
||||||
import {showErrorToast} from './modules/toast.ts';
|
|
||||||
|
|
||||||
type HtmxEvent = Event & {detail: HtmxResponseInfo};
|
|
||||||
|
|
||||||
export function initHtmx() {
|
|
||||||
window.htmx = htmx;
|
|
||||||
|
|
||||||
// https://htmx.org/reference/#config
|
|
||||||
htmx.config.requestClass = 'is-loading';
|
|
||||||
htmx.config.scrollIntoViewOnBoost = false;
|
|
||||||
|
|
||||||
// https://htmx.org/events/#htmx:sendError
|
|
||||||
document.body.addEventListener('htmx:sendError', (event: Partial<HtmxEvent>) => {
|
|
||||||
// TODO: add translations
|
|
||||||
showErrorToast(`Network error when calling ${event.detail!.requestConfig.path}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
// https://htmx.org/events/#htmx:responseError
|
|
||||||
document.body.addEventListener('htmx:responseError', (event: Partial<HtmxEvent>) => {
|
|
||||||
// TODO: add translations
|
|
||||||
showErrorToast(`Error ${event.detail!.xhr.status} when calling ${event.detail!.requestConfig.path}`);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
11
web_src/js/iife.ts
Normal file
11
web_src/js/iife.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
// This file is the entry point for the code which should block the page rendering, it is compiled by our "iife" vite plugin
|
||||||
|
|
||||||
|
// bootstrap module must be the first one to be imported, it handles global errors
|
||||||
|
import './bootstrap.ts';
|
||||||
|
|
||||||
|
// many users expect to use jQuery in their custom scripts (https://docs.gitea.com/administration/customizing-gitea#example-plantuml)
|
||||||
|
// so load globals (including jQuery) as early as possible
|
||||||
|
import './globals.ts';
|
||||||
|
|
||||||
|
import './webcomponents/index.ts';
|
||||||
|
import './modules/user-settings.ts'; // templates also need to use localUserSettings in inline scripts
|
||||||
@@ -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'));
|
|
||||||
@@ -1,29 +1,188 @@
|
|||||||
// bootstrap module must be the first one to be imported, it handles webpack lazy-loading and global errors
|
import '../fomantic/build/fomantic.js';
|
||||||
import './bootstrap.ts';
|
import '../css/index.css';
|
||||||
|
import type {HtmxResponseInfo} from 'htmx.org';
|
||||||
|
import {showErrorToast} from './modules/toast.ts';
|
||||||
|
|
||||||
// many users expect to use jQuery in their custom scripts (https://docs.gitea.com/administration/customizing-gitea#example-plantuml)
|
import {initDashboardRepoList} from './features/dashboard.ts';
|
||||||
// so load globals (including jQuery) as early as possible
|
import {initGlobalCopyToClipboardListener} from './features/clipboard.ts';
|
||||||
import './globals.ts';
|
import {initRepoGraphGit} from './features/repo-graph.ts';
|
||||||
|
import {initHeatmap} from './features/heatmap.ts';
|
||||||
|
import {initImageDiff} from './features/imagediff.ts';
|
||||||
|
import {initRepoMigration} from './features/repo-migration.ts';
|
||||||
|
import {initRepoProject} from './features/repo-projects.ts';
|
||||||
|
import {initTableSort} from './features/tablesort.ts';
|
||||||
|
import {initAdminUserListSearchForm} from './features/admin/users.ts';
|
||||||
|
import {initAdminConfigs} from './features/admin/config.ts';
|
||||||
|
import {initMarkupAnchors} from './markup/anchors.ts';
|
||||||
|
import {initNotificationCount} from './features/notification.ts';
|
||||||
|
import {initRepoIssueContentHistory} from './features/repo-issue-content.ts';
|
||||||
|
import {initStopwatch} from './features/stopwatch.ts';
|
||||||
|
import {initRepoFileSearch} from './features/repo-findfile.ts';
|
||||||
|
import {initMarkupContent} from './markup/content.ts';
|
||||||
|
import {initRepoFileView} from './features/file-view.ts';
|
||||||
|
import {initUserAuthOauth2, initUserCheckAppUrl} from './features/user-auth.ts';
|
||||||
|
import {initRepoPullRequestAllowMaintainerEdit, initRepoPullRequestReview, initRepoIssueSidebarDependency, initRepoIssueFilterItemLabel} from './features/repo-issue.ts';
|
||||||
|
import {initRepoEllipsisButton, initCommitStatuses} from './features/repo-commit.ts';
|
||||||
|
import {initRepoTopicBar} from './features/repo-home.ts';
|
||||||
|
import {initAdminCommon} from './features/admin/common.ts';
|
||||||
|
import {initRepoCodeView} from './features/repo-code.ts';
|
||||||
|
import {initSshKeyFormParser} from './features/sshkey-helper.ts';
|
||||||
|
import {initUserSettings} from './features/user-settings.ts';
|
||||||
|
import {initRepoActivityTopAuthorsChart, initRepoArchiveLinks} from './features/repo-common.ts';
|
||||||
|
import {initRepoMigrationStatusChecker} from './features/repo-migrate.ts';
|
||||||
|
import {initRepoDiffView} from './features/repo-diff.ts';
|
||||||
|
import {initOrgTeam} from './features/org-team.ts';
|
||||||
|
import {initUserAuthWebAuthn, initUserAuthWebAuthnRegister} from './features/user-auth-webauthn.ts';
|
||||||
|
import {initRepoReleaseNew} from './features/repo-release.ts';
|
||||||
|
import {initRepoEditor} from './features/repo-editor.ts';
|
||||||
|
import {initCompSearchUserBox} from './features/comp/SearchUserBox.ts';
|
||||||
|
import {initInstall} from './features/install.ts';
|
||||||
|
import {initCompWebHookEditor} from './features/comp/WebHookEditor.ts';
|
||||||
|
import {initRepoBranchButton} from './features/repo-branch.ts';
|
||||||
|
import {initCommonOrganization} from './features/common-organization.ts';
|
||||||
|
import {initRepoWikiForm} from './features/repo-wiki.ts';
|
||||||
|
import {initRepository, initBranchSelectorTabs} from './features/repo-legacy.ts';
|
||||||
|
import {initCopyContent} from './features/copycontent.ts';
|
||||||
|
import {initCaptcha} from './features/captcha.ts';
|
||||||
|
import {initRepositoryActionView} from './features/repo-actions.ts';
|
||||||
|
import {initGlobalTooltips} from './modules/tippy.ts';
|
||||||
|
import {initGiteaFomantic} from './modules/fomantic.ts';
|
||||||
|
import {initSubmitEventPolyfill} from './utils/dom.ts';
|
||||||
|
import {initRepoIssueList} from './features/repo-issue-list.ts';
|
||||||
|
import {initCommonIssueListQuickGoto} from './features/common-issue-list.ts';
|
||||||
|
import {initRepoContributors} from './features/contributors.ts';
|
||||||
|
import {initRepoCodeFrequency} from './features/code-frequency.ts';
|
||||||
|
import {initRepoRecentCommits} from './features/recent-commits.ts';
|
||||||
|
import {initRepoDiffCommitBranchesAndTags} from './features/repo-diff-commit.ts';
|
||||||
|
import {initGlobalSelectorObserver} from './modules/observer.ts';
|
||||||
|
import {initRepositorySearch} from './features/repo-search.ts';
|
||||||
|
import {initColorPickers} from './features/colorpicker.ts';
|
||||||
|
import {initAdminSelfCheck} from './features/admin/selfcheck.ts';
|
||||||
|
import {initOAuth2SettingsDisableCheckbox} from './features/oauth2-settings.ts';
|
||||||
|
import {initGlobalFetchAction} from './features/common-fetch-action.ts';
|
||||||
|
import {initCommmPageComponents, initGlobalComponent, initGlobalDropdown, initGlobalInput} from './features/common-page.ts';
|
||||||
|
import {initGlobalButtonClickOnEnter, initGlobalButtons, initGlobalDeleteButton} from './features/common-button.ts';
|
||||||
|
import {initGlobalComboMarkdownEditor, initGlobalEnterQuickSubmit, initGlobalFormDirtyLeaveConfirm} from './features/common-form.ts';
|
||||||
|
import {callInitFunctions} from './modules/init.ts';
|
||||||
|
import {initRepoViewFileTree} from './features/repo-view-file-tree.ts';
|
||||||
|
import {initActionsPermissionsForm} from './features/common-actions-permissions.ts';
|
||||||
|
import {initGlobalShortcut} from './modules/shortcut.ts';
|
||||||
|
|
||||||
import './webcomponents/index.ts';
|
const initStartTime = performance.now();
|
||||||
import './modules/user-settings.ts'; // templates also need to use localUserSettings in inline scripts
|
const initPerformanceTracer = callInitFunctions([
|
||||||
import {onDomReady} from './utils/dom.ts';
|
initSubmitEventPolyfill,
|
||||||
|
initGiteaFomantic,
|
||||||
|
|
||||||
// TODO: There is a bug in htmx, it incorrectly checks "readyState === 'complete'" when the DOM tree is ready and won't trigger DOMContentLoaded
|
initGlobalComponent,
|
||||||
// Then importing the htmx in our onDomReady will make htmx skip its initialization.
|
initGlobalDropdown,
|
||||||
// If the bug would be fixed (https://github.com/bigskysoftware/htmx/pull/3365), then we can only import htmx in "onDomReady"
|
initGlobalFetchAction,
|
||||||
import 'htmx.org';
|
initGlobalTooltips,
|
||||||
|
initGlobalButtonClickOnEnter,
|
||||||
|
initGlobalButtons,
|
||||||
|
initGlobalCopyToClipboardListener,
|
||||||
|
initGlobalEnterQuickSubmit,
|
||||||
|
initGlobalFormDirtyLeaveConfirm,
|
||||||
|
initGlobalComboMarkdownEditor,
|
||||||
|
initGlobalDeleteButton,
|
||||||
|
initGlobalInput,
|
||||||
|
initGlobalShortcut,
|
||||||
|
|
||||||
onDomReady(async () => {
|
initCommonOrganization,
|
||||||
// when navigate before the import complete, there will be an error from webpack chunk loader:
|
initCommonIssueListQuickGoto,
|
||||||
// JavaScript promise rejection: Loading chunk index-domready failed.
|
|
||||||
try {
|
initCompSearchUserBox,
|
||||||
await import(/* webpackChunkName: "index-domready" */'./index-domready.ts');
|
initCompWebHookEditor,
|
||||||
} catch (e) {
|
|
||||||
if (e.name === 'ChunkLoadError') {
|
initInstall,
|
||||||
console.error('Error loading index-domready:', e);
|
|
||||||
} else {
|
initCommmPageComponents,
|
||||||
throw e;
|
|
||||||
}
|
initHeatmap,
|
||||||
}
|
initImageDiff,
|
||||||
|
initMarkupAnchors,
|
||||||
|
initMarkupContent,
|
||||||
|
initSshKeyFormParser,
|
||||||
|
initStopwatch,
|
||||||
|
initTableSort,
|
||||||
|
initRepoFileSearch,
|
||||||
|
initCopyContent,
|
||||||
|
|
||||||
|
initAdminCommon,
|
||||||
|
initAdminUserListSearchForm,
|
||||||
|
initAdminConfigs,
|
||||||
|
initAdminSelfCheck,
|
||||||
|
|
||||||
|
initDashboardRepoList,
|
||||||
|
|
||||||
|
initNotificationCount,
|
||||||
|
|
||||||
|
initOrgTeam,
|
||||||
|
|
||||||
|
initRepoActivityTopAuthorsChart,
|
||||||
|
initRepoArchiveLinks,
|
||||||
|
initRepoBranchButton,
|
||||||
|
initRepoCodeView,
|
||||||
|
initBranchSelectorTabs,
|
||||||
|
initRepoEllipsisButton,
|
||||||
|
initRepoDiffCommitBranchesAndTags,
|
||||||
|
initRepoEditor,
|
||||||
|
initRepoGraphGit,
|
||||||
|
initRepoIssueContentHistory,
|
||||||
|
initRepoIssueList,
|
||||||
|
initRepoIssueFilterItemLabel,
|
||||||
|
initRepoIssueSidebarDependency,
|
||||||
|
initRepoMigration,
|
||||||
|
initRepoMigrationStatusChecker,
|
||||||
|
initRepoProject,
|
||||||
|
initRepoPullRequestAllowMaintainerEdit,
|
||||||
|
initRepoPullRequestReview,
|
||||||
|
initRepoReleaseNew,
|
||||||
|
initRepoTopicBar,
|
||||||
|
initRepoViewFileTree,
|
||||||
|
initRepoWikiForm,
|
||||||
|
initRepository,
|
||||||
|
initRepositoryActionView,
|
||||||
|
initRepositorySearch,
|
||||||
|
initRepoContributors,
|
||||||
|
initRepoCodeFrequency,
|
||||||
|
initRepoRecentCommits,
|
||||||
|
|
||||||
|
initCommitStatuses,
|
||||||
|
initCaptcha,
|
||||||
|
|
||||||
|
initUserCheckAppUrl,
|
||||||
|
initUserAuthOauth2,
|
||||||
|
initUserAuthWebAuthn,
|
||||||
|
initUserAuthWebAuthnRegister,
|
||||||
|
initUserSettings,
|
||||||
|
initRepoDiffView,
|
||||||
|
initColorPickers,
|
||||||
|
|
||||||
|
initOAuth2SettingsDisableCheckbox,
|
||||||
|
|
||||||
|
initRepoFileView,
|
||||||
|
initActionsPermissionsForm,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// it must be the last one, then the "querySelectorAll" only needs to be executed once for global init functions.
|
||||||
|
initGlobalSelectorObserver(initPerformanceTracer);
|
||||||
|
if (initPerformanceTracer) initPerformanceTracer.printResults();
|
||||||
|
|
||||||
|
const initDur = performance.now() - initStartTime;
|
||||||
|
if (initDur > 500) {
|
||||||
|
console.error(`slow init functions took ${initDur.toFixed(3)}ms`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// https://htmx.org/events/#htmx:sendError
|
||||||
|
type HtmxEvent = Event & {detail: HtmxResponseInfo};
|
||||||
|
document.body.addEventListener('htmx:sendError', (event) => {
|
||||||
|
// TODO: add translations
|
||||||
|
showErrorToast(`Network error when calling ${(event as HtmxEvent).detail.requestConfig.path}`);
|
||||||
});
|
});
|
||||||
|
// https://htmx.org/events/#htmx:responseError
|
||||||
|
document.body.addEventListener('htmx:responseError', (event) => {
|
||||||
|
// TODO: add translations
|
||||||
|
showErrorToast(`Error ${(event as HtmxEvent).detail.xhr.status} when calling ${(event as HtmxEvent).detail.requestConfig.path}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.dispatchEvent(new CustomEvent('gitea:index-ready'));
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ import {queryElems} from '../utils/dom.ts';
|
|||||||
export async function initMarkupRenderAsciicast(elMarkup: HTMLElement): Promise<void> {
|
export async function initMarkupRenderAsciicast(elMarkup: HTMLElement): Promise<void> {
|
||||||
queryElems(elMarkup, '.asciinema-player-container', async (el) => {
|
queryElems(elMarkup, '.asciinema-player-container', async (el) => {
|
||||||
const [player] = await Promise.all([
|
const [player] = await Promise.all([
|
||||||
import(/* webpackChunkName: "asciinema-player" */'asciinema-player'),
|
import('asciinema-player'),
|
||||||
import(/* webpackChunkName: "asciinema-player" */'asciinema-player/dist/bundle/asciinema-player.css'),
|
import('asciinema-player/dist/bundle/asciinema-player.css'),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
player.create(el.getAttribute('data-asciinema-player-src')!, el, {
|
player.create(el.getAttribute('data-asciinema-player-src')!, el, {
|
||||||
|
|||||||
@@ -16,8 +16,8 @@ export async function initMarkupCodeMath(elMarkup: HTMLElement): Promise<void> {
|
|||||||
// .markup code.language-math'
|
// .markup code.language-math'
|
||||||
queryElems(elMarkup, 'code.language-math', async (el) => {
|
queryElems(elMarkup, 'code.language-math', async (el) => {
|
||||||
const [{default: katex}] = await Promise.all([
|
const [{default: katex}] = await Promise.all([
|
||||||
import(/* webpackChunkName: "katex" */'katex'),
|
import('katex'),
|
||||||
import(/* webpackChunkName: "katex" */'katex/dist/katex.css'),
|
import('katex/dist/katex.css'),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const MAX_CHARS = 1000;
|
const MAX_CHARS = 1000;
|
||||||
|
|||||||
@@ -72,8 +72,8 @@ export function sourceNeedsElk(source: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadMermaid(needElkRender: boolean) {
|
async function loadMermaid(needElkRender: boolean) {
|
||||||
const mermaidPromise = import(/* webpackChunkName: "mermaid" */'mermaid');
|
const mermaidPromise = import('mermaid');
|
||||||
const elkPromise = needElkRender ? import(/* webpackChunkName: "mermaid-layout-elk" */'@mermaid-js/layout-elk') : null;
|
const elkPromise = needElkRender ? import('@mermaid-js/layout-elk') : null;
|
||||||
const results = await Promise.all([mermaidPromise, elkPromise]);
|
const results = await Promise.all([mermaidPromise, elkPromise]);
|
||||||
return {
|
return {
|
||||||
mermaid: results[0].default,
|
mermaid: results[0].default,
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ function showMarkupRefIssuePopup(e: MouseEvent | FocusEvent) {
|
|||||||
|
|
||||||
const el = document.createElement('div');
|
const el = document.createElement('div');
|
||||||
const onShowAsync = async () => {
|
const onShowAsync = async () => {
|
||||||
const {default: ContextPopup} = await import(/* webpackChunkName: "ContextPopup" */ '../components/ContextPopup.vue');
|
const {default: ContextPopup} = await import('../components/ContextPopup.vue');
|
||||||
const view = createApp(ContextPopup, {
|
const view = createApp(ContextPopup, {
|
||||||
// backend: GetIssueInfo
|
// backend: GetIssueInfo
|
||||||
loadIssueInfoUrl: `${window.config.appSubUrl}/${issuePathInfo.ownerName}/${issuePathInfo.repoName}/issues/${issuePathInfo.indexString}/info`,
|
loadIssueInfoUrl: `${window.config.appSubUrl}/${issuePathInfo.ownerName}/${issuePathInfo.repoName}/issues/${issuePathInfo.indexString}/info`,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import {showGlobalErrorMessage, shouldIgnoreError} from './bootstrap.ts';
|
import {showGlobalErrorMessage, shouldIgnoreError} from './errors.ts';
|
||||||
|
|
||||||
test('showGlobalErrorMessage', () => {
|
test('showGlobalErrorMessage', () => {
|
||||||
document.body.innerHTML = '<div class="page-content"></div>';
|
document.body.innerHTML = '<div class="page-content"></div>';
|
||||||
@@ -13,9 +13,9 @@ test('showGlobalErrorMessage', () => {
|
|||||||
|
|
||||||
test('shouldIgnoreError', () => {
|
test('shouldIgnoreError', () => {
|
||||||
for (const url of [
|
for (const url of [
|
||||||
'https://gitea.test/assets/js/monaco.b359ef7e.js',
|
'https://gitea.test/assets/js/monaco.D14TzjS9.js',
|
||||||
'https://gitea.test/assets/js/monaco-editor.4a969118.worker.js',
|
'https://gitea.test/assets/js/editor.api2.BdhK7zNg.js',
|
||||||
'https://gitea.test/assets/js/vendors-node_modules_pnpm_monaco-editor_0_55_1_node_modules_monaco-editor_esm_vs_base_common_-e11c7c.966a028d.js',
|
'https://gitea.test/assets/js/editor.worker.BYgvyFya.js',
|
||||||
]) {
|
]) {
|
||||||
const err = new Error('test');
|
const err = new Error('test');
|
||||||
err.stack = `Error: test\n at ${url}:1:1`;
|
err.stack = `Error: test\n at ${url}:1:1`;
|
||||||
67
web_src/js/modules/errors.ts
Normal file
67
web_src/js/modules/errors.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
// keep this file lightweight, it's imported into IIFE chunk in bootstrap
|
||||||
|
import {html} from '../utils/html.ts';
|
||||||
|
import type {Intent} from '../types.ts';
|
||||||
|
|
||||||
|
export function showGlobalErrorMessage(msg: string, msgType: Intent = 'error') {
|
||||||
|
const msgContainer = document.querySelector('.page-content') ?? document.body;
|
||||||
|
if (!msgContainer) {
|
||||||
|
alert(`${msgType}: ${msg}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const msgCompact = msg.replace(/\W/g, '').trim(); // compact the message to a data attribute to avoid too many duplicated messages
|
||||||
|
let msgDiv = msgContainer.querySelector<HTMLDivElement>(`.js-global-error[data-global-error-msg-compact="${msgCompact}"]`);
|
||||||
|
if (!msgDiv) {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
el.innerHTML = html`<div class="ui container js-global-error tw-my-[--page-spacing]"><div class="ui ${msgType} message tw-text-center tw-whitespace-pre-line"></div></div>`;
|
||||||
|
msgDiv = el.childNodes[0] as HTMLDivElement;
|
||||||
|
}
|
||||||
|
// merge duplicated messages into "the message (count)" format
|
||||||
|
const msgCount = Number(msgDiv.getAttribute(`data-global-error-msg-count`)) + 1;
|
||||||
|
msgDiv.setAttribute(`data-global-error-msg-compact`, msgCompact);
|
||||||
|
msgDiv.setAttribute(`data-global-error-msg-count`, msgCount.toString());
|
||||||
|
msgDiv.querySelector('.ui.message')!.textContent = msg + (msgCount > 1 ? ` (${msgCount})` : '');
|
||||||
|
msgContainer.prepend(msgDiv);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldIgnoreError(err: Error) {
|
||||||
|
const ignorePatterns: Array<RegExp> = [
|
||||||
|
// https://github.com/go-gitea/gitea/issues/30861
|
||||||
|
// https://github.com/microsoft/monaco-editor/issues/4496
|
||||||
|
// https://github.com/microsoft/monaco-editor/issues/4679
|
||||||
|
/\/assets\/js\/.*(monaco|editor\.(api|worker))/,
|
||||||
|
];
|
||||||
|
for (const pattern of ignorePatterns) {
|
||||||
|
if (pattern.test(err.stack ?? '')) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function processWindowErrorEvent({error, reason, message, type, filename, lineno, colno}: ErrorEvent & PromiseRejectionEvent) {
|
||||||
|
const err = error ?? reason;
|
||||||
|
const assetBaseUrl = String(new URL(`${window.config?.assetUrlPrefix ?? '/assets'}/`, window.location.origin));
|
||||||
|
const {runModeIsProd} = window.config ?? {};
|
||||||
|
|
||||||
|
// `error` and `reason` are not guaranteed to be errors. If the value is falsy, it is likely a
|
||||||
|
// non-critical event from the browser. We log them but don't show them to users. Examples:
|
||||||
|
// - https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver#observation_errors
|
||||||
|
// - https://github.com/mozilla-mobile/firefox-ios/issues/10817
|
||||||
|
// - https://github.com/go-gitea/gitea/issues/20240
|
||||||
|
if (!err) {
|
||||||
|
if (message) console.error(new Error(message));
|
||||||
|
if (runModeIsProd) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (err instanceof Error) {
|
||||||
|
// If the error stack trace does not include the base URL of our script assets, it likely came
|
||||||
|
// from a browser extension or inline script. Do not show such errors in production.
|
||||||
|
if (!err.stack?.includes(assetBaseUrl) && runModeIsProd) return;
|
||||||
|
// Ignore some known errors that are unable to fix
|
||||||
|
if (shouldIgnoreError(err)) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let msg = err?.message ?? message;
|
||||||
|
if (lineno) msg += ` (${filename} @ ${lineno}:${colno})`;
|
||||||
|
const dot = msg.endsWith('.') ? '' : '.';
|
||||||
|
const renderedType = type === 'unhandledrejection' ? 'promise rejection' : type;
|
||||||
|
showGlobalErrorMessage(`JavaScript ${renderedType}: ${msg}${dot} Open browser console to see more details.`);
|
||||||
|
}
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
import $ from 'jquery';
|
|
||||||
import {initAriaCheckboxPatch} from './fomantic/checkbox.ts';
|
import {initAriaCheckboxPatch} from './fomantic/checkbox.ts';
|
||||||
import {initAriaFormFieldPatch} from './fomantic/form.ts';
|
import {initAriaFormFieldPatch} from './fomantic/form.ts';
|
||||||
import {initAriaDropdownPatch} from './fomantic/dropdown.ts';
|
import {initAriaDropdownPatch} from './fomantic/dropdown.ts';
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import $ from 'jquery';
|
|
||||||
import {generateElemId} from '../../utils/dom.ts';
|
import {generateElemId} from '../../utils/dom.ts';
|
||||||
|
|
||||||
export function linkLabelAndInput(label: Element, input: Element) {
|
export function linkLabelAndInput(label: Element, input: Element) {
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import $ from 'jquery';
|
|
||||||
import {queryElemChildren} from '../../utils/dom.ts';
|
import {queryElemChildren} from '../../utils/dom.ts';
|
||||||
|
|
||||||
export function initFomanticDimmer() {
|
export function initFomanticDimmer() {
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import $ from 'jquery';
|
|
||||||
import type {FomanticInitFunction} from '../../types.ts';
|
import type {FomanticInitFunction} from '../../types.ts';
|
||||||
import {generateElemId, queryElems} from '../../utils/dom.ts';
|
import {generateElemId, queryElems} from '../../utils/dom.ts';
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import $ from 'jquery';
|
|
||||||
import type {FomanticInitFunction} from '../../types.ts';
|
import type {FomanticInitFunction} from '../../types.ts';
|
||||||
import {queryElems} from '../../utils/dom.ts';
|
import {queryElems} from '../../utils/dom.ts';
|
||||||
import {hideToastsFrom} from '../toast.ts';
|
import {hideToastsFrom} from '../toast.ts';
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import $ from 'jquery';
|
|
||||||
import {queryElemSiblings} from '../../utils/dom.ts';
|
import {queryElemSiblings} from '../../utils/dom.ts';
|
||||||
|
|
||||||
export function initFomanticTab() {
|
export function initFomanticTab() {
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import $ from 'jquery';
|
|
||||||
|
|
||||||
export function initFomanticTransition() {
|
export function initFomanticTransition() {
|
||||||
const transitionNopBehaviors = new Set([
|
const transitionNopBehaviors = new Set([
|
||||||
'clear queue', 'stop', 'stop all', 'destroy',
|
'clear queue', 'stop', 'stop all', 'destroy',
|
||||||
|
|||||||
17
web_src/js/modules/monaco.ts
Normal file
17
web_src/js/modules/monaco.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import editorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker';
|
||||||
|
import jsonWorker from 'monaco-editor/esm/vs/language/json/json.worker?worker';
|
||||||
|
import cssWorker from 'monaco-editor/esm/vs/language/css/css.worker?worker';
|
||||||
|
import htmlWorker from 'monaco-editor/esm/vs/language/html/html.worker?worker';
|
||||||
|
import tsWorker from 'monaco-editor/esm/vs/language/typescript/ts.worker?worker';
|
||||||
|
|
||||||
|
window.MonacoEnvironment = {
|
||||||
|
getWorker(_: string, label: string) {
|
||||||
|
if (label === 'json') return new jsonWorker();
|
||||||
|
if (label === 'css' || label === 'scss' || label === 'less') return new cssWorker();
|
||||||
|
if (label === 'html' || label === 'handlebars' || label === 'razor') return new htmlWorker();
|
||||||
|
if (label === 'typescript' || label === 'javascript') return new tsWorker();
|
||||||
|
return new editorWorker();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export * from 'monaco-editor';
|
||||||
@@ -3,7 +3,7 @@ import type SortableType from 'sortablejs';
|
|||||||
|
|
||||||
export async function createSortable(el: HTMLElement, opts: {handle?: string} & SortableOptions = {}): Promise<SortableType> {
|
export async function createSortable(el: HTMLElement, opts: {handle?: string} & SortableOptions = {}): Promise<SortableType> {
|
||||||
// type reassigned because typescript derives the wrong type from this import
|
// type reassigned because typescript derives the wrong type from this import
|
||||||
const {Sortable} = (await import(/* webpackChunkName: "sortablejs" */'sortablejs') as unknown as {Sortable: typeof SortableType});
|
const {Sortable} = (await import('sortablejs') as unknown as {Sortable: typeof SortableType});
|
||||||
|
|
||||||
return new Sortable(el, {
|
return new Sortable(el, {
|
||||||
animation: 150,
|
animation: 150,
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
const {appSubUrl, assetVersionEncoded} = window.config;
|
const {appSubUrl, sharedWorkerUri} = window.config;
|
||||||
|
|
||||||
export class UserEventsSharedWorker {
|
export class UserEventsSharedWorker {
|
||||||
sharedWorker: SharedWorker;
|
sharedWorker: SharedWorker;
|
||||||
|
|
||||||
// options can be either a string (the debug name of the worker) or an object of type WorkerOptions
|
// options can be either a string (the debug name of the worker) or an object of type WorkerOptions
|
||||||
constructor(options?: string | WorkerOptions) {
|
constructor(options?: string | WorkerOptions) {
|
||||||
const worker = new SharedWorker(`${window.__webpack_public_path__}js/eventsource.sharedworker.js?v=${assetVersionEncoded}`, options);
|
const worker = new SharedWorker(sharedWorkerUri, options);
|
||||||
this.sharedWorker = worker;
|
this.sharedWorker = worker;
|
||||||
worker.addEventListener('error', (event) => {
|
worker.addEventListener('error', (event) => {
|
||||||
console.error('worker error', event);
|
console.error('worker error', event);
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ export function newRenderPlugin3DViewer(): FileRenderPlugin {
|
|||||||
|
|
||||||
async render(container: HTMLElement, fileUrl: string): Promise<void> {
|
async render(container: HTMLElement, fileUrl: string): Promise<void> {
|
||||||
// TODO: height and/or max-height?
|
// TODO: height and/or max-height?
|
||||||
const OV = await import(/* webpackChunkName: "online-3d-viewer" */'online-3d-viewer');
|
const OV = await import('online-3d-viewer');
|
||||||
const viewer = new OV.EmbeddedViewer(container, {
|
const viewer = new OV.EmbeddedViewer(container, {
|
||||||
backgroundColor: new OV.RGBAColor(59, 68, 76, 0),
|
backgroundColor: new OV.RGBAColor(59, 68, 76, 0),
|
||||||
defaultColor: new OV.RGBColor(65, 131, 196),
|
defaultColor: new OV.RGBColor(65, 131, 196),
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ export function newRenderPluginPdfViewer(): FileRenderPlugin {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async render(container: HTMLElement, fileUrl: string): Promise<void> {
|
async render(container: HTMLElement, fileUrl: string): Promise<void> {
|
||||||
const PDFObject = await import(/* webpackChunkName: "pdfobject" */'pdfobject');
|
const PDFObject = await import('pdfobject');
|
||||||
// TODO: the PDFObject library does not support dynamic height adjustment,
|
// TODO: the PDFObject library does not support dynamic height adjustment,
|
||||||
container.style.height = `${window.innerHeight - 100}px`;
|
container.style.height = `${window.innerHeight - 100}px`;
|
||||||
if (!PDFObject.default.embed(fileUrl, container)) {
|
if (!PDFObject.default.embed(fileUrl, container)) {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import '../../css/standalone/devtest.css';
|
||||||
import {showInfoToast, showWarningToast, showErrorToast, type Toast} from '../modules/toast.ts';
|
import {showInfoToast, showWarningToast, showErrorToast, type Toast} from '../modules/toast.ts';
|
||||||
|
|
||||||
type LevelMap = Record<string, (message: string) => Toast | null>;
|
type LevelMap = Record<string, (message: string) => Toast | null>;
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ RENDER_COMMAND = `echo '<div style="width: 100%; height: 2000px; border: 10px so
|
|||||||
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import '../../css/standalone/external-render-iframe.css';
|
||||||
|
|
||||||
function mainExternalRenderIframe() {
|
function mainExternalRenderIframe() {
|
||||||
const u = new URL(window.location.href);
|
const u = new URL(window.location.href);
|
||||||
const iframeId = u.searchParams.get('gitea-iframe-id');
|
const iframeId = u.searchParams.get('gitea-iframe-id');
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
|
import '../../css/standalone/swagger.css';
|
||||||
import SwaggerUI from 'swagger-ui-dist/swagger-ui-es-bundle.js';
|
import SwaggerUI from 'swagger-ui-dist/swagger-ui-es-bundle.js';
|
||||||
import 'swagger-ui-dist/swagger-ui.css';
|
import 'swagger-ui-dist/swagger-ui.css';
|
||||||
import {load as loadYaml} from 'js-yaml';
|
import {load as loadYaml} from 'js-yaml';
|
||||||
import {GET} from '../modules/fetch.ts';
|
|
||||||
|
|
||||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)');
|
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)');
|
||||||
const apply = () => document.documentElement.classList.toggle('dark-mode', prefersDark.matches);
|
const apply = () => document.documentElement.classList.toggle('dark-mode', prefersDark.matches);
|
||||||
@@ -13,7 +13,7 @@ window.addEventListener('load', async () => {
|
|||||||
const url = elSwaggerUi.getAttribute('data-source')!;
|
const url = elSwaggerUi.getAttribute('data-source')!;
|
||||||
let spec: any;
|
let spec: any;
|
||||||
if (url) {
|
if (url) {
|
||||||
const res = await GET(url);
|
const res = await fetch(url); // eslint-disable-line no-restricted-globals
|
||||||
spec = await res.json();
|
spec = await res.json();
|
||||||
} else {
|
} else {
|
||||||
const elSpecContent = elSwaggerUi.querySelector<HTMLTextAreaElement>('.swagger-spec-content')!;
|
const elSpecContent = elSwaggerUi.querySelector<HTMLTextAreaElement>('.swagger-spec-content')!;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
// even if backend is in testing mode, frontend could be complied in production mode
|
// even if backend is in testing mode, frontend could be complied in production mode
|
||||||
// so this function only checks if the frontend is in unit testing mode (usually from *.test.ts files)
|
// so this function only checks if the frontend is in unit testing mode (usually from *.test.ts files)
|
||||||
export function isInFrontendUnitTest() {
|
export function isInFrontendUnitTest() {
|
||||||
return import.meta.env.TEST === 'true';
|
return import.meta.env.MODE === 'test';
|
||||||
}
|
}
|
||||||
|
|
||||||
/** strip common indentation from a string and trim it */
|
/** strip common indentation from a string and trim it */
|
||||||
|
|||||||
@@ -1,10 +1,20 @@
|
|||||||
window.__webpack_public_path__ = '';
|
// Stub APIs not implemented by happy-dom but needed by dependencies
|
||||||
|
// XPathEvaluator is used by htmx at module evaluation time
|
||||||
|
// TODO: Remove after https://github.com/capricorn86/happy-dom/pull/2103 is released
|
||||||
|
if (!globalThis.XPathEvaluator) {
|
||||||
|
globalThis.XPathEvaluator = class {
|
||||||
|
createExpression() { return {evaluate: () => ({iterateNext: () => null})} }
|
||||||
|
} as any;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dynamic import so polyfills above are applied before htmx evaluates
|
||||||
|
await import('./globals.ts');
|
||||||
|
|
||||||
window.config = {
|
window.config = {
|
||||||
appUrl: 'http://localhost:3000/',
|
appUrl: 'http://localhost:3000/',
|
||||||
appSubUrl: '',
|
appSubUrl: '',
|
||||||
assetVersionEncoded: '',
|
|
||||||
assetUrlPrefix: '',
|
assetUrlPrefix: '',
|
||||||
|
sharedWorkerUri: '',
|
||||||
runModeIsProd: true,
|
runModeIsProd: true,
|
||||||
customEmojis: {},
|
customEmojis: {},
|
||||||
pageData: {},
|
pageData: {},
|
||||||
@@ -13,3 +23,5 @@ window.config = {
|
|||||||
mermaidMaxSourceCharacters: 5000,
|
mermaidMaxSourceCharacters: 5000,
|
||||||
i18n: {},
|
i18n: {},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export {}; // mark as module for top-level await
|
||||||
|
|||||||
@@ -8,4 +8,4 @@ https://developer.mozilla.org/en-US/docs/Web/Web_Components
|
|||||||
|
|
||||||
* These components are loaded in `<head>` (before DOM body) in a separate entry point, they need to be lightweight to not affect the page loading time too much.
|
* These components are loaded in `<head>` (before DOM body) in a separate entry point, they need to be lightweight to not affect the page loading time too much.
|
||||||
* Do not import `svg.js` into a web component because that file is currently not tree-shakeable, import svg files individually insteat.
|
* Do not import `svg.js` into a web component because that file is currently not tree-shakeable, import svg files individually insteat.
|
||||||
* All our components must be added to `webpack.config.js` so they work correctly in Vue.
|
* All our components must be added to `vite.config.ts` so they work correctly in Vue.
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
import {throttle} from 'throttle-debounce';
|
import {throttle} from 'throttle-debounce';
|
||||||
import {createTippy} from '../modules/tippy.ts';
|
import {addDelegatedEventListener, generateElemId, isDocumentFragmentOrElementNode} from '../utils/dom.ts';
|
||||||
import {addDelegatedEventListener, isDocumentFragmentOrElementNode} from '../utils/dom.ts';
|
|
||||||
import octiconKebabHorizontal from '../../../public/assets/img/svg/octicon-kebab-horizontal.svg';
|
import octiconKebabHorizontal from '../../../public/assets/img/svg/octicon-kebab-horizontal.svg';
|
||||||
|
|
||||||
window.customElements.define('overflow-menu', class extends HTMLElement {
|
window.customElements.define('overflow-menu', class extends HTMLElement {
|
||||||
tippyContent: HTMLDivElement;
|
popup: HTMLDivElement;
|
||||||
tippyItems: Array<HTMLElement>;
|
overflowItems: Array<HTMLElement>;
|
||||||
button: HTMLButtonElement | null;
|
button: HTMLButtonElement | null;
|
||||||
menuItemsEl: HTMLElement;
|
menuItemsEl: HTMLElement;
|
||||||
resizeObserver: ResizeObserver;
|
resizeObserver: ResizeObserver;
|
||||||
@@ -13,18 +12,42 @@ window.customElements.define('overflow-menu', class extends HTMLElement {
|
|||||||
lastWidth: number;
|
lastWidth: number;
|
||||||
|
|
||||||
updateButtonActivationState() {
|
updateButtonActivationState() {
|
||||||
if (!this.button || !this.tippyContent) return;
|
if (!this.button || !this.popup) return;
|
||||||
this.button.classList.toggle('active', Boolean(this.tippyContent.querySelector('.item.active')));
|
this.button.classList.toggle('active', Boolean(this.popup.querySelector('.item.active')));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
showPopup() {
|
||||||
|
if (!this.popup || this.popup.style.display !== 'none') return;
|
||||||
|
this.popup.style.display = '';
|
||||||
|
this.button!.setAttribute('aria-expanded', 'true');
|
||||||
|
setTimeout(() => this.popup.focus(), 0);
|
||||||
|
document.addEventListener('click', this.onClickOutside, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
hidePopup() {
|
||||||
|
if (!this.popup || this.popup.style.display === 'none') return;
|
||||||
|
this.popup.style.display = 'none';
|
||||||
|
this.button?.setAttribute('aria-expanded', 'false');
|
||||||
|
document.removeEventListener('click', this.onClickOutside, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
onClickOutside = (e: Event) => {
|
||||||
|
if (!this.popup?.contains(e.target as Node) && !this.button?.contains(e.target as Node)) {
|
||||||
|
this.hidePopup();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
updateItems = throttle(100, () => {
|
updateItems = throttle(100, () => {
|
||||||
if (!this.tippyContent) {
|
if (!this.popup) {
|
||||||
const div = document.createElement('div');
|
const div = document.createElement('div');
|
||||||
|
div.classList.add('overflow-menu-popup');
|
||||||
|
div.setAttribute('role', 'menu');
|
||||||
div.tabIndex = -1; // for initial focus, programmatic focus only
|
div.tabIndex = -1; // for initial focus, programmatic focus only
|
||||||
|
div.style.display = 'none';
|
||||||
div.addEventListener('keydown', (e) => {
|
div.addEventListener('keydown', (e) => {
|
||||||
if (e.isComposing) return;
|
if (e.isComposing) return;
|
||||||
if (e.key === 'Tab') {
|
if (e.key === 'Tab') {
|
||||||
const items = this.tippyContent.querySelectorAll<HTMLElement>('[role="menuitem"]');
|
const items = this.popup.querySelectorAll<HTMLElement>('[role="menuitem"]');
|
||||||
if (e.shiftKey) {
|
if (e.shiftKey) {
|
||||||
if (document.activeElement === items[0]) {
|
if (document.activeElement === items[0]) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -39,7 +62,7 @@ window.customElements.define('overflow-menu', class extends HTMLElement {
|
|||||||
} else if (e.key === 'Escape') {
|
} else if (e.key === 'Escape') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
this.button?._tippy.hide();
|
this.hidePopup();
|
||||||
this.button?.focus();
|
this.button?.focus();
|
||||||
} else if (e.key === ' ' || e.code === 'Enter') {
|
} else if (e.key === ' ' || e.code === 'Enter') {
|
||||||
if (document.activeElement?.matches('[role="menuitem"]')) {
|
if (document.activeElement?.matches('[role="menuitem"]')) {
|
||||||
@@ -48,20 +71,20 @@ window.customElements.define('overflow-menu', class extends HTMLElement {
|
|||||||
(document.activeElement as HTMLElement).click();
|
(document.activeElement as HTMLElement).click();
|
||||||
}
|
}
|
||||||
} else if (e.key === 'ArrowDown') {
|
} else if (e.key === 'ArrowDown') {
|
||||||
if (document.activeElement?.matches('.tippy-target')) {
|
if (document.activeElement === this.popup) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
document.activeElement.querySelector<HTMLElement>('[role="menuitem"]:first-of-type')?.focus();
|
this.popup.querySelector<HTMLElement>('[role="menuitem"]:first-of-type')?.focus();
|
||||||
} else if (document.activeElement?.matches('[role="menuitem"]')) {
|
} else if (document.activeElement?.matches('[role="menuitem"]')) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
(document.activeElement.nextElementSibling as HTMLElement)?.focus();
|
(document.activeElement.nextElementSibling as HTMLElement)?.focus();
|
||||||
}
|
}
|
||||||
} else if (e.key === 'ArrowUp') {
|
} else if (e.key === 'ArrowUp') {
|
||||||
if (document.activeElement?.matches('.tippy-target')) {
|
if (document.activeElement === this.popup) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
document.activeElement.querySelector<HTMLElement>('[role="menuitem"]:last-of-type')?.focus();
|
this.popup.querySelector<HTMLElement>('[role="menuitem"]:last-of-type')?.focus();
|
||||||
} else if (document.activeElement?.matches('[role="menuitem"]')) {
|
} else if (document.activeElement?.matches('[role="menuitem"]')) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -69,16 +92,15 @@ window.customElements.define('overflow-menu', class extends HTMLElement {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
div.classList.add('tippy-target');
|
this.handleItemClick(div, '.overflow-menu-popup > .item');
|
||||||
this.handleItemClick(div, '.tippy-target > .item');
|
this.popup = div;
|
||||||
this.tippyContent = div;
|
} // end if: no popup and create a new one
|
||||||
} // end if: no tippyContent and create a new one
|
|
||||||
|
|
||||||
const itemFlexSpace = this.menuItemsEl.querySelector<HTMLSpanElement>('.item-flex-space');
|
const itemFlexSpace = this.menuItemsEl.querySelector<HTMLSpanElement>('.item-flex-space');
|
||||||
const itemOverFlowMenuButton = this.querySelector<HTMLButtonElement>('.overflow-menu-button');
|
const itemOverFlowMenuButton = this.querySelector<HTMLButtonElement>('.overflow-menu-button');
|
||||||
|
|
||||||
// move items in tippy back into the menu items for subsequent measurement
|
// move items in popup back into the menu items for subsequent measurement
|
||||||
for (const item of this.tippyItems || []) {
|
for (const item of this.overflowItems || []) {
|
||||||
if (!itemFlexSpace || item.getAttribute('data-after-flex-space')) {
|
if (!itemFlexSpace || item.getAttribute('data-after-flex-space')) {
|
||||||
this.menuItemsEl.append(item);
|
this.menuItemsEl.append(item);
|
||||||
} else {
|
} else {
|
||||||
@@ -90,7 +112,7 @@ window.customElements.define('overflow-menu', class extends HTMLElement {
|
|||||||
// flex space and overflow menu are excluded from measurement
|
// flex space and overflow menu are excluded from measurement
|
||||||
itemFlexSpace?.style.setProperty('display', 'none', 'important');
|
itemFlexSpace?.style.setProperty('display', 'none', 'important');
|
||||||
itemOverFlowMenuButton?.style.setProperty('display', 'none', 'important');
|
itemOverFlowMenuButton?.style.setProperty('display', 'none', 'important');
|
||||||
this.tippyItems = [];
|
this.overflowItems = [];
|
||||||
const menuRight = this.offsetLeft + this.offsetWidth;
|
const menuRight = this.offsetLeft + this.offsetWidth;
|
||||||
const menuItems = this.menuItemsEl.querySelectorAll<HTMLElement>('.item, .item-flex-space');
|
const menuItems = this.menuItemsEl.querySelectorAll<HTMLElement>('.item, .item-flex-space');
|
||||||
let afterFlexSpace = false;
|
let afterFlexSpace = false;
|
||||||
@@ -102,64 +124,64 @@ window.customElements.define('overflow-menu', class extends HTMLElement {
|
|||||||
if (afterFlexSpace) item.setAttribute('data-after-flex-space', 'true');
|
if (afterFlexSpace) item.setAttribute('data-after-flex-space', 'true');
|
||||||
const itemRight = item.offsetLeft + item.offsetWidth;
|
const itemRight = item.offsetLeft + item.offsetWidth;
|
||||||
if (menuRight - itemRight < 38) { // roughly the width of .overflow-menu-button with some extra space
|
if (menuRight - itemRight < 38) { // roughly the width of .overflow-menu-button with some extra space
|
||||||
const onlyLastItem = idx === menuItems.length - 1 && this.tippyItems.length === 0;
|
const onlyLastItem = idx === menuItems.length - 1 && this.overflowItems.length === 0;
|
||||||
const lastItemFit = onlyLastItem && menuRight - itemRight > 0;
|
const lastItemFit = onlyLastItem && menuRight - itemRight > 0;
|
||||||
const moveToPopup = !onlyLastItem || !lastItemFit;
|
const moveToPopup = !onlyLastItem || !lastItemFit;
|
||||||
if (moveToPopup) this.tippyItems.push(item);
|
if (moveToPopup) this.overflowItems.push(item);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
itemFlexSpace?.style.removeProperty('display');
|
itemFlexSpace?.style.removeProperty('display');
|
||||||
itemOverFlowMenuButton?.style.removeProperty('display');
|
itemOverFlowMenuButton?.style.removeProperty('display');
|
||||||
|
|
||||||
// if there are no overflown items, remove any previously created button
|
// if there are no overflown items, remove any previously created button
|
||||||
if (!this.tippyItems?.length) {
|
if (!this.overflowItems?.length) {
|
||||||
const btn = this.querySelector('.overflow-menu-button');
|
this.hidePopup();
|
||||||
btn?._tippy?.destroy();
|
this.button?.remove();
|
||||||
btn?.remove();
|
this.popup?.remove();
|
||||||
this.button = null;
|
this.button = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// remove aria role from items that moved from tippy to menu
|
// remove aria role from items that moved from popup to menu
|
||||||
for (const item of menuItems) {
|
for (const item of menuItems) {
|
||||||
if (!this.tippyItems.includes(item)) {
|
if (!this.overflowItems.includes(item)) {
|
||||||
item.removeAttribute('role');
|
item.removeAttribute('role');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// move all items that overflow into tippy
|
// move all items that overflow into popup
|
||||||
for (const item of this.tippyItems) {
|
for (const item of this.overflowItems) {
|
||||||
item.setAttribute('role', 'menuitem');
|
item.setAttribute('role', 'menuitem');
|
||||||
this.tippyContent.append(item);
|
this.popup.append(item);
|
||||||
}
|
}
|
||||||
|
|
||||||
// update existing tippy
|
// update existing popup
|
||||||
if (this.button?._tippy) {
|
if (this.button) {
|
||||||
this.button._tippy.setContent(this.tippyContent);
|
|
||||||
this.updateButtonActivationState();
|
this.updateButtonActivationState();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// create button initially
|
// create button and attach popup
|
||||||
|
const popupId = generateElemId('overflow-popup-');
|
||||||
|
this.popup.id = popupId;
|
||||||
|
|
||||||
this.button = document.createElement('button');
|
this.button = document.createElement('button');
|
||||||
this.button.classList.add('overflow-menu-button');
|
this.button.classList.add('overflow-menu-button');
|
||||||
this.button.setAttribute('aria-label', window.config.i18n.more_items);
|
this.button.setAttribute('aria-label', window.config.i18n.more_items);
|
||||||
|
this.button.setAttribute('aria-haspopup', 'true');
|
||||||
|
this.button.setAttribute('aria-expanded', 'false');
|
||||||
|
this.button.setAttribute('aria-controls', popupId);
|
||||||
this.button.innerHTML = octiconKebabHorizontal;
|
this.button.innerHTML = octiconKebabHorizontal;
|
||||||
this.append(this.button);
|
this.button.addEventListener('click', (e) => {
|
||||||
createTippy(this.button, {
|
e.stopPropagation();
|
||||||
trigger: 'click',
|
if (this.popup.style.display === 'none') {
|
||||||
hideOnClick: true,
|
this.showPopup();
|
||||||
interactive: true,
|
} else {
|
||||||
placement: 'bottom-end',
|
this.hidePopup();
|
||||||
role: 'menu',
|
}
|
||||||
theme: 'menu',
|
|
||||||
content: this.tippyContent,
|
|
||||||
onShow: () => { // FIXME: onShown doesn't work (never be called)
|
|
||||||
setTimeout(() => {
|
|
||||||
this.tippyContent.focus();
|
|
||||||
}, 0);
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
this.append(this.button);
|
||||||
|
this.append(this.popup);
|
||||||
this.updateButtonActivationState();
|
this.updateButtonActivationState();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -202,7 +224,7 @@ window.customElements.define('overflow-menu', class extends HTMLElement {
|
|||||||
|
|
||||||
handleItemClick(el: Element, selector: string) {
|
handleItemClick(el: Element, selector: string) {
|
||||||
addDelegatedEventListener(el, 'click', selector, () => {
|
addDelegatedEventListener(el, 'click', selector, () => {
|
||||||
this.button?._tippy?.hide();
|
this.hidePopup();
|
||||||
this.updateButtonActivationState();
|
this.updateButtonActivationState();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -239,5 +261,6 @@ window.customElements.define('overflow-menu', class extends HTMLElement {
|
|||||||
disconnectedCallback() {
|
disconnectedCallback() {
|
||||||
this.mutationObserver?.disconnect();
|
this.mutationObserver?.disconnect();
|
||||||
this.resizeObserver?.disconnect();
|
this.resizeObserver?.disconnect();
|
||||||
|
document.removeEventListener('click', this.onClickOutside, true);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,268 +0,0 @@
|
|||||||
import wrapAnsi from 'wrap-ansi';
|
|
||||||
import AddAssetPlugin from 'add-asset-webpack-plugin';
|
|
||||||
import LicenseCheckerWebpackPlugin from '@techknowlogick/license-checker-webpack-plugin';
|
|
||||||
import MiniCssExtractPlugin from 'mini-css-extract-plugin';
|
|
||||||
import MonacoWebpackPlugin from 'monaco-editor-webpack-plugin';
|
|
||||||
import {VueLoaderPlugin} from 'vue-loader';
|
|
||||||
import {EsbuildPlugin} from 'esbuild-loader';
|
|
||||||
import {parse} from 'node:path';
|
|
||||||
import webpack, {type Configuration, type EntryObject} from 'webpack';
|
|
||||||
import {fileURLToPath} from 'node:url';
|
|
||||||
import {readFileSync, globSync} from 'node:fs';
|
|
||||||
import {env} from 'node:process';
|
|
||||||
import tailwindcss from 'tailwindcss';
|
|
||||||
import tailwindConfig from './tailwind.config.ts';
|
|
||||||
|
|
||||||
const {SourceMapDevToolPlugin, DefinePlugin, EnvironmentPlugin} = webpack;
|
|
||||||
const formatLicenseText = (licenseText: string) => wrapAnsi(licenseText || '', 80).trim();
|
|
||||||
|
|
||||||
const themes: EntryObject = {};
|
|
||||||
for (const path of globSync('web_src/css/themes/*.css', {cwd: import.meta.dirname})) {
|
|
||||||
themes[parse(path).name] = [`./${path}`];
|
|
||||||
}
|
|
||||||
|
|
||||||
const isProduction = env.NODE_ENV !== 'development';
|
|
||||||
|
|
||||||
// ENABLE_SOURCEMAP accepts the following values:
|
|
||||||
// true - all enabled, the default in development
|
|
||||||
// reduced - minimal sourcemaps, the default in production
|
|
||||||
// false - all disabled
|
|
||||||
let sourceMaps;
|
|
||||||
if ('ENABLE_SOURCEMAP' in env) {
|
|
||||||
sourceMaps = ['true', 'false'].includes(env.ENABLE_SOURCEMAP || '') ? env.ENABLE_SOURCEMAP : 'reduced';
|
|
||||||
} else {
|
|
||||||
sourceMaps = isProduction ? 'reduced' : 'true';
|
|
||||||
}
|
|
||||||
|
|
||||||
// define which web components we use for Vue to not interpret them as Vue components
|
|
||||||
const webComponents = new Set([
|
|
||||||
// our own, in web_src/js/webcomponents
|
|
||||||
'overflow-menu',
|
|
||||||
'origin-url',
|
|
||||||
// from dependencies
|
|
||||||
'markdown-toolbar',
|
|
||||||
'relative-time',
|
|
||||||
'text-expander',
|
|
||||||
]);
|
|
||||||
|
|
||||||
const filterCssImport = (url: string, ...args: Array<any>) => {
|
|
||||||
const cssFile = args[1] || args[0]; // resourcePath is 2nd argument for url and 3rd for import
|
|
||||||
const importedFile = url.replace(/[?#].+/, '').toLowerCase();
|
|
||||||
|
|
||||||
if (cssFile.includes('fomantic')) {
|
|
||||||
if (importedFile.includes('brand-icons')) return false;
|
|
||||||
if (/(eot|ttf|otf|woff|svg)$/i.test(importedFile)) return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (cssFile.includes('katex') && /(ttf|woff)$/i.test(importedFile)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default {
|
|
||||||
mode: isProduction ? 'production' : 'development',
|
|
||||||
entry: {
|
|
||||||
index: [
|
|
||||||
fileURLToPath(new URL('web_src/js/index.ts', import.meta.url)),
|
|
||||||
fileURLToPath(new URL('web_src/css/index.css', import.meta.url)),
|
|
||||||
],
|
|
||||||
swagger: [
|
|
||||||
fileURLToPath(new URL('web_src/js/standalone/swagger.ts', import.meta.url)),
|
|
||||||
fileURLToPath(new URL('web_src/css/standalone/swagger.css', import.meta.url)),
|
|
||||||
],
|
|
||||||
'external-render-iframe': [
|
|
||||||
fileURLToPath(new URL('web_src/js/standalone/external-render-iframe.ts', import.meta.url)),
|
|
||||||
fileURLToPath(new URL('web_src/css/standalone/external-render-iframe.css', import.meta.url)),
|
|
||||||
],
|
|
||||||
'eventsource.sharedworker': [
|
|
||||||
fileURLToPath(new URL('web_src/js/features/eventsource.sharedworker.ts', import.meta.url)),
|
|
||||||
],
|
|
||||||
...(!isProduction && {
|
|
||||||
devtest: [
|
|
||||||
fileURLToPath(new URL('web_src/js/standalone/devtest.ts', import.meta.url)),
|
|
||||||
fileURLToPath(new URL('web_src/css/standalone/devtest.css', import.meta.url)),
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
...themes,
|
|
||||||
},
|
|
||||||
devtool: false,
|
|
||||||
output: {
|
|
||||||
path: fileURLToPath(new URL('public/assets', import.meta.url)),
|
|
||||||
filename: 'js/[name].js',
|
|
||||||
chunkFilename: 'js/[name].[contenthash:8].js',
|
|
||||||
},
|
|
||||||
optimization: {
|
|
||||||
minimize: isProduction,
|
|
||||||
minimizer: [
|
|
||||||
new EsbuildPlugin({
|
|
||||||
target: 'es2020',
|
|
||||||
minify: true,
|
|
||||||
css: true,
|
|
||||||
legalComments: 'none',
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
moduleIds: 'named',
|
|
||||||
chunkIds: 'named',
|
|
||||||
},
|
|
||||||
module: {
|
|
||||||
rules: [
|
|
||||||
{
|
|
||||||
test: /\.vue$/i,
|
|
||||||
exclude: /node_modules/,
|
|
||||||
loader: 'vue-loader',
|
|
||||||
options: {
|
|
||||||
compilerOptions: {
|
|
||||||
isCustomElement: (tag: string) => webComponents.has(tag),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
test: /\.js$/i,
|
|
||||||
exclude: /node_modules/,
|
|
||||||
use: [
|
|
||||||
{
|
|
||||||
loader: 'esbuild-loader',
|
|
||||||
options: {
|
|
||||||
loader: 'js',
|
|
||||||
target: 'es2020',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
test: /\.ts$/i,
|
|
||||||
exclude: /node_modules/,
|
|
||||||
use: [
|
|
||||||
{
|
|
||||||
loader: 'esbuild-loader',
|
|
||||||
options: {
|
|
||||||
loader: 'ts',
|
|
||||||
target: 'es2020',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
test: /\.css$/i,
|
|
||||||
use: [
|
|
||||||
{
|
|
||||||
loader: MiniCssExtractPlugin.loader,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
loader: 'css-loader',
|
|
||||||
options: {
|
|
||||||
sourceMap: sourceMaps === 'true',
|
|
||||||
url: {filter: filterCssImport},
|
|
||||||
import: {filter: filterCssImport},
|
|
||||||
importLoaders: 1,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
loader: 'postcss-loader',
|
|
||||||
options: {
|
|
||||||
postcssOptions: {
|
|
||||||
plugins: [
|
|
||||||
tailwindcss(tailwindConfig),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
test: /\.svg$/i,
|
|
||||||
include: fileURLToPath(new URL('public/assets/img/svg', import.meta.url)),
|
|
||||||
type: 'asset/source',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
test: /\.(ttf|woff2?)$/i,
|
|
||||||
type: 'asset/resource',
|
|
||||||
generator: {
|
|
||||||
filename: 'fonts/[name].[contenthash:8][ext]',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
plugins: [
|
|
||||||
new DefinePlugin({
|
|
||||||
__VUE_OPTIONS_API__: true, // at the moment, many Vue components still use the Vue Options API
|
|
||||||
__VUE_PROD_DEVTOOLS__: false, // do not enable devtools support in production
|
|
||||||
__VUE_PROD_HYDRATION_MISMATCH_DETAILS__: false, // https://github.com/vuejs/vue-cli/pull/7443
|
|
||||||
}),
|
|
||||||
// all environment variables used in bundled js via process.env must be declared here
|
|
||||||
new EnvironmentPlugin({
|
|
||||||
TEST: 'false',
|
|
||||||
}),
|
|
||||||
new VueLoaderPlugin(),
|
|
||||||
new MiniCssExtractPlugin({
|
|
||||||
filename: 'css/[name].css',
|
|
||||||
chunkFilename: 'css/[name].[contenthash:8].css',
|
|
||||||
}),
|
|
||||||
sourceMaps !== 'false' && new SourceMapDevToolPlugin({
|
|
||||||
filename: '[file].[contenthash:8].map',
|
|
||||||
...(sourceMaps === 'reduced' && {include: /^js\/index\.js$/}),
|
|
||||||
}),
|
|
||||||
new MonacoWebpackPlugin({
|
|
||||||
filename: 'js/monaco-[name].[contenthash:8].worker.js',
|
|
||||||
}),
|
|
||||||
isProduction ? new LicenseCheckerWebpackPlugin({
|
|
||||||
outputFilename: 'licenses.txt',
|
|
||||||
outputWriter: ({dependencies}: {dependencies: Array<Record<string, string>>}) => {
|
|
||||||
const line = '-'.repeat(80);
|
|
||||||
const goJson = readFileSync('assets/go-licenses.json', 'utf8');
|
|
||||||
const goModules = JSON.parse(goJson).map(({name, licenseText}: Record<string, string>) => {
|
|
||||||
return {name, body: formatLicenseText(licenseText)};
|
|
||||||
});
|
|
||||||
const jsModules = dependencies.map(({name, version, licenseName, licenseText}) => {
|
|
||||||
return {name, version, licenseName, body: formatLicenseText(licenseText)};
|
|
||||||
});
|
|
||||||
|
|
||||||
const modules = [...goModules, ...jsModules].sort((a, b) => a.name.localeCompare(b.name));
|
|
||||||
return modules.map(({name, version, licenseName, body}) => {
|
|
||||||
const title = licenseName ? `${name}@${version} - ${licenseName}` : name;
|
|
||||||
return `${line}\n${title}\n${line}\n${body}`;
|
|
||||||
}).join('\n');
|
|
||||||
},
|
|
||||||
override: {
|
|
||||||
'khroma@*': {licenseName: 'MIT'}, // https://github.com/fabiospampinato/khroma/pull/33
|
|
||||||
},
|
|
||||||
emitError: true,
|
|
||||||
allow: '(Apache-2.0 OR 0BSD OR BSD-2-Clause OR BSD-3-Clause OR MIT OR ISC OR CPAL-1.0 OR Unlicense OR EPL-1.0 OR EPL-2.0)',
|
|
||||||
}) : new AddAssetPlugin('licenses.txt', `Licenses are disabled during development`),
|
|
||||||
],
|
|
||||||
performance: {
|
|
||||||
hints: false,
|
|
||||||
maxEntrypointSize: Infinity,
|
|
||||||
maxAssetSize: Infinity,
|
|
||||||
},
|
|
||||||
resolve: {
|
|
||||||
symlinks: true,
|
|
||||||
modules: ['node_modules'],
|
|
||||||
},
|
|
||||||
watchOptions: {
|
|
||||||
ignored: [
|
|
||||||
'node_modules/**',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
stats: {
|
|
||||||
assetsSort: 'name',
|
|
||||||
assetsSpace: Infinity,
|
|
||||||
cached: false,
|
|
||||||
cachedModules: false,
|
|
||||||
children: false,
|
|
||||||
chunkModules: false,
|
|
||||||
chunkOrigins: false,
|
|
||||||
chunksSort: 'name',
|
|
||||||
colors: true,
|
|
||||||
entrypoints: false,
|
|
||||||
groupAssetsByChunk: false,
|
|
||||||
groupAssetsByEmitStatus: false,
|
|
||||||
groupAssetsByInfo: false,
|
|
||||||
groupModulesByAttributes: false,
|
|
||||||
modules: false,
|
|
||||||
reasons: false,
|
|
||||||
runtimeModules: false,
|
|
||||||
},
|
|
||||||
} satisfies Configuration;
|
|
||||||
Reference in New Issue
Block a user