renderer.go

v0.1.0
Doc Versions Source
1
package confluence
2
3
import (
4
	"bytes"
5
	"fmt"
6
	"html"
7
	"strings"
8
9
	"github.com/yuin/goldmark/ast"
10
	east "github.com/yuin/goldmark/extension/ast"
11
	"github.com/yuin/goldmark/renderer"
12
	"github.com/yuin/goldmark/util"
13
)
14
15
// Renderer renders goldmark AST nodes into Confluence storage format XML.
16
type Renderer struct {
17
	taskIDCounter      int
18
	inTaskBody         bool
19
	inlineCommentDepth int
20
	pendingTableAttrs  string // stored from <!-- table-attrs: ... --> comment
21
	pendingCodeMacroID    string // stored from <!-- ac:code macro-id="..." --> comment
22
	pendingCodeAttrOrder string // stored from <!-- ac:code ... attr-order="..." --> comment
23
}
24
25
// NewRenderer creates a new Confluence storage format renderer.
26
func NewRenderer() renderer.NodeRenderer {
27
	return &Renderer{}
28
}
29
30
func (r *Renderer) nextTaskID() int {
31
	r.taskIDCounter++
32
	return r.taskIDCounter
33
}
34
35
// RegisterFuncs implements renderer.NodeRenderer.
36
func (r *Renderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
37
	// Block nodes
38
	reg.Register(ast.KindDocument, r.renderDocument)
39
	reg.Register(ast.KindHeading, r.renderHeading)
40
	reg.Register(ast.KindParagraph, r.renderParagraph)
41
	reg.Register(ast.KindTextBlock, r.renderTextBlock)
42
	reg.Register(ast.KindFencedCodeBlock, r.renderFencedCodeBlock)
43
	reg.Register(ast.KindCodeBlock, r.renderCodeBlock)
44
	reg.Register(ast.KindThematicBreak, r.renderThematicBreak)
45
	reg.Register(ast.KindBlockquote, r.renderBlockquote)
46
	reg.Register(ast.KindList, r.renderList)
47
	reg.Register(ast.KindListItem, r.renderListItem)
48
	reg.Register(ast.KindHTMLBlock, r.renderHTMLBlock)
49
50
	// Inline nodes
51
	reg.Register(ast.KindText, r.renderText)
52
	reg.Register(ast.KindString, r.renderString)
53
	reg.Register(ast.KindEmphasis, r.renderEmphasis)
54
	reg.Register(ast.KindCodeSpan, r.renderCodeSpan)
55
	reg.Register(ast.KindLink, r.renderLink)
56
	reg.Register(ast.KindAutoLink, r.renderAutoLink)
57
	reg.Register(ast.KindImage, r.renderImage)
58
	reg.Register(ast.KindRawHTML, r.renderRawHTML)
59
60
	// GFM extensions
61
	reg.Register(east.KindTable, r.renderTable)
62
	reg.Register(east.KindTableHeader, r.renderTableHeader)
63
	// Note: goldmark GFM has no KindTableBody
64
	reg.Register(east.KindTableRow, r.renderTableRow)
65
	reg.Register(east.KindTableCell, r.renderTableCell)
66
	reg.Register(east.KindStrikethrough, r.renderStrikethrough)
67
	reg.Register(east.KindTaskCheckBox, r.renderTaskCheckBox)
68
}
69
70
func (r *Renderer) renderDocument(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
71
	return ast.WalkContinue, nil
72
}
73
74
func (r *Renderer) renderHeading(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
75
	n := node.(*ast.Heading)
76
	tag := fmt.Sprintf("h%d", n.Level)
77
	if entering {
78
		fmt.Fprintf(w, "<%s>", tag)
79
	} else {
80
		fmt.Fprintf(w, "</%s>\n", tag)
81
	}
82
	return ast.WalkContinue, nil
83
}
84
85
func (r *Renderer) renderParagraph(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
86
	// Inside task items, don't wrap in <p> - the task-body handles it
87
	if r.inTaskBody {
88
		if !entering {
89
			w.WriteString("</ac:task-body>\n")
90
			r.inTaskBody = false
91
		}
92
		return ast.WalkContinue, nil
93
	}
94
	if entering {
95
		w.WriteString("<p>")
96
	} else {
97
		w.WriteString("</p>\n")
98
	}
99
	return ast.WalkContinue, nil
100
}
101
102
func (r *Renderer) renderTextBlock(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
103
	if !entering {
104
		if r.inTaskBody {
105
			w.WriteString("</ac:task-body>\n")
106
			r.inTaskBody = false
107
		} else if _, ok := node.Parent().(*ast.ListItem); !ok {
108
			w.WriteString("\n")
109
		}
110
	}
111
	return ast.WalkContinue, nil
112
}
113
114
func (r *Renderer) renderFencedCodeBlock(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
115
	if !entering {
116
		return ast.WalkContinue, nil
117
	}
118
	n := node.(*ast.FencedCodeBlock)
119
	language := ""
120
	if n.Info != nil {
121
		lang := n.Info.Segment.Value(source)
122
		// Take first word only (e.g., "go title=foo" -> "go")
123
		if idx := bytes.IndexByte(lang, ' '); idx > 0 {
124
			lang = lang[:idx]
125
		}
126
		language = string(lang)
127
	}
128
129
	var buf bytes.Buffer
130
	for i := 0; i < n.Lines().Len(); i++ {
131
		line := n.Lines().At(i)
132
		buf.Write(line.Value(source))
133
	}
134
	// Remove trailing newline from code content
135
	code := strings.TrimRight(buf.String(), "\n")
136
137
	w.WriteString(CodeMacroWithID(language, code, r.pendingCodeMacroID, r.pendingCodeAttrOrder))
138
	r.pendingCodeMacroID = ""
139
	r.pendingCodeAttrOrder = ""
140
	w.WriteString("\n")
141
	return ast.WalkSkipChildren, nil
142
}
143
144
func (r *Renderer) renderCodeBlock(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
145
	if !entering {
146
		return ast.WalkContinue, nil
147
	}
148
	n := node.(*ast.CodeBlock)
149
	var buf bytes.Buffer
150
	for i := 0; i < n.Lines().Len(); i++ {
151
		line := n.Lines().At(i)
152
		buf.Write(line.Value(source))
153
	}
154
	code := strings.TrimRight(buf.String(), "\n")
155
	w.WriteString(CodeMacro("", code))
156
	w.WriteString("\n")
157
	return ast.WalkSkipChildren, nil
158
}
159
160
func (r *Renderer) renderThematicBreak(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
161
	if entering {
162
		w.WriteString("<hr />\n")
163
	}
164
	return ast.WalkContinue, nil
165
}
166
167
func (r *Renderer) renderBlockquote(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
168
	if entering {
169
		w.WriteString(`<ac:structured-macro ac:name="info" ac:schema-version="1"><ac:rich-text-body>`)
170
	} else {
171
		w.WriteString(`</ac:rich-text-body></ac:structured-macro>` + "\n")
172
	}
173
	return ast.WalkContinue, nil
174
}
175
176
func (r *Renderer) renderList(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
177
	n := node.(*ast.List)
178
	// Check if this is a task list (first item has a TaskCheckBox child)
179
	if isTaskList(n) {
180
		if entering {
181
			w.WriteString("<ac:task-list>\n")
182
		} else {
183
			w.WriteString("</ac:task-list>\n")
184
		}
185
		return ast.WalkContinue, nil
186
	}
187
	tag := "ul"
188
	if n.IsOrdered() {
189
		tag = "ol"
190
	}
191
	if entering {
192
		fmt.Fprintf(w, "<%s>\n", tag)
193
	} else {
194
		fmt.Fprintf(w, "</%s>\n", tag)
195
	}
196
	return ast.WalkContinue, nil
197
}
198
199
// isTaskList checks if a list node contains task checkbox items.
200
func isTaskList(n *ast.List) bool {
201
	for child := n.FirstChild(); child != nil; child = child.NextSibling() {
202
		if li, ok := child.(*ast.ListItem); ok {
203
			for c := li.FirstChild(); c != nil; c = c.NextSibling() {
204
				if hasCheckbox(c) {
205
					return true
206
				}
207
			}
208
		}
209
	}
210
	return false
211
}
212
213
func hasCheckbox(n ast.Node) bool {
214
	for c := n.FirstChild(); c != nil; c = c.NextSibling() {
215
		if c.Kind() == east.KindTaskCheckBox {
216
			return true
217
		}
218
	}
219
	return false
220
}
221
222
func (r *Renderer) renderListItem(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
223
	// Check if parent is a task list
224
	if parent, ok := node.Parent().(*ast.List); ok && isTaskList(parent) {
225
		if entering {
226
			w.WriteString("<ac:task>\n")
227
			fmt.Fprintf(w, "<ac:task-id>%d</ac:task-id>\n", r.nextTaskID())
228
		} else {
229
			w.WriteString("</ac:task>\n")
230
		}
231
		return ast.WalkContinue, nil
232
	}
233
	if entering {
234
		w.WriteString("<li>")
235
	} else {
236
		w.WriteString("</li>\n")
237
	}
238
	return ast.WalkContinue, nil
239
}
240
241
func (r *Renderer) renderHTMLBlock(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
242
	if !entering {
243
		return ast.WalkContinue, nil
244
	}
245
	n := node.(*ast.HTMLBlock)
246
	for i := 0; i < n.Lines().Len(); i++ {
247
		line := n.Lines().At(i)
248
		raw := strings.TrimRight(string(line.Value(source)), "\n")
249
		if converted, ok := r.convertComment(raw); ok {
250
			w.WriteString(converted)
251
			w.WriteString("\n")
252
		} else {
253
			w.WriteString(r.convertRawSpan(raw))
254
			w.WriteString("\n")
255
		}
256
	}
257
	return ast.WalkSkipChildren, nil
258
}
259
260
// convertComment converts preserved HTML comments back to Confluence XML.
261
func (r *Renderer) convertComment(raw string) (string, bool) {
262
	trimmed := strings.TrimSpace(raw)
263
264
	switch {
265
	// Layout
266
	case trimmed == "<!-- ac:layout -->":
267
		return "<ac:layout>", true
268
	case trimmed == "<!-- /ac:layout -->":
269
		return "</ac:layout>", true
270
	case trimmed == "<!-- ac:layout-cell -->":
271
		return "<ac:layout-cell>", true
272
	case trimmed == "<!-- /ac:layout-cell -->":
273
		return "</ac:layout-cell>", true
274
	case trimmed == "<!-- /ac:layout-section -->":
275
		return "</ac:layout-section>", true
276
	case strings.HasPrefix(trimmed, "<!-- ac:layout-section"):
277
		sectionType := extractCommentAttr(trimmed, "type")
278
		if sectionType != "" {
279
			return `<ac:layout-section ac:type="` + sectionType + `">`, true
280
		}
281
		return "<ac:layout-section>", true
282
283
	// TOC macro
284
	case strings.HasPrefix(trimmed, "<!-- ac:toc"):
285
		macroID := extractCommentAttr(trimmed, "macro-id")
286
		if macroID != "" {
287
			return `<ac:structured-macro ac:macro-id="` + macroID + `" ac:name="toc" ac:schema-version="1"/>`, true
288
		}
289
		return `<ac:structured-macro ac:name="toc" ac:schema-version="1"/>`, true
290
291
	// Table attributes — store for next table
292
	case strings.HasPrefix(trimmed, "<!-- table-attrs:"):
293
		r.pendingTableAttrs = trimmed
294
		return "", true
295
296
	// Code macro-id — store for next code block
297
	case strings.HasPrefix(trimmed, "<!-- ac:code"):
298
		macroID := extractCommentAttr(trimmed, "macro-id")
299
		attrOrder := extractCommentAttr(trimmed, "attr-order")
300
		if macroID != "" {
301
			r.pendingCodeMacroID = macroID
302
		}
303
		r.pendingCodeAttrOrder = attrOrder
304
		return "", true
305
	}
306
307
	return "", false
308
}
309
310
// extractCommentAttr extracts a key="value" from an HTML comment.
311
func extractCommentAttr(comment, key string) string {
312
	search := key + `="`
313
	idx := strings.Index(comment, search)
314
	if idx == -1 {
315
		return ""
316
	}
317
	start := idx + len(search)
318
	end := strings.Index(comment[start:], `"`)
319
	if end == -1 {
320
		return ""
321
	}
322
	return comment[start : start+end]
323
}
324
325
func (r *Renderer) renderText(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
326
	if !entering {
327
		return ast.WalkContinue, nil
328
	}
329
	n := node.(*ast.Text)
330
	w.WriteString(html.EscapeString(string(n.Segment.Value(source))))
331
	if n.HardLineBreak() {
332
		w.WriteString("<br/>")
333
	} else if n.SoftLineBreak() {
334
		w.WriteString("\n")
335
	}
336
	return ast.WalkContinue, nil
337
}
338
339
func (r *Renderer) renderString(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
340
	if !entering {
341
		return ast.WalkContinue, nil
342
	}
343
	n := node.(*ast.String)
344
	w.Write(n.Value)
345
	return ast.WalkContinue, nil
346
}
347
348
func (r *Renderer) renderEmphasis(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
349
	n := node.(*ast.Emphasis)
350
	tag := "em"
351
	if n.Level == 2 {
352
		tag = "strong"
353
	}
354
	if entering {
355
		fmt.Fprintf(w, "<%s>", tag)
356
	} else {
357
		fmt.Fprintf(w, "</%s>", tag)
358
	}
359
	return ast.WalkContinue, nil
360
}
361
362
func (r *Renderer) renderCodeSpan(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
363
	if entering {
364
		w.WriteString("<code>")
365
	} else {
366
		w.WriteString("</code>")
367
	}
368
	return ast.WalkContinue, nil
369
}
370
371
func (r *Renderer) renderLink(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
372
	n := node.(*ast.Link)
373
	if entering {
374
		fmt.Fprintf(w, `<a href="%s">`, html.EscapeString(string(n.Destination)))
375
	} else {
376
		w.WriteString("</a>")
377
	}
378
	return ast.WalkContinue, nil
379
}
380
381
func (r *Renderer) renderAutoLink(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
382
	n := node.(*ast.AutoLink)
383
	if entering {
384
		url := string(n.URL(source))
385
		fmt.Fprintf(w, `<a href="%s">%s</a>`, html.EscapeString(url), html.EscapeString(url))
386
	}
387
	return ast.WalkContinue, nil
388
}
389
390
func (r *Renderer) renderImage(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
391
	if !entering {
392
		return ast.WalkContinue, nil
393
	}
394
	n := node.(*ast.Image)
395
	url := string(n.Destination)
396
	alt := nodeText(n, source)
397
	if alt != "" {
398
		fmt.Fprintf(w, `<ac:image ac:alt="%s"><ri:url ri:value="%s"/></ac:image>`,
399
			html.EscapeString(alt), html.EscapeString(url))
400
	} else {
401
		fmt.Fprintf(w, `<ac:image><ri:url ri:value="%s"/></ac:image>`, html.EscapeString(url))
402
	}
403
	return ast.WalkSkipChildren, nil
404
}
405
406
func (r *Renderer) renderRawHTML(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
407
	if !entering {
408
		return ast.WalkContinue, nil
409
	}
410
	n := node.(*ast.RawHTML)
411
	for i := 0; i < n.Segments.Len(); i++ {
412
		seg := n.Segments.At(i)
413
		raw := string(seg.Value(source))
414
		w.WriteString(r.convertRawSpan(raw))
415
	}
416
	return ast.WalkContinue, nil
417
}
418
419
// convertRawSpan converts round-trip spans back to Confluence XML.
420
func (r *Renderer) convertRawSpan(raw string) string {
421
	if !strings.HasPrefix(raw, "<span ") {
422
		// Handle closing tag — convert back if we're inside an inline comment
423
		if raw == "</span>" && r.inlineCommentDepth > 0 {
424
			r.inlineCommentDepth--
425
			return "</ac:inline-comment-marker>"
426
		}
427
		return raw
428
	}
429
430
	// <span data-inline-comment="ref">
431
	if strings.Contains(raw, "data-inline-comment=") {
432
		ref := extractAttrValue(raw, "data-inline-comment")
433
		if ref != "" {
434
			r.inlineCommentDepth++
435
			return `<ac:inline-comment-marker ac:ref="` + ref + `">`
436
		}
437
	}
438
439
	// <span data-user-key="key"/>
440
	if strings.Contains(raw, "data-user-key=") {
441
		key := extractAttrValue(raw, "data-user-key")
442
		if key != "" {
443
			return `<ac:link><ri:user ri:userkey="` + key + `"/></ac:link>`
444
		}
445
	}
446
447
	// <span data-attachment="filename"/>
448
	if strings.Contains(raw, "data-attachment=") {
449
		filename := extractAttrValue(raw, "data-attachment")
450
		if filename != "" {
451
			alt := extractAttrValue(raw, "data-alt")
452
			if alt != "" {
453
				return `<ac:image ac:alt="` + alt + `"><ri:attachment ri:filename="` + filename + `"/></ac:image>`
454
			}
455
			return `<ac:image><ri:attachment ri:filename="` + filename + `"/></ac:image>`
456
		}
457
	}
458
459
	return raw
460
}
461
462
func extractAttrValue(tag, attr string) string {
463
	key := attr + `="`
464
	idx := strings.Index(tag, key)
465
	if idx == -1 {
466
		return ""
467
	}
468
	start := idx + len(key)
469
	end := strings.Index(tag[start:], `"`)
470
	if end == -1 {
471
		return ""
472
	}
473
	return tag[start : start+end]
474
}
475
476
// GFM Table support
477
478
func (r *Renderer) renderTable(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
479
	if entering {
480
		if r.pendingTableAttrs != "" {
481
			r.writeTableFromAttrs(w)
482
		} else {
483
			w.WriteString("<table>\n<tbody>\n")
484
		}
485
	} else {
486
		w.WriteString("</tbody>\n</table>\n")
487
	}
488
	return ast.WalkContinue, nil
489
}
490
491
// writeTableFromAttrs reconstructs <table> with class/style/colgroup from stored comment.
492
func (r *Renderer) writeTableFromAttrs(w util.BufWriter) {
493
	attrs := r.pendingTableAttrs
494
	r.pendingTableAttrs = ""
495
496
	cls := extractCommentAttr(attrs, "class")
497
	style := extractCommentAttr(attrs, "style")
498
499
	w.WriteString("<table")
500
	if cls != "" {
501
		fmt.Fprintf(w, ` class="%s"`, cls)
502
	}
503
	if style != "" {
504
		fmt.Fprintf(w, ` style="%s"`, style)
505
	}
506
	w.WriteString(">")
507
508
	// Extract colgroup
509
	colsStart := strings.Index(attrs, "cols=[")
510
	if colsStart != -1 {
511
		colsEnd := strings.Index(attrs[colsStart:], "]")
512
		if colsEnd != -1 {
513
			colsStr := attrs[colsStart+len("cols=[") : colsStart+colsEnd]
514
			colStyles := strings.Split(colsStr, "|")
515
			w.WriteString("<colgroup>")
516
			for _, cs := range colStyles {
517
				if cs != "" {
518
					fmt.Fprintf(w, `<col style="%s"/>`, cs)
519
				}
520
			}
521
			w.WriteString("</colgroup>")
522
		}
523
	}
524
	w.WriteString("\n<tbody>\n")
525
}
526
527
func (r *Renderer) renderTableHeader(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
528
	if entering {
529
		w.WriteString("<tr>\n")
530
	} else {
531
		w.WriteString("</tr>\n")
532
	}
533
	return ast.WalkContinue, nil
534
}
535
536
func (r *Renderer) renderTableRow(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
537
	if entering {
538
		w.WriteString("<tr>\n")
539
	} else {
540
		w.WriteString("</tr>\n")
541
	}
542
	return ast.WalkContinue, nil
543
}
544
545
func (r *Renderer) renderTableCell(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
546
	n := node.(*east.TableCell)
547
	tag := "td"
548
	if n.Parent().Kind() == east.KindTableHeader {
549
		tag = "th"
550
	}
551
	if entering {
552
		fmt.Fprintf(w, "<%s><p>", tag)
553
	} else {
554
		fmt.Fprintf(w, "</p></%s>\n", tag)
555
	}
556
	return ast.WalkContinue, nil
557
}
558
559
func (r *Renderer) renderStrikethrough(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
560
	if entering {
561
		w.WriteString("<del>")
562
	} else {
563
		w.WriteString("</del>")
564
	}
565
	return ast.WalkContinue, nil
566
}
567
568
func (r *Renderer) renderTaskCheckBox(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
569
	if !entering {
570
		return ast.WalkContinue, nil
571
	}
572
	n := node.(*east.TaskCheckBox)
573
	if n.IsChecked {
574
		w.WriteString("<ac:task-status>complete</ac:task-status>\n")
575
	} else {
576
		w.WriteString("<ac:task-status>incomplete</ac:task-status>\n")
577
	}
578
	w.WriteString("<ac:task-body>")
579
	r.inTaskBody = true
580
	return ast.WalkContinue, nil
581
}
582
583
// nodeText extracts plain text from a node tree.
584
func nodeText(n ast.Node, source []byte) string {
585
	var buf bytes.Buffer
586
	for c := n.FirstChild(); c != nil; c = c.NextSibling() {
587
		if t, ok := c.(*ast.Text); ok {
588
			buf.Write(t.Segment.Value(source))
589
		}
590
	}
591
	return buf.String()
592
}
593

Source Files