feat: even more M4 closing

This commit is contained in:
2026-04-13 20:45:29 +02:00
parent 316a218685
commit eca5fafc1c
12 changed files with 521 additions and 9 deletions

View File

@@ -1,13 +1,15 @@
use std::collections::{HashMap, HashSet};
use std::path::Path;
use std::collections::HashMap;
use chrono::{DateTime, TimeZone, Utc};
use pagefind::api::PagefindIndex;
use pagefind::options::PagefindServiceConfig;
use rusqlite::Connection;
use walkdir::WalkDir;
use crate::db::queries;
use crate::engine::site_assets::write_bundled_site_assets;
use crate::engine::validate_site::SiteValidationReport;
use crate::engine::{EngineError, EngineResult};
use crate::model::{Post, ProjectMetadata};
use crate::render::{
@@ -26,6 +28,16 @@ pub struct PublishedPostSource {
pub struct GenerationReport {
pub written_paths: Vec<String>,
pub skipped_paths: Vec<String>,
pub deleted_paths: Vec<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum GenerationSection {
Core,
Single,
Category,
Tag,
Date,
}
pub fn generate_starter_site(
@@ -87,6 +99,124 @@ pub fn generate_starter_site(
Ok(report)
}
pub fn sections_from_validation_report(report: &SiteValidationReport) -> Vec<GenerationSection> {
let mut sections = HashSet::new();
let mut saw_unknown = false;
for path in report
.missing_pages
.iter()
.chain(report.extra_pages.iter())
.chain(report.stale_pages.iter())
{
match classify_generated_path(path) {
Some(section) => {
sections.insert(section);
}
None => {
saw_unknown = true;
}
}
}
if saw_unknown && !report_is_empty(report) {
return all_sections();
}
let mut ordered = sections.into_iter().collect::<Vec<_>>();
ordered.sort_by_key(section_sort_key);
ordered
}
pub fn apply_validation_sections(
conn: &Connection,
output_dir: &Path,
project_id: &str,
metadata: &ProjectMetadata,
posts: &[PublishedPostSource],
sections: &[GenerationSection],
) -> EngineResult<GenerationReport> {
if sections.is_empty() {
return Ok(GenerationReport::default());
}
let section_set = sections.iter().copied().collect::<HashSet<_>>();
let data_dir = project_data_dir(output_dir);
let input_posts = posts
.iter()
.map(|source| (source.post.clone(), source.body_markdown.clone()))
.collect::<Vec<_>>();
let artifacts = build_site_render_artifacts(
conn,
&data_dir,
project_id,
metadata,
&input_posts,
)
.map_err(|error| EngineError::Parse(error.to_string()))?;
let mut report = GenerationReport::default();
let expected_paths = expected_paths_for_sections(metadata, &artifacts.pages, &section_set);
for page in &artifacts.pages {
if path_matches_sections(&page.relative_path, &section_set) {
write_out(conn, output_dir, project_id, &page.relative_path, &page.html, &mut report)?;
}
}
if section_set.contains(&GenerationSection::Core) {
write_bundled_site_assets(conn, output_dir, project_id, &mut report)?;
write_out(
conn,
output_dir,
project_id,
"calendar.json",
&build_calendar_json(&posts.iter().map(|source| source.post.clone()).collect::<Vec<_>>())?,
&mut report,
)?;
for render_language in render_languages(metadata) {
let localized_posts = localized_sources(
conn,
&data_dir,
posts,
&render_language,
metadata,
)?;
let prefix = if render_language == metadata.main_language.clone().unwrap_or_else(|| "en".to_string()) {
String::new()
} else {
format!("{}/", render_language)
};
let rss = build_rss_xml(metadata, &localized_posts, &render_language);
if prefix.is_empty() {
write_out(conn, output_dir, project_id, "rss.xml", &rss, &mut report)?;
}
write_out(conn, output_dir, project_id, &format!("{prefix}feed.xml"), &rss, &mut report)?;
write_out(
conn,
output_dir,
project_id,
&format!("{prefix}atom.xml"),
&build_atom_xml(metadata, &localized_posts, &render_language),
&mut report,
)?;
write_out(
conn,
output_dir,
project_id,
&format!("{prefix}sitemap.xml"),
&build_sitemap_xml(metadata, &artifacts.pages, &localized_posts, &render_language),
&mut report,
)?;
}
}
remove_extra_section_paths(output_dir, &expected_paths, &section_set, &mut report)?;
write_pagefind_indexes(conn, output_dir, project_id, &artifacts.pagefind_documents, &mut report)?;
Ok(report)
}
fn build_media_rewrite_map(
conn: &Connection,
project_id: &str,
@@ -174,6 +304,168 @@ fn write_pagefind_indexes(
Ok(())
}
fn project_data_dir(output_dir: &Path) -> std::path::PathBuf {
if output_dir.join("meta").exists() {
output_dir.to_path_buf()
} else {
output_dir.parent().unwrap_or(output_dir).to_path_buf()
}
}
fn expected_paths_for_sections(
metadata: &ProjectMetadata,
pages: &[crate::render::SitePage],
sections: &HashSet<GenerationSection>,
) -> HashSet<String> {
let mut expected = pages
.iter()
.filter(|page| path_matches_sections(&page.relative_path, sections))
.map(|page| page.relative_path.clone())
.collect::<HashSet<_>>();
if sections.contains(&GenerationSection::Core) {
expected.insert("calendar.json".to_string());
expected.insert("rss.xml".to_string());
for language in render_languages(metadata) {
let prefix = if language == metadata.main_language.clone().unwrap_or_else(|| "en".to_string()) {
String::new()
} else {
format!("{language}/")
};
expected.insert(format!("{prefix}feed.xml"));
expected.insert(format!("{prefix}atom.xml"));
expected.insert(format!("{prefix}sitemap.xml"));
}
}
expected
}
fn remove_extra_section_paths(
output_dir: &Path,
expected: &HashSet<String>,
sections: &HashSet<GenerationSection>,
report: &mut GenerationReport,
) -> EngineResult<()> {
if !output_dir.exists() {
return Ok(());
}
let mut deleted = Vec::new();
for entry in WalkDir::new(output_dir).into_iter().filter_map(Result::ok) {
if !entry.file_type().is_file() {
continue;
}
let rel = entry
.path()
.strip_prefix(output_dir)
.unwrap_or(entry.path())
.to_string_lossy()
.replace('\\', "/");
if rel.starts_with("meta/")
|| rel.starts_with("posts/")
|| rel.starts_with("media/")
|| rel.starts_with("assets/")
|| rel.starts_with("pagefind")
|| rel.contains("/pagefind/")
{
continue;
}
if !matches_generated_extension(&rel) || !path_matches_sections(&rel, sections) || expected.contains(&rel) {
continue;
}
std::fs::remove_file(entry.path()).map_err(EngineError::Io)?;
deleted.push(rel);
}
deleted.sort();
report.deleted_paths.extend(deleted);
Ok(())
}
fn path_matches_sections(path: &str, sections: &HashSet<GenerationSection>) -> bool {
classify_generated_path(path)
.map(|section| sections.contains(&section))
.unwrap_or(false)
}
fn classify_generated_path(path: &str) -> Option<GenerationSection> {
if path.ends_with(".xml") || path.ends_with(".json") {
return Some(GenerationSection::Core);
}
let mut parts = path.split('/').collect::<Vec<_>>();
if parts.is_empty() {
return None;
}
if has_language_prefix(&parts) {
parts.remove(0);
}
match parts.as_slice() {
["index.html"] => Some(GenerationSection::Core),
["category", ..] => Some(GenerationSection::Category),
["tag", ..] => Some(GenerationSection::Tag),
[year, "index.html"] if is_year_segment(year) => Some(GenerationSection::Date),
[year, month, "index.html"] if is_year_segment(year) && is_month_segment(month) => Some(GenerationSection::Date),
[year, month, day, _slug, "index.html"]
if is_year_segment(year) && is_month_segment(month) && is_day_segment(day) => Some(GenerationSection::Single),
_ => None,
}
}
fn has_language_prefix(parts: &[&str]) -> bool {
match parts {
[first, second, ..] => {
!is_year_segment(first)
&& *first != "category"
&& *first != "tag"
&& (*second == "index.html" || is_year_segment(second) || *second == "category" || *second == "tag")
}
_ => false,
}
}
fn is_year_segment(value: &str) -> bool {
value.len() == 4 && value.chars().all(|ch| ch.is_ascii_digit())
}
fn is_month_segment(value: &str) -> bool {
value.len() == 2 && value.chars().all(|ch| ch.is_ascii_digit())
}
fn is_day_segment(value: &str) -> bool {
is_month_segment(value)
}
fn matches_generated_extension(path: &str) -> bool {
path.ends_with(".html") || path.ends_with(".xml") || path.ends_with(".json")
}
fn all_sections() -> Vec<GenerationSection> {
vec![
GenerationSection::Core,
GenerationSection::Single,
GenerationSection::Category,
GenerationSection::Tag,
GenerationSection::Date,
]
}
fn section_sort_key(section: &GenerationSection) -> u8 {
match section {
GenerationSection::Core => 0,
GenerationSection::Single => 1,
GenerationSection::Category => 2,
GenerationSection::Tag => 3,
GenerationSection::Date => 4,
}
}
fn report_is_empty(report: &SiteValidationReport) -> bool {
report.missing_pages.is_empty() && report.extra_pages.is_empty() && report.stale_pages.is_empty()
}
fn render_languages(metadata: &ProjectMetadata) -> Vec<String> {
let main = metadata.main_language.clone().unwrap_or_else(|| "en".to_string());
let mut languages = vec![main.clone()];

View File

@@ -23,7 +23,7 @@ pub fn validate_site(
project_id: &str,
) -> EngineResult<SiteValidationReport> {
let metadata = crate::engine::meta::read_project_json(data_dir)?;
let output_dir = data_dir.to_path_buf();
let output_dir = generated_output_dir(data_dir);
let published_posts = load_published_posts(data_dir, conn, project_id)?;
let artifacts = build_site_render_artifacts(conn, data_dir, project_id, &metadata, &published_posts)
.map_err(|error| EngineError::Parse(error.to_string()))?;
@@ -98,6 +98,15 @@ pub fn validate_site(
})
}
fn generated_output_dir(data_dir: &Path) -> std::path::PathBuf {
let html_dir = data_dir.join("html");
if html_dir.exists() {
html_dir
} else {
data_dir.to_path_buf()
}
}
fn load_published_posts(
data_dir: &Path,
conn: &Connection,

View File

@@ -8,7 +8,7 @@ use serde::Serialize;
use crate::db::queries::generated_file_hash as qhash;
use crate::model::{GeneratedFileHash, Post};
use crate::util::{atomic_write_str, content_hash, now_unix_ms};
use crate::util::{atomic_write_str, content_hash, file_hash, now_unix_ms};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum GeneratedWriteOutcome {
@@ -31,13 +31,16 @@ pub fn write_generated_file(
content: &str,
) -> Result<GeneratedWriteOutcome, Box<dyn std::error::Error + Send + Sync>> {
let hash = content_hash(content.as_bytes());
let target_path = output_dir.join(relative_path);
if let Ok(existing) = qhash::get_generated_file_hash(conn, project_id, relative_path) {
if existing.content_hash == hash {
if existing.content_hash == hash
&& target_path.exists()
&& file_hash(&target_path)? == hash
{
return Ok(GeneratedWriteOutcome::SkippedUnchanged);
}
}
let target_path = output_dir.join(relative_path);
if let Some(parent) = target_path.parent() {
fs::create_dir_all(parent)?;
}
@@ -64,13 +67,16 @@ pub fn write_generated_bytes(
content: &[u8],
) -> Result<GeneratedWriteOutcome, Box<dyn std::error::Error + Send + Sync>> {
let hash = content_hash(content);
let target_path = output_dir.join(relative_path);
if let Ok(existing) = qhash::get_generated_file_hash(conn, project_id, relative_path) {
if existing.content_hash == hash {
if existing.content_hash == hash
&& target_path.exists()
&& file_hash(&target_path)? == hash
{
return Ok(GeneratedWriteOutcome::SkippedUnchanged);
}
}
let target_path = output_dir.join(relative_path);
if let Some(parent) = target_path.parent() {
fs::create_dir_all(parent)?;
}

View File

@@ -1,6 +1,9 @@
use bds_core::db::queries::project::insert_project;
use bds_core::db::Database;
use bds_core::engine::generation::{PublishedPostSource, generate_starter_site};
use bds_core::engine::generation::{
PublishedPostSource, apply_validation_sections, generate_starter_site,
sections_from_validation_report,
};
use bds_core::engine::validate_site::validate_site;
use bds_core::model::{Post, PostStatus, Project, ProjectMetadata};
use tempfile::TempDir;
@@ -184,4 +187,78 @@ fn site_validation_detects_stale_and_missing_outputs() {
assert!(report.stale_pages.contains(&"index.html".to_string()));
assert!(report.missing_pages.contains(&"feed.xml".to_string()));
assert!(report.extra_pages.is_empty());
}
#[test]
fn apply_validation_repairs_core_section_outputs() {
let (db, dir) = setup();
let metadata = make_metadata();
let post = make_post("hello", 1_710_000_000_000);
bds_core::db::queries::post::insert_post(db.conn(), &post).unwrap();
let posts = vec![PublishedPostSource {
post,
body_markdown: "Hello **world**".into(),
}];
generate_starter_site(db.conn(), dir.path(), "p1", &metadata, &posts, "en").unwrap();
std::fs::write(dir.path().join("index.html"), "tampered").unwrap();
std::fs::remove_file(dir.path().join("feed.xml")).unwrap();
let report = validate_site(db.conn(), dir.path(), "p1").unwrap();
let sections = sections_from_validation_report(&report);
let apply_report = apply_validation_sections(db.conn(), dir.path(), "p1", &metadata, &posts, &sections).unwrap();
let repaired = validate_site(db.conn(), dir.path(), "p1").unwrap();
assert!(!apply_report.written_paths.is_empty() || !apply_report.skipped_paths.is_empty());
assert!(repaired.missing_pages.is_empty());
assert!(repaired.extra_pages.is_empty());
assert!(repaired.stale_pages.is_empty());
}
#[test]
fn apply_validation_removes_extra_section_outputs() {
let (db, dir) = setup();
let metadata = make_metadata();
let post = make_post("hello", 1_710_000_000_000);
bds_core::db::queries::post::insert_post(db.conn(), &post).unwrap();
let posts = vec![PublishedPostSource {
post,
body_markdown: "Hello **world**".into(),
}];
generate_starter_site(db.conn(), dir.path(), "p1", &metadata, &posts, "en").unwrap();
let extra_dir = dir.path().join("tag/ghost");
std::fs::create_dir_all(&extra_dir).unwrap();
std::fs::write(extra_dir.join("index.html"), "ghost").unwrap();
let report = validate_site(db.conn(), dir.path(), "p1").unwrap();
assert!(report.extra_pages.contains(&"tag/ghost/index.html".to_string()));
let sections = sections_from_validation_report(&report);
let apply_report = apply_validation_sections(db.conn(), dir.path(), "p1", &metadata, &posts, &sections).unwrap();
let repaired = validate_site(db.conn(), dir.path(), "p1").unwrap();
assert!(apply_report.deleted_paths.contains(&"tag/ghost/index.html".to_string()));
assert!(repaired.extra_pages.is_empty());
}
#[test]
fn site_validation_uses_html_output_directory_when_present() {
let (db, dir) = setup();
let metadata = make_metadata();
let post = make_post("hello", 1_710_000_000_000);
bds_core::db::queries::post::insert_post(db.conn(), &post).unwrap();
let posts = vec![PublishedPostSource {
post,
body_markdown: "Hello **world**".into(),
}];
let output_dir = dir.path().join("html");
std::fs::create_dir_all(&output_dir).unwrap();
generate_starter_site(db.conn(), &output_dir, "p1", &metadata, &posts, "en").unwrap();
let report = validate_site(db.conn(), dir.path(), "p1").unwrap();
assert!(report.missing_pages.is_empty());
assert!(report.extra_pages.is_empty());
assert!(report.stale_pages.is_empty());
}

View File

@@ -75,6 +75,32 @@ fn generated_write_skips_unchanged_content() {
assert!(fs::read_to_string(dir.path().join("index.html")).unwrap().contains("changed"));
}
#[test]
fn generated_write_rewrites_missing_file_even_when_hash_matches() {
let (db, dir) = setup();
let first = write_generated_file(db.conn(), dir.path(), "p1", "index.html", "hello").unwrap();
fs::remove_file(dir.path().join("index.html")).unwrap();
let second = write_generated_file(db.conn(), dir.path(), "p1", "index.html", "hello").unwrap();
assert_eq!(first, GeneratedWriteOutcome::Written);
assert_eq!(second, GeneratedWriteOutcome::Written);
assert_eq!(fs::read_to_string(dir.path().join("index.html")).unwrap(), "hello");
}
#[test]
fn generated_write_rewrites_stale_file_even_when_db_hash_matches() {
let (db, dir) = setup();
let first = write_generated_file(db.conn(), dir.path(), "p1", "index.html", "hello").unwrap();
fs::write(dir.path().join("index.html"), "tampered").unwrap();
let second = write_generated_file(db.conn(), dir.path(), "p1", "index.html", "hello").unwrap();
assert_eq!(first, GeneratedWriteOutcome::Written);
assert_eq!(second, GeneratedWriteOutcome::Written);
assert_eq!(fs::read_to_string(dir.path().join("index.html")).unwrap(), "hello");
}
#[test]
fn core_generation_paths_include_language_prefixed_variants() {
let paths = build_core_generation_paths("en", &["en".into(), "de".into(), "fr".into()]);

View File

@@ -137,8 +137,10 @@ pub enum Message {
GenerateSite,
RunMetadataDiff,
RunSiteValidation,
ApplySiteValidation,
EngineTaskDone { task_id: TaskId, label: String, result: Result<String, String> },
SiteValidationLoaded(Result<engine::validate_site::SiteValidationReport, String>),
SiteValidationApplied(Result<String, String>),
// Editor views
PostEditor(PostEditorMsg),
@@ -2351,6 +2353,7 @@ impl BdsApp {
Task::none()
}
Message::RunSiteValidation => self.start_site_validation(),
Message::ApplySiteValidation => self.apply_site_validation(),
Message::EngineTaskDone { task_id, label, result } => {
match &result {
Ok(detail) => {
@@ -2396,6 +2399,21 @@ impl BdsApp {
}
Task::none()
}
Message::SiteValidationApplied(result) => {
self.site_validation_state.is_applying = false;
match result {
Ok(detail) => {
self.site_validation_state.error_message = None;
self.notify(ToastLevel::Success, &detail);
self.start_site_validation()
}
Err(error) => {
self.site_validation_state.error_message = Some(error.clone());
self.notify(ToastLevel::Error, &error);
Task::none()
}
}
}
// ── Toast ──
Message::ShowToast(level, msg) => {
@@ -3703,6 +3721,70 @@ impl BdsApp {
)
}
fn apply_site_validation(&mut self) -> Task<Message> {
if self.site_validation_state.is_running || self.site_validation_state.is_applying {
return Task::none();
}
let report = engine::validate_site::SiteValidationReport {
missing_pages: self.site_validation_state.missing_files.clone(),
extra_pages: self.site_validation_state.extra_files.clone(),
stale_pages: self.site_validation_state.stale_files.clone(),
};
let sections = engine::generation::sections_from_validation_report(&report);
if sections.is_empty() {
return Task::none();
}
let Some(project_id) = self.active_project.as_ref().map(|project| project.id.clone()) else {
self.site_validation_state.error_message = Some(t(self.ui_locale, "engine.generateSiteNoProject"));
return Task::none();
};
let Some(data_dir) = self.data_dir.clone() else {
self.site_validation_state.error_message = Some(t(self.ui_locale, "engine.previewDataDirUnavailable"));
return Task::none();
};
self.site_validation_state.is_applying = true;
self.site_validation_state.error_message = None;
let db_path = self.db_path.clone();
let applied_label = t(self.ui_locale, "siteValidation.apply");
Task::perform(
async move {
let db = Database::open(&db_path).map_err(|error| error.to_string())?;
let metadata = engine::meta::read_project_json(&data_dir).map_err(|error| error.to_string())?;
let all_posts = bds_core::db::queries::post::list_posts_by_project(db.conn(), &project_id)
.map_err(|error| error.to_string())?;
let mut sources = Vec::new();
for post in all_posts.into_iter().filter(|post| post.status == PostStatus::Published) {
let body_markdown = load_generation_post_body(&data_dir, &post)?;
sources.push(engine::generation::PublishedPostSource { post, body_markdown });
}
let output_dir = data_dir.join("html");
std::fs::create_dir_all(&output_dir).map_err(|error| error.to_string())?;
let apply_report = engine::generation::apply_validation_sections(
db.conn(),
&output_dir,
&project_id,
&metadata,
&sources,
&sections,
)
.map_err(|error| error.to_string())?;
Ok(format!(
"{}: written={}, skipped={}, deleted={}, output={}",
applied_label,
apply_report.written_paths.len(),
apply_report.skipped_paths.len(),
apply_report.deleted_paths.len(),
output_dir.display(),
))
},
Message::SiteValidationApplied,
)
}
/// Spawn a blocking engine operation on a background thread via TaskManager.
///
/// Returns `Task::none()` if no active project/db/data_dir.

View File

@@ -11,6 +11,7 @@ use crate::i18n::t;
pub struct SiteValidationState {
pub has_run: bool,
pub is_running: bool,
pub is_applying: bool,
pub missing_files: Vec<String>,
pub extra_files: Vec<String>,
pub stale_files: Vec<String>,
@@ -24,6 +25,15 @@ pub fn view<'a>(state: &'a SiteValidationState, locale: UiLocale) -> Element<'a,
button(text(t(locale, "siteValidation.run")).size(13).shaping(Shaping::Advanced))
.on_press(Message::RunSiteValidation)
};
let has_issues = !state.missing_files.is_empty() || !state.extra_files.is_empty() || !state.stale_files.is_empty();
let apply_button = if state.is_applying {
button(text(t(locale, "siteValidation.applying")).size(13).shaping(Shaping::Advanced))
} else if !state.is_running && state.error_message.is_none() && has_issues {
button(text(t(locale, "siteValidation.apply")).size(13).shaping(Shaping::Advanced))
.on_press(Message::ApplySiteValidation)
} else {
button(text(t(locale, "siteValidation.apply")).size(13).shaping(Shaping::Advanced))
};
let mut content = column![
row![
@@ -31,7 +41,7 @@ pub fn view<'a>(state: &'a SiteValidationState, locale: UiLocale) -> Element<'a,
.size(24)
.shaping(Shaping::Advanced)
.color(Color::from_rgb(0.88, 0.88, 0.92)),
run_button,
row![run_button, apply_button].spacing(12),
]
.spacing(16),
]

View File

@@ -94,6 +94,8 @@
"tabBar.siteValidation": "Website-Validierung",
"tabBar.translationValidation": "Übersetzungsvalidierung",
"siteValidation.run": "Validierung starten",
"siteValidation.apply": "Validierung anwenden",
"siteValidation.applying": "Wird angewendet...",
"siteValidation.idle": "Starte die Website-Validierung, um generierte Dateien mit dem erwarteten Output zu vergleichen.",
"siteValidation.running": "Validierung läuft...",
"siteValidation.clean": "Es wurden keine fehlenden, zusätzlichen oder veralteten generierten Dateien gefunden.",

View File

@@ -94,6 +94,8 @@
"tabBar.siteValidation": "Site Validation",
"tabBar.translationValidation": "Translation Validation",
"siteValidation.run": "Run Validation",
"siteValidation.apply": "Apply Validation",
"siteValidation.applying": "Applying...",
"siteValidation.idle": "Run site validation to compare generated files against expected output.",
"siteValidation.running": "Validation in progress...",
"siteValidation.clean": "No missing, extra, or stale generated files were found.",

View File

@@ -94,6 +94,8 @@
"tabBar.siteValidation": "Validación del sitio",
"tabBar.translationValidation": "Validación de traducciones",
"siteValidation.run": "Ejecutar validación",
"siteValidation.apply": "Aplicar validación",
"siteValidation.applying": "Aplicando...",
"siteValidation.idle": "Ejecuta la validación del sitio para comparar los archivos generados con la salida esperada.",
"siteValidation.running": "Validación en curso...",
"siteValidation.clean": "No se encontraron archivos generados faltantes, extra o desactualizados.",

View File

@@ -94,6 +94,8 @@
"tabBar.siteValidation": "Validation du site",
"tabBar.translationValidation": "Validation des traductions",
"siteValidation.run": "Lancer la validation",
"siteValidation.apply": "Appliquer la validation",
"siteValidation.applying": "Application en cours...",
"siteValidation.idle": "Lancez la validation du site pour comparer les fichiers générés à la sortie attendue.",
"siteValidation.running": "Validation en cours...",
"siteValidation.clean": "Aucun fichier généré manquant, supplémentaire ou obsolète n'a été trouvé.",

View File

@@ -94,6 +94,8 @@
"tabBar.siteValidation": "Validazione sito",
"tabBar.translationValidation": "Validazione traduzioni",
"siteValidation.run": "Esegui validazione",
"siteValidation.apply": "Applica validazione",
"siteValidation.applying": "Applicazione in corso...",
"siteValidation.idle": "Esegui la validazione del sito per confrontare i file generati con l'output previsto.",
"siteValidation.running": "Validazione in corso...",
"siteValidation.clean": "Non sono stati trovati file generati mancanti, aggiuntivi o obsoleti.",