chore: update

This commit is contained in:
Jesús Pérez 2026-07-18 20:16:16 +01:00
parent 642998ba35
commit f1010e9d07
Signed by: jesus
GPG key ID: 9F243E355E0BC939
110 changed files with 7919 additions and 0 deletions

94
resources/README.md Normal file
View file

@ -0,0 +1,94 @@
# Rustelo Resource Library
Base resources for all Rustelo implementations. These are the framework-level
schemas, contracts, translations, and templates that any site built on Rustelo
can import and override.
## Structure
```
resources/
├── nickel/ NCL schemas and contracts (validation layer)
├── i18n/ Base FTL translations (framework-level only)
└── templates/ Base templates for email and build tooling
```
## nickel/ — Nickel Schemas
Type contracts and default values for every configuration area. Implementations
import these to validate their `site/config/` files at compile time.
| Directory | Validates |
|---------------------|------------------------------------------------|
| `site/` | `site/config/index.ncl` — full site config |
| `rbac/` | `site/rbac.ncl` — access policy |
| `routes/` | Route definitions and menu entries |
| `themes/` | Theme configuration |
| `content/` | Content types and post metadata |
| `security/` | Security headers and CSP |
| `server/` | HTTP server configuration |
| `features/` | Feature flags |
| `menus/` | Navigation menu entries |
| `footer/` | Footer configuration |
| `email/` | Email provider configuration |
| `pipelines/` | CI/CD and NATS pipeline configuration |
| `external-services/`| Third-party service integration |
| `deps/` | Dependency version constraints |
`nickel/rbac/rbac-starter.ncl` is a minimal starter policy to copy into a new
implementation's `site/rbac.ncl`.
## i18n/ — Base Translations
Framework-level FTL strings covering authentication, common UI, navigation,
cookie consent, generic page messages, and admin tooling.
Implementations override individual keys by providing the same key in their
own FTL files — the registration system applies implementation strings with
higher priority than framework defaults.
### Naming convention
All keys are prefixed by their domain to avoid collisions across plugins:
```
auth-sign-in auth-*
nav-home nav-*
footer-copyright footer-*
cookie-accept cookie-*
content-read-more content-*
form-submit form-*
```
### What is NOT here
Site-specific page strings (home page, about, contact, services, etc.) live
in the implementation's `site/i18n/locales/` and are never part of the
framework resource library.
## templates/ — Base Templates
| Directory | Contents |
|-----------------|--------------------------------------------------|
| `email/` | OTP code emails, form submission notifications |
| `build-tools/` | Tera templates for build-time documentation |
## Usage in implementations
```nickel
# site/config/index.ncl
let C = import "path/to/rustelo/resources/nickel/site/contracts.ncl" in
{
site = { name = "My Site", ... },
...
} | C.SiteConfig
```
```nickel
# site/rbac.ncl — start from the starter template
# cp rustelo/resources/nickel/rbac/rbac-starter.ncl site/rbac.ncl
```
FTL strings are loaded automatically at startup via `ResourceContributor`
no manual import required. Implementation strings override framework defaults.

View file

@ -0,0 +1,25 @@
-- Migration: 008 — Activity completions (charlas, talleres, presentaciones)
-- Database: PostgreSQL
CREATE TABLE IF NOT EXISTS activity_completions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
activity_id TEXT NOT NULL,
completed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
questionnaire_type TEXT NOT NULL DEFAULT 'external',
notes TEXT,
UNIQUE(user_id, activity_id)
);
CREATE INDEX IF NOT EXISTS idx_activity_completions_user ON activity_completions(user_id);
CREATE INDEX IF NOT EXISTS idx_activity_completions_activity ON activity_completions(activity_id);
CREATE TABLE IF NOT EXISTS activity_responses (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
completion_id UUID NOT NULL REFERENCES activity_completions(id) ON DELETE CASCADE,
question_key TEXT NOT NULL,
answer TEXT NOT NULL,
UNIQUE(completion_id, question_key)
);
CREATE INDEX IF NOT EXISTS idx_activity_responses_completion ON activity_responses(completion_id);

View file

@ -0,0 +1,25 @@
-- Migration: 008 — Activity completions (charlas, talleres, presentaciones)
-- Database: SQLite
CREATE TABLE IF NOT EXISTS activity_completions (
id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
activity_id TEXT NOT NULL,
completed_at TEXT NOT NULL DEFAULT (datetime('now')),
questionnaire_type TEXT NOT NULL DEFAULT 'external',
notes TEXT,
UNIQUE(user_id, activity_id)
);
CREATE INDEX IF NOT EXISTS idx_activity_completions_user ON activity_completions(user_id);
CREATE INDEX IF NOT EXISTS idx_activity_completions_activity ON activity_completions(activity_id);
CREATE TABLE IF NOT EXISTS activity_responses (
id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
completion_id TEXT NOT NULL REFERENCES activity_completions(id) ON DELETE CASCADE,
question_key TEXT NOT NULL,
answer TEXT NOT NULL,
UNIQUE(completion_id, question_key)
);
CREATE INDEX IF NOT EXISTS idx_activity_responses_completion ON activity_responses(completion_id);

View file

