fix(ci): correct subpath URL emit + build into public/tsuki for htmltest

Run 25919953710 still failed htmltest on both Hugo legs. Two distinct
issues, both pre-existing latent bugs that v0.3.0 made impossible to
ignore:

1. Several theme templates piped paths with a leading slash through
   `relURL`, which in Hugo means "this is already absolute — leave it
   alone." The intended subpath prefix (`/tsuki/`) was therefore never
   added, producing hrefs like `href="/search/"` and `href="/"`. In
   production the GitHub Pages mount masked this (broken links from
   the page's perspective still happened to resolve under the repo
   path most of the time), but htmltest correctly reported them as
   broken. Fix by either:
   - using `site.Home.RelPermalink` for the home link, or
   - piping a string without a leading slash through `relLangURL`
     (so Hugo prepends the baseURL path), or
   - stripping the leading slash from data-driven URLs first via
     `strings.TrimPrefix "/"` and then `relLangURL`.

   Affected: header.html, search-button.html, recent-posts.html,
   home/hero.html, nav.html, 404.html, search/list.html (Pagefind UI
   asset URLs).

2. htmltest has no URL-rewriting feature (the `URLSwap` config in the
   prior `d30f50f` "fix" was never read by htmltest — confirmed by
   reading htmltest 0.17.0 source). The only clean way to make
   internal-link checking work with a `/repo/`-style baseURL is to
   mirror the URL structure on disk. CI now builds with
   `hugo --destination public/tsuki`, runs Pagefind/smoke/htmltest
   against that path, and uploads `exampleSite/public/tsuki/` as the
   Pages artifact. The artifact contents become the served site
   under `/tsuki/`, identical to the prior behaviour from the
   browser's point of view. `.htmltest.yml` drops the dead URLSwap
   block.

