confluence-md-sync 0.8.7 → 0.8.9

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.
@@ -1,6 +1,6 @@
1
1
  import { Markdown } from '../markdown/markdown.js';
2
2
  import { unescapeParamValue } from './builder.js';
3
- import { generateMacroId, structuredMacro } from './xml.js';
3
+ import { stableMacroId, structuredMacro } from './xml.js';
4
4
  /**
5
5
  * Реестр макросов. Пустой при создании — наполняется плагинами через
6
6
  * {@link use} или точечной регистрацией через {@link register}.
@@ -126,7 +126,9 @@ function processMacroType(storage, macroName, renderer) {
126
126
  }
127
127
  const bodyStart = startIdx + match[0].length;
128
128
  const body = storage.substring(bodyStart, endIdx).trim();
129
- const macroId = generateMacroId();
129
+ // Стабильный id от содержимого маркера — иначе он менялся бы на КАЖДЫЙ
130
+ // рендер (см. stableMacroId), ломая content-hash идемпотентность publish.
131
+ const macroId = stableMacroId(`${macroName}\u0000${paramStr}\u0000${body}`);
130
132
  const macroXhtml = renderer({ params, body, macroId });
131
133
  const before = storage.substring(0, startIdx);
132
134
  const after = storage.substring(endIdx + endMarker.length);
@@ -2,8 +2,21 @@
2
2
  import type { MacroParam } from './types.js';
3
3
  /** Экранирует строку для XML-атрибутов и текстовых узлов. */
4
4
  export declare function escapeXmlAttr(s: string): string;
5
- /** Генерирует UUID для ac:macro-id. */
5
+ /** Генерирует случайный UUID для ac:macro-id (когда стабильность не нужна). */
6
6
  export declare function generateMacroId(): string;
7
+ /**
8
+ * Детерминированный macro-id (UUID-формы) от seed — как правило, от исходного
9
+ * содержимого маркера (имя+параметры+тело). ВАЖНО для идемпотентности
10
+ * content-hash: если id генерировать случайно на каждый рендер (см.
11
+ * {@link generateMacroId}), storage у страницы с любым макросом отличается
12
+ * при каждой публикации, даже когда исходный markdown не менялся — hash
13
+ * никогда не совпадает, и publish принудительно обновляет страницу заново
14
+ * при каждом прогоне (замечено на проде: 1 изменённый файл → 250+ страниц
15
+ * «обновились», потому что ~250 из них содержат макрос details/properties).
16
+ * Стабильный id по содержимому убирает этот псевдо-дрейф: одинаковый маркер
17
+ * даёт одинаковый storage → hash совпадает → UNCHANGED.
18
+ */
19
+ export declare function stableMacroId(seed: string): string;
7
20
  export interface StructuredMacroOptions {
8
21
  /**
9
22
  * Macro parameters. `undefined` values are skipped. A key rendered as
@@ -1,4 +1,5 @@
1
1
  /** XML helpers shared by macro renderers. */
2
+ import { createHash } from 'node:crypto';
2
3
  /** Экранирует строку для XML-атрибутов и текстовых узлов. */
3
4
  export function escapeXmlAttr(s) {
4
5
  return s.replace(/[<>&"']/g, (c) => {
@@ -12,7 +13,7 @@ export function escapeXmlAttr(s) {
12
13
  }
13
14
  });
14
15
  }
15
- /** Генерирует UUID для ac:macro-id. */
16
+ /** Генерирует случайный UUID для ac:macro-id (когда стабильность не нужна). */
16
17
  export function generateMacroId() {
17
18
  return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
18
19
  const r = (Math.random() * 16) | 0;
@@ -20,6 +21,24 @@ export function generateMacroId() {
20
21
  return v.toString(16);
21
22
  });
22
23
  }
