@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 +0 -1
- package/dist/index.js +44 -146
- package/dist/manual-test-plugin/collect-pages.d.ts +1 -1
- package/package.json +1 -1
- package/theme/catalog-favorites.ts +94 -0
- package/theme/catalog-search.ts +152 -0
- package/theme/catalog.css +148 -15
- package/theme/catalog.html +33 -6
- package/theme/catalog.ts +147 -17
- package/theme/favorite-icon.ts +25 -0
- package/theme/manual-bootstrap.css +3 -3
- package/theme/manual-header.css +26 -9
- package/theme/manual-header.ts +36 -4
- package/theme/manual-instructions.css +11 -3
- package/dist/static-assets-plugin/plugin.d.ts +0 -10
- package/dist/static-assets-plugin/static-assets.d.ts +0 -9
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 {
|
|
1
|
+
import { existsSync, globSync, readFileSync, realpathSync } from "node:fs";
|
|
2
2
|
import { fileURLToPath } from "node:url";
|
|
3
|
-
import { basename,
|
|
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 = "
|
|
65
|
-
const MANUAL_TEST_SUFFIX$
|
|
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 `
|
|
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$
|
|
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 +
|
|
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
|
|
98
|
-
const MANUAL_TESTS_DIRECTORY = "/
|
|
99
|
-
const
|
|
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
|
|
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
|
|
120
|
+
const getPackageThemeEntries = (packageRootSpecifier) => {
|
|
121
121
|
if (!packageThemeEntryCache.has(packageRootSpecifier)) {
|
|
122
|
-
const
|
|
123
|
-
packageThemeEntryCache.set(packageRootSpecifier,
|
|
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
|
-
|
|
177
|
+
const themeEntries = getPackageThemeEntries(packageRootSpecifier);
|
|
178
|
+
if (!themeEntries.length) return;
|
|
178
179
|
return {
|
|
179
|
-
code: `${code}\
|
|
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
|
|
520
|
-
|
|
521
|
-
client
|
|
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
|
-
|
|
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,
|
|
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 `
|
|
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
|
@@ -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
|
+
}
|