0.0.1-alpha.5

This commit is contained in:
2026-09-28 09:50:20 +02:00
parent 8f6f072e11
commit c17b7068a3
26 changed files with 1952 additions and 23 deletions

View File

@@ -0,0 +1,15 @@
# file: crates/knowledge-howtos-server/Cargo.toml
# version: 1
[package]
name = "knowledge-howtos-server"
version.workspace = true
edition.workspace = true
publish.workspace = true
repository.workspace = true
[dependencies]
knowledge-web-lib.workspace = true
[lints]
workspace = true

View File

@@ -0,0 +1,8 @@
// file: crates/knowledge-howtos-server/src/main.rs
// version: 1
//! Thin launcher for the knowledge-howtos-server process.
fn main() -> std::io::Result<()> {
return knowledge_web_lib::run_server(knowledge_web_lib::ServerSite::Howtos, "KNOWLEDGE_HOWTOS_BIND_ADDRESS");
}

View File

@@ -0,0 +1,15 @@
# file: crates/knowledge-pgr-server/Cargo.toml
# version: 1
[package]
name = "knowledge-pgr-server"
version.workspace = true
edition.workspace = true
publish.workspace = true
repository.workspace = true
[dependencies]
knowledge-web-lib.workspace = true
[lints]
workspace = true

View File

@@ -0,0 +1,8 @@
// file: crates/knowledge-pgr-server/src/main.rs
// version: 1
//! Thin launcher for the knowledge-pgr-server process.
fn main() -> std::io::Result<()> {
return knowledge_web_lib::run_server(knowledge_web_lib::ServerSite::Pgr, "KNOWLEDGE_PGR_BIND_ADDRESS");
}

View File

@@ -0,0 +1,15 @@
# file: crates/knowledge-snippets-server/Cargo.toml
# version: 1
[package]
name = "knowledge-snippets-server"
version.workspace = true
edition.workspace = true
publish.workspace = true
repository.workspace = true
[dependencies]
knowledge-web-lib.workspace = true
[lints]
workspace = true

View File

@@ -0,0 +1,8 @@
// file: crates/knowledge-snippets-server/src/main.rs
// version: 1
//! Thin launcher for the knowledge-snippets-server process.
fn main() -> std::io::Result<()> {
return knowledge_web_lib::run_server(knowledge_web_lib::ServerSite::Snippets, "KNOWLEDGE_SNIPPETS_BIND_ADDRESS");
}

View File

@@ -0,0 +1,20 @@
# file: crates/knowledge-web-lib/Cargo.toml
# version: 1
[package]
name = "knowledge-web-lib"
version.workspace = true
edition.workspace = true
publish.workspace = true
repository.workspace = true
[dependencies]
actix-files = { version = "0.7.0", default-features = false }
actix-web = { version = "4.15.0", default-features = false }
knowledge-domain-lib.workspace = true
maud = { version = "0.27.0", features = ["actix-web"] }
serde = { version = "1.0.229", features = ["derive"] }
serde_json = "1.0.151"
[lints]
workspace = true

View File

