Bugs identified by exporting flow_ip_release.org and comparing output
against expected Confluence wiki markup, then verifying rendering via
the Confluence REST API.
Verbatim/code spans ({{...}}):
- Replace brace-escaping with new org-confluence--monospace helper that
correctly escapes: leading "--" → \-\- (prevents strikethrough
mis-parse),
* → \* (prevents bold mis-parse; even a lone trailing * closes the span
early without escaping), [ → \[ and ] → \] (prevents wiki-link
mis-parse)
Anchor targets and internal links:
- Add org-confluence-target: emits {anchor:NAME} macro for <<target>>
- Add org-confluence-radio-target: emits {anchor:NAME}contents
- Add org-confluence--sanitize-anchor: replaces ":" with " -" to prevent
the wiki link parser treating "prefix:rest" as "spaceKey:pageTitle"
- Add fuzzy link branch in org-confluence-link emitting [desc|#anchor]
- Register target, radio-target, verbatim in translate-alist
Inline markup:
- bold, italic, strike-through, underline: collapse newlines to spaces
(Confluence inline markup cannot span lines)
List items:
- org-confluence-item: fix nil contents crash; collapse soft-wrapped
continuation lines to a single line (Confluence list items must be
one line); use Confluence emoticons for checkboxes: (/) checked,
(x) unchecked, (!) partial
Links:
- org-confluence-link: collapse newlines in multi-line descriptions
Headings:
- org-confluence-headline: insert blank line between heading and body
Template:
- Emit {toc:maxLevel=N} when #+OPTIONS: toc:N specifies a depth
- Collapse runs of 3+ blank lines to a single blank line
- Do not emit #+TITLE or #+DATE (redundant when page title is set
on the Confluence page object via the API)
---
lisp/ox-confluence.el | 116 ++++++++++++++++++++++++++++++++++--------
1 file changed, 94 insertions(+), 22 deletions(-)
diff --git a/lisp/ox-confluence.el b/lisp/ox-confluence.el
index cf712eb..d5d6637 100644
--- a/lisp/ox-confluence.el
+++ b/lisp/ox-confluence.el
@@ -60,6 +60,8 @@
(template . org-confluence-template)
(timestamp . org-confluence-timestamp)
(underline . org-confluence-underline)
+ (radio-target . org-confluence-radio-target)
+ (target . org-confluence-target)
(verbatim . org-confluence-verbatim))
:menu-entry
'(?f "Export to Confluence"
@@ -78,7 +80,8 @@
;; All the functions we use
(defun org-confluence-bold (_ contents _)
- (format "*%s*" contents))
+ ;; Collapse newlines — Confluence bold cannot span lines.
+ (format "*%s*" (replace-regexp-in-string "\n" " " (org-trim contents))))
(defun org-confluence-empty (_ _ _)
"")
@@ -89,23 +92,28 @@
(org-confluence--block "none" "Confluence" content)))
(defun org-confluence-italic (_ contents _)
- (format "_%s_" contents))
+ (format "_%s_" (replace-regexp-in-string "\n" " " (org-trim contents))))
(defun org-confluence-item (item contents info)
- (let ((list-type (org-element-property :type (org-export-get-parent item))))
+ (let* ((list-type (org-element-property :type (org-export-get-parent item)))
+ (checkbox (org-element-property :checkbox item))
+ (checkbox-str (pcase checkbox
+ (`on "(/) ")
+ (`off "(x) ")
+ (`trans "(!) "))))
(concat
(make-string (1+ (org-confluence--li-depth item))
(if (eq list-type 'ordered) ?\# ?\-))
" "
- (pcase (org-element-property :checkbox item)
- (`on "*{{(X)}}* ")
- (`off "*{{( )}}* ")
- (`trans "*{{(\\-)}}* "))
+ checkbox-str
(when (eq list-type 'descriptive)
(concat "*"
(org-export-data (org-element-property :tag item) info)
"* - "))
- (org-trim contents))))
+ ;; Confluence list items must be a single line; collapse any
+ ;; wrapped continuation text and guard against nil contents.
+ (replace-regexp-in-string "[ \t]*\n[ \t]*" " "
+ (org-trim (or contents ""))))))
(defun org-confluence-fixed-width (fixed-width _ _)
(org-confluence--block
@@ -113,13 +121,33 @@
"Confluence"
(org-trim (org-element-property :value fixed-width))))
+(defun org-confluence--monospace (content)
+ "Wrap CONTENT in Confluence {{...}} monospace markup.
+Escapes characters that the Confluence wiki parser mis-interprets
+even inside {{...}} spans:
+ leading \"--\" → \\-\\- (prevents strikethrough-start mis-parse)
+ * → \\* (prevents bold mis-parse; even a lone trailing *
+ causes Confluence to close monospace early)
+ [ → \\[ (prevents wiki-link-start mis-parse)
+ ] → \\] (prevents wiki-link-end mis-parse)"
+ (let* ((s (if (string-prefix-p "--" content)
+ (concat "\\-\\-" (substring content 2))
+ content))
+ ;; Escape * unconditionally: even a lone trailing * (e.g.
--allow-new-*)
+ ;; causes the Confluence parser to close the monospace span early and
+ ;; start a bold region, producing broken output like
+ ;; <code>--allow-new-</code><strong> flags are *off</strong>
+ ;; The \* keeps the * inside the monospace where it belongs.
+ (s (replace-regexp-in-string "\\*" "\\\\*" s))
+ (s (replace-regexp-in-string "\\[" "\\\\[" s))
+ (s (replace-regexp-in-string "\\]" "\\\\]" s)))
+ (format "\{\{%s\}\}" s)))
+
(defun org-confluence-verbatim (verbatim _ _)
- (let ((content (org-element-property :value verbatim)))
- (format "\{\{%s\}\}" (string-replace "{" "\\{" content))))
+ (org-confluence--monospace (org-element-property :value verbatim)))
(defun org-confluence-code (code _ _)
- (let ((content (org-element-property :value code)))
- (format "\{\{%s\}\}" (string-replace "{" "\\{" content))))
+ (org-confluence--monospace (org-element-property :value code)))
(defun org-confluence-headline (headline contents info)
(let* (;; (low-level-rank (org-export-low-level-p headline info))
@@ -132,22 +160,58 @@
(string= todo ""))
""
(format "*{{%s}}* " todo))))
- (format "h%s. %s%s\n%s" level todo-text text
- (if (org-string-nw-p contents) contents ""))))
+ (format "h%s. %s%s\n%s"
+ level
+ todo-text
+ text
+ ;; Blank line between heading and body improves readability.
+ (if (org-string-nw-p contents) (concat "\n" contents) ""))))
(defun org-confluence-link (link desc _)
(if (string= "radio" (org-element-property :type link))
desc
- (let ((raw-link (org-element-property :raw-link link)))
+ (let* ((type (org-element-property :type link))
+ (raw-link (org-element-property :raw-link link))
+ ;; Normalise multi-line descriptions to a single line.
+ (desc (when desc
+ (replace-regexp-in-string "\n" " " (org-trim desc)))))
(concat "["
- (when (org-string-nw-p desc) (format "%s|" desc))
(cond
+ ;; Fuzzy links are internal org targets; emit [text|#anchor].
+ ;; Sanitize the anchor name to match org-confluence-target.
+ ((string= type "fuzzy")
+ (let ((anchor (org-confluence--sanitize-anchor raw-link)))
+ (if (org-string-nw-p desc)
+ (format "%s|#%s" desc anchor)
+ (format "%s|#%s" raw-link anchor))))
+ ;; Explicit confluence: scheme — strip the prefix.
((string-match "^confluence:" raw-link)
- (replace-regexp-in-string "^confluence:" "" raw-link))
+ (concat (when (org-string-nw-p desc) (format "%s|" desc))
+ (replace-regexp-in-string "^confluence:" "" raw-link)))
+ ;; External URL or any other link type.
(t
- raw-link))
+ (concat (when (org-string-nw-p desc) (format "%s|" desc))
+ raw-link)))
"]"))))
+(defun org-confluence--sanitize-anchor (name)
+ "Sanitize NAME for use in Confluence anchor macros and wiki links.
+Colons are replaced with \" -\" because the wiki link parser treats
+\"prefix:rest\" as \"spaceKey:pageTitle\", breaking in-page anchor links."
+ (replace-regexp-in-string ":" " -" name))
+
+(defun org-confluence-target (target _ _)
+ "Transcode a TARGET element to a Confluence {anchor:} macro."
+ (let ((name (org-confluence--sanitize-anchor
+ (org-element-property :value target))))
+ (format "\{anchor:%s\}" name)))
+
+(defun org-confluence-radio-target (radio-target contents _)
+ "Transcode a RADIO-TARGET element to a Confluence {anchor:} macro."
+ (let ((name (org-confluence--sanitize-anchor
+ (org-element-property :value radio-target))))
+ (format "\{anchor:%s\}%s" name contents)))
+
(defun org-confluence-paragraph (_ contents _)
"Transcode PARAGRAPH element for Confluence.
CONTENTS is the paragraph contents. INFO is a plist used as
@@ -172,7 +236,7 @@ a communication channel."
(org-confluence--block language "Emacs" content)))
(defun org-confluence-strike-through (_ contents _)
- (format "-%s-" contents))
+ (format "-%s-" (replace-regexp-in-string "\n" " " (org-trim contents))))
(defun org-confluence-table (_ contents _)
contents)
@@ -191,8 +255,16 @@ a communication channel."
"|")))
(defun org-confluence-template (contents info)
- (let ((depth (plist-get info :with-toc)))
- (concat (when depth "\{toc\}\n\n") contents)))
+ (let* ((depth (plist-get info :with-toc))
+ ;; Collapse runs of 3+ newlines from ascii-backend inter-section
+ ;; spacing down to a single blank line.
+ (body (replace-regexp-in-string "\n\n\n+" "\n\n" contents)))
+ (concat
+ (when depth
+ (if (wholenump depth)
+ (format "\{toc:maxLevel=%d\}\n\n" depth)
+ "\{toc\}\n\n"))
+ body)))
(defun org-confluence-timestamp (timestamp _contents _info)
"Transcode a TIMESTAMP object from Org to Confluence.
@@ -203,7 +275,7 @@ CONTENTS and INFO are ignored."
translated)))
(defun org-confluence-underline (_ contents _)
- (format "+%s+" contents))
+ (format "+%s+" (replace-regexp-in-string "\n" " " (org-trim contents))))
(defun org-confluence--block (language theme contents)
(concat "\{code:theme=" theme
--
2.30.1