Skip to content

Commit 1c12563

Browse files
committed
menu: validate generated navigation markup
Check every generated page containing site navigation for duplicate navigation controls, malformed disclosure structure or initial state, leaked FCPP macros, and empty disclosures. Expose the check as a make target and run it in CI after the website build.
1 parent 618d1e5 commit 1c12563

3 files changed

Lines changed: 170 additions & 0 deletions

File tree

.github/workflows/make.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,3 +59,6 @@ jobs:
5959

6060
- name: make
6161
run: make
62+
63+
- name: navcheck
64+
run: make navcheck

Makefile

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,10 @@ perlcheck:
160160
fi \
161161
done)
162162

163+
.PHONY: navcheck
164+
navcheck:
165+
./navcheck.pl
166+
163167
full: all
164168
@cd libcurl; make
165169

navcheck.pl

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
#!/usr/bin/env perl
2+
# Copyright (C) Daniel Stenberg, <daniel@haxx.se>, et al.
3+
#
4+
# SPDX-License-Identifier: curl
5+
6+
use strict;
7+
use warnings;
8+
9+
use File::Basename qw(basename);
10+
use File::Find qw(find);
11+
12+
my @problems;
13+
my %files;
14+
my $nav_pages = 0;
15+
16+
sub problem {
17+
my ($file, $message) = @_;
18+
push @problems, "$file: $message";
19+
}
20+
21+
sub match_count {
22+
my ($text, $pattern) = @_;
23+
my $count = 0;
24+
while($text =~ /$pattern/g) {
25+
$count++;
26+
}
27+
return $count;
28+
}
29+
30+
sub candidate {
31+
my ($path) = @_;
32+
my $name = basename($path);
33+
return if $name =~ /^_/;
34+
return if $name !~ /\.html\z/;
35+
$files{$path} = 1 if -f $path;
36+
}
37+
38+
my %skip_directory = map { $_ => 1 }
39+
qw(.git .playwright-mcp cvssource fcpp roffit);
40+
41+
my $default_scan = !@ARGV;
42+
my @roots = @ARGV ? @ARGV : ('.');
43+
for my $root (@roots) {
44+
if(-f $root) {
45+
candidate($root);
46+
}
47+
elsif(-d $root) {
48+
find({
49+
no_chdir => 1,
50+
wanted => sub {
51+
my $path = $File::Find::name;
52+
if(-d $path) {
53+
if($skip_directory{basename($path)}) {
54+
$File::Find::prune = 1;
55+
}
56+
return;
57+
}
58+
candidate($path);
59+
}
60+
}, $root);
61+
}
62+
else {
63+
problem($root, 'file or directory does not exist');
64+
}
65+
}
66+
67+
my $nav_start = qr{
68+
<nav\b
69+
(?=[^>]*\bclass\s*=\s*(?:"[^"]*\bsitenav\b[^"]*"|'[^']*\bsitenav\b[^']*'))
70+
[^>]*>
71+
}ix;
72+
73+
my $detail_start = qr{
74+
<details\b
75+
(?=[^>]*\bclass\s*=\s*(?:"[^"]*\bsitenav-disclosure\b[^"]*"|'[^']*\bsitenav-disclosure\b[^']*'))
76+
[^>]*>
77+
}ix;
78+
79+
my $state_id = qr{\bid\s*=\s*(?:"sitenav-state"|'sitenav-state')}i;
80+
my $state_for = qr{\bfor\s*=\s*(?:"sitenav-state"|'sitenav-state')}i;
81+
my $group_name = qr{\bname\s*=\s*(?:"sitenav-submenus"|'sitenav-submenus')}i;
82+
my $open_attribute = qr{\sopen(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+))?(?=\s|/?>)}i;
83+
my $leaked_macro = qr{\b(SITENAV_[A-Z0-9_]+|START_OF_MAIN|__NAV_T|__MENU)\b};
84+
85+
for my $file (sort keys %files) {
86+
open(my $input, '<', $file) or do {
87+
problem($file, "cannot read: $!");
88+
next;
89+
};
90+
local $/;
91+
my $html = <$input>;
92+
close($input);
93+
94+
my %leaked;
95+
while($html =~ /$leaked_macro/g) {
96+
$leaked{$1} = 1;
97+
}
98+
if(%leaked) {
99+
problem($file, 'leaked FCPP token(s): ' .
100+
join(', ', sort keys %leaked));
101+
}
102+
103+
my $nav_count = match_count($html, $nav_start);
104+
next if !$nav_count;
105+
$nav_pages++;
106+
107+
my @nav_blocks = ($html =~ /($nav_start.*?<\/nav\s*>)/gis);
108+
if($nav_count != 1) {
109+
problem($file, "expected one sitenav, found $nav_count");
110+
}
111+
if(@nav_blocks != $nav_count) {
112+
problem($file, 'could not match every sitenav start with a closing </nav>');
113+
}
114+
next if !@nav_blocks;
115+
116+
my $nav = join("\n", @nav_blocks);
117+
my $id_count = match_count($nav, $state_id);
118+
my $for_count = match_count($nav, $state_for);
119+
if($id_count != 1) {
120+
problem($file, "expected one sitenav-state id, found $id_count");
121+
}
122+
if($for_count != 1) {
123+
problem($file, "expected one sitenav-state label, found $for_count");
124+
}
125+
126+
my @detail_tags = ($nav =~ /($detail_start)/g);
127+
my $detail_number = 0;
128+
for my $tag (@detail_tags) {
129+
$detail_number++;
130+
my $name_count = match_count($tag, $group_name);
131+
if($name_count != 1) {
132+
problem($file, "disclosure $detail_number has $name_count sitenav-submenus name attributes");
133+
}
134+
if($tag =~ /$open_attribute/) {
135+
problem($file, "disclosure $detail_number starts open");
136+
}
137+
}
138+
139+
my @detail_blocks = ($nav =~ /($detail_start.*?<\/details\s*>)/gis);
140+
if(@detail_blocks != @detail_tags) {
141+
problem($file, 'could not match every navigation disclosure with a closing </details>');
142+
}
143+
$detail_number = 0;
144+
for my $details (@detail_blocks) {
145+
$detail_number++;
146+
if($details !~ /<a\b/i) {
147+
problem($file, "disclosure $detail_number contains no links");
148+
}
149+
}
150+
}
151+
152+
if($default_scan && !$nav_pages) {
153+
problem('navcheck', 'no generated sitenav pages found; run make first');
154+
}
155+
156+
if(@problems) {
157+
print STDERR "$_\n" for @problems;
158+
print STDERR 'navcheck: ', scalar(@problems), " problem(s) found\n";
159+
exit 1;
160+
}
161+
162+
print 'navcheck: ', $nav_pages, ' navigation page(s) checked across ',
163+
scalar(keys %files), " generated HTML file(s)\n";

0 commit comments

Comments
 (0)