@@ -0,0 +1,195 @@
// file: crates/knowledge-web-lib/src/assets.rs
// version: 1
//! Vite development and production asset resolution.
#[derive(Clone, Debug, serde::Deserialize)]
struct ManifestEntry {
file: String,
#[serde(default)]
css: Vec<String>,
#[serde(default, rename = "isEntry")]
is_entry: bool,
}
/// Internal resolved asset set consumed by the Maud renderer and Actix runtime.
#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) struct ViteAssets {
scripts: Vec<String>,
stylesheets: Vec<String>,
static_assets_directory: Option<std::path::PathBuf>,
static_mount_path: Option<String>,
}
impl crate::ViteAssets {
/// Build development asset references from a configured Vite origin.
pub(crate) fn development(origin: &str) -> Result<crate::ViteAssets, String> {
let normalized = origin.trim().trim_end_matches('/');
if normalized.is_empty() {
return Err("KNOWLEDGE_VITE_DEV_ORIGIN must not be empty".to_owned());
}
return Ok(crate::ViteAssets {
scripts: vec![format!("{normalized}/@vite/client"), format!("{normalized}/src/main.ts")],
stylesheets: Vec::new(),
static_assets_directory: None,
static_mount_path: None,
});
}
/// Resolve production asset references from Vite manifest JSON text.
pub(crate) fn from_manifest_text(manifest: &str, asset_base: &str, static_assets_directory: std::path::PathBuf) -> Result<crate::ViteAssets, String> {
let entries: std::collections::BTreeMap<String, ManifestEntry> = match serde_json::from_str(manifest) {
Ok(value) => value,
Err(error) => return Err(format!("invalid Vite manifest JSON: {error}")),
};
let entry = match entries.get("src/main.ts") {
Some(value) => value,
None => return Err("Vite manifest is missing src/main.ts".to_owned()),
};
if !entry.is_entry {
return Err("Vite manifest src/main.ts is not marked as an entry".to_owned());
}
if entry.file.trim().is_empty() {
return Err("Vite manifest src/main.ts has an empty JavaScript file".to_owned());
}
let javascript = match asset_url(asset_base, entry.file.as_str()) {
Ok(value) => value,
Err(error) => return Err(error),
};
let mut stylesheets = Vec::new();
for stylesheet in &entry.css {
let resolved = match asset_url(asset_base, stylesheet.as_str()) {
Ok(value) => value,
Err(error) => return Err(error),
};
stylesheets.push(resolved);
}
let static_mount_path = local_static_mount_path(asset_base);
return Ok(crate::ViteAssets {
scripts: vec![javascript],
stylesheets,
static_assets_directory: Some(static_assets_directory),
static_mount_path,
});
}
/// Return module script URLs in load order.
pub(crate) fn scripts(&self) -> &[String] {
return self.scripts.as_slice();
}
/// Return production stylesheet URLs. Development CSS is injected by Vite.
pub(crate) fn stylesheets(&self) -> &[String] {
return self.stylesheets.as_slice();
}
/// Return the local production assets directory when static serving is available.
pub(crate) fn static_assets_directory(&self) -> Option<&std::path::Path> {
match self.static_assets_directory.as_deref() {
Some(value) => return Some(value),
None => return None,
}
}
/// Return the local HTTP mount used for built assets, or `None` for an external asset base.
pub(crate) fn static_mount_path(&self) -> Option<&str> {
match self.static_mount_path.as_deref() {
Some(value) => return Some(value),
None => return None,
}
}
}
/// Resolve the active Vite asset mode from process environment variables.
pub(crate) fn load_assets_from_environment() -> Result<crate::ViteAssets, std::io::Error> {
let development_origin = environment_value("KNOWLEDGE_VITE_DEV_ORIGIN");
let manifest_path = environment_value("KNOWLEDGE_VITE_MANIFEST_PATH");
match (development_origin, manifest_path) {
(Some(_), Some(_)) => {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"configure either KNOWLEDGE_VITE_DEV_ORIGIN or KNOWLEDGE_VITE_MANIFEST_PATH, not both",
));
},
(Some(origin), None) => {
let assets = match crate::ViteAssets::development(origin.as_str()) {
Ok(value) => value,
Err(error) => return Err(std::io::Error::new(std::io::ErrorKind::InvalidInput, error)),
};
return Ok(assets);
},
(None, Some(path)) => {
return load_manifest_assets(path.as_str());
},
(None, None) => {
return Err(std::io::Error::new(std::io::ErrorKind::InvalidInput, "configure KNOWLEDGE_VITE_DEV_ORIGIN or KNOWLEDGE_VITE_MANIFEST_PATH"));
},
}
}
fn environment_value(name: &str) -> Option<String> {
match std::env::var(name) {
Ok(value) => {
if value.trim().is_empty() {
return None;
}
return Some(value);
},
Err(_) => return None,
}
}
fn load_manifest_assets(path: &str) -> Result<crate::ViteAssets, std::io::Error> {
let manifest_path = std::path::PathBuf::from(path);
let text = match std::fs::read_to_string(manifest_path.as_path()) {
Ok(value) => value,
Err(error) => return Err(error),
};
let manifest_directory = match manifest_path.parent() {
Some(value) => value,
None => {
return Err(std::io::Error::new(std::io::ErrorKind::InvalidInput, "Vite manifest path has no parent directory"));
},
};
let output_root = match manifest_directory.parent() {
Some(value) => value,
None => {
return Err(std::io::Error::new(std::io::ErrorKind::InvalidInput, "Vite manifest path must be located below an output root"));
},
};
let asset_base = match environment_value("KNOWLEDGE_ASSET_BASE_PATH") {
Some(value) => value,
None => "/".to_owned(),
};
let assets = match crate::ViteAssets::from_manifest_text(text.as_str(), asset_base.as_str(), output_root.join("assets")) {
Ok(value) => value,
Err(error) => return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, error)),
};
return Ok(assets);
}
fn asset_url(base: &str, relative: &str) -> Result<String, String> {
let normalized_base = base.trim();
let normalized_relative = relative.trim().trim_start_matches('/');
if normalized_base.is_empty() {
return Err("KNOWLEDGE_ASSET_BASE_PATH must not be empty".to_owned());
}
if normalized_relative.is_empty() {
return Err("Vite manifest contains an empty asset path".to_owned());
}
if normalized_base == "/" {
return Ok(format!("/{normalized_relative}"));
}
return Ok(format!("{}/{normalized_relative}", normalized_base.trim_end_matches('/')));
}
fn local_static_mount_path(base: &str) -> Option<String> {
let normalized = base.trim();
if !normalized.starts_with('/') {
return None;
}
if normalized == "/" {
return Some("/assets".to_owned());
}
return Some(format!("{}/assets", normalized.trim_end_matches('/')));
}

