confluence-md-sync 0.2.0 → 0.3.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.
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Мини-парсер и сериализатор storage-формата Confluence.
3
+ *
4
+ * Storage — well-formed XML-фрагмент (XHTML + пространства имён ac:/ri:),
5
+ * но с HTML-сущностями (  и т.п.), которые «настоящий» XML-парсер не
6
+ * переварит. Поэтому свой парсер: сущности в тексте и атрибутах НЕ
7
+ * декодируются (хранятся как в исходнике — это даёт побайтовую
8
+ * сериализацию обратно), а декодирование по требованию делает
9
+ * {@link decodeEntities}.
10
+ */
11
+ export interface XElement {
12
+ kind: 'el';
13
+ name: string;
14
+ /** Пары [имя, raw-значение] в исходном порядке; значения не декодированы. */
15
+ attrs: Array<[string, string]>;
16
+ children: XNode[];
17
+ selfClosing: boolean;
18
+ }
19
+ export interface XText {
20
+ kind: 'text';
21
+ /** Текст как в исходнике, сущности не декодированы. */
22
+ raw: string;
23
+ }
24
+ export interface XCdata {
25
+ kind: 'cdata';
26
+ text: string;
27
+ }
28
+ export interface XComment {
29
+ kind: 'comment';
30
+ text: string;
31
+ }
32
+ export type XNode = XElement | XText | XCdata | XComment;
33
+ export declare class StorageParseError extends Error {
34
+ readonly position: number;
35
+ constructor(message: string, position: number);
36
+ }
37
+ /** Парсит storage-фрагмент в список узлов. Бросает StorageParseError. */
38
+ export declare function parseStorage(input: string): XNode[];
39
+ /** Сериализует узлы обратно в storage. Для нетронутого дерева — побайтово. */
40
+ export declare function serializeStorage(nodes: XNode[]): string;
41
+ /** Декодирует HTML/XML-сущности (именованные из словаря + числовые). */
42
+ export declare function decodeEntities(raw: string): string;
43
+ /** Значение атрибута элемента (декодированное) или undefined. */
44
+ export declare function getAttr(el: XElement, name: string): string | undefined;
45
+ /** Текстовое содержимое поддерева (сущности декодированы, теги отброшены). */
46
+ export declare function textContent(nodes: XNode[]): string;
47
+ /** true, если в поддереве есть элементы с namespace-префиксом (ac:, ri:, …). */
48
+ export declare function hasNamespacedElements(nodes: XNode[]): boolean;
49
+ /** Только элементы среди узлов. */
50
+ export declare function elements(nodes: XNode[]): XElement[];
51
+ /** Экранирует текст для XML (используется при генерации новых узлов). */
52
+ export declare function escapeXmlText(s: string): string;
@@ -0,0 +1,230 @@
1
+ /**
2
+ * Мини-парсер и сериализатор storage-формата Confluence.
3
+ *
4
+ * Storage — well-formed XML-фрагмент (XHTML + пространства имён ac:/ri:),
5
+ * но с HTML-сущностями (&nbsp; и т.п.), которые «настоящий» XML-парсер не
6
+ * переварит. Поэтому свой парсер: сущности в тексте и атрибутах НЕ
7
+ * декодируются (хранятся как в исходнике — это даёт побайтовую
8
+ * сериализацию обратно), а декодирование по требованию делает
9
+ * {@link decodeEntities}.
10
+ */
11
+ export class StorageParseError extends Error {
12
+ position;
13
+ constructor(message, position) {
14
+ super(`${message} (at offset ${position})`);
15
+ this.position = position;
16
+ this.name = 'StorageParseError';
17
+ }
18
+ }
19
+ // XHTML-«пустые» элементы: на случай невалидного <br> без самозакрытия.
20
+ const VOID_ELEMENTS = new Set(['br', 'hr', 'img', 'col', 'input', 'meta', 'link']);
21
+ const NAME_RE = /[A-Za-z_][A-Za-z0-9._:-]*/y;
22
+ /** Парсит storage-фрагмент в список узлов. Бросает StorageParseError. */
23
+ export function parseStorage(input) {
24
+ const root = [];
25
+ const stack = [];
26
+ let current = root;
27
+ let i = 0;
28
+ const pushText = (from, to) => {
29
+ if (to > from)
30
+ current.push({ kind: 'text', raw: input.slice(from, to) });
31
+ };
32
+ let textStart = 0;
33
+ while (i < input.length) {
34
+ const lt = input.indexOf('<', i);
35
+ if (lt === -1)
36
+ break;
37
+ pushText(textStart, lt);
38
+ if (input.startsWith('<![CDATA[', lt)) {
39
+ const end = input.indexOf(']]>', lt + 9);
40
+ if (end === -1)
41
+ throw new StorageParseError('unterminated CDATA', lt);
42
+ current.push({ kind: 'cdata', text: input.slice(lt + 9, end) });
43
+ i = textStart = end + 3;
44
+ continue;
45
+ }
46
+ if (input.startsWith('<!--', lt)) {
47
+ const end = input.indexOf('-->', lt + 4);
48
+ if (end === -1)
49
+ throw new StorageParseError('unterminated comment', lt);
50
+ current.push({ kind: 'comment', text: input.slice(lt + 4, end) });
51
+ i = textStart = end + 3;
52
+ continue;
53
+ }
54
+ if (input.startsWith('</', lt)) {
55
+ NAME_RE.lastIndex = lt + 2;
56
+ const m = NAME_RE.exec(input);
57
+ if (!m)
58
+ throw new StorageParseError('malformed closing tag', lt);
59
+ const gt = input.indexOf('>', lt);
60
+ if (gt === -1)
61
+ throw new StorageParseError('unterminated closing tag', lt);
62
+ const frame = stack.pop();
63
+ if (!frame || frame.el.name !== m[0]) {
64
+ throw new StorageParseError(`unexpected </${m[0]}>${frame ? `, open element is <${frame.el.name}>` : ''}`, lt);
65
+ }
66
+ current = stack.length > 0 ? stack[stack.length - 1].children : root;
67
+ i = textStart = gt + 1;
68
+ continue;
69
+ }
70
+ // Открывающий тег
71
+ NAME_RE.lastIndex = lt + 1;
72
+ const nameMatch = NAME_RE.exec(input);
73
+ if (!nameMatch) {
74
+ // Не тег (например, одинокий '<' — в валидном storage не бывает).
75
+ throw new StorageParseError('malformed tag', lt);
76
+ }
77
+ const el = {
78
+ kind: 'el',
79
+ name: nameMatch[0],
80
+ attrs: [],
81
+ children: [],
82
+ selfClosing: false,
83
+ };
84
+ i = NAME_RE.lastIndex;
85
+ // Атрибуты
86
+ for (;;) {
87
+ while (/[\s]/.test(input[i] ?? ''))
88
+ i++;
89
+ const ch = input[i];
90
+ if (ch === '>') {
91
+ i++;
92
+ break;
93
+ }
94
+ if (ch === '/') {
95
+ if (input[i + 1] !== '>')
96
+ throw new StorageParseError('malformed self-closing tag', i);
97
+ el.selfClosing = true;
98
+ i += 2;
99
+ break;
100
+ }
101
+ NAME_RE.lastIndex = i;
102
+ const attrName = NAME_RE.exec(input);
103
+ if (!attrName)
104
+ throw new StorageParseError(`malformed attribute in <${el.name}>`, i);
105
+ i = NAME_RE.lastIndex;
106
+ while (/\s/.test(input[i] ?? ''))
107
+ i++;
108
+ if (input[i] !== '=') {
109
+ // Булев атрибут без значения — в storage не встречается, но переживём.
110
+ el.attrs.push([attrName[0], '']);
111
+ continue;
112
+ }
113
+ i++;
114
+ while (/\s/.test(input[i] ?? ''))
115
+ i++;
116
+ const quote = input[i];
117
+ if (quote !== '"' && quote !== "'") {
118
+ throw new StorageParseError(`unquoted attribute value in <${el.name}>`, i);
119
+ }
120
+ const endQuote = input.indexOf(quote, i + 1);
121
+ if (endQuote === -1)
122
+ throw new StorageParseError('unterminated attribute value', i);
123
+ el.attrs.push([attrName[0], input.slice(i + 1, endQuote)]);
124
+ i = endQuote + 1;
125
+ }
126
+ current.push(el);
127
+ if (!el.selfClosing && !VOID_ELEMENTS.has(el.name.toLowerCase())) {
128
+ stack.push({ el, children: el.children });
129
+ current = el.children;
130
+ }
131
+ else if (!el.selfClosing) {
132
+ el.selfClosing = true; // нормализуем невалидный <br> к <br/>
133
+ }
134
+ textStart = i;
135
+ }
136
+ pushText(textStart, input.length);
137
+ if (stack.length > 0) {
138
+ throw new StorageParseError(`unclosed element <${stack[stack.length - 1].el.name}>`, input.length);
139
+ }
140
+ return root;
141
+ }
142
+ /** Сериализует узлы обратно в storage. Для нетронутого дерева — побайтово. */
143
+ export function serializeStorage(nodes) {
144
+ let out = '';
145
+ for (const n of nodes) {
146
+ switch (n.kind) {
147
+ case 'text':
148
+ out += n.raw;
149
+ break;
150
+ case 'cdata':
151
+ out += `<![CDATA[${n.text}]]>`;
152
+ break;
153
+ case 'comment':
154
+ out += `<!--${n.text}-->`;
155
+ break;
156
+ case 'el': {
157
+ const attrs = n.attrs.map(([k, v]) => ` ${k}="${v}"`).join('');
158
+ if (n.selfClosing) {
159
+ out += `<${n.name}${attrs} />`;
160
+ }
161
+ else {
162
+ out += `<${n.name}${attrs}>${serializeStorage(n.children)}</${n.name}>`;
163
+ }
164
+ break;
165
+ }
166
+ }
167
+ }
168
+ return out;
169
+ }
170
+ // Частые именованные сущности Confluence-страниц; остальное — числовые.
171
+ const NAMED_ENTITIES = {
172
+ amp: '&', lt: '<', gt: '>', quot: '"', apos: "'",
173
+ nbsp: ' ', shy: '­',
174
+ rsquo: '’', lsquo: '‘', ldquo: '“', rdquo: '”',
175
+ ndash: '–', mdash: '—', hellip: '…',
176
+ laquo: '«', raquo: '»', times: '×', middot: '·',
177
+ deg: '°', plusmn: '±', copy: '©', reg: '®', trade: '™',
178
+ bull: '•', rarr: '→', larr: '←', darr: '↓', uarr: '↑',
179
+ sect: '§', para: '¶', euro: '€',
180
+ };
181
+ /** Декодирует HTML/XML-сущности (именованные из словаря + числовые). */
182
+ export function decodeEntities(raw) {
183
+ return raw.replace(/&(#x?[0-9a-fA-F]+|[a-zA-Z][a-zA-Z0-9]*);/g, (full, body) => {
184
+ if (body.startsWith('#x') || body.startsWith('#X')) {
185
+ return String.fromCodePoint(parseInt(body.slice(2), 16));
186
+ }
187
+ if (body.startsWith('#')) {
188
+ return String.fromCodePoint(parseInt(body.slice(1), 10));
189
+ }
190
+ return NAMED_ENTITIES[body] ?? full;
191
+ });
192
+ }
193
+ /** Значение атрибута элемента (декодированное) или undefined. */
194
+ export function getAttr(el, name) {
195
+ const found = el.attrs.find(([k]) => k === name);
196
+ return found === undefined ? undefined : decodeEntities(found[1]);
197
+ }
198
+ /** Текстовое содержимое поддерева (сущности декодированы, теги отброшены). */
199
+ export function textContent(nodes) {
200
+ let out = '';
201
+ for (const n of nodes) {
202
+ if (n.kind === 'text')
203
+ out += decodeEntities(n.raw);
204
+ else if (n.kind === 'cdata')
205
+ out += n.text;
206
+ else if (n.kind === 'el')
207
+ out += textContent(n.children);
208
+ }
209
+ return out;
210
+ }
211
+ /** true, если в поддереве есть элементы с namespace-префиксом (ac:, ri:, …). */
212
+ export function hasNamespacedElements(nodes) {
213
+ for (const n of nodes) {
214
+ if (n.kind === 'el') {
215
+ if (n.name.includes(':'))
216
+ return true;
217
+ if (hasNamespacedElements(n.children))
218
+ return true;
219
+ }
220
+ }
221
+ return false;
222
+ }
223
+ /** Только элементы среди узлов. */
224
+ export function elements(nodes) {
225
+ return nodes.filter((n) => n.kind === 'el');
226
+ }
227
+ /** Экранирует текст для XML (используется при генерации новых узлов). */
228
+ export function escapeXmlText(s) {
229
+ return s.replace(/[<>&]/g, (c) => (c === '<' ? '&lt;' : c === '>' ? '&gt;' : '&amp;'));
230
+ }
package/dist/index.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  export { ConfluenceClient, ConfluenceApiError, type ConfluencePage, type ConfluencePageStorage, type ConfluenceAttachment, type ConfluenceLabel, type AttachmentVersionData, type CreatePageOptions, } from './client/client.js';
2
2
  export { authHeader, loadConfigFromEnv, type ConfluenceAuthType, type ConfluenceConfig, type LoadConfigOptions, } from './client/config.js';
