Skip to content

Commit e080da1

Browse files
Add client-side full-text search and edit controls
Search (Lunr.js): - search.json: Jekyll template generating JSON search index at build time - search.html: Search results page with accessible form - assets/search.js: Client-side search logic with highlighted excerpts - On mobile, search button shows magnifying glass icon - High-contrast highlight colors in both light and dark modes Edit controls: - "Edit" button (compose icon + text) next to page title - Footer links: "Edit this page" and "View history" - Uses GitHub Octicons icons
1 parent 3d7a7bc commit e080da1

5 files changed

Lines changed: 327 additions & 4 deletions

File tree

README.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,17 @@ The wiki was initially populated with 294 pages from wiki.csswg.org covering:
1616
- **[Specification Issues and Planning](/spec/)** — Wiki pages for tracking spec-related thoughts
1717
- **[Testing](/test/)** — CSS testing documentation
1818

19+
## Search
20+
21+
The site includes client-side full-text search powered by [Lunr.js](https://lunrjs.com/):
22+
23+
- **Build-time indexing**`search.json` generates a JSON index of all pages at build time
24+
- **Client-side search** — No server required; search runs entirely in the browser
25+
- **Highlighted excerpts** — Search results show matching terms highlighted in context
26+
- **Keyboard accessible** — Header search form submits to dedicated results page
27+
28+
Search is available via the search box in the site header, or directly at `/search/`.
29+
1930
## Editing
2031

2132
Each page has an “edit this page” link that takes you to the GitHub editor. Changes are deployed automatically when merged to `main`.

_layouts/default.html

Lines changed: 132 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -550,6 +550,20 @@
550550
outline: var(--focus-outline);
551551
outline-offset: var(--focus-offset);
552552
}
553+
.search-page-form button {
554+
display: inline-flex;
555+
align-items: center;
556+
justify-content: center;
557+
}
558+
.search-btn-icon { display: none; }
559+
@media (max-width: 640px) {
560+
.search-btn-text { display: none; }
561+
.search-btn-icon { display: block; }
562+
.search-page-form button {
563+
padding: var(--space-sm);
564+
aspect-ratio: 1;
565+
}
566+
}
553567
.search-results-list {
554568
list-style: none;
555569
padding: 0;
@@ -585,17 +599,100 @@
585599
line-height: 1.5;
586600
}
587601
.search-result-excerpt mark {
588-
background: #fff8c5;
589-
color: inherit;
602+
background: #fef08a;
603+
color: #1f2328;
590604
padding: 0.1em 0.2em;
591605
border-radius: var(--radius-sm);
592606
}
593607
@media (prefers-color-scheme: dark) {
594608
.search-result-excerpt mark {
595-
background: #3d2e00;
609+
background: #fbbf24;
610+
color: #1f2328;
596611
}
597612
}
598613

