Skip to content

Commit 9502952

Browse files
committed
feat: Add code blocks transformation to Confluence code macro
Implements proper rendering of markdown code blocks in Confluence by converting them to structured code macros with syntax highlighting. This addresses issue #65 where code blocks were appearing as inline text instead of formatted code blocks. The feature is enabled by default and can be disabled for compatibility with older Confluence versions via the confluence.rehype.codeBlocks configuration option.
1 parent a0c87be commit 9502952

File tree

7 files changed

+429
-3
lines changed

7 files changed

+429
-3
lines changed

components/markdown-confluence-sync/CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

88
#### Added
9+
10+
* feat: Add code blocks transformation to Confluence code macro format.
11+
Code blocks are now converted to Confluence's structured code macro
12+
with syntax highlighting support. This feature is enabled by default
13+
and can be disabled via `confluence.rehype.codeBlocks` configuration
14+
option for compatibility with older Confluence versions.
15+
916
#### Changed
1017
#### Fixed
1118
#### Deprecated

components/markdown-confluence-sync/README.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -300,6 +300,8 @@ The namespace for the configuration of this library is `markdown-confluence-sync
300300
| `confluence.rootPageName` | `string` | Customize Confluence page titles by adding a prefix to all of them for improved organization and clarity | |
301301
| `confluence.noticeMessage` | `string` | Notice message to add at the beginning of the Confluence pages. | |
302302
| `confluence.noticeTemplate` | `string` | Template string to use for the notice message. | |
303+
| `confluence.rehype` | `object` | Rehype plugin options to customize markdown to Confluence HTML conversion. | |
304+
| `confluence.rehype.codeBlocks` | `boolean` | Enable conversion of code blocks to Confluence code macro format with syntax highlighting. When disabled, code blocks remain as plain HTML pre/code tags. | `true` |
303305
| `confluence.dryRun` | `boolean` | Log create, update or delete requests to Confluence instead of really making them | `false` |
304306
| `dryRun` | `boolean` | Process markdown files without sending them to `confluence-sync`. Useful to early detection of possible errors in configuration, etc. Note that, requests that would be made to Confluence won't be logged, use `confluence.dryRun` for that, which also connects to Confluence to calculate the requests to do | `false` |
305307
| `config.readArguments` | `boolean` | Read configuration from arguments or not | `false` |
@@ -493,6 +495,32 @@ Apart of supporting the most common markdown features, the library also supports
493495
<ac:rich-text-body><p>This is the content of the details.</p></ac:rich-text-body>
494496
</ac:structured-macro>
495497
```
498+
* Code blocks - Markdown fenced code blocks are converted to
499+
Confluence code macro format with syntax highlighting support. This
500+
feature is enabled by default but can be disabled via the
501+
`confluence.rehype.codeBlocks` configuration option.
502+
* The plugin converts fenced code blocks to Confluence's
503+
`<ac:structured-macro ac:name="code">` format.
504+
* Language syntax highlighting is preserved when specified in the
505+
markdown code fence.
506+
* This feature can be disabled for compatibility with older
507+
Confluence versions by setting
508+
`confluence.rehype.codeBlocks: false`.
509+
* For example, the following markdown code block:
510+
````markdown
511+
```javascript
512+
const hello = "world";
513+
console.log(hello);
514+
```
515+
````
516+
will be converted to:
517+
```markdown
518+
<ac:structured-macro ac:name="code">
519+
<ac:parameter ac:name="language">javascript</ac:parameter>
520+
<ac:plain-text-body><![CDATA[const hello = "world";
521+
console.log(hello);]]></ac:plain-text-body>
522+
</ac:structured-macro>
523+
```
496524
497525
### Unsupported features
498526