3
3
  export { Markdown } from './markdown/markdown.js';
4
- export { renderToStorage, extractPlaceholders, renameImagePlaceholders, MissingAttachmentUrlError, PLACEHOLDER_RE, type AttachmentUrls, type ExtractedPlaceholders, } from './markdown/render.js';
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
6
  export * from './macros/index.js';
7
7
  export { convertBpmn, convertBpmnFolder, isBpmnFile, bpmnOutputName, BPMN_FILE_RE, type BpmnConversion, type BpmnImageFormat, type ConvertBpmnFolderOptions, } from './bpmn/convert.js';
@@ -10,5 +10,11 @@ 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 { isHttpUrl, remoteFilename, isSameConfluenceOrigin, remoteRequestHeaders, downloadToFile, } from './publish/remote.js';
13
14
  export { runPublish, type Here, type Build, type PublishPlan, type RunPublishOptions, } from './publish/runner.js';
15
+ export { parseStorage, serializeStorage, decodeEntities, StorageParseError, type XNode, type XElement, } from './export/xhtml.js';
16
+ export { canonicalize, compareStorage, type CNode, type CompareResult, type StorageDiff, } from './export/canonical.js';
17
+ export { storageToMarkdown, type StorageToMarkdownOptions, type StorageToMarkdownResult, } from './export/to-markdown.js';
18
+ export { roundTripStorage, roundTripPage, renderExportedMarkdown, type RoundTripResult, } from './export/roundtrip.js';
19
+ export { exportPage, type ExportPageOptions, type ExportPageResult } from './export/export-page.js';
14
20
  export { confluence, ConfluenceWrapper } from './wrapper.js';
