@ckeditor/ckeditor5-dev-manual-server 57.4.0 → 59.0.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/dist/index.d.ts CHANGED
@@ -3,7 +3,6 @@
3
3
  * For licensing, see LICENSE.md.
4
4
  */
5
5
  export { manualTestsPlugin, type ManualTestsPluginOptions } from "./manual-test-plugin/plugin.js";
6
- export { manualStaticAssetsPlugin, type ManualStaticAssetsPluginOptions } from "./static-assets-plugin/plugin.js";
7
6
  export { ckDebugPlugin } from "./debug-plugin/plugin.js";
8
7
  export { refreshPlugin } from "./refresh-plugin/plugin.js";
9
8
  export { rawSvgPlugin } from "./svg-plugin/plugin.js";
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
- import { createReadStream, existsSync, globSync, readFileSync, realpathSync, statSync } from "node:fs";
1
+ import { existsSync, globSync, readFileSync, realpathSync } from "node:fs";
2
2
  import { fileURLToPath } from "node:url";
3
- import { basename, extname, isAbsolute, posix, relative, resolve } from "node:path";
3
+ import { basename, isAbsolute, posix, relative, resolve } from "node:path";
4
4
  //#region src/utils.ts
5
5
  /**
6
6
  * @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
@@ -61,11 +61,11 @@ function stripLeadingSlash(value) {
61
61
  * @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
62
62
  * For licensing, see LICENSE.md.
63
63
  */
64
- const MANUAL_TESTS_DIRECTORY$1 = "tests/manual/";
65
- const MANUAL_TEST_SUFFIX$2 = ".manual.html";
64
+ const MANUAL_TESTS_DIRECTORY$1 = "manual/";
65
+ const MANUAL_TEST_SUFFIX$1 = ".manual.html";
66
66
  /**
67
67
  * Discovers manual test pages. A manual test is a single full HTML document named
68
- * `<slug>.manual.html` located under a package's `tests/manual/` directory. Plain `.html`
68
+ * `<slug>.manual.html` located under a package's `manual/` directory. Plain `.html`
69
69
  * files are treated as static fixtures and are never collected here.
70
70
  *
71
71
  * The `patterns` are package root globs, for example `packages/*`.
@@ -75,12 +75,12 @@ function collectManualPages(patterns, workspaceRoot) {
75
75
  return new Map(manualPages);
76
76
  }
77
77
  function matchManualPageFiles(patterns, workspaceRoot) {
78
- return patterns.map((pattern) => `${pattern.replace(/\/+$/, "")}/${MANUAL_TESTS_DIRECTORY$1}**/*${MANUAL_TEST_SUFFIX$2}`).flatMap((pattern) => globSync(pattern, { cwd: workspaceRoot }).map((match) => toPosixPath(match)));
78
+ return patterns.map((pattern) => `${pattern.replace(/\/+$/, "")}/${MANUAL_TESTS_DIRECTORY$1}**/*${MANUAL_TEST_SUFFIX$1}`).flatMap((pattern) => globSync(pattern, { cwd: workspaceRoot }).map((match) => toPosixPath(match)));
79
79
  }
80
80
  function toManualPageEntry(relativeFilePath, workspaceRoot) {
81
81
  const directoryStartIndex = relativeFilePath.startsWith(MANUAL_TESTS_DIRECTORY$1) ? 0 : relativeFilePath.indexOf(`/${MANUAL_TESTS_DIRECTORY$1}`) + 1;
82
82
  const packagePath = relativeFilePath.slice(0, Math.max(directoryStartIndex - 1, 0));
83
- const slugPath = relativeFilePath.slice(directoryStartIndex + 13);
83
+ const slugPath = relativeFilePath.slice(directoryStartIndex + 7);
84
84
  return {
85
85
  htmlFilePath: toPublicSpecifier(relativeFilePath),
86
86
  packageName: packagePath ? packagePath.slice(packagePath.lastIndexOf("/") + 1) : basename(workspaceRoot),
@@ -94,9 +94,9 @@ function toManualPageEntry(relativeFilePath, workspaceRoot) {
94
94
  * For licensing, see LICENSE.md.
95
95
  */
