From f1010e9d070ccdf052d2ddb11363ac332b419cf9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jesu=CC=81s=20Pe=CC=81rez?= Date: Sat, 18 Jul 2026 20:16:16 +0100 Subject: [PATCH] chore: update --- resources/README.md | 94 +++++ .../008_activity_completions_postgres.sql | 25 ++ .../008_activity_completions_sqlite.sql | 25 ++ .../migrations/001_initial_setup_postgres.sql | 100 ++++++ .../migrations/001_initial_setup_sqlite.sql | 96 ++++++ .../002_add_2fa_support_postgres.sql | 131 +++++++ .../migrations/002_add_2fa_support_sqlite.sql | 117 +++++++ .../migrations/003_rbac_system_postgres.sql | 320 ++++++++++++++++++ .../migrations/003_rbac_system_sqlite.sql | 4 + .../006_service_tokens_postgres.sql | 21 ++ .../migrations/006_service_tokens_sqlite.sql | 17 + resources/i18n/en/common.ftl | 20 ++ resources/i18n/en/components/footer.ftl | 13 + resources/i18n/en/components/forms.ftl | 66 ++++ .../i18n/en/components/language_selector.ftl | 43 +++ resources/i18n/en/components/logo.ftl | 32 ++ resources/i18n/en/components/navigation.ftl | 56 +++ resources/i18n/en/cookies.ftl | 12 + resources/i18n/en/features/auth-signout.ftl | 11 + resources/i18n/en/features/auth.ftl | 126 +++++++ resources/i18n/en/manager/cli.ftl | 95 ++++++ resources/i18n/en/manager/dashboard.ftl | 83 +++++ resources/i18n/en/pages/about.ftl | 16 + resources/i18n/en/pages/contact.ftl | 29 ++ resources/i18n/en/pages/content.ftl | 124 +++++++ resources/i18n/en/pages/home.ftl | 22 ++ resources/i18n/en/pages/not_found.ftl | 28 ++ resources/i18n/en/pages/post.ftl | 23 ++ resources/i18n/es/common.ftl | 20 ++ resources/i18n/es/components/footer.ftl | 13 + resources/i18n/es/components/forms.ftl | 66 ++++ .../i18n/es/components/language_selector.ftl | 43 +++ resources/i18n/es/components/navigation.ftl | 56 +++ resources/i18n/es/cookies.ftl | 12 + resources/i18n/es/features/auth-signout.ftl | 11 + resources/i18n/es/features/auth.ftl | 126 +++++++ resources/i18n/es/manager/cli.ftl | 95 ++++++ resources/i18n/es/manager/dashboard.ftl | 83 +++++ resources/i18n/es/pages/about.ftl | 10 + resources/i18n/es/pages/contact.ftl | 21 ++ resources/i18n/es/pages/content.ftl | 125 +++++++ resources/i18n/es/pages/home.ftl | 22 ++ resources/i18n/es/pages/not_found.ftl | 28 ++ resources/i18n/es/pages/post.ftl | 23 ++ resources/nickel/content/contracts.ncl | 131 +++++++ resources/nickel/content/defaults.ncl | 199 +++++++++++ .../content/metadata/activity_metadata.ncl | 99 ++++++ .../nickel/content/metadata/contracts.ncl | 90 +++++ .../nickel/content/metadata/defaults.ncl | 22 ++ .../nickel/content/metadata/example_post.ncl | 24 ++ .../content/metadata/graph_contracts.ncl | 54 +++ .../nickel/content/metadata/post_metadata.ncl | 156 +++++++++ .../content/metadata/projects_metadata.ncl | 49 +++ .../content/metadata/recipe_metadata.ncl | 57 ++++ resources/nickel/deps/contracts.ncl | 183 ++++++++++ resources/nickel/email/contracts.ncl | 100 ++++++ .../nickel/external-services/contracts.ncl | 162 +++++++++ resources/nickel/features/contracts.ncl | 70 ++++ resources/nickel/footer/contracts.ncl | 97 ++++++ resources/nickel/footer/defaults.ncl | 81 +++++ resources/nickel/forms/contracts.ncl | 152 +++++++++ resources/nickel/manifest/contracts.ncl | 201 +++++++++++ resources/nickel/menus/contracts.ncl | 134 ++++++++ resources/nickel/menus/defaults.ncl | 132 ++++++++ .../nickel/pipelines/ci_cd/contracts.ncl | 45 +++ resources/nickel/pipelines/contracts.ncl | 127 +++++++ resources/nickel/pipelines/nats/contracts.ncl | 41 +++ resources/nickel/rbac/contracts.ncl | 166 +++++++++ resources/nickel/rbac/features/admin.ncl | 24 ++ resources/nickel/rbac/features/analytics.ncl | 10 + resources/nickel/rbac/features/auth.ncl | 33 ++ resources/nickel/rbac/features/content.ncl | 28 ++ .../nickel/rbac/features/user-dashboard.ncl | 24 ++ resources/nickel/rbac/features/websocket.ncl | 10 + resources/nickel/rbac/rbac-base.ncl | 43 +++ resources/nickel/rbac/rbac-compose.ncl | 112 ++++++ resources/nickel/rbac/roles_contracts.ncl | 98 ++++++ resources/nickel/routes/contracts.ncl | 129 +++++++ resources/nickel/routes/defaults.ncl | 142 ++++++++ resources/nickel/server/contracts.ncl | 243 +++++++++++++ resources/nickel/site/contracts.ncl | 308 +++++++++++++++++ resources/nickel/site/defaults.ncl | 96 ++++++ resources/nickel/themes/contracts.ncl | 187 ++++++++++ resources/nickel/themes/defaults.ncl | 298 ++++++++++++++++ resources/templates/build-tools/README.md | 86 +++++ .../build-tools/documentation/automation.tera | 190 +++++++++++ .../build-tools/documentation/base.tera | 11 + .../build-tools/documentation/reference.tera | 125 +++++++ .../build-tools/documentation/summary.tera | 71 ++++ .../build-tools/documentation/top_level.tera | 144 ++++++++ .../build-tools/partials/component_card.tera | 26 ++ .../build-tools/partials/footer.tera | 15 + .../build-tools/partials/header.tera | 11 + .../build-tools/partials/metrics_summary.tera | 11 + .../build-tools/partials/nav_breadcrumbs.tera | 5 + .../build-tools/partials/route_table.tera | 5 + .../build-tools/partials/source_link.tera | 9 + resources/templates/build-tools/setup.sh | 105 ++++++ .../templates/email/html/form_submission.html | 25 ++ resources/templates/email/html/otp_code.html | 27 ++ resources/templates/email/otp_code.html | 27 ++ resources/templates/email/otp_code.txt | 4 + .../templates/email/text/form_submission.txt | 8 + resources/templates/email/text/otp_code.txt | 8 + .../004_gdpr_bookmarks_postgres.sql | 17 + .../migrations/004_gdpr_bookmarks_sqlite.sql | 17 + .../005_user_dashboard_postgres.sql | 38 +++ .../migrations/005_user_dashboard_sqlite.sql | 38 +++ .../007_message_status_postgres.sql | 19 ++ .../migrations/007_message_status_sqlite.sql | 17 + 110 files changed, 7919 insertions(+) create mode 100644 resources/README.md create mode 100644 resources/activities/migrations/008_activity_completions_postgres.sql create mode 100644 resources/activities/migrations/008_activity_completions_sqlite.sql create mode 100644 resources/auth/migrations/001_initial_setup_postgres.sql create mode 100644 resources/auth/migrations/001_initial_setup_sqlite.sql create mode 100644 resources/auth/migrations/002_add_2fa_support_postgres.sql create mode 100644 resources/auth/migrations/002_add_2fa_support_sqlite.sql create mode 100644 resources/auth/migrations/003_rbac_system_postgres.sql create mode 100644 resources/auth/migrations/003_rbac_system_sqlite.sql create mode 100644 resources/auth/migrations/006_service_tokens_postgres.sql create mode 100644 resources/auth/migrations/006_service_tokens_sqlite.sql create mode 100644 resources/i18n/en/common.ftl create mode 100644 resources/i18n/en/components/footer.ftl create mode 100644 resources/i18n/en/components/forms.ftl create mode 100644 resources/i18n/en/components/language_selector.ftl create mode 100644 resources/i18n/en/components/logo.ftl create mode 100644 resources/i18n/en/components/navigation.ftl create mode 100644 resources/i18n/en/cookies.ftl create mode 100644 resources/i18n/en/features/auth-signout.ftl create mode 100644 resources/i18n/en/features/auth.ftl create mode 100644 resources/i18n/en/manager/cli.ftl create mode 100644 resources/i18n/en/manager/dashboard.ftl create mode 100644 resources/i18n/en/pages/about.ftl create mode 100644 resources/i18n/en/pages/contact.ftl create mode 100644 resources/i18n/en/pages/content.ftl create mode 100644 resources/i18n/en/pages/home.ftl create mode 100644 resources/i18n/en/pages/not_found.ftl create mode 100644 resources/i18n/en/pages/post.ftl create mode 100644 resources/i18n/es/common.ftl create mode 100644 resources/i18n/es/components/footer.ftl create mode 100644 resources/i18n/es/components/forms.ftl create mode 100644 resources/i18n/es/components/language_selector.ftl create mode 100644 resources/i18n/es/components/navigation.ftl create mode 100644 resources/i18n/es/cookies.ftl create mode 100644 resources/i18n/es/features/auth-signout.ftl create mode 100644 resources/i18n/es/features/auth.ftl create mode 100644 resources/i18n/es/manager/cli.ftl create mode 100644 resources/i18n/es/manager/dashboard.ftl create mode 100644 resources/i18n/es/pages/about.ftl create mode 100644 resources/i18n/es/pages/contact.ftl create mode 100644 resources/i18n/es/pages/content.ftl create mode 100644 resources/i18n/es/pages/home.ftl create mode 100644 resources/i18n/es/pages/not_found.ftl create mode 100644 resources/i18n/es/pages/post.ftl create mode 100644 resources/nickel/content/contracts.ncl create mode 100644 resources/nickel/content/defaults.ncl create mode 100644 resources/nickel/content/metadata/activity_metadata.ncl create mode 100644 resources/nickel/content/metadata/contracts.ncl create mode 100644 resources/nickel/content/metadata/defaults.ncl create mode 100644 resources/nickel/content/metadata/example_post.ncl create mode 100644 resources/nickel/content/metadata/graph_contracts.ncl create mode 100644 resources/nickel/content/metadata/post_metadata.ncl create mode 100644 resources/nickel/content/metadata/projects_metadata.ncl create mode 100644 resources/nickel/content/metadata/recipe_metadata.ncl create mode 100644 resources/nickel/deps/contracts.ncl create mode 100644 resources/nickel/email/contracts.ncl create mode 100644 resources/nickel/external-services/contracts.ncl create mode 100644 resources/nickel/features/contracts.ncl create mode 100644 resources/nickel/footer/contracts.ncl create mode 100644 resources/nickel/footer/defaults.ncl create mode 100644 resources/nickel/forms/contracts.ncl create mode 100644 resources/nickel/manifest/contracts.ncl create mode 100644 resources/nickel/menus/contracts.ncl create mode 100644 resources/nickel/menus/defaults.ncl create mode 100644 resources/nickel/pipelines/ci_cd/contracts.ncl create mode 100644 resources/nickel/pipelines/contracts.ncl create mode 100644 resources/nickel/pipelines/nats/contracts.ncl create mode 100644 resources/nickel/rbac/contracts.ncl create mode 100644 resources/nickel/rbac/features/admin.ncl create mode 100644 resources/nickel/rbac/features/analytics.ncl create mode 100644 resources/nickel/rbac/features/auth.ncl create mode 100644 resources/nickel/rbac/features/content.ncl create mode 100644 resources/nickel/rbac/features/user-dashboard.ncl create mode 100644 resources/nickel/rbac/features/websocket.ncl create mode 100644 resources/nickel/rbac/rbac-base.ncl create mode 100644 resources/nickel/rbac/rbac-compose.ncl create mode 100644 resources/nickel/rbac/roles_contracts.ncl create mode 100644 resources/nickel/routes/contracts.ncl create mode 100644 resources/nickel/routes/defaults.ncl create mode 100644 resources/nickel/server/contracts.ncl create mode 100644 resources/nickel/site/contracts.ncl create mode 100644 resources/nickel/site/defaults.ncl create mode 100644 resources/nickel/themes/contracts.ncl create mode 100644 resources/nickel/themes/defaults.ncl create mode 100644 resources/templates/build-tools/README.md create mode 100644 resources/templates/build-tools/documentation/automation.tera create mode 100644 resources/templates/build-tools/documentation/base.tera create mode 100644 resources/templates/build-tools/documentation/reference.tera create mode 100644 resources/templates/build-tools/documentation/summary.tera create mode 100644 resources/templates/build-tools/documentation/top_level.tera create mode 100644 resources/templates/build-tools/partials/component_card.tera create mode 100644 resources/templates/build-tools/partials/footer.tera create mode 100644 resources/templates/build-tools/partials/header.tera create mode 100644 resources/templates/build-tools/partials/metrics_summary.tera create mode 100644 resources/templates/build-tools/partials/nav_breadcrumbs.tera create mode 100644 resources/templates/build-tools/partials/route_table.tera create mode 100644 resources/templates/build-tools/partials/source_link.tera create mode 100755 resources/templates/build-tools/setup.sh create mode 100644 resources/templates/email/html/form_submission.html create mode 100644 resources/templates/email/html/otp_code.html create mode 100644 resources/templates/email/otp_code.html create mode 100644 resources/templates/email/otp_code.txt create mode 100644 resources/templates/email/text/form_submission.txt create mode 100644 resources/templates/email/text/otp_code.txt create mode 100644 resources/user-dashboard/migrations/004_gdpr_bookmarks_postgres.sql create mode 100644 resources/user-dashboard/migrations/004_gdpr_bookmarks_sqlite.sql create mode 100644 resources/user-dashboard/migrations/005_user_dashboard_postgres.sql create mode 100644 resources/user-dashboard/migrations/005_user_dashboard_sqlite.sql create mode 100644 resources/user-dashboard/migrations/007_message_status_postgres.sql create mode 100644 resources/user-dashboard/migrations/007_message_status_sqlite.sql diff --git a/resources/README.md b/resources/README.md new file mode 100644 index 0000000..96412cc --- /dev/null +++ b/resources/README.md @@ -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. diff --git a/resources/activities/migrations/008_activity_completions_postgres.sql b/resources/activities/migrations/008_activity_completions_postgres.sql new file mode 100644 index 0000000..2567c1c --- /dev/null +++ b/resources/activities/migrations/008_activity_completions_postgres.sql @@ -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); diff --git a/resources/activities/migrations/008_activity_completions_sqlite.sql b/resources/activities/migrations/008_activity_completions_sqlite.sql new file mode 100644 index 0000000..b22c187 --- /dev/null +++ b/resources/activities/migrations/008_activity_completions_sqlite.sql @@ -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); diff --git a/resources/auth/migrations/001_initial_setup_postgres.sql b/resources/auth/migrations/001_initial_setup_postgres.sql new file mode 100644 index 0000000..1e058c9 --- /dev/null +++ b/resources/auth/migrations/001_initial_setup_postgres.sql @@ -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; diff --git a/resources/auth/migrations/001_initial_setup_sqlite.sql b/resources/auth/migrations/001_initial_setup_sqlite.sql new file mode 100644 index 0000000..001ebc6 --- /dev/null +++ b/resources/auth/migrations/001_initial_setup_sqlite.sql @@ -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; diff --git a/resources/auth/migrations/002_add_2fa_support_postgres.sql b/resources/auth/migrations/002_add_2fa_support_postgres.sql new file mode 100644 index 0000000..0dd0745 --- /dev/null +++ b/resources/auth/migrations/002_add_2fa_support_postgres.sql @@ -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'; diff --git a/resources/auth/migrations/002_add_2fa_support_sqlite.sql b/resources/auth/migrations/002_add_2fa_support_sqlite.sql new file mode 100644 index 0000000..355dccf --- /dev/null +++ b/resources/auth/migrations/002_add_2fa_support_sqlite.sql @@ -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; diff --git a/resources/auth/migrations/003_rbac_system_postgres.sql b/resources/auth/migrations/003_rbac_system_postgres.sql new file mode 100644 index 0000000..c25a84d --- /dev/null +++ b/resources/auth/migrations/003_rbac_system_postgres.sql @@ -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; diff --git a/resources/auth/migrations/003_rbac_system_sqlite.sql b/resources/auth/migrations/003_rbac_system_sqlite.sql new file mode 100644 index 0000000..dfebf8a --- /dev/null +++ b/resources/auth/migrations/003_rbac_system_sqlite.sql @@ -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; diff --git a/resources/auth/migrations/006_service_tokens_postgres.sql b/resources/auth/migrations/006_service_tokens_postgres.sql new file mode 100644 index 0000000..dc6b4fe --- /dev/null +++ b/resources/auth/migrations/006_service_tokens_postgres.sql @@ -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); diff --git a/resources/auth/migrations/006_service_tokens_sqlite.sql b/resources/auth/migrations/006_service_tokens_sqlite.sql new file mode 100644 index 0000000..300b629 --- /dev/null +++ b/resources/auth/migrations/006_service_tokens_sqlite.sql @@ -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); diff --git a/resources/i18n/en/common.ftl b/resources/i18n/en/common.ftl new file mode 100644 index 0000000..22bb00a --- /dev/null +++ b/resources/i18n/en/common.ftl @@ -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 diff --git a/resources/i18n/en/components/footer.ftl b/resources/i18n/en/components/footer.ftl new file mode 100644 index 0000000..575eada --- /dev/null +++ b/resources/i18n/en/components/footer.ftl @@ -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 } diff --git a/resources/i18n/en/components/forms.ftl b/resources/i18n/en/components/forms.ftl new file mode 100644 index 0000000..a49900a --- /dev/null +++ b/resources/i18n/en/components/forms.ftl @@ -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 diff --git a/resources/i18n/en/components/language_selector.ftl b/resources/i18n/en/components/language_selector.ftl new file mode 100644 index 0000000..1c91cc1 --- /dev/null +++ b/resources/i18n/en/components/language_selector.ftl @@ -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 } diff --git a/resources/i18n/en/components/logo.ftl b/resources/i18n/en/components/logo.ftl new file mode 100644 index 0000000..034e6c1 --- /dev/null +++ b/resources/i18n/en/components/logo.ftl @@ -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 diff --git a/resources/i18n/en/components/navigation.ftl b/resources/i18n/en/components/navigation.ftl new file mode 100644 index 0000000..894d0be --- /dev/null +++ b/resources/i18n/en/components/navigation.ftl @@ -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 diff --git a/resources/i18n/en/cookies.ftl b/resources/i18n/en/cookies.ftl new file mode 100644 index 0000000..b5817df --- /dev/null +++ b/resources/i18n/en/cookies.ftl @@ -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 diff --git a/resources/i18n/en/features/auth-signout.ftl b/resources/i18n/en/features/auth-signout.ftl new file mode 100644 index 0000000..f8c9003 --- /dev/null +++ b/resources/i18n/en/features/auth-signout.ftl @@ -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… diff --git a/resources/i18n/en/features/auth.ftl b/resources/i18n/en/features/auth.ftl new file mode 100644 index 0000000..2364c1e --- /dev/null +++ b/resources/i18n/en/features/auth.ftl @@ -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 Terms of Service and Privacy Policy (required) +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. diff --git a/resources/i18n/en/manager/cli.ftl b/resources/i18n/en/manager/cli.ftl new file mode 100644 index 0000000..8a00024 --- /dev/null +++ b/resources/i18n/en/manager/cli.ftl @@ -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 diff --git a/resources/i18n/en/manager/dashboard.ftl b/resources/i18n/en/manager/dashboard.ftl new file mode 100644 index 0000000..cc95580 --- /dev/null +++ b/resources/i18n/en/manager/dashboard.ftl @@ -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 diff --git a/resources/i18n/en/pages/about.ftl b/resources/i18n/en/pages/about.ftl new file mode 100644 index 0000000..2754ca4 --- /dev/null +++ b/resources/i18n/en/pages/about.ftl @@ -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 diff --git a/resources/i18n/en/pages/contact.ftl b/resources/i18n/en/pages/contact.ftl new file mode 100644 index 0000000..e9d62b4 --- /dev/null +++ b/resources/i18n/en/pages/contact.ftl @@ -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. diff --git a/resources/i18n/en/pages/content.ftl b/resources/i18n/en/pages/content.ftl new file mode 100644 index 0000000..0a664d7 --- /dev/null +++ b/resources/i18n/en/pages/content.ftl @@ -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 diff --git a/resources/i18n/en/pages/home.ftl b/resources/i18n/en/pages/home.ftl new file mode 100644 index 0000000..3b8327a --- /dev/null +++ b/resources/i18n/en/pages/home.ftl @@ -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 diff --git a/resources/i18n/en/pages/not_found.ftl b/resources/i18n/en/pages/not_found.ftl new file mode 100644 index 0000000..ce9c555 --- /dev/null +++ b/resources/i18n/en/pages/not_found.ftl @@ -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. diff --git a/resources/i18n/en/pages/post.ftl b/resources/i18n/en/pages/post.ftl new file mode 100644 index 0000000..0835adc --- /dev/null +++ b/resources/i18n/en/pages/post.ftl @@ -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 diff --git a/resources/i18n/es/common.ftl b/resources/i18n/es/common.ftl new file mode 100644 index 0000000..d53e0fb --- /dev/null +++ b/resources/i18n/es/common.ftl @@ -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 diff --git a/resources/i18n/es/components/footer.ftl b/resources/i18n/es/components/footer.ftl new file mode 100644 index 0000000..c7ef009 --- /dev/null +++ b/resources/i18n/es/components/footer.ftl @@ -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 } diff --git a/resources/i18n/es/components/forms.ftl b/resources/i18n/es/components/forms.ftl new file mode 100644 index 0000000..662617c --- /dev/null +++ b/resources/i18n/es/components/forms.ftl @@ -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 diff --git a/resources/i18n/es/components/language_selector.ftl b/resources/i18n/es/components/language_selector.ftl new file mode 100644 index 0000000..bc8ab93 --- /dev/null +++ b/resources/i18n/es/components/language_selector.ftl @@ -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 } diff --git a/resources/i18n/es/components/navigation.ftl b/resources/i18n/es/components/navigation.ftl new file mode 100644 index 0000000..2c82f53 --- /dev/null +++ b/resources/i18n/es/components/navigation.ftl @@ -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ú diff --git a/resources/i18n/es/cookies.ftl b/resources/i18n/es/cookies.ftl new file mode 100644 index 0000000..4810406 --- /dev/null +++ b/resources/i18n/es/cookies.ftl @@ -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 diff --git a/resources/i18n/es/features/auth-signout.ftl b/resources/i18n/es/features/auth-signout.ftl new file mode 100644 index 0000000..a4d6519 --- /dev/null +++ b/resources/i18n/es/features/auth-signout.ftl @@ -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… diff --git a/resources/i18n/es/features/auth.ftl b/resources/i18n/es/features/auth.ftl new file mode 100644 index 0000000..4a17380 --- /dev/null +++ b/resources/i18n/es/features/auth.ftl @@ -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 Términos de Servicio y la Política de Privacidad (obligatorio) +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. diff --git a/resources/i18n/es/manager/cli.ftl b/resources/i18n/es/manager/cli.ftl new file mode 100644 index 0000000..cb93f41 --- /dev/null +++ b/resources/i18n/es/manager/cli.ftl @@ -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 diff --git a/resources/i18n/es/manager/dashboard.ftl b/resources/i18n/es/manager/dashboard.ftl new file mode 100644 index 0000000..f735454 --- /dev/null +++ b/resources/i18n/es/manager/dashboard.ftl @@ -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 diff --git a/resources/i18n/es/pages/about.ftl b/resources/i18n/es/pages/about.ftl new file mode 100644 index 0000000..231822f --- /dev/null +++ b/resources/i18n/es/pages/about.ftl @@ -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 diff --git a/resources/i18n/es/pages/contact.ftl b/resources/i18n/es/pages/contact.ftl new file mode 100644 index 0000000..d51f746 --- /dev/null +++ b/resources/i18n/es/pages/contact.ftl @@ -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. diff --git a/resources/i18n/es/pages/content.ftl b/resources/i18n/es/pages/content.ftl new file mode 100644 index 0000000..53c334e --- /dev/null +++ b/resources/i18n/es/pages/content.ftl @@ -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 diff --git a/resources/i18n/es/pages/home.ftl b/resources/i18n/es/pages/home.ftl new file mode 100644 index 0000000..03546a7 --- /dev/null +++ b/resources/i18n/es/pages/home.ftl @@ -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 diff --git a/resources/i18n/es/pages/not_found.ftl b/resources/i18n/es/pages/not_found.ftl new file mode 100644 index 0000000..0105d33 --- /dev/null +++ b/resources/i18n/es/pages/not_found.ftl @@ -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. diff --git a/resources/i18n/es/pages/post.ftl b/resources/i18n/es/pages/post.ftl new file mode 100644 index 0000000..df705db --- /dev/null +++ b/resources/i18n/es/pages/post.ftl @@ -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 diff --git a/resources/nickel/content/contracts.ncl b/resources/nickel/content/contracts.ncl new file mode 100644 index 0000000..f18a588 --- /dev/null +++ b/resources/nickel/content/contracts.ncl @@ -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 = [], + }, +} diff --git a/resources/nickel/content/defaults.ncl b/resources/nickel/content/defaults.ncl new file mode 100644 index 0000000..68be5ad --- /dev/null +++ b/resources/nickel/content/defaults.ncl @@ -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], + }, +} diff --git a/resources/nickel/content/metadata/activity_metadata.ncl b/resources/nickel/content/metadata/activity_metadata.ncl new file mode 100644 index 0000000..119f72b --- /dev/null +++ b/resources/nickel/content/metadata/activity_metadata.ncl @@ -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, +} diff --git a/resources/nickel/content/metadata/contracts.ncl b/resources/nickel/content/metadata/contracts.ncl new file mode 100644 index 0000000..1b2e7b8 --- /dev/null +++ b/resources/nickel/content/metadata/contracts.ncl @@ -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.", + }, +} diff --git a/resources/nickel/content/metadata/defaults.ncl b/resources/nickel/content/metadata/defaults.ncl new file mode 100644 index 0000000..da6882e --- /dev/null +++ b/resources/nickel/content/metadata/defaults.ncl @@ -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 = {}, +} diff --git a/resources/nickel/content/metadata/example_post.ncl b/resources/nickel/content/metadata/example_post.ncl new file mode 100644 index 0000000..ca0858f --- /dev/null +++ b/resources/nickel/content/metadata/example_post.ncl @@ -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" }, +} diff --git a/resources/nickel/content/metadata/graph_contracts.ncl b/resources/nickel/content/metadata/graph_contracts.ncl new file mode 100644 index 0000000..d97d1b3 --- /dev/null +++ b/resources/nickel/content/metadata/graph_contracts.ncl @@ -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 = [], + }, +} diff --git a/resources/nickel/content/metadata/post_metadata.ncl b/resources/nickel/content/metadata/post_metadata.ncl new file mode 100644 index 0000000..881c542 --- /dev/null +++ b/resources/nickel/content/metadata/post_metadata.ncl @@ -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, +} diff --git a/resources/nickel/content/metadata/projects_metadata.ncl b/resources/nickel/content/metadata/projects_metadata.ncl new file mode 100644 index 0000000..b8660f7 --- /dev/null +++ b/resources/nickel/content/metadata/projects_metadata.ncl @@ -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, +} diff --git a/resources/nickel/content/metadata/recipe_metadata.ncl b/resources/nickel/content/metadata/recipe_metadata.ncl new file mode 100644 index 0000000..9db721a --- /dev/null +++ b/resources/nickel/content/metadata/recipe_metadata.ncl @@ -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, +} diff --git a/resources/nickel/deps/contracts.ncl b/resources/nickel/deps/contracts.ncl new file mode 100644 index 0000000..68f3469 --- /dev/null +++ b/resources/nickel/deps/contracts.ncl @@ -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, + }, + }, +} diff --git a/resources/nickel/email/contracts.ncl b/resources/nickel/email/contracts.ncl new file mode 100644 index 0000000..0c024c2 --- /dev/null +++ b/resources/nickel/email/contracts.ncl @@ -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, + }, +} diff --git a/resources/nickel/external-services/contracts.ncl b/resources/nickel/external-services/contracts.ncl new file mode 100644 index 0000000..09761d0 --- /dev/null +++ b/resources/nickel/external-services/contracts.ncl @@ -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, + .. + }, +} diff --git a/resources/nickel/features/contracts.ncl b/resources/nickel/features/contracts.ncl new file mode 100644 index 0000000..af8aac5 --- /dev/null +++ b/resources/nickel/features/contracts.ncl @@ -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 = {}, + }, +} diff --git a/resources/nickel/footer/contracts.ncl b/resources/nickel/footer/contracts.ncl new file mode 100644 index 0000000..5c3b4d3 --- /dev/null +++ b/resources/nickel/footer/contracts.ncl @@ -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", + + .. + }, +} diff --git a/resources/nickel/footer/defaults.ncl b/resources/nickel/footer/defaults.ncl new file mode 100644 index 0000000..928ee6a --- /dev/null +++ b/resources/nickel/footer/defaults.ncl @@ -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, + }, +} diff --git a/resources/nickel/forms/contracts.ncl b/resources/nickel/forms/contracts.ncl new file mode 100644 index 0000000..62ad2dd --- /dev/null +++ b/resources/nickel/forms/contracts.ncl @@ -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` — 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 `-` 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/, and the file name `.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 = }` 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]), +} diff --git a/resources/nickel/manifest/contracts.ncl b/resources/nickel/manifest/contracts.ncl new file mode 100644 index 0000000..0c146c6 --- /dev/null +++ b/resources/nickel/manifest/contracts.ncl @@ -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, + }, +} diff --git a/resources/nickel/menus/contracts.ncl b/resources/nickel/menus/contracts.ncl new file mode 100644 index 0000000..adac464 --- /dev/null +++ b/resources/nickel/menus/contracts.ncl @@ -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)", + }, +} diff --git a/resources/nickel/menus/defaults.ncl b/resources/nickel/menus/defaults.ncl new file mode 100644 index 0000000..fe2c647 --- /dev/null +++ b/resources/nickel/menus/defaults.ncl @@ -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, + }, +} diff --git a/resources/nickel/pipelines/ci_cd/contracts.ncl b/resources/nickel/pipelines/ci_cd/contracts.ncl new file mode 100644 index 0000000..3fead9a --- /dev/null +++ b/resources/nickel/pipelines/ci_cd/contracts.ncl @@ -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 = [], + }, +} diff --git a/resources/nickel/pipelines/contracts.ncl b/resources/nickel/pipelines/contracts.ncl new file mode 100644 index 0000000..e1dd91f --- /dev/null +++ b/resources/nickel/pipelines/contracts.ncl @@ -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 = [], + }, +} diff --git a/resources/nickel/pipelines/nats/contracts.ncl b/resources/nickel/pipelines/nats/contracts.ncl new file mode 100644 index 0000000..9d5d1ac --- /dev/null +++ b/resources/nickel/pipelines/nats/contracts.ncl @@ -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 = [], + }, +} diff --git a/resources/nickel/rbac/contracts.ncl b/resources/nickel/rbac/contracts.ncl new file mode 100644 index 0000000..56e6fcb --- /dev/null +++ b/resources/nickel/rbac/contracts.ncl @@ -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 = {}, + }, +} diff --git a/resources/nickel/rbac/features/admin.ncl b/resources/nickel/rbac/features/admin.ncl new file mode 100644 index 0000000..fd5c543 --- /dev/null +++ b/resources/nickel/rbac/features/admin.ncl @@ -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 = [], +} diff --git a/resources/nickel/rbac/features/analytics.ncl b/resources/nickel/rbac/features/analytics.ncl new file mode 100644 index 0000000..ee7c13a --- /dev/null +++ b/resources/nickel/rbac/features/analytics.ncl @@ -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 = [] }], +} diff --git a/resources/nickel/rbac/features/auth.ncl b/resources/nickel/rbac/features/auth.ncl new file mode 100644 index 0000000..eadbcac --- /dev/null +++ b/resources/nickel/rbac/features/auth.ncl @@ -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 = [] }, + ], +} diff --git a/resources/nickel/rbac/features/content.ncl b/resources/nickel/rbac/features/content.ncl new file mode 100644 index 0000000..e35ce58 --- /dev/null +++ b/resources/nickel/rbac/features/content.ncl @@ -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 = [] }, + ], +} diff --git a/resources/nickel/rbac/features/user-dashboard.ncl b/resources/nickel/rbac/features/user-dashboard.ncl new file mode 100644 index 0000000..86ce40e --- /dev/null +++ b/resources/nickel/rbac/features/user-dashboard.ncl @@ -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 = [], +} diff --git a/resources/nickel/rbac/features/websocket.ncl b/resources/nickel/rbac/features/websocket.ncl new file mode 100644 index 0000000..0af640e --- /dev/null +++ b/resources/nickel/rbac/features/websocket.ncl @@ -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 = [] }], +} diff --git a/resources/nickel/rbac/rbac-base.ncl b/resources/nickel/rbac/rbac-base.ncl new file mode 100644 index 0000000..f5077c9 --- /dev/null +++ b/resources/nickel/rbac/rbac-base.ncl @@ -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}" }], + }, +} diff --git a/resources/nickel/rbac/rbac-compose.ncl b/resources/nickel/rbac/rbac-compose.ncl new file mode 100644 index 0000000..ae6591a --- /dev/null +++ b/resources/nickel/rbac/rbac-compose.ncl @@ -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 diff --git a/resources/nickel/rbac/roles_contracts.ncl b/resources/nickel/rbac/roles_contracts.ncl new file mode 100644 index 0000000..144a6fc --- /dev/null +++ b/resources/nickel/rbac/roles_contracts.ncl @@ -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 + "%, + }, +} diff --git a/resources/nickel/routes/contracts.ncl b/resources/nickel/routes/contracts.ncl new file mode 100644 index 0000000..9dfacf8 --- /dev/null +++ b/resources/nickel/routes/contracts.ncl @@ -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", + }, +} diff --git a/resources/nickel/routes/defaults.ncl b/resources/nickel/routes/defaults.ncl new file mode 100644 index 0000000..3c3b3af --- /dev/null +++ b/resources/nickel/routes/defaults.ncl @@ -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 + }, +} diff --git a/resources/nickel/server/contracts.ncl b/resources/nickel/server/contracts.ncl new file mode 100644 index 0000000..be12cc8 --- /dev/null +++ b/resources/nickel/server/contracts.ncl @@ -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 (1–65535)" + | 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", + }, +} diff --git a/resources/nickel/site/contracts.ncl b/resources/nickel/site/contracts.ncl new file mode 100644 index 0000000..93ecfb1 --- /dev/null +++ b/resources/nickel/site/contracts.ncl @@ -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.0–1.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, + }, +} diff --git a/resources/nickel/site/defaults.ncl b/resources/nickel/site/defaults.ncl new file mode 100644 index 0000000..3e55945 --- /dev/null +++ b/resources/nickel/site/defaults.ncl @@ -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 = [], + }, + }, +} diff --git a/resources/nickel/themes/contracts.ncl b/resources/nickel/themes/contracts.ncl new file mode 100644 index 0000000..147658d --- /dev/null +++ b/resources/nickel/themes/contracts.ncl @@ -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, + + .. + }, +} diff --git a/resources/nickel/themes/defaults.ncl b/resources/nickel/themes/defaults.ncl new file mode 100644 index 0000000..7ef75a3 --- /dev/null +++ b/resources/nickel/themes/defaults.ncl @@ -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", + }, +} diff --git a/resources/templates/build-tools/README.md b/resources/templates/build-tools/README.md new file mode 100644 index 0000000..0163b0d --- /dev/null +++ b/resources/templates/build-tools/README.md @@ -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 diff --git a/resources/templates/build-tools/documentation/automation.tera b/resources/templates/build-tools/documentation/automation.tera new file mode 100644 index 0000000..4fff87a --- /dev/null +++ b/resources/templates/build-tools/documentation/automation.tera @@ -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 { + 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 { + 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 %} diff --git a/resources/templates/build-tools/documentation/base.tera b/resources/templates/build-tools/documentation/base.tera new file mode 100644 index 0000000..6f2608e --- /dev/null +++ b/resources/templates/build-tools/documentation/base.tera @@ -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" %} diff --git a/resources/templates/build-tools/documentation/reference.tera b/resources/templates/build-tools/documentation/reference.tera new file mode 100644 index 0000000..fefbe91 --- /dev/null +++ b/resources/templates/build-tools/documentation/reference.tera @@ -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 %} diff --git a/resources/templates/build-tools/documentation/summary.tera b/resources/templates/build-tools/documentation/summary.tera new file mode 100644 index 0000000..ef64400 --- /dev/null +++ b/resources/templates/build-tools/documentation/summary.tera @@ -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 %} diff --git a/resources/templates/build-tools/documentation/top_level.tera b/resources/templates/build-tools/documentation/top_level.tera new file mode 100644 index 0000000..90744c2 --- /dev/null +++ b/resources/templates/build-tools/documentation/top_level.tera @@ -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 %} diff --git a/resources/templates/build-tools/partials/component_card.tera b/resources/templates/build-tools/partials/component_card.tera new file mode 100644 index 0000000..7abe236 --- /dev/null +++ b/resources/templates/build-tools/partials/component_card.tera @@ -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 %} + +--- diff --git a/resources/templates/build-tools/partials/footer.tera b/resources/templates/build-tools/partials/footer.tera new file mode 100644 index 0000000..647d2ad --- /dev/null +++ b/resources/templates/build-tools/partials/footer.tera @@ -0,0 +1,15 @@ +{# Site footer partial with Rustelo branding and generation info #} + +--- + +
+ +*{{ 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") }}* + +
diff --git a/resources/templates/build-tools/partials/header.tera b/resources/templates/build-tools/partials/header.tera new file mode 100644 index 0000000..db62210 --- /dev/null +++ b/resources/templates/build-tools/partials/header.tera @@ -0,0 +1,11 @@ +{# Site header partial with logo and branding #} +
+ +![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") }} + +--- + +
diff --git a/resources/templates/build-tools/partials/metrics_summary.tera b/resources/templates/build-tools/partials/metrics_summary.tera new file mode 100644 index 0000000..d832a7a --- /dev/null +++ b/resources/templates/build-tools/partials/metrics_summary.tera @@ -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 | + +--- diff --git a/resources/templates/build-tools/partials/nav_breadcrumbs.tera b/resources/templates/build-tools/partials/nav_breadcrumbs.tera new file mode 100644 index 0000000..4ea9320 --- /dev/null +++ b/resources/templates/build-tools/partials/nav_breadcrumbs.tera @@ -0,0 +1,5 @@ +{% if breadcrumbs %} +**Navigation:** {% for crumb in breadcrumbs %}[{{ crumb.title }}]({{ crumb.path }}){% if not loop.last %} → {% endif %}{% endfor %} + +--- +{% endif %} diff --git a/resources/templates/build-tools/partials/route_table.tera b/resources/templates/build-tools/partials/route_table.tera new file mode 100644 index 0000000..827f76b --- /dev/null +++ b/resources/templates/build-tools/partials/route_table.tera @@ -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 %}{{ method }}{% if not loop.last %} {% endif %}{% endfor %} | `{{ route.handler }}` | {% if route.requires_auth %}🔒{% else %}🌐{% endif %} | {{ route.response_type }} | +{% endfor %} diff --git a/resources/templates/build-tools/partials/source_link.tera b/resources/templates/build-tools/partials/source_link.tera new file mode 100644 index 0000000..e865077 --- /dev/null +++ b/resources/templates/build-tools/partials/source_link.tera @@ -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 %} diff --git a/resources/templates/build-tools/setup.sh b/resources/templates/build-tools/setup.sh new file mode 100755 index 0000000..d445ef8 --- /dev/null +++ b/resources/templates/build-tools/setup.sh @@ -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 diff --git a/resources/templates/email/html/form_submission.html b/resources/templates/email/html/form_submission.html new file mode 100644 index 0000000..3b8af7a --- /dev/null +++ b/resources/templates/email/html/form_submission.html @@ -0,0 +1,25 @@ + + + + + + Form Submission: {{ subject }} + + +

New {{ form_type }} submission

+

{{ submitted_at }}

+ + + + + + + + + + +
From{{ name }} <{{ email }}>
Subject{{ subject }}
+ +
{{ message }}
+ + diff --git a/resources/templates/email/html/otp_code.html b/resources/templates/email/html/otp_code.html new file mode 100644 index 0000000..3bdcfa2 --- /dev/null +++ b/resources/templates/email/html/otp_code.html @@ -0,0 +1,27 @@ + + + + + + {{ title }} + + +

{{ title }}

+

{{ subtitle }}

+
{{ code }}
+

+ {{ expire_msg }}
+ {{ ignore_msg }} +

+ + diff --git a/resources/templates/email/otp_code.html b/resources/templates/email/otp_code.html new file mode 100644 index 0000000..7c612df --- /dev/null +++ b/resources/templates/email/otp_code.html @@ -0,0 +1,27 @@ + + + + + + Your verification code + + +

Your verification code

+

Use this code to sign in:

+
{{code}}
+

+ This code expires in {{expiry_minutes}} minutes.
+ If you didn't request this, you can safely ignore this email. +

+ + diff --git a/resources/templates/email/otp_code.txt b/resources/templates/email/otp_code.txt new file mode 100644 index 0000000..f339c69 --- /dev/null +++ b/resources/templates/email/otp_code.txt @@ -0,0 +1,4 @@ +Your verification code: {{code}} + +This code expires in {{expiry_minutes}} minutes. +If you didn't request this, you can safely ignore this email. diff --git a/resources/templates/email/text/form_submission.txt b/resources/templates/email/text/form_submission.txt new file mode 100644 index 0000000..2bce6e0 --- /dev/null +++ b/resources/templates/email/text/form_submission.txt @@ -0,0 +1,8 @@ +New {{ form_type }} submission — {{ submitted_at }} + +From: {{ name }} <{{ email }}> +Subject: {{ subject }} + +--- + +{{ message }} diff --git a/resources/templates/email/text/otp_code.txt b/resources/templates/email/text/otp_code.txt new file mode 100644 index 0000000..6d38f8d --- /dev/null +++ b/resources/templates/email/text/otp_code.txt @@ -0,0 +1,8 @@ +{{ title }} + +{{ subtitle }} + +{{ code }} + +{{ expire_msg }} +{{ ignore_msg }} diff --git a/resources/user-dashboard/migrations/004_gdpr_bookmarks_postgres.sql b/resources/user-dashboard/migrations/004_gdpr_bookmarks_postgres.sql new file mode 100644 index 0000000..fc3eeb6 --- /dev/null +++ b/resources/user-dashboard/migrations/004_gdpr_bookmarks_postgres.sql @@ -0,0 +1,17 @@ +-- Migration: 004 – GDPR consent timestamp + user bookmarks +-- Database: PostgreSQL + +ALTER TABLE users ADD COLUMN IF NOT EXISTS gdpr_accepted_at TIMESTAMPTZ; + +CREATE TABLE IF NOT EXISTS user_bookmarks ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + content_type VARCHAR(50) NOT NULL, + content_id VARCHAR(255) NOT NULL, + content_title VARCHAR(500), + content_url VARCHAR(500), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE(user_id, content_type, content_id) +); + +CREATE INDEX IF NOT EXISTS idx_user_bookmarks_user_id ON user_bookmarks(user_id); diff --git a/resources/user-dashboard/migrations/004_gdpr_bookmarks_sqlite.sql b/resources/user-dashboard/migrations/004_gdpr_bookmarks_sqlite.sql new file mode 100644 index 0000000..37ab835 --- /dev/null +++ b/resources/user-dashboard/migrations/004_gdpr_bookmarks_sqlite.sql @@ -0,0 +1,17 @@ +-- Migration: 004 – GDPR consent timestamp + user bookmarks +-- Database: SQLite + +ALTER TABLE users ADD COLUMN gdpr_accepted_at DATETIME; + +CREATE TABLE IF NOT EXISTS user_bookmarks ( + 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)))), + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + content_type TEXT NOT NULL, + content_id TEXT NOT NULL, + content_title TEXT, + content_url TEXT, + created_at DATETIME NOT NULL DEFAULT (datetime('now')), + UNIQUE(user_id, content_type, content_id) +); + +CREATE INDEX IF NOT EXISTS idx_user_bookmarks_user_id ON user_bookmarks(user_id); diff --git a/resources/user-dashboard/migrations/005_user_dashboard_postgres.sql b/resources/user-dashboard/migrations/005_user_dashboard_postgres.sql new file mode 100644 index 0000000..05ab868 --- /dev/null +++ b/resources/user-dashboard/migrations/005_user_dashboard_postgres.sql @@ -0,0 +1,38 @@ +-- Migration: 005 – User dashboard tables (messages, notes, resources) +-- Database: PostgreSQL + +CREATE TABLE IF NOT EXISTS user_messages ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + sender_email TEXT NOT NULL, + subject TEXT NOT NULL, + body TEXT NOT NULL, + is_read BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_user_messages_user_id_read ON user_messages(user_id, is_read); + +CREATE TABLE IF NOT EXISTS user_notes ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + title TEXT NOT NULL DEFAULT '', + content TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_user_notes_user_id ON user_notes(user_id); + +CREATE TABLE IF NOT EXISTS user_resources ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name TEXT NOT NULL, + description TEXT, + url TEXT, + resource_type TEXT NOT NULL DEFAULT 'link', + role_name TEXT NOT NULL, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_user_resources_role_active ON user_resources(role_name, is_active); diff --git a/resources/user-dashboard/migrations/005_user_dashboard_sqlite.sql b/resources/user-dashboard/migrations/005_user_dashboard_sqlite.sql new file mode 100644 index 0000000..ecda866 --- /dev/null +++ b/resources/user-dashboard/migrations/005_user_dashboard_sqlite.sql @@ -0,0 +1,38 @@ +-- Migration: 005 – User dashboard tables (messages, notes, resources) +-- Database: SQLite + +CREATE TABLE IF NOT EXISTS user_messages ( + 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)))), + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + sender_email TEXT NOT NULL, + subject TEXT NOT NULL, + body TEXT NOT NULL, + is_read INTEGER NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT (datetime('now')) +); + +CREATE INDEX IF NOT EXISTS idx_user_messages_user_id_read ON user_messages(user_id, is_read); + +CREATE TABLE IF NOT EXISTS user_notes ( + 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)))), + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + title TEXT NOT NULL DEFAULT '', + content TEXT NOT NULL DEFAULT '', + created_at DATETIME NOT NULL DEFAULT (datetime('now')), + updated_at DATETIME NOT NULL DEFAULT (datetime('now')) +); + +CREATE INDEX IF NOT EXISTS idx_user_notes_user_id ON user_notes(user_id); + +CREATE TABLE IF NOT EXISTS user_resources ( + 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, + description TEXT, + url TEXT, + resource_type TEXT NOT NULL DEFAULT 'link', + role_name TEXT NOT NULL, + is_active INTEGER NOT NULL DEFAULT 1, + created_at DATETIME NOT NULL DEFAULT (datetime('now')) +); + +CREATE INDEX IF NOT EXISTS idx_user_resources_role_active ON user_resources(role_name, is_active); diff --git a/resources/user-dashboard/migrations/007_message_status_postgres.sql b/resources/user-dashboard/migrations/007_message_status_postgres.sql new file mode 100644 index 0000000..37823c9 --- /dev/null +++ b/resources/user-dashboard/migrations/007_message_status_postgres.sql @@ -0,0 +1,19 @@ +-- Migration: 007 – Enhance user_messages with status lifecycle and timestamps +-- Database: PostgreSQL +-- +-- Adds: status (sent | read | deleted), received_at, deleted_at +-- Existing rows are backfilled to is_read=true → status='read', else status='sent'. + +ALTER TABLE user_messages + ADD COLUMN IF NOT EXISTS status TEXT NOT NULL DEFAULT 'sent', + ADD COLUMN IF NOT EXISTS received_at TIMESTAMPTZ, + ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ; + +-- Backfill: rows already marked is_read should be 'read' with received_at = created_at. +UPDATE user_messages + SET status = 'read', + received_at = created_at + WHERE is_read = true AND status = 'sent'; + +CREATE INDEX IF NOT EXISTS idx_user_messages_status ON user_messages(user_id, status) + WHERE status != 'deleted'; diff --git a/resources/user-dashboard/migrations/007_message_status_sqlite.sql b/resources/user-dashboard/migrations/007_message_status_sqlite.sql new file mode 100644 index 0000000..373dd58 --- /dev/null +++ b/resources/user-dashboard/migrations/007_message_status_sqlite.sql @@ -0,0 +1,17 @@ +-- Migration: 007 – Enhance user_messages with status lifecycle and timestamps +-- Database: SQLite +-- +-- SQLite does not support ADD COLUMN with constraints referencing other columns +-- or backfill in a single ALTER, so we do it in two steps. + +ALTER TABLE user_messages ADD COLUMN status TEXT NOT NULL DEFAULT 'sent'; +ALTER TABLE user_messages ADD COLUMN received_at DATETIME; +ALTER TABLE user_messages ADD COLUMN deleted_at DATETIME; + +-- Backfill already-read rows. +UPDATE user_messages + SET status = 'read', + received_at = created_at + WHERE is_read = 1; + +CREATE INDEX IF NOT EXISTS idx_user_messages_status ON user_messages(user_id, status);