package/dist/index.js CHANGED
@@ -3,7 +3,7 @@ export { ConfluenceClient, ConfluenceApiError, } from './client/client.js';
3
3
  export { authHeader, loadConfigFromEnv, } from './client/config.js';
4
4
  // Markdown
5
5
  export { Markdown } from './markdown/markdown.js';
6
- export { renderToStorage, extractPlaceholders, renameImagePlaceholders, MissingAttachmentUrlError, PLACEHOLDER_RE, } from './markdown/render.js';
6
+ export { renderToStorage, extractPlaceholders, parsePlaceholder, renameImagePlaceholders, MissingAttachmentUrlError, PLACEHOLDER_RE, } from './markdown/render.js';
7
7
  export { validateMarkdown, MarkdownValidationError } from './markdown/validate.js';
8
8
  // Macros (pluggable)
9
9
  export * from './macros/index.js';
@@ -17,6 +17,13 @@ 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 { isHttpUrl, remoteFilename, isSameConfluenceOrigin, remoteRequestHeaders, downloadToFile, } from './publish/remote.js';
20
21
  export { runPublish, } from './publish/runner.js';
22
+ // Export (storage → markdown) & round-trip
23
+ export { parseStorage, serializeStorage, decodeEntities, StorageParseError, } from './export/xhtml.js';
24
+ export { canonicalize, compareStorage, } from './export/canonical.js';
25
+ export { storageToMarkdown, } from './export/to-markdown.js';
26
+ export { roundTripStorage, roundTripPage, renderExportedMarkdown, } from './export/roundtrip.js';
27
+ export { exportPage } from './export/export-page.js';
21
28
  // Facade