View File

@@ -0,0 +1,27 @@
// file: crates/knowledge-web-lib/src/lib.rs
// version: 1
//! Shared Actix Web and Maud composition for the three Knowledge server launchers.
//!
//! The crate owns the minimal HTTP runtime, Maud rendering and Vite asset resolution shared by
//! PGR, HOWTOs and snippets. It does not own authentication, administration or persistence.
mod assets;
mod page;
mod runtime;
mod site;
/// Start one configured Knowledge Actix Web server.
pub use self::runtime::run_server;
/// Identify the public Knowledge facade owned by one server launcher.
pub use self::site::ServerSite;
/// Internal asset description shared by the renderer and runtime.
pub(crate) use self::assets::ViteAssets;
/// Internal loader that resolves development or production assets from environment configuration.
pub(crate) use self::assets::load_assets_from_environment;
/// Internal Maud renderer for the public foundation page.
pub(crate) use self::page::render_index_page;
#[cfg(test)]
mod unit_tests;

View File

@@ -0,0 +1,52 @@
// file: crates/knowledge-web-lib/src/page.rs
// version: 1
//! Maud rendering adapted from the static template-lab contract.
/// Render the minimal public page shared by the three server launchers.
pub(crate) fn render_index_page(site: crate::ServerSite, assets: &crate::ViteAssets) -> maud::Markup {
return maud::html! {
(maud::DOCTYPE)
html lang="fr" {
head {
meta charset="UTF-8";
meta name="viewport" content="width=device-width, initial-scale=1";
title { (site.page_title()) }
@for stylesheet in assets.stylesheets() {
link rel="stylesheet" href=(stylesheet);
}
}
body {
main class="container py-5 knowledge-template-shell" data-content-family=(family_slug(site.content_family())) {
header class="mb-5" {
p class="text-body-secondary mb-2" { "SaseDev Knowledge" }
h1 class="display-5" { (site.heading()) }
p class="lead mb-0" { (site.lead()) }
}
section class="row g-4" aria-label="Famille de contenu" {
div class="col-12" {
article class="card knowledge-family-card" {
div class="card-body" {
h2 class="h4" { (site.heading()) }
p class="card-text" { (site.lead()) }
}
}
}
}
}
@for script in assets.scripts() {
script type="module" src=(script) {}
}
}
}
};
}
fn family_slug(family: knowledge_domain_lib::ContentFamily) -> &'static str {
match family {
knowledge_domain_lib::ContentFamily::Pgr => return "pgr",
knowledge_domain_lib::ContentFamily::Howto => return "howtos",
knowledge_domain_lib::ContentFamily::Snippet => return "snippets",
knowledge_domain_lib::ContentFamily::ProjectLocal => return "project-local",
}
}

