Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
6b751cb
test: guard frontend behaviour a successful build does not demonstrate
xantorres Jul 31, 2026
e4d6252
test: check languages resolve through the dev server, not a bundle
xantorres Aug 1, 2026
de20398
chore: ignore build output regardless of asset directory depth
xantorres Aug 1, 2026
48b8c92
build: replace Create React App with Vite
xantorres Aug 1, 2026
545cfbf
refactor: read environment variables through import.meta.env
xantorres Aug 1, 2026
ac5336a
refactor: import diff, semver and image assets as ES modules
xantorres Aug 1, 2026
e2647fa
style: drop tilde prefixes from Sass imports
xantorres Aug 1, 2026
df5c550
fix: correct malformed custom property in editor background
xantorres Aug 1, 2026
4b3855f
fix: resolve page modules through a statically analyzable import
xantorres Aug 1, 2026
ef8b059
fix: parse built asset tags without depending on the emitted format
xantorres Aug 1, 2026
a8d23b7
build: remove the Create React App toolchain
xantorres Aug 1, 2026
31f95cb
fix: render every entry stylesheet, not only the first
xantorres Aug 1, 2026
d29d183
fix: register plugin translations without depending on evaluation order
xantorres Aug 1, 2026
0ba54a8
test: guard plugin translation registration against evaluation order
xantorres Aug 1, 2026
cb9f77f
chore: drop leftovers from the previous build toolchain
xantorres Aug 1, 2026
0400b4e
build: load the build config as a real ES module
xantorres Aug 1, 2026
a744004
fix: drop the no-op defer attribute from the module script tag
xantorres Aug 1, 2026
7c2d196
style: apply the project formatter to the check scripts
xantorres Aug 1, 2026
9a530d8
style: iterate the rel attribute tokens without building a slice
xantorres Aug 1, 2026
7200ca3
chore: remove the scaffold test left by the old toolchain
xantorres Aug 1, 2026
7a1b36c
test: assert every declared stylesheet is parsed, not just one
xantorres Aug 1, 2026
a952960
fix: emit the bootstrap-icons font files the stylesheet points at
xantorres Aug 2, 2026
96b605a
fix: honor the configured public url in built asset paths
xantorres Aug 2, 2026
3ed4347
build: check types during the production build
xantorres Aug 2, 2026
1908f9a
style: apply the formatter to request.ts
xantorres Aug 2, 2026
9a42b08
build: silence dependency deprecation noise in sass output
xantorres Aug 2, 2026
77ba407
style: declare properties before nested rules in comment styles
xantorres Aug 2, 2026
8e9c941
build: keep yaml parsing on the schema the previous loader used
xantorres Aug 2, 2026
80442a9
fix: serve the module script with the same tag shape the build emits
xantorres Aug 2, 2026
883d8e2
fix: point the 403 route at its existing page module
xantorres Aug 2, 2026
e9fd0ff
test: fail the asset check when its Go test no longer exists
xantorres Aug 2, 2026
479b652
test: fail the frontend checks closed instead of exiting mid-cleanup
xantorres Aug 2, 2026
9932b8c
test: cover immediate plugin translation registration too
xantorres Aug 2, 2026
341718f
test: assert a non-stylesheet link never counts as a stylesheet
xantorres Aug 2, 2026
d73b568
build: raise the release workflow Node to the bundler's floor
xantorres Aug 2, 2026
9f8744b
test: close the dev server when its startup times out
xantorres Aug 2, 2026
c81105a
chore: note vite's serve-mode reduction of absolute base urls
xantorres Aug 2, 2026
0d8d35d
build: declare the bundler's real Node support range
xantorres Aug 2, 2026
500dd60
build: type the environment variables the app reads
xantorres Aug 2, 2026
3c8e544
test: bound server creation and guarantee check termination
xantorres Aug 2, 2026
d06b623
test: fail the checks by default until they prove success
xantorres Aug 2, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/build-binary-for-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 2 additions & 4 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 17 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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 ./...
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
75 changes: 61 additions & 14 deletions internal/controller/template_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
package controller

import (
"bytes"
"encoding/json"
"fmt"
"html/template"
Expand Down Expand Up @@ -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
Expand All @@ -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(`<script defer="defer" src="([^"]*)"></script>`)
scriptData := scriptRegexp.FindAllStringSubmatch(string(file), -1)
for _, s := range scriptData {
if len(s) == 2 {
script = append(script, s[1])

// The frontend build tool controls attribute order, attribute set (e.g.
// module vs classic scripts), and quoting for the emitted tags, and that
// shape has already changed once. Walk the parsed DOM instead of matching
// a literal tag shape so the next bundler change fails a 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(`<link href="(.*)" rel="stylesheet">`)
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 {
Expand Down Expand Up @@ -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 {
Expand All @@ -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
}
Expand Down
78 changes: 78 additions & 0 deletions internal/controller/template_controller_test.go
Original file line number Diff line number Diff line change
@@ -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))
}
103 changes: 103 additions & 0 deletions script/check-built-assets.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
#!/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 with asset tag
# shapes the server cannot parse and confirm the check fails
# on them. 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 the asset tags change shape. 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" \
'<!doctype html><html><head><meta charset="utf-8"/><link rel="stylesheet" crossorigin href="/static/css/index-e5f6a7b8.css"></head><body><div id="root"></div></body></html>'

expect_failure "script src but no stylesheet link" \
'<!doctype html><html><head><meta charset="utf-8"/><script type="module" crossorigin src="/static/js/index-a1b2c3d4.js"></script></head><body><div id="root"></div></body></html>'

expect_failure "only an inline script, no src" \
'<!doctype html><html><head><meta charset="utf-8"/><link rel="stylesheet" crossorigin href="/static/css/index-e5f6a7b8.css"></head><body><div id="root"></div><script>window.__inline=1;</script></body></html>'

expect_failure "manifest link but no stylesheet link" \
'<!doctype html><html><head><meta charset="utf-8"/><script type="module" crossorigin src="/static/js/index-a1b2c3d4.js"></script><link rel="manifest" href="/manifest.json"></head><body><div id="root"></div></body></html>'

echo "==> self-check passed"
1 change: 0 additions & 1 deletion ui/.env.development
Original file line number Diff line number Diff line change
@@ -1,2 +1 @@
PUBLIC_URL
REACT_APP_API_URL = http://10.0.20.84:8080/
4 changes: 1 addition & 3 deletions ui/.env.production
Original file line number Diff line number Diff line change
@@ -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=
1 change: 0 additions & 1 deletion ui/.eslintignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
public
config-overrides.js
commitlint.config.js
build
.eslintrc.js
Expand Down
1 change: 0 additions & 1 deletion ui/.eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,6 @@ module.exports = {
es2021: true,
},
extends: [
'react-app/jest',
'plugin:react/recommended',
'airbnb',
'airbnb-typescript',
Expand Down
Loading
Loading