Skip to content

Commit c437d8c

Browse files
Validate generated pages and historical redirects
Check generated pages, metadata, local links, and historical redirects before deployment. Reject repeated preparation, duplicate redirect destinations, and redirect targets parsed from HTML. Correct the homepage image dimensions and use literal replacement for the shared page URL. Validation: Hugo Extended 0.111.3 production build; 81 sitemap pages and 92 redirects; six redirect preparation checks.
1 parent b3290a4 commit c437d8c

5 files changed

Lines changed: 145 additions & 3 deletions

File tree

.github/workflows/gh-pages.yaml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,10 @@ jobs:
7676
if: github.ref == 'refs/heads/main'
7777
run: python3 scripts/prepare-pages.py --origin "${{ env.base_url }}"
7878

79+
- name: Check generated pages and historical redirects
80+
if: github.ref == 'refs/heads/main'
81+
run: python3 scripts/check-pages.py --origin "${{ env.base_url }}"
82+
7983
# Deploy main build to /latest
8084
- name: Deploy latest (main)
8185
if: github.ref == 'refs/heads/main'

content/en/_index.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ <h2>See the hardware and mowing in action</h2>
6363
</div>
6464
<div class="col-12 col-lg-6">
6565
<figure>
66-
<img class="img-fluid nozoom" loading="lazy" width="800" height="600" src='{{< relref "docs/Knowledge-Base/getting-started/compatible-mowers" >}}images/johndeere-s1-v01-assembled.jpg' alt="OpenMower carrier board and wiring installed in a John Deere Tango E5 mower">
66+
<img class="img-fluid nozoom" loading="lazy" width="799" height="479" src='{{< relref "docs/Knowledge-Base/getting-started/compatible-mowers" >}}images/johndeere-s1-v01-assembled.jpg' alt="OpenMower carrier board and wiring installed in a John Deere Tango E5 mower">
6767
<figcaption>OpenMower carrier board installed in a John Deere Tango E5 (Series I).</figcaption>
6868
</figure>
6969
</div>

layouts/partials/head.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
{{- end -}}
2525
</title>
2626
<meta name="description" content="{{ template "partials/page-description.html" . }}">
27-
{{ partial "opengraph.html" . | replaceRE (printf `content="%s"` .Permalink) (printf `content="%s"` (partial "canonical-url.html" .)) | safeHTML -}}
27+
{{ replace (partial "opengraph.html" .) (printf `content="%s"` .Permalink) (printf `content="%s"` (partial "canonical-url.html" .)) | safeHTML -}}
2828
{{ template "_internal/schema.html" . -}}
2929
{{ template "_internal/twitter_cards.html" . -}}
3030
{{ partialCached "head-css.html" . "" -}}

