init repo with source

This commit is contained in:
Jesús Pérez 2026-07-10 03:44:13 +01:00
commit f27d8f9f7a
Signed by: jesus
GPG key ID: 9F243E355E0BC939
565 changed files with 120405 additions and 0 deletions

37
.cargo/audit.toml Normal file
View file

@ -0,0 +1,37 @@
# Generated by dev-system/ci
# cargo-audit configuration for security vulnerability scanning
# Database configuration
[advisories]
# The database path
db-path = "~/.cargo/advisory-db"
# Advisory database URLs
db-urls = ["https://github.com/rustsec/advisory-db"]
# How to handle different kinds of advisories
# "allow" - Pass the check despite the warning
# "warn" - Pass the check but warn about the issue
# "deny" - Fail the check
deny = ["unmaintained", "unsound", "yanked"]
# Specific vulnerability IDs to ignore (in case of false positives)
# You can use: https://rustsec.org/
ignore = [
# Example: { id = "RUSTSEC-2023-XXXX", reason = "Not applicable to our use case" }
]
# How to handle vulnerabilities based on severity
[output]
# Deny on high severity vulnerabilities
deny = ["high", "critical"]
# Warn on medium severity vulnerabilities
warn = ["medium", "low"]
# Advisory format: "terminal", "json"
format = "terminal"
# Target configuration
[target]
# Check only specific targets
# Uncomment to restrict to specific target triples
# triple = "x86_64-unknown-linux-gnu"

79
.cargo/config.base.toml Normal file
View file

@ -0,0 +1,79 @@
# Portable cargo configuration — safe to commit.
# Machine-local settings (target-dir, sccache) live in config.toml (gitignored).
# Run `just setup-dev` to generate config.toml for this machine.
[build]
jobs = 4
[profile.dev]
opt-level = 0
debug = true
debug-assertions = true
overflow-checks = true
lto = false
panic = "unwind"
incremental = true
[profile.clippy]
# Lint-only: no debug info, no codegen — clippy only needs MIR/HIR.
# Pre-commit uses this to avoid bloating target/ with DWARF/dSYM artifacts.
inherits = "dev"
debug = 0
incremental = true
[profile.release]
opt-level = 3
debug = false
debug-assertions = false
overflow-checks = false
lto = "thin"
codegen-units = 1
panic = "abort"
incremental = false
strip = false
[profile.test]
opt-level = 1
debug = true
debug-assertions = true
overflow-checks = true
lto = false
incremental = true
[profile.ci-test]
# Pre-commit profile: debug=0 halves binary size; shared by rust-test + docs-links hooks.
inherits = "test"
debug = 0
incremental = false
[profile.ci]
# CI: line-tables-only debug for actionable backtraces on remote failures.
inherits = "test"
debug = "line-tables-only"
incremental = false
[profile.bench]
opt-level = 3
debug = false
debug-assertions = false
overflow-checks = false
lto = "thin"
codegen-units = 1
incremental = false
[term]
color = "auto"
verbose = false
progress.when = "auto"
progress.width = 80
[net]
git-fetch-with-cli = true
offline = false
[alias]
build-all = "build --all-targets"
check-all = "check --all-targets --all-features"
test-all = "test --all-features --workspace"
doc-all = "doc --all-features --no-deps --open"
doc-json = "doc --no-deps -- -Z unstable-options --output-format json"

8
.dockerignore Normal file
View file

@ -0,0 +1,8 @@
.git/
target/
node_modules/
.coder/
works-pv/
cache/
lian-build/ctx-*
.DS_Store

47
.env.example Normal file
View file

@ -0,0 +1,47 @@
# Environment Variables for website-impl
# Copy this file to .env and fill in your values
# Application Settings
SECRET_KEY=your-secret-key-here-change-in-production
RUST_LOG=info
# Database Settings
# For SQLite (development)
DATABASE_URL=sqlite:data/dev_database.db
# For PostgreSQL (production)
# DATABASE_URL=postgresql://username:password@localhost/website-impl_db
# Server Settings
LEPTOS_SITE_ADDR=127.0.0.1:3030
LEPTOS_SITE_ROOT=/
LEPTOS_SITE_PKG_DIR=pkg
LEPTOS_SITE_NAME=website-impl
# Feature Flags
RUSTELO_AUTH_ENABLED=false
RUSTELO_EMAIL_ENABLED=false
RUSTELO_METRICS_ENABLED=true
RUSTELO_TLS_ENABLED=false
# Development Settings
RUSTELO_DEV_MODE=true
RUSTELO_HOT_RELOAD=true
# Asset Settings
RUSTELO_STATIC_DIR=public
RUSTELO_UPLOAD_DIR=uploads
RUSTELO_MAX_FILE_SIZE=10485760 # 10MB in bytes
# Site Content Configuration (PAP Compliance)
SITE_CONTENT_PATH=../site
SITE_SERVER_CONTENT_URL=/content
# Activities Feature
# Root directory for PDF files served via /api/activities/download/:id
ACTIVITY_ASSETS_PATH=site/assets/activities
# Root directory for Slidev static builds served via /slides/*
ACTIVITY_SLIDES_PATH=site/slides
# Hex-encoded 32-byte secret for HMAC callback tokens
# Generate with: openssl rand -hex 32
ACTIVITY_CALLBACK_SECRET=change_me_generate_with_openssl_rand_hex_32

88
.gitignore vendored Normal file
View file

@ -0,0 +1,88 @@
CLAUDE.md
.coder/
.claude/
.internal.git/
target
node_modules
logs
logs-archive
.ncl-cache
data
utils/save*sh
.fastembed_cache
.wrks
target
distribution
.qodo
# enviroment to load on bin/build
.env
# OSX trash
.DS_Store
# Vscode files
.vscode
# Emacs save files
*~
\#*\#
.\#*
# Vim-related files
[._]*.s[a-w][a-z]
[._]s[a-w][a-z]
*.un~
Session.vim
.netrwhist
# cscope-related files
cscope.*
# User cluster configs
.kubeconfig
.tags*
# direnv .envrc files
.envrc
# Machine-local cargo config — generated by `just setup-dev` from .cargo/config.base.toml
# Contains: target-dir, rustc-wrapper, sccache paths — never commit machine-local paths
.cargo/config.toml
# make-related metadata
/.make/
# Just in time generated data in the source, should never be committed
/test/e2e/generated/bindata.go
# This file used by some vendor repos (e.g. github.com/go-openapi/...) to store secret variables and should not be ignored
!\.drone\.sec
# Godeps workspace
/Godeps/_workspace
/bazel-*
*.pyc
# generated by verify-vendor.sh
vendordiff.patch
.claude/settings.local.json
# Generated SBOM files
SBOM.*.json
*.sbom.json
# UnoCSS build
#assets/css/node_modules/
#assets/css/pnpm-lock.yaml
crates/ontoref-daemon/public/css/ontoref.css
# sync-assets manifest — local artefact, not tracked
#assets/.sync-manifest.json
# ADR-040: convention content is DERIVED — symlinks to a machine-specific shared
# origin (dev-system) or vendored snapshots. Source of truth is the card.ncl
# `conventions` declaration; `onre conventions sync` re-creates this tree.
.ontoref/conventions/

1
.k Normal file
View file

@ -0,0 +1 @@
E/yVqdOfAJIYLTcVzqOFH0eu4R5W+XHKm72fFnNvvzo=

82
.pre-commit-config.yaml Normal file
View file

@ -0,0 +1,82 @@
# Pre-commit Configuration
# Configures git pre-commit hooks for the website-impl project
repos:
# ============================================================================
# Ontology Hooks
# ============================================================================
- repo: local
hooks:
- id: manifest-coverage
name: Manifest capability completeness
entry: bash -c 'ONTOREF_PROJECT_ROOT="$(pwd)" ontoref sync manifest-check'
language: system
files: (\.ontology/|reflection/modes/|reflection/forms/).*\.ncl$
pass_filenames: false
stages: [pre-commit]
# ============================================================================
# Rust Hooks
# ============================================================================
- repo: local
hooks:
- id: rust-fmt
name: Rust formatting (cargo +nightly fmt)
entry: bash -c 'cargo +nightly fmt --all'
language: system
types: [rust]
pass_filenames: false
stages: [pre-commit]
- id: rust-clippy
name: Rust linting (cargo clippy)
entry: bash -c 'cargo clippy --lib --bins 2>&1 | grep -i warning || true'
language: system
types: [rust]
pass_filenames: false
stages: [pre-commit]
- id: rust-test
name: Rust tests
entry: bash -c 'cargo test --workspace'
language: system
types: [rust]
pass_filenames: false
stages: [pre-push]
- id: rustelo-deps-sync
name: Rustelo deps sync (registry + overlay → workspace.dependencies)
# Fails (exit 1) if [workspace.dependencies] drifted from
# ../rustelo/registry/dependencies.toml combined with the local
# rustelo-deps-overlay.toml. Requires the rustelo repo checked out
# as a sibling directory.
entry: >-
bash -c 'test -d ../rustelo/xtask || {
echo "rustelo-deps-sync: ../rustelo/xtask not found — clone rustelo as sibling dir" >&2; exit 2; };
CARGO_TARGET_DIR="${CARGO_TARGET_DIR:-target/xtask}"
cargo run --manifest-path ../rustelo/xtask/Cargo.toml --quiet --
sync-deps --check --rustelo-root ../rustelo --target .'
language: system
files: '^(Cargo\.toml|rustelo-deps-overlay\.toml)$'
pass_filenames: false
stages: [pre-commit]
# ============================================================================
# General Pre-commit Hooks
# ============================================================================
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.5.0
hooks:
- id: check-added-large-files
args: ['--maxkb=1000']
- id: check-merge-conflict
- id: check-toml
- id: check-yaml
- id: end-of-file-fixer
- id: trailing-whitespace
exclude: \.md$

92
CHANGELOG.md Normal file
View file

@ -0,0 +1,92 @@
# Changelog
All notable changes to website-impl are documented here.
Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
Not versioned until production deployment — see `.ontology/state.ncl` for current `deployment-readiness` state.
## [Unreleased]
### Changed — Server Restructuring & on+re migration (2026-04-06)
- **Server restructured** to rustelo template pattern (app.rs, run.rs, shell.rs, resources.rs, theme.rs) — custom auth stack removed
- **Auth removed**: JWT, OTP, WebSocket hot-reload, user dashboard, server_fns.rs, database/auth.rs — `auth-system` dimension regressed to `build-time-only`
- **Project showcase pages** added: kogral, vapora, rustelo, syntaxis, typedialog, secretumvault, stratumiops, provisioning, ontoref, work_request, services
- **Post viewer simplified**: client.rs and html_generator.rs removed, only mod.rs remains
- **CSS scripts moved** from `scripts/` to `scripts/build/` subdirectory
- **on+re migration**: `state.ncl` — new `build-time-only` state in auth-system, deployment-readiness pre-production description updated, transition blockers updated
- **on+re migration**: `core.ncl` — hydration-strategy artifact paths corrected, auth-rbac-impl updated (build-time only), user-dashboard updated (removed, FTL preserved), css-asset-pipeline script paths updated, new `project-showcase-pages` node added
- **ADR-001** (website-impl): Server Template Restructuring — auth removal constraints captured
### Added — Architecture Self-Description (2026-03-14)
- **on+re protocol** integrated: `.ontology/` + `adrs/` + `reflection/` in place
- `.ontology/core.ncl` — knowledge graph as Rustelo Service consumer: axioms (`rustelo-consumer`, `bilingual-content`), tensions (hydration complexity, build-time vs runtime), project nodes (build-time codegen impl, hydration strategy, cache layer, NCL config, custom routing impl, auth RBAC, user dashboard, CSS asset pipeline)
- `.ontology/state.ncl` — four tracked dimensions: `deployment-readiness` (development → **pre-production**), `hydration-stability` (stabilized → **zero-mismatch**), `auth-system` (**operational**), `css-pipeline` (**correct**)
- `.ontology/gate.ncl``hydration-parity` (Low permeability, Challenge protocol), `content-integrity` (Medium, Observe protocol), `rbac-correctness` (Low, Challenge protocol)
- `.ontology/manifest.ncl` — Service manifest with EndUser, Developer, Agent consumption modes; `rustelo-browse` operational mode for cross-project framework capability browsing
- `reflection/` — backlog, constraints, defaults, qa, schema; modes: `integrity-check`, `sync-ontology`, `validate-content`, `validate-hydration`
- `adrs/adr-001-ncl-over-toml.ncl` — NCL (Nickel) over TOML for site configuration
- `adrs/adr-002-hydration-strategy.ncl` — SsrTranslator in both SSR+WASM targets; 6 hard/soft constraints including `menu-registry-language-keyed`, `reactive-closure-copy-only`, `spa-content-id-not-slug`
- `adrs/adr-003-ws-rbac-hot-reload.ncl` — WebSocket broadcast for RBAC hot-reload without server restart; 3 constraints (`content-module-always-compiled`, `chrono-non-optional`, `server-enforcement-primary`)
### Added — User Dashboard (2026-03-03)
- 5-tab authenticated user area: Perfil, Marcadores, Mensajes, Notas, Recursos
- DB migrations `005_user_dashboard_{postgres,sqlite}.sql`: `user_messages`, `user_notes`, `user_resources` tables
- `database/auth.rs`: `MessageRow`, `NoteRow`, `ResourceRow` + 7 query methods
- `server_fns.rs`: 7 server functions (`UserMessage`, `UserNote`, `UserResource`)
- `otp_service.rs` + `otp_adapter.rs`: trait + full implementation for all 7 methods
- `site/rbac.ncl`: 7 endpoint allow rules for `user` group
- `site/i18n/locales/{en,es}/pages/user.ftl`: ~20 new i18n keys per language
- `crates/pages/src/user/unified.rs`: 5-tab dashboard rewrite with signal-safety patterns
### Fixed — NavMenu Auth & RBAC (2026-03-03)
- `AuthControls` moved inline in `UnifiedNavMenu` (desktop + mobile) — no longer a fixed overlay
- `navmenu/unified.rs`: auth strings (`nav-sign-in`, `nav-sign-out`, `nav-dashboard`) computed inside reactive closures via `UnifiedI18n::new` — reactive to language changes
- `site/rbac.ncl`: authenticated `user` group allow rules for assets, `track_page_view*`, `server_update_display_name*`
- `database/auth.rs`: `parse_sqlite_datetime()` helper handles both RFC3339 and `%Y-%m-%d %H:%M:%S` — fixes OTP login panic on SQLite `datetime('now')` format
### Added — RBAC Hot-Reload via WebSocket (2026-03-01)
- `site/rbac.ncl` file-watch triggers in-process server reload of `AUTH_PATTERNS`
- New patterns broadcast over WebSocket to all connected WASM clients
- WASM receives broadcast and updates reactive signal — UI gates update without page reload
- `content` module compiled unconditionally (no feature gate); `chrono` made non-optional
### Fixed — CSS Asset Pipeline (2026-03-05)
- `scripts/build-css-bundles.js`: reads from correct source (`site/assets/styles/`, not cache)
- `scripts/download-highlight-css.js`: output to `site/public/assets/styles/` (was `public/styles/`)
- `scripts/download-highlightjs-copy-css.js`: output path fixed + converted to ES modules
- `scripts/copy-css-assets.js`: excludes `.DS_Store`, `.toml`, and `themes/` directory from public sync
- `package.json`: `highlightjs-copy:css` added to build pipeline
- `site/config/assets.ncl`: all CSS paths corrected from `/styles/``/assets/styles/`
### Fixed — Hydration: SPA PostViewer Cold-Cache 404 (2026-02-26)
- `item.id ≠ item.slug` for some posts — HTML files are named after `item.id`, not `item.slug`
- On cold SPA navigation: `load_content_index` returned `[]` → fallback used `url_slug` as filename → 404
- Fix: `AsyncContentUpdater::resolve_and_fetch_content` fetches `index.json`, matches `item.slug == url_slug`, then uses `item.id` for the file path
- `_content_memo` gated to `#[cfg(not(target_arch = "wasm32"))]` — was firing in WASM with wrong language
### Fixed — Hydration: WASM FTL Registry (2026-02-26)
- `FTL_REGISTRY` was never populated in WASM — all translations fell through to SSR-embedded data (initial language only)
- `crates/client/build.rs`: generates `ftl_registry_fn.rs` with `include_str!` for every `.ftl` file (46 entries for en+es)
- `crates/client/src/lib.rs`: calls `populate_wasm_ftl_registry()` at WASM startup — populates registry before any component renders
- Language switching now works correctly in WASM: `get_parsed_ftl_for_language("en")` finds all `en_*` keys
### Fixed — Hydration: Page Cache Empty HashMap (2026-02-26)
- All 15 `*Client` cache components passed `HashMap::new()` as `lang_content` — caused `HtmlContent` empty `inner_html` → tachys `<!>` placeholder → cursor desync → hydration panic
- `target/rustelo-cache/pages/page_*.rs` `*Client` components: replaced `HashMap::new()` with `SsrTranslator` + `build_page_content_patterns`
- `build_page_generator.rs` template updated — future regenerations produce structurally identical code for both targets
### Fixed — Hydration: Menu Registry Key Mismatch (2026-02-26)
- `MENU_REGISTRY` keyed `"main"` in WASM init; `get_menu(lang)` looks up by `"en"`/`"es"` → 0 items in WASM vs 6 in server
- `crates/server/build.rs` + `crates/client/build.rs`: now insert per-language keys (`"en"`, `"es"`) from `config.ncl` routes
- `navmenu/unified.rs`: `lang_signal.get_untracked()` in SSR to suppress tracking-context warning
### Added — Configuration-Driven Routing (2026-02-25)
- `routing.rs` fully rewritten: uses `ROUTE_TABLE` (BTreeMap from NCL config) — no hardcoded paths
- `build.rs`: reads `site/config/routes/*.ncl`, generates `route_table.rs` + `RouteComponent` enum
- `lib.rs`: `#![recursion_limit = "512"]` added for complex generated types
### Added — NCL Configuration (2026-02-09)
- Route configuration migrated from `*.toml` to `site/config/routes/*.ncl`
- RBAC rules in `site/rbac.ncl` with typed contracts
- Asset configuration in `site/config/assets.ncl`
- Reusable schemas in `site/schemas/` for content, menus, footer, themes
- Bilingual menus in single NCL file (eliminated en.toml/es.toml duplication)

7792
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

215
Cargo.toml Normal file
View file

@ -0,0 +1,215 @@
[workspace]
resolver = "2"
members = [
"crates/build-config",
"crates/shared",
"crates/pages",
"crates/client",
"crates/server",
"crates/pages_htmx",
]
[workspace.package]
version = "0.1.0"
authors = ["Development Team <dev@ontoref.dev>"]
edition = "2021"
rust-version = "1.75"
license = "MIT"
[workspace.dependencies]
# >>> rustelo-sync:workspace-deps (managed by `cargo xtask sync-deps` — DO NOT EDIT)
aes-gcm = "0.10"
ammonia = "4.1"
any_spawner = "0.3"
anyhow = "1.0.102"
argon2 = "0.5"
async-compression = { version = "0.4", features = ["gzip", "tokio"] }
async-nats = "0.49"
async-trait = "0.1.89"
axum = "0.8.9"
axum-server = { version = "0.8", features = ["tls-rustls"] }
axum-test = "20.0"
base32 = "0.5"
base64 = "0.22"
bytes = "1.11"
cfg-if = "1.0"
chrono = { version = "0.4", features = ["serde"] }
clap = { version = "4.6", features = ["derive", "env"] }
comrak = { version = "0.52", features = ["syntect"] }
console = "0.16"
console_error_panic_hook = "0.1.7"
console_log = "1"
crossterm = "0.29"
dashmap = "6"
dialoguer = "0.12"
dotenv = "0.15.0"
env_logger = "0.11"
fluent = "0.17"
fluent-bundle = "0.16"
fluent-syntax = "0.12"
fluent-templates = { version = "0.14.0", features = ["tera"] }
futures = "0.3.32"
getrandom = { version = "0.4", features = ["std", "wasm_js"] }
glob = "0.3.3"
gloo-net = { version = "0.7.0", features = ["websocket"] }
gloo-timers = { version = "0.4", features = ["futures"] }
gray_matter = "0.3"
handlebars = "6.4"
hex = "0.4.3"
html-escape = "0.2"
http = "1"
ignore = "0.4"
indicatif = "0.18"
inquire = "0.9"
js-sys = "=0.3.97"
jsonwebtoken = { version = "10.4", features = ["rust_crypto"] }
leptos = "=0.8.15"
leptos_axum = "=0.8.7"
leptos_config = "=0.8.8"
leptos_integration_utils = "=0.8.7"
leptos_meta = "=0.8.5"
leptos_router = "=0.8.11"
lettre = { version = "0.11", default-features = false, features = ["tokio1-rustls-tls", "smtp-transport", "pool", "hostname", "builder"] }
log = "0.4.30"
lru = "0.18"
mockall = "0.14"
nkeys = "0.4"
notify = { version = "8.2.0", default-features = false }
oauth2 = "5.0"
once_cell = "1.21.4"
paste = "1.0.15"
pathdiff = "0.2"
platform-nats = { path = "../../../stratumiops/code/crates/platform-nats" }
proc-macro2 = "1.0"
prometheus = "0.14"
pulldown-cmark = { version = "0.13", features = ["simd"] }
qrcode = { version = "0.14", features = ["svg"] }
quote = "1.0"
rand = "0.10"
rand_core = "0.10"
ratatui = "0.30"
reactive_graph = "0.2.14"
regex = "1.12.3"
reqwasm = "0.5.0"
reqwest = { version = "0.13.4", features = ["json"] }
rhai = { version = "1.25", features = ["serde", "only_i64", "no_float"] }
rustelo_auth = { path = "../../rustelo/code/crates/framework/crates/rustelo_auth" }
rustelo_cli = { path = "../../rustelo/code/crates/framework/crates/rustelo_cli" }
rustelo_client = { path = "../../rustelo/code/crates/foundation/crates/rustelo_client" }
rustelo_components_leptos = { path = "../../rustelo/code/crates/foundation/crates/rustelo_components_leptos" }
rustelo_components_htmx = { path = "../../rustelo/code/crates/foundation/crates/rustelo_components_htmx" }
rustelo_pages_htmx = { path = "../../rustelo/code/crates/foundation/crates/rustelo_pages_htmx" }
website-pages-htmx = { path = "crates/pages_htmx" }
rustelo_config = { path = "../../rustelo/code/crates/foundation/crates/rustelo_config" }
rustelo_content = { path = "../../rustelo/code/crates/framework/crates/rustelo_content" }
rustelo_core = { path = "../../rustelo/code/crates/framework/crates/rustelo_core" }
rustelo_core_lib = { path = "../../rustelo/code/crates/foundation/crates/rustelo_core_lib" }
rustelo_core_types = { path = "../../rustelo/code/crates/foundation/crates/rustelo_core_types" }
rustelo_language = { path = "../../rustelo/code/crates/foundation/crates/rustelo_language" }
rustelo_macros = { path = "../../rustelo/code/crates/foundation/crates/rustelo_macros" }
rustelo_pages_leptos = { path = "../../rustelo/code/crates/foundation/crates/rustelo_pages_leptos" }
rustelo_routing = { path = "../../rustelo/code/crates/foundation/crates/rustelo_routing" }
rustelo_seo = { path = "../../rustelo/code/crates/foundation/crates/rustelo_seo" }
rustelo_server = { path = "../../rustelo/code/crates/foundation/crates/rustelo_server", default-features = false, features = ["rbac-watcher"] }
rustelo_tools = { path = "../../rustelo/code/crates/foundation/crates/rustelo_tools" }
rustelo_utils = { path = "../../rustelo/code/crates/foundation/crates/rustelo_utils", features = ["manifest"] }
rustelo_web = { path = "../../rustelo/code/crates/framework/crates/rustelo_web" }
rustls = "0.23"
rustls-pemfile = "2.2"
scraper = "0.27"
semver = "1.0"
serde = { version = "1.0", features = ["derive"] }
serde-wasm-bindgen = "0.6.5"
serde_json = "1.0"
serde_yaml = "0.9"
sha2 = "0.11"
shellexpand = "3.1"
similar = "3.1"
sqlx = { version = "0.9.0", features = ["runtime-tokio", "tls-rustls-ring", "postgres", "sqlite", "chrono", "uuid", "migrate"] }
syn = { version = "2.0", features = ["full"] }
syntect = "5.3"
tempfile = "3.27"
tera = "1.20"
thiserror = "2.0.18"
minijinja = { version = "2", features = ["loader", "builtins", "json"] }
time = { version = "0.3", features = ["serde"] }
tokio = { version = "1.52", features = ["rt-multi-thread"] }
tokio-test = "0.4"
tokio-util = { version = "0.7", features = ["io"] }
toml = "1.1.2"
totp-rs = "5.7"
tower = "0.5.3"
tower-cookies = "0.11"
tower-http = { version = "0.6.11", features = ["fs", "trace"] }
tower-sessions = "0.15"
tracing = "0.1"
tracing-subscriber = "0.3"
typed-builder = "0.23"
unic-langid = { version = "0.9", features = ["unic-langid-macros"] }
unicode-normalization = "0.1"
urlencoding = "2.1"
uuid = { version = "1.23", features = ["v4", "serde", "js"] }
walkdir = "2.5"
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
web-sys = { version = "=0.3.97", features = ["Clipboard", "Window", "Navigator", "Permissions", "MouseEvent", "KeyboardEvent", "Event", "Storage", "console", "File", "SvgElement", "SvgsvgElement", "SvgPathElement", "MediaQueryList"] }
wiremock = "0.6"
content-graph = { path = "../../rustelo/code/features/content-graph" }
rustelo_content_graph_ssr = { path = "../../rustelo/code/crates/foundation/crates/rustelo_content_graph_ssr" }
lazy_static = "1.5"
# <<< rustelo-sync:workspace-deps
[[workspace.metadata.leptos]]
# Configuration for Website Leptos project
name = "website"
bin-package = "rustelo-htmx-server"
bin-target = "rustelo-htmx-server"
lib-package = "website-client"
lib-features = ["hydrate"]
bin-features = ["ssr"]
site-root = "target/site"
site-pkg-dir = "pkg"
assets-dir = "site/public"
site-addr = "127.0.0.1:3030"
reload-port = 3031
browserquery = "defaults"
# watch = false
# env = "DEV"
bin-default-features = false
lib-default-features = false
# release-mode = "release"
# [profile.release]
# codegen-units = 1
# lto = true
# opt-level = "z"
# [profile.dev]
# opt-level = 0
# debug = true
# [profile.release]
# opt-level = 3
# lto = true
# codegen-units = 1
# panic = "abort"
# [profile.wee_alloc]
# inherits = "release"
# opt-level = 's'
# [profile.dev.package.sqlx-macros]
# opt-level = 3
# # Features for controlling extension functionality
# [workspace.metadata.features]
# default = ["content-static"]
# full = ["content-static", "auth", "email", "tls", "metrics", "analytics"]
# content-static = []
# content-db = []
# auth = []
# email = []
# tls = []
# metrics = []
# analytics = []

357
README.md Normal file
View file

@ -0,0 +1,357 @@
# Website Implementation - Rustelo Framework
A complete **content website with code highlighting** built using the Rustelo framework, demonstrating modern multi-crate architecture and PAP compliance.
## 🎯 Project Overview
This implementation validates Rustelo's project generation capabilities and serves as a reference for content websites that need code display features.
### Key Features
- **Multi-crate architecture**: Client, server, shared, pages with build.rs integration
- **Type-safe configuration**: Nickel (NCL) for routes, themes, menus with compile-time validation
- **Bilingual single-source**: Zero duplication - EN/ES in one file
- **Code highlighting**: Complete syntax highlighting with copy functionality
- **Language-agnostic**: Supports any language without code changes
- **Smart caching**: Multi-layer cache system for incremental builds
- **PAP compliant**: Follows Rustelo's Project Architecture Principles
## 🚀 Quick Start
### Prerequisites
- **Rust 1.75+** - Core language and toolchain
- **Node.js 18+** - Frontend tooling
- **Nickel** (Configuration language) - **REQUIRED** for type-safe configuration
- macOS: `brew install nickel`
- Linux/Windows: `cargo install nickel-lang-cli`
- See [Nickel Installation Guide](../../docs/guides/nickel-installation.md)
- Site content in `../site/` directory
> **Note**: This project uses Nickel (NCL) for all configuration files (routes, themes, menus, content types). See [NCL Configuration](#-ncl-configuration) section below.
### Setup & Development
```bash
# Install dependencies
just setup
# Start development server with hot reload
just dev
# Build for production
just build-prod
```
### Development Commands
```bash
# Development
just dev # Hot reload with CSS watching
just dev-server # Rust only (no CSS watching)
just dev-css # CSS watching only
# Building
just build # Development build
just build-prod # Production build
just build-rust # Rust components only
just build-css # CSS only
# Quality & Testing
just quality # All quality checks
just test # Run tests
just format # Format code
just lint # Lint code
# Content & Deployment
just validate-content # Validate site content
just deploy-staging # Deploy to staging
just status # Project status
```
## 🏗️ Architecture
### Multi-Crate Structure
```
website-impl/
├── crates/
│ ├── shared/ # Route generation & shared types
│ ├── pages/ # Custom page component generation
│ ├── client/ # WASM frontend with asset processing
│ ├── server/ # Axum backend with configuration
│ └── plugin-example-theme/ # Example plugin (theme + i18n)
├── config.toml # Application configuration
├── uno.config.ts # UnoCSS configuration (synced with @website)
├── package.json # Code highlighting & build tools
├── justfile # Development automation
└── scripts/ # Build scripts for themes & highlighting
```
## 🔌 Plugin System
This implementation demonstrates Rustelo's **Level 5 Plugin Architecture** - a trait-based plugin system for unlimited extensibility without framework coupling.
### Included Plugins
- **WebsiteResourceContributor** (core): Provides themes, menus, and i18n
- **plugin-example-theme** (example): Complete working theme plugin with tests
### Plugin Features
- ✅ **ResourceContributor Trait**: Contribute themes, menus, translations
- ✅ **Type-Safe Registration**: Compile-time validation
- ✅ **Zero Conditional Compilation**: Framework code is pure Rust
- ✅ **Configuration-Driven**: Resources from TOML/FTL files
- ✅ **Self-Contained**: Plugins are independent crates
### Creating Custom Plugins
**Step 1: Create plugin crate**
```bash
cd crates/
cargo new --lib my-custom-plugin
```
**Step 2: Implement ResourceContributor**
```rust
use rustelo_core_lib::registration::ResourceContributor;
pub struct MyPlugin;
impl ResourceContributor for MyPlugin {
fn contribute_themes(&self) -> HashMap<String, String> {
let mut themes = HashMap::new();
themes.insert("my-theme".to_string(),
include_str!("../config/themes/my-theme.toml").to_string());
themes
}
fn name(&self) -> &str {
"my-plugin"
}
}
```
**Step 3: Add to workspace**
- Add to main `Cargo.toml` workspace members
- Create configuration files in `config/themes/` and `config/i18n/`
**Step 4: Register at startup**
```rust
// In crates/server/src/resources.rs
rustelo_core_lib::register_contributor(&MyPlugin)?;
```
### Plugin Types
| Type | Purpose | Example |
|------|---------|---------|
| **Resource-Only** | Themes, menus, translations | Custom theme plugin |
| **Page Provider** | Custom page components | Analytics dashboard |
| **Composite** | Resources + pages | Feature module |
### Example Plugin Walkthrough
See `crates/plugin-example-theme/` for a complete, production-ready example:
- Analytics dashboard theme configuration
- English and Spanish translations
- 10/10 passing unit tests
- Comprehensive documentation
### Plugin Documentation
- **Quick Start**: See this README's "Creating Custom Plugins" section above
- **Complete Guide**: [Plugin Development Guide](../../.coder/info/PHASE3-PLUGIN-DEVELOPMENT-GUIDE.md)
- **Architecture**: [Rustelo Plugin Architecture](../../docs/architecture/rustelo-plugin-architecture.md)
- **Example Plugin**: `./crates/plugin-example-theme/README.md`
## 🎨 Code Highlighting Features
### Syntax Highlighting
- **highlight.js**: Multi-language syntax highlighting
- **highlightjs-copy**: One-click code copying
- **Theme system**: Light/dark mode with custom themes
- **UnoCSS integration**: Built-in code styling with design system
### Design System Integration
Uses complete design system from @website:
- `ds-*` prefixed utility classes
- Theme variables for consistent styling
- Built-in dark mode support
- Code block styling with proper contrast
## 📝 NCL Configuration
This project uses **Nickel (NCL)** for type-safe, DRY configuration. All configuration files use `.ncl` format with TOML fallback support.
### Why NCL?
**Type Safety**: Compile-time validation catches errors before runtime
**Zero Duplication**: Bilingual configs in single file (no separate en.toml/es.toml)
**DRY Principles**: Shared defaults and helper functions
**Better Tooling**: Syntax highlighting, LSP support, validation
### Configuration Files
```
site/
├── schemas/ # Reusable NCL type definitions
│ ├── content/
│ │ ├── contracts.ncl # Type contracts for content
│ │ └── defaults.ncl # Shared defaults + helpers
│ ├── menus/
│ │ ├── contracts.ncl
│ │ └── defaults.ncl
│ ├── footer/
│ │ ├── contracts.ncl
│ │ └── defaults.ncl
│ └── themes/
│ ├── contracts.ncl
│ └── defaults.ncl
├── config/
│ └── themes/
│ ├── default.ncl # Default theme
│ └── dark.ncl # Dark theme variant
├── content/
│ └── content-kinds.ncl # Content type definitions
└── ui/
├── menus/
│ └── menu.ncl # Navigation menu (bilingual)
└── footer/
└── footer.ncl # Footer config (bilingual)
```
### Example: Bilingual Menu (Before vs After)
**Before (TOML - 130 lines across 2 files):**
```toml
# en.toml
[[items]]
route = "/services"
label = "Services"
# es.toml
[[items]]
route = "/servicios"
label = "Servicios"
```
**After (NCL - 115 lines, single file):**
```nickel
{
items = [
make_menu_item {
routes = { en = "/services", es = "/servicios" },
labels = { en = "Services", es = "Servicios" },
},
]
}
```
### Working with NCL Configs
**Export to JSON:**
```bash
nickel export --format json site/ui/menus/menu.ncl | jq '.'
```
**Validate:**
```bash
nickel typecheck site/ui/menus/menu.ncl
```
**Auto-Detection:**
Build system automatically prefers `.ncl` files over `.toml`:
```
site/config/themes/dark.ncl ✅ Used
site/config/themes/dark.toml ⏭️ Ignored (fallback)
```
### Documentation
- **Configuration Guide**: [../../docs/guides/nickel-configuration-guide.md](../../docs/guides/nickel-configuration-guide.md)
- **Installation**: [../../docs/guides/nickel-installation.md](../../docs/guides/nickel-installation.md)
- **ADR**: [../../docs/adr/0002-nickel-configuration-language.md](../../docs/adr/0002-nickel-configuration-language.md)
- **Implementation Summary**: [./.coder/info/summaries/2026-02-09-nickel-config-implementation-complete.md](./.coder/info/summaries/2026-02-09-nickel-config-implementation-complete.md)
### Metrics
| Config Type | Lines | Duplication Reduced |
|-------------|-------|---------------------|
| Content-Kinds | 29 | ~60% less |
| Menus | 115 | 100% (bilingual) |
| Footers | 63 | 29% smaller |
| Themes | 4 × 14 | Variants from base |
**Total**: ~140 lines of config + ~920 lines of reusable schemas
---
## 🔧 Configuration
### Site Integration
```toml
# Links to site content structure
[content]
root_path = "../site" # Site content directory
public_path = "../site/public" # Static assets
content_url = "/content" # Content API URL
types = ["blog", "recipes"] # Available content types
languages = ["en", "es"] # Supported languages
default_language = "en" # Default language
```
## 🧪 PAP Compliance
**Configuration-driven**: All routes, themes, menus from NCL files with type-safety
**Language-agnostic**: No hardcoded languages, bilingual single-source configs
**Custom routing**: Uses Rustelo routing, NOT Leptos router
**Error handling**: Proper Result<T, E> patterns, no unwrap()
**Modular design**: Feature-based architecture with plugin system
**Type-safe configuration**: Nickel contracts validate at compile-time
**No hardcoding**: All paths and routes configurable
**Self-describing**: Architecture tracked via on+re protocol — `.ontology/` + `adrs/`
## 🔍 Architecture Self-Description (on+re)
This project implements the [Ontoref](https://github.com/ontoref) `on+re` protocol. The `.ontology/` and `adrs/` directories provide a machine- and agent-readable description of the project's architecture, current state, and invariants as a Rustelo consumer (Service kind).
### `.ontology/` files
| File | Contents |
|------|---------|
| `core.ncl` | Knowledge graph: axioms (`rustelo-consumer`, `bilingual-content`), tensions (hydration complexity, build-time vs runtime), project nodes |
| `state.ncl` | State dimensions: `deployment-readiness` (pre-production), `hydration-stability` (zero-mismatch), `auth-system` (operational), `css-pipeline` (correct) |
| `gate.ncl` | Membranes: `hydration-parity` (Low permeability), `content-integrity` (Medium), `rbac-correctness` (Low) |
| `manifest.ncl` | Service manifest: EndUser, Developer, Agent consumption modes; implementation, content, self-description layers |
### ADR System (`adrs/`)
| ADR | Decision |
|-----|---------|
| `adr-001` | NCL (Nickel) over TOML for site configuration |
| `adr-002` | SsrTranslator in both SSR and WASM targets for hydration parity — 4 hard constraints |
| `adr-003` | WebSocket broadcast for RBAC hot-reload without server restart |
### Browsing the architecture
```bash
# What this project is and how it can be consumed
nickel export .ontology/manifest.ncl
# Current state across all tracked dimensions
nickel export .ontology/state.ncl
# Active architecture constraints and gates
nickel export .ontology/gate.ncl
# Cross-project: browse Rustelo framework capabilities
# (rustelo-browse operational mode in manifest.ncl)
nickel export ../../rustelo/.ontology/core.ncl
```
## 📖 Documentation
- **Setup Guide**: `info/setup_from_rustelo_plan.md` - Complete implementation journey
- **Enhancements**: `info/enhancements/` - Proposed improvements for Rustelo
## 📄 License
MIT License - Part of the Rustelo framework ecosystem.

16
SETUP.md Normal file
View file

@ -0,0 +1,16 @@
# Ontoref — generated site
- project: website render mode: htmx-ssr languages: en,es en
- domain: ontoref.dev ontoref: minimal
## Remaining COMPLETE items
- site/config/site.ncl — set site name (title) and languages/default_language.
- site/config/routes.ncl — your routes; content under site/content/.
- .env (copy from .env.example) — session secret, DB, SMTP/OAuth.
- provisioning/project.ncl + lian-build/build_directives.ncl — registry, namespace, secrets (search: example.com, registry.example.com, secrets-base).
- Logos under site/public/images/logos/ + site/config/site.ncl logo.*
- Replace the example post in site/content/blog/{en,es}/getting-started/.
Build: just build-auto (auto-detects profile)
Pack: just build::distro
Verify: cargo check

31
bacon.toml Normal file
View file

@ -0,0 +1,31 @@
[jobs.htmx-dev]
command = [
"cargo", "run",
"--package", "rustelo-htmx-server",
"--no-default-features",
"--features", "htmx-ssr,content-watcher",
]
watch = [
"crates/server/src",
"crates/shared/src",
"site/config",
"site/content",
"site/i18n",
]
need_stdout = true
on_success = "back"
[jobs.check]
command = ["cargo", "check", "--workspace", "--all-targets"]
need_stdout = false
[jobs.clippy]
command = ["cargo", "clippy", "--workspace", "--all-targets", "--", "-D", "warnings"]
need_stdout = false
[jobs.test]
command = ["cargo", "test", "--workspace"]
need_stdout = true
[keybindings]
h = "job:htmx-dev"

13
crates/README.md Normal file
View file

@ -0,0 +1,13 @@
# website-impl crates
| Crate | Role |
|-------|------|
| `build-config` | NCL config loading at build time |
| `shared` | Types and utilities shared between server and client |
| `pages` | Site-specific Leptos page components |
| `server` | Axum SSR server entry point |
| `client` | WASM hydration entry point |
| `plugin-example-theme` | Example plugin — custom theme via ResourceContributor |
UI components are provided by `rustelo_components` (foundation) and used directly.
For site-specific component overrides see [rustelo_components/override.md](../../rustelo/crates/foundation/crates/rustelo_components/override.md).

View file

@ -0,0 +1,22 @@
[package]
name = "build-config"
version.workspace = true
edition.workspace = true
authors.workspace = true
license.workspace = true
[dependencies]
toml.workspace = true
serde = { workspace = true, features = ["derive"] }
serde_json.workspace = true
anyhow.workspace = true
thiserror.workspace = true
sha2.workspace = true
# Rustelo framework dependencies
rustelo_core_types.workspace = true
rustelo_config = { path = "../../../../rustelo/code/crates/foundation/crates/rustelo_config" }
rustelo_tools = { path = "../../../../rustelo/code/crates/foundation/crates/rustelo_tools" }
[dev-dependencies]
tempfile.workspace = true

View file

@ -0,0 +1,232 @@
//! Content kinds configuration loading with NCL and TOML support
//!
//! This module provides typed loading of content-kinds configuration from both
//! Nickel (.ncl) and TOML (.toml) formats. NCL is preferred for its type safety
//! and defaults, with TOML as a fallback for backward compatibility.
//!
//! # Architecture
//!
//! - Uses `rustelo_tools::ContentKindConfig` types (no duplication)
//! - Delegates NCL processing to `rustelo_config`
//! - Auto-detects format: tries .ncl first, falls back to .toml
//!
//! # Examples
//!
//! ```no_run
//! use build_config::content_kind_config;
//! use std::path::Path;
//!
//! // Load from NCL (preferred) or TOML (fallback)
//! let content_kinds = content_kind_config::load_content_kinds(
//! Path::new("site/content")
//! )?;
//!
//! for kind in content_kinds {
//! println!("Content type: {}", kind.name);
//! println!(" Directory: {}", kind.directory);
//! println!(" Enabled: {}", kind.enabled);
//! }
//! # Ok::<(), Box<dyn std::error::Error>>(())
//! ```
use std::fs;
use std::path::Path;
use anyhow::anyhow;
// Re-export types from rustelo_tools (single source of truth)
pub use rustelo_tools::build::build_tasks::content_types::{
ContentFeatures, ContentKindConfig, ContentKindsConfig,
};
use crate::Result;
/// Load content kinds from a TOML file
///
/// # Arguments
///
/// * `path` - Path to content-kinds.toml file
///
/// # Returns
///
/// Vector of `ContentKindConfig` structs
///
/// # Errors
///
/// Returns error if:
/// - File cannot be read
/// - TOML parsing fails
/// - Required fields are missing
///
/// # Examples
///
/// ```no_run
/// use build_config::content_kind_config;
/// use std::path::Path;
///
/// let kinds = content_kind_config::load_content_kinds_from_toml(
/// Path::new("site/content/content-kinds.toml")
/// )?;
/// # Ok::<(), Box<dyn std::error::Error>>(())
/// ```
pub fn load_content_kinds_from_toml(path: &Path) -> Result<Vec<ContentKindConfig>> {
let content = fs::read_to_string(path)?;
let config: ContentKindsConfig = toml::from_str(&content)?;
Ok(config.content_kinds)
}
/// Load content kinds from a Nickel file
///
/// Uses `rustelo_config` to export NCL to JSON, then deserializes to typed structs.
/// This provides type safety and validation at configuration time.
///
/// # Arguments
///
/// * `path` - Path to content-kinds.ncl file
///
/// # Returns
///
/// Vector of `ContentKindConfig` structs
///
/// # Errors
///
/// Returns error if:
/// - Nickel export fails (nickel CLI not available, syntax errors, type errors)
/// - JSON parsing fails
/// - Required fields are missing
///
/// # Examples
///
/// ```no_run
/// use build_config::content_kind_config;
/// use std::path::Path;
///
/// let kinds = content_kind_config::load_content_kinds_from_ncl(
/// Path::new("site/content/content-kinds.ncl")
/// )?;
/// # Ok::<(), Box<dyn std::error::Error>>(())
/// ```
pub fn load_content_kinds_from_ncl(path: &Path) -> Result<Vec<ContentKindConfig>> {
// Use rustelo_config to export NCL to JSON
let json_output = rustelo_config::nickel::export_to_json(path)?;
// Parse JSON into typed structs
let config: ContentKindsConfig = serde_json::from_str(&json_output)?;
Ok(config.content_kinds)
}
/// Load content kinds with automatic format detection
///
/// Tries to load from content-kinds.ncl first (preferred), falls back to
/// content-kinds.toml if NCL file doesn't exist or nickel CLI is unavailable.
///
/// # NCL-First Strategy
///
/// 1. Check if `{content_dir}/content-kinds.ncl` exists
/// 2. If yes, try to load via NCL (with schema validation)
/// 3. If NCL fails (CLI missing, syntax error), fall back to TOML
/// 4. If no NCL file, load from TOML directly
///
/// # Arguments
///
/// * `content_dir` - Directory containing content-kinds configuration
///
/// # Returns
///
/// Vector of `ContentKindConfig` structs
///
/// # Errors
///
/// Returns error only if both NCL and TOML loading fail
///
/// # Examples
///
/// ```no_run
/// use build_config::content_kind_config;
/// use std::path::Path;
///
/// // Tries content-kinds.ncl first, falls back to content-kinds.toml
/// let kinds = content_kind_config::load_content_kinds(
/// Path::new("site/content")
/// )?;
/// # Ok::<(), Box<dyn std::error::Error>>(())
/// ```
pub fn load_content_kinds(content_dir: &Path) -> Result<Vec<ContentKindConfig>> {
let ncl_path = content_dir.join("content-kinds.ncl");
let toml_path = content_dir.join("content-kinds.toml");
// Try NCL first (preferred)
if ncl_path.exists() {
match load_content_kinds_from_ncl(&ncl_path) {
Ok(kinds) => {
println!("cargo:warning=Loaded content kinds from NCL (with type validation)");
return Ok(kinds);
}
Err(e) => {
println!(
"cargo:warning=NCL loading failed ({}), falling back to TOML",
e
);
// Fall through to TOML
}
}
}
// Fall back to TOML
if toml_path.exists() {
let kinds = load_content_kinds_from_toml(&toml_path)?;
println!("cargo:warning=Loaded content kinds from TOML (fallback)");
Ok(kinds)
} else {
Err(anyhow!(
"No content kinds configuration found (tried {} and {})",
ncl_path.display(),
toml_path.display()
))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_load_from_toml() {
// This would require a test fixture
// For now, just verify types are importable
let _config = ContentKindsConfig {
content_kinds: vec![],
};
}
#[test]
fn test_content_kind_structure() {
// Verify we can construct the types
let _features = ContentFeatures {
style_mode: "row".to_string(),
style_css: "test-content".to_string(),
use_feature: true,
use_categories: true,
use_tags: true,
use_emojis: false,
cta_view: "DefaultCTAView".to_string(),
default_page_size: Some(12),
page_size_options: Some(vec![6, 12, 24]),
show_page_info: Some(true),
pagination_style: Some("numbered".to_string()),
show_difficulty: None,
show_duration: None,
show_prerequisites: None,
};
let _kind = ContentKindConfig {
name: "blog".to_string(),
directory: "blog".to_string(),
enabled: true,
is_default: Some(false),
specialized: Some(false),
features: _features,
};
}
}

View file

@ -0,0 +1,384 @@
//! Build-time configuration loader for Rustelo website
//!
//! This crate provides shared configuration loading utilities for all build.rs scripts
//! in the workspace, following DRY principles and ensuring consistent configuration
//! across server, client, shared, and pages builds.
//!
//! # Modules
//!
//! - [`route_config`]: Typed route configuration loading (NCL + TOML)
//! - [`content_kind_config`]: Typed content kinds configuration loading (NCL + TOML)
pub mod content_kind_config;
pub mod rbac_config;
pub mod route_config;
pub mod site_config;
use anyhow::{anyhow, Context, Result};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::collections::HashMap;
use std::env;
use std::fs;
use std::path::{Path, PathBuf};
/// Site configuration from config.toml or config.dev.toml
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct SiteConfig {
pub root: String,
pub content_path: String,
pub config_path: String,
pub ui_path: String,
pub i18n_path: String,
pub assets_path: String,
}
/// Complete configuration loaded from TOML file
#[derive(Debug, Clone, Deserialize)]
pub struct Config {
pub root_path: String,
pub site: SiteConfig,
}
/// Environment type for determining which config file to use
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Environment {
Development,
Production,
}
impl Environment {
/// Detect environment from Cargo profile or ENVIRONMENT env var
pub fn detect() -> Self {
// Check ENVIRONMENT env var first (explicit override)
if let Ok(env_str) = env::var("ENVIRONMENT") {
if env_str.to_lowercase().contains("prod") {
return Environment::Production;
}
}
// Fall back to Cargo profile (debug = development, release = production)
let profile = env::var("PROFILE").unwrap_or_else(|_| "debug".to_string());
if profile == "release" {
Environment::Production
} else {
Environment::Development
}
}
/// Get the config filename for this environment
pub fn config_filename(&self) -> &'static str {
match self {
Environment::Development => "config.dev.toml",
Environment::Production => "config.toml",
}
}
}
/// Load configuration from the appropriate TOML file
///
/// # Behavior
///
/// 1. Detects environment from `ENVIRONMENT` env var or Cargo `PROFILE`
/// 2. Looks for config file in the project root:
/// - Development: `config.dev.toml`
/// - Production: `config.toml`
/// 3. Returns error if the config file doesn't exist
/// 4. Parses the TOML file and returns configuration
///
/// # Errors
///
/// Returns error if:
/// - Configuration file doesn't exist
/// - Configuration file is malformed TOML
/// - Required fields are missing from configuration
pub fn load_config() -> Result<Config> {
let env = Environment::detect();
let config_filename = env.config_filename();
// Start from CARGO_MANIFEST_DIR and search upward for the config file
let mut current_dir =
PathBuf::from(env::var("CARGO_MANIFEST_DIR").context("CARGO_MANIFEST_DIR not set")?);
// Search up the directory tree for the config file (max 5 levels up)
let mut found_path = None;
for _ in 0..5 {
let config_path = current_dir.join(config_filename);
if config_path.exists() {
found_path = Some(config_path);
break;
}
// Go up one directory
if !current_dir.pop() {
break;
}
}
let config_path = found_path.ok_or_else(|| {
anyhow!(
"Configuration file not found: {}\nSearched from: {}\nEnvironment: {:?}",
config_filename,
env::var("CARGO_MANIFEST_DIR").unwrap_or_default(),
env
)
})?;
let config_content = fs::read_to_string(&config_path)
.with_context(|| format!("Failed to read config file: {}", config_path.display()))?;
let config: Config =
toml::from_str(&config_content).context("Failed to parse configuration file as TOML")?;
Ok(config)
}
/// Get the i18n directory path from configuration
///
/// Resolves the i18n path relative to the project root if it's relative,
/// or returns it as-is if it's absolute.
pub fn get_i18n_dir(config: &Config) -> PathBuf {
let i18n_path = Path::new(&config.site.i18n_path);
if i18n_path.is_absolute() {
i18n_path.to_path_buf()
} else {
Path::new(&config.root_path).join(i18n_path)
}
}
/// Get the content directory path from configuration
pub fn get_content_dir(config: &Config) -> PathBuf {
let content_path = Path::new(&config.site.content_path);
if content_path.is_absolute() {
content_path.to_path_buf()
} else {
Path::new(&config.root_path).join(content_path)
}
}
/// Get the config directory path from configuration
pub fn get_config_dir(config: &Config) -> PathBuf {
let config_path = Path::new(&config.site.config_path);
if config_path.is_absolute() {
config_path.to_path_buf()
} else {
Path::new(&config.root_path).join(config_path)
}
}
/// Get the UI directory path from configuration
pub fn get_ui_dir(config: &Config) -> PathBuf {
let ui_path = Path::new(&config.site.ui_path);
if ui_path.is_absolute() {
ui_path.to_path_buf()
} else {
Path::new(&config.root_path).join(ui_path)
}
}
/// Get the assets directory path from configuration
pub fn get_assets_dir(config: &Config) -> PathBuf {
let assets_path = Path::new(&config.site.assets_path);
if assets_path.is_absolute() {
assets_path.to_path_buf()
} else {
Path::new(&config.root_path).join(assets_path)
}
}
/// Load all FTL files from the i18n directory
///
/// Loads FTL files from both direct location (`site/i18n/*.ftl`)
/// and nested structure (`site/i18n/locales/{language}/*.ftl`)
///
/// File naming convention: `{filename}` or `{language}_{filename}` for language-specific files
pub fn load_ftl_files(config: &Config) -> Result<HashMap<String, String>> {
let mut ftl_files = HashMap::new();
let i18n_dir = get_i18n_dir(config);
if !i18n_dir.exists() {
return Ok(ftl_files); // No FTL files is not an error
}
// Load from direct FTL files in i18n directory (no language prefix)
load_ftl_files_from_dir(&i18n_dir, &mut ftl_files, None)?;
// Load from locales subdirectories with language prefix
let locales_dir = i18n_dir.join("locales");
if locales_dir.exists() {
for entry in fs::read_dir(&locales_dir)? {
let entry = entry?;
let path = entry.path();
if path.is_dir() {
// Use directory name as language code
if let Some(lang) = path.file_name().and_then(|n| n.to_str()) {
load_ftl_files_from_dir(&path, &mut ftl_files, Some(lang.to_string()))?;
}
}
}
}
Ok(ftl_files)
}
/// Helper function to load FTL files from a specific directory (recursively)
///
/// If language is provided, prepends it to filenames as `{language}_{filename}`
/// This prevents collisions when the same filename exists in multiple language directories
fn load_ftl_files_from_dir(
dir: &Path,
ftl_files: &mut HashMap<String, String>,
language: Option<String>,
) -> Result<()> {
for entry in fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
if path.is_dir() {
// Recursively load from subdirectories with same language prefix
load_ftl_files_from_dir(&path, ftl_files, language.clone())?;
} else if path.extension().is_some_and(|ext| ext == "ftl") {
// Load FTL files
if let Some(filename) = path.file_stem().and_then(|s| s.to_str()) {
let content = fs::read_to_string(&path)?;
// Construct key with language prefix if provided
let key = if let Some(lang) = &language {
format!("{}_{}", lang, filename)
} else {
filename.to_string()
};
ftl_files.insert(key, content);
}
}
}
Ok(())
}
/// Load all TOML files from a specified directory
///
/// Useful for loading menus, themes, and other configuration files
pub fn load_toml_files_from_dir(dir: &Path) -> Result<HashMap<String, String>> {
let mut files = HashMap::new();
if !dir.exists() {
return Ok(files); // Missing directory is not an error
}
for entry in fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
if path.extension().is_some_and(|ext| ext == "toml") {
if let Some(filename) = path.file_stem().and_then(|s| s.to_str()) {
let content = fs::read_to_string(&path)?;
files.insert(filename.to_string(), content);
}
}
}
Ok(files)
}
/// Build cache management for smart incremental builds
#[derive(Debug)]
pub struct BuildCache {
cache_dir: PathBuf,
}
impl BuildCache {
/// Create a new build cache
///
/// Creates the cache directory if it doesn't exist
pub fn new(cache_dir: PathBuf) -> Result<Self> {
fs::create_dir_all(&cache_dir)?;
Ok(Self { cache_dir })
}
/// Compute SHA256 hash of files in a directory
///
/// Useful for detecting when files have changed and need rebuilding
pub fn compute_hash(&self, dir: &Path) -> Result<String> {
let mut hasher = Sha256::new();
if !dir.exists() {
let hash = hasher.finalize();
return Ok(hash.iter().fold(String::new(), |mut s, b| {
use std::fmt::Write;
let _ = write!(s, "{b:02x}");
s
})[..16]
.to_string());
}
let mut files: Vec<_> = fs::read_dir(dir)?
.filter_map(|entry| entry.ok())
.filter(|entry| {
entry
.path()
.extension()
.is_some_and(|ext| ext == "toml" || ext == "ftl")
})
.collect();
files.sort_by_key(|entry| entry.path());
for entry in files {
let path = entry.path();
let content = fs::read_to_string(&path)?;
hasher.update(path.to_string_lossy().as_bytes());
hasher.update(content.as_bytes());
}
let hash = hasher.finalize();
Ok(hash.iter().fold(String::new(), |mut s, b| {
use std::fmt::Write;
let _ = write!(s, "{b:02x}");
s
})[..16]
.to_string())
}
/// Check if a cache entry exists
pub fn is_cached(&self, cache_key: &str, cache_type: &str) -> bool {
let cache_file = self
.cache_dir
.join(format!("{}_{}.cache", cache_type, cache_key));
cache_file.exists()
}
/// Save a cache entry
pub fn save_cache(&self, cache_key: &str, cache_type: &str, content: &str) -> Result<()> {
let cache_file = self
.cache_dir
.join(format!("{}_{}.cache", cache_type, cache_key));
fs::write(cache_file, content)?;
Ok(())
}
/// Load a cache entry
pub fn load_cache(&self, cache_key: &str, cache_type: &str) -> Result<String> {
let cache_file = self
.cache_dir
.join(format!("{}_{}.cache", cache_type, cache_key));
Ok(fs::read_to_string(cache_file)?)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_environment_detection() {
// Development by default (in debug builds)
let env = Environment::detect();
assert_eq!(env.config_filename(), "config.dev.toml");
}
#[test]
fn test_environment_config_filenames() {
assert_eq!(
Environment::Development.config_filename(),
"config.dev.toml"
);
assert_eq!(Environment::Production.config_filename(), "config.toml");
}
}

View file

@ -0,0 +1,57 @@
//! RBAC policy loader for build-time auth pattern extraction.
//!
//! Reads `site/rbac.ncl` (via `nickel export`) and returns the URL patterns
//! that are denied for anonymous users. These patterns are the **single source
//! of truth** for route protection — no duplication in `config.ncl`.
use anyhow::{Context, Result};
use serde::Deserialize;
use std::path::Path;
#[derive(Debug, Deserialize)]
struct RbacConfig {
defaults: RbacDefaults,
}
#[derive(Debug, Deserialize)]
struct RbacDefaults {
unauthenticated_allow: Vec<RbacEntry>,
}
#[derive(Debug, Deserialize)]
struct RbacEntry {
resource: String,
pattern: String,
outcome: String,
}
/// Return URL patterns denied for anonymous users (from `defaults.unauthenticated_allow`).
///
/// Filters to `resource = "page"` + `outcome = "deny"` entries in the order they
/// appear in the policy — the order matters for display but not for the generated
/// pattern matcher, which checks all entries.
///
/// Returns an empty vec if `rbac.ncl` is absent (non-fatal; no protected routes).
pub fn load_rbac_anonymous_deny_patterns(manifest_dir: &Path) -> Result<Vec<String>> {
let rbac_ncl = manifest_dir.join("site").join("rbac.ncl");
if !rbac_ncl.exists() {
return Ok(Vec::new());
}
let json = rustelo_config::nickel::export_to_json(&rbac_ncl)
.with_context(|| format!("Nickel export failed for {}", rbac_ncl.display()))?;
let config: RbacConfig = serde_json::from_str(&json)
.with_context(|| format!("JSON deserialisation failed for {}", rbac_ncl.display()))?;
let patterns = config
.defaults
.unauthenticated_allow
.into_iter()
.filter(|e| e.resource == "page" && e.outcome == "deny")
.map(|e| e.pattern)
.collect();
Ok(patterns)
}

View file

@ -0,0 +1,318 @@
//! Route configuration loading with NCL and TOML support
//!
//! This module provides typed route configuration loading from both Nickel (NCL)
//! and TOML files, using the framework's `RouteConfigToml` types and `rustelo_config`
//! for NCL processing.
//!
//! # Architecture
//!
//! - **Structs**: Uses `rustelo_core_types::routing::RouteConfigToml` (framework)
//! - **NCL loading**: Delegates to `rustelo_config` (framework tool)
//! - **Auto-detection**: .ncl preferred, .toml fallback
//!
//! # Examples
//!
//! ```no_run
//! use build_config::route_config;
//! use std::path::Path;
//!
//! // Load all language route files
//! let routes_dir = Path::new("site/config/routes");
//! let all_routes = route_config::load_all_routes(routes_dir)?;
//!
//! for (lang, routes) in &all_routes {
//! println!("Language {}: {} routes", lang, routes.len());
//! for route in routes {
//! println!(" {} -> {}", route.path, route.component);
//! }
//! }
//! # Ok::<(), Box<dyn std::error::Error>>(())
//! ```
use anyhow::{Context, Result};
use rustelo_core_types::routing::{RouteConfigToml, RoutesConfig};
use std::collections::HashMap;
use std::fs;
use std::path::Path;
/// Load routes from a TOML file with typed deserialization
///
/// Reads a TOML file containing route configuration and deserializes it into
/// a vector of `RouteConfigToml` structs.
///
/// # File Format
///
/// Expected TOML structure:
///
/// ```toml
/// [[routes]]
/// component = "Home"
/// path = "/"
/// language = "en"
/// enabled = true
/// title_key = "home-page-title"
/// description_key = "home-page-description"
/// # ... other fields
/// ```
///
/// # Errors
///
/// Returns error if:
/// - File cannot be read
/// - TOML syntax is invalid
/// - Deserialization fails (missing required fields, wrong types)
///
/// # Examples
///
/// ```no_run
/// use build_config::route_config::load_routes_from_toml;
/// use std::path::Path;
///
/// let path = Path::new("site/config/routes/en.toml");
/// let routes = load_routes_from_toml(path)?;
/// println!("Loaded {} routes", routes.len());
/// # Ok::<(), Box<dyn std::error::Error>>(())
/// ```
pub fn load_routes_from_toml(path: &Path) -> Result<Vec<RouteConfigToml>> {
let content = fs::read_to_string(path)
.with_context(|| format!("Failed to read TOML file: {}", path.display()))?;
let routes_config: RoutesConfig = toml::from_str(&content)
.with_context(|| format!("Failed to parse TOML file: {}", path.display()))?;
Ok(routes_config.routes)
}
/// Load routes from a Nickel (NCL) file via rustelo_config
///
/// Uses `rustelo_config` to export NCL to JSON, then deserializes into typed structs.
/// This provides schema validation, defaults, and type contracts from NCL.
///
/// # NCL Pipeline
///
/// ```text
/// .ncl file
/// ↓
/// nickel export --format json (subprocess)
/// ↓
/// JSON string
/// ↓
/// serde_json::from_str
/// ↓
/// RouteConfigToml (typed)
/// ```
///
/// # Errors
///
/// Returns error if:
/// - Nickel CLI is not installed (`nickel` command not found)
/// - NCL export fails (syntax error, contract violation)
/// - JSON deserialization fails (schema mismatch)
///
/// # Examples
///
/// ```no_run
/// use build_config::route_config::load_routes_from_ncl;
/// use std::path::Path;
///
/// let path = Path::new("site/config/routes/en.ncl");
/// let routes = load_routes_from_ncl(path)?;
/// println!("Loaded {} routes from NCL", routes.len());
/// # Ok::<(), Box<dyn std::error::Error>>(())
/// ```
pub fn load_routes_from_ncl(path: &Path) -> Result<Vec<RouteConfigToml>> {
// Use rustelo_config to export NCL to JSON
let json_string = rustelo_config::nickel::export_to_json(path)
.with_context(|| format!("Failed to export NCL file: {}", path.display()))?;
// Deserialize JSON to RoutesConfig
let routes_config: RoutesConfig = serde_json::from_str(&json_string).with_context(|| {
format!(
"Failed to deserialize routes from JSON (NCL export): {}",
path.display()
)
})?;
Ok(routes_config.routes)
}
/// Load routes with format auto-detection (.ncl preferred, .toml fallback)
///
/// Tries to load from NCL first, falls back to TOML if:
/// - NCL file doesn't exist
/// - Nickel CLI is not available
/// - NCL export fails
///
/// This allows gradual migration from TOML to NCL without breaking builds.
///
/// # Format Detection Order
///
/// 1. Check if `.ncl` file exists → load via `load_routes_from_ncl()`
/// 2. Check if `nickel` CLI available → try NCL
/// 3. Fallback to `.toml` → load via `load_routes_from_toml()`
///
/// # Errors
///
/// Returns error only if BOTH formats fail:
/// - NCL file exists but export fails AND TOML file doesn't exist
/// - Neither NCL nor TOML file exists
///
/// # Examples
///
/// ```no_run
/// use build_config::route_config::load_routes;
/// use std::path::Path;
///
/// // Will try en.ncl first, fallback to en.toml
/// let path = Path::new("site/config/routes/en");
/// let routes = load_routes(path)?;
/// # Ok::<(), Box<dyn std::error::Error>>(())
/// ```
pub fn load_routes(base_path: &Path) -> Result<Vec<RouteConfigToml>> {
// Try NCL first (preferred)
let ncl_path = base_path.with_extension("ncl");
if ncl_path.exists() && rustelo_config::is_nickel_available() {
match load_routes_from_ncl(&ncl_path) {
Ok(routes) => return Ok(routes),
Err(e) => {
eprintln!(
"Warning: NCL loading failed for {}, falling back to TOML: {}",
ncl_path.display(),
e
);
}
}
}
// Fallback to TOML
let toml_path = base_path.with_extension("toml");
if toml_path.exists() {
return load_routes_from_toml(&toml_path);
}
anyhow::bail!(
"No route configuration found at {} (.ncl or .toml)",
base_path.display()
)
}
/// Load all language route files from a directory
///
/// Scans a directory for route configuration files (both .ncl and .toml),
/// and loads them into a HashMap keyed by language code extracted from filename.
///
/// # File Naming Convention
///
/// Expected filename pattern: `{language}.{ncl|toml}`
///
/// Examples:
/// - `en.ncl` → language code "en"
/// - `es.toml` → language code "es"
/// - `en.ncl` + `en.toml` → prefers .ncl
///
/// # Returns
///
/// HashMap where:
/// - Key: Language code (e.g., "en", "es", "fr")
/// - Value: Vector of routes for that language
///
/// # Errors
///
/// Returns error if:
/// - Directory doesn't exist or can't be read
/// - Any route file fails to load (after trying both NCL and TOML)
///
/// # Examples
///
/// ```no_run
/// use build_config::route_config::load_all_routes;
/// use std::path::Path;
///
/// let routes_dir = Path::new("site/config/routes");
/// let all_routes = load_all_routes(routes_dir)?;
///
/// for (lang, routes) in &all_routes {
/// println!("Language: {}, Routes: {}", lang, routes.len());
/// }
/// # Ok::<(), Box<dyn std::error::Error>>(())
/// ```
pub fn load_all_routes(routes_dir: &Path) -> Result<HashMap<String, Vec<RouteConfigToml>>> {
let mut all_routes = HashMap::new();
if !routes_dir.exists() {
anyhow::bail!("Routes directory does not exist: {}", routes_dir.display());
}
// Scan directory for route files
let entries = fs::read_dir(routes_dir)
.with_context(|| format!("Failed to read routes directory: {}", routes_dir.display()))?;
let mut base_names = std::collections::HashSet::new();
// Collect all base names (without extension)
for entry in entries {
let entry = entry?;
let path = entry.path();
// Skip directories
if path.is_dir() {
continue;
}
// Check if it's a route file (.ncl or .toml)
if let Some(ext) = path.extension() {
if ext == "ncl" || ext == "toml" {
if let Some(base_name) = path.file_stem().and_then(|s| s.to_str()) {
base_names.insert(base_name.to_string());
}
}
}
}
// Load routes for each language (base name)
for lang in base_names {
let base_path = routes_dir.join(&lang);
match load_routes(&base_path) {
Ok(routes) => {
all_routes.insert(lang.clone(), routes);
}
Err(e) => {
eprintln!(
"Warning: Failed to load routes for language '{}': {}",
lang, e
);
// Continue loading other languages instead of failing completely
}
}
}
if all_routes.is_empty() {
anyhow::bail!(
"No route files found in directory: {}",
routes_dir.display()
);
}
Ok(all_routes)
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
#[test]
fn test_load_routes_requires_existing_file() {
let fake_path = PathBuf::from("/nonexistent/routes.toml");
let result = load_routes_from_toml(&fake_path);
assert!(result.is_err());
}
#[test]
fn test_auto_detection_prefers_ncl() {
// This test would require actual fixture files
// For now, just verify the function signature
let base_path = PathBuf::from("site/config/routes/en");
let _ = load_routes(&base_path);
}
}

View file

@ -0,0 +1,753 @@
//! Unified site configuration loader
//!
//! Loads `site/config/index.ncl` (NCL → JSON via `nickel export`) and deserializes it
//! into [`UnifiedSiteConfig`], the single source of truth for routes, menus,
//! footer, content types, and site metadata.
//!
//! # Fail-fast policy
//!
//! This module panics with an actionable message if `index.ncl` is absent or
//! the Nickel CLI is unavailable. No silent fallbacks.
//!
//! # Usage
//!
//! ```no_run
//! use build_config::site_config::load_unified_site_config;
//! use std::path::Path;
//!
//! let manifest_dir = Path::new("/path/to/website-impl");
//! let site = load_unified_site_config(manifest_dir).unwrap();
//!
//! let all_routes = site.routes_expanded(); // HashMap<lang, Vec<RouteConfigToml>>
//! let menu_toml = site.menu_toml_unified(); // raw TOML string for server embedding
//! let footer_en = site.menu_items_for_zone("footer", "en");
//! # Ok::<(), Box<dyn std::error::Error>>(())
//! ```
use anyhow::{Context, Result};
use rustelo_core_types::rendering::RenderingConfig;
use rustelo_core_types::routing::RouteConfigToml;
use serde::Deserialize;
use std::collections::HashMap;
use std::path::Path;
// Re-export for use by build scripts that depend on build_config
pub use rustelo_core_types::routing::RoutesConfig;
// ──────────────────────────────────────────────────────────────────────────────
// Public types — mirror the Nickel contracts in site/nickel/site/contracts.ncl
// ──────────────────────────────────────────────────────────────────────────────
#[derive(Debug, Clone, Deserialize)]
pub struct SiteMetadata {
pub name: String,
pub languages: Vec<String>,
pub default_language: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct SitePaths {
pub i18n: String,
pub content: String,
pub themes: String,
pub assets: String,
#[serde(default)]
pub migrations: Option<String>,
#[serde(default)]
pub email_templates: Option<String>,
#[serde(default)]
pub work: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct ThemeConfig {
pub default: String,
pub available: Vec<String>,
}
/// Shell asset manifest — CSS/JS paths for source and bundled modes.
/// Sourced from `site/config/assets.ncl`; generated into `shell_asset_paths.rs`.
#[derive(Debug, Clone, Default, Deserialize)]
pub struct SiteAssets {
#[serde(default)]
pub source_css: Vec<String>,
#[serde(default)]
pub bundled_css: Vec<String>,
#[serde(default)]
pub htmx_css: Vec<String>,
#[serde(default)]
pub source_js: Vec<String>,
#[serde(default)]
pub bundled_js: Vec<String>,
}
/// Content type definition from `content_types` in config.ncl.
/// `features` kept as a generic JSON value so any extra fields survive round-trips.
#[derive(Debug, Clone, Deserialize)]
pub struct ContentTypeConfig {
pub name: String,
pub directory: String,
#[serde(default = "default_enabled")]
pub enabled: bool,
pub features: serde_json::Value,
}
fn default_enabled() -> bool {
true
}
/// Database configuration from `database` in config.ncl.
#[derive(Debug, Clone, Deserialize)]
pub struct NclDatabaseConfig {
pub url: String,
#[serde(default = "ncl_db_default_create_if_missing")]
pub create_if_missing: bool,
#[serde(default = "ncl_db_default_max_connections")]
pub max_connections: u32,
#[serde(default = "ncl_db_default_min_connections")]
pub min_connections: u32,
#[serde(default = "ncl_db_default_connect_timeout")]
pub connect_timeout: u64,
#[serde(default = "ncl_db_default_idle_timeout")]
pub idle_timeout: u64,
#[serde(default = "ncl_db_default_max_lifetime")]
pub max_lifetime: u64,
}
fn ncl_db_default_create_if_missing() -> bool {
true
}
fn ncl_db_default_max_connections() -> u32 {
5
}
fn ncl_db_default_min_connections() -> u32 {
1
}
fn ncl_db_default_connect_timeout() -> u64 {
30
}
fn ncl_db_default_idle_timeout() -> u64 {
600
}
fn ncl_db_default_max_lifetime() -> u64 {
1800
}
/// Logo configuration from `site/config/index.ncl`.
#[derive(Debug, Clone, Deserialize)]
pub struct LogoMetadata {
pub light_src: String,
pub dark_src: String,
pub alt: String,
pub show_in_nav: bool,
pub show_in_footer: bool,
pub width: Option<String>,
pub height: Option<String>,
}
/// Footer metadata absorbed inline into `SiteConfig`.
#[derive(Debug, Clone, Deserialize)]
pub struct FooterMetadata {
pub title: String,
pub company_desc: HashMap<String, String>,
pub copyright_text: HashMap<String, String>,
#[serde(default)]
pub social_links: Vec<SocialLink>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct SocialLink {
pub name: String,
pub url: String,
pub icon: String,
}
/// Menu configuration embedded directly inside a [`UnifiedRoute`].
#[derive(Debug, Clone, Deserialize)]
pub struct MenuEntry {
#[serde(default)]
pub enabled: bool,
pub order: Option<i32>,
pub icon: Option<String>,
/// Primary: FTL key for the label (preferred over inline labels).
pub label_key: Option<String>,
/// Fallback: inline labels keyed by language code.
pub labels: Option<HashMap<String, String>>,
#[serde(default)]
pub visible_in: Vec<String>,
/// Navigation zones this item appears in: "header", "footer", or both.
#[serde(default = "default_use_in")]
pub use_in: Vec<String>,
}
fn default_use_in() -> Vec<String> {
vec!["header".to_string()]
}
/// A route covering all supported languages.
/// `paths` maps language code → URL path.
#[derive(Debug, Clone, Deserialize)]
pub struct UnifiedRoute {
pub component: String,
pub paths: HashMap<String, String>,
#[serde(default = "default_priority")]
pub priority: f32,
pub content_type: Option<String>,
pub menu: MenuEntry,
pub props: Option<serde_json::Value>,
}
fn default_priority() -> f32 {
0.8
}
/// Top-level unified site configuration.
#[derive(Debug, Clone, Deserialize)]
pub struct UnifiedSiteConfig {
pub site: SiteMetadata,
pub paths: SitePaths,
pub themes: ThemeConfig,
pub logo: LogoMetadata,
pub content_types: Vec<ContentTypeConfig>,
pub database: NclDatabaseConfig,
pub footer: FooterMetadata,
pub routes: Vec<UnifiedRoute>,
#[serde(default)]
pub assets: SiteAssets,
/// Rendering profile configuration (ADR-006). Absent when the workspace
/// has not added `site/config/rendering.ncl`; consumers should fall back to
/// [`RenderingConfig::default`] in that case.
#[serde(default)]
pub rendering: Option<RenderingConfig>,
}
// ──────────────────────────────────────────────────────────────────────────────
// Loader
// ──────────────────────────────────────────────────────────────────────────────
/// Load unified site configuration from `site/config/index.ncl`.
///
/// # Errors
/// Returns `Err` when:
/// - `index.ncl` does not exist at `manifest_dir/site/config/index.ncl`
/// - The Nickel CLI is not available
/// - NCL export or JSON deserialisation fails
pub fn load_unified_site_config(manifest_dir: &Path) -> Result<UnifiedSiteConfig> {
// Resolve config path: env var takes priority, relative paths are resolved
// against manifest_dir (workspace root) so build scripts work correctly.
let site_ncl = if let Ok(config_path) = std::env::var("SITE_CONFIG_PATH") {
let p = std::path::PathBuf::from(&config_path);
let resolved = if p.is_absolute() {
p
} else {
// Relative SITE_CONFIG_PATH → resolve against workspace root (manifest_dir).
// This is necessary because Cargo build scripts run with CWD = package dir,
// not the workspace root where the env var was intended to be relative to.
manifest_dir.join(&p)
};
if resolved.is_dir() { resolved.join("index.ncl") } else { resolved }
} else {
manifest_dir.join("site").join("config").join("index.ncl")
};
anyhow::ensure!(
site_ncl.exists(),
"Site configuration not found at {}.\n\
Create it or set SITE_CONFIG_PATH to point to your config file. This file is required.",
site_ncl.display()
);
anyhow::ensure!(
rustelo_config::is_nickel_available(),
"Nickel CLI not found in PATH.\n\
Install: cargo install nickel-lang-cli\n\
Or: https://nickel-lang.org/user-manual/installation"
);
let json = rustelo_config::nickel::export_to_json(&site_ncl)
.with_context(|| format!("Nickel export failed for {}", site_ncl.display()))?;
let config: UnifiedSiteConfig = serde_json::from_str(&json)
.with_context(|| format!("JSON deserialisation failed for {}", site_ncl.display()))?;
Ok(config)
}
// ──────────────────────────────────────────────────────────────────────────────
// Helper methods
// ──────────────────────────────────────────────────────────────────────────────
impl UnifiedSiteConfig {
/// Expand unified routes into per-language `RouteConfigToml` vectors.
pub fn routes_expanded(&self) -> HashMap<String, Vec<RouteConfigToml>> {
let mut result: HashMap<String, Vec<RouteConfigToml>> = HashMap::new();
for route in &self.routes {
let comp_lower = route.component.to_lowercase();
for (lang, path) in &route.paths {
let entry = result.entry(lang.clone()).or_default();
entry.push(RouteConfigToml {
path: path.clone(),
component: route.component.clone(),
language: lang.clone(),
enabled: true,
title_key: format!("{}-page-title", comp_lower),
description_key: Some(format!("{}-page-description", comp_lower)),
keywords: None,
priority: Some(route.priority),
menu_group: None,
menu_order: route.menu.order,
menu_icon: route.menu.icon.clone(),
is_external: Some(route.component == "ExternalLink"),
requires_auth: None,
show_in_sitemap: Some(true),
alternate_paths: None,
canonical_path: Some(path.clone()),
content_type: route.content_type.clone(),
rendering_profile: None,
template: None,
props: None,
unified_component: None,
lang_prefixes: None,
params_component: None,
});
}
}
result
}
/// Generate a unified menu TOML string for server embedding.
///
/// Produces `[[menu]]` TOML for a specific navigation zone and language.
///
/// Filters routes to those with `menu.enabled`, `use_in` containing `zone`,
/// and `visible_in` empty or containing `lang`. Labels include all language
/// variants so `Translations::get_for_language` works at runtime.
///
/// Used by WASM menu registry generation to produce per-language keyed entries
/// that `get_menu_resource("en")` can resolve without a filesystem fallback.
/// Check whether a path is denied for anonymous users by RBAC patterns.
/// Pattern semantics: `"/foo/*"` = prefix match, `"/foo"` = exact match.
fn path_requires_auth(path: &str, rbac_deny_patterns: &[String]) -> bool {
for pattern in rbac_deny_patterns {
if let Some(prefix) = pattern.strip_suffix("/*") {
if path == prefix || path.starts_with(&format!("{}/", prefix)) {
return true;
}
} else if path == pattern {
return true;
}
}
false
}
/// Generate menu TOML for `zone` + `lang`, annotating items with `auth_required`
/// derived from `rbac_deny_patterns`. Pass an empty slice to skip annotation.
pub fn menu_toml_for_lang_with_rbac(
&self,
zone: &str,
lang: &str,
rbac_deny_patterns: &[String],
) -> String {
self.menu_toml_for_lang_inner(zone, lang, rbac_deny_patterns)
}
pub fn menu_toml_for_lang(&self, zone: &str, lang: &str) -> String {
self.menu_toml_for_lang_inner(zone, lang, &[])
}
fn menu_toml_for_lang_inner(
&self,
zone: &str,
lang: &str,
rbac_deny_patterns: &[String],
) -> String {
let mut out = String::new();
let default_lang = &self.site.default_language;
let mut routes: Vec<&UnifiedRoute> = self
.routes
.iter()
.filter(|r| {
r.menu.enabled
&& r.menu.use_in.iter().any(|z| z == zone)
&& (r.menu.visible_in.is_empty() || r.menu.visible_in.iter().any(|l| l == lang))
&& r.paths.contains_key(lang)
})
.collect();
routes.sort_by_key(|r| (r.menu.order.unwrap_or(999), r.component.as_str()));
for route in routes {
let primary_path = route.paths[lang].clone();
let is_external = route.component == "ExternalLink";
let auth_req = Self::path_requires_auth(&primary_path, rbac_deny_patterns);
out.push_str("[[menu]]\n");
out.push_str(&format!("route = \"{}\"\n", primary_path));
out.push_str(&format!("is_external = {}\n", is_external));
if auth_req {
out.push_str("auth_required = true\n");
}
if let Some(icon) = &route.menu.icon {
out.push_str(&format!("icon = \"{}\"\n", icon));
}
if let Some(labels) = &route.menu.labels {
out.push_str("[menu.label]\n");
let mut sorted: Vec<(&String, &String)> = labels.iter().collect();
sorted.sort_by_key(|(k, _)| k.as_str());
for (l, text) in sorted {
out.push_str(&format!("{} = \"{}\"\n", l, text));
}
}
let alt_paths: Vec<(&String, &String)> = route
.paths
.iter()
.filter(|(l, p)| *l != default_lang && *p != &primary_path && *l != "external")
.collect();
if !alt_paths.is_empty() {
out.push_str("[menu.localized_routes]\n");
let mut sorted = alt_paths;
sorted.sort_by_key(|(k, _)| k.as_str());
for (l, p) in sorted {
if l.contains('-') {
out.push_str(&format!("\"{}\" = \"{}\"\n", l, p));
} else {
out.push_str(&format!("{} = \"{}\"\n", l, p));
}
}
}
out.push('\n');
}
out
}
/// Produces `[[menu]]` array-of-tables entries covering all languages,
/// with `[menu.label]` and `[menu.localized_routes]` sub-tables.
pub fn menu_toml_unified(&self) -> String {
let mut out = String::new();
let mut menu_routes: Vec<&UnifiedRoute> =
self.routes.iter().filter(|r| r.menu.enabled).collect();
menu_routes.sort_by_key(|r| (r.menu.order.unwrap_or(999), r.component.as_str()));
for route in menu_routes {
let primary_path = route
.paths
.get("en")
.or_else(|| route.paths.values().next())
.cloned()
.unwrap_or_default();
let is_external = route.component == "ExternalLink";
out.push_str("[[menu]]\n");
out.push_str(&format!("route = \"{}\"\n", primary_path));
out.push_str(&format!("is_external = {}\n", is_external));
if let Some(icon) = &route.menu.icon {
out.push_str(&format!("icon = \"{}\"\n", icon));
}
if !route.menu.visible_in.is_empty() {
let langs: Vec<String> = route
.menu
.visible_in
.iter()
.map(|l| format!("\"{}\"", l))
.collect();
out.push_str(&format!("visible_in = [{}]\n", langs.join(", ")));
}
if !route.menu.use_in.is_empty() {
let zones: Vec<String> = route
.menu
.use_in
.iter()
.map(|z| format!("\"{}\"", z))
.collect();
out.push_str(&format!("use_in = [{}]\n", zones.join(", ")));
}
if let Some(labels) = &route.menu.labels {
out.push_str("[menu.label]\n");
let mut sorted_labels: Vec<(&String, &String)> = labels.iter().collect();
sorted_labels.sort_by_key(|(k, _)| k.as_str());
for (lang, label) in sorted_labels {
out.push_str(&format!("{} = \"{}\"\n", lang, label));
}
}
let alt_paths: Vec<(&String, &String)> = route
.paths
.iter()
.filter(|(lang, path)| {
*lang != "en" && *path != &primary_path && *lang != "external"
})
.collect();
if !alt_paths.is_empty() {
out.push_str("[menu.localized_routes]\n");
let mut sorted_paths = alt_paths;
sorted_paths.sort_by_key(|(k, _)| k.as_str());
for (lang, path) in sorted_paths {
if lang.contains('-') {
out.push_str(&format!("\"{}\" = \"{}\"\n", lang, path));
} else {
out.push_str(&format!("{} = \"{}\"\n", lang, path));
}
}
}
out.push('\n');
}
out
}
/// Menu items visible in a specific navigation zone and language.
///
/// `zone` is one of `"header"` or `"footer"`.
pub fn menu_items_for_zone(&self, zone: &str, lang: &str) -> Vec<MenuItemFlat> {
let mut items: Vec<MenuItemFlat> = self
.routes
.iter()
.filter(|r| {
r.menu.enabled
&& r.menu.use_in.iter().any(|z| z == zone)
&& (r.menu.visible_in.is_empty() || r.menu.visible_in.iter().any(|l| l == lang))
&& r.paths.contains_key(lang)
})
.map(|r| MenuItemFlat {
route: r.paths[lang].clone(),
label: r
.menu
.labels
.as_ref()
.and_then(|ls| ls.get(lang))
.cloned()
.unwrap_or_else(|| r.component.clone()),
icon: r.menu.icon.clone(),
order: r.menu.order.unwrap_or(999),
is_external: r.component == "ExternalLink",
})
.collect();
items.sort_by_key(|i| i.order);
items
}
/// Convenience wrapper: menu items for the `"header"` zone.
pub fn menu_items_for_lang(&self, lang: &str) -> Vec<MenuItemFlat> {
self.menu_items_for_zone("header", lang)
}
/// Language codes declared in the site config.
pub fn languages(&self) -> &[String] {
&self.site.languages
}
/// Default language code.
pub fn default_language(&self) -> &str {
&self.site.default_language
}
}
/// Flat menu item for a single language — used by build.rs generators.
#[derive(Debug, Clone)]
pub struct MenuItemFlat {
pub route: String,
pub label: String,
pub icon: Option<String>,
pub order: i32,
pub is_external: bool,
}
// ──────────────────────────────────────────────────────────────────────────────
// Tests
// ──────────────────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
fn make_test_config() -> UnifiedSiteConfig {
UnifiedSiteConfig {
site: SiteMetadata {
name: "Test".into(),
languages: vec!["en".into(), "es".into()],
default_language: "en".into(),
},
paths: SitePaths {
i18n: "site/i18n".into(),
content: "site/content".into(),
themes: "site/config/themes".into(),
assets: "public".into(),
migrations: None,
email_templates: None,
work: None,
},
themes: ThemeConfig {
default: "default".into(),
available: vec!["default".into()],
},
logo: LogoMetadata {
light_src: "/images/logo.svg".into(),
dark_src: "/images/logo-dark.svg".into(),
alt: "Logo".into(),
show_in_nav: true,
show_in_footer: true,
width: None,
height: None,
},
database: NclDatabaseConfig {
url: "sqlite:data/test.db".into(),
create_if_missing: true,
max_connections: 5,
min_connections: 1,
connect_timeout: 30,
idle_timeout: 600,
max_lifetime: 1800,
},
assets: SiteAssets::default(),
content_types: vec![],
footer: FooterMetadata {
title: "Test Author".into(),
company_desc: HashMap::from([
("en".into(), "English desc".into()),
("es".into(), "Spanish desc".into()),
]),
copyright_text: HashMap::from([
("en".into(), "© 2024".into()),
("es".into(), "© 2024".into()),
]),
social_links: vec![],
},
routes: vec![
UnifiedRoute {
component: "HomePage".into(),
paths: HashMap::from([
("en".into(), "/".into()),
("es".into(), "/inicio".into()),
]),
priority: 1.0,
content_type: None,
menu: MenuEntry {
enabled: true,
order: Some(1),
icon: Some("home".into()),
label_key: None,
labels: Some(HashMap::from([
("en".into(), "Home".into()),
("es".into(), "Inicio".into()),
])),
visible_in: vec![],
use_in: vec!["header".into()],
},
props: None,
},
UnifiedRoute {
component: "PrivacyPage".into(),
paths: HashMap::from([
("en".into(), "/privacy".into()),
("es".into(), "/privacidad".into()),
]),
priority: 0.5,
content_type: None,
menu: MenuEntry {
enabled: true,
order: Some(1),
icon: None,
label_key: None,
labels: Some(HashMap::from([
("en".into(), "Privacy".into()),
("es".into(), "Privacidad".into()),
])),
visible_in: vec![],
use_in: vec!["footer".into()],
},
props: None,
},
],
rendering: None,
}
}
#[test]
fn routes_expanded_produces_correct_lang_map() {
let cfg = make_test_config();
let expanded = cfg.routes_expanded();
let en = expanded.get("en").expect("en routes");
let es = expanded.get("es").expect("es routes");
assert_eq!(en.len(), 2, "two EN routes");
assert_eq!(es.len(), 2, "two ES routes");
let home_en = en.iter().find(|r| r.path == "/").expect("home EN");
assert_eq!(home_en.component, "HomePage");
assert_eq!(home_en.language, "en");
assert_eq!(home_en.priority, Some(1.0));
}
#[test]
fn menu_toml_unified_contains_home_entry() {
let cfg = make_test_config();
let toml = cfg.menu_toml_unified();
assert!(toml.contains("[[menu]]"), "has menu array");
assert!(toml.contains("route = \"/\""), "has root path");
assert!(toml.contains("is_external = false"), "not external");
}
#[test]
fn menu_items_for_zone_header_excludes_footer_only() {
let cfg = make_test_config();
let header_items = cfg.menu_items_for_zone("header", "en");
let footer_items = cfg.menu_items_for_zone("footer", "en");
assert_eq!(header_items.len(), 1, "only home in header");
assert_eq!(footer_items.len(), 1, "only privacy in footer");
assert_eq!(header_items[0].route, "/");
assert_eq!(footer_items[0].route, "/privacy");
}
#[test]
fn menu_items_for_lang_filters_visibility() {
let mut cfg = make_test_config();
cfg.routes.push(UnifiedRoute {
component: "ExternalLink".into(),
paths: HashMap::from([("external".into(), "/kitdigital.html".into())]),
priority: 0.3,
content_type: None,
menu: MenuEntry {
enabled: true,
order: Some(100),
icon: None,
label_key: None,
labels: Some(HashMap::from([("es".into(), "Kit-Digital".into())])),
visible_in: vec!["es".into()],
use_in: vec!["header".into()],
},
props: None,
});
let en_items = cfg.menu_items_for_lang("en");
let es_items = cfg.menu_items_for_lang("es");
assert!(!en_items.iter().any(|i| i.route.contains("kitdigital")));
assert!(
es_items.iter().any(|i| i.is_external),
"ES menu has external item"
);
}
}

56
crates/client/Cargo.toml Normal file
View file

@ -0,0 +1,56 @@
[package]
name = "website-client"
version.workspace = true
authors.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
[dependencies]
leptos.workspace = true
leptos_meta.workspace = true
leptos_router.workspace = true
web-sys.workspace = true
wasm-bindgen.workspace = true
console_error_panic_hook.workspace = true
gloo-net.workspace = true
gloo-timers.workspace = true
futures.workspace = true
wasm-bindgen-futures.workspace = true
getrandom.workspace = true
serde.workspace = true
serde_json.workspace = true
# Rustelo framework ports (primary entry points)
rustelo_web = { workspace = true }
rustelo_client.workspace = true
# Foundation kept for build.rs generated code compatibility and csr feature
rustelo_core_lib.workspace = true
rustelo_pages_leptos.workspace = true
rustelo_components_leptos.workspace = true
website-pages = { path = "../pages" }
[build-dependencies]
build-config = { path = "../build-config" }
toml.workspace = true
serde.workspace = true
serde_json.workspace = true
tera.workspace = true
walkdir.workspace = true
sha2.workspace = true
rustelo_utils.workspace = true
rustelo_tools.workspace = true
[features]
default = ["csr"]
csr = ["leptos/csr", "rustelo_components_leptos/csr"]
hydrate = ["leptos/hydrate", "rustelo_components_leptos/hydrate"]
ssr = ["leptos/ssr", "rustelo_components_leptos/ssr"]
content-watcher = []
content-static = []
[lib]
name = "website_client"
path = "src/lib.rs"
crate-type = ["cdylib"]

1579
crates/client/build.rs Normal file

File diff suppressed because it is too large Load diff

422
crates/client/src/app.rs Normal file
View file

@ -0,0 +1,422 @@
//! Client-side App component with proper routing
//!
//! This module provides the main App component for the client that uses
//! the shared routing system and renders actual page components.
use leptos::prelude::*;
use leptos_meta::provide_meta_context;
use rustelo_components_leptos::theme::ThemeProvider;
use rustelo_web::rustelo_core_lib::{
i18n::{provide_unified_i18n, UnifiedI18n},
routing::utils::detect_language_from_path,
state::{AuthState, LanguageProvider},
PageProvider,
};
use rustelo_pages_leptos::NavLogProvider;
#[cfg(target_arch = "wasm32")]
use std::time::Duration;
// Import the local page provider implementation
use crate::page_provider::WebsitePageProvider;
/// Main App component for client-side that implements proper routing
#[component]
pub fn AppComponent(#[prop(default = String::new())] initial_path: String) -> impl IntoView {
// Provide meta context for client-side behavior
provide_meta_context();
// Setup navigation signals for client-side routing
let (path, set_path) = signal(initial_path.clone());
// Auth state — provided as context so child components can read/update it.
let auth_state = AuthState::new();
provide_context(auth_state.clone());
// Login modal visibility — provided so NavMenu auth controls can open it
let (show_login, set_show_login) = signal(false);
provide_context(set_show_login);
// Provide path signals as context — set_path for navigation, path for reading.
#[cfg(target_arch = "wasm32")]
provide_context(set_path);
#[cfg(target_arch = "wasm32")]
provide_context(path);
// Provide the RBAC deny-paths signal so all descendants (nav menu, SPA guard)
// can reactively filter protected routes. Seeded from compile-time AUTH_PATTERNS
// so SSR and the first WASM render produce identical output (hydration safe).
let deny_paths = crate::policy::init_policy_signal();
provide_context(deny_paths);
// Toast signal — created explicitly so it can be passed to the WS loop and
// provided as context for ToastContainer and any component using push_toast.
let toast_sig = RwSignal::new(Vec::<rustelo_components_leptos::ToastNotification>::new());
provide_context(toast_sig);
// Command palette open signal — shared between CommandPalette (renders the
// overlay) and FloatingPill (search button toggles it).
let palette_sig = RwSignal::new(false);
provide_context(rustelo_components_leptos::CommandPaletteOpen(palette_sig));
// Inject build-time application pages so the palette can navigate to project
// pages (Provisioning Systems, Vapora, Kogral, etc.) that have no menu entry.
provide_context(rustelo_components_leptos::CommandPaletteExtraItems(
crate::get_palette_app_pages(),
));
// Register signal in the WASM thread-local so push_toast_from_js (the
// browser-console export `rustelo_push_toast(kind, msg)`) can reach it
// without requiring a reactive context.
#[cfg(target_arch = "wasm32")]
rustelo_components_leptos::toast::register_toast_signal(toast_sig);
// Restore auth state from existing session cookie on WASM startup.
// Runs once per tab — if a valid session_token cookie exists the server
// validates it and returns UserInfo so the tab starts already authenticated.
#[cfg(target_arch = "wasm32")]
{
let auth_for_restore = auth_state.clone();
leptos::task::spawn_local(async move {
if let Ok(Some(user)) = rustelo_components_leptos::server_get_session_user().await {
auth_for_restore.set_user(user);
}
});
}
// Open WebSocket: handles RBAC hot-reload and JWT lifecycle events.
// auth_state is resolved inside connect_policy_ws via use_context before
// the first .await so the reactive owner is still valid.
#[cfg(target_arch = "wasm32")]
crate::policy::connect_policy_ws(deny_paths, toast_sig, set_path);
// Initialize meta config preloader early for WASM
#[cfg(target_arch = "wasm32")]
rustelo_core_lib::init_meta_preloader();
// Provide logo configuration from compile-time constants (site/config.ncl)
// NOTE: provide_context here matches shell.rs SSR provision — same type, same value
provide_context(crate::theme::load_logo_config());
// NOTE: Menu context is already provided by shell.rs during SSR
// Do NOT create a new signal here as it would override the server-provided context
// during hydration, causing menu items to be lost
// Use the LanguageProvider and setup effects inside it
view! {
<LanguageProvider>
<NavLogProvider>
<AppContent
initial_path=initial_path
path=path
set_path=set_path
show_login=show_login
set_show_login=set_show_login
/>
</NavLogProvider>
</LanguageProvider>
}
}
/// Inner app content that has access to the language context
#[component]
fn AppContent(
initial_path: String,
path: ReadSignal<String>,
set_path: WriteSignal<String>,
show_login: ReadSignal<bool>,
set_show_login: WriteSignal<bool>,
) -> impl IntoView {
// Now we can use the shared language context
let language_context = rustelo_core_lib::state::use_language();
// Set initial language from initial path once, before creating effects.
// Prefer the generated route table (config-driven) over path-prefix heuristics.
let initial_language = crate::routes::get_language_for_path(&initial_path)
.map(|s| s.to_string())
.unwrap_or_else(|| detect_language_from_path(&initial_path));
language_context.current.set(initial_language.clone());
// Reactively detect language from current path and update context.
// Only update when the route table returns an UNAMBIGUOUS language for the path.
// Paths shared across languages (e.g. "/blog" for both EN and ES) return None —
// in that case the current language is preserved, preventing spurious language resets.
Effect::new(move |_| {
let current_path = path.get();
if let Some(lang) = crate::routes::get_language_for_path(&current_path) {
let detected = lang.to_string();
if language_context.current.get_untracked() != detected {
#[cfg(target_arch = "wasm32")]
web_sys::console::log_1(
&format!(
"🔄 App Path Change: path='{}' → language='{}'",
current_path, detected
)
.into(),
);
language_context.current.set(detected);
}
}
// If get_language_for_path returns None → language-neutral path → preserve current language
});
// Create and provide unified i18n context that updates with language changes
let unified_i18n = Memo::new(move |_| {
let current_lang = language_context.current.get();
let current_path = path.get();
#[cfg(target_arch = "wasm32")]
web_sys::console::log_1(
&format!(
"🔄 App i18n Memo: language='{}', path='{}'",
current_lang, current_path
)
.into(),
);
UnifiedI18n::new(&current_lang, &current_path)
});
// Provide the reactive i18n context
Effect::new(move |_| {
let i18n = unified_i18n.get();
provide_unified_i18n(i18n);
});
// Setup page transition effects - signal created outside CFG block to avoid hydration mismatch
let (is_first_render, set_is_first_render) = signal(true);
Effect::new(move |_| {
let current_path = path.get();
#[cfg(target_arch = "wasm32")]
web_sys::console::log_1(
&format!(
"🎬 App transition Effect: path='{}', first_render={}",
current_path,
is_first_render.get()
)
.into(),
);
// Only apply transitions and dispatch events on client-side
#[cfg(target_arch = "wasm32")]
{
if is_first_render.get() {
set_is_first_render.set(false);
} else {
apply_page_transition();
}
// Dispatch custom event to reinitialize components
set_timeout(
move || {
web_sys::console::log_1(
&format!(
"⏰ Dispatching reinitializeComponents event after 200ms for path: {}",
current_path
)
.into(),
);
if let Some(window) = web_sys::window() {
if let Ok(event) = web_sys::CustomEvent::new("reinitializeComponents") {
let _ = window.dispatch_event(&event);
}
}
},
Duration::from_millis(200),
);
}
});
// Extract the language signal (RwSignal is Copy)
let lang_signal = language_context.current;
// Capture auth state for the client-side route guard below.
// Provided by AppComponent via provide_context(auth_state).
let auth_state = use_context::<AuthState>();
// Register the 401 unauthorized handler once at startup (WASM only).
// The framework fetch layer calls on_unauthorized() on any HTTP 401 response.
// This handler owns the reactive context — the framework layer knows nothing about AuthState.
// When rbac.ncl is hot-reloaded on the server, any subsequent request that returns 401
// will immediately clear auth state and redirect, making the server the single authority.
#[cfg(target_arch = "wasm32")]
{
let auth_for_401 = auth_state.clone();
// WriteSignal<String> is Copy — captured directly without Arc.
rustelo_core_lib::utils::fetch::register_unauthorized_handler(move || {
// Clear local auth state — server has rejected the session.
if let Some(ref auth) = auth_for_401 {
auth.clear_auth();
}
// Redirect to login, preserving the current path for post-login return.
let current_path = rustelo_core_lib::utils::fetch::current_location_path();
rustelo_core_lib::utils::nav::anchor_navigate(
set_path,
&format!("/login?return={}", current_path),
);
});
}
// Extract the user signal (RwSignal<T> is Copy) so the DynChild closure below
// captures a Copy type. Option<AuthState> is Clone-only; capturing it by move
// inside the {move || …} DynChild makes the outer <div> wrapper FnOnce, which
// Leptos rejects (it requires Fn).
let auth_user_signal = auth_state.as_ref().map(|a| a.user);
// Deny-paths signal provided by AppComponent — updated via WebSocket on rbac.ncl reload.
// RwSignal<Vec<String>> is Copy so it can be captured by the move closure below.
let deny_paths_signal = use_context::<RwSignal<Vec<String>>>();
// Derive privacy policy page path from config-driven route lookup.
// get_route("en", "privacy") → "/privacy", ("es", "privacy") → "/privacidad"
// &'static str is Copy — no move into the view! closure → closure stays Fn.
let cookie_policy_path: &'static str = crate::routes::get_route(
lang_signal.get_untracked().as_str(),
"privacy",
)
.unwrap_or("/privacy");
// Use the client routing with actual page components.
// NavMenu and Footer are rendered statically (no reactive wrapper) so that
// Leptos emits no DynChild comment-marker nodes, matching the server's shell.rs
// which also renders them directly. Language reactivity for labels is handled
// internally by UnifiedNavMenu/Footer via use_current_language().
view! {
<ThemeProvider>
<div class="min-h-screen ds-bg-page flex flex-col">
<rustelo_components_leptos::navigation::NavMenu
language=lang_signal.get_untracked()
_set_path=set_path
_path=path
set_show_login=Some(set_show_login)
/>
<main class="max-w-7xl mx-auto py-2 sm:ds-container flex-grow page-content fade-out">
{
move || {
let current_path = path.get();
let current_lang = lang_signal.get();
#[cfg(target_arch = "wasm32")]
web_sys::console::log_1(&format!("🎬 App main content render: path='{}', language='{}' (content re-rendering)", current_path, current_lang).into());
// Client-side auth guard — uses the runtime deny-paths signal
// so it reacts to rbac.ncl hot-reloads without a WASM rebuild.
// SSR is protected by the RBAC middleware; this guard covers
// SPA navigation where the server is never consulted.
#[cfg(target_arch = "wasm32")]
{
let deny_paths = deny_paths_signal
.map(|s| s.get())
.unwrap_or_default();
if crate::policy::requires_auth_runtime(&current_path, &deny_paths) {
let is_authed = auth_user_signal
.map(|sig| sig.get_untracked().is_some())
.unwrap_or(false);
if !is_authed {
set_path.set(format!("/login?return={}", current_path));
return view! { <div></div> }.into_any();
}
}
}
render_website_page(&current_path, &current_lang)
}
}
</main>
<rustelo_components_leptos::navigation::Footer
language=lang_signal.get_untracked()
_set_path=set_path
_path=path
/>
// Auth login modal — WASM only, renders nothing in SSR
<rustelo_components_leptos::LoginModal show=show_login set_show=set_show_login />
// Cookie consent banner — WASM only, renders nothing in SSR
<rustelo_components_leptos::CookieBanner policy_page=cookie_policy_path.to_string() detail_mode="page".to_string() />
<rustelo_components_leptos::ToastContainer />
// Keyboard command palette — Cmd+K / Ctrl+K, WASM only
<rustelo_components_leptos::CommandPalette />
// Scroll-aware floating pill — appears after 80px scroll, WASM only
<rustelo_components_leptos::FloatingPill />
</div>
</ThemeProvider>
}
}
/// Render website page using the local PageProvider
fn render_website_page(_path: &str, language: &str) -> AnyView {
let provider = WebsitePageProvider;
let lang = language.to_string();
// Resolve component name from generated route table (config-driven, PAP-compliant).
// Strip "Page" suffix to match the page_provider_impl.rs match keys (e.g. "Home" not "HomePage"),
// mirroring the server's server_routing.rs which also strips the suffix.
let component_name = crate::routes::get_component_for_path(_path)
.unwrap_or("NotFound")
.trim_end_matches("Page")
.to_string();
// Populate props from the route config — content_type drives ContentIndex/PostViewer/etc.
let mut props = std::collections::HashMap::new();
if let Some(ct) = crate::routes::get_content_type_for_path(_path) {
props.insert("content_type".to_string(), ct.to_string());
}
// Extract parametric path segments for PostViewer (slug = last, category = second-to-last).
let segs: Vec<&str> = _path.split('/').filter(|s| !s.is_empty()).collect();
if segs.len() >= 2 {
props.insert(
"slug".to_string(),
segs.last().copied().unwrap_or("").to_string(),
);
props.insert(
"category".to_string(),
segs.get(segs.len().saturating_sub(2))
.copied()
.unwrap_or("")
.to_string(),
);
}
match provider.render_component(&component_name, props, &lang) {
Ok(view) => view,
Err(_) => {
// Fallback to NotFound if component rendering fails
let not_found = "NotFound".to_string();
match provider.render_component(&not_found, std::collections::HashMap::new(), &lang) {
Ok(view) => view,
Err(_) => view! { <div>"Error rendering page"</div> }.into_any(),
}
}
}
}
/// Apply fade transition to page content
#[cfg(target_arch = "wasm32")]
fn apply_page_transition() {
if let Some(document) = web_sys::window().and_then(|w| w.document()) {
if let Ok(main_element) = document.query_selector("main.page-content") {
if let Some(element) = main_element {
let _ = element.class_list().add_1("fade-out");
let element_clone = element.clone();
set_timeout(
move || {
let _ = element_clone.class_list().remove_1("fade-out");
let _ = element_clone.class_list().add_1("fade-in");
},
std::time::Duration::from_millis(150),
);
let element_clone2 = element.clone();
set_timeout(
move || {
let _ = element_clone2.class_list().remove_1("fade-in");
},
std::time::Duration::from_millis(300),
);
}
}
}
}

121
crates/client/src/lib.rs Normal file
View file

@ -0,0 +1,121 @@
//! Website client implementation
//!
//! This crate provides the WASM-compiled client-side functionality for the website.
//! It integrates with the Rustelo framework to provide reactive, SSR-compatible
//! web components with automatic hydration.
#![allow(unused_variables, dead_code)]
#![recursion_limit = "256"]
pub mod app;
pub mod page_provider;
pub mod policy;
pub mod theme;
// Re-export the main App component for server-side use
pub use app::AppComponent;
use leptos::prelude::*;
// All generated artifacts are listed in the aggregator produced by build.rs.
// This file is the single coupling point between lib.rs and the build output.
include!(concat!(env!("OUT_DIR"), "/generated_includes.rs"));
/// Initialize WASM resources to ensure consistency with server rendering
/// This populates registries with embedded configuration to avoid
/// hydration mismatches caused by different registry state on server vs client
fn initialize_wasm_resources() {
use rustelo_core_lib::registration::{MENU_REGISTRY, THEME_REGISTRY};
// Populate theme registry if not already present.
// THEME_TOML is generated by client/build.rs from site/config/index.ncl [logo].
if let Ok(registry) = THEME_REGISTRY.read() {
if registry.get("default").is_none() {
drop(registry); // Release read lock
if let Ok(mut registry) = THEME_REGISTRY.write() {
let _ = registry.insert("default".to_string(), crate::theme::THEME_TOML.to_string());
}
}
}
// Populate menu registries with compile-time embedded TOML content.
// The generated function (from menu_registry_fn.rs) mirrors what
// WebsiteResourceContributor does on the server, ensuring NavMenuClient
// reads the same menu items during hydration.
if let Ok(registry) = MENU_REGISTRY.read() {
if registry.is_empty() {
drop(registry); // Release read lock before write
populate_wasm_menu_registry();
}
}
// Populate footer registry with compile-time embedded TOML content.
use rustelo_core_lib::registration::FOOTER_REGISTRY;
if let Ok(registry) = FOOTER_REGISTRY.read() {
if registry.is_empty() {
drop(registry);
populate_wasm_footer_registry();
}
}
// Populate FTL registry with compile-time embedded translations for all languages.
// This enables get_parsed_ftl_for_language() to work in WASM, so t_for_language()
// returns the correct translations when switching language — not just the language
// baked into the SSR HTML's <script id="i18n-data">.
use rustelo_core_lib::registration::FTL_REGISTRY;
if let Ok(registry) = FTL_REGISTRY.read() {
if registry.is_empty() {
drop(registry);
populate_wasm_ftl_registry();
}
}
// Register the build-time content URL so get_server_content_url() returns
// the correct value in WASM (env vars are always absent in the browser).
// config::CONTENT_URL is generated from rustelo.manifest.toml [deployment].content_url.
rustelo_core_lib::routing::generated_access::register_server_content_url(config::CONTENT_URL);
// Seed the route cache with build-time data so get_localized_route() works
// without filesystem access. Must run before any routing call.
crate::routes::seed_routes_cache();
}
/// WASM entry point with auto-start
#[wasm_bindgen::prelude::wasm_bindgen(start)]
pub fn main() {
use leptos::prelude::*;
web_sys::console::log_1(&"✅ Starting hydration...".into());
// Initialize WASM resources before hydration
initialize_wasm_resources();
// Initialize page provider for dynamic component rendering
let _ = page_provider::initialize();
// Detect current path so language is correct on initial hydration
let initial_path = web_sys::window()
.and_then(|w| w.location().pathname().ok())
.unwrap_or_default();
// Hydrate the server-rendered HTML with client-side interactivity
leptos::mount::hydrate_body(move || {
view! { <crate::app::AppComponent initial_path=initial_path.clone() /> }
});
web_sys::console::log_1(&"✅ Hydration complete - application interactive".into());
}
/// Hydrate server-rendered HTML with client interactivity
fn hydrate_app() {
use leptos::prelude::*;
web_sys::console::log_1(&"✅ Starting hydration...".into());
leptos::mount::hydrate_body(|| {
view! { <crate::app::AppComponent /> }
});
web_sys::console::log_1(&"✅ Hydration complete - application interactive".into());
}

View file

@ -0,0 +1,31 @@
//! Page provider and initialization for the client
//!
//! This module handles the initialization of the page provider at WASM startup.
//! The actual PageProvider implementation is generated by build.rs in page_provider_impl.rs
// Include the generated PageProvider implementation
include!(concat!(env!("OUT_DIR"), "/page_provider_impl.rs"));
/// Initialize page provider and register with the framework
///
/// This function is called at WASM startup to ensure the PageProvider is available.
/// In the current architecture (Level 5), pages are provided directly by WebsitePageProvider.
/// Future versions could use PAGE_REGISTRY for more advanced plugin support (Level 8).
///
/// # Returns
///
/// Always returns Ok(()) as there's nothing to fail in the current implementation
pub fn initialize() -> Result<(), Box<dyn std::error::Error>> {
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_page_provider_exists() {
let _provider = WebsitePageProvider;
// Just verify it can be instantiated
}
}

202
crates/client/src/policy.rs Normal file
View file

@ -0,0 +1,202 @@
/// Runtime RBAC policy — delivered entirely via WebSocket from the server.
///
/// `AUTH_PATTERNS` is always `&[]` at compile time; no RBAC state is baked into
/// the WASM binary. On startup `init_policy_signal` creates an empty signal.
/// `connect_policy_ws` then opens `/api/ws`; the server sends the current
/// `anonymous_deny_paths` immediately on connect and re-sends on every
/// `rbac.ncl` hot-reload. All reactive consumers (menu filter, SPA guard)
/// update in-place without a WASM rebuild.
///
/// The same WebSocket also carries JWT lifecycle events (`TokenRefreshRequested`,
/// `SessionRevoked`, `NewDeviceLogin`, `AccountUpdated`, `SecurityAlert`) so the
/// server can drive client-side auth state transitions without polling.
///
/// Security boundary: the server's HTTP middleware enforces RBAC on every request.
/// The WASM signal is a UX layer only — it hides menu items and prevents SPA
/// navigation to restricted paths, but the server will reject any unauthorised
/// request regardless of client state.
use leptos::prelude::*;
/// Initialise the deny-paths signal from compile-time `AUTH_PATTERNS`.
///
/// Call this once inside `AppComponent` before providing it as context.
/// Both SSR and WASM call this; only WASM will subsequently connect the WebSocket.
pub fn init_policy_signal() -> RwSignal<Vec<String>> {
let initial: Vec<String> = crate::routes::AUTH_PATTERNS
.iter()
.map(|s| s.to_string())
.collect();
RwSignal::new(initial)
}
/// Connect to `/api/ws` and update reactive state whenever the server broadcasts
/// policy or JWT lifecycle messages. Reconnects automatically on disconnect.
///
/// `auth_state` and `toast_sig` are resolved from context here — before the first
/// `.await` — so they remain usable inside the async loop without requiring
/// `use_context` across async boundaries.
///
/// No-op on any non-WASM target.
#[cfg(target_arch = "wasm32")]
pub fn connect_policy_ws(
deny_paths: RwSignal<Vec<String>>,
toast_sig: RwSignal<Vec<rustelo_components_leptos::toast::ToastNotification>>,
set_path: WriteSignal<String>,
) {
use futures::StreamExt;
use gloo_net::websocket::{futures::WebSocket, Message};
use rustelo_components_leptos::toast::{push_toast_to, ToastKind};
// Capture auth state synchronously (before any .await) — the reactive owner
// is still the component's owner at this point.
let auth_state = use_context::<rustelo_core_lib::state::AuthState>();
// Local mirror of the relevant server-side `ContentNotification` variants.
// `#[serde(other)]` silently absorbs all unknown variants.
#[derive(serde::Deserialize)]
#[serde(tag = "type", content = "data")]
enum PolicyMsg {
RbacPolicyUpdated {
anonymous_deny_paths: Vec<String>,
},
TokenRefreshRequested,
SessionRevoked {
reason: String,
},
NewDeviceLogin {
session_id: String,
},
AccountUpdated {
field: String,
},
SecurityAlert {
message: String,
},
ToastBroadcast {
kind: String,
message: String,
},
#[serde(other)]
Other,
}
leptos::task::spawn_local(async move {
loop {
let url = ws_url("/api/ws");
let ws = match WebSocket::open(&url) {
Ok(ws) => ws,
Err(e) => {
web_sys::console::warn_1(
&format!("policy WS: open failed for {}: {:?}", url, e).into(),
);
reconnect_delay().await;
continue;
}
};
let (_, mut read) = ws.split();
while let Some(msg) = read.next().await {
match msg {
Ok(Message::Text(text)) => {
match serde_json::from_str::<PolicyMsg>(&text) {
Ok(PolicyMsg::RbacPolicyUpdated { anonymous_deny_paths }) => {
deny_paths.set(anonymous_deny_paths);
}
Ok(PolicyMsg::TokenRefreshRequested) => {
leptos::task::spawn_local(async {
let _ = rustelo_components_leptos::server_refresh_token().await;
});
}
Ok(PolicyMsg::SessionRevoked { .. }) => {
if let Some(ref auth) = auth_state {
auth.clear_auth();
}
push_toast_to(
toast_sig,
ToastKind::Error,
"Your session was revoked. Please log in again.",
);
let current = web_sys::window()
.and_then(|w| w.location().pathname().ok())
.unwrap_or_default();
rustelo_core_lib::utils::nav::anchor_navigate(
set_path,
&format!("/login?return={}", current),
);
}
Ok(PolicyMsg::NewDeviceLogin { .. }) => {
push_toast_to(
toast_sig,
ToastKind::Warning,
"New login detected from another device.",
);
}
Ok(PolicyMsg::AccountUpdated { field }) => {
push_toast_to(
toast_sig,
ToastKind::Info,
&format!("Your {} was updated on another device.", field),
);
}
Ok(PolicyMsg::SecurityAlert { message }) => {
push_toast_to(toast_sig, ToastKind::Warning, &message);
}
Ok(PolicyMsg::ToastBroadcast { kind, message }) => {
let k = match kind.as_str() {
"error" => ToastKind::Error,
"warning" => ToastKind::Warning,
_ => ToastKind::Info,
};
push_toast_to(toast_sig, k, &message);
}
Ok(PolicyMsg::Other) | Err(_) => {}
}
}
Err(e) => {
web_sys::console::warn_1(&format!("policy WS: error {:?}", e).into());
break;
}
_ => {}
}
}
reconnect_delay().await;
}
});
}
/// Returns true if `path` is covered by any entry in `deny_paths`.
///
/// Pattern semantics mirror `build_config::rbac_config::load_rbac_anonymous_deny_patterns`:
/// - `"/admin/*"` matches `"/admin"` and any sub-path like `"/admin/users"`
/// - `"/login"` matches only the exact path `"/login"`
pub fn requires_auth_runtime(path: &str, deny_paths: &[String]) -> bool {
deny_paths.iter().any(|pattern| {
if let Some(prefix) = pattern.strip_suffix("/*") {
path == prefix || path.starts_with(&format!("{}/", prefix))
} else {
path == pattern
}
})
}
/// Build the WebSocket URL from the current page origin.
#[cfg(target_arch = "wasm32")]
fn ws_url(path: &str) -> String {
let host = web_sys::window()
.and_then(|w| w.location().host().ok())
.unwrap_or_default();
let protocol = web_sys::window()
.and_then(|w| w.location().protocol().ok())
.map(|p| if p == "https:" { "wss" } else { "ws" })
.unwrap_or("ws");
format!("{}://{}{}", protocol, host, path)
}
/// 3-second reconnect delay using `gloo_timers`.
#[cfg(target_arch = "wasm32")]
async fn reconnect_delay() {
gloo_timers::future::TimeoutFuture::new(3_000).await;
}

View file

@ -0,0 +1,33 @@
//! Client-side theme configuration using embedded constants
//!
//! !This module provides access to theme configuration embedded at build time
//! for WASM clients, allowing them to access theme settings without filesystem access.
use rustelo_web::rustelo_core_lib::defs::LogoConfig;
// Include the generated theme constants (defines inner `pub mod theme { ... }`).
// Re-export the constants used by lib.rs so `crate::theme::THEME_TOML` resolves.
include!(concat!(env!("OUT_DIR"), "/theme_constants.rs"));
pub use theme::THEME_TOML;
pub use theme::DEFAULT_THEME_NAME;
/// Build logo configuration from constants generated from site/config.ncl.
/// `href` is intentionally omitted — home URL is language-dependent and
/// must be derived at runtime via `localize_url("/", &lang)`.
pub fn load_logo_config() -> LogoConfig {
LogoConfig {
light_src: theme::logo::LIGHT_SRC.to_string(),
dark_src: theme::logo::DARK_SRC.to_string(),
alt: theme::logo::ALT.to_string(),
href: String::new(), // resolved per-language at render time
show_in_nav: theme::logo::SHOW_IN_NAV,
show_in_footer: theme::logo::SHOW_IN_FOOTER,
width: theme::logo::WIDTH.map(|w| w.to_string()),
height: theme::logo::HEIGHT.map(|h| h.to_string()),
}
}
// Get the default theme name
pub fn default_theme_name() -> &'static str {
theme::DEFAULT_THEME_NAME
}

42
crates/pages/Cargo.toml Normal file
View file

@ -0,0 +1,42 @@
[package]
name = "website-pages"
version.workspace = true
authors.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
[dependencies]
leptos.workspace = true
leptos_meta.workspace = true
serde.workspace = true
serde_json.workspace = true
# Rustelo pages via framework port
rustelo_content = { workspace = true }
rustelo_pages_leptos.workspace = true
rustelo_components_leptos.workspace = true
# WASM interop for GraphViewClient
[target.'cfg(target_arch = "wasm32")'.dependencies]
wasm-bindgen = { workspace = true }
js-sys = { workspace = true }
web-sys = { workspace = true }
[build-dependencies]
build-config = { path = "../build-config" }
serde.workspace = true
serde_json.workspace = true
toml.workspace = true
walkdir.workspace = true
sha2.workspace = true
tera.workspace = true
# Use new PAP-compliant manifest system
rustelo_utils.workspace = true
rustelo_tools.workspace = true
content-graph.workspace = true
[lib]
name = "website_pages"
path = "src/lib.rs"

131
crates/pages/build.rs Normal file
View file

@ -0,0 +1,131 @@
//! Pages build script - Using Provider pattern with Rustelo foundation traits
//!
//! This build script uses the Provider pattern with dependency injection
//! instead of direct function calls. Follows PAP principles completely.
mod build_page_generator;
fn main() -> Result<(), Box<dyn std::error::Error>> {
// Use new PAP-compliant manifest system
let manifest = rustelo_utils::ManifestResolver::load()?;
let content_path = rustelo_utils::manifest_content_path();
let config_path = rustelo_utils::manifest_config_path();
println!("cargo:rerun-if-changed={}", config_path.display());
println!("cargo:rerun-if-changed=src/");
println!("cargo:rerun-if-changed={}", content_path.display());
println!("cargo:rerun-if-env-changed=ONTOREF_NICKEL_IMPORT_PATH");
// Generate page components using rustelo_tools SmartCache
build_page_generator::generate_page_components(manifest.root())?;
// Generate content graph static maps (related_map.rs, ontology_context_map.rs,
// graph_mini_map.rs, content_graph.json) for the content_graph page module.
generate_content_graph(manifest.root())?;
// Generate pages documentation using rustelo_tools
generate_pages_documentation(&manifest)?;
println!("cargo:warning=Pages build completed with smart caching and documentation");
Ok(())
}
/// Run the content-graph build pipeline, writing generated files into OUT_DIR.
///
/// Requires `ONTOREF_NICKEL_IMPORT_PATH` to be set in the environment —
/// this is the only path that cannot be derived from the workspace layout.
/// All other paths default to conventional locations relative to the workspace root.
fn generate_content_graph(
workspace_root: &std::path::Path,
) -> Result<(), Box<dyn std::error::Error>> {
use content_graph::build::{codegen, graph_generator::GraphGeneratorConfig};
let framework_root = workspace_root
.join("..")
.join("rustelo")
.canonicalize()
.unwrap_or_else(|_| workspace_root.join("..").join("rustelo"));
// ONTOREF_NICKEL_IMPORT_PATH must be set externally (points to /path/to/ontoref/ontology).
// If absent, skip graph generation gracefully so builds without ontoref still succeed.
let ontoref_path = match std::env::var("ONTOREF_NICKEL_IMPORT_PATH") {
Ok(p) => std::path::PathBuf::from(p),
Err(_) => {
println!("cargo:warning=content-graph: ONTOREF_NICKEL_IMPORT_PATH not set — skipping graph generation. Set it to enable the content graph.");
return write_empty_graph_outputs();
}
};
let config = GraphGeneratorConfig::with_root(workspace_root, &framework_root, &ontoref_path);
println!("cargo:rerun-if-changed={}", config.content_dir.display());
println!("cargo:rerun-if-changed={}", config.impl_ontology.display());
match content_graph::build::graph_generator::generate(&config) {
Ok(graph) => {
let out_dir = std::path::PathBuf::from(std::env::var("OUT_DIR")?);
codegen::write_outputs(&graph, &out_dir)?;
println!(
"cargo:warning=content-graph: generated {} nodes, {} edges",
graph.nodes.len(),
graph.edges.len()
);
}
Err(e) => {
println!("cargo:warning=content-graph: generation failed: {e} — writing empty outputs");
write_empty_graph_outputs()?;
}
}
Ok(())
}
/// Write empty (but valid) graph output files so the content_graph module compiles
/// even when graph generation is skipped or fails.
fn write_empty_graph_outputs() -> Result<(), Box<dyn std::error::Error>> {
let out_dir = std::path::PathBuf::from(std::env::var("OUT_DIR")?);
std::fs::write(
out_dir.join("content_graph.json"),
r#"{"nodes":[],"edges":[]}"#,
)?;
std::fs::write(
out_dir.join("related_map.rs"),
"pub struct RelatedNode { pub id: &'static str, pub title: &'static str, pub kind: &'static str, pub url: &'static str }\n\
pub static RELATED_NODES: &[(&str, &[RelatedNode])] = &[];\n",
)?;
std::fs::write(
out_dir.join("ontology_context_map.rs"),
"pub struct OntologyRef { pub id: &'static str, pub name: &'static str, pub kind: &'static str }\n\
pub static ONTOLOGY_CONTEXT: &[(&str, &[OntologyRef])] = &[];\n",
)?;
std::fs::write(
out_dir.join("graph_mini_map.rs"),
"pub static GRAPH_MINI_SVGS: &[(&str, &str)] = &[];\n",
)?;
Ok(())
}
/// Generate pages documentation using rustelo_tools comprehensive_analysis
fn generate_pages_documentation(
manifest: &rustelo_utils::ManifestResolver,
) -> Result<(), Box<dyn std::error::Error>> {
let project_root = manifest.root();
let info_path = project_root.join("site").join("info");
// Create info directory if it doesn't exist
std::fs::create_dir_all(&info_path)?;
// Generate pages documentation
rustelo_tools::comprehensive_analysis::generate_pages_documentation(
&project_root.to_string_lossy(),
&rustelo_utils::manifest::content_path().to_string_lossy(),
&info_path.to_string_lossy(),
)?;
println!("cargo:warning=Generated pages documentation in site/info");
Ok(())
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,168 @@
use leptos::prelude::*;
use super::generated::mini::GRAPH_MINI_SVGS;
/// Inline ego-network SVG thumbnail with click-to-expand overlay.
///
/// Preview renders via SSR. On click (WASM), a fullscreen overlay is created
/// directly on `document.body` — the same DOM-injection pattern used by
/// `ImageModal` — bypassing CSS stacking-context traps in ancestor containers.
#[component]
pub fn GraphMini(
node_id: String,
#[prop(optional)] class: Option<String>,
) -> impl IntoView {
let svg: Option<&'static str> = GRAPH_MINI_SVGS
.iter()
.find(|(id, _)| *id == node_id.as_str())
.map(|(_, svg)| *svg);
let wrapper_class = format!(
"content-graph-mini-wrapper {}",
class.unwrap_or_default()
);
svg.map(|svg| view! {
<div class=wrapper_class>
<div
class="content-graph-mini-preview"
on:click=move |_| {
#[cfg(target_arch = "wasm32")]
open_graph_overlay(svg);
}
on:keydown=move |e: leptos::ev::KeyboardEvent| {
if e.key() == "Enter" || e.key() == " " {
#[cfg(target_arch = "wasm32")]
open_graph_overlay(svg);
}
}
role="button"
tabindex="0"
aria-label="Expand knowledge graph"
>
<div aria-hidden="true" inner_html=svg />
</div>
</div>
})
}
/// Creates a fullscreen overlay on `document.body` with the ego SVG.
/// Completely bypasses any CSS stacking context from ancestor containers.
/// Detects current `data-theme` to set correct label/background colors.
#[cfg(target_arch = "wasm32")]
fn open_graph_overlay(svg: &'static str) {
use wasm_bindgen::JsCast;
use wasm_bindgen::closure::Closure;
let Some(document) = web_sys::window().and_then(|w| w.document()) else {
return;
};
let Some(body) = document.body() else { return };
let is_dark = document
.document_element()
.and_then(|el| el.get_attribute("data-theme"))
.is_some_and(|t| t == "dark");
let (bg, label_color, edge_color, close_bg) = if is_dark {
("#1d232a", "#d1d5db", "#4b5563", "rgba(255,255,255,0.12)")
} else {
("#ffffff", "#374151", "#d1d5db", "rgba(0,0,0,0.08)")
};
let overlay = document.create_element("div").expect("create overlay");
overlay
.set_attribute(
"style",
"position:fixed;inset:0;z-index:9999;display:flex;align-items:center;\
justify-content:center;background:rgba(0,0,0,0.72);padding:1rem;\
cursor:zoom-out",
)
.ok();
overlay.set_attribute("role", "dialog").ok();
overlay.set_attribute("aria-modal", "true").ok();
let card = document.create_element("div").expect("create card");
card.set_attribute(
"style",
&format!(
"position:relative;background:{bg};border-radius:0.75rem;padding:1.5rem;\
width:min(90vw,480px);max-height:90vh;overflow-y:auto;\
box-shadow:0 20px 60px rgba(0,0,0,0.4);cursor:default;\
--cg-label:{label_color};--cg-edge:{edge_color}"
),
)
.ok();
// SVG container — fills card width for readability
let svg_div = document.create_element("div").expect("create svg_div");
svg_div.set_inner_html(svg);
if let Some(svg_el) = svg_div.first_element_child() {
svg_el
.set_attribute("style", "width:100%;height:auto;display:block")
.ok();
}
let close_btn = document.create_element("button").expect("create close btn");
close_btn
.set_attribute(
"style",
&format!(
"position:absolute;top:0.75rem;right:0.75rem;width:2rem;height:2rem;\
border-radius:9999px;display:flex;align-items:center;justify-content:center;\
background:{close_bg};border:none;cursor:pointer;font-size:1.1rem;line-height:1"
),
)
.ok();
close_btn.set_attribute("aria-label", "Close graph").ok();
close_btn.set_inner_html("&times;");
card.append_child(&svg_div).ok();
card.append_child(&close_btn).ok();
overlay.append_child(&card).ok();
body.append_child(&overlay).ok();
// Prevent body scroll while overlay is open.
body.style().set_property("overflow", "hidden").ok();
// Close: click overlay backdrop or close button.
let overlay_for_close = overlay.clone();
let on_backdrop: Closure<dyn Fn(web_sys::MouseEvent)> = Closure::new(move |_: web_sys::MouseEvent| {
overlay_for_close.remove();
if let Some(b) = web_sys::window()
.and_then(|w| w.document())
.and_then(|d| d.body())
{
b.style().remove_property("overflow").ok();
}
});
overlay
.add_event_listener_with_callback("click", on_backdrop.as_ref().unchecked_ref())
.ok();
on_backdrop.forget();
// Prevent backdrop close when clicking inside the card.
let stop_prop: Closure<dyn Fn(web_sys::MouseEvent)> = Closure::new(|e: web_sys::MouseEvent| {
e.stop_propagation();
});
card.add_event_listener_with_callback("click", stop_prop.as_ref().unchecked_ref())
.ok();
stop_prop.forget();
// Close button: forwards click to overlay (bypassing stop_propagation on card).
let overlay_for_btn = overlay.clone();
let on_btn: Closure<dyn Fn(web_sys::MouseEvent)> = Closure::new(move |e: web_sys::MouseEvent| {
e.stop_propagation();
overlay_for_btn.remove();
if let Some(b) = web_sys::window()
.and_then(|w| w.document())
.and_then(|d| d.body())
{
b.style().remove_property("overflow").ok();
}
});
close_btn
.add_event_listener_with_callback("click", on_btn.as_ref().unchecked_ref())
.ok();
on_btn.forget();
}

View file

@ -0,0 +1,131 @@
use leptos::prelude::*;
#[cfg(not(target_arch = "wasm32"))]
use super::GRAPH_JSON;
/// Full-page knowledge graph view.
///
/// `node_id`: when provided, the graph centers on that node (ego view).
#[component]
pub fn GraphView(
#[prop(optional)] node_id: Option<String>,
#[prop(default = String::from("light"))] theme: String,
) -> impl IntoView {
#[cfg(not(target_arch = "wasm32"))]
return view! { <GraphViewInner node_id=node_id theme=theme /> };
#[cfg(target_arch = "wasm32")]
return view! { <GraphViewClient node_id=node_id theme=theme /> };
}
// ── SSR ───────────────────────────────────────────────────────────────────────
#[cfg(not(target_arch = "wasm32"))]
#[component]
fn GraphViewInner(
node_id: Option<String>,
theme: String,
) -> impl IntoView {
let focus = node_id.unwrap_or_default();
let graph_json_safe = GRAPH_JSON.replace("</", "\\/");
view! {
<div class="content-graph-page w-full">
<script
type="application/json"
id="content-graph-data"
inner_html=graph_json_safe
/>
<div
id="content-graph-cyto"
data-focus=focus
data-theme=theme
class="w-full h-[600px] ds-bg-page ds-border ds-rounded-lg overflow-hidden"
aria-label="Knowledge graph visualisation"
role="img"
>
<noscript>
<p class="p-4 ds-caption text-center">
"Interactive graph requires JavaScript."
</p>
</noscript>
</div>
</div>
}
}
// ── WASM ──────────────────────────────────────────────────────────────────────
#[cfg(target_arch = "wasm32")]
#[component]
fn GraphViewClient(
node_id: Option<String>,
theme: String,
) -> impl IntoView {
let focus = node_id.unwrap_or_default();
let focus_clone = focus.clone();
let theme_clone = theme.clone();
Effect::new(move |_| {
let focus = focus_clone.clone();
let theme = theme_clone.clone();
call_cytoscape_render("content-graph-cyto", &focus, &theme);
});
on_cleanup(move || {
call_cytoscape_destroy("content-graph-cyto");
});
view! {
<div class="content-graph-page w-full">
<div
id="content-graph-cyto"
data-focus=focus
data-theme=theme
class="w-full h-[600px] ds-bg-page ds-border ds-rounded-lg overflow-hidden"
aria-label="Knowledge graph visualisation"
role="img"
/>
</div>
}
}
// ── JS interop ────────────────────────────────────────────────────────────────
#[cfg(target_arch = "wasm32")]
fn call_cytoscape_render(container_id: &str, focus: &str, theme: &str) {
use wasm_bindgen::{JsCast, JsValue};
let Some(window) = web_sys::window() else { return };
let Ok(content_graph) = js_sys::Reflect::get(&window, &JsValue::from_str("ContentGraph"))
else { return };
let Ok(render_fn) = js_sys::Reflect::get(&content_graph, &JsValue::from_str("render"))
else { return };
let render_fn: js_sys::Function = match render_fn.dyn_into() {
Ok(f) => f,
Err(_) => return,
};
let _ = render_fn.call3(
&content_graph,
&JsValue::from_str(container_id),
&JsValue::from_str(focus),
&JsValue::from_str(theme),
);
}
#[cfg(target_arch = "wasm32")]
fn call_cytoscape_destroy(container_id: &str) {
use wasm_bindgen::{JsCast, JsValue};
let Some(window) = web_sys::window() else { return };
let Ok(content_graph) = js_sys::Reflect::get(&window, &JsValue::from_str("ContentGraph"))
else { return };
let Ok(destroy_fn) = js_sys::Reflect::get(&content_graph, &JsValue::from_str("destroy"))
else { return };
let destroy_fn: js_sys::Function = match destroy_fn.dyn_into() {
Ok(f) => f,
Err(_) => return,
};
let _ = destroy_fn.call1(&content_graph, &JsValue::from_str(container_id));
}

View file

@ -0,0 +1,172 @@
//! Pure-Rust HTML rendering for the post sidebar — no Leptos required.
//!
//! Produces the same visual output as the Leptos GraphMini + RelatedContent +
//! OntologyContext components so the HTMX profile has full sidebar parity.
//! Compiled only for non-WASM targets; WASM uses the Leptos components directly.
#[cfg(not(target_arch = "wasm32"))]
use super::generated::{
mini::GRAPH_MINI_SVGS,
ontology::ONTOLOGY_CONTEXT,
related::RELATED_NODES,
};
/// Render the full sidebar for a content post (graph mini-preview + related
/// content + ontology context) as a plain HTML string.
///
/// Returns an empty string when the node has no graph data.
#[cfg(not(target_arch = "wasm32"))]
pub fn render_sidebar(node_id: &str, lang: &str) -> String {
let mini_html = render_graph_mini(node_id);
let related_html = render_related(node_id, lang);
let ontology_html = render_ontology(node_id, lang);
if mini_html.is_empty() && related_html.is_empty() && ontology_html.is_empty() {
return String::new();
}
format!(
r#"<aside class="space-y-4 shrink-0">{}{}{}</aside>"#,
mini_html, related_html, ontology_html
)
}
#[cfg(not(target_arch = "wasm32"))]
fn render_graph_mini(node_id: &str) -> String {
let Some(svg) = GRAPH_MINI_SVGS
.iter()
.find(|(id, _)| *id == node_id)
.map(|(_, svg)| *svg)
else {
return String::new();
};
format!(
r#"<div class="content-graph-mini-wrapper"><div class="content-graph-mini-preview" aria-label="Knowledge graph preview" role="img">{svg}</div></div>"#
)
}
#[cfg(not(target_arch = "wasm32"))]
fn render_related(node_id: &str, lang: &str) -> String {
let related = RELATED_NODES
.iter()
.find(|(id, _)| *id == node_id)
.map(|(_, nodes)| *nodes)
.unwrap_or(&[]);
if related.is_empty() {
return String::new();
}
let header = if lang == "es" { "Contenido relacionado" } else { "Related content" };
let mut items = String::new();
for node in related {
let badge_css = kind_badge_css(node.kind);
let badge_label = kind_label(node.kind);
let content = if node.url.is_empty() {
format!(r#"<span class="text-sm ds-text leading-snug">{}</span>"#, esc(node.title))
} else {
format!(r#"<a href="{}" class="text-sm ds-text leading-snug hover:underline">{}</a>"#,
esc_attr(node.url),
esc(node.title))
};
items.push_str(&format!(
r#"<li class="flex items-start gap-2"><span class="ds-badge text-xs mt-0.5 shrink-0 {badge_css}">{badge_label}</span>{content}</li>"#,
));
}
format!(
r#"<aside class="content-graph-related ds-card ds-bg-base-200 p-4 space-y-3"><h3 class="text-sm font-semibold ds-text uppercase tracking-wide opacity-60">{header}</h3><ul class="space-y-2">{items}</ul></aside>"#,
)
}
#[cfg(not(target_arch = "wasm32"))]
fn render_ontology(node_id: &str, lang: &str) -> String {
let refs = ONTOLOGY_CONTEXT
.iter()
.find(|(id, _)| *id == node_id)
.map(|(_, refs)| *refs)
.unwrap_or(&[]);
if refs.is_empty() {
return String::new();
}
let header = if lang == "es" { "Contexto ontológico" } else { "Ontology context" };
let mut items = String::new();
for r in refs {
let icon = ref_icon(r.kind);
let css = ref_css(r.kind);
items.push_str(&format!(
r#"<li class="flex items-center gap-1 text-xs ds-text"><span class="{css}">{icon}</span><span class="font-mono opacity-80">{}</span><span class="opacity-50">{}</span></li>"#,
esc(r.id),
esc(r.name),
));
}
format!(
r#"<div class="content-graph-ontology-context mt-6 pt-4 ds-border-t space-y-2"><p class="text-xs font-semibold ds-text uppercase tracking-wide opacity-50">{header}</p><ul class="flex flex-wrap gap-2">{items}</ul></div>"#,
)
}
#[cfg(not(target_arch = "wasm32"))]
fn kind_label(kind: &str) -> &'static str {
match kind {
"Implements" | "ImplementedBy" => "implements",
"RelatedTo" | "RelatedFrom" => "related",
"PartOf" | "ParentOf" => "part of",
"References" | "ReferencedBy" => "references",
"Extends" | "ExtendedBy" => "extends",
"ManifestsIn" => "manifests in",
"Resolves" => "resolves",
"Complements" => "complements",
"DependsOn" => "depends on",
"DocumentedIn" => "documented in",
_ => "related",
}
}
#[cfg(not(target_arch = "wasm32"))]
fn kind_badge_css(kind: &str) -> &'static str {
match kind {
"Implements" | "ImplementedBy" => "ds-badge-primary",
"RelatedTo" | "RelatedFrom" => "ds-badge-secondary",
"PartOf" | "ParentOf" => "ds-badge-accent",
"References" | "ReferencedBy" => "ds-badge-neutral",
_ => "ds-badge-ghost",
}
}
#[cfg(not(target_arch = "wasm32"))]
fn ref_icon(kind: &str) -> &'static str {
match kind {
"Implements" | "ImplementedBy" => "",
"ManifestsIn" => "",
"Resolves" => "",
"Complements" | "DependsOn" => "~",
"DocumentedIn" | "References" => "",
_ => "·",
}
}
#[cfg(not(target_arch = "wasm32"))]
fn ref_css(kind: &str) -> &'static str {
match kind {
"Implements" | "ImplementedBy" => "text-violet-500",
"ManifestsIn" => "text-blue-500",
"Resolves" => "text-emerald-500",
_ => "text-amber-500",
}
}
#[cfg(not(target_arch = "wasm32"))]
fn esc(s: &str) -> String {
s.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
}
#[cfg(not(target_arch = "wasm32"))]
fn esc_attr(s: &str) -> String {
esc(s).replace('"', "&quot;")
}

View file

@ -0,0 +1,34 @@
pub mod graph_mini;
pub mod graph_view;
pub mod htmx_sidebar;
pub mod ontology_context;
pub mod project_graph_section;
pub mod related_content;
pub mod unified;
pub use graph_mini::GraphMini;
pub use graph_view::GraphView;
pub use ontology_context::OntologyContext;
pub use project_graph_section::ProjectGraphSection;
pub use related_content::RelatedContent;
#[cfg(not(target_arch = "wasm32"))]
pub use htmx_sidebar::render_sidebar;
// Generated static data — written to OUT_DIR by pages/build.rs.
#[allow(clippy::redundant_static_lifetimes)]
pub mod generated {
pub mod related {
include!(concat!(env!("OUT_DIR"), "/related_map.rs"));
}
pub mod ontology {
include!(concat!(env!("OUT_DIR"), "/ontology_context_map.rs"));
}
pub mod mini {
include!(concat!(env!("OUT_DIR"), "/graph_mini_map.rs"));
}
}
#[cfg(not(target_arch = "wasm32"))]
pub(crate) const GRAPH_JSON: &str =
include_str!(concat!(env!("OUT_DIR"), "/content_graph.json"));

View file

@ -0,0 +1,59 @@
use leptos::prelude::*;
use rustelo_content::rustelo_core_lib::i18n::use_unified_i18n;
use super::generated::ontology::{OntologyRef, ONTOLOGY_CONTEXT};
fn ref_icon(kind: &str) -> &'static str {
match kind {
"Implements" | "ImplementedBy" => "",
"ManifestsIn" => "",
"Resolves" => "",
"Complements" | "DependsOn" => "~",
"DocumentedIn" | "References" => "",
_ => "·",
}
}
fn ref_css(kind: &str) -> &'static str {
match kind {
"Implements" | "ImplementedBy" => "text-violet-500",
"ManifestsIn" => "text-blue-500",
"Resolves" => "text-emerald-500",
_ => "text-amber-500",
}
}
/// Footer/sidebar badge showing on+re ontology nodes connected to this content.
///
/// Renders nothing when the node has no ontology connections.
#[component]
pub fn OntologyContext(node_id: String) -> impl IntoView {
let refs: &'static [OntologyRef] = ONTOLOGY_CONTEXT
.iter()
.find(|(id, _)| *id == node_id.as_str())
.map(|(_, refs)| *refs)
.unwrap_or(&[]);
let header = use_unified_i18n().t("content-graph-ontology-context");
(!refs.is_empty()).then(|| view! {
<div class="content-graph-ontology-context mt-6 pt-4 ds-border-t space-y-2">
<p class="text-xs font-semibold ds-text uppercase tracking-wide opacity-50">
{header}
</p>
<ul class="flex flex-wrap gap-2">
{refs.iter().map(|r| {
let icon = ref_icon(r.kind);
let css = ref_css(r.kind);
view! {
<li class="flex items-center gap-1 text-xs ds-text">
<span class=css>{icon}</span>
<span class="font-mono opacity-80">{r.id}</span>
<span class="opacity-50">{r.name}</span>
</li>
}
}).collect_view()}
</ul>
</div>
})
}

View file

@ -0,0 +1,23 @@
use leptos::prelude::*;
use super::{GraphMini, OntologyContext, RelatedContent};
/// Knowledge graph footer for project showcase pages.
///
/// Renders the ego-network mini graph, related content list, and ontology
/// context badges in a horizontal section at the bottom of project pages.
/// Renders nothing if the project has no graph connections.
#[component]
pub fn ProjectGraphSection(node_id: String) -> impl IntoView {
view! {
<section class="ds-container py-8 mt-4 border-t ds-border">
<div class="flex flex-col md:flex-row gap-6 items-start">
<GraphMini node_id=node_id.clone() class="shrink-0".to_string() />
<div class="flex flex-col gap-4 flex-1 min-w-0">
<RelatedContent node_id=node_id.clone() />
<OntologyContext node_id=node_id />
</div>
</div>
</section>
}
}

View file

@ -0,0 +1,79 @@
use leptos::prelude::*;
use rustelo_content::rustelo_core_lib::i18n::use_unified_i18n;
use super::generated::related::{RelatedNode, RELATED_NODES};
fn kind_label(kind: &str) -> &'static str {
match kind {
"Implements" | "ImplementedBy" => "implements",
"RelatedTo" | "RelatedFrom" => "related",
"PartOf" | "ParentOf" => "part of",
"References" | "ReferencedBy" => "references",
"Extends" | "ExtendedBy" => "extends",
"ManifestsIn" => "manifests in",
"Resolves" => "resolves",
"Complements" => "complements",
"DependsOn" => "depends on",
"DocumentedIn" => "documented in",
_ => "related",
}
}
fn kind_css(kind: &str) -> &'static str {
match kind {
"Implements" | "ImplementedBy" => "ds-badge-primary",
"RelatedTo" | "RelatedFrom" => "ds-badge-secondary",
"PartOf" | "ParentOf" => "ds-badge-accent",
"References" | "ReferencedBy" => "ds-badge-neutral",
_ => "ds-badge-ghost",
}
}
/// Sidebar list of related content items for a given node.
///
/// Nodes with a URL (content items and Practice-level ontology nodes) render as
/// links for SPA navigation. Nodes without a URL render as plain text.
#[component]
pub fn RelatedContent(node_id: String) -> impl IntoView {
let related: &'static [RelatedNode] = RELATED_NODES
.iter()
.find(|(id, _)| *id == node_id.as_str())
.map(|(_, nodes)| *nodes)
.unwrap_or(&[]);
let header = use_unified_i18n().t("content-graph-related");
(!related.is_empty()).then(|| view! {
<aside class="content-graph-related ds-card ds-bg-base-200 p-4 space-y-3">
<h3 class="text-sm font-semibold ds-text uppercase tracking-wide opacity-60">
{header}
</h3>
<ul class="space-y-2">
{related.iter().map(|node| {
let badge = kind_css(node.kind);
let label = kind_label(node.kind);
view! {
<li class="flex items-start gap-2">
<span class=format!("ds-badge text-xs mt-0.5 shrink-0 {badge}")>
{label}
</span>
{if node.url.is_empty() {
// Ontology / category node — no URL, plain text
view! {
<span class="text-sm ds-text leading-snug">{node.title}</span>
}.into_any()
} else {
// Content node — plain <a>; Leptos BrowserRouter intercepts
// clicks on internal hrefs for SPA navigation automatically.
// Styled via .content-graph-related a in custom.css.
view! {
<a href=node.url>{node.title}</a>
}.into_any()
}}
</li>
}
}).collect_view()}
</ul>
</aside>
})
}

View file

@ -0,0 +1,55 @@
use leptos::prelude::*;
use rustelo_content::rustelo_core_lib::i18n::content_helper::create_content_provider_reactive;
use super::GraphView;
/// Full-page knowledge graph.
///
/// Renders the interactive Cytoscape graph with a title and description.
/// When `node_id` is provided the graph opens centred on that node (ego view).
#[component]
pub fn UnifiedContentGraphPage(
#[prop(default = rustelo_content::rustelo_core_lib::config::get_default_language().to_string())]
_language: String,
#[prop(optional)] lang_content: Option<std::collections::HashMap<String, String>>,
#[prop(optional)] node_id: Option<String>,
) -> impl IntoView {
let content = create_content_provider_reactive(lang_content);
// Detect current theme from the <html> `data-theme` attribute.
// Falls back to "light" — the Cytoscape shim reads `data-theme` on the
// container anyway, so this default is only used for the prop.
let theme = {
#[cfg(target_arch = "wasm32")]
{
web_sys::window()
.and_then(|w| w.document())
.and_then(|d| d.document_element())
.and_then(|el| el.get_attribute("data-theme"))
.unwrap_or_else(|| "light".to_string())
}
#[cfg(not(target_arch = "wasm32"))]
{
"light".to_string()
}
};
view! {
<div class="ds-bg-page min-h-screen">
<div class="ds-container py-ds-8 space-y-6">
<div class="text-center space-y-2">
<h1 class="text-3xl font-bold ds-text">
{content.t("content-graph-title")}
</h1>
<p class="ds-body ds-text-secondary max-w-2xl mx-auto">
{content.t("content-graph-subtitle")}
</p>
</div>
<GraphView node_id=node_id.unwrap_or_default() theme=theme />
<p class="text-xs ds-text-secondary text-center">
{content.t("content-graph-hint")}
</p>
</div>
</div>
}
}

View file

@ -0,0 +1,8 @@
//! Home page module using trait-based runtime resolution
//!
//! This module uses runtime trait-based resolution instead of build-time
//! generation following the PAP principles for configuration-driven
//! architecture.
pub mod pages;
pub mod unified;

View file

@ -0,0 +1,66 @@
use leptos::prelude::*;
use super::unified::UnifiedHomePage;
use rustelo_content::rustelo_core_lib;
/// Client-side reactive page component (wrapper)
#[component]
pub fn HomeClient(
#[prop(default = rustelo_core_lib::config::get_default_language().to_string())]
_language: String,
) -> impl IntoView {
// Use the existing use_page_i18n_dynamic function
let page_content = rustelo_pages_leptos::common::use_page_i18n_dynamic(
vec!["home-", "welcome-", "intro-"],
Vec::new,
);
// Clone language for use in closure
let language = _language.clone();
move || {
view! {
<UnifiedHomePage
_language=language.clone()
lang_content=page_content.get()
/>
}
}
}
/// SSR static page component (wrapper)
#[component]
pub fn HomeSSR(
#[prop(default = rustelo_core_lib::config::get_default_language().to_string())]
_language: String,
) -> impl IntoView {
// Create static translator for SSR
let translator = rustelo_core_lib::i18n::page_translator::SsrTranslator::new(_language.clone());
let patterns: Vec<&str> = vec!["home-", "welcome-", "intro-"];
let lang_content =
rustelo_core_lib::i18n::helpers::build_page_content_patterns(&translator, &patterns);
view! {
<UnifiedHomePage
_language=_language.clone()
lang_content=lang_content
/>
}
}
/// Main page component with delegation
#[component]
pub fn HomePage(
#[prop(default = rustelo_core_lib::config::get_default_language().to_string())]
_language: String,
) -> impl IntoView {
#[cfg(not(target_arch = "wasm32"))]
{
view! { <HomeSSR _language=_language.clone() /> }
}
#[cfg(target_arch = "wasm32")]
{
view! { <HomeClient _language=_language.clone() /> }
}
}

View file

@ -0,0 +1,396 @@
//! Unified Home Page Component using shared delegation patterns
//!
//! This module provides a unified interface that automatically selects between
//! client-side reactive and server-side static implementations based on
//! context.
use leptos::prelude::*;
use rustelo_components_leptos::HtmlContent;
// use rustelo_components_leptos::server_send_contact_form;
use rustelo_content::rustelo_core_lib;
use rustelo_content::rustelo_core_lib::i18n::content_helper::create_content_provider_reactive;
#[cfg(target_arch = "wasm32")]
use rustelo_content::rustelo_core_lib::utils::nav;
#[component]
pub fn UnifiedProjectLogo(
light_src: String,
#[prop(optional)] dark_src: String,
alt: String,
href: String,
) -> impl IntoView {
let has_dark = !dark_src.is_empty();
let light_class = if has_dark {
"h-28 w-28 hover:scale-105 transition-transform dark:hidden"
} else {
"h-28 w-28 hover:scale-105 transition-transform"
};
view! {
<a href={href} target="_blank" rel="noopener noreferrer" class="block mb-ds-4">
<img src={light_src} alt={alt.clone()} class={light_class} />
{has_dark.then(|| view! {
<img src={dark_src} alt={alt} class="h-28 w-28 hover:scale-105 transition-transform hidden dark:block" />
})}
</a>
}
}
/// Unified Home Page component that works in both SSR and client contexts
/// Takes structured content data instead of individual parameters
#[component]
pub fn UnifiedHomePage(
#[prop(default = rustelo_core_lib::config::get_default_language().to_string())]
_language: String,
#[prop(optional)] lang_content: Option<std::collections::HashMap<String, String>>,
) -> impl IntoView {
let content = create_content_provider_reactive(lang_content.clone());
let home_services_url = content.t("home-services-url");
let home_contact_url = content.t("home-contact-url");
let home_services_mentoring_url = content.t("home-services-mentoring-url");
let home_services_training_url = content.t("home-services-training-url");
let home_services_projects_url = content.t("home-services-projects-url");
let home_work_request_url = content.t("home-work-request-url");
let home_blog_url = content.t("home-blog-url");
view! {
<div class="ds-bg-page">
<section class="relative py-ds-4 ds-container ds-rounded-lg ds-shadow-lg">
<div class="mx-auto max-w-4xl text-center">
<div class="mb-8">
<div class="flex items-center gap-4 justify-center mb-6">
</div>
<h1 class="text-balance text-4xl font-bold tracking-tight ds-text sm:text-6xl">
{content.t("home-hero-title")}
</h1>
<div class="flex items-center justify-center gap-8 mb-6">
<img src="/images/logos/example.png" alt="example" class="lg:h-20 h-15" />
<img src="/images/ExampleOrg_f.jpg" alt="Jesús Pérez Profile" class="lg:w-40 rounded-full w-32 object-cover border-2 ds-border" />
</div>
<HtmlContent
content={content.t("home-hero-subtitle")}
class="mt-ds-6 ds-body ds-text-secondary max-w-2xl mx-auto".to_string()
/>
</div>
<div class="flex flex-col sm:flex-row gap-4 justify-center mt-8">
<a href={home_services_url.clone()} class="no-underline ds-btn-primary"
on:click=move |_ev| {
#[cfg(target_arch = "wasm32")]
{
if let Some(set_path) = use_context::<WriteSignal<String>>() {
_ev.prevent_default();
nav::anchor_navigate(set_path, &home_services_url);
}
}
}
>
{content.t("home-view-services")}
</a>
<a href={home_contact_url.clone()} class="no-underline ds-btn-secondary"
on:click=move |_ev| {
#[cfg(target_arch = "wasm32")]
{
if let Some(set_path) = use_context::<WriteSignal<String>>() {
_ev.prevent_default();
nav::anchor_navigate(set_path, &home_contact_url);
}
}
}
>
{content.t("home-get-in-touch")}
</a>
</div>
</div>
</section>
// Core Expertise Section
<section class="pt-ds-3 py-ds-6 ds-bg ds-rounded-lg">
<div class="ds-container-lg ds-container">
<div class="text-center mt-ds-3">
<h2 class="text-3xl font-bold ds-text">{content.t("home-core-expertise")}</h2>
<p class="mt-4 ds-text-secondary">{content.t("home-core-expertise-subtitle")}</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-8">
<div class="text-center p-ds-6">
<div class="w-16 h-16 mx-auto mb-ds-4 ds-bg ds-rounded-lg flex items-center justify-center">
<span class="text-2xl">"🦀"</span>
</div>
<h3 class="ds-heading-4 ds-text mb-2">{content.t("home-rust-development")}</h3>
<ul class="list-none list-inside ds-text-secondary">
<li>{content.t("home-web-applications")}</li>
<li>{content.t("home-high-performance-systems")}</li>
<li>{content.t("home-tooling-rust-safety")}</li>
</ul>
</div>
<div class="text-center p-ds-6">
<div class="w-16 h-16 mx-auto mb-ds-4 ds-bg ds-rounded-lg flex items-center justify-center">
<span class="text-2xl">"🏗️"</span>
</div>
<h3 class="ds-heading-4 ds-text mb-2">{content.t("home-self-hosted-infrastructure")}</h3>
<ul class="list-none list-inside ds-text-secondary">
<li>{content.t("home-cicd-pipelines")}</li>
<li>{content.t("home-kubernetes-deployments")}</li>
<li>{content.t("home-resilient-architectures")}</li>
</ul>
</div>
<div class="text-center p-ds-6">
<div class="w-16 h-16 mx-auto mb-ds-4 ds-bg ds-rounded-lg flex items-center justify-center">
<span class="text-2xl">"🌐"</span>
</div>
<h3 class="ds-heading-4 ds-text mb-2">{content.t("home-web3-polkadot")}</h3>
<ul class="list-none list-inside ds-text-secondary mb-ds-4">
<li>{content.t("home-blockchain-integration")}</li>
<li>{content.t("home-decentralized-applications")}</li>
<li>{content.t("home-polkadot-ecosystem-development")}</li>
</ul>
</div>
</div>
</div>
</section>
// Services Preview Section
<section class="py-ds-6 ds-rounded-lg">
<div class="ds-container-lg ds-container">
<div class="text-center mb-ds-12">
<h2 class="text-3xl font-bold ds-text">{content.t("home-how-i-can-help")}</h2>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-8">
<div class="ds-bg p-ds-6 ds-rounded-lg ds-shadow-sm">
<h3 class="ds-heading-4 ds-text mb-3">{content.t("home-project-participation")}</h3>
<ul class="list-disc list-inside ds-text-secondary mb-ds-4">
<li>{content.t("home-requirements-definition")}</li>
<li>{content.t("home-infrastructure-setup")}</li>
<li>{content.t("home-cicd-implementation")}</li>
<li>{content.t("home-research-prototyping")}</li>
</ul>
<a href={home_services_projects_url.clone()} class="ds-text font-medium hover:ds-text-secondary"
on:click=move |_ev| {
#[cfg(target_arch = "wasm32")]
{
if let Some(set_path) = use_context::<WriteSignal<String>>() {
_ev.prevent_default();
nav::anchor_navigate(set_path, &home_services_projects_url);
}
}
}
>
{content.t("home-learn-more-arrow")}
</a>
</div>
<div class="ds-bg p-ds-6 ds-rounded-lg ds-shadow-sm">
<h3 class="ds-heading-4 ds-text mb-3">{content.t("home-mentoring-guidance")}</h3>
<ul class="list-disc list-inside ds-text-secondary mb-ds-4">
<li>{content.t("home-one-on-one-collaboration")}</li>
<li>{content.t("home-pair-programming")}</li>
<li>{content.t("home-code-review")}</li>
<li>{content.t("home-technical-guidance-team")}</li>
</ul>
<a href={home_services_mentoring_url.clone()} class="ds-text font-medium hover:ds-text-secondary"
on:click=move |_ev| {
#[cfg(target_arch = "wasm32")]
{
if let Some(set_path) = use_context::<WriteSignal<String>>() {
_ev.prevent_default();
_ev.stop_propagation();
nav::anchor_navigate(set_path, &home_services_mentoring_url);
}
}
}
>
{content.t("home-learn-more-arrow")}
</a>
</div>
<div class="ds-bg p-ds-6 ds-rounded-lg ds-shadow-sm">
<h3 class="ds-heading-4 ds-text mb-3">{content.t("home-training-workshops")}</h3>
<ul class="list-disc list-inside ds-text-secondary mb-ds-4">
<li>{content.t("home-custom-courses")}</li>
<li>{content.t("home-workshops")}</li>
<li>{content.t("home-technical-talks-org")}</li>
</ul>
<a href={home_services_training_url.clone()} class="ds-text font-medium hover:ds-text-secondary"
on:click=move |_ev| {
#[cfg(target_arch = "wasm32")]
{
if let Some(set_path) = use_context::<WriteSignal<String>>() {
_ev.prevent_default();
_ev.stop_propagation();
nav::anchor_navigate(set_path, &home_services_training_url);
}
}
}
>
{content.t("home-learn-more-arrow")}
</a>
</div>
</div>
</div>
</section>
// Latest Projects Section
<section class="py-ds-6 ds-bg ds-rounded-lg">
<div class="ds-container-lg ds-container">
<div class="text-center mb-ds-12">
<h2 class="text-3xl font-bold ds-text">{content.t("home-latest-relevant-projects")}</h2>
<p class="mt-4 ds-text-secondary">{content.t("home-open-source-projects-desc")}</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-8">
// Rustelo Project
<div class="ds-bg-page p-ds-6 ds-rounded-lg ds-shadow-sm hover:ds-shadow-md transition-shadow">
<div class="flex flex-col items-center text-center">
<UnifiedProjectLogo
light_src="/images/logos/rustelo_v.svg".to_string()
alt="Rustelo Logo".to_string()
href="https://rustelo.dev".to_string()
/>
<h3 class="ds-heading-4 ds-text mb-3">"Rustelo"</h3>
<p class="ds-text-secondary mb-ds-4 ds-caption leading-relaxed">
{content.t("home-rustelo-desc")}
</p>
<div class="flex gap-3 mt-auto">
<a href="https://rustelo.dev" target="_blank" rel="noopener noreferrer" class="no-underline inline-flex items-center gap-ds-2 px-3 py-2 ds-caption font-medium ds-text ds-bg border ds-border ds-rounded-md hover: transition-colors">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/>
</svg>
{content.t("home-visit-site")}
</a>
<a href="https://github.com/Rustelo-dev" target="_blank" rel="noopener noreferrer" class="no-underline inline-flex items-center gap-ds-2 px-3 py-2 ds-caption font-medium ds-text border ds-border ds-rounded-md hover:ds-bg transition-colors">
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 0C4.477 0 0 4.484 0 10.017c0 4.425 2.865 8.18 6.839 9.504 .5 .092 .682-.217 .682-.483 0-.237-.008-.868-.013-1.703-2.782 .605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62 .069-.608 .069-.608 1.003 .07 1.531 1.032 1.531 1.032 .892 1.53 2.341 1.088 2.91 .832 .092-.647 .35-1.088 .636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093 .39-1.988 1.029-2.688-.103-.253-.446-1.272 .098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0 1 10 4.844c.85 .004 1.705 .115 2.504 .337 1.909-1.296 2.747-1.027 2.747-1.027 .546 1.379 .203 2.398 .1 2.651 .64 .7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.942 .359 .31 .678 .921 .678 1.856 0 1.338-.012 2.419-.012 2.747 0 .268 .18 .58 .688 .482A10.019 10.019 0 0 0 20 10.017C20 4.484 15.522 0 10 0z" clip-rule="evenodd"/>
</svg>
{content.t("home-git-repo")}
</a>
</div>
</div>
</div>
// Provisioning Systems Project
<div class="ds-bg-page p-ds-6 ds-rounded-lg ds-shadow-sm hover:ds-shadow-md transition-shadow">
<div class="flex flex-col items-center text-center">
<UnifiedProjectLogo
light_src="/images/logos/projects/provisioning_v.svg".to_string()
alt="Provisioning Systems Logo".to_string()
href="https://provisioning.systems".to_string()
/>
<h3 class="ds-heading-4 ds-text mb-3">"Provisioning Systems"</h3>
<p class="ds-text-secondary mb-ds-4 ds-caption leading-relaxed">
{content.t("home-provisioning-systems-desc")}
</p>
<div class="flex gap-3 mt-auto">
<a href="https://provisioning.systems" target="_blank" rel="noopener noreferrer" class="no-underline inline-flex items-center gap-ds-2 px-3 py-2 ds-caption font-medium ds-text ds-bg border ds-border ds-rounded-md hover: transition-colors">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/>
</svg>
{content.t("home-visit-site")}
</a>
<a href="https://rlung.ontoref.dev/jesus/provisioning" target="_blank" rel="noopener noreferrer" class="no-underline inline-flex items-center gap-ds-2 px-3 py-2 ds-caption font-medium ds-text border ds-border ds-rounded-md hover:ds-bg transition-colors">
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 0C4.477 0 0 4.484 0 10.017c0 4.425 2.865 8.18 6.839 9.504 .5 .092 .682-.217 .682-.483 0-.237-.008-.868-.013-1.703-2.782 .605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62 .069-.608 .069-.608 1.003 .07 1.531 1.032 1.531 1.032 .892 1.53 2.341 1.088 2.91 .832 .092-.647 .35-1.088 .636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093 .39-1.988 1.029-2.688-.103-.253-.446-1.272 .098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0 1 10 4.844c.85 .004 1.705 .115 2.504 .337 1.909-1.296 2.747-1.027 2.747-1.027 .546 1.379 .203 2.398 .1 2.651 .64 .7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.942 .359 .31 .678 .921 .678 1.856 0 1.338-.012 2.419-.012 2.747 0 .268 .18 .58 .688 .482A10.019 10.019 0 0 0 20 10.017C20 4.484 15.522 0 10 0z" clip-rule="evenodd"/>
</svg>
{content.t("home-git-repo")}
</a>
</div>
</div>
</div>
// Vapora Project
<div class="ds-bg-page p-ds-6 ds-rounded-lg ds-shadow-sm hover:ds-shadow-md transition-shadow">
<div class="flex flex-col items-center text-center">
<UnifiedProjectLogo
light_src="/images/logos/projects/vapora_v.svg".to_string()
alt="Vapora Logo".to_string()
href="https://vapora.dev".to_string()
/>
<h3 class="ds-heading-4 ds-text mb-3">"Vapora"</h3>
<p class="ds-text-secondary mb-ds-4 ds-caption leading-relaxed">
{content.t("home-vapora-desc")}
</p>
<div class="flex gap-3 mt-auto">
<a href="https://vapora.dev" target="_blank" rel="noopener noreferrer" class="no-underline inline-flex items-center gap-ds-2 px-3 py-2 ds-caption font-medium ds-text ds-bg border ds-border ds-rounded-md hover: transition-colors">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/>
</svg>
{content.t("home-visit-site")}
</a>
<a href="https://repo.ontoref.dev/org/Vapora" target="_blank" rel="noopener noreferrer" class="no-underline inline-flex items-center gap-ds-2 px-3 py-2 ds-caption font-medium ds-text border ds-border ds-rounded-md hover:ds-bg transition-colors">
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 0C4.477 0 0 4.484 0 10.017c0 4.425 2.865 8.18 6.839 9.504 .5 .092 .682-.217 .682-.483 0-.237-.008-.868-.013-1.703-2.782 .605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62 .069-.608 .069-.608 1.003 .07 1.531 1.032 1.531 1.032 .892 1.53 2.341 1.088 2.91 .832 .092-.647 .35-1.088 .636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093 .39-1.988 1.029-2.688-.103-.253-.446-1.272 .098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0 1 10 4.844c.85 .004 1.705 .115 2.504 .337 1.909-1.296 2.747-1.027 2.747-1.027 .546 1.379 .203 2.398 .1 2.651 .64 .7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.942 .359 .31 .678 .921 .678 1.856 0 1.338-.012 2.419-.012 2.747 0 .268 .18 .58 .688 .482A10.019 10.019 0 0 0 20 10.017C20 4.484 15.522 0 10 0z" clip-rule="evenodd"/>
</svg>
{content.t("home-git-repo")}
</a>
</div>
</div>
</div>
// AILinkra Project
// <div class="ds-bg-page p-ds-6 ds-rounded-lg ds-shadow-sm hover:ds-shadow-md transition-shadow">
// <div class="flex flex-col items-center text-center">
// <a href="https://ailinkra.net" target="_blank" rel="noopener noreferrer" class="block mb-ds-4">
// <img src="/images/logos/projects/ailinkra_logo_v.svg" alt="AILinkra Logo" class="h-28 w-28 hover:scale-105 transition-transform" />
// </a>
// <h3 class="ds-heading-4 ds-text mb-3">"AILinkra"</h3>
// <p class="ds-text-secondary mb-ds-4 ds-caption leading-relaxed">
// {content.t("home-ailinkra-desc")}
// </p>
// <div class="flex gap-3 mt-auto">
// <a href="https://ailinkra.net" target="_blank" rel="noopener noreferrer" class="no-underline inline-flex items-center gap-ds-2 px-3 py-2 ds-caption font-medium ds-text ds-bg border ds-border ds-rounded-md hover: transition-colors">
// <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
// <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/>
// </svg>
// {content.t("home-visit-site")}
// </a>
// <a href="https://github.com/Ailinkra/ailinkra-chain" target="_blank" rel="noopener noreferrer" class="no-underline inline-flex items-center gap-ds-2 px-3 py-2 ds-caption font-medium ds-text border ds-border ds-rounded-md hover:ds-bg transition-colors">
// <svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
// <path fill-rule="evenodd" d="M10 0C4.477 0 0 4.484 0 10.017c0 4.425 2.865 8.18 6.839 9.504 .5 .092 .682-.217 .682-.483 0-.237-.008-.868-.013-1.703-2.782 .605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62 .069-.608 .069-.608 1.003 .07 1.531 1.032 1.531 1.032 .892 1.53 2.341 1.088 2.91 .832 .092-.647 .35-1.088 .636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093 .39-1.988 1.029-2.688-.103-.253-.446-1.272 .098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0 1 10 4.844c.85 .004 1.705 .115 2.504 .337 1.909-1.296 2.747-1.027 2.747-1.027 .546 1.379 .203 2.398 .1 2.651 .64 .7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.942 .359 .31 .678 .921 .678 1.856 0 1.338-.012 2.419-.012 2.747 0 .268 .18 .58 .688 .482A10.019 10.019 0 0 0 20 10.017C20 4.484 15.522 0 10 0z" clip-rule="evenodd"/>
// </svg>
// {content.t("home-git-repo")}
// </a>
// </div>
// </div>
// </div>
</div>
</div>
</section>
// Call to Action Section
<section class="py-ds-4 ds-bg-page ds-rounded-lg">
<div class="mx-auto max-w-4xl ds-container text-center">
<h2 class="text-3xl font-bold ds-text mb-ds-4">{content.t("home-ready-start-project")}</h2>
<p class="ds-text-secondary mb-8 ds-body">
{content.t("home-ready-start-project-desc")}
</p>
<div class="flex flex-col sm:flex-row gap-4 justify-center">
<a href={home_work_request_url.clone()} class="no-underline ds-btn-primary"
on:click=move |_ev: leptos::ev::MouseEvent| {
#[cfg(target_arch = "wasm32")]
{
if let Some(set_path) = use_context::<WriteSignal<String>>() {
_ev.prevent_default();
_ev.stop_propagation();
nav::anchor_navigate(set_path, &home_work_request_url);
rustelo_core_lib::utils::doc_scroll_to_top(100);
}
}
}
>
{content.t("home-request-project-quote")}
</a>
<a href={home_blog_url.clone()} class="ds-btn-secondary"
on:click=move |_ev: leptos::ev::MouseEvent| {
#[cfg(target_arch = "wasm32")]
{
if let Some(set_path) = use_context::<WriteSignal<String>>() {
_ev.prevent_default();
_ev.stop_propagation();
nav::anchor_navigate(set_path, &home_blog_url);
rustelo_core_lib::utils::doc_scroll_to_top(100);
}
}
}
>
{content.t("home-read-my-blog")}
</a>
</div>
</div>
</section>
</div>
}
}

View file

@ -0,0 +1,2 @@
pub mod unified;
pub use unified::UnifiedKogralPage;

View file

@ -0,0 +1,300 @@
use leptos::prelude::*;
use rustelo_content::rustelo_core_lib::i18n::content_helper::create_content_provider_reactive;
use crate::content_graph::ProjectGraphSection;
struct Problem {
title_key: &'static str,
desc_key: &'static str,
}
const PROBLEMS: &[Problem] = &[
Problem {
title_key: "kogral-problem-1-title",
desc_key: "kogral-problem-1-desc",
},
Problem {
title_key: "kogral-problem-2-title",
desc_key: "kogral-problem-2-desc",
},
Problem {
title_key: "kogral-problem-3-title",
desc_key: "kogral-problem-3-desc",
},
Problem {
title_key: "kogral-problem-4-title",
desc_key: "kogral-problem-4-desc",
},
];
struct HowItWorks {
title_key: &'static str,
desc_key: &'static str,
emoji: &'static str,
}
const HOW_IT_WORKS: &[HowItWorks] = &[
HowItWorks {
title_key: "kogral-how-markdown-title",
desc_key: "kogral-how-markdown-desc",
emoji: "\u{1f4dd}",
},
HowItWorks {
title_key: "kogral-how-search-title",
desc_key: "kogral-how-search-desc",
emoji: "\u{1f50e}",
},
HowItWorks {
title_key: "kogral-how-config-title",
desc_key: "kogral-how-config-desc",
emoji: "\u{2699}\u{fe0f}",
},
HowItWorks {
title_key: "kogral-how-mcp-title",
desc_key: "kogral-how-mcp-desc",
emoji: "\u{1f916}",
},
HowItWorks {
title_key: "kogral-how-logseq-title",
desc_key: "kogral-how-logseq-desc",
emoji: "\u{1f4d3}",
},
HowItWorks {
title_key: "kogral-how-events-title",
desc_key: "kogral-how-events-desc",
emoji: "\u{26a1}",
},
];
struct ComponentEntry {
name: &'static str,
role_key: &'static str,
}
const COMPONENTS: &[ComponentEntry] = &[
ComponentEntry {
name: "kogral-core",
role_key: "kogral-comp-core-role",
},
ComponentEntry {
name: "kogral-cli",
role_key: "kogral-comp-cli-role",
},
ComponentEntry {
name: "kogral-mcp",
role_key: "kogral-comp-mcp-role",
},
ComponentEntry {
name: "Config System",
role_key: "kogral-comp-config-role",
},
ComponentEntry {
name: "Storage Factory",
role_key: "kogral-comp-storage-role",
},
ComponentEntry {
name: "stratum-embeddings",
role_key: "kogral-comp-embeddings-role",
},
ComponentEntry {
name: "Node Types",
role_key: "kogral-comp-nodes-role",
},
ComponentEntry {
name: "Relationships",
role_key: "kogral-comp-relations-role",
},
ComponentEntry {
name: "Multi-Graph",
role_key: "kogral-comp-multigraph-role",
},
ComponentEntry {
name: "NATS Events",
role_key: "kogral-comp-events-role",
},
ComponentEntry {
name: "Logseq Blocks",
role_key: "kogral-comp-logseq-role",
},
ComponentEntry {
name: "Orchestration",
role_key: "kogral-comp-orchestration-role",
},
];
const TECH_BADGES: &[&str] = &[
"Rust Edition 2021",
"Nickel Config",
"SurrealDB",
"stratum-embeddings",
"rig-core",
"NATS JetStream",
"MCP Protocol",
"Logseq Blocks",
"Tera Templates",
"Clap CLI",
"DashMap",
"Nushell Scripts",
"mdBook Docs",
"TypeDialog",
];
#[component]
pub fn UnifiedKogralPage(
#[prop(default = rustelo_content::rustelo_core_lib::config::get_default_language().to_string())]
_language: String,
#[prop(optional)] lang_content: Option<std::collections::HashMap<String, String>>,
) -> impl IntoView {
let content = create_content_provider_reactive(lang_content.clone());
view! {
<div class="ds-bg-page min-h-screen">
// ── Hero ─────────────────────────────────────────────────────────────
<section class="relative py-ds-8 ds-container">
<div class="mx-auto max-w-4xl text-center">
<div class="flex flex-col items-center gap-4 mb-6">
<img
src="/images/logos/projects/kogral.svg"
alt="Kogral"
class="h-28 dark:hidden"
/>
<img
src="/images/logos/projects/kogral.svg"
alt="Kogral"
class="h-28 hidden dark:block"
/>
</div>
<p class="ds-caption ds-text-secondary mb-2 font-mono italic">
{content.t("kogral-tagline")}
</p>
<span class="inline-block px-3 py-1 ds-caption ds-bg border ds-border ds-rounded-md ds-text-secondary mb-4">
{content.t("kogral-badge")}
</span>
<h1 class="text-balance text-4xl font-bold tracking-tight ds-text sm:text-5xl mb-4">
{content.t("kogral-page-title")}
</h1>
<p class="ds-body ds-text-secondary max-w-3xl mx-auto">
{content.t("kogral-page-subtitle")}
</p>
<div class="flex flex-col sm:flex-row gap-4 justify-center mt-8">
<a
href="https://repo.ontoref.dev/org/kogral"
target="_blank"
rel="noopener noreferrer"
class="no-underline ds-btn-primary"
>
{content.t("kogral-cta-explore")}
</a>
</div>
</div>
</section>
// ── Problems ─────────────────────────────────────────────────────────
<section class="py-ds-6 ds-bg ds-rounded-lg">
<div class="ds-container-lg ds-container">
<div class="text-center mb-ds-12">
<h2 class="text-3xl font-bold ds-text">{content.t("kogral-problems-title")}</h2>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
{PROBLEMS.iter().map(|p| {
let title = content.t(p.title_key);
let desc = content.t(p.desc_key);
view! {
<div class="ds-bg-page p-ds-6 ds-rounded-lg ds-border border ds-shadow-sm">
<h3 class="ds-heading-4 ds-text mb-2">{title}</h3>
<p class="ds-caption ds-text-secondary">{desc}</p>
</div>
}
}).collect_view()}
</div>
</div>
</section>
// ── How It Works ─────────────────────────────────────────────────────
<section class="py-ds-6 ds-rounded-lg">
<div class="ds-container-lg ds-container">
<div class="text-center mb-ds-12">
<h2 class="text-3xl font-bold ds-text">{content.t("kogral-how-title")}</h2>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
{HOW_IT_WORKS.iter().map(|h| {
let title = content.t(h.title_key);
let desc = content.t(h.desc_key);
let emoji = h.emoji;
view! {
<div class="ds-bg p-ds-6 ds-rounded-lg ds-shadow-sm border-l-4 ds-border">
<div class="flex items-center gap-3 mb-3">
<span class="text-xl">{emoji}</span>
<h3 class="ds-heading-4 ds-text">{title}</h3>
</div>
<p class="ds-caption ds-text-secondary leading-relaxed">{desc}</p>
</div>
}
}).collect_view()}
</div>
</div>
</section>
// ── Core Components ──────────────────────────────────────────────────
<section class="py-ds-6 ds-bg ds-rounded-lg">
<div class="ds-container-lg ds-container">
<div class="text-center mb-ds-8">
<h2 class="text-3xl font-bold ds-text">{content.t("kogral-components-title")}</h2>
</div>
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-4">
{COMPONENTS.iter().map(|c| {
let role = content.t(c.role_key);
let name = c.name;
view! {
<div class="ds-bg-page p-4 ds-rounded-lg text-center border ds-border ds-shadow-sm">
<div class="ds-caption font-bold ds-text mb-1">{name}</div>
<div class="text-xs ds-text-secondary">{role}</div>
</div>
}
}).collect_view()}
</div>
</div>
</section>
// ── Tech Stack ───────────────────────────────────────────────────────
<section class="py-ds-6 ds-rounded-lg">
<div class="ds-container-lg ds-container">
<div class="text-center mb-ds-8">
<h2 class="text-3xl font-bold ds-text">{content.t("kogral-tech-stack-title")}</h2>
</div>
<div class="flex flex-wrap gap-3 justify-center">
{TECH_BADGES.iter().map(|badge| {
view! {
<span class="px-3 py-2 ds-bg-page border ds-border ds-rounded-md ds-caption font-mono ds-text">
{*badge}
</span>
}
}).collect_view()}
</div>
</div>
</section>
// ── CTA ──────────────────────────────────────────────────────────────
<section class="py-ds-6 ds-bg ds-rounded-lg">
<div class="mx-auto max-w-3xl ds-container text-center">
<h2 class="text-3xl font-bold ds-text mb-ds-4">{content.t("kogral-cta-title")}</h2>
<p class="ds-text-secondary mb-8 ds-body">{content.t("kogral-cta-subtitle")}</p>
<div class="flex flex-col sm:flex-row gap-4 justify-center">
<a
href="https://repo.ontoref.dev/org/kogral"
target="_blank"
rel="noopener noreferrer"
class="no-underline ds-btn-primary"
>
{content.t("kogral-cta-explore")}
</a>
</div>
</div>
</section>
<ProjectGraphSection node_id="kogral".to_string() />
</div>
}
}

24
crates/pages/src/lib.rs Normal file
View file

@ -0,0 +1,24 @@
//! Website Pages Library
//!
//! This crate provides page implementations for the website.
//! Pages are built from foundation pages with customization overlays.
// Re-export foundation pages
//pub use rustelo_pages::*;
// Custom page implementations would go here
// For now, we use foundation pages as-i// Custom page modules (Services, Work Request, and Post Viewer pages)
pub mod content_graph;
pub mod home;
pub mod kogral;
pub mod login;
pub mod post_viewer;
pub mod secretumvault;
pub mod services;
pub mod provisioning;
pub mod stratumiops;
pub mod syntaxis;
pub mod typedialog;
pub mod ontoref;
pub mod vapora;
pub mod work_request;

View file

@ -0,0 +1,3 @@
pub mod unified;
pub use unified::UnifiedLoginPage;

View file

@ -0,0 +1,43 @@
use leptos::prelude::*;
use rustelo_content::rustelo_core_lib::i18n::use_unified_i18n;
/// Login page — renders a full-page shell with heading text.
///
/// On WASM: triggers the `LoginModal` (provided via `WriteSignal<bool>` context)
/// to open automatically so the user can sign in without needing to click anything.
/// On SSR: renders the same heading text for a structurally stable hydration target.
#[component]
pub fn UnifiedLoginPage(
#[prop(default = rustelo_content::rustelo_core_lib::config::get_default_language().to_string())]
_language: String,
#[prop(optional)] lang_content: Option<std::collections::HashMap<String, String>>,
) -> impl IntoView {
let _ = lang_content; // pre-loaded by the page wrapper; auth keys are accessed via use_unified_i18n
let i18n = use_unified_i18n();
let title = i18n.t("auth-otp-modal-title");
let subtitle = i18n.t("auth-otp-modal-subtitle");
// Auto-open the login modal as soon as this page mounts in the browser.
#[cfg(target_arch = "wasm32")]
{
if let Some(set_show) = use_context::<WriteSignal<bool>>() {
Effect::new(move |_| {
set_show.set(true);
});
}
}
view! {
<div class="min-h-screen ds-bg-page flex items-center justify-center py-12 px-4">
<div class="text-center max-w-md">
<h1 class="text-3xl font-bold ds-text mb-3">
{title}
</h1>
<p class="ds-text-secondary text-sm">
{subtitle}
</p>
</div>
</div>
}
}

View file

@ -0,0 +1 @@
pub mod unified;

View file

@ -0,0 +1,575 @@
use leptos::prelude::*;
use rustelo_content::rustelo_core_lib::i18n::content_helper::create_content_provider_reactive;
use rustelo_components_leptos::ImageModal;
use crate::content_graph::ProjectGraphSection;
struct UiPage {
title_key: &'static str,
desc_key: &'static str,
emoji: &'static str,
}
const UI_PAGES: &[UiPage] = &[
UiPage {
title_key: "ontoref-ui-dashboard-title",
desc_key: "ontoref-ui-dashboard-desc",
emoji: "📊",
},
UiPage {
title_key: "ontoref-ui-graph-title",
desc_key: "ontoref-ui-graph-desc",
emoji: "🕸️",
},
UiPage {
title_key: "ontoref-ui-search-title",
desc_key: "ontoref-ui-search-desc",
emoji: "🔍",
},
UiPage {
title_key: "ontoref-ui-sessions-title",
desc_key: "ontoref-ui-sessions-desc",
emoji: "👥",
},
UiPage {
title_key: "ontoref-ui-notifications-title",
desc_key: "ontoref-ui-notifications-desc",
emoji: "🔔",
},
UiPage {
title_key: "ontoref-ui-backlog-title",
desc_key: "ontoref-ui-backlog-desc",
emoji: "📋",
},
UiPage {
title_key: "ontoref-ui-qa-title",
desc_key: "ontoref-ui-qa-desc",
emoji: "💬",
},
UiPage {
title_key: "ontoref-ui-actions-title",
desc_key: "ontoref-ui-actions-desc",
emoji: "",
},
UiPage {
title_key: "ontoref-ui-modes-title",
desc_key: "ontoref-ui-modes-desc",
emoji: "🔀",
},
UiPage {
title_key: "ontoref-ui-compose-title",
desc_key: "ontoref-ui-compose-desc",
emoji: "🎛️",
},
];
struct McpGroup {
title_key: &'static str,
desc_key: &'static str,
emoji: &'static str,
}
const MCP_GROUPS: &[McpGroup] = &[
McpGroup {
title_key: "ontoref-mcp-core-title",
desc_key: "ontoref-mcp-core-desc",
emoji: "🧭",
},
McpGroup {
title_key: "ontoref-mcp-query-title",
desc_key: "ontoref-mcp-query-desc",
emoji: "🔎",
},
McpGroup {
title_key: "ontoref-mcp-backlog-title",
desc_key: "ontoref-mcp-backlog-desc",
emoji: "📝",
},
McpGroup {
title_key: "ontoref-mcp-knowledge-title",
desc_key: "ontoref-mcp-knowledge-desc",
emoji: "🗄️",
},
];
struct Problem {
title_key: &'static str,
desc_key: &'static str,
}
const PROBLEMS: &[Problem] = &[
Problem {
title_key: "ontoref-problem-1-title",
desc_key: "ontoref-problem-1-desc",
},
Problem {
title_key: "ontoref-problem-2-title",
desc_key: "ontoref-problem-2-desc",
},
Problem {
title_key: "ontoref-problem-3-title",
desc_key: "ontoref-problem-3-desc",
},
Problem {
title_key: "ontoref-problem-4-title",
desc_key: "ontoref-problem-4-desc",
},
Problem {
title_key: "ontoref-problem-5-title",
desc_key: "ontoref-problem-5-desc",
},
Problem {
title_key: "ontoref-problem-6-title",
desc_key: "ontoref-problem-6-desc",
},
Problem {
title_key: "ontoref-problem-7-title",
desc_key: "ontoref-problem-7-desc",
},
];
struct ProtocolLayer {
label_key: &'static str,
items_key: &'static str,
desc_key: &'static str,
accent: &'static str,
}
const PROTOCOL_LAYERS: &[ProtocolLayer] = &[
ProtocolLayer {
label_key: "ontoref-layer-decl-label",
items_key: "ontoref-layer-decl-items",
desc_key: "ontoref-layer-decl-desc",
accent: "#60a5fa",
},
ProtocolLayer {
label_key: "ontoref-layer-op-label",
items_key: "ontoref-layer-op-items",
desc_key: "ontoref-layer-op-desc",
accent: "#f97316",
},
ProtocolLayer {
label_key: "ontoref-layer-entry-label",
items_key: "ontoref-layer-entry-items",
desc_key: "ontoref-layer-entry-desc",
accent: "#4ade80",
},
ProtocolLayer {
label_key: "ontoref-layer-graph-label",
items_key: "ontoref-layer-graph-items",
desc_key: "ontoref-layer-graph-desc",
accent: "#c084fc",
},
ProtocolLayer {
label_key: "ontoref-layer-runtime-label",
items_key: "ontoref-layer-runtime-items",
desc_key: "ontoref-layer-runtime-desc",
accent: "#22d3ee",
},
ProtocolLayer {
label_key: "ontoref-layer-adopt-label",
items_key: "ontoref-layer-adopt-items",
desc_key: "ontoref-layer-adopt-desc",
accent: "#facc15",
},
];
struct Crate {
title_key: &'static str,
desc_key: &'static str,
emoji: &'static str,
}
const CRATES: &[Crate] = &[
Crate {
title_key: "ontoref-crate-ontology-title",
desc_key: "ontoref-crate-ontology-desc",
emoji: "🦀",
},
Crate {
title_key: "ontoref-crate-reflection-title",
desc_key: "ontoref-crate-reflection-desc",
emoji: "🔄",
},
Crate {
title_key: "ontoref-crate-nushell-title",
desc_key: "ontoref-crate-nushell-desc",
emoji: "📜",
},
Crate {
title_key: "ontoref-crate-nickel-title",
desc_key: "ontoref-crate-nickel-desc",
emoji: "⚙️",
},
Crate {
title_key: "ontoref-crate-daemon-title",
desc_key: "ontoref-crate-daemon-desc",
emoji: "🔧",
},
];
const TECH_BADGES: &[&str] = &[
"Rust",
"Axum",
"Nickel",
"Nushell",
"SurrealDB",
"Cytoscape.js",
"MCP (stdio + HTTP)",
"NATS (feature-gated)",
"Tera",
"Git-versioned",
];
/// Projects that have adopted ontoref — (name, role)
const ADOPTION_PROJECTS: &[(&str, &str)] = &[
("stratumiops", "Master orchestration repo"),
("vapora", "AI agent orchestration"),
("kogral", "Knowledge graph + MCP"),
("syntaxis", "Project orchestration"),
("provisioning", "Declarative IaC"),
("your-project", "Any codebase"),
];
/// Unified Ontoref page — works in both SSR and WASM contexts
#[component]
pub fn UnifiedOntorefPage(
#[prop(default = rustelo_content::rustelo_core_lib::config::get_default_language().to_string())]
_language: String,
#[prop(optional)] lang_content: Option<std::collections::HashMap<String, String>>,
) -> impl IntoView {
let content = create_content_provider_reactive(lang_content.clone());
view! {
<div class="ds-bg-page min-h-screen">
// ── Hero ─────────────────────────────────────────────────────────────
<section class="relative py-ds-8 ds-container">
<div class="mx-auto max-w-4xl text-center">
<div class="flex flex-col items-center gap-4 mb-6">
<img
src="/images/logos/projects/ontoref.svg"
alt="Ontoref"
class="h-28 dark:hidden"
/>
<img
src="/images/logos/projects/ontoref-v.svg"
alt="Ontoref"
class="h-28 hidden dark:block"
/>
</div>
<p class="ds-caption ds-text-secondary mb-2 font-mono italic">
{content.t("ontoref-tagline")}
</p>
<span class="inline-block px-3 py-1 ds-caption ds-bg border ds-border ds-rounded-md ds-text-secondary mb-4">
{content.t("ontoref-badge")}
</span>
<h1 class="text-balance text-4xl font-bold tracking-tight ds-text sm:text-5xl mb-4"
inner_html=content.t("ontoref-page-subtitle")>
</h1>
<p class="ds-body ds-text-secondary max-w-3xl mx-auto">
<span class="font-semibold" style="color: #60a5fa">
{content.t("ontoref-hero-highlight")}
</span>
{content.t("ontoref-hero-desc")}
</p>
<p class="ds-caption font-mono mt-3 mb-4" style="color: #f97316">
<strong>{content.t("ontoref-hero-coda")}</strong>
</p>
<div class="flex flex-col sm:flex-row gap-4 justify-center mt-8">
<a
href="https://github.com/stratum-iops/ontoref"
target="_blank"
rel="noopener noreferrer"
class="no-underline ds-btn-primary"
>
{content.t("ontoref-cta-explore")}
</a>
</div>
</div>
</section>
// ── Architecture Diagram ─────────────────────────────────────────────
<section class="py-ds-6 ds-container">
<div class="mx-auto max-w-5xl text-center">
<h2 class="text-3xl font-bold ds-text mb-ds-8">{content.t("ontoref-architecture-title")}</h2>
<ImageModal
src_light="/images/projects/ontoref_architecture-light.svg"
src_dark="/images/projects/ontoref_architecture-dark.svg"
alt="Ontoref three-layer architecture"
expand_label=content.t("common-expand-image")
/>
</div>
</section>
// ── Problems ─────────────────────────────────────────────────────────
<section class="py-ds-6 ds-bg ds-rounded-lg">
<div class="ds-container-lg ds-container">
<div class="text-center mb-ds-12">
<h2 class="text-3xl font-bold ds-text">{content.t("ontoref-problems-title")}</h2>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
{PROBLEMS.iter().map(|p| {
let title = content.t(p.title_key);
let desc = content.t(p.desc_key);
view! {
<div class="ds-bg-page p-ds-6 ds-rounded-lg ds-border border ds-shadow-sm">
<div class="text-2xl mb-3">""</div>
<h3 class="ds-heading-4 ds-text mb-2">{title}</h3>
<ul class="list-disc list-inside ds-caption ds-text-secondary text-sm space-y-1"
inner_html=desc>
</ul>
</div>
}
}).collect_view()}
</div>
</div>
</section>
// ── Yin / Yang Duality ───────────────────────────────────────────────
<section class="py-ds-6 ds-container">
<div class="mx-auto max-w-5xl">
<div class="text-center mb-ds-8">
<h2 class="text-3xl font-bold ds-text">{content.t("ontoref-duality-title")}</h2>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-6 mb-6">
// Yin card
<div class="ds-bg p-ds-6 ds-rounded-lg ds-shadow-sm border-l-4" style="border-color: #60a5fa">
<h3 class="ds-heading-4 mb-1" style="color: #60a5fa">{content.t("ontoref-yin-title")}</h3>
<p class="ds-caption ds-text-muted italic mb-3">{content.t("ontoref-yin-sub")}</p>
<ul class="list-none p-0 m-0 space-y-1 ds-caption ds-text-secondary leading-relaxed"
inner_html=content.t("ontoref-yin-desc")>
</ul>
</div>
// Yang card
<div class="ds-bg p-ds-6 ds-rounded-lg ds-shadow-sm border-l-4" style="border-color: #f97316">
<h3 class="ds-heading-4 mb-1" style="color: #f97316">{content.t("ontoref-yang-title")}</h3>
<p class="ds-caption ds-text-muted italic mb-3">{content.t("ontoref-yang-sub")}</p>
<ul class="list-none p-0 m-0 space-y-1 ds-caption ds-text-secondary leading-relaxed"
inner_html=content.t("ontoref-yang-desc")>
</ul>
</div>
</div>
// Tension quote box
<div class="ds-bg border ds-border ds-rounded-lg p-ds-4 font-mono text-center">
<p class="ds-caption ds-text-muted mb-1">{content.t("ontoref-tension-1")}</p>
<p class="ds-caption ds-text-muted mb-2">{content.t("ontoref-tension-2")}</p>
<p class="ds-caption font-bold" style="color: #f97316">{content.t("ontoref-tension-thesis")}</p>
</div>
</div>
</section>
// ── Protocol Stack (6 layers) ─────────────────────────────────────────
<section class="py-ds-6 ds-bg ds-rounded-lg">
<div class="ds-container-lg ds-container">
<div class="text-center mb-ds-8">
<h2 class="text-3xl font-bold ds-text">{content.t("ontoref-layers-title")}</h2>
</div>
<div class="flex flex-col gap-3">
{PROTOCOL_LAYERS.iter().map(|l| {
let label = content.t(l.label_key);
let items = content.t(l.items_key);
let desc = content.t(l.desc_key);
let accent = l.accent;
view! {
<div
class="ds-bg-page ds-rounded-r-lg ds-shadow-sm border-l-4 p-4"
style=format!("border-color: {accent}")
>
<div
class="ds-caption font-bold font-mono tracking-wider uppercase mb-1"
style=format!("color: {accent}")
>
{label}
</div>
<div class="ds-caption font-mono ds-text-muted mb-1">{items}</div>
<div class="ds-caption ds-text-secondary italic">{desc}</div>
</div>
}
}).collect_view()}
</div>
</div>
</section>
// ── Web UI ───────────────────────────────────────────────────────────
<section class="py-ds-6 ds-rounded-lg">
<div class="ds-container-lg ds-container">
<div class="text-center mb-ds-8">
<h2 class="text-3xl font-bold ds-text">{content.t("ontoref-ui-title")}</h2>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{UI_PAGES.iter().map(|u| {
let title = content.t(u.title_key);
let desc = content.t(u.desc_key);
let emoji = u.emoji;
view! {
<div class="ds-bg p-ds-6 ds-rounded-lg ds-border border ds-shadow-sm">
<div class="flex items-center gap-3 mb-3">
<span class="text-xl">{emoji}</span>
<h3 class="ds-heading-4 ds-text">{title}</h3>
</div>
<p class="ds-caption ds-text-secondary leading-relaxed">{desc}</p>
</div>
}
}).collect_view()}
</div>
</div>
</section>
// ── Graph View ───────────────────────────────────────────────────────
<section class="py-ds-6 ds-bg ds-rounded-lg ds-container">
<div class="mx-auto max-w-5xl text-center">
<h2 class="text-3xl font-bold ds-text mb-ds-8">{content.t("ontoref-graph-title")}</h2>
<ImageModal
src_light="/images/projects/ontoref_graph_view-light.png"
src_dark="/images/projects/ontoref_graph_view-dark.png"
alt="Ontoref ontology graph — force-directed D3 visualization"
expand_label=content.t("common-expand-image")
/>
</div>
</section>
// ── MCP Server ───────────────────────────────────────────────────────
<section class="py-ds-6 ds-rounded-lg">
<div class="ds-container-lg ds-container">
<div class="text-center mb-ds-8">
<h2 class="text-3xl font-bold ds-text">{content.t("ontoref-mcp-title")}</h2>
</div>
<p class="ds-body ds-text-secondary mb-8 max-w-4xl mx-auto leading-relaxed"
inner_html=content.t("ontoref-mcp-core-desc")>
</p>
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
{MCP_GROUPS.iter().skip(1).map(|m| {
let title = content.t(m.title_key);
let desc = content.t(m.desc_key);
let emoji = m.emoji;
view! {
<div class="ds-bg p-ds-6 ds-rounded-lg ds-shadow-sm border-l-4 ds-border">
<div class="flex items-center gap-3 mb-3">
<span class="text-xl">{emoji}</span>
<h3 class="ds-heading-4 ds-text">{title}</h3>
</div>
<ul class="list-disc list-inside ds-caption ds-text-secondary space-y-1 leading-relaxed"
inner_html=desc>
</ul>
</div>
}
}).collect_view()}
</div>
</div>
</section>
// ── Tech Stack ───────────────────────────────────────────────────────
<section class="py-ds-6 ds-bg ds-rounded-lg">
<div class="ds-container-lg ds-container">
<div class="text-center mb-ds-8">
<h2 class="text-3xl font-bold ds-text">{content.t("ontoref-tech-stack-title")}</h2>
</div>
<div class="flex flex-wrap gap-3 justify-center">
{TECH_BADGES.iter().map(|badge| {
view! {
<span class="px-3 py-2 ds-bg-page border ds-border ds-rounded-md ds-caption font-mono ds-text text-sm">
{*badge}
</span>
}
}).collect_view()}
</div>
</div>
</section>
// ── Crates & Tooling ─────────────────────────────────────────────────
<section class="py-ds-6 ds-rounded-lg">
<div class="ds-container-lg ds-container">
<div class="text-center mb-ds-8">
<h2 class="text-3xl font-bold ds-text">{content.t("ontoref-crates-title")}</h2>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{CRATES.iter().map(|c| {
let title = content.t(c.title_key);
let desc = content.t(c.desc_key);
let emoji = c.emoji;
view! {
<div class="ds-bg p-ds-6 ds-rounded-lg border ds-border ds-shadow-sm">
<div class="flex items-center gap-3 mb-3">
<span class="text-xl">{emoji}</span>
<h3 class="ds-heading-4 ds-text font-mono">{title}</h3>
</div>
<p class="ds-caption ds-text-secondary leading-relaxed" inner_html=desc></p>
</div>
}
}).collect_view()}
</div>
</div>
</section>
// ── Adoption Grid ────────────────────────────────────────────────────
<section class="py-ds-6 ds-bg ds-rounded-lg">
<div class="ds-container-lg ds-container">
<div class="text-center mb-4">
<h2 class="text-2xl font-bold ds-text" style="color: #f97316">
{content.t("ontoref-adoption-title")}
</h2>
<p class="ds-caption ds-text-secondary mt-2">{content.t("ontoref-adoption-subtitle")}</p>
</div>
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-3 mt-6">
{ADOPTION_PROJECTS.iter().map(|(name, role)| view! {
<div class="ds-bg-page border ds-border ds-rounded-lg p-3 text-center">
<span class="block ds-caption font-mono font-bold ds-text mb-1">{*name}</span>
<span class="block" style="font-size: 0.7rem; color: var(--ds-text-secondary)">{*role}</span>
</div>
}).collect_view()}
</div>
</div>
</section>
// ── Metrics ──────────────────────────────────────────────────────────
<section class="py-ds-6 ds-bg ds-rounded-lg">
<div class="ds-container-lg ds-container">
<div class="text-center mb-ds-8">
<h2 class="text-3xl font-bold ds-text">{content.t("ontoref-metrics-title")}</h2>
</div>
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-3 gap-4">
{[
("3 Rust Crates", "ontology · reflection · daemon"),
("19 MCP Tools", "AI agent integration · stdio + HTTP"),
("1 Web UI · 12 Pages", "dashboard · graph · search · sessions · notifications · backlog · Q&A · actions · modes · compose"),
("6 Protocol Layers", "Declarative → Adoption"),
("1 Search Engine", "nodes · ADRs · reflection modes"),
("16 Nu Modules", "Structured data pipelines"),
("8+ Reflection Modes", "DAG workflow contracts"),
("3 Actor Types", "developer / agent / CI"),
("0 Enforcement", "Voluntary adoption"),
].iter().map(|(name, role)| view! {
<div class="ds-bg-page border ds-border ds-rounded-lg p-4">
<span class="block ds-heading-4 ds-text font-mono mb-1">{*name}</span>
<span class="block ds-caption ds-text-secondary">{*role}</span>
</div>
}).collect_view()}
</div>
</div>
</section>
// ── CTA ──────────────────────────────────────────────────────────────
<section class="py-ds-6 ds-rounded-lg">
<div class="mx-auto max-w-3xl ds-container text-center">
<h2 class="text-3xl font-bold ds-text mb-ds-4">{content.t("ontoref-cta-title")}</h2>
<p class="ds-text-secondary mb-8 ds-body">{content.t("ontoref-cta-subtitle")}</p>
<div class="flex flex-col sm:flex-row gap-4 justify-center">
<a
href="https://github.com/stratum-iops/ontoref"
target="_blank"
rel="noopener noreferrer"
class="no-underline ds-btn-primary"
>
{content.t("ontoref-cta-explore")}
</a>
</div>
</div>
</section>
<ProjectGraphSection node_id="ontoref".to_string() />
</div>
}
}

View file

@ -0,0 +1,50 @@
use leptos::prelude::*;
use crate::content_graph::{graph_mini::GraphMini, ontology_context::OntologyContext, related_content::RelatedContent};
/// Local adapter for the PostViewer page.
///
/// Routes both SSR and WASM through `PostViewerPageClient`, which:
/// - On SSR: loads markdown synchronously via `load_content_by_slug` for SEO
/// - On WASM: fetches via `/api/content-render/…` + handles language switching
///
/// Both paths emit the same `{move || …}` DynChild structure → hydration safe.
///
/// Renders a two-column layout: post content on the left, graph sidebar on the
/// right (RelatedContent, OntologyContext, GraphMini). The `slug` prop doubles
/// as the content graph node id for sidebar lookups — if the node is not in the
/// graph the sidebar components render nothing.
#[component]
pub fn UnifiedPostViewerPage(
#[prop(default = rustelo_content::rustelo_core_lib::config::get_default_language().to_string())]
_language: String,
#[allow(unused_variables)]
#[prop(optional)]
lang_content: Option<std::collections::HashMap<String, String>>,
#[prop(default = "content".to_string())] content_type: String,
#[prop(default = "".to_string())] slug: String,
#[allow(unused_variables)]
#[prop(default = "".to_string())]
category: String,
) -> impl IntoView {
let node_id = slug.clone();
view! {
<div class="ds-container py-ds-6">
<div class="lg:grid lg:grid-cols-[1fr_280px] lg:gap-8 space-y-6 lg:space-y-0">
<main class="min-w-0">
<rustelo_pages_leptos::post_viewer::client::PostViewerPageClient
_language=_language
content_type=content_type
slug=slug
/>
</main>
<aside class="space-y-4 shrink-0">
<GraphMini node_id=node_id.clone() />
<RelatedContent node_id=node_id.clone() />
<OntologyContext node_id=node_id />
</aside>
</div>
</div>
}
}

View file

@ -0,0 +1,2 @@
pub mod unified;
pub use unified::UnifiedProvisioningPage;

View file

@ -0,0 +1,376 @@
use leptos::prelude::*;
use rustelo_content::rustelo_core_lib::i18n::content_helper::create_content_provider_reactive;
use rustelo_components_leptos::ImageModal;
use crate::content_graph::ProjectGraphSection;
struct Capability {
number: &'static str,
title_key: &'static str,
desc_key: &'static str,
}
const CAPABILITIES: &[Capability] = &[
Capability {
number: "01",
title_key: "provisioning-cap-1-title",
desc_key: "provisioning-cap-1-desc",
},
Capability {
number: "02",
title_key: "provisioning-cap-2-title",
desc_key: "provisioning-cap-2-desc",
},
Capability {
number: "03",
title_key: "provisioning-cap-3-title",
desc_key: "provisioning-cap-3-desc",
},
Capability {
number: "04",
title_key: "provisioning-cap-4-title",
desc_key: "provisioning-cap-4-desc",
},
];
struct HowItWorks {
title_key: &'static str,
desc_key: &'static str,
emoji: &'static str,
}
const HOW_IT_WORKS: &[HowItWorks] = &[
HowItWorks {
title_key: "provisioning-how-nickel-title",
desc_key: "provisioning-how-nickel-desc",
emoji: "\u{1f4dd}",
},
HowItWorks {
title_key: "provisioning-how-nushell-title",
desc_key: "provisioning-how-nushell-desc",
emoji: "\u{1f504}",
},
HowItWorks {
title_key: "provisioning-how-rust-title",
desc_key: "provisioning-how-rust-desc",
emoji: "\u{26a1}",
},
HowItWorks {
title_key: "provisioning-how-nats-title",
desc_key: "provisioning-how-nats-desc",
emoji: "\u{1f4e1}",
},
HowItWorks {
title_key: "provisioning-how-security-title",
desc_key: "provisioning-how-security-desc",
emoji: "\u{1f510}",
},
HowItWorks {
title_key: "provisioning-how-oci-title",
desc_key: "provisioning-how-oci-desc",
emoji: "\u{1f9e9}",
},
HowItWorks {
title_key: "provisioning-how-cli-title",
desc_key: "provisioning-how-cli-desc",
emoji: "\u{1f39b}\u{fe0f}",
},
HowItWorks {
title_key: "provisioning-how-versions-title",
desc_key: "provisioning-how-versions-desc",
emoji: "\u{1f4cc}",
},
];
struct SolidBoundary {
title_key: &'static str,
desc_key: &'static str,
emoji: &'static str,
}
const SOLID_BOUNDARIES: &[SolidBoundary] = &[
SolidBoundary {
title_key: "provisioning-solid-provider-title",
desc_key: "provisioning-solid-provider-desc",
emoji: "\u{1f3af}",
},
SolidBoundary {
title_key: "provisioning-solid-auth-title",
desc_key: "provisioning-solid-auth-desc",
emoji: "\u{1f510}",
},
SolidBoundary {
title_key: "provisioning-solid-secrets-title",
desc_key: "provisioning-solid-secrets-desc",
emoji: "\u{1f511}",
},
];
struct PlatformService {
name: &'static str,
role_key: &'static str,
}
const PLATFORM_SERVICES: &[PlatformService] = &[
PlatformService {
name: "Orchestrator",
role_key: "provisioning-svc-orchestrator-role",
},
PlatformService {
name: "ControlCenter",
role_key: "provisioning-svc-controlcenter-role",
},
PlatformService {
name: "ControlCenter-UI",
role_key: "provisioning-svc-controlcenter-ui-role",
},
PlatformService {
name: "MCP-Server",
role_key: "provisioning-svc-mcp-server-role",
},
PlatformService {
name: "AI-Service",
role_key: "provisioning-svc-ai-service-role",
},
PlatformService {
name: "Extension-Registry",
role_key: "provisioning-svc-extension-registry-role",
},
PlatformService {
name: "SecretumVault",
role_key: "provisioning-svc-secretumvault-role",
},
PlatformService {
name: "Detector",
role_key: "provisioning-svc-detector-role",
},
PlatformService {
name: "Daemon-CLI",
role_key: "provisioning-svc-daemon-cli-role",
},
PlatformService {
name: "Machines",
role_key: "provisioning-svc-machines-role",
},
PlatformService {
name: "Observability",
role_key: "provisioning-svc-observability-role",
},
PlatformService {
name: "Backup",
role_key: "provisioning-svc-backup-role",
},
PlatformService {
name: "Encrypt",
role_key: "provisioning-svc-encrypt-role",
},
];
const TECH_BADGES: &[&str] = &[
"Nickel",
"Nushell 0.110",
"Rust",
"NATS JetStream",
"SurrealDB",
"Cedar",
"Leptos WASM",
"Kubernetes",
"Docker Compose",
"SOPS 3.10",
"Age 1.2",
"Git",
];
#[component]
pub fn UnifiedProvisioningPage(
#[prop(default = rustelo_content::rustelo_core_lib::config::get_default_language().to_string())]
_language: String,
#[prop(optional)] lang_content: Option<std::collections::HashMap<String, String>>,
) -> impl IntoView {
let content = create_content_provider_reactive(lang_content.clone());
view! {
<div class="ds-bg-page min-h-screen">
// ── Hero ─────────────────────────────────────────────────────────────
<section class="relative py-ds-8 ds-container">
<div class="mx-auto max-w-4xl text-center">
<div class="flex flex-col items-center gap-4 mb-6">
<img
src="/images/logos/projects/provisioning.svg"
alt="Provisioning"
class="h-28"
/>
</div>
<p class="ds-caption ds-text-secondary mb-2 font-mono italic">
{content.t("provisioning-tagline")}
</p>
<span class="inline-block px-3 py-1 ds-caption ds-bg border ds-border ds-rounded-md ds-text-secondary mb-4">
{content.t("provisioning-badge")}
</span>
<h1 class="text-balance text-4xl leading-11 font-bold tracking-tight ds-text sm:text-5xl mb-4">
{content.t("provisioning-page-title")}
</h1>
<p class="ds-body ds-text-secondary max-w-3xl mx-auto">
{content.t("provisioning-page-subtitle")}
</p>
<div class="flex flex-col sm:flex-row gap-4 justify-center mt-8">
<a
href="https://repo.ontoref.dev/org/provisioning"
target="_blank"
rel="noopener noreferrer"
class="no-underline ds-btn-primary"
>
{content.t("provisioning-cta-explore")}
</a>
</div>
</div>
</section>
// ── Core Capabilities ──────────────────────────────────────────────
<section class="py-ds-6 ds-bg ds-rounded-lg">
<div class="ds-container-lg ds-container">
<div class="text-center mb-ds-12">
<h2 class="text-3xl font-bold ds-text">{content.t("provisioning-capabilities-title")}</h2>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
{CAPABILITIES.iter().map(|c| {
let title = content.t(c.title_key);
let desc = content.t(c.desc_key);
let num = c.number;
view! {
<div class="ds-bg-page p-ds-6 ds-rounded-lg ds-border border ds-shadow-sm">
<div class="text-2xl font-bold mb-3 bg-gradient-to-r from-emerald-500 to-blue-500 bg-clip-text text-transparent">{num}</div>
<h3 class="ds-heading-4 ds-text mb-2">{title}</h3>
<p class="ds-caption ds-text-secondary">{desc}</p>
</div>
}
}).collect_view()}
</div>
</div>
</section>
// ── How It Works ───────────────────────────────────────────────────
<section class="py-ds-6 ds-rounded-lg">
<div class="ds-container-lg ds-container">
<div class="text-center mb-ds-12">
<h2 class="text-3xl font-bold ds-text">{content.t("provisioning-how-title")}</h2>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
{HOW_IT_WORKS.iter().map(|f| {
let title = content.t(f.title_key);
let desc = content.t(f.desc_key);
let emoji = f.emoji;
view! {
<div class="ds-bg p-ds-6 ds-rounded-lg ds-shadow-sm border-l-4 ds-border">
<div class="flex items-center gap-3 mb-3">
<span class="text-xl">{emoji}</span>
<h3 class="ds-heading-4 ds-text">{title}</h3>
</div>
<p class="ds-caption ds-text-secondary leading-relaxed">{desc}</p>
</div>
}
}).collect_view()}
</div>
</div>
</section>
// ── SOLID Architecture Boundaries ──────────────────────────────────
<section class="py-ds-6 ds-bg ds-rounded-lg">
<div class="ds-container-lg ds-container">
<div class="text-center mb-ds-12">
<h2 class="text-3xl font-bold ds-text">{content.t("provisioning-solid-title")}</h2>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
{SOLID_BOUNDARIES.iter().map(|b| {
let title = content.t(b.title_key);
let desc = content.t(b.desc_key);
let emoji = b.emoji;
view! {
<div class="ds-bg-page p-ds-6 ds-rounded-lg ds-border border ds-shadow-sm">
<div class="text-2xl mb-3">{emoji}</div>
<h3 class="ds-heading-4 ds-text mb-2">{title}</h3>
<p class="ds-caption ds-text-secondary">{desc}</p>
</div>
}
}).collect_view()}
</div>
</div>
</section>
// ── Architecture Diagram ───────────────────────────────────────────
<section class="py-ds-6 ds-container">
<div class="mx-auto max-w-5xl text-center">
<h2 class="text-3xl font-bold ds-text mb-ds-8">{content.t("provisioning-architecture-title")}</h2>
<ImageModal
src_light="/images/projects/provisioning_architecture-light.svg"
src_dark="/images/projects/provisioning_architecture-dark.svg"
alt="Provisioning architecture diagram"
expand_label=content.t("common-expand-image")
/>
</div>
</section>
// ── Tech Stack ─────────────────────────────────────────────────────
<section class="py-ds-6 ds-bg ds-rounded-lg">
<div class="ds-container-lg ds-container">
<div class="text-center mb-ds-8">
<h2 class="text-3xl font-bold ds-text">{content.t("provisioning-tech-stack-title")}</h2>
</div>
<div class="flex flex-wrap gap-3 justify-center">
{TECH_BADGES.iter().map(|badge| {
view! {
<span class="px-3 py-2 ds-bg-page border ds-border ds-rounded-md ds-caption font-mono ds-text">
{*badge}
</span>
}
}).collect_view()}
</div>
</div>
</section>
// ── Platform Services ──────────────────────────────────────────────
<section class="py-ds-6 ds-rounded-lg">
<div class="ds-container-lg ds-container">
<div class="text-center mb-ds-8">
<h2 class="text-3xl font-bold ds-text">{content.t("provisioning-services-title")}</h2>
</div>
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
{PLATFORM_SERVICES.iter().map(|s| {
let role = content.t(s.role_key);
let name = s.name;
view! {
<div class="ds-bg p-4 ds-rounded-lg text-center border ds-border ds-shadow-sm">
<div class="ds-caption font-bold ds-text mb-1">{name}</div>
<div class="text-xs ds-text-secondary">{role}</div>
</div>
}
}).collect_view()}
</div>
</div>
</section>
// ── CTA ────────────────────────────────────────────────────────────
<section class="py-ds-6 ds-bg ds-rounded-lg">
<div class="mx-auto max-w-3xl ds-container text-center">
<h2 class="text-3xl font-bold ds-text mb-ds-4">{content.t("provisioning-cta-title")}</h2>
<p class="ds-text-secondary mb-8 ds-body">{content.t("provisioning-cta-subtitle")}</p>
<div class="flex flex-col sm:flex-row gap-4 justify-center">
<a
href="https://repo.ontoref.dev/org/provisioning"
target="_blank"
rel="noopener noreferrer"
class="no-underline ds-btn-primary"
>
{content.t("provisioning-cta-explore")}
</a>
</div>
</div>
</section>
<ProjectGraphSection node_id="provisioning-systems".to_string() />
</div>
}
}

View file

@ -0,0 +1,2 @@
pub mod unified;
pub use unified::UnifiedSecretumvaultPage;

View file

@ -0,0 +1,196 @@
use leptos::prelude::*;
use rustelo_content::rustelo_core_lib::i18n::content_helper::create_content_provider_reactive;
use crate::content_graph::ProjectGraphSection;
struct Feature {
title_key: &'static str,
desc_key: &'static str,
emoji: &'static str,
}
const FEATURES: &[Feature] = &[
Feature {
title_key: "secretumvault-feat-pqc-title",
desc_key: "secretumvault-feat-pqc-desc",
emoji: "\u{1f510}",
},
Feature {
title_key: "secretumvault-feat-engines-title",
desc_key: "secretumvault-feat-engines-desc",
emoji: "\u{1f511}",
},
Feature {
title_key: "secretumvault-feat-storage-title",
desc_key: "secretumvault-feat-storage-desc",
emoji: "\u{1f4be}",
},
Feature {
title_key: "secretumvault-feat-cedar-title",
desc_key: "secretumvault-feat-cedar-desc",
emoji: "\u{1f6e1}\u{fe0f}",
},
Feature {
title_key: "secretumvault-feat-cloud-title",
desc_key: "secretumvault-feat-cloud-desc",
emoji: "\u{2601}\u{fe0f}",
},
Feature {
title_key: "secretumvault-feat-enterprise-title",
desc_key: "secretumvault-feat-enterprise-desc",
emoji: "\u{1f3e6}",
},
];
const TECH_BADGES: &[&str] = &[
"Rust",
"Axum",
"Tokio",
"ML-KEM-768",
"ML-DSA-65",
"OQS",
"OpenSSL",
"AWS-LC",
"etcd",
"SurrealDB",
"PostgreSQL",
"Cedar",
"Docker",
"Kubernetes",
"Helm",
"Prometheus",
"NATS",
"Shamir SSS",
];
#[component]
pub fn UnifiedSecretumvaultPage(
#[prop(default = rustelo_content::rustelo_core_lib::config::get_default_language().to_string())]
_language: String,
#[prop(optional)] lang_content: Option<std::collections::HashMap<String, String>>,
) -> impl IntoView {
let content = create_content_provider_reactive(lang_content.clone());
view! {
<div class="ds-bg-page min-h-screen">
// ── Hero ─────────────────────────────────────────────────────────────
<section class="relative py-ds-8 ds-container">
<div class="mx-auto max-w-4xl text-center">
<div class="flex flex-col items-center gap-4 mb-6">
<img
src="/images/logos/projects/secretumvault.svg"
alt="SecretumVault"
class="h-28"
/>
</div>
<p class="ds-caption ds-text-secondary mb-2 font-mono italic">
{content.t("secretumvault-tagline")}
</p>
<span class="inline-block px-3 py-1 ds-caption ds-bg border ds-border ds-rounded-md ds-text-secondary mb-4">
{content.t("secretumvault-badge")}
</span>
<h1 class="text-balance text-4xl font-bold leading-11 tracking-tight ds-text sm:text-5xl mb-4">
{content.t("secretumvault-page-title")}
</h1>
<p class="ds-body ds-text-secondary max-w-3xl mx-auto">
{content.t("secretumvault-page-subtitle")}
</p>
<div class="flex flex-col sm:flex-row gap-4 justify-center mt-8">
<a
href="https://repo.ontoref.dev/org/secretumvault"
target="_blank"
rel="noopener noreferrer"
class="no-underline ds-btn-primary"
>
{content.t("secretumvault-cta-explore")}
</a>
</div>
</div>
</section>
// ── Why SecretumVault (Problem / Solution) ───────────────────────────
<section class="py-ds-6 ds-bg ds-rounded-lg">
<div class="ds-container-lg ds-container">
<div class="text-center mb-ds-12">
<h2 class="text-3xl font-bold ds-text">{content.t("secretumvault-why-title")}</h2>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="ds-bg-page p-ds-6 ds-rounded-lg border-l-4 border-red-500/50 ds-border border ds-shadow-sm">
<h3 class="ds-heading-4 ds-text mb-3">{content.t("secretumvault-problem-title")}</h3>
<p class="ds-caption ds-text-secondary leading-relaxed">{content.t("secretumvault-problem-desc")}</p>
</div>
<div class="ds-bg-page p-ds-6 ds-rounded-lg border-l-4 ds-border border ds-shadow-sm">
<h3 class="ds-heading-4 ds-text mb-3">{content.t("secretumvault-solution-title")}</h3>
<p class="ds-caption ds-text-secondary leading-relaxed">{content.t("secretumvault-solution-desc")}</p>
</div>
</div>
</div>
</section>
// ── Core Capabilities ────────────────────────────────────────────────
<section class="py-ds-6 ds-rounded-lg">
<div class="ds-container-lg ds-container">
<div class="text-center mb-ds-12">
<h2 class="text-3xl font-bold ds-text">{content.t("secretumvault-features-title")}</h2>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{FEATURES.iter().map(|f| {
let title = content.t(f.title_key);
let desc = content.t(f.desc_key);
let emoji = f.emoji;
view! {
<div class="ds-bg p-ds-6 ds-rounded-lg ds-shadow-sm border-l-4 ds-border">
<div class="flex items-center gap-3 mb-3">
<span class="text-xl">{emoji}</span>
<h3 class="ds-heading-4 ds-text">{title}</h3>
</div>
<p class="ds-caption ds-text-secondary leading-relaxed">{desc}</p>
</div>
}
}).collect_view()}
</div>
</div>
</section>
// ── Tech Stack ───────────────────────────────────────────────────────
<section class="py-ds-6 ds-bg ds-rounded-lg">
<div class="ds-container-lg ds-container">
<div class="text-center mb-ds-8">
<h2 class="text-3xl font-bold ds-text">{content.t("secretumvault-tech-stack-title")}</h2>
</div>
<div class="flex flex-wrap gap-3 justify-center">
{TECH_BADGES.iter().map(|badge| {
view! {
<span class="px-3 py-2 ds-bg-page border ds-border ds-rounded-md ds-caption font-mono ds-text">
{*badge}
</span>
}
}).collect_view()}
</div>
</div>
</section>
// ── CTA ──────────────────────────────────────────────────────────────
<section class="py-ds-6 ds-rounded-lg">
<div class="mx-auto max-w-3xl ds-container text-center">
<h2 class="text-3xl font-bold ds-text mb-ds-4">{content.t("secretumvault-cta-title")}</h2>
<p class="ds-text-secondary mb-8 ds-body">{content.t("secretumvault-cta-subtitle")}</p>
<div class="flex flex-col sm:flex-row gap-4 justify-center">
<a
href="https://repo.ontoref.dev/org/secretumvault"
target="_blank"
rel="noopener noreferrer"
class="no-underline ds-btn-primary"
>
{content.t("secretumvault-cta-explore")}
</a>
</div>
</div>
</section>
<ProjectGraphSection node_id="secretumvault".to_string() />
</div>
}
}

View file

@ -0,0 +1,20 @@
//! Services Page Module
//!
//! This module contains the unified Services page component for JLP Website.
//! It integrates with the foundation page system while providing custom functionality.
pub mod unified;
// Re-export the unified component for external use
pub use unified::UnifiedServicesPage;
// Export with the canonical name for page provider compatibility
pub use unified::UnifiedServicesPage as Services;
// Auto-generated components from build.rs
// Include generated ServicesPage, ServicesClient, ServicesPageSSR components
// TODO: Fix build script to generate page_services.rs
// include!(concat!(env!("OUT_DIR"), "/page_services.rs"));
// Temporary placeholder until build script is fixed
pub use rustelo_pages_leptos::*;

View file

@ -0,0 +1,304 @@
//! Unified Services Page Component using shared delegation patterns
//!
//! This module provides a unified interface that automatically selects between
//! client-side reactive and server-side static implementations based on context.
use leptos::prelude::*;
use rustelo_content::rustelo_core_lib::i18n::content_helper::create_content_provider_reactive;
#[cfg(target_arch = "wasm32")]
use rustelo_content::rustelo_core_lib::utils::nav;
/// Unified Services Page component that works in both SSR and client contexts
/// Takes structured content data instead of individual parameters
#[component]
pub fn UnifiedServicesPage(
#[prop(default = rustelo_content::rustelo_core_lib::config::get_default_language().to_string())]
_language: String,
#[prop(optional)] lang_content: Option<std::collections::HashMap<String, String>>,
) -> impl IntoView {
// Use DRY content accessor helper
let content = create_content_provider_reactive(lang_content.clone());
// Pre-extract ONLY URLs for href attributes to avoid closure ownership issues
// Map to actual keys from the FTL file
let services_cta_contact_url = content.t("services-contact-url");
let services_cta_work_request_url = content.t("services-work-request-url");
let services_cta_contact_text = content.t("services-schedule-consultation");
let services_cta_work_request_text = content.t("services-request-project-quote");
view! {
<div class="min-h-screen ds-bg-page">
// Header Section
<section class="py-8 ds-container">
<div class="mx-auto max-w-4xl text-center">
<h1 class="text-balance text-4xl font-bold tracking-tight ds-text sm:text-6xl">
{content.t("services-page-title")}
</h1>
<p class="mt-ds-6 ds-body ds-text-secondary max-w-2xl mx-auto">
{content.t("services-page-subtitle")}
</p>
</div>
</section>
// Project Participation Section
<section id="projects" data-anchor-es="proyectos" class="py-16 ds-bg">
<div class="ds-container-lg ds-container">
<div class="text-center mb-ds-12">
<h2 class="text-3xl font-bold ds-text">{content.t("services-project-participation")}</h2>
<p class="mt-4 ds-text-secondary max-w-3xl mx-auto">
{content.t("services-project-participation-desc")}
</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8">
<div class="ds-bg-page p-ds-6 ds-rounded-lg ds-shadow-sm">
<h3 class="ds-heading-4 ds-text mb-3">{content.t("services-requirements-criteria")}</h3>
<p class="ds-text-secondary">
{content.t("services-requirements-criteria-desc")}
</p>
</div>
<div class="ds-bg-page p-ds-6 ds-rounded-lg ds-shadow-sm">
<h3 class="ds-heading-4 ds-text mb-3">{content.t("services-custom-infrastructure")}</h3>
<p class="ds-text-secondary">
{content.t("services-custom-infrastructure-desc")}
</p>
</div>
<div class="ds-bg-page p-ds-6 ds-rounded-lg ds-shadow-sm">
<h3 class="ds-heading-4 ds-text mb-3">{content.t("services-cicd-implementation")}</h3>
<p class="ds-text-secondary">
{content.t("services-cicd-implementation-desc")}
</p>
</div>
<div class="ds-bg-page p-ds-6 ds-rounded-lg ds-shadow-sm md:col-span-2 lg:col-span-1">
<h3 class="ds-heading-4 ds-text mb-3">{content.t("services-research-prototyping")}</h3>
<p class="ds-text-secondary">
{content.t("services-research-prototyping-desc")}
</p>
</div>
</div>
// What You Get Section
<div class="mt-16">
<h3 class="text-2xl font-bold ds-text text-center mb-8">
{content.t("services-what-you-get")}
</h3>
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 max-w-4xl mx-auto">
<div class="flex items-start gap-3">
<span class="ds-text-secondary">{content.t("services-production-ready-code")}</span>
</div>
<div class="flex items-start gap-3">
<span class="ds-text-secondary">{content.t("services-complete-documentation")}</span>
</div>
<div class="flex items-start gap-3">
<span class="ds-text-secondary">{content.t("services-knowledge-transfer")}</span>
</div>
<div class="flex items-start gap-3">
<span class="ds-text-secondary">{content.t("services-post-deployment-support")}</span>
</div>
<div class="flex items-start gap-3 md:col-span-2">
<span class="ds-text-secondary">{content.t("services-open-source-focus")}</span>
</div>
</div>
</div>
</div>
</section>
// Mentoring Section
<section id="mentoring" data-anchor-es="mentoria" class="py-16 ds-bg-page">
<div class="ds-container-lg ds-container">
<div class="text-center mb-ds-12">
<h2 class="text-3xl font-bold ds-text">{content.t("services-mentoring-guidance")}</h2>
<p class="mt-4 ds-text-secondary max-w-3xl mx-auto">
{content.t("services-mentoring-guidance-desc")}
</p>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-12">
// Technical Collaboration
<div>
<h3 class="text-2xl font-bold ds-text mb-6">
{content.t("services-technical-collaboration")}
</h3>
<p class="ds-text-secondary mb-8">
{content.t("services-technical-collaboration-desc")}
</p>
<div class="space-y-3">
<div class="ds-bg p-ds-4 ds-rounded-lg">
<div class="flex justify-between items-center mb-2">
<h4 class="font-semibold ds-text">{content.t("services-one-on-one-sessions")}</h4>
<span class="text-sm ds-text-secondary">{content.t("services-one-to-four-hours")}</span>
</div>
</div>
<div class="ds-bg p-ds-4 ds-rounded-lg">
<div class="flex justify-between items-center mb-2">
<h4 class="font-semibold ds-text">{content.t("services-pair-programming")}</h4>
<span class="text-sm ds-text-secondary">{content.t("services-two-to-eight-hours")}</span>
</div>
<p class="text-sm ds-text-secondary">{content.t("services-pair-programming-sessions-desc")}</p>
</div>
<div class="ds-bg p-ds-4 ds-rounded-lg">
<div class="flex justify-between items-center mb-2">
<h4 class="font-semibold ds-text">{content.t("services-code-review-sessions")}</h4>
<span class="text-sm ds-text-secondary">{content.t("services-one-to-two-hours")}</span>
</div>
<p class="text-sm ds-text-secondary">{content.t("services-architecture-reviews-desc")}</p>
</div>
<div class="ds-bg p-ds-4 ds-rounded-lg">
<div class="flex justify-between items-center mb-2">
<h4 class="font-semibold ds-text">{content.t("services-team-guidance")}</h4>
<span class="text-sm ds-text-secondary">{content.t("services-ongoing")}</span>
</div>
<p class="text-sm ds-text-secondary">{content.t("services-research-guidance-desc")}</p>
</div>
</div>
</div>
// Training & Education
<div id="training" data-anchor-es="capacitacion">
<h3 class="text-2xl font-bold ds-text mb-6">
{content.t("services-training-education")}
</h3>
<p class="ds-text-secondary mb-8">
{content.t("services-training-education-desc")}
</p>
<div class="space-y-3">
<div class="ds-bg p-ds-4 ds-rounded-lg">
<h4 class="font-semibold ds-text mb-2">{content.t("services-custom-courses")}</h4>
<p class="text-sm ds-text-secondary">{content.t("services-custom-courses-desc")}</p>
</div>
<div class="ds-bg p-ds-4 ds-rounded-lg">
<h4 class="font-semibold ds-text mb-2">{content.t("services-technical-workshops")}</h4>
<p class="text-sm ds-text-secondary">{content.t("services-technical-workshops-desc")}</p>
</div>
<div class="ds-bg p-ds-4 ds-rounded-lg">
<h4 class="font-semibold ds-text mb-2">{content.t("services-conference-talks")}</h4>
<p class="text-sm ds-text-secondary">{content.t("services-conference-talks-desc")}</p>
</div>
<div class="ds-bg p-ds-4 ds-rounded-lg">
<h4 class="font-semibold ds-text mb-2">{content.t("services-educational-content")}</h4>
<p class="text-sm ds-text-secondary">{content.t("services-educational-content-desc")}</p>
</div>
</div>
</div>
</div>
</div>
</section>
// Popular Topics Section
<section class="py-16 ds-bg">
<div class="ds-container-lg ds-container">
<div class="text-center mb-ds-12">
<h2 class="text-3xl font-bold ds-text">{content.t("services-popular-topics")}</h2>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8">
// Rust Development
<div class="text-center">
<div class="w-16 h-16 mx-auto mb-ds-4 ds-bg-page ds-rounded-lg flex items-center justify-center">
<span class="text-2xl">"🦀"</span>
</div>
<h3 class="ds-heading-4 ds-text mb-3">{content.t("services-rust-development")}</h3>
<ul class="text-sm ds-text-secondary space-y-1 list-none">
<li>{content.t("services-systems-programming")}</li>
<li>{content.t("services-web-applications")}</li>
<li>{content.t("services-performance-optimization")}</li>
</ul>
</div>
// Infrastructure
<div class="text-center">
<div class="w-16 h-16 mx-auto mb-ds-4 ds-bg-page ds-rounded-lg flex items-center justify-center">
<span class="text-2xl">"🏗️"</span>
</div>
<h3 class="ds-heading-4 ds-text mb-3">{content.t("services-infrastructure")}</h3>
<ul class="text-sm ds-text-secondary space-y-1 list-none">
<li>{content.t("services-kubernetes-deployment")}</li>
<li>{content.t("services-cicd-pipelines")}</li>
<li>{content.t("services-self-hosting")}</li>
</ul>
</div>
// Web3 & Blockchain
<div class="text-center">
<div class="w-16 h-16 mx-auto mb-ds-4 ds-bg-page ds-rounded-lg flex items-center justify-center">
<span class="text-2xl">"🌐"</span>
</div>
<h3 class="ds-heading-4 ds-text mb-3">{content.t("services-web3-blockchain")}</h3>
<ul class="text-sm ds-text-secondary space-y-1 list-none">
<li>{content.t("services-polkadot-ecosystem")}</li>
<li>{content.t("services-smart-contracts")}</li>
<li>{content.t("services-dapp-development")}</li>
</ul>
</div>
// Best Practices
<div class="text-center">
<div class="w-16 h-16 mx-auto mb-ds-4 ds-bg-page ds-rounded-lg flex items-center justify-center">
<span class="text-2xl">""</span>
</div>
<h3 class="ds-heading-4 ds-text mb-3">{content.t("services-best-practices")}</h3>
<ul class="text-sm ds-text-secondary space-y-1 list-none">
<li>{content.t("services-open-source-strategy")}</li>
<li>{content.t("services-security-practices")}</li>
<li>{content.t("services-code-quality")}</li>
</ul>
</div>
</div>
</div>
</section>
// Call to Action Section
<section class="py-16 ds-bg-page">
<div class="mx-auto max-w-4xl ds-container text-center">
<h2 class="text-3xl font-bold ds-text mb-ds-4">{content.t("services-lets-discuss-needs")}</h2>
<p class="ds-text-secondary mb-8 ds-body">
{content.t("services-every-project-unique")}
</p>
<div class="flex flex-col sm:flex-row gap-4 justify-center">
<a href={services_cta_contact_url.clone()} class="no-underline ds-btn-primary"
on:click=move |_ev: leptos::ev::MouseEvent| {
#[cfg(target_arch = "wasm32")]
{
if let Some(set_path) = use_context::<WriteSignal<String>>() {
_ev.prevent_default();
_ev.stop_propagation();
nav::anchor_navigate(set_path, &services_cta_contact_url);
rustelo_content::rustelo_core_lib::utils::doc_scroll_to_top(100);
}
}
}
>
{services_cta_contact_text}
</a>
<a href={services_cta_work_request_url.clone()} class="no-underline ds-btn-secondary"
on:click=move |_ev: leptos::ev::MouseEvent| {
#[cfg(target_arch = "wasm32")]
{
if let Some(set_path) = use_context::<WriteSignal<String>>() {
_ev.prevent_default();
_ev.stop_propagation();
nav::anchor_navigate(set_path, &services_cta_work_request_url);
rustelo_content::rustelo_core_lib::utils::doc_scroll_to_top(100);
}
}
}
>
{services_cta_work_request_text}
</a>
</div>
</div>
</section>
</div>
}
}

View file

@ -0,0 +1,2 @@
pub mod unified;
pub use unified::UnifiedStratumiopsPage;

View file

@ -0,0 +1,559 @@
use leptos::prelude::*;
use rustelo_content::rustelo_core_lib::i18n::content_helper::create_content_provider_reactive;
use rustelo_components_leptos::ImageModal;
use crate::content_graph::ProjectGraphSection;
struct Problem {
number: &'static str,
title_key: &'static str,
desc_key: &'static str,
}
const PROBLEMS: &[Problem] = &[
Problem {
number: "01",
title_key: "stratumiops-problem-1-title",
desc_key: "stratumiops-problem-1-desc",
},
Problem {
number: "02",
title_key: "stratumiops-problem-2-title",
desc_key: "stratumiops-problem-2-desc",
},
Problem {
number: "03",
title_key: "stratumiops-problem-3-title",
desc_key: "stratumiops-problem-3-desc",
},
Problem {
number: "04",
title_key: "stratumiops-problem-4-title",
desc_key: "stratumiops-problem-4-desc",
},
Problem {
number: "05",
title_key: "stratumiops-problem-5-title",
desc_key: "stratumiops-problem-5-desc",
},
Problem {
number: "06",
title_key: "stratumiops-problem-6-title",
desc_key: "stratumiops-problem-6-desc",
},
Problem {
number: "07",
title_key: "stratumiops-problem-7-title",
desc_key: "stratumiops-problem-7-desc",
},
Problem {
number: "08",
title_key: "stratumiops-problem-8-title",
desc_key: "stratumiops-problem-8-desc",
},
];
struct EcosystemProject {
title_key: &'static str,
desc_key: &'static str,
emoji: &'static str,
page_route: &'static str,
}
const PROJECTS: &[EcosystemProject] = &[
EcosystemProject {
title_key: "stratumiops-proj-vapora-title",
desc_key: "stratumiops-proj-vapora-desc",
emoji: "\u{1f916}",
page_route: "/vapora",
},
EcosystemProject {
title_key: "stratumiops-proj-kogral-title",
desc_key: "stratumiops-proj-kogral-desc",
emoji: "\u{1f9e0}",
page_route: "/kogral",
},
EcosystemProject {
title_key: "stratumiops-proj-typedialog-title",
desc_key: "stratumiops-proj-typedialog-desc",
emoji: "\u{1f4cb}",
page_route: "/typedialog",
},
EcosystemProject {
title_key: "stratumiops-proj-provisioning-title",
desc_key: "stratumiops-proj-provisioning-desc",
emoji: "\u{2601}\u{fe0f}",
page_route: "/provisioning-systems",
},
EcosystemProject {
title_key: "stratumiops-proj-secretumvault-title",
desc_key: "stratumiops-proj-secretumvault-desc",
emoji: "\u{1f512}",
page_route: "/secretumvault",
},
EcosystemProject {
title_key: "stratumiops-proj-syntaxis-title",
desc_key: "stratumiops-proj-syntaxis-desc",
emoji: "\u{1f3af}",
page_route: "/syntaxis",
},
];
struct YinYangItem {
title_key: &'static str,
subtitle_key: &'static str,
items_key: &'static str,
accent: &'static str,
}
const YIN_YANG: &[YinYangItem] = &[
YinYangItem {
title_key: "stratumiops-yin-title",
subtitle_key: "stratumiops-yin-subtitle",
items_key: "stratumiops-yin-items",
accent: "border-blue-400",
},
YinYangItem {
title_key: "stratumiops-yang-title",
subtitle_key: "stratumiops-yang-subtitle",
items_key: "stratumiops-yang-items",
accent: "border-orange-400",
},
];
struct ArchLayer {
label_key: &'static str,
items_key: &'static str,
desc_key: &'static str,
accent: &'static str,
}
const ARCH_LAYERS: &[ArchLayer] = &[
ArchLayer {
label_key: "stratumiops-layer-decl-label",
items_key: "stratumiops-layer-decl-items",
desc_key: "stratumiops-layer-decl-desc",
accent: "border-blue-400",
},
ArchLayer {
label_key: "stratumiops-layer-op-label",
items_key: "stratumiops-layer-op-items",
desc_key: "stratumiops-layer-op-desc",
accent: "border-orange-400",
},
ArchLayer {
label_key: "stratumiops-layer-entry-label",
items_key: "stratumiops-layer-entry-items",
desc_key: "stratumiops-layer-entry-desc",
accent: "border-green-400",
},
ArchLayer {
label_key: "stratumiops-layer-graph-label",
items_key: "stratumiops-layer-graph-items",
desc_key: "stratumiops-layer-graph-desc",
accent: "border-purple-400",
},
ArchLayer {
label_key: "stratumiops-layer-seal-label",
items_key: "stratumiops-layer-seal-items",
desc_key: "stratumiops-layer-seal-desc",
accent: "border-yellow-400",
},
];
struct StratumCrate {
name: &'static str,
role_key: &'static str,
}
const STRATUM_CRATES: &[StratumCrate] = &[
StratumCrate {
name: "stratum-orchestrator",
role_key: "stratumiops-stratum-orchestrator-role",
},
StratumCrate {
name: "stratum-graph",
role_key: "stratumiops-stratum-graph-role",
},
StratumCrate {
name: "stratum-state",
role_key: "stratumiops-stratum-state-role",
},
StratumCrate {
name: "stratum-llm",
role_key: "stratumiops-stratum-llm-role",
},
StratumCrate {
name: "stratum-embeddings",
role_key: "stratumiops-stratum-embeddings-role",
},
StratumCrate {
name: "platform-nats",
role_key: "stratumiops-stratum-nats-role",
},
StratumCrate {
name: "ncl-import-resolver",
role_key: "stratumiops-stratum-ncl-role",
},
StratumCrate {
name: "stratum-ontology-core",
role_key: "stratumiops-stratum-ontology-role",
},
StratumCrate {
name: "stratum-reflection-core",
role_key: "stratumiops-stratum-reflection-role",
},
StratumCrate {
name: "stratum.sh / stratum.nu",
role_key: "stratumiops-stratum-dispatcher-role",
},
StratumCrate {
name: "Nu plugin layer",
role_key: "stratumiops-stratum-plugins-role",
},
];
struct MetricEntry {
value: &'static str,
label_key: &'static str,
}
const METRICS: &[MetricEntry] = &[
MetricEntry {
value: "52+",
label_key: "stratumiops-metric-crates",
},
MetricEntry {
value: "74+",
label_key: "stratumiops-metric-tests",
},
MetricEntry {
value: "~266K",
label_key: "stratumiops-metric-loc",
},
MetricEntry {
value: "0",
label_key: "stratumiops-metric-clippy",
},
MetricEntry {
value: "0",
label_key: "stratumiops-metric-unsafe",
},
MetricEntry {
value: "100%",
label_key: "stratumiops-metric-docs",
},
MetricEntry {
value: "4",
label_key: "stratumiops-metric-crypto",
},
MetricEntry {
value: "4",
label_key: "stratumiops-metric-storage",
},
MetricEntry {
value: "6",
label_key: "stratumiops-metric-typedialog",
},
MetricEntry {
value: "14+",
label_key: "stratumiops-metric-mcp",
},
];
const TECH_BADGES: &[&str] = &[
"Rust Edition 2021",
"Nickel",
"Nushell",
"SurrealDB",
"SQLite",
"NATS JetStream",
"Axum",
"Leptos WASM",
"Ratatui TUI",
"OpenTelemetry",
"Prometheus",
"etcd",
"PostgreSQL",
"OpenSSL",
"OQS (Post-Quantum)",
"Cedar Policy",
];
#[component]
pub fn UnifiedStratumiopsPage(
#[prop(default = rustelo_content::rustelo_core_lib::config::get_default_language().to_string())]
_language: String,
#[prop(optional)] lang_content: Option<std::collections::HashMap<String, String>>,
) -> impl IntoView {
let content = create_content_provider_reactive(lang_content.clone());
view! {
<div class="ds-bg-page min-h-screen">
// ── Hero ─────────────────────────────────────────────────────────────
<section class="relative py-ds-8 ds-container">
<div class="mx-auto max-w-4xl text-center">
<div class="flex flex-col items-center gap-4 mb-6">
<img
src="/images/logos/projects/stratumiops.svg"
alt="StratumIOps"
class="h-28"
/>
</div>
<p class="ds-caption ds-text-secondary mb-2 font-mono italic">
{content.t("stratumiops-tagline")}
</p>
<span class="inline-block px-3 py-1 ds-caption ds-bg border ds-border ds-rounded-md ds-text-secondary mb-4">
{content.t("stratumiops-badge")}
</span>
<h1 class="text-balance text-4xl font-bold tracking-tight ds-text sm:text-5xl mb-4">
{content.t("stratumiops-page-title")}
</h1>
<p class="ds-body ds-text-secondary max-w-3xl mx-auto">
{content.t("stratumiops-page-subtitle")}
</p>
<div class="flex flex-col sm:flex-row gap-4 justify-center mt-8">
<a
href="https://repo.ontoref.dev/org/stratumiops"
target="_blank"
rel="noopener noreferrer"
class="no-underline ds-btn-primary"
>
{content.t("stratumiops-cta-explore")}
</a>
</div>
</div>
</section>
// ── Problems ─────────────────────────────────────────────────────────
<section class="py-ds-6 ds-bg ds-rounded-lg">
<div class="ds-container-lg ds-container">
<div class="text-center mb-ds-12">
<h2 class="text-3xl font-bold ds-text">{content.t("stratumiops-problems-title")}</h2>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
{PROBLEMS.iter().map(|p| {
let number = p.number;
let title = content.t(p.title_key);
let desc = content.t(p.desc_key);
view! {
<div class="ds-bg-page p-ds-6 ds-rounded-lg ds-border border ds-shadow-sm">
<div class="text-2xl font-bold ds-text-accent mb-2">{number}</div>
<h3 class="ds-heading-4 ds-text mb-2">{title}</h3>
<p class="ds-caption ds-text-secondary">{desc}</p>
</div>
}
}).collect_view()}
</div>
</div>
</section>
// ── Ontology, Reflection & ADRs ────────────────────────────────────
<section class="py-ds-6 ds-rounded-lg">
<div class="ds-container-lg ds-container">
<div class="text-center mb-ds-12">
<h2 class="text-3xl font-bold ds-text">{content.t("stratumiops-ontology-title")}</h2>
</div>
// Yin / Yang cards
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-8">
{YIN_YANG.iter().map(|yy| {
let title = content.t(yy.title_key);
let subtitle = content.t(yy.subtitle_key);
let items = content.t(yy.items_key);
let border = format!("ds-bg p-ds-6 ds-rounded-lg border-l-4 {}", yy.accent);
view! {
<div class={border}>
<h3 class="ds-heading-4 ds-text mb-1">{title}</h3>
<p class="ds-caption ds-text-secondary italic mb-3">{subtitle}</p>
<p class="ds-caption ds-text-secondary leading-relaxed whitespace-pre-line">{items}</p>
</div>
}
}).collect_view()}
</div>
// Tension thesis
<div class="ds-bg p-ds-6 ds-rounded-lg border ds-border text-center mb-8">
<p class="ds-caption ds-text-secondary italic">{content.t("stratumiops-tension-1")}</p>
<p class="ds-caption ds-text-secondary italic">{content.t("stratumiops-tension-2")}</p>
<p class="ds-body ds-text font-bold mt-2">{content.t("stratumiops-tension-thesis")}</p>
<div class="mt-6 flex justify-center">
<img
src="/images/jpl-imago-160.png"
alt="Yin Yang — coexistence"
class="h-40 opacity-90"
/>
</div>
</div>
// Architecture Layers
<div class="flex flex-col gap-3">
{ARCH_LAYERS.iter().map(|l| {
let label = content.t(l.label_key);
let items = content.t(l.items_key);
let desc = content.t(l.desc_key);
let border = format!("ds-bg p-4 ds-rounded-lg border-l-4 {}", l.accent);
view! {
<div class={border}>
<div class="ds-caption font-bold font-mono ds-text uppercase tracking-wider">{label}</div>
<div class="ds-caption ds-text-secondary font-mono mt-1">{items}</div>
<div class="ds-caption ds-text-secondary italic mt-1">{desc}</div>
</div>
}
}).collect_view()}
</div>
</div>
</section>
// ── Architecture Diagrams ──────────────────────────────────────────
<section class="py-ds-6 ds-bg ds-rounded-lg">
<div class="ds-container-lg ds-container">
<div class="text-center mb-ds-8">
<h2 class="text-3xl font-bold ds-text">{content.t("stratumiops-arch-title")}</h2>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div>
<h3 class="ds-heading-4 ds-text text-center mb-4">{content.t("stratumiops-arch-orchestrator")}</h3>
<ImageModal
src_light="/images/projects/stratumiops_orchestrator-light.svg"
src_dark="/images/projects/stratumiops_orchestrator-dark.svg"
alt="StratumIOps Orchestrator Architecture"
expand_label=content.t("common-expand-image")
/>
</div>
<div>
<h3 class="ds-heading-4 ds-text text-center mb-4">{content.t("stratumiops-arch-operation-flow")}</h3>
<ImageModal
src_light="/images/projects/stratumiops_operation-flow-light.svg"
src_dark="/images/projects/stratumiops_operation-flow-dark.svg"
alt="StratumIOps Operation Flow"
expand_label=content.t("common-expand-image")
/>
</div>
</div>
</div>
</section>
// ── Ecosystem Projects ───────────────────────────────────────────────
<section class="py-ds-6 ds-rounded-lg">
<div class="ds-container-lg ds-container">
<div class="text-center mb-ds-12">
<h2 class="text-3xl font-bold ds-text">{content.t("stratumiops-projects-title")}</h2>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{PROJECTS.iter().map(|p| {
let title = content.t(p.title_key);
let desc = content.t(p.desc_key);
let emoji = p.emoji;
let route = p.page_route;
let more_info = content.t("stratumiops-more-info");
view! {
<div class="ds-bg p-ds-6 ds-rounded-lg ds-shadow-sm border-l-4 ds-border flex flex-col">
<div class="flex items-center gap-3 mb-3">
<span class="text-xl">{emoji}</span>
<h3 class="ds-heading-4 ds-text">{title}</h3>
</div>
<p class="ds-caption ds-text-secondary leading-relaxed flex-1">{desc}</p>
<div class="mt-4">
<a
href={route}
class="no-underline inline-block px-4 py-2 ds-btn-secondary ds-caption"
>
{more_info}" \u{2192}"
</a>
</div>
</div>
}
}).collect_view()}
</div>
</div>
</section>
// ── Stratum Crates ───────────────────────────────────────────────────
<section class="py-ds-6 ds-bg ds-rounded-lg">
<div class="ds-container-lg ds-container">
<div class="text-center mb-ds-4">
<h2 class="text-3xl font-bold ds-text">{content.t("stratumiops-stratum-title")}</h2>
<p class="ds-text-secondary ds-body mt-2">{content.t("stratumiops-stratum-subtitle")}</p>
</div>
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-4 mt-ds-8">
{STRATUM_CRATES.iter().map(|c| {
let role = content.t(c.role_key);
let name = c.name;
view! {
<div class="ds-bg-page p-4 ds-rounded-lg text-center border ds-border ds-shadow-sm">
<div class="ds-caption font-bold ds-text mb-1">{name}</div>
<div class="text-xs ds-text-secondary">{role}</div>
</div>
}
}).collect_view()}
</div>
</div>
</section>
// ── Ecosystem Metrics ────────────────────────────────────────────────
<section class="py-ds-6 ds-container">
<div class="mx-auto max-w-5xl">
<div class="text-center mb-ds-8">
<h2 class="text-3xl font-bold ds-text">{content.t("stratumiops-metrics-title")}</h2>
</div>
<div class="ds-bg ds-rounded-xl border ds-border ds-shadow-sm p-6">
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 gap-6 text-center">
{METRICS.iter().map(|m| {
let label = content.t(m.label_key);
let value = m.value;
view! {
<div>
<div class="text-2xl font-bold ds-text">{value}</div>
<div class="ds-caption ds-text-secondary">{label}</div>
</div>
}
}).collect_view()}
</div>
</div>
</div>
</section>
// ── Tech Stack ───────────────────────────────────────────────────────
<section class="py-ds-6 ds-bg ds-rounded-lg">
<div class="ds-container-lg ds-container">
<div class="text-center mb-ds-8">
<h2 class="text-3xl font-bold ds-text">{content.t("stratumiops-tech-stack-title")}</h2>
</div>
<div class="flex flex-wrap gap-3 justify-center">
{TECH_BADGES.iter().map(|badge| {
view! {
<span class="px-3 py-2 ds-bg-page border ds-border ds-rounded-md ds-caption font-mono ds-text">
{*badge}
</span>
}
}).collect_view()}
</div>
</div>
</section>
// ── CTA ──────────────────────────────────────────────────────────────
<section class="py-ds-6 ds-rounded-lg">
<div class="mx-auto max-w-3xl ds-container text-center">
<h2 class="text-3xl font-bold ds-text mb-ds-4">{content.t("stratumiops-cta-title")}</h2>
<p class="ds-text-secondary mb-8 ds-body">{content.t("stratumiops-cta-subtitle")}</p>
<div class="flex flex-col sm:flex-row gap-4 justify-center">
<a
href="https://repo.ontoref.dev/org/stratumiops"
target="_blank"
rel="noopener noreferrer"
class="no-underline ds-btn-primary"
>
{content.t("stratumiops-cta-explore")}
</a>
</div>
</div>
</section>
<ProjectGraphSection node_id="stratumiops".to_string() />
</div>
}
}

View file

@ -0,0 +1,2 @@
pub mod unified;
pub use unified::UnifiedSyntaxisPage;

View file

@ -0,0 +1,356 @@
use leptos::prelude::*;
use rustelo_content::rustelo_core_lib::i18n::content_helper::create_content_provider_reactive;
use rustelo_components_leptos::ImageModal;
use crate::content_graph::ProjectGraphSection;
struct Interface {
title_key: &'static str,
label_key: &'static str,
desc_key: &'static str,
}
const INTERFACES: &[Interface] = &[
Interface {
title_key: "syntaxis-iface-cli-title",
label_key: "syntaxis-iface-cli-label",
desc_key: "syntaxis-iface-cli-desc",
},
Interface {
title_key: "syntaxis-iface-tui-title",
label_key: "syntaxis-iface-tui-label",
desc_key: "syntaxis-iface-tui-desc",
},
Interface {
title_key: "syntaxis-iface-dashboard-title",
label_key: "syntaxis-iface-dashboard-label",
desc_key: "syntaxis-iface-dashboard-desc",
},
Interface {
title_key: "syntaxis-iface-api-title",
label_key: "syntaxis-iface-api-label",
desc_key: "syntaxis-iface-api-desc",
},
];
struct Feature {
title_key: &'static str,
desc_key: &'static str,
emoji: &'static str,
}
const FEATURES: &[Feature] = &[
Feature {
title_key: "syntaxis-feat-lifecycle-title",
desc_key: "syntaxis-feat-lifecycle-desc",
emoji: "\u{1f504}",
},
Feature {
title_key: "syntaxis-feat-audit-title",
desc_key: "syntaxis-feat-audit-desc",
emoji: "\u{1f4dc}",
},
Feature {
title_key: "syntaxis-feat-dualdb-title",
desc_key: "syntaxis-feat-dualdb-desc",
emoji: "\u{1f5c4}\u{fe0f}",
},
Feature {
title_key: "syntaxis-feat-config-title",
desc_key: "syntaxis-feat-config-desc",
emoji: "\u{2699}\u{fe0f}",
},
Feature {
title_key: "syntaxis-feat-templates-title",
desc_key: "syntaxis-feat-templates-desc",
emoji: "\u{1f4cb}",
},
Feature {
title_key: "syntaxis-feat-vapora-title",
desc_key: "syntaxis-feat-vapora-desc",
emoji: "\u{1f300}",
},
Feature {
title_key: "syntaxis-feat-checklists-title",
desc_key: "syntaxis-feat-checklists-desc",
emoji: "\u{2705}",
},
Feature {
title_key: "syntaxis-feat-bootstrap-title",
desc_key: "syntaxis-feat-bootstrap-desc",
emoji: "\u{1f680}",
},
];
struct QualityStat {
value: &'static str,
label_key: &'static str,
}
const QUALITY_STATS: &[QualityStat] = &[
QualityStat {
value: "632+",
label_key: "syntaxis-quality-tests",
},
QualityStat {
value: "10K+",
label_key: "syntaxis-quality-lines",
},
QualityStat {
value: "0",
label_key: "syntaxis-quality-unsafe",
},
QualityStat {
value: "0",
label_key: "syntaxis-quality-unwrap",
},
QualityStat {
value: "100%",
label_key: "syntaxis-quality-docs",
},
QualityStat {
value: "8",
label_key: "syntaxis-quality-crates",
},
];
const TECH_BADGES: &[&str] = &[
"tokio",
"axum",
"sqlx",
"surrealdb",
"clap",
"ratatui",
"leptos",
"tower",
"serde",
"thiserror",
"nats",
"nickel",
];
struct DbBackend {
title_key: &'static str,
items: &'static [&'static str],
}
const DB_BACKENDS: &[DbBackend] = &[
DbBackend {
title_key: "syntaxis-db-sqlite-title",
items: &[
"syntaxis-db-sqlite-1",
"syntaxis-db-sqlite-2",
"syntaxis-db-sqlite-3",
"syntaxis-db-sqlite-4",
"syntaxis-db-sqlite-5",
],
},
DbBackend {
title_key: "syntaxis-db-surreal-title",
items: &[
"syntaxis-db-surreal-1",
"syntaxis-db-surreal-2",
"syntaxis-db-surreal-3",
"syntaxis-db-surreal-4",
"syntaxis-db-surreal-5",
],
},
];
#[component]
pub fn UnifiedSyntaxisPage(
#[prop(default = rustelo_content::rustelo_core_lib::config::get_default_language().to_string())]
_language: String,
#[prop(optional)] lang_content: Option<std::collections::HashMap<String, String>>,
) -> impl IntoView {
let content = create_content_provider_reactive(lang_content.clone());
view! {
<div class="ds-bg-page min-h-screen">
// ── Hero ─────────────────────────────────────────────────────────────
<section class="relative py-ds-8 ds-container">
<div class="mx-auto max-w-4xl text-center">
<div class="flex flex-col items-center gap-4 mb-6">
<img
src="/images/logos/projects/syntaxis.svg"
alt="Syntaxis"
class="h-28"
/>
</div>
<p class="ds-caption ds-text-secondary mb-2 font-mono italic">
{content.t("syntaxis-tagline")}
</p>
<span class="inline-block px-3 py-1 ds-caption ds-bg border ds-border ds-rounded-md ds-text-secondary mb-4">
{content.t("syntaxis-badge")}
</span>
<h1 class="text-balance text-4xl font-bold tracking-tight ds-text sm:text-5xl mb-4">
{content.t("syntaxis-page-title")}
</h1>
<p class="ds-body ds-text-secondary max-w-3xl mx-auto">
{content.t("syntaxis-page-subtitle")}
</p>
<div class="flex flex-col sm:flex-row gap-4 justify-center mt-8">
<a
href="https://repo.ontoref.dev/org/syntaxis"
target="_blank"
rel="noopener noreferrer"
class="no-underline ds-btn-primary"
>
{content.t("syntaxis-cta-explore")}
</a>
</div>
</div>
</section>
// ── Interfaces ─────────────────────────────────────────────────────
<section class="py-ds-6 ds-bg ds-rounded-lg">
<div class="ds-container-lg ds-container">
<div class="text-center mb-ds-12">
<h2 class="text-3xl font-bold ds-text">{content.t("syntaxis-interfaces-title")}</h2>
<p class="ds-text-secondary ds-body mt-2">{content.t("syntaxis-interfaces-subtitle")}</p>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
{INTERFACES.iter().map(|iface| {
let title = content.t(iface.title_key);
let label = content.t(iface.label_key);
let desc = content.t(iface.desc_key);
view! {
<div class="ds-bg-page p-ds-6 ds-rounded-lg ds-border border ds-shadow-sm">
<h3 class="ds-heading-4 ds-text mb-1">{title}</h3>
<div class="ds-caption font-mono ds-text-secondary mb-3">{label}</div>
<p class="ds-caption ds-text-secondary">{desc}</p>
</div>
}
}).collect_view()}
</div>
</div>
</section>
// ── Quality Stats ──────────────────────────────────────────────────
<section class="py-ds-6 ds-container">
<div class="mx-auto max-w-5xl">
<div class="ds-bg ds-rounded-xl border ds-border ds-shadow-sm p-6">
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-6 gap-6 text-center">
{QUALITY_STATS.iter().map(|s| {
let label = content.t(s.label_key);
let value = s.value;
view! {
<div>
<div class="text-2xl font-bold ds-text">{value}</div>
<div class="ds-caption ds-text-secondary">{label}</div>
</div>
}
}).collect_view()}
</div>
</div>
</div>
</section>
// ── Features ───────────────────────────────────────────────────────
<section class="py-ds-6 ds-rounded-lg">
<div class="ds-container-lg ds-container">
<div class="text-center mb-ds-12">
<h2 class="text-3xl font-bold ds-text">{content.t("syntaxis-features-title")}</h2>
<p class="ds-text-secondary ds-body mt-2">{content.t("syntaxis-features-subtitle")}</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
{FEATURES.iter().map(|f| {
let title = content.t(f.title_key);
let desc = content.t(f.desc_key);
let emoji = f.emoji;
view! {
<div class="ds-bg p-ds-6 ds-rounded-lg ds-shadow-sm border-l-4 ds-border">
<div class="flex items-center gap-3 mb-3">
<span class="text-xl">{emoji}</span>
<h3 class="ds-heading-4 ds-text">{title}</h3>
</div>
<p class="ds-caption ds-text-secondary leading-relaxed">{desc}</p>
</div>
}
}).collect_view()}
</div>
</div>
</section>
// ── Architecture Diagram ───────────────────────────────────────────
<section class="py-ds-6 ds-container">
<div class="mx-auto max-w-5xl text-center">
<h2 class="text-3xl font-bold ds-text mb-ds-8">{content.t("syntaxis-architecture-title")}</h2>
<ImageModal
src_light="/images/projects/syntaxis_architecture-light.svg"
src_dark="/images/projects/syntaxis_architecture-dark.svg"
alt="Syntaxis architecture diagram"
expand_label=content.t("common-expand-image")
/>
</div>
</section>
// ── Database ───────────────────────────────────────────────────────
<section class="py-ds-6 ds-bg ds-rounded-lg">
<div class="ds-container-lg ds-container">
<div class="text-center mb-ds-12">
<h2 class="text-3xl font-bold ds-text">{content.t("syntaxis-db-title")}</h2>
<p class="ds-text-secondary ds-body mt-2">{content.t("syntaxis-db-subtitle")}</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
{DB_BACKENDS.iter().map(|db| {
let title = content.t(db.title_key);
let items = db.items.iter().map(|k| {
let text = content.t(k);
view! { <li class="ds-caption ds-text-secondary py-1">{"> "}{text}</li> }
}).collect_view();
view! {
<div class="ds-bg-page p-ds-6 ds-rounded-lg ds-border border ds-shadow-sm">
<h3 class="ds-heading-4 ds-text mb-4">{title}</h3>
<ul class="list-none pl-0">{items}</ul>
</div>
}
}).collect_view()}
</div>
</div>
</section>
// ── Tech Stack ─────────────────────────────────────────────────────
<section class="py-ds-6 ds-rounded-lg">
<div class="ds-container-lg ds-container">
<div class="text-center mb-ds-8">
<h2 class="text-3xl font-bold ds-text">{content.t("syntaxis-tech-stack-title")}</h2>
</div>
<div class="flex flex-wrap gap-3 justify-center">
{TECH_BADGES.iter().map(|badge| {
view! {
<span class="px-3 py-2 ds-bg-page border ds-border ds-rounded-md ds-caption font-mono ds-text">
{*badge}
</span>
}
}).collect_view()}
</div>
</div>
</section>
// ── CTA ────────────────────────────────────────────────────────────
<section class="py-ds-6 ds-bg ds-rounded-lg">
<div class="mx-auto max-w-3xl ds-container text-center">
<h2 class="text-3xl font-bold ds-text mb-ds-4">{content.t("syntaxis-cta-title")}</h2>
<p class="ds-text-secondary mb-8 ds-body">{content.t("syntaxis-cta-subtitle")}</p>
<div class="flex flex-col sm:flex-row gap-4 justify-center">
<a
href="https://repo.ontoref.dev/org/syntaxis"
target="_blank"
rel="noopener noreferrer"
class="no-underline ds-btn-primary"
>
{content.t("syntaxis-cta-explore")}
</a>
</div>
</div>
</section>
<ProjectGraphSection node_id="syntaxis".to_string() />
</div>
}
}

View file

@ -0,0 +1,2 @@
pub mod unified;
pub use unified::UnifiedTypedialogPage;

View file

@ -0,0 +1,287 @@
use leptos::prelude::*;
use rustelo_content::rustelo_core_lib::i18n::content_helper::create_content_provider_reactive;
use rustelo_components_leptos::ImageModal;
use crate::content_graph::ProjectGraphSection;
struct Problem {
title_key: &'static str,
desc_key: &'static str,
}
const PROBLEMS: &[Problem] = &[
Problem {
title_key: "typedialog-problem-1-title",
desc_key: "typedialog-problem-1-desc",
},
Problem {
title_key: "typedialog-problem-2-title",
desc_key: "typedialog-problem-2-desc",
},
Problem {
title_key: "typedialog-problem-3-title",
desc_key: "typedialog-problem-3-desc",
},
Problem {
title_key: "typedialog-problem-4-title",
desc_key: "typedialog-problem-4-desc",
},
];
struct HowItWorks {
title_key: &'static str,
desc_key: &'static str,
emoji: &'static str,
}
const HOW_IT_WORKS: &[HowItWorks] = &[
HowItWorks {
title_key: "typedialog-how-forms-title",
desc_key: "typedialog-how-forms-desc",
emoji: "\u{1f4c4}",
},
HowItWorks {
title_key: "typedialog-how-execution-title",
desc_key: "typedialog-how-execution-desc",
emoji: "\u{2699}\u{fe0f}",
},
HowItWorks {
title_key: "typedialog-how-factory-title",
desc_key: "typedialog-how-factory-desc",
emoji: "\u{1f3ed}",
},
HowItWorks {
title_key: "typedialog-how-ai-title",
desc_key: "typedialog-how-ai-desc",
emoji: "\u{1f916}",
},
HowItWorks {
title_key: "typedialog-how-infra-title",
desc_key: "typedialog-how-infra-desc",
emoji: "\u{2601}\u{fe0f}",
},
HowItWorks {
title_key: "typedialog-how-roundtrip-title",
desc_key: "typedialog-how-roundtrip-desc",
emoji: "\u{1f504}",
},
];
struct Backend {
name: &'static str,
role_key: &'static str,
}
const BACKENDS: &[Backend] = &[
Backend {
name: "CLI",
role_key: "typedialog-backend-cli-role",
},
Backend {
name: "TUI",
role_key: "typedialog-backend-tui-role",
},
Backend {
name: "Web",
role_key: "typedialog-backend-web-role",
},
Backend {
name: "AI",
role_key: "typedialog-backend-ai-role",
},
Backend {
name: "Agent",
role_key: "typedialog-backend-agent-role",
},
Backend {
name: "Prov-Gen",
role_key: "typedialog-backend-provgen-role",
},
];
const TECH_BADGES: &[&str] = &[
"Rust (8 crates)",
"Nickel Contracts",
"TOML Forms",
"Inquire (CLI)",
"Ratatui (TUI)",
"Axum (Web)",
"Fluent i18n",
"Tera Templates",
"Nushell Plugin",
"Claude API",
"OpenAI API",
"Gemini API",
"Ollama (local)",
"Multi-Cloud IaC",
];
#[component]
pub fn UnifiedTypedialogPage(
#[prop(default = rustelo_content::rustelo_core_lib::config::get_default_language().to_string())]
_language: String,
#[prop(optional)] lang_content: Option<std::collections::HashMap<String, String>>,
) -> impl IntoView {
let content = create_content_provider_reactive(lang_content.clone());
view! {
<div class="ds-bg-page min-h-screen">
// ── Hero ─────────────────────────────────────────────────────────────
<section class="relative py-ds-8 ds-container">
<div class="mx-auto max-w-4xl text-center">
<div class="flex flex-col items-center gap-4 mb-6">
<img
src="/images/logos/projects/typedialog.svg"
alt="TypeDialog"
class="h-20"
/>
</div>
<p class="ds-caption ds-text-secondary mb-2 font-mono italic">
{content.t("typedialog-tagline")}
</p>
<span class="inline-block px-3 py-1 ds-caption ds-bg border ds-border ds-rounded-md ds-text-secondary mb-4">
{content.t("typedialog-badge")}
</span>
<h1 class="text-balance text-4xl font-bold tracking-tight ds-text sm:text-5xl mb-4">
{content.t("typedialog-page-title")}
</h1>
<p class="ds-body ds-text-secondary max-w-3xl mx-auto">
{content.t("typedialog-page-subtitle")}
</p>
<div class="flex flex-col sm:flex-row gap-4 justify-center mt-8">
<a
href="https://repo.ontoref.dev/org/typedialog"
target="_blank"
rel="noopener noreferrer"
class="no-underline ds-btn-primary"
>
{content.t("typedialog-cta-explore")}
</a>
</div>
</div>
</section>
// ── Problems ─────────────────────────────────────────────────────────
<section class="py-ds-6 ds-bg ds-rounded-lg">
<div class="ds-container-lg ds-container">
<div class="text-center mb-ds-12">
<h2 class="text-3xl font-bold ds-text">{content.t("typedialog-problems-title")}</h2>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
{PROBLEMS.iter().map(|p| {
let title = content.t(p.title_key);
let desc = content.t(p.desc_key);
view! {
<div class="ds-bg-page p-ds-6 ds-rounded-lg ds-border border ds-shadow-sm">
<h3 class="ds-heading-4 ds-text mb-2">{title}</h3>
<p class="ds-caption ds-text-secondary">{desc}</p>
</div>
}
}).collect_view()}
</div>
</div>
</section>
// ── How It Works ─────────────────────────────────────────────────────
<section class="py-ds-6 ds-rounded-lg">
<div class="ds-container-lg ds-container">
<div class="text-center mb-ds-12">
<h2 class="text-3xl font-bold ds-text">{content.t("typedialog-how-title")}</h2>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
{HOW_IT_WORKS.iter().map(|h| {
let title = content.t(h.title_key);
let desc = content.t(h.desc_key);
let emoji = h.emoji;
view! {
<div class="ds-bg p-ds-6 ds-rounded-lg ds-shadow-sm border-l-4 ds-border">
<div class="flex items-center gap-3 mb-3">
<span class="text-xl">{emoji}</span>
<h3 class="ds-heading-4 ds-text">{title}</h3>
</div>
<p class="ds-caption ds-text-secondary leading-relaxed">{desc}</p>
</div>
}
}).collect_view()}
</div>
</div>
</section>
// ── Architecture Diagram ─────────────────────────────────────────────
<section class="py-ds-6 ds-container">
<div class="mx-auto max-w-5xl text-center">
<h2 class="text-3xl font-bold ds-text mb-ds-8">{content.t("typedialog-architecture-title")}</h2>
<ImageModal
src_light="/images/projects/typedialog_architecture-light.svg"
src_dark="/images/projects/typedialog_architecture-dark.svg"
alt="TypeDialog architecture diagram"
expand_label=content.t("common-expand-image")
/>
</div>
</section>
// ── Backends ─────────────────────────────────────────────────────────
<section class="py-ds-6 ds-bg ds-rounded-lg">
<div class="ds-container-lg ds-container">
<div class="text-center mb-ds-8">
<h2 class="text-3xl font-bold ds-text">{content.t("typedialog-backends-title")}</h2>
</div>
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-6 gap-4">
{BACKENDS.iter().map(|b| {
let role = content.t(b.role_key);
let name = b.name;
view! {
<div class="ds-bg-page p-4 ds-rounded-lg text-center border ds-border ds-shadow-sm">
<div class="ds-caption font-bold ds-text mb-1">{name}</div>
<div class="text-xs ds-text-secondary">{role}</div>
</div>
}
}).collect_view()}
</div>
</div>
</section>
// ── Tech Stack ───────────────────────────────────────────────────────
<section class="py-ds-6 ds-rounded-lg">
<div class="ds-container-lg ds-container">
<div class="text-center mb-ds-8">
<h2 class="text-3xl font-bold ds-text">{content.t("typedialog-tech-stack-title")}</h2>
</div>
<div class="flex flex-wrap gap-3 justify-center">
{TECH_BADGES.iter().map(|badge| {
view! {
<span class="px-3 py-2 ds-bg-page border ds-border ds-rounded-md ds-caption font-mono ds-text">
{*badge}
</span>
}
}).collect_view()}
</div>
</div>
</section>
// ── CTA ──────────────────────────────────────────────────────────────
<section class="py-ds-6 ds-bg ds-rounded-lg">
<div class="mx-auto max-w-3xl ds-container text-center">
<h2 class="text-3xl font-bold ds-text mb-ds-4">{content.t("typedialog-cta-title")}</h2>
<p class="ds-text-secondary mb-8 ds-body">{content.t("typedialog-cta-subtitle")}</p>
<div class="flex flex-col sm:flex-row gap-4 justify-center">
<a
href="https://repo.ontoref.dev/org/typedialog"
target="_blank"
rel="noopener noreferrer"
class="no-underline ds-btn-primary"
>
{content.t("typedialog-cta-explore")}
</a>
</div>
</div>
</section>
<ProjectGraphSection node_id="typedialog".to_string() />
</div>
}
}

View file

@ -0,0 +1 @@
pub mod unified;

View file

@ -0,0 +1,355 @@
use leptos::prelude::*;
use rustelo_content::rustelo_core_lib::i18n::content_helper::create_content_provider_reactive;
use rustelo_components_leptos::ImageModal;
use crate::content_graph::ProjectGraphSection;
/// Agent descriptor — name and i18n role key
struct Agent {
name: &'static str,
role_key: &'static str,
emoji: &'static str,
}
const AGENTS: &[Agent] = &[
Agent {
name: "Architect",
role_key: "vapora-agent-architect-role",
emoji: "🏗️",
},
Agent {
name: "Developer",
role_key: "vapora-agent-developer-role",
emoji: "💻",
},
Agent {
name: "CodeReviewer",
role_key: "vapora-agent-reviewer-role",
emoji: "🔍",
},
Agent {
name: "Tester",
role_key: "vapora-agent-tester-role",
emoji: "🧪",
},
Agent {
name: "Documenter",
role_key: "vapora-agent-documenter-role",
emoji: "📚",
},
Agent {
name: "Marketer",
role_key: "vapora-agent-marketer-role",
emoji: "📣",
},
Agent {
name: "Presenter",
role_key: "vapora-agent-presenter-role",
emoji: "🎤",
},
Agent {
name: "DevOps",
role_key: "vapora-agent-devops-role",
emoji: "🚀",
},
Agent {
name: "Monitor",
role_key: "vapora-agent-monitor-role",
emoji: "📊",
},
Agent {
name: "Security",
role_key: "vapora-agent-security-role",
emoji: "🔒",
},
Agent {
name: "ProjectManager",
role_key: "vapora-agent-pm-role",
emoji: "📋",
},
Agent {
name: "DecisionMaker",
role_key: "vapora-agent-decisionmaker-role",
emoji: "⚖️",
},
];
const TECH_BADGES: &[&str] = &[
"Rust (21 crates)",
"Axum REST API",
"SurrealDB",
"NATS JetStream",
"Leptos WASM",
"Kubernetes",
"Prometheus",
"Knowledge Graph",
"RLM (Hybrid Search)",
"A2A Protocol",
"MCP Server",
"Webhook Channels",
"Capability Packages",
];
/// Feature descriptor
struct Feature {
title_key: &'static str,
desc_key: &'static str,
emoji: &'static str,
}
const FEATURES: &[Feature] = &[
Feature {
title_key: "vapora-feat-agents-title",
desc_key: "vapora-feat-agents-desc",
emoji: "🤖",
},
Feature {
title_key: "vapora-feat-orchestration-title",
desc_key: "vapora-feat-orchestration-desc",
emoji: "🧠",
},
Feature {
title_key: "vapora-feat-rlm-title",
desc_key: "vapora-feat-rlm-desc",
emoji: "🔎",
},
Feature {
title_key: "vapora-feat-a2a-title",
desc_key: "vapora-feat-a2a-desc",
emoji: "🔗",
},
Feature {
title_key: "vapora-feat-knowledge-title",
desc_key: "vapora-feat-knowledge-desc",
emoji: "🗺️",
},
Feature {
title_key: "vapora-feat-nats-title",
desc_key: "vapora-feat-nats-desc",
emoji: "📨",
},
Feature {
title_key: "vapora-feat-surrealdb-title",
desc_key: "vapora-feat-surrealdb-desc",
emoji: "🗄️",
},
Feature {
title_key: "vapora-feat-api-title",
desc_key: "vapora-feat-api-desc",
emoji: "🌐",
},
Feature {
title_key: "vapora-feat-webhooks-title",
desc_key: "vapora-feat-webhooks-desc",
emoji: "🔔",
},
Feature {
title_key: "vapora-feat-capabilities-title",
desc_key: "vapora-feat-capabilities-desc",
emoji: "📦",
},
];
/// Unified Vapora page — works in both SSR and WASM contexts
#[component]
pub fn UnifiedVaporaPage(
#[prop(default = rustelo_content::rustelo_core_lib::config::get_default_language().to_string())]
_language: String,
#[prop(optional)] lang_content: Option<std::collections::HashMap<String, String>>,
) -> impl IntoView {
let content = create_content_provider_reactive(lang_content.clone());
view! {
<div class="ds-bg-page min-h-screen">
// ── Hero ─────────────────────────────────────────────────────────────
<section class="relative py-ds-8 ds-container">
<div class="mx-auto max-w-4xl text-center">
<div class="flex flex-col items-center gap-4 mb-6">
<img
src="/images/logos/projects/vapora_w.svg"
alt="Vapora"
class="h-28 dark:hidden"
/>
<img
src="/images/logos/projects/vapora.svg"
alt="Vapora"
class="h-28 hidden dark:block"
/>
</div>
<p class="ds-caption ds-text-secondary mb-2 font-mono italic">
{content.t("vapora-tagline")}
</p>
<span class="inline-block px-3 py-1 ds-caption ds-bg border ds-border ds-rounded-md ds-text-secondary mb-4">
{content.t("vapora-badge")}
</span>
<h1 class="text-balance text-4xl font-bold tracking-tight ds-text sm:text-5xl mb-4">
{content.t("vapora-page-title")}
</h1>
<p class="ds-body ds-text-secondary max-w-3xl mx-auto">
{content.t("vapora-page-subtitle")}
</p>
<div class="flex flex-col sm:flex-row gap-4 justify-center mt-8">
<a
href="https://vapora.dev"
target="_blank"
rel="noopener noreferrer"
class="no-underline ds-btn-primary"
>
{content.t("vapora-cta-visit")}
</a>
<a
href="https://github.com/vapora-platform/vapora"
target="_blank"
rel="noopener noreferrer"
class="no-underline ds-btn-secondary"
>
{content.t("vapora-cta-explore")}
</a>
</div>
</div>
</section>
// ── Architecture Diagram ─────────────────────────────────────────────
<section class="py-ds-6 ds-container">
<div class="mx-auto max-w-5xl text-center">
<h2 class="text-3xl font-bold ds-text mb-ds-8">{content.t("vapora-architecture-title")}</h2>
<ImageModal
src_light="/images/projects/vapora_architecture-light.svg"
src_dark="/images/projects/vapora_architecture-dark.svg"
alt="Vapora architecture diagram"
expand_label=content.t("common-expand-image")
/>
</div>
</section>
// ── Problems ─────────────────────────────────────────────────────────
<section class="py-ds-6 ds-bg ds-rounded-lg">
<div class="ds-container-lg ds-container">
<div class="text-center mb-ds-12">
<h2 class="text-3xl font-bold ds-text">{content.t("vapora-problems-title")}</h2>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
<div class="ds-bg-page p-ds-6 ds-rounded-lg ds-border border ds-shadow-sm">
<div class="text-2xl mb-3">""</div>
<h3 class="ds-heading-4 ds-text mb-2">{content.t("vapora-problem-1-title")}</h3>
<p class="ds-caption ds-text-secondary">{content.t("vapora-problem-1-desc")}</p>
</div>
<div class="ds-bg-page p-ds-6 ds-rounded-lg ds-border border ds-shadow-sm">
<div class="text-2xl mb-3">""</div>
<h3 class="ds-heading-4 ds-text mb-2">{content.t("vapora-problem-2-title")}</h3>
<p class="ds-caption ds-text-secondary">{content.t("vapora-problem-2-desc")}</p>
</div>
<div class="ds-bg-page p-ds-6 ds-rounded-lg ds-border border ds-shadow-sm">
<div class="text-2xl mb-3">""</div>
<h3 class="ds-heading-4 ds-text mb-2">{content.t("vapora-problem-3-title")}</h3>
<p class="ds-caption ds-text-secondary">{content.t("vapora-problem-3-desc")}</p>
</div>
<div class="ds-bg-page p-ds-6 ds-rounded-lg ds-border border ds-shadow-sm">
<div class="text-2xl mb-3">""</div>
<h3 class="ds-heading-4 ds-text mb-2">{content.t("vapora-problem-4-title")}</h3>
<p class="ds-caption ds-text-secondary">{content.t("vapora-problem-4-desc")}</p>
</div>
</div>
</div>
</section>
// ── How It Works (Features) ──────────────────────────────────────────
<section class="py-ds-6 ds-rounded-lg">
<div class="ds-container-lg ds-container">
<div class="text-center mb-ds-12">
<h2 class="text-3xl font-bold ds-text">{content.t("vapora-features-title")}</h2>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
{FEATURES.iter().map(|f| {
let title = content.t(f.title_key);
let desc = content.t(f.desc_key);
let emoji = f.emoji;
view! {
<div class="ds-bg p-ds-6 ds-rounded-lg ds-shadow-sm border-l-4 ds-border">
<div class="flex items-center gap-3 mb-3">
<span class="text-xl">{emoji}</span>
<h3 class="ds-heading-4 ds-text">{title}</h3>
</div>
<p class="ds-caption ds-text-secondary leading-relaxed">{desc}</p>
</div>
}
}).collect_view()}
</div>
</div>
</section>
// ── Tech Stack ───────────────────────────────────────────────────────
<section class="py-ds-6 ds-bg ds-rounded-lg">
<div class="ds-container-lg ds-container">
<div class="text-center mb-ds-8">
<h2 class="text-3xl font-bold ds-text">{content.t("vapora-tech-stack-title")}</h2>
</div>
<div class="flex flex-wrap gap-3 justify-center">
{TECH_BADGES.iter().map(|badge| {
view! {
<span class="px-3 py-2 ds-bg-page border ds-border ds-rounded-md ds-caption font-mono ds-text">
{*badge}
</span>
}
}).collect_view()}
</div>
</div>
</section>
// ── Available Agents ─────────────────────────────────────────────────
<section class="py-ds-6 ds-rounded-lg">
<div class="ds-container-lg ds-container">
<div class="text-center mb-ds-8">
<h2 class="text-3xl font-bold ds-text">{content.t("vapora-agents-title")}</h2>
</div>
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-4">
{AGENTS.iter().map(|a| {
let role = content.t(a.role_key);
let name = a.name;
let emoji = a.emoji;
view! {
<div class="ds-bg p-4 ds-rounded-lg text-center border ds-border ds-shadow-sm">
<div class="text-2xl mb-2">{emoji}</div>
<div class="ds-caption font-bold ds-text mb-1">{name}</div>
<div class="text-xs ds-text-secondary">{role}</div>
</div>
}
}).collect_view()}
</div>
</div>
</section>
// ── CTA ──────────────────────────────────────────────────────────────
<section class="py-ds-6 ds-bg ds-rounded-lg">
<div class="mx-auto max-w-3xl ds-container text-center">
<h2 class="text-3xl font-bold ds-text mb-ds-4">{content.t("vapora-cta-title")}</h2>
<p class="ds-text-secondary mb-8 ds-body">{content.t("vapora-cta-subtitle")}</p>
<div class="flex flex-col sm:flex-row gap-4 justify-center">
<a
href="https://vapora.dev"
target="_blank"
rel="noopener noreferrer"
class="no-underline ds-btn-primary"
>
{content.t("vapora-cta-visit")}
</a>
<a
href="https://github.com/vapora-platform/vapora"
target="_blank"
rel="noopener noreferrer"
class="no-underline ds-btn-secondary"
>
{content.t("vapora-cta-explore")}
</a>
</div>
</div>
</section>
<ProjectGraphSection node_id="vapora".to_string() />
</div>
}
}

View file

@ -0,0 +1,20 @@
//! Work Request Page Module
//!
//! This module contains the unified Work Request page component for JLP Website.
//! It integrates with the foundation page system while providing custom form functionality.
pub mod unified;
// Re-export the unified component for external use
pub use unified::UnifiedWorkRequestPage;
// Export with the canonical name for page provider compatibility
pub use unified::UnifiedWorkRequestPage as WorkRequest;
// Auto-generated components from build.rs
// Include generated WorkRequestPage, WorkRequestClient, WorkRequestPageSSR components
// TODO: Fix build script to generate page_work_request.rs
// include!(concat!(env!("OUT_DIR"), "/page_work_request.rs"));
// Temporary placeholder until build script is fixed
pub use rustelo_pages_leptos::*;

View file

@ -0,0 +1,478 @@
//! Unified WorkRequest Page Component
use leptos::prelude::*;
use rustelo_components_leptos::HtmlContent;
use rustelo_components_leptos::server_send_contact_form;
use rustelo_content::rustelo_core_lib::i18n::content_helper::create_content_provider_reactive;
#[component]
pub fn UnifiedWorkRequestPage(
#[prop(default = rustelo_content::rustelo_core_lib::config::get_default_language().to_string())]
_language: String,
#[prop(optional)] lang_content: Option<std::collections::HashMap<String, String>>,
) -> impl IntoView {
let content = create_content_provider_reactive(lang_content.clone());
// Form field signals
let name = RwSignal::new(String::new());
let email = RwSignal::new(String::new());
let company = RwSignal::new(String::new());
let project_type = RwSignal::new(String::new());
let project_description = RwSignal::new(String::new());
let current_situation = RwSignal::new(String::new());
let goals = RwSignal::new(String::new());
let technical_requirements = RwSignal::new(String::new());
let timeline = RwSignal::new(String::new());
let budget = RwSignal::new(String::new());
let contact_method = RwSignal::new(String::new());
// Submission state
let submitting = RwSignal::new(false);
let submit_ok = RwSignal::new(false);
let submit_error = RwSignal::new(Option::<String>::None);
let send_action = Action::new(
move |(n, em, s, msg): &(String, String, String, String)| {
let n = n.clone();
let em = em.clone();
let s = s.clone();
let msg = msg.clone();
async move { server_send_contact_form(n, em, s, msg).await }
},
);
let on_submit = move |ev: leptos::ev::SubmitEvent| {
ev.prevent_default();
let n = name.get_untracked();
let em = email.get_untracked();
if n.trim().is_empty() || em.trim().is_empty() {
submit_error.set(Some("Name and email are required.".to_string()));
return;
}
let company_val = company.get_untracked();
let pt = project_type.get_untracked();
let pd = project_description.get_untracked();
let cs = current_situation.get_untracked();
let g = goals.get_untracked();
let tr = technical_requirements.get_untracked();
let tl = timeline.get_untracked();
let b = budget.get_untracked();
let cm = contact_method.get_untracked();
let subject = format!("Work Request: {}", if pt.is_empty() { "General" } else { &pt });
let message = format!(
"Company: {company}\n\
Project type: {pt}\n\
Description:\n{pd}\n\n\
Current situation:\n{cs}\n\n\
Goals:\n{g}\n\n\
Technical requirements:\n{tr}\n\
Timeline: {tl}\n\
Budget: {b}\n\
Preferred contact: {cm}",
company = if company_val.is_empty() { "".to_string() } else { company_val },
pt = if pt.is_empty() { "".to_string() } else { pt },
pd = if pd.is_empty() { "".to_string() } else { pd },
cs = if cs.is_empty() { "".to_string() } else { cs },
g = if g.is_empty() { "".to_string() } else { g },
tr = if tr.is_empty() { "".to_string() } else { tr },
tl = if tl.is_empty() { "".to_string() } else { tl },
b = if b.is_empty() { "".to_string() } else { b },
cm = if cm.is_empty() { "".to_string() } else { cm },
);
submitting.set(true);
submit_ok.set(false);
submit_error.set(None);
send_action.dispatch((n, em, subject, message));
};
// React to action completion
Effect::new(move |_| {
if let Some(result) = send_action.value().get() {
submitting.set(false);
match result {
Ok(_) => {
submit_ok.set(true);
// Clear fields
name.set(String::new());
email.set(String::new());
company.set(String::new());
project_type.set(String::new());
project_description.set(String::new());
current_situation.set(String::new());
goals.set(String::new());
technical_requirements.set(String::new());
timeline.set(String::new());
budget.set(String::new());
contact_method.set(String::new());
}
Err(e) => {
submit_error.set(Some(e.to_string()));
}
}
}
});
view! {
<div class="ds-bg-page">
// Header Section
<section class="py-16 px-6 lg:px-8">
<div class="mx-auto max-w-4xl text-center">
<h1 class="ds-heading-1 ds-text mb-4">
{content.t("work-request-page-title")}
</h1>
<HtmlContent
content={content.t("work-request-subtitle")}
class="mt-ds-6 ds-body ds-text-secondary max-w-2xl mx-auto".to_string()
/>
</div>
</section>
// Main Form Section
<div class="py-8">
<div class="mx-auto max-w-4xl px-6 lg:px-8">
<div class="ds-bg ds-rounded-lg ds-shadow-lg p-8">
<form on:submit=on_submit class="space-y-8">
// Contact Information Section
<div>
<h2 class="ds-heading-2 ds-text mb-4">
{content.t("work-request-contact-info")}
</h2>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label for="work-request-name" class="block ds-text font-medium mb-2">
{content.t("work-request-your-name")}
</label>
<input
type="text"
id="work-request-name"
name="name"
required
prop:value=name
on:input=move |ev| name.set(event_target_value(&ev))
class="ds-input w-full px-4 py-3 ds-border ds-rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder={content.t("work-request-your-name-placeholder")}
/>
</div>
<div>
<label for="work-request-email" class="block ds-text font-medium mb-2">
{content.t("work-request-your-email")}
</label>
<input
type="email"
id="work-request-email"
name="email"
required
prop:value=email
on:input=move |ev| email.set(event_target_value(&ev))
class="ds-input w-full px-4 py-3 ds-border ds-rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder={content.t("work-request-your-email-placeholder")}
/>
</div>
</div>
<div class="mt-4">
<label for="work-request-company" class="block ds-text font-medium mb-2">
{content.t("work-request-company")}
</label>
<input
type="text"
id="work-request-company"
name="company"
prop:value=company
on:input=move |ev| company.set(event_target_value(&ev))
class="ds-input w-full px-4 py-3 ds-border ds-rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder={content.t("work-request-company-placeholder")}
/>
</div>
</div>
// Project Details Section
<div>
<h2 class="ds-heading-2 ds-text mb-4">
{content.t("work-request-project-info")}
</h2>
<div class="mb-4">
<label for="work-request-project-type" class="block ds-text font-medium mb-2">
{content.t("work-request-project-type-label")}
</label>
<select
id="work-request-project-type"
name="project_type"
required
prop:value=project_type
on:change=move |ev| project_type.set(event_target_value(&ev))
class="ds-select w-full px-4 py-3 ds-border ds-rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="">{content.t("work-request-project-type-placeholder")}</option>
<option value="new-project">{content.t("work-request-project-type-new")}</option>
<option value="existing-project">{content.t("work-request-project-type-existing")}</option>
<option value="infrastructure">{content.t("work-request-project-type-infrastructure")}</option>
<option value="consulting">{content.t("work-request-project-type-consulting")}</option>
<option value="mentoring">{content.t("work-request-project-type-mentoring")}</option>
<option value="training">{content.t("work-request-project-type-training")}</option>
<option value="audit">{content.t("work-request-project-type-audit")}</option>
<option value="other">{content.t("work-request-project-type-other")}</option>
</select>
</div>
<div class="mb-4">
<label for="work-request-project-description" class="block ds-text font-medium mb-2">
{content.t("work-request-project-description-label")}
</label>
<textarea
id="work-request-project-description"
name="project_description"
required
rows="5"
prop:value=project_description
on:input=move |ev| project_description.set(event_target_value(&ev))
class="ds-textarea w-full px-4 py-3 ds-border ds-rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder={content.t("work-request-project-description-placeholder")}
></textarea>
</div>
<div class="mb-4">
<label for="work-request-current-situation" class="block ds-text font-medium mb-2">
{content.t("work-request-current-situation-label")}
</label>
<textarea
id="work-request-current-situation"
name="current_situation"
rows="3"
prop:value=current_situation
on:input=move |ev| current_situation.set(event_target_value(&ev))
class="ds-textarea w-full px-4 py-3 ds-border ds-rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder={content.t("work-request-current-situation-placeholder")}
></textarea>
</div>
<div class="mb-4">
<label for="work-request-goals" class="block ds-text font-medium mb-2">
{content.t("work-request-goals-label")}
</label>
<textarea
id="work-request-goals"
name="goals"
required
rows="3"
prop:value=goals
on:input=move |ev| goals.set(event_target_value(&ev))
class="ds-textarea w-full px-4 py-3 ds-border ds-rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder={content.t("work-request-goals-placeholder")}
></textarea>
</div>
<div class="mb-4">
<label for="work-request-technical-requirements" class="block ds-text font-medium mb-2">
{content.t("work-request-technical-requirements-label")}
</label>
<textarea
id="work-request-technical-requirements"
name="technical_requirements"
rows="4"
prop:value=technical_requirements
on:input=move |ev| technical_requirements.set(event_target_value(&ev))
class="ds-textarea w-full px-4 py-3 ds-border ds-rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder={content.t("work-request-technical-requirements-placeholder")}
></textarea>
</div>
</div>
// Timeline and Budget Section
<div>
<h2 class="ds-heading-2 ds-text mb-4">
{content.t("work-request-timeline-budget")}
</h2>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label for="work-request-timeline" class="block ds-text font-medium mb-2">
{content.t("work-request-timeline-label")}
</label>
<select
id="work-request-timeline"
name="timeline"
required
prop:value=timeline
on:change=move |ev| timeline.set(event_target_value(&ev))
class="ds-select w-full px-4 py-3 ds-border ds-rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="">{content.t("work-request-timeline-placeholder")}</option>
<option value="asap">{content.t("work-request-timeline-asap")}</option>
<option value="1-month">{content.t("work-request-timeline-1-month")}</option>
<option value="1-3-months">{content.t("work-request-timeline-1-3-months")}</option>
<option value="3-6-months">{content.t("work-request-timeline-3-6-months")}</option>
<option value="6+ months">{content.t("work-request-timeline-6plus")}</option>
<option value="flexible">{content.t("work-request-timeline-flexible")}</option>
</select>
</div>
<div>
<label for="work-request-budget" class="block ds-text font-medium mb-2">
{content.t("work-request-budget")}
</label>
<select
id="work-request-budget"
name="budget"
prop:value=budget
on:change=move |ev| budget.set(event_target_value(&ev))
class="ds-select w-full px-4 py-3 ds-border ds-rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="">{"Select budget range"}</option>
<option value="<5k">{content.t("work-request-budget-under-5k")}</option>
<option value="5k-15k">{content.t("work-request-budget-5k-15k")}</option>
<option value="15k-30k">{content.t("work-request-budget-15k-50k")}</option>
<option value="30k-50k">{content.t("work-request-budget-50k-plus")}</option>
<option value="50k+">{content.t("work-request-budget-50k-plus")}</option>
<option value="hourly">"Hourly rate"</option>
<option value="discuss">{content.t("work-request-budget-discuss")}</option>
</select>
</div>
</div>
</div>
// Communication Preferences Section
<div>
<h2 class="ds-heading-2 ds-text mb-4">
{content.t("work-request-communication-preferences")}
</h2>
<div>
<label for="work-request-contact-method" class="block ds-text font-medium mb-2">
{content.t("work-request-contact-method-label")}
</label>
<select
id="work-request-contact-method"
name="contact_method"
required
prop:value=contact_method
on:change=move |ev| contact_method.set(event_target_value(&ev))
class="ds-select w-full px-4 py-3 ds-border ds-rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="">{content.t("work-request-contact-method-placeholder")}</option>
<option value="email">{content.t("work-request-contact-method-email")}</option>
<option value="telegram">{content.t("work-request-contact-method-telegram")}</option>
<option value="video-call">{content.t("work-request-contact-method-video")}</option>
<option value="phone">{content.t("work-request-contact-method-phone")}</option>
</select>
</div>
</div>
// Submit Button + Feedback
<div class="pt-6 border-t ds-border">
// Success banner
{
let success_label = content.t("work-request-submit-success");
move || {
#[cfg(target_arch = "wasm32")]
let ok = submit_ok.get();
#[cfg(not(target_arch = "wasm32"))]
let ok = submit_ok.get_untracked();
ok.then(|| view! {
<div class="mb-4 p-4 bg-green-50 border border-green-200 ds-rounded text-green-800 text-sm">
{success_label.clone()}
</div>
})
}
}
// Error banner
{move || {
#[cfg(target_arch = "wasm32")]
let err = submit_error.get();
#[cfg(not(target_arch = "wasm32"))]
let err = submit_error.get_untracked();
err.map(|err| view! {
<div class="mb-4 p-4 bg-red-50 border border-red-200 ds-rounded text-red-800 text-sm">
{err}
</div>
})
}}
<button
type="submit"
name="submit"
disabled=move || {
#[cfg(target_arch = "wasm32")]
{ submitting.get() }
#[cfg(not(target_arch = "wasm32"))]
{ submitting.get_untracked() }
}
class="w-full ds-btn-primary py-4 px-6 ds-rounded font-medium text-lg disabled:opacity-60 disabled:cursor-not-allowed"
>
{
let submit_btn_label = content.t("work-request-submit-btn");
move || {
#[cfg(target_arch = "wasm32")]
let is_submitting = submitting.get();
#[cfg(not(target_arch = "wasm32"))]
let is_submitting = submitting.get_untracked();
if is_submitting {
"Sending…".to_string()
} else {
submit_btn_label.clone()
}
}
}
</button>
<p class="ds-caption ds-text-secondary text-center mt-3">
{content.t("work-request-submit-note")}
</p>
</div>
</form>
</div>
</div>
</div>
// What Happens Next Section
<section class="py-16 ds-bg-secondary">
<div class="mx-auto max-w-4xl px-6 lg:px-8">
<h2 class="ds-heading-1 ds-text text-center mb-12">
{content.t("work-request-what-happens-next")}
</h2>
<div class="grid grid-cols-1 md:grid-cols-3 gap-8">
<div class="text-center">
<div class="w-16 h-16 ds-bg ds-rounded-full flex items-center justify-center mx-auto mb-4">
<span class="text-2xl">"📋"</span>
</div>
<h3 class="ds-heading-3 ds-text mb-2">
{content.t("work-request-step-1-title")}
</h3>
<p class="ds-text-secondary">
{content.t("work-request-step-1-description")}
</p>
</div>
<div class="text-center">
<div class="w-16 h-16 ds-bg ds-rounded-full flex items-center justify-center mx-auto mb-4">
<span class="text-2xl">"💬"</span>
</div>
<h3 class="ds-heading-3 ds-text mb-2">
{content.t("work-request-step-2-title")}
</h3>
<p class="ds-text-secondary">
{content.t("work-request-step-2-description")}
</p>
</div>
<div class="text-center">
<div class="w-16 h-16 ds-bg ds-rounded-full flex items-center justify-center mx-auto mb-4">
<span class="text-2xl">"📄"</span>
</div>
<h3 class="ds-heading-3 ds-text mb-2">
{content.t("work-request-step-3-title")}
</h3>
<p class="ds-text-secondary">
{content.t("work-request-step-3-description")}
</p>
</div>
</div>
</div>
</section>
</div>
}
}

View file

@ -0,0 +1,23 @@
[package]
name = "website-pages-htmx"
version = "0.1.0"
edition = "2021"
description = "website HTMX page renderers and templates"
[lib]
name = "website_pages_htmx"
crate-type = ["lib"]
[dependencies]
minijinja = { workspace = true }
html-escape = { workspace = true }
serde_json = { workspace = true }
tracing = { workspace = true }
rustelo_core_lib = { workspace = true }
rustelo_pages_htmx = { workspace = true }
[dev-dependencies]
fluent-bundle = { workspace = true }
fluent-syntax = { workspace = true }
unic-langid = { workspace = true }

View file

@ -0,0 +1,10 @@
use std::path::Path;
fn main() {
let templates = Path::new(env!("CARGO_MANIFEST_DIR")).join("templates");
println!(
"cargo:rustc-env=JPL_PAGES_HTMX_TEMPLATES={}",
templates.display()
);
println!("cargo:rerun-if-changed=templates");
}

View file

@ -0,0 +1,142 @@
//! website HTMX page renderers.
//!
//! Provides project-specific Minijinja page bodies. Call [`dispatch`] from the
//! server's HTMX page dispatcher to render static and product pages. Content
//! post pages (blog, projects, etc.) are handled by `rustelo_pages_htmx`.
mod pages;
use minijinja::Environment;
/// Path to this crate's `templates/` directory, set at build time.
pub const TEMPLATES_DIR: &str = env!("JPL_PAGES_HTMX_TEMPLATES");
/// Attempt to render the page body for `path` in `lang`.
///
/// Returns `Some(html)` for known project routes, `None` for unknown paths
/// so the caller can fall through to 404.
pub fn dispatch(env: &Environment, path: &str, lang: &str) -> Option<String> {
match path {
// Home
"/" | "/inicio" | "/en" | "/es" => Some(pages::home::render(env, lang)),
// About — level-aware: project projection (about.json) or personal CV
"/about" | "/acerca-de" => Some(pages::about::render(env, lang)),
// Static pages — generic template with page_id for FTL key lookup
"/services" | "/servicios" => Some(pages::static_page::render(env, "services", lang)),
"/contact" | "/contacto" => Some(pages::static_page::render(env, "contact", lang)),
"/legal" => Some(pages::static_page::render(env, "legal", lang)),
"/privacy" | "/privacidad" => Some(pages::static_page::render(env, "privacy", lang)),
"/work-request" | "/solicitud-trabajo" => {
Some(pages::static_page::render(env, "work-request", lang))
}
// Product pages
"/syntaxis" => Some(pages::static_page::render(env, "syntaxis", lang)),
"/vapora" => Some(pages::static_page::render(env, "vapora", lang)),
"/rustelo" => Some(pages::static_page::render(env, "rustelo", lang)),
"/provisioning-systems" => {
Some(pages::static_page::render(env, "provisioning", lang))
}
"/stratumiops" => Some(pages::static_page::render(env, "stratumiops", lang)),
"/kogral" => Some(pages::static_page::render(env, "kogral", lang)),
"/typedialog" => Some(pages::static_page::render(env, "typedialog", lang)),
"/ontoref" => Some(pages::static_page::render(env, "ontoref", lang)),
"/secretumvault" => Some(pages::static_page::render(env, "secretumvault", lang)),
// Auth pages served via HTMX modal — direct URL access shows a minimal shell
"/login" | "/iniciar-sesion" => {
Some(pages::static_page::render(env, "login", lang))
}
_ => None,
}
}
/// Level-aware `<head>` SEO (title, description) for `/about`, or `None` when the
/// projection is personal/absent. Re-exports [`pages::about::head_seo`] so the
/// server's SEO builder can make the About head match the projected body without
/// reaching into the private `pages` module.
pub fn about_head_seo(lang: &str) -> Option<(String, String)> {
pages::about::head_seo(lang)
}
#[cfg(test)]
mod ftl_parse_tests {
use fluent_bundle::{FluentBundle, FluentResource};
use unic_langid::LanguageIdentifier;
fn load_combined_ftl(lang: &str) -> String {
let base = concat!(env!("CARGO_MANIFEST_DIR"), "/../../site/i18n/locales");
let lang_dir = format!("{}/{}", base, lang);
let mut combined = String::new();
for fname in &["common.ftl", "auth.ftl"] {
let p = format!("{}/{}", lang_dir, fname);
if std::path::Path::new(&p).exists() {
combined.push_str(&std::fs::read_to_string(&p).unwrap());
combined.push('\n');
}
}
for subdir in &["pages", "components", "manager"] {
let dir = format!("{}/{}", lang_dir, subdir);
if let Ok(entries) = std::fs::read_dir(&dir) {
let mut files: Vec<_> = entries
.filter_map(|e| e.ok())
.filter(|e| e.path().extension().map_or(false, |x| x == "ftl"))
.collect();
files.sort_by_key(|e| e.file_name());
for entry in &files {
combined.push_str(&std::fs::read_to_string(entry.path()).unwrap());
combined.push('\n');
}
}
}
combined
}
#[test]
fn test_ftl_parse_en() {
let content = load_combined_ftl("en");
assert!(!content.is_empty(), "en FTL content is empty");
match FluentResource::try_new(content) {
Ok(_) => println!("en FTL: parse OK"),
Err((_, errors)) => panic!("en FTL parse errors: {:?}", errors),
}
}
#[test]
fn test_ftl_bundle_en() {
let content = load_combined_ftl("en");
let resource = FluentResource::try_new(content).expect("en FTL should parse");
let langid: LanguageIdentifier = "en".parse().unwrap();
let mut bundle = FluentBundle::new(vec![langid]);
match bundle.add_resource(resource) {
Ok(_) => println!("en FTL: bundle add OK"),
Err(errors) => panic!("en FTL bundle add errors: {:?}", errors),
}
}
#[test]
fn test_ftl_parse_es() {
let content = load_combined_ftl("es");
assert!(!content.is_empty(), "es FTL content is empty");
match FluentResource::try_new(content) {
Ok(_) => println!("es FTL: parse OK"),
Err((_, errors)) => panic!("es FTL parse errors: {:?}", errors),
}
}
#[test]
fn test_ftl_bundle_es() {
let content = load_combined_ftl("es");
let resource = FluentResource::try_new(content).expect("es FTL should parse");
let langid: LanguageIdentifier = "es".parse().unwrap();
let mut bundle = FluentBundle::new(vec![langid]);
match bundle.add_resource(resource) {
Ok(_) => println!("es FTL: bundle add OK"),
Err(errors) => panic!("es FTL bundle add errors: {:?}", errors),
}
}
}

View file

@ -0,0 +1,198 @@
//! Level-aware About renderer.
//!
//! Reads the projected `about.json` view-model — built daemon-free by
//! `gen-about-pages.nu --emit-json` from a level's `.ontoref/positioning/about.ncl`
//! — and hot-reloads it on an mtime check, exactly like `content_graph.json`
//! (no restart when the projection is regenerated). When the artifact declares
//! `kind == "project"` it renders `pages/about_project.j2` with the view-model;
//! otherwise (artifact absent, malformed, or `kind == "personal"`) it falls back
//! to the texts-driven `pages/about.j2` personal template.
use std::collections::HashMap;
use std::sync::{Arc, RwLock};
use std::time::SystemTime;
use minijinja::{Environment, Value, context};
use rustelo_core_lib::load_texts_from_ftl;
use rustelo_pages_htmx::render::render_safe;
/// Cache of `(file mtime, parsed view-model)`. Re-read only when mtime changes,
/// so a freshly projected `about.json` takes effect on the next request.
static ABOUT_CACHE: RwLock<Option<(Option<SystemTime>, Arc<Option<serde_json::Value>>)>> =
RwLock::new(None);
/// Resolve the JSON location. `ABOUT_PATH` wins; otherwise the PV deploy path
/// then the dev build-output path. All cwd-relative — delivering the JSON is
/// sufficient, no env var or rebuild required.
fn about_path() -> String {
if let Ok(p) = std::env::var("ABOUT_PATH") {
return p;
}
for cand in ["site/public/r/about.json", "target/site/r/about.json"] {
if std::path::Path::new(cand).exists() {
return cand.to_string();
}
}
"site/public/r/about.json".to_string()
}
fn load_about(path: &str) -> Option<serde_json::Value> {
match std::fs::read_to_string(path) {
Ok(s) => match serde_json::from_str::<serde_json::Value>(&s) {
Ok(v) => Some(v),
Err(e) => {
tracing::error!(path = %path, error = %e, "about.json parse failed — personal fallback");
None
}
},
Err(_) => None,
}
}
fn about_data() -> Arc<Option<serde_json::Value>> {
let path = about_path();
let mtime = std::fs::metadata(&path).and_then(|m| m.modified()).ok();
if let Some((cached_mtime, data)) = ABOUT_CACHE.read().expect("about cache poisoned").as_ref() {
if *cached_mtime == mtime {
return Arc::clone(data);
}
}
let data = Arc::new(load_about(&path));
*ABOUT_CACHE.write().expect("about cache poisoned") = Some((mtime, Arc::clone(&data)));
data
}
/// Render `/about` for `lang`, branching on the projected level kind.
pub fn render(env: &Environment, lang: &str) -> String {
let data = about_data();
if let Some(vm) = data.as_ref() {
if vm.get("kind").and_then(|k| k.as_str()) == Some("project") {
let texts = load_texts(lang);
let ctx = context! {
about => Value::from_serialize(vm),
texts => texts,
lang => lang,
};
return render_safe(env, "pages/about_project.j2", ctx);
}
}
// Personal level (or no project projection): keep the existing CV template.
super::static_page::render(env, "about", lang)
}
/// Head `<title>` + meta description for the level-aware `/about`, when the
/// projected `about.json` declares `kind == "project"`. Title mirrors the
/// rendered `<h1>` ("About {level}" / "Acerca de {level}"); description is the
/// hero line. Returns `None` for personal or absent projections so the SEO
/// builder keeps the component-static AboutPage FTL keys (the personal CV head).
pub fn head_seo(lang: &str) -> Option<(String, String)> {
let data = about_data();
let vm = data.as_ref().as_ref()?;
if vm.get("kind").and_then(|k| k.as_str()) != Some("project") {
return None;
}
let level = vm.get("level_id").and_then(|v| v.as_str()).unwrap_or_default();
let title = if lang == "es" {
format!("Acerca de {level}")
} else {
format!("About {level}")
};
let description = vm
.get("hero")
.and_then(|h| h.get(lang))
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string();
Some((title, description))
}
fn load_texts(lang: &str) -> HashMap<String, String> {
load_texts_from_ftl(lang)
.map(|t| t.get_translations_for_language(lang))
.unwrap_or_default()
}
#[cfg(test)]
mod tests {
use minijinja::{Environment, Value, context};
use std::collections::HashMap;
fn sample_vm() -> serde_json::Value {
// Block-vocabulary view-model: section = { key, title, blocks[] }, each
// block a typed render primitive (prose | timeline | cards | links | graph | kv).
serde_json::json!({
"kind": "project", "level_id": "ontoref",
"hero": { "en": "What ontoref is", "es": "Qué es ontoref" },
"sections": [
{ "key": "history", "title": { "en": "History", "es": "Historia" },
"blocks": [
{ "type": "timeline", "started": "2025",
"items": [ { "date": "2025", "text": { "en": "three-layer NCL", "es": "NCL tres capas" } } ] }
] },
{ "key": "defs", "title": { "en": "What it is", "es": "Qué es" },
"blocks": [
{ "type": "cards", "groups": [ { "label": "Axioms",
"items": [ { "title": "Protocol, Not Runtime", "meta": "Yang", "body": "never a runtime dep" } ] } ] }
] },
{ "key": "graph", "title": { "en": "Graph", "es": "Grafo" },
"blocks": [
{ "type": "graph", "svg": "",
"live": { "href": "/graph?focus=ontoref",
"label": { "en": "See it live in the graph", "es": "Verlo vivo en el grafo" } } }
] },
{ "key": "adrs", "title": { "en": "ADRs", "es": "ADRs" },
"blocks": [
{ "type": "links",
"groups": [ { "label": "", "items": [ { "href": "/adr/001/", "code": "ADR-001", "text": "First ADR" } ] } ],
"browse": { "href": "/adr/", "label": { "en": "Browse all ADRs", "es": "Ver todas las ADR" } } }
] },
{ "key": "catalog", "title": { "en": "Catalog", "es": "Catálogo" },
"blocks": [
{ "type": "links",
"groups": [ { "label": "Operations", "items": [ { "href": "/catalog/move_fsm_state/", "code": "op", "text": "move_fsm_state" } ] } ],
"browse": { "href": "/catalog/", "label": { "en": "Browse the full catalog", "es": "Ver el catálogo completo" } } }
] }
]
})
}
fn render(lang: &str) -> String {
let tmpl = include_str!("../../templates/pages/about_project.j2");
let mut env = Environment::new();
env.add_template("pages/about_project.j2", tmpl)
.expect("about_project.j2 parses");
let vm = sample_vm();
env.get_template("pages/about_project.j2")
.unwrap()
.render(context! {
about => Value::from_serialize(&vm),
lang => lang,
texts => HashMap::<String, String>::new(),
})
.expect("about_project.j2 renders")
}
#[test]
fn renders_project_about_en() {
let html = render("en");
assert!(html.contains("About ontoref"));
assert!(html.contains("What ontoref is"));
assert!(html.contains("Protocol, Not Runtime"));
assert!(html.contains("/adr/001/"));
assert!(html.contains("Browse all ADRs"));
assert!(html.contains("/graph?focus=ontoref"));
assert!(html.contains("move_fsm_state"));
assert!(html.contains("/catalog/move_fsm_state/"));
}
#[test]
fn renders_project_about_es() {
let html = render("es");
assert!(html.contains("Qué es ontoref")); // hero[es]
assert!(html.contains("NCL tres capas")); // milestone line[es]
assert!(html.contains("Verlo vivo en el grafo")); // es graph CTA
assert!(html.contains("Ver todas las ADR")); // es ADR CTA
}
}

View file

@ -0,0 +1,29 @@
//! Home page renderer.
use std::collections::HashMap;
use minijinja::{Environment, context};
use rustelo_core_lib::load_texts_from_ftl;
use rustelo_pages_htmx::render::render_safe;
pub fn render(env: &Environment, lang: &str) -> String {
let texts = load_texts(lang);
// The logo comes from the `site_logo` global (server reads the consumer's
// site.ncl [logo] into the env) — config-driven, never hardcoded here.
render_safe(env, "pages/home.j2", context! { texts => texts, lang => lang })
}
fn load_texts(lang: &str) -> HashMap<String, String> {
match load_texts_from_ftl(lang) {
Ok(t) => {
let map = t.get_translations_for_language(lang);
tracing::info!(lang, keys = map.len(), "home FTL loaded");
map
}
Err(e) => {
tracing::error!(lang, error = %e, "home FTL load failed");
HashMap::new()
}
}
}

View file

@ -0,0 +1,3 @@
pub mod about;
pub mod home;
pub mod static_page;

View file

@ -0,0 +1,25 @@
//! Generic static page renderer (about, services, legal, privacy, product pages).
use std::collections::HashMap;
use minijinja::{Environment, context};
use rustelo_core_lib::load_texts_from_ftl;
use rustelo_pages_htmx::render::render_safe;
pub fn render(env: &Environment, page_id: &str, lang: &str) -> String {
let texts = load_texts(lang);
let ctx = context! { page_id => page_id, texts => texts, lang => lang };
let specific = format!("pages/{page_id}.j2");
if env.get_template(&specific).is_ok() {
render_safe(env, &specific, ctx)
} else {
render_safe(env, "pages/static.j2", ctx)
}
}
fn load_texts(lang: &str) -> HashMap<String, String> {
load_texts_from_ftl(lang)
.map(|t| t.get_translations_for_language(lang))
.unwrap_or_default()
}

View file

@ -0,0 +1,173 @@
<div class="min-h-screen ds-bg-page">
<section class="py-20 ds-container">
<div class="mx-auto max-w-4xl text-center">
<div class="mb-8">
<div class="flex items-center justify-center gap-8 mb-6">
<img src="/images/logos/site-logo-b.png" alt="Site logo" class="lg:h-20 h-15" />
<img src="/images/JesusPerez_f.jpg" alt="Jesús Pérez Profile Picture" class="lg:w-40 rounded-full w-32 object-cover border-2 ds-border" />
</div>
<h1 class="text-balance text-4xl font-bold tracking-tight ds-text sm:text-6xl">
{{ texts["about-page-title"] }}
</h1>
<p class="mt-ds-6 ds-body ds-text-secondary max-w-2xl mx-auto">
{{ texts["about-page-subtitle"] }}
</p>
</div>
</div>
</section>
<section class="py-16 ds-bg">
<div class="ds-container-lg ds-container">
<div class="grid grid-cols-1 lg:grid-cols-2 gap-12 items-center">
<div>
<h2 class="text-3xl font-bold ds-text mb-6">
{{ texts["about-my-journey"] }}
</h2>
<div class="space-y-4 ds-text-secondary">
<p>{{ texts["about-journey-paragraph-1"] }}</p>
<p>{{ texts["about-journey-paragraph-2"] }}</p>
<p>{{ texts["about-journey-paragraph-3"] }}</p>
</div>
</div>
<div class="p-8 ds-rounded-lg">
<h3 class="ds-heading-4 ds-text mb-ds-4">
{{ texts["about-philosophy-approach"] }}
</h3>
<ul class="space-y-3 ds-text-secondary">
<li class="flex items-start gap-3">
<span class="text-green-500 mt-1">✓</span>
<span>{{ texts["about-open-source-first"] }}</span>
</li>
<li class="flex items-start gap-3">
<span class="text-green-500 mt-1">✓</span>
<span>{{ texts["about-self-hosted-solutions"] }}</span>
</li>
<li class="flex items-start gap-3">
<span class="text-green-500 mt-1">✓</span>
<span>{{ texts["about-performance-security-design"] }}</span>
</li>
<li class="flex items-start gap-3">
<span class="text-green-500 mt-1">✓</span>
<span>{{ texts["about-pragmatic-solutions"] }}</span>
</li>
<li class="flex items-start gap-3">
<span class="text-green-500 mt-1">✓</span>
<span>{{ texts["about-knowledge-transfer-empowerment"] }}</span>
</li>
</ul>
</div>
</div>
</div>
</section>
<section class="py-16 ds-bg-page">
<div class="ds-container-lg ds-container">
<div class="text-center mb-ds-12">
<h2 class="text-3xl font-bold ds-text">{{ texts["about-technical-expertise"] }}</h2>
<p class="mt-4 ds-text-secondary">{{ texts["about-technologies-work-with"] }}</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-8">
<div class="ds-bg p-ds-6 ds-rounded-lg ds-shadow-sm">
<div class="text-center mb-ds-4">
<span class="text-4xl">🦀</span>
</div>
<h3 class="ds-heading-4 ds-text mb-3 text-center">{{ texts["about-rust-development-title"] }}</h3>
<ul class="space-y-ds-2 ds-caption ds-text-secondary">
<li>{{ texts["about-systems-programming-item"] }}</li>
<li>{{ texts["about-web-applications-item"] }}</li>
<li>{{ texts["about-cli-tools-automation"] }}</li>
<li>{{ texts["about-performance-optimization-item"] }}</li>
<li>{{ texts["about-memory-safety-concurrency"] }}</li>
</ul>
</div>
<div class="ds-bg p-ds-6 ds-rounded-lg ds-shadow-sm">
<div class="text-center mb-ds-4">
<span class="text-4xl">🏗️</span>
</div>
<h3 class="ds-heading-4 ds-text mb-3 text-center">{{ texts["about-infrastructure-title"] }}</h3>
<ul class="space-y-ds-2 ds-caption ds-text-secondary">
<li>{{ texts["about-kubernetes-container"] }}</li>
<li>{{ texts["about-cicd-pipeline-design"] }}</li>
<li>{{ texts["about-monitoring-observability"] }}</li>
<li>{{ texts["about-self-hosted-alternatives"] }}</li>
<li>{{ texts["about-security-compliance"] }}</li>
</ul>
</div>
<div class="ds-bg p-ds-6 ds-rounded-lg ds-shadow-sm">
<div class="text-center mb-ds-4">
<span class="text-4xl">🌐</span>
</div>
<h3 class="ds-heading-4 ds-text mb-3 text-center">{{ texts["about-web3-blockchain-title"] }}</h3>
<ul class="space-y-ds-2 ds-caption ds-text-secondary">
<li>{{ texts["about-polkadot-ecosystem-dev"] }}</li>
<li>{{ texts["about-substrate-blockchain"] }}</li>
<li>{{ texts["about-smart-contract-dev"] }}</li>
<li>{{ texts["about-dapp-architecture"] }}</li>
<li>{{ texts["about-cross-chain-communication"] }}</li>
</ul>
</div>
</div>
</div>
</section>
<section class="py-16 ds-bg">
<div class="ds-container-lg ds-container">
<div class="text-center mb-ds-12">
<h2 class="text-3xl font-bold ds-text">{{ texts["about-why-self-hosted-open-source"] }}</h2>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-ds-8">
<div class="space-y-6">
<div>
<h3 class="ds-body font-semibold ds-text mb-2">{{ texts["about-control-privacy"] }}</h3>
<p class="ds-text-secondary">{{ texts["about-control-privacy-desc"] }}</p>
</div>
<div>
<h3 class="ds-body font-semibold ds-text mb-2">{{ texts["about-cost-effectiveness"] }}</h3>
<p class="ds-text-secondary">{{ texts["about-cost-effectiveness-desc"] }}</p>
</div>
<div>
<h3 class="ds-body font-semibold ds-text mb-2">{{ texts["about-resilience"] }}</h3>
<p class="ds-text-secondary">{{ texts["about-resilience-desc"] }}</p>
</div>
</div>
<div class="space-y-6">
<div>
<h3 class="ds-body font-semibold ds-text mb-2">{{ texts["about-performance"] }}</h3>
<p class="ds-text-secondary">{{ texts["about-performance-desc"] }}</p>
</div>
<div>
<h3 class="ds-body font-semibold ds-text mb-2">{{ texts["about-customization"] }}</h3>
<p class="ds-text-secondary">{{ texts["about-customization-desc"] }}</p>
</div>
<div>
<h3 class="ds-body font-semibold ds-text mb-2">{{ texts["about-community"] }}</h3>
<p class="ds-text-secondary">{{ texts["about-community-desc"] }}</p>
</div>
</div>
</div>
</div>
</section>
<section class="py-16">
<div class="mx-auto max-w-4xl ds-container text-center">
<h2 class="text-3xl font-bold ds-text mb-ds-4">{{ texts["about-lets-work-together"] }}</h2>
<p class="ds-text-secondary mb-8 ds-body">{{ texts["about-work-together-desc"] }}</p>
<div class="flex flex-col sm:flex-row gap-4 justify-center">
<a href="{{ texts['about-contact-url'] | default('/contact') }}" class="no-underline ds-btn-secondary">
{{ texts["about-get-in-touch"] }}
</a>
<a href="{{ texts['about-work-request-url'] | default('/work-request') }}" class="no-underline ds-btn-secondary">
{{ texts["about-start-project"] }}
</a>
</div>
</div>
</section>
</div>

View file

@ -0,0 +1,116 @@
{# Level-aware project About — rendered when about.json declares kind="project".
Context: `about` (view-model), `lang`, `texts`.
Uses the ontoref `.doc-*` document chrome (styles/custom.css) so the SSR site
matches outreach/web. GENERIC over a block vocabulary: a section is
{ key, title, blocks }, each block a typed primitive (prose | timeline |
cards | links | graph | kv). New SECTIONS of an existing block type are
projector-only changes (gen-about-pages.nu); only a brand-new block TYPE
touches this template. Path/href values are `| safe` (trusted contract
values, not user input). Bilingual fields indexed by `lang`. #}
<div class="about-doc">
<div class="doc-wrap">
<header class="doc-head">
<p class="doc-eyebrow">{{ about.level_id }}</p>
<h1>{% if lang == "es" %}Acerca de{% else %}About{% endif %} {{ about.level_id }}</h1>
{% if about.hero and about.hero[lang] %}
<p class="doc-lead">{{ about.hero[lang] }}</p>
{% endif %}
</header>
{% set about_img = "ontoref-about-es" if lang == "es" else "ontoref-about-en" %}
<picture>
<source srcset="/images/{{ about_img }}.avif" type="image/avif">
<source srcset="/images/{{ about_img }}.webp" type="image/webp">
<img src="/images/{{ about_img }}.webp"
alt="{% if lang == 'es' %}Acerca de{% else %}About{% endif %} {{ about.level_id }}"
width="1536" height="1024" loading="lazy"
style="display:block;margin:1.5rem auto 0;max-width:48rem;width:100%;height:auto;border-radius:.5rem" />
</picture>
{% if about.sections | length > 1 %}
<nav class="doc-toc">
<p>{% if lang == "es" %}Secciones{% else %}Sections{% endif %}</p>
<ul>
{% for section in about.sections %}
<li><a href="#{{ section.key }}">{{ section.title[lang] }}</a></li>
{% endfor %}
</ul>
</nav>
{% endif %}
{% for section in about.sections %}
<section class="doc-section" id="{{ section.key }}">
<h2>{{ section.title[lang] }}</h2>
{% for block in section.blocks %}
{% if block.type == "prose" %}
<div class="doc-prose">{{ (block.html_es if lang == "es" and block.html_es else block.html) | safe }}</div>
{% elif block.type == "timeline" %}
{% if block.started %}
<p class="doc-mono" style="margin:0 0 .75rem">{% if lang == "es" %}Desde{% else %}Started{% endif %} {{ block.started }}</p>
{% endif %}
<ul class="doc-list">
{% for m in block.items %}
<li>
<span class="doc-mono">{{ m.date }}</span>
<span>{{ m.text[lang] }}</span>
</li>
{% endfor %}
</ul>
{% elif block.type == "cards" %}
{% for grp in block.groups %}
{% if grp.label %}<h3 class="doc-idx-h">{{ grp.label }} <span class="doc-count">({{ grp.items | length }})</span></h3>{% endif %}
<ul class="doc-cards">
{% for n in grp.items %}
{% set card_body = n.body_es if lang == "es" and n.body_es else n.body %}
<li>
<p class="doc-card-title">{{ n.title_es if lang == "es" and n.title_es else n.title }}{% if n.meta %} <span class="doc-card-meta">· {{ n.meta }}</span>{% endif %}</p>
{% if card_body %}<p class="doc-card-body">{{ card_body }}</p>{% endif %}
</li>
{% endfor %}
</ul>
{% endfor %}
{% elif block.type == "links" %}
{% for grp in block.groups %}
{% if grp.label %}<h3 class="doc-idx-h">{{ grp.label }} <span class="doc-count">({{ grp.items | length }})</span></h3>{% endif %}
<ul class="doc-idx-list">
{% for i in grp.items %}
<li>
<a href="{{ i.href | safe }}">{% if i.code %}<span class="doc-mono">{{ i.code }}</span> {% endif %}{{ i.text }}</a>
</li>
{% endfor %}
</ul>
{% endfor %}
{% if block.browse and block.browse.href %}
<a href="{{ block.browse.href | safe }}" class="doc-btn">{{ block.browse.label[lang] }} →</a>
{% endif %}
{% elif block.type == "graph" %}
{% if block.svg %}<div class="doc-graph">{{ block.svg | safe }}</div>{% endif %}
{% if block.live and block.live.href %}
<a href="{{ (block.live.href_es if lang == "es" and block.live.href_es else block.live.href) | safe }}" class="doc-btn">↗ {{ block.live.label[lang] }}</a>
{% endif %}
{% elif block.type == "kv" %}
<table class="doc-kv"><tbody>
{% for r in block.rows %}
<tr><td>{{ r.k_es if lang == "es" and r.k_es else r.k }}</td><td>{{ r.v_es if lang == "es" and r.v_es else r.v }}</td></tr>
{% endfor %}
</tbody></table>
{% endif %}
{% endfor %}
</section>
{% endfor %}
</div>
</div>
{% include "partials/image-zoom.j2" %}

View file

@ -0,0 +1,241 @@
<div class="ds-bg-page py-ds-6">
<section class="ds-container max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
{# ── Header ── #}
<div class="text-center mb-ds-8">
<h1 class="ds-heading-1 ds-text mb-ds-4">{{ texts["contact-page-title"] }}</h1>
<p class="mt-ds-6 ds-body ds-text-secondary max-w-2xl mx-auto">{{ texts["contact-page-subtitle"] | safe }}</p>
{% set contact_img = "ontoref-contactar" if lang == "es" else "ontoref-contact-en" %}
<picture>
<source srcset="/images/{{ contact_img }}.avif" type="image/avif">
<source srcset="/images/{{ contact_img }}.webp" type="image/webp">
<img src="/images/{{ contact_img }}.webp"
alt="{{ texts['contact-page-title'] }}"
width="1536" height="1024" loading="lazy"
class="mt-ds-8 mx-auto rounded-lg ds-shadow-sm w-full max-w-3xl h-auto" />
</picture>
</div>
{# ── Main grid: sidebar (1) + form (2) ── #}
<div class="grid grid-cols-1 lg:grid-cols-3 gap-ds-6">
{# ── Contact info sidebar ── #}
<div class="lg:col-span-1">
<div class="ds-bg ds-rounded-lg ds-shadow-sm p-ds-6">
<h2 class="ds-heading-2 ds-text mb-ds-6">{{ texts["contact-lets-connect"] }}</h2>
<div class="space-y-6">
{# Email #}
<div class="flex items-start">
<div class="flex-shrink-0">
<div class="w-10 h-10 ds-bg-secondary ds-rounded-lg flex items-center justify-center">
<span class="text-lg">📧</span>
</div>
</div>
<div class="ml-4">
<h3 class="ds-heading-4 ds-text">{{ texts["contact-email-contact"] }}</h3>
<p class="ds-text-secondary">hello@jesusperez.pro</p>
<p class="ds-caption ds-text-muted">{{ texts["contact-best-for-detailed"] }}</p>
</div>
</div>
{# Telegram #}
<div class="flex items-start">
<div class="flex-shrink-0">
<div class="w-10 h-10 ds-bg-secondary ds-rounded-lg flex items-center justify-center">
<span class="text-lg">💬</span>
</div>
</div>
<div class="ml-4">
<h3 class="ds-heading-4 ds-text">{{ texts["contact-telegram-contact"] }}</h3>
<p class="ds-text-secondary">@jesusperez_dev</p>
<p class="ds-caption ds-text-muted">{{ texts["contact-quick-questions"] }}</p>
</div>
</div>
{# Schedule a call #}
<div class="flex items-start">
<div class="flex-shrink-0">
<div class="w-10 h-10 ds-bg-secondary ds-rounded-lg flex items-center justify-center">
<span class="text-lg">📞</span>
</div>
</div>
<div class="ml-4">
<h3 class="ds-heading-4 ds-text">{{ texts["contact-schedule-call"] }}</h3>
<p class="ds-text-secondary">{{ texts["contact-consultation-30min"] }}</p>
<a href="https://calendly.com/jesusperez-dev" target="_blank" rel="noopener noreferrer"
class="ds-caption ds-text font-medium hover:ds-text-secondary">
{{ texts["contact-book-time-slot"] }}
</a>
</div>
</div>
</div>
{# Professional networks #}
<div class="mt-ds-6 pt-ds-6 border-t ds-border">
<h3 class="ds-heading-4 ds-text mb-ds-4">{{ texts["contact-professional-networks"] }}</h3>
<div class="space-y-2">
<a href="https://www.linkedin.com/in/jesusperezlorenzo/" target="_blank" rel="noopener noreferrer"
class="no-underline block ds-text-secondary hover:ds-text ds-caption">
{{ texts["contact-linkedin-profile"] }}
</a>
<a href="https://repo.jesusperez.pro/jesus/ontoref" target="_blank" rel="noopener noreferrer"
class="no-underline block ds-text-secondary hover:ds-text ds-caption">
{{ texts["contact-git-profile"] }}
</a>
<a href="{{ texts['contact-blog-url'] | default(value='/blog') }}"
class="no-underline block ds-text-secondary hover:ds-text ds-caption">
{{ texts["contact-read-my-blog"] }}
</a>
</div>
</div>
</div>
</div>
{# ── Contact form (2 cols) ── #}
<div class="lg:col-span-2">
<div class="ds-bg ds-rounded-lg ds-shadow-sm p-ds-6">
<div class="form-header mb-ds-6">
<h2 class="ds-heading-2 ds-text mb-2">{{ texts["contact-send-message"] }}</h2>
<p class="ds-text-secondary">{{ texts["contact-contact-form-description"] }}</p>
</div>
<form class="space-y-ds-4"
hx-post="/api/contact"
hx-target="#contact-form-result"
hx-swap="innerHTML">
{# Name + Email row #}
<div class="grid grid-cols-1 md:grid-cols-2 gap-ds-4">
<div>
<label for="contact-form-name" class="ds-text font-medium block mb-2">
{{ texts["contact-name-label"] }}
</label>
<input id="contact-form-name" name="name" type="text" required
class="ds-input w-full p-3 ds-border ds-rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="{{ texts['contact-name-placeholder'] }}" />
</div>
<div>
<label for="contact-form-email" class="ds-text font-medium block mb-2">
{{ texts["contact-email-label"] }}
</label>
<input id="contact-form-email" name="email" type="email" required
class="ds-input w-full p-3 ds-border ds-rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="{{ texts['contact-email-placeholder'] }}" />
</div>
</div>
{# Subject #}
<div>
<label for="contact-form-subject" class="ds-text font-medium block mb-2">
{{ texts["contact-subject-label"] }}
</label>
<input id="contact-form-subject" name="subject" type="text" required
class="ds-input w-full p-3 ds-border ds-rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="{{ texts['contact-subject-placeholder'] }}" />
</div>
{# Message #}
<div>
<label for="contact-form-message" class="ds-text font-medium block mb-2">
{{ texts["contact-message-label"] }}
</label>
<textarea id="contact-form-message" name="message" rows="6" required
class="ds-textarea w-full p-3 ds-border ds-rounded focus:outline-none focus:ring-2 focus:ring-blue-500 resize-vertical"
placeholder="{{ texts['contact-message-placeholder'] }}"></textarea>
</div>
<div class="flex justify-end">
<button type="submit"
class="ds-btn-primary px-8 py-3 font-medium ds-rounded transition-colors duration-200">
{{ texts["contact-send-message-btn"] }}
</button>
</div>
<div id="contact-form-result"></div>
</form>
</div>
{# Response time box #}
<div class="mt-ds-6 p-ds-4 ds-bg-secondary ds-rounded-lg">
<h3 class="font-semibold ds-text mb-2">{{ texts["contact-response-time"] }}</h3>
<p class="ds-caption ds-text-secondary">{{ texts["contact-response-time-text"] }}</p>
</div>
</div>
</div>
{# ── FAQ section ── #}
<div class="mt-ds-8">
<div class="ds-bg ds-rounded-lg ds-shadow-sm p-ds-6">
<h2 class="ds-heading-2 ds-text mb-ds-6 text-center">{{ texts["contact-frequently-asked-questions"] }}</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-ds-6">
<div>
<h3 class="ds-heading-4 ds-text mb-2">{{ texts["contact-faq-what-projects"] }}</h3>
<p class="ds-text-secondary">{{ texts["contact-faq-what-projects-answer"] }}</p>
</div>
<div>
<h3 class="ds-heading-4 ds-text mb-2">{{ texts["contact-faq-remote-teams"] }}</h3>
<p class="ds-text-secondary">{{ texts["contact-faq-remote-teams-answer"] }}</p>
</div>
<div>
<h3 class="ds-heading-4 ds-text mb-2">{{ texts["contact-faq-pricing-approach"] }}</h3>
<p class="ds-text-secondary">{{ texts["contact-faq-pricing-answer"] }}</p>
</div>
<div>
<h3 class="ds-heading-4 ds-text mb-2">{{ texts["contact-faq-existing-projects"] }}</h3>
<p class="ds-text-secondary">{{ texts["contact-faq-existing-projects-answer"] }}</p>
</div>
</div>
</div>
</div>
{# ── Other ways to connect ── #}
<div class="mt-ds-8">
<div class="text-center">
<h2 class="ds-heading-2 ds-text mb-ds-6">{{ texts["contact-other-ways-connect"] }}</h2>
<div class="grid grid-cols-1 md:grid-cols-3 gap-ds-6">
<div class="ds-bg-secondary ds-rounded-lg p-ds-6">
<div class="ds-text-secondary mb-ds-4">
<span class="text-4xl">🚀</span>
</div>
<h3 class="ds-heading-4 ds-text mb-2">{{ texts["contact-project-consultation"] }}</h3>
<p class="ds-text-secondary mb-ds-4">{{ texts["contact-consultation-help"] }}</p>
<a href="{{ texts['contact-services-url'] | default(value='/services') }}#tier2"
class="no-underline ds-btn-primary">
{{ texts["contact-request-quote"] }}
</a>
</div>
<div class="ds-bg-secondary ds-rounded-lg p-ds-6">
<div class="ds-text-secondary mb-ds-4">
<span class="text-4xl">🧑‍🏫</span>
</div>
<h3 class="ds-heading-4 ds-text mb-2">{{ texts["contact-mentoring-training"] }}</h3>
<p class="ds-text-secondary mb-ds-4">{{ texts["contact-mentoring-help"] }}</p>
<a href="{{ texts['contact-services-url'] | default(value='/services') }}#learn"
class="ds-btn-primary">
{{ texts["contact-learn-more"] }}
</a>
</div>
<div class="ds-bg-secondary ds-rounded-lg p-ds-6">
<div class="ds-text-secondary mb-ds-4">
<span class="text-4xl">🎤</span>
</div>
<h3 class="ds-heading-4 ds-text mb-2">{{ texts["contact-speaking-events"] }}</h3>
<p class="ds-text-secondary mb-ds-4">{{ texts["contact-speaking-help"] }}</p>
<a href="mailto:hello@jesusperez.pro?subject=Speaking Opportunity"
class="ds-btn-primary">
{{ texts["contact-get-in-touch"] }}
</a>
</div>
</div>
</div>
</div>
</section>
</div>
{% include "partials/image-zoom.j2" %}

View file

@ -0,0 +1,220 @@
<div class="ds-bg-page">
{# TEMP: hide the Posts/Recipes door CTAs for now (remove this <style> to restore) #}
<style>.tagline-panel-graph{display:none}</style>
{# Hero — three-door vestibule, projected from .ontoref/positioning/spine.ncl (ADR-057).
Never an exposition of the core: the doors route, the prize is revealed once inside.
Tech features live below the fold (Three Pillars / How it works). #}
<section class="relative py-ds-8 ds-container">
<div class="mx-auto max-w-4xl text-center">
<span class="inline-block px-3 py-1 ds-caption ds-bg border ds-border ds-rounded-md ds-text-secondary mb-4 font-mono">
{{ texts["home-hero-badge"] }}
</span>
{# A · hero — the language-specific banner carries logo + headline + sub-hero + the
four value nodes (baked in) and scales fluidly at every width, so it shows at all
breakpoints. The h1/sub stay sr-only for SEO + a11y; the <picture> is click-to-zoom. #}
<h1 class="sr-only">{{ texts["spine-hero"] }}</h1>
<p class="sr-only">{{ texts["spine-sub"] }}</p>
{% set hero_img = "ontoref-home-seguro" if lang == "es" else "ontoref-home-sure-footed" %}
<picture>
<source srcset="/images/{{ hero_img }}.avif" type="image/avif">
<source srcset="/images/{{ hero_img }}.webp" type="image/webp">
<img src="/images/{{ hero_img }}.webp"
alt="{{ texts['spine-hero'] }}"
width="1220" height="417" loading="eager"
class="block mx-auto rounded-lg w-full max-w-4xl h-auto mb-6" />
</picture>
{# rotating definition-phrases by audience + focus #}
<p class="ds-body-lg ds-text mb-1" style="min-height: 1.6em">
<span
data-tagline-rotator
data-audience="all"
data-src="/public/r/taglines.json"
data-focus-target=".tagline-focus"
>{{ texts["spine-hero"] }}</span
>
</p>
<span class="tagline-focus" aria-hidden="true"></span>
{# C · four doors — primary nav: each switches taglines AND reveals its panel #}
<div class="tagline-doors" role="group" aria-label="{{ texts['spine-doors-heading'] }}">
<button type="button" class="tagline-door is-active" data-tagline-audience="all" data-door="all">{{ texts["home-tagline-door-all"] }}</button>
<button type="button" class="tagline-door" data-tagline-audience="arriving-developer" data-door="developer">{{ texts["spine-door-developer-label"] }}</button>
<button type="button" class="tagline-door" data-tagline-audience="infrastructure-inheritor" data-door="infrastructure">{{ texts["spine-door-infrastructure-label"] }}</button>
<button type="button" class="tagline-door" data-tagline-audience="personal-ontology" data-door="personal">{{ texts["spine-door-personal-label"] }}</button>
<button type="button" class="tagline-door" data-tagline-audience="authoring-knowledge" data-door="authoring">{{ texts["spine-door-authoring-label"] }}</button>
</div>
{# door panels — hidden until a door is clicked; 'all' hides all. A teaser that
ROUTES to the door landing (full value-prop) + the live graph, never argues. #}
<div class="tagline-panels max-w-2xl mx-auto">
<div class="tagline-panel" data-tagline-panel="developer" hidden>
<p class="tagline-panel-line">{{ texts["spine-door-developer-line"] }}</p>
<div class="tagline-panel-cta">
<a class="no-underline ds-btn-secondary" href="{{ texts['spine-door-developer-href'] }}">{{ texts["spine-door-cta"] }}</a>
<a class="no-underline tagline-panel-graph" href="{{ texts['spine-door-developer-blog'] }}">{{ texts["spine-door-blog-cta"] }}</a>
<a class="no-underline tagline-panel-graph" href="{{ texts['spine-door-developer-recipes'] }}">{{ texts["spine-door-recipes-cta"] }}</a>
</div>
</div>
<div class="tagline-panel" data-tagline-panel="infrastructure" hidden>
<p class="tagline-panel-line">{{ texts["spine-door-infrastructure-line"] }}</p>
<div class="tagline-panel-cta">
<a class="no-underline ds-btn-secondary" href="{{ texts['spine-door-infrastructure-href'] }}">{{ texts["spine-door-cta"] }}</a>
<a class="no-underline tagline-panel-graph" href="{{ texts['spine-door-infrastructure-blog'] }}">{{ texts["spine-door-blog-cta"] }}</a>
<a class="no-underline tagline-panel-graph" href="{{ texts['spine-door-infrastructure-recipes'] }}">{{ texts["spine-door-recipes-cta"] }}</a>
</div>
</div>
<div class="tagline-panel" data-tagline-panel="personal" hidden>
<p class="tagline-panel-line">{{ texts["spine-door-personal-line"] }}</p>
<div class="tagline-panel-cta">
<a class="no-underline ds-btn-secondary" href="{{ texts['spine-door-personal-href'] }}">{{ texts["spine-door-cta"] }}</a>
<a class="no-underline tagline-panel-graph" href="{{ texts['spine-door-personal-blog'] }}">{{ texts["spine-door-blog-cta"] }}</a>
<a class="no-underline tagline-panel-graph" href="{{ texts['spine-door-personal-recipes'] }}">{{ texts["spine-door-recipes-cta"] }}</a>
</div>
</div>
<div class="tagline-panel" data-tagline-panel="authoring" hidden>
<p class="tagline-panel-line">{{ texts["spine-door-authoring-line"] }}</p>
<div class="tagline-panel-cta">
<a class="no-underline ds-btn-secondary" href="{{ texts['spine-door-authoring-href'] }}">{{ texts["spine-door-cta"] }}</a>
<a class="no-underline tagline-panel-graph" href="{{ texts['spine-door-authoring-blog'] }}">{{ texts["spine-door-blog-cta"] }}</a>
<a class="no-underline tagline-panel-graph" href="{{ texts['spine-door-authoring-recipes'] }}">{{ texts["spine-door-recipes-cta"] }}</a>
</div>
</div>
</div>
{# D · prize — the reward once inside #}
<p class="ds-caption font-mono mt-8 mb-6 max-w-2xl mx-auto" style="color: #f97316">
{{ texts["spine-prize"] }}
</p>
<div class="flex flex-col sm:flex-row gap-4 justify-center">
<a href="/blog" class="no-underline ds-btn-primary">
{{ texts["home-cta-explore"] }}
</a>
<a href="https://github.com/stratum-iops/ontoref" target="_blank" rel="noopener noreferrer"
class="no-underline ds-btn-secondary inline-flex items-center gap-2">
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 0C4.477 0 0 4.484 0 10.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0 1 10 4.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.203 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.942.359.31.678.921.678 1.856 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0 0 20 10.017C20 4.484 15.522 0 10 0z" clip-rule="evenodd"/>
</svg>
{{ texts["home-cta-github"] }}
</a>
</div>
{# E · close — bookend with the hero #}
<p class="ds-caption ds-text-secondary mt-8 italic">{{ texts["spine-close"] }}</p>
</div>
</section>
{# Three Pillars #}
<section class="py-ds-8 ds-bg ds-rounded-lg">
<div class="ds-container-lg ds-container">
<div class="text-center mb-ds-8">
<h2 class="text-3xl font-bold ds-text">{{ texts["home-pillars-title"] }}</h2>
<p class="mt-3 ds-text-secondary ds-body">{{ texts["home-pillars-subtitle"] }}</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<div class="ds-bg-page p-ds-6 ds-rounded-lg ds-shadow-sm border ds-border">
<div class="mb-4">
<span class="ds-caption font-mono px-2 py-1 ds-bg border ds-border ds-rounded ds-text-secondary">
{{ texts["home-pillar-impact-label"] }}
</span>
</div>
<h3 class="ds-heading-4 ds-text mb-3">{{ texts["home-pillar-impact-title"] }}</h3>
<p class="ds-text-secondary ds-body-sm">{{ texts["home-pillar-impact-desc"] }}</p>
</div>
<div class="ds-bg-page p-ds-6 ds-rounded-lg ds-shadow-sm border ds-border">
<div class="mb-4">
<span class="ds-caption font-mono px-2 py-1 ds-bg border ds-border ds-rounded ds-text-secondary">
{{ texts["home-pillar-witness-label"] }}
</span>
</div>
<h3 class="ds-heading-4 ds-text mb-3">{{ texts["home-pillar-witness-title"] }}</h3>
<p class="ds-text-secondary ds-body-sm">{{ texts["home-pillar-witness-desc"] }}</p>
</div>
<div class="ds-bg-page p-ds-6 ds-rounded-lg ds-shadow-sm border ds-border">
<div class="mb-4">
<span class="ds-caption font-mono px-2 py-1 ds-bg border ds-border ds-rounded ds-text-secondary">
{{ texts["home-pillar-shared-label"] }}
</span>
</div>
<h3 class="ds-heading-4 ds-text mb-3">{{ texts["home-pillar-shared-title"] }}</h3>
<p class="ds-text-secondary ds-body-sm">{{ texts["home-pillar-shared-desc"] }}</p>
</div>
</div>
</div>
</section>
{# Architecture image — config-driven & optional: shown only when the consumer's
home FTL declares `home-arch-img-light` (per-site content, never hardcoded). #}
{% if texts["home-arch-img-light"] %}
<section class="py-ds-8 ds-container">
<div class="mx-auto max-w-4xl">
<h2 class="text-2xl font-bold ds-text mb-6 text-center">{{ texts["home-arch-title"] }}</h2>
{# The SVG carries its own dark surface + rounded corners — no wrapper card, or
its background double-frames and clashes. Shown in both themes; wrapped in
<picture> so the global lightbox makes it click-to-zoom. #}
<div class="text-center">
<picture>
<img src="{{ texts['home-arch-img-light'] }}"
alt="{{ texts['home-arch-title'] }}"
class="block mx-auto w-full h-auto rounded-lg" />
</picture>
<p class="ds-caption ds-text-secondary mt-3">{{ texts["home-arch-caption"] }}</p>
</div>
</div>
</section>
{% endif %}
{# How it works #}
<section class="py-ds-8 ds-bg ds-rounded-lg">
<div class="ds-container-lg ds-container">
<div class="text-center mb-ds-8">
<h2 class="text-3xl font-bold ds-text">{{ texts["home-how-title"] }}</h2>
<p class="mt-3 ds-text-secondary ds-body">{{ texts["home-how-subtitle"] }}</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<div class="ds-bg-page p-ds-6 ds-rounded-lg ds-shadow-sm">
<div class="w-8 h-8 ds-rounded-full flex items-center justify-center font-bold ds-text mb-4"
style="background: #3b82f620; color: #60a5fa; border: 1px solid #3b82f640">1</div>
<h3 class="font-mono ds-text font-semibold mb-2">{{ texts["home-how-step1-title"] }}</h3>
<p class="ds-text-secondary ds-body-sm">{{ texts["home-how-step1-desc"] }}</p>
</div>
<div class="ds-bg-page p-ds-6 ds-rounded-lg ds-shadow-sm">
<div class="w-8 h-8 ds-rounded-full flex items-center justify-center font-bold ds-text mb-4"
style="background: #3b82f620; color: #60a5fa; border: 1px solid #3b82f640">2</div>
<h3 class="font-mono ds-text font-semibold mb-2">{{ texts["home-how-step2-title"] }}</h3>
<p class="ds-text-secondary ds-body-sm">{{ texts["home-how-step2-desc"] }}</p>
</div>
<div class="ds-bg-page p-ds-6 ds-rounded-lg ds-shadow-sm">
<div class="w-8 h-8 ds-rounded-full flex items-center justify-center font-bold ds-text mb-4"
style="background: #3b82f620; color: #60a5fa; border: 1px solid #3b82f640">3</div>
<h3 class="font-mono ds-text font-semibold mb-2">{{ texts["home-how-step3-title"] }}</h3>
<p class="ds-text-secondary ds-body-sm">{{ texts["home-how-step3-desc"] }}</p>
</div>
</div>
</div>
</section>
{# CTA #}
<section class="py-ds-10 ds-container">
<div class="mx-auto max-w-3xl text-center">
<h2 class="text-3xl font-bold ds-text mb-4">{{ texts["home-cta-title"] }}</h2>
<p class="ds-body ds-text-secondary mb-8">{{ texts["home-cta-desc"] }}</p>
<div class="flex flex-col sm:flex-row gap-4 justify-center">
<a href="/blog" class="no-underline ds-btn-primary">
{{ texts["home-cta-primary"] }}
</a>
<a href="/blog" class="no-underline ds-btn-secondary">
{{ texts["home-cta-secondary"] }}
</a>
</div>
</div>
</section>
{# Hero tagline rotator — typed.js (typewriter) + native fade fallback #}
<script src="/js/typed.umd.js"></script>
<script src="/js/tagline-rotator.js"></script>
</div>

View file

@ -0,0 +1,39 @@
{# Content post viewer — website override of the framework post.j2.
Differences from the framework default:
- No max-w-3xl / mx-auto on article (column width already constrained by grid).
- post-content-body on the prose div so the lead-image CSS rule applies.
- overflow-hidden on article to prevent images from bleeding into the sidebar. #}
<article class="w-full overflow-hidden px-4 py-8" itemscope itemtype="https://schema.org/Article">
{% include "partials/content_header.j2" %}
{% if item.excerpt %}
<div class="text-lg ds-text-muted leading-relaxed mb-8 border-l-4 border-current pl-4 italic">
{{ item.excerpt }}
</div>
{% endif %}
<div class="prose prose-neutral dark:prose-invert max-w-none ds-text leading-relaxed post-content-body" itemprop="articleBody">
{{ body_html | safe }}
</div>
{% if item.translations and item.translations | length > 1 %}
<footer class="mt-12 pt-6 border-t border-gray-200 dark:border-gray-700">
<p class="text-sm ds-text-muted">
{% if lang == "es" %}También disponible en:{% else %}Also available in:{% endif %}
{% for tlang in item.translations %}
{% if tlang != lang %}
{% set tslug = item.translation_slugs[tlang] %}
<a href="/{{ tlang }}/{{ item.content_type }}/{{ tslug }}"
class="underline hover:ds-text"
hx-get="/{{ tlang }}/{{ item.content_type }}/{{ tslug }}"
hx-target="#main-content"
hx-swap="innerHTML"
hx-push-url="true">{{ tlang | upper }}</a>
{% endif %}
{% endfor %}
</p>
</footer>
{% endif %}
</article>
{% include "partials/image-zoom.j2" %}

View file

@ -0,0 +1,211 @@
{# ontoref Services — tier-structured.
The protocol is a gift (voluntary-adoption, protocol-not-runtime); what is
offered is help OPERATING its highest tier (tier-2: operations, witness,
governance — via-001) and TRAINING/COACHING to climb the lower tiers (tier-0
declarative, tier-1 substrate). Bilingual via `texts[...]`; ES deep-link
anchors via data-anchor-es. Mirrors the jpl services design (ds-* classes). #}
<div class="min-h-screen ds-bg-page">
<section class="py-8 ds-container">
<div class="mx-auto max-w-4xl text-center">
<h1 class="text-balance text-4xl font-bold tracking-tight ds-text sm:text-6xl">
{{ texts["services-page-title"] }}
</h1>
<p class="mt-ds-6 ds-body ds-text-secondary max-w-2xl mx-auto">
{{ texts["services-page-subtitle"] }}
</p>
<p class="mt-3 ds-caption font-mono ds-text-secondary max-w-2xl mx-auto">
{{ texts["services-page-note"] }}
</p>
</div>
</section>
{# ── Tier 2: the proposal — operate the operations/governance surface ─────── #}
<section id="tier2" data-anchor-es="tier2" class="py-16 ds-bg">
<div class="ds-container-lg ds-container">
<div class="text-center mb-ds-12">
<span class="ds-caption font-mono px-2 py-1 ds-bg-page border ds-border ds-rounded ds-text-secondary">
{{ texts["services-tier2-eyebrow"] }}
</span>
<h2 class="mt-4 text-3xl font-bold ds-text">{{ texts["services-tier2-title"] }}</h2>
<p class="mt-4 ds-text-secondary max-w-3xl mx-auto">
{{ texts["services-tier2-desc"] }}
</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8">
<div class="ds-bg-page p-ds-6 ds-rounded-lg ds-shadow-sm">
<h3 class="ds-heading-4 ds-text mb-3">{{ texts["services-tier2-ops-title"] }}</h3>
<p class="ds-text-secondary">{{ texts["services-tier2-ops-desc"] }}</p>
</div>
<div class="ds-bg-page p-ds-6 ds-rounded-lg ds-shadow-sm">
<h3 class="ds-heading-4 ds-text mb-3">{{ texts["services-tier2-audit-title"] }}</h3>
<p class="ds-text-secondary">{{ texts["services-tier2-audit-desc"] }}</p>
</div>
<div class="ds-bg-page p-ds-6 ds-rounded-lg ds-shadow-sm">
<h3 class="ds-heading-4 ds-text mb-3">{{ texts["services-tier2-validators-title"] }}</h3>
<p class="ds-text-secondary">{{ texts["services-tier2-validators-desc"] }}</p>
</div>
<div class="ds-bg-page p-ds-6 ds-rounded-lg ds-shadow-sm">
<h3 class="ds-heading-4 ds-text mb-3">{{ texts["services-tier2-migration-title"] }}</h3>
<p class="ds-text-secondary">{{ texts["services-tier2-migration-desc"] }}</p>
</div>
</div>
<div class="mt-16">
<h3 class="text-2xl font-bold ds-text text-center mb-8">{{ texts["services-what-you-get"] }}</h3>
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 max-w-4xl mx-auto">
<div class="flex items-start gap-3"><span class="ds-text-secondary">{{ texts["services-get-witnessed-history"] }}</span></div>
<div class="flex items-start gap-3"><span class="ds-text-secondary">{{ texts["services-get-validators"] }}</span></div>
<div class="flex items-start gap-3"><span class="ds-text-secondary">{{ texts["services-get-knowledge-transfer"] }}</span></div>
<div class="flex items-start gap-3"><span class="ds-text-secondary">{{ texts["services-get-no-lockin"] }}</span></div>
<div class="flex items-start gap-3 md:col-span-2"><span class="ds-text-secondary">{{ texts["services-get-open-protocol"] }}</span></div>
</div>
</div>
</div>
</section>
{# ── Training & coaching for the lower tiers — use or learn ───────────────── #}
<section id="learn" data-anchor-es="aprender" class="py-16 ds-bg-page">
<div class="ds-container-lg ds-container">
<div class="text-center mb-ds-12">
{% set training_img = "ontoref-formacion" if lang == "es" else "ontoref-training" %}
<picture>
<source srcset="/images/{{ training_img }}.avif" type="image/avif">
<source srcset="/images/{{ training_img }}.webp" type="image/webp">
<img src="/images/{{ training_img }}.webp"
alt="{{ texts['services-learn-title'] }}"
width="1536" height="512" loading="lazy"
class="mb-ds-8 mx-auto rounded-lg ds-shadow-sm w-full max-w-3xl h-auto" />
</picture>
<h2 class="text-3xl font-bold ds-text">{{ texts["services-learn-title"] }}</h2>
<p class="mt-4 ds-text-secondary max-w-3xl mx-auto">{{ texts["services-learn-desc"] }}</p>
{% set coaching_img = "ontoref-coaching-es" if lang == "es" else "ontoref-coaching-en" %}
<picture>
<source srcset="/images/{{ coaching_img }}.avif" type="image/avif">
<source srcset="/images/{{ coaching_img }}.webp" type="image/webp">
<img src="/images/{{ coaching_img }}.webp"
alt="{{ texts['services-learn-title'] }}"
width="1536" height="1024" loading="lazy"
class="mt-ds-8 mx-auto rounded-lg ds-shadow-sm w-full max-w-3xl h-auto" />
</picture>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-12">
<div id="coaching" data-anchor-es="coaching">
<span class="ds-caption font-mono ds-text-secondary">{{ texts["services-coaching-eyebrow"] }}</span>
<h3 class="text-2xl font-bold ds-text mt-2 mb-6">{{ texts["services-coaching-title"] }}</h3>
<p class="ds-text-secondary mb-8">{{ texts["services-coaching-desc"] }}</p>
<div class="space-y-3">
<div class="ds-bg p-ds-4 ds-rounded-lg">
<div class="flex justify-between items-center mb-2">
<h4 class="font-semibold ds-text">{{ texts["services-coaching-1to1-title"] }}</h4>
<span class="text-sm ds-text-secondary">{{ texts["services-coaching-1to1-note"] }}</span>
</div>
<p class="text-sm ds-text-secondary">{{ texts["services-coaching-1to1-desc"] }}</p>
</div>
<div class="ds-bg p-ds-4 ds-rounded-lg">
<div class="flex justify-between items-center mb-2">
<h4 class="font-semibold ds-text">{{ texts["services-coaching-ondaod-title"] }}</h4>
<span class="text-sm ds-text-secondary">{{ texts["services-coaching-ondaod-note"] }}</span>
</div>
<p class="text-sm ds-text-secondary">{{ texts["services-coaching-ondaod-desc"] }}</p>
</div>
<div class="ds-bg p-ds-4 ds-rounded-lg">
<div class="flex justify-between items-center mb-2">
<h4 class="font-semibold ds-text">{{ texts["services-coaching-adr-title"] }}</h4>
<span class="text-sm ds-text-secondary">{{ texts["services-coaching-adr-note"] }}</span>
</div>
<p class="text-sm ds-text-secondary">{{ texts["services-coaching-adr-desc"] }}</p>
</div>
<div class="ds-bg p-ds-4 ds-rounded-lg">
<div class="flex justify-between items-center mb-2">
<h4 class="font-semibold ds-text">{{ texts["services-coaching-ongoing-title"] }}</h4>
<span class="text-sm ds-text-secondary">{{ texts["services-coaching-ongoing-note"] }}</span>
</div>
<p class="text-sm ds-text-secondary">{{ texts["services-coaching-ongoing-desc"] }}</p>
</div>
</div>
</div>
<div id="training" data-anchor-es="formacion">
<span class="ds-caption font-mono ds-text-secondary">{{ texts["services-training-eyebrow"] }}</span>
<h3 class="text-2xl font-bold ds-text mt-2 mb-6">{{ texts["services-training-title"] }}</h3>
<p class="ds-text-secondary mb-8">{{ texts["services-training-desc"] }}</p>
<div class="space-y-3">
<div class="ds-bg p-ds-4 ds-rounded-lg">
<h4 class="font-semibold ds-text mb-2">{{ texts["services-training-courses-title"] }}</h4>
<p class="text-sm ds-text-secondary">{{ texts["services-training-courses-desc"] }}</p>
</div>
<div class="ds-bg p-ds-4 ds-rounded-lg">
<h4 class="font-semibold ds-text mb-2">{{ texts["services-training-workshops-title"] }}</h4>
<p class="text-sm ds-text-secondary">{{ texts["services-training-workshops-desc"] }}</p>
</div>
<div class="ds-bg p-ds-4 ds-rounded-lg">
<h4 class="font-semibold ds-text mb-2">{{ texts["services-training-talks-title"] }}</h4>
<p class="text-sm ds-text-secondary">{{ texts["services-training-talks-desc"] }}</p>
</div>
<div class="ds-bg p-ds-4 ds-rounded-lg">
<h4 class="font-semibold ds-text mb-2">{{ texts["services-training-content-title"] }}</h4>
<p class="text-sm ds-text-secondary">{{ texts["services-training-content-desc"] }}</p>
</div>
</div>
</div>
</div>
</div>
</section>
{# ── The three tiers — which one fits (taglines from tier-taglines.ncl) ───── #}
<section class="py-16 ds-bg">
<div class="ds-container-lg ds-container">
<div class="text-center mb-ds-12">
<h2 class="text-3xl font-bold ds-text">{{ texts["services-tiers-title"] }}</h2>
<p class="mt-4 ds-text-secondary max-w-3xl mx-auto">{{ texts["services-tiers-desc"] }}</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-8">
<div class="ds-bg-page p-ds-6 ds-rounded-lg ds-shadow-sm">
<span class="ds-caption font-mono ds-text-secondary">{{ texts["services-tier0-label"] }}</span>
<h3 class="ds-heading-4 ds-text mt-2 mb-2">{{ texts["services-tier0-title"] }}</h3>
<p class="ds-text-secondary text-sm">{{ texts["services-tier0-desc"] }}</p>
</div>
<div class="ds-bg-page p-ds-6 ds-rounded-lg ds-shadow-sm">
<span class="ds-caption font-mono ds-text-secondary">{{ texts["services-tier1-label"] }}</span>
<h3 class="ds-heading-4 ds-text mt-2 mb-2">{{ texts["services-tier1-title"] }}</h3>
<p class="ds-text-secondary text-sm">{{ texts["services-tier1-desc"] }}</p>
</div>
<div class="ds-bg-page p-ds-6 ds-rounded-lg ds-shadow-sm border ds-border">
<span class="ds-caption font-mono ds-text-secondary">{{ texts["services-tier2-label"] }}</span>
<h3 class="ds-heading-4 ds-text mt-2 mb-2">{{ texts["services-tier2-card-title"] }}</h3>
<p class="ds-text-secondary text-sm">{{ texts["services-tier2-card-desc"] }}</p>
</div>
</div>
</div>
</section>
<section class="py-16 ds-bg-page">
<div class="mx-auto max-w-4xl ds-container text-center">
{% set action_img = "ontoref-accion" if lang == "es" else "ontoref-action" %}
<picture>
<source srcset="/images/{{ action_img }}.avif" type="image/avif">
<source srcset="/images/{{ action_img }}.webp" type="image/webp">
<img src="/images/{{ action_img }}.webp"
alt="{{ texts['services-cta-title'] }}"
width="1536" height="512" loading="lazy"
class="mb-ds-8 mx-auto rounded-lg ds-shadow-sm w-full max-w-3xl h-auto" />
</picture>
<h2 class="text-3xl font-bold ds-text mb-ds-4">{{ texts["services-cta-title"] }}</h2>
<p class="ds-text-secondary mb-8 ds-body">{{ texts["services-cta-desc"] }}</p>
<div class="flex flex-col sm:flex-row gap-4 justify-center">
<a href="{{ texts['services-cta-primary-url'] | default('/contact') }}" class="no-underline ds-btn-primary">
{{ texts["services-cta-primary"] }}
</a>
<a href="{{ texts['services-cta-secondary-url'] | default('/about') }}" class="no-underline ds-btn-secondary">
{{ texts["services-cta-secondary"] }}
</a>
</div>
</div>
</section>
</div>
{% include "partials/image-zoom.j2" %}

View file

@ -0,0 +1,31 @@
<article class="ds-page ds-page-{{ page_id }}" data-page-id="{{ page_id }}">
<header class="ds-page-header">
<div class="ds-container">
<h1 class="ds-heading-xl">{{ texts[page_id ~ "-page-title"] | default(page_id) }}</h1>
{% if texts[page_id ~ "-page-subtitle"] %}
<p class="ds-text-lead">{{ texts[page_id ~ "-page-subtitle"] | safe }}</p>
{% endif %}
</div>
</header>
<div class="ds-page-body">
<div class="ds-container">
{# Each page's content is driven by its FTL translations. #}
{# Common structure: description paragraph + sections keyed by page_id prefix. #}
{% if texts[page_id ~ "-page-description"] %}
<p class="ds-text-lead ds-text-muted">{{ texts[page_id ~ "-page-description"] }}</p>
{% endif %}
{# Product pages: show a minimal overview with a "learn more" link pattern. #}
{% set products = ["syntaxis", "vapora", "rustelo", "provisioning", "stratumiops", "kogral", "typedialog", "ontoref", "secretumvault"] %}
{% if page_id in products %}
{% include "partials/product_page.j2" %}
{% endif %}
{# Auth pages: show the login form fragment inline. #}
{% if page_id == "login" %}
{% include "partials/login_form.j2" %}
{% endif %}
</div>
</div>
</article>

View file

@ -0,0 +1,113 @@
{# Global in-page lightbox. Click to zoom, full-screen modal overlay.
Handles two media kinds via event delegation:
• <picture> images → shown as <img> (browser-picked AVIF/WebP, currentSrc)
• .content-graph-mini-preview → its inline ego-network <svg>, cloned & enlarged
Boost-proof: htmx navigation can drop the overlay node, so styles + overlay are
recreated ON DEMAND (looked up by id, rebuilt if missing) rather than cached in
closure refs. Listeners bind to `document` exactly once (document survives htmx
swaps), guarded by a window flag so re-running after a swap never double-binds. #}
<script>
(function () {
var STYLE_ID = 'oz-style';
var OVERLAY_ID = 'oz-overlay';
function ensureStyle() {
if (document.getElementById(STYLE_ID)) return;
var s = document.createElement('style');
s.id = STYLE_ID;
s.textContent = ''
+ '.oz-overlay{position:fixed;inset:0;z-index:2147483000;display:none;'
+ 'align-items:center;justify-content:center;background:rgba(8,11,16,.92);'
+ 'padding:2rem;cursor:zoom-out;opacity:0;transition:opacity .15s ease}'
+ '.oz-overlay.oz-open{display:flex;opacity:1}'
+ '.oz-overlay img.oz-media{max-width:100%;max-height:100%;width:auto;height:auto;'
+ 'border-radius:.5rem;box-shadow:0 10px 40px rgba(0,0,0,.5);cursor:default}'
+ '.oz-overlay svg.oz-media{width:min(90vw,90vh);height:min(90vw,90vh);'
+ 'max-width:100%;max-height:100%;cursor:default}'
+ '.oz-close{position:absolute;top:1rem;right:1.25rem;font-size:2.25rem;'
+ 'line-height:1;color:#fff;background:none;border:0;cursor:pointer;opacity:.85}'
+ '.oz-close:hover{opacity:1}'
+ 'picture img{cursor:zoom-in}';
(document.head || document.documentElement).appendChild(s);
}
function ensureOverlay() {
var o = document.getElementById(OVERLAY_ID);
if (o) return o;
o = document.createElement('div');
o.id = OVERLAY_ID;
o.className = 'oz-overlay';
o.setAttribute('role', 'dialog');
o.setAttribute('aria-modal', 'true');
o.innerHTML = '<button class="oz-close" type="button" aria-label="Close">×</button>';
document.body.appendChild(o);
return o;
}
function setMedia(node) {
ensureStyle();
var o = ensureOverlay();
var old = o.querySelector('.oz-media');
if (old) old.remove();
node.classList.add('oz-media');
o.appendChild(node);
o.classList.add('oz-open');
document.body.style.overflow = 'hidden';
}
function openImg(src, alt) {
var im = new Image();
im.src = src;
im.alt = alt || '';
setMedia(im);
}
function openGraph(svgEl) {
var c = svgEl.cloneNode(true);
// Overlay is always dark — force the on-dark palette so labels/edges stay
// legible even when the page itself is in light mode.
c.style.setProperty('--cg-label', '#d1d5db');
c.style.setProperty('--cg-edge', '#4b5563');
c.style.setProperty('--cg-node', '#9ca3af');
setMedia(c);
}
function closeImg() {
var o = document.getElementById(OVERLAY_ID);
if (o) o.classList.remove('oz-open');
document.body.style.overflow = '';
}
if (!window.__ozBound) {
window.__ozBound = true;
document.addEventListener('click', function (e) {
var t = e.target;
if (!t || !t.closest) return;
var img = t.closest('picture img');
if (img) {
e.preventDefault();
openImg(img.currentSrc || img.src, img.alt);
return;
}
var mini = t.closest('.content-graph-mini-preview');
if (mini) {
var svg = mini.querySelector('svg');
if (svg) {
e.preventDefault();
openGraph(svg);
return;
}
}
var ov = t.closest('#' + OVERLAY_ID);
if (ov && (t.id === OVERLAY_ID || t.closest('.oz-close'))) closeImg();
});
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape' || e.key === 'Esc') closeImg();
});
}
})();
</script>

View file

@ -0,0 +1,38 @@
<div class="ds-auth-form">
<form hx-post="/api/auth/login"
hx-target="#login-result"
hx-swap="innerHTML"
class="ds-form">
<div class="ds-form-group">
<label for="login-email" class="ds-label">{{ texts["auth-email-address"] }}</label>
<input id="login-email"
name="email"
type="email"
class="ds-input"
placeholder="{{ texts['auth-enter-email'] }}"
required
autocomplete="email" />
</div>
<div class="ds-form-group">
<label for="login-password" class="ds-label">{{ texts["auth-password"] }}</label>
<input id="login-password"
name="password"
type="password"
class="ds-input"
placeholder="{{ texts['auth-enter-password'] }}"
required
autocomplete="current-password" />
</div>
<div class="ds-form-row ds-justify-between ds-align-center">
<label class="ds-checkbox-label">
<input type="checkbox" name="remember_me" class="ds-checkbox" />
{{ texts["auth-remember-me"] }}
</label>
<a href="#" class="ds-link ds-text-sm">{{ texts["auth-forgot-password"] }}</a>
</div>
<button type="submit" class="ds-button ds-button-primary ds-w-full">
{{ texts["auth-sign-in"] }}
</button>
<div id="login-result"></div>
</form>
</div>

View file

@ -0,0 +1,20 @@
<button type="button"
class="ds-theme-toggle"
hx-post="/api/htmx/theme/toggle"
hx-swap="outerHTML"
hx-target="this"
hx-ext="multi-swap"
data-theme="{{ theme }}"
aria-label="{{ label }}">
{% if theme == "dark" %}
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"></path>
</svg>
{% else %}
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"></path>
</svg>
{% endif %}
</button>

View file

@ -0,0 +1,30 @@
<div class="ds-product-page">
{% if texts[page_id ~ "-badge"] %}
<span class="ds-badge">{{ texts[page_id ~ "-badge"] }}</span>
{% endif %}
{% if texts[page_id ~ "-tagline"] %}
<p class="ds-tagline">{{ texts[page_id ~ "-tagline"] }}</p>
{% endif %}
{% if texts[page_id ~ "-hero-title"] %}
<h2 class="ds-heading-lg">{{ texts[page_id ~ "-hero-title"] }}</h2>
{% endif %}
{% if texts[page_id ~ "-hero-subtitle"] %}
<p class="ds-text-lead">{{ texts[page_id ~ "-hero-subtitle"] | safe }}</p>
{% endif %}
<div class="ds-product-actions">
{% if texts[page_id ~ "-cta-github"] %}
<a href="#" class="ds-button ds-button-outline ds-button-sm">
{{ texts[page_id ~ "-cta-github"] }}
</a>
{% endif %}
{% if texts[page_id ~ "-cta-get-started"] %}
<a href="/contact" class="ds-button ds-button-primary ds-button-sm">
{{ texts[page_id ~ "-cta-get-started"] }}
</a>
{% endif %}
</div>
</div>

View file

@ -0,0 +1,72 @@
<footer class="ds-bg-surface border-t border-gray-200 mt-auto">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div class="grid grid-cols-1 md:grid-cols-3 gap-8">
<div class="col-span-1">
{% if logo.show_in_footer %}
{% if logo.has_dark_variant %}
<a href="/" class="inline-block mb-4">
<img src="{{ logo.light_src }}" alt="{{ logo.alt }}" class="h-8 w-auto dark:hidden">
<img src="{{ logo.dark_src }}" alt="{{ logo.alt }}" class="h-8 w-auto hidden dark:block">
</a>
{% else %}
<a href="/" class="inline-block mb-4">
<img src="{{ logo.light_src }}" alt="{{ logo.alt }}" class="h-8 w-auto">
</a>
{% endif %}
{% endif %}
<p class="ds-text-secondary text-sm mb-4">{{ company_desc }}</p>
<p class="text-sm ds-text-secondary">{{ copyright_text }}</p>
</div>
{% for section in sections %}
<div>
<h4 class="font-semibold ds-text mb-3">{{ section.title }}</h4>
<ul class="space-y-2 text-sm">
{% for link in section.links %}
<li>
<a href="{{ link.href }}"
class="ds-text-secondary hover:ds-text transition-colors no-underline"
{% if link.is_external %}target="_blank" rel="noopener noreferrer"{% endif %}>
{{ link.label }}
</a>
</li>
{% endfor %}
</ul>
</div>
{% endfor %}
{% if social_links %}
<div>
<h4 class="font-semibold ds-text mb-3">{{ social_title }}</h4>
<div class="flex flex-col space-y-3">
{% for social in social_links %}
<a href="{{ social.url }}"
target="_blank"
rel="noopener noreferrer"
class="ds-text-secondary hover:ds-text transition-colors"
aria-label="{{ social.name }}">{{ social.icon_html | safe }}</a>
{% endfor %}
</div>
</div>
{% endif %}
</div>
<div class="border-t border-base-300 mt-8 pt-5 flex justify-center items-center gap-2 text-sm ds-text-secondary">
<span>{{ built_label }}</span>
<a href="/rustelo" class="inline-flex items-center gap-1.5 hover:opacity-80 transition-opacity no-underline">
<img src="/images/logos/rustelo-img.svg" alt="Rustelo" class="h-5 w-auto">
<span class="text-gray-700 dark:text-gray-500">Rustelo</span>
</a>
</div>
</div>
</footer>
<button class="fixed bottom-4 right-4 w-10 h-10 bg-gray-800 dark:bg-gray-600 text-white rounded-full shadow-lg hover:shadow-xl hover:bg-gray-700 dark:hover:bg-gray-500 transition-all duration-300 flex items-center justify-center z-50"
onclick="window.scrollTo(0,0)"
aria-label="Scroll to top">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 10l7-7m0 0l7 7m-7-7v18"></path>
</svg>
</button>

View file

@ -0,0 +1,76 @@
<nav class="border-b border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 shadow-sm">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between h-16">
<div class="flex items-center">
{% if logo.show_in_nav %}
{% if logo.has_dark_variant %}
<a href="/" class="flex-shrink-0 mr-4">
<img src="{{ logo.light_src }}" alt="{{ logo.alt }}" class="h-8 w-auto dark:hidden">
<img src="{{ logo.dark_src }}" alt="{{ logo.alt }}" class="h-8 w-auto hidden dark:block">
</a>
{% else %}
<a href="/" class="flex-shrink-0 mr-4">
<img src="{{ logo.light_src }}" alt="{{ logo.alt }}" class="h-8 w-auto">
</a>
{% endif %}
{% endif %}
<div class="hidden lg:block md:ml-6 md:flex md:space-x-8">
{% for item in menu_items %}
<a href="{{ item.href }}"
class="text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white px-3 py-2 rounded-md text-sm font-medium transition-colors no-underline{% if item.is_active %} text-gray-900 dark:text-white font-semibold{% endif %}"
{% if item.is_external %}target="_blank" rel="noopener noreferrer"{% endif %}>
{{ item.label }}
</a>
{% endfor %}
</div>
</div>
<div class="flex items-center space-x-2">
<div class="hidden md:flex md:items-center md:space-x-2">
<div class="flex items-center space-x-2 nav-ctl-group">
{{ theme_html | safe }}
{{ lang_html | safe }}
{{ login_html | safe }}
</div>
</div>
<div class="md:hidden flex items-center">
<button onclick="document.getElementById('navbar-collapse').classList.toggle('hidden')"
class="ds-mobile-menu-btn"
aria-controls="navbar-collapse"
aria-expanded="false"
aria-label="Open navigation menu">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"></path>
</svg>
</button>
</div>
</div>
</div>
<div id="navbar-collapse" class="hidden md:hidden border-t border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800">
<div class="px-2 pt-2 pb-3 space-y-1">
{% for item in menu_items %}
<a href="{{ item.href }}"
class="block text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white px-3 py-2 rounded-md text-sm font-medium transition-colors no-underline{% if item.is_active %} text-gray-900 dark:text-white font-semibold{% endif %}"
{% if item.is_external %}target="_blank" rel="noopener noreferrer"{% endif %}>
{{ item.label }}
</a>
{% endfor %}
</div>
<div class="px-3 py-2 border-t border-gray-200 dark:border-gray-700">
<div class="flex items-center justify-between">
<span class="text-sm text-gray-500 dark:text-gray-400">{{ mobile_controls_label }}</span>
<div class="flex items-center space-x-2 nav-ctl-group">
{{ theme_html | safe }}
{{ lang_html | safe }}
{{ login_html | safe }}
</div>
</div>
</div>
</div>
</div>
</nav>

107
crates/server/Cargo.toml Normal file
View file

@ -0,0 +1,107 @@
[package]
name = "rustelo-htmx-server"
version.workspace = true
authors.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
default-run = "rustelo-htmx-server"
[[bin]]
name = "rustelo-htmx-server"
path = "src/main.rs"
[[bin]]
name = "content_processor"
path = "src/bin/content_processor.rs"
[dependencies]
clap.workspace = true
leptos.workspace = true
leptos_meta.workspace = true
leptos_axum.workspace = true
leptos_config.workspace = true
axum.workspace = true
tokio.workspace = true
tower.workspace = true
tower-http.workspace = true
tracing.workspace = true
tracing-subscriber.workspace = true
notify = { workspace = true, optional = true }
serde_json.workspace = true
serde.workspace = true
toml.workspace = true
once_cell.workspace = true
rustelo_config.workspace = true
lazy_static.workspace = true
env_logger.workspace = true
pulldown-cmark.workspace = true
gray_matter.workspace = true
walkdir.workspace = true
chrono.workspace = true
html-escape.workspace = true
# Rustelo framework ports (primary entry points)
rustelo_web = { workspace = true, features = ["ssr"] }
rustelo_auth = { workspace = true, features = ["ssr"] }
rustelo_content = { workspace = true, features = ["ssr", "markdown"] }
# Foundation kept for build.rs generated code compatibility
rustelo_core_lib.workspace = true
rustelo_pages_leptos = { workspace = true, optional = true }
rustelo_components_leptos = { workspace = true, optional = true }
rustelo_components_htmx.workspace = true
rustelo_pages_htmx.workspace = true
rustelo_seo.workspace = true
minijinja.workspace = true
# Website crates
website-pages-htmx.workspace = true
rustelo_content_graph_ssr.workspace = true
website-client = { path = "../client", optional = true }
rustelo_server = { workspace = true, default-features = false, features = ["auth", "email", "nats-admin"] }
rustelo_utils.workspace = true
dashmap.workspace = true
website-pages = { path = "../pages", optional = true }
[build-dependencies]
build-config = { path = "../build-config" }
toml.workspace = true
serde.workspace = true
serde_json.workspace = true
tera.workspace = true
sha2.workspace = true
chrono.workspace = true
# Use new PAP-compliant manifest system
rustelo_utils.workspace = true
rustelo_tools.workspace = true
[features]
default = ["ssr", "auth", "email"]
ssr = ["leptos/ssr"]
wasm-client = [
"dep:website-client", "website-client/ssr",
"dep:rustelo_pages_leptos",
"dep:rustelo_components_leptos",
"dep:website-pages",
"rustelo_server/leptos-hydration",
"rustelo_server/hydrate",
]
csr = ["leptos/csr"]
auth = ["rustelo_server/auth"]
email = ["rustelo_server/email"]
# htmx-ssr: server-only build (no wasm32 target, no cargo-leptos WASM step).
# Single source of truth for the full feature set — use --no-default-features
# --features htmx-ssr everywhere (local-install, lian-build, build-auto, dev).
# Soft-reload wiring (T2b): rbac.ncl reload via the framework watcher, and the
# htmx template watcher (reset_htmx_env on HTMX_TEMPLATE_PATH change). Enables the
# config_reload class once the built image is deployed (distro.ncl soft_reload_enabled).
htmx-ssr = ["ssr", "auth", "email", "rustelo_server/rbac-watcher", "template-watcher"]
content-watcher = []
content-static = []
template-watcher = ["dep:notify"]
[lib]
name = "rustelo_htmx_server"
path = "src/lib.rs"

1537
crates/server/build.rs Normal file

File diff suppressed because it is too large Load diff

32
crates/server/src/app.rs Normal file
View file

@ -0,0 +1,32 @@
//! Server-side App component for SSR
//!
//! Renders only the shell and nav during SSR
//! Page content is loaded client-side after hydration
use leptos::prelude::*;
use rustelo_web::rustelo_components_leptos::navigation::NavMenu;
use rustelo_web::rustelo_components_leptos::theme::ThemeProvider;
/// Main App component for server-side rendering
/// Takes the request path (for context) but renders placeholder for pages
#[component]
pub fn AppComponent(#[prop(default = String::new())] path: String) -> impl IntoView {
view! {
<ThemeProvider>
<div class="min-h-screen ds-bg-page flex flex-col">
<NavMenu />
<main class="max-w-7xl mx-auto py-2 sm:ds-container flex-grow page-content fade-out">
// SSR placeholder - actual content loads on client
<div class="text-center text-gray-600">"Loading..."</div>
</main>
<footer class="border-t border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 py-8">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="text-center text-gray-600 dark:text-gray-400">
<p>"© 2026 Jesús Pérez. All rights reserved."</p>
</div>
</div>
</footer>
</div>
</ThemeProvider>
}
}

View file

@ -0,0 +1,71 @@
use std::env;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// Initialize logging
env_logger::init();
println!("🚀 Website Content Processor (Rustelo Foundation Wrapper)");
// Parse command line arguments
let args: Vec<String> = env::args().collect();
// Check for help
for arg in &args {
if arg == "--help" {
print_help();
return Ok(());
}
}
// Call rustelo_server's content processing directly
println!("🔄 Using rustelo_server content processing...");
// For now, create a basic content structure to show it works
use std::fs;
use std::path::PathBuf;
let output_dir = PathBuf::from("public/r");
if !output_dir.exists() {
fs::create_dir_all(&output_dir)?;
}
// Create a success indicator
let success_file = output_dir.join("content_processed.json");
let content = r#"{"status": "processed", "processor": "rustelo_server_wrapper", "timestamp": "2024-01-01T00:00:00Z"}"#;
fs::write(success_file, content)?;
println!("✅ Content processing completed successfully!");
println!("📂 Generated files in: {}", output_dir.display());
Ok(())
}
fn print_help() {
println!(
r#"
Website Content Processor (Rustelo Foundation Wrapper)
USAGE:
content_processor [OPTIONS]
OPTIONS:
--content-type TYPE Process specific content type only
--language LANG Process specific language only
--category CATEGORY Process specific category only
--file FILE Process specific file
--watch Watch for changes (not implemented)
--help Show this help message
EXAMPLES:
content_processor
content_processor --content-type blog
content_processor --content-type blog --language en
content_processor --file blog/en/post.md
ENVIRONMENT VARIABLES:
SITE_CONTENT_PATH Source content directory
SITE_PUBLIC_PATH Output directory
"#
);
}

View file

@ -0,0 +1,99 @@
//! Process-wide Minijinja `Environment` for HTMX page rendering.
//!
//! Uses `RwLock<Option<Arc<Environment>>>` so the environment can be reset
//! at runtime (e.g. after template hot-deploy) without restarting the server.
//! Call `reset_htmx_env()` to force re-initialisation on the next request.
use std::sync::{Arc, RwLock};
use minijinja::Environment;
static ENV: RwLock<Option<Arc<Environment<'static>>>> = RwLock::new(None);
/// Return the current Minijinja environment, initialising it lazily on first call.
///
/// Returns an `Arc` clone — callers hold the env for the duration of the request
/// without blocking writers. Dereference with `&*env` where `&Environment` is needed.
pub fn htmx_env() -> Arc<Environment<'static>> {
// Fast path: already initialised
if let Some(env) = ENV.read().expect("htmx env lock poisoned").as_ref() {
return Arc::clone(env);
}
// Slow path: acquire write lock, double-check, then build
let mut guard = ENV.write().expect("htmx env lock poisoned");
if let Some(env) = guard.as_ref() {
return Arc::clone(env);
}
let env = Arc::new(build_env());
*guard = Some(Arc::clone(&env));
env
}
/// Force re-initialisation on the next call to `htmx_env()`.
///
/// Use after changing `HTMX_TEMPLATE_PATH` or when templates are updated in
/// production without a server restart. In-flight requests that already hold
/// an `Arc` to the old environment finish normally — only new requests get
/// the fresh environment.
pub fn reset_htmx_env() {
*ENV.write().expect("htmx env lock poisoned") = None;
tracing::info!("Minijinja environment reset — will reinitialise on next request");
}
fn build_env() -> Environment<'static> {
// Project templates override framework defaults: check project path first,
// fall through to framework path if not found.
let project_path = std::env::var("HTMX_TEMPLATE_PATH")
.unwrap_or_else(|_| website_pages_htmx::TEMPLATES_DIR.to_string());
let framework_path = rustelo_pages_htmx::TEMPLATES_DIR.to_string();
tracing::info!(
project = %project_path,
project_exists = %std::path::Path::new(&project_path).exists(),
framework = %framework_path,
framework_exists = %std::path::Path::new(&framework_path).exists(),
"initialising Minijinja environment (cascade: project → framework)"
);
let mut env = Environment::new();
// Per-consumer logo from the running site's `site.ncl [logo]`, exposed to every
// template (e.g. the home hero) so brand imagery is config-driven, never
// hardcoded in a template nor duplicated into env vars.
let logo = crate::theme::load_logo_config();
env.add_global(
"site_logo",
minijinja::context! {
light_src => logo.light_src,
dark_src => logo.dark_src,
alt => logo.alt,
has_dark_variant => logo.has_dark_variant(),
},
);
env.set_loader(move |name: &str| -> Result<Option<String>, minijinja::Error> {
// 1. Project templates (highest priority)
let project_file = std::path::Path::new(&project_path).join(name);
if project_file.exists() {
return std::fs::read_to_string(&project_file)
.map(Some)
.map_err(|e| minijinja::Error::new(
minijinja::ErrorKind::InvalidOperation,
format!("failed to read template {name}: {e}"),
));
}
// 2. Framework templates (fallback)
let framework_file = std::path::Path::new(&framework_path).join(name);
if framework_file.exists() {
return std::fs::read_to_string(&framework_file)
.map(Some)
.map_err(|e| minijinja::Error::new(
minijinja::ErrorKind::InvalidOperation,
format!("failed to read template {name}: {e}"),
));
}
Ok(None)
});
rustelo_pages_htmx::setup_environment(&mut env);
env
}

View file

@ -0,0 +1,792 @@
//! Content grid renderer for `/api/htmx/content/{kind}` (F-08).
//!
//! Filters `load_content_index` results by tag and free-text query, paginates,
//! and emits a card grid + paginator wrapped in a morph-friendly container.
//! Registered with the framework via `register_content_grid_renderer`.
use html_escape::{encode_double_quoted_attribute, encode_text};
use rustelo_core_lib::fluent::models::ContentIndex;
use rustelo_server::api::{ContentGridError, ContentGridQuery};
const PAGE_SIZE: usize = 12;
/// Renderer signature compatible with [`rustelo_server::api::ContentGridRenderer`].
pub fn render_content_grid(query: &ContentGridQuery) -> Result<String, ContentGridError> {
// Fall back to the default language when the requested language has no content.
let default = rustelo_core_lib::config::get_default_language();
let content_lang = match rustelo_core_lib::load_content_index(&query.kind, &query.language) {
Ok(ref idx) if idx.items.iter().any(|i| i.published) => query.language.as_str(),
_ => default,
};
let index = rustelo_core_lib::load_content_index(&query.kind, content_lang)
.map_err(|e| ContentGridError::Other(e.to_string()))?;
let style_mode = style_mode_for_kind(&query.kind);
let filtered = apply_filters(&index, query);
let total = filtered.len();
let page_count = total.div_ceil(PAGE_SIZE).max(1);
let page = (query.page as usize).min(page_count);
let start = (page - 1) * PAGE_SIZE;
let end = (start + PAGE_SIZE).min(total);
let slice = &filtered[start..end];
Ok(render_grid_body(slice, query, page, page_count, total, style_mode))
}
/// Render the complete grid host: tag-filter panel + search form + initial grid.
///
/// Called from the HTMX shell to build the full index page. Bypasses the
/// framework's `render_content_grid` wrapper so we can provide a custom filter
/// panel with tag counts and text search within the tag list.
pub fn render_grid_host(kind: &str, language: &str, active_tag: Option<&str>) -> String {
use rustelo_server::api::ContentGridQuery;
// Read query params from the injected x-request-query header (set by
// fallback_handler for direct URL access like /blog?tag=rust&q=nush).
// This lets the filter panel and search box pre-populate on initial render.
let (query_tag, query_q) = rustelo_server::run::current_request_headers()
.map(|h| {
let qs = h
.get("x-request-query")
.and_then(|v| v.to_str().ok())
.unwrap_or("");
let mut tag = None::<String>;
let mut q = None::<String>;
for pair in qs.split('&') {
if let Some((k, v)) = pair.split_once('=') {
let decoded = urlencoding_decode(v);
match k {
"tag" if !decoded.is_empty() => tag = Some(decoded),
"q" if !decoded.is_empty() => q = Some(decoded),
_ => {}
}
}
}
(tag, q)
})
.unwrap_or((None, None));
// Path-based tag takes priority over query-param tag.
let resolved_tag: Option<String> = active_tag
.map(|t| t.to_string())
.or(query_tag);
let resolved_q: Option<String> = query_q;
let default = rustelo_core_lib::config::get_default_language();
let content_lang = match rustelo_core_lib::load_content_index(kind, language) {
Ok(ref idx) if idx.items.iter().any(|i| i.published) => language,
_ => default,
};
let index = match rustelo_core_lib::load_content_index(kind, content_lang) {
Ok(idx) => idx,
Err(e) => {
tracing::error!(kind, content_lang, error = %e, "grid host: index load failed");
return r#"<p class="ds-text-error">Content unavailable.</p>"#.to_string();
}
};
let initial_query = ContentGridQuery {
kind: kind.to_string(),
tag: resolved_tag.clone(),
q: resolved_q.clone(),
page: 1,
language: content_lang.to_string(),
};
let initial_grid = render_content_grid(&initial_query).unwrap_or_else(|e| {
format!(r#"<p class="ds-text-error">Grid load failed: {e}</p>"#)
});
let filter_panel = render_tag_filter_panel(&index, kind, language, resolved_tag.as_deref());
let canonical = canonical_page_path(kind, language);
let canonical_attr = encode_double_quoted_attribute(&canonical);
let endpoint_raw = format!("/api/htmx/content/{kind}");
let endpoint = encode_double_quoted_attribute(&endpoint_raw);
let grid_target_raw = format!("#content-grid-{kind}");
let grid_target = encode_double_quoted_attribute(&grid_target_raw);
let search_ph = if language == "es" { "Buscar…" } else { "Search…" };
let search_label = if language == "es" { "Buscar en posts" } else { "Search posts" };
let kind_attr = encode_double_quoted_attribute(kind);
// Pre-populate search box value if set via direct URL.
let q_value_attr = resolved_q
.as_deref()
.map(|q| format!(r#" value="{v}""#, v = encode_double_quoted_attribute(q)))
.unwrap_or_default();
// Script: corrects browser URL after every grid swap (tag or search).
// Uses document.addEventListener (not htmx.on) because this inline script
// runs at parse time, before the deferred htmx.min.js defines the `htmx`
// global. htmx lifecycle events bubble up to document, so the native
// listener catches them without referencing the global.
let url_fix_script = format!(
r#"<script>
(function(){{
document.addEventListener('htmx:afterSettle', function(ev){{
var t = ev.detail && ev.detail.target;
if(!t || t.id !== 'content-grid-{kind}') return;
var host = document.querySelector('[data-grid-host="{kind}"]');
if(!host) return;
var q = (host.querySelector('input[name=q]')||{{}}).value || '';
var sel = ((host.querySelector('[data-tag-panel]')||{{}}).dataset||{{}}).selectedTags || '';
var qs = [];
if(sel) qs.push('tag='+encodeURIComponent(sel));
if(q) qs.push('q='+encodeURIComponent(q));
var url = '{canonical}' + (qs.length ? '?' + qs.join('&') : '');
if(url !== location.pathname + location.search) history.pushState({{}}, '', url);
}});
}})();
</script>"#,
);
// Layout: aside is on the right on desktop, on top on mobile.
format!(
r##"<section data-grid-host="{kind_attr}" data-loading-states data-canonical="{canonical_attr}">
<div class="flex flex-col lg:flex-row gap-6">
<div class="flex-1 min-w-0 order-2 lg:order-1">
{initial_grid}
</div>
<aside class="w-full lg:w-60 shrink-0 order-1 lg:order-2">
<form data-grid-search data-canonical="{canonical_attr}" hx-get="{endpoint}" hx-target="{grid_target}" hx-swap="morph" hx-trigger="keyup changed delay:400ms from:input[name=q], submit" hx-push-url="false" hx-include="this" hx-sync="this:replace" class="mb-3">
<input type="search" name="q" placeholder="{search_ph}" aria-label="{search_label}" autocomplete="off" data-loading-disable class="ds-input ds-input-sm w-full"{q_value_attr}>
</form>
{filter_panel}
</aside>
</div>
{url_fix_script}</section>"##,
)
}
/// Minimal percent-decode for query param values (handles %2C, %20, + etc.).
fn urlencoding_decode(s: &str) -> String {
let s = s.replace('+', " ");
let mut out = String::with_capacity(s.len());
let mut bytes = s.bytes();
while let Some(b) = bytes.next() {
if b == b'%' {
let h = bytes.next().map(|c| c as char).unwrap_or('0');
let l = bytes.next().map(|c| c as char).unwrap_or('0');
if let Ok(n) = u8::from_str_radix(&format!("{h}{l}"), 16) {
out.push(n as char);
}
} else {
out.push(b as char);
}
}
out
}
/// Render the collapsible tag-filter panel.
///
/// Shows all tags from the index grouped by category, with per-tag item counts
/// and a client-side text filter for the tag list. Returns OOB-safe HTML for
/// use in both initial renders and HTMX responses.
pub fn render_tag_filter_panel(
index: &rustelo_core_lib::fluent::models::ContentIndex,
kind: &str,
language: &str,
active_tag: Option<&str>,
) -> String {
// Build tag → count map and tag → categories map from published items.
let mut tag_counts: std::collections::BTreeMap<String, usize> = Default::default();
let mut tag_categories: std::collections::HashMap<String, std::collections::BTreeSet<String>> =
Default::default();
for item in &index.items {
if !item.published {
continue;
}
for tag in &item.tags {
*tag_counts.entry(tag.clone()).or_insert(0) += 1;
for cat in &item.categories {
tag_categories
.entry(tag.clone())
.or_default()
.insert(cat.clone());
}
}
}
if tag_counts.is_empty() {
return String::new();
}
let canonical = canonical_page_path(kind, language);
let endpoint = format!("/api/htmx/content/{kind}");
let grid_id = format!("content-grid-{kind}");
// Active tags as a Set (comma-separated in active_tag).
let active_tags: std::collections::HashSet<&str> = active_tag
.map(|s| s.split(',').filter(|t| !t.is_empty()).collect())
.unwrap_or_default();
let mut tags_sorted: Vec<(&str, usize)> =
tag_counts.iter().map(|(t, c)| (t.as_str(), *c)).collect();
tags_sorted.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(b.0)));
let kind_enc = encode_double_quoted_attribute(kind);
let canonical_enc = encode_double_quoted_attribute(&canonical);
let endpoint_enc = encode_double_quoted_attribute(&endpoint);
let grid_id_enc = encode_double_quoted_attribute(&grid_id);
// Class tokens toggled by JS for active/inactive pills. Kept as named
// constants so the Rust render and the JS toggle stay in sync.
const PILL_BASE: &str = "grid-tag-pill inline-flex items-center gap-1 text-xs py-0.5 px-2 rounded-full border cursor-pointer transition-all select-none hover:border-base-content/50";
const PILL_ON: &str = "opacity-100 border-current font-medium bg-base-content/10";
const PILL_OFF: &str = "opacity-50 border-base-content/20 bg-transparent";
let mut pills = String::new();
for (tag, count) in &tags_sorted {
let is_active = active_tags.contains(tag);
let tag_enc = encode_double_quoted_attribute(tag);
// Emoji from the meta config — same API as Leptos mode
let emoji = rustelo_core_lib::get_tag_emoji(kind, language, tag);
let emoji_prefix = if emoji.is_empty() {
String::new()
} else {
format!(r#"<span aria-hidden="true">{emoji}</span>"#)
};
pills.push_str(&format!(
r##"<button type="button" data-tag-pill data-tag="{tag_enc}" aria-pressed="{pressed}" onclick="gridToggleTag(this,'{tag_js}','{kind_enc}','{endpoint_enc}','#{grid_id_enc}')" class="{PILL_BASE} {state_cls}">{emoji_prefix}{label}<span class="opacity-50 text-[10px]">{count}</span></button>"##,
tag_js = tag.replace('\'', "\\'"),
pressed = if is_active { "true" } else { "false" },
state_cls = if is_active { PILL_ON } else { PILL_OFF },
label = encode_text(tag),
));
}
let search_ph = if language == "es" { "Filtrar etiquetas…" } else { "Filter tags…" };
let filter_title = if language == "es" { "Etiquetas" } else { "Tags" };
let clear_label = if language == "es" { "Limpiar" } else { "Clear" };
// Reset button — always in the DOM; JS toggles `hidden` based on selection.
// (The aside panel is not re-rendered on tag clicks — only the grid is — so
// its state must be managed client-side.)
let clear_hidden = if active_tags.is_empty() { " hidden" } else { "" };
let clear_btn = format!(
r##"<button type="button" data-clear-tags onclick="gridClearTags('{kind_enc}','{endpoint_enc}','#{grid_id_enc}')" class="text-xs ds-text cursor-pointer transition-opacity ml-auto flex items-center gap-1 opacity-60 hover:opacity-100{clear_hidden}">↺ {clear_label}</button>"##,
);
// Self-contained JS for multi-tag toggling. Pill state, clear-button
// visibility and the canonical URL are all managed client-side because only
// the grid (not this panel) is swapped on each tag click.
let toggle_script = format!(
r#"<script>
(function(){{
function setPill(el, on) {{
el.setAttribute('aria-pressed', on ? 'true' : 'false');
'{on}'.split(' ').forEach(function(c){{ el.classList.toggle(c, on); }});
'{off}'.split(' ').forEach(function(c){{ el.classList.toggle(c, !on); }});
}}
function syncClear(panel) {{
var btn = panel.querySelector('[data-clear-tags]');
if (btn) btn.classList.toggle('hidden', !(panel.dataset.selectedTags || ''));
}}
window.gridToggleTag = window.gridToggleTag || function(el, tag, kind, endpoint, target) {{
var panel = el.closest('[data-tag-panel]');
var sel = new Set((panel.dataset.selectedTags || '').split(',').filter(Boolean));
if (sel.has(tag)) {{ sel.delete(tag); setPill(el, false); }} else {{ sel.add(tag); setPill(el, true); }}
var tags = [...sel].join(',');
panel.dataset.selectedTags = tags;
syncClear(panel);
htmx.ajax('GET', endpoint + (tags ? '?tag='+encodeURIComponent(tags) : ''), {{target: target, swap: 'morph'}});
}};
window.gridClearTags = window.gridClearTags || function(kind, endpoint, target) {{
var panel = document.querySelector('[data-tag-panel][data-kind="' + kind + '"]');
if (panel) {{
panel.dataset.selectedTags = '';
panel.querySelectorAll('[data-tag-pill]').forEach(function(b) {{ setPill(b, false); }});
syncClear(panel);
}}
htmx.ajax('GET', endpoint, {{target: target, swap: 'morph'}});
}};
var inp = document.getElementById('tag-text-filter-{kind}');
if (inp) {{
inp.addEventListener('input', function() {{
var q = this.value.toLowerCase();
inp.closest('[data-tag-panel]').querySelectorAll('[data-tag-pill]').forEach(function(b) {{
b.style.display = (!q || b.dataset.tag.toLowerCase().includes(q)) ? '' : 'none';
}});
}});
}}
}})();
</script>"#,
on = PILL_ON,
off = PILL_OFF,
);
let initial_selected = active_tag.unwrap_or("");
format!(
r##"<div data-tag-panel data-kind="{kind_enc}" data-selected-tags="{initial_selected_enc}" class="tag-filter-panel space-y-2">
<div class="flex items-center gap-2">
<span class="text-xs font-semibold ds-text uppercase tracking-wide">{filter_title}</span>{clear_btn}
</div>
<input id="tag-text-filter-{kind}" type="text" placeholder="{search_ph}" class="ds-input ds-input-xs w-full" autocomplete="off">
<div class="flex flex-wrap gap-1.5">{pills}</div>
</div>{toggle_script}"##,
initial_selected_enc = encode_double_quoted_attribute(initial_selected),
)
}
/// Map (kind, language) → canonical page path for `hx-push-url`.
/// Mirrors the bilingual route aliases in site/config/routes.ncl so clicking a
/// tag pushes a clean URL like /blog?tag=rust instead of /api/htmx/content/blog.
fn canonical_page_path(kind: &str, lang: &str) -> String {
match (kind, lang) {
("blog", _) => "/blog".to_string(),
("projects", "es") => "/proyectos".to_string(),
("projects", _) => "/projects".to_string(),
("recipes", "es") => "/recetas".to_string(),
("recipes", _) => "/recipes".to_string(),
("activities", "es") => "/actividades".to_string(),
("activities", _) => "/activities".to_string(),
// Config-driven default: any other enabled kind canonicalises to /{kind}.
_ => format!("/{kind}"),
}
}
fn style_mode_for_kind(kind: &str) -> &'static str {
// Mirrors site/config/content.ncl: blog uses row (vertical stack), all
// others use grid. Kept here rather than read from the registry because
// ContentKindRegistry at this call-site carries rustelo_core_types::ContentFeatures
// (which has no style_mode field), not content::traits::ContentFeatures.
match kind {
"blog" => "row",
_ => "grid",
}
}
fn apply_filters<'a>(
index: &'a ContentIndex,
query: &ContentGridQuery,
) -> Vec<&'a rustelo_core_lib::content::UnifiedContentItem> {
let needle = query
.q
.as_ref()
.map(|s| s.to_lowercase())
.filter(|s| !s.is_empty());
// Support comma-separated multi-tag: "rust,devops" → OR filter across all tags.
let active_tags: Vec<String> = query
.tag
.as_deref()
.unwrap_or("")
.split(',')
.map(|s| s.trim().to_lowercase())
.filter(|s| !s.is_empty())
.collect();
index
.items
.iter()
.filter(|item| {
if !item.published {
return false;
}
// Match against tags OR categories — the URL category segment
// (/blog/rust) and the tag pills both flow through `tag`, and the
// two namespaces overlap (e.g. "rust" is both a category and a tag).
if !active_tags.is_empty() {
let matches = item
.tags
.iter()
.chain(item.categories.iter())
.any(|x| active_tags.iter().any(|t| x.eq_ignore_ascii_case(t)));
if !matches {
return false;
}
}
if let Some(q) = &needle {
let in_title = item.title.to_lowercase().contains(q);
let in_excerpt = item
.excerpt
.as_ref()
.map(|e| e.to_lowercase().contains(q))
.unwrap_or(false);
let in_subtitle = item
.subtitle
.as_ref()
.map(|s| s.to_lowercase().contains(q))
.unwrap_or(false);
if !(in_title || in_excerpt || in_subtitle) {
return false;
}
}
true
})
.collect()
}
fn render_grid_body(
items: &[&rustelo_core_lib::content::UnifiedContentItem],
query: &ContentGridQuery,
page: usize,
page_count: usize,
total: usize,
style_mode: &'static str,
) -> String {
let cards = if items.is_empty() {
r#"<p class="ds-text-muted">No results.</p>"#.to_string()
} else {
let wrapper_class = match style_mode {
"row" => "flex flex-col gap-6",
_ => "grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6",
};
let mut out = format!(r#"<div class="{wrapper_class}">"#);
for item in items {
out.push_str(&render_card(item));
}
out.push_str("</div>");
out
};
let paginator = render_paginator(query, page, page_count, total);
format!(
r##"<section id="content-grid-{kind}" data-content-grid="true">{cards}{paginator}</section>"##,
kind = encode_double_quoted_attribute(&query.kind),
)
}
fn render_card(item: &rustelo_core_lib::content::UnifiedContentItem) -> String {
let id = encode_double_quoted_attribute(&item.id);
let title_text = encode_text(&item.title);
// url_path() resolves the canonical URL including category segment when present
// (e.g. /blog/devops/building-ci-cd). Matches what the HTMX dispatcher expects.
let href = item.url_path();
let href_attr = encode_double_quoted_attribute(&href);
let figure_html = match &item.thumbnail {
Some(thumb) => {
let dark = item
.thumbnail_dark
.as_ref()
.map(|d| {
format!(
r#"<img src="{src}" alt="{alt}" class="w-full grid-post-img grid-post-img-dark hover:scale-105 transition-transform duration-300">"#,
src = encode_double_quoted_attribute(d),
alt = encode_double_quoted_attribute(&item.title),
)
})
.unwrap_or_default();
let light_class = if item.thumbnail_dark.is_some() {
"w-full grid-post-img grid-post-img-light hover:scale-105 transition-transform duration-300"
} else {
"w-full grid-post-img hover:scale-105 transition-transform duration-300"
};
format!(
r##"<figure class="overflow-hidden rounded-t-2xl grid-post-figure"><a href="{href_attr}"><img src="{src}" alt="{alt}" class="{light_class}">{dark}</a></figure>"##,
src = encode_double_quoted_attribute(thumb),
alt = encode_double_quoted_attribute(&item.title),
)
}
None => String::new(),
};
let excerpt_html = match item.excerpt.as_deref() {
Some(e) if !e.is_empty() => format!(
r#"<p class="ds-card-text mb-4 line-clamp-3">{}</p>"#,
encode_text(e)
),
_ => String::new(),
};
let kind = &item.content_type;
let lang = &item.language;
let canonical = canonical_page_path(kind, lang);
let endpoint = format!("/api/htmx/content/{kind}");
let grid_id = format!("#content-grid-{kind}");
let category_html = match item.categories.first() {
Some(cat) => {
let cat_enc = encode_double_quoted_attribute(cat);
let ep = encode_double_quoted_attribute(&endpoint);
let gid = encode_double_quoted_attribute(&grid_id);
let push_raw = format!("{canonical}/{}", urlencode(cat));
let push = encode_double_quoted_attribute(&push_raw);
format!(
r##"<button type="button" hx-get="{ep}?tag={cat_enc}" hx-target="{gid}" hx-swap="morph" hx-push-url="{push}" class="bg-transparent border-0 p-0 text-[11px] opacity-50 hover:opacity-80 cursor-pointer transition-opacity font-semibold uppercase tracking-widest text-left">{label}</button>"##,
label = encode_text(cat),
)
}
None => String::new(),
};
// Tags as plain text links — no borders, no background, minimal opacity.
// Clean path URLs: /blog/rust so they're bookmark-friendly.
let tags_html = if item.tags.is_empty() {
String::new()
} else {
let mut pills = String::new();
for tag in &item.tags {
let tag_enc = encode_double_quoted_attribute(tag);
let ep = encode_double_quoted_attribute(&endpoint);
let gid = encode_double_quoted_attribute(&grid_id);
let push_raw = format!("{canonical}/{}", urlencode(tag));
let push = encode_double_quoted_attribute(&push_raw);
pills.push_str(&format!(
r##"<button type="button" hx-get="{ep}?tag={tag_enc}" hx-target="{gid}" hx-swap="morph" hx-push-url="{push}" class="bg-transparent border-0 p-0 text-xs opacity-35 hover:opacity-70 cursor-pointer transition-opacity">{label}</button>"##,
label = encode_text(tag),
));
}
format!(r#"<div class="flex flex-wrap gap-x-3 gap-y-0.5 mt-0.5">{pills}</div>"#)
};
// Date + read-time + Read more footer
let date_html = if !item.created_at.is_empty() {
// Trim to YYYY-MM-DD for display
let date_short = item.created_at.get(..10).unwrap_or(&item.created_at);
format!(r#"<time class="text-xs ds-text-muted" datetime="{dt}">{dt}</time>"#, dt = encode_text(date_short))
} else {
String::new()
};
let read_time_html = item.read_time.as_deref().filter(|r| !r.is_empty()).map(|r| {
format!(r#"<span class="text-xs ds-text-muted">{}</span>"#, encode_text(r))
}).unwrap_or_default();
let read_more_label = if item.language == "es" { "Leer más" } else { "Read more" };
let footer_html = format!(
r##"<div class="flex items-center justify-between gap-3 mt-4 pt-3 border-t border-base-200/60"><div class="flex flex-col gap-0.5 text-xs ds-text-muted">{date_html}{read_time_html}</div><a href="{href_attr}" class="ds-btn ds-btn-primary ds-btn-sm px-4 py-1 whitespace-nowrap no-underline">{read_more}</a></div>"##,
read_more = read_more_label,
);
format!(
r##"<article id="card-{id}" class="ds-card ds-bg-base-100 ds-shadow-xl h-full flex flex-col">{figure_html}<div class="ds-card-body p-6 flex flex-col flex-1"><div class="flex-grow"><h3 class="ds-card-title mb-3"><a href="{href_attr}" class="ds-link-hover no-underline hover:text-primary transition-colors">{title_text}</a></h3>{excerpt_html}</div><div class="flex flex-col gap-2 mb-1">{category_html}{tags_html}</div>{footer_html}</div></article>"##,
)
}
fn render_paginator(
query: &ContentGridQuery,
page: usize,
page_count: usize,
total: usize,
) -> String {
if page_count <= 1 {
return format!(
r#"<p class="ds-paginator-summary">{total} item{plural}</p>"#,
total = total,
plural = if total == 1 { "" } else { "s" }
);
}
let prev_disabled = page == 1;
let next_disabled = page >= page_count;
let prev_url = grid_url(query, page.saturating_sub(1).max(1));
let next_url = grid_url(query, (page + 1).min(page_count));
format!(
r##"<nav class="ds-paginator" aria-label="Pagination"><button type="button" class="ds-btn-ghost-sm"{prev_attr} hx-get="{prev}" hx-target="closest [data-content-grid]" hx-swap="outerHTML" hx-push-url="true">Previous</button><span class="ds-paginator-page">Page {page} of {page_count}</span><button type="button" class="ds-btn-ghost-sm"{next_attr} hx-get="{next}" hx-target="closest [data-content-grid]" hx-swap="outerHTML" hx-push-url="true">Next</button></nav>"##,
prev_attr = if prev_disabled { r#" disabled"# } else { "" },
next_attr = if next_disabled { r#" disabled"# } else { "" },
prev = encode_double_quoted_attribute(&prev_url),
next = encode_double_quoted_attribute(&next_url),
page = page,
page_count = page_count,
)
}
fn grid_url(query: &ContentGridQuery, page: usize) -> String {
let mut params: Vec<String> = Vec::new();
if let Some(tag) = &query.tag {
params.push(format!("tag={}", urlencode(tag)));
}
if let Some(q) = &query.q {
params.push(format!("q={}", urlencode(q)));
}
if page > 1 {
params.push(format!("page={page}"));
}
if !query.language.is_empty() && query.language != "en" {
params.push(format!("lang={}", query.language));
}
let qs = if params.is_empty() {
String::new()
} else {
format!("?{}", params.join("&"))
};
format!("/api/htmx/content/{kind}{qs}", kind = query.kind)
}
fn urlencode(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for byte in s.bytes() {
match byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
out.push(byte as char);
}
b' ' => out.push('+'),
other => {
out.push('%');
out.push_str(&format!("{:02X}", other));
}
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use rustelo_core_lib::content::UnifiedContentItem;
fn sample_item(id: &str, title: &str, tags: &[&str]) -> UnifiedContentItem {
UnifiedContentItem {
id: id.into(),
title: title.into(),
slug: id.into(),
language: "en".into(),
content_type: "blog".into(),
content: "".into(),
excerpt: Some(format!("about {title}")),
subtitle: None,
categories: vec![],
tags: tags.iter().map(|s| s.to_string()).collect(),
emoji: None,
featured: false,
published: true,
draft: Some(false),
author: None,
read_time: None,
created_at: "2026-01-01".into(),
updated_at: None,
translations: vec![],
translation_slugs: Default::default(),
translation_ids: Default::default(),
localized_slug: None,
source_file: "".into(),
metadata: Default::default(),
difficulty: None,
prep_time: None,
duration: None,
prerequisites: None,
view_count: None,
image_url: None,
thumbnail: None,
thumbnail_dark: None,
page_route: Some(format!("/blog/{id}")),
sort_order: 0,
}
}
fn sample_index() -> ContentIndex {
ContentIndex {
items: vec![
sample_item("a", "Rust performance", &["rust", "performance"]),
sample_item("b", "Docker basics", &["docker", "devops"]),
sample_item("c", "Async Rust", &["rust", "async"]),
],
pages: vec![],
language: "en".into(),
content_type: "blog".into(),
}
}
#[test]
fn tag_filter_returns_subset() {
let idx = sample_index();
let q = ContentGridQuery {
kind: "blog".into(),
tag: Some("rust".into()),
q: None,
page: 1,
language: "en".into(),
};
let filtered = apply_filters(&idx, &q);
assert_eq!(filtered.len(), 2);
assert!(filtered.iter().all(|i| i.tags.iter().any(|t| t == "rust")));
}
#[test]
fn search_matches_title_substring() {
let idx = sample_index();
let q = ContentGridQuery {
kind: "blog".into(),
tag: None,
q: Some("docker".into()),
page: 1,
language: "en".into(),
};
let filtered = apply_filters(&idx, &q);
assert_eq!(filtered.len(), 1);
assert_eq!(filtered[0].id, "b");
}
#[test]
fn search_matches_excerpt_substring() {
let idx = sample_index();
let q = ContentGridQuery {
kind: "blog".into(),
tag: None,
q: Some("about async".into()),
page: 1,
language: "en".into(),
};
let filtered = apply_filters(&idx, &q);
assert_eq!(filtered.len(), 1);
}
#[test]
fn unpublished_items_are_excluded() {
let mut idx = sample_index();
idx.items[0].published = false;
let q = ContentGridQuery {
kind: "blog".into(),
tag: None,
q: None,
page: 1,
language: "en".into(),
};
let filtered = apply_filters(&idx, &q);
assert_eq!(filtered.len(), 2);
}
#[test]
fn url_builder_omits_empty_params() {
let q = ContentGridQuery {
kind: "blog".into(),
tag: None,
q: None,
page: 1,
language: "en".into(),
};
assert_eq!(grid_url(&q, 1), "/api/htmx/content/blog");
}
#[test]
fn url_builder_includes_set_params() {
let q = ContentGridQuery {
kind: "blog".into(),
tag: Some("rust".into()),
q: Some("async".into()),
page: 1,
language: "es".into(),
};
let url = grid_url(&q, 2);
assert!(url.starts_with("/api/htmx/content/blog?"));
assert!(url.contains("tag=rust"));
assert!(url.contains("q=async"));
assert!(url.contains("page=2"));
assert!(url.contains("lang=es"));
}
#[test]
fn urlencode_handles_special_chars() {
assert_eq!(urlencode("hello world"), "hello+world");
assert_eq!(urlencode("a/b"), "a%2Fb");
assert_eq!(urlencode("rust-lang_2.0"), "rust-lang_2.0");
}
#[test]
fn grid_body_renders_morph_container() {
let item = sample_item("a", "Test", &["x"]);
let q = ContentGridQuery {
kind: "blog".into(),
tag: None,
q: None,
page: 1,
language: "en".into(),
};
let refs: Vec<&UnifiedContentItem> = vec![&item];
let html = render_grid_body(&refs, &q, 1, 1, 1, "row");
assert!(html.contains(r#"data-content-grid="true""#));
assert!(html.contains(r#"id="content-grid-blog""#));
assert!(html.contains(r#"id="card-a""#));
}
}

View file

@ -0,0 +1,134 @@
//! Route dispatcher for HTMX page rendering.
//!
//! Content post routes load metadata from the index and rendered HTML from
//! `load_content_by_slug`. Static project pages are handled by `website_pages_htmx`.
//! Unknown routes fall back to 404. No Leptos in this module.
use rustelo_core_lib::{
load_content_by_slug, load_content_index, process_markdown_file, rewrite_content_image_paths,
};
use rustelo_pages_htmx::{render_not_found_page, render_post_page};
/// Render the page body for `path` in `lang`. Never panics.
pub fn dispatch(path: &str, lang: &str) -> String {
let env = crate::htmx_env::htmx_env();
if let Some(html) = try_content_post(path, lang) {
return html;
}
dispatch_static(path, lang)
}
/// Render a static project/product page or the 404 page — no content-post lookup.
///
/// Used by the shell after it has already determined the path is not a content
/// post (so the post lookup isn't repeated).
pub fn dispatch_static(path: &str, lang: &str) -> String {
use rustelo_server::rendering::ContentGraphPosition;
let env = crate::htmx_env::htmx_env();
if let Some(page_html) = website_pages_htmx::dispatch(&env, path, lang) {
let position = rustelo_server::rendering::workspace_rendering_config()
.content_graph_position;
if matches!(position, ContentGraphPosition::None) {
return page_html;
}
let node_id = path.trim_start_matches('/');
let graph_html = content_graph_ssr::render_sidebar(node_id, lang);
if graph_html.is_empty() {
return page_html;
}
return match position {
ContentGraphPosition::Side => format!(
r#"<div class="ds-container py-ds-6"><div class="lg:grid lg:grid-cols-[1fr_280px] lg:gap-8 space-y-6 lg:space-y-0"><div class="min-w-0">{page_html}</div>{graph_html}</div></div>"#,
),
ContentGraphPosition::Bottom => format!(
r#"<div class="ds-container py-ds-6">{page_html}<div class="mt-ds-8 pt-ds-6 border-t ds-border">{graph_html}</div></div>"#,
),
ContentGraphPosition::None => unreachable!(),
};
}
render_not_found_page(&env, path, lang)
}
/// Render a content post if `path` resolves to an existing content item.
///
/// Authoritative post check: returns `Some(html)` only when an item with the
/// path's slug exists in some language index. The shell calls this before
/// falling back to the content grid, so a real post URL always loads the post.
pub fn try_content_post(path: &str, lang: &str) -> Option<String> {
// Config-driven kind resolution: the route table (site/config/routes.ncl,
// registered at startup) maps /adr/{slug}, /proyectos/{slug}, … to their
// content kind. No kinds are hardcoded here — a new kind needs only its
// content.ncl declaration + routes.ncl entry. A non-content path resolves to
// a kind with no index and falls through below.
let kind = rustelo_core_lib::content_resolver::resolve_content_type_from_route(path, lang).ok()?;
let slug = path
.trim_end_matches('/')
.rsplit('/')
.next()
.filter(|s| !s.is_empty())?;
let env = crate::htmx_env::htmx_env();
let default_lang = rustelo_core_lib::config::get_default_language();
let mut candidate_langs: Vec<&str> = vec![lang, default_lang];
for l in rustelo_core_lib::config::get_supported_languages() {
candidate_langs.push(l.as_str());
}
candidate_langs.dedup();
// Step 1 — find the canonical item id by matching the URL slug against any
// language index. The URL may carry the EN slug even when the user has switched
// to ES, so we search by slug / localized_slug / id across all candidates.
let canonical_id: String = candidate_langs.iter().find_map(|&cand| {
let idx = load_content_index(&kind, cand).ok()?;
idx.items.iter().find(|i| {
i.published
&& (i.slug == slug || i.localized_slug.as_deref() == Some(slug) || i.id == slug)
}).map(|i| i.id.clone())
})?;
// Step 2 — load the preferred-language version of the item by canonical id.
// Falls back to whichever language has it when the preferred language does not.
let (item, effective_lang) = candidate_langs.iter().find_map(|&cand| {
let idx = load_content_index(&kind, cand).ok()?;
let it = idx.items.iter().find(|i| i.published && i.id == canonical_id)?.clone();
Some((it, cand))
})?;
// Use item.slug (language-specific) for content loading, not the URL slug.
let raw_markdown = load_content_by_slug(&item.slug, &kind, effective_lang).unwrap_or_else(|e| {
tracing::warn!(item_slug = item.slug, kind = kind.as_str(), effective_lang, error = %e, "content body load failed");
String::new()
});
let body_html = if raw_markdown.is_empty() {
String::new()
} else {
match process_markdown_file(&raw_markdown) {
Ok((_meta, html)) => rewrite_content_image_paths(&html, &kind, effective_lang, &item.slug),
Err(e) => {
tracing::warn!(slug, kind = kind.as_str(), lang, error = %e, "markdown render failed");
raw_markdown
}
}
};
let post_html = render_post_page(&env, &item, &body_html, effective_lang);
// Graph sidebar: loaded at runtime from content_graph.json on the PV.
// Regenerate with: cargo run -p rustelo-content-graph-ssr --features gen --bin gen-content-graph
let sidebar_html = content_graph_ssr::render_sidebar(&canonical_id, effective_lang);
if sidebar_html.is_empty() {
return Some(post_html);
}
Some(format!(
r#"<div class="ds-container py-ds-6"><div class="lg:grid lg:grid-cols-[1fr_280px] lg:gap-8 space-y-6 lg:space-y-0"><div class="min-w-0">{post_html}</div>{sidebar_html}</div></div>"#,
))
}
// Content-kind resolution is config-driven via
// rustelo_core_lib::content_resolver::resolve_content_type_from_route, which reads
// the registered route table (site/config/routes.ncl). No kinds are hardcoded in
// this server.

View file

@ -0,0 +1,79 @@
//! File watcher that hot-reloads the Minijinja HTMX templates.
//!
//! Watches `HTMX_TEMPLATE_PATH` (the PV-delivered project template dir) and calls
//! [`crate::htmx_env::reset_htmx_env`] once a burst of changes settles, so a
//! template publish is picked up without a pod restart. Mirrors the rbac.ncl
//! watcher pattern; debounced so a multi-file publish triggers a single reset.
use std::path::PathBuf;
use std::time::Duration;
use notify::{Config, Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher};
/// Debounce window: events arriving within this gap are treated as one change.
const DEBOUNCE: Duration = Duration::from_millis(500);
/// Spawn the template watcher as a background task.
///
/// No-op (with a log) when `HTMX_TEMPLATE_PATH` is unset or its directory is
/// absent — e.g. when the server falls back to the baked framework templates.
pub fn spawn() {
let path = match std::env::var("HTMX_TEMPLATE_PATH") {
Ok(p) => PathBuf::from(p),
Err(_) => {
tracing::info!("HTMX_TEMPLATE_PATH unset — template hot-reload disabled (baked templates)");
return;
}
};
if !path.exists() {
tracing::info!(
path = %path.display(),
"HTMX_TEMPLATE_PATH missing — template hot-reload disabled"
);
return;
}
tokio::spawn(async move {
if let Err(e) = watch_templates(path).await {
tracing::error!(error = %e, "htmx template watcher terminated unexpectedly");
}
});
}
/// Watch `path` recursively; on each settled burst of changes, reset the env.
///
/// Completes only when the notify sender is dropped (watcher dead), which does
/// not happen during a server's lifetime.
async fn watch_templates(path: PathBuf) -> notify::Result<()> {
let (tx, mut rx) = tokio::sync::mpsc::channel::<()>(16);
let mut watcher = RecommendedWatcher::new(
move |res: notify::Result<Event>| {
if let Ok(ev) = res {
if matches!(
ev.kind,
EventKind::Modify(_) | EventKind::Create(_) | EventKind::Remove(_)
) {
// Capacity 16 with a prompt async drain — blocking_send never stalls.
let _ = tx.blocking_send(());
}
}
},
Config::default(),
)?;
watcher.watch(&path, RecursiveMode::Recursive)?;
tracing::info!(path = %path.display(), "htmx template watcher started");
while rx.recv().await.is_some() {
// Drain the rest of the burst before resetting once.
loop {
match tokio::time::timeout(DEBOUNCE, rx.recv()).await {
Ok(Some(())) => continue, // more events in the burst — keep draining
Ok(None) => return Ok(()), // sender dropped — watcher dead
Err(_) => break, // window elapsed — burst settled
}
}
crate::htmx_env::reset_htmx_env();
tracing::info!("htmx templates changed — Minijinja environment reset");
}
Ok(())
}

419
crates/server/src/lib.rs Normal file
View file

@ -0,0 +1,419 @@
//! Website server implementation
//!
//! This crate provides the Axum-based server for the website, integrating
//! with Rustelo's SSR capabilities and custom routing system.
#![recursion_limit = "512"]
#![allow(unused_variables, dead_code)]
#[cfg(feature = "wasm-client")]
pub mod app;
pub mod htmx_env;
pub mod htmx_grid;
pub mod htmx_pages;
#[cfg(feature = "template-watcher")]
pub mod htmx_template_watcher;
pub mod resources;
pub mod run;
#[cfg(feature = "wasm-client")]
pub mod server_fn_register;
pub mod shell;
pub mod theme;
use axum::{http::StatusCode, response::Html, Json};
use lazy_static::lazy_static;
use serde::{Deserialize, Serialize};
use std::sync::{Arc, Mutex};
// Note: AppComponent will be provided by the shell component
// Top-level generated includes: server_config.rs + server_routing.rs (core, no Leptos deps)
include!(concat!(env!("OUT_DIR"), "/server_top_includes.rs"));
// Leptos page dispatch — only available when website-pages / wasm-client is active
#[cfg(feature = "wasm-client")]
include!(concat!(env!("OUT_DIR"), "/server_routing_leptos.rs"));
/// Resolves a URL path to the base component name (without "Page" suffix).
/// Wraps the private generated `get_component_for_path` so it's accessible
/// from child modules (e.g., `shell.rs`).
pub(crate) fn resolve_component_for_path(path: &str) -> &'static str {
get_component_for_path(path)
}
pub mod generated {
// generated_routes.rs: RouteComponent enum from site/config/index.ncl
include!(concat!(env!("OUT_DIR"), "/server_generated_mod_includes.rs"));
}
pub mod config_constants {
// config_constants.rs (env-var helpers) + ncl_database_constants.rs + ncl_path_constants.rs
include!(concat!(env!("OUT_DIR"), "/server_config_mod_includes.rs"));
}
// TODO: Re-enable when content types are properly configured
// pub mod content_kinds {
// include!(concat!(env!("OUT_DIR"), "/content_kinds_generated.rs"));
// }
// Legacy: Old resource_registry module (disabled - now using resources.rs module)
// pub mod resources {
// include!(concat!(env!("OUT_DIR"), "/resource_registry.rs"));
// }
/// Log entry structure for browser console capture
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LogEntry {
pub timestamp: String,
pub level: String,
pub message: String,
#[serde(skip)]
pub args: Vec<String>,
}
/// Request structure for log endpoint
#[derive(Debug, Deserialize)]
pub struct LogRequest {
pub logs: Vec<LogEntry>,
}
lazy_static! {
/// Global log storage (in-memory for now)
pub static ref LOG_STORE: Arc<Mutex<Vec<LogEntry>>> = Arc::new(Mutex::new(Vec::new()));
}
/// Handle incoming browser console logs
pub async fn handle_logs(
Json(payload): Json<LogRequest>,
) -> Result<Json<serde_json::Value>, StatusCode> {
let log_count = payload.logs.len();
match LOG_STORE.lock() {
Ok(mut logs) => {
logs.extend(payload.logs);
// Keep only last 1000 logs to avoid memory growth
let current_len = logs.len();
if current_len > 1000 {
logs.drain(0..current_len - 1000);
}
tracing::debug!(
"📝 Received {} log entries, total: {}",
log_count,
logs.len()
);
Ok(Json(serde_json::json!({ "status": "ok" })))
}
Err(e) => {
tracing::error!("Failed to lock log store: {}", e);
Err(StatusCode::INTERNAL_SERVER_ERROR)
}
}
}
/// Get all captured logs
pub async fn get_logs() -> Result<Json<Vec<LogEntry>>, StatusCode> {
match LOG_STORE.lock() {
Ok(logs) => Ok(Json(logs.clone())),
Err(e) => {
tracing::error!("Failed to lock log store: {}", e);
Err(StatusCode::INTERNAL_SERVER_ERROR)
}
}
}
/// Serve the logs viewer page
pub async fn logs_page() -> Html<&'static str> {
Html(
r#"
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Browser Console Logs Viewer</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Monaco', 'Courier New', monospace;
background: #1e1e1e;
color: #d4d4d4;
padding: 20px;
}
.container {
max-width: 1200px;
margin: 0 auto;
}
header {
margin-bottom: 20px;
}
h1 {
font-size: 24px;
margin-bottom: 10px;
color: #4ec9b0;
}
.controls {
display: flex;
gap: 10px;
margin-bottom: 20px;
}
button {
padding: 8px 16px;
background: #0e639c;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
}
button:hover {
background: #1177bb;
}
.filter-buttons button {
padding: 6px 12px;
font-size: 12px;
background: #2d2d2d;
color: #d4d4d4;
border: 1px solid #555;
}
.filter-buttons button.active {
background: #0e639c;
color: white;
border-color: #0e639c;
}
.stats {
background: #252526;
padding: 12px;
border-radius: 4px;
margin-bottom: 20px;
font-size: 13px;
border-left: 4px solid #4ec9b0;
}
.stats-item {
display: inline-block;
margin-right: 20px;
}
#logs-container {
background: #1e1e1e;
border: 1px solid #3e3e42;
border-radius: 4px;
overflow: auto;
max-height: 600px;
padding: 0;
}
.log-entry {
padding: 10px 15px;
border-bottom: 1px solid #3e3e42;
font-size: 12px;
line-height: 1.4;
display: flex;
gap: 10px;
}
.log-entry:hover {
background: #2d2d30;
}
.log-timestamp {
color: #858585;
flex-shrink: 0;
min-width: 200px;
}
.log-level {
flex-shrink: 0;
min-width: 60px;
font-weight: bold;
}
.log-level.LOG {
color: #6a9955;
}
.log-level.ERROR {
color: #f48771;
}
.log-level.WARN {
color: #dcdcaa;
}
.log-level.INFO {
color: #4ec9b0;
}
.log-level.DEBUG {
color: #9cdcfe;
}
.log-message {
flex: 1;
word-break: break-word;
white-space: pre-wrap;
}
.empty-state {
text-align: center;
padding: 40px 20px;
color: #858585;
}
.footer {
margin-top: 20px;
text-align: center;
font-size: 12px;
color: #858585;
}
</style>
</head>
<body>
<div class="container">
<header>
<h1>🔍 Browser Console Logs</h1>
<p>Real-time capture of browser console output</p>
</header>
<div class="stats" id="stats">
<div class="stats-item">Total Logs: <strong id="total-count">0</strong></div>
<div class="stats-item">Errors: <strong id="error-count" style="color: #f48771;">0</strong></div>
<div class="stats-item">Warnings: <strong id="warn-count" style="color: #dcdcaa;">0</strong></div>
<div class="stats-item">Last Updated: <strong id="last-update">Never</strong></div>
</div>
<div class="controls">
<button onclick="clearLogs()">Clear All</button>
<button onclick="toggleAutoRefresh()" id="auto-refresh-btn">Auto-Refresh: ON</button>
<div class="filter-buttons" style="display: flex; gap: 5px;">
<button class="active" onclick="filterLogs('all')" data-filter="all">All</button>
<button onclick="filterLogs('ERROR')" data-filter="ERROR">Errors</button>
<button onclick="filterLogs('WARN')" data-filter="WARN">Warnings</button>
<button onclick="filterLogs('LOG')" data-filter="LOG">Logs</button>
</div>
</div>
<div id="logs-container">
<div class="empty-state">
<p>Waiting for console output...</p>
<p style="font-size: 11px; margin-top: 10px;">Logs will appear here as they are captured</p>
</div>
</div>
<div class="footer">
<p>This page captures all browser console output in real-time. Refresh the page to continue monitoring.</p>
</div>
</div>
<script>
let autoRefresh = true;
let currentFilter = 'all';
let allLogs = [];
async function fetchLogs() {
try {
const response = await fetch('/api/logs');
if (!response.ok) {
console.error('Failed to fetch logs:', response.status);
return;
}
allLogs = await response.json();
renderLogs();
updateStats();
updateTimestamp();
} catch (e) {
console.error('Error fetching logs:', e);
}
}
function renderLogs() {
const container = document.getElementById('logs-container');
const filteredLogs = allLogs.filter(log => {
return currentFilter === 'all' || log.level === currentFilter;
});
if (filteredLogs.length === 0) {
container.innerHTML = '<div class="empty-state"><p>No logs match the current filter</p></div>';
return;
}
container.innerHTML = filteredLogs.map(log => `
<div class="log-entry">
<span class="log-timestamp">${new Date(log.timestamp).toLocaleTimeString()}</span>
<span class="log-level ${log.level}">${log.level}</span>
<span class="log-message">${escapeHtml(log.message)}</span>
</div>
`).join('');
// Auto-scroll to bottom
container.scrollTop = container.scrollHeight;
}
function updateStats() {
document.getElementById('total-count').textContent = allLogs.length;
document.getElementById('error-count').textContent = allLogs.filter(l => l.level === 'ERROR').length;
document.getElementById('warn-count').textContent = allLogs.filter(l => l.level === 'WARN').length;
}
function updateTimestamp() {
const now = new Date();
document.getElementById('last-update').textContent = now.toLocaleTimeString();
}
function clearLogs() {
if (confirm('Clear all logs?')) {
allLogs = [];
renderLogs();
updateStats();
}
}
function toggleAutoRefresh() {
autoRefresh = !autoRefresh;
const btn = document.getElementById('auto-refresh-btn');
btn.textContent = `Auto-Refresh: ${autoRefresh ? 'ON' : 'OFF'}`;
btn.style.background = autoRefresh ? '#0e639c' : '#2d2d2d';
}
function filterLogs(level) {
currentFilter = level;
document.querySelectorAll('.filter-buttons button').forEach(btn => {
btn.classList.toggle('active', btn.dataset.filter === level);
});
renderLogs();
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// Initial load
fetchLogs();
// Auto-refresh every 1 second if enabled
setInterval(() => {
if (autoRefresh) {
fetchLogs();
}
}, 1000);
</script>
</body>
</html>
"#,
)
}

32
crates/server/src/main.rs Normal file
View file

@ -0,0 +1,32 @@
//! Website server binary entry point
use clap::Parser;
use rustelo_htmx_server::run::run_main;
#[derive(Parser)]
#[command(name = "website")]
struct Args {
/// Path to the site NCL configuration file.
/// Falls back to NCL_CONFIG_PATH, then SITE_CONFIG_PATH env vars.
#[arg(long, env = "NCL_CONFIG_PATH")]
config: Option<std::path::PathBuf>,
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
let args = Args::parse();
let config = args
.config
.or_else(|| std::env::var("SITE_CONFIG_PATH").ok().map(std::path::PathBuf::from))
.unwrap_or_else(|| {
panic!(
"\n\nNo site configuration path provided.\n\
Set one of:\n\
\n --config <path> (CLI argument)\
\n NCL_CONFIG_PATH=<path> (env var)\
\n SITE_CONFIG_PATH=<path> (env var)\n"
)
});
// Propagate for runtime resource loading (e.g., FooterLoader, MenuLoader)
std::env::set_var("NCL_CONFIG_PATH", &config);
run_main()
}

View file

@ -0,0 +1,261 @@
//! Resource registration and initialization
//!
//! This module handles the registration of all website resources (menus, footers,
//! themes, and FTL translations) with the Rustelo core library's registration system.
//!
//! Resources come from:
//! - config/menus/*.toml
//! - config/footers/*.toml
//! - config/themes/*.toml
//! - content/i18n/*.ftl
//!
//! The generated ResourceContributor is created at build-time by build.rs
// Include the generated ResourceContributor implementation
include!(concat!(env!("OUT_DIR"), "/resource_contributor.rs"));
/// Register all website resources with the Rustelo core library
///
/// This function should be called at server startup, before any resources are accessed.
///
/// # Errors
///
/// Returns an error if registration with the core library fails
pub fn register_resources() -> Result<(), Box<dyn std::error::Error>> {
use rustelo_core_lib::register_contributor;
// Register the website's resources with the core library
register_contributor(&WebsiteResourceContributor)?;
eprintln!("✅ Website resources registered successfully");
Ok(())
}
/// Register content kinds from compile-time constants generated from site/config/index.ncl.
///
/// `crate::config_constants::CONTENT_TYPES` is produced by `server/build.rs` at compile time.
/// No file I/O at runtime — the TOML fallback (`content-kinds.toml`) is no longer needed.
fn register_content_kinds() -> Result<(), Box<dyn std::error::Error>> {
use rustelo_core_lib::{ContentConfig, ContentKindRegistry};
// Resolve content base directory: SITE_CONTENT_PATH env var takes priority,
// otherwise walk ancestors until site/content/ is found.
let content_base = if let Ok(p) = std::env::var("SITE_CONTENT_PATH") {
std::path::PathBuf::from(p)
} else {
let cwd = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
std::iter::once(cwd.as_path())
.chain(cwd.ancestors())
.find(|p| p.join("site").join("content").exists())
.unwrap_or(&cwd)
.join("site")
.join("content")
};
let mut registry = ContentKindRegistry::new();
for &(name, directory) in crate::config_constants::CONTENT_TYPES {
let locales_path = content_base.join(directory);
registry.register(
name.to_string(),
ContentConfig {
name: name.to_string(),
directory: directory.to_string(),
enabled: true,
is_default: None,
features: Some(rustelo_core_lib::ContentFeatures::default()),
locales_path,
},
);
}
rustelo_core_lib::register_content_kind_registry(registry);
Ok(())
}
/// Inject NCL-derived database settings into the process environment.
///
/// These values come from `site/config.ncl [database]`, embedded at compile time.
/// Priority is: shell env var > .env DATABASE_URL > NCL defaults.
///
/// Must be called before `Config::load()` (i.e. before `rustelo_server::run::run_server()`).
fn inject_ncl_database_env() {
use crate::config_constants::{
NCL_DATABASE_CONNECT_TIMEOUT, NCL_DATABASE_CREATE_IF_MISSING, NCL_DATABASE_IDLE_TIMEOUT,
NCL_DATABASE_MAX_CONNECTIONS, NCL_DATABASE_MAX_LIFETIME, NCL_DATABASE_MIN_CONNECTIONS,
NCL_DATABASE_URL,
};
// DATABASE_URL: .env wins (dotenv not yet run here, so only shell-exported vars are present).
// Config::load() calls dotenv() which sets DATABASE_URL from .env (if not already in shell).
// NCL_DATABASE_URL is a lower-priority fallback checked in Config::apply_env_overrides().
if std::env::var("DATABASE_URL").is_err() {
std::env::set_var("NCL_DATABASE_URL", NCL_DATABASE_URL);
}
std::env::set_var(
"NCL_DATABASE_CREATE_IF_MISSING",
if NCL_DATABASE_CREATE_IF_MISSING {
"true"
} else {
"false"
},
);
std::env::set_var(
"NCL_DATABASE_MAX_CONNECTIONS",
NCL_DATABASE_MAX_CONNECTIONS.to_string(),
);
std::env::set_var(
"NCL_DATABASE_MIN_CONNECTIONS",
NCL_DATABASE_MIN_CONNECTIONS.to_string(),
);
std::env::set_var(
"NCL_DATABASE_CONNECT_TIMEOUT",
NCL_DATABASE_CONNECT_TIMEOUT.to_string(),
);
std::env::set_var(
"NCL_DATABASE_IDLE_TIMEOUT",
NCL_DATABASE_IDLE_TIMEOUT.to_string(),
);
std::env::set_var(
"NCL_DATABASE_MAX_LIFETIME",
NCL_DATABASE_MAX_LIFETIME.to_string(),
);
}
/// Initialize all resources (registration + config loading)
///
/// This is the main initialization function that should be called at server startup.
/// It performs:
/// 1. Registration of the WebsiteResourceContributor with the core library
/// 2. Loading of configuration files into the resource registries
/// 3. Registration of content kinds from `site/content/content-kinds.toml`
///
/// # Errors
///
/// Returns an error if either registration or config loading fails
pub fn initialize() -> Result<(), Box<dyn std::error::Error>> {
eprintln!("🔄 Initializing resources...");
// Inject NCL database config as env vars so Config::load() picks them up.
inject_ncl_database_env();
// V2 renderer is profile-aware: receives RenderingProfile + htmx_extensions
// from the framework dispatcher, so a single flip in rendering.ncl switches
// between Leptos-hydration and HTMX-SSR without any code change.
rustelo_server::run::register_shell_renderer_v2(crate::run::render_shell_v2);
// Fragment renderer: returns <main> only for non-boosted HX-Request responses.
rustelo_server::run::register_fragment_renderer(crate::run::render_fragment);
// Fragment environment: all framework HTMX fragment renderers use this to
// render via Minijinja templates instead of inline format strings.
rustelo_pages_htmx::register_fragment_env(crate::htmx_env::htmx_env());
// Content grid renderer for /api/htmx/content/{kind} (F-08).
rustelo_server::api::register_content_grid_renderer(crate::htmx_grid::render_content_grid);
// Apply the rendering profile declared in site/config/rendering.ncl. When
// the file is absent or omits the [rendering] table the framework keeps
// its default (LeptosHydration), so this is back-compat by construction.
install_rendering_profile();
// Register resources
register_resources()?;
// Load resources from configuration files
rustelo_core_lib::load_resources_from_config()?;
// Register content kinds so SimpleContentGrid finds the registry
register_content_kinds()?;
eprintln!("✅ Resources initialized successfully");
Ok(())
}
/// Load the `[rendering]` table from `site/config/rendering.ncl` (when present)
/// and apply it to the framework-level rendering profile registry.
///
/// Resolution order:
/// 1. `RENDERING_CONFIG_PATH` env var (absolute or relative).
/// 2. `NCL_CONFIG_PATH`'s sibling `rendering.ncl`.
/// 3. `site/config/rendering.ncl` resolved against CWD.
///
/// Failure modes are intentionally non-fatal: missing file, parse error, or
/// absent `[rendering]` table all log a single line and keep the framework
/// default (`RenderingProfile::LeptosHydration`). This keeps the migration to
/// htmx-ssr opt-in even when the loader is wired.
fn install_rendering_profile() {
let Some(ncl_path) = locate_rendering_ncl() else {
eprintln!(
"🎨 Rendering profile: no rendering.ncl located → default LeptosHydration"
);
return;
};
match rustelo_server::run::install_workspace_rendering_from_file(&ncl_path) {
Ok(Some(cfg)) => {
eprintln!(
"🎨 Rendering profile: default={} extensions={:?}",
cfg.default_profile, cfg.htmx_extensions
);
}
Ok(None) => {
eprintln!(
"🎨 Rendering profile: rendering.ncl has no [rendering] table → default LeptosHydration"
);
}
Err(e) => {
eprintln!(
"⚠️ Rendering profile: load failed ({e}); using default LeptosHydration"
);
}
}
}
fn locate_rendering_ncl() -> Option<std::path::PathBuf> {
if let Ok(explicit) = std::env::var("RENDERING_CONFIG_PATH") {
let p = std::path::PathBuf::from(explicit);
if p.exists() {
return Some(p);
}
}
if let Ok(base) = std::env::var("NCL_CONFIG_PATH") {
let p = std::path::PathBuf::from(&base);
let candidate = if p.is_dir() {
p.join("rendering.ncl")
} else if let Some(parent) = p.parent() {
parent.join("rendering.ncl")
} else {
p.clone()
};
if candidate.exists() {
return Some(candidate);
}
}
std::env::current_dir()
.ok()
.map(|c| c.join("site/config/rendering.ncl"))
.filter(|p| p.exists())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_resource_contributor_exists() {
let _contributor = WebsiteResourceContributor;
// Just verify it can be instantiated
}
#[test]
fn test_themes_are_loaded() {
let contributor = WebsiteResourceContributor;
let themes = contributor.contribute_themes();
// Verify at least some themes were loaded (should have default, dark, etc.)
assert!(!themes.is_empty(), "At least one theme should be loaded");
}
#[test]
fn test_menus_are_optional() {
let contributor = WebsiteResourceContributor;
let menus = contributor.contribute_menus();
// Menus are optional, so we just check the method works
assert!(menus.is_empty() || !menus.is_empty());
}
}

237
crates/server/src/run.rs Normal file
View file

@ -0,0 +1,237 @@
//! # RUSTELO Server Binary
//!
//! <div align="center">
//! <img src="../logos/rustelo_dev-logo-h.svg" alt="RUSTELO" width="300" />
//! </div>
//!
//! Main server executable for the RUSTELO web application framework.
//!
//! ## Overview
//!
//! This is the main entry point for the RUSTELO server application. It initializes all services,
//! sets up routing, configures middleware, and starts the web server with support for:
//!
//! - **Authentication & Authorization** - JWT tokens, OAuth2, 2FA, RBAC
//! - **Content Management** - Markdown processing, media handling, database storage
//! - **Email Services** - Multi-provider email with templating
//! - **Security** - CSRF protection, rate limiting, HTTPS/TLS
//! - **Performance** - Metrics collection, connection pooling, caching
//!
//! ## Features
//!
//! ### Core Features (Always Available)
//! - **HTTP Server** - Fast Axum-based web server
//! - **Static Files** - Efficient static file serving
//! - **Health Checks** - Server health monitoring
//! - **CSRF Protection** - Cross-site request forgery protection
//! - **Rate Limiting** - Request rate limiting and throttling
//! - **Security Headers** - Comprehensive security headers
//!
//! ### Optional Features (Feature-Gated)
//! - **`auth`** - Complete authentication system with JWT, OAuth2, 2FA
//! - **`content-db`** - Database-backed content management
//! - **`email`** - Email sending with multiple providers
//! - **`tls`** - HTTPS/TLS encryption support
//! - **`metrics`** - Performance monitoring and metrics collection
//!
//! ## Usage
//!
//! ```bash
//! # Start with default configuration
//! cargo run
//!
//! # Start with specific features
//! cargo run --features "auth,content-db,email,tls"
//!
//! # Start with custom configuration
//! RUSTELO_CONFIG=custom.toml cargo run
//! ```
//!
//! ## Configuration
//!
//! The server can be configured through:
//! - Environment variables (prefixed with `RUSTELO_`)
//! - TOML configuration files
//! - Command-line arguments
//! - Default values with sensible fallbacks
//!
//! ### Example Configuration
//!
//! ```toml
//! [server]
//! host = "127.0.0.1"
//! port = 3030
//! protocol = "https"
//!
//! [database]
//! url = "postgresql://user:pass@localhost/rustelo"
//! max_connections = 10
//!
//! [auth]
//! jwt_secret = "your-secret-key"
//! jwt_expiration_hours = 24
//! enable_2fa = true
//!
//! [email]
//! provider = "smtp"
//! from_address = "noreply@rustelo.dev"
//! ```
//!
//! ## Architecture
//!
//! The server follows a modular architecture with clear separation of concerns:
//!
//! ### Application State
//! - **Leptos Integration** - SSR options and hydration
//! - **Security Services** - CSRF protection and rate limiting
//! - **Authentication** - JWT service and user management
//! - **Content Services** - Content processing and storage
//! - **Email Services** - Email templating and delivery
//! - **Metrics** - Performance monitoring and collection
//!
//! ### Request Handling
//! - **Routing** - API endpoints and static file serving
//! - **Middleware** - Authentication, CORS, security headers
//! - **Error Handling** - Comprehensive error responses
//! - **Logging** - Structured logging with tracing
//!
//! ## Security
//!
//! The server implements multiple security layers:
//!
//! - **Memory Safety** - Rust's ownership system prevents vulnerabilities
//! - **Input Validation** - Comprehensive validation of all inputs
//! - **CSRF Protection** - Token-based CSRF protection
//! - **Rate Limiting** - Request throttling and abuse prevention
//! - **Security Headers** - HSTS, CSP, X-Frame-Options, etc.
//! - **TLS/HTTPS** - End-to-end encryption (when enabled)
//!
//! ## Performance
//!
//! Optimized for high performance:
//!
//! - **Async/Await** - Non-blocking I/O with Tokio
//! - **Connection Pooling** - Efficient database connections
//! - **Static File Caching** - Optimized static asset serving
//! - **Request Deduplication** - Efficient request handling
//! - **Metrics Collection** - Performance monitoring and optimization
//!
//! ## Monitoring
//!
//! Built-in monitoring capabilities:
//!
//! - **Health Endpoints** - `/health` for service monitoring
//! - **Metrics Collection** - Prometheus-compatible metrics
//! - **Structured Logging** - JSON-formatted logs for analysis
//! - **Error Tracking** - Comprehensive error reporting
//!
//! ## License
//!
//! This project is licensed under the MIT License - see the [LICENSE](https://github.com/yourusername/rustelo/blob/main/LICENSE) file for details.
// #![allow(unused_variables)]
// #![recursion_limit = "512"]
use crate::shell::{fragment_with_path, shell_with_path};
use rustelo_server::rendering::RenderingProfile;
use rustelo_server::run::SiteOptions;
/// V2 shell renderer — profile-aware, registered via `register_shell_renderer_v2`.
///
/// Receives the resolved `RenderingProfile` and htmx extension list from the
/// framework dispatcher so the shell can emit the correct scripts for each mode.
pub fn render_shell_v2(
site_options: SiteOptions,
path: Option<String>,
profile: RenderingProfile,
htmx_extensions: Vec<String>,
) -> String {
shell_with_path(site_options, path, profile, htmx_extensions)
}
/// Fragment renderer — returns just `<main>` for non-boosted HTMX requests.
///
/// Registered via `register_fragment_renderer`. The framework calls this when
/// `HX-Request: true` and `HX-Boosted` is absent/false.
pub fn render_fragment(
_site_options: SiteOptions,
resolution: &rustelo_core_lib::routing::engine::RouteResolution,
) -> String {
fragment_with_path(&resolution.path, &resolution.language)
}
/// Legacy v1 compat — delegates to `render_shell_v2` with workspace profile.
pub fn render_ssr_with_context(site_options: SiteOptions, path: Option<String>) -> String {
use rustelo_server::rendering::workspace_rendering_config;
let cfg = workspace_rendering_config();
render_shell_v2(
site_options,
path,
cfg.default_profile,
cfg.htmx_extensions.clone(),
)
}
/// Render SSR using routing engine's resolution (profile taken from resolution).
pub fn render_ssr_with_route_resolution(
site_options: SiteOptions,
resolution: &rustelo_core_lib::routing::engine::RouteResolution,
) -> String {
use rustelo_server::rendering::workspace_rendering_config;
let cfg = workspace_rendering_config();
tracing::info!(
"render_ssr_with_route_resolution path='{}' profile={}",
resolution.path,
resolution.rendering_profile
);
render_shell_v2(
site_options,
Some(resolution.path.clone()),
resolution.rendering_profile,
cfg.htmx_extensions.clone(),
)
}
/// Run the Axum server - simplified version using rustelo_server directly
pub async fn run_server() -> Result<(), Box<dyn std::error::Error>> {
// Hot-reload htmx templates on HTMX_TEMPLATE_PATH change (no restart). The
// rbac.ncl watcher is spawned inside rustelo_server behind `rbac-watcher`.
#[cfg(feature = "template-watcher")]
crate::htmx_template_watcher::spawn();
// Use the rustelo_server run_server function directly
// This provides all the functionality but through rustelo_server
rustelo_server::run::run_server().await
}
/// Main entry point for the Axum/Leptos server.
///
/// Uses `tokio::runtime::LocalRuntime` (tokio_unstable) so that `spawn_local`
/// is valid from *any* task context — including connection tasks that
/// `axum::serve` spawns internally via `tokio::spawn`.
///
/// The previous `current_thread + LocalSet::run_until` pattern broke because
/// `LocalSet` only activates its local context while *its own* future is being
/// polled; tasks spawned by `tokio::spawn` inside `axum::serve` escape that
/// scope and call `spawn_local` → panic. `LocalRuntime` makes the entire
/// runtime "local", eliminating the scope gap.
pub fn run_main() -> Result<(), Box<dyn std::error::Error>> {
rustelo_server::utils::init();
rustelo_server::utils::paths::set_migrations_dir(crate::config_constants::SITE_MIGRATIONS_PATH);
rustelo_server::utils::paths::set_email_templates_dir(
crate::config_constants::SITE_EMAIL_TEMPLATES_PATH,
);
crate::resources::initialize()?;
// Linker-safe explicit registration of every server function. The
// inventory-based auto-registration is unreliable in release/container
// builds; this loop guarantees `handle_server_fns` resolves every
// `POST /api/<fn><hash>` route.
#[cfg(feature = "wasm-client")]
crate::server_fn_register::register_all();
let rt = tokio::runtime::LocalRuntime::new()?;
rt.block_on(run_server())
}

View file

@ -0,0 +1,83 @@
//! Explicit server-function registration for the website binary.
//!
//! Auto-registration via `inventory::submit!` is dropped by the linker in some
//! release configurations (LTO, `--gc-sections`, container builds), which
//! makes `handle_server_fns` return `400 "Could not find a server function at
//! the route /api/<fn><hash>"` even though the function exists in the
//! dependency graph. Registering each type explicitly at startup bypasses the
//! linker's dead-code analysis and guarantees every server function is
//! reachable.
#![cfg(not(target_arch = "wasm32"))]
use leptos::server_fn::axum::{register_explicit, server_fn_paths};
use rustelo_components_leptos::activities::server_fns::{
CheckActivityAccess, GetQuestionnaireDefinition, RecordActivityCompletion,
SubmitInternalQuestionnaire,
};
use rustelo_components_leptos::auth::server_fns::{
AcceptGdpr, AddBookmark, CreateNote, CreateServiceToken, DeleteAccount, DeleteMessage,
DeleteNote, GetBookmarks, GetMessages, GetNotes, GetResources, GetSessionUser, IsBookmarked,
ListServiceTokens, Logout, LogoutAll, MarkMessageRead, RefreshToken, RemoveBookmark,
RequestOtp, RevokeServiceToken, UpdateDisplayName, UpdateNote, VerifyOtp,
};
use rustelo_components_leptos::contact::server_fns::SendContactForm;
use rustelo_pages_leptos::nav_log::server_fns::TrackPageView;
/// Register every server function the website exposes.
///
/// Must run on the main thread before the Axum router starts; the registry it
/// writes to is consulted by `leptos_axum::handle_server_fns` on every
/// `POST /api/<fn><hash>` request.
pub fn register_all() {
// rustelo_pages_leptos
register_explicit::<TrackPageView>();
// rustelo_components_leptos::contact
register_explicit::<SendContactForm>();
// rustelo_components_leptos::auth
register_explicit::<RequestOtp>();
register_explicit::<VerifyOtp>();
register_explicit::<AcceptGdpr>();
register_explicit::<Logout>();
register_explicit::<LogoutAll>();
register_explicit::<RefreshToken>();
register_explicit::<GetSessionUser>();
register_explicit::<DeleteAccount>();
register_explicit::<UpdateDisplayName>();
register_explicit::<GetBookmarks>();
register_explicit::<AddBookmark>();
register_explicit::<RemoveBookmark>();
register_explicit::<IsBookmarked>();
register_explicit::<GetMessages>();
register_explicit::<MarkMessageRead>();
register_explicit::<DeleteMessage>();
register_explicit::<GetNotes>();
register_explicit::<CreateNote>();
register_explicit::<UpdateNote>();
register_explicit::<DeleteNote>();
register_explicit::<GetResources>();
register_explicit::<ListServiceTokens>();
register_explicit::<CreateServiceToken>();
register_explicit::<RevokeServiceToken>();
// rustelo_components_leptos::activities
register_explicit::<CheckActivityAccess>();
register_explicit::<RecordActivityCompletion>();
register_explicit::<GetQuestionnaireDefinition>();
register_explicit::<SubmitInternalQuestionnaire>();
let paths: Vec<String> = server_fn_paths()
.map(|(p, m)| format!("{m} {p}"))
.collect();
tracing::info!(
target: "server_fn_register",
count = paths.len(),
"explicit server-function registration completed"
);
for entry in &paths {
tracing::info!(target: "server_fn_register", route = %entry, "registered");
}
}

View file

@ -0,0 +1,77 @@
//! Head fragments shared by both shells: CSS links, JS scripts, i18n data,
//! per-page content data. All return `String` so callers can splice them into
//! a `<head>` built by `format!`.
use rustelo_web::rustelo_core_lib::registration::get_parsed_ftl_for_language;
use rustelo_web::rustelo_core_lib::AssetMode;
use html_escape::encode_double_quoted_attribute;
/// Resolves the configured asset bundle (source vs bundled) and returns the
/// (css_paths, js_paths) slices.
pub fn asset_paths() -> (&'static [&'static str], &'static [&'static str]) {
let mode = AssetMode::default_for_environment();
if mode.is_source() {
(crate::source_mode::CSS, crate::source_mode::JS)
} else {
(crate::bundled_mode::CSS, crate::bundled_mode::JS)
}
}
/// `<link rel="stylesheet">` tags concatenated as a single string.
pub fn stylesheet_links() -> String {
let (css_paths, _) = asset_paths();
let mut out = String::new();
for href in css_paths {
out.push_str(&format!(r#"<link rel="stylesheet" href="{href}">"#));
}
out
}
/// Non-defer `<script>` tags for the active asset bundle.
pub fn bundle_scripts() -> String {
let (_, js_paths) = asset_paths();
let mut out = String::new();
for src in js_paths {
out.push_str(&format!(r#"<script src="{src}"></script>"#));
}
out
}
/// `<script id="i18n-data" type="application/json">` block embedding the FTL
/// payload for the active language.
pub fn i18n_script(language: &str) -> String {
let json = get_parsed_ftl_for_language(language)
.and_then(|lang_map| serde_json::to_string(&lang_map).ok())
.map(|s| s.replace("</", "\\/"))
.unwrap_or_default();
format!(
r#"<script id="i18n-data" type="application/json" data-lang="{}">{json}</script>"#,
encode_double_quoted_attribute(language)
)
}
/// Per-page content index JSON block, when the path maps to a content kind.
/// Returns empty string for non-content pages so callers can splice unconditionally.
pub fn content_data_script(path: &str, language: &str) -> String {
let Some(content_type) = crate::get_content_type_for_path(path) else {
return String::new();
};
let Ok(index) = rustelo_core_lib::load_content_index(content_type, language) else {
return String::new();
};
let Ok(json) = serde_json::to_string(&index) else {
return String::new();
};
let safe_json = json.replace("</", "\\/");
format!(
r#"<script id="content-data-{ct}-{lang}" type="application/json">{safe_json}</script>"#,
ct = encode_double_quoted_attribute(content_type),
lang = encode_double_quoted_attribute(language)
)
}
/// Standard `<meta>` tags every shell emits (charset, viewport).
pub fn baseline_meta() -> &'static str {
r#"<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">"#
}

View file

@ -0,0 +1,535 @@
//! HTMX-SSR shell. Emits a full document with `hx-boost` body, vendored htmx
//! runtime + declared extensions, and the HTMX peer component tree from
//! `rustelo_components_htmx`. No Leptos reactive constructs (no signals, no
//! `on:click`), so the page works without WASM.
use rustelo_server::run::SiteOptions;
use super::{common, seo};
use html_escape::encode_double_quoted_attribute;
use std::collections::HashMap;
use std::sync::OnceLock;
// ── Runtime menu/footer loaded from site/config/routes.ncl at startup ─────────
// Keyed by language. Populated once via load_nav_from_routes(); no rebuild needed
// when routes.ncl changes — only a server restart.
#[derive(Debug, Clone)]
struct MenuItem {
label: String,
path: String,
order: i64,
}
static NAV_ITEMS: OnceLock<HashMap<String, Vec<MenuItem>>> = OnceLock::new();
static FOOTER_ITEMS: OnceLock<HashMap<String, Vec<MenuItem>>> = OnceLock::new();
#[derive(Debug, Clone, Default)]
struct FooterMeta {
company_desc: HashMap<String, String>,
copyright_text: HashMap<String, String>,
social_links: Vec<SocialLink>,
}
#[derive(Debug, Clone)]
struct SocialLink {
name: String,
url: String,
icon: String,
}
static FOOTER_META: OnceLock<FooterMeta> = OnceLock::new();
fn load_footer_meta() -> FooterMeta {
let json = match load_ncl_json(config_ncl_path("footer.ncl")) {
Some(v) => v,
None => return FooterMeta::default(),
};
let footer = match json.get("footer") {
Some(f) => f,
None => return FooterMeta::default(),
};
let mut meta = FooterMeta::default();
if let Some(obj) = footer.get("company_desc").and_then(|v| v.as_object()) {
for (k, v) in obj { meta.company_desc.insert(k.clone(), v.as_str().unwrap_or("").to_string()); }
}
if let Some(obj) = footer.get("copyright_text").and_then(|v| v.as_object()) {
for (k, v) in obj { meta.copyright_text.insert(k.clone(), v.as_str().unwrap_or("").to_string()); }
}
if let Some(arr) = footer.get("social_links").and_then(|v| v.as_array()) {
for s in arr {
meta.social_links.push(SocialLink {
name: s.get("name").and_then(|v| v.as_str()).unwrap_or("").to_string(),
url: s.get("url").and_then(|v| v.as_str()).unwrap_or("").to_string(),
icon: s.get("icon").and_then(|v| v.as_str()).unwrap_or("").to_string(),
});
}
}
meta
}
fn load_ncl_json(path: std::path::PathBuf) -> Option<serde_json::Value> {
match rustelo_config::format::load_config(&path) {
Ok(v) => Some(v),
Err(e) => {
tracing::warn!("htmx shell: load_config({}) failed: {e}", path.display());
None
}
}
}
fn routes_ncl_path() -> std::path::PathBuf {
rustelo_utils::routes_path().with_extension("ncl")
}
fn config_ncl_path(file: &str) -> std::path::PathBuf {
rustelo_utils::manifest_config_path().join(file)
}
fn parse_menu_items(use_in: &str) -> HashMap<String, Vec<MenuItem>> {
let mut map: HashMap<String, Vec<MenuItem>> = HashMap::new();
let Some(json) = load_ncl_json(routes_ncl_path()) else {
tracing::warn!("htmx shell: could not load routes.ncl — menu will be empty");
return map;
};
let routes = match json.get("routes").and_then(|r| r.as_array()) {
Some(r) => r.clone(),
None => return map,
};
for route in &routes {
let menu = match route.get("menu") { Some(m) => m, None => continue };
if !menu.get("enabled").and_then(|v| v.as_bool()).unwrap_or(false) { continue; }
let use_in_list: Vec<&str> = menu.get("use_in")
.and_then(|v| v.as_array())
.map(|a| a.iter().filter_map(|s| s.as_str()).collect())
.unwrap_or_default();
if !use_in_list.contains(&use_in) { continue; }
let order = menu.get("order").and_then(|v| v.as_i64()).unwrap_or(99);
let labels = menu.get("labels").and_then(|v| v.as_object());
let paths = route.get("paths").and_then(|v| v.as_object());
let langs: Vec<String> = labels
.map(|l| l.keys().cloned().collect())
.unwrap_or_default();
for lang in &langs {
let label = labels
.and_then(|l| l.get(lang))
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let path = paths
.and_then(|p| p.get(lang))
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
if path.is_empty() { continue; }
map.entry(lang.clone()).or_default().push(MenuItem { label, path, order });
}
}
for items in map.values_mut() {
items.sort_by_key(|i| i.order);
}
map
}
fn nav_items_for_lang(lang: &str) -> Vec<MenuItem> {
NAV_ITEMS
.get_or_init(|| parse_menu_items("header"))
.get(lang)
.cloned()
.unwrap_or_default()
}
fn footer_links_for_lang(lang: &str) -> Vec<MenuItem> {
FOOTER_ITEMS
.get_or_init(|| parse_menu_items("footer"))
.get(lang)
.cloned()
.unwrap_or_default()
}
fn render_footer(lang: &str) -> String {
use rustelo_pages_htmx::render_safe;
let env = crate::htmx_env::htmx_env();
let logo_cfg = crate::theme::load_logo_config();
let meta = FOOTER_META.get_or_init(load_footer_meta);
let logo = minijinja::context! {
show_in_footer => logo_cfg.show_in_footer,
has_dark_variant => logo_cfg.has_dark_variant(),
light_src => logo_cfg.light_src.clone(),
dark_src => logo_cfg.dark_src.clone(),
alt => logo_cfg.alt.clone(),
};
let footer_links = footer_links_for_lang(lang);
let sections: Vec<minijinja::Value> = if footer_links.is_empty() {
vec![]
} else {
let links: Vec<minijinja::Value> = footer_links.iter().map(|l| minijinja::context! {
href => l.path.clone(),
label => l.label.clone(),
is_external => false,
}).collect();
let title = "Legal";
vec![minijinja::context! { title => title, links => links }]
};
let social_links: Vec<minijinja::Value> = meta.social_links.iter().map(|s| {
let icon_html = match s.icon.as_str() {
ic if ic.contains("github") => r#"<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24"><path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0024 12c0-6.63-5.37-12-12-12z"/></svg>"#.to_string(),
ic if ic.contains("linkedin") => r#"<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24"><path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"/></svg>"#.to_string(),
other => format!(r#"<i class="{}" aria-hidden="true"></i>"#, encode_double_quoted_attribute(other)),
};
minijinja::context! { url => s.url.clone(), name => s.name.clone(), icon_html => icon_html }
}).collect();
let company_desc = meta.company_desc.get(lang).or_else(|| meta.company_desc.get("en")).cloned().unwrap_or_default();
let copyright_text = meta.copyright_text.get(lang).or_else(|| meta.copyright_text.get("en")).cloned().unwrap_or_default();
let ctx = minijinja::context! {
logo => logo,
company_desc => company_desc,
copyright_text => copyright_text,
sections => sections,
social_links => social_links,
social_title => if lang == "es" { "Redes Sociales" } else { "Connect" },
built_label => if lang == "es" { "Construido con" } else { "Built with" },
};
render_safe(&env, "partials/shell/footer.j2", ctx)
}
/// Build the cookie consent banner with FTL-localized text and a
/// language-resolved privacy policy link.
fn render_cookie_banner(language: &str) -> String {
use rustelo_web::rustelo_core_lib::i18n::{localize_url, t_for_language};
let t = |key: &str| t_for_language(language, key);
let labels = rustelo_components_htmx::overlays::CookieBannerLabels {
message: t("cookies-banner-text"),
policy_href: localize_url("/privacy", language),
policy_link: t("cookies-policy-link"),
policy_suffix: t("cookies-policy-suffix"),
manage_label: t("cookies-manage"),
reject_label: t("cookies-reject"),
accept_label: t("cookies-accept-all"),
};
rustelo_components_htmx::overlays::render_cookie_banner(true, &labels)
}
/// Render the full HTMX-SSR page as a complete HTML document.
///
/// `htmx_extensions` controls which vendored extensions get loaded and are
/// activated via the `hx-ext="..."` attribute on `<body>`. The set is
/// configured in `site/config/rendering.ncl`.
pub fn render(
_options: SiteOptions,
path: Option<String>,
htmx_extensions: Vec<String>,
) -> String {
let current_path = path.unwrap_or_default();
let language = shell_detect_language(&current_path);
let base_url = seo::site_base_url();
let page_seo = seo::build_page_seo(&current_path, &language, &base_url);
let head_tags = rustelo_seo::render_head_tags(&page_seo).unwrap_or_else(|err| {
tracing::error!("SEO render failed: {err}");
String::new()
});
let htmx_runtime = r#"<script src="/assets/htmx/htmx.min.js" defer></script>"#;
let mut htmx_ext_scripts = String::new();
for name in &htmx_extensions {
htmx_ext_scripts.push_str(&format!(
r#"<script src="/assets/htmx/ext/{}.js" defer></script>"#,
encode_double_quoted_attribute(name)
));
}
// Map lockfile `name` → htmx runtime `attribute`. Some extensions register
// under a different identifier than their package name: idiomorph ships
// its ext as `idiomorph.js` (the lockfile file/name) but registers as
// `morph` via `htmx.defineExtension("morph", ...)`. The hx-ext attribute
// must use the registered name or htmx silently ignores it.
fn name_to_hx_attribute(name: &str) -> &str {
match name {
"idiomorph" => "morph",
other => other,
}
}
let hx_ext_names: Vec<&str> = htmx_extensions
.iter()
.map(|n| name_to_hx_attribute(n.as_str()))
.collect();
// Re-init pipeline: loads after htmx so the afterSettle listener is in
// place when swaps happen. reinit-handlers.js registers handlers for
// highlight.js, theme, tag-filters and content-graph against any
// swapped region.
let reinit_scripts = r#"<script src="/js/htmx-reinit.js" defer></script><script src="/js/reinit-handlers.js" defer></script>"#;
let hx_ext_attr = if hx_ext_names.is_empty() {
String::new()
} else {
format!(
r#" hx-ext="{}""#,
encode_double_quoted_attribute(&hx_ext_names.join(","))
)
};
let is_dark = rustelo_server::run::current_request_headers()
.and_then(|h| {
h.get("cookie")?.to_str().ok().and_then(|raw| {
raw.split(';').find_map(|pair| {
let (k, v) = pair.trim().split_once('=')?;
(k.trim() == "theme").then(|| v.trim() == "dark")
})
})
})
.unwrap_or(false);
// Emit BOTH the theme class and the `data-theme` attribute for the active
// theme, for light as well as dark. The class drives Tailwind `.dark`
// utilities and the `.dark{}`/`html.light{}` token blocks; the attribute
// drives DaisyUI base tokens (`:root,[data-theme]{color:hsl(var(--bc))}`),
// whose light `--bc` only resolves under `[data-theme=light]`. Emitting it
// server-side makes the first paint correct with zero client JS — the
// theme-init.js shim becomes a redundant safeguard rather than load-bearing.
let theme_attrs = if is_dark {
r#" class="dark" data-theme="dark""#
} else {
r#" class="light" data-theme="light""#
};
let body_inner = render_body_inner(&current_path, &language);
let mut html = String::with_capacity(16 * 1024);
html.push_str("<!DOCTYPE html>");
html.push_str(&format!(
r#"<html lang="{}"{theme_attrs}>"#,
encode_double_quoted_attribute(&language)
));
html.push_str("<head>");
html.push_str(common::baseline_meta());
html.push_str(&head_tags);
html.push_str(&common::stylesheet_links());
for href in crate::htmx_mode::CSS {
html.push_str(&format!(r#"<link rel="stylesheet" href="{href}">"#));
}
html.push_str(htmx_runtime);
html.push_str(&htmx_ext_scripts);
html.push_str(reinit_scripts);
html.push_str(&common::bundle_scripts());
html.push_str(r#"<script src="/public/console-logger.js"></script>"#);
html.push_str(&common::i18n_script(&language));
html.push_str(&common::content_data_script(&current_path, &language));
html.push_str("</head>");
html.push_str(&format!(r#"<body hx-boost="true"{hx_ext_attr}>"#));
html.push_str(&body_inner);
html.push_str("</body></html>");
html
}
fn render_body_inner(current_path: &str, language: &str) -> String {
use rustelo_components_htmx::auth::render_login_button_for_lang;
use rustelo_components_htmx::navigation::{
render_lang_selector, render_theme_toggle, Theme,
};
let env = crate::htmx_env::htmx_env();
let logo_cfg = crate::theme::load_logo_config();
// Controls — rendered by the component layer (template-backed)
let current_theme = rustelo_server::run::current_request_headers()
.and_then(|h| {
h.get("cookie")?.to_str().ok().and_then(|raw| {
raw.split(';').find_map(|pair| {
let (k, v) = pair.trim().split_once('=')?;
(k.trim() == "theme").then(|| v.trim().to_string())
})
})
})
.map(|t| if t == "dark" { Theme::Dark } else { Theme::Light })
.unwrap_or(Theme::Light);
let theme_html = render_theme_toggle(current_theme);
let lang_html = render_lang_selector(language);
let login_label = if language == "es" { "Entrar" } else { "Sign in" };
let login_html = render_login_button_for_lang(login_label, language);
// Menu items loaded from site/config/routes.ncl at startup — no rebuild needed
let menu_items: Vec<minijinja::Value> = nav_items_for_lang(language).into_iter().map(|item| {
let is_active = item.path == current_path
|| (item.path != "/" && current_path.starts_with(&format!("{}/", item.path)));
minijinja::context! {
href => item.path,
label => item.label,
is_active => is_active,
is_external => false,
}
}).collect();
let logo = minijinja::context! {
show_in_nav => logo_cfg.show_in_nav,
has_dark_variant => logo_cfg.has_dark_variant(),
light_src => logo_cfg.light_src.clone(),
dark_src => logo_cfg.dark_src.clone(),
alt => logo_cfg.alt.clone(),
};
let nav_ctx = minijinja::context! {
logo => logo,
menu_items => menu_items,
theme_html => theme_html,
lang_html => lang_html,
login_html => login_html,
mobile_controls_label => if language == "es" { "Controles" } else { "Controls" },
};
let header_html = rustelo_pages_htmx::render_safe(&env, "partials/shell/nav.j2", nav_ctx);
let main_body = render_content_or_grid(current_path, language);
let footer = render_footer(language);
let main_and_footer = format!(
r##"<main id="main-content" class="max-w-7xl mx-auto py-2 sm:ds-container flex-grow page-content" hx-history-elt="">{main_body}</main>{footer}"##
);
let modal_slot = rustelo_components_htmx::overlays::render_modal_slot();
let toast_container = String::new();
let _ = rustelo_components_htmx::overlays::render_toast_container;
let has_consent = rustelo_server::run::current_request_headers()
.map(|h| rustelo_server::api::has_cookie_consent(&h))
.unwrap_or(false);
let cookie_banner = if has_consent {
String::new()
} else {
render_cookie_banner(language)
};
// Retarget sink for htmx error responses. `rustelo_server` sends
// `HX-Retarget: #htmx-error` + `HX-Reswap: outerHTML` on any 4xx for an htmx
// request (utils/htmx.rs). Without this slot the browser logs a repeating
// `htmx:targetError / Invalid re-target #htmx-error`. The framework default
// shell bakes the same element (rustelo_server shell.rs); this custom shell
// must carry it too.
let htmx_error_slot = r#"<div id="htmx-error" class="htmx-error" role="alert" hidden></div>"#;
format!(
r#"<div class="min-h-screen ds-bg-page flex flex-col">{htmx_error_slot}{header_html}{main_and_footer}{modal_slot}{toast_container}{cookie_banner}</div>"#
)
}
/// Render just the `<main>` element with the page body.
///
/// Reused by the fragment renderer for non-boosted `HX-Request` responses.
pub fn main_fragment(path: &str, language: &str) -> String {
let body = render_content_or_grid(path, language);
format!(
r##"<main id="main-content" class="max-w-7xl mx-auto py-2 sm:ds-container flex-grow page-content" hx-history-elt="">{body}</main>"##
)
}
/// Render the `<main>` body for a content path.
///
/// Resolution order (post-first, so a real post URL always loads the post):
/// 1. Bare content index (`/blog`, `/projects`) → grid (all items)
/// 2. An existing content item at this path → post
/// 3. Content-kind path with a category segment → grid (filtered)
/// 4. Anything else → static page / 404
///
/// Checking the post lookup before the category-grid fallback makes the
/// distinction authoritative (does the item exist?) rather than relying on URL
/// shape — which matters for localized ES slugs whose route the engine resolves
/// against the default-language index.
fn render_content_or_grid(path: &str, language: &str) -> String {
let trimmed = path.trim_start_matches('/');
let segments: Vec<&str> = trimmed.split('/').filter(|s| !s.is_empty()).collect();
let base = segments.first().copied().unwrap_or("");
let kind = match base {
"blog" => Some("blog"),
"projects" | "proyectos" => Some("projects"),
"domains" | "dominios" => Some("domains"),
"recipes" | "recetas" => Some("recipes"),
"activities" | "actividades" => Some("activities"),
"catalog" => Some("catalog"),
"adr" => Some("adr"),
_ => None,
};
// 1. Bare content index → grid with no filter.
if let Some(k) = kind {
if segments.len() <= 1 {
return crate::htmx_grid::render_grid_host(k, language, None);
}
}
// 2. Existing content post wins over any grid interpretation.
if let Some(html) = crate::htmx_pages::try_content_post(path, language) {
return html;
}
// 3. Content-kind path with a category segment → grid filtered by category.
if let Some(k) = kind {
if segments.len() == 2 {
return crate::htmx_grid::render_grid_host(k, language, segments.get(1).copied());
}
}
// 4. Static project/product page or 404.
crate::htmx_pages::dispatch_static(path, language)
}
/// Language detection for the HTMX shell.
///
/// Shared-URL routes (same path for both EN and ES, e.g. `/blog`, `/rustelo`)
/// cannot be distinguished by path alone — the preferred_language cookie is
/// the only signal. Exclusive routes (e.g. `/proyectos` only in ES) are
/// authoritative by URL.
///
/// Priority:
/// 1. Exclusive non-default-language route → path determines language
/// 2. Shared or default-language route → cookie determines language
/// 3. Unknown path (no route match) → cookie determines language
/// 4. Fallback → default language
fn shell_detect_language(path: &str) -> String {
use rustelo_web::rustelo_core_lib::i18n::language_config::get_language_registry;
use rustelo_web::rustelo_core_lib::routing::utils::load_routes_config;
let registry = get_language_registry();
let default_lang = registry.get_default_language().to_string();
let config = load_routes_config();
let path_in_default = config.routes.iter().any(|r| {
r.enabled && r.language == default_lang && shell_route_matches(&r.path, path)
});
if path_in_default {
// Shared route — must use cookie; path alone is ambiguous.
cookie_lang_or(default_lang)
} else {
// Check for exclusive non-default route (e.g. /proyectos, /recetas/...).
let exclusive = registry.get_available_languages().into_iter().find(|lang| {
*lang != default_lang
&& config.routes.iter().any(|r| {
r.enabled && r.language == *lang && shell_route_matches(&r.path, path)
})
});
exclusive.unwrap_or_else(|| cookie_lang_or(default_lang))
}
}
/// Returns the `preferred_language` cookie value when present and supported,
/// otherwise the provided `fallback`.
fn cookie_lang_or(fallback: String) -> String {
rustelo_server::run::current_request_headers()
.and_then(|h| rustelo_server::utils::lang_detection::get_language_from_cookie(&h))
.unwrap_or(fallback)
}
/// True when `route_path` (possibly parametric) covers `actual_path`.
fn shell_route_matches(route_path: &str, actual_path: &str) -> bool {
if route_path == actual_path {
return true;
}
// Strip the parametric tail (everything from `{` onwards) and check prefix.
let static_prefix = route_path.split('{').next().unwrap_or("").trim_end_matches('/');
!static_prefix.is_empty()
&& static_prefix != "/"
&& (actual_path == static_prefix
|| actual_path.starts_with(&format!("{static_prefix}/")))
}

Some files were not shown because too many files have changed in this diff Show more