chore: update templates

This commit is contained in:
Jesús Pérez 2026-07-18 19:57:03 +01:00
parent 0d0297423e
commit 8610b0f0d6
Signed by: jesus
GPG key ID: 9F243E355E0BC939
1075 changed files with 191646 additions and 3698 deletions

View file

@ -6,6 +6,9 @@ repos:
# ============================================================================
# Rust Hooks
# ============================================================================
# Ontoref-governance hooks (manifest-coverage) live at the constellation root
# (../.pre-commit-config.yaml), anchored to ONTOREF_PROJECT_ROOT. This file is
# scoped to the Rust implementation sub-repo.
- repo: local
hooks:
- id: rust-fmt
@ -39,6 +42,20 @@ repos:
pass_filenames: false
stages: [pre-push]
- id: rustelo-deps-sync
name: Rustelo deps sync (registry → workspace.dependencies)
# Fails (exit 1) if [workspace.dependencies] drifted from registry/Cargo.toml.
# CARGO_TARGET_DIR override avoids the /Volumes/Devel/rustelo/target path from
# .cargo/config.toml in case that volume is not mounted on the dev machine.
entry: >-
bash -c 'CARGO_TARGET_DIR="${CARGO_TARGET_DIR:-target/xtask}"
cargo run --manifest-path xtask/Cargo.toml --quiet --
sync-deps --check'
language: system
files: '^(Cargo\.toml|registry/(Cargo|sync)\.toml)$'
pass_filenames: false
stages: [pre-commit]
# ============================================================================
# Nushell Hooks (optional - enable if using Nushell)
# ============================================================================

View file