scripts/check-pages.py

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
#!/usr/bin/env python3
2+
"""Check a prepared root/latest deployment without making network requests."""
3+
import argparse
4+
import csv
5+
from collections import defaultdict
6+
from html.parser import HTMLParser
7+
from pathlib import Path
8+
from urllib.parse import unquote, urljoin, urlsplit
9+
import xml.etree.ElementTree as ET
10+
11+
12+
class Page(HTMLParser):
13+
def __init__(self, path):
14+
super().__init__()
15+
self.canonical = []
16+
self.robots = []
17+
self.refresh = []
18+
self.links = []
19+
self.h1 = 0
20+
self.title = ''
21+
self.in_title = False
22+
self.og_url = None
23+
self.description = ''
24+
self.feed(path.read_text())
25+
26+
def handle_starttag(self, tag, attrs):
27+
a = dict(attrs)
28+
if tag == 'h1':
29+
self.h1 += 1
30+
if tag == 'title':
31+
self.in_title = True
32+
if tag == 'link' and a.get('rel') == 'canonical':
33+
self.canonical.append(a['href'])
34+
if tag == 'meta':
35+
if a.get('name') == 'robots':
36+
self.robots.append(a['content'])
37+
if a.get('name') == 'description':
38+
self.description = a['content']
39+
if a.get('http-equiv', '').lower() == 'refresh':
40+
self.refresh.append(a['content'])
41+
if a.get('property') == 'og:url':
42+
self.og_url = a['content']
43+
if tag == 'a' and 'href' in a:
44+
self.links.append(a['href'])
45+
if tag in ('img', 'script') and 'src' in a:
46+
self.links.append(a['src'])
47+
if tag == 'link' and a.get('rel') == 'stylesheet':
48+
self.links.append(a['href'])
49+
50+
def handle_endtag(self, tag):
51+
if tag == 'title':
52+
self.in_title = False
53+
54+
def handle_data(self, text):
55+
if self.in_title:
56+
self.title += text
57+
58+
59+
def check(latest, root, origin, inventory):
60+
origin = origin.rstrip('/')
61+
62+
def local(url):
63+
path = unquote(urlsplit(url).path)
64+
file = latest / path.removeprefix('/latest/') if path.startswith('/latest/') else root / path.lstrip('/')
65+
return file / 'index.html' if path.endswith('/') else file
66+
67+
urls = [e.text for e in ET.parse(root / 'sitemap.xml').iter('{http://www.sitemaps.org/schemas/sitemap/0.9}loc')]
68+
assert origin + '/' in urls and origin + '/latest/' not in urls
69+
assert len(urls) == len(set(urls)), 'Duplicate sitemap URLs'
70+
titles = defaultdict(list)
71+
for url in urls:
72+
page = Page(local(url))
73+
assert not page.refresh, url
74+
assert page.canonical == [url], (url, page.canonical)
75+
assert page.robots == ['index, follow'], (url, page.robots)
76+
assert page.h1 == 1 and page.description.strip(), url
77+
assert page.og_url == url, (url, page.og_url)
78+
titles[page.title].append(url)
79+
if url != origin + '/':
80+
assert page.title.endswith(' | OpenMower'), (url, page.title)
81+
for link in page.links:
82+
target = urljoin(url, link)
83+
parsed = urlsplit(target)
84+
if parsed.netloc != urlsplit(origin).netloc or parsed.path.startswith('/archive/'):
85+
continue
86+
assert local(target).is_file(), (url, link)
87+
assert all(len(v) == 1 for v in titles.values()), dict(titles)
88+
assert f'Sitemap: {origin}/sitemap.xml' in (root / 'robots.txt').read_text()
89+
assert (root / 'sitemap.xml').read_bytes() == (latest / 'sitemap.xml').read_bytes()
90+
for route in ('search/', 'tags/', 'tags/mapping/', 'categories/'):
91+
url = origin + '/latest/' + route
92+
assert url not in urls
93+
assert Page(local(url)).robots == ['noindex, follow'], url
94+
rows = list(csv.DictReader(inventory.open()))
95+
rows.append({'old_path': '/latest/', 'replacement_path': '/'})
96+
for row in rows:
97+
target = origin + row['replacement_path']
98+
page = Page(local(origin + row['old_path']))
99+
assert page.canonical == [target], row
100+
assert page.refresh == [f'0; url={target}'], row
101+
assert not Page(local(target)).refresh, row
102+
home = (root / 'index.html').read_text()
103+
for asset in ('asciinema-player', 'carousel.js', 'carousel.css', 'medium-zoom'):
104+
assert asset not in home, asset
105+
print(f'Passed: {len(urls)} sitemap pages, {len(rows)} redirects, metadata, headings, internal links/assets, and indexing rules')
106+
107+
108+
if __name__ == '__main__':
109+
parser = argparse.ArgumentParser(description=__doc__)
110+
parser.add_argument('--latest', type=Path, default=Path('public'))
111+
parser.add_argument('--root', type=Path, default=Path('public-root'))
112+
parser.add_argument('--origin', default='https://openmower.de')
113+
parser.add_argument('--inventory', type=Path, default=Path('data/redirects.csv'))
114+
args = parser.parse_args()
115+
check(args.latest, args.root, args.origin, args.inventory)

scripts/prepare-pages.py

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,26 @@
88
import csv
99
import html
1010
import shutil
11+
from html.parser import HTMLParser
1112
from pathlib import Path
1213
from urllib.parse import urlsplit
1314

1415

16+
class PageMetadata(HTMLParser):
17+
def __init__(self, path):
18+
super().__init__()
19+
self.redirect = False
20+
self.canonical = None
21+
self.feed(path.read_text())
22+
23+
def handle_starttag(self, tag, attrs):
24+
attrs = dict(attrs)
25+
if tag == 'meta' and attrs.get('http-equiv', '').lower() == 'refresh':
26+
self.redirect = True
27+
if tag == 'link' and attrs.get('rel') == 'canonical':
28+
self.canonical = attrs.get('href')
29+
30+
1531
def redirect_page(target):
1632
target = html.escape(target, quote=True)
1733
return ('<!doctype html><html lang="en"><head><meta charset="utf-8">'
@@ -27,7 +43,11 @@ def prepare(latest, root, origin, inventory):
2743
parsed = urlsplit(origin)
2844
if parsed.scheme not in ('http', 'https') or not parsed.netloc or parsed.path:
2945
raise ValueError('origin must be an HTTP(S) origin without a path')
46+
home = PageMetadata(latest / "index.html")
47+
if home.redirect or home.canonical != origin + "/":
48+
raise ValueError("Expected a fresh latest build with the root homepage canonical")
3049
planned = []
50+
seen = set()
3151
with inventory.open() as source:
3252
for row in csv.DictReader(source):
3353
old, target = row['old_path'], row['replacement_path']
@@ -37,10 +57,13 @@ def prepare(latest, root, origin, inventory):
3757
if not target.startswith('/latest/') or old == target:
3858
raise ValueError(f'Invalid redirect: {old} -> {target}')
3959
replacement = latest / target.removeprefix('/latest/') / 'index.html'
40-
if not replacement.is_file() or 'http-equiv="refresh"' in replacement.read_text():
60+
if not replacement.is_file() or PageMetadata(replacement).redirect:
4161
raise ValueError(f'Missing or non-final target: {target}')
4262
output = (latest / old.removeprefix('/latest/') if old.startswith('/latest/')
4363
else root / old.lstrip('/')) / 'index.html'
64+
if output in seen:
65+
raise ValueError(f"Duplicate redirect destination: {output}")
66+
seen.add(output)
4467
if output.exists():
4568
raise ValueError(f'Redirect would overwrite an existing page: {output}')
4669
planned.append((output, origin + target))

0 commit comments

Comments
 (0)