diff --git a/.eslintignore b/.eslintignore
index 54d3063e00..aeb0777246 100644
--- a/.eslintignore
+++ b/.eslintignore
@@ -6,6 +6,8 @@ node_modules
packages/cli/src/lib/live-server/*
+packages/cli/test/**/pagefind/**/*.js
+
# --- packages/core ---
# Ignore JS files that are compiled from TS
diff --git a/.eslintrc.js b/.eslintrc.js
index 59369755a0..b8e6aa686e 100644
--- a/.eslintrc.js
+++ b/.eslintrc.js
@@ -2,6 +2,7 @@
/* eslint quotes: ["error", "double"] */
module.exports = {
+ "ignorePatterns": ["docs/_site/**", "**/dist/**", "**/node_modules/**"],
"env": {
"node": true,
"es6": true,
diff --git a/.gitignore b/.gitignore
index 118814adde..d351437352 100644
--- a/.gitignore
+++ b/.gitignore
@@ -46,6 +46,9 @@ packages/core/template/*/_site
# Generated site (MarkBind)
packages/cli/test/functional/*/_site
+# Generated pagefind directories for sites and in expected
+packages/cli/test/functional/**/pagefind
+
# Ignore .page-vue-render.js files in functional test and subdirectories on update
packages/cli/test/functional/**/*.page-vue-render.js
@@ -117,6 +120,9 @@ packages/core/src/lib/markdown-it/patches/**/*.js
.nx/cache
.nx/workspace-data
+# Pagefind fragments
+*.pf_fragment
+
# AI Tools directories (symlinks)
.opencode
.cline
diff --git a/.stylelintrc.js b/.stylelintrc.js
index f3276222e8..b883f26813 100644
--- a/.stylelintrc.js
+++ b/.stylelintrc.js
@@ -6,5 +6,16 @@ module.exports = {
// MarkBind generates some blank CSS files when initialising a site,
// which violates the no-empty-source rule
"no-empty-source": null
- }
+ },
+ "overrides": [
+ {
+ // pagefind uses BEM-style class names (e.g., .pagefind-ui__result) as default.
+ // Since we currently style pagefind's default UI classes, we need to ignore the kebab-case rule here.
+ // This override should be removed once we no longer rely on pagefind's default CSS classes.
+ "files": ["**/pagefindSearchBar/**"],
+ "rules": {
+ "selector-class-pattern": null
+ }
+ }
+ ]
};
diff --git a/docs/userGuide/makingTheSiteSearchable.md b/docs/userGuide/makingTheSiteSearchable.md
index 4e28680c49..709939541f 100644
--- a/docs/userGuide/makingTheSiteSearchable.md
+++ b/docs/userGuide/makingTheSiteSearchable.md
@@ -37,6 +37,112 @@ You can add a search bar component to your website to allow users to search the
+
+
+
+
+## Using Pagefind (Beta)
+
+
+MarkBind now supports [Pagefind](https://pagefind.app/), a static low-bandwidth search library, as a built-in feature. This provides full-text search capabilities without external services.
+
+
+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
+
+```
+
+The following UI will be rendered, which is provided by Pagefind:
+
+
+
+
+
+### Ignoring Individual Elements from Pagefind Search
+
+You can exclude specific elements from the search index by adding the `data-pagefind-ignore` attribute to them:
+
+```html
+
+
This content will be in your search index.
+
+ This content and all its children will be excluded from search.
+
+
+```
+
+For more details, see the [Pagefind documentation on removing individual elements](https://pagefind.app/docs/indexing/#removing-individual-elements-from-the-index).
+
+### Using Pagefind Configuration
+
+You can customize Pagefind's indexing behavior by adding a `pagefind` configuration in your `site.json`. This allows you to control which content is indexed and how search works.
+
+#### Excluding Content from Search Index
+
+You can use the `exclude_selectors` option to exclude specific elements from the search index. This is useful if you are migrating from Algolia and want to reuse your existing CSS class selectors.
+
+In your `site.json`:
+
+```json
+{
+ "pagefind": {
+ "exclude_selectors": [".algolia-no-index", "[class*='algolia-no-index']"]
+ }
+}
+```
+
+This tells Pagefind to exclude any element with the `algolia-no-index` class (or containing it in a space-separated list) from the search index, similar to using `data-pagefind-ignore`.
+
+#### Limiting Which Pages Are Searchable
+
+You can use the `glob` option to limit which pages are indexed by Pagefind. This is useful when you want search results to only show pages from specific sections of your site.
+
+In your `site.json`:
+
+```json
+{
+ "pagefind": {
+ "glob": [
+ "devGuide",
+ "userGuide/*"
+ ]
+ }
+}
+```
+
+MarkBind supports glob patterns and will automatically append `.html` to your patterns if not specified. For example:
+- `"devGuide"` becomes `"devGuide/**/*.html"`
+- `"devGuide/*"` becomes `"devGuide/*.html"`
+- `"**/devGuide/**"` becomes `"**/devGuide/**/*.html"`
+- `"*.html"` remains `"*.html"` (no change needed)
+
+Only pages matching these glob patterns will appear in search results. This can be particularly useful for:
+- Multi-site setups where you want to search only specific sections
+- Including only certain directories from search results
+
+For more details on glob patterns, see the [Pagefind documentation](https://pagefind.app/docs/config-options/#glob).
+
+
+
+Additional Pagefind configuration options may be supported in future releases:
+
+- **`root_selector`**: Allows specifying a custom root element for indexing (default: `html`). Useful for sites with specific content containers.
+- **`force_language`**: Forces a specific language for indexing (e.g., `"en"`, `"pt"`). Improves search accuracy for multilingual sites.
+
+
+
+
+
+
+
## Using External Search Services
MarkBind sites can use Algolia Doc Search services easily via the Algolia plugin. Unlike the built-in search, Algolia provides full-text search. See the panel below for more info.
diff --git a/package-lock.json b/package-lock.json
index ac24fad8e4..e6e19519ce 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -4753,6 +4753,84 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@pagefind/darwin-arm64": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/@pagefind/darwin-arm64/-/darwin-arm64-1.4.0.tgz",
+ "integrity": "sha512-2vMqkbv3lbx1Awea90gTaBsvpzgRs7MuSgKDxW0m9oV1GPZCZbZBJg/qL83GIUEN2BFlY46dtUZi54pwH+/pTQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@pagefind/darwin-x64": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/@pagefind/darwin-x64/-/darwin-x64-1.4.0.tgz",
+ "integrity": "sha512-e7JPIS6L9/cJfow+/IAqknsGqEPjJnVXGjpGm25bnq+NPdoD3c/7fAwr1OXkG4Ocjx6ZGSCijXEV4ryMcH2E3A==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@pagefind/freebsd-x64": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/@pagefind/freebsd-x64/-/freebsd-x64-1.4.0.tgz",
+ "integrity": "sha512-WcJVypXSZ+9HpiqZjFXMUobfFfZZ6NzIYtkhQ9eOhZrQpeY5uQFqNWLCk7w9RkMUwBv1HAMDW3YJQl/8OqsV0Q==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@pagefind/linux-arm64": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/@pagefind/linux-arm64/-/linux-arm64-1.4.0.tgz",
+ "integrity": "sha512-PIt8dkqt4W06KGmQjONw7EZbhDF+uXI7i0XtRLN1vjCUxM9vGPdtJc2mUyVPevjomrGz5M86M8bqTr6cgDp1Uw==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@pagefind/linux-x64": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/@pagefind/linux-x64/-/linux-x64-1.4.0.tgz",
+ "integrity": "sha512-z4oddcWwQ0UHrTHR8psLnVlz6USGJ/eOlDPTDYZ4cI8TK8PgwRUPQZp9D2iJPNIPcS6Qx/E4TebjuGJOyK8Mmg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@pagefind/windows-x64": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/@pagefind/windows-x64/-/windows-x64-1.4.0.tgz",
+ "integrity": "sha512-NkT+YAdgS2FPCn8mIA9bQhiBs+xmniMGq1LFPDhcFn0+2yIUEiIG06t7bsZlhdjknEQRTSdT7YitP6fC5qwP0g==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
"node_modules/@pkgjs/parseargs": {
"version": "0.11.0",
"dev": true,
@@ -16722,6 +16800,23 @@
"node": ">=16 || 14 >=14.17"
}
},
+ "node_modules/pagefind": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/pagefind/-/pagefind-1.4.0.tgz",
+ "integrity": "sha512-z2kY1mQlL4J8q5EIsQkLzQjilovKzfNVhX8De6oyE6uHpfFtyBaqUpcl/XzJC/4fjD8vBDyh1zolimIcVrCn9g==",
+ "license": "MIT",
+ "bin": {
+ "pagefind": "lib/runner/bin.cjs"
+ },
+ "optionalDependencies": {
+ "@pagefind/darwin-arm64": "1.4.0",
+ "@pagefind/darwin-x64": "1.4.0",
+ "@pagefind/freebsd-x64": "1.4.0",
+ "@pagefind/linux-arm64": "1.4.0",
+ "@pagefind/linux-x64": "1.4.0",
+ "@pagefind/windows-x64": "1.4.0"
+ }
+ },
"node_modules/parent-module": {
"version": "1.0.1",
"dev": true,
@@ -22140,6 +22235,7 @@
"material-icons": "^1.9.1",
"moment": "^2.29.4",
"nunjucks": "3.2.4",
+ "pagefind": "^1.4.0",
"path-is-inside": "^1.0.2",
"simple-git": "^3.22.0",
"url-parse": "^1.5.10",
diff --git a/packages/cli/test/functional/test.ts b/packages/cli/test/functional/test.ts
index 8db339da13..80fd9f774d 100644
--- a/packages/cli/test/functional/test.ts
+++ b/packages/cli/test/functional/test.ts
@@ -50,12 +50,14 @@ expectedErrors.forEach((error, index) => {
logger.info(`${index + 1}: ${error}`);
});
+const GENERATED_DIRECTORIES_TO_IGNORE = ['**/pagefind/**'];
+
testSites.forEach((siteName) => {
console.log(`Running ${siteName} tests`);
try {
execSync(`node ${CLI_PATH} build ${siteName}`, execOptions);
const siteIgnoredFiles = plantumlGeneratedFilesForTestSites[siteName];
- compare(siteName, 'expected', '_site', siteIgnoredFiles);
+ compare(siteName, 'expected', '_site', siteIgnoredFiles, GENERATED_DIRECTORIES_TO_IGNORE);
} catch (err) {
if (_.isError(err)) {
printFailedMessage(err, siteName);
@@ -74,7 +76,8 @@ testConvertSites.forEach((sitePath) => {
execSync(`node ${CLI_PATH} init ${nonMarkBindSitePath} -c`, execOptions);
execSync(`node ${CLI_PATH} build ${nonMarkBindSitePath}`, execOptions);
const siteIgnoredFiles = plantumlGeneratedFilesForConvertSites[siteName];
- compare(sitePath, 'expected', 'non_markbind_site/_site', siteIgnoredFiles);
+ compare(sitePath, 'expected', 'non_markbind_site/_site', siteIgnoredFiles,
+ GENERATED_DIRECTORIES_TO_IGNORE);
} catch (err) {
if (_.isError(err)) {
printFailedMessage(err, sitePath);
@@ -98,7 +101,7 @@ testTemplateSites.forEach((templateAndSitePath) => {
execSync(`node ${CLI_PATH} init ${siteCreationTempPath} --template ${flag}`, execOptions);
execSync(`node ${CLI_PATH} build ${siteCreationTempPath}`, execOptions);
const siteIgnoredFiles = plantumlGeneratedFilesForTemplateSites[siteName];
- compare(sitePath, 'expected', 'tmp/_site', siteIgnoredFiles);
+ compare(sitePath, 'expected', 'tmp/_site', siteIgnoredFiles, GENERATED_DIRECTORIES_TO_IGNORE);
} catch (err) {
if (_.isError(err)) {
printFailedMessage(err, sitePath);
@@ -141,7 +144,7 @@ function testEmptyDirectoryBuild() {
} catch (err) {
// Verify that test_empty directory remains empty using compare()
try {
- compare(siteRootName, 'expected', 'empty_dir', [], true);
+ compare(siteRootName, 'expected', 'empty_dir', [], [], true);
} catch (compareErr) {
if (_.isError(compareErr)) {
printFailedMessage(compareErr, siteRootName);
diff --git a/packages/cli/test/functional/testUtil/compare.ts b/packages/cli/test/functional/testUtil/compare.ts
index d522f5a19c..c631b14f7b 100644
--- a/packages/cli/test/functional/testUtil/compare.ts
+++ b/packages/cli/test/functional/testUtil/compare.ts
@@ -13,6 +13,11 @@ const TEST_BLACKLIST = ignore().add([
'*.log',
'*.woff',
'*.woff2',
+ '*.pf_fragment',
+ '*.pf_index',
+ '*.pf_meta',
+ '*.wasm.pagefind',
+ 'wasm.unknown.pagefind',
]);
const CRLF_REGEX = /\r\n/g;
@@ -54,10 +59,12 @@ function getDirectoryStructure(dirPath: string) {
* @param {string} expectedSiteRelativePath - Relative path to expected site output (default: "expected")
* @param {string} siteRelativePath - Relative path to actual generated site output (default: "_site")
* @param {string[]} ignoredPaths - Specify any paths to ignore for comparison, but still check for existence.
+ * @param {string[]} ignoredDirectories - Specify any directories to ignore for comparison (e.g. 'pagefind')
* @param {boolean} compareDirectories - Whether to compare directory structures (default: false)
*/
function compare(root: string, expectedSiteRelativePath = 'expected', siteRelativePath = '_site',
- ignoredPaths: string[] = [], compareDirectories = false) {
+ ignoredPaths: string[] = [], ignoredDirectories: string[] = [],
+ compareDirectories = false) {
const expectedDirectory = path.join(root, expectedSiteRelativePath);
const actualDirectory = path.join(root, siteRelativePath);
@@ -73,8 +80,9 @@ function compare(root: string, expectedSiteRelativePath = 'expected', siteRelati
}
}
- let expectedPaths = walkSync(expectedDirectory, { directories: false });
- let actualPaths = walkSync(actualDirectory, { directories: false });
+ const walkSyncOptions = { directories: false, ignore: ignoredDirectories };
+ let expectedPaths = walkSync(expectedDirectory, walkSyncOptions);
+ let actualPaths = walkSync(actualDirectory, walkSyncOptions);
// Vue render JS files (*.page-vue-render.js) are not committed to version control,
// so we exclude them from the comparison to avoid false positive diffs.
diff --git a/packages/cli/test/functional/test_site/expected/bugs/index.html b/packages/cli/test/functional/test_site/expected/bugs/index.html
index 7d3c9c86d9..32e743057d 100644
--- a/packages/cli/test/functional/test_site/expected/bugs/index.html
+++ b/packages/cli/test/functional/test_site/expected/bugs/index.html
@@ -16,6 +16,7 @@
+
@@ -359,5 +360,6 @@