@iwonz/ywtc 3.1.0 → 3.2.1

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.
Files changed (47) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/README.md +25 -7
  3. package/dist/adapters/confluence.d.ts +6 -0
  4. package/dist/adapters/confluence.js +28 -3
  5. package/dist/adapters/confluence.js.map +1 -1
  6. package/dist/adapters/yandex.d.ts +6 -0
  7. package/dist/adapters/yandex.js +32 -5
  8. package/dist/adapters/yandex.js.map +1 -1
  9. package/dist/cli-arguments.d.ts +11 -2
  10. package/dist/cli-arguments.js +20 -1
  11. package/dist/cli-arguments.js.map +1 -1
  12. package/dist/cli.js +58 -1
  13. package/dist/cli.js.map +1 -1
  14. package/dist/consistency.d.ts +11 -0
  15. package/dist/consistency.js +38 -0
  16. package/dist/consistency.js.map +1 -0
  17. package/dist/content-model.d.ts +16 -1
  18. package/dist/content-model.js +518 -30
  19. package/dist/content-model.js.map +1 -1
  20. package/dist/conversion.d.ts +1 -0
  21. package/dist/conversion.js +17 -0
  22. package/dist/conversion.js.map +1 -1
  23. package/dist/domain.d.ts +39 -5
  24. package/dist/domain.js.map +1 -1
  25. package/dist/filesystem-export.js +6 -5
  26. package/dist/filesystem-export.js.map +1 -1
  27. package/dist/interactive-init.js +12 -8
  28. package/dist/interactive-init.js.map +1 -1
  29. package/dist/issues.d.ts +23 -0
  30. package/dist/issues.js +31 -0
  31. package/dist/issues.js.map +1 -0
  32. package/dist/migrator.d.ts +8 -1
  33. package/dist/migrator.js +557 -166
  34. package/dist/migrator.js.map +1 -1
  35. package/dist/report-catalog.d.ts +67 -0
  36. package/dist/report-catalog.js +119 -0
  37. package/dist/report-catalog.js.map +1 -0
  38. package/dist/report.d.ts +18 -2
  39. package/dist/report.js +36 -7
  40. package/dist/report.js.map +1 -1
  41. package/dist/resource-reconciliation.d.ts +21 -0
  42. package/dist/resource-reconciliation.js +72 -0
  43. package/dist/resource-reconciliation.js.map +1 -0
  44. package/dist/state.d.ts +3 -0
  45. package/dist/state.js +3 -0
  46. package/dist/state.js.map +1 -1
  47. package/package.json +2 -1
@@ -1,4 +1,5 @@
1
1
  import transform from '@diplodoc/transform';
2
+ import { createHash } from 'node:crypto';
2
3
  import path from 'node:path';
3
4
  import { load } from 'cheerio';
4
5
  import { LinkifyIt } from 'linkify-it';
@@ -12,9 +13,128 @@ import { unified } from 'unified';
12
13
  import { convertYfmToStorage } from './conversion.js';
13
14
  import { normalizeWikiSlug, resolveRelativeWikiSlug } from './input.js';
14
15
  const linkifier = new LinkifyIt();
16
+ function sha256(value) {
17
+ return createHash('sha256').update(value).digest('hex');
18
+ }
15
19
  function escapeHtml(value) {
16
20
  return String(value ?? '').replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('"', '&quot;');
17
21
  }
