@ckeditor/ckeditor5-dev-manual-server 58.0.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.
@@ -598,4 +481,4 @@ function rawSvgPlugin() {
598
481
  * For licensing, see LICENSE.md.
599
482
  */
600
483
  //#endregion
601
- 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": "58.0.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
+ }
package/theme/catalog.css CHANGED
@@ -1,3 +1,8 @@
1
+ /**
2
+ * @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
3
+ * For licensing, see LICENSE.md.
4
+ */
5
+
1
6
  :root {
2
7
  color-scheme: light;
3
8
  font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
@@ -15,6 +20,10 @@
15
20
  --text-subtle: #a3a3a3;
16
21
  --accent: #171717;
17
22
  --accent-ring: rgb(23 23 23 / 10%);
23
+ --search-match-background: #e6d3fa;
24
+ --search-match-text: #480090;
25
+ --favorites-surface: #f7f0ff;
26
+ --favorites-border: #d9c2f0;
18
27
  --radius-lg: 12px;
19
28
  --radius-md: 8px;
20
29
  --radius-sm: 6px;
@@ -36,6 +45,12 @@ a {
36
45
  color: inherit;
37
46
  }
38
47
 
48
+ ::highlight(ckeditor5-manual-search-match) {
49
+ background-color: var(--search-match-background);
50
+ color: var(--search-match-text);
51
+ }
52
+
53
+ /* Application shell. */
39
54
  .app {
40
55
  max-width: 1280px;
41
56
  margin: 0 auto;
@@ -75,12 +90,11 @@ a {
75
90
  font-variant-numeric: tabular-nums;
76
91
  }
77
92
 
93
+ /* Search. */
78
94
  .app__search {
79
95
  display: flex;
80
96
  align-items: center;
81
- gap: 10px;
82
97
  height: 44px;
83
- padding: 0 14px;
84
98
  background: var(--surface);
85
99
  border: 1px solid var(--border);
86
100
  border-radius: var(--radius-md);
@@ -92,7 +106,19 @@ a {
92
106
  box-shadow: 0 0 0 4px var(--accent-ring);
93
107
  }
94
108
 
95
- .app__search-icon {
109
+ .app__search-label {
110
+ display: flex;
111
+ align-items: center;
112
+ align-self: stretch;
113
+ flex: 1;
114
+ min-width: 0;
115
+ gap: 10px;
116
+ padding: 0 10px 0 14px;
117
+ cursor: text;
118
+ }
119
+
120
+ .app__search-icon,
121
+ .app__search-clear {
96
122
  flex-shrink: 0;
97
123
  color: var(--text-subtle);
98
124
  }
@@ -110,13 +136,33 @@ a {
110
136
  }
111
137
 
112
138
  .app__search-input::-webkit-search-cancel-button {
113
- cursor: pointer;
139
+ display: none;
114
140
  }
115
141
 
116
142
  .app__search-input::placeholder {
117
143
  color: var(--text-muted);
118
144
  }
119
145
 
146
+ .app__search:has(.app__search-input:placeholder-shown) .app__search-clear {
147
+ visibility: hidden;
148
+ }
149
+
150
+ .app__search-clear {
151
+ margin-inline-end: 10px;
152
+ padding: 6px;
153
+ line-height: 0;
154
+ background: none;
155
+ border: 0;
156
+ border-radius: var(--radius-sm);
157
+ cursor: pointer;
158
+ }
159
+
160
+ .app__search-clear:hover {
161
+ color: var(--text);
162
+ background: var(--surface-hover);
163
+ }
164
+
165
+ /* Catalog content. */
120
166
  .app__list {
121
167
  display: grid;
122
168
  grid-template-columns: repeat(auto-fill, minmax(360px, 1fr));
@@ -145,6 +191,7 @@ a {
145
191
  color: var(--text-muted);
146
192
  }
147
193
 
194
+ /* Package cards. */
148
195
  .pkg {
149
196
  background: var(--surface);
150
197
  border: 1px solid var(--border);
@@ -160,11 +207,14 @@ a {
160
207
  .pkg__header {
161
208
  display: flex;
162
209
  align-items: center;
163
- gap: 10px;
164
- padding: 12px 16px;
210
+ padding: 12px 6px 12px 16px;
165
211
  border-bottom: 1px solid var(--border);
166
212
  }
167
213
 
214
+ .pkg__header > * + * {
215
+ margin-inline-start: 10px;
216
+ }
217
+
168
218
  .pkg__name {
169
219
  flex: 1;
170
220
  min-width: 0;
@@ -177,23 +227,91 @@ a {
177
227
  }
178
228
 
179
229
  .pkg__count {
180
- flex-shrink: 0;
181
- min-width: 24px;
182
- text-align: right;
230
+ flex: 0 0 38px;
231
+ text-align: center;
183
232
  font-size: 12px;
184
233
  color: var(--text-muted);
185
234
  font-variant-numeric: tabular-nums;
186
235
  }
187
236
 
237
+ .pkg__favorite,
238
+ .pkg__favorites-clear {
239
+ flex-shrink: 0;
240
+ line-height: 0;
241
+ color: var(--text-subtle);
242
+ background: none;
243
+ border: 0;
244
+ border-radius: var(--radius-sm);
245
+ cursor: pointer;
246
+ }
247
+
248
+ /* Favorites card. */
249
+ .pkg--favorites,
250
+ .pkg--favorites:hover {
251
+ border-color: var(--favorites-border);
252
+ }
253
+
254
+ .pkg--favorites .pkg__header {
255
+ color: var(--search-match-text);
256
+ background: var(--favorites-surface);
257
+ }
258
+
259
+ .pkg--favorites .pkg__name {
260
+ font-family: inherit;
261
+ }
262
+
263
+ .pkg__favorites-icon {
264
+ flex-shrink: 0;
265
+ }
266
+
267
+ .pkg__favorites-icon path,
268
+ .pkg__favorite-icon path {
269
+ fill: none;
270
+ stroke: currentColor;
271
+ stroke-linejoin: round;
272
+ stroke-width: 1.5px;
273
+ }
274
+
275
+ .pkg__favorites-icon path,
276
+ .favorite--active .pkg__favorite-icon path {
277
+ fill: currentColor;
278
+ }
279
+
280
+ .pkg__favorites-clear {
281
+ padding: 0;
282
+ }
283
+
284
+ .pkg__favorites-clear:hover {
285
+ color: var(--text);
286
+ }
287
+
288
+ .pkg__favorites-clear:not([hidden]) + .pkg__count {
289
+ margin-inline-start: 2px;
290
+ }
291
+
292
+ /* Package contents. */
188
293
  .pkg__list {
189
294
  list-style: none;
190
295
  margin: 0;
191
296
  padding: 6px;
192
297
  }
193
298
 
299
+ .pkg__favorites-empty {
300
+ padding: 10px 12px;
301
+ font-size: 12px;
302
+ color: var(--text-muted);
303
+ }
304
+
305
+ .pkg__item {
306
+ display: flex;
307
+ align-items: center;
308
+ }
309
+
194
310
  .pkg__link {
195
311
  display: flex;
196
312
  align-items: center;
313
+ flex: 1;
314
+ min-width: 0;
197
315
  gap: 12px;
198
316
  padding: 10px 12px;
199
317
  border-radius: var(--radius-sm);
@@ -206,12 +324,6 @@ a {
206
324
  background: var(--surface-hover);
207
325
  }
208
326
 
209
- .pkg__link:focus-visible {
210
- outline: none;
211
- background: var(--surface-hover);
212
- box-shadow: inset 0 0 0 2px var(--border-strong);
213
- }
214
-
215
327
  .pkg__test-name {
216
328
  flex: 1;
217
329
  min-width: 0;
@@ -223,6 +335,27 @@ a {
223
335
  white-space: nowrap;
224
336
  }
225
337
 
338
+ .pkg__favorite {
339
+ padding: 11px;
340
+ }
341
+
342
+ .pkg__favorite:hover {
343
+ color: var(--text);
344
+ background: var(--surface-hover);
345
+ }
346
+
347
+ .pkg__favorite.favorite--active {
348
+ color: var(--search-match-text);
349
+ }
350
+
351
+ .pkg__link:focus-visible,
352
+ .pkg__favorite:focus-visible,
353
+ .pkg__favorites-clear:focus-visible {
354
+ outline: none;
355
+ background: var(--surface-hover);
356
+ box-shadow: inset 0 0 0 2px var(--border-strong);
357
+ }
358
+
226
359
  @media (max-width: 600px) {
227
360
  .app {
228
361
  padding: 0 16px 48px;
@@ -15,12 +15,19 @@
15
15
  <h1 class="app__title">CKEditor 5 manual tests</h1>
16
16
  <p class="app__subtitle"></p>
17
17
  </div>
18
- <label class="app__search">
19
- <svg class="app__search-icon" viewBox="0 0 16 16" aria-hidden="true" width="16" height="16">
20
- <path fill="currentColor" d="M7 2a5 5 0 103.546 8.546l3.204 3.204a.75.75 0 101.06-1.06l-3.204-3.204A5 5 0 007 2zM3.5 7a3.5 3.5 0 117 0 3.5 3.5 0 01-7 0z"/>
21
- </svg>
22
- <input type="search" class="app__search-input" placeholder="Filter by package or test name…" autocomplete="off" autofocus>
23
- </label>
18
+ <div class="app__search" role="search">
19
+ <label class="app__search-label">
20
+ <svg class="app__search-icon" viewBox="0 0 16 16" aria-hidden="true" width="16" height="16">
21
+ <path fill="currentColor" d="M7 2a5 5 0 103.546 8.546l3.204 3.204a.75.75 0 101.06-1.06l-3.204-3.204A5 5 0 007 2zM3.5 7a3.5 3.5 0 117 0 3.5 3.5 0 01-7 0z"/>
22
+ </svg>
23
+ <input type="search" class="app__search-input" placeholder="Filter by package or test name…" autocomplete="off" autofocus>
24
+ </label>
25
+ <button type="button" class="app__search-clear" aria-label="Clear search">
26
+ <svg viewBox="0 0 16 16" aria-hidden="true" width="16" height="16">
27
+ <path fill="currentColor" d="M3.22 3.22a.75.75 0 011.06 0L8 6.94l3.72-3.72a.75.75 0 111.06 1.06L9.06 8l3.72 3.72a.75.75 0 11-1.06 1.06L8 9.06l-3.72 3.72a.75.75 0 01-1.06-1.06L6.94 8 3.22 4.28a.75.75 0 010-1.06z"/>
28
+ </svg>
29
+ </button>
30
+ </div>
24
31
  </header>
25
32
  <div class="app__list" role="list"></div>
26
33
  </main>
@@ -43,11 +50,31 @@
43
50
  </section>
44
51
  </template>
45
52
 
53
+ <template id="manual-favorites-template">
54
+ <section class="pkg pkg--favorites" role="listitem">
55
+ <header class="pkg__header">
56
+ <span class="pkg__name">Favorites</span>
57
+ <button type="button" class="pkg__favorites-clear" aria-label="Clear all favorites" title="Clear all favorites" hidden>
58
+ <svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true">
59
+ <path fill="currentColor" d="M5.5 2.5h5l.5 1H14V5H2V3.5h3l.5-1zM3.5 6h9l-.65 8h-7.7L3.5 6zm2.25 1.5v5h1.5v-5h-1.5zm3 0v5h1.5v-5h-1.5z"/>
60
+ </svg>
61
+ </button>
62
+ <span class="pkg__count"></span>
63
+ </header>
64
+ <ul class="pkg__list"></ul>
65
+ </section>
66
+ </template>
67
+
68
+ <template id="manual-favorites-empty-template">
69
+ <li class="pkg__favorites-empty">Star tests to add them here.</li>
70
+ </template>
71
+
46
72
  <template id="manual-test-template">
47
73
  <li class="pkg__item">
48
74
  <a class="pkg__link">
49
75
  <span class="pkg__test-name"></span>
50
76
  </a>
77
+ <button type="button" class="pkg__favorite"></button>
51
78
  </li>
52
79
  </template>
53
80
 
package/theme/catalog.ts CHANGED
@@ -5,10 +5,22 @@
5
5
 
6
6
  // @ts-expect-error Virtual module provided by the manual test entries plugin.
7
7
  import { manualTestEntries, type ManualTestEntry } from 'virtual:ckeditor5-manual-entries';
8
+ import {
9
+ filterFavoriteEntries,
10
+ getFavoriteId,
11
+ loadFavoriteIds,
12
+ saveFavoriteIds,
13
+ toggleFavoriteId,
14
+ updateFavoriteButtonState
15
+ } from './catalog-favorites.js';
16
+ import { filterEntries, findMatchOffsets } from './catalog-search.js';
17
+ import { createFavoriteIcon } from './favorite-icon.js';
8
18
 
9
19
  import './catalog.css';
10
20
 
11
21
  const SEARCH_QUERY_PARAMETER = 'q';
22
+ const SEARCH_HIGHLIGHT_NAME = 'ckeditor5-manual-search-match';
23
+ const SEARCH_LABEL_SELECTOR = '.pkg__name, .pkg__test-name';
12
24
 
13
25
  /**
14
26
  * Root element for the manual test index application.
@@ -22,6 +34,7 @@ renderApp( appElement, manualTestEntries );
22
34
  */
23
35
  function renderApp( root: HTMLElement, entries: Array<ManualTestEntry> ): void {
24
36
  const packageCount = new Set( entries.map( entry => entry.packageName ) ).size;
37
+ const favoriteIds = loadFavoriteIds();
25
38
 
26
39
  root.replaceChildren( cloneTemplateElement( 'manual-index-template' ) );
27
40
 
@@ -29,9 +42,18 @@ function renderApp( root: HTMLElement, entries: Array<ManualTestEntry> ): void {
29
42
 
30
43
  const listElement = root.querySelector<HTMLElement>( '.app__list' )!;
31
44
  const searchInput = root.querySelector<HTMLInputElement>( '.app__search-input' )!;
45
+ const clearSearchButton = root.querySelector<HTMLButtonElement>( '.app__search-clear' )!;
32
46
 
33
47
  const render = ( query: string ): void => {
34
- listElement.replaceChildren( ...renderGroups( filterEntries( entries, query ) ) );
48
+ const normalizedQuery = query.trim().toLowerCase();
49
+ const renderedGroups = renderGroups( filterEntries( entries, normalizedQuery ), favoriteIds );
50
+
51
+ if ( !normalizedQuery && entries.length ) {
52
+ renderedGroups.unshift( renderFavoritesCard( entries, favoriteIds ) );
53
+ }
54
+
55
+ listElement.replaceChildren( ...renderedGroups );
56
+ updateSearchHighlights( listElement, normalizedQuery );
35
57
  };
36
58
 
37
59
  const setQuery = ( query: string ): void => {
@@ -39,9 +61,54 @@ function renderApp( root: HTMLElement, entries: Array<ManualTestEntry> ): void {
39
61
  render( query );
40
62
  };
41
63
 
42
- searchInput.addEventListener( 'input', () => {
64
+ const applySearchInput = (): void => {
43
65
  updateSearchQueryInUrl( searchInput.value );
44
66
  render( searchInput.value );
67
+ };
68
+
69
+ searchInput.addEventListener( 'input', applySearchInput );
70
+
71
+ listElement.addEventListener( 'click', event => {
72
+ const target = event.target;
73
+
74
+ if ( !( target instanceof Element ) ) {
75
+ return;
76
+ }
77
+
78
+ const favoriteActionButton = target.closest<HTMLButtonElement>( '.pkg__favorite, .pkg__favorites-clear' );
79
+
80
+ if ( !favoriteActionButton ) {
81
+ return;
82
+ }
83
+
84
+ const favoriteId = favoriteActionButton.dataset.favoriteId;
85
+ const shouldRestoreFocus = document.activeElement == favoriteActionButton;
86
+
87
+ if ( favoriteId ) {
88
+ toggleFavoriteId( favoriteIds, favoriteId );
89
+ } else {
90
+ favoriteIds.clear();
91
+ }
92
+
93
+ saveFavoriteIds( favoriteIds );
94
+ render( searchInput.value );
95
+
96
+ if ( !shouldRestoreFocus ) {
97
+ return;
98
+ }
99
+
100
+ if ( favoriteId ) {
101
+ focusFavoriteButton( listElement, favoriteId );
102
+ } else {
103
+ searchInput.focus();
104
+ }
105
+ } );
106
+
107
+ clearSearchButton.addEventListener( 'click', () => {
108
+ searchInput.value = '';
109
+ searchInput.focus();
110
+
111
+ applySearchInput();
45
112
  } );
46
113
 
47
114
  window.addEventListener( 'popstate', () => {
@@ -59,6 +126,16 @@ function renderApp( root: HTMLElement, entries: Array<ManualTestEntry> ): void {
59
126
  setQuery( getSearchQueryFromUrl() );
60
127
  }
61
128
 
129
+ /**
130
+ * Focuses the re-rendered favorite control in its package card.
131
+ */
132
+ function focusFavoriteButton( root: HTMLElement, favoriteId: string ): void {
133
+ const favoriteButton = [ ...root.querySelectorAll<HTMLButtonElement>( '.pkg:not(.pkg--favorites) .pkg__favorite' ) ]
134
+ .find( button => button.dataset.favoriteId == favoriteId );
135
+
136
+ favoriteButton?.focus();
137
+ }
138
+
62
139
  /**
63
140
  * Returns the current search query from the page URL.
64
141
  */
@@ -86,25 +163,46 @@ function updateSearchQueryInUrl( query: string ): void {
86
163
  }
87
164
 
88
165
  /**
89
- * Returns entries matching the current search query.
166
+ * Highlights query matches in the currently rendered package and test names.
90
167
  */
91
- function filterEntries( entries: Array<ManualTestEntry>, query: string ): Array<ManualTestEntry> {
92
- const normalized = query.trim().toLowerCase();
168
+ function updateSearchHighlights( root: HTMLElement, normalizedQuery: string ): void {
169
+ if ( !( 'highlights' in CSS ) ) {
170
+ return;
171
+ }
93
172
 
94
- if ( !normalized ) {
95
- return entries;
173
+ CSS.highlights.delete( SEARCH_HIGHLIGHT_NAME );
174
+
175
+ if ( !normalizedQuery ) {
176
+ return;
96
177
  }
97
178
 
98
- return entries.filter( entry =>
99
- entry.packageName.toLowerCase().includes( normalized ) ||
100
- entry.slug.toLowerCase().includes( normalized )
101
- );
179
+ const ranges: Array<Range> = [];
180
+
181
+ for ( const label of root.querySelectorAll<HTMLElement>( SEARCH_LABEL_SELECTOR ) ) {
182
+ const textNode = label.firstChild;
183
+
184
+ if ( !textNode || textNode.nodeType != Node.TEXT_NODE ) {
185
+ continue;
186
+ }
187
+
188
+ for ( const { start, end } of findMatchOffsets( textNode.textContent!, normalizedQuery ) ) {
189
+ const range = new Range();
190
+
191
+ range.setStart( textNode, start );
192
+ range.setEnd( textNode, end );
193
+ ranges.push( range );
194
+ }
195
+ }
196
+
197
+ if ( ranges.length ) {
198
+ CSS.highlights.set( SEARCH_HIGHLIGHT_NAME, new Highlight( ...ranges ) );
199
+ }
102
200
  }
103
201
 
104
202
  /**
105
203
  * Groups visible entries by package and renders package cards.
106
204
  */
107
- function renderGroups( entries: Array<ManualTestEntry> ): Array<HTMLElement> {
205
+ function renderGroups( entries: Array<ManualTestEntry>, favoriteIds: Set<string> ): Array<HTMLElement> {
108
206
  if ( !entries.length ) {
109
207
  return [ cloneTemplateElement( 'manual-empty-template' ) ];
110
208
  }
@@ -118,32 +216,64 @@ function renderGroups( entries: Array<ManualTestEntry> ): Array<HTMLElement> {
118
216
  groups.set( entry.packageName, group );
119
217
  }
120
218
 
121
- return [ ...groups.entries() ].map( ( [ packageName, packageEntries ] ) => renderPackageCard( packageName, packageEntries ) );
219
+ return [ ...groups.entries() ].map( ( [ packageName, packageEntries ] ) =>
220
+ renderPackageCard( packageName, packageEntries, favoriteIds )
221
+ );
122
222
  }
123
223
 
124
224
  /**
125
225
  * Renders a single package card with all tests belonging to that package.
126
226
  */
127
- function renderPackageCard( packageName: string, entries: Array<ManualTestEntry> ): HTMLElement {
227
+ function renderPackageCard( packageName: string, entries: Array<ManualTestEntry>, favoriteIds: Set<string> ): HTMLElement {
128
228
  const packageCard = cloneTemplateElement( 'manual-package-template' );
129
229
  const list = packageCard.querySelector<HTMLElement>( '.pkg__list' )!;
130
230
 
131
231
  packageCard.querySelector<HTMLElement>( '.pkg__name' )!.textContent = packageName;
132
232
  packageCard.querySelector<HTMLElement>( '.pkg__count' )!.textContent = String( entries.length );
133
- list.replaceChildren( ...entries.map( entry => renderTestItem( entry ) ) );
233
+ list.replaceChildren( ...entries.map( entry => renderTestItem( entry, favoriteIds ) ) );
134
234
 
135
235
  return packageCard;
136
236
  }
137
237
 
238
+ /**
239
+ * Renders the favorites card, including its empty state.
240
+ */
241
+ function renderFavoritesCard( entries: Array<ManualTestEntry>, favoriteIds: Set<string> ): HTMLElement {
242
+ const favoritesCard = cloneTemplateElement( 'manual-favorites-template' );
243
+ const favoriteEntries = filterFavoriteEntries( entries, favoriteIds );
244
+ const list = favoritesCard.querySelector<HTMLElement>( '.pkg__list' )!;
245
+ const clearButton = favoritesCard.querySelector<HTMLButtonElement>( '.pkg__favorites-clear' )!;
246
+
247
+ favoritesCard.querySelector<HTMLElement>( '.pkg__header' )!.prepend( createFavoriteIcon( 'pkg__favorites-icon' ) );
248
+ favoritesCard.querySelector<HTMLElement>( '.pkg__count' )!.textContent = String( favoriteEntries.length );
249
+ clearButton.hidden = !favoriteEntries.length;
250
+
251
+ if ( favoriteEntries.length ) {
252
+ list.replaceChildren( ...favoriteEntries.map( entry => renderTestItem( entry, favoriteIds, true ) ) );
253
+ } else {
254
+ list.replaceChildren( cloneTemplateElement( 'manual-favorites-empty-template' ) );
255
+ }
256
+
257
+ return favoritesCard;
258
+ }
259
+
138
260
  /**
139
261
  * Renders a single test link inside a package card.
140
262
  */
141
- function renderTestItem( entry: ManualTestEntry ): HTMLElement {
263
+ function renderTestItem( entry: ManualTestEntry, favoriteIds: Set<string>, showPackageName = false ): HTMLElement {
142
264
  const testItem = cloneTemplateElement( 'manual-test-template' );
143
265
  const link = testItem.querySelector<HTMLAnchorElement>( '.pkg__link' )!;
266
+ const favoriteButton = testItem.querySelector<HTMLButtonElement>( '.pkg__favorite' )!;
267
+ const favoriteId = getFavoriteId( entry );
144
268
 
145
269
  link.href = entry.href;
146
- testItem.querySelector<HTMLElement>( '.pkg__test-name' )!.textContent = entry.slug;
270
+
271
+ testItem.querySelector<HTMLElement>( '.pkg__test-name' )!.textContent = showPackageName ? favoriteId : entry.slug;
272
+
273
+ favoriteButton.dataset.favoriteId = favoriteId;
274
+ favoriteButton.append( createFavoriteIcon( 'pkg__favorite-icon' ) );
275
+
276
+ updateFavoriteButtonState( favoriteButton, favoriteIds, favoriteId );
147
277
 
148
278
  return testItem;
149
279
  }
@@ -0,0 +1,25 @@
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
+ const SVG_NAMESPACE = 'http://www.w3.org/2000/svg';
7
+ const FAVORITE_ICON_PATH = 'M8 1.5l1.94 3.93 4.34.63-3.14 3.06.74 4.32L8 11.4l-3.88 2.04.74-4.32-3.14-3.06 4.34-.63L8 1.5z';
8
+
9
+ /**
10
+ * Creates the favorite star icon used by the catalog and manual test header.
11
+ */
12
+ export function createFavoriteIcon( className: string ): SVGSVGElement {
13
+ const icon = document.createElementNS( SVG_NAMESPACE, 'svg' );
14
+ const path = document.createElementNS( SVG_NAMESPACE, 'path' );
15
+
16
+ icon.classList.add( className );
17
+ icon.setAttribute( 'viewBox', '0 0 16 16' );
18
+ icon.setAttribute( 'width', '16' );
19
+ icon.setAttribute( 'height', '16' );
20
+ icon.setAttribute( 'aria-hidden', 'true' );
21
+ path.setAttribute( 'd', FAVORITE_ICON_PATH );
22
+ icon.append( path );
23
+
24
+ return icon;
25
+ }
@@ -7,7 +7,7 @@
7
7
 
8
8
  .refresh-prompt {
9
9
  position: fixed;
10
- top: calc( var( --ck-manual-header-height, 44px ) / 1.25 );
10
+ top: 16px;
11
11
  left: 50%;
12
12
  z-index: 100000;
13
13
  box-sizing: border-box;
@@ -20,14 +20,14 @@
20
20
  cursor: pointer;
21
21
  opacity: 0;
22
22
  pointer-events: none;
23
- transform: translate(-50%, calc( -100% - var( --ck-manual-header-height, 44px ) ) );
23
+ transform: translate(-50%, calc( -100% - 16px ) );
24
24
  transition: transform 0.18s ease-out, opacity 0.18s ease-out, background 0.1s;
25
25
  }
26
26
 
27
27
  .refresh-prompt--visible {
28
28
  opacity: 1;
29
29
  pointer-events: auto;
30
- transform: translate(-50%, -50%);
30
+ transform: translate(-50%, 0);
31
31
  }
32
32
 
33
33
  .refresh-prompt:hover {
@@ -7,15 +7,9 @@
7
7
 
8
8
  :host {
9
9
  display: block;
10
- --ck-manual-header-height: 44px;
11
10
  }
12
11
 
13
12
  .bar {
14
- position: fixed;
15
- top: 0;
16
- left: 0;
17
- right: 0;
18
- z-index: 99999;
19
13
  display: flex;
20
14
  align-items: stretch;
21
15
  box-sizing: border-box;
@@ -37,7 +31,7 @@
37
31
  padding: 8px 14px;
38
32
  border: 0;
39
33
  background: transparent;
40
- border-radius: 999px;
34
+ border-radius: 6px;
41
35
  text-decoration: none;
42
36
  color: inherit;
43
37
  font: inherit;
@@ -74,7 +68,7 @@
74
68
  align-items: center;
75
69
  flex: 1 1 auto;
76
70
  gap: 10px;
77
- padding: 8px 14px;
71
+ padding: 0 14px;
78
72
  overflow: hidden;
79
73
  min-width: 0;
80
74
  }
@@ -96,6 +90,29 @@
96
90
  white-space: nowrap;
97
91
  }
98
92
 
93
+ .favorite {
94
+ flex-shrink: 0;
95
+ justify-content: center;
96
+ padding: 8px;
97
+ margin-inline-start: -8px;
98
+ color: #737373;
99
+ }
100
+
101
+ .favorite__icon {
102
+ fill: none;
103
+ stroke: currentColor;
104
+ stroke-linejoin: round;
105
+ stroke-width: 1.5px;
106
+ }
107
+
108
+ .favorite--active {
109
+ color: #6d28d9;
110
+ }
111
+
112
+ .favorite--active .favorite__icon {
113
+ fill: currentColor;
114
+ }
115
+
99
116
  :host( :not( [ has-instructions ] ) ) .instructions-only {
100
117
  display: none;
101
118
  }
@@ -119,7 +136,7 @@
119
136
  --panel-transition-duration: 0.18s;
120
137
 
121
138
  position: fixed;
122
- top: var(--ck-manual-header-height);
139
+ top: 0;
123
140
  right: 0;
124
141
  bottom: 0;
125
142
  z-index: 99998;
@@ -7,6 +7,14 @@
7
7
  // Imported as raw strings so they can be adopted into constructable stylesheets.
8
8
  import headerStyles from './manual-header.css?inline';
9
9
  import instructionsStyles from './manual-instructions.css?inline';
10
+ import {
11
+ getFavoriteId,
12
+ loadFavoriteIds,
13
+ saveFavoriteIds,
14
+ toggleFavoriteId,
15
+ updateFavoriteButtonState
16
+ } from './catalog-favorites.js';
17
+ import { createFavoriteIcon } from './favorite-icon.js';
10
18
 
11
19
  const MANUAL_HEADER_DATA_META = 'ck-manual-header';
12
20
 
@@ -19,6 +27,7 @@ const TEMPLATE = /* html */ `
19
27
  <a class="pill back" title="Back to test index">&larr; All tests</a>
20
28
  <span class="divider" aria-hidden="true"></span>
21
29
  <span class="label">
30
+ <button type="button" class="pill favorite" data-action="favorite"></button>
22
31
  <span class="label__name" data-field="name"></span>
23
32
  <span class="label__package" data-field="package"></span>
24
33
  </span>
@@ -47,9 +56,9 @@ const TEMPLATE = /* html */ `
47
56
  /* eslint-enable @stylistic/max-len */
48
57
 
49
58
  /**
50
- * `<ck-manual-header>` renders the manual test chrome: a fixed header bar with the test
51
- * name, package name and a back-to-index link, plus a collapsible instructions panel that
52
- * projects the element's light-DOM children (default slot).
59
+ * `<ck-manual-header>` renders the manual test chrome: a header bar with the test name,
60
+ * package name, back-to-index link and favorite toggle, plus a collapsible instructions
61
+ * panel that projects the element's light-DOM children (default slot).
53
62
  *
54
63
  * The manual test server injects a `<meta name="ck-manual-header">` carrying the package
55
64
  * name and the base-aware catalog href, and the `<script>` that defines this
@@ -61,6 +70,7 @@ const TEMPLATE = /* html */ `
61
70
  */
62
71
  class ManualHeaderElement extends HTMLElement {
63
72
  private _panel!: HTMLElement;
73
+ private _favoriteId!: string;
64
74
 
65
75
  constructor() {
66
76
  super();
@@ -69,6 +79,7 @@ class ManualHeaderElement extends HTMLElement {
69
79
 
70
80
  shadow.adoptedStyleSheets = [ headerStyleSheet ];
71
81
  shadow.innerHTML = TEMPLATE;
82
+ shadow.querySelector<HTMLElement>( '[data-action="favorite"]' )!.append( createFavoriteIcon( 'favorite__icon' ) );
72
83
  }
73
84
 
74
85
  public connectedCallback(): void {
@@ -82,6 +93,10 @@ class ManualHeaderElement extends HTMLElement {
82
93
  const backLink = shadow.querySelector<HTMLAnchorElement>( '.back' )!;
83
94
 
84
95
  backLink.href = data.catalogHref;
96
+ this._favoriteId = getFavoriteId( {
97
+ packageName: data.packageName,
98
+ slug: getSlugFromLocation()
99
+ } );
85
100
 
86
101
  for ( const element of shadow.querySelectorAll<HTMLElement>( '[data-field="name"]' ) ) {
87
102
  element.textContent = this._resolveDisplayName();
@@ -91,6 +106,7 @@ class ManualHeaderElement extends HTMLElement {
91
106
  element.textContent = data.packageName;
92
107
  }
93
108
 
109
+ this._renderFavoriteAction( loadFavoriteIds() );
94
110
  this._setUpInstructions();
95
111
  this._setUpActions( shadow );
96
112
  }
@@ -129,7 +145,9 @@ class ManualHeaderElement extends HTMLElement {
129
145
  return;
130
146
  }
131
147
 
132
- if ( trigger.dataset.action == 'toggle' ) {
148
+ if ( trigger.dataset.action == 'favorite' ) {
149
+ this._toggleFavorite();
150
+ } else if ( trigger.dataset.action == 'toggle' ) {
133
151
  this._toggleInstructions( !this._panel.classList.contains( 'panel--open' ) );
134
152
  } else if ( trigger.dataset.action == 'close' ) {
135
153
  this._toggleInstructions( false );
@@ -143,6 +161,20 @@ class ManualHeaderElement extends HTMLElement {
143
161
  } );
144
162
  }
145
163
 
164
+ private _toggleFavorite(): void {
165
+ const favoriteIds = loadFavoriteIds();
166
+
167
+ toggleFavoriteId( favoriteIds, this._favoriteId );
168
+ saveFavoriteIds( favoriteIds );
169
+ this._renderFavoriteAction( favoriteIds );
170
+ }
171
+
172
+ private _renderFavoriteAction( favoriteIds: Set<string> ): void {
173
+ const trigger = this.shadowRoot!.querySelector<HTMLButtonElement>( '[data-action="favorite"]' )!;
174
+
175
+ updateFavoriteButtonState( trigger, favoriteIds, this._favoriteId );
176
+ }
177
+
146
178
  private _toggleInstructions( isOpen: boolean ): void {
147
179
  const trigger = this.shadowRoot!.querySelector<HTMLElement>( '[data-action="toggle"]' )!;
148
180
 
@@ -11,12 +11,20 @@
11
11
  * reach nested nodes, so instruction typography is scoped here to `ck-manual-header` descendants.
12
12
  */
13
13
 
14
- /* Offset for the fixed header bar injected by the component. */
15
- body {
16
- padding-top: var( --ck-manual-header-height, 44px );
14
+ /*
15
+ * `:where()` keeps zero specificity so anything a test styles itself takes precedence.
16
+ */
17
+ :where(*, *::before, *::after) {
18
+ box-sizing: border-box;
19
+ }
20
+
21
+ :where(body) {
22
+ margin: 0;
23
+ min-height: 100vh;
17
24
  }
18
25
 
19
26
  ck-manual-header {
27
+ margin-bottom: 12px;
20
28
  font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
21
29
  line-height: 1.6;
22
30
  color: #0a0a0a;
@@ -1,10 +0,0 @@
1
- /**
2
- * @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
3
- * For licensing, see LICENSE.md.
4
- */
5
- import type { Plugin } from "vite";
6
- export interface ManualStaticAssetsPluginOptions {
7
- paths: Array<string>;
8
- include?: Array<string>;
9
- }
10
- export declare function manualStaticAssetsPlugin(options: ManualStaticAssetsPluginOptions): Plugin;
@@ -1,9 +0,0 @@
1
- /**
2
- * @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
3
- * For licensing, see LICENSE.md.
4
- */
5
- import type { IncomingMessage, ServerResponse } from "node:http";
6
- type ManualStaticAssetsMiddleware = (request: IncomingMessage, response: ServerResponse, next: () => void) => void;
7
- export declare function collectManualStaticAssets(patterns: Array<string>, workspaceRoot: string): Map<string, string>;
8
- export declare function createManualStaticAssetsMiddleware(collectStaticAssets: () => Map<string, string>): ManualStaticAssetsMiddleware;
9
- export {};