components/markdown-confluence-sync/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "@telefonica/markdown-confluence-sync",
33
"description": "Creates/updates/deletes Confluence pages based on markdown files in a directory. Supports Mermaid diagrams and per-page configuration using frontmatter metadata. Works great with Docusaurus",
4-
"version": "2.2.0",
4+
"version": "2.3.0",
55
"license": "Apache-2.0",
66
"author": "Telefónica Innovación Digital",
77
"repository": {

components/markdown-confluence-sync/src/lib/confluence/transformer/ConfluencePageTransformer.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import { InvalidTemplateError } from "./errors/InvalidTemplateError.js";
2727
import rehypeAddAttachmentsImages from "./support/rehype/rehype-add-attachments-images.js";
2828
import type { ImagesMetadata } from "./support/rehype/rehype-add-attachments-images.types.js";
2929
import rehypeAddNotice from "./support/rehype/rehype-add-notice.js";
30+
import rehypeReplaceCodeBlocks from "./support/rehype/rehype-replace-code-blocks.js";
3031
import rehypeReplaceDetails from "./support/rehype/rehype-replace-details.js";
3132
import rehypeReplaceImgTags from "./support/rehype/rehype-replace-img-tags.js";
3233
import rehypeReplaceInternalReferences from "./support/rehype/rehype-replace-internal-references.js";
@@ -50,13 +51,15 @@ export const ConfluencePageTransformer: ConfluencePageTransformerConstructor = c
5051
private readonly _rootPageName?: string;
5152
private readonly _spaceKey: string;
5253
private readonly _logger?: LoggerInterface;
54+
private readonly _rehypeCodeBlocksEnabled: boolean;
5355

5456
constructor({
5557
noticeMessage,
5658
noticeTemplate,
5759
rootPageName,
5860
spaceKey,
5961
logger,
62+
rehype,
6063
}: ConfluencePageTransformerOptions) {
6164
this._noticeMessage = noticeMessage;
6265
this._noticeTemplateRaw = noticeTemplate;
@@ -66,6 +69,7 @@ export const ConfluencePageTransformer: ConfluencePageTransformerConstructor = c
6669
this._rootPageName = rootPageName;
6770
this._spaceKey = spaceKey;
6871
this._logger = logger;
72+
this._rehypeCodeBlocksEnabled = rehype?.codeBlocks ?? true;
6973
}
7074

7175
public async transform(
@@ -88,7 +92,7 @@ export const ConfluencePageTransformer: ConfluencePageTransformerConstructor = c
8892
DEFAULT_MERMAID_DIAGRAMS_LOCATION,
8993
);
9094
try {
91-
const content = remark()
95+
let processor = remark()
9296
.use(remarkGfm)
9397
.use(remarkFrontmatter)
9498
.use(remarkRemoveFootnotes)
@@ -101,7 +105,14 @@ export const ConfluencePageTransformer: ConfluencePageTransformerConstructor = c
101105
.use(rehypeAddNotice, { noticeMessage })
102106
.use(rehypeReplaceDetails)
103107
.use(rehypeReplaceStrikethrough)
104-
.use(rehypeReplaceTaskList)
108+
.use(rehypeReplaceTaskList);
109+
110+
// Conditionally add code blocks plugin
111+
if (this._rehypeCodeBlocksEnabled) {
112+
processor = processor.use(rehypeReplaceCodeBlocks);
113+
}
114+
115+
const content = processor
105116
.use(rehypeAddAttachmentsImages)
106117
.use(rehypeReplaceImgTags)
107118
.use(rehypeReplaceInternalReferences, {

components/markdown-confluence-sync/src/lib/confluence/transformer/ConfluencePageTransformer.types.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,16 @@ import type { ConfluenceInputPage } from "@telefonica/confluence-sync";
66

77
import type { ConfluenceSyncPage } from "../ConfluenceSync.types.js";
88

9+
export interface RehypePluginOptions {
10+
/**
11+
* Enable code blocks transformation to Confluence code macro.
12+
* When enabled, markdown code blocks will be converted to Confluence's
13+
* structured code macro format with syntax highlighting support.
14+
* @default true
15+
*/
16+
codeBlocks?: boolean;
17+
}
18+
919
export interface ConfluencePageTransformerOptions {
1020
/** Confluence page notice message */
1121
noticeMessage?: string;
@@ -24,6 +34,8 @@ export interface ConfluencePageTransformerOptions {
2434
spaceKey: string;
2535
/** Logger */
2636
logger?: LoggerInterface;
37+
/** Rehype plugin options */
38+
rehype?: RehypePluginOptions;
2739
}
2840

2941
/** Creates a ConfluencePageTransformer interface */
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
// SPDX-FileCopyrightText: 2025 Telefónica Innovación Digital
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
import type { Element as HastElement, Root, Text as HastText } from "hast";
5+
import type { Plugin as UnifiedPlugin } from "unified";
6+
7+
import { replace } from "../../../../support/unist/unist-util-replace.js";
8+
9+
/**
10+
* UnifiedPlugin to replace `<pre><code>` HastElements with Confluence's
11+
* structured code macro format.
12+
*
13+
* @see {@link https://developer.atlassian.com/server/confluence/confluence-storage-format/ | Confluence Storage Format }
14+
*
15+
* @example
16+
* <pre><code class="language-javascript">const x = 42;</code></pre>
17+
* // becomes
18+
* <ac:structured-macro ac:name="code">
19+
* <ac:parameter ac:name="language">javascript</ac:parameter>
20+
* <ac:plain-text-body><![CDATA[const x = 42;]]></ac:plain-text-body>
21+
* </ac:structured-macro>
22+
*/
23+
const rehypeReplaceCodeBlocks: UnifiedPlugin<[], Root> =
24+
function rehypeReplaceCodeBlocks() {
25+
return function transformer(tree) {
26+
replace(tree, { type: "element", tagName: "pre" }, (node) => {
27+
// Check if this pre element contains a code element
28+
const codeElement = node.children.find(
29+
(child) =>
30+
child.type === "element" &&
31+
(child as HastElement).tagName === "code",
32+
) as HastElement | undefined;
33+
34+
if (!codeElement) {
35+
// If there's no code element, return the pre element unchanged
36+
return node;
37+
}
38+
39+
// Extract the language from the code element's className
40+
const language = extractLanguage(codeElement);
41+
42+
// Extract the text content from the code element
43+
const codeContent = extractTextContent(codeElement);
44+
45+
// Build the Confluence code macro
46+
const macroChildren: HastElement[] = [];
47+
48+
// Add language parameter if present
49+
if (language) {
50+
macroChildren.push({
51+
type: "element" as const,
52+
tagName: "ac:parameter",
53+
properties: {
54+
"ac:name": "language",
55+
},
56+
children: [
57+
{
58+
type: "text" as const,
59+
value: language,
60+
},
61+
],
62+
});
63+
}
64+
65+
// Add the code content
66+
// Note: We use a text node with the raw CDATA markup
67+
// The rehypeStringify with allowDangerousHtml will preserve it
68+
macroChildren.push({
69+
type: "element" as const,
70+
tagName: "ac:plain-text-body",
71+
properties: {},
72+
children: [
73+
{
74+
type: "text" as const,
75+
value: `<![CDATA[${codeContent}]]>`,
76+
},
77+
],
78+
});
79+
80+
return {
81+
type: "element" as const,
82+
tagName: "ac:structured-macro",
83+
properties: {
84+
"ac:name": "code",
85+
},
86+
children: macroChildren,
87+
};
88+
});
89+
};
90+
};
91+
92+
/**
93+
* Extract the language from the code element's className property.
94+
* Markdown renderers typically add classes like "language-javascript"
95+
* to code elements.
96+
*
97+
* @param codeElement - The code element to extract the language from
98+
* @returns The language identifier or undefined if not found
99+
*/
100+
function extractLanguage(codeElement: HastElement): string | undefined {
101+
const className = codeElement.properties?.className;
102+
103+
if (!className) {
104+
return undefined;
105+
}
106+
107+
// className can be a string or an array of strings
108+
const classNames = Array.isArray(className) ? className : [className];
109+
110+
// Look for a class that starts with "language-"
111+
for (const cls of classNames) {
112+
if (typeof cls === "string" && cls.startsWith("language-")) {
113+
return cls.substring(9); // Remove "language-" prefix
114+
}
115+
}
116+
117+
return undefined;
118+
}
119+
120+
/**
121+
* Extract all text content from an element recursively.
122+
*
123+
* @param element - The element to extract text from
124+
* @returns The concatenated text content
125+
*/
126+
function extractTextContent(element: HastElement): string {
127+
let text = "";
128+
129+
for (const child of element.children) {
130+
if (child.type === "text") {
131+
text += (child as HastText).value;
132+
} else if (child.type === "element") {
133+
text += extractTextContent(child as HastElement);
134+
}
135+
}
136+
137+
return text;
138+
}
139+
140+
export default rehypeReplaceCodeBlocks;

0 commit comments

Comments
 (0)