22
+ function absoluteResourceUrl(page, resource) {
23
+ if (resource.downloadUrl) {
24
+ try {
25
+ return new URL(resource.downloadUrl, page.url).toString();
26
+ }
27
+ catch {
28
+ // Fall through to the page URL so the resource remains discoverable.
29
+ }
30
+ }
31
+ return page.url;
32
+ }
33
+ function normalizedResourceAlias(value) {
34
+ const trimmed = value.trim();
35
+ if (!trimmed)
36
+ return undefined;
37
+ let normalized = trimmed;
38
+ try {
39
+ normalized = decodeURIComponent(trimmed);
40
+ }
41
+ catch {
42
+ // Keep provider text when percent encoding is malformed.
43
+ }
44
+ return normalized.replace(/\\/g, '/').replace(/^\.\//, '').toLocaleLowerCase();
45
+ }
46
+ function resourceAliasCandidates(reference) {
47
+ const aliases = new Set();
48
+ const add = (value) => {
49
+ if (!value)
50
+ return;
51
+ const normalized = normalizedResourceAlias(value);
52
+ if (!normalized)
53
+ return;
54
+ aliases.add(normalized);
55
+ aliases.add(normalized.replace(/^attachment:/, ''));
56
+ aliases.add(normalized.replace(/^\/+/, ''));
57
+ const marker = normalized.match(/(?:^|\/)((?:\.files|_files|attachments)\/.*)$/i)?.[1];
58
+ if (marker)
59
+ aliases.add(marker);
60
+ };
61
+ const withoutFragment = reference.split('#', 1)[0] ?? reference;
62
+ add(withoutFragment);
63
+ try {
64
+ const url = new URL(withoutFragment);
65
+ add(url.pathname);
66
+ }
67
+ catch {
68
+ // Relative references are already covered above.
69
+ }
70
+ const snapshot = [...aliases];
71
+ for (const alias of snapshot)
72
+ add(alias.split('/').at(-1));
73
+ return [...aliases];
74
+ }
75
+ export function preferredResourceName(resource) {
76
+ return resource.preferredName ?? resource.name;
77
+ }
78
+ export function sourceResourceAliases(page, resource) {
79
+ const aliases = new Set();
80
+ const add = (value) => {
81
+ for (const alias of value ? resourceAliasCandidates(value) : [])
82
+ aliases.add(alias);
83
+ };
84
+ add(resource.name);
85
+ add(resource.preferredName);
86
+ add(resource.downloadUrl);
87
+ for (const alias of resource.referenceAliases ?? [])
88
+ add(alias);
89
+ add(absoluteResourceUrl(page, resource));
90
+ return [...aliases];
91
+ }
92
+ export function sourceResourceBindings(page, resources) {
93
+ const bindings = new Map();
94
+ const aliases = new Map();
95
+ for (const resource of resources.filter((item) => item.kind !== 'grid')) {
96
+ for (const alias of sourceResourceAliases(page, resource)) {
97
+ const matched = aliases.get(alias) ?? [];
98
+ if (!matched.some((candidate) => candidate.id === resource.id))
99
+ matched.push(resource);
100
+ aliases.set(alias, matched);
101
+ }
102
+ }
103
+ for (const [alias, matched] of aliases) {
104
+ if (matched.length !== 1)
105
+ continue;
106
+ const resource = matched[0];
107
+ bindings.set(alias, { kind: 'source', resource, url: absoluteResourceUrl(page, resource) });
108
+ }
109
+ return bindings;
110
+ }
111
+ export function setSourceResourceBinding(bindings, page, resources, resource, binding) {
112
+ const aliasOwners = new Map();
113
+ for (const candidate of resources.filter((item) => item.kind !== 'grid')) {
114
+ for (const alias of sourceResourceAliases(page, candidate)) {
115
+ const owners = aliasOwners.get(alias) ?? new Set();
116
+ owners.add(candidate.id);
117
+ aliasOwners.set(alias, owners);
118
+ }
119
+ }
120
+ for (const alias of sourceResourceAliases(page, resource)) {
121
+ if (aliasOwners.get(alias)?.size === 1)
122
+ bindings.set(alias, binding);
123
+ }
124
+ }
125
+ function bindingTarget(binding) {
126
+ return binding.kind === 'uploaded' ? binding.resource.url ?? binding.resource.name
127
+ : binding.kind === 'source' ? binding.url
128
+ : binding.path;
129
+ }
130
+ function resourceBinding(reference, context) {
131
+ for (const alias of resourceAliasCandidates(reference)) {
132
+ const binding = context.resources.get(alias);
133
+ if (binding)
134
+ return binding;
135
+ }
136
+ return undefined;
137
+ }
18
138
  function uniqueReferences(references) {
19
139
  const seen = new Set();
20
140
  return references.filter((reference) => {
@@ -137,7 +257,69 @@ function storageToSemanticHtml(storage, warnings) {
137
257
  });
138
258
  return $('main[data-ywtc-root]').html() ?? '';
139
259
  }
140
- function yfmToHtml(content, warnings) {
260
+ export function stripAnsi(value) {
261
+ let output = '';
262
+ for (let index = 0; index < value.length;) {
263
+ const code = value.charCodeAt(index);
264
+ const controlSequence = code === 0x9b || (code === 0x1b && value[index + 1] === '[');
265
+ if (controlSequence) {
266
+ index += code === 0x9b ? 1 : 2;
267
+ while (index < value.length) {
268
+ const current = value.charCodeAt(index++);
269
+ if (current >= 0x40 && current <= 0x7e)
270
+ break;
271
+ }
272
+ continue;
273
+ }
274
+ if (code === 0x1b && value[index + 1] === ']') {
275
+ index += 2;
276
+ while (index < value.length) {
277
+ if (value.charCodeAt(index) === 0x07) {
278
+ index += 1;
279
+ break;
280
+ }
281
+ if (value.charCodeAt(index) === 0x1b && value[index + 1] === '\\') {
282
+ index += 2;
283
+ break;
284
+ }
285
+ index += 1;
286
+ }
287
+ continue;
288
+ }
289
+ if (code === 0x1b) {
290
+ index += Math.min(2, value.length - index);
291
+ continue;
292
+ }
293
+ output += value[index];
294
+ index += 1;
295
+ }
296
+ return output;
297
+ }
298
+ function extractTreeDirectives(content) {
299
+ const directives = [];
300
+ const next = content.replace(/{%\s*tree\b([^%]*?)%}/gi, (raw, attributes) => {
301
+ const values = new Map();
302
+ for (const match of attributes.matchAll(/([\w-]+)\s*=\s*["']([^"']*)["']/g)) {
303
+ if (match[1] && match[2] !== undefined)
304
+ values.set(match[1].toLocaleLowerCase(), match[2].toLocaleLowerCase());
305
+ }
306
+ const depthValue = Number(values.get('depth'));
307
+ const sort = values.get('sort');
308
+ const sortBy = values.get('sort_by');
309
+ directives.push({
310
+ raw,
311
+ ...(Number.isInteger(depthValue) && depthValue > 0 ? { depth: depthValue } : {}),
312
+ ...(sort === 'asc' || sort === 'desc' ? { sort } : {}),
313
+ ...(sortBy === 'title' ? { sortBy } : {}),
314
+ });
315
+ return '';
316
+ });
317
+ return { content: next, directives };
318
+ }
319
+ function containsEmptyYfmHeading(content) {
320
+ return /^\s{0,3}#{1,6}\s*$/m.test(content);
321
+ }
322
+ function yfmToHtml(content, warnings, normalizations = []) {
141
323
  try {
142
324
  const result = transform(content || '', {
143
325
  allowHTML: true,
@@ -146,11 +328,36 @@ function yfmToHtml(content, warnings) {
146
328
  lang: 'ru',
147
329
  linkify: true,
148
330
  });
149
- warnings.push(...(result.logs.warn ?? []), ...(result.logs.error ?? []));
150
- return result.result.html || '<p></p>';
331
+ const $ = load(result.result.html || '<p></p>', null, false);
332
+ let removedHeadings = 0;
333
+ $('h1,h2,h3,h4,h5,h6').each((_index, element) => {
334
+ const heading = $(element);
335
+ const meaningful = heading.text().replace(/\s+/g, '').length > 0
336
+ || heading.find('img,table,pre,code').length > 0;
337
+ if (meaningful)
338
+ return;
339
+ heading.find('[id],a[name]').each((_anchorIndex, anchor) => { heading.before($(anchor).clone()); });
340
+ heading.remove();
341
+ removedHeadings += 1;
342
+ });
343
+ let ignoredHeaderDiagnostics = removedHeadings;
344
+ for (const diagnostic of [...(result.logs.warn ?? []), ...(result.logs.error ?? [])]) {
345
+ const clean = stripAnsi(String(diagnostic)).trim();
346
+ if (/Header without title/i.test(clean)) {
347
+ if (ignoredHeaderDiagnostics > 0)
348
+ ignoredHeaderDiagnostics -= 1;
349
+ if (removedHeadings > 0 || !containsEmptyYfmHeading(content))
350
+ continue;
351
+ }
352
+ if (clean)
353
+ warnings.push(clean);
354
+ }
355
+ if (removedHeadings > 0)
356
+ normalizations.push(`Removed ${removedHeadings} empty heading${removedHeadings === 1 ? '' : 's'} emitted by source conversion`);
357
+ return $.html() || '<p></p>';
151
358
  }
152
359
  catch (error) {
153
- warnings.push(`YFM transformation failed: ${error instanceof Error ? error.message : String(error)}`);
360
+ warnings.push(`YFM transformation failed: ${stripAnsi(error instanceof Error ? error.message : String(error))}`);
154
361
  return `<pre data-ywtc-fallback><code>${escapeHtml(content)}</code></pre>`;
155
362
  }
156
363
  }
@@ -160,7 +367,9 @@ function metrics(html) {
160
367
  }
161
368
  export function normalizePageContent(page) {
162
369
  const warnings = [];
163
- const html = page.rawFormat === 'yfm' ? yfmToHtml(page.rawContent, warnings) : storageToSemanticHtml(page.rawContent, warnings);
370
+ const normalizations = [];
371
+ const tree = page.rawFormat === 'yfm' ? extractTreeDirectives(page.rawContent) : { content: page.rawContent, directives: [] };
372
+ const html = page.rawFormat === 'yfm' ? yfmToHtml(tree.content, warnings, normalizations) : storageToSemanticHtml(page.rawContent, warnings);
164
373
  const references = extractPageReferences(page.rawContent, page.rawFormat).map((reference, index) => ({
165
374
  token: `ref-${index}`,
166
375
  kind: reference.kind,
@@ -170,6 +379,15 @@ export function normalizePageContent(page) {
170
379
  ...(reference.anchor ? { anchor: reference.anchor } : {}),
171
380
  }));
172
381
  const children = [{ type: 'html', value: html }];
382
+ children.push(...tree.directives.map((directive) => ({
383
+ type: 'tree-directive',
384
+ value: directive.raw,
385
+ tree: {
386
+ ...(directive.depth ? { depth: directive.depth } : {}),
387
+ ...(directive.sort ? { sort: directive.sort } : {}),
388
+ ...(directive.sortBy ? { sortBy: directive.sortBy } : {}),
389
+ },
390
+ })));
173
391
  if (warnings.length > 0)
174
392
  children.push({ type: 'vendor-extension', vendor: page.provider, value: page.rawContent });
175
393
  return {
@@ -178,6 +396,7 @@ export function normalizePageContent(page) {
178
396
  rawFormat: page.rawFormat,
179
397
  references,
180
398
  warnings,
399
+ normalizations,
181
400
  degraded: warnings.length > 0,
182
401
  metrics: metrics(html),
183
402
  };
@@ -248,6 +467,121 @@ export function renderedPreviewHtml(body, provider) {
248
467
  const warnings = [];
249
468
  return provider === 'confluence' ? storageToSemanticHtml(body, warnings) : yfmToHtml(body, warnings);
250
469
  }
470
+ export function legacyTransferPlaceholder(provider, page) {
471
+ return provider === 'confluence'
472
+ ? `<p><em>ywtc transfer in progress from <a href="${escapeHtml(page.url)}">${page.provider}</a>.</em></p>`
473
+ : `_ywtc transfer in progress from [${page.provider}](${page.url})._`;
474
+ }
475
+ export function isLegacyTransferPlaceholder(body, provider, page) {
476
+ return body?.trim() === legacyTransferPlaceholder(provider, page).trim();
477
+ }
478
+ function markdownFence(value) {
479
+ const longest = Math.max(0, ...[...value.matchAll(/`+/g)].map((match) => match[0].length));
480
+ return '`'.repeat(Math.max(3, longest + 1));
481
+ }
482
+ export function rawContentFallback(page, provider, reason) {
483
+ const warning = `Preferred rendering failed; complete raw source is shown instead: ${reason}`;
484
+ if (provider === 'confluence') {
485
+ const raw = page.rawContent
486
+ ? `<pre><code>${escapeHtml(page.rawContent)}</code></pre>`
487
+ : '<p><em>The source page has no body content.</em></p>';
488
+ return {
489
+ body: `<p><strong>Source content fallback</strong> · <a href="${escapeHtml(page.url)}">original page</a></p>${raw}`,
490
+ warnings: [warning],
491
+ normalizations: [],
492
+ degraded: true,
493
+ representedReferences: page.references.length,
494
+ };
495
+ }
496
+ const raw = page.rawContent
497
+ ? `${markdownFence(page.rawContent)}text\n${page.rawContent}\n${markdownFence(page.rawContent)}`
498
+ : '_The source page has no body content._';
499
+ return {
500
+ body: `**Source content fallback** · [original page](${page.url})\n\n${raw}\n`,
501
+ warnings: [warning],
502
+ normalizations: [],
503
+ degraded: true,
504
+ representedReferences: page.references.length,
505
+ };
506
+ }
507
+ function semanticText(value) {
508
+ return value.replace(/\u00a0/g, ' ').replace(/\s+/g, ' ').trim();
509
+ }
510
+ function semanticContent(body, provider) {
511
+ const html = renderedPreviewHtml(body, provider);
512
+ const $ = load(html, null, false);
513
+ $('script,style').remove();
514
+ $('h1,h2,h3,h4,h5,h6,p,div,span').each((_index, element) => {
515
+ const node = $(element);
516
+ if (!semanticText(node.text()) && node.find('img,table,pre,code,a[href]').length === 0)
517
+ node.remove();
518
+ });
519
+ const textValues = (selector) => $(selector).toArray().map((element) => semanticText($(element).text()));
520
+ const macroNames = [];
521
+ if (provider === 'confluence') {
522
+ const storage = load(`<root>${body}</root>`, { xmlMode: true }, false);
523
+ storage('ac\\:structured-macro').each((_index, element) => {
524
+ const macro = storage(element);
525
+ const name = macro.attr('ac:name') ?? 'unknown';
526
+ const parameters = macro.children('ac\\:parameter').toArray().map((parameter) => (`${storage(parameter).attr('ac:name') ?? ''}=${semanticText(storage(parameter).text())}`)).sort();
527
+ macroNames.push(`${name}[${parameters.join(',')}]`);
528
+ });
529
+ }
530
+ return {
531
+ text: semanticText($.root().text()),
532
+ headings: $('h1,h2,h3,h4,h5,h6').toArray().map((element) => `${element.tagName}:${semanticText($(element).text())}`),
533
+ listItems: textValues('li'),
534
+ tableCells: $('th,td').toArray().map((element) => `${element.tagName}:${semanticText($(element).text())}`),
535
+ code: textValues('pre,code'),
536
+ links: $('a[href]').toArray().map((element) => `${$(element).attr('href') ?? ''}|${semanticText($(element).text())}`).sort(),
537
+ images: $('img').toArray().map((element) => `${$(element).attr('src') ?? ''}|${$(element).attr('alt') ?? ''}`).sort(),
538
+ anchors: $('[id],a[name]').toArray().map((element) => $(element).attr('id') ?? $(element).attr('name') ?? '').filter(Boolean).sort(),
539
+ structure: {
540
+ lists: $('ul,ol').length,
541
+ rows: $('tr').length,
542
+ details: $('details').length,
543
+ asides: $('aside').length,
544
+ codeBlocks: $('pre').length,
545
+ },
546
+ macros: macroNames.sort(),
547
+ };
548
+ }
549
+ export function semanticContentFingerprint(body, provider) {
550
+ return sha256(JSON.stringify(semanticContent(body, provider)));
551
+ }
552
+ export function verifyDestinationContent(expectedBody, actualBody, provider, page) {
553
+ if (actualBody === undefined)
554
+ return { status: 'failed', differences: [`Verification could not read destination content for ${page.title}`] };
555
+ if (actualBody.trim().length === 0)
556
+ return { status: 'failed', differences: [`Verification found empty destination content for ${page.title}`] };
557
+ if (isLegacyTransferPlaceholder(actualBody, provider, page)) {
558
+ return { status: 'failed', differences: [`Verification found a transfer placeholder instead of source content for ${page.title}`] };
559
+ }
560
+ const expected = semanticContent(expectedBody, provider);
561
+ const actual = semanticContent(actualBody, provider);
562
+ const differs = (key) => JSON.stringify(expected[key]) !== JSON.stringify(actual[key]);
563
+ const missingData = ['text', 'tableCells', 'code', 'links', 'images', 'anchors', 'macros'].filter(differs);
564
+ if (missingData.length > 0) {
565
+ return {
566
+ status: 'failed',
567
+ differences: [`Verification found destination content that differs from the rendered source for ${page.title}: ${missingData.join(', ')}`],
568
+ };
569
+ }
570
+ const simplifiedFormatting = ['headings', 'listItems', 'structure'].filter(differs);
571
+ if (simplifiedFormatting.length > 0) {
572
+ return {
573
+ status: 'partial',
574
+ differences: [`Destination formatting was simplified for ${page.title}: ${simplifiedFormatting.join(', ')}`],
575
+ };
576
+ }
577
+ return { status: 'complete', differences: [] };
578
+ }
579
+ export function assertDestinationContent(expectedBody, actualBody, provider, page) {
580
+ const result = verifyDestinationContent(expectedBody, actualBody, provider, page);
581
+ if (result.status === 'failed')
582
+ throw new Error(result.differences.join('; '));
583
+ return result;
584
+ }
251
585
  function relativeTarget(currentPath, targetPath) {
252
586
  if (!currentPath)
253
587
  return targetPath;
@@ -278,35 +612,78 @@ function rewritePortableHtml(html, context) {
278
612
  }
279
613
  return;
280
614
  }
281
- const attachmentName = href.replace(/^attachment:/, '').split('/').at(-1)?.toLocaleLowerCase();
282
- const resource = attachmentName ? context.resources.get(attachmentName) : undefined;
283
- if (resource)
284
- $(element).attr('href', 'path' in resource ? relativeTarget(context.currentPath, resource.path) : resource.url ?? resource.name);
615
+ const resource = resourceBinding(href, context);
616
+ const target = resource ? bindingTarget(resource) : undefined;
617
+ if (resource && target) {
618
+ $(element).attr('href', resource.kind === 'file' ? relativeTarget(context.currentPath, target) : target);
619
+ }
285
620
  });
286
621
  $('img').each((_index, element) => {
287
622
  const src = $(element).attr('src') ?? '';
288
- const name = src.replace(/^attachment:/, '').split('/').at(-1);
289
- if (!name)
290
- return;
291
- const resource = context.resources.get(name.toLocaleLowerCase());
292
- if (resource && 'path' in resource)
293
- $(element).attr('src', relativeTarget(context.currentPath, resource.path));
623
+ const resource = resourceBinding(src, context);
624
+ const target = resource ? bindingTarget(resource) : undefined;
625
+ if (resource && target) {
626
+ $(element).attr('src', resource.kind === 'file' ? relativeTarget(context.currentPath, target) : target);
627
+ }
294
628
  });
295
629
  return $.html();
296
630
  }
631
+ function treeDirectiveNodes(document) {
632
+ return document.root.children?.filter((node) => node.type === 'tree-directive') ?? [];
633
+ }
634
+ function treeChildren(context, parentId, directive, depth = 1) {
635
+ if (directive.depth !== undefined && depth > directive.depth)
636
+ return [];
637
+ return [...context.sourcePages.values()]
638
+ .filter((page) => page.projectedParentId === parentId)
639
+ .sort((left, right) => {
640
+ const order = directive.sortBy === 'title' ? left.title.localeCompare(right.title) : left.canonicalId.localeCompare(right.canonicalId);
641
+ return directive.sort === 'desc' ? -order : order;
642
+ });
643
+ }
644
+ function renderTreeMarkdown(context, directive) {
645
+ const render = (parentId, depth) => treeChildren(context, parentId, directive, depth).map((page) => {
646
+ const destination = context.pages.get(page.canonicalId);
647
+ if (!destination)
648
+ return '';
649
+ const href = 'path' in destination ? relativeTarget(context.currentPath, destination.path) : destination.url;
650
+ const nested = render(page.canonicalId, depth + 1);
651
+ return `${' '.repeat(depth - 1)}- [${page.title}](${href})${nested ? `\n${nested}` : ''}`;
652
+ }).filter(Boolean).join('\n');
653
+ return render(context.currentPageId, 1);
654
+ }
655
+ function renderTreeHtml(context, directive) {
656
+ const render = (parentId, depth) => {
657
+ const items = treeChildren(context, parentId, directive, depth).map((page) => {
658
+ const destination = context.pages.get(page.canonicalId);
659
+ if (!destination)
660
+ return '';
661
+ const href = 'path' in destination ? relativeTarget(context.currentPath, destination.path) : destination.url;
662
+ return `<li><a href="${escapeHtml(href)}">${escapeHtml(page.title)}</a>${render(page.canonicalId, depth + 1)}</li>`;
663
+ }).filter(Boolean).join('');
664
+ return items ? `<ul>${items}</ul>` : '';
665
+ };
666
+ const tree = render(context.currentPageId, 1);
667
+ return tree ? `<nav class="ywtc-tree" aria-label="Child pages">${tree}</nav>` : '';
668
+ }
297
669
  export async function renderMarkdown(document, context) {
298
670
  const html = rewritePortableHtml(htmlFromDocument(document), context);
299
- const body = await htmlToMarkdown(html);
300
- return { body, warnings: [...document.warnings], degraded: document.degraded, representedReferences: document.references.length };
671
+ const tree = treeDirectiveNodes(document).map((node) => renderTreeMarkdown(context, node.tree ?? {})).filter(Boolean).join('\n\n');
672
+ const content = await htmlToMarkdown(html);
673
+ const body = tree ? `${content.trimEnd()}\n\n${tree}\n` : content;
674
+ return { body, warnings: [...document.warnings], normalizations: [...(document.normalizations ?? [])], degraded: document.degraded, representedReferences: document.references.length };
301
675
  }
302
676
  export async function renderStaticHtml(document, context) {
303
677
  const rewritten = rewritePortableHtml(htmlFromDocument(document), context);
304
- const body = await sanitizeHtml(rewritten);
305
- return { body, warnings: [...document.warnings], degraded: document.degraded, representedReferences: document.references.length };
678
+ const content = await sanitizeHtml(rewritten);
679
+ const tree = treeDirectiveNodes(document).map((node) => renderTreeHtml(context, node.tree ?? {})).filter(Boolean).join('');
680
+ const body = `${content}${tree}`;
681
+ return { body, warnings: [...document.warnings], normalizations: [...(document.normalizations ?? [])], degraded: document.degraded, representedReferences: document.references.length };
306
682
  }
307
683
  export async function renderYfm(document, context) {
308
684
  if (document.rawFormat === 'yfm') {
309
685
  let body = document.raw;
686
+ const resourceWarnings = new Set();
310
687
  const replacement = (href) => {
311
688
  const current = context.sourcePages.get(context.currentPageId);
312
689
  if (!current)
@@ -319,7 +696,13 @@ export async function renderYfm(document, context) {
319
696
  };
320
697
  body = body.replace(/(!?\[[^\]]*\]\()([^\s)]+)([^)]*\))/g, (match, prefix, href, suffix) => {
321
698
  const next = replacement(href);
322
- return next ? `${prefix}${next}${suffix}` : match;
699
+ if (next)
700
+ return `${prefix}${next}${suffix}`;
701
+ const binding = resourceBinding(href, context);
702
+ const target = binding ? bindingTarget(binding) : undefined;
703
+ if (binding?.kind === 'source')
704
+ resourceWarnings.add(`Resource uses its source fallback because it was not transferred: ${binding.resource.name}`);
705
+ return target ? `${prefix}${target}${suffix}` : match;
323
706
  });
324
707
  body = body.replace(/\[\[([^\]|]+)(\|[^\]]+)?\]\]/g, (match, href, label = '') => {
325
708
  const next = replacement(href);
@@ -334,14 +717,78 @@ export async function renderYfm(document, context) {
334
717
  const gridHtml = $('.ywtc-grid').toArray().map((element) => $.html(element)).join('');
335
718
  if (gridHtml)
336
719
  body = `${body.trimEnd()}\n\n${(await htmlToMarkdown(gridHtml)).trimStart()}`;
337
- return { body, warnings: [...document.warnings], degraded: document.degraded, representedReferences: document.references.length };
720
+ return {
721
+ body,
722
+ warnings: [...document.warnings, ...resourceWarnings],
723
+ normalizations: [...(document.normalizations ?? [])],
724
+ degraded: document.degraded || resourceWarnings.size > 0,
725
+ representedReferences: document.references.length,
726
+ };
338
727
  }
339
728
  const body = await htmlToMarkdown(rewritePortableHtml(htmlFromDocument(document), context));
340
- return { body, warnings: [...document.warnings, 'Confluence Storage Format was converted to YFM-compatible Markdown'], degraded: document.degraded, representedReferences: document.references.length };
729
+ return { body, warnings: [...document.warnings, 'Confluence Storage Format was converted to YFM-compatible Markdown'], normalizations: [...(document.normalizations ?? [])], degraded: document.degraded, representedReferences: document.references.length };
341
730
  }
342
731
  export async function renderConfluenceStorage(document, page, context, spaceKey) {
343
732
  if (document.rawFormat === 'confluence-storage') {
344
733
  const $ = load(`<main data-root>${document.raw}</main>`, { xmlMode: true }, false);
734
+ const warnings = [...document.warnings];
735
+ $('ac\\:image').each((_index, element) => {
736
+ const image = $(element);
737
+ const attachment = image.children('ri\\:attachment');
738
+ const filename = attachment.attr('ri:filename');
739
+ if (!filename)
740
+ return;
741
+ const binding = resourceBinding(filename, context);
742
+ if (!binding)
743
+ return;
744
+ if (binding.kind === 'uploaded') {
745
+ attachment.attr('ri:filename', binding.resource.name);
746
+ return;
747
+ }
748
+ const target = bindingTarget(binding);
749
+ if (!target)
750
+ return;
751
+ warnings.push(`Image uses its source fallback because it was not transferred: ${filename}`);
752
+ attachment.replaceWith(`<ri:url ri:value="${escapeHtml(target)}"/>`);
753
+ });
754
+ $('ac\\:link').each((_index, element) => {
755
+ const link = $(element);
756
+ const attachment = link.children('ri\\:attachment');
757
+ const filename = attachment.attr('ri:filename');
758
+ if (!filename)
759
+ return;
760
+ const binding = resourceBinding(filename, context);
761
+ if (!binding)
762
+ return;
763
+ if (binding.kind === 'uploaded') {
764
+ attachment.attr('ri:filename', binding.resource.name);
765
+ return;
766
+ }
767
+ const target = bindingTarget(binding);
768
+ if (!target)
769
+ return;
770
+ const label = link.children('ac\\:link-body, ac\\:plain-text-link-body').text().trim() || filename;
771
+ warnings.push(`Attachment uses its source fallback because it was not transferred: ${filename}`);
772
+ link.replaceWith(`<a href="${escapeHtml(target)}">${escapeHtml(label)}</a>`);
773
+ });
774
+ $('ri\\:attachment').each((_index, element) => {
775
+ const attachment = $(element);
776
+ const filename = attachment.attr('ri:filename');
777
+ if (!filename)
778
+ return;
779
+ const binding = resourceBinding(filename, context);
780
+ if (!binding)
781
+ return;
782
+ if (binding.kind === 'uploaded') {
783
+ attachment.attr('ri:filename', binding.resource.name);
784
+ return;
785
+ }
786
+ const target = bindingTarget(binding);
787
+ if (!target)
788
+ return;
789
+ warnings.push(`Attachment uses its source fallback because it was not transferred: ${filename}`);
790
+ attachment.replaceWith(`<a href="${escapeHtml(target)}">${escapeHtml(filename)}</a>`);
791
+ });
345
792
  $('ri\\:page').each((_index, element) => {
346
793
  const title = $(element).attr('ri:content-title');
347
794
  const id = $(element).attr('ri:content-id');
@@ -358,8 +805,9 @@ export async function renderConfluenceStorage(document, page, context, spaceKey)
358
805
  });
359
806
  return {
360
807
  body: $('main[data-root]').html() ?? '<p></p>',
361
- warnings: [...document.warnings],
362
- degraded: document.degraded,
808
+ warnings,
809
+ normalizations: [...(document.normalizations ?? [])],
810
+ degraded: document.degraded || warnings.length > document.warnings.length,
363
811
  representedReferences: document.references.length,
364
812
  };
365
813
  }
@@ -376,21 +824,61 @@ export async function renderConfluenceStorage(document, page, context, spaceKey)
376
824
  });
377
825
  }
378
826
  const attachments = new Map();
379
- for (const [name, resource] of context.resources)
380
- attachments.set(name, resource.name);
381
- const converted = convertYfmToStorage(document.raw, {
827
+ const resourceFallbacks = new Map();
828
+ for (const [name, binding] of context.resources) {
829
+ if (binding.kind === 'uploaded')
830
+ attachments.set(name, binding.resource.name);
831
+ else if (binding.kind === 'source' && binding.url)
832
+ resourceFallbacks.set(name, binding.url);
833
+ }
834
+ const tree = extractTreeDirectives(document.raw);
835
+ const converted = convertYfmToStorage(tree.content, {
382
836
  currentSlug: page.locator,
383
837
  spaceKey,
384
838
  wikiWebUrl: new URL(page.url).origin,
385
839
  pagesBySlug,
386
840
  attachmentsByReference: attachments,
841
+ resourceFallbacksByReference: resourceFallbacks,
387
842
  });
843
+ const normalizedStorage = load(`<main data-root>${converted.storage}</main>`, { xmlMode: true }, false);
844
+ let removedHeadings = 0;
845
+ normalizedStorage('h1,h2,h3,h4,h5,h6').each((_index, element) => {
846
+ const heading = normalizedStorage(element);
847
+ if (semanticText(heading.text()) || heading.find('img,table,pre,code').length > 0)
848
+ return;
849
+ heading.find('[id],a[name]').each((_anchorIndex, anchor) => { heading.before(normalizedStorage(anchor).clone()); });
850
+ heading.remove();
851
+ removedHeadings += 1;
852
+ });
853
+ let ignorableHeaderWarnings = removedHeadings;
854
+ const conversionWarnings = converted.warnings.map(stripAnsi).filter((warning) => {
855
+ if (/Header without title/i.test(warning)) {
856
+ if (ignorableHeaderWarnings > 0)
857
+ ignorableHeaderWarnings -= 1;
858
+ if (removedHeadings > 0 || !containsEmptyYfmHeading(tree.content))
859
+ return false;
860
+ }
861
+ return Boolean(warning.trim());
862
+ });
863
+ const normalizations = [...(document.normalizations ?? [])];
864
+ if (removedHeadings > 0 && !normalizations.some((item) => /empty heading/i.test(item))) {
865
+ normalizations.push(`Removed ${removedHeadings} empty heading${removedHeadings === 1 ? '' : 's'} emitted by source conversion`);
866
+ }
867
+ const treeStorage = tree.directives.map((directive) => {
868
+ const parameters = [
869
+ directive.depth ? `<ac:parameter ac:name="depth">${directive.depth}</ac:parameter>` : '',
870
+ directive.sortBy ? `<ac:parameter ac:name="sort">${directive.sortBy}</ac:parameter>` : '',
871
+ directive.sort ? `<ac:parameter ac:name="reverse">${directive.sort === 'desc' ? 'true' : 'false'}</ac:parameter>` : '',
872
+ ].join('');
873
+ return `<ac:structured-macro ac:name="children">${parameters}</ac:structured-macro>`;
874
+ }).join('');
388
875
  const $ = load(htmlFromDocument(document), null, false);
389
876
  const gridStorage = $('.ywtc-grid').toArray().map((element) => $(element).html() ?? '').join('');
390
877
  return {
391
- body: `${converted.storage}${gridStorage}`,
392
- warnings: [...document.warnings, ...converted.warnings],
393
- degraded: document.degraded || converted.warnings.length > 0,
878
+ body: `${normalizedStorage('main[data-root]').html() ?? '<p></p>'}${treeStorage}${gridStorage}`,
879
+ warnings: [...document.warnings, ...conversionWarnings],
880
+ normalizations,
881
+ degraded: document.degraded || conversionWarnings.length > 0,
394
882
  representedReferences: document.references.length,
395
883
  };
396
884
  }