chore: update templates
This commit is contained in:
parent
0d0297423e
commit
8610b0f0d6
1075 changed files with 191646 additions and 3698 deletions
|
|
@ -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)
|
||||
# ============================================================================
|
||||
|
|
|
|||
31
templates/_ontoref-skeleton/README.md
Normal file
31
templates/_ontoref-skeleton/README.md
Normal 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`.
|
||||
24
templates/_ontoref-skeleton/minimal/card.ncl
Normal file
24
templates/_ontoref-skeleton/minimal/card.ncl
Normal 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 = "",
|
||||
}
|
||||
39
templates/_ontoref-skeleton/minimal/config.ncl
Normal file
39
templates/_ontoref-skeleton/minimal/config.ncl
Normal 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",
|
||||
}
|
||||
33
templates/_ontoref-skeleton/minimal/schemas/backlog.ncl
Normal file
33
templates/_ontoref-skeleton/minimal/schemas/backlog.ncl
Normal 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,
|
||||
},
|
||||
}
|
||||
37
templates/_ontoref-skeleton/minimal/schemas/project-card.ncl
Normal file
37
templates/_ontoref-skeleton/minimal/schemas/project-card.ncl
Normal 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,
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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"
|
||||
|
|
@ -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"]
|
||||
|
|
@ -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"
|
||||
|
|
@ -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"
|
||||
|
|
@ -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
|
||||
|
|
@ -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! 🚀
|
||||
|
|
@ -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"
|
||||
|
|
@ -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.*
|
||||
|
|
@ -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'
|
||||
|
|
@ -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}}"
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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"
|
||||
|
|
@ -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>
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
}
|
||||
}
|
||||
|
|
@ -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/> }
|
||||
})
|
||||
}
|
||||
|
|
@ -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'
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
|
|
@ -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
|
||||
|
|
@ -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"
|
||||
|
|
@ -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"]
|
||||
|
|
@ -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"
|
||||
|
|
@ -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"
|
||||
|
|
@ -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
|
||||
|
|
@ -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! 🚀
|
||||
|
|
@ -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"
|
||||
|
|
@ -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.*
|
||||
|
|
@ -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"
|
||||
]
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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"
|
||||
]
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -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"]
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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"
|
||||
]
|
||||
|
|
@ -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>
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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}}"
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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"
|
||||
|
|
@ -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'
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
|
|
@ -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
|
||||
|
|
@ -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"
|
||||
|
|
@ -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"]
|
||||
|
|
@ -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"
|
||||
|
|
@ -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"
|
||||
|
|
@ -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
|
||||
|
|
@ -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! 🚀
|
||||
|
|
@ -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"
|
||||
|
|
@ -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.*
|
||||
|
|
@ -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'
|
||||
|
|
@ -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}}"
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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/
|
||||
|
|
@ -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"
|
||||
|
|
@ -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>
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
}
|
||||
}
|
||||
|
|
@ -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/> }
|
||||
})
|
||||
}
|
||||
|
|
@ -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
21
templates/options.ncl
Normal 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",
|
||||
},
|
||||
}
|
||||
147
templates/shared/htmx/ext/head-support.js
Normal file
147
templates/shared/htmx/ext/head-support.js
Normal 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;
|
||||
})
|
||||
}
|
||||
});
|
||||
|
||||
})()
|
||||
1
templates/shared/htmx/ext/idiomorph.js
Normal file
1
templates/shared/htmx/ext/idiomorph.js
Normal file
File diff suppressed because one or more lines are too long
184
templates/shared/htmx/ext/loading-states.js
Normal file
184
templates/shared/htmx/ext/loading-states.js
Normal 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()()
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
})()
|
||||
44
templates/shared/htmx/ext/multi-swap.js
Normal file
44
templates/shared/htmx/ext/multi-swap.js
Normal 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
|
||||
}
|
||||
}
|
||||
})
|
||||
})()
|
||||
59
templates/shared/htmx/ext/path-deps.js
Normal file
59
templates/shared/htmx/ext/path-deps.js
Normal 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)
|
||||
140
templates/shared/htmx/ext/preload.js
Normal file
140
templates/shared/htmx/ext/preload.js
Normal 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)
|
||||
})
|
||||
}
|
||||
})
|
||||
129
templates/shared/htmx/ext/response-targets.js
Normal file
129
templates/shared/htmx/ext/response-targets.js
Normal 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
|
||||
}
|
||||
}
|
||||
})
|
||||
})()
|
||||
301
templates/shared/htmx/ext/sse.js
Normal file
301
templates/shared/htmx/ext/sse.js
Normal 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
|
||||
}
|
||||
})()
|
||||
467
templates/shared/htmx/ext/ws.js
Normal file
467
templates/shared/htmx/ext/ws.js
Normal 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])
|
||||
}
|
||||
}
|
||||
}
|
||||
})()
|
||||
99
templates/shared/htmx/htmx.lock.toml
Normal file
99
templates/shared/htmx/htmx.lock.toml
Normal 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
1
templates/shared/htmx/htmx.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
22
templates/shared/public/scripts/README.md
Normal file
22
templates/shared/public/scripts/README.md
Normal 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).
|
||||
48
templates/shared/public/scripts/highlight-utils.js
Normal file
48
templates/shared/public/scripts/highlight-utils.js
Normal 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
|
||||
});
|
||||
});
|
||||
133
templates/shared/public/scripts/htmx-reinit.js
Normal file
133
templates/shared/public/scripts/htmx-reinit.js
Normal 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 });
|
||||
}
|
||||
})();
|
||||
117
templates/shared/public/scripts/reinit-handlers.js
Normal file
117
templates/shared/public/scripts/reinit-handlers.js
Normal 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);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
})();
|
||||
184
templates/shared/public/scripts/tag-filters.js
Normal file
184
templates/shared/public/scripts/tag-filters.js
Normal 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;
|
||||
|
||||
})();
|
||||
17
templates/shared/public/scripts/theme-init.js
Normal file
17
templates/shared/public/scripts/theme-init.js
Normal 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');
|
||||
}
|
||||
})();
|
||||
|
|
@ -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": []
|
||||
}
|
||||
}
|
||||
]
|
||||
|
|
|
|||
8
templates/website-htmx-ssr/.dockerignore
Normal file
8
templates/website-htmx-ssr/.dockerignore
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
.git/
|
||||
target/
|
||||
node_modules/
|
||||
.coder/
|
||||
works-pv/
|
||||
cache/
|
||||
lian-build/ctx-*
|
||||
.DS_Store
|
||||
|
|
@ -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
|
||||
82
templates/website-htmx-ssr/.pre-commit-config.yaml
Normal file
82
templates/website-htmx-ssr/.pre-commit-config.yaml
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
# Pre-commit Configuration
|
||||
# Configures git pre-commit hooks for the website-impl project
|
||||
|
||||
repos:
|
||||
# ============================================================================
|
||||
# Ontology Hooks
|
||||
# ============================================================================
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: manifest-coverage
|
||||
name: Manifest capability completeness
|
||||
entry: bash -c 'ONTOREF_PROJECT_ROOT="$(pwd)" ontoref sync manifest-check'
|
||||
language: system
|
||||
files: (\.ontology/|reflection/modes/|reflection/forms/).*\.ncl$
|
||||
pass_filenames: false
|
||||
stages: [pre-commit]
|
||||
|
||||
# ============================================================================
|
||||
# Rust Hooks
|
||||
# ============================================================================
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: rust-fmt
|
||||
name: Rust formatting (cargo +nightly fmt)
|
||||
entry: bash -c 'cargo +nightly fmt --all'
|
||||
language: system
|
||||
types: [rust]
|
||||
pass_filenames: false
|
||||
stages: [pre-commit]
|
||||
|
||||
- id: rust-clippy
|
||||
name: Rust linting (cargo clippy)
|
||||
entry: bash -c 'cargo clippy --lib --bins 2>&1 | grep -i warning || true'
|
||||
language: system
|
||||
types: [rust]
|
||||
pass_filenames: false
|
||||
stages: [pre-commit]
|
||||
|
||||
- id: rust-test
|
||||
name: Rust tests
|
||||
entry: bash -c 'cargo test --workspace'
|
||||
language: system
|
||||
types: [rust]
|
||||
pass_filenames: false
|
||||
stages: [pre-push]
|
||||
|
||||
- id: rustelo-deps-sync
|
||||
name: Rustelo deps sync (registry + overlay → workspace.dependencies)
|
||||
# Fails (exit 1) if [workspace.dependencies] drifted from
|
||||
# {{RUSTELO_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$
|
||||
92
templates/website-htmx-ssr/CHANGELOG.md
Normal file
92
templates/website-htmx-ssr/CHANGELOG.md
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
# Changelog
|
||||
|
||||
All notable changes to website-impl are documented here.
|
||||
Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
Not versioned until production deployment — see `.ontology/state.ncl` for current `deployment-readiness` state.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Changed — Server Restructuring & on+re migration (2026-04-06)
|
||||
- **Server restructured** to rustelo template pattern (app.rs, run.rs, shell.rs, resources.rs, theme.rs) — custom auth stack removed
|
||||
- **Auth removed**: JWT, OTP, WebSocket hot-reload, user dashboard, server_fns.rs, database/auth.rs — `auth-system` dimension regressed to `build-time-only`
|
||||
- **Project showcase pages** added: kogral, vapora, rustelo, syntaxis, typedialog, secretumvault, stratumiops, provisioning, ontoref, work_request, services
|
||||
- **Post viewer simplified**: client.rs and html_generator.rs removed, only mod.rs remains
|
||||
- **CSS scripts moved** from `scripts/` to `scripts/build/` subdirectory
|
||||
- **on+re migration**: `state.ncl` — new `build-time-only` state in auth-system, deployment-readiness pre-production description updated, transition blockers updated
|
||||
- **on+re migration**: `core.ncl` — hydration-strategy artifact paths corrected, auth-rbac-impl updated (build-time only), user-dashboard updated (removed, FTL preserved), css-asset-pipeline script paths updated, new `project-showcase-pages` node added
|
||||
- **ADR-001** (website-impl): Server Template Restructuring — auth removal constraints captured
|
||||
|
||||
### Added — Architecture Self-Description (2026-03-14)
|
||||
- **on+re protocol** integrated: `.ontology/` + `adrs/` + `reflection/` in place
|
||||
- `.ontology/core.ncl` — knowledge graph as Rustelo Service consumer: axioms (`rustelo-consumer`, `bilingual-content`), tensions (hydration complexity, build-time vs runtime), project nodes (build-time codegen impl, hydration strategy, cache layer, NCL config, custom routing impl, auth RBAC, user dashboard, CSS asset pipeline)
|
||||
- `.ontology/state.ncl` — four tracked dimensions: `deployment-readiness` (development → **pre-production**), `hydration-stability` (stabilized → **zero-mismatch**), `auth-system` (**operational**), `css-pipeline` (**correct**)
|
||||
- `.ontology/gate.ncl` — `hydration-parity` (Low permeability, Challenge protocol), `content-integrity` (Medium, Observe protocol), `rbac-correctness` (Low, Challenge protocol)
|
||||
- `.ontology/manifest.ncl` — Service manifest with EndUser, Developer, Agent consumption modes; `rustelo-browse` operational mode for cross-project framework capability browsing
|
||||
- `reflection/` — backlog, constraints, defaults, qa, schema; modes: `integrity-check`, `sync-ontology`, `validate-content`, `validate-hydration`
|
||||
- `adrs/adr-001-ncl-over-toml.ncl` — NCL (Nickel) over TOML for site configuration
|
||||
- `adrs/adr-002-hydration-strategy.ncl` — SsrTranslator in both SSR+WASM targets; 6 hard/soft constraints including `menu-registry-language-keyed`, `reactive-closure-copy-only`, `spa-content-id-not-slug`
|
||||
- `adrs/adr-003-ws-rbac-hot-reload.ncl` — WebSocket broadcast for RBAC hot-reload without server restart; 3 constraints (`content-module-always-compiled`, `chrono-non-optional`, `server-enforcement-primary`)
|
||||
|
||||
### Added — User Dashboard (2026-03-03)
|
||||
- 5-tab authenticated user area: Perfil, Marcadores, Mensajes, Notas, Recursos
|
||||
- DB migrations `005_user_dashboard_{postgres,sqlite}.sql`: `user_messages`, `user_notes`, `user_resources` tables
|
||||
- `database/auth.rs`: `MessageRow`, `NoteRow`, `ResourceRow` + 7 query methods
|
||||
- `server_fns.rs`: 7 server functions (`UserMessage`, `UserNote`, `UserResource`)
|
||||
- `otp_service.rs` + `otp_adapter.rs`: trait + full implementation for all 7 methods
|
||||
- `site/rbac.ncl`: 7 endpoint allow rules for `user` group
|
||||
- `site/i18n/locales/{en,es}/pages/user.ftl`: ~20 new i18n keys per language
|
||||
- `crates/pages/src/user/unified.rs`: 5-tab dashboard rewrite with signal-safety patterns
|
||||
|
||||
### Fixed — NavMenu Auth & RBAC (2026-03-03)
|
||||
- `AuthControls` moved inline in `UnifiedNavMenu` (desktop + mobile) — no longer a fixed overlay
|
||||
- `navmenu/unified.rs`: auth strings (`nav-sign-in`, `nav-sign-out`, `nav-dashboard`) computed inside reactive closures via `UnifiedI18n::new` — reactive to language changes
|
||||
- `site/rbac.ncl`: authenticated `user` group allow rules for assets, `track_page_view*`, `server_update_display_name*`
|
||||
- `database/auth.rs`: `parse_sqlite_datetime()` helper handles both RFC3339 and `%Y-%m-%d %H:%M:%S` — fixes OTP login panic on SQLite `datetime('now')` format
|
||||
|
||||
### Added — RBAC Hot-Reload via WebSocket (2026-03-01)
|
||||
- `site/rbac.ncl` file-watch triggers in-process server reload of `AUTH_PATTERNS`
|
||||
- New patterns broadcast over WebSocket to all connected WASM clients
|
||||
- WASM receives broadcast and updates reactive signal — UI gates update without page reload
|
||||
- `content` module compiled unconditionally (no feature gate); `chrono` made non-optional
|
||||
|
||||
### Fixed — CSS Asset Pipeline (2026-03-05)
|
||||
- `scripts/build-css-bundles.js`: reads from correct source (`site/assets/styles/`, not cache)
|
||||
- `scripts/download-highlight-css.js`: output to `site/public/assets/styles/` (was `public/styles/`)
|
||||
- `scripts/download-highlightjs-copy-css.js`: output path fixed + converted to ES modules
|
||||
- `scripts/copy-css-assets.js`: excludes `.DS_Store`, `.toml`, and `themes/` directory from public sync
|
||||
- `package.json`: `highlightjs-copy:css` added to build pipeline
|
||||
- `site/config/assets.ncl`: all CSS paths corrected from `/styles/` → `/assets/styles/`
|
||||
|
||||
### Fixed — Hydration: SPA PostViewer Cold-Cache 404 (2026-02-26)
|
||||
- `item.id ≠ item.slug` for some posts — HTML files are named after `item.id`, not `item.slug`
|
||||
- On cold SPA navigation: `load_content_index` returned `[]` → fallback used `url_slug` as filename → 404
|
||||
- Fix: `AsyncContentUpdater::resolve_and_fetch_content` fetches `index.json`, matches `item.slug == url_slug`, then uses `item.id` for the file path
|
||||
- `_content_memo` gated to `#[cfg(not(target_arch = "wasm32"))]` — was firing in WASM with wrong language
|
||||
|
||||
### Fixed — Hydration: WASM FTL Registry (2026-02-26)
|
||||
- `FTL_REGISTRY` was never populated in WASM — all translations fell through to SSR-embedded data (initial language only)
|
||||
- `crates/client/build.rs`: generates `ftl_registry_fn.rs` with `include_str!` for every `.ftl` file (46 entries for en+es)
|
||||
- `crates/client/src/lib.rs`: calls `populate_wasm_ftl_registry()` at WASM startup — populates registry before any component renders
|
||||
- Language switching now works correctly in WASM: `get_parsed_ftl_for_language("en")` finds all `en_*` keys
|
||||
|
||||
### Fixed — Hydration: Page Cache Empty HashMap (2026-02-26)
|
||||
- All 15 `*Client` cache components passed `HashMap::new()` as `lang_content` — caused `HtmlContent` empty `inner_html` → tachys `<!>` placeholder → cursor desync → hydration panic
|
||||
- `target/rustelo-cache/pages/page_*.rs` `*Client` components: replaced `HashMap::new()` with `SsrTranslator` + `build_page_content_patterns`
|
||||
- `build_page_generator.rs` template updated — future regenerations produce structurally identical code for both targets
|
||||
|
||||
### Fixed — Hydration: Menu Registry Key Mismatch (2026-02-26)
|
||||
- `MENU_REGISTRY` keyed `"main"` in WASM init; `get_menu(lang)` looks up by `"en"`/`"es"` → 0 items in WASM vs 6 in server
|
||||
- `crates/server/build.rs` + `crates/client/build.rs`: now insert per-language keys (`"en"`, `"es"`) from `config.ncl` routes
|
||||
- `navmenu/unified.rs`: `lang_signal.get_untracked()` in SSR to suppress tracking-context warning
|
||||
|
||||
### Added — Configuration-Driven Routing (2026-02-25)
|
||||
- `routing.rs` fully rewritten: uses `ROUTE_TABLE` (BTreeMap from NCL config) — no hardcoded paths
|
||||
- `build.rs`: reads `site/config/routes/*.ncl`, generates `route_table.rs` + `RouteComponent` enum
|
||||
- `lib.rs`: `#![recursion_limit = "512"]` added for complex generated types
|
||||
|
||||
### Added — NCL Configuration (2026-02-09)
|
||||
- Route configuration migrated from `*.toml` to `site/config/routes/*.ncl`
|
||||
- RBAC rules in `site/rbac.ncl` with typed contracts
|
||||
- Asset configuration in `site/config/assets.ncl`
|
||||
- Reusable schemas in `site/schemas/` for content, menus, footer, themes
|
||||
- Bilingual menus in single NCL file (eliminated en.toml/es.toml duplication)
|
||||
215
templates/website-htmx-ssr/Cargo.toml
Normal file
215
templates/website-htmx-ssr/Cargo.toml
Normal file
|
|
@ -0,0 +1,215 @@
|
|||
|
||||
[workspace]
|
||||
resolver = "2"
|
||||
members = [
|
||||
"crates/build-config",
|
||||
"crates/shared",
|
||||
"crates/pages",
|
||||
"crates/client",
|
||||
"crates/server",
|
||||
"crates/pages_htmx",
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.0"
|
||||
authors = ["Development Team <dev@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 = []
|
||||
357
templates/website-htmx-ssr/README.md
Normal file
357
templates/website-htmx-ssr/README.md
Normal file
|
|
@ -0,0 +1,357 @@
|
|||
# Website Implementation - Rustelo Framework
|
||||
|
||||
A complete **content website with code highlighting** built using the Rustelo framework, demonstrating modern multi-crate architecture and PAP compliance.
|
||||
|
||||
## 🎯 Project Overview
|
||||
|
||||
This implementation validates Rustelo's project generation capabilities and serves as a reference for content websites that need code display features.
|
||||
|
||||
### Key Features
|
||||
|
||||
- **Multi-crate architecture**: Client, server, shared, pages with build.rs integration
|
||||
- **Type-safe configuration**: Nickel (NCL) for routes, themes, menus with compile-time validation
|
||||
- **Bilingual single-source**: Zero duplication - EN/ES in one file
|
||||
- **Code highlighting**: Complete syntax highlighting with copy functionality
|
||||
- **Language-agnostic**: Supports any language without code changes
|
||||
- **Smart caching**: Multi-layer cache system for incremental builds
|
||||
- **PAP compliant**: Follows Rustelo's Project Architecture Principles
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
### Prerequisites
|
||||
- **Rust 1.75+** - Core language and toolchain
|
||||
- **Node.js 18+** - Frontend tooling
|
||||
- **Nickel** (Configuration language) - **REQUIRED** for type-safe configuration
|
||||
- macOS: `brew install nickel`
|
||||
- Linux/Windows: `cargo install nickel-lang-cli`
|
||||
- See [Nickel Installation Guide](../../docs/guides/nickel-installation.md)
|
||||
- Site content in `../site/` directory
|
||||
|
||||
> **Note**: This project uses Nickel (NCL) for all configuration files (routes, themes, menus, content types). See [NCL Configuration](#-ncl-configuration) section below.
|
||||
|
||||
### Setup & Development
|
||||
```bash
|
||||
# Install dependencies
|
||||
just setup
|
||||
|
||||
# Start development server with hot reload
|
||||
just dev
|
||||
|
||||
# Build for production
|
||||
just build-prod
|
||||
```
|
||||
|
||||
### Development Commands
|
||||
```bash
|
||||
# Development
|
||||
just dev # Hot reload with CSS watching
|
||||
just dev-server # Rust only (no CSS watching)
|
||||
just dev-css # CSS watching only
|
||||
|
||||
# Building
|
||||
just build # Development build
|
||||
just build-prod # Production build
|
||||
just build-rust # Rust components only
|
||||
just build-css # CSS only
|
||||
|
||||
# Quality & Testing
|
||||
just quality # All quality checks
|
||||
just test # Run tests
|
||||
just format # Format code
|
||||
just lint # Lint code
|
||||
|
||||
# Content & Deployment
|
||||
just validate-content # Validate site content
|
||||
just deploy-staging # Deploy to staging
|
||||
just status # Project status
|
||||
```
|
||||
|
||||
## 🏗️ Architecture
|
||||
|
||||
### Multi-Crate Structure
|
||||
```
|
||||
website-impl/
|
||||
├── crates/
|
||||
│ ├── shared/ # Route generation & shared types
|
||||
│ ├── pages/ # Custom page component generation
|
||||
│ ├── client/ # WASM frontend with asset processing
|
||||
│ ├── server/ # Axum backend with configuration
|
||||
│ └── plugin-example-theme/ # Example plugin (theme + i18n)
|
||||
├── config.toml # Application configuration
|
||||
├── uno.config.ts # UnoCSS configuration (synced with @website)
|
||||
├── package.json # Code highlighting & build tools
|
||||
├── justfile # Development automation
|
||||
└── scripts/ # Build scripts for themes & highlighting
|
||||
```
|
||||
|
||||
## 🔌 Plugin System
|
||||
|
||||
This implementation demonstrates Rustelo's **Level 5 Plugin Architecture** - a trait-based plugin system for unlimited extensibility without framework coupling.
|
||||
|
||||
### Included Plugins
|
||||
|
||||
- **WebsiteResourceContributor** (core): Provides themes, menus, and i18n
|
||||
- **plugin-example-theme** (example): Complete working theme plugin with tests
|
||||
|
||||
### Plugin Features
|
||||
|
||||
- ✅ **ResourceContributor Trait**: Contribute themes, menus, translations
|
||||
- ✅ **Type-Safe Registration**: Compile-time validation
|
||||
- ✅ **Zero Conditional Compilation**: Framework code is pure Rust
|
||||
- ✅ **Configuration-Driven**: Resources from TOML/FTL files
|
||||
- ✅ **Self-Contained**: Plugins are independent crates
|
||||
|
||||
### Creating Custom Plugins
|
||||
|
||||
**Step 1: Create plugin crate**
|
||||
```bash
|
||||
cd crates/
|
||||
cargo new --lib my-custom-plugin
|
||||
```
|
||||
|
||||
**Step 2: Implement ResourceContributor**
|
||||
```rust
|
||||
use rustelo_core_lib::registration::ResourceContributor;
|
||||
|
||||
pub struct MyPlugin;
|
||||
|
||||
impl ResourceContributor for MyPlugin {
|
||||
fn contribute_themes(&self) -> HashMap<String, String> {
|
||||
let mut themes = HashMap::new();
|
||||
themes.insert("my-theme".to_string(),
|
||||
include_str!("../config/themes/my-theme.toml").to_string());
|
||||
themes
|
||||
}
|
||||
|
||||
fn name(&self) -> &str {
|
||||
"my-plugin"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 3: Add to workspace**
|
||||
- Add to main `Cargo.toml` workspace members
|
||||
- Create configuration files in `config/themes/` and `config/i18n/`
|
||||
|
||||
**Step 4: Register at startup**
|
||||
```rust
|
||||
// In crates/server/src/resources.rs
|
||||
rustelo_core_lib::register_contributor(&MyPlugin)?;
|
||||
```
|
||||
|
||||
### Plugin Types
|
||||
|
||||
| Type | Purpose | Example |
|
||||
|------|---------|---------|
|
||||
| **Resource-Only** | Themes, menus, translations | Custom theme plugin |
|
||||
| **Page Provider** | Custom page components | Analytics dashboard |
|
||||
| **Composite** | Resources + pages | Feature module |
|
||||
|
||||
### Example Plugin Walkthrough
|
||||
|
||||
See `crates/plugin-example-theme/` for a complete, production-ready example:
|
||||
- Analytics dashboard theme configuration
|
||||
- English and Spanish translations
|
||||
- 10/10 passing unit tests
|
||||
- Comprehensive documentation
|
||||
|
||||
### Plugin Documentation
|
||||
|
||||
- **Quick Start**: See this README's "Creating Custom Plugins" section above
|
||||
- **Complete Guide**: [Plugin Development Guide](../../.coder/info/PHASE3-PLUGIN-DEVELOPMENT-GUIDE.md)
|
||||
- **Architecture**: [Rustelo Plugin Architecture](../../docs/architecture/rustelo-plugin-architecture.md)
|
||||
- **Example Plugin**: `./crates/plugin-example-theme/README.md`
|
||||
|
||||
## 🎨 Code Highlighting Features
|
||||
|
||||
### Syntax Highlighting
|
||||
- **highlight.js**: Multi-language syntax highlighting
|
||||
- **highlightjs-copy**: One-click code copying
|
||||
- **Theme system**: Light/dark mode with custom themes
|
||||
- **UnoCSS integration**: Built-in code styling with design system
|
||||
|
||||
### Design System Integration
|
||||
Uses complete design system from @website:
|
||||
- `ds-*` prefixed utility classes
|
||||
- Theme variables for consistent styling
|
||||
- Built-in dark mode support
|
||||
- Code block styling with proper contrast
|
||||
|
||||
## 📝 NCL Configuration
|
||||
|
||||
This project uses **Nickel (NCL)** for type-safe, DRY configuration. All configuration files use `.ncl` format with TOML fallback support.
|
||||
|
||||
### Why NCL?
|
||||
|
||||
✅ **Type Safety**: Compile-time validation catches errors before runtime
|
||||
✅ **Zero Duplication**: Bilingual configs in single file (no separate en.toml/es.toml)
|
||||
✅ **DRY Principles**: Shared defaults and helper functions
|
||||
✅ **Better Tooling**: Syntax highlighting, LSP support, validation
|
||||
|
||||
### Configuration Files
|
||||
|
||||
```
|
||||
site/
|
||||
├── schemas/ # Reusable NCL type definitions
|
||||
│ ├── content/
|
||||
│ │ ├── contracts.ncl # Type contracts for content
|
||||
│ │ └── defaults.ncl # Shared defaults + helpers
|
||||
│ ├── menus/
|
||||
│ │ ├── contracts.ncl
|
||||
│ │ └── defaults.ncl
|
||||
│ ├── footer/
|
||||
│ │ ├── contracts.ncl
|
||||
│ │ └── defaults.ncl
|
||||
│ └── themes/
|
||||
│ ├── contracts.ncl
|
||||
│ └── defaults.ncl
|
||||
├── config/
|
||||
│ └── themes/
|
||||
│ ├── default.ncl # Default theme
|
||||
│ └── dark.ncl # Dark theme variant
|
||||
├── content/
|
||||
│ └── content-kinds.ncl # Content type definitions
|
||||
└── ui/
|
||||
├── menus/
|
||||
│ └── menu.ncl # Navigation menu (bilingual)
|
||||
└── footer/
|
||||
└── footer.ncl # Footer config (bilingual)
|
||||
```
|
||||
|
||||
### Example: Bilingual Menu (Before vs After)
|
||||
|
||||
**Before (TOML - 130 lines across 2 files):**
|
||||
```toml
|
||||
# en.toml
|
||||
[[items]]
|
||||
route = "/services"
|
||||
label = "Services"
|
||||
|
||||
# es.toml
|
||||
[[items]]
|
||||
route = "/servicios"
|
||||
label = "Servicios"
|
||||
```
|
||||
|
||||
**After (NCL - 115 lines, single file):**
|
||||
```nickel
|
||||
{
|
||||
items = [
|
||||
make_menu_item {
|
||||
routes = { en = "/services", es = "/servicios" },
|
||||
labels = { en = "Services", es = "Servicios" },
|
||||
},
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Working with NCL Configs
|
||||
|
||||
**Export to JSON:**
|
||||
```bash
|
||||
nickel export --format json site/ui/menus/menu.ncl | jq '.'
|
||||
```
|
||||
|
||||
**Validate:**
|
||||
```bash
|
||||
nickel typecheck site/ui/menus/menu.ncl
|
||||
```
|
||||
|
||||
**Auto-Detection:**
|
||||
Build system automatically prefers `.ncl` files over `.toml`:
|
||||
```
|
||||
site/config/themes/dark.ncl ✅ Used
|
||||
site/config/themes/dark.toml ⏭️ Ignored (fallback)
|
||||
```
|
||||
|
||||
### Documentation
|
||||
|
||||
- **Configuration Guide**: [../../docs/guides/nickel-configuration-guide.md](../../docs/guides/nickel-configuration-guide.md)
|
||||
- **Installation**: [../../docs/guides/nickel-installation.md](../../docs/guides/nickel-installation.md)
|
||||
- **ADR**: [../../docs/adr/0002-nickel-configuration-language.md](../../docs/adr/0002-nickel-configuration-language.md)
|
||||
- **Implementation Summary**: [./.coder/info/summaries/2026-02-09-nickel-config-implementation-complete.md](./.coder/info/summaries/2026-02-09-nickel-config-implementation-complete.md)
|
||||
|
||||
### Metrics
|
||||
|
||||
| Config Type | Lines | Duplication Reduced |
|
||||
|-------------|-------|---------------------|
|
||||
| Content-Kinds | 29 | ~60% less |
|
||||
| Menus | 115 | 100% (bilingual) |
|
||||
| Footers | 63 | 29% smaller |
|
||||
| Themes | 4 × 14 | Variants from base |
|
||||
|
||||
**Total**: ~140 lines of config + ~920 lines of reusable schemas
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Configuration
|
||||
|
||||
### Site Integration
|
||||
```toml
|
||||
# Links to site content structure
|
||||
[content]
|
||||
root_path = "../site" # Site content directory
|
||||
public_path = "../site/public" # Static assets
|
||||
content_url = "/content" # Content API URL
|
||||
types = ["blog", "recipes"] # Available content types
|
||||
languages = ["en", "es"] # Supported languages
|
||||
default_language = "en" # Default language
|
||||
```
|
||||
|
||||
## 🧪 PAP Compliance
|
||||
|
||||
✅ **Configuration-driven**: All routes, themes, menus from NCL files with type-safety
|
||||
✅ **Language-agnostic**: No hardcoded languages, bilingual single-source configs
|
||||
✅ **Custom routing**: Uses Rustelo routing, NOT Leptos router
|
||||
✅ **Error handling**: Proper Result<T, E> patterns, no unwrap()
|
||||
✅ **Modular design**: Feature-based architecture with plugin system
|
||||
✅ **Type-safe configuration**: Nickel contracts validate at compile-time
|
||||
✅ **No hardcoding**: All paths and routes configurable
|
||||
✅ **Self-describing**: Architecture tracked via on+re protocol — `.ontology/` + `adrs/`
|
||||
|
||||
## 🔍 Architecture Self-Description (on+re)
|
||||
|
||||
This project implements the [Ontoref](https://github.com/ontoref) `on+re` protocol. The `.ontology/` and `adrs/` directories provide a machine- and agent-readable description of the project's architecture, current state, and invariants as a Rustelo consumer (Service kind).
|
||||
|
||||
### `.ontology/` files
|
||||
|
||||
| File | Contents |
|
||||
|------|---------|
|
||||
| `core.ncl` | Knowledge graph: axioms (`rustelo-consumer`, `bilingual-content`), tensions (hydration complexity, build-time vs runtime), project nodes |
|
||||
| `state.ncl` | State dimensions: `deployment-readiness` (pre-production), `hydration-stability` (zero-mismatch), `auth-system` (operational), `css-pipeline` (correct) |
|
||||
| `gate.ncl` | Membranes: `hydration-parity` (Low permeability), `content-integrity` (Medium), `rbac-correctness` (Low) |
|
||||
| `manifest.ncl` | Service manifest: EndUser, Developer, Agent consumption modes; implementation, content, self-description layers |
|
||||
|
||||
### ADR System (`adrs/`)
|
||||
|
||||
| ADR | Decision |
|
||||
|-----|---------|
|
||||
| `adr-001` | NCL (Nickel) over TOML for site configuration |
|
||||
| `adr-002` | SsrTranslator in both SSR and WASM targets for hydration parity — 4 hard constraints |
|
||||
| `adr-003` | WebSocket broadcast for RBAC hot-reload without server restart |
|
||||
|
||||
### Browsing the architecture
|
||||
|
||||
```bash
|
||||
# What this project is and how it can be consumed
|
||||
nickel export .ontology/manifest.ncl
|
||||
|
||||
# Current state across all tracked dimensions
|
||||
nickel export .ontology/state.ncl
|
||||
|
||||
# Active architecture constraints and gates
|
||||
nickel export .ontology/gate.ncl
|
||||
|
||||
# Cross-project: browse Rustelo framework capabilities
|
||||
# (rustelo-browse operational mode in manifest.ncl)
|
||||
nickel export ../../rustelo/.ontology/core.ncl
|
||||
```
|
||||
|
||||
## 📖 Documentation
|
||||
|
||||
- **Setup Guide**: `info/setup_from_rustelo_plan.md` - Complete implementation journey
|
||||
- **Enhancements**: `info/enhancements/` - Proposed improvements for Rustelo
|
||||
|
||||
## 📄 License
|
||||
|
||||
MIT License - Part of the Rustelo framework ecosystem.
|
||||
41
templates/website-htmx-ssr/TEMPLATE-SETUP.md
Normal file
41
templates/website-htmx-ssr/TEMPLATE-SETUP.md
Normal 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.
|
||||
31
templates/website-htmx-ssr/bacon.toml
Normal file
31
templates/website-htmx-ssr/bacon.toml
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
[jobs.htmx-dev]
|
||||
command = [
|
||||
"cargo", "run",
|
||||
"--package", "rustelo-htmx-server",
|
||||
"--no-default-features",
|
||||
"--features", "htmx-ssr,content-watcher",
|
||||
]
|
||||
watch = [
|
||||
"crates/server/src",
|
||||
"crates/shared/src",
|
||||
"site/config",
|
||||
"site/content",
|
||||
"site/i18n",
|
||||
]
|
||||
need_stdout = true
|
||||
on_success = "back"
|
||||
|
||||
[jobs.check]
|
||||
command = ["cargo", "check", "--workspace", "--all-targets"]
|
||||
need_stdout = false
|
||||
|
||||
[jobs.clippy]
|
||||
command = ["cargo", "clippy", "--workspace", "--all-targets", "--", "-D", "warnings"]
|
||||
need_stdout = false
|
||||
|
||||
[jobs.test]
|
||||
command = ["cargo", "test", "--workspace"]
|
||||
need_stdout = true
|
||||
|
||||
[keybindings]
|
||||
h = "job:htmx-dev"
|
||||
13
templates/website-htmx-ssr/crates/README.md
Normal file
13
templates/website-htmx-ssr/crates/README.md
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
# website-impl crates
|
||||
|
||||
| Crate | Role |
|
||||
|-------|------|
|
||||
| `build-config` | NCL config loading at build time |
|
||||
| `shared` | Types and utilities shared between server and client |
|
||||
| `pages` | Site-specific Leptos page components |
|
||||
| `server` | Axum SSR server entry point |
|
||||
| `client` | WASM hydration entry point |
|
||||
| `plugin-example-theme` | Example plugin — custom theme via ResourceContributor |
|
||||
|
||||
UI components are provided by `rustelo_components` (foundation) and used directly.
|
||||
For site-specific component overrides see [rustelo_components/override.md](../../rustelo/crates/foundation/crates/rustelo_components/override.md).
|
||||
22
templates/website-htmx-ssr/crates/build-config/Cargo.toml
Normal file
22
templates/website-htmx-ssr/crates/build-config/Cargo.toml
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
[package]
|
||||
name = "build-config"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
authors.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
toml.workspace = true
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json.workspace = true
|
||||
anyhow.workspace = true
|
||||
thiserror.workspace = true
|
||||
sha2.workspace = true
|
||||
|
||||
# Rustelo framework dependencies
|
||||
rustelo_core_types.workspace = true
|
||||
rustelo_config = { path = "{{RUSTELO_ROOT}}/crates/foundation/crates/rustelo_config" }
|
||||
rustelo_tools = { path = "{{RUSTELO_ROOT}}/crates/foundation/crates/rustelo_tools" }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile.workspace = true
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
384
templates/website-htmx-ssr/crates/build-config/src/lib.rs
Normal file
384
templates/website-htmx-ssr/crates/build-config/src/lib.rs
Normal file
|
|
@ -0,0 +1,384 @@
|
|||
//! Build-time configuration loader for Rustelo website
|
||||
//!
|
||||
//! This crate provides shared configuration loading utilities for all build.rs scripts
|
||||
//! in the workspace, following DRY principles and ensuring consistent configuration
|
||||
//! across server, client, shared, and pages builds.
|
||||
//!
|
||||
//! # Modules
|
||||
//!
|
||||
//! - [`route_config`]: Typed route configuration loading (NCL + TOML)
|
||||
//! - [`content_kind_config`]: Typed content kinds configuration loading (NCL + TOML)
|
||||
|
||||
pub mod content_kind_config;
|
||||
pub mod rbac_config;
|
||||
pub mod route_config;
|
||||
pub mod site_config;
|
||||
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::collections::HashMap;
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Site configuration from config.toml or config.dev.toml
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct SiteConfig {
|
||||
pub root: String,
|
||||
pub content_path: String,
|
||||
pub config_path: String,
|
||||
pub ui_path: String,
|
||||
pub i18n_path: String,
|
||||
pub assets_path: String,
|
||||
}
|
||||
|
||||
/// Complete configuration loaded from TOML file
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct Config {
|
||||
pub root_path: String,
|
||||
pub site: SiteConfig,
|
||||
}
|
||||
|
||||
/// Environment type for determining which config file to use
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Environment {
|
||||
Development,
|
||||
Production,
|
||||
}
|
||||
|
||||
impl Environment {
|
||||
/// Detect environment from Cargo profile or ENVIRONMENT env var
|
||||
pub fn detect() -> Self {
|
||||
// Check ENVIRONMENT env var first (explicit override)
|
||||
if let Ok(env_str) = env::var("ENVIRONMENT") {
|
||||
if env_str.to_lowercase().contains("prod") {
|
||||
return Environment::Production;
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to Cargo profile (debug = development, release = production)
|
||||
let profile = env::var("PROFILE").unwrap_or_else(|_| "debug".to_string());
|
||||
if profile == "release" {
|
||||
Environment::Production
|
||||
} else {
|
||||
Environment::Development
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the config filename for this environment
|
||||
pub fn config_filename(&self) -> &'static str {
|
||||
match self {
|
||||
Environment::Development => "config.dev.toml",
|
||||
Environment::Production => "config.toml",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Load configuration from the appropriate TOML file
|
||||
///
|
||||
/// # Behavior
|
||||
///
|
||||
/// 1. Detects environment from `ENVIRONMENT` env var or Cargo `PROFILE`
|
||||
/// 2. Looks for config file in the project root:
|
||||
/// - Development: `config.dev.toml`
|
||||
/// - Production: `config.toml`
|
||||
/// 3. Returns error if the config file doesn't exist
|
||||
/// 4. Parses the TOML file and returns configuration
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if:
|
||||
/// - Configuration file doesn't exist
|
||||
/// - Configuration file is malformed TOML
|
||||
/// - Required fields are missing from configuration
|
||||
pub fn load_config() -> Result<Config> {
|
||||
let env = Environment::detect();
|
||||
let config_filename = env.config_filename();
|
||||
|
||||
// Start from CARGO_MANIFEST_DIR and search upward for the config file
|
||||
let mut current_dir =
|
||||
PathBuf::from(env::var("CARGO_MANIFEST_DIR").context("CARGO_MANIFEST_DIR not set")?);
|
||||
|
||||
// Search up the directory tree for the config file (max 5 levels up)
|
||||
let mut found_path = None;
|
||||
for _ in 0..5 {
|
||||
let config_path = current_dir.join(config_filename);
|
||||
if config_path.exists() {
|
||||
found_path = Some(config_path);
|
||||
break;
|
||||
}
|
||||
// Go up one directory
|
||||
if !current_dir.pop() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let config_path = found_path.ok_or_else(|| {
|
||||
anyhow!(
|
||||
"Configuration file not found: {}\nSearched from: {}\nEnvironment: {:?}",
|
||||
config_filename,
|
||||
env::var("CARGO_MANIFEST_DIR").unwrap_or_default(),
|
||||
env
|
||||
)
|
||||
})?;
|
||||
|
||||
let config_content = fs::read_to_string(&config_path)
|
||||
.with_context(|| format!("Failed to read config file: {}", config_path.display()))?;
|
||||
|
||||
let config: Config =
|
||||
toml::from_str(&config_content).context("Failed to parse configuration file as TOML")?;
|
||||
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
/// Get the i18n directory path from configuration
|
||||
///
|
||||
/// Resolves the i18n path relative to the project root if it's relative,
|
||||
/// or returns it as-is if it's absolute.
|
||||
pub fn get_i18n_dir(config: &Config) -> PathBuf {
|
||||
let i18n_path = Path::new(&config.site.i18n_path);
|
||||
if i18n_path.is_absolute() {
|
||||
i18n_path.to_path_buf()
|
||||
} else {
|
||||
Path::new(&config.root_path).join(i18n_path)
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the content directory path from configuration
|
||||
pub fn get_content_dir(config: &Config) -> PathBuf {
|
||||
let content_path = Path::new(&config.site.content_path);
|
||||
if content_path.is_absolute() {
|
||||
content_path.to_path_buf()
|
||||
} else {
|
||||
Path::new(&config.root_path).join(content_path)
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the config directory path from configuration
|
||||
pub fn get_config_dir(config: &Config) -> PathBuf {
|
||||
let config_path = Path::new(&config.site.config_path);
|
||||
if config_path.is_absolute() {
|
||||
config_path.to_path_buf()
|
||||
} else {
|
||||
Path::new(&config.root_path).join(config_path)
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the UI directory path from configuration
|
||||
pub fn get_ui_dir(config: &Config) -> PathBuf {
|
||||
let ui_path = Path::new(&config.site.ui_path);
|
||||
if ui_path.is_absolute() {
|
||||
ui_path.to_path_buf()
|
||||
} else {
|
||||
Path::new(&config.root_path).join(ui_path)
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the assets directory path from configuration
|
||||
pub fn get_assets_dir(config: &Config) -> PathBuf {
|
||||
let assets_path = Path::new(&config.site.assets_path);
|
||||
if assets_path.is_absolute() {
|
||||
assets_path.to_path_buf()
|
||||
} else {
|
||||
Path::new(&config.root_path).join(assets_path)
|
||||
}
|
||||
}
|
||||
|
||||
/// Load all FTL files from the i18n directory
|
||||
///
|
||||
/// Loads FTL files from both direct location (`site/i18n/*.ftl`)
|
||||
/// and nested structure (`site/i18n/locales/{language}/*.ftl`)
|
||||
///
|
||||
/// File naming convention: `{filename}` or `{language}_{filename}` for language-specific files
|
||||
pub fn load_ftl_files(config: &Config) -> Result<HashMap<String, String>> {
|
||||
let mut ftl_files = HashMap::new();
|
||||
let i18n_dir = get_i18n_dir(config);
|
||||
|
||||
if !i18n_dir.exists() {
|
||||
return Ok(ftl_files); // No FTL files is not an error
|
||||
}
|
||||
|
||||
// Load from direct FTL files in i18n directory (no language prefix)
|
||||
load_ftl_files_from_dir(&i18n_dir, &mut ftl_files, None)?;
|
||||
|
||||
// Load from locales subdirectories with language prefix
|
||||
let locales_dir = i18n_dir.join("locales");
|
||||
if locales_dir.exists() {
|
||||
for entry in fs::read_dir(&locales_dir)? {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
// Use directory name as language code
|
||||
if let Some(lang) = path.file_name().and_then(|n| n.to_str()) {
|
||||
load_ftl_files_from_dir(&path, &mut ftl_files, Some(lang.to_string()))?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(ftl_files)
|
||||
}
|
||||
|
||||
/// Helper function to load FTL files from a specific directory (recursively)
|
||||
///
|
||||
/// If language is provided, prepends it to filenames as `{language}_{filename}`
|
||||
/// This prevents collisions when the same filename exists in multiple language directories
|
||||
fn load_ftl_files_from_dir(
|
||||
dir: &Path,
|
||||
ftl_files: &mut HashMap<String, String>,
|
||||
language: Option<String>,
|
||||
) -> Result<()> {
|
||||
for entry in fs::read_dir(dir)? {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
|
||||
if path.is_dir() {
|
||||
// Recursively load from subdirectories with same language prefix
|
||||
load_ftl_files_from_dir(&path, ftl_files, language.clone())?;
|
||||
} else if path.extension().is_some_and(|ext| ext == "ftl") {
|
||||
// Load FTL files
|
||||
if let Some(filename) = path.file_stem().and_then(|s| s.to_str()) {
|
||||
let content = fs::read_to_string(&path)?;
|
||||
// Construct key with language prefix if provided
|
||||
let key = if let Some(lang) = &language {
|
||||
format!("{}_{}", lang, filename)
|
||||
} else {
|
||||
filename.to_string()
|
||||
};
|
||||
ftl_files.insert(key, content);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Load all TOML files from a specified directory
|
||||
///
|
||||
/// Useful for loading menus, themes, and other configuration files
|
||||
pub fn load_toml_files_from_dir(dir: &Path) -> Result<HashMap<String, String>> {
|
||||
let mut files = HashMap::new();
|
||||
|
||||
if !dir.exists() {
|
||||
return Ok(files); // Missing directory is not an error
|
||||
}
|
||||
|
||||
for entry in fs::read_dir(dir)? {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
if path.extension().is_some_and(|ext| ext == "toml") {
|
||||
if let Some(filename) = path.file_stem().and_then(|s| s.to_str()) {
|
||||
let content = fs::read_to_string(&path)?;
|
||||
files.insert(filename.to_string(), content);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(files)
|
||||
}
|
||||
|
||||
/// Build cache management for smart incremental builds
|
||||
#[derive(Debug)]
|
||||
pub struct BuildCache {
|
||||
cache_dir: PathBuf,
|
||||
}
|
||||
|
||||
impl BuildCache {
|
||||
/// Create a new build cache
|
||||
///
|
||||
/// Creates the cache directory if it doesn't exist
|
||||
pub fn new(cache_dir: PathBuf) -> Result<Self> {
|
||||
fs::create_dir_all(&cache_dir)?;
|
||||
Ok(Self { cache_dir })
|
||||
}
|
||||
|
||||
/// Compute SHA256 hash of files in a directory
|
||||
///
|
||||
/// Useful for detecting when files have changed and need rebuilding
|
||||
pub fn compute_hash(&self, dir: &Path) -> Result<String> {
|
||||
let mut hasher = Sha256::new();
|
||||
|
||||
if !dir.exists() {
|
||||
let hash = hasher.finalize();
|
||||
return Ok(hash.iter().fold(String::new(), |mut s, b| {
|
||||
use std::fmt::Write;
|
||||
let _ = write!(s, "{b:02x}");
|
||||
s
|
||||
})[..16]
|
||||
.to_string());
|
||||
}
|
||||
|
||||
let mut files: Vec<_> = fs::read_dir(dir)?
|
||||
.filter_map(|entry| entry.ok())
|
||||
.filter(|entry| {
|
||||
entry
|
||||
.path()
|
||||
.extension()
|
||||
.is_some_and(|ext| ext == "toml" || ext == "ftl")
|
||||
})
|
||||
.collect();
|
||||
|
||||
files.sort_by_key(|entry| entry.path());
|
||||
|
||||
for entry in files {
|
||||
let path = entry.path();
|
||||
let content = fs::read_to_string(&path)?;
|
||||
hasher.update(path.to_string_lossy().as_bytes());
|
||||
hasher.update(content.as_bytes());
|
||||
}
|
||||
|
||||
let hash = hasher.finalize();
|
||||
Ok(hash.iter().fold(String::new(), |mut s, b| {
|
||||
use std::fmt::Write;
|
||||
let _ = write!(s, "{b:02x}");
|
||||
s
|
||||
})[..16]
|
||||
.to_string())
|
||||
}
|
||||
|
||||
/// Check if a cache entry exists
|
||||
pub fn is_cached(&self, cache_key: &str, cache_type: &str) -> bool {
|
||||
let cache_file = self
|
||||
.cache_dir
|
||||
.join(format!("{}_{}.cache", cache_type, cache_key));
|
||||
cache_file.exists()
|
||||
}
|
||||
|
||||
/// Save a cache entry
|
||||
pub fn save_cache(&self, cache_key: &str, cache_type: &str, content: &str) -> Result<()> {
|
||||
let cache_file = self
|
||||
.cache_dir
|
||||
.join(format!("{}_{}.cache", cache_type, cache_key));
|
||||
fs::write(cache_file, content)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Load a cache entry
|
||||
pub fn load_cache(&self, cache_key: &str, cache_type: &str) -> Result<String> {
|
||||
let cache_file = self
|
||||
.cache_dir
|
||||
.join(format!("{}_{}.cache", cache_type, cache_key));
|
||||
Ok(fs::read_to_string(cache_file)?)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_environment_detection() {
|
||||
// Development by default (in debug builds)
|
||||
let env = Environment::detect();
|
||||
assert_eq!(env.config_filename(), "config.dev.toml");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_environment_config_filenames() {
|
||||
assert_eq!(
|
||||
Environment::Development.config_filename(),
|
||||
"config.dev.toml"
|
||||
);
|
||||
assert_eq!(Environment::Production.config_filename(), "config.toml");
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
55
templates/website-htmx-ssr/crates/client/Cargo.toml
Normal file
55
templates/website-htmx-ssr/crates/client/Cargo.toml
Normal 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"]
|
||||
1579
templates/website-htmx-ssr/crates/client/build.rs
Normal file
1579
templates/website-htmx-ssr/crates/client/build.rs
Normal file
File diff suppressed because it is too large
Load diff
422
templates/website-htmx-ssr/crates/client/src/app.rs
Normal file
422
templates/website-htmx-ssr/crates/client/src/app.rs
Normal file
|
|
@ -0,0 +1,422 @@
|
|||
//! Client-side App component with proper routing
|
||||
//!
|
||||
//! This module provides the main App component for the client that uses
|
||||
//! the shared routing system and renders actual page components.
|
||||
|
||||
use leptos::prelude::*;
|
||||
use leptos_meta::provide_meta_context;
|
||||
use rustelo_components_leptos::theme::ThemeProvider;
|
||||
use rustelo_web::rustelo_core_lib::{
|
||||
i18n::{provide_unified_i18n, UnifiedI18n},
|
||||
routing::utils::detect_language_from_path,
|
||||
state::{AuthState, LanguageProvider},
|
||||
PageProvider,
|
||||
};
|
||||
use rustelo_pages_leptos::NavLogProvider;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
use std::time::Duration;
|
||||
|
||||
// Import the local page provider implementation
|
||||
use crate::page_provider::WebsitePageProvider;
|
||||
|
||||
/// Main App component for client-side that implements proper routing
|
||||
#[component]
|
||||
pub fn AppComponent(#[prop(default = String::new())] initial_path: String) -> impl IntoView {
|
||||
// Provide meta context for client-side behavior
|
||||
provide_meta_context();
|
||||
|
||||
// Setup navigation signals for client-side routing
|
||||
let (path, set_path) = signal(initial_path.clone());
|
||||
|
||||
// Auth state — provided as context so child components can read/update it.
|
||||
let auth_state = AuthState::new();
|
||||
provide_context(auth_state.clone());
|
||||
|
||||
// Login modal visibility — provided so NavMenu auth controls can open it
|
||||
let (show_login, set_show_login) = signal(false);
|
||||
provide_context(set_show_login);
|
||||
|
||||
// Provide path signals as context — set_path for navigation, path for reading.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
provide_context(set_path);
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
provide_context(path);
|
||||
|
||||
// Provide the RBAC deny-paths signal so all descendants (nav menu, SPA guard)
|
||||
// can reactively filter protected routes. Seeded from compile-time AUTH_PATTERNS
|
||||
// so SSR and the first WASM render produce identical output (hydration safe).
|
||||
let deny_paths = crate::policy::init_policy_signal();
|
||||
provide_context(deny_paths);
|
||||
|
||||
// Toast signal — created explicitly so it can be passed to the WS loop and
|
||||
// provided as context for ToastContainer and any component using push_toast.
|
||||
let toast_sig = RwSignal::new(Vec::<rustelo_components_leptos::ToastNotification>::new());
|
||||
provide_context(toast_sig);
|
||||
|
||||
// Command palette open signal — shared between CommandPalette (renders the
|
||||
// overlay) and FloatingPill (search button toggles it).
|
||||
let palette_sig = RwSignal::new(false);
|
||||
provide_context(rustelo_components_leptos::CommandPaletteOpen(palette_sig));
|
||||
|
||||
// Inject build-time application pages so the palette can navigate to project
|
||||
// pages (Provisioning Systems, Vapora, Kogral, etc.) that have no menu entry.
|
||||
provide_context(rustelo_components_leptos::CommandPaletteExtraItems(
|
||||
crate::get_palette_app_pages(),
|
||||
));
|
||||
|
||||
// Register signal in the WASM thread-local so push_toast_from_js (the
|
||||
// browser-console export `rustelo_push_toast(kind, msg)`) can reach it
|
||||
// without requiring a reactive context.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
rustelo_components_leptos::toast::register_toast_signal(toast_sig);
|
||||
|
||||
// Restore auth state from existing session cookie on WASM startup.
|
||||
// Runs once per tab — if a valid session_token cookie exists the server
|
||||
// validates it and returns UserInfo so the tab starts already authenticated.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
let auth_for_restore = auth_state.clone();
|
||||
leptos::task::spawn_local(async move {
|
||||
if let Ok(Some(user)) = rustelo_components_leptos::server_get_session_user().await {
|
||||
auth_for_restore.set_user(user);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Open WebSocket: handles RBAC hot-reload and JWT lifecycle events.
|
||||
// auth_state is resolved inside connect_policy_ws via use_context before
|
||||
// the first .await so the reactive owner is still valid.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
crate::policy::connect_policy_ws(deny_paths, toast_sig, set_path);
|
||||
|
||||
// Initialize meta config preloader early for WASM
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
rustelo_core_lib::init_meta_preloader();
|
||||
|
||||
// Provide logo configuration from compile-time constants (site/config.ncl)
|
||||
// NOTE: provide_context here matches shell.rs SSR provision — same type, same value
|
||||
provide_context(crate::theme::load_logo_config());
|
||||
|
||||
// NOTE: Menu context is already provided by shell.rs during SSR
|
||||
// Do NOT create a new signal here as it would override the server-provided context
|
||||
// during hydration, causing menu items to be lost
|
||||
|
||||
// Use the LanguageProvider and setup effects inside it
|
||||
view! {
|
||||
<LanguageProvider>
|
||||
<NavLogProvider>
|
||||
<AppContent
|
||||
initial_path=initial_path
|
||||
path=path
|
||||
set_path=set_path
|
||||
show_login=show_login
|
||||
set_show_login=set_show_login
|
||||
/>
|
||||
</NavLogProvider>
|
||||
</LanguageProvider>
|
||||
}
|
||||
}
|
||||
|
||||
/// Inner app content that has access to the language context
|
||||
#[component]
|
||||
fn AppContent(
|
||||
initial_path: String,
|
||||
path: ReadSignal<String>,
|
||||
set_path: WriteSignal<String>,
|
||||
show_login: ReadSignal<bool>,
|
||||
set_show_login: WriteSignal<bool>,
|
||||
) -> impl IntoView {
|
||||
// Now we can use the shared language context
|
||||
let language_context = rustelo_core_lib::state::use_language();
|
||||
|
||||
// Set initial language from initial path once, before creating effects.
|
||||
// Prefer the generated route table (config-driven) over path-prefix heuristics.
|
||||
let initial_language = crate::routes::get_language_for_path(&initial_path)
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_else(|| detect_language_from_path(&initial_path));
|
||||
language_context.current.set(initial_language.clone());
|
||||
|
||||
// Reactively detect language from current path and update context.
|
||||
// Only update when the route table returns an UNAMBIGUOUS language for the path.
|
||||
// Paths shared across languages (e.g. "/blog" for both EN and ES) return None —
|
||||
// in that case the current language is preserved, preventing spurious language resets.
|
||||
Effect::new(move |_| {
|
||||
let current_path = path.get();
|
||||
|
||||
if let Some(lang) = crate::routes::get_language_for_path(¤t_path) {
|
||||
let detected = lang.to_string();
|
||||
if language_context.current.get_untracked() != detected {
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
web_sys::console::log_1(
|
||||
&format!(
|
||||
"🔄 App Path Change: path='{}' → language='{}'",
|
||||
current_path, detected
|
||||
)
|
||||
.into(),
|
||||
);
|
||||
language_context.current.set(detected);
|
||||
}
|
||||
}
|
||||
// If get_language_for_path returns None → language-neutral path → preserve current language
|
||||
});
|
||||
|
||||
// Create and provide unified i18n context that updates with language changes
|
||||
let unified_i18n = Memo::new(move |_| {
|
||||
let current_lang = language_context.current.get();
|
||||
let current_path = path.get();
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
web_sys::console::log_1(
|
||||
&format!(
|
||||
"🔄 App i18n Memo: language='{}', path='{}'",
|
||||
current_lang, current_path
|
||||
)
|
||||
.into(),
|
||||
);
|
||||
|
||||
UnifiedI18n::new(¤t_lang, ¤t_path)
|
||||
});
|
||||
|
||||
// Provide the reactive i18n context
|
||||
Effect::new(move |_| {
|
||||
let i18n = unified_i18n.get();
|
||||
provide_unified_i18n(i18n);
|
||||
});
|
||||
|
||||
// Setup page transition effects - signal created outside CFG block to avoid hydration mismatch
|
||||
let (is_first_render, set_is_first_render) = signal(true);
|
||||
|
||||
Effect::new(move |_| {
|
||||
let current_path = path.get();
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
web_sys::console::log_1(
|
||||
&format!(
|
||||
"🎬 App transition Effect: path='{}', first_render={}",
|
||||
current_path,
|
||||
is_first_render.get()
|
||||
)
|
||||
.into(),
|
||||
);
|
||||
|
||||
// Only apply transitions and dispatch events on client-side
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
if is_first_render.get() {
|
||||
set_is_first_render.set(false);
|
||||
} else {
|
||||
apply_page_transition();
|
||||
}
|
||||
|
||||
// Dispatch custom event to reinitialize components
|
||||
set_timeout(
|
||||
move || {
|
||||
web_sys::console::log_1(
|
||||
&format!(
|
||||
"⏰ Dispatching reinitializeComponents event after 200ms for path: {}",
|
||||
current_path
|
||||
)
|
||||
.into(),
|
||||
);
|
||||
|
||||
if let Some(window) = web_sys::window() {
|
||||
if let Ok(event) = web_sys::CustomEvent::new("reinitializeComponents") {
|
||||
let _ = window.dispatch_event(&event);
|
||||
}
|
||||
}
|
||||
},
|
||||
Duration::from_millis(200),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// Extract the language signal (RwSignal is Copy)
|
||||
let lang_signal = language_context.current;
|
||||
|
||||
// Capture auth state for the client-side route guard below.
|
||||
// Provided by AppComponent via provide_context(auth_state).
|
||||
let auth_state = use_context::<AuthState>();
|
||||
|
||||
// Register the 401 unauthorized handler once at startup (WASM only).
|
||||
// The framework fetch layer calls on_unauthorized() on any HTTP 401 response.
|
||||
// This handler owns the reactive context — the framework layer knows nothing about AuthState.
|
||||
// When rbac.ncl is hot-reloaded on the server, any subsequent request that returns 401
|
||||
// will immediately clear auth state and redirect, making the server the single authority.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
let auth_for_401 = auth_state.clone();
|
||||
// WriteSignal<String> is Copy — captured directly without Arc.
|
||||
rustelo_core_lib::utils::fetch::register_unauthorized_handler(move || {
|
||||
// Clear local auth state — server has rejected the session.
|
||||
if let Some(ref auth) = auth_for_401 {
|
||||
auth.clear_auth();
|
||||
}
|
||||
// Redirect to login, preserving the current path for post-login return.
|
||||
let current_path = rustelo_core_lib::utils::fetch::current_location_path();
|
||||
rustelo_core_lib::utils::nav::anchor_navigate(
|
||||
set_path,
|
||||
&format!("/login?return={}", current_path),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// Extract the user signal (RwSignal<T> is Copy) so the DynChild closure below
|
||||
// captures a Copy type. Option<AuthState> is Clone-only; capturing it by move
|
||||
// inside the {move || …} DynChild makes the outer <div> wrapper FnOnce, which
|
||||
// Leptos rejects (it requires Fn).
|
||||
let auth_user_signal = auth_state.as_ref().map(|a| a.user);
|
||||
|
||||
// Deny-paths signal provided by AppComponent — updated via WebSocket on rbac.ncl reload.
|
||||
// RwSignal<Vec<String>> is Copy so it can be captured by the move closure below.
|
||||
let deny_paths_signal = use_context::<RwSignal<Vec<String>>>();
|
||||
|
||||
// Derive privacy policy page path from config-driven route lookup.
|
||||
// get_route("en", "privacy") → "/privacy", ("es", "privacy") → "/privacidad"
|
||||
// &'static str is Copy — no move into the view! closure → closure stays Fn.
|
||||
let cookie_policy_path: &'static str = crate::routes::get_route(
|
||||
lang_signal.get_untracked().as_str(),
|
||||
"privacy",
|
||||
)
|
||||
.unwrap_or("/privacy");
|
||||
|
||||
// Use the client routing with actual page components.
|
||||
// NavMenu and Footer are rendered statically (no reactive wrapper) so that
|
||||
// Leptos emits no DynChild comment-marker nodes, matching the server's shell.rs
|
||||
// which also renders them directly. Language reactivity for labels is handled
|
||||
// internally by UnifiedNavMenu/Footer via use_current_language().
|
||||
view! {
|
||||
<ThemeProvider>
|
||||
<div class="min-h-screen ds-bg-page flex flex-col">
|
||||
<rustelo_components_leptos::navigation::NavMenu
|
||||
language=lang_signal.get_untracked()
|
||||
_set_path=set_path
|
||||
_path=path
|
||||
set_show_login=Some(set_show_login)
|
||||
/>
|
||||
<main class="max-w-7xl mx-auto py-2 sm:ds-container flex-grow page-content fade-out">
|
||||
{
|
||||
move || {
|
||||
let current_path = path.get();
|
||||
let current_lang = lang_signal.get();
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
web_sys::console::log_1(&format!("🎬 App main content render: path='{}', language='{}' (content re-rendering)", current_path, current_lang).into());
|
||||
|
||||
// Client-side auth guard — uses the runtime deny-paths signal
|
||||
// so it reacts to rbac.ncl hot-reloads without a WASM rebuild.
|
||||
// SSR is protected by the RBAC middleware; this guard covers
|
||||
// SPA navigation where the server is never consulted.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
let deny_paths = deny_paths_signal
|
||||
.map(|s| s.get())
|
||||
.unwrap_or_default();
|
||||
if crate::policy::requires_auth_runtime(¤t_path, &deny_paths) {
|
||||
let is_authed = auth_user_signal
|
||||
.map(|sig| sig.get_untracked().is_some())
|
||||
.unwrap_or(false);
|
||||
if !is_authed {
|
||||
set_path.set(format!("/login?return={}", current_path));
|
||||
return view! { <div></div> }.into_any();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
render_website_page(¤t_path, ¤t_lang)
|
||||
}
|
||||
}
|
||||
</main>
|
||||
<rustelo_components_leptos::navigation::Footer
|
||||
language=lang_signal.get_untracked()
|
||||
_set_path=set_path
|
||||
_path=path
|
||||
/>
|
||||
// Auth login modal — WASM only, renders nothing in SSR
|
||||
<rustelo_components_leptos::LoginModal show=show_login set_show=set_show_login />
|
||||
// Cookie consent banner — WASM only, renders nothing in SSR
|
||||
<rustelo_components_leptos::CookieBanner policy_page=cookie_policy_path.to_string() detail_mode="page".to_string() />
|
||||
<rustelo_components_leptos::ToastContainer />
|
||||
// Keyboard command palette — Cmd+K / Ctrl+K, WASM only
|
||||
<rustelo_components_leptos::CommandPalette />
|
||||
// Scroll-aware floating pill — appears after 80px scroll, WASM only
|
||||
<rustelo_components_leptos::FloatingPill />
|
||||
</div>
|
||||
</ThemeProvider>
|
||||
}
|
||||
}
|
||||
|
||||
/// Render website page using the local PageProvider
|
||||
fn render_website_page(_path: &str, language: &str) -> AnyView {
|
||||
let provider = WebsitePageProvider;
|
||||
let lang = language.to_string();
|
||||
|
||||
// Resolve component name from generated route table (config-driven, PAP-compliant).
|
||||
// Strip "Page" suffix to match the page_provider_impl.rs match keys (e.g. "Home" not "HomePage"),
|
||||
// mirroring the server's server_routing.rs which also strips the suffix.
|
||||
let component_name = crate::routes::get_component_for_path(_path)
|
||||
.unwrap_or("NotFound")
|
||||
.trim_end_matches("Page")
|
||||
.to_string();
|
||||
|
||||
// Populate props from the route config — content_type drives ContentIndex/PostViewer/etc.
|
||||
let mut props = std::collections::HashMap::new();
|
||||
if let Some(ct) = crate::routes::get_content_type_for_path(_path) {
|
||||
props.insert("content_type".to_string(), ct.to_string());
|
||||
}
|
||||
// Extract parametric path segments for PostViewer (slug = last, category = second-to-last).
|
||||
let segs: Vec<&str> = _path.split('/').filter(|s| !s.is_empty()).collect();
|
||||
if segs.len() >= 2 {
|
||||
props.insert(
|
||||
"slug".to_string(),
|
||||
segs.last().copied().unwrap_or("").to_string(),
|
||||
);
|
||||
props.insert(
|
||||
"category".to_string(),
|
||||
segs.get(segs.len().saturating_sub(2))
|
||||
.copied()
|
||||
.unwrap_or("")
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
match provider.render_component(&component_name, props, &lang) {
|
||||
Ok(view) => view,
|
||||
Err(_) => {
|
||||
// Fallback to NotFound if component rendering fails
|
||||
let not_found = "NotFound".to_string();
|
||||
match provider.render_component(¬_found, std::collections::HashMap::new(), &lang) {
|
||||
Ok(view) => view,
|
||||
Err(_) => view! { <div>"Error rendering page"</div> }.into_any(),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply fade transition to page content
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn apply_page_transition() {
|
||||
if let Some(document) = web_sys::window().and_then(|w| w.document()) {
|
||||
if let Ok(main_element) = document.query_selector("main.page-content") {
|
||||
if let Some(element) = main_element {
|
||||
let _ = element.class_list().add_1("fade-out");
|
||||
|
||||
let element_clone = element.clone();
|
||||
set_timeout(
|
||||
move || {
|
||||
let _ = element_clone.class_list().remove_1("fade-out");
|
||||
let _ = element_clone.class_list().add_1("fade-in");
|
||||
},
|
||||
std::time::Duration::from_millis(150),
|
||||
);
|
||||
|
||||
let element_clone2 = element.clone();
|
||||
set_timeout(
|
||||
move || {
|
||||
let _ = element_clone2.class_list().remove_1("fade-in");
|
||||
},
|
||||
std::time::Duration::from_millis(300),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue