umberto 9.0.0 → 9.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/CHANGELOG.md +14 -9
  2. package/package.json +8 -13
  3. package/scripts/filter/after-post-render/fix-code-samples.js +82 -18
  4. package/scripts/filter/after-post-render/gloria.js +27 -0
  5. package/scripts/filter/after-post-render/insert-error-codes.js +34 -26
  6. package/scripts/filter/after-post-render/validate-after-render.js +27 -6
  7. package/scripts/filter/after-render/process-svg.js +21 -0
  8. package/scripts/filter/before-post-render/gloria/render-post-render-pug-components.js +46 -18
  9. package/scripts/helper/u-extract-and-cache-title.js +27 -8
  10. package/scripts/helper/u-split-to-title-and-content.js +32 -8
  11. package/scripts/utils/gloria-after-post-render/append-copy-heading-buttons.js +119 -0
  12. package/scripts/utils/gloria-after-post-render/apply-design-doc-classes.js +157 -0
  13. package/scripts/utils/gloria-after-post-render/wrap-table-into-wrappers.js +25 -0
  14. package/scripts/utils/inline-svg.js +63 -94
  15. package/scripts/utils/spritesheet-svg.js +82 -102
  16. package/scripts/utils/toc.js +85 -31
  17. package/src/api-builder/api-builder.js +53 -40
  18. package/src/api-builder/build-page-worker.js +35 -0
  19. package/src/api-builder/classes/description-parser.js +77 -38
  20. package/src/data-converter/converters/jsduck2umberto.js +43 -15
  21. package/src/hexo/filter/project-locals.js +3 -0
  22. package/src/sdk-builder/get-sdk-sources.js +81 -44
  23. package/src/tasks/build-documentation.js +4 -0
  24. package/src/tasks/minify-html.js +1 -1
  25. package/src/tasks/validate-links-collect-worker.js +34 -0
  26. package/src/tasks/validate-links-worker.js +127 -0
  27. package/src/tasks/validate-links.js +61 -259
  28. package/themes/umberto/layout/gloria/_head/head.pug +3 -0
  29. package/themes/umberto/layout/gloria/_modules/index.pug +1 -0
  30. package/themes/umberto/layout/gloria/_modules/kapa/index.pug +0 -1
  31. package/themes/umberto/layout/gloria/_modules/sentry/index.pug +27 -0
  32. package/scripts/filter/after-post-render/gloria/append-copy-heading-buttons.js +0 -90
  33. package/scripts/filter/after-post-render/gloria/apply-design-doc-classes.js +0 -96
  34. package/scripts/filter/after-post-render/gloria/wrap-table-into-wrappers.js +0 -36
  35. package/scripts/filter/after-render/gloria/inline-svg.js +0 -14
  36. package/scripts/filter/after-render/gloria/spritesheet-svg.js +0 -14
  37. package/scripts/utils/apply-design-doc-classes.js +0 -82
  38. /package/src/tasks/{minify-worker.js → minify-html-worker.js} +0 -0
@@ -6,7 +6,10 @@
6
6
  'use strict';
7
7
 
8
8
  const { styleText } = require( 'util' );
9
- const cheerio = require( 'cheerio' );
9
+ const { parseDocument } = require( 'htmlparser2' );
10
+ const { default: render } = require( 'dom-serializer' );
11
+ const { selectAll, selectOne } = require( 'css-select' );
12
+ const { replaceElement, removeElement, appendChild } = require( 'domutils' );
10
13
  const splitLongname = require( '../../helpers/split-longname' );
11
14
  const macroReplacer = require( '../../tasks/macro-replacer' );
12
15
  const findTargetDoclet = require( '../utils/findtargetdoclet' );
