UNPKG

@telefonica/markdown-confluence-sync

Version:

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

175 lines (174 loc) 8.32 kB
// SPDX-FileCopyrightText: 2024 Telefónica Innovación Digital // SPDX-License-Identifier: Apache-2.0 import { dirname, resolve } from "node:path"; import Handlebars from "handlebars"; import rehypeRaw from "rehype-raw"; import rehypeStringify from "rehype-stringify"; import { remark } from "remark"; import remarkFrontmatter from "remark-frontmatter"; import remarkGfm from "remark-gfm"; import remarkRehype from "remark-rehype"; import { toVFile } from "to-vfile"; import { InvalidTemplateError } from "./errors/InvalidTemplateError.js"; import rehypeAddAttachmentsImages from "./support/rehype/rehype-add-attachments-images.js"; import rehypeAddNotice from "./support/rehype/rehype-add-notice.js"; import rehypeReplaceGithubAlerts from "./support/rehype/rehype-replace-github-alerts.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"; import rehypeReplaceStrikethrough from "./support/rehype/rehype-replace-strikethrough.js"; import rehypeReplaceTaskList from "./support/rehype/rehype-replace-task-list.js"; import remarkRemoveFootnotes from "./support/remark/remark-remove-footnotes.js"; import remarkRemoveMdxCodeBlocks from "./support/remark/remark-remove-mdx-code-blocks.js"; import remarkReplaceMermaid from "./support/remark/remark-replace-mermaid.js"; const DEFAULT_NOTICE_MESSAGE = "AUTOMATION NOTICE: This page is synced automatically, changes made manually will be lost"; const DEFAULT_MERMAID_DIAGRAMS_LOCATION = "mermaid-diagrams"; export const ConfluencePageTransformer = class ConfluenceTransformer { _noticeMessage; _noticeTemplateRaw; _noticeTemplate; _rootPageName; _spaceKey; _logger; _rehypeCodeBlocksEnabled; _rehypeGithubAlertsEnabled; constructor({ noticeMessage, noticeTemplate, rootPageName, spaceKey, logger, rehype: { codeBlocks, githubAlerts }, }) { this._noticeMessage = noticeMessage; this._noticeTemplateRaw = noticeTemplate; this._noticeTemplate = noticeTemplate ? Handlebars.compile(noticeTemplate, { noEscape: true }) : undefined; this._rootPageName = rootPageName; this._spaceKey = spaceKey; this._logger = logger; this._rehypeCodeBlocksEnabled = codeBlocks ?? false; this._rehypeGithubAlertsEnabled = githubAlerts ?? false; logger?.debug(`ConfluencePageTransformer initialized with rehype options: ${JSON.stringify({ codeBlocks: this._rehypeCodeBlocksEnabled, alerts: this._rehypeGithubAlertsEnabled })}`); } async transform(_pages) { const pages = this._transformPageTitles(_pages); const pagesMap = new Map(pages.map((page) => [page.path, page])); return Promise.all(pages.map((page) => this._transformPage(page, pagesMap))); } async _transformPageContent(page, pages) { const noticeMessage = this._composeNoticeMessage(page); const mermaidDiagramsDir = resolve(dirname(page.path), DEFAULT_MERMAID_DIAGRAMS_LOCATION); try { let processor = remark() .use(remarkGfm) .use(remarkFrontmatter) .use(remarkRemoveFootnotes) .use(remarkRemoveMdxCodeBlocks) .use(remarkReplaceMermaid, { outDir: mermaidDiagramsDir, }) .use(remarkRehype, { allowDangerousHtml: true }) .use(rehypeRaw) .use(rehypeAddNotice, { noticeMessage }) .use(rehypeReplaceDetails) .use(rehypeReplaceStrikethrough) .use(rehypeReplaceTaskList); // Conditionally add code blocks plugin if (this._rehypeCodeBlocksEnabled) { this._logger?.debug(`Registering rehypeReplaceCodeBlocks plugin`); processor = processor.use(rehypeReplaceCodeBlocks); } // Conditionally add alerts plugin if (this._rehypeGithubAlertsEnabled) { this._logger?.debug(`Registering rehypeReplaceAlerts plugin`); processor = processor.use(rehypeReplaceGithubAlerts); } const content = processor .use(rehypeAddAttachmentsImages) .use(rehypeReplaceImgTags) .use(rehypeReplaceInternalReferences, { spaceKey: this._spaceKey, pages, removeMissing: true, }) .use(rehypeStringify, { allowDangerousHtml: true, closeSelfClosing: true, tightSelfClosing: true, }) .processSync(toVFile({ value: page.content, path: page.path })); if (content.messages.length > 0) this._logger?.silly(`Transformed page content: ${JSON.stringify(content.messages, null, 2)}`); return { id: page.id, title: page.title, content: content.toString(), attachments: content.data.images, ancestors: page.ancestors, }; } catch (e) { this._logger?.error(`Error occurs while transforming page content ${page.path}: ${e}`); throw e; } } _composeNoticeMessage(page) { let noticeMessage; try { noticeMessage = this._noticeTemplate ? this._noticeTemplate({ relativePath: page.relativePath, relativePathWithoutExtension: page.relativePath .split(".") .slice(0, -1) .join("."), title: page.title, message: this._noticeMessage ?? "", default: DEFAULT_NOTICE_MESSAGE, }) : undefined; } catch (e) { const error = new InvalidTemplateError(`Invalid notice template: ${this._noticeTemplateRaw}`, { cause: e }); this._logger?.error(`Error occurs while rendering template: ${error}`); throw error; } if (typeof noticeMessage === "string") { return noticeMessage; } return this._noticeMessage ?? DEFAULT_NOTICE_MESSAGE; } async _transformPage(page, pages) { const confluenceInputPage = await this._transformPageContent(page, pages); this._logger?.silly(`Transformed page: ${JSON.stringify(confluenceInputPage, null, 2)}`); return confluenceInputPage; } _transformPageTitles(pages) { const pagesMap = new Map(pages.map((page) => [page.path, page])); const rootPageAncestor = this._rootPageName !== undefined ? [this._rootPageName] : []; const pageTitleLookupTable = new Map(pages.map((page) => { const ancestors = this._resolveAncestorsTitles(page, pagesMap); const ancestorsTitle = rootPageAncestor .concat(ancestors) .map((ancestor) => `[${ancestor}]`) .join(""); const title = ancestorsTitle !== "" ? `${ancestorsTitle} ${page.title}` : page.title; return [page.path, title]; })); this._logger?.debug(`pageTitleLookupTable: ${JSON.stringify(Object.fromEntries(pageTitleLookupTable), null, 2)}`); return pages.map((page) => ({ ...page, title: pageTitleLookupTable.get(page.path), ancestors: page.ancestors.map((ancestor) => pageTitleLookupTable.get(ancestor)), })); } _resolveAncestorsTitles(page, pages) { return page.ancestors.map((ancestor) => { const ancestorPage = pages.get(ancestor); // NOTE: Coverage ignored because it is unreachable from tests. Defensive programming. // istanbul ignore next if (!ancestorPage) { throw new Error(`Ancestor page not found: ${ancestor}`); } return ancestorPage.name ?? ancestorPage.title; }); } };