Skip to content

Commit 5b9a368

Browse files
authored
Merge pull request #80 from feel-co/notashelf/push-zymtmkqlsntk
ndg: filter out included files in search index
2 parents 873d4bf + dd6b50d commit 5b9a368

5 files changed

Lines changed: 228 additions & 59 deletions

File tree

Cargo.lock

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ clap = { version = "4.5.53", default-features = false, features = [
2626
clap_complete = "4.5.61"
2727
clap_mangen = "0.2.31"
2828
color-eyre = "0.6.5"
29-
comrak = { version = "0.48.0", default-features = false, features = [ "syntect" ] }
29+
comrak = { version = "0.49.0", default-features = false, features = ["syntect"] }
3030
env_logger = "0.11.8"
3131
fs_extra = "1.3.0"
3232
grass = { version = "0.13.4", default-features = false }

ndg/src/html/search.rs

Lines changed: 23 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,12 @@
11
use std::{
22
collections::{HashMap, HashSet},
33
fs,
4-
path::{Path, PathBuf},
4+
path::PathBuf,
55
sync::OnceLock,
66
};
77

88
use color_eyre::eyre::{Context, Result};
99
use log::info;
10-
use ndg_commonmark::utils::slugify;
1110
use rayon::prelude::*;
1211
use regex::Regex;
1312
use serde::{Deserialize, Serialize};
@@ -68,7 +67,12 @@ fn tokenize(text: &str) -> Vec<String> {
6867
tokens.into_iter().collect()
6968
}
7069

71-
/// Generate search index from markdown files
70+
/// Generate search index from standalone markdown files.
71+
///
72+
/// This function only processes markdown files that will be rendered as
73+
/// standalone HTML pages. Files that are included in other documents via
74+
/// `{=include=}` directives should not be passed to this function - their
75+
/// content is already indexed as part of the parent document.
7276
///
7377
/// # Errors
7478
///
@@ -95,7 +99,7 @@ pub fn generate_search_index(
9599
let mut search_index = SearchIndex::new();
96100
let mut doc_id = 0;
97101

98-
// Process markdown files in parallel if available and input_dir is provided
102+
// Process standalone markdown files in parallel
99103
if !markdown_files.is_empty()
100104
&& let Some(ref input_dir) = config.input_dir
101105
{
@@ -109,16 +113,17 @@ pub fn generate_search_index(
109113
)
110114
})?;
111115

112-
let (title, id) = extract_title_and_id(&content).unwrap_or_else(|| {
113-
(
114-
file_path
115-
.file_stem()
116-
.unwrap_or_default()
117-
.to_string_lossy()
118-
.to_string(),
119-
None,
120-
)
121-
});
116+
let (title, _id) =
117+
extract_title_and_id(&content).unwrap_or_else(|| {
118+
(
119+
file_path
120+
.file_stem()
121+
.unwrap_or_default()
122+
.to_string_lossy()
123+
.to_string(),
124+
None,
125+
)
126+
});
122127

123128
let plain_text = crate::utils::html::content_to_plaintext(&content);
124129

@@ -130,30 +135,10 @@ pub fn generate_search_index(
130135
)
131136
})?;
132137

133-
let mut output_path = config.included_files.get(rel_path).map_or_else(
134-
|| rel_path.to_owned(),
135-
|mut includer| {
136-
// find the root document that transitively includes this file
137-
// NOTE: it'll be more efficient to resolve this in
138-
// collect_included_files() but with the way it works
139-
// right now, it's not easy to
140-
while let Some(parent) = config.included_files.get(includer) {
141-
includer = parent;
142-
}
143-
includer.to_owned()
144-
},
145-
);
146-
output_path.set_extension("html");
147-
148-
let path = if config.included_files.contains_key(rel_path) {
149-
format!(
150-
"{}#{}",
151-
output_path.to_string_lossy(),
152-
id.unwrap_or_else(|| slugify(&title))
153-
)
154-
} else {
155-
output_path.to_string_lossy().to_string()
156-
};
138+
// Convert markdown path to HTML path
139+
let mut html_path = rel_path.to_path_buf();
140+
html_path.set_extension("html");
141+
let path = html_path.to_string_lossy().to_string();
157142

158143
let tokens = tokenize(&plain_text);
159144
let title_tokens = tokenize(&title);

ndg/src/main.rs

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
use std::fs;
1+
use std::{fs, path::PathBuf};
22