@@ -72,27 +75,29 @@ module.exports = class DescriptionParser {
72
75
  * @returns {Object}.content Full description.
73
76
  */
74
77
  _splitToExcerptAndContent( str, { withExcerpt = false } = {} ) {
75
- if ( withExcerpt ) {
76
- if ( !str.startsWith( '<p>' ) ) {
77
- return {
78
- excerpt: '',
79
- content: str
80
- };
81
- }
82
-
83
- const $ = cheerio.load( str, null, false );
84
- const excerpt = $( 'p' ).first().remove().html();
85
- const content = $.html();
86
-
78
+ if ( !withExcerpt ) {
87
79
  return {
88
- excerpt,
89
- content
80
+ content: str
90
81
  };
91
- } else {
82
+ }
83
+
84
+ if ( !str.startsWith( '<p>' ) ) {
92
85
  return {
86
+ excerpt: '',
93
87
  content: str
94
88
  };
95
89
  }
90
+
91
+ const doc = parseDocument( str );
92
+ const firstParagraph = selectOne( 'p', doc );
93
+ const excerpt = render( firstParagraph.children || [] );
94
+
95
+ removeElement( firstParagraph );
96
+
97
+ return {
98
+ excerpt,
99
+ content: render( doc )
100
+ };
96
101
  }
97
102
 
98
103
  /**
@@ -361,16 +366,16 @@ module.exports = class DescriptionParser {
361
366
  return data;
362
367
  }
363
368
 
364
- const $ = cheerio.load( data, null, false );
369
+ const doc = parseDocument( data );
365
370
 
366
- $( ':header' ).replaceWith( ( i, item ) => {
367
- const headerLevel = Number( item.name.slice( -1 ) );
371
+ for ( const header of selectAll( 'h1,h2,h3,h4,h5,h6', doc ) ) {
372
+ const headerLevel = Number( header.name.slice( -1 ) );
368
373
  const newHeaderLevel = headerLevel + decreaseLevel <= 6 ? headerLevel + decreaseLevel : 6;
369
374
 
370
- return cheerio.load( `<h${ newHeaderLevel }></h${ newHeaderLevel }>` )( `h${ newHeaderLevel }` ).append( $( item ).html() );
371
- } );
375
+ header.name = `h${ newHeaderLevel }`;
376
+ }
372
377
 
373
- return $.html();
378
+ return render( doc );
374
379
  }
375
380
 
376
381
  /**
@@ -383,36 +388,38 @@ module.exports = class DescriptionParser {
383
388
  return str;
384
389
  }
385
390
 
386
- const $ = cheerio.load( str, null, false );
391
+ const doc = parseDocument( str );
387
392
  const theme = options.theme;
388
393
 
389
394
  if ( theme === 'gloria' ) {
390
- $( 'pre' ).each( function() {
391
- const $code = $( this ).find( 'code' );
395
+ for ( const pre of selectAll( 'pre', doc ) ) {
396
+ const codeEl = selectOne( 'code', pre );
392
397
 
393
- if ( !$code || !$code.length ) {
394
- return;
398
+ if ( !codeEl ) {
399
+ continue;
395
400
  }
396
401
 
397
402
  // Get the language from the class (e.g., "language-js" -> "js")
398
- const language = $code.attr( 'class' ) ?
399
- $code.attr( 'class' ).replace( 'doc', '' ).replace( 'language-', '' ).replace( 'ts', 'typescript' ).trim() :
403
+ const classAttr = codeEl.attribs && codeEl.attribs.class ? codeEl.attribs.class : null;
404
+ const language = classAttr ?
405
+ classAttr.replace( 'doc', '' ).replace( 'language-', '' ).replace( 'ts', 'typescript' ).trim() :
400
406
  null;
401
407
 
402
408
  // Get the code content
403
- const code = $code.html();
409
+ const code = render( codeEl.children || [] );
404
410
 
405
- // Use the render-pug-component utility
411
+ // Use the render-pug-component utility.
406
412
  const html = renderCodeBlockPug( {
407
413
  language,
408
414
  code
409
415
  } );
410
416
 
411
- $( this ).replaceWith( html );
412
- } );
417
+ replaceElement( pre, parseFirstElement( html ) );
418
+ }
413
419
  } else {
414
- $( 'pre[class~="source"]' ).each( function() {
415
- let code = $( this ).html();
420
+ for ( const pre of selectAll( 'pre.source', doc ) ) {
421
+ let code = render( pre.children || [] );
422
+
416
423
  const begin = /<code> {4}/.exec( code );
417
424
 
418
425
  if ( begin ) {
@@ -421,11 +428,11 @@ module.exports = class DescriptionParser {
421
428
  } );
422
429
  }
423
430
 
424
- $( this ).html( code );
425
- } );
431
+ setInnerHTML( pre, code );
432
+ }
426
433
  }
427
434
 
428
- return $.html();
435
+ return render( doc );
429
436
  }
430
437
 
431
438
  _macrosReplacer( fullText ) {
@@ -442,3 +449,35 @@ function composeFunctions( ...fns ) {
442
449
  return result;
443
450
  };
444
451
  }
452
+
453
+ /**
454
+ * Helper: parses an HTML fragment and returns the first element node.
455
+ */
456
+ function parseFirstElement( html ) {
457
+ const frag = parseDocument( html );
458
+ const body = selectOne( 'body', frag );
459
+ const nodes = body ? body.children : frag.children;
460
+
461
+ for ( const node of nodes || [] ) {
462
+ if ( node && node.type === 'tag' ) {
463
+ return node;
464
+ }
465
+ }
466
+
467
+ return null;
468
+ }
469
+
470
+ /**
471
+ * Helper: sets element's inner HTML using a fragment string.
472
+ */
473
+ function setInnerHTML( element, html ) {
474
+ const frag = parseDocument( html );
475
+ const body = selectOne( 'body', frag );
476
+ const nodes = body ? body.children : frag.children;
477
+
478
+ element.children = [];
479
+
480
+ for ( const node of nodes || [] ) {
481
+ appendChild( element, node );
482
+ }
483
+ }
@@ -5,7 +5,11 @@
5
5
 
6
6
  'use strict';
7
7
 
8
- const cheerio = require( 'cheerio' );
8
+ const { parseDocument } = require( 'htmlparser2' );
9
+ const { default: render } = require( 'dom-serializer' );
10
+ const { selectAll, selectOne } = require( 'css-select' );
11
+ const { getAttributeValue, textContent, replaceElement } = require( 'domutils' );
12
+
9
13
  const keyNameMap = {
10
14
  tagname: {
11
15
  name: 'kind',
@@ -161,16 +165,22 @@ function tagnameAdapter( item ) {
161
165
 
162
166
  function docAdapter( item ) {
163
167
  const description = item.doc;
164
- const $ = cheerio.load( description, null, false );
168
+ const doc = parseDocument( description, { decodeEntities: false } );
165
169
 
166
- $( 'a' ).each( function() {
167
- const href = $( this ).attr( 'href' );
168
- const linkText = $( this ).text();
170
+ for ( const a of selectAll( 'a', doc ) ) {
171
+ const href = getAttributeValue( a, 'href' );
172
+ const linkText = textContent( a );
173
+ const original = render( a, { encodeEntities: false } );
169
174
 
170
- $( this ).replaceWith( fixLink( href, linkText, $( this ).toString() ) );
171
- } );
175
+ const replacementHtml = fixLink( href, linkText, original );
176
+ const replacementNode = parseFirstNode( replacementHtml );
172
177
 
173
- return $.html();
178
+ if ( replacementNode ) {
179
+ replaceElement( a, replacementNode );
180
+ }
181
+ }
182
+
183
+ return render( doc, { encodeEntities: false } );
174
184
  }
175
185
 
176
186
  function fixLink( href, linkText, original ) {
@@ -263,16 +273,19 @@ function overridesAdapter( item ) {
263
273
  const overrides = item.overrides || [];
264
274
 
265
275
  return overrides.map( o => {
266
- const $ = cheerio.load( o.link, null, false );
276
+ const doc = parseDocument( o.link );
267
277
 
268
- $( 'a' ).each( function() {
269
- const href = $( this ).attr( 'href' );
270
- const linkText = $( this ).text();
278
+ for ( const a of selectAll( 'a', doc ) ) {
279
+ const replacementHtml = fixLink(
280
+ getAttributeValue( a, 'href' ),
281
+ textContent( a ),
282
+ render( a )
283
+ );
271
284
 
272
- $( this ).replaceWith( fixLink( href, linkText, $( this ).toString() ) );
273
- } );
285
+ replaceElement( a, parseFirstNode( replacementHtml ) );
286
+ }
274
287
 
275
- o.link = $.html();
288
+ o.link = render( doc );
276
289
 
277
290
  return o;
278
291
  } );
@@ -300,3 +313,18 @@ function toArray( item ) {
300
313
 
301
314
  return [];
302
315
  }
316
+
317
+ /**
318
+ * Parses an HTML fragment and returns the first node (element or text).
319
+ */
320
+ function parseFirstNode( html ) {
321
+ const doc = parseDocument( html );
322
+ const body = selectOne( 'body', doc );
323
+ const nodes = body ? body.children : doc.children;
324
+
325
+ if ( nodes && nodes.length ) {
326
+ return nodes[ 0 ];
327
+ }
328
+
329
+ return null;
330
+ }
@@ -21,6 +21,7 @@ const umbertoVersion = require( '../../../package.json' ).version;
21
21
  * @param {Object} googletagmanager Google Tag Manager config.
22
22
  * @param {Object} googleanalytics Google Analytics config.
23
23
  * @param {Object} kapa Kapa.ai config.
24
+ * @param {Object} sentry Sentry config.
24
25
  * @param {Object} vwo Visual Website Optimizer config.
25
26
  * @param {Object} feedbackWidget Feedback widget config
26
27
  * @param {Array} extraStylePaths Paths to extra external css.
@@ -38,6 +39,7 @@ module.exports = ( ctx, {
38
39
  googletagmanager,
39
40
  googleanalytics,
40
41
  kapa,
42
+ sentry,
41
43
  promobar,
42
44
  vwo,
43
45
  feedbackWidget,
@@ -108,6 +110,7 @@ module.exports = ( ctx, {
108
110
  locals.googletagmanager = googletagmanager;
109
111
  locals.googleanalytics = googleanalytics;
110
112
  locals.kapa = kapa;
113
+ locals.sentry = sentry;
111
114
  locals.promobar = promobar;
112
115
  locals.vwo = vwo;
113
116
  locals.feedbackWidget = feedbackWidget;
@@ -8,7 +8,16 @@
8
8
  const fs = require( 'fs' );
9
9
  const upath = require( 'upath' );
10
10
  const { globSync } = require( 'glob' );
11
- const cheerio = require( 'cheerio' );
11
+ const { parseDocument } = require( 'htmlparser2' );
12
+ const { cloneNode, Text } = require( 'domhandler' );
13
+ const { default: render } = require( 'dom-serializer' );
14
+ const { selectAll, selectOne } = require( 'css-select' );
15
+ const {
16
+ getAttributeValue,
17
+ hasAttrib,
18
+ textContent,
19
+ getName
20
+ } = require( 'domutils' );
12
21
 
13
22
  const SDK_SELECTOR = 'meta[name="sdk-samples"]';
14
23
 
@@ -18,53 +27,66 @@ module.exports = sourcePath => {
18
27
 
19
28
  for ( const filePath of filePaths ) {
20
29
  const content = fs.readFileSync( filePath, { encoding: 'utf8' } );
21
- const $ = cheerio.load( content, null, false );
22
- const samplesNames = $( SDK_SELECTOR ).attr( 'content' ) ? $( SDK_SELECTOR ).attr( 'content' ).split( '|' ) : null;
30
+ const doc = parseDocument( content );
31
+ const sdkMeta = selectOne( SDK_SELECTOR, doc );
32
+ const samplesNames = sdkMeta && getAttributeValue( sdkMeta, 'content' ) ?
33
+ getAttributeValue( sdkMeta, 'content' ).split( '|' ) :
34
+ null;
35
+
23
36
  const samples = samplesNames ? [] : null;
24
37
  const meta = {};
25
38
 
26
39
  if ( samplesNames ) {
27
- for ( const sample of samplesNames.entries() ) {
40
+ for ( const [ index, name ] of samplesNames.entries() ) {
28
41
  const bodyItems = [];
29
42
  const headItems = [];
30
43
  const flags = {};
31
- $( `[data-sample*="${ sample[ 0 ] + 1 }"]` ).map( ( i, el ) => {
32
- const element = $( el ).clone();
44
+
45
+ for ( const el of selectAll( `[data-sample*="${ index + 1 }"]`, doc ) ) {
46
+ const element = cloneNode( el, true );
33
47
  const ret = {};
34
- const outputArray = $( el ).parents( 'head' ).length ? headItems : bodyItems;
48
+ const outputArray = isInsideHead( el ) ? headItems : bodyItems;
49
+
50
+ if ( hasAttrib( el, 'type' ) && getAttributeValue( el, 'type' ) === 'template' ) {
51
+ const source = textContent( el )
52
+ .replace( /&lt;/g, '<' )
53
+ .replace( /&gt;/g, '>' );
54
+
55
+ outputArray.push( { source } );
56
+ continue;
57
+ }
58
+
59
+ if ( hasAttrib( element, 'data-sample-short' ) ) {
60
+ element.children = [ new Text( '{%SHORT_EDITOR_CONTENT%}' ) ];
61
+ ret.short = true;
62
+ }
63
+
64
+ if (
65
+ hasAttrib( element, 'data-sample-preservewhitespace' ) ||
66
+ hasAttrib( element, 'data-sample-preserve-whitespace' )
67
+ ) {
68
+ ret.preserveWhitespace = true;
69
+ }
35
70
 
36
- if ( $( el ).is( '[type=template]' ) ) {
37
- // Template code support
38
- outputArray.push( {
39
- source: $( el ).text().replace( /&lt;/g, '<' ).replace( /&gt;/g, '>' )
40
- } );
71
+ if ( hasAttrib( element, 'data-sample-template' ) && !flags.template ) {
72
+ flags.template = getAttributeValue( element, 'data-sample-template' );
73
+ }
74
+
75
+ if ( hasAttrib( element, 'data-sample-highlighter' ) && !flags.highlighter ) {
76
+ flags.highlighter = getAttributeValue( element, 'data-sample-highlighter' );
77
+ }
78
+
79
+ if ( hasAttrib( element, 'data-sample-strip-outer-tag' ) ) {
80
+ ret.source = render( element.children || [] ).replace( /^\n|\n$/g, '' );
41
81
  } else {
42
- if ( element.is( '[data-sample-short]' ) ) {
43
- element.text( '{%SHORT_EDITOR_CONTENT%}' );
44
- ret.short = true;
45
- }
46
- if ( element.is( '[data-sample-preserveWhitespace]' ) || element.is( '[data-sample-preserve-whitespace]' ) ) {
47
- ret.preserveWhitespace = true;
48
- }
49
- if ( element.is( '[data-sample-template]' ) && !flags.template ) {
50
- flags.template = element.data( 'sample-template' );
51
- }
52
- if ( element.is( '[data-sample-highlighter]' ) && !flags.highlighter ) {
53
- flags.highlighter = element.data( 'sample-highlighter' );
54
- }
55
-
56
- if ( element.is( '[data-sample-strip-outer-tag]' ) ) {
57
- ret.source = element.html().replace( /^\n|\n$/g, '' );
58
- } else {
59
- ret.source = $.html( element ).replace( /\s?data-sample(-[\w-]+)?="[^"]*?"/g, '' ); // Strip data attributes
60
- }
61
-
62
- outputArray.push( ret );
82
+ ret.source = render( element ).replace( /\s?data-sample(-[\w-]+)?="[^"]*?"/g, '' );
63
83
  }
64
- } );
84
+
85
+ outputArray.push( ret );
86
+ }
65
87
 
66
88
  samples.push( {
67
- name: sample[ 1 ],
89
+ name,
68
90
  bodyItems,
69
91
  headItems,
70
92
  flags
@@ -72,23 +94,38 @@ module.exports = sourcePath => {
72
94
  }
73
95
  }
74
96
 
75
- $( 'meta[name^="sdk-"]' ).map( ( i, el ) => {
76
- const keyName = $( el ).attr( 'name' ).replace( 'sdk-', '' );
97
+ for ( const el of selectAll( 'meta[name^="sdk-"]', doc ) ) {
98
+ const keyName = getAttributeValue( el, 'name' ).replace( 'sdk-', '' );
77
99
 
78
- meta[ keyName ] = $( el ).attr( 'content' );
79
- } );
100
+ meta[ keyName ] = getAttributeValue( el, 'content' );
101
+ }
102
+
103
+ const sdkContents = selectOne( '.sdk-contents', doc );
104
+ const contentHtml = render( sdkContents.children || [] );
105
+ const presetVersion = getAttributeValue( sdkContents, 'data-cke-preset' );
80
106
 
81
107
  files.push( {
82
108
  name: upath.basename( filePath, '.html' ),
83
- description: $( 'meta[name="description"]' ).attr( 'content' ),
84
- title: $( 'title' ).text(),
85
- content: $( '.sdk-contents' ).html(),
86
- source: $.html(),
109
+ description: getAttributeValue( selectOne( 'meta[name="description"]', doc ), 'content' ),
110
+ title: textContent( selectOne( 'title', doc ) ),
111
+ content: contentHtml,
112
+ source: render( doc ),
87
113
  path: upath.relative( sourcePath, filePath ),
88
- presetVersion: $( '.sdk-contents' ).data( 'cke-preset' ),
114
+ presetVersion,
89
115
  sdkSamples: samples,
90
116
  meta
91
117
  } );
92
118
  }
119
+
93
120
  return files;
94
121
  };
122
+
123
+ function isInsideHead( node ) {
124
+ for ( let p = node && node.parent; p; p = p.parent ) {
125
+ if ( getName( p ) === 'head' ) {
126
+ return true;
127
+ }
128
+ }
129
+
130
+ return false;
131
+ }
@@ -146,6 +146,7 @@ module.exports = options => {
146
146
  googletagmanager: mainConfig.googletagmanager,
147
147
  googleanalytics: mainConfig.googleanalytics,
148
148
  kapa: mainConfig.kapa,
149
+ sentry: mainConfig.sentry,
149
150
  promobar: mainConfig.promobar,
150
151
  sitemap: mainConfig.sitemap,
151
152
  vwo: mainConfig.vwo,
@@ -291,6 +292,7 @@ async function buildProjects( rootPath, projectPaths, options = {} ) {
291
292
  googletagmanager: options.googletagmanager,
292
293
  googleanalytics: options.googleanalytics,
293
294
  kapa: options.kapa,
295
+ sentry: options.sentry,
294
296
  promobar: options.promobar,
295
297
  sitemap: options.sitemap,
296
298
  vwo: options.vwo,
@@ -370,6 +372,7 @@ async function buildProjects( rootPath, projectPaths, options = {} ) {
370
372
  googletagmanager: options.googletagmanager,
371
373
  googleanalytics: options.googleanalytics,
372
374
  kapa: options.kapa,
375
+ sentry: options.sentry,
373
376
  promobar: options.promobar,
374
377
  quickNavigationProjects: options.quickNavigationProjects,
375
378
  vwo: options.vwo,
@@ -477,6 +480,7 @@ async function buildApis( projectConfigs, options = {} ) {
477
480
  googletagmanager: options.googletagmanager,
478
481
  googleanalytics: options.googleanalytics,
479
482
  kapa: options.kapa,
483
+ sentry: options.sentry,
480
484
  promobar: options.promobar,
481
485
  vwo: options.vwo,
482
486
  feedbackWidget: options.feedbackWidget,
@@ -20,7 +20,7 @@ module.exports = async function minifyHtml( outputDir ) {
20
20
 
21
21
  const pool = new TinyPool( {
22
22
  runtime: 'child_process',
23
- filename: require.resolve( './minify-worker.js' )
23
+ filename: require.resolve( './minify-html-worker.js' )
24
24
  } );
25
25
 
26
26
  try {
@@ -0,0 +1,34 @@
1
+ /**
2
+ * @license Copyright (c) 2017-2025, CKSource Holding sp. z o.o. All rights reserved.
3
+ * For licensing, see LICENSE.md.
4
+ */
5
+
6
+ 'use strict';
7
+
8
+ const fs = require( 'fs-extra' );
9
+ const upath = require( 'upath' );
10
+ const { isMaskedID } = require( '../../scripts/utils/random-id' );
11
+
12
+ module.exports = function( { chunk } ) {
13
+ const links = [];
14
+
15
+ for ( const filePath of chunk ) {
16
+ links.push( upath.resolve( filePath ) );
17
+ links.push( upath.resolve( filePath + '#' ) );
18
+
19
+ const content = fs.readFileSync( filePath, 'utf-8' );
20
+ // Extract both quoted and unquoted "id" attributes.
21
+ const ids = [
22
+ ...Array.from( content.matchAll( /id="([^"]+)"/g ), m => m[ 1 ] ),
23
+ ...Array.from( content.matchAll( /id=([^\s"'/>]+)/g ), m => m[ 1 ] )
24
+ ];
25
+
26
+ for ( const id of ids ) {
27
+ if ( !isMaskedID( id ) && !ids.includes( 'icons-' ) ) {
28
+ links.push( upath.resolve( `${ filePath }#${ id }` ) );
29
+ }
30
+ }
31
+ }
32
+
33
+ return links;
34
+ };
@@ -0,0 +1,127 @@
1
+ /**
2
+ * @license Copyright (c) 2017-2025, CKSource Holding sp. z o.o. All rights reserved.
3
+ * For licensing, see LICENSE.md.
4
+ */
5
+
6
+ 'use strict';
7
+
8
+ const fs = require( 'fs-extra' );
9
+ const upath = require( 'upath' );
10
+ const { parseDocument } = require( 'htmlparser2' );
11
+
12
+ /**
13
+ * Validates links in the given chunk of files.
14
+ * Checks if links point to existing files or fragments.
15
+ * Returns a list of invalid links.
16
+ *
17
+ * @param {Object} params
18
+ * @param {string} params.chunk Array of file paths to validate.
19
+ * @param {Set<string>} params.links Set of valid links.
20
+ * @param {Object} params.options Validation options.
21
+ * @returns {Array<Object>}
22
+ */
23
+ module.exports = function( { chunk, links, options } ) {
24
+ const errors = [];
25
+
26
+ for ( const filePath of chunk ) {
27
+ const invalidHrefs = [];
28
+ const content = fs.readFileSync( filePath, 'utf-8' );
29
+ const linkElements = [];
30
+
31
+ processNode( parseDocument( content ), linkElements );
32
+
33
+ for ( let { href, text } of linkElements ) {
34
+ if ( href.endsWith( '/' ) && !href.includes( '#' ) ) {
35
+ href += 'index.html';
36
+ }
37
+
38
+ let resolvedPath = getResolvedPath( href, filePath, { publicDir: options.publicDir } );
39
+
40
+ if ( options.skipApi && resolvedPath.includes( '/api/' ) ) {
41
+ continue;
42
+ }
43
+
44
+ if ( resolvedPath.includes( '/latest/' ) ) {
45
+ const projectInfo = options.projectsInfo ?
46
+ options.projectsInfo.find( i => resolvedPath.includes( `/${ i.slug }/` ) ) :
47
+ null;
48
+ const projectVersion = projectInfo ? projectInfo.version : 'latest';
49
+ resolvedPath = resolvedPath.replace( '/latest/', `/${ projectVersion }/` );
50
+ }
51
+
52
+ if ( options.projectsInfo ) {
53
+ const isCorrectLocalPath = options.projectsInfo.some( info => {
54
+ return resolvedPath.includes( info.basePath );
55
+ } );
56
+
57
+ if ( !isCorrectLocalPath ) {
58
+ continue;
59
+ }
60
+ }
61
+
62
+ if ( !links.has( resolvedPath ) ) {
63
+ invalidHrefs.push( { href, text, filePath } );
64
+ }
65
+ }
66
+
67
+ if ( invalidHrefs.length ) {
68
+ errors.push( { filePath, invalidHrefs } );
69
+ }
70
+ }
71
+
72
+ return errors;
73
+ };
74
+
75
+ /**
76
+ * Resolves the target path for the given href relative to the file and public directory.
77
+ *
78
+ * @param {string} href
79
+ * @param {string} filePath
80
+ * @param {Object} options
81
+ * @param {string} options.publicDir
82
+ * @returns {string}
83
+ */
84
+ function getResolvedPath( href, filePath, options ) {
85
+ if ( href.startsWith( '#' ) ) {
86
+ return upath.resolve( filePath + href );
87
+ }
88
+
89
+ if ( href.startsWith( '/' ) ) {
90
+ const commonPoint = href.match( /^\/[^/]+/ )[ 0 ];
91
+
92
+ // The `commonPoint` should be removed from the end of the `options.publicDir` string.
93
+ // It may happen that `commonPoint` is present at the middle of that path and removing it produces invalid results.
94
+ // See: #909.
95
+ const regExpCommonPoint = new RegExp( commonPoint + '$' );
96
+
97
+ return upath.resolve( options.publicDir.replace( regExpCommonPoint, '' ) + href );
98
+ }
99
+
100
+ return upath.resolve( filePath, '..', href );
101
+ }
102
+
103
+ /**
104
+ * Recursively processes the DOM tree, collecting <a> elements with href.
105
+ *
106
+ * @param {Object} node
107
+ * @param {Array<Object>} linkElements
108
+ */
109
+ function processNode( node, linkElements ) {
110
+ if ( node.type === 'tag' && node.name === 'a' ) {
111
+ const text = node.children.find( child => child.type === 'text' )?.data;
112
+ const href = node.attribs?.href;
113
+ const skipValidation = node.attribs?.[ 'data-skip-validation' ] !== undefined;
114
+
115
+ if ( !skipValidation && text && href && !href.match( /[a-z:]*\/\// ) && !href.match( /mailto:/ ) ) {
116
+ linkElements.push( { href, text } );
117
+ }
118
+ }
119
+
120
+ if ( !node.childNodes ) {
121
+ return;
122
+ }
123
+
124
+ for ( const childNode of node.childNodes ) {
125
+ processNode( childNode, linkElements );
126
+ }
127
+ }