init repo with source
This commit is contained in:
commit
f27d8f9f7a
565 changed files with 120405 additions and 0 deletions
37
.cargo/audit.toml
Normal file
37
.cargo/audit.toml
Normal 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
79
.cargo/config.base.toml
Normal 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
8
.dockerignore
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
.git/
|
||||
target/
|
||||
node_modules/
|
||||
.coder/
|
||||
works-pv/
|
||||
cache/
|
||||
lian-build/ctx-*
|
||||
.DS_Store
|
||||
47
.env.example
Normal file
47
.env.example
Normal 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
88
.gitignore
vendored
Normal 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
1
.k
Normal file
|
|
@ -0,0 +1 @@
|
|||
E/yVqdOfAJIYLTcVzqOFH0eu4R5W+XHKm72fFnNvvzo=
|
||||
82
.pre-commit-config.yaml
Normal file
82
.pre-commit-config.yaml
Normal 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
92
CHANGELOG.md
Normal 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
7792
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
215
Cargo.toml
Normal file
215
Cargo.toml
Normal 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
357
README.md
Normal 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
16
SETUP.md
Normal 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
31
bacon.toml
Normal 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
13
crates/README.md
Normal 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).
|
||||
22
crates/build-config/Cargo.toml
Normal file
22
crates/build-config/Cargo.toml
Normal 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
|
||||
232
crates/build-config/src/content_kind_config.rs
Normal file
232
crates/build-config/src/content_kind_config.rs
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
384
crates/build-config/src/lib.rs
Normal file
384
crates/build-config/src/lib.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
57
crates/build-config/src/rbac_config.rs
Normal file
57
crates/build-config/src/rbac_config.rs
Normal 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)
|
||||
}
|
||||
318
crates/build-config/src/route_config.rs
Normal file
318
crates/build-config/src/route_config.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
753
crates/build-config/src/site_config.rs
Normal file
753
crates/build-config/src/site_config.rs
Normal 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
56
crates/client/Cargo.toml
Normal 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
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
422
crates/client/src/app.rs
Normal 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(¤t_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(¤t_lang, ¤t_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(¤t_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(¤t_path, ¤t_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(¬_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
121
crates/client/src/lib.rs
Normal 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());
|
||||
}
|
||||
31
crates/client/src/page_provider.rs
Normal file
31
crates/client/src/page_provider.rs
Normal 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
202
crates/client/src/policy.rs
Normal 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;
|
||||
}
|
||||
33
crates/client/src/theme.rs
Normal file
33
crates/client/src/theme.rs
Normal 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
42
crates/pages/Cargo.toml
Normal 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
131
crates/pages/build.rs
Normal 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(())
|
||||
}
|
||||
1013
crates/pages/build_page_generator.rs
Normal file
1013
crates/pages/build_page_generator.rs
Normal file
File diff suppressed because it is too large
Load diff
168
crates/pages/src/content_graph/graph_mini.rs
Normal file
168
crates/pages/src/content_graph/graph_mini.rs
Normal 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("×");
|
||||
|
||||
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();
|
||||
}
|
||||
131
crates/pages/src/content_graph/graph_view.rs
Normal file
131
crates/pages/src/content_graph/graph_view.rs
Normal 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));
|
||||
}
|
||||
|
||||
172
crates/pages/src/content_graph/htmx_sidebar.rs
Normal file
172
crates/pages/src/content_graph/htmx_sidebar.rs
Normal 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('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn esc_attr(s: &str) -> String {
|
||||
esc(s).replace('"', """)
|
||||
}
|
||||
34
crates/pages/src/content_graph/mod.rs
Normal file
34
crates/pages/src/content_graph/mod.rs
Normal 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"));
|
||||
59
crates/pages/src/content_graph/ontology_context.rs
Normal file
59
crates/pages/src/content_graph/ontology_context.rs
Normal 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>
|
||||
})
|
||||
}
|
||||
23
crates/pages/src/content_graph/project_graph_section.rs
Normal file
23
crates/pages/src/content_graph/project_graph_section.rs
Normal 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>
|
||||
}
|
||||
}
|
||||
79
crates/pages/src/content_graph/related_content.rs
Normal file
79
crates/pages/src/content_graph/related_content.rs
Normal 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>
|
||||
})
|
||||
}
|
||||
55
crates/pages/src/content_graph/unified.rs
Normal file
55
crates/pages/src/content_graph/unified.rs
Normal 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>
|
||||
}
|
||||
}
|
||||
8
crates/pages/src/home/mod.rs
Normal file
8
crates/pages/src/home/mod.rs
Normal 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;
|
||||
66
crates/pages/src/home/pages.rs
Normal file
66
crates/pages/src/home/pages.rs
Normal 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() /> }
|
||||
}
|
||||
}
|
||||
396
crates/pages/src/home/unified.rs
Normal file
396
crates/pages/src/home/unified.rs
Normal 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>
|
||||
}
|
||||
}
|
||||
2
crates/pages/src/kogral/mod.rs
Normal file
2
crates/pages/src/kogral/mod.rs
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
pub mod unified;
|
||||
pub use unified::UnifiedKogralPage;
|
||||
300
crates/pages/src/kogral/unified.rs
Normal file
300
crates/pages/src/kogral/unified.rs
Normal 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
24
crates/pages/src/lib.rs
Normal 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;
|
||||
3
crates/pages/src/login/mod.rs
Normal file
3
crates/pages/src/login/mod.rs
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
pub mod unified;
|
||||
|
||||
pub use unified::UnifiedLoginPage;
|
||||
43
crates/pages/src/login/unified.rs
Normal file
43
crates/pages/src/login/unified.rs
Normal 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>
|
||||
}
|
||||
}
|
||||
1
crates/pages/src/ontoref/mod.rs
Normal file
1
crates/pages/src/ontoref/mod.rs
Normal file
|
|
@ -0,0 +1 @@
|
|||
pub mod unified;
|
||||
575
crates/pages/src/ontoref/unified.rs
Normal file
575
crates/pages/src/ontoref/unified.rs
Normal 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>
|
||||
}
|
||||
}
|
||||
50
crates/pages/src/post_viewer/mod.rs
Normal file
50
crates/pages/src/post_viewer/mod.rs
Normal 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>
|
||||
}
|
||||
}
|
||||
2
crates/pages/src/provisioning/mod.rs
Normal file
2
crates/pages/src/provisioning/mod.rs
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
pub mod unified;
|
||||
pub use unified::UnifiedProvisioningPage;
|
||||
376
crates/pages/src/provisioning/unified.rs
Normal file
376
crates/pages/src/provisioning/unified.rs
Normal 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>
|
||||
}
|
||||
}
|
||||
2
crates/pages/src/secretumvault/mod.rs
Normal file
2
crates/pages/src/secretumvault/mod.rs
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
pub mod unified;
|
||||
pub use unified::UnifiedSecretumvaultPage;
|
||||
196
crates/pages/src/secretumvault/unified.rs
Normal file
196
crates/pages/src/secretumvault/unified.rs
Normal 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>
|
||||
}
|
||||
}
|
||||
20
crates/pages/src/services/mod.rs
Normal file
20
crates/pages/src/services/mod.rs
Normal 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::*;
|
||||
304
crates/pages/src/services/unified.rs
Normal file
304
crates/pages/src/services/unified.rs
Normal 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>
|
||||
}
|
||||
}
|
||||
2
crates/pages/src/stratumiops/mod.rs
Normal file
2
crates/pages/src/stratumiops/mod.rs
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
pub mod unified;
|
||||
pub use unified::UnifiedStratumiopsPage;
|
||||
559
crates/pages/src/stratumiops/unified.rs
Normal file
559
crates/pages/src/stratumiops/unified.rs
Normal 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>
|
||||
}
|
||||
}
|
||||
2
crates/pages/src/syntaxis/mod.rs
Normal file
2
crates/pages/src/syntaxis/mod.rs
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
pub mod unified;
|
||||
pub use unified::UnifiedSyntaxisPage;
|
||||
356
crates/pages/src/syntaxis/unified.rs
Normal file
356
crates/pages/src/syntaxis/unified.rs
Normal 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>
|
||||
}
|
||||
}
|
||||
2
crates/pages/src/typedialog/mod.rs
Normal file
2
crates/pages/src/typedialog/mod.rs
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
pub mod unified;
|
||||
pub use unified::UnifiedTypedialogPage;
|
||||
287
crates/pages/src/typedialog/unified.rs
Normal file
287
crates/pages/src/typedialog/unified.rs
Normal 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>
|
||||
}
|
||||
}
|
||||
1
crates/pages/src/vapora/mod.rs
Normal file
1
crates/pages/src/vapora/mod.rs
Normal file
|
|
@ -0,0 +1 @@
|
|||
pub mod unified;
|
||||
355
crates/pages/src/vapora/unified.rs
Normal file
355
crates/pages/src/vapora/unified.rs
Normal 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>
|
||||
}
|
||||
}
|
||||
20
crates/pages/src/work_request/mod.rs
Normal file
20
crates/pages/src/work_request/mod.rs
Normal 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::*;
|
||||
478
crates/pages/src/work_request/unified.rs
Normal file
478
crates/pages/src/work_request/unified.rs
Normal 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>
|
||||
}
|
||||
}
|
||||
23
crates/pages_htmx/Cargo.toml
Normal file
23
crates/pages_htmx/Cargo.toml
Normal 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 }
|
||||
10
crates/pages_htmx/build.rs
Normal file
10
crates/pages_htmx/build.rs
Normal 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");
|
||||
}
|
||||
142
crates/pages_htmx/src/lib.rs
Normal file
142
crates/pages_htmx/src/lib.rs
Normal 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),
|
||||
}
|
||||
}
|
||||
}
|
||||
198
crates/pages_htmx/src/pages/about.rs
Normal file
198
crates/pages_htmx/src/pages/about.rs
Normal 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
|
||||
}
|
||||
}
|
||||
29
crates/pages_htmx/src/pages/home.rs
Normal file
29
crates/pages_htmx/src/pages/home.rs
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
3
crates/pages_htmx/src/pages/mod.rs
Normal file
3
crates/pages_htmx/src/pages/mod.rs
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
pub mod about;
|
||||
pub mod home;
|
||||
pub mod static_page;
|
||||
25
crates/pages_htmx/src/pages/static_page.rs
Normal file
25
crates/pages_htmx/src/pages/static_page.rs
Normal 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()
|
||||
}
|
||||
173
crates/pages_htmx/templates/pages/about.j2
Normal file
173
crates/pages_htmx/templates/pages/about.j2
Normal 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>
|
||||
116
crates/pages_htmx/templates/pages/about_project.j2
Normal file
116
crates/pages_htmx/templates/pages/about_project.j2
Normal 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" %}
|
||||
241
crates/pages_htmx/templates/pages/contact.j2
Normal file
241
crates/pages_htmx/templates/pages/contact.j2
Normal 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" %}
|
||||
220
crates/pages_htmx/templates/pages/home.j2
Normal file
220
crates/pages_htmx/templates/pages/home.j2
Normal 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>
|
||||
39
crates/pages_htmx/templates/pages/post.j2
Normal file
39
crates/pages_htmx/templates/pages/post.j2
Normal 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" %}
|
||||
211
crates/pages_htmx/templates/pages/services.j2
Normal file
211
crates/pages_htmx/templates/pages/services.j2
Normal 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" %}
|
||||
31
crates/pages_htmx/templates/pages/static.j2
Normal file
31
crates/pages_htmx/templates/pages/static.j2
Normal 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>
|
||||
113
crates/pages_htmx/templates/partials/image-zoom.j2
Normal file
113
crates/pages_htmx/templates/partials/image-zoom.j2
Normal 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>
|
||||
38
crates/pages_htmx/templates/partials/login_form.j2
Normal file
38
crates/pages_htmx/templates/partials/login_form.j2
Normal 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>
|
||||
20
crates/pages_htmx/templates/partials/nav/theme_toggle.j2
Normal file
20
crates/pages_htmx/templates/partials/nav/theme_toggle.j2
Normal 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>
|
||||
30
crates/pages_htmx/templates/partials/product_page.j2
Normal file
30
crates/pages_htmx/templates/partials/product_page.j2
Normal 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>
|
||||
72
crates/pages_htmx/templates/partials/shell/footer.j2
Normal file
72
crates/pages_htmx/templates/partials/shell/footer.j2
Normal 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>
|
||||
76
crates/pages_htmx/templates/partials/shell/nav.j2
Normal file
76
crates/pages_htmx/templates/partials/shell/nav.j2
Normal 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
107
crates/server/Cargo.toml
Normal 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
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
32
crates/server/src/app.rs
Normal 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>
|
||||
}
|
||||
}
|
||||
71
crates/server/src/bin/content_processor.rs
Normal file
71
crates/server/src/bin/content_processor.rs
Normal 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
|
||||
"#
|
||||
);
|
||||
}
|
||||
99
crates/server/src/htmx_env.rs
Normal file
99
crates/server/src/htmx_env.rs
Normal 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
|
||||
}
|
||||
792
crates/server/src/htmx_grid.rs
Normal file
792
crates/server/src/htmx_grid.rs
Normal 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""#));
|
||||
}
|
||||
}
|
||||
134
crates/server/src/htmx_pages.rs
Normal file
134
crates/server/src/htmx_pages.rs
Normal 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.
|
||||
79
crates/server/src/htmx_template_watcher.rs
Normal file
79
crates/server/src/htmx_template_watcher.rs
Normal 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
419
crates/server/src/lib.rs
Normal 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
32
crates/server/src/main.rs
Normal 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()
|
||||
}
|
||||
261
crates/server/src/resources.rs
Normal file
261
crates/server/src/resources.rs
Normal 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
237
crates/server/src/run.rs
Normal 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())
|
||||
}
|
||||
83
crates/server/src/server_fn_register.rs
Normal file
83
crates/server/src/server_fn_register.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
77
crates/server/src/shell/common.rs
Normal file
77
crates/server/src/shell/common.rs
Normal 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">"#
|
||||
}
|
||||
535
crates/server/src/shell/htmx.rs
Normal file
535
crates/server/src/shell/htmx.rs
Normal 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(¤t_path);
|
||||
|
||||
let base_url = seo::site_base_url();
|
||||
let page_seo = seo::build_page_seo(¤t_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(¤t_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(¤t_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
Loading…
Add table
Reference in a new issue