confluence-md-sync 0.6.0 → 0.8.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
@@ -25,6 +25,9 @@ npm install -g confluence-md-sync # as a CLI: `confluence-md-sync …`
25
25
  - **Attachment dedup** — uploads are tagged `sha256:<hash>`; unchanged files
26
26
  are reused. `<file>.src-sha256` sidecars pin dedup to the *source* of
27
27
  non-deterministic artifacts (e.g. PNGs rendered by a headless browser).
28
+ - **Managed-page banner** — optionally stamp a “edit this in <source>” notice
29
+ (info/warning panel with a link) into the page; injected into storage only,
30
+ never into the markdown source. See *Managed-page banner*.
28
31
  - **Fail before write** — placeholders, files and macro markers are validated
29
32
  up front; Confluence is never touched on a broken input.
30
33
  - **Pluggable macros** — built-in `core` + `table-filter` plugins, extend
@@ -121,6 +124,34 @@ await publishPage({
121
124
  }, cfg);
122
125
  ```
123
126
 
127
+ ### Managed-page banner
128
+
129
+ When a page is generated from an external source (e.g. docs-studio) and users
130
+ should not edit it in Confluence, add a `managedNotice`. It is injected into the
131
+ **storage** at publish time — never into the markdown source, so export and
132
+ round-trip stay clean, and git remains the single source of truth. The banner is
133
+ deterministic (fixed macro id), so it doesn't break the no-history-spam hash skip.
134
+
135
+ ```ts
136
+ await publishPage({
137
+ pageId,
138
+ markdownPath,
139
+ managedNotice: {
140
+ linkUrl: 'https://studio.example/doc/123', // required
141
+ // everything below is optional:
142
+ linkText: 'постановка в docs-studio', // default: 'docs-studio'
143
+ text: 'Правьте страницу в {link}. Здесь только чтение — правки будут перезаписаны.',
144
+ panel: 'warning', // info | note | warning | tip (default: info)
145
+ position: 'top', // 'top' (default) | 'bottom'
146
+ },
147
+ }, cfg);
148
+ ```
149
+
150
+ `{link}` in `text` is replaced by the link; omit it and the link is appended.
151
+ The presence of `managedNotice` turns the banner on — leave it out and no notice
152
+ is added. To apply one banner to every page of a CI plan, pass `managedNotice`
153
+ to `runPublish(dir, plan, { managedNotice })`; a per-page value overrides it.
154
+
124
155
  ## BPMN diagrams out of the box
125
156
 
126
157
  Pass a `.bpmn` file as an image (a path or an `http(s)` URL) — it is
@@ -287,6 +318,31 @@ const rows = readCsv(decodeText(await page.getAttachment('data.csv')));
287
318
 
288
319
  ## Macros
289
320
 
321
+ ### Native Markdown syntax (0.8.0+)
322
+
323
+ Popular macros are written (and exported back) as plain Markdown — no comment
324
+ markers, no raw XHTML. Round-trip is verified canonically per macro; anything
325
+ that doesn't fit falls back to markers/fenced blocks automatically.
326
+
327
+ | Macro | Markdown |
328
+ | --- | --- |
329
+ | `info` / `note` / `warning` / `tip` | `> [!INFO] Title?` + quoted body (GitHub-style admonition) |
330
+ | `details` (Page Properties) | `::: properties [id=…] [hidden=true]` + md-table + `:::` |
331
+ | `expand` | `::: expand Title` + body + `:::` |
332
+ | `panel` | `::: panel title=… borderColor=…` + body + `:::` |
333
+ | `toc` | `{{toc}}` / `{{toc:maxLevel=3}}` |
334
+ | `children` | `{{children}}` / `{{children:depth=2}}` |
335
+ | `jira` | `{{jira:KEY-1}}` / `{{jira:jql=project = X\|maximumIssues=20}}` |
336
+ | `status` | `{{status:Текст\|colour=Green\|subtle=true}}` |
337
+ | `anchor` | `{{anchor:name}}` |
338
+ | `detailssummary` (Page Properties Report) | `{{properties-report:cql=…\|firstcolumn=…}}` |
339
+
340
+ Inside fenced code blocks the syntax is left untouched. The machine-readable
341
+ list is exported as `nativeMacroList()`; the md→markers pass is
342
+ `nativeToMarkers()` (runs automatically inside `renderToStorage`).
343
+
344
+ ### Marker builders
345
+
290
346
  Builders return `Markdown` with comment markers; markers become
291
347
  `<ac:structured-macro>` after rendering (nested macros resolve inner-first):
292
348
 
@@ -61,6 +61,8 @@ export interface StorageToMarkdownResult {
61
61
  rawHtml: number;
62
62
  /** readable-режим: сколько узлов конвертировано с потерей оформления. */
63
63
  lossy: number;
64
+ /** Макросы, выраженные нативным md-синтаксисом (панели, ::: …, {{…}}). */
65
+ native: number;
64
66
  };
65
67
  }
66
68
  /** Конвертирует storage-фрагмент страницы в Markdown. */
@@ -79,7 +79,7 @@ class Converter {
79
79
  registry;
80
80
  images = new Set();
81
81
  files = new Set();
82
- stats = { markers: 0, fenced: 0, rawHtml: 0, lossy: 0 };
82
+ stats = { markers: 0, fenced: 0, rawHtml: 0, lossy: 0, native: 0 };
83
83
  readable;
84
84
  localFiles;
85
85
  tablesAsRecords;
@@ -290,8 +290,147 @@ class Converter {
290
290
  return escapeMdText(textContent(el.children), {}, this.readable).trim();
291
291
  }
292
292
  // ── Макросы ──────────────────────────────────────────────────────────
293
+ // ── Нативный md-синтаксис (native.ts): панели, ::: properties/expand/panel,
294
+ // {{toc}}/{{children}}/{{jira}}/{{status}}/{{anchor}}/{{properties-report}}.
295
+ // Каждый кандидат проверяется рендером и канонической сверкой (как маркеры).
296
+ static NATIVE_ADMONITION_TAG = {
297
+ info: 'INFO', note: 'NOTE', warning: 'WARNING', tip: 'TIP',
298
+ };
299
+ static NATIVE_PLACEHOLDER_NAME = {
300
+ toc: 'toc', children: 'children', jira: 'jira', status: 'status',
301
+ anchor: 'anchor', detailssummary: 'properties-report',
302
+ };
303
+ tryNativeMd(el, name) {
304
+ if (this.readable)
305
+ return null; // readable-путь остаётся прежним
306
+ const parts = this.macroParts(el);
307
+ if (parts === null)
308
+ return null;
309
+ const { params, richBody, plainBody } = parts;
310
+ if (plainBody !== null)
311
+ return null;
312
+ const pmap = new Map(params.map((p) => [p.name, p.value]));
313
+ const oneline = (v) => v !== undefined && !/[\n|{}]/.test(v) && !v.includes('}}');
314
+ let candidate = null;
315
+ const tag = Converter.NATIVE_ADMONITION_TAG[name];
316
+ if (tag && richBody !== null) {
317
+ // Панель: параметры — только title.
318
+ if (![...pmap.keys()].every((k) => k === 'title'))
319
+ return null;
320
+ const title = pmap.get('title');
321
+ if (title !== undefined && (!oneline(title) || /\[|\]/.test(title)))
322
+ return null;
323
+ const bodyMd = this.bodyMd(richBody);
324
+ if (bodyMd === null || bodyMd.trim() === '')
325
+ return null;
326
+ const quoted = bodyMd.split('\n').map((l) => (l === '' ? '>' : `> ${l}`)).join('\n');
327
+ candidate = `> [!${tag}]${title ? ' ' + title : ''}\n${quoted}`;
328
+ }
329
+ else if (name === 'details' && richBody !== null) {
330
+ // «Свойства страницы»: параметры id/hidden, тело — простая md-таблица.
331
+ if (![...pmap.keys()].every((k) => k === 'id' || k === 'hidden'))
332
+ return null;
333
+ const bodyEls = elements(richBody.children);
334
+ const nonWs = richBody.children.filter((n) => !(n.kind === 'text' && /^[ \t\r\n]*$/.test(n.raw)));
335
+ if (bodyEls.length !== 1 || nonWs.length !== 1 || bodyEls[0].name !== 'table')
336
+ return null;
337
+ let tableMd;
338
+ try {
339
+ tableMd = this.tableToMd(bodyEls[0]);
340
+ }
341
+ catch (e) {
342
+ if (e instanceof Unrepresentable)
343
+ return null;
344
+ throw e;
345
+ }
346
+ const dp = [...pmap.entries()].map(([k, v]) => (oneline(v) && !/["\s]/.test(v) ? ` ${k}=${v}` : null));
347
+ if (dp.some((x) => x === null))
348
+ return null;
349
+ candidate = `::: properties${dp.join('')}\n${tableMd}\n:::`;
350
+ }
351
+ else if (name === 'expand' && richBody !== null) {
352
+ if (![...pmap.keys()].every((k) => k === 'title'))
353
+ return null;
354
+ const title = pmap.get('title');
355
+ if (title !== undefined && (!oneline(title) || /=|"/.test(title)))
356
+ return null;
357
+ const bodyMd = this.bodyMd(richBody);
358
+ if (bodyMd === null)
359
+ return null;
360
+ candidate = `::: expand${title ? ' ' + title : ''}\n${bodyMd}\n:::`;
361
+ }
362
+ else if (Converter.NATIVE_PLACEHOLDER_NAME[name] && richBody === null) {
363
+ // Bodyless-плейсхолдеры. Части значений с | или }} не выразить — маркер.
364
+ const ph = Converter.NATIVE_PLACEHOLDER_NAME[name];
365
+ const attrs = [];
366
+ let head = '';
367
+ for (const p of params) {
368
+ if (!oneline(p.value) || p.value.includes('|'))
369
+ return null;
370
+ if (name === 'jira' && p.name === 'key' && head === '') {
371
+ head = p.value;
372
+ continue;
373
+ }
374
+ if (name === 'status' && p.name === 'title' && head === '') {
375
+ head = p.value;
376
+ continue;
377
+ }
378
+ if (name === 'anchor' && (p.name === 'name' || p.name === '') && head === '') {
379
+ head = p.value;
380
+ continue;
381
+ }
382
+ const attrName = name === 'jira' && p.name === 'jqlQuery' ? 'jql' : p.name;
383
+ if (!/^[A-Za-z-]+$/.test(attrName))
384
+ return null;
385
+ attrs.push(`${attrName}=${p.value}`);
386
+ }
387
+ const inner = [head, ...attrs].filter((s, i) => s !== '' || i > 0).join('|');
388
+ candidate = inner === '' ? `{{${ph}}}` : `{{${ph}:${inner}}}`;
389
+ }
390
+ if (candidate === null)
391
+ return null;
392
+ return this.verifyMacroMarker(el, candidate) ? candidate : null;
393
+ }
394
+ /** Разбирает macro-элемент на параметры и тела; null — посторонние дети. */
395
+ macroParts(el) {
396
+ const params = [];
397
+ let richBody = null;
398
+ let plainBody = null;
399
+ for (const child of elements(el.children)) {
400
+ if (child.name === 'ac:parameter') {
401
+ const pname = getAttr(child, 'ac:name') ?? '';
402
+ params.push({ name: pname, value: decodeEntities(textContent(child.children)) });
403
+ }
404
+ else if (child.name === 'ac:rich-text-body')
405
+ richBody = child;
406
+ else if (child.name === 'ac:plain-text-body')
407
+ plainBody = child;
408
+ else
409
+ return null;
410
+ }
411
+ return { params, richBody, plainBody };
412
+ }
413
+ /** Тело макроса → md; null, если не легло без потерь. */
414
+ bodyMd(richBody) {
415
+ const before = { images: new Set(this.images), files: new Set(this.files) };
416
+ try {
417
+ return this.blocksToMd(richBody.children).trimEnd();
418
+ }
419
+ catch (e) {
420
+ if (!(e instanceof Unrepresentable))
421
+ throw e;
422
+ this.images = before.images;
423
+ this.files = before.files;
424
+ return null;
425
+ }
426
+ }
293
427
  macroToMd(el) {
294
428
  const name = getAttr(el, 'ac:name') ?? '';
429
+ const nativeMd = this.tryNativeMd(el, name);
430
+ if (nativeMd !== null) {
431
+ this.stats.native++;
432
+ return nativeMd;
433
+ }
295
434
  const markerMd = this.tryMacroMarker(el, name);
296
435
  if (markerMd !== null) {
297
436
  this.stats.markers++;
package/dist/index.d.ts CHANGED
@@ -3,6 +3,7 @@ export { authHeader, loadConfigFromEnv, type ConfluenceAuthType, type Confluence
3
3
  export { Markdown } from './markdown/markdown.js';
4
4
  export { renderToStorage, extractPlaceholders, parsePlaceholder, renameImagePlaceholders, MissingAttachmentUrlError, PLACEHOLDER_RE, type AttachmentUrls, type ExtractedPlaceholders, type PlaceholderRef, type RenderStorageOptions, } from './markdown/render.js';
5
5
  export { validateMarkdown, MarkdownValidationError, type ValidateOptions } from './markdown/validate.js';
6
+ export { nativeToMarkers, nativeMacroList, NATIVE_ADMONITIONS, NATIVE_DIRECTIVES, NATIVE_PLACEHOLDERS, } from './markdown/native.js';
6
7
  export * from './macros/index.js';
7
8
  export { convertBpmn, convertBpmnFolder, isBpmnFile, bpmnOutputName, BPMN_FILE_RE, type BpmnConversion, type BpmnImageFormat, type ConvertBpmnFolderOptions, } from './bpmn/convert.js';
8
9
  export { fileSha256, HASH_TAG_PREFIX } from './attachments/hash.js';
@@ -10,6 +11,7 @@ export { Attachment, AttachmentService, toAttachmentVersion, SRC_SHA_SIDECAR_SUF
10
11
  export { Page, Table } from './pages/page.js';
11
12
  export { readTableFromConfluence, findTable, findTableInMacro, parseHtmlTable, decodeHtmlCell, renderMarkdownTable, escapeMdTableCell, readAndMapTable, type ColumnAlign, type TableColumn, } from './pages/tables.js';
12
13
  export { publishPage, computeContentHash, DEFAULT_HASH_PROPERTY_KEY, type PublishPageOptions, type PublishPageResult, type TableData, } from './publish/publish.js';
14
+ export { buildManagedNotice, applyManagedNotice, DEFAULT_MANAGED_NOTICE_TEXT, DEFAULT_MANAGED_NOTICE_LINK_TEXT, type ManagedNoticeOptions, } from './publish/notice.js';
13
15
  export { isHttpUrl, remoteFilename, isSameConfluenceOrigin, remoteRequestHeaders, downloadToFile, } from './publish/remote.js';
14
16
  export { runPublish, type Here, type Build, type PublishPlan, type RunPublishOptions, } from './publish/runner.js';
15
17
  export { parseStorage, serializeStorage, decodeEntities, StorageParseError, type XNode, type XElement, } from './export/xhtml.js';
package/dist/index.js CHANGED
@@ -5,6 +5,7 @@ export { authHeader, loadConfigFromEnv, } from './client/config.js';
5
5
  export { Markdown } from './markdown/markdown.js';
6
6
  export { renderToStorage, extractPlaceholders, parsePlaceholder, renameImagePlaceholders, MissingAttachmentUrlError, PLACEHOLDER_RE, } from './markdown/render.js';
7
7
  export { validateMarkdown, MarkdownValidationError } from './markdown/validate.js';
8
+ export { nativeToMarkers, nativeMacroList, NATIVE_ADMONITIONS, NATIVE_DIRECTIVES, NATIVE_PLACEHOLDERS, } from './markdown/native.js';
8
9
  // Macros (pluggable)
9
10
  export * from './macros/index.js';
10
11
  // BPMN (optional peer dep 'bpmn-to-image' is loaded lazily on use)
@@ -17,6 +18,7 @@ export { Page, Table } from './pages/page.js';
17
18
  export { readTableFromConfluence, findTable, findTableInMacro, parseHtmlTable, decodeHtmlCell, renderMarkdownTable, escapeMdTableCell, readAndMapTable, } from './pages/tables.js';
18
19
  // Publish
19
20
  export { publishPage, computeContentHash, DEFAULT_HASH_PROPERTY_KEY, } from './publish/publish.js';
21
+ export { buildManagedNotice, applyManagedNotice, DEFAULT_MANAGED_NOTICE_TEXT, DEFAULT_MANAGED_NOTICE_LINK_TEXT, } from './publish/notice.js';
20
22
  export { isHttpUrl, remoteFilename, isSameConfluenceOrigin, remoteRequestHeaders, downloadToFile, } from './publish/remote.js';
21
23
  export { runPublish, } from './publish/runner.js';
22
24
  // Export (storage → markdown) & round-trip
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Нативный Markdown-синтаксис для популярных макросов Confluence.
3
+ * Сахар над маркерами <!-- MACRO:start/end -->: перед рендером native-
4
+ * конструкции переписываются в маркеры (nativeToMarkers), а экспорт
5
+ * (storage → md) эмитит их обратно с канонической верификацией.
6
+ *
7
+ * Перечень (см. README, раздел «Нативные макросы»):
8
+ *
9
+ * Панели (admonition в стиле GitHub):
10
+ * > [!INFO] Заголовок → info (заголовок опционален)
11
+ * > [!NOTE] / [!WARNING] / [!TIP] → note / warning / tip
12
+ * > текст панели — обычные строки цитаты после тега.
13
+ *
14
+ * Блоки-директивы (fenced ::: … :::):
15
+ * ::: properties [id=x] [hidden=true] → details («Свойства страницы»;
16
+ * тело — обычная md-таблица) работает с отчётом detailssummary
17
+ * ::: expand Заголовок → expand (разворачиваемый блок)
18
+ * ::: panel title=… borderColor=… → panel
19
+ *
20
+ * Строчные плейсхолдеры (семейство {{img:}}/{{page:}}):
21
+ * {{toc}} / {{toc:maxLevel=3}} → toc (оглавление)
22
+ * {{children}} / {{children:depth=2}} → children (дочерние страницы)
23
+ * {{jira:DR-123}} → jira (карточка задачи)
24
+ * {{jira:jql=project = DR|maximumIssues=20}} → jira (выгрузка по JQL)
25
+ * {{status:Готово|colour=Green}} → status (лейбл)
26
+ * {{anchor:имя}} → anchor (якорь)
27
+ * {{properties-report:cql=label = "x"}} → detailssummary (отчёт по
28
+ * свойствам страниц)
29
+ *
30
+ * Внутри fenced-код-блоков (``` / ~~~) синтаксис не интерпретируется.
31
+ */
32
+ /** Панели: тег admonition → имя макроса. */
33
+ export declare const NATIVE_ADMONITIONS: Record<string, string>;
34
+ /** Блоки-директивы: имя директивы → имя макроса. */
35
+ export declare const NATIVE_DIRECTIVES: Record<string, string>;
36
+ /** Строчные плейсхолдеры: имя → имя макроса. */
37
+ export declare const NATIVE_PLACEHOLDERS: Record<string, string>;
38
+ /** Полный перечень макросов с нативной md-разметкой (для документации/UI). */
39
+ export declare function nativeMacroList(): {
40
+ macro: string;
41
+ syntax: string;
42
+ }[];
43
+ /**
44
+ * Переписывает нативные конструкции в маркеры макросов. Идемпотентна для
45
+ * текста без нативного синтаксиса; содержимое fenced-код-блоков не трогает.
46
+ */
47
+ export declare function nativeToMarkers(src: string): string;
@@ -0,0 +1,232 @@
1
+ /**
2
+ * Нативный Markdown-синтаксис для популярных макросов Confluence.
3
+ * Сахар над маркерами <!-- MACRO:start/end -->: перед рендером native-
4
+ * конструкции переписываются в маркеры (nativeToMarkers), а экспорт
5
+ * (storage → md) эмитит их обратно с канонической верификацией.
6
+ *
7
+ * Перечень (см. README, раздел «Нативные макросы»):
8
+ *
9
+ * Панели (admonition в стиле GitHub):
10
+ * > [!INFO] Заголовок → info (заголовок опционален)
11
+ * > [!NOTE] / [!WARNING] / [!TIP] → note / warning / tip
12
+ * > текст панели — обычные строки цитаты после тега.
13
+ *
14
+ * Блоки-директивы (fenced ::: … :::):
15
+ * ::: properties [id=x] [hidden=true] → details («Свойства страницы»;
16
+ * тело — обычная md-таблица) работает с отчётом detailssummary
17
+ * ::: expand Заголовок → expand (разворачиваемый блок)
18
+ * ::: panel title=… borderColor=… → panel
19
+ *
20
+ * Строчные плейсхолдеры (семейство {{img:}}/{{page:}}):
21
+ * {{toc}} / {{toc:maxLevel=3}} → toc (оглавление)
22
+ * {{children}} / {{children:depth=2}} → children (дочерние страницы)
23
+ * {{jira:DR-123}} → jira (карточка задачи)
24
+ * {{jira:jql=project = DR|maximumIssues=20}} → jira (выгрузка по JQL)
25
+ * {{status:Готово|colour=Green}} → status (лейбл)
26
+ * {{anchor:имя}} → anchor (якорь)
27
+ * {{properties-report:cql=label = "x"}} → detailssummary (отчёт по
28
+ * свойствам страниц)
29
+ *
30
+ * Внутри fenced-код-блоков (``` / ~~~) синтаксис не интерпретируется.
31
+ */
32
+ import { escapeParamValue } from '../macros/builder.js';
33
+ /** Панели: тег admonition → имя макроса. */
34
+ export const NATIVE_ADMONITIONS = {
35
+ INFO: 'info',
36
+ NOTE: 'note',
37
+ WARNING: 'warning',
38
+ TIP: 'tip',
39
+ };
40
+ /** Блоки-директивы: имя директивы → имя макроса. */
41
+ export const NATIVE_DIRECTIVES = {
42
+ properties: 'details',
43
+ expand: 'expand',
44
+ panel: 'panel',
45
+ };
46
+ /** Строчные плейсхолдеры: имя → имя макроса. */
47
+ export const NATIVE_PLACEHOLDERS = {
48
+ toc: 'toc',
49
+ children: 'children',
50
+ jira: 'jira',
51
+ status: 'status',
52
+ anchor: 'anchor',
53
+ 'properties-report': 'detailssummary',
54
+ };
55
+ /** Полный перечень макросов с нативной md-разметкой (для документации/UI). */
56
+ export function nativeMacroList() {
57
+ return [
58
+ { macro: 'info', syntax: '> [!INFO] Заголовок?' },
59
+ { macro: 'note', syntax: '> [!NOTE] Заголовок?' },
60
+ { macro: 'warning', syntax: '> [!WARNING] Заголовок?' },
61
+ { macro: 'tip', syntax: '> [!TIP] Заголовок?' },
62
+ { macro: 'details', syntax: '::: properties [id=…] [hidden=true] … :::' },
63
+ { macro: 'expand', syntax: '::: expand Заголовок … :::' },
64
+ { macro: 'panel', syntax: '::: panel title=… … :::' },
65
+ { macro: 'toc', syntax: '{{toc}} | {{toc:maxLevel=3}}' },
66
+ { macro: 'children', syntax: '{{children}} | {{children:depth=2}}' },
67
+ { macro: 'jira', syntax: '{{jira:KEY-1}} | {{jira:jql=…|maximumIssues=20}}' },
68
+ { macro: 'status', syntax: '{{status:Текст|colour=Green|subtle=true}}' },
69
+ { macro: 'anchor', syntax: '{{anchor:имя}}' },
70
+ { macro: 'detailssummary', syntax: '{{properties-report:cql=…|firstcolumn=…}}' },
71
+ ];
72
+ }
73
+ const ADMONITION_FIRST_RE = /^>\s*\[!([A-Za-z]+)\]\s*(.*)$/;
74
+ const DIRECTIVE_OPEN_RE = /^:::\s+([a-z-]+)(?:\s+(.*?))?\s*$/;
75
+ const DIRECTIVE_CLOSE_RE = /^:::\s*$/;
76
+ const PLACEHOLDER_INLINE_RE = /\{\{(toc|children|jira|status|anchor|properties-report)(?::((?:[^{}]|\{[^{])*?))?\}\}/g;
77
+ const FENCE_RE = /^\s*(`{3,}|~{3,})/;
78
+ /** Компактная пара маркеров без тела — для строчного контекста. */
79
+ function inlineMarker(macroName, params) {
80
+ const paramStr = params.length
81
+ ? ':' + params.map((p) => `${escapeParamValue(p.name)}=${escapeParamValue(p.value)}`).join(':')
82
+ : '';
83
+ return `<!-- MACRO:start:${macroName}${paramStr} --><!-- MACRO:end:${macroName} -->`;
84
+ }
85
+ function blockMarker(macroName, params, body) {
86
+ const paramStr = params.length
87
+ ? ':' + params.map((p) => `${escapeParamValue(p.name)}=${escapeParamValue(p.value)}`).join(':')
88
+ : '';
89
+ return `<!-- MACRO:start:${macroName}${paramStr} -->\n${body}\n<!-- MACRO:end:${macroName} -->`;
90
+ }
91
+ /** `a|b=c|d=e` → первый сегмент + пары; поведение как у parsePlaceholder. */
92
+ function splitAttrs(raw) {
93
+ const parts = raw.split('|');
94
+ const attrs = [];
95
+ for (const part of parts.slice(1)) {
96
+ const eq = part.indexOf('=');
97
+ if (eq === -1)
98
+ attrs.push({ name: part.trim(), value: '' });
99
+ else
100
+ attrs.push({ name: part.slice(0, eq).trim(), value: part.slice(eq + 1).trim() });
101
+ }
102
+ return { head: parts[0].trim(), attrs };
103
+ }
104
+ /** Параметры плейсхолдера → параметры макроса Confluence. */
105
+ function placeholderParams(kind, raw) {
106
+ if (raw === undefined || raw.trim() === '')
107
+ return [];
108
+ const { head, attrs } = splitAttrs(raw);
109
+ const params = [];
110
+ const headIsPair = head.includes('=');
111
+ if (headIsPair) {
112
+ const eq = head.indexOf('=');
113
+ attrs.unshift({ name: head.slice(0, eq).trim(), value: head.slice(eq + 1).trim() });
114
+ }
115
+ switch (kind) {
116
+ case 'jira':
117
+ if (!headIsPair && head !== '')
118
+ params.push({ name: 'key', value: head });
119
+ break;
120
+ case 'status':
121
+ if (!headIsPair && head !== '')
122
+ params.push({ name: 'title', value: head });
123
+ break;
124
+ case 'anchor':
125
+ if (!headIsPair && head !== '')
126
+ params.push({ name: 'name', value: head });
127
+ break;
128
+ default:
129
+ // toc/children/properties-report: только k=v-атрибуты.
130
+ if (!headIsPair && head !== '')
131
+ params.push({ name: head, value: '' });
132
+ }
133
+ for (const a of attrs) {
134
+ // jira: короткое `jql=` — алиас родного jqlQuery.
135
+ if (kind === 'jira' && a.name === 'jql')
136
+ params.push({ name: 'jqlQuery', value: a.value });
137
+ else
138
+ params.push(a);
139
+ }
140
+ return params;
141
+ }
142
+ function replaceInline(line) {
143
+ return line.replace(PLACEHOLDER_INLINE_RE, (_full, kind, raw) => inlineMarker(NATIVE_PLACEHOLDERS[kind], placeholderParams(kind, raw)));
144
+ }
145
+ /** Параметры директивы: токены `k=v` (значение можно в кавычках) + свободный текст → title. */
146
+ function directiveParams(name, rest) {
147
+ const params = [];
148
+ if (!rest)
149
+ return params;
150
+ const free = [];
151
+ const re = /([A-Za-z-]+)=("([^"]*)"|\S+)|(\S+)/g;
152
+ for (const m of rest.matchAll(re)) {
153
+ if (m[1])
154
+ params.push({ name: m[1], value: m[3] ?? m[2] });
155
+ else
156
+ free.push(m[4]);
157
+ }
158
+ if (free.length && name === 'expand')
159
+ params.unshift({ name: 'title', value: free.join(' ') });
160
+ return params;
161
+ }
162
+ /**
163
+ * Переписывает нативные конструкции в маркеры макросов. Идемпотентна для
164
+ * текста без нативного синтаксиса; содержимое fenced-код-блоков не трогает.
165
+ */
166
+ export function nativeToMarkers(src) {
167
+ const lines = src.split('\n');
168
+ const out = [];
169
+ let fence = null;
170
+ for (let i = 0; i < lines.length; i++) {
171
+ const line = lines[i];
172
+ const fm = FENCE_RE.exec(line);
173
+ if (fm) {
174
+ if (fence === null)
175
+ fence = fm[1][0];
176
+ else if (fm[1][0] === fence)
177
+ fence = null;
178
+ out.push(line);
179
+ continue;
180
+ }
181
+ if (fence !== null) {
182
+ out.push(line);
183
+ continue;
184
+ }
185
+ // ── Панели: > [!TYPE] Заголовок? ─────────────────────────────────────
186
+ const am = ADMONITION_FIRST_RE.exec(line);
187
+ const macroName = am ? NATIVE_ADMONITIONS[am[1].toUpperCase()] : undefined;
188
+ if (am && macroName) {
189
+ const body = [];
190
+ let j = i + 1;
191
+ for (; j < lines.length && /^>( |$)/.test(lines[j]); j++) {
192
+ body.push(lines[j].replace(/^> ?/, ''));
193
+ }
194
+ const params = am[2].trim() ? [{ name: 'title', value: am[2].trim() }] : [];
195
+ out.push(blockMarker(macroName, params, nativeToMarkers(body.join('\n'))));
196
+ i = j - 1;
197
+ continue;
198
+ }
199
+ // ── Директивы: ::: name … / ::: ──────────────────────────────────────
200
+ const dm = DIRECTIVE_OPEN_RE.exec(line);
201
+ const dirMacro = dm ? NATIVE_DIRECTIVES[dm[1]] : undefined;
202
+ if (dm && dirMacro) {
203
+ const body = [];
204
+ let j = i + 1;
205
+ let innerFence = null;
206
+ let closed = false;
207
+ for (; j < lines.length; j++) {
208
+ const bl = lines[j];
209
+ const bfm = FENCE_RE.exec(bl);
210
+ if (bfm) {
211
+ if (innerFence === null)
212
+ innerFence = bfm[1][0];
213
+ else if (bfm[1][0] === innerFence)
214
+ innerFence = null;
215
+ }
216
+ else if (innerFence === null && DIRECTIVE_CLOSE_RE.test(bl)) {
217
+ closed = true;
218
+ break;
219
+ }
220
+ body.push(bl);
221
+ }
222
+ if (closed) {
223
+ out.push(blockMarker(dirMacro, directiveParams(dm[1], dm[2]), nativeToMarkers(body.join('\n'))));
224
+ i = j;
225
+ continue;
226
+ }
227
+ // Незакрытая директива — оставляем как текст.
228
+ }
229
+ out.push(replaceInline(line));
230
+ }
231
+ return out.join('\n');
232
+ }
@@ -1,4 +1,5 @@
1
1
  import MarkdownIt from 'markdown-it';
2
+ import { nativeToMarkers } from './native.js';
2
3
  // xhtmlOut: true — Confluence storage format = XHTML, void-элементы
3
4
  // (<hr/>, <br/>, <img/>) обязаны быть самозакрывающимися.
4
5
  // html: true — разрешить HTML (нужно для <!-- MACRO:... --> комментариев)
@@ -106,6 +107,9 @@ export class MissingAttachmentUrlError extends Error {
106
107
  * посимвольно.
107
108
  */
108
109
  export function renderToStorage(markdown, urls, opts = {}) {
110
+ // Нативный md-синтаксис макросов (панели, ::: properties, {{toc}} …) —
111
+ // сахар над маркерами: переписываем до markdown-it (см. native.ts).
112
+ markdown = nativeToMarkers(markdown);
109
113
  const renderer = opts.linkify === false ? mdNoLinkify : md;
110
114
  let html = renderer.render(markdown);
111
115
  // ```confluence-storage — транспорт для дословного XHTML (ac:/ri:-теги
@@ -0,0 +1,34 @@
1
+ /**
2
+ * «Баннер управляемой страницы»: примечание о том, что страница ведётся во
3
+ * внешнем источнике (docs-studio), а правки в самой Confluence будут
4
+ * перезаписаны. Вставляется в storage при публикации, но НЕ в markdown-
5
+ * источник — round-trip/экспорт его не увидят, git остаётся чистым.
6
+ */
7
+ export interface ManagedNoticeOptions {
8
+ /**
9
+ * URL источника (docs-studio / репозиторий постановки), куда ведёт ссылка.
10
+ * Обязателен — без него примечание не имеет смысла.
11
+ */
12
+ linkUrl: string;
13
+ /** Текст ссылки. Default: {@link DEFAULT_MANAGED_NOTICE_LINK_TEXT}. */
14
+ linkText?: string;
15
+ /**
16
+ * Текст примечания. Плейсхолдер `{link}` заменяется ссылкой; если его в
17
+ * тексте нет, ссылка добавляется в конец. Default:
18
+ * {@link DEFAULT_MANAGED_NOTICE_TEXT}.
19
+ */
20
+ text?: string;
21
+ /** Куда вставлять баннер: 'top' (шапка) или 'bottom' (низ). Default: 'top'. */
22
+ position?: 'top' | 'bottom';
23
+ /**
24
+ * Тип панели Confluence (влияет на цвет/иконку). Default: 'info'.
25
+ * 'warning' — красная, самый заметный вариант для «не редактировать».
26
+ */
27
+ panel?: 'info' | 'note' | 'warning' | 'tip';
28
+ }
29
+ export declare const DEFAULT_MANAGED_NOTICE_TEXT: string;
30
+ export declare const DEFAULT_MANAGED_NOTICE_LINK_TEXT = "docs-studio";
31
+ /** Строит storage-разметку баннера (одиночный `<ac:structured-macro>`). */
32
+ export declare function buildManagedNotice(opts: ManagedNoticeOptions): string;
33
+ /** Дописывает баннер в начало (top) или конец (bottom) storage-контента. */
34
+ export declare function applyManagedNotice(storage: string, opts: ManagedNoticeOptions): string;
@@ -0,0 +1,37 @@
1
+ /**
2
+ * «Баннер управляемой страницы»: примечание о том, что страница ведётся во
3
+ * внешнем источнике (docs-studio), а правки в самой Confluence будут
4
+ * перезаписаны. Вставляется в storage при публикации, но НЕ в markdown-
5
+ * источник — round-trip/экспорт его не увидят, git остаётся чистым.
6
+ */
7
+ import { structuredMacro } from '../macros/xml.js';
8
+ import { escapeXmlAttr } from '../macros/xml.js';
9
+ export const DEFAULT_MANAGED_NOTICE_TEXT = 'Страница синхронизируется автоматически. Вносите правки в источнике ({link}) — ' +
10
+ 'ручные изменения на этой странице будут перезаписаны при следующей публикации.';
11
+ export const DEFAULT_MANAGED_NOTICE_LINK_TEXT = 'docs-studio';
12
+ // Фиксированный ac:macro-id: баннер обязан давать БАЙТ-В-БАЙТ одинаковый
13
+ // storage при каждой публикации. Со случайным id (generateMacroId) content-
14
+ // hash менялся бы каждый раз → страница вечно считалась бы изменённой.
15
+ const NOTICE_MACRO_ID = '0f0e0d0c-0b0a-4009-8008-000000000001';
16
+ /** Собирает `<a href>`-ссылку на источник. */
17
+ function noticeLink(opts) {
18
+ const text = opts.linkText ?? DEFAULT_MANAGED_NOTICE_LINK_TEXT;
19
+ return `<a href="${escapeXmlAttr(opts.linkUrl)}">${escapeXmlAttr(text)}</a>`;
20
+ }
21
+ /** Строит storage-разметку баннера (одиночный `<ac:structured-macro>`). */
22
+ export function buildManagedNotice(opts) {
23
+ if (!opts.linkUrl)
24
+ throw new Error('managedNotice: linkUrl is required');
25
+ const panel = opts.panel ?? 'info';
26
+ const link = noticeLink(opts);
27
+ const text = opts.text ?? DEFAULT_MANAGED_NOTICE_TEXT;
28
+ const inner = text.includes('{link}')
29
+ ? text.split('{link}').map(escapeXmlAttr).join(link)
30
+ : `${escapeXmlAttr(text)} ${link}`;
31
+ return structuredMacro(panel, NOTICE_MACRO_ID, { richBody: `<p>${inner}</p>` });
32
+ }
33
+ /** Дописывает баннер в начало (top) или конец (bottom) storage-контента. */
34
+ export function applyManagedNotice(storage, opts) {
35
+ const notice = buildManagedNotice(opts);
36
+ return (opts.position ?? 'top') === 'bottom' ? `${storage}${notice}` : `${notice}${storage}`;
37
+ }
@@ -1,5 +1,6 @@
1
1
  import type { ConfluenceConfig } from '../client/config.js';
2
2
  import { type RenderStorageOptions } from '../markdown/render.js';
3
+ import { type ManagedNoticeOptions } from './notice.js';
3
4
  import type { MacroRegistry } from '../macros/registry.js';
4
5
  import { Markdown } from '../markdown/markdown.js';
5
6
  export interface TableData {
@@ -42,6 +43,13 @@ export interface PublishPageOptions {
42
43
  labels?: string[];
43
44
  /** Комментарий к версии страницы. */
44
45
  versionMessage?: string;
46
+ /**
47
+ * Примечание «страница управляется извне» (docs-studio). Вставляется в
48
+ * storage при публикации, НО не в markdown-источник — экспорт/round-trip
49
+ * его не увидят. Наличие опции включает баннер; настраиваются текст, ссылка,
50
+ * её текст, тип панели и позиция (шапка/низ). См. {@link ManagedNoticeOptions}.
51
+ */
52
+ managedNotice?: ManagedNoticeOptions;
45
53
  /** Свой реестр макросов (default: встроенные core + table-filter). */
46
54
  registry?: MacroRegistry;
47
55
  /**
@@ -8,6 +8,7 @@ import { bpmnOutputName, convertBpmn, isBpmnFile } from '../bpmn/convert.js';
8
8
  import { downloadToFile, isHttpUrl, remoteFilename } from './remote.js';
9
9
  import { renameImagePlaceholders, renderToStorage, } from '../markdown/render.js';
10
10
  import { validateMarkdown } from '../markdown/validate.js';
11
+ import { applyManagedNotice } from './notice.js';
11
12
  import { processMacros } from '../macros/registry.js';
12
13
  import { defaultMacroRegistry } from '../macros/index.js';
13
14
  import { Markdown } from '../markdown/markdown.js';
@@ -85,6 +86,10 @@ export async function publishPage(opts, cfg) {
85
86
  const tables = opts.tables ?? [];
86
87
  const registry = opts.registry ?? defaultMacroRegistry;
87
88
  const hashKey = opts.hashPropertyKey ?? DEFAULT_HASH_PROPERTY_KEY;
89
+ // Fail fast: баннер без ссылки бессмыслен — падаем до любого сетевого I/O.
90
+ if (opts.managedNotice && !opts.managedNotice.linkUrl) {
91
+ throw new Error('publishPage: managedNotice.linkUrl is required');
92
+ }
88
93
  // 0a. Удалённые источники: http(s)://-элементы в images[]/files[]
89
94
  // заменяются на локальный путь в downloadDir с именем из URL — дальше
90
95
  // конвейер (валидация, BPMN, аплоад) работает с обычными путями.
@@ -195,6 +200,13 @@ export async function publishPage(opts, cfg) {
195
200
  let storage = renderToStorage(markdown, urls, opts.render ?? {});
196
201
  // 2.5. Преобразование маркеров макросов в XHTML.
197
202
  storage = processMacros(storage, registry).toString();
203
+ // 2.6. Баннер «страница управляется извне». Вставляется здесь, в storage —
204
+ // не в markdown — поэтому источник/round-trip его не содержат. Баннер
205
+ // детерминирован (фиксированный macro-id), значит попадает в content-hash
206
+ // стабильно и не ломает идемпотентность.
207
+ if (opts.managedNotice) {
208
+ storage = applyManagedNotice(storage, opts.managedNotice);
209
+ }
198
210
  if (opts.dryRun) {
199
211
  console.log(`[publish] dry-run: page ${pageId} rendered OK (${storage.length} bytes of storage)`);
200
212
  return {
@@ -1,4 +1,5 @@
1
1
  import { type PublishPageOptions, type PublishPageResult } from './publish.js';
2
+ import type { ManagedNoticeOptions } from './notice.js';
2
3
  import { type ConfluenceConfig, type LoadConfigOptions } from '../client/config.js';
3
4
  export type Here = (relativePath: string) => string;
4
5
  export type Build = (relativePath: string) => string;
@@ -14,6 +15,12 @@ export interface RunPublishOptions {
14
15
  * соответствует компоновке репо `<repo>/docs/<set>` + `<repo>/build/<set>`.
15
16
  */
16
17
  buildDir?: string;
18
+ /**
19
+ * Баннер «страница управляется извне» по умолчанию для ВСЕХ страниц прогона
20
+ * (docs-studio). Значение, заданное у конкретной страницы в `managedNotice`,
21
+ * имеет приоритет. См. {@link ManagedNoticeOptions}.
22
+ */
23
+ managedNotice?: ManagedNoticeOptions;
17
24
  }
18
25
  /**
19
26
  * Минимальная точка входа для публикующего скрипта (`docs/<set>/publish.ts`).
@@ -29,7 +29,11 @@ export async function runPublish(baseDir, plan, opts = {}) {
29
29
  const pages = typeof plan === 'function' ? await plan(here, build) : plan;
30
30
  const results = [];
31
31
  for (const page of pages) {
32
- results.push(await publishPage(page, cfg));
32
+ // Дефолтный баннер прогона применяется, только если страница его не задала.
33
+ const merged = opts.managedNotice && page.managedNotice === undefined
34
+ ? { ...page, managedNotice: opts.managedNotice }
35
+ : page;
36
+ results.push(await publishPage(merged, cfg));
33
37
  }
34
38
  return results;
35
39
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "confluence-md-sync",
3
- "version": "0.6.0",
3
+ "version": "0.8.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",