96
96
  const MANUAL_HEADER_ELEMENT = "ck-manual-header";
97
- const MANUAL_TEST_SUFFIX$1 = ".manual.html";
98
- const MANUAL_TESTS_DIRECTORY = "/tests/manual/";
99
- const THEME_ENTRY_FILE_PATH = "theme/index.css";
97
+ const MANUAL_TEST_SUFFIX = ".manual.html";
98
+ const MANUAL_TESTS_DIRECTORY = "/manual/";
99
+ const THEME_ENTRY_FILE_PATHS = ["theme/index-editor.css", "theme/index-content.css"];
100
100
  const MANUAL_ENTRIES_VIRTUAL_ID = "virtual:ckeditor5-manual-entries";
101
101
  const MANUAL_THEME_ROOT = realpathSync(fileURLToPath(import.meta.resolve("@ckeditor/ckeditor5-dev-manual-server/theme")));
102
102
  const MANUAL_CATALOG_FILE_PATH = resolve(MANUAL_THEME_ROOT, "catalog.html");
@@ -113,14 +113,14 @@ function manualTestsPlugin(options) {
113
113
  return getManualPages().get(toPublicSpecifier(relative(workspaceRoot, filePath)));
114
114
  };
115
115
  const getManualPageEntryForScriptSpecifier = (scriptSpecifier) => {
116
- const htmlSpecifier = scriptSpecifier.replace(/\.(?:js|ts)$/, MANUAL_TEST_SUFFIX$1);
116
+ const htmlSpecifier = scriptSpecifier.replace(/\.(?:js|ts)$/, MANUAL_TEST_SUFFIX);
117
117
  return htmlSpecifier == scriptSpecifier ? void 0 : getManualPages().get(htmlSpecifier);
118
118
  };
119
119
  const packageThemeEntryCache = /* @__PURE__ */ new Map();
