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
6 changes: 6 additions & 0 deletions components/markdown-confluence-sync/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

#### Added

* feat: Add code blocks transformation to Confluence code macro format.
Code blocks are now converted to Confluence's structured code macro
with syntax highlighting support. This feature is disabled by default
and can be enabled via `codeBlocks` configuration option.

#### Changed
#### Fixed
#### Deprecated
Expand Down
26 changes: 26 additions & 0 deletions components/markdown-confluence-sync/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,7 @@ The namespace for the configuration of this library is `markdown-confluence-sync
| `confluence.noticeMessage` | `string` | Notice message to add at the beginning of the Confluence pages. | |
| `confluence.noticeTemplate` | `string` | Template string to use for the notice message. | |
| `confluence.dryRun` | `boolean` | Log create, update or delete requests to Confluence instead of really making them | `false` |
| `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. | `false` |
| `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` |
| `config.readArguments` | `boolean` | Read configuration from arguments or not | `false` |
| `config.readFile` | `boolean` | Read configuration from file or not | `false` |
Expand Down Expand Up @@ -493,6 +494,31 @@ Apart of supporting the most common markdown features, the library also supports
<ac:rich-text-body><p>This is the content of the details.</p></ac:rich-text-body>
</ac:structured-macro>
```
* Code blocks - Markdown fenced code blocks can be converted to
Confluence code macro format with syntax highlighting support. This
feature is disabled by default but can be enabled via the
`codeBlocks` configuration option.
* The plugin converts fenced code blocks to Confluence's
`<ac:structured-macro ac:name="code">` format.
* Language syntax highlighting is preserved when specified in the
markdown code fence.
* This feature is disabled by default for compatibility with older
Confluence versions. Enable it by setting `codeBlocks: true`.
* For example, the following markdown code block:
````markdown
```javascript
const hello = "world";
console.log(hello);
```
````
will be converted to:
```markdown
<ac:structured-macro ac:name="code">
<ac:parameter ac:name="language">javascript</ac:parameter>
<ac:plain-text-body><![CDATA[const hello = "world";
console.log(hello);]]></ac:plain-text-body>
</ac:structured-macro>
```

### Unsupported features

Expand Down
2 changes: 1 addition & 1 deletion components/markdown-confluence-sync/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@telefonica/markdown-confluence-sync",
"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",
"version": "2.2.0",
"version": "2.3.0",
"license": "Apache-2.0",
"author": "Telefónica Innovación Digital",
"repository": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import { InvalidTemplateError } from "./errors/InvalidTemplateError.js";
import rehypeAddAttachmentsImages from "./support/rehype/rehype-add-attachments-images.js";
import type { ImagesMetadata } from "./support/rehype/rehype-add-attachments-images.types.js";
import rehypeAddNotice from "./support/rehype/rehype-add-notice.js";
import rehypeReplaceCodeBlocks from "./support/rehype/rehype-replace-code-blocks.js";
import rehypeReplaceDetails from "./support/rehype/rehype-replace-details.js";
import rehypeReplaceImgTags from "./support/rehype/rehype-replace-img-tags.js";
import rehypeReplaceInternalReferences from "./support/rehype/rehype-replace-internal-references.js";
Expand All @@ -50,13 +51,15 @@ export const ConfluencePageTransformer: ConfluencePageTransformerConstructor = c
private readonly _rootPageName?: string;
private readonly _spaceKey: string;
private readonly _logger?: LoggerInterface;
private readonly _rehypeCodeBlocksEnabled: boolean;

constructor({
noticeMessage,
noticeTemplate,
rootPageName,
spaceKey,
logger,
codeBlocks,
}: ConfluencePageTransformerOptions) {
this._noticeMessage = noticeMessage;
this._noticeTemplateRaw = noticeTemplate;
Expand All @@ -66,6 +69,7 @@ export const ConfluencePageTransformer: ConfluencePageTransformerConstructor = c
this._rootPageName = rootPageName;
this._spaceKey = spaceKey;
this._logger = logger;
this._rehypeCodeBlocksEnabled = codeBlocks ?? false;
}

public async transform(
Expand All @@ -88,7 +92,7 @@ export const ConfluencePageTransformer: ConfluencePageTransformerConstructor = c
DEFAULT_MERMAID_DIAGRAMS_LOCATION,
);
try {
const content = remark()
let processor = remark()
.use(remarkGfm)
.use(remarkFrontmatter)
.use(remarkRemoveFootnotes)
Expand All @@ -101,7 +105,14 @@ export const ConfluencePageTransformer: ConfluencePageTransformerConstructor = c
.use(rehypeAddNotice, { noticeMessage })
.use(rehypeReplaceDetails)
.use(rehypeReplaceStrikethrough)
.use(rehypeReplaceTaskList)
.use(rehypeReplaceTaskList);

// Conditionally add code blocks plugin
if (this._rehypeCodeBlocksEnabled) {
processor = processor.use(rehypeReplaceCodeBlocks);
}

const content = processor
.use(rehypeAddAttachmentsImages)
.use(rehypeReplaceImgTags)
.use(rehypeReplaceInternalReferences, {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,13 @@ export interface ConfluencePageTransformerOptions {
spaceKey: string;
/** Logger */
logger?: LoggerInterface;
/**
* Enable code blocks transformation to Confluence code macro.
* When enabled, markdown code blocks will be converted to Confluence's
* structured code macro format with syntax highlighting support.
* @default false
*/
codeBlocks?: boolean;
}

/** Creates a ConfluencePageTransformer interface */
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
// SPDX-FileCopyrightText: 2025 Telefónica Innovación Digital
// SPDX-License-Identifier: Apache-2.0

import type { Element as HastElement, Root, Text as HastText } from "hast";
import type { Plugin as UnifiedPlugin } from "unified";

import { replace } from "../../../../support/unist/unist-util-replace.js";

/**
* UnifiedPlugin to replace `<pre><code>` HastElements with Confluence's
* structured code macro format.
*
* @see {@link https://developer.atlassian.com/server/confluence/confluence-storage-format/ | Confluence Storage Format }
*
* @example
* <pre><code class="language-javascript">const x = 42;</code></pre>
* // becomes
* <ac:structured-macro ac:name="code">
* <ac:parameter ac:name="language">javascript</ac:parameter>
* <ac:plain-text-body><![CDATA[const x = 42;]]></ac:plain-text-body>
* </ac:structured-macro>
*/
const rehypeReplaceCodeBlocks: UnifiedPlugin<[], Root> =
function rehypeReplaceCodeBlocks() {
return function transformer(tree) {
replace(tree, { type: "element", tagName: "pre" }, (node) => {
// Check if this pre element contains a code element
const codeElement = node.children.find(
(child) =>
child.type === "element" &&
(child as HastElement).tagName === "code",
) as HastElement | undefined;

if (!codeElement) {
// If there's no code element, return the pre element unchanged
return node;
}

// Extract the language from the code element's className
const language = extractLanguage(codeElement);

// Extract the text content from the code element
const codeContent = extractTextContent(codeElement);

// Build the Confluence code macro
const macroChildren: HastElement[] = [];

// Add language parameter if present
if (language) {
macroChildren.push({
type: "element" as const,
tagName: "ac:parameter",
properties: {
"ac:name": "language",
},
children: [
{
type: "text" as const,
value: language,
},
],
});
}

// Add the code content
// Note: We use a text node with the raw CDATA markup
// The rehypeStringify with allowDangerousHtml will preserve it
macroChildren.push({
type: "element" as const,
tagName: "ac:plain-text-body",
properties: {},
children: [
{
type: "text" as const,
value: `<![CDATA[${codeContent}]]>`,
},
],
});

return {
type: "element" as const,
tagName: "ac:structured-macro",
properties: {
"ac:name": "code",
},
children: macroChildren,
};
});
};
};

/**
* Extract the language from the code element's className property.
* Markdown renderers typically add classes like "language-javascript"
* to code elements.
*
* @param codeElement - The code element to extract the language from
* @returns The language identifier or undefined if not found
*/
function extractLanguage(codeElement: HastElement): string | undefined {
const className = codeElement.properties?.className;

if (!className) {
return undefined;
}

// className can be a string or an array of strings
const classNames = Array.isArray(className) ? className : [className];

// Look for a class that starts with "language-"
for (const cls of classNames) {
if (typeof cls === "string" && cls.startsWith("language-")) {
return cls.substring(9); // Remove "language-" prefix
}
}

return undefined;
}

/**
* Extract all text content from an element recursively.
*
* @param element - The element to extract text from
* @returns The concatenated text content
*/
function extractTextContent(element: HastElement): string {
let text = "";

for (const child of element.children) {
if (child.type === "text") {
text += (child as HastText).value;
} else if (child.type === "element") {
text += extractTextContent(child as HastElement);
}
}

return text;
}

export default rehypeReplaceCodeBlocks;
Loading
Loading