Skip to content

Commit 5d76ba8

Browse files
imfingCopilot
andauthored
fix(search): handle inline markup in heading fragments (#1015)
* fix(search): handle inline markup in heading fragments * fix: resolve merge conflicts with origin/main - Keep marker/endTag approach for Hugo Markdown headings (data-hextra-search-id) - Add ID-based fallback for external renderers like AsciiDoc - Use headings.html partial (from main) for asciidoctor-compatible heading extraction - Preserve preamble handling and inline markup fragment support * test: fix search-fragments env and run build tests in CI The search-fragments spec read public/en.search-data.json, but a bare hugo build defaults to the production environment, which fingerprints the search data since 2d3aadc. Build with --environment development so the plain filename exists. Add a Build Tests workflow running the four build-based specs (asciidoc, render-link, search-data, search-fragments), which previously never ran in CI. --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
1 parent f4cea78 commit 5d76ba8

6 files changed

Lines changed: 185 additions & 35 deletions

File tree

.github/workflows/test-build.yml

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
name: Build Tests
2+
3+
on:
4+
pull_request:
5+
branches: [main]
6+
7+
concurrency:
8+
group: build-tests-${{ github.head_ref || github.ref_name }}
9+
cancel-in-progress: true
10+
11+
defaults:
12+
run:
13+
shell: bash
14+
15+
jobs:
16+
build-tests:
17+
runs-on: ubuntu-latest
18+
env:
19+
HUGO_VERSION: 0.161.1
20+
# These specs build temporary Hugo sites and inspect the output files
21+
# directly, so no browser or web server is needed. Setting BASE_URL
22+
# keeps Playwright from starting the docs webServer.
23+
BASE_URL: http://localhost:1313
24+
steps:
25+
- name: Checkout
26+
uses: actions/checkout@v4
27+
with:
28+
fetch-depth: 0
29+
submodules: recursive
30+
31+
- name: Setup Node
32+
uses: actions/setup-node@v4
33+
with:
34+
node-version: "24"
35+
cache: npm
36+
37+
- name: Setup Hugo
38+
run: |
39+
wget -O ${{ runner.temp }}/hugo.deb https://github.com/gohugoio/hugo/releases/download/v${HUGO_VERSION}/hugo_extended_${HUGO_VERSION}_linux-amd64.deb \
40+
&& sudo dpkg -i ${{ runner.temp }}/hugo.deb
41+
42+
- name: Install dependencies
43+
run: npm ci
44+
45+
- name: Run build tests
46+
run: npm run test:build
47+
48+
- name: Upload report
49+
if: always()
50+
uses: actions/upload-artifact@v4
51+
with:
52+
name: build-tests-report
53+
path: playwright-report/
54+
retention-days: 14

layouts/_markup/render-heading.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
<h{{ .Level }} {{- with .Attributes.class }} class="{{ . }}" {{- end }}>
1+
<h{{ .Level }} data-hextra-search-id="{{ .Anchor | safeURL }}" {{- with .Attributes.class }} class="{{ . }}" {{- end }}>
22
{{- .Text | safeHTML -}}
33
{{- if gt .Level 1 -}}
44
<span class="hx:absolute hx:-mt-20" id="{{ .Anchor | safeURL }}"></span>

layouts/_partials/utils/extract-headings.html

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,29 +2,33 @@
22
Extracts all headings from a page and adds them to the scratchpad.
33

44
The keys can be obtained from the scratchpad by using the "keys" key.
5-
The titles can be obtained from the scratchpad by using the "titles" key.
6-
The IDs can be obtained from the scratchpad by using the "ids" key.
5+
The opening heading markers and closing tags can be obtained from the scratchpad by using the
6+
"markers" and "endTags" keys.
7+
The heading IDs can be obtained from the scratchpad by using the "ids" key.
78

8-
The scratchpad must be initialized with empty slices before calling this function for the keys "keys", "titles", and "ids".
9+
The scratchpad must be initialized with empty slices before calling this function for the keys
10+
"keys", "markers", "endTags", and "ids".
911

1012
@param {any} target The element to extract headings from.
11-
@param {any} scratch The scratchpad to add the keys and titles to.
13+
@param {any} scratch The scratchpad to add the heading metadata to.
1214

1315
@example {{ partial "utils/extract-headings.html" (dict "target" $h1 "scratch" $s) }}
1416
*/ -}}
15-
1617
{{- range $heading := index .target.Headings -}}
1718
{{- if and (eq $heading.Level 0) (not $heading.Title) -}}
1819
{{- $.scratch.Add "keys" (slice $heading.Title) -}}
20+
{{- $.scratch.Add "markers" (slice "") -}}
21+
{{- $.scratch.Add "endTags" (slice "") -}}
22+
{{- $.scratch.Add "ids" (slice "") -}}
1923
{{- else -}}
2024
{{- $key := (printf "%s#%s" $heading.ID $heading.Title) -}}
2125
{{- $.scratch.Add "keys" (slice $key) -}}
26+
{{- $marker := printf "<h%d data-hextra-search-id=\"%s\"" $heading.Level ($heading.ID | safeURL) -}}
27+
{{- $.scratch.Add "markers" (slice $marker) -}}
28+
{{- $.scratch.Add "endTags" (slice (printf "</h%d>" $heading.Level)) -}}
29+
{{- $.scratch.Add "ids" (slice $heading.ID) -}}
2230
{{- end -}}
2331

