confluence-md-sync 0.11.2 → 0.12.0
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/README.md +16 -0
- package/dist/export/to-markdown.js +72 -7
- package/dist/macros/plugins/core.js +2 -1
- package/dist/macros/plugins/drawio.js +3 -2
- package/dist/macros/plugins/note.d.ts +3 -0
- package/dist/macros/plugins/note.js +13 -0
- package/dist/macros/plugins/structurizr.js +3 -3
- package/dist/markdown/image.d.ts +2 -0
- package/dist/markdown/image.js +19 -0
- package/dist/markdown/inline-syntax.d.ts +13 -0
- package/dist/markdown/inline-syntax.js +72 -0
- package/dist/markdown/native.js +3 -3
- package/dist/markdown/note-layout.d.ts +1 -0
- package/dist/markdown/note-layout.js +41 -0
- package/dist/markdown/note.d.ts +6 -0
- package/dist/markdown/note.js +26 -0
- package/dist/markdown/placeholder.d.ts +8 -0
- package/dist/markdown/placeholder.js +23 -0
- package/dist/markdown/render.d.ts +2 -29
- package/dist/markdown/render.js +74 -84
- package/dist/syntax.d.ts +3 -0
- package/dist/syntax.js +4 -0
- package/package.json +5 -1
package/README.md
CHANGED
|
@@ -496,3 +496,19 @@ the `v*` tag triggers the npm publish workflow (tag must match
|
|
|
496
496
|
## License
|
|
497
497
|
|
|
498
498
|
[MIT](LICENSE)
|
|
499
|
+
|
|
500
|
+
### Inline Note and native image formatting
|
|
501
|
+
|
|
502
|
+
`{{note:Required category}}` works in prose and GFM cells. Nested `{{page:…}}`,
|
|
503
|
+
`{{jira:…}}` and `{{file:…}}` stay editable. Escape literal `|`, `{`, `}`, `:` with
|
|
504
|
+
backslashes. A newline (or `\n` in a plain body) creates a paragraph; `<br>` keeps
|
|
505
|
+
a line break within the same paragraph. For parameters or a multi-paragraph cell,
|
|
506
|
+
use a JSON-quoted body: `{{note:"First\nSecond<br>line"|title=Context|icon=false}}`.
|
|
507
|
+
Block `> [!NOTE]` is unchanged. Complex imported cell Notes retain storage losslessly.
|
|
508
|
+
|
|
509
|
+
Native attachment images forward Confluence image parameters: width/height, align,
|
|
510
|
+
border, thumbnail, title (Data Center caption), alt, hspace/vspace, class/style and
|
|
511
|
+
queryparams (effects). `link=https://…` hyperlinks an image; `display=link` displays
|
|
512
|
+
the attachment as a link. Structurizr and optional draw.io image mode share these
|
|
513
|
+
parameters; draw.io uses its native macro by default. Values containing separators
|
|
514
|
+
can use JSON quotes. Browser-safe authoring helpers are exported from `/syntax`.
|
|
@@ -24,6 +24,9 @@ import { escapeXmlAttr } from '../macros/xml.js';
|
|
|
24
24
|
import { processMacros } from '../macros/registry.js';
|
|
25
25
|
import { defaultMacroRegistry } from '../macros/index.js';
|
|
26
26
|
import { renderToStorage } from '../markdown/render.js';
|
|
27
|
+
import { parsePlaceholder, placeholder } from '../markdown/placeholder.js';
|
|
28
|
+
import { readInlineSyntax } from '../markdown/inline-syntax.js';
|
|
29
|
+
import { inlineNote } from '../markdown/note.js';
|
|
27
30
|
import { compareStorage } from './canonical.js';
|
|
28
31
|
import { NOTICE_MACRO_ID } from '../publish/notice.js';
|
|
29
32
|
import { rewriteStorageLinks } from './links.js';
|
|
@@ -160,6 +163,8 @@ class Converter {
|
|
|
160
163
|
return this.listToMd(el);
|
|
161
164
|
if (el.name === 'table') {
|
|
162
165
|
// tables:'records' — любую таблицу разворачиваем в записи.
|
|
166
|
+
if (this.hasComplexNote(el.children))
|
|
167
|
+
return this.fence(serializeStorage([el]));
|
|
163
168
|
if (this.tablesAsRecords)
|
|
164
169
|
return this.recordsTable(el);
|
|
165
170
|
try {
|
|
@@ -859,6 +864,33 @@ class Converter {
|
|
|
859
864
|
const row = (cells) => `| ${cells.join(' | ')} |`;
|
|
860
865
|
return [row(header), row(sep), ...bodyRows.map(row)].join('\n');
|
|
861
866
|
}
|
|
867
|
+
/** Only paragraphs with representable inline content; complex Notes keep storage. */
|
|
868
|
+
inlineNoteMd(el) {
|
|
869
|
+
const parts = this.macroParts(el);
|
|
870
|
+
if (!parts?.richBody || parts.plainBody || parts.params.some(p => !['title', 'icon'].includes(p.name)))
|
|
871
|
+
return null;
|
|
872
|
+
const readable = this.readable;
|
|
873
|
+
this.readable = false;
|
|
874
|
+
try {
|
|
875
|
+
const nodes = parts.richBody.children.filter(n => n.kind !== 'text' || n.raw.trim());
|
|
876
|
+
if (!nodes.length || nodes.some(n => n.kind !== 'el' || n.name !== 'p' || n.attrs.length))
|
|
877
|
+
return null;
|
|
878
|
+
const text = nodes.map(n => this.inlineToMd(n.children, { cell: true })).join('\n');
|
|
879
|
+
const candidate = inlineNote(text, parts.params.map(p => [p.name, p.value]));
|
|
880
|
+
return this.verifyMacroMarker(el, candidate) ? candidate : null;
|
|
881
|
+
}
|
|
882
|
+
catch (e) {
|
|
883
|
+
if (!(e instanceof Unrepresentable))
|
|
884
|
+
throw e;
|
|
885
|
+
return null;
|
|
886
|
+
}
|
|
887
|
+
finally {
|
|
888
|
+
this.readable = readable;
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
hasComplexNote(nodes) {
|
|
892
|
+
return nodes.some(n => n.kind === 'el' && ((n.name === 'ac:structured-macro' && getAttr(n, 'ac:name') === 'note' && this.inlineNoteMd(n) === null) || this.hasComplexNote(n.children)));
|
|
893
|
+
}
|
|
862
894
|
cellMd(cell) {
|
|
863
895
|
// Единственный <p> внутри ячейки — разворачиваем (типичная форма).
|
|
864
896
|
let content = cell.children;
|
|
@@ -867,7 +899,8 @@ class Converter {
|
|
|
867
899
|
content.every((n) => n.kind !== 'text' || /^[ \t\r\n]*$/.test(n.raw))) {
|
|
868
900
|
content = els[0].children;
|
|
869
901
|
}
|
|
870
|
-
const
|
|
902
|
+
const hasNote = content.some(n => n.kind === 'el' && n.name === 'ac:structured-macro' && getAttr(n, 'ac:name') === 'note');
|
|
903
|
+
const md = (hasNote ? content.map(n => n.kind === 'el' && n.name === 'p' && !n.attrs.length ? this.inlineToMd(n.children, { cell: true }) : this.inlineToMd([n], { cell: true })).join('') : this.inlineToMd(content, { cell: true })).trim();
|
|
871
904
|
if (md.includes('\n'))
|
|
872
905
|
throw new Unrepresentable();
|
|
873
906
|
// Пайпы экранируем один раз над всей ячейкой — покрывает и текст, и
|
|
@@ -1189,6 +1222,20 @@ class Converter {
|
|
|
1189
1222
|
}
|
|
1190
1223
|
return this.readable ? escapeMdText(alt, ctx, true) : this.rawInline(el, ctx);
|
|
1191
1224
|
}
|
|
1225
|
+
case 'ac:structured-macro': {
|
|
1226
|
+
if (getAttr(el, 'ac:name') === 'note') {
|
|
1227
|
+
const note = this.inlineNoteMd(el);
|
|
1228
|
+
if (note !== null)
|
|
1229
|
+
return note;
|
|
1230
|
+
throw new Unrepresentable();
|
|
1231
|
+
}
|
|
1232
|
+
const native = this.tryNativeMd(el, getAttr(el, 'ac:name') ?? '');
|
|
1233
|
+
if (native !== null && !native.includes('\n'))
|
|
1234
|
+
return native;
|
|
1235
|
+
if (this.readable)
|
|
1236
|
+
return escapeMdText(textContent(el.children), ctx, true);
|
|
1237
|
+
throw new Unrepresentable();
|
|
1238
|
+
}
|
|
1192
1239
|
case 'ac:image':
|
|
1193
1240
|
return this.acImageToMd(el);
|
|
1194
1241
|
case 'ac:link':
|
|
@@ -1237,6 +1284,14 @@ class Converter {
|
|
|
1237
1284
|
const href = getAttr(el, 'href') ?? '';
|
|
1238
1285
|
const inner = this.inlineToMd(el.children, ctx);
|
|
1239
1286
|
const onlyHref = el.attrs.length === 1 && el.attrs[0][0] === 'href';
|
|
1287
|
+
const children = elements(el.children);
|
|
1288
|
+
if (onlyHref && children.length === 1 && children[0].name === 'ac:image' && el.children.every(n => n.kind !== 'text' || !n.raw.trim())) {
|
|
1289
|
+
const image = readInlineSyntax(this.acImageToMd(children[0]), 0);
|
|
1290
|
+
if (image && ['img', 'structurizr', 'drawio'].includes(image.id)) {
|
|
1291
|
+
const ref = parsePlaceholder(image.body);
|
|
1292
|
+
return placeholder(image.id, ref.name, [...ref.attrs, ['link', href]]);
|
|
1293
|
+
}
|
|
1294
|
+
}
|
|
1240
1295
|
if ((onlyHref || this.readable) && SAFE_URL_RE.test(href) && !/[[\]]/.test(inner)) {
|
|
1241
1296
|
return `[${inner}](${href})`; // readable: доп. атрибуты ссылки отбрасываются
|
|
1242
1297
|
}
|
|
@@ -1257,7 +1312,10 @@ class Converter {
|
|
|
1257
1312
|
this.files.add(architecture[1]);
|
|
1258
1313
|
this.files.add(architecture[1].replace(/\.dsl$/, '.json'));
|
|
1259
1314
|
this.images.add(filename);
|
|
1260
|
-
|
|
1315
|
+
const attrs = el.attrs.map(([key]) => [key.slice(3), getAttr(el, key) ?? '']);
|
|
1316
|
+
if (el.attrs.some(([key]) => !key.startsWith('ac:')))
|
|
1317
|
+
throw new Unrepresentable();
|
|
1318
|
+
return placeholder('structurizr', architecture[1], [['view', view], ...attrs.filter(([key, value]) => key !== 'alt' || value !== `Structurizr · ${view}`)]);
|
|
1261
1319
|
}
|
|
1262
1320
|
}
|
|
1263
1321
|
const stem = filename.replace(/\.png$/i, '');
|
|
@@ -1268,7 +1326,10 @@ class Converter {
|
|
|
1268
1326
|
if (preview && !/[{}|\n/\\]/.test(preview)) {
|
|
1269
1327
|
this.files.add(drawioName);
|
|
1270
1328
|
this.images.add(preview);
|
|
1271
|
-
|
|
1329
|
+
if (el.attrs.some(([key]) => !key.startsWith('ac:')))
|
|
1330
|
+
throw new Unrepresentable();
|
|
1331
|
+
const attrs = el.attrs.map(([key]) => [key.slice(3), getAttr(el, key) ?? '']);
|
|
1332
|
+
return placeholder('drawio', drawioName, [['format', 'image'], ...(preview !== `${drawioName}.png` ? [['preview', preview]] : []), ...attrs]);
|
|
1272
1333
|
}
|
|
1273
1334
|
}
|
|
1274
1335
|
// attachments:'local' — картинка как HTML <img> (сохраняет размеры).
|
|
@@ -1325,12 +1386,9 @@ class Converter {
|
|
|
1325
1386
|
throw new Unrepresentable();
|
|
1326
1387
|
attrs.push([k.slice(3), getAttr(el, k) ?? '']);
|
|
1327
1388
|
}
|
|
1328
|
-
for (const [, v] of attrs)
|
|
1329
|
-
badPlaceholderPart(v);
|
|
1330
1389
|
badPlaceholderPart(filename);
|
|
1331
1390
|
this.images.add(filename);
|
|
1332
|
-
|
|
1333
|
-
return `{{img:${filename}${attrStr}}}`;
|
|
1391
|
+
return placeholder('img', filename, attrs);
|
|
1334
1392
|
}
|
|
1335
1393
|
acLinkToMd(el) {
|
|
1336
1394
|
// readable безотказен: непредставимая ссылка (user-mention, ссылка без цели,
|
|
@@ -1384,6 +1442,13 @@ class Converter {
|
|
|
1384
1442
|
}
|
|
1385
1443
|
const filename = getAttr(ref, 'ri:filename') ?? '';
|
|
1386
1444
|
this.files.add(filename);
|
|
1445
|
+
if (!this.localFiles && !text && /\.dsl\.([0-9a-f]{2})+\.png$/.test(filename)) {
|
|
1446
|
+
const image = readInlineSyntax(this.acImageToMd({ kind: 'el', name: 'ac:image', attrs: [], children: [ref], selfClosing: false }), 0);
|
|
1447
|
+
if (image?.id === 'structurizr') {
|
|
1448
|
+
const parsed = parsePlaceholder(image.body);
|
|
1449
|
+
return placeholder('structurizr', parsed.name, [...parsed.attrs, ['display', 'link']]);
|
|
1450
|
+
}
|
|
1451
|
+
}
|
|
1387
1452
|
// attachments:'local' — md-ссылка на локальный файл вместо {{file:}}.
|
|
1388
1453
|
if (this.localFiles) {
|
|
1389
1454
|
const label = (text ?? filename).replace(/[[\]]/g, '\\$&');
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import { macro } from '../builder.js';
|
|
8
8
|
import { paramMap } from '../types.js';
|
|
9
9
|
import { pageLinkValue, structuredMacro } from '../xml.js';
|
|
10
|
+
import { noteMacro } from './note.js';
|
|
10
11
|
/** Rich-body macro that forwards the listed params (if present). */
|
|
11
12
|
function richBodyMacro(name, allowedParams) {
|
|
12
13
|
return {
|
|
@@ -103,7 +104,7 @@ export const coreMacrosPlugin = {
|
|
|
103
104
|
macros: [
|
|
104
105
|
// Rich-body containers
|
|
105
106
|
richBodyMacro('expand', ['title']),
|
|
106
|
-
|
|
107
|
+
noteMacro,
|
|
107
108
|
richBodyMacro('info', ['title', 'icon']),
|
|
108
109
|
richBodyMacro('warning', ['title', 'icon']),
|
|
109
110
|
richBodyMacro('tip', ['title', 'icon']),
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { macro } from '../builder.js';
|
|
2
2
|
import { paramMap } from '../types.js';
|
|
3
|
-
import {
|
|
3
|
+
import { structuredMacro } from '../xml.js';
|
|
4
|
+
import { attachmentImage } from '../../markdown/image.js';
|
|
4
5
|
/** XML keeps its attachment name, including extensionless Confluence diagrams. */
|
|
5
6
|
export const drawioPlugin = {
|
|
6
7
|
name: 'drawio',
|
|
@@ -10,7 +11,7 @@ export const drawioPlugin = {
|
|
|
10
11
|
if (!name)
|
|
11
12
|
throw new Error('drawio: diagramName is required');
|
|
12
13
|
if (params.format === 'image') {
|
|
13
|
-
return
|
|
14
|
+
return attachmentImage(params.preview || `${name}.png`, ctx.params.filter(p => !['diagramName', 'format', 'preview'].includes(p.name)).map(p => [p.name, p.value]));
|
|
14
15
|
}
|
|
15
16
|
if (params.format && params.format !== 'macro')
|
|
16
17
|
throw new Error('drawio: format must be macro or image');
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { parseStorage, serializeStorage } from '../../export/xhtml.js';
|
|
2
|
+
import { stableMacroId, structuredMacro } from '../xml.js';
|
|
3
|
+
export const noteMacro = {
|
|
4
|
+
name: 'note',
|
|
5
|
+
render: ctx => structuredMacro('note', ctx.macroId, { params: ctx.params.filter(p => ['title', 'icon'].includes(p.name)), richBody: ctx.body }),
|
|
6
|
+
};
|
|
7
|
+
export function renderInlineNote(body, attrs) {
|
|
8
|
+
body = serializeStorage(parseStorage(body));
|
|
9
|
+
const params = attrs.map(([name, value]) => ({ name, value })).sort((a, b) => a.name.localeCompare(b.name));
|
|
10
|
+
if (params.some(p => !['title', 'icon'].includes(p.name)))
|
|
11
|
+
throw new Error('Inline Note supports title and icon parameters');
|
|
12
|
+
return noteMacro.render({ macroId: stableMacroId(JSON.stringify([body, params])), params, body });
|
|
13
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { macro } from '../builder.js';
|
|
2
2
|
import { paramMap } from '../types.js';
|
|
3
|
-
import {
|
|
3
|
+
import { attachmentImage } from '../../markdown/image.js';
|
|
4
4
|
export function structurizrPreview(name, view) {
|
|
5
5
|
if (typeof name !== 'string' || !/\.dsl$/i.test(name) || /[/\\{}|]/.test(name) || !view || view.length > 120 || /[{}|\n\r]/.test(view))
|
|
6
6
|
throw new Error('structurizr: a .dsl attachment and view key are required');
|
|
@@ -10,8 +10,8 @@ export function structurizrPreview(name, view) {
|
|
|
10
10
|
export const structurizrPlugin = {
|
|
11
11
|
name: 'structurizr',
|
|
12
12
|
macros: [{ name: 'structurizr', render(ctx) {
|
|
13
|
-
const { name, view } = paramMap(ctx.params);
|
|
14
|
-
return
|
|
13
|
+
const { name, view, ...attrs } = paramMap(ctx.params);
|
|
14
|
+
return attachmentImage(structurizrPreview(name, view), Object.entries({ alt: `Structurizr · ${view}`, ...attrs }));
|
|
15
15
|
} }],
|
|
16
16
|
};
|
|
17
17
|
export const structurizr = (name, view) => macro('structurizr').param('name', name).param('view', view).toMarkdown();
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { escapeXmlAttr } from '../macros/xml.js';
|
|
2
|
+
/** Native attachment images retain Confluence's image toolbar and formatting. */
|
|
3
|
+
export function attachmentImage(name, attrs) {
|
|
4
|
+
const values = new Map(attrs);
|
|
5
|
+
const attachment = `<ri:attachment ri:filename="${escapeXmlAttr(name)}" />`;
|
|
6
|
+
if (values.get('display') === 'link')
|
|
7
|
+
return `<ac:link>${attachment}</ac:link>`;
|
|
8
|
+
const attributes = attrs.filter(([key]) => !['display', 'link'].includes(key));
|
|
9
|
+
for (const [key] of attributes)
|
|
10
|
+
if (!/^[A-Za-z_][\w.-]*$/.test(key))
|
|
11
|
+
throw new Error(`Invalid image attribute: ${key}`);
|
|
12
|
+
const image = `<ac:image${attributes.map(([key, value]) => ` ac:${key}="${escapeXmlAttr(value)}"`).join('')}>${attachment}</ac:image>`;
|
|
13
|
+
const link = values.get('link');
|
|
14
|
+
if (!link)
|
|
15
|
+
return image;
|
|
16
|
+
if (/^[a-z][a-z0-9+.-]*:/i.test(link) && !/^(https?|mailto|tel):/i.test(link))
|
|
17
|
+
throw new Error('Unsupported image link protocol');
|
|
18
|
+
return `<a href="${escapeXmlAttr(link)}">${image}</a>`;
|
|
19
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export interface InlineSyntax {
|
|
2
|
+
id: string;
|
|
3
|
+
body: string;
|
|
4
|
+
raw: string;
|
|
5
|
+
from: number;
|
|
6
|
+
to: number;
|
|
7
|
+
}
|
|
8
|
+
/** Balanced braces, quoted parameters and escaped delimiters. */
|
|
9
|
+
export declare function readInlineSyntax(source: string, from: number): InlineSyntax | null;
|
|
10
|
+
/** Visit authored macros, excluding fenced and inline code. */
|
|
11
|
+
export declare function replaceInlineSyntax(source: string, replace: (macro: InlineSyntax) => string | undefined): string;
|
|
12
|
+
/** Remove the one GFM escaping layer; keep JSON-encoded backslashes intact. */
|
|
13
|
+
export declare const unescapeTablePipes: (raw: string) => string;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/** Balanced braces, quoted parameters and escaped delimiters. */
|
|
2
|
+
export function readInlineSyntax(source, from) {
|
|
3
|
+
const head = /^\{\{([a-z][a-z0-9-]*)(:|\}\})/.exec(source.slice(from));
|
|
4
|
+
if (!head)
|
|
5
|
+
return null;
|
|
6
|
+
if (head[2] === '}}')
|
|
7
|
+
return { id: head[1], body: '', raw: head[0], from, to: from + head[0].length };
|
|
8
|
+
const start = from + head[0].length;
|
|
9
|
+
let depth = 1, quoted = false;
|
|
10
|
+
for (let i = start; i < source.length; i++) {
|
|
11
|
+
if (source[i] === '\\') {
|
|
12
|
+
i++;
|
|
13
|
+
continue;
|
|
14
|
+
}
|
|
15
|
+
if (source[i] === '"') {
|
|
16
|
+
quoted = !quoted;
|
|
17
|
+
continue;
|
|
18
|
+
}
|
|
19
|
+
if (quoted)
|
|
20
|
+
continue;
|
|
21
|
+
if (source.slice(i, i + 2) === '{{') {
|
|
22
|
+
depth++;
|
|
23
|
+
i++;
|
|
24
|
+
}
|
|
25
|
+
else if (source.slice(i, i + 2) === '}}') {
|
|
26
|
+
if (--depth === 0)
|
|
27
|
+
return { id: head[1], body: source.slice(start, i), raw: source.slice(from, i + 2), from, to: i + 2 };
|
|
28
|
+
i++;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
/** Visit authored macros, excluding fenced and inline code. */
|
|
34
|
+
export function replaceInlineSyntax(source, replace) {
|
|
35
|
+
let out = '', pos = 0;
|
|
36
|
+
while (pos < source.length) {
|
|
37
|
+
if (pos === 0 || source[pos - 1] === '\n') {
|
|
38
|
+
const fence = /^ {0,3}(`{3,}|~{3,})[^\n]*(?:\n|$)/.exec(source.slice(pos));
|
|
39
|
+
if (fence) {
|
|
40
|
+
const end = new RegExp(`^ {0,3}${fence[1][0]}{${fence[1].length},}[ \\t]*(?:\\n|$)`, 'm');
|
|
41
|
+
const rest = source.slice(pos + fence[0].length), close = end.exec(rest);
|
|
42
|
+
const to = close ? pos + fence[0].length + close.index + close[0].length : source.length;
|
|
43
|
+
out += source.slice(pos, to);
|
|
44
|
+
pos = to;
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
if (source[pos] === '\\') {
|
|
49
|
+
out += source.slice(pos, pos + 2);
|
|
50
|
+
pos += 2;
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
if (source[pos] === '`') {
|
|
54
|
+
const run = /^`+/.exec(source.slice(pos))[0], end = source.indexOf(run, pos + run.length);
|
|
55
|
+
if (end >= 0) {
|
|
56
|
+
out += source.slice(pos, end + run.length);
|
|
57
|
+
pos = end + run.length;
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
const macro = source.slice(pos, pos + 2) === '{{' ? readInlineSyntax(source, pos) : null;
|
|
62
|
+
if (macro) {
|
|
63
|
+
out += replace(macro) ?? macro.raw;
|
|
64
|
+
pos = macro.to;
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
out += source[pos++];
|
|
68
|
+
}
|
|
69
|
+
return out;
|
|
70
|
+
}
|
|
71
|
+
/** Remove the one GFM escaping layer; keep JSON-encoded backslashes intact. */
|
|
72
|
+
export const unescapeTablePipes = (raw) => raw.replace(/(\\+)\|/g, (_match, slash) => (slash.length % 2 ? slash.slice(1) : slash) + '|');
|
package/dist/markdown/native.js
CHANGED
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
* Внутри fenced-код-блоков (``` / ~~~) синтаксис не интерпретируется.
|
|
31
31
|
*/
|
|
32
32
|
import { escapeParamValue } from '../macros/builder.js';
|
|
33
|
+
import { replaceInlineSyntax } from './inline-syntax.js';
|
|
33
34
|
/** Панели: тег admonition → имя макроса. */
|
|
34
35
|
export const NATIVE_ADMONITIONS = {
|
|
35
36
|
INFO: 'info',
|
|
@@ -63,7 +64,7 @@ export function nativeMacroList() {
|
|
|
63
64
|
{ macro: 'structurizr', syntax: '{{structurizr:workspace.dsl|view=containers}}' },
|
|
64
65
|
{ macro: 'drawio', syntax: '{{drawio:diagram.drawio|format=macro}} | {{drawio:diagram.drawio|format=image}}' },
|
|
65
66
|
{ macro: 'info', syntax: '> [!INFO] Заголовок?' },
|
|
66
|
-
{ macro: 'note', syntax: '> [!NOTE] Заголовок?' },
|
|
67
|
+
{ macro: 'note', syntax: '> [!NOTE] Заголовок? | {{note:Текст}}' },
|
|
67
68
|
{ macro: 'warning', syntax: '> [!WARNING] Заголовок?' },
|
|
68
69
|
{ macro: 'tip', syntax: '> [!TIP] Заголовок?' },
|
|
69
70
|
{ macro: 'details', syntax: '::: properties [id=…] [hidden=true] … :::' },
|
|
@@ -81,7 +82,6 @@ export function nativeMacroList() {
|
|
|
81
82
|
const ADMONITION_FIRST_RE = /^>\s*\[!([A-Za-z]+)\]\s*(.*)$/;
|
|
82
83
|
const DIRECTIVE_OPEN_RE = /^:::\s+([a-z-]+)(?:\s+(.*?))?\s*$/;
|
|
83
84
|
const DIRECTIVE_CLOSE_RE = /^:::\s*$/;
|
|
84
|
-
const PLACEHOLDER_INLINE_RE = /\{\{(search|drawio|structurizr|toc|children|jira|status|anchor|properties-report|portfolio-for-jira-plan)(?::((?:"(?:\\.|[^"\\])*"|[^{}"]|\{[^{])*?))?\}\}/g;
|
|
85
85
|
const FENCE_RE = /^\s*(`{3,}|~{3,})/;
|
|
86
86
|
/** Компактная пара маркеров без тела — для строчного контекста. */
|
|
87
87
|
function inlineMarker(macroName, params) {
|
|
@@ -174,7 +174,7 @@ function placeholderParams(kind, raw) {
|
|
|
174
174
|
return params;
|
|
175
175
|
}
|
|
176
176
|
function replaceInline(line) {
|
|
177
|
-
return line
|
|
177
|
+
return replaceInlineSyntax(line, ref => ref.id in NATIVE_PLACEHOLDERS ? inlineMarker(NATIVE_PLACEHOLDERS[ref.id], placeholderParams(ref.id, ref.body)) : undefined);
|
|
178
178
|
}
|
|
179
179
|
/** Параметры директивы: токены `k=v` (значение можно в кавычках) + свободный текст → title. */
|
|
180
180
|
function directiveParams(name, rest) {
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function liftInlineNotes(html: string): string;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { getAttr, parseStorage, serializeStorage } from '../export/xhtml.js';
|
|
2
|
+
const isNote = (n) => n.kind === 'el' && n.name === 'ac:structured-macro' && getAttr(n, 'ac:name') === 'note';
|
|
3
|
+
/** Split inline wrappers as well, so a Note never becomes a block inside <p>/<strong>. */
|
|
4
|
+
function split(nodes) {
|
|
5
|
+
const result = [[]];
|
|
6
|
+
for (const n of nodes) {
|
|
7
|
+
if (isNote(n)) {
|
|
8
|
+
result.push([n], []);
|
|
9
|
+
continue;
|
|
10
|
+
}
|
|
11
|
+
if (n.kind !== 'el') {
|
|
12
|
+
result[result.length - 1].push(n);
|
|
13
|
+
continue;
|
|
14
|
+
}
|
|
15
|
+
const inner = split(n.children);
|
|
16
|
+
for (const chunk of inner) {
|
|
17
|
+
if (chunk.length === 1 && isNote(chunk[0]))
|
|
18
|
+
result.push(chunk, []);
|
|
19
|
+
else if (chunk.length)
|
|
20
|
+
result[result.length - 1].push({ ...n, children: chunk });
|
|
21
|
+
}
|
|
22
|
+
if (!n.children.length)
|
|
23
|
+
result[result.length - 1].push(n);
|
|
24
|
+
}
|
|
25
|
+
return result.filter(chunk => chunk.length);
|
|
26
|
+
}
|
|
27
|
+
export function liftInlineNotes(html) {
|
|
28
|
+
const walk = (nodes) => nodes.flatMap(n => {
|
|
29
|
+
if (n.kind !== 'el')
|
|
30
|
+
return [n];
|
|
31
|
+
const children = walk(n.children);
|
|
32
|
+
if (['td', 'th'].includes(n.name) && children.some(isNote)) {
|
|
33
|
+
const blocks = split(children).flatMap(chunk => chunk.every(n => isNote(n) || (n.kind === 'el' && n.name === 'p')) ? chunk : [{ kind: 'el', name: 'p', attrs: [], children: chunk, selfClosing: false }]);
|
|
34
|
+
return [{ ...n, children: blocks }];
|
|
35
|
+
}
|
|
36
|
+
if (n.name !== 'p')
|
|
37
|
+
return [{ ...n, children }];
|
|
38
|
+
return split(children).map(chunk => chunk.length === 1 && isNote(chunk[0]) ? chunk[0] : { ...n, children: chunk });
|
|
39
|
+
});
|
|
40
|
+
return serializeStorage(walk(parseStorage(html)));
|
|
41
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/** Plain body is Markdown; quoted body permits parameters and a single-line table cell. */
|
|
2
|
+
export declare function parseInlineNote(body: string): {
|
|
3
|
+
text: string;
|
|
4
|
+
attrs: [string, string][];
|
|
5
|
+
};
|
|
6
|
+
export declare function inlineNote(text: string, attrs?: [string, string][]): string;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { parsePlaceholder, quotePlaceholder } from './placeholder.js';
|
|
2
|
+
/** Plain body is Markdown; quoted body permits parameters and a single-line table cell. */
|
|
3
|
+
export function parseInlineNote(body) {
|
|
4
|
+
if (body.startsWith('"')) {
|
|
5
|
+
const ref = parsePlaceholder(body);
|
|
6
|
+
return { text: ref.name, attrs: ref.attrs };
|
|
7
|
+
}
|
|
8
|
+
let text = '';
|
|
9
|
+
for (let i = 0; i < body.length; i++) {
|
|
10
|
+
if (body[i] === '\\' && body[i + 1] === 'n') {
|
|
11
|
+
text += '\n';
|
|
12
|
+
i++;
|
|
13
|
+
}
|
|
14
|
+
else if (body[i] === '\\' && body[i + 1] === '\\') {
|
|
15
|
+
text += '\\\\';
|
|
16
|
+
i++;
|
|
17
|
+
}
|
|
18
|
+
else
|
|
19
|
+
text += body[i];
|
|
20
|
+
}
|
|
21
|
+
return { text, attrs: [] };
|
|
22
|
+
}
|
|
23
|
+
export function inlineNote(text, attrs = []) {
|
|
24
|
+
// JSON quotes avoid confusing nested macro pipes with Note parameters.
|
|
25
|
+
return `{{note:${JSON.stringify(text)}${attrs.map(([key, value]) => `|${key}=${quotePlaceholder(value)}`).join('')}}}`;
|
|
26
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export interface PlaceholderRef {
|
|
2
|
+
name: string;
|
|
3
|
+
attrs: Array<[string, string]>;
|
|
4
|
+
}
|
|
5
|
+
export declare const PLACEHOLDER_RE: RegExp;
|
|
6
|
+
export declare const quotePlaceholder: (value: string) => string;
|
|
7
|
+
export declare function parsePlaceholder(body: string): PlaceholderRef;
|
|
8
|
+
export declare function placeholder(kind: string, name: string, attrs?: Array<[string, string]>): string;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export const PLACEHOLDER_RE = /\{\{(img|file|page):((?:"(?:\\.|[^"\\])*"|[^{}"]|\{[^{])*?)\}\}/g;
|
|
2
|
+
const unquote = (value) => {
|
|
3
|
+
if (value.startsWith('"') && value.endsWith('"')) {
|
|
4
|
+
try {
|
|
5
|
+
return JSON.parse(value);
|
|
6
|
+
}
|
|
7
|
+
catch { /* Legacy values remain literal. */ }
|
|
8
|
+
}
|
|
9
|
+
return value;
|
|
10
|
+
};
|
|
11
|
+
export const quotePlaceholder = (value) => /[|{}"\\\r\n]/.test(value) ? JSON.stringify(value) : value;
|
|
12
|
+
export function parsePlaceholder(body) {
|
|
13
|
+
const parts = body.match(/(?:"(?:\\.|[^"\\])*"|[^|"])+/g) ?? [];
|
|
14
|
+
const name = unquote((parts.shift() ?? '').trim());
|
|
15
|
+
const attrs = parts.map(part => {
|
|
16
|
+
const eq = part.indexOf('=');
|
|
17
|
+
return eq < 0 ? [part.trim(), ''] : [part.slice(0, eq).trim(), unquote(part.slice(eq + 1).trim())];
|
|
18
|
+
});
|
|
19
|
+
return { name, attrs };
|
|
20
|
+
}
|
|
21
|
+
export function placeholder(kind, name, attrs = []) {
|
|
22
|
+
return `{{${kind}:${quotePlaceholder(name)}${attrs.map(([key, value]) => `|${key}=${quotePlaceholder(value)}`).join('')}}}`;
|
|
23
|
+
}
|
|
@@ -1,18 +1,10 @@
|
|
|
1
|
+
export { PLACEHOLDER_RE, parsePlaceholder, type PlaceholderRef } from './placeholder.js';
|
|
1
2
|
/**
|
|
2
3
|
* Плейсхолдеры: `{{img:name}}`, `{{file:name}}`, `{{page:Title}}`.
|
|
3
4
|
* После имени допустимы `|key=value`-атрибуты:
|
|
4
5
|
* {{img:chart.png|thumbnail=true|height=250}}
|
|
5
6
|
* {{page:Другая страница|space=DOCS|text=якорный текст}}
|
|
6
7
|
*/
|
|
7
|
-
export declare const PLACEHOLDER_RE: RegExp;
|
|
8
|
-
export interface PlaceholderRef {
|
|
9
|
-
/** Имя файла (img/file) или title страницы (page). */
|
|
10
|
-
name: string;
|
|
11
|
-
/** `|key=value`-атрибуты в порядке записи. */
|
|
12
|
-
attrs: Array<[string, string]>;
|
|
13
|
-
}
|
|
14
|
-
/** Разбирает содержимое плейсхолдера: `name|k=v|k2=v2`. */
|
|
15
|
-
export declare function parsePlaceholder(body: string): PlaceholderRef;
|
|
16
8
|
export interface ExtractedPlaceholders {
|
|
17
9
|
images: string[];
|
|
18
10
|
files: string[];
|
|
@@ -67,24 +59,5 @@ export interface RenderStorageOptions {
|
|
|
67
59
|
export declare class MissingAttachmentUrlError extends Error {
|
|
68
60
|
constructor(message: string);
|
|
69
61
|
}
|
|
70
|
-
/**
|
|
71
|
-
* Превращает Markdown в Confluence storage format.
|
|
72
|
-
*
|
|
73
|
-
* Логика двушаговая:
|
|
74
|
-
* 1) markdown-it рендерит сам Markdown (без знания про плейсхолдеры).
|
|
75
|
-
* `{{img:foo}}` и `{{file:foo}}` для markdown-it — обычный текст
|
|
76
|
-
* без спецсимволов, доходят до выхода нетронутыми (могут быть
|
|
77
|
-
* обёрнуты в <p>...</p>, что нас устраивает).
|
|
78
|
-
* 2) В готовом HTML регуляркой подставляем `<img src=URL/>` и
|
|
79
|
-
* `<a href=URL>filename</a>` на места плейсхолдеров. URL обязан
|
|
80
|
-
* существовать для каждого плейсхолдера — иначе
|
|
81
|
-
* MissingAttachmentUrlError.
|
|
82
|
-
*
|
|
83
|
-
* Раньше использовалась схема через sentinel-токены (` CFLIMG_0 `):
|
|
84
|
-
* markdown-it трогал окружающие пробелы при упаковке в <p>, sentinel
|
|
85
|
-
* терял пробелы, итоговый .split() не находил совпадения и в Confluence
|
|
86
|
-
* вместо картинки попадал кусок «CFLIMG_0». Прямая регекс-замена по
|
|
87
|
-
* HTML этой проблемы лишена — `{{img:foo}}` мимо markdown-it проходит
|
|
88
|
-
* посимвольно.
|
|
89
|
-
*/
|
|
62
|
+
/** Render Markdown after shielding authored macros from GFM table/inline parsing. */
|
|
90
63
|
export declare function renderToStorage(markdown: string, urls: AttachmentUrls, opts?: RenderStorageOptions): string;
|
package/dist/markdown/render.js
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
import MarkdownIt from 'markdown-it';
|
|
2
|
-
import { nativeToMarkers } from './native.js';
|
|
2
|
+
import { nativeToMarkers, NATIVE_PLACEHOLDERS } from './native.js';
|
|
3
|
+
import { parsePlaceholder, placeholder } from './placeholder.js';
|
|
4
|
+
import { attachmentImage } from './image.js';
|
|
5
|
+
import { replaceInlineSyntax, unescapeTablePipes } from './inline-syntax.js';
|
|
6
|
+
import { parseInlineNote } from './note.js';
|
|
7
|
+
import { renderInlineNote } from '../macros/plugins/note.js';
|
|
8
|
+
import { liftInlineNotes } from './note-layout.js';
|
|
9
|
+
export { PLACEHOLDER_RE, parsePlaceholder } from './placeholder.js';
|
|
3
10
|
// xhtmlOut: true — Confluence storage format = XHTML, void-элементы
|
|
4
11
|
// (<hr/>, <br/>, <img/>) обязаны быть самозакрывающимися.
|
|
5
12
|
// html: true — разрешить HTML (нужно для <!-- MACRO:... --> комментариев)
|
|
@@ -15,40 +22,20 @@ const md = new MarkdownIt(mdOptions);
|
|
|
15
22
|
// для страниц из exportPage — иначе round-trip превращал бы нессылочные
|
|
16
23
|
// упоминания URL в <a>.
|
|
17
24
|
const mdNoLinkify = new MarkdownIt({ ...mdOptions, linkify: false });
|
|
18
|
-
/**
|
|
19
|
-
* Плейсхолдеры: `{{img:name}}`, `{{file:name}}`, `{{page:Title}}`.
|
|
20
|
-
* После имени допустимы `|key=value`-атрибуты:
|
|
21
|
-
* {{img:chart.png|thumbnail=true|height=250}}
|
|
22
|
-
* {{page:Другая страница|space=DOCS|text=якорный текст}}
|
|
23
|
-
*/
|
|
24
|
-
export const PLACEHOLDER_RE = /\{\{(img|file|page):([^}]+)\}\}/g;
|
|
25
|
-
/** Разбирает содержимое плейсхолдера: `name|k=v|k2=v2`. */
|
|
26
|
-
export function parsePlaceholder(body) {
|
|
27
|
-
const parts = body.split('|');
|
|
28
|
-
const name = parts[0].trim();
|
|
29
|
-
const attrs = [];
|
|
30
|
-
for (const part of parts.slice(1)) {
|
|
31
|
-
const eq = part.indexOf('=');
|
|
32
|
-
if (eq === -1)
|
|
33
|
-
attrs.push([part.trim(), '']);
|
|
34
|
-
else
|
|
35
|
-
attrs.push([part.slice(0, eq).trim(), part.slice(eq + 1).trim()]);
|
|
36
|
-
}
|
|
37
|
-
return { name, attrs };
|
|
38
|
-
}
|
|
39
25
|
export function extractPlaceholders(markdown) {
|
|
40
26
|
const images = new Set();
|
|
41
27
|
const files = new Set();
|
|
42
28
|
const pages = new Set();
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
files.add(name);
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
}
|
|
29
|
+
const collect = (source) => replaceInlineSyntax(source, ref => {
|
|
30
|
+
if (ref.id === 'note')
|
|
31
|
+
collect(parseInlineNote(ref.body).text);
|
|
32
|
+
else if (['img', 'file', 'page'].includes(ref.id)) {
|
|
33
|
+
const { name } = parsePlaceholder(ref.body);
|
|
34
|
+
(ref.id === 'img' ? images : ref.id === 'file' ? files : pages).add(name);
|
|
35
|
+
}
|
|
36
|
+
return undefined;
|
|
37
|
+
});
|
|
38
|
+
collect(markdown);
|
|
52
39
|
return { images: [...images], files: [...files], pages: [...pages] };
|
|
53
40
|
}
|
|
54
41
|
function escapeXmlAttr(s) {
|
|
@@ -71,13 +58,15 @@ function escapeXmlAttr(s) {
|
|
|
71
58
|
export function renameImagePlaceholders(markdown, renames) {
|
|
72
59
|
if (renames.size === 0)
|
|
73
60
|
return markdown;
|
|
74
|
-
return markdown
|
|
75
|
-
|
|
61
|
+
return replaceInlineSyntax(markdown, ref => {
|
|
62
|
+
if (ref.id !== 'img')
|
|
63
|
+
return;
|
|
64
|
+
const full = ref.raw;
|
|
65
|
+
const { name, attrs } = parsePlaceholder(ref.body);
|
|
76
66
|
const renamed = renames.get(name);
|
|
77
67
|
if (!renamed)
|
|
78
68
|
return full;
|
|
79
|
-
|
|
80
|
-
return `{{img:${renamed}${attrStr}}}`;
|
|
69
|
+
return placeholder('img', renamed, attrs);
|
|
81
70
|
});
|
|
82
71
|
}
|
|
83
72
|
export class MissingAttachmentUrlError extends Error {
|
|
@@ -86,32 +75,61 @@ export class MissingAttachmentUrlError extends Error {
|
|
|
86
75
|
this.name = 'MissingAttachmentUrlError';
|
|
87
76
|
}
|
|
88
77
|
}
|
|
89
|
-
/**
|
|
90
|
-
* Превращает Markdown в Confluence storage format.
|
|
91
|
-
*
|
|
92
|
-
* Логика двушаговая:
|
|
93
|
-
* 1) markdown-it рендерит сам Markdown (без знания про плейсхолдеры).
|
|
94
|
-
* `{{img:foo}}` и `{{file:foo}}` для markdown-it — обычный текст
|
|
95
|
-
* без спецсимволов, доходят до выхода нетронутыми (могут быть
|
|
96
|
-
* обёрнуты в <p>...</p>, что нас устраивает).
|
|
97
|
-
* 2) В готовом HTML регуляркой подставляем `<img src=URL/>` и
|
|
98
|
-
* `<a href=URL>filename</a>` на места плейсхолдеров. URL обязан
|
|
99
|
-
* существовать для каждого плейсхолдера — иначе
|
|
100
|
-
* MissingAttachmentUrlError.
|
|
101
|
-
*
|
|
102
|
-
* Раньше использовалась схема через sentinel-токены (` CFLIMG_0 `):
|
|
103
|
-
* markdown-it трогал окружающие пробелы при упаковке в <p>, sentinel
|
|
104
|
-
* терял пробелы, итоговый .split() не находил совпадения и в Confluence
|
|
105
|
-
* вместо картинки попадал кусок «CFLIMG_0». Прямая регекс-замена по
|
|
106
|
-
* HTML этой проблемы лишена — `{{img:foo}}` мимо markdown-it проходит
|
|
107
|
-
* посимвольно.
|
|
108
|
-
*/
|
|
78
|
+
/** Render Markdown after shielding authored macros from GFM table/inline parsing. */
|
|
109
79
|
export function renderToStorage(markdown, urls, opts = {}) {
|
|
110
80
|
// Нативный md-синтаксис макросов (панели, ::: properties, {{toc}} …) —
|
|
111
81
|
// сахар над маркерами: переписываем до markdown-it (см. native.ts).
|
|
82
|
+
const placeholders = [];
|
|
83
|
+
let prefix = 'CFLATTACHTOKEN';
|
|
84
|
+
while (markdown.includes(prefix))
|
|
85
|
+
prefix += 'X';
|
|
86
|
+
markdown = replaceInlineSyntax(markdown, ref => {
|
|
87
|
+
if (!['img', 'file', 'page', 'note'].includes(ref.id) && !(ref.id in NATIVE_PLACEHOLDERS))
|
|
88
|
+
return;
|
|
89
|
+
const line = markdown.slice(markdown.lastIndexOf('\n', ref.from - 1) + 1, ref.from);
|
|
90
|
+
const cell = line.trimStart().startsWith('|');
|
|
91
|
+
placeholders.push(cell ? { ...ref, raw: unescapeTablePipes(ref.raw), body: unescapeTablePipes(ref.body) } : ref);
|
|
92
|
+
return `${prefix}${placeholders.length - 1}TOKEN`;
|
|
93
|
+
});
|
|
112
94
|
markdown = nativeToMarkers(markdown);
|
|
113
95
|
const renderer = opts.linkify === false ? mdNoLinkify : md;
|
|
114
96
|
let html = renderer.render(markdown);
|
|
97
|
+
const renderPlaceholder = (kind, body) => {
|
|
98
|
+
const { name, attrs } = parsePlaceholder(body);
|
|
99
|
+
if (kind === 'page') {
|
|
100
|
+
return renderPageLink(name, attrs);
|
|
101
|
+
}
|
|
102
|
+
if (kind === 'img') {
|
|
103
|
+
if (opts.imageStyle === 'attachment') {
|
|
104
|
+
return attachmentImage(name, attrs);
|
|
105
|
+
}
|
|
106
|
+
const url = urls.images.get(name);
|
|
107
|
+
if (!url) {
|
|
108
|
+
throw new MissingAttachmentUrlError(`No uploaded URL for image '${name}' — was it included in publishPage().images and successfully uploaded?`);
|
|
109
|
+
}
|
|
110
|
+
return `<img src="${escapeXmlAttr(url)}" alt="${escapeXmlAttr(name)}" />`;
|
|
111
|
+
}
|
|
112
|
+
if (opts.fileStyle === 'attachment') {
|
|
113
|
+
const text = attrs.find(([k]) => k === 'text')?.[1];
|
|
114
|
+
const body = text !== undefined ? plainTextLinkBody(text) : '';
|
|
115
|
+
return `<ac:link><ri:attachment ri:filename="${escapeXmlAttr(name)}" />${body}</ac:link>`;
|
|
116
|
+
}
|
|
117
|
+
const url = urls.files.get(name);
|
|
118
|
+
if (!url) {
|
|
119
|
+
throw new MissingAttachmentUrlError(`No uploaded URL for file '${name}' — was it included in publishPage().files and successfully uploaded?`);
|
|
120
|
+
}
|
|
121
|
+
return `<a href="${escapeXmlAttr(url)}">${escapeXmlAttr(name)}</a>`;
|
|
122
|
+
};
|
|
123
|
+
html = html.replace(new RegExp(prefix + '(\\d+)TOKEN', 'g'), (_full, index) => {
|
|
124
|
+
const ref = placeholders[Number(index)];
|
|
125
|
+
if (ref.id === 'note') {
|
|
126
|
+
const note = parseInlineNote(ref.body);
|
|
127
|
+
const body = renderToStorage(note.text.replace(/\r?\n/g, '\n\n'), urls, opts);
|
|
128
|
+
return renderInlineNote(body.trim(), note.attrs);
|
|
129
|
+
}
|
|
130
|
+
return ['img', 'file', 'page'].includes(ref.id) ? renderPlaceholder(ref.id, ref.body) : nativeToMarkers(ref.raw);
|
|
131
|
+
});
|
|
132
|
+
html = html.replace(/<p>((?:\s*<!-- MACRO:start:[^>]* -->\s*<!-- MACRO:end:[^>]* -->\s*)+)<\/p>/g, '$1');
|
|
115
133
|
// ```confluence-storage — транспорт для дословного XHTML (ac:/ri:-теги
|
|
116
134
|
// markdown-it сквозь себя не пропускает). Fence рендерится в экранированный
|
|
117
135
|
// <pre><code>; здесь разэкранируем содержимое обратно в живую разметку.
|
|
@@ -160,35 +178,7 @@ export function renderToStorage(markdown, urls, opts = {}) {
|
|
|
160
178
|
return `<ac:link><ri:attachment ri:filename="${escapeXmlAttr(resource.attachment)}" />${body}</ac:link>`;
|
|
161
179
|
});
|
|
162
180
|
}
|
|
163
|
-
return
|
|
164
|
-
const { name, attrs } = parsePlaceholder(String(rawBody));
|
|
165
|
-
if (kind === 'page') {
|
|
166
|
-
return renderPageLink(name, attrs);
|
|
167
|
-
}
|
|
168
|
-
if (kind === 'img') {
|
|
169
|
-
if (opts.imageStyle === 'attachment') {
|
|
170
|
-
const acAttrs = attrs
|
|
171
|
-
.map(([k, v]) => ` ac:${k}="${escapeXmlAttr(v)}"`)
|
|
172
|
-
.join('');
|
|
173
|
-
return `<ac:image${acAttrs}><ri:attachment ri:filename="${escapeXmlAttr(name)}" /></ac:image>`;
|
|
174
|
-
}
|
|
175
|
-
const url = urls.images.get(name);
|
|
176
|
-
if (!url) {
|
|
177
|
-
throw new MissingAttachmentUrlError(`No uploaded URL for image '${name}' — was it included in publishPage().images and successfully uploaded?`);
|
|
178
|
-
}
|
|
179
|
-
return `<img src="${escapeXmlAttr(url)}" alt="${escapeXmlAttr(name)}" />`;
|
|
180
|
-
}
|
|
181
|
-
if (opts.fileStyle === 'attachment') {
|
|
182
|
-
const text = attrs.find(([k]) => k === 'text')?.[1];
|
|
183
|
-
const body = text !== undefined ? plainTextLinkBody(text) : '';
|
|
184
|
-
return `<ac:link><ri:attachment ri:filename="${escapeXmlAttr(name)}" />${body}</ac:link>`;
|
|
185
|
-
}
|
|
186
|
-
const url = urls.files.get(name);
|
|
187
|
-
if (!url) {
|
|
188
|
-
throw new MissingAttachmentUrlError(`No uploaded URL for file '${name}' — was it included in publishPage().files and successfully uploaded?`);
|
|
189
|
-
}
|
|
190
|
-
return `<a href="${escapeXmlAttr(url)}">${escapeXmlAttr(name)}</a>`;
|
|
191
|
-
});
|
|
181
|
+
return placeholders.some(ref => ref.id === 'note') ? liftInlineNotes(html) : html;
|
|
192
182
|
}
|
|
193
183
|
function renderPageLink(title, attrs) {
|
|
194
184
|
const space = attrs.find(([k]) => k === 'space')?.[1];
|
package/dist/syntax.d.ts
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
export { PLACEHOLDER_RE, parsePlaceholder, placeholder, quotePlaceholder, type PlaceholderRef } from './markdown/placeholder.js';
|
|
2
|
+
export { readInlineSyntax, replaceInlineSyntax, unescapeTablePipes, type InlineSyntax } from './markdown/inline-syntax.js';
|
|
3
|
+
export { parseInlineNote, inlineNote } from './markdown/note.js';
|
package/dist/syntax.js
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
// Browser-safe authoring helpers shared with editors.
|
|
2
|
+
export { PLACEHOLDER_RE, parsePlaceholder, placeholder, quotePlaceholder } from './markdown/placeholder.js';
|
|
3
|
+
export { readInlineSyntax, replaceInlineSyntax, unescapeTablePipes } from './markdown/inline-syntax.js';
|
|
4
|
+
export { parseInlineNote, inlineNote } from './markdown/note.js';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "confluence-md-sync",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.0",
|
|
4
4
|
"description": "Publish Markdown to Confluence (Data Center & Cloud): idempotent page sync, attachment dedup, tables and a pluggable macro system",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"confluence",
|
|
@@ -40,6 +40,10 @@
|
|
|
40
40
|
"./macros": {
|
|
41
41
|
"types": "./dist/macros/index.d.ts",
|
|
42
42
|
"import": "./dist/macros/index.js"
|
|
43
|
+
},
|
|
44
|
+
"./syntax": {
|
|
45
|
+
"types": "./dist/syntax.d.ts",
|
|
46
|
+
"import": "./dist/syntax.js"
|
|
43
47
|
}
|
|
44
48
|
},
|
|
45
49
|
"bin": {
|