confluence-md-sync 0.5.2 → 0.7.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
@@ -18,9 +18,16 @@ npm install -g confluence-md-sync # as a CLI: `confluence-md-sync …`
18
18
 
19
19
  - **No history spam** — rendered content is SHA-256-hashed into a content
20
20
  property; identical re-publish skips the update, page version doesn't grow.
21
+ - **Drift-proof** — the content property also stores the page version we last
22
+ wrote. If someone edits or wipes the page in Confluence, the version diverges
23
+ and the next publish detects the drift and restores the page from markdown
24
+ (git stays the source of truth) instead of trusting the stale hash.
21
25
  - **Attachment dedup** — uploads are tagged `sha256:<hash>`; unchanged files
22
26
  are reused. `<file>.src-sha256` sidecars pin dedup to the *source* of
23
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*.
24
31
  - **Fail before write** — placeholders, files and macro markers are validated
25
32
  up front; Confluence is never touched on a broken input.
26
33
  - **Pluggable macros** — built-in `core` + `table-filter` plugins, extend
@@ -117,6 +124,34 @@ await publishPage({
117
124
  }, cfg);
118
125
  ```
119
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
+
120
155
  ## BPMN diagrams out of the box
121
156
 
122
157
  Pass a `.bpmn` file as an image (a path or an `http(s)` URL) — it is
@@ -73,12 +73,13 @@ export declare class ConfluenceClient {
73
73
  /** Finds a page by space key and exact title. Returns null if not found. */
74
74
  getPageByTitle(spaceKey: string, title: string): Promise<ConfluencePage | null>;
75
75
  createPage(opts: CreatePageOptions): Promise<ConfluencePage>;
76
+ /** Обновляет страницу и возвращает номер версии из ответа Confluence. */
76
77
  updatePage(pageId: string, body: {
77
78
  title: string;
78
79
  version: number;
79
80
  storage: string;
80
81
  versionMessage?: string;
81
- }): Promise<void>;
82
+ }): Promise<number>;
82
83
  deletePage(pageId: string): Promise<void>;
83
84
  /** Direct child pages of a page. */
84
85
  getChildPages(pageId: string, limit?: number): Promise<ConfluencePage[]>;
@@ -99,6 +99,7 @@ export class ConfluenceClient {
99
99
  await this.parseError(res, `createPage(${opts.spaceKey}/${opts.title})`);
100
100
  return (await res.json());
101
101
  }
102
+ /** Обновляет страницу и возвращает номер версии из ответа Confluence. */
102
103
  async updatePage(pageId, body) {
103
104
  const version = { number: body.version };
104
105
  if (body.versionMessage)
@@ -118,6 +119,10 @@ export class ConfluenceClient {
118
119
  });
119
120
  if (!res.ok)
120
121
  await this.parseError(res, `updatePage(${pageId})`);
122
+ // Confluence возвращает обновлённый content с актуальным version.number.
123
+ // Если тела/поля нет — падаем обратно на посланную версию.
124
+ const data = (await res.json().catch(() => null));
125
+ return data?.version?.number ?? body.version;
121
126
  }
122
127
  async deletePage(pageId) {
123
128
  const res = await fetch(this.url(`/rest/api/content/${pageId}`), {
package/dist/index.d.ts CHANGED
@@ -10,6 +10,7 @@ export { Attachment, AttachmentService, toAttachmentVersion, SRC_SHA_SIDECAR_SUF
10
10
  export { Page, Table } from './pages/page.js';
11
11
  export { readTableFromConfluence, findTable, findTableInMacro, parseHtmlTable, decodeHtmlCell, renderMarkdownTable, escapeMdTableCell, readAndMapTable, type ColumnAlign, type TableColumn, } from './pages/tables.js';
12
12
  export { publishPage, computeContentHash, DEFAULT_HASH_PROPERTY_KEY, type PublishPageOptions, type PublishPageResult, type TableData, } from './publish/publish.js';
13
+ export { buildManagedNotice, applyManagedNotice, DEFAULT_MANAGED_NOTICE_TEXT, DEFAULT_MANAGED_NOTICE_LINK_TEXT, type ManagedNoticeOptions, } from './publish/notice.js';
13
14
  export { isHttpUrl, remoteFilename, isSameConfluenceOrigin, remoteRequestHeaders, downloadToFile, } from './publish/remote.js';
14
15
  export { runPublish, type Here, type Build, type PublishPlan, type RunPublishOptions, } from './publish/runner.js';
15
16
  export { parseStorage, serializeStorage, decodeEntities, StorageParseError, type XNode, type XElement, } from './export/xhtml.js';
package/dist/index.js CHANGED
@@ -17,6 +17,7 @@ export { Page, Table } from './pages/page.js';
17
17
  export { readTableFromConfluence, findTable, findTableInMacro, parseHtmlTable, decodeHtmlCell, renderMarkdownTable, escapeMdTableCell, readAndMapTable, } from './pages/tables.js';
18
18
  // Publish
19
19
  export { publishPage, computeContentHash, DEFAULT_HASH_PROPERTY_KEY, } from './publish/publish.js';
20
+ export { buildManagedNotice, applyManagedNotice, DEFAULT_MANAGED_NOTICE_TEXT, DEFAULT_MANAGED_NOTICE_LINK_TEXT, } from './publish/notice.js';
20
21
  export { isHttpUrl, remoteFilename, isSameConfluenceOrigin, remoteRequestHeaders, downloadToFile, } from './publish/remote.js';
21
22
  export { runPublish, } from './publish/runner.js';
22
23
  // Export (storage → markdown) & round-trip
@@ -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';
@@ -28,15 +29,19 @@ export function computeContentHash(storage) {
28
29
  const canonical = storage.replace(/(\/download\/attachments\/[^"?\s]+)\?[^"\s]*/g, '$1');
29
30
  return createHash('sha256').update(canonical, 'utf-8').digest('hex');
30
31
  }
31
- // Схема записи download-URL в body. Схема 1 подставляла URL из
32
+ // Схема content property. Схема 1 подставляла в body download-URL из
32
33
  // _links.download как есть — с ?version=N&modificationDate=…; при ребампе
33
34
  // аттача без изменения текста страница оставалась UNCHANGED и продолжала
34
35
  // отдавать старую, пиненную версию картинки. Схема 2 подставляет
35
36
  // канонический URL без query — Confluence по нему отдаёт последнюю версию
36
- // аттача, и обновление диаграммы видно без переписывания body. Property со
37
- // схемой текущей т.ч. без поля scheme) считается устаревшей — страница
38
- // один раз переписывается каноническими URL.
39
- const HASH_SCHEME = 2;
37
+ // аттача, и обновление диаграммы видно без переписывания body. Схема 3
38
+ // добавляет pageVersion номер версии страницы после нашей последней
39
+ // записи; по нему детектится «дрейф» (страницу правили мимо публикатора:
40
+ // version.number вырос, а hash-свойство осталось прежним → без этой проверки
41
+ // испорченная страница никогда бы не восстановилась из markdown). Property со
42
+ // схемой ≠ текущей (в т.ч. без поля scheme/pageVersion) считается устаревшей —
43
+ // hash не сверяется, страница один раз переписывается в новом формате.
44
+ const HASH_SCHEME = 3;
40
45
  /** Канонический download-URL аттача: без query (?version=N&…). */
41
46
  function canonicalDownloadUrl(url) {
42
47
  return url.split('?')[0];
@@ -81,6 +86,10 @@ export async function publishPage(opts, cfg) {
81
86
  const tables = opts.tables ?? [];
82
87
  const registry = opts.registry ?? defaultMacroRegistry;
83
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
+ }
84
93
  // 0a. Удалённые источники: http(s)://-элементы в images[]/files[]
85
94
  // заменяются на локальный путь в downloadDir с именем из URL — дальше
86
95
  // конвейер (валидация, BPMN, аплоад) работает с обычными путями.
@@ -191,6 +200,13 @@ export async function publishPage(opts, cfg) {
191
200
  let storage = renderToStorage(markdown, urls, opts.render ?? {});
192
201
  // 2.5. Преобразование маркеров макросов в XHTML.
193
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
+ }
194
210
  if (opts.dryRun) {
195
211
  console.log(`[publish] dry-run: page ${pageId} rendered OK (${storage.length} bytes of storage)`);
196
212
  return {
@@ -203,42 +219,71 @@ export async function publishPage(opts, cfg) {
203
219
  storage,
204
220
  };
205
221
  }
206
- // 3. Content-hash check. Hash хранится в content property, не в body.
222
+ // 3. Решение о публикации. Hash и версия нашей последней записи хранятся в
223
+ // content property (не в body — normalize storage её бы съел). Страница
224
+ // и её версия так и так читаются перед сравнением. Причина публикации
225
+ // (для диагностики инцидентов пишется в лог):
226
+ // no-property — свойства нет (мы эту страницу ещё не публиковали);
227
+ // hash-mismatch — контент изменился (или свойство устаревшей схемы);
228
+ // title-change — сменился заголовок при том же контенте;
229
+ // drift — страницу правили мимо публикатора (version.number
230
+ // разошёлся с записанным нами) → чиним из markdown.
207
231
  const newHash = computeContentHash(storage);
208
232
  const [existing, hashProp] = await Promise.all([
209
233
  client.getPageStorage(pageId),
210
234
  client.getContentProperty(pageId, hashKey),
211
235
  ]);
212
236
  const title = opts.title ?? existing.title;
213
- // Property, писанная другой схемой (или до появления scheme), не считается
214
- // совпадением: body мог быть записан с пином версий аттачей его нужно
215
- // один раз переписать каноническими URL.
237
+ // Только свойство текущей схемы несёт доверенные hash + pageVersion.
238
+ // Устаревшая схема (или без scheme) «версия неизвестна»: hash не сверяем
239
+ // (existingHash = null одна принудительная публикация с записью нового
240
+ // формата; она же самовосстанавливает ранее испорченные страницы).
216
241
  const propValue = hashProp && typeof hashProp.value === 'object' && hashProp.value !== null
217
242
  ? hashProp.value
218
243
  : null;
219
- const existingHash = propValue && propValue.scheme === HASH_SCHEME
244
+ const isCurrentScheme = propValue !== null && propValue.scheme === HASH_SCHEME;
245
+ const existingHash = isCurrentScheme
220
246
  ? (propValue.hash ?? null)
221
247
  : null;
222
- if (existingHash === newHash && title === existing.title) {
248
+ const publishedVersion = isCurrentScheme && typeof propValue.pageVersion === 'number'
249
+ ? propValue.pageVersion
250
+ : null;
251
+ let reason;
252
+ if (propValue === null)
253
+ reason = 'no-property';
254
+ else if (existingHash !== newHash)
255
+ reason = 'hash-mismatch';
256
+ else if (title !== existing.title)
257
+ reason = 'title-change';
258
+ else if (publishedVersion !== existing.version)
259
+ reason = 'drift';
260
+ else
261
+ reason = null;
262
+ if (reason === null) {
223
263
  console.log(`[publish] ${pageId} "${title}" → UNCHANGED (hash ${newHash.slice(0, 12)}, v${existing.version})`);
224
264
  if (opts.labels?.length)
225
265
  await client.addLabels(pageId, opts.labels);
226
266
  return { pageId, title, version: existing.version, attachments, updated: false, created, storage };
227
267
  }
228
268
  // 4. Обновление страницы — только после успешного аплоада всех аттачей.
229
- const nextVersion = existing.version + 1;
230
- await client.updatePage(pageId, {
269
+ // Версию берём из ответа Confluence (update инкрементирует её сам).
270
+ const nextVersion = await client.updatePage(pageId, {
231
271
  title,
232
- version: nextVersion,
272
+ version: existing.version + 1,
233
273
  storage,
234
274
  versionMessage: opts.versionMessage,
235
275
  });
236
- // 5. Запись/обновление content property с новым hash. Делаем ПОСЛЕ
237
- // updatePage чтобы при сбое publish hash не «опередил» реальное содержимое.
238
- await client.setContentProperty(pageId, hashKey, { hash: newHash, scheme: HASH_SCHEME }, hashProp ? hashProp.version : null);
276
+ // 5. Запись/обновление content property: hash + версия, которая ПОЛУЧИЛАСЬ
277
+ // после нашей записи. Делаем ПОСЛЕ updatePage, чтобы при сбое publish
278
+ // свойство не «опередило» реальное содержимое. pageVersion закрывает
279
+ // слепую зону дрейфа (см. HASH_SCHEME).
280
+ await client.setContentProperty(pageId, hashKey, { hash: newHash, scheme: HASH_SCHEME, pageVersion: nextVersion }, hashProp ? hashProp.version : null);
239
281
  if (opts.labels?.length)
240
282
  await client.addLabels(pageId, opts.labels);
241
- console.log(`[publish] ${pageId} "${title}" v${nextVersion} (hash ${newHash.slice(0, 12)})`);
283
+ const detail = reason === 'drift'
284
+ ? `DRIFT (page v${existing.version} != published v${publishedVersion})`
285
+ : reason;
286
+ console.log(`[publish] ${pageId} "${title}" → ${detail} → v${nextVersion} (hash ${newHash.slice(0, 12)})`);
242
287
  return { pageId, title, version: nextVersion, attachments, updated: true, created, storage };
243
288
  }
244
289
  function escapeRegex(s) {
@@ -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.5.2",
3
+ "version": "0.7.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",