confluence-md-sync 0.11.1 → 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 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`.
@@ -5,7 +5,9 @@
5
5
  import type { ConfluenceConfig } from '../client/config.js';
6
6
  import type { MacroRegistry } from '../macros/registry.js';
7
7
  import { type StorageToMarkdownResult } from './to-markdown.js';
8
+ import type { StorageLinkResolver } from './links.js';
8
9
  export interface ExportPageOptions {
10
+ linkResolver?: StorageLinkResolver;
9
11
  /**
10
12
  * Точный путь до итогового md-файла. Альтернатива `outDir`. Аттачи (если
11
13
  * включены) кладутся в `attachments/` рядом с этим файлом.
@@ -16,6 +16,7 @@ export async function exportPage(pageId, opts, cfg) {
16
16
  mode: opts.mode,
17
17
  attachments: opts.attachments,
18
18
  tables: opts.tables,
19
+ linkResolver: opts.linkResolver,
19
20
  });
20
21
  const markdownPath = opts.outFile ?? join(opts.outDir ?? `./${pageId}`, 'page.md');
21
22
  mkdirSync(dirname(markdownPath), { recursive: true });
@@ -0,0 +1,12 @@
1
+ export interface StorageLink {
2
+ href?: string;
3
+ pageId?: string;
4
+ title?: string;
5
+ space?: string;
6
+ anchor?: string;
7
+ }
8
+ export type StorageLinkResolver = (link: StorageLink) => string | null | undefined;
9
+ /** Collect targets before a batch determines their final file paths. */
10
+ export declare function storageLinks(storage: string): StorageLink[];
11
+ /** Rewrite links only after the caller has prepared every destination. */
12
+ export declare function rewriteStorageLinks(storage: string, resolver: StorageLinkResolver): string;
@@ -0,0 +1,42 @@
1
+ import { escapeXmlAttr } from '../macros/xml.js';
2
+ import { elements, getAttr, parseStorage, serializeStorage, textContent } from './xhtml.js';
3
+ function visit(nodes, fn) {
4
+ for (const el of nodes) {
5
+ if (el.kind !== 'el' || ['code', 'pre', 'ac:parameter', 'ac:plain-text-body'].includes(el.name))
6
+ continue;
7
+ if (el.name === 'a' && getAttr(el, 'href'))
8
+ fn(el, { href: getAttr(el, 'href') });
9
+ else if (el.name === 'ac:link') {
10
+ const page = elements(el.children).find(c => c.name === 'ri:page');
11
+ if (page)
12
+ fn(el, { pageId: getAttr(page, 'ri:content-id'), title: getAttr(page, 'ri:content-title'), space: getAttr(page, 'ri:space-key'), anchor: getAttr(el, 'ac:anchor') });
13
+ }
14
+ visit(el.children, fn);
15
+ }
16
+ }
17
+ /** Collect targets before a batch determines their final file paths. */
18
+ export function storageLinks(storage) {
19
+ const result = [];
20
+ visit(parseStorage(storage), (_el, link) => result.push(link));
21
+ return result;
22
+ }
23
+ /** Rewrite links only after the caller has prepared every destination. */
24
+ export function rewriteStorageLinks(storage, resolver) {
25
+ const nodes = parseStorage(storage);
26
+ let changed = false;
27
+ visit(nodes, (el, link) => {
28
+ const href = resolver(link);
29
+ if (!href || href === link.href)
30
+ return;
31
+ if (el.name === 'ac:link') {
32
+ const body = elements(el.children).find(c => c.name === 'ac:link-body' || c.name === 'ac:plain-text-link-body');
33
+ el.children = body?.name === 'ac:link-body' ? body.children : [{ kind: 'text', raw: escapeXmlAttr(body ? textContent(body.children) : link.title ?? href) }];
34
+ el.name = 'a';
35
+ el.attrs = [];
36
+ el.selfClosing = false;
37
+ }
38
+ el.attrs = [...el.attrs.filter(([key]) => key !== 'href'), ['href', escapeXmlAttr(href)]];
39
+ changed = true;
40
+ });
41
+ return changed ? serializeStorage(nodes) : storage;
42
+ }
@@ -20,7 +20,10 @@
20
20
  * выдаётся никогда.
21
21
  */
22
22
  import { type MacroRegistry } from '../macros/registry.js';
23
+ import { type StorageLinkResolver } from './links.js';
23
24
  export interface StorageToMarkdownOptions {
25
+ /** Map known page references to repository URLs after allocating all batch paths. */
26
+ linkResolver?: StorageLinkResolver;
24
27
  /** Known XML attachments; needed for image-mode diagrams without a .drawio suffix. */
25
28
  drawioNames?: string[];
26
29
  /** Реестр для проверки маркеров макросов (default: встроенный). */
@@ -24,11 +24,16 @@ 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';
32
+ import { rewriteStorageLinks } from './links.js';
29
33
  import { decodeEntities, elements, getAttr, hasNamespacedElements, parseStorage, serializeStorage, textContent, } from './xhtml.js';
30
34
  /** Конвертирует storage-фрагмент страницы в Markdown. */
31
35
  export function storageToMarkdown(storage, opts = {}) {
36
+ const rewritten = opts.linkResolver ? rewriteStorageLinks(storage, opts.linkResolver) : storage;
32
37
  const local = opts.attachments === 'local';
33
38
  const records = opts.tables === 'records';
34
39
  // 'local' и 'records' — заведомо не round-trip, поэтому включают readable-
@@ -41,7 +46,9 @@ export function storageToMarkdown(storage, opts = {}) {
41
46
  localPrefix: opts.localPrefix ?? 'attachments/',
42
47
  drawioNames: opts.drawioNames ?? [],
43
48
  });
44
- const markdown = conv.blocksToMd(parseStorage(storage));
49
+ const markdown = conv.blocksToMd(parseStorage(rewritten));
50
+ if (rewritten !== storage)
51
+ conv.stats.normalized++;
45
52
  const attachmentRefs = new Set([...conv.images, ...conv.files]);
46
53
  for (const m of storage.matchAll(/ri:filename="([^"]*)"/g))
47
54
  attachmentRefs.add(m[1]);
@@ -156,6 +163,8 @@ class Converter {
156
163
  return this.listToMd(el);
157
164
  if (el.name === 'table') {
158
165
  // tables:'records' — любую таблицу разворачиваем в записи.
166
+ if (this.hasComplexNote(el.children))
167
+ return this.fence(serializeStorage([el]));
159
168
  if (this.tablesAsRecords)
160
169
  return this.recordsTable(el);
161
170
  try {
@@ -855,6 +864,33 @@ class Converter {
855
864
  const row = (cells) => `| ${cells.join(' | ')} |`;
856
865
  return [row(header), row(sep), ...bodyRows.map(row)].join('\n');
857
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
+ }
858
894
  cellMd(cell) {
859
895
  // Единственный <p> внутри ячейки — разворачиваем (типичная форма).
860
896
  let content = cell.children;
@@ -863,7 +899,8 @@ class Converter {
863
899
  content.every((n) => n.kind !== 'text' || /^[ \t\r\n]*$/.test(n.raw))) {
864
900
  content = els[0].children;
865
901
  }
866
- const md = this.inlineToMd(content, { cell: true }).trim();
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();
867
904
  if (md.includes('\n'))
868
905
  throw new Unrepresentable();
869
906
  // Пайпы экранируем один раз над всей ячейкой — покрывает и текст, и
@@ -1185,6 +1222,20 @@ class Converter {
1185
1222
  }
1186
1223
  return this.readable ? escapeMdText(alt, ctx, true) : this.rawInline(el, ctx);
1187
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
+ }
1188
1239
  case 'ac:image':
1189
1240
  return this.acImageToMd(el);
1190
1241
  case 'ac:link':
@@ -1233,6 +1284,14 @@ class Converter {
1233
1284
  const href = getAttr(el, 'href') ?? '';
1234
1285
  const inner = this.inlineToMd(el.children, ctx);
1235
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
+ }
1236
1295
  if ((onlyHref || this.readable) && SAFE_URL_RE.test(href) && !/[[\]]/.test(inner)) {
1237
1296
  return `[${inner}](${href})`; // readable: доп. атрибуты ссылки отбрасываются
1238
1297
  }
@@ -1253,7 +1312,10 @@ class Converter {
1253
1312
  this.files.add(architecture[1]);
1254
1313
  this.files.add(architecture[1].replace(/\.dsl$/, '.json'));
1255
1314
  this.images.add(filename);
1256
- return `{{structurizr:${architecture[1]}|view=${view}}}`;
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}`)]);
1257
1319
  }
