Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -152,3 +152,4 @@ venv.bak/

# Project Specifics
.vscode
setenv.sh
29 changes: 28 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,29 @@
- **Talk to the Confluence API:** `mdfluence` also features an embedded micro-implementation of the [Confluence Server REST API](https://developer.atlassian.com/server/confluence/confluence-server-rest-api/) with basic support for creating and updating pages and attachments.
- **Automate the upload process:** You can use `mdfluence`'s full-featured command line utility to automate the upload process for you.

### Supported Markdown plugins

`mdfluence` enables the following [Mistune plugins](https://mistune.lepture.com/en/latest/plugins.html) for extended Markdown syntax:

| Plugin | Markdown syntax | Confluence notes |
| ---------------- | ------------------------------------- | --------------------------------------------------------------- |
| Tables | `\| a \| b \|` | — |
| Strikethrough | `~~text~~` | — |
| Mark | `==text==` | ⚠️ `<mark>` tags stripped by Confluence; degrades to plain text |
| Insert | `^^text^^` | — |
| Superscript | `x^2^` | — |
| Subscript | `H~2~O` | — |
| Task lists | `- [x] done` | Rendered with XHTML-compliant attributes |
| Definition lists | `Term` / `: Definition` | — |
| Abbreviations | `*[HTML]: Hyper Text Markup Language` | ⚠️ `<abbr>` tags stripped by Confluence; degrades to plain text |
| Footnotes | `text[^1]` / `[^1]: note` | Uses Confluence anchor macros for navigation |
| Math (inline) | `$E=mc^2$` | Requires the **LaTeX Math - MathJax** Confluence plugin |
| Math (block) | `$$\sum x$$` | Requires the **LaTeX Math - MathJax** Confluence plugin |
| Spoiler | `>! hidden text` | ⚠️ No native Confluence equivalent; renders as plain div |
| Auto-link URLs | `https://example.com` | — |

> :information_source: When math syntax (`$...$` or `$$...$$`) is detected, `mdfluence` automatically injects the `enablelatexmath` macro into the page. This macro activates MathJax rendering and requires the [LaTeX Math - MathJax](https://marketplace.atlassian.com/apps/1210882) Confluence plugin to be installed on your instance.

## Installation

```bash
Expand Down Expand Up @@ -360,14 +383,18 @@ output from a Markdown document.
```python
import mistune
from mdfluence.confluence_renderer import ConfluenceRenderer
from mistune.plugins.table import table

markdown_text = "# Page title\n\nInteresting *content* here!"

renderer = ConfluenceRenderer(use_xhtml=True)
renderer = ConfluenceRenderer()
confluence_mistune = mistune.Markdown(renderer=renderer)
table(confluence_mistune) # enable plugins as needed
confluence_body = confluence_mistune(markdown_text)
```

> :information_source: When using `mdfluence` as a command-line tool or via `parse_page()`, all plugins listed above are enabled automatically.

### API

mdfluence embeds a teeny-tiny implementation of the Confluence Server REST
Expand Down
111 changes: 88 additions & 23 deletions mdfluence/confluence_renderer.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,35 +60,36 @@ def append(self, child):
self.children.append(child)


class ConfluenceRenderer(mistune.Renderer):
class ConfluenceRenderer(mistune.HTMLRenderer):
def __init__(
self,
strip_header=False,
remove_text_newlines=False,
enable_relative_links=False,
**kwargs,
):
super().__init__(**kwargs)
super().__init__(escape=False)
self.strip_header = strip_header
self.remove_text_newlines = remove_text_newlines
self.attachments = list()
self.title = None
self.enable_relative_links = enable_relative_links
self.relative_links: List[RelativeLink] = list()
self._has_math = False

def reinit(self):
self.attachments = list()
self.relative_links = list()
self._has_math = False
self.title = None

def header(self, text, level, raw=None):
def heading(self, text, level, **attrs):
if self.title is None and level == 1:
self.title = text
# Don't duplicate page title as a header
if self.strip_header:
return ""

return super(ConfluenceRenderer, self).header(text, level, raw=raw)
return super().heading(text, level, **attrs)

def structured_macro(self, name):
return ConfluenceTag("structured-macro", attrib={"name": name})
Expand All @@ -103,60 +104,124 @@ def plain_text_body(self, text):
body_tag.text = text
return body_tag

def link(self, link, title, text):
parsed_link = urlparse(link)
def link(self, text, url, title=None):
parsed_url = urlparse(url)
if (
self.enable_relative_links
and (not parsed_link.scheme and not parsed_link.netloc)
and parsed_link.path
and (not parsed_url.scheme and not parsed_url.netloc)
and parsed_url.path
):
# relative link
replacement_link = f"md2cf-internal-link-{uuid.uuid4()}"
self.relative_links.append(
RelativeLink(
# make sure to unquote the url as relative paths
# might have escape sequences
path=unquote(parsed_link.path),
path=unquote(parsed_url.path),
replacement=replacement_link,
fragment=parsed_link.fragment,
original=link,
escaped_original=mistune.escape_link(link),
fragment=parsed_url.fragment,
original=url,
escaped_original=mistune.escape_url(url),
)
)
link = replacement_link
return super(ConfluenceRenderer, self).link(link, title, text)
url = replacement_link
return super().link(text, url, title)

def text(self, text):
if self.remove_text_newlines:
text = text.replace("\n", " ")

return super().text(text)

def block_code(self, code, lang=None):
def block_code(self, code, info=None):
root_element = self.structured_macro("code")
if lang is not None:
lang_parameter = self.parameter(name="language", value=lang)
if info is not None:
lang_parameter = self.parameter(name="language", value=info)
root_element.append(lang_parameter)
root_element.append(self.parameter(name="linenumbers", value="true"))
root_element.append(self.plain_text_body(code))
return root_element.render()

def image(self, src, title, text):
def image(self, text, url, title=None):
attributes = {"alt": text}
if title:
attributes["title"] = title

root_element = ConfluenceTag(name="image", attrib=attributes)
parsed_source = urlparse(src)
parsed_source = urlparse(url)
if not parsed_source.netloc:
# Local file, requires upload
basename = Path(src).name
basename = Path(url).name
url_tag = ConfluenceTag(
"attachment", attrib={"filename": basename}, namespace="ri"
)
self.attachments.append(src)
self.attachments.append(url)
else:
url_tag = ConfluenceTag("url", attrib={"value": src}, namespace="ri")
url_tag = ConfluenceTag("url", attrib={"value": url}, namespace="ri")
root_element.append(url_tag)

return root_element.render()

def task_list_item(self, text, checked=False):
checkbox = (
'<input class="task-list-item-checkbox" type="checkbox" disabled="disabled"'
)
if checked:
checkbox += ' checked="checked"'
checkbox += "/>"

if text.startswith("<p>"):
text = text.replace("<p>", "<p>" + checkbox, 1)
else:
text = checkbox + text

return '<li class="task-list-item">' + text + "</li>\n"

def _confluence_anchor(self, name):
macro = self.structured_macro("anchor")
macro.append(self.parameter("", name))
return macro.render()

def _confluence_anchor_link(self, anchor_name, text):
link_tag = ConfluenceTag("link", attrib={"anchor": anchor_name})
body_tag = ConfluenceTag("plain-text-link-body", cdata=True)
body_tag.text = text
link_tag.append(body_tag)
return link_tag.render()

def footnote_ref(self, key, index):
i = str(index)
anchor = self._confluence_anchor("fnref-" + i)
link = self._confluence_anchor_link("fn-" + i, i)
return anchor + "<sup>" + link + "</sup>"

def footnote_item(self, text, key, index):
i = str(index)
anchor = self._confluence_anchor("fn-" + i)
back = self._confluence_anchor_link("fnref-" + i, "\u21a9")
text = text.rstrip()
if text.endswith("</p>"):
text = text[:-4] + back + "</p>"
else:
text = text + "\n" + back
return "<li>" + anchor + text + "</li>\n"

def footnotes(self, text):
return '<section class="footnotes">\n<ol>\n' + text + "</ol>\n</section>\n"

def _enable_latex_math_macro(self):
macro = self.structured_macro("enablelatexmath")
macro.append(self.parameter("hide", "true"))
return macro.render()

def inline_math(self, text):
self._has_math = True
macro = self.structured_macro("mathinline")
macro.append(self.parameter("body", text))
return macro.render().rstrip("\n")

def block_math(self, text):
self._has_math = True
root = self.structured_macro("mathblock")
root.append(self.plain_text_body(text))
return root.render()
40 changes: 38 additions & 2 deletions mdfluence/document.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,21 @@

import chardet
import mistune
from mistune.plugins.abbr import abbr
from mistune.plugins.def_list import def_list
from mistune.plugins.footnotes import footnotes
from mistune.plugins.formatting import (
insert,
mark,
strikethrough,
subscript,
superscript,
)
from mistune.plugins.math import math
from mistune.plugins.spoiler import spoiler
from mistune.plugins.table import table
from mistune.plugins.task_lists import task_lists
from mistune.plugins.url import url
import yaml
from yaml.parser import ParserError

Expand Down Expand Up @@ -301,13 +316,34 @@ def parse_page(
enable_relative_links: bool = False,
) -> Page:
renderer = ConfluenceRenderer(
use_xhtml=True,
strip_header=strip_header,
remove_text_newlines=remove_text_newlines,
enable_relative_links=enable_relative_links,
)
confluence_mistune = mistune.Markdown(renderer=renderer)
confluence_content = confluence_mistune("".join(markdown_lines))
for plugin in [
abbr,
def_list,
footnotes,
insert,
mark,
math,
spoiler,
strikethrough,
subscript,
superscript,
table,
task_lists,
url,
]:
plugin(confluence_mistune)
confluence_result = confluence_mistune("".join(markdown_lines))
if not isinstance(confluence_result, str):
raise TypeError("Expected string output from Markdown renderer")
confluence_content = confluence_result

if renderer._has_math:
confluence_content = renderer._enable_latex_math_macro() + confluence_content

page = Page(
title=renderer.title,
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ classifiers = [
dependencies = [
"rich-argparse>=1.0.0,<2",
"rich>=13.0,<16",
"mistune>=0.8.4,<1",
"mistune>=3.2.1",
"chardet>=5.1.0,<8",
"requests>=2.31.0,<3",
"PyYAML>=6.0.1,<7",
Expand Down
Loading
Loading