@ -0,0 +1,100 @@
-- Initial database setup for PostgreSQL
-- Migration: 001_initial_setup
-- Database: PostgreSQL
-- Enable UUID extension
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
-- Users table for authentication
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
username VARCHAR(255) NOT NULL UNIQUE,
email VARCHAR(255) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL,
is_active BOOLEAN NOT NULL DEFAULT true,
is_verified BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- User sessions table
CREATE TABLE IF NOT EXISTS user_sessions (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
session_token VARCHAR(255) NOT NULL UNIQUE,
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Content table for CMS functionality
CREATE TABLE IF NOT EXISTS content (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
title VARCHAR(255) NOT NULL,
slug VARCHAR(255) NOT NULL UNIQUE,
content_type VARCHAR(50) NOT NULL DEFAULT 'markdown',
body TEXT,
metadata JSONB,
is_published BOOLEAN NOT NULL DEFAULT false,
published_at TIMESTAMPTZ,
created_by UUID REFERENCES users(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- User roles table
CREATE TABLE IF NOT EXISTS user_roles (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name VARCHAR(100) NOT NULL UNIQUE,
description TEXT,
permissions JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- User role assignments
CREATE TABLE IF NOT EXISTS user_role_assignments (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role_id UUID NOT NULL REFERENCES user_roles(id) ON DELETE CASCADE,
assigned_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
assigned_by UUID REFERENCES users(id),
UNIQUE(user_id, role_id)
);
-- Create indexes for better performance
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
CREATE INDEX IF NOT EXISTS idx_users_active ON users(is_active);
CREATE INDEX IF NOT EXISTS idx_user_sessions_token ON user_sessions(session_token);
CREATE INDEX IF NOT EXISTS idx_user_sessions_user_id ON user_sessions(user_id);
CREATE INDEX IF NOT EXISTS idx_user_sessions_expires ON user_sessions(expires_at);
CREATE INDEX IF NOT EXISTS idx_content_slug ON content(slug);
CREATE INDEX IF NOT EXISTS idx_content_published ON content(is_published);
CREATE INDEX IF NOT EXISTS idx_content_created_by ON content(created_by);
CREATE INDEX IF NOT EXISTS idx_content_type ON content(content_type);
CREATE INDEX IF NOT EXISTS idx_user_role_assignments_user ON user_role_assignments(user_id);
CREATE INDEX IF NOT EXISTS idx_user_role_assignments_role ON user_role_assignments(role_id);
-- Update timestamp trigger function
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ language 'plpgsql';
-- Apply triggers to tables with updated_at columns
CREATE TRIGGER update_users_updated_at
BEFORE UPDATE ON users
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_content_updated_at
BEFORE UPDATE ON content
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
-- Insert default roles
INSERT INTO user_roles (name, description, permissions) VALUES
('admin', 'Administrator with full access', '{"all": true}'),
('editor', 'Content editor', '{"content": {"read": true, "write": true, "delete": true}}'),
('user', 'Regular user', '{"content": {"read": true}}')
ON CONFLICT (name) DO NOTHING;

View file

@ -0,0 +1,96 @@
-- Initial database setup for SQLite
-- Migration: 001_initial_setup
-- Database: SQLite
-- Users table for authentication
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
username TEXT NOT NULL UNIQUE,
email TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
is_active INTEGER NOT NULL DEFAULT 1,
is_verified INTEGER NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- User sessions table
CREATE TABLE IF NOT EXISTS user_sessions (
id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
session_token TEXT NOT NULL UNIQUE,
expires_at DATETIME NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- Content table for CMS functionality
CREATE TABLE IF NOT EXISTS content (
id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
title TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE,
content_type TEXT NOT NULL DEFAULT 'markdown',
body TEXT,
metadata TEXT, -- JSON as TEXT in SQLite
is_published INTEGER NOT NULL DEFAULT 0,
published_at DATETIME,
created_by TEXT REFERENCES users(id),
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- User roles table
CREATE TABLE IF NOT EXISTS user_roles (
id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
name TEXT NOT NULL UNIQUE,
description TEXT,
permissions TEXT, -- JSON as TEXT in SQLite
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- User role assignments
CREATE TABLE IF NOT EXISTS user_role_assignments (
id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role_id TEXT NOT NULL REFERENCES user_roles(id) ON DELETE CASCADE,
assigned_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
assigned_by TEXT REFERENCES users(id),
UNIQUE(user_id, role_id)
);
-- Create indexes for better performance
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
CREATE INDEX IF NOT EXISTS idx_users_active ON users(is_active);
CREATE INDEX IF NOT EXISTS idx_user_sessions_token ON user_sessions(session_token);
CREATE INDEX IF NOT EXISTS idx_user_sessions_user_id ON user_sessions(user_id);
CREATE INDEX IF NOT EXISTS idx_user_sessions_expires ON user_sessions(expires_at);
CREATE INDEX IF NOT EXISTS idx_content_slug ON content(slug);
CREATE INDEX IF NOT EXISTS idx_content_published ON content(is_published);
CREATE INDEX IF NOT EXISTS idx_content_created_by ON content(created_by);
CREATE INDEX IF NOT EXISTS idx_content_type ON content(content_type);
CREATE INDEX IF NOT EXISTS idx_user_role_assignments_user ON user_role_assignments(user_id);
CREATE INDEX IF NOT EXISTS idx_user_role_assignments_role ON user_role_assignments(role_id);
-- Triggers for updated_at timestamps (SQLite doesn't have automatic timestamp updates)
CREATE TRIGGER IF NOT EXISTS update_users_updated_at
AFTER UPDATE ON users
FOR EACH ROW
WHEN NEW.updated_at = OLD.updated_at
BEGIN
UPDATE users SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id;
END;
CREATE TRIGGER IF NOT EXISTS update_content_updated_at
AFTER UPDATE ON content
FOR EACH ROW
WHEN NEW.updated_at = OLD.updated_at
BEGIN
UPDATE content SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id;
END;
-- Insert default roles
INSERT INTO user_roles (name, description, permissions) VALUES
('admin', 'Administrator with full access', '{"all": true}'),
('editor', 'Content editor', '{"content": {"read": true, "write": true, "delete": true}}'),
('user', 'Regular user', '{"content": {"read": true}}')
ON CONFLICT (name) DO NOTHING;

View file

@ -0,0 +1,131 @@
-- Add 2FA support to users table
-- Migration: 002_add_2fa_support
-- Database: PostgreSQL
-- Add 2FA columns to users table
ALTER TABLE users
ADD COLUMN IF NOT EXISTS two_factor_secret VARCHAR(255),
ADD COLUMN IF NOT EXISTS two_factor_enabled BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN IF NOT EXISTS backup_codes TEXT[], -- Array of backup codes
ADD COLUMN IF NOT EXISTS last_login_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS failed_login_attempts INTEGER NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS locked_until TIMESTAMPTZ;
-- Create 2FA recovery codes table
CREATE TABLE IF NOT EXISTS two_factor_recovery_codes (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
code_hash VARCHAR(255) NOT NULL,
used_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Create login attempts table for security tracking
CREATE TABLE IF NOT EXISTS login_attempts (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
ip_address INET,
user_agent TEXT,
success BOOLEAN NOT NULL,
failure_reason VARCHAR(255),
attempted_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Create password reset tokens table
CREATE TABLE IF NOT EXISTS password_reset_tokens (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash VARCHAR(255) NOT NULL UNIQUE,
expires_at TIMESTAMPTZ NOT NULL,
used_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Create email verification tokens table
CREATE TABLE IF NOT EXISTS email_verification_tokens (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash VARCHAR(255) NOT NULL UNIQUE,
email VARCHAR(255) NOT NULL, -- Allow email change verification
expires_at TIMESTAMPTZ NOT NULL,
verified_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Create indexes for 2FA and security tables
CREATE INDEX IF NOT EXISTS idx_users_2fa_enabled ON users(two_factor_enabled);
CREATE INDEX IF NOT EXISTS idx_users_locked_until ON users(locked_until);
CREATE INDEX IF NOT EXISTS idx_users_last_login ON users(last_login_at);
CREATE INDEX IF NOT EXISTS idx_2fa_recovery_codes_user_id ON two_factor_recovery_codes(user_id);
CREATE INDEX IF NOT EXISTS idx_2fa_recovery_codes_hash ON two_factor_recovery_codes(code_hash);
CREATE INDEX IF NOT EXISTS idx_login_attempts_user_id ON login_attempts(user_id);
CREATE INDEX IF NOT EXISTS idx_login_attempts_ip ON login_attempts(ip_address);
CREATE INDEX IF NOT EXISTS idx_login_attempts_time ON login_attempts(attempted_at);
CREATE INDEX IF NOT EXISTS idx_password_reset_tokens_hash ON password_reset_tokens(token_hash);
CREATE INDEX IF NOT EXISTS idx_password_reset_tokens_user ON password_reset_tokens(user_id);
CREATE INDEX IF NOT EXISTS idx_email_verification_tokens_hash ON email_verification_tokens(token_hash);
CREATE INDEX IF NOT EXISTS idx_email_verification_tokens_user ON email_verification_tokens(user_id);
-- Add constraint to ensure backup codes are valid JSON array
ALTER TABLE users ADD CONSTRAINT backup_codes_valid_json
CHECK (backup_codes IS NULL OR jsonb_typeof(backup_codes::jsonb) = 'array');
-- Function to clean up expired tokens
CREATE OR REPLACE FUNCTION cleanup_expired_tokens()
RETURNS INTEGER AS $$
DECLARE
deleted_count INTEGER := 0;
BEGIN
-- Clean up expired password reset tokens
DELETE FROM password_reset_tokens
WHERE expires_at < NOW() AND used_at IS NULL;
GET DIAGNOSTICS deleted_count = ROW_COUNT;
-- Clean up expired email verification tokens
DELETE FROM email_verification_tokens
WHERE expires_at < NOW() AND verified_at IS NULL;
GET DIAGNOSTICS deleted_count = deleted_count + ROW_COUNT;
-- Clean up old login attempts (keep last 30 days)
DELETE FROM login_attempts
WHERE attempted_at < NOW() - INTERVAL '30 days';
GET DIAGNOSTICS deleted_count = deleted_count + ROW_COUNT;
-- Clean up expired user sessions
DELETE FROM user_sessions
WHERE expires_at < NOW();
GET DIAGNOSTICS deleted_count = deleted_count + ROW_COUNT;
RETURN deleted_count;
END;
$$ LANGUAGE plpgsql;
-- Function to unlock user accounts after lock period
CREATE OR REPLACE FUNCTION unlock_expired_accounts()
RETURNS INTEGER AS $$
DECLARE
unlocked_count INTEGER := 0;
BEGIN
UPDATE users
SET locked_until = NULL,
failed_login_attempts = 0
WHERE locked_until IS NOT NULL
AND locked_until < NOW();
GET DIAGNOSTICS unlocked_count = ROW_COUNT;
RETURN unlocked_count;
END;
$$ LANGUAGE plpgsql;
-- Add comments for documentation
COMMENT ON COLUMN users.two_factor_secret IS 'Base32-encoded TOTP secret for 2FA';
COMMENT ON COLUMN users.two_factor_enabled IS 'Whether 2FA is enabled for this user';
COMMENT ON COLUMN users.backup_codes IS 'JSON array of hashed backup codes for 2FA recovery';
COMMENT ON COLUMN users.last_login_at IS 'Timestamp of last successful login';
COMMENT ON COLUMN users.failed_login_attempts IS 'Number of consecutive failed login attempts';
COMMENT ON COLUMN users.locked_until IS 'Account locked until this timestamp due to failed attempts';
COMMENT ON TABLE two_factor_recovery_codes IS 'Individual 2FA recovery codes for account recovery';
COMMENT ON TABLE login_attempts IS 'Log of all login attempts for security monitoring';
COMMENT ON TABLE password_reset_tokens IS 'Tokens for password reset functionality';
COMMENT ON TABLE email_verification_tokens IS 'Tokens for email verification and changes';

View file

@ -0,0 +1,117 @@
-- Add 2FA support to users table
-- Migration: 002_add_2fa_support
-- Database: SQLite
-- Add 2FA columns to users table (SQLite requires one column at a time)
ALTER TABLE users ADD COLUMN two_factor_secret TEXT;
ALTER TABLE users ADD COLUMN two_factor_enabled INTEGER NOT NULL DEFAULT 0;
ALTER TABLE users ADD COLUMN backup_codes TEXT; -- JSON array as TEXT
ALTER TABLE users ADD COLUMN last_login_at DATETIME;
ALTER TABLE users ADD COLUMN failed_login_attempts INTEGER NOT NULL DEFAULT 0;
ALTER TABLE users ADD COLUMN locked_until DATETIME;
-- Create 2FA recovery codes table
CREATE TABLE IF NOT EXISTS two_factor_recovery_codes (
id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
code_hash TEXT NOT NULL,
used_at DATETIME,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- Create login attempts table for security tracking
CREATE TABLE IF NOT EXISTS login_attempts (
id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
user_id TEXT REFERENCES users(id) ON DELETE CASCADE,
ip_address TEXT,
user_agent TEXT,
success INTEGER NOT NULL,
failure_reason TEXT,
attempted_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- Create password reset tokens table
CREATE TABLE IF NOT EXISTS password_reset_tokens (
id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash TEXT NOT NULL UNIQUE,
expires_at DATETIME NOT NULL,
used_at DATETIME,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- Create email verification tokens table
CREATE TABLE IF NOT EXISTS email_verification_tokens (
id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash TEXT NOT NULL UNIQUE,
email TEXT NOT NULL, -- Allow email change verification
expires_at DATETIME NOT NULL,
verified_at DATETIME,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- Create indexes for 2FA and security tables
CREATE INDEX IF NOT EXISTS idx_users_2fa_enabled ON users(two_factor_enabled);
CREATE INDEX IF NOT EXISTS idx_users_locked_until ON users(locked_until);
CREATE INDEX IF NOT EXISTS idx_users_last_login ON users(last_login_at);
CREATE INDEX IF NOT EXISTS idx_2fa_recovery_codes_user_id ON two_factor_recovery_codes(user_id);
CREATE INDEX IF NOT EXISTS idx_2fa_recovery_codes_hash ON two_factor_recovery_codes(code_hash);
CREATE INDEX IF NOT EXISTS idx_login_attempts_user_id ON login_attempts(user_id);
CREATE INDEX IF NOT EXISTS idx_login_attempts_ip ON login_attempts(ip_address);
CREATE INDEX IF NOT EXISTS idx_login_attempts_time ON login_attempts(attempted_at);
CREATE INDEX IF NOT EXISTS idx_password_reset_tokens_hash ON password_reset_tokens(token_hash);
CREATE INDEX IF NOT EXISTS idx_password_reset_tokens_user ON password_reset_tokens(user_id);
CREATE INDEX IF NOT EXISTS idx_email_verification_tokens_hash ON email_verification_tokens(token_hash);
CREATE INDEX IF NOT EXISTS idx_email_verification_tokens_user ON email_verification_tokens(user_id);
-- SQLite doesn't support stored procedures, but we can create triggers for cleanup
-- Trigger to automatically clean up expired password reset tokens on insert
CREATE TRIGGER IF NOT EXISTS cleanup_expired_password_tokens
AFTER INSERT ON password_reset_tokens
BEGIN
DELETE FROM password_reset_tokens
WHERE expires_at < datetime('now') AND used_at IS NULL;
END;
-- Trigger to automatically clean up expired email verification tokens on insert
CREATE TRIGGER IF NOT EXISTS cleanup_expired_email_tokens
AFTER INSERT ON email_verification_tokens
BEGIN
DELETE FROM email_verification_tokens
WHERE expires_at < datetime('now') AND verified_at IS NULL;
END;
-- Trigger to clean up old login attempts (keep last 1000 entries per user)
CREATE TRIGGER IF NOT EXISTS cleanup_old_login_attempts
AFTER INSERT ON login_attempts
BEGIN
DELETE FROM login_attempts
WHERE id IN (
SELECT id FROM login_attempts
WHERE user_id = NEW.user_id
ORDER BY attempted_at DESC
LIMIT -1 OFFSET 1000
);
END;
-- Trigger to clean up expired user sessions on new session creation
CREATE TRIGGER IF NOT EXISTS cleanup_expired_sessions
AFTER INSERT ON user_sessions
BEGIN
DELETE FROM user_sessions
WHERE expires_at < datetime('now');
END;
-- Trigger to automatically unlock accounts when lock period expires
CREATE TRIGGER IF NOT EXISTS auto_unlock_accounts
BEFORE UPDATE ON users
FOR EACH ROW
WHEN NEW.locked_until IS NOT NULL
AND NEW.locked_until < datetime('now')
BEGIN
UPDATE users
SET locked_until = NULL,
failed_login_attempts = 0
WHERE id = NEW.id;
END;

View file

@ -0,0 +1,320 @@
-- RBAC System Migration for PostgreSQL
-- Migration: 003_rbac_system
-- Database: PostgreSQL
-- User categories table
CREATE TABLE IF NOT EXISTS user_categories (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name VARCHAR(100) NOT NULL UNIQUE,
description TEXT,
parent_id UUID REFERENCES user_categories(id) ON DELETE CASCADE,
metadata JSONB,
is_active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- User tags table
CREATE TABLE IF NOT EXISTS user_tags (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name VARCHAR(100) NOT NULL UNIQUE,
description TEXT,
color VARCHAR(7), -- hex color code
metadata JSONB,
is_active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- User category assignments
CREATE TABLE IF NOT EXISTS user_category_assignments (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
category_id UUID NOT NULL REFERENCES user_categories(id) ON DELETE CASCADE,
assigned_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
assigned_by UUID REFERENCES users(id),
expires_at TIMESTAMPTZ,
UNIQUE(user_id, category_id)
);
-- User tag assignments
CREATE TABLE IF NOT EXISTS user_tag_assignments (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
tag_id UUID NOT NULL REFERENCES user_tags(id) ON DELETE CASCADE,
assigned_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
assigned_by UUID REFERENCES users(id),
expires_at TIMESTAMPTZ,
UNIQUE(user_id, tag_id)
);
-- Access rules table
CREATE TABLE IF NOT EXISTS access_rules (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name VARCHAR(255) NOT NULL,
description TEXT,
resource_type VARCHAR(50) NOT NULL, -- 'database', 'file', 'directory', 'content', 'api'
resource_name VARCHAR(500) NOT NULL, -- supports wildcards
action VARCHAR(50) NOT NULL, -- 'read', 'write', 'delete', 'execute'
priority INTEGER NOT NULL DEFAULT 0, -- higher priority rules evaluated first
is_active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Role requirements for access rules
CREATE TABLE IF NOT EXISTS access_rule_roles (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
rule_id UUID NOT NULL REFERENCES access_rules(id) ON DELETE CASCADE,
role_id UUID NOT NULL REFERENCES user_roles(id) ON DELETE CASCADE,
UNIQUE(rule_id, role_id)
);
-- Permission requirements for access rules
CREATE TABLE IF NOT EXISTS access_rule_permissions (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
rule_id UUID NOT NULL REFERENCES access_rules(id) ON DELETE CASCADE,
permission_name VARCHAR(100) NOT NULL,
resource_scope VARCHAR(255), -- optional scope for permission
UNIQUE(rule_id, permission_name, resource_scope)
);
-- Category requirements for access rules
CREATE TABLE IF NOT EXISTS access_rule_categories (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
rule_id UUID NOT NULL REFERENCES access_rules(id) ON DELETE CASCADE,
category_id UUID NOT NULL REFERENCES user_categories(id) ON DELETE CASCADE,
requirement_type VARCHAR(20) NOT NULL DEFAULT 'required', -- 'required', 'denied'
UNIQUE(rule_id, category_id, requirement_type)
);
-- Tag requirements for access rules
CREATE TABLE IF NOT EXISTS access_rule_tags (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
rule_id UUID NOT NULL REFERENCES access_rules(id) ON DELETE CASCADE,
tag_id UUID NOT NULL REFERENCES user_tags(id) ON DELETE CASCADE,
requirement_type VARCHAR(20) NOT NULL DEFAULT 'required', -- 'required', 'denied'
UNIQUE(rule_id, tag_id, requirement_type)
);
-- Permission cache for performance
CREATE TABLE IF NOT EXISTS user_permission_cache (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
resource_type VARCHAR(50) NOT NULL,
resource_name VARCHAR(500) NOT NULL,
action VARCHAR(50) NOT NULL,
access_result VARCHAR(20) NOT NULL, -- 'allow', 'deny', 'require_additional_auth'
cache_key VARCHAR(255) NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(user_id, cache_key)
);
-- RBAC configuration table for storing TOML configs
CREATE TABLE IF NOT EXISTS rbac_configs (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name VARCHAR(100) NOT NULL UNIQUE,
description TEXT,
config_data JSONB NOT NULL,
is_active BOOLEAN NOT NULL DEFAULT true,
version INTEGER NOT NULL DEFAULT 1,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Audit log for access attempts
CREATE TABLE IF NOT EXISTS access_audit_log (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID REFERENCES users(id) ON DELETE SET NULL,
resource_type VARCHAR(50) NOT NULL,
resource_name VARCHAR(500) NOT NULL,
action VARCHAR(50) NOT NULL,
access_result VARCHAR(20) NOT NULL,
rule_id UUID REFERENCES access_rules(id) ON DELETE SET NULL,
ip_address INET,
user_agent TEXT,
session_id VARCHAR(255),
additional_context JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Create indexes for performance
CREATE INDEX IF NOT EXISTS idx_user_categories_name ON user_categories(name);
CREATE INDEX IF NOT EXISTS idx_user_categories_parent ON user_categories(parent_id);
CREATE INDEX IF NOT EXISTS idx_user_categories_active ON user_categories(is_active);
CREATE INDEX IF NOT EXISTS idx_user_tags_name ON user_tags(name);
CREATE INDEX IF NOT EXISTS idx_user_tags_active ON user_tags(is_active);
CREATE INDEX IF NOT EXISTS idx_user_category_assignments_user ON user_category_assignments(user_id);
CREATE INDEX IF NOT EXISTS idx_user_category_assignments_category ON user_category_assignments(category_id);
CREATE INDEX IF NOT EXISTS idx_user_category_assignments_expires ON user_category_assignments(expires_at);
CREATE INDEX IF NOT EXISTS idx_user_tag_assignments_user ON user_tag_assignments(user_id);
CREATE INDEX IF NOT EXISTS idx_user_tag_assignments_tag ON user_tag_assignments(tag_id);
CREATE INDEX IF NOT EXISTS idx_user_tag_assignments_expires ON user_tag_assignments(expires_at);
CREATE INDEX IF NOT EXISTS idx_access_rules_resource ON access_rules(resource_type, resource_name);
CREATE INDEX IF NOT EXISTS idx_access_rules_action ON access_rules(action);
CREATE INDEX IF NOT EXISTS idx_access_rules_priority ON access_rules(priority DESC);
CREATE INDEX IF NOT EXISTS idx_access_rules_active ON access_rules(is_active);
CREATE INDEX IF NOT EXISTS idx_access_rule_roles_rule ON access_rule_roles(rule_id);
CREATE INDEX IF NOT EXISTS idx_access_rule_roles_role ON access_rule_roles(role_id);
CREATE INDEX IF NOT EXISTS idx_access_rule_permissions_rule ON access_rule_permissions(rule_id);
CREATE INDEX IF NOT EXISTS idx_access_rule_permissions_name ON access_rule_permissions(permission_name);
CREATE INDEX IF NOT EXISTS idx_access_rule_categories_rule ON access_rule_categories(rule_id);
CREATE INDEX IF NOT EXISTS idx_access_rule_categories_category ON access_rule_categories(category_id);
CREATE INDEX IF NOT EXISTS idx_access_rule_tags_rule ON access_rule_tags(rule_id);
CREATE INDEX IF NOT EXISTS idx_access_rule_tags_tag ON access_rule_tags(tag_id);
CREATE INDEX IF NOT EXISTS idx_user_permission_cache_user ON user_permission_cache(user_id);
CREATE INDEX IF NOT EXISTS idx_user_permission_cache_key ON user_permission_cache(cache_key);
CREATE INDEX IF NOT EXISTS idx_user_permission_cache_expires ON user_permission_cache(expires_at);
CREATE INDEX IF NOT EXISTS idx_rbac_configs_name ON rbac_configs(name);
CREATE INDEX IF NOT EXISTS idx_rbac_configs_active ON rbac_configs(is_active);
CREATE INDEX IF NOT EXISTS idx_access_audit_log_user ON access_audit_log(user_id);
CREATE INDEX IF NOT EXISTS idx_access_audit_log_resource ON access_audit_log(resource_type, resource_name);
CREATE INDEX IF NOT EXISTS idx_access_audit_log_created ON access_audit_log(created_at);
-- Add triggers for updated_at columns
CREATE TRIGGER update_user_categories_updated_at
BEFORE UPDATE ON user_categories
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_user_tags_updated_at
BEFORE UPDATE ON user_tags
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_access_rules_updated_at
BEFORE UPDATE ON access_rules
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_rbac_configs_updated_at
BEFORE UPDATE ON rbac_configs
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
-- Function to clean up expired permission cache
CREATE OR REPLACE FUNCTION cleanup_expired_permission_cache()
RETURNS INTEGER AS $$
DECLARE
deleted_count INTEGER;
BEGIN
DELETE FROM user_permission_cache WHERE expires_at < NOW();
GET DIAGNOSTICS deleted_count = ROW_COUNT;
RETURN deleted_count;
END;
$$ LANGUAGE plpgsql;
-- Function to get user categories (including inherited)
CREATE OR REPLACE FUNCTION get_user_categories(user_uuid UUID)
RETURNS TABLE(category_name VARCHAR(100)) AS $$
BEGIN
RETURN QUERY
WITH RECURSIVE category_tree AS (
-- Direct categories
SELECT uc.name, uc.parent_id
FROM user_categories uc
JOIN user_category_assignments uca ON uc.id = uca.category_id
WHERE uca.user_id = user_uuid
AND uc.is_active = true
AND (uca.expires_at IS NULL OR uca.expires_at > NOW())
UNION ALL
-- Parent categories
SELECT uc.name, uc.parent_id
FROM user_categories uc
JOIN category_tree ct ON uc.id = ct.parent_id
WHERE uc.is_active = true
)
SELECT DISTINCT ct.name
FROM category_tree ct;
END;
$$ LANGUAGE plpgsql;
-- Function to get user tags
CREATE OR REPLACE FUNCTION get_user_tags(user_uuid UUID)
RETURNS TABLE(tag_name VARCHAR(100)) AS $$
BEGIN
RETURN QUERY
SELECT ut.name
FROM user_tags ut
JOIN user_tag_assignments uta ON ut.id = uta.tag_id
WHERE uta.user_id = user_uuid
AND ut.is_active = true
AND (uta.expires_at IS NULL OR uta.expires_at > NOW());
END;
$$ LANGUAGE plpgsql;
-- Insert default categories
INSERT INTO user_categories (name, description) VALUES
('admin', 'Administrative access category'),
('editor', 'Content editing access category'),
('viewer', 'Read-only access category'),
('finance', 'Financial data access category'),
('hr', 'Human resources access category'),
('it', 'Information technology access category')
ON CONFLICT (name) DO NOTHING;
-- Insert default tags
INSERT INTO user_tags (name, description, color) VALUES
('sensitive', 'Access to sensitive data', '#ff0000'),
('public', 'Public data access', '#00ff00'),
('internal', 'Internal data access', '#ffff00'),
('confidential', 'Confidential data access', '#ff8800'),
('restricted', 'Restricted access', '#8800ff'),
('temporary', 'Temporary access', '#00ffff')
ON CONFLICT (name) DO NOTHING;
-- Insert default RBAC configuration
INSERT INTO rbac_configs (name, description, config_data) VALUES
('default', 'Default RBAC configuration', '{
"rules": [
{
"id": "admin_full_access",
"resource_type": "Database",
"resource_name": "*",
"allowed_roles": ["Admin"],
"allowed_permissions": [],
"required_categories": [],
"required_tags": [],
"deny_categories": [],
"deny_tags": [],
"is_active": true
},
{
"id": "editor_content_access",
"resource_type": "Database",
"resource_name": "content*",
"allowed_roles": ["Editor"],
"allowed_permissions": ["WriteContent"],
"required_categories": ["editor"],
"required_tags": [],
"deny_categories": [],
"deny_tags": ["restricted"],
"is_active": true
}
],
"default_permissions": {
"Database": ["ReadContent"],
"File": ["ReadFile"]
},
"category_hierarchies": {
"admin": ["editor", "viewer"],
"editor": ["viewer"]
},
"tag_hierarchies": {
"public": ["internal"],
"internal": ["confidential"],
"confidential": ["restricted"]
},
"cache_ttl_seconds": 300
}')
ON CONFLICT (name) DO NOTHING;

View file

@ -0,0 +1,4 @@
-- RBAC System Migration for SQLite
-- The full RBAC system is implemented for PostgreSQL only.
-- SQLite environments use a simplified permissions model.
SELECT 1;

View file

@ -0,0 +1,21 @@
-- Migration: 006 Service tokens for external notification senders
-- Database: PostgreSQL
--
-- Service tokens allow external services (CI/CD, monitoring, admin scripts) to
-- POST /api/notify without a full user JWT. Each token is identified by a
-- SHA-256 hash so the plaintext is never stored after creation.
CREATE TABLE IF NOT EXISTS service_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
token_hash TEXT NOT NULL UNIQUE,
created_by UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
scopes TEXT[] NOT NULL DEFAULT '{"notify"}',
is_active BOOLEAN NOT NULL DEFAULT TRUE,
last_used_at TIMESTAMPTZ,
expires_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_service_tokens_hash ON service_tokens(token_hash) WHERE is_active;
CREATE INDEX IF NOT EXISTS idx_service_tokens_creator ON service_tokens(created_by);

View file

@ -0,0 +1,17 @@
-- Migration: 006 Service tokens for external notification senders
-- Database: SQLite
CREATE TABLE IF NOT EXISTS service_tokens (
id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(4))) || '-' || lower(hex(randomblob(2))) || '-4' || substr(lower(hex(randomblob(2))),2) || '-' || substr('89ab',abs(random()) % 4 + 1, 1) || substr(lower(hex(randomblob(2))),2) || '-' || lower(hex(randomblob(6)))),
name TEXT NOT NULL,
token_hash TEXT NOT NULL UNIQUE,
created_by TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
scopes TEXT NOT NULL DEFAULT '["notify"]',
is_active INTEGER NOT NULL DEFAULT 1,
last_used_at DATETIME,
expires_at DATETIME,
created_at DATETIME NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_service_tokens_hash ON service_tokens(token_hash);
CREATE INDEX IF NOT EXISTS idx_service_tokens_creator ON service_tokens(created_by);

View file

@ -0,0 +1,20 @@
# Common translations shared across the site
common-welcome = Welcome to Jesús Pérez Professional Services
common-not-found = Page not found.
common-home = Home
common-about = About
common-services = Services
common-blog = Blog
common-contact = Contact
common-user = User
common-main-desc = Rust Developer & Infrastructure Consultant
pages = Pages
# Common navigation
common-user-page = User page for ID: { $id }
# Language selection
common-language = Language
common-select-language = Select Language
common-english = English
common-spanish = Spanish

View file

@ -0,0 +1,13 @@
# Footer component translations - English
footer-links = Links
# Footer Links
footer-privacy = Privacy
footer-legal = Legal
footer-copyright-name = Jesús Pérez
footer-scroll-to-top = Scroll to top
footer-social-links = Connect with me
# Footer Attribution
footer-built-with = Built with { $tech }
footer-hosting = Hosted on { $host }

View file

@ -0,0 +1,66 @@
# Form components translations - English
# Common form labels
form-name-label = Name
form-email-label = Email
form-subject-label = Subject
form-message-label = Message
form-required-field = *
form-submit-button = Submit
form-send-message = Send Message
form-sending = Sending...
form-cancel = Cancel
form-reset = Reset
# Placeholders
form-name-placeholder = Your full name
form-email-placeholder = your.email@example.com
form-subject-placeholder = Brief description of your inquiry
form-message-placeholder = Please describe your message in detail...
# Validation messages
form-validation-name-required = Name is required
form-validation-name-too-long = Name must be less than 100 characters
form-validation-email-required = Email is required
form-validation-email-invalid = Please enter a valid email address
form-validation-subject-required = Subject is required
form-validation-subject-too-long = Subject must be less than 200 characters
form-validation-message-required = Message is required
form-validation-message-too-long = Message must be less than 5000 characters
# Form states
form-success-title = Message sent successfully!
form-success-message = Thank you for your message! We'll get back to you soon.
form-error-title = Failed to send message
form-error-generic = An error occurred while sending your message. Please try again.
# Contact form specific
contact-form-title = Contact Form
contact-form-description = Get in touch with us
# Support form specific
support-form-title = Support Request
support-form-description = Need help? Send us a support request
support-form-priority-label = Priority
support-form-category-label = Category
support-form-priority-low = Low
support-form-priority-medium = Medium
support-form-priority-high = High
support-form-priority-urgent = Urgent
support-form-category-general = General Inquiry
support-form-category-technical = Technical Support
support-form-category-billing = Billing
support-form-category-bug-report = Bug Report
support-form-category-feature-request = Feature Request
# Subscription form specific
subscription-form-title = Newsletter Subscription
subscription-form-description = Stay updated with our latest news and updates
subscription-form-subscribe = Subscribe
subscription-form-subscribing = Subscribing...
subscription-form-success = Successfully subscribed to our newsletter!
subscription-form-already-subscribed = You're already subscribed to our newsletter.
# Additional form elements
form-select-placeholder = Select an option...
form-validation-field-too-long = Field is too long

View file

@ -0,0 +1,43 @@
# Language Selector component translations - English
# Language Selector Button
lang-selector-button = Language Selector
lang-selector-tooltip = Select Language
lang-selector-current = Current language: English
# Language Options
lang-english = English
lang-spanish = Spanish
lang-english-code = EN
lang-spanish-code = ES
# Language Display Names
lang-en-display = English
lang-es-display = Español
lang-english-display = English
lang-spanish-display = Español
# Accessibility
lang-selector-aria-label = Language selection menu
lang-selector-aria-expanded = Language menu expanded
lang-selector-aria-haspopup = Language options available
lang-selector-menu-role = Language selection menu
# States
lang-selector-loading = Loading languages...
lang-selector-disabled = Language selection unavailable
lang-current-selection = Currently selected language
# Icons and UI
lang-icon-alt = Language icon
lang-dropdown-icon = Dropdown arrow
lang-checkmark-alt = Selected language indicator
# Toggle Component
lang-toggle-title = Switch language
lang-toggle-switch-to = Switch to { $language }
lang-toggle-current = Current: { $language }
# Mobile
lang-mobile-label = Language
lang-mobile-current = Language: { $code }

View file

@ -0,0 +1,32 @@
# Logo component translations - English
# Basic Logo
logo-alt-text = Logo
logo-brand-alt = Company Logo
logo-icon-alt = Company Icon
# Loading States
logo-loading = Loading logo...
logo-error = Logo unavailable
# Brand Header
brand-header-title = Brand Header
brand-header-subtitle = Professional Services
# Logo Link
logo-link-title = Go to homepage
logo-link-home = Return to homepage
# Navbar Logo
navbar-logo-alt = Navigation logo
navbar-logo-title = Return to homepage
# Logo Variants
logo-light-alt = Logo (Light Theme)
logo-dark-alt = Logo (Dark Theme)
logo-horizontal-alt = Horizontal Logo
logo-vertical-alt = Vertical Logo
# Accessibility
logo-aria-label = Company logo and homepage link
logo-loading-aria = Logo is loading

View file

@ -0,0 +1,56 @@
# Navigation component translations - English
# Main Navigation
nav-home = Home
nav-about = About
nav-services = Services
nav-blog = Blog
nav-prescriptions = Prescriptions
nav-contact = Contact
nav-admin = Admin
# Admin Navigation
nav-dashboard = Dashboard
nav-settings = Settings
nav-users = Users
nav-content = Content
# User Status
nav-welcome-user = Welcome, { $name }
nav-signed-in-as = Signed in as { $email }
av-last-login = Last login: { $date }
# Authentication Actions
nav-sign-in = Sign In
nav-sign-up = Sign Up
nav-sign-out = Sign Out
# Language
nav-language = Language
nav-select-language = Select Language
# Mobile Menu
nav-menu-close = Close menu
nav-menu-open = Open menu
# Additional Menu Items
nav-account = Account
nav-docs = Docs
nav-blocks = Blocks
nav-theme = Theme
nav-pages = Pages
# Mobile Menu Controls
nav-mobile-controls = Controls
nav-mobile-theme-language = Theme & Language
nav-menu-toggle = Toggle mobile menu
nav-external-link = External link
# Accessibility
nav-menu-aria-controls = navbar-collapse
nav-menu-aria-expanded = Mobile menu expanded
nav-menu-aria-label = Toggle mobile menu
# Menu States
nav-menu-loading = Loading menu...
nav-menu-error = Unable to load menu

View file

@ -0,0 +1,12 @@
# Cookie consent - English
cookies-banner-text = We use cookies to help this site function, understand service usage, and support marketing efforts.
cookies-manage = Manage Cookies
cookies-reject = Reject non-essential
cookies-accept-all = Accept all
cookies-policy-link = Cookie Policy
cookies-policy-suffix = for more info.
cookies-essential = Essential
cookies-analytics = Analytics
cookies-marketing = Marketing
cookies-save-prefs = Save preferences
cookies-cancel = Cancel

View file

@ -0,0 +1,11 @@
# Sign-out page - English
signout-page-title = Signed out
signout-page-description = You have been signed out
signout-title = See you soon!
signout-title-with-name = See you soon, $name!
signout-subtitle = You have been signed out successfully. Thank you for visiting.
signout-home = Back to home
signout-login-again = Sign in again
signout-redirect = Redirecting to home in $seconds seconds…

View file

@ -0,0 +1,126 @@
# Authentication - English
# Authentication Actions
auth-sign-in = Sign In
auth-sign-up = Sign Up
auth-sign-out = Sign Out
auth-login = Login
auth-register = Register
auth-logout = Logout
# User Information
auth-email = Email
auth-password = Password
auth-username = Username
auth-display-name = Display Name
auth-confirm-password = Confirm Password
auth-remember-me = Remember me
auth-forgot-password = Forgot your password?
auth-create-account = Create Account
auth-already-have-account = Already have an account?
auth-dont-have-account = Do not have an account?
# Form Labels and Placeholders
auth-email-address = Email Address
auth-enter-email = Enter your email
auth-enter-username = Choose a username
auth-enter-password = Enter your password
auth-create-password = Create a strong password
auth-confirm-your-password = Confirm your password
auth-how-should-we-call-you = How should we call you?
# Success Messages
auth-sign-in-success = Sign in successful
auth-registration-success = Registration successful
auth-logout-success = Logout successful
# Validation Messages
auth-password-required = Password is required
auth-email-required = Email is required
auth-username-required = Username is required
auth-passwords-no-match = Passwords do not match
auth-passwords-match = Passwords match
auth-password-too-short = Password must be at least 8 characters
auth-invalid-email = Please enter a valid email address
auth-username-format = 3-50 characters, letters, numbers, underscores and hyphens only
# Password Strength
auth-password-strength = Password strength:
auth-very-weak = Very Weak
auth-weak = Weak
auth-fair = Fair
auth-good = Good
auth-strong = Strong
auth-password-requirements = Must be at least 8 characters with uppercase, lowercase, number and special character
# OAuth Providers
auth-continue-with = Or continue with
auth-sign-up-with = Or sign up with
auth-google = Google
auth-github = GitHub
auth-discord = Discord
auth-microsoft = Microsoft
# Terms and Privacy
auth-agree-to-terms = I agree to the
auth-terms-of-service = Terms of Service
auth-privacy-policy = Privacy Policy
auth-and = and
# Error Messages
auth-invalid-credentials = Invalid email or password
auth-user-not-found = User not found
auth-email-already-exists = An account with this email already exists
auth-username-already-exists = This username is already taken
auth-account-not-verified = Please verify your email before signing in
auth-account-suspended = Your account has been suspended
auth-rate-limit-exceeded = Too many attempts. Please try again later
auth-network-error = Network error. Please check your connection
auth-login-failed = Login failed
auth-registration-failed = Registration failed
auth-session-expired = Your session has expired. Please sign in again
auth-invalid-token = Invalid authentication token
auth-token-expired = Your authentication token has expired
auth-insufficient-permissions = You do not have permission to perform this action
auth-oauth-error = OAuth authentication error
auth-database-error = A database error occurred. Please try again
auth-internal-error = An internal error occurred. Please try again
auth-validation-error = Please check your input and try again
auth-authentication-failed = Authentication failed
auth-server-error = Server error occurred. Please try again later
auth-request-failed = Request failed. Please try again
auth-unknown-error = An unknown error occurred
# OTP / Passwordless login
auth-otp-modal-title = Log in or sign up
auth-otp-modal-subtitle = Enter your email to continue
auth-otp-check-inbox = Check your inbox
auth-otp-sent-to = Enter the verification code we sent to { $email }
auth-otp-resend = Resend email
auth-otp-code-placeholder = Code
auth-otp-code-expired = Code expired. Request a new one.
auth-otp-code-invalid = Invalid code. { $remaining ->
[one] { $remaining } attempt remaining.
*[other] { $remaining } attempts remaining.
}
auth-otp-too-many-attempts = Too many failed attempts. Request a new code.
auth-otp-success = Signed in successfully.
auth-otp-continue = Continue
auth-otp-resend-countdown = Resend in { $seconds }s
auth-otp-resend-now = Resend code
auth-otp-back-to-email = ← Change email
auth-modal-close = Close
# GDPR consent step (new accounts)
auth-gdpr-title = Create your account
auth-gdpr-description = Before we create your account, we need your consent to process your data in accordance with our Privacy Policy.
auth-gdpr-accept-required = I accept the <a href="/privacy" class="link link-primary" target="_blank" rel="noopener noreferrer">Terms of Service and Privacy Policy (required)</a>
auth-gdpr-accept-marketing = I agree to receive occasional newsletters and product updates (optional)
auth-gdpr-submit = Create account
# Email: OTP verification code
auth-email-otp-subject = Your verification code
auth-email-otp-title = Your verification code
auth-email-otp-subtitle = Use this code to sign in:
auth-email-otp-expire-msg = This code expires in %minutes% minutes.
auth-email-otp-ignore-msg = If you didn't request this, you can safely ignore this email.

View file

@ -0,0 +1,95 @@
# Page Manager CLI - English Translations
# All keys MUST have mgr- prefix for shared locales system
# Welcome and headers
mgr-cli-title = Rustelo Page Manager (for now Static Content)
mgr-cli-description = Manage and generate new pages with templates, routes, and translations
mgr-cli-project-root = Project root: {$path}
mgr-cli-available-languages = Available languages: {$languages}
# Prompts
mgr-prompt-page-name = Page name (PascalCase, e.g., 'ContactForm', 'AboutUs'):
mgr-prompt-page-name-help = Use PascalCase naming. This will be your component name.
mgr-prompt-template-select = Select page template:
mgr-prompt-template-help = Choose the template that best fits your page needs
mgr-prompt-languages-select = Select languages:
mgr-prompt-languages-help = Choose which languages to generate translations for
mgr-prompt-route-path = Route path:
mgr-prompt-route-path-help = URL path for this page (must start with '/')
mgr-prompt-auto-scaffold = Auto-scaffold page files?
mgr-prompt-auto-scaffold-help = Generate mod.rs, unified.rs, and FTL translation files
mgr-prompt-custom-priority = Set custom route priority?
mgr-prompt-custom-priority-help = Higher priority routes are matched first (default: 1.0)
mgr-prompt-priority-value = Route priority (0.1 - 10.0):
mgr-prompt-custom-patterns = Customize i18n translation patterns?
mgr-prompt-custom-patterns-help = Advanced: customize the prefixes used for translation keys
mgr-prompt-patterns-value = I18n patterns (comma-separated):
mgr-prompt-patterns-example = Example: page-gen-contact-, contact-form-
mgr-prompt-final-confirm = Generate page with this configuration?
# Configuration summary
mgr-config-summary = 📋 Configuration Summary:
mgr-config-name = Name: {$name}
mgr-config-module = Module: {$module}
mgr-config-template = Template: {$template} ({$description})
mgr-config-languages = Languages: {$languages}
mgr-config-route = Route: {$route}
mgr-config-auto-scaffold = Auto-scaffold: {$enabled}
# Labels
mgr-label-name = Name
mgr-label-module = Module
mgr-label-template = Template
mgr-label-languages = Languages
mgr-label-route = Route
mgr-label-auto-scaffold = Auto-scaffold
# Text values
mgr-text-yes = Yes
mgr-text-no = No
# Status messages
mgr-status-generating = 🏗️ Generating page...
mgr-status-cancelled = 🚫 Generation cancelled.
mgr-status-completed = ✨ Page generation completed successfully!
# Next steps
mgr-next-steps = 💡 Next steps:
mgr-next-steps-with-scaffold = 💡 Next steps:\n 1. Run 'cargo build' to compile the new page\n 2. Check the generated FTL files and customize translations\n 3. Implement your page logic in the unified.rs file\n 4. Test your page at {$route}
mgr-next-steps-without-scaffold = 💡 Next steps:\n 1. Run 'cargo build' to update route generation\n 2. Implement the page component manually\n 3. Add the component export to pages/src/lib.rs
# Warnings
mgr-warning-page-exists = Page module '{$module}' already exists.
mgr-warning-overwrite-confirm = Overwrite existing page?
mgr-warning-cannot-proceed = Cannot proceed with existing page. Choose a different name or allow overwriting.
# Error messages
mgr-error-project-root = Could not find project root. Run this command from within a Rustelo project.
mgr-error-generator-init = Failed to initialize page generator
mgr-error-page-name = Failed to get page name
mgr-error-template-selection = Failed to get template selection
mgr-error-language-selection = Failed to get language selection
mgr-error-route-path = Failed to get route path
mgr-error-auto-scaffold = Failed to get auto-scaffold preference
mgr-error-priority = Failed to get priority preference
mgr-error-priority-value = Failed to get route priority
mgr-error-patterns = Failed to get i18n patterns preference
mgr-error-patterns-value = Failed to get i18n patterns
mgr-error-confirmation = Failed to get confirmation
mgr-error-generation = Page generation failed
mgr-error-at-least-one-language = At least one language must be selected
# Validation errors
mgr-validation-name-empty = Page name cannot be empty
mgr-validation-name-case = Page name must start with an uppercase letter (PascalCase)
mgr-validation-name-chars = Page name must contain only ASCII letters and numbers
mgr-validation-name-length = Page name too long (max 50 characters)
mgr-validation-name-reserved = '{$name}' is a reserved name, please choose another
mgr-validation-path-empty = Route path cannot be empty
mgr-validation-path-start = Route path must start with '/'
mgr-validation-path-end = Route path cannot end with '/' (except root)
mgr-validation-path-consecutive = Route path cannot contain consecutive slashes
mgr-validation-path-chars = Route path must contain only ASCII characters and no spaces
mgr-validation-path-reserved = '{$path}' conflicts with reserved paths
mgr-validation-priority-range = Priority must be between 0.1 and 10.0
mgr-validation-priority-number = Priority must be a valid number

View file

@ -0,0 +1,83 @@
# Dashboard Menu Translations - English
# Dashboard
dashboard-title = Rustelo Development Dashboard
dashboard-description = Comprehensive development toolkit for Rustelo projects
# Categories
category-content-name = Content Management
category-content-description = Create, edit, and manage content
category-development-name = Development Tools
category-development-description = Development and debugging utilities
category-system-name = System & APIs
category-system-description = System information and API management
category-deployment-name = Build & Deploy
category-deployment-description = Build, test, and deployment tools
# Content Management Actions
action-create-page-name = Create New Page
action-create-page-description = Generate a new page with templates, routes, and translations
action-edit-content-name = Edit Static Content
action-edit-content-description = Edit existing pages, blog posts, and recipes
action-manage-translations-name = Manage Translations
action-manage-translations-description = Edit FTL translation files for multiple languages
action-content-preview-name = Content Preview
action-content-preview-description = Preview content with hot reloading
# Development Tools Actions
action-dev-server-name = Start Dev Server
action-dev-server-description = Launch development server with hot reload
action-css-watch-name = CSS Watch Mode
action-css-watch-description = Watch and rebuild CSS files automatically
action-browser-logs-name = Browser Logs
action-browser-logs-description = Collect and view browser console logs
action-code-format-name = Format Code
action-code-format-description = Format Rust code with rustfmt and clippy
# System & APIs Actions
action-show-apis-name = Show Available APIs
action-show-apis-description = List all available API endpoints and routes
action-system-info-name = System Information
action-system-info-description = Display system info, environment, and configuration
action-route-inspector-name = Route Inspector
action-route-inspector-description = Inspect and validate route configurations
action-config-manager-name = Configuration Manager
action-config-manager-description = View and edit configuration files
# Build & Deploy Actions
action-build-prod-name = Production Build
action-build-prod-description = Build for production deployment
action-run-tests-name = Run Tests
action-run-tests-description = Execute all tests and quality checks
action-quality-check-name = Quality Check
action-quality-check-description = Run comprehensive quality checks
action-deploy-status-name = Deployment Status
action-deploy-status-description = Check deployment status and health
# Quick Actions
action-help-name = Help
action-help-description = Show keyboard shortcuts and help
action-refresh-name = Refresh
action-refresh-description = Refresh dashboard data
action-settings-name = Settings
action-settings-description = Dashboard settings and preferences
action-quit-name = Quit
action-quit-description = Exit dashboard
# UI Elements
ui-items = Items
ui-items-focused = Items [FOCUSED]
ui-subitems = Sub-items
ui-subitems-focused = Sub-items [FOCUSED]
ui-quick-actions = Quick Actions
ui-action-result = Result
ui-language-selector = Language
ui-chat-prompt = Ask AI Assistant
ui-chat-placeholder = Press '/' to start typing or Tab to focus...
ui-send-button = Send
ui-clear-button = Clear
ui-ai-button = AI
ui-copy-button = Copy
ui-space-switch = Space Switch Panel
ui-chat-focus = Chat
ui-f12-language = F12 Language

View file

@ -0,0 +1,16 @@
# About page — base translations (English)
# Override any key in your implementation's site/i18n/locales/en/pages/about.ftl
# Page Metadata
about-page-title = About
about-page-description = Learn more about us
about-page-keywords = about, team, mission
# Header
about-page-subtitle = Our story and mission
# Sections
about-mission-title = Our Mission
about-mission-text = Describe your mission here.
about-team-title = The Team
about-values-title = Our Values

View file

@ -0,0 +1,29 @@
# Contact page — base translations (English)
# Override any key in your implementation's site/i18n/locales/en/pages/contact.ftl
# Page Metadata
contact-page-title = Contact
contact-page-description = Get in touch with us
contact-page-keywords = contact, reach out, inquiry
# Header
contact-page-subtitle = We'd love to hear from you.
# Form
contact-send-message = Send a Message
contact-name-label = Name
contact-name-placeholder = Your full name
contact-email-label = Email
contact-email-placeholder = your.email@example.com
contact-subject-label = Subject
contact-subject-placeholder = Brief description of your inquiry
contact-message-label = Message
contact-message-placeholder = Tell us about your project or question.
contact-submit-btn = Send Message
contact-submit-success = Message sent! We'll get back to you soon.
# Contact methods
contact-lets-connect = Let's Connect
contact-email-contact = Email
contact-response-time = Response Time
contact-response-time-text = We typically respond within 24 hours on weekdays.

View file

@ -0,0 +1,124 @@
# Content Kind Translations
# Dynamic translations for different content types
# Content kind names
content-kind-blog = Blog
content-kind-recetas = Recipes
content-kind-content = Content
content-kind-tutoriales = Tutorials
# Content kind descriptions
content-kind-blog-description = Technical articles and insights
content-kind-recetas-description = Solutions and best practices
content-kind-content-description = General content and documentation
content-kind-tutoriales-description = Step-by-step tutorials
# Page titles
content-kind-blog-page-title = { $title ->
[blog] Blog - { $site_name }
*[other] { $title } - { $site_name }
}
content-kind-recetas-page-title = { $title ->
[recetas] Recipes - { $site_name }
*[other] { $title } - { $site_name }
}
# Navigation labels
content-nav-back-to = { $content_kind ->
[blog] Back to Blog
[recetas] Back to Recipes
[content] Back to Content
[tutoriales] Back to Tutorials
*[other] Back to { $content_kind }
}
# Feature support
content-supports-features = { $content_kind ->
[blog] true
[tutoriales] true
*[other] false
}
content-supports-emojis = { $content_kind ->
[recetas] true
*[other] false
}
# Style modes
content-style-mode = { $content_kind ->
[blog] row
[recetas] grid
[content] row
[tutoriales] row
*[other] row
}
# CTA views
content-cta-view = { $content_kind ->
[blog] BlogCTAView
[recetas] RecetasCTAView
[tutoriales] TutorialesCTAView
*[other] DefaultCTAView
}
# Shared content messages (used by UnifiedContentGrid)
content-no-items-found = No items found
content-all-categories = All Categories
# Content Manager UI
content-manager-title = Content Management
content-manager-new-content = New Content
content-manager-filter-type = Content Type:
content-manager-filter-language = Language:
content-manager-all-types = All Types
content-manager-all-languages = All Languages
content-manager-refresh = Refresh
# Content Manager Table Headers
content-manager-table-title = Title
content-manager-table-type = Type
content-manager-table-language = Language
content-manager-table-status = Status
content-manager-table-updated = Updated
content-manager-table-actions = Actions
# Content Manager Status
content-manager-status-published = Published
content-manager-status-draft = Draft
# Content Manager Actions
content-manager-action-edit = Edit
content-manager-action-publish = Publish
content-manager-action-unpublish = Unpublish
content-manager-action-delete = Delete
# Content Manager Notes
content-manager-note-label = Note:
content-manager-note-text = This is a mockup of the content management interface. Full functionality with API integration will be implemented in future updates.
# Content Manager Sample Data
content-manager-sample-category = General
content-manager-sample-blog-title = Sample Blog Post
content-manager-sample-blog-content = # Sample Blog Post\n\nThis is a sample blog post for demonstration purposes.
content-manager-sample-blog-excerpt = A sample blog post demonstrating the content management system.
content-manager-sample-recipes-title = Sample Recipe
content-manager-sample-recipes-content = # Sample Recipe\n\n## Ingredients\n- Sample ingredient 1\n- Sample ingredient 2\n\n## Instructions\n1. Sample step 1\n2. Sample step 2
content-manager-sample-recipes-excerpt = A sample recipe demonstrating the content management system.
# Language Display Names
language-en = English
language-es = Español
# Content Type Display Names (fallbacks if not in content-kinds)
content-kind-recipes = Recipes
content-featured-post = Featured Post
content-recent-posts = Recent Posts
content-read-full-article = Read full article →
content-read-more = Read more →
content-back-to-all-posts = ← Back to all posts
content-failed-to-load = Failed to load content
content-view-all = View all
content-related-view-all = Related: View all

View file

@ -0,0 +1,22 @@
# Home page — base translations (English)
# Override any key in your implementation's site/i18n/locales/en/pages/home.ftl
# Page Metadata
home-page-title = Home
home-page-description = Welcome to our website
home-page-keywords = home, welcome
# Hero
home-hero-title = Welcome
home-hero-subtitle = Your site tagline goes here.
home-cta-primary = Get Started
home-cta-secondary = Learn More
# Features / highlights section
home-features-title = What We Offer
home-features-subtitle = Key areas of focus
# Call to action section
home-cta-title = Ready to get started?
home-cta-description = Contact us to discuss how we can help.
home-cta-button = Contact Us

View file

@ -0,0 +1,28 @@
# 404 Not Found page translations
# Page Metadata
not-found-page-title = Page Not Found
not-found-page-description = The requested page could not be found
not-found-page-keywords = 404, not found, error
# Page title and content
not-found-title = Page Not Found
not-found-description = Sorry, the page you are looking for doesn't exist or has been moved.
# Navigation buttons
not-found-go-home = Go Home
not-found-contact-us = Contact Us
not-found-back-to-blog = Back to Blog
not-found-back-to-prescriptions = Back to Prescriptions
not-found-sign-in = Sign In
# Navigation URLs
not-found-home-url = /
not-found-contact-url = /contact
not-found-blog-url = /blog
not-found-prescriptions-url = /prescriptions
not-found-login-url = /login
# Additional helpful text (optional)
not-found-section-title = What can you do?
not-found-help-text = You can try going back to the home page or contact us if you believe this is an error.

View file

@ -0,0 +1,23 @@
# Post viewer page translations
# Post content structure
post-title =
post-subtitle =
post-content =
post-author = Author
post-date = Published Date
post-category = Category
post-tags = Tags
# Navigation and actions
post-back-to-blog = Back to Blog
post-back-to-recetas = Back to Recetas
post-back-to-content = Back to Content
post-back-to-tutoriales = Back to Tutorials
post-share = Share this article
post-print = Print article
# Metadata
post-reading-time = Reading time
post-difficulty = Difficulty
post-last-updated = Last updated

View file

@ -0,0 +1,20 @@
# Traducciones comunes compartidas en todo el sitio
common-welcome = Bienvenido a los Servicios Profesionales de Jesús Pérez
common-not-found = Página no encontrada.
common-home = Inicio
common-about = Acerca de
common-services = Servicios
common-blog = Blog
common-contact = Contacto
common-user = Usuario
common-main-desc = Desarrollador Rust y Consultor de Infraestructura
pages = Páginas
# Navegación común
common-user-page = Página de usuario con ID: { $id }
# Selección de idioma
common-language = Idioma
common-select-language = Seleccionar Idioma
common-english = English
common-spanish = Español

View file

@ -0,0 +1,13 @@
# Traducciones del componente footer - Español
footer-links = Enlaces
# Enlaces del Footer
footer-privacy = Privacidad
footer-legal = Legal
footer-copyright-name = Jesús Pérez
footer-scroll-to-top = Ir al inicio
footer-social-links = Conéctate conmigo
# Atribución del Footer
footer-built-with = Construido con { $tech }
footer-hosting = Alojado en { $host }

View file

@ -0,0 +1,66 @@
# Traducciones de componentes de formularios - Español
# Etiquetas comunes de formularios
form-name-label = Nombre
form-email-label = Correo electrónico
form-subject-label = Asunto
form-message-label = Mensaje
form-required-field = *
form-submit-button = Enviar
form-send-message = Enviar Mensaje
form-sending = Enviando...
form-cancel = Cancelar
form-reset = Restablecer
# Marcadores de posición
form-name-placeholder = Tu nombre completo
form-email-placeholder = tu.correo@ejemplo.com
form-subject-placeholder = Breve descripción de tu consulta
form-message-placeholder = Por favor describe tu mensaje en detalle...
# Mensajes de validación
form-validation-name-required = El nombre es requerido
form-validation-name-too-long = El nombre debe tener menos de 100 caracteres
form-validation-email-required = El correo electrónico es requerido
form-validation-email-invalid = Por favor ingresa una dirección de correo válida
form-validation-subject-required = El asunto es requerido
form-validation-subject-too-long = El asunto debe tener menos de 200 caracteres
form-validation-message-required = El mensaje es requerido
form-validation-message-too-long = El mensaje debe tener menos de 5000 caracteres
# Estados del formulario
form-success-title = ¡Mensaje enviado exitosamente!
form-success-message = ¡Gracias por tu mensaje! Te responderemos pronto.
form-error-title = Error al enviar el mensaje
form-error-generic = Ocurrió un error al enviar tu mensaje. Por favor intenta de nuevo.
# Formulario de contacto específico
contact-form-title = Formulario de Contacto
contact-form-description = Ponte en contacto con nosotros
# Formulario de soporte específico
support-form-title = Solicitud de Soporte
support-form-description = ¿Necesitas ayuda? Envíanos una solicitud de soporte
support-form-priority-label = Prioridad
support-form-category-label = Categoría
support-form-priority-low = Baja
support-form-priority-medium = Media
support-form-priority-high = Alta
support-form-priority-urgent = Urgente
support-form-category-general = Consulta General
support-form-category-technical = Soporte Técnico
support-form-category-billing = Facturación
support-form-category-bug-report = Reporte de Error
support-form-category-feature-request = Solicitud de Funcionalidad
# Formulario de suscripción específico
subscription-form-title = Suscripción al Boletín
subscription-form-description = Mantente actualizado con nuestras últimas noticias y actualizaciones
subscription-form-subscribe = Suscribirse
subscription-form-subscribing = Suscribiendo...
subscription-form-success = ¡Te has suscrito exitosamente a nuestro boletín!
subscription-form-already-subscribed = Ya estás suscrito a nuestro boletín.
# Elementos adicionales del formulario
form-select-placeholder = Selecciona una opción...
form-validation-field-too-long = El campo es demasiado largo

View file

@ -0,0 +1,43 @@
# Language Selector component translations - Spanish
# Language Selector Button
lang-selector-button = Selector de idioma
lang-selector-tooltip = Seleccionar idioma
lang-selector-current = Idioma actual: Español
# Language Options
lang-english = Inglés
lang-spanish = Español
lang-english-code = EN
lang-spanish-code = ES
# Language Display Names
lang-en-display = English
lang-es-display = Español
lang-english-display = English
lang-spanish-display = Español
# Accessibility
lang-selector-aria-label = Menú de selección de idioma
lang-selector-aria-expanded = Menú de idioma expandido
lang-selector-aria-haspopup = Opciones de idioma disponibles
lang-selector-menu-role = Menú de selección de idioma
# States
lang-selector-loading = Cargando idiomas...
lang-selector-disabled = Selección de idioma no disponible
lang-current-selection = Idioma seleccionado actualmente
# Icons and UI
lang-icon-alt = Icono de idioma
lang-dropdown-icon = Flecha desplegable
lang-checkmark-alt = Indicador de idioma seleccionado
# Toggle Component
lang-toggle-title = Cambiar idioma
lang-toggle-switch-to = Cambiar a { $language }
lang-toggle-current = Actual: { $language }
# Mobile
lang-mobile-label = Idioma
lang-mobile-current = Idioma: { $code }

View file

@ -0,0 +1,56 @@
# Traducciones del componente de navegación - Español
# Navegación Principal
nav-home = Inicio
nav-about = Acerca de
nav-services = Servicios
nav-blog = Blog
nav-prescriptions = Prescripciones
nav-contact = Contacto
nav-admin = Administrador
# Navegación de Admin
nav-dashboard = Panel de Control
nav-settings = Configuraciones
nav-users = Usuarios
nav-content = Contenido
# Estado de Usuario
nav-welcome-user = Bienvenido, { $name }
nav-signed-in-as = Conectado como { $email }
nav-last-login = Último acceso: { $date }
# Acciones de Autenticación
nav-sign-in = Login
nav-sign-up = Registrarse
nav-sign-out = Cerrar Sesión
# Idioma
nav-language = Idioma
nav-select-language = Seleccionar Idioma
# Menú Móvil
nav-menu-close = Cerrar menú
nav-menu-open = Abrir menú
# Elementos Adicionales del Menú
nav-account = Cuenta
nav-docs = Documentación
nav-blocks = Bloques
nav-theme = Tema
nav-pages = Páginas
# Controles del Menú Móvil
nav-mobile-controls = Controles
nav-mobile-theme-language = Tema e Idioma
nav-menu-toggle = Alternar menú móvil
nav-external-link = Enlace externo
# Accesibilidad
nav-menu-aria-controls = navbar-collapse
nav-menu-aria-expanded = Menú móvil expandido
nav-menu-aria-label = Alternar menú móvil
# Estados del Menú
nav-menu-loading = Cargando menú...
nav-menu-error = No se pudo cargar el menú

View file

@ -0,0 +1,12 @@
# Consentimiento de cookies - Español
cookies-banner-text = Usamos cookies para que este sitio funcione, entender el uso del servicio y apoyar acciones de marketing.
cookies-manage = Gestionar cookies
cookies-reject = Rechazar no esenciales
cookies-accept-all = Aceptar todas
cookies-policy-link = Política de cookies
cookies-policy-suffix = para más información.
cookies-essential = Esenciales
cookies-analytics = Analítica
cookies-marketing = Marketing
cookies-save-prefs = Guardar preferencias
cookies-cancel = Cancelar

View file

@ -0,0 +1,11 @@
# Página de cierre de sesión - Español
signout-page-title = Sesión cerrada
signout-page-description = Has cerrado sesión correctamente
signout-title = ¡Hasta pronto!
signout-title-with-name = ¡Hasta pronto, $name!
signout-subtitle = Has cerrado sesión correctamente. Gracias por visitarnos.
signout-home = Volver al inicio
signout-login-again = Iniciar sesión de nuevo
signout-redirect = Redirigiendo al inicio en $seconds segundos…

View file

@ -0,0 +1,126 @@
# Authentication - Spanish
# Authentication Actions
auth-sign-in = Iniciar Sesión
auth-sign-up = Registrarse
auth-sign-out = Cerrar Sesión
auth-login = Iniciar Sesión
auth-register = Registrarse
auth-logout = Cerrar Sesión
# User Information
auth-email = Correo Electrónico
auth-password = Contraseña
auth-username = Nombre de Usuario
auth-display-name = Nombre para Mostrar
auth-confirm-password = Confirmar Contraseña
auth-remember-me = Recordarme
auth-forgot-password = ¿Olvidaste tu contraseña?
auth-create-account = Crear Cuenta
auth-already-have-account = ¿Ya tienes una cuenta?
auth-dont-have-account = ¿No tienes una cuenta?
# Form Labels and Placeholders
auth-email-address = Dirección de Correo Electrónico
auth-enter-email = Ingresa tu correo electrónico
auth-enter-username = Elige un nombre de usuario
auth-enter-password = Ingresa tu contraseña
auth-create-password = Crea una contraseña segura
auth-confirm-your-password = Confirma tu contraseña
auth-how-should-we-call-you = ¿Cómo deberíamos llamarte?
# Success Messages
auth-sign-in-success = Inicio de sesión exitoso
auth-registration-success = Registro exitoso
auth-logout-success = Cierre de sesión exitoso
# Validation Messages
auth-password-required = La contraseña es requerida
auth-email-required = El correo electrónico es requerido
auth-username-required = El nombre de usuario es requerido
auth-passwords-no-match = Las contraseñas no coinciden
auth-passwords-match = Las contraseñas coinciden
auth-password-too-short = La contraseña debe tener al menos 8 caracteres
auth-invalid-email = Por favor ingresa un correo electrónico válido
auth-username-format = 3-50 caracteres, solo letras, números, guiones bajos y guiones
# Password Strength
auth-password-strength = Fuerza de la contraseña:
auth-very-weak = Muy Débil
auth-weak = Débil
auth-fair = Regular
auth-good = Buena
auth-strong = Fuerte
auth-password-requirements = Debe tener al menos 8 caracteres con mayúscula, minúscula, número y carácter especial
# OAuth Providers
auth-continue-with = O continúa con
auth-sign-up-with = O regístrate con
auth-google = Google
auth-github = GitHub
auth-discord = Discord
auth-microsoft = Microsoft
# Terms and Privacy
auth-agree-to-terms = Acepto los
auth-terms-of-service = Términos de Servicio
auth-privacy-policy = Política de Privacidad
auth-and = y
# Error Messages
auth-invalid-credentials = Correo electrónico o contraseña inválidos
auth-user-not-found = Usuario no encontrado
auth-email-already-exists = Ya existe una cuenta con este correo electrónico
auth-username-already-exists = Este nombre de usuario ya está en uso
auth-account-not-verified = Por favor verifica tu correo electrónico antes de iniciar sesión
auth-account-suspended = Tu cuenta ha sido suspendida
auth-rate-limit-exceeded = Demasiados intentos. Por favor intenta de nuevo más tarde
auth-network-error = Error de red. Por favor verifica tu conexión
auth-login-failed = Error al iniciar sesión
auth-registration-failed = Error en el registro
auth-session-expired = Tu sesión ha expirado. Por favor inicia sesión de nuevo
auth-invalid-token = Token de autenticación inválido
auth-token-expired = Tu token de autenticación ha expirado
auth-insufficient-permissions = No tienes permisos para realizar esta acción
auth-oauth-error = Error de autenticación OAuth
auth-database-error = Ocurrió un error en la base de datos. Por favor intenta de nuevo
auth-internal-error = Ocurrió un error interno. Por favor intenta de nuevo
auth-validation-error = Por favor revisa tu información e intenta de nuevo
auth-authentication-failed = Error de autenticación
auth-server-error = Error del servidor. Por favor intenta más tarde
auth-request-failed = La solicitud falló. Por favor intenta de nuevo
auth-unknown-error = Ocurrió un error desconocido
# OTP / Inicio de sesión sin contraseña
auth-otp-modal-title = Iniciar sesión o registrarse
auth-otp-modal-subtitle = Introduce tu email para continuar
auth-otp-check-inbox = Comprueba tu bandeja de entrada
auth-otp-sent-to = Introduce el código de verificación que enviamos a { $email }
auth-otp-resend = Reenviar email
auth-otp-code-placeholder = Código
auth-otp-code-expired = Código expirado. Solicita uno nuevo.
auth-otp-code-invalid = Código incorrecto. { $remaining ->
[one] { $remaining } intento restante.
*[other] { $remaining } intentos restantes.
}
auth-otp-too-many-attempts = Demasiados intentos fallidos. Solicita un nuevo código.
auth-otp-success = Sesión iniciada correctamente.
auth-otp-continue = Continuar
auth-otp-resend-countdown = Reenviar en { $seconds }s
auth-otp-resend-now = Reenviar código
auth-otp-back-to-email = ← Cambiar email
auth-modal-close = Cerrar
# Paso de consentimiento GDPR (nuevas cuentas)
auth-gdpr-title = Crea tu cuenta
auth-gdpr-description = Antes de crear tu cuenta, necesitamos tu consentimiento para procesar tus datos de acuerdo con nuestra Política de Privacidad.
auth-gdpr-accept-required = Acepto los <a href="/privacidad" class="link link-primary" target="_blank" rel="noopener noreferrer">Términos de Servicio y la Política de Privacidad (obligatorio)</a>
auth-gdpr-accept-marketing = Acepto recibir boletines ocasionales y novedades del producto (opcional)
auth-gdpr-submit = Crear cuenta
# Email: código de verificación OTP
auth-email-otp-subject = Tu código de verificación
auth-email-otp-title = Tu código de verificación
auth-email-otp-subtitle = Usa este código para iniciar sesión:
auth-email-otp-expire-msg = Este código expira en %minutes% minutos.
auth-email-otp-ignore-msg = Si no solicitaste esto, puedes ignorar este correo.

View file

@ -0,0 +1,95 @@
# Page Manager CLI - Spanish Translations
# All keys MUST have mgr- prefix for shared locales system
# Welcome and headers
mgr-cli-title = Gestor de Páginas Rustelo (para ahora Contenido Estático)
mgr-cli-description = Gestiona y genera nuevas páginas con plantillas, rutas y traducciones
mgr-cli-project-root = Raíz del proyecto: {$path}
mgr-cli-available-languages = Idiomas disponibles: {$languages}
# Prompts
mgr-prompt-page-name = Nombre de página (PascalCase, ej., 'FormularioContacto', 'AcercaDe'):
mgr-prompt-page-name-help = Usa nomenclatura PascalCase. Este será el nombre de tu componente.
mgr-prompt-template-select = Selecciona plantilla de página:
mgr-prompt-template-help = Elige la plantilla que mejor se adapte a las necesidades de tu página
mgr-prompt-languages-select = Selecciona idiomas:
mgr-prompt-languages-help = Elige para qué idiomas generar traducciones
mgr-prompt-route-path = Ruta de página:
mgr-prompt-route-path-help = Ruta URL para esta página (debe empezar con '/')
mgr-prompt-auto-scaffold = ¿Auto-generar archivos de página?
mgr-prompt-auto-scaffold-help = Generar mod.rs, unified.rs y archivos de traducción FTL
mgr-prompt-custom-priority = ¿Establecer prioridad de ruta personalizada?
mgr-prompt-custom-priority-help = Las rutas de mayor prioridad se evalúan primero (por defecto: 1.0)
mgr-prompt-priority-value = Prioridad de ruta (0.1 - 10.0):
mgr-prompt-custom-patterns = ¿Personalizar patrones de traducción i18n?
mgr-prompt-custom-patterns-help = Avanzado: personalizar los prefijos usados para claves de traducción
mgr-prompt-patterns-value = Patrones i18n (separados por comas):
mgr-prompt-patterns-example = Ejemplo: page-gen-contacto-, formulario-contacto-
mgr-prompt-final-confirm = ¿Generar página con esta configuración?
# Configuration summary
mgr-config-summary = 📋 Resumen de Configuración:
mgr-config-name = Nombre: {$name}
mgr-config-module = Módulo: {$module}
mgr-config-template = Plantilla: {$template} ({$description})
mgr-config-languages = Idiomas: {$languages}
mgr-config-route = Ruta: {$route}
mgr-config-auto-scaffold = Auto-generar: {$enabled}
# Labels
mgr-label-name = Nombre
mgr-label-module = Módulo
mgr-label-template = Plantilla
mgr-label-languages = Idiomas
mgr-label-route = Ruta
mgr-label-auto-scaffold = Auto-generar
# Text values
mgr-text-yes = Sí
mgr-text-no = No
# Status messages
mgr-status-generating = 🏗️ Generando página...
mgr-status-cancelled = 🚫 Generación cancelada.
mgr-status-completed = ✨ ¡Generación de página completada exitosamente!
# Next steps
mgr-next-steps = 💡 Próximos pasos:
mgr-next-steps-with-scaffold = 💡 Próximos pasos:\n 1. Ejecuta 'cargo build' para compilar la nueva página\n 2. Revisa los archivos FTL generados y personaliza las traducciones\n 3. Implementa la lógica de tu página en el archivo unified.rs\n 4. Prueba tu página en {$route}
mgr-next-steps-without-scaffold = 💡 Próximos pasos:\n 1. Ejecuta 'cargo build' para actualizar la generación de rutas\n 2. Implementa el componente de página manualmente\n 3. Agrega la exportación del componente a pages/src/lib.rs
# Warnings
mgr-warning-page-exists = El módulo de página '{$module}' ya existe.
mgr-warning-overwrite-confirm = ¿Sobrescribir página existente?
mgr-warning-cannot-proceed = No se puede continuar con página existente. Elige un nombre diferente o permite sobrescribir.
# Error messages
mgr-error-project-root = No se pudo encontrar la raíz del proyecto. Ejecuta este comando desde un proyecto Rustelo.
mgr-error-generator-init = Error al inicializar el generador de páginas
mgr-error-page-name = Error al obtener el nombre de página
mgr-error-template-selection = Error al obtener la selección de plantilla
mgr-error-language-selection = Error al obtener la selección de idioma
mgr-error-route-path = Error al obtener la ruta de página
mgr-error-auto-scaffold = Error al obtener la preferencia de auto-generación
mgr-error-priority = Error al obtener la preferencia de prioridad
mgr-error-priority-value = Error al obtener la prioridad de ruta
mgr-error-patterns = Error al obtener la preferencia de patrones i18n
mgr-error-patterns-value = Error al obtener los patrones i18n
mgr-error-confirmation = Error al obtener la confirmación
mgr-error-generation = Error en la generación de página
mgr-error-at-least-one-language = Debe seleccionarse al menos un idioma
# Validation errors
mgr-validation-name-empty = El nombre de página no puede estar vacío
mgr-validation-name-case = El nombre de página debe empezar con letra mayúscula (PascalCase)
mgr-validation-name-chars = El nombre de página debe contener solo letras ASCII y números
mgr-validation-name-length = Nombre de página demasiado largo (máximo 50 caracteres)
mgr-validation-name-reserved = '{$name}' es un nombre reservado, por favor elige otro
mgr-validation-path-empty = La ruta de página no puede estar vacía
mgr-validation-path-start = La ruta de página debe empezar con '/'
mgr-validation-path-end = La ruta de página no puede terminar con '/' (excepto raíz)
mgr-validation-path-consecutive = La ruta de página no puede contener barras consecutivas
mgr-validation-path-chars = La ruta de página debe contener solo caracteres ASCII y sin espacios
mgr-validation-path-reserved = '{$path}' conflicta con rutas reservadas
mgr-validation-priority-range = La prioridad debe estar entre 0.1 y 10.0
mgr-validation-priority-number = La prioridad debe ser un número válido

View file

@ -0,0 +1,83 @@
# Dashboard Menu Translations - Spanish
# Dashboard
dashboard-title = Panel de Desarrollo Rustelo
dashboard-description = Kit integral de herramientas de desarrollo para proyectos Rustelo
# Categories
category-content-name = Gestión de Contenido
category-content-description = Crear, editar y gestionar contenido
category-development-name = Herramientas de Desarrollo
category-development-description = Utilidades de desarrollo y depuración
category-system-name = Sistema y APIs
category-system-description = Información del sistema y gestión de APIs
category-deployment-name = Construcción y Despliegue
category-deployment-description = Herramientas de construcción, pruebas y despliegue
# Content Management Actions
action-create-page-name = Crear Nueva Página
action-create-page-description = Generar una nueva página con plantillas, rutas y traducciones
action-edit-content-name = Editar Contenido Estático
action-edit-content-description = Editar páginas existentes, posts de blog y recetas
action-manage-translations-name = Gestionar Traducciones
action-manage-translations-description = Editar archivos de traducción FTL para múltiples idiomas
action-content-preview-name = Vista Previa de Contenido
action-content-preview-description = Previsualizar contenido con recarga en caliente
# Development Tools Actions
action-dev-server-name = Iniciar Servidor de Desarrollo
action-dev-server-description = Lanzar servidor de desarrollo con recarga automática
action-css-watch-name = Modo de Vigilancia CSS
action-css-watch-description = Vigilar y reconstruir archivos CSS automáticamente
action-browser-logs-name = Registros del Navegador
action-browser-logs-description = Recopilar y ver registros de consola del navegador
action-code-format-name = Formatear Código
action-code-format-description = Formatear código Rust con rustfmt y clippy
# System & APIs Actions
action-show-apis-name = Mostrar APIs Disponibles
action-show-apis-description = Listar todos los endpoints de API y rutas disponibles
action-system-info-name = Información del Sistema
action-system-info-description = Mostrar información del sistema, entorno y configuración
action-route-inspector-name = Inspector de Rutas
action-route-inspector-description = Inspeccionar y validar configuraciones de rutas
action-config-manager-name = Gestor de Configuración
action-config-manager-description = Ver y editar archivos de configuración
# Build & Deploy Actions
action-build-prod-name = Construcción de Producción
action-build-prod-description = Construir para despliegue en producción
action-run-tests-name = Ejecutar Pruebas
action-run-tests-description = Ejecutar todas las pruebas y controles de calidad
action-quality-check-name = Control de Calidad
action-quality-check-description = Ejecutar controles de calidad integrales
action-deploy-status-name = Estado del Despliegue
action-deploy-status-description = Verificar estado y salud del despliegue
# Quick Actions
action-help-name = Ayuda
action-help-description = Mostrar atajos de teclado y ayuda
action-refresh-name = Actualizar
action-refresh-description = Actualizar datos del panel
action-settings-name = Configuración
action-settings-description = Configuración y preferencias del panel
action-quit-name = Salir
action-quit-description = Salir del panel
# UI Elements
ui-items = Elementos
ui-items-focused = Elementos [ENFOCADO]
ui-subitems = Sub-elementos
ui-subitems-focused = Sub-elementos [ENFOCADO]
ui-quick-actions = Acciones Rápidas
ui-action-result = Resultado
ui-language-selector = Idioma
ui-chat-prompt = Preguntar a Asistente IA
ui-chat-placeholder = Presiona '/' para escribir o Tab para enfocar...
ui-send-button = Enviar
ui-clear-button = Limpiar
ui-ai-button = IA
ui-copy-button = Copiar
ui-space-switch = Espacio Cambiar Panel
ui-chat-focus = Chat
ui-f12-language = F12 Idioma

View file

@ -0,0 +1,10 @@
# Página Sobre Nosotros — traducciones base (Español)
about-page-title = Sobre Nosotros
about-page-description = Conoce más sobre nosotros
about-page-keywords = sobre nosotros, equipo, misión
about-page-subtitle = Nuestra historia y misión
about-mission-title = Nuestra Misión
about-mission-text = Describe tu misión aquí.
about-team-title = El Equipo
about-values-title = Nuestros Valores

View file

@ -0,0 +1,21 @@
# Página de Contacto — traducciones base (Español)
contact-page-title = Contacto
contact-page-description = Ponte en contacto con nosotros
contact-page-keywords = contacto, consulta
contact-page-subtitle = Nos encantaría escucharte.
contact-send-message = Enviar un Mensaje
contact-name-label = Nombre
contact-name-placeholder = Tu nombre completo
contact-email-label = Correo electrónico
contact-email-placeholder = tu.correo@ejemplo.com
contact-subject-label = Asunto
contact-subject-placeholder = Descripción breve de tu consulta
contact-message-label = Mensaje
contact-message-placeholder = Cuéntanos sobre tu proyecto o pregunta.
contact-submit-btn = Enviar Mensaje
contact-submit-success = ¡Mensaje enviado! Te responderemos pronto.
contact-lets-connect = Conectemos
contact-email-contact = Correo electrónico
contact-response-time = Tiempo de respuesta
contact-response-time-text = Normalmente respondemos en 24 horas en días laborables.

View file

@ -0,0 +1,125 @@
# Traducciones de Tipos de Contenido
# Traducciones dinámicas para diferentes tipos de contenido
# Nombres de tipos de contenido
content-kind-blog = Blog
content-kind-recetas = Recetas
content-kind-content = Contenido
content-kind-tutoriales = Tutoriales
# Descripciones de tipos de contenido
content-kind-blog-description = Artículos técnicos e ideas
content-kind-recetas-description = Soluciones y mejores prácticas
content-kind-content-description = Contenido general y documentación
content-kind-tutoriales-description = Tutoriales paso a paso
# Títulos de página
content-kind-blog-page-title = { $title ->
[blog] Blog - { $site_name }
*[other] { $title } - { $site_name }
}
content-kind-recetas-page-title = { $title ->
[recetas] Recetas - { $site_name }
*[other] { $title } - { $site_name }
}
# Etiquetas de navegación
content-nav-back-to = { $content_kind ->
[blog] Volver al Blog
[recetas] Volver a Recetas
[content] Volver a Contenido
[tutoriales] Volver a Tutoriales
*[other] Volver a { $content_kind }
}
# Soporte de características
content-supports-features = { $content_kind ->
[blog] true
[tutoriales] true
*[other] false
}
content-supports-emojis = { $content_kind ->
[recetas] true
*[other] false
}
# Modos de estilo
content-style-mode = { $content_kind ->
[blog] row
[recetas] grid
[content] row
[tutoriales] row
*[other] row
}
# Vistas CTA
content-cta-view = { $content_kind ->
[blog] BlogCTAView
[recetas] RecetasCTAView
[tutoriales] TutorialesCTAView
*[other] DefaultCTAView
}
# Mensajes de contenido compartidos (usados por UnifiedContentGrid)
content-no-items-found = No se encontraron items
content-all-categories = Todas las Categorías
# Interfaz de Gestor de Contenido
content-manager-title = Gestión de Contenido
content-manager-new-content = Nuevo Contenido
content-manager-filter-type = Tipo de Contenido:
content-manager-filter-language = Idioma:
content-manager-all-types = Todos los Tipos
content-manager-all-languages = Todos los Idiomas
content-manager-refresh = Actualizar
# Encabezados de Tabla del Gestor de Contenido
content-manager-table-title = Título
content-manager-table-type = Tipo
content-manager-table-language = Idioma
content-manager-table-status = Estado
content-manager-table-updated = Actualizado
content-manager-table-actions = Acciones
# Estado del Gestor de Contenido
content-manager-status-published = Publicado
content-manager-status-draft = Borrador
# Acciones del Gestor de Contenido
content-manager-action-edit = Editar
content-manager-action-publish = Publicar
content-manager-action-unpublish = Despublicar
content-manager-action-delete = Eliminar
# Notas del Gestor de Contenido
content-manager-note-label = Nota:
content-manager-note-text = Esta es una maqueta de la interfaz de gestión de contenido. La funcionalidad completa con integración de API se implementará en futuras actualizaciones.
# Datos de Muestra del Gestor de Contenido
content-manager-sample-category = General
content-manager-sample-blog-title = Publicación de Blog de Ejemplo
content-manager-sample-blog-content = # Publicación de Blog de Ejemplo\n\nEsta es una publicación de blog de ejemplo para fines de demostración.
content-manager-sample-blog-excerpt = Una publicación de blog de ejemplo que demuestra el sistema de gestión de contenido.
content-manager-sample-recipes-title = Receta de Ejemplo
content-manager-sample-recipes-content = # Receta de Ejemplo\n\n## Ingredientes\n- Ingrediente de ejemplo 1\n- Ingrediente de ejemplo 2\n\n## Instrucciones\n1. Paso de ejemplo 1\n2. Paso de ejemplo 2
content-manager-sample-recipes-excerpt = Una receta de ejemplo que demuestra el sistema de gestión de contenido.
# Nombres de Idiomas
language-en = English
language-es = Español
# Nombres de Tipos de Contenido (reservas si no están en content-kinds)
content-kind-recipes = Recetas
# Contenido del Blog
content-featured-post = Publicación Destacada
content-recent-posts = Publicaciones Recientes
content-read-full-article = Leer artículo completo →
content-read-more = Leer más →
content-back-to-all-posts = ← Volver a todas las publicaciones
content-failed-to-load = Fallo al cargar el contenido
content-view-all = Ver todo
content-related-view-all = Relacionados: Ver todo

View file

@ -0,0 +1,22 @@
# Página de inicio — traducciones base (Español)
# Sobreescribe cualquier clave en site/i18n/locales/es/pages/home.ftl
# Metadatos
home-page-title = Inicio
home-page-description = Bienvenido a nuestro sitio web
home-page-keywords = inicio, bienvenida
# Hero
home-hero-title = Bienvenido
home-hero-subtitle = El eslogan de tu sitio va aquí.
home-cta-primary = Comenzar
home-cta-secondary = Saber más
# Sección de características
home-features-title = Lo que ofrecemos
home-features-subtitle = Áreas clave de enfoque
# Llamada a la acción
home-cta-title = ¿Listo para comenzar?
home-cta-description = Contáctanos para discutir cómo podemos ayudarte.
home-cta-button = Contáctanos

View file

@ -0,0 +1,28 @@
# Traducciones de la página 404 No Encontrada
# Metadatos de Página
not-found-page-title = Página No Encontrada
not-found-page-description = La página solicitada no pudo ser encontrada
not-found-page-keywords = 404, no encontrado, error
# Título y contenido de la página
not-found-title = Página No Encontrada
not-found-description = Lo sentimos, la página que busca no existe o ha sido movida.
# Botones de navegación
not-found-go-home = Ir al Inicio
not-found-contact-us = Contáctanos
not-found-back-to-blog = Volver al Blog
not-found-back-to-prescriptions = Volver a Prescripciones
not-found-sign-in = Iniciar Sesión
# URLs de navegación
not-found-home-url = /inicio
not-found-contact-url = /contacto
not-found-blog-url = /blog
not-found-prescriptions-url = /prescripciones
not-found-login-url = /iniciar-sesion
# Texto de ayuda adicional (opcional)
not-found-section-title = ¿Qué puedes hacer?
not-found-help-text = Puede intentar volver a la página de inicio o contactarnos si cree que esto es un error.

View file

@ -0,0 +1,23 @@
# Traducciones de la página de visualización de artículos
# Estructura del contenido del artículo
post-title =
post-subtitle =
post-content =
post-author = Autor
post-date = Fecha de Publicación
post-category = Categoría
post-tags = Etiquetas
# Navegación y acciones
post-back-to-blog = Volver al Blog
post-back-to-recetas = Volver a Recetas
post-back-to-content = Volver a Contenido
post-back-to-tutoriales = Volver a Tutoriales
post-share = Compartir este artículo
post-print = Imprimir artículo
# Metadatos
post-reading-time = Tiempo de lectura
post-difficulty = Dificultad
post-last-updated = Última actualización

View file

@ -0,0 +1,131 @@
# Content Types Configuration Contracts
#
# Type contracts for content-kinds configuration with comprehensive validation
# and documentation for each field.
{
# Content features configuration
ContentFeatures = {
style_mode
| String
| doc "Display mode for content (e.g., 'row', 'grid', 'masonry')",
style_css
| String
| doc "CSS class prefix for styling (e.g., 'blog-content', 'recipe-content')",
use_feature
| Bool
| doc "Enable featured content highlighting",
use_categories
| Bool
| doc "Enable category organization and filtering",
use_tags
| Bool
| doc "Enable tag-based classification and search",
use_emojis
| Bool
| doc "Enable emoji support in content display",
cta_view
| String
| doc "Call-to-action component name (e.g., 'BlogCTAView', 'RecipesCTAView')",
# Pagination features
default_page_size
| Number
| doc "Default number of items per page"
| optional,
page_size_options
| Array Number
| doc "Available page size options for users (e.g., [5, 10, 20])"
| optional,
show_page_info
| Bool
| doc "Display pagination information (e.g., 'Showing 1-10 of 50')"
| optional,
pagination_style
| String
| doc "Pagination UI style (e.g., 'numbered', 'simple', 'infinite')"
| optional,
# Specialized features (for tutorial content, etc.)
show_difficulty
| Bool
| doc "Display difficulty level indicator"
| optional,
show_duration
| Bool
| doc "Display estimated time/duration"
| optional,
show_prerequisites
| Bool
| doc "Display required prerequisites"
| optional,
},
# Content kind/type configuration
ContentKind = {
name
| String
| doc "Unique identifier for this content type (e.g., 'blog', 'recipes')",
directory
| String
| doc "Directory name where content is stored (relative to content root)",
enabled
| Bool
| doc "Whether this content type is active and should be processed"
| default = true,
is_default
| Bool
| doc "Whether this is the default/fallback content type"
| optional,
specialized
| Bool
| doc "Whether this content type has specialized handling (may have schema differences)"
| optional,
features
| ContentFeatures
| doc "Feature configuration for this content type",
},
# Root configuration structure
ContentKindsFile = {
content_kinds
| Array ContentKind
| doc "Array of content type configurations",
},
# Taxonomy entry used for categories and tags
TaxonomyEntry = {
id | String,
slug | String,
emoji | String | default = "",
icon | String | default = "",
},
# Standalone content metadata file (blog.ncl, recipes.ncl, etc.)
ContentMetadata = {
content_type = {
id | String,
slug | String,
name | String,
description | String,
},
categories | Array TaxonomyEntry | default = [],
tags | Array TaxonomyEntry | default = [],
},
}

View file

@ -0,0 +1,199 @@
# Content Types Configuration Defaults and Helpers
#
# Provides shared default values and helper functions for content type creation.
# Reduces duplication by providing common patterns.
let contracts = import "contracts.ncl" in
{
# Base content features with sensible defaults
# Use this as foundation via merge operator (&)
# Note: | default = allows overriding via merge
base_features = {
style_mode | default = "row",
use_feature | default = false,
use_categories | default = true,
use_tags | default = true,
use_emojis | default = false,
default_page_size | default = 12,
page_size_options | default = [6, 12, 24],
show_page_info | default = true,
pagination_style | default = "numbered",
},
# Base content kind with common defaults
base_content_kind = {
enabled | default = true,
is_default | default = false,
specialized | default = false,
},
# Helper function: Create a basic content type with minimal required fields
#
# Usage:
# make_content_kind "blog" "blog"
# & { features = { style_css = "blog-content", cta_view = "BlogCTAView" } }
#
# Parameters:
# kind_name: Content type name (e.g., "blog", "recipes")
# dir_name: Directory name where content is stored
#
# Returns: ContentKind record with defaults + required fields
make_content_kind
| String -> String -> Dyn
= fun kind_name dir_name =>
base_content_kind & {
name = kind_name,
directory = dir_name,
features = base_features & {
style_css | default = "%{kind_name}-content",
cta_view | default = "DefaultCTAView",
},
},
# Helper function: Create a blog-style content type
#
# Blog characteristics:
# - Row layout
# - Featured content
# - Categories and tags
# - Emojis enabled
# - Smaller page sizes
#
# Usage:
# make_blog_type "blog" "blog" "BlogCTAView"
#
# Parameters:
# kind_name: Content type name
# dir_name: Directory name
# cta_view_name: CTA view component name
#
# Returns: ContentKind configured for blog-style content
make_blog_type
| String -> String -> String -> Dyn
= fun kind_name dir_name cta_view_name =>
make_content_kind kind_name dir_name & {
features = base_features & {
style_mode = "row",
style_css = "%{kind_name}-content",
use_feature = true,
use_emojis = true,
cta_view = cta_view_name,
default_page_size = 8,
page_size_options = [5, 8, 12, 20],
},
},
# Helper function: Create a grid-style content type (recipes, portfolio, etc.)
#
# Grid characteristics:
# - Grid layout
# - No featured content typically
# - Categories (may or may not use tags)
# - Larger page sizes
#
# Usage:
# make_grid_type "recipes" "recipes" "RecipesCTAView"
# & { features.use_emojis = true }
#
# Parameters:
# kind_name: Content type name
# dir_name: Directory name
# cta_view_name: CTA view component name
#
# Returns: ContentKind configured for grid-style content
make_grid_type
| String -> String -> String -> Dyn
= fun kind_name dir_name cta_view_name =>
make_content_kind kind_name dir_name & {
features = base_features & {
style_mode = "grid",
style_css = "%{kind_name}-content",
use_feature | default = false,
cta_view = cta_view_name,
default_page_size = 12,
page_size_options = [6, 12, 18, 24],
},
},
# Helper function: Create a tutorial/course content type
#
# Tutorial characteristics:
# - Row layout
# - Featured content
# - Shows difficulty, duration, prerequisites
# - Categories and tags
#
# Usage:
# make_tutorial_type "tutorials" "tutorials" "TutorialsCTAView"
#
# Parameters:
# kind_name: Content type name
# dir_name: Directory name
# cta_view_name: CTA view component name
#
# Returns: ContentKind configured for tutorial content
make_tutorial_type
| String -> String -> String -> Dyn
= fun kind_name dir_name cta_view_name =>
make_content_kind kind_name dir_name & {
specialized = true,
features = base_features & {
style_mode = "row",
style_css = "%{kind_name}-content",
use_feature = true,
cta_view = cta_view_name,
show_difficulty = true,
show_duration = true,
show_prerequisites = true,
},
},
# Helper function: Create the default/fallback content type
#
# Default content type is used when no specific type matches.
# Typically has minimal features enabled.
#
# Usage:
# make_default_content "content" "content"
#
# Parameters:
# kind_name: Content type name (usually "content")
# dir_name: Directory name
#
# Returns: ContentKind configured as default type
make_default_content
| String -> String -> Dyn
= fun kind_name dir_name =>
make_content_kind kind_name dir_name & {
is_default = true,
features = base_features & {
style_css = "default-content",
cta_view = "DefaultCTAView",
},
},
# Common style modes
style_modes = {
row = "row",
grid = "grid",
masonry = "masonry",
list = "list",
},
# Common pagination styles
pagination_styles = {
numbered = "numbered",
simple = "simple",
infinite = "infinite",
load_more = "load_more",
},
# Common page size presets
page_sizes = {
small = [5, 10, 15, 20],
medium = [8, 12, 20, 30],
large = [12, 24, 36, 48],
cards = [6, 12, 18, 24],
},
}

View file

@ -0,0 +1,99 @@
# Activity Metadata Schema
#
# Extends PostMetadata with activity-specific fields (talks, workshops, events).
# The `metadata` field carries event-specific data: type, location, slides, downloads.
#
# Usage — preferred (make_activity factory):
#
# let schema = import "content/metadata/activity_metadata.ncl" in
# schema.make_activity {
# id = "rust-async-patterns-2026",
# title = "Rust Async Patterns in Production",
# slug = "rust-async-patterns",
# date = "2026-03-15",
# category = "talks",
# }
let c = import "contracts.ncl" in
let post = import "post_metadata.ncl" in
let d = import "defaults.ncl" in
{
ActivityEventMetadata = {
event_type
| String
| doc "Activity kind: 'talk' | 'workshop' | 'webinar' | 'panel' | 'other'"
| optional,
event_date
| String
| c.DateContract
| doc "Date of the event (ISO 8601). May differ from the post date."
| optional,
event_location
| String
| doc "City or venue name. Empty string or absent for online events."
| optional,
slides_id
| String
| doc "Identifier used to derive the slides URL or file path."
| optional,
pdf_filename
| String
| doc "Filename of the downloadable PDF (relative to the slides asset directory)."
| optional,
repository_url
| String
| doc "URL to the companion source code repository."
| optional,
questionnaire_type
| String
| doc "Gating mechanism for downloads: 'internal' | 'external' | 'none'."
| optional,
questionnaire_config
| String
| doc "Identifier of the questionnaire configuration used for gating."
| optional,
download_requires_auth
| Bool
| doc "Require authenticated session before allowing download."
| default = false,
download_requires_questionnaire
| Bool
| doc "Require questionnaire completion before allowing download."
| default = false,
gallery
| Array String
| doc "List of image filenames or URLs for the activity photo gallery."
| default = [],
..
},
ActivityMetadata = post.PostMetadata & {
sort_order
| Number
| doc "Display order within the category listing. Lower values appear first."
| default = 100,
metadata
| ActivityEventMetadata
| doc "Activity-specific event metadata (location, slides, downloads, etc.)."
| default = {},
},
make_activity
| not_exported
| doc "Create a validated activity metadata record. Provide at minimum: title, slug, date, category."
= fun overrides =>
d & overrides | ActivityMetadata,
}

View file

@ -0,0 +1,90 @@
# Content Item Metadata Contracts
#
# Primitive validation contracts for post metadata fields.
# Imported by post_metadata.ncl and recipe_metadata.ncl.
#
# All contracts are | not_exported — accessible via import but excluded
# from JSON export. Nickel evaluates contracts at validation time,
# not at export time, so this is safe.
{
SlugContract
| not_exported
| doc "URL-safe slug: lowercase letters, digits, and hyphens only. No leading/trailing hyphens."
= std.contract.custom (
fun label =>
fun value =>
if std.string.is_match "^[a-z0-9]([a-z0-9-]*[a-z0-9])?$" value then
'Ok value
else
'Error {
message = "Invalid slug '%{value}'.\nValid: lowercase letters, digits, hyphens only. E.g. 'my-rust-post-2024'"
}
),
DateContract
| not_exported
| doc "ISO 8601 calendar date only — no time component."
= std.contract.custom (
fun label =>
fun value =>
if std.string.is_match "^[0-9]{4}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$" value then
'Ok value
else
'Error {
message = "Invalid date '%{value}'.\nValid format: YYYY-MM-DD (e.g. '2024-01-15')"
}
),
NonEmptyString
| not_exported
| doc "Rejects empty strings. Allows any non-empty content."
= std.contract.custom (
fun label =>
fun value =>
if (value |> std.string.length) > 0 then
'Ok value
else
'Error {
message = "Invalid value: empty string not allowed.\nProvide at least one character."
}
),
DifficultyLevel
| not_exported
| doc "Skill level required for recipes and tutorials."
= std.contract.custom (
fun label =>
fun value =>
if std.array.elem value ["beginner", "intermediate", "advanced"] then
'Ok value
else
'Error {
message = "Invalid difficulty '%{value}'.\nValid values: beginner | intermediate | advanced"
}
),
ReadTimeContract
| not_exported
| doc "Human-readable duration string. Must contain a digit."
= std.contract.custom (
fun label =>
fun value =>
if (std.string.is_match "[0-9]" value) && ((std.string.length value) > 0) then
'Ok value
else
'Error {
message = "Invalid read_time '%{value}'.\nMust contain a number (e.g. '8 min read', '2 hours')"
}
),
TranslationEntry = {
id
| String
| doc "Stable identifier of the translated post (shared across all language versions).",
slug
| String
| doc "URL slug of the translated post in its target language.",
},
}

View file

@ -0,0 +1,22 @@
# Site-Level Content Metadata Defaults — Layer 1
#
# All fields carry | default (LOW priority) so they can be overridden
# by normal-priority values in any & merge without conflict.
#
# Used by make_post and make_recipe in their respective schema files:
#
# let d = import "defaults.ncl" in
# d & overrides | PostMetadata
#
# Required fields (title, slug, date, category) are NOT here —
# they have no site-wide default and must be provided per-post.
{
author | default = "Jesús Pérez Lorenzo",
published | default = true,
featured | default = false,
draft | default = false,
sort_order | default = 0,
tags | default = [],
translations | default = {},
}

View file

@ -0,0 +1,24 @@
# Example: a concrete blog post metadata file.
#
# Uses make_post (Layer 2 overrides) — only provide fields that differ
# from defaults. The factory merges site defaults (Layer 1) then
# applies PostMetadata as a contract for type checking.
#
# Validate: nickel export example_post.ncl --format json | jq .
# Should produce a complete record with all defaults filled in.
let schema = import "post_metadata.ncl" in
schema.make_post {
title = "Modern Rust Web Development in 2024",
slug = "modern-rust-web-development-in-2024",
date = "2024-01-15",
category = "rust",
subtitle = "A comprehensive guide to building scalable web applications",
excerpt = "Explore the latest trends in Rust web development.",
content_file = "rust-web-development-2024.md",
read_time = "8 min read",
featured = true,
tags = ["rust", "web-development", "leptos", "axum"],
translations = { es = "desarrollo-web-rust-2024" },
}

View file

@ -0,0 +1,54 @@
# Graph Relationship Contracts
#
# Contracts for the `graph` field present in all content metadata schemas.
# Consumed exclusively by the content-graph rustelo feature to build a
# knowledge graph connecting content items with each other and with on+re
# ontology nodes (Axiom, Tension, Practice, Project) and ADRs.
#
# The `graph` field and all its sub-arrays are optional with empty defaults.
# When the content-graph feature is not active, the build system ignores them
# entirely — zero cost for implementations that do not enable the feature.
#
# Relationship semantics
# ----------------------
# implements → this content demonstrates or realises an ontology node
# related-to → symmetric peer relationship (content ↔ content or content ↔ node)
# part-of → hierarchy: this item belongs to a parent project or series
# references → citation without implying implementation (e.g. post cites an ADR)
# extends → specialisation: this item builds upon another
{
RelationshipKind
| not_exported
| doc "Valid edge kinds from a content item to another node (content or ontology)."
= std.contract.from_predicate (fun k =>
std.array.elem k ["implements", "related-to", "part-of", "references", "extends"]
),
GraphMeta = {
implements
| Array String
| doc "Ontology node IDs (Practice, Project, Axiom, ADR id) this content implements or demonstrates."
| default = [],
related_to
| Array String
| doc "Content item IDs or ontology node IDs with a symmetric relationship to this item."
| default = [],
part_of
| Array String
| doc "Parent content item or project IDs. Expresses hierarchy (e.g. a recipe that belongs to a project)."
| default = [],
references
| Array String
| doc "IDs cited or linked without implying implementation. E.g. an ADR referenced by a blog post."
| default = [],
extends
| Array String
| doc "IDs of content items this item specialises or builds upon."
| default = [],
},
}

View file

@ -0,0 +1,156 @@
# Post Metadata Schema
#
# Canonical contract for any content item (blog, article, page, etc.).
# Consumed by:
# - ContentIndexer: validates *.ncl files, builds in-memory index
# - TypeDialog: generates web/CLI/TUI forms from this schema
# - DB migration: struct maps directly to posts table
#
# Usage — preferred (make_post factory):
#
# let schema = import "site/nickel/content/metadata/post_metadata.ncl" in
# schema.make_post {
# title = "My Rust Post",
# slug = "my-rust-post",
# date = "2024-01-15",
# category = "rust",
# }
#
# The factory merges site defaults (Layer 1) with the provided overrides
# (Layer 2) then applies PostMetadata as a contract for type checking.
let c = import "contracts.ncl" in
let d = import "defaults.ncl" in
let g = import "graph_contracts.ncl" in
{
PostMetadata = {
# --- Identity ---
id
| String
| c.NonEmptyString
| doc "Unique stable identifier. Inferred from filename stem when absent. Never changes after publication."
| optional,
title
| String
| c.NonEmptyString
| doc "Post title shown in listings, headings, and browser tab",
slug
| String
| c.SlugContract
| doc "URL path segment: lowercase, hyphens only. E.g. 'my-rust-post-2024'",
subtitle
| String
| doc "Secondary heading displayed under the title in the post view"
| optional,
excerpt
| String
| doc "Short summary (1-3 sentences) for index listings and SEO meta description"
| optional,
# --- Content file reference ---
content_file
| String
| doc "Filename of the content body relative to this *.ncl file. Inferred from slug when absent."
| optional,
# --- Publication ---
author
| String
| c.NonEmptyString
| doc "Author display name shown in post header and listings"
| default = "Jesús Pérez Lorenzo",
date
| String
| c.DateContract
| doc "Original publication date in YYYY-MM-DD format",
updated_at
| String
| c.DateContract
| doc "Date of last significant update in YYYY-MM-DD format"
| optional,
published
| Bool
| doc "False hides the post from all index listings and the API"
| default = true,
featured
| Bool
| doc "Pins to the top of the category listing and enables featured styling"
| default = false,
draft
| Bool
| doc "Draft posts are excluded from the index and not served by /api/content-render"
| default = false,
# --- Taxonomy ---
category
| String
| c.NonEmptyString
| doc "Primary category slug — must match the parent directory name (e.g. 'rust', 'devops')",
tags
| Array String
| doc "Secondary classification tags for filtering and related-content discovery"
| default = [],
# --- Display hints ---
read_time
| String
| c.ReadTimeContract
| doc "Estimated reading time for the listing card. E.g. '8 min read'. Auto-derived from word count when absent."
| optional,
sort_order
| Number
| doc "Manual position within the category listing. Lower values appear first. 0 = no preference."
| default = 0,
# --- SEO ---
image_url
| String
| doc "Open Graph and social share image. Absolute URL or root-relative path."
| optional,
thumbnail
| String
| doc "Card thumbnail image. Absolute URL or root-relative path. Falls back to image_url when absent."
| optional,
# --- Multilingual ---
translations
| { _ : c.TranslationEntry }
| doc "Map of language-code → { id, slug } for the translation. E.g. { es = { id = 'my-post', slug = 'mi-post-en-rust' } }."
| default = {},
# --- Knowledge graph ---
graph
| g.GraphMeta
| doc "Explicit relationships to other content items and on+re ontology nodes. Consumed by the content-graph feature. Ignored when the feature is not active."
| default = {},
},
# Factory function — Layer 1 (defaults) & Layer 2 (overrides) | contract.
# Not exported to JSON; accessible only via Nickel import.
make_post
| not_exported
| doc "Create a validated post metadata record. Provide at minimum: title, slug, date, category."
= fun overrides =>
d & overrides | PostMetadata,
}

View file

@ -0,0 +1,49 @@
# Projects Metadata Schema
#
# Extends PostMetadata with project-specific fields: thumbnails,
# page route, and optional source HTML for embedded project pages.
#
# Usage — preferred (make_project factory):
#
# let schema = import "content/metadata/projects_metadata.ncl" in
# schema.make_project {
# id = "my-project",
# title = "My Project",
# slug = "my-project",
# date = "2024-01-01",
# page_route = "/my-project",
# }
let c = import "contracts.ncl" in
let post = import "post_metadata.ncl" in
let d = import "defaults.ncl" in
{
ProjectMetadata = post.PostMetadata & {
page_route
| String
| doc "Absolute URL path for the project's dedicated page. E.g. '/my-project'."
| optional,
thumbnail_dark
| String
| doc "Dark-mode card thumbnail. Root-relative path. Falls back to thumbnail when absent."
| optional,
source_html
| String
| doc "Absolute filesystem path to an external HTML file embedded in the project page."
| optional,
sort_order
| Number
| doc "Display order in the projects listing. Lower values appear first."
| default = 100,
},
make_project
| not_exported
| doc "Create a validated project metadata record. Provide at minimum: title, slug, date."
= fun overrides =>
d & { category | default = "projects" } & overrides | ProjectMetadata,
}

View file

@ -0,0 +1,57 @@
# Recipe Metadata Schema
#
# Extends PostMetadata with recipe/tutorial-specific fields.
# The additional fields map to `specialized = true` content types
# configured in content-kinds.ncl.
#
# Usage — preferred (make_recipe factory):
#
# let schema = import "site/nickel/content/metadata/recipe_metadata.ncl" in
# schema.make_recipe {
# title = "Async Error Handling in Rust",
# slug = "async-error-handling-in-rust",
# date = "2024-01-12",
# category = "async-programming",
# difficulty = "intermediate",
# duration = "30 min",
# }
let c = import "contracts.ncl" in
let post = import "post_metadata.ncl" in
let d = import "defaults.ncl" in
{
RecipeMetadata = post.PostMetadata & {
# --- Recipe-specific ---
difficulty
| String
| c.DifficultyLevel
| doc "Skill level required: 'beginner', 'intermediate', or 'advanced'"
| optional,
duration
| String
| c.ReadTimeContract
| doc "Total estimated time to follow the recipe. E.g. '30 min', '2 hours'."
| optional,
prerequisites
| Array String
| doc "Concepts, tools, or recipes the reader should know before starting"
| default = [],
tools
| Array String
| doc "Software, crates, or commands used in this recipe"
| default = [],
},
# Factory function — Layer 1 (defaults) & Layer 2 (overrides) | contract.
# Not exported to JSON; accessible only via Nickel import.
make_recipe
| not_exported
| doc "Create a validated recipe metadata record. Provide at minimum: title, slug, date, category."
= fun overrides =>
d & overrides | RecipeMetadata,
}

View file

@ -0,0 +1,183 @@
# Rustelo Implementation Dependency Contracts
#
# Schema for rustelo.deps.ncl — declares which framework artifacts this
# implementation uses, overrides, or replaces with local implementations.
#
# NOTE: Cargo dependencies (crate versions, features) are NOT declared here.
# Only artifact resolution: pages, components, i18n and Nickel config
# layering — things Cargo cannot express.
#
# NOTE: This file will move to rustelo/nickel/deps/contracts.ncl once the
# framework/implementation boundary is formalized.
let ValidArtifactSource = std.contract.from_predicate (fun s =>
s == "framework" || s == "local" || s == "override"
) in
let ValidFrameworkStrategy = std.contract.from_predicate (fun s =>
s == "path" || s == "git" || s == "crates.io"
) in
let ValidI18nStrategy = std.contract.from_predicate (fun s =>
s == "merge" || s == "replace" || s == "framework-only"
) in
let ValidNickelStrategy = std.contract.from_predicate (fun s =>
s == "merge" || s == "replace"
) in
{
# ---------------------------------------------------------------------------
# Framework source declaration
# ---------------------------------------------------------------------------
FrameworkSource = {
strategy
| String
| ValidFrameworkStrategy
| doc "How the framework is referenced: 'path' | 'git' | 'crates.io'",
path
| String
| doc "Relative path to local framework root (required when strategy = 'path')"
| optional,
git
| String
| doc "Git repository URL (required when strategy = 'git')"
| optional,
branch
| String
| doc "Git branch or tag (only when strategy = 'git')"
| optional,
},
# ---------------------------------------------------------------------------
# Artifact source — how a single page or component is resolved
# ---------------------------------------------------------------------------
ArtifactSource = {
source
| String
| ValidArtifactSource
| doc "Origin: 'framework' | 'local' | 'override'",
# Required when source = 'local'
crate
| String
| doc "Cargo crate name containing the component (e.g. 'website-pages')"
| optional,
component
| String
| doc "Rust struct/component name (e.g. 'Services', 'WorkRequest')"
| optional,
# Required when source = 'override'
path
| String
| doc "Relative path to the override file (e.g. 'crates/server/src/shell.rs')"
| optional,
},
# ---------------------------------------------------------------------------
# i18n layering strategy
# ---------------------------------------------------------------------------
I18nConfig = {
strategy
| String
| ValidI18nStrategy
| doc "How FTL bundles are composed: 'merge' | 'replace' | 'framework-only'",
extend
| String
| doc "Path to implementation FTL directory merged on top of framework bundles"
| optional,
},
# ---------------------------------------------------------------------------
# Nickel config layering strategy
# ---------------------------------------------------------------------------
NickelConfig = {
strategy
| String
| ValidNickelStrategy
| doc "How site config is composed: 'merge' | 'replace'",
entry
| String
| doc "Path to implementation config entry point (e.g. 'site/config/index.ncl')",
},
# ---------------------------------------------------------------------------
# Build generation controls
# ---------------------------------------------------------------------------
BuildConfig = {
generate_routing
| Bool
| doc "Generate server_routing.rs and client_routes.rs from declared pages + config"
| default = true,
generate_page_provider
| Bool
| doc "Generate page_provider_impl.rs from declared pages"
| default = true,
generate_shell
| Bool
| doc "Generate shell.rs from framework template (set false when shell = 'override')"
| default = true,
generate_resource_registry
| Bool
| doc "Generate resource_registry.rs for menus, themes, FTL registries"
| default = true,
generate_migrations
| Bool
| doc "Include framework base migrations alongside implementation migrations"
| default = true,
},
# ---------------------------------------------------------------------------
# Top-level contract
# ---------------------------------------------------------------------------
RusteloImplDeps = {
framework
| FrameworkSource
| doc "How this implementation depends on the Rustelo framework",
pages
| { _ : ArtifactSource }
| doc "Page artifact resolution: which pages come from framework vs local",
components
| { _ : ArtifactSource }
| doc "Component overrides (omit to use framework components directly)"
| default = {},
i18n
| I18nConfig
| doc "FTL translation bundle layering strategy",
config
| NickelConfig
| doc "Nickel site config layering strategy",
build
| BuildConfig
| doc "Build-time code generation controls"
| default = {
generate_routing = true,
generate_page_provider = true,
generate_shell = true,
generate_resource_registry = true,
generate_migrations = true,
},
},
}

View file

@ -0,0 +1,100 @@
# Email and OAuth configuration contracts
#
# SecretValue enforces that sensitive fields never contain plaintext:
# they must be empty, a '${VAR}' substitution token, or an '@blob' AES-GCM
# encrypted value. The Rust side resolves substitutions and decrypts blobs
# after loading — never write real secrets in version-controlled files.
let SecretValue
| doc "Sensitive value: must be empty, '${ENV_VAR}', or '@encrypted' — no plaintext"
= fun label value =>
if std.is_string value then
if value == ""
|| std.string.is_match "^\\$\\{[A-Za-z_][A-Za-z0-9_]*\\}$" value
|| std.string.is_match "^@" value
then value
else std.contract.blame_with_message
"Sensitive field must be empty, '${ENV_VAR}', or '@encrypted' — no plaintext"
label
else std.contract.blame_with_message "Expected a String" label
in
{
SecretValue = SecretValue,
EmailConfig = {
enabled
| Bool
| default = true,
provider
| String
| doc "Email backend: 'console', 'smtp', or 'sendgrid'"
| default = "console",
smtp_host
| String
| default = "smtp.gmail.com",
smtp_port
| Number
| default = 587,
smtp_username
| String
| doc "SMTP user — prefer '${SMTP_USERNAME}'"
| default = "",
smtp_password
| String
| doc "SMTP password — prefer '${SMTP_PASSWORD}' or '@encrypted'"
| default = "",
smtp_use_tls
| Bool
| default = false,
smtp_use_starttls
| Bool
| default = true,
sendgrid_api_key
| String
| doc "SendGrid API key — prefer '${SENDGRID_API_KEY}'"
| default = "",
sendgrid_endpoint
| String
| default = "https://api.sendgrid.com/v3/mail/send",
from_email
| String
| doc "Sender address — prefer '${EMAIL_FROM}'"
| default = "",
from_name
| String
| doc "Sender display name — prefer '${EMAIL_FROM_NAME}'"
| default = "",
template_dir
| String
| optional,
email_enabled
| Bool
| optional,
},
OAuthProvider = {
client_id | String | default = "",
client_secret | String | default = "",
redirect_uri | String | default = "",
},
OAuthConfig = {
enabled | Bool | default = false,
google | OAuthProvider | optional,
github | OAuthProvider | optional,
},
}

View file

@ -0,0 +1,162 @@
# Contracts (type schemas) for external-services.ncl
#
# Each service contract validates its own fields.
# ExternalServicesConfig uses an open record (..) so new services can be
# added to external-services.ncl without modifying this file.
{
NatsConfig = {
enabled | Bool,
url | String,
token | String,
connection_timeout | Number,
max_reconnects | Number,
reconnect_delay | Number,
subjects | { _ : String },
},
OllamaConfig = {
enabled | Bool,
base_url | String,
default_model | String,
request_timeout | Number,
stream | Bool,
keep_alive | String,
max_tokens | Number,
temperature | Number,
},
S3Config = {
enabled | Bool,
endpoint | String,
region | String,
bucket | String,
access_key | String,
secret_key | String,
path_style | Bool,
upload_prefix | String,
max_object_size | Number,
presign_ttl | Number,
},
# Admin NATS connection — separate credentials and namespace from the
# general app NATS. Uses nkeys credentials file instead of token.
NatsAdminNamespace = {
prefix
| String
| doc "Implementation-specific prefix, e.g. 'evol.website'. Never hardcoded in framework code.",
env
| String
| doc "Deployment environment tag: 'prod' | 'staging' | 'dev'. Override via ENV var in k8s.",
},
NatsAdminConsumer = {
name | String,
ack_policy | String | default = "Explicit",
filter_subjects | Array String | default = [],
..
},
NatsAdminJetStream = {
enabled
| Bool
| default = true,
stream_name
| String
| doc "JetStream stream that captures all admin subject traffic for audit log.",
retention_days
| Number
| doc "Message retention period in days."
| default = 30,
retention
| String
| doc "Retention policy: Limits | Interest | WorkQueue."
| default = "Limits",
storage
| String
| doc "Storage backend: File | Memory."
| default = "File",
max_messages
| Number
| doc "Max messages in stream. 0 = unlimited."
| default = 0,
max_bytes
| Number
| doc "Max bytes in stream. 0 = unlimited."
| default = 0,
consumers
| Array NatsAdminConsumer
| default = [],
},
NatsAdminConfig = {
enabled
| Bool
| default = false,
url
| String
| doc "NATS server URL. Use '${NATS_ADMIN_URL}' to read from env.",
credentials_file
| String
| doc "Path to nkeys credentials file mounted as k8s Secret (e.g. /etc/nats/admin.creds). Never inline the key here.",
connection_timeout
| Number
| doc "Connection timeout in seconds."
| default = 5,
max_reconnects
| Number
| default = 10,
reconnect_delay
| Number
| doc "Delay between reconnect attempts in seconds."
| default = 2,
namespace
| NatsAdminNamespace
| doc "Subject namespace. Full subject = '{prefix}.{env}.{subject_leaf}'.",
subjects
| { _ : String }
| doc "Admin subject leaves. Full paths assembled at runtime using namespace. Open record — implementations may extend.",
jetstream
| NatsAdminJetStream
| doc "JetStream configuration for audit log persistence.",
nkey_seed
| String
| doc "NKEY seed for server-to-server authentication. Empty string disables nkey auth."
| default = "",
require_signed_messages
| Bool
| doc "Reject messages not signed with a trusted NKEY."
| default = false,
trusted_nkeys
| Array String
| doc "List of trusted NKEY public keys for message signature verification."
| default = [],
},
ExternalServicesConfig = {
nats | NatsConfig,
nats_admin | NatsAdminConfig,
ollama | OllamaConfig,
s3 | S3Config,
..
},
}

View file

@ -0,0 +1,70 @@
# Feature flag configuration contracts
#
# Mirrors rustelo_server::config::FeatureConfig and its sub-structs.
# These flags enable/disable runtime behaviour — they are distinct from
# Cargo features which control compile-time code inclusion.
{
AuthFeatures = {
enabled | Bool | default = true,
jwt | Bool | default = true,
oauth | Bool | default = false,
two_factor | Bool | default = false,
sessions | Bool | default = true,
password_reset | Bool | default = true,
email_verification | Bool | default = false,
},
RbacFeatures = {
enabled | Bool | default = false,
database_access | Bool | default = false,
file_access | Bool | default = false,
content_access | Bool | default = false,
api_access | Bool | default = false,
categories | Bool | default = false,
tags | Bool | default = false,
caching | Bool | default = false,
audit_logging | Bool | default = false,
toml_config | Bool | default = false,
hierarchical_permissions | Bool | default = false,
dynamic_rules | Bool | default = false,
},
ContentFeatures = {
enabled | Bool | default = true,
markdown | Bool | default = true,
syntax_highlighting | Bool | default = false,
file_uploads | Bool | default = true,
versioning | Bool | default = false,
scheduling | Bool | default = false,
seo | Bool | default = true,
},
SecurityFeatures = {
csrf | Bool | default = true,
security_headers | Bool | default = true,
rate_limiting | Bool | default = true,
input_sanitization | Bool | default = true,
sql_injection_protection | Bool | default = true,
xss_protection | Bool | default = true,
content_security_policy | Bool | default = true,
},
PerformanceFeatures = {
response_caching | Bool | default = true,
query_caching | Bool | default = true,
compression | Bool | default = true,
connection_pooling | Bool | default = true,
lazy_loading | Bool | default = false,
background_tasks | Bool | default = true,
},
FeatureConfig = {
auth | AuthFeatures,
rbac | RbacFeatures,
content | ContentFeatures,
security | SecurityFeatures,
performance | PerformanceFeatures,
custom | { _ : Bool } | default = {},
},
}

View file

@ -0,0 +1,97 @@
# Footer Configuration Contracts
#
# Type contracts for footer configuration with multilingual support.
# Single source of truth for footer content, menu items, and social links.
{
# Bilingual text fields
BilingualText = {
en
| String
| doc "English text",
es
| String
| doc "Spanish text",
},
# Footer menu item (subset of main menu contract)
FooterMenuItem = {
id
| String
| doc "Unique identifier",
routes
| { en | String, es | String, .. }
| doc "Routes for each language",
labels
| BilingualText
| doc "Display labels for each language",
icon
| String
| optional,
priority
| Number
| doc "Display priority (negative for footer-only items)"
| default = 0,
is_external
| Bool
| default = false,
enabled
| Bool
| default = true,
..
},
# Social media link
SocialLink = {
name
| String
| doc "Social network name (e.g., 'LinkedIn', 'GitHub')",
url
| String
| doc "Full URL to social profile",
icon_class
| String
| doc "Icon class (e.g., 'fab fa-linkedin')",
enabled
| Bool
| default = true,
..
},
# Root footer configuration
FooterFile = {
title
| String
| doc "Footer title (same for all languages)",
company_desc
| BilingualText
| doc "Company description for each language",
copyright_text
| BilingualText
| doc "Copyright notice for each language",
menu
| Array FooterMenuItem
| doc "Footer-specific menu items (e.g., Privacy, Legal)",
social_links
| Array SocialLink
| doc "Social media links",
..
},
}

View file

@ -0,0 +1,81 @@
# Footer Configuration Defaults and Helpers
#
# Provides helper functions for creating footer items with minimal duplication.
let contracts = import "contracts.ncl" in
{
# Helper function: Create a footer menu item
#
# Usage:
# make_footer_item {
# id = "privacy",
# routes = { en = "/privacy", es = "/privacidad" },
# labels = { en = "Privacy Policy", es = "Política de Privacidad" },
# icon = "fas fa-shield-alt",
# }
#
# Parameters:
# config: Record with id, routes, labels, and optional icon/priority
#
# Returns: FooterMenuItem record
make_footer_item
| Dyn -> Dyn
= fun config =>
{
id = config.id,
routes = config.routes,
labels = config.labels,
is_external = false,
enabled = true,
priority = -1,
}
& (if std.record.has_field "icon" config then { icon = config.icon } else {})
& (if std.record.has_field "priority" config then { priority = config.priority } else {})
& (if std.record.has_field "enabled" config then { enabled = config.enabled } else {}),
# Helper function: Create a social link
#
# Usage:
# make_social_link "LinkedIn" "https://linkedin.com/in/user" "fab fa-linkedin"
#
# Parameters:
# social_name: Social network name
# profile_url: Full URL to profile
# icon: Icon class
#
# Returns: SocialLink record
make_social_link
| String -> String -> String -> Dyn
= fun social_name profile_url icon =>
{
name = social_name,
url = profile_url,
icon_class = icon,
enabled = true,
},
# Helper function: Create bilingual text
#
# Usage:
# bilingual "English text" "Texto español"
#
# Parameters:
# en_text: English text
# es_text: Spanish text
#
# Returns: BilingualText record
bilingual
| String -> String -> Dyn
= fun en_text es_text =>
{
en = en_text,
es = es_text,
},
# Common footer priorities
priorities = {
footer_only = -1,
hidden = -999,
},
}

View file

@ -0,0 +1,152 @@
# Form Contracts — declarative HTML forms, served and delivered WITHOUT a build.
#
# ─────────────────────────────────────────────────────────────────────────────
# WHY THIS EXISTS
#
# Before this, every form meant a typed `struct` in Rust, a handler, a `nest(...)` and a
# RECOMPILE of the shared image. So a form was the one part of a page you could not add: the
# template, the Fluent text, the content and (since `merge_site_routes`) the route were all
# already the consumer's. The endpoint was the last baked thing.
#
# The result was predictable and it is on the record: `work-request` — a public
# "request a project quote" form with eleven fields — has a template and NO handler.
# `POST /api/work-request` is registered nowhere in the stack. The page renders, the visitor
# fills it in, and the submission goes to a route that does not exist. It has been like that
# long enough that nobody noticed, because a form that renders LOOKS like a form that works.
#
# And the naive repair was worse. `ContactForm` (htmx_contact.rs) declares four fields and does
# NOT set `deny_unknown_fields`, so serde SILENTLY DROPS anything it does not know. Wiring
# work-request to it would have returned 200, said "sent", and delivered an email containing the
# name and the email address — with the project description, the budget and the timeline gone.
# A form that appears to work and throws the data away is worse than one that visibly fails.
#
# THE FIX IS THE SAME MOVE AS THE ROUTES. The form is DECLARED, in NCL, by the site. One generic
# handler reads the declaration and delivers what the declaration names. A new form is a `.ncl`
# and a template — no Rust, no build.
#
# And it can be NCL rather than TOML precisely because the runtime already reads NCL: the htmx
# shell loads `routes.ncl` and `footer.ncl` through `rustelo_config::format::load_config`, which
# dispatches `.ncl` to `nickel::export_to_json`. No new dependency; the one that is already there.
#
# WHAT THE CONTRACT BUYS OVER A BARE MAP. The handler accepts a `HashMap<String,String>` — no
# per-form struct, which is the whole point. The contract is what keeps that from becoming a
# free-for-all:
#
# · a field not in `fields` is REFUSED, not silently dropped. The failure mode that made this
# necessary cannot recur here — it is exactly what serde's unknown-field default did.
# · `required` is checked before delivery, not after.
# · `deliver_to` names an env var, never a literal address: a recipient in git is a recipient
# harvested.
# · every declared field appears in the composed body, in declaration order. What the form
# collects is what the owner receives.
# ─────────────────────────────────────────────────────────────────────────────
let field_kind = [|
'Text,
'Email,
'Textarea,
'Select,
# A honeypot. Rendered hidden; a non-empty value means a bot, and the handler confirms
# WITHOUT delivering, so the bot gets no signal that it was caught.
'Honeypot,
|] in
let field_type = {
# The form-encoded name. This is the contract with the template: a field the template posts
# and the spec does not declare is REFUSED by the handler.
name | String,
kind | field_kind | default = 'Text,
required | Bool | default = false,
# Fluent key for the label. The text lives where all text lives.
label_key | String | default = "",
# Fluent key for the placeholder / help text.
hint_key | String | default = "",
# For 'Select: the option values. Their labels are `<label_key>-<value>` in Fluent.
options | Array String | default = [],
# Included in the delivered body. False for a honeypot, and for anything the owner does not
# need to read. Default true: a field that is collected and NOT delivered is the bug this
# whole contract was written after.
deliver | Bool | default = true,
} in
let RequiredFieldsExist = std.contract.from_validator (fun spec =>
let names = spec.fields |> std.array.map (fun f => f.name) in
let missing = spec.required |> std.array.filter (fun r => !(std.array.elem r names)) in
if std.array.length missing == 0 then
'Ok
else
'Error {
message = "`required` names fields that are not declared: " ++ std.string.join ", " missing,
notes = ["A required field the form never renders can never be filled in."],
}
) in
# Nothing declared twice: two entries for one field name is not a form, it is a race between
# whichever the handler reads last.
let FieldNamesUnique = std.contract.from_validator (fun spec =>
let names = spec.fields |> std.array.map (fun f => f.name) in
let dups =
names
|> std.array.filter (fun n => (names |> std.array.filter (fun m => m == n) |> std.array.length) > 1)
in
if std.array.length dups == 0 then
'Ok
else
'Error { message = "duplicate field name: " ++ std.string.join ", " dups }
) in
# The recipient is an ENV VAR NAME, never an address. An address in a config file is an address
# in git, and an address in git is an address in a scraper's list.
let RecipientIsEnvVar = std.contract.from_validator (fun v =>
if std.string.is_match "^[A-Z][A-Z0-9_]*$" v then
'Ok
else
'Error {
message = "`deliver_to` must be an ENV VAR NAME (e.g. WORK_REQUEST_RECIPIENT), got: " ++ v,
notes = ["A literal address here is a literal address in the repository."],
}
) in
let form_spec_type = {
# Matches the URL: POST /api/htmx/form/<id>, and the file name `<id>.ncl`.
id | String,
# The env var holding the destination address. Unset at runtime ⇒ the submission is logged and
# the visitor still gets a confirmation: a form that 500s because the OWNER forgot to configure
# it punishes the wrong person.
deliver_to | String | RecipientIsEnvVar,
# The subject line. `{field}` interpolates a submitted value; an unknown field interpolates to
# nothing rather than failing, because a subject line is not worth losing a lead over.
subject | String | default = "",
# Fluent keys for the two response fragments the handler returns.
success_key | String | default = "",
error_key | String | default = "",
fields | Array field_type,
required | Array String | default = [],
} in
{
FieldKind = field_kind,
Field = field_type,
FormSpec = std.contract.Sequence [
form_spec_type,
RequiredFieldsExist,
FieldNamesUnique,
],
# Build a spec. `required` defaults to every field marked `required = true`, so the two cannot
# disagree — an earlier design had them as independent lists and that is a contradiction waiting.
make_form = fun data =>
# The contract must be applied to each field BEFORE reading `required` off it — a raw record
# carries no defaults, and `f.required` on one is a missing field, not `false`.
let flds = data.fields |> std.array.map (fun f => field_type & f) in
let req = flds |> std.array.filter (fun f => f.required) |> std.array.map (fun f => f.name) in
# `data` must lose its own `fields` before the merge: `data & { fields = <derived from
# data.fields> }` is a record referring to itself, and Nickel calls that what it is.
((std.record.remove "fields" data) & { fields = flds, required = req })
| (std.contract.Sequence [form_spec_type, RequiredFieldsExist, FieldNamesUnique]),
}

View file

@ -0,0 +1,201 @@
# Rustelo Manifest Contracts
#
# Type validation for rustelo.manifest.ncl files.
# Generated by `cargo rustelo new` from template + framework options.
# Evaluated at runtime by ManifestResolver (rustelo_utils::manifest).
#
# JSON output is deserialized into rustelo_utils::manifest::RusteloManifest.
{
ManifestMeta = {
version
| String
| doc "Manifest schema version",
schema
| String
| doc "Schema URL for IDE/tooling support"
| optional,
},
ProjectInfo = {
name
| String
| doc "Implementation name (matches Cargo workspace package name)",
type
| String
| doc "Project type: 'rustelo-app' | 'rustelo-plugin'",
description
| String
| doc "Human-readable description"
| optional,
},
PathConfig = {
content
| String
| doc "Content root, relative to manifest location",
config
| String
| doc "NCL configuration root (site/config/index.ncl entrypoint)",
routes
| String
| doc "Routes configuration directory",
i18n
| String
| doc "FTL locales root directory",
assets
| String
| doc "Public static assets directory served as-is",
ui
| String
| doc "UI configuration root (menus, themes, footer — now in NCL)",
build_output
| String
| doc "Leptos site output directory",
wasm_output
| String
| doc "WASM package output directory",
},
# PAP: language and content-type discovery is always config-driven.
# Use 'auto' to discover from site/config/index.ncl [site.languages]
# and [content_types]. Explicit lists are allowed for restricted deploys.
DiscoveryConfig = {
default_lang
| String
| doc "Default language ISO 639-1 code (e.g. 'en')",
languages
| String
| doc "'auto' = discovered from site config | explicit = 'en,es,fr'",
content_types
| String
| doc "'auto' = discovered from site config | explicit = 'blog,recipes'",
},
# Deployment values that vary between environments.
# Resolved from environment variables at evaluation time via std.env.try_var.
# Credentials and secrets belong ONLY in environment variables, never here.
DeploymentConfig = {
base_url
| String
| doc "Site base URL — set BASE_URL env var to override",
content_url
| String
| doc "URL prefix for content serving (e.g. '/r')",
content_root
| String
| doc "Content URL path segment (matches content_url without leading slash)",
cache_path
| String
| doc "Runtime cache directory name",
api_endpoint
| String
| doc "API base path — set API_ENDPOINT env var to override",
database_url
| String
| doc "Database connection URL — set DATABASE_URL env var to override",
},
BuildConfig = {
leptos_output_name
| String
| doc "Leptos output name (must match [[workspace.metadata.leptos]].name in Cargo.toml)",
site_addr
| String
| doc "Development server listen address",
reload_port
| Number
| doc "Hot-reload WebSocket port",
debug
| Number
| doc "Debug verbosity: 0=off 1=basic 2=verbose 3=trace routes 4=deep trace"
| default = 0,
cache_build_path
| String
| doc "Build cache subdirectory name (placed under target/)",
},
# Cargo feature flags for this implementation.
# These control compile-time code inclusion — distinct from runtime feature flags.
# The generator (cargo rustelo new) sets these from --features arguments.
FrameworkFeatures = {
auth
| Bool
| doc "JWT/OAuth2/2FA authentication"
| default = true,
content
| Bool
| doc "Markdown/static content management"
| default = true,
email
| Bool
| doc "SMTP email support"
| default = false,
tls
| Bool
| doc "HTTPS/TLS via rustls"
| default = false,
metrics
| Bool
| doc "Prometheus metrics endpoint"
| default = false,
crypto
| Bool
| doc "AES-GCM encryption for sensitive config values"
| default = false,
# Allow implementation-specific feature flags without breaking the contract
..
},
RusteloManifest = {
manifest
| ManifestMeta,
project
| ProjectInfo,
paths
| PathConfig,
discovery
| DiscoveryConfig,
deployment
| DeploymentConfig,
build
| BuildConfig,
features
| FrameworkFeatures
| doc "Cargo feature flags enabled for this implementation"
| optional,
},
}

View file

@ -0,0 +1,134 @@
# Menu Configuration Contracts
#
# Type contracts for menu configuration with support for multilingual menus.
# Single source of truth - no more duplicated labels between language files.
{
# Multilingual labels for menu items
MenuLabels = {
en
| String
| doc "English label for menu item",
es
| String
| doc "Spanish label for menu item",
# Extensible for more languages
# fr | String | optional,
# de | String | optional,
},
# Routes for different languages
MenuRoutes = {
en
| String
| doc "English route path (e.g., '/about')",
es
| String
| doc "Spanish route path (e.g., '/acerca-de')",
# Alternate routes for language prefixed paths
"en-alt"
| String
| doc "Alternate English route (e.g., '/en/about')"
| optional,
"es-alt"
| String
| doc "Alternate Spanish route (e.g., '/es/acerca-de')"
| optional,
# Extensible for more languages
# fr | String | optional,
},
# Menu item configuration
MenuItem = {
id
| String
| doc "Unique identifier for menu item (e.g., 'home', 'about')",
routes
| MenuRoutes
| doc "URL paths for each language",
labels
| MenuLabels
| doc "Display labels for each language",
icon
| String
| doc "Icon class (e.g., 'fas fa-user', 'lucide:home')"
| optional,
is_external
| Bool
| doc "Whether this links to an external URL"
| default = false,
enabled
| Bool
| doc "Whether this menu item is active"
| default = true,
order
| Number
| doc "Display order (lower numbers first)"
| optional,
children
| Array MenuItem
| doc "Nested submenu items"
| optional,
..
},
# External link menu item (single URL, same for all languages)
ExternalMenuItem = {
id
| String
| doc "Unique identifier",
url
| String
| doc "External URL (same for all languages)",
label
| String
| doc "Display label (same for all languages)",
icon
| String
| optional,
enabled
| Bool
| default = true,
order
| Number
| optional,
is_external
| Bool
| doc "Must be true for external links"
| default = true,
visible_in
| Array String
| doc "Languages this item appears in. Empty/absent = all languages. Example: [\"es\"] shows only in Spanish."
| optional,
..
},
# Root menu configuration
MenuFile = {
items
| Array Dyn
| doc "Menu items (internal or external links)",
},
}

View file

@ -0,0 +1,132 @@
# Menu Configuration Defaults and Helpers
#
# Provides helper functions for creating menu items with minimal duplication.
# Single source of truth for multilingual menus.
let contracts = import "contracts.ncl" in
{
# Base defaults for menu items
base_menu_item = {
is_external = false,
enabled = true,
},
# Helper function: Create a basic menu item
#
# Usage:
# make_menu_item {
# id = "home",
# routes = { en = "/", es = "/inicio" },
# labels = { en = "Home", es = "Inicio" },
# }
#
# Parameters:
# config: Record with id, routes, labels, and optional icon/order
#
# Returns: MenuItem record with defaults
make_menu_item
| Dyn -> Dyn
= fun config =>
base_menu_item & {
id = config.id,
routes = config.routes,
labels = config.labels,
}
& (if std.record.has_field "icon" config then { icon = config.icon } else {})
& (if std.record.has_field "order" config then { order = config.order } else {})
& (if std.record.has_field "children" config then { children = config.children } else {})
& (if std.record.has_field "is_external" config then { is_external = config.is_external } else {})
& (if std.record.has_field "enabled" config then { enabled = config.enabled } else {})
& (if std.record.has_field "visible_in" config then { visible_in = config.visible_in } else {}),
# Helper function: Create an external link menu item
#
# External links use the same URL and label for all languages.
#
# Usage:
# make_external_item {
# id = "github",
# url = "https://github.com/user/repo",
# label = "GitHub",
# icon = "fab fa-github",
# }
#
# Parameters:
# config: Record with id, url, label, and optional icon/order
#
# Returns: ExternalMenuItem record
make_external_item
| Dyn -> Dyn
= fun config =>
{
id = config.id,
url = config.url,
label = config.label,
is_external = true,
enabled = true,
}
& (if std.record.has_field "icon" config then { icon = config.icon } else {})
& (if std.record.has_field "order" config then { order = config.order } else {})
& (if std.record.has_field "enabled" config then { enabled = config.enabled } else {})
& (if std.record.has_field "visible_in" config then { visible_in = config.visible_in } else {}),
# Helper function: Create a menu item with alternate language routes
#
# Some sites support both /about and /en/about for the same content.
#
# Usage:
# make_menu_item_with_alts {
# id = "services",
# routes = {
# en = "/services",
# es = "/servicios",
# "en-alt" = "/en/services",
# "es-alt" = "/es/servicios",
# },
# labels = { en = "Services", es = "Servicios" },
# }
#
# Parameters:
# config: Record with id, routes (including -alt), labels
#
# Returns: MenuItem with alternate routes
make_menu_item_with_alts
| Dyn -> Dyn
= fun config =>
make_menu_item config,
# Helper function: Create a menu item with submenu
#
# Usage:
# make_menu_with_children {
# id = "products",
# routes = { en = "/products", es = "/productos" },
# labels = { en = "Products", es = "Productos" },
# children = [
# make_menu_item { id = "product-a", ... },
# make_menu_item { id = "product-b", ... },
# ],
# }
#
# Parameters:
# config: Record with id, routes, labels, children array
#
# Returns: MenuItem with nested children
make_menu_with_children
| Dyn -> Dyn
= fun config =>
make_menu_item config,
# Common menu orders for consistent organization
menu_orders = {
home = 1,
about = 2,
services = 3,
portfolio = 4,
blog = 5,
recipes = 6,
contact = 10,
external_links = 100,
},
}

View file

@ -0,0 +1,45 @@
# Type contracts for CI/CD-triggered pipelines
#
# A CiCdPipeline binds a named CI/CD event to an ordered list of steps.
# `pipeline_type` must be "ci_cd".
#
# At runtime, ci_cd pipelines are dispatched via a dedicated HTTP endpoint
# (not a NATS subscription). The `event` field is the path token used
# in the webhook URL: POST /pipeline/trigger/{event}.
#
# Validate:
# nickel export --format json --import-path site/nickel site/config/pipelines.ncl
let C = import "pipelines/contracts.ncl" in
let OneOf = fun allowed =>
std.contract.from_predicate (fun v => std.array.elem v allowed)
in
let NonEmpty = std.contract.from_predicate (fun s =>
std.is_string s && std.string.length s > 0)
in
{
CiCdPipeline = {
pipeline_type | OneOf ["ci_cd"]
| doc "Discriminator — must be \"ci_cd\" for this contract.",
name | String | NonEmpty
| doc "Human-readable identifier used in log output.",
event | String | NonEmpty
| doc "CI/CD event name used in the webhook trigger path.
Example: 'deploy.completed', 'test.passed'.",
steps | Array C.PipelineStep
| doc "Ordered actions executed sequentially on each trigger."
| default = [],
},
CiCdPipelinesFile = {
pipelines | Array CiCdPipeline
| doc "List of CI/CD-triggered pipelines defined in this file."
| default = [],
},
}

View file

@ -0,0 +1,127 @@
# Shared contracts for the event pipeline system
#
# Mirrors rustelo_server::nats::pipeline — PipelineConfig, Pipeline,
# PipelineTrigger, PipelineStep, MessageTarget.
#
# Sub-type contracts are in:
# pipelines/nats/contracts.ncl — NatsPipeline (pipeline_type = "nats")
# pipelines/ci_cd/contracts.ncl — CiCdPipeline (pipeline_type = "ci_cd")
#
# Validate standalone:
# source .env && nickel export --format json site/config/pipelines.ncl
# # or with explicit import path:
# nickel export --format json --import-path site/nickel site/config/pipelines.ncl
# ── Allowed value sets ─────────────────────────────────────────────────────────
let PipelineTypes = ["nats", "ci_cd"] in
let StepTypes = ["cache_invalidate", "broadcast", "script",
"persist_message"] in
let TargetTypes = ["user_id", "payload_field"] in
let ToastKinds = ["info", "success", "warning", "error"] in
# ── Reusable primitive contracts ───────────────────────────────────────────────
let OneOf = fun allowed =>
std.contract.from_predicate (fun v => std.array.elem v allowed)
in
let NonEmpty = std.contract.from_predicate (fun s =>
std.is_string s && std.string.length s > 0)
in
# ── Contracts ──────────────────────────────────────────────────────────────────
{
# MessageTarget — selects the target user for a persist_message step.
MessageTarget = {
type | OneOf TargetTypes
| doc "Selector variant:
user_id — literal UUID configured in the pipeline
payload_field — extract UUID from a JSON field in the payload",
value | String | NonEmpty
| doc "user_id: literal UUID of the target user"
| optional,
field | String | NonEmpty
| doc "payload_field: JSON field name that contains the user UUID"
| optional,
..
},
# PipelineStep — one action in the execution chain.
# Variant-specific fields are optional; the executor validates
# required fields at runtime matching the Rust enum.
PipelineStep = {
type | OneOf StepTypes
| doc "Step variant:
cache_invalidate — flush the in-process content cache
broadcast — send a WebSocket toast to all clients
script — execute a Nu or shell script
persist_message — store a message in a user's inbox (requires auth feature)",
# ── broadcast ────────────────────────────────────────────────────────────
kind | String | OneOf ToastKinds
| doc "broadcast: toast severity — info | success | warning | error"
| optional,
message | String | NonEmpty
| doc "broadcast: toast body shown to connected WebSocket clients"
| optional,
# ── script ───────────────────────────────────────────────────────────────
path | String | NonEmpty
| doc "script: path to script file (.nu → nu, .sh → sh, other → direct exec).
Relative paths resolve against SITE_CONTENT_PATH.
Script receives PIPELINE_EVENT and PIPELINE_PAYLOAD env vars
(plus NATS_SUBJECT / NATS_PAYLOAD aliases for NATS pipelines)."
| optional,
# ── persist_message ───────────────────────────────────────────────────────
target | MessageTarget
| doc "persist_message: user selector"
| optional,
sender_email | String | NonEmpty
| doc "persist_message: sender label shown in the user's inbox"
| optional,
subject_template | String | NonEmpty
| doc "persist_message: message subject; supports {{field}} substitution"
| optional,
body_template | String
| doc "persist_message: body template; defaults to raw payload when omitted"
| optional,
},
# Pipeline — generic shape shared by all pipeline types.
# Use the type-specific contracts (nats/contracts.ncl, ci_cd/contracts.ncl)
# for stricter validation of individual files.
Pipeline = {
pipeline_type | OneOf PipelineTypes
| doc "Trigger mechanism:
nats — NATS JetStream subject subscription
ci_cd — CI/CD event hook (webhook-driven)",
name | String | NonEmpty
| doc "Human-readable identifier used in structured log output",
steps | Array PipelineStep
| doc "Ordered actions executed sequentially on each trigger.
Step failures are logged and do not abort subsequent steps."
| default = [],
..
},
# PipelinesConfig — root record, deserialized directly into PipelineConfig.
PipelinesConfig = {
pipelines | Array Pipeline
| doc "Aggregated list of all event pipelines across all trigger types."
| default = [],
},
}

View file

@ -0,0 +1,41 @@
# Type contracts for NATS-triggered pipelines
#
# A NatsPipeline binds a NATS subject (with optional wildcards) to an
# ordered list of steps. `pipeline_type` must be "nats".
#
# Validate:
# nickel export --format json --import-path site/nickel site/config/pipelines.ncl
let C = import "pipelines/contracts.ncl" in
let OneOf = fun allowed =>
std.contract.from_predicate (fun v => std.array.elem v allowed)
in
let NonEmpty = std.contract.from_predicate (fun s =>
std.is_string s && std.string.length s > 0)
in
{
NatsPipeline = {
pipeline_type | OneOf ["nats"]
| doc "Discriminator — must be \"nats\" for this contract.",
name | String | NonEmpty
| doc "Human-readable identifier used in log output.",
subject | String | NonEmpty
| doc "Raw NATS subject (e.g. 'evol.website.prod.content.published').
Wildcards (* and >) are valid per NATS subject syntax.",
steps | Array C.PipelineStep
| doc "Ordered actions executed sequentially on each message."
| default = [],
},
NatsPipelinesFile = {
pipelines | Array NatsPipeline
| doc "List of NATS-triggered pipelines defined in this file."
| default = [],
},
}

View file

@ -0,0 +1,166 @@
# RBAC Policy Configuration Type Contracts
#
# Defines types for the file-based RBAC layer.
# Identity (who the user is) comes from JWT claims / AuthContext.
# This file defines what groups are allowed to do (policy rules + effects).
let EffectTypes = ["redirect", "log", "audit_log", "notify", "response"] in
let ResourceKinds = ["endpoint", "page", "asset"] in
let Outcomes = ["allow", "deny"] in
{
# Validate that a value belongs to a fixed set
OneOf = fun allowed => std.contract.from_predicate (fun v => std.array.elem v allowed),
# Effect type discriminator contract
EffectType
| doc "Possible effect types: redirect | log | audit_log | notify | response"
= OneOf EffectTypes,
# Policy effect: describes what to do when a rule matches
# The `type` field discriminates the variant
PolicyEffect = {
type
| OneOf EffectTypes
| doc "Effect variant discriminator",
# --- redirect: issue HTTP redirect to another URL ------------------
to
| String
| doc "redirect: destination URL. Supports {request.path}, {user.email}, {user.id}"
| optional,
# --- log | audit_log: write to application or audit log -----------
level
| String
| doc "log|audit_log: severity level (debug|info|warn|error)"
| default = "info",
message
| String
| doc "log|audit_log|notify: message template with interpolation support"
| optional,
event
| String
| doc "audit_log: semantic event name (e.g. 'access_denied')"
| optional,
# --- notify: send to external channel ----------------------------
channel
| String
| doc "notify: destination channel (security|slack|webhook|email)"
| optional,
# --- response: return HTTP response directly ---------------------
status
| Number
| doc "response: HTTP status code (401, 403, 302 ...)"
| default = 403,
body
| { _ : Dyn }
| doc "response: JSON response body"
| optional,
},
# A single access rule: matches resource kind, URL pattern, HTTP methods
PolicyRule = {
resource
| OneOf ResourceKinds
| doc "Resource kind: endpoint | page | asset",
pattern
| String
| doc "Glob pattern matched against the request path (e.g. '/api/*', '/*')",
outcome
| OneOf Outcomes
| doc "Decision when this rule matches: allow | deny",
methods
| Array String
| doc "HTTP methods this rule applies to. Empty array = all methods"
| default = [],
effects
| Array PolicyEffect
| doc "Effects executed when this rule matches"
| default = [],
},
# Group definition with optional inheritance
FileRbacGroup = {
name
| String
| doc "Group identifier (must match role names from JWT, e.g. 'admin', 'moderator')",
parent_groups
| Array String
| doc "Groups whose permissions are inherited (BFS expansion)"
| default = [],
permissions
| Array PolicyRule
| doc "Access rules for this group (first match wins)"
| default = [],
},
# Per-user permission override (highest priority, matched by email or sub)
FileRbacUserOverride = {
id
| String
| doc "User identifier: email address or JWT sub claim",
permissions
| Array PolicyRule
| doc "User-specific rules (override all group rules)"
| default = [],
},
# Default rules for unauthenticated and fallback behaviour
FileRbacDefaults = {
unauthenticated_allow
| Array PolicyRule
| doc "Explicit allow rules for unauthenticated requests (first match wins)"
| default = [],
unauthenticated_deny
| Array PolicyRule
| doc "Explicit deny rules for unauthenticated requests (evaluated after allow)"
| default = [],
authenticated_fallback
| OneOf Outcomes
| doc "Outcome when an authenticated user matches no rule"
| default = "deny",
unauthenticated_fallback
| OneOf Outcomes
| doc "Outcome when an unauthenticated user matches no rule"
| default = "deny",
fallback_effects
| Array PolicyEffect
| doc "Effects applied when the fallback outcome is triggered"
| default = [],
},
# Root RBAC configuration record
FileRbacConfig = {
groups
| Array FileRbacGroup
| doc "Group definitions with their permissions"
| default = [],
user_overrides
| Array FileRbacUserOverride
| doc "Per-user permission overrides (highest priority)"
| default = [],
defaults
| FileRbacDefaults
| doc "Fallback rules for unmatched requests"
| default = {},
},
}

View file

@ -0,0 +1,24 @@
# RBAC — admin feature permissions
#
# Covers: admin pages, admin API endpoints, service token management.
# Enable when using the admin panel feature.
let E = (import "../rbac-base.ncl").effects in
{
admin = [
{ resource = "page", pattern = "/admin/*", outcome = "allow", methods = [], effects = E.audit_allow },
{ resource = "endpoint", pattern = "/api/admin/*", outcome = "allow", methods = [], effects = E.audit_allow },
{ resource = "endpoint", pattern = "/api/server_list_service_tokens*", outcome = "allow", methods = ["POST"], effects = [] },
{ resource = "endpoint", pattern = "/api/server_create_service_token*", outcome = "allow", methods = ["POST"], effects = [] },
{ resource = "endpoint", pattern = "/api/server_revoke_service_token*", outcome = "allow", methods = ["POST"], effects = [] },
],
user = [
# Explicitly deny admin endpoints for non-admin authenticated users
{ resource = "page", pattern = "/admin/*", outcome = "deny", methods = [], effects = E.audit_deny @ E.api_forbidden },
{ resource = "endpoint", pattern = "/api/admin/*", outcome = "deny", methods = [], effects = E.audit_deny @ E.api_forbidden },
],
guest = [],
}

View file

@ -0,0 +1,10 @@
# RBAC — analytics/tracing feature permissions
#
# Covers: page view tracking endpoint.
# Enable when using rustelo analytics or tracing.
{
admin = [{ resource = "endpoint", pattern = "/api/track_page_view*", outcome = "allow", methods = ["POST"], effects = [] }],
user = [{ resource = "endpoint", pattern = "/api/track_page_view*", outcome = "allow", methods = ["POST"], effects = [] }],
guest = [{ resource = "endpoint", pattern = "/api/track_page_view*", outcome = "allow", methods = ["POST"], effects = [] }],
}

View file

@ -0,0 +1,33 @@
# RBAC — auth feature permissions
#
# Covers: OTP login flow, session management, account management.
# Enable when using rustelo_auth.
#
# Each field is an array of permission entries for that role.
# Concatenate with @ in your site/rbac.ncl:
# permissions = auth.admin @ other_feature.admin
let E = (import "../rbac-base.ncl").effects in
{
admin = [], # admin inherits all via wildcard — no specific auth rules needed
user = [
{ resource = "endpoint", pattern = "/api/server_logout*", outcome = "allow", methods = ["POST"], effects = [] },
{ resource = "endpoint", pattern = "/api/server_logout_all*", outcome = "allow", methods = ["POST"], effects = [] },
{ resource = "endpoint", pattern = "/api/server_refresh_token*", outcome = "allow", methods = ["POST"], effects = [] },
{ resource = "endpoint", pattern = "/api/server_get_session_user*", outcome = "allow", methods = ["POST"], effects = [] },
{ resource = "endpoint", pattern = "/api/server_update_display_name*", outcome = "allow", methods = ["POST"], effects = [] },
{ resource = "endpoint", pattern = "/api/server_delete_account*", outcome = "allow", methods = ["POST"], effects = [] },
],
guest = [
{ resource = "page", pattern = "/login", outcome = "allow", methods = [], effects = [] },
{ resource = "page", pattern = "/register", outcome = "allow", methods = [], effects = [] },
{ resource = "endpoint", pattern = "/api/auth/*", outcome = "allow", methods = [], effects = [] },
{ resource = "endpoint", pattern = "/api/server_request_otp*", outcome = "allow", methods = ["POST"], effects = [] },
{ resource = "endpoint", pattern = "/api/server_verify_otp*", outcome = "allow", methods = ["POST"], effects = [] },
{ resource = "endpoint", pattern = "/api/server_accept_gdpr*", outcome = "allow", methods = ["POST"], effects = [] },
{ resource = "endpoint", pattern = "/api/server_get_session_user*", outcome = "allow", methods = ["POST"], effects = [] },
],
}

View file

@ -0,0 +1,28 @@
# RBAC — content feature permissions
#
# Covers: content rendering and index endpoints.
# Enable when using rustelo_content.
let E = (import "../rbac-base.ncl").effects in
{
admin = [
{ resource = "endpoint", pattern = "/api/content/*", outcome = "allow", methods = ["GET", "POST", "PUT", "DELETE"], effects = E.audit_allow },
{ resource = "page", pattern = "/admin/content/*", outcome = "allow", methods = [], effects = [] },
],
moderator = [
{ resource = "endpoint", pattern = "/api/content/*", outcome = "allow", methods = ["GET", "POST", "PUT"], effects = E.audit_allow },
{ resource = "page", pattern = "/admin/content/*", outcome = "allow", methods = [], effects = [] },
],
user = [
{ resource = "endpoint", pattern = "/api/content-render/*", outcome = "allow", methods = ["GET"], effects = [] },
{ resource = "endpoint", pattern = "/api/content-index/*", outcome = "allow", methods = ["GET"], effects = [] },
],
guest = [
{ resource = "endpoint", pattern = "/api/content-render/*", outcome = "allow", methods = ["GET"], effects = [] },
{ resource = "endpoint", pattern = "/api/content-index/*", outcome = "allow", methods = ["GET"], effects = [] },
],
}

View file

@ -0,0 +1,24 @@
# RBAC — user dashboard feature permissions
#
# Covers: bookmarks, messages, notes, resources — the authenticated user area.
# Enable when using the user dashboard feature.
{
admin = [], # admin inherits all via wildcard
user = [
{ resource = "endpoint", pattern = "/api/server_get_bookmarks*", outcome = "allow", methods = ["POST"], effects = [] },
{ resource = "endpoint", pattern = "/api/server_add_bookmark*", outcome = "allow", methods = ["POST"], effects = [] },
{ resource = "endpoint", pattern = "/api/server_remove_bookmark*", outcome = "allow", methods = ["POST"], effects = [] },
{ resource = "endpoint", pattern = "/api/server_get_messages*", outcome = "allow", methods = ["POST"], effects = [] },
{ resource = "endpoint", pattern = "/api/server_mark_message_read*",outcome = "allow", methods = ["POST"], effects = [] },
{ resource = "endpoint", pattern = "/api/server_delete_message*", outcome = "allow", methods = ["POST"], effects = [] },
{ resource = "endpoint", pattern = "/api/server_get_notes*", outcome = "allow", methods = ["POST"], effects = [] },
{ resource = "endpoint", pattern = "/api/server_create_note*", outcome = "allow", methods = ["POST"], effects = [] },
{ resource = "endpoint", pattern = "/api/server_update_note*", outcome = "allow", methods = ["POST"], effects = [] },
{ resource = "endpoint", pattern = "/api/server_delete_note*", outcome = "allow", methods = ["POST"], effects = [] },
{ resource = "endpoint", pattern = "/api/server_get_resources*", outcome = "allow", methods = ["POST"], effects = [] },
],
guest = [],
}

View file

@ -0,0 +1,10 @@
# RBAC — websocket feature permissions
#
# Covers: /api/ws endpoint for real-time updates (RBAC hot-reload, JWT lifecycle).
# Enable when using WebSocket support.
{
admin = [{ resource = "endpoint", pattern = "/api/ws", outcome = "allow", methods = ["GET"], effects = [] }],
user = [{ resource = "endpoint", pattern = "/api/ws", outcome = "allow", methods = ["GET"], effects = [] }],
guest = [{ resource = "endpoint", pattern = "/api/ws", outcome = "allow", methods = ["GET"], effects = [] }],
}

View file

@ -0,0 +1,43 @@
# RBAC Base — role skeleton and shared effects
#
# Provides the common effect dictionary and the default fallback policy.
# Feature fragments (features/*.ncl) provide permissions per role.
# Compose in your site/rbac.ncl using the @ array concatenation operator.
#
# Usage:
# let base = import "rustelo/resources/nickel/rbac/rbac-base.ncl" in
# let auth = import "rustelo/resources/nickel/rbac/features/auth.ncl" in
# let content = import "rustelo/resources/nickel/rbac/features/content.ncl" in
#
# { groups = [
# { name = "admin", ..., permissions = auth.admin @ content.admin },
# { name = "user", ..., permissions = auth.user @ content.user },
# { name = "guest", ..., permissions = auth.guest @ content.guest },
# ],
# defaults = base.defaults & { unauthenticated_allow = [ ...site-specific... ] },
# } | C.FileRbacConfig | RoleNamesConsistent
{
# Reusable effect sets — import and reference as E.redirect_login, etc.
effects = {
redirect_login = [{ type = "redirect", to = "/login?return={request.path}" }],
audit_deny = [{ type = "audit_log", event = "access_denied", level = "warn" }],
audit_allow = [{ type = "audit_log", event = "access_granted", level = "info" }],
api_forbidden = [{ type = "response", status = 403, body.error = "Forbidden" }],
log_warn = [{ type = "log", level = "warn", message = "Denied: {request.path}" }],
security_alert = [{
type = "notify",
channel = "security",
message = "Access: {request.path} by {user.email}",
}],
},
# Minimal defaults — override unauthenticated_allow in your site/rbac.ncl
defaults = {
unauthenticated_allow = [],
unauthenticated_deny = [],
authenticated_fallback = "deny",
unauthenticated_fallback = "deny",
fallback_effects = [{ type = "log", level = "warn", message = "Denied: {request.path}" }],
},
}

View file

@ -0,0 +1,112 @@
# RBAC Composition Template
#
# Copy to site/rbac.ncl and activate the features your site uses.
# Remove feature imports you don't need.
#
# Validate: nickel export site/rbac.ncl
let C = import "rbac/contracts.ncl" in
let Roles = import "config/roles.ncl" in
let valid_role_names = Roles.roles |> std.array.map (fun r => r.name) in
let RoleNamesConsistent =
std.contract.custom (fun _label value =>
let unknown_groups =
value.groups
|> std.array.filter (fun g => !(std.array.elem g.name valid_role_names))
|> std.array.map (fun g => g.name)
in
let unknown_parents =
value.groups
|> std.array.flat_map (fun g =>
g.parent_groups
|> std.array.filter (fun p => !(std.array.elem p valid_role_names))
)
in
let all_invalid = unknown_groups @ unknown_parents in
if all_invalid == [] then 'Ok value
else 'Error { message = "Unknown roles: " ++ std.string.join ", " all_invalid }
)
in
# ── Feature fragments — uncomment what your site uses ────────────────────────
# Each fragment exposes permissions arrays keyed by role name.
let base = import "rustelo/resources/nickel/rbac/rbac-base.ncl" in
let auth = import "rustelo/resources/nickel/rbac/features/auth.ncl" in
let content = import "rustelo/resources/nickel/rbac/features/content.ncl" in
let ws = import "rustelo/resources/nickel/rbac/features/websocket.ncl" in
let analytics = import "rustelo/resources/nickel/rbac/features/analytics.ncl" in
# let dashboard = import "rustelo/resources/nickel/rbac/features/user-dashboard.ncl" in
# let admin_ft = import "rustelo/resources/nickel/rbac/features/admin.ncl" in
# ── Site-specific permissions ─────────────────────────────────────────────────
# Add rules specific to your site that don't belong in any framework feature.
let site = {
admin = [],
user = [],
guest = [
{ resource = "page", pattern = "/*", outcome = "allow", methods = [], effects = [] },
],
} in
{
groups = [
{
name = "admin",
parent_groups = [],
permissions =
auth.admin
@ content.admin
@ ws.admin
@ analytics.admin
# @ dashboard.admin
# @ admin_ft.admin
@ site.admin
@ [
# admin catch-all — keep last
{ resource = "endpoint", pattern = "/api/*", outcome = "allow", methods = [], effects = base.effects.audit_allow },
{ resource = "page", pattern = "/*", outcome = "allow", methods = [], effects = [] },
],
},
{
name = "user",
parent_groups = [],
permissions =
auth.user
@ content.user
@ ws.user
@ analytics.user
# @ dashboard.user
# @ admin_ft.user
@ site.user
@ [
# authenticated catch-all deny — keep last
{ resource = "endpoint", pattern = "/api/*", outcome = "deny", methods = [], effects = base.effects.audit_deny @ base.effects.api_forbidden },
],
},
{
name = "guest",
parent_groups = [],
permissions =
auth.guest
@ content.guest
@ ws.guest
@ analytics.guest
# @ dashboard.guest
@ site.guest,
},
],
user_overrides = [],
defaults = base.defaults & {
unauthenticated_allow = [
# Public assets and health — always allow
{ resource = "asset", pattern = "/*", outcome = "allow", methods = [], effects = [] },
{ resource = "endpoint", pattern = "/api/health", outcome = "allow", methods = [], effects = [] },
# Site-specific public routes — add your pages here
],
},
} | C.FileRbacConfig | RoleNamesConsistent

View file

@ -0,0 +1,98 @@
# Role Registry Type Contracts
#
# Schema for site/config/roles.ncl — the canonical role list that
# rbac.ncl groups, DB user_roles seeds, and the Rust Role enum must agree with.
let RustVariants = ["Admin", "Moderator", "User", "Guest", "Custom"] in
{
OneOf = fun allowed => std.contract.from_predicate (fun v => std.array.elem v allowed),
# Single role definition — one entry per role in the application
RoleEntry = {
name
| String
| doc m%"
Canonical role identifier.
Must match: JWT claims, DB user_roles.name, and rbac.ncl group names.
"%,
description
| String
| doc "Human-readable description for admin UI and documentation",
db_seed
| Bool
| doc m%"
Whether this role is inserted into user_roles on initial DB migration.
Set false for virtual roles (e.g. guest) that have no DB row.
"%
| default = true,
is_system
| Bool
| doc "System roles cannot be deleted or renamed via admin UI"
| default = false,
parent_roles
| Array String
| doc m%"
Role names inherited by this role (BFS expansion, same semantics as
rbac.ncl parent_groups). Names must resolve to other entries in this array.
"%
| default = [],
rust_variant
| OneOf RustVariants
| doc m%"
Corresponding Rust enum variant in rustelo_core_lib::auth::Role.
Use "Custom" for roles that have no dedicated variant.
"%
| default = "Custom",
db_level
| Number
| doc "Privilege level for UI ordering (higher = more privilege)"
| default = 0,
is_default
| Bool
| doc m%"
Role assigned automatically on new user registration.
Exactly one role in the registry must have is_default = true.
Must match registration_role in site/config/auth.toml.
"%
| default = false,
},
# Validates that exactly one role carries is_default = true.
ExactlyOneDefaultRole =
std.contract.custom (fun _label value =>
let defaults = value.roles |> std.array.filter (fun r => r.is_default) in
let n = std.array.length defaults in
if n == 1 then
'Ok value
else
'Error {
message =
"Exactly one role must have is_default = true, found "
++ std.string.from_number n,
}
),
# Root contract applied to site/config/roles.ncl
RolesConfig = {
roles
| Array RoleEntry
| doc m%"
Authoritative role registry.
Invariants enforced at eval time:
- Exactly one entry has is_default = true (enforced by ExactlyOneDefaultRole)
- Every rbac.ncl group name must appear here
- Every parent_role reference must resolve to another entry in this array
- DB migrations must seed all entries where db_seed = true
- registration_role in auth.toml must match the entry with is_default = true
"%,
},
}

View file

@ -0,0 +1,129 @@
# Route Configuration Type Contracts
#
# Defines the schema for route configuration with validation rules.
# Used by both en.ncl and es.ncl to ensure type safety and consistency.
{
# Single route configuration
Route = {
# Required fields
component
| String
| doc "Component name (e.g., 'Home', 'About', 'Services')",
path
| String
| doc "URL path (e.g., '/', '/about', '/services')",
language
| String
| doc "Language code (ISO 639-1, e.g., 'en', 'es')",
title_key
| String
| doc "i18n key for page title",
description_key
| String
| doc "i18n key for page description",
# Optional fields with defaults
enabled
| Bool
| doc "Whether this route is active"
| default = true,
canonical_path
| String
| doc "Canonical URL path for this route"
| optional,
keywords
| Array String
| doc "SEO keywords for this page"
| default = [],
lang_prefixes
| Array String
| doc "i18n key prefixes (e.g., ['home-', 'welcome-'])"
| default = [],
menu_group
| String
| doc "Menu grouping (e.g., 'main', 'footer', 'admin')"
| default = "main",
menu_icon
| String
| doc "Icon identifier for menu item"
| optional,
menu_order
| Number
| doc "Display order within menu group (lower = higher priority)"
| default = 999,
page_component
| String
| doc "Leptos component name (e.g., 'HomePage')"
| optional,
unified_component
| String
| doc "Unified component name for SSR/WASM delegation"
| optional,
module_path
| String
| doc "Module path for component (e.g., 'services')"
| optional,
priority
| Number
| doc "SEO/sitemap priority (0.0 to 1.0)"
| default = 0.8,
show_in_sitemap
| Bool
| doc "Whether to include in sitemap.xml"
| default = true,
generate_boilerplate_only
| Bool
| doc "Only generate boilerplate code (no full implementation)"
| default = false,
replace_existing_boilerplate
| Bool
| doc "Replace existing boilerplate if it exists"
| default = false,
is_external
| Bool
| doc "Whether this is an external URL"
| default = false,
component_dynamic
| Bool
| doc "Whether component is dynamically loaded"
| default = false,
content_type_param
| Bool
| doc "Whether route accepts content_type parameter"
| default = false,
# Dynamic properties (extensible)
props
| { _ : Dyn }
| doc "Additional dynamic properties (e.g., content_type, style)"
| optional,
},
# Container for all routes
RoutesFile = {
routes
| Array Route
| doc "List of all route configurations",
},
}

View file

@ -0,0 +1,142 @@
# Route Configuration Defaults and Helpers
#
# Provides shared default values and helper functions for route creation.
# Reduces duplication by providing common patterns.
let contracts = import "contracts.ncl" in
{
# Base route with common defaults
# Use this as foundation via merge operator (&)
# Note: | default = allows overriding via merge
base_route = {
enabled | default = true,
generate_boilerplate_only | default = false,
replace_existing_boilerplate | default = false,
show_in_sitemap | default = true,
menu_group | default = "main",
priority | default = 0.8,
is_external | default = false,
component_dynamic | default = false,
content_type_param | default = false,
keywords | default = [],
lang_prefixes | default = [],
menu_order | default = 999,
},
# Helper function: Create a route with minimal required fields
#
# Usage:
# make_route "Home" "/" "en"
# & { priority = 1.0, menu_icon = "home", ... }
#
# Parameters:
# comp_name: Component name (e.g., "Home", "About")
# url_path: URL path (e.g., "/", "/about")
# lang: Language code (e.g., "en", "es")
#
# Returns: Route record with defaults + generated keys
make_route
| String -> String -> String -> Dyn
= fun comp_name url_path lang =>
base_route & {
component = comp_name,
path = url_path,
language = lang,
# Auto-generate title/description keys from component name (| default = allows override)
title_key | default = "%{std.string.lowercase comp_name}-page-title",
description_key | default = "%{std.string.lowercase comp_name}-page-description",
},
# Helper function: Create a content route (blog, portfolio, etc.)
#
# Usage:
# make_content_route "ContentIndex" "/blog" "en" "blog"
# & { menu_icon = "book", ... }
#
# Parameters:
# comp_name: Component name
# url_path: URL path
# lang: Language code
# ctype: Content type (e.g., "blog", "portfolio")
#
# Returns: Route record with content-specific settings
make_content_route
| String -> String -> String -> String -> Dyn
= fun comp_name url_path lang ctype =>
make_route comp_name url_path lang & {
component_dynamic = true,
content_type_param = true,
props = {
content_type = ctype,
use_categories = true,
use_feature = true,
},
},
# Helper function: Create an admin route (protected)
#
# Usage:
# make_admin_route "AdminDashboard" "/admin" "en"
# & { menu_icon = "shield", ... }
#
# Parameters:
# comp_name: Component name
# url_path: URL path
# lang: Language code
#
# Returns: Route record with admin-specific settings
make_admin_route
| String -> String -> String -> Dyn
= fun comp_name url_path lang =>
make_route comp_name url_path lang & {
menu_group = "admin",
show_in_sitemap = false,
priority = 0.3,
},
# Helper function: Create an external link route
#
# Usage:
# make_external_link "GitHub" "https://github.com" "en"
# & { menu_icon = "external", ... }
#
# Parameters:
# lbl: Display label
# ext_url: External URL
# lang: Language code
#
# Returns: Route record for external links
make_external_link
| String -> String -> String -> Dyn
= fun lbl ext_url lang =>
base_route & {
component = "ExternalLink",
path = ext_url,
language = lang,
title_key = lbl,
description_key = lbl,
is_external = true,
show_in_sitemap = false,
},
# Common menu orders for consistency
menu_orders = {
home = 1,
about = 2,
services = 3,
portfolio = 4,
blog = 5,
contact = 10,
admin = 100,
},
# Common priorities for SEO
priorities = {
critical = 1.0, # Home page
high = 0.9, # Main pages (About, Services)
normal = 0.8, # Content pages (Blog posts)
low = 0.5, # Archive, Tags
minimal = 0.3, # Admin, Login
},
}

View file

@ -0,0 +1,243 @@
# Server runtime configuration contracts
#
# Mirrors the Rust structs in rustelo_server/src/config/defs.rs.
# These contracts validate server, session, CORS, static files,
# directories, app, logging, redis, routing, cache, and RBAC path.
{
ServerConfig = {
protocol
| String
| doc "HTTP protocol: 'http' or 'https'"
| default = "http",
host
| String
| doc "Server bind address"
| default = "0.0.0.0",
port
| Number
| doc "Server port (165535)"
| default = 3030,
environment
| String
| doc "Runtime environment: 'development' or 'production'"
| default = "development",
log_level
| String
| doc "Log level: trace, debug, info, warn, error"
| default = "info",
},
SessionConfig = {
secret
| String
| doc "Session secret key — use '${SESSION_SECRET}' to read from env",
cookie_name
| String
| default = "session_id",
cookie_secure
| Bool
| doc "Require HTTPS for session cookies — set true in production"
| default = false,
cookie_http_only
| Bool
| default = true,
cookie_same_site
| String
| doc "SameSite policy: 'lax', 'strict', or 'none'"
| default = "lax",
max_age
| Number
| doc "Session lifetime in seconds"
| default = 3600,
},
CorsConfig = {
allowed_origins
| Array String
| default = ["http://localhost:3030"],
allowed_methods
| Array String
| default = ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
allowed_headers
| Array String
| default = ["Content-Type", "Authorization", "X-Requested-With"],
allow_credentials
| Bool
| default = true,
max_age
| Number
| doc "CORS preflight cache duration in seconds"
| default = 3600,
},
StaticFilesConfig = {
assets_dir
| String
| doc "Static assets directory (relative to root_path)"
| default = "public",
site_root
| String
| doc "cargo-leptos output directory"
| default = "target/site",
site_pkg_dir
| String
| doc "WASM/JS package subdirectory under site_root"
| default = "pkg",
},
ServerDirConfig = {
public_dir | String | default = "public",
uploads_dir | String | default = "uploads",
logs_dir | String | default = "logs",
temp_dir | String | default = "tmp",
cache_dir | String | default = "cache",
config_dir | String | default = "config",
data_dir | String | default = "data",
backup_dir | String | default = "backups",
template_dir | String | optional,
},
AppConfig = {
name
| String
| default = "Website",
version
| String
| default = "0.1.0",
debug
| Bool
| default = true,
enable_metrics
| Bool
| default = false,
enable_health_check
| Bool
| default = true,
enable_compression
| Bool
| default = true,
max_request_size
| Number
| doc "Maximum request body size in bytes"
| default = 10485760,
admin_email
| String
| optional,
},
LoggingConfig = {
format
| String
| doc "Log format: 'pretty' (dev) or 'json' (prod)"
| default = "pretty",
level
| String
| default = "debug",
file_path
| String
| default = "logs/app.log",
max_file_size
| Number
| doc "Log rotation threshold in bytes"
| default = 10485760,
max_files
| Number
| doc "Maximum rotated log files to keep"
| default = 5,
enable_console
| Bool
| default = true,
enable_file
| Bool
| default = false,
},
RedisConfig = {
enabled | Bool | default = false,
url | String | default = "redis://localhost:6379",
pool_size | Number | default = 10,
connection_timeout | Number | default = 5,
command_timeout | Number | default = 5,
},
RoutingConfig = {
enable_tracking | Bool | optional,
tracking_log_path | String | optional,
max_events_in_memory | Number | optional,
slow_resolution_threshold_ms | Number | optional,
enable_console_debug | Bool | optional,
cache_max_entries | Number | optional,
cache_ttl_seconds | Number | optional,
},
CacheConfig = {
persistent_enabled
| Bool
| default = false,
memory_enabled
| Bool
| default = true,
memory_max_size
| Number
| doc "Memory cache capacity in bytes"
| default = 104857600,
memory_ttl
| Number
| doc "Memory cache TTL in seconds"
| default = 3600,
persistent_ttl
| Number
| doc "Persistent cache TTL in seconds"
| default = 86400,
persistent_max_size
| Number
| doc "Persistent cache max size in bytes"
| default = 1073741824,
persistent_cleanup_interval
| Number
| doc "Cleanup task interval in seconds"
| default = 3600,
},
RbacNclConfig = {
ncl_path
| String
| doc "Path to rbac.ncl policy file (hot-reloadable)"
| default = "site/rbac.ncl",
},
}

View file

@ -0,0 +1,308 @@
# Unified Site Configuration Contracts
#
# Type contracts for site.ncl — validates the complete site configuration
# including site metadata, paths, themes, content types, and routes with
# embedded multilingual menu data.
#
# JSON output of site.ncl is deserialized by build_config::site_config::UnifiedSiteConfig.
{
SiteMetadata = {
name
| String
| doc "Site display name",
languages
| Array String
| doc "Supported language codes (ISO 639-1, e.g. ['en', 'es'])",
default_language
| String
| doc "Default language code when none is specified in URL",
},
SitePaths = {
i18n
| String
| doc "Path to i18n/FTL locales directory",
content
| String
| doc "Path to content directory",
themes
| String
| doc "Path to themes configuration directory",
assets
| String
| doc "Path to public assets directory",
migrations
| String
| doc "Path to SQL migration files directory (server-only)"
| optional,
email_templates
| String
| doc "Path to email template files directory (server-only)"
| optional,
work
| String
| doc "Path to work-in-progress / staging assets directory (implementation-specific)"
| optional,
},
ThemeConfig = {
default
| String
| doc "Default theme name",
available
| Array String
| doc "List of available theme names",
},
ContentTypeFeatures = {
style_mode
| String
| doc "Display style: 'row' or 'grid'",
use_feature
| Bool
| doc "Enable featured content at top of index",
use_categories
| Bool
| default = true,
use_tags
| Bool
| default = true,
use_emojis
| Bool
| default = false,
cta_view
| String
| doc "CTA component name (e.g. 'BlogCTAView')",
default_page_size
| Number
| default = 10,
# Allow additional feature fields without breaking the contract
..
},
ContentType = {
name
| String
| doc "Content type identifier (e.g. 'blog', 'recipes')",
directory
| String
| doc "Directory name under content path",
enabled
| Bool
| default = true,
features
| ContentTypeFeatures
| doc "Content display and pagination settings",
},
# Database connection configuration.
# Structural settings (type, path, pool sizing) belong here.
# Credentials and full overrides go in .env via DATABASE_URL.
DatabaseConfig = {
url
| String
| doc "Database connection URL (e.g. 'sqlite:data/dev.db' or 'postgres://...')",
create_if_missing
| Bool
| doc "Create the SQLite database file if it does not exist"
| default = true,
max_connections
| Number
| doc "Maximum number of connections in the pool"
| default = 5,
min_connections
| Number
| doc "Minimum number of idle connections in the pool"
| default = 1,
connect_timeout
| Number
| doc "Connection acquisition timeout in seconds"
| default = 30,
idle_timeout
| Number
| doc "Maximum idle connection duration in seconds"
| default = 600,
max_lifetime
| Number
| doc "Maximum connection lifetime in seconds"
| default = 1800,
},
# Footer metadata — absorbed inline into SiteConfig.
FooterMetadata = {
title
| String
| doc "Footer title (language-neutral, e.g. author name)",
company_desc
| { _ : String }
| doc "Company description keyed by language code",
copyright_text
| { _ : String }
| doc "Copyright notice keyed by language code",
social_links
| Array {
name | String,
url | String,
icon | String,
}
| default = [],
},
# Menu configuration embedded directly in Route.
# At least one of label_key or labels must be present (validated by Nickel contracts
# plus runtime assertion in Rust loader).
MenuEntry = {
enabled
| Bool
| doc "Whether this route appears in navigation"
| default = false,
order
| Number
| doc "Display order (lower = first)"
| optional,
icon
| String
| doc "Icon identifier (e.g. 'home', 'fas fa-user')"
| optional,
label_key
| String
| doc "Primary: FTL i18n key for label (e.g. 'nav-home')"
| optional,
labels
| { _ : String }
| doc "Fallback inline labels keyed by language code"
| optional,
visible_in
| Array String
| doc "Language codes this item is visible in. Empty = all languages."
| default = [],
use_in
| Array String
| doc "Navigation zones: ['header'], ['footer'], or ['header','footer']"
| default = ["header"],
},
# A unified route entry covering all languages in a single record.
# paths maps language code → URL path (e.g. { en = '/', es = '/inicio' }).
Route = {
component
| String
| doc "Page component name (e.g. 'HomePage', 'ContentIndex')",
paths
| { _ : String }
| doc "URL paths keyed by language code",
priority
| Number
| doc "SEO/sitemap priority (0.01.0)"
| default = 0.8,
content_type
| String
| doc "Content type for dynamic routes (e.g. 'blog')"
| optional,
menu
| MenuEntry
| doc "Navigation menu configuration for this route",
props
| { _ : Dyn }
| doc "Additional route-specific properties passed to component"
| optional,
},
LogoConfig = {
light_src
| String
| doc "Path to light-mode logo image",
dark_src
| String
| doc "Path to dark-mode logo image (empty string disables dark variant)",
alt
| String
| doc "Alt text for the logo image",
show_in_nav
| Bool
| doc "Whether to show the logo in the navigation bar",
show_in_footer
| Bool
| doc "Whether to show the logo in the footer",
width
| optional
| doc "Optional logo width (CSS value or null)",
height
| optional
| doc "Optional logo height (CSS value or null)",
},
SiteConfig = {
site
| SiteMetadata,
paths
| SitePaths,
themes
| ThemeConfig,
logo
| LogoConfig,
content_types
| Array ContentType,
database
| DatabaseConfig
| doc "Database connection and pool configuration",
footer
| FooterMetadata,
routes
| Array Route,
},
}

View file

@ -0,0 +1,96 @@
# Unified Site Configuration Defaults and Helpers
#
# Provides make_route, make_content_route, make_external and shared
# priority/order constants for site.ncl.
#
# All helpers return open records so callers can merge additional fields
# with the & operator.
let contracts = import "contracts.ncl" in
{
# SEO priority constants
priorities = {
critical = 1.0,
high = 0.9,
normal = 0.8,
low = 0.5,
minimal = 0.3,
},
# Default menu order constants for consistent navigation
orders = {
home = 1,
about = 2,
services = 3,
portfolio = 4,
projects = 5,
blog = 6,
recipes = 7,
contact = 10,
external = 100,
},
# Sentinel for routes that do not appear in any navigation menu
no_menu = {
enabled = false,
visible_in = [],
},
# make_route
#
# Create a standard page route covering multiple languages.
#
# Parameters:
# component : String — Component name, e.g. "HomePage"
# path_map : Record — Language → URL, e.g. { en = "/", es = "/inicio" }
#
# Returns a Route record with priority and menu set to defaults.
# Callers add specifics with & { priority = ..., menu = { ... } }.
make_route
| String -> { _ : String } -> Dyn
= fun comp path_map =>
{
component = comp,
paths = path_map,
priority | default = 0.8,
menu | default = { enabled = false, visible_in = [] },
},
# make_content_route
#
# Like make_route but also sets content_type for dynamic content pages.
#
# Parameters:
# comp : String — Component name (typically "ContentIndex")
# ct : String — Content kind, e.g. "blog", "recipes"
# path_map : Record — Language → URL
make_content_route
| String -> String -> { _ : String } -> Dyn
= fun comp ct path_map =>
make_route comp path_map & {
content_type = ct,
},
# make_external
#
# Create an external link entry (same URL for all languages).
# Produces a Route record with component = "ExternalLink".
#
# Parameters:
# lbl : String — Display label (used as fallback for all languages)
# ext_url : String — Full external URL
make_external
| String -> String -> Dyn
= fun lbl ext_url =>
{
component = "ExternalLink",
paths = { external = ext_url },
priority = 0.3,
menu = {
enabled = true,
labels = { en = lbl, es = lbl },
visible_in | default = [],
},
},
}

View file

@ -0,0 +1,187 @@
# Theme Configuration Contracts
#
# Type contracts for theme configuration with support for inheritance and variants.
{
# Theme metadata
Metadata = {
name
| String
| doc "Theme name (e.g., 'Default', 'Dark', 'Corporate')",
description
| String
| doc "Theme description",
version
| String
| default = "1.0.0",
author
| String
| default = "Rustelo Contributors",
extends
| String
| doc "Parent theme to inherit from (e.g., 'default')"
| optional,
..
},
# Color palette
Colors = {
# Primary brand colors
primary | String | doc "Primary brand color",
primary_hover | String | doc "Primary hover state",
secondary | String | doc "Secondary color",
secondary_hover | String | doc "Secondary hover state",
accent | String | doc "Accent color",
# Semantic colors
success | String | doc "Success state color",
warning | String | doc "Warning state color",
error | String | doc "Error state color",
info | String | doc "Info state color",
# Surface colors
surface | String | doc "Background surface color",
surface_dark | String | doc "Dark surface color",
surface_hover | String | doc "Surface hover state",
surface_hover_dark | String | doc "Dark surface hover state",
# Text colors
text | String | doc "Primary text color",
text_dark | String | doc "Dark text color",
text_secondary | String | doc "Secondary text color",
text_secondary_dark | String | doc "Dark secondary text color",
# Border colors
border | String | doc "Border color",
border_dark | String | doc "Dark border color",
..
},
# Typography settings
Typography = {
font_family_sans | String | default = "Inter, ui-sans-serif, system-ui, sans-serif",
font_family_mono | String | default = "JetBrains Mono, ui-monospace, monospace",
font_size_xs | String | default = "0.75rem",
font_size_sm | String | default = "0.875rem",
font_size_base | String | default = "1rem",
font_size_lg | String | default = "1.125rem",
font_size_xl | String | default = "1.25rem",
font_size_2xl | String | default = "1.5rem",
font_size_3xl | String | default = "1.875rem",
line_height_tight | String | default = "1.25",
line_height_normal | String | default = "1.5",
line_height_relaxed | String | default = "1.75",
..
},
# Spacing scale
Spacing = {
xs | String | default = "0.25rem",
sm | String | default = "0.5rem",
md | String | default = "1rem",
lg | String | default = "1.5rem",
xl | String | default = "2rem",
"2xl" | String | default = "2.5rem",
"3xl" | String | default = "3rem",
..
},
# Border radius values
Radius = {
sm | String | default = "0.25rem",
md | String | default = "0.375rem",
lg | String | default = "0.5rem",
xl | String | default = "0.75rem",
"2xl" | String | default = "1rem",
full | String | default = "9999px",
..
},
# Shadow definitions
Shadows = {
sm | String | optional,
base | String | optional,
md | String | optional,
lg | String | optional,
xl | String | optional,
..
},
# Component-specific settings
Components = {
button | Dyn | optional,
card | Dyn | optional,
input | Dyn | optional,
navbar | Dyn | optional,
footer | Dyn | optional,
..
},
# Asset paths
Assets = {
logo_light | String | optional,
logo_dark | String | optional,
logo_icon | String | optional,
favicon | String | optional,
favicon_dark | String | optional,
logo_alt | String | optional,
logo_href | String | optional,
logo_show_in_nav | Bool | optional,
logo_show_in_footer | Bool | optional,
logo_width | String | optional,
logo_height | String | optional,
..
},
# Animations
Animations = {
duration_fast | String | optional,
duration_normal | String | optional,
duration_slow | String | optional,
easing | String | optional,
..
},
# Responsive breakpoints
Breakpoints = {
sm | String | optional,
md | String | optional,
lg | String | optional,
xl | String | optional,
"2xl" | String | optional,
..
},
# Root theme configuration
ThemeFile = {
metadata | Metadata,
colors | Colors,
typography | Typography | optional,
spacing | Spacing | optional,
radius | Radius | optional,
shadows | Shadows | optional,
components | Components | optional,
assets | Assets | optional,
animations | Animations | optional,
breakpoints | Breakpoints | optional,
..
},
}

View file

@ -0,0 +1,298 @@
# Theme Configuration Defaults and Helpers
#
# Provides base theme with full configuration and helpers for creating variants.
let contracts = import "contracts.ncl" in
{
# Base theme with complete default configuration
# All theme variants should extend this
base_theme = {
typography = {
font_family_sans = "Inter, ui-sans-serif, system-ui, sans-serif",
font_family_mono = "JetBrains Mono, ui-monospace, 'Cascadia Code', monospace",
font_size_xs = "0.75rem",
font_size_sm = "0.875rem",
font_size_base = "1rem",
font_size_lg = "1.125rem",
font_size_xl = "1.25rem",
font_size_2xl = "1.5rem",
font_size_3xl = "1.875rem",
line_height_tight = "1.25",
line_height_normal = "1.5",
line_height_relaxed = "1.75",
},
spacing = {
xs = "0.25rem",
sm = "0.5rem",
md = "1rem",
lg = "1.5rem",
xl = "2rem",
"2xl" = "2.5rem",
"3xl" = "3rem",
},
radius = {
sm = "0.25rem",
md = "0.375rem",
lg = "0.5rem",
xl = "0.75rem",
"2xl" = "1rem",
full = "9999px",
},
shadows = {
sm = "0 1px 2px 0 rgb(0 0 0 / 0.05)",
base = "0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)",
md = "0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)",
lg = "0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1)",
xl = "0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1)",
},
components = {
button = {
border_radius = "0.5rem",
padding_x_sm = "0.75rem",
padding_y_sm = "0.375rem",
padding_x_md = "1rem",
padding_y_md = "0.5rem",
padding_x_lg = "1.5rem",
padding_y_lg = "0.75rem",
font_weight = "500",
transition = "all 200ms ease-in-out",
},
card = {
border_radius = "0.75rem",
padding = "1.5rem",
shadow = "sm",
border_width = "1px",
},
input = {
border_radius = "0.375rem",
padding_x = "0.75rem",
padding_y = "0.5rem",
border_width = "1px",
transition = "border-color 200ms ease-in-out, box-shadow 200ms ease-in-out",
},
navbar = {
height = "4rem",
padding_x = "1rem",
backdrop_blur = "true",
border_bottom_width = "1px",
},
footer = {
padding_y = "2rem",
border_top_width = "1px",
},
},
assets = {
logo_light = "/logos/jesusperez-logo-b.png",
logo_dark = "/logos/jesusperez-logo-w.png",
logo_icon = "/logos/logo-icon.svg",
favicon = "/images/favicon.ico",
favicon_dark = "/images/favicon.ico",
logo_alt = "Jesús Pérez Lorenzo",
logo_href = "/",
logo_show_in_nav = true,
logo_show_in_footer = true,
logo_width = "auto",
logo_height = "2.5rem",
logo_height_sm = "2rem",
logo_height_md = "2.5rem",
logo_height_lg = "3rem",
},
animations = {
duration_fast = "150ms",
duration_normal = "200ms",
duration_slow = "300ms",
easing = "cubic-bezier(0.4, 0, 0.2, 1)",
},
breakpoints = {
sm = "640px",
md = "768px",
lg = "1024px",
xl = "1280px",
"2xl" = "1536px",
},
},
# Helper function: Create a color variant theme
#
# Usage:
# make_color_variant {
# name = "Dark",
# description = "Dark theme with high contrast",
# colors = { primary = "#60a5fa", ... },
# }
#
# Parameters:
# config: Record with name, description, colors, and optional assets
#
# Returns: Complete theme with colors overridden
make_color_variant
| Dyn -> Dyn
= fun config =>
# For assets, rebuild the entire record with conditional field selection
# This avoids merge conflicts when overriding string fields
let final_assets =
if std.record.has_field "assets" config then
{
logo_light = if std.record.has_field "logo_light" config.assets then config.assets.logo_light else base_theme.assets.logo_light,
logo_dark = if std.record.has_field "logo_dark" config.assets then config.assets.logo_dark else base_theme.assets.logo_dark,
logo_icon = if std.record.has_field "logo_icon" config.assets then config.assets.logo_icon else base_theme.assets.logo_icon,
favicon = if std.record.has_field "favicon" config.assets then config.assets.favicon else base_theme.assets.favicon,
favicon_dark = if std.record.has_field "favicon_dark" config.assets then config.assets.favicon_dark else base_theme.assets.favicon_dark,
logo_alt = if std.record.has_field "logo_alt" config.assets then config.assets.logo_alt else base_theme.assets.logo_alt,
logo_href = if std.record.has_field "logo_href" config.assets then config.assets.logo_href else base_theme.assets.logo_href,
logo_show_in_nav = if std.record.has_field "logo_show_in_nav" config.assets then config.assets.logo_show_in_nav else base_theme.assets.logo_show_in_nav,
logo_show_in_footer = if std.record.has_field "logo_show_in_footer" config.assets then config.assets.logo_show_in_footer else base_theme.assets.logo_show_in_footer,
logo_width = if std.record.has_field "logo_width" config.assets then config.assets.logo_width else base_theme.assets.logo_width,
logo_height = if std.record.has_field "logo_height" config.assets then config.assets.height else base_theme.assets.logo_height,
logo_height_sm = if std.record.has_field "logo_height_sm" config.assets then config.assets.logo_height_sm else base_theme.assets.logo_height_sm,
logo_height_md = if std.record.has_field "logo_height_md" config.assets then config.assets.logo_height_md else base_theme.assets.logo_height_md,
logo_height_lg = if std.record.has_field "logo_height_lg" config.assets then config.assets.logo_height_lg else base_theme.assets.logo_height_lg,
}
else
base_theme.assets
in
# Explicitly construct new record to avoid merge conflicts
# Cannot use & merge because assets field has conflicting string values
{
metadata = {
name = config.name,
description = config.description,
version = "1.0.0",
author = "Rustelo Contributors",
extends = "default",
},
colors = config.colors,
typography = base_theme.typography,
spacing = base_theme.spacing,
radius = base_theme.radius,
shadows = base_theme.shadows,
components = base_theme.components,
assets = final_assets,
animations = base_theme.animations,
breakpoints = base_theme.breakpoints,
},
# Default light colors (Blue theme)
light_colors = {
primary = "#3b82f6",
primary_hover = "#2563eb",
secondary = "#6b7280",
secondary_hover = "#4b5563",
accent = "#f59e0b",
success = "#10b981",
warning = "#f59e0b",
error = "#ef4444",
info = "#06b6d4",
surface = "#ffffff",
surface_dark = "#1f2937",
surface_hover = "#f9fafb",
surface_hover_dark = "#374151",
text = "#111827",
text_dark = "#f9fafb",
text_secondary = "#6b7280",
text_secondary_dark = "#9ca3af",
border = "#e5e7eb",
border_dark = "#374151",
},
# Dark theme colors
dark_colors = {
primary = "#60a5fa",
primary_hover = "#3b82f6",
secondary = "#9ca3af",
secondary_hover = "#d1d5db",
accent = "#fbbf24",
success = "#34d399",
warning = "#fbbf24",
error = "#f87171",
info = "#22d3ee",
surface = "#111827",
surface_dark = "#030712",
surface_hover = "#1f2937",
surface_hover_dark = "#374151",
text = "#f9fafb",
text_dark = "#111827",
text_secondary = "#d1d5db",
text_secondary_dark = "#6b7280",
border = "#374151",
border_dark = "#4b5563",
},
# Corporate colors (Professional blue-gray)
corporate_colors = {
primary = "#1e40af",
primary_hover = "#1e3a8a",
secondary = "#64748b",
secondary_hover = "#475569",
accent = "#0ea5e9",
success = "#059669",
warning = "#d97706",
error = "#dc2626",
info = "#0284c7",
surface = "#ffffff",
surface_dark = "#1e293b",
surface_hover = "#f8fafc",
surface_hover_dark = "#334155",
text = "#0f172a",
text_dark = "#f8fafc",
text_secondary = "#64748b",
text_secondary_dark = "#94a3b8",
border = "#e2e8f0",
border_dark = "#334155",
},
# Purple colors (Creative purple theme)
purple_colors = {
primary = "#9333ea",
primary_hover = "#7e22ce",
secondary = "#71717a",
secondary_hover = "#52525b",
accent = "#f59e0b",
success = "#10b981",
warning = "#f59e0b",
error = "#ef4444",
info = "#06b6d4",
surface = "#ffffff",
surface_dark = "#18181b",
surface_hover = "#fafafa",
surface_hover_dark = "#27272a",
text = "#09090b",
text_dark = "#fafafa",
text_secondary = "#71717a",
text_secondary_dark = "#a1a1aa",
border = "#e4e4e7",
border_dark = "#3f3f46",
},
}

View file

@ -0,0 +1,86 @@
# Site-Info Documentation Templates
This directory contains the Tera templates used by the site-info system to generate documentation from code analysis.
## Structure
```
site-info/
├── documentation/ # Main page templates
│ ├── base.tera # Base layout template
│ ├── summary.tera # Summary pages with metrics
│ ├── reference.tera # Detailed reference docs
│ ├── automation.tera # Automation guide
│ └── top_level.tera # Top-level overview
├── partials/ # Reusable components
│ ├── route_table.tera # Route information table
│ ├── component_card.tera # Component display card
│ ├── source_link.tera # Source reference links
│ ├── metrics_summary.tera # Statistics summary
│ └── nav_breadcrumbs.tera # Navigation breadcrumbs
├── i18n/ # Language-specific templates
│ ├── en/ # English templates
│ └── es/ # Spanish templates
└── README.md # This file
```
## Configuration
Templates are configured in `config/site-info.toml`:
```toml
[templates]
engine = "tera"
templates_path = "site/templates/site-info"
default_language = "en"
supported_languages = ["en", "es"]
```
## Internationalization
Labels and text are loaded from FTL files in:
- `site/content/i18n/site-info/en/templates.ftl` (English)
- `site/content/i18n/site-info/es/templates.ftl` (Spanish)
## Template Context
Templates receive a `DocumentationContext` with:
- `doc.routes` - API route information
- `doc.components` - Component definitions
- `doc.pages` - Page route configurations
- `i18n.labels` - Localized text labels
- `summary` - Statistical information
- `metadata` - Generation metadata
- `theme` - Display configuration
## Custom Filters
Available Tera filters:
- `anchor` - Generate kebab-case anchors
- `source_link` - Format source reference links
- `pluralize` - Handle singular/plural forms
- `method_color` - HTTP method color coding
## Custom Functions
Available Tera functions:
- `toc(items)` - Generate table of contents
- `format_number(number)` - Format numbers with commas
## Environment Variables
- `SITE_TEMPLATES_PATH` - Base templates directory (default: "site/templates")
- `SITE_I18N_PATH` - I18n content directory (default: "site/content")
- `SITE_CONFIG_PATH` - Configuration directory (default: "config")
## Usage
Templates are automatically loaded by the site-info build system. To customize:
1. Edit templates in this directory
2. Update FTL files for text changes
3. Modify `config/site-info.toml` for settings
4. Rebuild the project to regenerate documentation

View file

@ -0,0 +1,190 @@
{% extends "documentation/base.tera" %}
{% block title %}{{ i18n.labels.title_automation }}{% endblock title %}
{% block toc %}
## {{ i18n.labels.table_of_contents }}
- [{{ i18n.labels.overview }}](#overview)
- [Rustelo Manager Integration](#rustelo-manager)
- [MCP Server Integration](#mcp-server)
- [API Client Generation](#api-client)
- [Testing Automation](#testing)
- [Documentation Generation](#docs-generation)
- [CI/CD Integration](#ci-cd)
{% endblock toc %}
{% block content %}
## {{ i18n.labels.overview }} {% raw %}{#overview}{% endraw %}
The Rustelo route documentation system generates comprehensive metadata that powers numerous automation scenarios beyond basic API client generation. This system is designed to be the single source of truth for all routing, component, and page information in your application.
## 🚀 Automation Possibilities
### 1. Rustelo Manager Integration {% raw %}{#rustelo-manager}{% endraw %}
The generated documentation directly powers the **Rustelo Manager** dashboard:
- **Route Discovery**: Automatically populates available routes for navigation
- **Component Inspector**: Live component documentation and prop validation
- **Page Editor**: Dynamic form generation based on page route configurations
- **Feature Detection**: Shows which features are enabled for each route
```rust
// Example: Manager uses generated data for route management
let routes = load_route_data("site/info/server/data.toml");
manager.populate_routes(routes);
```
### 2. MCP Server Integration {% raw %}{#mcp-server}{% endraw %}
Transform your Rustelo app into an **MCP (Model Context Protocol) Server**:
- **Route Endpoints**: Expose API routes as MCP tools
- **Component Library**: Share component definitions with AI assistants
- **Page Templates**: Generate new pages using existing patterns
- **Real-time Updates**: Live documentation updates via MCP
```rust
// Example: MCP tool for route information
#[mcp_tool]
fn get_route_info(path: &str) -> Result<RouteInfo, McpError> {
let routes = load_generated_routes()?;
routes.find_by_path(path).ok_or_else(|| McpError::NotFound)
}
```
### 3. API Client Generation {% raw %}{#api-client}{% endraw %}
Generate type-safe API clients from route documentation:
{% if doc.routes %}
**Available Routes for Client Generation:**
{% for route in doc.routes %}
{% if route.methods %}
- `{{ route.path }}` - {{ route.handler }}
{% endif %}
{% endfor %}
{% endif %}
```typescript
// Generated TypeScript client
export class RusteloApiClient {
{% for route in doc.routes %}
{% if route.methods %}
async {{ route.handler }}(): Promise<{{ route.response_type }}> {
return this.request('{{ route.path }}', 'GET');
}
{% endif %}
{% endfor %}
}
```
### 4. Testing Automation {% raw %}{#testing}{% endraw %}
Automated test generation from route specifications:
```rust
// Generated integration tests
{% for route in doc.routes %}
{% if route %}
#[tokio::test]
async fn test_route_{{ loop.index }}() {
let app = create_test_app().await;
let response = app
.oneshot(Request::builder()
.uri("{{ route.path }}")
.method("{{ route.methods | first }}")
.body(Body::empty())?)
.await?;
assert_eq!(response.status(), StatusCode::OK);
}
{% endif %}
{% endfor %}
```
### 5. Documentation Generation {% raw %}{#docs-generation}{% endraw %}
Multi-format documentation from the same source:
- **OpenAPI/Swagger**: Auto-generated API specs
- **Markdown**: Human-readable documentation (this file!)
- **JSON Schema**: Machine-readable specifications
- **Postman Collections**: Ready-to-use API collections
### 6. CI/CD Integration {% raw %}{#ci-cd}{% endraw %}
Automate deployment and validation:
```yaml
# GitHub Actions integration
- name: Validate Route Documentation
run: |
cargo run --bin site-info-validator
- name: Generate API Documentation
run: |
cargo run --bin docs-generator --format openapi
- name: Update Postman Collection
run: |
cargo run --bin postman-generator
```
## 📊 Current System Statistics
Based on the current route analysis:
- **{{ summary.total_routes }}** total routes available for automation
- **{{ summary.api_routes }}** API routes for client generation
- **{{ summary.static_routes }}** static routes for optimization
- **{{ summary.total_components }}** components for template generation
- **{{ summary.total_pages }}** pages across **{{ summary.languages | length }}** languages
## 🔧 Integration Examples
### Route Validation Pipeline
```rust
use site_info::route_analysis::RouteDocumentation;
// Validate all routes are documented
fn validate_documentation_completeness() -> Result<(), ValidationError> {
let docs = RouteDocumentation::load_from_analysis()?;
// Check all routes have descriptions
for route in &docs.api_routes {
if route.description.is_none() {
eprintln!("Warning: Route {} lacks description", route.path);
}
}
// Validate auth requirements consistency
validate_auth_consistency(&docs.api_routes)?;
Ok(())
}
```
### Dynamic Menu Generation
```rust
// Generate navigation menus from page routes
fn generate_navigation_menu(language: &str) -> Result<NavMenu, MenuError> {
let docs = RouteDocumentation::load_from_analysis()?;
let pages = docs.page_routes
.iter()
.filter(|p| p.language == language && p.enabled)
.collect();
NavMenu::from_pages(pages)
}
```
This automation system transforms static route definitions into a powerful foundation for development tooling, testing, and deployment automation.
{% endblock content %}

View file

@ -0,0 +1,11 @@
{% include "partials/header.tera" %}
# {% block title %}{{ i18n.labels.title }}{% endblock title %}
{% if theme.show_toc %}
{% block toc %}{% endblock toc %}
{% endif %}
{% block content %}{% endblock content %}
{% include "partials/footer.tera" %}

View file

@ -0,0 +1,125 @@
{% extends "documentation/base.tera" %}
{% block title %}{{ i18n.labels.title_server_reference }}{% endblock title %}
{% block toc %}
## {{ i18n.labels.table_of_contents }}
{% if doc.routes %}
- [{{ i18n.labels.routes }}](#routes)
{% for route in doc.routes %}
- [{{ route.path }}](#{{ route.anchor }})
{% endfor %}
{% endif %}
{% if doc.components %}
- [{{ i18n.labels.components }}](#components)
{% for component in doc.components %}
- [{{ component.name }}](#{{ component.anchor }})
{% endfor %}
{% endif %}
{% if doc.pages %}
- [{{ i18n.labels.pages }}](#pages)
{% for page in doc.pages %}
- [{{ page.path }}](#{{ page.anchor }})
{% endfor %}
{% endif %}
{% endblock toc %}
{% block content %}
{% if doc.routes %}
## {{ i18n.labels.routes }} {% raw %}{#routes}{% endraw %}
{% for route in doc.routes %}
### {{ route.path }} {% raw %}{#{% endraw %}{{ route.anchor }}{% raw %}}{% endraw %}}
{% if route.src_ref %}
{% include "partials/source_link.tera" %}
{% endif %}
**{{ i18n.labels.methods }}:** {% for method in route.methods %}`{{ method }}`{% if not loop.last %}, {% endif %}{% endfor %}
**{{ i18n.labels.handler }}:** `{{ route.handler }}`
**{{ i18n.labels.module }}:** `{{ route.module }}`
**{{ i18n.labels.response_type }}:** {{ route.response_type }}
**{{ i18n.labels.requires_auth }}:** {{ route.requires_auth }}
{% if route.middleware %}
**{{ i18n.labels.middleware }}:** {% for mw in route.middleware %}`{{ mw }}`{% if not loop.last %}, {% endif %}{% endfor %}
{% endif %}
{% if route.parameters %}
**{{ i18n.labels.parameters }}:**
| Name | Type | Source | Optional |
|---|---|---|---|
{% for param in route.parameters %}
| `{{ param.name }}` | `{{ param.param_type }}` | {{ param.source }} | {{ param.optional }} |
{% endfor %}
{% endif %}
{% if route.description %}
**{{ i18n.labels.description }}:** {{ route.description }}
{% endif %}
---
{% endfor %}
{% endif %}
{% if doc.components %}
## {{ i18n.labels.components }} {% raw %}{#components}{% endraw %}
{% for component in doc.components %}
{% include "partials/component_card.tera" %}
{% endfor %}
{% endif %}
{% if doc.pages %}
## {{ i18n.labels.pages }} {% raw %}{#pages}{% endraw %}
{% for page in doc.pages %}
#### {{ page.path }} {% raw %}{#{% endraw %}{{ page.anchor }}{% raw %}}{% endraw %}}
{% if page.src_ref %}
{% include "partials/source_link.tera" %}
{% endif %}
**Component:** `{{ page.component }}`
**Page Component:** `{{ page.page_component }}`
**Unified Component:** `{{ page.unified_component }}`
{% if page.module_path %}
**{{ i18n.labels.module }}:** `{{ page.module_path }}`
{% endif %}
**{{ i18n.labels.enabled }}:** {{ page.enabled }}
**{{ i18n.labels.priority }}:** {{ page.priority }}
{% if page.requires_auth is defined %}
**{{ i18n.labels.requires_auth }}:** {{ page.requires_auth }}
{% endif %}
{% if page.menu_group %}
**Menu Group:** {{ page.menu_group }}
{% endif %}
{% if page.keywords %}
**{{ i18n.labels.keywords }}:** {{ page.keywords | join(sep=", ") }}
{% endif %}
---
{% endfor %}
{% endif %}
{% endblock content %}

View file

@ -0,0 +1,71 @@
{% extends "documentation/base.tera" %}
{% block title %}{{ i18n.labels.title_server_summary }}{% endblock title %}
{% block toc %}
## {{ i18n.labels.table_of_contents }}
- [{{ i18n.labels.overview }}](#overview)
{% if doc.routes %}
- [{{ i18n.labels.routes }}](#routes)
{% endif %}
{% if doc.components %}
- [{{ i18n.labels.components }}](#components)
{% endif %}
{% if doc.pages %}
- [{{ i18n.labels.pages }}](#pages)
{% endif %}
- [{{ i18n.labels.summary }}](#statistics)
{% endblock toc %}
{% block content %}
## {{ i18n.labels.overview }} {% raw %}{#overview}{% endraw %}
{% include "partials/metrics_summary.tera" %}
{% if doc.routes %}
## {{ i18n.labels.routes }} {% raw %}{#routes}{% endraw %}
{% include "partials/route_table.tera" %}
**Quick Navigation:**
{% for route in doc.routes %}
- [{{ route.path }}](reference.md#{{ route.anchor }})
{% endfor %}
{% endif %}
{% if doc.components %}
## {{ i18n.labels.components }} {% raw %}{#components}{% endraw %}
| {{ i18n.labels.components }} | Type | {{ i18n.labels.module }} |
|---|---|---|
{% for component in doc.components %}
| [{{ component.name }}](reference.md#{{ component.anchor }}) | {{ component.component_type }} | `{{ component.module_path }}` |
{% endfor %}
{% endif %}
{% if doc.pages %}
## {{ i18n.labels.pages }} {% raw %}{#pages}{% endraw %}
### {{ i18n.labels.languages | upper }}
{% for page in doc.pages %}
- [{{ page.path }}](reference.md#{{ page.anchor }}) ({{ page.language | upper }}) - {{ page.component }}
{% endfor %}
{% endif %}
## {{ i18n.labels.summary }} {% raw %}{#statistics}{% endraw %}
| Metric | Count |
|---|---|
| {{ i18n.labels.total_routes }} | {{ summary.total_routes }} |
| {{ i18n.labels.total_components }} | {{ summary.total_components }} |
| {{ i18n.labels.total_pages }} | {{ summary.total_pages }} |
| {{ i18n.labels.auth_required }} | {{ summary.auth_required_routes }} |
| {{ i18n.labels.static_routes }} | {{ summary.static_routes }} |
| {{ i18n.labels.api_routes }} | {{ summary.api_routes }} |
| {{ i18n.labels.languages }} | {{ summary.languages | join(sep=", ") }} |
{% endblock content %}

View file

@ -0,0 +1,144 @@
{% extends "documentation/base.tera" %}
{% block title %}{{ i18n.labels.title_top_level }}{% endblock title %}
{% block toc %}
## {{ i18n.labels.table_of_contents }}
- [{{ i18n.labels.overview }}](#overview)
- [🔧 Server Routes](#server-routes)
- [📄 Page Routes](#page-routes)
- [🧩 Components](#components)
- [🤖 Automation](#automation)
- [📊 Statistics](#statistics)
{% endblock toc %}
{% block content %}
## {{ i18n.labels.overview }} {% raw %}{#overview}{% endraw %}
This is a comprehensive documentation system for the **{{ metadata.project_name }}** web application. The documentation is automatically generated from code analysis and provides detailed information about routes, components, and pages.
### Quick Navigation
| Section | {{ i18n.labels.summary }} | Reference | Description |
|---------|---------|-----------|-------------|
| **🔧 Server** | [server/summary.md](server/summary.md) | [server/reference.md](server/reference.md) | API routes, handlers, and middleware |
| **📄 Pages** | [pages/summary.md](pages/summary.md) | [pages/reference.md](pages/reference.md) | Page routes and components |
| **🧩 Components** | [components/summary.md](components/summary.md) | [components/reference.md](components/reference.md) | Reusable UI components |
---
## 🔧 Server Routes {% raw %}{#server-routes}{% endraw %}
**{{ summary.total_routes }}** total routes | **{{ summary.api_routes }}** API routes | **{{ summary.static_routes }}** static routes
### Route Categories
{# Routes grouped by auth requirement #}
- **🔒 Authentication Required**: {{ auth_routes | length }} routes
- **🌐 Public Access**: {{ public_routes | length }} routes
- **📁 Static Files**: {{ summary.static_routes }} routes
### Popular Routes
{% for route in doc.routes %}
- [`{{ route.path }}`](server/reference.md#{{ route.anchor }}) - {{ route.response_type }}
{% endfor %}
[**View all server routes →**](server/reference.md)
---
## 📄 Page Routes {% raw %}{#page-routes}{% endraw %}
**{{ summary.total_pages }}** pages across **{{ summary.languages | length }}** languages
### By Language
**Multiple languages supported**
### Recent Pages
{% for page in doc.pages %}
- [`{{ page.path }}`](pages/reference.md#{{ page.anchor }}) ({{ page.language | upper }}) - {{ page.component }}
{% endfor %}
[**View all page routes →**](pages/reference.md)
---
## 🧩 Components {% raw %}{#components}{% endraw %}
**{{ summary.total_components }}** reusable components
### Component Types
**Various component types available**
### Key Components
{% for component in doc.components %}
- [`{{ component.name }}`](components/reference.md#{{ component.anchor }}) - {{ component.component_type }}
{% endfor %}
[**View all components →**](components/reference.md)
---
## 🤖 Automation {% raw %}{#automation}{% endraw %}
This documentation system powers several automation features:
### Available Integrations
- **🎛️ Rustelo Manager** - Live route management and editing
- **🔌 MCP Server** - AI assistant integration via Model Context Protocol
- **📝 API Clients** - Auto-generated TypeScript/Rust clients
- **🧪 Test Generation** - Automated integration tests
- **📋 OpenAPI Specs** - Swagger/Postman collections
### Quick Actions
```bash
# Generate API client
cargo run --bin api-client-generator --lang typescript
# Export to OpenAPI
cargo run --bin openapi-exporter --output api-spec.yaml
# Validate documentation
cargo run --bin docs-validator
```
[**View automation guide →**](automation/reference.md)
---
## 📊 Statistics {% raw %}{#statistics}{% endraw %}
### System Overview
| Metric | Value | Notes |
|--------|-------|-------|
| **{{ i18n.labels.total_routes }}** | {{ summary.total_routes }} | Including API and static routes |
| **{{ i18n.labels.total_components }}** | {{ summary.total_components }} | Reusable UI components |
| **{{ i18n.labels.total_pages }}** | {{ summary.total_pages }} | Across all languages |
| **{{ i18n.labels.languages }}** | {{ summary.languages | join(sep=", ") }} | Supported languages |
| **{{ i18n.labels.auth_required }}** | {{ summary.auth_required_routes }} | Routes requiring authentication |
### Documentation Coverage
- ✅ **Route Documentation**: {{ (summary.total_routes / summary.total_routes * 100) | round }}%
- ✅ **Component Documentation**: {{ (summary.total_components / summary.total_components * 100) | round }}%
- ✅ **Page Documentation**: {{ (summary.total_pages / summary.total_pages * 100) | round }}%
---
*Last updated: {{ metadata.generated_at }}*
**Need help?** Check the [automation guide](automation/reference.md) for integration examples and advanced usage.
{% endblock content %}

View file

@ -0,0 +1,26 @@
### {{ component.name }} {% raw %}{#{% endraw %}{{ component.anchor }}{% raw %}}{% endraw %}}
{% if component.src_ref %}
{% include "partials/source_link.tera" %}
{% endif %}
**Type:** {{ component.component_type }}
**{{ i18n.labels.module }}:** `{{ component.module_path }}`
{% if component.props %}
**Props:** {% for prop in component.props %}`{{ prop }}`{% if not loop.last %}, {% endif %}{% endfor %}
{% endif %}
{% if component.description %}
**{{ i18n.labels.description }}:** {{ component.description }}
{% endif %}
{% if component.usage_example %}
**Usage Example:**
```rust
{{ component.usage_example }}
```
{% endif %}
---

View file

@ -0,0 +1,15 @@
{# Site footer partial with Rustelo branding and generation info #}
---
<div align="center">
*{{ i18n.labels.generated_at }}: **{{ metadata.generated_at }}***
[![Rustelo Logo]({{ rustelo_logo_path | default(value="/logos/projects/rustelo_dev-logo-b-v.svg") }})]({{ rustelo_url | default(value="https://rustelo.dev") }})
**{{ i18n.labels.made_with }} [Rustelo]({{ rustelo_url | default(value="https://rustelo.dev") }})** - {{ i18n.labels.rustelo_tagline | default(value="Modern Rust Web Framework") }}
*{{ i18n.labels.rustelo_description | default(value="Blazingly fast, type-safe web applications with Rust + Leptos + Axum") }}*
</div>

View file

@ -0,0 +1,11 @@
{# Site header partial with logo and branding #}
<div align="center">
![Site Logo]({{ site_logo_path | default(value="/logos/jesusperez-logo-b.png") }})
# {{ site_name | default(value="Jesús Pérez") }}
{{ site_tagline | default(value="Professional Web Development & DevOps Solutions") }}
---
</div>

View file

@ -0,0 +1,11 @@
### 📊 Quick Metrics
| Metric | Count | Details |
|--------|-------|---------|
| 🌐 {{ i18n.labels.total_routes }} | **{{ summary.total_routes }}** | {{ summary.api_routes }} API + {{ summary.static_routes }} static |
| 🧩 {{ i18n.labels.total_components }} | **{{ summary.total_components }}** | Reusable UI components |
| 📄 {{ i18n.labels.total_pages }} | **{{ summary.total_pages }}** | Across {{ summary.languages | length }} languages |
| 🔒 {{ i18n.labels.auth_required }} | **{{ summary.auth_required_routes }}** | Secured endpoints |
| 🌍 {{ i18n.labels.languages }} | **{{ summary.languages | join(sep=", ") }}** | Supported locales |
---

View file

@ -0,0 +1,5 @@
{% if breadcrumbs %}
**Navigation:** {% for crumb in breadcrumbs %}[{{ crumb.title }}]({{ crumb.path }}){% if not loop.last %} → {% endif %}{% endfor %}
---
{% endif %}

View file

@ -0,0 +1,5 @@
| Path | Methods | Handler | Auth | Type |
|------|---------|---------|------|------|
{% for route in doc.routes %}
| [`{{ route.path }}`](reference.md#{{ route.anchor }}) | {% for method in route.methods %}<span style="background: {{ method | method_color }}; color: white; padding: 2px 6px; border-radius: 3px;">{{ method }}</span>{% if not loop.last %} {% endif %}{% endfor %} | `{{ route.handler }}` | {% if route.requires_auth %}🔒{% else %}🌐{% endif %} | {{ route.response_type }} |
{% endfor %}

View file

@ -0,0 +1,9 @@
{% if src_ref %}
{{ src_ref | source_link(item_name=item_name | default(value="item")) }}
{% elif route.src_ref %}
{{ route.src_ref | source_link(item_name=route.path | default(value="route")) }}
{% elif component.src_ref %}
{{ component.src_ref | source_link(item_name=component.name | default(value="component")) }}
{% elif page.src_ref %}
{{ page.src_ref | source_link(item_name=page.path | default(value="page")) }}
{% endif %}

View file

@ -0,0 +1,105 @@
#!/bin/bash
# Site-Info Template Setup Script
# This script initializes the site-info template system
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
TEMPLATES_DIR="$SCRIPT_DIR"
echo "🎨 Site-Info Template System Setup"
echo "=================================="
echo ""
# Check if templates already exist
if [ -f "$TEMPLATES_DIR/documentation/base.tera" ]; then
echo "✅ Templates are already set up at: $TEMPLATES_DIR"
echo ""
echo "📁 Template structure:"
find "$TEMPLATES_DIR" -name "*.tera" -o -name "*.md" | sort | sed 's|^| |'
echo ""
echo " Templates are ready for use. No action needed."
exit 0
fi
echo "🚨 WARNING: Templates directory exists but templates are missing!"
echo " Directory: $TEMPLATES_DIR"
echo ""
echo "This usually happens when:"
echo " - Templates were moved from source but files are missing"
echo " - Git didn't track the template files"
echo " - Template files were accidentally deleted"
echo ""
# Check if we're in the right place
if [ ! -f "$SCRIPT_DIR/../../../crates/site-info/Cargo.toml" ]; then
echo "❌ Error: This script must be run from the correct project directory"
echo " Expected: site/templates/site-info/"
echo " Current: $SCRIPT_DIR"
exit 1
fi
echo "🔧 Template Recovery Options:"
echo ""
echo "1. Restore from crate source (if available):"
echo " cp -r ../../../crates/site-info/src/templates/* ."
echo ""
echo "2. Initialize from scratch:"
echo " Create templates manually using the documentation in README.md"
echo ""
echo "3. Check git history:"
echo " git log --follow -- site/templates/site-info/"
echo ""
# Offer to restore from crate if source still exists
CRATE_TEMPLATES="../../../crates/site-info/src/templates"
if [ -d "$CRATE_TEMPLATES" ]; then
echo "✨ Found template source in crate directory!"
echo ""
read -p "Restore templates from crate source? [y/N]: " -n 1 -r
echo ""
if [[ $REPLY =~ ^[Yy]$ ]]; then
echo "📋 Copying templates from crate source..."
# Copy templates while preserving structure
if [ -d "$CRATE_TEMPLATES/documentation" ]; then
cp -r "$CRATE_TEMPLATES/documentation" "$TEMPLATES_DIR/"
echo " ✓ Copied documentation templates"
fi
if [ -d "$CRATE_TEMPLATES/partials" ]; then
cp -r "$CRATE_TEMPLATES/partials" "$TEMPLATES_DIR/"
echo " ✓ Copied partial templates"
fi
if [ -d "$CRATE_TEMPLATES/i18n" ]; then
cp -r "$CRATE_TEMPLATES/i18n" "$TEMPLATES_DIR/"
echo " ✓ Copied i18n template directories"
fi
echo ""
echo "✅ Templates restored successfully!"
echo ""
echo "📁 Template structure:"
find "$TEMPLATES_DIR" -name "*.tera" | sort | sed 's|^| |'
echo ""
echo "🎯 Next steps:"
echo " 1. Test the build: cargo build"
echo " 2. Generate docs: cargo run --bin site-info-generator"
echo " 3. Commit templates: git add site/templates/site-info/"
exit 0
fi
fi
echo ""
echo "📖 For manual template creation, see:"
echo " site/templates/site-info/README.md"
echo ""
echo "🔗 Template examples and documentation:"
echo " https://keats.github.io/tera/docs/"
echo ""
exit 1

View file

@ -0,0 +1,25 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Form Submission: {{ subject }}</title>
</head>
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; max-width: 600px; margin: 40px auto; padding: 0 24px; color: #111827;">
<h2 style="font-size: 1.25rem; font-weight: 700; margin-bottom: 4px;">New {{ form_type }} submission</h2>
<p style="color: #6b7280; font-size: 0.875rem; margin-top: 0; margin-bottom: 24px;">{{ submitted_at }}</p>
<table style="width: 100%; border-collapse: collapse; margin-bottom: 24px;">
<tr>
<td style="padding: 8px 12px; background: #f4f4f5; font-weight: 600; width: 120px;">From</td>
<td style="padding: 8px 12px; border-bottom: 1px solid #e5e7eb;">{{ name }} &lt;{{ email }}&gt;</td>
</tr>
<tr>
<td style="padding: 8px 12px; background: #f4f4f5; font-weight: 600;">Subject</td>
<td style="padding: 8px 12px; border-bottom: 1px solid #e5e7eb;">{{ subject }}</td>
</tr>
</table>
<div style="background: #f9fafb; border-left: 4px solid #6366f1; padding: 16px 20px; border-radius: 0 6px 6px 0; white-space: pre-wrap; font-size: 0.9375rem; line-height: 1.6;">{{ message }}</div>
</body>
</html>

View file

@ -0,0 +1,27 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ title }}</title>
</head>
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; max-width: 480px; margin: 40px auto; padding: 0 24px; color: #111827;">
<h2 style="font-size: 1.5rem; font-weight: 700; margin-bottom: 8px;">{{ title }}</h2>
<p style="color: #6b7280; margin-bottom: 24px;">{{ subtitle }}</p>
<div style="
font-size: 2.5rem;
font-weight: 700;
letter-spacing: 0.4em;
text-align: center;
padding: 24px;
background: #f4f4f5;
border-radius: 8px;
margin: 24px 0;
font-variant-numeric: tabular-nums;
">{{ code }}</div>
<p style="color: #6b7280; font-size: 0.875rem; line-height: 1.5;">
{{ expire_msg }}<br>
{{ ignore_msg }}
</p>
</body>
</html>

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