umberto 9.3.0 → 9.4.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.
package/CHANGELOG.md CHANGED
@@ -1,6 +1,22 @@
1
1
  Changelog
2
2
  =========
3
3
 
4
+ ## [9.4.0](https://github.com/cksource/umberto/compare/v9.3.0...v9.4.0) (February 2, 2026)
5
+
6
+ ### Features
7
+
8
+ * Introduced configuration option to prevent selected project pages from being indexed by search engines. For these projects, the build injects `<meta name="robots" content="noindex, nofollow">` into the page `<head>`.
9
+
10
+ * When using `buildSingleProject()`, set the `shouldInjectNoIndexMeta` option in `umberto.json`:
11
+ `"shouldInjectNoIndexMeta": true`
12
+ * When using `buildMultiProjects()`, set the `nonIndexableProjects` option in `umberto-main.json` using the paths of projects that should not be indexed, e.g.:
13
+ `"nonIndexableProjects": [ "projects-external/ckeditor5-commercial/external/ckeditor5", "projects/trial" ]`
14
+
15
+ ### Other changes
16
+
17
+ * Reduced the size of API docs sidebars by only rendering the full navigation tree for the current package. Other packages now link to their landing pages, significantly lowering total link count and improving build size and performance.
18
+
19
+
4
20
  ## [9.3.0](https://github.com/cksource/umberto/compare/v9.2.0...v9.3.0) (January 27, 2026)
5
21
 
6
22
  ### Features
@@ -47,13 +63,6 @@ Changelog
47
63
  * Minification should preserve closing tags and output spec-compliant HTML.
48
64
  * Fix encoding of SDK sources.
49
65
 
50
-
51
- ## [9.1.1](https://github.com/cksource/umberto/compare/v9.1.0...v9.1.1) (November 25, 2025)
52
-
53
- ### Other changes
54
-
55
- * Added support for the `environment` field in Sentry configuration.
56
-
57
66
  ---
58
67
 
59
68
  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.3.0",
3
+ "version": "9.4.0",
4
4
  "description": "CKSource Documentation builder",
5
5
  "main": "src/index.js",
6
6
  "files": [
@@ -151,6 +151,7 @@ module.exports = class ApiBuilder {
151
151
  } );
152
152
 
153
153
  this._navTreeHtml = '';
154
+ this._navTreeHtmlByPackage = {};
154
155
  this._filterHtml = '';
155
156
 
156
157
  /**
@@ -255,6 +256,15 @@ module.exports = class ApiBuilder {
255
256
  return this._navTreeHtml;
256
257
  }
257
258
 
259
+ /**
260
+ * Get HTML of API navigation tree by package name.
261
+ *
262
+ * @returns {Object}
263
+ */
264
+ getNavTreeByPackage() {
265
+ return Object.assign( {}, this._navTreeHtmlByPackage );
266
+ }
267
+
258
268
  /**
259
269
  * Get HTML of API error codes.
260
270
  * @returns {String}
@@ -385,13 +395,55 @@ module.exports = class ApiBuilder {
385
395
  }
386
396
  }
387
397
 
388
- this._navTreeHtml = this._tmplCol.renderTemplate( '_partial/navtree', {
398
+ this._navTreeHtml = this._renderNavTreeHtml();
399
+ this._navTreeHtmlByPackage = this._buildNavTreeHtmlByPackage();
400
+ }
401
+
402
+ /**
403
+ * Renders API navigation tree HTML.
404
+ *
405
+ * @param {String|null} activeApiPackage Package name used to expand its subtree.
406
+ * @returns {String}
407
+ */
408
+ _renderNavTreeHtml( activeApiPackage = null ) {
409
+ return this._tmplCol.renderTemplate( '_partial/navtree', {
389
410
  projectLocals: {
390
411
  apiTree: this._navTree
391
- }
412
+ },
413
+ activeApiPackage
392
414
  } );
393
415
  }
394
416
 
