Repository: CloudCannon/pagefind (language: python)

Issue:
Excerpts show "__PAGEFIND_ANCHOR__"
I'm experimenting with pagefind 1.1.0, and in the [search page](https://tia.mat.br/pages/search.html) for my blog, all hits contains `__PAGEFIND_ANCHOR__a:8:...`, where `...` is the anchor name.

For instance:
![Screenshot from 2024-08-14 22-35-08](https://github.com/user-attachments/assets/77aff7b9-5223-4198-9435-9b74b9a248db)

The anchors are created like so:

```html
<h1 id="anchor-name">
<a href="#anchor-name" id="anchor-name">Anchor Name</a>
</h1>
```

I'm invoking pagefind with `pagefind --site root-directory`.

On a quick glance, it seems like there's some code to remove this substring in `pagefind/src/fossick/mod.rs`, but it doesn't seem to work in all cases. I'm having issues building from source (`include_bytes!` macro invocation failing to include things in the vendored directory; I'll investigate the root cause later), otherwise I'd look into this. I'm happy, however, to provide any additional information that might be required to reproduce this issue.



Discussion / hints:
Hey @lpereira 👋 

Thanks for the report! That's not great, that token shouldn't be leaking out anywhere 🙈 I'll get this test case into an integration test stat and figure out what's afoot.

> I'm having issues building from source

I should really get onto providing better build scripts — sorry about that! The things that need to vendor before building Pagefind are detailed here: https://github.com/CloudCannon/pagefind/blob/main/CONTRIBUTING.md#building-the-supporting-packages
Update: Didn't have to look far! Somehow these slipped through never being handled when inside metadata. Will fix.

Fix patch:
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 7d0a4efa..becef70d 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -9,7 +9,7 @@ on:
 
 env:
   CARGO_TERM_COLOR: always
-  HUMANE_VERSION: "0.9.1"
+  HUMANE_VERSION: "0.9.0"
   WASM_PACK_VERSION: "v0.10.3"
 
 jobs:
@@ -29,14 +29,14 @@ jobs:
             ~/.rustup
             target
           key: ${{ runner.os }}-stable-min165
-          
+
       - name: Install Rust
         uses: actions-rs/toolchain@v1
         with:
-            toolchain: stable
-            override: true
-            default: true
-            components: rustfmt, clippy
+          toolchain: stable
+          override: true
+          default: true
+          components: rustfmt, clippy
 
       - name: Install wasm-pack
         uses: jetli/wasm-pack-action@v0.4.0
@@ -54,7 +54,7 @@ jobs:
           git checkout -b deploy_branch
       - name: Prepare Crates
         run: |
-          # Update cargo version, 
+          # Update cargo version,
           node ./.backstage/version.cjs
           git add ./pagefind/Cargo.toml
           git add ./pagefind_web/Cargo.toml
@@ -398,19 +398,19 @@ jobs:
         if: matrix.target == 'aarch64-unknown-linux-musl'
         run: |
           sudo apt update -y
-          sudo apt install -y clang gcc-aarch64-linux-gnu         
+          sudo apt install -y clang gcc-aarch64-linux-gnu
           echo "TARGET_CC=clang" >> $GITHUB_ENV
           echo "CFLAGS_aarch64_unknown_linux_musl=--sysroot=/usr/aarch64-linux-gnu" >> $GITHUB_ENV
           echo "CARGO_TARGET_AARCH64_UNKNOWN_LINUX_MUSL_LINKER=/usr/aarch64-linux-gnu/bin/ld" >> $GITHUB_ENV
-      
+
       - name: Install Rust
         uses: actions-rs/toolchain@v1
         with:
-            toolchain: ${{ matrix.rust }}
-            target: ${{ matrix.target }}
-            override: true
-            default: true
-            components: rustfmt, clippy
+          toolchain: ${{ matrix.rust }}
+          target: ${{ matrix.target }}
+          override: true
+          default: true
+          components: rustfmt, clippy
 
       - name: Install wasm-pack
         uses: jetli/wasm-pack-action@v0.4.0
@@ -434,7 +434,7 @@ jobs:
           git checkout -b deploy_branch
       - name: Prepare Crates
         run: |
-          # Update cargo version, 
+          # Update cargo version,
           node ./.backstage/version.cjs
           git add ./pagefind/Cargo.toml
           git add ./pagefind_web/Cargo.toml
@@ -486,7 +486,6 @@ jobs:
         working-directory: ./pagefind
         run: cargo build --release --target ${{ matrix.target }}
 
