Skip to content

Commit 2b84229

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 43c34b7 commit 2b84229

3 files changed

Lines changed: 165 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: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
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 $default_scan = !@ARGV;
39+
my @roots = @ARGV ? @ARGV : ('.');
40+
for my $root (@roots) {
41+
if(-f $root) {
42+
candidate($root);
43+
}
44+
elsif(-d $root) {
45+
find({
46+
no_chdir => 1,
47+
wanted => sub {
48+
my $path = $File::Find::name;
49+
if(-d $path) {
50+
$File::Find::prune = 1 if basename($path) eq '.git';
51+
return;
52+
}
53+
candidate($path);
54+
}
55+
}, $root);
56+
}
57+
else {
58+
problem($root, 'file or directory does not exist');
59+
}
60+
}
61+
62+
my $nav_start = qr{
63+
<nav\b
64+
(?=[^>]*\bclass\s*=\s*(?:"[^"]*\bsitenav\b[^"]*"|'[^']*\bsitenav\b[^']*'))
65+
[^>]*>
66+
}ix;
67+
68+
my $detail_start = qr{
69+
<details\b
70+
(?=[^>]*\bclass\s*=\s*(?:"[^"]*\bsitenav-disclosure\b[^"]*"|'[^']*\bsitenav-disclosure\b[^']*'))
71+
[^>]*>
72+
}ix;
73+
74+
my $state_id = qr{\bid\s*=\s*(?:"sitenav-state"|'sitenav-state')}i;
75+
my $state_for = qr{\bfor\s*=\s*(?:"sitenav-state"|'sitenav-state')}i;
76+
my $group_name = qr{\bname\s*=\s*(?:"sitenav-submenus"|'sitenav-submenus')}i;
77+
my $open_attribute = qr{\sopen(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+))?(?=\s|/?>)}i;
78+
my $leaked_macro = qr{\b(SITENAV_[A-Z0-9_]+|START_OF_MAIN|__NAV_T|__MENU)\b};
79+
80+
for my $file (sort keys %files) {
81+
open(my $input, '<', $file) or do {
82+
problem($file, "cannot read: $!");
83+
next;
84+
};
85+
local $/;
86+
my $html = <$input>;
87+
close($input);
88+
89+
my %leaked;
90+
while($html =~ /$leaked_macro/g) {
91+
$leaked{$1} = 1;
92+
}
93+
if(%leaked) {
94+
problem($file, 'leaked FCPP token(s): ' .
95+
join(', ', sort keys %leaked));
96+
}
97+
98+
my $nav_count = match_count($html, $nav_start);
99+
next if !$nav_count;
100+
$nav_pages++;
101+
102+
my @nav_blocks = ($html =~ /($nav_start.*?<\/nav\s*>)/gis);
103+
if($nav_count != 1) {
104+
problem($file, "expected one sitenav, found $nav_count");
105+
}
106+
if(@nav_blocks != $nav_count) {
107+
problem($file, 'could not match every sitenav start with a closing </nav>');
108+
}
109+
next if !@nav_blocks;
110+
111+
my $nav = join("\n", @nav_blocks);
112+
my $id_count = match_count($nav, $state_id);
113+
my $for_count = match_count($nav, $state_for);
114+
if($id_count != 1) {
115+
problem($file, "expected one sitenav-state id, found $id_count");
116+
}
117+
if($for_count != 1) {
118+
problem($file, "expected one sitenav-state label, found $for_count");
119+
}
120+
121+
my @detail_tags = ($nav =~ /($detail_start)/g);
122+
my $detail_number = 0;
123+
for my $tag (@detail_tags) {
124+
$detail_number++;
125+
my $name_count = match_count($tag, $group_name);
126+
if($name_count != 1) {
127+
problem($file, "disclosure $detail_number has $name_count sitenav-submenus name attributes");
128+
}
129+
if($tag =~ /$open_attribute/) {
130+
problem($file, "disclosure $detail_number starts open");
131+
}
132+
}
133+
134+
my @detail_blocks = ($nav =~ /($detail_start.*?<\/details\s*>)/gis);
135+
if(@detail_blocks != @detail_tags) {
136+
problem($file, 'could not match every navigation disclosure with a closing </details>');
137+
}
138+
$detail_number = 0;
139+
for my $details (@detail_blocks) {
140+
$detail_number++;
141+
if($details !~ /<a\b/i) {
142+
problem($file, "disclosure $detail_number contains no links");
143+
}
144+
}
145+
}
146+
147+
if($default_scan && !$nav_pages) {
148+
problem('navcheck', 'no generated sitenav pages found; run make first');
149+
}
150+
151+
if(@problems) {
152+
print STDERR "$_\n" for @problems;
153+
print STDERR 'navcheck: ', scalar(@problems), " problem(s) found\n";
154+
exit 1;
155+
}
156+
157+
print 'navcheck: ', $nav_pages, ' navigation page(s) checked across ',
158+
scalar(keys %files), " generated HTML file(s)\n";

0 commit comments

Comments
 (0)