diff --git a/.github/workflows/build-binary-for-release.yml b/.github/workflows/build-binary-for-release.yml index 2bce67142..76f1c9530 100644 --- a/.github/workflows/build-binary-for-release.yml +++ b/.github/workflows/build-binary-for-release.yml @@ -36,7 +36,7 @@ jobs: - name: Set up Node uses: actions/setup-node@v4 with: - node-version: 20.18.1 + node-version: 20.19.0 - name: Node Build run: make install-ui-packages ui diff --git a/.gitignore b/.gitignore index ba66f51a0..d5aed0da5 100644 --- a/.gitignore +++ b/.gitignore @@ -17,10 +17,8 @@ /go.work* /logs /ui/node_modules -/ui/build/*/*/* -/ui/build/*.json -/ui/build/*.html -/ui/build/*.txt +/ui/build/* +!/ui/build/favicon.ico /vendor Thumbs*.db tmp diff --git a/Makefile b/Makefile index 0623e1efd..1ea3ac3a4 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: build clean ui +.PHONY: build clean ui check-ui check-ui-assets check-ui-locales check-ui-plugin-i18n VERSION=2.0.2 BIN=answer @@ -47,6 +47,22 @@ check: test: @$(GO) test ./internal/repo/repo_test +# Frontend checks for behaviour a successful build does not demonstrate. +# Each guards a runtime failure that leaves every build step reporting success. +check-ui: check-ui-assets check-ui-locales check-ui-plugin-i18n + +# The server reads the built asset paths out of index.html. +check-ui-assets: + @./script/check-built-assets.sh + +# The app loads languages other than the default one through a dynamic import. +check-ui-locales: + @cd ui && pnpm check-locales + +# Plugin translations register while modules evaluate, in bundler-decided order. +check-ui-plugin-i18n: + @cd ui && pnpm check-plugin-i18n + # clean all build result clean: @$(GO) clean ./... diff --git a/go.mod b/go.mod index 5787c8b18..7b68c180c 100644 --- a/go.mod +++ b/go.mod @@ -64,6 +64,7 @@ require ( go.uber.org/mock v0.6.0 golang.org/x/crypto v0.53.0 golang.org/x/image v0.20.0 + golang.org/x/net v0.56.0 golang.org/x/term v0.44.0 golang.org/x/text v0.39.0 gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df @@ -170,7 +171,6 @@ require ( go.uber.org/zap v1.27.0 // indirect golang.org/x/arch v0.10.0 // indirect golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 // indirect - golang.org/x/net v0.56.0 // indirect golang.org/x/sys v0.46.0 // indirect golang.org/x/tools v0.47.0 // indirect google.golang.org/protobuf v1.34.2 // indirect diff --git a/internal/controller/template_controller.go b/internal/controller/template_controller.go index 31cc5152a..0f2f5b68b 100644 --- a/internal/controller/template_controller.go +++ b/internal/controller/template_controller.go @@ -20,6 +20,7 @@ package controller import ( + "bytes" "encoding/json" "fmt" "html/template" @@ -50,13 +51,17 @@ import ( "github.com/apache/answer/ui" "github.com/gin-gonic/gin" "github.com/segmentfault/pacman/log" + "golang.org/x/net/html" ) var SiteUrl = "" type TemplateController struct { - scriptPath []string - cssPath string + scriptPath []string + // cssPath lists every stylesheet the frontend build emits, in document + // order; a build that emits more than one entry stylesheet needs all of + // them, not just the first, or server-rendered pages come back unstyled. + cssPath []string templateRenderController *templaterender.TemplateRenderController siteInfoService siteinfo_common.SiteInfoCommonService eventQueueService eventqueue.Service @@ -83,24 +88,64 @@ func NewTemplateController( questionService: questionService, } } -func GetStyle() (script []string, css string) { +func GetStyle() (script []string, css []string) { file, err := ui.Build.ReadFile("build/index.html") if err != nil { return } - scriptRegexp := regexp.MustCompile(``) - scriptData := scriptRegexp.FindAllStringSubmatch(string(file), -1) - for _, s := range scriptData { - if len(s) == 2 { - script = append(script, s[1]) + + // Script and stylesheet tags are read from the parsed document, so + // attribute order, attribute set (module vs classic scripts), and + // quoting do not matter. That shape has already changed once; a + // bundler change that breaks it now fails the guarding test instead + // of silently shipping pages with no JS or CSS. + doc, err := html.Parse(bytes.NewReader(file)) + if err != nil { + return + } + + attr := func(n *html.Node, key string) (string, bool) { + for _, a := range n.Attr { + if a.Key == key { + return a.Val, true + } } + return "", false + } + isStylesheet := func(n *html.Node) bool { + rel, ok := attr(n, "rel") + if !ok { + return false + } + for tok := range strings.FieldsSeq(rel) { + if strings.EqualFold(tok, "stylesheet") { + return true + } + } + return false } - cssRegexp := regexp.MustCompile(``) - cssListData := cssRegexp.FindStringSubmatch(string(file)) - if len(cssListData) == 2 { - css = cssListData[1] + var walk func(*html.Node) + walk = func(n *html.Node) { + if n.Type == html.ElementNode { + switch n.Data { + case "script": + if src, ok := attr(n, "src"); ok && src != "" { + script = append(script, src) + } + case "link": + if isStylesheet(n) { + if href, ok := attr(n, "href"); ok && href != "" { + css = append(css, href) + } + } + } + } + for c := n.FirstChild; c != nil; c = c.NextSibling { + walk(c) + } } + walk(doc) return } func (tc *TemplateController) SiteInfo(ctx *gin.Context) *schema.TemplateSiteInfoResp { @@ -560,7 +605,7 @@ func (tc *TemplateController) Page404(ctx *gin.Context) { func (tc *TemplateController) html(ctx *gin.Context, code int, tpl string, siteInfo *schema.TemplateSiteInfoResp, data gin.H) { prefix := "" - cssPath := "" + cssPath := make([]string, len(tc.cssPath)) scriptPath := make([]string, len(tc.scriptPath)) _ = plugin.CallCDN(func(fn plugin.CDN) error { @@ -572,7 +617,9 @@ func (tc *TemplateController) html(ctx *gin.Context, code int, tpl string, siteI if prefix[len(prefix)-1:] == "/" { prefix = strings.TrimSuffix(prefix, "/") } - cssPath = prefix + tc.cssPath + for i, path := range tc.cssPath { + cssPath[i] = prefix + path + } for i, path := range tc.scriptPath { scriptPath[i] = prefix + path } diff --git a/internal/controller/template_controller_test.go b/internal/controller/template_controller_test.go new file mode 100644 index 000000000..74c0db0a6 --- /dev/null +++ b/internal/controller/template_controller_test.go @@ -0,0 +1,78 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package controller + +import ( + "strings" + "testing" + + "github.com/apache/answer/ui" + "github.com/stretchr/testify/require" +) + +// GetStyle scrapes the script and stylesheet paths out of the built +// index.html and every server-rendered page reuses them. The scrape is +// coupled to the exact attribute order and attribute set that the frontend +// build tool writes into those tags, and nothing in the system reports a +// mismatch: the frontend build still succeeds, the dev server still works, +// the binary still compiles, and the server-rendered pages simply come back +// with no script tags and no stylesheet. +// +// Assert the coupling directly so a change to the emitted tag shape fails +// here instead of shipping. +func TestGetStyleResolvesBuiltAssets(t *testing.T) { + const builtIndexPath = "build/index.html" + + raw, err := ui.Build.ReadFile(builtIndexPath) + if err != nil { + t.Skipf("no frontend build embedded at %s; build the frontend and re-run: %v", builtIndexPath, err) + } + + scripts, css := GetStyle() + + require.NotEmpty(t, scripts, + "no script sources parsed out of %s; server-rendered pages would load without any JavaScript", builtIndexPath) + for i, src := range scripts { + require.NotEmpty(t, src, "script source %d parsed out of %s is empty", i, builtIndexPath) + } + + require.NotEmpty(t, css, + "no stylesheet href parsed out of %s; server-rendered pages would load unstyled", builtIndexPath) + for i, href := range css { + require.NotEmpty(t, href, + "stylesheet href %d parsed out of %s is empty; server-rendered pages would load unstyled", i, builtIndexPath) + } + + // Finding every stylesheet matters as much as finding one. The build emits + // more than a single entry stylesheet, and a parser that stopped at the + // first one would still satisfy every assertion above while half the page's + // CSS silently stopped loading. That regression has happened once already. + // + // Count them again by a deliberately different and cruder method than the + // parser uses, so the two have to agree. It is a lower bound: a build that + // quotes attributes differently drives this to zero and the comparison + // simply stops constraining, which is why it supplements the assertions + // above rather than replacing them. + declared := strings.Count(string(raw), `rel="stylesheet"`) + require.GreaterOrEqual(t, len(css), declared, + "%s declares at least %d stylesheets but only %d were parsed out of it; "+ + "server-rendered pages would load missing part of their CSS", + builtIndexPath, declared, len(css)) +} diff --git a/script/check-built-assets.sh b/script/check-built-assets.sh new file mode 100755 index 000000000..6e8d2cc8b --- /dev/null +++ b/script/check-built-assets.sh @@ -0,0 +1,104 @@ +#!/bin/bash +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +# Builds the frontend and asserts the server can still find the built assets +# inside index.html. See internal/controller/template_controller_test.go for +# why that is not implied by a successful build. +# +# --skip-build reuse an existing ui/build, do not rebuild +# --self-check additionally rewrite ui/build/index.html so a script +# or stylesheet tag is missing, and confirm the check +# fails on the missing asset. Restores the real build +# output afterwards. + +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +INDEX_HTML="$REPO_ROOT/ui/build/index.html" +TEST_PACKAGE="./internal/controller/" +TEST_NAME="TestGetStyleResolvesBuiltAssets" + +list_output="$(cd "$REPO_ROOT" && go test "$TEST_PACKAGE" -list "^${TEST_NAME}$" 2>&1)" +if ! grep -qx "$TEST_NAME" <<<"$list_output"; then + echo "no test named $TEST_NAME in $TEST_PACKAGE; go test -run with a stale/renamed test name matches nothing and still exits 0, which would turn this check into a silent no-op" >&2 + exit 1 +fi + +skip_build=0 +self_check=0 +for arg in "$@"; do + case "$arg" in + --skip-build) skip_build=1 ;; + --self-check) self_check=1 ;; + *) echo "unknown option: $arg" >&2; exit 2 ;; + esac +done + +run_check() { + (cd "$REPO_ROOT" && go test -count=1 "$TEST_PACKAGE" -run "$TEST_NAME" "$@") +} + +if [ "$skip_build" -eq 0 ]; then + echo "==> building frontend" + (cd "$REPO_ROOT/ui" && pnpm build) +fi + +if [ ! -f "$INDEX_HTML" ]; then + echo "no built index.html at $INDEX_HTML; run without --skip-build" >&2 + exit 1 +fi + +echo "==> checking the server can parse the built asset tags" +run_check -v + +if [ "$self_check" -eq 0 ]; then + exit 0 +fi + +# Confirm the check actually fails when a required asset is missing from +# the build output. Without this, a check that silently stopped asserting +# anything would look identical to a passing one. +backup="$(mktemp)" +cp "$INDEX_HTML" "$backup" +trap 'cp "$backup" "$INDEX_HTML"; rm -f "$backup"' EXIT + +expect_failure() { + local label="$1" + local html="$2" + printf '%s' "$html" > "$INDEX_HTML" + echo "==> self-check: expecting failure on $label" + if run_check >/dev/null 2>&1; then + echo "SELF-CHECK FAILED: the check passed on $label, so it is not guarding anything" >&2 + exit 1 + fi + echo " check failed as expected" +} + +expect_failure "stylesheet link but no script src" \ + '
' + +expect_failure "script src but no stylesheet link" \ + '
' + +expect_failure "only an inline script, no src" \ + '
' + +expect_failure "manifest link but no stylesheet link" \ + '
' + +echo "==> self-check passed" diff --git a/ui/.env.development b/ui/.env.development index a634cee2e..3e28eec99 100644 --- a/ui/.env.development +++ b/ui/.env.development @@ -1,2 +1 @@ -PUBLIC_URL REACT_APP_API_URL = http://10.0.20.84:8080/ diff --git a/ui/.env.production b/ui/.env.production index f86b9ccdd..192714cf7 100644 --- a/ui/.env.production +++ b/ui/.env.production @@ -1,6 +1,4 @@ -TSC_COMPILE_ON_ERROR=true -ESLINT_NO_DEV_ERRORS=true -PUBLIC_URL=/ +REACT_APP_PUBLIC_URL=/ REACT_APP_API_URL=/ REACT_APP_BASE_URL= REACT_APP_API_BASE_URL= diff --git a/ui/.eslintignore b/ui/.eslintignore index 1e6d1c5cd..0e9877734 100644 --- a/ui/.eslintignore +++ b/ui/.eslintignore @@ -1,5 +1,4 @@ public -config-overrides.js commitlint.config.js build .eslintrc.js diff --git a/ui/.eslintrc.js b/ui/.eslintrc.js index 1d9052600..bd81c2d6b 100644 --- a/ui/.eslintrc.js +++ b/ui/.eslintrc.js @@ -24,7 +24,6 @@ module.exports = { es2021: true, }, extends: [ - 'react-app/jest', 'plugin:react/recommended', 'airbnb', 'airbnb-typescript', diff --git a/ui/.gitignore b/ui/.gitignore index 3b1e96bd4..653e95a4e 100644 --- a/ui/.gitignore +++ b/ui/.gitignore @@ -10,10 +10,8 @@ node_modules # production -/build/*/*/* -/build/*.json -/build/*.html -/build/*.txt +/build/* +!/build/favicon.ico # misc .DS_Store diff --git a/ui/config-overrides.js b/ui/config-overrides.js deleted file mode 100644 index 7d62b1d8e..000000000 --- a/ui/config-overrides.js +++ /dev/null @@ -1,154 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -const { - addWebpackModuleRule, - addWebpackAlias, - setWebpackOptimizationSplitChunks, - addWebpackPlugin, -} = require("customize-cra"); -const webpack = require('webpack'); - -const path = require("path"); -const i18nPath = path.resolve(__dirname, "../i18n"); - -module.exports = { - webpack: function(config, env) { - addWebpackAlias({ - "@": path.resolve(__dirname, "src"), - "@i18n": i18nPath, - buffer: 'buffer', - })(config); - - addWebpackModuleRule({ - test: /\.ya?ml$/, - use: "yaml-loader" - })(config); - - addWebpackPlugin( - new webpack.ProvidePlugin({ - Buffer: ['buffer', 'Buffer'], - }) - )(config); - - setWebpackOptimizationSplitChunks({ - maxInitialRequests: 20, - minSize: 20 * 1024, - minChunks: 2, - cacheGroups: { - automaticNamePrefix: 'chunk', - mix1: { - test: (module, chunks) => { - return ( - module.resource && - (module.resource.includes('components') || - /\/node_modules\/react-bootstrap\//.test(module.resource)) - ); - }, - name: 'chunk-mix1', - filename: 'static/js/[name].[contenthash:8].chunk.js', - priority: 14, - reuseExistingChunk: true, - minChunks: process.env.NODE_ENV === 'production' ? 1 : 2, - chunks: 'initial', - }, - mix2: { - name: 'chunk-mix2', - test: /[\/]node_modules[\/](i18next|lodash|marked|next-share)[\/]/, - filename: 'static/js/[name].[contenthash:8].chunk.js', - priority: 13, - reuseExistingChunk: true, - minChunks: 1, - chunks: 'initial', - }, - mix3: { - name: 'chunk-mix3', - test: /[\/]node_modules[\/](@remix-run|@restart|axios|diff)[\/]/, - filename: 'static/js/[name].[contenthash:8].chunk.js', - priority: 12, - reuseExistingChunk: true, - minChunks: 1, - chunks: 'initial', - }, - codemirror: { - name: 'codemirror', - test: /[\/]node_modules[\/](\@codemirror)[\/]/, - priority: 10, - reuseExistingChunk: true, - minChunks: process.env.NODE_ENV === 'production' ? 1 : 2, - chunks: 'initial', - enforce: true, - }, - lezer: { - name: 'lezer', - test: /[\/]node_modules[\/](\@lezer)[\/]/, - priority: 9, - reuseExistingChunk: true, - minChunks: process.env.NODE_ENV === 'production' ? 1 : 2, - chunks: 'initial', - enforce: true, - }, - reactDom: { - name: 'react-dom', - test: /[\/]node_modules[\/](react-dom)[\/]/, - filename: 'static/js/[name].[contenthash:8].chunk.js', - priority: 8, - reuseExistingChunk: true, - chunks: 'all', - enforce: true, - }, - nodesInitial: { - name: 'chunk-nodesInitial', - filename: 'static/js/[name].[contenthash:8].chunk.js', - test: /[\/]node_modules[\/]/, - priority: 1, - minChunks: 1, - chunks: 'initial', - reuseExistingChunk: true, - }, - }, - })(config); - - // add i18n dir to ModuleScopePlugin allowedPaths - const moduleScopePlugin = config.resolve.plugins.find(_ => _.constructor.name === "ModuleScopePlugin"); - if (moduleScopePlugin) { - moduleScopePlugin.allowedPaths.push(i18nPath); - } - - return config; - }, - devServer: function(configFunction) { - return function(proxy, allowedHost) { - const config = configFunction(proxy, allowedHost); - config.proxy = [ - { - context: ['/answer', '/installation'], - target: process.env.REACT_APP_API_URL, - changeOrigin: true, - secure: false, - }, - { - context: ['/custom.css'], - target: process.env.REACT_APP_API_URL, - } - ]; - return config; - }; - } -}; diff --git a/ui/public/index.html b/ui/index.html similarity index 97% rename from ui/public/index.html rename to ui/index.html index 5bca47e40..af48ece0b 100644 --- a/ui/public/index.html +++ b/ui/index.html @@ -25,7 +25,7 @@ - + @@ -86,6 +86,7 @@ + + {{end}} {{if $.siteinfo.JsonLD }}{{ .siteinfo.JsonLD | templateHTML}}{{end}} diff --git a/ui/tsconfig.json b/ui/tsconfig.json index 648dd0253..02d23f751 100644 --- a/ui/tsconfig.json +++ b/ui/tsconfig.json @@ -33,7 +33,6 @@ }, "include": [ "src", - "node_modules/@testing-library/jest-dom", "scripts" ], "exclude": [ diff --git a/ui/vite.config.mts b/ui/vite.config.mts new file mode 100644 index 000000000..90f47e78d --- /dev/null +++ b/ui/vite.config.mts @@ -0,0 +1,130 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import path from 'path'; +import { fileURLToPath } from 'url'; + +import react from '@vitejs/plugin-react'; +import yaml from '@modyfi/vite-plugin-yaml'; +import { CORE_SCHEMA } from 'js-yaml'; +import { defineConfig, loadEnv } from 'vite'; + +// This file is loaded as a real ES module, where __dirname does not exist. +const rootDir = path.dirname(fileURLToPath(import.meta.url)); +const i18nDir = path.resolve(rootDir, '../i18n'); + +export default defineConfig(({ mode }) => { + const env = loadEnv(mode, rootDir, 'REACT_APP_'); + + // configs/config.yaml ui.public_url, as written by scripts/env.js, may or + // may not already carry a trailing slash (the root value is exactly "/"). + // Vite requires base to end with one, so add it only when missing rather + // than concatenating blindly and risking "//". + // + // Vite keeps an absolute external base (e.g. a CDN URL) exactly as given + // only for `vite build`. `vite dev` and `vite preview` reduce the same + // base to its bare pathname, dropping the scheme and host. That split is + // intentional here, not a bug to unify: those two commands only serve + // this app locally, and the Go server only ever embeds `vite build`'s + // output, so the reduction never reaches anything a real deployment + // serves. + const publicUrl = env.REACT_APP_PUBLIC_URL || '/'; + const base = publicUrl.endsWith('/') ? publicUrl : `${publicUrl}/`; + + return { + // The previous yaml-loader (yaml@2.6.1 core schema) kept bare dates as + // strings and left merge keys unresolved. @modyfi/vite-plugin-yaml + // defaults to js-yaml's DEFAULT_SCHEMA, which resolves bare YYYY-MM-DD + // scalars to JS Date objects and enables merge keys. Pin CORE_SCHEMA so + // yaml imports keep parsing the way they did before the migration. + plugins: [react(), yaml({ schema: CORE_SCHEMA })], + + css: { + preprocessorOptions: { + // bootstrap 5.3.3's own scss internals emit dozens of deprecation + // warnings (color functions, mixed-decls) on every build. They are + // unactionable here and bury warnings that point at our own code. + scss: { quietDeps: true }, + }, + }, + + // scripts/env.js generates .env.production from the server's own + // configs/config.yaml using REACT_APP_ names. Reading that prefix keeps the + // generator as the single source of truth for both sides. + envPrefix: 'REACT_APP_', + + base, + + resolve: { + alias: { + '@': path.resolve(rootDir, 'src'), + '@i18n': i18nDir, + }, + }, + + build: { + // ui/static.go embeds this directory, and internal/router/ui.go serves + // /static from it. Neither path is configurable from here. + outDir: 'build', + assetsDir: 'static', + // Matches the previous build so before/after size comparisons measure the + // bundler rather than a change of sourcemap setting. + sourcemap: true, + rollupOptions: { + output: { + // Keep emitted files grouped under static/js, static/css and + // static/media. The analyze script globs that layout, and a flat + // static/ directory silently matches nothing. + entryFileNames: 'static/js/[name].[hash].js', + chunkFileNames: 'static/js/[name].[hash].chunk.js', + assetFileNames: (assetInfo) => { + const name = assetInfo.names?.[0] ?? ''; + if (name.endsWith('.css')) { + return 'static/css/[name].[hash][extname]'; + } + return 'static/media/[name].[hash][extname]'; + }, + }, + }, + }, + + server: { + port: 3000, + proxy: { + '/answer': { + target: env.REACT_APP_API_URL, + changeOrigin: true, + secure: false, + }, + '/installation': { + target: env.REACT_APP_API_URL, + changeOrigin: true, + secure: false, + }, + '/custom.css': { + target: env.REACT_APP_API_URL, + }, + }, + fs: { + // Languages live outside this root and are loaded through @i18n. + allow: [rootDir, i18nDir], + }, + }, + }; +});