feat: new archives layout (#989)

* feat(archives): add search bar to archives page

* fix: error `file is nil; wrap it in if or with` from Hugo 0.123.0

* style: rename `search.ts` to `archives.ts`

* refactor: remove `search` page layout

* doc: remove `search` from `exampleSite`

* fix: generate JSON output for archives page in exampleSite

* feat: put archives search form under categories list
This commit is contained in:
Jimmy Cai 2024-03-25 00:02:47 +01:00 committed by GitHub
parent 9df2641193
commit 5d100a7f99
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 219 additions and 403 deletions

165
assets/ts/archives.ts Normal file
View File

@ -0,0 +1,165 @@
interface PageData {
title: string;
content: string;
id: string;
}
class Search {
private data: PageData[];
private form: HTMLFormElement;
private input: HTMLInputElement;
private resultTitle: HTMLHeadElement;
private resultTitleTemplate: string;
constructor({ form, input, resultTitle, resultTitleTemplate }) {
this.form = form;
this.input = input;
this.resultTitle = resultTitle;
this.resultTitleTemplate = resultTitleTemplate;
this.handleQueryString();
this.bindQueryStringChange();
this.bindSearchForm();
}
private async getIndex() {
if (!this.data) {
const jsonURL = this.form.dataset.json;
this.data = await fetch(jsonURL).then(res => res.json());
}
return this.data;
}
private async searchKeywords(keywords: string[]) {
const index = await this.getIndex();
/// Return an set of ids that match the keywords
return new Set(index.filter(item => {
return keywords.every(keyword => {
return item.title.includes(keyword) || item.content.includes(keyword);
});
}).map(item => item.id));
}
private async doSearch(keywords: string[]) {
const startTime = performance.now();
const results = await this.searchKeywords(keywords);
this.clear();
/// Hide all articles except the ones that are in the results
const archiveGroups = document.querySelectorAll('.archives-group') as NodeListOf<HTMLDivElement>;
archiveGroups.forEach(group => {
const articles = Array.from(group.querySelectorAll('article'));
articles.map(article => {
article.style.display = 'none';
article.style.removeProperty('border-bottom');
});
const matchingArticles = articles.filter(article => results.has(article.id));
const hasResults = matchingArticles.length > 0;
if (!hasResults) return group.style.display = 'none';
matchingArticles.map(article => article.style.removeProperty('display'));
matchingArticles[matchingArticles.length - 1].style.borderBottom = 'none';
});
const endTime = performance.now();
this.resultTitle.innerText = this.generateResultTitle(results.size, ((endTime - startTime) / 1000).toPrecision(1));
this.resultTitle.style.display = 'block';
}
private generateResultTitle(resultLen, time) {
return this.resultTitleTemplate.replace("#PAGES_COUNT", resultLen).replace("#TIME_SECONDS", time);
}
private bindSearchForm() {
let lastSearch = '';
const eventHandler = (e) => {
e.preventDefault();
const keywords = this.input.value.trim();
Search.updateQueryString(keywords, true);
if (keywords === '') {
lastSearch = '';
return this.clear();
}
if (lastSearch === keywords) return;
lastSearch = keywords;
this.doSearch(keywords.split(' '));
}
this.input.addEventListener('input', eventHandler);
this.input.addEventListener('compositionend', eventHandler);
}
private clear() {
this.resultTitle.style.display = 'none';
document.querySelectorAll('.archives-group, .archives-group article').forEach(el => el.removeAttribute('style'));
}
private bindQueryStringChange() {
window.addEventListener('popstate', (e) => {
this.handleQueryString()
})
}
private handleQueryString() {
const pageURL = new URL(window.location.toString());
const keywords = pageURL.searchParams.get('keyword');
this.input.value = keywords;
if (keywords) {
this.doSearch(keywords.split(' '));
}
else {
this.clear()
}
}
private static updateQueryString(keywords: string, replaceState = false) {
const pageURL = new URL(window.location.toString());
if (keywords === '') {
pageURL.searchParams.delete('keyword')
}
else {
pageURL.searchParams.set('keyword', keywords);
}
if (replaceState) {
window.history.replaceState('', '', pageURL.toString());
}
else {
window.history.pushState('', '', pageURL.toString());
}
}
}
declare global {
interface Window {
searchResultTitleTemplate: string;
}
}
window.addEventListener('load', () => {
setTimeout(function () {
const searchForm = document.getElementById('search-form') as HTMLFormElement,
searchInput = searchForm.querySelector('input') as HTMLInputElement,
searchResultTitle = document.querySelector('.search-result--title') as HTMLHeadingElement;
new Search({
form: searchForm,
input: searchInput,
resultTitle: searchResultTitle,
resultTitleTemplate: window.searchResultTitleTemplate
});
}, 0);
})
export default Search;

View File

@ -1,326 +0,0 @@
interface pageData {
title: string,
date: string,
permalink: string,
content: string,
image?: string,
preview: string,
matchCount: number
}
interface match {
start: number,
end: number
}
/**
* Escape HTML tags as HTML entities
* Edited from:
* @link https://stackoverflow.com/a/5499821
*/
const tagsToReplace = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
'…': '&hellip;'
};
function replaceTag(tag) {
return tagsToReplace[tag] || tag;
}
function replaceHTMLEnt(str) {
return str.replace(/[&<>"]/g, replaceTag);
}
function escapeRegExp(string) {
return string.replace(/[.*+\-?^${}()|[\]\\]/g, '\\$&');
}
class Search {
private data: pageData[];
private form: HTMLFormElement;
private input: HTMLInputElement;
private list: HTMLDivElement;
private resultTitle: HTMLHeadElement;
private resultTitleTemplate: string;
constructor({ form, input, list, resultTitle, resultTitleTemplate }) {
this.form = form;
this.input = input;
this.list = list;
this.resultTitle = resultTitle;
this.resultTitleTemplate = resultTitleTemplate;
this.handleQueryString();
this.bindQueryStringChange();
this.bindSearchForm();
}
/**
* Processes search matches
* @param str original text
* @param matches array of matches
* @param ellipsis whether to add ellipsis to the end of each match
* @param charLimit max length of preview string
* @param offset how many characters before and after the match to include in preview
* @returns preview string
*/
private static processMatches(str: string, matches: match[], ellipsis: boolean = true, charLimit = 140, offset = 20): string {
matches.sort((a, b) => {
return a.start - b.start;
});
let i = 0,
lastIndex = 0,
charCount = 0;
const resultArray: string[] = [];
while (i < matches.length) {
const item = matches[i];
/// item.start >= lastIndex (equal only for the first iteration)
/// because of the while loop that comes after, iterating over variable j
if (ellipsis && item.start - offset > lastIndex) {
resultArray.push(`${replaceHTMLEnt(str.substring(lastIndex, lastIndex + offset))} [...] `);
resultArray.push(`${replaceHTMLEnt(str.substring(item.start - offset, item.start))}`);
charCount += offset * 2;
}
else {
/// If the match is too close to the end of last match, don't add ellipsis
resultArray.push(replaceHTMLEnt(str.substring(lastIndex, item.start)));
charCount += item.start - lastIndex;
}
let j = i + 1,
end = item.end;
/// Include as many matches as possible
/// [item.start, end] is the range of the match
while (j < matches.length && matches[j].start <= end) {
end = Math.max(matches[j].end, end);
++j;
}
resultArray.push(`<mark>${replaceHTMLEnt(str.substring(item.start, end))}</mark>`);
charCount += end - item.start;
i = j;
lastIndex = end;
if (ellipsis && charCount > charLimit) break;
}
/// Add the rest of the string
if (lastIndex < str.length) {
let end = str.length;
if (ellipsis) end = Math.min(end, lastIndex + offset);
resultArray.push(`${replaceHTMLEnt(str.substring(lastIndex, end))}`);
if (ellipsis && end != str.length) {
resultArray.push(` [...]`);
}
}
return resultArray.join('');
}
private async searchKeywords(keywords: string[]) {
const rawData = await this.getData();
const results: pageData[] = [];
const regex = new RegExp(keywords.filter((v, index, arr) => {
arr[index] = escapeRegExp(v);
return v.trim() !== '';
}).join('|'), 'gi');
for (const item of rawData) {
const titleMatches: match[] = [],
contentMatches: match[] = [];
let result = {
...item,
preview: '',
matchCount: 0
}
const contentMatchAll = item.content.matchAll(regex);
for (const match of Array.from(contentMatchAll)) {
contentMatches.push({
start: match.index,
end: match.index + match[0].length
});
}
const titleMatchAll = item.title.matchAll(regex);
for (const match of Array.from(titleMatchAll)) {
titleMatches.push({
start: match.index,
end: match.index + match[0].length
});
}
if (titleMatches.length > 0) result.title = Search.processMatches(result.title, titleMatches, false);
if (contentMatches.length > 0) {
result.preview = Search.processMatches(result.content, contentMatches);
}
else {
/// If there are no matches in the content, use the first 140 characters as preview
result.preview = replaceHTMLEnt(result.content.substring(0, 140));
}
result.matchCount = titleMatches.length + contentMatches.length;
if (result.matchCount > 0) results.push(result);
}
/// Result with more matches appears first
return results.sort((a, b) => {
return b.matchCount - a.matchCount;
});
}
private async doSearch(keywords: string[]) {
const startTime = performance.now();
const results = await this.searchKeywords(keywords);
this.clear();
for (const item of results) {
this.list.append(Search.render(item));
}
const endTime = performance.now();
this.resultTitle.innerText = this.generateResultTitle(results.length, ((endTime - startTime) / 1000).toPrecision(1));
}
private generateResultTitle(resultLen, time) {
return this.resultTitleTemplate.replace("#PAGES_COUNT", resultLen).replace("#TIME_SECONDS", time);
}
public async getData() {
if (!this.data) {
/// Not fetched yet
const jsonURL = this.form.dataset.json;
this.data = await fetch(jsonURL).then(res => res.json());
const parser = new DOMParser();
for (const item of this.data) {
item.content = parser.parseFromString(item.content, 'text/html').body.innerText;
}
}
return this.data;
}
private bindSearchForm() {
let lastSearch = '';
const eventHandler = (e) => {
e.preventDefault();
const keywords = this.input.value.trim();
Search.updateQueryString(keywords, true);
if (keywords === '') {
lastSearch = '';
return this.clear();
}
if (lastSearch === keywords) return;
lastSearch = keywords;
this.doSearch(keywords.split(' '));
}
this.input.addEventListener('input', eventHandler);
this.input.addEventListener('compositionend', eventHandler);
}
private clear() {
this.list.innerHTML = '';
this.resultTitle.innerText = '';
}
private bindQueryStringChange() {
window.addEventListener('popstate', (e) => {
this.handleQueryString()
})
}
private handleQueryString() {
const pageURL = new URL(window.location.toString());
const keywords = pageURL.searchParams.get('keyword');
this.input.value = keywords;
if (keywords) {
this.doSearch(keywords.split(' '));
}
else {
this.clear()
}
}
private static updateQueryString(keywords: string, replaceState = false) {
const pageURL = new URL(window.location.toString());
if (keywords === '') {
pageURL.searchParams.delete('keyword')
}
else {
pageURL.searchParams.set('keyword', keywords);
}
if (replaceState) {
window.history.replaceState('', '', pageURL.toString());
}
else {
window.history.pushState('', '', pageURL.toString());
}
}
public static render(item: pageData) {
return <article>
<a href={item.permalink}>
<div class="article-details">
<h2 class="article-title" dangerouslySetInnerHTML={{ __html: item.title }}></h2>
<section class="article-preview" dangerouslySetInnerHTML={{ __html: item.preview }}></section>
</div>
{item.image &&
<div class="article-image">
<img src={item.image} loading="lazy" />
</div>
}
</a>
</article>;
}
}
declare global {
interface Window {
searchResultTitleTemplate: string;
}
}
window.addEventListener('load', () => {
setTimeout(function () {
const searchForm = document.querySelector('.search-form') as HTMLFormElement,
searchInput = searchForm.querySelector('input') as HTMLInputElement,
searchResultList = document.querySelector('.search-result--list') as HTMLDivElement,
searchResultTitle = document.querySelector('.search-result--title') as HTMLHeadingElement;
new Search({
form: searchForm,
input: searchInput,
list: searchResultList,
resultTitle: searchResultTitle,
resultTitleTemplate: window.searchResultTitleTemplate
});
}, 0);
})
export default Search;

View File

@ -3,6 +3,9 @@ title: "Archives"
date: 2019-05-28
layout: "archives"
slug: "archives"
outputs:
- html
- json
menu:
main:
weight: -70

View File

@ -1,13 +0,0 @@
---
title: "Search"
slug: "search"
layout: "search"
outputs:
- html
- json
menu:
main:
weight: -60
params:
icon: search
---

View File

@ -1,9 +1,14 @@
{{ define "body-class" }}template-archives{{ end }}
{{ define "body-class" }}template-archives template-search{{ end }}
{{ define "head" }}
{{- with .OutputFormats.Get "json" -}}
<link rel="preload" href="{{ .RelPermalink }}" as="fetch" crossorigin="anonymous">
{{- end -}}
{{ end }}
{{ define "main" }}
{{- $taxonomy := $.Site.GetPage "taxonomyTerm" "categories" -}}
{{- $terms := $taxonomy.Pages -}}
{{ if $terms }}
<header>
{{- $taxonomy := $.Site.GetPage "taxonomyTerm" "categories" -}}
{{- $terms := $taxonomy.Pages -}}
{{ if $terms }}
<h2 class="section-title">{{ $taxonomy.Title }}</h2>
<div class="subsection-list">
<div class="article-list--tile">
@ -12,9 +17,22 @@
{{ end }}
</div>
</div>
{{ end }}
</header>
{{ end }}
<form id="search-form" action="{{ .RelPermalink }}" class="search-form"{{ with .OutputFormats.Get "json" -}} data-json="{{ .RelPermalink }}"{{- end }}>
<p>
<label>{{ T "search.title" }}</label>
<input name="keyword" placeholder="{{ T `search.placeholder` }}" />
</p>
<button title="{{ T `search.title` }}">
{{ partial "helper/icon" "search" }}
</button>
</form>
<h3 class="search-result--title section-title"></h3>
{{ $pages := where .Site.RegularPages "Type" "in" .Site.Params.mainSections }}
{{ range $pages.GroupByDate "2006" }}
@ -30,4 +48,16 @@
{{ end }}
{{ partialCached "footer/footer" . }}
<script>
window.searchResultTitleTemplate = "{{ T `search.resultTitle` }}"
</script>
{{- $opts := dict "minify" hugo.IsProduction -}}
{{- $archivesScript := resources.Get "ts/archives.ts" | js.Build $opts -}}
<script type="text/javascript" src="{{ $archivesScript.RelPermalink }}" defer></script>
{{ end }}
{{ define "right-sidebar" }}
{{ partial "sidebar/right.html" (dict "Context" . "Scope" "homepage") }}
{{ end }}

View File

@ -0,0 +1,13 @@
{{- $pages := where .Site.RegularPages "Type" "in" .Site.Params.mainSections -}}
{{- $result := slice -}}
{{- range $pages -}}
{{- $data := dict
"title" .Title
"content" (.Plain)
"id" .File.UniqueID
-}}
{{- $result = $result | append $data -}}
{{- end -}}
{{ jsonify $result }}

View File

@ -1,33 +0,0 @@
{{ define "body-class" }}template-search{{ end }}
{{ define "head" }}
{{- with .OutputFormats.Get "json" -}}
<link rel="preload" href="{{ .RelPermalink }}" as="fetch" crossorigin="anonymous">
{{- end -}}
{{ end }}
{{ define "main" }}
<form action="{{ .RelPermalink }}" class="search-form"{{ with .OutputFormats.Get "json" -}} data-json="{{ .RelPermalink }}"{{- end }}>
<p>
<label>{{ T "search.title" }}</label>
<input name="keyword" placeholder="{{ T `search.placeholder` }}" />
</p>
<button title="{{ T `search.title` }}">
{{ partial "helper/icon" "search" }}
</button>
</form>
<div class="search-result">
<h3 class="search-result--title section-title"></h3>
<div class="search-result--list article-list--compact"></div>
</div>
<script>
window.searchResultTitleTemplate = "{{ T `search.resultTitle` }}"
</script>
{{- $opts := dict "minify" hugo.IsProduction "JSXFactory" "createElement" -}}
{{- $searchScript := resources.Get "ts/search.tsx" | js.Build $opts -}}
<script type="text/javascript" src="{{ $searchScript.RelPermalink }}" defer></script>
{{ partialCached "footer/footer" . }}
{{ end }}

View File

@ -1,23 +0,0 @@
{{- $pages := where .Site.RegularPages "Type" "in" .Site.Params.mainSections -}}
{{- $result := slice -}}
{{- range $pages -}}
{{- $data := dict "title" .Title "date" .Date "permalink" .Permalink "content" (.Plain) -}}
{{- $image := partialCached "helper/image" (dict "Context" . "Type" "articleList") .RelPermalink "articleList" -}}
{{- if $image.exists -}}
{{- $imagePermalink := "" -}}
{{- if and $image.resource .Page.Site.Params.imageProcessing.cover.enabled -}}
{{- $thumbnail := $image.resource.Fill "120x120" -}}
{{- $imagePermalink = (absURL $thumbnail.Permalink) -}}
{{- else -}}
{{- $imagePermalink = $image.permalink -}}
{{- end -}}
{{- $data = merge $data (dict "image" (absURL $imagePermalink)) -}}
{{- end -}}
{{- $result = $result | append $data -}}
{{- end -}}
{{ jsonify $result }}

View File

@ -1,4 +1,4 @@
<article>
<article id="{{ with .File }}{{ .UniqueID }}{{ end }}">
<a href="{{ .RelPermalink }}">
<div class="article-details">
<h2 class="article-title">

View File

@ -1,4 +1,4 @@
{{- $query := first 1 (where .Context.Site.Pages "Layout" "==" "search") -}}
{{- $query := first 1 (where .Context.Site.Pages "Layout" "==" "archives") -}}
{{- if $query -}}
{{- $searchPage := index $query 0 -}}
<form action="{{ $searchPage.RelPermalink }}" class="search-form widget" {{ with .OutputFormats.Get "json" -}}data-json="{{ .Permalink }}" {{- end }}>
@ -12,5 +12,5 @@
</p>
</form>
{{- else -}}
{{- warnf "Search page not found. Create a page with layout: search." -}}
{{- warnf "Archives page not found. Create a page with layout: archives." -}}
{{- end -}}