codoc-cli 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +104 -0
  3. package/assets/drawio-viewer/README.md +13 -0
  4. package/assets/drawio-viewer/viewer-static.min.js +8640 -0
  5. package/bin/dev.cmd +3 -0
  6. package/bin/dev.js +5 -0
  7. package/bin/run.cmd +3 -0
  8. package/bin/run.js +7 -0
  9. package/dist/clients/confluence/clients/attachments-client.js +74 -0
  10. package/dist/clients/confluence/clients/folders-client.js +111 -0
  11. package/dist/clients/confluence/clients/pages-client.js +128 -0
  12. package/dist/clients/confluence/confluence-client.js +44 -0
  13. package/dist/clients/confluence/utils/confluence-url.js +25 -0
  14. package/dist/clients/confluence/utils/confluence-util.js +20 -0
  15. package/dist/commands/init.js +29 -0
  16. package/dist/commands/publish.js +34 -0
  17. package/dist/commands/pull.js +54 -0
  18. package/dist/commands/sync.js +27 -0
  19. package/dist/commands/tree.js +17 -0
  20. package/dist/config/codoc-config-atlassian.js +89 -0
  21. package/dist/config/codoc-config-raw.js +21 -0
  22. package/dist/config/codoc-config.js +50 -0
  23. package/dist/config/codoc-paths.js +9 -0
  24. package/dist/hooks/command_not_found.js +7 -0
  25. package/dist/hooks/init/check-for-update.js +68 -0
  26. package/dist/hooks/init/load-env.js +15 -0
  27. package/dist/index.js +2 -0
  28. package/dist/services/codoc-id.js +7 -0
  29. package/dist/services/confluence/attachments.js +56 -0
  30. package/dist/services/confluence/folders.js +42 -0
  31. package/dist/services/confluence/labels.js +17 -0
  32. package/dist/services/confluence/pages.js +28 -0
  33. package/dist/services/conversion/confluenceToMarkdown/diagrams/drawio-diagrams.js +77 -0
  34. package/dist/services/conversion/confluenceToMarkdown/diagrams/drawio-to-image.js +131 -0
  35. package/dist/services/conversion/confluenceToMarkdown/index.js +17 -0
  36. package/dist/services/conversion/confluenceToMarkdown/preprocess/macro-converters.js +68 -0
  37. package/dist/services/conversion/confluenceToMarkdown/preprocess/preprocess.js +113 -0
  38. package/dist/services/conversion/confluenceToMarkdown/turndown.js +71 -0
  39. package/dist/services/conversion/markdownToConfluence/conversion-state.js +18 -0
  40. package/dist/services/conversion/markdownToConfluence/diagrams/mermaid-diagrams.js +56 -0
  41. package/dist/services/conversion/markdownToConfluence/diagrams/mermaid-to-drawio.js +331 -0
  42. package/dist/services/conversion/markdownToConfluence/index.js +48 -0
  43. package/dist/services/conversion/markdownToConfluence/languages.js +66 -0
  44. package/dist/services/conversion/markdownToConfluence/links.js +40 -0
  45. package/dist/services/conversion/markdownToConfluence/raw-html/raw-html.js +86 -0
  46. package/dist/services/conversion/markdownToConfluence/raw-html/task-lists.js +59 -0
  47. package/dist/services/conversion/markdownToConfluence/render/blocks.js +171 -0
  48. package/dist/services/conversion/markdownToConfluence/render/inline.js +44 -0
  49. package/dist/services/conversion/markdownToConfluence/render/page.js +31 -0
  50. package/dist/services/conversion/shared/confluence-macro-builder.js +11 -0
  51. package/dist/services/conversion/shared/gitlab-url.js +16 -0
  52. package/dist/services/conversion/shared/image-attachments.js +57 -0
  53. package/dist/services/conversion/shared/jira.js +7 -0
  54. package/dist/services/conversion/shared/macro-types.js +12 -0
  55. package/dist/services/conversion/shared/preserved-macros.js +8 -0
  56. package/dist/services/conversion/shared/read-storage-format.js +29 -0
  57. package/dist/services/conversion/shared/regex-cache.js +14 -0
  58. package/dist/services/conversion/shared/xml-escaping.js +19 -0
  59. package/dist/services/ensure-env.js +59 -0
  60. package/dist/services/files-service.js +74 -0
  61. package/dist/services/git/default-branch.js +33 -0
  62. package/dist/services/init-templates/agent-md-init.js +55 -0
  63. package/dist/services/init-templates/doc-guide-init.js +131 -0
  64. package/dist/services/init-templates/env-init.js +12 -0
  65. package/dist/services/init-templates/yaml-init.js +56 -0
  66. package/dist/services/lock/lock-file.js +61 -0
  67. package/dist/services/log/init-printer.js +50 -0
  68. package/dist/services/log/logger.js +131 -0
  69. package/dist/services/prompt.js +32 -0
  70. package/dist/services/slugify.js +6 -0
  71. package/dist/types/codoc-types.js +1 -0
  72. package/dist/use-cases/init/init.js +150 -0
  73. package/dist/use-cases/publish/publish.js +166 -0
  74. package/dist/use-cases/pull/parse-page-input.js +22 -0
  75. package/dist/use-cases/pull/pull.js +337 -0
  76. package/dist/use-cases/shared/codoc-yaml.js +45 -0
  77. package/dist/use-cases/shared/confluence-client-registry.js +27 -0
  78. package/dist/use-cases/shared/env-select.js +23 -0
  79. package/dist/use-cases/shared/fetch-page.js +11 -0
  80. package/dist/use-cases/shared/list-documents.js +172 -0
  81. package/dist/use-cases/shared/pull-folder.js +90 -0
  82. package/dist/use-cases/shared/render-remote-page.js +71 -0
  83. package/dist/use-cases/sync/attachment-upload.js +31 -0
  84. package/dist/use-cases/sync/sync-actions.js +170 -0
  85. package/dist/use-cases/sync/sync-associate.js +41 -0
  86. package/dist/use-cases/sync/sync-entries.js +36 -0
  87. package/dist/use-cases/sync/sync-handlers.js +140 -0
  88. package/dist/use-cases/sync/sync-helpers.js +8 -0
  89. package/dist/use-cases/sync/sync.js +94 -0
  90. package/dist/use-cases/tree/docs-tree.js +141 -0
  91. package/oclif.manifest.json +255 -0
  92. package/package.json +98 -0
