Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -65,3 +65,6 @@ vendor/bundle/

# Git worktrees
worktrees/

# Graphify knowledge-graph output (generate locally with `/graphify app`)
graphify-out/
34 changes: 32 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,17 @@
# AI Agent Instructions for Exercism Website
# CLAUDE.md

This file provides specific guidance for AI agents working on the Exercism website codebase.
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

This file provides specific guidance for AI agents working on the Exercism website codebase. (`CLAUDE.md` is a symlink to this `AGENTS.md`.)

## Big Picture

Exercism Website is a Ruby on Rails app (Ruby 3.4.4) with a React/TypeScript frontend. The two halves meet through server-rendered HAML views that mount React components and pass props as JSON:

- **Backend**: Rails controllers stay thin and delegate business logic to Mandate **commands** in `app/commands/`. Data leaving the app is shaped by **serializers** (`docs/context/serializers.md`) and **assemblers** (`docs/context/assemblers.md`) so API and SSR responses stay consistent.
- **View layer**: HAML templates in `app/views/` render **ViewComponents** (server-side, encapsulated logic — `docs/context/view-components.md`) and mount **React components** (`app/javascript/components/`, `docs/context/react-components.md`).
- **Frontend**: React/TypeScript in `app/javascript/`, styled with PostCSS + Tailwind (`app/css/`, `tailwind.config.js`), built with esbuild (`app/javascript/esbuild.js`).
- **Routing**: three surfaces — standard user-facing routes, `/api` (Bearer-auth public/CLI/frontend endpoints, `config/routes/api.rb`), and `/spi` (internal AWS Lambda callbacks, no app-level auth, `config/routes/spi.rb`).

## Complete Documentation

Expand Down Expand Up @@ -41,6 +52,17 @@ bundle exec rails test test/system # System tests (15-20 min)
yarn test # JavaScript tests (2-3 min)
```

**Running a single test (fast feedback loop — prefer this over the full suite while iterating):**

```bash
bin/rails test test/commands/user/update_test.rb # one file
bin/rails test test/commands/user/update_test.rb:42 # one test by line number
yarn test path/to/Component.test.tsx # one JS test file (jest)
yarn test -t "renders the label" # JS tests matching a name
```

You do NOT need to run lint or rubocop on files before committing. These will AUTOMATICALLY happen via a git hook. Running before this just wastes time.

**Asset builds:**

```bash
Expand Down Expand Up @@ -71,6 +93,14 @@ rm -rf .built-assets/ # Clear asset cache if needed
- Always use Bearer token authentication for API
- Delegate business logic to commands, keep controllers thin

**Internationalization (i18n) — active work:**

Hardcoded UI strings are being extracted into i18n. Two parallel systems:

- **Rails/HAML**: keys live in `config/locales/` (organized by area, e.g. `pages/`, `views/`). Reference with the standard `t('...')` / `I18n.t`.
- **React/TypeScript**: keys live in `app/javascript/i18n/en/`, one file **per component**, named after the component's path (e.g. `components-common-Loading.tsx.ts`). Each file `export default`s a nested object; the top comment records the namespace. In components, use `useAppTranslation('<namespace>')` from `@/i18n/useAppTranslation` and call `t('key.path')`. `app/javascript/i18n/generateIndexFile.ts` regenerates the aggregated `app/javascript/i18n/en/index.ts` from the per-component files.
- Some areas are intentionally **excluded** from extraction (bootcamp, courses, admin, hiring, campaigns, dead mailers). Confirm scope before extracting strings in an unfamiliar area.

## Git Usage

- **Do not use `git -C <path>`**. Instead, `cd` to the correct directory before running git commands. This avoids issues with worktrees and ensures hooks run in the right context.
Expand Down
8 changes: 5 additions & 3 deletions app/controllers/images_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,11 @@ def profile
@top_three_tracks = ::Track.active.where(id: track_ids).sort_by { |t| track_ids.index(t.id) }

@header_tags = []
@header_tags << { class: "tag staff", icon: :logo, title: "Exercism Staff" } if @user.staff?
@header_tags << { class: "tag maintainer", icon: :maintaining, title: "Maintainer" } if @user.maintainer?
@header_tags << { class: "tag insider", icon: :insiders, title: "Insider" } if @user.insider?
@header_tags << { class: "tag staff", icon: :logo, title: I18n.t("profiles.header.tags.staff") } if @user.staff?
if @user.maintainer?
@header_tags << { class: "tag maintainer", icon: :maintaining, title: I18n.t("profiles.header.tags.maintainer") }
end
@header_tags << { class: "tag insider", icon: :insiders, title: I18n.t("profiles.header.tags.insider") } if @user.insider?
@header_tags = @header_tags.take(2)
end

