Coverage for src/coursebook/markdown.py: 99%
92 statements
« prev ^ index » next coverage.py v7.17.0, created at 2026-10-31 22:06 +0000
« prev ^ index » next coverage.py v7.17.0, created at 2026-10-31 22:06 +0000
1"""Tools for reading and adjusting the markdown in source files.
3Contents:
4 extract_images: returns the images in markdown text.
5 namespace_footnotes: adds a prefix to the footnote labels in markdown text.
6 normalize_name: converts a name to lower case words joined by underscores.
7 shift_headings: changes the level of every heading in markdown text.
8 split_front_matter: separates the front matter from markdown text.
9 split_sections: divides markdown text into its marked sections.
10 _DIVIDER: matches a line that opens or closes a section (such as
11 '<slides>').
12 _FENCE: matches the opening or closing line of a fenced code block.
13 _FOOTNOTE: matches a footnote reference or definition label.
14 _FRONT_MATTER_DELIMITERS: the lines that may open and close front matter.
15 _HEADING: matches the '#' characters at the start of an ATX heading.
16 _IMAGE: matches a markdown image and any pandoc attributes after it.
17 _MAXIMUM_HEADING_LEVEL: the deepest heading level that markdown supports.
19"""
21from __future__ import annotations
23import re
24from typing import TYPE_CHECKING, Any
26import yaml
28if TYPE_CHECKING:
29 from collections.abc import Iterator
31_FENCE: re.Pattern[str] = re.compile(r'^ {0,3}(`{3,}|~{3,})')
32# Front matter normally opens with '---'. The `'''` delimiter is also allowed
33# because the original `coursebook` examples used it.
34_FRONT_MATTER_DELIMITERS: dict[str, tuple[str, ...]] = {
35 '---': ('---', '...'),
36 "'''": ("'''",)}
37_DIVIDER: re.Pattern[str] = re.compile(
38 r'^\s*<\s*(/?)\s*([A-Za-z_]+)\s*>\s*$')
39_FOOTNOTE: re.Pattern[str] = re.compile(r'\[\^([^\]\s]+)\]')
40_HEADING: re.Pattern[str] = re.compile(r'^(#{1,6})(?=\s|$)')
41_IMAGE: re.Pattern[str] = re.compile(
42 r'!\[[^\]]*\]\([^)]+\)(?:\{[^}]*\})?')
43_MAXIMUM_HEADING_LEVEL: int = 6
46""" Public Functions """
49def extract_images(text: str) -> list[str]:
50 """Returns the images in markdown `text`.
52 Images inside fenced code blocks are ignored.
54 Args:
55 text: markdown text.
57 Returns:
58 The markdown for each image (including any pandoc attributes, such as
59 `{width=50%}`), in the order in which they appear.
61 """
62 images: list[str] = []
63 for line, in_code in _iterate_lines(text):
64 if not in_code:
65 images.extend(match.group(0) for match in _IMAGE.finditer(line))
66 return images
69def namespace_footnotes(text: str, prefix: str) -> str:
70 """Adds `prefix` to the footnote labels in markdown `text`.
72 Pandoc treats footnote labels as belonging to the whole document. So, if two
73 source files each use `[^1]`, the second footnote would be replaced by the
74 first when the files are combined. Adding a prefix that is unique to each
75 source file keeps the footnotes separate.
77 Args:
78 text: markdown text.
79 prefix: text to add to the start of each footnote label.
81 Returns:
82 `text` with each footnote label (in both the references and the
83 definitions) changed from `[^label]` to `[^prefix-label]`.
85 """
86 lines = []
87 for line, in_code in _iterate_lines(text):
88 lines.append(
89 line if in_code else _FOOTNOTE.sub(rf'[^{prefix}-\1]', line))
90 return '\n'.join(lines)
93def normalize_name(name: str) -> str:
94 """Converts `name` to lower case words joined by underscores.
96 Every run of characters that are not letters or numbers is replaced with a
97 single underscore. For example, 'Law & Economics' becomes 'law_economics'
98 and 'Law Review' becomes 'law_review'.
100 Args:
101 name: the name to convert.
103 Returns:
104 The normalized name.
106 """
107 return re.sub(r'[^0-9a-z]+', '_', name.lower()).strip('_')
110def shift_headings(text: str, levels: int) -> str:
111 """Changes the level of every heading in markdown `text`.
113 Only ATX headings (headings that start with '#') are changed. Headings
114 inside fenced code blocks are left alone. Markdown does not support more
115 than six levels of headings, so deeper headings are kept at level six.
117 Args:
118 text: markdown text.
119 levels: number of levels to add to each heading. A negative number
120 raises the headings, but never above level one.
122 Returns:
123 `text` with its headings shifted.
125 """
126 lines = []
127 for line, in_code in _iterate_lines(text):
128 match = None if in_code else _HEADING.match(line)
129 if match:
130 level = len(match.group(1)) + levels
131 level = max(1, min(level, _MAXIMUM_HEADING_LEVEL))
132 lines.append('#' * level + line[match.end():])
133 else:
134 lines.append(line)
135 return '\n'.join(lines)
138def split_front_matter(text: str) -> tuple[dict[str, Any], str]:
139 """Separates the YAML front matter from markdown `text`.
141 Front matter must start on the first line that is not blank. It opens with
142 '---' (and closes with '---' or '...') or opens and closes with "'''".
144 Args:
145 text: markdown text that may start with front matter.
147 Returns:
148 The front matter as a `dict` (which is empty if there is no front
149 matter) and the rest of `text`.
151 Raises:
152 ValueError: if the front matter is never closed or is not a mapping of
153 keys to values.
155 """
156 lines = text.lstrip('').splitlines()
157 start = 0
158 while start < len(lines) and not lines[start].strip():
159 start += 1
160 if start == len(lines): 160 ↛ 161line 160 didn't jump to line 161 because the condition on line 160 was never true
161 return {}, ''
162 opener = lines[start].strip()
163 if opener not in _FRONT_MATTER_DELIMITERS:
164 return {}, text
165 closers = _FRONT_MATTER_DELIMITERS[opener]
166 for end in range(start + 1, len(lines)):
167 if lines[end].strip() in closers:
168 break
169 else:
170 message = f'front matter opened with "{opener}" is never closed'
171 raise ValueError(message)
172 metadata = yaml.safe_load('\n'.join(lines[start + 1:end]))
173 if metadata is None:
174 metadata = {}
175 if not isinstance(metadata, dict):
176 message = 'front matter must be a mapping of keys to values'
177 raise ValueError(message) # noqa: TRY004
178 return metadata, '\n'.join(lines[end + 1:])
181def split_sections(
182 text: str,
183 names: dict[str, str],
184 default: str) -> dict[str, str]:
185 """Divides markdown `text` into the sections marked by dividers.
187 A divider is a line with only a section name inside '<' and '>' (such as
188 '<slides>'). The text after a divider, up to the next divider, belongs to
189 that section. Text before the first divider belongs to the `default`
190 section. A closing divider (such as '</slides>') returns to the `default`
191 section. Dividers inside fenced code blocks are ignored. If a section
192 appears more than once, its parts are joined in order.
194 Args:
195 text: markdown text without front matter.
196 names: `dict` with keys that are the names that may appear in dividers
197 (in lower case) and values that are the sections they mark. This
198 allows more than one name for a section (such as 'manuscript' for
199 'text').
200 default: the section for text that is not after a divider.
202 Returns:
203 A `dict` with keys that are section names and values that are the
204 stripped text of each section. Only sections with text are included.
206 Raises:
207 ValueError: if a divider names a section that is not in `names`.
209 """
210 parts: dict[str, list[str]] = {}
211 current = default
212 for line, in_code in _iterate_lines(text):
213 match = None if in_code else _DIVIDER.match(line)
214 if match:
215 closing, name = match.groups()
216 if name.lower() not in names:
217 known = ', '.join(f'<{key}>' for key in names)
218 message = f'unknown divider "{line.strip()}" (use {known})'
219 raise ValueError(message)
220 current = default if closing else names[name.lower()]
221 parts.setdefault(current, []).append('')
222 continue
223 parts.setdefault(current, []).append(line)
224 sections = {}
225 for name, lines in parts.items():
226 section = '\n'.join(lines).strip('\n')
227 if section.strip():
228 sections[name] = section
229 return sections
232""" Private Functions """
235def _iterate_lines(text: str) -> Iterator[tuple[str, bool]]:
236 """Yields each line of `text` and whether it is in a fenced code block.
238 The opening and closing lines of a fenced code block count as being in the
239 code block.
241 Args:
242 text: markdown text.
244 Yields:
245 Each line of `text` and whether that line is part of a fenced code
246 block.
248 """
249 fence: str | None = None
250 for line in text.split('\n'):
251 match = _FENCE.match(line)
252 if fence is None and match:
253 fence = match.group(1)
254 yield line, True
255 elif fence is not None:
256 if (
257 match
258 and match.group(1)[0] == fence[0]
259 and len(match.group(1)) >= len(fence)
260 and not line.strip()[len(match.group(1)):].strip()):
261 fence = None
262 yield line, True
263 else:
264 yield line, False