From 428efd5dc3702015a92def6656a0bb2a0eabeac5 Mon Sep 17 00:00:00 2001 From: Thaddaeus Chua Date: Wed, 1 Apr 2026 23:15:01 +0800 Subject: [PATCH 1/8] Add updatePagefindIndex method It takes in an array of pages that needs to be updated. Filters if the page is need searchable, then add a new HTML file to update the in-memory index. Finally, we retrieve all the in-memory indexes, wipe the old files and write newly updated files to disk. --- .../core/src/Site/SiteGenerationManager.ts | 71 +++++++++++++++++-- packages/core/src/Site/index.ts | 8 +++ 2 files changed, 75 insertions(+), 4 deletions(-) diff --git a/packages/core/src/Site/SiteGenerationManager.ts b/packages/core/src/Site/SiteGenerationManager.ts index a5fce2f302..230d26cc4f 100644 --- a/packages/core/src/Site/SiteGenerationManager.ts +++ b/packages/core/src/Site/SiteGenerationManager.ts @@ -84,6 +84,9 @@ export class SiteGenerationManager { currentOpenedPages: string[]; toRebuild: Set; + // 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) { @@ -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) { @@ -872,9 +878,10 @@ export class SiteGenerationManager { ); /** - * Indexes all the pages of the site using pagefind. - * @returns true if indexing succeeded and pagefind assets were written, false otherwise. - */ + * Indexes all the pages of the site using pagefind. + * In dev mode (serve), keeps the index in memory for incremental updates. + * @returns true if indexing succeeded and pagefind assets were written, false otherwise. + */ async indexSiteWithPagefind(): Promise { const startTime = new Date(); logger.info('Creating Pagefind Search Index...'); @@ -895,6 +902,9 @@ export class SiteGenerationManager { const { index } = await createIndex(createIndexOptions); if (index) { + // Store index in memory for incremental updates in dev mode + this.pagefindIndex = index; + // Filter pages that should be indexed (searchable !== false) const searchablePages = this.sitePages.pages.filter( page => page.pageConfig.searchable, @@ -942,7 +952,15 @@ export class SiteGenerationManager { 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 !== ''; // Suggest a better fix if possible + if (!isServeMode) { + await close(); + this.pagefindIndex = null; + } + return true; } logger.error('Pagefind failed to create index'); @@ -954,6 +972,51 @@ 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 { + if (!this.pagefindIndex) { + logger.warn('Pagefind index not in memory, skipping incremental update'); + return false; + } + + 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) => { + 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, + }); + })); + + const { files } = await this.pagefindIndex.getFiles(); + 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(); diff --git a/packages/core/src/Site/index.ts b/packages/core/src/Site/index.ts index 763cc025d0..bc42058142 100644 --- a/packages/core/src/Site/index.ts +++ b/packages/core/src/Site/index.ts @@ -86,6 +86,14 @@ export class Site { return this.generationManager.rebuildSourceFiles(); } + async updatePagefindIndex(filePaths: string | string[]): Promise { + const paths = Array.isArray(filePaths) ? filePaths : [filePaths]; + const pages = this.generationManager.sitePages.pages.filter(page => + paths.some(p => page.pageConfig.sourcePath === p), + ); + return this.generationManager.updatePagefindIndex(pages); + } + buildAsset(filePaths: string | string[]) { return this.assetsManager.buildAsset(filePaths); } From 4152c7978c7171fb7f4eba656a930024d6474009 Mon Sep 17 00:00:00 2001 From: Thaddaeus Chua Date: Thu, 2 Apr 2026 00:28:32 +0800 Subject: [PATCH 2/8] Update serveUtils for add, change & remove handler add & change handlers use updatePagefindIndex as there is only a need to either add/update 1 file in-memory. However, as there is no way of to remove just 1 specific index file from in-memory indexes, remove handler will use indexSiteWithPagefind, meaning that it will reindex the entire site. There might be a better solution out there. But since removing already calls rebuildSourceFiles which takes significantly longer that it takes to reindex the site. I don't think its a big deal. --- packages/cli/src/util/serveUtil.ts | 9 ++++++--- .../core/src/Site/SiteGenerationManager.ts | 19 +++++++++++++++---- packages/core/src/Site/index.ts | 4 ++++ 3 files changed, 25 insertions(+), 7 deletions(-) diff --git a/packages/cli/src/util/serveUtil.ts b/packages/cli/src/util/serveUtil.ts index eec7886aa8..8022cf4702 100755 --- a/packages/cli/src/util/serveUtil.ts +++ b/packages/cli/src/util/serveUtil.ts @@ -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); } return site.buildAsset(filePath); }).catch((err: Error) => { @@ -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); } return site.buildAsset(filePath); }).catch((err: Error) => { @@ -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(); } return site.removeAsset(filePath); }).catch((err: Error) => { diff --git a/packages/core/src/Site/SiteGenerationManager.ts b/packages/core/src/Site/SiteGenerationManager.ts index 230d26cc4f..543cbb956c 100644 --- a/packages/core/src/Site/SiteGenerationManager.ts +++ b/packages/core/src/Site/SiteGenerationManager.ts @@ -879,7 +879,7 @@ export class SiteGenerationManager { /** * Indexes all the pages of the site using pagefind. - * In dev mode (serve), keeps the index in memory for incremental updates. + * Performs a full rebuild of the search index. * @returns true if indexing succeeded and pagefind assets were written, false otherwise. */ async indexSiteWithPagefind(): Promise { @@ -900,9 +900,16 @@ export class SiteGenerationManager { 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); + if (index) { - // Store index in memory for incremental updates in dev mode + // Store index in memory for incremental updates in serve mode this.pagefindIndex = index; // Filter pages that should be indexed (searchable !== false) @@ -949,14 +956,18 @@ 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}`); // 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 !== ''; // Suggest a better fix if possible - if (!isServeMode) { + const isServeMode = this.postBackgroundBuildFunc.name !== ''; + const shouldClose = !isServeMode; + + if (shouldClose) { await close(); this.pagefindIndex = null; } diff --git a/packages/core/src/Site/index.ts b/packages/core/src/Site/index.ts index bc42058142..533d7cdb40 100644 --- a/packages/core/src/Site/index.ts +++ b/packages/core/src/Site/index.ts @@ -86,6 +86,10 @@ export class Site { return this.generationManager.rebuildSourceFiles(); } + async indexSiteWithPagefind(): Promise { + return this.generationManager.indexSiteWithPagefind(); + } + async updatePagefindIndex(filePaths: string | string[]): Promise { const paths = Array.isArray(filePaths) ? filePaths : [filePaths]; const pages = this.generationManager.sitePages.pages.filter(page => From b1b192b3771fdcbfe448e9560023950c153702dd Mon Sep 17 00:00:00 2001 From: Thaddaeus Chua Date: Fri, 10 Apr 2026 00:13:18 +0800 Subject: [PATCH 3/8] Add test cases --- packages/core/test/unit/Site/Site.test.ts | 20 +++++ .../unit/Site/SiteGenerationManager.test.ts | 76 +++++++++++++++++++ packages/core/test/unit/utils/data.ts | 4 + 3 files changed, 100 insertions(+) diff --git a/packages/core/test/unit/Site/Site.test.ts b/packages/core/test/unit/Site/Site.test.ts index 8523920322..5cce07873e 100644 --- a/packages/core/test/unit/Site/Site.test.ts +++ b/packages/core/test/unit/Site/Site.test.ts @@ -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: [] }, })), })); @@ -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(); diff --git a/packages/core/test/unit/Site/SiteGenerationManager.test.ts b/packages/core/test/unit/Site/SiteGenerationManager.test.ts index 02745f543b..59fb111ebc 100644 --- a/packages/core/test/unit/Site/SiteGenerationManager.test.ts +++ b/packages/core/test/unit/Site/SiteGenerationManager.test.ts @@ -367,6 +367,82 @@ describe('SiteGenerationManager', () => { }); }); + describe('updatePagefindIndex', () => { + beforeEach(() => { + const json = { + ...PAGE_NJK, + 'site.json': SITE_JSON_DEFAULT, + '_site/index.html': 'Test page', + '_site/page1.html': 'Page 1', + }; + mockFs.vol.fromJSON(json, rootPath); + }); + + test('should return false when pagefindIndex is null', async () => { + generationManager.pagefindIndex = null; + + const result = await generationManager.updatePagefindIndex([]); + expect(result).toBe(false); + }); + + test('should call addHTMLFile for each searchable page', async () => { + const mockIndex = createMockIndex({ page_count: 1, errors: [] }, { errors: [] }); + generationManager.pagefindIndex = mockIndex; + + const pages = [ + { pageConfig: { resultPath: path.join(outputPath, 'index.html'), searchable: true } }, + { pageConfig: { resultPath: path.join(outputPath, 'page1.html'), searchable: true } }, + ] as any; + + await generationManager.updatePagefindIndex(pages); + + expect(mockIndex.addHTMLFile).toHaveBeenCalledTimes(2); + expect(mockIndex.addHTMLFile).toHaveBeenCalledWith({ + sourcePath: 'index.html', + content: 'Test page', + }); + expect(mockIndex.addHTMLFile).toHaveBeenCalledWith({ + sourcePath: 'page1.html', + content: 'Page 1', + }); + }); + + test('should skip non-searchable pages', async () => { + const mockIndex = createMockIndex({ page_count: 1, errors: [] }, { errors: [] }); + generationManager.pagefindIndex = mockIndex; + + const pages = [ + { pageConfig: { resultPath: path.join(outputPath, 'index.html'), searchable: false } }, + { pageConfig: { resultPath: path.join(outputPath, 'page1.html'), searchable: true } }, + ] as any; + + await generationManager.updatePagefindIndex(pages); + + expect(mockIndex.addHTMLFile).toHaveBeenCalledTimes(1); + expect(mockIndex.addHTMLFile).toHaveBeenCalledWith({ + sourcePath: 'page1.html', + content: 'Page 1', + }); + }); + + test('should log error and return false on failure', async () => { + const mockIndex = createMockIndex({ page_count: 1, errors: [] }, { errors: [] }); + (mockIndex.addHTMLFile as jest.Mock).mockRejectedValue(new Error('Index error')); + generationManager.pagefindIndex = mockIndex; + const errorSpy = jest.spyOn(logger, 'error').mockImplementation(); + + const failData = { resultPath: path.join(outputPath, 'index.html'), searchable: true }; + const failPages = [{ pageConfig: failData }] as any; + + const result = await generationManager.updatePagefindIndex(failPages); + + expect(result).toBe(false); + expect(errorSpy).toHaveBeenCalledWith('Failed to update Pagefind index: Error: Index error'); + + errorSpy.mockRestore(); + }); + }); + test('collectBaseUrl should collect baseurls correctly for sub nested subsites', async () => { const json = { ...PAGE_NJK, diff --git a/packages/core/test/unit/utils/data.ts b/packages/core/test/unit/utils/data.ts index a55a4a6ccb..b9e24e5f87 100644 --- a/packages/core/test/unit/utils/data.ts +++ b/packages/core/test/unit/utils/data.ts @@ -112,6 +112,8 @@ export interface MockIndex { addDirectory: ReturnType; addHTMLFile: ReturnType; writeFiles: ReturnType; + deleteIndex?: ReturnType; + getFiles?: ReturnType; } export interface MockPagefind { @@ -132,6 +134,8 @@ export function createMockIndex( addDirectory: jest.fn().mockResolvedValue(result), addHTMLFile: jest.fn().mockResolvedValue(htmlFileResult), writeFiles: jest.fn().mockResolvedValue(undefined), + deleteIndex: jest.fn().mockResolvedValue(undefined), + getFiles: jest.fn().mockResolvedValue({ files: [] }), }; } From c954ca95b87738bfb792830647dee51dafb273e5 Mon Sep 17 00:00:00 2001 From: Thaddaeus Chua Date: Mon, 13 Apr 2026 21:22:24 +0800 Subject: [PATCH 4/8] Add incremental indexing for -o & -b --- .../core/src/Site/SiteGenerationManager.ts | 28 +++++++++++++++---- 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/packages/core/src/Site/SiteGenerationManager.ts b/packages/core/src/Site/SiteGenerationManager.ts index 543cbb956c..bacdfb6cf4 100644 --- a/packages/core/src/Site/SiteGenerationManager.ts +++ b/packages/core/src/Site/SiteGenerationManager.ts @@ -446,6 +446,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]); @@ -472,6 +477,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(); } } @@ -1001,13 +1011,19 @@ export class SiteGenerationManager { const searchablePages = pages.filter(page => page.pageConfig.searchable); await Promise.all(searchablePages.map(async (page) => { - const content = await fs.readFile(page.pageConfig.resultPath, 'utf8'); - const relativePath = path.relative(this.outputPath, page.pageConfig.resultPath); + 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, - }); + 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; + } })); const { files } = await this.pagefindIndex.getFiles(); From e821cbb5b1adc2b1e87656996e9fb13359c4515f Mon Sep 17 00:00:00 2001 From: Thaddaeus Chua Date: Mon, 13 Apr 2026 22:05:56 +0800 Subject: [PATCH 5/8] Add test cases for one-page and background build --- .../unit/Site/SiteGenerationManager.test.ts | 99 +++++++++++++++++++ 1 file changed, 99 insertions(+) diff --git a/packages/core/test/unit/Site/SiteGenerationManager.test.ts b/packages/core/test/unit/Site/SiteGenerationManager.test.ts index 59fb111ebc..3085522368 100644 --- a/packages/core/test/unit/Site/SiteGenerationManager.test.ts +++ b/packages/core/test/unit/Site/SiteGenerationManager.test.ts @@ -441,6 +441,105 @@ describe('SiteGenerationManager', () => { errorSpy.mockRestore(); }); + + test('should skip pages that do not exist when updating index', async () => { + const mockIndex = createMockIndex({ page_count: 1, errors: [] }, { errors: [] }); + generationManager.pagefindIndex = mockIndex; + generationManager.siteConfig = { enableSearch: true } as any; + + const missingPageConfig = { resultPath: path.join(outputPath, 'nonexistent.html'), searchable: true }; + const pages = [{ pageConfig: missingPageConfig }] as any; + + const warnSpy = jest.spyOn(logger, 'warn').mockImplementation(); + + await generationManager.updatePagefindIndex(pages); + + expect(warnSpy).toHaveBeenCalled(); + warnSpy.mockRestore(); + }); + }); + + describe('Lazy serve pagefind indexing', () => { + beforeEach(() => { + const json = { + ...PAGE_NJK, + 'site.json': SITE_JSON_DEFAULT, + '_site/index.html': 'Test page', + }; + mockFs.vol.fromJSON(json, rootPath); + }); + + test('_rebuildPagesBeingViewed should NOT update index when enableSearch is false', async () => { + generationManager.siteConfig = { enableSearch: false } as any; + const resultPath = path.join(outputPath, 'index.html'); + const pageConfig = { sourcePath: 'index.md', resultPath, searchable: true }; + generationManager.sitePages.pages = [{ pageConfig }] as any; + + const runTaskSpy = jest.spyOn(generationManager, 'runPageGenerationTasks') + .mockResolvedValue(undefined as any); + const writeDataSpy = jest.spyOn(generationManager, 'writeSiteData') + .mockResolvedValue(undefined as any); + const updateIndexSpy = jest.spyOn(generationManager, 'updatePagefindIndex'); + + await generationManager._rebuildPagesBeingViewed(['index']); + + expect(updateIndexSpy).not.toHaveBeenCalled(); + runTaskSpy.mockRestore(); + writeDataSpy.mockRestore(); + updateIndexSpy.mockRestore(); + }); + + test('_rebuildPagesBeingViewed should NOT update index when pagefindIndex is null', async () => { + generationManager.siteConfig = { enableSearch: true } as any; + generationManager.pagefindIndex = null; + const resultPath = path.join(outputPath, 'index.html'); + const pageConfig = { sourcePath: 'index.md', resultPath, searchable: true }; + generationManager.sitePages.pages = [{ pageConfig }] as any; + + const runTaskSpy = jest.spyOn(generationManager, 'runPageGenerationTasks') + .mockResolvedValue(undefined as any); + const writeDataSpy = jest.spyOn(generationManager, 'writeSiteData') + .mockResolvedValue(undefined as any); + const updateIndexSpy = jest.spyOn(generationManager, 'updatePagefindIndex'); + + await generationManager._rebuildPagesBeingViewed(['index']); + + expect(updateIndexSpy).not.toHaveBeenCalled(); + runTaskSpy.mockRestore(); + writeDataSpy.mockRestore(); + updateIndexSpy.mockRestore(); + }); + + test('_backgroundBuild should call indexSiteWithPagefind when enableSearch is true', async () => { + generationManager.siteConfig = { enableSearch: true } as any; + generationManager.toRebuild = new Set(['page1', 'page2']); + + const genPagesSpy = jest.spyOn(generationManager, 'generatePagesMarkedToRebuild') + .mockResolvedValue(true); + const indexSpy = jest.spyOn(generationManager, 'indexSiteWithPagefind') + .mockResolvedValue(true); + + await generationManager._backgroundBuildNotViewedFiles(); + + expect(indexSpy).toHaveBeenCalled(); + genPagesSpy.mockRestore(); + indexSpy.mockRestore(); + }); + + test('_backgroundBuild should NOT call indexSiteWithPagefind when enableSearch is false', async () => { + generationManager.siteConfig = { enableSearch: false } as any; + generationManager.toRebuild = new Set(['page1', 'page2']); + + const genPagesSpy = jest.spyOn(generationManager, 'generatePagesMarkedToRebuild') + .mockResolvedValue(true); + const indexSpy = jest.spyOn(generationManager, 'indexSiteWithPagefind'); + + await generationManager._backgroundBuildNotViewedFiles(); + + expect(indexSpy).not.toHaveBeenCalled(); + genPagesSpy.mockRestore(); + indexSpy.mockRestore(); + }); }); test('collectBaseUrl should collect baseurls correctly for sub nested subsites', async () => { From 326ce05d4a4e332f6ffed5f73f090762c77ec59f Mon Sep 17 00:00:00 2001 From: Thaddaeus Chua Date: Tue, 14 Apr 2026 00:45:48 +0800 Subject: [PATCH 6/8] Update serve onepage to index one page only Now serving one page will directly call updatePagefindIndex with the respective page to build instead of calling indexSiteWithPagefind which will attempt to index all other pages too, even if they DNE. Still need to update test cases... arghhhhhhh lol --- .../core/src/Site/SiteGenerationManager.ts | 53 ++++++++++++------- 1 file changed, 35 insertions(+), 18 deletions(-) diff --git a/packages/core/src/Site/SiteGenerationManager.ts b/packages/core/src/Site/SiteGenerationManager.ts index bacdfb6cf4..3d9162a1f2 100644 --- a/packages/core/src/Site/SiteGenerationManager.ts +++ b/packages/core/src/Site/SiteGenerationManager.ts @@ -323,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); @@ -887,6 +895,28 @@ export class SiteGenerationManager { 1000, ); + /** + * Initializes a new Pagefind index with proper configuration. + * @returns The created index object + */ + private async initializePagefindIndex(): Promise { + const { createIndex } = pagefind; + const pagefindConfig = this.siteConfig.pagefind || {}; + + const createIndexOptions: Record = { + 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. @@ -896,27 +926,14 @@ export class SiteGenerationManager { const startTime = new Date(); logger.info('Creating Pagefind Search Index...'); try { - const { createIndex, close } = pagefind; - - const pagefindConfig = this.siteConfig.pagefind || {}; - - const createIndexOptions: Record = { - 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 @@ -1001,8 +1018,8 @@ export class SiteGenerationManager { */ async updatePagefindIndex(pages: Page[]): Promise { if (!this.pagefindIndex) { - logger.warn('Pagefind index not in memory, skipping incremental update'); - return false; + 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'); From c2ae33ed52ce33538322d54d3887e232312c54f6 Mon Sep 17 00:00:00 2001 From: Thaddaeus Chua Date: Tue, 14 Apr 2026 01:00:52 +0800 Subject: [PATCH 7/8] Update test cases --- .../unit/Site/SiteGenerationManager.test.ts | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/packages/core/test/unit/Site/SiteGenerationManager.test.ts b/packages/core/test/unit/Site/SiteGenerationManager.test.ts index 3085522368..8adde4dff7 100644 --- a/packages/core/test/unit/Site/SiteGenerationManager.test.ts +++ b/packages/core/test/unit/Site/SiteGenerationManager.test.ts @@ -378,16 +378,24 @@ describe('SiteGenerationManager', () => { mockFs.vol.fromJSON(json, rootPath); }); - test('should return false when pagefindIndex is null', async () => { + test('should auto-create index when pagefindIndex is null', async () => { generationManager.pagefindIndex = null; + generationManager.siteConfig = { pagefind: undefined } as any; + const mockIndex = createMockIndex({ page_count: 0, errors: [] }); + const createIndexSpy = jest.spyOn(pagefind, 'createIndex').mockResolvedValue( + { index: mockIndex } as any, + ); - const result = await generationManager.updatePagefindIndex([]); - expect(result).toBe(false); + await generationManager.updatePagefindIndex([]); + + expect(createIndexSpy).toHaveBeenCalled(); + createIndexSpy.mockRestore(); }); test('should call addHTMLFile for each searchable page', async () => { const mockIndex = createMockIndex({ page_count: 1, errors: [] }, { errors: [] }); generationManager.pagefindIndex = mockIndex; + generationManager.siteConfig = { pagefind: undefined } as any; const pages = [ { pageConfig: { resultPath: path.join(outputPath, 'index.html'), searchable: true } }, @@ -410,6 +418,7 @@ describe('SiteGenerationManager', () => { test('should skip non-searchable pages', async () => { const mockIndex = createMockIndex({ page_count: 1, errors: [] }, { errors: [] }); generationManager.pagefindIndex = mockIndex; + generationManager.siteConfig = { pagefind: undefined } as any; const pages = [ { pageConfig: { resultPath: path.join(outputPath, 'index.html'), searchable: false } }, @@ -429,6 +438,7 @@ describe('SiteGenerationManager', () => { const mockIndex = createMockIndex({ page_count: 1, errors: [] }, { errors: [] }); (mockIndex.addHTMLFile as jest.Mock).mockRejectedValue(new Error('Index error')); generationManager.pagefindIndex = mockIndex; + generationManager.siteConfig = { pagefind: undefined } as any; const errorSpy = jest.spyOn(logger, 'error').mockImplementation(); const failData = { resultPath: path.join(outputPath, 'index.html'), searchable: true }; @@ -445,7 +455,7 @@ describe('SiteGenerationManager', () => { test('should skip pages that do not exist when updating index', async () => { const mockIndex = createMockIndex({ page_count: 1, errors: [] }, { errors: [] }); generationManager.pagefindIndex = mockIndex; - generationManager.siteConfig = { enableSearch: true } as any; + generationManager.siteConfig = { enableSearch: true, pagefind: undefined } as any; const missingPageConfig = { resultPath: path.join(outputPath, 'nonexistent.html'), searchable: true }; const pages = [{ pageConfig: missingPageConfig }] as any; From 14dd6dcba79f4773445f30ee16633cf34462ec76 Mon Sep 17 00:00:00 2001 From: Thaddaeus Chua Date: Tue, 14 Apr 2026 01:04:22 +0800 Subject: [PATCH 8/8] Update docs Removed warning about how pagefind only works on full site build as that is no longer the case after these changes --- docs/userGuide/makingTheSiteSearchable.md | 4 ---- 1 file changed, 4 deletions(-) diff --git a/docs/userGuide/makingTheSiteSearchable.md b/docs/userGuide/makingTheSiteSearchable.md index a1e92e96f4..6e83cf5213 100644 --- a/docs/userGuide/makingTheSiteSearchable.md +++ b/docs/userGuide/makingTheSiteSearchable.md @@ -50,10 +50,6 @@ MarkBind now supports [Pagefind](https://pagefind.app/), a static low-bandwidth This is a beta feature and will be refined in future updates. To use it, you must have enableSearch: true in your site.json (this is the default). - -The Pagefind index is currently only generated during a full site build (e.g., markbind build). It will not repeatedly update during live reload (markbind serve) when you modify pages. You must restart the server (re-run markbind serve) or rebuild to refresh the search index. - - To add the Pagefind search bar to your page, simply insert the following element where you want it to appear: ```md