diff --git a/CHANGELOG.md b/CHANGELOG.md index 8fd284a..f44c271 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,13 @@ ## main +- [UPDATE] `serve` コマンドで増分ビルド機能を追加 + - ファイル変更時に全ファイルを再ビルドするのではなく、変更があったファイルのみを再ビルドするように改善 + - Markdown ファイル変更時はそのファイルのみ再生成 + - 静的ファイル変更時はそのファイルのみコピー + - テンプレート変更時はそのテンプレートを使用する Markdown ファイルのみ再生成 + - 設定ファイル (vss.toml) 変更時のみフルビルドを実行 + - ファイル削除時は対応する出力ファイルを削除 - [FIX] `serve` コマンドで `ls` 実行時にビルドが誤って走る問題を修正 - `notify-debouncer-mini` から `notify-debouncer-full` に移行し、`EventKind::Access` を除外するようにした - [#46](https://github.com/veltiosoft/vss/pull/46) diff --git a/Cargo.lock b/Cargo.lock index 5fb136d..c687ced 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2206,7 +2206,7 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] name = "vss" -version = "0.18.0" +version = "0.19.0" dependencies = [ "anyhow", "axum", diff --git a/src/subcommand_build.rs b/src/subcommand_build.rs index e0aab30..55b5eb7 100644 --- a/src/subcommand_build.rs +++ b/src/subcommand_build.rs @@ -9,49 +9,64 @@ use std::{ time::Instant, }; +/// 変更されたファイルの種別 +#[derive(Debug, Clone)] +pub enum ChangedFile { + /// Markdown ファイルの変更 + Markdown(PathBuf), + /// 静的ファイルの変更 + Static(PathBuf), + /// テンプレートファイルの変更 + Template(PathBuf), + /// 設定ファイルの変更 + Config, + /// ファイル削除 + Deleted(PathBuf), +} + /// vss.toml の設定構造 #[derive(Debug, Deserialize)] -struct Config { +pub(crate) struct Config { #[serde(default = "default_site_title")] - site_title: String, + pub(crate) site_title: String, #[serde(default)] - site_description: String, + pub(crate) site_description: String, #[serde(default)] - base_url: String, + pub(crate) base_url: String, #[serde(default = "default_dist")] - dist: String, + pub(crate) dist: String, #[serde(default = "default_static")] - r#static: String, + pub(crate) r#static: String, #[serde(default = "default_layouts")] - layouts: String, + pub(crate) layouts: String, #[serde(default)] - build: BuildConfig, + pub(crate) build: BuildConfig, } #[derive(Debug, Deserialize, Default)] -struct BuildConfig { +pub(crate) struct BuildConfig { #[serde(default)] - ignore_files: Vec, + pub(crate) ignore_files: Vec, #[serde(default)] - markdown: MarkdownConfig, + pub(crate) markdown: MarkdownConfig, #[serde(default)] - tags: TagsConfig, + pub(crate) tags: TagsConfig, } #[derive(Debug, Deserialize, Default)] -struct MarkdownConfig { +pub(crate) struct MarkdownConfig { #[serde(default)] - allow_dangerous_html: bool, + pub(crate) allow_dangerous_html: bool, } #[derive(Debug, Deserialize)] -struct TagsConfig { +pub(crate) struct TagsConfig { #[serde(default = "default_tags_enable")] - enable: bool, + pub(crate) enable: bool, #[serde(default = "default_tags_template")] - template: String, + pub(crate) template: String, #[serde(default = "default_tags_url_pattern")] - url_pattern: String, + pub(crate) url_pattern: String, } impl Default for TagsConfig { @@ -92,6 +107,27 @@ fn default_layouts() -> String { "layouts".to_string() } +/// タグ名が安全かどうかを検証する +/// パストラバーサル攻撃を防ぐため、危険な文字列を含むタグ名を拒否する +fn is_safe_tag_name(tag: &str) -> bool { + // 空のタグ名は不正 + if tag.is_empty() { + return false; + } + + // パストラバーサルシーケンスを含む場合は不正 + if tag.contains("..") || tag.contains('/') || tag.contains('\\') { + return false; + } + + // null バイトを含む場合は不正 + if tag.contains('\0') { + return false; + } + + true +} + /// YAML frontmatter の構造 #[derive(Debug, Deserialize, Default)] struct FrontMatter { @@ -134,7 +170,7 @@ struct Tag { /// タグページ生成用の投稿メタデータ #[derive(Content, Clone)] -struct PostMetadata { +pub(crate) struct PostMetadata { title: String, description: String, author: String, @@ -154,7 +190,7 @@ struct TagPageContext { } /// 設定ファイルを読み込む -fn load_config(path: &Path) -> Result { +pub(crate) fn load_config(path: &Path) -> Result { let content = fs::read_to_string(path) .with_context(|| format!("Failed to read config file: {}", path.display()))?; let config: Config = toml::from_str(&content) @@ -189,7 +225,9 @@ fn markdown_to_html(markdown: &str, allow_dangerous_html: bool) -> Result Result>> { +pub(crate) fn load_templates( + layouts_dir: &str, +) -> Result>> { let mut templates = HashMap::new(); let pattern = format!("{}/**/*.html", layouts_dir); @@ -371,7 +409,7 @@ pub fn run_build(config_path: &Path) -> Result<()> { } /// 個別の Markdown ファイルを処理する -fn process_markdown_file( +pub(crate) fn process_markdown_file( md_path: &Path, config: &Config, templates: &HashMap>, @@ -494,6 +532,15 @@ fn generate_tag_pages( // 各タグのページを生成 for (tag_name, posts) in tag_to_posts { + // タグ名の安全性を検証(パストラバーサル攻撃を防ぐ) + if !is_safe_tag_name(&tag_name) { + eprintln!( + "[WARN] Skipping unsafe tag name: {:?} (contains path traversal characters)", + tag_name + ); + continue; + } + let context = TagPageContext { site_title: config.site_title.clone(), site_description: config.site_description.clone(), @@ -563,3 +610,304 @@ fn find_files_with_glob(extension: &str) -> Result, glob::PatternEr } Ok(files) } + +/// 単一の静的ファイルをコピーする +pub(crate) fn copy_single_static_file( + src_path: &Path, + static_dir: &str, + dist_dir: &str, +) -> Result<()> { + // static/ からの相対パスを取得 + if let Ok(rel_path) = src_path.strip_prefix(static_dir) { + let dest_path = Path::new(dist_dir).join(rel_path); + + // 親ディレクトリを作成 + if let Some(parent) = dest_path.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("Failed to create directory: {}", parent.display()))?; + } + + // ファイルをコピー + fs::copy(src_path, &dest_path).with_context(|| { + format!( + "Failed to copy file from {} to {}", + src_path.display(), + dest_path.display() + ) + })?; + + println!("Copied: {}", dest_path.display()); + } + + Ok(()) +} + +/// 出力ファイルを削除する(ソースファイル削除時に呼び出す) +pub(crate) fn delete_output_file( + src_path: &Path, + src_base_dir: &str, + dist_dir: &str, + convert_extension: Option<&str>, +) -> Result<()> { + // ソースファイルからの相対パスを取得 + if let Ok(rel_path) = src_path.strip_prefix(src_base_dir) { + let mut dest_path = Path::new(dist_dir).join(rel_path); + + // 拡張子変換が必要な場合(.md → .html) + if let Some(ext) = convert_extension { + dest_path.set_extension(ext); + } + + // ファイルが存在する場合は削除 + if dest_path.exists() { + fs::remove_file(&dest_path).with_context(|| { + format!("Failed to delete output file: {}", dest_path.display()) + })?; + println!("Deleted: {}", dest_path.display()); + } + } + + Ok(()) +} + +/// テンプレートを使用する Markdown ファイルを検索 +/// +/// テンプレートの検索優先順位: +/// 1. 完全一致: layouts/{md_path}.html +/// 2. ディレクトリデフォルト: layouts/{dir}/default.html +/// 3. ルートデフォルト: layouts/default.html +/// +/// テンプレートも一緒に返すことで、呼び出し側での再読み込みを避ける +pub(crate) fn find_markdown_files_using_template( + template_path: &Path, + layouts_dir: &str, + ignore_files: &[String], +) -> Result<(Vec, HashMap>)> { + let mut affected_files = Vec::new(); + + // テンプレートを読み込む + let templates = load_templates(layouts_dir)?; + + // テンプレートの相対パス(layouts/ からの相対) + let template_rel_path = template_path + .strip_prefix(layouts_dir) + .ok() + .map(|p| p.to_string_lossy().to_string()); + + let Some(template_key) = template_rel_path else { + return Ok((affected_files, templates)); + }; + + // すべての Markdown ファイルを取得 + let md_files = find_files_with_glob("md").context("Failed to find markdown files")?; + + // ignore_files でフィルタリング + let md_files: Vec = md_files + .into_iter() + .filter(|path| { + let path_str = path.to_string_lossy(); + !ignore_files.iter().any(|ignore| path_str.contains(ignore)) + }) + .collect(); + + for md_path in md_files { + let html_path = md_path.with_extension("html"); + let html_path_str = html_path.to_string_lossy().to_string(); + + // このファイルが使用するテンプレートを特定 + let used_template_key = determine_template_key(&templates, &html_path_str); + + // 変更されたテンプレートを使用しているか確認 + if let Some(key) = used_template_key + && key == template_key + { + affected_files.push(md_path); + } + } + + Ok((affected_files, templates)) +} + +/// Markdown ファイルが使用するテンプレートキーを決定する +fn determine_template_key( + templates: &HashMap>, + html_path: &str, +) -> Option { + // 1. 完全一致 + if templates.contains_key(html_path) { + return Some(html_path.to_string()); + } + + // 2. ディレクトリ内の default.html + if let Some(dir) = Path::new(html_path).parent() { + let dir_default = format!("{}/default.html", dir.display()); + if templates.contains_key(&dir_default) { + return Some(dir_default); + } + } + + // 3. ルートの default.html + if templates.contains_key("default.html") { + return Some("default.html".to_string()); + } + + None +} + +/// 絶対パスを相対パスに変換する +fn to_relative_path(path: &Path) -> PathBuf { + if path.is_absolute() + && let Ok(current_dir) = std::env::current_dir() + && let Ok(rel) = path.strip_prefix(¤t_dir) + { + return rel.to_path_buf(); + } + path.to_path_buf() +} + +/// 増分ビルドのエントリポイント +/// 変更されたファイルの種別に応じて最小限の再ビルドを行う +pub fn run_incremental_build(config_path: &Path, changed_files: &[ChangedFile]) -> Result<()> { + // 設定ファイルを読み込む + let config = load_config(config_path)?; + + // テンプレートを読み込む + let templates = load_templates(&config.layouts)?; + + // 投稿メタデータを収集(タグページ再生成用) + let mut all_posts: Vec = Vec::new(); + let mut need_regenerate_tags = false; + + // カレントディレクトリを取得(パス正規化用) + let current_dir = std::env::current_dir().context("Failed to get current directory")?; + + for changed_file in changed_files { + match changed_file { + ChangedFile::Markdown(path) => { + // 絶対パスを相対パスに変換 + let rel_path = to_relative_path(path); + + // Markdown ファイルが ignore_files に含まれているかチェック + let path_str = rel_path.to_string_lossy(); + if config + .build + .ignore_files + .iter() + .any(|ignore| path_str.contains(ignore)) + { + continue; + } + + // 単一の Markdown ファイルを処理(相対パスを使用) + if let Ok(Some(metadata)) = process_markdown_file(&rel_path, &config, &templates) { + all_posts.push(metadata); + } + need_regenerate_tags = true; + } + ChangedFile::Static(path) => { + // 単一の静的ファイルをコピー + copy_single_static_file(path, &config.r#static, &config.dist)?; + } + ChangedFile::Template(path) => { + // テンプレート変更時は、そのテンプレートを使用する全 Markdown を再ビルド + // テンプレートも一緒に取得して再読み込みを避ける + let (affected_md_files, fresh_templates) = find_markdown_files_using_template( + path, + &config.layouts, + &config.build.ignore_files, + )?; + + for md_path in affected_md_files { + if let Ok(Some(metadata)) = + process_markdown_file(&md_path, &config, &fresh_templates) + { + all_posts.push(metadata); + } + } + need_regenerate_tags = true; + } + ChangedFile::Config => { + // 設定ファイル変更時はフルビルド + // この場合は run_build() を呼び出すべきなので、 + // 呼び出し側で処理する + return Err(anyhow::anyhow!("Config changed, full rebuild required")); + } + ChangedFile::Deleted(path) => { + // ファイル削除時は対応する出力ファイルを削除 + let path_str = path.to_string_lossy(); + + if path_str.ends_with(".md") { + // Markdown ファイルの削除 + // 絶対パスを相対パスに変換してから処理 + let rel_path = to_relative_path(path); + let html_path = rel_path.with_extension("html"); + let output_path = Path::new(&config.dist).join(&html_path); + if output_path.exists() { + fs::remove_file(&output_path).with_context(|| { + format!("Failed to delete output file: {}", output_path.display()) + })?; + println!("Deleted: {}", output_path.display()); + } + need_regenerate_tags = true; + } else if path.starts_with(current_dir.join(&config.r#static)) { + // 静的ファイルの削除 + delete_output_file( + path, + ¤t_dir.join(&config.r#static).to_string_lossy(), + &config.dist, + None, + )?; + } else if path.starts_with(current_dir.join(&config.layouts)) { + // テンプレートが削除された場合はフルビルドを要求 + return Err(anyhow::anyhow!( + "Template file deleted, full rebuild required" + )); + } + } + } + } + + // タグページを再生成(Markdown の変更があった場合) + if need_regenerate_tags && config.build.tags.enable { + // 全 Markdown ファイルからメタデータを再収集してタグページを生成 + let md_files = find_files_with_glob("md").context("Failed to find markdown files")?; + let md_files: Vec = md_files + .into_iter() + .filter(|path| { + let path_str = path.to_string_lossy(); + !config + .build + .ignore_files + .iter() + .any(|ignore| path_str.contains(ignore)) + }) + .collect(); + + let mut tag_posts: Vec = Vec::new(); + for md_path in &md_files { + // frontmatter を読み取ってメタデータを収集 + if let Ok(content) = fs::read_to_string(md_path) + && let Ok((frontmatter, _)) = parse_frontmatter(&content) + && let Some(tags_vec) = &frontmatter.tags + && !tags_vec.is_empty() + { + let html_path = md_path.with_extension("html"); + let url = format!("/{}", html_path.to_string_lossy()); + tag_posts.push(PostMetadata { + title: frontmatter.title, + description: frontmatter.description, + author: frontmatter.author, + pub_datetime: frontmatter.pub_datetime, + url, + tags: Some(tags_vec.clone()), + }); + } + } + + if !tag_posts.is_empty() { + generate_tag_pages(tag_posts, &config, &templates)?; + } + } + + Ok(()) +} diff --git a/src/subcommand_serve.rs b/src/subcommand_serve.rs index 40fb286..2e9c681 100644 --- a/src/subcommand_serve.rs +++ b/src/subcommand_serve.rs @@ -16,7 +16,7 @@ use std::{ }; use tower_http::services::ServeDir; -use crate::subcommand_build; +use crate::subcommand_build::{self, ChangedFile}; /// serve コマンドのエントリポイント pub fn run(mut args: noargs::RawArgs) -> noargs::Result<()> { @@ -123,8 +123,12 @@ async fn html_fallback_middleware(mut request: Request, next: Next) -> Response fn watch_files(config_path: &Path, _rebuild_flag: Arc>) -> Result<()> { let config_path_clone = config_path.to_path_buf(); - // dist ディレクトリのパスを取得(絶対パスに変換) - let dist_dir = get_dist_dir(config_path)?; + // 設定を取得(subcommand_build の load_config を直接使用) + let config = subcommand_build::load_config(config_path)?; + let dist_dir = config.dist.clone(); + let static_dir = config.r#static.clone(); + let layouts_dir = config.layouts.clone(); + let current_dir = std::env::current_dir().context("Failed to get current directory")?; let dist_path = current_dir.join(&dist_dir); @@ -134,21 +138,82 @@ fn watch_files(config_path: &Path, _rebuild_flag: Arc>) -> Result<() None, move |res: DebounceEventResult| match res { Ok(events) => { - let should_rebuild = events.iter().any(|event| { + // dist ディレクトリ外の変更されたファイルを収集 + let mut changed_files: Vec = Vec::new(); + let mut has_deletion = false; + + for event in &events { // Access イベント(ls による atime 更新など)は無視 if matches!(event.kind, EventKind::Access(_)) { - return false; + continue; + } + + // 削除イベントかどうか判定 + let is_remove = matches!(event.kind, EventKind::Remove(_)); + + for path in &event.paths { + // dist ディレクトリ内は無視 + if path.starts_with(&dist_path) { + continue; + } + + if is_remove { + // 削除されたファイル + has_deletion = true; + changed_files.push(ChangedFile::Deleted(path.clone())); + } else if let Some(classified) = classify_changed_file( + path, + &config_path_clone, + &static_dir, + &layouts_dir, + ) { + // Config 変更の場合は即座にフルビルド + if matches!(classified, ChangedFile::Config) { + println!("[INFO] Config changed, running full rebuild..."); + if let Err(e) = subcommand_build::run_build(&config_path_clone) { + eprintln!("[ERROR] Full rebuild failed: {:#}", e); + } else { + println!("[INFO] Full rebuild completed"); + } + return; + } + changed_files.push(classified); + } + } + } + + if changed_files.is_empty() { + return; + } + + // 変更されたファイル数を表示 + let file_count = changed_files.len(); + let file_desc = if file_count == 1 { + "1 file".to_string() + } else { + format!("{} files", file_count) + }; + + println!("[INFO] {} changed, rebuilding...", file_desc); + + // 増分ビルドを試行 + match subcommand_build::run_incremental_build(&config_path_clone, &changed_files) { + Ok(()) => { + println!("[INFO] Incremental rebuild completed"); } - // いずれかのパスが dist ディレクトリ外であれば再ビルド - event.paths.iter().any(|path| !path.starts_with(&dist_path)) - }); - - if should_rebuild { - println!("[INFO] File changed, rebuilding..."); - if let Err(e) = subcommand_build::run_build(&config_path_clone) { - eprintln!("[ERROR] Rebuild failed: {:#}", e); - } else { - println!("[INFO] Rebuild completed"); + Err(e) => { + // 増分ビルドが失敗した場合(Config 変更など)はフルビルドにフォールバック + let error_msg = format!("{:#}", e); + if error_msg.contains("full rebuild required") || has_deletion { + println!("[INFO] Falling back to full rebuild..."); + if let Err(e) = subcommand_build::run_build(&config_path_clone) { + eprintln!("[ERROR] Full rebuild failed: {:#}", e); + } else { + println!("[INFO] Full rebuild completed"); + } + } else { + eprintln!("[ERROR] Incremental rebuild failed: {:#}", e); + } } } } @@ -172,22 +237,38 @@ fn watch_files(config_path: &Path, _rebuild_flag: Arc>) -> Result<() /// 設定ファイルから dist ディレクトリのパスを取得 fn get_dist_dir(config_path: &Path) -> Result { - use serde::Deserialize; + let config = subcommand_build::load_config(config_path)?; + Ok(config.dist) +} - #[derive(Debug, Deserialize)] - struct Config { - #[serde(default = "default_dist")] - dist: String, +/// 変更されたファイルを分類する +fn classify_changed_file( + path: &Path, + config_path: &Path, + static_dir: &str, + layouts_dir: &str, +) -> Option { + let path_str = path.to_string_lossy(); + + // 設定ファイルの変更 + if path == config_path { + return Some(ChangedFile::Config); } - fn default_dist() -> String { - "dist".to_string() + // 静的ファイルの変更 + if path.starts_with(static_dir) || path_str.starts_with(&format!("./{}", static_dir)) { + return Some(ChangedFile::Static(path.to_path_buf())); } - let content = std::fs::read_to_string(config_path) - .with_context(|| format!("Failed to read config file: {}", config_path.display()))?; - let config: Config = toml::from_str(&content) - .with_context(|| format!("Failed to parse config file: {}", config_path.display()))?; + // テンプレートファイルの変更 + if path.starts_with(layouts_dir) || path_str.starts_with(&format!("./{}", layouts_dir)) { + return Some(ChangedFile::Template(path.to_path_buf())); + } - Ok(config.dist) + // Markdown ファイルの変更 + if path.extension().is_some_and(|ext| ext == "md") { + return Some(ChangedFile::Markdown(path.to_path_buf())); + } + + None }