120
- const hasPackageThemeEntry = (packageRootSpecifier) => {
120
+ const getPackageThemeEntries = (packageRootSpecifier) => {
121
121
  if (!packageThemeEntryCache.has(packageRootSpecifier)) {
122
- const themeEntryFilePath = resolve(workspaceRoot, stripLeadingSlash(packageRootSpecifier), THEME_ENTRY_FILE_PATH);
123
- packageThemeEntryCache.set(packageRootSpecifier, existsSync(themeEntryFilePath));
122
+ const themeEntries = THEME_ENTRY_FILE_PATHS.filter((themeEntryFilePath) => existsSync(resolve(workspaceRoot, stripLeadingSlash(packageRootSpecifier), themeEntryFilePath)));
123
+ packageThemeEntryCache.set(packageRootSpecifier, themeEntries);
124
124
  }
125
125
  return packageThemeEntryCache.get(packageRootSpecifier);
126
126
  };
@@ -174,9 +174,12 @@ function manualTestsPlugin(options) {
174
174
  const entry = getManualPageEntryForScriptSpecifier(scriptSpecifier);
175
175
  if (!entry) return;
176
176
  const packageRootSpecifier = entry.htmlFilePath.slice(0, entry.htmlFilePath.indexOf(MANUAL_TESTS_DIRECTORY));
177
- if (!hasPackageThemeEntry(packageRootSpecifier)) return;
177
+ const themeEntries = getPackageThemeEntries(packageRootSpecifier);
178
+ if (!themeEntries.length) return;
178
179
  return {
179
- code: `${code}\nimport '${posix.relative(posix.dirname(scriptSpecifier), `${packageRootSpecifier}/${THEME_ENTRY_FILE_PATH}`)}';\n`,
180
+ code: `${code}\n${themeEntries.map((themeEntryFilePath) => {
181
+ return `import '${posix.relative(posix.dirname(scriptSpecifier), `${packageRootSpecifier}/${themeEntryFilePath}`)}';`;
182
+ }).join("\n")}\n`,
180
183
  map: null
181
184
  };
182
185
  }
@@ -324,126 +327,6 @@ function parseRequestUrl(requestUrl) {
324
327
  return URL.parse(requestUrl || "", "http://localhost");
325
328
  }
326
329
  //#endregion
327
- //#region src/static-assets-plugin/static-assets.ts
328
- /**
329
- * @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
330
- * For licensing, see LICENSE.md.
331
- */
332
- const PROCESSED_MANUAL_TEST_EXTENSIONS = /* @__PURE__ */ new Set([".js", ".ts"]);
333
- const MANUAL_TEST_SUFFIX = ".manual.html";
334
- const VITE_MODULE_QUERY_PARAMETERS = /* @__PURE__ */ new Set([
335
- "import",
336
- "raw",
337
- "url",
338
- "worker",
339
- "inline"
340
- ]);
341
- function collectManualStaticAssets(patterns, workspaceRoot) {
342
- return new Map(patterns.map((pattern) => `${pattern.replace(/\/+$/, "")}/tests/manual/**/*`).flatMap((pattern) => globSync(pattern, { cwd: workspaceRoot })).sort().filter((relativeFilePath) => statSync(resolve(workspaceRoot, relativeFilePath), { throwIfNoEntry: false })?.isFile()).filter(isManualStaticAssetPath).map((relativeFilePath) => [toPublicSpecifier(relativeFilePath), resolve(workspaceRoot, relativeFilePath)]));
343
- }
344
- function createManualStaticAssetsMiddleware(collectStaticAssets) {
345
- return (request, response, next) => {
346
- if (request.method != "GET" && request.method != "HEAD") {
347
- next();
348
- return;
349
- }
350
- const publicPath = getManualStaticAssetPublicPath(request.url);
351
- const filePath = publicPath && collectStaticAssets().get(publicPath);
352
- const fileStats = filePath ? statSync(filePath, { throwIfNoEntry: false }) : void 0;
353
- if (!filePath || !fileStats?.isFile()) {
354
- next();
355
- return;
356
- }
357
- response.statusCode = 200;
358
- response.setHeader("Content-Length", fileStats.size);
359
- response.setHeader("Content-Type", getContentType(extname(filePath)));
360
- if (request.method == "HEAD") {
361
- response.end();
362
- return;
363
- }
364
- const fileStream = createReadStream(filePath);
365
- fileStream.on("error", () => response.destroy());
366
- fileStream.pipe(response);
367
- };
368
- }
369
- function getManualStaticAssetPublicPath(requestUrl) {
370
- const url = URL.parse(requestUrl || "", "http://localhost");
371
- if (!url) return null;
372
- if ([...url.searchParams.keys()].some((key) => VITE_MODULE_QUERY_PARAMETERS.has(key))) return null;
373
- const pathname = decodePathname(url.pathname);
374
- if (!pathname || !isManualStaticAssetPath(pathname)) return null;
375
- return pathname;
376
- }
377
- function decodePathname(pathname) {
378
- try {
379
- return decodeURIComponent(pathname);
380
- } catch {
381
- return null;
382
- }
383
- }
384
- function isManualStaticAssetPath(filePath) {
385
- if (filePath.endsWith(MANUAL_TEST_SUFFIX)) return false;
386
- return extname(filePath) != "" && !PROCESSED_MANUAL_TEST_EXTENSIONS.has(extname(filePath));
387
- }
388
- function getContentType(extension) {
389
- switch (extension) {
390
- case ".avif": return "image/avif";
391
- case ".css": return "text/css; charset=utf-8";
392
- case ".gif": return "image/gif";
393
- case ".htm":
394
- case ".html": return "text/html; charset=utf-8";
395
- case ".ico": return "image/x-icon";
396
- case ".jpg":
397
- case ".jpeg": return "image/jpeg";
398
- case ".json":
399
- case ".map": return "application/json; charset=utf-8";
400
- case ".mp3": return "audio/mpeg";
401
- case ".mp4": return "video/mp4";
402
- case ".png": return "image/png";
403
- case ".svg": return "image/svg+xml";
404
- case ".txt": return "text/plain; charset=utf-8";
405
- case ".webp": return "image/webp";
406
- case ".woff": return "font/woff";
407
- case ".woff2": return "font/woff2";
408
- default: return "application/octet-stream";
409
- }
410
- }
411
- //#endregion
412
- //#region src/static-assets-plugin/plugin.ts
413
- /**
414
- * @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
415
- * For licensing, see LICENSE.md.
416
- */
417
- function manualStaticAssetsPlugin(options) {
418
- let workspaceRoot = process.cwd();
419
- const includePackageNames = (options.include || []).filter(Boolean);
420
- const getManualStaticAssets = cacheValue(() => filterManualStaticAssets(collectManualStaticAssets(options.paths, workspaceRoot), includePackageNames, workspaceRoot)).get;
421
- return {
422
- name: "ckeditor5-manual-static-assets",
423
- configResolved(config) {
424
- workspaceRoot = config.root;
425
- },
426
- configureServer(server) {
427
- server.middlewares.use(createManualStaticAssetsMiddleware(getManualStaticAssets));
428
- },
429
- generateBundle() {
430
- for (const [publicPath, filePath] of getManualStaticAssets()) this.emitFile({
431
- type: "asset",
432
- fileName: stripLeadingSlash(publicPath),
433
- source: readFileSync(filePath)
434
- });
435
- }
436
- };
437
- }
438
- function filterManualStaticAssets(staticAssets, includePackageNames, workspaceRoot) {
439
- const isIncluded = createPackageNameFilter(includePackageNames);
440
- return new Map([...staticAssets].filter(([publicPath]) => isIncluded(getManualTestPackageName(publicPath, workspaceRoot))));
441
- }
442
- function getManualTestPackageName(publicPath, workspaceRoot) {
443
- if (publicPath.startsWith("/tests/manual/")) return basename(workspaceRoot);
444
- return publicPath.match(/^\/(?:.*\/)?([^/]+)\/tests\/manual\//)?.[1] || null;
445
- }
446
- //#endregion
447
330
  //#region src/debug-plugin/plugin.ts
448
331
  /**
449
332
  * @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
@@ -494,7 +377,7 @@ function refreshPlugin() {
494
377
  const bundledDev = server.environments.client.bundledDev;
495
378
  if (!bundledDev) return;
496
379
  wrapBundledDevClientSend(bundledDev.clients);
497
- wrapBundledDevFullReloads(bundledDev);
380
+ wrapBundledDevFullReloads(bundledDev, server.config.root);
498
381
  }
499
382
  };
500
383
  }
@@ -512,27 +395,42 @@ function wrapBundledDevClientSend(clients) {
512
395
  return setupIfNeeded(client, clientId);
513
396
  };
514
397
  }
515
- function wrapBundledDevFullReloads(bundledDev) {
398
+ function wrapBundledDevFullReloads(bundledDev, workspaceRoot) {
516
399
  if (typeof bundledDev.handleHmrOutput != "function") return;
517
400
  const handleHmrOutput = bundledDev.handleHmrOutput.bind(bundledDev);
518
401
  bundledDev.handleHmrOutput = (client, files, hmrOutput, invalidateInformation) => {
519
- if (hmrOutput.type == "FullReload" && shouldShowManualRefreshPromptForFiles(files)) {
520
- ensureLatestBuildOutput(bundledDev);
521
- client.send({
522
- type: "custom",
523
- event: MANUAL_REFRESH_EVENT_NAME
524
- });
402
+ if (hmrOutput.type != "FullReload") return handleHmrOutput(client, files, hmrOutput, invalidateInformation);
403
+ if (!shouldShowManualRefreshPromptForFiles(files)) {
404
+ reloadClientAfterLatestBuildOutput(bundledDev, client, files, workspaceRoot);
525
405
  return;
526
406
  }
527
- return handleHmrOutput(client, files, hmrOutput, invalidateInformation);
407
+ ensureLatestBuildOutput(bundledDev);
408
+ client.send({
409
+ type: "custom",
410
+ event: MANUAL_REFRESH_EVENT_NAME
411
+ });
528
412
  };
529
413
  }
414
+ async function reloadClientAfterLatestBuildOutput(bundledDev, client, files, workspaceRoot) {
415
+ try {
416
+ await bundledDev.devEngine?.ensureLatestBuildOutput();
417
+ } catch {}
418
+ client.send({
419
+ type: "full-reload",
420
+ path: getChangedHtmlPublicPath(files, workspaceRoot)
421
+ });
422
+ }
423
+ function getChangedHtmlPublicPath(files, workspaceRoot) {
424
+ const htmlFile = files.find((file) => file.endsWith(".html"));
425
+ return htmlFile ? toPublicFilePath(htmlFile, workspaceRoot) : void 0;
426
+ }
530
427
  function ensureLatestBuildOutput(bundledDev) {
531
428
  try {
532
429
  bundledDev.devEngine?.ensureLatestBuildOutput().catch(() => {});
533
430
  } catch {}
534
431
  }
535
432
  function sendManualRefreshPayload(payload, send) {
433
+ if (payload.type == "update" && !payload.updates.length) return;
536
434
  if (shouldShowManualRefreshPrompt(payload)) {
537
435
  send({
538
436
  type: "custom",
@@ -583,4 +481,4 @@ function rawSvgPlugin() {
583
481
  * For licensing, see LICENSE.md.
584
482
  */
585
483
  //#endregion
586
- export { ckDebugPlugin, manualStaticAssetsPlugin, manualTestsPlugin, rawSvgPlugin, refreshPlugin, stringifyValues };
484
+ export { ckDebugPlugin, manualTestsPlugin, rawSvgPlugin, refreshPlugin, stringifyValues };
@@ -5,7 +5,7 @@
5
5
  import type { ManualPageEntry } from "./types.js";
6
6
  /**
7
7
  * Discovers manual test pages. A manual test is a single full HTML document named
8
- * `<slug>.manual.html` located under a package's `tests/manual/` directory. Plain `.html`
8
+ * `<slug>.manual.html` located under a package's `manual/` directory. Plain `.html`
9
9
  * files are treated as static fixtures and are never collected here.
10
10
  *
11
11
  * The `patterns` are package root globs, for example `packages/*`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ckeditor/ckeditor5-dev-manual-server",
3
- "version": "57.4.0",
3
+ "version": "59.0.0",
4
4
  "description": "Used to extend Vite to create a manual test server for CKEditor 5.",
5
5
  "keywords": [],
6
6
  "author": "CKSource (http://cksource.com/)",
@@ -0,0 +1,94 @@
1
+ /**
2
+ * @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
3
+ * For licensing, see LICENSE.md.
4
+ */
5
+
6
+ interface FavoritableEntry {
7
+ packageName: string;
8
+ slug: string;
9
+ }
10
+
11
+ const FAVORITES_STORAGE_KEY = 'ckeditor5-manual-test-favorites';
12
+
13
+ /**
14
+ * Returns the stable identifier used to persist a manual test as a favorite.
15
+ */
16
+ export function getFavoriteId( entry: FavoritableEntry ): string {
17
+ return `${ entry.packageName }/${ entry.slug }`;
18
+ }
19
+
20
+ /**
21
+ * Returns the accessible label for a favorite toggle action.
22
+ */
23
+ export function getFavoriteActionLabel( isFavorite: boolean ): string {
24
+ return isFavorite ? 'Remove from favorites' : 'Add to favorites';
25
+ }
26
+
27
+ /**
28
+ * Updates a favorite button from the current favorite identifier set.
29
+ */
30
+ export function updateFavoriteButtonState(
31
+ button: HTMLButtonElement,
32
+ favoriteIds: Set<string>,
33
+ favoriteId: string
34
+ ): void {
35
+ const isFavorite = favoriteIds.has( favoriteId );
36
+ const label = getFavoriteActionLabel( isFavorite );
37
+
38
+ button.title = label;
39
+ button.setAttribute( 'aria-label', label );
40
+ button.setAttribute( 'aria-pressed', String( isFavorite ) );
41
+ button.classList.toggle( 'favorite--active', isFavorite );
42
+ }
43
+
44
+ /**
45
+ * Returns favorite entries in their original order.
46
+ */
47
+ export function filterFavoriteEntries<T extends FavoritableEntry>( entries: Array<T>, favoriteIds: Set<string> ): Array<T> {
48
+ return entries.filter( entry => favoriteIds.has( getFavoriteId( entry ) ) );
49
+ }
50
+
51
+ /**
52
+ * Toggles an identifier in the favorite set.
53
+ */
54
+ export function toggleFavoriteId( favoriteIds: Set<string>, favoriteId: string ): void {
55
+ if ( favoriteIds.has( favoriteId ) ) {
56
+ favoriteIds.delete( favoriteId );
57
+ } else {
58
+ favoriteIds.add( favoriteId );
59
+ }
60
+ }
61
+
62
+ /**
63
+ * Loads favorite entry identifiers from local storage.
64
+ */
65
+ export function loadFavoriteIds(): Set<string> {
66
+ try {
67
+ const storedValue = localStorage.getItem( FAVORITES_STORAGE_KEY );
68
+
69
+ if ( !storedValue ) {
70
+ return new Set();
71
+ }
72
+
73
+ const parsedValue: unknown = JSON.parse( storedValue );
74
+
75
+ if ( !Array.isArray( parsedValue ) || parsedValue.some( value => typeof value != 'string' ) ) {
76
+ return new Set();
77
+ }
78
+
79
+ return new Set( parsedValue );
80
+ } catch {
81
+ return new Set();
82
+ }
83
+ }
84
+
85
+ /**
86
+ * Persists favorite entry identifiers in local storage.
87
+ */
88
+ export function saveFavoriteIds( favoriteIds: Set<string> ): void {
89
+ try {
90
+ localStorage.setItem( FAVORITES_STORAGE_KEY, JSON.stringify( [ ...favoriteIds ] ) );
91
+ } catch {
92
+ // Favorites are optional, so storage failures must not block the catalog.
93
+ }
94
+ }
@@ -0,0 +1,152 @@
1
+ /**
2
+ * @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
3
+ * For licensing, see LICENSE.md.
4
+ */
5
+
6
+ interface SearchableEntry {
7
+ packageName: string;
8
+ slug: string;
9
+ }
10
+
11
+ const PACKAGE_MATCH_PRIORITY = {
12
+ exactName: 0,
13
+ namePrefix: 1,
14
+ nameContains: 2,
15
+ testSlugOnly: 3
16
+ } as const;
17
+
18
+ type PackageMatchPriority = typeof PACKAGE_MATCH_PRIORITY[keyof typeof PACKAGE_MATCH_PRIORITY];
19
+
20
+ interface PackageSearchResult<T extends SearchableEntry> {
21
+ visibleEntries: Array<T>;
22
+ priority: PackageMatchPriority;
23
+ matchingSlugCount: number;
24
+ }
25
+
26
+ export interface MatchOffset {
27
+ start: number;
28
+ end: number;
29
+ }
30
+
31
+ /**
32
+ * Filters tests and orders matching packages by relevance:
33
+ *
34
+ * 1. Exact full or short package name, e.g. `ckeditor5-table` or `table`.
35
+ * 2. Package name whose full or short name starts with the query.
36
+ * 3. Package name containing the query.
37
+ * 4. Packages with matching test slugs only.
38
+ *
39
+ * Packages at the same priority are ordered by the number of matching test slugs. Stable sorting
40
+ * preserves the original package order when that number is also equal. Tests within a package
41
+ * always retain their original order.
42
+ *
43
+ * A package-name match shows all tests from that package. A slug match shows only matching tests.
44
+ */
45
+ export function filterEntries<T extends SearchableEntry>( entries: Array<T>, normalizedQuery: string ): Array<T> {
46
+ if ( !normalizedQuery ) {
47
+ return entries;
48
+ }
49
+
50
+ const packageGroups = groupEntriesByPackage( entries );
51
+ const searchResults = [ ...packageGroups.entries() ]
52
+ .map( ( [ packageName, packageEntries ] ) => createPackageSearchResult(
53
+ packageName,
54
+ packageEntries,
55
+ normalizedQuery
56
+ ) )
57
+ .filter( result => result.visibleEntries.length );
58
+
59
+ searchResults.sort( comparePackageSearchResults );
60
+
61
+ return searchResults.flatMap( result => result.visibleEntries );
62
+ }
63
+
64
+ /**
65
+ * Groups entries by package while preserving their original order.
66
+ */
67
+ function groupEntriesByPackage<T extends SearchableEntry>( entries: Array<T> ): Map<string, Array<T>> {
68
+ const packageGroups = new Map<string, Array<T>>();
69
+
70
+ for ( const entry of entries ) {
71
+ const packageEntries = packageGroups.get( entry.packageName ) || [];
72
+
73
+ packageEntries.push( entry );
74
+ packageGroups.set( entry.packageName, packageEntries );
75
+ }
76
+
77
+ return packageGroups;
78
+ }
79
+
80
+ /**
81
+ * Creates the visible entries and ranking metadata for one package.
82
+ */
83
+ function createPackageSearchResult<T extends SearchableEntry>(
84
+ packageName: string,
85
+ packageEntries: Array<T>,
86
+ normalizedQuery: string
87
+ ): PackageSearchResult<T> {
88
+ const priority = getPackageMatchPriority( packageName, normalizedQuery );
89
+ const matchingSlugEntries = packageEntries.filter( entry => entry.slug.toLowerCase().includes( normalizedQuery ) );
90
+ const packageNameMatches = priority != PACKAGE_MATCH_PRIORITY.testSlugOnly;
91
+
92
+ return {
93
+ visibleEntries: packageNameMatches ? packageEntries : matchingSlugEntries,
94
+ priority,
95
+ matchingSlugCount: matchingSlugEntries.length
96
+ };
97
+ }
98
+
99
+ /**
100
+ * Orders package search results by match priority and matching test count.
101
+ */
102
+ function comparePackageSearchResults<T extends SearchableEntry>(
103
+ first: PackageSearchResult<T>,
104
+ second: PackageSearchResult<T>
105
+ ): number {
106
+ return first.priority - second.priority ||
107
+ second.matchingSlugCount - first.matchingSlugCount;
108
+ }
109
+
110
+ /**
111
+ * Returns how closely a package name matches the normalized query.
112
+ */
113
+ function getPackageMatchPriority( packageName: string, normalizedQuery: string ): PackageMatchPriority {
114
+ const normalizedPackageName = packageName.toLowerCase();
115
+ const searchablePackageNames = [ normalizedPackageName, normalizedPackageName.replace( /^ckeditor5-/, '' ) ];
116
+
117
+ if ( searchablePackageNames.includes( normalizedQuery ) ) {
118
+ return PACKAGE_MATCH_PRIORITY.exactName;
119
+ }
120
+
121
+ if ( searchablePackageNames.some( name => name.startsWith( normalizedQuery ) ) ) {
122
+ return PACKAGE_MATCH_PRIORITY.namePrefix;
123
+ }
124
+
125
+ if ( searchablePackageNames.some( name => name.includes( normalizedQuery ) ) ) {
126
+ return PACKAGE_MATCH_PRIORITY.nameContains;
127
+ }
128
+
129
+ return PACKAGE_MATCH_PRIORITY.testSlugOnly;
130
+ }
131
+
132
+ /**
133
+ * Returns offsets of all non-overlapping, case-insensitive query matches in text.
134
+ */
135
+ export function findMatchOffsets( text: string, normalizedQuery: string ): Array<MatchOffset> {
136
+ if ( !normalizedQuery ) {
137
+ return [];
138
+ }
139
+
140
+ const normalizedText = text.toLowerCase();
141
+ const matches: Array<MatchOffset> = [];
142
+ let matchStart = normalizedText.indexOf( normalizedQuery );
143
+
144
+ while ( matchStart != -1 ) {
145
+ const matchEnd = matchStart + normalizedQuery.length;
146
+
147
+ matches.push( { start: matchStart, end: matchEnd } );
148
+ matchStart = normalizedText.indexOf( normalizedQuery, matchEnd );
149
+ }
150
+
151
+ return matches;
152
+ }