22
29
  export { confluence, ConfluenceWrapper } from './wrapper.js';
@@ -26,5 +26,12 @@ export declare class MacroBuilder {
26
26
  * macro('table-excerpt').param('name', 'data').body(md).toMarkdown()
27
27
  */
28
28
  export declare function macro(name: string): MacroBuilder;
29
+ /**
30
+ * Экранирование значения параметра для однострочного маркера-комментария.
31
+ * Кодируются: `%` (первым — иначе двойное декодирование), `=`/`:`
32
+ * (разделители маркера), `<`/`>` (чтобы значение с `-->` не оборвало
33
+ * комментарий) и переводы строк (маркер обязан остаться одной строкой —
34
+ * нужно для многострочных параметров вроде SQL у table-joiner).
35
+ */
29
36
  export declare function escapeParamValue(s: string): string;
30
37
  export declare function unescapeParamValue(s: string): string;
@@ -50,9 +50,30 @@ export class MacroBuilder {
50
50
  export function macro(name) {
51
51
  return new MacroBuilder(name);
52
52
  }
53
+ /**
54
+ * Экранирование значения параметра для однострочного маркера-комментария.
55
+ * Кодируются: `%` (первым — иначе двойное декодирование), `=`/`:`
56
+ * (разделители маркера), `<`/`>` (чтобы значение с `-->` не оборвало
57
+ * комментарий) и переводы строк (маркер обязан остаться одной строкой —
58
+ * нужно для многострочных параметров вроде SQL у table-joiner).
59
+ */
53
60
  export function escapeParamValue(s) {
54
- return s.replace(/[=:]/g, (c) => (c === '=' ? '%3D' : '%3A'));
61
+ return s
62
+ .replace(/%/g, '%25')
63
+ .replace(/=/g, '%3D')
64
+ .replace(/:/g, '%3A')
65
+ .replace(/</g, '%3C')
66
+ .replace(/>/g, '%3E')
67
+ .replace(/\r/g, '%0D')
68
+ .replace(/\n/g, '%0A');
55
69
  }
56
70
  export function unescapeParamValue(s) {
57
- return s.replace(/%3D/g, '=').replace(/%3A/g, ':');
71
+ return s
72
+ .replace(/%0A/g, '\n')
73
+ .replace(/%0D/g, '\r')
74
+ .replace(/%3E/g, '>')
75
+ .replace(/%3C/g, '<')
76
+ .replace(/%3A/g, ':')
77
+ .replace(/%3D/g, '=')
78
+ .replace(/%25/g, '%');
58
79
  }
@@ -6,7 +6,7 @@ export { coreMacrosPlugin, extractPlainText } from './plugins/core.js';
6
6
  export { tableFilterPlugin, TABLE_FILTER_DEFAULTS } from './plugins/table-filter.js';
7
7
  import { MacroRegistry } from './registry.js';
8
8
  import { anchor, children, codeBlock, excerpt, excerptInclude, expand, includePage, jiraIssue, panel, status, toc } from './plugins/core.js';
9
- import { tableExcerpt, tableExcerptInclude, tableFilter } from './plugins/table-filter.js';
9
+ import { tableExcerpt, tableExcerptInclude, tableFilter, tableJoiner } from './plugins/table-filter.js';
10
10
  /** Creates a registry pre-loaded with all built-in plugins. */
11
11
  export declare function createDefaultRegistry(): MacroRegistry;
12
12
  /**
@@ -41,4 +41,5 @@ export declare const macros: {
41
41
  tableExcerpt: typeof tableExcerpt;
42
42
  tableFilter: typeof tableFilter;
43
43
  tableExcerptInclude: typeof tableExcerptInclude;
44
+ tableJoiner: typeof tableJoiner;
44
45
  };
@@ -8,7 +8,7 @@ import { MacroRegistry } from './registry.js';
8
8
  import { coreMacrosPlugin } from './plugins/core.js';
9
9
  import { tableFilterPlugin } from './plugins/table-filter.js';
10
10
  import { anchor, children, codeBlock, excerpt, excerptInclude, expand, includePage, info, jiraIssue, note, panel, status, tip, toc, warning, } from './plugins/core.js';
11
- import { tableExcerpt, tableExcerptInclude, tableFilter } from './plugins/table-filter.js';
11
+ import { tableExcerpt, tableExcerptInclude, tableFilter, tableJoiner } from './plugins/table-filter.js';
12
12
  /** Creates a registry pre-loaded with all built-in plugins. */
13
13
  export function createDefaultRegistry() {
14
14
  return new MacroRegistry().use(coreMacrosPlugin).use(tableFilterPlugin);
@@ -47,4 +47,5 @@ export const macros = {
47
47
  tableExcerpt,
48
48
  tableFilter,
49
49
  tableExcerptInclude,
50
+ tableJoiner,
50
51
  };
@@ -109,6 +109,8 @@ export const coreMacrosPlugin = {
109
109
  richBodyMacro('tip', ['title', 'icon']),
110
110
  richBodyMacro('panel'),
111
111
  richBodyMacro('excerpt', ['hidden', 'atlassian-macro-output-type']),
112
+ // Page Properties (details) + Page Properties Report (detailssummary)
113
+ richBodyMacro('details'),
112
114
  // Plain-text body
113
115
  codeMacro,
114
116
  // Bodyless
@@ -118,6 +120,7 @@ export const coreMacrosPlugin = {
118
120
  bodylessMacro('children'),
119
121
  bodylessMacro('pagetree'),
120
122
  bodylessMacro('recently-updated'),
123
+ bodylessMacro('detailssummary'),
121
124
  anchorMacro,
122
125
  includeMacro,
123
126
  excerptIncludeMacro,
@@ -28,6 +28,14 @@ export declare function tableExcerpt(body: Markdown | string, name?: string, hid
28
28
  * tableFilter(tableExcerptMd, { totalrow: ',,,Sum,Sum,Sum' })
29
29
  */
30
30
  export declare function tableFilter(body: Markdown | string, opts?: Record<string, string | undefined>): Markdown;
31
+ /**
32
+ * Обёртывает markdown (обычно table-excerpt-include'ы) в макрос
33
+ * "объединение таблиц" с SQL-запросом. Многострочный SQL допустим.
34
+ *
35
+ * @example
36
+ * tableJoiner(includesMd, "SELECT * FROM T1 LEFT JOIN T2 ON T1.'Код' = T2.'Код'")
37
+ */
38
+ export declare function tableJoiner(body: Markdown | string, sql: string, opts?: Record<string, string | undefined>): Markdown;
31
39
  /** Включение table-excerpt с другой страницы по имени выборки. */
32
40
  export declare function tableExcerptInclude(name: string, page: string, opts?: {
33
41
  space?: string;
@@ -89,15 +89,37 @@ export const tableFilterPlugin = {
89
89
  }),
90
90
  },
91
91
  {
92
- // Включение table-excerpt с другой страницы.
92
+ // Макрос "Объединение таблиц" (table-joiner): SQL-запрос по таблицам
93
+ // из тела макроса (обычно table-excerpt-include). Параметры — как есть;
94
+ // многострочный sql в маркере кодируется %0A (см. escapeParamValue).
95
+ name: 'table-joiner',
96
+ render: (ctx) => structuredMacro('table-joiner', ctx.macroId, {
97
+ params: ctx.params,
98
+ richBody: ctx.body,
99
+ }),
100
+ },
101
+ {
102
+ // Включение table-excerpt с другой страницы. page/space собираются в
103
+ // ac:link-параметр; остальные параметры (v, merge-tables, …)
104
+ // пробрасываются как есть.
93
105
  name: 'table-excerpt-include',
94
106
  render: (ctx) => {
95
107
  const map = paramMap(ctx.params);
96
108
  const params = [
97
109
  { name: 'name', value: map.name ?? '' },
98
- { name: 'page', value: pageLinkValue(map.page ?? '', map.space), raw: true },
99
- { name: 'type', value: map.type ?? 'page' },
100
110
  ];
111
+ // Без page макрос ссылается на текущую страницу — параметр не пишем.
112
+ if (map.page !== undefined) {
113
+ params.push({ name: 'page', value: pageLinkValue(map.page, map.space), raw: true });
114
+ params.push({ name: 'type', value: map.type ?? 'page' });
115
+ }
116
+ else if (map.type !== undefined) {
117
+ params.push({ name: 'type', value: map.type });
118
+ }
119
+ for (const p of ctx.params) {
120
+ if (!['name', 'page', 'space', 'type'].includes(p.name))
121
+ params.push(p);
122
+ }
101
123
  return structuredMacro('table-excerpt-include', ctx.macroId, { params });
102
124
  },
103
125
  },
@@ -144,6 +166,16 @@ export function tableFilter(body, opts = {}) {
144
166
  }
145
167
  return builder.toMarkdown();
146
168
  }
169
+ /**
170
+ * Обёртывает markdown (обычно table-excerpt-include'ы) в макрос
171
+ * "объединение таблиц" с SQL-запросом. Многострочный SQL допустим.
172
+ *
173
+ * @example
174
+ * tableJoiner(includesMd, "SELECT * FROM T1 LEFT JOIN T2 ON T1.'Код' = T2.'Код'")
175
+ */
176
+ export function tableJoiner(body, sql, opts = {}) {
177
+ return macro('table-joiner').param('sql', sql).withParams(opts).body(body).toMarkdown();
178
+ }
147
179
  /** Включение table-excerpt с другой страницы по имени выборки. */
148
180
  export function tableExcerptInclude(name, page, opts = {}) {
149
181
  const builder = macro('table-excerpt-include').param('name', name).param('page', page);
@@ -13,12 +13,23 @@ import type { MacroPlugin, MacroRenderer } from './types.js';
13
13
  export declare class MacroRegistry {
14
14
  private renderers;
15
15
  private pluginNames;
16
+ private passthroughUnknown;
16
17
  /** Registers all macros of a plugin. Later registrations win on name clash. */
17
18
  use(plugin: MacroPlugin): this;
18
19
  register(name: string, renderer: MacroRenderer): this;
19
20
  has(name: string): boolean;
20
21
  getRenderer(name: string): MacroRenderer | undefined;
21
22
  getAllRenderers(): Map<string, MacroRenderer>;
23
+ /**
24
+ * Управляет обработкой маркеров с именами, которых нет в реестре.
25
+ * По умолчанию ВКЛЮЧЕНО: такой маркер рендерится «как есть» —
26
+ * <ac:structured-macro> с параметрами из маркера и rich-text телом
27
+ * (если тело непустое). Это делает публикуемым любой макрос без явной
28
+ * регистрации (в т.ч. страницы, полученные из exportPage). Выключение
29
+ * возвращает старое поведение: незнакомый маркер остаётся комментарием.
30
+ */
31
+ passthroughUnknownMacros(enable?: boolean): this;
32
+ get allowsUnknownMacros(): boolean;
22
33
  /** Names of plugins registered via {@link use} (for diagnostics). */
23
34
  get plugins(): string[];
24
35
  }
@@ -1,6 +1,6 @@
1
1
  import { Markdown } from '../markdown/markdown.js';
2
2
  import { unescapeParamValue } from './builder.js';
3
- import { generateMacroId } from './xml.js';
3
+ import { generateMacroId, structuredMacro } from './xml.js';
4
4
  /**
5
5
  * Реестр макросов. Пустой при создании — наполняется плагинами через
6
6
  * {@link use} или точечной регистрацией через {@link register}.
@@ -14,6 +14,7 @@ import { generateMacroId } from './xml.js';
14
14
  export class MacroRegistry {
15
15
  renderers = new Map();
16
16
  pluginNames = [];
17
+ passthroughUnknown = true;
17
18
  /** Registers all macros of a plugin. Later registrations win on name clash. */
18
19
  use(plugin) {
19
20
  for (const def of plugin.macros) {
@@ -35,11 +36,34 @@ export class MacroRegistry {
35
36
  getAllRenderers() {
36
37
  return this.renderers;
37
38
  }
39
+ /**
40
+ * Управляет обработкой маркеров с именами, которых нет в реестре.
41
+ * По умолчанию ВКЛЮЧЕНО: такой маркер рендерится «как есть» —
42
+ * <ac:structured-macro> с параметрами из маркера и rich-text телом
43
+ * (если тело непустое). Это делает публикуемым любой макрос без явной
44
+ * регистрации (в т.ч. страницы, полученные из exportPage). Выключение
45
+ * возвращает старое поведение: незнакомый маркер остаётся комментарием.
46
+ */
47
+ passthroughUnknownMacros(enable = true) {
48
+ this.passthroughUnknown = enable;
49
+ return this;
50
+ }
51
+ get allowsUnknownMacros() {
52
+ return this.passthroughUnknown;
53
+ }
38
54
  /** Names of plugins registered via {@link use} (for diagnostics). */
39
55
  get plugins() {
40
56
  return [...this.pluginNames];
41
57
  }
42
58
  }
59
+ /** Renderer «как есть» для макросов, не зарегистрированных в реестре. */
60
+ function passthroughRenderer(name) {
61
+ return (ctx) => structuredMacro(name, ctx.macroId, {
62
+ params: ctx.params,
63
+ ...(ctx.body.trim() !== '' ? { richBody: ctx.body } : {}),
64
+ });
65
+ }
66
+ const ANY_START_MARKER_RE = /<!-- MACRO:start:([A-Za-z0-9_-]+)/g;
43
67
  /**
44
68
  * Преобразует маркеры макросов в XHTML storage format.
45
69
  * Обрабатывает вложенные макросы снизу вверх (от inner к outer).
@@ -61,9 +85,29 @@ export function processMacros(storage, registry) {
61
85
  break;
62
86
  }
63
87
  }
88
+ // Незнакомые имена — passthrough-рендером (если разрешён). После
89
+ // зарегистрированных, чтобы явные рендереры всегда были в приоритете.
90
+ if (!modified && registry.allowsUnknownMacros) {
91
+ for (const name of findMarkerNames(content)) {
92
+ if (registry.has(name))
93
+ continue;
94
+ const result = processMacroType(content, name, passthroughRenderer(name));
95
+ if (result !== content) {
96
+ content = result;
97
+ modified = true;
98
+ break;
99
+ }
100
+ }
101
+ }
64
102
  }
65
103
  return new Markdown(content);
66
104
  }
105
+ function findMarkerNames(storage) {
106
+ const names = new Set();
107
+ for (const m of storage.matchAll(ANY_START_MARKER_RE))
108
+ names.add(m[1]);
109
+ return names;
110
+ }
67
111
  function processMacroType(storage, macroName, renderer) {
68
112
  // `:[^\n]*?` — lazy до конца строки маркера. Раньше тут было `[^-]*`,
69
113
  // что ломалось на дефис в имени параметра (например cell-width=) или
@@ -1,7 +1,22 @@
1
+ /**
2
+ * Плейсхолдеры: `{{img:name}}`, `{{file:name}}`, `{{page:Title}}`.
3
+ * После имени допустимы `|key=value`-атрибуты:
4
+ * {{img:chart.png|thumbnail=true|height=250}}
5
+ * {{page:Другая страница|space=DOCS|text=якорный текст}}
6
+ */
1
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;
2
16
  export interface ExtractedPlaceholders {
3
17
  images: string[];
4
18
  files: string[];
19
+ pages: string[];
5
20
  }
6
21
  export declare function extractPlaceholders(markdown: string): ExtractedPlaceholders;
7
22
  /**
@@ -15,6 +30,20 @@ export interface AttachmentUrls {
15
30
  images: Map<string, string>;
16
31
  files: Map<string, string>;
17
32
  }
33
+ export interface RenderStorageOptions {
34
+ /**
35
+ * Как рендерить `{{img:...}}` / `{{file:...}}`:
36
+ * - 'url' (default) — `<img src=…>` / `<a href=…>` с download-URL аттача;
37
+ * - 'attachment' — нативные `<ac:image><ri:attachment/>` /
38
+ * `<ac:link><ri:attachment/>` (ссылка по имени файла, URL не нужен).
39
+ * Используется exportPage/round-trip: форма совпадает с тем, что
40
+ * пишет сам Confluence.
41
+ */
42
+ imageStyle?: 'url' | 'attachment';
43
+ fileStyle?: 'url' | 'attachment';
44
+ /** Автопревращение голых URL в ссылки (default: true). */
45
+ linkify?: boolean;
46
+ }
18
47
  export declare class MissingAttachmentUrlError extends Error {
19
48
  constructor(message: string);
20
49
  }
@@ -38,4 +67,4 @@ export declare class MissingAttachmentUrlError extends Error {
38
67
  * HTML этой проблемы лишена — `{{img:foo}}` мимо markdown-it проходит
39
68
  * посимвольно.
40
69
  */
41
- export declare function renderToStorage(markdown: string, urls: AttachmentUrls): string;
70
+ export declare function renderToStorage(markdown: string, urls: AttachmentUrls, opts?: RenderStorageOptions): string;