From 264e48dacd568aeca7aab0edb7342f26bdf0ac87 Mon Sep 17 00:00:00 2001 From: Xan Torres Date: Wed, 19 Aug 2026 11:58:07 +0800 Subject: [PATCH 1/6] test: guard frontend behaviour a successful build does not demonstrate Two things the server depends on are invisible to the build. Both fail silently, so a green build and a working dev server actively disguise them. The server parses the script and stylesheet paths out of the built index.html and reuses them on every server-rendered page. That parse is coupled to the exact attribute set and attribute order the frontend build writes into those tags. A build that emits a different tag shape still succeeds, the dev server still works, the binary still compiles, and the pages simply render with no scripts and no stylesheet. TestGetStyleResolvesBuiltAssets asserts the parse still finds them. Languages other than the default one are loaded with a template-literal dynamic import through an alias that points outside the frontend root. A bundler that cannot enumerate that pattern still builds and still serves a working app; the resources never arrive, and only for non-default languages, so a smoke test in the default language misses it. check-locale-resolution.js bundles that same import with the project's own configuration, runs it, and requires two languages to resolve to distinct translated content. Both run through make check-ui. check-built-assets.sh --self-check confirms the asset check still fails on tag shapes the parser cannot read, so a check that quietly stopped asserting anything is distinguishable from a passing one. (cherry picked from commit 71cd9246fedbf6430c6a7373759fdf0be4029e4a, internal/controller/template_controller_test.go only) --- .../controller/template_controller_test.go | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 internal/controller/template_controller_test.go diff --git a/internal/controller/template_controller_test.go b/internal/controller/template_controller_test.go new file mode 100644 index 000000000..273b7df84 --- /dev/null +++ b/internal/controller/template_controller_test.go @@ -0,0 +1,56 @@ +/* + * 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 ( + "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" + + if _, err := ui.Build.ReadFile(builtIndexPath); 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) +} From 8771cacf35be5bef9067720345d52ef30afa5c48 Mon Sep 17 00:00:00 2001 From: Xan Torres Date: Wed, 19 Aug 2026 11:58:34 +0800 Subject: [PATCH 2/6] fix: parse built asset tags without depending on the emitted format GetStyle scraped index.html with regexes matching one exact tag shape: classic scripts with defer first, and stylesheet links with href before rel. Any bundler emitting a different shape returned nothing, and server-rendered pages would load with no JavaScript and no stylesheet while every build step still reported success. The tags are now read from the parsed document, so attribute order, attribute set and quoting no longer matter. header.html emits the scraped paths as script tags itself, and those were classic scripts. A module bundle loaded that way fails on its first import, so fixing only the parsing would have left server-rendered pages broken; the tag is now declared as a module. The self-check fixtures are replaced. The previous two asserted failure on module scripts and on rel-before-href, both of which the parser now accepts, so they would have inverted into false alarms. The replacements cover a stylesheet with no script, a script with no stylesheet, and an inline script with no src. golang.org/x/net moves to a direct requirement, matching its use here. (cherry picked from commit eab6f9d80c084d8345a4c82821a64aaef512cce5, go.mod and internal/controller/template_controller.go only) --- go.mod | 2 +- internal/controller/template_controller.go | 60 ++++++++++++++++++---- 2 files changed, 52 insertions(+), 10 deletions(-) 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..8290d8b68 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,6 +51,7 @@ import ( "github.com/apache/answer/ui" "github.com/gin-gonic/gin" "github.com/segmentfault/pacman/log" + "golang.org/x/net/html" ) var SiteUrl = "" @@ -88,19 +90,59 @@ func GetStyle() (script []string, css string) { 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]) + + // 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.Fields(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 css == "" && isStylesheet(n) { + if href, ok := attr(n, "href"); ok && href != "" { + 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 { From 7b9d941bd110efb2f5eb0bc8b7b32a006e65ade6 Mon Sep 17 00:00:00 2001 From: Xan Torres Date: Sat, 1 Aug 2026 19:44:13 +0800 Subject: [PATCH 3/6] fix: render every entry stylesheet, not only the first GetStyle returned a single stylesheet path because the previous build emitted exactly one. The current build emits two, so server-rendered pages loaded partially unstyled while every build step still reported success. The stylesheet is now a list, mirroring how script paths are already collected and prefixed, and the template renders one link per entry. This is the same assumption as the tag-shape one fixed earlier: the server encoded a property of one bundler's output, here that there is exactly one entry stylesheet. (cherry picked from commit d66e21b252752ef9d231b958e7452e66202ba0f3) --- internal/controller/template_controller.go | 19 ++++++++++++------- .../controller/template_controller_test.go | 4 ++++ ui/template/header.html | 4 +++- 3 files changed, 19 insertions(+), 8 deletions(-) diff --git a/internal/controller/template_controller.go b/internal/controller/template_controller.go index 8290d8b68..3c1021662 100644 --- a/internal/controller/template_controller.go +++ b/internal/controller/template_controller.go @@ -57,8 +57,11 @@ import ( 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 @@ -85,7 +88,7 @@ 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 @@ -131,9 +134,9 @@ func GetStyle() (script []string, css string) { script = append(script, src) } case "link": - if css == "" && isStylesheet(n) { + if isStylesheet(n) { if href, ok := attr(n, "href"); ok && href != "" { - css = href + css = append(css, href) } } } @@ -602,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 { @@ -614,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 index 273b7df84..6b4b4b79b 100644 --- a/internal/controller/template_controller_test.go +++ b/internal/controller/template_controller_test.go @@ -53,4 +53,8 @@ func TestGetStyleResolvesBuiltAssets(t *testing.T) { 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) + } } diff --git a/ui/template/header.html b/ui/template/header.html index d5d9a18ac..f9f0468b6 100644 --- a/ui/template/header.html +++ b/ui/template/header.html @@ -34,7 +34,9 @@ - + {{range $path := .cssPath}} + + {{end}} Date: Sat, 1 Aug 2026 20:40:39 +0800 Subject: [PATCH 4/6] style: iterate the rel attribute tokens without building a slice The linter configured for this repository flags the slice-building form, so make lint fails on it. (cherry picked from commit 1cfd5daf81bfe0823f497c27fea43297b15b18c9) --- internal/controller/template_controller.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/controller/template_controller.go b/internal/controller/template_controller.go index 3c1021662..4343738a6 100644 --- a/internal/controller/template_controller.go +++ b/internal/controller/template_controller.go @@ -117,7 +117,7 @@ func GetStyle() (script []string, css []string) { if !ok { return false } - for _, tok := range strings.Fields(rel) { + for tok := range strings.FieldsSeq(rel) { if strings.EqualFold(tok, "stylesheet") { return true } From cc11a52fde283c792cf6ce918ca23e2cc526e8e4 Mon Sep 17 00:00:00 2001 From: Xan Torres Date: Sat, 1 Aug 2026 21:09:52 +0800 Subject: [PATCH 5/6] test: assert every declared stylesheet is parsed, not just one Asserting that the parsed stylesheet list is non-empty leaves the exact regression this repository already hit uncovered: a parser that stops at the first stylesheet returns a one element list, satisfies every existing assertion, and silently drops the rest of the page's CSS. Count the declarations again by a cruder method than the parser uses and require the two to agree, so the parser has to be checked against something other than itself. The count is a lower bound: a build that quotes attributes differently drives it to zero and it stops constraining, which is why it supplements the shape-independent assertions rather than replacing them. Verified by reintroducing the truncation and watching this fail. (cherry picked from commit 9232cbd1fa7ef0a19e053474acf7c5468af43e74) --- .../controller/template_controller_test.go | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/internal/controller/template_controller_test.go b/internal/controller/template_controller_test.go index 6b4b4b79b..74c0db0a6 100644 --- a/internal/controller/template_controller_test.go +++ b/internal/controller/template_controller_test.go @@ -20,6 +20,7 @@ package controller import ( + "strings" "testing" "github.com/apache/answer/ui" @@ -39,7 +40,8 @@ import ( func TestGetStyleResolvesBuiltAssets(t *testing.T) { const builtIndexPath = "build/index.html" - if _, err := ui.Build.ReadFile(builtIndexPath); err != nil { + 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) } @@ -57,4 +59,20 @@ func TestGetStyleResolvesBuiltAssets(t *testing.T) { 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)) } From 25c5682acd3451a96f382141a16181a87ceae081 Mon Sep 17 00:00:00 2001 From: Xan Torres Date: Wed, 19 Aug 2026 11:59:30 +0800 Subject: [PATCH 6/6] chore: align asset-check comments with the DOM-tolerant parser The GetStyle comment framed the DOM walk around attribute order, attribute set, and quoting, the same properties the old regex parser depended on, without stating that the new parser ignores all of them. check-built-assets.sh described its self-check as failing when asset tags change shape, but the fixtures test a missing script or stylesheet tag, and the parser accepts any shape as long as the tag is present. Comments now state the actual constraint: tag shape does not affect parsing, and the guarding check fails when a build is present but a required script or stylesheet tag is missing from it. (cherry picked from commit 3bc12e80a659a08dbe72c840a60b25c8c0f90963, internal/controller/template_controller.go only) --- internal/controller/template_controller.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/internal/controller/template_controller.go b/internal/controller/template_controller.go index 4343738a6..0f2f5b68b 100644 --- a/internal/controller/template_controller.go +++ b/internal/controller/template_controller.go @@ -94,11 +94,11 @@ func GetStyle() (script []string, css []string) { return } - // 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. + // 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