View File

@@ -0,0 +1,73 @@
// file: crates/knowledge-web-lib/src/runtime.rs
// version: 1
//! Shared Actix Web process composition.
struct ServerState {
site: crate::ServerSite,
assets: crate::ViteAssets,
}
/// Start one Knowledge HTTP server using environment-owned bind and asset configuration.
///
/// `bind_environment` names the launcher-specific environment variable containing the socket
/// address, for example `127.0.0.1:18081`. The function never chooses a deployment address.
pub fn run_server(site: crate::ServerSite, bind_environment: &str) -> std::io::Result<()> {
let bind_address = match std::env::var(bind_environment) {
Ok(value) => {
if value.trim().is_empty() {
return Err(std::io::Error::new(std::io::ErrorKind::InvalidInput, format!("{bind_environment} must not be empty")));
}
value
},
Err(_) => {
return Err(std::io::Error::new(std::io::ErrorKind::InvalidInput, format!("{bind_environment} must be configured")));
},
};
let assets = match crate::load_assets_from_environment() {
Ok(value) => value,
Err(error) => return Err(error),
};
let static_assets_directory = match assets.static_assets_directory() {
Some(value) => Some(value.to_path_buf()),
None => None,
};
let static_mount_path = match assets.static_mount_path() {
Some(value) => Some(value.to_owned()),
None => None,
};
let state = actix_web::web::Data::new(ServerState { site, assets });
return actix_web::rt::System::new().block_on(async move {
let server = actix_web::HttpServer::new(move || {
let app_state = state.clone();
let static_directory = static_assets_directory.clone();
let static_mount = static_mount_path.clone();
return actix_web::App::new()
.app_data(app_state)
.route("/", actix_web::web::get().to(index))
.route("/healthz", actix_web::web::get().to(health))
.configure(move |configuration| {
configure_static_assets(configuration, static_mount.as_deref(), static_directory.as_deref());
});
});
let bound = match server.bind(bind_address.as_str()) {
Ok(value) => value,
Err(error) => return Err(error),
};
return bound.run().await;
});
}
async fn index(state: actix_web::web::Data<ServerState>) -> maud::Markup {
return crate::render_index_page(state.site, &state.assets);
}
async fn health() -> actix_web::HttpResponse {
return actix_web::HttpResponse::Ok().content_type("text/plain; charset=utf-8").body("ok");
}
fn configure_static_assets(configuration: &mut actix_web::web::ServiceConfig, mount_path: Option<&str>, directory: Option<&std::path::Path>) {
if let (Some(mount), Some(path)) = (mount_path, directory) {
configuration.service(actix_files::Files::new(mount, path.to_path_buf()));
}
}

View File

