From fdefb9d16c133d3f6dd417a1a3a52bc09db15d29 Mon Sep 17 00:00:00 2001 From: Adam Wright Date: Thu, 26 Feb 2026 15:47:15 -0500 Subject: [PATCH 1/3] feat: Add client-side site search for static content pages Adds a /tools/site-search page that lets users search across all 161 Reactome static pages (news, docs, community, about, etc.) using MiniSearch for client-side full-text search. The build-time index generator is extended to produce a consolidated site-search-index.json with URL deduplication. Results are grouped by category with filterable chips, counts, and expandable "Show all" pagination per group. Also fixes: search bar no longer hardcodes advanced=true, and adds tools/advanced redirect for backward compatibility. Co-Authored-By: Claude Opus 4.6 --- .gitignore | 3 + package-lock.json | 7 + package.json | 1 + .../website-angular/src/app/app.routes.ts | 4 +- .../search-bar/search-bar.component.scss | 9 +- .../search/search-bar/search-bar.component.ts | 4 +- .../site-search/site-search.component.html | 79 ++++++ .../site-search/site-search.component.scss | 228 ++++++++++++++++++ .../app/site-search/site-search.component.ts | 207 ++++++++++++++++ .../src/scripts/generate-index.ts | 148 +++++++++++- .../website-angular/src/types/site-search.ts | 9 + 11 files changed, 694 insertions(+), 5 deletions(-) create mode 100644 projects/website-angular/src/app/site-search/site-search.component.html create mode 100644 projects/website-angular/src/app/site-search/site-search.component.scss create mode 100644 projects/website-angular/src/app/site-search/site-search.component.ts create mode 100644 projects/website-angular/src/types/site-search.ts diff --git a/.gitignore b/.gitignore index a5bbec0..e78e2f3 100644 --- a/.gitignore +++ b/.gitignore @@ -55,6 +55,9 @@ Thumbs.db **/*.css **/*.css.map +# Generated search indices +projects/website-angular/public/site-search-index.json + # Ignore npm lock file package-lock.json diff --git a/package-lock.json b/package-lock.json index 4adf2fc..76d9153 100644 --- a/package-lock.json +++ b/package-lock.json @@ -43,6 +43,7 @@ "express": "4.18.2", "immer": "^11.1.3", "marked": "^17.0.1", + "minisearch": "^7.2.0", "ng-table-virtual-scroll": "^1.6.1", "ngrx-wieder": "^15.0.0", "ngx-custom-material-file-input": "^19.0.0", @@ -21497,6 +21498,12 @@ "dev": true, "license": "ISC" }, + "node_modules/minisearch": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/minisearch/-/minisearch-7.2.0.tgz", + "integrity": "sha512-dqT2XBYUOZOiC5t2HRnwADjhNS2cecp9u+TJRiJ1Qp/f5qjkeT5APcGPjHw+bz89Ms8Jp+cG4AlE+QZ/QnDglg==", + "license": "MIT" + }, "node_modules/minizlib": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", diff --git a/package.json b/package.json index e69f3d5..a1978f9 100644 --- a/package.json +++ b/package.json @@ -56,6 +56,7 @@ "express": "4.18.2", "immer": "^11.1.3", "marked": "^17.0.1", + "minisearch": "^7.2.0", "ng-table-virtual-scroll": "^1.6.1", "ngrx-wieder": "^15.0.0", "ngx-custom-material-file-input": "^19.0.0", diff --git a/projects/website-angular/src/app/app.routes.ts b/projects/website-angular/src/app/app.routes.ts index 07838fa..7ff911e 100644 --- a/projects/website-angular/src/app/app.routes.ts +++ b/projects/website-angular/src/app/app.routes.ts @@ -26,9 +26,11 @@ export const routes: Routes = [ { path: 'content/reactome-research-spotlight', loadComponent: () => import('./article/article-page/article-page.component').then(m => m.ArticlePageComponent), pathMatch: 'full' }, { path: 'content/reactome-research-spotlight/:slug', loadComponent: () => import('./article/article/article.component').then(m => m.ArticleComponent), pathMatch: 'full' }, - //Search Page + //Search Pages { path: 'content/advanced', redirectTo: '/content/query?advanced=true' }, + { path: 'tools/advanced', redirectTo: '/content/query?advanced=true' }, { path: 'content/query', loadComponent: () => import('./search/search.component').then(m => m.SearchComponent) }, + { path: 'tools/site-search', loadComponent: () => import('./site-search/site-search.component').then(m => m.SiteSearchComponent) }, //404 Page { path: '404', loadComponent: () => import('./page-not-found/page-not-found.component').then(m => m.PageNotFoundComponent) }, //TODO: Remove? diff --git a/projects/website-angular/src/app/search/search-bar/search-bar.component.scss b/projects/website-angular/src/app/search/search-bar/search-bar.component.scss index 6913ea3..75d7e6d 100644 --- a/projects/website-angular/src/app/search/search-bar/search-bar.component.scss +++ b/projects/website-angular/src/app/search/search-bar/search-bar.component.scss @@ -29,10 +29,17 @@ $border-radius: 8px; flex: 1; padding: 8px 16px; border: 2px solid var(--primary); - border-radius: 4px 0 0 4px; + border-radius: 4px; font-size: 20px; resize: vertical; line-height: 1.3; + + ~ .search-button { + align-self: flex-start; + margin-left: 8px; + padding: 8px 20px; + border-radius: 4px; + } } .search-button { diff --git a/projects/website-angular/src/app/search/search-bar/search-bar.component.ts b/projects/website-angular/src/app/search/search-bar/search-bar.component.ts index f08555f..d485ad3 100644 --- a/projects/website-angular/src/app/search/search-bar/search-bar.component.ts +++ b/projects/website-angular/src/app/search/search-bar/search-bar.component.ts @@ -72,7 +72,7 @@ export class SearchBarComponent implements OnChanges { const params: Record = { q: q, - advanced: 'true', + advanced: this.advancedMode ? 'true' : null, page: null, }; @@ -109,7 +109,7 @@ export class SearchBarComponent implements OnChanges { const params: Record = { q: s, - advanced: 'true', + advanced: this.advancedMode ? 'true' : null, page: null, }; diff --git a/projects/website-angular/src/app/site-search/site-search.component.html b/projects/website-angular/src/app/site-search/site-search.component.html new file mode 100644 index 0000000..a2ad47c --- /dev/null +++ b/projects/website-angular/src/app/site-search/site-search.component.html @@ -0,0 +1,79 @@ + + + diff --git a/projects/website-angular/src/app/site-search/site-search.component.scss b/projects/website-angular/src/app/site-search/site-search.component.scss new file mode 100644 index 0000000..e79c193 --- /dev/null +++ b/projects/website-angular/src/app/site-search/site-search.component.scss @@ -0,0 +1,228 @@ +$spacing: 24px; +$border-radius: 8px; + +.site-search { + max-width: 800px; + margin: 0 auto; + padding: $spacing 0; +} + +h1 { + margin: 0 0 8px; +} + +.site-search-description { + color: var(--on-surface-variant); + margin: 0 0 $spacing; +} + +.search-bar-container { + display: flex; + align-items: stretch; + width: 100%; + margin-bottom: $spacing; +} + +.search-input { + flex: 1; + padding: 10px 16px; + border: 2px solid var(--primary); + border-radius: 4px; + border-top-right-radius: 0; + border-bottom-right-radius: 0; + font-size: 18px; + + &:focus { + outline: none; + border-color: var(--primary); + } +} + +.search-button { + padding: 10px 24px; + border: 2px solid var(--primary); + border-radius: 4px; + border-top-left-radius: 0; + border-bottom-left-radius: 0; + background-color: var(--primary); + color: var(--on-primary); + font-size: 18px; + cursor: pointer; +} + +.loading { + text-align: center; + padding: $spacing * 2; + color: var(--on-surface-variant); + font-size: 1.1rem; +} + +.no-results { + text-align: center; + padding: $spacing * 2; + color: var(--on-surface-variant); + + p:first-child { + font-size: 1.1rem; + } +} + +.results-summary { + color: var(--on-surface-variant); + font-size: 0.9rem; + margin-bottom: 12px; +} + +.category-chips { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-bottom: $spacing; +} + +.category-chip { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 14px; + border: 1px solid rgba(0, 0, 0, 0.15); + border-radius: 20px; + background: none; + color: var(--on-surface); + font-size: 0.85rem; + cursor: pointer; + transition: all 0.15s ease; + + :host-context(.dark) & { + border-color: rgba(255, 255, 255, 0.15); + } + + &:hover { + border-color: var(--primary); + color: var(--primary); + } + + &.active { + background: var(--primary); + border-color: var(--primary); + color: var(--on-primary); + + .chip-count { + background: rgba(255, 255, 255, 0.25); + color: var(--on-primary); + } + } +} + +.chip-count { + background: rgba(0, 0, 0, 0.08); + border-radius: 10px; + padding: 1px 7px; + font-size: 0.78rem; + font-weight: 600; + + :host-context(.dark) & { + background: rgba(255, 255, 255, 0.1); + } +} + +.result-group { + margin-bottom: $spacing; +} + +.show-all-btn { + display: block; + width: 100%; + padding: 10px; + margin-top: 4px; + border: 1px dashed rgba(0, 0, 0, 0.15); + border-radius: $border-radius; + background: none; + color: var(--primary); + font-size: 0.85rem; + font-weight: 500; + cursor: pointer; + transition: all 0.15s ease; + + &:hover { + background: rgba(0, 0, 0, 0.03); + border-color: var(--primary); + } + + :host-context(.dark) & { + border-color: rgba(255, 255, 255, 0.15); + + &:hover { + background: rgba(255, 255, 255, 0.04); + border-color: var(--primary); + } + } +} + +.group-title { + font-size: 1rem; + font-weight: 600; + color: var(--on-surface-variant); + text-transform: uppercase; + letter-spacing: 0.5px; + padding-bottom: 8px; + border-bottom: 2px solid var(--primary); + margin: 0 0 12px; +} + +.result-item { + display: block; + padding: 12px 16px; + border-radius: $border-radius; + text-decoration: none; + color: inherit; + transition: background 0.15s ease; + + &:hover { + background: rgba(0, 0, 0, 0.04); + + :host-context(.dark) & { + background: rgba(255, 255, 255, 0.04); + } + } + + & + .result-item { + border-top: 1px solid rgba(0, 0, 0, 0.06); + + :host-context(.dark) & { + border-top-color: rgba(255, 255, 255, 0.06); + } + } +} + +.result-title { + font-size: 1.05rem; + font-weight: 500; + color: var(--primary); + margin-bottom: 2px; +} + +.result-url { + font-size: 0.8rem; + color: var(--on-surface-variant); + margin-bottom: 4px; +} + +.result-excerpt { + font-size: 0.9rem; + color: var(--on-surface); + line-height: 1.4; +} + +.search-prompt { + text-align: center; + padding: $spacing * 3 $spacing; + color: var(--on-surface-variant); + + .search-prompt-icon { + font-size: 48px; + opacity: 0.3; + display: block; + margin-bottom: 12px; + } +} diff --git a/projects/website-angular/src/app/site-search/site-search.component.ts b/projects/website-angular/src/app/site-search/site-search.component.ts new file mode 100644 index 0000000..1a69633 --- /dev/null +++ b/projects/website-angular/src/app/site-search/site-search.component.ts @@ -0,0 +1,207 @@ +import { Component, inject, OnInit, OnDestroy } from '@angular/core'; +import { ActivatedRoute, Router, RouterLink } from '@angular/router'; +import { HttpClient } from '@angular/common/http'; +import { Subscription } from 'rxjs'; +import MiniSearch from 'minisearch'; +import { PageLayoutComponent } from '../page-layout/page-layout.component'; +import { SiteSearchIndexItem } from '../../types/site-search'; + +interface SearchResultItem extends SiteSearchIndexItem { + score: number; +} + +interface GroupedResults { + category: string; + items: SearchResultItem[]; +} + +@Component({ + selector: 'app-site-search', + standalone: true, + imports: [PageLayoutComponent, RouterLink], + templateUrl: './site-search.component.html', + styleUrl: './site-search.component.scss', +}) +export class SiteSearchComponent implements OnInit, OnDestroy { + private route = inject(ActivatedRoute); + private router = inject(Router); + private http = inject(HttpClient); + + private miniSearch: MiniSearch | null = null; + private allItems: SearchResultItem[] = []; + private paramsSub!: Subscription; + + query = ''; + results: SearchResultItem[] = []; + groupedResults: GroupedResults[] = []; + totalCategoryCounts: { category: string; count: number }[] = []; + categoryCounts: { category: string; count: number }[] = []; + activeCategory: string | null = null; + expandedGroups: Set = new Set(); + previewLimit = 5; + loading = true; + indexLoaded = false; + searched = false; + totalCount = 0; + + ngOnInit(): void { + this.loadIndex(); + this.paramsSub = this.route.queryParams.subscribe((params) => { + const q = params['q'] || ''; + if (q !== this.query) { + this.query = q; + if (this.indexLoaded) { + this.doSearch(); + } + } + }); + } + + ngOnDestroy(): void { + this.paramsSub?.unsubscribe(); + } + + private loadIndex(): void { + this.http.get('/site-search-index.json').subscribe({ + next: (items) => { + this.miniSearch = new MiniSearch({ + fields: ['title', 'body'], + storeFields: ['title', 'category', 'url', 'excerpt', 'date'], + searchOptions: { + boost: { title: 3 }, + fuzzy: 0.2, + prefix: true, + }, + }); + this.miniSearch.addAll(items); + this.indexLoaded = true; + this.loading = false; + + // Store all items for browsing without a query + this.allItems = items.map((item) => ({ ...item, score: 0 })); + this.totalCount = items.length; + + // Build total category counts from full index + const countMap = new Map(); + for (const item of items) { + countMap.set(item.category, (countMap.get(item.category) || 0) + 1); + } + this.totalCategoryCounts = Array.from(countMap.entries()) + .map(([category, count]) => ({ category, count })) + .sort((a, b) => b.count - a.count); + + // Show all results by default + this.doSearch(); + }, + error: () => { + this.loading = false; + }, + }); + } + + onInput(event: Event): void { + const value = (event.target as HTMLInputElement).value; + this.query = value; + this.doSearch(); + } + + onSubmit(event: Event): void { + event.preventDefault(); + this.updateUrl(); + } + + filterByCategory(category: string | null): void { + this.activeCategory = category; + this.buildGroupedResults(); + } + + isExpanded(category: string): boolean { + return this.expandedGroups.has(category); + } + + toggleExpand(category: string): void { + if (this.expandedGroups.has(category)) { + this.expandedGroups.delete(category); + } else { + this.expandedGroups.add(category); + } + } + + getVisibleItems(group: GroupedResults): SearchResultItem[] { + if (this.expandedGroups.has(group.category)) { + return group.items; + } + return group.items.slice(0, this.previewLimit); + } + + private doSearch(): void { + if (!this.miniSearch) return; + + this.searched = !!this.query.trim(); + this.expandedGroups.clear(); + + if (!this.query.trim()) { + // No query — show all items + this.results = this.allItems; + this.categoryCounts = this.totalCategoryCounts; + } else { + // Search with query + const raw = this.miniSearch.search(this.query.trim()); + this.results = raw.map((r) => ({ + id: r.id as number, + title: r['title'], + category: r['category'], + url: r['url'], + body: r['body'] || '', + excerpt: r['excerpt'], + date: r['date'], + score: r.score, + })); + + // Build category counts from search results + const countMap = new Map(); + for (const item of this.results) { + countMap.set(item.category, (countMap.get(item.category) || 0) + 1); + } + this.categoryCounts = Array.from(countMap.entries()) + .map(([category, count]) => ({ category, count })) + .sort((a, b) => b.count - a.count); + } + + // Reset filter if the active category has no results + if (this.activeCategory && !this.results.some((r) => r.category === this.activeCategory)) { + this.activeCategory = null; + } + + this.buildGroupedResults(); + } + + private buildGroupedResults(): void { + const filtered = this.activeCategory + ? this.results.filter((item) => item.category === this.activeCategory) + : this.results; + + const groups = new Map(); + for (const item of filtered) { + const existing = groups.get(item.category); + if (existing) { + existing.push(item); + } else { + groups.set(item.category, [item]); + } + } + + this.groupedResults = Array.from(groups.entries()).map(([category, items]) => ({ + category, + items, + })); + } + + private updateUrl(): void { + this.router.navigate([], { + relativeTo: this.route, + queryParams: { q: this.query.trim() || null }, + queryParamsHandling: 'merge', + }); + } +} diff --git a/projects/website-angular/src/scripts/generate-index.ts b/projects/website-angular/src/scripts/generate-index.ts index b7c29d6..2ed0cf9 100644 --- a/projects/website-angular/src/scripts/generate-index.ts +++ b/projects/website-angular/src/scripts/generate-index.ts @@ -1,6 +1,7 @@ import * as fs from 'fs'; import * as path from 'path'; import { ArticleIndexItem } from '../types/article'; +import { SiteSearchIndexItem } from '../types/site-search'; import parseFrontmatter from '../utils/parseFrontmatter'; import truncateHtml from '../utils/truncateHtml'; @@ -52,6 +53,151 @@ function generateIndex(...directories: string[]): void { } +/** + * Strip markdown/MDX syntax to produce plain text for search indexing + */ +function stripMarkdown(md: string): string { + return md + .replace(/^---[\s\S]*?---\n?/, '') // frontmatter + .replace(/import\s+.*?from\s+['"].*?['"]\s*;?\n?/g, '') // ESM imports + .replace(/<[^>]+>/g, '') // HTML/JSX tags + .replace(/!\[.*?\]\(.*?\)/g, '') // images + .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1') // links → text + .replace(/#{1,6}\s+/g, '') // headings + .replace(/(\*{1,3}|_{1,3})(.*?)\1/g, '$2') // bold/italic + .replace(/`{1,3}[^`]*`{1,3}/g, '') // inline/block code + .replace(/>\s?/gm, '') // blockquotes + .replace(/[-*+]\s+/gm, '') // list markers + .replace(/\d+\.\s+/gm, '') // ordered list markers + .replace(/\n{2,}/g, '\n') // collapse blank lines + .replace(/\s+/g, ' ') // normalize whitespace + .trim(); +} + +/** + * Recursively find all .mdx/.md files in a directory + */ +function findAllMdxFiles(dir: string): string[] { + const results: string[] = []; + if (!fs.existsSync(dir)) return results; + + const entries = fs.readdirSync(dir, { withFileTypes: true }); + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + results.push(...findAllMdxFiles(fullPath)); + } else if (entry.name.endsWith('.mdx') || entry.name.endsWith('.md')) { + // Skip index.json artifacts + if (entry.name !== 'index.json') { + results.push(fullPath); + } + } + } + return results; +} + +/** + * Map a file path to its site URL + * e.g. content/about/what-is-reactome.mdx → /about/what-is-reactome + * content/about/news/article-1.mdx → /about/news/article-1 + * content/documentation/dev/index.mdx → /documentation/dev + */ +function filePathToUrl(filePath: string, contentRoot: string): string { + let relative = path.relative(contentRoot, filePath); + // Remove extension + relative = relative.replace(/\.(mdx|md)$/, ''); + // Remove trailing /index + relative = relative.replace(/\/index$/, ''); + // Convert to URL + return '/' + relative.replace(/\\/g, '/'); +} + +/** + * Infer a human-readable category from the top-level directory + */ +function inferCategory(url: string): string { + const categoryMap: Record = { + about: 'About', + content: 'Content', + documentation: 'Documentation', + community: 'Community', + tools: 'Tools', + }; + const topDir = url.split('/')[1] || ''; + // Special sub-categories + if (url.startsWith('/about/news/')) return 'News'; + if (url.startsWith('/content/reactome-research-spotlight/')) return 'Research Spotlight'; + return categoryMap[topDir] || 'Other'; +} + +/** + * Generate a consolidated site search index covering all content + */ +function generateSiteSearchIndex(): void { + const contentRoot = path.resolve( + process.cwd(), + 'projects', + 'website-angular', + 'content' + ); + + if (!fs.existsSync(contentRoot)) { + console.warn('Content directory not found:', contentRoot); + return; + } + + const allFiles = findAllMdxFiles(contentRoot); + const items: SiteSearchIndexItem[] = []; + const seenUrls = new Set(); + let nextId = 1; + + for (const filePath of allFiles) { + const raw = fs.readFileSync(filePath, 'utf-8'); + const { frontmatter, body } = parseFrontmatter(raw); + + const url = filePathToUrl(filePath, contentRoot); + + // Skip duplicates (e.g. collaboration.mdx and collaboration/index.mdx) + if (seenUrls.has(url)) continue; + seenUrls.add(url); + const title = + (frontmatter['title'] as string) || + path.basename(filePath).replace(/\.(mdx|md)$/, '').replace(/-/g, ' '); + const category = + (frontmatter['category'] as string) ? inferCategory(url) : inferCategory(url); + const plainBody = stripMarkdown(body); + const excerpt = plainBody.slice(0, 200) + (plainBody.length > 200 ? '...' : ''); + + items.push({ + id: nextId++, + title, + category, + url, + body: plainBody, + excerpt, + date: (frontmatter['date'] as string) || undefined, + }); + } + + // Write to public assets so it can be fetched at runtime + const outputDir = path.resolve( + process.cwd(), + 'projects', + 'website-angular', + 'public' + ); + if (!fs.existsSync(outputDir)) { + fs.mkdirSync(outputDir, { recursive: true }); + } + + const outputPath = path.join(outputDir, 'site-search-index.json'); + fs.writeFileSync(outputPath, JSON.stringify(items)); + console.log( + `Site search index generated: ${items.length} entries → ${outputPath}` + ); +} + // Run on module load generateIndex('projects', 'website-angular', 'content', 'about', 'news'); -generateIndex('projects', 'website-angular', 'content', 'content', 'reactome-research-spotlight') \ No newline at end of file +generateIndex('projects', 'website-angular', 'content', 'content', 'reactome-research-spotlight'); +generateSiteSearchIndex(); \ No newline at end of file diff --git a/projects/website-angular/src/types/site-search.ts b/projects/website-angular/src/types/site-search.ts new file mode 100644 index 0000000..367784b --- /dev/null +++ b/projects/website-angular/src/types/site-search.ts @@ -0,0 +1,9 @@ +export interface SiteSearchIndexItem { + id: number; + title: string; + category: string; + url: string; + body: string; + excerpt: string; + date?: string; +} From 314c73506b64becaa2118118ac3dd2540e29b79e Mon Sep 17 00:00:00 2001 From: el-rabies Date: Fri, 27 Feb 2026 09:46:08 -0500 Subject: [PATCH 2/3] fix: Removed Unused Routes --- projects/website-angular/src/app/app.routes.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/projects/website-angular/src/app/app.routes.ts b/projects/website-angular/src/app/app.routes.ts index 7ff911e..ca2651b 100644 --- a/projects/website-angular/src/app/app.routes.ts +++ b/projects/website-angular/src/app/app.routes.ts @@ -27,8 +27,6 @@ export const routes: Routes = [ { path: 'content/reactome-research-spotlight/:slug', loadComponent: () => import('./article/article/article.component').then(m => m.ArticleComponent), pathMatch: 'full' }, //Search Pages - { path: 'content/advanced', redirectTo: '/content/query?advanced=true' }, - { path: 'tools/advanced', redirectTo: '/content/query?advanced=true' }, { path: 'content/query', loadComponent: () => import('./search/search.component').then(m => m.SearchComponent) }, { path: 'tools/site-search', loadComponent: () => import('./site-search/site-search.component').then(m => m.SiteSearchComponent) }, From 4f0e4c3a830f358609d138884c23d01bdb18fd82 Mon Sep 17 00:00:00 2001 From: el-rabies Date: Fri, 27 Feb 2026 09:50:09 -0500 Subject: [PATCH 3/3] chore: formatting --- .../search-bar/search-bar.component.scss | 6 +- .../search/search-bar/search-bar.component.ts | 16 ++- .../site-search/site-search.component.html | 106 +++++++++--------- .../app/site-search/site-search.component.ts | 15 ++- .../src/scripts/generate-index.ts | 67 ++++++----- 5 files changed, 119 insertions(+), 91 deletions(-) diff --git a/projects/website-angular/src/app/search/search-bar/search-bar.component.scss b/projects/website-angular/src/app/search/search-bar/search-bar.component.scss index 75d7e6d..4fd6143 100644 --- a/projects/website-angular/src/app/search/search-bar/search-bar.component.scss +++ b/projects/website-angular/src/app/search/search-bar/search-bar.component.scss @@ -201,7 +201,7 @@ $border-radius: 8px; } .advanced-facets-title { - text-align: left; + text-align: left; } .facet-option { @@ -221,7 +221,7 @@ $border-radius: 8px; } } - input[type='checkbox'] { + input[type="checkbox"] { flex-shrink: 0; accent-color: var(--primary); } @@ -238,4 +238,4 @@ $border-radius: 8px; .facet-count { color: var(--on-surface-variant); flex-shrink: 0; -} \ No newline at end of file +} diff --git a/projects/website-angular/src/app/search/search-bar/search-bar.component.ts b/projects/website-angular/src/app/search/search-bar/search-bar.component.ts index d485ad3..54d2801 100644 --- a/projects/website-angular/src/app/search/search-bar/search-bar.component.ts +++ b/projects/website-angular/src/app/search/search-bar/search-bar.component.ts @@ -15,7 +15,7 @@ import { SearchFilters, SearchService, } from 'projects/website-angular/src/services/search.service'; -import { DropdownToggleComponent } from "../../reactome-components/dropdown-toggle/dropdown-toggle.component"; +import { DropdownToggleComponent } from '../../reactome-components/dropdown-toggle/dropdown-toggle.component'; @Component({ selector: 'app-search-bar', @@ -76,7 +76,12 @@ export class SearchBarComponent implements OnChanges { page: null, }; - for (const key of ['species', 'types', 'compartments', 'keywords'] as const) { + for (const key of [ + 'species', + 'types', + 'compartments', + 'keywords', + ] as const) { const values = this.advancedFilters[key]; params[key] = values?.length ? values : null; } @@ -113,7 +118,12 @@ export class SearchBarComponent implements OnChanges { page: null, }; - for (const key of ['species', 'types', 'compartments', 'keywords'] as const) { + for (const key of [ + 'species', + 'types', + 'compartments', + 'keywords', + ] as const) { const values = this.advancedFilters[key]; params[key] = values?.length ? values : null; } diff --git a/projects/website-angular/src/app/site-search/site-search.component.html b/projects/website-angular/src/app/site-search/site-search.component.html index a2ad47c..c7cd306 100644 --- a/projects/website-angular/src/app/site-search/site-search.component.html +++ b/projects/website-angular/src/app/site-search/site-search.component.html @@ -2,7 +2,8 @@ diff --git a/projects/website-angular/src/app/site-search/site-search.component.ts b/projects/website-angular/src/app/site-search/site-search.component.ts index 1a69633..f49ad0b 100644 --- a/projects/website-angular/src/app/site-search/site-search.component.ts +++ b/projects/website-angular/src/app/site-search/site-search.component.ts @@ -169,7 +169,10 @@ export class SiteSearchComponent implements OnInit, OnDestroy { } // Reset filter if the active category has no results - if (this.activeCategory && !this.results.some((r) => r.category === this.activeCategory)) { + if ( + this.activeCategory && + !this.results.some((r) => r.category === this.activeCategory) + ) { this.activeCategory = null; } @@ -191,10 +194,12 @@ export class SiteSearchComponent implements OnInit, OnDestroy { } } - this.groupedResults = Array.from(groups.entries()).map(([category, items]) => ({ - category, - items, - })); + this.groupedResults = Array.from(groups.entries()).map( + ([category, items]) => ({ + category, + items, + }) + ); } private updateUrl(): void { diff --git a/projects/website-angular/src/scripts/generate-index.ts b/projects/website-angular/src/scripts/generate-index.ts index 2ed0cf9..707e8e2 100644 --- a/projects/website-angular/src/scripts/generate-index.ts +++ b/projects/website-angular/src/scripts/generate-index.ts @@ -13,7 +13,9 @@ function loadNewsArticles(...directories: string[]): ArticleIndexItem[] { return []; } - const files = fs.readdirSync(newsDir).filter((f) => f.endsWith('.mdx') || f.endsWith('.md')); + const files = fs + .readdirSync(newsDir) + .filter((f) => f.endsWith('.mdx') || f.endsWith('.md')); const articles = files .map((filename) => { const filePath = path.join(newsDir, filename); @@ -26,13 +28,17 @@ function loadNewsArticles(...directories: string[]): ArticleIndexItem[] { excerpt: truncateHtml(body || '', 50), date: frontmatter['date'] || new Date().toISOString(), slug: filename.replace(/\.(mdx|md)$/, ''), - tags: typeof frontmatter['tags'] === 'string' ? frontmatter['tags'].split(',').map((t: string) => t.trim().replace(/^[\[\["']+|[\]'"]+$/g, '')) : frontmatter['tags'], + tags: + typeof frontmatter['tags'] === 'string' + ? frontmatter['tags'] + .split(',') + .map((t: string) => + t.trim().replace(/^[\[\["']+|[\]'"]+$/g, '') + ) + : frontmatter['tags'], } as ArticleIndexItem; }) - .sort( - (a, b) => - new Date(b.date).getTime() - new Date(a.date).getTime() - ); + .sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()); return articles; } @@ -50,7 +56,6 @@ function generateIndex(...directories: string[]): void { const outputPath = path.join(outputDir, 'index.json'); fs.writeFileSync(outputPath, JSON.stringify(articles, null, 2)); - } /** @@ -58,19 +63,19 @@ function generateIndex(...directories: string[]): void { */ function stripMarkdown(md: string): string { return md - .replace(/^---[\s\S]*?---\n?/, '') // frontmatter + .replace(/^---[\s\S]*?---\n?/, '') // frontmatter .replace(/import\s+.*?from\s+['"].*?['"]\s*;?\n?/g, '') // ESM imports - .replace(/<[^>]+>/g, '') // HTML/JSX tags - .replace(/!\[.*?\]\(.*?\)/g, '') // images - .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1') // links → text - .replace(/#{1,6}\s+/g, '') // headings + .replace(/<[^>]+>/g, '') // HTML/JSX tags + .replace(/!\[.*?\]\(.*?\)/g, '') // images + .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1') // links → text + .replace(/#{1,6}\s+/g, '') // headings .replace(/(\*{1,3}|_{1,3})(.*?)\1/g, '$2') // bold/italic - .replace(/`{1,3}[^`]*`{1,3}/g, '') // inline/block code - .replace(/>\s?/gm, '') // blockquotes - .replace(/[-*+]\s+/gm, '') // list markers - .replace(/\d+\.\s+/gm, '') // ordered list markers - .replace(/\n{2,}/g, '\n') // collapse blank lines - .replace(/\s+/g, ' ') // normalize whitespace + .replace(/`{1,3}[^`]*`{1,3}/g, '') // inline/block code + .replace(/>\s?/gm, '') // blockquotes + .replace(/[-*+]\s+/gm, '') // list markers + .replace(/\d+\.\s+/gm, '') // ordered list markers + .replace(/\n{2,}/g, '\n') // collapse blank lines + .replace(/\s+/g, ' ') // normalize whitespace .trim(); } @@ -126,7 +131,8 @@ function inferCategory(url: string): string { const topDir = url.split('/')[1] || ''; // Special sub-categories if (url.startsWith('/about/news/')) return 'News'; - if (url.startsWith('/content/reactome-research-spotlight/')) return 'Research Spotlight'; + if (url.startsWith('/content/reactome-research-spotlight/')) + return 'Research Spotlight'; return categoryMap[topDir] || 'Other'; } @@ -162,11 +168,16 @@ function generateSiteSearchIndex(): void { seenUrls.add(url); const title = (frontmatter['title'] as string) || - path.basename(filePath).replace(/\.(mdx|md)$/, '').replace(/-/g, ' '); - const category = - (frontmatter['category'] as string) ? inferCategory(url) : inferCategory(url); + path + .basename(filePath) + .replace(/\.(mdx|md)$/, '') + .replace(/-/g, ' '); + const category = (frontmatter['category'] as string) + ? inferCategory(url) + : inferCategory(url); const plainBody = stripMarkdown(body); - const excerpt = plainBody.slice(0, 200) + (plainBody.length > 200 ? '...' : ''); + const excerpt = + plainBody.slice(0, 200) + (plainBody.length > 200 ? '...' : ''); items.push({ id: nextId++, @@ -199,5 +210,11 @@ function generateSiteSearchIndex(): void { // Run on module load generateIndex('projects', 'website-angular', 'content', 'about', 'news'); -generateIndex('projects', 'website-angular', 'content', 'content', 'reactome-research-spotlight'); -generateSiteSearchIndex(); \ No newline at end of file +generateIndex( + 'projects', + 'website-angular', + 'content', + 'content', + 'reactome-research-spotlight' +); +generateSiteSearchIndex();