Also: `tmp/` (htmltest's runtime cache) added to .gitignore so local
runs don't dirty the working tree.

Local verification: 32 smoke checks green; htmltest reports
`✔✔✔ passed, tested 29 documents` against the subpath build.
This commit is contained in:
2026-05-15 20:31:00 +07:00
parent 60c14917fb
commit d0f617d919
10 changed files with 23 additions and 21 deletions
+10 -5
View File
@@ -44,14 +44,18 @@ jobs:
run: npm ci
- name: Build site
# Build into public/tsuki so the on-disk layout mirrors the URL structure
# (`/tsuki/foo` href → exampleSite/public/tsuki/foo file). This lets
# htmltest resolve internal links without needing URL-rewriting (which
# htmltest does not support).
working-directory: exampleSite
run: hugo --gc --minify --baseURL "https://tiennm99.github.io/tsuki/"
run: hugo --gc --minify --destination public/tsuki --baseURL "https://tiennm99.github.io/tsuki/"
- name: Build Pagefind index
run: npx pagefind --site exampleSite/public
run: npx pagefind --site exampleSite/public/tsuki
- name: Smoke tests (SEO + a11y + per-kind CSS budget + features regression guard)
run: ./scripts/smoke-tests.sh exampleSite/public
run: ./scripts/smoke-tests.sh exampleSite/public/tsuki
- name: htmltest (broken internal links + HTML5 validation)
# Pinned to master SHA (2026-05-10) for supply-chain hygiene. Refresh periodically.
@@ -60,11 +64,12 @@ jobs:
config: .htmltest.yml
- name: Upload artifact
# Only upload from the current Hugo version; the floor-version run is for compatibility check.
# Upload only the inner build (exampleSite/public/tsuki). Pages mounts the
# artifact root at /tsuki/, so the inner contents become the served site.
if: matrix.hugo == '0.154.0'
uses: actions/upload-pages-artifact@v5
with:
path: exampleSite/public
path: exampleSite/public/tsuki
deploy:
needs: build
+1
View File
@@ -24,3 +24,4 @@ Thumbs.db
.vscode/
.idea/
*.swp
tmp/
+4 -8
View File
@@ -4,14 +4,10 @@ IgnoreDirectoryMissingTrailingSlash: true
IgnoreInternalEmptyHash: true
IgnoreEmptyHref: false
EnforceHTML5: true
# Strip the /tsuki/ baseURL prefix so links resolve against exampleSite/public/.
# htmltest's URLSwap is a regex substring match (not anchored), so the leading
# "^" used previously silently failed against the minified `href=/tsuki/...`
# outputs in built HTML — every link reported missing. Quoted plain-prefix form
# matches whatever position /tsuki/ appears in the href, which for our build is
# always at the start of relative URLs.
URLSwap:
"/tsuki/": /
# CI builds with `hugo --destination public/tsuki` so the on-disk layout
# mirrors the URL structure (`/tsuki/foo` → exampleSite/public/tsuki/foo).
# htmltest has no URL-rewriting feature, so this is the cleanest way to make
# internal-link resolution work without dual builds.
IgnoreURLs:
- "^https://giscus.app"
- "^https://github.com/"
+1 -1
View File
@@ -4,6 +4,6 @@
<section class="error-404">
<h1>404</h1>
<p>{{ i18n "pageNotFound" | default "Trang không tồn tại." }}</p>
<p><a href="{{ "/" | relURL }}">{{ i18n "backHome" | default "Về trang chủ" }}</a></p>
<p><a href="{{ site.Home.RelPermalink }}">{{ i18n "backHome" | default "Về trang chủ" }}</a></p>
</section>
{{ end }}
+1 -1
View File
@@ -1,5 +1,5 @@
<header class="site-header">
<a class="site-title" href="{{ "/" | relURL }}">
<a class="site-title" href="{{ site.Home.RelPermalink }}">
{{- with site.Data.profile }}{{ .name | default site.Title }}{{ else }}{{ site.Title }}{{ end -}}
</a>
{{ partial "nav.html" . }}
+1 -1
View File
@@ -19,7 +19,7 @@
<ul class="home-hero-links">
{{- range . }}
<li>
<a href="{{ .url }}" rel="me noopener" aria-label="{{ .label | default .icon }}">
<a href="{{ strings.TrimPrefix "/" .url | relLangURL }}" rel="me noopener" aria-label="{{ .label | default .icon }}">
{{- with .icon }}{{ partial "icon.html" . }}{{ end }}
<span>{{ .label | default .icon }}</span>
</a>
+1 -1
View File
@@ -15,7 +15,7 @@
{{- if gt (len $all) $count }}
<p class="home-recent-more">
<a href="{{ "/post/" | relURL }}">{{ i18n "viewAll" | default "View all" }} →</a>
<a href="{{ "post/" | relLangURL }}">{{ i18n "viewAll" | default "View all" }} →</a>
</p>
{{- end }}
</section>
+1 -1
View File
@@ -3,7 +3,7 @@
{{- range site.Menus.main }}
{{- $current := or ($.IsMenuCurrent "main" .) ($.HasMenuCurrent "main" .) }}
<li>
<a href="{{ .URL | relURL }}"{{ if $current }} aria-current="page"{{ end }}>{{ .Name }}</a>
<a href="{{ strings.TrimPrefix "/" .URL | relLangURL }}"{{ if $current }} aria-current="page"{{ end }}>{{ .Name }}
</li>
{{- end }}
</ul>
+1 -1
View File
@@ -1,3 +1,3 @@
<a class="search-button" href="{{ "/search/" | relURL }}" aria-label="{{ i18n "search" | default "Tìm kiếm" }}">
<a class="search-button" href="{{ "search/" | relLangURL }}" aria-label="{{ i18n "search" | default "Tìm kiếm" }}">
{{ partial "icon.html" "search" }}
</a>
+2 -2
View File
@@ -4,7 +4,7 @@
{{- $search := resources.Get "css/search.css" | minify | fingerprint -}}
<link rel="stylesheet" href="{{ $search.RelPermalink }}" integrity="{{ $search.Data.Integrity }}" crossorigin="anonymous">
{{- if site.Params.search.enable | default true }}
{{- $pagefindCss := "/pagefind/pagefind-ui.css" | relURL }}
{{- $pagefindCss := "pagefind/pagefind-ui.css" | relLangURL }}
<link rel="preload" as="style" href="{{ $pagefindCss }}" onload="this.onload=null;this.rel='stylesheet'">
<noscript><link rel="stylesheet" href="{{ $pagefindCss }}"></noscript>
{{- end }}
@@ -30,7 +30,7 @@
{{ define "scripts" }}
{{- if site.Params.search.enable | default true }}
<script type="module">
import { PagefindUI } from "{{ "/pagefind/pagefind-ui.js" | relURL }}";
import { PagefindUI } from "{{ "pagefind/pagefind-ui.js" | relLangURL }}";
new PagefindUI({
element: "#search",
showSubResults: true,