Feat/pagefind search (#56)

* feat: pagefind search engine

* fix: search now works

* fixed layout

---------

Co-authored-by: hugo <hugoms@me.com>
This commit is contained in:
Georg Bauer
2026-03-15 11:44:33 +01:00
committed by GitHub
parent f03b087c13
commit 1e7d60e63e
19 changed files with 561 additions and 42 deletions

View File

@@ -10,6 +10,7 @@ import { PICO_THEME_NAMES } from '../shared/picoThemes';
import { CODE_ENHANCEMENTS_RUNTIME_JS } from './assets/codeEnhancementsRuntime';
import { CALENDAR_RUNTIME_JS } from './assets/calendarRuntime';
import { TAG_CLOUD_RUNTIME_JS } from './assets/tagCloudRuntime';
import { SEARCH_RUNTIME_JS } from './assets/searchRuntime';
import { resolveRenderLanguageFromProjectPreferences, translateRender, getRenderTranslations } from '../shared/i18n';
function readLocalAsset(filename: string): string {
@@ -312,6 +313,10 @@ export const PREVIEW_ASSETS: Record<string, PreviewAssetDefinition> = {
contentType: 'application/javascript; charset=utf-8',
sourceText: CALENDAR_RUNTIME_JS,
},
'search-runtime.js': {
contentType: 'application/javascript; charset=utf-8',
sourceText: SEARCH_RUNTIME_JS,
},
'bds.css': {
contentType: 'text/css; charset=utf-8',
sourceText: readLocalAsset('bds.css'),

View File

@@ -0,0 +1,107 @@
import * as path from 'path';
import * as os from 'os';
import { spawn } from 'node:child_process';
export interface SearchIndexOptions {
htmlDir: string;
mainLanguage: string;
additionalLanguages: string[];
onProgress?: (progress: number, message?: string) => void;
}
export interface SearchIndexLanguageResult {
language: string;
pageCount: number;
}
export interface SearchIndexResult {
languageIndexes: SearchIndexLanguageResult[];
}
function resolvePagefindBinaryPath(): string {
const cpu = os.arch();
const platform = process.platform === 'win32' ? 'windows' : process.platform;
const execnames = ['pagefind_extended', 'pagefind'];
for (const execname of execnames) {
const executable = platform === 'windows' ? `${execname}.exe` : execname;
try {
let resolved = require.resolve(`@pagefind/${platform}-${cpu}/bin/${executable}`);
// In packaged Electron, require.resolve returns a path through app.asar
// but the binary is in the unpacked directory alongside it
resolved = resolved.replace(/app\.asar([/\\])/, 'app.asar.unpacked$1');
return resolved;
} catch {
// try next
}
}
throw new Error(
`Failed to find pagefind binary for ${platform}-${cpu}. ` +
`Ensure @pagefind/${platform}-${cpu} is installed.`,
);
}
function runPagefind(args: string[]): Promise<{ stdout: string; stderr: string }> {
const binaryPath = resolvePagefindBinaryPath();
return new Promise((resolve, reject) => {
const proc = spawn(binaryPath, args, { stdio: ['ignore', 'pipe', 'pipe'] });
let stdout = '';
let stderr = '';
proc.stdout.on('data', (data: Buffer) => { stdout += data.toString(); });
proc.stderr.on('data', (data: Buffer) => { stderr += data.toString(); });
proc.on('error', reject);
proc.on('close', (code) => {
if (code !== 0) {
reject(new Error(`Pagefind exited with code ${code}: ${stderr}`));
} else {
resolve({ stdout, stderr });
}
});
});
}
function parsePageCount(output: string): number {
const match = output.match(/indexed\s+(\d+)\s+page/i) ?? output.match(/(\d+)\s+page/i);
return match ? parseInt(match[1], 10) : 0;
}
/**
* Build Pagefind search indexes for a generated static site.
*
* For single-language sites, creates one index at `{htmlDir}/pagefind/`.
* For multilingual sites, creates per-language indexes:
* - main language: `{htmlDir}/pagefind/` (indexes root html)
* - additional languages: `{htmlDir}/{lang}/pagefind/` (indexes `{htmlDir}/{lang}/`)
*/
export async function buildSearchIndex(options: SearchIndexOptions): Promise<SearchIndexResult> {
const { htmlDir, mainLanguage, additionalLanguages, onProgress } = options;
const languages = [mainLanguage, ...additionalLanguages];
const results: SearchIndexLanguageResult[] = [];
for (let i = 0; i < languages.length; i++) {
const lang = languages[i];
const isMain = lang === mainLanguage;
const sourceDir = isMain ? htmlDir : path.join(htmlDir, lang);
const outputDir = isMain
? path.join(htmlDir, 'pagefind')
: path.join(htmlDir, lang, 'pagefind');
const progressBase = Math.floor((i / languages.length) * 100);
onProgress?.(progressBase, `Indexing search for ${lang}...`);
const { stdout, stderr } = await runPagefind([
'--site', sourceDir,
'--output-path', outputDir,
'--force-language', lang,
]);
const pageCount = parsePageCount(stdout + stderr);
results.push({ language: lang, pageCount });
}
onProgress?.(100, 'Search indexes built');
return { languageIndexes: results };
}

View File

@@ -148,3 +148,22 @@ main { display: grid; gap: 1rem; }
.language-switcher-badge:hover,
.language-switcher-badge:focus-visible { opacity: 1; border-color: var(--pico-color, var(--color)); }
.language-switcher-badge-current { opacity: 1; border-color: var(--pico-primary, var(--primary)); }
.blog-search-widget, .blog-search-standalone { position: relative; margin-top: .15rem; }
.blog-search-standalone { position: fixed; right: .75rem; top: 1.5rem; z-index: 100; }
.blog-search-toggle { display: inline-flex; align-items: center; justify-content: center; border: 0; background: transparent; color: var(--pico-muted-color, var(--muted-color)); cursor: pointer; padding: .15rem; opacity: .7; transition: opacity .15s ease-in-out; }
.blog-search-toggle:hover, .blog-search-toggle:focus-visible { opacity: 1; color: var(--pico-color, var(--color)); }
.blog-search-toggle svg { display: block; }
.blog-search-panel { position: absolute; top: calc(100% + .25rem); right: 0; width: min(24rem, 90vw); z-index: 40; border: 1px solid var(--pico-muted-border-color, var(--muted-border-color)); background: var(--pico-card-background-color, var(--card-background-color)); padding: .5rem; border-radius: .35rem; box-shadow: 0 4px 24px rgba(0,0,0,.25); }
.blog-search-panel .pagefind-ui { --pagefind-ui-scale: .8; --pagefind-ui-primary: var(--pico-primary, var(--primary)); --pagefind-ui-text: var(--pico-color, var(--color)); --pagefind-ui-background: var(--pico-card-background-color, var(--card-background-color)); --pagefind-ui-border: var(--pico-muted-border-color, var(--muted-border-color)); --pagefind-ui-tag: var(--pico-muted-border-color, var(--muted-border-color)); --pagefind-ui-border-width: 1px; --pagefind-ui-border-radius: .2rem; --pagefind-ui-image-border-radius: .2rem; --pagefind-ui-image-box-ratio: 0; --pagefind-ui-font: inherit; font-size: .85rem; }
.blog-search-panel .pagefind-ui__search-input { font-size: .85rem; padding: .3rem .5rem; border: 1px solid var(--pico-muted-border-color, var(--muted-border-color)); background: var(--pico-background-color, var(--background-color)); color: var(--pico-color, var(--color)); border-radius: .2rem; width: 100%; }
.blog-search-panel .pagefind-ui__search-clear { color: var(--pico-muted-color, var(--muted-color)); background: none; font-size: .8rem; }
.blog-search-panel .pagefind-ui__search-clear:focus { outline-color: var(--pico-primary, var(--primary)); }
.blog-search-panel .pagefind-ui__drawer { max-height: min(60vh, 28rem); overflow-y: auto; }
.blog-search-panel .pagefind-ui__message { color: var(--pico-muted-color, var(--muted-color)); font-size: .78rem; padding: .25rem 0; }
.blog-search-panel .pagefind-ui__result { border-top: 1px solid var(--pico-muted-border-color, var(--muted-border-color)); padding: .4rem 0; }
.blog-search-panel .pagefind-ui__result-link { color: var(--pico-primary, var(--primary)); font-size: .85rem; }
.blog-search-panel .pagefind-ui__result-title { font-size: .85rem; }
.blog-search-panel .pagefind-ui__result-excerpt { font-size: .78rem; color: var(--pico-muted-color, var(--muted-color)); }
.blog-search-panel .pagefind-ui__result-excerpt mark { background-color: var(--pico-primary-focus, rgba(255,223,0,.35)); color: inherit; }
.blog-search-panel .pagefind-ui__button { color: var(--pico-primary, var(--primary)); background: none; border: 1px solid var(--pico-muted-border-color, var(--muted-border-color)); border-radius: .2rem; font-size: .78rem; cursor: pointer; }
.blog-search-panel .pagefind-ui__button:hover { border-color: var(--pico-primary, var(--primary)); }

View File

@@ -0,0 +1,52 @@
export const SEARCH_RUNTIME_JS = String.raw`(() => {
const toggle = document.querySelector('[data-blog-search-toggle]');
const panel = document.querySelector('[data-blog-search-panel]');
const root = document.querySelector('[data-blog-search-root]');
if (!toggle || !panel || !root) {
return;
}
let initialized = false;
function initSearch() {
if (initialized || typeof PagefindUI === 'undefined') {
return;
}
initialized = true;
var placeholder = root.getAttribute('data-search-placeholder') || 'Search...';
new PagefindUI({
element: root,
showSubResults: true,
showImages: false,
translations: { placeholder: placeholder }
});
var input = root.querySelector('input');
if (input) {
input.focus();
}
}
toggle.addEventListener('click', function() {
var isHidden = panel.hasAttribute('hidden');
if (isHidden) {
panel.removeAttribute('hidden');
initSearch();
} else {
panel.setAttribute('hidden', '');
}
});
document.addEventListener('click', function(e) {
if (!panel.hasAttribute('hidden') && !panel.contains(e.target) && !toggle.contains(e.target)) {
panel.setAttribute('hidden', '');
}
});
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape' && !panel.hasAttribute('hidden')) {
panel.setAttribute('hidden', '');
toggle.focus();
}
});
})();`;

View File

@@ -21,4 +21,7 @@
<script defer src="/assets/lightbox.min.js"></script>
<script defer src="/assets/vanilla-calendar.min.js"></script>
<script defer src="/assets/calendar-runtime.js"></script>
<script defer src="/assets/search-runtime.js"></script>
<link rel="stylesheet" href="{{ language_prefix }}/pagefind/pagefind-ui.css" />
<script defer src="{{ language_prefix }}/pagefind/pagefind-ui.js"></script>
</head>

View File

@@ -7,6 +7,17 @@
<a class="language-switcher-badge" href="{{ lang.href_prefix | default: '/' }}" data-lang-prefix="{{ lang.href_prefix }}" title="{{ lang.code }}">{{ lang.flag }}</a>
{% endif %}
{% endfor %}
<div class="blog-search-widget" aria-label="{{ 'render.search.ariaLabel' | i18n: language }}">
<button type="button" class="blog-search-toggle" data-blog-search-toggle aria-label="{{ 'render.search.ariaLabel' | i18n: language }}">
<svg aria-hidden="true" viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" focusable="false">
<circle cx="11" cy="11" r="8"></circle>
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
</svg>
</button>
<div class="blog-search-panel" data-blog-search-panel hidden>
<div id="blog-search" data-blog-search-root data-search-placeholder="{{ 'render.search.placeholder' | i18n: language }}"></div>
</div>
</div>
</nav>
<script>
(function(){
@@ -15,4 +26,16 @@
links.forEach(function(a){a.href=(a.dataset.langPrefix||'')+path;});
}());
</script>
{% else %}
<div class="blog-search-standalone" aria-label="{{ 'render.search.ariaLabel' | i18n: language }}">
<button type="button" class="blog-search-toggle" data-blog-search-toggle aria-label="{{ 'render.search.ariaLabel' | i18n: language }}">
<svg aria-hidden="true" viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" focusable="false">
<circle cx="11" cy="11" r="8"></circle>
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
</svg>
</button>
<div class="blog-search-panel" data-blog-search-panel hidden>
<div id="blog-search" data-blog-search-root data-search-placeholder="{{ 'render.search.placeholder' | i18n: language }}"></div>
</div>
</div>
{% endif %}

View File

@@ -17,7 +17,7 @@
{% endfor %}
</div>
{% endif %}
<article class="single-post" data-template="single-post">
<article class="single-post" data-template="single-post" data-pagefind-body>
<div class="post">{{ post.content | markdown: post.id, post_data_json_by_id, canonical_post_path_by_slug, canonical_media_path_by_source_path, language, language_prefix }}</div>
</article>
{% if backlinks.size > 0 %}

View File

@@ -1,3 +1,4 @@
import * as path from 'path';
import { dialog } from 'electron';
import {
resolvePublicBaseUrl,
@@ -8,6 +9,7 @@ import {
type ApplyValidationPreparation,
} from '../engine/BlogGenerationEngine';
import { resolvePageTitle } from '../engine/PageRenderer';
import { buildSearchIndex } from '../engine/SearchIndexEngine';
import type { EngineBundle } from '../engine/EngineBundle';
import type { TranslationValidationReport } from '../shared/electronApi';
import { autoTranslatePost, autoTranslateMediaMetadata } from './chatHandlers';
@@ -152,6 +154,26 @@ export function registerBlogHandlers(safeHandle: SafeHandle, bundle: EngineBundl
runSectionTask('date', 'Render Date Archives', 'site-render-date'),
]);
await bundle.taskManager.runTask({
id: `site-render-search-index-${taskTimestamp}`,
name: 'Build Search Index',
groupId: taskGroupId,
groupName: taskGroupName,
execute: async (onProgress) => {
const htmlDir = path.join(baseOptions.dataDir, 'html');
const mainLanguage = (baseOptions.language ?? 'en').trim().toLowerCase();
const additionalLanguages = (baseOptions.blogLanguages ?? [])
.map((lang) => lang.trim().toLowerCase())
.filter((lang) => lang.length > 0 && lang !== mainLanguage);
return buildSearchIndex({
htmlDir,
mainLanguage,
additionalLanguages,
onProgress: (progress, message) => onProgress(progress, message || 'Building search index...'),
});
},
});
return mergeResults([coreResult, singleResult, categoryResult, tagResult, dateResult]);
});
@@ -388,6 +410,27 @@ export function registerBlogHandlers(safeHandle: SafeHandle, bundle: EngineBundl
});
},
});
// Phase 4: Rebuild search index
await bundle.taskManager.runTask({
id: `site-validate-search-index-${taskTimestamp}`,
name: 'Build Search Index',
groupId: taskGroupId,
groupName: taskGroupName,
execute: async (onProgress) => {
const htmlDir = path.join(baseOptions.dataDir, 'html');
const mainLanguage = (baseOptions.language ?? 'en').trim().toLowerCase();
const additionalLanguages = (baseOptions.blogLanguages ?? [])
.map((lang) => lang.trim().toLowerCase())
.filter((lang) => lang.length > 0 && lang !== mainLanguage);
return buildSearchIndex({
htmlDir,
mainLanguage,
additionalLanguages,
onProgress: (progress, message) => onProgress(progress, message || 'Building search index...'),
});
},
});
}
return {

View File

@@ -94,5 +94,7 @@
"task.rebuildEmbeddingIndex.clearing": "Index wird geleert…",
"task.duplicateSearch.name": "Doppelte Beiträge finden",
"task.duplicateSearch.searching": "Prüfe: {checked}/{total}",
"menu.item.fillMissingTranslations": "Fehlende Übersetzungen ausfüllen"
"menu.item.fillMissingTranslations": "Fehlende Übersetzungen ausfüllen",
"render.search.placeholder": "Suchen...",
"render.search.ariaLabel": "Seitensuche"
}

