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.
- package/LICENSE +21 -0
- package/README.md +104 -0
- package/assets/drawio-viewer/README.md +13 -0
- package/assets/drawio-viewer/viewer-static.min.js +8640 -0
- package/bin/dev.cmd +3 -0
- package/bin/dev.js +5 -0
- package/bin/run.cmd +3 -0
- package/bin/run.js +7 -0
- package/dist/clients/confluence/clients/attachments-client.js +74 -0
- package/dist/clients/confluence/clients/folders-client.js +111 -0
- package/dist/clients/confluence/clients/pages-client.js +128 -0
- package/dist/clients/confluence/confluence-client.js +44 -0
- package/dist/clients/confluence/utils/confluence-url.js +25 -0
- package/dist/clients/confluence/utils/confluence-util.js +20 -0
- package/dist/commands/init.js +29 -0
- package/dist/commands/publish.js +34 -0
- package/dist/commands/pull.js +54 -0
- package/dist/commands/sync.js +27 -0
- package/dist/commands/tree.js +17 -0
- package/dist/config/codoc-config-atlassian.js +89 -0
- package/dist/config/codoc-config-raw.js +21 -0
- package/dist/config/codoc-config.js +50 -0
- package/dist/config/codoc-paths.js +9 -0
- package/dist/hooks/command_not_found.js +7 -0
- package/dist/hooks/init/check-for-update.js +68 -0
- package/dist/hooks/init/load-env.js +15 -0
- package/dist/index.js +2 -0
- package/dist/services/codoc-id.js +7 -0
- package/dist/services/confluence/attachments.js +56 -0
- package/dist/services/confluence/folders.js +42 -0
- package/dist/services/confluence/labels.js +17 -0
- package/dist/services/confluence/pages.js +28 -0
- package/dist/services/conversion/confluenceToMarkdown/diagrams/drawio-diagrams.js +77 -0
- package/dist/services/conversion/confluenceToMarkdown/diagrams/drawio-to-image.js +131 -0
- package/dist/services/conversion/confluenceToMarkdown/index.js +17 -0
- package/dist/services/conversion/confluenceToMarkdown/preprocess/macro-converters.js +68 -0
- package/dist/services/conversion/confluenceToMarkdown/preprocess/preprocess.js +113 -0
- package/dist/services/conversion/confluenceToMarkdown/turndown.js +71 -0
- package/dist/services/conversion/markdownToConfluence/conversion-state.js +18 -0
- package/dist/services/conversion/markdownToConfluence/diagrams/mermaid-diagrams.js +56 -0
- package/dist/services/conversion/markdownToConfluence/diagrams/mermaid-to-drawio.js +331 -0
- package/dist/services/conversion/markdownToConfluence/index.js +48 -0
- package/dist/services/conversion/markdownToConfluence/languages.js +66 -0
- package/dist/services/conversion/markdownToConfluence/links.js +40 -0
- package/dist/services/conversion/markdownToConfluence/raw-html/raw-html.js +86 -0
- package/dist/services/conversion/markdownToConfluence/raw-html/task-lists.js +59 -0
- package/dist/services/conversion/markdownToConfluence/render/blocks.js +171 -0
- package/dist/services/conversion/markdownToConfluence/render/inline.js +44 -0
- package/dist/services/conversion/markdownToConfluence/render/page.js +31 -0
- package/dist/services/conversion/shared/confluence-macro-builder.js +11 -0
- package/dist/services/conversion/shared/gitlab-url.js +16 -0
- package/dist/services/conversion/shared/image-attachments.js +57 -0
- package/dist/services/conversion/shared/jira.js +7 -0
- package/dist/services/conversion/shared/macro-types.js +12 -0
- package/dist/services/conversion/shared/preserved-macros.js +8 -0
- package/dist/services/conversion/shared/read-storage-format.js +29 -0
- package/dist/services/conversion/shared/regex-cache.js +14 -0
- package/dist/services/conversion/shared/xml-escaping.js +19 -0
- package/dist/services/ensure-env.js +59 -0
- package/dist/services/files-service.js +74 -0
- package/dist/services/git/default-branch.js +33 -0
- package/dist/services/init-templates/agent-md-init.js +55 -0
- package/dist/services/init-templates/doc-guide-init.js +131 -0
- package/dist/services/init-templates/env-init.js +12 -0
- package/dist/services/init-templates/yaml-init.js +56 -0
- package/dist/services/lock/lock-file.js +61 -0
- package/dist/services/log/init-printer.js +50 -0
- package/dist/services/log/logger.js +131 -0
- package/dist/services/prompt.js +32 -0
- package/dist/services/slugify.js +6 -0
- package/dist/types/codoc-types.js +1 -0
- package/dist/use-cases/init/init.js +150 -0
- package/dist/use-cases/publish/publish.js +166 -0
- package/dist/use-cases/pull/parse-page-input.js +22 -0
- package/dist/use-cases/pull/pull.js +337 -0
- package/dist/use-cases/shared/codoc-yaml.js +45 -0
- package/dist/use-cases/shared/confluence-client-registry.js +27 -0
- package/dist/use-cases/shared/env-select.js +23 -0
- package/dist/use-cases/shared/fetch-page.js +11 -0
- package/dist/use-cases/shared/list-documents.js +172 -0
- package/dist/use-cases/shared/pull-folder.js +90 -0
- package/dist/use-cases/shared/render-remote-page.js +71 -0
- package/dist/use-cases/sync/attachment-upload.js +31 -0
- package/dist/use-cases/sync/sync-actions.js +170 -0
- package/dist/use-cases/sync/sync-associate.js +41 -0
- package/dist/use-cases/sync/sync-entries.js +36 -0
- package/dist/use-cases/sync/sync-handlers.js +140 -0
- package/dist/use-cases/sync/sync-helpers.js +8 -0
- package/dist/use-cases/sync/sync.js +94 -0
- package/dist/use-cases/tree/docs-tree.js +141 -0
- package/oclif.manifest.json +255 -0
- package/package.json +98 -0
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { escapeXml } from "../shared/xml-escaping.js";
|
|
2
|
+
import { structuredMacro } from "../shared/confluence-macro-builder.js";
|
|
3
|
+
import { beginConversion, endConversion } from "./conversion-state.js";
|
|
4
|
+
import { renderTokens } from "./render/blocks.js";
|
|
5
|
+
import { manageMermaidsInMarkdownFile } from "./diagrams/mermaid-diagrams.js";
|
|
6
|
+
import { buildConfluencePage } from "./render/page.js";
|
|
7
|
+
import { marked } from "marked";
|
|
8
|
+
//#region src/services/conversion/markdownToConfluence/index.ts
|
|
9
|
+
/** 📅 YYYY-MM-DD → <time datetime="YYYY-MM-DD"/>. */
|
|
10
|
+
function restoreSpecialElements(xml) {
|
|
11
|
+
return xml.replace(/📅\s*(\d{4}-\d{2}-\d{2})/g, (_, date) => `<time datetime="${date}"/>`);
|
|
12
|
+
}
|
|
13
|
+
function restoreStatusMacros(xml) {
|
|
14
|
+
return xml.replace(/<span[^>]*\sdata-confluence-status="([^"]*)"[^>]*>([\s\S]*?)<\/span>/gi, (_, colour, inner) => {
|
|
15
|
+
const title = inner.replace(/<[^>]+>/g, "").trim();
|
|
16
|
+
return structuredMacro("status", {
|
|
17
|
+
schemaVersion: true,
|
|
18
|
+
params: [["title", escapeXml(title)], ["colour", escapeXml(colour)]]
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
function cleanup(xml) {
|
|
23
|
+
return xml.replace(/[ \t]+\n/g, "\n").replace(/\n{3,}/g, "\n\n").replace(/<p>\s*<\/p>/g, "").trim();
|
|
24
|
+
}
|
|
25
|
+
function convertMarkdownToConfluence(markdown, options) {
|
|
26
|
+
beginConversion(options ?? {});
|
|
27
|
+
try {
|
|
28
|
+
const tokens = marked.lexer(markdown, { gfm: true });
|
|
29
|
+
return restoreSpecialElements(restoreStatusMacros(cleanup(renderTokens(tokens))));
|
|
30
|
+
} finally {
|
|
31
|
+
endConversion();
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
/** Sens publish : Markdown → page Confluence (Storage Format + pièces jointes). */
|
|
35
|
+
function renderConfluencePage(markdown, options) {
|
|
36
|
+
const { markdown: processed, attachments } = manageMermaidsInMarkdownFile(markdown, options.diagramBaseName, options.drawio, options.sourceFile, options.gitlab);
|
|
37
|
+
const body = convertMarkdownToConfluence(processed, {
|
|
38
|
+
sourceFile: options.sourceFile,
|
|
39
|
+
jira: options.jira,
|
|
40
|
+
gitlab: options.gitlab
|
|
41
|
+
});
|
|
42
|
+
return {
|
|
43
|
+
xml: buildConfluencePage(body, options.generatedAt, options.generateSummary),
|
|
44
|
+
attachments
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
//#endregion
|
|
48
|
+
export { convertMarkdownToConfluence, renderConfluencePage };
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
//#region src/services/conversion/markdownToConfluence/languages.ts
|
|
2
|
+
const CONFLUENCE_LANGUAGES = /* @__PURE__ */ new Set([
|
|
3
|
+
"actionscript3",
|
|
4
|
+
"bash",
|
|
5
|
+
"csharp",
|
|
6
|
+
"coldfusion",
|
|
7
|
+
"cpp",
|
|
8
|
+
"css",
|
|
9
|
+
"delphi",
|
|
10
|
+
"diff",
|
|
11
|
+
"erlang",
|
|
12
|
+
"groovy",
|
|
13
|
+
"html",
|
|
14
|
+
"java",
|
|
15
|
+
"javafx",
|
|
16
|
+
"javascript",
|
|
17
|
+
"json",
|
|
18
|
+
"lua",
|
|
19
|
+
"none",
|
|
20
|
+
"perl",
|
|
21
|
+
"php",
|
|
22
|
+
"powershell",
|
|
23
|
+
"python",
|
|
24
|
+
"ruby",
|
|
25
|
+
"scala",
|
|
26
|
+
"sql",
|
|
27
|
+
"vbnet",
|
|
28
|
+
"xml",
|
|
29
|
+
"yaml"
|
|
30
|
+
]);
|
|
31
|
+
const LANGUAGE_MAP = {
|
|
32
|
+
ts: "javascript",
|
|
33
|
+
tsx: "javascript",
|
|
34
|
+
typescript: "javascript",
|
|
35
|
+
jsx: "javascript",
|
|
36
|
+
js: "javascript",
|
|
37
|
+
mjs: "javascript",
|
|
38
|
+
cjs: "javascript",
|
|
39
|
+
sh: "bash",
|
|
40
|
+
shell: "bash",
|
|
41
|
+
zsh: "bash",
|
|
42
|
+
fish: "bash",
|
|
43
|
+
htm: "html",
|
|
44
|
+
svg: "xml",
|
|
45
|
+
yml: "yaml",
|
|
46
|
+
cs: "csharp",
|
|
47
|
+
"c#": "csharp",
|
|
48
|
+
vb: "vbnet",
|
|
49
|
+
"c++": "cpp",
|
|
50
|
+
c: "cpp",
|
|
51
|
+
jsonc: "json",
|
|
52
|
+
text: "none",
|
|
53
|
+
plaintext: "none",
|
|
54
|
+
txt: "none",
|
|
55
|
+
console: "bash",
|
|
56
|
+
terminal: "bash"
|
|
57
|
+
};
|
|
58
|
+
/** Normalise un langage Markdown vers un langage supporté par la macro code (sinon "none"). */
|
|
59
|
+
function confluenceLanguage(lang) {
|
|
60
|
+
if (!lang) return "none";
|
|
61
|
+
const normalized = lang.trim().toLowerCase();
|
|
62
|
+
const mapped = LANGUAGE_MAP[normalized] ?? normalized;
|
|
63
|
+
return CONFLUENCE_LANGUAGES.has(mapped) ? mapped : "none";
|
|
64
|
+
}
|
|
65
|
+
//#endregion
|
|
66
|
+
export { confluenceLanguage };
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { escapeAttr, escapeXml } from "../shared/xml-escaping.js";
|
|
2
|
+
import { JIRA_KEY_RE } from "../shared/jira.js";
|
|
3
|
+
import { structuredMacro } from "../shared/confluence-macro-builder.js";
|
|
4
|
+
import { conversionOptions } from "./conversion-state.js";
|
|
5
|
+
import { toGitlabFileUrl } from "../shared/gitlab-url.js";
|
|
6
|
+
//#region src/services/conversion/markdownToConfluence/links.ts
|
|
7
|
+
function resolveHref(href, title) {
|
|
8
|
+
if (/^https?:\/\/|^mailto:|^#/.test(href)) return href;
|
|
9
|
+
const sourceFile = conversionOptions().sourceFile;
|
|
10
|
+
if (!sourceFile) return href;
|
|
11
|
+
let withFragment = href;
|
|
12
|
+
if (!href.includes("#") && title) {
|
|
13
|
+
const lineMatch = title.match(/^L(\d+)$/);
|
|
14
|
+
if (lineMatch) withFragment = `${href}#L${lineMatch[1]}`;
|
|
15
|
+
}
|
|
16
|
+
return toGitlabFileUrl(withFragment, sourceFile, conversionOptions().gitlab);
|
|
17
|
+
}
|
|
18
|
+
function buildJiraMacro(key, href) {
|
|
19
|
+
const jira = conversionOptions().jira;
|
|
20
|
+
if (!jira?.serverId && !jira?.server) return `<a href="${escapeAttr(href)}">${escapeXml(key)}</a>`;
|
|
21
|
+
const params = [["key", escapeXml(key)]];
|
|
22
|
+
if (jira.serverId) params.push(["serverId", escapeXml(jira.serverId)]);
|
|
23
|
+
if (jira.server) params.push(["server", escapeXml(jira.server)]);
|
|
24
|
+
return structuredMacro("jira", {
|
|
25
|
+
schemaVersion: true,
|
|
26
|
+
params
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
const USER_MENTION_SCHEME = "user:";
|
|
30
|
+
function emitConfluenceLink(rawHref, detectText, labelHtml, title) {
|
|
31
|
+
if (detectText.includes("Tableau Jira")) return `<a href="${escapeAttr(resolveHref(rawHref, title))}" data-card-appearance="block"></a>`;
|
|
32
|
+
if (JIRA_KEY_RE.test(detectText.trim()) && /\/browse\//i.test(rawHref)) return buildJiraMacro(detectText.trim(), rawHref);
|
|
33
|
+
if (rawHref.toLowerCase().startsWith(USER_MENTION_SCHEME)) {
|
|
34
|
+
const accountId = rawHref.slice(5);
|
|
35
|
+
return `<ac:link><ri:user ri:account-id="${escapeAttr(accountId)}" /></ac:link>`;
|
|
36
|
+
}
|
|
37
|
+
return `<a href="${escapeAttr(resolveHref(rawHref, title))}">${labelHtml}</a>`;
|
|
38
|
+
}
|
|
39
|
+
//#endregion
|
|
40
|
+
export { buildJiraMacro, emitConfluenceLink };
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { escapeXml, unescapeXml } from "../../shared/xml-escaping.js";
|
|
2
|
+
import { findDivEnd } from "../../shared/read-storage-format.js";
|
|
3
|
+
import { RICH_BODY_MACRO_TYPES_PATTERN } from "../../shared/macro-types.js";
|
|
4
|
+
import { JIRA_KEY_PATTERN } from "../../shared/jira.js";
|
|
5
|
+
import { confluenceLanguage } from "../languages.js";
|
|
6
|
+
import { buildJiraMacro } from "../links.js";
|
|
7
|
+
import { taskListDivToXml } from "./task-lists.js";
|
|
8
|
+
//#region src/services/conversion/markdownToConfluence/raw-html/raw-html.ts
|
|
9
|
+
const RAW_JIRA_LINK_RE = new RegExp(`<a\\s+href="([^"]*/browse/(${JIRA_KEY_PATTERN})[^"]*)"[^>]*>([^<]*)</a>`, "gi");
|
|
10
|
+
const RAW_BLOCKQUOTE_MACRO_RE = new RegExp(`<blockquote([^>]*)data-macro-type="(${RICH_BODY_MACRO_TYPES_PATTERN})"([^>]*)>([\\s\\S]*?)</blockquote>`, "gi");
|
|
11
|
+
function expandTaskListDivs(html) {
|
|
12
|
+
if (!html.includes("data-ac-task-list")) return html;
|
|
13
|
+
const parts = [];
|
|
14
|
+
let pos = 0;
|
|
15
|
+
while (pos < html.length) {
|
|
16
|
+
const idx = html.indexOf("<div data-ac-task-list", pos);
|
|
17
|
+
if (idx === -1) {
|
|
18
|
+
parts.push(html.slice(pos));
|
|
19
|
+
break;
|
|
20
|
+
}
|
|
21
|
+
parts.push(html.slice(pos, idx));
|
|
22
|
+
const end = findDivEnd(html, idx);
|
|
23
|
+
parts.push(taskListDivToXml(html.slice(idx, end)));
|
|
24
|
+
pos = end;
|
|
25
|
+
}
|
|
26
|
+
return parts.join("");
|
|
27
|
+
}
|
|
28
|
+
function restorePreservedMacros(html) {
|
|
29
|
+
let s = html;
|
|
30
|
+
if (s.includes("data-confluence-macro=")) s = s.replace(/<div[^>]*\sdata-confluence-macro="([^"]*)"[^>]*>\s*<\/div>/gi, (_, b64) => Buffer.from(b64, "base64").toString("utf-8"));
|
|
31
|
+
if (s.includes("data-confluence-macro-preserved")) s = s.replace(/<div[^>]*data-confluence-macro-preserved[^>]*>([\s\S]*?)<\/div>/gi, (_, escaped) => unescapeXml(escaped));
|
|
32
|
+
return s;
|
|
33
|
+
}
|
|
34
|
+
const stripHtmlComments = (s) => s.replace(/<!--[\s\S]*?-->/g, "");
|
|
35
|
+
function rawBlockquotesToMacros(s) {
|
|
36
|
+
return s.replace(RAW_BLOCKQUOTE_MACRO_RE, (fullMatch, _before, type, _after, content) => {
|
|
37
|
+
const title = fullMatch.match(/data-macro-title="([^"]*)"/i)?.[1]?.trim() ?? "";
|
|
38
|
+
const lines = [`<ac:structured-macro ac:name="${type}">`];
|
|
39
|
+
if (title) lines.push(` <ac:parameter ac:name="title">${escapeXml(title)}</ac:parameter>`);
|
|
40
|
+
lines.push(` <ac:rich-text-body>${content}</ac:rich-text-body>`);
|
|
41
|
+
lines.push(`</ac:structured-macro>`);
|
|
42
|
+
return lines.join("\n");
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
function rawJiraLinksToMacros(s) {
|
|
46
|
+
return s.replace(RAW_JIRA_LINK_RE, (full, url, key, text) => text.trim() === key ? buildJiraMacro(key, url) : full);
|
|
47
|
+
}
|
|
48
|
+
function rawLocalImagesToAttachments(s) {
|
|
49
|
+
return s.replace(/<img\s[^>]*src="(?!https?:\/\/)([^"]+)"[^>]*\/?>/gi, (_, src) => {
|
|
50
|
+
return `<ac:image><ri:attachment ri:filename="${src.split("/").pop() ?? src}" /></ac:image>`;
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
const rawExternalImagesToUrls = (s) => s.replace(/<img\s[^>]*src="(https?:\/\/[^"]+)"[^>]*\/?>/gi, (_, url) => `<ac:image><ri:url ri:value="${url}" /></ac:image>`);
|
|
54
|
+
function rawCodeBlocksToMacros(s) {
|
|
55
|
+
return s.replace(/<pre><code(?:\s+class="language-([^"]+)")?>([\s\S]*?)<\/code><\/pre>/gi, (_, lang, code) => {
|
|
56
|
+
const cfLang = confluenceLanguage(lang);
|
|
57
|
+
const decoded = unescapeXml(code);
|
|
58
|
+
const lines = [`<ac:structured-macro ac:name="code">`];
|
|
59
|
+
if (cfLang && cfLang !== "none") lines.push(` <ac:parameter ac:name="language">${cfLang}</ac:parameter>`);
|
|
60
|
+
lines.push(` <ac:plain-text-body><![CDATA[${decoded}]]></ac:plain-text-body>`);
|
|
61
|
+
lines.push(`</ac:structured-macro>`);
|
|
62
|
+
return lines.join("\n");
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
const stripUnsupportedTags = (s) => s.replace(/<\/?(div|section|article|aside|header|footer|main|nav|details|summary|figure|figcaption|template|time|mark|ruby|rp|rt)[^>]*>/gi, "");
|
|
66
|
+
const stripNonColorSpans = (s) => s.replace(/<span(?![^>]*\bcolor\b)[^>]*>([\s\S]*?)<\/span>/gi, "$1").replace(/<span(?![^>]*\bcolor\b)[^>]*>/gi, "");
|
|
67
|
+
const stripProprietaryAttributes = (s) => s.replace(/\s+(?:local-id|ac:local-id|data-(?!(?:card-appearance|layout|confluence-status)\b)[\w-]+)="[^"]*"/gi, "");
|
|
68
|
+
const RAW_HTML_STEPS = [
|
|
69
|
+
stripHtmlComments,
|
|
70
|
+
rawBlockquotesToMacros,
|
|
71
|
+
rawJiraLinksToMacros,
|
|
72
|
+
rawLocalImagesToAttachments,
|
|
73
|
+
rawExternalImagesToUrls,
|
|
74
|
+
rawCodeBlocksToMacros,
|
|
75
|
+
stripUnsupportedTags,
|
|
76
|
+
stripNonColorSpans,
|
|
77
|
+
stripProprietaryAttributes
|
|
78
|
+
];
|
|
79
|
+
function sanitizeRawHtml(html) {
|
|
80
|
+
let processed = expandTaskListDivs(html);
|
|
81
|
+
processed = restorePreservedMacros(processed);
|
|
82
|
+
for (const step of RAW_HTML_STEPS) processed = step(processed);
|
|
83
|
+
return processed.trim();
|
|
84
|
+
}
|
|
85
|
+
//#endregion
|
|
86
|
+
export { sanitizeRawHtml };
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { findDivEnd } from "../../shared/read-storage-format.js";
|
|
2
|
+
import { nextTaskId } from "../conversion-state.js";
|
|
3
|
+
//#region src/services/conversion/markdownToConfluence/raw-html/task-lists.ts
|
|
4
|
+
function emitTask(id, status, body) {
|
|
5
|
+
return [
|
|
6
|
+
`<ac:task>`,
|
|
7
|
+
` <ac:task-id>${id}</ac:task-id>`,
|
|
8
|
+
` <ac:task-status>${status}</ac:task-status>`,
|
|
9
|
+
` <ac:task-body><span class="placeholder-inline-tasks">${body}</span></ac:task-body>`,
|
|
10
|
+
`</ac:task>`
|
|
11
|
+
].join("\n");
|
|
12
|
+
}
|
|
13
|
+
function taskListDivToXml(html) {
|
|
14
|
+
const openEnd = html.indexOf(">");
|
|
15
|
+
if (openEnd === -1) return html;
|
|
16
|
+
const lastClose = html.lastIndexOf("</div>");
|
|
17
|
+
const inner = html.slice(openEnd + 1, lastClose < 0 ? html.length : lastClose);
|
|
18
|
+
const parts = [];
|
|
19
|
+
let pos = 0;
|
|
20
|
+
while (pos < inner.length) {
|
|
21
|
+
const divIdx = inner.indexOf("<div", pos);
|
|
22
|
+
if (divIdx === -1) break;
|
|
23
|
+
const tagEnd = inner.indexOf(">", divIdx);
|
|
24
|
+
if (tagEnd === -1) break;
|
|
25
|
+
const tag = inner.slice(divIdx, tagEnd + 1);
|
|
26
|
+
const end = findDivEnd(inner, divIdx);
|
|
27
|
+
if (tag.includes("data-ac-task-list")) parts.push(taskListDivToXml(inner.slice(divIdx, end)));
|
|
28
|
+
else if (tag.includes("data-ac-task")) parts.push(taskDivToXml(inner.slice(divIdx, end)));
|
|
29
|
+
pos = end;
|
|
30
|
+
}
|
|
31
|
+
return `<ac:task-list>\n${parts.join("\n")}\n</ac:task-list>`;
|
|
32
|
+
}
|
|
33
|
+
function taskDivToXml(html) {
|
|
34
|
+
const status = html.match(/data-status="(\w+)"/)?.[1] ?? "incomplete";
|
|
35
|
+
const openEnd = html.indexOf(">");
|
|
36
|
+
const lastClose = html.lastIndexOf("</div>");
|
|
37
|
+
const inner = html.slice(openEnd + 1, lastClose < 0 ? html.length : lastClose);
|
|
38
|
+
const taskId = nextTaskId();
|
|
39
|
+
let bodyText = "";
|
|
40
|
+
const subListsXml = [];
|
|
41
|
+
let pos = 0;
|
|
42
|
+
let textStart = 0;
|
|
43
|
+
while (pos < inner.length) {
|
|
44
|
+
const subIdx = inner.indexOf("<div data-ac-task-list", pos);
|
|
45
|
+
if (subIdx === -1) break;
|
|
46
|
+
const textBefore = inner.slice(textStart, subIdx).replace(/<[^>]+>/g, "").trim();
|
|
47
|
+
if (textBefore) bodyText += (bodyText ? " " : "") + textBefore;
|
|
48
|
+
const subEnd = findDivEnd(inner, subIdx);
|
|
49
|
+
subListsXml.push(taskListDivToXml(inner.slice(subIdx, subEnd)));
|
|
50
|
+
pos = subEnd;
|
|
51
|
+
textStart = subEnd;
|
|
52
|
+
}
|
|
53
|
+
const remaining = inner.slice(textStart).replace(/<[^>]+>/g, "").trim();
|
|
54
|
+
if (remaining) bodyText += (bodyText ? " " : "") + remaining;
|
|
55
|
+
const taskXml = emitTask(taskId, status, bodyText);
|
|
56
|
+
return subListsXml.length > 0 ? taskXml + "\n" + subListsXml.join("\n") : taskXml;
|
|
57
|
+
}
|
|
58
|
+
//#endregion
|
|
59
|
+
export { emitTask, taskListDivToXml };
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { log } from "../../../log/logger.js";
|
|
2
|
+
import { escapeCdata, escapeXml } from "../../shared/xml-escaping.js";
|
|
3
|
+
import { RICH_BODY_MACRO_TYPES_PATTERN } from "../../shared/macro-types.js";
|
|
4
|
+
import { structuredMacro } from "../../shared/confluence-macro-builder.js";
|
|
5
|
+
import { nextTaskId } from "../conversion-state.js";
|
|
6
|
+
import { confluenceLanguage } from "../languages.js";
|
|
7
|
+
import { emitConfluenceLink } from "../links.js";
|
|
8
|
+
import { emitTask } from "../raw-html/task-lists.js";
|
|
9
|
+
import { sanitizeRawHtml } from "../raw-html/raw-html.js";
|
|
10
|
+
import { fromMarked, renderInline, renderInlineImage, renderInlineToken } from "./inline.js";
|
|
11
|
+
import { marked } from "marked";
|
|
12
|
+
//#region src/services/conversion/markdownToConfluence/render/blocks.ts
|
|
13
|
+
const BLOCKQUOTE_MACRO_TYPE_RE = new RegExp(`^\\[(${RICH_BODY_MACRO_TYPES_PATTERN})(?: ([^\\n\\]]+))?\\]\\s*\\n?([\\s\\S]*)$`);
|
|
14
|
+
function renderTokens(tokens) {
|
|
15
|
+
return tokens.map(renderBlock).filter(Boolean).join("\n");
|
|
16
|
+
}
|
|
17
|
+
function renderBlock(token) {
|
|
18
|
+
switch (token.type) {
|
|
19
|
+
case "space": return "";
|
|
20
|
+
case "text": {
|
|
21
|
+
const t = token;
|
|
22
|
+
return t.tokens?.length ? `<p>${renderInline(t.tokens)}</p>` : `<p>${fromMarked(t.text)}</p>`;
|
|
23
|
+
}
|
|
24
|
+
case "heading": return renderHeading(token);
|
|
25
|
+
case "paragraph": return renderParagraph(token);
|
|
26
|
+
case "list": return renderList(token);
|
|
27
|
+
case "code": return renderCode(token);
|
|
28
|
+
case "blockquote": return renderBlockquote(token);
|
|
29
|
+
case "table": return renderTable(token);
|
|
30
|
+
case "hr": return "<hr/>";
|
|
31
|
+
case "html": return sanitizeRawHtml(token.text);
|
|
32
|
+
default:
|
|
33
|
+
log.warning0(`Unsupported block token: ${token.type}`);
|
|
34
|
+
return "";
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
function renderHeading(token) {
|
|
38
|
+
return `<h${token.depth}>${renderInline(token.tokens).trim()}</h${token.depth}>`;
|
|
39
|
+
}
|
|
40
|
+
function renderParagraph(token) {
|
|
41
|
+
if (token.tokens.length === 1 && token.tokens[0].type === "image") return renderInlineToken(token.tokens[0]);
|
|
42
|
+
const content = renderInline(token.tokens).trim();
|
|
43
|
+
return content ? `<p>${content}</p>` : "";
|
|
44
|
+
}
|
|
45
|
+
function renderCode(token) {
|
|
46
|
+
return structuredMacro("code", {
|
|
47
|
+
params: [["language", confluenceLanguage(token.lang)], ["linenumbers", "true"]],
|
|
48
|
+
plainTextBody: escapeCdata(token.text)
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
/** Parse `[info]` / `[note]` / `[warning]` / `[tip]` / `[expand Titre]` en tête de blockquote. */
|
|
52
|
+
function parseBlockquoteMacroHeader(paraText) {
|
|
53
|
+
const typeMatch = paraText.trim().match(BLOCKQUOTE_MACRO_TYPE_RE);
|
|
54
|
+
if (!typeMatch) return void 0;
|
|
55
|
+
return {
|
|
56
|
+
macroName: typeMatch[1],
|
|
57
|
+
title: typeMatch[2]?.trim() ?? "",
|
|
58
|
+
restOfFirstPara: typeMatch[3]?.trim() ?? ""
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
function renderBlockquote(token) {
|
|
62
|
+
const first = token.tokens[0];
|
|
63
|
+
const header = first?.type === "paragraph" ? parseBlockquoteMacroHeader(first.text ?? "") : void 0;
|
|
64
|
+
if (!header) return structuredMacro("info", { richTextBody: renderTokens(token.tokens) });
|
|
65
|
+
const { macroName, title, restOfFirstPara } = header;
|
|
66
|
+
const bodyTokens = restOfFirstPara ? [...marked.lexer(restOfFirstPara, { gfm: true }), ...token.tokens.slice(1)] : token.tokens.slice(1);
|
|
67
|
+
return structuredMacro(macroName, {
|
|
68
|
+
params: title ? [["title", escapeXml(title)]] : [],
|
|
69
|
+
richTextBody: renderTokens(bodyTokens)
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
function renderList(token) {
|
|
73
|
+
if (token.items.some((item) => item.task)) {
|
|
74
|
+
const output = [];
|
|
75
|
+
for (const item of token.items) {
|
|
76
|
+
const status = item.checked ? "complete" : "incomplete";
|
|
77
|
+
const taskId = nextTaskId();
|
|
78
|
+
const bodyTokens = [];
|
|
79
|
+
const subTaskLists = [];
|
|
80
|
+
for (const t of item.tokens) if (t.type === "list" && t.items.some((i) => i.task)) subTaskLists.push(t);
|
|
81
|
+
else bodyTokens.push(t);
|
|
82
|
+
output.push(emitTask(taskId, status, renderListItemInner(bodyTokens).trim()));
|
|
83
|
+
for (const sub of subTaskLists) output.push(renderList(sub));
|
|
84
|
+
}
|
|
85
|
+
return `<ac:task-list>\n${output.join("\n")}\n</ac:task-list>`;
|
|
86
|
+
}
|
|
87
|
+
const tag = token.ordered ? "ol" : "ul";
|
|
88
|
+
return `<${tag}${token.ordered && token.start && token.start !== 1 ? ` start="${token.start}"` : ""}>\n${token.items.map(renderListItem).join("\n")}\n</${tag}>`;
|
|
89
|
+
}
|
|
90
|
+
function renderListItem(item) {
|
|
91
|
+
const inner = renderListItemInner(item.tokens);
|
|
92
|
+
return `<li>${item.task ? `${item.checked ? "☑" : "☐"} ${inner}` : inner}</li>`;
|
|
93
|
+
}
|
|
94
|
+
function renderListItemInner(tokens) {
|
|
95
|
+
return tokens.map((t) => {
|
|
96
|
+
switch (t.type) {
|
|
97
|
+
case "paragraph": return renderInline(t.tokens);
|
|
98
|
+
case "text": {
|
|
99
|
+
const tt = t;
|
|
100
|
+
return tt.tokens?.length ? renderInline(tt.tokens) : renderInlineText(tt.text);
|
|
101
|
+
}
|
|
102
|
+
case "list": return renderList(t);
|
|
103
|
+
case "strong":
|
|
104
|
+
case "em":
|
|
105
|
+
case "codespan":
|
|
106
|
+
case "link":
|
|
107
|
+
case "image": return renderInlineToken(t);
|
|
108
|
+
default: return renderBlock(t);
|
|
109
|
+
}
|
|
110
|
+
}).join("");
|
|
111
|
+
}
|
|
112
|
+
function renderInlineText(text) {
|
|
113
|
+
let i = 0;
|
|
114
|
+
const parts = [];
|
|
115
|
+
while (i < text.length) {
|
|
116
|
+
if (text[i] === "!" && text[i + 1] === "[") {
|
|
117
|
+
const endAlt = text.indexOf("]", i + 2);
|
|
118
|
+
const startUrl = endAlt !== -1 ? text.indexOf("(", endAlt) : -1;
|
|
119
|
+
const endUrl = startUrl !== -1 ? text.indexOf(")", startUrl) : -1;
|
|
120
|
+
if (endAlt !== -1 && startUrl === endAlt + 1 && endUrl !== -1) {
|
|
121
|
+
const alt = text.slice(i + 2, endAlt);
|
|
122
|
+
const url = text.slice(startUrl + 1, endUrl);
|
|
123
|
+
parts.push(renderInlineImage({
|
|
124
|
+
type: "image",
|
|
125
|
+
href: url,
|
|
126
|
+
text: alt
|
|
127
|
+
}));
|
|
128
|
+
i = endUrl + 1;
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
if (text[i] === "[") {
|
|
133
|
+
const endLabel = text.indexOf("]", i + 1);
|
|
134
|
+
const startUrl = endLabel !== -1 ? text.indexOf("(", endLabel) : -1;
|
|
135
|
+
const endUrl = startUrl !== -1 ? text.indexOf(")", startUrl) : -1;
|
|
136
|
+
if (endLabel !== -1 && startUrl === endLabel + 1 && endUrl !== -1) {
|
|
137
|
+
const label = text.slice(i + 1, endLabel);
|
|
138
|
+
const rawUrl = text.slice(startUrl + 1, endUrl);
|
|
139
|
+
parts.push(emitConfluenceLink(rawUrl, label, escapeXml(label)));
|
|
140
|
+
i = endUrl + 1;
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
parts.push(escapeXml(text[i]));
|
|
145
|
+
i++;
|
|
146
|
+
}
|
|
147
|
+
return parts.join("");
|
|
148
|
+
}
|
|
149
|
+
function alignStyle(align) {
|
|
150
|
+
return align ? ` style="text-align: ${align};"` : "";
|
|
151
|
+
}
|
|
152
|
+
function renderTable(token) {
|
|
153
|
+
const parts = ["<table>\n<thead><tr>\n"];
|
|
154
|
+
for (let i = 0; i < token.header.length; i++) {
|
|
155
|
+
const align = alignStyle(token.align?.[i] ?? null);
|
|
156
|
+
parts.push(`<th${align}>${renderInline(token.header[i].tokens)}</th>\n`);
|
|
157
|
+
}
|
|
158
|
+
parts.push("</tr></thead>\n<tbody>\n");
|
|
159
|
+
for (const row of token.rows) {
|
|
160
|
+
parts.push("<tr>\n");
|
|
161
|
+
for (let i = 0; i < row.length; i++) {
|
|
162
|
+
const align = alignStyle(token.align?.[i] ?? null);
|
|
163
|
+
parts.push(`<td${align}>${renderInline(row[i].tokens)}</td>\n`);
|
|
164
|
+
}
|
|
165
|
+
parts.push("</tr>\n");
|
|
166
|
+
}
|
|
167
|
+
parts.push("</tbody>\n</table>");
|
|
168
|
+
return parts.join("");
|
|
169
|
+
}
|
|
170
|
+
//#endregion
|
|
171
|
+
export { renderTokens };
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { log } from "../../../log/logger.js";
|
|
2
|
+
import { escapeAttr, escapeXml } from "../../shared/xml-escaping.js";
|
|
3
|
+
import { emitConfluenceLink } from "../links.js";
|
|
4
|
+
import { sanitizeRawHtml } from "../raw-html/raw-html.js";
|
|
5
|
+
//#region src/services/conversion/markdownToConfluence/render/inline.ts
|
|
6
|
+
/** Décode les entités produites par marked puis ré-échappe pour le XML Confluence. */
|
|
7
|
+
function fromMarked(text) {
|
|
8
|
+
const decoded = text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, "\"").replace(/'|'/g, "'");
|
|
9
|
+
return escapeXml(decoded);
|
|
10
|
+
}
|
|
11
|
+
function renderInline(tokens) {
|
|
12
|
+
return tokens ? tokens.map(renderInlineToken).join("") : "";
|
|
13
|
+
}
|
|
14
|
+
function renderInlineToken(token) {
|
|
15
|
+
switch (token.type) {
|
|
16
|
+
case "text": {
|
|
17
|
+
const t = token;
|
|
18
|
+
return t.tokens?.length ? renderInline(t.tokens) : fromMarked(t.text);
|
|
19
|
+
}
|
|
20
|
+
case "escape": return fromMarked(token.text);
|
|
21
|
+
case "codespan": return `<code>${fromMarked(token.text)}</code>`;
|
|
22
|
+
case "html": return sanitizeRawHtml(token.text);
|
|
23
|
+
case "br": return "<br/>";
|
|
24
|
+
case "strong": return `<strong>${renderInline(token.tokens)}</strong>`;
|
|
25
|
+
case "em": return `<em>${renderInline(token.tokens)}</em>`;
|
|
26
|
+
case "del": return `<del>${renderInline(token.tokens)}</del>`;
|
|
27
|
+
case "link": return renderLink(token);
|
|
28
|
+
case "image": return renderInlineImage(token);
|
|
29
|
+
default:
|
|
30
|
+
log.warning0(`Unsupported inline token: ${token.type}`);
|
|
31
|
+
return "";
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
function renderLink(token) {
|
|
35
|
+
const labelHtml = token.tokens ? renderInline(token.tokens) : fromMarked(token.text);
|
|
36
|
+
return emitConfluenceLink(token.href, token.text, labelHtml, token.title);
|
|
37
|
+
}
|
|
38
|
+
function renderInlineImage(token) {
|
|
39
|
+
const href = token.href;
|
|
40
|
+
if (href.startsWith("http://") || href.startsWith("https://")) return `<ac:image><ri:url ri:value="${escapeAttr(href)}" /></ac:image>`;
|
|
41
|
+
return `<ac:image><ri:attachment ri:filename="${escapeAttr(href.split("/").pop() ?? href)}" /></ac:image>`;
|
|
42
|
+
}
|
|
43
|
+
//#endregion
|
|
44
|
+
export { fromMarked, renderInline, renderInlineImage, renderInlineToken };
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { escapeAttr } from "../../shared/xml-escaping.js";
|
|
2
|
+
import { structuredMacro } from "../../shared/confluence-macro-builder.js";
|
|
3
|
+
//#region src/services/conversion/markdownToConfluence/render/page.ts
|
|
4
|
+
function stripColgroups(html) {
|
|
5
|
+
return html.replace(/<colgroup\b[^>]*>[\s\S]*?<\/colgroup>/gi, "");
|
|
6
|
+
}
|
|
7
|
+
/** Enveloppe le corps converti : bandeau « Généré le … » + sommaire (optionnel), puis le corps (colgroups retirés). */
|
|
8
|
+
function buildConfluencePage(body, generatedAt, generateSummary = true) {
|
|
9
|
+
const info = structuredMacro("info", { richTextBody: ` <p><strong>Généré le :</strong> ${escapeAttr(generatedAt)}</p>` });
|
|
10
|
+
if (!generateSummary) return [
|
|
11
|
+
info,
|
|
12
|
+
"",
|
|
13
|
+
stripColgroups(body)
|
|
14
|
+
].join("\n");
|
|
15
|
+
return [
|
|
16
|
+
info,
|
|
17
|
+
"",
|
|
18
|
+
structuredMacro("toc", { params: [
|
|
19
|
+
["printable", "true"],
|
|
20
|
+
["style", "disc"],
|
|
21
|
+
["maxLevel", "3"],
|
|
22
|
+
["minLevel", "1"],
|
|
23
|
+
["type", "list"],
|
|
24
|
+
["outline", "false"]
|
|
25
|
+
] }),
|
|
26
|
+
"",
|
|
27
|
+
stripColgroups(body)
|
|
28
|
+
].join("\n");
|
|
29
|
+
}
|
|
30
|
+
//#endregion
|
|
31
|
+
export { buildConfluencePage };
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
//#region src/services/conversion/shared/confluence-macro-builder.ts
|
|
2
|
+
function structuredMacro(name, opts = {}) {
|
|
3
|
+
const lines = [`<ac:structured-macro ac:name="${name}"${opts.schemaVersion ? " ac:schema-version=\"1\"" : ""}>`];
|
|
4
|
+
for (const [key, value] of opts.params ?? []) lines.push(` <ac:parameter ac:name="${key}">${value}</ac:parameter>`);
|
|
5
|
+
if (opts.plainTextBody !== void 0) lines.push(` <ac:plain-text-body><![CDATA[${opts.plainTextBody}]]></ac:plain-text-body>`);
|
|
6
|
+
if (opts.richTextBody !== void 0) lines.push(` <ac:rich-text-body>`, opts.richTextBody, ` </ac:rich-text-body>`);
|
|
7
|
+
lines.push(`</ac:structured-macro>`);
|
|
8
|
+
return lines.join("\n");
|
|
9
|
+
}
|
|
10
|
+
//#endregion
|
|
11
|
+
export { structuredMacro };
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import path from "path";
|
|
2
|
+
//#region src/services/conversion/shared/gitlab-url.ts
|
|
3
|
+
function toGitlabFileUrl(href, sourceFile, gitlab) {
|
|
4
|
+
if (/^https?:\/\/|^mailto:|^#/.test(href)) return href;
|
|
5
|
+
const gitlabBase = (gitlab?.baseUrl ?? process.env.GITLAB_BASE_URL)?.replace(/\/$/, "");
|
|
6
|
+
if (!gitlabBase) return href;
|
|
7
|
+
const hashIdx = href.indexOf("#");
|
|
8
|
+
const fragment = hashIdx !== -1 ? href.slice(hashIdx) : "";
|
|
9
|
+
const filePart = hashIdx !== -1 ? href.slice(0, hashIdx) : href;
|
|
10
|
+
if (!filePart) return href;
|
|
11
|
+
const sourceDir = path.dirname(sourceFile).replace(/\\/g, "/");
|
|
12
|
+
const normalized = path.posix.normalize(path.posix.join(sourceDir, filePart));
|
|
13
|
+
return `${gitlabBase}/-/blob/${gitlab?.branch ?? "main"}/${normalized}${fragment}`;
|
|
14
|
+
}
|
|
15
|
+
//#endregion
|
|
16
|
+
export { toGitlabFileUrl };
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { unescapeXml } from "./xml-escaping.js";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
//#region src/services/conversion/shared/image-attachments.ts
|
|
4
|
+
function decodeAttachmentFilename(rawFilename) {
|
|
5
|
+
return unescapeXml(rawFilename);
|
|
6
|
+
}
|
|
7
|
+
const EMBEDDED_ATTACHMENT_PATTERNS = [/<ac:image[^>]*>[\s\S]*?<ri:attachment[^>]*ri:filename="([^"]+)"[^>]*\/>[\s\S]*?<\/ac:image>/g, /<ac:structured-macro[^>]*ac:name="view-file"[^>]*>[\s\S]*?<ri:attachment[^>]*ri:filename="([^"]+)"[^>]*\/>[\s\S]*?<\/ac:structured-macro>/g];
|
|
8
|
+
function embeddedAttachmentFilenames(storageXml) {
|
|
9
|
+
const raw = EMBEDDED_ATTACHMENT_PATTERNS.flatMap((pattern) => [...storageXml.matchAll(pattern)].map((m) => m[1]));
|
|
10
|
+
return [...new Set(raw.map(decodeAttachmentFilename))];
|
|
11
|
+
}
|
|
12
|
+
const CONTROL_CHARS = Array.from({ length: 32 }, (_, i) => String.fromCharCode(i)).join("");
|
|
13
|
+
const ILLEGAL_FILENAME_CHARS = new RegExp(`[<>:"/\\\\|?*${CONTROL_CHARS}]`, "g");
|
|
14
|
+
const MAX_FILENAME_LENGTH = 120;
|
|
15
|
+
function sanitizeAttachmentFilename(filename) {
|
|
16
|
+
const safe = filename.replace(ILLEGAL_FILENAME_CHARS, "_");
|
|
17
|
+
if (safe.length <= MAX_FILENAME_LENGTH) return safe;
|
|
18
|
+
const hash = createHash("sha1").update(filename).digest("hex").slice(0, 8);
|
|
19
|
+
return `${safe.slice(0, MAX_FILENAME_LENGTH)}-${hash}`;
|
|
20
|
+
}
|
|
21
|
+
const IMAGE_EXTENSIONS = /* @__PURE__ */ new Set([
|
|
22
|
+
"png",
|
|
23
|
+
"jpg",
|
|
24
|
+
"jpeg",
|
|
25
|
+
"gif",
|
|
26
|
+
"svg",
|
|
27
|
+
"webp",
|
|
28
|
+
"bmp"
|
|
29
|
+
]);
|
|
30
|
+
const VIDEO_EXTENSIONS = /* @__PURE__ */ new Set([
|
|
31
|
+
"mp4",
|
|
32
|
+
"mov",
|
|
33
|
+
"webm",
|
|
34
|
+
"avi",
|
|
35
|
+
"mkv",
|
|
36
|
+
"m4v"
|
|
37
|
+
]);
|
|
38
|
+
function extensionOf(filename) {
|
|
39
|
+
return /\.([a-z0-9]+)$/i.exec(filename)?.[1]?.toLowerCase();
|
|
40
|
+
}
|
|
41
|
+
function classifyAttachment(filename) {
|
|
42
|
+
const ext = extensionOf(filename);
|
|
43
|
+
if (ext && IMAGE_EXTENSIONS.has(ext)) return "image";
|
|
44
|
+
if (ext && VIDEO_EXTENSIONS.has(ext)) return "video";
|
|
45
|
+
return "other";
|
|
46
|
+
}
|
|
47
|
+
function renderAttachmentTag(rawFilename, imgRelPath) {
|
|
48
|
+
const filename = decodeAttachmentFilename(rawFilename);
|
|
49
|
+
const localFilename = sanitizeAttachmentFilename(filename);
|
|
50
|
+
const href = imgRelPath ? `${imgRelPath}/${localFilename}` : localFilename;
|
|
51
|
+
const kind = classifyAttachment(filename);
|
|
52
|
+
if (kind === "video") return `<video src="${href}" controls>${filename}</video>`;
|
|
53
|
+
if (kind === "other") return `<a href="${href}">${filename}</a>`;
|
|
54
|
+
return `<img src="${href}" alt="${filename}">`;
|
|
55
|
+
}
|
|
56
|
+
//#endregion
|
|
57
|
+
export { EMBEDDED_ATTACHMENT_PATTERNS, classifyAttachment, decodeAttachmentFilename, embeddedAttachmentFilenames, renderAttachmentTag, sanitizeAttachmentFilename };
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
//#region src/services/conversion/shared/jira.ts
|
|
2
|
+
/** Motif (source, sans ancres ni flags) d'une clé de ticket Jira. */
|
|
3
|
+
const JIRA_KEY_PATTERN = "[A-Z][A-Z0-9]*-\\d+";
|
|
4
|
+
/** Clé de ticket Jira, ancrée sur toute la chaîne (ex. validation d'un texte détecté). */
|
|
5
|
+
const JIRA_KEY_RE = new RegExp(`^${JIRA_KEY_PATTERN}$`);
|
|
6
|
+
//#endregion
|
|
7
|
+
export { JIRA_KEY_PATTERN, JIRA_KEY_RE };
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
//#region src/services/conversion/shared/macro-types.ts
|
|
2
|
+
const RICH_BODY_MACRO_TYPES = [
|
|
3
|
+
"info",
|
|
4
|
+
"note",
|
|
5
|
+
"warning",
|
|
6
|
+
"tip",
|
|
7
|
+
"expand"
|
|
8
|
+
];
|
|
9
|
+
/** Fragment d'alternation regex correspondant, ex. "info|note|warning|tip|expand". */
|
|
10
|
+
const RICH_BODY_MACRO_TYPES_PATTERN = RICH_BODY_MACRO_TYPES.join("|");
|
|
11
|
+
//#endregion
|
|
12
|
+
export { RICH_BODY_MACRO_TYPES, RICH_BODY_MACRO_TYPES_PATTERN };
|