@@ -0,0 +1,53 @@
// file: crates/knowledge-web-lib/src/site.rs
// version: 1
//! Public facade identities used by the shared Web runtime.
/// Public Knowledge facade served by one launcher.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ServerSite {
/// Normative PGR facade.
Pgr,
/// Procedural HOWTOs facade.
Howtos,
/// Contextual snippets facade.
Snippets,
}
impl crate::ServerSite {
/// Return the domain content family associated with this facade.
pub fn content_family(self) -> knowledge_domain_lib::ContentFamily {
match self {
crate::ServerSite::Pgr => return knowledge_domain_lib::ContentFamily::Pgr,
crate::ServerSite::Howtos => return knowledge_domain_lib::ContentFamily::Howto,
crate::ServerSite::Snippets => return knowledge_domain_lib::ContentFamily::Snippet,
}
}
/// Return the page title used by the foundation renderer.
pub fn page_title(self) -> &'static str {
match self {
crate::ServerSite::Pgr => return "SaseDev Knowledge — PGR",
crate::ServerSite::Howtos => return "SaseDev Knowledge — HOWTOs",
crate::ServerSite::Snippets => return "SaseDev Knowledge — Snippets",
}
}
/// Return the main heading used by the foundation renderer.
pub fn heading(self) -> &'static str {
match self {
crate::ServerSite::Pgr => return "Project Governance Rules",
crate::ServerSite::Howtos => return "HOWTOs",
crate::ServerSite::Snippets => return "Snippets",
}
}
/// Return the short explanatory lead used by the foundation renderer.
pub fn lead(self) -> &'static str {
match self {
crate::ServerSite::Pgr => return "Connaissance normative de gouvernance.",
crate::ServerSite::Howtos => return "Guides procéduraux et explicatifs.",
crate::ServerSite::Snippets => return "Fragments courts et contextualisés.",
}
}
}

View File

@@ -0,0 +1,72 @@
// file: crates/knowledge-web-lib/src/unit_tests.rs
// version: 1
//! Unit tests for shared Web composition primitives.
#[test]
fn development_assets_include_vite_client_before_main_entry() {
let assets = match crate::ViteAssets::development("http://127.0.0.1:5173/") {
Ok(value) => value,
Err(error) => {
assert!(error.is_empty(), "unexpected development asset error: {error}");
return;
},
};
assert_eq!(assets.scripts().len(), 2);
assert_eq!(assets.scripts()[0], "http://127.0.0.1:5173/@vite/client");
assert_eq!(assets.scripts()[1], "http://127.0.0.1:5173/src/main.ts");
assert!(assets.stylesheets().is_empty());
assert!(assets.static_assets_directory().is_none());
assert!(assets.static_mount_path().is_none());
}
#[test]
fn production_manifest_resolves_hashes_and_asset_base() {
let manifest = r#"{
"src/main.ts": {
"file": "assets/main-abc.js",
"css": ["assets/main-def.css"],
"isEntry": true
}
}"#;
let assets = match crate::ViteAssets::from_manifest_text(manifest, "/", std::path::PathBuf::from("/tmp/knowledge-assets")) {
Ok(value) => value,
Err(error) => {
assert!(error.is_empty(), "unexpected manifest error: {error}");
return;
},
};
assert_eq!(assets.scripts().len(), 1);
assert_eq!(assets.scripts()[0], "/assets/main-abc.js");
assert_eq!(assets.stylesheets().len(), 1);
assert_eq!(assets.stylesheets()[0], "/assets/main-def.css");
assert_eq!(assets.static_assets_directory(), Some(std::path::Path::new("/tmp/knowledge-assets")));
assert_eq!(assets.static_mount_path(), Some("/assets"));
}
#[test]
fn manifest_requires_the_stable_main_entry() {
let manifest = r#"{"index.html": {"file": "assets/index.js", "isEntry": true}}"#;
let result = crate::ViteAssets::from_manifest_text(manifest, "/", std::path::PathBuf::from("/tmp/knowledge-assets"));
assert!(result.is_err());
}
#[test]
fn maud_page_uses_resolved_assets_and_site_identity() {
let assets = match crate::ViteAssets::from_manifest_text(
r#"{"src/main.ts": {"file": "assets/main.js", "css": ["assets/main.css"], "isEntry": true}}"#,
"/",
std::path::PathBuf::from("/tmp/knowledge-assets"),
) {
Ok(value) => value,
Err(error) => {
assert!(error.is_empty(), "unexpected manifest error: {error}");
return;
},
};
let rendered = crate::render_index_page(crate::ServerSite::Pgr, &assets).into_string();
assert!(rendered.contains("SaseDev Knowledge — PGR"));
assert!(rendered.contains("/assets/main.js"));
assert!(rendered.contains("/assets/main.css"));
assert!(rendered.contains("data-content-family=\"pgr\""));
}