diff --git a/components/markdown-confluence-sync/CHANGELOG.md b/components/markdown-confluence-sync/CHANGELOG.md index 8478c698..09ee31f9 100644 --- a/components/markdown-confluence-sync/CHANGELOG.md +++ b/components/markdown-confluence-sync/CHANGELOG.md @@ -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 diff --git a/components/markdown-confluence-sync/README.md b/components/markdown-confluence-sync/README.md index 396f0deb..f4d9324a 100644 --- a/components/markdown-confluence-sync/README.md +++ b/components/markdown-confluence-sync/README.md @@ -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` | @@ -493,6 +494,31 @@ Apart of supporting the most common markdown features, the library also supports

This is the content of the details.

``` +* 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 + `` 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 + + javascript + + + ``` ### Unsupported features diff --git a/components/markdown-confluence-sync/package.json b/components/markdown-confluence-sync/package.json index 9980e753..ce1b48b7 100644 --- a/components/markdown-confluence-sync/package.json +++ b/components/markdown-confluence-sync/package.json @@ -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": { diff --git a/components/markdown-confluence-sync/src/lib/confluence/transformer/ConfluencePageTransformer.ts b/components/markdown-confluence-sync/src/lib/confluence/transformer/ConfluencePageTransformer.ts index 85b966f5..3bf7ae79 100644 --- a/components/markdown-confluence-sync/src/lib/confluence/transformer/ConfluencePageTransformer.ts +++ b/components/markdown-confluence-sync/src/lib/confluence/transformer/ConfluencePageTransformer.ts @@ -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"; @@ -50,6 +51,7 @@ export const ConfluencePageTransformer: ConfluencePageTransformerConstructor = c private readonly _rootPageName?: string; private readonly _spaceKey: string; private readonly _logger?: LoggerInterface; + private readonly _rehypeCodeBlocksEnabled: boolean; constructor({ noticeMessage, @@ -57,6 +59,7 @@ export const ConfluencePageTransformer: ConfluencePageTransformerConstructor = c rootPageName, spaceKey, logger, + codeBlocks, }: ConfluencePageTransformerOptions) { this._noticeMessage = noticeMessage; this._noticeTemplateRaw = noticeTemplate; @@ -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( @@ -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) @@ -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, { diff --git a/components/markdown-confluence-sync/src/lib/confluence/transformer/ConfluencePageTransformer.types.ts b/components/markdown-confluence-sync/src/lib/confluence/transformer/ConfluencePageTransformer.types.ts index 6a5cfd6c..84242d47 100644 --- a/components/markdown-confluence-sync/src/lib/confluence/transformer/ConfluencePageTransformer.types.ts +++ b/components/markdown-confluence-sync/src/lib/confluence/transformer/ConfluencePageTransformer.types.ts @@ -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 */ diff --git a/components/markdown-confluence-sync/src/lib/confluence/transformer/support/rehype/rehype-replace-code-blocks.ts b/components/markdown-confluence-sync/src/lib/confluence/transformer/support/rehype/rehype-replace-code-blocks.ts new file mode 100644 index 00000000..d0c05941 --- /dev/null +++ b/components/markdown-confluence-sync/src/lib/confluence/transformer/support/rehype/rehype-replace-code-blocks.ts @@ -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 `
` HastElements with Confluence's
+ * structured code macro format.
+ *
+ * @see {@link https://developer.atlassian.com/server/confluence/confluence-storage-format/ | Confluence Storage Format }
+ *
+ * @example
+ *  
const x = 42;
+ * // becomes + * + * javascript + * + * + */ +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: ``, + }, + ], + }); + + 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; diff --git a/components/markdown-confluence-sync/test/unit/specs/confluence/transformer/support/rehype/rehype-replace-code-blocks.test.ts b/components/markdown-confluence-sync/test/unit/specs/confluence/transformer/support/rehype/rehype-replace-code-blocks.test.ts new file mode 100644 index 00000000..7e72e50a --- /dev/null +++ b/components/markdown-confluence-sync/test/unit/specs/confluence/transformer/support/rehype/rehype-replace-code-blocks.test.ts @@ -0,0 +1,228 @@ +// SPDX-FileCopyrightText: 2025 Telefónica Innovación Digital +// SPDX-License-Identifier: Apache-2.0 + +import rehypeParse from "rehype-parse"; +import rehypeRaw from "rehype-raw"; +import rehypeStringify from "rehype-stringify"; +import { unified } from "unified"; + +import rehypeReplaceCodeBlocks from "@src/lib/confluence/transformer/support/rehype/rehype-replace-code-blocks"; + +describe("rehype-replace-code-blocks", () => { + it("should be defined", () => { + expect(rehypeReplaceCodeBlocks).toBeDefined(); + }); + + it("should replace code block with language to Confluence code macro", () => { + // Arrange + const html = + '
const x = 42;
'; + + // Act + const result = unified() + .use(rehypeParse) + .use(rehypeRaw) + .use(rehypeReplaceCodeBlocks) + .use(rehypeStringify, { + allowDangerousHtml: true, + closeSelfClosing: true, + tightSelfClosing: true, + }) + .processSync(html) + .toString(); + + // Assert + expect(result).toContain(''); + expect(result).toContain(''); + expect(result).toContain("javascript"); + expect(result).toContain(""); + expect(result).toContain("<![CDATA[const x = 42;]]>"); + }); + + it("should replace code block without language to Confluence code macro", () => { + // Arrange + const html = "
plain text code
"; + + // Act + const result = unified() + .use(rehypeParse) + .use(rehypeRaw) + .use(rehypeReplaceCodeBlocks) + .use(rehypeStringify, { + allowDangerousHtml: true, + closeSelfClosing: true, + tightSelfClosing: true, + }) + .processSync(html) + .toString(); + + // Assert + expect(result).toContain(''); + expect(result).not.toContain(''); + expect(result).toContain(""); + expect(result).toContain("<![CDATA[plain text code]]>"); + }); + + it("should handle different programming languages", () => { + // Arrange + const languages = ["python", "java", "typescript", "bash", "sql"]; + + for (const lang of languages) { + const html = `
code here
`; + + // Act + const result = unified() + .use(rehypeParse) + .use(rehypeStringify) + .use(rehypeReplaceCodeBlocks) + .processSync(html) + .toString(); + + // Assert + expect(result).toContain(''); + expect(result).toContain(`${lang}`); + expect(result).toContain("<![CDATA[code here]]>"); + } + }); + + it("should handle code with special characters", () => { + // Arrange + const html = + '
const str = "Hello  & \'Friends\'";
'; + + // Act + const result = unified() + .use(rehypeParse) + .use(rehypeRaw) + .use(rehypeReplaceCodeBlocks) + .use(rehypeStringify, { + allowDangerousHtml: true, + closeSelfClosing: true, + tightSelfClosing: true, + }) + .processSync(html) + .toString(); + + // Assert + expect(result).toContain(''); + expect(result).toContain( + "<![CDATA[const str = \"Hello & 'Friends'\";]]>", + ); + }); + + it("should handle multi-line code blocks", () => { + // Arrange + const html = `
function test() {
+  return true;
+}
`; + + // Act + const result = unified() + .use(rehypeParse) + .use(rehypeRaw) + .use(rehypeReplaceCodeBlocks) + .use(rehypeStringify, { + allowDangerousHtml: true, + closeSelfClosing: true, + tightSelfClosing: true, + }) + .processSync(html) + .toString(); + + // Assert + expect(result).toContain(''); + expect(result).toContain("function test()"); + expect(result).toContain("return true;"); + }); + + it("should not transform pre elements without code children", () => { + // Arrange + const html = "
just text
"; + + // Act + const result = unified() + .use(rehypeParse) + .use(rehypeRaw) + .use(rehypeReplaceCodeBlocks) + .use(rehypeStringify, { + allowDangerousHtml: true, + closeSelfClosing: true, + tightSelfClosing: true, + }) + .processSync(html) + .toString(); + + // Assert + expect(result).not.toContain(''); + expect(result).toContain("
just text
"); + }); + + it("should not transform other elements", () => { + // Arrange + const html = "

paragraph

division
"; + + // Act + const result = unified() + .use(rehypeParse) + .use(rehypeRaw) + .use(rehypeReplaceCodeBlocks) + .use(rehypeStringify, { + allowDangerousHtml: true, + closeSelfClosing: true, + tightSelfClosing: true, + }) + .processSync(html) + .toString(); + + // Assert + expect(result).not.toContain(''); + expect(result).toContain("

paragraph

"); + expect(result).toContain("
division
"); + }); + + it("should handle code blocks with empty content", () => { + // Arrange + const html = '
'; + + // Act + const result = unified() + .use(rehypeParse) + .use(rehypeRaw) + .use(rehypeReplaceCodeBlocks) + .use(rehypeStringify, { + allowDangerousHtml: true, + closeSelfClosing: true, + tightSelfClosing: true, + }) + .processSync(html) + .toString(); + + // Assert + expect(result).toContain(''); + expect(result).toContain("<![CDATA[]]>"); + }); + + it("should handle code blocks with multiple class names", () => { + // Arrange + const html = + '
const x = 42;
'; + + // Act + const result = unified() + .use(rehypeParse) + .use(rehypeRaw) + .use(rehypeReplaceCodeBlocks) + .use(rehypeStringify, { + allowDangerousHtml: true, + closeSelfClosing: true, + tightSelfClosing: true, + }) + .processSync(html) + .toString(); + + // Assert + expect(result).toContain(''); + expect(result).toContain('typescript'); + expect(result).toContain("<![CDATA[const x = 42;]]>"); + }); +});