-
       - name: Create Standard Release
         run: |
           EXEC_NAME="pagefind"
diff --git a/pagefind/features/edge_cases.feature b/pagefind/features/edge_cases.feature
index d0370023..04ebb05e 100644
--- a/pagefind/features/edge_cases.feature
+++ b/pagefind/features/edge_cases.feature
@@ -162,3 +162,68 @@ Feature: Graceful Pagefind Errors
             """
         Then There should be no logs
         Then The selector "[data-url]" should contain "/test/#%D8%A7%D8%B2, /test/#_top, /test/#rtl-content"
+
+    Scenario: Anchors do not leak through metadata
+        Given I have a "public/index.html" file with the content:
+            """
+            <!DOCTYPE html>
+            <html>
+            <body>
+                <p data-title>Nothing</p>
+                <p data-subs>Nothing</p>
+                <p data-meta>Nothing</p>
+                <p data-filter>Nothing</p>
+            </body>
+            </html>
+            """
+        Given I have a "public/test/index.html" file with the content:
+            """
+            <!DOCTYPE html>
+            <html>
+            <body>
+                <h1 id="heading_id">
+                    <a href="#heading_id" id="heading_id">Heading text</a>
+                </h1>
+                <h2 id="second_heading_id">
+                    <a href="#ack" id="ack">Second meta text</a>
+                </h2>
+                <p data-pagefind-meta="extra_meta">
+                    <a href="#meta_id" id="meta_id">Extra meta text</a>
+                </p>
+                <p data-pagefind-filter="extra_filter">
+                    <a href="#filter_id" id="filter_id">Extra filter text</a>
+                </p>
+            </body>
+            </html>
+            """
+        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() {
+                let pagefind = await import("/pagefind/pagefind.js");
+
+                let search = await pagefind.search("text");
+                let results = await Promise.all(search.results.map(r => r.data()));
+                let result = results[0];
+
+                document.querySelector('[data-title]').innerText = result.meta.title;
+
+                let subs = result.sub_results.map(s => s.title).sort().join(', ');
+                document.querySelector('[data-subs]').innerText = subs;
+
+                document.querySelector('[data-meta]').innerText = result.meta.extra_meta;
+
+                let filters = await pagefind.filters();
+
+                document.querySelector('[data-filter]').innerText = Object.keys(filters.extra_filter).join(", ");
+            }
+            """
+        Then There should be no logs
+        Then The selector "[data-title]" should contain "Heading text"
+        Then The selector "[data-subs]" should contain "Heading text, Second meta text"
+        Then The selector "[data-meta]" should contain "Extra meta text"
+        Then The selector "[data-filter]" should contain "Extra filter text"
diff --git a/pagefind/src/fossick/mod.rs b/pagefind/src/fossick/mod.rs
index 4ffddeb2..0c346a0d 100644
--- a/pagefind/src/fossick/mod.rs
+++ b/pagefind/src/fossick/mod.rs
@@ -26,6 +26,7 @@ lazy_static! {
     static ref NEWLINES: Regex = Regex::new("(\n|\r\n)+").unwrap();
     static ref TRIM_NEWLINES: Regex = Regex::new("^[\n\r\\s]+|[\n\r\\s]+$").unwrap();
     static ref EXTRANEOUS_SPACES: Regex = Regex::new("\\s{2,}").unwrap();
+    static ref PRIVATE_PAGEFIND: Regex = Regex::new("___PAGEFIND_[\\S]+\\s?").unwrap();
     // TODO: i18n?
     static ref SPECIAL_CHARS: Regex = Regex::new("[^\\w]").unwrap();
 }
