diff --git a/docs/content/docs/_index.md b/docs/content/docs/_index.md index 274c81fc..027c0c46 100644 --- a/docs/content/docs/_index.md +++ b/docs/content/docs/_index.md @@ -53,6 +53,19 @@ We can see that a bunch of content was indexed, and Pagefind will be running a p Loading this in your browser, you should see a search input on your page. Try searching for some content and you will see results appear from your site. +## Highlighting + +To highlight the search terms on results page, add the following snippet on every page that has been indexed + +```html + + + + """ + Given I have a "public/single-body/index.html" file with the body: + """ +
+

This should be highlighted

+

This should not be highlighted

+
+

This should not be highlighted

+ + """ + Given I have a "public/multiple-bodies/index.html" file with the body: + """ +
+

This should be highlighted

+

This should not be highlighted

+
+

This should not be highlighted

+
+

This should be highlighted

+

This should not be highlighted

+
+ + """ + Given I have a "public/options/index.html" file with the body: + """ +
+

This should be highlighted

+

This should not be highlighted

+
+

This should not be highlighted

+
+

This should be highlighted

+

This should not be highlighted

+

This should not be highlighted

+
+ + """ + When I run my program + Then I should see "Running Pagefind" in stdout + When I serve the "public" directory + + Scenario: Highlight script is loaded + When I load "/words/" + Then I should see the file "public/pagefind/pagefind-highlight.js" + Then There should be no logs + + Scenario: Highlight script marks correctly + When I load "/words/?pagefind-highlight=this" + Then There should be no logs + Then The selector "#has-highlight mark" should contain "this" + Then The selector "#has-highlight mark.pagefind-highlight" should contain "this" + Then The selector "p[data-pagefind-ignore]:not(:has(span))" should contain "This should not be highlighted" + Then The selector "p[data-pagefind-ignore]:has(span)" should contain "This should not be highlighted" + When I load "/words/?pagefind-highlight=this&pagefind-highlight=should" + Then There should be no logs + Then The selector "#has-highlight mark:first-of-type" should contain "this" + Then The selector "#has-highlight mark:nth-of-type(2)" should contain "should" + When I load "/words/?pagefind-highlight=is+this" + Then There should be no logs + Then The selector "#has-highlight mark" should contain "Is this" + Then The selector "p[data-pagefind-ignore]" should contain "This should not be highlighted" + When I load "/words/?pagefind-highlight=highlighted%3F" + Then There should be no logs + Then The selector "#has-highlight mark" should contain "highlighted?" + When I load "/words/?pagefind-highlight=this+highlighted%3F" + Then There should be no logs + Then The selector "#has-highlight mark:first-of-type" should contain "this highlighted?" + + Scenario: Highlight script stays within pagefind-body + When I load "/single-body/?pagefind-highlight=this" + Then There should be no logs + Then The selector "#has-highlight mark" should contain "This" + Then The selector "p[data-pagefind-ignore]" should contain "This should not be highlighted" + Then The selector "#no-highlight" should contain "This should not be highlighted" + When I load "/multiple-bodies/?pagefind-highlight=this" + Then There should be no logs + Then The selector "#has-highlight mark" should contain "This" + Then The selector "p[data-pagefind-ignore]" should contain "This should not be highlighted" + Then The selector "#no-highlight" should contain "This should not be highlighted" + + Scenario: Highlight script options work + When I load "/options/?custom-name=this" + Then There should be no logs + Then The selector "#has-highlight mark" should contain "This" + Then The selector "#has-highlight mark.custom-class" should contain "This" + Then The selector "p[data-pagefind-ignore]" should contain "This should not be highlighted" + Then The selector "p.ignore" should contain "This should not be highlighted" + Then The selector "#no-highlight" should contain "This should not be highlighted" + diff --git a/pagefind/features/ui/ui_highlight.feature b/pagefind/features/ui/ui_highlight.feature new file mode 100644 index 00000000..642a2fbe --- /dev/null +++ b/pagefind/features/ui/ui_highlight.feature @@ -0,0 +1,143 @@ +Feature: Base UI Tests + Background: + Given I have the environment variables: + | PAGEFIND_SITE | public | + + # in this senario I use the css attribute selector to make sure the link has the query param as the end + # if the link doesn't exist, the check will fail + # see https://developer.mozilla.org/en-US/docs/Web/CSS/Attribute_selectors#syntax:~:text=%5Battr%24%3Dvalue%5D,by%20value. + + Scenario: Pagefind UI adds highlight query params + Given I have a "public/index.html" file with the body: + """ + + + + + """ + Given I have a "public/cat/index.html" file with the body: + """ +

