-
Notifications
You must be signed in to change notification settings - Fork 9
👌 IMPROVE: Stop using YAML frontmatter-based directive options #49
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,9 +1,13 @@ | ||
| """Helpers to handle directives---including their headers and fence syntax.""" | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| from collections.abc import Mapping, MutableMapping, Sequence | ||
| import io | ||
|
|
||
| from markdown_it import MarkdownIt | ||
| import mdformat | ||
| import mdformat.plugins | ||
| from mdformat.renderer import LOGGER, RenderContext, RenderTreeNode | ||
| import ruamel.yaml | ||
|
|
||
|
|
@@ -13,7 +17,12 @@ | |
|
|
||
| def longest_consecutive_sequence(seq: str, char: str) -> int: | ||
| """Return length of the longest consecutive sequence of `char` characters | ||
| in string `seq`.""" | ||
| in string `seq`. | ||
|
|
||
| This measured faster than the more "Pythonic": | ||
|
|
||
| `max((len(list(g)) for k, g in groupby(s) if k == char), default=0)` | ||
| """ | ||
| assert len(char) == 1 | ||
| longest = 0 | ||
| current_streak = 0 | ||
|
|
@@ -30,27 +39,32 @@ def longest_consecutive_sequence(seq: str, char: str) -> int: | |
| def fence(node: "RenderTreeNode", context: "RenderContext") -> str: | ||
| """Render fences (and directives). | ||
|
|
||
| Copied from upstream `mdformat` core and should be kept up-to-date | ||
| if upstream introduces changes. Note that only two lines are added | ||
| to the upstream implementation, i.e. the condition that calls | ||
| `format_directive_content` function. | ||
| Originally copied from upstream `mdformat` core. Key changes so far: | ||
| - call our `format_directive_content` function when a directive is detected (instead | ||
| of treating the contents as code in that case). | ||
| - allow colon fences (and use a heuristic to ensure good spacing when recombining | ||
| those). | ||
| """ | ||
| info_str = node.info.strip() | ||
| lang = info_str.split(maxsplit=1)[0] if info_str else "" | ||
| code_block = node.content | ||
| is_directive = lang.startswith("{") and lang.endswith("}") | ||
| unformatted_body = node.content | ||
|
|
||
| if node.type == "colon_fence": | ||
| fence_char = ":" | ||
| # Info strings of backtick code fences can not contain backticks or tildes. | ||
| # If that is the case, we make a tilde code fence instead. | ||
| if "`" in info_str or "~" in info_str: | ||
| elif "`" in info_str or "~" in info_str: | ||
| fence_char = "~" | ||
| else: | ||
| fence_char = "`" | ||
|
|
||
| # Format the code block using enabled codeformatter funcs | ||
| if lang in context.options.get("codeformatters", {}): | ||
| if is_directive: | ||
| body = format_directive_content(unformatted_body, context=context) | ||
| elif lang in context.options.get("codeformatters", {}): | ||
| fmt_func = context.options["codeformatters"][lang] | ||
| try: | ||
| code_block = fmt_func(code_block, info_str) | ||
| body = fmt_func(unformatted_body, info_str) | ||
| except Exception: | ||
| # Swallow exceptions so that formatter errors (e.g. due to | ||
| # invalid code) do not crash mdformat. | ||
|
|
@@ -59,49 +73,67 @@ def fence(node: "RenderTreeNode", context: "RenderContext") -> str: | |
| f"Failed formatting content of a {lang} code block " | ||
| f"(line {node.map[0] + 1} before formatting)" | ||
| ) | ||
| # This "elif" is the *only* thing added to the upstream `fence` implementation! | ||
| elif lang.startswith("{") and lang.endswith("}"): | ||
| code_block = format_directive_content(code_block) | ||
| body = unformatted_body | ||
| else: | ||
| body = unformatted_body | ||
|
|
||
| # The code block must not include as long or longer sequence of `fence_char`s | ||
| # as the fence string itself | ||
| fence_len = max(3, longest_consecutive_sequence(code_block, fence_char) + 1) | ||
| # The fenced contents must not include as long or longer sequence of `fence_char`s | ||
| # as the fence string itself. | ||
| fence_len = max(3, longest_consecutive_sequence(body, fence_char) + 1) | ||
| fence_str = fence_char * fence_len | ||
|
|
||
| return f"{fence_str}{info_str}\n{code_block}{fence_str}" | ||
|
|
||
|
|
||
| def format_directive_content(raw_content: str) -> str: | ||
| parse_result = parse_opts_and_content(raw_content) | ||
| if not parse_result: | ||
| return raw_content | ||
| unformatted_yaml, content = parse_result | ||
| dump_stream = io.StringIO() | ||
| try: | ||
| parsed = yaml.load(unformatted_yaml) | ||
| yaml.dump(parsed, stream=dump_stream) | ||
| except ruamel.yaml.YAMLError: | ||
| LOGGER.warning("Invalid YAML in MyST directive options.") | ||
| return raw_content | ||
| formatted_yaml = dump_stream.getvalue() | ||
|
|
||
| # Remove the YAML closing tag if added by `ruamel.yaml` | ||
| if formatted_yaml.endswith("\n...\n"): | ||
| formatted_yaml = formatted_yaml[:-4] | ||
|
|
||
| # Convert empty YAML to most concise form | ||
| if formatted_yaml == "null\n": | ||
| formatted_yaml = "" | ||
|
|
||
| formatted = "---\n" + formatted_yaml + "---\n" | ||
| if content: | ||
| formatted += content + "\n" | ||
| formatted_fence = f"{fence_str}{info_str}\n" | ||
| # Heuristic to ensure child colon fences recombine with a leading blank line for | ||
| # consistency. | ||
| if body.startswith(":::"): | ||
| formatted_fence += "\n" | ||
| formatted_fence += f"{body}{fence_str}" | ||
| return formatted_fence | ||
|
|
||
|
|
||
| def format_directive_content(raw_content: str, context) -> str: | ||
| unformatted_yaml, content = parse_opts_and_content(raw_content) | ||
| formatted = "" | ||
| if unformatted_yaml is not None: | ||
| dump_stream = io.StringIO() | ||
| try: | ||
| parsed = yaml.load(unformatted_yaml) | ||
| yaml.dump(parsed, stream=dump_stream) | ||
| except ruamel.yaml.YAMLError: | ||
| LOGGER.warning("Invalid YAML in MyST directive options.") | ||
| return raw_content | ||
| if parsed: | ||
| formatted += "\n".join([f":{k}: {v}" for k, v in parsed.items()]) + "\n\n" | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Would it make sense to have a way for people to opt-out of the newer formatting with a configuration option or do you think most people would prefer these changes? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Isn't the problem here, that yaml.load from above will parse valueless options as There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I believe this line has another problem. If you have a code-cell the formatting works fine for scalars, but if there is a list it yields So this line needs to differentiate between:
|
||
| if content.strip(): | ||
| # Get currently active plugin modules | ||
| active_plugins = context.options.get("parser_extension", []) | ||
|
|
||
| # Resolve modules back to their string names | ||
| # mdformat.text() requires names (str), not objects | ||
| extension_names = [ | ||
| name | ||
| for name, plugin in mdformat.plugins.PARSER_EXTENSIONS.items() | ||
| if plugin in active_plugins | ||
| ] | ||
| formatted += mdformat.text( | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Directive bodies are not always CommonMark. toctree entries use |
||
| content, options=context.options, extensions=extension_names | ||
| ) | ||
| if not formatted: | ||
| return "" | ||
| # In both the content-containing case (in which case we might have many terminal | ||
| # newlines in the content) and the options-only case (in which case, we have | ||
| # inserted two newlines above to separate the options from the non-existent content) | ||
| # we want to ensure we end in _exactly_ one newline. | ||
| formatted = formatted.rstrip("\n") + "\n" | ||
| # Unless the last thing in the content is a colon-fence, which for consistency we | ||
| # always add padding to. | ||
| if formatted.endswith(":::\n"): | ||
| formatted += "\n" | ||
| return formatted | ||
|
|
||
|
|
||
| def parse_opts_and_content(raw_content: str) -> tuple[str, str] | None: | ||
| def parse_opts_and_content(raw_content: str) -> tuple[str | None, str]: | ||
| if not raw_content: | ||
| return None | ||
| return None, raw_content | ||
| lines = raw_content.splitlines() | ||
| line = lines.pop(0) | ||
| yaml_lines = [] | ||
|
|
@@ -111,15 +143,17 @@ def parse_opts_and_content(raw_content: str) -> tuple[str, str] | None: | |
| if all(c == "-" for c in line) and len(line) >= 3: | ||
| break | ||
| yaml_lines.append(line) | ||
| elif line.lstrip().startswith(":"): | ||
| elif line.lstrip().startswith(":") and not line.lstrip().startswith(":::"): | ||
| yaml_lines.append(line.lstrip()[1:]) | ||
| while lines: | ||
| if not lines[0].lstrip().startswith(":"): | ||
| if not lines[0].lstrip().startswith(":") or lines[0].lstrip().startswith( | ||
| ":::" | ||
| ): | ||
| break | ||
| line = lines.pop(0).lstrip()[1:] | ||
| yaml_lines.append(line) | ||
| else: | ||
| return None | ||
| return None, raw_content | ||
|
|
||
| first_line_is_empty_but_second_line_isnt = ( | ||
| len(lines) >= 2 and not lines[0].strip() and lines[1].strip() | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Doesn't
parse_opts_and_contentreturnNoneif the directive has no options but does have content, i.e. line 156? In that case, directives without options but with a body would lose their entire body.