Expand Down
18 changes: 10 additions & 8 deletions app/helpers/react_components/impact/chart.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,20 @@ def to_s
"impact-chart",
{
users_per_month: USERS_PER_MONTH,
milestones: MILESTONES
milestones: milestones
}
)
end

MILESTONES = [
{ date: '202207', text: 'Reached 1M users!', emoji: '🤩' },
{ date: '202109', text: 'Exercism v3', emoji: '3️⃣' },
{ date: '202006', text: 'Automated feedback!', emoji: '🤖' },
{ date: '201807', text: 'Exercism v2', emoji: '2️⃣' },
{ date: '201312', text: 'Exercism launched', emoji: '🚀' }
].to_json
def milestones
[
{ date: '202207', text: I18n.t("components.impact.chart.milestones.reached_1m_users"), emoji: '🤩' },
{ date: '202109', text: I18n.t("components.impact.chart.milestones.exercism_v3"), emoji: '3️⃣' },
{ date: '202006', text: I18n.t("components.impact.chart.milestones.automated_feedback"), emoji: '🤖' },
{ date: '201807', text: I18n.t("components.impact.chart.milestones.exercism_v2"), emoji: '2️⃣' },
{ date: '201312', text: I18n.t("components.impact.chart.milestones.exercism_launched"), emoji: '🚀' }
].to_json
end

USERS_PER_MONTH = {
'201305': 0,
Expand Down
19 changes: 10 additions & 9 deletions app/helpers/react_components/mentoring/inbox.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,26 +9,27 @@ def to_s
{
discussions_request:,
tracks_request:,
sort_options: SORT_OPTIONS,
sort_options:,
links: {
queue: Exercism::Routes.mentoring_queue_path
}
}
)
end

SORT_OPTIONS = [
{ value: :recent, label: 'Sort by recent first' },
{ value: :oldest, label: 'Sort by oldest first' },
{ value: :exercise, label: 'Sort by exercise' },
{ value: :student, label: 'Sort by student' }
].freeze
private_constant :SORT_OPTIONS

DEFAULT_STATUS = "awaiting_mentor".freeze
private_constant :DEFAULT_STATUS

private
def sort_options
[
{ value: :recent, label: I18n.t("components.mentoring.inbox.sort_options.recent_first") },
{ value: :oldest, label: I18n.t("components.mentoring.inbox.sort_options.oldest_first") },
{ value: :exercise, label: I18n.t("components.mentoring.inbox.sort_options.exercise") },
{ value: :student, label: I18n.t("components.mentoring.inbox.sort_options.student") }
]
end

def discussions_request
{
endpoint: Exercism::Routes.api_mentoring_discussions_path(sideload: [:all_discussion_counts]),
Expand Down
15 changes: 8 additions & 7 deletions app/helpers/react_components/mentoring/queue.rb
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ def to_s
tracks_request:,
default_track:,
default_exercise:,
sort_options: SORT_OPTIONS,
sort_options:,
links: {
tracks: Exercism::Routes.api_mentoring_tracks_url,
update_tracks: Exercism::Routes.api_mentoring_tracks_url
Expand All @@ -27,15 +27,16 @@ def to_s
)
end

SORT_OPTIONS = [
{ value: "", label: "Sort by oldest first" },
{ value: "recent", label: "Sort by recent first" }
].freeze
private_constant :SORT_OPTIONS

private
attr_reader :mentor, :params

def sort_options
[
{ value: "", label: I18n.t("components.mentoring.queue.sort_options.oldest_first") },
{ value: "recent", label: I18n.t("components.mentoring.queue.sort_options.recent_first") }
]
end

def tracks_request
{
endpoint: Exercism::Routes.mentored_api_mentoring_tracks_url,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ def to_s
tracks: context[:with_feedback][:tracks],
counts: context.transform_values { |v| v[:representation_count] }.to_h,
links:,
sort_options: SORT_OPTIONS,
sort_options:,
is_introducer_hidden:
}
)
Expand Down Expand Up @@ -56,12 +56,15 @@ def links

def is_introducer_hidden = mentor.introducer_dismissed?(INTRODUCER_SLUG)