View File

@@ -94,5 +94,7 @@
"task.rebuildEmbeddingIndex.clearing": "Clearing index…",
"task.duplicateSearch.name": "Find Duplicate Posts",
"task.duplicateSearch.searching": "Checking: {checked}/{total}",
"menu.item.fillMissingTranslations": "Fill Missing Translations"
"menu.item.fillMissingTranslations": "Fill Missing Translations",
"render.search.placeholder": "Search...",
"render.search.ariaLabel": "Site search"
}

View File

@@ -94,5 +94,7 @@
"task.rebuildEmbeddingIndex.clearing": "Vaciando índice…",
"task.duplicateSearch.name": "Buscar entradas duplicadas",
"task.duplicateSearch.searching": "Comprobando: {checked}/{total}",
"menu.item.fillMissingTranslations": "Completar traducciones faltantes"
"menu.item.fillMissingTranslations": "Completar traducciones faltantes",
"render.search.placeholder": "Buscar...",
"render.search.ariaLabel": "Buscar en el sitio"
}

View File

@@ -94,5 +94,7 @@
"task.rebuildEmbeddingIndex.clearing": "Vidage de l'index…",
"task.duplicateSearch.name": "Trouver les articles en double",
"task.duplicateSearch.searching": "Vérification : {checked}/{total}",
"menu.item.fillMissingTranslations": "Compléter les traductions manquantes"
"menu.item.fillMissingTranslations": "Compléter les traductions manquantes",
"render.search.placeholder": "Rechercher...",
"render.search.ariaLabel": "Recherche du site"
}

View File

@@ -94,5 +94,7 @@
"task.rebuildEmbeddingIndex.clearing": "Svuotamento indice…",
"task.duplicateSearch.name": "Trova post duplicati",
"task.duplicateSearch.searching": "Controllo: {checked}/{total}",
"menu.item.fillMissingTranslations": "Completa le traduzioni mancanti"
"menu.item.fillMissingTranslations": "Completa le traduzioni mancanti",
"render.search.placeholder": "Cerca...",
"render.search.ariaLabel": "Ricerca nel sito"
}