Skip to content
4 changes: 0 additions & 4 deletions docs/userGuide/makingTheSiteSearchable.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,10 +50,6 @@ MarkBind now supports [Pagefind](https://pagefind.app/), a static low-bandwidth
This is a <strong>beta</strong> feature and will be refined in future updates. To use it, you must have <code>enableSearch: true</code> in your <code>site.json</code> (this is the default).
</box>

<box type="warning">
The Pagefind index is currently only generated during a full site build (e.g., <code>markbind build</code>). It will <strong>not</strong> repeatedly update during live reload (<code>markbind serve</code>) when you modify pages. You must restart the server (re-run <code>markbind serve</code>) or rebuild to refresh the search index.
</box>

To add the Pagefind search bar to your page, simply insert the following element where you want it to appear:

```md
Expand Down
9 changes: 6 additions & 3 deletions packages/cli/src/util/serveUtil.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,8 @@ const addHandler = (site: any, onePagePath?: boolean) => (filePath: string): voi
}
Promise.resolve().then(async () => {
if (site.isFilepathAPage(filePath) || site.isDependencyOfPage(filePath)) {
return site.rebuildSourceFiles();
await site.rebuildSourceFiles();
return await site.updatePagefindIndex(filePath);
}
Comment on lines 37 to 40
Copy link

Copilot AI Apr 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

updatePagefindIndex can auto-create a Pagefind index even when search is disabled, because it doesn’t check siteConfig.enableSearch. Since serveUtil now calls site.updatePagefindIndex(...) on every page/dependency change, this can generate Pagefind assets unexpectedly for sites with enableSearch: false. Add a guard (either here in serveUtil, or inside Site.updatePagefindIndex / SiteGenerationManager.updatePagefindIndex) to no-op when enableSearch is false.

Copilot uses AI. Check for mistakes.
return site.buildAsset(filePath);
}).catch((err: Error) => {
Expand All @@ -59,7 +60,8 @@ const changeHandler = (site: any, onePagePath?: boolean) => (filePath: string):
return site.reloadSiteConfig();
}
if (site.isDependencyOfPage(filePath)) {
return site.rebuildAffectedSourceFiles(filePath);
await site.rebuildAffectedSourceFiles(filePath);
return await site.updatePagefindIndex(filePath);
Copy link

Copilot AI Apr 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On dependency changes, changeHandler calls rebuildAffectedSourceFiles(filePath) but then calls updatePagefindIndex(filePath). Site.updatePagefindIndex filters by page.pageConfig.sourcePath === filePath, so a dependency path (e.g. _markbind/variables.md) won’t match any page source paths and the index won’t be updated for the pages that were regenerated. Pass the affected pages instead (e.g., compute pages where page.isDependency(filePath) is true, or change updatePagefindIndex to accept a dependency path and map it to the affected pages).

Suggested change
return await site.updatePagefindIndex(filePath);
return await site.indexSiteWithPagefind();

Copilot uses AI. Check for mistakes.
}
return site.buildAsset(filePath);
}).catch((err: Error) => {
Expand All @@ -80,7 +82,8 @@ const removeHandler = (site: any, onePagePath?: boolean) => (filePath: string):
}
Promise.resolve().then(async () => {
if (site.isFilepathAPage(filePath) || site.isDependencyOfPage(filePath)) {
return site.rebuildSourceFiles();
await site.rebuildSourceFiles();
return await site.indexSiteWithPagefind();
Copy link

Copilot AI Apr 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

removeHandler now calls site.indexSiteWithPagefind() unconditionally after rebuildSourceFiles(). Unlike generate(), this bypasses the enableSearch guard and will attempt to build a Pagefind index even when enableSearch: false. Add an enableSearch check before calling indexSiteWithPagefind, or make Site/SiteGenerationManager.indexSiteWithPagefind a no-op when search is disabled.

Suggested change
return await site.indexSiteWithPagefind();
if (site.siteConfig?.enableSearch) {
return await site.indexSiteWithPagefind();
}
return;

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fair enuf

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

regarding the asynchronous update, perhaps we don't await this instead

}
return site.removeAsset(filePath);
}).catch((err: Error) => {
Expand Down
143 changes: 125 additions & 18 deletions packages/core/src/Site/SiteGenerationManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,9 @@ export class SiteGenerationManager {
currentOpenedPages: string[];
toRebuild: Set<string>;

// Pagefind index state (kept in memory for serve mode for incremental updates)
pagefindIndex: any;

constructor(rootPath: string, outputPath: string, onePagePath: string, forceReload = false,
siteConfigPath = SITE_CONFIG_NAME, isDevMode: any, backgroundBuildMode: boolean,
postBackgroundBuildFunc: () => void) {
Expand All @@ -105,6 +108,9 @@ export class SiteGenerationManager {
: '';
this.currentOpenedPages = [];
this.toRebuild = new Set();

// Pagefind index state (kept in memory for serve mode for incremental updates)
this.pagefindIndex = null;
}

configure(siteAssets: SiteAssetsManager, sitePages: SitePagesManager) {
Expand Down Expand Up @@ -317,7 +323,15 @@ export class SiteGenerationManager {
await this.siteAssets.copyMaterialIconsAsset();
await this.writeSiteData();
if (this.siteConfig.enableSearch) {
const indexingSucceeded = await this.indexSiteWithPagefind();
let indexingSucceeded: boolean;
if (this.onePagePath) {
const builtPages = this.sitePages.pages.filter(page =>
fs.existsSync(page.pageConfig.resultPath),
);
indexingSucceeded = await this.updatePagefindIndex(builtPages);
} else {
indexingSucceeded = await this.indexSiteWithPagefind();
}
this.sitePages.pagefindIndexingSucceeded = indexingSucceeded;
}
this.calculateBuildTimeForGenerate(startTime, lazyWebsiteGenerationString);
Expand Down Expand Up @@ -440,6 +454,11 @@ export class SiteGenerationManager {
this._setTimestampVariable();
await this.runPageGenerationTasks([pageGenerationTask]);
await this.writeSiteData();

if (this.siteConfig.enableSearch && this.pagefindIndex) {
await this.updatePagefindIndex(pagesToRebuild);
}

SiteGenerationManager.calculateBuildTimeForRebuildPagesBeingViewed(startTime);
} catch (err) {
await SiteGenerationManager.rejectHandler(err, [this.tempPath, this.outputPath]);
Expand All @@ -466,6 +485,11 @@ export class SiteGenerationManager {
const isCompleted = await this.generatePagesMarkedToRebuild();
if (isCompleted) {
logger.info('Background building completed!');

if (this.siteConfig.enableSearch) {
await this.indexSiteWithPagefind();
}

this.postBackgroundBuildFunc();
}
}
Expand Down Expand Up @@ -872,29 +896,49 @@ export class SiteGenerationManager {
);

/**
* Indexes all the pages of the site using pagefind.
* @returns true if indexing succeeded and pagefind assets were written, false otherwise.
*/
* Initializes a new Pagefind index with proper configuration.
* @returns The created index object
*/
private async initializePagefindIndex(): Promise<any> {
const { createIndex } = pagefind;
const pagefindConfig = this.siteConfig.pagefind || {};

const createIndexOptions: Record<string, unknown> = {
keepIndexUrl: true,
verbose: true,
logfile: 'debug.log',
};

if (pagefindConfig.exclude_selectors) {
createIndexOptions.excludeSelectors = pagefindConfig.exclude_selectors;
}

const { index } = await createIndex(createIndexOptions);
return index;
}

/**
* Indexes all the pages of the site using pagefind.
* Performs a full rebuild of the search index.
* @returns true if indexing succeeded and pagefind assets were written, false otherwise.
*/
async indexSiteWithPagefind(): Promise<boolean> {
const startTime = new Date();
logger.info('Creating Pagefind Search Index...');
try {
const { createIndex, close } = pagefind;

const pagefindConfig = this.siteConfig.pagefind || {};

const createIndexOptions: Record<string, unknown> = {
keepIndexUrl: true,
verbose: true,
logfile: 'debug.log',
};

if (pagefindConfig.exclude_selectors) {
createIndexOptions.excludeSelectors = pagefindConfig.exclude_selectors;
// Clean up existing in-memory index if it exists
if (this.pagefindIndex) {
await this.pagefindIndex.deleteIndex();
this.pagefindIndex = null;
}

const { index } = await createIndex(createIndexOptions);
const index = await this.initializePagefindIndex();
const { close } = pagefind;

if (index) {
// Store index in memory for incremental updates in serve mode
this.pagefindIndex = index;

// Filter pages that should be indexed (searchable !== false)
const searchablePages = this.sitePages.pages.filter(
page => page.pageConfig.searchable,
Expand Down Expand Up @@ -939,10 +983,22 @@ export class SiteGenerationManager {
logger.info(`Pagefind indexed ${totalPageCount} pages in ${totalTime}s`);

const pagefindOutputPath = path.join(this.outputPath, TEMPLATE_SITE_ASSET_FOLDER_NAME, 'pagefind');
// Clear output directory before writing
await fs.emptyDir(pagefindOutputPath);
await fs.ensureDir(pagefindOutputPath);
await index.writeFiles({ outputPath: pagefindOutputPath });
logger.info(`Pagefind assets written to ${pagefindOutputPath}`);
await close();

// Only close the index in build/deploy mode; keep it in memory for serve mode
// Detect serve mode by checking if postBackgroundBuildFunc has a name (named function = serve)
const isServeMode = this.postBackgroundBuildFunc.name !== '';
const shouldClose = !isServeMode;
Comment on lines +992 to +995
Copy link

Copilot AI Apr 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Serve-mode detection via this.postBackgroundBuildFunc.name !== '' is brittle (function names can be inferred/changed by refactors/minifiers, and build/deploy could accidentally pass a named function). This affects whether Pagefind is closed and whether pagefindIndex is retained for incremental updates. Prefer an explicit flag (e.g., store the isDevMode constructor param, or add a dedicated keepPagefindIndexInMemory boolean) to decide whether to call pagefind.close() and clear pagefindIndex.

Copilot uses AI. Check for mistakes.

if (shouldClose) {
await close();
this.pagefindIndex = null;
}

return true;
}
logger.error('Pagefind failed to create index');
Expand All @@ -954,6 +1010,57 @@ export class SiteGenerationManager {
}
}

/**
* Updates the search index for changed pages only (incremental update).
* Requires the index to be kept in memory from a prior indexSiteWithPagefind() call.
* @param pages Array of pages that were modified/added
* @returns true if update succeeded, false otherwise
*/
async updatePagefindIndex(pages: Page[]): Promise<boolean> {
if (!this.pagefindIndex) {
logger.info('Pagefind index not in memory, auto-creating...');
this.pagefindIndex = await this.initializePagefindIndex();
}

const pagefindOutputPath = path.join(this.outputPath, TEMPLATE_SITE_ASSET_FOLDER_NAME, 'pagefind');

try {
const searchablePages = pages.filter(page => page.pageConfig.searchable);

await Promise.all(searchablePages.map(async (page) => {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just curious, is really necessary for we to await? or can we have it run asynchronously.

By awaiting we would increase the time taken for each update to be shown. I don't think we would need the search result to be immediately ready too. It would require time for the users to click on the text box and then start searching, which is presumably sufficient for the pagefind to complete indexing.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yea u right, but the updating process usually only takes a few second maximum to complete so I felt like the latency reduction is negligible. Users already wait for the rebuild to complete anyway.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yep i think the idea is to avoid increasing the rebuild time haha it can be annoying (to me at least)

wld there be any downsides to dropping the await?

try {
const content = await fs.readFile(page.pageConfig.resultPath, 'utf8');
const relativePath = path.relative(this.outputPath, page.pageConfig.resultPath);

return this.pagefindIndex.addHTMLFile({
sourcePath: relativePath,
content,
});
} catch (err) {
const pageResultPath = page.pageConfig.resultPath;
logger.warn(`Skipping index update for ${pageResultPath}: file not built yet`);
return null;
}
Comment on lines +1031 to +1043
Copy link

Copilot AI Apr 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In updatePagefindIndex, the per-page try/catch swallows all errors (including addHTMLFile failures) and turns them into a warn+skip. This makes genuine indexing failures look like “file not built yet”, and it prevents the outer catch from returning false (the new unit test expects false when addHTMLFile rejects). Consider only catching fs.readFile/missing-file errors, and letting addHTMLFile errors propagate (or rethrow) so the method can fail fast and log an error appropriately.

Suggested change
try {
const content = await fs.readFile(page.pageConfig.resultPath, 'utf8');
const relativePath = path.relative(this.outputPath, page.pageConfig.resultPath);
return this.pagefindIndex.addHTMLFile({
sourcePath: relativePath,
content,
});
} catch (err) {
const pageResultPath = page.pageConfig.resultPath;
logger.warn(`Skipping index update for ${pageResultPath}: file not built yet`);
return null;
}
let content;
try {
content = await fs.readFile(page.pageConfig.resultPath, 'utf8');
} catch (err) {
const pageResultPath = page.pageConfig.resultPath;
logger.warn(`Skipping index update for ${pageResultPath}: file not built yet`);
return null;
}
const relativePath = path.relative(this.outputPath, page.pageConfig.resultPath);
return this.pagefindIndex.addHTMLFile({
sourcePath: relativePath,
content,
});

Copilot uses AI. Check for mistakes.
}));

const { files } = await this.pagefindIndex.getFiles();
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is it necessary to clear the files after adding getting them here?
on line 1035 of this file:

 return this.pagefindIndex.addHTMLFile({
            sourcePath: relativePath,
            content,
          });

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep it is!
addHTMLFile() updates entries in the in-memory index but doesn't clear old on-disk files, so emptyDir() removes stale files before we updated files from getFiles() writes the complete current index.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ops sorry for not being clear there, i meant clearing the in-memory index added by addHTMLPages on each call to this function

await fs.emptyDir(pagefindOutputPath);

const pagefindFiles: { path: string; content: Uint8Array }[] = files;
await Promise.all(pagefindFiles.map(async (file) => {
const filePath = path.join(pagefindOutputPath, file.path);
await fs.ensureDir(path.dirname(filePath));
return fs.writeFile(filePath, Buffer.from(file.content));
}));

logger.info(`Updated Pagefind index for ${searchablePages.length} page(s)`);
return true;
} catch (error) {
logger.error(`Failed to update Pagefind index: ${error}`);
return false;
}
}

async reloadSiteConfig() {
if (this.backgroundBuildMode) {
this.stopOngoingBuilds();
Expand Down
12 changes: 12 additions & 0 deletions packages/core/src/Site/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,18 @@ export class Site {
return this.generationManager.rebuildSourceFiles();
}

async indexSiteWithPagefind(): Promise<boolean> {
return this.generationManager.indexSiteWithPagefind();
}

async updatePagefindIndex(filePaths: string | string[]): Promise<boolean> {
const paths = Array.isArray(filePaths) ? filePaths : [filePaths];
const pages = this.generationManager.sitePages.pages.filter(page =>
paths.some(p => page.pageConfig.sourcePath === p),
Copy link

Copilot AI Apr 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Site.updatePagefindIndex only selects pages whose pageConfig.sourcePath exactly matches the provided filePaths. When callers pass a dependency path (e.g. from isDependencyOfPage), this will produce an empty page list and the index won’t update for regenerated pages. Consider either (1) changing this method to accept Page[] instead of file paths, or (2) enhancing the selection logic to include pages that depend on the provided path(s) (e.g., page.isDependency(p)), not just direct source path matches.

Suggested change
paths.some(p => page.pageConfig.sourcePath === p),
paths.some(p =>
page.pageConfig.sourcePath === p || page.isDependency(p),
),

Copilot uses AI. Check for mistakes.
);
return this.generationManager.updatePagefindIndex(pages);
}

buildAsset(filePaths: string | string[]) {
return this.assetsManager.buildAsset(filePaths);
}
Expand Down
20 changes: 20 additions & 0 deletions packages/core/test/unit/Site/Site.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@ jest.mock('../../../src/Site/SiteGenerationManager', () => ({
buildSourceFiles: jest.fn(),
rebuildSourceFiles: jest.fn(),
reloadSiteConfig: jest.fn(),
updatePagefindIndex: jest.fn().mockResolvedValue(true),
indexSiteWithPagefind: jest.fn().mockResolvedValue(true),
sitePages: { pages: [] },
})),
}));

Expand Down Expand Up @@ -120,6 +123,23 @@ test('Site rebuildSourceFiles delegates to SiteGenerationManager', () => {
expect(site.generationManager.rebuildSourceFiles).toHaveBeenCalled();
});

test('Site updatePagefindIndex delegates to SiteGenerationManager', async () => {
const site = new Site(...siteArguments);
const mockPage = { pageConfig: { sourcePath: 'test.md', resultPath: '_site/test.html', searchable: true } };
const mockPages = [mockPage];
site.generationManager.sitePages = { pages: mockPages } as any;

await site.updatePagefindIndex('test.md');
expect(site.generationManager.updatePagefindIndex).toHaveBeenCalledWith(mockPages);
});

test('Site indexSiteWithPagefind delegates to SiteGenerationManager', async () => {
const site = new Site(...siteArguments);

await site.indexSiteWithPagefind();
expect(site.generationManager.indexSiteWithPagefind).toHaveBeenCalled();
});

test('Site reloadSiteConfig delegates to SiteGenerationManager', async () => {
const site = new Site(...siteArguments);
await site.reloadSiteConfig();
Expand Down
Loading
Loading