1258
1320
  }
1259
1321
  const stem = filename.replace(/\.png$/i, '');
@@ -1264,7 +1326,10 @@ class Converter {
1264
1326
  if (preview && !/[{}|\n/\\]/.test(preview)) {
1265
1327
  this.files.add(drawioName);
1266
1328
  this.images.add(preview);
1267
- return `{{drawio:${drawioName}|format=image${preview !== `${drawioName}.png` ? `|preview=${preview}` : ''}}}`;
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]);
1268
1333
  }
1269
1334
  }
1270
1335
  // attachments:'local' — картинка как HTML <img> (сохраняет размеры).
@@ -1321,12 +1386,9 @@ class Converter {
1321
1386
  throw new Unrepresentable();
1322
1387
  attrs.push([k.slice(3), getAttr(el, k) ?? '']);
1323
1388
  }
1324
- for (const [, v] of attrs)
1325
- badPlaceholderPart(v);
1326
1389
  badPlaceholderPart(filename);
1327
1390
  this.images.add(filename);
1328
- const attrStr = attrs.map(([k, v]) => `|${k}=${v}`).join('');
1329
- return `{{img:${filename}${attrStr}}}`;
1391
+ return placeholder('img', filename, attrs);
1330
1392
  }
1331
1393
  acLinkToMd(el) {
1332
1394
  // readable безотказен: непредставимая ссылка (user-mention, ссылка без цели,
@@ -1380,6 +1442,13 @@ class Converter {
1380
1442
  }
1381
1443
  const filename = getAttr(ref, 'ri:filename') ?? '';
1382
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
+ }
1383
1452
  // attachments:'local' — md-ссылка на локальный файл вместо {{file:}}.
