diff --git a/processMDSpec.py b/processMDSpec.py index f065f95d5a71f7d428886d4e8272095b02dce65e..9b3d93c85f5635c80917d8834bf6b0f15682d8ef 100644 --- a/processMDSpec.py +++ b/processMDSpec.py @@ -10,10 +10,13 @@ """ from __future__ import annotations + +_print = print # save the original print function + from typing import Tuple, Generator import argparse -from rich import print, markdown -import re, sys, yaml +from rich import markdown, print +import re, sys, yaml, os from contextlib import contextmanager @@ -37,13 +40,79 @@ def includeStack(filename:str) -> Generator [None, None, None]: Generator: A generator that yields nothing. """ if filename in _includeStack: - print(f'[red]Circular include detected: {filename}') - raise Exception('Circular include detected') + raise Exception(f'Circular include detected: {" -> ".join(_includeStack)} -> {filename}') _includeStack.append(filename) yield _includeStack.pop() +def expandPaths(lines:list[str], currentPath:str, childPath:str) -> list[str]: + """ Expand the paths in the markdown file. This means that all paths in links, + images, and include statements are extended so that they would be valid paths + from the root document. + + Args: + lines: The lines of the markdown file. + currentPath: The current path of the file being processed. + childPath: The path of the child file being processed. + + Returns: + list[str]: The lines of the markdown file with expanded paths. + """ + + # Replace all relative paths in the markdown with the new path + # add a path to the current path + if currentPath[-1] != '/': + currentPath += '/' + newPath = currentPath + childPath + # Remove the leading './' from the path + while newPath.startswith('./'): + newPath = newPath[2:] + + inCodeFence = False + for index, line in enumerate(lines): + + # Ignore stuff in code fences + if re.match(r'^\s*```.*', line): + inCodeFence = not inCodeFence + continue + if inCodeFence: + continue + + # handle the links in a line (there could be multiple links in a line) + links = re.findall(r'\[([^\]]+)\]\(([^\)]+)\)', line) + for linkText, linkPath in links: + # Skip URLs and absolute paths + if linkPath.startswith(('http://', 'https://', '/')): + continue + + # Construct the new path by adding addedPath to the original path + newLinkPath = linkPath[2:] if linkPath.startswith('./') else linkPath + + # Create the updated path + updatedPath = f"{newPath}{linkPath}" if newPath.endswith('/') else f"{newPath}/{newLinkPath}" + + # Replace the original link with the updated one in the markdown + line = line.replace(f'[{linkText}]({linkPath})', f'[{linkText}]({updatedPath})') + + # handle the include statements (there should only be one per line) + includes = re.findall(r'^\s*::include{file=([^\}]+)}', line) + for includePath in includes: + + # Construct the new path by adding addedPath to the original path + includePath = includePath[2:] if includePath.startswith('./') else includePath + + # Create the updated path + updatedPath = f'{newPath}{includePath}' if newPath.endswith('/') else f'{newPath}/{includePath}' + + # Replace the original include with the updated one in the markdown + line = line.replace(f'::include{{file={includePath}}}', f'::include{{file={updatedPath}}}') + + lines[index] = line + + return lines + + def processFrontMatter(lines:list[str], args:argparse.Namespace) -> Tuple[dict, list[str]]: """ Process the front matter of a markdown file. This includes extracting the front matter information and returning it as a dictionary. @@ -97,7 +166,7 @@ def processFile(args:argparse.Namespace) -> str: The processed markdown content as a string. """ - def handleIncludesForFile(filename:str) -> str: + def handleIncludesForFile(filename:str, currentPath:str) -> str: """ Read a single markdown file and return its content. Args: @@ -109,6 +178,14 @@ def processFile(args:argparse.Namespace) -> str: Returns: The content of the file. """ + # Get the directory path from the filename + dirname = os.path.dirname(filename) + if dirname and not dirname.endswith('/'): + dirname = dirname + '/' + + dirname = dirname if dirname else '.' + currentPath = currentPath if currentPath else '.' + filename = os.path.normpath(filename) with includeStack(filename): try: @@ -117,8 +194,11 @@ def processFile(args:argparse.Namespace) -> str: except FileNotFoundError: print(f'[red]File not found: {filename}') raise + + # Expand the paths in the markdown file # extract front matter information + lines = expandPaths(lines, currentPath, dirname) fm, lines = processFrontMatter(lines, args) if fm: _frontMatter[filename] = fm @@ -129,7 +209,7 @@ def processFile(args:argparse.Namespace) -> str: inCodeFence = False for line in lines: - # Ignore code fences + # Ignore stuff code fences if re.match(r'^\s*```.*', line): inCodeFence = not inCodeFence continue @@ -139,17 +219,15 @@ def processFile(args:argparse.Namespace) -> str: # Check for ::include{file=...} pattern using regex at the beginning of a line match = re.search(r'^::include\{\s*file=(.*?)\s*\}', line.strip()) if match: - include_filename = match.group(1) + includeFilename = match.group(1) # Read the included file and replace the include statement with its content - include_content = handleIncludesForFile(include_filename) - lines[lines.index(line)] = include_content - + lines[lines.index(line)] = handleIncludesForFile(includeFilename, os.path.dirname(filename)) + return ''.join(lines) - return handleIncludesForFile(args.document) + return handleIncludesForFile(args.document, os.path.dirname(args.document)) - if __name__ == '__main__': parser = argparse.ArgumentParser(description='Process markdown specification files.') @@ -158,7 +236,7 @@ if __name__ == '__main__': parser.add_argument('--process-frontmatter', '-fm', dest='outputFrontMatter', action='store_true', help='output front matter only') parser.add_argument('--frontmatter-only', '-fmo', dest='onlyFrontMatter', action='store_true', help='output only front matter') parser.add_argument('--verbose', '-v', action='store_true', help='print debug information to stderr.') - parser.add_argument('document', type = str, help = 'a markdown specification document to process') + parser.add_argument('document', type=str, help='a markdown specification document to process') args = parser.parse_args() if args.verbose: @@ -170,7 +248,7 @@ if __name__ == '__main__': try: lines = processFile(args) except Exception as e: - print(f'[red]Error processing file: {e}', file=sys.stderr) + print(f'[red]Error while processing {args.document}\n{e}', file=sys.stderr) quit(1) if args.outputFrontMatter or args.onlyFrontMatter: @@ -192,7 +270,7 @@ if __name__ == '__main__': print(markdown.Markdown(lines)) else: # Print the raw markdown content - print(lines) + _print(lines)