Skip to content

Commit fd25054

Browse files
committed
Added markdowner.py functionality from docs
Signed-off-by: Ole Herman Schumacher Elgesem <ole.elgesem@northern.tech>
1 parent 3f9e930 commit fd25054

4 files changed

Lines changed: 305 additions & 7 deletions

File tree

src/cfengine_cli/dev.py

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
from cfbs.commands import generate_release_information_command
22
from cfengine_cli.utils import UserError
33
from cfengine_cli.dependency_tables import update_dependency_tables
4-
from cfengine_cli.docs_formatting import update_docs
4+
from cfengine_cli.docs import update_docs, check_docs
55

66

77
def _continue_prompt() -> bool:
@@ -26,13 +26,20 @@ def dependency_tables() -> int:
2626
return 1
2727

2828

29-
def docs_formatting() -> int:
29+
def docs_format() -> int:
3030
answer = _repo_notice("documentation")
3131
if answer:
3232
return update_docs()
3333
return 1
3434

3535

36+
def docs_check() -> int:
37+
answer = _repo_notice("documentation")
38+
if answer:
39+
return check_docs()
40+
return 1
41+
42+
3643
def release_information() -> int:
3744
answer = _repo_notice("release-information")
3845
if answer:
@@ -44,8 +51,10 @@ def release_information() -> int:
4451
def dispatch_dev_subcommand(subcommand) -> int:
4552
if subcommand == "dependency-tables":
4653
return dependency_tables()
47-
if subcommand == "docs-formatting":
48-
return docs_formatting()
54+
if subcommand == "docs-format":
55+
return docs_format()
56+
if subcommand == "docs-check":
57+
return docs_check()
4958
if subcommand == "release-information":
5059
return release_information()
5160

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
import markdown_it
1717
from cfbs.pretty import pretty_file
1818

19+
from cfengine_cli.markdowner import markdown_prettify
1920
from cfengine_cli.utils import UserError
2021

2122

@@ -118,6 +119,17 @@ def fn_check_syntax(origin_path, snippet_path, language, first_line, _last_line)
118119
raise UserError(f"Couldn't run cf-promises on '{snippet_abs_path}'")
119120
except subprocess.TimeoutExpired:
120121
raise UserError("Timed out")
122+
case "json":
123+
try:
124+
with open(snippet_abs_path, "r") as f:
125+
json.loads(f.read())
126+
except json.decoder.JSONDecodeError as e:
127+
raise UserError(
128+
f"Unknown error when checking '{snippet_abs_path}': {str(e)}"
129+
)
130+
except Exception as e:
131+
print(str(e))
132+
raise UserError(f"Unknown error when checking '{snippet_abs_path}'")
121133

122134

123135
def fn_check_output():
@@ -245,12 +257,19 @@ def _markdown_code_checker(
245257
)
246258
if cleanup:
247259
os.remove(snippet_path)
260+
if autoformat:
261+
markdown_prettify(origin_path)
248262

249263

250264
def update_docs() -> int:
251-
"""Entry point to be called by other files
265+
"""
266+
Iterate through entire docs repo (.), autoformatting all JSONs.
252267
253-
I.e. what is actually run when you do cfengine dev docs-formatting"""
268+
Will be expanded to more types of formatting in the future.
269+
270+
Run by the command:
271+
cfengine dev docs-format
272+
"""
254273
_markdown_code_checker(
255274
path=".",
256275
syntax_check=False,
@@ -262,3 +281,21 @@ def update_docs() -> int:
262281
cleanup=True,
263282
)
264283
return 0
284+
285+
286+
def check_docs() -> int:
287+
"""Entry point to be called by other files
288+
289+
Run by the command:
290+
cfengine dev docs-checking"""
291+
_markdown_code_checker(
292+
path=".",
293+
syntax_check=True,
294+
extract=True,
295+
replace=False,
296+
autoformat=False,
297+
languages=["json"],
298+
output_check=False,
299+
cleanup=True,
300+
)
301+
return 0

src/cfengine_cli/main.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,8 @@ def _get_arg_parser():
5959
)
6060
dev_subparsers = dev_parser.add_subparsers(dest="dev_command")
6161
dev_subparsers.add_parser("dependency-tables")
62-
dev_subparsers.add_parser("docs-formatting")
62+
dev_subparsers.add_parser("docs-format")
63+
dev_subparsers.add_parser("docs-check")
6364
dev_subparsers.add_parser("release-information")
6465