1384
1453
  if (this.localFiles) {
1385
1454
  const label = (text ?? filename).replace(/[[\]]/g, '\\$&');
package/dist/index.d.ts CHANGED
@@ -15,6 +15,7 @@ export { publishPage, computeContentHash, DEFAULT_HASH_PROPERTY_KEY, type Publis
15
15
  export { buildManagedNotice, applyManagedNotice, DEFAULT_MANAGED_NOTICE_TEXT, DEFAULT_MANAGED_NOTICE_LINK_TEXT, type ManagedNoticeOptions, } from './publish/notice.js';
16
16
  export { isHttpUrl, remoteFilename, isSameConfluenceOrigin, remoteRequestHeaders, downloadToFile, } from './publish/remote.js';
17
17
  export { runPublish, type Here, type Build, type PublishPlan, type RunPublishOptions, } from './publish/runner.js';
18
+ export { storageLinks, rewriteStorageLinks, type StorageLink, type StorageLinkResolver } from './export/links.js';
18
19
  export { parseStorage, serializeStorage, decodeEntities, StorageParseError, type XNode, type XElement, } from './export/xhtml.js';
19
20
  export { canonicalize, compareStorage, type CNode, type CompareResult, type StorageDiff, } from './export/canonical.js';
20
21
  export { storageToMarkdown, type StorageToMarkdownOptions, type StorageToMarkdownResult, } from './export/to-markdown.js';
package/dist/index.js CHANGED
@@ -23,6 +23,7 @@ export { buildManagedNotice, applyManagedNotice, DEFAULT_MANAGED_NOTICE_TEXT, DE
23
23
  export { isHttpUrl, remoteFilename, isSameConfluenceOrigin, remoteRequestHeaders, downloadToFile, } from './publish/remote.js';
24
24
  export { runPublish, } from './publish/runner.js';
25
25
  // Export (storage → markdown) & round-trip
26
+ export { storageLinks, rewriteStorageLinks } from './export/links.js';
26
27
  export { parseStorage, serializeStorage, decodeEntities, StorageParseError, } from './export/xhtml.js';
27
28
  export { canonicalize, compareStorage, } from './export/canonical.js';
28
29
  export { storageToMarkdown, } from './export/to-markdown.js';
@@ -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
- richBodyMacro('note', ['title', 'icon']),
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 { escapeXmlAttr, structuredMacro } from '../xml.js';
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 `<ac:image><ri:attachment ri:filename="${escapeXmlAttr(params.preview || `${name}.png`)}" /></ac:image>`;
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,3 @@
1
+ import type { MacroDefinition } from '../types.js';
2
+ export declare const noteMacro: MacroDefinition;
3
+ export declare function renderInlineNote(body: string, attrs: [string, string][]): string;
@@ -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 { escapeXmlAttr } from '../xml.js';
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 `<ac:image ac:alt="${escapeXmlAttr(`Structurizr · ${view}`)}"><ri:attachment ri:filename="${escapeXmlAttr(structurizrPreview(name, view))}" /></ac:image>`;
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,2 @@
1
+ /** Native attachment images retain Confluence's image toolbar and formatting. */
2
+ export declare function attachmentImage(name: string, attrs: Array<[string, string]>): string;
@@ -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) + '|');
@@ -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.replace(PLACEHOLDER_INLINE_RE, (_full, kind, raw) => inlineMarker(NATIVE_PLACEHOLDERS[kind], placeholderParams(kind, raw)));
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[];
@@ -54,6 +46,8 @@ export interface RenderStorageOptions {
54
46
  linkResolver?: (href: string) => {
55
47
  title: string;
56
48
  space?: string;
49
+ } | {
50
+ url: string;
57
51
  } | null;
58
52
  /** Local Markdown assets: attachment on this page, or a canonical repository URL. */
59
53
  resourceResolver?: (href: string) => {
@@ -65,24 +59,5 @@ export interface RenderStorageOptions {
65
59
  export declare class MissingAttachmentUrlError extends Error {
66
60
  constructor(message: string);
67
61
  }
68
- /**
69
- * Превращает Markdown в Confluence storage format.
70
- *
71
- * Логика двушаговая:
72
- * 1) markdown-it рендерит сам Markdown (без знания про плейсхолдеры).
73
- * `{{img:foo}}` и `{{file:foo}}` для markdown-it — обычный текст
74
- * без спецсимволов, доходят до выхода нетронутыми (могут быть
75
- * обёрнуты в <p>...</p>, что нас устраивает).
76
- * 2) В готовом HTML регуляркой подставляем `<img src=URL/>` и
77
- * `<a href=URL>filename</a>` на места плейсхолдеров. URL обязан
78
- * существовать для каждого плейсхолдера — иначе
79
- * MissingAttachmentUrlError.
80
- *
81
- * Раньше использовалась схема через sentinel-токены (` CFLIMG_0 `):
82
- * markdown-it трогал окружающие пробелы при упаковке в <p>, sentinel
83
- * терял пробелы, итоговый .split() не находил совпадения и в Confluence
84
- * вместо картинки попадал кусок «CFLIMG_0». Прямая регекс-замена по
85
- * HTML этой проблемы лишена — `{{img:foo}}` мимо markdown-it проходит
86
- * посимвольно.
87
- */
62
+ /** Render Markdown after shielding authored macros from GFM table/inline parsing. */
88
63
  export declare function renderToStorage(markdown: string, urls: AttachmentUrls, opts?: RenderStorageOptions): string;
@@ -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
- for (const m of markdown.matchAll(PLACEHOLDER_RE)) {
44
- const { name } = parsePlaceholder(m[2]);
45
- if (m[1] === 'img')
46
- images.add(name);
47
- else if (m[1] === 'file')
48
- files.add(name);
49
- else
50
- pages.add(name);
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.replace(/\{\{img:([^}]+)\}\}/g, (full, rawBody) => {
75
- const { name, attrs } = parsePlaceholder(rawBody);
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
- const attrStr = attrs.map(([k, v]) => `|${k}=${v}`).join('');
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>; здесь разэкранируем содержимое обратно в живую разметку.
@@ -137,9 +155,11 @@ export function renderToStorage(markdown, urls, opts = {}) {
137
155
  if (opts.linkResolver || opts.resourceResolver) {
138
156
  html = html.replace(/<a href="([^"]*)"(?:\s+title="[^"]*")?>([\s\S]*?)<\/a>/g, (full, href, inner) => {
139
157
  const decoded = unescapeHtml(href);
140
- if (/^(?:[a-z][a-z0-9+.-]*:|#|\/\/)/i.test(decoded))
141
- return full; // http(s):, mailto:, #anchor
158
+ if (/^(?:#|(?:mailto|tel|javascript|data):)/i.test(decoded))
159
+ return full;
142
160
  const target = opts.linkResolver?.(decoded);
161
+ if (target && 'url' in target)
162
+ return `<a href="${escapeXmlAttr(target.url)}">${inner}</a>`;
143
163
  const body = /<[^>]+>/.test(inner)
144
164
  ? `<ac:link-body>${inner}</ac:link-body>`
145
165
  : plainTextLinkBody(unescapeHtml(inner));
@@ -148,6 +168,8 @@ export function renderToStorage(markdown, urls, opts = {}) {
148
168
  const anchor = decoded.includes('#') ? ` ac:anchor="${escapeXmlAttr(decoded.slice(decoded.indexOf('#') + 1))}"` : '';
149
169
  return `<ac:link${anchor}><ri:page ri:content-title="${escapeXmlAttr(target.title)}"${space} />${body}</ac:link>`;
150
170
  }
171
+ if (/^(?:[a-z][a-z0-9+.-]*:|\/\/)/i.test(decoded))
172
+ return full;
151
173
  const resource = opts.resourceResolver?.(decoded);
152
174
  if (!resource)
153
175
  return full;
@@ -156,35 +178,7 @@ export function renderToStorage(markdown, urls, opts = {}) {
156
178
  return `<ac:link><ri:attachment ri:filename="${escapeXmlAttr(resource.attachment)}" />${body}</ac:link>`;
157
179
  });
158
180
  }
159
- return html.replace(PLACEHOLDER_RE, (_full, kind, rawBody) => {
160
- const { name, attrs } = parsePlaceholder(String(rawBody));
161
- if (kind === 'page') {
162
- return renderPageLink(name, attrs);
163
- }
164
- if (kind === 'img') {
165
- if (opts.imageStyle === 'attachment') {
166
- const acAttrs = attrs
167
- .map(([k, v]) => ` ac:${k}="${escapeXmlAttr(v)}"`)
168
- .join('');
169
- return `<ac:image${acAttrs}><ri:attachment ri:filename="${escapeXmlAttr(name)}" /></ac:image>`;
170
- }
171
- const url = urls.images.get(name);
172
- if (!url) {
173
- throw new MissingAttachmentUrlError(`No uploaded URL for image '${name}' — was it included in publishPage().images and successfully uploaded?`);
174
- }
175
- return `<img src="${escapeXmlAttr(url)}" alt="${escapeXmlAttr(name)}" />`;
176
- }
177
- if (opts.fileStyle === 'attachment') {
178
- const text = attrs.find(([k]) => k === 'text')?.[1];
179
- const body = text !== undefined ? plainTextLinkBody(text) : '';
180
- return `<ac:link><ri:attachment ri:filename="${escapeXmlAttr(name)}" />${body}</ac:link>`;
181
- }
182
- const url = urls.files.get(name);
183
- if (!url) {
184
- throw new MissingAttachmentUrlError(`No uploaded URL for file '${name}' — was it included in publishPage().files and successfully uploaded?`);
185
- }
186
- return `<a href="${escapeXmlAttr(url)}">${escapeXmlAttr(name)}</a>`;
187
- });
181
+ return placeholders.some(ref => ref.id === 'note') ? liftInlineNotes(html) : html;
188
182
  }
189
183
  function renderPageLink(title, attrs) {
190
184
  const space = attrs.find(([k]) => k === 'space')?.[1];
@@ -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.11.1",
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": {