umberto 9.1.0 → 9.1.2

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 CHANGED
@@ -1,6 +1,22 @@
1
1
  Changelog
2
2
  =========
3
3
 
4
+ ## [9.1.2](https://github.com/cksource/umberto/compare/v9.1.1...v9.1.2) (December 8, 2025)
5
+
6
+ ### Bug fixes
7
+
8
+ * Fix a race condition in the Tooltip/Popover component between `createTooltipPopover` and `TooltipPopover.attach` that could cause duplicate initialization. Tooltips now initialize only once per element and more reliably detect when trigger elements have been detached.
9
+ * Minification should preserve closing tags and output spec-compliant HTML.
10
+ * Fix encoding of SDK sources.
11
+
12
+
13
+ ## [9.1.1](https://github.com/cksource/umberto/compare/v9.1.0...v9.1.1) (November 25, 2025)
14
+
15
+ ### Other changes
16
+
17
+ * Added support for the `environment` field in Sentry configuration.
18
+
19
+
4
20
  ## [9.1.0](https://github.com/cksource/umberto/compare/v9.0.0...v9.1.0) (November 24, 2025)
5
21
 
6
22
  ### Features
@@ -36,20 +52,6 @@ Changelog
36
52
 
37
53
  * Umberto reads a project configuration once and passes it through the pipeline. Thanks to that, hooks can modify it, which affects a project's build.
38
54
 
39
-
40
- ## [8.3.5](https://github.com/cksource/umberto/compare/v8.3.4...v8.3.5) (October 29, 2025)
41
-
42
- ### Other changes
43
-
44
- * Hook scripts now receive the full project configuration object when executed.
45
-
46
-
47
- ## [8.3.4](https://github.com/cksource/umberto/compare/v8.3.3...v8.3.4) (October 20, 2025)
48
-
49
- ### Other changes
50
-
51
- * Bump dependencies (including `hexo` to version `8.0.0`).
52
-
53
55
  ---
54
56
 