@@ -0,0 +1,68 @@
1
+ import { escapeAttr, escapeXml } from "../../shared/xml-escaping.js";
2
+ import { macroParam, macroRegex } from "../../shared/read-storage-format.js";
3
+ import { preserveAsSentinel } from "../../shared/preserved-macros.js";
4
+ import { JIRA_KEY_PATTERN } from "../../shared/jira.js";
5
+ //#region src/services/conversion/confluenceToMarkdown/preprocess/macro-converters.ts
6
+ const JIRA_BROWSE_KEY_RE = new RegExp(`/browse/(${JIRA_KEY_PATTERN})`, "i");
7
+ const STATUS_COLOURS = {
8
+ grey: {
9
+ bg: "#DFE1E6",
10
+ fg: "#42526E"
11
+ },
12
+ red: {
13
+ bg: "#FFEBE6",
14
+ fg: "#BF2600"
15
+ },
16
+ yellow: {
17
+ bg: "#FFF0B3",
18
+ fg: "#172B4D"
19
+ },
20
+ green: {
21
+ bg: "#E3FCEF",
22
+ fg: "#006644"
23
+ },
24
+ blue: {
25
+ bg: "#DEEBFF",
26
+ fg: "#0747A6"
27
+ },
28
+ purple: {
29
+ bg: "#EAE6FF",
30
+ fg: "#403294"
31
+ }
32
+ };
33
+ function statusStyle(colour) {
34
+ const c = STATUS_COLOURS[colour.trim().toLowerCase()] ?? STATUS_COLOURS.grey;
35
+ return `background-color:${c.bg};color:${c.fg};padding:2px 6px;border-radius:3px;font-weight:bold`;
36
+ }
37
+ function convertStatusMacros(html) {
38
+ return html.replace(/<ac:structured-macro[^>]*ac:name="status"[^>]*>([\s\S]*?)<\/ac:structured-macro>/g, (_, inner) => {
39
+ const title = macroParam(inner, "title") ?? "";
40
+ const colour = macroParam(inner, "colou?r") ?? "Grey";
41
+ return `<span data-confluence-status="${escapeAttr(colour)}" style="${statusStyle(colour)}">${escapeXml(title)}</span>`;
42
+ });
43
+ }
44
+ function preserveDrawioMacros(html) {
45
+ html = html.replace(/<ac:adf-extension[\s\S]*?<\/ac:adf-extension>/g, (m) => preserveAsSentinel(m));
46
+ html = html.replace(macroRegex("drawio"), (m) => preserveAsSentinel(m));
47
+ return html;
48
+ }
49
+ function convertJiraElements(html, jiraBase) {
50
+ html = html.replace(/<a\s[^>]*?href="([^"]+)"[^>]*?data-card-appearance="block"[^>]*?>[\s\S]*?<\/a>|<a\s[^>]*?data-card-appearance="block"[^>]*?href="([^"]+)"[^>]*?>[\s\S]*?<\/a>/g, (_, url1, url2) => `<a href="${url1 || url2}">Tableau Jira</a>`);
51
+ html = html.replace(/<a([^>]*)data-card-appearance="inline"([^>]*)>[\s\S]*?<\/a>/gi, (_, before, after) => {
52
+ const url = (before + after).match(/href="([^"]+)"/)?.[1] ?? "";
53
+ return `<a href="${url}">${url.match(JIRA_BROWSE_KEY_RE)?.[1] ?? url}</a>`;
54
+ });
55
+ html = html.replace(/<ac:structured-macro[^>]*ac:name="jira"[^>]*>([\s\S]*?)<\/ac:structured-macro>/g, (_, inner) => {
56
+ const key = macroParam(inner, "key") ?? "";
57
+ if (!key) return "";
58
+ return `<a href="${jiraBase ? `${jiraBase}/browse/${key}` : `#${key}`}">${key}</a>`;
59
+ });
60
+ return html;
61
+ }
62
+ function richMacroToBlockquote(type, inner) {
63
+ const title = macroParam(inner, "title") ?? "";
64
+ const body = inner.match(/<ac:rich-text-body>([\s\S]*?)<\/ac:rich-text-body>/)?.[1] ?? "";
65
+ return `<blockquote data-macro-type="${type}"${title ? ` data-macro-title="${title.replace(/"/g, "&quot;")}"` : ""}>${body}</blockquote>`;
66
+ }
67
+ //#endregion
68
+ export { convertJiraElements, convertStatusMacros, preserveDrawioMacros, richMacroToBlockquote };
@@ -0,0 +1,113 @@
1
+ import { escapeXml } from "../../shared/xml-escaping.js";
2
+ import { EMBEDDED_ATTACHMENT_PATTERNS, renderAttachmentTag } from "../../shared/image-attachments.js";
3
+ import { findDivEnd, macroParam } from "../../shared/read-storage-format.js";
4
+ import { RICH_BODY_MACRO_TYPES_PATTERN } from "../../shared/macro-types.js";
5
+ import { preserveAsSentinel } from "../../shared/preserved-macros.js";
6
+ import { convertJiraElements, convertStatusMacros, preserveDrawioMacros, richMacroToBlockquote } from "./macro-converters.js";
7
+ //#region src/services/conversion/confluenceToMarkdown/preprocess/preprocess.ts
8
+ function convertTaskLists(html) {
9
+ const convertInnermostLevel = (h) => h.replace(/<ac:task-list[^>]*>((?:(?!<ac:task-list)[\s\S])*?)<\/ac:task-list>/g, (_, content) => {
10
+ const entries = [];
11
+ const taskRe = /<ac:task[^>]*>([\s\S]*?)<\/ac:task>/g;
12
+ let m;
13
+ while ((m = taskRe.exec(content)) !== null) {
14
+ const inner = m[1];
15
+ const status = inner.match(/<ac:task-status>(\w+)<\/ac:task-status>/)?.[1] ?? "incomplete";
16
+ const body = (inner.match(/<ac:task-body>([\s\S]*?)<\/ac:task-body>/)?.[1] ?? "").replace(/<(?!\/?div\b)[^>]+>/g, "").trim();
17
+ entries.push({
18
+ pos: m.index,
19
+ kind: "task",
20
+ status,
21
+ body
22
+ });
23
+ }
24
+ let searchStart = 0;
25
+ while (true) {
26
+ const idx = content.indexOf("<div data-ac-task-list", searchStart);
27
+ if (idx === -1) break;
28
+ const closeEnd = findDivEnd(content, idx);
29
+ entries.push({
30
+ pos: idx,
31
+ kind: "sub",
32
+ html: content.slice(idx, closeEnd)
33
+ });
34
+ searchStart = closeEnd;
35
+ }
36
+ entries.sort((a, b) => a.pos - b.pos);
37
+ const items = [];
38
+ for (let i = 0; i < entries.length; i++) {
39
+ const e = entries[i];
40
+ if (e.kind !== "task") continue;
41
+ let subHtml = "";
42
+ if (i + 1 < entries.length && entries[i + 1].kind === "sub") {
43
+ subHtml = entries[i + 1].html;
44
+ i++;
45
+ }
46
+ items.push(`<div data-ac-task="1" data-status="${e.status}">${e.body}${subHtml}</div>`);
47
+ }
48
+ return `<div data-ac-task-list="1">\n${items.join("\n")}\n</div>`;
49
+ });
50
+ let prev = "";
51
+ let cur = html;
52
+ while (prev !== cur) {
53
+ prev = cur;
54
+ cur = convertInnermostLevel(cur);
55
+ }
56
+ return cur;
57
+ }
58
+ function stripGeneratedBanner(html) {
59
+ html = html.replace(/<ac:structured-macro[^>]*ac:name="info"[^>]*>[\s\S]*?Généré le[\s\S]*?<\/ac:structured-macro>/g, "");
60
+ return html.replace(/<ac:structured-macro[^>]*ac:name="toc"[^>]*\/>|<ac:structured-macro[^>]*ac:name="toc"[^>]*>[\s\S]*?<\/ac:structured-macro>/g, "");
61
+ }
62
+ function convertTimeElements(html) {
63
+ html = html.replace(/<time[^>]*datetime="([^"]+)"[^>]*>[\s\S]*?<\/time>/g, (_, dt) => `📅 ${dt}`);
64
+ html = html.replace(/<time[^>]*datetime="([^"]+)"[^>]*\/>/g, (_, dt) => `📅 ${dt}`);
65
+ html = html.replace(/<time[^>]*>[\s\S]*?<\/time>/g, "");
66
+ return html.replace(/<time[^>]*\/>/g, "");
67
+ }
68
+ const RICH_BODY_MACRO_RE = new RegExp(`<ac:structured-macro[^>]*ac:name="(${RICH_BODY_MACRO_TYPES_PATTERN})"[^>]*>([\\s\\S]*?)<\\/ac:structured-macro>`, "g");
69
+ function convertRichBodyMacros(html) {
70
+ return html.replace(RICH_BODY_MACRO_RE, (_, type, inner) => richMacroToBlockquote(type, inner));
71
+ }
72
+ function convertCodeMacros(html) {
73
+ return html.replace(/<ac:structured-macro[^>]*ac:name="code"[^>]*>([\s\S]*?)<\/ac:structured-macro>/g, (_, inner) => {
74
+ const lang = macroParam(inner, "language") ?? "";
75
+ const unescaped = (inner.match(/<!\[CDATA\[([\s\S]*?)\]\]>/)?.[1] ?? "").replace(/\]\]>\s*<!\[CDATA\[>/g, "]]>");
76
+ return `<pre><code${lang ? ` class="language-${lang}"` : ""}>${escapeXml(unescaped)}</code></pre>`;
77
+ });
78
+ }
79
+ function convertImages(html, imgRelPath) {
80
+ for (const pattern of EMBEDDED_ATTACHMENT_PATTERNS) html = html.replace(pattern, (_, rawFilename) => renderAttachmentTag(rawFilename, imgRelPath));
81
+ return html.replace(/<ac:image[^>]*>[\s\S]*?<ri:url[^>]*ri:value="([^"]+)"[^>]*\/>[\s\S]*?<\/ac:image>/g, (_, url) => `<img src="${url}" alt="">`);
82
+ }
83
+ function collapseSingleParagraphCells(html) {
84
+ return html.replace(/<(th|td)([^>]*)>\s*<p[^>]*>([\s\S]*?)<\/p>\s*<\/(th|td)>/gi, (match, open, attrs, content, close) => open.toLowerCase() === close.toLowerCase() ? `<${open}${attrs}>${content}</${close}>` : match);
85
+ }
86
+ function preserveUnknownMacros(html) {
87
+ return html.replace(/<ac:structured-macro[\s\S]*?<\/ac:structured-macro>/g, (m) => preserveAsSentinel(m));
88
+ }
89
+ function stripFinalArtifacts(html) {
90
+ html = html.replace(/\s+(?:local-id|ac:local-id|data-table-width|data-layout|data-card-appearance|data-datasource)="[^"]*"/g, "");
91
+ html = html.replace(/<hr[^>]+>/g, "<hr/>");
92
+ html = html.replace(/<\/?ac:[a-z-]+(?:\s[^>]*)?\/?>/g, "");
93
+ return html.replace(/<\/?ri:[a-z-]+(?:\s[^>]*)?\/?>/g, "");
94
+ }
95
+ function preprocessStorage(xml, imgRelPath, baseUrl) {
96
+ const jiraBase = baseUrl ? baseUrl.replace(/\/wiki\/?$/, "").replace(/\/$/, "") : "";
97
+ return [
98
+ stripGeneratedBanner,
99
+ convertTaskLists,
100
+ preserveDrawioMacros,
101
+ (html) => convertJiraElements(html, jiraBase),
102
+ convertTimeElements,
103
+ convertRichBodyMacros,
104
+ convertStatusMacros,
105
+ convertCodeMacros,
106
+ (html) => convertImages(html, imgRelPath),
107
+ collapseSingleParagraphCells,
108
+ preserveUnknownMacros,
109
+ stripFinalArtifacts
110
+ ].reduce((html, step) => step(html), xml);
111
+ }
112
+ //#endregion
113
+ export { convertTaskLists, preprocessStorage };
@@ -0,0 +1,71 @@
1
+ import "../shared/preserved-macros.js";
2
+ import TurndownService from "turndown";
3
+ import { gfm } from "turndown-plugin-gfm";
4
+ //#region src/services/conversion/confluenceToMarkdown/turndown.ts
5
+ let cached;
6
+ /** Instance mémoïsée : TurndownService + ses règles n'ont pas d'état entre deux `.turndown()`. */
7
+ function buildTurndown() {
8
+ if (cached) return cached;
9
+ const td = new TurndownService({
10
+ headingStyle: "atx",
11
+ codeBlockStyle: "fenced",
12
+ bulletListMarker: "-",
13
+ hr: "---",
14
+ fence: "```"
15
+ });
16
+ td.use(gfm);
17
+ td.addRule("underline", {
18
+ filter: ["u"],
19
+ replacement: (content) => `<u>${content}</u>`
20
+ });
21
+ td.addRule("video", {
22
+ filter: ["video"],
23
+ replacement: (_content, node) => `<video src="${node.getAttribute("src") ?? ""}" controls></video>`
24
+ });
25
+ td.addRule("color-span", {
26
+ filter: (node) => node.nodeName === "SPAN" && /\bcolor\b/.test(node.getAttribute("style") ?? ""),
27
+ replacement: (content, node) => `<span style="${node.getAttribute("style") ?? ""}">${content}</span>`
28
+ });
29
+ td.addRule("confluence-status", {
30
+ filter: (node) => node.nodeName === "SPAN" && !!node.getAttribute("data-confluence-status"),
31
+ replacement: (content, node) => {
32
+ return `<span data-confluence-status="${node.getAttribute("data-confluence-status") ?? "Grey"}" style="${node.getAttribute("style") ?? ""}">${content}</span>`;
33
+ }
34
+ });
35
+ td.addRule("confluence-task-list", {
36
+ filter: (node) => node.nodeName === "DIV" && !!node.getAttribute("data-ac-task-list"),
37
+ replacement: (content) => `\n\n${content.trim()}\n\n`
38
+ });
39
+ td.addRule("confluence-task-item", {
40
+ filter: (node) => node.nodeName === "DIV" && !!node.getAttribute("data-ac-task"),
41
+ replacement: (content, node) => {
42
+ const checkbox = node.getAttribute("data-status") === "complete" ? "[x]" : "[ ]";
43
+ const trimmed = content.replace(/^\n+|\n+$/g, "");
44
+ const nestedIdx = trimmed.search(/\n\n-\s+\[/);
45
+ if (nestedIdx === -1) return `\n- ${checkbox} ${trimmed}`;
46
+ return `\n- ${checkbox} ${trimmed.slice(0, nestedIdx).trim()}${"\n" + trimmed.slice(nestedIdx).trimStart().replace(/^/gm, " ")}`;
47
+ }
48
+ });
49
+ td.addRule("confluence-macro-blockquote", {
50
+ filter: (node) => node.nodeName === "BLOCKQUOTE" && !!node.getAttribute("data-macro-type"),
51
+ replacement: (content, node) => {
52
+ const type = node.getAttribute("data-macro-type") ?? "";
53
+ const title = node.getAttribute("data-macro-title") ?? "";
54
+ return `\n\n> ${title ? `[${type} ${title}]` : `[${type}]`}\n>\n${content.replace(/^\n+|\n+$/g, "").replace(/^/gm, "> ")}\n\n`;
55
+ }
56
+ });
57
+ td.addRule("confluence-preserved-macro", {
58
+ filter: (node) => node.nodeName === "DIV" && !!node.getAttribute("data-confluence-macro-preserved"),
59
+ replacement: (_content, node) => {
60
+ return `\n\n<div data-confluence-macro="${Buffer.from(node.textContent ?? "", "utf-8").toString("base64")}"></div>\n\n`;
61
+ }
62
+ });
63
+ td.addRule("empty-wrapper", {
64
+ filter: (node) => ["DIV", "SPAN"].includes(node.nodeName) && !node.getAttribute("data-type") && node.textContent.trim() === "",
65
+ replacement: () => ""
66
+ });
67
+ cached = td;
68
+ return td;
69
+ }
70
+ //#endregion
71
+ export { buildTurndown };
@@ -0,0 +1,18 @@
1
+ //#region src/services/conversion/markdownToConfluence/conversion-state.ts
2
+ let convCtx = {};
3
+ let taskIdCounter = 0;
4
+ function beginConversion(options) {
5
+ convCtx = options;
6
+ taskIdCounter = 0;
7
+ }
8
+ function endConversion() {
9
+ convCtx = {};
10
+ }
11
+ function conversionOptions() {
12
+ return convCtx;
13
+ }
14
+ function nextTaskId() {
15
+ return ++taskIdCounter;
16
+ }
17
+ //#endregion
18
+ export { beginConversion, conversionOptions, endConversion, nextTaskId };
@@ -0,0 +1,56 @@
1
+ import { escapeXml } from "../../shared/xml-escaping.js";
2
+ import { preserveAsSentinel } from "../../shared/preserved-macros.js";
3
+ import { structuredMacro } from "../../shared/confluence-macro-builder.js";
4
+ import { toGitlabFileUrl } from "../../shared/gitlab-url.js";
5
+ import { mermaidToDrawio } from "./mermaid-to-drawio.js";
6
+ //#region src/services/conversion/markdownToConfluence/diagrams/mermaid-diagrams.ts
7
+ /** Macro draw.io référençant `filename` - diagramName ET attachment doivent porter le nom exact, sinon « fichier introuvable ». */
8
+ function buildDrawioMacro(filename, cfg) {
9
+ const params = [
10
+ ["diagramName", escapeXml(filename)],
11
+ ["attachment", escapeXml(filename)],
12
+ ["pageSize", "false"]
13
+ ];
14
+ if (cfg.width) params.push(["width", String(cfg.width)]);
15
+ return structuredMacro(cfg.macroName, {
16
+ schemaVersion: true,
17
+ params
18
+ });
19
+ }
20
+ /** Blocs ```mermaid → macro draw.io (sentinelle) dans le markdown + fichiers .drawio à uploader. */
21
+ function manageMermaidsInMarkdownFile(markdown, diagramBaseName, cfg, sourceFile, gitlab) {
22
+ const matches = [...markdown.matchAll(/```mermaid\r?\n([\s\S]*?)```/g)];
23
+ if (!matches.length) return {
24
+ markdown,
25
+ attachments: []
26
+ };
27
+ const attachments = [];
28
+ const parts = [];
29
+ let last = 0;
30
+ for (let i = 0; i < matches.length; i++) {
31
+ const m = matches[i];
32
+ const diagramName = matches.length === 1 ? diagramBaseName : `${diagramBaseName}-${i + 1}`;
33
+ const filename = `${diagramName}.drawio`;
34
+ const mermaidSrc = m[1].replace(/<a href='([^']*)'>/g, (_full, href) => `<a href='${toGitlabFileUrl(href, sourceFile, gitlab)}'>`);
35
+ const content = mermaidToDrawio(mermaidSrc, diagramName, {
36
+ edgeStyle: cfg.edgeStyle,
37
+ edgeAnchor: cfg.edgeAnchor,
38
+ colWidth: cfg.colWidth,
39
+ rowStep: cfg.rowStep
40
+ });
41
+ attachments.push({
42
+ filename,
43
+ content
44
+ });
45
+ const sentinel = preserveAsSentinel(buildDrawioMacro(filename, cfg));
46
+ parts.push(markdown.slice(last, m.index), `\n\n${sentinel}\n\n`);
47
+ last = m.index + m[0].length;
48
+ }
49
+ parts.push(markdown.slice(last));
50
+ return {
51
+ markdown: parts.join(""),
52
+ attachments
53
+ };
54
+ }
55
+ //#endregion
56
+ export { manageMermaidsInMarkdownFile };
@@ -0,0 +1,331 @@
1
+ import { escapeAttr } from "../../shared/xml-escaping.js";
2
+ //#region src/services/conversion/markdownToConfluence/diagrams/mermaid-to-drawio.ts
3
+ const RE_DIRECTION = /^(?:graph|flowchart)\s+(LR|RL|TB|TD|BT)/;
4
+ const RE_SUBGRAPH = /^subgraph\s+(\w+)(?:\["([^"]*)"\])?/;
5
+ const RE_NODE_STADIUM = /^(\w+)\(\["([\s\S]*)"\]\)$/;
6
+ const RE_NODE_RECT = /^(\w+)\["([\s\S]*)"\]$/;
7
+ const RE_EDGE = /^(\w+)\s*-->\s*(\w+)$/;
8
+ const RE_CLICK = /^click\s+(\w+)\s+href\s+"([^"]+)"/;
9
+ const RE_STYLE = /^style\s+(\w+)\s+(.+)$/;
10
+ const RE_CLASS_DEF = /^classDef\s+(\w+)\s+(.+)$/;
11
+ const RE_CLASS = /^class\s+([\w,\s]+)\s+(\w+)\s*;?$/;
12
+ /** Extrait `fill` et `stroke` d'une chaîne de directive style (`fill:#xxx,stroke:#yyy,…`). */
13
+ function parseFillStroke(directive) {
14
+ return {
15
+ fill: directive.match(/fill:\s*([^,;]+)/)?.[1]?.trim(),
16
+ stroke: directive.match(/stroke:\s*([^,;]+)/)?.[1]?.trim()
17
+ };
18
+ }
19
+ function parseMermaid(mermaid) {
20
+ const groups = [];
21
+ const nodes = [];
22
+ const edges = [];
23
+ const links = /* @__PURE__ */ new Map();
24
+ const styles = /* @__PURE__ */ new Map();
25
+ const classDefs = /* @__PURE__ */ new Map();
26
+ const nodeClasses = /* @__PURE__ */ new Map();
27
+ const nodeById = /* @__PURE__ */ new Map();
28
+ let direction = "LR";
29
+ let currentGroup = -1;
30
+ for (const raw of mermaid.split("\n")) {
31
+ const line = raw.trim();
32
+ if (!line || line.startsWith("```") || line.startsWith("%%")) continue;
33
+ if (line.startsWith("direction ")) continue;
34
+ const dir = line.match(RE_DIRECTION);
35
+ if (dir) {
36
+ direction = dir[1];
37
+ continue;
38
+ }
39
+ if (line.startsWith("graph ") || line.startsWith("flowchart ")) continue;
40
+ const sg = line.match(RE_SUBGRAPH);
41
+ if (sg) {
42
+ currentGroup = groups.length;
43
+ groups.push({
44
+ id: sg[1],
45
+ title: sg[2] ?? sg[1]
46
+ });
47
+ continue;
48
+ }
49
+ if (line === "end") {
50
+ currentGroup = -1;
51
+ continue;
52
+ }
53
+ const click = line.match(RE_CLICK);
54
+ if (click) {
55
+ links.set(click[1], click[2]);
56
+ continue;
57
+ }
58
+ const cdef = line.match(RE_CLASS_DEF);
59
+ if (cdef) {
60
+ classDefs.set(cdef[1], parseFillStroke(cdef[2]));
61
+ continue;
62
+ }
63
+ const cls = line.match(RE_CLASS);
64
+ if (cls) {
65
+ const className = cls[2];
66
+ for (const id of cls[1].split(",").map((s) => s.trim()).filter(Boolean)) nodeClasses.set(id, className);
67
+ continue;
68
+ }
69
+ const st = line.match(RE_STYLE);
70
+ if (st) {
71
+ styles.set(st[1], parseFillStroke(st[2]));
72
+ continue;
73
+ }
74
+ const edge = line.match(RE_EDGE);
75
+ if (edge) {
76
+ edges.push([edge[1], edge[2]]);
77
+ continue;
78
+ }
79
+ const stadium = line.match(RE_NODE_STADIUM);
80
+ const rect = stadium ? null : line.match(RE_NODE_RECT);
81
+ const m = stadium ?? rect;
82
+ if (m) {
83
+ const node = {
84
+ id: m[1],
85
+ label: m[2],
86
+ shape: stadium ? "stadium" : "rect",
87
+ group: currentGroup
88
+ };
89
+ nodes.push(node);
90
+ nodeById.set(node.id, node);
91
+ }
92
+ }
93
+ for (const [id, url] of links) {
94
+ const n = nodeById.get(id);
95
+ if (n) n.link = url;
96
+ }
97
+ for (const n of nodes) {
98
+ const inline = styles.get(n.id);
99
+ const className = nodeClasses.get(n.id);
100
+ const fromClass = className ? classDefs.get(className) : void 0;
101
+ n.fill = inline?.fill ?? fromClass?.fill;
102
+ n.stroke = inline?.stroke ?? fromClass?.stroke;
103
+ }
104
+ const known = new Set(nodes.map((n) => n.id));
105
+ return {
106
+ groups,
107
+ nodes,
108
+ edges: edges.filter(([a, b]) => known.has(a) && known.has(b)),
109
+ direction
110
+ };
111
+ }
112
+ const DEFAULT_LAYOUT = {
113
+ colWidth: 480,
114
+ rowStep: 110,
115
+ nodeWidth: 280,
116
+ nodeHeight: 60,
117
+ edgeStyle: "curved",
118
+ edgeAnchor: "side"
119
+ };
120
+ const TITLE_HEIGHT = 30;
121
+ const MARGIN = 40;
122
+ /** Listes d'adjacence (parents / enfants) du graphe, restreintes aux nœuds connus. */
123
+ function buildAdjacency(nodes, edges) {
124
+ const idSet = new Set(nodes.map((n) => n.id));
125
+ const preds = new Map(nodes.map((n) => [n.id, []]));
126
+ const succs = new Map(nodes.map((n) => [n.id, []]));
127
+ for (const [from, to] of edges) {
128
+ if (!idSet.has(from) || !idSet.has(to)) continue;
129
+ succs.get(from).push(to);
130
+ preds.get(to).push(from);
131
+ }
132
+ return {
133
+ preds,
134
+ succs
135
+ };
136
+ }
137
+ /** Retire les clés `undefined` pour qu'elles n'écrasent pas les valeurs de DEFAULT_LAYOUT au merge. */
138
+ function cleanLayout(layout) {
139
+ return Object.fromEntries(Object.entries(layout).filter(([, v]) => v !== void 0));
140
+ }
141
+ /** Niveau de chaque nœud = max(niveaux parents)+1 (0 si racine). Cycles détectés par DFS et ignorés pour garder la cascade gauche→droite. */
142
+ function assignLevels(nodes, preds, succs) {
143
+ const backEdges = /* @__PURE__ */ new Set();
144
+ const state = /* @__PURE__ */ new Map();
145
+ const visit = (u) => {
146
+ state.set(u, 0);
147
+ for (const v of succs.get(u) ?? []) {
148
+ const s = state.get(v);
149
+ if (s === 0) backEdges.add(`${u}->${v}`);
150
+ else if (s === void 0) visit(v);
151
+ }
152
+ state.set(u, 1);
153
+ };
154
+ const isRoot = (id) => (preds.get(id)?.length ?? 0) === 0;
155
+ for (const n of nodes) if (isRoot(n.id) && !state.has(n.id)) visit(n.id);
156
+ for (const n of nodes) if (!state.has(n.id)) visit(n.id);
157
+ const level = /* @__PURE__ */ new Map();
158
+ const levelOf = (id) => {
159
+ const cached = level.get(id);
160
+ if (cached !== void 0) return cached;
161
+ let maxParent = -1;
162
+ for (const p of preds.get(id) ?? []) {
163
+ if (backEdges.has(`${p}->${id}`)) continue;
164
+ maxParent = Math.max(maxParent, levelOf(p));
165
+ }
166
+ const lv = maxParent + 1;
167
+ level.set(id, lv);
168
+ return lv;
169
+ };
170
+ for (const n of nodes) levelOf(n.id);
171
+ return level;
172
+ }
173
+ /** Ordonne verticalement chaque colonne pour limiter les croisements (méthode barycentrique, 2 passes aller-retour). */
174
+ function orderColumns(columns, preds, succs, level) {
175
+ const maxLevel = columns.length - 1;
176
+ const order = /* @__PURE__ */ new Map();
177
+ columns.forEach((col) => col.forEach((id, i) => order.set(id, i)));
178
+ const barycenter = (id, neighbors, atLevel) => {
179
+ const ns = neighbors.filter((n) => level.get(n) === atLevel);
180
+ return ns.length ? ns.reduce((s, n) => s + order.get(n), 0) / ns.length : order.get(id);
181
+ };
182
+ const sortColumn = (l, adjacency, atLevel) => {
183
+ columns[l].sort((a, b) => barycenter(a, adjacency.get(a), atLevel) - barycenter(b, adjacency.get(b), atLevel));
184
+ columns[l].forEach((id, i) => order.set(id, i));
185
+ };
186
+ for (let pass = 0; pass < 2; pass++) {
187
+ for (let l = 1; l <= maxLevel; l++) sortColumn(l, preds, l - 1);
188
+ for (let l = maxLevel - 1; l >= 0; l--) sortColumn(l, succs, l + 1);
189
+ }
190
+ }
191
+ /** Mode subgraphs : une colonne par subgraph, cases empilées dans l'ordre. */
192
+ function columnPositions(nodes, lo) {
193
+ const positions = /* @__PURE__ */ new Map();
194
+ const rowInGroup = /* @__PURE__ */ new Map();
195
+ for (const node of nodes) {
196
+ const gi = node.group >= 0 ? node.group : 0;
197
+ const row = rowInGroup.get(gi) ?? 0;
198
+ rowInGroup.set(gi, row + 1);
199
+ positions.set(node.id, {
200
+ x: MARGIN + gi * lo.colWidth,
201
+ y: 70 + row * lo.rowStep
202
+ });
203
+ }
204
+ return positions;
205
+ }
206
+ /** Mode cascade : placement par niveaux (parents à gauche, enfants à droite en LR). */
207
+ function levelPositions(nodes, edges, direction, lo) {
208
+ if (!nodes.length) return /* @__PURE__ */ new Map();
209
+ const { preds, succs } = buildAdjacency(nodes, edges);
210
+ const level = assignLevels(nodes, preds, succs);
211
+ const maxLevel = Math.max(0, ...level.values());
212
+ const columns = Array.from({ length: maxLevel + 1 }, () => []);
213
+ for (const n of nodes) columns[level.get(n.id)].push(n.id);
214
+ orderColumns(columns, preds, succs, level);
215
+ const horizontal = direction === "LR" || direction === "RL";
216
+ const reverse = direction === "RL" || direction === "BT";
217
+ const mainStep = horizontal ? lo.colWidth : lo.rowStep;
218
+ const crossStep = horizontal ? lo.rowStep : lo.colWidth;
219
+ const crossCenter = horizontal ? 400 : 600;
220
+ const positions = /* @__PURE__ */ new Map();
221
+ for (let l = 0; l <= maxLevel; l++) {
222
+ const col = columns[l];
223
+ const span = (col.length - 1) * crossStep;
224
+ const mainIdx = reverse ? maxLevel - l : l;
225
+ col.forEach((id, i) => {
226
+ const main = MARGIN + mainIdx * mainStep;
227
+ const cross = crossCenter + i * crossStep - span / 2;
228
+ positions.set(id, horizontal ? {
229
+ x: main,
230
+ y: cross
231
+ } : {
232
+ x: cross,
233
+ y: main
234
+ });
235
+ });
236
+ }
237
+ return positions;
238
+ }
239
+ /** Tracé (routage) de la flèche selon le style choisi. */
240
+ function edgeRouting(kind) {
241
+ const arrow = "html=1;endArrow=block;orthogonalLoop=1;jettySize=auto;";
242
+ switch (kind) {
243
+ case "straight": return arrow;
244
+ case "orthogonal": return `edgeStyle=orthogonalEdgeStyle;rounded=1;${arrow}`;
245
+ default: return `edgeStyle=orthogonalEdgeStyle;curved=1;${arrow}`;
246
+ }
247
+ }
248
+ /** Points d'ancrage sortie/entrée : "auto" = flottant, "side" = côtés alignés au flux (inversé en RL/BT). */
249
+ function edgeAnchorStyle(anchor, direction) {
250
+ if (anchor !== "side") return "";
251
+ const horizontal = direction === "LR" || direction === "RL";
252
+ const reverse = direction === "RL" || direction === "BT";
253
+ let exit;
254
+ let entry;
255
+ if (horizontal) {
256
+ exit = reverse ? [0, .5] : [1, .5];
257
+ entry = reverse ? [1, .5] : [0, .5];
258
+ } else {
259
+ exit = reverse ? [.5, 0] : [.5, 1];
260
+ entry = reverse ? [.5, 1] : [.5, 0];
261
+ }
262
+ return `exitX=${exit[0]};exitY=${exit[1]};exitPerimeter=0;entryX=${entry[0]};entryY=${entry[1]};entryPerimeter=0;`;
263
+ }
264
+ function styleFor(node) {
265
+ const base = "whiteSpace=wrap;html=1;align=center;verticalAlign=middle;fontSize=11;";
266
+ const rounded = node.shape === "stadium" ? "rounded=1;arcSize=50;" : "rounded=0;";
267
+ if (node.fill) return `${rounded}${base}fillColor=${node.fill};strokeColor=${node.stroke ?? "#666666"};`;
268
+ return `${rounded}${base}`;
269
+ }
270
+ function nodeCell(node, pos, lo) {
271
+ const geo = `<mxGeometry x="${pos.x}" y="${pos.y}" width="${lo.nodeWidth}" height="${lo.nodeHeight}" as="geometry" />`;
272
+ const style = styleFor(node);
273
+ const label = escapeAttr(node.label);
274
+ if (node.link) return [
275
+ ` <UserObject label="${label}" link="${escapeAttr(node.link)}" id="${node.id}">`,
276
+ ` <mxCell style="${style}" vertex="1" parent="1">`,
277
+ ` ${geo}`,
278
+ ` </mxCell>`,
279
+ ` </UserObject>`
280
+ ].join("\n");
281
+ return [
282
+ ` <mxCell id="${node.id}" value="${label}" style="${style}" vertex="1" parent="1">`,
283
+ ` ${geo}`,
284
+ ` </mxCell>`
285
+ ].join("\n");
286
+ }
287
+ function titleCell(group, gi, lo) {
288
+ return [
289
+ ` <mxCell id="title_${group.id}" value="${escapeAttr(group.title)}" style="text;html=1;fontStyle=1;fontSize=14;align=center;verticalAlign=middle;" vertex="1" parent="1">`,
290
+ ` <mxGeometry x="${MARGIN + gi * lo.colWidth}" y="0" width="${lo.nodeWidth}" height="${TITLE_HEIGHT}" as="geometry" />`,
291
+ ` </mxCell>`
292
+ ].join("\n");
293
+ }
294
+ function edgeCell(source, target, i, style) {
295
+ return [
296
+ ` <mxCell id="e${i}" style="${style}" edge="1" parent="1" source="${source}" target="${target}">`,
297
+ ` <mxGeometry relative="1" as="geometry" />`,
298
+ ` </mxCell>`
299
+ ].join("\n");
300
+ }
301
+ function mermaidToDrawio(mermaid, diagramName = "diagram", layout = {}) {
302
+ const lo = {
303
+ ...DEFAULT_LAYOUT,
304
+ ...cleanLayout(layout)
305
+ };
306
+ const { groups, nodes, edges, direction } = parseMermaid(mermaid);
307
+ const positions = groups.length ? columnPositions(nodes, lo) : levelPositions(nodes, edges, direction, lo);
308
+ const cells = [];
309
+ if (groups.length) groups.forEach((g, gi) => cells.push(titleCell(g, gi, lo)));
310
+ for (const node of nodes) cells.push(nodeCell(node, positions.get(node.id) ?? {
311
+ x: MARGIN,
312
+ y: MARGIN
313
+ }, lo));
314
+ const edgeStyle = edgeRouting(lo.edgeStyle) + edgeAnchorStyle(lo.edgeAnchor, direction);
315
+ edges.forEach(([a, b], i) => cells.push(edgeCell(a, b, i, edgeStyle)));
316
+ return [
317
+ `<mxfile host="app.diagrams.net" type="device">`,
318
+ ` <diagram id="${escapeAttr(diagramName)}" name="${escapeAttr(diagramName)}">`,
319
+ ` <mxGraphModel dx="1200" dy="800" grid="0" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="1169" pageHeight="826" math="0" shadow="0">`,
320
+ ` <root>`,
321
+ ` <mxCell id="0" />`,
322
+ ` <mxCell id="1" parent="0" />`,
323
+ cells.join("\n"),
324
+ ` </root>`,
325
+ ` </mxGraphModel>`,
326
+ ` </diagram>`,
327
+ `</mxfile>`
328
+ ].join("\n");
329
+ }
330
+ //#endregion
331
+ export { mermaidToDrawio };