@@ -108,7 +109,9 @@ impl Fossicker {
     }
 
     async fn read_file(&mut self, options: &SearchOptions) -> Result<(), Error> {
-        let Some(file_path) = &self.file_path else { return Ok(()) }; // TODO: Change to thiserror
+        let Some(file_path) = &self.file_path else {
+            return Ok(());
+        }; // TODO: Change to thiserror
         let file = File::open(file_path).await?;
 
         let mut rewriter = DomParser::new(options);
@@ -162,7 +165,9 @@ impl Fossicker {
     }
 
     async fn read_synthetic(&mut self, options: &SearchOptions) -> Result<(), Error> {
-        let Some(contents) = self.synthetic_content.as_ref() else { return Ok(()) };
+        let Some(contents) = self.synthetic_content.as_ref() else {
+            return Ok(());
+        };
 
         let mut rewriter = DomParser::new(options);
 
@@ -419,6 +424,29 @@ impl Fossicker {
         (content, map, anchors, max_word_index + 1)
     }
 
+    /// Removes private Pagefind sentinel values from content that would otherwise leak.
+    /// This should probably be handled better by not inserting these flags here in the first place,
+    /// though there's a chance we do want to process them when we arrive at indexing metadata.
+    fn tidy_meta_and_filters(&mut self) {
+        if let Some(data) = self.data.as_mut() {
+            for filter in data.filters.values_mut() {
+                for filter_val in filter.iter_mut() {
+                    match PRIVATE_PAGEFIND.replace_all(filter_val, "") {
+                        std::borrow::Cow::Borrowed(_) => { /* no-op, no replace happened */ }
+                        std::borrow::Cow::Owned(s) => *filter_val = s,
+                    }
+                }
+            }
+
+            for meta in data.meta.values_mut() {
+                match PRIVATE_PAGEFIND.replace_all(meta, "") {
+                    std::borrow::Cow::Borrowed(_) => { /* no-op, no replace happened */ }
+                    std::borrow::Cow::Owned(s) => *meta = s,
+                }
+            }
+        }
+    }
+
     async fn fossick_html(&mut self, options: &SearchOptions) {
         if self.synthetic_content.is_some() {
             while self.read_synthetic(options).await.is_err() {
@@ -437,6 +465,7 @@ impl Fossicker {
         };
 
         let (content, word_data, anchors, word_count) = self.parse_digest();
+        self.tidy_meta_and_filters();
 
         let data = self.data.unwrap();
         let url = if let Some(url) = &self.page_url {
@@ -896,14 +925,14 @@ mod tests {
             let full_zh_input = "___PAGEFIND_AUTO_WEIGHT___7 擁有遠端帳號權限 ___END_PAGEFIND_WEIGHT___
 
         我們建議大多數具有遠端帳號權限的使用者，採用 ___PAGEFIND_ANCHOR___a:0:my-link Certbot 這個 ACME 客戶端。它可以自動執行憑證的頒發、安裝，甚至不需要停止你的伺服器；Certbot 也提供專家模式，給不想要自動設定的使用者。Certbot 操作簡單，適用於許多系統；並且具有完善的文檔。參考 Certbot 官網，以獲取對於不同系統和網頁伺服器的操作說明。
-        
+
         如果 Certbot 不能滿足你的需求，或是你想嘗試別的客戶端，還有很多 ACME 用戶端可供選擇。在你選定 ACME 客戶端軟體後，請參閱該客戶端的文檔。
         ___PAGEFIND_WEIGHT___44
         如果你正在嘗試使用不同的 ACME 用戶端，請使用我們的測試環境以免超過憑證頒發與更新的速率限制。
         沒有遠端帳號權限
-        
+
         在沒有遠端帳號權限的情況下，最好的辦法是使用服務業者所提供的現有支援。如果你的業者支援 ___PAGEFIND_ANCHOR___a:1:my-second-link Let’s Encrypt，那麼他們就能幫助你申請免費憑證；安裝並設定自動更新。某些業者會需要你在控制介面或聯繫客服以開啟 Let’s Encrypt 服務。也有些業者會為所有客戶自動設定並安裝憑證。
-        
+
         查看支援 Let’s Encrypt 的業者列表，確認你提供商的是否有出現在列表上。如果有的話，請按照他們的文檔設定 Let’s Encrypt 憑證。 ___END_PAGEFIND_WEIGHT___";
 
             let (legitimate_zh_output, chunked_zh_output) =
@@ -915,14 +944,14 @@ mod tests {
             let full_zh_cn_input = "没有命令行访问权限
 
         在没有命令行访问权限的情况下，___PAGEFIND_AUTO_WEIGHT___7 最好的办法是使用您托管服务提供商提供的内置功能。 支持 Let’s Encrypt 的服务商能替您自动完成免费证书的申请、安装、续期步骤。 某些服务商可能需要您在控制面板中开启相关选项， 也有一些服务商会自动为所有客户申请并安装证书。
-        
+
         如果您的服务商存在于我们的服务商列表中， 参照其文档设置 Let’s Encrypt ___END_PAGEFIND_WEIGHT___ 证书即可。
-        
+
         如果您的托管服务提供商不支持 ___PAGEFIND_ANCHOR___a:0:my-link Let’s Encrypt，您可以与他们联系请求支持。 我们尽力使添加 Let’s Encrypt 支持变得非常容易，提供商（注：非中国国内提供商）通常很乐意听取客户的建议！
-        
+
         如果您的托管服务提供商不想集成 Let’s Encrypt，但支持上传自定义证书，您可以在自己的计算机上安装 Certbot 并使用手动模式（Manual Mode）。 在手动模式下，您需要将指定文件上传到您的网站以证明您的控制权。 然后，Certbot 将获取您可以上传到提供商的证书。 我们不建议使用此选项，因为它非常耗时，并且您需要在证书过期时重复此步骤。 对于大多数人来说，最好从提供商处请求 Let’s Encrypt 支持。若您的提供商不打算兼容，建议您更换提供商。
         获取帮助
-        
+
         如果您对选择 ACME 客户端，使用特定客户端或与 Let’s Encrypt 相关的任何其他内容有疑问，请前往我们的社区论坛获取帮助。";
 
             let (legitimate_zh_cn_output, chunked_zh_cn_output) =
@@ -934,14 +963,14 @@ mod tests {
             let full_ja_input = "___PAGEFIND_AUTO_WEIGHT___7 シェルへのアクセス権を持っている場合
 
             シェルアクセスができるほとんどの人には、Certbot という ACME クライアントを使うのがおすすめです。 ___END_PAGEFIND_WEIGHT___ 証明書の発行とインストールを、ダウンタイムゼロで自動化できます。 自動設定を使いたくない人のために、エキスパートモードも用意されています。 とても簡単に使え、多数のオペレーティングシステムで動作し、たくさんのドキュメントもあります。 Certbot のウェブサイトでは、各オペレーティングシステムやウェブサーバーごとの個別の設定方法について解説されています。
-            
+
             Certbot があなたの要件を満たさない場合や、他のクライアントを試してみたい場合には、Certbot の他にもたくさんの ACME クライアントが利用できます。 ACME クライアントを自分で選んだ場合は、そのクライアントのドキュメントを参照してください。
-            
+
             別の ACME クライアントを使って実験を行う場合は、 ___PAGEFIND_ANCHOR___a:0:my-link 私たちが用意したステージング環境を利用して、レート・リミットの制限を受けないように気をつけてください。
             シェルへのアクセス権を持っていない場合
-            
+
             シェルアクセスができない場合に Let’s Encrypt を利用する一番良い方法は、ホスティング・プロバイダが用意したサポートを利用することです。 もし、あなたが利用するホスティング・プロバイダが Let’s Encrypt をサポートしている場合、あなたの代わりに無料の証明書をリクエスト、インストールし、自動的に最新の状態に更新してくれます。 一部のホスティング・プロバイダでは、この機能は自分で設定から有効にする必要がある場合があります。 それ以外のプロバイダでは、すべてのユーザーのために、自動で証明書が発行・インストールされるようになっています。
-            
+
             あなたが利用しているホスティング・プロバイダが Let’s Encrypt をサポートしているかどうかは、 ホスティング・プロバイダのリストで確認してください。 もしサポートされている場合は、ホスティング・プロバイダのドキュメンに書かれている Let’s Encrypt の設定方法に従ってください。";
 
             let (legitimate_ja_output, chunked_ja_output) =


Test patch:
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index de1eb313..8e38a39b 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -8,7 +8,7 @@ on:
 
 env:
   CARGO_TERM_COLOR: always
-  HUMANE_VERSION: "0.9.1"
+  HUMANE_VERSION: "0.9.0"
   WASM_PACK_VERSION: "v0.10.3"
 
 jobs:
@@ -59,11 +59,11 @@ jobs:
       - name: Install Rust
         uses: actions-rs/toolchain@v1
         with:
-            toolchain: ${{ matrix.rust }}
-            target: ${{ matrix.target }}
-            override: true
-            default: true
-            components: rustfmt, clippy
+          toolchain: ${{ matrix.rust }}
+          target: ${{ matrix.target }}
+          override: true
+          default: true
+          components: rustfmt, clippy
 
       - name: Check versions
         run: |
diff --git a/pagefind/test.sh b/pagefind/test.sh
index 23fbf7e8..595ec0b5 100755
--- a/pagefind/test.sh
+++ b/pagefind/test.sh
@@ -2,7 +2,7 @@
 
 cargo build --release --features extended
 if [ -z "$1" ]; then
-    TEST_BINARY=../target/release/pagefind npx -y humane@latest
+    TEST_BINARY=../target/release/pagefind npx -y humane@0.9.0
 else
-    TEST_BINARY=../target/release/pagefind npx -y humane@latest --name "$1"
+    TEST_BINARY=../target/release/pagefind npx -y humane@0.9.0 --name "$1"
 fi