SORT_OPTIONS = [
{ value: :most_submissions, label: 'Sort by highest occurence' },
{ value: :most_recent, label: 'Sort by recent first' }
].freeze
def sort_options
[
{ value: :most_submissions, label: I18n.t("components.mentoring.representations.sort_options.highest_occurrence") },
{ value: :most_recent, label: I18n.t("components.mentoring.representations.sort_options.recent_first") }
]
end

INTRODUCER_SLUG = 'feedback_automation'.freeze
private_constant :SORT_OPTIONS, :INTRODUCER_SLUG
private_constant :INTRODUCER_SLUG
end
end
end
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ def to_s
tracks: context[:without_feedback][:tracks],
counts: context.transform_values { |v| v[:representation_count] }.to_h,
links:,
sort_options: SORT_OPTIONS,
sort_options:,
is_introducer_hidden:
}
)
Expand Down Expand Up @@ -59,12 +59,15 @@ def links

def is_introducer_hidden = mentor.introducer_dismissed?(INTRODUCER_SLUG)

SORT_OPTIONS = [
{ value: :most_submissions, label: 'Sort by highest occurence' },
{ value: :most_recent, label: 'Sort by recent first' }
].freeze
def sort_options
[
{ value: :most_submissions, label: I18n.t("components.mentoring.representations.sort_options.highest_occurrence") },
{ value: :most_recent, label: I18n.t("components.mentoring.representations.sort_options.recent_first") }
]
end

