mkdocs-include-markdown-plugin 7.1.3__tar.gz → 7.1.5__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (15) hide show
  1. {mkdocs_include_markdown_plugin-7.1.3 → mkdocs_include_markdown_plugin-7.1.5}/PKG-INFO +1 -1
  2. {mkdocs_include_markdown_plugin-7.1.3 → mkdocs_include_markdown_plugin-7.1.5}/pyproject.toml +1 -1
  3. {mkdocs_include_markdown_plugin-7.1.3 → mkdocs_include_markdown_plugin-7.1.5}/src/mkdocs_include_markdown_plugin/directive.py +91 -31
  4. {mkdocs_include_markdown_plugin-7.1.3 → mkdocs_include_markdown_plugin-7.1.5}/src/mkdocs_include_markdown_plugin/event.py +121 -108
  5. {mkdocs_include_markdown_plugin-7.1.3 → mkdocs_include_markdown_plugin-7.1.5}/src/mkdocs_include_markdown_plugin/process.py +65 -31
  6. {mkdocs_include_markdown_plugin-7.1.3 → mkdocs_include_markdown_plugin-7.1.5}/.gitignore +0 -0
  7. {mkdocs_include_markdown_plugin-7.1.3 → mkdocs_include_markdown_plugin-7.1.5}/LICENSE +0 -0
  8. {mkdocs_include_markdown_plugin-7.1.3 → mkdocs_include_markdown_plugin-7.1.5}/README.md +0 -0
  9. {mkdocs_include_markdown_plugin-7.1.3 → mkdocs_include_markdown_plugin-7.1.5}/src/mkdocs_include_markdown_plugin/__init__.py +0 -0
  10. {mkdocs_include_markdown_plugin-7.1.3 → mkdocs_include_markdown_plugin-7.1.5}/src/mkdocs_include_markdown_plugin/cache.py +0 -0
  11. {mkdocs_include_markdown_plugin-7.1.3 → mkdocs_include_markdown_plugin-7.1.5}/src/mkdocs_include_markdown_plugin/config.py +0 -0
  12. {mkdocs_include_markdown_plugin-7.1.3 → mkdocs_include_markdown_plugin-7.1.5}/src/mkdocs_include_markdown_plugin/files_watcher.py +0 -0
  13. {mkdocs_include_markdown_plugin-7.1.3 → mkdocs_include_markdown_plugin-7.1.5}/src/mkdocs_include_markdown_plugin/logger.py +0 -0
  14. {mkdocs_include_markdown_plugin-7.1.3 → mkdocs_include_markdown_plugin-7.1.5}/src/mkdocs_include_markdown_plugin/plugin.py +0 -0
  15. {mkdocs_include_markdown_plugin-7.1.3 → mkdocs_include_markdown_plugin-7.1.5}/src/mkdocs_include_markdown_plugin/py.typed +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mkdocs-include-markdown-plugin
3
- Version: 7.1.3
3
+ Version: 7.1.5
4
4
  Summary: Mkdocs Markdown includer plugin.
5
5
  Project-URL: Source, https://github.com/mondeja/mkdocs-include-markdown-plugin
6
6
  Project-URL: Documentation, https://github.com/mondeja/mkdocs-include-markdown-plugin#documentation
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "mkdocs-include-markdown-plugin"
3
- version = "7.1.3"
3
+ version = "7.1.5"
4
4
  description = "Mkdocs Markdown includer plugin."
5
5
  readme = "README.md"
6
6
  license = "Apache-2.0"
@@ -20,10 +20,11 @@ from mkdocs_include_markdown_plugin.logger import logger
20
20
  @dataclass
21
21
  class DirectiveBoolArgument: # noqa: D101
22
22
  value: bool
23
- regex: re.Pattern[str]
23
+ regex: Callable[[], re.Pattern[str]]
24
24
 
25
25
 
26
26
  if TYPE_CHECKING: # pragma: no cover
27
+ from collections.abc import Iterable
27
28
  from typing import Callable, Literal, TypedDict
28
29
 
29
30
  DirectiveBoolArgumentsDict = dict[str, DirectiveBoolArgument]
@@ -50,18 +51,18 @@ RE_ESCAPED_PUNCTUATION = re.escape(string.punctuation)
50
51
  DOUBLE_QUOTED_STR_RE = r'([^"]|(?<=\\)")+'