614+
/* Edit Controls */
615+
.edit-icon-link {
616+
display: inline-flex;
617+
align-items: center;
618+
justify-content: center;
619+
min-width: var(--min-tap-size);
620+
min-height: var(--min-tap-size);
621+
padding: var(--space-sm);
622+
color: var(--color-text-muted);
623+
text-decoration: none;
624+
border-radius: var(--radius);
625+
transition: color var(--transition), background var(--transition);
626+
}
627+
.edit-icon-link:hover,
628+
.edit-icon-link:focus {
629+
color: var(--color-link);
630+
background: var(--color-bg-subtle);
631+
}
632+
.edit-icon-link svg {
633+
width: 18px;
634+
height: 18px;
635+
}
636+
637+
/* Page title with edit (JS-injected) */
638+
.page-title-wrapper {
639+
display: flex;
640+
align-items: flex-start;
641+
gap: var(--space-sm);
642+
}
643+
.page-title-wrapper h1 {
644+
flex: 1;
645+
}
646+
.page-title-edit {
647+
flex-shrink: 0;
648+
margin-top: 0.35em;
649+
min-width: auto;
650+
min-height: auto;
651+
padding: var(--space-xs) var(--space-sm);
652+
display: inline-flex;
653+
align-items: center;
654+
gap: 0.3em;
655+
font-size: 0.875rem;
656+
font-weight: 500;
657+
border: 1px solid var(--color-border);
658+
}
659+
.page-title-edit svg {
660+
width: 14px;
661+
height: 14px;
662+
}
663+
664+
/* Footer edit link */
665+
.edit-footer {
666+
margin-top: var(--space-xl);
667+
padding-top: var(--space-lg);
668+
border-top: 1px solid var(--color-border-muted);
669+
display: flex;
670+
flex-wrap: wrap;
671+
align-items: center;
672+
gap: var(--space-sm) var(--space-lg);
673+
}
674+
.edit-footer-link {
675+
display: inline-flex;
676+
align-items: center;
677+
gap: var(--space-sm);
678+
color: var(--color-text-muted);
679+
text-decoration: none;
680+
font-size: 0.9375rem;
681+
padding: var(--space-sm) 0;
682+
}
683+
.edit-footer-link:hover,
684+
.edit-footer-link:focus {
685+
color: var(--color-link);
686+
}
687+
.edit-footer-link svg {
688+
width: 16px;
689+
height: 16px;
690+
}
691+
.edit-footer-sep {
692+
color: var(--color-border);
693+
line-height: 1;
694+
}
695+
599696
/* Selection */
600697
::selection {
601698
background: var(--color-link);
@@ -618,7 +715,7 @@
618715
/* Print Styles */
619716
@media print {
620717
body { background: #fff; color: #000; }
621-
.edit-banner, .menu-toggle, .skip-link, .search-form { display: none; }
718+
.edit-banner, .menu-toggle, .skip-link, .search-form, .page-title-edit, .edit-footer { display: none; }
622719
.site-header { position: static; border-bottom: 1px solid #ccc; }
623720
a { color: #000; text-decoration: underline; }
624721
a[href^="http"]::after {
@@ -683,6 +780,17 @@
683780
</nav>
684781
{% endif %}
685782
{{ content }}
783+
<footer class="edit-footer">
784+
<a href="https://github.com/{{ site.repository }}/edit/main/{{ page.path }}" class="edit-footer-link">
785+
<svg viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="m14.515.456.965.965a1.555 1.555 0 0 1 0 2.2L9.745 9.355a1.549 1.549 0 0 1-.672.396l-2.89.826a.67.67 0 0 1-.828-.474.66.66 0 0 1 .004-.35l.825-2.89c.073-.254.209-.486.396-.673L12.315.456c.144-.145.316-.259.505-.337a1.538 1.538 0 0 1 1.19 0c.189.078.361.192.505.337Zm-3.322 3.008-3.67 3.669a.214.214 0 0 0-.057.096L6.97 8.965l1.736-.496a.218.218 0 0 0 .096-.056l3.67-3.67Zm2.065-2.066L12.135 2.52l1.28 1.28 1.122-1.122a.216.216 0 0 0 .065-.157.216.216 0 0 0-.065-.157l-.965-.966a.216.216 0 0 0-.157-.065.226.226 0 0 0-.157.065Z"/><path d="M0 14.25V2.75A1.75 1.75 0 0 1 1.75 1H7a.75.75 0 0 1 0 1.5H1.75a.25.25 0 0 0-.25.25v11.5a.25.25 0 0 0 .25.25h11.5a.25.25 0 0 0 .25-.25V8.5a.75.75 0 0 1 1.5 0v5.75c0 .464-.184.909-.513 1.237A1.746 1.746 0 0 1 13.25 16H1.75A1.75 1.75 0 0 1 0 14.25Z"/></svg>
786+
Edit this page
787+
</a>
788+
<span class="edit-footer-sep" aria-hidden="true">|</span>
789+
<a href="https://github.com/{{ site.repository }}/commits/main/{{ page.path }}" class="edit-footer-link">
790+
<svg viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="m.427 1.927 5.455 5.455a.25.25 0 0 1 0 .354L.427 13.19A.25.25 0 0 1 0 13.014V1.986a.25.25 0 0 1 .427-.06Zm1.822 5.077 1.25-1.25a.25.25 0 0 1 .354 0l1.25 1.25a.25.25 0 0 1-.354.353l-.823-.823v4.216a.25.25 0 0 1-.5 0V6.534l-.823.823a.25.25 0 0 1-.354-.353Zm5.25-5.75a.75.75 0 0 1 .75-.75h6a.75.75 0 0 1 0 1.5h-6a.75.75 0 0 1-.75-.75Zm.75 5.75a.75.75 0 0 0 0 1.5h6a.75.75 0 0 0 0-1.5h-6Zm-.75 6.75a.75.75 0 0 1 .75-.75h6a.75.75 0 0 1 0 1.5h-6a.75.75 0 0 1-.75-.75Z"/></svg>
791+
View history
792+
</a>
793+
</footer>
686794
</div>
687795
</main>
688796

@@ -735,6 +843,26 @@
735843
wrapper.appendChild(table);
736844
}
737845
});
846+
847+
// Inject edit icon next to first h1
848+
const firstH1 = document.querySelector('main h1');
849+
if (firstH1 && !firstH1.closest('.page-title-wrapper')) {
850+
const editUrl = document.querySelector('.edit-footer-link')?.href;
851+
if (editUrl) {
852+
const wrapper = document.createElement('div');
853+
wrapper.className = 'page-title-wrapper';
854+
firstH1.parentNode.insertBefore(wrapper, firstH1);
855+
wrapper.appendChild(firstH1);
856+
857+
const editLink = document.createElement('a');
858+
editLink.href = editUrl;
859+
editLink.className = 'edit-icon-link page-title-edit';
860+
editLink.setAttribute('aria-label', 'Edit this page on GitHub');
861+
editLink.setAttribute('title', 'Edit this page');
862+
editLink.innerHTML = '<svg viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="m14.515.456.965.965a1.555 1.555 0 0 1 0 2.2L9.745 9.355a1.549 1.549 0 0 1-.672.396l-2.89.826a.67.67 0 0 1-.828-.474.66.66 0 0 1 .004-.35l.825-2.89c.073-.254.209-.486.396-.673L12.315.456c.144-.145.316-.259.505-.337a1.538 1.538 0 0 1 1.19 0c.189.078.361.192.505.337Zm-3.322 3.008-3.67 3.669a.214.214 0 0 0-.057.096L6.97 8.965l1.736-.496a.218.218 0 0 0 .096-.056l3.67-3.67Zm2.065-2.066L12.135 2.52l1.28 1.28 1.122-1.122a.216.216 0 0 0 .065-.157.216.216 0 0 0-.065-.157l-.965-.966a.216.216 0 0 0-.157-.065.226.226 0 0 0-.157.065Z"/><path d="M0 14.25V2.75A1.75 1.75 0 0 1 1.75 1H7a.75.75 0 0 1 0 1.5H1.75a.25.25 0 0 0-.25.25v11.5a.25.25 0 0 0 .25.25h11.5a.25.25 0 0 0 .25-.25V8.5a.75.75 0 0 1 1.5 0v5.75c0 .464-.184.909-.513 1.237A1.746 1.746 0 0 1 13.25 16H1.75A1.75 1.75 0 0 1 0 14.25Z"/></svg>Edit';
863+
wrapper.appendChild(editLink);
864+
}
865+
}
738866
})();
739867
</script>
740868
<script src="{{ site.baseurl }}/assets/abbr.js"></script>

assets/search.js

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
(() => {
2+
'use strict';
3+
4+
let searchIndex = null;
5+
let searchData = null;
6+
const resultsContainer = document.getElementById('search-results');
7+
const searchInput = document.getElementById('search-input');
8+
const searchForm = document.getElementById('search-form');
9+
10+
// Get base URL from the search form action
11+
const baseUrl = searchForm.action.replace(/\/search\/$/, '');
12+
13+
// Load search data and build index
14+
async function loadSearchIndex() {
15+
if (searchIndex) return;
16+
17+
const response = await fetch(`${baseUrl}/search.json`);
18+
searchData = await response.json();
19+
20+
searchIndex = lunr(function() {
21+
this.ref('url');
22+
this.field('title', { boost: 10 });
23+
this.field('content');
24+
25+
searchData.forEach(doc => this.add(doc));
26+
});
27+
}
28+
29+
// Escape HTML to prevent XSS
30+
function escapeHtml(text) {
31+
const div = document.createElement('div');
32+
div.textContent = text;
33+
return div.innerHTML;
34+
}
35+
36+
// Extract excerpt around first match and highlight terms
37+
function getExcerpt(content, terms) {
38+
const lowerContent = content.toLowerCase();
39+
let position = -1;
40+
41+
for (const term of terms) {
42+
const pos = lowerContent.indexOf(term.toLowerCase());
43+
if (pos !== -1 && (position === -1 || pos < position)) {
44+
position = pos;
45+
}
46+
}
47+
48+
if (position === -1) position = 0;
49+
50+
const start = Math.max(0, position - 60);
51+
const end = Math.min(content.length, position + 200);
52+
let excerpt = content.substring(start, end);
53+
54+
if (start > 0) excerpt = '…' + excerpt;
55+
if (end < content.length) excerpt += '…';
56+
57+
// Highlight matching terms
58+
let highlighted = escapeHtml(excerpt);
59+
for (const term of terms) {
60+
const regex = new RegExp(`(${term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi');
61+
highlighted = highlighted.replace(regex, '<mark>$1</mark>');
62+
}
63+
64+
return highlighted;
65+
}
66+
67+
// Display search results
68+
function displayResults(results, query) {
69+
if (!results.length) {
70+
resultsContainer.innerHTML = `<p class="search-no-results">No results found for "${escapeHtml(query)}"</p>`;
71+
return;
72+
}
73+
74+
const terms = query.toLowerCase().split(/\s+/).filter(t => t.length > 1);
75+
const count = results.length;
76+
const plural = count === 1 ? '' : 's';
77+
78+
let html = `<p class="search-summary">Found ${count} result${plural} for "${escapeHtml(query)}"</p>`;
79+
html += '<ul class="search-results-list">';
80+
81+
for (const result of results) {
82+
const doc = searchData.find(d => d.url === result.ref);
83+
if (!doc) continue;
84+
85+
const excerpt = getExcerpt(doc.content, terms);
86+
const path = doc.url.replace(/^\/csswg-wiki/, '').replace(/\/$/, '') || '/';
87+
88+
html += `
89+
<li class="search-result">
90+
<a href="${escapeHtml(doc.url)}" class="search-result-title">${escapeHtml(doc.title)}</a>
91+
<span class="search-result-path">${escapeHtml(path)}</span>
92+
<p class="search-result-excerpt">${excerpt}</p>
93+
</li>`;
94+
}
95+
96+
html += '</ul>';
97+
resultsContainer.innerHTML = html;
98+
}
99+
100+
// Perform search
101+
async function doSearch(query) {
102+
if (!query || query.length < 2) {
103+
resultsContainer.innerHTML = '<p class="search-hint">Enter at least 2 characters to search.</p>';
104+
return;
105+
}
106+
107+
resultsContainer.innerHTML = '<p class="search-loading">Searching…</p>';
108+
109+
try {
110+
await loadSearchIndex();
111+
112+
let results;
113+
try {
114+
results = searchIndex.search(query);
115+
} catch {
116+
// Handle Lunr parse errors by doing a simpler search
117+
results = searchIndex.search(query.replace(/[^\w\s]/g, ''));
118+
}
119+
120+
displayResults(results, query);
121+
} catch (err) {
122+
resultsContainer.innerHTML = '<p class="search-error">Error loading search index.</p>';
123+
console.error(err);
124+
}
125+
}
126+
127+
// Handle form submission
128+
searchForm.addEventListener('submit', e => {
129+
e.preventDefault();
130+
const query = searchInput.value.trim();
131+
history.replaceState(null, '', `?q=${encodeURIComponent(query)}`);
132+
doSearch(query);
133+
});
134+
135+
// Check for query parameter on page load
136+
const params = new URLSearchParams(window.location.search);
137+
const initialQuery = params.get('q');
138+
if (initialQuery) {
139+
searchInput.value = initialQuery;
140+
doSearch(initialQuery);
141+
}
142+
})();

search.html

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
title: Search
3+
layout: default
4+
---
5+
6+
<h1>Search</h1>
7+
8+
<form id="search-form" class="search-page-form" action="{{ site.baseurl }}/search/" method="get" role="search">
9+
<input type="search" id="search-input" name="q" placeholder="Search the wiki…" autocomplete="off" autofocus aria-label="Search query">
10+
<button type="submit" aria-label="Search">
11+
<span class="search-btn-text">Search</span>
12+
<svg class="search-btn-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" aria-hidden="true" width="20" height="20">
13+
<circle cx="11" cy="11" r="8"/>
14+
<path d="m21 21-4.35-4.35"/>
15+
</svg>
16+
</button>
17+
</form>
18+
19+
<div id="search-results" aria-live="polite"></div>
20+
21+
<script src="https://unpkg.com/lunr@2.3.9/lunr.min.js"></script>
22+
<script src="{{ site.baseurl }}/assets/search.js"></script>

search.json

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
layout: null
3+
---
4+
[
5+
{%- assign all_pages = site.pages | sort: "title" -%}
6+
{%- assign first = true -%}
7+
{%- for page in all_pages -%}
8+
{%- if page.title and page.url != "/search/" -%}
9+
{%- assign content = page.content | strip_html | normalize_whitespace | truncate: 5000 | jsonify -%}
10+
{%- assign title = page.title | jsonify -%}
11+
{%- unless first %},{% endunless -%}
12+
{%- assign first = false %}
13+
{
14+
"title": {{ title }},
15+
"url": "{{ page.url | relative_url }}",
16+
"content": {{ content }}
17+
}
18+
{%- endif -%}
19+
{%- endfor -%}
20+
]

0 commit comments

Comments
 (0)