24
+ /**
25
+ * Детерминированный macro-id (UUID-формы) от seed — как правило, от исходного
26
+ * содержимого маркера (имя+параметры+тело). ВАЖНО для идемпотентности
27
+ * content-hash: если id генерировать случайно на каждый рендер (см.
28
+ * {@link generateMacroId}), storage у страницы с любым макросом отличается
29
+ * при каждой публикации, даже когда исходный markdown не менялся — hash
30
+ * никогда не совпадает, и publish принудительно обновляет страницу заново
31
+ * при каждом прогоне (замечено на проде: 1 изменённый файл → 250+ страниц
32
+ * «обновились», потому что ~250 из них содержат макрос details/properties).
33
+ * Стабильный id по содержимому убирает этот псевдо-дрейф: одинаковый маркер
34
+ * даёт одинаковый storage → hash совпадает → UNCHANGED.
35
+ */
36
+ export function stableMacroId(seed) {
37
+ const hex = createHash('sha256').update(seed, 'utf-8').digest('hex');
38
+ const bytes = hex.slice(0, 32);
39
+ return (`${bytes.slice(0, 8)}-${bytes.slice(8, 12)}-4${bytes.slice(13, 16)}-` +
40
+ `${'89ab'[parseInt(bytes[16], 16) % 4]}${bytes.slice(17, 20)}-${bytes.slice(20, 32)}`);
41
+ }
23
42
  /**
24
43
  * Assembles an `<ac:structured-macro>` element. Takes care of parameter
25
44
  * escaping, rich vs plain bodies and CDATA safety.
@@ -43,6 +43,18 @@ export interface RenderStorageOptions {
43
43
  fileStyle?: 'url' | 'attachment';
44
44
  /** Автопревращение голых URL в ссылки (default: true). */
45
45
  linkify?: boolean;
46
+ /**
47
+ * Резолвер относительных ссылок на другие md-файлы набора. Для каждой
48
+ * markdown-ссылки `[text](href)` вызывается с исходным href; если вернул
49
+ * цель — ссылка публикуется как Confluence page-link (`<ac:link><ri:page>`)
50
+ * на страницу связанного md (карту file→page строит вызывающий, напр.
51
+ * docs-studio из манифеста). `null`/`undefined` — ссылка остаётся как есть
52
+ * (обычная `<a href>`), пригодна для внешних URL и якорей.
53
+ */
54
+ linkResolver?: (href: string) => {
55
+ title: string;
56
+ space?: string;
57
+ } | null;
46
58
  }
47
59
  export declare class MissingAttachmentUrlError extends Error {
48
60
  constructor(message: string);
@@ -116,6 +116,28 @@ export function renderToStorage(markdown, urls, opts = {}) {
116
116
  // markdown-it сквозь себя не пропускает). Fence рендерится в экранированный
117
117
  // <pre><code>; здесь разэкранируем содержимое обратно в живую разметку.
118
118
  html = html.replace(/<pre><code class="language-confluence-storage">([\s\S]*?)<\/code><\/pre>\n?/g, (_full, escaped) => unescapeHtml(escaped.replace(/\n$/, '')) + '\n');
119
+ // Ссылки на связанные md → Confluence page-link по резолверу (карта из
120
+ // манифеста). Только markdown-ссылки (`<a href>` из markdown-it); плейсхолдеры
121
+ // {{file:}}/{{img:}} рендерятся ниже и сюда ещё не попали. Внешние URL/якоря
122
+ // резолвер отсекает (возвращает null) — они остаются как есть.
123
+ if (opts.linkResolver) {
124
+ const resolve = opts.linkResolver;
125
+ html = html.replace(/<a href="([^"]*)">([\s\S]*?)<\/a>/g, (full, href, inner) => {
126
+ const decoded = unescapeHtml(href);
127
+ if (/^(?:[a-z][a-z0-9+.-]*:|#|\/\/)/i.test(decoded))
128
+ return full; // http(s):, mailto:, #anchor
129
+ const target = resolve(decoded);
130
+ if (!target)
131
+ return full;
132
+ const text = unescapeHtml(inner.replace(/<[^>]+>/g, '')).trim();
133
+ const attrs = [];
134
+ if (target.space)
135
+ attrs.push(['space', target.space]);
136
+ if (text)
137
+ attrs.push(['text', text]);
138
+ return renderPageLink(target.title, attrs);
139
+ });
140
+ }
119
141
  return html.replace(PLACEHOLDER_RE, (_full, kind, rawBody) => {
120
142
  const { name, attrs } = parsePlaceholder(String(rawBody));
121
143
  if (kind === 'page') {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "confluence-md-sync",
3
- "version": "0.8.7",
3
+ "version": "0.8.9",
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",