Skip to content

Commit 3114a86

Browse files
Fix stepless HowTo schema on 17 template pages (#20576) (#20582)
The 17 pages under /templates/ (the product template gallery for static-website, serverless-application, container-service, kubernetes, and virtual-machine across AWS/Azure/GCP, plus the two kubernetes-application pages) declare `schema_type: howto` but contain no numbered lists, so the HowTo collector's step-extraction pass found nothing and emitted a HowTo entity with no `step` array at all - invalid structured data per schema.org's own HowTo requirements. Two changes in layouts/partials/schema/collectors/howto-entity.html: 1. Added a second, narrowly-scoped extraction pass that runs only when the numbered-list pass finds nothing AND the page is `layout: template`. It derives one HowToStep per H2 section, using the heading as the step name and an excerpt of the section's prose as the step text, and skips purely navigational sections ("Learn more", "Related", "See also", "Further reading"). This is deliberately not applied to blog posts or tutorials, where H2 headings are often narrative rather than actions. 2. If both extraction passes still find no steps, the collector now returns an empty dict instead of a stepless HowTo. main-entity.html mirrors the existing FAQ-with-no-questions fallback pattern at both HowTo call sites (explicit schema_type: howto, and auto-detected tutorial leaf pages): fall back to article-entity.html when the HowTo collector returns nothing, so the page still gets a valid main entity. SCHEMA.md documents both the new template-page step derivation and the empty-HowTo fallback. Validated locally with a Hugo test harness rendering all 17 pages plus regression pages (a tutorial leaf page, a blog post with howto_schema: true, and the one other schema_type: howto page that already had real steps): all 17 template pages now emit sensible, sequential steps; every regression page's JSON-LD is byte-identical to its pre-change baseline; all JSON-LD across the build parses cleanly. Co-authored-by: workprentice <257153108+workprentice@users.noreply.github.com>
1 parent 3fb1e6d commit 3114a86

3 files changed

Lines changed: 149 additions & 4 deletions

File tree

SCHEMA.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,8 @@ By default (or when `schema_type: auto`), the system automatically determines th
6161

6262
If a page is detected as FAQ but has no Q&A content, it automatically falls back to Article schema to prevent invalid structured data.
6363

64+
The same principle applies to HowTo: a HowTo entity with no steps is invalid structured data (schema.org and Google both require at least one `step`), so if step extraction finds nothing, the system falls back to Article/TechArticle instead of emitting a broken HowTo. See "HowTo Schema" below for how steps are extracted, including the template-page-specific fallback.
65+
6466
## Explicit Schema Declaration
6567

6668
You can override auto-detection by specifying `schema_type` in your page frontmatter:
@@ -221,10 +223,16 @@ main:
221223
- How-to content
222224

223225
**Auto-extracts:**
224-
- Steps from markdown headers and content
226+
227+
- Steps from numbered lists (`1.`, `2.`, ...) in the page body
228+
- On `layout: template` pages with no numbered list, steps derived from H2 sections instead (see below)
225229
- Duration if specified
226230
- Prerequisites
227231

232+
Template pages (the product template gallery under `/templates/`) share a uniform, verified section structure ("Using this template", "Deploying the project", "Customizing the project", "Cleaning up", and similar) but rarely use numbered lists. For these pages only, each `##` heading becomes one `HowToStep`, using the heading as the step name and an excerpt of the section's prose as the step text. This heuristic is scoped to `layout: template` on purpose: the same approach applied to blog posts or tutorials would invent steps out of narrative headings ("Why this matters", "Conclusion") that describe no action. Purely navigational sections ("Learn more", "Related", "See also", "Further reading") are excluded from both extraction passes.
233+
234+
If neither extraction pass finds any steps, the page falls back to Article/TechArticle schema rather than emitting a HowTo with no `step` array (see "Smart Fallbacks" above).
235+
228236
[Learn more about HowTo schema](https://schema.org/HowTo)
229237

230238
## Testing Your Schema

layouts/partials/schema/collectors/howto-entity.html

Lines changed: 122 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,114 @@
104104
{{ end }}
105105
{{ end }}
106106

107+
{{/* Template pages (the product template gallery under /templates/) follow a
108+
uniform, hand-verified H2 section structure ("Using this template",
109+
"Deploying the project", "Customizing the project", "Cleaning up", plus a
110+
handful of page-specific sections such as "Verifying cluster access" or
111+
"Next steps") but contain no numbered lists, so the pass above finds no
112+
steps for them. When that happens and this is a template page, derive
113+
HowToStep entries from H2 sections instead: each section becomes one
114+
step, named after its heading, with a short prose excerpt as the text.
115+
This is scoped to layout=template on purpose — applying the same
116+
heading-derived heuristic to blog posts or tutorials would invent steps
117+
out of narrative headings ("Why this matters", "Conclusion") that are
118+
not actions for the reader. Template pages are structurally uniform
119+
enough that their headings really are the how-to steps. */}}
120+
{{ if and (not $steps) (eq .Layout "template") }}
121+
{{ $pagePermalink := .Permalink }}
122+
{{ $navHeadings := slice "learn more" "related" "see also" "further reading" }}
123+
{{ $sectionName := "" }}
124+
{{ $sectionLines := slice }}
125+
{{ $sectionNumber := 0 }}
126+
{{ $inFence := false }}
127+
128+
{{ range $index, $line := $lines }}
129+
{{ if findRE `^##\s+` $line }}
130+
{{/* Flush the previous section as a step before starting the new one */}}
131+
{{ if and $sectionName (gt (len $sectionLines) 0) }}
132+
{{ if not (in $navHeadings (lower $sectionName)) }}
133+
{{ $text := delimit $sectionLines " " | replaceRE "\\s+" " " }}
134+
{{ $text = trim $text " \t\n\r" }}
135+
{{ if gt (len $text) 300 }}
136+
{{ $text = printf "%s…" (substr $text 0 300) }}
137+
{{ end }}
138+
{{ if $text }}
139+
{{ $sectionNumber = add $sectionNumber 1 }}
140+
{{ $step := dict
141+
"@type" "HowToStep"
142+
"position" $sectionNumber
143+
"name" $sectionName
144+
"text" $text
145+
"url" (printf "%s#%s" $pagePermalink (anchorize $sectionName))
146+
}}
147+
{{ $steps = $steps | append $step }}
148+
{{ end }}
149+
{{ end }}
150+
{{ end }}
151+
{{ $sectionName = trim (replaceRE `^##\s+` "" $line) " \t\n\r" }}
152+
{{ $sectionLines = slice }}
153+
{{ $inFence = false }}
154+
155+
{{ else if $sectionName }}
156+
{{ $trimForFence := trim $line " \t" }}
157+
{{ if strings.HasPrefix $trimForFence "```" }}
158+
{{ $inFence = not $inFence }}
159+
{{ else if not $inFence }}
160+
{{ $trimmedLine := trim $line " \t\n\r" }}
161+
{{ $skip := false }}
162+
{{ if eq $trimmedLine "" }}
163+
{{ $skip = true }}
164+
{{ else if strings.HasPrefix $trimmedLine "#" }}
165+
{{ $skip = true }}
166+
{{ else if strings.HasPrefix $trimmedLine "{{" }}
167+
{{ $skip = true }}
168+
{{ else if strings.HasPrefix $trimmedLine "![" }}
169+
{{ $skip = true }}
170+
{{ else if strings.HasPrefix $trimmedLine "|" }}
171+
{{ $skip = true }}
172+
{{ else if strings.HasPrefix $trimmedLine ">" }}
173+
{{ $skip = true }}
174+
{{ else if strings.HasPrefix $trimmedLine "-" }}
175+
{{ $skip = true }}
176+
{{ else if strings.HasPrefix $trimmedLine "*" }}
177+
{{ $skip = true }}
178+
{{ else if strings.HasPrefix $trimmedLine ":" }}
179+
{{ $skip = true }}
180+
{{ end }}
181+
{{ if not $skip }}
182+
{{ $clean := replaceRE `\[([^\]]+)\]\([^)]+\)` "$1" $trimmedLine }}
183+
{{ $clean = replaceRE "`" "" $clean }}
184+
{{ if $clean }}
185+
{{ $sectionLines = $sectionLines | append $clean }}
186+
{{ end }}
187+
{{ end }}
188+
{{ end }}
189+
{{ end }}
190+
{{ end }}
191+
192+
{{/* Flush the final section */}}
193+
{{ if and $sectionName (gt (len $sectionLines) 0) }}
194+
{{ if not (in $navHeadings (lower $sectionName)) }}
195+
{{ $text := delimit $sectionLines " " | replaceRE "\\s+" " " }}
196+
{{ $text = trim $text " \t\n\r" }}
197+
{{ if gt (len $text) 300 }}
198+
{{ $text = printf "%s…" (substr $text 0 300) }}
199+
{{ end }}
200+
{{ if $text }}
201+
{{ $sectionNumber = add $sectionNumber 1 }}
202+
{{ $step := dict
203+
"@type" "HowToStep"
204+
"position" $sectionNumber
205+
"name" $sectionName
206+
"text" $text
207+
"url" (printf "%s#%s" $pagePermalink (anchorize $sectionName))
208+
}}
209+
{{ $steps = $steps | append $step }}
210+
{{ end }}
211+
{{ end }}
212+
{{ end }}
213+
{{ end }}
214+
107215
{{/* Add steps to schema if we found any */}}
108216
{{ if $steps }}
109217
{{ $schema = merge $schema (dict "step" $steps) }}
@@ -213,4 +321,17 @@
213321
{{ $schema = merge $schema (dict "yield" "Infrastructure deployed using Pulumi") }}
214322
{{ end }}
215323

216-
{{ return $schema }}
324+
{{/* A HowTo with no steps is invalid structured data — Google's own HowTo
325+
documentation requires at least one step, and a stepless HowTo gives
326+
LLM/agent crawlers nothing to act on either. If neither extraction pass
327+
above found any steps, return an empty dict so the caller
328+
(main-entity.html) can fall back to a different entity type rather than
329+
emitting a broken HowTo. Hugo requires a partial to end in exactly one
330+
`return` statement, so the empty-vs-populated choice is resolved into a
331+
single result variable rather than two conditional `return`s. */}}
332+
{{ $result := $schema }}
333+
{{ if not $steps }}
334+
{{ $result = dict }}
335+
{{ end }}
336+
337+
{{ return $result }}

layouts/partials/schema/collectors/main-entity.html

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,16 @@
1515
{{ else if eq $explicitSchema "blog" }}
1616
{{ $entity = partial "schema/collectors/blog-entity.html" . }}
1717
{{ else if eq $explicitSchema "howto" }}
18-
{{ $entity = partial "schema/collectors/howto-entity.html" . }}
18+
{{ $howToEntity := partial "schema/collectors/howto-entity.html" . }}
19+
{{ if $howToEntity }}
20+
{{ $entity = $howToEntity }}
21+
{{ else }}
22+
{{/* HowTo page with no extractable steps (numbered list or, for
23+
layout=template pages, H2 sections) - fall back to article so
24+
the page still gets a valid main entity instead of none at
25+
all. */}}
26+
{{ $entity = partial "schema/collectors/article-entity.html" . }}
27+
{{ end }}
1928
{{ else if eq $explicitSchema "product" }}
2029
{{ $entity = partial "schema/collectors/product-entity.html" . }}
2130
{{ else if eq $explicitSchema "event" }}
@@ -43,7 +52,14 @@
4352
{{ else }}
4453
{{/* Leaf tutorial pages (including single-page _index.md bundles
4554
with no children) get HowTo schema */}}
46-
{{ $entity = partial "schema/collectors/howto-entity.html" . }}
55+
{{ $howToEntity := partial "schema/collectors/howto-entity.html" . }}
56+
{{ if $howToEntity }}
57+
{{ $entity = $howToEntity }}
58+
{{ else }}
59+
{{/* Leaf tutorial with no extractable numbered-list steps - fall
60+
back to article rather than emit a stepless HowTo. */}}
61+
{{ $entity = partial "schema/collectors/article-entity.html" . }}
62+
{{ end }}
4763
{{ end }}
4864

4965
{{ else if and (eq .Type "events") .IsPage }}

0 commit comments

Comments
 (0)