1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
|
/*
* Copyright (c) 2021, Ben Wiederhake <BenWiederhake.GitHub@gmx.de>
*
* SPDX-License-Identifier: BSD-2-Clause
*/
/*
* You may want to invoke the checker like this:
* $ cd Build/lagom
* $ ninja
* $ find ../../AK ../../Base ../../Documentation/ ../../Kernel/ ../../Meta/ ../../Ports/ ../../Tests/ ../../Userland/ -type f -name '*.md' | xargs ./markdown-check ../../README.md
*/
#include <AK/Format.h>
#include <AK/HashMap.h>
#include <AK/HashTable.h>
#include <AK/LexicalPath.h>
#include <AK/OwnPtr.h>
#include <AK/RecursionDecision.h>
#include <AK/StdLibExtras.h>
#include <AK/Vector.h>
#include <LibCore/File.h>
#include <LibMarkdown/Document.h>
#include <LibMarkdown/Visitor.h>
static bool is_missing_file_acceptable(String const& filename)
{
const StringView acceptable_missing_files[] = {
// These ones are okay:
"/home/anon/js-tests/test-common.js",
"/man1/index.html",
"/man2/index.html",
"/man3/index.html",
"/man4/index.html",
"/man5/index.html",
"/man6/index.html",
"/man7/index.html",
"/man8/index.html",
"index.html",
};
for (auto const& suffix : acceptable_missing_files) {
if (filename.ends_with(suffix))
return true;
}
return false;
}
struct FileLink {
String file_path; // May be empty, but not null
String anchor; // May be null ("foo.md", "bar.png"), may be empty ("baz.md#")
String label; // May be empty, but not null
};
class MarkdownLinkage final : Markdown::Visitor {
public:
~MarkdownLinkage() = default;
static MarkdownLinkage analyze(Markdown::Document const&);
bool has_anchor(String const& anchor) const { return m_anchors.contains(anchor); }
HashTable<String> const& anchors() const { return m_anchors; }
Vector<FileLink> const& file_links() const { return m_file_links; }
private:
MarkdownLinkage() = default;
virtual RecursionDecision visit(Markdown::Heading const&) override;
virtual RecursionDecision visit(Markdown::Text::LinkNode const&) override;
HashTable<String> m_anchors;
Vector<FileLink> m_file_links;
};
MarkdownLinkage MarkdownLinkage::analyze(Markdown::Document const& document)
{
MarkdownLinkage linkage;
document.walk(linkage);
return linkage;
}
class StringCollector final : Markdown::Visitor {
public:
StringCollector() = default;
virtual ~StringCollector() = default;
String build() { return m_builder.build(); }
static String from(Markdown::Heading const& heading)
{
StringCollector collector;
heading.walk(collector);
return collector.build();
}
static String from(Markdown::Text::Node const& node)
{
StringCollector collector;
node.walk(collector);
return collector.build();
}
private:
virtual RecursionDecision visit(String const& text) override
{
m_builder.append(text);
return RecursionDecision::Recurse;
}
StringBuilder m_builder;
};
static String slugify(String const& text)
{
// TODO: This feels like it belongs into LibWeb.
String slug = text.to_lowercase();
// Reverse-engineered through github, using:
// find AK/ Base/ Documentation/ Kernel/ Meta/ Ports/ Tests/ Userland/ -name '*.md' | xargs grep --color=always -Pin '^##+ .*[^a-z0-9 ?()`_:/!&|.$'"'"',<>"+-]' README.md
slug = slug.replace(" ", "-", true)
.replace("!", "", true)
.replace("?", "", true)
.replace("(", "", true)
.replace(")", "", true)
.replace(":", "", true)
.replace("/", "-", true)
.replace("&", "", true)
.replace("|", "", true)
.replace(".", "", true)
.replace("$", "", true)
.replace("'", "", true)
.replace(",", "", true)
.replace("\"", "", true)
.replace("+", "", true)
.replace("\\", "", true)
.replace("<", "", true)
.replace(">", "", true);
// What about "="?
return slug;
}
RecursionDecision MarkdownLinkage::visit(Markdown::Heading const& heading)
{
m_anchors.set(slugify(StringCollector::from(heading)));
return RecursionDecision::Recurse;
}
RecursionDecision MarkdownLinkage::visit(Markdown::Text::LinkNode const& link_node)
{
String const& href = link_node.href;
if (href.is_null()) {
// Nothing to do here.
return RecursionDecision::Recurse;
}
if (href.starts_with("https://") || href.starts_with("http://")) {
outln("Not checking external link {}", href);
return RecursionDecision::Recurse;
}
if (href.starts_with("help://")) {
// TODO: Check that the man page actually exists. (That check would also fail because we are currently referring to some nonexistent man pages.)
outln("Not checking man page link {}", href);
return RecursionDecision::Recurse;
}
if (href.starts_with("file://")) {
// TODO: Resolve relative to $SERENITY_SOURCE_DIR/Base/
// Currently, this affects only one link, so it's not worth the effort.
outln("Not checking local link {}", href);
return RecursionDecision::Recurse;
}
if (href.starts_with("/res/icons/")) {
// TODO: Resolve relative to $SERENITY_SOURCE_DIR/Base/
outln("Not checking icon link {}", href);
return RecursionDecision::Recurse;
}
String label = StringCollector::from(*link_node.text);
Optional<size_t> last_hash = href.find_last('#');
if (last_hash.has_value()) {
m_file_links.append({ href.substring(0, last_hash.value()), href.substring(last_hash.value() + 1), label });
} else {
m_file_links.append({ href, String(), label });
}
return RecursionDecision::Recurse;
}
int main(int argc, char** argv)
{
if (argc < 2) {
// Technically it is valid to call this program with zero markdown files: When there are
// no files, there are no dead links. However, any such usage is probably erroneous.
warnln("Usage: {} Foo.md Bar.md ...", argv[0]);
// E.g.: find AK/ Base/ Documentation/ Kernel/ Meta/ Ports/ Tests/ Userland/ -name '*.md' -print0 | xargs -0 ./MarkdownCheck
return 1;
}
outln("Reading and parsing Markdown files ...");
HashMap<String, MarkdownLinkage> files;
for (int i = 1; i < argc; ++i) {
auto path = argv[i];
auto file_or_error = Core::File::open(path, Core::OpenMode::ReadOnly);
if (file_or_error.is_error()) {
warnln("Failed to read {}: {}", path, file_or_error.error());
// Since this should never happen anyway, fail early.
return 1;
}
auto file = file_or_error.release_value();
auto content_buffer = file->read_all();
auto content = StringView(content_buffer);
auto document = Markdown::Document::parse(content);
if (!document) {
warnln("Failed to parse {} due to an unspecified error.", path);
// Since this should never happen anyway, fail early.
return 1;
}
files.set(Core::File::real_path_for(path), MarkdownLinkage::analyze(*document));
}
outln("Checking links ...");
bool any_problems = false;
for (auto const& file_item : files) {
auto file_lexical_path = LexicalPath(file_item.key);
auto file_dir = file_lexical_path.dirname();
for (auto const& file_link : file_item.value.file_links()) {
String pointee_file;
if (file_link.file_path.is_empty()) {
pointee_file = file_item.key;
} else {
pointee_file = LexicalPath::absolute_path(file_dir, file_link.file_path);
}
if (!Core::File::exists(pointee_file) && !is_missing_file_acceptable(pointee_file)) {
outln("File '{}' points to '{}' (label '{}'), but '{}' does not exist!",
file_item.key, file_link.file_path, file_link.label, pointee_file);
any_problems = true;
continue;
}
if (file_link.anchor.is_empty()) {
// No anchor to test for.
continue;
}
auto pointee_linkage = files.find(pointee_file);
if (pointee_linkage == files.end()) {
outln("File '{}' points to file '{}', which exists, but was not scanned. Add it to the command-line arguments and re-run.",
file_item.key, pointee_file);
any_problems = true;
continue;
}
if (!pointee_linkage->value.has_anchor(file_link.anchor)) {
outln("File '{}' points to '{}#{}' (label '{}'), but file '{}' does not have any heading that results in the anchor '{}'.",
file_item.key, file_link.file_path, file_link.anchor, file_link.label, pointee_file, file_link.anchor);
out(" The following anchors seem to be available:\n ");
bool any_anchors = false;
for (auto const& anchor : pointee_linkage->value.anchors()) {
if (any_anchors)
out(", ");
out("'{}'", anchor);
any_anchors = true;
}
if (!any_anchors)
out("(none)");
outln();
any_problems = true;
}
}
}
if (any_problems) {
outln("Done. Some errors were encountered, please check above log.");
return 1;
} else {
outln("Done. No problems detected.");
return 0;
}
}
|