417
+ /**
418
+ * Builds an object of API navigation tree HTML keyed by package name.
419
+ *
420
+ * @returns {Object}
421
+ */
422
+ _buildNavTreeHtmlByPackage() {
423
+ const htmlByPackage = {};
424
+ const rootChildren = this._navTree.getTree().getChildren();
425
+
426
+ if ( !Array.isArray( rootChildren ) || rootChildren.length === 0 ) {
427
+ return htmlByPackage;
428
+ }
429
+
430
+ for ( const child of rootChildren ) {
431
+ if ( child.getProp( 'type' ) !== this._navTree.NODE_TYPES.PACKAGE ) {
432
+ continue;
433
+ }
434
+
435
+ const packageName = child.getProp( 'name' );
436
+
437
+ if ( !packageName ) {
438
+ continue;
439
+ }
440
+
441
+ htmlByPackage[ packageName ] = this._renderNavTreeHtml( packageName );
442
+ }
443
+
444
+ return htmlByPackage;
445
+ }
446
+
395
447
  /**
396
448
  * Renders HTML of each API docs page and creates an HtmlFile instance for that page.
397
449
  */
@@ -412,6 +464,7 @@ module.exports = class ApiBuilder {
412
464
  const shortModulePath = getShortModulePath( data.longname );
413
465
  const split = splitLongname( data.longname );
414
466
  const splitParts = [ split.packageName, ...split.directoryNames ];
467
+ const navTreeHtml = this._navTreeHtmlByPackage[ split.packageName ] || this._navTreeHtml;
415
468
  const realImportPath = typeof this._getRealImportPath === 'function' ? this._getRealImportPath( shortModulePath ) : '';
416
469
 
417
470
  let title;
@@ -498,7 +551,7 @@ module.exports = class ApiBuilder {
498
551
  jobs.push(
499
552
  pool.run( {
500
553
  content: view.content,
501
- navTreeHtml: this._navTreeHtml,
554
+ navTreeHtml,
502
555
  outputPath: upath.join( view.dirname, view.basename ),
503
556
  themeDir: hexoManager.hexo.theme_dir
504
557
  } )
@@ -267,7 +267,7 @@ module.exports = class NavigationTree {
267
267
  return '';
268
268
  }
269
269
 
270
- const packageGuidePath = upath.join(
270
+ const packageGuidePath = upath.resolve(
271
271
  that.projectRootPath,
272
272
  packagePath.path,
273
273
  'docs',
@@ -10,6 +10,66 @@ const getPageGroupHelper = require( '../helper/get-page-group' );
10
10
  const getDocSearchConfig = require( '../../helpers/get-docsearch-config' );
11
11
  const umbertoVersion = require( '../../../package.json' ).version;
12
12
 
13
+ /**
14
+ * Resolves the API package name from a docs path.
15
+ *
16
+ * @param {Object} params
17
+ * @param {String} params.path
18
+ * @param {String} params.basePath
19
+ * @param {String|null} params.apiSlug
20
+ * @returns {String|null}
21
+ */
22
+ function getApiPackageFromPath( { path, basePath, apiSlug } ) {
23
+ if ( !apiSlug ) {
24
+ return null;
25
+ }
26
+
27
+ const apiBasePath = upath.join( basePath, apiSlug );
28
+ const normalizedPath = upath.normalize( path );
29
+
30
+ if ( normalizedPath !== apiBasePath && !normalizedPath.startsWith( `${ apiBasePath }/` ) ) {
31
+ return null;
32
+ }
33
+
34
+ const firstSegment = normalizedPath
35
+ .slice( apiBasePath.length )
36
+ .replace( /^\/+/, '' )
37
+ .split( '/' )[ 0 ];
38
+
39
+ if ( !firstSegment || firstSegment === 'index.html' ) {
40
+ return null;
41
+ }
42
+
43
+ const packageName = firstSegment.replace( /\.html$/, '' );
44
+
45
+ return packageName === 'index' ? null : packageName;
46
+ }
47
+
48
+ /**
49
+ * Chooses the API tree HTML based on current page context.
50
+ *
51
+ * @param {Object} params
52
+ * @param {String} params.path
53
+ * @param {String} params.basePath
54
+ * @param {String|null} params.apiSlug
55
+ * @param {String} params.navTree
56
+ * @param {Object} params.navTreeByPackage
57
+ * @returns {String}
58
+ */
59
+ function getApiTreeHtml( { path, basePath, apiSlug, navTree, navTreeByPackage } ) {
60
+ if ( !navTreeByPackage || Object.keys( navTreeByPackage ).length === 0 ) {
61
+ return navTree;
62
+ }
63
+
64
+ const apiPackage = getApiPackageFromPath( { path, basePath, apiSlug } );
65
+
66
+ if ( apiPackage && navTreeByPackage[ apiPackage ] ) {
67
+ return navTreeByPackage[ apiPackage ];
68
+ }
69
+
70
+ return navTree;
71
+ }
72
+
13
73
  /**
14
74
  * Adds various project data as hexo locals available in templates.
15
75
  *
@@ -27,6 +87,7 @@ const umbertoVersion = require( '../../../package.json' ).version;
27
87
  * @param {Array} extraStylePaths Paths to extra external css.
28
88
  * @param {Array} extraScriptsPaths Paths to extra external js.
29
89
  * @param {Boolean} disableSearch Extra flag for disabling docsearch if needed.
90
+ * @param {Boolean} shouldInjectNoIndexMeta Whether the "do not index" header for bots should be included.
30
91
  * @param {Array} quickNavigationProjects Projects to display in the quick navigation dropdown.
31
92
  * @param {Boolean} navigationShowEmptyCategories If set on true, empty categories will be displayed.
32
93
  * @param {Object} og Open Graph config.
@@ -46,6 +107,7 @@ module.exports = ( ctx, {
46
107
  extraStylePaths,
47
108
  extraScriptsPaths,
48
109
  disableSearch,
110
+ shouldInjectNoIndexMeta,
49
111
  og,
50
112
  quickNavigationProjects,
51
113
  navigationShowEmptyCategories
@@ -63,10 +125,18 @@ module.exports = ( ctx, {
63
125
  return g;
64
126
  } );
65
127
 
128
+ const apiTree = getApiTreeHtml( {
129
+ path: locals.path,
130
+ basePath,
131
+ apiSlug: groupsModified.find( g => g.id === 'api-reference' )?.slug ?? null,
132
+ navTree: ctx.projectGlobals[ projectSlug ].config.navTree,
133
+ navTreeByPackage: ctx.projectGlobals[ projectSlug ].config.navTreeByPackage
134
+ } );
135
+
66
136
  locals.projectLocals = {
67
137
  groups: groupsModified,
68
138
  getPageGroup: getPageGroupHelper( groupsModified ),
69
- apiTree: ctx.projectGlobals[ projectSlug ].config.navTree,
139
+ apiTree,
70
140
  sdkNavTree: ctx.projectGlobals[ projectSlug ].config.sdkNavTree,
71
141
  latestBasePath: upath.join( projectSlug, 'latest' ),
72
142
  extraStylePaths,
@@ -85,7 +155,10 @@ module.exports = ( ctx, {
85
155
  };
86
156
  }
87
157
 
88
- // locals same for all projects
158
+ // ⚠️ WARNING ⚠️
159
+ // Following locals are the same for all projects. Each project overwrites previous ones.
160
+ // For unique values, use`projectLocals` or `projectsData`.
161
+
89
162
  if ( !locals.projectsData ) {
90
163
  locals.projectsData = [];
91
164
  }
@@ -94,6 +167,7 @@ module.exports = ( ctx, {
94
167
  locals.projectsData.push( {
95
168
  name: projectName,
96
169
  slug: projectSlug,
170
+ shouldInjectNoIndexMeta,
97
171
  BASE_PATH: upath.join( projectSlug, config.version ),
98
172
  latestBasePath: upath.join( projectSlug, 'latest' ),
99
173
  startPage: config.startPage ? '/' + config.startPage : '/index.html'
@@ -131,7 +131,7 @@ module.exports = options => {
131
131
  } );
132
132
  } )
133
133
  .then( async () => {
134
- const configs = await getProjectConfigs( rootPath, projectPaths, { skipLiveSnippets } );
134
+ const configs = await getProjectConfigs( rootPath, projectPaths, { ...mainConfig, skipLiveSnippets } );
135
135
 
136
136
  return executeHooks( configs, 'beforeHexo' );
137
137
  } )
@@ -143,6 +143,8 @@ module.exports = options => {
143
143
  dev,
144
144
  docSearch: mainConfig.docsearch,
145
145
  disableSearch: mainConfig.docsearch === false,
146
+ shouldInjectNoIndexMeta: mainConfig.shouldInjectNoIndexMeta,
147
+ nonIndexableProjects: mainConfig.nonIndexableProjects || [],
146
148
  googleoptimize: mainConfig.googleoptimize,
147
149
  googletagmanager: mainConfig.googletagmanager,
148
150
  googleanalytics: mainConfig.googleanalytics,
@@ -189,6 +191,7 @@ module.exports = options => {
189
191
  }
190
192
 
191
193
  const projectConfigs = await getProjectConfigs( rootPath, projectPaths, {
194
+ ...mainConfig,
192
195
  skipLiveSnippets
193
196
  } );
194
197
  const buildDir = hexoManager.getPublicDir();
@@ -232,6 +235,7 @@ module.exports = options => {
232
235
  // Links validation.
233
236
  .then( async () => {
234
237
  const projectConfigs = await getProjectConfigs( rootPath, projectPaths, {
238
+ ...mainConfig,
235
239
  skipLiveSnippets
236
240
  } );
237
241
 
@@ -263,12 +267,12 @@ module.exports = options => {
263
267
  return Promise.resolve();
264
268
  } )
265
269
  .then( async () => {
266
- const projectConfigs = await getProjectConfigs( rootPath, projectPaths, { skipLiveSnippets } );
270
+ const projectConfigs = await getProjectConfigs( rootPath, projectPaths, { ...mainConfig, skipLiveSnippets } );
267
271
 
268
272
  return createSitemapStep( { projectConfigs, verbose, mainConfig } );
269
273
  } )
270
274
  .then( async () => {
271
- const configs = await getProjectConfigs( rootPath, projectPaths, { skipLiveSnippets } );
275
+ const configs = await getProjectConfigs( rootPath, projectPaths, { ...mainConfig, skipLiveSnippets } );
272
276
 
273
277
  return executeHooks( configs, 'afterHexo' );
274
278
  } )
@@ -382,6 +386,7 @@ async function buildProjects( rootPath, projectPaths, options = {} ) {
382
386
  bindProjectLocals( hexoManager.hexo, {
383
387
  basePath,
384
388
  docSearch: options.docSearch,
389
+ shouldInjectNoIndexMeta: config.shouldInjectNoIndexMeta,
385
390
  config,
386
391
  googleoptimize: options.googleoptimize,
387
392
  googletagmanager: options.googletagmanager,
@@ -428,8 +433,12 @@ async function getProjectConfigs( rootPath, projectPaths, options = {} ) {
428
433
  const promises = projectPaths.map( async pPath => {
429
434
  const projectRootPath = upath.join( rootPath, pPath );
430
435
 
436
+ const nonIndexableProjects = options.nonIndexableProjects || [];
437
+ const shouldInjectNoIndexMeta = options.shouldInjectNoIndexMeta || nonIndexableProjects.includes( pPath );
438
+
431
439
  return getProjectConfig( projectRootPath, {
432
- skipLiveSnippets: options.skipLiveSnippets
440
+ skipLiveSnippets: options.skipLiveSnippets,
441
+ shouldInjectNoIndexMeta
433
442
  } );
434
443
  } );
435
444
 
@@ -491,6 +500,7 @@ async function buildApis( projectConfigs, options = {} ) {
491
500
  apiType: apiConfig.type
492
501
  },
493
502
  disableSearch: options.disableSearch,
503
+ shouldInjectNoIndexMeta: config.shouldInjectNoIndexMeta,
494
504
  googleoptimize: options.googleoptimize,
495
505
  googletagmanager: options.googletagmanager,
496
506
  googleanalytics: options.googleanalytics,
@@ -507,8 +517,9 @@ async function buildApis( projectConfigs, options = {} ) {
507
517
  projectConfig: config,
508
518
  canonicalUrlBeginning: getCanonicalBeginning( { config, options, hexoManager } )
509
519
  } ).then( apiDocs => {
510
- // API docs navigation tree is rendered once and reused as HTML.
520
+ // API docs navigation tree is rendered per package and reused as HTML.
511
521
  config.navTree = apiDocs.getNavTree();
522
+ config.navTreeByPackage = apiDocs.getNavTreeByPackage();
512
523
  // API errors documentation is stored here so that it's available in guides (and hexo filters).
513
524
  config.errorsHtml = apiDocs.getErrors();
514
525
  hexoManager.addProjectGlobalData( config.slug, 'doclets', apiDocs.getDataCollection() );
@@ -571,6 +582,7 @@ function buildSdks( projectConfigs, options = {} ) {
571
582
  extraScriptsPaths: extraScripts
572
583
  },
573
584
  disableSearch: options.disableSearch,
585
+ shouldInjectNoIndexMeta: config.shouldInjectNoIndexMeta,
574
586
  projectsData: basicProjectsData,
575
587
  googleoptimize: options.googleoptimize,
576
588
  googletagmanager: options.googletagmanager,
@@ -18,6 +18,7 @@ const cache = new Map();
18
18
  * @param {String} rootPath
19
19
  * @param {Object} [options={}]
20
20
  * @param {Boolean} [options.skipLiveSnippets=false] If set to `true`, the `snippetAdapter` module will not be added to the configuration.
21
+ * @param {Boolean} [options.shouldInjectNoIndexMeta] Whether the "do not index" header for bots should be included.
21
22
  * @returns {Promise}
22
23
  */
23
24
  module.exports = async ( rootPath, options = {} ) => {
@@ -75,6 +76,10 @@ module.exports = async ( rootPath, options = {} ) => {
75
76
  config.reportIssueWidget = {};
76
77
  }
77
78
 
79
+ if ( options.shouldInjectNoIndexMeta ) {
80
+ config.shouldInjectNoIndexMeta = true;
81
+ }
82
+
78
83
  // Prepare default values.
79
84
  config.reportIssueWidget.enabled = config.reportIssueWidget.enabled || false;
80
85
  config.reportIssueWidget.skipPages = config.reportIssueWidget.skipPages || [];
@@ -4,6 +4,9 @@ include ../../_components/svg/index
4
4
  mixin treeItem( node, level )
5
5
  - const children = node.getChildren();
6
6
  - const hasChildren = children && children.length > 0;
7
+ - const isPackageNode = node.getProp( 'type' ) === 'package';
8
+ - const shouldRenderChildren = hasChildren && ( !isPackageNode || ( activeApiPackage && node.getProp( 'name' ) === activeApiPackage ) );
9
+ - const packageChevronClass = isPackageNode && hasChildren ? [ 'api-tree__button', 'api-tree__button--chevron', 'api-tree__button--package', shouldRenderChildren ? 'api-tree__button--open' : '' ] : [];
7
10
  - let dataAttr = {};
8
11
 
9
12
  - if ( node.getProp( 'access' ) ) dataAttr[ `data-${node.getProp('access')}` ] = 'true';
@@ -18,7 +21,21 @@ mixin treeItem( node, level )
18
21
  - const longname = node.getProp( 'longname' );
19
22
  - const iconClassName = "api-tree__link--with-icon api-tree__link--icon-" + node.getProp( 'type' );
20
23
 
21
- if !hasChildren
24
+ if isPackageNode
25
+ div.api-tree__item-wrapper( data-ln = longname )
26
+ a(
27
+ class = [ classStr, 'api-tree__link', iconClassName ],
28
+ href=link,
29
+ data-skip-validation=''
30
+ )
31
+ if hasChildren
32
+ i( class = packageChevronClass )
33
+ | #{ node.getProp( 'name' ) }
34
+ if shouldRenderChildren
35
+ ul.api-tree__list( 'style'='--level:'+level )
36
+ each child in children
37
+ +treeItem( child, level + 1 )
38
+ else if !shouldRenderChildren
22
39
  div.api-tree__item-wrapper
23
40
  a.api-tree__link(
24
41
  href=link,
@@ -17,7 +17,12 @@ if ( page[ 'twitter-card' ] || ogConfig.description )
17
17
  meta( name='twitter:card' content= page[ 'twitter-card' ] || ogConfig.description )
18
18
  meta( name='twitter:site' content= '@ckeditor' )
19
19
 
20
+ - const currentProject = projectLocals && projectsData.find( project => project.slug === projectLocals.projectSlug );
21
+ - const isNonIndexableProject = currentProject && currentProject.shouldInjectNoIndexMeta
22
+
20
23
  meta( name= 'x-generated-at' content= Date() )
24
+ if shouldInjectNoIndexMeta || isNonIndexableProject
25
+ meta( name= 'robots' content='noindex, nofollow' )
21
26
 
22
27
  if ( mainOg || ( projectLocals && projectLocals.og ) || page[ 'og-description' ] )
23
28
  meta( property='og:title' content= page[ 'og-title' ] || title )
@@ -15,7 +15,12 @@ if ( page[ 'twitter-card' ] || ogConfig.description )
15
15
  meta( name='twitter:card' content= page[ 'twitter-card' ] || ogConfig.description )
16
16
  meta( name='twitter:site' content= '@ckeditor' )
17
17
 
18
+ - const currentProject = projectLocals && projectsData.find( project => project.slug === projectLocals.projectSlug );
19
+ - const isNonIndexableProject = currentProject && currentProject.shouldInjectNoIndexMeta
20
+
18
21
  meta( name= 'x-generated-at' content= Date() )
22
+ if shouldInjectNoIndexMeta || isNonIndexableProject
23
+ meta( name= 'robots' content='noindex, nofollow' )
19
24
 
20
25
  if ( mainOg || ( projectLocals && projectLocals.og ) || page[ 'og-description' ] )
21
26
  meta( property='og:title' content= page[ 'og-title' ] || title )
@@ -184,6 +184,10 @@
184
184
  transform: translate(-125%, -50%) rotate(90deg);
185
185
  }
186
186
 
187
+ &--package {
188
+ left: 0;
189
+ }
190
+
187
191
  &--chevron {
188
192
  &:before {
189
193
  content: url('data:image/svg+xml,<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M7.29274 3.96428C6.90222 3.57376 6.26905 3.57376 5.87853 3.96428C5.488 4.3548 5.488 4.98797 5.87853 5.37849L7.99982 7.49979L5.8785 9.62111C5.48798 10.0116 5.48798 10.6448 5.8785 11.0353C6.26903 11.4258 6.90219 11.4258 7.29272 11.0353L10.115 8.21301C10.1171 8.21099 10.1191 8.20896 10.1212 8.20692C10.5117 7.8164 10.5117 7.18323 10.1212 6.79271L10.1211 6.79268L7.29274 3.96428Z" fill="%233B4958"/></svg>') / 'Api-chevron icon';
@@ -155,7 +155,7 @@ export class ApiNavTree extends BaseComponent {
155
155
  sidebarNav.scrollTop = Number( storedScrollPosition );
156
156
  }
157
157
 
158
- const activeElement = navTree.querySelector( '.api-tree__item-wrapper.active' );
158
+ const activeElement = navTree.querySelector( '.api-tree__item-wrapper.active' )?.parentElement;
159
159
 
160
160
  setTimeout( () => {
161
161
  // Ensure the sidebar is scrolled to the active element after a short delay.