mkdocs_include_markdown_plugin-7.2.0/src/mkdocs_include_markdown_plugin/__init__.py0000644000000000000000000000030113615410400026040 0ustar00"""Mkdocs Markdown plugin to include files.""" from __future__ import annotations __all__ = ['IncludeMarkdownPlugin'] from mkdocs_include_markdown_plugin.plugin import IncludeMarkdownPlugin mkdocs_include_markdown_plugin-7.2.0/src/mkdocs_include_markdown_plugin/cache.py0000644000000000000000000000737413615410400025365 0ustar00"""Persistent file cache.""" from __future__ import annotations import hashlib import os import stat import time from importlib.util import find_spec class Cache: """Cache for arbitrary content, one file per entry.""" def __init__( # noqa: D107 self, cache_dir: str, expiration_seconds: int = 0, ): self.cache_dir = cache_dir self.expiration_seconds = expiration_seconds def get_creation_time_from_fpath(self, fpath: str) -> int: """Get creation time of an entry in the cache given its path.""" with open(fpath, 'rb') as f: return int(f.readline()) @classmethod def generate_unique_key_from_url(cls, url: str) -> str: """Generate a unique key from an URL.""" return hashlib.blake2b(url.encode(), digest_size=16).digest().hex() def read_file(self, fpath: str, encoding: str = 'utf-8') -> str: # noqa: D102 f = open(fpath, encoding=encoding) # noqa: SIM115 content = f.read().split('\n', 1)[1] f.close() return content def get_(self, url: str, encoding: str = 'utf-8') -> str | None: # noqa: D102 key = self.generate_unique_key_from_url(url) fpath = os.path.join(self.cache_dir, key) try: is_file = stat.S_ISREG(os.stat(fpath).st_mode) except (FileNotFoundError, OSError): # pragma: no cover return None if is_file: # pragma: no branch creation_time = self.get_creation_time_from_fpath(fpath) if time.time() < creation_time + self.expiration_seconds: return self.read_file(fpath, encoding=encoding) os.remove(fpath) return None def set_(self, url: str, value: str, encoding: str = 'utf-8') -> None: # noqa: D102 key = self.generate_unique_key_from_url(url) fpath = os.path.join(self.cache_dir, key) with open(fpath, 'wb') as fp: now = f'{int(time.time())}\n' fp.write(now.encode(encoding)) fp.write(value.encode(encoding)) def clean(self) -> None: """Clean expired entries from the cache.""" for fname in os.listdir(self.cache_dir): if fname == '.gitignore': continue fpath = os.path.join(self.cache_dir, fname) creation_time = self.get_creation_time_from_fpath(fpath) if time.time() > creation_time + self.expiration_seconds: os.remove(fpath) def get_cache_directory(cache_dir: str) -> str | None: """Get cache directory.""" if cache_dir: return cache_dir if not is_platformdirs_installed(): return None try: from platformdirs import user_data_dir # noqa: PLC0415 except ImportError: # pragma: no cover return None else: return user_data_dir('mkdocs-include-markdown-plugin') def initialize_cache(expiration_seconds: int, cache_dir: str) -> Cache | None: """Initialize a cache instance.""" cache_directory = get_cache_directory(cache_dir) if cache_directory is None: return None os.makedirs(cache_directory, exist_ok=True) # Add a `.gitignore` file to prevent the cache directory from being # included in the repository. This is needed because the cache directory # can be configured as a relative path with `cache_dir` setting. gitignore = os.path.join(cache_directory, '.gitignore') if not os.path.exists(gitignore): with open(gitignore, 'wb') as f: f.write(b'*\n') cache = Cache(cache_directory, expiration_seconds) cache.clean() return cache def is_platformdirs_installed() -> bool: """Check if `platformdirs` package is installed without importing it.""" return find_spec('platformdirs') is not None mkdocs_include_markdown_plugin-7.2.0/src/mkdocs_include_markdown_plugin/config.py0000644000000000000000000000216113615410400025554 0ustar00"""Plugin configuration.""" from __future__ import annotations from mkdocs.config.base import Config from mkdocs.config.config_options import ( ListOfItems, Optional, Type as MkType, ) class PluginConfig(Config): # noqa: D101 opening_tag = MkType(str, default='{%') closing_tag = MkType(str, default='%}') encoding = MkType(str, default='utf-8') preserve_includer_indent = MkType(bool, default=True) dedent = MkType(bool, default=False) trailing_newlines = MkType(bool, default=True) comments = MkType(bool, default=False) rewrite_relative_urls = MkType(bool, default=True) heading_offset = MkType(int, default=0) order = MkType(str, default='alpha-path') start = Optional(MkType(str)) end = Optional(MkType(str)) exclude = ListOfItems(MkType(str), default=[]) cache = MkType(int, default=0) cache_dir = MkType(str, default='') recursive = MkType(bool, default=True) directives = MkType( dict, default={ '__default': '', 'include': 'include', 'include-markdown': 'include-markdown', }, ) mkdocs_include_markdown_plugin-7.2.0/src/mkdocs_include_markdown_plugin/directive.py0000644000000000000000000003450313615410400026272 0ustar00"""Utilities related with the syntax of directives.""" from __future__ import annotations import functools import os import re import stat import string from dataclasses import dataclass from typing import TYPE_CHECKING from mkdocs.exceptions import PluginError from wcmatch import glob from mkdocs_include_markdown_plugin.logger import logger from mkdocs_include_markdown_plugin.process import ( file_lineno_message, filter_paths, is_absolute_path, is_relative_path, is_url, sort_paths, ) @dataclass class DirectiveBoolArgument: # noqa: D101 value: bool regex: Callable[[], re.Pattern[str]] if TYPE_CHECKING: # pragma: no cover from collections.abc import Iterable from typing import Callable, Literal, TypedDict DirectiveBoolArgumentsDict = dict[str, DirectiveBoolArgument] OrderOption = tuple[bool, str, str] DefaultValues = TypedDict( 'DefaultValues', { 'encoding': str, 'preserve-includer-indent': bool, 'dedent': bool, 'trailing-newlines': bool, 'comments': bool, 'rewrite-relative-urls': bool, 'heading-offset': int, 'recursive': bool, 'start': str | None, 'end': str | None, 'order': str, }, ) GLOB_FLAGS = glob.NEGATE | glob.EXTGLOB | glob.GLOBSTAR | glob.BRACE RE_ESCAPED_PUNCTUATION = re.escape(string.punctuation) DOUBLE_QUOTED_STR_RE = r'([^"]|(?<=\\)")+' SINGLE_QUOTED_STR_RE = r"([^']|(?<=\\)')+" # In the following regular expression, the substrings "\{%", "%\}" # will be replaced by custom opening and closing tags in the `on_config` # plugin event if required. INCLUDE_TAG_RE = r''' (?P<_includer_indent>[ \t\w\\.]*?)\{% \s* include \s+ (?:"(?P''' + DOUBLE_QUOTED_STR_RE + r''')")?(?:'(?P''' + SINGLE_QUOTED_STR_RE + r''')')? (?P.*?) \s* %\} ''' # noqa: E501 TRUE_FALSE_STR_BOOL = { 'true': True, 'false': False, } TRUE_FALSE_BOOL_STR = { True: 'true', False: 'false', } @functools.lru_cache def arg(arg: str) -> re.Pattern[str]: """Return a compiled regexp to match a boolean argument.""" return re.compile(rf'{arg}=([{RE_ESCAPED_PUNCTUATION}\w]*)') @functools.lru_cache def str_arg(arg: str) -> re.Pattern[str]: """Return a compiled regexp to match a string argument.""" return re.compile( rf'{arg}=(?:"({DOUBLE_QUOTED_STR_RE})")?' rf"(?:'({SINGLE_QUOTED_STR_RE})')?", ) ARGUMENT_REGEXES = { # str 'start': functools.partial(str_arg, 'start'), 'end': functools.partial(str_arg, 'end'), 'exclude': functools.partial(str_arg, 'exclude'), 'encoding': functools.partial(str_arg, 'encoding'), 'order': functools.partial(str_arg, 'order'), # bool 'comments': functools.partial(arg, 'comments'), 'preserve-includer-indent': functools.partial( arg, 'preserve-includer-indent', ), 'dedent': functools.partial(arg, 'dedent'), 'trailing-newlines': functools.partial(arg, 'trailing-newlines'), 'rewrite-relative-urls': functools.partial(arg, 'rewrite-relative-urls'), 'recursive': functools.partial(arg, 'recursive'), # int 'heading-offset': functools.partial(arg, 'heading-offset'), } INCLUDE_MARKDOWN_DIRECTIVE_ARGS = set(ARGUMENT_REGEXES) INCLUDE_DIRECTIVE_ARGS = { key for key in ARGUMENT_REGEXES if key not in ( 'rewrite-relative-urls', 'heading-offset', 'comments', ) } WARN_INVALID_DIRECTIVE_ARGS_REGEX = re.compile( rf'[\w-]*=[{RE_ESCAPED_PUNCTUATION}\w]*', ) def _maybe_arguments_iter(arguments_string: str) -> Iterable[str]: """Iterate over parts of the string that look like arguments.""" current_string_opening = '' # can be either `'` or `"` inside_string = False escaping = False opening_argument = False # whether we are at the beginning of an argument current_value = '' for c in arguments_string: if inside_string: if c == '\\': escaping = not escaping continue elif c == current_string_opening and not escaping: inside_string = False current_string_opening = '' else: escaping = False elif c == '=': new_current_value = '' for ch in reversed(current_value): if ch in string.whitespace: current_value = new_current_value[::-1] break new_current_value += ch yield current_value current_value = '' opening_argument = True elif opening_argument: opening_argument = False if c in ('"', "'"): current_string_opening = c inside_string = True current_value += c current_value += c else: current_value += c def warn_invalid_directive_arguments( arguments_string: str, directive_lineno: Callable[[], int], directive: Literal['include', 'include-markdown'], page_src_path: str | None, docs_dir: str, ) -> list[str]: """Warns about the invalid arguments passed to a directive.""" used_arguments = [] valid_args = ( INCLUDE_DIRECTIVE_ARGS if directive == 'include' else INCLUDE_MARKDOWN_DIRECTIVE_ARGS ) for maybe_arg in _maybe_arguments_iter(arguments_string): if maybe_arg not in valid_args: location = file_lineno_message( page_src_path, docs_dir, directive_lineno(), ) logger.warning( f"Invalid argument '{maybe_arg}' in" f" '{directive}' directive at {location}. Ignoring...", ) else: used_arguments.append(maybe_arg) return used_arguments def parse_filename_argument( match: re.Match[str], ) -> tuple[str | None, str | None]: """Return filename argument matched by ``match``.""" raw_filename = match['double_quoted_filename'] if raw_filename is None: raw_filename = match['single_quoted_filename'] if raw_filename is None: filename = None else: filename = raw_filename.replace(r"\'", "'") else: filename = raw_filename.replace(r'\"', '"') return filename, raw_filename def parse_string_argument(match: re.Match[str] | None) -> str | None: """Return the string argument matched by ``match``.""" if match is None: # pragma: no cover return None value = match[1] if value is None: value = match[3] if value is not None: value = value.replace(r"\'", "'") else: value = value.replace(r'\"', '"') return value def create_include_tag( opening_tag: str, closing_tag: str, tag: str, ) -> re.Pattern[str]: """Create a regex pattern to match an inclusion tag directive. Replaces the substrings '$OPENING_TAG' and '$CLOSING_TAG' from INCLUDE_TAG_RE by the effective tag. """ pattern = INCLUDE_TAG_RE if tag != 'include': pattern = pattern.replace( ' include', ( ' include-markdown' if tag == 'include-markdown' else f' {re.escape(tag)}' ), 1, ) if opening_tag != '{%': pattern = pattern.replace(r'\{%', re.escape(opening_tag), 1) if closing_tag != '%}': pattern = pattern.replace(r'%\}', re.escape(closing_tag), 1) return re.compile(pattern, flags=re.VERBOSE | re.DOTALL) def parse_bool_options( option_names: list[str], defaults: DefaultValues, arguments_string: str, used_arguments: list[str], ) -> tuple[DirectiveBoolArgumentsDict, list[str]]: """Parse boolean options from arguments string.""" invalid_args: list[str] = [] bool_options: dict[str, DirectiveBoolArgument] = {} for option_name in option_names: bool_options[option_name] = DirectiveBoolArgument( value=defaults[option_name], # type: ignore regex=ARGUMENT_REGEXES[option_name], ) for arg_name, arg in bool_options.items(): if arg_name not in used_arguments: continue bool_arg_match = arg.regex().search(arguments_string) try: bool_options[arg_name].value = TRUE_FALSE_STR_BOOL[ (bool_arg_match and bool_arg_match[1]) or TRUE_FALSE_BOOL_STR[arg.value] ] except KeyError: invalid_args.append(arg_name) return bool_options, invalid_args def resolve_file_paths_to_include( # noqa: PLR0912 include_string: str, includer_page_src_path: str | None, docs_dir: str, ignore_paths: list[str], order: str, ) -> tuple[list[str], bool]: """Resolve the file paths to include for a directive.""" if is_url(include_string): return [include_string], True if is_absolute_path(include_string): if os.name == 'nt': # pragma: no cover # Windows fpath = os.path.normpath(include_string) try: is_file = stat.S_ISREG(os.stat(fpath).st_mode) except (FileNotFoundError, OSError): is_file = False if not is_file: return [], False paths = filter_paths([fpath], ignore_paths) is_url_ = False return sort_paths(paths, parse_order_option(order)), is_url_ try: is_file = stat.S_ISREG(os.stat(include_string).st_mode) except (FileNotFoundError, OSError): is_file = False paths = filter_paths( [include_string] if is_file else glob.iglob( include_string, flags=GLOB_FLAGS, ), ignore_paths, ) is_url_ = False sort_paths(paths, parse_order_option(order)) return paths, is_url_ if is_relative_path(include_string): if includer_page_src_path is None: # pragma: no cover raise PluginError( 'Relative paths are not allowed when the includer page' ' source path is not provided. The include string' f" '{include_string}' is located inside a generated page.", ) root_dir = os.path.abspath( os.path.dirname(includer_page_src_path), ) paths = [] include_path = os.path.join(root_dir, include_string) try: is_file = stat.S_ISREG(os.stat(include_path).st_mode) except (FileNotFoundError, OSError): is_file = False if is_file: paths.append(include_path) else: for fp in glob.iglob( include_string, flags=GLOB_FLAGS, root_dir=root_dir, ): paths.append(os.path.join(root_dir, fp)) paths = filter_paths(paths, ignore_paths) is_url_ = False sort_paths(paths, parse_order_option(order)) return paths, is_url_ # relative to docs_dir paths = [] root_dir = docs_dir include_path = os.path.join(root_dir, include_string) try: is_file = stat.S_ISREG(os.stat(include_path).st_mode) except (FileNotFoundError, OSError): is_file = False if is_file: paths.append(include_path) else: for fp in glob.iglob( include_string, flags=GLOB_FLAGS, root_dir=root_dir, ): paths.append(os.path.join(root_dir, fp)) paths = filter_paths(paths, ignore_paths) is_url_ = False sort_paths(paths, parse_order_option(order)) return paths, is_url_ def resolve_file_paths_to_exclude( exclude_string: str, includer_page_src_path: str | None, docs_dir: str, ) -> list[str]: """Resolve the file paths to exclude for a directive.""" if is_absolute_path(exclude_string): return glob.glob(exclude_string, flags=GLOB_FLAGS) if is_relative_path(exclude_string): if includer_page_src_path is None: # pragma: no cover raise PluginError( 'Relative paths are not allowed when the includer page' ' source path is not provided. The exclude string' f" '{exclude_string}' is located inside a generated page.", ) root_dir = os.path.abspath( os.path.dirname(includer_page_src_path), ) return [ os.path.normpath( os.path.join(root_dir, fp), ) for fp in glob.glob( exclude_string, flags=GLOB_FLAGS, root_dir=root_dir, ) ] return glob.glob( # pragma: no cover exclude_string, flags=GLOB_FLAGS, root_dir=docs_dir, ) def validate_order_option( order: str, page_src_path: str | None, docs_dir: str, directive_lineno: Callable[[], int], directive: str, ) -> None: """Validate the 'order' option.""" regex = get_order_option_regex() match = regex.match(order) if not match: location = file_lineno_message( page_src_path, docs_dir, directive_lineno(), ) raise PluginError( f"Invalid value '{order}' for the 'order' argument in" f" '{directive}' directive at {location}. The argument" " 'order' must be a string that matches the regex" f" '{regex.pattern}'.", ) @functools.cache def get_order_option_regex() -> re.Pattern[str]: """Return the compiled regex to validate the 'order' option.""" return re.compile( r'^-?' r'(?:' r'(?:alpha|natural)?(?:-?(?:path|name|extension))?' r'|system|random|size|mtime|ctime|atime' r')?$', ) def parse_order_option(order: str) -> OrderOption: """Parse the 'order' option into a tuple.""" ascending = False order_type = 'alpha' order_by = 'path' if order.startswith('-'): ascending = True order = order[1:] order_split = order.split('-', 1) if len(order_split) == 2: # noqa: PLR2004 order_type, order_by = order_split elif order_split[0] in ( 'alpha', 'random', 'natural', 'system', 'size', 'mtime', 'ctime', 'atime', ): order_type = order_split[0] elif order_split[0] in ('name', 'path', 'extension'): order_by = order_split[0] return ascending, order_type, order_by mkdocs_include_markdown_plugin-7.2.0/src/mkdocs_include_markdown_plugin/event.py0000644000000000000000000006334513615410400025443 0ustar00"""Module where the `on_page_markdown` plugin event is processed.""" from __future__ import annotations import functools import html import os import re import textwrap from dataclasses import dataclass from typing import TYPE_CHECKING from mkdocs.exceptions import PluginError from wcmatch import glob from mkdocs_include_markdown_plugin import process from mkdocs_include_markdown_plugin.cache import Cache from mkdocs_include_markdown_plugin.directive import ( ARGUMENT_REGEXES, GLOB_FLAGS, create_include_tag, parse_bool_options, parse_filename_argument, parse_string_argument, resolve_file_paths_to_exclude, resolve_file_paths_to_include, validate_order_option, warn_invalid_directive_arguments, ) from mkdocs_include_markdown_plugin.files_watcher import FilesWatcher from mkdocs_include_markdown_plugin.logger import logger from mkdocs_include_markdown_plugin.placeholders import ( escape_placeholders, save_placeholder, unescape_placeholders, ) if TYPE_CHECKING: # pragma: no cover from typing import TypedDict from mkdocs.structure.pages import Page from mkdocs_include_markdown_plugin.directive import DefaultValues from mkdocs_include_markdown_plugin.plugin import IncludeMarkdownPlugin IncludeTags = TypedDict( 'IncludeTags', { 'include': re.Pattern[str], 'include-markdown': re.Pattern[str], }, ) @dataclass class Settings: # noqa: D101 exclude: list[str] | None def get_file_content( # noqa: PLR0913, PLR0915 markdown: str, # Generated pages return `None` for `file.abs_src_path` because # they are not read from a file. In this case, page_src_path is # set to `None`. page_src_path: str | None, docs_dir: str, tags: IncludeTags, defaults: DefaultValues, settings: Settings, cumulative_heading_offset: int = 0, files_watcher: FilesWatcher | None = None, http_cache: Cache | None = None, ) -> str: """Return the content of the file to include.""" if settings.exclude: settings_ignore_paths = list(glob.glob( [ os.path.join(docs_dir, fp) if not os.path.isabs(fp) else fp for fp in settings.exclude ], flags=GLOB_FLAGS, root_dir=docs_dir, )) if page_src_path in settings_ignore_paths: return markdown else: settings_ignore_paths = [] markdown = escape_placeholders(markdown) placeholders_contents: list[tuple[str, str]] = [] def found_include_tag( # noqa: PLR0912, PLR0915 match: re.Match[str], ) -> str: directive_match_start = match.start() directive_lineno = functools.partial( process.lineno_from_content_start, markdown, directive_match_start, ) includer_indent = match['_includer_indent'] filename, raw_filename = parse_filename_argument(match) if filename is None: location = process.file_lineno_message( page_src_path, docs_dir, directive_lineno(), ) raise PluginError( "Found no path passed including with 'include'" f' directive at {location}', ) arguments_string = match['arguments'] used_arguments = warn_invalid_directive_arguments( arguments_string, directive_lineno, 'include', page_src_path, docs_dir, ) ignore_paths = [*settings_ignore_paths] if 'exclude' in used_arguments: exclude_match = ARGUMENT_REGEXES['exclude']().search( arguments_string, ) exclude_string = parse_string_argument(exclude_match) if exclude_string is None: location = process.file_lineno_message( page_src_path, docs_dir, directive_lineno(), ) raise PluginError( "Invalid empty 'exclude' argument in 'include'" f' directive at {location}', ) for path in resolve_file_paths_to_exclude( exclude_string, page_src_path, docs_dir, ): ignore_paths.append(path) order = defaults['order'] if 'order' in used_arguments: order_match = ARGUMENT_REGEXES['order']().search( arguments_string, ) order_ = parse_string_argument(order_match) if order_ is None: location = process.file_lineno_message( page_src_path, docs_dir, directive_lineno(), ) raise PluginError( "Invalid empty 'order' argument in 'include'" f' directive at {location}', ) validate_order_option( order_, page_src_path, docs_dir, directive_lineno, 'include', ) order = order_ file_paths_to_include, is_url = resolve_file_paths_to_include( filename, page_src_path, docs_dir, ignore_paths, order, ) if is_url and 'order' in used_arguments: # pragma: no cover location = process.file_lineno_message( page_src_path, docs_dir, directive_lineno(), ) logger.warning( f"Ignoring 'order' argument of 'include' directive" f" at {location} because the included path is a URL", ) if not file_paths_to_include: location = process.file_lineno_message( page_src_path, docs_dir, directive_lineno(), ) raise PluginError( f"No files found including '{raw_filename}' at {location}", ) if files_watcher is not None and not is_url: files_watcher.included_files.extend(file_paths_to_include) start = defaults['start'] if 'start' in used_arguments: start_match = ARGUMENT_REGEXES['start']().search(arguments_string) start = parse_string_argument(start_match) if start is None: location = process.file_lineno_message( page_src_path, docs_dir, directive_lineno(), ) raise PluginError( "Invalid empty 'start' argument in 'include' directive" f' at {location}', ) end = defaults['end'] if 'end' in used_arguments: end_match = ARGUMENT_REGEXES['end']().search(arguments_string) end = parse_string_argument(end_match) if end is None: location = process.file_lineno_message( page_src_path, docs_dir, directive_lineno(), ) raise PluginError( "Invalid empty 'end' argument in 'include' directive at" f' {location}', ) encoding = defaults['encoding'] if 'encoding' in used_arguments: encoding_match = ARGUMENT_REGEXES['encoding']().search( arguments_string, ) encoding_ = parse_string_argument(encoding_match) if encoding_ is None: location = process.file_lineno_message( page_src_path, docs_dir, directive_lineno(), ) raise PluginError( "Invalid empty 'encoding' argument in 'include'" f' directive at {location}', ) encoding = encoding_ bool_options, invalid_bool_args = parse_bool_options( [ 'preserve-includer-indent', 'dedent', 'trailing-newlines', 'recursive', ], defaults, arguments_string, used_arguments, ) if invalid_bool_args: location = process.file_lineno_message( page_src_path, docs_dir, directive_lineno(), ) raise PluginError( f"Invalid value for '{invalid_bool_args[0]}' argument of" f" 'include' directive at {location}." f' Possible values are true or false.', ) text_to_include = '' expected_but_any_found = [start is not None, end is not None] for file_path in file_paths_to_include: if process.is_url(filename): new_text_to_include = process.read_url( file_path, http_cache, encoding, ) else: new_text_to_include = process.read_file(file_path, encoding) if start or end: new_text_to_include, *expected_not_found = ( process.filter_inclusions( start, end, new_text_to_include, ) ) for i in range(2): if expected_but_any_found[i] and not expected_not_found[i]: expected_but_any_found[i] = False # nested includes if bool_options['recursive'].value: new_text_to_include = get_file_content( new_text_to_include, file_path, docs_dir, tags, defaults, settings, files_watcher=files_watcher, http_cache=http_cache, ) # trailing newlines right stripping if not bool_options['trailing-newlines'].value: new_text_to_include = process.rstrip_trailing_newlines( new_text_to_include, ) if bool_options['dedent'].value: new_text_to_include = textwrap.dedent(new_text_to_include) # includer indentation preservation if bool_options['preserve-includer-indent'].value: new_text_to_include = ''.join( includer_indent + line for line in ( new_text_to_include.splitlines(keepends=True) or [''] ) ) else: new_text_to_include = includer_indent + new_text_to_include text_to_include += new_text_to_include # warn if expected start or ends haven't been found in included content for i, delimiter_name in enumerate(['start', 'end']): if expected_but_any_found[i]: delimiter_value = locals()[delimiter_name] readable_files_to_include = ', '.join([ process.safe_os_path_relpath(fpath, docs_dir) for fpath in file_paths_to_include ]) plural_suffix = 's' if len(file_paths_to_include) > 1 else '' location = process.file_lineno_message( page_src_path, docs_dir, directive_lineno(), ) logger.warning( f"Delimiter {delimiter_name} '{delimiter_value}'" f" of 'include' directive at {location}" f' not detected in the file{plural_suffix}' f' {readable_files_to_include}', ) return save_placeholder(placeholders_contents, text_to_include) def found_include_markdown_tag( # noqa: PLR0912, PLR0915 match: re.Match[str], ) -> str: directive_match_start = match.start() directive_lineno = functools.partial( process.lineno_from_content_start, markdown, directive_match_start, ) includer_indent = match['_includer_indent'] filled_includer_indent = ' ' * len(includer_indent) filename, raw_filename = parse_filename_argument(match) if filename is None: location = process.file_lineno_message( page_src_path, docs_dir, directive_lineno(), ) raise PluginError( "Found no path passed including with 'include-markdown'" f' directive at {location}', ) arguments_string = match['arguments'] used_arguments = warn_invalid_directive_arguments( arguments_string, directive_lineno, 'include-markdown', page_src_path, docs_dir, ) ignore_paths = [*settings_ignore_paths] if 'exclude' in used_arguments: exclude_match = ARGUMENT_REGEXES['exclude']().search( arguments_string, ) exclude_string = parse_string_argument(exclude_match) if exclude_string is None: location = process.file_lineno_message( page_src_path, docs_dir, directive_lineno(), ) raise PluginError( "Invalid empty 'exclude' argument in 'include-markdown'" f' directive at {location}', ) for path in resolve_file_paths_to_exclude( exclude_string, page_src_path, docs_dir, ): ignore_paths.append(path) order = defaults['order'] if 'order' in used_arguments: order_match = ARGUMENT_REGEXES['order']().search( arguments_string, ) order_ = parse_string_argument(order_match) if order_ is None: location = process.file_lineno_message( page_src_path, docs_dir, directive_lineno(), ) raise PluginError( "Invalid empty 'order' argument in 'include-markdown'" f' directive at {location}', ) validate_order_option( order_, page_src_path, docs_dir, directive_lineno, 'include-markdown', ) order = order_ file_paths_to_include, is_url = resolve_file_paths_to_include( filename, page_src_path, docs_dir, ignore_paths, order, ) if is_url and 'order' in used_arguments: # pragma: no cover location = process.file_lineno_message( page_src_path, docs_dir, directive_lineno(), ) logger.warning( f"Ignoring 'order' argument of 'include-markdown' directive" f" at {location} because the included path is a URL", ) if not file_paths_to_include: location = process.file_lineno_message( page_src_path, docs_dir, directive_lineno(), ) raise PluginError( f"No files found including '{raw_filename}' at {location}", ) if files_watcher is not None and not is_url: files_watcher.included_files.extend(file_paths_to_include) # start and end arguments start = defaults['start'] if 'start' in used_arguments: start_match = ARGUMENT_REGEXES['start']().search(arguments_string) start = parse_string_argument(start_match) if start is None: location = process.file_lineno_message( page_src_path, docs_dir, directive_lineno(), ) raise PluginError( "Invalid empty 'start' argument in" f" 'include-markdown' directive at {location}", ) end = defaults['end'] if 'end' in used_arguments: end_match = ARGUMENT_REGEXES['end']().search(arguments_string) end = parse_string_argument(end_match) if end is None: location = process.file_lineno_message( page_src_path, docs_dir, directive_lineno(), ) raise PluginError( "Invalid empty 'end' argument in 'include-markdown'" f' directive at {location}', ) encoding = defaults['encoding'] if 'encoding' in used_arguments: encoding_match = ARGUMENT_REGEXES['encoding']().search( arguments_string, ) encoding_ = parse_string_argument(encoding_match) if encoding_ is None: location = process.file_lineno_message( page_src_path, docs_dir, directive_lineno(), ) raise PluginError( "Invalid empty 'encoding' argument in" f" 'include-markdown' directive at {location}", ) encoding = encoding_ # heading offset offset = defaults['heading-offset'] if 'heading-offset' in used_arguments: offset_match = ARGUMENT_REGEXES['heading-offset']().search( arguments_string, ) try: # Here None[1] would raise a TypeError offset_raw_value = offset_match[1] # type: ignore except (IndexError, TypeError): # pragma: no cover offset_raw_value = '' if offset_raw_value == '': location = process.file_lineno_message( page_src_path, docs_dir, directive_lineno(), ) raise PluginError( "Invalid empty 'heading-offset' argument in" f" 'include-markdown' directive at {location}", ) try: offset = int(offset_raw_value) except ValueError: location = process.file_lineno_message( page_src_path, docs_dir, directive_lineno(), ) raise PluginError( f"Invalid 'heading-offset' argument" f" '{offset_raw_value}' in 'include-markdown'" f" directive at {location}", ) from None bool_options, invalid_bool_args = parse_bool_options( [ 'rewrite-relative-urls', 'comments', 'preserve-includer-indent', 'dedent', 'trailing-newlines', 'recursive', ], defaults, arguments_string, used_arguments, ) if invalid_bool_args: location = process.file_lineno_message( page_src_path, docs_dir, directive_lineno(), ) raise PluginError( f"Invalid value for '{invalid_bool_args[0]}' argument of" " 'include-markdown' directive at" f' {location}. Possible values are true or false.', ) separator = '\n' if bool_options['trailing-newlines'].value else '' if not start and not end: start_end_part = '' else: start_end_part = f"'{html.escape(start)}' " if start else "'' " start_end_part += f"'{html.escape(end)}' " if end else "'' " # if any start or end strings are found in the included content # but the arguments are specified, we must raise a warning # # `True` means that no start/end strings have been found in content # but they have been specified, so the warning(s) must be raised expected_but_any_found = [start is not None, end is not None] text_to_include = '' for file_path in file_paths_to_include: if process.is_url(filename): new_text_to_include = process.read_url( file_path, http_cache, encoding, ) else: new_text_to_include = process.read_file(file_path, encoding) if start or end: new_text_to_include, *expected_not_found = ( process.filter_inclusions( start, end, new_text_to_include, ) ) for i in range(2): if expected_but_any_found[i] and not expected_not_found[i]: expected_but_any_found[i] = False # nested includes if bool_options['recursive'].value: new_text_to_include = get_file_content( new_text_to_include, file_path, docs_dir, tags, defaults, settings, files_watcher=files_watcher, http_cache=http_cache, ) # trailing newlines right stripping if not bool_options['trailing-newlines'].value: new_text_to_include = process.rstrip_trailing_newlines( new_text_to_include, ) # relative URLs rewriting if bool_options['rewrite-relative-urls'].value: if page_src_path is None: # pragma: no cover logger.warning( 'Relative URLs rewriting is not supported in' ' generated pages.', ) else: new_text_to_include = process.rewrite_relative_urls( new_text_to_include, source_path=file_path, destination_path=page_src_path, ) # comments if bool_options['comments'].value: new_text_to_include = ( f'{includer_indent}' f'{separator}{new_text_to_include}' f'{separator}' ) else: new_text_to_include = ( f'{includer_indent}{new_text_to_include}' ) # dedent if bool_options['dedent'].value: new_text_to_include = textwrap.dedent(new_text_to_include) # includer indentation preservation if bool_options['preserve-includer-indent'].value and ( new_text_to_include ): lines = new_text_to_include.splitlines(keepends=True) new_text_to_include = lines[0] for i in range(1, len(lines)): new_text_to_include += ( filled_includer_indent + lines[i] ) if offset: new_text_to_include = process.increase_headings_offset( new_text_to_include, offset=offset + cumulative_heading_offset, ) text_to_include += new_text_to_include # warn if expected start or ends haven't been found in included content for i, delimiter_name in enumerate(['start', 'end']): if expected_but_any_found[i]: delimiter_value = locals()[delimiter_name] readable_files_to_include = ', '.join([ process.safe_os_path_relpath(fpath, docs_dir) for fpath in file_paths_to_include ]) plural_suffix = 's' if len(file_paths_to_include) > 1 else '' location = process.file_lineno_message( page_src_path, docs_dir, directive_lineno(), ) logger.warning( f"Delimiter {delimiter_name} '{delimiter_value}' of" f" 'include-markdown' directive at {location}" f' not detected in the file{plural_suffix}' f' {readable_files_to_include}', ) return save_placeholder(placeholders_contents, text_to_include) # Replace contents by placeholders markdown = tags['include-markdown'].sub( found_include_markdown_tag, markdown, ) markdown = tags['include'].sub( found_include_tag, markdown, ) # Replace placeholders by contents for placeholder, text in placeholders_contents: markdown = markdown.replace(placeholder, text, 1) return unescape_placeholders(markdown) def on_page_markdown( markdown: str, page: Page, docs_dir: str, plugin: IncludeMarkdownPlugin, http_cache: Cache | None = None, ) -> str: """Process markdown content of a page.""" config = plugin.config return get_file_content( markdown, page.file.abs_src_path, docs_dir, { 'include': create_include_tag( config.opening_tag, config.closing_tag, config.directives.get('include', 'include'), ), 'include-markdown': create_include_tag( config.opening_tag, config.closing_tag, config.directives.get('include-markdown', 'include-markdown'), ), }, { 'encoding': config.encoding, 'preserve-includer-indent': config.preserve_includer_indent, 'dedent': config.dedent, 'trailing-newlines': config.trailing_newlines, 'comments': config.comments, 'rewrite-relative-urls': config.rewrite_relative_urls, 'heading-offset': config.heading_offset, 'recursive': config.recursive, 'start': config.start, 'end': config.end, 'order': config.order, }, Settings( exclude=config.exclude, ), files_watcher=plugin._files_watcher, http_cache=plugin._cache or http_cache, ) mkdocs_include_markdown_plugin-7.2.0/src/mkdocs_include_markdown_plugin/files_watcher.py0000644000000000000000000000045213615410400027127 0ustar00"""Implementation to watch for files when using livereload server.""" from __future__ import annotations class FilesWatcher: # noqa: D101 def __init__(self) -> None: # noqa: D107 pragma: no cover self.prev_included_files: list[str] = [] self.included_files: list[str] = [] mkdocs_include_markdown_plugin-7.2.0/src/mkdocs_include_markdown_plugin/logger.py0000644000000000000000000000034613615410400025571 0ustar00"""Mkdocs plugin logger.""" from __future__ import annotations import logging # TODO: when Mkdocs < 1.5.0 support is dropped, use # mkdocs.plugin.get_plugin_logger logger = logging.getLogger('mkdocs.plugins.include_markdown') mkdocs_include_markdown_plugin-7.2.0/src/mkdocs_include_markdown_plugin/placeholders.py0000644000000000000000000000215413615410400026756 0ustar00"""Module for placeholders processing.""" # Placeholders (taken from Python-Markdown) from __future__ import annotations STX = '\u0002' ''' "Start of Text" marker for placeholder templates. ''' ETX = '\u0003' ''' "End of Text" marker for placeholder templates. ''' INLINE_PLACEHOLDER_PREFIX = f'{STX}klzzwxh:' def build_placeholder(num: int) -> str: """Return a placeholder.""" return f'{INLINE_PLACEHOLDER_PREFIX}{num}{ETX}' def escape_placeholders(text: str) -> str: """Escape placeholders in the given text.""" return text.replace(STX, f'\\{STX}').replace(ETX, f'\\{ETX}') def unescape_placeholders(text: str) -> str: """Unescape placeholders in the given text.""" return text.replace(f'\\{STX}', STX).replace(f'\\{ETX}', ETX) def save_placeholder( placeholders_contents: list[tuple[str, str]], text_to_include: str, ) -> str: """Save the included text and return the placeholder.""" inclusion_index = len(placeholders_contents) placeholder = build_placeholder(inclusion_index) placeholders_contents.append((placeholder, text_to_include)) return placeholder mkdocs_include_markdown_plugin-7.2.0/src/mkdocs_include_markdown_plugin/plugin.py0000644000000000000000000001035613615410400025612 0ustar00"""Plugin entry point.""" from __future__ import annotations from collections.abc import Callable from functools import cached_property from typing import TYPE_CHECKING from mkdocs.exceptions import PluginError from mkdocs.plugins import BasePlugin, event_priority if TYPE_CHECKING: # pragma: no cover from mkdocs.config.defaults import MkDocsConfig from mkdocs.livereload import LiveReloadServer from mkdocs.structure.files import Files from mkdocs.structure.pages import Page from mkdocs_include_markdown_plugin.cache import Cache, initialize_cache from mkdocs_include_markdown_plugin.config import PluginConfig from mkdocs_include_markdown_plugin.directive import ( get_order_option_regex, ) from mkdocs_include_markdown_plugin.event import ( on_page_markdown as _on_page_markdown, ) from mkdocs_include_markdown_plugin.files_watcher import FilesWatcher class IncludeMarkdownPlugin(BasePlugin[PluginConfig]): _cache: Cache | None = None _server: LiveReloadServer | None = None def on_config(self, config: MkDocsConfig) -> MkDocsConfig: if self.config.cache > 0: cache = initialize_cache(self.config.cache, self.config.cache_dir) if cache is None: raise PluginError( 'Either `cache_dir` global setting must be configured or' ' `platformdirs` package is required to use the' ' `cache` option. Install mkdocs-include-markdown-plugin' " with the 'cache' extra to install `platformdirs`.", ) self._cache = cache if '__default' not in self.config.directives: # pragma: no cover for directive in self.config.directives: if directive not in ('include', 'include-markdown'): raise PluginError( f"Invalid directive name '{directive}' at 'directives'" ' global setting. Valid values are "include" and' ' "include-markdown".', ) if self.config.order != 'alpha-path': regex = get_order_option_regex() if not regex.match(self.config.order): raise PluginError( f"Invalid value '{self.config.order}' for the 'order'" ' global setting. Order must be a string' f" that matches the regex '{regex.pattern}'.", ) return config @cached_property def _files_watcher(self) -> FilesWatcher: return FilesWatcher() def _update_watched_files(self) -> None: # pragma: no cover """Function executed on server reload. At this execution point, the ``self._server`` attribute must be set. """ watcher, server = self._files_watcher, self._server # unwatch previous watched files not needed anymore for file_path in watcher.prev_included_files: if file_path not in watcher.included_files: server.unwatch(file_path) # type: ignore watcher.prev_included_files = watcher.included_files[:] # watch new included files for file_path in watcher.included_files: server.watch(file_path, recursive=False) # type: ignore watcher.included_files = [] def on_page_content( self, html: str, page: Page, # noqa: ARG002 config: MkDocsConfig, # noqa: ARG002 files: Files, # noqa: ARG002 ) -> str: if self._server is not None: # pragma: no cover self._update_watched_files() return html def on_serve( self, server: LiveReloadServer, config: MkDocsConfig, # noqa: ARG002 builder: Callable, # noqa: ARG002 ) -> None: if self._server is None: # pragma: no cover self._server = server self._update_watched_files() @event_priority(100) def on_page_markdown( self, markdown: str, page: Page, config: MkDocsConfig, files: Files, # noqa: ARG002 ) -> str: return _on_page_markdown( markdown, page, config.docs_dir, plugin=self, ) mkdocs_include_markdown_plugin-7.2.0/src/mkdocs_include_markdown_plugin/process.py0000644000000000000000000005411113615410400025767 0ustar00"""Utilities for string processing.""" from __future__ import annotations import functools import io import os import re import stat from collections.abc import Callable, Iterator from typing import TYPE_CHECKING if TYPE_CHECKING: # pragma: no cover from typing import Any from mkdocs_include_markdown_plugin.cache import Cache from mkdocs_include_markdown_plugin.directive import OrderOption # Markdown regular expressions. Taken from the original Markdown.pl by John # Gruber, and modified to work in Python # Matches markdown links. # e.g. [scikit-learn](https://github.com/scikit-learn/scikit-learn) # # The next Regex can raise a catastrophic backtracking, but with the current # implementation of the plugin it is not very much likely to reach the case. # Can be checked with dlint: # python3 -m dlint.redos --pattern '\[(?:(?:\[[^\[\]]+\])*)?\]' # # In the original Markdown.pl, the nested brackets are enclosed by an atomic # group (?>...), but atomic groups are not supported by Python in versions # previous to Python3.11. Also, these nested brackets can be recursive in the # Perl implementation but this doesn't seem possible in Python, the current # implementation only reaches two levels. MARKDOWN_LINK_REGEX = re.compile( r''' ( # wrap whole match in $1 (?? # href = $3 \s* ( # $4 (['"]) # quote char = $5 (.*?) # Title = $6 \5 # matching quote )? # title is optional \) ) ''', flags=re.VERBOSE, ) # Matches markdown inline images. # e.g. ![alt-text](path/to/image.png) MARKDOWN_IMAGE_REGEX = re.compile( r''' ( # wrap whole match in $1 !\[ (.*?) # alt text = $2 \] \( # literal paren [ \t]* ? # src url = $3 [ \t]* ( # $4 (['"]) # quote char = $5 (.*?) # title = $6 \5 # matching quote [ \t]* )? # title is optional \) ) ''', flags=re.VERBOSE, ) # Matches markdown link definitions. # e.g. [scikit-learn]: https://github.com/scikit-learn/scikit-learn MARKDOWN_LINK_DEFINITION_REGEX = re.compile( r''' ^[ ]{0,4}\[(.+)\]: # id = $1 [ \t]* \n? # maybe *one* newline [ \t]* ? # url = $2 [ \t]* \n? # maybe one newline [ \t]* (?: (?<=\s) # lookbehind for whitespace ["(] (.+?) # title = $3 [")] [ \t]* )? # title is optional (?:\n+|\Z) ''', flags=re.VERBOSE | re.MULTILINE, ) # Matched html image and source definition. # e.g. alt-text # e.g. MARKDOWN_HTML_IMAGE_REGEX = re.compile( r''' <(?:img|source) # img or source (?:\s+ # More than one whitespace (?!src=) # Not src= [\w-]+ # attribute name (?:\s*=\s*)? # arbitrary whitespace (optional) (?: "[^"]*" # Quoted value (double quote) | '[^']*' # Quoted value (single quote) )? )* # Other attributes are repeated 0 or more times \s+ # More than one whitespace src=["'](\S+?)["'] # src = $1 (double quote or single quote) ''', flags=re.VERBOSE | re.MULTILINE, ) # Matched html anchor definition. # e.g. example MARKDOWN_HTML_ANCHOR_DEFINITION_REGEX = re.compile( r''' str: """Apply a transformation paragraph by paragraph in a Markdown text. Apply a transformation paragraph by paragraph in a Markdown using a function. Skip indented and fenced codeblock lines, where the transformation is never applied. """ # current fenced codeblock delimiter _current_fcodeblock_delimiter = '' # inside indented codeblock _maybe_icodeblock_lines: list[str] = [] _previous_line_was_empty = False lines, current_paragraph = ([], '') def process_current_paragraph() -> None: lines.extend(func(current_paragraph).splitlines(keepends=True)) # The next implementation takes into account that indented code # blocks must be surrounded by newlines as per the CommonMark # specification. See https://spec.commonmark.org/0.28/#indented-code-blocks # # However, note that ambiguities with list items are not handled. for line in io.StringIO(markdown): if not _current_fcodeblock_delimiter: lstripped_line = line.lstrip() if lstripped_line.startswith(('```', '~~~')): _current_fcodeblock_delimiter = lstripped_line[:3] process_current_paragraph() current_paragraph = '' lines.append(line) elif line.startswith(' '): if not lstripped_line or _maybe_icodeblock_lines: # maybe enter indented codeblock _maybe_icodeblock_lines.append(line) else: current_paragraph += line elif _maybe_icodeblock_lines: process_current_paragraph() current_paragraph = '' if not _previous_line_was_empty: # wasn't an indented code block for line_ in _maybe_icodeblock_lines: current_paragraph += line_ _maybe_icodeblock_lines = [] current_paragraph += line process_current_paragraph() current_paragraph = '' else: # exit indented codeblock for line_ in _maybe_icodeblock_lines: lines.append(line_) _maybe_icodeblock_lines = [] lines.append(line) else: current_paragraph += line _previous_line_was_empty = not lstripped_line else: lines.append(line) lstripped_line = line.lstrip() if lstripped_line.startswith(_current_fcodeblock_delimiter): _current_fcodeblock_delimiter = '' _previous_line_was_empty = not lstripped_line if _maybe_icodeblock_lines: if not _previous_line_was_empty: # at EOF process_current_paragraph() current_paragraph = '' for line_ in _maybe_icodeblock_lines: current_paragraph += line_ process_current_paragraph() current_paragraph = '' else: process_current_paragraph() current_paragraph = '' for line_ in _maybe_icodeblock_lines: lines.append(line_) else: process_current_paragraph() return ''.join(lines) def transform_line_by_line_skipping_codeblocks( markdown: str, func: Callable[[str], str], ) -> str: """Apply a transformation line by line in a Markdown text using a function,. Skip fenced codeblock lines and empty lines, where the transformation is never applied. Indented codeblocks are not taken into account because in the practice this function is only used for transformations of heading prefixes. See the PR https://github.com/mondeja/mkdocs-include-markdown-plugin/pull/95 to recover the implementation handling indented codeblocks. """ # current fenced codeblock delimiter _current_fcodeblock_delimiter = '' lines = [] for line in io.StringIO(markdown): lstripped_line = line.lstrip() if not _current_fcodeblock_delimiter: if lstripped_line.startswith('```'): _current_fcodeblock_delimiter = '```' elif lstripped_line.startswith('~~~'): _current_fcodeblock_delimiter = '~~~' else: line = func(line) # noqa: PLW2901 elif lstripped_line.startswith(_current_fcodeblock_delimiter): _current_fcodeblock_delimiter = '' lines.append(line) return ''.join(lines) def rewrite_relative_urls( markdown: str, source_path: str, destination_path: str, ) -> str: """Rewrite relative URLs in a Markdown text. Rewrites markdown so that relative links that were written at ``source_path`` will still work when inserted into a file at ``destination_path``. """ def rewrite_url(url: str) -> str: if is_url(url) or is_absolute_path(url) or is_anchor(url): return url new_path = os.path.relpath( os.path.join(os.path.dirname(source_path), url), os.path.dirname(destination_path), ) # ensure forward slashes are used, on Windows new_path = new_path.replace('\\', '/').replace('//', '/') try: if url[-1] == '/': # the above operation removes a trailing slash, # so add it back if it was present in the input new_path += '/' except IndexError: # pragma: no cover pass return new_path def found_href(m: re.Match[str], url_group_index: int = -1) -> str: match_start, match_end = m.span(0) href = m[url_group_index] href_start, href_end = m.span(url_group_index) rewritten_url = rewrite_url(href) return ( m.string[match_start:href_start] + rewritten_url + m.string[href_end:match_end] ) found_href_url_group_index_3 = functools.partial( found_href, url_group_index=3, ) def transform(paragraph: str) -> str: paragraph = MARKDOWN_LINK_REGEX.sub( found_href_url_group_index_3, paragraph, ) paragraph = MARKDOWN_IMAGE_REGEX.sub( found_href_url_group_index_3, paragraph, ) paragraph = MARKDOWN_LINK_DEFINITION_REGEX.sub( functools.partial(found_href, url_group_index=2), paragraph, ) paragraph = MARKDOWN_HTML_IMAGE_REGEX.sub( functools.partial(found_href, url_group_index=1), paragraph, ) return MARKDOWN_HTML_ANCHOR_DEFINITION_REGEX.sub( functools.partial(found_href, url_group_index=1), paragraph, ) return transform_p_by_p_skipping_codeblocks( markdown, transform, ) def interpret_escapes(value: str) -> str: """Interpret Python literal escapes in a string. Replaces any standard escape sequences in value with their usual meanings as in ordinary Python string literals. """ return value.encode('latin-1', 'backslashreplace').decode('unicode_escape') def filter_inclusions( # noqa: PLR0912 start: str | None, end: str | None, text_to_include: str, ) -> tuple[str, bool, bool]: """Filter inclusions in a text. Manages inclusions from files using ``start`` and ``end`` directive arguments. """ expected_start_not_found, expected_end_not_found = (False, False) new_text_to_include = '' if start is not None and end is None: start = interpret_escapes(start) if start not in text_to_include: expected_start_not_found = True else: new_text_to_include = text_to_include.split( start, maxsplit=1, )[1] elif start is None and end is not None: end = interpret_escapes(end) if end not in text_to_include: expected_end_not_found = True new_text_to_include = text_to_include else: new_text_to_include = text_to_include.split( end, maxsplit=1, )[0] elif start is not None and end is not None: start, end = interpret_escapes(start), interpret_escapes(end) if start not in text_to_include: expected_start_not_found = True if end not in text_to_include: expected_end_not_found = True start_split = text_to_include.split(start) text_parts = ( start_split[1:] if len(start_split) > 1 else [text_to_include] ) for start_text in text_parts: for i, end_text in enumerate(start_text.split(end)): if not i % 2: new_text_to_include += end_text else: # pragma: no cover new_text_to_include = text_to_include return ( new_text_to_include, expected_start_not_found, expected_end_not_found, ) def _transform_negative_offset_func_factory( offset: int, ) -> Callable[[str], str]: abs_offset = abs(offset) def transform(line: str) -> str: try: if line[0] != '#': return line except IndexError: # pragma: no cover # Note for pragma: all lines include a newline # so this exception is never raised in tests. return line stripped_line = line.lstrip('#') new_n_headings = max(len(line) - len(stripped_line) - abs_offset, 1) return '#' * new_n_headings + stripped_line return transform def _transform_positive_offset_func_factory( offset: int, ) -> Callable[[str], str]: heading_prefix = '#' * offset def transform(line: str) -> str: try: if line[0] != '#': return line except IndexError: # pragma: no cover return line return heading_prefix + line return transform def increase_headings_offset(markdown: str, offset: int = 0) -> str: """Increases the headings depth of a snippet of Makdown content.""" if not offset: # pragma: no cover return markdown return transform_line_by_line_skipping_codeblocks( markdown, _transform_positive_offset_func_factory(offset) if offset > 0 else _transform_negative_offset_func_factory(offset), ) def rstrip_trailing_newlines(content: str) -> str: """Removes trailing newlines from a string.""" while content.endswith(('\n', '\r')): content = content.rstrip('\r\n') return content def filter_paths( filepaths: Iterator[str] | list[str], ignore_paths: list[str], ) -> list[str]: """Filters a list of paths removing those defined in other list of paths. The paths to filter can be defined in the list of paths to ignore in several forms: - The same string. - Only the file name. - Only their direct directory name. - Their direct directory full path. Args: filepaths (list): Set of source paths to filter. ignore_paths (list): Paths that are ignored. Returns: list: Non filtered paths ordered alphabetically. """ result = [] for filepath in filepaths: # ignore by filepath if filepath in ignore_paths: continue # ignore by dirpath (relative or absolute) fp_split = filepath.split(os.sep) fp_split.pop() if (os.sep).join(fp_split) in ignore_paths: continue # ignore if is a directory try: if not stat.S_ISDIR(os.stat(filepath).st_mode): result.append(filepath) except (FileNotFoundError, OSError): # pragma: no cover continue return result def natural_sort_key(s: str) -> list[Any]: """Key function for natural sorting of strings.""" return [int(text) if text.isdigit() else text.lower() for text in re.split(r'(\d+)', s)] def sort_paths(paths: list[str], order: OrderOption) -> list[str]: """Sort a list of paths in-place according to an order option.""" ascending, order_type, order_by = order if order_type == 'random': import random # noqa: PLC0415 random.shuffle(paths) return paths key = None if order_type == 'alpha': if order_by == 'name': def key(p: str) -> str: return os.path.basename(p) elif order_by == 'extension': def key(p: str) -> str: return os.path.splitext(p)[1] elif order_type == 'natural': if order_by == 'extension': def key(p: str) -> str: return natural_sort_key(os.path.splitext(p)[1]) # type: ignore elif order_by == 'name': def key(p: str) -> str: return natural_sort_key(os.path.basename(p)) # type: ignore else: key = natural_sort_key # type: ignore elif order_type == 'size': def key(p: str) -> int: # type: ignore return os.path.getsize(p) ascending = not ascending # larger files first elif order_type == 'mtime': def key(p: str) -> float: # type: ignore return os.path.getmtime(p) elif order_type == 'ctime': def key(p: str) -> float: # type: ignore return os.path.getctime(p) elif order_type == 'atime': def key(p: str) -> float: # type: ignore return os.path.getatime(p) paths.sort(key=key, reverse=ascending) return paths def _is_valid_url_scheme_char(c: str) -> bool: """Determine is a character is a valid URL scheme character. Valid characters are: ``` abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789+-. ``` """ codepoint = ord(c) A = 65 Z = 90 a = 97 z = 122 zero = 48 nine = 57 dot = 46 plus = 43 minus = 45 return ( A <= codepoint <= Z or a <= codepoint <= z or zero <= codepoint <= nine or codepoint in (plus, minus, dot) ) def is_url(string: str) -> bool: """Determine if a string is an URL. The implementation has been adapted from `urllib.urlparse`. """ i = string.find(':') if i <= 1: # noqa: PLR2004 -> exclude C: or D: on Windows return False try: return all(_is_valid_url_scheme_char(string[j]) for j in range(i)) except (IndexError, ValueError): # pragma: no cover return False def is_relative_path(string: str) -> bool: """Check if a string looks like a relative path.""" try: return ( string[0] == '.' and ( string[1] == '/' or (string[1] == '.' and string[2] == '/') ) ) except IndexError: # pragma: no cover return False def is_absolute_path(string: str) -> bool: """Check if a string looks like an absolute path.""" try: return string[0] == '/' or string[0] == os.sep except IndexError: # pragma: no cover return False def is_anchor(string: str) -> bool: """Check if a string looks like an anchor. An anchor is a string that starts with `#` and is not a relative path. """ try: return string[0] == '#' except IndexError: # pragma: no cover return False def read_file(file_path: str, encoding: str) -> str: """Read a file and return its content.""" f = open(file_path, encoding=encoding) # noqa: SIM115 content = f.read() f.close() return content def read_url( url: str, http_cache: Cache | None, encoding: str = 'utf-8', ) -> Any: """Read an HTTP location and return its content.""" from urllib.request import Request, urlopen # noqa: PLC0415 if http_cache is not None: cached_content = http_cache.get_(url, encoding) if cached_content is not None: return cached_content with urlopen(Request(url)) as response: content = response.read().decode(encoding) if http_cache is not None: http_cache.set_(url, content, encoding) return content def safe_os_path_relpath(path: str, start: str) -> str: """Return the relative path of a file from a start directory. Safe version of `os.path.relpath` that catches possible `ValueError` exceptions and returns the original path in case of error. On Windows, `ValueError` is raised when `path` and `start` are on different drives. """ try: return os.path.relpath(path, start) except ValueError: # pragma: no cover return path def file_lineno_message( page_src_path: str | None, docs_dir: str, lineno: int, ) -> str: """Return a message with the file path and line number.""" if page_src_path is None: # pragma: no cover return f'generated page content (line {lineno})' return ( f'{safe_os_path_relpath(page_src_path, docs_dir)}' f':{lineno}' ) def lineno_from_content_start(content: str, start: int) -> int: """Return the line number of the first line of ``start`` in ``content``.""" return content[:start].count('\n') + 1 mkdocs_include_markdown_plugin-7.2.0/src/mkdocs_include_markdown_plugin/py.typed0000644000000000000000000000000013615410400025422 0ustar00mkdocs_include_markdown_plugin-7.2.0/.gitignore0000644000000000000000000000030013615410400016664 0ustar00build/ dist/ __pycache__/ .hatch/ report.html .coverage .pytest_cache/ .ruff_cache/ htmlcov/ *.egg-info/ venv*/ *.whl *.tgz *.mo *.pot .vscode .mypy_cache/ *.so .prettier-cache /*.html site/ mkdocs_include_markdown_plugin-7.2.0/LICENSE0000644000000000000000000002614513615410400015720 0ustar00 Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright 2017-2025 Joe Rickerby and contributors Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. mkdocs_include_markdown_plugin-7.2.0/README.md0000644000000000000000000003366113615410400016173 0ustar00 # mkdocs-include-markdown-plugin [![PyPI][pypi-version-badge-link]][pypi-link] [![License][license-image]][license-link] [![Tests][tests-image]][tests-link] [![Coverage status][coverage-image]][coverage-link] [![Downloads][downloads-image]][downloads-link] Mkdocs Markdown includer plugin. > Read this document in other languages: > > - [Español][es-readme-link] > - [Français][fr-readme-link] ## Installation ```bash pip install mkdocs-include-markdown-plugin ``` ## Documentation ### Setup Enable the plugin in your `mkdocs.yml`: ```yaml plugins: - include-markdown ``` ### Configuration The global behaviour of the plugin can be customized in the configuration. Most of the settings will define the default values passed to arguments of directives and are documented in the [reference](#reference). ```yaml plugins: - include-markdown: encoding: ascii preserve_includer_indent: false dedent: false trailing_newlines: true comments: true rewrite_relative_urls: true heading_offset: 0 start: end: recursive: true ``` #### `opening_tag` and `closing_tag` Default opening and closing tags. When not specified they are `{%` and `%}`. ```yaml plugins: - include-markdown: opening_tag: "{!" closing_tag: "!}" ``` #### `exclude` Global exclusion wildcard patterns. Relative paths defined here will be relative to the [`docs_dir`] directory. ```yaml plugins: - include-markdown: exclude: - LICENSE.md - api/** ``` #### `cache` Expiration time in seconds for cached HTTP requests when including from URLs. ```yaml plugins: - include-markdown: cache: 600 ``` In order to use this feature, the dependency [platformdirs] must be installed or the setting [`cache_dir`](#cache_dir) must be defined. You can include [platformdirs] in the installation of the plugin adding the `cache` extra: ```txt # requirements.txt mkdocs-include-markdown-plugin[cache] ``` #### `cache_dir` Directory where cached HTTP requests will be stored. If set, [platformdirs] is not needed to be installed to use [`cache`](#cache). ```yaml plugins: - include-markdown: cache: 600 cache_dir: ./mkdocs-include-markdown-cache ``` A _.gitignore_ file will be added to the cache directory if not exists to avoid committing the cache files. #### `directives` Customize the names of the directives. ```yaml plugins: - include-markdown: directives: include-markdown: include-md include: replace ``` ### Reference This plugin provides two directives, one to include Markdown files and another to include files of any type. Paths of included files can be either: - URLs to include remote content. - Local files: - Absolute paths (starting with a path separator). - Relative from the file that includes them (starting with `./` or `../`). - Relative to the [`docs_dir`] directory. For instance if your `docs_dir` is _./docs/_, then `includes/header.md` will match the file _./docs/includes/header.md_. - [Bash wildcard globs] matching multiple local files. File paths to include and string arguments can be wrapped by double `"` or single `'` quotes, which can be escaped prepending them a `\` character as `\"` and `\'`. The arguments **start** and **end** may contain usual (Python-style) escape sequences like `\n` to match against newlines. #### **`include-markdown`** Includes Markdown files content, optionally using two delimiters to filter the content to include. - # **start**: Delimiter that marks the beginning of the content to include. - # **end**: Delimiter that marks the end of the content to include. - # **preserve-includer-indent** (_true_): When this option is enabled (default), every line of the content to include is indented with the same number of spaces used to indent the includer `{% %}` template. Possible values are `true` and `false`. - # **dedent** (_false_): If enabled, the included content will be dedented. - # **exclude**: Specify with a glob which files should be ignored. Only useful when passing globs to include multiple files. - # **trailing-newlines** (_true_): When this option is disabled, the trailing newlines found in the content to include are stripped. Possible values are `true` and `false`. - # **recursive** (_true_): When this option is disabled, included files are not processed for recursive includes. Possible values are `true` and `false`. - # **order** (_'alpha-path'_): Define the order in which multiple files are included when using globs. Possible values are: - A combination of an optional order type and an optional order by separated by a hyphen (`-`), and optionally prefixed by a hyphen (`-`) to indicate ascending order. If an order type or an order by is not specified, the defaults are used. It follows the form: `[-]-` where: - **Order type**: - `'alpha'` (default): Alphabetical order. - `'natural'`: Natural order, so that e.g. `file2.md` comes before `file10.md`. - **Order by**: - `'path'` (default): Order by full file path. - `'name'`: Order by file name only. - `'extension'`: Order by file extension. - A combination of an optional prefix hyphen to denote ascending order and one of the following values in the form `[-]` where `` is one of: - `'size'`: Order by file size. - `'mtime'`: Order by file modification time. - `'ctime'`: Order by file creation time (or the last metadata change time on Unix systems). - `'atime'`: Order by file last access time. - `'random'`: Random order. - `'system'`: Order provided by the operating system. This is the same as not specifying any order and relying on the default order of the filesystem. This may be different between operating systems, so use it with care. - # **encoding** (_'utf-8'_): Specify the encoding of the included file. If not defined `'utf-8'` will be used. - # **rewrite-relative-urls** (_true_): When this option is enabled (default), Markdown links and images in the content that are specified by a relative URL are rewritten to work correctly in their new location. Possible values are `true` and `false`. - # **comments** (_false_): When this option is enabled, the content to include is wrapped by `` and `` comments which help to identify that the content has been included. Possible values are `true` and `false`. - # **heading-offset** (0): Increases or decreases the Markdown headings depth by this number. Only supports number sign (`#`) heading syntax. Accepts negative values to drop leading `#` characters. ##### Examples ```jinja {% include-markdown "../README.md" start="" end="" %} ``` ```jinja {% include-markdown 'includes/header.md' start='' end='' rewrite-relative-urls=false comments=true %} ``` ```jinja {% include-markdown "includes/header.md" heading-offset=1 %} ``` ```jinja {% include-markdown "../LICENSE*" start="" end='' exclude="../*.rst" %} ``` ```jinja {% include-markdown "**" exclude="./{index,LICENSE}.md" order="name" %} ``` ```jinja {% include-markdown '/escap\'ed/single-quotes/in/file\'/name.md' %} ``` ```jinja {% include-markdown "**" order="-natural-extension" %} ``` #### **`include`** Includes the content of a file or a group of files. - # **start**: Delimiter that marks the beginning of the content to include. - # **end**: Delimiter that marks the end of the content to include. - # **preserve-includer-indent** (_true_): When this option is enabled (default), every line of the content to include is indented with the same number of spaces used to indent the includer `{% %}` template. Possible values are `true` and `false`. - # **dedent** (_false_): If enabled, the included content will be dedented. - # **exclude**: Specify with a glob which files should be ignored. Only useful when passing globs to include multiple files. - # **trailing-newlines** (_true_): When this option is disabled, the trailing newlines found in the content to include are stripped. Possible values are `true` and `false`. - # **recursive** (_true_): When this option is disabled, included files are not processed for recursive includes. Possible values are `true` and `false`. - # **order** (_'alpha-path'_): Define the order in which multiple files are included when using globs. Possible values are: - A combination of an optional order type and an optional order by separated by a hyphen (`-`), and optionally prefixed by a hyphen (`-`) to indicate ascending order. If an order type or an order by is not specified, the defaults are used. It follows the form: `[-]-` where: - **Order type**: - `'alpha'` (default): Alphabetical order. - `'natural'`: Natural order, so that e.g. `file2.md` comes before `file10.md`. - **Order by**: - `'path'` (default): Order by full file path. - `'name'`: Order by file name only. - `'extension'`: Order by file extension. - A combination of an optional prefix hyphen to denote ascending order and one of the following values in the form `[-]` where `` is one of: - `'size'`: Order by file size. - `'mtime'`: Order by file modification time. - `'ctime'`: Order by file creation time (or the last metadata change time on Unix systems). - `'atime'`: Order by file last access time. - `'random'`: Random order. - `'system'`: Order provided by the operating system. This is the same as not specifying any order and relying on the default order of the filesystem. This may be different between operating systems, so use it with care. - # **encoding** (_'utf-8'_): Specify the encoding of the included file. If not defined `'utf-8'` will be used. ##### Examples ```jinja ~~~yaml {% include "../examples/github-minimal.yml" %} ~~~ ``` ```jinja {% include "../examples.md" start="~~~yaml" end="~~~\n" %} ``` ```jinja {% include '**' exclude='./*.md' order='random' %} ``` ## Acknowledgment - [Joe Rickerby] and [contributors] for [giving me the permissions][cibuildwheel-470] to [separate this plugin][cibuildwheel-475] from the documentation of [cibuildwheel][cibuildwheel-repo-link]. [Bash wildcard globs]: https://facelessuser.github.io/wcmatch/glob/#syntax [pypi-link]: https://pypi.org/project/mkdocs-include-markdown-plugin [pypi-version-badge-link]: https://img.shields.io/pypi/v/mkdocs-include-markdown-plugin?logo=pypi&logoColor=white [tests-image]: https://img.shields.io/github/actions/workflow/status/mondeja/mkdocs-include-markdown-plugin/ci.yml?logo=github&label=tests&branch=master [tests-link]: https://github.com/mondeja/mkdocs-include-markdown-plugin/actions?query=workflow%3ACI [coverage-image]: https://img.shields.io/codecov/c/github/mondeja/mkdocs-include-markdown-plugin?logo=codecov&logoColor=white [coverage-link]: https://app.codecov.io/gh/mondeja/mkdocs-include-markdown-plugin [license-image]: https://img.shields.io/pypi/l/mkdocs-include-markdown-plugin?color=light-green&logo=apache&logoColor=white [license-link]: https://github.com/mondeja/mkdocs-include-markdown-plugin/blob/master/LICENSE [downloads-image]: https://img.shields.io/pypi/dm/mkdocs-include-markdown-plugin [downloads-link]: https://pepy.tech/project/mkdocs-include-markdown-plugin [platformdirs]: https://pypi.org/project/platformdirs/ [cibuildwheel-470]: https://github.com/pypa/cibuildwheel/issues/470 [cibuildwheel-475]: https://github.com/pypa/cibuildwheel/pull/475 [cibuildwheel-repo-link]: https://github.com/pypa/cibuildwheel [es-readme-link]: https://github.com/mondeja/mkdocs-include-markdown-plugin/blob/master/locale/es/README.md [fr-readme-link]: https://github.com/mondeja/mkdocs-include-markdown-plugin/blob/master/locale/fr/README.md [`docs_dir`]: https://www.mkdocs.org/user-guide/configuration/#docs_dir [Joe Rickerby]: https://github.com/joerick [contributors]: https://github.com/mondeja/mkdocs-include-markdown-plugin/graphs/contributors mkdocs_include_markdown_plugin-7.2.0/pyproject.toml0000644000000000000000000001156413615410400017626 0ustar00[project] name = "mkdocs-include-markdown-plugin" version = "7.2.0" description = "Mkdocs Markdown includer plugin." readme = "README.md" license = "Apache-2.0" requires-python = ">=3.9" classifiers = [ "Development Status :: 5 - Production/Stable", "Operating System :: OS Independent", "License :: OSI Approved :: Apache Software License", "Intended Audience :: Developers", "Topic :: Documentation", "Topic :: Software Development :: Documentation", "Topic :: Text Processing", "Topic :: Text Processing :: Markup :: Markdown", "Environment :: Console", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", ] keywords = ["markdown", "mkdocs", "includer", "plugin"] dependencies = [ "mkdocs>=1.4", "wcmatch" ] [[project.authors]] name = "Joe Rickerby" [[project.authors]] name = "Álvaro Mondéjar Rubio" email = "mondejar1994@gmail.com" [[project.maintainers]] name = "Álvaro Mondéjar Rubio" email = "mondejar1994@gmail.com" [project.urls] Source = "https://github.com/mondeja/mkdocs-include-markdown-plugin" Documentation = "https://github.com/mondeja/mkdocs-include-markdown-plugin#documentation" "Bug tracker" = "https://github.com/mondeja/mkdocs-include-markdown-plugin/issues" Changelog = "https://github.com/mondeja/mkdocs-include-markdown-plugin/releases" [project.entry-points."mkdocs.plugins"] include-markdown = "mkdocs_include_markdown_plugin.plugin:IncludeMarkdownPlugin" [project.optional-dependencies] cache = ["platformdirs"] [tool.hatch.build] include = ["/src"] [tool.hatch.build.targets.wheel] packages = ["src/mkdocs_include_markdown_plugin"] [tool.hatch.envs.default] python = "3.10" dependencies = ["mondeja-bump"] [tool.hatch.envs.style] python = "3.10" detached = true dependencies = ["pre-commit"] [tool.hatch.envs.style.scripts] lint = "pre-commit run -a" [tool.hatch.envs.tests] matrix-name-format = "{variable}-{value}" dependencies = ["pytest~=7.0", "coverage~=6.4", "covdefaults"] [[tool.hatch.envs.tests.matrix]] python = ["py39", "py310", "py311", "py312", "py313"] mkdocs = ["1.4.0", "1.4.3", "1.5.0", "1.5.3", "1.6.0"] cache = ["yes", "no"] [tool.hatch.envs.tests.overrides] matrix.mkdocs.dependencies = [ { value = "mkdocs==1.4.0", if = ["1.4.0"] }, { value = "mkdocs==1.4.3", if = ["1.4.3"] }, { value = "mkdocs==1.5.0", if = ["1.5.0"] }, { value = "mkdocs==1.5.3", if = ["1.5.3"] }, { value = "mkdocs==1.6.0", if = ["1.6.0"] }, ] matrix.cache.dependencies = [{ value = "platformdirs", if = ["yes"] }] [tool.hatch.envs.tests.scripts] all = "coverage run -m pytest" unit = "coverage run -m pytest tests/test_unit" integration = "pytest tests/test_integration --override-ini addopts=-svv" cov = [ "hatch run +py=py310 tests:all", "coverage html", "python -c 'import webbrowser as w;w.open(\"http://127.0.0.1:8088\")'", "python -m http.server 8088 -b localhost -d htmlcov", ] [tool.bump] targets = [{ file = "pyproject.toml" }] [tool.project-config] cache = "2 days" style = [ "gh://mondeja/project-config-styles@v5.5/base/pre-commit/md2po2md.json5", "gh://mondeja/project-config-styles@v5.5/python/base.json5", "gh://mondeja/project-config-styles@v5.5/python/mypy.json5", ] [tool.coverage.run] source = ["src"] plugins = ["covdefaults"] parallel = true data_file = ".coverage/.coverage" [tool.coverage.report] exclude_lines = ["def __repr__\\(", "@(abc\\.)?abstractmethod"] fail_under = 1 [tool.ruff] line-length = 80 target-version = "py39" [tool.ruff.lint] select = [ "W", "B", "E", "I", "F", "A", "D", "G", "Q", "PL", "UP", "PT", "C4", "EXE", "ISC", "T20", "INP", "ARG", "SIM", "RET", "FBT", "ERA", "T10", "COM", "SLOT", ] ignore = ["G004", "E731"] [tool.ruff.lint.pydocstyle] convention = "google" [tool.ruff.lint.flake8-quotes] inline-quotes = "single" multiline-quotes = "single" [tool.ruff.lint.flake8-pytest-style] fixture-parentheses = false parametrize-values-type = "tuple" parametrize-values-row-type = "tuple" [tool.ruff.lint.isort] lines-after-imports = 2 combine-as-imports = true force-wrap-aliases = true known-first-party = ["mkdocs_include_markdown_plugin", "testing_helpers"] known-local-folder = ["tests"] required-imports = ["from __future__ import annotations"] extra-standard-library = [ "zoneinfo", "graphlib", "tomllib", "wsgiref.types" ] [tool.ruff.lint.per-file-ignores] "tests/**" = [ "I002", "D100", "D101", "D102", "D103", "D104", "D107", "D205", "D415", "INP001", "PLR0913", "PLR2004", ] "setup.py" = ["D205", "INP001", "I002"] "src/mkdocs_include_markdown_plugin/plugin.py" = [ "D100", "D101", "D102", ] [tool.mypy] strict = true python_version = "3.12" allow_untyped_calls = true allow_any_generics = true [build-system] requires = ["hatchling"] build-backend = "hatchling.build" mkdocs_include_markdown_plugin-7.2.0/PKG-INFO0000644000000000000000000003675313615410400016016 0ustar00Metadata-Version: 2.4 Name: mkdocs-include-markdown-plugin Version: 7.2.0 Summary: Mkdocs Markdown includer plugin. Project-URL: Source, https://github.com/mondeja/mkdocs-include-markdown-plugin Project-URL: Documentation, https://github.com/mondeja/mkdocs-include-markdown-plugin#documentation Project-URL: Bug tracker, https://github.com/mondeja/mkdocs-include-markdown-plugin/issues Project-URL: Changelog, https://github.com/mondeja/mkdocs-include-markdown-plugin/releases Author: Joe Rickerby Author-email: Álvaro Mondéjar Rubio Maintainer-email: Álvaro Mondéjar Rubio License-Expression: Apache-2.0 License-File: LICENSE Keywords: includer,markdown,mkdocs,plugin Classifier: Development Status :: 5 - Production/Stable Classifier: Environment :: Console Classifier: Intended Audience :: Developers Classifier: License :: OSI Approved :: Apache Software License Classifier: Operating System :: OS Independent Classifier: Programming Language :: Python :: 3.9 Classifier: Programming Language :: Python :: 3.10 Classifier: Programming Language :: Python :: 3.11 Classifier: Programming Language :: Python :: 3.12 Classifier: Programming Language :: Python :: 3.13 Classifier: Topic :: Documentation Classifier: Topic :: Software Development :: Documentation Classifier: Topic :: Text Processing Classifier: Topic :: Text Processing :: Markup :: Markdown Requires-Python: >=3.9 Requires-Dist: mkdocs>=1.4 Requires-Dist: wcmatch Provides-Extra: cache Requires-Dist: platformdirs; extra == 'cache' Description-Content-Type: text/markdown # mkdocs-include-markdown-plugin [![PyPI][pypi-version-badge-link]][pypi-link] [![License][license-image]][license-link] [![Tests][tests-image]][tests-link] [![Coverage status][coverage-image]][coverage-link] [![Downloads][downloads-image]][downloads-link] Mkdocs Markdown includer plugin. > Read this document in other languages: > > - [Español][es-readme-link] > - [Français][fr-readme-link] ## Installation ```bash pip install mkdocs-include-markdown-plugin ``` ## Documentation ### Setup Enable the plugin in your `mkdocs.yml`: ```yaml plugins: - include-markdown ``` ### Configuration The global behaviour of the plugin can be customized in the configuration. Most of the settings will define the default values passed to arguments of directives and are documented in the [reference](#reference). ```yaml plugins: - include-markdown: encoding: ascii preserve_includer_indent: false dedent: false trailing_newlines: true comments: true rewrite_relative_urls: true heading_offset: 0 start: end: recursive: true ``` #### `opening_tag` and `closing_tag` Default opening and closing tags. When not specified they are `{%` and `%}`. ```yaml plugins: - include-markdown: opening_tag: "{!" closing_tag: "!}" ``` #### `exclude` Global exclusion wildcard patterns. Relative paths defined here will be relative to the [`docs_dir`] directory. ```yaml plugins: - include-markdown: exclude: - LICENSE.md - api/** ``` #### `cache` Expiration time in seconds for cached HTTP requests when including from URLs. ```yaml plugins: - include-markdown: cache: 600 ``` In order to use this feature, the dependency [platformdirs] must be installed or the setting [`cache_dir`](#cache_dir) must be defined. You can include [platformdirs] in the installation of the plugin adding the `cache` extra: ```txt # requirements.txt mkdocs-include-markdown-plugin[cache] ``` #### `cache_dir` Directory where cached HTTP requests will be stored. If set, [platformdirs] is not needed to be installed to use [`cache`](#cache). ```yaml plugins: - include-markdown: cache: 600 cache_dir: ./mkdocs-include-markdown-cache ``` A _.gitignore_ file will be added to the cache directory if not exists to avoid committing the cache files. #### `directives` Customize the names of the directives. ```yaml plugins: - include-markdown: directives: include-markdown: include-md include: replace ``` ### Reference This plugin provides two directives, one to include Markdown files and another to include files of any type. Paths of included files can be either: - URLs to include remote content. - Local files: - Absolute paths (starting with a path separator). - Relative from the file that includes them (starting with `./` or `../`). - Relative to the [`docs_dir`] directory. For instance if your `docs_dir` is _./docs/_, then `includes/header.md` will match the file _./docs/includes/header.md_. - [Bash wildcard globs] matching multiple local files. File paths to include and string arguments can be wrapped by double `"` or single `'` quotes, which can be escaped prepending them a `\` character as `\"` and `\'`. The arguments **start** and **end** may contain usual (Python-style) escape sequences like `\n` to match against newlines. #### **`include-markdown`** Includes Markdown files content, optionally using two delimiters to filter the content to include. - # **start**: Delimiter that marks the beginning of the content to include. - # **end**: Delimiter that marks the end of the content to include. - # **preserve-includer-indent** (_true_): When this option is enabled (default), every line of the content to include is indented with the same number of spaces used to indent the includer `{% %}` template. Possible values are `true` and `false`. - # **dedent** (_false_): If enabled, the included content will be dedented. - # **exclude**: Specify with a glob which files should be ignored. Only useful when passing globs to include multiple files. - # **trailing-newlines** (_true_): When this option is disabled, the trailing newlines found in the content to include are stripped. Possible values are `true` and `false`. - # **recursive** (_true_): When this option is disabled, included files are not processed for recursive includes. Possible values are `true` and `false`. - # **order** (_'alpha-path'_): Define the order in which multiple files are included when using globs. Possible values are: - A combination of an optional order type and an optional order by separated by a hyphen (`-`), and optionally prefixed by a hyphen (`-`) to indicate ascending order. If an order type or an order by is not specified, the defaults are used. It follows the form: `[-]-` where: - **Order type**: - `'alpha'` (default): Alphabetical order. - `'natural'`: Natural order, so that e.g. `file2.md` comes before `file10.md`. - **Order by**: - `'path'` (default): Order by full file path. - `'name'`: Order by file name only. - `'extension'`: Order by file extension. - A combination of an optional prefix hyphen to denote ascending order and one of the following values in the form `[-]` where `` is one of: - `'size'`: Order by file size. - `'mtime'`: Order by file modification time. - `'ctime'`: Order by file creation time (or the last metadata change time on Unix systems). - `'atime'`: Order by file last access time. - `'random'`: Random order. - `'system'`: Order provided by the operating system. This is the same as not specifying any order and relying on the default order of the filesystem. This may be different between operating systems, so use it with care. - # **encoding** (_'utf-8'_): Specify the encoding of the included file. If not defined `'utf-8'` will be used. - # **rewrite-relative-urls** (_true_): When this option is enabled (default), Markdown links and images in the content that are specified by a relative URL are rewritten to work correctly in their new location. Possible values are `true` and `false`. - # **comments** (_false_): When this option is enabled, the content to include is wrapped by `` and `` comments which help to identify that the content has been included. Possible values are `true` and `false`. - # **heading-offset** (0): Increases or decreases the Markdown headings depth by this number. Only supports number sign (`#`) heading syntax. Accepts negative values to drop leading `#` characters. ##### Examples ```jinja {% include-markdown "../README.md" start="" end="" %} ``` ```jinja {% include-markdown 'includes/header.md' start='' end='' rewrite-relative-urls=false comments=true %} ``` ```jinja {% include-markdown "includes/header.md" heading-offset=1 %} ``` ```jinja {% include-markdown "../LICENSE*" start="" end='' exclude="../*.rst" %} ``` ```jinja {% include-markdown "**" exclude="./{index,LICENSE}.md" order="name" %} ``` ```jinja {% include-markdown '/escap\'ed/single-quotes/in/file\'/name.md' %} ``` ```jinja {% include-markdown "**" order="-natural-extension" %} ``` #### **`include`** Includes the content of a file or a group of files. - # **start**: Delimiter that marks the beginning of the content to include. - # **end**: Delimiter that marks the end of the content to include. - # **preserve-includer-indent** (_true_): When this option is enabled (default), every line of the content to include is indented with the same number of spaces used to indent the includer `{% %}` template. Possible values are `true` and `false`. - # **dedent** (_false_): If enabled, the included content will be dedented. - # **exclude**: Specify with a glob which files should be ignored. Only useful when passing globs to include multiple files. - # **trailing-newlines** (_true_): When this option is disabled, the trailing newlines found in the content to include are stripped. Possible values are `true` and `false`. - # **recursive** (_true_): When this option is disabled, included files are not processed for recursive includes. Possible values are `true` and `false`. - # **order** (_'alpha-path'_): Define the order in which multiple files are included when using globs. Possible values are: - A combination of an optional order type and an optional order by separated by a hyphen (`-`), and optionally prefixed by a hyphen (`-`) to indicate ascending order. If an order type or an order by is not specified, the defaults are used. It follows the form: `[-]-` where: - **Order type**: - `'alpha'` (default): Alphabetical order. - `'natural'`: Natural order, so that e.g. `file2.md` comes before `file10.md`. - **Order by**: - `'path'` (default): Order by full file path. - `'name'`: Order by file name only. - `'extension'`: Order by file extension. - A combination of an optional prefix hyphen to denote ascending order and one of the following values in the form `[-]` where `` is one of: - `'size'`: Order by file size. - `'mtime'`: Order by file modification time. - `'ctime'`: Order by file creation time (or the last metadata change time on Unix systems). - `'atime'`: Order by file last access time. - `'random'`: Random order. - `'system'`: Order provided by the operating system. This is the same as not specifying any order and relying on the default order of the filesystem. This may be different between operating systems, so use it with care. - # **encoding** (_'utf-8'_): Specify the encoding of the included file. If not defined `'utf-8'` will be used. ##### Examples ```jinja ~~~yaml {% include "../examples/github-minimal.yml" %} ~~~ ``` ```jinja {% include "../examples.md" start="~~~yaml" end="~~~\n" %} ``` ```jinja {% include '**' exclude='./*.md' order='random' %} ``` ## Acknowledgment - [Joe Rickerby] and [contributors] for [giving me the permissions][cibuildwheel-470] to [separate this plugin][cibuildwheel-475] from the documentation of [cibuildwheel][cibuildwheel-repo-link]. [Bash wildcard globs]: https://facelessuser.github.io/wcmatch/glob/#syntax [pypi-link]: https://pypi.org/project/mkdocs-include-markdown-plugin [pypi-version-badge-link]: https://img.shields.io/pypi/v/mkdocs-include-markdown-plugin?logo=pypi&logoColor=white [tests-image]: https://img.shields.io/github/actions/workflow/status/mondeja/mkdocs-include-markdown-plugin/ci.yml?logo=github&label=tests&branch=master [tests-link]: https://github.com/mondeja/mkdocs-include-markdown-plugin/actions?query=workflow%3ACI [coverage-image]: https://img.shields.io/codecov/c/github/mondeja/mkdocs-include-markdown-plugin?logo=codecov&logoColor=white [coverage-link]: https://app.codecov.io/gh/mondeja/mkdocs-include-markdown-plugin [license-image]: https://img.shields.io/pypi/l/mkdocs-include-markdown-plugin?color=light-green&logo=apache&logoColor=white [license-link]: https://github.com/mondeja/mkdocs-include-markdown-plugin/blob/master/LICENSE [downloads-image]: https://img.shields.io/pypi/dm/mkdocs-include-markdown-plugin [downloads-link]: https://pepy.tech/project/mkdocs-include-markdown-plugin [platformdirs]: https://pypi.org/project/platformdirs/ [cibuildwheel-470]: https://github.com/pypa/cibuildwheel/issues/470 [cibuildwheel-475]: https://github.com/pypa/cibuildwheel/pull/475 [cibuildwheel-repo-link]: https://github.com/pypa/cibuildwheel [es-readme-link]: https://github.com/mondeja/mkdocs-include-markdown-plugin/blob/master/locale/es/README.md [fr-readme-link]: https://github.com/mondeja/mkdocs-include-markdown-plugin/blob/master/locale/fr/README.md [`docs_dir`]: https://www.mkdocs.org/user-guide/configuration/#docs_dir [Joe Rickerby]: https://github.com/joerick [contributors]: https://github.com/mondeja/mkdocs-include-markdown-plugin/graphs/contributors