24-
{{- $title := (printf "<h%d>%s" $heading.Level $heading.Title) | htmlUnescape -}}
25-
{{- $.scratch.Add "titles" (slice $title) -}}
26-
{{- $.scratch.Add "ids" (slice $heading.ID) -}}
27-
2832
{{- partial "utils/extract-headings.html" (dict
2933
"target" $heading
3034
"scratch" $.scratch

layouts/_partials/utils/fragments.html

Lines changed: 45 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,8 @@
4343
{{- /* Process all headings */ -}}
4444
{{- $s := newScratch -}}
4545
{{- $s.Set "keys" slice -}}
46-
{{- $s.Set "titles" slice -}}
46+
{{- $s.Set "markers" slice -}}
47+
{{- $s.Set "endTags" slice -}}
4748
{{- $s.Set "ids" slice -}}
4849

4950
{{- $headingData := partial "utils/headings.html" $page -}}
@@ -53,7 +54,8 @@
5354
{{- end -}}
5455

5556
{{- $headingKeys := $s.Get "keys" -}}
56-
{{- $headingTitles := $s.Get "titles" -}}
57+
{{- $headingMarkers := $s.Get "markers" -}}
58+
{{- $headingEndTags := $s.Get "endTags" -}}
5759
{{- $headingIDs := $s.Get "ids" -}}
5860

5961
{{- $content := $page.Content | htmlUnescape -}}
@@ -65,40 +67,59 @@
6567
{{ if eq $len 0 }}
6668
{{ $data = $data | merge (dict "" ($page.Plain | htmlUnescape | strings.TrimSpace)) }}
6769
{{ else }}
68-
{{/* Split the raw content from bottom to top */}}
70+
{{/* Split the rendered content from bottom to top using stable heading identifiers. */}}
6971
{{ range seq $len }}
7072
{{ $i := sub $len . }}
7173
{{ $headingKey := index $headingKeys $i }}
72-
{{ $headingTitle := index $headingTitles $i }}
74+
{{ $headingMarker := index $headingMarkers $i }}
75+
{{ $headingEndTag := index $headingEndTags $i }}
7376
{{ $headingID := index $headingIDs $i }}
7477

75-
{{ if eq $i 0 }}
76-
{{ $data = $data | merge (dict $headingKey ($content | plainify | htmlUnescape | strings.TrimSpace)) }}
77-
{{ else }}
78-
{{ $splitMarker := $headingTitle }}
79-
{{ $usedIDMarker := false }}
80-
{{ $parts := split $content (printf "%s" $headingTitle) }}
81-
82-
{{ if and (eq (len $parts) 1) $headingID }}
83-
{{ $splitMarker = printf "id=\"%s\"" $headingID }}
84-
{{ $usedIDMarker = true }}
85-
{{ $parts = split $content $splitMarker }}
86-
{{ end }}
78+
{{ with $headingMarker }}
79+
{{ $headingParts := split $content . }}
80+
81+
{{ if gt (len $headingParts) 1 }}
82+
{{ $beforeHeading := index $headingParts 0 }}
83+
{{ $headingAndContent := delimit (after 1 $headingParts) . }}
84+
{{ $parts := split $headingAndContent $headingEndTag }}
85+
86+
{{ if gt (len $parts) 1 }}
87+
{{ $fragment := delimit (after 1 $parts) $headingEndTag }}
88+
{{ $data = $data | merge (dict $headingKey ($fragment | plainify | htmlUnescape | strings.TrimSpace)) }}
89+
{{ $content = $beforeHeading }}
90+
{{ else }}
91+
{{/* Preserve the remaining content when a custom render hook has malformed output. */}}
92+
{{ $data = $data | merge (dict $headingKey "") }}
93+
{{ end }}
94+
{{ else if $headingID }}
95+
{{/* Fallback for external renderers (e.g. AsciiDoc) where id is on the heading element itself */}}
96+
{{ $idMarker := printf "id=\"%s\"" $headingID }}
97+
{{ $idParts := split $content $idMarker }}
8798

88-
{{ if gt (len $parts) 1 }}
89-
{{ $lastPart := index $parts (sub (len $parts) 1) }}
90-
{{ if $usedIDMarker }}
99+
{{ if gt (len $idParts) 1 }}
100+
{{ $lastPart := index $idParts (sub (len $idParts) 1) }}
91101
{{ $lastPart = replaceRE `^[^>]*>` "" $lastPart }}
92-
{{ end }}
93102

94-
{{ $data = $data | merge (dict $headingKey ($lastPart | plainify | htmlUnescape | strings.TrimSpace)) }}
95-
{{ $content = strings.TrimSuffix $lastPart $content }}
96-
{{ $content = strings.TrimSuffix (printf "%s" $splitMarker) $content }}
103+
{{ $data = $data | merge (dict $headingKey ($lastPart | plainify | htmlUnescape | strings.TrimSpace)) }}
104+
{{ $content = strings.TrimSuffix $lastPart $content }}
105+
{{ $content = strings.TrimSuffix $idMarker $content }}
106+
{{/* Strip the partial opening heading tag from the tail of the remaining content */}}
107+
{{ $content = replaceRE `<[^>]*$` "" $content }}
108+
{{ else }}
109+
{{/* Fail open so one unmatched heading cannot swallow fragments above it. */}}
110+
{{ $data = $data | merge (dict $headingKey "") }}
111+
{{ end }}
97112
{{ else }}
98-
{{ $data = $data | merge (dict $headingKey ($content | plainify | htmlUnescape | strings.TrimSpace)) }}
113+
{{/* Fail open so one unmatched heading cannot swallow fragments above it. */}}
114+
{{ $data = $data | merge (dict $headingKey "") }}
99115
{{ end }}
100116
{{ end }}
101117
{{ end }}
118+
119+
{{ $preamble := $content | plainify | htmlUnescape | strings.TrimSpace }}
120+
{{ with $preamble }}
121+
{{ $data = $data | merge (dict "" .) }}
122+
{{ end }}
102123
{{ end }}
103124
{{ else if (eq $type "heading" ) }}
104125
{{/* Put heading keys with empty content to the data object */}}

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@
66
"build": "hugo --gc --minify --themesDir=../.. --source=docs",
77
"test": "npx playwright test",
88
"test:a11y": "npx playwright test tests/accessibility.spec.ts",
9-
"test:mobile-menu": "npx playwright test tests/mobile-menu.spec.ts"
9+
"test:mobile-menu": "npx playwright test tests/mobile-menu.spec.ts",
10+
"test:build": "npx playwright test tests/asciidoc.spec.ts tests/render-link.spec.ts tests/search-data.spec.ts tests/search-fragments.spec.ts"
1011
},
1112
"devDependencies": {
1213
"@axe-core/playwright": "^4.11.3",

tests/search-fragments.spec.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import { test, expect } from "@playwright/test";
2+
import { execFileSync } from "node:child_process";
3+
import { mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
4+
import { tmpdir } from "node:os";
5+
import { join } from "node:path";
6+
7+
test("search fragments support inline markup in headings", () => {
8+
const siteDir = mkdtempSync(join(tmpdir(), "hextra-search-fragments-"));
9+
const contentDir = join(siteDir, "content");
10+
const publishDir = join(siteDir, "public");
11+
const themesDir = join(siteDir, "themes");
12+
13+
mkdirSync(join(contentDir, "repro"), { recursive: true });
14+
mkdirSync(themesDir);
15+
symlinkSync(process.cwd(), join(themesDir, "hextra"), "dir");
16+
17+
writeFileSync(
18+
join(siteDir, "hugo.yaml"),
19+
`title: Test
20+
baseURL: https://example.org/
21+
theme: hextra
22+
params:
23+
search:
24+
flexsearch:
25+
index: content
26+
`
27+
);
28+
writeFileSync(
29+
join(contentDir, "repro", "index.md"),
30+
`---
31+
title: Search Fragment Repro
32+
---
33+
PREAMBLE_TOKEN
34+
35+
## Plain Heading
36+
PLAIN_BODY_TOKEN
37+
38+
## [Link Heading](https://example.com)
39+
LINK_BODY_TOKEN
40+
41+
## *Emphasized* \`Code\` #1
42+
FORMATTED_BODY_TOKEN
43+
44+
## Duplicate
45+
FIRST_DUPLICATE_BODY_TOKEN
46+
47+
## Duplicate
48+
SECOND_DUPLICATE_BODY_TOKEN
49+
`
50+
);
51+
52+
try {
53+
execFileSync("hugo", ["--source", siteDir, "--themesDir", themesDir, "--destination", publishDir, "--environment", "development"], {
54+
cwd: process.cwd(),
55+
stdio: "pipe",
56+
});
57+
58+
const searchData = JSON.parse(readFileSync(join(publishDir, "en.search-data.json"), "utf8"));
59+
const fragments = searchData["/repro/"].data;
60+
61+
expect(fragments[""]).toBe("PREAMBLE_TOKEN");
62+
expect(fragments["plain-heading#Plain Heading"]).toBe("PLAIN_BODY_TOKEN");
63+
expect(fragments["link-heading#Link Heading"]).toBe("LINK_BODY_TOKEN");
64+
expect(fragments["emphasized-code-1#<em>Emphasized</em> <code>Code</code> #1"]).toBe("FORMATTED_BODY_TOKEN");
65+
expect(fragments["duplicate#Duplicate"]).toBe("FIRST_DUPLICATE_BODY_TOKEN");
66+
expect(fragments["duplicate-1#Duplicate"]).toBe("SECOND_DUPLICATE_BODY_TOKEN");
67+
} finally {
68+
rmSync(siteDir, { recursive: true, force: true });
69+
}
70+
});

0 commit comments

Comments
 (0)