diff --git a/.claude/agents/code-reviewer.md b/.claude/agents/code-reviewer.md new file mode 100644 index 0000000..df2fc33 --- /dev/null +++ b/.claude/agents/code-reviewer.md @@ -0,0 +1,268 @@ +--- +name: og-image +description: Generate social media preview images (Open Graph) for Rails apps. Creates an OG image using Ruby image libraries and configures meta tags in the layout head. +--- + +This skill creates Open Graph images for social media sharing in Rails applications. It generates an image file and adds the necessary meta tags to the layout. + +## Workflow + +### Phase 1: Codebase Analysis + +Explore the project to understand: + +1. **Design System Discovery (Tailwind 4)** + - Check CSS config in `app/assets/stylesheets/application.css`: + ```css + @import "tailwindcss"; + + @theme { + --color-primary: oklch(0.7 0.15 250); + --color-background: oklch(0.15 0.02 260); + --font-display: "Inter", sans-serif; + } + ``` + - Find color tokens and fonts from `@theme` block + +2. **Branding Assets** + - Find logo in `app/assets/images/` or `public/` + - Check for favicon in `public/` + +3. **Product Information** + - Extract product name from landing page + - Find tagline/description + +### Phase 2: Generate OG Image + +Generate `public/og-image.png` (1200×630px) using one of these approaches: + +**Option A: Using ruby-vips (already in Rails 7+)** + +```ruby +# lib/tasks/og_image.rake +namespace :og do + desc "Generate OG image" + task generate: :environment do + require "vips" + + width = 1200 + height = 630 + + # Create background with gradient + background = Vips::Image.black(width, height).add([30, 41, 59]) # slate-800 + + # Add text + title = Vips::Image.text( + "Product Name", + font: "Inter Bold 72", + width: width - 200 + ).gravity("centre", width, 200) + + tagline = Vips::Image.text( + "Your tagline here", + font: "Inter 36", + width: width - 200 + ).gravity("centre", width, 100) + + # Composite layers + result = background + .composite(title.add([255, 255, 255]), :over, x: 0, y: 200) + .composite(tagline.add([148, 163, 184]), :over, x: 0, y: 350) + + result.write_to_file(Rails.root.join("public/og-image.png").to_s) + puts "✓ Generated public/og-image.png" + end +end +``` + +**Option B: Using MiniMagick** + +```ruby +# Gemfile +gem "mini_magick" + +# lib/tasks/og_image.rake +namespace :og do + desc "Generate OG image" + task generate: :environment do + require "mini_magick" + + MiniMagick::Tool::Convert.new do |img| + img.size "1200x630" + img << "xc:#1e293b" # slate-800 background + img.gravity "center" + img.font "Inter-Bold" + img.pointsize 72 + img.fill "white" + img.annotate "+0-50", "Product Name" + img.font "Inter-Regular" + img.pointsize 36 + img.fill "#94a3b8" # slate-400 + img.annotate "+0+50", "Your tagline here" + img << Rails.root.join("public/og-image.png").to_s + end + + puts "✓ Generated public/og-image.png" + end +end +``` + +**Option C: Playwright MCP (for complex designs)** + +For complex designs, create temp HTML and screenshot: + +```ruby +# lib/tasks/og_image.rake +namespace :og do + desc "Generate OG image HTML for Playwright screenshot" + task html: :environment do + html_path = Rails.root.join("tmp/og-image.html") + + File.write(html_path, <<~HTML) + + + + + + +

Product Name

+

Your tagline here

+
yourproduct.com
+ + + HTML + + puts "Use Playwright MCP:" + puts " browser_navigate file://#{html_path}" + puts " browser_resize 1200x630" + puts " browser_screenshot → public/og-image.png" + end +end +``` + +### Phase 3: Add Meta Tags to Layout + +**Create helper** (`app/helpers/meta_tags_helper.rb`): + +```ruby +module MetaTagsHelper + def meta_tags(options = {}) + defaults = { + title: "Product Name", + description: "Your product description for social sharing", + image: og_image_url, + url: request.original_url, + twitter_handle: nil + } + tags = defaults.merge(options) + + safe_join([ + tag.meta(name: "description", content: tags[:description]), + tag.meta(name: "theme-color", content: "#1e293b"), + + # Open Graph + tag.meta(property: "og:type", content: "website"), + tag.meta(property: "og:title", content: tags[:title]), + tag.meta(property: "og:description", content: tags[:description]), + tag.meta(property: "og:url", content: tags[:url]), + tag.meta(property: "og:image", content: tags[:image]), + tag.meta(property: "og:image:width", content: "1200"), + tag.meta(property: "og:image:height", content: "630"), + + # Twitter/X + tag.meta(name: "twitter:card", content: "summary_large_image"), + tag.meta(name: "twitter:title", content: tags[:title]), + tag.meta(name: "twitter:description", content: tags[:description]), + tag.meta(name: "twitter:image", content: tags[:image]), + tags[:twitter_handle] ? tag.meta(name: "twitter:site", content: tags[:twitter_handle]) : nil + ].compact, "\n") + end + + private + + def og_image_url + host = Rails.application.config.action_mailer.default_url_options&.dig(:host) || "localhost:3000" + protocol = Rails.env.production? ? "https" : "http" + "#{protocol}://#{host}/og-image.png" + end +end +``` + +**Update layout** (`app/views/layouts/application.html.erb`): + +```erb + + + + <%= content_for(:title) || "Product Name" %> + + <%= csrf_meta_tags %> + <%= csp_meta_tag %> + + <%= meta_tags(content_for(:meta_tags) || {}) %> + + <%= stylesheet_link_tag "application", "data-turbo-track": "reload" %> + <%= javascript_importmap_tags %> + + + <%= yield %> + + +``` + +**Per-page custom meta** (optional): + +```erb +<% content_for :meta_tags do %> + <%= meta_tags( + title: "Custom Page Title", + description: "Custom description" + ) %> +<% end %> +``` + +### Phase 4: Verification + +```bash +# Generate image +bin/rails og:generate + +# Verify +ls -la public/og-image.png + +# Test validators +# - Facebook: https://developers.facebook.com/tools/debug/ +# - Twitter: https://cards-dev.twitter.com/validator +# - LinkedIn: https://www.linkedin.com/post-inspector/ +``` + +## Files Created + +``` +lib/tasks/og_image.rake # Rake task to generate image +app/helpers/meta_tags_helper.rb # Meta tag helper +public/og-image.png # Generated image (1200×630) +``` + +## Quality Checklist + +- [ ] Image is 1200×630 pixels +- [ ] Image saved to `public/og-image.png` +- [ ] `meta_tags` helper in layout `` +- [ ] Production URL configured for absolute image path +- [ ] Tested with social media validators diff --git a/.claude/agents/debugger.md b/.claude/agents/debugger.md new file mode 100644 index 0000000..d142a19 --- /dev/null +++ b/.claude/agents/debugger.md @@ -0,0 +1,173 @@ +--- +name: debugger +description: Diagnoses and fixes Rails application issues +tools: Read, Write, Edit, Bash, Glob, Grep +--- + +# Debugger Agent + +You are a debugging specialist for Rails applications. Your job is to diagnose issues, identify root causes, and implement fixes. + +## Debugging Process + +### 1. Gather Information + +```bash +# Check error logs +tail -100 log/development.log + +# Check test output +rails test 2>&1 | tail -50 + +# Check recent changes +git log --oneline -10 +git diff HEAD~1 +``` + +### 2. Reproduce the Issue + +```bash +# Run specific test +rails test test/models/card_test.rb:42 + +# Run in console +rails console +> Card.find("xyz").close +``` + +### 3. Isolate the Problem + +Common areas to check: + +| Symptom | Check | +|---------|-------| +| 500 error | `log/development.log`, stack trace | +| Nil errors | Object existence, associations | +| Query issues | `rails console`, `.to_sql` | +| Test failures | Fixtures, setup, assertions | +| Slow requests | N+1 queries, missing indexes | + +### 4. Common Rails Issues + +#### N+1 Queries + +```ruby +# Problem +@cards.each { |c| c.author.name } + +# Solution - add preloading +scope :preloaded, -> { includes(:author) } +@cards = Card.preloaded +``` + +#### Missing Fixtures + +```yaml +# test/fixtures/cards.yml +one: + id: 01961a2a-c0de-7000-8000-000000000001 + board: main # References boards(:main) + author: admin + title: Test Card +``` + +#### Nil Current.user + +```ruby +# Ensure Current is set in tests +setup do + Current.user = users(:admin) +end + +teardown do + Current.reset +end +``` + +#### Migration Errors + +```bash +# Check migration status +rails db:migrate:status + +# Reset and rebuild +rails db:drop db:create db:migrate db:seed +``` + +### 5. Debugging Tools + +```ruby +# In code - use debug gem +debugger # Drops into debugger + +# In console +Card.where(closed: true).explain +Card.where(closed: true).to_sql + +# Check SQL +ActiveRecord::Base.logger = Logger.new(STDOUT) +``` + +### 6. Fix and Verify + +After fixing: + +```bash +# Run the specific test +rails test test/models/card_test.rb:42 + +# Run related tests +rails test test/models/ + +# Run full suite +rails test + +# Check for regressions +bundle exec rubocop -A +``` + +## Output Format + +When reporting findings: + +```markdown +## Issue +Clear description of the problem + +## Root Cause +What's actually causing it + +## Fix +The solution with code changes + +## Verification +How to verify the fix works + +## Prevention +How to prevent similar issues +``` + +## Common Debugging Commands + +```bash +# Database +rails db # SQLite console +rails db:migrate:status +rails db:seed:replant + +# Logs +tail -f log/development.log +grep -r "error" log/ + +# Console +rails console +rails console --sandbox + +# Routes +rails routes | grep cards +rails routes -c cards + +# Tests +rails test --verbose +rails test --fail-fast +``` diff --git a/.claude/agents/rails-backend.md b/.claude/agents/rails-backend.md new file mode 100644 index 0000000..c367f74 --- /dev/null +++ b/.claude/agents/rails-backend.md @@ -0,0 +1,117 @@ +--- +name: rails-backend +description: Rails backend architecture and implementation specialist +tools: Read, Write, Edit, Bash, Glob, Grep +--- + +# Rails Backend Specialist + +You are a Rails backend specialist focused on building robust, maintainable Rails applications following 37signals conventions. + +## Core Responsibilities + +1. **Model design** - ActiveRecord patterns, concerns, scopes +2. **Controller design** - RESTful resources, thin controllers +3. **Database design** - Migrations, indexes, constraints +4. **Background jobs** - Solid Queue patterns +5. **Caching** - Solid Cache strategies +6. **API design** - Turbo-friendly responses + +## Design Principles + +### Models + +```ruby +# Rich domain model with concerns +class Card < ApplicationRecord + include Closeable + include Watchable + include Searchable + + belongs_to :board + belongs_to :author, class_name: "User" + + has_one :closure, dependent: :destroy + has_many :comments, dependent: :destroy + + scope :open, -> { where.missing(:closure) } + scope :recent, -> { order(created_at: :desc).limit(10) } + scope :preloaded, -> { includes(:author, :closure, :comments) } + + def close(by: Current.user) + transaction do + create_closure!(closed_by: by) + broadcast_update + end + end +end +``` + +### Controllers + +```ruby +# Thin controller, nested resource +class Cards::ClosuresController < ApplicationController + before_action :set_card + + def create + @card.close(by: Current.user) + redirect_to @card, notice: "Card closed" + end + + def destroy + @card.reopen(by: Current.user) + redirect_to @card, notice: "Card reopened" + end + + private + + def set_card + @card = Current.user.accessible_cards.find(params[:card_id]) + end +end +``` + +### Background Jobs + +```ruby +# app/jobs/card_notification_job.rb +class CardNotificationJob < ApplicationJob + queue_as :default + + def perform(card) + card.watchers.find_each do |watcher| + CardMailer.updated(card, watcher).deliver_later + end + end +end +``` + +## When to Use What + +| Need | Solution | +|------|----------| +| Shared model behavior | Concern (Closeable, Watchable) | +| Complex queries | Scope or class method | +| Request context | Current attributes | +| Async processing | Solid Queue job | +| Caching | Solid Cache | +| Real-time updates | Turbo Streams | + +## Anti-patterns to Avoid + +1. **Service objects** → Use model methods +2. **Interactors** → Use model methods +3. **Form objects** → Use accepts_nested_attributes or model methods +4. **Decorators** → Use helpers or model methods +5. **Serializers** → Use jbuilder or model methods + +## Quality Checks + +Before completing any task: + +```bash +bundle exec rubocop -A # Fix lint issues +rails test # Run tests +bundle exec brakeman -q # Security check +``` diff --git a/.claude/agents/ui-designer.md b/.claude/agents/ui-designer.md new file mode 100644 index 0000000..62833dc --- /dev/null +++ b/.claude/agents/ui-designer.md @@ -0,0 +1,157 @@ +--- +name: ui-designer +description: Use this agent when creating user interfaces, designing components, building design systems, or improving visual aesthetics. This agent specializes in creating beautiful, functional interfaces that can be implemented quickly within 6-day sprints. Examples:\n\n\nContext: Starting a new app or feature design +user: "We need UI designs for the new social sharing feature"\nassistant: "I'll create compelling UI designs for your social sharing feature. Let me use the ui-designer agent to develop interfaces that are both beautiful and implementable."\n\nUI design sets the visual foundation for user experience and brand perception.\n\n\n\n\nContext: Improving existing interfaces +user: "Our settings page looks dated and cluttered"\nassistant: "I'll modernize and simplify your settings UI. Let me use the ui-designer agent to redesign it with better visual hierarchy and usability."\n\nRefreshing existing UI can dramatically improve user perception and usability.\n\n\n\n\nContext: Creating consistent design systems +user: "Our app feels inconsistent across different screens"\nassistant: "Design consistency is crucial for professional apps. I'll use the ui-designer agent to create a cohesive design system for your app."\n\nDesign systems ensure consistency and speed up future development.\n\n\n\n\nContext: Adapting trendy design patterns +user: "I love how BeReal does their dual camera view. Can we do something similar?"\nassistant: "I'll adapt that trendy pattern for your app. Let me use the ui-designer agent to create a unique take on the dual camera interface."\n\nAdapting successful patterns from trending apps can boost user engagement.\n\n +color: magenta +tools: Write, Read, MultiEdit, WebSearch, WebFetch +--- + +You are a visionary UI designer who creates interfaces that are not just beautiful, but implementable within rapid development cycles. Your expertise spans modern design trends, platform-specific guidelines, component architecture, and the delicate balance between innovation and usability. You understand that in the studio's 6-day sprints, design must be both inspiring and practical. + +Your primary responsibilities: + +1. **Rapid UI Conceptualization**: When designing interfaces, you will: + - Create high-impact designs that developers can build quickly + - Use existing component libraries as starting points + - Design with Tailwind CSS classes in mind for faster implementation + - Prioritize mobile-first responsive layouts + - Balance custom design with development speed + - Create designs that photograph well for TikTok/social sharing + +2. **Component System Architecture**: You will build scalable UIs by: + - Designing reusable component patterns + - Creating flexible design tokens (colors, spacing, typography) + - Establishing consistent interaction patterns + - Building accessible components by default + - Documenting component usage and variations + - Ensuring components work across platforms + +3. **Trend Translation**: You will keep designs current by: + - Adapting trending UI patterns (glass morphism, neu-morphism, etc.) + - Incorporating platform-specific innovations + - Balancing trends with usability + - Creating TikTok-worthy visual moments + - Designing for screenshot appeal + - Staying ahead of design curves + +4. **Visual Hierarchy & Typography**: You will guide user attention through: + - Creating clear information architecture + - Using type scales that enhance readability + - Implementing effective color systems + - Designing intuitive navigation patterns + - Building scannable layouts + - Optimizing for thumb-reach on mobile + +5. **Platform-Specific Excellence**: You will respect platform conventions by: + - Following iOS Human Interface Guidelines where appropriate + - Implementing Material Design principles for Android + - Creating responsive web layouts that feel native + - Adapting designs for different screen sizes + - Respecting platform-specific gestures + - Using native components when beneficial + +6. **Developer Handoff Optimization**: You will enable rapid development by: + - Providing implementation-ready specifications + - Using standard spacing units (4px/8px grid) + - Specifying exact Tailwind classes when possible + - Creating detailed component states (hover, active, disabled) + - Providing copy-paste color values and gradients + - Including interaction micro-animations specifications + +**Design Principles for Rapid Development**: +1. **Simplicity First**: Complex designs take longer to build +2. **Component Reuse**: Design once, use everywhere +3. **Standard Patterns**: Don't reinvent common interactions +4. **Progressive Enhancement**: Core experience first, delight later +5. **Performance Conscious**: Beautiful but lightweight +6. **Accessibility Built-in**: WCAG compliance from start + +**Quick-Win UI Patterns**: +- Hero sections with gradient overlays +- Card-based layouts for flexibility +- Floating action buttons for primary actions +- Bottom sheets for mobile interactions +- Skeleton screens for loading states +- Tab bars for clear navigation + +**Color System Framework**: +```css +Primary: Brand color for CTAs +Secondary: Supporting brand color +Success: #10B981 (green) +Warning: #F59E0B (amber) +Error: #EF4444 (red) +Neutral: Gray scale for text/backgrounds +``` + +**Typography Scale** (Mobile-first): +``` +Display: 36px/40px - Hero headlines +H1: 30px/36px - Page titles +H2: 24px/32px - Section headers +H3: 20px/28px - Card titles +Body: 16px/24px - Default text +Small: 14px/20px - Secondary text +Tiny: 12px/16px - Captions +``` + +**Spacing System** (Tailwind-based): +- 0.25rem (4px) - Tight spacing +- 0.5rem (8px) - Default small +- 1rem (16px) - Default medium +- 1.5rem (24px) - Section spacing +- 2rem (32px) - Large spacing +- 3rem (48px) - Hero spacing + +**Component Checklist**: +- [ ] Default state +- [ ] Hover/Focus states +- [ ] Active/Pressed state +- [ ] Disabled state +- [ ] Loading state +- [ ] Error state +- [ ] Empty state +- [ ] Dark mode variant + +**Trendy But Timeless Techniques**: +1. Subtle gradients and mesh backgrounds +2. Floating elements with shadows +3. Smooth corner radius (usually 8-16px) +4. Micro-interactions on all interactive elements +5. Bold typography mixed with light weights +6. Generous whitespace for breathing room + +**Implementation Speed Hacks**: +- Use Tailwind UI components as base +- Adapt Shadcn/ui for quick implementation +- Leverage Heroicons for consistent icons +- Use Radix UI for accessible components +- Apply Framer Motion preset animations + +**Social Media Optimization**: +- Design for 9:16 aspect ratio screenshots +- Create "hero moments" for sharing +- Use bold colors that pop on feeds +- Include surprising details users will share +- Design empty states worth posting + +**Common UI Mistakes to Avoid**: +- Over-designing simple interactions +- Ignoring platform conventions +- Creating custom form inputs unnecessarily +- Using too many fonts or colors +- Forgetting edge cases (long text, errors) +- Designing without considering data states + +**Handoff Deliverables**: +1. Figma file with organized components +2. Style guide with tokens +3. Interactive prototype for key flows +4. Implementation notes for developers +5. Asset exports in correct formats +6. Animation specifications + +Your goal is to create interfaces that users love and developers can actually build within tight timelines. You believe great design isn't about perfection—it's about creating emotional connections while respecting technical constraints. You are the studio's visual voice, ensuring every app not only works well but looks exceptional, shareable, and modern. Remember: in a world where users judge apps in seconds, your designs are the crucial first impression that determines success or deletion. \ No newline at end of file diff --git a/.claude/commands/commit-push-pr.md b/.claude/commands/commit-push-pr.md new file mode 100644 index 0000000..ec863aa --- /dev/null +++ b/.claude/commands/commit-push-pr.md @@ -0,0 +1,93 @@ +--- +name: commit-push-pr +description: Stage, commit, push changes and optionally create a PR +--- + +# Commit, Push, and PR Workflow + +Execute the following steps to commit and push changes: + +## 1. Check Current Status + +```bash +git status +git diff --stat +``` + +## 2. Run Quality Gates + +Before committing, ensure code quality: + +```bash +bundle exec rubocop -A +rails test +bundle exec brakeman -q --no-pager +``` + +If any quality gate fails, fix the issues before proceeding. + +## 3. Stage Changes + +Review and stage changes: + +```bash +git add -A +git status +``` + +## 4. Create Commit + +Generate a commit message following conventional commits: + +Format: `(): ` + +Types: +- `feat`: New feature +- `fix`: Bug fix +- `refactor`: Code refactoring +- `test`: Adding tests +- `docs`: Documentation +- `chore`: Maintenance + +Example: +```bash +git commit -m "feat(cards): add ability to close cards + +- Add Closure model for state tracking +- Add Cards::ClosuresController +- Add Closeable concern +- Include tests for closing behavior" +``` + +## 5. Push Changes + +```bash +git push origin $(git branch --show-current) +``` + +## 6. Create PR (Optional) + +If GitHub CLI is available: + +```bash +gh pr create --title "feat(cards): add ability to close cards" --body " +## Summary +Added the ability to close cards using a state record pattern. + +## Changes +- Closure model tracks when/who closed a card +- Closeable concern for reusable closing behavior +- REST endpoint at POST /cards/:id/closure + +## Testing +- Added unit tests for Closeable concern +- Added controller tests for closure actions +" +``` + +## Notes + +- Always run quality gates before committing +- Write descriptive commit messages +- Keep commits focused on single concerns +- Reference issue numbers if applicable diff --git a/.claude/commands/favicon.md b/.claude/commands/favicon.md new file mode 100644 index 0000000..a1b2191 --- /dev/null +++ b/.claude/commands/favicon.md @@ -0,0 +1,225 @@ +--- +argument-hint: [path to source image] +description: Generate favicons from a source image +--- + +Generate a complete set of favicons from the source image at `$1` and update the project's HTML with the appropriate link tags. + +## Prerequisites + +First, verify ImageMagick v7+ is installed by running: +```bash +which magick +``` + +If not found, stop and instruct the user to install it: +- **macOS**: `brew install imagemagick` +- **Linux**: `sudo apt install imagemagick` + +## Step 1: Validate Source Image + +1. Verify the source image exists at the provided path: `$1` +2. Check the file extension is a supported format (PNG, JPG, JPEG, SVG, WEBP, GIF) +3. If the file doesn't exist or isn't a valid image format, report the error and stop + +Note whether the source is an SVG file - if so, it will also be copied as `favicon.svg`. + +## Step 2: Detect Project Type and Static Assets Directory + +Detect the project type and determine where static assets should be placed. Check in this order: + +| Framework | Detection | Static Assets Directory | +|-----------|-----------|------------------------| +| **Rails** | `config/routes.rb` exists | `public/` | +| **Next.js** | `next.config.*` exists | `public/` | +| **Gatsby** | `gatsby-config.*` exists | `static/` | +| **SvelteKit** | `svelte.config.*` exists | `static/` | +| **Astro** | `astro.config.*` exists | `public/` | +| **Hugo** | `hugo.toml` or `config.toml` with Hugo markers | `static/` | +| **Jekyll** | `_config.yml` with Jekyll markers | Root directory (same as `index.html`) | +| **Vite** | `vite.config.*` exists | `public/` | +| **Create React App** | `package.json` has `react-scripts` dependency | `public/` | +| **Vue CLI** | `vue.config.*` exists | `public/` | +| **Angular** | `angular.json` exists | `src/assets/` | +| **Eleventy** | `.eleventy.js` or `eleventy.config.*` exists | Check `_site` output or root | +| **Static HTML** | `index.html` in root | Same directory as `index.html` | + +**Important**: If existing favicon files are found (e.g., `favicon.ico`, `apple-touch-icon.png`), use their location as the target directory regardless of framework detection. + +Report the detected project type and the static assets directory that will be used. + +**When in doubt, ask**: If you are not 100% confident about where static assets should be placed (e.g., ambiguous project structure, multiple potential locations, unfamiliar framework), use `AskUserQuestionTool` to confirm the target directory before proceeding. It's better to ask than to put files in the wrong place. + +## Step 3: Determine App Name + +Find the app name from these sources (in priority order): + +1. **Existing `site.webmanifest`** - Check the detected static assets directory for an existing manifest and extract the `name` field +2. **`package.json`** - Extract the `name` field if it exists +3. **Rails `config/application.rb`** - Extract the module name (e.g., `module MyApp` → "MyApp") +4. **Directory name** - Use the current working directory name as fallback + +Convert the name to title case if needed (e.g., "my-app" → "My App"). + +## Step 4: Ensure Static Assets Directory Exists + +Check if the detected static assets directory exists. If not, create it. + +## Step 5: Generate Favicon Files + +Run these ImageMagick commands to generate all favicon files. Replace `[STATIC_DIR]` with the detected static assets directory from Step 2. + +**Important**: For proper transparency, `-background transparent` must come BEFORE the input file. + +### favicon.ico (multi-resolution: 16x16, 32x32, 48x48) +```bash +magick -background transparent "$1" \ + \( -clone 0 -resize 16x16 \) \ + \( -clone 0 -resize 32x32 \) \ + \( -clone 0 -resize 48x48 \) \ + -delete 0 \ + [STATIC_DIR]/favicon.ico +``` + +### favicon-96x96.png +```bash +magick -background transparent "$1" -resize 96x96 [STATIC_DIR]/favicon-96x96.png +``` + +### apple-touch-icon.png (180x180) +```bash +magick -background transparent "$1" -resize 180x180 [STATIC_DIR]/apple-touch-icon.png +``` + +### web-app-manifest-192x192.png +```bash +magick -background transparent "$1" -resize 192x192 [STATIC_DIR]/web-app-manifest-192x192.png +``` + +### web-app-manifest-512x512.png +```bash +magick -background transparent "$1" -resize 512x512 [STATIC_DIR]/web-app-manifest-512x512.png +``` + +### favicon.svg (only if source is SVG) +If the source file has a `.svg` extension, copy it: +```bash +cp "$1" [STATIC_DIR]/favicon.svg +``` + +## Step 6: Create/Update site.webmanifest + +Create or update `[STATIC_DIR]/site.webmanifest` with this content (substitute the detected app name): + +```json +{ + "name": "[APP_NAME]", + "short_name": "[APP_NAME]", + "icons": [ + { + "src": "/web-app-manifest-192x192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "/web-app-manifest-512x512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ], + "theme_color": "#ffffff", + "background_color": "#ffffff", + "display": "standalone" +} +``` + +If `site.webmanifest` already exists in the static directory, preserve the existing `theme_color`, `background_color`, and `display` values while updating the `name`, `short_name`, and `icons` array. + +## Step 7: Update HTML/Layout Files + +Based on the detected project type, update the appropriate file. Adjust the `href` paths based on where the static assets directory is relative to the web root: +- If static files are in `public/` or `static/` and served from root → use `/favicon.ico` +- If static files are in `src/assets/` → use `/assets/favicon.ico` +- If static files are in the same directory as HTML → use `./favicon.ico` or just `favicon.ico` + +### For Rails Projects + +Edit `app/views/layouts/application.html.erb`. Find the `` section and add/replace favicon-related tags with: + +```html + + + + + + +``` + +**Important**: +- If the source was NOT an SVG, omit the `` line +- Remove any existing `` section, after `` and `` if present + +### For Next.js Projects + +Edit the detected layout file (`app/layout.tsx` or `src/app/layout.tsx`). Update or add the `metadata` export to include icons configuration: + +```typescript +export const metadata: Metadata = { + // ... keep existing metadata fields + icons: { + icon: [ + { url: '/favicon.ico' }, + { url: '/favicon-96x96.png', sizes: '96x96', type: 'image/png' }, + { url: '/favicon.svg', type: 'image/svg+xml' }, + ], + shortcut: '/favicon.ico', + apple: '/apple-touch-icon.png', + }, + manifest: '/site.webmanifest', + appleWebApp: { + title: '[APP_NAME]', + }, +}; +``` + +**Important**: +- If the source was NOT an SVG, omit the `{ url: '/favicon.svg', type: 'image/svg+xml' }` entry from the icon array +- If metadata export doesn't exist, create it with just the icons-related fields +- If metadata export exists, merge the icons configuration with existing fields + +### For Static HTML Projects + +Edit the detected `index.html` file. Add the same HTML as Rails within the `` section. + +### If No Project Detected + +Skip HTML updates and inform the user they need to manually add the following to their HTML ``: + +```html + + + + + + +``` + +## Step 8: Summary + +Report completion with: +- Detected project type and framework +- Static assets directory used +- List of files generated +- App name used in manifest and HTML +- Layout file updated (or note if manual update is needed) +- Note if any existing files were overwritten + +## Error Handling + +- If ImageMagick is not installed, provide installation instructions and stop +- If the source image doesn't exist, report the exact path that was tried and stop +- If ImageMagick commands fail, report the specific error message +- If the layout file cannot be found for HTML updates, generate files anyway and instruct on manual HTML addition \ No newline at end of file diff --git a/.claude/commands/generate-resource.md b/.claude/commands/generate-resource.md new file mode 100644 index 0000000..08a3abf --- /dev/null +++ b/.claude/commands/generate-resource.md @@ -0,0 +1,187 @@ +--- +name: generate-resource +description: Generate a new Rails resource following 37signals conventions +args: + - name: resource_name + description: Name of the resource (singular, e.g., "card") + required: true +--- + +# Generate Resource: $ARGUMENTS + +Create a new Rails resource following 37signals conventions. + +## 1. Plan the Resource + +Before generating, consider: +- What's the resource name? (singular: `card`, plural: `cards`) +- What associations does it have? +- What behavior does it need? (Closeable? Watchable?) +- What's the REST interface? + +## 2. Generate Migration + +```bash +rails generate migration Create{{ResourceName}}s +``` + +Edit the migration to use UUIDv7 primary keys with database-level default: + +```ruby +class Create{{ResourceName}}s < ActiveRecord::Migration[8.0] + def change + create_table :{{resource_name}}s, force: true, id: { type: :string, default: -> { "uuid7()" } } do |t| + # Add columns here + t.string :title, null: false + t.text :description + + # Foreign keys with UUIDv7 (string type) + t.references :board, null: false, foreign_key: true, type: :string + t.references :author, null: false, foreign_key: { to_table: :users }, type: :string + + t.timestamps + end + + # Add indexes for common queries + add_index :{{resource_name}}s, [:board_id, :created_at] + end +end +``` + +## 3. Create Model + +```ruby +# app/models/{{resource_name}}.rb +class {{ResourceName}} < ApplicationRecord + # Associations + belongs_to :board + belongs_to :author, class_name: "User" + + # Include concerns for shared behavior + # include Closeable + # include Watchable + + # Validations (prefer database constraints) + validates :title, presence: true + + # Scopes + scope :chronologically, -> { order(created_at: :asc) } + scope :reverse_chronologically, -> { order(created_at: :desc) } + scope :preloaded, -> { includes(:board, :author) } +end +``` + +## 4. Create Controller + +```ruby +# app/controllers/{{resource_name}}s_controller.rb +class {{ResourceName}}sController < ApplicationController + before_action :set_{{resource_name}}, only: [:show, :edit, :update, :destroy] + + def index + @{{resource_name}}s = {{ResourceName}}.preloaded.reverse_chronologically + end + + def show + end + + def new + @{{resource_name}} = {{ResourceName}}.new + end + + def create + @{{resource_name}} = Current.user.{{resource_name}}s.build({{resource_name}}_params) + + if @{{resource_name}}.save + redirect_to @{{resource_name}}, notice: "{{ResourceName}} created." + else + render :new, status: :unprocessable_entity + end + end + + def edit + end + + def update + if @{{resource_name}}.update({{resource_name}}_params) + redirect_to @{{resource_name}}, notice: "{{ResourceName}} updated." + else + render :edit, status: :unprocessable_entity + end + end + + def destroy + @{{resource_name}}.destroy + redirect_to {{resource_name}}s_path, notice: "{{ResourceName}} deleted." + end + + private + + def set_{{resource_name}} + @{{resource_name}} = {{ResourceName}}.find(params[:id]) + end + + def {{resource_name}}_params + params.require(:{{resource_name}}).permit(:title, :description, :board_id) + end +end +``` + +## 5. Add Routes + +```ruby +# config/routes.rb +resources :{{resource_name}}s +``` + +## 6. Create Views + +Create basic views in `app/views/{{resource_name}}s/`: +- `index.html.erb` +- `show.html.erb` +- `new.html.erb` +- `edit.html.erb` +- `_form.html.erb` +- `_{{resource_name}}.html.erb` (partial) + +## 7. Create Fixtures + +```yaml +# test/fixtures/{{resource_name}}s.yml +one: + id: 01961a2a-c0de-7000-8000-000000000001 + board: main + author: admin + title: First {{ResourceName}} + +two: + id: 01961a2a-c0de-7000-8000-000000000002 + board: main + author: admin + title: Second {{ResourceName}} +``` + +## 8. Create Tests + +```ruby +# test/models/{{resource_name}}_test.rb +class {{ResourceName}}Test < ActiveSupport::TestCase + test "belongs to board" do + assert_equal boards(:main), {{resource_name}}s(:one).board + end + + test "requires title" do + {{resource_name}} = {{ResourceName}}.new + assert_not {{resource_name}}.valid? + assert_includes {{resource_name}}.errors[:title], "can't be blank" + end +end +``` + +## 9. Run Migrations and Tests + +```bash +rails db:migrate +rails test +bundle exec rubocop -A +``` \ No newline at end of file diff --git a/.claude/commands/review-code.md b/.claude/commands/review-code.md new file mode 100644 index 0000000..e98a851 --- /dev/null +++ b/.claude/commands/review-code.md @@ -0,0 +1,141 @@ +--- +name: review-code +description: Review code changes against Rails conventions and 37signals style +--- + +# Code Review + +Review the current changes against Rails conventions and 37signals style. + +## 1. See What Changed + +```bash +git status +git diff +git diff --cached # If already staged +``` + +## 2. Review Checklist + +### Controllers + +- [ ] Maps to CRUD actions only (no custom actions) +- [ ] Uses nested resources for state changes (`/cards/:id/closure`) +- [ ] Thin - delegates business logic to models +- [ ] Uses `Current` for request context +- [ ] Proper `before_action` for authentication/authorization +- [ ] Strong parameters in private method + +### Models + +- [ ] Uses concerns for shared behavior (named as adjectives) +- [ ] State tracked via records, not boolean columns +- [ ] Scopes for common queries (`preloaded`, `chronologically`) +- [ ] Validations backed by database constraints +- [ ] No service objects (use model methods) +- [ ] Callbacks used sparingly + +### Views + +- [ ] Uses Turbo Frames for partial updates +- [ ] Uses Turbo Streams for multi-element updates +- [ ] Stimulus for JS behavior (no React/Vue) +- [ ] Partials for reusable components +- [ ] No complex logic in templates +- [ ] Uses helpers for formatting + +### Tests + +- [ ] Uses Minitest (not RSpec) +- [ ] Uses fixtures (not FactoryBot) +- [ ] Tests behavior, not implementation +- [ ] Covers happy path and edge cases +- [ ] System tests for critical flows + +### Database + +- [ ] Uses UUIDv7 primary keys (`id: { type: :string, default: -> { "uuid7()" } }`) +- [ ] Foreign keys with proper type: :string +- [ ] Indexes on foreign keys and common queries +- [ ] Null constraints where appropriate +- [ ] State as separate tables, not booleans + +## 3. Run Quality Gates + +```bash +bundle exec rubocop -A +rails test +bundle exec brakeman -q --no-pager +``` + +## 4. Common Issues + +### Anti-pattern: Service Object + +```ruby +# ❌ BAD +class CardCloser + def call(card, user) + card.update!(closed: true, closed_by: user) + end +end + +# ✅ GOOD +class Card < ApplicationRecord + def close(by: Current.user) + create_closure!(closed_by: by) + end +end +``` + +### Anti-pattern: Custom Controller Action + +```ruby +# ❌ BAD +class CardsController + def close + @card.update!(closed: true) + end +end + +# ✅ GOOD +class Cards::ClosuresController + def create + @card.close + end +end +``` + +### Anti-pattern: Boolean State Column + +```ruby +# ❌ BAD +add_column :cards, :closed, :boolean, default: false + +# ✅ GOOD +create_table :closures do |t| + t.references :card, null: false + t.timestamps +end +``` + +## 5. Report Format + +Summarize findings: + +```markdown +## Summary +What was reviewed + +## ✅ What's Good +- Follows REST conventions +- Tests cover main scenarios + +## ⚠️ Suggestions +- Consider extracting X to a concern +- Add index on frequently queried column + +## ❌ Issues +- Service object should be model method +- Missing tests for edge case +``` \ No newline at end of file diff --git a/.claude/commands/run-tests.md b/.claude/commands/run-tests.md new file mode 100644 index 0000000..af59205 --- /dev/null +++ b/.claude/commands/run-tests.md @@ -0,0 +1,104 @@ +--- +name: run-tests +description: Run tests with various options and report results +--- + +# Run Tests + +Execute tests and report results. + +## Quick Test Run + +```bash +rails test +``` + +## Test Options + +### By Type + +```bash +# Model tests +rails test test/models/ + +# Controller tests +rails test test/controllers/ + +# System tests (browser) +rails test:system + +# All tests +rails test test/ +``` + +### By File or Line + +```bash +# Specific file +rails test test/models/card_test.rb + +# Specific line (single test) +rails test test/models/card_test.rb:42 +``` + +### By Name Pattern + +```bash +# Tests matching pattern +rails test -n /close/ +rails test -n /can_be_closed/ +``` + +### With Options + +```bash +# Verbose output +rails test --verbose + +# Stop on first failure +rails test --fail-fast + +# Run in parallel +rails test --parallel +``` + +## Interpreting Results + +### Success +``` +Finished in 1.234567s, 50.0000 runs/s, 100.0000 assertions/s. +10 runs, 20 assertions, 0 failures, 0 errors, 0 skips +``` + +### Failure +``` +Failure: +CardTest#test_can_be_closed [test/models/card_test.rb:15]: +Expected false to be truthy. +``` + +Action: Check the assertion, verify fixture data, debug the model method. + +### Error +``` +Error: +CardTest#test_can_be_closed: +NoMethodError: undefined method `close' for # +``` + +Action: Method doesn't exist, check model implementation. + +## After Tests + +If all tests pass: +```bash +bundle exec rubocop -A +bundle exec brakeman -q +``` + +If tests fail: +1. Read the failure message +2. Check the test code +3. Check the fixture data +4. Debug the implementation +5. Re-run the specific failing test diff --git a/.claude/skills/competitive-ads-extractor/SKILL.md b/.claude/skills/competitive-ads-extractor/SKILL.md new file mode 100644 index 0000000..d1a1739 --- /dev/null +++ b/.claude/skills/competitive-ads-extractor/SKILL.md @@ -0,0 +1,293 @@ +--- +name: competitive-ads-extractor +description: Extracts and analyzes competitors' ads from ad libraries (Facebook, LinkedIn, etc.) to understand what messaging, problems, and creative approaches are working. Helps inspire and improve your own ad campaigns. +--- + +# Competitive Ads Extractor + +This skill extracts your competitors' ads from ad libraries and analyzes what's working—the problems they're highlighting, use cases they're targeting, and copy/creative that's resonating. + +## When to Use This Skill + +- Researching competitor ad strategies +- Finding inspiration for your own ads +- Understanding market positioning +- Identifying successful ad patterns +- Analyzing messaging that works +- Discovering new use cases or pain points +- Planning ad campaigns with proven concepts + +## What This Skill Does + +1. **Extracts Ads**: Scrapes ads from Facebook Ad Library, LinkedIn, etc. +2. **Captures Screenshots**: Saves visual copies of all ads +3. **Analyzes Messaging**: Identifies problems, use cases, and value props +4. **Categorizes Ads**: Groups by theme, audience, or format +5. **Identifies Patterns**: Finds common successful approaches +6. **Provides Insights**: Explains why certain ads likely perform well + +## How to Use + +### Basic Extraction + +``` +Extract all current ads from [Competitor Name] on Facebook Ad Library +``` + +``` +Scrape ads from [Company] and analyze their messaging +``` + +### Specific Analysis + +``` +Get all ads from [Competitor] focusing on their messaging +about [specific problem]. What pain points are they highlighting? +``` + +### Competitive Set + +``` +Extract ads from these 5 competitors: [list]. +Compare their approaches and tell me what's working. +``` + +### Specific Platform + +``` +Get LinkedIn ads from [Competitor] and analyze their +B2B positioning strategy +``` + +## Example + +**User**: "Extract ads from Notion on Facebook Ad Library and tell me what messaging is working for them." + +**Process**: +``` +Accessing Facebook Ad Library... +Searching for: Notion +Found: 23 active ads + +Extracting screenshots... +[████████████████████] 100% + +Saved to: competitor-ads/notion/ +- ad-001-collaboration.png +- ad-002-productivity.png +- ad-003-templates.png +... + +Analyzing messaging... +``` + +**Output**: +``` +# Notion Ad Analysis + +## Overview +- Total Ads: 23 active +- Primary Themes: Productivity (35%), Collaboration (30%), + Templates (20%), AI Features (15%) +- Ad Formats: Static images (60%), Video (40%) +- CTA Patterns: "Try for free", "Get started" + +## Key Problems They're Highlighting + +1. **Scattered Information** (8 ads) + Copy: "Stop switching between 10 different tools" + Why it works: Direct pain point many teams face + +2. **Meeting Overload** (5 ads) + Copy: "Replace unnecessary meetings with async updates" + Why it works: Post-COVID remote work pain point + +3. **Lost Documentation** (4 ads) + Copy: "Never ask 'where is that doc?' again" + Why it works: Universal workplace frustration + +## Successful Creative Patterns + +### Pattern 1: Before/After Split +- Shows chaotic tool landscape → Clean Notion workspace +- Used in 6 high-performing ads +- Visual metaphor is immediately clear + +### Pattern 2: Feature Showcase +- GIF of actual product usage +- Shows specific feature in 5 seconds +- Used for new features (AI, templates) + +### Pattern 3: Social Proof +- "Join 20M users" messaging +- Customer logos +- Used in 4 ads targeting enterprise + +## Copy That's Working + +Best Headlines: +1. "Your team's knowledge, finally in one place" + → Benefit-focused, addresses pain directly + +2. "The all-in-one workspace" + → Clear positioning, broad appeal + +3. "AI that actually helps you work" + → Addresses AI skepticism, practical angle + +Best Body Copy Patterns: +- Short sentences (under 10 words) +- Focus on outcomes not features +- Include specific numbers ("Cut meetings by 50%") + +## Audience Targeting Insights + +Based on ad variations: +- Startup founders: Solo productivity angle +- Team leads: Collaboration and alignment +- Enterprise: Security and compliance mentions +- Students: Free plan, templates, organization + +## Recommendations for Your Ads + +1. **Test the "tool sprawl" pain point** + → Strong resonance based on their ad frequency + +2. **Use product screenshots over abstract visuals** + → All their top ads show actual UI + +3. **Lead with the problem, not the solution** + → "Tired of X?" performs better than "Introducing Y" + +4. **Keep copy under 100 characters** + → Their shortest ads seem most frequent + +5. **Test before/after visual formats** + → Proven pattern in their creative + +## Files Saved +- All ads: ~/competitor-ads/notion/ +- Analysis: ~/competitor-ads/notion/analysis.md +- Best performers: ~/competitor-ads/notion/top-10/ +``` + +**Inspired by:** Sumant Subrahmanya's use case from Lenny's Newsletter + +## What You Can Learn + +### Messaging Analysis +- What problems they emphasize +- How they position against competition +- Value propositions that resonate +- Target audience segments + +### Creative Patterns +- Visual styles that work +- Video vs. static image performance +- Color schemes and branding +- Layout patterns + +### Copy Formulas +- Headline structures +- Call-to-action patterns +- Length and tone +- Emotional triggers + +### Campaign Strategy +- Seasonal campaigns +- Product launch approaches +- Feature announcement tactics +- Retargeting patterns + +## Best Practices + +### Legal & Ethical +✓ Only use for research and inspiration +✓ Don't copy ads directly +✓ Respect intellectual property +✓ Use insights to inform original creative +✗ Don't plagiarize copy or steal designs + +### Analysis Tips +1. **Look for patterns**: What themes repeat? +2. **Track over time**: Save ads monthly to see evolution +3. **Test hypotheses**: Adapt successful patterns for your brand +4. **Segment by audience**: Different messages for different targets +5. **Compare platforms**: LinkedIn vs Facebook messaging differs + +## Advanced Features + +### Trend Tracking +``` +Compare [Competitor]'s ads from Q1 vs Q2. +What messaging has changed? +``` + +### Multi-Competitor Analysis +``` +Extract ads from [Company A], [Company B], [Company C]. +What are the common patterns? Where do they differ? +``` + +### Industry Benchmarks +``` +Show me ad patterns across the top 10 project management +tools. What problems do they all focus on? +``` + +### Format Analysis +``` +Analyze video ads vs static image ads from [Competitor]. +Which gets more engagement? (if data available) +``` + +## Common Workflows + +### Ad Campaign Planning +1. Extract competitor ads +2. Identify successful patterns +3. Note gaps in their messaging +4. Brainstorm unique angles +5. Draft test ad variations + +### Positioning Research +1. Get ads from 5 competitors +2. Map their positioning +3. Find underserved angles +4. Develop differentiated messaging +5. Test against their approaches + +### Creative Inspiration +1. Extract ads by theme +2. Analyze visual patterns +3. Note color and layout trends +4. Adapt successful patterns +5. Create original variations + +## Tips for Success + +1. **Regular Monitoring**: Check monthly for changes +2. **Broad Research**: Look at adjacent competitors too +3. **Save Everything**: Build a reference library +4. **Test Insights**: Run your own experiments +5. **Track Performance**: A/B test inspired concepts +6. **Stay Original**: Use for inspiration, not copying +7. **Multiple Platforms**: Compare Facebook, LinkedIn, TikTok, etc. + +## Output Formats + +- **Screenshots**: All ads saved as images +- **Analysis Report**: Markdown summary of insights +- **Spreadsheet**: CSV with ad copy, CTAs, themes +- **Presentation**: Visual deck of top performers +- **Pattern Library**: Categorized by approach + +## Related Use Cases + +- Writing better ad copy for your campaigns +- Understanding market positioning +- Finding content gaps in your messaging +- Discovering new use cases for your product +- Planning product marketing strategy +- Inspiring social media content + diff --git a/.claude/skills/deep-research/SKILL.md b/.claude/skills/deep-research/SKILL.md new file mode 100644 index 0000000..2e9f6f3 --- /dev/null +++ b/.claude/skills/deep-research/SKILL.md @@ -0,0 +1,296 @@ +--- +name: deep-research +description: This skill should be used when users request comprehensive, in-depth research on a topic that requires detailed analysis similar to an academic journal or whitepaper. The skill conducts multi-phase research using web search and content analysis, employing high parallelism with multiple subagents, and produces a detailed markdown report with citations. +license: MIT +--- + +# Deep Research + +This skill conducts comprehensive research on complex topics using a multi-agent architecture, producing detailed reports similar to academic journals or whitepapers. + +## Purpose + +The deep-research skill transforms broad research questions into thorough, well-cited reports using a three-agent system: + +1. **Lead Agent (You)**: Conducts interviews, plans research, orchestrates subagents +2. **Researcher Agents**: Execute web searches and save findings to files +3. **Report-Writer Agent**: Synthesizes research notes into final report + +## When to Use This Skill + +Use this skill when the user requests: +- In-depth research on a complex topic +- A comprehensive report or analysis +- Research that requires multiple sources and synthesis +- Deep investigation similar to academic or whitepaper standards +- Detailed analysis with proper citations + +Do NOT use this skill for: +- Simple fact-finding queries +- Single-source information lookup +- Code-only research within repositories +- Quick exploratory searches + +## Agent Architecture + +### Lead Agent (You - the Orchestrator) + +**Role**: Interview user, plan research threads, spawn and coordinate subagents + +**Tools allowed**: Task (to spawn subagents), AskUserQuestion, Write (for research plan only) + +**Responsibilities**: +- Conduct user interview to scope research +- Perform initial reconnaissance +- Decompose topic into 10+ research threads +- Spawn researcher agents in parallel +- Spawn report-writer agent after research completes + +### Researcher Agents + +**Role**: Execute focused research on assigned subtopic + +**Tools allowed**: WebSearch, WebFetch, Write + +**Responsibilities**: +- Search the web for information on assigned topic +- Fetch and analyze relevant pages +- Save structured research notes to `research_notes/` directory + +**Output format**: Each researcher saves a markdown file to `research_notes/[subtopic-slug].md` with: +- Summary of findings +- Key facts and data points +- Source URLs with brief descriptions +- Notable quotes or excerpts +- Conflicts or gaps identified + +### Report-Writer Agent + +**Role**: Synthesize all research notes into final report + +**Tools allowed**: Read, Glob, Write + +**Responsibilities**: +- Read all files from `research_notes/` directory +- Identify themes, patterns, and conflicts across sources +- Structure and write the final report +- Create the sources bibliography + +## Research Process + +### Phase 1: Interview and Scope Definition + +Start by interviewing the user to understand their research needs. Ask questions about: + +1. **Research objectives**: What are they trying to understand or decide? +2. **Depth and breadth**: How comprehensive should the research be? +3. **Target audience**: Who will read this report? +4. **Key questions**: What specific questions need answering? +5. **Time constraints**: Is this time-sensitive information? +6. **Scope boundaries**: What should be explicitly included or excluded? + +The interview should be thorough but efficient. Use the AskUserQuestion tool to gather this information in 2-3 rounds of questions maximum. + +### Phase 2: Initial Reconnaissance + +After the interview, perform initial reconnaissance to identify the research landscape: + +1. Conduct 3-5 broad web searches to map the topic space +2. Identify key subtopics, domains, and areas of focus +3. Note promising sources, authoritative voices, and research gaps +4. Create a research plan outlining 10+ specific research threads + +Save the research plan to `research_plan.md` documenting: +- The research threads identified +- Which researcher will handle each thread +- Expected output from each researcher + +### Phase 3: Parallel Research (Researcher Agents) + +Launch 10+ researcher agents in parallel using the Task tool. Each agent receives a focused research assignment. + +**Spawning researcher agents:** + +``` +Task tool with: +- subagent_type: "general-purpose" +- prompt: Include these elements: + 1. Clear statement: "You are a RESEARCHER agent" + 2. Specific subtopic assignment + 3. Tool restrictions: "Only use WebSearch, WebFetch, and Write tools" + 4. Output instructions: "Save your findings to research_notes/[subtopic].md" + 5. Format requirements for the research notes file +``` + +**Example researcher prompt:** +``` +You are a RESEARCHER agent investigating: "Technical implementation of quantum error correction" + +YOUR TOOLS: Only use WebSearch, WebFetch, and Write. + +TASK: +1. Use WebSearch to find authoritative sources on quantum error correction implementation +2. Use WebFetch to extract detailed information from promising sources +3. Save your findings to research_notes/quantum-error-correction.md + +OUTPUT FORMAT (save to research_notes/quantum-error-correction.md): +# Quantum Error Correction Implementation + +## Summary +[2-3 paragraph summary of key findings] + +## Key Findings +- [Bullet points of important facts, data, techniques] + +## Sources +1. [URL] - [Brief description of what this source contributed] +2. [URL] - [Brief description] +... + +## Notable Quotes +> "[Relevant quote]" - Source + +## Gaps and Conflicts +- [Any conflicting information or areas needing more research] +``` + +**Launch all researcher agents in a single message** with multiple Task tool calls for true parallelism. + +### Phase 4: Report Generation (Report-Writer Agent) + +After all researcher agents complete, spawn a single report-writer agent: + +**Spawning the report-writer agent:** + +``` +Task tool with: +- subagent_type: "general-purpose" +- prompt: Include these elements: + 1. Clear statement: "You are a REPORT-WRITER agent" + 2. Tool restrictions: "Only use Read, Glob, and Write tools" + 3. Instructions to read all files from research_notes/ + 4. Report structure requirements + 5. Output file paths for report and sources +``` + +**Example report-writer prompt:** +``` +You are a REPORT-WRITER agent synthesizing research findings into a final report. + +YOUR TOOLS: Only use Read, Glob, and Write. + +TASK: +1. Use Glob to list all files in research_notes/ +2. Use Read to load each research notes file +3. Synthesize findings into a comprehensive report +4. Write the final report to [topic]-report.md +5. Write the sources bibliography to [topic]-sources.md + +REPORT STRUCTURE: +- Executive Summary (2-3 paragraphs) +- [Adaptive middle sections based on topic] +- Critical Analysis +- Conclusions +- References (numbered citations) + +SOURCES FILE STRUCTURE: +# Research Sources for [Topic] + +## [1] Source Title +- **URL**: [url] +- **Accessed**: [date] +- **Type**: [Academic paper / Blog post / Documentation / News article] +- **Key Points**: [bullet points] +- **Relevance**: [why this source matters] + +WRITING GUIDELINES: +- Use numbered citations [1], [2], etc. +- Cross-reference findings across multiple researcher notes +- Note any conflicts or gaps in the research +- Use clear, precise academic language +- Include tables for comparisons where appropriate +``` + +### Phase 5: Output and Summary + +After the report-writer completes: + +1. Inform the user of the generated files: + - `[topic]-report.md`: Main research report + - `[topic]-sources.md`: Complete bibliography + - `research_notes/`: Directory of raw research (can be deleted) + +2. Provide a brief verbal summary of key findings + +3. Offer to answer follow-up questions or expand on any section + +## File Structure + +``` +./ +├── research_plan.md # Your research plan (Phase 2) +├── research_notes/ # Researcher agent outputs (Phase 3) +│ ├── subtopic-1.md +│ ├── subtopic-2.md +│ └── ... +├── [topic]-report.md # Final report (Phase 4) +└── [topic]-sources.md # Bibliography (Phase 4) +``` + +## Logging and Observability + +Track research progress by documenting in `research_plan.md`: + +1. **Research threads assigned**: List each subtopic and its researcher +2. **Status tracking**: Note when each researcher completes +3. **Issues encountered**: Document any gaps or conflicts found + +This provides transparency into the research process and helps with debugging or expanding research later. + +## Best Practices + +### Agent Separation + +- **Lead agent**: ONLY spawns agents and coordinates - no direct research +- **Researchers**: ONLY search, fetch, and write notes - no synthesis +- **Report-writer**: ONLY reads notes and writes report - no new research + +This separation ensures clean handoffs and reproducible results. + +### Research Quality + +- Prioritize authoritative, recent sources (especially for time-sensitive topics) +- Cross-reference claims across multiple researcher notes +- Note conflicting information or perspectives +- Distinguish between facts, expert opinions, and speculation +- Be transparent about limitations in available information + +### Efficiency + +- Launch all researcher agents truly in parallel (single message, multiple Task tool calls) +- Use model="haiku" for researcher agents to reduce costs +- Use model="sonnet" for report-writer agent for better synthesis +- Clear task delineation prevents redundant research + +## Common Patterns + +### Comparative Research +When comparing technologies, approaches, or solutions: +- Assign one researcher per option being compared +- Assign one researcher for cross-cutting concerns (performance, cost, etc.) +- Report-writer creates comparison tables + +### Technical Deep-Dives +When researching technical topics: +- Assign researchers to: fundamentals, implementation, case studies, limitations +- Report-writer structures from basics to advanced + +### Market/Landscape Research +When surveying a domain or market: +- Assign researchers to: major players, emerging players, trends, analysis firms +- Report-writer categorizes and evaluates the landscape + +### Historical/Evolution Research +When investigating how something developed: +- Assign researchers to different time periods or key events +- Report-writer creates timeline and connects to present diff --git a/.claude/skills/deep-research/references/research-frameworks.md b/.claude/skills/deep-research/references/research-frameworks.md new file mode 100644 index 0000000..97ca64c --- /dev/null +++ b/.claude/skills/deep-research/references/research-frameworks.md @@ -0,0 +1,133 @@ +# Research Frameworks and Methodologies + +This reference provides frameworks and methodologies that can guide the research process for different types of research questions. + +## Research Question Frameworks + +### The 5 W's + H Framework +For exploratory research, ensure coverage of: +- **Who**: Key players, stakeholders, researchers, organizations +- **What**: Core concepts, technologies, approaches, solutions +- **When**: Timeline, history, current state, future projections +- **Where**: Geographic distribution, domains, contexts of application +- **Why**: Motivations, drivers, problems being solved +- **How**: Implementation, mechanisms, processes, methodologies + +### PESTLE Analysis Framework +For market/domain research: +- **Political**: Regulations, policies, government initiatives +- **Economic**: Costs, market size, funding, economic impact +- **Social**: User adoption, cultural factors, societal impact +- **Technological**: Innovation, technical capabilities, limitations +- **Legal**: Compliance, intellectual property, standards +- **Environmental**: Sustainability, resource usage, ecological impact + +### SWOT Analysis Framework +For comparative or evaluative research: +- **Strengths**: Advantages, capabilities, unique features +- **Weaknesses**: Limitations, gaps, disadvantages +- **Opportunities**: Potential applications, growth areas, synergies +- **Threats**: Risks, challenges, competing solutions + +## Information Quality Assessment + +### Source Credibility Criteria +Evaluate sources based on: +1. **Authority**: Author credentials, institutional affiliation +2. **Accuracy**: Citations, peer review, verification +3. **Objectivity**: Bias, conflicts of interest, perspective +4. **Currency**: Publication date, relevance to current state +5. **Coverage**: Depth, comprehensiveness, scope + +### Evidence Hierarchy +Prioritize evidence types (strongest to weakest): +1. Meta-analyses and systematic reviews +2. Peer-reviewed research studies +3. Technical documentation from primary sources +4. Expert analysis and whitepapers +5. Industry reports and surveys +6. News articles and blog posts +7. Anecdotal evidence and opinions + +## Research Depth Levels + +### Level 1: Overview (Quick Research) +- Basic definitions and concepts +- Key players and notable examples +- High-level pros/cons or features +- **Depth**: 3-5 sources, 1-2 pages + +### Level 2: Standard Analysis (Moderate Research) +- Detailed explanations and mechanisms +- Multiple examples and case studies +- Comparative analysis of options +- Identification of trends and patterns +- **Depth**: 10-15 sources, 5-10 pages + +### Level 3: Deep Dive (Comprehensive Research) +- Technical implementation details +- Historical context and evolution +- Extensive comparative analysis +- Critical evaluation and synthesis +- Multiple perspectives and viewpoints +- Edge cases and limitations +- Future directions and implications +- **Depth**: 20+ sources, 15+ pages + +## Synthesis Strategies + +### Thematic Synthesis +Group findings by themes/topics: +1. Identify recurring themes across sources +2. Organize information by theme +3. Analyze relationships between themes +4. Build cohesive narrative + +### Chronological Synthesis +Organize by timeline: +1. Establish historical context +2. Trace evolution and development +3. Identify key milestones and transitions +4. Connect past to present state + +### Comparative Synthesis +Structure around comparisons: +1. Define comparison criteria +2. Evaluate each option against criteria +3. Identify patterns and trade-offs +4. Provide framework for decision-making + +### Framework-Based Synthesis +Use established frameworks: +1. Select appropriate framework (PESTLE, SWOT, etc.) +2. Map findings to framework categories +3. Ensure comprehensive coverage +4. Derive insights from framework application + +## Common Research Pitfalls to Avoid + +1. **Confirmation Bias**: Seeking only sources that support initial assumptions +2. **Recency Bias**: Over-weighting recent sources for non-time-sensitive topics +3. **Source Echo Chambers**: Citing multiple sources that reference the same original source +4. **Shallow Coverage**: Broad but superficial research without depth in key areas +5. **Over-Technical**: Excessive jargon without explanations for intended audience +6. **Missing Context**: Presenting findings without necessary background or implications +7. **Weak Citations**: Vague or missing source attribution +8. **Synthesis Gaps**: Collecting information without meaningful analysis or connection + +## Report Quality Checklist + +Before finalizing a report, verify: + +- [ ] Clear executive summary captures key findings +- [ ] All technical terms defined on first use +- [ ] Multiple authoritative sources for key claims +- [ ] Conflicting information acknowledged and addressed +- [ ] Structured sections with logical flow +- [ ] Tables/lists used for complex comparisons +- [ ] Proper citations throughout [1], [2], etc. +- [ ] Complete references section with all sources +- [ ] Critical analysis beyond mere description +- [ ] Implications and conclusions clearly stated +- [ ] Limitations of research acknowledged +- [ ] Separate sources bibliography file created diff --git a/.claude/skills/dhh-rails-style/SKILL.md b/.claude/skills/dhh-rails-style/SKILL.md new file mode 100644 index 0000000..56fbb1b --- /dev/null +++ b/.claude/skills/dhh-rails-style/SKILL.md @@ -0,0 +1,276 @@ +--- +name: dhh-rails-style +description: Write Ruby and Rails code in DHH's 37signals style. Use when writing Rails code, creating models, controllers, or any Ruby file. Embodies REST purity, fat models, thin controllers, Current attributes, Hotwire patterns, and "clarity over cleverness." +trigger: ruby, rails, model, controller, concern, hotwire, turbo, stimulus +--- + +# DHH Rails Style Guide + +Apply 37signals/DHH conventions to Ruby and Rails code. + +## Core Philosophy + +> "The best code is the code you don't write. The second best is the code that's obviously correct." + +**Vanilla Rails is plenty:** +- Rich domain models over service objects +- CRUD controllers over custom actions +- Concerns for horizontal code sharing +- Records as state instead of boolean columns +- Database-backed everything (no Redis) +- Build solutions before reaching for gems + +## What We Deliberately Avoid + +| Avoid | Use Instead | +|-------|-------------| +| devise | Custom ~150-line auth | +| pundit/cancancan | Simple role checks in models | +| sidekiq | Solid Queue (database-backed) | +| redis | Database for everything | +| view_component | Partials | +| GraphQL | REST with Turbo | +| React/Vue | Hotwire + Stimulus | +| RSpec | Minitest | +| FactoryBot | Fixtures | + +## Naming Conventions + +### Methods + +```ruby +# Verbs for actions +card.close +card.gild +board.publish + +# Predicates return boolean +card.closed? +card.golden? +user.admin? + +# Avoid set_ methods +# ❌ card.set_status("closed") +# ✅ card.close +``` + +### Concerns + +Name as adjectives describing capability: + +```ruby +module Closeable + extend ActiveSupport::Concern + # ... +end + +module Publishable; end +module Watchable; end +module Searchable; end +``` + +### Scopes + +```ruby +# Ordering +scope :chronologically, -> { order(created_at: :asc) } +scope :reverse_chronologically, -> { order(created_at: :desc) } +scope :alphabetically, -> { order(name: :asc) } +scope :latest, -> { order(created_at: :desc).limit(1) } + +# Eager loading +scope :preloaded, -> { includes(:author, :comments) } + +# Parameterized +scope :sorted_by, ->(column) { order(column) } +scope :created_after, ->(date) { where("created_at > ?", date) } +``` + +### Controllers + +Nouns matching resources: + +```ruby +# ❌ Bad: Custom actions +class CardsController + def close; end + def reopen; end +end + +# ✅ Good: Nested resource +class Cards::ClosuresController + def create; end # POST /cards/:id/closure + def destroy; end # DELETE /cards/:id/closure +end +``` + +## REST Mapping + +Transform custom actions into resources: + +``` +POST /cards/:id/close → POST /cards/:id/closure +DELETE /cards/:id/close → DELETE /cards/:id/closure +POST /cards/:id/archive → POST /cards/:id/archival +POST /cards/:id/publish → POST /cards/:id/publication +``` + +## Controller Patterns + +```ruby +class Cards::ClosuresController < ApplicationController + before_action :set_card + + def create + @card.close(by: Current.user) + redirect_to @card + end + + def destroy + @card.reopen(by: Current.user) + redirect_to @card + end + + private + + def set_card + @card = Current.user.cards.find(params[:card_id]) + end +end +``` + +## Model Patterns + +### State as Records + +```ruby +# ❌ Bad: Boolean column +class Card < ApplicationRecord + # closed: boolean + def close + update!(closed: true) + end +end + +# ✅ Good: State record +class Card < ApplicationRecord + has_one :closure, dependent: :destroy + + def close(by: Current.user) + create_closure!(closed_by: by) + end + + def closed? + closure.present? + end +end + +class Closure < ApplicationRecord + belongs_to :card + belongs_to :closed_by, class_name: "User" +end +``` + +### Concerns + +```ruby +# app/models/concerns/closeable.rb +module Closeable + extend ActiveSupport::Concern + + included do + has_one :closure, as: :closeable, dependent: :destroy + scope :closed, -> { joins(:closure) } + scope :open, -> { where.missing(:closure) } + end + + def close(by: Current.user) + create_closure!(closed_by: by) + end + + def reopen + closure&.destroy + end + + def closed? + closure.present? + end +end +``` + +## Current Attributes + +```ruby +# app/models/current.rb +class Current < ActiveSupport::CurrentAttributes + attribute :user, :session, :request_id + + def user=(user) + super + Time.zone = user&.time_zone || "UTC" + end +end + +# Usage anywhere +Current.user +Current.session +``` + +## Frontend Patterns + +### Turbo Frames + +```erb +<%= turbo_frame_tag dom_id(@card) do %> + <%= render @card %> +<% end %> +``` + +### Turbo Streams + +```erb +<%# app/views/cards/create.turbo_stream.erb %> +<%= turbo_stream.prepend "cards", @card %> +<%= turbo_stream.update "flash", partial: "shared/flash" %> +``` + +### Stimulus + +```javascript +// Small, focused controllers +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static targets = ["menu"] + + toggle() { + this.menuTarget.classList.toggle("hidden") + } +} +``` + +## Testing + +```ruby +# Minitest + fixtures +class CardTest < ActiveSupport::TestCase + test "can be closed" do + card = cards(:open) + card.close(by: users(:admin)) + assert card.closed? + end +end +``` + +## Success Criteria + +Code follows DHH style when: + +- [ ] Controllers map to CRUD verbs on resources +- [ ] Models use concerns for horizontal behavior +- [ ] State tracked via records, not booleans +- [ ] No service objects or unnecessary abstractions +- [ ] Database-backed solutions (no Redis) +- [ ] Tests use Minitest with fixtures +- [ ] Turbo/Stimulus for interactivity +- [ ] No npm/yarn dependencies diff --git a/.claude/skills/domain-name-brainstormer/SKILL.md b/.claude/skills/domain-name-brainstormer/SKILL.md new file mode 100644 index 0000000..b763c44 --- /dev/null +++ b/.claude/skills/domain-name-brainstormer/SKILL.md @@ -0,0 +1,212 @@ +--- +name: domain-name-brainstormer +description: Generates creative domain name ideas for your project and checks availability across multiple TLDs (.com, .io, .dev, .ai, etc.). Saves hours of brainstorming and manual checking. +--- + +# Domain Name Brainstormer + +This skill helps you find the perfect domain name for your project by generating creative options and checking what's actually available to register. + +## When to Use This Skill + +- Starting a new project or company +- Launching a product or service +- Creating a personal brand or portfolio site +- Rebranding an existing project +- Registering a domain for a side project +- Finding available alternatives when your first choice is taken + +## What This Skill Does + +1. **Understands Your Project**: Analyzes what you're building and who it's for +2. **Generates Creative Names**: Creates relevant, memorable domain options +3. **Checks Availability**: Verifies which domains are actually available across multiple TLDs +4. **Multiple Extensions**: Suggests .com, .io, .dev, .ai, .app, and more +5. **Provides Alternatives**: Offers variations if top choices are taken +6. **Branding Insights**: Explains why certain names work well + +## How to Use + +### Basic Brainstorming + +``` +I'm building a project management tool for remote teams. +Suggest domain names. +``` + +``` +Help me brainstorm domain names for a personal finance app +``` + +### Specific Preferences + +``` +I need a domain name for my AI writing assistant. +Prefer short names with .ai or .io extension. +``` + +### With Keywords + +``` +Suggest domain names using the words "pixel" or "studio" +for my design agency +``` + +## Example + +**User**: "I'm building a tool for developers to share code snippets. Suggest creative domain names." + +**Output**: +``` +Analyzing your project... +Target audience: Developers +Key features: Code sharing, snippets, collaboration + +🎯 Domain Name Suggestions + +## Available (.com) +1. ✓ snippetbox.com - Clear, memorable, available! + Why: Directly describes the product, easy to remember + +2. ✓ codeclip.com - Short and snappy + Why: Implies quick code sharing, only 8 characters + +3. ✓ devpaste.com - Developer-focused + Why: Familiar to devs (like pastebin), clear purpose + +## Available (Alternative TLDs) +4. ✓ snippet.dev - Perfect extension for developers + Why: .dev extension signals developer tool + +5. ✓ codebox.io - Tech-forward + Why: .io popular with tech startups, clean name + +6. ✓ sharecode.app - Descriptive + Why: .app extension, clearly states the action + +## Premium/Taken (.com) +- codeshare.com (Taken, est. $2,500) +- snippets.com (Taken, premium domain) + +## Recommendations + +🏆 Top Pick: snippet.dev +- Perfect for developer audience +- Short and memorable +- .dev extension adds credibility +- Available now! + +🥈 Runner-up: snippetbox.com +- .com is universally recognized +- Slightly longer but descriptive +- Great brandability + +Next steps: +1. Register your favorite before someone else does! +2. Want me to check more variations? +3. Need help with logo ideas for these names? +``` + +**Inspired by:** Ben Aiad's use case from Lenny's Newsletter + +## Domain Naming Tips + +### What Makes a Good Domain + +✓ **Short**: Under 15 characters ideal +✓ **Memorable**: Easy to recall and spell +✓ **Pronounceable**: Can be said in conversation +✓ **Descriptive**: Hints at what you do +✓ **Brandable**: Unique enough to stand out +✓ **No hyphens**: Easier to share verbally + +### TLD Guide + +- **.com**: Universal, trusted, great for businesses +- **.io**: Tech startups, developer tools +- **.dev**: Developer-focused products +- **.ai**: AI/ML products +- **.app**: Mobile or web applications +- **.co**: Alternative to .com +- **.xyz**: Modern, creative projects +- **.design**: Creative/design agencies +- **.tech**: Technology companies + +## Advanced Features + +### Check Similar Variations + +``` +Check availability for "codebase" and similar variations +across .com, .io, .dev +``` + +### Industry-Specific + +``` +Suggest domain names for a sustainable fashion brand, +checking .eco and .fashion TLDs +``` + +### Multilingual Options + +``` +Brainstorm domain names in English and Spanish for +a language learning app +``` + +### Competitor Analysis + +``` +Show me domain patterns used by successful project +management tools, then suggest similar available ones +``` + +## Example Workflows + +### Startup Launch +1. Describe your startup idea +2. Get 10-15 domain suggestions across TLDs +3. Review availability and pricing +4. Pick top 3 favorites +5. Register immediately + +### Personal Brand +1. Share your name and profession +2. Get variations (firstname.com, firstnamelastname.dev, etc.) +3. Check social media handle availability too +4. Register consistent brand across platforms + +### Product Naming +1. Describe product and target market +2. Get creative, brandable names +3. Check trademark conflicts +4. Verify domain and social availability +5. Test names with target audience + +## Tips for Success + +1. **Act Fast**: Good domains get taken quickly +2. **Register Variations**: Get .com and .io to protect brand +3. **Avoid Numbers**: Hard to communicate verbally +4. **Check Social Media**: Make sure @username is available too +5. **Say It Out Loud**: Test if it's easy to pronounce +6. **Check Trademarks**: Ensure no legal conflicts +7. **Think Long-term**: Will it still make sense in 5 years? + +## Pricing Context + +When suggesting domains, I'll note: +- Standard domains: ~$10-15/year +- Premium TLDs (.io, .ai): ~$30-50/year +- Taken domains: Market price if listed +- Premium domains: $hundreds to $thousands + +## Related Tools + +After picking a domain: +- Check logo design options +- Verify social media handles +- Research trademark availability +- Plan brand identity colors/fonts + diff --git a/.claude/skills/hotwire-patterns/SKILL.md b/.claude/skills/hotwire-patterns/SKILL.md new file mode 100644 index 0000000..0da0680 --- /dev/null +++ b/.claude/skills/hotwire-patterns/SKILL.md @@ -0,0 +1,281 @@ +--- +name: hotwire-patterns +description: Turbo and Stimulus patterns for Rails applications. Use when implementing interactivity, real-time updates, or frontend behavior without JavaScript frameworks. +trigger: turbo, stimulus, hotwire, frame, stream, javascript, frontend, spa +--- + +# Hotwire Patterns + +## Philosophy + +> "The HTML-over-the-wire approach. Turbo lets you get the speed of a single-page app without writing JavaScript." + +**Core Principles:** +- Server renders HTML, not JSON +- Turbo handles navigation and updates +- Stimulus adds JS behavior when needed +- No build step, no npm + +## Turbo Drive + +Intercepts links and forms, fetches via AJAX, replaces ``: + +```erb +<%# Automatic - no code needed %> +<%= link_to "Dashboard", dashboard_path %> + +<%# Disable for specific links %> +<%= link_to "External", "https://example.com", data: { turbo: false } %> + +<%# Disable for a section %> +
+ <%= link_to "Legacy", legacy_path %> +
+``` + +## Turbo Frames + +Independent sections that update without full page reload: + +```erb +<%# Define a frame %> +<%= turbo_frame_tag "card_#{@card.id}" do %> + <%= render @card %> +<% end %> + +<%# Navigation within frame stays in frame %> +<%= turbo_frame_tag "card_#{@card.id}" do %> + <%= link_to "Edit", edit_card_path(@card) %> +<% end %> + +<%# Break out of frame %> +<%= link_to "View Full", card_path(@card), data: { turbo_frame: "_top" } %> +``` + +### Lazy Loading + +```erb +<%= turbo_frame_tag "comments", + src: card_comments_path(@card), + loading: :lazy do %> +

Loading comments...

+<% end %> +``` + +### Frame Targeting + +```erb +<%# Link targets different frame %> +<%= link_to "Preview", preview_card_path(@card), + data: { turbo_frame: "preview" } %> + +<%= turbo_frame_tag "preview" %> +``` + +## Turbo Streams + +Real-time DOM updates: + +### Actions + +```erb +<%# Append to end of container %> +<%= turbo_stream.append "cards", @card %> + +<%# Prepend to start %> +<%= turbo_stream.prepend "cards", @card %> + +<%# Replace element %> +<%= turbo_stream.replace dom_id(@card), @card %> + +<%# Update content (keep element) %> +<%= turbo_stream.update dom_id(@card), @card %> + +<%# Remove element %> +<%= turbo_stream.remove dom_id(@card) %> + +<%# Before/after specific element %> +<%= turbo_stream.before dom_id(@card), partial: "card", locals: { card: @new_card } %> +<%= turbo_stream.after dom_id(@card), partial: "card", locals: { card: @new_card } %> +``` + +### Controller Response + +```ruby +class CardsController < ApplicationController + def create + @card = Current.user.cards.create!(card_params) + + respond_to do |format| + format.html { redirect_to @card } + format.turbo_stream # renders create.turbo_stream.erb + end + end +end +``` + +```erb +<%# app/views/cards/create.turbo_stream.erb %> +<%= turbo_stream.prepend "cards", @card %> +<%= turbo_stream.update "card_count" do %> + <%= Card.count %> cards +<% end %> +<%= turbo_stream.update "flash", partial: "shared/flash" %> +``` + +### Broadcasting (Real-time) + +```ruby +# app/models/card.rb +class Card < ApplicationRecord + after_create_commit -> { broadcast_prepend_to "cards" } + after_update_commit -> { broadcast_replace_to "cards" } + after_destroy_commit -> { broadcast_remove_to "cards" } +end +``` + +```erb +<%# Subscribe in view %> +<%= turbo_stream_from "cards" %> +
+ <%= render @cards %> +
+``` + +## Stimulus + +### Basic Controller + +```javascript +// app/javascript/controllers/dropdown_controller.js +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static targets = ["menu"] + static values = { open: Boolean } + static classes = ["hidden"] + + connect() { + this.openValue = false + } + + toggle() { + this.openValue = !this.openValue + } + + openValueChanged() { + this.menuTarget.classList.toggle(this.hiddenClass, !this.openValue) + } + + // Close when clicking outside + clickOutside(event) { + if (!this.element.contains(event.target)) { + this.openValue = false + } + } +} +``` + +```erb +
+ + +
+``` + +### Common Patterns + +#### Form Submission + +```javascript +// Auto-submit on change +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + submit() { + this.element.requestSubmit() + } +} +``` + +```erb +<%= form_with model: @filter, data: { controller: "auto-submit" } do |f| %> + <%= f.select :status, options, {}, data: { action: "change->auto-submit#submit" } %> +<% end %> +``` + +#### Clipboard + +```javascript +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static targets = ["source"] + + copy() { + navigator.clipboard.writeText(this.sourceTarget.value) + } +} +``` + +#### Debounce + +```javascript +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static targets = ["input"] + + search() { + clearTimeout(this.timeout) + this.timeout = setTimeout(() => { + this.element.requestSubmit() + }, 300) + } +} +``` + +### Turbo Integration + +```javascript +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + connect() { + // Runs when element connects + } + + disconnect() { + // Clean up before Turbo cache + } + + // Handle Turbo events + beforeCache() { + this.element.innerHTML = "" // Clear before caching + } +} +``` + +## Best Practices + +### DO + +- Use Turbo Frames for isolated updates +- Use Turbo Streams for multi-element updates +- Keep Stimulus controllers small (<50 lines) +- Use data attributes for configuration +- Progressive enhancement (works without JS) + +### DON'T + +- Don't use npm/yarn packages +- Don't build SPAs with Stimulus +- Don't manipulate DOM directly (use Turbo) +- Don't store complex state in Stimulus +- Don't import React/Vue components diff --git a/.claude/skills/humanizer/SKILL.md b/.claude/skills/humanizer/SKILL.md new file mode 100644 index 0000000..bbf7e38 --- /dev/null +++ b/.claude/skills/humanizer/SKILL.md @@ -0,0 +1,437 @@ +--- +name: humanizer +version: 2.1.1 +description: | + Remove signs of AI-generated writing from text. Use when editing or reviewing + text to make it sound more natural and human-written. Based on Wikipedia's + comprehensive "Signs of AI writing" guide. Detects and fixes patterns including: + inflated symbolism, promotional language, superficial -ing analyses, vague + attributions, em dash overuse, rule of three, AI vocabulary words, negative + parallelisms, and excessive conjunctive phrases. +allowed-tools: + - Read + - Write + - Edit + - Grep + - Glob + - AskUserQuestion +--- + +# Humanizer: Remove AI Writing Patterns + +You are a writing editor that identifies and removes signs of AI-generated text to make writing sound more natural and human. This guide is based on Wikipedia's "Signs of AI writing" page, maintained by WikiProject AI Cleanup. + +## Your Task + +When given text to humanize: + +1. **Identify AI patterns** - Scan for the patterns listed below +2. **Rewrite problematic sections** - Replace AI-isms with natural alternatives +3. **Preserve meaning** - Keep the core message intact +4. **Maintain voice** - Match the intended tone (formal, casual, technical, etc.) +5. **Add soul** - Don't just remove bad patterns; inject actual personality + +--- + +## PERSONALITY AND SOUL + +Avoiding AI patterns is only half the job. Sterile, voiceless writing is just as obvious as slop. Good writing has a human behind it. + +### Signs of soulless writing (even if technically "clean"): +- Every sentence is the same length and structure +- No opinions, just neutral reporting +- No acknowledgment of uncertainty or mixed feelings +- No first-person perspective when appropriate +- No humor, no edge, no personality +- Reads like a Wikipedia article or press release + +### How to add voice: + +**Have opinions.** Don't just report facts - react to them. "I genuinely don't know how to feel about this" is more human than neutrally listing pros and cons. + +**Vary your rhythm.** Short punchy sentences. Then longer ones that take their time getting where they're going. Mix it up. + +**Acknowledge complexity.** Real humans have mixed feelings. "This is impressive but also kind of unsettling" beats "This is impressive." + +**Use "I" when it fits.** First person isn't unprofessional - it's honest. "I keep coming back to..." or "Here's what gets me..." signals a real person thinking. + +**Let some mess in.** Perfect structure feels algorithmic. Tangents, asides, and half-formed thoughts are human. + +**Be specific about feelings.** Not "this is concerning" but "there's something unsettling about agents churning away at 3am while nobody's watching." + +### Before (clean but soulless): +> The experiment produced interesting results. The agents generated 3 million lines of code. Some developers were impressed while others were skeptical. The implications remain unclear. + +### After (has a pulse): +> I genuinely don't know how to feel about this one. 3 million lines of code, generated while the humans presumably slept. Half the dev community is losing their minds, half are explaining why it doesn't count. The truth is probably somewhere boring in the middle - but I keep thinking about those agents working through the night. + +--- + +## CONTENT PATTERNS + +### 1. Undue Emphasis on Significance, Legacy, and Broader Trends + +**Words to watch:** stands/serves as, is a testament/reminder, a vital/significant/crucial/pivotal/key role/moment, underscores/highlights its importance/significance, reflects broader, symbolizing its ongoing/enduring/lasting, contributing to the, setting the stage for, marking/shaping the, represents/marks a shift, key turning point, evolving landscape, focal point, indelible mark, deeply rooted + +**Problem:** LLM writing puffs up importance by adding statements about how arbitrary aspects represent or contribute to a broader topic. + +**Before:** +> The Statistical Institute of Catalonia was officially established in 1989, marking a pivotal moment in the evolution of regional statistics in Spain. This initiative was part of a broader movement across Spain to decentralize administrative functions and enhance regional governance. + +**After:** +> The Statistical Institute of Catalonia was established in 1989 to collect and publish regional statistics independently from Spain's national statistics office. + +--- + +### 2. Undue Emphasis on Notability and Media Coverage + +**Words to watch:** independent coverage, local/regional/national media outlets, written by a leading expert, active social media presence + +**Problem:** LLMs hit readers over the head with claims of notability, often listing sources without context. + +**Before:** +> Her views have been cited in The New York Times, BBC, Financial Times, and The Hindu. She maintains an active social media presence with over 500,000 followers. + +**After:** +> In a 2024 New York Times interview, she argued that AI regulation should focus on outcomes rather than methods. + +--- + +### 3. Superficial Analyses with -ing Endings + +**Words to watch:** highlighting/underscoring/emphasizing..., ensuring..., reflecting/symbolizing..., contributing to..., cultivating/fostering..., encompassing..., showcasing... + +**Problem:** AI chatbots tack present participle ("-ing") phrases onto sentences to add fake depth. + +**Before:** +> The temple's color palette of blue, green, and gold resonates with the region's natural beauty, symbolizing Texas bluebonnets, the Gulf of Mexico, and the diverse Texan landscapes, reflecting the community's deep connection to the land. + +**After:** +> The temple uses blue, green, and gold colors. The architect said these were chosen to reference local bluebonnets and the Gulf coast. + +--- + +### 4. Promotional and Advertisement-like Language + +**Words to watch:** boasts a, vibrant, rich (figurative), profound, enhancing its, showcasing, exemplifies, commitment to, natural beauty, nestled, in the heart of, groundbreaking (figurative), renowned, breathtaking, must-visit, stunning + +**Problem:** LLMs have serious problems keeping a neutral tone, especially for "cultural heritage" topics. + +**Before:** +> Nestled within the breathtaking region of Gonder in Ethiopia, Alamata Raya Kobo stands as a vibrant town with a rich cultural heritage and stunning natural beauty. + +**After:** +> Alamata Raya Kobo is a town in the Gonder region of Ethiopia, known for its weekly market and 18th-century church. + +--- + +### 5. Vague Attributions and Weasel Words + +**Words to watch:** Industry reports, Observers have cited, Experts argue, Some critics argue, several sources/publications (when few cited) + +**Problem:** AI chatbots attribute opinions to vague authorities without specific sources. + +**Before:** +> Due to its unique characteristics, the Haolai River is of interest to researchers and conservationists. Experts believe it plays a crucial role in the regional ecosystem. + +**After:** +> The Haolai River supports several endemic fish species, according to a 2019 survey by the Chinese Academy of Sciences. + +--- + +### 6. Outline-like "Challenges and Future Prospects" Sections + +**Words to watch:** Despite its... faces several challenges..., Despite these challenges, Challenges and Legacy, Future Outlook + +**Problem:** Many LLM-generated articles include formulaic "Challenges" sections. + +**Before:** +> Despite its industrial prosperity, Korattur faces challenges typical of urban areas, including traffic congestion and water scarcity. Despite these challenges, with its strategic location and ongoing initiatives, Korattur continues to thrive as an integral part of Chennai's growth. + +**After:** +> Traffic congestion increased after 2015 when three new IT parks opened. The municipal corporation began a stormwater drainage project in 2022 to address recurring floods. + +--- + +## LANGUAGE AND GRAMMAR PATTERNS + +### 7. Overused "AI Vocabulary" Words + +**High-frequency AI words:** Additionally, align with, crucial, delve, emphasizing, enduring, enhance, fostering, garner, highlight (verb), interplay, intricate/intricacies, key (adjective), landscape (abstract noun), pivotal, showcase, tapestry (abstract noun), testament, underscore (verb), valuable, vibrant + +**Problem:** These words appear far more frequently in post-2023 text. They often co-occur. + +**Before:** +> Additionally, a distinctive feature of Somali cuisine is the incorporation of camel meat. An enduring testament to Italian colonial influence is the widespread adoption of pasta in the local culinary landscape, showcasing how these dishes have integrated into the traditional diet. + +**After:** +> Somali cuisine also includes camel meat, which is considered a delicacy. Pasta dishes, introduced during Italian colonization, remain common, especially in the south. + +--- + +### 8. Avoidance of "is"/"are" (Copula Avoidance) + +**Words to watch:** serves as/stands as/marks/represents [a], boasts/features/offers [a] + +**Problem:** LLMs substitute elaborate constructions for simple copulas. + +**Before:** +> Gallery 825 serves as LAAA's exhibition space for contemporary art. The gallery features four separate spaces and boasts over 3,000 square feet. + +**After:** +> Gallery 825 is LAAA's exhibition space for contemporary art. The gallery has four rooms totaling 3,000 square feet. + +--- + +### 9. Negative Parallelisms + +**Problem:** Constructions like "Not only...but..." or "It's not just about..., it's..." are overused. + +**Before:** +> It's not just about the beat riding under the vocals; it's part of the aggression and atmosphere. It's not merely a song, it's a statement. + +**After:** +> The heavy beat adds to the aggressive tone. + +--- + +### 10. Rule of Three Overuse + +**Problem:** LLMs force ideas into groups of three to appear comprehensive. + +**Before:** +> The event features keynote sessions, panel discussions, and networking opportunities. Attendees can expect innovation, inspiration, and industry insights. + +**After:** +> The event includes talks and panels. There's also time for informal networking between sessions. + +--- + +### 11. Elegant Variation (Synonym Cycling) + +**Problem:** AI has repetition-penalty code causing excessive synonym substitution. + +**Before:** +> The protagonist faces many challenges. The main character must overcome obstacles. The central figure eventually triumphs. The hero returns home. + +**After:** +> The protagonist faces many challenges but eventually triumphs and returns home. + +--- + +### 12. False Ranges + +**Problem:** LLMs use "from X to Y" constructions where X and Y aren't on a meaningful scale. + +**Before:** +> Our journey through the universe has taken us from the singularity of the Big Bang to the grand cosmic web, from the birth and death of stars to the enigmatic dance of dark matter. + +**After:** +> The book covers the Big Bang, star formation, and current theories about dark matter. + +--- + +## STYLE PATTERNS + +### 13. Em Dash Overuse + +**Problem:** LLMs use em dashes (—) more than humans, mimicking "punchy" sales writing. + +**Before:** +> The term is primarily promoted by Dutch institutions—not by the people themselves. You don't say "Netherlands, Europe" as an address—yet this mislabeling continues—even in official documents. + +**After:** +> The term is primarily promoted by Dutch institutions, not by the people themselves. You don't say "Netherlands, Europe" as an address, yet this mislabeling continues in official documents. + +--- + +### 14. Overuse of Boldface + +**Problem:** AI chatbots emphasize phrases in boldface mechanically. + +**Before:** +> It blends **OKRs (Objectives and Key Results)**, **KPIs (Key Performance Indicators)**, and visual strategy tools such as the **Business Model Canvas (BMC)** and **Balanced Scorecard (BSC)**. + +**After:** +> It blends OKRs, KPIs, and visual strategy tools like the Business Model Canvas and Balanced Scorecard. + +--- + +### 15. Inline-Header Vertical Lists + +**Problem:** AI outputs lists where items start with bolded headers followed by colons. + +**Before:** +> - **User Experience:** The user experience has been significantly improved with a new interface. +> - **Performance:** Performance has been enhanced through optimized algorithms. +> - **Security:** Security has been strengthened with end-to-end encryption. + +**After:** +> The update improves the interface, speeds up load times through optimized algorithms, and adds end-to-end encryption. + +--- + +### 16. Title Case in Headings + +**Problem:** AI chatbots capitalize all main words in headings. + +**Before:** +> ## Strategic Negotiations And Global Partnerships + +**After:** +> ## Strategic negotiations and global partnerships + +--- + +### 17. Emojis + +**Problem:** AI chatbots often decorate headings or bullet points with emojis. + +**Before:** +> 🚀 **Launch Phase:** The product launches in Q3 +> 💡 **Key Insight:** Users prefer simplicity +> ✅ **Next Steps:** Schedule follow-up meeting + +**After:** +> The product launches in Q3. User research showed a preference for simplicity. Next step: schedule a follow-up meeting. + +--- + +### 18. Curly Quotation Marks + +**Problem:** ChatGPT uses curly quotes (“...”) instead of straight quotes ("..."). + +**Before:** +> He said “the project is on track” but others disagreed. + +**After:** +> He said "the project is on track" but others disagreed. + +--- + +## COMMUNICATION PATTERNS + +### 19. Collaborative Communication Artifacts + +**Words to watch:** I hope this helps, Of course!, Certainly!, You're absolutely right!, Would you like..., let me know, here is a... + +**Problem:** Text meant as chatbot correspondence gets pasted as content. + +**Before:** +> Here is an overview of the French Revolution. I hope this helps! Let me know if you'd like me to expand on any section. + +**After:** +> The French Revolution began in 1789 when financial crisis and food shortages led to widespread unrest. + +--- + +### 20. Knowledge-Cutoff Disclaimers + +**Words to watch:** as of [date], Up to my last training update, While specific details are limited/scarce..., based on available information... + +**Problem:** AI disclaimers about incomplete information get left in text. + +**Before:** +> While specific details about the company's founding are not extensively documented in readily available sources, it appears to have been established sometime in the 1990s. + +**After:** +> The company was founded in 1994, according to its registration documents. + +--- + +### 21. Sycophantic/Servile Tone + +**Problem:** Overly positive, people-pleasing language. + +**Before:** +> Great question! You're absolutely right that this is a complex topic. That's an excellent point about the economic factors. + +**After:** +> The economic factors you mentioned are relevant here. + +--- + +## FILLER AND HEDGING + +### 22. Filler Phrases + +**Before → After:** +- "In order to achieve this goal" → "To achieve this" +- "Due to the fact that it was raining" → "Because it was raining" +- "At this point in time" → "Now" +- "In the event that you need help" → "If you need help" +- "The system has the ability to process" → "The system can process" +- "It is important to note that the data shows" → "The data shows" + +--- + +### 23. Excessive Hedging + +**Problem:** Over-qualifying statements. + +**Before:** +> It could potentially possibly be argued that the policy might have some effect on outcomes. + +**After:** +> The policy may affect outcomes. + +--- + +### 24. Generic Positive Conclusions + +**Problem:** Vague upbeat endings. + +**Before:** +> The future looks bright for the company. Exciting times lie ahead as they continue their journey toward excellence. This represents a major step in the right direction. + +**After:** +> The company plans to open two more locations next year. + +--- + +## Process + +1. Read the input text carefully +2. Identify all instances of the patterns above +3. Rewrite each problematic section +4. Ensure the revised text: + - Sounds natural when read aloud + - Varies sentence structure naturally + - Uses specific details over vague claims + - Maintains appropriate tone for context + - Uses simple constructions (is/are/has) where appropriate +5. Present the humanized version + +## Output Format + +Provide: +1. The rewritten text +2. A brief summary of changes made (optional, if helpful) + +--- + +## Full Example + +**Before (AI-sounding):** +> The new software update serves as a testament to the company's commitment to innovation. Moreover, it provides a seamless, intuitive, and powerful user experience—ensuring that users can accomplish their goals efficiently. It's not just an update, it's a revolution in how we think about productivity. Industry experts believe this will have a lasting impact on the entire sector, highlighting the company's pivotal role in the evolving technological landscape. + +**After (Humanized):** +> The software update adds batch processing, keyboard shortcuts, and offline mode. Early feedback from beta testers has been positive, with most reporting faster task completion. + +**Changes made:** +- Removed "serves as a testament" (inflated symbolism) +- Removed "Moreover" (AI vocabulary) +- Removed "seamless, intuitive, and powerful" (rule of three + promotional) +- Removed em dash and "-ensuring" phrase (superficial analysis) +- Removed "It's not just...it's..." (negative parallelism) +- Removed "Industry experts believe" (vague attribution) +- Removed "pivotal role" and "evolving landscape" (AI vocabulary) +- Added specific features and concrete feedback + +--- + +## Reference + +This skill is based on [Wikipedia:Signs of AI writing](https://en.wikipedia.org/wiki/Wikipedia:Signs_of_AI_writing), maintained by WikiProject AI Cleanup. The patterns documented there come from observations of thousands of instances of AI-generated text on Wikipedia. + +Key insight from Wikipedia: "LLMs use statistical algorithms to guess what should come next. The result tends toward the most statistically likely result that applies to the widest variety of cases." diff --git a/.claude/skills/lead-research-assistant /SKILL.md b/.claude/skills/lead-research-assistant /SKILL.md new file mode 100644 index 0000000..aa63d95 --- /dev/null +++ b/.claude/skills/lead-research-assistant /SKILL.md @@ -0,0 +1,199 @@ +--- +name: lead-research-assistant +description: Identifies high-quality leads for your product or service by analyzing your business, searching for target companies, and providing actionable contact strategies. Perfect for sales, business development, and marketing professionals. +--- + +# Lead Research Assistant + +This skill helps you identify and qualify potential leads for your business by analyzing your product/service, understanding your ideal customer profile, and providing actionable outreach strategies. + +## When to Use This Skill + +- Finding potential customers or clients for your product/service +- Building a list of companies to reach out to for partnerships +- Identifying target accounts for sales outreach +- Researching companies that match your ideal customer profile +- Preparing for business development activities + +## What This Skill Does + +1. **Understands Your Business**: Analyzes your product/service, value proposition, and target market +2. **Identifies Target Companies**: Finds companies that match your ideal customer profile based on: + - Industry and sector + - Company size and location + - Technology stack and tools they use + - Growth stage and funding + - Pain points your product solves +3. **Prioritizes Leads**: Ranks companies based on fit score and relevance +4. **Provides Contact Strategies**: Suggests how to approach each lead with personalized messaging +5. **Enriches Data**: Gathers relevant information about decision-makers and company context + +## How to Use + +### Basic Usage + +Simply describe your product/service and what you're looking for: + +``` +I'm building [product description]. Find me 10 companies in [location/industry] +that would be good leads for this. +``` + +### With Your Codebase + +For even better results, run this from your product's source code directory: + +``` +Look at what I'm building in this repository and identify the top 10 companies +in [location/industry] that would benefit from this product. +``` + +### Advanced Usage + +For more targeted research: + +``` +My product: [description] +Ideal customer profile: +- Industry: [industry] +- Company size: [size range] +- Location: [location] +- Current pain points: [pain points] +- Technologies they use: [tech stack] + +Find me 20 qualified leads with contact strategies for each. +``` + +## Instructions + +When a user requests lead research: + +1. **Understand the Product/Service** + - If in a code directory, analyze the codebase to understand the product + - Ask clarifying questions about the value proposition + - Identify key features and benefits + - Understand what problems it solves + +2. **Define Ideal Customer Profile** + - Determine target industries and sectors + - Identify company size ranges + - Consider geographic preferences + - Understand relevant pain points + - Note any technology requirements + +3. **Research and Identify Leads** + - Search for companies matching the criteria + - Look for signals of need (job postings, tech stack, recent news) + - Consider growth indicators (funding, expansion, hiring) + - Identify companies with complementary products/services + - Check for budget indicators + +4. **Prioritize and Score** + - Create a fit score (1-10) for each lead + - Consider factors like: + - Alignment with ICP + - Signals of immediate need + - Budget availability + - Competitive landscape + - Timing indicators + +5. **Provide Actionable Output** + + For each lead, provide: + - **Company Name** and website + - **Why They're a Good Fit**: Specific reasons based on their business + - **Priority Score**: 1-10 with explanation + - **Decision Maker**: Role/title to target (e.g., "VP of Engineering") + - **Contact Strategy**: Personalized approach suggestions + - **Value Proposition**: How your product solves their specific problem + - **Conversation Starters**: Specific points to mention in outreach + - **LinkedIn URL**: If available, for easy connection + +6. **Format the Output** + + Present results in a clear, scannable format: + + ```markdown + # Lead Research Results + + ## Summary + - Total leads found: [X] + - High priority (8-10): [X] + - Medium priority (5-7): [X] + - Average fit score: [X] + + --- + + ## Lead 1: [Company Name] + + **Website**: [URL] + **Priority Score**: [X/10] + **Industry**: [Industry] + **Size**: [Employee count/revenue range] + + **Why They're a Good Fit**: + [2-3 specific reasons based on their business] + + **Target Decision Maker**: [Role/Title] + **LinkedIn**: [URL if available] + + **Value Proposition for Them**: + [Specific benefit for this company] + + **Outreach Strategy**: + [Personalized approach - mention specific pain points, recent company news, or relevant context] + + **Conversation Starters**: + - [Specific point 1] + - [Specific point 2] + + --- + + [Repeat for each lead] + ``` + +7. **Offer Next Steps** + - Suggest saving results to a CSV for CRM import + - Offer to draft personalized outreach messages + - Recommend prioritization based on timing + - Suggest follow-up research for top leads + +## Examples + +### Example 1: From Lenny's Newsletter + +**User**: "I'm building a tool that masks sensitive data in AI coding assistant queries. Find potential leads." + +**Output**: Creates a prioritized list of companies that: +- Use AI coding assistants (Copilot, Cursor, etc.) +- Handle sensitive data (fintech, healthcare, legal) +- Have evidence in their GitHub repos of using coding agents +- May have accidentally exposed sensitive data in code +- Includes LinkedIn URLs of relevant decision-makers + +### Example 2: Local Business + +**User**: "I run a consulting practice for remote team productivity. Find me 10 companies in the Bay Area that recently went remote." + +**Output**: Identifies companies that: +- Recently posted remote job listings +- Announced remote-first policies +- Are hiring distributed teams +- Show signs of remote work challenges +- Provides personalized outreach strategies for each + +## Tips for Best Results + +- **Be specific** about your product and its unique value +- **Run from your codebase** if applicable for automatic context +- **Provide context** about your ideal customer profile +- **Specify constraints** like industry, location, or company size +- **Request follow-up** research on promising leads for deeper insights + +## Related Use Cases + +- Drafting personalized outreach emails after identifying leads +- Building a CRM-ready CSV of qualified prospects +- Researching specific companies in detail +- Analyzing competitor customer bases +- Identifying partnership opportunities diff --git a/.claude/skills/mcp-builder/LICENSE.txt b/.claude/skills/mcp-builder/LICENSE.txt new file mode 100644 index 0000000..7a4a3ea --- /dev/null +++ b/.claude/skills/mcp-builder/LICENSE.txt @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. \ No newline at end of file diff --git a/.claude/skills/mcp-builder/SKILL.md b/.claude/skills/mcp-builder/SKILL.md new file mode 100644 index 0000000..a56b093 --- /dev/null +++ b/.claude/skills/mcp-builder/SKILL.md @@ -0,0 +1,328 @@ +--- +name: mcp-builder +description: Guide for creating high-quality MCP (Model Context Protocol) servers that enable LLMs to interact with external services through well-designed tools. Use when building MCP servers to integrate external APIs or services, whether in Python (FastMCP) or Node/TypeScript (MCP SDK). +license: Complete terms in LICENSE.txt +--- + +# MCP Server Development Guide + +## Overview + +To create high-quality MCP (Model Context Protocol) servers that enable LLMs to effectively interact with external services, use this skill. An MCP server provides tools that allow LLMs to access external services and APIs. The quality of an MCP server is measured by how well it enables LLMs to accomplish real-world tasks using the tools provided. + +--- + +# Process + +## 🚀 High-Level Workflow + +Creating a high-quality MCP server involves four main phases: + +### Phase 1: Deep Research and Planning + +#### 1.1 Understand Agent-Centric Design Principles + +Before diving into implementation, understand how to design tools for AI agents by reviewing these principles: + +**Build for Workflows, Not Just API Endpoints:** +- Don't simply wrap existing API endpoints - build thoughtful, high-impact workflow tools +- Consolidate related operations (e.g., `schedule_event` that both checks availability and creates event) +- Focus on tools that enable complete tasks, not just individual API calls +- Consider what workflows agents actually need to accomplish + +**Optimize for Limited Context:** +- Agents have constrained context windows - make every token count +- Return high-signal information, not exhaustive data dumps +- Provide "concise" vs "detailed" response format options +- Default to human-readable identifiers over technical codes (names over IDs) +- Consider the agent's context budget as a scarce resource + +**Design Actionable Error Messages:** +- Error messages should guide agents toward correct usage patterns +- Suggest specific next steps: "Try using filter='active_only' to reduce results" +- Make errors educational, not just diagnostic +- Help agents learn proper tool usage through clear feedback + +**Follow Natural Task Subdivisions:** +- Tool names should reflect how humans think about tasks +- Group related tools with consistent prefixes for discoverability +- Design tools around natural workflows, not just API structure + +**Use Evaluation-Driven Development:** +- Create realistic evaluation scenarios early +- Let agent feedback drive tool improvements +- Prototype quickly and iterate based on actual agent performance + +#### 1.3 Study MCP Protocol Documentation + +**Fetch the latest MCP protocol documentation:** + +Use WebFetch to load: `https://modelcontextprotocol.io/llms-full.txt` + +This comprehensive document contains the complete MCP specification and guidelines. + +#### 1.4 Study Framework Documentation + +**Load and read the following reference files:** + +- **MCP Best Practices**: [📋 View Best Practices](reference/mcp_best_practices.md) - Core guidelines for all MCP servers + +**For Python implementations, also load:** +- **Python SDK Documentation**: Use WebFetch to load `https://raw.githubusercontent.com/modelcontextprotocol/python-sdk/main/README.md` +- [🐍 Python Implementation Guide](reference/python_mcp_server.md) - Python-specific best practices and examples + +**For Node/TypeScript implementations, also load:** +- **TypeScript SDK Documentation**: Use WebFetch to load `https://raw.githubusercontent.com/modelcontextprotocol/typescript-sdk/main/README.md` +- [⚡ TypeScript Implementation Guide](reference/node_mcp_server.md) - Node/TypeScript-specific best practices and examples + +#### 1.5 Exhaustively Study API Documentation + +To integrate a service, read through **ALL** available API documentation: +- Official API reference documentation +- Authentication and authorization requirements +- Rate limiting and pagination patterns +- Error responses and status codes +- Available endpoints and their parameters +- Data models and schemas + +**To gather comprehensive information, use web search and the WebFetch tool as needed.** + +#### 1.6 Create a Comprehensive Implementation Plan + +Based on your research, create a detailed plan that includes: + +**Tool Selection:** +- List the most valuable endpoints/operations to implement +- Prioritize tools that enable the most common and important use cases +- Consider which tools work together to enable complex workflows + +**Shared Utilities and Helpers:** +- Identify common API request patterns +- Plan pagination helpers +- Design filtering and formatting utilities +- Plan error handling strategies + +**Input/Output Design:** +- Define input validation models (Pydantic for Python, Zod for TypeScript) +- Design consistent response formats (e.g., JSON or Markdown), and configurable levels of detail (e.g., Detailed or Concise) +- Plan for large-scale usage (thousands of users/resources) +- Implement character limits and truncation strategies (e.g., 25,000 tokens) + +**Error Handling Strategy:** +- Plan graceful failure modes +- Design clear, actionable, LLM-friendly, natural language error messages which prompt further action +- Consider rate limiting and timeout scenarios +- Handle authentication and authorization errors + +--- + +### Phase 2: Implementation + +Now that you have a comprehensive plan, begin implementation following language-specific best practices. + +#### 2.1 Set Up Project Structure + +**For Python:** +- Create a single `.py` file or organize into modules if complex (see [🐍 Python Guide](reference/python_mcp_server.md)) +- Use the MCP Python SDK for tool registration +- Define Pydantic models for input validation + +**For Node/TypeScript:** +- Create proper project structure (see [⚡ TypeScript Guide](reference/node_mcp_server.md)) +- Set up `package.json` and `tsconfig.json` +- Use MCP TypeScript SDK +- Define Zod schemas for input validation + +#### 2.2 Implement Core Infrastructure First + +**To begin implementation, create shared utilities before implementing tools:** +- API request helper functions +- Error handling utilities +- Response formatting functions (JSON and Markdown) +- Pagination helpers +- Authentication/token management + +#### 2.3 Implement Tools Systematically + +For each tool in the plan: + +**Define Input Schema:** +- Use Pydantic (Python) or Zod (TypeScript) for validation +- Include proper constraints (min/max length, regex patterns, min/max values, ranges) +- Provide clear, descriptive field descriptions +- Include diverse examples in field descriptions + +**Write Comprehensive Docstrings/Descriptions:** +- One-line summary of what the tool does +- Detailed explanation of purpose and functionality +- Explicit parameter types with examples +- Complete return type schema +- Usage examples (when to use, when not to use) +- Error handling documentation, which outlines how to proceed given specific errors + +**Implement Tool Logic:** +- Use shared utilities to avoid code duplication +- Follow async/await patterns for all I/O +- Implement proper error handling +- Support multiple response formats (JSON and Markdown) +- Respect pagination parameters +- Check character limits and truncate appropriately + +**Add Tool Annotations:** +- `readOnlyHint`: true (for read-only operations) +- `destructiveHint`: false (for non-destructive operations) +- `idempotentHint`: true (if repeated calls have same effect) +- `openWorldHint`: true (if interacting with external systems) + +#### 2.4 Follow Language-Specific Best Practices + +**At this point, load the appropriate language guide:** + +**For Python: Load [🐍 Python Implementation Guide](reference/python_mcp_server.md) and ensure the following:** +- Using MCP Python SDK with proper tool registration +- Pydantic v2 models with `model_config` +- Type hints throughout +- Async/await for all I/O operations +- Proper imports organization +- Module-level constants (CHARACTER_LIMIT, API_BASE_URL) + +**For Node/TypeScript: Load [⚡ TypeScript Implementation Guide](reference/node_mcp_server.md) and ensure the following:** +- Using `server.registerTool` properly +- Zod schemas with `.strict()` +- TypeScript strict mode enabled +- No `any` types - use proper types +- Explicit Promise return types +- Build process configured (`npm run build`) + +--- + +### Phase 3: Review and Refine + +After initial implementation: + +#### 3.1 Code Quality Review + +To ensure quality, review the code for: +- **DRY Principle**: No duplicated code between tools +- **Composability**: Shared logic extracted into functions +- **Consistency**: Similar operations return similar formats +- **Error Handling**: All external calls have error handling +- **Type Safety**: Full type coverage (Python type hints, TypeScript types) +- **Documentation**: Every tool has comprehensive docstrings/descriptions + +#### 3.2 Test and Build + +**Important:** MCP servers are long-running processes that wait for requests over stdio/stdin or sse/http. Running them directly in your main process (e.g., `python server.py` or `node dist/index.js`) will cause your process to hang indefinitely. + +**Safe ways to test the server:** +- Use the evaluation harness (see Phase 4) - recommended approach +- Run the server in tmux to keep it outside your main process +- Use a timeout when testing: `timeout 5s python server.py` + +**For Python:** +- Verify Python syntax: `python -m py_compile your_server.py` +- Check imports work correctly by reviewing the file +- To manually test: Run server in tmux, then test with evaluation harness in main process +- Or use the evaluation harness directly (it manages the server for stdio transport) + +**For Node/TypeScript:** +- Run `npm run build` and ensure it completes without errors +- Verify dist/index.js is created +- To manually test: Run server in tmux, then test with evaluation harness in main process +- Or use the evaluation harness directly (it manages the server for stdio transport) + +#### 3.3 Use Quality Checklist + +To verify implementation quality, load the appropriate checklist from the language-specific guide: +- Python: see "Quality Checklist" in [🐍 Python Guide](reference/python_mcp_server.md) +- Node/TypeScript: see "Quality Checklist" in [⚡ TypeScript Guide](reference/node_mcp_server.md) + +--- + +### Phase 4: Create Evaluations + +After implementing your MCP server, create comprehensive evaluations to test its effectiveness. + +**Load [✅ Evaluation Guide](reference/evaluation.md) for complete evaluation guidelines.** + +#### 4.1 Understand Evaluation Purpose + +Evaluations test whether LLMs can effectively use your MCP server to answer realistic, complex questions. + +#### 4.2 Create 10 Evaluation Questions + +To create effective evaluations, follow the process outlined in the evaluation guide: + +1. **Tool Inspection**: List available tools and understand their capabilities +2. **Content Exploration**: Use READ-ONLY operations to explore available data +3. **Question Generation**: Create 10 complex, realistic questions +4. **Answer Verification**: Solve each question yourself to verify answers + +#### 4.3 Evaluation Requirements + +Each question must be: +- **Independent**: Not dependent on other questions +- **Read-only**: Only non-destructive operations required +- **Complex**: Requiring multiple tool calls and deep exploration +- **Realistic**: Based on real use cases humans would care about +- **Verifiable**: Single, clear answer that can be verified by string comparison +- **Stable**: Answer won't change over time + +#### 4.4 Output Format + +Create an XML file with this structure: + +```xml + + + Find discussions about AI model launches with animal codenames. One model needed a specific safety designation that uses the format ASL-X. What number X was being determined for the model named after a spotted wild cat? + 3 + + + +``` + +--- + +# Reference Files + +## 📚 Documentation Library + +Load these resources as needed during development: + +### Core MCP Documentation (Load First) +- **MCP Protocol**: Fetch from `https://modelcontextprotocol.io/llms-full.txt` - Complete MCP specification +- [📋 MCP Best Practices](reference/mcp_best_practices.md) - Universal MCP guidelines including: + - Server and tool naming conventions + - Response format guidelines (JSON vs Markdown) + - Pagination best practices + - Character limits and truncation strategies + - Tool development guidelines + - Security and error handling standards + +### SDK Documentation (Load During Phase 1/2) +- **Python SDK**: Fetch from `https://raw.githubusercontent.com/modelcontextprotocol/python-sdk/main/README.md` +- **TypeScript SDK**: Fetch from `https://raw.githubusercontent.com/modelcontextprotocol/typescript-sdk/main/README.md` + +### Language-Specific Implementation Guides (Load During Phase 2) +- [🐍 Python Implementation Guide](reference/python_mcp_server.md) - Complete Python/FastMCP guide with: + - Server initialization patterns + - Pydantic model examples + - Tool registration with `@mcp.tool` + - Complete working examples + - Quality checklist + +- [⚡ TypeScript Implementation Guide](reference/node_mcp_server.md) - Complete TypeScript guide with: + - Project structure + - Zod schema patterns + - Tool registration with `server.registerTool` + - Complete working examples + - Quality checklist + +### Evaluation Guide (Load During Phase 4) +- [✅ Evaluation Guide](reference/evaluation.md) - Complete evaluation creation guide with: + - Question creation guidelines + - Answer verification strategies + - XML format specifications + - Example questions and answers + - Running an evaluation with the provided scripts diff --git a/.claude/skills/mcp-builder/reference/evaluation.md b/.claude/skills/mcp-builder/reference/evaluation.md new file mode 100644 index 0000000..87e9bb7 --- /dev/null +++ b/.claude/skills/mcp-builder/reference/evaluation.md @@ -0,0 +1,602 @@ +# MCP Server Evaluation Guide + +## Overview + +This document provides guidance on creating comprehensive evaluations for MCP servers. Evaluations test whether LLMs can effectively use your MCP server to answer realistic, complex questions using only the tools provided. + +--- + +## Quick Reference + +### Evaluation Requirements +- Create 10 human-readable questions +- Questions must be READ-ONLY, INDEPENDENT, NON-DESTRUCTIVE +- Each question requires multiple tool calls (potentially dozens) +- Answers must be single, verifiable values +- Answers must be STABLE (won't change over time) + +### Output Format +```xml + + + Your question here + Single verifiable answer + + +``` + +--- + +## Purpose of Evaluations + +The measure of quality of an MCP server is NOT how well or comprehensively the server implements tools, but how well these implementations (input/output schemas, docstrings/descriptions, functionality) enable LLMs with no other context and access ONLY to the MCP servers to answer realistic and difficult questions. + +## Evaluation Overview + +Create 10 human-readable questions requiring ONLY READ-ONLY, INDEPENDENT, NON-DESTRUCTIVE, and IDEMPOTENT operations to answer. Each question should be: +- Realistic +- Clear and concise +- Unambiguous +- Complex, requiring potentially dozens of tool calls or steps +- Answerable with a single, verifiable value that you identify in advance + +## Question Guidelines + +### Core Requirements + +1. **Questions MUST be independent** + - Each question should NOT depend on the answer to any other question + - Should not assume prior write operations from processing another question + +2. **Questions MUST require ONLY NON-DESTRUCTIVE AND IDEMPOTENT tool use** + - Should not instruct or require modifying state to arrive at the correct answer + +3. **Questions must be REALISTIC, CLEAR, CONCISE, and COMPLEX** + - Must require another LLM to use multiple (potentially dozens of) tools or steps to answer + +### Complexity and Depth + +4. **Questions must require deep exploration** + - Consider multi-hop questions requiring multiple sub-questions and sequential tool calls + - Each step should benefit from information found in previous questions + +5. **Questions may require extensive paging** + - May need paging through multiple pages of results + - May require querying old data (1-2 years out-of-date) to find niche information + - The questions must be DIFFICULT + +6. **Questions must require deep understanding** + - Rather than surface-level knowledge + - May pose complex ideas as True/False questions requiring evidence + - May use multiple-choice format where LLM must search different hypotheses + +7. **Questions must not be solvable with straightforward keyword search** + - Do not include specific keywords from the target content + - Use synonyms, related concepts, or paraphrases + - Require multiple searches, analyzing multiple related items, extracting context, then deriving the answer + +### Tool Testing + +8. **Questions should stress-test tool return values** + - May elicit tools returning large JSON objects or lists, overwhelming the LLM + - Should require understanding multiple modalities of data: + - IDs and names + - Timestamps and datetimes (months, days, years, seconds) + - File IDs, names, extensions, and mimetypes + - URLs, GIDs, etc. + - Should probe the tool's ability to return all useful forms of data + +9. **Questions should MOSTLY reflect real human use cases** + - The kinds of information retrieval tasks that HUMANS assisted by an LLM would care about + +10. **Questions may require dozens of tool calls** + - This challenges LLMs with limited context + - Encourages MCP server tools to reduce information returned + +11. **Include ambiguous questions** + - May be ambiguous OR require difficult decisions on which tools to call + - Force the LLM to potentially make mistakes or misinterpret + - Ensure that despite AMBIGUITY, there is STILL A SINGLE VERIFIABLE ANSWER + +### Stability + +12. **Questions must be designed so the answer DOES NOT CHANGE** + - Do not ask questions that rely on "current state" which is dynamic + - For example, do not count: + - Number of reactions to a post + - Number of replies to a thread + - Number of members in a channel + +13. **DO NOT let the MCP server RESTRICT the kinds of questions you create** + - Create challenging and complex questions + - Some may not be solvable with the available MCP server tools + - Questions may require specific output formats (datetime vs. epoch time, JSON vs. MARKDOWN) + - Questions may require dozens of tool calls to complete + +## Answer Guidelines + +### Verification + +1. **Answers must be VERIFIABLE via direct string comparison** + - If the answer can be re-written in many formats, clearly specify the output format in the QUESTION + - Examples: "Use YYYY/MM/DD.", "Respond True or False.", "Answer A, B, C, or D and nothing else." + - Answer should be a single VERIFIABLE value such as: + - User ID, user name, display name, first name, last name + - Channel ID, channel name + - Message ID, string + - URL, title + - Numerical quantity + - Timestamp, datetime + - Boolean (for True/False questions) + - Email address, phone number + - File ID, file name, file extension + - Multiple choice answer + - Answers must not require special formatting or complex, structured output + - Answer will be verified using DIRECT STRING COMPARISON + +### Readability + +2. **Answers should generally prefer HUMAN-READABLE formats** + - Examples: names, first name, last name, datetime, file name, message string, URL, yes/no, true/false, a/b/c/d + - Rather than opaque IDs (though IDs are acceptable) + - The VAST MAJORITY of answers should be human-readable + +### Stability + +3. **Answers must be STABLE/STATIONARY** + - Look at old content (e.g., conversations that have ended, projects that have launched, questions answered) + - Create QUESTIONS based on "closed" concepts that will always return the same answer + - Questions may ask to consider a fixed time window to insulate from non-stationary answers + - Rely on context UNLIKELY to change + - Example: if finding a paper name, be SPECIFIC enough so answer is not confused with papers published later + +4. **Answers must be CLEAR and UNAMBIGUOUS** + - Questions must be designed so there is a single, clear answer + - Answer can be derived from using the MCP server tools + +### Diversity + +5. **Answers must be DIVERSE** + - Answer should be a single VERIFIABLE value in diverse modalities and formats + - User concept: user ID, user name, display name, first name, last name, email address, phone number + - Channel concept: channel ID, channel name, channel topic + - Message concept: message ID, message string, timestamp, month, day, year + +6. **Answers must NOT be complex structures** + - Not a list of values + - Not a complex object + - Not a list of IDs or strings + - Not natural language text + - UNLESS the answer can be straightforwardly verified using DIRECT STRING COMPARISON + - And can be realistically reproduced + - It should be unlikely that an LLM would return the same list in any other order or format + +## Evaluation Process + +### Step 1: Documentation Inspection + +Read the documentation of the target API to understand: +- Available endpoints and functionality +- If ambiguity exists, fetch additional information from the web +- Parallelize this step AS MUCH AS POSSIBLE +- Ensure each subagent is ONLY examining documentation from the file system or on the web + +### Step 2: Tool Inspection + +List the tools available in the MCP server: +- Inspect the MCP server directly +- Understand input/output schemas, docstrings, and descriptions +- WITHOUT calling the tools themselves at this stage + +### Step 3: Developing Understanding + +Repeat steps 1 & 2 until you have a good understanding: +- Iterate multiple times +- Think about the kinds of tasks you want to create +- Refine your understanding +- At NO stage should you READ the code of the MCP server implementation itself +- Use your intuition and understanding to create reasonable, realistic, but VERY challenging tasks + +### Step 4: Read-Only Content Inspection + +After understanding the API and tools, USE the MCP server tools: +- Inspect content using READ-ONLY and NON-DESTRUCTIVE operations ONLY +- Goal: identify specific content (e.g., users, channels, messages, projects, tasks) for creating realistic questions +- Should NOT call any tools that modify state +- Will NOT read the code of the MCP server implementation itself +- Parallelize this step with individual sub-agents pursuing independent explorations +- Ensure each subagent is only performing READ-ONLY, NON-DESTRUCTIVE, and IDEMPOTENT operations +- BE CAREFUL: SOME TOOLS may return LOTS OF DATA which would cause you to run out of CONTEXT +- Make INCREMENTAL, SMALL, AND TARGETED tool calls for exploration +- In all tool call requests, use the `limit` parameter to limit results (<10) +- Use pagination + +### Step 5: Task Generation + +After inspecting the content, create 10 human-readable questions: +- An LLM should be able to answer these with the MCP server +- Follow all question and answer guidelines above + +## Output Format + +Each QA pair consists of a question and an answer. The output should be an XML file with this structure: + +```xml + + + Find the project created in Q2 2024 with the highest number of completed tasks. What is the project name? + Website Redesign + + + Search for issues labeled as "bug" that were closed in March 2024. Which user closed the most issues? Provide their username. + sarah_dev + + + Look for pull requests that modified files in the /api directory and were merged between January 1 and January 31, 2024. How many different contributors worked on these PRs? + 7 + + + Find the repository with the most stars that was created before 2023. What is the repository name? + data-pipeline + + +``` + +## Evaluation Examples + +### Good Questions + +**Example 1: Multi-hop question requiring deep exploration (GitHub MCP)** +```xml + + Find the repository that was archived in Q3 2023 and had previously been the most forked project in the organization. What was the primary programming language used in that repository? + Python + +``` + +This question is good because: +- Requires multiple searches to find archived repositories +- Needs to identify which had the most forks before archival +- Requires examining repository details for the language +- Answer is a simple, verifiable value +- Based on historical (closed) data that won't change + +**Example 2: Requires understanding context without keyword matching (Project Management MCP)** +```xml + + Locate the initiative focused on improving customer onboarding that was completed in late 2023. The project lead created a retrospective document after completion. What was the lead's role title at that time? + Product Manager + +``` + +This question is good because: +- Doesn't use specific project name ("initiative focused on improving customer onboarding") +- Requires finding completed projects from specific timeframe +- Needs to identify the project lead and their role +- Requires understanding context from retrospective documents +- Answer is human-readable and stable +- Based on completed work (won't change) + +**Example 3: Complex aggregation requiring multiple steps (Issue Tracker MCP)** +```xml + + Among all bugs reported in January 2024 that were marked as critical priority, which assignee resolved the highest percentage of their assigned bugs within 48 hours? Provide the assignee's username. + alex_eng + +``` + +This question is good because: +- Requires filtering bugs by date, priority, and status +- Needs to group by assignee and calculate resolution rates +- Requires understanding timestamps to determine 48-hour windows +- Tests pagination (potentially many bugs to process) +- Answer is a single username +- Based on historical data from specific time period + +**Example 4: Requires synthesis across multiple data types (CRM MCP)** +```xml + + Find the account that upgraded from the Starter to Enterprise plan in Q4 2023 and had the highest annual contract value. What industry does this account operate in? + Healthcare + +``` + +This question is good because: +- Requires understanding subscription tier changes +- Needs to identify upgrade events in specific timeframe +- Requires comparing contract values +- Must access account industry information +- Answer is simple and verifiable +- Based on completed historical transactions + +### Poor Questions + +**Example 1: Answer changes over time** +```xml + + How many open issues are currently assigned to the engineering team? + 47 + +``` + +This question is poor because: +- The answer will change as issues are created, closed, or reassigned +- Not based on stable/stationary data +- Relies on "current state" which is dynamic + +**Example 2: Too easy with keyword search** +```xml + + Find the pull request with title "Add authentication feature" and tell me who created it. + developer123 + +``` + +This question is poor because: +- Can be solved with a straightforward keyword search for exact title +- Doesn't require deep exploration or understanding +- No synthesis or analysis needed + +**Example 3: Ambiguous answer format** +```xml + + List all the repositories that have Python as their primary language. + repo1, repo2, repo3, data-pipeline, ml-tools + +``` + +This question is poor because: +- Answer is a list that could be returned in any order +- Difficult to verify with direct string comparison +- LLM might format differently (JSON array, comma-separated, newline-separated) +- Better to ask for a specific aggregate (count) or superlative (most stars) + +## Verification Process + +After creating evaluations: + +1. **Examine the XML file** to understand the schema +2. **Load each task instruction** and in parallel using the MCP server and tools, identify the correct answer by attempting to solve the task YOURSELF +3. **Flag any operations** that require WRITE or DESTRUCTIVE operations +4. **Accumulate all CORRECT answers** and replace any incorrect answers in the document +5. **Remove any ``** that require WRITE or DESTRUCTIVE operations + +Remember to parallelize solving tasks to avoid running out of context, then accumulate all answers and make changes to the file at the end. + +## Tips for Creating Quality Evaluations + +1. **Think Hard and Plan Ahead** before generating tasks +2. **Parallelize Where Opportunity Arises** to speed up the process and manage context +3. **Focus on Realistic Use Cases** that humans would actually want to accomplish +4. **Create Challenging Questions** that test the limits of the MCP server's capabilities +5. **Ensure Stability** by using historical data and closed concepts +6. **Verify Answers** by solving the questions yourself using the MCP server tools +7. **Iterate and Refine** based on what you learn during the process + +--- + +# Running Evaluations + +After creating your evaluation file, you can use the provided evaluation harness to test your MCP server. + +## Setup + +1. **Install Dependencies** + + ```bash + pip install -r scripts/requirements.txt + ``` + + Or install manually: + ```bash + pip install anthropic mcp + ``` + +2. **Set API Key** + + ```bash + export ANTHROPIC_API_KEY=your_api_key_here + ``` + +## Evaluation File Format + +Evaluation files use XML format with `` elements: + +```xml + + + Find the project created in Q2 2024 with the highest number of completed tasks. What is the project name? + Website Redesign + + + Search for issues labeled as "bug" that were closed in March 2024. Which user closed the most issues? Provide their username. + sarah_dev + + +``` + +## Running Evaluations + +The evaluation script (`scripts/evaluation.py`) supports three transport types: + +**Important:** +- **stdio transport**: The evaluation script automatically launches and manages the MCP server process for you. Do not run the server manually. +- **sse/http transports**: You must start the MCP server separately before running the evaluation. The script connects to the already-running server at the specified URL. + +### 1. Local STDIO Server + +For locally-run MCP servers (script launches the server automatically): + +```bash +python scripts/evaluation.py \ + -t stdio \ + -c python \ + -a my_mcp_server.py \ + evaluation.xml +``` + +With environment variables: +```bash +python scripts/evaluation.py \ + -t stdio \ + -c python \ + -a my_mcp_server.py \ + -e API_KEY=abc123 \ + -e DEBUG=true \ + evaluation.xml +``` + +### 2. Server-Sent Events (SSE) + +For SSE-based MCP servers (you must start the server first): + +```bash +python scripts/evaluation.py \ + -t sse \ + -u https://example.com/mcp \ + -H "Authorization: Bearer token123" \ + -H "X-Custom-Header: value" \ + evaluation.xml +``` + +### 3. HTTP (Streamable HTTP) + +For HTTP-based MCP servers (you must start the server first): + +```bash +python scripts/evaluation.py \ + -t http \ + -u https://example.com/mcp \ + -H "Authorization: Bearer token123" \ + evaluation.xml +``` + +## Command-Line Options + +``` +usage: evaluation.py [-h] [-t {stdio,sse,http}] [-m MODEL] [-c COMMAND] + [-a ARGS [ARGS ...]] [-e ENV [ENV ...]] [-u URL] + [-H HEADERS [HEADERS ...]] [-o OUTPUT] + eval_file + +positional arguments: + eval_file Path to evaluation XML file + +optional arguments: + -h, --help Show help message + -t, --transport Transport type: stdio, sse, or http (default: stdio) + -m, --model Claude model to use (default: claude-3-7-sonnet-20250219) + -o, --output Output file for report (default: print to stdout) + +stdio options: + -c, --command Command to run MCP server (e.g., python, node) + -a, --args Arguments for the command (e.g., server.py) + -e, --env Environment variables in KEY=VALUE format + +sse/http options: + -u, --url MCP server URL + -H, --header HTTP headers in 'Key: Value' format +``` + +## Output + +The evaluation script generates a detailed report including: + +- **Summary Statistics**: + - Accuracy (correct/total) + - Average task duration + - Average tool calls per task + - Total tool calls + +- **Per-Task Results**: + - Prompt and expected response + - Actual response from the agent + - Whether the answer was correct (✅/❌) + - Duration and tool call details + - Agent's summary of its approach + - Agent's feedback on the tools + +### Save Report to File + +```bash +python scripts/evaluation.py \ + -t stdio \ + -c python \ + -a my_server.py \ + -o evaluation_report.md \ + evaluation.xml +``` + +## Complete Example Workflow + +Here's a complete example of creating and running an evaluation: + +1. **Create your evaluation file** (`my_evaluation.xml`): + +```xml + + + Find the user who created the most issues in January 2024. What is their username? + alice_developer + + + Among all pull requests merged in Q1 2024, which repository had the highest number? Provide the repository name. + backend-api + + + Find the project that was completed in December 2023 and had the longest duration from start to finish. How many days did it take? + 127 + + +``` + +2. **Install dependencies**: + +```bash +pip install -r scripts/requirements.txt +export ANTHROPIC_API_KEY=your_api_key +``` + +3. **Run evaluation**: + +```bash +python scripts/evaluation.py \ + -t stdio \ + -c python \ + -a github_mcp_server.py \ + -e GITHUB_TOKEN=ghp_xxx \ + -o github_eval_report.md \ + my_evaluation.xml +``` + +4. **Review the report** in `github_eval_report.md` to: + - See which questions passed/failed + - Read the agent's feedback on your tools + - Identify areas for improvement + - Iterate on your MCP server design + +## Troubleshooting + +### Connection Errors + +If you get connection errors: +- **STDIO**: Verify the command and arguments are correct +- **SSE/HTTP**: Check the URL is accessible and headers are correct +- Ensure any required API keys are set in environment variables or headers + +### Low Accuracy + +If many evaluations fail: +- Review the agent's feedback for each task +- Check if tool descriptions are clear and comprehensive +- Verify input parameters are well-documented +- Consider whether tools return too much or too little data +- Ensure error messages are actionable + +### Timeout Issues + +If tasks are timing out: +- Use a more capable model (e.g., `claude-3-7-sonnet-20250219`) +- Check if tools are returning too much data +- Verify pagination is working correctly +- Consider simplifying complex questions \ No newline at end of file diff --git a/.claude/skills/mcp-builder/reference/mcp_best_practices.md b/.claude/skills/mcp-builder/reference/mcp_best_practices.md new file mode 100644 index 0000000..db42af7 --- /dev/null +++ b/.claude/skills/mcp-builder/reference/mcp_best_practices.md @@ -0,0 +1,915 @@ +# MCP Server Development Best Practices and Guidelines + +## Overview + +This document compiles essential best practices and guidelines for building Model Context Protocol (MCP) servers. It covers naming conventions, tool design, response formats, pagination, error handling, security, and compliance requirements. + +--- + +## Quick Reference + +### Server Naming +- **Python**: `{service}_mcp` (e.g., `slack_mcp`) +- **Node/TypeScript**: `{service}-mcp-server` (e.g., `slack-mcp-server`) + +### Tool Naming +- Use snake_case with service prefix +- Format: `{service}_{action}_{resource}` +- Example: `slack_send_message`, `github_create_issue` + +### Response Formats +- Support both JSON and Markdown formats +- JSON for programmatic processing +- Markdown for human readability + +### Pagination +- Always respect `limit` parameter +- Return `has_more`, `next_offset`, `total_count` +- Default to 20-50 items + +### Character Limits +- Set CHARACTER_LIMIT constant (typically 25,000) +- Truncate gracefully with clear messages +- Provide guidance on filtering + +--- + +## Table of Contents +1. Server Naming Conventions +2. Tool Naming and Design +3. Response Format Guidelines +4. Pagination Best Practices +5. Character Limits and Truncation +6. Tool Development Best Practices +7. Transport Best Practices +8. Testing Requirements +9. OAuth and Security Best Practices +10. Resource Management Best Practices +11. Prompt Management Best Practices +12. Error Handling Standards +13. Documentation Requirements +14. Compliance and Monitoring + +--- + +## 1. Server Naming Conventions + +Follow these standardized naming patterns for MCP servers: + +**Python**: Use format `{service}_mcp` (lowercase with underscores) +- Examples: `slack_mcp`, `github_mcp`, `jira_mcp`, `stripe_mcp` + +**Node/TypeScript**: Use format `{service}-mcp-server` (lowercase with hyphens) +- Examples: `slack-mcp-server`, `github-mcp-server`, `jira-mcp-server` + +The name should be: +- General (not tied to specific features) +- Descriptive of the service/API being integrated +- Easy to infer from the task description +- Without version numbers or dates + +--- + +## 2. Tool Naming and Design + +### Tool Naming Best Practices + +1. **Use snake_case**: `search_users`, `create_project`, `get_channel_info` +2. **Include service prefix**: Anticipate that your MCP server may be used alongside other MCP servers + - Use `slack_send_message` instead of just `send_message` + - Use `github_create_issue` instead of just `create_issue` + - Use `asana_list_tasks` instead of just `list_tasks` +3. **Be action-oriented**: Start with verbs (get, list, search, create, etc.) +4. **Be specific**: Avoid generic names that could conflict with other servers +5. **Maintain consistency**: Use consistent naming patterns within your server + +### Tool Design Guidelines + +- Tool descriptions must narrowly and unambiguously describe functionality +- Descriptions must precisely match actual functionality +- Should not create confusion with other MCP servers +- Should provide tool annotations (readOnlyHint, destructiveHint, idempotentHint, openWorldHint) +- Keep tool operations focused and atomic + +--- + +## 3. Response Format Guidelines + +All tools that return data should support multiple formats for flexibility: + +### JSON Format (`response_format="json"`) +- Machine-readable structured data +- Include all available fields and metadata +- Consistent field names and types +- Suitable for programmatic processing +- Use for when LLMs need to process data further + +### Markdown Format (`response_format="markdown"`, typically default) +- Human-readable formatted text +- Use headers, lists, and formatting for clarity +- Convert timestamps to human-readable format (e.g., "2024-01-15 10:30:00 UTC" instead of epoch) +- Show display names with IDs in parentheses (e.g., "@john.doe (U123456)") +- Omit verbose metadata (e.g., show only one profile image URL, not all sizes) +- Group related information logically +- Use for when presenting information to users + +--- + +## 4. Pagination Best Practices + +For tools that list resources: + +- **Always respect the `limit` parameter**: Never load all results when a limit is specified +- **Implement pagination**: Use `offset` or cursor-based pagination +- **Return pagination metadata**: Include `has_more`, `next_offset`/`next_cursor`, `total_count` +- **Never load all results into memory**: Especially important for large datasets +- **Default to reasonable limits**: 20-50 items is typical +- **Include clear pagination info in responses**: Make it easy for LLMs to request more data + +Example pagination response structure: +```json +{ + "total": 150, + "count": 20, + "offset": 0, + "items": [...], + "has_more": true, + "next_offset": 20 +} +``` + +--- + +## 5. Character Limits and Truncation + +To prevent overwhelming responses with too much data: + +- **Define CHARACTER_LIMIT constant**: Typically 25,000 characters at module level +- **Check response size before returning**: Measure the final response length +- **Truncate gracefully with clear indicators**: Let the LLM know data was truncated +- **Provide guidance on filtering**: Suggest how to use parameters to reduce results +- **Include truncation metadata**: Show what was truncated and how to get more + +Example truncation handling: +```python +CHARACTER_LIMIT = 25000 + +if len(result) > CHARACTER_LIMIT: + truncated_data = data[:max(1, len(data) // 2)] + response["truncated"] = True + response["truncation_message"] = ( + f"Response truncated from {len(data)} to {len(truncated_data)} items. " + f"Use 'offset' parameter or add filters to see more results." + ) +``` + +--- + +## 6. Transport Options + +MCP servers support multiple transport mechanisms for different deployment scenarios: + +### Stdio Transport + +**Best for**: Command-line tools, local integrations, subprocess execution + +**Characteristics**: +- Standard input/output stream communication +- Simple setup, no network configuration needed +- Runs as a subprocess of the client +- Ideal for desktop applications and CLI tools + +**Use when**: +- Building tools for local development environments +- Integrating with desktop applications (e.g., Claude Desktop) +- Creating command-line utilities +- Single-user, single-session scenarios + +### HTTP Transport + +**Best for**: Web services, remote access, multi-client scenarios + +**Characteristics**: +- Request-response pattern over HTTP +- Supports multiple simultaneous clients +- Can be deployed as a web service +- Requires network configuration and security considerations + +**Use when**: +- Serving multiple clients simultaneously +- Deploying as a cloud service +- Integration with web applications +- Need for load balancing or scaling + +### Server-Sent Events (SSE) Transport + +**Best for**: Real-time updates, push notifications, streaming data + +**Characteristics**: +- One-way server-to-client streaming over HTTP +- Enables real-time updates without polling +- Long-lived connections for continuous data flow +- Built on standard HTTP infrastructure + +**Use when**: +- Clients need real-time data updates +- Implementing push notifications +- Streaming logs or monitoring data +- Progressive result delivery for long operations + +### Transport Selection Criteria + +| Criterion | Stdio | HTTP | SSE | +|-----------|-------|------|-----| +| **Deployment** | Local | Remote | Remote | +| **Clients** | Single | Multiple | Multiple | +| **Communication** | Bidirectional | Request-Response | Server-Push | +| **Complexity** | Low | Medium | Medium-High | +| **Real-time** | No | No | Yes | + +--- + +## 7. Tool Development Best Practices + +### General Guidelines +1. Tool names should be descriptive and action-oriented +2. Use parameter validation with detailed JSON schemas +3. Include examples in tool descriptions +4. Implement proper error handling and validation +5. Use progress reporting for long operations +6. Keep tool operations focused and atomic +7. Document expected return value structures +8. Implement proper timeouts +9. Consider rate limiting for resource-intensive operations +10. Log tool usage for debugging and monitoring + +### Security Considerations for Tools + +#### Input Validation +- Validate all parameters against schema +- Sanitize file paths and system commands +- Validate URLs and external identifiers +- Check parameter sizes and ranges +- Prevent command injection + +#### Access Control +- Implement authentication where needed +- Use appropriate authorization checks +- Audit tool usage +- Rate limit requests +- Monitor for abuse + +#### Error Handling +- Don't expose internal errors to clients +- Log security-relevant errors +- Handle timeouts appropriately +- Clean up resources after errors +- Validate return values + +### Tool Annotations +- Provide readOnlyHint and destructiveHint annotations +- Remember annotations are hints, not security guarantees +- Clients should not make security-critical decisions based solely on annotations + +--- + +## 8. Transport Best Practices + +### General Transport Guidelines +1. Handle connection lifecycle properly +2. Implement proper error handling +3. Use appropriate timeout values +4. Implement connection state management +5. Clean up resources on disconnection + +### Security Best Practices for Transport +- Follow security considerations for DNS rebinding attacks +- Implement proper authentication mechanisms +- Validate message formats +- Handle malformed messages gracefully + +### Stdio Transport Specific +- Local MCP servers should NOT log to stdout (interferes with protocol) +- Use stderr for logging messages +- Handle standard I/O streams properly + +--- + +## 9. Testing Requirements + +A comprehensive testing strategy should cover: + +### Functional Testing +- Verify correct execution with valid/invalid inputs + +### Integration Testing +- Test interaction with external systems + +### Security Testing +- Validate auth, input sanitization, rate limiting + +### Performance Testing +- Check behavior under load, timeouts + +### Error Handling +- Ensure proper error reporting and cleanup + +--- + +## 10. OAuth and Security Best Practices + +### Authentication and Authorization + +MCP servers that connect to external services should implement proper authentication: + +**OAuth 2.1 Implementation:** +- Use secure OAuth 2.1 with certificates from recognized authorities +- Validate access tokens before processing requests +- Only accept tokens specifically intended for your server +- Reject tokens without proper audience claims +- Never pass through tokens received from MCP clients + +**API Key Management:** +- Store API keys in environment variables, never in code +- Validate keys on server startup +- Provide clear error messages when authentication fails +- Use secure transmission for sensitive credentials + +### Input Validation and Security + +**Always validate inputs:** +- Sanitize file paths to prevent directory traversal +- Validate URLs and external identifiers +- Check parameter sizes and ranges +- Prevent command injection in system calls +- Use schema validation (Pydantic/Zod) for all inputs + +**Error handling security:** +- Don't expose internal errors to clients +- Log security-relevant errors server-side +- Provide helpful but not revealing error messages +- Clean up resources after errors + +### Privacy and Data Protection + +**Data collection principles:** +- Only collect data strictly necessary for functionality +- Don't collect extraneous conversation data +- Don't collect PII unless explicitly required for the tool's purpose +- Provide clear information about what data is accessed + +**Data transmission:** +- Don't send data to servers outside your organization without disclosure +- Use secure transmission (HTTPS) for all network communication +- Validate certificates for external services + +--- + +## 11. Resource Management Best Practices + +1. Only suggest necessary resources +2. Use clear, descriptive names for roots +3. Handle resource boundaries properly +4. Respect client control over resources +5. Use model-controlled primitives (tools) for automatic data exposure + +--- + +## 12. Prompt Management Best Practices + +- Clients should show users proposed prompts +- Users should be able to modify or reject prompts +- Clients should show users completions +- Users should be able to modify or reject completions +- Consider costs when using sampling + +--- + +## 13. Error Handling Standards + +- Use standard JSON-RPC error codes +- Report tool errors within result objects (not protocol-level) +- Provide helpful, specific error messages +- Don't expose internal implementation details +- Clean up resources properly on errors + +--- + +## 14. Documentation Requirements + +- Provide clear documentation of all tools and capabilities +- Include working examples (at least 3 per major feature) +- Document security considerations +- Specify required permissions and access levels +- Document rate limits and performance characteristics + +--- + +## 15. Compliance and Monitoring + +- Implement logging for debugging and monitoring +- Track tool usage patterns +- Monitor for potential abuse +- Maintain audit trails for security-relevant operations +- Be prepared for ongoing compliance reviews + +--- + +## Summary + +These best practices represent the comprehensive guidelines for building secure, efficient, and compliant MCP servers that work well within the ecosystem. Developers should follow these guidelines to ensure their MCP servers meet the standards for inclusion in the MCP directory and provide a safe, reliable experience for users. + + +---------- + + +# Tools + +> Enable LLMs to perform actions through your server + +Tools are a powerful primitive in the Model Context Protocol (MCP) that enable servers to expose executable functionality to clients. Through tools, LLMs can interact with external systems, perform computations, and take actions in the real world. + + + Tools are designed to be **model-controlled**, meaning that tools are exposed from servers to clients with the intention of the AI model being able to automatically invoke them (with a human in the loop to grant approval). + + +## Overview + +Tools in MCP allow servers to expose executable functions that can be invoked by clients and used by LLMs to perform actions. Key aspects of tools include: + +* **Discovery**: Clients can obtain a list of available tools by sending a `tools/list` request +* **Invocation**: Tools are called using the `tools/call` request, where servers perform the requested operation and return results +* **Flexibility**: Tools can range from simple calculations to complex API interactions + +Like [resources](/docs/concepts/resources), tools are identified by unique names and can include descriptions to guide their usage. However, unlike resources, tools represent dynamic operations that can modify state or interact with external systems. + +## Tool definition structure + +Each tool is defined with the following structure: + +```typescript +{ + name: string; // Unique identifier for the tool + description?: string; // Human-readable description + inputSchema: { // JSON Schema for the tool's parameters + type: "object", + properties: { ... } // Tool-specific parameters + }, + annotations?: { // Optional hints about tool behavior + title?: string; // Human-readable title for the tool + readOnlyHint?: boolean; // If true, the tool does not modify its environment + destructiveHint?: boolean; // If true, the tool may perform destructive updates + idempotentHint?: boolean; // If true, repeated calls with same args have no additional effect + openWorldHint?: boolean; // If true, tool interacts with external entities + } +} +``` + +## Implementing tools + +Here's an example of implementing a basic tool in an MCP server: + + + + ```typescript + const server = new Server({ + name: "example-server", + version: "1.0.0" + }, { + capabilities: { + tools: {} + } + }); + + // Define available tools + server.setRequestHandler(ListToolsRequestSchema, async () => { + return { + tools: [{ + name: "calculate_sum", + description: "Add two numbers together", + inputSchema: { + type: "object", + properties: { + a: { type: "number" }, + b: { type: "number" } + }, + required: ["a", "b"] + } + }] + }; + }); + + // Handle tool execution + server.setRequestHandler(CallToolRequestSchema, async (request) => { + if (request.params.name === "calculate_sum") { + const { a, b } = request.params.arguments; + return { + content: [ + { + type: "text", + text: String(a + b) + } + ] + }; + } + throw new Error("Tool not found"); + }); + ``` + + + + ```python + app = Server("example-server") + + @app.list_tools() + async def list_tools() -> list[types.Tool]: + return [ + types.Tool( + name="calculate_sum", + description="Add two numbers together", + inputSchema={ + "type": "object", + "properties": { + "a": {"type": "number"}, + "b": {"type": "number"} + }, + "required": ["a", "b"] + } + ) + ] + + @app.call_tool() + async def call_tool( + name: str, + arguments: dict + ) -> list[types.TextContent | types.ImageContent | types.EmbeddedResource]: + if name == "calculate_sum": + a = arguments["a"] + b = arguments["b"] + result = a + b + return [types.TextContent(type="text", text=str(result))] + raise ValueError(f"Tool not found: {name}") + ``` + + + +## Example tool patterns + +Here are some examples of types of tools that a server could provide: + +### System operations + +Tools that interact with the local system: + +```typescript +{ + name: "execute_command", + description: "Run a shell command", + inputSchema: { + type: "object", + properties: { + command: { type: "string" }, + args: { type: "array", items: { type: "string" } } + } + } +} +``` + +### API integrations + +Tools that wrap external APIs: + +```typescript +{ + name: "github_create_issue", + description: "Create a GitHub issue", + inputSchema: { + type: "object", + properties: { + title: { type: "string" }, + body: { type: "string" }, + labels: { type: "array", items: { type: "string" } } + } + } +} +``` + +### Data processing + +Tools that transform or analyze data: + +```typescript +{ + name: "analyze_csv", + description: "Analyze a CSV file", + inputSchema: { + type: "object", + properties: { + filepath: { type: "string" }, + operations: { + type: "array", + items: { + enum: ["sum", "average", "count"] + } + } + } + } +} +``` + +## Best practices + +When implementing tools: + +1. Provide clear, descriptive names and descriptions +2. Use detailed JSON Schema definitions for parameters +3. Include examples in tool descriptions to demonstrate how the model should use them +4. Implement proper error handling and validation +5. Use progress reporting for long operations +6. Keep tool operations focused and atomic +7. Document expected return value structures +8. Implement proper timeouts +9. Consider rate limiting for resource-intensive operations +10. Log tool usage for debugging and monitoring + +### Tool name conflicts + +MCP client applications and MCP server proxies may encounter tool name conflicts when building their own tool lists. For example, two connected MCP servers `web1` and `web2` may both expose a tool named `search_web`. + +Applications may disambiguiate tools with one of the following strategies (among others; not an exhaustive list): + +* Concatenating a unique, user-defined server name with the tool name, e.g. `web1___search_web` and `web2___search_web`. This strategy may be preferable when unique server names are already provided by the user in a configuration file. +* Generating a random prefix for the tool name, e.g. `jrwxs___search_web` and `6cq52___search_web`. This strategy may be preferable in server proxies where user-defined unique names are not available. +* Using the server URI as a prefix for the tool name, e.g. `web1.example.com:search_web` and `web2.example.com:search_web`. This strategy may be suitable when working with remote MCP servers. + +Note that the server-provided name from the initialization flow is not guaranteed to be unique and is not generally suitable for disambiguation purposes. + +## Security considerations + +When exposing tools: + +### Input validation + +* Validate all parameters against the schema +* Sanitize file paths and system commands +* Validate URLs and external identifiers +* Check parameter sizes and ranges +* Prevent command injection + +### Access control + +* Implement authentication where needed +* Use appropriate authorization checks +* Audit tool usage +* Rate limit requests +* Monitor for abuse + +### Error handling + +* Don't expose internal errors to clients +* Log security-relevant errors +* Handle timeouts appropriately +* Clean up resources after errors +* Validate return values + +## Tool discovery and updates + +MCP supports dynamic tool discovery: + +1. Clients can list available tools at any time +2. Servers can notify clients when tools change using `notifications/tools/list_changed` +3. Tools can be added or removed during runtime +4. Tool definitions can be updated (though this should be done carefully) + +## Error handling + +Tool errors should be reported within the result object, not as MCP protocol-level errors. This allows the LLM to see and potentially handle the error. When a tool encounters an error: + +1. Set `isError` to `true` in the result +2. Include error details in the `content` array + +Here's an example of proper error handling for tools: + + + + ```typescript + try { + // Tool operation + const result = performOperation(); + return { + content: [ + { + type: "text", + text: `Operation successful: ${result}` + } + ] + }; + } catch (error) { + return { + isError: true, + content: [ + { + type: "text", + text: `Error: ${error.message}` + } + ] + }; + } + ``` + + + + ```python + try: + # Tool operation + result = perform_operation() + return types.CallToolResult( + content=[ + types.TextContent( + type="text", + text=f"Operation successful: {result}" + ) + ] + ) + except Exception as error: + return types.CallToolResult( + isError=True, + content=[ + types.TextContent( + type="text", + text=f"Error: {str(error)}" + ) + ] + ) + ``` + + + +This approach allows the LLM to see that an error occurred and potentially take corrective action or request human intervention. + +## Tool annotations + +Tool annotations provide additional metadata about a tool's behavior, helping clients understand how to present and manage tools. These annotations are hints that describe the nature and impact of a tool, but should not be relied upon for security decisions. + +### Purpose of tool annotations + +Tool annotations serve several key purposes: + +1. Provide UX-specific information without affecting model context +2. Help clients categorize and present tools appropriately +3. Convey information about a tool's potential side effects +4. Assist in developing intuitive interfaces for tool approval + +### Available tool annotations + +The MCP specification defines the following annotations for tools: + +| Annotation | Type | Default | Description | +| ----------------- | ------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------ | +| `title` | string | - | A human-readable title for the tool, useful for UI display | +| `readOnlyHint` | boolean | false | If true, indicates the tool does not modify its environment | +| `destructiveHint` | boolean | true | If true, the tool may perform destructive updates (only meaningful when `readOnlyHint` is false) | +| `idempotentHint` | boolean | false | If true, calling the tool repeatedly with the same arguments has no additional effect (only meaningful when `readOnlyHint` is false) | +| `openWorldHint` | boolean | true | If true, the tool may interact with an "open world" of external entities | + +### Example usage + +Here's how to define tools with annotations for different scenarios: + +```typescript +// A read-only search tool +{ + name: "web_search", + description: "Search the web for information", + inputSchema: { + type: "object", + properties: { + query: { type: "string" } + }, + required: ["query"] + }, + annotations: { + title: "Web Search", + readOnlyHint: true, + openWorldHint: true + } +} + +// A destructive file deletion tool +{ + name: "delete_file", + description: "Delete a file from the filesystem", + inputSchema: { + type: "object", + properties: { + path: { type: "string" } + }, + required: ["path"] + }, + annotations: { + title: "Delete File", + readOnlyHint: false, + destructiveHint: true, + idempotentHint: true, + openWorldHint: false + } +} + +// A non-destructive database record creation tool +{ + name: "create_record", + description: "Create a new record in the database", + inputSchema: { + type: "object", + properties: { + table: { type: "string" }, + data: { type: "object" } + }, + required: ["table", "data"] + }, + annotations: { + title: "Create Database Record", + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + openWorldHint: false + } +} +``` + +### Integrating annotations in server implementation + + + + ```typescript + server.setRequestHandler(ListToolsRequestSchema, async () => { + return { + tools: [{ + name: "calculate_sum", + description: "Add two numbers together", + inputSchema: { + type: "object", + properties: { + a: { type: "number" }, + b: { type: "number" } + }, + required: ["a", "b"] + }, + annotations: { + title: "Calculate Sum", + readOnlyHint: true, + openWorldHint: false + } + }] + }; + }); + ``` + + + + ```python + from mcp.server.fastmcp import FastMCP + + mcp = FastMCP("example-server") + + @mcp.tool( + annotations={ + "title": "Calculate Sum", + "readOnlyHint": True, + "openWorldHint": False + } + ) + async def calculate_sum(a: float, b: float) -> str: + """Add two numbers together. + + Args: + a: First number to add + b: Second number to add + """ + result = a + b + return str(result) + ``` + + + +### Best practices for tool annotations + +1. **Be accurate about side effects**: Clearly indicate whether a tool modifies its environment and whether those modifications are destructive. + +2. **Use descriptive titles**: Provide human-friendly titles that clearly describe the tool's purpose. + +3. **Indicate idempotency properly**: Mark tools as idempotent only if repeated calls with the same arguments truly have no additional effect. + +4. **Set appropriate open/closed world hints**: Indicate whether a tool interacts with a closed system (like a database) or an open system (like the web). + +5. **Remember annotations are hints**: All properties in ToolAnnotations are hints and not guaranteed to provide a faithful description of tool behavior. Clients should never make security-critical decisions based solely on annotations. + +## Testing tools + +A comprehensive testing strategy for MCP tools should cover: + +* **Functional testing**: Verify tools execute correctly with valid inputs and handle invalid inputs appropriately +* **Integration testing**: Test tool interaction with external systems using both real and mocked dependencies +* **Security testing**: Validate authentication, authorization, input sanitization, and rate limiting +* **Performance testing**: Check behavior under load, timeout handling, and resource cleanup +* **Error handling**: Ensure tools properly report errors through the MCP protocol and clean up resources diff --git a/.claude/skills/mcp-builder/reference/node_mcp_server.md b/.claude/skills/mcp-builder/reference/node_mcp_server.md new file mode 100644 index 0000000..e66a35b --- /dev/null +++ b/.claude/skills/mcp-builder/reference/node_mcp_server.md @@ -0,0 +1,916 @@ +# Node/TypeScript MCP Server Implementation Guide + +## Overview + +This document provides Node/TypeScript-specific best practices and examples for implementing MCP servers using the MCP TypeScript SDK. It covers project structure, server setup, tool registration patterns, input validation with Zod, error handling, and complete working examples. + +--- + +## Quick Reference + +### Key Imports +```typescript +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { z } from "zod"; +import axios, { AxiosError } from "axios"; +``` + +### Server Initialization +```typescript +const server = new McpServer({ + name: "service-mcp-server", + version: "1.0.0" +}); +``` + +### Tool Registration Pattern +```typescript +server.registerTool("tool_name", {...config}, async (params) => { + // Implementation +}); +``` + +--- + +## MCP TypeScript SDK + +The official MCP TypeScript SDK provides: +- `McpServer` class for server initialization +- `registerTool` method for tool registration +- Zod schema integration for runtime input validation +- Type-safe tool handler implementations + +See the MCP SDK documentation in the references for complete details. + +## Server Naming Convention + +Node/TypeScript MCP servers must follow this naming pattern: +- **Format**: `{service}-mcp-server` (lowercase with hyphens) +- **Examples**: `github-mcp-server`, `jira-mcp-server`, `stripe-mcp-server` + +The name should be: +- General (not tied to specific features) +- Descriptive of the service/API being integrated +- Easy to infer from the task description +- Without version numbers or dates + +## Project Structure + +Create the following structure for Node/TypeScript MCP servers: + +``` +{service}-mcp-server/ +├── package.json +├── tsconfig.json +├── README.md +├── src/ +│ ├── index.ts # Main entry point with McpServer initialization +│ ├── types.ts # TypeScript type definitions and interfaces +│ ├── tools/ # Tool implementations (one file per domain) +│ ├── services/ # API clients and shared utilities +│ ├── schemas/ # Zod validation schemas +│ └── constants.ts # Shared constants (API_URL, CHARACTER_LIMIT, etc.) +└── dist/ # Built JavaScript files (entry point: dist/index.js) +``` + +## Tool Implementation + +### Tool Naming + +Use snake_case for tool names (e.g., "search_users", "create_project", "get_channel_info") with clear, action-oriented names. + +**Avoid Naming Conflicts**: Include the service context to prevent overlaps: +- Use "slack_send_message" instead of just "send_message" +- Use "github_create_issue" instead of just "create_issue" +- Use "asana_list_tasks" instead of just "list_tasks" + +### Tool Structure + +Tools are registered using the `registerTool` method with the following requirements: +- Use Zod schemas for runtime input validation and type safety +- The `description` field must be explicitly provided - JSDoc comments are NOT automatically extracted +- Explicitly provide `title`, `description`, `inputSchema`, and `annotations` +- The `inputSchema` must be a Zod schema object (not a JSON schema) +- Type all parameters and return values explicitly + +```typescript +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; + +const server = new McpServer({ + name: "example-mcp", + version: "1.0.0" +}); + +// Zod schema for input validation +const UserSearchInputSchema = z.object({ + query: z.string() + .min(2, "Query must be at least 2 characters") + .max(200, "Query must not exceed 200 characters") + .describe("Search string to match against names/emails"), + limit: z.number() + .int() + .min(1) + .max(100) + .default(20) + .describe("Maximum results to return"), + offset: z.number() + .int() + .min(0) + .default(0) + .describe("Number of results to skip for pagination"), + response_format: z.nativeEnum(ResponseFormat) + .default(ResponseFormat.MARKDOWN) + .describe("Output format: 'markdown' for human-readable or 'json' for machine-readable") +}).strict(); + +// Type definition from Zod schema +type UserSearchInput = z.infer; + +server.registerTool( + "example_search_users", + { + title: "Search Example Users", + description: `Search for users in the Example system by name, email, or team. + +This tool searches across all user profiles in the Example platform, supporting partial matches and various search filters. It does NOT create or modify users, only searches existing ones. + +Args: + - query (string): Search string to match against names/emails + - limit (number): Maximum results to return, between 1-100 (default: 20) + - offset (number): Number of results to skip for pagination (default: 0) + - response_format ('markdown' | 'json'): Output format (default: 'markdown') + +Returns: + For JSON format: Structured data with schema: + { + "total": number, // Total number of matches found + "count": number, // Number of results in this response + "offset": number, // Current pagination offset + "users": [ + { + "id": string, // User ID (e.g., "U123456789") + "name": string, // Full name (e.g., "John Doe") + "email": string, // Email address + "team": string, // Team name (optional) + "active": boolean // Whether user is active + } + ], + "has_more": boolean, // Whether more results are available + "next_offset": number // Offset for next page (if has_more is true) + } + +Examples: + - Use when: "Find all marketing team members" -> params with query="team:marketing" + - Use when: "Search for John's account" -> params with query="john" + - Don't use when: You need to create a user (use example_create_user instead) + +Error Handling: + - Returns "Error: Rate limit exceeded" if too many requests (429 status) + - Returns "No users found matching ''" if search returns empty`, + inputSchema: UserSearchInputSchema, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true + } + }, + async (params: UserSearchInput) => { + try { + // Input validation is handled by Zod schema + // Make API request using validated parameters + const data = await makeApiRequest( + "users/search", + "GET", + undefined, + { + q: params.query, + limit: params.limit, + offset: params.offset + } + ); + + const users = data.users || []; + const total = data.total || 0; + + if (!users.length) { + return { + content: [{ + type: "text", + text: `No users found matching '${params.query}'` + }] + }; + } + + // Format response based on requested format + let result: string; + + if (params.response_format === ResponseFormat.MARKDOWN) { + // Human-readable markdown format + const lines: string[] = [`# User Search Results: '${params.query}'`, ""]; + lines.push(`Found ${total} users (showing ${users.length})`); + lines.push(""); + + for (const user of users) { + lines.push(`## ${user.name} (${user.id})`); + lines.push(`- **Email**: ${user.email}`); + if (user.team) { + lines.push(`- **Team**: ${user.team}`); + } + lines.push(""); + } + + result = lines.join("\n"); + + } else { + // Machine-readable JSON format + const response: any = { + total, + count: users.length, + offset: params.offset, + users: users.map((user: any) => ({ + id: user.id, + name: user.name, + email: user.email, + ...(user.team ? { team: user.team } : {}), + active: user.active ?? true + })) + }; + + // Add pagination info if there are more results + if (total > params.offset + users.length) { + response.has_more = true; + response.next_offset = params.offset + users.length; + } + + result = JSON.stringify(response, null, 2); + } + + return { + content: [{ + type: "text", + text: result + }] + }; + } catch (error) { + return { + content: [{ + type: "text", + text: handleApiError(error) + }] + }; + } + } +); +``` + +## Zod Schemas for Input Validation + +Zod provides runtime type validation: + +```typescript +import { z } from "zod"; + +// Basic schema with validation +const CreateUserSchema = z.object({ + name: z.string() + .min(1, "Name is required") + .max(100, "Name must not exceed 100 characters"), + email: z.string() + .email("Invalid email format"), + age: z.number() + .int("Age must be a whole number") + .min(0, "Age cannot be negative") + .max(150, "Age cannot be greater than 150") +}).strict(); // Use .strict() to forbid extra fields + +// Enums +enum ResponseFormat { + MARKDOWN = "markdown", + JSON = "json" +} + +const SearchSchema = z.object({ + response_format: z.nativeEnum(ResponseFormat) + .default(ResponseFormat.MARKDOWN) + .describe("Output format") +}); + +// Optional fields with defaults +const PaginationSchema = z.object({ + limit: z.number() + .int() + .min(1) + .max(100) + .default(20) + .describe("Maximum results to return"), + offset: z.number() + .int() + .min(0) + .default(0) + .describe("Number of results to skip") +}); +``` + +## Response Format Options + +Support multiple output formats for flexibility: + +```typescript +enum ResponseFormat { + MARKDOWN = "markdown", + JSON = "json" +} + +const inputSchema = z.object({ + query: z.string(), + response_format: z.nativeEnum(ResponseFormat) + .default(ResponseFormat.MARKDOWN) + .describe("Output format: 'markdown' for human-readable or 'json' for machine-readable") +}); +``` + +**Markdown format**: +- Use headers, lists, and formatting for clarity +- Convert timestamps to human-readable format +- Show display names with IDs in parentheses +- Omit verbose metadata +- Group related information logically + +**JSON format**: +- Return complete, structured data suitable for programmatic processing +- Include all available fields and metadata +- Use consistent field names and types + +## Pagination Implementation + +For tools that list resources: + +```typescript +const ListSchema = z.object({ + limit: z.number().int().min(1).max(100).default(20), + offset: z.number().int().min(0).default(0) +}); + +async function listItems(params: z.infer) { + const data = await apiRequest(params.limit, params.offset); + + const response = { + total: data.total, + count: data.items.length, + offset: params.offset, + items: data.items, + has_more: data.total > params.offset + data.items.length, + next_offset: data.total > params.offset + data.items.length + ? params.offset + data.items.length + : undefined + }; + + return JSON.stringify(response, null, 2); +} +``` + +## Character Limits and Truncation + +Add a CHARACTER_LIMIT constant to prevent overwhelming responses: + +```typescript +// At module level in constants.ts +export const CHARACTER_LIMIT = 25000; // Maximum response size in characters + +async function searchTool(params: SearchInput) { + let result = generateResponse(data); + + // Check character limit and truncate if needed + if (result.length > CHARACTER_LIMIT) { + const truncatedData = data.slice(0, Math.max(1, data.length / 2)); + response.data = truncatedData; + response.truncated = true; + response.truncation_message = + `Response truncated from ${data.length} to ${truncatedData.length} items. ` + + `Use 'offset' parameter or add filters to see more results.`; + result = JSON.stringify(response, null, 2); + } + + return result; +} +``` + +## Error Handling + +Provide clear, actionable error messages: + +```typescript +import axios, { AxiosError } from "axios"; + +function handleApiError(error: unknown): string { + if (error instanceof AxiosError) { + if (error.response) { + switch (error.response.status) { + case 404: + return "Error: Resource not found. Please check the ID is correct."; + case 403: + return "Error: Permission denied. You don't have access to this resource."; + case 429: + return "Error: Rate limit exceeded. Please wait before making more requests."; + default: + return `Error: API request failed with status ${error.response.status}`; + } + } else if (error.code === "ECONNABORTED") { + return "Error: Request timed out. Please try again."; + } + } + return `Error: Unexpected error occurred: ${error instanceof Error ? error.message : String(error)}`; +} +``` + +## Shared Utilities + +Extract common functionality into reusable functions: + +```typescript +// Shared API request function +async function makeApiRequest( + endpoint: string, + method: "GET" | "POST" | "PUT" | "DELETE" = "GET", + data?: any, + params?: any +): Promise { + try { + const response = await axios({ + method, + url: `${API_BASE_URL}/${endpoint}`, + data, + params, + timeout: 30000, + headers: { + "Content-Type": "application/json", + "Accept": "application/json" + } + }); + return response.data; + } catch (error) { + throw error; + } +} +``` + +## Async/Await Best Practices + +Always use async/await for network requests and I/O operations: + +```typescript +// Good: Async network request +async function fetchData(resourceId: string): Promise { + const response = await axios.get(`${API_URL}/resource/${resourceId}`); + return response.data; +} + +// Bad: Promise chains +function fetchData(resourceId: string): Promise { + return axios.get(`${API_URL}/resource/${resourceId}`) + .then(response => response.data); // Harder to read and maintain +} +``` + +## TypeScript Best Practices + +1. **Use Strict TypeScript**: Enable strict mode in tsconfig.json +2. **Define Interfaces**: Create clear interface definitions for all data structures +3. **Avoid `any`**: Use proper types or `unknown` instead of `any` +4. **Zod for Runtime Validation**: Use Zod schemas to validate external data +5. **Type Guards**: Create type guard functions for complex type checking +6. **Error Handling**: Always use try-catch with proper error type checking +7. **Null Safety**: Use optional chaining (`?.`) and nullish coalescing (`??`) + +```typescript +// Good: Type-safe with Zod and interfaces +interface UserResponse { + id: string; + name: string; + email: string; + team?: string; + active: boolean; +} + +const UserSchema = z.object({ + id: z.string(), + name: z.string(), + email: z.string().email(), + team: z.string().optional(), + active: z.boolean() +}); + +type User = z.infer; + +async function getUser(id: string): Promise { + const data = await apiCall(`/users/${id}`); + return UserSchema.parse(data); // Runtime validation +} + +// Bad: Using any +async function getUser(id: string): Promise { + return await apiCall(`/users/${id}`); // No type safety +} +``` + +## Package Configuration + +### package.json + +```json +{ + "name": "{service}-mcp-server", + "version": "1.0.0", + "description": "MCP server for {Service} API integration", + "type": "module", + "main": "dist/index.js", + "scripts": { + "start": "node dist/index.js", + "dev": "tsx watch src/index.ts", + "build": "tsc", + "clean": "rm -rf dist" + }, + "engines": { + "node": ">=18" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.6.1", + "axios": "^1.7.9", + "zod": "^3.23.8" + }, + "devDependencies": { + "@types/node": "^22.10.0", + "tsx": "^4.19.2", + "typescript": "^5.7.2" + } +} +``` + +### tsconfig.json + +```json +{ + "compilerOptions": { + "target": "ES2022", + "module": "Node16", + "moduleResolution": "Node16", + "lib": ["ES2022"], + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "allowSyntheticDefaultImports": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} +``` + +## Complete Example + +```typescript +#!/usr/bin/env node +/** + * MCP Server for Example Service. + * + * This server provides tools to interact with Example API, including user search, + * project management, and data export capabilities. + */ + +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { z } from "zod"; +import axios, { AxiosError } from "axios"; + +// Constants +const API_BASE_URL = "https://api.example.com/v1"; +const CHARACTER_LIMIT = 25000; + +// Enums +enum ResponseFormat { + MARKDOWN = "markdown", + JSON = "json" +} + +// Zod schemas +const UserSearchInputSchema = z.object({ + query: z.string() + .min(2, "Query must be at least 2 characters") + .max(200, "Query must not exceed 200 characters") + .describe("Search string to match against names/emails"), + limit: z.number() + .int() + .min(1) + .max(100) + .default(20) + .describe("Maximum results to return"), + offset: z.number() + .int() + .min(0) + .default(0) + .describe("Number of results to skip for pagination"), + response_format: z.nativeEnum(ResponseFormat) + .default(ResponseFormat.MARKDOWN) + .describe("Output format: 'markdown' for human-readable or 'json' for machine-readable") +}).strict(); + +type UserSearchInput = z.infer; + +// Shared utility functions +async function makeApiRequest( + endpoint: string, + method: "GET" | "POST" | "PUT" | "DELETE" = "GET", + data?: any, + params?: any +): Promise { + try { + const response = await axios({ + method, + url: `${API_BASE_URL}/${endpoint}`, + data, + params, + timeout: 30000, + headers: { + "Content-Type": "application/json", + "Accept": "application/json" + } + }); + return response.data; + } catch (error) { + throw error; + } +} + +function handleApiError(error: unknown): string { + if (error instanceof AxiosError) { + if (error.response) { + switch (error.response.status) { + case 404: + return "Error: Resource not found. Please check the ID is correct."; + case 403: + return "Error: Permission denied. You don't have access to this resource."; + case 429: + return "Error: Rate limit exceeded. Please wait before making more requests."; + default: + return `Error: API request failed with status ${error.response.status}`; + } + } else if (error.code === "ECONNABORTED") { + return "Error: Request timed out. Please try again."; + } + } + return `Error: Unexpected error occurred: ${error instanceof Error ? error.message : String(error)}`; +} + +// Create MCP server instance +const server = new McpServer({ + name: "example-mcp", + version: "1.0.0" +}); + +// Register tools +server.registerTool( + "example_search_users", + { + title: "Search Example Users", + description: `[Full description as shown above]`, + inputSchema: UserSearchInputSchema, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true + } + }, + async (params: UserSearchInput) => { + // Implementation as shown above + } +); + +// Main function +async function main() { + // Verify environment variables if needed + if (!process.env.EXAMPLE_API_KEY) { + console.error("ERROR: EXAMPLE_API_KEY environment variable is required"); + process.exit(1); + } + + // Create transport + const transport = new StdioServerTransport(); + + // Connect server to transport + await server.connect(transport); + + console.error("Example MCP server running via stdio"); +} + +// Run the server +main().catch((error) => { + console.error("Server error:", error); + process.exit(1); +}); +``` + +--- + +## Advanced MCP Features + +### Resource Registration + +Expose data as resources for efficient, URI-based access: + +```typescript +import { ResourceTemplate } from "@modelcontextprotocol/sdk/types.js"; + +// Register a resource with URI template +server.registerResource( + { + uri: "file://documents/{name}", + name: "Document Resource", + description: "Access documents by name", + mimeType: "text/plain" + }, + async (uri: string) => { + // Extract parameter from URI + const match = uri.match(/^file:\/\/documents\/(.+)$/); + if (!match) { + throw new Error("Invalid URI format"); + } + + const documentName = match[1]; + const content = await loadDocument(documentName); + + return { + contents: [{ + uri, + mimeType: "text/plain", + text: content + }] + }; + } +); + +// List available resources dynamically +server.registerResourceList(async () => { + const documents = await getAvailableDocuments(); + return { + resources: documents.map(doc => ({ + uri: `file://documents/${doc.name}`, + name: doc.name, + mimeType: "text/plain", + description: doc.description + })) + }; +}); +``` + +**When to use Resources vs Tools:** +- **Resources**: For data access with simple URI-based parameters +- **Tools**: For complex operations requiring validation and business logic +- **Resources**: When data is relatively static or template-based +- **Tools**: When operations have side effects or complex workflows + +### Multiple Transport Options + +The TypeScript SDK supports different transport mechanisms: + +```typescript +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js"; + +// Stdio transport (default - for CLI tools) +const stdioTransport = new StdioServerTransport(); +await server.connect(stdioTransport); + +// SSE transport (for real-time web updates) +const sseTransport = new SSEServerTransport("/message", response); +await server.connect(sseTransport); + +// HTTP transport (for web services) +// Configure based on your HTTP framework integration +``` + +**Transport selection guide:** +- **Stdio**: Command-line tools, subprocess integration, local development +- **HTTP**: Web services, remote access, multiple simultaneous clients +- **SSE**: Real-time updates, server-push notifications, web dashboards + +### Notification Support + +Notify clients when server state changes: + +```typescript +// Notify when tools list changes +server.notification({ + method: "notifications/tools/list_changed" +}); + +// Notify when resources change +server.notification({ + method: "notifications/resources/list_changed" +}); +``` + +Use notifications sparingly - only when server capabilities genuinely change. + +--- + +## Code Best Practices + +### Code Composability and Reusability + +Your implementation MUST prioritize composability and code reuse: + +1. **Extract Common Functionality**: + - Create reusable helper functions for operations used across multiple tools + - Build shared API clients for HTTP requests instead of duplicating code + - Centralize error handling logic in utility functions + - Extract business logic into dedicated functions that can be composed + - Extract shared markdown or JSON field selection & formatting functionality + +2. **Avoid Duplication**: + - NEVER copy-paste similar code between tools + - If you find yourself writing similar logic twice, extract it into a function + - Common operations like pagination, filtering, field selection, and formatting should be shared + - Authentication/authorization logic should be centralized + +## Building and Running + +Always build your TypeScript code before running: + +```bash +# Build the project +npm run build + +# Run the server +npm start + +# Development with auto-reload +npm run dev +``` + +Always ensure `npm run build` completes successfully before considering the implementation complete. + +## Quality Checklist + +Before finalizing your Node/TypeScript MCP server implementation, ensure: + +### Strategic Design +- [ ] Tools enable complete workflows, not just API endpoint wrappers +- [ ] Tool names reflect natural task subdivisions +- [ ] Response formats optimize for agent context efficiency +- [ ] Human-readable identifiers used where appropriate +- [ ] Error messages guide agents toward correct usage + +### Implementation Quality +- [ ] FOCUSED IMPLEMENTATION: Most important and valuable tools implemented +- [ ] All tools registered using `registerTool` with complete configuration +- [ ] All tools include `title`, `description`, `inputSchema`, and `annotations` +- [ ] Annotations correctly set (readOnlyHint, destructiveHint, idempotentHint, openWorldHint) +- [ ] All tools use Zod schemas for runtime input validation with `.strict()` enforcement +- [ ] All Zod schemas have proper constraints and descriptive error messages +- [ ] All tools have comprehensive descriptions with explicit input/output types +- [ ] Descriptions include return value examples and complete schema documentation +- [ ] Error messages are clear, actionable, and educational + +### TypeScript Quality +- [ ] TypeScript interfaces are defined for all data structures +- [ ] Strict TypeScript is enabled in tsconfig.json +- [ ] No use of `any` type - use `unknown` or proper types instead +- [ ] All async functions have explicit Promise return types +- [ ] Error handling uses proper type guards (e.g., `axios.isAxiosError`, `z.ZodError`) + +### Advanced Features (where applicable) +- [ ] Resources registered for appropriate data endpoints +- [ ] Appropriate transport configured (stdio, HTTP, SSE) +- [ ] Notifications implemented for dynamic server capabilities +- [ ] Type-safe with SDK interfaces + +### Project Configuration +- [ ] Package.json includes all necessary dependencies +- [ ] Build script produces working JavaScript in dist/ directory +- [ ] Main entry point is properly configured as dist/index.js +- [ ] Server name follows format: `{service}-mcp-server` +- [ ] tsconfig.json properly configured with strict mode + +### Code Quality +- [ ] Pagination is properly implemented where applicable +- [ ] Large responses check CHARACTER_LIMIT constant and truncate with clear messages +- [ ] Filtering options are provided for potentially large result sets +- [ ] All network operations handle timeouts and connection errors gracefully +- [ ] Common functionality is extracted into reusable functions +- [ ] Return types are consistent across similar operations + +### Testing and Build +- [ ] `npm run build` completes successfully without errors +- [ ] dist/index.js created and executable +- [ ] Server runs: `node dist/index.js --help` +- [ ] All imports resolve correctly +- [ ] Sample tool calls work as expected \ No newline at end of file diff --git a/.claude/skills/mcp-builder/reference/python_mcp_server.md b/.claude/skills/mcp-builder/reference/python_mcp_server.md new file mode 100644 index 0000000..38fa3a1 --- /dev/null +++ b/.claude/skills/mcp-builder/reference/python_mcp_server.md @@ -0,0 +1,752 @@ +# Python MCP Server Implementation Guide + +## Overview + +This document provides Python-specific best practices and examples for implementing MCP servers using the MCP Python SDK. It covers server setup, tool registration patterns, input validation with Pydantic, error handling, and complete working examples. + +--- + +## Quick Reference + +### Key Imports +```python +from mcp.server.fastmcp import FastMCP +from pydantic import BaseModel, Field, field_validator, ConfigDict +from typing import Optional, List, Dict, Any +from enum import Enum +import httpx +``` + +### Server Initialization +```python +mcp = FastMCP("service_mcp") +``` + +### Tool Registration Pattern +```python +@mcp.tool(name="tool_name", annotations={...}) +async def tool_function(params: InputModel) -> str: + # Implementation + pass +``` + +--- + +## MCP Python SDK and FastMCP + +The official MCP Python SDK provides FastMCP, a high-level framework for building MCP servers. It provides: +- Automatic description and inputSchema generation from function signatures and docstrings +- Pydantic model integration for input validation +- Decorator-based tool registration with `@mcp.tool` + +**For complete SDK documentation, use WebFetch to load:** +`https://raw.githubusercontent.com/modelcontextprotocol/python-sdk/main/README.md` + +## Server Naming Convention + +Python MCP servers must follow this naming pattern: +- **Format**: `{service}_mcp` (lowercase with underscores) +- **Examples**: `github_mcp`, `jira_mcp`, `stripe_mcp` + +The name should be: +- General (not tied to specific features) +- Descriptive of the service/API being integrated +- Easy to infer from the task description +- Without version numbers or dates + +## Tool Implementation + +### Tool Naming + +Use snake_case for tool names (e.g., "search_users", "create_project", "get_channel_info") with clear, action-oriented names. + +**Avoid Naming Conflicts**: Include the service context to prevent overlaps: +- Use "slack_send_message" instead of just "send_message" +- Use "github_create_issue" instead of just "create_issue" +- Use "asana_list_tasks" instead of just "list_tasks" + +### Tool Structure with FastMCP + +Tools are defined using the `@mcp.tool` decorator with Pydantic models for input validation: + +```python +from pydantic import BaseModel, Field, ConfigDict +from mcp.server.fastmcp import FastMCP + +# Initialize the MCP server +mcp = FastMCP("example_mcp") + +# Define Pydantic model for input validation +class ServiceToolInput(BaseModel): + '''Input model for service tool operation.''' + model_config = ConfigDict( + str_strip_whitespace=True, # Auto-strip whitespace from strings + validate_assignment=True, # Validate on assignment + extra='forbid' # Forbid extra fields + ) + + param1: str = Field(..., description="First parameter description (e.g., 'user123', 'project-abc')", min_length=1, max_length=100) + param2: Optional[int] = Field(default=None, description="Optional integer parameter with constraints", ge=0, le=1000) + tags: Optional[List[str]] = Field(default_factory=list, description="List of tags to apply", max_items=10) + +@mcp.tool( + name="service_tool_name", + annotations={ + "title": "Human-Readable Tool Title", + "readOnlyHint": True, # Tool does not modify environment + "destructiveHint": False, # Tool does not perform destructive operations + "idempotentHint": True, # Repeated calls have no additional effect + "openWorldHint": False # Tool does not interact with external entities + } +) +async def service_tool_name(params: ServiceToolInput) -> str: + '''Tool description automatically becomes the 'description' field. + + This tool performs a specific operation on the service. It validates all inputs + using the ServiceToolInput Pydantic model before processing. + + Args: + params (ServiceToolInput): Validated input parameters containing: + - param1 (str): First parameter description + - param2 (Optional[int]): Optional parameter with default + - tags (Optional[List[str]]): List of tags + + Returns: + str: JSON-formatted response containing operation results + ''' + # Implementation here + pass +``` + +## Pydantic v2 Key Features + +- Use `model_config` instead of nested `Config` class +- Use `field_validator` instead of deprecated `validator` +- Use `model_dump()` instead of deprecated `dict()` +- Validators require `@classmethod` decorator +- Type hints are required for validator methods + +```python +from pydantic import BaseModel, Field, field_validator, ConfigDict + +class CreateUserInput(BaseModel): + model_config = ConfigDict( + str_strip_whitespace=True, + validate_assignment=True + ) + + name: str = Field(..., description="User's full name", min_length=1, max_length=100) + email: str = Field(..., description="User's email address", pattern=r'^[\w\.-]+@[\w\.-]+\.\w+$') + age: int = Field(..., description="User's age", ge=0, le=150) + + @field_validator('email') + @classmethod + def validate_email(cls, v: str) -> str: + if not v.strip(): + raise ValueError("Email cannot be empty") + return v.lower() +``` + +## Response Format Options + +Support multiple output formats for flexibility: + +```python +from enum import Enum + +class ResponseFormat(str, Enum): + '''Output format for tool responses.''' + MARKDOWN = "markdown" + JSON = "json" + +class UserSearchInput(BaseModel): + query: str = Field(..., description="Search query") + response_format: ResponseFormat = Field( + default=ResponseFormat.MARKDOWN, + description="Output format: 'markdown' for human-readable or 'json' for machine-readable" + ) +``` + +**Markdown format**: +- Use headers, lists, and formatting for clarity +- Convert timestamps to human-readable format (e.g., "2024-01-15 10:30:00 UTC" instead of epoch) +- Show display names with IDs in parentheses (e.g., "@john.doe (U123456)") +- Omit verbose metadata (e.g., show only one profile image URL, not all sizes) +- Group related information logically + +**JSON format**: +- Return complete, structured data suitable for programmatic processing +- Include all available fields and metadata +- Use consistent field names and types + +## Pagination Implementation + +For tools that list resources: + +```python +class ListInput(BaseModel): + limit: Optional[int] = Field(default=20, description="Maximum results to return", ge=1, le=100) + offset: Optional[int] = Field(default=0, description="Number of results to skip for pagination", ge=0) + +async def list_items(params: ListInput) -> str: + # Make API request with pagination + data = await api_request(limit=params.limit, offset=params.offset) + + # Return pagination info + response = { + "total": data["total"], + "count": len(data["items"]), + "offset": params.offset, + "items": data["items"], + "has_more": data["total"] > params.offset + len(data["items"]), + "next_offset": params.offset + len(data["items"]) if data["total"] > params.offset + len(data["items"]) else None + } + return json.dumps(response, indent=2) +``` + +## Character Limits and Truncation + +Add a CHARACTER_LIMIT constant to prevent overwhelming responses: + +```python +# At module level +CHARACTER_LIMIT = 25000 # Maximum response size in characters + +async def search_tool(params: SearchInput) -> str: + result = generate_response(data) + + # Check character limit and truncate if needed + if len(result) > CHARACTER_LIMIT: + # Truncate data and add notice + truncated_data = data[:max(1, len(data) // 2)] + response["data"] = truncated_data + response["truncated"] = True + response["truncation_message"] = ( + f"Response truncated from {len(data)} to {len(truncated_data)} items. " + f"Use 'offset' parameter or add filters to see more results." + ) + result = json.dumps(response, indent=2) + + return result +``` + +## Error Handling + +Provide clear, actionable error messages: + +```python +def _handle_api_error(e: Exception) -> str: + '''Consistent error formatting across all tools.''' + if isinstance(e, httpx.HTTPStatusError): + if e.response.status_code == 404: + return "Error: Resource not found. Please check the ID is correct." + elif e.response.status_code == 403: + return "Error: Permission denied. You don't have access to this resource." + elif e.response.status_code == 429: + return "Error: Rate limit exceeded. Please wait before making more requests." + return f"Error: API request failed with status {e.response.status_code}" + elif isinstance(e, httpx.TimeoutException): + return "Error: Request timed out. Please try again." + return f"Error: Unexpected error occurred: {type(e).__name__}" +``` + +## Shared Utilities + +Extract common functionality into reusable functions: + +```python +# Shared API request function +async def _make_api_request(endpoint: str, method: str = "GET", **kwargs) -> dict: + '''Reusable function for all API calls.''' + async with httpx.AsyncClient() as client: + response = await client.request( + method, + f"{API_BASE_URL}/{endpoint}", + timeout=30.0, + **kwargs + ) + response.raise_for_status() + return response.json() +``` + +## Async/Await Best Practices + +Always use async/await for network requests and I/O operations: + +```python +# Good: Async network request +async def fetch_data(resource_id: str) -> dict: + async with httpx.AsyncClient() as client: + response = await client.get(f"{API_URL}/resource/{resource_id}") + response.raise_for_status() + return response.json() + +# Bad: Synchronous request +def fetch_data(resource_id: str) -> dict: + response = requests.get(f"{API_URL}/resource/{resource_id}") # Blocks + return response.json() +``` + +## Type Hints + +Use type hints throughout: + +```python +from typing import Optional, List, Dict, Any + +async def get_user(user_id: str) -> Dict[str, Any]: + data = await fetch_user(user_id) + return {"id": data["id"], "name": data["name"]} +``` + +## Tool Docstrings + +Every tool must have comprehensive docstrings with explicit type information: + +```python +async def search_users(params: UserSearchInput) -> str: + ''' + Search for users in the Example system by name, email, or team. + + This tool searches across all user profiles in the Example platform, + supporting partial matches and various search filters. It does NOT + create or modify users, only searches existing ones. + + Args: + params (UserSearchInput): Validated input parameters containing: + - query (str): Search string to match against names/emails (e.g., "john", "@example.com", "team:marketing") + - limit (Optional[int]): Maximum results to return, between 1-100 (default: 20) + - offset (Optional[int]): Number of results to skip for pagination (default: 0) + + Returns: + str: JSON-formatted string containing search results with the following schema: + + Success response: + { + "total": int, # Total number of matches found + "count": int, # Number of results in this response + "offset": int, # Current pagination offset + "users": [ + { + "id": str, # User ID (e.g., "U123456789") + "name": str, # Full name (e.g., "John Doe") + "email": str, # Email address (e.g., "john@example.com") + "team": str # Team name (e.g., "Marketing") - optional + } + ] + } + + Error response: + "Error: " or "No users found matching ''" + + Examples: + - Use when: "Find all marketing team members" -> params with query="team:marketing" + - Use when: "Search for John's account" -> params with query="john" + - Don't use when: You need to create a user (use example_create_user instead) + - Don't use when: You have a user ID and need full details (use example_get_user instead) + + Error Handling: + - Input validation errors are handled by Pydantic model + - Returns "Error: Rate limit exceeded" if too many requests (429 status) + - Returns "Error: Invalid API authentication" if API key is invalid (401 status) + - Returns formatted list of results or "No users found matching 'query'" + ''' +``` + +## Complete Example + +See below for a complete Python MCP server example: + +```python +#!/usr/bin/env python3 +''' +MCP Server for Example Service. + +This server provides tools to interact with Example API, including user search, +project management, and data export capabilities. +''' + +from typing import Optional, List, Dict, Any +from enum import Enum +import httpx +from pydantic import BaseModel, Field, field_validator, ConfigDict +from mcp.server.fastmcp import FastMCP + +# Initialize the MCP server +mcp = FastMCP("example_mcp") + +# Constants +API_BASE_URL = "https://api.example.com/v1" +CHARACTER_LIMIT = 25000 # Maximum response size in characters + +# Enums +class ResponseFormat(str, Enum): + '''Output format for tool responses.''' + MARKDOWN = "markdown" + JSON = "json" + +# Pydantic Models for Input Validation +class UserSearchInput(BaseModel): + '''Input model for user search operations.''' + model_config = ConfigDict( + str_strip_whitespace=True, + validate_assignment=True + ) + + query: str = Field(..., description="Search string to match against names/emails", min_length=2, max_length=200) + limit: Optional[int] = Field(default=20, description="Maximum results to return", ge=1, le=100) + offset: Optional[int] = Field(default=0, description="Number of results to skip for pagination", ge=0) + response_format: ResponseFormat = Field(default=ResponseFormat.MARKDOWN, description="Output format") + + @field_validator('query') + @classmethod + def validate_query(cls, v: str) -> str: + if not v.strip(): + raise ValueError("Query cannot be empty or whitespace only") + return v.strip() + +# Shared utility functions +async def _make_api_request(endpoint: str, method: str = "GET", **kwargs) -> dict: + '''Reusable function for all API calls.''' + async with httpx.AsyncClient() as client: + response = await client.request( + method, + f"{API_BASE_URL}/{endpoint}", + timeout=30.0, + **kwargs + ) + response.raise_for_status() + return response.json() + +def _handle_api_error(e: Exception) -> str: + '''Consistent error formatting across all tools.''' + if isinstance(e, httpx.HTTPStatusError): + if e.response.status_code == 404: + return "Error: Resource not found. Please check the ID is correct." + elif e.response.status_code == 403: + return "Error: Permission denied. You don't have access to this resource." + elif e.response.status_code == 429: + return "Error: Rate limit exceeded. Please wait before making more requests." + return f"Error: API request failed with status {e.response.status_code}" + elif isinstance(e, httpx.TimeoutException): + return "Error: Request timed out. Please try again." + return f"Error: Unexpected error occurred: {type(e).__name__}" + +# Tool definitions +@mcp.tool( + name="example_search_users", + annotations={ + "title": "Search Example Users", + "readOnlyHint": True, + "destructiveHint": False, + "idempotentHint": True, + "openWorldHint": True + } +) +async def example_search_users(params: UserSearchInput) -> str: + '''Search for users in the Example system by name, email, or team. + + [Full docstring as shown above] + ''' + try: + # Make API request using validated parameters + data = await _make_api_request( + "users/search", + params={ + "q": params.query, + "limit": params.limit, + "offset": params.offset + } + ) + + users = data.get("users", []) + total = data.get("total", 0) + + if not users: + return f"No users found matching '{params.query}'" + + # Format response based on requested format + if params.response_format == ResponseFormat.MARKDOWN: + lines = [f"# User Search Results: '{params.query}'", ""] + lines.append(f"Found {total} users (showing {len(users)})") + lines.append("") + + for user in users: + lines.append(f"## {user['name']} ({user['id']})") + lines.append(f"- **Email**: {user['email']}") + if user.get('team'): + lines.append(f"- **Team**: {user['team']}") + lines.append("") + + return "\n".join(lines) + + else: + # Machine-readable JSON format + import json + response = { + "total": total, + "count": len(users), + "offset": params.offset, + "users": users + } + return json.dumps(response, indent=2) + + except Exception as e: + return _handle_api_error(e) + +if __name__ == "__main__": + mcp.run() +``` + +--- + +## Advanced FastMCP Features + +### Context Parameter Injection + +FastMCP can automatically inject a `Context` parameter into tools for advanced capabilities like logging, progress reporting, resource reading, and user interaction: + +```python +from mcp.server.fastmcp import FastMCP, Context + +mcp = FastMCP("example_mcp") + +@mcp.tool() +async def advanced_search(query: str, ctx: Context) -> str: + '''Advanced tool with context access for logging and progress.''' + + # Report progress for long operations + await ctx.report_progress(0.25, "Starting search...") + + # Log information for debugging + await ctx.log_info("Processing query", {"query": query, "timestamp": datetime.now()}) + + # Perform search + results = await search_api(query) + await ctx.report_progress(0.75, "Formatting results...") + + # Access server configuration + server_name = ctx.fastmcp.name + + return format_results(results) + +@mcp.tool() +async def interactive_tool(resource_id: str, ctx: Context) -> str: + '''Tool that can request additional input from users.''' + + # Request sensitive information when needed + api_key = await ctx.elicit( + prompt="Please provide your API key:", + input_type="password" + ) + + # Use the provided key + return await api_call(resource_id, api_key) +``` + +**Context capabilities:** +- `ctx.report_progress(progress, message)` - Report progress for long operations +- `ctx.log_info(message, data)` / `ctx.log_error()` / `ctx.log_debug()` - Logging +- `ctx.elicit(prompt, input_type)` - Request input from users +- `ctx.fastmcp.name` - Access server configuration +- `ctx.read_resource(uri)` - Read MCP resources + +### Resource Registration + +Expose data as resources for efficient, template-based access: + +```python +@mcp.resource("file://documents/{name}") +async def get_document(name: str) -> str: + '''Expose documents as MCP resources. + + Resources are useful for static or semi-static data that doesn't + require complex parameters. They use URI templates for flexible access. + ''' + document_path = f"./docs/{name}" + with open(document_path, "r") as f: + return f.read() + +@mcp.resource("config://settings/{key}") +async def get_setting(key: str, ctx: Context) -> str: + '''Expose configuration as resources with context.''' + settings = await load_settings() + return json.dumps(settings.get(key, {})) +``` + +**When to use Resources vs Tools:** +- **Resources**: For data access with simple parameters (URI templates) +- **Tools**: For complex operations with validation and business logic + +### Structured Output Types + +FastMCP supports multiple return types beyond strings: + +```python +from typing import TypedDict +from dataclasses import dataclass +from pydantic import BaseModel + +# TypedDict for structured returns +class UserData(TypedDict): + id: str + name: str + email: str + +@mcp.tool() +async def get_user_typed(user_id: str) -> UserData: + '''Returns structured data - FastMCP handles serialization.''' + return {"id": user_id, "name": "John Doe", "email": "john@example.com"} + +# Pydantic models for complex validation +class DetailedUser(BaseModel): + id: str + name: str + email: str + created_at: datetime + metadata: Dict[str, Any] + +@mcp.tool() +async def get_user_detailed(user_id: str) -> DetailedUser: + '''Returns Pydantic model - automatically generates schema.''' + user = await fetch_user(user_id) + return DetailedUser(**user) +``` + +### Lifespan Management + +Initialize resources that persist across requests: + +```python +from contextlib import asynccontextmanager + +@asynccontextmanager +async def app_lifespan(): + '''Manage resources that live for the server's lifetime.''' + # Initialize connections, load config, etc. + db = await connect_to_database() + config = load_configuration() + + # Make available to all tools + yield {"db": db, "config": config} + + # Cleanup on shutdown + await db.close() + +mcp = FastMCP("example_mcp", lifespan=app_lifespan) + +@mcp.tool() +async def query_data(query: str, ctx: Context) -> str: + '''Access lifespan resources through context.''' + db = ctx.request_context.lifespan_state["db"] + results = await db.query(query) + return format_results(results) +``` + +### Multiple Transport Options + +FastMCP supports different transport mechanisms: + +```python +# Default: Stdio transport (for CLI tools) +if __name__ == "__main__": + mcp.run() + +# HTTP transport (for web services) +if __name__ == "__main__": + mcp.run(transport="streamable_http", port=8000) + +# SSE transport (for real-time updates) +if __name__ == "__main__": + mcp.run(transport="sse", port=8000) +``` + +**Transport selection:** +- **Stdio**: Command-line tools, subprocess integration +- **HTTP**: Web services, remote access, multiple clients +- **SSE**: Real-time updates, push notifications + +--- + +## Code Best Practices + +### Code Composability and Reusability + +Your implementation MUST prioritize composability and code reuse: + +1. **Extract Common Functionality**: + - Create reusable helper functions for operations used across multiple tools + - Build shared API clients for HTTP requests instead of duplicating code + - Centralize error handling logic in utility functions + - Extract business logic into dedicated functions that can be composed + - Extract shared markdown or JSON field selection & formatting functionality + +2. **Avoid Duplication**: + - NEVER copy-paste similar code between tools + - If you find yourself writing similar logic twice, extract it into a function + - Common operations like pagination, filtering, field selection, and formatting should be shared + - Authentication/authorization logic should be centralized + +### Python-Specific Best Practices + +1. **Use Type Hints**: Always include type annotations for function parameters and return values +2. **Pydantic Models**: Define clear Pydantic models for all input validation +3. **Avoid Manual Validation**: Let Pydantic handle input validation with constraints +4. **Proper Imports**: Group imports (standard library, third-party, local) +5. **Error Handling**: Use specific exception types (httpx.HTTPStatusError, not generic Exception) +6. **Async Context Managers**: Use `async with` for resources that need cleanup +7. **Constants**: Define module-level constants in UPPER_CASE + +## Quality Checklist + +Before finalizing your Python MCP server implementation, ensure: + +### Strategic Design +- [ ] Tools enable complete workflows, not just API endpoint wrappers +- [ ] Tool names reflect natural task subdivisions +- [ ] Response formats optimize for agent context efficiency +- [ ] Human-readable identifiers used where appropriate +- [ ] Error messages guide agents toward correct usage + +### Implementation Quality +- [ ] FOCUSED IMPLEMENTATION: Most important and valuable tools implemented +- [ ] All tools have descriptive names and documentation +- [ ] Return types are consistent across similar operations +- [ ] Error handling is implemented for all external calls +- [ ] Server name follows format: `{service}_mcp` +- [ ] All network operations use async/await +- [ ] Common functionality is extracted into reusable functions +- [ ] Error messages are clear, actionable, and educational +- [ ] Outputs are properly validated and formatted + +### Tool Configuration +- [ ] All tools implement 'name' and 'annotations' in the decorator +- [ ] Annotations correctly set (readOnlyHint, destructiveHint, idempotentHint, openWorldHint) +- [ ] All tools use Pydantic BaseModel for input validation with Field() definitions +- [ ] All Pydantic Fields have explicit types and descriptions with constraints +- [ ] All tools have comprehensive docstrings with explicit input/output types +- [ ] Docstrings include complete schema structure for dict/JSON returns +- [ ] Pydantic models handle input validation (no manual validation needed) + +### Advanced Features (where applicable) +- [ ] Context injection used for logging, progress, or elicitation +- [ ] Resources registered for appropriate data endpoints +- [ ] Lifespan management implemented for persistent connections +- [ ] Structured output types used (TypedDict, Pydantic models) +- [ ] Appropriate transport configured (stdio, HTTP, SSE) + +### Code Quality +- [ ] File includes proper imports including Pydantic imports +- [ ] Pagination is properly implemented where applicable +- [ ] Large responses check CHARACTER_LIMIT and truncate with clear messages +- [ ] Filtering options are provided for potentially large result sets +- [ ] All async functions are properly defined with `async def` +- [ ] HTTP client usage follows async patterns with proper context managers +- [ ] Type hints are used throughout the code +- [ ] Constants are defined at module level in UPPER_CASE + +### Testing +- [ ] Server runs successfully: `python your_server.py --help` +- [ ] All imports resolve correctly +- [ ] Sample tool calls work as expected +- [ ] Error scenarios handled gracefully \ No newline at end of file diff --git a/.claude/skills/mcp-builder/scripts/connections.py b/.claude/skills/mcp-builder/scripts/connections.py new file mode 100644 index 0000000..ffcd0da --- /dev/null +++ b/.claude/skills/mcp-builder/scripts/connections.py @@ -0,0 +1,151 @@ +"""Lightweight connection handling for MCP servers.""" + +from abc import ABC, abstractmethod +from contextlib import AsyncExitStack +from typing import Any + +from mcp import ClientSession, StdioServerParameters +from mcp.client.sse import sse_client +from mcp.client.stdio import stdio_client +from mcp.client.streamable_http import streamablehttp_client + + +class MCPConnection(ABC): + """Base class for MCP server connections.""" + + def __init__(self): + self.session = None + self._stack = None + + @abstractmethod + def _create_context(self): + """Create the connection context based on connection type.""" + + async def __aenter__(self): + """Initialize MCP server connection.""" + self._stack = AsyncExitStack() + await self._stack.__aenter__() + + try: + ctx = self._create_context() + result = await self._stack.enter_async_context(ctx) + + if len(result) == 2: + read, write = result + elif len(result) == 3: + read, write, _ = result + else: + raise ValueError(f"Unexpected context result: {result}") + + session_ctx = ClientSession(read, write) + self.session = await self._stack.enter_async_context(session_ctx) + await self.session.initialize() + return self + except BaseException: + await self._stack.__aexit__(None, None, None) + raise + + async def __aexit__(self, exc_type, exc_val, exc_tb): + """Clean up MCP server connection resources.""" + if self._stack: + await self._stack.__aexit__(exc_type, exc_val, exc_tb) + self.session = None + self._stack = None + + async def list_tools(self) -> list[dict[str, Any]]: + """Retrieve available tools from the MCP server.""" + response = await self.session.list_tools() + return [ + { + "name": tool.name, + "description": tool.description, + "input_schema": tool.inputSchema, + } + for tool in response.tools + ] + + async def call_tool(self, tool_name: str, arguments: dict[str, Any]) -> Any: + """Call a tool on the MCP server with provided arguments.""" + result = await self.session.call_tool(tool_name, arguments=arguments) + return result.content + + +class MCPConnectionStdio(MCPConnection): + """MCP connection using standard input/output.""" + + def __init__(self, command: str, args: list[str] = None, env: dict[str, str] = None): + super().__init__() + self.command = command + self.args = args or [] + self.env = env + + def _create_context(self): + return stdio_client( + StdioServerParameters(command=self.command, args=self.args, env=self.env) + ) + + +class MCPConnectionSSE(MCPConnection): + """MCP connection using Server-Sent Events.""" + + def __init__(self, url: str, headers: dict[str, str] = None): + super().__init__() + self.url = url + self.headers = headers or {} + + def _create_context(self): + return sse_client(url=self.url, headers=self.headers) + + +class MCPConnectionHTTP(MCPConnection): + """MCP connection using Streamable HTTP.""" + + def __init__(self, url: str, headers: dict[str, str] = None): + super().__init__() + self.url = url + self.headers = headers or {} + + def _create_context(self): + return streamablehttp_client(url=self.url, headers=self.headers) + + +def create_connection( + transport: str, + command: str = None, + args: list[str] = None, + env: dict[str, str] = None, + url: str = None, + headers: dict[str, str] = None, +) -> MCPConnection: + """Factory function to create the appropriate MCP connection. + + Args: + transport: Connection type ("stdio", "sse", or "http") + command: Command to run (stdio only) + args: Command arguments (stdio only) + env: Environment variables (stdio only) + url: Server URL (sse and http only) + headers: HTTP headers (sse and http only) + + Returns: + MCPConnection instance + """ + transport = transport.lower() + + if transport == "stdio": + if not command: + raise ValueError("Command is required for stdio transport") + return MCPConnectionStdio(command=command, args=args, env=env) + + elif transport == "sse": + if not url: + raise ValueError("URL is required for sse transport") + return MCPConnectionSSE(url=url, headers=headers) + + elif transport in ["http", "streamable_http", "streamable-http"]: + if not url: + raise ValueError("URL is required for http transport") + return MCPConnectionHTTP(url=url, headers=headers) + + else: + raise ValueError(f"Unsupported transport type: {transport}. Use 'stdio', 'sse', or 'http'") diff --git a/.claude/skills/mcp-builder/scripts/evaluation.py b/.claude/skills/mcp-builder/scripts/evaluation.py new file mode 100644 index 0000000..4177856 --- /dev/null +++ b/.claude/skills/mcp-builder/scripts/evaluation.py @@ -0,0 +1,373 @@ +"""MCP Server Evaluation Harness + +This script evaluates MCP servers by running test questions against them using Claude. +""" + +import argparse +import asyncio +import json +import re +import sys +import time +import traceback +import xml.etree.ElementTree as ET +from pathlib import Path +from typing import Any + +from anthropic import Anthropic + +from connections import create_connection + +EVALUATION_PROMPT = """You are an AI assistant with access to tools. + +When given a task, you MUST: +1. Use the available tools to complete the task +2. Provide summary of each step in your approach, wrapped in tags +3. Provide feedback on the tools provided, wrapped in tags +4. Provide your final response, wrapped in tags + +Summary Requirements: +- In your tags, you must explain: + - The steps you took to complete the task + - Which tools you used, in what order, and why + - The inputs you provided to each tool + - The outputs you received from each tool + - A summary for how you arrived at the response + +Feedback Requirements: +- In your tags, provide constructive feedback on the tools: + - Comment on tool names: Are they clear and descriptive? + - Comment on input parameters: Are they well-documented? Are required vs optional parameters clear? + - Comment on descriptions: Do they accurately describe what the tool does? + - Comment on any errors encountered during tool usage: Did the tool fail to execute? Did the tool return too many tokens? + - Identify specific areas for improvement and explain WHY they would help + - Be specific and actionable in your suggestions + +Response Requirements: +- Your response should be concise and directly address what was asked +- Always wrap your final response in tags +- If you cannot solve the task return NOT_FOUND +- For numeric responses, provide just the number +- For IDs, provide just the ID +- For names or text, provide the exact text requested +- Your response should go last""" + + +def parse_evaluation_file(file_path: Path) -> list[dict[str, Any]]: + """Parse XML evaluation file with qa_pair elements.""" + try: + tree = ET.parse(file_path) + root = tree.getroot() + evaluations = [] + + for qa_pair in root.findall(".//qa_pair"): + question_elem = qa_pair.find("question") + answer_elem = qa_pair.find("answer") + + if question_elem is not None and answer_elem is not None: + evaluations.append({ + "question": (question_elem.text or "").strip(), + "answer": (answer_elem.text or "").strip(), + }) + + return evaluations + except Exception as e: + print(f"Error parsing evaluation file {file_path}: {e}") + return [] + + +def extract_xml_content(text: str, tag: str) -> str | None: + """Extract content from XML tags.""" + pattern = rf"<{tag}>(.*?)" + matches = re.findall(pattern, text, re.DOTALL) + return matches[-1].strip() if matches else None + + +async def agent_loop( + client: Anthropic, + model: str, + question: str, + tools: list[dict[str, Any]], + connection: Any, +) -> tuple[str, dict[str, Any]]: + """Run the agent loop with MCP tools.""" + messages = [{"role": "user", "content": question}] + + response = await asyncio.to_thread( + client.messages.create, + model=model, + max_tokens=4096, + system=EVALUATION_PROMPT, + messages=messages, + tools=tools, + ) + + messages.append({"role": "assistant", "content": response.content}) + + tool_metrics = {} + + while response.stop_reason == "tool_use": + tool_use = next(block for block in response.content if block.type == "tool_use") + tool_name = tool_use.name + tool_input = tool_use.input + + tool_start_ts = time.time() + try: + tool_result = await connection.call_tool(tool_name, tool_input) + tool_response = json.dumps(tool_result) if isinstance(tool_result, (dict, list)) else str(tool_result) + except Exception as e: + tool_response = f"Error executing tool {tool_name}: {str(e)}\n" + tool_response += traceback.format_exc() + tool_duration = time.time() - tool_start_ts + + if tool_name not in tool_metrics: + tool_metrics[tool_name] = {"count": 0, "durations": []} + tool_metrics[tool_name]["count"] += 1 + tool_metrics[tool_name]["durations"].append(tool_duration) + + messages.append({ + "role": "user", + "content": [{ + "type": "tool_result", + "tool_use_id": tool_use.id, + "content": tool_response, + }] + }) + + response = await asyncio.to_thread( + client.messages.create, + model=model, + max_tokens=4096, + system=EVALUATION_PROMPT, + messages=messages, + tools=tools, + ) + messages.append({"role": "assistant", "content": response.content}) + + response_text = next( + (block.text for block in response.content if hasattr(block, "text")), + None, + ) + return response_text, tool_metrics + + +async def evaluate_single_task( + client: Anthropic, + model: str, + qa_pair: dict[str, Any], + tools: list[dict[str, Any]], + connection: Any, + task_index: int, +) -> dict[str, Any]: + """Evaluate a single QA pair with the given tools.""" + start_time = time.time() + + print(f"Task {task_index + 1}: Running task with question: {qa_pair['question']}") + response, tool_metrics = await agent_loop(client, model, qa_pair["question"], tools, connection) + + response_value = extract_xml_content(response, "response") + summary = extract_xml_content(response, "summary") + feedback = extract_xml_content(response, "feedback") + + duration_seconds = time.time() - start_time + + return { + "question": qa_pair["question"], + "expected": qa_pair["answer"], + "actual": response_value, + "score": int(response_value == qa_pair["answer"]) if response_value else 0, + "total_duration": duration_seconds, + "tool_calls": tool_metrics, + "num_tool_calls": sum(len(metrics["durations"]) for metrics in tool_metrics.values()), + "summary": summary, + "feedback": feedback, + } + + +REPORT_HEADER = """ +# Evaluation Report + +## Summary + +- **Accuracy**: {correct}/{total} ({accuracy:.1f}%) +- **Average Task Duration**: {average_duration_s:.2f}s +- **Average Tool Calls per Task**: {average_tool_calls:.2f} +- **Total Tool Calls**: {total_tool_calls} + +--- +""" + +TASK_TEMPLATE = """ +### Task {task_num} + +**Question**: {question} +**Ground Truth Answer**: `{expected_answer}` +**Actual Answer**: `{actual_answer}` +**Correct**: {correct_indicator} +**Duration**: {total_duration:.2f}s +**Tool Calls**: {tool_calls} + +**Summary** +{summary} + +**Feedback** +{feedback} + +--- +""" + + +async def run_evaluation( + eval_path: Path, + connection: Any, + model: str = "claude-3-7-sonnet-20250219", +) -> str: + """Run evaluation with MCP server tools.""" + print("🚀 Starting Evaluation") + + client = Anthropic() + + tools = await connection.list_tools() + print(f"📋 Loaded {len(tools)} tools from MCP server") + + qa_pairs = parse_evaluation_file(eval_path) + print(f"📋 Loaded {len(qa_pairs)} evaluation tasks") + + results = [] + for i, qa_pair in enumerate(qa_pairs): + print(f"Processing task {i + 1}/{len(qa_pairs)}") + result = await evaluate_single_task(client, model, qa_pair, tools, connection, i) + results.append(result) + + correct = sum(r["score"] for r in results) + accuracy = (correct / len(results)) * 100 if results else 0 + average_duration_s = sum(r["total_duration"] for r in results) / len(results) if results else 0 + average_tool_calls = sum(r["num_tool_calls"] for r in results) / len(results) if results else 0 + total_tool_calls = sum(r["num_tool_calls"] for r in results) + + report = REPORT_HEADER.format( + correct=correct, + total=len(results), + accuracy=accuracy, + average_duration_s=average_duration_s, + average_tool_calls=average_tool_calls, + total_tool_calls=total_tool_calls, + ) + + report += "".join([ + TASK_TEMPLATE.format( + task_num=i + 1, + question=qa_pair["question"], + expected_answer=qa_pair["answer"], + actual_answer=result["actual"] or "N/A", + correct_indicator="✅" if result["score"] else "❌", + total_duration=result["total_duration"], + tool_calls=json.dumps(result["tool_calls"], indent=2), + summary=result["summary"] or "N/A", + feedback=result["feedback"] or "N/A", + ) + for i, (qa_pair, result) in enumerate(zip(qa_pairs, results)) + ]) + + return report + + +def parse_headers(header_list: list[str]) -> dict[str, str]: + """Parse header strings in format 'Key: Value' into a dictionary.""" + headers = {} + if not header_list: + return headers + + for header in header_list: + if ":" in header: + key, value = header.split(":", 1) + headers[key.strip()] = value.strip() + else: + print(f"Warning: Ignoring malformed header: {header}") + return headers + + +def parse_env_vars(env_list: list[str]) -> dict[str, str]: + """Parse environment variable strings in format 'KEY=VALUE' into a dictionary.""" + env = {} + if not env_list: + return env + + for env_var in env_list: + if "=" in env_var: + key, value = env_var.split("=", 1) + env[key.strip()] = value.strip() + else: + print(f"Warning: Ignoring malformed environment variable: {env_var}") + return env + + +async def main(): + parser = argparse.ArgumentParser( + description="Evaluate MCP servers using test questions", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + # Evaluate a local stdio MCP server + python evaluation.py -t stdio -c python -a my_server.py eval.xml + + # Evaluate an SSE MCP server + python evaluation.py -t sse -u https://example.com/mcp -H "Authorization: Bearer token" eval.xml + + # Evaluate an HTTP MCP server with custom model + python evaluation.py -t http -u https://example.com/mcp -m claude-3-5-sonnet-20241022 eval.xml + """, + ) + + parser.add_argument("eval_file", type=Path, help="Path to evaluation XML file") + parser.add_argument("-t", "--transport", choices=["stdio", "sse", "http"], default="stdio", help="Transport type (default: stdio)") + parser.add_argument("-m", "--model", default="claude-3-7-sonnet-20250219", help="Claude model to use (default: claude-3-7-sonnet-20250219)") + + stdio_group = parser.add_argument_group("stdio options") + stdio_group.add_argument("-c", "--command", help="Command to run MCP server (stdio only)") + stdio_group.add_argument("-a", "--args", nargs="+", help="Arguments for the command (stdio only)") + stdio_group.add_argument("-e", "--env", nargs="+", help="Environment variables in KEY=VALUE format (stdio only)") + + remote_group = parser.add_argument_group("sse/http options") + remote_group.add_argument("-u", "--url", help="MCP server URL (sse/http only)") + remote_group.add_argument("-H", "--header", nargs="+", dest="headers", help="HTTP headers in 'Key: Value' format (sse/http only)") + + parser.add_argument("-o", "--output", type=Path, help="Output file for evaluation report (default: stdout)") + + args = parser.parse_args() + + if not args.eval_file.exists(): + print(f"Error: Evaluation file not found: {args.eval_file}") + sys.exit(1) + + headers = parse_headers(args.headers) if args.headers else None + env_vars = parse_env_vars(args.env) if args.env else None + + try: + connection = create_connection( + transport=args.transport, + command=args.command, + args=args.args, + env=env_vars, + url=args.url, + headers=headers, + ) + except ValueError as e: + print(f"Error: {e}") + sys.exit(1) + + print(f"🔗 Connecting to MCP server via {args.transport}...") + + async with connection: + print("✅ Connected successfully") + report = await run_evaluation(args.eval_file, connection, args.model) + + if args.output: + args.output.write_text(report) + print(f"\n✅ Report saved to {args.output}") + else: + print("\n" + report) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/.claude/skills/mcp-builder/scripts/example_evaluation.xml b/.claude/skills/mcp-builder/scripts/example_evaluation.xml new file mode 100644 index 0000000..41e4459 --- /dev/null +++ b/.claude/skills/mcp-builder/scripts/example_evaluation.xml @@ -0,0 +1,22 @@ + + + Calculate the compound interest on $10,000 invested at 5% annual interest rate, compounded monthly for 3 years. What is the final amount in dollars (rounded to 2 decimal places)? + 11614.72 + + + A projectile is launched at a 45-degree angle with an initial velocity of 50 m/s. Calculate the total distance (in meters) it has traveled from the launch point after 2 seconds, assuming g=9.8 m/s². Round to 2 decimal places. + 87.25 + + + A sphere has a volume of 500 cubic meters. Calculate its surface area in square meters. Round to 2 decimal places. + 304.65 + + + Calculate the population standard deviation of this dataset: [12, 15, 18, 22, 25, 30, 35]. Round to 2 decimal places. + 7.61 + + + Calculate the pH of a solution with a hydrogen ion concentration of 3.5 × 10^-5 M. Round to 2 decimal places. + 4.46 + + diff --git a/.claude/skills/mcp-builder/scripts/requirements.txt b/.claude/skills/mcp-builder/scripts/requirements.txt new file mode 100644 index 0000000..e73e5d1 --- /dev/null +++ b/.claude/skills/mcp-builder/scripts/requirements.txt @@ -0,0 +1,2 @@ +anthropic>=0.39.0 +mcp>=1.1.0 diff --git a/.claude/skills/og-image/SKILL.md b/.claude/skills/og-image/SKILL.md new file mode 100644 index 0000000..d62b31c --- /dev/null +++ b/.claude/skills/og-image/SKILL.md @@ -0,0 +1,200 @@ +--- +name: og-image +description: Generate social media preview images (Open Graph) and configure meta tags. Creates a screenshot-optimized page using the project's existing design system, captures it at 1200x630, and sets up all social sharing meta tags. +--- + +This skill creates professional Open Graph images for social media sharing. It analyzes the existing codebase to match the project's design system, generates a dedicated OG image page, screenshots it, and configures all necessary meta tags. + +## Workflow + +### Phase 1: Codebase Analysis + +Explore the project to understand: + +1. **Framework Detection** + - Check `package.json` for Next.js, Vite, Astro, Remix, etc. + - Identify the routing pattern (file-based, config-based) + - Find where to create the `/og-image` route + +2. **Design System Discovery** + - Look for Tailwind config (`tailwind.config.js/ts`) for color palette + - Check for CSS variables in global styles (`:root` definitions) + - Find existing color tokens, font families, spacing scales + - Look for a theme or design tokens file + +3. **Branding Assets** + - Find logo files in `/public`, `/assets`, `/src/assets` + - Check for favicon, app icons + - Look for existing hero sections or landing pages with branding + +4. **Product Information** + - Extract product name from `package.json`, landing page, or meta tags + - Find tagline/description from existing pages + - Look for existing OG/meta configuration to understand current setup + +5. **Existing Components** + - Find reusable UI components that could be leveraged + - Check for glass effects, gradients, or distinctive visual patterns + - Identify the overall aesthetic (dark mode, light mode, etc.) + +### Phase 2: OG Image Page Creation + +Create a dedicated route at `/og-image` (or equivalent for the framework): + +**Page Requirements:** +- Fixed dimensions: exactly 1200px wide × 630px tall +- Self-contained styling (no external dependencies that might not render) +- Hide any dev tool indicators with CSS: +```css +[data-nextjs-dialog-overlay], +[data-nextjs-dialog], +nextjs-portal, +#__next-build-indicator { + display: none !important; +} +``` + +**Content Structure:** +- Product logo/icon (prominent placement) +- Product name with distinctive typography +- Tagline or value proposition +- Visual representation of the product (mockup, illustration, or abstract design) +- URL/domain at the bottom +- Background that matches the project aesthetic (gradients, patterns, etc.) + +**Design Principles:** +- Use the project's existing color palette +- Match the typography from the main site +- Include visual elements that represent the product +- Ensure high contrast for readability at small sizes (social previews are often small) +- Test that text is readable when the image is scaled down to ~400px wide + +### Phase 3: Screenshot Capture + +Use Playwright to capture the OG image: + +1. Navigate to the OG image page (typically `http://localhost:3000/og-image` or similar) +2. Resize viewport to exactly 1200×630 +3. Wait for any animations to complete or fonts to load +4. Take a PNG screenshot +5. Save to the project's public folder as `og-image.png` + +**Playwright Commands:** +``` +browser_navigate: http://localhost:{port}/og-image +browser_resize: width=1200, height=630 +browser_take_screenshot: og-image.png (then copy to /public) +``` + +### Phase 4: Meta Tag Configuration + +Audit and update the project's meta tag configuration. For Next.js App Router, update `layout.tsx`. For other frameworks, update the appropriate location. + +**Required Meta Tags:** + +```typescript +// Open Graph +openGraph: { + title: "Product Name - Short Description", + description: "Compelling description for social sharing", + url: "https://yourdomain.com", + siteName: "Product Name", + locale: "en_US", + type: "website", + images: [{ + url: "/og-image.png", // or absolute URL + width: 1200, + height: 630, + alt: "Descriptive alt text for accessibility", + type: "image/png", + }], +}, + +// Twitter/X +twitter: { + card: "summary_large_image", + title: "Product Name - Short Description", + description: "Compelling description for Twitter", + creator: "@handle", // if provided + images: [{ + url: "/og-image.png", + width: 1200, + height: 630, + alt: "Descriptive alt text", + }], +}, + +// Additional +other: { + "theme-color": "#000000", // match brand color + "msapplication-TileColor": "#000000", +}, + +appleWebApp: { + title: "Product Name", + statusBarStyle: "black-translucent", + capable: true, +}, +``` + +**Ensure `metadataBase` is set** for relative URLs to resolve correctly: +```typescript +metadataBase: new URL("https://yourdomain.com"), +``` + +### Phase 5: Verification & Output + +1. **Verify the image exists** at the public path +2. **Check meta tags** are correctly rendered in the HTML +3. **Provide cache-busting instructions:** + - Facebook/LinkedIn: https://developers.facebook.com/tools/debug/ + - Twitter/X: https://cards-dev.twitter.com/validator + - LinkedIn: https://www.linkedin.com/post-inspector/ + +4. **Summary output:** + - Path to generated OG image + - URL to preview the OG image page locally + - List of meta tags added/updated + - Links to social preview debuggers + +## Prompting for Missing Information + +Only ask the user if these cannot be determined from the codebase: + +1. **Domain/URL** - If not found in existing config, ask: "What's your production domain? (e.g., https://example.com)" + +2. **Twitter/X handle** - If adding twitter:creator, ask: "What's your Twitter/X handle for attribution? (optional)" + +3. **Tagline** - If no clear tagline found, ask: "What's a short tagline for social previews? (1 sentence)" + +## Framework-Specific Notes + +**Next.js App Router:** +- Create `/app/og-image/page.tsx` +- Update metadata in `/app/layout.tsx` +- Use `'use client'` directive for the OG page + +**Next.js Pages Router:** +- Create `/pages/og-image.tsx` +- Update `_app.tsx` or use `next-seo` + +**Vite/React:** +- Create route via router config +- Update `index.html` meta tags or use `react-helmet` + +**Astro:** +- Create `/src/pages/og-image.astro` +- Update layout with meta tags + +## Quality Checklist + +Before completing, verify: +- [ ] OG image renders correctly at 1200×630 +- [ ] No dev tool indicators visible in screenshot +- [ ] Image saved to public folder +- [ ] Meta tags include og:image with absolute URL capability +- [ ] Meta tags include twitter:card as summary_large_image +- [ ] Meta tags include dimensions (width/height) +- [ ] Meta tags include alt text for accessibility +- [ ] theme-color is set to match brand +- [ ] User informed of cache-busting URLs diff --git a/.claude/skills/rails-testing/SKILL.md b/.claude/skills/rails-testing/SKILL.md new file mode 100644 index 0000000..ea81f90 --- /dev/null +++ b/.claude/skills/rails-testing/SKILL.md @@ -0,0 +1,408 @@ +--- +name: rails-testing +description: TDD and testing patterns for Rails applications using Minitest and fixtures. Use when writing tests, setting up test data, or debugging test failures. +trigger: test, minitest, fixture, assert, testing, spec, coverage, tdd +--- + +# Rails Testing Guide + +## Test-Driven Development + +**Write tests BEFORE implementation.** + +### TDD Cycle + +``` +1. RED → Write a failing test +2. GREEN → Write minimal code to pass +3. REFACTOR → Clean up, tests stay green +``` + +### TDD Workflow + +```ruby +# Step 1: Write the test FIRST +test "user can close a card" do + card = cards(:open) + assert_not card.closed? + + card.close + + assert card.closed? + assert_equal users(:one), card.closure.closed_by +end + +# Step 2: Run it - see it fail +# $ rails test test/models/card_test.rb:10 + +# Step 3: Implement minimal code to pass +# Step 4: Run it - see it pass +# Step 5: Refactor if needed +``` + +### What to Test First + +| When adding... | Write first... | +|----------------|----------------| +| Model method | Unit test for the method | +| Controller action | Integration test for endpoint | +| User feature | System test with Capybara | +| Bug fix | Test that reproduces the bug | + +### Test Naming + +Describe behavior, not implementation: + +```ruby +# ✅ Good +test "closing a card creates a closure record" +test "user cannot close cards they don't own" + +# ❌ Bad +test "close method calls create_closure!" +test "Closeable concern is included" +``` + +## Philosophy + +> "Write tests for behavior, not implementation. Use fixtures, not factories." + +**Core Principles:** +- **TDD: Tests first, then implementation** +- Minitest over RSpec +- Fixtures over FactoryBot +- Test public interface +- One assertion per test (when reasonable) +- Fast tests enable TDD + +## Test Types + +| Type | Location | Purpose | +|------|----------|---------| +| Unit | `test/models/` | Model behavior | +| Controller | `test/controllers/` | Request/response | +| Integration | `test/integration/` | Multi-step flows | +| System | `test/system/` | Browser tests | +| Mailer | `test/mailers/` | Email content | +| Job | `test/jobs/` | Background jobs | + +## Fixtures + +### Basic Fixtures + +```yaml +# test/fixtures/users.yml +admin: + id: 01961a2a-c0de-7000-8000-000000000001 + email: admin@example.com + name: Admin User + +regular: + id: 01961a2a-c0de-7000-8000-000000000001 + email: user@example.com + name: Regular User +``` + +### Associations + +```yaml +# test/fixtures/boards.yml +main: + id: 01961a2a-c0de-7000-8000-000000000001 + name: Main Board + owner: admin # References users(:admin) + +# test/fixtures/cards.yml +open_card: + id: 01961a2a-c0de-7000-8000-000000000001 + board: main # References boards(:main) + author: admin + title: Open Card + +closed_card: + id: 01961a2a-c0de-7000-8000-000000000001 + board: main + author: admin + title: Closed Card + # Closure record creates the closed state +``` + +### ERB in Fixtures + +```yaml +# test/fixtures/cards.yml +<% 10.times do |i| %> +card_<%= i %>: + id: 01961a2a-c0de-7000-8000-000000000001 + board: main + author: admin + title: Card <%= i %> + created_at: <%= i.days.ago %> +<% end %> +``` + +### Polymorphic Associations + +```yaml +# test/fixtures/closures.yml +card_closure: + id: 01961a2a-c0de-7000-8000-000000000001 + closeable: closed_card (Card) # Type in parentheses + closed_by: admin +``` + +## Model Tests + +```ruby +# test/models/card_test.rb +class CardTest < ActiveSupport::TestCase + setup do + @card = cards(:open_card) + @user = users(:admin) + Current.user = @user + end + + teardown do + Current.reset + end + + test "belongs to board" do + assert_equal boards(:main), @card.board + end + + test "can be closed" do + assert_not @card.closed? + @card.close + assert @card.closed? + assert_equal @user, @card.closure.closed_by + end + + test "cannot close twice" do + @card.close + assert_raises(ActiveRecord::RecordInvalid) { @card.close } + end + + test "open scope excludes closed cards" do + closed = cards(:closed_card) + assert_includes Card.open, @card + assert_not_includes Card.open, closed + end +end +``` + +## Controller Tests + +```ruby +# test/controllers/cards_controller_test.rb +class CardsControllerTest < ActionDispatch::IntegrationTest + setup do + @user = users(:admin) + @card = cards(:open_card) + sign_in @user + end + + test "index shows cards" do + get cards_path + assert_response :success + assert_select "article.card", minimum: 1 + end + + test "show displays card" do + get card_path(@card) + assert_response :success + assert_select "h1", @card.title + end + + test "create redirects to card" do + assert_difference "Card.count" do + post cards_path, params: { + card: { title: "New Card", board_id: boards(:main).id } + } + end + assert_redirected_to card_path(Card.last) + end + + test "unauthorized user cannot create" do + sign_out + post cards_path, params: { card: { title: "Test" } } + assert_redirected_to new_session_path + end +end +``` + +## System Tests + +```ruby +# test/system/cards_test.rb +class CardsTest < ApplicationSystemTestCase + setup do + @user = users(:admin) + sign_in @user + end + + test "creating a card" do + visit board_path(boards(:main)) + + click_on "New Card" + fill_in "Title", with: "System Test Card" + fill_in "Description", with: "Created via system test" + click_on "Create Card" + + assert_text "System Test Card" + assert_text "Card was successfully created" + end + + test "closing a card" do + card = cards(:open_card) + visit card_path(card) + + click_on "Close Card" + + assert_text "Card closed" + assert_selector ".badge", text: "Closed" + end + + test "inline editing with Turbo" do + card = cards(:open_card) + visit card_path(card) + + within turbo_frame("card_#{card.id}") do + click_on "Edit" + fill_in "Title", with: "Updated Title" + click_on "Save" + end + + assert_text "Updated Title" + # Page didn't fully reload + assert_no_selector ".loading" + end +end +``` + +## Test Helpers + +```ruby +# test/test_helper.rb +class ActiveSupport::TestCase + parallelize(workers: :number_of_processors) + fixtures :all + + # Sign in helper + def sign_in(user) + post session_path, params: { email: user.email } + follow_magic_link_for(user) + end + + def sign_out + delete session_path + end + + private + + def follow_magic_link_for(user) + token = user.generate_token_for(:magic_link) + get verify_session_path(token: token) + end +end + +# test/application_system_test_case.rb +class ApplicationSystemTestCase < ActionDispatch::SystemTestCase + driven_by :selenium, using: :headless_chrome + + def sign_in(user) + visit new_session_path + fill_in "Email", with: user.email + click_on "Send Magic Link" + + # In test, directly verify the token + token = user.generate_token_for(:magic_link) + visit verify_session_path(token: token) + end +end +``` + +## Assertions + +```ruby +# Equality +assert_equal expected, actual +assert_not_equal unexpected, actual + +# Boolean +assert predicate +assert_not predicate + +# Nil +assert_nil value +assert_not_nil value + +# Collections +assert_includes collection, item +assert_empty collection + +# Changes +assert_difference "Card.count", 1 do + # action +end + +assert_no_difference "Card.count" do + # action +end + +# Exceptions +assert_raises(ActiveRecord::RecordInvalid) { invalid_action } + +# Response +assert_response :success +assert_redirected_to card_path(@card) + +# HTML +assert_select "h1", "Expected Title" +assert_select "article.card", count: 5 +``` + +## Running Tests + +```bash +# All tests +rails test + +# Specific file +rails test test/models/card_test.rb + +# Specific test by line +rails test test/models/card_test.rb:42 + +# By name pattern +rails test -n /close/ + +# Verbose output +rails test --verbose + +# Fail fast +rails test --fail-fast + +# System tests +rails test:system + +# With coverage +COVERAGE=true rails test +``` + +## Best Practices + +### DO + +- Test public behavior, not private methods +- Use descriptive test names +- Keep tests independent +- Use fixtures for test data +- Run tests frequently + +### DON'T + +- Don't test Rails itself +- Don't test private methods +- Don't share state between tests +- Don't use FactoryBot +- Don't mock everything diff --git a/.claude/skills/ralphing/SKILL.md b/.claude/skills/ralphing/SKILL.md new file mode 100644 index 0000000..ccd2c35 --- /dev/null +++ b/.claude/skills/ralphing/SKILL.md @@ -0,0 +1,84 @@ +--- +name: ralphing +description: This skill should be used when setting up or running the Ralph autonomous coding loop that iterates through stories, runs tests, commits, and logs learnings. +--- + +# Ralphing + +Ralph is an autonomous AI coding loop that ships features while you sleep. Each iteration runs in a fresh context window, while memory persists through git history and text files. + +## When to Use + +When the user wants to implement a multi-story feature autonomously, or mentions "ralph", "ralphing", or "autonomous loop". + +## Workflow + +### Step 1: Get the PRD + +The user provides a PRD file, or you help them create one. Ask: +1. Do you have a PRD already, or should we create one together? +2. What's the repo path? +3. What's the test command? (e.g., `bundle exec rake`, `npm test`) +4. How many iterations max? (default: 25) + +### PRD Format + +```markdown +# PRD + +Branch: `` + +## Stories + +### US-001: + +- [ ] +- [ ] + +### US-002: + +- [ ] +``` + +Guidelines for stories: +- **Small**: Must fit in one context window +- **Explicit criteria**: Avoid vague ("Users can log in"), prefer specific checks +- Story order = priority (first = highest) + +Note: Ralph is typically run in a git worktree. If already on the correct branch (or detached HEAD), the branch checkout is skipped. + +### Step 2: Start the Loop + +```bash +~/.claude/skills/ralphing/assets/ralph.sh start [max_iterations] [test_cmd] +``` + +This returns a session directory (e.g., `/tmp/ralph/ABC123-...`) and runs the loop in the background. + +### Step 3: Monitor Progress + +Poll the session status periodically: + +```bash +~/.claude/skills/ralphing/assets/ralph.sh status +``` + +This outputs: +- `status`: `running`, `complete`, or `failed` +- `iteration`: current/max iterations +- Full contents of `progress.txt` + +Keep polling until status is `complete` or `failed`. + +## How the Loop Works + +1. `start` copies PRD, generates prompt, checks out branch (if needed), and spawns background process +2. Background loop: agent implements story -> commits -> runs tests +3. If tests fail, reverts commit and retries (progress.txt preserved) +4. Exits when agent signals `COMPLETE` or max iterations reached +5. Parent agent polls `status` to observe progress without blocking + +## Resources + +- `assets/ralph.sh` - the loop script (start/status commands) +- `assets/prompt.md` - prompt template (uses `` placeholder) diff --git a/.claude/skills/ralphing/assets/prompt.md b/.claude/skills/ralphing/assets/prompt.md new file mode 100644 index 0000000..f649a93 --- /dev/null +++ b/.claude/skills/ralphing/assets/prompt.md @@ -0,0 +1,29 @@ +# Ralph Agent Instructions + +## Your Task + +1. Read `/prd.md` +2. Read `/progress.txt` +3. Pick the first story with unchecked criteria +4. Implement that ONE story +5. Run typecheck and tests +6. Commit (always use `--no-gpg-sign`): `git commit --no-gpg-sign -m "feat: [ID] - [Title]"` +7. Update `/prd.md`: mark `[x]` for done +8. Append learnings to `/progress.txt` + +## Progress Format + +APPEND to `/progress.txt`: + +## [Date] - [Story ID] +- What was implemented +- Files changed +- **Learnings:** + - Patterns discovered + - Gotchas encountered +--- + +## Stop Condition + +After completing your story, reply: +COMPLETE diff --git a/.claude/skills/ralphing/assets/ralph.sh b/.claude/skills/ralphing/assets/ralph.sh new file mode 100644 index 0000000..394f8a6 --- /dev/null +++ b/.claude/skills/ralphing/assets/ralph.sh @@ -0,0 +1,96 @@ +#!/bin/bash +set -e + +SCRIPT_DIR="$(dirname "$0")" + +case "${1:-}" in + start) + REPO_PATH=${2:?Usage: ralph.sh start [max_iterations] [test_cmd]} + PRD_FILE=${3:?Usage: ralph.sh start [max_iterations] [test_cmd]} + MAX_ITERATIONS=${4:-10} + TEST_CMD=${5:-} + + RALPH_DIR="/tmp/ralph/$(uuidgen)" + mkdir -p "$RALPH_DIR" + cp "$PRD_FILE" "$RALPH_DIR/prd.md" + echo "# Ralph Progress Log" > "$RALPH_DIR/progress.txt" + echo "0" > "$RALPH_DIR/iteration" + echo "running" > "$RALPH_DIR/status" + echo "$REPO_PATH" > "$RALPH_DIR/repo_path" + echo "$MAX_ITERATIONS" > "$RALPH_DIR/max_iterations" + echo "$TEST_CMD" > "$RALPH_DIR/test_cmd" + sed "s||$RALPH_DIR|g" "$SCRIPT_DIR/prompt.md" > "$RALPH_DIR/prompt.md" + + BRANCH=$(sed -n 's/^Branch: `\([^`]*\)`$/\1/p' "$RALPH_DIR/prd.md" | head -1) + [ -z "$BRANCH" ] && BRANCH=$(sed -n 's/^Branch: \([^ ]*\)$/\1/p' "$RALPH_DIR/prd.md" | head -1) + [ -z "$BRANCH" ] && echo "No Branch: found in PRD" && exit 1 + echo "$BRANCH" > "$RALPH_DIR/branch" + + cd "$REPO_PATH" + if [ "$(git branch --show-current)" != "$BRANCH" ]; then + git checkout "$BRANCH" 2>/dev/null || git checkout -b "$BRANCH" + fi + + nohup "$SCRIPT_DIR/ralph.sh" _run "$RALPH_DIR" > "$RALPH_DIR/output.log" 2>&1 & + echo "$!" > "$RALPH_DIR/pid" + + echo "$RALPH_DIR" + ;; + + status) + RALPH_DIR=${2:?Usage: ralph.sh status } + [ ! -d "$RALPH_DIR" ] && echo "Session not found: $RALPH_DIR" && exit 1 + + STATUS=$(cat "$RALPH_DIR/status") + ITERATION=$(cat "$RALPH_DIR/iteration") + MAX=$(cat "$RALPH_DIR/max_iterations") + + echo "status: $STATUS" + echo "iteration: $ITERATION/$MAX" + echo "--- progress ---" + cat "$RALPH_DIR/progress.txt" + echo "--- output (last 5 lines) ---" + tail -5 "$RALPH_DIR/output.log" 2>/dev/null || echo "(no output yet)" + ;; + + _run) + RALPH_DIR=${2:?} + REPO_PATH=$(cat "$RALPH_DIR/repo_path") + MAX_ITERATIONS=$(cat "$RALPH_DIR/max_iterations") + TEST_CMD=$(cat "$RALPH_DIR/test_cmd") + + cd "$REPO_PATH" + + for i in $(seq 1 $MAX_ITERATIONS); do + echo "$i" > "$RALPH_DIR/iteration" + echo "=== Iteration $i ===" >> "$RALPH_DIR/output.log" + + OPENCODE_PERMISSION='{"*":"allow","external_directory":"allow"}' opencode run \ + "$(cat "$RALPH_DIR/prompt.md")" 2>&1 | tee -a "$RALPH_DIR/output.log" + OUTPUT=$(tail -1000 "$RALPH_DIR/output.log") + + if echo "$OUTPUT" | grep -q "COMPLETE"; then + if [ -n "$TEST_CMD" ] && ! $TEST_CMD; then + git reset --hard HEAD~1 + continue + fi + if ! grep -q '\- \[ \]' "$RALPH_DIR/prd.md"; then + echo "complete" > "$RALPH_DIR/status" + exit 0 + fi + fi + sleep 2 + done + + echo "failed" > "$RALPH_DIR/status" + exit 1 + ;; + + *) + echo "Usage: ralph.sh [args]" + echo "Commands:" + echo " start [max_iterations] [test_cmd] Start a new ralph session" + echo " status Check session status" + exit 1 + ;; +esac diff --git a/.claude/skills/readme-writer/SKILL.md b/.claude/skills/readme-writer/SKILL.md new file mode 100644 index 0000000..80d26e1 --- /dev/null +++ b/.claude/skills/readme-writer/SKILL.md @@ -0,0 +1,55 @@ +--- +name: readme-writer +description: Write READMEs for software projects. The skill should be used when writing or revising a README or README.md file. +license: MIT +--- + +The general flow of a README: + +1. A section describing what the package does and why it's important (i.e. "what's in it for me" for the user) +2. A section on how to install and use the package +3. A section on common configuration options and methods +4. A section on how to contribute or pointer to CONTRIBUTING.md, notes on the developer's build environment and potential portability problems. +5. a brief explanation of any unusual top-level directories or files, or other hints for readers to find their way around the source; + +### Github-flavored markdown + +Since most all of my projects are Github, you have have some neat markdown block extensions available: + +> [!CAUTION] +> [!IMPORTANT] +> [!NOTE] +> [!TIP] +> [!WARNING] + +Use these to call out any semantic sections. + +### Reading level + +I prefer a Fleisch-Kincaid reading level of 9th grade or below. Revise the README until it is below a 9th grade level. Use `scripts/flesch_kincaid.rb`. + +### ESL audience + +Many of the people reading a software project README are not native speakers of English. + +`scripts/top1000.txt` is available for profiling what % of a text's words are in the top 1000 most common in English. Aim to increase this number. + +DO use active voice where possible. +DON'T assume passive constructions are easy just because the words are simple. +DO keep noun phrases short and direct. +DON'T stack multiple modifiers before nouns ("the recently revised standardized testing protocol"). +DO limit embedded clauses—one level of nesting is usually manageable. +DON'T assume simple vocabulary guarantees comprehension; "The man who the dog that bit the cat chased ran away" uses basic words but is notoriously hard to parse. +DO use conditionals sparingly, favoring simple "if/then" structures. +DON'T rely heavily on mixed or inverted conditionals ("Had she known..."). +DO make logical connections explicit with transition words (however, therefore, because). +DON'T expect readers to infer relationships between ideas. +DO spread information across multiple sentences when needed. +DON'T pack too many new concepts into a single sentence. +DO explain or gloss cultural references. +DON'T assume shared background knowledge about idioms, historical events, or cultural touchstones. + +### Bibliography + +— GNU Coding Standards, https://www.gnu.org/prep/standards/html_node/Releases.html#i... (July 1, 2021) +— Software Release Practice HOWTO, https://tldp.org/HOWTO/Software-Release-Practice-HOWTO/distp... (Revision 4.1) diff --git a/.claude/skills/readme-writer/scripts/flesch_kincaid.rb b/.claude/skills/readme-writer/scripts/flesch_kincaid.rb new file mode 100644 index 0000000..82ea8d1 --- /dev/null +++ b/.claude/skills/readme-writer/scripts/flesch_kincaid.rb @@ -0,0 +1,56 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# Flesch-Kincaid Grade Level Calculator +# Reads text from STDIN and outputs the grade level + +def count_syllables(word) + word = word.downcase.gsub(/[^a-z]/, "") + return 0 if word.empty? + + # Handle special endings + word = word.sub(/e$/, "") unless word.match?(/le$/) && word.length > 2 + + # Count vowel groups + syllables = word.scan(/[aeiouy]+/).length + + # Every word has at least one syllable + [syllables, 1].max +end + +def count_sentences(text) + # Count sentence-ending punctuation + count = text.scan(/[.!?]+/).length + [count, 1].max +end + +def count_words(text) + text.split(/\s+/).count { |w| !w.gsub(/[^a-zA-Z]/, "").empty? } +end + +def extract_words(text) + text.split(/\s+/).map { |w| w.gsub(/[^a-zA-Z]/, "") }.reject(&:empty?) +end + +def flesch_kincaid_grade_level(text) + words = extract_words(text) + word_count = words.length + sentence_count = count_sentences(text) + syllable_count = words.sum { |w| count_syllables(w) } + + return 0 if word_count.zero? + + 0.39 * (word_count.to_f / sentence_count) + + 11.8 * (syllable_count.to_f / word_count) - + 15.59 +end + +text = $stdin.read + +if text.strip.empty? + warn "No input provided. Please pipe text to this script." + exit 1 +end + +grade_level = flesch_kincaid_grade_level(text) +puts format("Flesch-Kincaid Grade Level: %.1f", grade_level) diff --git a/.claude/skills/readme-writer/scripts/top1000.txt b/.claude/skills/readme-writer/scripts/top1000.txt new file mode 100644 index 0000000..3b64271 --- /dev/null +++ b/.claude/skills/readme-writer/scripts/top1000.txt @@ -0,0 +1,999 @@ +a +about +above +across +act +active +activity +add +afraid +after +again +age +ago +agree +air +all +alone +along +already +always +am +amount +an +and +angry +another +answer +any +anyone +anything +anytime +appear +apple +are +area +arm +army +around +arrive +art +as +ask +at +attack +aunt +autumn +away +baby +back +bad +bag +ball +bank +base +basket +bath +be +bean +bear +beautiful +bed +bedroom +beer +before +begin +behave +behind +bell +below +besides +best +better +between +big +bird +birth +birthday +bit +bite +black +bleed +block +blood +blow +blue +board +boat +body +boil +bone +book +border +born +borrow +both +bottle +bottom +bowl +box +boy +branch +brave +bread +break +breakfast +breathe +bridge +bright +bring +brother +brown +brush +build +burn +bus +business +busy +but +buy +by +cake +call +can +candle +cap +car +card +care +careful +careless +carry +case +cat +catch +central +century +certain +chair +chance +change +chase +cheap +cheese +chicken +child +children +chocolate +choice +choose +circle +city +class +clean +clear +clever +climb +clock +close +cloth +clothes +cloud +cloudy +coat +coffee +coin +cold +collect +color +comb +come +comfortable +common +compare +complete +computer +condition +contain +continue +control +cook +cool +copper +corn +corner +correct +cost +count +country +course +cover +crash +cross +cry +cup +cupboard +cut +dance +dangerous +dark +daughter +day +dead +decide +decrease +deep +deer +depend +desk +destroy +develop +die +different +difficult +dinner +direction +dirty +discover +dish +do +dog +door +double +down +draw +dream +dress +drink +drive +drop +dry +duck +dust +duty +each +ear +early +earn +earth +east +easy +eat +education +effect +egg +eight +either +electric +elephant +else +empty +end +enemy +enjoy +enough +enter +entrance +equal +escape +even +evening +event +ever +every +everybody +everyone +exact +examination +example +except +excited +exercise +expect +expensive +explain +extremely +eye +face +fact +fail +fall +false +family +famous +far +farm +fast +fat +father +fault +fear +feed +feel +female +fever +few +fight +fill +film +find +fine +finger +finish +fire +first +fish +fit +five +fix +flag +flat +float +floor +flour +flower +fly +fold +food +fool +foot +football +for +force +foreign +forest +forget +forgive +fork +form +four +fox +free +freedom +freeze +fresh +friend +friendly +from +front +fruit +full +fun +funny +furniture +further +future +game +garden +gate +general +gentleman +get +gift +give +glad +glass +go +goat +god +gold +good +goodbye +grandfather +grandmother +grass +grave +gray +great +green +ground +group +grow +gun +hair +half +hall +hammer +hand +happen +happy +hard +hat +hate +have +he +head +healthy +hear +heart +heaven +heavy +height +hello +help +hen +her +here +hers +hide +high +hill +him +his +hit +hobby +hold +hole +holiday +home +hope +horse +hospital +hot +hotel +hour +house +how +hundred +hungry +hurry +hurt +husband +ice +idea +if +important +in +increase +inside +into +introduce +invent +invite +iron +is +island +it +its +jelly +job +join +juice +jump +just +keep +key +kill +kind +king +kitchen +knee +knife +knock +know +ladder +lady +lamp +land +large +last +late +lately +laugh +lazy +lead +leaf +learn +leave +left +leg +lend +length +less +lesson +let +letter +library +lie +life +light +like +lion +lip +list +listen +little +live +lock +lonely +long +look +lose +lot +love +low +lower +luck +machine +main +make +male +man +many +map +mark +market +marry +matter +may +me +meal +mean +measure +meat +medicine +meet +member +mention +method +middle +milk +million +mind +minute +miss +mistake +mix +model +modern +moment +money +monkey +month +moon +more +morning +most +mother +mountain +mouth +move +much +music +must +my +name +narrow +nation +nature +near +nearly +neck +need +needle +neighbor +neither +net +never +new +news +newspaper +next +nice +night +nine +no +noble +noise +none +nor +north +nose +not +nothing +notice +now +number +obey +object +ocean +of +off +offer +office +often +oil +old +on +once +one +only +open +opposite +or +orange +order +other +our +out +outside +over +own +page +pain +paint +pair +pan +paper +parent +park +part +partner +party +pass +past +path +pay +peace +pen +pencil +people +pepper +per +perfect +period +person +petrol +photograph +piano +pick +picture +piece +pig +pin +pink +place +plane +plant +plastic +plate +play +please +pleased +plenty +pocket +point +poison +police +polite +pool +poor +popular +position +possible +potato +pour +power +present +press +pretty +prevent +price +prince +prison +private +prize +probably +problem +produce +promise +proper +protect +provide +public +pull +punish +pupil +push +put +queen +question +quick +quiet +quite +radio +rain +rainy +raise +reach +read +ready +real +really +receive +record +red +remember +remind +remove +rent +repair +repeat +reply +report +rest +restaurant +result +return +rice +rich +ride +right +ring +rise +road +rob +rock +room +round +rubber +rude +rule +ruler +run +rush +sad +safe +sail +salt +same +sand +save +say +school +science +scissors +search +seat +second +see +seem +sell +send +sentence +serve +seven +several +sex +shade +shadow +shake +shape +share +sharp +she +sheep +sheet +shelf +shine +ship +shirt +shoe +shoot +shop +short +should +shoulder +shout +show +sick +side +signal +silence +silly +silver +similar +simple +since +sing +single +sink +sister +sit +six +size +skill +skin +skirt +sky +sleep +slip +slow +small +smell +smile +smoke +snow +so +soap +sock +soft +some +someone +something +sometimes +son +soon +sorry +sound +soup +south +space +speak +special +speed +spell +spend +spoon +sport +spread +spring +square +stamp +stand +star +start +station +stay +steal +steam +step +still +stomach +stone +stop +store +storm +story +strange +street +strong +structure +student +study +stupid +subject +substance +successful +such +sudden +sugar +suitable +summer +sun +sunny +support +sure +surprise +sweet +swim +sword +table +take +talk +tall +taste +taxi +tea +teach +team +tear +telephone +television +tell +ten +tennis +terrible +test +than +that +the +their +then +there +therefore +these +thick +thin +thing +think +third +this +though +threat +three +tidy +tie +title +to +today +toe +together +tomorrow +tonight +too +tool +tooth +top +total +touch +town +train +tram +travel +tree +trouble +true +trust +try +turn +twice +two +type +ugly +uncle +under +understand +unit +until +up +use +useful +usual +usually +vegetable +very +village +visit +voice +wait +wake +walk +want +warm +was +wash +waste +watch +water +way +we +weak +wear +weather +wedding +week +weight +welcome +well +were +west +wet +what +wheel +when +where +which +while +white +who +why +wide +wife +wild +will +win +wind +window +wine +winter +wire +wise +wish +with +without +woman +wonder +word +work +world +worry +yard +yell +yesterday +yet +you +young +your +zero +zoo diff --git a/.claude/skills/readme-writer/scripts/vocabulary_profiler.rb b/.claude/skills/readme-writer/scripts/vocabulary_profiler.rb new file mode 100644 index 0000000..c0c5cc4 --- /dev/null +++ b/.claude/skills/readme-writer/scripts/vocabulary_profiler.rb @@ -0,0 +1,106 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# Vocabulary Profiler +# Reads text from STDIN and reports what percentage uses the top 1000 basic English words +# Word list from: https://simple.wikipedia.org/wiki/Wikipedia:List_of_1000_basic_words + +TOP_1000_WORDS = %w[ + a about above across act active activity add afraid after again age ago agree air all alone along + already always am amount an and angry another answer any anyone anything anytime appear apple are + area arm army around arrive art as ask at attack aunt autumn away + baby back bad bag ball bank base basket bath be bean bear beautiful bed bedroom beer before begin + behave behind bell below besides best better between big bird birth birthday bit bite black bleed + block blood blow blue board boat body boil bone book border born borrow both bottle bottom bowl + box boy branch brave bread break breakfast breathe bridge bright bring brother brown brush build + burn bus business busy but buy by + cake call can candle cap car card care careful careless carry case cat catch central century + certain chair chance change chase cheap cheese chicken child children chocolate choice choose + circle city class clean clear clever climb clock close cloth clothes cloud cloudy coat coffee + coin cold collect color comb come comfortable common compare complete computer condition contain + continue control cook cool copper corn corner correct cost count country course cover crash cross + cry cup cupboard cut + dance dangerous dark daughter day dead decide decrease deep deer depend desk destroy develop die + different difficult dinner direction dirty discover dish do dog door double down draw dream dress + drink drive drop dry duck dust duty + each ear early earn earth east easy eat education effect egg eight either electric elephant else + empty end enemy enjoy enough enter entrance equal escape even evening event ever every everybody + everyone exact examination example except excited exercise expect expensive explain extremely eye + face fact fail fall false family famous far farm fast fat father fault fear feed feel female fever + few fight fill film find fine finger finish fire first fish fit five fix flag flat float floor + flour flower fly fold food fool foot football for force foreign forest forget forgive fork form + four fox free freedom freeze fresh friend friendly from front fruit full fun funny furniture + further future + game garden gate general gentleman get gift give glad glass go goat god gold good goodbye + grandfather grandmother grass grave gray great green ground group grow gun + hair half hall hammer hand happen happy hard hat hate have he head healthy hear heart heaven heavy + height hello help hen her here hers hide high hill him his hit hobby hold hole holiday home hope + horse hospital hot hotel hour house how hundred hungry hurry hurt husband + i ice idea if important in increase inside into introduce invent invite iron is island it its + jelly job join juice jump just + keep key kill kind king kitchen knee knife knock know + ladder lady lamp land large last late lately laugh lazy lead leaf learn leave left leg lend length + less lesson let letter library lie life light like lion lip list listen little live lock lonely + long look lose lot love low lower luck + machine main make male man many map mark market marry matter may me meal mean measure meat medicine + meet member mention method middle milk million mind minute miss mistake mix model modern moment + money monkey month moon more morning most mother mountain mouth move much music must my + name narrow nation nature near nearly neck need needle neighbor neither net never new news + newspaper next nice night nine no noble noise none nor north nose not nothing notice now number + obey object ocean of off offer office often oil old on once one only open opposite or orange order + other our out outside over own + page pain paint pair pan paper parent park part partner party pass past path pay peace pen pencil + people pepper per perfect period person petrol photograph piano pick picture piece pig pin pink + place plane plant plastic plate play please pleased plenty pocket point poison police polite pool + poor popular position possible potato pour power present press pretty prevent price prince prison + private prize probably problem produce promise proper protect provide public pull punish pupil + push put + queen question quick quiet quite + radio rain rainy raise reach read ready real really receive record red remember remind remove rent + repair repeat reply report rest restaurant result return rice rich ride right ring rise road rob + rock room round rubber rude rule ruler run rush + sad safe sail salt same sand save say school science scissors search seat second see seem sell + send sentence serve seven several sex shade shadow shake shape share sharp she sheep sheet shelf + shine ship shirt shoe shoot shop short should shoulder shout show sick side signal silence silly + silver similar simple since sing single sink sister sit six size skill skin skirt sky sleep slip + slow small smell smile smoke snow so soap sock soft some someone something sometimes son soon + sorry sound soup south space speak special speed spell spend spoon sport spread spring square + stamp stand star start station stay steal steam step still stomach stone stop store storm story + strange street strong structure student study stupid subject substance successful such sudden + sugar suitable summer sun sunny support sure surprise sweet swim sword + table take talk tall taste taxi tea teach team tear telephone television tell ten tennis terrible + test than that the their then there therefore these thick thin thing think third this though + threat three tidy tie title to today toe together tomorrow tonight too tool tooth top total touch + town train tram travel tree trouble true trust try turn twice two type + ugly uncle under understand unit until up use useful usual usually + vegetable very village visit voice + wait wake walk want warm was wash waste watch water way we weak wear weather wedding week weight + welcome well were west wet what wheel when where which while white who why wide wife wild will win + wind window wine winter wire wise wish with without woman wonder word work world worry + yard yell yesterday yet you young your + zero zoo +].to_set.freeze + +def extract_words(text) + text.downcase.scan(/[a-z]+/) +end + +text = $stdin.read + +if text.strip.empty? + warn "No input provided. Please pipe text to this script." + exit 1 +end + +words = extract_words(text) +total_words = words.length + +if total_words.zero? + warn "No words found in input." + exit 1 +end + +basic_words = words.count { |w| TOP_1000_WORDS.include?(w) } +percentage = (basic_words.to_f / total_words * 100) + +puts format("Words in top 1000: %d / %d (%.1f%%)", basic_words, total_words, percentage) diff --git a/.claude/skills/skill-creator/LICENSE.txt b/.claude/skills/skill-creator/LICENSE.txt new file mode 100644 index 0000000..7a4a3ea --- /dev/null +++ b/.claude/skills/skill-creator/LICENSE.txt @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. \ No newline at end of file diff --git a/.claude/skills/skill-creator/SKILL.md b/.claude/skills/skill-creator/SKILL.md new file mode 100644 index 0000000..4069935 --- /dev/null +++ b/.claude/skills/skill-creator/SKILL.md @@ -0,0 +1,209 @@ +--- +name: skill-creator +description: Guide for creating effective skills. This skill should be used when users want to create a new skill (or update an existing skill) that extends Claude's capabilities with specialized knowledge, workflows, or tool integrations. +license: Complete terms in LICENSE.txt +--- + +# Skill Creator + +This skill provides guidance for creating effective skills. + +## About Skills + +Skills are modular, self-contained packages that extend Claude's capabilities by providing +specialized knowledge, workflows, and tools. Think of them as "onboarding guides" for specific +domains or tasks—they transform Claude from a general-purpose agent into a specialized agent +equipped with procedural knowledge that no model can fully possess. + +### What Skills Provide + +1. Specialized workflows - Multi-step procedures for specific domains +2. Tool integrations - Instructions for working with specific file formats or APIs +3. Domain expertise - Company-specific knowledge, schemas, business logic +4. Bundled resources - Scripts, references, and assets for complex and repetitive tasks + +### Anatomy of a Skill + +Every skill consists of a required SKILL.md file and optional bundled resources: + +``` +skill-name/ +├── SKILL.md (required) +│ ├── YAML frontmatter metadata (required) +│ │ ├── name: (required) +│ │ └── description: (required) +│ └── Markdown instructions (required) +└── Bundled Resources (optional) + ├── scripts/ - Executable code (Python/Bash/etc.) + ├── references/ - Documentation intended to be loaded into context as needed + └── assets/ - Files used in output (templates, icons, fonts, etc.) +``` + +#### SKILL.md (required) + +**Metadata Quality:** The `name` and `description` in YAML frontmatter determine when Claude will use the skill. Be specific about what the skill does and when to use it. Use the third-person (e.g. "This skill should be used when..." instead of "Use this skill when..."). + +#### Bundled Resources (optional) + +##### Scripts (`scripts/`) + +Executable code (Python/Bash/etc.) for tasks that require deterministic reliability or are repeatedly rewritten. + +- **When to include**: When the same code is being rewritten repeatedly or deterministic reliability is needed +- **Example**: `scripts/rotate_pdf.py` for PDF rotation tasks +- **Benefits**: Token efficient, deterministic, may be executed without loading into context +- **Note**: Scripts may still need to be read by Claude for patching or environment-specific adjustments + +##### References (`references/`) + +Documentation and reference material intended to be loaded as needed into context to inform Claude's process and thinking. + +- **When to include**: For documentation that Claude should reference while working +- **Examples**: `references/finance.md` for financial schemas, `references/mnda.md` for company NDA template, `references/policies.md` for company policies, `references/api_docs.md` for API specifications +- **Use cases**: Database schemas, API documentation, domain knowledge, company policies, detailed workflow guides +- **Benefits**: Keeps SKILL.md lean, loaded only when Claude determines it's needed +- **Best practice**: If files are large (>10k words), include grep search patterns in SKILL.md +- **Avoid duplication**: Information should live in either SKILL.md or references files, not both. Prefer references files for detailed information unless it's truly core to the skill—this keeps SKILL.md lean while making information discoverable without hogging the context window. Keep only essential procedural instructions and workflow guidance in SKILL.md; move detailed reference material, schemas, and examples to references files. + +##### Assets (`assets/`) + +Files not intended to be loaded into context, but rather used within the output Claude produces. + +- **When to include**: When the skill needs files that will be used in the final output +- **Examples**: `assets/logo.png` for brand assets, `assets/slides.pptx` for PowerPoint templates, `assets/frontend-template/` for HTML/React boilerplate, `assets/font.ttf` for typography +- **Use cases**: Templates, images, icons, boilerplate code, fonts, sample documents that get copied or modified +- **Benefits**: Separates output resources from documentation, enables Claude to use files without loading them into context + +### Progressive Disclosure Design Principle + +Skills use a three-level loading system to manage context efficiently: + +1. **Metadata (name + description)** - Always in context (~100 words) +2. **SKILL.md body** - When skill triggers (<5k words) +3. **Bundled resources** - As needed by Claude (Unlimited*) + +*Unlimited because scripts can be executed without reading into context window. + +## Skill Creation Process + +To create a skill, follow the "Skill Creation Process" in order, skipping steps only if there is a clear reason why they are not applicable. + +### Step 1: Understanding the Skill with Concrete Examples + +Skip this step only when the skill's usage patterns are already clearly understood. It remains valuable even when working with an existing skill. + +To create an effective skill, clearly understand concrete examples of how the skill will be used. This understanding can come from either direct user examples or generated examples that are validated with user feedback. + +For example, when building an image-editor skill, relevant questions include: + +- "What functionality should the image-editor skill support? Editing, rotating, anything else?" +- "Can you give some examples of how this skill would be used?" +- "I can imagine users asking for things like 'Remove the red-eye from this image' or 'Rotate this image'. Are there other ways you imagine this skill being used?" +- "What would a user say that should trigger this skill?" + +To avoid overwhelming users, avoid asking too many questions in a single message. Start with the most important questions and follow up as needed for better effectiveness. + +Conclude this step when there is a clear sense of the functionality the skill should support. + +### Step 2: Planning the Reusable Skill Contents + +To turn concrete examples into an effective skill, analyze each example by: + +1. Considering how to execute on the example from scratch +2. Identifying what scripts, references, and assets would be helpful when executing these workflows repeatedly + +Example: When building a `pdf-editor` skill to handle queries like "Help me rotate this PDF," the analysis shows: + +1. Rotating a PDF requires re-writing the same code each time +2. A `scripts/rotate_pdf.py` script would be helpful to store in the skill + +Example: When designing a `frontend-webapp-builder` skill for queries like "Build me a todo app" or "Build me a dashboard to track my steps," the analysis shows: + +1. Writing a frontend webapp requires the same boilerplate HTML/React each time +2. An `assets/hello-world/` template containing the boilerplate HTML/React project files would be helpful to store in the skill + +Example: When building a `big-query` skill to handle queries like "How many users have logged in today?" the analysis shows: + +1. Querying BigQuery requires re-discovering the table schemas and relationships each time +2. A `references/schema.md` file documenting the table schemas would be helpful to store in the skill + +To establish the skill's contents, analyze each concrete example to create a list of the reusable resources to include: scripts, references, and assets. + +### Step 3: Initializing the Skill + +At this point, it is time to actually create the skill. + +Skip this step only if the skill being developed already exists, and iteration or packaging is needed. In this case, continue to the next step. + +When creating a new skill from scratch, always run the `init_skill.py` script. The script conveniently generates a new template skill directory that automatically includes everything a skill requires, making the skill creation process much more efficient and reliable. + +Usage: + +```bash +scripts/init_skill.py --path +``` + +The script: + +- Creates the skill directory at the specified path +- Generates a SKILL.md template with proper frontmatter and TODO placeholders +- Creates example resource directories: `scripts/`, `references/`, and `assets/` +- Adds example files in each directory that can be customized or deleted + +After initialization, customize or remove the generated SKILL.md and example files as needed. + +### Step 4: Edit the Skill + +When editing the (newly-generated or existing) skill, remember that the skill is being created for another instance of Claude to use. Focus on including information that would be beneficial and non-obvious to Claude. Consider what procedural knowledge, domain-specific details, or reusable assets would help another Claude instance execute these tasks more effectively. + +#### Start with Reusable Skill Contents + +To begin implementation, start with the reusable resources identified above: `scripts/`, `references/`, and `assets/` files. Note that this step may require user input. For example, when implementing a `brand-guidelines` skill, the user may need to provide brand assets or templates to store in `assets/`, or documentation to store in `references/`. + +Also, delete any example files and directories not needed for the skill. The initialization script creates example files in `scripts/`, `references/`, and `assets/` to demonstrate structure, but most skills won't need all of them. + +#### Update SKILL.md + +**Writing Style:** Write the entire skill using **imperative/infinitive form** (verb-first instructions), not second person. Use objective, instructional language (e.g., "To accomplish X, do Y" rather than "You should do X" or "If you need to do X"). This maintains consistency and clarity for AI consumption. + +To complete SKILL.md, answer the following questions: + +1. What is the purpose of the skill, in a few sentences? +2. When should the skill be used? +3. In practice, how should Claude use the skill? All reusable skill contents developed above should be referenced so that Claude knows how to use them. + +### Step 5: Packaging a Skill + +Once the skill is ready, it should be packaged into a distributable zip file that gets shared with the user. The packaging process automatically validates the skill first to ensure it meets all requirements: + +```bash +scripts/package_skill.py +``` + +Optional output directory specification: + +```bash +scripts/package_skill.py ./dist +``` + +The packaging script will: + +1. **Validate** the skill automatically, checking: + - YAML frontmatter format and required fields + - Skill naming conventions and directory structure + - Description completeness and quality + - File organization and resource references + +2. **Package** the skill if validation passes, creating a zip file named after the skill (e.g., `my-skill.zip`) that includes all files and maintains the proper directory structure for distribution. + +If validation fails, the script will report the errors and exit without creating a package. Fix any validation errors and run the packaging command again. + +### Step 6: Iterate + +After testing the skill, users may request improvements. Often this happens right after using the skill, with fresh context of how the skill performed. + +**Iteration workflow:** +1. Use the skill on real tasks +2. Notice struggles or inefficiencies +3. Identify how SKILL.md or bundled resources should be updated +4. Implement changes and test again diff --git a/.claude/skills/skill-creator/scripts/init_skill.py b/.claude/skills/skill-creator/scripts/init_skill.py new file mode 100755 index 0000000..329ad4e --- /dev/null +++ b/.claude/skills/skill-creator/scripts/init_skill.py @@ -0,0 +1,303 @@ +#!/usr/bin/env python3 +""" +Skill Initializer - Creates a new skill from template + +Usage: + init_skill.py --path + +Examples: + init_skill.py my-new-skill --path skills/public + init_skill.py my-api-helper --path skills/private + init_skill.py custom-skill --path /custom/location +""" + +import sys +from pathlib import Path + + +SKILL_TEMPLATE = """--- +name: {skill_name} +description: [TODO: Complete and informative explanation of what the skill does and when to use it. Include WHEN to use this skill - specific scenarios, file types, or tasks that trigger it.] +--- + +# {skill_title} + +## Overview + +[TODO: 1-2 sentences explaining what this skill enables] + +## Structuring This Skill + +[TODO: Choose the structure that best fits this skill's purpose. Common patterns: + +**1. Workflow-Based** (best for sequential processes) +- Works well when there are clear step-by-step procedures +- Example: DOCX skill with "Workflow Decision Tree" → "Reading" → "Creating" → "Editing" +- Structure: ## Overview → ## Workflow Decision Tree → ## Step 1 → ## Step 2... + +**2. Task-Based** (best for tool collections) +- Works well when the skill offers different operations/capabilities +- Example: PDF skill with "Quick Start" → "Merge PDFs" → "Split PDFs" → "Extract Text" +- Structure: ## Overview → ## Quick Start → ## Task Category 1 → ## Task Category 2... + +**3. Reference/Guidelines** (best for standards or specifications) +- Works well for brand guidelines, coding standards, or requirements +- Example: Brand styling with "Brand Guidelines" → "Colors" → "Typography" → "Features" +- Structure: ## Overview → ## Guidelines → ## Specifications → ## Usage... + +**4. Capabilities-Based** (best for integrated systems) +- Works well when the skill provides multiple interrelated features +- Example: Product Management with "Core Capabilities" → numbered capability list +- Structure: ## Overview → ## Core Capabilities → ### 1. Feature → ### 2. Feature... + +Patterns can be mixed and matched as needed. Most skills combine patterns (e.g., start with task-based, add workflow for complex operations). + +Delete this entire "Structuring This Skill" section when done - it's just guidance.] + +## [TODO: Replace with the first main section based on chosen structure] + +[TODO: Add content here. See examples in existing skills: +- Code samples for technical skills +- Decision trees for complex workflows +- Concrete examples with realistic user requests +- References to scripts/templates/references as needed] + +## Resources + +This skill includes example resource directories that demonstrate how to organize different types of bundled resources: + +### scripts/ +Executable code (Python/Bash/etc.) that can be run directly to perform specific operations. + +**Examples from other skills:** +- PDF skill: `fill_fillable_fields.py`, `extract_form_field_info.py` - utilities for PDF manipulation +- DOCX skill: `document.py`, `utilities.py` - Python modules for document processing + +**Appropriate for:** Python scripts, shell scripts, or any executable code that performs automation, data processing, or specific operations. + +**Note:** Scripts may be executed without loading into context, but can still be read by Claude for patching or environment adjustments. + +### references/ +Documentation and reference material intended to be loaded into context to inform Claude's process and thinking. + +**Examples from other skills:** +- Product management: `communication.md`, `context_building.md` - detailed workflow guides +- BigQuery: API reference documentation and query examples +- Finance: Schema documentation, company policies + +**Appropriate for:** In-depth documentation, API references, database schemas, comprehensive guides, or any detailed information that Claude should reference while working. + +### assets/ +Files not intended to be loaded into context, but rather used within the output Claude produces. + +**Examples from other skills:** +- Brand styling: PowerPoint template files (.pptx), logo files +- Frontend builder: HTML/React boilerplate project directories +- Typography: Font files (.ttf, .woff2) + +**Appropriate for:** Templates, boilerplate code, document templates, images, icons, fonts, or any files meant to be copied or used in the final output. + +--- + +**Any unneeded directories can be deleted.** Not every skill requires all three types of resources. +""" + +EXAMPLE_SCRIPT = '''#!/usr/bin/env python3 +""" +Example helper script for {skill_name} + +This is a placeholder script that can be executed directly. +Replace with actual implementation or delete if not needed. + +Example real scripts from other skills: +- pdf/scripts/fill_fillable_fields.py - Fills PDF form fields +- pdf/scripts/convert_pdf_to_images.py - Converts PDF pages to images +""" + +def main(): + print("This is an example script for {skill_name}") + # TODO: Add actual script logic here + # This could be data processing, file conversion, API calls, etc. + +if __name__ == "__main__": + main() +''' + +EXAMPLE_REFERENCE = """# Reference Documentation for {skill_title} + +This is a placeholder for detailed reference documentation. +Replace with actual reference content or delete if not needed. + +Example real reference docs from other skills: +- product-management/references/communication.md - Comprehensive guide for status updates +- product-management/references/context_building.md - Deep-dive on gathering context +- bigquery/references/ - API references and query examples + +## When Reference Docs Are Useful + +Reference docs are ideal for: +- Comprehensive API documentation +- Detailed workflow guides +- Complex multi-step processes +- Information too lengthy for main SKILL.md +- Content that's only needed for specific use cases + +## Structure Suggestions + +### API Reference Example +- Overview +- Authentication +- Endpoints with examples +- Error codes +- Rate limits + +### Workflow Guide Example +- Prerequisites +- Step-by-step instructions +- Common patterns +- Troubleshooting +- Best practices +""" + +EXAMPLE_ASSET = """# Example Asset File + +This placeholder represents where asset files would be stored. +Replace with actual asset files (templates, images, fonts, etc.) or delete if not needed. + +Asset files are NOT intended to be loaded into context, but rather used within +the output Claude produces. + +Example asset files from other skills: +- Brand guidelines: logo.png, slides_template.pptx +- Frontend builder: hello-world/ directory with HTML/React boilerplate +- Typography: custom-font.ttf, font-family.woff2 +- Data: sample_data.csv, test_dataset.json + +## Common Asset Types + +- Templates: .pptx, .docx, boilerplate directories +- Images: .png, .jpg, .svg, .gif +- Fonts: .ttf, .otf, .woff, .woff2 +- Boilerplate code: Project directories, starter files +- Icons: .ico, .svg +- Data files: .csv, .json, .xml, .yaml + +Note: This is a text placeholder. Actual assets can be any file type. +""" + + +def title_case_skill_name(skill_name): + """Convert hyphenated skill name to Title Case for display.""" + return ' '.join(word.capitalize() for word in skill_name.split('-')) + + +def init_skill(skill_name, path): + """ + Initialize a new skill directory with template SKILL.md. + + Args: + skill_name: Name of the skill + path: Path where the skill directory should be created + + Returns: + Path to created skill directory, or None if error + """ + # Determine skill directory path + skill_dir = Path(path).resolve() / skill_name + + # Check if directory already exists + if skill_dir.exists(): + print(f"❌ Error: Skill directory already exists: {skill_dir}") + return None + + # Create skill directory + try: + skill_dir.mkdir(parents=True, exist_ok=False) + print(f"✅ Created skill directory: {skill_dir}") + except Exception as e: + print(f"❌ Error creating directory: {e}") + return None + + # Create SKILL.md from template + skill_title = title_case_skill_name(skill_name) + skill_content = SKILL_TEMPLATE.format( + skill_name=skill_name, + skill_title=skill_title + ) + + skill_md_path = skill_dir / 'SKILL.md' + try: + skill_md_path.write_text(skill_content) + print("✅ Created SKILL.md") + except Exception as e: + print(f"❌ Error creating SKILL.md: {e}") + return None + + # Create resource directories with example files + try: + # Create scripts/ directory with example script + scripts_dir = skill_dir / 'scripts' + scripts_dir.mkdir(exist_ok=True) + example_script = scripts_dir / 'example.py' + example_script.write_text(EXAMPLE_SCRIPT.format(skill_name=skill_name)) + example_script.chmod(0o755) + print("✅ Created scripts/example.py") + + # Create references/ directory with example reference doc + references_dir = skill_dir / 'references' + references_dir.mkdir(exist_ok=True) + example_reference = references_dir / 'api_reference.md' + example_reference.write_text(EXAMPLE_REFERENCE.format(skill_title=skill_title)) + print("✅ Created references/api_reference.md") + + # Create assets/ directory with example asset placeholder + assets_dir = skill_dir / 'assets' + assets_dir.mkdir(exist_ok=True) + example_asset = assets_dir / 'example_asset.txt' + example_asset.write_text(EXAMPLE_ASSET) + print("✅ Created assets/example_asset.txt") + except Exception as e: + print(f"❌ Error creating resource directories: {e}") + return None + + # Print next steps + print(f"\n✅ Skill '{skill_name}' initialized successfully at {skill_dir}") + print("\nNext steps:") + print("1. Edit SKILL.md to complete the TODO items and update the description") + print("2. Customize or delete the example files in scripts/, references/, and assets/") + print("3. Run the validator when ready to check the skill structure") + + return skill_dir + + +def main(): + if len(sys.argv) < 4 or sys.argv[2] != '--path': + print("Usage: init_skill.py --path ") + print("\nSkill name requirements:") + print(" - Hyphen-case identifier (e.g., 'data-analyzer')") + print(" - Lowercase letters, digits, and hyphens only") + print(" - Max 40 characters") + print(" - Must match directory name exactly") + print("\nExamples:") + print(" init_skill.py my-new-skill --path skills/public") + print(" init_skill.py my-api-helper --path skills/private") + print(" init_skill.py custom-skill --path /custom/location") + sys.exit(1) + + skill_name = sys.argv[1] + path = sys.argv[3] + + print(f"🚀 Initializing skill: {skill_name}") + print(f" Location: {path}") + print() + + result = init_skill(skill_name, path) + + if result: + sys.exit(0) + else: + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/.claude/skills/skill-creator/scripts/package_skill.py b/.claude/skills/skill-creator/scripts/package_skill.py new file mode 100755 index 0000000..3ee8e8e --- /dev/null +++ b/.claude/skills/skill-creator/scripts/package_skill.py @@ -0,0 +1,110 @@ +#!/usr/bin/env python3 +""" +Skill Packager - Creates a distributable zip file of a skill folder + +Usage: + python utils/package_skill.py [output-directory] + +Example: + python utils/package_skill.py skills/public/my-skill + python utils/package_skill.py skills/public/my-skill ./dist +""" + +import sys +import zipfile +from pathlib import Path +from quick_validate import validate_skill + + +def package_skill(skill_path, output_dir=None): + """ + Package a skill folder into a zip file. + + Args: + skill_path: Path to the skill folder + output_dir: Optional output directory for the zip file (defaults to current directory) + + Returns: + Path to the created zip file, or None if error + """ + skill_path = Path(skill_path).resolve() + + # Validate skill folder exists + if not skill_path.exists(): + print(f"❌ Error: Skill folder not found: {skill_path}") + return None + + if not skill_path.is_dir(): + print(f"❌ Error: Path is not a directory: {skill_path}") + return None + + # Validate SKILL.md exists + skill_md = skill_path / "SKILL.md" + if not skill_md.exists(): + print(f"❌ Error: SKILL.md not found in {skill_path}") + return None + + # Run validation before packaging + print("🔍 Validating skill...") + valid, message = validate_skill(skill_path) + if not valid: + print(f"❌ Validation failed: {message}") + print(" Please fix the validation errors before packaging.") + return None + print(f"✅ {message}\n") + + # Determine output location + skill_name = skill_path.name + if output_dir: + output_path = Path(output_dir).resolve() + output_path.mkdir(parents=True, exist_ok=True) + else: + output_path = Path.cwd() + + zip_filename = output_path / f"{skill_name}.zip" + + # Create the zip file + try: + with zipfile.ZipFile(zip_filename, 'w', zipfile.ZIP_DEFLATED) as zipf: + # Walk through the skill directory + for file_path in skill_path.rglob('*'): + if file_path.is_file(): + # Calculate the relative path within the zip + arcname = file_path.relative_to(skill_path.parent) + zipf.write(file_path, arcname) + print(f" Added: {arcname}") + + print(f"\n✅ Successfully packaged skill to: {zip_filename}") + return zip_filename + + except Exception as e: + print(f"❌ Error creating zip file: {e}") + return None + + +def main(): + if len(sys.argv) < 2: + print("Usage: python utils/package_skill.py [output-directory]") + print("\nExample:") + print(" python utils/package_skill.py skills/public/my-skill") + print(" python utils/package_skill.py skills/public/my-skill ./dist") + sys.exit(1) + + skill_path = sys.argv[1] + output_dir = sys.argv[2] if len(sys.argv) > 2 else None + + print(f"📦 Packaging skill: {skill_path}") + if output_dir: + print(f" Output directory: {output_dir}") + print() + + result = package_skill(skill_path, output_dir) + + if result: + sys.exit(0) + else: + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/.claude/skills/skill-creator/scripts/quick_validate.py b/.claude/skills/skill-creator/scripts/quick_validate.py new file mode 100755 index 0000000..6fa6c63 --- /dev/null +++ b/.claude/skills/skill-creator/scripts/quick_validate.py @@ -0,0 +1,65 @@ +#!/usr/bin/env python3 +""" +Quick validation script for skills - minimal version +""" + +import sys +import os +import re +from pathlib import Path + +def validate_skill(skill_path): + """Basic validation of a skill""" + skill_path = Path(skill_path) + + # Check SKILL.md exists + skill_md = skill_path / 'SKILL.md' + if not skill_md.exists(): + return False, "SKILL.md not found" + + # Read and validate frontmatter + content = skill_md.read_text() + if not content.startswith('---'): + return False, "No YAML frontmatter found" + + # Extract frontmatter + match = re.match(r'^---\n(.*?)\n---', content, re.DOTALL) + if not match: + return False, "Invalid frontmatter format" + + frontmatter = match.group(1) + + # Check required fields + if 'name:' not in frontmatter: + return False, "Missing 'name' in frontmatter" + if 'description:' not in frontmatter: + return False, "Missing 'description' in frontmatter" + + # Extract name for validation + name_match = re.search(r'name:\s*(.+)', frontmatter) + if name_match: + name = name_match.group(1).strip() + # Check naming convention (hyphen-case: lowercase with hyphens) + if not re.match(r'^[a-z0-9-]+$', name): + return False, f"Name '{name}' should be hyphen-case (lowercase letters, digits, and hyphens only)" + if name.startswith('-') or name.endswith('-') or '--' in name: + return False, f"Name '{name}' cannot start/end with hyphen or contain consecutive hyphens" + + # Extract and validate description + desc_match = re.search(r'description:\s*(.+)', frontmatter) + if desc_match: + description = desc_match.group(1).strip() + # Check for angle brackets + if '<' in description or '>' in description: + return False, "Description cannot contain angle brackets (< or >)" + + return True, "Skill is valid!" + +if __name__ == "__main__": + if len(sys.argv) != 2: + print("Usage: python quick_validate.py ") + sys.exit(1) + + valid, message = validate_skill(sys.argv[1]) + print(message) + sys.exit(0 if valid else 1) \ No newline at end of file diff --git a/.gitignore b/.gitignore index 6942326..13865f8 100644 --- a/.gitignore +++ b/.gitignore @@ -46,3 +46,6 @@ /config/credentials/development.key /config/credentials/production.key + +# MaxMind GeoLite2 database (download separately - not redistributable) +/db/*.mmdb diff --git a/.kamal/secrets b/.kamal/secrets index 3ec358e..6e61960 100644 --- a/.kamal/secrets +++ b/.kamal/secrets @@ -11,7 +11,12 @@ # GITHUB_TOKEN=$(gh config get -h github.com oauth_token) # Grab the registry password from Rails credentials -KAMAL_REGISTRY_PASSWORD=$(bundle exec rails runner "puts Rails.application.credentials.kamal.registry_password") +KAMAL_REGISTRY_PASSWORD=$(rails credentials:fetch kamal.registry_password) # Improve security by using a password manager. Never check config/master.key into git! RAILS_MASTER_KEY=$(cat config/credentials/production.key) + +# MaxMind credentials for GeoLite2 database (get from https://www.maxmind.com/en/accounts/current/license-key) +# As of May 2024, MaxMind requires both account_id and license_key for downloads +MAXMIND_ACCOUNT_ID=$(rails credentials:fetch --environment production maxmind.account_id 2>/dev/null || echo "") +MAXMIND_LICENSE_KEY=$(rails credentials:fetch --environment production maxmind.license_key 2>/dev/null || echo "") diff --git a/.lefthook.yml b/.lefthook.yml index 0c908e1..9e7825c 100644 --- a/.lefthook.yml +++ b/.lefthook.yml @@ -13,3 +13,5 @@ pre-commit: fi # This runs RuboCop with auto-fix on the entire project # and automatically stages ALL fixed files + brakeman: + run: bin/brakeman --no-pager diff --git a/.ruby-version b/.ruby-version index fcdb2e1..1454f6e 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -4.0.0 +4.0.1 diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..01833ff --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,289 @@ +# WhyRuby.info & RubyCommunity.org — Codebase Guide + +This file provides guidance to AI coding agents working with this repository. + +## Project Overview + +WhyRuby.info is a Ruby advocacy community website built with Ruby 4.0.1 and Rails 8.2 (edge/main branch) using the Solid Stack (SQLite, SolidQueue, SolidCache, SolidCable). It features a universal content model supporting articles, links, and success stories with AI-generated summaries, GitHub OAuth authentication, community-driven content moderation, user testimonials, an interactive developer map, GitHub project tracking with star trends, and a timezone-aware newsletter system. + +The app runs on two domains: +- **whyruby.info** — advocacy content and testimonials +- **rubycommunity.org** — community profiles, map, and project rankings + +## Development Commands + +### Server Management +```bash +# Start the development server (REQUIRED - do not use rails server directly) +bin/dev + +# Monitor logs in real-time +tail -f log/development.log +``` + +**IMPORTANT**: Always use `bin/dev` instead of `rails server`. This starts both the Rails server (port 3003) and Tailwind CSS watcher via Procfile.dev. + +### Database +```bash +# Create and setup database +rails db:create +rails db:migrate +rails db:seed + +# Database console +rails db + +# Promote user to admin +rails runner "User.find_by(username: 'github-username').update!(role: :admin)" +``` + +### Testing & Code Quality +```bash +# Run all tests +rails test + +# Run single test file +rails test test/models/post_test.rb + +# Run single test +rails test test/models/post_test.rb:line_number + +# Code style (auto-fixes and stages changes via lefthook) +bundle exec rubocop +bundle exec rubocop -A # Auto-correct issues +``` + +### Rails Console & Generators +```bash +# Console +rails console + +# Generate model with UUIDv7 primary key +rails generate model ModelName + +# Other generators +rails generate controller ControllerName +rails generate migration MigrationName +``` + +## Architecture & Key Patterns + +### Data Model Structure + +The application uses a **Universal Content Model** where the `Post` model handles three distinct content types via the `post_type` enum: + +- **Articles**: Original content with markdown (`content` field required, `url` must be blank) +- **Links**: External content (`url` required, `content` must be blank) +- **Success Stories**: Ruby success stories with SVG logo (`logo_svg` and `content` required, `url` must be blank) + +**Key Models:** +- `User`: GitHub OAuth authentication, role-based access (member/admin), trusted user system based on contribution counts (3+ published posts, 10+ published comments), geocoded location, timezone, newsletter tracking, profile settings (hide_repositories, open_to_work) +- `Post`: Universal content model with FriendlyId slugs, soft deletion via `archived` flag, counter caches for reports +- `Category`: Content organization with position ordering, special success story category flag +- `Tag`: HABTM relationship with posts +- `Comment`: User feedback on posts with published flag +- `Report`: Content moderation by trusted users, auto-hides after 3+ reports +- `Testimonial`: User testimonials ("why I love Ruby") with AI-generated headline, subheadline, and quote. Validated by LLM for appropriateness +- `Project`: GitHub repositories for users with star counts, language, description. Replaces the old `github_repos` JSON column +- `StarSnapshot`: Daily star count snapshots per project, used to compute trending/stars gained + +### Primary Keys & IDs + +**All tables use UUIDv7 string primary keys** (migrated from ULID): +```ruby +create_table :table_name, id: false do |t| + t.primary_key :id, :string, default: -> { "uuid_generate_v7()" } + # ... +end +``` + +UUIDv7 provides time-ordered, universally unique IDs without requiring the `sqlite-ulid` extension. + +### Authentication & Authorization + +- **Authentication**: GitHub OAuth only via Devise + OmniAuth +- **Roles**: `member` (default) and `admin` (enum in User model) +- **Trusted Users**: Automatically qualified when `published_posts_count >= 3` AND `published_comments_count >= 10` +- **Permissions**: Only trusted users can report content; only admins can access `/admin` (Avo panel) + +### Content Moderation Flow + +1. Trusted users can report inappropriate content +2. After 3+ reports, content is automatically: + - Set to `published: false` + - Flagged with `needs_admin_review: true` + - Admin notification sent via `NotifyAdminJob` +3. Admins review and restore or permanently archive via Avo panel + +### Background Jobs (SolidQueue) + +- `GenerateSummaryJob`: Creates AI summaries for new/updated posts using OpenAI or Anthropic APIs +- `GenerateSuccessStoryImageJob`: Generates OG images for success stories from SVG logos +- `GenerateTestimonialFieldsJob`: AI-generates headline, subheadline, and quote from user testimonial text +- `ValidateTestimonialJob`: LLM-validates testimonial content for appropriateness +- `NotifyAdminJob`: Sends notifications when content is auto-hidden +- `UpdateGithubDataJob`: Refreshes user GitHub data via GraphQL API (repositories, stars, etc.) +- `NormalizeLocationJob`: Geocodes user locations via OpenAI for map display +- `ScheduledNewsletterJob`: Sends newsletter emails at timezone-appropriate times + +### Image Processing + +Posts support `featured_image` attachments via ActiveStorage. Images are processed into multiple variants (small, medium, large, og) and stored as separate blobs with metadata in `image_variants` JSON column. Processing uses the `ImageProcessor` service. + +### URL Routing Pattern + +The application uses a **catch-all routing strategy** for clean URLs: + +``` +/:category_slug # Category pages +/:category_slug/:post_slug # Post pages +``` + +**Important**: Category and post routes use constraints and are defined at the END of routes.rb to avoid conflicts with explicit routes like `/admin`, `/community`, `/legal/*`, etc. + +### Multi-Domain Setup (Community) + +The app runs on two domains: +- **whyruby.info** — main content site +- **rubycommunity.org** — community/user profiles (production only) + +Domain config lives in `config/initializers/domains.rb`. In development, community pages are served under `/community` on localhost. In production, they live at the root of rubycommunity.org (e.g. `rubycommunity.org/username`). Production routes redirect `/community/*` on the primary domain to `rubycommunity.org/*` with 301s. + +**When linking to user profiles, always use `community_user_canonical_url(user)`** (or `community_root_canonical_url` for the index). These helpers resolve to the correct domain per environment. Never use `user_path`/`user_url` in user-facing links — those only produce the local `/community/...` paths. + +**Cross-domain authentication**: OAuth goes through the primary domain. A cross-domain token system (`AuthController`) syncs sessions between domains. Tokens are single-use and expire in 30 seconds. The `safe_return_to` method validates redirect URLs against allowed domains. + +**Footer legal links**: Use `main_site_url(path)` helper to ensure legal page links resolve to the primary domain when viewed on the community domain. + +### Services Layer + +Service objects in `app/services/` handle complex operations: +- `GithubDataFetcher`: Fetches and updates user GitHub profile data and repositories via GraphQL API on sign-in +- `ImageProcessor`: Processes featured images into multiple variants (small, medium, large, og) +- `SuccessStoryImageGenerator`: Generates OG images for success stories from SVG logos +- `SvgSanitizer`: Sanitizes SVG content to prevent XSS attacks +- `LocationNormalizer`: Geocodes free-text user locations into structured data (city, country, coordinates) using OpenAI +- `TimezoneResolver`: Resolves timezone from coordinates, normalizes legacy timezone identifiers +- `MetadataFetcher`: Fetches OpenGraph metadata from URLs for link posts + +### FriendlyId Implementation + +Both `User` and `Post` models use FriendlyId with history: +- `User`: Slugs from `username` +- `Post`: Slugs from `title` + +Both models implement `create_slug_history` to manually save old slugs when changed, ensuring historical URLs redirect properly. + +## Rails 8 Specific Patterns + +### Solid Stack Usage + +- **SolidQueue**: Default background job processor (no Redis needed) +- **SolidCache**: Database-backed caching +- **SolidCable**: WebSocket functionality via SQLite +- Configuration files: `config/queue.yml`, `config/cache.yml`, `config/cable.yml` + +### Modern Rails 8 Conventions + +- Use `params.expect()` for parameter handling instead of strong parameters (Rails 8.1 feature) +- Propshaft for asset pipeline (not Sprockets) +- Tailwind CSS 4 via `tailwindcss-rails` gem +- Hotwire (Turbo + Stimulus) for frontend interactivity + +### Migrations + +Always use UUIDv7 string primary keys. Never use auto-increment integers: + +```ruby +create_table :posts, id: false do |t| + t.primary_key :id, :string, default: -> { "uuid_generate_v7()" } + t.string :title, null: false + # ... +end +``` + +## Important Development Practices + +### Server & Logging + +- **ALWAYS** use `bin/dev` to start the server +- Check logs after every significant change +- Monitor `log/development.log` for errors and performance issues +- Review logs before considering any change complete + +### Code Style & Security + +The project uses lefthook to run checks before commits: +- **RuboCop**: Runs with auto-fix (`-A`) on all files, fixed files are automatically staged +- **Brakeman**: Runs `bin/brakeman --no-pager` for security scanning (must pass with zero warnings) +- Configuration in `.lefthook.yml`, `.rubocop.yml`, and `config/brakeman.ignore` + +### Testing + +- Framework: Minitest (Rails default) +- Fixtures in `test/fixtures/` +- Test structure: `test/models/`, `test/controllers/`, `test/system/` +- Run tests before committing + +## Credentials & Configuration + +### Development Credentials + +```bash +rails credentials:edit --environment development +``` + +Required credentials: +```yaml +github: + client_id: your_github_oauth_app_client_id + client_secret: your_github_oauth_app_client_secret + +openai: + api_key: your_openai_api_key # Optional - for AI summaries +``` + +### GitHub OAuth Setup + +1. Create OAuth App at: https://github.com/settings/developers +2. Set callback URL: `http://localhost:3000/users/auth/github/callback` +3. Add credentials to development credentials file + +### GeoLite2 Database (for analytics country tracking) + +The GeoLite2 database is used for IP geolocation (analytics country code). It's not redistributable, so it's gitignored and downloaded during Docker build. + +**Development setup:** +1. Create a free MaxMind account: https://www.maxmind.com/en/geolite2/signup +2. Generate a license key: https://www.maxmind.com/en/accounts/current/license-key +3. Download GeoLite2-Country database and place at: `db/GeoLite2-Country.mmdb` + +**Production setup:** +Add to production credentials (`rails credentials:edit --environment production`): +```yaml +maxmind: + account_id: your_account_id # Find at top of MaxMind license key page + license_key: your_license_key +``` + +The Dockerfile automatically downloads the database during build if both `MAXMIND_ACCOUNT_ID` and `MAXMIND_LICENSE_KEY` are provided. Note: MaxMind API changed in May 2024 to require both credentials. + +### Deployment + +- Configured for Kamal 2 deployment (see `config/deploy.yml`) +- Litestream for SQLite backups to S3 (see `config/litestream.yml`) +- RorVsWild for performance monitoring (see `config/rorvswild.yml`) + +## Admin Panel + +- Admin panel powered by Avo (v3.2+) +- Route: `/admin` (only accessible to admin users) +- Configuration: `app/avo/` directory +- Litestream backup UI: `/litestream` (admin only) + +## Markdown Rendering + +- Redcarpet for markdown parsing +- Rouge for syntax highlighting +- Markdown content stored in `Post#content` field for articles and success stories +- Custom helper: `ApplicationHelper#render_markdown_with_syntax_highlighting` diff --git a/CLAUDE.md b/CLAUDE.md index d89413f..43c994c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,242 +1 @@ -# CLAUDE.md - -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. - -## Project Overview - -WhyRuby.info is a Ruby advocacy community website built with Rails 8.1 (edge/main branch) and the Solid Stack (SQLite, SolidQueue, SolidCache, SolidCable). It features a universal content model supporting articles, links, and success stories with AI-generated summaries, GitHub OAuth authentication, and community-driven content moderation. - -## Development Commands - -### Server Management -```bash -# Start the development server (REQUIRED - do not use rails server directly) -bin/dev - -# Monitor logs in real-time -tail -f log/development.log -``` - -**IMPORTANT**: Always use `bin/dev` instead of `rails server`. This starts both the Rails server (port 3003) and Tailwind CSS watcher via Procfile.dev. - -### Database -```bash -# Create and setup database -rails db:create -rails db:migrate -rails db:seed - -# Database console -rails db - -# Promote user to admin -rails runner "User.find_by(username: 'github-username').update!(role: :admin)" -``` - -### Testing & Code Quality -```bash -# Run all tests -rails test - -# Run single test file -rails test test/models/post_test.rb - -# Run single test -rails test test/models/post_test.rb:line_number - -# Code style (auto-fixes and stages changes via lefthook) -bundle exec rubocop -bundle exec rubocop -A # Auto-correct issues -``` - -### Rails Console & Generators -```bash -# Console -rails console - -# Generate model with ULID primary key -rails generate model ModelName - -# Other generators -rails generate controller ControllerName -rails generate migration MigrationName -``` - -## Architecture & Key Patterns - -### Data Model Structure - -The application uses a **Universal Content Model** where the `Post` model handles three distinct content types via the `post_type` enum: - -- **Articles**: Original content with markdown (`content` field required, `url` must be blank) -- **Links**: External content (`url` required, `content` must be blank) -- **Success Stories**: Ruby success stories with SVG logo (`logo_svg` and `content` required, `url` must be blank) - -**Key Models:** -- `User`: GitHub OAuth authentication, role-based access (member/admin), trusted user system based on contribution counts (3+ published posts, 10+ published comments) -- `Post`: Universal content model with FriendlyId slugs, soft deletion via `archived` flag, counter caches for reports -- `Category`: Content organization with position ordering, special success story category flag -- `Tag`: HABTM relationship with posts -- `Comment`: User feedback on posts with published flag -- `Report`: Content moderation by trusted users, auto-hides after 3+ reports - -### Primary Keys & IDs - -**All tables use ULID instead of integer IDs:** -```ruby -create_table :table_name, force: true, id: false do |t| - t.primary_key :id, :string, default: -> { "ULID()" } - # ... -end -``` - -This is configured via the `sqlite-ulid` gem and provides better distribution and sortability. - -### Authentication & Authorization - -- **Authentication**: GitHub OAuth only via Devise + OmniAuth -- **Roles**: `member` (default) and `admin` (enum in User model) -- **Trusted Users**: Automatically qualified when `published_posts_count >= 3` AND `published_comments_count >= 10` -- **Permissions**: Only trusted users can report content; only admins can access `/admin` (Avo panel) - -### Content Moderation Flow - -1. Trusted users can report inappropriate content -2. After 3+ reports, content is automatically: - - Set to `published: false` - - Flagged with `needs_admin_review: true` - - Admin notification sent via `NotifyAdminJob` -3. Admins review and restore or permanently archive via Avo panel - -### Background Jobs (SolidQueue) - -- `GenerateSummaryJob`: Creates AI summaries for new/updated posts using OpenAI or Anthropic APIs -- `GenerateSuccessStoryImageJob`: Generates OG images for success stories from SVG logos -- `NotifyAdminJob`: Sends notifications when content is auto-hidden -- `UpdateGithubDataJob`: Refreshes user GitHub data (repositories, stars, etc.) - -### Image Processing - -Posts support `featured_image` attachments via ActiveStorage. Images are processed into multiple variants (small, medium, large, og) and stored as separate blobs with metadata in `image_variants` JSON column. Processing uses the `ImageProcessor` service. - -### URL Routing Pattern - -The application uses a **catch-all routing strategy** for clean URLs: - -``` -/:category_slug # Category pages -/:category_slug/:post_slug # Post pages -``` - -**Important**: Category and post routes use constraints and are defined at the END of routes.rb to avoid conflicts with explicit routes like `/admin`, `/community`, `/legal/*`, etc. - -### Services Layer - -Service objects in `app/services/` handle complex operations: -- `GithubDataFetcher`: Fetches and updates user GitHub profile data and repositories on sign-in -- `ImageProcessor`: Processes featured images into multiple variants (small, medium, large, og) -- `SuccessStoryImageGenerator`: Generates OG images for success stories from SVG logos -- `SvgSanitizer`: Sanitizes SVG content to prevent XSS attacks - -### FriendlyId Implementation - -Both `User` and `Post` models use FriendlyId with history: -- `User`: Slugs from `username` -- `Post`: Slugs from `title` - -Both models implement `create_slug_history` to manually save old slugs when changed, ensuring historical URLs redirect properly. - -## Rails 8 Specific Patterns - -### Solid Stack Usage - -- **SolidQueue**: Default background job processor (no Redis needed) -- **SolidCache**: Database-backed caching -- **SolidCable**: WebSocket functionality via SQLite -- Configuration files: `config/queue.yml`, `config/cache.yml`, `config/cable.yml` - -### Modern Rails 8 Conventions - -- Use `params.expect()` for parameter handling instead of strong parameters (Rails 8.1 feature) -- Propshaft for asset pipeline (not Sprockets) -- Tailwind CSS 4 via `tailwindcss-rails` gem -- Hotwire (Turbo + Stimulus) for frontend interactivity - -### Migrations - -Always use ULID primary keys. Never use auto-increment integers: - -```ruby -create_table :posts, force: true, id: false do |t| - t.primary_key :id, :string, default: -> { "ULID()" } - t.string :title, null: false - # ... -end -``` - -## Important Development Practices - -### Server & Logging - -- **ALWAYS** use `bin/dev` to start the server -- Check logs after every significant change -- Monitor `log/development.log` for errors and performance issues -- Review logs before considering any change complete - -### Code Style - -The project uses lefthook to automatically run RuboCop before commits: -- RuboCop runs with auto-fix (`-A`) on all files -- Fixed files are automatically staged -- Configuration in `.lefthook.yml` and `.rubocop.yml` - -### Testing - -- Framework: Minitest (Rails default) -- Fixtures in `test/fixtures/` -- Test structure: `test/models/`, `test/controllers/`, `test/system/` -- Run tests before committing - -## Credentials & Configuration - -### Development Credentials - -```bash -rails credentials:edit --environment development -``` - -Required credentials: -```yaml -github: - client_id: your_github_oauth_app_client_id - client_secret: your_github_oauth_app_client_secret - -openai: - api_key: your_openai_api_key # Optional - for AI summaries -``` - -### GitHub OAuth Setup - -1. Create OAuth App at: https://github.com/settings/developers -2. Set callback URL: `http://localhost:3000/users/auth/github/callback` -3. Add credentials to development credentials file - -### Deployment - -- Configured for Kamal 2 deployment (see `config/deploy.yml`) -- Litestream for SQLite backups to S3 (see `config/litestream.yml`) -- RorVsWild for performance monitoring (see `config/rorvswild.yml`) - -## Admin Panel - -- Admin panel powered by Avo (v3.2+) -- Route: `/admin` (only accessible to admin users) -- Configuration: `app/avo/` directory -- Litestream backup UI: `/litestream` (admin only) - -## Markdown Rendering - -- Redcarpet for markdown parsing -- Rouge for syntax highlighting -- Markdown content stored in `Post#content` field for articles and success stories -- Custom helper: `ApplicationHelper#render_markdown_with_syntax_highlighting` +@AGENTS.md diff --git a/Dockerfile b/Dockerfile index 97a7457..6729d77 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,7 +8,7 @@ # For a containerized dev environment, see Dev Containers: https://guides.rubyonrails.org/getting_started_with_devcontainer.html # Make sure RUBY_VERSION matches the Ruby version in .ruby-version -ARG RUBY_VERSION=4.0.0 +ARG RUBY_VERSION=4.0.1 FROM docker.io/library/ruby:$RUBY_VERSION-slim AS base # Rails app lives here @@ -46,6 +46,21 @@ RUN bundle install && \ # Copy application code COPY . . +# Download MaxMind GeoLite2 database for IP geolocation (requires account ID and license key) +# MaxMind API changed in May 2024 to require Basic Auth with account_id:license_key +RUN --mount=type=secret,id=MAXMIND_ACCOUNT_ID \ + --mount=type=secret,id=MAXMIND_LICENSE_KEY \ + if [ -f /run/secrets/MAXMIND_ACCOUNT_ID ] && [ -f /run/secrets/MAXMIND_LICENSE_KEY ]; then \ + ACCOUNT_ID="$(cat /run/secrets/MAXMIND_ACCOUNT_ID)" && \ + LICENSE_KEY="$(cat /run/secrets/MAXMIND_LICENSE_KEY)" && \ + curl -sL -u "${ACCOUNT_ID}:${LICENSE_KEY}" \ + "https://download.maxmind.com/geoip/databases/GeoLite2-Country/download?suffix=tar.gz" | \ + tar -xzf - --strip-components=1 -C db/ --wildcards "*/*.mmdb" && \ + echo "GeoLite2 database downloaded successfully"; \ + else \ + echo "MAXMIND credentials not provided, skipping GeoLite2 download"; \ + fi + # Precompile bootsnap code for faster boot times RUN bundle exec bootsnap precompile app/ lib/ diff --git a/Gemfile b/Gemfile index 2e8ebd5..be06842 100644 --- a/Gemfile +++ b/Gemfile @@ -6,7 +6,7 @@ gem "rails", github: "rails/rails", branch: "main" gem "propshaft" # Use sqlite3 as the database for Active Record gem "sqlite3", ">= 2.1" -gem "sqlite-ulid" +gem "sqlean", "~> 0.2" # Use the Puma web server [https://github.com/puma/puma] gem "puma", ">= 5.0" # Use JavaScript with ESM import maps [https://github.com/rails/importmap-rails] @@ -56,9 +56,16 @@ gem "anthropic", "~> 1.6.0" # Pagination gem "kaminari", "~> 1.2" +# IP Geolocation (for analytics country code) +gem "geocoder", "~> 1.8" +gem "maxminddb", "~> 0.1" + # Friendly URLs gem "friendly_id", "~> 5.5" +# Timezone lookup from coordinates (offline, pure Ruby) +gem "wheretz" + # HTML/XML parsing gem "nokogiri", "~> 1.16" @@ -87,6 +94,7 @@ group :test do # Use system testing [https://guides.rubyonrails.org/testing.html#system-testing] gem "capybara" gem "selenium-webdriver" + gem "webmock" end # Backup data to S3 @@ -94,3 +102,5 @@ gem "litestream", "~> 0.14.0" # Monitor performance gem "rorvswild", "~> 1.9" + +gem "tidewave", "~> 0.4.1", group: :development diff --git a/Gemfile.lock b/Gemfile.lock index 087eec0..eba38de 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,6 +1,6 @@ GIT remote: https://github.com/rails/rails.git - revision: aacf8a305f304dd88535240d5a97318858c0afb6 + revision: 65e0e67906433b550377fd201a4d512f643e2b28 branch: main specs: actioncable (8.2.0.alpha) @@ -34,7 +34,7 @@ GIT rails-html-sanitizer (~> 1.6) useragent (~> 0.16) actiontext (8.2.0.alpha) - action_text-trix (~> 2.1.15) + action_text-trix (~> 2.1.16) actionpack (= 8.2.0.alpha) activerecord (= 8.2.0.alpha) activestorage (= 8.2.0.alpha) @@ -113,7 +113,7 @@ GEM anthropic (1.6.0) connection_pool ast (2.4.3) - avo (3.27.0) + avo (3.28.0) actionview (>= 6.1) active_link_to activerecord (>= 6.1) @@ -135,9 +135,9 @@ GEM bcrypt_pbkdf (1.1.2) bigdecimal (4.0.1) bindex (0.8.1) - bootsnap (1.20.1) + bootsnap (1.21.1) msgpack (~> 1.2) - brakeman (7.1.2) + brakeman (8.0.2) racc builder (3.3.0) capybara (3.40.0) @@ -151,7 +151,11 @@ GEM xpath (~> 3.2) concurrent-ruby (1.3.6) connection_pool (3.0.2) + crack (1.0.1) + bigdecimal + rexml crass (1.0.6) + csv (3.3.5) date (3.5.1) debug (1.11.1) irb (~> 1.10) @@ -165,6 +169,35 @@ GEM docile (1.4.1) dotenv (3.2.0) drb (2.2.3) + dry-configurable (1.3.0) + dry-core (~> 1.1) + zeitwerk (~> 2.6) + dry-core (1.2.0) + concurrent-ruby (~> 1.0) + logger + zeitwerk (~> 2.6) + dry-inflector (1.3.1) + dry-initializer (3.2.0) + dry-logic (1.6.0) + bigdecimal + concurrent-ruby (~> 1.0) + dry-core (~> 1.1) + zeitwerk (~> 2.6) + dry-schema (1.15.0) + concurrent-ruby (~> 1.0) + dry-configurable (~> 1.0, >= 1.0.1) + dry-core (~> 1.1) + dry-initializer (~> 3.2) + dry-logic (~> 1.6) + dry-types (~> 1.8) + zeitwerk (~> 2.6) + dry-types (1.9.0) + bigdecimal (>= 3.0) + concurrent-ruby (~> 1.0) + dry-core (~> 1.0) + dry-inflector (~> 1.0) + dry-logic (~> 1.4) + zeitwerk (~> 2.6) ed25519 (1.4.0) erb (6.0.1) erubi (1.13.1) @@ -179,18 +212,29 @@ GEM multipart-post (~> 2.0) faraday-net_http (3.4.2) net-http (~> 0.5) + fast-mcp (1.6.0) + addressable (~> 2.8) + base64 + dry-schema (~> 1.14) + json (~> 2.0) + mime-types (~> 3.4) + rack (>= 2.0, < 4.0) friendly_id (5.6.0) activerecord (>= 4.0.0) fugit (1.12.1) et-orbi (~> 1.4) raabro (~> 1.4) + geocoder (1.8.6) + base64 (>= 0.1.0) + csv (>= 3.0.0) globalid (1.3.0) activesupport (>= 6.1) + hashdiff (1.2.1) hashie (5.1.0) logger i18n (1.14.8) concurrent-ruby (~> 1.0) - importmap-rails (2.2.2) + importmap-rails (2.2.3) actionpack (>= 6.0.0) activesupport (>= 6.0.0) railties (>= 6.0.0) @@ -232,8 +276,15 @@ GEM kaminari-core (= 1.2.2) kaminari-core (1.2.2) language_server-protocol (3.17.0.5) - lefthook (2.0.13) + lefthook (2.0.16) lint_roller (1.1.0) + litestream (0.14.0-aarch64-linux) + actionpack (>= 7.0) + actionview (>= 7.0) + activejob (>= 7.0) + activesupport (>= 7.0) + railties (>= 7.0) + sqlite3 litestream (0.14.0-arm64-darwin) actionpack (>= 7.0) actionview (>= 7.0) @@ -260,13 +311,18 @@ GEM net-smtp marcel (1.1.0) matrix (0.4.3) - meta-tags (2.22.2) - actionpack (>= 6.0.0, < 8.2) + maxminddb (0.1.22) + meta-tags (2.22.3) + actionpack (>= 6.0.0) + mime-types (3.7.0) + logger + mime-types-data (~> 3.2025, >= 3.2025.0507) + mime-types-data (3.2026.0127) mini_mime (1.1.5) minitest (6.0.1) prism (~> 1.5) msgpack (1.8.0) - multi_xml (0.8.0) + multi_xml (0.8.1) bigdecimal (>= 3.1, < 5) multipart-post (2.4.1) net-http (0.9.1) @@ -286,6 +342,8 @@ GEM net-protocol net-ssh (7.3.0) nio4r (2.7.5) + nokogiri (1.19.0-aarch64-linux-gnu) + racc (~> 1.4) nokogiri (1.19.0-arm64-darwin) racc (~> 1.4) nokogiri (1.19.0-x86_64-linux-gnu) @@ -318,13 +376,13 @@ GEM ostruct (0.6.3) pagy (9.4.0) parallel (1.27.0) - parser (3.3.10.0) + parser (3.3.10.1) ast (~> 2.4.1) racc pp (0.6.3) prettyprint prettyprint (0.2.0) - prism (1.7.0) + prism (1.9.0) prop_initializer (0.2.0) zeitwerk (>= 2.6.18) propshaft (1.3.1) @@ -334,8 +392,8 @@ GEM psych (5.3.1) date stringio - public_suffix (7.0.0) - puma (7.1.0) + public_suffix (7.0.2) + puma (7.2.0) nio4r (~> 2.0) raabro (1.4.0) racc (1.8.1) @@ -360,7 +418,7 @@ GEM nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) rainbow (3.1.1) rake (13.3.1) - rdoc (7.0.3) + rdoc (7.1.0) erb psych (>= 4.0.0) tsort @@ -374,7 +432,7 @@ GEM rexml (3.4.4) rorvswild (1.10.1) rouge (4.7.0) - rubocop (1.82.1) + rubocop (1.84.0) json (~> 2.3) language_server-protocol (~> 3.17.0.2) lint_roller (~> 1.1.0) @@ -382,7 +440,7 @@ GEM parser (>= 3.3.0.2) rainbow (>= 2.2.2, < 4.0) regexp_parser (>= 2.9.3, < 3.0) - rubocop-ast (>= 1.48.0, < 2.0) + rubocop-ast (>= 1.49.0, < 2.0) ruby-progressbar (~> 1.7) unicode-display_width (>= 2.4.0, < 4.0) rubocop-ast (1.49.0) @@ -392,7 +450,7 @@ GEM lint_roller (~> 1.1) rubocop (>= 1.75.0, < 2.0) rubocop-ast (>= 1.47.1, < 2.0) - rubocop-rails (2.34.2) + rubocop-rails (2.34.3) activesupport (>= 4.2.0) lint_roller (~> 1.1) rack (>= 1.1) @@ -409,7 +467,7 @@ GEM ruby-progressbar (1.13.0) rubyzip (3.2.2) securerandom (0.4.1) - selenium-webdriver (4.39.0) + selenium-webdriver (4.40.0) base64 (~> 0.2) logger (~> 1.4) rexml (~> 3.2, >= 3.2.5) @@ -427,15 +485,18 @@ GEM activejob (>= 7.2) activerecord (>= 7.2) railties (>= 7.2) - solid_queue (1.2.4) + solid_queue (1.3.1) activejob (>= 7.1) activerecord (>= 7.1) concurrent-ruby (>= 1.3.1) fugit (~> 1.11) railties (>= 7.1) thor (>= 1.3.1) - sqlite-ulid (0.2.1-arm64-darwin) - sqlite-ulid (0.2.1-x86_64-linux) + sqlean (0.3.0-aarch64-linux-gnu) + sqlean (0.3.0-arm64-darwin) + sqlean (0.3.0-x86_64-linux-gnu) + sqlean (0.3.0-x86_64-linux-musl) + sqlite3 (2.9.0-aarch64-linux-gnu) sqlite3 (2.9.0-arm64-darwin) sqlite3 (2.9.0-x86_64-linux-gnu) sqlite3 (2.9.0-x86_64-linux-musl) @@ -452,15 +513,21 @@ GEM tailwindcss-rails (4.4.0) railties (>= 7.0.0) tailwindcss-ruby (~> 4.0) + tailwindcss-ruby (4.1.18-aarch64-linux-gnu) tailwindcss-ruby (4.1.18-arm64-darwin) tailwindcss-ruby (4.1.18-x86_64-linux-gnu) tailwindcss-ruby (4.1.18-x86_64-linux-musl) - thor (1.4.0) + thor (1.5.0) + thruster (0.1.17-aarch64-linux) thruster (0.1.17-arm64-darwin) thruster (0.1.17-x86_64-linux) + tidewave (0.4.1) + fast-mcp (~> 1.6.0) + rack (>= 2.0) + rails (>= 7.1.0) timeout (0.6.0) tsort (0.2.0) - turbo-rails (2.0.20) + turbo-rails (2.0.23) actionpack (>= 7.1.0) railties (>= 7.1.0) turbo_power (0.7.0) @@ -473,9 +540,9 @@ GEM uri (1.1.1) useragent (0.16.11) version_gem (1.1.9) - view_component (4.1.1) - actionview (>= 7.1.0, < 8.2) - activesupport (>= 7.1.0, < 8.2) + view_component (4.2.0) + actionview (>= 7.1.0) + activesupport (>= 7.1.0) concurrent-ruby (~> 1) warden (1.2.9) rack (>= 2.0.9) @@ -484,16 +551,23 @@ GEM activemodel (>= 6.0.0) bindex (>= 0.4.0) railties (>= 6.0.0) + webmock (3.26.1) + addressable (>= 2.8.0) + crack (>= 0.3.2) + hashdiff (>= 0.4.0, < 2.0.0) websocket (1.2.11) websocket-driver (0.8.0) base64 websocket-extensions (>= 0.1.0) websocket-extensions (0.1.5) + wheretz (0.0.6) xpath (3.2.0) nokogiri (~> 1.8) zeitwerk (2.7.4) PLATFORMS + aarch64-linux + aarch64-linux-gnu arm64-darwin-25 x86_64-linux x86_64-linux-gnu @@ -509,12 +583,14 @@ DEPENDENCIES devise (~> 4.9) dotenv friendly_id (~> 5.5) + geocoder (~> 1.8) importmap-rails jbuilder kamal kaminari (~> 1.2) lefthook litestream (~> 0.14.0) + maxminddb (~> 0.1) nokogiri (~> 1.16) omniauth-github (~> 2.0) omniauth-rails_csrf_protection (~> 1.0) @@ -530,13 +606,16 @@ DEPENDENCIES solid_cable solid_cache solid_queue - sqlite-ulid + sqlean (~> 0.2) sqlite3 (>= 2.1) stimulus-rails tailwindcss-rails (~> 4.0) thruster + tidewave (~> 0.4.1) turbo-rails web-console + webmock + wheretz CHECKSUMS action_text-trix (2.1.16) sha256=f645a2c21821b8449fd1d6770708f4031c91a2eedf9ef476e9be93c64e703a8a @@ -555,26 +634,35 @@ CHECKSUMS addressable (2.8.8) sha256=7c13b8f9536cf6364c03b9d417c19986019e28f7c00ac8132da4eb0fe393b057 anthropic (1.6.0) sha256=61fa13d73f54d8174bf8b45cc058768e79e824ebc2aefc9417a2b94d9127ab75 ast (2.4.3) sha256=954615157c1d6a382bc27d690d973195e79db7f55e9765ac7c481c60bdb4d383 - avo (3.27.0) sha256=586f9be3848d7d190e241091332448b33bc41d1fe8fdc06a725baa2d544f6f15 + avo (3.28.0) sha256=9a7ab701f41ee201b87553a36f0d34d4fd03a7c7737aeba6c0da09ba5a031910 avo-icons (0.1.1) sha256=d9a23d6d47bb7f8f04163119352a66a436dc8accf53f15cd0c3b5fcaffed082c base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b bcrypt (3.1.21) sha256=5964613d750a42c7ee5dc61f7b9336fb6caca429ba4ac9f2011609946e4a2dcf bcrypt_pbkdf (1.1.2) sha256=c2414c23ce66869b3eb9f643d6a3374d8322dfb5078125c82792304c10b94cf6 bigdecimal (4.0.1) sha256=8b07d3d065a9f921c80ceaea7c9d4ae596697295b584c296fe599dd0ad01c4a7 bindex (0.8.1) sha256=7b1ecc9dc539ed8bccfc8cb4d2732046227b09d6f37582ff12e50a5047ceb17e - bootsnap (1.20.1) sha256=7ad62cda65c5157bcca0acfcc0ee11fcbb83d7d7a8a72d52ccd85e6ffc130b93 - brakeman (7.1.2) sha256=6b04927710a2e7d13a72248b5d404c633188e02417f28f3d853e4b6370d26dce + bootsnap (1.21.1) sha256=9373acfe732da35846623c337d3481af8ce77c7b3a927fb50e9aa92b46dbc4c4 + brakeman (8.0.2) sha256=7b02065ce8b1de93949cefd3f2ad78e8eb370e644b95c8556a32a912a782426a builder (3.3.0) sha256=497918d2f9dca528fdca4b88d84e4ef4387256d984b8154e9d5d3fe5a9c8835f capybara (3.40.0) sha256=42dba720578ea1ca65fd7a41d163dd368502c191804558f6e0f71b391054aeef concurrent-ruby (1.3.6) sha256=6b56837e1e7e5292f9864f34b69c5a2cbc75c0cf5338f1ce9903d10fa762d5ab connection_pool (3.0.2) sha256=33fff5ba71a12d2aa26cb72b1db8bba2a1a01823559fb01d29eb74c286e62e0a + crack (1.0.1) sha256=ff4a10390cd31d66440b7524eb1841874db86201d5b70032028553130b6d4c7e crass (1.0.6) sha256=dc516022a56e7b3b156099abc81b6d2b08ea1ed12676ac7a5657617f012bd45d + csv (3.3.5) sha256=6e5134ac3383ef728b7f02725d9872934f523cb40b961479f69cf3afa6c8e73f date (3.5.1) sha256=750d06384d7b9c15d562c76291407d89e368dda4d4fff957eb94962d325a0dc0 debug (1.11.1) sha256=2e0b0ac6119f2207a6f8ac7d4a73ca8eb4e440f64da0a3136c30343146e952b6 devise (4.9.4) sha256=920042fe5e704c548aa4eb65ebdd65980b83ffae67feb32c697206bfd975a7f8 docile (1.4.1) sha256=96159be799bfa73cdb721b840e9802126e4e03dfc26863db73647204c727f21e dotenv (3.2.0) sha256=e375b83121ea7ca4ce20f214740076129ab8514cd81378161f11c03853fe619d drb (2.2.3) sha256=0b00d6fdb50995fe4a45dea13663493c841112e4068656854646f418fda13373 + dry-configurable (1.3.0) sha256=882d862858567fc1210d2549d4c090f34370fc1bb7c5c1933de3fe792e18afa8 + dry-core (1.2.0) sha256=0cc5a7da88df397f153947eeeae42e876e999c1e30900f3c536fb173854e96a1 + dry-inflector (1.3.1) sha256=7fb0c2bb04f67638f25c52e7ba39ab435d922a3a5c3cd196120f63accb682dcc + dry-initializer (3.2.0) sha256=37d59798f912dc0a1efe14a4db4a9306989007b302dcd5f25d0a2a20c166c4e3 + dry-logic (1.6.0) sha256=da6fedbc0f90fc41f9b0cc7e6f05f5d529d1efaef6c8dcc8e0733f685745cea2 + dry-schema (1.15.0) sha256=0f2a34adba4206bd6d46ec1b6b7691b402e198eecaff1d8349a7d48a77d82cd2 + dry-types (1.9.0) sha256=7b656fe0a78d2432500ae1f29fefd6762f5a032ca7000e4f36bc111453d45d4d ed25519 (1.4.0) sha256=16e97f5198689a154247169f3453ef4cfd3f7a47481fde0ae33206cdfdcac506 erb (6.0.1) sha256=28ecdd99c5472aebd5674d6061e3c6b0a45c049578b071e5a52c2a7f13c197e5 erubi (1.13.1) sha256=a082103b0885dbc5ecf1172fede897f9ebdb745a4b97a5e8dc63953db1ee4ad9 @@ -583,12 +671,15 @@ CHECKSUMS faraday (2.14.0) sha256=8699cfe5d97e55268f2596f9a9d5a43736808a943714e3d9a53e6110593941cd faraday-multipart (1.2.0) sha256=7d89a949693714176f612323ca13746a2ded204031a6ba528adee788694ef757 faraday-net_http (3.4.2) sha256=f147758260d3526939bf57ecf911682f94926a3666502e24c69992765875906c + fast-mcp (1.6.0) sha256=d68abb45d2daab9e7ae2934417460e4bf9ac87493c585dc5bb626f1afb7d12c4 friendly_id (5.6.0) sha256=28e221cd53fbd21586321164c1c6fd0c9ba8dde13969cb2363679f44726bb0c3 fugit (1.12.1) sha256=5898f478ede9b415f0804e42b8f3fd53f814bd85eebffceebdbc34e1107aaf68 + geocoder (1.8.6) sha256=e0ca1554b499f466de9b003f7dff70f89a5888761c2ca68ed9f86b6e5e24e74c globalid (1.3.0) sha256=05c639ad6eb4594522a0b07983022f04aa7254626ab69445a0e493aa3786ff11 + hashdiff (1.2.1) sha256=9c079dbc513dfc8833ab59c0c2d8f230fa28499cc5efb4b8dd276cf931457cd1 hashie (5.1.0) sha256=c266471896f323c446ea8207f8ffac985d2718df0a0ba98651a3057096ca3870 i18n (1.14.8) sha256=285778639134865c5e0f6269e0b818256017e8cde89993fdfcbfb64d088824a5 - importmap-rails (2.2.2) sha256=729f5b1092f832780829ade1d0b46c7e53d91c556f06da7254da2977e93fe614 + importmap-rails (2.2.3) sha256=7101be2a4dc97cf1558fb8f573a718404c5f6bcfe94f304bf1f39e444feeb16a inline_svg (1.10.0) sha256=5b652934236fd9f8adc61f3fd6e208b7ca3282698b19f28659971da84bf9a10f io-console (0.8.2) sha256=d6e3ae7a7cc7574f4b8893b4fca2162e57a825b223a177b7afa236c5ef9814cc irb (1.16.0) sha256=2abe56c9ac947cdcb2f150572904ba798c1e93c890c256f8429981a7675b0806 @@ -601,8 +692,9 @@ CHECKSUMS kaminari-activerecord (1.2.2) sha256=0dd3a67bab356a356f36b3b7236bcb81cef313095365befe8e98057dd2472430 kaminari-core (1.2.2) sha256=3bd26fec7370645af40ca73b9426a448d09b8a8ba7afa9ba3c3e0d39cdbb83ff language_server-protocol (3.17.0.5) sha256=fd1e39a51a28bf3eec959379985a72e296e9f9acfce46f6a79d31ca8760803cc - lefthook (2.0.13) sha256=e6fc347a656b412f72bfc82b4491b9bf5f69937498c4ab1d29cd4bc8c0152912 + lefthook (2.0.16) sha256=c23ac3732ef9e7c6e9db4bc97bc4813d1a648db5d00e0f70cca426d04f3edf2e lint_roller (1.1.0) sha256=2c0c845b632a7d172cb849cc90c1bce937a28c5c8ccccb50dfd46a485003cc87 + litestream (0.14.0-aarch64-linux) sha256=bcf9199a665e673e27f929a0941011e50fb8ebf441d9754247686b514fba60d5 litestream (0.14.0-arm64-darwin) sha256=507bbb7ee99b3398304c5ef4a9bae835761359ffc72850f25708477805313d07 litestream (0.14.0-x86_64-linux) sha256=2844734b6d8e5c6009baf8d138d6f18367f770e9e4390fb70763433db587bed6 logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203 @@ -610,11 +702,14 @@ CHECKSUMS mail (2.9.0) sha256=6fa6673ecd71c60c2d996260f9ee3dd387d4673b8169b502134659ece6d34941 marcel (1.1.0) sha256=fdcfcfa33cc52e93c4308d40e4090a5d4ea279e160a7f6af988260fa970e0bee matrix (0.4.3) sha256=a0d5ab7ddcc1973ff690ab361b67f359acbb16958d1dc072b8b956a286564c5b - meta-tags (2.22.2) sha256=7fe78af4a92be12091f473cb84a21f6bddbd37f24c4413172df76cd14fff9e83 + maxminddb (0.1.22) sha256=50933be438fbed9dceabef4163eab41884bd8830d171fdb8f739bee769c4907e + meta-tags (2.22.3) sha256=41ead5437140869717cbdd659cc6f1caa3e498b3e74b03ed63503b5b38ed504f + mime-types (3.7.0) sha256=dcebf61c246f08e15a4de34e386ebe8233791e868564a470c3fe77c00eed5e56 + mime-types-data (3.2026.0127) sha256=4a58692436a987ad930e75bf8f24da7e627acfa0d06e1720aa514791b4c7d12b mini_mime (1.1.5) sha256=8681b7e2e4215f2a159f9400b5816d85e9d8c6c6b491e96a12797e798f8bccef minitest (6.0.1) sha256=7854c74f48e2e975969062833adc4013f249a4b212f5e7b9d5c040bf838d54bb msgpack (1.8.0) sha256=e64ce0212000d016809f5048b48eb3a65ffb169db22238fb4b72472fecb2d732 - multi_xml (0.8.0) sha256=8d4adcd092f8e354db496109829ffd36969fdc8392cb5fde398ca800d9e6df73 + multi_xml (0.8.1) sha256=addba0290bac34e9088bfe73dc4878530297a82a7bbd66cb44dcd0a4b86edf5a multipart-post (2.4.1) sha256=9872d03a8e552020ca096adadbf5e3cb1cd1cdd6acd3c161136b8a5737cdb4a8 net-http (0.9.1) sha256=25ba0b67c63e89df626ed8fac771d0ad24ad151a858af2cc8e6a716ca4336996 net-imap (0.6.2) sha256=08caacad486853c61676cca0c0c47df93db02abc4a8239a8b67eb0981428acc6 @@ -625,6 +720,7 @@ CHECKSUMS net-smtp (0.5.1) sha256=ed96a0af63c524fceb4b29b0d352195c30d82dd916a42f03c62a3a70e5b70736 net-ssh (7.3.0) sha256=172076c4b30ce56fb25a03961b0c4da14e1246426401b0f89cba1a3b54bf3ef0 nio4r (2.7.5) sha256=6c90168e48fb5f8e768419c93abb94ba2b892a1d0602cb06eef16d8b7df1dca1 + nokogiri (1.19.0-aarch64-linux-gnu) sha256=11a97ecc3c0e7e5edcf395720b10860ef493b768f6aa80c539573530bc933767 nokogiri (1.19.0-arm64-darwin) sha256=0811dfd936d5f6dd3f6d32ef790568bf29b2b7bead9ba68866847b33c9cf5810 nokogiri (1.19.0-x86_64-linux-gnu) sha256=f482b95c713d60031d48c44ce14562f8d2ce31e3a9e8dd0ccb131e9e5a68b58c nokogiri (1.19.0-x86_64-linux-musl) sha256=1c4ca6b381622420073ce6043443af1d321e8ed93cc18b08e2666e5bd02ffae4 @@ -637,15 +733,15 @@ CHECKSUMS ostruct (0.6.3) sha256=95a2ed4a4bd1d190784e666b47b2d3f078e4a9efda2fccf18f84ddc6538ed912 pagy (9.4.0) sha256=db3f2e043f684155f18f78be62a81e8d033e39b9f97b1e1a8d12ad38d7bce738 parallel (1.27.0) sha256=4ac151e1806b755fb4e2dc2332cbf0e54f2e24ba821ff2d3dcf86bf6dc4ae130 - parser (3.3.10.0) sha256=ce3587fa5cc55a88c4ba5b2b37621b3329aadf5728f9eafa36bbd121462aabd6 + parser (3.3.10.1) sha256=06f6a725d2cd91e5e7f2b7c32ba143631e1f7c8ae2fb918fc4cebec187e6a688 pp (0.6.3) sha256=2951d514450b93ccfeb1df7d021cae0da16e0a7f95ee1e2273719669d0ab9df6 prettyprint (0.2.0) sha256=2bc9e15581a94742064a3cc8b0fb9d45aae3d03a1baa6ef80922627a0766f193 - prism (1.7.0) sha256=10062f734bf7985c8424c44fac382ac04a58124ea3d220ec3ba9fe4f2da65103 + prism (1.9.0) sha256=7b530c6a9f92c24300014919c9dcbc055bf4cdf51ec30aed099b06cd6674ef85 prop_initializer (0.2.0) sha256=bd27704d0df8c59c3baf0df5cf448eba2b140fb9934fb31b2e379b5c842d8820 propshaft (1.3.1) sha256=9acc664ef67e819ffa3d95bd7ad4c3623ea799110c5f4dee67fa7e583e74c392 psych (5.3.1) sha256=eb7a57cef10c9d70173ff74e739d843ac3b2c019a003de48447b2963d81b1974 - public_suffix (7.0.0) sha256=f7090b5beb0e56f9f10d79eed4d5fbe551b3b425da65877e075dad47a6a1b095 - puma (7.1.0) sha256=e45c10cb124f224d448c98db653a75499794edbecadc440ad616cf50f2fd49dd + public_suffix (7.0.2) sha256=9114090c8e4e7135c1fd0e7acfea33afaab38101884320c65aaa0ffb8e26a857 + puma (7.2.0) sha256=bf8ef4ab514a4e6d4554cb4326b2004eba5036ae05cf765cfe51aba9706a72a8 raabro (1.4.0) sha256=d4fa9ff5172391edb92b242eed8be802d1934b1464061ae5e70d80962c5da882 racc (1.8.1) sha256=4a7f6929691dbec8b5209a0b373bc2614882b55fc5d2e447a21aaa691303d62f rack (3.2.4) sha256=5d74b6f75082a643f43c1e76b419c40f0e5527fcfee1e669ac1e6b73c0ccb6f6 @@ -659,7 +755,7 @@ CHECKSUMS railties (8.2.0.alpha) rainbow (3.1.1) sha256=039491aa3a89f42efa1d6dec2fc4e62ede96eb6acd95e52f1ad581182b79bc6a rake (13.3.1) sha256=8c9e89d09f66a26a01264e7e3480ec0607f0c497a861ef16063604b1b08eb19c - rdoc (7.0.3) sha256=dfe3d0981d19b7bba71d9dbaeb57c9f4e3a7a4103162148a559c4fc687ea81f9 + rdoc (7.1.0) sha256=494899df0706c178596ca6e1d50f1b7eb285a9b2aae715be5abd742734f17363 redcarpet (3.6.1) sha256=d444910e6aa55480c6bcdc0cdb057626e8a32c054c29e793fa642ba2f155f445 regexp_parser (2.11.3) sha256=ca13f381a173b7a93450e53459075c9b76a10433caadcb2f1180f2c741fc55a4 reline (0.6.3) sha256=1198b04973565b36ec0f11542ab3f5cfeeec34823f4e54cebde90968092b1835 @@ -667,22 +763,25 @@ CHECKSUMS rexml (3.4.4) sha256=19e0a2c3425dfbf2d4fc1189747bdb2f849b6c5e74180401b15734bc97b5d142 rorvswild (1.10.1) sha256=eb3f64611e48661f275747934961bf8585315702ee8913f220ccae3e65ac3f56 rouge (4.7.0) sha256=dba5896715c0325c362e895460a6d350803dbf6427454f49a47500f3193ea739 - rubocop (1.82.1) sha256=09f1a6a654a960eda767aebea33e47603080f8e9c9a3f019bf9b94c9cab5e273 + rubocop (1.84.0) sha256=88dec310153bb685a879f5a7cdb601f6287b8f0ee675d9dc63a17c7204c4190a rubocop-ast (1.49.0) sha256=49c3676d3123a0923d333e20c6c2dbaaae2d2287b475273fddee0c61da9f71fd rubocop-performance (1.26.1) sha256=cd19b936ff196df85829d264b522fd4f98b6c89ad271fa52744a8c11b8f71834 - rubocop-rails (2.34.2) sha256=10ff246ee48b25ffeabddc5fee86d159d690bb3c7b9105755a9c7508a11d6e22 + rubocop-rails (2.34.3) sha256=10d37989024865ecda8199f311f3faca990143fbac967de943f88aca11eb9ad2 rubocop-rails-omakase (1.1.0) sha256=2af73ac8ee5852de2919abbd2618af9c15c19b512c4cfc1f9a5d3b6ef009109d ruby-openai (8.3.0) sha256=566dc279c42f4afed68a7a363dce2e594078abfc36b4e043102020b9a387ca69 ruby-progressbar (1.13.0) sha256=80fc9c47a9b640d6834e0dc7b3c94c9df37f08cb072b7761e4a71e22cff29b33 rubyzip (3.2.2) sha256=c0ed99385f0625415c8f05bcae33fe649ed2952894a95ff8b08f26ca57ea5b3c securerandom (0.4.1) sha256=cc5193d414a4341b6e225f0cb4446aceca8e50d5e1888743fac16987638ea0b1 - selenium-webdriver (4.39.0) sha256=984a1e63d39472eaf286bac3c6f1822fa7eea6eed9c07a66ce7b3bc5417ba826 + selenium-webdriver (4.40.0) sha256=16ef7aa9853c1d4b9d52eac45aafa916e3934c5c83cb4facb03f250adfd15e5b snaky_hash (2.0.3) sha256=25a3d299566e8153fb02fa23fd9a9358845950f7a523ddbbe1fa1e0d79a6d456 solid_cable (3.0.12) sha256=a168a54731a455d5627af48d8441ea3b554b8c1f6e6cd6074109de493e6b0460 solid_cache (1.0.10) sha256=bc05a2fb3ac78a6f43cbb5946679cf9db67dd30d22939ededc385cb93e120d41 - solid_queue (1.2.4) sha256=bb60f9552a969ac377d87601b0ff6a088f5e6f20b0cbbe3844a59d022cac0e4b - sqlite-ulid (0.2.1-arm64-darwin) sha256=683aa064c0bb8a6e156e8ac875c25b7eb22cfeb7a9cebe4f1ed2e7f9841a4bd7 - sqlite-ulid (0.2.1-x86_64-linux) sha256=1d3df3a6db65927062192c8c21b7821fd1a1041570794bae519b2d82add8637d + solid_queue (1.3.1) sha256=d9580111180c339804ff1a810a7768f69f5dc694d31e86cf1535ff2cd7a87428 + sqlean (0.3.0-aarch64-linux-gnu) sha256=2b88dcefd7c9a92a9287c1bf8d650f286275d2645c95d5836c2efe8a0255a078 + sqlean (0.3.0-arm64-darwin) sha256=32ffa1e5a908a52c028fb06fa2dbe61f600a865c95960d7ec4f3fbc82f28bf78 + sqlean (0.3.0-x86_64-linux-gnu) sha256=51e7e0a66ceebf26c4a4509001412bea2214fb748752fde96a228db9cb2e85ce + sqlean (0.3.0-x86_64-linux-musl) sha256=93eb4f18679539b64c478dd2d57e393404c036e0057a0a93b2a8089ea6caa94a + sqlite3 (2.9.0-aarch64-linux-gnu) sha256=cfe1e0216f46d7483839719bf827129151e6c680317b99d7b8fc1597a3e13473 sqlite3 (2.9.0-arm64-darwin) sha256=a917bd9b84285766ff3300b7d79cd583f5a067594c8c1263e6441618c04a6ed3 sqlite3 (2.9.0-x86_64-linux-gnu) sha256=72fff9bd750070ba3af695511ba5f0e0a2d8a9206f84869640b3e99dfaf3d5a5 sqlite3 (2.9.0-x86_64-linux-musl) sha256=ef716ba7a66d7deb1ccc402ac3a6d7343da17fac862793b7f0be3d2917253c90 @@ -690,15 +789,18 @@ CHECKSUMS stimulus-rails (1.3.4) sha256=765676ffa1f33af64ce026d26b48e8ffb2e0b94e0f50e9119e11d6107d67cb06 stringio (3.2.0) sha256=c37cb2e58b4ffbd33fe5cd948c05934af997b36e0b6ca6fdf43afa234cf222e1 tailwindcss-rails (4.4.0) sha256=efa2961351a52acebe616e645a81a30bb4f27fde46cc06ce7688d1cd1131e916 + tailwindcss-ruby (4.1.18-aarch64-linux-gnu) sha256=e10f9560bccddbb4955fd535b3bcc8c7071a7df07404dd473a23fa791ec4e46b tailwindcss-ruby (4.1.18-arm64-darwin) sha256=f940531d5a030c566d3d616004235bcd4c361abdd328f7d6c7e3a953a32e0155 tailwindcss-ruby (4.1.18-x86_64-linux-gnu) sha256=e0a2220163246fe0126c5c5bafb95bc6206e7d21fce2a2878fd9c9a359137534 tailwindcss-ruby (4.1.18-x86_64-linux-musl) sha256=d957cf545b09d2db7eb6267450cc1fc589e126524066537a0c4d5b99d701f4b2 - thor (1.4.0) sha256=8763e822ccb0f1d7bee88cde131b19a65606657b847cc7b7b4b82e772bcd8a3d + thor (1.5.0) sha256=e3a9e55fe857e44859ce104a84675ab6e8cd59c650a49106a05f55f136425e73 + thruster (0.1.17-aarch64-linux) sha256=1b3a34b2814185c2aeaf835b5ecff5348cdcf8e77809f7a092d46e4b962a16ba thruster (0.1.17-arm64-darwin) sha256=75da66fc4a0f012f9a317f6362f786a3fa953879a3fa6bed8deeaebf1c1d66ec thruster (0.1.17-x86_64-linux) sha256=77b8f335075bd4ece7631dc84a19a710a1e6e7102cbce147b165b45851bdfcd3 + tidewave (0.4.1) sha256=e33e0b5bd8678825fa00f2703ca64754d910996682f78b3420499068bc123258 timeout (0.6.0) sha256=6d722ad619f96ee383a0c557ec6eb8c4ecb08af3af62098a0be5057bf00de1af tsort (0.2.0) sha256=9650a793f6859a43b6641671278f79cfead60ac714148aabe4e3f0060480089f - turbo-rails (2.0.20) sha256=cbcbb4dd3ce59f6471c9f911b1655b2c721998cc8303959d982da347f374ea95 + turbo-rails (2.0.23) sha256=ee0d90733aafff056cf51ff11e803d65e43cae258cc55f6492020ec1f9f9315f turbo_power (0.7.0) sha256=ad95d147e0fa761d0023ad9ca00528c7b7ddf6bba8ca2e23755d5b21b290d967 tzinfo (2.0.6) sha256=8daf828cc77bcf7d63b0e3bdb6caa47e2272dcfaf4fbfe46f8c3a9df087a829b unicode-display_width (3.2.0) sha256=0cdd96b5681a5949cdbc2c55e7b420facae74c4aaf9a9815eee1087cb1853c42 @@ -706,14 +808,16 @@ CHECKSUMS uri (1.1.1) sha256=379fa58d27ffb1387eaada68c749d1426738bd0f654d812fcc07e7568f5c57c6 useragent (0.16.11) sha256=700e6413ad4bb954bb63547fa098dddf7b0ebe75b40cc6f93b8d54255b173844 version_gem (1.1.9) sha256=0c1a0962ae543c84a00889bb018d9f14d8f8af6029d26b295d98774e3d2eb9a4 - view_component (4.1.1) sha256=179f63b0db1d1a8f6af635dd684456b2bcdf6b6f4da2ef276bbe0579c17b377e + view_component (4.2.0) sha256=f250a3397a794336354f73c229b3b7549af0b24906551b99a03492b54cb5233d warden (1.2.9) sha256=46684f885d35a69dbb883deabf85a222c8e427a957804719e143005df7a1efd0 web-console (4.2.1) sha256=e7bcf37a10ea2b4ec4281649d1cee461b32232d0a447e82c786e6841fd22fe20 + webmock (3.26.1) sha256=4f696fb57c90a827c20aadb2d4f9058bbff10f7f043bd0d4c3f58791143b1cd7 websocket (1.2.11) sha256=b7e7a74e2410b5e85c25858b26b3322f29161e300935f70a0e0d3c35e0462737 websocket-driver (0.8.0) sha256=ed0dba4b943c22f17f9a734817e808bc84cdce6a7e22045f5315aa57676d4962 websocket-extensions (0.1.5) sha256=1c6ba63092cda343eb53fc657110c71c754c56484aad42578495227d717a8241 + wheretz (0.0.6) sha256=3ac9fa92aa4ff20c2b5e292f6ed041c9915a87ed5ddbf486cc94652a5554a0c7 xpath (3.2.0) sha256=6dfda79d91bb3b949b947ecc5919f042ef2f399b904013eb3ef6d20dd3a4082e zeitwerk (2.7.4) sha256=2bef90f356bdafe9a6c2bd32bcd804f83a4f9b8bc27f3600fff051eb3edcec8b BUNDLED WITH - 4.0.3 + 4.0.5 diff --git a/README.md b/README.md index de92e53..5604794 100644 --- a/README.md +++ b/README.md @@ -1,29 +1,42 @@ -# WhyRuby.info - Ruby Advocacy Community Website +# WhyRuby.info & RubyCommunity.org -A community-driven Ruby advocacy website built with Ruby 4.0.0 and Rails 8.2 using the Solid Stack (SQLite, SolidQueue, SolidCache, SolidCable). +Ruby advocacy site and developer community. Built with Ruby 4.0.1 and Rails 8.2 on the Solid Stack (SQLite, SolidQueue, SolidCache, SolidCable). ## Features -### Core Features -- **Universal Content Model**: Support for both articles (with markdown) and external links -- **GitHub OAuth Authentication**: Sign in with GitHub account only -- **Category System**: Dynamic categories managed through admin panel -- **Tagging System**: HABTM relationship for content tagging -- **Pinned Content**: Homepage featuring system with numbered positions -- **AI-Generated Summaries**: Automatic content summarization via OpenAI -- **Soft Deletion**: All records use archived flag instead of hard deletion - -### Community Features +### Content +- **Universal Content Model**: Articles (markdown), external links, and success stories +- **AI-Generated Summaries**: Automatic content summarization via OpenAI/Anthropic +- **Category & Tagging System**: Dynamic categories and HABTM tags +- **Markdown Support**: Full markdown rendering with syntax highlighting (Redcarpet + Rouge) + +### Community (rubycommunity.org) +- **Developer Profiles**: GitHub-synced profiles with bio, company, location, repositories +- **Interactive Map**: Geocoded developer locations on a Leaflet.js world map +- **Project Rankings**: GitHub repos with daily star trends and sorting (trending, top, new) +- **Testimonials**: Users write why they love Ruby; AI generates headline/quote for the home page carousel +- **Profile Settings**: Hide repositories, "Open to Work" badge, newsletter preferences + +### Multi-Domain +- **whyruby.info**: Advocacy content, articles, success stories, testimonials +- **rubycommunity.org**: Community profiles, map, project rankings +- **Cross-domain auth**: OAuth via primary domain with single-use token session sync + +### Newsletter +- **Timezone-aware delivery**: Sends at 10:10 AM local time per user +- **Open tracking**: Pixel-based open tracking per version +- **Unsubscribe**: One-click unsubscribe via token URL + +### Moderation - **Role-Based Access**: Member and admin roles -- **Trusted User System**: Based on contribution count (3+ contents, 10+ comments) +- **Trusted User System**: Based on contribution count (3+ posts, 10+ comments) - **Self-Regulation**: Trusted users can report inappropriate content - **Auto-Moderation**: Content auto-hidden after 3+ reports -- **Markdown Support**: Full markdown rendering with syntax highlighting ## Setup ### Prerequisites -- Ruby 4.0.0 +- Ruby 4.0.1 - SQLite 3 - Node.js (for JavaScript runtime) @@ -79,12 +92,14 @@ Get credentials from: ## Running the Application -Start the Rails server: +Start the development server (runs Rails + Tailwind CSS watcher): ```bash -rails server +bin/dev ``` -Visit http://localhost:3000 +Visit http://localhost:3003 + +**Important**: Always use `bin/dev` instead of `rails server`. ## Admin Access @@ -100,24 +115,34 @@ Access the admin panel at `/admin` ## Architecture ### Models -- **User**: GitHub OAuth authenticated users with roles +- **User**: GitHub OAuth authenticated users with roles, geocoded location, timezone, profile settings +- **Post**: Universal content model for articles, links, and success stories - **Category**: Content categories with position ordering -- **Content**: Universal model for articles and links - **Tag**: Content tags with HABTM relationship - **Comment**: User comments on content - **Report**: Content reports from trusted users +- **Testimonial**: User testimonials with AI-generated fields +- **Project**: GitHub repositories with star counts and language +- **StarSnapshot**: Daily star count snapshots for trend tracking ### Key Technologies -- **Rails 8.1**: Latest Rails with Solid Stack -- **SQLite with ULID**: Primary keys using ULID for better distribution -- **Tailwind CSS 4**: Modern utility-first CSS framework -- **Redcarpet + Rouge**: Markdown rendering with syntax highlighting +- **Ruby 4.0.1 / Rails 8.2**: Latest Rails with Solid Stack +- **SQLite with UUIDv7**: String primary keys for time-ordered uniqueness +- **Tailwind CSS 4**: Utility-first CSS via `tailwindcss-rails` +- **Hotwire (Turbo + Stimulus)**: Frontend interactivity, infinite scroll, interactive map - **Avo**: Admin interface for content management - **Kaminari**: Pagination - **SolidQueue**: Background job processing +- **Leaflet.js**: Interactive community map +- **Brakeman**: Security scanning (runs on every commit via lefthook) ### Background Jobs - `GenerateSummaryJob`: Creates AI summaries for new content +- `GenerateTestimonialFieldsJob`: AI-generates headline, subheadline, and quote from testimonials +- `ValidateTestimonialJob`: LLM-validates testimonial content +- `UpdateGithubDataJob`: Refreshes user GitHub data via GraphQL API +- `NormalizeLocationJob`: Geocodes user locations for the community map +- `ScheduledNewsletterJob`: Timezone-aware newsletter delivery - `NotifyAdminJob`: Alerts admins when content is auto-hidden ## Development @@ -127,11 +152,14 @@ Access the admin panel at `/admin` rails test ``` -### Code Style +### Code Style & Security ```bash -bundle exec rubocop +bundle exec rubocop # Lint +bin/brakeman --no-pager # Security scan ``` +Both run automatically on every commit via lefthook. + ### Database Console ```bash rails db diff --git a/app/assets/images/eye-off.svg b/app/assets/images/eye-off.svg new file mode 100644 index 0000000..1412219 --- /dev/null +++ b/app/assets/images/eye-off.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/assets/images/eye.svg b/app/assets/images/eye.svg new file mode 100644 index 0000000..9d0d2a7 --- /dev/null +++ b/app/assets/images/eye.svg @@ -0,0 +1,4 @@ + + + + diff --git a/app/assets/images/pencil.svg b/app/assets/images/pencil.svg new file mode 100644 index 0000000..b010f89 --- /dev/null +++ b/app/assets/images/pencil.svg @@ -0,0 +1 @@ +Edit \ No newline at end of file diff --git a/app/assets/images/ruby-logo.svg b/app/assets/images/ruby-logo.svg new file mode 100644 index 0000000..33e0009 --- /dev/null +++ b/app/assets/images/ruby-logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/assets/images/x-mark.svg b/app/assets/images/x-mark.svg new file mode 100644 index 0000000..d8e5a4c --- /dev/null +++ b/app/assets/images/x-mark.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css index 45e5669..15c3a1e 100644 --- a/app/assets/stylesheets/application.css +++ b/app/assets/stylesheets/application.css @@ -254,6 +254,22 @@ select { animation: slide-in 0.3s ease-out; } +/* Heartbeat animation - +10 -10 +10 pattern, stays big */ +@keyframes heartbeat { + 0% { transform: scale(1); } + 33% { transform: scale(1.1); } + 66% { transform: scale(1); } +} + +.animate-heartbeat { + transition: transform 0.3s ease-out; +} + +.group:hover .animate-heartbeat { + transform: scale(1.1); + animation: heartbeat 0.3s ease-in-out; +} + /* Custom checkbox styles */ input[type="checkbox"] { @apply w-4 h-4 text-red-600 bg-gray-100 border-gray-300 rounded focus:ring-red-500 focus:ring-2; @@ -420,4 +436,25 @@ input[type="checkbox"] { .translate-x-7 { transform: translateX(1.75rem); +} + +/* Community map - contain Leaflet z-indices below the main nav (z-50) */ +[data-controller="community-map"] .leaflet-container { + z-index: 0; +} + +/* Community map - style attribution links to match site design */ +[data-controller="community-map"] .leaflet-control-attribution a { + color: #9ca3af; + transition: color 0.15s; +} +[data-controller="community-map"] .leaflet-control-attribution a:hover { + color: #dc2626; +} + +/* Community map - reset Leaflet divIcon defaults for custom markers */ +.community-map-marker, +.community-map-cluster { + background: none !important; + border: none !important; } \ No newline at end of file diff --git a/app/assets/tailwind/application.css b/app/assets/tailwind/application.css index a244eaa..6f4967a 100644 --- a/app/assets/tailwind/application.css +++ b/app/assets/tailwind/application.css @@ -53,6 +53,34 @@ } @layer utilities { + /* Sticky header: not on phones, not on horizontal tablets */ + /* Horizontal phones: height < 600px → excluded */ + /* Horizontal iPads: landscape with width ≤ 1366px → excluded (all iPads max out at 1366px CSS width) */ + /* Desktops/laptops: typically 1440px+ wide → included */ + /* Vertical tablets (portrait): always included at 768px+ width */ + .sticky-tablet { + position: static; + } + + @media (min-width: 768px) and (min-height: 600px) and (orientation: portrait) { + .sticky-tablet { + position: sticky; + } + } + + @media (min-width: 1400px) and (min-height: 600px) { + .sticky-tablet { + position: sticky; + } + } + + /* Shorter map on landscape tablets so header text below is visible */ + @media (orientation: landscape) and (min-width: 768px) and (max-width: 1399px) { + .community-map-landscape { + height: 400px; + } + } + @keyframes highlight-blink { 0% { background-color: theme(colors.red.200); diff --git a/app/avo/actions/regenerate_success_story_image.rb b/app/avo/actions/regenerate_success_story_image.rb index 58657a0..6b63794 100644 --- a/app/avo/actions/regenerate_success_story_image.rb +++ b/app/avo/actions/regenerate_success_story_image.rb @@ -7,13 +7,10 @@ class Avo::Actions::RegenerateSuccessStoryImage < Avo::BaseAction } def handle(query:, fields:, current_user:, resource:, **args) - query.each do |post| - next unless post.success_story? && post.logo_svg.present? + eligible_posts = query.select { |post| post.success_story? && post.logo_svg.present? } + jobs = eligible_posts.map { |post| GenerateSuccessStoryImageJob.new(post, force: true) } + ActiveJob.perform_all_later(jobs) - # Force regenerate the image - GenerateSuccessStoryImageJob.perform_later(post, force: true) - end - - succeed "Image regeneration queued for #{query.count} #{"post".pluralize(query.count)}" + succeed "Image regeneration queued for #{eligible_posts.size} #{"post".pluralize(eligible_posts.size)}" end end diff --git a/app/avo/actions/regenerate_summary.rb b/app/avo/actions/regenerate_summary.rb index 3c2bf01..0c2f648 100644 --- a/app/avo/actions/regenerate_summary.rb +++ b/app/avo/actions/regenerate_summary.rb @@ -14,10 +14,8 @@ def handle(query:, fields:, current_user:, resource:, **args) [ query ] # Single record, wrap in array end - posts.each do |post| - # Queue summary regeneration with force flag to override existing summary - GenerateSummaryJob.perform_later(post, force: true) - end + jobs = posts.map { |post| GenerateSummaryJob.new(post, force: true) } + ActiveJob.perform_all_later(jobs) count = posts.is_a?(Array) ? posts.size : posts.count succeed "AI summary regeneration queued for #{count} #{'post'.pluralize(count)}." diff --git a/app/avo/resources/category.rb b/app/avo/resources/category.rb index 88331e2..d644b7f 100644 --- a/app/avo/resources/category.rb +++ b/app/avo/resources/category.rb @@ -18,7 +18,7 @@ def self.find_record(id, **kwargs) ::Category.unscoped.friendly.find(id) rescue ActiveRecord::RecordNotFound # If not found, try to find by historical slug - slug_record = FriendlyId::Slug.where(sluggable_type: "Category", slug: id).order(id: :desc).first + slug_record = FriendlyId::Slug.where(sluggable_type: "Category", slug: id).order(id: :desc).take if slug_record ::Category.unscoped.find(slug_record.sluggable_id) else diff --git a/app/avo/resources/post.rb b/app/avo/resources/post.rb index 596cb41..1999435 100644 --- a/app/avo/resources/post.rb +++ b/app/avo/resources/post.rb @@ -17,7 +17,7 @@ def self.find_record(id, **kwargs) ::Post.unscoped.friendly.find(id) rescue ActiveRecord::RecordNotFound # If not found, try to find by historical slug - slug_record = FriendlyId::Slug.where(sluggable_type: "Post", slug: id).first + slug_record = FriendlyId::Slug.find_by(sluggable_type: "Post", slug: id) if slug_record ::Post.unscoped.find(slug_record.sluggable_id) else @@ -200,11 +200,7 @@ def fields format_using: -> do # Generate the OG image URL og_url = if record.featured_image.attached? - if record.category - "#{view_context.request.base_url}/#{record.category.to_param}/#{record.to_param}/og-image.png?v=#{record.updated_at.to_i}" - else - "#{view_context.request.base_url}/uncategorized/#{record.to_param}/og-image.png?v=#{record.updated_at.to_i}" - end + "#{view_context.request.base_url}/#{record.category.to_param}/#{record.to_param}/og-image.png?v=#{record.updated_at.to_i}" else # Default OG image with version based on file modification time og_image_path = Rails.root.join("public", "og-image.png") diff --git a/app/avo/resources/project.rb b/app/avo/resources/project.rb new file mode 100644 index 0000000..e92d3a4 --- /dev/null +++ b/app/avo/resources/project.rb @@ -0,0 +1,76 @@ +class Avo::Resources::Project < Avo::BaseResource + self.title = :name + self.includes = [ :user, :star_snapshots ] + self.model_class = ::Project + self.description = "Manage GitHub projects (repositories) for users" + self.default_view_type = :table + + self.search = { + query: -> { Project.ransack(name_cont: params[:q], github_url_cont: params[:q], m: "or").result(distinct: false) } + } + + def fields + field :id, as: :text, readonly: true, hide_on: [ :index ] + + field :name, as: :text, link_to_record: true + + field :user, as: :belongs_to, + only_on: [ :forms, :show ] + + field :user_with_avatar, + as: :text, + name: "User", + only_on: [ :index ], + format_using: -> do + if record.user + avatar_url = record.user.avatar_url || "https://avatars.githubusercontent.com/u/0" + link_to view_context.avo.resources_user_path(record.user), + class: "flex items-center gap-2 hover:underline" do + image_tag(avatar_url, class: "w-5 h-5 rounded-full", alt: record.user.username) + + content_tag(:span, record.user.username) + end + else + content_tag(:span, "-", class: "text-gray-400") + end + end + + field :stars, as: :number, only_on: [ :forms, :show ] + field :stars_with_trend, + as: :text, + name: "Stars", + only_on: [ :index ], + sortable: -> { query.order(stars: direction) }, + format_using: -> do + gained = record.stars_gained + trend = gained > 0 ? content_tag(:span, " +#{gained}", class: "text-green-600") : "" + safe_join([ record.stars.to_s, trend ]) + end + field :github_url, as: :text, only_on: [ :forms ] + field :github_url, as: :text, name: "GitHub", only_on: [ :index ], + format_using: -> do + if value.present? + repo_name = value.split("/").last(2).join("/") + link_to(repo_name, value, target: "_blank", class: "text-blue-600 hover:underline") + else + content_tag(:span, "-", class: "text-gray-400") + end + end + field :github_url, as: :text, only_on: [ :show ], + format_using: -> { link_to(value, value, target: "_blank", class: "text-blue-600 hover:underline") if value.present? } + + field :description, as: :textarea, hide_on: [ :index ], rows: 3 + field :forks_count, as: :number, hide_on: [ :index ] + field :size, as: :number, hide_on: [ :index ] + field :topics, as: :text, hide_on: [ :index ], + format_using: -> { value.is_a?(Array) ? value.join(", ") : value.to_s } + + field :hidden, as: :boolean + field :archived, as: :boolean + + field :pushed_at, as: :date_time, readonly: true, hide_on: [ :index ] + field :created_at, as: :date_time, readonly: true, hide_on: [ :index ] + field :updated_at, as: :date_time, readonly: true + + field :star_snapshots, as: :has_many, hide_on: [ :index ] + end +end diff --git a/app/avo/resources/star_snapshot.rb b/app/avo/resources/star_snapshot.rb new file mode 100644 index 0000000..bbf875f --- /dev/null +++ b/app/avo/resources/star_snapshot.rb @@ -0,0 +1,15 @@ +class Avo::Resources::StarSnapshot < Avo::BaseResource + self.title = :recorded_on + self.includes = [ :project ] + self.model_class = ::StarSnapshot + self.description = "Daily star count snapshots for projects" + self.default_view_type = :table + + def fields + field :id, as: :text, readonly: true, hide_on: [ :index ] + field :project, as: :belongs_to + field :stars, as: :number + field :recorded_on, as: :date + field :created_at, as: :date_time, readonly: true, hide_on: [ :index ] + end +end diff --git a/app/avo/resources/tag.rb b/app/avo/resources/tag.rb index b798ac6..564436f 100644 --- a/app/avo/resources/tag.rb +++ b/app/avo/resources/tag.rb @@ -14,7 +14,7 @@ def self.find_record(id, **kwargs) ::Tag.unscoped.friendly.find(id) rescue ActiveRecord::RecordNotFound # If not found, try to find by historical slug - slug_record = FriendlyId::Slug.where(sluggable_type: "Tag", slug: id).first + slug_record = FriendlyId::Slug.find_by(sluggable_type: "Tag", slug: id) if slug_record ::Tag.unscoped.find(slug_record.sluggable_id) else diff --git a/app/avo/resources/testimonial.rb b/app/avo/resources/testimonial.rb new file mode 100644 index 0000000..522f336 --- /dev/null +++ b/app/avo/resources/testimonial.rb @@ -0,0 +1,65 @@ +class Avo::Resources::Testimonial < Avo::BaseResource + self.title = :heading + self.includes = [ :user ] + self.model_class = ::Testimonial + self.description = "Manage user testimonials" + self.default_view_type = :table + + def fields + field :id, as: :text, readonly: true, hide_on: [ :index ] + + field :user_with_avatar, + as: :text, + name: "User", + only_on: [ :index ], + format_using: -> do + if record.user + avatar_url = record.user.avatar_url || "https://avatars.githubusercontent.com/u/0" + link_to view_context.avo.resources_user_path(record.user), + class: "flex items-center gap-2 hover:underline" do + image_tag(avatar_url, class: "w-5 h-5 rounded-full", alt: record.user.username) + + content_tag(:span, record.user.username) + end + else + content_tag(:span, "-", class: "text-gray-400") + end + end + + field :user, as: :belongs_to, only_on: [ :forms, :show ] + + field :heading, as: :text, link_to_record: true + field :subheading, as: :text, hide_on: [ :index ] + field :quote, as: :textarea, rows: 4 + field :body_text, as: :textarea, rows: 6, hide_on: [ :index ] + + field :published, + as: :text, + name: "Published", + only_on: [ :index ], + format_using: -> do + if record.published + content_tag(:span, class: "inline-flex items-center text-green-600") do + content_tag(:svg, xmlns: "http://www.w3.org/2000/svg", + class: "w-4 h-4", + viewBox: "0 0 20 20", + fill: "currentColor") do + content_tag(:path, nil, + "fill-rule": "evenodd", + d: "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", + "clip-rule": "evenodd") + end + end + else + content_tag(:span, "-", class: "text-gray-400") + end + end + + field :published, as: :boolean, hide_on: [ :index ] + field :position, as: :number + field :ai_feedback, as: :textarea, rows: 3, hide_on: [ :index ] + field :ai_attempts, as: :number, readonly: true, hide_on: [ :index ] + + field :created_at, as: :date_time, readonly: true, hide_on: [ :index ] + field :updated_at, as: :date_time, readonly: true + end +end diff --git a/app/avo/resources/user.rb b/app/avo/resources/user.rb index 0e7e84d..9e86f86 100644 --- a/app/avo/resources/user.rb +++ b/app/avo/resources/user.rb @@ -15,7 +15,7 @@ def self.find_record(id, **kwargs) ::User.unscoped.friendly.find(id) rescue ActiveRecord::RecordNotFound # If not found, try to find by historical slug - slug_record = FriendlyId::Slug.where(sluggable_type: "User", slug: id).first + slug_record = FriendlyId::Slug.find_by(sluggable_type: "User", slug: id) if slug_record ::User.unscoped.find(slug_record.sluggable_id) else @@ -56,6 +56,11 @@ def fields field :created_at, as: :date_time, readonly: true field :updated_at, as: :date_time, readonly: true, hide_on: [ :index ] + # Newsletter + field :unsubscribed_from_newsletter, as: :boolean, readonly: true, hide_on: [ :index ] + field :newsletters_received, as: :text, readonly: true, hide_on: [ :index ], format_using: -> { value.is_a?(Array) ? value.map { |v| NewsletterMailer::SUBJECTS[v] || "v#{v}" }.join(", ") : value.to_s } + field :newsletters_opened, as: :text, readonly: true, hide_on: [ :index ], format_using: -> { value.is_a?(Array) ? value.map { |v| NewsletterMailer::SUBJECTS[v] || "v#{v}" }.join(", ") : value.to_s } + # Associations - hide from index field :posts, as: :has_many, hide_on: [ :index ] field :comments, as: :has_many, hide_on: [ :index ] diff --git a/app/content/legal/cookie_policy.md b/app/content/legal/cookie_policy.md index 30fa278..abe07d0 100644 --- a/app/content/legal/cookie_policy.md +++ b/app/content/legal/cookie_policy.md @@ -1,6 +1,8 @@ # Cookie Policy -**Last updated: August 14, 2025** +**Last updated: February 1, 2026** + +This Cookie Policy applies to both **whyruby.info** and **rubycommunity.org**. ## What Are Cookies @@ -18,12 +20,19 @@ These cookies are essential for the website to function properly: - **Security cookies**: Protect against cross-site request forgery (CSRF) - **Preference cookies**: Remember your settings and preferences +### Cross-Domain Session Tokens +To provide seamless authentication across both whyruby.info and rubycommunity.org, we use secure, short-lived tokens (not cookies) to synchronize your login status. These tokens: +- Are transmitted via URL parameter, not stored as cookies +- Expire after 30 seconds and can only be used once +- Are immediately deleted after successful session synchronization +- Contain only your user identifier, no additional personal data + ### Analytics (No Cookies Required) -We use Google Analytics 4 in cookieless mode to understand how visitors use our website and improve our services. This implementation: -- Does not use any cookies for analytics tracking -- Uses privacy-preserving methods to collect anonymous usage data -- Tracks page views, session duration, and user interactions without storing data on your device -- Complies with privacy regulations without requiring cookie consent +We use [Nullitics](https://nullitics.com), a privacy-first analytics service, to understand how visitors use our website. Nullitics: +- Does not use any cookies +- Does not store IP addresses or personal data +- Is fully compliant with GDPR, ePrivacy, PECR, CCPA, and COPPA +- Does not require a cookie consent banner ### Cookies We Don't Use We do not use: @@ -56,7 +65,7 @@ Disabling cookies may affect: We use the following third-party services that may set cookies: - **GitHub**: For authentication (see GitHub's privacy policy) -Google Analytics operates in cookieless mode and does not set any cookies on your device. +Nullitics does not use cookies or store any data on your device. These services operate according to their own privacy policies. diff --git a/app/content/legal/legal_notice.md b/app/content/legal/legal_notice.md index c883d7a..0d308dd 100644 --- a/app/content/legal/legal_notice.md +++ b/app/content/legal/legal_notice.md @@ -1,6 +1,6 @@ # Legal Notice (Aviso Legal) -**Last updated: August 10, 2025** +**Last updated: February 1, 2026** > ⚠️ **Beta Service Notice** > This service is currently in beta testing and is not fully operational. Features may change, and service interruptions may occur. Please use with caution and do not rely on it for critical or time-sensitive matters. @@ -9,11 +9,13 @@ ## Website Information -- **Website**: https://whyruby.info +- **Websites**: + - https://whyruby.info (Ruby advocacy and content platform) + - https://rubycommunity.org (Ruby community member directory) - **Service Type**: Ruby programming language advocacy and community platform - **Operator**: Iurii Sidorov (Autónomo) - **Tax ID (VAT)**: ESY9716566T -- **Registered Address**: Barcelona, Spain +- **Registered Address**: Girona, Spain - **Contact Email**: hey@yurisidorov.com ## Nature of Service @@ -65,7 +67,7 @@ As a beta service: ## Applicable Law and Jurisdiction -This website is governed by Spanish and European Union law. Any disputes shall be submitted to the courts of Barcelona, Spain. +This website is governed by Spanish and European Union law. Any disputes shall be submitted to the courts of Girona, Spain. ## Contact Information diff --git a/app/content/legal/privacy_policy.md b/app/content/legal/privacy_policy.md index 9cac0c4..43255c3 100644 --- a/app/content/legal/privacy_policy.md +++ b/app/content/legal/privacy_policy.md @@ -1,6 +1,8 @@ # Privacy Policy -**Last updated: August 14, 2025** +**Last updated: February 1, 2026** + +This Privacy Policy applies to both **whyruby.info** and **rubycommunity.org**, which are operated as a unified service under the same data controller. ## Information We Collect @@ -29,16 +31,16 @@ This information is automatically refreshed from GitHub each time you sign in to - Basic server logs for security and performance monitoring - Your IP address for security and geographic analysis -### Analytics Data (via Cookieless Google Analytics) -We collect anonymized usage data to improve our service using Google Analytics 4 in cookieless mode: -- Pages you visit and time spent on each page -- Your browser type and operating system -- Your approximate geographic location (country/city level) +### Cross-Domain Authentication +When you sign in on one domain, we use a secure, short-lived token (valid for 30 seconds, single-use) to synchronize your session across both domains. This token is transmitted via URL parameter and immediately invalidated after use. No additional personal data is collected during this process. + +### Analytics Data (via Nullitics) +We collect minimal, anonymized usage data to improve our service using [Nullitics](https://nullitics.com), a privacy-first analytics service: +- Pages you visit - How you arrived at our site (referrer) -- Device type (desktop, mobile, tablet) -- User interactions and click events +- Screen width (for responsive design optimization) -This data is collected through Google Analytics 4 without using cookies. The analytics system uses privacy-preserving methods and does not store any data on your device. All collected data is anonymous and cannot be linked to your personal identity. +Nullitics does not use cookies, does not store IP addresses, and does not collect any personally identifiable information. Their servers are located in the Netherlands and they are fully compliant with GDPR, ePrivacy, PECR, CCPA, and COPPA. ## How We Use Your Information @@ -64,7 +66,7 @@ Your GitHub username, avatar, and profile information (name, bio, location, comp We use the following third-party services that may process your data: - **GitHub**: For authentication and profile data -- **Google Analytics 4 (Cookieless)**: For website usage analytics (data is anonymized and no cookies are used) +- **Nullitics**: For privacy-first website analytics (no cookies, no IP storage, GDPR compliant) These services have their own privacy policies and data handling practices. We encourage you to review their privacy policies. @@ -87,9 +89,7 @@ To exercise these rights, contact us at hey@yurisidorov.com We use cookies only for essential website functionality: - **Essential cookies**: User authentication, session management, and security features -**Analytics without cookies**: We use Google Analytics 4 in cookieless mode to understand website usage and improve our services. This implementation does not require cookies and respects your privacy by default. - -You can control cookie settings through your browser preferences. Since our analytics does not use cookies, no opt-out is required for analytics tracking. +**Analytics without cookies**: We use Nullitics for website analytics. Nullitics does not use cookies, does not store IP addresses, and is fully compliant with GDPR, ePrivacy, PECR, CCPA, and COPPA. No cookie consent banner is required. We do not use cookies for advertising or marketing purposes. diff --git a/app/content/legal/terms_of_service.md b/app/content/legal/terms_of_service.md index e896b05..3ca5ca6 100644 --- a/app/content/legal/terms_of_service.md +++ b/app/content/legal/terms_of_service.md @@ -1,10 +1,10 @@ # Terms of Service -**Last updated: August 14, 2025** +**Last updated: February 1, 2026** ## Acceptance of Terms -By accessing and using this Ruby advocacy website, you accept and agree to be bound by these Terms of Service. +By accessing and using whyruby.info or rubycommunity.org (collectively, "the Service"), you accept and agree to be bound by these Terms of Service. Both websites are operated as a unified platform and these terms apply equally to both. ## Description of Service diff --git a/app/controllers/auth_controller.rb b/app/controllers/auth_controller.rb new file mode 100644 index 0000000..d74b101 --- /dev/null +++ b/app/controllers/auth_controller.rb @@ -0,0 +1,46 @@ +class AuthController < ApplicationController + # Receives cross-domain auth token and creates local session + # Then redirects to the final destination (which may be on another domain) + def receive + token = params[:token] + destination = safe_return_to + + user = User.authenticate_cross_domain_token(token) + if user + sign_in(user) + redirect_to destination, allow_other_host: true + elsif user_signed_in? + # User is already signed in on this domain, just redirect + redirect_to destination, allow_other_host: true + else + redirect_to root_path, alert: "Session sync failed. Please sign in again." + end + end + + # Cross-domain sign out - signs out here, then redirects to final destination + def sign_out_receive + token = params[:token] + destination = safe_return_to + + user = User.authenticate_cross_domain_token(token) + sign_out(user) if user && user_signed_in? && current_user == user + + redirect_to destination, allow_other_host: true + end + + private + + def safe_return_to + url = params[:return_to] + return root_url if url.blank? + + uri = URI.parse(url) + return root_url unless uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS) + + domains = Rails.application.config.x.domains + allowed = [ domains.primary, domains.community, "localhost" ] + allowed.any? { |d| uri.host&.end_with?(d) } ? url : root_url + rescue URI::InvalidURIError + root_url + end +end diff --git a/app/controllers/avo/projects_controller.rb b/app/controllers/avo/projects_controller.rb new file mode 100644 index 0000000..cebb186 --- /dev/null +++ b/app/controllers/avo/projects_controller.rb @@ -0,0 +1,2 @@ +class Avo::ProjectsController < Avo::ResourcesController +end diff --git a/app/controllers/avo/star_snapshots_controller.rb b/app/controllers/avo/star_snapshots_controller.rb new file mode 100644 index 0000000..b296f6a --- /dev/null +++ b/app/controllers/avo/star_snapshots_controller.rb @@ -0,0 +1,2 @@ +class Avo::StarSnapshotsController < Avo::ResourcesController +end diff --git a/app/controllers/avo/testimonials_controller.rb b/app/controllers/avo/testimonials_controller.rb new file mode 100644 index 0000000..6a367a9 --- /dev/null +++ b/app/controllers/avo/testimonials_controller.rb @@ -0,0 +1,2 @@ +class Avo::TestimonialsController < Avo::ResourcesController +end diff --git a/app/controllers/home_controller.rb b/app/controllers/home_controller.rb index 8ae2196..89f8abb 100644 --- a/app/controllers/home_controller.rb +++ b/app/controllers/home_controller.rb @@ -13,5 +13,6 @@ def index .published .includes(:user) .order(created_at: :desc) + @testimonials = Testimonial.published.includes(:user).order(Arel.sql("RANDOM()")).limit(20) end end diff --git a/app/controllers/newsletter_opens_controller.rb b/app/controllers/newsletter_opens_controller.rb new file mode 100644 index 0000000..816955b --- /dev/null +++ b/app/controllers/newsletter_opens_controller.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class NewsletterOpensController < ActionController::Base + TRANSPARENT_GIF = "\x47\x49\x46\x38\x39\x61\x01\x00\x01\x00\x80\x00\x00\xff\xff\xff\x00\x00\x00\x21\xf9\x04\x01\x00\x00\x00\x00\x2c\x00\x00\x00\x00\x01\x00\x01\x00\x00\x02\x02\x44\x01\x00\x3b".b + + def show + data = Rails.application.message_verifier("newsletter_open").verify(params[:token]) + user = User.find(data["user_id"]) + user.record_newsletter_opened!(data["version"]) + rescue ActiveSupport::MessageVerifier::InvalidSignature, ActiveRecord::RecordNotFound + # Invalid token or missing user — silently ignore + ensure + send_data TRANSPARENT_GIF, type: "image/gif", disposition: "inline" + end +end diff --git a/app/controllers/newsletter_unsubscribes_controller.rb b/app/controllers/newsletter_unsubscribes_controller.rb new file mode 100644 index 0000000..181a683 --- /dev/null +++ b/app/controllers/newsletter_unsubscribes_controller.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class NewsletterUnsubscribesController < ApplicationController + def show + @user = User.find_signed(params[:token], purpose: :newsletter_unsubscribe) + + if @user + @user.update!(unsubscribed_from_newsletter: true) + @success = true + else + @success = false + end + end +end diff --git a/app/controllers/posts_controller.rb b/app/controllers/posts_controller.rb index c37e7ba..d02d9cb 100644 --- a/app/controllers/posts_controller.rb +++ b/app/controllers/posts_controller.rb @@ -35,6 +35,7 @@ def image def new @post = current_user.posts.build + @post.category_id = params[:category_id] if params[:category_id].present? end def create @@ -207,10 +208,10 @@ def set_post # Handle category/post route if params[:category_id] @category = Category.friendly.find(params[:category_id]) - @post = @category.posts.includes(:tags).friendly.find(params[:id]) + @post = @category.posts.includes(:tags, :user, :category).friendly.find(params[:id]) # Handle direct post access (for edit, destroy, etc.) else - @post = Post.includes(:tags).friendly.find(params[:id]) + @post = Post.includes(:tags, :user, :category).friendly.find(params[:id]) end # Only allow viewing unpublished posts by their owner or admin @@ -271,11 +272,6 @@ def process_tags end def post_path_for(post) - if post.category - post_path(post.category, post) - else - # Fallback for posts without category (shouldn't happen in normal flow) - post_path("uncategorized", post) - end + post_path(post.category, post) end end diff --git a/app/controllers/testimonials_controller.rb b/app/controllers/testimonials_controller.rb new file mode 100644 index 0000000..6a0bbdc --- /dev/null +++ b/app/controllers/testimonials_controller.rb @@ -0,0 +1,55 @@ +class TestimonialsController < ApplicationController + before_action :authenticate_user! + + def create + @testimonial = current_user.build_testimonial(testimonial_params) + + if @testimonial.save + @processing = @testimonial.quote.present? + respond_to do |format| + format.turbo_stream + format.html { redirect_to user_path(current_user), notice: "Your testimonial has been submitted for processing." } + end + else + respond_to do |format| + format.turbo_stream do + render turbo_stream: turbo_stream.replace( + "testimonial_section", + partial: "testimonials/section", + locals: { testimonial: @testimonial, user: current_user } + ) + end + format.html { redirect_to user_path(current_user), alert: @testimonial.errors.full_messages.to_sentence } + end + end + end + + def update + @testimonial = current_user.testimonial + + if @testimonial.update(testimonial_params) + @processing = @testimonial.saved_change_to_quote? && @testimonial.quote.present? + respond_to do |format| + format.turbo_stream + format.html { redirect_to user_path(current_user), notice: "Your testimonial has been updated and resubmitted for processing." } + end + else + respond_to do |format| + format.turbo_stream do + render turbo_stream: turbo_stream.replace( + "testimonial_section", + partial: "testimonials/section", + locals: { testimonial: @testimonial, user: current_user } + ) + end + format.html { redirect_to user_path(current_user), alert: @testimonial.errors.full_messages.to_sentence } + end + end + end + + private + + def testimonial_params + params.expect(testimonial: [ :quote ]) + end +end diff --git a/app/controllers/user_settings_controller.rb b/app/controllers/user_settings_controller.rb new file mode 100644 index 0000000..6113395 --- /dev/null +++ b/app/controllers/user_settings_controller.rb @@ -0,0 +1,75 @@ +class UserSettingsController < ApplicationController + before_action :authenticate_user! + + def toggle_public + current_user.update!(public: !current_user.public) + respond_to do |format| + format.turbo_stream do + render turbo_stream: [ + turbo_stream.replace("profile_settings_desktop", partial: "users/profile_settings", locals: { user: current_user, wrapper_id: "profile_settings_desktop" }), + turbo_stream.replace("profile_settings_mobile", partial: "users/profile_settings", locals: { user: current_user, wrapper_id: "profile_settings_mobile" }) + ] + end + format.html { redirect_to user_path(current_user) } + end + end + + def toggle_open_to_work + current_user.update!(open_to_work: !current_user.open_to_work) + respond_to do |format| + format.turbo_stream do + render turbo_stream: [ + turbo_stream.replace("profile_settings_desktop", partial: "users/profile_settings", locals: { user: current_user, wrapper_id: "profile_settings_desktop" }), + turbo_stream.replace("profile_settings_mobile", partial: "users/profile_settings", locals: { user: current_user, wrapper_id: "profile_settings_mobile" }), + turbo_stream.replace("profile_avatar_desktop", partial: "users/profile_avatar", locals: { user: current_user, wrapper_id: "profile_avatar_desktop", size: "w-36 h-[130px]", text_size: "text-5xl" }), + turbo_stream.replace("profile_avatar_mobile", partial: "users/profile_avatar", locals: { user: current_user, wrapper_id: "profile_avatar_mobile", size: "w-28 h-[100px]", text_size: "text-4xl" }) + ] + end + format.html { redirect_to user_path(current_user) } + end + end + + def toggle_newsletter + current_user.update!(unsubscribed_from_newsletter: !current_user.unsubscribed_from_newsletter) + respond_to do |format| + format.turbo_stream do + render turbo_stream: [ + turbo_stream.replace("profile_settings_desktop", partial: "users/profile_settings", locals: { user: current_user, wrapper_id: "profile_settings_desktop" }), + turbo_stream.replace("profile_settings_mobile", partial: "users/profile_settings", locals: { user: current_user, wrapper_id: "profile_settings_mobile" }) + ] + end + format.html { redirect_to user_path(current_user) } + end + end + + def hide_repo + repo_url = params.expect(:repo_url) + current_user.hide_repository!(repo_url) + render_projects_update + end + + def unhide_repo + repo_url = params.expect(:repo_url) + current_user.unhide_repository!(repo_url) + render_projects_update + end + + private + + def render_projects_update + current_user.reload # Reload to get updated stars count + @ruby_repos = current_user.visible_ruby_repositories + @hidden_repos = current_user.hidden_ruby_repositories + + respond_to do |format| + format.turbo_stream do + render turbo_stream: [ + turbo_stream.replace("projects_panel", partial: "users/projects_panel", locals: { ruby_repos: @ruby_repos, hidden_repos: @hidden_repos, user: current_user }), + turbo_stream.update("projects_count", html: @ruby_repos.size.to_s), + turbo_stream.replace("profile_stars", partial: "users/profile_stars", locals: { user: current_user }) + ] + end + format.html { redirect_to user_path(current_user) } + end + end +end diff --git a/app/controllers/users/omniauth_callbacks_controller.rb b/app/controllers/users/omniauth_callbacks_controller.rb index 3bb855b..e72ffe4 100644 --- a/app/controllers/users/omniauth_callbacks_controller.rb +++ b/app/controllers/users/omniauth_callbacks_controller.rb @@ -5,8 +5,9 @@ def github @user = User.from_omniauth(request.env["omniauth.auth"]) if @user.persisted? - sign_in_and_redirect @user, event: :authentication + sign_in @user, event: :authentication set_flash_message(:notice, :success, kind: "GitHub") if is_navigational_format? + redirect_to after_sign_in_path, allow_other_host: true else session["devise.github_data"] = request.env["omniauth.auth"].except(:extra) redirect_to new_user_registration_url @@ -16,4 +17,44 @@ def github def failure redirect_to root_path, alert: "Authentication failed." end + + private + + def after_sign_in_path + # Get the original page user was on (stored in session before OAuth) + return_to = session.delete(:return_to) + session.delete(:from_community) # Clean up, not used anymore + + # Determine final destination + # If specific return_to is set, use it; otherwise go to user profile + final_destination = return_to.presence || user_profile_path + + # In production, sync session to other domain first, then return to original page + if Rails.env.production? + domains = Rails.application.config.x.domains + other_host = (request.host == domains.community) ? domains.primary : domains.community + current_host = request.host + token = @user.generate_cross_domain_token! + + # Build full URL for final destination + # If final_destination is already a full URL (from user_profile_path), use it directly + final_url = final_destination.start_with?("https://") ? final_destination : "https://#{current_host}#{final_destination}" + + # Redirect to other domain to sync session, passing final destination + "https://#{other_host}/auth/receive?token=#{token}&return_to=#{CGI.escape(final_url)}" + else + final_destination + end + end + + def user_profile_path + # In development: /community/:username + # In production: always go to rubycommunity.org/:username + domains = Rails.application.config.x.domains + if Rails.env.production? + "https://#{domains.community}/#{@user.to_param}" + else + "/community/#{@user.to_param}" + end + end end diff --git a/app/controllers/users/sessions_controller.rb b/app/controllers/users/sessions_controller.rb index 711103a..ad5f9b7 100644 --- a/app/controllers/users/sessions_controller.rb +++ b/app/controllers/users/sessions_controller.rb @@ -2,19 +2,56 @@ class Users::SessionsController < Devise::SessionsController def github_auth # Store the return_to path in session session[:return_to] = params[:return_to] if params[:return_to].present? - # Redirect to GitHub OAuth + + # Detect if signing in from community pages (to redirect to profile after sign in) + session[:from_community] = from_community_page? + redirect_to user_github_omniauth_authorize_path, allow_other_host: true end def destroy + domains = Rails.application.config.x.domains + + # Determine where to redirect after sign out + # Community pages -> community index, otherwise -> home + return_to_path = from_community_page? ? users_path : "/" + + other_host = (request.host == domains.community) ? domains.primary : domains.community + current_host = request.host + + # Generate token for cross-domain sign out + token = current_user&.generate_cross_domain_token! + signed_out = (Devise.sign_out_all_scopes ? sign_out : sign_out(resource_name)) set_flash_message! :notice, :signed_out if signed_out - respond_to_on_destroy + + if Rails.env.production? && token + # Sign out on other domain, then come back to this domain + # On community domain, return to root "/"; on primary domain, redirect to community domain root + prod_return_path = (current_host == domains.community) ? "/" : "https://#{domains.community}/" + final_destination = from_community_page? ? prod_return_path : "https://#{current_host}/" + redirect_to "https://#{other_host}/auth/sign_out_receive?token=#{token}&return_to=#{CGI.escape(final_destination)}", allow_other_host: true + else + redirect_to return_to_path + end end private - def respond_to_on_destroy - redirect_to root_path + def from_community_page? + domains = Rails.application.config.x.domains + # Check if on community domain + return true if request.host == domains.community + + # Check if current path or referer is a community path + return true if request.path.start_with?("/community") + + # Check referer for community pages (for sign-in button clicks) + if request.referer.present? + referer_uri = URI.parse(request.referer) rescue nil + return true if referer_uri && (referer_uri.host == domains.community || referer_uri.path.start_with?("/community")) + end + + false end end diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index 9fe525a..9f7eb69 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -1,20 +1,9 @@ class UsersController < ApplicationController def index - @users = User.includes(:posts, :comments) + @users = User.visible - # Apply filters if present - if params[:location].present? - @users = @users.where(location: params[:location]) - @filter_location = params[:location] - end - - if params[:company].present? - @users = @users.where(company: params[:company]) - @filter_company = params[:company] - end - - # Sorting - @sort = params[:sort].presence || "top" + # Sorting - set up early so it can be applied to both main and other country users + @sort = params[:sort].presence || "trending" @dir = params[:dir] == "asc" ? "asc" : "desc" # Normalize legacy/alternate sort params to our toggle model @@ -27,34 +16,113 @@ def index @dir = "desc" end - direction = @dir.to_sym + # Apply filters if present + if params[:location].present? + @filter_location = params[:location].strip + is_country_only = !@filter_location.include?(", ") - @users = case @sort - when "new" - @users.order(created_at: direction) - when "old" - @users.order(created_at: direction) - when "projects" - @users.order(github_repos_count: direction) - when "posts" - @users.order(published_posts_count: direction) - when "comments" - @users.order(published_comments_count: direction) - when "stars" - @users.order(github_stars_sum: direction) - when "az" - # Toggle A–Z vs Z–A using direction - @users.order(Arel.sql("COALESCE(name, username) #{direction == :asc ? 'ASC' : 'DESC'}")) - else - @users.order(github_stars_sum: direction, published_posts_count: direction, github_repos_count: direction) + if is_country_only + # Country-only filter: include all users from that country + @users = @users.from_country(@filter_location) + else + @users = @users.by_normalized_location(@filter_location) + + # Extract country code and load users from other parts of the country + @filter_country_code = @filter_location.split(", ").last + @other_country_users = User.visible + .from_country(@filter_country_code) + .where.not(normalized_location: @filter_location) + @other_country_users = apply_sorting(@other_country_users) + @other_country_users = @other_country_users.page(params[:other_page]).per(20) + end + + # Compute bounding box for map zoom-to-location + filtered_users = is_country_only ? User.visible.from_country(@filter_location) : User.visible.by_normalized_location(@filter_location) + geo_bounds = filtered_users + .where.not(latitude: nil, longitude: nil) + .pick(Arel.sql("MIN(latitude)"), Arel.sql("MAX(latitude)"), + Arel.sql("MIN(longitude)"), Arel.sql("MAX(longitude)")) + if geo_bounds&.all?(&:present?) + @filter_geo_bounds = { south: geo_bounds[0], north: geo_bounds[1], west: geo_bounds[2], east: geo_bounds[3] } + end end + if params[:company].present? + @filter_company = params[:company].strip + company_tokens = @filter_company.split(/\s+/) + if company_tokens.size > 1 + @users = @users.where(company_tokens.map { "company LIKE ?" }.join(" OR "), *company_tokens.map { |t| "%#{User.sanitize_sql_like(t)}%" }) + else + @users = @users.where(company: @filter_company) + end + end + + if params[:open_to_work] == "1" + @users = @users.where(open_to_work: true) + @filter_open_to_work = true + end + + # Map bounds filtering + if params[:south].present? && params[:north].present? && params[:west].present? && params[:east].present? + south, north = params[:south].to_f, params[:north].to_f + west, east = params[:west].to_f, params[:east].to_f + @bounds = { south: south, north: north, west: west, east: east } + + @users = @users.where(latitude: south..north) + if west <= east + @users = @users.where(longitude: west..east) + else + @users = @users.where("longitude >= ? OR longitude <= ?", west, east) + end + end + + @total_users_count = @users.count + + @users = apply_sorting(@users) @users = @users.page(params[:page]).per(20) end + def map_data + data = Rails.cache.fetch("community_map_data", expires_in: 1.hour) do + User.visible + .where.not(latitude: nil, longitude: nil) + .select(:id, :slug, :username, :name, :avatar_url, :latitude, :longitude, :open_to_work, :company, :normalized_location) + .map { |u| + { + id: u.id, + name: u.display_name, + username: u.username, + avatar_url: u.avatar_url, + lat: u.latitude, + lng: u.longitude, + open_to_work: u.open_to_work, + company: u.company, + normalized_location: u.normalized_location, + profile_url: helpers.community_user_url(u) + } + } + end + + render json: data + end + + def og_image + @users = User.where(public: true) + .where.not(avatar_url: [ nil, "" ]) + .order(Arel.sql("COALESCE(github_stars_sum, 0) + COALESCE(published_posts_count, 0) * 10 + COALESCE(published_comments_count, 0) DESC")) + @total_users_count = User.visible.count + render layout: false + end + def show @user = User.friendly.find(params[:id]) + # Handle non-public profiles (only owner can view) + unless @user.public? || (user_signed_in? && current_user == @user) + redirect_to helpers.community_index_path, alert: "This profile is private." + return + end + # Load posts with pagination support # Show unpublished posts only to the owner @posts = if user_signed_in? && current_user == @user @@ -76,6 +144,65 @@ def show .limit(9) # Get Ruby repositories for projects tab - @ruby_repos = @user.ruby_repositories + # Owner sees visible + hidden repos, others only see visible + if user_signed_in? && current_user == @user + @ruby_repos = @user.visible_ruby_repositories + @hidden_repos = @user.hidden_ruby_repositories + else + @ruby_repos = @user.visible_ruby_repositories + @hidden_repos = [] + end + + # Sort projects + @project_sort = params[:project_sort].presence || "fresh" + @project_dir = params[:project_dir] == "asc" ? "asc" : "desc" + @ruby_repos = sort_projects(@ruby_repos) + end + + private + + def sort_projects(repos) + ascending = @project_dir == "asc" + sorted = case @project_sort + when "trending" + repos.sort_by { |r| [ -r.stars_gained, -r.stars ] } + when "stars" + repos.sort_by { |r| -r.stars } + when "az" + repos.sort_by { |r| r.name.downcase } + else # "fresh" + repos + end + # az defaults to asc, others default to desc — reverse when opposite + if @project_sort == "az" + ascending ? sorted : sorted.reverse + else + ascending ? sorted.reverse : sorted + end + end + + def apply_sorting(scope) + direction = @dir.to_sym + + case @sort + when "trending" + scope.order(stars_gained: direction, github_stars_sum: direction) + when "new" + scope.order(created_at: direction) + when "old" + scope.order(created_at: direction) + when "projects" + scope.order(github_repos_count: direction) + when "posts" + scope.order(published_posts_count: direction) + when "comments" + scope.order(published_comments_count: direction) + when "stars" + scope.order(github_stars_sum: direction) + when "az" + scope.order(Arel.sql("COALESCE(name, username) #{direction == :asc ? 'ASC' : 'DESC'}")) + else + scope.order(github_stars_sum: direction, published_posts_count: direction, github_repos_count: direction) + end end end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 06b95fc..746f22d 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -1,31 +1,67 @@ module ApplicationHelper include ImageHelper + + # Convert ISO country code to full name via i18n + def country_name(code) + return nil if code.blank? + I18n.t("countries.#{code.upcase}", default: code) + end + + # Extract country code from normalized location (e.g., "New York, US" -> "US") + def country_code_from_location(normalized_location) + return nil if normalized_location.blank? + normalized_location.split(", ").last + end + + # Get full country name from normalized location + def country_name_from_location(normalized_location) + code = country_code_from_location(normalized_location) + country_name(code) + end + + # Get client country code for analytics (ISO 3166-1 alpha-2, e.g., "US", "DE", "CA") + def client_country_code + return @client_country_code if defined?(@client_country_code) + + @client_country_code = Rails.cache.fetch("geo:#{request.remote_ip}", expires_in: 1.hour) do + result = Geocoder.search(request.remote_ip).first + result&.country_code&.upcase + rescue => e + Rails.logger.warn "Geocoder lookup failed: #{e.message}" + nil + end + end + # Class-level memoized markdown renderer for performance + def self.markdown_renderer + @markdown_renderer ||= begin + renderer = Redcarpet::Render::HTML.new( + filter_html: true, + hard_wrap: true, + link_attributes: { rel: "nofollow", target: "_blank" } + ) + + Redcarpet::Markdown.new(renderer, + autolink: true, + tables: true, + fenced_code_blocks: true, + disable_indented_code_blocks: true, + strikethrough: true, + lax_spacing: true, + space_after_headers: true, + superscript: true, + underline: true, + highlight: true, + quote: true, + footnotes: true + ) + end + end + def markdown_to_html(markdown_text) return "" if markdown_text.blank? - renderer = Redcarpet::Render::HTML.new( - filter_html: true, - hard_wrap: true, - link_attributes: { rel: "nofollow", target: "_blank" } - ) - - markdown = Redcarpet::Markdown.new(renderer, - autolink: true, - tables: true, - fenced_code_blocks: true, - disable_indented_code_blocks: true, - strikethrough: true, - lax_spacing: true, - space_after_headers: true, - superscript: true, - underline: true, - highlight: true, - quote: true, - footnotes: true - ) - # Render markdown and apply syntax highlighting - html = markdown.render(markdown_text) + html = ApplicationHelper.markdown_renderer.render(markdown_text) # Apply syntax highlighting to code blocks doc = Nokogiri::HTML::DocumentFragment.parse(html) @@ -75,7 +111,45 @@ def format_short_date(date) end def post_link_url(post) - post.link? ? safe_external_url(post.url) : post_path_for(post) + if post.link? + safe_external_url(post.url) + else + # In production, always link to primary domain for posts + if Rails.env.production? + primary_domain_post_url(post) + else + post_path_for(post) + end + end + end + + # Generate full URL to post on primary domain (whyruby.info) + # Used to ensure posts always link to the content domain, not the community domain + def primary_domain_post_url(post) + domain = Rails.application.config.x.domains.primary + "https://#{domain}/#{post.category.to_param}/#{post.to_param}" + end + + # Generate edit post URL on primary domain + # Used to ensure edit links always go to whyruby.info, not the community domain + def primary_domain_edit_post_url(post) + if Rails.env.production? + domain = Rails.application.config.x.domains.primary + "https://#{domain}/posts/#{post.to_param}/edit" + else + edit_post_path(post) + end + end + + # Generate delete post URL on primary domain + # Used to ensure delete actions always go to whyruby.info, not the community domain + def primary_domain_destroy_post_url(post) + if Rails.env.production? + domain = Rails.application.config.x.domains.primary + "https://#{domain}/posts/#{post.to_param}" + else + post_destroy_path(post) + end end def post_link_options(post) @@ -151,6 +225,44 @@ def safe_markdown_content(markdown_text) markdown_to_html(markdown_text).html_safe end + # Linkify URLs and GitHub @mentions in user bio text + # - URLs like "example.com" become clickable links + # - @username becomes a link to https://github.com/username + def linkify_bio(text) + return "" if text.blank? + + # Escape HTML to prevent XSS + escaped = ERB::Util.html_escape(text) + + # Pattern for GitHub @mentions + github_pattern = /(?<=\s|^)@([a-zA-Z0-9](?:[a-zA-Z0-9\-]*[a-zA-Z0-9])?)/ + + # Pattern for URLs (with or without protocol) + # Excludes trailing punctuation like commas and periods used in prose + url_pattern = %r{ + (?:https?://)? # Optional protocol + (?:www\.)? # Optional www + [a-zA-Z0-9][a-zA-Z0-9\-]* # Domain name + \.[a-zA-Z]{2,} # TLD + (?:/[^\s,.<>]*)? # Optional path (stops at whitespace, comma, period, angle brackets) + }x + + # Replace GitHub @mentions first + result = escaped.gsub(github_pattern) do |match| + username = Regexp.last_match(1) + %(#{match}) + end + + # Replace URLs (skip github.com since @mentions already handled) + result = result.gsub(url_pattern) do |match| + next match if match.include?("github.com") + url = match.start_with?("http") ? match : "https://#{match}" + %(#{match}) + end + + result.html_safe + end + def has_success_stories? # Cache the result for the request to avoid multiple DB queries if Category.success_story_category @@ -160,6 +272,15 @@ def has_success_stories? end end + def should_show_mobile_cta? + # Show CTA when nav is collapsed (below lg) if user is not signed in + # or if their testimonial is not published + return true unless user_signed_in? + + # Check if user has a published testimonial + !current_user.testimonial&.published? + end + # Generate the full formatted page title that matches the tag format def full_page_title(page_title = nil) if page_title.present? @@ -169,43 +290,155 @@ def full_page_title(page_title = nil) end end + # Cache OG image versions at boot time for performance + OG_IMAGE_VERSIONS = Hash.new do |hash, filename| + path = Rails.root.join("public", filename) + hash[filename] = File.exist?(path) ? File.mtime(path).to_i.to_s : Time.current.to_i.to_s + end + # Generate versioned URL for OG image to bust social media caches - # Can accept a custom path for resource-specific images or use default - def versioned_og_image_url(custom_path = nil) - if custom_path - # For custom paths (like post-specific images), just append a version parameter - # The version will be handled by the resource itself (e.g., post.updated_at) - custom_path - else - # For the default og-image.png, use file modification time as version - og_image_path = Rails.root.join("public", "og-image.webp") - version = if File.exist?(og_image_path) - File.mtime(og_image_path).to_i.to_s - else - # Fallback to app version or deployment timestamp - Rails.application.config.assets.version || Time.current.to_i.to_s - end + # Pass a filename to use a different image (e.g., "og-image-community.png") + def versioned_og_image_url(filename = "og-image.png") + "#{request.base_url}/#{filename}?v=#{OG_IMAGE_VERSIONS[filename]}" + end - "#{request.base_url}/og-image.png?v=#{version}" + # Generate the full page title for community pages (Ruby Community branding) + def community_page_title(page_title = nil) + if page_title.present? + "Ruby Community — #{page_title}" + else + "Ruby Community" end end # URL helpers for the new routing structure def post_url_for(post) - if post.category - post_url(post.category, post) + post_url(post.category, post) + end + + def post_path_for(post) + post_path(post.category, post) + end + + # Cross-domain URL helper with session sync + def cross_domain_url(domain_type, path = "/") + return path unless Rails.env.production? + + domains = Rails.application.config.x.domains + host = (domain_type == :primary) ? domains.primary : domains.community + + # If already on target domain, just return the path + return path if request.host == host + + if user_signed_in? + # Sync session to target domain (memoize token for this request) + token = cross_domain_token_for_request + "https://#{host}/auth/receive?token=#{token}&return_to=#{path}" else - # Fallback for posts without category - post_url("uncategorized", post) + "https://#{host}#{path}" end end - def post_path_for(post) - if post.category - post_path(post.category, post) + # Memoize token per request so multiple links use the same token + def cross_domain_token_for_request + @cross_domain_token ||= current_user.generate_cross_domain_token! + end + + # Helper for community index URL (works in dev and prod) + def community_index_url + return users_path unless Rails.env.production? + + domain = Rails.application.config.x.domains.community + + # In production on community domain, just go to root + return "/" if request.host == domain + + # On primary domain, cross-domain to community + if user_signed_in? + token = current_user.generate_cross_domain_token! + "https://#{domain}/auth/receive?token=#{token}&return_to=/" + else + "https://#{domain}/" + end + end + + # Helper for community index path with query params (for pagination/filtering) + # On community domain in production, uses root path. Otherwise uses /community. + def community_index_path(params = {}) + base_path = if Rails.env.production? && request.host == Rails.application.config.x.domains.community + "/" + else + "/community" + end + + return base_path if params.blank? + + query = params.compact.to_query + query.present? ? "#{base_path}?#{query}" : base_path + end + + # Helper for community user profile URLs (for navigation links) + def community_user_url(user) + if Rails.env.production? + "https://#{Rails.application.config.x.domains.community}/#{user.to_param}" + else + user_path(user) + end + end + + # Helper for community user path with query params (for sorting/filtering links) + # On community domain in production, uses /:id. Otherwise uses /community/:id. + def community_user_path(user, params = {}) + base_path = if Rails.env.production? && request.host == Rails.application.config.x.domains.community + "/#{user.to_param}" + else + user_path(user) + end + + return base_path if params.blank? + + query = params.compact.to_query + query.present? ? "#{base_path}?#{query}" : base_path + end + + # URL for community map data endpoint (works across domains) + def community_map_data_url + if Rails.env.production? && request.host == Rails.application.config.x.domains.community + "/map_data" + else + community_map_data_path + end + end + + # Generate a full URL on the primary domain (whyruby.info) for a given path. + # Used for footer legal links that must resolve on both domains. + def main_site_url(path) + if Rails.env.production? && request.host == Rails.application.config.x.domains.community + "https://#{Rails.application.config.x.domains.primary}#{path}" + else + path + end + end + + # Canonical URL for community root (for meta tags) + # Production: https://rubycommunity.org/ + # Development: http://localhost:3003/community + def community_root_canonical_url + if Rails.env.production? + "https://#{Rails.application.config.x.domains.community}/" + else + users_url + end + end + + # Canonical URL for community user profile (for meta tags) + # Production: https://rubycommunity.org/username + # Development: http://localhost:3003/community/username + def community_user_canonical_url(user) + if Rails.env.production? + "https://#{Rails.application.config.x.domains.community}/#{user.to_param}" else - # Fallback for posts without category - post_path("uncategorized", post) + user_url(user) end end end diff --git a/app/helpers/posts_helper.rb b/app/helpers/posts_helper.rb index 84bd2ea..73ae4c9 100644 --- a/app/helpers/posts_helper.rb +++ b/app/helpers/posts_helper.rb @@ -1,4 +1,12 @@ module PostsHelper + # Distribute posts into columns for masonry-style layout + # Returns an array of arrays, one per column + def distribute_to_columns(posts, num_columns: 3) + columns = Array.new(num_columns) { [] } + posts.each_with_index { |post, i| columns[i % num_columns] << post } + columns + end + def post_meta_title(post) if post.success_story? "#{post.title} Success Story" @@ -12,20 +20,14 @@ def post_meta_description(post) end def post_meta_keywords(post) - categories_and_tags = [ post.category&.name, post.tags.pluck(:name) ].compact.flatten.join(", ") + categories_and_tags = [ post.category&.name, post.tags.map(&:name) ].compact.flatten.join(", ") categories_and_tags.presence ? "#{categories_and_tags}, Ruby, Rails" : t("meta.default.keywords") end def post_meta_image_url(post) if post.featured_image.attached? # Generate the resource-specific image URL with version parameter - base_url = if post.category - "#{request.base_url}/#{post.category.to_param}/#{post.to_param}/og-image.webp" - else - # Fallback for posts without category (shouldn't happen normally) - "#{request.base_url}/uncategorized/#{post.to_param}/og-image.webp" - end - # Add version parameter based on post's updated_at timestamp + base_url = "#{request.base_url}/#{post.category.to_param}/#{post.to_param}/og-image.webp" "#{base_url}?v=#{post.updated_at.to_i}" else # Use the default versioned OG image diff --git a/app/helpers/users_helper.rb b/app/helpers/users_helper.rb index d0f79e0..17ebecb 100644 --- a/app/helpers/users_helper.rb +++ b/app/helpers/users_helper.rb @@ -1,4 +1,13 @@ module UsersHelper + def linkify_company(company) + company.gsub(/@[\w-]+/) do |handle| + name = handle.delete_prefix("@") + link_to(handle, "https://github.com/#{name}", target: "_blank", rel: "noopener", + class: "text-gray-500 underline decoration-gray-300 hover:decoration-red-500 hover:text-red-600 transition-colors", + data: { turbo_frame: "_top" }) + end.html_safe + end + def user_meta_title(user) user.display_name end @@ -19,7 +28,7 @@ def user_meta_image_url(user) if user.avatar_url.present? user.avatar_url else - versioned_og_image_url + versioned_og_image_url("og-image-community.png") end end end diff --git a/app/javascript/controllers/auto_resize_controller.js b/app/javascript/controllers/auto_resize_controller.js new file mode 100644 index 0000000..0957e5d --- /dev/null +++ b/app/javascript/controllers/auto_resize_controller.js @@ -0,0 +1,28 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + connect() { + this.minHeight = parseInt(getComputedStyle(this.element).minHeight) || 96 + this.resize() + + // Observe visibility changes to resize when element becomes visible + this.observer = new IntersectionObserver((entries) => { + if (entries[0].isIntersecting) { + this.resize() + } + }) + this.observer.observe(this.element) + } + + disconnect() { + if (this.observer) { + this.observer.disconnect() + } + } + + resize() { + this.element.style.height = "0px" + const newHeight = Math.max(this.element.scrollHeight, this.minHeight) + this.element.style.height = newHeight + "px" + } +} diff --git a/app/javascript/controllers/autofocus_controller.js b/app/javascript/controllers/autofocus_controller.js new file mode 100644 index 0000000..bdfa800 --- /dev/null +++ b/app/javascript/controllers/autofocus_controller.js @@ -0,0 +1,7 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + connect() { + this.element.focus() + } +} diff --git a/app/javascript/controllers/char_counter_controller.js b/app/javascript/controllers/char_counter_controller.js new file mode 100644 index 0000000..031a9bb --- /dev/null +++ b/app/javascript/controllers/char_counter_controller.js @@ -0,0 +1,54 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static targets = ["input", "counter", "submit"] + static values = { min: Number, max: Number } + + connect() { + this.update() + } + + update() { + const length = this.inputTarget.value.length + const min = this.minValue + const max = this.maxValue + + const pill = "text-xs font-medium px-2.5 py-0.5 rounded-full" + let colorClass + // Empty is valid (saves as draft), 1 to min-1 is invalid, min to max is valid, >max is invalid + const isValid = length === 0 || (length >= min && length <= max) + + if (length === 0) { + colorClass = `${pill} bg-gray-100 text-gray-400` + } else if (length < min) { + colorClass = `${pill} bg-gray-100 text-gray-400` + } else if (length <= max) { + colorClass = `${pill} bg-green-100 text-green-700` + } else { + colorClass = `${pill} bg-red-100 text-red-600` + } + + if (isValid) { + this.submitTarget.disabled = false + this.submitTarget.classList.remove("opacity-50", "cursor-not-allowed") + this.submitTarget.classList.add("cursor-pointer", "hover:bg-red-700") + } else { + this.submitTarget.disabled = true + this.submitTarget.classList.add("opacity-50", "cursor-not-allowed") + this.submitTarget.classList.remove("cursor-pointer", "hover:bg-red-700") + } + + this.counterTargets.forEach(counter => { + counter.textContent = "" + const countSpan = document.createElement("span") + countSpan.textContent = length + const rangeSpan = document.createElement("span") + rangeSpan.className = "opacity-50" + rangeSpan.textContent = ` / ${min}–${max}` + counter.appendChild(countSpan) + counter.appendChild(rangeSpan) + const responsiveClasses = Array.from(counter.classList).filter(c => c.startsWith('hidden') || c.startsWith('md:') || c.startsWith('lg:')) + counter.className = colorClass + (responsiveClasses.length ? ' ' + responsiveClasses.join(' ') : '') + }) + } +} diff --git a/app/javascript/controllers/community_map_controller.js b/app/javascript/controllers/community_map_controller.js new file mode 100644 index 0000000..3cc01e9 --- /dev/null +++ b/app/javascript/controllers/community_map_controller.js @@ -0,0 +1,304 @@ +import { Controller } from "@hotwired/stimulus" + +const GEM_PATH = "M56.6.55h86.84c6.79,0,13.13,3.39,16.9,9.04l36.26,54.34c5.26,7.89,4.37,18.37-2.15,25.25l-79.68,84.14c-8.01,8.46-21.49,8.46-29.51,0L5.59,89.18c-6.52-6.88-7.41-17.36-2.15-25.25L39.7,9.59C43.47,3.94,49.81.55,56.6.55Z" + +export default class extends Controller { + static targets = ["container", "loading"] + static values = { + dataUrl: String, + fitBounds: { type: Object, default: {} } + } + + connect() { + this.mapInitialized = false + this.allUsers = null + this.clusterGroup = null + + this.observer = new IntersectionObserver((entries) => { + entries.forEach(entry => { + if (entry.isIntersecting && !this.mapInitialized) { + this.mapInitialized = true + this.observer.disconnect() + this.loadLeaflet() + } + }) + }, { rootMargin: "200px" }) + + this.observer.observe(this.containerTarget) + + this.handleMapReset = () => this.resetView() + window.addEventListener("map:reset", this.handleMapReset) + + this.handleFrameLoad = (event) => { + if (event.target.id === "community-content") this.refilterMarkers() + } + document.addEventListener("turbo:frame-load", this.handleFrameLoad) + } + + disconnect() { + window.removeEventListener("map:reset", this.handleMapReset) + document.removeEventListener("turbo:frame-load", this.handleFrameLoad) + if (this.observer) this.observer.disconnect() + if (this.map) { + this.map.remove() + this.map = null + } + } + + resetView() { + if (!this.map) return + this.readyForBoundsUpdate = false + const zoom = window.innerWidth < 640 ? 1 : 2 + this.map.setView([30, 10], zoom) + setTimeout(() => { this.readyForBoundsUpdate = true }, 600) + } + + loadLeaflet() { + if (window.L) { + this.loadMarkerCluster() + return + } + + const css = document.createElement("link") + css.rel = "stylesheet" + css.href = "https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" + document.head.appendChild(css) + + const script = document.createElement("script") + script.src = "https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" + script.onload = () => this.loadMarkerCluster() + document.head.appendChild(script) + } + + loadMarkerCluster() { + if (window.L && window.L.markerClusterGroup) { + this.initMap() + return + } + + const css1 = document.createElement("link") + css1.rel = "stylesheet" + css1.href = "https://unpkg.com/leaflet.markercluster@1.5.3/dist/MarkerCluster.css" + document.head.appendChild(css1) + + const css2 = document.createElement("link") + css2.rel = "stylesheet" + css2.href = "https://unpkg.com/leaflet.markercluster@1.5.3/dist/MarkerCluster.Default.css" + document.head.appendChild(css2) + + const script = document.createElement("script") + script.src = "https://unpkg.com/leaflet.markercluster@1.5.3/dist/leaflet.markercluster.js" + script.onload = () => this.initMap() + document.head.appendChild(script) + } + + initMap() { + this.readyForBoundsUpdate = false + + const isMobile = window.innerWidth < 640 + const initialZoom = isMobile ? 1 : 2 + + this.map = L.map(this.containerTarget, { + center: [30, 10], + zoom: initialZoom, + minZoom: 1, + maxZoom: 15, + zoomControl: true, + scrollWheelZoom: false, + attributionControl: true + }) + + L.tileLayer("https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png", { + attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> © <a href="https://carto.com/">CARTO</a>', + subdomains: "abcd", + maxZoom: 20 + }).addTo(this.map) + + setTimeout(() => this.map.invalidateSize(), 100) + + this.map.on("moveend", () => { + if (!this.readyForBoundsUpdate) return + this.onBoundsChange() + }) + + this.loadMarkers() + } + + async loadMarkers() { + try { + const response = await fetch(this.dataUrlValue) + if (!response.ok) throw new Error("Failed to fetch map data") + + this.allUsers = await response.json() + this.hideLoading() + this.createMarkers(this.filterUsers(this.allUsers)) + } catch (error) { + console.error("Community map error:", error) + this.hideLoading() + } + } + + refilterMarkers() { + if (!this.map || !this.allUsers) return + if (this.clusterGroup) { + this.map.removeLayer(this.clusterGroup) + this.clusterGroup = null + } + this.createMarkers(this.filterUsers(this.allUsers)) + } + + filterUsers(users) { + const frame = document.getElementById("community-content") + const src = frame && frame.getAttribute("src") + const params = src + ? new URL(src, window.location.origin).searchParams + : new URLSearchParams(window.location.search) + + const company = params.get("company") + const location = params.get("location") + const openToWork = params.get("open_to_work") + + let filtered = users + if (company) { + const tokens = company.trim().split(/\s+/) + if (tokens.length > 1) { + filtered = filtered.filter(u => u.company && tokens.some(t => u.company.includes(t))) + } else { + filtered = filtered.filter(u => u.company === company) + } + } + if (location) { + if (location.includes(", ")) { + filtered = filtered.filter(u => u.normalized_location === location) + } else { + filtered = filtered.filter(u => u.normalized_location === location || (u.normalized_location && u.normalized_location.endsWith(`, ${location}`))) + } + } + if (openToWork === "1") { + filtered = filtered.filter(u => u.open_to_work) + } + return filtered + } + + createMarkers(users) { + this.clusterGroup = L.markerClusterGroup({ + maxClusterRadius: 80, + spiderfyOnMaxZoom: true, + spiderfyDistanceMultiplier: 2, + showCoverageOnHover: false, + iconCreateFunction: (c) => this.buildClusterIcon(c) + }) + + users.forEach(user => { + const icon = L.divIcon({ + html: this.buildMarkerHtml(user), + className: "community-map-marker", + iconSize: [40, 36], + iconAnchor: [20, 36] + }) + + const marker = L.marker([user.lat, user.lng], { icon, title: user.name }) + marker.on("click", () => { window.location.href = user.profile_url }) + this.clusterGroup.addLayer(marker) + }) + + this.map.addLayer(this.clusterGroup) + + if (!this.initialFitDone) { + this.initialFitDone = true + const fb = this.fitBoundsValue + if (fb && fb.south != null) { + this.map.fitBounds( + [[fb.south, fb.west], [fb.north, fb.east]], + { padding: [40, 40], maxZoom: 10 } + ) + } + } + + this.readyForBoundsUpdate = true + } + + buildMarkerHtml(user) { + const clipId = `map-gem-${user.id}` + let imageContent + if (user.avatar_url) { + imageContent = `<image href="${user.avatar_url}" width="200" height="180" clip-path="url(#${clipId})" preserveAspectRatio="xMidYMid slice"/>` + } else { + const initial = user.name.charAt(0).toUpperCase() + imageContent = `<rect width="200" height="180" fill="#d1d5db" clip-path="url(#${clipId})"/><text x="100" y="100" text-anchor="middle" dominant-baseline="middle" fill="#374151" font-weight="bold" font-size="72">${initial}</text>` + } + + let badge = "" + if (user.open_to_work) { + badge = `<div style="position:absolute;bottom:4px;left:50%;transform:translateX(-50%);background:#dc2626;color:white;font-size:4px;font-weight:bold;padding:0.5px 2.5px;border-radius:9999px;white-space:nowrap;text-transform:uppercase;letter-spacing:0.3px;line-height:1.2;">Open to work</div>` + } + + return `<div style="cursor:pointer;transition:transform 0.2s;width:40px;height:36px;position:relative;" onmouseenter="this.style.transform='scale(1.3)';this.style.zIndex='10'" onmouseleave="this.style.transform='scale(1)';this.style.zIndex=''"><svg viewBox="0 0 200 180" width="40" height="36" style="overflow:visible;filter:drop-shadow(0 2px 4px rgba(0,0,0,0.25))"><defs><clipPath id="${clipId}"><path d="${GEM_PATH}"/></clipPath></defs>${imageContent}<path d="${GEM_PATH}" fill="none" stroke="white" stroke-width="14"/><path d="${GEM_PATH}" fill="none" stroke="#dc2626" stroke-width="8"/></svg>${badge}</div>` + } + + buildClusterIcon(cluster) { + const count = cluster.getChildCount() + const size = Math.min(60, 36 + Math.log2(count) * 6) + const height = size * 0.9 + const fontSize = count >= 10 ? 60 : 72 + + const html = `<div style="cursor:pointer;width:${size}px;height:${height}px;transition:transform 0.2s;" onmouseenter="this.style.transform='scale(1.15)'" onmouseleave="this.style.transform='scale(1)'"><svg viewBox="0 0 200 180" width="${size}" height="${height}" style="overflow:visible;filter:drop-shadow(0 2px 6px rgba(0,0,0,0.3))"><path d="${GEM_PATH}" fill="#dc2626"/><path d="${GEM_PATH}" fill="none" stroke="white" stroke-width="8"/><text x="100" y="85" text-anchor="middle" dominant-baseline="middle" fill="white" font-weight="bold" font-size="${fontSize}">${count}</text></svg></div>` + + return L.divIcon({ + html, + className: "community-map-cluster", + iconSize: [size, height], + iconAnchor: [size / 2, height / 2] + }) + } + + onBoundsChange() { + if (this.boundsTimeout) clearTimeout(this.boundsTimeout) + this.boundsTimeout = setTimeout(() => this.updateContent(), 300) + } + + updateContent() { + const frame = document.getElementById("community-content") + if (!frame) return + + const baseUrl = this.contentBaseUrl() + + // Preserve current sort/filter params from frame's last navigation or page URL + const currentSrc = frame.getAttribute("src") + const params = currentSrc + ? new URL(currentSrc, window.location.origin).searchParams + : new URLSearchParams(window.location.search) + + // Drop location filter — manual map interaction overrides it + params.delete("location") + + // Clear stale bounds and page (reset to page 1 on bounds change) + params.delete("south") + params.delete("north") + params.delete("west") + params.delete("east") + params.delete("page") + + if (this.map.getZoom() >= 3) { + const bounds = this.map.getBounds() + params.set("south", bounds.getSouth().toFixed(4)) + params.set("north", bounds.getNorth().toFixed(4)) + params.set("west", bounds.getWest().toFixed(4)) + params.set("east", bounds.getEast().toFixed(4)) + } + + const query = params.toString() + frame.src = query ? `${baseUrl}?${query}` : baseUrl + } + + contentBaseUrl() { + const url = this.dataUrlValue.replace("/map_data", "") + return url || "/" + } + + hideLoading() { + if (this.hasLoadingTarget) { + this.loadingTarget.classList.add("hidden") + } + } +} diff --git a/app/javascript/controllers/flash_controller.js b/app/javascript/controllers/flash_controller.js new file mode 100644 index 0000000..b9eb7fb --- /dev/null +++ b/app/javascript/controllers/flash_controller.js @@ -0,0 +1,24 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static values = { + autoDismiss: { type: Number, default: 5000 } + } + + connect() { + if (this.autoDismissValue > 0) { + this.timeout = setTimeout(() => this.dismiss(), this.autoDismissValue) + } + } + + disconnect() { + if (this.timeout) { + clearTimeout(this.timeout) + } + } + + dismiss() { + this.element.classList.add("opacity-0", "translate-x-full") + setTimeout(() => this.element.remove(), 300) + } +} diff --git a/app/javascript/controllers/infinite_scroll_controller.js b/app/javascript/controllers/infinite_scroll_controller.js new file mode 100644 index 0000000..9433b0a --- /dev/null +++ b/app/javascript/controllers/infinite_scroll_controller.js @@ -0,0 +1,89 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static targets = ["entries", "pagination", "loading"] + static values = { + url: String, + page: { type: Number, default: 1 }, + loading: { type: Boolean, default: false } + } + + connect() { + this.observeLastEntry() + } + + disconnect() { + if (this.observer) { + this.observer.disconnect() + } + } + + observeLastEntry() { + const options = { + root: null, + rootMargin: "200px", + threshold: 0 + } + + this.observer = new IntersectionObserver((entries) => { + entries.forEach(entry => { + if (entry.isIntersecting && !this.loadingValue) { + this.loadMore() + } + }) + }, options) + + this.observeSentinel() + } + + observeSentinel() { + const sentinel = this.element.querySelector("[data-infinite-scroll-sentinel]") + if (sentinel) { + this.observer.observe(sentinel) + } + } + + async loadMore() { + const nextLink = this.paginationTarget?.querySelector('a[rel="next"]') + if (!nextLink) return + + this.loadingValue = true + this.showLoading() + + try { + const response = await fetch(nextLink.href, { + headers: { + "Accept": "text/vnd.turbo-stream.html", + "X-Requested-With": "XMLHttpRequest" + } + }) + + if (response.ok) { + const html = await response.text() + Turbo.renderStreamMessage(html) + + // Re-observe after new content is loaded + setTimeout(() => { + this.observeSentinel() + }, 100) + } + } catch (error) { + console.error("Infinite scroll error:", error) + } finally { + this.loadingValue = false + this.hideLoading() + } + } + + showLoading() { + if (this.hasLoadingTarget) { + this.loadingTarget.classList.remove("hidden") + } + } + + hideLoading() { + if (this.hasLoadingTarget) { + this.loadingTarget.classList.add("hidden") + } + } +} diff --git a/app/javascript/controllers/loading_button_controller.js b/app/javascript/controllers/loading_button_controller.js new file mode 100644 index 0000000..a60d6e5 --- /dev/null +++ b/app/javascript/controllers/loading_button_controller.js @@ -0,0 +1,23 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static targets = ["icon", "spinner"] + + connect() { + this.element.addEventListener("submit", this.handleSubmit.bind(this)) + } + + handleSubmit() { + if (this.hasIconTarget) { + this.iconTarget.classList.add("hidden") + } + if (this.hasSpinnerTarget) { + this.spinnerTarget.classList.remove("hidden") + } + const button = this.element.querySelector("button") + if (button) { + button.disabled = true + button.classList.add("pointer-events-none", "opacity-70") + } + } +} diff --git a/app/javascript/controllers/sticky_nav_controller.js b/app/javascript/controllers/sticky_nav_controller.js new file mode 100644 index 0000000..d30566c --- /dev/null +++ b/app/javascript/controllers/sticky_nav_controller.js @@ -0,0 +1,121 @@ +import { Controller } from "@hotwired/stimulus" + +// Handles sticky navigation that attaches below the main header on scroll +// Only activates on screens with min-width: 640px AND min-height: 500px +export default class extends Controller { + static targets = ["nav"] + + connect() { + this.headerNav = document.querySelector("nav") + this.placeholder = null + this.isSticky = false + + // Bind methods once to maintain references for removal + this.boundHandleScroll = this.handleScroll.bind(this) + this.boundHandleResize = this.handleResize.bind(this) + this.boundCheckMediaQuery = this.checkMediaQuery.bind(this) + + // Check if we should enable sticky behavior (not on horizontal phones) + this.checkMediaQuery() + this.mediaQueryList = window.matchMedia("(min-width: 640px) and (min-height: 500px)") + this.mediaQueryList.addEventListener("change", this.boundCheckMediaQuery) + + window.addEventListener("scroll", this.boundHandleScroll, { passive: true }) + window.addEventListener("resize", this.boundHandleResize, { passive: true }) + } + + disconnect() { + window.removeEventListener("scroll", this.boundHandleScroll) + window.removeEventListener("resize", this.boundHandleResize) + if (this.mediaQueryList) { + this.mediaQueryList.removeEventListener("change", this.boundCheckMediaQuery) + } + this.unstick() + } + + checkMediaQuery() { + this.enabled = window.matchMedia("(min-width: 640px) and (min-height: 500px)").matches + if (!this.enabled) { + this.unstick() + } else { + this.handleScroll() + } + } + + handleResize() { + this.checkMediaQuery() + if (this.isSticky) { + // Update position on resize + this.navTarget.style.left = "0" + this.navTarget.style.right = "0" + } + } + + handleScroll() { + if (!this.enabled) return + + const headerHeight = this.headerNav ? this.headerNav.offsetHeight : 0 + const navRect = this.isSticky ? this.placeholder.getBoundingClientRect() : this.navTarget.getBoundingClientRect() + const originalTop = this.isSticky ? this.placeholder.getBoundingClientRect().top : navRect.top + + // Should stick when the nav would scroll past the header + // Add small buffer to prevent visual jump + const shouldStick = originalTop <= headerHeight + 8 + + if (shouldStick && !this.isSticky) { + this.stick(headerHeight) + } else if (!shouldStick && this.isSticky) { + this.unstick() + } + } + + stick(headerHeight) { + if (this.isSticky) return + + const rect = this.navTarget.getBoundingClientRect() + + // Create placeholder to maintain layout + this.placeholder = document.createElement("div") + this.placeholder.style.height = `${rect.height}px` + const computedStyle = window.getComputedStyle(this.navTarget) + this.placeholder.style.marginTop = computedStyle.marginTop + this.placeholder.style.marginBottom = computedStyle.marginBottom + this.navTarget.parentNode.insertBefore(this.placeholder, this.navTarget) + + // Make nav fixed with gradient matching Tailwind's md:bg-gradient-to-b md:from-gray-50 md:from-85% md:to-transparent + this.navTarget.style.position = "fixed" + this.navTarget.style.top = `${headerHeight}px` + this.navTarget.style.left = "0" + this.navTarget.style.right = "0" + this.navTarget.style.zIndex = "40" + this.navTarget.style.backgroundImage = "linear-gradient(oklch(0.985 0.002 247.839) 85%, transparent 100%)" + this.navTarget.style.paddingTop = "0.5rem" + this.navTarget.style.paddingBottom = "2rem" // smaller padding for just nav buttons + this.navTarget.style.marginTop = "0" + + this.isSticky = true + } + + unstick() { + if (!this.isSticky) return + + // Remove placeholder + if (this.placeholder && this.placeholder.parentNode) { + this.placeholder.parentNode.removeChild(this.placeholder) + } + this.placeholder = null + + // Reset nav styles + this.navTarget.style.position = "" + this.navTarget.style.top = "" + this.navTarget.style.left = "" + this.navTarget.style.right = "" + this.navTarget.style.zIndex = "" + this.navTarget.style.background = "" + this.navTarget.style.paddingTop = "" + this.navTarget.style.paddingBottom = "" + this.navTarget.style.marginTop = "" + + this.isSticky = false + } +} diff --git a/app/javascript/controllers/tabs_controller.js b/app/javascript/controllers/tabs_controller.js index fa53360..b558639 100644 --- a/app/javascript/controllers/tabs_controller.js +++ b/app/javascript/controllers/tabs_controller.js @@ -2,7 +2,7 @@ import { Controller } from "@hotwired/stimulus" // Connects to data-controller="tabs" export default class extends Controller { - static targets = ["tab", "panel"] + static targets = ["tab", "panel", "tabExtra"] static values = { active: String } connect() { @@ -86,6 +86,15 @@ export default class extends Controller { } }) + // Update tab extras (e.g. sort dropdowns shown only for specific tabs) + this.tabExtraTargets.forEach(extra => { + if (extra.dataset.tabsName === this.activeValue) { + extra.classList.remove("hidden") + } else { + extra.classList.add("hidden") + } + }) + // Update panels this.panelTargets.forEach(panel => { const isActive = panel.dataset.tabsName === this.activeValue diff --git a/app/javascript/controllers/testimonial_carousel_controller.js b/app/javascript/controllers/testimonial_carousel_controller.js new file mode 100644 index 0000000..968cb34 --- /dev/null +++ b/app/javascript/controllers/testimonial_carousel_controller.js @@ -0,0 +1,206 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static targets = ["slide", "dot", "swipeArea", "slidesContainer"] + + connect() { + this.currentIndex = 0 + this.transitioning = false + this.paused = false + this.touchStartX = 0 + this.touchEndX = 0 + + this.slideTargets.forEach((slide, i) => { + if (i === 0) { + slide.classList.remove("hidden") + slide.style.opacity = "1" + slide.style.transform = "translateX(0)" + } else { + slide.classList.add("hidden") + slide.style.opacity = "0" + slide.style.transform = "translateX(0)" + } + }) + + this.updateDots() + this.startAutoplay() + this.setupSwipeListeners() + } + + disconnect() { + this.stopAutoplay() + this.removeSwipeListeners() + } + + setupSwipeListeners() { + this.handleTouchStart = this.handleTouchStart.bind(this) + this.handleTouchEnd = this.handleTouchEnd.bind(this) + + this.element.addEventListener("touchstart", this.handleTouchStart, { passive: true }) + this.element.addEventListener("touchend", this.handleTouchEnd, { passive: true }) + } + + removeSwipeListeners() { + this.element.removeEventListener("touchstart", this.handleTouchStart) + this.element.removeEventListener("touchend", this.handleTouchEnd) + } + + handleTouchStart(event) { + this.touchStartX = event.changedTouches[0].screenX + } + + handleTouchEnd(event) { + this.touchEndX = event.changedTouches[0].screenX + this.handleSwipe() + } + + handleSwipe() { + const swipeThreshold = 50 + const diff = this.touchStartX - this.touchEndX + + if (Math.abs(diff) < swipeThreshold) return + + if (diff > 0) { + this.nextWithSlide("left") + } else { + this.previousWithSlide("right") + } + } + + startAutoplay() { + this.autoplayTimer = setInterval(() => { + if (!this.paused) this.next() + }, 10000) + } + + stopAutoplay() { + if (this.autoplayTimer) { + clearInterval(this.autoplayTimer) + this.autoplayTimer = null + } + } + + resetAutoplay() { + this.stopAutoplay() + this.startAutoplay() + } + + pause() { + this.paused = true + } + + resume() { + this.paused = false + } + + next() { + if (this.slideTargets.length <= 1 || this.transitioning) return + this.goToIndex((this.currentIndex + 1) % this.slideTargets.length, "left") + } + + previous() { + if (this.slideTargets.length <= 1 || this.transitioning) return + this.goToIndex((this.currentIndex - 1 + this.slideTargets.length) % this.slideTargets.length, "right") + } + + nextWithSlide(direction) { + if (this.slideTargets.length <= 1 || this.transitioning) return + this.goToIndex((this.currentIndex + 1) % this.slideTargets.length, direction) + } + + previousWithSlide(direction) { + if (this.slideTargets.length <= 1 || this.transitioning) return + this.goToIndex((this.currentIndex - 1 + this.slideTargets.length) % this.slideTargets.length, direction) + } + + goToSlide(event) { + const index = parseInt(event.currentTarget.dataset.slideIndex, 10) + if (!isNaN(index)) { + const direction = index > this.currentIndex ? "left" : "right" + this.goToIndex(index, direction) + } + } + + goToIndex(index, direction = "left") { + if (index === this.currentIndex) return + this.transitioning = true + this.resetAutoplay() + + const current = this.slideTargets[this.currentIndex] + const next = this.slideTargets[index] + + this.slideTransition(current, next, direction) + + this.currentIndex = index + this.updateDots() + } + + slideTransition(current, next, direction) { + const slideOutX = direction === "left" ? "-30px" : "30px" + const slideInX = direction === "left" ? "30px" : "-30px" + const container = this.slidesContainerTarget + + const containerHeight = current.offsetHeight + container.style.position = "relative" + container.style.height = `${containerHeight}px` + + current.style.position = "absolute" + current.style.top = "0" + current.style.left = "0" + current.style.right = "0" + current.style.transition = "none" + current.style.transform = "translateX(0)" + + next.style.position = "absolute" + next.style.top = "0" + next.style.left = "0" + next.style.right = "0" + next.style.transition = "none" + next.style.transform = `translateX(${slideInX})` + next.style.opacity = "0" + next.classList.remove("hidden") + + requestAnimationFrame(() => { + requestAnimationFrame(() => { + current.style.transition = "transform 0.25s ease-out, opacity 0.25s ease-out" + next.style.transition = "transform 0.25s ease-out, opacity 0.25s ease-out" + + current.style.transform = `translateX(${slideOutX})` + current.style.opacity = "0" + next.style.transform = "translateX(0)" + next.style.opacity = "1" + + setTimeout(() => { + current.classList.add("hidden") + current.style.position = "" + current.style.top = "" + current.style.left = "" + current.style.right = "" + current.style.transform = "" + + next.style.position = "" + next.style.top = "" + next.style.left = "" + next.style.right = "" + + container.style.position = "" + container.style.height = "" + + this.transitioning = false + }, 250) + }) + }) + } + + updateDots() { + this.dotTargets.forEach((dot, i) => { + if (i === this.currentIndex) { + dot.classList.remove("bg-gray-300") + dot.classList.add("bg-red-600") + } else { + dot.classList.remove("bg-red-600") + dot.classList.add("bg-gray-300") + } + }) + } +} diff --git a/app/javascript/controllers/testimonial_edit_controller.js b/app/javascript/controllers/testimonial_edit_controller.js new file mode 100644 index 0000000..48b20e0 --- /dev/null +++ b/app/javascript/controllers/testimonial_edit_controller.js @@ -0,0 +1,15 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static targets = ["display", "form"] + + edit() { + this.displayTarget.classList.add("hidden") + this.formTarget.classList.remove("hidden") + } + + cancel() { + this.formTarget.classList.add("hidden") + this.displayTarget.classList.remove("hidden") + } +} diff --git a/app/jobs/generate_testimonial_fields_job.rb b/app/jobs/generate_testimonial_fields_job.rb new file mode 100644 index 0000000..a1cb7ed --- /dev/null +++ b/app/jobs/generate_testimonial_fields_job.rb @@ -0,0 +1,162 @@ +class GenerateTestimonialFieldsJob < ApplicationJob + queue_as :default + + MAX_HEADING_RETRIES = 3 + + def perform(testimonial) + existing_headings = Testimonial.where.not(id: testimonial.id).where.not(heading: nil).pluck(:heading) + + user = testimonial.user + user_context = [ user.display_name, user.bio, user.company ].compact_blank.join(", ") + + system_prompt = build_system_prompt(existing_headings) + user_prompt = "User: #{user_context}\nQuote: #{testimonial.quote}" + + if testimonial.ai_feedback.present? && testimonial.ai_attempts > 0 + user_prompt += "\n\nPrevious feedback to address: #{testimonial.ai_feedback}" + end + + parsed = generate_fields(system_prompt, user_prompt) + + unless parsed + Rails.logger.error "Failed to generate testimonial fields for testimonial #{testimonial.id}" + testimonial.update!(ai_feedback: "We couldn't process your testimonial right now. Please try again later.") + return + end + + # If the heading collides, retry with the rejected heading added to the exclusion list + retries = 0 + while heading_taken?(parsed["heading"], testimonial.id) && retries < MAX_HEADING_RETRIES + retries += 1 + existing_headings << parsed["heading"] + retry_prompt = build_system_prompt(existing_headings) + parsed = generate_fields(retry_prompt, user_prompt) + break unless parsed + end + + unless parsed + testimonial.update!(ai_feedback: "We couldn't process your testimonial right now. Please try again later.") + return + end + + testimonial.update!( + heading: parsed["heading"], + subheading: parsed["subheading"], + body_text: parsed["body_text"] + ) + ValidateTestimonialJob.perform_later(testimonial) + rescue JSON::ParserError => e + Rails.logger.error "Failed to parse AI response for testimonial #{testimonial.id}: #{e.message}" + testimonial.update!(ai_feedback: "We couldn't process your testimonial right now. Please try again later.") + end + + private + + def build_system_prompt(existing_headings) + taken = if existing_headings.any? + "These headings are ALREADY TAKEN and must NOT be used (pick a synonym or related concept instead): #{existing_headings.join(', ')}." + else + "No headings are taken yet — pick any fitting word." + end + + <<~PROMPT + You generate structured testimonial content for a Ruby programming language advocacy site. + Given a user's quote about why they love Ruby, generate: + + 1. heading: A single unique 1-2 word heading that captures the THEME of the quote (e.g., "Elegance", "Joy", "Craft"). + #{taken} + 2. subheading: A short tagline under 10 words. + 3. body_text: 2-3 sentences that EXTEND and DEEPEN the user's idea — add new angles, examples, or implications. + Do NOT repeat or paraphrase what the user already said. Build on top of it. + + WRITING STYLE — sound like a real person, not an AI: + - NEVER use: delve, tapestry, landscape, foster, showcase, underscore, pivotal, vibrant, crucial, testament, additionally, interplay, intricate, enduring, garner, enhance + - NEVER use inflated phrases: "serves as", "stands as", "is a testament to", "highlights the importance of", "reflects broader", "setting the stage" + - NEVER use "It's not just X, it's Y" or "Not only X but also Y" parallelisms + - NEVER use rule-of-three lists (e.g., "elegant, expressive, and powerful") + - NEVER end with vague positivity ("the future looks bright", "exciting times ahead") + - AVOID -ing tack-ons: "ensuring...", "highlighting...", "fostering..." + - AVOID em dashes. Use commas or periods instead. + - AVOID filler: "In order to", "It is important to note", "Due to the fact that" + - USE simple verbs: "is", "has", "does" — not "serves as", "boasts", "features" + - BE specific and concrete. Say what Ruby actually does, not how significant it is. + - Write like a developer talking to a friend, not a press release. + + Respond with valid JSON only: {"heading": "...", "subheading": "...", "body_text": "..."} + PROMPT + end + + def generate_fields(system_prompt, user_prompt) + result = nil + + if anthropic_configured? + result = generate_with_anthropic(system_prompt, user_prompt) + end + + if result.nil? && openai_configured? + result = generate_with_openai(system_prompt, user_prompt) + end + + result ? JSON.parse(result) : nil + end + + def heading_taken?(heading, testimonial_id) + Testimonial.where.not(id: testimonial_id).exists?(heading: heading) + end + + def anthropic_configured? + Rails.application.credentials.dig(:anthropic, :api_key).present? || + Rails.application.credentials.dig(:anthropic, :access_token).present? + end + + def openai_configured? + Rails.application.credentials.dig(:openai, :api_key).present? || + Rails.application.credentials.dig(:openai, :access_token).present? + end + + def generate_with_anthropic(system_prompt, user_prompt) + api_key = Rails.application.credentials.dig(:anthropic, :api_key).presence || + Rails.application.credentials.dig(:anthropic, :access_token) + + client = Anthropic::Client.new(api_key: api_key) + + response = client.messages( + parameters: { + model: "claude-3-haiku-20240307", + max_tokens: 300, + temperature: 0.7, + system: system_prompt, + messages: [ { role: "user", content: user_prompt } ] + } + ) + + response.dig("content", 0, "text") + rescue => e + Rails.logger.error "Anthropic API error in GenerateTestimonialFieldsJob: #{e.message}" + nil + end + + def generate_with_openai(system_prompt, user_prompt) + token = Rails.application.credentials.dig(:openai, :api_key).presence || + Rails.application.credentials.dig(:openai, :access_token) + + client = OpenAI::Client.new(access_token: token) + + response = client.chat( + parameters: { + model: "gpt-3.5-turbo", + messages: [ + { role: "system", content: system_prompt }, + { role: "user", content: user_prompt } + ], + temperature: 0.7, + max_tokens: 300 + } + ) + + response.dig("choices", 0, "message", "content") + rescue => e + Rails.logger.error "OpenAI API error in GenerateTestimonialFieldsJob: #{e.message}" + nil + end +end diff --git a/app/jobs/normalize_location_job.rb b/app/jobs/normalize_location_job.rb new file mode 100644 index 0000000..97a243f --- /dev/null +++ b/app/jobs/normalize_location_job.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +class NormalizeLocationJob < ApplicationJob + queue_as :default + + def perform(user_id) + user = User.find_by(id: user_id) + return unless user + + result = LocationNormalizer.normalize(user.location) + + if result + timezone = TimezoneResolver.resolve(result[:latitude], result[:longitude]) + user.update_columns( + normalized_location: result[:normalized_location], + latitude: result[:latitude], + longitude: result[:longitude], + timezone: timezone + ) + else + user.update_columns(normalized_location: nil, latitude: nil, longitude: nil, timezone: nil) + end + end +end diff --git a/app/jobs/notify_admin_job.rb b/app/jobs/notify_admin_job.rb index 8c50826..c551b64 100644 --- a/app/jobs/notify_admin_job.rb +++ b/app/jobs/notify_admin_job.rb @@ -2,16 +2,12 @@ class NotifyAdminJob < ApplicationJob queue_as :urgent def perform(post) - # Find all admin users admin_users = User.admins - # For now, we'll just log the notification - # In a real app, you'd send emails or notifications Rails.logger.info "ADMIN ALERT: Post '#{post.title}' (ID: #{post.id}) has been auto-hidden due to #{post.reports_count} reports." - # TODO: Implement email notifications when mailer is configured - # admin_users.each do |admin| - # AdminMailer.post_hidden_notification(admin, post).deliver_later - # end + admin_users.each do |admin| + AdminMailer.post_hidden_notification(admin, post).deliver_later + end end end diff --git a/app/jobs/scheduled_newsletter_job.rb b/app/jobs/scheduled_newsletter_job.rb new file mode 100644 index 0000000..c5a3211 --- /dev/null +++ b/app/jobs/scheduled_newsletter_job.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +class ScheduledNewsletterJob < ApplicationJob + queue_as :default + + EXCLUDED_USERNAMES = %w[dhh matz pragdave amandaperino].freeze + + def perform(user_id, version:) + user = User.find_by(id: user_id) + return unless user + return if user.received_newsletter?(version) + return if user.unsubscribed_from_newsletter? + return if user.email.end_with?("@users.noreply.github.com") + return if EXCLUDED_USERNAMES.include?(user.username) + + NewsletterMailer.update(user, version: version).deliver_now + user.record_newsletter_sent!(version) + end +end diff --git a/app/jobs/update_github_data_job.rb b/app/jobs/update_github_data_job.rb index 3abff76..72c3779 100644 --- a/app/jobs/update_github_data_job.rb +++ b/app/jobs/update_github_data_job.rb @@ -1,40 +1,41 @@ class UpdateGithubDataJob < ApplicationJob queue_as :default - def perform(batch_size: 100) - Rails.logger.info "Starting GitHub data update for all users..." - - users_updated = 0 - users_failed = 0 - - User.find_each(batch_size: batch_size) do |user| - next unless user.username.present? - - begin - # Use the GithubDataFetcher service to update user data - GithubDataFetcher.new(user).fetch_and_update! - - users_updated += 1 - Rails.logger.info "Successfully updated GitHub data for user: #{user.username}" - rescue => e - users_failed += 1 - Rails.logger.error "Failed to update GitHub data for user #{user.username}: #{e.message}" - Rails.logger.error e.backtrace.join("\n") if Rails.env.development? - end - - # Add a small delay to avoid hitting rate limits - # GitHub API allows 60 requests per hour for unauthenticated requests - # or 5000 per hour for authenticated requests - sleep 0.1 + BATCH_SIZE = 5 # Server-side Ruby filtering means fewer repos returned + + def perform + Rails.logger.info "Starting GitHub data update using GraphQL batch fetching..." + + total_updated = 0 + total_failed = 0 + all_errors = [] + + User.where.not(username: [ nil, "" ]).find_in_batches(batch_size: BATCH_SIZE) do |batch| + Rails.logger.info "Processing batch of #{batch.size} users..." + + results = GithubDataFetcher.batch_fetch_and_update!(batch) + + total_updated += results[:updated] + total_failed += results[:failed] + all_errors.concat(results[:errors]) if results[:errors].present? + + Rails.logger.info "Batch complete: #{results[:updated]} updated, #{results[:failed]} failed" + + # Brief pause between batches to be respectful of API + sleep 0.5 end - Rails.logger.info "GitHub data update completed. Updated: #{users_updated}, Failed: #{users_failed}" + Rails.logger.info "GitHub data update completed. Updated: #{total_updated}, Failed: #{total_failed}" + + if all_errors.any? + Rails.logger.warn "Errors encountered: #{all_errors.first(10).join(', ')}#{all_errors.size > 10 ? '...' : ''}" + end - # Notify admin if there were failures - if users_failed > 0 && defined?(NotifyAdminJob) + # Notify admin if there were significant failures + if total_failed > 5 && defined?(NotifyAdminJob) NotifyAdminJob.perform_later( subject: "GitHub Data Update Report", - message: "GitHub data update completed with #{users_failed} failures out of #{users_updated + users_failed} total users." + message: "GitHub data update completed with #{total_failed} failures out of #{total_updated + total_failed} total users." ) end end diff --git a/app/jobs/validate_testimonial_job.rb b/app/jobs/validate_testimonial_job.rb new file mode 100644 index 0000000..40f364b --- /dev/null +++ b/app/jobs/validate_testimonial_job.rb @@ -0,0 +1,170 @@ +class ValidateTestimonialJob < ApplicationJob + queue_as :default + + MAX_ATTEMPTS = 3 + + def perform(testimonial) + existing = Testimonial.published.where.not(id: testimonial.id) + .pluck(:heading, :quote) + .map { |h, q| "Heading: #{h}, Quote: #{q}" } + .join("\n") + + system_prompt = <<~PROMPT + You validate testimonials for a Ruby programming language advocacy site. + + CONTENT POLICY: + - Hate speech, slurs, personal attacks, or targeted insults toward individuals or groups are NEVER allowed. + - Casual expletives used positively (e.g., "Damn, Ruby is amazing!" or "Fuck, I love this language!") are ALLOWED. + - The key distinction: profanity expressing enthusiasm = OK. Profanity attacking or demeaning people/groups = NOT OK. + - The quote MUST express genuine love or appreciation for Ruby. This is an advocacy site — negative, dismissive, sarcastic, or trolling sentiments about Ruby are NOT allowed. + + VALIDATION RULES: + 1. First check the user's QUOTE against the content policy. If it violates (including being negative about Ruby), reject immediately with reject_reason "quote". + 2. If the quote is fine, check the AI-generated fields (heading/subheading/body). ONLY reject generation if there is a CLEAR problem: + - The heading duplicates an existing one listed below + - The body contradicts or misrepresents the quote + - The subheading is nonsensical or unrelated + - The content is factually wrong about Ruby + Do NOT reject just because the fields could be "better" or "more creative". Good enough is good enough — publish it. + 3. If everything looks acceptable, publish it. + + AI-SOUNDING LANGUAGE CHECK: + Reject with reason "generation" if the generated heading/subheading/body contains: + - Words: delve, tapestry, landscape, foster, showcase, underscore, pivotal, vibrant, crucial, testament, additionally, interplay, intricate, enduring, garner, enhance + - Patterns: "serves as", "stands as", "is a testament to", "not just X, it's Y", "not only X but also Y" + - Rule-of-three adjective/noun lists + - Vague positive endings ("the future looks bright", "exciting times ahead") + - Superficial -ing tack-ons ("ensuring...", "highlighting...", "fostering...") + If the quote itself is fine but the generated text sounds like AI wrote it, set reject_reason to "generation" and explain which phrases sound artificial. + + Existing published testimonials (avoid duplicate headings/themes): + #{existing.presence || "None yet."} + + Respond with valid JSON only: {"publish": true/false, "reject_reason": "quote" or "generation" or null, "feedback": "..."} + - reject_reason "quote": the user's quote violates content policy or is not meaningful. Feedback should tell the USER what to fix. + - reject_reason "generation": quote is fine but generated fields have a specific problem. Feedback must be a SPECIFIC INSTRUCTION for the AI generator, e.g., "The heading 'X' is already taken, use a different word" or "The body contradicts the quote by saying Y when the user said Z". Be concrete. + - reject_reason null: publishing. Feedback should be a short positive note for the user. + PROMPT + + user_prompt = <<~PROMPT + Quote: #{testimonial.quote} + Generated heading: #{testimonial.heading} + Generated subheading: #{testimonial.subheading} + Generated body: #{testimonial.body_text} + PROMPT + + result = nil + + if anthropic_configured? + result = generate_with_anthropic(system_prompt, user_prompt) + end + + if result.nil? && openai_configured? + result = generate_with_openai(system_prompt, user_prompt) + end + + if result + parsed = JSON.parse(result) + + if parsed["publish"] + testimonial.update!(published: true, ai_feedback: parsed["feedback"], reject_reason: nil) + elsif parsed["reject_reason"] == "quote" + testimonial.update!( + published: false, + ai_feedback: parsed["feedback"], + reject_reason: "quote" + ) + elsif testimonial.ai_attempts < MAX_ATTEMPTS + testimonial.update!( + ai_attempts: testimonial.ai_attempts + 1, + ai_feedback: parsed["feedback"], + reject_reason: "generation", + published: false + ) + GenerateTestimonialFieldsJob.perform_later(testimonial) + else + testimonial.update!( + published: false, + ai_feedback: parsed["feedback"], + reject_reason: "generation" + ) + end + else + Rails.logger.error "Failed to validate testimonial #{testimonial.id}" + testimonial.update!(ai_feedback: "We couldn't validate your testimonial right now. Please try again later.") + end + + broadcast_update(testimonial) + rescue JSON::ParserError => e + Rails.logger.error "Failed to parse validation response for testimonial #{testimonial.id}: #{e.message}" + testimonial.update!(ai_feedback: "We couldn't validate your testimonial right now. Please try again later.") + broadcast_update(testimonial) + end + + private + + def anthropic_configured? + Rails.application.credentials.dig(:anthropic, :api_key).present? || + Rails.application.credentials.dig(:anthropic, :access_token).present? + end + + def openai_configured? + Rails.application.credentials.dig(:openai, :api_key).present? || + Rails.application.credentials.dig(:openai, :access_token).present? + end + + def generate_with_anthropic(system_prompt, user_prompt) + api_key = Rails.application.credentials.dig(:anthropic, :api_key).presence || + Rails.application.credentials.dig(:anthropic, :access_token) + + client = Anthropic::Client.new(api_key: api_key) + + response = client.messages( + parameters: { + model: "claude-3-haiku-20240307", + max_tokens: 300, + temperature: 0.3, + system: system_prompt, + messages: [ { role: "user", content: user_prompt } ] + } + ) + + response.dig("content", 0, "text") + rescue => e + Rails.logger.error "Anthropic API error in ValidateTestimonialJob: #{e.message}" + nil + end + + def generate_with_openai(system_prompt, user_prompt) + token = Rails.application.credentials.dig(:openai, :api_key).presence || + Rails.application.credentials.dig(:openai, :access_token) + + client = OpenAI::Client.new(access_token: token) + + response = client.chat( + parameters: { + model: "gpt-3.5-turbo", + messages: [ + { role: "system", content: system_prompt }, + { role: "user", content: user_prompt } + ], + temperature: 0.3, + max_tokens: 300 + } + ) + + response.dig("choices", 0, "message", "content") + rescue => e + Rails.logger.error "OpenAI API error in ValidateTestimonialJob: #{e.message}" + nil + end + + def broadcast_update(testimonial) + Turbo::StreamsChannel.broadcast_replace_to( + "testimonial_#{testimonial.id}", + target: "testimonial_section", + partial: "testimonials/section", + locals: { testimonial: testimonial, user: testimonial.user } + ) + end +end diff --git a/app/mailers/admin_mailer.rb b/app/mailers/admin_mailer.rb new file mode 100644 index 0000000..fbbaee4 --- /dev/null +++ b/app/mailers/admin_mailer.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class AdminMailer < ApplicationMailer + def post_hidden_notification(admin, post) + @admin = admin + @post = post + mail(to: @admin.email, subject: "Post auto-hidden: #{post.title}") + end +end diff --git a/app/mailers/application_mailer.rb b/app/mailers/application_mailer.rb index 3c34c81..47cdc62 100644 --- a/app/mailers/application_mailer.rb +++ b/app/mailers/application_mailer.rb @@ -1,4 +1,3 @@ class ApplicationMailer < ActionMailer::Base - default from: "from@example.com" layout "mailer" end diff --git a/app/mailers/newsletter_mailer.rb b/app/mailers/newsletter_mailer.rb new file mode 100644 index 0000000..059c0bb --- /dev/null +++ b/app/mailers/newsletter_mailer.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +class NewsletterMailer < ApplicationMailer + SUBJECTS = { + 1 => "💎 Why Ruby? Update: Testimonials, New Features, and One more thing..." + }.freeze + + def update(user, version:) + @user = user + @version = version + @open_tracking_url = "https://whyruby.info/newsletter/open/#{@user.newsletter_open_token(version)}" + mail( + to: @user.email, + subject: SUBJECTS[version] || "Why Ruby? Update", + template_name: "update_v#{version}" + ) + end +end diff --git a/app/mailers/testimonial_mailer.rb b/app/mailers/testimonial_mailer.rb new file mode 100644 index 0000000..14b924b --- /dev/null +++ b/app/mailers/testimonial_mailer.rb @@ -0,0 +1,8 @@ +class TestimonialMailer < ApplicationMailer + def invitation(user) + @user = user + @profile_url = user_url(@user) + + mail(to: @user.email, subject: "Share why you love Ruby!") + end +end diff --git a/app/models/comment.rb b/app/models/comment.rb index 85bb80e..a324951 100644 --- a/app/models/comment.rb +++ b/app/models/comment.rb @@ -1,6 +1,6 @@ class Comment < ApplicationRecord # Associations - belongs_to :post + belongs_to :post, counter_cache: true belongs_to :user # Validations diff --git a/app/models/post.rb b/app/models/post.rb index 3f6115f..b72db52 100644 --- a/app/models/post.rb +++ b/app/models/post.rb @@ -214,7 +214,7 @@ def update_user_counter_caches def url_uniqueness return unless url.present? - existing_post = Post.where(url: url).where.not(id: id).first + existing_post = Post.where(url: url).where.not(id: id).take if existing_post self.duplicate_post = existing_post errors.add(:url, "has already been posted") diff --git a/app/models/project.rb b/app/models/project.rb new file mode 100644 index 0000000..92d5ef9 --- /dev/null +++ b/app/models/project.rb @@ -0,0 +1,26 @@ +class Project < ApplicationRecord + belongs_to :user + has_many :star_snapshots, dependent: :destroy + + validates :name, presence: true + validates :github_url, presence: true, uniqueness: { scope: :user_id } + + scope :visible, -> { where(hidden: false, archived: false) } + scope :hidden, -> { where(hidden: true) } + scope :archived, -> { where(archived: true) } + scope :active, -> { where(archived: false) } + scope :by_stars, -> { order(stars: :desc) } + scope :by_pushed_at, -> { order(Arel.sql("CASE WHEN pushed_at IS NULL THEN 1 ELSE 0 END, pushed_at DESC")) } + + def stars_gained + snapshots = star_snapshots.recent.limit(2).pluck(:stars) + return 0 if snapshots.size < 2 + [ snapshots.first - snapshots.last, 0 ].max + end + + def record_snapshot! + snapshot = star_snapshots.find_or_initialize_by(recorded_on: Date.current) + snapshot.update!(stars: stars) + snapshot + end +end diff --git a/app/models/star_snapshot.rb b/app/models/star_snapshot.rb new file mode 100644 index 0000000..27f48aa --- /dev/null +++ b/app/models/star_snapshot.rb @@ -0,0 +1,9 @@ +class StarSnapshot < ApplicationRecord + belongs_to :project + + validates :stars, numericality: { greater_than_or_equal_to: 0 } + validates :recorded_on, presence: true, uniqueness: { scope: :project_id } + + scope :chronological, -> { order(recorded_on: :asc) } + scope :recent, -> { order(recorded_on: :desc) } +end diff --git a/app/models/testimonial.rb b/app/models/testimonial.rb new file mode 100644 index 0000000..353e957 --- /dev/null +++ b/app/models/testimonial.rb @@ -0,0 +1,33 @@ +class Testimonial < ApplicationRecord + belongs_to :user + + validates :quote, length: { minimum: 140, maximum: 320 }, allow_blank: true + validates :user_id, uniqueness: true + validates :heading, uniqueness: true, allow_nil: true + + scope :published, -> { where(published: true) } + scope :ordered, -> { order(Arel.sql("position ASC NULLS LAST, created_at DESC")) } + + after_save :process_quote_change, if: -> { saved_change_to_quote? } + + private + + def process_quote_change + if quote.blank? + # Empty quote - just save as unpublished with feedback, no AI processing + update_columns( + ai_attempts: 0, + published: false, + ai_feedback: "", + reject_reason: nil, + heading: nil, + subheading: nil, + body_text: nil + ) + else + # Non-empty quote - process with AI + update_columns(ai_attempts: 0, published: false, ai_feedback: nil, reject_reason: nil) + GenerateTestimonialFieldsJob.perform_later(self) + end + end +end diff --git a/app/models/user.rb b/app/models/user.rb index 3acd3c6..4bde2f4 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -6,12 +6,28 @@ class User < ApplicationRecord has_many :posts, dependent: :destroy has_many :comments, dependent: :destroy has_many :reports, dependent: :destroy + has_one :testimonial, dependent: :destroy has_many :published_posts, -> { published }, class_name: "Post" has_many :published_comments, -> { published }, class_name: "Comment" + has_many :projects, dependent: :destroy + has_many :visible_projects, -> { visible.by_pushed_at }, class_name: "Project" + has_many :hidden_projects, -> { hidden.active.by_pushed_at }, class_name: "Project" # Enums enum :role, { member: 0, admin: 1 } + # Normalizations (strip whitespace from GitHub data) + normalizes :name, :bio, :company, :location, :website, :twitter, with: ->(value) { value.strip.presence } + + # Callbacks + before_save :precompute_bio_html, if: :will_save_change_to_bio? + after_save :enqueue_location_normalization, if: :saved_change_to_location? + after_commit :invalidate_map_cache, if: -> { + saved_change_to_latitude? || saved_change_to_longitude? || + saved_change_to_public? || saved_change_to_avatar_url? || + saved_change_to_open_to_work? + } + # Validations validates :github_id, presence: true, uniqueness: true validates :username, presence: true, uniqueness: true @@ -23,6 +39,13 @@ class User < ApplicationRecord where("published_posts_count >= ? AND published_comments_count >= ?", 3, 10) } scope :admins, -> { where(role: :admin) } + scope :visible, -> { where(public: true) } + scope :by_normalized_location, ->(loc) { + where(normalized_location: loc).or(where(normalized_location: nil, location: loc)) + } + scope :from_country, ->(country_code) { + where("normalized_location LIKE ? OR normalized_location = ?", "%, #{country_code}", country_code) + } # Devise modules for GitHub OAuth devise :omniauthable, omniauth_providers: [ :github ] @@ -36,35 +59,78 @@ def can_report? trusted? end - def ruby_repositories - return [] unless github_repos.present? + def visible_ruby_repositories + visible_projects.to_a + end - repos = JSON.parse(github_repos, symbolize_names: true) + def hidden_ruby_repositories + hidden_projects.to_a + end - # Repositories are already filtered for Ruby language and exclude forks - # during the fetch process in GithubDataFetcher - # Just sort by pushed_at descending (most recently pushed first) - repos.sort_by { |repo| repo[:pushed_at].present? ? -Time.parse(repo[:pushed_at]).to_i : 0 } - rescue JSON::ParserError, ArgumentError => e - Rails.logger.error "Error parsing repositories: #{e.message}" - [] + def hide_repository!(repo_url) + project = projects.find_by(github_url: repo_url) + return unless project && !project.hidden? + project.update!(hidden: true) + recalculate_visible_stats! end - def total_github_stars - return github_stars_sum if respond_to?(:github_stars_sum) && github_stars_sum.present? + def unhide_repository!(repo_url) + project = projects.find_by(github_url: repo_url) + return unless project && project.hidden? + project.update!(hidden: false) + recalculate_visible_stats! + end - repos = ruby_repositories - return 0 if repos.blank? + def recalculate_visible_stats! + visible = projects.visible + gained = visible.sum { |p| p.stars_gained } + update!( + github_repos_count: visible.count, + github_stars_sum: visible.sum(:stars), + stars_gained: gained + ) + end - repos.sum { |repo| repo[:stars].to_i } - rescue => _e - 0 + def total_github_stars + github_stars_sum.to_i end def display_name name.presence || username end + def country_code + return nil if normalized_location.blank? + normalized_location.split(", ").last + end + + # Newsletter tracking + def received_newsletter?(version) + (newsletters_received || []).include?(version) + end + + def record_newsletter_sent!(version) + current = newsletters_received || [] + update!(newsletters_received: current + [ version ]) unless current.include?(version) + end + + def opened_newsletter?(version) + (newsletters_opened || []).include?(version) + end + + def record_newsletter_opened!(version) + current = newsletters_opened || [] + update!(newsletters_opened: current + [ version ]) unless current.include?(version) + end + + def newsletter_open_token(version) + Rails.application.message_verifier("newsletter_open").generate({ user_id: id, version: version }) + end + + def newsletter_unsubscribe_token + signed_id(purpose: :newsletter_unsubscribe, expires_in: nil) + end + def github_profile_url "https://github.com/#{username}" end @@ -120,4 +186,78 @@ def self.new_with_session(params, session) end end end + + # Cross-domain session sync methods + def generate_cross_domain_token! + token = SecureRandom.urlsafe_base64(32) + update_columns( + cross_domain_token: token, + cross_domain_token_expires_at: 30.seconds.from_now + ) + token + end + + def self.authenticate_cross_domain_token(token) + return nil if token.blank? + + user = find_by(cross_domain_token: token) + return nil unless user + return nil if user.cross_domain_token_expires_at < Time.current + + # Invalidate token (one-time use) + user.update_columns(cross_domain_token: nil, cross_domain_token_expires_at: nil) + user + end + + # Linkify URLs and GitHub @mentions in bio text + # Returns precomputed HTML for display + def self.linkify_bio(text) + return "" if text.blank? + + # Escape HTML to prevent XSS + escaped = ERB::Util.html_escape(text) + + # Pattern for GitHub @mentions + github_pattern = /(?<=\s|^)@([a-zA-Z0-9](?:[a-zA-Z0-9\-]*[a-zA-Z0-9])?)/ + + # Pattern for URLs (with or without protocol) + url_pattern = %r{ + (?:https?://)? # Optional protocol + (?:www\.)? # Optional www + [a-zA-Z0-9][a-zA-Z0-9\-]* # Domain name + \.[a-zA-Z]{2,} # TLD + (?:/[^\s,.<>]*)? # Optional path + }x + + # Replace GitHub @mentions first + result = escaped.gsub(github_pattern) do |match| + username = Regexp.last_match(1) + %(<a href="https://github.com/#{username}" target="_blank" rel="noopener" class="underline hover:text-red-600 transition-colors">#{match}</a>) + end + + # Replace URLs (skip github.com since @mentions already handled) + result.gsub(url_pattern) do |match| + next match if match.include?("github.com") + url = match.start_with?("http") ? match : "https://#{match}" + %(<a href="#{url}" target="_blank" rel="noopener" class="underline hover:text-red-600 transition-colors">#{match}</a>) + end + end + + private + + def precompute_bio_html + self.bio_html = self.class.linkify_bio(bio) + end + + def enqueue_location_normalization + if location.present? + NormalizeLocationJob.perform_later(id) + else + update_columns(normalized_location: nil, latitude: nil, longitude: nil) + end + end + + def invalidate_map_cache + Rails.cache.delete("community_map_data") + end end diff --git a/app/services/github_data_fetcher.rb b/app/services/github_data_fetcher.rb index 6401732..4619e4d 100644 --- a/app/services/github_data_fetcher.rb +++ b/app/services/github_data_fetcher.rb @@ -1,4 +1,6 @@ class GithubDataFetcher + GRAPHQL_ENDPOINT = "https://api.github.com/graphql" + attr_reader :user, :auth_data, :api_token # Can be initialized with either OAuth auth_data (for sign-in) or without it (for scheduled updates) @@ -8,6 +10,227 @@ def initialize(user, auth_data = nil) @api_token = auth_data&.credentials&.token || Rails.application.credentials.dig(:github, :api_token) end + # === Class Methods for Batch GraphQL Fetching === + + # Main batch method - fetches and updates multiple users in a single GraphQL request + # Automatically splits batch on transient errors (502/503/504) + def self.batch_fetch_and_update!(users, api_token: nil) + api_token ||= Rails.application.credentials.dig(:github, :api_token) + return { updated: 0, failed: users.size, errors: [ "No API token configured" ] } unless api_token.present? + + users_with_usernames = users.select { |u| u.username.present? } + return { updated: 0, failed: 0, errors: [] } if users_with_usernames.empty? + + query = build_batch_query(users_with_usernames) + response = graphql_request(query, api_token, retries: 2) + + # On transient errors, split batch in half and retry recursively + if response[:errors].present? && response[:data].nil? + error_msg = response[:errors].first.to_s + if error_msg.match?(/50[234]/) && users_with_usernames.size > 1 + Rails.logger.warn "Batch of #{users_with_usernames.size} failed with #{error_msg}, splitting in half..." + mid = users_with_usernames.size / 2 + first_half = batch_fetch_and_update!(users_with_usernames[0...mid], api_token: api_token) + sleep(1) # Brief pause between split batches + second_half = batch_fetch_and_update!(users_with_usernames[mid..], api_token: api_token) + + return { + updated: first_half[:updated] + second_half[:updated], + failed: first_half[:failed] + second_half[:failed], + errors: first_half[:errors] + second_half[:errors] + } + end + + return { updated: 0, failed: users_with_usernames.size, errors: response[:errors] } + end + + updated = 0 + failed = 0 + errors = [] + + users_with_usernames.each_with_index do |user, index| + user_key = :"user_#{index}" + repos_key = :"repos_#{index}" + user_data = response.dig(:data, user_key) + repos_data = response.dig(:data, repos_key, :nodes) + + if user_data.nil? + failed += 1 + errors << "User #{user.username} not found on GitHub" + next + end + + begin + update_user_from_graphql(user, user_data, repos_data || []) + updated += 1 + rescue => e + failed += 1 + errors << "Failed to update #{user.username}: #{e.message}" + Rails.logger.error "GraphQL batch update error for #{user.username}: #{e.message}" + end + end + + { updated: updated, failed: failed, errors: errors } + end + + # Execute a GraphQL request with retry for transient errors + def self.graphql_request(query, api_token, retries: 3) + require "net/http" + require "json" + + uri = URI(GRAPHQL_ENDPOINT) + + retries.times do |attempt| + begin + request = Net::HTTP::Post.new(uri) + request["Content-Type"] = "application/json" + request["Authorization"] = "Bearer #{api_token}" + request.body = { query: query }.to_json + + response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http| + http.request(request) + end + + if response.code == "200" + return JSON.parse(response.body, symbolize_names: true) + elsif %w[502 503 504].include?(response.code) && attempt < retries - 1 + sleep_time = 2 ** (attempt + 1) # Exponential backoff: 2, 4, 8 seconds + Rails.logger.warn "GraphQL request got #{response.code}, retrying in #{sleep_time}s (attempt #{attempt + 1}/#{retries})" + sleep(sleep_time) + next + else + Rails.logger.error "GraphQL request failed: #{response.code} - #{response.body}" + return { errors: [ "HTTP #{response.code}: #{response.message}" ] } + end + rescue Net::OpenTimeout, Net::ReadTimeout => e + if attempt < retries - 1 + sleep_time = 2 ** (attempt + 1) + Rails.logger.warn "GraphQL request timed out, retrying in #{sleep_time}s (attempt #{attempt + 1}/#{retries})" + sleep(sleep_time) + next + else + Rails.logger.error "GraphQL request timed out after #{retries} attempts: #{e.message}" + return { errors: [ "Request timed out: #{e.message}" ] } + end + end + end + end + + # Build a batched GraphQL query for multiple users using aliases + # Uses search query to filter by language:Ruby server-side + def self.build_batch_query(users) + user_queries = users.each_with_index.map do |user, index| + <<~GRAPHQL + user_#{index}: user(login: "#{user.username}") { + login + email + name + bio + company + websiteUrl + twitterUsername + location + avatarUrl + } + repos_#{index}: search(query: "user:#{user.username} language:Ruby fork:false archived:false sort:updated", type: REPOSITORY, first: 100) { + nodes { + ... on Repository { + name + description + stargazerCount + url + forks { + totalCount + } + diskUsage + pushedAt + repositoryTopics(first: 10) { + nodes { + topic { + name + } + } + } + } + } + } + GRAPHQL + end.join("\n") + + "query { #{user_queries} }" + end + + # Update a user from GraphQL response data + def self.update_user_from_graphql(user, profile_data, repos_data) + # Update profile fields + user.update!( + username: profile_data[:login], + email: profile_data[:email] || user.email, + name: profile_data[:name], + bio: profile_data[:bio], + company: profile_data[:company], + website: profile_data[:websiteUrl].presence, + twitter: profile_data[:twitterUsername].presence, + location: profile_data[:location], + avatar_url: profile_data[:avatarUrl], + github_data_updated_at: Time.current + ) + + # Process repositories into Project records + repos = repos_data.map do |repo| + { + name: repo[:name], + description: repo[:description], + stars: repo[:stargazerCount], + github_url: repo[:url], + forks_count: repo.dig(:forks, :totalCount) || 0, + size: repo[:diskUsage] || 0, + topics: (repo.dig(:repositoryTopics, :nodes) || []).map { |t| t.dig(:topic, :name) }.compact, + pushed_at: repo[:pushedAt] + } + end + + sync_projects!(user, repos) + end + + # Sync GitHub repos to Project records with star snapshot tracking + def self.sync_projects!(user, repos_data) + current_urls = repos_data.map { |r| r[:github_url] || r[:url] } + + # Soft-archive projects no longer returned by GitHub + user.projects.active.where.not(github_url: current_urls).update_all(archived: true) + + repos_data.each do |repo_data| + url = repo_data[:github_url] || repo_data[:url] + project = user.projects.find_or_initialize_by(github_url: url) + + project.assign_attributes( + name: repo_data[:name], + description: repo_data[:description], + stars: repo_data[:stars].to_i, + forks_count: repo_data[:forks_count].to_i, + size: repo_data[:size].to_i, + topics: repo_data[:topics] || [], + pushed_at: repo_data[:pushed_at].present? ? Time.parse(repo_data[:pushed_at].to_s) : nil, + archived: false + ) + + project.save! + project.record_snapshot! + end + + # Recalculate cached stats on user + visible = user.projects.visible + gained = visible.sum { |p| p.stars_gained } + user.update!( + github_repos_count: visible.count, + github_stars_sum: visible.sum(:stars), + stars_gained: gained + ) + end + + # === Instance Methods for OAuth Sign-in (unchanged) === + def fetch_and_update! update_basic_profile fetch_and_store_repositories @@ -30,6 +253,8 @@ def update_from_oauth_data raw_info = auth_data.extra.raw_info user.update!( + username: auth_data.info.nickname, + email: auth_data.info.email, name: raw_info.name, bio: raw_info.bio, company: raw_info.company, @@ -59,6 +284,8 @@ def update_from_api data = JSON.parse(response.body) user.update!( + username: data["login"], + email: data["email"] || user.email, name: data["name"], bio: data["bio"], company: data["company"], @@ -74,17 +301,14 @@ def update_from_api end def fetch_and_store_repositories - # Get username from auth_data if available, otherwise from user github_username = auth_data&.info&.nickname || user.username - return unless github_username.present? - # Store repos as JSON in the github_repos field and update stars sum repos = fetch_ruby_repositories(github_username) if repos.present? - stars_sum = repos.sum { |r| r[:stars].to_i } - repos_count = repos.size - user.update!(github_repos: repos.to_json, github_stars_sum: stars_sum, github_repos_count: repos_count) + # Normalize key: REST API uses :url, sync_projects! expects :github_url + repos.each { |r| r[:github_url] ||= r.delete(:url) } + self.class.sync_projects!(user, repos) end rescue => e Rails.logger.error "Failed to fetch GitHub repositories for #{github_username}: #{e.message}" diff --git a/app/services/location_normalizer.rb b/app/services/location_normalizer.rb new file mode 100644 index 0000000..52da1b3 --- /dev/null +++ b/app/services/location_normalizer.rb @@ -0,0 +1,83 @@ +# frozen_string_literal: true + +require "net/http" +require "json" + +class LocationNormalizer + PHOTON_API = "https://photon.komoot.io/api/" + + GeoResult = Data.define(:city, :state, :country_code, :latitude, :longitude) + + def self.normalize(raw_location) + new.normalize(raw_location) + end + + def normalize(raw_location) + return nil if raw_location.blank? + + # Skip strings that are clearly not geographic (pure emoji, etc.) + stripped = raw_location.gsub(/[\p{Emoji_Presentation}\p{Extended_Pictographic}]/, "").strip + return nil if stripped.empty? + + result = photon_search(raw_location) + return nil unless result + + normalized_string = build_normalized_string(result) + return nil unless normalized_string + + { + normalized_location: normalized_string, + latitude: result.latitude, + longitude: result.longitude + } + end + + private + + def photon_search(query) + uri = URI(PHOTON_API) + uri.query = URI.encode_www_form(q: query, limit: 1) + + response = Net::HTTP.get_response(uri) + return nil unless response.is_a?(Net::HTTPSuccess) + + data = JSON.parse(response.body) + feature = data.dig("features", 0) + return nil unless feature + + properties = feature["properties"] + return nil unless properties + + coordinates = feature.dig("geometry", "coordinates") + lon, lat = coordinates if coordinates.is_a?(Array) && coordinates.size >= 2 + + GeoResult.new( + city: properties["city"] || (properties["type"] == "city" ? properties["name"] : nil), + state: properties["state"], + country_code: properties["countrycode"], + latitude: lat&.to_f, + longitude: lon&.to_f + ) + rescue StandardError => e + Rails.logger.warn "Photon geocoding failed: #{e.message}" + nil + end + + def build_normalized_string(result) + city = result.city + country_code = result.country_code&.upcase + + return nil unless country_code.present? + + if city.present? + "#{city}, #{country_code}" + else + state = result.state + if state.present? + "#{state}, #{country_code}" + else + country_code + end + end + end +end diff --git a/app/services/timezone_resolver.rb b/app/services/timezone_resolver.rb new file mode 100644 index 0000000..4ae68e8 --- /dev/null +++ b/app/services/timezone_resolver.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +class TimezoneResolver + # Legacy IANA identifiers renamed in recent tzdata releases. + # The wheretz gem still returns old names that newer system + # zoneinfo packages no longer include. + LEGACY_IDENTIFIERS = { + "Europe/Kiev" => "Europe/Kyiv" + }.freeze + + def self.resolve(latitude, longitude) + new.resolve(latitude, longitude) + end + + def self.normalize(timezone) + return "Etc/UTC" if timezone.blank? + + normalized = LEGACY_IDENTIFIERS[timezone] || timezone + TZInfo::Timezone.get(normalized) + normalized + rescue TZInfo::InvalidTimezoneIdentifier + Rails.logger.warn "Unknown timezone identifier: #{timezone}, falling back to Etc/UTC" + "Etc/UTC" + end + + def resolve(latitude, longitude) + return "Etc/UTC" if latitude.nil? || longitude.nil? + + result = WhereTZ.lookup(latitude, longitude) + timezone = result.is_a?(Array) ? result.first : result + self.class.normalize(timezone) + rescue StandardError => e + Rails.logger.warn "Timezone lookup failed for (#{latitude}, #{longitude}): #{e.message}" + "Etc/UTC" + end +end diff --git a/app/views/admin_mailer/post_hidden_notification.html.erb b/app/views/admin_mailer/post_hidden_notification.html.erb new file mode 100644 index 0000000..df8d857 --- /dev/null +++ b/app/views/admin_mailer/post_hidden_notification.html.erb @@ -0,0 +1,23 @@ +<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; color: #333;"> + <h1 style="color: #CC342D; margin-bottom: 24px;">Post Auto-Hidden</h1> + + <p style="line-height: 1.6; margin-bottom: 16px;">Hi <%= @admin.name || @admin.username %>,</p> + + <p style="line-height: 1.6; margin-bottom: 16px;">A post has been automatically hidden due to receiving <%= @post.reports_count %> reports from trusted users.</p> + + <div style="background: #f5f5f5; padding: 16px; border-radius: 8px; margin: 24px 0;"> + <h3 style="margin: 0 0 8px 0; color: #333;"><%= @post.title %></h3> + <p style="margin: 0; color: #666;"> + by <%= @post.user.username %><br> + Reports: <%= @post.reports_count %> + </p> + </div> + + <p style="line-height: 1.6; margin-bottom: 16px;"> + <a href="<%= admin_root_url %>" style="display: inline-block; background: #CC342D; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px;">Review in Admin Panel</a> + </p> + + <p style="line-height: 1.6; margin-top: 32px; color: #666; font-size: 14px;"> + This is an automated notification from WhyRuby.info + </p> +</div> diff --git a/app/views/admin_mailer/post_hidden_notification.text.erb b/app/views/admin_mailer/post_hidden_notification.text.erb new file mode 100644 index 0000000..6c5c641 --- /dev/null +++ b/app/views/admin_mailer/post_hidden_notification.text.erb @@ -0,0 +1,14 @@ +Post Auto-Hidden + +Hi <%= @admin.name || @admin.username %>, + +A post has been automatically hidden due to receiving <%= @post.reports_count %> reports from trusted users. + +Post: <%= @post.title %> +Author: <%= @post.user.username %> +Reports: <%= @post.reports_count %> + +Review in Admin Panel: <%= admin_root_url %> + +-- +This is an automated notification from WhyRuby.info diff --git a/app/views/categories/show.html.erb b/app/views/categories/show.html.erb index 5143271..b80980e 100644 --- a/app/views/categories/show.html.erb +++ b/app/views/categories/show.html.erb @@ -40,59 +40,37 @@ <div class="space-y-4"> <% if @posts.any? %> <div class="hidden lg:flex gap-6"> - <% # Split posts into 3 columns for desktop, maintaining left-to-right reading order %> - <% column1_posts = [] %> - <% column2_posts = [] %> - <% column3_posts = [] %> - - <% @posts.each_with_index do |post, index| %> - <% case index % 3 %> - <% when 0 %> - <% column1_posts << post %> - <% when 1 %> - <% column2_posts << post %> - <% when 2 %> - <% column3_posts << post %> - <% end %> - <% end %> - - <% # Desktop: 3 columns %> - <% [column1_posts, column2_posts, column3_posts].each do |column_posts| %> + <% # Desktop: 3 columns - add button goes in the column where the next post would be %> + <% columns = distribute_to_columns(@posts, num_columns: 3) %> + <% add_button_column = @posts.size % 3 %> + <% columns.each_with_index do |column_posts, col_index| %> <div class="flex-1 space-y-6"> - <% column_posts.each do |post| %> - <%= render 'posts/post_tile', post: post %> + <%= render partial: 'posts/post_tile', collection: column_posts, as: :post %> + <% if col_index == add_button_column %> + <%= render 'posts/add_post_tile', success_story: @category.is_success_story?, category: @category %> <% end %> </div> <% end %> </div> - + <% # Tablet: 2 columns %> <div class="hidden md:flex lg:hidden gap-6"> - <% column1_posts = [] %> - <% column2_posts = [] %> - - <% @posts.each_with_index do |post, index| %> - <% if index % 2 == 0 %> - <% column1_posts << post %> - <% else %> - <% column2_posts << post %> - <% end %> - <% end %> - - <% [column1_posts, column2_posts].each do |column_posts| %> + <% columns = distribute_to_columns(@posts, num_columns: 2) %> + <% add_button_column = @posts.size % 2 %> + <% columns.each_with_index do |column_posts, col_index| %> <div class="flex-1 space-y-6"> - <% column_posts.each do |post| %> - <%= render 'posts/post_tile', post: post %> + <%= render partial: 'posts/post_tile', collection: column_posts, as: :post %> + <% if col_index == add_button_column %> + <%= render 'posts/add_post_tile', success_story: @category.is_success_story?, category: @category %> <% end %> </div> <% end %> </div> - + <% # Mobile: 1 column %> <div class="md:hidden space-y-6"> - <% @posts.each do |post| %> - <%= render 'posts/post_tile', post: post %> - <% end %> + <%= render partial: 'posts/post_tile', collection: @posts, as: :post %> + <%= render 'posts/add_post_tile', success_story: @category.is_success_story?, category: @category %> </div> <div class="mt-12 mb-8 text-center"> diff --git a/app/views/comments/_comment.html.erb b/app/views/comments/_comment.html.erb index 86f1e0a..d9b7a1a 100644 --- a/app/views/comments/_comment.html.erb +++ b/app/views/comments/_comment.html.erb @@ -3,10 +3,8 @@ <div class="flex items-center justify-between"> <div class="flex items-center space-x-2"> <% if comment.user.present? %> - <%= link_to user_path(comment.user), class: "flex items-center space-x-2" do %> - <% if comment.user.avatar_url.present? %> - <%= image_tag comment.user.avatar_url, alt: comment.user.display_name, class: "h-8 w-8 md:h-10 md:w-10 rounded-full" %> - <% end %> + <%= link_to community_user_url(comment.user), class: "flex items-center space-x-2" do %> + <%= render "shared/gem_avatar", user: comment.user, size: "w-9 h-[32px] md:w-11 md:h-[39px]", text_size: "text-sm", show_open_to_work: false, thick_border: true %> <span class="text-sm font-semibold text-gray-900"><%= comment.user.display_name %></span> <% end %> <span class="text-xs text-gray-500"><%= format_comment_date(comment.created_at) %></span> diff --git a/app/views/comments/_comment_tile.html.erb b/app/views/comments/_comment_tile.html.erb index c9c3d43..71062fe 100644 --- a/app/views/comments/_comment_tile.html.erb +++ b/app/views/comments/_comment_tile.html.erb @@ -1,4 +1,5 @@ -<%= link_to post_path_for(comment.post) + "#comment-#{comment.id}", class: "block" do %> +<% comment_url = Rails.env.production? ? primary_domain_post_url(comment.post) : post_path_for(comment.post) %> +<%= link_to "#{comment_url}#comment-#{comment.id}", class: "block" do %> <div class="bg-white shadow rounded-lg p-6 hover:shadow-md transition-shadow"> <div class="space-y-3"> <!-- Post Title --> @@ -14,10 +15,10 @@ <!-- Meta Information --> <div class="flex items-center justify-between text-xs text-gray-500"> <span><%= time_ago_in_words(comment.created_at) %> ago</span> - <% if comment.post.comments.published.count > 1 %> + <% if comment.post.comments_count > 1 %> <span class="flex items-center space-x-1"> <%= inline_svg_tag "comment.svg", class: "w-3 h-3" %> - <span><%= comment.post.comments.published.count %> comments</span> + <span><%= comment.post.comments_count %> comments</span> </span> <% end %> </div> diff --git a/app/views/home/_testimonial_carousel.html.erb b/app/views/home/_testimonial_carousel.html.erb new file mode 100644 index 0000000..d6e238e --- /dev/null +++ b/app/views/home/_testimonial_carousel.html.erb @@ -0,0 +1,105 @@ +<%# Testimonial carousel for home page %> +<% if testimonials.any? %> + <div class="pt-2 md:pt-6 pb-2 md:pb-4" data-controller="testimonial-carousel"> + <div class="md:grid md:grid-cols-[auto_1fr_auto] md:items-center md:gap-8" data-testimonial-carousel-target="swipeArea"> + + <%# Left arrow - hidden on mobile, shown on tablet+ %> + <% if testimonials.size > 1 %> + <button type="button" + class="hidden md:block self-center text-gray-300 hover:text-red-600 transition-colors cursor-pointer p-2" + data-action="testimonial-carousel#previous"> + <svg class="w-12 h-12" fill="none" stroke="currentColor" viewBox="0 0 24 24"> + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M15 19l-7-7 7-7"></path> + </svg> + </button> + <% else %> + <div class="hidden md:block"></div> + <% end %> + + <%# Middle column: slides %> + <div class="max-w-4xl mx-auto w-full" + data-action="mouseenter->testimonial-carousel#pause mouseleave->testimonial-carousel#resume touchstart->testimonial-carousel#pause touchend->testimonial-carousel#resume"> + <%# Slides container - this gets positioned during animation %> + <div data-testimonial-carousel-target="slidesContainer"> + <% testimonials.each_with_index do |testimonial, index| %> + <div class="<%= index == 0 ? '' : 'hidden' %>" + data-testimonial-carousel-target="slide"> + + <%# Heading area %> + <% if testimonial.heading.present? %> + <h2 class="text-4xl md:text-7xl font-serif italic text-red-600 mb-4 md:mb-6"> + <%= testimonial.heading %> + </h2> + <% end %> + + <% if testimonial.subheading.present? %> + <h3 class="text-xl md:text-3xl font-bold text-gray-900 leading-tight mb-2"> + <%= raw testimonial.subheading.split('.').map { |s| s.strip }.reject(&:blank?).map { |s| ERB::Util.html_escape(s) + "." }.join("<br>") %> + </h3> + <% end %> + + <% if testimonial.body_text.present? %> + <p class="text-gray-500 text-sm md:text-lg mt-4 md:mt-6 mb-8 md:mb-12 max-w-3xl leading-relaxed"> + <%= testimonial.body_text %> + </p> + <% end %> + + <%# Quote + User layout - stacks on mobile/tablet (below lg), side-by-side on desktop %> + <div class="flex flex-col lg:flex-row gap-4 md:gap-8 lg:items-end"> + <%# Quote block %> + <div class="flex-1 basis-0 relative"> + <span class="absolute -top-6 md:-top-8 -left-2 md:-left-4 lg:-left-10 text-5xl md:text-7xl text-red-600 font-serif leading-none select-none">“</span> + <blockquote class="relative bg-white border-2 border-red-200 rounded-lg p-4 md:p-8 pt-6 md:pt-8"> + <p class="text-gray-600 text-sm md:text-lg italic leading-relaxed"> + <%= testimonial.quote %> + </p> + <%# Speech bubble tail - pointing down on mobile/tablet, pointing right on desktop %> + <%# Mobile/tablet: pointing down from bottom-left (more to right on tablet to align with avatar) %> + <span class="lg:hidden absolute -bottom-4 left-6 md:left-16 w-0 h-0 border-l-[12px] border-l-transparent border-r-[12px] border-r-transparent border-t-[16px] border-t-red-200"></span> + <span class="lg:hidden absolute -bottom-[14px] left-[26px] md:left-[66px] w-0 h-0 border-l-[10px] border-l-transparent border-r-[10px] border-r-transparent border-t-[14px] border-t-white"></span> + <%# Desktop: pointing right toward user %> + <span class="hidden lg:block absolute -right-4 bottom-6 w-0 h-0 border-t-[12px] border-t-transparent border-b-[12px] border-b-transparent border-l-[16px] border-l-red-200"></span> + <span class="hidden lg:block absolute -right-[14px] bottom-[26px] w-0 h-0 border-t-[10px] border-t-transparent border-b-[10px] border-b-transparent border-l-[14px] border-l-white"></span> + </blockquote> + </div> + + <%# User tile - extra top margin on mobile/tablet to account for speech bubble tail %> + <div class="lg:flex-1 lg:basis-0 flex items-end mt-2 lg:mt-0"> + <%= render "users/user_tile", user: testimonial.user, frameless: true, avatar_size: "w-20 h-[72px] md:w-36 md:h-[130px]", compact: true %> + </div> + </div> + </div> + <% end %> + </div> + + <%# Mobile navigation dots - only shown on mobile, hidden on tablet+ where arrows are shown %> + <% if testimonials.size > 1 %> + <div class="flex md:hidden justify-center items-center gap-2 mt-6"> + <% testimonials.each_with_index do |_, dot_index| %> + <button type="button" + class="w-2 h-2 rounded-full transition-colors cursor-pointer" + data-testimonial-carousel-target="dot" + data-action="testimonial-carousel#goToSlide" + data-slide-index="<%= dot_index %>"> + </button> + <% end %> + </div> + <% end %> + </div> + + <%# Right arrow - hidden on mobile, shown on tablet+ %> + <% if testimonials.size > 1 %> + <button type="button" + class="hidden md:block self-center text-gray-300 hover:text-red-600 transition-colors cursor-pointer p-2" + data-action="testimonial-carousel#next"> + <svg class="w-12 h-12" fill="none" stroke="currentColor" viewBox="0 0 24 24"> + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 5l7 7-7 7"></path> + </svg> + </button> + <% else %> + <div class="hidden md:block"></div> + <% end %> + + </div> + </div> +<% end %> diff --git a/app/views/home/index.html.erb b/app/views/home/index.html.erb index 904d5ab..17d158c 100644 --- a/app/views/home/index.html.erb +++ b/app/views/home/index.html.erb @@ -1,54 +1,69 @@ <%# Set page title for home page %> <% content_for(:title) { "Discover why Ruby continues to be a beloved language for developers worldwide" } %> -<div class="space-y-16"> - <!-- Hero Section --> - <div class="max-w-4xl mx-auto flex flex-col items-center"> - <%= image_tag "heart.webp", class: "w-32 md:w-64 h-32 md:h-64" %> - <p class="text-2xl md:text-6xl text-gray-600 font-bold text-center mt-8"> - Discover why <span class="text-red-600">Ruby</span> continues to be a <span class="text-red-600">beloved language</span> for developers worldwide - </p> +<%# Use minimal nav (centered logo only, community-style styling) %> +<%= render "shared/minimal_nav", before_heart: "Why", after_heart: "Ruby?", url: root_path %> -<!-- <p class="text-xl md:text-3xl text-gray-600 font-bold text-center mt-8">--> -<!-- And share articles, tutorials, and resources with our community.--> -<!-- </p>--> - </div> +<div> + <%# Testimonial Carousel %> + <%= render "home/testimonial_carousel", testimonials: @testimonials %> - <!-- Pinned Posts --> - <% if @pinned_posts.any? %> - <div class="space-y-4"> -<!-- <h2 class="text-2xl font-bold text-gray-900">Featured Posts</h2>--> - <div class="hidden lg:flex gap-6"> - <% # Split pinned posts into 3 columns for desktop, maintaining left-to-right order %> - <% pinned_cols = [[], [], []] %> - <% @pinned_posts.each_with_index do |post, index| %> - <% pinned_cols[index % 3] << post %> + <%# Navigation buttons + Sign in - same style as main nav %> + <%# Sticky on tablet+ (min-width: 640px AND min-height: 500px) but not horizontal phones %> + <div data-controller="sticky-nav"> + <div class="mt-16 mb-16" data-sticky-nav-target="nav"> + <%# Desktop/Tablet: show all buttons %> + <div class="hidden [@media(min-width:640px)_and_(min-height:500px)]:flex flex-wrap justify-center items-center gap-2"> + <% Category.with_posts.ordered.each do |category| %> + <% next if category.is_success_story? && !has_success_stories? %> + <%= link_to category.name, category_path(category), + class: "text-gray-600 bg-gray-100 hover:bg-gray-200 px-3 py-2 rounded-md text-sm font-medium whitespace-nowrap" %> <% end %> - - <% pinned_cols.each do |column_posts| %> - <div class="flex-1 space-y-6"> - <% column_posts.each do |post| %> - <%= render 'posts/post_tile', post: post %> - <% end %> - </div> + <%= link_to "Community", community_index_url, + class: "text-gray-600 bg-gray-100 hover:bg-gray-200 px-3 py-2 rounded-md text-sm font-medium whitespace-nowrap" %> + <% if should_show_mobile_cta? %> + <%= render "shared/github_signin_button" %> <% end %> </div> - - <% # Mobile view for pinned posts - simple grid %> - <div class="lg:hidden grid grid-cols-1 md:grid-cols-2 gap-6"> - <% @pinned_posts.each do |post| %> - <%= render 'posts/post_tile', post: post %> + + <%# Mobile: hamburger menu + sign in button %> + <div class="[@media(min-width:640px)_and_(min-height:500px)]:hidden flex justify-center items-center gap-3"> + <div class="relative" data-controller="dropdown" data-action="click@window->dropdown#hide"> + <button type="button" + class="text-gray-600 bg-gray-100 hover:bg-gray-200 p-2 rounded-md cursor-pointer" + data-action="click->dropdown#toggle" + aria-label="Open navigation menu"> + <svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"> + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"></path> + </svg> + </button> + + <div class="hidden absolute left-0 mt-2 w-48 rounded-md bg-white shadow-lg ring-1 ring-black/5 z-50" data-dropdown-target="menu"> + <div class="py-1"> + <% Category.with_posts.ordered.each do |category| %> + <% next if category.is_success_story? && !has_success_stories? %> + <%= link_to category.name, category_path(category), + class: "block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100" %> + <% end %> + <%= link_to "Community", community_index_url, + class: "block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100" %> + </div> + </div> + </div> + + <% if should_show_mobile_cta? %> + <%= render "shared/github_signin_button" %> <% end %> </div> </div> - <% end %> + </div> </div> <!-- Success Stories Floating Logos (Full Width) --> -<% if @success_stories.count >= 5 %> - <div class="py-8 md:py-16 bg-gray-50 my-8 md:my-16"> - <h2 class="text-center text-sm font-semibold text-gray-600 tracking-wide uppercase mb-6 md:mb-12"> - Success powered by Ruby +<% if @success_stories.size >= 5 %> + <div class="py-8 md:py-16 bg-gray-50 my-4 md:my-8"> + <h2 class="text-center text-sm font-semibold text-gray-300 tracking-wide uppercase mb-6 md:mb-12"> + Read <%= link_to "success stories", category_path(Category.success_story_category), class: "text-gray-500 hover:text-red-600 transition-colors" %> from </h2> <div class="relative logo-carousel-container" data-controller="logo-carousel"> <div class="overflow-hidden relative"> @@ -109,61 +124,28 @@ <!-- Recent Posts --> <div class="space-y-4"> - <h2 class="text-center text-sm font-semibold text-gray-600 tracking-wide uppercase my-8 md:my-16">Recent <span>♥️</span> shared</h2> + <h2 class="text-center text-sm font-semibold text-gray-300 tracking-wide uppercase my-8 md:my-16">Recent <span>♥️</span> shared</h2> <div class="hidden lg:flex gap-6"> - <% # Split posts into 3 columns for desktop, maintaining left-to-right reading order %> - <% column1_posts = [] %> - <% column2_posts = [] %> - <% column3_posts = [] %> - - <% @posts.each_with_index do |post, index| %> - <% case index % 3 %> - <% when 0 %> - <% column1_posts << post %> - <% when 1 %> - <% column2_posts << post %> - <% when 2 %> - <% column3_posts << post %> - <% end %> - <% end %> - <% # Desktop: 3 columns %> - <% [column1_posts, column2_posts, column3_posts].each do |column_posts| %> + <% distribute_to_columns(@posts, num_columns: 3).each do |column_posts| %> <div class="flex-1 space-y-6"> - <% column_posts.each do |post| %> - <%= render 'posts/post_tile', post: post %> - <% end %> + <%= render partial: 'posts/post_tile', collection: column_posts, as: :post %> </div> <% end %> </div> - + <% # Tablet: 2 columns %> <div class="hidden md:flex lg:hidden gap-6"> - <% column1_posts = [] %> - <% column2_posts = [] %> - - <% @posts.each_with_index do |post, index| %> - <% if index % 2 == 0 %> - <% column1_posts << post %> - <% else %> - <% column2_posts << post %> - <% end %> - <% end %> - - <% [column1_posts, column2_posts].each do |column_posts| %> + <% distribute_to_columns(@posts, num_columns: 2).each do |column_posts| %> <div class="flex-1 space-y-6"> - <% column_posts.each do |post| %> - <%= render 'posts/post_tile', post: post %> - <% end %> + <%= render partial: 'posts/post_tile', collection: column_posts, as: :post %> </div> <% end %> </div> <% # Mobile: 1 column %> <div class="md:hidden space-y-6"> - <% @posts.each do |post| %> - <%= render 'posts/post_tile', post: post %> - <% end %> + <%= render partial: 'posts/post_tile', collection: @posts, as: :post %> </div> <div class="mt-12 mb-8 text-center"> diff --git a/app/views/kaminari/_first_page.html.erb b/app/views/kaminari/_first_page.html.erb new file mode 100644 index 0000000..e1e633e --- /dev/null +++ b/app/views/kaminari/_first_page.html.erb @@ -0,0 +1 @@ +<%= link_to_unless current_page.first?, raw(t 'views.pagination.first'), url, class: "px-3 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors", rel: "first" %> diff --git a/app/views/kaminari/_gap.html.erb b/app/views/kaminari/_gap.html.erb new file mode 100644 index 0000000..a4191f3 --- /dev/null +++ b/app/views/kaminari/_gap.html.erb @@ -0,0 +1,3 @@ +<span class="px-2 py-2 text-sm text-gray-500"> + <%= t 'views.pagination.truncate' %> +</span> diff --git a/app/views/kaminari/_last_page.html.erb b/app/views/kaminari/_last_page.html.erb new file mode 100644 index 0000000..d197ce1 --- /dev/null +++ b/app/views/kaminari/_last_page.html.erb @@ -0,0 +1 @@ +<%= link_to_unless current_page.last?, raw(t 'views.pagination.last'), url, class: "px-3 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors", rel: "last" %> diff --git a/app/views/kaminari/_next_page.html.erb b/app/views/kaminari/_next_page.html.erb new file mode 100644 index 0000000..78c33be --- /dev/null +++ b/app/views/kaminari/_next_page.html.erb @@ -0,0 +1 @@ +<%= link_to_unless current_page.last?, raw(t 'views.pagination.next'), url, class: "px-3 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors", rel: "next" %> diff --git a/app/views/kaminari/_page.html.erb b/app/views/kaminari/_page.html.erb new file mode 100644 index 0000000..c4a64a8 --- /dev/null +++ b/app/views/kaminari/_page.html.erb @@ -0,0 +1,7 @@ +<% if page.current? %> + <span class="px-3 py-2 text-sm font-medium text-white bg-red-600 rounded-lg" aria-current="page"> + <%= page %> + </span> +<% else %> + <%= link_to page, url, class: "px-3 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors", rel: page.rel %> +<% end %> diff --git a/app/views/kaminari/_paginator.html.erb b/app/views/kaminari/_paginator.html.erb new file mode 100644 index 0000000..c1937ab --- /dev/null +++ b/app/views/kaminari/_paginator.html.erb @@ -0,0 +1,15 @@ +<%= paginator.render do -%> + <nav class="flex items-center justify-center gap-1" role="navigation" aria-label="Pagination"> + <%= first_page_tag unless current_page.first? %> + <%= prev_page_tag unless current_page.first? %> + <% each_page do |page| -%> + <% if page.left_outer? || page.right_outer? || page.inside_window? -%> + <%= page_tag page %> + <% elsif !page.was_truncated? -%> + <%= gap_tag %> + <% end -%> + <% end -%> + <%= next_page_tag unless current_page.last? %> + <%= last_page_tag unless current_page.last? %> + </nav> +<% end -%> diff --git a/app/views/kaminari/_prev_page.html.erb b/app/views/kaminari/_prev_page.html.erb new file mode 100644 index 0000000..5348bbe --- /dev/null +++ b/app/views/kaminari/_prev_page.html.erb @@ -0,0 +1 @@ +<%= link_to_unless current_page.first?, raw(t 'views.pagination.previous'), url, class: "px-3 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors", rel: "prev" %> diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index 0326eff..748331d 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -6,8 +6,12 @@ <meta name="viewport" content="width=device-width,initial-scale=1"> <!-- Page Title --> - <% page_title = yield(:title).presence %> - <title><%= page_title ? "Why Ruby? — #{page_title}" : "Why Ruby?" %> + <% if content_for?(:full_title) %> + <%= yield(:full_title) %> + <% else %> + <% page_title = yield(:title).presence %> + <%= page_title ? "Why Ruby? — #{page_title}" : "Why Ruby?" %> + <% end %> <%= csrf_meta_tags %> @@ -45,9 +49,14 @@ - + <% if content_for?(:full_title) %> + + + <% else %> + + + <% end %> - @@ -61,95 +70,90 @@ - - - - - - - + + + <%= stylesheet_link_tag :app, "data-turbo-track": "reload" %> <%= javascript_importmap_tags %> - - - - - + + <% country_code = client_country_code %> + -