INTRODUCER_SLUG = 'feedback_automation'.freeze
private_constant :SORT_OPTIONS, :INTRODUCER_SLUG
private_constant :INTRODUCER_SLUG
end
end
end
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ module ReactComponents
module Mentoring
class TryMentoringButton < ReactComponent
def initialize(
text: "Try mentoring now",
text: I18n.t("components.mentoring.try_mentoring_button.default_text"),
size: 'm',
redirect_link: Exercism::Routes.mentoring_queue_url
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ def videos
{
url: Exercism::Routes.doc_path(:using, "feedback/guide-to-being-mentored"),
thumb: "https://exercism-static.s3.eu-west-1.amazonaws.com/blog/tutorial-making-the-most-of-being-mentored.png",
title: "Making the most of being mentored",
title: I18n.t("components.student.mentoring_session.videos.making_the_most_title"),
date: Date.new(2021, 9, 1).iso8601
}
]
Expand Down
10 changes: 5 additions & 5 deletions app/helpers/view_components/about_nav.rb
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,12 @@ def lhs
tag.ul(data: { scrollable_container: true }) do
safe_join(
[
li_link("About Exercism"),
li_link("Our Impact", :impact),
li_link("Team", :team),
li_link(I18n.t("components.about_nav.about_exercism")),
li_link(I18n.t("components.about_nav.our_impact"), :impact),
li_link(I18n.t("components.about_nav.team"), :team),
# li_link("Jobs", :hiring),
li_link("Supporters", :individual_supporters),
tag.li(link_to("Partners", Exercism::Routes.about_partners_path),
li_link(I18n.t("components.about_nav.supporters"), :individual_supporters),
tag.li(link_to(I18n.t("components.about_nav.partners"), Exercism::Routes.about_partners_path),
class: selected_section == :organisation_supporters ? "selected" : nil, data: scroll_into_view(:organisation_supporters))
# li_link("Community", :community),
# li_link("Not-for-profit", :organisation),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,10 @@ def to_s
hours, minutes = video.length_in_minutes.divmod(60)

parts = [
hours.positive? ? format("%02d#{'HR'.pluralize(hours).upcase}", hours) : nil,
minutes.positive? ? format("%02d#{'MIN'.pluralize(minutes).upcase}", minutes) : nil
hours.positive? ? format("%02d#{I18n.t("components.community.stories.video_length.hours", count: hours)}", hours) : nil,
minutes.positive? ? format("%02d#{I18n.t("components.community.stories.video_length.minutes", count: minutes)}", minutes) : nil
].compact

"LENGTH #{parts.join(' ')}"
"#{I18n.t("components.community.stories.video_length.label")} #{parts.join(' ')}"
end
end
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@ class ShareSolutionButton < ViewComponent
def to_s
ReactComponents::Common::ShareButton.new(
{
title: "Share #{solution.user.handle}'s solution",
share_title: "View this solution on Exercism",
title: I18n.t("components.community_solutions.share_solution_button.title", handle: solution.user.handle),
share_title: I18n.t("components.community_solutions.share_solution_button.share_title"),
share_link: Exercism::Routes.published_solution_url(solution)
}
).to_s
Expand Down
16 changes: 8 additions & 8 deletions app/helpers/view_components/contributing/header.rb
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,13 @@ def top
tag.div(class: 'lg-container top-container') do
tag.div(class: 'content') do
graphical_icon("contributing-header") +
tag.h1("Let’s build the best coding education platform, together") +
tag.h1(I18n.t("components.contributing.header.title")) +
tag.p do
safe_join(
[
"Exercism is an ",
link_to("open source, not-for-profit project", Exercism::Routes.about_path),
" built by people from all backgrounds. With over one hundred dedicated maintainers and thousands of contributors, our goal is to create the best, free, code learning platform on the web." # rubocop:disable Layout/LineLength
I18n.t("components.contributing.header.intro_before_link"),
link_to(I18n.t("components.contributing.header.intro_link"), Exercism::Routes.about_path),
I18n.t("components.contributing.header.intro_after_link")
]
)
end
Expand All @@ -37,7 +37,7 @@ def nav
tag.div(class: 'lg-container nav-container', data: { scrollable_container: true }) do
tag.div(safe_join(tabs), class: 'tabs') +
link_to(Exercism::Routes.docs_section_path(:building), class: "c-tab-2 guides") do
graphical_icon(:guides) + tag.span("Contributing Help")
graphical_icon(:guides) + tag.span(I18n.t("components.contributing.header.contributing_help"))
end
end
end
Expand All @@ -50,7 +50,7 @@ def tabs
data: scroll_into_view(:dashboard)
) do
graphical_icon(:overview) +
tag.span("Getting Started")
tag.span(I18n.t("components.contributing.header.getting_started"))
end,

link_to(
Expand All @@ -59,7 +59,7 @@ def tabs
data: scroll_into_view(:tasks)
) do
graphical_icon(:tasks) +
tag.span("Explore tasks") +
tag.span(I18n.t("components.contributing.header.explore_tasks")) +
tag.span(number_with_delimiter(tasks_size), class: 'count')
end,

Expand All @@ -69,7 +69,7 @@ def tabs
data: scroll_into_view(:contributors)
) do
graphical_icon(:contributors) +
tag.span("Contributors") +
tag.span(I18n.t("components.contributing.header.contributors")) +
tag.span(number_with_delimiter(contributors_size), class: 'count')
end

Expand Down
27 changes: 15 additions & 12 deletions app/helpers/view_components/docs_main_nav.rb
Original file line number Diff line number Diff line change
Expand Up @@ -19,25 +19,28 @@ def lhs
tag.ul(class: 'scroll-x-hidden', data: { scrollable_container: true }) do
safe_join(
[
tag.li(data: { scroll_into_view: (!selected_section ? ScrollAxis::X : nil) },
class: !selected_section ? "selected" : nil) do
link_to Exercism::Routes.docs_url do
icon :home, "Docs home"
end
end,

li_link("Using Exercism", :using),
li_link("Building Exercism", :building),
li_link("Mentoring", :mentoring),
li_link("Community", :community),
home_li,
li_link(I18n.t("components.docs_main_nav.using"), :using),
li_link(I18n.t("components.docs_main_nav.building"), :building),
li_link(I18n.t("components.docs_main_nav.mentoring"), :mentoring),
li_link(I18n.t("components.docs_main_nav.community"), :community),
# li_link("Not-for-profit", :organisation),
li_link("Track-specific", :tracks)
li_link(I18n.t("components.docs_main_nav.tracks"), :tracks)
]
)
end
end
end

def home_li
tag.li(data: { scroll_into_view: (!selected_section ? ScrollAxis::X : nil) },
class: !selected_section ? "selected" : nil) do
link_to Exercism::Routes.docs_url do
icon :home, I18n.t("components.docs_main_nav.home")
end
end
end

def rhs
return nil

Expand Down
2 changes: 1 addition & 1 deletion app/helpers/view_components/docs_track_list.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ def to_s
[
track_icon(track),
tag.div(track.title, class: 'title'),
tag.div(pluralize(doc_counts[track.id], "doc"), class: 'count')
tag.div(I18n.t("components.docs_track_list.doc_count", count: doc_counts[track.id]), class: 'count')
]
)
end
Expand Down
Loading
Loading