@ -0,0 +1,31 @@
# ontoref onboarding skeleton
Stamped into a generated site by `scripts/generator/onto-onboard.nu`. Governs the
**site's own project** (not the rustelo domain). Three levels:
| Level | What gets written |
|----------|-------------------|
| `none` | nothing — the site is not governed by ontoref. |
| `minimal`| `.ontoref/{config.ncl, card.ncl}` + local schemas + `.rustelo.ontoref` domain pointer. Owner runs `ontoref setup` later to scaffold ontology/reflection/adrs. |
| `full` | minimal, then delegates to `ontoref setup` (the tool's own onboarding) to scaffold ontology/reflection/adrs from ontoref's bundled templates. |
## Why delegate to `ontoref setup`
ontoref ships its own onboarding templates (config, project, `ontology/{core,state,gate,manifest,connections}`)
under its app-support dir. We do NOT re-author or vendor the full ontology here — that
would drift from the tool and overstep the rustelo **domain** layer, which is authored
separately (referenced via `.rustelo.ontoref`). This skeleton only provides the minimal
floor (`config.ncl` + `card.ncl` + the schemas their imports need locally — see the
post-0023 layout requirement) and a domain pointer.
## Local schemas
Per the post-0023 layout, reflection/ontology NCL import schemas as `schemas/<x>.ncl`
resolved against the project. `minimal/schemas/` carries `project-card.ncl` (for
`card.ncl`) and `backlog.ncl`, copied into the site so imports resolve without relying
on a global schema path. Re-copy from ontoref's app-support schemas when ontoref updates.
## Placeholders
`{{ project_name }}`, `{{ languages }}`, `{{ render_profile }}` — substituted by
`onto-onboard.nu`.

View file

@ -0,0 +1,24 @@
let d = import "ontology/schemas/project-card.ncl" in
d.ProjectCard & {
id = "{{ project_name }}",
name = "{{ project_name }}",
tagline = "A website built on the Rustelo framework",
description = "Config-driven website on Rustelo. COMPLETE: set your own tagline, description, and metadata.",
version = "0.1.0",
status = 'Beta,
source = 'Local,
url = "",
repo = "",
started_at = "2026",
tags = ["rust", "rustelo", "nickel", "website"],
tools = ["Rust", "Nickel", "TypeScript", "UnoCSS"],
features = [
"Config-driven routes, themes, menus (NCL)",
"Bilingual content ({{ languages }})",
"Dual-mode build (htmx-ssr / leptos-hydration)",
],
featured = false,
sort_order = 0,
logo = "",
}

View file

@ -0,0 +1,39 @@
# .ontoref/config.ncl — ontoref configuration for {{ project_name }}
{
nickel_import_paths = [
".",
".ontoref",
".ontoref/ontology",
".ontoref/ontology/schemas",
".ontoref/adrs",
".ontoref/reflection/schemas",
".ontoref/reflection/requirements",
],
log = {
level = "info",
path = ".ontoref/logs",
rotation = "daily",
compress = false,
archive = ".ontoref/logs/archive",
max_files = 7,
},
mode_run = {
rules = [
{ when = { mode_id = "validate-ontology" }, allow = true, reason = "validation always allowed" },
{ when = { actor = "agent" }, allow = true, reason = "agent actor always allowed" },
{ when = { actor = "ci" }, allow = true, reason = "ci actor always allowed" },
],
},
nats_events = {
enabled = false,
url = "nats://localhost:4222",
emit = [],
subscribe = [],
handlers_dir = "reflection/handlers",
},
card = import "card.ncl",
}

View file

@ -0,0 +1,33 @@
let status_type = [| 'Open, 'InProgress, 'Done, 'Cancelled |] in
let priority_type = [| 'Critical, 'High, 'Medium, 'Low |] in
let kind_type = [| 'Todo, 'Wish, 'Idea, 'Bug, 'Debt |] in
let graduate_type = [| 'Adr, 'Mode, 'StateTransition, 'PrItem |] in
let item_type = {
id | String,
title | String,
kind | kind_type,
priority | priority_type | default = 'Medium,
status | status_type | default = 'Open,
detail | String | default = "",
# Optional links to existing artifacts
related_adrs | Array String | default = [],
related_modes | Array String | default = [],
related_dim | String | optional, # state.ncl dimension id
# Graduation target — when this item is ready to be promoted
graduates_to | graduate_type | optional,
# ISO date strings
created | String | default = "",
updated | String | default = "",
} in
{
Status = status_type,
Priority = priority_type,
Kind = kind_type,
GraduateTo = graduate_type,
Item = item_type,
BacklogConfig = {
items | Array item_type,
},
}

View file

@ -0,0 +1,37 @@
# Project card schema — typed self-definition for any project.
# Source of truth for display metadata, web assets, and portfolio publication.
#
# Each project maintains card.ncl locally and publishes (copies) to the
# portfolio repo alongside its assets/. The portfolio is self-contained —
# it does not depend on the original project repo being alive.
let source_type = [| 'Local, 'Remote, 'Historical |] in
let project_pub_status_type = [| 'Active, 'Beta, 'Maintenance, 'Archived, 'Stealth |] in
let project_card_type = {
id | String, # matches ontology_node in jpl DAG
name | String,
tagline | String, # stable identity statement
description | String,
primary_value_prop_id | String | optional, # ADR-035: canonical value-prop in .ontoref/positioning/value-props/; downstream renderers MAY prefer its claim over `tagline`
version | String | default = "",
status | project_pub_status_type,
source | source_type | default = 'Local,
url | String | default = "",
repo | String | default = "",
docs | String | default = "",
logo | String | default = "",
started_at | String | default = "",
tags | Array String | default = [],
tools | Array String | default = [],
features | Array String | default = [],
featured | Bool | default = false,
sort_order | Number | default = 0,
} in
{
SourceType = source_type,
ProjectPubStatus = project_pub_status_type,
ProjectCard = project_card_type,
}

View file

@ -1,31 +0,0 @@
# Development Environment Variables for jpl-website
# This file contains development-specific settings
# Application Settings
SECRET_KEY=dev-secret-key-change-in-production
RUST_LOG=debug
# Database Settings (SQLite for development)
DATABASE_URL=sqlite:data/dev_database.db
# Server Settings
LEPTOS_SITE_ADDR=127.0.0.1:3030
LEPTOS_SITE_ROOT=/
LEPTOS_SITE_PKG_DIR=pkg
LEPTOS_SITE_NAME=jpl-website
# 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
RUSTELO_DEBUG_MODE=true
# Asset Settings
RUSTELO_STATIC_DIR=public
RUSTELO_UPLOAD_DIR=uploads
RUSTELO_MAX_FILE_SIZE=52428800 # 50MB in bytes for development

View file

@ -1,44 +0,0 @@
# Development Configuration for Rustelo Implementation
# This file overrides production settings for development
[app]
name = "jpl-website"
version = "0.1.0"
environment = "development"
debug = true
[server]
host = "127.0.0.1"
port = 3030
workers = 1
auto_reload = true
[database]
# Development database - defaults to SQLite
url = "sqlite:data/dev_database.db"
max_connections = 5
min_connections = 1
create_database = true
[features]
# Development feature flags
content_static = true
auth = false
email = false
metrics = true # Enable metrics in development
tls = false
[assets]
# Development asset configuration
static_dir = "public"
upload_dir = "uploads"
max_file_size = "50MB" # Larger limit for development
[security]
# Development security settings (use defaults)
secret_key = "dev-secret-key-change-in-production"
cors_origins = ["http://localhost:3030", "http://127.0.0.1:3030"]
[logging]
level = "debug"
format = "pretty"

View file

@ -1,38 +0,0 @@
# Main Configuration for Rustelo Implementation
# This file contains production settings
[app]
name = "jpl-website"
version = "0.1.0"
environment = "production"
[server]
host = "127.0.0.1"
port = 3000
workers = 4
[database]
# Database configuration will be loaded from environment variables
# Set DATABASE_URL in your .env file
url = "${DATABASE_URL}"
max_connections = 10
min_connections = 1
[features]
# Feature flags for this implementation
content_static = true
auth = false
email = false
metrics = false
tls = false
[assets]
# Static asset configuration
static_dir = "public"
upload_dir = "uploads"
max_file_size = "10MB"
[security]
# Security settings
secret_key = "${SECRET_KEY}"
cors_origins = ["http://localhost:3000"]

View file

@ -1,25 +0,0 @@
# Application Configuration for jpl-website
[app]
name = "jpl-website"
version = "0.1.0"
description = "A Rustelo basic implementation"
author = "{{project_author}}"
[runtime]
# Runtime configuration
tokio_threads = 4
blocking_threads = 512
[logging]
# Logging configuration
level = "info"
format = "json"
target = "stdout"
[paths]
# Path configuration
static_files = "public"
templates = "templates"
uploads = "uploads"
cache = "cache"

View file

@ -1,27 +0,0 @@
# Database Configuration for jpl-website
[database]
# Main database connection
url = "${DATABASE_URL}"
max_connections = 10
min_connections = 1
acquire_timeout = 30
idle_timeout = 600
[migrations]
# Migration settings
auto_migrate = true
migration_dir = "migrations"
[sqlite]
# SQLite-specific settings (for development)
journal_mode = "WAL"
synchronous = "NORMAL"
foreign_keys = true
busy_timeout = 30000
[postgresql]
# PostgreSQL-specific settings (for production)
application_name = "jpl-website"
statement_timeout = "30s"
lock_timeout = "10s"

View file

@ -1,68 +0,0 @@
# Content Directory
This directory contains your site's content files. The basic template provides a simple content structure that you can customize.
## Structure
```
content/
├── blog/ # Blog posts
├── pages/ # Static pages
├── menu.toml # Site navigation
└── config.toml # Content configuration
```
## Usage
### Blog Posts
Add markdown files to `blog/` directory:
```markdown
# My First Post
Date: 2024-01-01
Author: {{author}}
Tags: rustelo, web-development
Your content here...
```
### Pages
Add static pages to `pages/` directory:
```markdown
# About
This is the about page for {{project_name}}.
```
### Navigation
Edit `menu.toml` to customize site navigation:
```toml
[[main]]
name = "Home"
url = "/"
[[main]]
name = "Blog"
url = "/blog"
[[main]]
name = "About"
url = "/about"
```
## Content Processing
Content is processed by the Rustelo content system, which supports:
- Markdown rendering
- Front matter parsing
- Automatic routing
- SEO optimization
- Search indexing (if enabled)
## Customization
You can extend the content structure by:
1. Adding new directories for different content types
2. Customizing front matter fields
3. Creating content templates
4. Adding content processing hooks

View file

@ -1,92 +0,0 @@
# Welcome to Rustelo!
Date: {{generation_timestamp}}
Author: {{author}}
Tags: rustelo, welcome, getting-started
Description: Welcome to your new Rustelo implementation! This guide will help you get started.
---
Welcome to **{{project_name}}**, your new Rustelo-powered web application! 🦀✨
## What is Rustelo?
Rustelo is a modern Rust web framework that provides:
- 🚀 **Fast Development** - Hot reload, efficient builds, and great DX
- 🏗️ **Framework as Dependency** - No forks, clean updates, easy maintenance
- 🎨 **Modern UI** - Built-in UnoCSS, components, and responsive design
- 📝 **Content Management** - Markdown support, static generation, SEO
- 🔐 **Authentication** - Built-in auth system with multiple providers
- 📦 **Asset Management** - Smart asset resolution and optimization
## Getting Started
Your new implementation is ready to go! Here are your next steps:
### 1. Start Development Server
```bash
just dev
```
This will start your application at [http://localhost:3030](http://localhost:3030) with hot reload enabled.
### 2. Explore the Structure
```
{{project_name}}/
├── src/ # Your Rust code
├── content/ # Content files (this blog!)
├── public/ # Static assets
├── justfile # Development tasks
└── rustelo-deps.toml # Framework configuration
```
### 3. Create Your First Page
Add a new markdown file to `content/pages/`:
```markdown
# My Custom Page
This is my custom content!
```
### 4. Customize Styling
Edit `unocss.config.ts` to customize your design system, or add styles to `public/styles/custom.css`.
### 5. Add Components
Create new Leptos components in `src/components/` and use them in your layouts.
## Framework Updates
Your implementation stays up-to-date with the framework:
```bash
# Check for updates
just update-framework
# Apply updates safely
just update
```
The framework will never overwrite your custom code - only configuration files are updated, and you're always asked first.
## Getting Help
- 📖 [Rustelo Documentation](https://docs.rustelo.dev)
- 💬 [Community Discord](https://discord.gg/rustelo)
- 🐛 [Issue Tracker](https://github.com/your-org/rustelo/issues)
- 🎓 [Examples Repository](https://github.com/your-org/rustelo-examples)
## What's Next?
- Customize your `content/menu.toml` to add navigation
- Add authentication with `cargo rustelo features enable auth oauth2`
- Set up analytics with `cargo rustelo features enable analytics tracking`
- Deploy to production with `just prepare-deploy`
Happy building! 🚀

View file

@ -1,37 +0,0 @@
# Site Navigation Configuration
# Edit this file to customize your site's navigation
[[main]]
name = "Home"
url = "/"
icon = "i-heroicons-home"
[[main]]
name = "Blog"
url = "/blog"
icon = "i-heroicons-document-text"
[[main]]
name = "About"
url = "/about"
icon = "i-heroicons-information-circle"
# Footer navigation
[[footer]]
name = "Privacy"
url = "/privacy"
[[footer]]
name = "Terms"
url = "/terms"
# Social links
[[social]]
name = "GitHub"
url = "{{repository}}"
icon = "i-lucide-github"
[[social]]
name = "RSS"
url = "/feed.xml"
icon = "i-heroicons-rss"

View file

@ -1,45 +0,0 @@
# About {{project_name}}
{{description}}
## Built with Rustelo
This site is built with the [Rustelo framework](https://rustelo.dev), a modern Rust web framework that combines the power of Rust with the flexibility of modern web development.
### Key Features
- **Performance**: Built with Rust for maximum performance and safety
- **Modern UI**: Responsive design with UnoCSS and component-based architecture
- **Content Management**: Markdown-based content with automatic routing
- **Developer Experience**: Hot reload, type safety, and excellent tooling
- **Maintainable**: Framework-as-dependency architecture keeps your code clean
## Getting Started
This implementation uses the **basic** template, which provides:
- Simple blog and pages structure
- Responsive design out of the box
- Essential development tools
- Framework update management
- Asset optimization
## Technology Stack
- **Backend**: Rust with Leptos framework
- **Frontend**: Leptos with UnoCSS for styling
- **Content**: Markdown with front matter
- **Build**: Vite with hot module replacement
- **Deployment**: Docker-ready with cross-compilation support
## Contact
For questions or support:
- **Author**: {{author}}
- **Framework**: [Rustelo Documentation](https://docs.rustelo.dev)
- **Repository**: {{repository}}
---
*This page was generated from the basic template. Edit `content/pages/about.md` to customize it.*

View file

@ -1,117 +0,0 @@
# jpl-website - Rustelo Implementation
# Generated from basic template
# Self-contained justfile for implementation development
# Default task - show available commands
default:
@just --list
# Development server with hot reload
dev:
@echo "🚀 Starting jpl-website development server..."
@echo "📁 Implementation: jpl-website"
@echo "🏷️ Template: basic"
cargo rustelo dev --port 3030 --watch
# Build the application
build:
@echo "🔨 Building jpl-website..."
cargo rustelo build
# Build for production
build-release:
@echo "🔨 Building jpl-website for production..."
cargo rustelo build --release
# Run tests
test:
@echo "🧪 Running tests..."
cargo test
# Format code
fmt:
@echo "🎨 Formatting code..."
cargo fmt
# Lint code
lint:
@echo "📝 Linting code..."
cargo clippy -- -D warnings
# Clean build artifacts
clean:
@echo "🧹 Cleaning build artifacts..."
cargo clean
rm -rf dist/
# Update framework dependencies
update-framework:
@echo "🔄 Checking for framework updates..."
cargo rustelo update --check
# Apply framework updates
update:
@echo "🔄 Applying framework updates..."
cargo rustelo update
# Sync framework assets (for local development)
sync-assets:
@echo "📦 Syncing framework assets..."
cargo rustelo assets sync
# Setup development environment
setup:
@echo "⚙️ Setting up development environment..."
@echo "Installing frontend dependencies..."
npm install
@echo "✅ Setup complete!"
# Start development with frontend build
dev-full: setup
@echo "🚀 Starting full development environment..."
just dev
# Production deployment preparation
prepare-deploy: build-release
@echo "📦 Preparing for deployment..."
@echo "✅ Ready for deployment"
# Quick health check
check:
@echo "🔍 Running health checks..."
cargo check
npm run check
@echo "✅ Health check complete"
# View logs (implementation-specific)
logs:
@echo "📜 Viewing application logs..."
tail -f .rustelo/logs/app.log
# Configuration management
config:
@echo "⚙️ Configuration:"
@echo "Project: jpl-website"
@echo "Template: basic"
@echo "Config file: rustelo-deps.toml"
@cat rustelo-deps.toml
# Content management helpers
content-new title:
@echo "📝 Creating new content: {{title}}"
mkdir -p content/posts
echo "# {{title}}" > "content/posts/{{title}}.md"
echo "" >> "content/posts/{{title}}.md"
echo "Date: $(date -I)" >> "content/posts/{{title}}.md"
echo "Author: {{author}}" >> "content/posts/{{title}}.md"
echo "" >> "content/posts/{{title}}.md"
echo "Your content here..." >> "content/posts/{{title}}.md"
# Backup important files
backup:
@echo "💾 Creating backup..."
tar -czf "backup-$(date +%Y%m%d_%H%M%S).tar.gz" src/ content/ config/ rustelo-deps.toml Cargo.toml
@echo "✅ Backup created"
# Include local tasks if they exist
import? 'local-tasks.just'

View file

@ -1,51 +0,0 @@
{
"name": "jpl-website",
"version": "0.1.0",
"description": "{{description}}",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"check": "npm run check:css && npm run check:js",
"check:css": "unocss --check",
"check:js": "eslint . --ext .js,.ts",
"format": "prettier --write .",
"format:check": "prettier --check ."
},
"devDependencies": {
"@unocss/cli": "^0.58.0",
"@unocss/reset": "^0.58.0",
"unocss": "^0.58.0",
"vite": "^5.0.0",
"autoprefixer": "^10.4.16",
"postcss": "^8.4.32",
"prettier": "^3.1.0",
"eslint": "^8.55.0",
"@typescript-eslint/eslint-plugin": "^6.13.0",
"@typescript-eslint/parser": "^6.13.0",
"typescript": "^5.3.0"
},
"dependencies": {
"@iconify-json/heroicons": "^1.1.15",
"@iconify-json/lucide": "^1.1.134"
},
"engines": {
"node": ">=18.0.0",
"npm": ">=9.0.0"
},
"keywords": [
"rustelo",
"leptos",
"rust",
"web",
"fullstack"
],
"author": "{{author}}",
"license": "MIT",
"repository": {
"type": "git",
"url": "{{repository}}"
}
}

View file

@ -1,68 +0,0 @@
# Public Assets Directory
This directory contains static assets that are served directly by your web server.
## Structure
```
public/
├── favicon.ico # Site favicon
├── robots.txt # Search engine directives
├── sitemap.xml # Site map for SEO
├── images/ # Image assets
├── styles/ # Additional CSS files
├── scripts/ # Client-side JavaScript
└── documents/ # Downloadable files
```
## Asset Processing
- **Images**: Automatically optimized and served
- **Styles**: Processed through UnoCSS and PostCSS
- **Scripts**: Bundled through Vite
- **Documents**: Served as-is
## Usage
### Images
Place images in `public/images/` and reference them:
```html
<img src="/images/logo.png" alt="Logo" />
```
### Custom Styles
Add custom CSS to `public/styles/custom.css`:
```css
.my-custom-class {
/* Your styles */
}
```
### Client Scripts
Add JavaScript to `public/scripts/`:
```javascript
// Custom client-side code
console.log('Hello from {{project_name}}!');
```
## SEO Files
The basic template includes:
- `robots.txt` - Search engine crawling rules
- `sitemap.xml` - Generated automatically from your content
- `manifest.json` - PWA configuration (optional)
## Optimization
All assets are automatically:
- Compressed (gzip/brotli)
- Cached with appropriate headers
- Served through CDN (if configured)
- Optimized for performance
## Customization
You can customize asset handling in:
- `vite.config.js` - Build-time processing
- `rustelo.toml` - Runtime serving
- `unocss.config.ts` - CSS processing

View file

@ -1,145 +0,0 @@
# jpl-website - Rustelo Framework Configuration
# Generated from basic template v0.1.0
#
# This file controls how your implementation depends on and interacts with the Rustelo framework.
[project]
name = "jpl-website"
description = "{{description}}"
template = "basic"
created = "2025-10-28T18:46:17.145459+00:00"
[dependencies]
# How this implementation depends on the Rustelo framework
strategy = "{{dependency_strategy}}"
[dependencies.git]
repository = "https://github.com/your-org/rustelo.git"
branch = "main"
# tag = "v1.0.0" # Use for production deployments
[dependencies.crates_io]
# Production-ready approach using published crates
[dependencies.path]
# Local development only
base_path = "../rustelo/crates"
[versions]
# Framework crate versions
rustelo-core = "{{rustelo_core_version}}"
rustelo-web = "{{rustelo_web_version}}"
rustelo-auth = "{{rustelo_auth_version}}"
rustelo-content = "{{rustelo_content_version}}"
[features]
# Enabled framework features for this implementation
rustelo-core = []
rustelo-web = ["ssr"]
rustelo-auth = []
rustelo-content = ["markdown"]
[features.available]
# Available features for discovery (updated automatically)
rustelo-core = ["tracing", "metrics", "caching", "async-runtime"]
rustelo-web = ["islands", "streaming", "hydration", "prerendering"]
rustelo-auth = ["oauth2", "jwt", "session", "2fa", "social-login"]
rustelo-content = ["search", "cms", "multilang", "versioning"]
rustelo-analytics = ["tracking", "events", "reporting", "dashboards"]
rustelo-realtime = ["websockets", "sse", "notifications", "collaboration"]
# Asset management configuration
[assets]
source = "local"
download_location = ".rustelo-assets/"
[assets.remote]
base_url = "https://raw.githubusercontent.com/your-org/rustelo/main/templates"
template_variant = "basic"
cache_enabled = true
cache_ttl = "24h"
cache_directory = ".rustelo-cache"
fallback_to_embedded = true
[assets.local]
framework_path = "{{framework_path}}"
implementation_templates = "templates"
framework_assets = "framework-assets"
watch_changes = {{dev_mode}}
auto_sync = {{dev_mode}}
[development]
enabled = {{dev_mode}}
auto_detect_changes = true
auto_sync_assets = {{dev_mode}}
validate_assets = true
hot_reload = true
# Update and maintenance configuration
[update]
policy = "conservative"
backup = true
backup_dir = ".rustelo-backups"
[update.automation]
enabled = {{automation_enabled}}
check_frequency = "{{check_frequency}}"
check_targets = ["framework", "dependencies", "features"]
notify_methods = {{notify_methods}}
auto_update_policy = "notify_only"
fail_on_critical_updates = false
update_log_file = ".rustelo/update-history.log"
# Files safe to regenerate during updates
regenerate = [
"justfile",
"package.json",
"unocss.config.ts",
"rustelo.toml"
]
# Protected files (never overwritten)
protected = [
"src/**/*.rs",
"content/**/*",
"public/**/*",
"local-tasks.just",
"unocss.local.ts",
"rustelo.local.toml",
".env*"
]
# Contribution detection and sharing
[contributions]
enabled = {{contributions_enabled}}
scan_frequency = "monthly"
detect_types = ["improvements", "bug_fixes", "new_features", "documentation"]
confidence_threshold = 0.7
auto_package = false
package_directory = ".rustelo/contributions"
notify_methods = ["log", "file"]
# Notification preferences
[notifications]
enabled = true
[notifications.channels]
console = { enabled = true, level = "info" }
file = {
enabled = {{file_notifications}},
path = ".rustelo/notifications.json"
}
# CI/CD integration
[ci_cd]
enabled = {{ci_cd_enabled}}
exit_codes = true
cache_results = true
cache_key = "rustelo-jpl-website-0.1.0"
# Template-specific configuration
[template.basic]
# Basic template doesn't require additional configuration
content_types = ["blog", "pages"]
ui_framework = "unocss"
build_system = "vite"

View file

@ -1,84 +0,0 @@
//! UI Components for jpl-website
//!
//! Reusable UI components built with Leptos and UnoCSS.
use leptos::*;
mod navigation;
mod footer;
pub use navigation::*;
pub use footer::*;
/// Common button component with consistent styling
#[component]
pub fn Button(
/// Button text
children: Children,
/// Click handler
#[prop(optional)] on_click: Option<Box<dyn Fn() + 'static>>,
/// Button variant
#[prop(default = "primary")] variant: &'static str,
/// Additional CSS classes
#[prop(default = "")] class: &'static str,
/// Button type
#[prop(default = "button")] r#type: &'static str,
/// Disabled state
#[prop(default = false)] disabled: bool,
) -> impl IntoView {
let button_class = format!(
"btn {} {}",
match variant {
"primary" => "btn-primary",
"secondary" => "btn-secondary",
"ghost" => "btn-ghost",
_ => "btn-primary",
},
class
);
let disabled_class = if disabled { " opacity-50 cursor-not-allowed" } else { "" };
let final_class = format!("{}{}", button_class, disabled_class);
view! {
<button
type=r#type
class=final_class
disabled=disabled
on:click=move |_| {
if let Some(handler) = &on_click {
if !disabled {
handler();
}
}
}
>
{children()}
</button>
}
}
/// Loading spinner component
#[component]
pub fn LoadingSpinner(
#[prop(default = "w-4 h-4")] size: &'static str,
#[prop(default = "")] class: &'static str,
) -> impl IntoView {
view! {
<div class={format!("animate-spin rounded-full border-2 border-gray-300 border-t-primary-600 {size} {class}")}>
</div>
}
}
/// Card container component
#[component]
pub fn Card(
children: Children,
#[prop(default = "")] class: &'static str,
) -> impl IntoView {
view! {
<div class={format!("card {class}")}>
{children()}
</div>
}
}

View file

@ -1,66 +0,0 @@
//! jpl-website Library
//!
//! Core application components and functionality.
use leptos::*;
use rustelo_core::prelude::*;
use rustelo_web::prelude::*;
pub mod components;
pub mod pages;
use components::*;
use pages::*;
/// Main application component
#[component]
pub fn App() -> impl IntoView {
// Provide global application state
provide_meta_context();
view! {
<Html lang="en" />
<Title text="jpl-website" />
<Meta name="description" content="{{description}}" />
<Meta charset="utf-8" />
<Meta name="viewport" content="width=device-width, initial-scale=1" />
// Global styles
<Link rel="stylesheet" href="/styles/unocss.css" />
<Link rel="icon" type="image/x-icon" href="/favicon.ico" />
<Body class="bg-gray-50 text-gray-900" />
<Router>
<Navigation />
<main class="min-h-screen">
<Routes>
<Route path="/" view=Home />
<Route path="/about" view=About />
<Route path="/blog" view=BlogList />
<Route path="/blog/:slug" view=BlogPost />
<Route path="/*any" view=NotFound />
</Routes>
</main>
<Footer />
</Router>
}
}
/// 404 Not Found page
#[component]
fn NotFound() -> impl IntoView {
view! {
<div class="rustelo-container rustelo-section text-center">
<h1 class="text-4xl font-bold text-gray-900 mb-4">
"404 - Page Not Found"
</h1>
<p class="text-xl text-gray-600 mb-8">
"The page you're looking for doesn't exist."
</p>
<a href="/" class="btn-primary">
"Go Home"
</a>
</div>
}
}

View file

@ -1,17 +0,0 @@
//! jpl-website
//!
//! A Rustelo framework implementation.
//! Generated from basic template.
use leptos::*;
use jpl_website::*;
fn main() {
// Initialize the application
console_error_panic_hook::set_once();
// Mount the application
mount_to_body(|| {
view! { <App/> }
})
}

View file

@ -1,107 +0,0 @@
import { defineConfig, presetUno, presetIcons, presetWebFonts } from 'unocss'
export default defineConfig({
// Basic UnoCSS configuration for jpl-website
presets: [
presetUno(),
presetIcons({
collections: {
heroicons: () => import('@iconify-json/heroicons/icons.json').then(i => i.default),
lucide: () => import('@iconify-json/lucide/icons.json').then(i => i.default),
}
}),
presetWebFonts({
fonts: {
sans: 'Inter:400,500,600,700',
mono: 'JetBrains Mono:400,500',
}
})
],
// Theme configuration
theme: {
colors: {
primary: {
50: '#fef7ee',
100: '#fdedd3',
200: '#fbd6a5',
300: '#f8b86d',
400: '#f59332',
500: '#f2751a',
600: '#e35a0f',
700: '#bc4210',
800: '#953515',
900: '#792d14',
},
gray: {
50: '#f9fafb',
100: '#f3f4f6',
200: '#e5e7eb',
300: '#d1d5db',
400: '#9ca3af',
500: '#6b7280',
600: '#4b5563',
700: '#374151',
800: '#1f2937',
900: '#111827',
}
},
fontFamily: {
sans: ['Inter', 'system-ui', 'sans-serif'],
mono: ['JetBrains Mono', 'monospace'],
}
},
// Content paths for purging
content: {
filesystem: [
'src/**/*.rs',
'templates/**/*.html',
'content/**/*.md'
]
},
// Custom shortcuts for common patterns
shortcuts: {
'btn': 'px-4 py-2 rounded-lg font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2',
'btn-primary': 'btn bg-primary-600 text-white hover:bg-primary-700',
'btn-secondary': 'btn bg-gray-200 text-gray-900 hover:bg-gray-300',
'btn-ghost': 'btn bg-transparent text-gray-700 hover:bg-gray-100',
'card': 'bg-white rounded-lg shadow-sm border border-gray-200 p-6',
'input': 'w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500',
'nav-link': 'text-gray-700 hover:text-primary-600 px-3 py-2 rounded-md text-sm font-medium transition-colors',
'nav-link-active': 'text-primary-600 bg-primary-50 px-3 py-2 rounded-md text-sm font-medium',
},
// Custom rules for Rustelo-specific patterns
rules: [
// Custom spacing for Rustelo layouts
[/^rustelo-container$/, () => ({ 'max-width': '1200px', 'margin': '0 auto', 'padding': '0 1rem' })],
[/^rustelo-section$/, () => ({ 'padding': '4rem 0' })],
[/^rustelo-hero$/, () => ({ 'padding': '8rem 0 6rem' })],
],
// Safelist important classes that might be used dynamically
safelist: [
'bg-primary-600',
'text-primary-600',
'border-primary-600',
'ring-primary-500',
'btn-primary',
'btn-secondary',
'btn-ghost',
'card',
'nav-link',
'nav-link-active',
],
// Development configuration
cli: {
entry: [
{
patterns: ['src/**/*.rs'],
outFile: 'public/styles/unocss.css'
}
]
}
})

View file

@ -1,74 +0,0 @@
# jpl-website - Rustelo cms Implementation
# Generated by cargo rustelo
# Workspace structure for complex applications
[workspace]
resolver = "2"
members = [
"crates/shared",
"crates/server",
"crates/client",
"crates/ssr"
]
[workspace.dependencies]
# Local crates
jpl-website-shared = { path = "crates/shared" }
jpl-website-server = { path = "crates/server" }
jpl-website-client = { path = "crates/client" }
jpl-website-ssr = { path = "crates/ssr" }
# Rustelo Framework (local development)
rustelo-core = { path = "{{dependencies.rustelo_path}}/crates/rustelo-core" }
rustelo-web = { path = "{{dependencies.rustelo_path}}/crates/rustelo-web" }
rustelo-content = { path = "{{dependencies.rustelo_path}}/crates/rustelo-content" }
rustelo-auth = { path = "{{dependencies.rustelo_path}}/crates/rustelo-auth" }
# Leptos Framework
leptos = { version = "0.8.6" }
leptos_axum = { version = "0.8.5" }
leptos_router = { version = "0.8.5" }
leptos_meta = { version = "0.8.5" }
leptos_config = { version = "0.8.5" }
# Server Dependencies
axum = { version = "0.8.4", features = ["macros", "tracing"] }
tower = { version = "0.5.2", features = ["util"] }
tower-http = { version = "0.6.6", features = ["fs", "cors"] }
tokio = { version = "1.47.1", features = ["rt-multi-thread", "macros", "signal"] }
# Shared Dependencies
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
uuid = { version = "1.18", features = ["v4", "serde"] }
chrono = { version = "0.4", features = ["serde"] }
thiserror = "2.0"
anyhow = "1.0"
# WASM Dependencies
web-sys = { version = "0.3.77" }
wasm-bindgen = "0.2.100"
console_error_panic_hook = "0.1"
[profile.release]
codegen-units = 1
lto = true
opt-level = 'z'
panic = 'abort'
strip = true
[[workspace.metadata.leptos]]
name = "jpl-website"
bin-package = "jpl-website-server"
lib-package = "jpl-website-client"
bin-target = "jpl-website-server"
output-name = "jpl-website"
site-root = "target/site"
site-pkg-dir = "pkg"
assets-dir = "public"
site-addr = "127.0.0.1:3000"
reload-port = 3001
env = "DEV"
bin-features = ["ssr"]
lib-features = ["hydrate"]
watch = true

View file

@ -1,44 +0,0 @@
# Development Configuration for Rustelo Implementation
# This file overrides production settings for development
[app]
name = "jpl-website"
version = "0.1.0"
environment = "development"
debug = true
[server]
host = "127.0.0.1"
port = 3030
workers = 1
auto_reload = true
[database]
# Development database - defaults to SQLite
url = "sqlite:data/dev_database.db"
max_connections = 5
min_connections = 1
create_database = true
[features]
# Development feature flags
content_static = true
auth = false
email = false
metrics = true # Enable metrics in development
tls = false
[assets]
# Development asset configuration
static_dir = "public"
upload_dir = "uploads"
max_file_size = "50MB" # Larger limit for development
[security]
# Development security settings (use defaults)
secret_key = "dev-secret-key-change-in-production"
cors_origins = ["http://localhost:3030", "http://127.0.0.1:3030"]
[logging]
level = "debug"
format = "pretty"

View file

@ -1,38 +0,0 @@
# Main Configuration for Rustelo Implementation
# This file contains production settings
[app]
name = "jpl-website"
version = "0.1.0"
environment = "production"
[server]
host = "127.0.0.1"
port = 3000
workers = 4
[database]
# Database configuration will be loaded from environment variables
# Set DATABASE_URL in your .env file
url = "${DATABASE_URL}"
max_connections = 10
min_connections = 1
[features]
# Feature flags for this implementation
content_static = true
auth = false
email = false
metrics = false
tls = false
[assets]
# Static asset configuration
static_dir = "public"
upload_dir = "uploads"
max_file_size = "10MB"
[security]
# Security settings
secret_key = "${SECRET_KEY}"
cors_origins = ["http://localhost:3000"]

View file

@ -1,25 +0,0 @@
# Application Configuration for jpl-website
[app]
name = "jpl-website"
version = "0.1.0"
description = "A Rustelo basic implementation"
author = "{{project_author}}"
[runtime]
# Runtime configuration
tokio_threads = 4
blocking_threads = 512
[logging]
# Logging configuration
level = "info"
format = "json"
target = "stdout"
[paths]
# Path configuration
static_files = "public"
templates = "templates"
uploads = "uploads"
cache = "cache"

View file

@ -1,27 +0,0 @@
# Database Configuration for jpl-website
[database]
# Main database connection
url = "${DATABASE_URL}"
max_connections = 10
min_connections = 1
acquire_timeout = 30
idle_timeout = 600
[migrations]
# Migration settings
auto_migrate = true
migration_dir = "migrations"
[sqlite]
# SQLite-specific settings (for development)
journal_mode = "WAL"
synchronous = "NORMAL"
foreign_keys = true
busy_timeout = 30000
[postgresql]
# PostgreSQL-specific settings (for production)
application_name = "jpl-website"
statement_timeout = "30s"
lock_timeout = "10s"

View file

@ -1,68 +0,0 @@
# Content Directory
This directory contains your site's content files. The basic template provides a simple content structure that you can customize.
## Structure
```
content/
├── blog/ # Blog posts
├── pages/ # Static pages
├── menu.toml # Site navigation
└── config.toml # Content configuration
```
## Usage
### Blog Posts
Add markdown files to `blog/` directory:
```markdown
# My First Post
Date: 2024-01-01
Author: {{author}}
Tags: rustelo, web-development
Your content here...
```
### Pages
Add static pages to `pages/` directory:
```markdown
# About
This is the about page for {{project_name}}.
```
### Navigation
Edit `menu.toml` to customize site navigation:
```toml
[[main]]
name = "Home"
url = "/"
[[main]]
name = "Blog"
url = "/blog"
[[main]]
name = "About"
url = "/about"
```
## Content Processing
Content is processed by the Rustelo content system, which supports:
- Markdown rendering
- Front matter parsing
- Automatic routing
- SEO optimization
- Search indexing (if enabled)
## Customization
You can extend the content structure by:
1. Adding new directories for different content types
2. Customizing front matter fields
3. Creating content templates
4. Adding content processing hooks

View file

@ -1,92 +0,0 @@
# Welcome to Rustelo!
Date: {{generation_timestamp}}
Author: {{author}}
Tags: rustelo, welcome, getting-started
Description: Welcome to your new Rustelo implementation! This guide will help you get started.
---
Welcome to **{{project_name}}**, your new Rustelo-powered web application! 🦀✨
## What is Rustelo?
Rustelo is a modern Rust web framework that provides:
- 🚀 **Fast Development** - Hot reload, efficient builds, and great DX
- 🏗️ **Framework as Dependency** - No forks, clean updates, easy maintenance
- 🎨 **Modern UI** - Built-in UnoCSS, components, and responsive design
- 📝 **Content Management** - Markdown support, static generation, SEO
- 🔐 **Authentication** - Built-in auth system with multiple providers
- 📦 **Asset Management** - Smart asset resolution and optimization
## Getting Started
Your new implementation is ready to go! Here are your next steps:
### 1. Start Development Server
```bash
just dev
```
This will start your application at [http://localhost:3030](http://localhost:3030) with hot reload enabled.
### 2. Explore the Structure
```
{{project_name}}/
├── src/ # Your Rust code
├── content/ # Content files (this blog!)
├── public/ # Static assets
├── justfile # Development tasks
└── rustelo-deps.toml # Framework configuration
```
### 3. Create Your First Page
Add a new markdown file to `content/pages/`:
```markdown
# My Custom Page
This is my custom content!
```
### 4. Customize Styling
Edit `unocss.config.ts` to customize your design system, or add styles to `public/styles/custom.css`.
### 5. Add Components
Create new Leptos components in `src/components/` and use them in your layouts.
## Framework Updates
Your implementation stays up-to-date with the framework:
```bash
# Check for updates
just update-framework
# Apply updates safely
just update
```
The framework will never overwrite your custom code - only configuration files are updated, and you're always asked first.
## Getting Help
- 📖 [Rustelo Documentation](https://docs.rustelo.dev)
- 💬 [Community Discord](https://discord.gg/rustelo)
- 🐛 [Issue Tracker](https://github.com/your-org/rustelo/issues)
- 🎓 [Examples Repository](https://github.com/your-org/rustelo-examples)
## What's Next?
- Customize your `content/menu.toml` to add navigation
- Add authentication with `cargo rustelo features enable auth oauth2`
- Set up analytics with `cargo rustelo features enable analytics tracking`
- Deploy to production with `just prepare-deploy`
Happy building! 🚀

View file

@ -1,37 +0,0 @@
# Site Navigation Configuration
# Edit this file to customize your site's navigation
[[main]]
name = "Home"
url = "/"
icon = "i-heroicons-home"
[[main]]
name = "Blog"
url = "/blog"
icon = "i-heroicons-document-text"
[[main]]
name = "About"
url = "/about"
icon = "i-heroicons-information-circle"
# Footer navigation
[[footer]]
name = "Privacy"
url = "/privacy"
[[footer]]
name = "Terms"
url = "/terms"
# Social links
[[social]]
name = "GitHub"
url = "{{repository}}"
icon = "i-lucide-github"
[[social]]
name = "RSS"
url = "/feed.xml"
icon = "i-heroicons-rss"

View file

@ -1,45 +0,0 @@
# About {{project_name}}
{{description}}
## Built with Rustelo
This site is built with the [Rustelo framework](https://rustelo.dev), a modern Rust web framework that combines the power of Rust with the flexibility of modern web development.
### Key Features
- **Performance**: Built with Rust for maximum performance and safety
- **Modern UI**: Responsive design with UnoCSS and component-based architecture
- **Content Management**: Markdown-based content with automatic routing
- **Developer Experience**: Hot reload, type safety, and excellent tooling
- **Maintainable**: Framework-as-dependency architecture keeps your code clean
## Getting Started
This implementation uses the **basic** template, which provides:
- Simple blog and pages structure
- Responsive design out of the box
- Essential development tools
- Framework update management
- Asset optimization
## Technology Stack
- **Backend**: Rust with Leptos framework
- **Frontend**: Leptos with UnoCSS for styling
- **Content**: Markdown with front matter
- **Build**: Vite with hot module replacement
- **Deployment**: Docker-ready with cross-compilation support
## Contact
For questions or support:
- **Author**: {{author}}
- **Framework**: [Rustelo Documentation](https://docs.rustelo.dev)
- **Repository**: {{repository}}
---
*This page was generated from the basic template. Edit `content/pages/about.md` to customize it.*

View file

@ -1,46 +0,0 @@
[package]
name = "jpl-website-client"
version = "0.1.0"
edition = "2021"
description = "jpl-website client from Rustelo framework"
[lib]
crate-type = ["cdylib"]
[dependencies]
# Workspace crates
jpl-website-shared = { workspace = true }
jpl-website-ssr = { workspace = true }
# Rustelo Framework (client features)
rustelo-web = { workspace = true }
rustelo-content = { workspace = true }
# Leptos Client
leptos = { workspace = true, features = ["hydrate"] }
leptos_router = { workspace = true }
leptos_meta = { workspace = true }
# WASM
web-sys = { workspace = true }
wasm-bindgen = { workspace = true }
console_error_panic_hook = { workspace = true }
# Data
serde = { workspace = true }
serde_json = { workspace = true }
uuid = { workspace = true, features = ["js"] }
chrono = { workspace = true }
[features]
default = ["hydrate"]
hydrate = [
"leptos/hydrate",
"jpl-website-shared/hydrate",
"jpl-website-ssr/hydrate"
]
csr = [
"leptos/csr",
"jpl-website-shared/csr",
"jpl-website-ssr/csr"
]

View file

@ -1,12 +0,0 @@
//! jpl-website client-side WASM entry point
use leptos::prelude::*;
use wasm_bindgen::prelude::wasm_bindgen;
use jpl-website_ssr::App;
#[wasm_bindgen]
pub fn hydrate() {
console_error_panic_hook::set_once();
mount_to_body(App);
}

View file

@ -1,50 +0,0 @@
[package]
name = "jpl-website-server"
version = "0.1.0"
edition = "2021"
description = "jpl-website server from Rustelo framework"
[[bin]]
name = "jpl-website-server"
path = "src/main.rs"
[dependencies]
# Workspace crates
jpl-website-shared = { workspace = true }
jpl-website-client = { workspace = true }
jpl-website-ssr = { workspace = true }
# Rustelo Framework
rustelo-core = { workspace = true }
rustelo-web = { workspace = true }
rustelo-content = { workspace = true }
rustelo-auth = { workspace = true }
# Leptos SSR
leptos = { workspace = true, features = ["ssr"] }
leptos_axum = { workspace = true }
leptos_router = { workspace = true, features = ["ssr"] }
leptos_meta = { workspace = true, features = ["ssr"] }
leptos_config = { workspace = true }
# Web Server
axum = { workspace = true }
tower = { workspace = true }
tower-http = { workspace = true }
tokio = { workspace = true }
# Data
serde = { workspace = true }
serde_json = { workspace = true }
uuid = { workspace = true }
chrono = { workspace = true }
anyhow = { workspace = true }
thiserror = { workspace = true }
[features]
default = ["ssr"]
ssr = [
"leptos/ssr",
"jpl-website-shared/ssr",
"jpl-website-ssr/ssr"
]

View file

@ -1,29 +0,0 @@
//! jpl-website server main entry point
use axum::Router;
use leptos::prelude::*;
use leptos_axum::{generate_route_list, LeptosRoutes};
use jpl-website_ssr::App;
#[tokio::main]
async fn main() {
println!("🚀 Starting jpl-website server...");
let conf = leptos_config::get_configuration(None).unwrap();
let leptos_options = conf.leptos_options;
let addr = leptos_options.site_addr;
// Generate the list of routes in your Leptos App
let routes = generate_route_list(App);
let app = Router::new()
.leptos_routes(&leptos_options, routes, App)
.fallback(|| async { "jpl-website - Page not found" });
println!("🌐 jpl-website server running at http://{}", &addr);
let listener = tokio::net::TcpListener::bind(&addr).await.unwrap();
axum::serve(listener, app.into_make_service())
.await
.unwrap();
}

View file

@ -1,32 +0,0 @@
[package]
name = "jpl-website-shared"
version = "0.1.0"
edition = "2021"
description = "jpl-website shared components from Rustelo framework"
[dependencies]
# Rustelo Framework (shared features)
rustelo-core = { workspace = true }
rustelo-web = { workspace = true }
rustelo-content = { workspace = true }
# Leptos (isomorphic)
leptos = { workspace = true }
leptos_router = { workspace = true }
leptos_meta = { workspace = true }
# Data
serde = { workspace = true }
serde_json = { workspace = true }
uuid = { workspace = true }
chrono = { workspace = true }
[features]
default = []
ssr = [
"leptos/ssr",
"leptos_router/ssr",
"leptos_meta/ssr"
]
hydrate = ["leptos/hydrate"]
csr = ["leptos/csr"]

View file

@ -1,35 +0,0 @@
//! jpl-website shared components and utilities
use leptos::prelude::*;
/// Shared component that works on both server and client
#[component]
pub fn SharedComponent() -> impl IntoView {
view! {
<div class="shared-component">
<h2>"jpl-website Shared Component"</h2>
<p>"This component works on both server and client"</p>
</div>
}
}
/// Shared data structures for CMS
#[derive(Clone, Debug)]
pub struct CmsPage {
pub id: String,
pub title: String,
pub content: String,
pub slug: String,
}
/// Server-only code
#[cfg(feature = "ssr")]
pub mod server {
//! Server-only code for jpl-website
}
/// Client-only code
#[cfg(not(feature = "ssr"))]
pub mod client {
//! Client-only code for jpl-website
}

View file

@ -1,42 +0,0 @@
[package]
name = "jpl-website-ssr"
version = "0.1.0"
edition = "2021"
description = "jpl-website SSR components from Rustelo framework"
[dependencies]
# Workspace crates
jpl-website-shared = { workspace = true }
# Rustelo Framework
rustelo-core = { workspace = true }
rustelo-web = { workspace = true }
rustelo-content = { workspace = true }
# Leptos SSR
leptos = { workspace = true }
leptos_router = { workspace = true }
leptos_meta = { workspace = true }
# Data
serde = { workspace = true }
serde_json = { workspace = true }
uuid = { workspace = true }
chrono = { workspace = true }
[features]
default = []
ssr = [
"leptos/ssr",
"leptos_router/ssr",
"leptos_meta/ssr",
"jpl-website-shared/ssr"
]
hydrate = [
"leptos/hydrate",
"jpl-website-shared/hydrate"
]
csr = [
"leptos/csr",
"jpl-website-shared/csr"
]

View file

@ -1,94 +0,0 @@
//! jpl-website SSR components and routing
use leptos::prelude::*;
use leptos_meta::*;
use leptos_router::*;
use jpl-website_shared::SharedComponent;
/// Main application component
#[component]
pub fn App() -> impl IntoView {
provide_meta_context();
view! {
<Stylesheet id="leptos" href="/pkg/jpl-website.css"/>
<Title text="jpl-website - Rustelo CMS"/>
<Router>
<main>
<Routes>
<Route path="" view=HomePage/>
<Route path="/about" view=AboutPage/>
<Route path="/*any" view=NotFound/>
</Routes>
</main>
</Router>
}
}
/// Home page component
#[component]
fn HomePage() -> impl IntoView {
view! {
<div class="container mx-auto px-4 py-8">
<h1 class="text-4xl font-bold text-center mb-8">
"Welcome to jpl-website"
</h1>
<p class="text-lg text-center mb-8">
"A powerful CMS built with Rustelo framework"
</p>
<SharedComponent/>
<div class="text-center">
<a href="/about" class="btn btn-primary">
"Learn More"
</a>
</div>
</div>
}
}
/// About page component
#[component]
fn AboutPage() -> impl IntoView {
view! {
<div class="container mx-auto px-4 py-8">
<h1 class="text-3xl font-bold mb-6">"About jpl-website"</h1>
<p class="text-lg mb-4">
"This is a CMS application built with the Rustelo framework."
</p>
<p class="mb-4">
"Features include:"
</p>
<ul class="list-disc list-inside mb-6">
<li>"Content management"</li>
<li>"User authentication"</li>
<li>"Admin interface"</li>
<li>"SEO optimization"</li>
</ul>
<a href="/" class="btn btn-secondary">
"Back to Home"
</a>
</div>
}
}
/// 404 Not Found page
#[component]
fn NotFound() -> impl IntoView {
#[cfg(feature = "ssr")]
{
let resp = expect_context::<leptos_axum::ResponseOptions>();
resp.set_status(axum::http::StatusCode::NOT_FOUND);
}
view! {
<div class="container mx-auto px-4 py-8 text-center">
<h1 class="text-6xl font-bold text-error mb-4">"404"</h1>
<h2 class="text-2xl mb-4">"Page Not Found"</h2>
<p class="mb-6">"The page you're looking for doesn't exist."</p>
<a href="/" class="btn btn-primary">
"Go Home"
</a>
</div>
}
}

View file

@ -1,56 +0,0 @@
# =============================================================================
# {{project_name|upper}} - RUSTELO FRAMEWORK IMPLEMENTATION
# =============================================================================
# jpl-website implementation using Rustelo modular justfile system
#
# This justfile uses the framework fallback pattern:
# 1. Try to load local implementation-specific task files
# 2. Fall back to framework defaults if local versions don't exist
#
# This allows jpl-website to:
# - Override any framework task with custom implementation
# - Use framework defaults for common tasks
# - Add jpl-website-specific tasks in local justfile modules
# Set shell for commands
set shell := ["bash", "-c"]
# =============================================================================
# IMPLEMENTATION MODULE IMPORTS WITH FRAMEWORK FALLBACK
# =============================================================================
# Local implementation-specific modules take precedence over framework defaults
mod? local-base 'justfiles/base.just' # Local jpl-website base tasks
mod? base '.rustelo-assets//justfiles/base.just' # Framework from assets
mod? local-database 'justfiles/database.just' # Local jpl-website database tasks
mod? database '.rustelo-assets//justfiles/database.just' # Framework from assets
mod? local-quality 'justfiles/quality.just' # Local jpl-website quality tasks
mod? quality '.rustelo-assets//justfiles/quality.just' # Framework from assets
mod? local-docs 'justfiles/docs.just' # Local jpl-website docs tasks
mod? docs '.rustelo-assets//justfiles/docs.just' # Framework from assets
mod? local-content 'justfiles/content.just' # Local jpl-website content tasks
mod? content '.rustelo-assets//justfiles/content.just' # Framework from assets
mod? local-testing 'justfiles/testing.just' # Local jpl-website testing tasks
mod? testing '.rustelo-assets//justfiles/testing.just' # Framework from assets
mod? local-build 'justfiles/build.just' # Local jpl-website build tasks
mod? build-tasks '.rustelo-assets//justfiles/build.just' # Framework from assets
# =============================================================================
# IMPLEMENTATION-SPECIFIC COMMANDS
# =============================================================================
# Default recipe to display help
default:
@just --list
# =============================================================================
# LOCAL CUSTOMIZATION
# =============================================================================
# Custom jpl-website-specific tasks can be added to justfiles/ directory
# They will take precedence over framework defaults via the fallback pattern

View file

@ -1,51 +0,0 @@
{
"name": "jpl-website",
"version": "0.1.0",
"description": "{{description}}",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"check": "npm run check:css && npm run check:js",
"check:css": "unocss --check",
"check:js": "eslint . --ext .js,.ts",
"format": "prettier --write .",
"format:check": "prettier --check ."
},
"devDependencies": {
"@unocss/cli": "^0.58.0",
"@unocss/reset": "^0.58.0",
"unocss": "^0.58.0",
"vite": "^5.0.0",
"autoprefixer": "^10.4.16",
"postcss": "^8.4.32",
"prettier": "^3.1.0",
"eslint": "^8.55.0",
"@typescript-eslint/eslint-plugin": "^6.13.0",
"@typescript-eslint/parser": "^6.13.0",
"typescript": "^5.3.0"
},
"dependencies": {
"@iconify-json/heroicons": "^1.1.15",
"@iconify-json/lucide": "^1.1.134"
},
"engines": {
"node": ">=18.0.0",
"npm": ">=9.0.0"
},
"keywords": [
"rustelo",
"leptos",
"rust",
"web",
"fullstack"
],
"author": "{{author}}",
"license": "MIT",
"repository": {
"type": "git",
"url": "{{repository}}"
}
}

View file

@ -1,68 +0,0 @@
# Public Assets Directory
This directory contains static assets that are served directly by your web server.
## Structure
```
public/
├── favicon.ico # Site favicon
├── robots.txt # Search engine directives
├── sitemap.xml # Site map for SEO
├── images/ # Image assets
├── styles/ # Additional CSS files
├── scripts/ # Client-side JavaScript
└── documents/ # Downloadable files
```
## Asset Processing
- **Images**: Automatically optimized and served
- **Styles**: Processed through UnoCSS and PostCSS
- **Scripts**: Bundled through Vite
- **Documents**: Served as-is
## Usage
### Images
Place images in `public/images/` and reference them:
```html
<img src="/images/logo.png" alt="Logo" />
```
### Custom Styles
Add custom CSS to `public/styles/custom.css`:
```css
.my-custom-class {
/* Your styles */
}
```
### Client Scripts
Add JavaScript to `public/scripts/`:
```javascript
// Custom client-side code
console.log('Hello from {{project_name}}!');
```
## SEO Files
The basic template includes:
- `robots.txt` - Search engine crawling rules
- `sitemap.xml` - Generated automatically from your content
- `manifest.json` - PWA configuration (optional)
## Optimization
All assets are automatically:
- Compressed (gzip/brotli)
- Cached with appropriate headers
- Served through CDN (if configured)
- Optimized for performance
## Customization
You can customize asset handling in:
- `vite.config.js` - Build-time processing
- `rustelo.toml` - Runtime serving
- `unocss.config.ts` - CSS processing

View file

@ -1,140 +0,0 @@
# jpl-website - Rustelo Framework Configuration
# Generated from basic template v0.1.0
#
# This file controls how your implementation depends on and interacts with the Rustelo framework.
[project]
name = "jpl-website"
description = "{{description}}"
template = "basic"
created = "2025-10-28T18:46:17.145459+00:00"
[dependencies]
# How this implementation depends on the Rustelo framework
strategy = "{{dependency_strategy}}"
[dependencies.git]
repository = "https://github.com/your-org/rustelo.git"
branch = "main"
# tag = "v1.0.0" # Use for production deployments
[dependencies.crates_io]
# Production-ready approach using published crates
[dependencies.path]
# Local development only
base_path = "../rustelo/crates"
[versions]
# Framework crate versions
rustelo-core = "{{rustelo_core_version}}"
rustelo-web = "{{rustelo_web_version}}"
rustelo-auth = "{{rustelo_auth_version}}"
rustelo-content = "{{rustelo_content_version}}"
[features]
# Enabled framework features for this implementation
rustelo-core = []
rustelo-web = ["ssr"]
rustelo-auth = []
rustelo-content = ["markdown"]
[features.available]
# Available features for discovery (updated automatically)
rustelo-core = ["tracing", "metrics", "caching", "async-runtime"]
rustelo-web = ["islands", "streaming", "hydration", "prerendering"]
rustelo-auth = ["oauth2", "jwt", "session", "2fa", "social-login"]
rustelo-content = ["search", "cms", "multilang", "versioning"]
rustelo-analytics = ["tracking", "events", "reporting", "dashboards"]
rustelo-realtime = ["websockets", "sse", "notifications", "collaboration"]
# Asset management configuration
[assets]
source = "local"
download_location = ".rustelo-assets/"
[assets.remote]
base_url = "https://raw.githubusercontent.com/your-org/rustelo/main/templates"
template_variant = "basic"
cache_enabled = true
cache_ttl = "24h"
cache_directory = ".rustelo-cache"
fallback_to_embedded = true
[assets.local]
framework_path = "{{framework_path}}"
watch_changes = {{dev_mode}}
sync_assets = ["templates", "configs", "scripts"]
[development]
enabled = {{dev_mode}}
auto_detect_changes = true
auto_sync_assets = {{dev_mode}}
validate_assets = true
hot_reload = true
# Update and maintenance configuration
[update]
policy = "conservative"
backup = true
backup_dir = ".rustelo-backups"
[update.automation]
enabled = {{automation_enabled}}
check_frequency = "{{check_frequency}}"
check_targets = ["framework", "dependencies", "features"]
notify_methods = {{notify_methods}}
auto_update_policy = "notify_only"
fail_on_critical_updates = false
update_log_file = ".rustelo/update-history.log"
# Files safe to regenerate during updates
regenerate = [
"justfile",
"package.json",
"unocss.config.ts",
"rustelo.toml"
]
# Protected files (never overwritten)
protected = [
"src/**/*.rs",
"content/**/*",
"public/**/*",
"local-tasks.just",
"unocss.local.ts",
"rustelo.local.toml",
".env*"
]
# Contribution detection and sharing
[contributions]
enabled = {{contributions_enabled}}
scan_frequency = "monthly"
detect_types = ["improvements", "bug_fixes", "new_features", "documentation"]
confidence_threshold = 0.7
auto_package = false
package_directory = ".rustelo/contributions"
notify_methods = ["log", "file"]
# Notification preferences
[notifications]
enabled = true
[notifications.channels]
console = { enabled = true, level = "info" }
file = { enabled = {{file_notifications}}, path = ".rustelo/notifications.json" }
# CI/CD integration
[ci_cd]
enabled = {{ci_cd_enabled}}
exit_codes = true
cache_results = true
cache_key = "rustelo-jpl-website-0.1.0"
# Template-specific configuration
[template.basic]
# Basic template doesn't require additional configuration
content_types = ["blog", "pages"]
ui_framework = "unocss"
build_system = "vite"

View file

@ -1,107 +0,0 @@
import { defineConfig, presetUno, presetIcons, presetWebFonts } from 'unocss'
export default defineConfig({
// Basic UnoCSS configuration for jpl-website
presets: [
presetUno(),
presetIcons({
collections: {
heroicons: () => import('@iconify-json/heroicons/icons.json').then(i => i.default),
lucide: () => import('@iconify-json/lucide/icons.json').then(i => i.default),
}
}),
presetWebFonts({
fonts: {
sans: 'Inter:400,500,600,700',
mono: 'JetBrains Mono:400,500',
}
})
],
// Theme configuration
theme: {
colors: {
primary: {
50: '#fef7ee',
100: '#fdedd3',
200: '#fbd6a5',
300: '#f8b86d',
400: '#f59332',
500: '#f2751a',
600: '#e35a0f',
700: '#bc4210',
800: '#953515',
900: '#792d14',
},
gray: {
50: '#f9fafb',
100: '#f3f4f6',
200: '#e5e7eb',
300: '#d1d5db',
400: '#9ca3af',
500: '#6b7280',
600: '#4b5563',
700: '#374151',
800: '#1f2937',
900: '#111827',
}
},
fontFamily: {
sans: ['Inter', 'system-ui', 'sans-serif'],
mono: ['JetBrains Mono', 'monospace'],
}
},
// Content paths for purging
content: {
filesystem: [
'src/**/*.rs',
'templates/**/*.html',
'content/**/*.md'
]
},
// Custom shortcuts for common patterns
shortcuts: {
'btn': 'px-4 py-2 rounded-lg font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2',
'btn-primary': 'btn bg-primary-600 text-white hover:bg-primary-700',
'btn-secondary': 'btn bg-gray-200 text-gray-900 hover:bg-gray-300',
'btn-ghost': 'btn bg-transparent text-gray-700 hover:bg-gray-100',
'card': 'bg-white rounded-lg shadow-sm border border-gray-200 p-6',
'input': 'w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500',
'nav-link': 'text-gray-700 hover:text-primary-600 px-3 py-2 rounded-md text-sm font-medium transition-colors',
'nav-link-active': 'text-primary-600 bg-primary-50 px-3 py-2 rounded-md text-sm font-medium',
},
// Custom rules for Rustelo-specific patterns
rules: [
// Custom spacing for Rustelo layouts
[/^rustelo-container$/, () => ({ 'max-width': '1200px', 'margin': '0 auto', 'padding': '0 1rem' })],
[/^rustelo-section$/, () => ({ 'padding': '4rem 0' })],
[/^rustelo-hero$/, () => ({ 'padding': '8rem 0 6rem' })],
],
// Safelist important classes that might be used dynamically
safelist: [
'bg-primary-600',
'text-primary-600',
'border-primary-600',
'ring-primary-500',
'btn-primary',
'btn-secondary',
'btn-ghost',
'card',
'nav-link',
'nav-link-active',
],
// Development configuration
cli: {
entry: [
{
patterns: ['src/**/*.rs'],
outFile: 'public/styles/unocss.css'
}
]
}
})

View file

@ -1,31 +0,0 @@
# Development Environment Variables for jpl-website
# This file contains development-specific settings
# Application Settings
SECRET_KEY=dev-secret-key-change-in-production
RUST_LOG=debug
# Database Settings (SQLite for development)
DATABASE_URL=sqlite:data/dev_database.db
# Server Settings
LEPTOS_SITE_ADDR=127.0.0.1:3030
LEPTOS_SITE_ROOT=/
LEPTOS_SITE_PKG_DIR=pkg
LEPTOS_SITE_NAME=jpl-website
# 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
RUSTELO_DEBUG_MODE=true
# Asset Settings
RUSTELO_STATIC_DIR=public
RUSTELO_UPLOAD_DIR=uploads
RUSTELO_MAX_FILE_SIZE=52428800 # 50MB in bytes for development

View file

@ -1,44 +0,0 @@
# Development Configuration for Rustelo Implementation
# This file overrides production settings for development
[app]
name = "jpl-website"
version = "0.1.0"
environment = "development"
debug = true
[server]
host = "127.0.0.1"
port = 3030
workers = 1
auto_reload = true
[database]
# Development database - defaults to SQLite
url = "sqlite:data/dev_database.db"
max_connections = 5
min_connections = 1
create_database = true
[features]
# Development feature flags
content_static = true
auth = false
email = false
metrics = true # Enable metrics in development
tls = false
[assets]
# Development asset configuration
static_dir = "public"
upload_dir = "uploads"
max_file_size = "50MB" # Larger limit for development
[security]
# Development security settings (use defaults)
secret_key = "dev-secret-key-change-in-production"
cors_origins = ["http://localhost:3030", "http://127.0.0.1:3030"]
[logging]
level = "debug"
format = "pretty"

View file

@ -1,38 +0,0 @@
# Main Configuration for Rustelo Implementation
# This file contains production settings
[app]
name = "jpl-website"
version = "0.1.0"
environment = "production"
[server]
host = "127.0.0.1"
port = 3000
workers = 4
[database]
# Database configuration will be loaded from environment variables
# Set DATABASE_URL in your .env file
url = "${DATABASE_URL}"
max_connections = 10
min_connections = 1
[features]
# Feature flags for this implementation
content_static = true
auth = false
email = false
metrics = false
tls = false
[assets]
# Static asset configuration
static_dir = "public"
upload_dir = "uploads"
max_file_size = "10MB"
[security]
# Security settings
secret_key = "${SECRET_KEY}"
cors_origins = ["http://localhost:3000"]

View file

@ -1,25 +0,0 @@
# Application Configuration for jpl-website
[app]
name = "jpl-website"
version = "0.1.0"
description = "A Rustelo basic implementation"
author = "{{project_author}}"
[runtime]
# Runtime configuration
tokio_threads = 4
blocking_threads = 512
[logging]
# Logging configuration
level = "info"
format = "json"
target = "stdout"
[paths]
# Path configuration
static_files = "public"
templates = "templates"
uploads = "uploads"
cache = "cache"

View file

@ -1,27 +0,0 @@
# Database Configuration for jpl-website
[database]
# Main database connection
url = "${DATABASE_URL}"
max_connections = 10
min_connections = 1
acquire_timeout = 30
idle_timeout = 600
[migrations]
# Migration settings
auto_migrate = true
migration_dir = "migrations"
[sqlite]
# SQLite-specific settings (for development)
journal_mode = "WAL"
synchronous = "NORMAL"
foreign_keys = true
busy_timeout = 30000
[postgresql]
# PostgreSQL-specific settings (for production)
application_name = "jpl-website"
statement_timeout = "30s"
lock_timeout = "10s"

View file

@ -1,68 +0,0 @@
# Content Directory
This directory contains your site's content files. The basic template provides a simple content structure that you can customize.
## Structure
```
content/
├── blog/ # Blog posts
├── pages/ # Static pages
├── menu.toml # Site navigation
└── config.toml # Content configuration
```
## Usage
### Blog Posts
Add markdown files to `blog/` directory:
```markdown
# My First Post
Date: 2024-01-01
Author: {{author}}
Tags: rustelo, web-development
Your content here...
```
### Pages
Add static pages to `pages/` directory:
```markdown
# About
This is the about page for {{project_name}}.
```
### Navigation
Edit `menu.toml` to customize site navigation:
```toml
[[main]]
name = "Home"
url = "/"
[[main]]
name = "Blog"
url = "/blog"
[[main]]
name = "About"
url = "/about"
```
## Content Processing
Content is processed by the Rustelo content system, which supports:
- Markdown rendering
- Front matter parsing
- Automatic routing
- SEO optimization
- Search indexing (if enabled)
## Customization
You can extend the content structure by:
1. Adding new directories for different content types
2. Customizing front matter fields
3. Creating content templates
4. Adding content processing hooks

View file

@ -1,92 +0,0 @@
# Welcome to Rustelo!
Date: {{generation_timestamp}}
Author: {{author}}
Tags: rustelo, welcome, getting-started
Description: Welcome to your new Rustelo implementation! This guide will help you get started.
---
Welcome to **{{project_name}}**, your new Rustelo-powered web application! 🦀✨
## What is Rustelo?
Rustelo is a modern Rust web framework that provides:
- 🚀 **Fast Development** - Hot reload, efficient builds, and great DX
- 🏗️ **Framework as Dependency** - No forks, clean updates, easy maintenance
- 🎨 **Modern UI** - Built-in UnoCSS, components, and responsive design
- 📝 **Content Management** - Markdown support, static generation, SEO
- 🔐 **Authentication** - Built-in auth system with multiple providers
- 📦 **Asset Management** - Smart asset resolution and optimization
## Getting Started
Your new implementation is ready to go! Here are your next steps:
### 1. Start Development Server
```bash
just dev
```
This will start your application at [http://localhost:3030](http://localhost:3030) with hot reload enabled.
### 2. Explore the Structure
```
{{project_name}}/
├── src/ # Your Rust code
├── content/ # Content files (this blog!)
├── public/ # Static assets
├── justfile # Development tasks
└── rustelo-deps.toml # Framework configuration
```
### 3. Create Your First Page
Add a new markdown file to `content/pages/`:
```markdown
# My Custom Page
This is my custom content!
```
### 4. Customize Styling
Edit `unocss.config.ts` to customize your design system, or add styles to `public/styles/custom.css`.
### 5. Add Components
Create new Leptos components in `src/components/` and use them in your layouts.
## Framework Updates
Your implementation stays up-to-date with the framework:
```bash
# Check for updates
just update-framework
# Apply updates safely
just update
```
The framework will never overwrite your custom code - only configuration files are updated, and you're always asked first.
## Getting Help
- 📖 [Rustelo Documentation](https://docs.rustelo.dev)
- 💬 [Community Discord](https://discord.gg/rustelo)
- 🐛 [Issue Tracker](https://github.com/your-org/rustelo/issues)
- 🎓 [Examples Repository](https://github.com/your-org/rustelo-examples)
## What's Next?
- Customize your `content/menu.toml` to add navigation
- Add authentication with `cargo rustelo features enable auth oauth2`
- Set up analytics with `cargo rustelo features enable analytics tracking`
- Deploy to production with `just prepare-deploy`
Happy building! 🚀

View file

@ -1,37 +0,0 @@
# Site Navigation Configuration
# Edit this file to customize your site's navigation
[[main]]
name = "Home"
url = "/"
icon = "i-heroicons-home"
[[main]]
name = "Blog"
url = "/blog"
icon = "i-heroicons-document-text"
[[main]]
name = "About"
url = "/about"
icon = "i-heroicons-information-circle"
# Footer navigation
[[footer]]
name = "Privacy"
url = "/privacy"
[[footer]]
name = "Terms"
url = "/terms"
# Social links
[[social]]
name = "GitHub"
url = "{{repository}}"
icon = "i-lucide-github"
[[social]]
name = "RSS"
url = "/feed.xml"
icon = "i-heroicons-rss"

View file

@ -1,45 +0,0 @@
# About {{project_name}}
{{description}}
## Built with Rustelo
This site is built with the [Rustelo framework](https://rustelo.dev), a modern Rust web framework that combines the power of Rust with the flexibility of modern web development.
### Key Features
- **Performance**: Built with Rust for maximum performance and safety
- **Modern UI**: Responsive design with UnoCSS and component-based architecture
- **Content Management**: Markdown-based content with automatic routing
- **Developer Experience**: Hot reload, type safety, and excellent tooling
- **Maintainable**: Framework-as-dependency architecture keeps your code clean
## Getting Started
This implementation uses the **basic** template, which provides:
- Simple blog and pages structure
- Responsive design out of the box
- Essential development tools
- Framework update management
- Asset optimization
## Technology Stack
- **Backend**: Rust with Leptos framework
- **Frontend**: Leptos with UnoCSS for styling
- **Content**: Markdown with front matter
- **Build**: Vite with hot module replacement
- **Deployment**: Docker-ready with cross-compilation support
## Contact
For questions or support:
- **Author**: {{author}}
- **Framework**: [Rustelo Documentation](https://docs.rustelo.dev)
- **Repository**: {{repository}}
---
*This page was generated from the basic template. Edit `content/pages/about.md` to customize it.*

View file

@ -1,117 +0,0 @@
# jpl-website - Rustelo Implementation
# Generated from basic template
# Self-contained justfile for implementation development
# Default task - show available commands
default:
@just --list
# Development server with hot reload
dev:
@echo "🚀 Starting jpl-website development server..."
@echo "📁 Implementation: jpl-website"
@echo "🏷️ Template: basic"
cargo rustelo dev --port 3030 --watch
# Build the application
build:
@echo "🔨 Building jpl-website..."
cargo rustelo build
# Build for production
build-release:
@echo "🔨 Building jpl-website for production..."
cargo rustelo build --release
# Run tests
test:
@echo "🧪 Running tests..."
cargo test
# Format code
fmt:
@echo "🎨 Formatting code..."
cargo fmt
# Lint code
lint:
@echo "📝 Linting code..."
cargo clippy -- -D warnings
# Clean build artifacts
clean:
@echo "🧹 Cleaning build artifacts..."
cargo clean
rm -rf dist/
# Update framework dependencies
update-framework:
@echo "🔄 Checking for framework updates..."
cargo rustelo update --check
# Apply framework updates
update:
@echo "🔄 Applying framework updates..."
cargo rustelo update
# Sync framework assets (for local development)
sync-assets:
@echo "📦 Syncing framework assets..."
cargo rustelo assets sync
# Setup development environment
setup:
@echo "⚙️ Setting up development environment..."
@echo "Installing frontend dependencies..."
npm install
@echo "✅ Setup complete!"
# Start development with frontend build
dev-full: setup
@echo "🚀 Starting full development environment..."
just dev
# Production deployment preparation
prepare-deploy: build-release
@echo "📦 Preparing for deployment..."
@echo "✅ Ready for deployment"
# Quick health check
check:
@echo "🔍 Running health checks..."
cargo check
npm run check
@echo "✅ Health check complete"
# View logs (implementation-specific)
logs:
@echo "📜 Viewing application logs..."
tail -f .rustelo/logs/app.log
# Configuration management
config:
@echo "⚙️ Configuration:"
@echo "Project: jpl-website"
@echo "Template: basic"
@echo "Config file: rustelo-deps.toml"
@cat rustelo-deps.toml
# Content management helpers
content-new title:
@echo "📝 Creating new content: {{title}}"
mkdir -p content/posts
echo "# {{title}}" > "content/posts/{{title}}.md"
echo "" >> "content/posts/{{title}}.md"
echo "Date: $(date -I)" >> "content/posts/{{title}}.md"
echo "Author: {{author}}" >> "content/posts/{{title}}.md"
echo "" >> "content/posts/{{title}}.md"
echo "Your content here..." >> "content/posts/{{title}}.md"
# Backup important files
backup:
@echo "💾 Creating backup..."
tar -czf "backup-$(date +%Y%m%d_%H%M%S).tar.gz" src/ content/ config/ rustelo-deps.toml Cargo.toml
@echo "✅ Backup created"
# Include local tasks if they exist
import? 'local-tasks.just'

View file

@ -1,51 +0,0 @@
{
"name": "jpl-website",
"version": "0.1.0",
"description": "{{description}}",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"check": "npm run check:css && npm run check:js",
"check:css": "unocss --check",
"check:js": "eslint . --ext .js,.ts",
"format": "prettier --write .",
"format:check": "prettier --check ."
},
"devDependencies": {
"@unocss/cli": "^0.58.0",
"@unocss/reset": "^0.58.0",
"unocss": "^0.58.0",
"vite": "^5.0.0",
"autoprefixer": "^10.4.16",
"postcss": "^8.4.32",
"prettier": "^3.1.0",
"eslint": "^8.55.0",
"@typescript-eslint/eslint-plugin": "^6.13.0",
"@typescript-eslint/parser": "^6.13.0",
"typescript": "^5.3.0"
},
"dependencies": {
"@iconify-json/heroicons": "^1.1.15",
"@iconify-json/lucide": "^1.1.134"
},
"engines": {
"node": ">=18.0.0",
"npm": ">=9.0.0"
},
"keywords": [
"rustelo",
"leptos",
"rust",
"web",
"fullstack"
],
"author": "{{author}}",
"license": "MIT",
"repository": {
"type": "git",
"url": "{{repository}}"
}
}

View file

@ -1,68 +0,0 @@
# Public Assets Directory
This directory contains static assets that are served directly by your web server.
## Structure
```
public/
├── favicon.ico # Site favicon
├── robots.txt # Search engine directives
├── sitemap.xml # Site map for SEO
├── images/ # Image assets
├── styles/ # Additional CSS files
├── scripts/ # Client-side JavaScript
└── documents/ # Downloadable files
```
## Asset Processing
- **Images**: Automatically optimized and served
- **Styles**: Processed through UnoCSS and PostCSS
- **Scripts**: Bundled through Vite
- **Documents**: Served as-is
## Usage
### Images
Place images in `public/images/` and reference them:
```html
<img src="/images/logo.png" alt="Logo" />
```
### Custom Styles
Add custom CSS to `public/styles/custom.css`:
```css
.my-custom-class {
/* Your styles */
}
```
### Client Scripts
Add JavaScript to `public/scripts/`:
```javascript
// Custom client-side code
console.log('Hello from {{project_name}}!');
```
## SEO Files
The basic template includes:
- `robots.txt` - Search engine crawling rules
- `sitemap.xml` - Generated automatically from your content
- `manifest.json` - PWA configuration (optional)
## Optimization
All assets are automatically:
- Compressed (gzip/brotli)
- Cached with appropriate headers
- Served through CDN (if configured)
- Optimized for performance
## Customization
You can customize asset handling in:
- `vite.config.js` - Build-time processing
- `rustelo.toml` - Runtime serving
- `unocss.config.ts` - CSS processing

View file

@ -1,14 +0,0 @@
User-agent: *
Allow: /
# Sitemap location
Sitemap: {{base_url}}/sitemap.xml
# Crawl-delay (optional, in seconds)
# Crawl-delay: 1
# Disallow specific paths (customize as needed)
Disallow: /admin
Disallow: /.rustelo/
Disallow: /target/
Disallow: /node_modules/

View file

@ -1,145 +0,0 @@
# jpl-website - Rustelo Framework Configuration
# Generated from basic template v0.1.0
#
# This file controls how your implementation depends on and interacts with the Rustelo framework.
[project]
name = "jpl-website"
description = "{{description}}"
template = "basic"
created = "2025-10-28T18:46:17.145459+00:00"
[dependencies]
# How this implementation depends on the Rustelo framework
strategy = "{{dependency_strategy}}"
[dependencies.git]
repository = "https://github.com/your-org/rustelo.git"
branch = "main"
# tag = "v1.0.0" # Use for production deployments
[dependencies.crates_io]
# Production-ready approach using published crates
[dependencies.path]
# Local development only
base_path = "../rustelo/crates"
[versions]
# Framework crate versions
rustelo-core = "{{rustelo_core_version}}"
rustelo-web = "{{rustelo_web_version}}"
rustelo-auth = "{{rustelo_auth_version}}"
rustelo-content = "{{rustelo_content_version}}"
[features]
# Enabled framework features for this implementation
rustelo-core = []
rustelo-web = ["ssr"]
rustelo-auth = []
rustelo-content = ["markdown"]
[features.available]
# Available features for discovery (updated automatically)
rustelo-core = ["tracing", "metrics", "caching", "async-runtime"]
rustelo-web = ["islands", "streaming", "hydration", "prerendering"]
rustelo-auth = ["oauth2", "jwt", "session", "2fa", "social-login"]
rustelo-content = ["search", "cms", "multilang", "versioning"]
rustelo-analytics = ["tracking", "events", "reporting", "dashboards"]
rustelo-realtime = ["websockets", "sse", "notifications", "collaboration"]
# Asset management configuration
[assets]
source = "local"
download_location = ".rustelo-assets/"
[assets.remote]
base_url = "https://raw.githubusercontent.com/your-org/rustelo/main/templates"
template_variant = "basic"
cache_enabled = true
cache_ttl = "24h"
cache_directory = ".rustelo-cache"
fallback_to_embedded = true
[assets.local]
framework_path = "{{framework_path}}"
implementation_templates = "templates"
framework_assets = "framework-assets"
watch_changes = {{dev_mode}}
auto_sync = {{dev_mode}}
[development]
enabled = {{dev_mode}}
auto_detect_changes = true
auto_sync_assets = {{dev_mode}}
validate_assets = true
hot_reload = true
# Update and maintenance configuration
[update]
policy = "conservative"
backup = true
backup_dir = ".rustelo-backups"
[update.automation]
enabled = {{automation_enabled}}
check_frequency = "{{check_frequency}}"
check_targets = ["framework", "dependencies", "features"]
notify_methods = {{notify_methods}}
auto_update_policy = "notify_only"
fail_on_critical_updates = false
update_log_file = ".rustelo/update-history.log"
# Files safe to regenerate during updates
regenerate = [
"justfile",
"package.json",
"unocss.config.ts",
"rustelo.toml"
]
# Protected files (never overwritten)
protected = [
"src/**/*.rs",
"content/**/*",
"public/**/*",
"local-tasks.just",
"unocss.local.ts",
"rustelo.local.toml",
".env*"
]
# Contribution detection and sharing
[contributions]
enabled = {{contributions_enabled}}
scan_frequency = "monthly"
detect_types = ["improvements", "bug_fixes", "new_features", "documentation"]
confidence_threshold = 0.7
auto_package = false
package_directory = ".rustelo/contributions"
notify_methods = ["log", "file"]
# Notification preferences
[notifications]
enabled = true
[notifications.channels]
console = { enabled = true, level = "info" }
file = {
enabled = {{file_notifications}},
path = ".rustelo/notifications.json"
}
# CI/CD integration
[ci_cd]
enabled = {{ci_cd_enabled}}
exit_codes = true
cache_results = true
cache_key = "rustelo-jpl-website-0.1.0"
# Template-specific configuration
[template.basic]
# Basic template doesn't require additional configuration
content_types = ["blog", "pages"]
ui_framework = "unocss"
build_system = "vite"

View file

@ -1,84 +0,0 @@
//! UI Components for jpl-website
//!
//! Reusable UI components built with Leptos and UnoCSS.
use leptos::*;
mod navigation;
mod footer;
pub use navigation::*;
pub use footer::*;
/// Common button component with consistent styling
#[component]
pub fn Button(
/// Button text
children: Children,
/// Click handler
#[prop(optional)] on_click: Option<Box<dyn Fn() + 'static>>,
/// Button variant
#[prop(default = "primary")] variant: &'static str,
/// Additional CSS classes
#[prop(default = "")] class: &'static str,
/// Button type
#[prop(default = "button")] r#type: &'static str,
/// Disabled state
#[prop(default = false)] disabled: bool,
) -> impl IntoView {
let button_class = format!(
"btn {} {}",
match variant {
"primary" => "btn-primary",
"secondary" => "btn-secondary",
"ghost" => "btn-ghost",
_ => "btn-primary",
},
class
);
let disabled_class = if disabled { " opacity-50 cursor-not-allowed" } else { "" };
let final_class = format!("{}{}", button_class, disabled_class);
view! {
<button
type=r#type
class=final_class
disabled=disabled
on:click=move |_| {
if let Some(handler) = &on_click {
if !disabled {
handler();
}
}
}
>
{children()}
</button>
}
}
/// Loading spinner component
#[component]
pub fn LoadingSpinner(
#[prop(default = "w-4 h-4")] size: &'static str,
#[prop(default = "")] class: &'static str,
) -> impl IntoView {
view! {
<div class={format!("animate-spin rounded-full border-2 border-gray-300 border-t-primary-600 {size} {class}")}>
</div>
}
}
/// Card container component
#[component]
pub fn Card(
children: Children,
#[prop(default = "")] class: &'static str,
) -> impl IntoView {
view! {
<div class={format!("card {class}")}>
{children()}
</div>
}
}

View file

@ -1,66 +0,0 @@
//! jpl-website Library
//!
//! Core application components and functionality.
use leptos::*;
use rustelo_core::prelude::*;
use rustelo_web::prelude::*;
pub mod components;
pub mod pages;
use components::*;
use pages::*;
/// Main application component
#[component]
pub fn App() -> impl IntoView {
// Provide global application state
provide_meta_context();
view! {
<Html lang="en" />
<Title text="jpl-website" />
<Meta name="description" content="{{description}}" />
<Meta charset="utf-8" />
<Meta name="viewport" content="width=device-width, initial-scale=1" />
// Global styles
<Link rel="stylesheet" href="/styles/unocss.css" />
<Link rel="icon" type="image/x-icon" href="/favicon.ico" />
<Body class="bg-gray-50 text-gray-900" />
<Router>
<Navigation />
<main class="min-h-screen">
<Routes>
<Route path="/" view=Home />
<Route path="/about" view=About />
<Route path="/blog" view=BlogList />
<Route path="/blog/:slug" view=BlogPost />
<Route path="/*any" view=NotFound />
</Routes>
</main>
<Footer />
</Router>
}
}
/// 404 Not Found page
#[component]
fn NotFound() -> impl IntoView {
view! {
<div class="rustelo-container rustelo-section text-center">
<h1 class="text-4xl font-bold text-gray-900 mb-4">
"404 - Page Not Found"
</h1>
<p class="text-xl text-gray-600 mb-8">
"The page you're looking for doesn't exist."
</p>
<a href="/" class="btn-primary">
"Go Home"
</a>
</div>
}
}

View file

@ -1,17 +0,0 @@
//! jpl-website
//!
//! A Rustelo framework implementation.
//! Generated from basic template.
use leptos::*;
use jpl_website::*;
fn main() {
// Initialize the application
console_error_panic_hook::set_once();
// Mount the application
mount_to_body(|| {
view! { <App/> }
})
}

View file

@ -1,107 +0,0 @@
import { defineConfig, presetUno, presetIcons, presetWebFonts } from 'unocss'
export default defineConfig({
// Basic UnoCSS configuration for jpl-website
presets: [
presetUno(),
presetIcons({
collections: {
heroicons: () => import('@iconify-json/heroicons/icons.json').then(i => i.default),
lucide: () => import('@iconify-json/lucide/icons.json').then(i => i.default),
}
}),
presetWebFonts({
fonts: {
sans: 'Inter:400,500,600,700',
mono: 'JetBrains Mono:400,500',
}
})
],
// Theme configuration
theme: {
colors: {
primary: {
50: '#fef7ee',
100: '#fdedd3',
200: '#fbd6a5',
300: '#f8b86d',
400: '#f59332',
500: '#f2751a',
600: '#e35a0f',
700: '#bc4210',
800: '#953515',
900: '#792d14',
},
gray: {
50: '#f9fafb',
100: '#f3f4f6',
200: '#e5e7eb',
300: '#d1d5db',
400: '#9ca3af',
500: '#6b7280',
600: '#4b5563',
700: '#374151',
800: '#1f2937',
900: '#111827',
}
},
fontFamily: {
sans: ['Inter', 'system-ui', 'sans-serif'],
mono: ['JetBrains Mono', 'monospace'],
}
},
// Content paths for purging
content: {
filesystem: [
'src/**/*.rs',
'templates/**/*.html',
'content/**/*.md'
]
},
// Custom shortcuts for common patterns
shortcuts: {
'btn': 'px-4 py-2 rounded-lg font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2',
'btn-primary': 'btn bg-primary-600 text-white hover:bg-primary-700',
'btn-secondary': 'btn bg-gray-200 text-gray-900 hover:bg-gray-300',
'btn-ghost': 'btn bg-transparent text-gray-700 hover:bg-gray-100',
'card': 'bg-white rounded-lg shadow-sm border border-gray-200 p-6',
'input': 'w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500',
'nav-link': 'text-gray-700 hover:text-primary-600 px-3 py-2 rounded-md text-sm font-medium transition-colors',
'nav-link-active': 'text-primary-600 bg-primary-50 px-3 py-2 rounded-md text-sm font-medium',
},
// Custom rules for Rustelo-specific patterns
rules: [
// Custom spacing for Rustelo layouts
[/^rustelo-container$/, () => ({ 'max-width': '1200px', 'margin': '0 auto', 'padding': '0 1rem' })],
[/^rustelo-section$/, () => ({ 'padding': '4rem 0' })],
[/^rustelo-hero$/, () => ({ 'padding': '8rem 0 6rem' })],
],
// Safelist important classes that might be used dynamically
safelist: [
'bg-primary-600',
'text-primary-600',
'border-primary-600',
'ring-primary-500',
'btn-primary',
'btn-secondary',
'btn-ghost',
'card',
'nav-link',
'nav-link-active',
],
// Development configuration
cli: {
entry: [
{
patterns: ['src/**/*.rs'],
outFile: 'public/styles/unocss.css'
}
]
}
})

21
templates/options.ncl Normal file
View file

@ -0,0 +1,21 @@
# options.ncl — question schema for the website generator (scripts/generator/new-site.nu).
# Combines with the templates.json registry: render_mode picks the template tree.
{
questions = [
{ id = "project", prompt = "Project name (kebab-case)", default = "my-website" },
{ id = "render_mode", prompt = "Render mode", default = "htmx-ssr",
choices = ["htmx-ssr", "leptos-hydration"] },
{ id = "site_title", prompt = "Site title", default = "My Website" },
{ id = "domain", prompt = "Domain (no scheme)", default = "example.com" },
{ id = "languages", prompt = "Languages (csv)", default = "en,es" },
{ id = "default_lang", prompt = "Default language", default = "en" },
{ id = "ontoref", prompt = "ontoref onboarding", default = "minimal",
choices = ["full", "minimal", "none"] },
],
# render_mode → template directory (must match templates.json names)
template_for = {
"htmx-ssr" = "website-htmx-ssr",
"leptos-hydration" = "website-leptos",
},
}

View file

@ -0,0 +1,147 @@
//==========================================================
// head-support.js
//
// An extension to htmx 1.0 to add head tag merging.
//==========================================================
(function(){
var api = null;
function log() {
//console.log(arguments);
}
function mergeHead(newContent, defaultMergeStrategy) {
if (newContent && newContent.indexOf('<head') > -1) {
const htmlDoc = document.createElement("html");
// remove svgs to avoid conflicts
var contentWithSvgsRemoved = newContent.replace(/<svg(\s[^>]*>|>)([\s\S]*?)<\/svg>/gim, '');
// extract head tag
var headTag = contentWithSvgsRemoved.match(/(<head(\s[^>]*>|>)([\s\S]*?)<\/head>)/im);
// if the head tag exists...
if (headTag) {
var added = []
var removed = []
var preserved = []
var nodesToAppend = []
htmlDoc.innerHTML = headTag;
var newHeadTag = htmlDoc.querySelector("head");
var currentHead = document.head;
if (newHeadTag == null) {
return;
} else {
// put all new head elements into a Map, by their outerHTML
var srcToNewHeadNodes = new Map();
for (const newHeadChild of newHeadTag.children) {
srcToNewHeadNodes.set(newHeadChild.outerHTML, newHeadChild);
}
}
// determine merge strategy
var mergeStrategy = api.getAttributeValue(newHeadTag, "hx-head") || defaultMergeStrategy;
// get the current head
for (const currentHeadElt of currentHead.children) {
// If the current head element is in the map
var inNewContent = srcToNewHeadNodes.has(currentHeadElt.outerHTML);
var isReAppended = currentHeadElt.getAttribute("hx-head") === "re-eval";
var isPreserved = api.getAttributeValue(currentHeadElt, "hx-preserve") === "true";
if (inNewContent || isPreserved) {
if (isReAppended) {
// remove the current version and let the new version replace it and re-execute
removed.push(currentHeadElt);
} else {
// this element already exists and should not be re-appended, so remove it from
// the new content map, preserving it in the DOM
srcToNewHeadNodes.delete(currentHeadElt.outerHTML);
preserved.push(currentHeadElt);
}
} else {
if (mergeStrategy === "append") {
// we are appending and this existing element is not new content
// so if and only if it is marked for re-append do we do anything
if (isReAppended) {
removed.push(currentHeadElt);
nodesToAppend.push(currentHeadElt);
}
} else {
// if this is a merge, we remove this content since it is not in the new head
if (api.triggerEvent(document.body, "htmx:removingHeadElement", {headElement: currentHeadElt}) !== false) {
removed.push(currentHeadElt);
}
}
}
}
// Push the tremaining new head elements in the Map into the
// nodes to append to the head tag
nodesToAppend.push(...srcToNewHeadNodes.values());
log("to append: ", nodesToAppend);
for (const newNode of nodesToAppend) {
log("adding: ", newNode);
var newElt = document.createRange().createContextualFragment(newNode.outerHTML);
log(newElt);
if (api.triggerEvent(document.body, "htmx:addingHeadElement", {headElement: newElt}) !== false) {
currentHead.appendChild(newElt);
added.push(newElt);
}
}
// remove all removed elements, after we have appended the new elements to avoid
// additional network requests for things like style sheets
for (const removedElement of removed) {
if (api.triggerEvent(document.body, "htmx:removingHeadElement", {headElement: removedElement}) !== false) {
currentHead.removeChild(removedElement);
}
}
api.triggerEvent(document.body, "htmx:afterHeadMerge", {added: added, kept: preserved, removed: removed});
}
}
}
htmx.defineExtension("head-support", {
init: function(apiRef) {
// store a reference to the internal API.
api = apiRef;
htmx.on('htmx:afterSwap', function(evt){
// PATCH: htmx:afterSwap also fires on history restore from
// popstate, where evt.detail.xhr is undefined. The original
// line would TypeError and block subsequent navigations.
// History restores have their own handler below
// (htmx:historyRestore), so we just skip here.
if (!evt.detail || !evt.detail.xhr) return;
var serverResponse = evt.detail.xhr.response;
if (api.triggerEvent(document.body, "htmx:beforeHeadMerge", evt.detail)) {
mergeHead(serverResponse, evt.detail.boosted ? "merge" : "append");
}
})
htmx.on('htmx:historyRestore', function(evt){
if (api.triggerEvent(document.body, "htmx:beforeHeadMerge", evt.detail)) {
if (evt.detail.cacheMiss) {
mergeHead(evt.detail.serverResponse, "merge");
} else {
mergeHead(evt.detail.item.head, "merge");
}
}
})
htmx.on('htmx:historyItemCreated', function(evt){
var historyItem = evt.detail.item;
historyItem.head = document.head.outerHTML;
})
}
});
})()

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,184 @@
;(function() {
const loadingStatesUndoQueue = []
function loadingStateContainer(target) {
return htmx.closest(target, '[data-loading-states]') || document.body
}
function mayProcessUndoCallback(target, callback) {
if (document.body.contains(target)) {
callback()
}
}
function mayProcessLoadingStateByPath(elt, requestPath) {
const pathElt = htmx.closest(elt, '[data-loading-path]')
if (!pathElt) {
return true
}
return pathElt.getAttribute('data-loading-path') === requestPath
}
function queueLoadingState(sourceElt, targetElt, doCallback, undoCallback) {
const delayElt = htmx.closest(sourceElt, '[data-loading-delay]')
if (delayElt) {
const delayInMilliseconds =
delayElt.getAttribute('data-loading-delay') || 200
const timeout = setTimeout(function() {
doCallback()
loadingStatesUndoQueue.push(function() {
mayProcessUndoCallback(targetElt, undoCallback)
})
}, delayInMilliseconds)
loadingStatesUndoQueue.push(function() {
mayProcessUndoCallback(targetElt, function() { clearTimeout(timeout) })
})
} else {
doCallback()
loadingStatesUndoQueue.push(function() {
mayProcessUndoCallback(targetElt, undoCallback)
})
}
}
function getLoadingStateElts(loadingScope, type, path) {
return Array.from(htmx.findAll(loadingScope, '[' + type + ']')).filter(
function(elt) { return mayProcessLoadingStateByPath(elt, path) }
)
}
function getLoadingTarget(elt) {
if (elt.getAttribute('data-loading-target')) {
return Array.from(
htmx.findAll(elt.getAttribute('data-loading-target'))
)
}
return [elt]
}
htmx.defineExtension('loading-states', {
onEvent: function(name, evt) {
if (name === 'htmx:beforeRequest') {
const container = loadingStateContainer(evt.target)
const loadingStateTypes = [
'data-loading',
'data-loading-class',
'data-loading-class-remove',
'data-loading-disable',
'data-loading-aria-busy'
]
const loadingStateEltsByType = {}
loadingStateTypes.forEach(function(type) {
loadingStateEltsByType[type] = getLoadingStateElts(
container,
type,
evt.detail.pathInfo.requestPath
)
})
loadingStateEltsByType['data-loading'].forEach(function(sourceElt) {
getLoadingTarget(sourceElt).forEach(function(targetElt) {
queueLoadingState(
sourceElt,
targetElt,
function() {
targetElt.style.display =
sourceElt.getAttribute('data-loading') ||
'inline-block'
},
function() { targetElt.style.display = 'none' }
)
})
})
loadingStateEltsByType['data-loading-class'].forEach(
function(sourceElt) {
const classNames = sourceElt
.getAttribute('data-loading-class')
.split(' ')
getLoadingTarget(sourceElt).forEach(function(targetElt) {
queueLoadingState(
sourceElt,
targetElt,
function() {
classNames.forEach(function(className) {
targetElt.classList.add(className)
})
},
function() {
classNames.forEach(function(className) {
targetElt.classList.remove(className)
})
}
)
})
}
)
loadingStateEltsByType['data-loading-class-remove'].forEach(
function(sourceElt) {
const classNames = sourceElt
.getAttribute('data-loading-class-remove')
.split(' ')
getLoadingTarget(sourceElt).forEach(function(targetElt) {
queueLoadingState(
sourceElt,
targetElt,
function() {
classNames.forEach(function(className) {
targetElt.classList.remove(className)
})
},
function() {
classNames.forEach(function(className) {
targetElt.classList.add(className)
})
}
)
})
}
)
loadingStateEltsByType['data-loading-disable'].forEach(
function(sourceElt) {
getLoadingTarget(sourceElt).forEach(function(targetElt) {
queueLoadingState(
sourceElt,
targetElt,
function() { targetElt.disabled = true },
function() { targetElt.disabled = false }
)
})
}
)
loadingStateEltsByType['data-loading-aria-busy'].forEach(
function(sourceElt) {
getLoadingTarget(sourceElt).forEach(function(targetElt) {
queueLoadingState(
sourceElt,
targetElt,
function() { targetElt.setAttribute('aria-busy', 'true') },
function() { targetElt.removeAttribute('aria-busy') }
)
})
}
)
}
if (name === 'htmx:beforeOnLoad') {
while (loadingStatesUndoQueue.length > 0) {
loadingStatesUndoQueue.shift()()
}
}
}
})
})()

View file

@ -0,0 +1,44 @@
(function() {
/** @type {import("../htmx").HtmxInternalApi} */
var api
htmx.defineExtension('multi-swap', {
init: function(apiRef) {
api = apiRef
},
isInlineSwap: function(swapStyle) {
return swapStyle.indexOf('multi:') === 0
},
handleSwap: function(swapStyle, target, fragment, settleInfo) {
if (swapStyle.indexOf('multi:') === 0) {
var selectorToSwapStyle = {}
var elements = swapStyle.replace(/^multi\s*:\s*/, '').split(/\s*,\s*/)
elements.forEach(function(element) {
var split = element.split(/\s*:\s*/)
var elementSelector = split[0]
var elementSwapStyle = typeof (split[1]) !== 'undefined' ? split[1] : 'innerHTML'
if (elementSelector.charAt(0) !== '#') {
console.error("HTMX multi-swap: unsupported selector '" + elementSelector + "'. Only ID selectors starting with '#' are supported.")
return
}
selectorToSwapStyle[elementSelector] = elementSwapStyle
})
for (var selector in selectorToSwapStyle) {
var swapStyle = selectorToSwapStyle[selector]
var elementToSwap = fragment.querySelector(selector)
if (elementToSwap) {
api.oobSwap(swapStyle, elementToSwap, settleInfo)
} else {
console.warn("HTMX multi-swap: selector '" + selector + "' not found in source content.")
}
}
return true
}
}
})
})()

View file

@ -0,0 +1,59 @@
(function() {
'use strict'
// Save a reference to the global object (window in the browser)
var _root = this
function dependsOn(pathSpec, url) {
if (pathSpec === 'ignore') {
return false
}
var dependencyPath = pathSpec.split('/')
var urlPath = url.split('/')
for (var i = 0; i < urlPath.length; i++) {
var dependencyElement = dependencyPath.shift()
var pathElement = urlPath[i]
if (dependencyElement !== pathElement && dependencyElement !== '*') {
return false
}
if (dependencyPath.length === 0 || (dependencyPath.length === 1 && dependencyPath[0] === '')) {
return true
}
}
return false
}
function refreshPath(path) {
var eltsWithDeps = htmx.findAll('[path-deps]')
for (var i = 0; i < eltsWithDeps.length; i++) {
var elt = eltsWithDeps[i]
if (dependsOn(elt.getAttribute('path-deps'), path)) {
htmx.trigger(elt, 'path-deps')
}
}
}
htmx.defineExtension('path-deps', {
onEvent: function(name, evt) {
if (name === 'htmx:beforeOnLoad') {
var config = evt.detail.requestConfig
// mutating call
if (config.verb !== 'get' && evt.target.getAttribute('path-deps') !== 'ignore') {
refreshPath(config.path)
}
}
}
})
/**
* ********************
* Expose functionality
* ********************
*/
_root.PathDeps = {
refresh: function(path) {
refreshPath(path)
}
}
}).call(this)

View file

@ -0,0 +1,140 @@
// This adds the "preload" extension to htmx. By default, this will
// preload the targets of any tags with `href` or `hx-get` attributes
// if they also have a `preload` attribute as well. See documentation
// for more details
htmx.defineExtension('preload', {
onEvent: function(name, event) {
// Only take actions on "htmx:afterProcessNode"
if (name !== 'htmx:afterProcessNode') {
return
}
// SOME HELPER FUNCTIONS WE'LL NEED ALONG THE WAY
// attr gets the closest non-empty value from the attribute.
var attr = function(node, property) {
if (node == undefined) { return undefined }
return node.getAttribute(property) || node.getAttribute('data-' + property) || attr(node.parentElement, property)
}
// load handles the actual HTTP fetch, and uses htmx.ajax in cases where we're
// preloading an htmx resource (this sends the same HTTP headers as a regular htmx request)
var load = function(node) {
// Called after a successful AJAX request, to mark the
// content as loaded (and prevent additional AJAX calls.)
var done = function(html) {
if (!node.preloadAlways) {
node.preloadState = 'DONE'
}
if (attr(node, 'preload-images') == 'true') {
document.createElement('div').innerHTML = html // create and populate a node to load linked resources, too.
}
}
return function() {
// If this value has already been loaded, then do not try again.
if (node.preloadState !== 'READY') {
return
}
// Special handling for HX-GET - use built-in htmx.ajax function
// so that headers match other htmx requests, then set
// node.preloadState = TRUE so that requests are not duplicated
// in the future
var hxGet = node.getAttribute('hx-get') || node.getAttribute('data-hx-get')
if (hxGet) {
htmx.ajax('GET', hxGet, {
source: node,
handler: function(elt, info) {
done(info.xhr.responseText)
}
})
return
}
// Otherwise, perform a standard xhr request, then set
// node.preloadState = TRUE so that requests are not duplicated
// in the future.
if (node.getAttribute('href')) {
var r = new XMLHttpRequest()
r.open('GET', node.getAttribute('href'))
r.onload = function() { done(r.responseText) }
r.send()
}
}
}
// This function processes a specific node and sets up event handlers.
// We'll search for nodes and use it below.
var init = function(node) {
// If this node DOES NOT include a "GET" transaction, then there's nothing to do here.
if (node.getAttribute('href') + node.getAttribute('hx-get') + node.getAttribute('data-hx-get') == '') {
return
}
// Guarantee that we only initialize each node once.
if (node.preloadState !== undefined) {
return
}
// Get event name from config.
var on = attr(node, 'preload') || 'mousedown'
const always = on.indexOf('always') !== -1
if (always) {
on = on.replace('always', '').trim()
}
// FALL THROUGH to here means we need to add an EventListener
// Apply the listener to the node
node.addEventListener(on, function(evt) {
if (node.preloadState === 'PAUSE') { // Only add one event listener
node.preloadState = 'READY' // Required for the `load` function to trigger
// Special handling for "mouseover" events. Wait 100ms before triggering load.
if (on === 'mouseover') {
window.setTimeout(load(node), 100)
} else {
load(node)() // all other events trigger immediately.
}
}
})
// Special handling for certain built-in event handlers
switch (on) {
case 'mouseover':
// Mirror `touchstart` events (fires immediately)
node.addEventListener('touchstart', load(node))
// WHhen the mouse leaves, immediately disable the preload
node.addEventListener('mouseout', function(evt) {
if ((evt.target === node) && (node.preloadState === 'READY')) {
node.preloadState = 'PAUSE'
}
})
break
case 'mousedown':
// Mirror `touchstart` events (fires immediately)
node.addEventListener('touchstart', load(node))
break
}
// Mark the node as ready to run.
node.preloadState = 'PAUSE'
node.preloadAlways = always
htmx.trigger(node, 'preload:init') // This event can be used to load content immediately.
}
// Search for all child nodes that have a "preload" attribute
event.target.querySelectorAll('[preload]').forEach(function(node) {
// Initialize the node with the "preload" attribute
init(node)
// Initialize all child elements that are anchors or have `hx-get` (use with care)
node.querySelectorAll('a,[hx-get],[data-hx-get]').forEach(init)
})
}
})

View file

@ -0,0 +1,129 @@
(function() {
/** @type {import("../htmx").HtmxInternalApi} */
var api
var attrPrefix = 'hx-target-'
// IE11 doesn't support string.startsWith
function startsWith(str, prefix) {
return str.substring(0, prefix.length) === prefix
}
/**
* @param {HTMLElement} elt
* @param {number} respCode
* @returns {HTMLElement | null}
*/
function getRespCodeTarget(elt, respCodeNumber) {
if (!elt || !respCodeNumber) return null
var respCode = respCodeNumber.toString()
// '*' is the original syntax, as the obvious character for a wildcard.
// The 'x' alternative was added for maximum compatibility with HTML
// templating engines, due to ambiguity around which characters are
// supported in HTML attributes.
//
// Start with the most specific possible attribute and generalize from
// there.
var attrPossibilities = [
respCode,
respCode.substr(0, 2) + '*',
respCode.substr(0, 2) + 'x',
respCode.substr(0, 1) + '*',
respCode.substr(0, 1) + 'x',
respCode.substr(0, 1) + '**',
respCode.substr(0, 1) + 'xx',
'*',
'x',
'***',
'xxx'
]
if (startsWith(respCode, '4') || startsWith(respCode, '5')) {
attrPossibilities.push('error')
}
for (var i = 0; i < attrPossibilities.length; i++) {
var attr = attrPrefix + attrPossibilities[i]
var attrValue = api.getClosestAttributeValue(elt, attr)
if (attrValue) {
if (attrValue === 'this') {
return api.findThisElement(elt, attr)
} else {
return api.querySelectorExt(elt, attrValue)
}
}
}
return null
}
/** @param {Event} evt */
function handleErrorFlag(evt) {
if (evt.detail.isError) {
if (htmx.config.responseTargetUnsetsError) {
evt.detail.isError = false
}
} else if (htmx.config.responseTargetSetsError) {
evt.detail.isError = true
}
}
htmx.defineExtension('response-targets', {
/** @param {import("../htmx").HtmxInternalApi} apiRef */
init: function(apiRef) {
api = apiRef
if (htmx.config.responseTargetUnsetsError === undefined) {
htmx.config.responseTargetUnsetsError = true
}
if (htmx.config.responseTargetSetsError === undefined) {
htmx.config.responseTargetSetsError = false
}
if (htmx.config.responseTargetPrefersExisting === undefined) {
htmx.config.responseTargetPrefersExisting = false
}
if (htmx.config.responseTargetPrefersRetargetHeader === undefined) {
htmx.config.responseTargetPrefersRetargetHeader = true
}
},
/**
* @param {string} name
* @param {Event} evt
*/
onEvent: function(name, evt) {
if (name === 'htmx:beforeSwap' &&
evt.detail.xhr &&
evt.detail.xhr.status !== 200) {
if (evt.detail.target) {
if (htmx.config.responseTargetPrefersExisting) {
evt.detail.shouldSwap = true
handleErrorFlag(evt)
return true
}
if (htmx.config.responseTargetPrefersRetargetHeader &&
evt.detail.xhr.getAllResponseHeaders().match(/HX-Retarget:/i)) {
evt.detail.shouldSwap = true
handleErrorFlag(evt)
return true
}
}
if (!evt.detail.requestConfig) {
return true
}
var target = getRespCodeTarget(evt.detail.requestConfig.elt, evt.detail.xhr.status)
if (target) {
handleErrorFlag(evt)
evt.detail.shouldSwap = true
evt.detail.target = target
}
return true
}
}
})
})()

View file

@ -0,0 +1,301 @@
/*
Server Sent Events Extension
============================
This extension adds support for Server Sent Events to htmx. See /www/extensions/sse.md for usage instructions.
*/
(function() {
/** @type {import("../htmx").HtmxInternalApi} */
var api
htmx.defineExtension('sse', {
/**
* Init saves the provided reference to the internal HTMX API.
*
* @param {import("../htmx").HtmxInternalApi} api
* @returns void
*/
init: function(apiRef) {
// store a reference to the internal API.
api = apiRef
// set a function in the public API for creating new EventSource objects
if (htmx.createEventSource == undefined) {
htmx.createEventSource = createEventSource
}
},
/**
* onEvent handles all events passed to this extension.
*
* @param {string} name
* @param {Event} evt
* @returns void
*/
onEvent: function(name, evt) {
var parent = evt.target || evt.detail.elt
switch (name) {
case 'htmx:beforeCleanupElement':
var internalData = api.getInternalData(parent)
// Try to remove remove an EventSource when elements are removed
if (internalData.sseEventSource) {
internalData.sseEventSource.close()
}
return
// Try to create EventSources when elements are processed
case 'htmx:afterProcessNode':
ensureEventSourceOnElement(parent)
}
}
})
/// ////////////////////////////////////////////
// HELPER FUNCTIONS
/// ////////////////////////////////////////////
/**
* createEventSource is the default method for creating new EventSource objects.
* it is hoisted into htmx.config.createEventSource to be overridden by the user, if needed.
*
* @param {string} url
* @returns EventSource
*/
function createEventSource(url) {
return new EventSource(url, { withCredentials: true })
}
/**
* registerSSE looks for attributes that can contain sse events, right
* now hx-trigger and sse-swap and adds listeners based on these attributes too
* the closest event source
*
* @param {HTMLElement} elt
*/
function registerSSE(elt) {
// Add message handlers for every `sse-swap` attribute
queryAttributeOnThisOrChildren(elt, 'sse-swap').forEach(function(child) {
// Find closest existing event source
var sourceElement = api.getClosestMatch(child, hasEventSource)
if (sourceElement == null) {
// api.triggerErrorEvent(elt, "htmx:noSSESourceError")
return null // no eventsource in parentage, orphaned element
}
// Set internalData and source
var internalData = api.getInternalData(sourceElement)
var source = internalData.sseEventSource
var sseSwapAttr = api.getAttributeValue(child, 'sse-swap')
var sseEventNames = sseSwapAttr.split(',')
for (var i = 0; i < sseEventNames.length; i++) {
var sseEventName = sseEventNames[i].trim()
var listener = function(event) {
// If the source is missing then close SSE
if (maybeCloseSSESource(sourceElement)) {
return
}
// If the body no longer contains the element, remove the listener
if (!api.bodyContains(child)) {
source.removeEventListener(sseEventName, listener)
return
}
// swap the response into the DOM and trigger a notification
if (!api.triggerEvent(elt, 'htmx:sseBeforeMessage', event)) {
return
}
swap(child, event.data)
api.triggerEvent(elt, 'htmx:sseMessage', event)
}
// Register the new listener
api.getInternalData(child).sseEventListener = listener
source.addEventListener(sseEventName, listener)
}
})
// Add message handlers for every `hx-trigger="sse:*"` attribute
queryAttributeOnThisOrChildren(elt, 'hx-trigger').forEach(function(child) {
// Find closest existing event source
var sourceElement = api.getClosestMatch(child, hasEventSource)
if (sourceElement == null) {
// api.triggerErrorEvent(elt, "htmx:noSSESourceError")
return null // no eventsource in parentage, orphaned element
}
// Set internalData and source
var internalData = api.getInternalData(sourceElement)
var source = internalData.sseEventSource
var sseEventName = api.getAttributeValue(child, 'hx-trigger')
if (sseEventName == null) {
return
}
// Only process hx-triggers for events with the "sse:" prefix
if (sseEventName.slice(0, 4) != 'sse:') {
return
}
var listener = function(event) {
if (maybeCloseSSESource(sourceElement)) {
return
}
if (!api.bodyContains(child)) {
source.removeEventListener(sseEventName, listener)
}
// Trigger events to be handled by the rest of htmx
htmx.trigger(child, sseEventName, event)
htmx.trigger(child, 'htmx:sseMessage', event)
}
// Register the new listener
api.getInternalData(elt).sseEventListener = listener
source.addEventListener(sseEventName.slice(4), listener)
})
}
/**
* ensureEventSourceOnElement creates a new EventSource connection on the provided element.
* If a usable EventSource already exists, then it is returned. If not, then a new EventSource
* is created and stored in the element's internalData.
* @param {HTMLElement} elt
* @param {number} retryCount
* @returns {EventSource | null}
*/
function ensureEventSourceOnElement(elt, retryCount) {
if (elt == null) {
return null
}
// handle extension source creation attribute
queryAttributeOnThisOrChildren(elt, 'sse-connect').forEach(function(child) {
var sseURL = api.getAttributeValue(child, 'sse-connect')
if (sseURL == null) {
return
}
ensureEventSource(child, sseURL, retryCount)
})
registerSSE(elt)
}
function ensureEventSource(elt, url, retryCount) {
var source = htmx.createEventSource(url)
source.onerror = function(err) {
// Log an error event
api.triggerErrorEvent(elt, 'htmx:sseError', { error: err, source })
// If parent no longer exists in the document, then clean up this EventSource
if (maybeCloseSSESource(elt)) {
return
}
// Otherwise, try to reconnect the EventSource
if (source.readyState === EventSource.CLOSED) {
retryCount = retryCount || 0
var timeout = Math.random() * (2 ^ retryCount) * 500
window.setTimeout(function() {
ensureEventSourceOnElement(elt, Math.min(7, retryCount + 1))
}, timeout)
}
}
source.onopen = function(evt) {
api.triggerEvent(elt, 'htmx:sseOpen', { source })
}
api.getInternalData(elt).sseEventSource = source
}
/**
* maybeCloseSSESource confirms that the parent element still exists.
* If not, then any associated SSE source is closed and the function returns true.
*
* @param {HTMLElement} elt
* @returns boolean
*/
function maybeCloseSSESource(elt) {
if (!api.bodyContains(elt)) {
var source = api.getInternalData(elt).sseEventSource
if (source != undefined) {
source.close()
// source = null
return true
}
}
return false
}
/**
* queryAttributeOnThisOrChildren returns all nodes that contain the requested attributeName, INCLUDING THE PROVIDED ROOT ELEMENT.
*
* @param {HTMLElement} elt
* @param {string} attributeName
*/
function queryAttributeOnThisOrChildren(elt, attributeName) {
var result = []
// If the parent element also contains the requested attribute, then add it to the results too.
if (api.hasAttribute(elt, attributeName)) {
result.push(elt)
}
// Search all child nodes that match the requested attribute
elt.querySelectorAll('[' + attributeName + '], [data-' + attributeName + ']').forEach(function(node) {
result.push(node)
})
return result
}
/**
* @param {HTMLElement} elt
* @param {string} content
*/
function swap(elt, content) {
api.withExtensions(elt, function(extension) {
content = extension.transformResponse(content, null, elt)
})
var swapSpec = api.getSwapSpecification(elt)
var target = api.getTarget(elt)
api.swap(target, content, swapSpec)
}
/**
* doSettle mirrors much of the functionality in htmx that
* settles elements after their content has been swapped.
* TODO: this should be published by htmx, and not duplicated here
* @param {import("../htmx").HtmxSettleInfo} settleInfo
* @returns () => void
*/
function doSettle(settleInfo) {
return function() {
settleInfo.tasks.forEach(function(task) {
task.call()
})
settleInfo.elts.forEach(function(elt) {
if (elt.classList) {
elt.classList.remove(htmx.config.settlingClass)
}
api.triggerEvent(elt, 'htmx:afterSettle')
})
}
}
function hasEventSource(node) {
return api.getInternalData(node).sseEventSource != null
}
})()

View file

@ -0,0 +1,467 @@
/*
WebSockets Extension
============================
This extension adds support for WebSockets to htmx. See /www/extensions/ws.md for usage instructions.
*/
(function() {
/** @type {import("../htmx").HtmxInternalApi} */
var api
htmx.defineExtension('ws', {
/**
* init is called once, when this extension is first registered.
* @param {import("../htmx").HtmxInternalApi} apiRef
*/
init: function(apiRef) {
// Store reference to internal API
api = apiRef
// Default function for creating new EventSource objects
if (!htmx.createWebSocket) {
htmx.createWebSocket = createWebSocket
}
// Default setting for reconnect delay
if (!htmx.config.wsReconnectDelay) {
htmx.config.wsReconnectDelay = 'full-jitter'
}
},
/**
* onEvent handles all events passed to this extension.
*
* @param {string} name
* @param {Event} evt
*/
onEvent: function(name, evt) {
var parent = evt.target || evt.detail.elt
switch (name) {
// Try to close the socket when elements are removed
case 'htmx:beforeCleanupElement':
var internalData = api.getInternalData(parent)
if (internalData.webSocket) {
internalData.webSocket.close()
}
return
// Try to create websockets when elements are processed
case 'htmx:beforeProcessNode':
forEach(queryAttributeOnThisOrChildren(parent, 'ws-connect'), function(child) {
ensureWebSocket(child)
})
forEach(queryAttributeOnThisOrChildren(parent, 'ws-send'), function(child) {
ensureWebSocketSend(child)
})
}
}
})
function splitOnWhitespace(trigger) {
return trigger.trim().split(/\s+/)
}
function getLegacyWebsocketURL(elt) {
var legacySSEValue = api.getAttributeValue(elt, 'hx-ws')
if (legacySSEValue) {
var values = splitOnWhitespace(legacySSEValue)
for (var i = 0; i < values.length; i++) {
var value = values[i].split(/:(.+)/)
if (value[0] === 'connect') {
return value[1]
}
}
}
}
/**
* ensureWebSocket creates a new WebSocket on the designated element, using
* the element's "ws-connect" attribute.
* @param {HTMLElement} socketElt
* @returns
*/
function ensureWebSocket(socketElt) {
// If the element containing the WebSocket connection no longer exists, then
// do not connect/reconnect the WebSocket.
if (!api.bodyContains(socketElt)) {
return
}
// Get the source straight from the element's value
var wssSource = api.getAttributeValue(socketElt, 'ws-connect')
if (wssSource == null || wssSource === '') {
var legacySource = getLegacyWebsocketURL(socketElt)
if (legacySource == null) {
return
} else {
wssSource = legacySource
}
}
// Guarantee that the wssSource value is a fully qualified URL
if (wssSource.indexOf('/') === 0) {
var base_part = location.hostname + (location.port ? ':' + location.port : '')
if (location.protocol === 'https:') {
wssSource = 'wss://' + base_part + wssSource
} else if (location.protocol === 'http:') {
wssSource = 'ws://' + base_part + wssSource
}
}
var socketWrapper = createWebsocketWrapper(socketElt, function() {
return htmx.createWebSocket(wssSource)
})
socketWrapper.addEventListener('message', function(event) {
if (maybeCloseWebSocketSource(socketElt)) {
return
}
var response = event.data
if (!api.triggerEvent(socketElt, 'htmx:wsBeforeMessage', {
message: response,
socketWrapper: socketWrapper.publicInterface
})) {
return
}
api.withExtensions(socketElt, function(extension) {
response = extension.transformResponse(response, null, socketElt)
})
var settleInfo = api.makeSettleInfo(socketElt)
var fragment = api.makeFragment(response)
if (fragment.children.length) {
var children = Array.from(fragment.children)
for (var i = 0; i < children.length; i++) {
api.oobSwap(api.getAttributeValue(children[i], 'hx-swap-oob') || 'true', children[i], settleInfo)
}
}
api.settleImmediately(settleInfo.tasks)
api.triggerEvent(socketElt, 'htmx:wsAfterMessage', { message: response, socketWrapper: socketWrapper.publicInterface })
})
// Put the WebSocket into the HTML Element's custom data.
api.getInternalData(socketElt).webSocket = socketWrapper
}
/**
* @typedef {Object} WebSocketWrapper
* @property {WebSocket} socket
* @property {Array<{message: string, sendElt: Element}>} messageQueue
* @property {number} retryCount
* @property {(message: string, sendElt: Element) => void} sendImmediately sendImmediately sends message regardless of websocket connection state
* @property {(message: string, sendElt: Element) => void} send
* @property {(event: string, handler: Function) => void} addEventListener
* @property {() => void} handleQueuedMessages
* @property {() => void} init
* @property {() => void} close
*/
/**
*
* @param socketElt
* @param socketFunc
* @returns {WebSocketWrapper}
*/
function createWebsocketWrapper(socketElt, socketFunc) {
var wrapper = {
socket: null,
messageQueue: [],
retryCount: 0,
/** @type {Object<string, Function[]>} */
events: {},
addEventListener: function(event, handler) {
if (this.socket) {
this.socket.addEventListener(event, handler)
}
if (!this.events[event]) {
this.events[event] = []
}
this.events[event].push(handler)
},
sendImmediately: function(message, sendElt) {
if (!this.socket) {
api.triggerErrorEvent()
}
if (!sendElt || api.triggerEvent(sendElt, 'htmx:wsBeforeSend', {
message,
socketWrapper: this.publicInterface
})) {
this.socket.send(message)
sendElt && api.triggerEvent(sendElt, 'htmx:wsAfterSend', {
message,
socketWrapper: this.publicInterface
})
}
},
send: function(message, sendElt) {
if (this.socket.readyState !== this.socket.OPEN) {
this.messageQueue.push({ message, sendElt })
} else {
this.sendImmediately(message, sendElt)
}
},
handleQueuedMessages: function() {
while (this.messageQueue.length > 0) {
var queuedItem = this.messageQueue[0]
if (this.socket.readyState === this.socket.OPEN) {
this.sendImmediately(queuedItem.message, queuedItem.sendElt)
this.messageQueue.shift()
} else {
break
}
}
},
init: function() {
if (this.socket && this.socket.readyState === this.socket.OPEN) {
// Close discarded socket
this.socket.close()
}
// Create a new WebSocket and event handlers
/** @type {WebSocket} */
var socket = socketFunc()
// The event.type detail is added for interface conformance with the
// other two lifecycle events (open and close) so a single handler method
// can handle them polymorphically, if required.
api.triggerEvent(socketElt, 'htmx:wsConnecting', { event: { type: 'connecting' } })
this.socket = socket
socket.onopen = function(e) {
wrapper.retryCount = 0
api.triggerEvent(socketElt, 'htmx:wsOpen', { event: e, socketWrapper: wrapper.publicInterface })
wrapper.handleQueuedMessages()
}
socket.onclose = function(e) {
// If socket should not be connected, stop further attempts to establish connection
// If Abnormal Closure/Service Restart/Try Again Later, then set a timer to reconnect after a pause.
if (!maybeCloseWebSocketSource(socketElt) && [1006, 1012, 1013].indexOf(e.code) >= 0) {
var delay = getWebSocketReconnectDelay(wrapper.retryCount)
setTimeout(function() {
wrapper.retryCount += 1
wrapper.init()
}, delay)
}
// Notify client code that connection has been closed. Client code can inspect `event` field
// to determine whether closure has been valid or abnormal
api.triggerEvent(socketElt, 'htmx:wsClose', { event: e, socketWrapper: wrapper.publicInterface })
}
socket.onerror = function(e) {
api.triggerErrorEvent(socketElt, 'htmx:wsError', { error: e, socketWrapper: wrapper })
maybeCloseWebSocketSource(socketElt)
}
var events = this.events
Object.keys(events).forEach(function(k) {
events[k].forEach(function(e) {
socket.addEventListener(k, e)
})
})
},
close: function() {
this.socket.close()
}
}
wrapper.init()
wrapper.publicInterface = {
send: wrapper.send.bind(wrapper),
sendImmediately: wrapper.sendImmediately.bind(wrapper),
queue: wrapper.messageQueue
}
return wrapper
}
/**
* ensureWebSocketSend attaches trigger handles to elements with
* "ws-send" attribute
* @param {HTMLElement} elt
*/
function ensureWebSocketSend(elt) {
var legacyAttribute = api.getAttributeValue(elt, 'hx-ws')
if (legacyAttribute && legacyAttribute !== 'send') {
return
}
var webSocketParent = api.getClosestMatch(elt, hasWebSocket)
processWebSocketSend(webSocketParent, elt)
}
/**
* hasWebSocket function checks if a node has webSocket instance attached
* @param {HTMLElement} node
* @returns {boolean}
*/
function hasWebSocket(node) {
return api.getInternalData(node).webSocket != null
}
/**
* processWebSocketSend adds event listeners to the <form> element so that
* messages can be sent to the WebSocket server when the form is submitted.
* @param {HTMLElement} socketElt
* @param {HTMLElement} sendElt
*/
function processWebSocketSend(socketElt, sendElt) {
var nodeData = api.getInternalData(sendElt)
var triggerSpecs = api.getTriggerSpecs(sendElt)
triggerSpecs.forEach(function(ts) {
api.addTriggerHandler(sendElt, ts, nodeData, function(elt, evt) {
if (maybeCloseWebSocketSource(socketElt)) {
return
}
/** @type {WebSocketWrapper} */
var socketWrapper = api.getInternalData(socketElt).webSocket
var headers = api.getHeaders(sendElt, api.getTarget(sendElt))
var results = api.getInputValues(sendElt, 'post')
var errors = results.errors
var rawParameters = results.values
var expressionVars = api.getExpressionVars(sendElt)
var allParameters = api.mergeObjects(rawParameters, expressionVars)
var filteredParameters = api.filterValues(allParameters, sendElt)
var sendConfig = {
parameters: filteredParameters,
unfilteredParameters: allParameters,
headers,
errors,
triggeringEvent: evt,
messageBody: undefined,
socketWrapper: socketWrapper.publicInterface
}
if (!api.triggerEvent(elt, 'htmx:wsConfigSend', sendConfig)) {
return
}
if (errors && errors.length > 0) {
api.triggerEvent(elt, 'htmx:validation:halted', errors)
return
}
var body = sendConfig.messageBody
if (body === undefined) {
var toSend = Object.assign({}, sendConfig.parameters)
if (sendConfig.headers) { toSend.HEADERS = headers }
body = JSON.stringify(toSend)
}
socketWrapper.send(body, elt)
if (evt && api.shouldCancel(evt, elt)) {
evt.preventDefault()
}
})
})
}
/**
* getWebSocketReconnectDelay is the default easing function for WebSocket reconnects.
* @param {number} retryCount // The number of retries that have already taken place
* @returns {number}
*/
function getWebSocketReconnectDelay(retryCount) {
/** @type {"full-jitter" | ((retryCount:number) => number)} */
var delay = htmx.config.wsReconnectDelay
if (typeof delay === 'function') {
return delay(retryCount)
}
if (delay === 'full-jitter') {
var exp = Math.min(retryCount, 6)
var maxDelay = 1000 * Math.pow(2, exp)
return maxDelay * Math.random()
}
logError('htmx.config.wsReconnectDelay must either be a function or the string "full-jitter"')
}
/**
* maybeCloseWebSocketSource checks to the if the element that created the WebSocket
* still exists in the DOM. If NOT, then the WebSocket is closed and this function
* returns TRUE. If the element DOES EXIST, then no action is taken, and this function
* returns FALSE.
*
* @param {*} elt
* @returns
*/
function maybeCloseWebSocketSource(elt) {
if (!api.bodyContains(elt)) {
api.getInternalData(elt).webSocket.close()
return true
}
return false
}
/**
* createWebSocket is the default method for creating new WebSocket objects.
* it is hoisted into htmx.createWebSocket to be overridden by the user, if needed.
*
* @param {string} url
* @returns WebSocket
*/
function createWebSocket(url) {
var sock = new WebSocket(url, [])
sock.binaryType = htmx.config.wsBinaryType
return sock
}
/**
* queryAttributeOnThisOrChildren returns all nodes that contain the requested attributeName, INCLUDING THE PROVIDED ROOT ELEMENT.
*
* @param {HTMLElement} elt
* @param {string} attributeName
*/
function queryAttributeOnThisOrChildren(elt, attributeName) {
var result = []
// If the parent element also contains the requested attribute, then add it to the results too.
if (api.hasAttribute(elt, attributeName) || api.hasAttribute(elt, 'hx-ws')) {
result.push(elt)
}
// Search all child nodes that match the requested attribute
elt.querySelectorAll('[' + attributeName + '], [data-' + attributeName + '], [data-hx-ws], [hx-ws]').forEach(function(node) {
result.push(node)
})
return result
}
/**
* @template T
* @param {T[]} arr
* @param {(T) => void} func
*/
function forEach(arr, func) {
if (arr) {
for (var i = 0; i < arr.length; i++) {
func(arr[i])
}
}
}
})()

View file

@ -0,0 +1,99 @@
# Vendored htmx + extensions for the htmx-ssr rendering profile.
#
# Regenerate with: nu scripts/vendor-htmx.nu
# Verify with: nu scripts/vendor-htmx.nu --verify
#
# All hashes are SHA-384, matching the algorithm used by `<script integrity="...">`.
[runtime]
package = "htmx.org"
version = "2.0.6"
source_url = "https://unpkg.com/htmx.org@2.0.6/dist/htmx.min.js"
file = "htmx.min.js"
sha384 = "024a9fadb8ff1e9355a3c935d525c16fa4e50569975e5610ac24aa1169b22897be8439b767f0765951b8b26c01911566"
size_bytes = 51007
# Extensions live under `ext/`. The `name` field is what callers reference in
# `[rendering.htmx].extensions = [...]`. The `attribute` is the value emitted as
# `hx-ext="<attribute>"` when the runtime needs activation in markup.
[[extensions]]
name = "response-targets"
attribute = "response-targets"
version = "2.0.0"
source_url = "https://unpkg.com/htmx-ext-response-targets@2.0.0/response-targets.js"
file = "ext/response-targets.js"
sha384 = "db9123307f94ae8541265a1c54079f9133485691abcab0bb3cdfefc19159acb0d627ae1996013eeed3a0d73ee95847d6"
size_bytes = 3722
[[extensions]]
name = "preload"
attribute = "preload"
version = "2.0.0"
source_url = "https://unpkg.com/htmx-ext-preload@2.0.0/preload.js"
file = "ext/preload.js"
sha384 = "cca87a7aeee9705cd7999a9117fcdc3e32e8a1870881497302bc99ef0c966d842a7f078320e56b5a4dfc1a1ac1bfc8fa"
size_bytes = 5120
[[extensions]]
name = "head-support"
attribute = "head-support"
version = "2.0.0"
source_url = "https://unpkg.com/htmx-ext-head-support@2.0.0/head-support.js"
file = "ext/head-support.js"
sha384 = "0adaa14615756c7aea31259cf1c40640be1ea6352a0878eafe427dced1874b7f6f3bfd6984e832845222784f3b50a32e"
size_bytes = 6204
[[extensions]]
name = "loading-states"
attribute = "loading-states"
version = "2.0.0"
source_url = "https://unpkg.com/htmx-ext-loading-states@2.0.0/loading-states.js"
file = "ext/loading-states.js"
sha384 = "76ec3cb043b6dce068618826f8567da8b5885d771723c6707eddb6debb2873e4c9c5dc928ab79d42c6c9faf1eeee60c5"
size_bytes = 5552
[[extensions]]
name = "multi-swap"
attribute = "multi-swap"
version = "2.0.0"
source_url = "https://unpkg.com/htmx-ext-multi-swap@2.0.0/multi-swap.js"
file = "ext/multi-swap.js"
sha384 = "0762dc0f322df597806965da27b2424a9023936038cdea169fc919b0704993145cbb92aa2ca92a9a646d15c3fbd72d07"
size_bytes = 1480
[[extensions]]
name = "path-deps"
attribute = "path-deps"
version = "2.0.0"
source_url = "https://unpkg.com/htmx-ext-path-deps@2.0.0/path-deps.js"
file = "ext/path-deps.js"
sha384 = "3ca0b054901bd448c1746514b46ad85c500eb40d75cc19b3f691ea292d664ea6d7108e9ff78bd14683e473fff1231cd2"
size_bytes = 1515
[[extensions]]
name = "sse"
attribute = "sse"
version = "2.0.0"
source_url = "https://unpkg.com/htmx-ext-sse@2.0.0/sse.js"
file = "ext/sse.js"
sha384 = "e81434afa060049d477d007b1340bb2ba00ff24f3730a2e2777bf4c45a71f16d9ee2187807d9827843a65ead971ace4f"
size_bytes = 9237
[[extensions]]
name = "ws"
attribute = "ws"
version = "2.0.0"
source_url = "https://unpkg.com/htmx-ext-ws@2.0.0/ws.js"
file = "ext/ws.js"
sha384 = "56a349f9319ee69d7d202ae440f314a483dd7f4e06197e746657d77ebe5246fef599030be49c4e761fc8ba3e00a75909"
size_bytes = 14590
[[extensions]]
name = "idiomorph"
attribute = "morph"
version = "0.3.0"
source_url = "https://unpkg.com/idiomorph@0.3.0/dist/idiomorph-ext.min.js"
file = "ext/idiomorph.js"
sha384 = "d356b0320636431a28e7b7456707a1701e30aa2f53ba70ba7e217d8693da0ec2eef9ac8e1be5afa1ab6f80f5aab9987c"
size_bytes = 8364

1
templates/shared/htmx/htmx.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,22 @@
# Shared client scripts (htmx-ssr glue)
Base client scripts for the `htmx-ssr` render profile. The `website-htmx-ssr`
template pulls these into a site's `site/public/js/`. The `htmx.min.js` library
and extensions live in `templates/shared/htmx/`.
## Load order
1. `theme-init.js` — inline in `<head>` before first paint (no-flash). Minify for inlining.
2. `htmx.min.js` + needed extensions (from `../htmx/`).
3. Feature libraries when used: `hljs` (highlight bundle), `cytoscape`, content-graph.
4. `htmx-reinit.js` — defines `window.RusteloReinit` (must precede handlers).
5. `reinit-handlers.js` — registers `highlight` / `theme` / `tag-filters` / `content-graph`.
6. `tag-filters.js`, `highlight-utils.js` — provide the globals the handlers call.
Handlers feature-detect and no-op when a library is absent, so a site that omits
cytoscape/content-graph drops those libs without touching this glue.
## Provenance
Promoted from `jpl-website/site/public/js`. Snapshot, not a live mirror — when
jpl-website's glue evolves, re-promote (tracked as a backlog item).

View file

@ -0,0 +1,48 @@
// highlight.js utilities (auto-init is handled by the bundle). Exposes
// window.highlightCode / window.highlightContainer for dynamic content, and a
// MutationObserver fallback for non-HTMX DOM mutations. Under the htmx-ssr
// profile the RusteloReinit 'highlight' handler covers swaps; this observer
// covers any other dynamic insertion. Provenance: promoted from jpl-website.
window.highlightCode = function() {
document.querySelectorAll('pre code:not(.hljs)').forEach(function(block) {
if (typeof hljs !== 'undefined' && hljs.highlightElement) {
hljs.highlightElement(block);
}
});
};
window.highlightContainer = function(containerSelector) {
const container = document.querySelector(containerSelector);
if (container && typeof hljs !== 'undefined') {
container.querySelectorAll('pre code:not(.hljs)').forEach(function(block) {
hljs.highlightElement(block);
});
}
};
// Re-highlight when content changes (for dynamic content not driven by HTMX).
document.addEventListener('DOMContentLoaded', function() {
const observer = new MutationObserver(function(mutations) {
let shouldHighlight = false;
mutations.forEach(function(mutation) {
if (mutation.type === 'childList' && mutation.addedNodes.length > 0) {
mutation.addedNodes.forEach(function(node) {
if (node.nodeType === 1 && (node.tagName === 'PRE' || node.querySelector('pre'))) {
shouldHighlight = true;
}
});
}
});
if (shouldHighlight) {
setTimeout(function() {
window.highlightCode();
}, 100);
}
});
observer.observe(document.body, {
childList: true,
subtree: true
});
});

View file

@ -0,0 +1,133 @@
/**
* htmx-reinit.js re-initialise non-HTMX scripts after every HTMX swap.
*
* Framework base for the htmx-ssr render profile. Provenance: promoted from
* jpl-website/site/public/js (keep in sync; see backlog "sync glue").
*
* In the htmx-ssr rendering profile, scripts like highlight.js, cytoscape,
* theme-init, and tag-filters run once at page load. When HTMX swaps a
* fragment in, those scripts don't see the new DOM and the content goes
* "dead" (unhighlighted code blocks, unwired filter pills, etc.).
*
* This module fixes that by exposing a registry
* `window.RusteloReinit.register(fn)` and a single listener on
* `htmx:afterSettle` that walks the registry and calls each handler with
* the swapped element as scope.
*
* Handlers MUST be idempotent. We help by tracking processed nodes in a
* WeakSet keyed by handler id, so double-decoration is impossible even if
* a swap re-emits a region that contains an already-processed node.
*
* Usage from another script:
*
* (function () {
* window.RusteloReinit.register('highlight', function (scope) {
* scope.querySelectorAll('pre code:not(.hljs)').forEach(hljs.highlightElement);
* });
* })();
*
* The registered name is used to namespace the processed-nodes WeakSet so
* each handler manages its own idempotency.
*/
(function () {
'use strict';
if (window.RusteloReinit) {
return; // already loaded
}
/** @type {Array<{ name: string, fn: (scope: Element) => void, seen: WeakSet }>} */
const handlers = [];
function findHandler(name) {
for (let i = 0; i < handlers.length; i++) {
if (handlers[i].name === name) {
return handlers[i];
}
}
return null;
}
function runHandler(handler, scope) {
if (handler.seen.has(scope)) {
return;
}
handler.seen.add(scope);
try {
handler.fn(scope);
} catch (err) {
console.error('[RusteloReinit:' + handler.name + '] handler failed', err);
}
}
const api = {
/**
* Register a re-init handler. Calling register twice with the same
* name replaces the previous handler.
*/
register: function (name, fn) {
if (typeof name !== 'string' || typeof fn !== 'function') {
throw new TypeError('RusteloReinit.register(name, fn) — name must be string, fn must be function');
}
const existing = findHandler(name);
if (existing) {
existing.fn = fn;
// Reset the seen set so the replacement gets a fresh chance at the DOM.
existing.seen = new WeakSet();
} else {
handlers.push({ name: name, fn: fn, seen: new WeakSet() });
}
// Run immediately on document.body so the initial page gets processed.
if (document.body) {
runHandler(findHandler(name), document.body);
} else {
document.addEventListener('DOMContentLoaded', function () {
runHandler(findHandler(name), document.body);
}, { once: true });
}
},
/**
* Force-run every handler against the given scope. Used by tests and by
* the htmx:afterSettle listener.
*/
runAll: function (scope) {
for (let i = 0; i < handlers.length; i++) {
runHandler(handlers[i], scope);
}
},
/** Return registered handler names (debug). */
names: function () {
return handlers.map(function (h) { return h.name; });
}
};
window.RusteloReinit = api;
// Hook into htmx lifecycle events. afterSettle fires after the DOM is in
// its final state for the swap; we want this rather than afterSwap to
// avoid running against elements that get replaced moments later.
document.body && document.body.addEventListener('htmx:afterSettle', function (ev) {
const target = ev.detail && ev.detail.target;
if (target instanceof Element) {
api.runAll(target);
} else {
api.runAll(document.body);
}
});
// If body wasn't ready yet, wire on DOMContentLoaded.
if (!document.body) {
document.addEventListener('DOMContentLoaded', function () {
document.body.addEventListener('htmx:afterSettle', function (ev) {
const target = ev.detail && ev.detail.target;
if (target instanceof Element) {
api.runAll(target);
} else {
api.runAll(document.body);
}
});
}, { once: true });
}
})();

View file

@ -0,0 +1,117 @@
/**
* reinit-handlers.js registers the standard set of HTMX swap re-initialisers.
*
* Framework base for the htmx-ssr render profile. Provenance: promoted from
* jpl-website/site/public/js (keep in sync; see backlog "sync glue").
*
* Loaded AFTER htmx-reinit.js and after the underlying libraries (hljs,
* cytoscape) so the handler bodies can call them directly. Each handler
* scopes its DOM queries to the swap target passing an Element to
* querySelectorAll only searches that subtree, so handlers are O(swap)
* rather than O(document) on every swap.
*
* Handlers feature-detect their dependency and no-op when absent, so a site
* that ships no cytoscape / content-graph simply skips those handlers.
*/
(function () {
'use strict';
if (!window.RusteloReinit) {
console.error('[reinit-handlers] htmx-reinit.js must load before this script');
return;
}
// --- highlight.js -------------------------------------------------------
// Highlight every <pre><code> in the swapped region that isn't already
// decorated. hljs marks processed blocks by adding the .hljs class, which
// makes the selector self-cleaning on re-runs.
window.RusteloReinit.register('highlight', function (scope) {
if (typeof window.hljs === 'undefined' || !window.hljs.highlightElement) {
return;
}
scope.querySelectorAll('pre code:not(.hljs)').forEach(function (block) {
window.hljs.highlightElement(block);
});
});
// --- theme-init ---------------------------------------------------------
// Re-apply the dark/light class on <html> from the theme cookie. Cheap
// and idempotent: setting an already-set class is a no-op.
window.RusteloReinit.register('theme', function (_scope) {
const matches = document.cookie.match(/theme=([^;]+)/);
const theme = matches ? matches[1] : 'dark';
const html = document.documentElement;
const wantDark = theme === 'dark' ||
(theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
if (wantDark) {
html.classList.add('dark');
html.classList.remove('light');
} else {
html.classList.add('light');
html.classList.remove('dark');
}
});
// --- tag-filters --------------------------------------------------------
// Re-arm the filter pill click handlers in the swapped region. The
// legacy script set window.tagFiltersInitialized = true and bailed on
// subsequent runs; bypass that flag by re-binding only pills that don't
// yet carry our data-reinit attribute.
window.RusteloReinit.register('tag-filters', function (scope) {
if (typeof window.filterPostsByTag !== 'function') {
return;
}
scope.querySelectorAll('[data-tag-pill]:not([data-tag-reinit])').forEach(function (pill) {
pill.setAttribute('data-tag-reinit', '1');
pill.addEventListener('click', function (ev) {
ev.preventDefault();
const tag = pill.getAttribute('data-tag');
if (tag) {
window.filterPostsByTag(tag);
}
});
});
});
// --- scroll-to-top -------------------------------------------------------
// Wires a footer "Scroll to top" button emitted by SSR. The Leptos on:click
// handler is WASM-only and compiles to nothing in SSR; this provides the
// equivalent for the HTMX profile via event delegation. Runs once: the
// button lives in the footer which is never swapped.
(function () {
if (window.__rusteloScrollToTopWired) {
return;
}
window.__rusteloScrollToTopWired = true;
document.addEventListener('click', function (ev) {
var btn = ev.target && ev.target.closest('button[aria-label="Scroll to top"]');
if (btn) {
ev.preventDefault();
window.scrollTo({ top: 0, behavior: 'smooth' });
}
});
})();
// --- content-graph ------------------------------------------------------
// ContentGraph.render is idempotent against the same container id (it
// tears down the previous instance internally). Only fire when the
// swap actually contains a graph container. Optional: no-ops if a site
// does not ship the content-graph feature.
window.RusteloReinit.register('content-graph', function (scope) {
if (!window.ContentGraph || typeof window.ContentGraph.render !== 'function') {
return;
}
scope.querySelectorAll('[data-content-graph]').forEach(function (el) {
const containerId = el.getAttribute('id');
const focusId = el.getAttribute('data-focus-id') || null;
const theme = document.documentElement.classList.contains('dark') ? 'dark' : 'light';
if (containerId) {
try {
window.ContentGraph.render(containerId, focusId, theme);
} catch (err) {
console.error('[reinit:content-graph] render failed', err);
}
}
});
});
})();

View file

@ -0,0 +1,184 @@
/**
* Tag Filters - Client-side tag filtering for blog/content posts.
* Filters posts within the current page without navigation. Pairs with the
* RusteloReinit 'tag-filters' handler for HTMX swaps. Selectors assume the
* shared design-system markup (article cards, .ds-badge-ghost, ds-btn-*).
* Provenance: promoted from jpl-website/site/public/js.
*/
(function() {
'use strict';
// Prevent double initialization
if (window.tagFiltersInitialized) {
return;
}
let activeTagFilters = new Set();
// Global function to filter posts by tag (supports multi-selection)
window.filterPostsByTag = function(tagName) {
console.log(`🏷️ Filtering posts by tag: ${tagName}`);
if (activeTagFilters.has(tagName)) {
// If tag is already selected, remove it
activeTagFilters.delete(tagName);
console.log(`🏷️ Removed tag "${tagName}" from selection`);
} else {
// Add tag to selection
activeTagFilters.add(tagName);
console.log(`🏷️ Added tag "${tagName}" to selection`);
}
if (activeTagFilters.size === 0) {
showAllPosts();
updateTagButtonStates(activeTagFilters);
} else {
filterPostsByTags(activeTagFilters);
updateTagButtonStates(activeTagFilters);
}
};
// Global function to clear all tag filters
window.clearAllTagFilters = function() {
console.log('🏷️ Clearing all tag filters');
activeTagFilters.clear();
showAllPosts();
updateTagButtonStates(activeTagFilters);
updateShowAllButton(true);
};
function filterPostsByTags(selectedTags) {
const postCards = document.querySelectorAll('article');
let visibleCount = 0;
const tagArray = Array.from(selectedTags);
postCards.forEach(card => {
const tagElements = card.querySelectorAll('.ds-badge-ghost');
const postTags = Array.from(tagElements).map(el => el.textContent.trim().toLowerCase());
// Check if post has ALL selected tags (AND logic)
// Change to hasAnyTag for OR logic if preferred
const hasAllTags = tagArray.every(selectedTag =>
postTags.includes(selectedTag.toLowerCase())
);
if (hasAllTags) {
card.style.display = '';
card.classList.remove('filter-hidden');
visibleCount++;
} else {
card.style.display = 'none';
card.classList.add('filter-hidden');
}
});
const tagList = tagArray.join('", "');
console.log(`🏷️ Multi-tag filter result: ${visibleCount} posts visible with tags ["${tagList}"]`);
// Show a message if no posts found
const filterText = tagArray.length === 1
? `tagged with "${tagArray[0]}"`
: `tagged with all of: "${tagList}"`;
showFilterMessage(visibleCount, filterText);
updateShowAllButton(false);
}
function showAllPosts() {
const postCards = document.querySelectorAll('article');
postCards.forEach(card => {
card.style.display = '';
card.classList.remove('filter-hidden');
});
hideFilterMessage();
updateShowAllButton(true);
console.log('🏷️ Tag filter cleared: all posts visible');
}
function updateTagButtonStates(activeTagFilters) {
const tagButtons = document.querySelectorAll('button[data-filter-type="tag"]');
tagButtons.forEach(button => {
const tagValue = button.getAttribute('data-filter-value');
if (activeTagFilters.has(tagValue)) {
// Active tag button styling
button.classList.remove('ds-btn-outline');
button.classList.add('ds-btn-primary');
button.classList.add('tag-filter-active');
} else {
// Inactive tag button styling
button.classList.add('ds-btn-outline');
button.classList.remove('ds-btn-primary');
button.classList.remove('tag-filter-active');
}
});
}
function updateShowAllButton(isActive) {
const showAllButton = document.querySelector('button[data-filter-type="show-all"]');
if (showAllButton) {
if (isActive) {
// Active "Show All" button styling
showAllButton.classList.remove('ds-btn-outline');
showAllButton.classList.add('ds-btn-primary');
showAllButton.classList.add('show-all-active');
} else {
// Inactive "Show All" button styling
showAllButton.classList.add('ds-btn-outline');
showAllButton.classList.remove('ds-btn-primary');
showAllButton.classList.remove('show-all-active');
}
}
}
function showFilterMessage(count, filterText) {
// Remove any existing message
hideFilterMessage();
if (count === 0) {
const message = document.createElement('div');
message.className = 'tag-filter-message';
message.innerHTML = `
<div class="text-center py-8 text-gray-600">
<p class="text-lg">No posts found ${filterText}.</p>
<button onclick="window.clearAllTagFilters()" class="mt-2 text-blue-600 hover:text-blue-800 underline">
Show All Posts
</button>
</div>
`;
// Insert after the filter container
const filterContainer = document.querySelector('.filter-container');
if (filterContainer) {
filterContainer.parentNode.insertBefore(message, filterContainer.nextSibling);
}
}
}
function hideFilterMessage() {
const existing = document.querySelector('.tag-filter-message');
if (existing) {
existing.remove();
}
}
// Initialize tag filtering when DOM is ready
function initializeTagFilters() {
console.log('🏷️ Tag filters initialized');
}
// Initialize when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initializeTagFilters);
} else {
initializeTagFilters();
}
// Also try after hydration
setTimeout(initializeTagFilters, 500);
window.tagFiltersInitialized = true;
})();

View file

@ -0,0 +1,17 @@
// Theme initialization script to prevent flash. Inlined into the shell <head>
// before first paint. Provenance: promoted from jpl-website/site/public/js.
(function() {
const getThemeFromCookie = () => {
const matches = document.cookie.match(/theme=([^;]+)/);
return matches ? matches[1] : null;
};
const theme = getThemeFromCookie() || 'dark';
const html = document.documentElement;
if (theme === 'dark' || (theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
html.classList.add('dark');
html.classList.remove('light');
} else {
html.classList.add('light');
html.classList.remove('dark');
}
})();

View file

@ -1,370 +1,92 @@
[
{
"name": "content-website",
"display_name": "📝 Content Website",
"description": "Optimized for content-driven websites with blog, pages, and static content. Includes SSR, language-agnostic routing, and extensible features.",
"icon": "📝",
"complexity": "simple",
"use_cases": [
"Business websites with blog",
"Documentation sites",
"Portfolio sites with projects",
"Community websites",
"Content-driven applications",
"Multi-language content sites"
],
"requirements": {
"node_version": ">=18.0.0",
"rust_version": ">=1.70.0",
"system_dependencies": ["git"],
"optional_dependencies": ["docker", "just"],
"framework_features": ["rustelo-web", "rustelo-content", "content-static"],
"minimum_disk_space_mb": 500,
"supported_platforms": ["linux", "macos", "windows"]
},
"estimated_setup_time": "5 minutes",
"assets": {
"includes": [
"shared/scripts/setup/install.sh",
"shared/scripts/setup/setup_dev.sh",
"shared/scripts/build/build-docs.sh",
"shared/scripts/database/db-setup.sh",
"shared/scripts/database/db-migrate.sh",
"shared/scripts/testing/page-browser-tester.sh",
"shared/scripts/utils/*.sh",
"shared/configs/base/*.toml",
"shared/configs/environments/dev/*.toml",
"shared/configs/features/content.toml",
"shared/docker/Dockerfile.dev",
"shared/content/locales/**/*",
"shared/content/blog/**/*",
"shared/content/recipes/**/*",
"shared/content/menu.toml",
"shared/public/**/*"
],
"excludes": [
"shared/scripts/enterprise/**/*",
"shared/configs/features/advanced/**/*",
"shared/docker/Dockerfile.cross"
],
"template_files": [
"shared/justfile.template",
"shared/package.json.template",
"shared/Cargo.toml.template",
"shared/unocss.config.ts.template",
"shared/rustelo-deps.toml.template",
"shared/src/main.rs.template",
"shared/src/lib.rs.template"
]
}
},
{
"name": "basic",
"display_name": "🏗️ Basic",
"description": "Standard web application template for most projects. Includes blog, pages, responsive design, and essential development tools.",
"icon": "🏗️",
"complexity": "simple",
"use_cases": [
"Business websites",
"Personal portfolios",
"Blogs and documentation",
"Landing pages",
"Small to medium web applications"
],
"requirements": {
"node_version": ">=18.0.0",
"rust_version": ">=1.70.0",
"system_dependencies": ["git"],
"optional_dependencies": ["docker", "just"],
"framework_features": ["rustelo-web", "rustelo-content"],
"minimum_disk_space_mb": 500,
"supported_platforms": ["linux", "macos", "windows"]
},
"estimated_setup_time": "5 minutes",
"assets": {
"includes": [
"shared/scripts/setup/install.sh",
"shared/scripts/setup/setup_dev.sh",
"shared/scripts/build/build-docs.sh",
"shared/scripts/database/db-setup.sh",
"shared/scripts/database/db-migrate.sh",
"shared/scripts/testing/page-browser-tester.sh",
"shared/scripts/utils/*.sh",
"shared/configs/base/*.toml",
"shared/configs/environments/dev/*.toml",
"shared/configs/features/content.toml",
"shared/docker/Dockerfile.dev",
"shared/content/locales/**/*",
"shared/content/blog/**/*",
"shared/content/menu.toml",
"shared/public/**/*"
],
"excludes": [
"shared/scripts/enterprise/**/*",
"shared/configs/features/advanced/**/*",
"shared/docker/Dockerfile.cross"
],
"template_files": [
"shared/justfile.template",
"shared/package.json.template",
"shared/Cargo.toml.template",
"shared/unocss.config.ts.template",
"shared/rustelo-deps.toml.template",
"shared/src/main.rs.template",
"shared/src/lib.rs.template"
]
}
},
{
"name": "minimal",
"display_name": "🪶 Minimal",
"description": "Bare minimum template for simple applications, prototypes, and learning projects. Lightweight with essential features only.",
"icon": "🪶",
"complexity": "simple",
"use_cases": [
"Prototypes and experiments",
"Learning projects",
"Microservices",
"API-only applications",
"Minimal web apps"
],
"requirements": {
"node_version": ">=16.0.0",
"rust_version": ">=1.65.0",
"system_dependencies": ["git"],
"optional_dependencies": [],
"framework_features": ["rustelo-web"],
"minimum_disk_space_mb": 200,
"supported_platforms": ["linux", "macos", "windows"]
},
"estimated_setup_time": "2 minutes",
"assets": {
"includes": [
"shared/scripts/setup/install.sh",
"shared/scripts/utils/to_lower.sh",
"shared/configs/base/app.toml",
"shared/configs/base/server.toml",
"shared/configs/environments/dev/main.toml",
"shared/public/favicon.ico",
"shared/public/logos/**/*"
],
"excludes": [
"shared/scripts/database/**/*",
"shared/scripts/enterprise/**/*",
"shared/scripts/testing/**/*",
"shared/configs/features/**/*",
"shared/docker/**/*",
"shared/content/**/*"
],
"template_files": [
"shared/justfile.template",
"shared/package.json.template",
"shared/Cargo.toml.template",
"shared/unocss.config.ts.template",
"shared/rustelo-deps.toml.template",
"shared/src/main.rs.template",
"shared/src/lib.rs.template"
]
}
},
{
"name": "enterprise",
"display_name": "🏢 Enterprise",
"description": "Advanced template for enterprise applications with multi-environment support, CI/CD, monitoring, security, and compliance features.",
"icon": "🏢",
"complexity": "advanced",
"use_cases": [
"Enterprise applications",
"Large-scale deployments",
"Multi-tenant SaaS",
"Regulated environments",
"Corporate intranets",
"High-availability systems"
],
"requirements": {
"node_version": ">=18.0.0",
"rust_version": ">=1.75.0",
"system_dependencies": ["git", "docker", "kubectl"],
"optional_dependencies": ["helm", "terraform", "vault"],
"framework_features": [
"rustelo-web",
"rustelo-auth",
"rustelo-content",
"rustelo-analytics",
"rustelo-core"
],
"minimum_disk_space_mb": 2048,
"supported_platforms": ["linux", "macos"]
},
"estimated_setup_time": "15 minutes",
"assets": {
"includes": [
"shared/scripts/**/*",
"shared/configs/**/*",
"shared/docker/**/*",
"shared/content/**/*",
"shared/docs/**/*",
"shared/public/**/*"
],
"excludes": [],
"template_files": [
"shared/justfile.template",
"shared/package.json.template",
"shared/Cargo.toml.template",
"shared/unocss.config.ts.template",
"shared/rustelo-deps.toml.template",
"shared/src/main.rs.template",
"shared/src/lib.rs.template"
]
}
},
{
"name": "cms",
"display_name": "📝 CMS",
"description": "Content Management System focused template with admin interface, user roles, media management, and SEO optimization.",
"icon": "📝",
"name": "website-htmx-ssr",
"display_name": "🌐 Website (HTMX-SSR)",
"description": "Config-driven bilingual website rendered server-side with HTMX (no WASM). Blog + pages, design system, and the dual-mode build/deploy pipeline. Sanitized snapshot of the jpl-website model. Self-contained template tree.",
"icon": "🌐",
"complexity": "medium",
"render_mode": "htmx-ssr",
"self_contained": true,
"use_cases": [
"Content-heavy websites",
"Corporate blogs",
"Documentation sites",
"News and magazine sites",
"E-commerce content",
"Marketing websites"
"Content and marketing sites",
"Blogs and documentation",
"Bilingual (multi-language) sites",
"Low-JS / no-WASM deployments"
],
"requirements": {
"node_version": ">=18.0.0",
"rust_version": ">=1.70.0",
"system_dependencies": ["git", "imagemagick"],
"optional_dependencies": ["ffmpeg", "docker"],
"rust_version": ">=1.75.0",
"system_dependencies": [
"git"
],
"optional_dependencies": [
"docker",
"just",
"pnpm"
],
"framework_features": [
"rustelo-web",
"rustelo-content",
"rustelo-auth",
"rustelo-analytics"
"rustelo-pages-htmx"
],
"minimum_disk_space_mb": 1024,
"supported_platforms": ["linux", "macos", "windows"]
},
"estimated_setup_time": "10 minutes",
"assets": {
"includes": [
"shared/scripts/setup/**/*",
"shared/scripts/build/**/*",
"shared/scripts/database/**/*",
"shared/scripts/utils/**/*",
"shared/configs/base/**/*",
"shared/configs/environments/**/*",
"shared/configs/features/content.toml",
"shared/configs/features/auth.toml",
"shared/docker/Dockerfile.dev",
"shared/content/**/*",
"shared/docs/**/*",
"shared/public/**/*"
],
"excludes": [
"shared/scripts/enterprise/**/*",
"shared/configs/features/advanced/**/*"
],
"template_files": [
"shared/justfile.template",
"shared/package.json.template",
"shared/Cargo.toml.template",
"shared/unocss.config.ts.template",
"shared/rustelo-deps.toml.template",
"shared/src/main.rs.template",
"shared/src/lib.rs.template"
"minimum_disk_space_mb": 500,
"supported_platforms": [
"linux",
"macos",
"windows"
]
},
"estimated_setup_time": "n/a",
"assets": {
"includes": [],
"excludes": [],
"template_files": []
}
},
{
"name": "saas",
"display_name": "💼 SaaS",
"description": "Software-as-a-Service template with authentication, subscriptions, multi-tenancy, and business intelligence features.",
"icon": "💼",
"complexity": "advanced",
"name": "website-leptos",
"display_name": "⚛️ Website (Leptos hydration)",
"description": "Config-driven bilingual website with Leptos SSR + WASM hydration (leptos-hydration profile). Same content/config surface as the HTMX template plus the reactive client crate. Sanitized snapshot of the jpl-website model. Self-contained template tree.",
"icon": "⚛️",
"complexity": "medium",
"render_mode": "leptos-hydration",
"self_contained": true,
"use_cases": [
"SaaS applications",
"Subscription-based services",
"Multi-tenant platforms",
"Business applications",
"API-first services"
"Interactive/reactive sites",
"Sites needing client-side reactivity",
"Bilingual (multi-language) sites",
"Progressive enhancement with WASM"
],
"requirements": {
"node_version": ">=18.0.0",
"rust_version": ">=1.75.0",
"system_dependencies": ["git", "postgresql", "redis"],
"optional_dependencies": ["docker", "stripe-cli", "mailgun"],
"framework_features": [
"rustelo-web",
"rustelo-auth",
"rustelo-analytics",
"rustelo-realtime",
"rustelo-core"
"system_dependencies": [
"git",
"cargo-leptos"
],
"optional_dependencies": [
"docker",
"just",
"pnpm"
],
"minimum_disk_space_mb": 1500,
"supported_platforms": ["linux", "macos"]
},
"estimated_setup_time": "20 minutes"
},
{
"name": "ai-powered",
"display_name": "🤖 AI-Powered",
"description": "AI-integrated application template with LLM support, semantic search, embeddings, and intelligent features.",
"icon": "🤖",
"complexity": "advanced",
"use_cases": [
"AI-powered applications",
"Chatbots and assistants",
"Semantic search engines",
"Content generation tools",
"Intelligent document processing"
],
"requirements": {
"node_version": ">=18.0.0",
"rust_version": ">=1.75.0",
"system_dependencies": ["git", "python3", "pip"],
"optional_dependencies": ["cuda", "docker", "vector-db"],
"framework_features": [
"rustelo-web",
"rustelo-ai",
"rustelo-content",
"rustelo-auth",
"rustelo-realtime"
"rustelo-pages-leptos",
"rustelo-client"
],
"minimum_disk_space_mb": 3072,
"supported_platforms": ["linux", "macos"]
"minimum_disk_space_mb": 700,
"supported_platforms": [
"linux",
"macos",
"windows"
]
},
"estimated_setup_time": "25 minutes"
},
{
"name": "ecommerce",
"display_name": "🛒 E-Commerce",
"description": "Complete e-commerce solution with product catalog, shopping cart, payments, inventory, and order management.",
"icon": "🛒",
"complexity": "advanced",
"use_cases": [
"Online stores",
"Marketplace platforms",
"Digital product sales",
"Subscription commerce",
"B2B e-commerce"
],
"requirements": {
"node_version": ">=18.0.0",
"rust_version": ">=1.70.0",
"system_dependencies": ["git", "postgresql", "redis"],
"optional_dependencies": ["stripe-cli", "docker", "elasticsearch"],
"framework_features": [
"rustelo-web",
"rustelo-auth",
"rustelo-content",
"rustelo-analytics",
"rustelo-realtime"
],
"minimum_disk_space_mb": 2048,
"supported_platforms": ["linux", "macos", "windows"]
},
"estimated_setup_time": "30 minutes"
"estimated_setup_time": "n/a",
"assets": {
"includes": [],
"excludes": [],
"template_files": []
}
}
]

View file

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

View file

@ -1,4 +1,4 @@
# Environment Variables for jpl-website
# Environment Variables for website-impl
# Copy this file to .env and fill in your values
# Application Settings
@ -10,13 +10,13 @@ RUST_LOG=info
DATABASE_URL=sqlite:data/dev_database.db
# For PostgreSQL (production)
# DATABASE_URL=postgresql://username:password@localhost/jpl-website_db
# 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=jpl-website
LEPTOS_SITE_NAME=website-impl
# Feature Flags
RUSTELO_AUTH_ENABLED=false
@ -32,3 +32,17 @@ RUSTELO_HOT_RELOAD=true
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=/r
SITE_SERVER_ROOT_CONTENT=r
# 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

View file

@ -0,0 +1,82 @@
# Pre-commit Configuration
# Configures git pre-commit hooks for the website-impl project
repos:
# ============================================================================
# Ontology Hooks
# ============================================================================
- repo: local
hooks:
- id: manifest-coverage
name: Manifest capability completeness
entry: bash -c 'ONTOREF_PROJECT_ROOT="$(pwd)" ontoref sync manifest-check'
language: system
files: (\.ontology/|reflection/modes/|reflection/forms/).*\.ncl$
pass_filenames: false
stages: [pre-commit]
# ============================================================================
# Rust Hooks
# ============================================================================
- repo: local
hooks:
- id: rust-fmt
name: Rust formatting (cargo +nightly fmt)
entry: bash -c 'cargo +nightly fmt --all'
language: system
types: [rust]
pass_filenames: false
stages: [pre-commit]
- id: rust-clippy
name: Rust linting (cargo clippy)
entry: bash -c 'cargo clippy --lib --bins 2>&1 | grep -i warning || true'
language: system
types: [rust]
pass_filenames: false
stages: [pre-commit]
- id: rust-test
name: Rust tests
entry: bash -c 'cargo test --workspace'
language: system
types: [rust]
pass_filenames: false
stages: [pre-push]
- id: rustelo-deps-sync
name: Rustelo deps sync (registry + overlay → workspace.dependencies)
# Fails (exit 1) if [workspace.dependencies] drifted from
# {{RUSTELO_ROOT}}/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_ROOT}}/xtask || {
echo "rustelo-deps-sync: {{RUSTELO_ROOT}}/xtask not found — clone rustelo as sibling dir" >&2; exit 2; };
CARGO_TARGET_DIR="${CARGO_TARGET_DIR:-target/xtask}"
cargo run --manifest-path {{RUSTELO_ROOT}}/xtask/Cargo.toml --quiet --
sync-deps --check --rustelo-root {{RUSTELO_ROOT}} --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$

View file

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

View file

@ -0,0 +1,215 @@
[workspace]
resolver = "2"
members = [
"crates/build-config",
"crates/shared",
"crates/pages",
"crates/client",
"crates/server",
"crates/pages_htmx",
]
[workspace.package]
version = "0.1.0"
authors = ["Development Team <dev@example.com>"]
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/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_ROOT}}/crates/framework/crates/rustelo_auth" }
rustelo_cli = { path = "{{RUSTELO_ROOT}}/crates/framework/crates/rustelo_cli" }
rustelo_client = { path = "{{RUSTELO_ROOT}}/crates/foundation/crates/rustelo_client" }
rustelo_components_leptos = { path = "{{RUSTELO_ROOT}}/crates/foundation/crates/rustelo_components_leptos" }
rustelo_components_htmx = { path = "{{RUSTELO_ROOT}}/crates/foundation/crates/rustelo_components_htmx" }
rustelo_pages_htmx = { path = "{{RUSTELO_ROOT}}/crates/foundation/crates/rustelo_pages_htmx" }
website-pages-htmx = { path = "crates/pages_htmx" }
rustelo_config = { path = "{{RUSTELO_ROOT}}/crates/foundation/crates/rustelo_config" }
rustelo_content = { path = "{{RUSTELO_ROOT}}/crates/framework/crates/rustelo_content" }
rustelo_core = { path = "{{RUSTELO_ROOT}}/crates/framework/crates/rustelo_core" }
rustelo_core_lib = { path = "{{RUSTELO_ROOT}}/crates/foundation/crates/rustelo_core_lib" }
rustelo_core_types = { path = "{{RUSTELO_ROOT}}/crates/foundation/crates/rustelo_core_types" }
rustelo_language = { path = "{{RUSTELO_ROOT}}/crates/foundation/crates/rustelo_language" }
rustelo_macros = { path = "{{RUSTELO_ROOT}}/crates/foundation/crates/rustelo_macros" }
rustelo_pages_leptos = { path = "{{RUSTELO_ROOT}}/crates/foundation/crates/rustelo_pages_leptos" }
rustelo_routing = { path = "{{RUSTELO_ROOT}}/crates/foundation/crates/rustelo_routing" }
rustelo_seo = { path = "{{RUSTELO_ROOT}}/crates/foundation/crates/rustelo_seo" }
rustelo_server = { path = "{{RUSTELO_ROOT}}/crates/foundation/crates/rustelo_server", features = ["rbac-watcher"] }
rustelo_tools = { path = "{{RUSTELO_ROOT}}/crates/foundation/crates/rustelo_tools" }
rustelo_utils = { path = "{{RUSTELO_ROOT}}/crates/foundation/crates/rustelo_utils", features = ["manifest"] }
rustelo_web = { path = "{{RUSTELO_ROOT}}/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_ROOT}}/features/content-graph" }
rustelo_content_graph_ssr = { path = "{{RUSTELO_ROOT}}/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 = []

View file

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

View file

@ -0,0 +1,41 @@
# Template setup — complete these before going live
This site was generated from a Rustelo website template (a sanitized snapshot of a
living site). It builds and runs with placeholder defaults. Replace each item below.
Search the tree for `example.com`, `Your Name`, and `# COMPLETE:` to find spots.
## 1. Identity (required)
- `site/config/site.ncl` — site name, languages, default language.
- `site/config/seo.ncl` / `server.ncl``base_url` (currently `https://example.com`).
- Author/byline strings in `crates/*/templates/pages/about.j2`, `contact.j2`.
## 2. Branding (required)
- Logos were not shipped. Add yours under `site/public/images/logos/` and point
`site/config/site.ncl` `logo.*` at them.
- Theme: `site/assets/styles/themes/*.toml` + `uno.config.ts`.
## 3. Content (required)
- Replace the example post: `site/content/blog/{en,es}/getting-started/`.
- Add pages under `site/content/pages/` and routes in `site/config/routes.ncl`.
## 4. Secrets & services (required for auth/email/deploy)
- Copy `.env.example``.env`; fill session secret, DB URL, SMTP/OAuth.
- `site/config/{email.ncl,external-services.ncl,auth.toml}` — providers.
## 5. Deploy (required only to ship images)
- `provisioning/project.ncl` — namespace, image ref, PVC, pull secret
(`registry-pull-secret`), TLS issuer/zone. All default to `example.com`.
- `lian-build/build_directives.ncl` — registry (`registry.example.com`), signing,
cache, secrets base (`secrets-base`).
- `provisioning/build.nu``SECRETS_BASE` env / workspace.
## 6. Render profile
- `site/config/rendering.ncl` `default_profile` is preset for this template's mode.
Switch profiles only with a matching build (`just build-auto` auto-detects).
## 7. ontoref (optional)
- If you chose ontoref onboarding, `.ontoref/` governs this project. Run
`ONTOREF_ACTOR=agent ontoref describe project` to verify. The rustelo domain
ontology is referenced (not copied) via `.rustelo.ontoref`.
Delete this file once setup is done.

View file

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

View file

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

View file

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

View file

@ -0,0 +1,233 @@
//! 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::*;
use std::path::PathBuf;
#[test]
fn test_load_from_toml() {
// This would require a test fixture
// For now, just verify types are importable
let _config = ContentKindsConfig {
content_kinds: vec![],
};
}
#[test]
fn test_content_kind_structure() {
// Verify we can construct the types
let _features = ContentFeatures {
style_mode: "row".to_string(),
style_css: "test-content".to_string(),
use_feature: true,
use_categories: true,
use_tags: true,
use_emojis: false,
cta_view: "DefaultCTAView".to_string(),
default_page_size: Some(12),
page_size_options: Some(vec![6, 12, 24]),
show_page_info: Some(true),
pagination_style: Some("numbered".to_string()),
show_difficulty: None,
show_duration: None,
show_prerequisites: None,
};
let _kind = ContentKindConfig {
name: "blog".to_string(),
directory: "blog".to_string(),
enabled: true,
is_default: Some(false),
specialized: Some(false),
features: _features,
};
}
}

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,55 @@
[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"]

File diff suppressed because it is too large Load diff

View file

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

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