@iwonz/ywtc 3.2.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.
@@ -30,11 +30,97 @@ function absoluteResourceUrl(page, resource) {
30
30
  }
31
31
  return page.url;
32
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
+ }
33
92
  export function sourceResourceBindings(page, resources) {
34
- return new Map(resources.filter((resource) => resource.kind !== 'grid').map((resource) => [
35
- resource.name.toLocaleLowerCase(),
36
- { kind: 'source', resource, url: absoluteResourceUrl(page, resource) },
37
- ]));
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
+ }
38
124
  }
39
125
  function bindingTarget(binding) {
40
126
  return binding.kind === 'uploaded' ? binding.resource.url ?? binding.resource.name
@@ -42,13 +128,12 @@ function bindingTarget(binding) {
42
128
  : binding.path;
43
129
  }
44
130
  function resourceBinding(reference, context) {
45
- const withoutFragment = reference.split(/[?#]/, 1)[0] ?? reference;
46
- const normalized = withoutFragment.replace(/^attachment:/, '').replace(/^\.\//, '').replace(/^\/+/, '').toLocaleLowerCase();
47
- const direct = context.resources.get(normalized);
48
- if (direct)
49
- return direct;
50
- const basename = normalized.replace(/\\/g, '/').split('/').at(-1);
51
- return basename ? context.resources.get(basename) : undefined;
131
+ for (const alias of resourceAliasCandidates(reference)) {
132
+ const binding = context.resources.get(alias);
133
+ if (binding)
134
+ return binding;
135
+ }
136
+ return undefined;
52
137
  }
53
138
  function uniqueReferences(references) {
54
139
  const seen = new Set();
@@ -172,7 +257,69 @@ function storageToSemanticHtml(storage, warnings) {
172
257
  });
173
258
  return $('main[data-ywtc-root]').html() ?? '';
174
259
  }
175
- 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 = []) {
176
323
  try {
177
324
  const result = transform(content || '', {
178
325
  allowHTML: true,
@@ -181,11 +328,36 @@ function yfmToHtml(content, warnings) {
181
328
  lang: 'ru',
182
329
  linkify: true,
183
330
  });
184
- warnings.push(...(result.logs.warn ?? []), ...(result.logs.error ?? []));
185
- 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>';
186
358
  }
187
359
  catch (error) {
188
- 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))}`);
189
361
  return `<pre data-ywtc-fallback><code>${escapeHtml(content)}</code></pre>`;
190
362
  }
191
363
  }
@@ -195,7 +367,9 @@ function metrics(html) {
195
367
  }
196
368
  export function normalizePageContent(page) {
197
369
  const warnings = [];
198
- 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);
199
373
  const references = extractPageReferences(page.rawContent, page.rawFormat).map((reference, index) => ({
200
374
  token: `ref-${index}`,
201
375
  kind: reference.kind,
@@ -205,6 +379,15 @@ export function normalizePageContent(page) {
205
379
  ...(reference.anchor ? { anchor: reference.anchor } : {}),
206
380
  }));
207
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
+ })));
208
391
  if (warnings.length > 0)
209
392
  children.push({ type: 'vendor-extension', vendor: page.provider, value: page.rawContent });
210
393
  return {
@@ -213,6 +396,7 @@ export function normalizePageContent(page) {
213
396
  rawFormat: page.rawFormat,
214
397
  references,
215
398
  warnings,
399
+ normalizations,
216
400
  degraded: warnings.length > 0,
217
401
  metrics: metrics(html),
218
402
  };
@@ -304,6 +488,7 @@ export function rawContentFallback(page, provider, reason) {
304
488
  return {
305
489
  body: `<p><strong>Source content fallback</strong> · <a href="${escapeHtml(page.url)}">original page</a></p>${raw}`,
306
490
  warnings: [warning],
491
+ normalizations: [],
307
492
  degraded: true,
308
493
  representedReferences: page.references.length,
309
494
  };
@@ -314,35 +499,88 @@ export function rawContentFallback(page, provider, reason) {
314
499
  return {
315
500
  body: `**Source content fallback** · [original page](${page.url})\n\n${raw}\n`,
316
501
  warnings: [warning],
502
+ normalizations: [],
317
503
  degraded: true,
318
504
  representedReferences: page.references.length,
319
505
  };
320
506
  }
321
- export function semanticContentFingerprint(body, provider) {
507
+ function semanticText(value) {
508
+ return value.replace(/\u00a0/g, ' ').replace(/\s+/g, ' ').trim();
509
+ }
510
+ function semanticContent(body, provider) {
322
511
  const html = renderedPreviewHtml(body, provider);
323
512
  const $ = load(html, null, false);
324
- $('script, style').remove();
325
- const tokens = $('*').toArray().map((element) => {
513
+ $('script,style').remove();
514
+ $('h1,h2,h3,h4,h5,h6,p,div,span').each((_index, element) => {
326
515
  const node = $(element);
327
- const directText = node.clone().children().remove().end().text().replace(/\s+/g, ' ').trim();
328
- const attributes = ['href', 'src', 'id', 'class'].map((name) => node.attr(name) ? `${name}=${node.attr(name)}` : '').filter(Boolean);
329
- const tagName = 'name' in element ? String(element.name) : element.type;
330
- return `${tagName.toLocaleLowerCase()}[${attributes.join(',')}](${directText})`;
516
+ if (!semanticText(node.text()) && node.find('img,table,pre,code,a[href]').length === 0)
517
+ node.remove();
331
518
  });
332
- const text = $.root().text().replace(/\s+/g, ' ').trim();
333
- return sha256(JSON.stringify({ text, tokens }));
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
+ };
334
548
  }
335
- export function assertDestinationContent(expectedBody, actualBody, provider, page) {
549
+ export function semanticContentFingerprint(body, provider) {
550
+ return sha256(JSON.stringify(semanticContent(body, provider)));
551
+ }
552
+ export function verifyDestinationContent(expectedBody, actualBody, provider, page) {
336
553
  if (actualBody === undefined)
337
- throw new Error(`Verification could not read destination content for ${page.title}`);
554
+ return { status: 'failed', differences: [`Verification could not read destination content for ${page.title}`] };
338
555
  if (actualBody.trim().length === 0)
339
- throw new Error(`Verification found empty destination content for ${page.title}`);
556
+ return { status: 'failed', differences: [`Verification found empty destination content for ${page.title}`] };
340
557
  if (isLegacyTransferPlaceholder(actualBody, provider, page)) {
341
- throw new Error(`Verification found a transfer placeholder instead of source content for ${page.title}`);
558
+ return { status: 'failed', differences: [`Verification found a transfer placeholder instead of source content for ${page.title}`] };
342
559
  }
343
- if (semanticContentFingerprint(expectedBody, provider) !== semanticContentFingerprint(actualBody, provider)) {
344
- throw new Error(`Verification found destination content that differs from the rendered source for ${page.title}`);
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
+ };
345
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;
346
584
  }
347
585
  function relativeTarget(currentPath, targetPath) {
348
586
  if (!currentPath)
@@ -390,15 +628,57 @@ function rewritePortableHtml(html, context) {
390
628
  });
391
629
  return $.html();
392
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
+ }
393
669
  export async function renderMarkdown(document, context) {
394
670
  const html = rewritePortableHtml(htmlFromDocument(document), context);
395
- const body = await htmlToMarkdown(html);
396
- 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 };
397
675
  }
398
676
  export async function renderStaticHtml(document, context) {
399
677
  const rewritten = rewritePortableHtml(htmlFromDocument(document), context);
400
- const body = await sanitizeHtml(rewritten);
401
- 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 };
402
682
  }
403
683
  export async function renderYfm(document, context) {
404
684
  if (document.rawFormat === 'yfm') {
@@ -440,12 +720,13 @@ export async function renderYfm(document, context) {
440
720
  return {
441
721
  body,
442
722
  warnings: [...document.warnings, ...resourceWarnings],
723
+ normalizations: [...(document.normalizations ?? [])],
443
724
  degraded: document.degraded || resourceWarnings.size > 0,
444
725
  representedReferences: document.references.length,
445
726
  };
446
727
  }
447
728
  const body = await htmlToMarkdown(rewritePortableHtml(htmlFromDocument(document), context));
448
- 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 };
449
730
  }
450
731
  export async function renderConfluenceStorage(document, page, context, spaceKey) {
451
732
  if (document.rawFormat === 'confluence-storage') {
@@ -525,6 +806,7 @@ export async function renderConfluenceStorage(document, page, context, spaceKey)
525
806
  return {
526
807
  body: $('main[data-root]').html() ?? '<p></p>',
527
808
  warnings,
809
+ normalizations: [...(document.normalizations ?? [])],
528
810
  degraded: document.degraded || warnings.length > document.warnings.length,
529
811
  representedReferences: document.references.length,
530
812
  };
@@ -549,7 +831,8 @@ export async function renderConfluenceStorage(document, page, context, spaceKey)
549
831
  else if (binding.kind === 'source' && binding.url)
550
832
  resourceFallbacks.set(name, binding.url);
551
833
  }
552
- const converted = convertYfmToStorage(document.raw, {
834
+ const tree = extractTreeDirectives(document.raw);
835
+ const converted = convertYfmToStorage(tree.content, {
553
836
  currentSlug: page.locator,
554
837
  spaceKey,
555
838
  wikiWebUrl: new URL(page.url).origin,
@@ -557,12 +840,45 @@ export async function renderConfluenceStorage(document, page, context, spaceKey)
557
840
  attachmentsByReference: attachments,
558
841
  resourceFallbacksByReference: resourceFallbacks,
559
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('');
560
875
  const $ = load(htmlFromDocument(document), null, false);
561
876
  const gridStorage = $('.ywtc-grid').toArray().map((element) => $(element).html() ?? '').join('');
562
877
  return {
563
- body: `${converted.storage}${gridStorage}`,
564
- warnings: [...document.warnings, ...converted.warnings],
565
- 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,
566
882
  representedReferences: document.references.length,
567
883
  };
568
884
  }