33
use color_eyre::eyre::{Context, Result};
44
use log::{LevelFilter, info};
@@ -156,7 +156,26 @@ fn generate_documentation(config: &mut Config) -> Result<()> {
156156
// Generate search index if enabled, regardless of whether there are markdown
157157
// files
158158
if config.generate_search {
159-
html::search::generate_search_index(config, &markdown_files)?;
159+
// Filter out included files - they should not appear as standalone entries
160+
// in search results. Their content is indexed as part of the parent
161+
// document.
162+
let searchable_files: Vec<PathBuf> =
163+
if let Some(ref input_dir) = config.input_dir {
164+
markdown_files
165+
.iter()
166+
.filter(|file| {
167+
file
168+
.strip_prefix(input_dir)
169+
.ok()
170+
.is_none_or(|rel| !config.included_files.contains_key(rel))
171+
})
172+
.cloned()
173+
.collect()
174+
} else {
175+
markdown_files
176+
};
177+
178+
html::search::generate_search_index(config, &searchable_files)?;
160179
}
161180

162181
// Copy assets

ndg/tests/search_path_resolution.rs

Lines changed: 181 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -184,8 +184,21 @@ This file should be transitively included in `main.html`
184184
config.included_files = collect_included_files(&config, processor.as_ref())
185185
.expect("Failed to collect include files");
186186

187-
let markdown_files = collect_markdown_files(&input_dir);
188-
generate_search_index(&config, &markdown_files)
187+
let all_markdown_files = collect_markdown_files(&input_dir);
188+
189+
// Filter out included files - only standalone files should be in search index
190+
let searchable_files: Vec<_> = all_markdown_files
191+
.iter()
192+
.filter(|file| {
193+
file
194+
.strip_prefix(&input_dir)
195+
.ok()
196+
.map_or(true, |rel| !config.included_files.contains_key(rel))
197+
})
198+
.cloned()
199+
.collect();
200+
201+
generate_search_index(&config, &searchable_files)
189202
.expect("Failed to generate search index");
190203

191204
// Verify that search data is generated correctly
@@ -194,27 +207,179 @@ This file should be transitively included in `main.html`
194207
.expect("Failed to open search-data.json");
195208
let search_data: Vec<SearchDocument> =
196209
serde_json::from_reader(index_file).expect("Failed to read index data");
197-
let included_doc = search_data
210+
211+
// Only the main file should appear in search index
212+
// The included files' content is already in main.html, so they're searchable
213+
// through the main document
214+
let main_doc = search_data
198215
.iter()
199-
.find(|doc| doc.title == "Included file")
200-
.expect("included file not found in search-data.json");
216+
.find(|doc| doc.title == "Main file")
217+
.expect("main file not found in search-data.json");
218+
219+
assert_eq!(main_doc.path, "main.html");
220+
221+
// Included files should NOT have separate search entries
222+
assert!(
223+
search_data.iter().all(|doc| doc.title != "Included file"),
224+
"Included files should not have separate search entries"
225+
);
226+
assert!(
227+
search_data
228+
.iter()
229+
.all(|doc| doc.title != "Section without an anchor ID"),
230+
"Included files should not have separate search entries"
231+
);
232+
assert!(
233+
search_data
234+
.iter()
235+
.all(|doc| doc.title != "Transitively included file"),
236+
"Included files should not have separate search entries"
237+
);
238+
}
239+
240+
#[test]
241+
fn test_nested_directory_include_search_paths() {
242+
// This test replicates a real-world scenario where:
243+
// - index.md includes installation/modules.md
244+
// - installation/modules.md includes installation/modules/nixos.md
245+
// The search index should NOT create entries for included files as
246+
// standalone pages (e.g., installation/modules/nixos.html), but should
247+
// index their content under the root document with anchors.
248+
249+
let temp_dir = TempDir::new().expect("Failed to create temp dir");
250+
let input_dir = temp_dir.path().join("input");
251+
let installation_dir = input_dir.join("installation");
252+
let modules_dir = installation_dir.join("modules");
253+
let output_dir = temp_dir.path().join("output");
254+
255+
create_dir_all(&modules_dir).expect("failed to create modules dir");
256+
create_dir_all(&output_dir).expect("failed to create output dir");
257+
258+
// Root document that includes a file from a subdirectory
259+
let index_content = "# Documentation Index
260+
261+
Welcome to the documentation.
262+
263+
```{=include=}
264+
installation/modules.md
265+
```
266+
";
267+
fs::write(input_dir.join("index.md"), index_content)
268+
.expect("Failed to write index.md");
269+
270+
// Intermediate file that includes deeper nested files
271+
let modules_content = "# Module Installation {#ch-module-installation}
272+
273+
The below chapters describe module installation.
274+
275+
```{=include=}
276+
modules/nixos.md
277+
modules/home-manager.md
278+
```
279+
";
280+
fs::write(installation_dir.join("modules.md"), modules_content)
281+
.expect("Failed to write installation/modules.md");
282+
283+
// Deeply nested included files
284+
let nixos_content = "# NixOS Module {#ch-nixos-module}
285+
286+
This describes the NixOS module installation.
287+
";
288+
fs::write(modules_dir.join("nixos.md"), nixos_content)
289+
.expect("Failed to write installation/modules/nixos.md");
290+
291+
let hm_content = "# Home Manager Module {#ch-home-manager-module}
201292
202-
assert_eq!(included_doc.path, "main.html#included-file-heading");
293+
This describes the Home Manager module installation.
294+
";
295+
fs::write(modules_dir.join("home-manager.md"), hm_content)
296+
.expect("Failed to write installation/modules/home-manager.md");
297+
298+
let mut config = Config {
299+
input_dir: Some(input_dir.clone()),
300+
output_dir: output_dir.clone(),
301+
module_options: None,
302+
title: "Test Documentation".to_string(),
303+
generate_search: true,
304+
..Default::default()
305+
};
203306

204-
let no_id_doc = search_data
307+
let processor = Some(create_processor(&config, None));
308+
config.included_files = collect_included_files(&config, processor.as_ref())
309+
.expect("Failed to collect include files");
310+
311+
let all_markdown_files = collect_markdown_files(&input_dir);
312+
313+
// Process markdown files to generate HTML
314+
ndg::utils::process_markdown_files(&config, processor.as_ref())
315+
.expect("Failed to process markdown files");
316+
317+
// Filter out included files - only standalone files should be in search index
318+
let searchable_files: Vec<_> = all_markdown_files
205319
.iter()
206-
.find(|doc| doc.title == "Section without an anchor ID")
207-
.expect("section_no_id file not found in search-data.json");
320+
.filter(|file| {
321+
file
322+
.strip_prefix(&input_dir)
323+
.ok()
324+
.map_or(true, |rel| !config.included_files.contains_key(rel))
325+
})
326+
.cloned()
327+
.collect();
328+
329+
// Generate search index with only standalone files
330+
generate_search_index(&config, &searchable_files)
331+
.expect("Failed to generate search index");
208332

209-
assert_eq!(no_id_doc.path, "main.html#section-without-an-anchor-id");
333+
// Verify that search data is generated correctly
334+
let index_file =
335+
File::open(output_dir.join("assets").join("search-data.json"))
336+
.expect("Failed to open search-data.json");
337+
let search_data: Vec<SearchDocument> =
338+
serde_json::from_reader(index_file).expect("Failed to read index data");
210339

211-
let transitive_inc_doc = search_data
340+
// The index document should be in search results
341+
let index_doc = search_data
212342
.iter()
213-
.find(|doc| doc.title == "Transitively included file")
214-
.expect("transitively included file not found in search-data.json");
343+
.find(|doc| doc.title == "Documentation Index");
344+
assert!(
345+
index_doc.is_some(),
346+
"Index document should be in search results"
347+
);
348+
assert_eq!(index_doc.unwrap().path, "index.html");
349+
350+
// Included files should NOT appear as separate search entries
351+
// Their content is already in index.html
352+
assert!(
353+
search_data
354+
.iter()
355+
.all(|doc| doc.title != "Module Installation"),
356+
"Included files should not have separate search entries"
357+
);
358+
assert!(
359+
search_data.iter().all(|doc| doc.title != "NixOS Module"),
360+
"Included files should not have separate search entries"
361+
);
362+
assert!(
363+
search_data
364+
.iter()
365+
.all(|doc| doc.title != "Home Manager Module"),
366+
"Included files should not have separate search entries"
367+
);
215368

216-
assert_eq!(
217-
transitive_inc_doc.path,
218-
"main.html#transitively-included-file"
369+
// Verify that the included files are NOT created as standalone HTML files
370+
assert!(
371+
!output_dir.join("installation/modules.html").exists(),
372+
"installation/modules.html should not be created (file is included)"
373+
);
374+
assert!(
375+
!output_dir.join("installation/modules/nixos.html").exists(),
376+
"installation/modules/nixos.html should not be created (file is included)"
377+
);
378+
assert!(
379+
!output_dir
380+
.join("installation/modules/home-manager.html")
381+
.exists(),
382+
"installation/modules/home-manager.html should not be created (file is \
383+
included)"
219384
);
220385
}

0 commit comments

Comments
 (0)