6566
return ap

src/cfengine_cli/markdowner.py

Lines changed: 251 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,251 @@
1+
#!/usr/bin/env python3
2+
r"""
3+
Minimal markdown prettifier which gets rid of some common mistakes:
4+
- Trailing whitespace
5+
- Repeated newlines
6+
- Missing trailing newline before end of file
7+
8+
To run this on all markdown files do:
9+
find . -name '*.markdown' -type f -exec python3 ./markdowner.py {} \; | tee output.log
10+
11+
In the future, we might switch to just running Prettier on all markdown files.
12+
This is just a halfway step.
13+
markdowner.py is kept in a separate file for this reason.
14+
In the future it might be entirely deleted and replaced with Prettier.
15+
"""
16+
17+
18+
import sys
19+
import re
20+
21+
22+
def replace_with_dict(content, replacements, filename):
23+
for k, v in replacements.items():
24+
while k in content:
25+
print(f"{filename}: {repr(k)} -> {repr(v)}")
26+
content = content.replace(k, v)
27+
return content
28+
29+
30+
def replace_with_regex_dict(content, replacements, filename):
31+
for str_pattern, replacement in replacements.items():
32+
pattern = re.compile(str_pattern, flags=re.MULTILINE)
33+
while True:
34+
match = pattern.search(content)
35+
if not match:
36+
break
37+
start, end = match.span()
38+
match = match.group(0)
39+
print(f"{filename}: {repr(match)} -> {repr(replacement)}")
40+
content = content[0:start] + replacement + content[end:]
41+
return content
42+
43+
44+
def process_codeblock(lines, filename, lineno_start):
45+
result = []
46+
begin = lines[0]
47+
end = lines[-1]
48+
lines = lines[1:-1] # Lines inside code block
49+
50+
prefix = begin[0 : begin.index("```")]
51+
lang = begin[len(prefix) + 3 :].strip()
52+
53+
# Checks for warnings which make us leave the code block alone:
54+
55+
if not end == (prefix + "```"):
56+
lineno = lineno_start + len(lines) + 1
57+
print(f"WARNING {filename}:{lineno}: End backticks not matching beginning")
58+
return [begin, *lines, end]
59+
60+
lineno = lineno_start
61+
for i, line in enumerate(lines):
62+
# Empty lines are already correct, skip them:
63+
if line == "":
64+
lineno += 1
65+
continue
66+
if not line.startswith(prefix):
67+
print(f"WARNING {filename}:{lineno}: Code block indentation inconsistent")
68+
return [begin, *lines, end]
69+
# Should already be fixed if using the trailing whitespace removal:
70+
if line == prefix or line.strip() == "":
71+
print(f"WARNING {filename}:{lineno}: Code block has whitespace-only lines")
72+
return [begin, *lines, end]
73+
lineno += 1
74+
75+
# Find the common indentation which we would like to remove:
76+
common_indent = None
77+
lineno = lineno_start
78+
for i, line in enumerate(lines):
79+
# Don't consider empty lines for common indentation:
80+
if line == "":
81+
lineno += 1
82+
continue
83+
if line[len(prefix) :][0] != " ":
84+
# Found content without extra indentation -
85+
# no common indentation to remove.
86+
common_indent = None
87+
break
88+
index = len(prefix)
89+
spaces = 0
90+
while True:
91+
c = line[index]
92+
if c != " ":
93+
break
94+
spaces += 1
95+
index += 1
96+
if index >= len(line):
97+
break
98+
if common_indent is None or spaces < common_indent:
99+
common_indent = spaces
100+
lineno += 1
101+
102+
# Remove common indent if found:
103+
if common_indent is not None and common_indent > 0:
104+
spaces = common_indent
105+
lines = [
106+
x if x == "" else x[0 : len(prefix)] + x[len(prefix) + spaces :]
107+
for x in lines
108+
]
109+
print(
110+
f"{filename}:{lineno_start}: De-indented {lang + ' ' if lang else ''}code block"
111+
)
112+
113+
# Remove empty lines at beginning and end:
114+
while lines and lines[0] == "":
115+
lines = lines[1:]
116+
print(
117+
f"{filename}:{lineno_start}: Removed empty line at beginning of {lang + ' ' if lang else ''} code block"
118+
)
119+
while lines and lines[-1] == "":
120+
lines = lines[0:-1]
121+
print(
122+
f"{filename}:{lineno_start}: Removed empty line at beginning of {lang + ' ' if lang else ''} code block"
123+
)
124+
125+
# "Render" result - May or may not be different
126+
result.append(begin)
127+
result.extend(lines)
128+
result.append(end)
129+
return result
130+
131+
132+
def edit_codeblocks(content, filename):
133+
done = []
134+
to_do = []
135+
state = "outside"
136+
lineno = 0
137+
lineno_start = None
138+
will_try = False
139+
140+
for line in content.split("\n"):
141+
lineno += 1
142+
if state == "outside":
143+
count = len(line.split("```")) - 1
144+
if count == 0:
145+
done.append(line)
146+
elif count == 1 and line.strip().startswith("```"):
147+
to_do.append(line)
148+
state = "inside"
149+
lineno_start = lineno
150+
will_try = True
151+
elif count % 2 != 0:
152+
print(
153+
f"WARNING {filename}:{lineno}: Start of code block not on start of line"
154+
)
155+
done.append(line)
156+
will_try = False
157+
state = "inside"
158+
else:
159+
done.append(line)
160+
else:
161+
assert state == "inside"
162+
if will_try:
163+
to_do.append(line)
164+
else:
165+
done.append(line)
166+
if line.strip().startswith("```"):
167+
if to_do:
168+
done.extend(process_codeblock(to_do, filename, lineno_start))
169+
to_do = []
170+
state = "outside"
171+
elif "```" in line:
172+
print(
173+
f"WARNING {filename}:{lineno}: End of code block not on start of line"
174+
)
175+
will_try = False
176+
done.extend(to_do)
177+
to_do = []
178+
179+
count = len(line.split("```")) - 1
180+
if count % 2 == 1:
181+
state = "outside"
182+
183+
done.extend(to_do)
184+
content = "\n".join(done)
185+
return content
186+
187+
188+
def perform_edits(
189+
content,
190+
filename,
191+
newlines=True,
192+
trailing=True,
193+
ascii=True,
194+
eof=True,
195+
codeblocks=True,
196+
all=False,
197+
):
198+
if trailing or all:
199+
replacements = {" \n": "\n", "\t\n": "\n"}
200+
content = replace_with_dict(content, replacements, filename)
201+
202+
if ascii or all:
203+
replacements = {"‘": "'", "’": "'", "“": '"', "”": '"', "–": "-"}
204+
content = replace_with_dict(content, replacements, filename)
205+
if newlines or all:
206+
replacements = {
207+
r"\n{3,}": "\n\n",
208+
}
209+
content = replace_with_regex_dict(content, replacements, filename)
210+
211+
if eof or all:
212+
while content.endswith("\n\n"):
213+
content = content[:-1]
214+
print(f"{filename}: Removed excess newlines before EOF")
215+
if not content.endswith("\n"):
216+
content = content + "\n"
217+
print(f"{filename}: Added newline before EOF")
218+
219+
if codeblocks or all:
220+
replacements = {
221+
# Empty line (double newline) before command:
222+
r"(?<!\n)\n```command": "\n\n```command",
223+
# Exactly one empty line between code block and output:
224+
r"```(\n|\n\n\n+)```output": "```\n\n```output",
225+
}
226+
content = replace_with_regex_dict(content, replacements, filename)
227+
content = edit_codeblocks(content, filename)
228+
return content
229+
230+
231+
def markdown_prettify(filename):
232+
# Loading content:
233+
with open(filename, "r") as f:
234+
old_content = f.read()
235+
236+
new_content = perform_edits(old_content, filename)
237+
238+
# Save if necessary:
239+
if new_content != old_content:
240+
with open(filename, "w") as f:
241+
f.write(new_content)
242+
243+
244+
def main():
245+
# Argument parsing:
246+
filename = sys.argv[1]
247+
markdown_prettify(filename)
248+
249+
250+
if __name__ == "__main__":
251+
main()

0 commit comments

Comments
 (0)