hello world

+

Hello world! How are you

+ """ + When I run my program + Then I should see "Running Pagefind" in stdout + Then I should see the file "public/pagefind/pagefind.js" + When I serve the "public" directory + When I load "/" + When I evaluate: + """ + async function() { + window.pui.triggerSearch("world"); + await new Promise(r => setTimeout(r, 1500)); // TODO: await el in humane + } + """ + Then There should be no logs + Then The selector ".pagefind-ui__result-link[href$='?pagefind-highlight=world']" should contain "hello world" + When I evaluate: + """ + async function() { + window.pui.triggerSearch("hello world"); + await new Promise(r => setTimeout(r, 1500)); // TODO: await el in humane + } + """ + Then There should be no logs + Then The selector ".pagefind-ui__result-link[href$='?pagefind-highlight=hello&pagefind-highlight=world']" should contain "hello world" + When I evaluate: + """ + async function() { + window.pui.triggerSearch("hello world!"); + await new Promise(r => setTimeout(r, 1500)); // TODO: await el in humane + } + """ + Then There should be no logs + Then The selector ".pagefind-ui__result-link[href$='?pagefind-highlight=hello&pagefind-highlight=world%21']" should contain "hello world" + + Scenario: Pagefind UI does not add highlight query params + Given I have a "public/index.html" file with the body: + """ + + + + + """ + Given I have a "public/cat/index.html" file with the body: + """ +

hello world

+

Hello world! How are you

+ """ + When I run my program + Then I should see "Running Pagefind" in stdout + Then I should see the file "public/pagefind/pagefind.js" + When I serve the "public" directory + When I load "/" + When I evaluate: + """ + async function() { + window.pui.triggerSearch("world"); + await new Promise(r => setTimeout(r, 1500)); // TODO: await el in humane + } + """ + Then There should be no logs + Then The selector ".pagefind-ui__result-link[href$='/']" should contain "hello world" + When I evaluate: + """ + async function() { + window.pui.triggerSearch("hello world"); + await new Promise(r => setTimeout(r, 1500)); // TODO: await el in humane + } + """ + Then There should be no logs + Then The selector ".pagefind-ui__result-link[href$='/']" should contain "hello world" + + Scenario: Pagefind UI uses custom highlight query param name + Given I have a "public/index.html" file with the body: + """ + + + + + """ + Given I have a "public/cat/index.html" file with the body: + """ +

hello world

+

Hello world! How are you

+ """ + When I run my program + Then I should see "Running Pagefind" in stdout + Then I should see the file "public/pagefind/pagefind.js" + When I serve the "public" directory + When I load "/" + When I evaluate: + """ + async function() { + window.pui.triggerSearch("world"); + await new Promise(r => setTimeout(r, 1500)); // TODO: await el in humane + } + """ + Then There should be no logs + Then The selector ".pagefind-ui__result-link[href$='?custom-param=world']" should contain "hello world" + When I evaluate: + """ + async function() { + window.pui.triggerSearch("hello world"); + await new Promise(r => setTimeout(r, 1500)); // TODO: await el in humane + } + """ + Then There should be no logs + Then The selector ".pagefind-ui__result-link[href$='?custom-param=hello&custom-param=world']" should contain "hello world" + When I evaluate: + """ + async function() { + window.pui.triggerSearch("hello world!"); + await new Promise(r => setTimeout(r, 1500)); // TODO: await el in humane + } + """ + Then There should be no logs + Then The selector ".pagefind-ui__result-link[href$='?custom-param=hello&custom-param=world%21']" should contain "hello world" diff --git a/pagefind/features/ui/ui_hooks.feature b/pagefind/features/ui/ui_hooks.feature index f0451a05..3c645c4a 100644 --- a/pagefind/features/ui/ui_hooks.feature +++ b/pagefind/features/ui/ui_hooks.feature @@ -14,6 +14,7 @@ Feature: UI Hooks window.pui = new PagefindUI({ element: "#search", processTerm: (t) => t.replace("word", "search"), + highlightQueryParamName: null }); """ @@ -31,7 +32,7 @@ Feature: UI Hooks // TODO: Add more web test steps to humane instead of throwing js let el = document.querySelector(".pagefind-ui__result-link"); if (el.getAttribute("href") !== "/") { - throw new Error("Search term should have been normalized by processTerm"); + throw new Error(`Search term should have been normalized by processTerm. href: ${el.getAttribute("href")}`); } } """ diff --git a/pagefind/src/output/mod.rs b/pagefind/src/output/mod.rs index c2003f9f..9ab7e37e 100644 --- a/pagefind/src/output/mod.rs +++ b/pagefind/src/output/mod.rs @@ -62,6 +62,12 @@ const SEARCH_JS: &str = include_str!(concat!( env!("CARGO_PKG_VERSION"), ".js" )); +const HIGHLIGHT_JS: &[u8] = include_bytes!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/vendor/pagefind_highlight.", + env!("CARGO_PKG_VERSION"), + ".js" +)); pub struct LanguageMeta { pub page_count: usize, @@ -125,6 +131,12 @@ async fn write_common( Compress::None, write_behavior, ), + write( + outdir.join("pagefind-highlight.js"), + vec![HIGHLIGHT_JS], + Compress::None, + write_behavior, + ), write( outdir.join("pagefind-ui.js"), vec![WEB_UI_JS], diff --git a/pagefind_ui/default/svelte/result.svelte b/pagefind_ui/default/svelte/result.svelte index e1fca739..86639611 100644 --- a/pagefind_ui/default/svelte/result.svelte +++ b/pagefind_ui/default/svelte/result.svelte @@ -1,162 +1,155 @@
  • - {#if data} - {#if show_images} -
    - {#if data.meta.image} - {data.meta?.image_alt - {/if} -
    - {/if} -
    -

    - {data.meta?.title} -

    -

    {@html data.excerpt}

    - {#if meta.length} - - {/if} -
    - {:else} - {#if show_images} -
    + {#if data} + {#if show_images} +
    + {#if data.meta.image} + {data.meta?.image_alt {/if} -
    -

    - {placeholder(30)} -

    -

    - {placeholder(40)} -

    -
    +
    + {/if} +
    +

    + {@html data.meta?.title} +

    +

    {@html data.excerpt}

    + {#if meta.length} +
      + {#each meta as [metaTitle, metaValue]} +
    • + {@html metaTitle.replace(/^(\w)/, (c) => c.toLocaleUpperCase())}: {@html metaValue} +
    • + {/each} +
    + {/if} +
    + {:else} + {#if show_images} +
    {/if} +
    +

    + {placeholder(30)} +

    +

    + {placeholder(40)} +

    +
    + {/if}
  • diff --git a/pagefind_ui/default/svelte/result_with_subs.svelte b/pagefind_ui/default/svelte/result_with_subs.svelte index 1f16885e..5ee77ee9 100644 --- a/pagefind_ui/default/svelte/result_with_subs.svelte +++ b/pagefind_ui/default/svelte/result_with_subs.svelte @@ -2,6 +2,8 @@ export let show_images = true; export let process_result = null; export let result = { data: async () => {} }; + // string or null + export let highlight_query_param = null; const skipMeta = ["title", "image", "image_alt", "url"]; @@ -26,10 +28,12 @@ const load = async (r) => { data = await r.data(); data = process_result?.(data) ?? data; - meta = Object.entries(data.meta).filter(([key]) => !skipMeta.includes(key)); + meta = Object.entries(data?.meta).filter( + ([key]) => !skipMeta.includes(key) + ); if (Array.isArray(data.sub_results)) { has_root_sub_result = - data.sub_results?.[0]?.url === (data.meta?.url || data.url); + data.sub_results?.[0]?.url === (data?.meta?.url || data?.url); if (has_root_sub_result) { non_root_sub_results = thin_sub_results(data.sub_results.slice(1), 3); } else { @@ -59,8 +63,11 @@ {/if}

    - {data.meta?.title}{@html data.meta?.title}

    {#if has_root_sub_result} @@ -70,8 +77,11 @@ {#each non_root_sub_results as subres}

    - {subres.title}{@html subres.title}

    {@html subres.excerpt}

    @@ -82,7 +92,7 @@ diff --git a/pagefind_ui/default/svelte/ui.svelte b/pagefind_ui/default/svelte/ui.svelte index 3946be2e..ff285dfa 100644 --- a/pagefind_ui/default/svelte/ui.svelte +++ b/pagefind_ui/default/svelte/ui.svelte @@ -1,452 +1,477 @@
    - diff --git a/pagefind_ui/default/ui-core.js b/pagefind_ui/default/ui-core.js index f3260c60..e6c6fc00 100644 --- a/pagefind_ui/default/ui-core.js +++ b/pagefind_ui/default/ui-core.js @@ -25,6 +25,11 @@ export class PagefindUI { let debounceTimeoutMs = opts.debounceTimeoutMs ?? 300; let mergeIndex = opts.mergeIndex ?? []; let translations = opts.translations ?? []; + // setting the param to null should disable highlighting, hence this more complicated check + let highlightQueryParamName = "pagefind-highlight"; + if (opts.highlightQueryParamName !== undefined) { + highlightQueryParamName = opts.highlightQueryParamName; + } // Remove the UI-specific config before passing it along to the Pagefind backend delete opts["element"]; @@ -59,6 +64,7 @@ export class PagefindUI { debounce_timeout_ms: debounceTimeoutMs, merge_index: mergeIndex, translations, + highlight_query_param_name: highlightQueryParamName, pagefind_options: opts, }, }); diff --git a/pagefind_web_js/build.js b/pagefind_web_js/build.js index 33a7005c..99ca925b 100644 --- a/pagefind_web_js/build.js +++ b/pagefind_web_js/build.js @@ -28,6 +28,18 @@ const build = async () => { const compiledVendor = await esbuild.build(esbuildVendorOptions); console.log(`Vendor Build: `, compiledVendor); + // Coupled search vendor build + const esbuildVendorHighlightOptions = { + ...commonOpts, + entryPoints: [path.join(__dirname, 'lib/highlight.ts')], + entryNames: `pagefind_[name].${version}`, + outdir: path.join(__dirname, `../pagefind/vendor/`), + format: 'esm', + target: 'es2020' + } + const compiledVendorHighlight = await esbuild.build(esbuildVendorHighlightOptions); + console.log(`Vendor Highlight Build: `, compiledVendorHighlight); + // // CJS "main" build // const esbuildCjsOptions = { // ...commonOpts, diff --git a/pagefind_web_js/lib/highlight.ts b/pagefind_web_js/lib/highlight.ts new file mode 100644 index 00000000..7feeabd5 --- /dev/null +++ b/pagefind_web_js/lib/highlight.ts @@ -0,0 +1,110 @@ +// this script should be imported on the result pages to enable highlighting +// after a user clicks on a result, the linked page should have this script to enable highlighting + +import Mark from "mark.js"; + +// the separateWordSearch of mark options treats each space separated word as a separate search +// I am not letting the user set it, because it should be handled on our side +// if pagefind ever supports exact matches including spaces ('hello world'), then each sequence to be highlighted should be passed as an entry in the pagefind-highlight query param +// so if the search is "'hello world' lorem" then the query param should be "pagefind-highlight=hello%20world&pagefind-highlight=lorem" +// see the tests for more examples +// right now, since that isn't supported, to separateWordSearch should be false + +type pagefindHighlightOptions = { + markContext?: string | HTMLElement | HTMLElement[] | NodeList | null; + pagefindQueryParamName?: string; + markOptions?: Omit; + addStyles?: boolean; +}; + +export default class PagefindHighlight { + pagefindQueryParamName: string; + markContext: string | HTMLElement | HTMLElement[] | NodeList | null; + markOptions: Mark.MarkOptions; + addStyles: boolean; + + constructor( + options: pagefindHighlightOptions = { + markContext: null, + pagefindQueryParamName: "pagefind-highlight", + markOptions: { + className: "pagefind-highlight", + exclude: ["[data-pagefind-ignore]", "[data-pagefind-ignore] *"], + }, + addStyles: true, + } + ) { + const { pagefindQueryParamName, markContext, markOptions, addStyles } = + options; + + this.pagefindQueryParamName = + pagefindQueryParamName ?? "pagefind-highlight"; + this.addStyles = addStyles ?? true; + this.markContext = markContext !== undefined ? markContext : null; + this.markOptions = + markOptions !== undefined + ? markOptions + : { + className: "pagefind-highlight", + exclude: ["[data-pagefind-ignore]", "[data-pagefind-ignore] *"], + }; + + // make sure these are always set (in case the user passes {} or {exclude: '.exclude'} to markOptions) + // if the user doesn't want to exclude anything, they should pass an empty array + // if the user doesn't want a className they should pass an empty string + this.markOptions.className ??= "pagefind__highlight"; + this.markOptions.exclude ??= [ + "[data-pagefind-ignore]", + "[data-pagefind-ignore] *", + ]; + this.markOptions.separateWordSearch = false; + this.highlight(); + } + + getHighlightParams(paramName: string): string[] { + const urlParams = new URLSearchParams(window.location.search); + return urlParams.getAll(paramName); + } + + // Inline styles might be too hard to override + addHighlightStyles(className: string) { + // class name could be "" + if (!className) return; + const styleElement = document.createElement("style"); + styleElement.innerText = `:where(.${className}) { background-color: yellow; color: black; }`; + document.head.appendChild(styleElement); + } + + createMarkInstance() { + if (this.markContext) { + return new Mark(this.markContext); + } + const pagefindBody = document.querySelectorAll("[data-pagefind-body]"); + if (pagefindBody.length !== 0) { + return new Mark(pagefindBody); + } else { + return new Mark(document.body); + } + } + + markText(instance: Mark, text: string[]) { + instance.mark(text, this.markOptions); + } + + highlight() { + const params = this.getHighlightParams(this.pagefindQueryParamName); + if (!params || params.length === 0) return; + this.addStyles && + this.addHighlightStyles(this.markOptions.className as string); + const markInstance = this.createMarkInstance(); + this.markText(markInstance, params); + } +} + +declare global { + interface Window { + PagefindHighlight: typeof PagefindHighlight; + } +} + +window.PagefindHighlight = PagefindHighlight; diff --git a/pagefind_web_js/package-lock.json b/pagefind_web_js/package-lock.json index bb856358..7eb6ff55 100644 --- a/pagefind_web_js/package-lock.json +++ b/pagefind_web_js/package-lock.json @@ -8,6 +8,10 @@ "name": "@pagefind/js", "version": "0.0.0", "license": "MIT", + "dependencies": { + "@types/mark.js": "^8.11.8", + "mark.js": "^8.11.1" + }, "devDependencies": { "ava": "^5.3.1", "esbuild": "^0.19.0", @@ -821,6 +825,27 @@ "node": ">= 8" } }, + "node_modules/@types/jquery": { + "version": "3.5.18", + "resolved": "https://registry.npmjs.org/@types/jquery/-/jquery-3.5.18.tgz", + "integrity": "sha512-sNm7O6LECFhHmF+3KYo6QIl2fIbjlPYa0PDgDQwfOaEJzwpK20Eub9Ke7VKkGsSJ2K0HUR50S266qYzRX4GlSw==", + "dependencies": { + "@types/sizzle": "*" + } + }, + "node_modules/@types/mark.js": { + "version": "8.11.8", + "resolved": "https://registry.npmjs.org/@types/mark.js/-/mark.js-8.11.8.tgz", + "integrity": "sha512-BoWCd9ydi1hZxDfu/lF0v1hHMsNUjuxZEDJsdHlmm6GlKk4qxlLya7D3FS81QmabwFbYPpoDOh9603JESUkHbA==", + "dependencies": { + "@types/jquery": "*" + } + }, + "node_modules/@types/sizzle": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/@types/sizzle/-/sizzle-2.3.3.tgz", + "integrity": "sha512-JYM8x9EGF163bEyhdJBpR2QX1R5naCJHC8ucJylJ3w9/CVBaskdQ8WqBf8MmQrd1kRvp/a4TS8HJ+bxzR7ZJYQ==" + }, "node_modules/acorn": { "version": "8.10.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.10.0.tgz", @@ -1808,6 +1833,11 @@ "node": ">=6" } }, + "node_modules/mark.js": { + "version": "8.11.1", + "resolved": "https://registry.npmjs.org/mark.js/-/mark.js-8.11.1.tgz", + "integrity": "sha512-1I+1qpDt4idfgLQG+BNWmrqku+7/2bi5nLf4YwF8y8zXvmfiTBY3PV3ZibfrjBueCByROpuBjLLFCajqkgYoLQ==" + }, "node_modules/matcher": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/matcher/-/matcher-5.0.0.tgz", diff --git a/pagefind_web_js/package.json b/pagefind_web_js/package.json index 5f2368c9..ae7931d7 100644 --- a/pagefind_web_js/package.json +++ b/pagefind_web_js/package.json @@ -26,5 +26,9 @@ "nodeArguments": [ "--loader=tsx" ] + }, + "dependencies": { + "@types/mark.js": "^8.11.8", + "mark.js": "^8.11.1" } }