55
57
  To see all releases, visit the [release page](https://github.com/cksource/umberto/releases).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "umberto",
3
- "version": "9.1.0",
3
+ "version": "9.1.2",
4
4
  "description": "CKSource Documentation builder",
5
5
  "main": "src/index.js",
6
6
  "files": [
@@ -0,0 +1,31 @@
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 { styleText } = require( 'util' );
9
+
10
+ module.exports = function logWithTime( message ) {
11
+ const lowercase = message.charAt( 0 ).toLowerCase() + message.slice( 1 );
12
+ const getTime = measureSeconds();
13
+
14
+ console.log( `Started ${ lowercase }.` );
15
+
16
+ return () => console.log( `Finished ${ lowercase } in ${ styleText( 'bold', getTime() ) }.` );
17
+ };
18
+
19
+ function measureSeconds() {
20
+ const start = process.hrtime();
21
+
22
+ return () => {
23
+ const [ totalSeconds ] = process.hrtime( start );
24
+ const minutes = Math.floor( totalSeconds / 60 );
25
+ const seconds = totalSeconds % 60;
26
+
27
+ return minutes ?
28
+ minutes + 'm ' + String( seconds ).padStart( 2, '0' ) + 's' :
29
+ seconds + 's';
30
+ };
31
+ }
package/src/index.js CHANGED
@@ -17,6 +17,7 @@ const getProjectConfig = require( './tasks/get-project-config' );
17
17
  const watcher = require( './tasks/watcher' );
18
18
  const validateHtml = require( './tasks/validate-html-w3c' );
19
19
  const { version } = require( '../package.json' );
20
+ const logWithTime = require( './helpers/log-with-time' );
20
21
 
21
22
  module.exports = {
22
23
  /**
@@ -68,9 +69,8 @@ module.exports = {
68
69
  * @returns {Promise}
69
70
  */
70
71
  async function buildAndWatch( options ) {
71
- const timer = process.hrtime();
72
+ const logTime = logWithTime( 'Building documentation' );
72
73
  const outputDir = path.join( process.cwd(), 'build', 'docs' );
73
-
74
74
  const hexoManager = await buildDocumentation( options );
75
75
 
76
76
  if ( !options.skipThemes ) {
@@ -91,9 +91,7 @@ async function buildAndWatch( options ) {
91
91
  } );
92
92
  }
93
93
 
94
- const [ time ] = process.hrtime( timer );
95
-
96
- console.log( styleText( 'greenBright', `Building documentation complete in ${ Math.floor( time / 60 ) }m ${ time % 60 }s.` ) );
94
+ logTime();
97
95
 
98
96
  if ( options.watch ) {
99
97
  return watcher( hexoManager );
@@ -18,6 +18,7 @@ const {
18
18
  textContent,
19
19
  getName
20
20
  } = require( 'domutils' );
21
+ const decodeHtmlEntities = require( '../../scripts/utils/decode-html-entities' );
21
22
 
22
23
  const SDK_SELECTOR = 'meta[name="sdk-samples"]';
23
24
 
@@ -27,7 +28,7 @@ module.exports = sourcePath => {
27
28
 
28
29
  for ( const filePath of filePaths ) {
29
30
  const content = fs.readFileSync( filePath, { encoding: 'utf8' } );
30
- const doc = parseDocument( content );
31
+ const doc = parseDocument( content, { decodeEntities: false } );
31
32
  const sdkMeta = selectOne( SDK_SELECTOR, doc );
32
33
  const samplesNames = sdkMeta && getAttributeValue( sdkMeta, 'content' ) ?
33
34
  getAttributeValue( sdkMeta, 'content' ).split( '|' ) :
@@ -77,9 +78,16 @@ module.exports = sourcePath => {
77
78
  }
78
79
 
79
80
  if ( hasAttrib( element, 'data-sample-strip-outer-tag' ) ) {
80
- ret.source = render( element.children || [] ).replace( /^\n|\n$/g, '' );
81
+ ret.source = render( element.children || [], { encodeEntities: false } ).replace( /^\n|\n$/g, '' );
81
82
  } else {
82
- ret.source = render( element ).replace( /\s?data-sample(-[\w-]+)?="[^"]*?"/g, '' );
83
+ // Delete all data-sample* attributes.
84
+ for ( const attrib of Object.keys( element.attribs ) ) {
85
+ if ( attrib.startsWith( 'data-sample' ) ) {
86
+ delete element.attribs[ attrib ];
87
+ }
88
+ }
89
+
90
+ ret.source = render( element, { encodeEntities: false } );
83
91
  }
84
92
 
85
93
  outputArray.push( ret );
@@ -101,15 +109,15 @@ module.exports = sourcePath => {
101
109
  }
102
110
 
103
111
  const sdkContents = selectOne( '.sdk-contents', doc );
104
- const contentHtml = render( sdkContents.children || [] );
112
+ const contentHtml = render( sdkContents.children || [], { encodeEntities: false } );
105
113
  const presetVersion = getAttributeValue( sdkContents, 'data-cke-preset' );
106
114
 
107
115
  files.push( {
108
116
  name: upath.basename( filePath, '.html' ),
109
- description: getAttributeValue( selectOne( 'meta[name="description"]', doc ), 'content' ),
110
- title: textContent( selectOne( 'title', doc ) ),
117
+ description: decodeHtmlEntities( getAttributeValue( selectOne( 'meta[name="description"]', doc ), 'content' ) ),
118
+ title: decodeHtmlEntities( textContent( selectOne( 'title', doc ) ) ),
111
119
  content: contentHtml,
112
- source: render( doc ),
120
+ source: render( doc, { encodeEntities: false } ),
113
121
  path: upath.relative( sourcePath, filePath ),
114
122
  presetVersion,
115
123
  sdkSamples: samples,
@@ -10,6 +10,7 @@ const readDocSources = require( './read-doc-sources' );
10
10
  const DataProvider = require( '../data-converter/data-provider' );
11
11
  const ApiBuilder = require( '../api-builder/api-builder' );
12
12
  const TemplateCollection = require( '../template/template-collection' );
13
+ const logWithTime = require( '../helpers/log-with-time' );
13
14
 
14
15
  /**
15
16
  * Builds API docs.
@@ -64,10 +65,10 @@ module.exports = async config => {
64
65
  }
65
66
  );
66
67
 
67
- console.log( `Building API docs of ${ projectConfig.name } ...` );
68
- console.time( `Built API docs of ${ projectConfig.name } in` );
68
+ const logTime = logWithTime( `Building API docs for ${ projectConfig.name }` );
69
69
  const buildInfo = await apiBuilder.buildApi();
70
- console.timeEnd( `Built API docs of ${ projectConfig.name } in` );
70
+
71
+ logTime();
71
72
 
72
73
  if ( process.argv.includes( '--strict' ) && buildInfo.warningCount > 0 ) {
73
74
  console.log( `There were ${ buildInfo.warningCount } warnings reported in strict mode. Aborting.` );
@@ -31,6 +31,7 @@ const umbertoVersion = require( '../../package.json' ).version;
31
31
 
32
32
  const { parseLinks } = require( '../../scripts/utils/parselinks' );
33
33
  const getFilePatternsToProcess = require( '../helpers/get-file-patterns-to-process' );
34
+ const logWithTime = require( '../helpers/log-with-time' );
34
35
 
35
36
  /**
36
37
  * Main function building the documentation.
@@ -166,10 +167,18 @@ module.exports = options => {
166
167
  skipGuides
167
168
  } );
168
169
  } )
169
- .then( () => hexoManager.generate( {
170
- watch,
171
- concurrency: 80
172
- } ) )
170
+ .then( async () => {
171
+ const logTime = logWithTime( 'Generating pages with Hexo' );
172
+
173
+ const result = await hexoManager.generate( {
174
+ watch,
175
+ concurrency: 80
176
+ } );
177
+
178
+ logTime();
179
+
180
+ return result;
181
+ } )
173
182
  .then( () => buildSnippets( hexoManager.hexo.projectGlobals ) )
174
183
  .then( () => copyProjectIcons( hexoManager.hexo.projectGlobals, hexoManager.getPublicDir() ) )
175
184
  // A workaround for API guides when API generation is skipped.
@@ -18,7 +18,13 @@ module.exports = function( file ) {
18
18
  const html = readFileSync( file );
19
19
 
20
20
  const minified = minifier.minify( html, {
21
+ allow_noncompliant_unquoted_attribute_values: false,
22
+ allow_optimal_entities: false,
23
+ allow_removing_spaces_between_attributes: false,
24
+ keep_closing_tags: true,
21
25
  keep_comments: true,
26
+ keep_html_and_head_opening_tags: true,
27
+ keep_input_type_text_attr: true,
22
28
  minify_css: true,
23
29
  minify_js: true
24
30
  } );
@@ -9,6 +9,7 @@ const { join } = require( 'path' );
9
9
  const { globSync } = require( 'fs' );
10
10
  const { styleText } = require( 'util' );
11
11
  const { default: TinyPool } = require( 'tinypool' );
12
+ const logWithTime = require( '../helpers/log-with-time' );
12
13
 
13
14
  /**
14
15
  * Minifies all HTML files under outputDir in parallel using tinypool.
@@ -16,6 +17,7 @@ const { default: TinyPool } = require( 'tinypool' );
16
17
  * @param {string} outputDir - base directory to search for *.html files
17
18
  */
18
19
  module.exports = async function minifyHtml( outputDir ) {
20
+ const logTime = logWithTime( 'Minifying HTML' );
19
21
  const files = globSync( join( outputDir, '**', 'api', '**', '*.html' ) );
20
22
 
21
23
  const pool = new TinyPool( {
@@ -24,8 +26,6 @@ module.exports = async function minifyHtml( outputDir ) {
24
26
  } );
25
27
 
26
28
  try {
27
- console.log( 'Started HTML minification.' );
28
-
29
29
  const results = await Promise.allSettled(
30
30
  files.map( async file => {
31
31
  try {
@@ -49,6 +49,6 @@ module.exports = async function minifyHtml( outputDir ) {
49
49
  } finally {
50
50
  await pool.destroy();
51
51
 
52
- console.log( 'Finished HTML minification.' );
52
+ logTime();
53
53
  }
54
54
  };
@@ -16,6 +16,7 @@ const fs = require( 'fs-extra' );
16
16
  const upath = require( 'upath' );
17
17
  const { globSync } = require( 'glob' );
18
18
  const { default: TinyPool } = require( 'tinypool' );
19
+ const logWithTime = require( '../helpers/log-with-time' );
19
20
 
20
21
  /**
21
22
  * Collects all links from the provided HTML files.
@@ -81,16 +82,17 @@ async function collectLinks( pattern, options ) {
81
82
  * @param {Object} [options={}] Validation options.
82
83
  */
83
84
  module.exports = async function validateLinks( buildPath, options = {} ) {
84
- console.info( `Gathering links in ${ styleText( 'magenta', buildPath ) } to validate...` );
85
+ const logGatheringTime = logWithTime( 'Gathering links to validate' );
85
86
 
86
- const start = Date.now();
87
87
  const { links, pathsToFiles } = await collectLinks( upath.join( buildPath, '**', '*' ), options );
88
88
  const pool = new TinyPool( {
89
89
  filename: require.resolve( './validate-links-worker.js' )
90
90
  } );
91
91
 
92
+ logGatheringTime();
93
+
92
94
  try {
93
- console.info( `Validating links in ${ styleText( 'magenta', buildPath ) }...` );
95
+ const logValidatingTime = logWithTime( 'Validating links' );
94
96
 
95
97
  const tasks = splitArrayBalanced( pathsToFiles, pool.options.maxThreads ).map( chunk => pool.run( { chunk, links, options } ) );
96
98
  const results = await Promise.all( tasks );
@@ -101,7 +103,7 @@ module.exports = async function validateLinks( buildPath, options = {} ) {
101
103
  throw new Error( `Found ${ errors.length } invalid links.` );
102
104
  }
103
105
 
104
- console.info( `Links validation finished in ${ styleText( 'green', ( Date.now() - start ) + 'ms' ) }.` );
106
+ logValidatingTime();
105
107
  } finally {
106
108
  await pool.destroy();
107
109
  }
@@ -6,6 +6,7 @@ mixin load-sentry-script( sentry )
6
6
  if ( isProductionHost ) {
7
7
  window.sentryOnLoad = function () {
8
8
  Sentry.init( {
9
+ environment: '!{ sentry.environment }',
9
10
  integrations: [
10
11
  Sentry.browserTracingIntegration(),
11
12
  Sentry.replayIntegration()
@@ -15,8 +16,8 @@ mixin load-sentry-script( sentry )
15
16
  'https://ckeditor.com/docs',
16
17
  'https://ckeditor5.github.io/docs/nightly'
17
18
  ],
18
- replaysSessionSampleRate: 1.0,
19
- replaysOnErrorSampleRate: 1.0
19
+ replaysSessionSampleRate: 0,
20
+ replaysOnErrorSampleRate: 0
20
21
  } );
21
22
  };
22
23
 
@@ -13,8 +13,9 @@ import { randomId } from '../helpers/random-id';
13
13
  import { createAnchorPositioningWatcher } from '../helpers/create-anchor-positioning-watcher';
14
14
  import { createMutationObserver } from '../helpers/create-mutation-observer';
15
15
  import { createCleanupRegistry } from '../helpers/create-cleanup-registry';
16
- import { isElementAttached } from '../helpers/is-element-attached';
16
+ import { isElementDetached } from '../helpers/is-element-detached';
17
17
  import { afterDomReady } from '../helpers/after-dom-ready';
18
+ import { timeout } from '../helpers/timeout';
18
19
 
19
20
  /**
20
21
  * Tooltip component.
@@ -71,7 +72,7 @@ export class TooltipPopover extends BaseComponent {
71
72
  const tooltips = document.querySelectorAll( '.js-tooltip-popover' );
72
73
 
73
74
  for ( const tooltip of tooltips ) {
74
- new TooltipPopover( tooltip ).init();
75
+ tooltip.tooltipPopover ||= new TooltipPopover( tooltip ).init();
75
76
  }
76
77
  }
77
78
 
@@ -123,14 +124,14 @@ export class TooltipPopover extends BaseComponent {
123
124
  // If so, and the tooltip is visible, hide it.
124
125
  // Sometimes, framework might remove the trigger element after the click event.
125
126
  // It handles such cases gracefully.
126
- this.triggerCleanup.addEventListener( window, 'mousedown', () => {
127
- setTimeout( () => {
128
- isElementAttached( trigger ).then( isDetached => {
129
- if ( isDetached && this.state.isVisible ) {
130
- this.hide();
131
- }
132
- } );
133
- }, 300 );
127
+ this.triggerCleanup.addEventListener( window, 'mousedown', async () => {
128
+ await timeout( 300 );
129
+
130
+ const isDetached = await isElementDetached( trigger );
131
+
132
+ if ( isDetached && this.state.isVisible ) {
133
+ this.hide();
134
+ }
134
135
  } );
135
136
  break;
136
137
 
@@ -397,6 +398,7 @@ export class TooltipPopover extends BaseComponent {
397
398
  arrow.style.left = `calc(50% - ${ offset }px)`;
398
399
  } else if ( position.endsWith( '-end' ) ) {
399
400
  const currentRight = parseFloat( arrow.style.right || 0 );
401
+
400
402
  arrow.style.right = `${ currentRight + offset }px`;
401
403
  }
402
404
  } else if ( left + tooltipRect.width > window.innerWidth ) {
@@ -407,9 +409,11 @@ export class TooltipPopover extends BaseComponent {
407
409
 
408
410
  if ( !position.endsWith( '-start' ) && !position.endsWith( '-end' ) ) {
409
411
  const offset = origLeft - left;
412
+
410
413
  arrow.style.left = `calc(50% + ${ offset }px)`;
411
414
  } else if ( position.endsWith( '-start' ) ) {
412
415
  const currentLeft = parseFloat( arrow.style.left || 0 );
416
+
413
417
  arrow.style.left = `${ currentLeft - overflowAmount }px`;
414
418
  }
415
419
  }
@@ -560,6 +564,8 @@ export function createTooltipPopover( config = {} ) {
560
564
  ...rest
561
565
  } );
562
566
 
567
+ tooltip.tooltipPopover = popover;
568
+
563
569
  afterDomReady( () => {
564
570
  // Add to document
565
571
  document.body.appendChild( tooltip );
@@ -32,6 +32,7 @@ export function createCleanupRegistry() {
32
32
  destroy() {
33
33
  while ( callbacks.length ) {
34
34
  const callback = callbacks.pop();
35
+
35
36
  if ( typeof callback === 'function' ) {
36
37
  callback();
37
38
  }
@@ -6,23 +6,32 @@
6
6
  /**
7
7
  * Asynchronously checks if the given element or any of its parents are detached from the document body.
8
8
  *
9
+ * This function intentionally does not use `isConnected` to ensure compatibility with environments like Shadow DOM.
10
+ * It only checks the connection to `document.body` not to any other root nodes.
11
+ *
9
12
  * @param element The element to check.
10
13
  * @returns A promise that resolves to true if the element or any parent is detached, false otherwise.
11
14
  */
12
- export async function isElementAttached( element ) {
15
+ export async function isElementDetached( element ) {
13
16
  return new Promise( resolve => {
14
17
  // Use requestAnimationFrame to ensure the check happens after any potential DOM manipulations in the same tick.
15
18
  requestAnimationFrame( () => {
16
19
  let currentElement = element;
17
- while ( currentElement ) {
20
+
21
+ for (
22
+ let watchdog = 0;
23
+ currentElement && currentElement !== document.body && watchdog < 100;
24
+ watchdog++
25
+ ) {
18
26
  if ( !document.body.contains( currentElement ) ) {
19
- resolve( true ); // Detached
27
+ resolve( true );
20
28
  return;
21
29
  }
30
+
22
31
  currentElement = currentElement.parentElement;
23
32
  }
24
33
 
25
- resolve( false ); // Attached
34
+ resolve( false );
26
35
  } );
27
36
  } );
28
37
  }