@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.
- package/CHANGELOG.md +30 -0
- package/README.md +25 -7
- package/dist/adapters/confluence.d.ts +6 -0
- package/dist/adapters/confluence.js +28 -3
- package/dist/adapters/confluence.js.map +1 -1
- package/dist/adapters/yandex.d.ts +6 -0
- package/dist/adapters/yandex.js +32 -5
- package/dist/adapters/yandex.js.map +1 -1
- package/dist/cli-arguments.d.ts +11 -2
- package/dist/cli-arguments.js +20 -1
- package/dist/cli-arguments.js.map +1 -1
- package/dist/cli.js +58 -1
- package/dist/cli.js.map +1 -1
- package/dist/consistency.d.ts +11 -0
- package/dist/consistency.js +38 -0
- package/dist/consistency.js.map +1 -0
- package/dist/content-model.d.ts +16 -1
- package/dist/content-model.js +518 -30
- package/dist/content-model.js.map +1 -1
- package/dist/conversion.d.ts +1 -0
- package/dist/conversion.js +17 -0
- package/dist/conversion.js.map +1 -1
- package/dist/domain.d.ts +39 -5
- package/dist/domain.js.map +1 -1
- package/dist/filesystem-export.js +6 -5
- package/dist/filesystem-export.js.map +1 -1
- package/dist/interactive-init.js +12 -8
- package/dist/interactive-init.js.map +1 -1
- package/dist/issues.d.ts +23 -0
- package/dist/issues.js +31 -0
- package/dist/issues.js.map +1 -0
- package/dist/migrator.d.ts +8 -1
- package/dist/migrator.js +557 -166
- package/dist/migrator.js.map +1 -1
- package/dist/report-catalog.d.ts +67 -0
- package/dist/report-catalog.js +119 -0
- package/dist/report-catalog.js.map +1 -0
- package/dist/report.d.ts +18 -2
- package/dist/report.js +36 -7
- package/dist/report.js.map +1 -1
- package/dist/resource-reconciliation.d.ts +21 -0
- package/dist/resource-reconciliation.js +72 -0
- package/dist/resource-reconciliation.js.map +1 -0
- package/dist/state.d.ts +3 -0
- package/dist/state.js +3 -0
- package/dist/state.js.map +1 -1
- package/package.json +2 -1
package/dist/content-model.js
CHANGED
|
@@ -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('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"');
|
|
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
|
|
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
|
-
|
|
150
|
-
|
|
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
|
|
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
|
|
282
|
-
const
|
|
283
|
-
if (resource)
|
|
284
|
-
$(element).attr('href', '
|
|
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
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
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
|
|
300
|
-
|
|
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
|
|
305
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
|
362
|
-
|
|
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
|
-
|
|
380
|
-
|
|
381
|
-
|
|
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: `${
|
|
392
|
-
warnings: [...document.warnings, ...
|
|
393
|
-
|
|
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
|
}
|