51
52
  SINGLE_QUOTED_STR_RE = r"([^']|(?<=\\)')+"
52
53
 
53
- # In the following regular expression, the substrings "$OPENING_TAG"
54
- # and "$CLOSING_TAG" will be replaced by the effective opening and
55
- # closing tags in the `on_config` plugin event.
56
- INCLUDE_TAG_RE = rf'''
57
- (?P<_includer_indent>[ \t\w\\.]*?)$OPENING_TAG
54
+ # In the following regular expression, the substrings "\{%", "%\}"
55
+ # will be replaced by custom opening and closing tags in the `on_config`
56
+ # plugin event if required.
57
+ INCLUDE_TAG_RE = r'''
58
+ (?P<_includer_indent>[ \t\w\\.]*?)\{%
58
59
  \s*
59
60
  include
60
61
  \s+
61
- (?:"(?P<double_quoted_filename>{DOUBLE_QUOTED_STR_RE})")?(?:'(?P<single_quoted_filename>{SINGLE_QUOTED_STR_RE})')?
62
+ (?:"(?P<double_quoted_filename>''' + DOUBLE_QUOTED_STR_RE + r''')")?(?:'(?P<single_quoted_filename>''' + SINGLE_QUOTED_STR_RE + r''')')?
62
63
  (?P<arguments>.*?)
63
64
  \s*
64
- $CLOSING_TAG
65
+ %\}
65
66
  ''' # noqa: E501
66
67
 
67
68
  TRUE_FALSE_STR_BOOL = {
@@ -110,6 +111,7 @@ ARGUMENT_REGEXES = {
110
111
  'heading-offset': functools.partial(arg, 'heading-offset'),
111
112
  }
112
113
 
114
+ INCLUDE_MARKDOWN_DIRECTIVE_ARGS = set(ARGUMENT_REGEXES)
113
115
  INCLUDE_DIRECTIVE_ARGS = {
114
116
  key for key in ARGUMENT_REGEXES if key not in (
115
117
  'rewrite-relative-urls', 'heading-offset', 'comments',
@@ -121,29 +123,71 @@ WARN_INVALID_DIRECTIVE_ARGS_REGEX = re.compile(
121
123
  )
122
124
 
123
125
 
126
+ def _maybe_arguments_iter(arguments_string: str) -> Iterable[str]:
127
+ """Iterate over parts of the string that look like arguments."""
128
+ current_string_opening = '' # can be either `'` or `"`
129
+ inside_string = False
130
+ escaping = False
131
+ opening_argument = False # whether we are at the beginning of an argument
132
+ current_value = ''
133
+
134
+ for c in arguments_string:
135
+ if inside_string:
136
+ if c == '\\':
137
+ escaping = not escaping
138
+ continue
139
+ elif c == current_string_opening and not escaping:
140
+ inside_string = False
141
+ current_string_opening = ''
142
+ else:
143
+ escaping = False
144
+ elif c == '=':
145
+ new_current_value = ''
146
+ for ch in reversed(current_value):
147
+ if ch in string.whitespace:
148
+ current_value = new_current_value[::-1]
149
+ break
150
+ new_current_value += ch
151
+ yield current_value
152
+ current_value = ''
153
+ opening_argument = True
154
+ elif opening_argument:
155
+ opening_argument = False
156
+ if c in ('"', "'"):
157
+ current_string_opening = c
158
+ inside_string = True
159
+ current_value += c
160
+ current_value += c
161
+ else:
162
+ current_value += c
163
+
164
+
124
165
  def warn_invalid_directive_arguments(
125
166
  arguments_string: str,
126
167
  directive_lineno: Callable[[], int],
127
168
  directive: Literal['include', 'include-markdown'],
128
169
  page_src_path: str | None,
129
170
  docs_dir: str,
130
- ) -> None:
171
+ ) -> list[str]:
131
172
  """Warns about the invalid arguments passed to a directive."""
173
+ used_arguments = []
132
174
  valid_args = (
133
- INCLUDE_DIRECTIVE_ARGS if directive == 'include'
134
- else set(ARGUMENT_REGEXES)
175
+ INCLUDE_DIRECTIVE_ARGS
176
+ if directive == 'include'
177
+ else INCLUDE_MARKDOWN_DIRECTIVE_ARGS
135
178
  )
136
- for arg_value in WARN_INVALID_DIRECTIVE_ARGS_REGEX.findall(
137
- arguments_string,
138
- ):
139
- if arg_value.split('=', 1)[0] not in valid_args:
179
+ for maybe_arg in _maybe_arguments_iter(arguments_string):
180
+ if maybe_arg not in valid_args:
140
181
  location = process.file_lineno_message(
141
182
  page_src_path, docs_dir, directive_lineno(),
142
183
  )
143
184
  logger.warning(
144
- f"Invalid argument '{arg_value}' in"
185
+ f"Invalid argument '{maybe_arg}' in"
145
186
  f" '{directive}' directive at {location}. Ignoring...",
146
187
  )
188
+ else:
189
+ used_arguments.append(maybe_arg)
190
+ return used_arguments
147
191
 
148
192
 
149
193
  def parse_filename_argument(
@@ -156,21 +200,23 @@ def parse_filename_argument(
156
200
  if raw_filename is None:
157
201
  filename = None
158
202
  else:
159
- filename = raw_filename.replace("\\'", "'")
203
+ filename = raw_filename.replace(r"\'", "'")
160
204
  else:
161
- filename = raw_filename.replace('\\"', '"')
205
+ filename = raw_filename.replace(r'\"', '"')
162
206
  return filename, raw_filename
163
207
 
164
208
 
165
- def parse_string_argument(match: re.Match[str]) -> str | None:
209
+ def parse_string_argument(match: re.Match[str] | None) -> str | None:
166
210
  """Return the string argument matched by ``match``."""
211
+ if match is None:
212
+ return None
167
213
  value = match[1]
168
214
  if value is None:
169
215
  value = match[3]
170
216
  if value is not None:
171
- value = value.replace("\\'", "'")
217
+ value = value.replace(r"\'", "'")
172
218
  else:
173
- value = value.replace('\\"', '"')
219
+ value = value.replace(r'\"', '"')
174
220
  return value
175
221
 
176
222
 
@@ -182,18 +228,31 @@ def create_include_tag(
182
228
  Replaces the substrings '$OPENING_TAG' and '$CLOSING_TAG' from
183
229
  INCLUDE_TAG_RE by the effective tag.
184
230
  """
185
- return re.compile(
186
- INCLUDE_TAG_RE.replace(' include', f' {tag}', 1).replace(
187
- '$OPENING_TAG', re.escape(opening_tag), 1,
188
- ).replace('$CLOSING_TAG', re.escape(closing_tag), 1),
189
- flags=re.VERBOSE | re.DOTALL,
190
- )
231
+ pattern = INCLUDE_TAG_RE
232
+ if tag != 'include':
233
+ pattern = pattern.replace(
234
+ ' include',
235
+ (
236
+ ' include-markdown' if tag == 'include-markdown'
237
+ else f' {re.escape(tag)}'
238
+ ),
239
+ 1,
240
+ )
241
+
242
+ if opening_tag != '{%':
243
+ pattern = pattern.replace(r'\{%', re.escape(opening_tag), 1)
244
+
245
+ if closing_tag != '%}':
246
+ pattern = pattern.replace(r'%\}', re.escape(closing_tag), 1)
247
+
248
+ return re.compile(pattern, flags=re.VERBOSE | re.DOTALL)
191
249
 
192
250
 
193
251
  def parse_bool_options(
194
252
  option_names: list[str],
195
253
  defaults: DefaultValues,
196
254
  arguments_string: str,
255
+ used_arguments: list[str],
197
256
  ) -> tuple[DirectiveBoolArgumentsDict, list[str]]:
198
257
  """Parse boolean options from arguments string."""
199
258
  invalid_args: list[str] = []
@@ -202,16 +261,17 @@ def parse_bool_options(
202
261
  for option_name in option_names:
203
262
  bool_options[option_name] = DirectiveBoolArgument(
204
263
  value=defaults[option_name], # type: ignore
205
- regex=ARGUMENT_REGEXES[option_name](),
264
+ regex=ARGUMENT_REGEXES[option_name],
206
265
  )
207
266
 
208
267
  for arg_name, arg in bool_options.items():
209
- bool_arg_match = arg.regex.search(arguments_string)
210
- if bool_arg_match is None:
268
+ if arg_name not in used_arguments:
211
269
  continue
270
+ bool_arg_match = arg.regex().search(arguments_string)
212
271
  try:
213
272
  bool_options[arg_name].value = TRUE_FALSE_STR_BOOL[
214
- bool_arg_match[1] or TRUE_FALSE_BOOL_STR[arg.value]
273
+ (bool_arg_match and bool_arg_match[1])
274
+ or TRUE_FALSE_BOOL_STR[arg.value]
215
275
  ]
216
276
  except KeyError:
217
277
  invalid_args.append(arg_name)
@@ -125,7 +125,7 @@ def get_file_content( # noqa: PLR0913, PLR0915
125
125
 
126
126
  arguments_string = match['arguments']
127
127
 
128
- warn_invalid_directive_arguments(
128
+ used_arguments = warn_invalid_directive_arguments(
129
129
  arguments_string,
130
130
  directive_lineno,
131
131
  'include',
@@ -133,9 +133,11 @@ def get_file_content( # noqa: PLR0913, PLR0915
133
133
  docs_dir,
134
134
  )
135
135
 
136
- exclude_match = ARGUMENT_REGEXES['exclude']().search(arguments_string)
137
136
  ignore_paths = [*settings_ignore_paths]
138
- if exclude_match is not None:
137
+ if 'exclude' in used_arguments:
138
+ exclude_match = ARGUMENT_REGEXES['exclude']().search(
139
+ arguments_string,
140
+ )
139
141
  exclude_string = parse_string_argument(exclude_match)
140
142
  if exclude_string is None:
141
143
  location = process.file_lineno_message(
@@ -169,38 +171,22 @@ def get_file_content( # noqa: PLR0913, PLR0915
169
171
  if files_watcher is not None and not is_url:
170
172
  files_watcher.included_files.extend(file_paths_to_include)
171
173
 
172
- bool_options, invalid_bool_args = parse_bool_options(
173
- ['preserve-includer-indent', 'dedent',
174
- 'trailing-newlines', 'recursive'],
175
- defaults,
176
- arguments_string,
177
- )
178
- if invalid_bool_args:
179
- location = process.file_lineno_message(
180
- page_src_path, docs_dir, directive_lineno(),
181
- )
182
- raise PluginError(
183
- f"Invalid value for '{invalid_bool_args[0]}' argument of"
184
- f" 'include' directive at {location}."
185
- f' Possible values are true or false.',
186
- )
187
-
188
- start_match = ARGUMENT_REGEXES['start']().search(arguments_string)
189
- if start_match:
174
+ start = defaults['start']
175
+ if 'start' in used_arguments:
176
+ start_match = ARGUMENT_REGEXES['start']().search(arguments_string)
190
177
  start = parse_string_argument(start_match)
191
178
  if start is None:
192
179
  location = process.file_lineno_message(
193
180
  page_src_path, docs_dir, directive_lineno(),
194
181
  )
195
182
  raise PluginError(
196
- "Invalid empty 'start' argument in 'include' directive at"
197
- f' {location}',
183
+ "Invalid empty 'start' argument in 'include' directive"
184
+ f' at {location}',
198
185
  )
199
- else:
200
- start = defaults['start']
201
186
 
202
- end_match = ARGUMENT_REGEXES['end']().search(arguments_string)
203
- if end_match:
187
+ end = defaults['end']
188
+ if 'end' in used_arguments:
189
+ end_match = ARGUMENT_REGEXES['end']().search(arguments_string)
204
190
  end = parse_string_argument(end_match)
205
191
  if end is None:
206
192
  location = process.file_lineno_message(
@@ -210,14 +196,14 @@ def get_file_content( # noqa: PLR0913, PLR0915
210
196
  "Invalid empty 'end' argument in 'include' directive at"
211
197
  f' {location}',
212
198
  )
213
- else:
214
- end = defaults['end']
215
199
 
216
- encoding_match = ARGUMENT_REGEXES['encoding']().search(
217
- arguments_string)
218
- if encoding_match:
219
- encoding = parse_string_argument(encoding_match)
220
- if encoding is None:
200
+ encoding = defaults['encoding']
201
+ if 'encoding' in used_arguments:
202
+ encoding_match = ARGUMENT_REGEXES['encoding']().search(
203
+ arguments_string,
204
+ )
205
+ encoding_ = parse_string_argument(encoding_match)
206
+ if encoding_ is None:
221
207
  location = process.file_lineno_message(
222
208
  page_src_path, docs_dir, directive_lineno(),
223
209
  )
@@ -225,8 +211,28 @@ def get_file_content( # noqa: PLR0913, PLR0915
225
211
  "Invalid empty 'encoding' argument in 'include'"
226
212
  f' directive at {location}',
227
213
  )
228
- else:
229
- encoding = defaults['encoding']
214
+ encoding = encoding_
215
+
216
+ bool_options, invalid_bool_args = parse_bool_options(
217
+ [
218
+ 'preserve-includer-indent',
219
+ 'dedent',
220
+ 'trailing-newlines',
221
+ 'recursive',
222
+ ],
223
+ defaults,
224
+ arguments_string,
225
+ used_arguments,
226
+ )
227
+ if invalid_bool_args:
228
+ location = process.file_lineno_message(
229
+ page_src_path, docs_dir, directive_lineno(),
230
+ )
231
+ raise PluginError(
232
+ f"Invalid value for '{invalid_bool_args[0]}' argument of"
233
+ f" 'include' directive at {location}."
234
+ f' Possible values are true or false.',
235
+ )
230
236
 
231
237
  text_to_include = ''
232
238
  expected_but_any_found = [start is not None, end is not None]
@@ -322,7 +328,7 @@ def get_file_content( # noqa: PLR0913, PLR0915
322
328
  )
323
329
 
324
330
  includer_indent = match['_includer_indent']
325
- empty_includer_indent = ' ' * len(includer_indent)
331
+ filled_includer_indent = ' ' * len(includer_indent)
326
332
 
327
333
  filename, raw_filename = parse_filename_argument(match)
328
334
  if filename is None:
@@ -336,7 +342,7 @@ def get_file_content( # noqa: PLR0913, PLR0915
336
342
 
337
343
  arguments_string = match['arguments']
338
344
 
339
- warn_invalid_directive_arguments(
345
+ used_arguments = warn_invalid_directive_arguments(
340
346
  arguments_string,
341
347
  directive_lineno,
342
348
  'include-markdown',
@@ -344,9 +350,11 @@ def get_file_content( # noqa: PLR0913, PLR0915
344
350
  docs_dir,
345
351
  )
346
352
 
347
- exclude_match = ARGUMENT_REGEXES['exclude']().search(arguments_string)
348
353
  ignore_paths = [*settings_ignore_paths]
349
- if exclude_match is not None:
354
+ if 'exclude' in used_arguments:
355
+ exclude_match = ARGUMENT_REGEXES['exclude']().search(
356
+ arguments_string,
357
+ )
350
358
  exclude_string = parse_string_argument(exclude_match)
351
359
  if exclude_string is None:
352
360
  location = process.file_lineno_message(
@@ -379,42 +387,23 @@ def get_file_content( # noqa: PLR0913, PLR0915
379
387
  if files_watcher is not None and not is_url:
380
388
  files_watcher.included_files.extend(file_paths_to_include)
381
389
 
382
- bool_options, invalid_bool_args = parse_bool_options(
383
- [
384
- 'rewrite-relative-urls', 'comments',
385
- 'preserve-includer-indent', 'dedent',
386
- 'trailing-newlines', 'recursive',
387
- ],
388
- defaults,
389
- arguments_string,
390
- )
391
- if invalid_bool_args:
392
- location = process.file_lineno_message(
393
- page_src_path, docs_dir, directive_lineno(),
394
- )
395
- raise PluginError(
396
- f"Invalid value for '{invalid_bool_args[0]}' argument of"
397
- " 'include-markdown' directive at"
398
- f' {location}. Possible values are true or false.',
399
- )
400
-
401
390
  # start and end arguments
402
- start_match = ARGUMENT_REGEXES['start']().search(arguments_string)
403
- if start_match:
391
+ start = defaults['start']
392
+ if 'start' in used_arguments:
393
+ start_match = ARGUMENT_REGEXES['start']().search(arguments_string)
404
394
  start = parse_string_argument(start_match)
405
395
  if start is None:
406
396
  location = process.file_lineno_message(
407
397
  page_src_path, docs_dir, directive_lineno(),
408
398
  )
409
399
  raise PluginError(
410
- "Invalid empty 'start' argument in 'include-markdown'"
411
- f' directive at {location}',
400
+ "Invalid empty 'start' argument in"
401
+ f" 'include-markdown' directive at {location}",
412
402
  )
413
- else:
414
- start = defaults['start']
415
403
 
416
- end_match = ARGUMENT_REGEXES['end']().search(arguments_string)
417
- if end_match:
404
+ end = defaults['end']
405
+ if 'end' in used_arguments:
406
+ end_match = ARGUMENT_REGEXES['end']().search(arguments_string)
418
407
  end = parse_string_argument(end_match)
419
408
  if end is None:
420
409
  location = process.file_lineno_message(
@@ -424,50 +413,73 @@ def get_file_content( # noqa: PLR0913, PLR0915
424
413
  "Invalid empty 'end' argument in 'include-markdown'"
425
414
  f' directive at {location}',
426
415
  )
427
- else:
428
- end = defaults['end']
429
416
 
430
- encoding_match = ARGUMENT_REGEXES['encoding']().search(
431
- arguments_string)
432
- if encoding_match:
433
- encoding = parse_string_argument(encoding_match)
434
- if encoding is None:
417
+ encoding = defaults['encoding']
418
+ if 'encoding' in used_arguments:
419
+ encoding_match = ARGUMENT_REGEXES['encoding']().search(
420
+ arguments_string,
421
+ )
422
+ encoding_ = parse_string_argument(encoding_match)
423
+ if encoding_ is None:
435
424
  location = process.file_lineno_message(
436
425
  page_src_path, docs_dir, directive_lineno(),
437
426
  )
438
427
  raise PluginError(
439
- "Invalid empty 'encoding' argument in 'include-markdown'"
440
- f' directive at {location}',
428
+ "Invalid empty 'encoding' argument in"
429
+ f" 'include-markdown' directive at {location}",
441
430
  )
442
- else:
443
- encoding = defaults['encoding']
431
+ encoding = encoding_
444
432
 
445
433
  # heading offset
446
- offset_match = ARGUMENT_REGEXES['heading-offset']().search(
434
+ offset = defaults['heading-offset']
435
+ if 'heading-offset' in used_arguments:
436
+ offset_match = ARGUMENT_REGEXES['heading-offset']().search(
437
+ arguments_string,
438
+ )
439
+ if offset_match:
440
+ offset_raw_value = offset_match[1]
441
+ if offset_raw_value == '':
442
+ location = process.file_lineno_message(
443
+ page_src_path, docs_dir, directive_lineno(),
444
+ )
445
+ raise PluginError(
446
+ "Invalid empty 'heading-offset' argument in"
447
+ f" 'include-markdown' directive at {location}",
448
+ )
449
+ try:
450
+ offset = int(offset_raw_value)
451
+ except ValueError:
452
+ location = process.file_lineno_message(
453
+ page_src_path, docs_dir, directive_lineno(),
454
+ )
455
+ raise PluginError(
456
+ f"Invalid 'heading-offset' argument"
457
+ f" '{offset_raw_value}' in 'include-markdown'"
458
+ f" directive at {location}",
459
+ ) from None
460
+
461
+ bool_options, invalid_bool_args = parse_bool_options(
462
+ [
463
+ 'rewrite-relative-urls',
464
+ 'comments',
465
+ 'preserve-includer-indent',
466
+ 'dedent',
467
+ 'trailing-newlines',
468
+ 'recursive',
469
+ ],
470
+ defaults,
447
471
  arguments_string,
472
+ used_arguments,
448
473
  )
449
- if offset_match:
450
- offset_raw_value = offset_match[1]
451
- if offset_raw_value == '':
452
- location = process.file_lineno_message(
453
- page_src_path, docs_dir, directive_lineno(),
454
- )
455
- raise PluginError(
456
- "Invalid empty 'heading-offset' argument in"
457
- f" 'include-markdown' directive at {location}",
458
- )
459
- try:
460
- offset = int(offset_raw_value)
461
- except ValueError:
462
- location = process.file_lineno_message(
463
- page_src_path, docs_dir, directive_lineno(),
464
- )
465
- raise PluginError(
466
- f"Invalid 'heading-offset' argument \"{offset_raw_value}\""
467
- f" in 'include-markdown' directive at {location}",
468
- ) from None
469
- else:
470
- offset = defaults['heading-offset']
474
+ if invalid_bool_args:
475
+ location = process.file_lineno_message(
476
+ page_src_path, docs_dir, directive_lineno(),
477
+ )
478
+ raise PluginError(
479
+ f"Invalid value for '{invalid_bool_args[0]}' argument of"
480
+ " 'include-markdown' directive at"
481
+ f' {location}. Possible values are true or false.',
482
+ )
471
483
 
472
484
  separator = '\n' if bool_options['trailing-newlines'].value else ''
473
485
  if not start and not end:
@@ -555,14 +567,15 @@ def get_file_content( # noqa: PLR0913, PLR0915
555
567
  new_text_to_include = textwrap.dedent(new_text_to_include)
556
568
 
557
569
  # includer indentation preservation
558
- if bool_options['preserve-includer-indent'].value:
559
- new_text_to_include = ''.join(
560
- (empty_includer_indent if i > 0 else '') + line
561
- for i, line in enumerate(
562
- new_text_to_include.splitlines(keepends=True)
563
- or [''],
570
+ if bool_options['preserve-includer-indent'].value and (
571
+ new_text_to_include
572
+ ):
573
+ lines = new_text_to_include.splitlines(keepends=True)
574
+ new_text_to_include = lines[0]
575
+ for i in range(1, len(lines)):
576
+ new_text_to_include += (
577
+ filled_includer_indent + lines[i]
564
578
  )
565
- )
566
579
 
567
580
  if offset:
568
581
  new_text_to_include = process.increase_headings_offset(
@@ -249,9 +249,10 @@ def transform_line_by_line_skipping_codeblocks(
249
249
  markdown: str,
250
250
  func: Callable[[str], str],
251
251
  ) -> str:
252
- """Apply a transformation line by line in a Markdown text using a function.
252
+ """Apply a transformation line by line in a Markdown text using a function,.
253
253
 
254
- Skip fenced codeblock lines, where the transformation never is applied.
254
+ Skip fenced codeblock lines and empty lines, where the transformation
255
+ is never applied.
255
256
 
256
257
  Indented codeblocks are not taken into account because in the practice
257
258
  this function is only used for transformations of heading prefixes. See
@@ -263,13 +264,15 @@ def transform_line_by_line_skipping_codeblocks(
263
264
 
264
265
  lines = []
265
266
  for line in io.StringIO(markdown):
267
+ lstripped_line = line.lstrip()
266
268
  if not _current_fcodeblock_delimiter:
267
- lstripped_line = line.lstrip()
268
- if lstripped_line.startswith(('```', '~~~')):
269
- _current_fcodeblock_delimiter = lstripped_line[:3]
269
+ if lstripped_line.startswith('```'):
270
+ _current_fcodeblock_delimiter = '```'
271
+ elif lstripped_line.startswith('~~~'):
272
+ _current_fcodeblock_delimiter = '~~~'
270
273
  else:
271
274
  line = func(line) # noqa: PLW2901
272
- elif line.lstrip().startswith(_current_fcodeblock_delimiter):
275
+ elif lstripped_line.startswith(_current_fcodeblock_delimiter):
273
276
  _current_fcodeblock_delimiter = ''
274
277
  lines.append(line)
275
278
 
@@ -287,20 +290,12 @@ def rewrite_relative_urls(
287
290
  ``source_path`` will still work when inserted into a file at
288
291
  ``destination_path``.
289
292
  """
290
- from urllib.parse import urlparse, urlunparse
291
-
292
293
  def rewrite_url(url: str) -> str:
293
- if is_url(url):
294
- return url
295
-
296
- scheme, netloc, path, params, query, fragment = urlparse(url)
297
-
298
- # absolute or mail
299
- if path.startswith('/') or scheme == 'mailto':
294
+ if is_url(url) or is_absolute_path(url):
300
295
  return url
301
296
 
302
297
  new_path = os.path.relpath(
303
- os.path.join(os.path.dirname(source_path), path),
298
+ os.path.join(os.path.dirname(source_path), url),
304
299
  os.path.dirname(destination_path),
305
300
  )
306
301
 
@@ -308,14 +303,14 @@ def rewrite_relative_urls(
308
303
  new_path = new_path.replace('\\', '/').replace('//', '/')
309
304
 
310
305
  try:
311
- if path[-1] == '/':
306
+ if url[-1] == '/':
312
307
  # the above operation removes a trailing slash,
313
308
  # so add it back if it was present in the input
314
309
  new_path += '/'
315
310
  except IndexError: # pragma: no cover
316
311
  pass
317
312
 
318
- return urlunparse((scheme, netloc, new_path, params, query, fragment))
313
+ return new_path
319
314
 
320
315
  def found_href(m: re.Match[str], url_group_index: int = -1) -> str:
321
316
  match_start, match_end = m.span(0)
@@ -524,27 +519,68 @@ def filter_paths(
524
519
  return response
525
520
 
526
521
 
522
+ def _is_valid_url_scheme_char(c: str) -> bool:
523
+ """Determine is a character is a valid URL scheme character.
524
+
525
+ Valid characters are:
526
+
527
+ ```
528
+ abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789+-.
529
+ ```
530
+ """
531
+ codepoint = ord(c)
532
+ A = 65
533
+ Z = 90
534
+ a = 97
535
+ z = 122
536
+ zero = 48
537
+ nine = 57
538
+ dot = 46
539
+ plus = 43
540
+ minus = 45
541
+ return (
542
+ A <= codepoint <= Z
543
+ or a <= codepoint <= z
544
+ or zero <= codepoint <= nine
545
+ or codepoint in (plus, minus, dot)
546
+ )
547
+
548
+
527
549
  def is_url(string: str) -> bool:
528
- """Determine if a string is an URL."""
529
- if ':' not in string: # fast path
550
+ """Determine if a string is an URL.
551
+
552
+ The implementation has been adapted from `urllib.urlparse`.
553
+ """
554
+ i = string.find(':')
555
+ if i <= 1: # noqa: PLR2004 -> exclude C: or D: on Windows
530
556
  return False
531
- from urllib.parse import urlparse
532
557
 
533
558
  try:
534
- result = urlparse(string)
535
- return all([result.scheme, result.netloc])
536
- except ValueError: # pragma: no cover
559
+ return all(_is_valid_url_scheme_char(string[j]) for j in range(i))
560
+ except (IndexError, ValueError): # pragma: no cover
537
561
  return False
538
562
 
539
563
 
540
564
  def is_relative_path(string: str) -> bool:
541
565
  """Check if a string looks like a relative path."""
542
- return string.startswith(('./', '../'))
566
+ try:
567
+ return (
568
+ string[0] == '.'
569
+ and (
570
+ string[1] == '/'
571
+ or (string[1] == '.' and string[2] == '/')
572
+ )
573
+ )
574
+ except IndexError: # pragma: no cover
575
+ return False
543
576
 
544
577
 
545
578
  def is_absolute_path(string: str) -> bool:
546
579
  """Check if a string looks like an absolute path."""
547
- return string.startswith((os.sep, '/'))
580
+ try:
581
+ return string[0] == '/' or string[0] == os.sep
582
+ except IndexError: # pragma: no cover
583
+ return False
548
584
 
549
585
 
550
586
  def read_file(file_path: str, encoding: str) -> str:
@@ -577,14 +613,12 @@ def read_url(
577
613
  def safe_os_path_relpath(path: str, start: str) -> str:
578
614
  """Return the relative path of a file from a start directory.
579
615
 
580
- Safe version of `os.path.relpath` that catches `ValueError` exceptions
581
- on Windows and returns the original path in case of error.
616
+ Safe version of `os.path.relpath` that catches possible `ValueError`
617
+ exceptions and returns the original path in case of error.
582
618
  On Windows, `ValueError` is raised when `path` and `start` are on
583
619
  different drives.
584
620
  """
585
- if os.name != 'nt': # pragma: nt no cover
586
- return os.path.relpath(path, start)
587
- try: # pragma: nt cover
621
+ try:
588
622
  return os.path.relpath(path, start)
589
623
  except ValueError: # pragma: no cover
590
624
  return path