From 2a5a933ed1a56611f138f291a69df252ced394d2 Mon Sep 17 00:00:00 2001 From: Alex Date: Thu, 3 Sep 2020 17:11:07 -0400 Subject: [PATCH] Add routes for i18n (#10193) * Add routes * Update all_routes spec * Add i18n route specs * Add docs * Docs cleanup * Add note about fragment caching --- config/routes.rb | 931 ++++++++++++++------------- docs/backend/internationalization.md | 148 +++++ docs/backend/readme.md | 1 + spec/routing/all_routes_spec.rb | 3 + spec/routing/i18n_routes_spec.rb | 34 + 5 files changed, 655 insertions(+), 462 deletions(-) create mode 100644 docs/backend/internationalization.md create mode 100644 spec/routing/i18n_routes_spec.rb diff --git a/config/routes.rb b/config/routes.rb index a4bbbaa35..320b090d1 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -5,6 +5,8 @@ Rails.application.routes.draw do controllers tokens: "oauth/tokens" end + # Devise does not support scoping omniauth callbacks under a dynamic segment + # so this lives outside our i18n scope. devise_for :users, controllers: { omniauth_callbacks: "omniauth_callbacks", registrations: "registrations", @@ -18,497 +20,502 @@ Rails.application.routes.draw do delete "/sign_out", to: "devise/sessions#destroy" end - require "sidekiq/web" - require "sidekiq_unique_jobs/web" - require "sidekiq/cron/web" + # [@forem/delightful] - all routes are nested under this optional scope to + # begin supporting i18n. + scope "(/locale/:locale)", defaults: { locale: nil } do + get "/locale/:locale" => "stories#index" + require "sidekiq/web" + require "sidekiq_unique_jobs/web" + require "sidekiq/cron/web" - authenticated :user, ->(user) { user.tech_admin? } do - Sidekiq::Web.set :session_secret, Rails.application.secrets[:secret_key_base] - Sidekiq::Web.set :sessions, Rails.application.config.session_options - Sidekiq::Web.class_eval do - use Rack::Protection, origin_whitelist: [URL.url] # resolve Rack Protection HttpOrigin - end - mount Sidekiq::Web => "/sidekiq" - mount FieldTest::Engine, at: "abtests" - end - - namespace :resource_admin do - # Check administrate gem docs - DashboardManifest::DASHBOARDS.each do |dashboard_resource| - resources dashboard_resource + authenticated :user, ->(user) { user.tech_admin? } do + Sidekiq::Web.set :session_secret, Rails.application.secrets[:secret_key_base] + Sidekiq::Web.set :sessions, Rails.application.config.session_options + Sidekiq::Web.class_eval do + use Rack::Protection, origin_whitelist: [URL.url] # resolve Rack Protection HttpOrigin + end + mount Sidekiq::Web => "/sidekiq" + mount FieldTest::Engine, at: "abtests" end - root controller: DashboardManifest::ROOT_DASHBOARD, action: :index - end + namespace :resource_admin do + # Check administrate gem docs + DashboardManifest::DASHBOARDS.each do |dashboard_resource| + resources dashboard_resource + end - namespace :admin do - get "/", to: redirect("/admin/articles") - - authenticate :user, ->(user) { user.has_role?(:tech_admin) } do - mount Blazer::Engine, at: "blazer" - - flipper_ui = Flipper::UI.app(Flipper, - { rack_protection: { except: %i[authenticity_token form_token json_csrf - remote_token http_origin session_hijacking] } }) - mount flipper_ui, at: "feature_flags" + root controller: DashboardManifest::ROOT_DASHBOARD, action: :index end - resources :articles, only: %i[index show update] - resources :broadcasts - resources :buffer_updates, only: %i[create update] - resources :listings, only: %i[index edit update destroy] - resources :comments, only: [:index] - resources :events, only: %i[index create update] - resources :feedback_messages, only: %i[index show] - resources :invitations, only: %i[index new create] - resources :pages, only: %i[index new create edit update destroy] - resources :mods, only: %i[index update] - resources :moderator_actions, only: %i[index] - resources :privileged_reactions, only: %i[index] - resources :permissions, only: %i[index] - resources :podcasts, only: %i[index edit update destroy] do - member do - post :fetch - post :add_admin - delete :remove_admin + namespace :admin do + get "/", to: redirect("/admin/articles") + + authenticate :user, ->(user) { user.has_role?(:tech_admin) } do + mount Blazer::Engine, at: "blazer" + + flipper_ui = Flipper::UI.app(Flipper, + { rack_protection: { except: %i[authenticity_token form_token json_csrf + remote_token http_origin session_hijacking] } }) + mount flipper_ui, at: "feature_flags" + end + + resources :articles, only: %i[index show update] + resources :broadcasts + resources :buffer_updates, only: %i[create update] + resources :listings, only: %i[index edit update destroy] + resources :comments, only: [:index] + resources :events, only: %i[index create update] + resources :feedback_messages, only: %i[index show] + resources :invitations, only: %i[index new create] + resources :pages, only: %i[index new create edit update destroy] + resources :mods, only: %i[index update] + resources :moderator_actions, only: %i[index] + resources :privileged_reactions, only: %i[index] + resources :permissions, only: %i[index] + resources :podcasts, only: %i[index edit update destroy] do + member do + post :fetch + post :add_admin + delete :remove_admin + end + end + + resources :profile_field_groups, only: %i[update create destroy] + resources :profile_fields, only: %i[index update create destroy] + resources :reactions, only: [:update] + resources :response_templates, only: %i[index new edit create update destroy] + resources :chat_channels, only: %i[index create update destroy] do + member do + delete :remove_user + end + end + resources :reports, only: %i[index show], controller: "feedback_messages" do + collection do + post "send_email" + post "create_note" + post "save_status" + end + end + resources :tags, only: %i[index update show] + resources :users, only: %i[index show edit update] do + member do + post "banish" + post "full_delete" + patch "user_status" + post "merge" + delete "remove_identity" + post "recover_identity" + post "send_email" + post "verify_email_ownership" + patch "unlock_access" + end + end + resources :organization_memberships, only: %i[update destroy create] + resources :organizations, only: %i[index show] do + member do + patch "update_org_credits" + end + end + resources :sponsorships, only: %i[index edit update new create destroy] + resources :welcome, only: %i[index create] + resources :growth, only: %i[index] + resources :tools, only: %i[index create] do + collection do + post "bust_cache" + end + end + resources :webhook_endpoints, only: :index + resource :config + resources :badges, only: %i[index edit update new create] + resources :display_ads, only: %i[index edit update new create destroy] + # These redirects serve as a safegaurd to prevent 404s for any Admins + # who have the old badge_achievement URLs bookmarked. + get "/badges/badge_achievements", to: redirect("/admin/badge_achievements") + get "/badges/badge_achievements/award_badges", to: redirect("/admin/badge_achievements/award_badges") + resources :badge_achievements, only: %i[index destroy] + get "/badge_achievements/award_badges", to: "badge_achievements#award" + post "/badge_achievements/award_badges", to: "badge_achievements#award_badges" + resources :secrets, only: %i[index] + put "secrets", to: "secrets#update" + end + + namespace :stories, defaults: { format: "json" } do + resource :feed, only: [:show] do + get ":timeframe" => "feeds#show" end end - resources :profile_field_groups, only: %i[update create destroy] - resources :profile_fields, only: %i[index update create destroy] - resources :reactions, only: [:update] - resources :response_templates, only: %i[index new edit create update destroy] - resources :chat_channels, only: %i[index create update destroy] do - member do - delete :remove_user + namespace :api, defaults: { format: "json" } do + scope module: :v0, + constraints: ApiConstraints.new(version: 0, default: true) do + resources :articles, only: %i[index show create update] do + collection do + get "me(/:status)", to: "articles#me", as: :me, constraints: { status: /published|unpublished|all/ } + get "/:username/:slug", to: "articles#show_by_slug", as: :slug + end + end + resources :comments, only: %i[index show] + resources :videos, only: [:index] + resources :podcast_episodes, only: [:index] + resources :users, only: %i[show] do + collection do + get :me + end + end + resources :tags, only: [:index] + resources :follows, only: [:create] + namespace :followers do + get :users + get :organizations + end + resources :webhooks, only: %i[index create show destroy] + + resources :listings, only: %i[index show create update] + get "/listings/category/:category", to: "listings#index", as: :listings_category + get "/analytics/totals", to: "analytics#totals" + get "/analytics/historical", to: "analytics#historical" + get "/analytics/past_day", to: "analytics#past_day" + get "/analytics/referrers", to: "analytics#referrers" + + resources :health_checks, only: [] do + collection do + get :app + get :search + get :database + get :cache + end + end end end - resources :reports, only: %i[index show], controller: "feedback_messages" do + + namespace :notifications do + resources :counts, only: [:index] + resources :reads, only: [:create] + end + + namespace :incoming_webhooks do + get "/mailchimp/:secret/unsubscribe", to: "mailchimp_unsubscribes#index", as: :mailchimp_unsubscribe_check + post "/mailchimp/:secret/unsubscribe", to: "mailchimp_unsubscribes#create", as: :mailchimp_unsubscribe + end + + resources :messages, only: [:create] + resources :chat_channels, only: %i[index show create update] + resources :chat_channel_memberships, only: %i[index create edit update destroy] + resources :articles, only: %i[update create destroy] do + patch "/admin_unpublish", to: "articles#admin_unpublish" + end + resources :article_mutes, only: %i[update] + resources :comments, only: %i[create update destroy] do + patch "/hide", to: "comments#hide" + patch "/unhide", to: "comments#unhide" + patch "/admin_delete", to: "comments#admin_delete" collection do - post "send_email" - post "create_note" - post "save_status" + post "/moderator_create", to: "comments#moderator_create" end end - resources :tags, only: %i[index update show] - resources :users, only: %i[index show edit update] do - member do - post "banish" - post "full_delete" - patch "user_status" - post "merge" - delete "remove_identity" - post "recover_identity" - post "send_email" - post "verify_email_ownership" - patch "unlock_access" - end + resources :comment_mutes, only: %i[update] + resources :users, only: %i[index], defaults: { format: :json } # internal API + resources :users, only: %i[update] do + resource :twitch_stream_updates, only: %i[show create] end - resources :organization_memberships, only: %i[update destroy create] - resources :organizations, only: %i[index show] do - member do - patch "update_org_credits" - end - end - resources :sponsorships, only: %i[index edit update new create destroy] - resources :welcome, only: %i[index create] - resources :growth, only: %i[index] - resources :tools, only: %i[index create] do + resources :twitch_live_streams, only: :show, param: :username + resources :reactions, only: %i[index create] + resources :response_templates, only: %i[index create edit update destroy] + resources :feedback_messages, only: %i[index create] + resources :organizations, only: %i[update create destroy] + resources :followed_articles, only: [:index] + resources :follows, only: %i[show create update] do collection do - post "bust_cache" + get "/bulk_show", to: "follows#bulk_show" end end - resources :webhook_endpoints, only: :index - resource :config - resources :badges, only: %i[index edit update new create] - resources :display_ads, only: %i[index edit update new create destroy] - # These redirects serve as a safegaurd to prevent 404s for any Admins - # who have the old badge_achievement URLs bookmarked. - get "/badges/badge_achievements", to: redirect("/admin/badge_achievements") - get "/badges/badge_achievements/award_badges", to: redirect("/admin/badge_achievements/award_badges") - resources :badge_achievements, only: %i[index destroy] - get "/badge_achievements/award_badges", to: "badge_achievements#award" - post "/badge_achievements/award_badges", to: "badge_achievements#award_badges" - resources :secrets, only: %i[index] - put "secrets", to: "secrets#update" - end - - namespace :stories, defaults: { format: "json" } do - resource :feed, only: [:show] do - get ":timeframe" => "feeds#show" - end - end - - namespace :api, defaults: { format: "json" } do - scope module: :v0, - constraints: ApiConstraints.new(version: 0, default: true) do - resources :articles, only: %i[index show create update] do - collection do - get "me(/:status)", to: "articles#me", as: :me, constraints: { status: /published|unpublished|all/ } - get "/:username/:slug", to: "articles#show_by_slug", as: :slug - end - end - resources :comments, only: %i[index show] - resources :videos, only: [:index] - resources :podcast_episodes, only: [:index] - resources :users, only: %i[show] do - collection do - get :me - end - end - resources :tags, only: [:index] - resources :follows, only: [:create] - namespace :followers do - get :users - get :organizations - end - resources :webhooks, only: %i[index create show destroy] - - resources :listings, only: %i[index show create update] - get "/listings/category/:category", to: "listings#index", as: :listings_category - get "/analytics/totals", to: "analytics#totals" - get "/analytics/historical", to: "analytics#historical" - get "/analytics/past_day", to: "analytics#past_day" - get "/analytics/referrers", to: "analytics#referrers" - - resources :health_checks, only: [] do - collection do - get :app - get :search - get :database - get :cache - end + resources :image_uploads, only: [:create] + resources :notifications, only: [:index] + resources :tags, only: [:index] do + collection do + get "/onboarding", to: "tags#onboarding" end end - end - - namespace :notifications do - resources :counts, only: [:index] - resources :reads, only: [:create] - end - - namespace :incoming_webhooks do - get "/mailchimp/:secret/unsubscribe", to: "mailchimp_unsubscribes#index", as: :mailchimp_unsubscribe_check - post "/mailchimp/:secret/unsubscribe", to: "mailchimp_unsubscribes#create", as: :mailchimp_unsubscribe - end - - resources :messages, only: [:create] - resources :chat_channels, only: %i[index show create update] - resources :chat_channel_memberships, only: %i[index create edit update destroy] - resources :articles, only: %i[update create destroy] do - patch "/admin_unpublish", to: "articles#admin_unpublish" - end - resources :article_mutes, only: %i[update] - resources :comments, only: %i[create update destroy] do - patch "/hide", to: "comments#hide" - patch "/unhide", to: "comments#unhide" - patch "/admin_delete", to: "comments#admin_delete" - collection do - post "/moderator_create", to: "comments#moderator_create" + resources :stripe_active_cards, only: %i[create update destroy] + resources :github_repos, only: %i[index] do + collection do + post "/update_or_create", to: "github_repos#update_or_create" + end end - end - resources :comment_mutes, only: %i[update] - resources :users, only: %i[index], defaults: { format: :json } # internal API - resources :users, only: %i[update] do - resource :twitch_stream_updates, only: %i[show create] - end - resources :twitch_live_streams, only: :show, param: :username - resources :reactions, only: %i[index create] - resources :response_templates, only: %i[index create edit update destroy] - resources :feedback_messages, only: %i[index create] - resources :organizations, only: %i[update create destroy] - resources :followed_articles, only: [:index] - resources :follows, only: %i[show create update] do - collection do - get "/bulk_show", to: "follows#bulk_show" + resources :buffered_articles, only: [:index] + resources :events, only: %i[index show] + resources :videos, only: %i[index create new] + resources :video_states, only: [:create] + resources :twilio_tokens, only: [:show] + resources :html_variants, only: %i[index new create show edit update] + resources :html_variant_trials, only: [:create] + resources :html_variant_successes, only: [:create] + resources :tag_adjustments, only: %i[create destroy] + resources :rating_votes, only: [:create] + resources :page_views, only: %i[create update] + resources :listings, only: %i[index new create edit update destroy dashboard] + resources :credits, only: %i[index new create] do + get "purchase", on: :collection, to: "credits#new" end - end - resources :image_uploads, only: [:create] - resources :notifications, only: [:index] - resources :tags, only: [:index] do - collection do - get "/onboarding", to: "tags#onboarding" + resources :buffer_updates, only: [:create] + resources :reading_list_items, only: [:update] + resources :poll_votes, only: %i[show create] + resources :poll_skips, only: [:create] + resources :profile_pins, only: %i[create update] + resources :partnerships, only: %i[index create show], param: :option + resources :display_ad_events, only: [:create] + resources :badges, only: [:index] + resources :user_blocks, param: :blocked_id, only: %i[show create destroy] + resources :podcasts, only: %i[new create] + resources :article_approvals, only: %i[create] + resources :video_chats, only: %i[show] + resources :user_subscriptions, only: %i[create] do + collection do + get "/subscribed", action: "subscribed" + end end - end - resources :stripe_active_cards, only: %i[create update destroy] - resources :github_repos, only: %i[index] do - collection do - post "/update_or_create", to: "github_repos#update_or_create" + namespace :followings, defaults: { format: :json } do + get :users + get :tags + get :organizations + get :podcasts end - end - resources :buffered_articles, only: [:index] - resources :events, only: %i[index show] - resources :videos, only: %i[index create new] - resources :video_states, only: [:create] - resources :twilio_tokens, only: [:show] - resources :html_variants, only: %i[index new create show edit update] - resources :html_variant_trials, only: [:create] - resources :html_variant_successes, only: [:create] - resources :tag_adjustments, only: %i[create destroy] - resources :rating_votes, only: [:create] - resources :page_views, only: %i[create update] - resources :listings, only: %i[index new create edit update destroy dashboard] - resources :credits, only: %i[index new create] do - get "purchase", on: :collection, to: "credits#new" - end - resources :buffer_updates, only: [:create] - resources :reading_list_items, only: [:update] - resources :poll_votes, only: %i[show create] - resources :poll_skips, only: [:create] - resources :profile_pins, only: %i[create update] - resources :partnerships, only: %i[index create show], param: :option - resources :display_ad_events, only: [:create] - resources :badges, only: [:index] - resources :user_blocks, param: :blocked_id, only: %i[show create destroy] - resources :podcasts, only: %i[new create] - resources :article_approvals, only: %i[create] - resources :video_chats, only: %i[show] - resources :user_subscriptions, only: %i[create] do - collection do - get "/subscribed", action: "subscribed" + + resource :onboarding, only: :show + resources :profile_field_groups, only: %i[index], defaults: { format: :json } + + get "/verify_email_ownership", to: "email_authorizations#verify", as: :verify_email_authorizations + get "/search/tags" => "search#tags" + get "/search/chat_channels" => "search#chat_channels" + get "/search/listings" => "search#listings" + get "/search/users" => "search#users" + get "/search/feed_content" => "search#feed_content" + get "/search/reactions" => "search#reactions" + get "/chat_channel_memberships/find_by_chat_channel_id" => "chat_channel_memberships#find_by_chat_channel_id" + get "/listings/dashboard" => "listings#dashboard" + get "/listings/:category" => "listings#index", :as => :listing_category + get "/listings/:category/:slug" => "listings#index", :as => :listing_slug + get "/listings/:category/:slug/:view" => "listings#index", + :constraints => { view: /moderate/ } + get "/listings/:category/:slug/delete_confirm" => "listings#delete_confirm" + delete "/listings/:category/:slug" => "listings#destroy" + get "/notifications/:filter" => "notifications#index" + get "/notifications/:filter/:org_id" => "notifications#index" + get "/notification_subscriptions/:notifiable_type/:notifiable_id" => "notification_subscriptions#show" + post "/notification_subscriptions/:notifiable_type/:notifiable_id" => "notification_subscriptions#upsert" + patch "/onboarding_update" => "users#onboarding_update" + patch "/onboarding_checkbox_update" => "users#onboarding_checkbox_update" + get "email_subscriptions/unsubscribe" + post "/chat_channels/:id/moderate" => "chat_channels#moderate" + post "/chat_channels/:id/open" => "chat_channels#open" + get "/connect" => "chat_channels#index" + get "/connect/:slug" => "chat_channels#index" + get "/chat_channels/:id/channel_info", to: "chat_channels#channel_info", as: :chat_channel_info + post "/chat_channels/create_chat" => "chat_channels#create_chat" + post "/chat_channels/block_chat" => "chat_channels#block_chat" + post "/chat_channel_memberships/remove_membership" => "chat_channel_memberships#remove_membership" + post "/chat_channel_memberships/add_membership" => "chat_channel_memberships#add_membership" + post "/join_chat_channel" => "chat_channel_memberships#join_channel" + delete "/messages/:id" => "messages#destroy" + patch "/messages/:id" => "messages#update" + get "/live/:username" => "twitch_live_streams#show" + get "/internal", to: redirect("/admin") + get "/internal/:path", to: redirect("/admin/%{path}") + + post "/pusher/auth" => "pusher#auth" + + # Chat channel + patch "/chat_channels/update_channel/:id" => "chat_channels#update_channel" + + # Chat Channel Membership json response + get "/chat_channel_memberships/chat_channel_info/:id" => "chat_channel_memberships#chat_channel_info" + post "/chat_channel_memberships/create_membership_request" => "chat_channel_memberships#create_membership_request" + patch "/chat_channel_memberships/leave_membership/:id" => "chat_channel_memberships#leave_membership" + patch "/chat_channel_memberships/update_membership/:id" => "chat_channel_memberships#update_membership" + get "/channel_request_info/" => "chat_channel_memberships#request_details" + patch "/chat_channel_memberships/update_membership_role/:id" => "chat_channel_memberships#update_membership_role" + get "/join_channel_invitation/:channel_slug" => "chat_channel_memberships#join_channel_invitation" + post "/joining_invitation_response" => "chat_channel_memberships#joining_invitation_response" + + get "/social_previews/article/:id" => "social_previews#article", :as => :article_social_preview + get "/social_previews/user/:id" => "social_previews#user", :as => :user_social_preview + get "/social_previews/organization/:id" => "social_previews#organization", :as => :organization_social_preview + get "/social_previews/tag/:id" => "social_previews#tag", :as => :tag_social_preview + get "/social_previews/listing/:id" => "social_previews#listing", :as => :listing_social_preview + get "/social_previews/comment/:id" => "social_previews#comment", :as => :comment_social_preview + + get "/async_info/base_data", controller: "async_info#base_data", defaults: { format: :json } + get "/async_info/shell_version", controller: "async_info#shell_version", defaults: { format: :json } + + get "/future", to: redirect("devteam/the-future-of-dev-160n") + get "/forem", to: redirect("devteam/for-empowering-community-2k6h") + + # Settings + post "users/update_language_settings" => "users#update_language_settings" + post "users/update_twitch_username" => "users#update_twitch_username" + post "users/join_org" => "users#join_org" + post "users/leave_org/:organization_id" => "users#leave_org", :as => :users_leave_org + post "users/add_org_admin" => "users#add_org_admin" + post "users/remove_org_admin" => "users#remove_org_admin" + post "users/remove_from_org" => "users#remove_from_org" + delete "users/remove_identity", to: "users#remove_identity" + post "users/request_destroy", to: "users#request_destroy", as: :user_request_destroy + get "users/confirm_destroy/:token", to: "users#confirm_destroy", as: :user_confirm_destroy + delete "users/full_delete", to: "users#full_delete", as: :user_full_delete + post "organizations/generate_new_secret" => "organizations#generate_new_secret" + post "users/api_secrets" => "api_secrets#create", :as => :users_api_secrets + delete "users/api_secrets/:id" => "api_secrets#destroy", :as => :users_api_secret + + # The priority is based upon order of creation: first created -> highest priority. + # See how all your routes lay out with "rake routes". + + # You can have the root of your site routed with "root + get "/robots.:format" => "pages#robots" + get "/api", to: redirect("https://docs.forem.com/api") + get "/privacy" => "pages#privacy" + get "/terms" => "pages#terms" + get "/contact" => "pages#contact" + get "/code-of-conduct" => "pages#code_of_conduct" + get "/report-abuse" => "pages#report_abuse" + get "/welcome" => "pages#welcome" + get "/challenge" => "pages#challenge" + get "/checkin" => "pages#checkin" + get "/badge" => "pages#badge" + get "/💸", to: redirect("t/hiring") + get "/survey", to: redirect("https://dev.to/ben/final-thoughts-on-the-state-of-the-web-survey-44nn") + get "/events" => "events#index" + get "/workshops", to: redirect("events") + get "/sponsors" => "pages#sponsors" + get "/search" => "stories#search" + post "articles/preview" => "articles#preview" + post "comments/preview" => "comments#preview" + get "/stories/warm_comments/:username/:slug" => "stories#warm_comments" + + # These routes are required by links in the sites and will most likely to be replaced by a db page + get "/about" => "pages#about" + get "/about-listings" => "pages#about_listings" + get "/security", to: "pages#bounty" + get "/community-moderation" => "pages#community_moderation" + get "/faq" => "pages#faq" + get "/page/post-a-job" => "pages#post_a_job" + get "/tag-moderation" => "pages#tag_moderation" + + # NOTE: can't remove the hardcoded URL here as SiteConfig is not available here, we should eventually + # setup dynamic redirects, see + get "/shop", to: redirect("https://shop.dev.to") + + get "/mod" => "moderations#index", :as => :mod + get "/mod/:tag" => "moderations#index" + get "/page/crayons" => "pages#crayons" + + post "/fallback_activity_recorder" => "ga_events#create" + + get "/page/:slug" => "pages#show" + + scope "p" do + pages_actions = %w[welcome editor_guide publishing_from_rss_guide information markdown_basics badges].freeze + pages_actions.each do |action| + get action, action: action, controller: "pages" + end end - end - namespace :followings, defaults: { format: :json } do - get :users - get :tags - get :organizations - get :podcasts - end - resource :onboarding, only: :show - resources :profile_field_groups, only: %i[index], defaults: { format: :json } + get "/settings/(:tab)" => "users#edit", :as => :user_settings + get "/settings/:tab/:org_id" => "users#edit", :constraints => { tab: /organization/ } + get "/settings/:tab/:id" => "users#edit", :constraints => { tab: /response-templates/ } + get "/signout_confirm" => "users#signout_confirm" + get "/dashboard" => "dashboards#show" + get "/dashboard/pro", to: "dashboards#pro" + get "dashboard/pro/org/:org_id", to: "dashboards#pro", as: :dashboard_pro_org + get "dashboard/following" => "dashboards#following_tags" + get "dashboard/following_tags" => "dashboards#following_tags" + get "dashboard/following_users" => "dashboards#following_users" + get "dashboard/following_organizations" => "dashboards#following_organizations" + get "dashboard/following_podcasts" => "dashboards#following_podcasts" + get "/dashboard/subscriptions" => "dashboards#subscriptions" + get "/dashboard/:which" => "dashboards#followers", :constraints => { which: /user_followers/ } + get "/dashboard/:which/:org_id" => "dashboards#show", + :constraints => { + which: /organization/ + } + get "/dashboard/:username" => "dashboards#show" - get "/verify_email_ownership", to: "email_authorizations#verify", as: :verify_email_authorizations - get "/search/tags" => "search#tags" - get "/search/chat_channels" => "search#chat_channels" - get "/search/listings" => "search#listings" - get "/search/users" => "search#users" - get "/search/feed_content" => "search#feed_content" - get "/search/reactions" => "search#reactions" - get "/chat_channel_memberships/find_by_chat_channel_id" => "chat_channel_memberships#find_by_chat_channel_id" - get "/listings/dashboard" => "listings#dashboard" - get "/listings/:category" => "listings#index", :as => :listing_category - get "/listings/:category/:slug" => "listings#index", :as => :listing_slug - get "/listings/:category/:slug/:view" => "listings#index", - :constraints => { view: /moderate/ } - get "/listings/:category/:slug/delete_confirm" => "listings#delete_confirm" - delete "/listings/:category/:slug" => "listings#destroy" - get "/notifications/:filter" => "notifications#index" - get "/notifications/:filter/:org_id" => "notifications#index" - get "/notification_subscriptions/:notifiable_type/:notifiable_id" => "notification_subscriptions#show" - post "/notification_subscriptions/:notifiable_type/:notifiable_id" => "notification_subscriptions#upsert" - patch "/onboarding_update" => "users#onboarding_update" - patch "/onboarding_checkbox_update" => "users#onboarding_checkbox_update" - get "email_subscriptions/unsubscribe" - post "/chat_channels/:id/moderate" => "chat_channels#moderate" - post "/chat_channels/:id/open" => "chat_channels#open" - get "/connect" => "chat_channels#index" - get "/connect/:slug" => "chat_channels#index" - get "/chat_channels/:id/channel_info", to: "chat_channels#channel_info", as: :chat_channel_info - post "/chat_channels/create_chat" => "chat_channels#create_chat" - post "/chat_channels/block_chat" => "chat_channels#block_chat" - post "/chat_channel_memberships/remove_membership" => "chat_channel_memberships#remove_membership" - post "/chat_channel_memberships/add_membership" => "chat_channel_memberships#add_membership" - post "/join_chat_channel" => "chat_channel_memberships#join_channel" - delete "/messages/:id" => "messages#destroy" - patch "/messages/:id" => "messages#update" - get "/live/:username" => "twitch_live_streams#show" - get "/internal", to: redirect("/admin") - get "/internal/:path", to: redirect("/admin/%{path}") - - post "/pusher/auth" => "pusher#auth" - - # Chat channel - patch "/chat_channels/update_channel/:id" => "chat_channels#update_channel" - - # Chat Channel Membership json response - get "/chat_channel_memberships/chat_channel_info/:id" => "chat_channel_memberships#chat_channel_info" - post "/chat_channel_memberships/create_membership_request" => "chat_channel_memberships#create_membership_request" - patch "/chat_channel_memberships/leave_membership/:id" => "chat_channel_memberships#leave_membership" - patch "/chat_channel_memberships/update_membership/:id" => "chat_channel_memberships#update_membership" - get "/channel_request_info/" => "chat_channel_memberships#request_details" - patch "/chat_channel_memberships/update_membership_role/:id" => "chat_channel_memberships#update_membership_role" - get "/join_channel_invitation/:channel_slug" => "chat_channel_memberships#join_channel_invitation" - post "/joining_invitation_response" => "chat_channel_memberships#joining_invitation_response" - - get "/social_previews/article/:id" => "social_previews#article", :as => :article_social_preview - get "/social_previews/user/:id" => "social_previews#user", :as => :user_social_preview - get "/social_previews/organization/:id" => "social_previews#organization", :as => :organization_social_preview - get "/social_previews/tag/:id" => "social_previews#tag", :as => :tag_social_preview - get "/social_previews/listing/:id" => "social_previews#listing", :as => :listing_social_preview - get "/social_previews/comment/:id" => "social_previews#comment", :as => :comment_social_preview - - get "/async_info/base_data", controller: "async_info#base_data", defaults: { format: :json } - get "/async_info/shell_version", controller: "async_info#shell_version", defaults: { format: :json } - - get "/future", to: redirect("devteam/the-future-of-dev-160n") - get "/forem", to: redirect("devteam/for-empowering-community-2k6h") - - # Settings - post "users/update_language_settings" => "users#update_language_settings" - post "users/update_twitch_username" => "users#update_twitch_username" - post "users/join_org" => "users#join_org" - post "users/leave_org/:organization_id" => "users#leave_org", :as => :users_leave_org - post "users/add_org_admin" => "users#add_org_admin" - post "users/remove_org_admin" => "users#remove_org_admin" - post "users/remove_from_org" => "users#remove_from_org" - delete "users/remove_identity", to: "users#remove_identity" - post "users/request_destroy", to: "users#request_destroy", as: :user_request_destroy - get "users/confirm_destroy/:token", to: "users#confirm_destroy", as: :user_confirm_destroy - delete "users/full_delete", to: "users#full_delete", as: :user_full_delete - post "organizations/generate_new_secret" => "organizations#generate_new_secret" - post "users/api_secrets" => "api_secrets#create", :as => :users_api_secrets - delete "users/api_secrets/:id" => "api_secrets#destroy", :as => :users_api_secret - - # The priority is based upon order of creation: first created -> highest priority. - # See how all your routes lay out with "rake routes". - - # You can have the root of your site routed with "root - get "/robots.:format" => "pages#robots" - get "/api", to: redirect("https://docs.forem.com/api") - get "/privacy" => "pages#privacy" - get "/terms" => "pages#terms" - get "/contact" => "pages#contact" - get "/code-of-conduct" => "pages#code_of_conduct" - get "/report-abuse" => "pages#report_abuse" - get "/welcome" => "pages#welcome" - get "/challenge" => "pages#challenge" - get "/checkin" => "pages#checkin" - get "/badge" => "pages#badge" - get "/💸", to: redirect("t/hiring") - get "/survey", to: redirect("https://dev.to/ben/final-thoughts-on-the-state-of-the-web-survey-44nn") - get "/events" => "events#index" - get "/workshops", to: redirect("events") - get "/sponsors" => "pages#sponsors" - get "/search" => "stories#search" - post "articles/preview" => "articles#preview" - post "comments/preview" => "comments#preview" - get "/stories/warm_comments/:username/:slug" => "stories#warm_comments" - - # These routes are required by links in the sites and will most likely to be replaced by a db page - get "/about" => "pages#about" - get "/about-listings" => "pages#about_listings" - get "/security", to: "pages#bounty" - get "/community-moderation" => "pages#community_moderation" - get "/faq" => "pages#faq" - get "/page/post-a-job" => "pages#post_a_job" - get "/tag-moderation" => "pages#tag_moderation" - - # NOTE: can't remove the hardcoded URL here as SiteConfig is not available here, we should eventually - # setup dynamic redirects, see - get "/shop", to: redirect("https://shop.dev.to") - - get "/mod" => "moderations#index", :as => :mod - get "/mod/:tag" => "moderations#index" - get "/page/crayons" => "pages#crayons" - - post "/fallback_activity_recorder" => "ga_events#create" - - get "/page/:slug" => "pages#show" - - scope "p" do - pages_actions = %w[welcome editor_guide publishing_from_rss_guide information markdown_basics badges].freeze - pages_actions.each do |action| - get action, action: action, controller: "pages" + # for testing rails mailers + unless Rails.env.production? + get "/rails/mailers" => "rails/mailers#index" + get "/rails/mailers/*path" => "rails/mailers#preview" end + + get "/embed/:embeddable", to: "liquid_embeds#show", as: "liquid_embed" + + # serviceworkers + get "/serviceworker" => "service_worker#index" + get "/manifest" => "service_worker#manifest" + + get "/shell_top" => "shell#top" + get "/shell_bottom" => "shell#bottom" + + get "/new" => "articles#new" + get "/new/:template" => "articles#new" + + get "/pod", to: "podcast_episodes#index" + get "/podcasts", to: redirect("pod") + get "/readinglist" => "reading_list_items#index" + get "/readinglist/:view" => "reading_list_items#index", :constraints => { view: /archive/ } + + get "/feed" => "articles#feed", :as => "feed", :defaults => { format: "rss" } + get "/feed/tag/:tag" => "articles#feed", :as => "tag_feed", :defaults => { format: "rss" } + get "/feed/:username" => "articles#feed", :as => "user_feed", :defaults => { format: "rss" } + get "/rss" => "articles#feed", :defaults => { format: "rss" } + + get "/tag/:tag" => "stories#index" + get "/t/:tag", to: "stories#index", as: :tag + get "/t/:tag/edit", to: "tags#edit" + get "/t/:tag/admin", to: "tags#admin" + patch "/tag/:id", to: "tags#update" + get "/t/:tag/top/:timeframe" => "stories#index" + get "/t/:tag/page/:page" => "stories#index" + get "/t/:tag/:timeframe" => "stories#index", + :constraints => { timeframe: /latest/ } + + get "/badge/:slug" => "badges#show" + + get "/top/:timeframe" => "stories#index" + + get "/:timeframe" => "stories#index", :constraints => { timeframe: /latest/ } + + get "/:username/series" => "collections#index", :as => "user_series" + get "/:username/series/:id" => "collections#show" + + # Legacy comment format (might still be floating around app, and external links) + get "/:username/:slug/comments" => "comments#index" + get "/:username/:slug/comments/:id_code" => "comments#index" + get "/:username/:slug/comments/:id_code/edit" => "comments#edit" + get "/:username/:slug/comments/:id_code/delete_confirm" => "comments#delete_confirm" + + # Proper link format + get "/:username/comment/:id_code" => "comments#index" + get "/:username/comment/:id_code/edit" => "comments#edit" + get "/:username/comment/:id_code/delete_confirm" => "comments#delete_confirm" + get "/:username/comment/:id_code/mod" => "moderations#comment" + get "/:username/comment/:id_code/settings", to: "comments#settings" + + get "/:username/:slug/:view" => "stories#show", + :constraints => { view: /moderate/ } + get "/:username/:slug/mod" => "moderations#article" + get "/:username/:slug/actions_panel" => "moderations#actions_panel" + get "/:username/:slug/manage" => "articles#manage" + get "/:username/:slug/edit" => "articles#edit" + get "/:username/:slug/delete_confirm" => "articles#delete_confirm" + get "/:username/:slug/stats" => "articles#stats" + get "/:username/:view" => "stories#index", + :constraints => { view: /comments|moderate|admin/ } + get "/:username/:slug" => "stories#show" + get "/:sitemap" => "sitemaps#show", + :constraints => { format: /xml/, sitemap: /sitemap-.+/ } + get "/:username" => "stories#index", :as => "user_profile" + + root "stories#index" end - - get "/settings/(:tab)" => "users#edit", :as => :user_settings - get "/settings/:tab/:org_id" => "users#edit", :constraints => { tab: /organization/ } - get "/settings/:tab/:id" => "users#edit", :constraints => { tab: /response-templates/ } - get "/signout_confirm" => "users#signout_confirm" - get "/dashboard" => "dashboards#show" - get "/dashboard/pro", to: "dashboards#pro" - get "dashboard/pro/org/:org_id", to: "dashboards#pro", as: :dashboard_pro_org - get "dashboard/following" => "dashboards#following_tags" - get "dashboard/following_tags" => "dashboards#following_tags" - get "dashboard/following_users" => "dashboards#following_users" - get "dashboard/following_organizations" => "dashboards#following_organizations" - get "dashboard/following_podcasts" => "dashboards#following_podcasts" - get "/dashboard/subscriptions" => "dashboards#subscriptions" - get "/dashboard/:which" => "dashboards#followers", :constraints => { which: /user_followers/ } - get "/dashboard/:which/:org_id" => "dashboards#show", - :constraints => { - which: /organization/ - } - get "/dashboard/:username" => "dashboards#show" - - # for testing rails mailers - unless Rails.env.production? - get "/rails/mailers" => "rails/mailers#index" - get "/rails/mailers/*path" => "rails/mailers#preview" - end - - get "/embed/:embeddable", to: "liquid_embeds#show", as: "liquid_embed" - - # serviceworkers - get "/serviceworker" => "service_worker#index" - get "/manifest" => "service_worker#manifest" - - get "/shell_top" => "shell#top" - get "/shell_bottom" => "shell#bottom" - - get "/new" => "articles#new" - get "/new/:template" => "articles#new" - - get "/pod", to: "podcast_episodes#index" - get "/podcasts", to: redirect("pod") - get "/readinglist" => "reading_list_items#index" - get "/readinglist/:view" => "reading_list_items#index", :constraints => { view: /archive/ } - - get "/feed" => "articles#feed", :as => "feed", :defaults => { format: "rss" } - get "/feed/tag/:tag" => "articles#feed", :as => "tag_feed", :defaults => { format: "rss" } - get "/feed/:username" => "articles#feed", :as => "user_feed", :defaults => { format: "rss" } - get "/rss" => "articles#feed", :defaults => { format: "rss" } - - get "/tag/:tag" => "stories#index" - get "/t/:tag", to: "stories#index", as: :tag - get "/t/:tag/edit", to: "tags#edit" - get "/t/:tag/admin", to: "tags#admin" - patch "/tag/:id", to: "tags#update" - get "/t/:tag/top/:timeframe" => "stories#index" - get "/t/:tag/page/:page" => "stories#index" - get "/t/:tag/:timeframe" => "stories#index", - :constraints => { timeframe: /latest/ } - - get "/badge/:slug" => "badges#show" - - get "/top/:timeframe" => "stories#index" - - get "/:timeframe" => "stories#index", :constraints => { timeframe: /latest/ } - - get "/:username/series" => "collections#index", :as => "user_series" - get "/:username/series/:id" => "collections#show" - - # Legacy comment format (might still be floating around app, and external links) - get "/:username/:slug/comments" => "comments#index" - get "/:username/:slug/comments/:id_code" => "comments#index" - get "/:username/:slug/comments/:id_code/edit" => "comments#edit" - get "/:username/:slug/comments/:id_code/delete_confirm" => "comments#delete_confirm" - - # Proper link format - get "/:username/comment/:id_code" => "comments#index" - get "/:username/comment/:id_code/edit" => "comments#edit" - get "/:username/comment/:id_code/delete_confirm" => "comments#delete_confirm" - get "/:username/comment/:id_code/mod" => "moderations#comment" - get "/:username/comment/:id_code/settings", to: "comments#settings" - - get "/:username/:slug/:view" => "stories#show", - :constraints => { view: /moderate/ } - get "/:username/:slug/mod" => "moderations#article" - get "/:username/:slug/actions_panel" => "moderations#actions_panel" - get "/:username/:slug/manage" => "articles#manage" - get "/:username/:slug/edit" => "articles#edit" - get "/:username/:slug/delete_confirm" => "articles#delete_confirm" - get "/:username/:slug/stats" => "articles#stats" - get "/:username/:view" => "stories#index", - :constraints => { view: /comments|moderate|admin/ } - get "/:username/:slug" => "stories#show" - get "/:sitemap" => "sitemaps#show", - :constraints => { format: /xml/, sitemap: /sitemap-.+/ } - get "/:username" => "stories#index", :as => "user_profile" - - root "stories#index" end # rubocop:enable Metrics/BlockLength diff --git a/docs/backend/internationalization.md b/docs/backend/internationalization.md new file mode 100644 index 000000000..89598cfe2 --- /dev/null +++ b/docs/backend/internationalization.md @@ -0,0 +1,148 @@ +--- +title: Internationalization (i18n) +--- + +# Internationalization (i18n) + +## What is internationlization (i18n)? + +To over simplify the concept a bit, internalization (i18n for short) is the +process of making the platform more user-friendly in various languages for +people around the globe. This includes, but is certainly not limited to, things +like making the site available in different languages, changing currency values +to match your region, changing date formats, etc. + +## What do we currently support? + +Currently, we allow users to set some language settings (beta) under +`/settings/misc`. Based on your selection, the feed algorithm will prioritize +content written in your preferred languages. + +We also introduced some routing to lay the groundwork for a more comprehensive +i18n implementation. + +## What is the goal? + +We want everyone to feel included, regardless of where they're located or what +language(s) they speak. The goal is to make the platform available in various +languages. + +## How do you get involved? + +The following is a high level outline of an approach to internationalization. +This is by no means set in stone. + +We encourage you to open a pull request (PR) to this documentation or to +contribute to internationalization with your ideas - we're +[open-source](https://github.com/forem/forem/pulls)! + +## Routing + +We have logic for routes setup. You can visit a page and add `/locale/:locale` +to the beginning of the path. For example, if you visit the homepage, you can +add `/locale/fr-ca` for French, Canadian where `fr` is the language code and +`ca` is the region code. + +Setting up languages under this "sub-folder" approach helps with Search Engine +Optimization (SEO), routing, and more. + +_Currently, the various language routes will not do anything - it will stil show +the site in English (US)._ + +Once i18n is up and running, users will be able to select their preferred +language to view the platform in. These routes will be the location of various +languages. + +## Translating content + +There are many ways to translate static content on the platform. To start, we +can explore tools like [i18n-tasks](https://glebm.github.io/i18n-tasks/) which +also has an option to leverage Google Translate programmatically. We'll need to +create locale files (likely `.yml`) to house the translations. + +## Search Engine Optimization (SEO) + +It seems search engines, especially Google, don't particularly like content on a +page to be in multiple languages. To account for this on pages like articles, we +can try an approach using the canonical URL for the language the article was +written in. + +For example, if we detect an article is written in Spanish, we can set the +canonical URL for that article to be `/locale/es/username/article-slug`. We can +then hide comments that are not in the same language as the article/rest of the +page (Spanish in this example) only for the views the crawlers would see. That +way, when the search engine crawler hits an article written in Spanish, the +crawler will see the entire page in Spanish. We will not hide comments for the +views that real users see. + +## Caching and service workers + +The platform relies on edge caching, especially with regards to articles. To +account for this, we'll need to add logic at the edge that understands what +languages the platform currently supports and where to look up the language +variant in the cache. + +If the edge doesn't pick up on a user selected preference (possibly sent as an +additional header or cookie), the edge will look at the `Accept-Language` header +and normalize it. The header can include more specific preferences and look +something like: `Accept-Language: fr-ca, fr;q=0.9, en;q=0.8, de;q=0.7, *;q=0.5`. +There are 2 things going on here. 1) A user can specify country/region variants +for a language - `fr-ca` (French - Canada) and `fr-fr` (French - France). For +simplicity's sake, we want to normalize that sort of preference to `fr` to +start. 2) A user can specify priority using the q argument. We'll want to +interpret the user's priority preferences to match their highest priority +language with one we currently support. + +Once the edge is aware of what language it should be looking for, it will set +the cache key accordingly. + +We also make use of fragment caching in several places. We need to update the +keys for those caches to account for `locale` so we're not mistakenly serving a +cached fragment in a different language than intended. + +Service workers load some parts of the UI up front, like the shell. Therefore, +the service worker will need to be aware of language preferences so that it +loads the shell in the correct language. We also need to make sure the service +worker/cache is aware of a change in the user's language preference. + +## Additional considerations + +- _Translating URLs_. For the best SEO result, we should also translate URLs + themselves into various languages. Something like the `/about` page could be + translated, for example. For now, we aren't going to account for this. +- _Translating dynamic/user generated content_. For now, we plan to _not_ + automatically translate any dynamic/user generated content (articles, + comments, listings, etc.). In the future we could explore what that looks + like, how a user can opt-in/out of that, etc. + +## Styles, design, and UI + +We'll want to expand some design aspects to support other languages that may be +right-to-left, have different spacings, have special characters, etc. + +## Next steps + +A few next steps we can take on the road to internationalization. + +- Update our logic to allow special characters/encodings in URLs. Currently, we + generate slugs on dynamic content like articles and tags that may include + characters that make the URL + invalid.[Here](https://github.com/forem/forem/issues/10116) is a good example. + We want to update this logic so these characters work in URLs as expected. +- Allow Forem Admins to set a "default language". Currently, if a user doesn't + select a language preference, it defaults to English ("en"). +- Update service workers and caching to interpret language preferences. +- Clean up some code. There are some places we're hard-coding strings on the + frontend. We'll want to explore moving that sort data to the backend to unify + where and how we're translating. +- Translate areas of the site into English (US) first to ensure things are still + working. In other words, have the platform adhere to the default locale + instead of hard-coded strings. +- Start translating! + +## Resources + +- [Rails Guides - Rails Internationalization (I18n)](https://guides.rubyonrails.org/i18n.html) +- [i18n-tasks](https://glebm.github.io/i18n-tasks/) +- [Google: Managing multi-regional and multilingual sites](https://support.google.com/webmasters/answer/182192) +- [forem.dev post: What internationalization features should we support?](https://forem.dev/vaidehijoshi/what-internationalization-features-should-we-support-4kl) diff --git a/docs/backend/readme.md b/docs/backend/readme.md index e80a90c18..a74d2fe0f 100644 --- a/docs/backend/readme.md +++ b/docs/backend/readme.md @@ -11,6 +11,7 @@ items: - data-update-scripts.md - elasticsearch.md - fastly.md + - internationalization.md - roles.md - pusher.md - resource-admin.md diff --git a/spec/routing/all_routes_spec.rb b/spec/routing/all_routes_spec.rb index 7049b855f..3dd023586 100644 --- a/spec/routing/all_routes_spec.rb +++ b/spec/routing/all_routes_spec.rb @@ -9,6 +9,7 @@ RSpec.describe "all routes", type: :routing do controller: "stories", action: "index", username: podcast.slug, + locale: nil, # default locale ) end @@ -17,6 +18,7 @@ RSpec.describe "all routes", type: :routing do controller: "stories", action: "index", username: user.username, + locale: nil, # default locale ) end @@ -26,6 +28,7 @@ RSpec.describe "all routes", type: :routing do action: "show", slug: "this-is-a-slug", username: "ben", + locale: nil, # default locale ) end diff --git a/spec/routing/i18n_routes_spec.rb b/spec/routing/i18n_routes_spec.rb new file mode 100644 index 000000000..4af1fd71f --- /dev/null +++ b/spec/routing/i18n_routes_spec.rb @@ -0,0 +1,34 @@ +require "rails_helper" + +RSpec.describe "i8n routes", type: :routing do + let(:locale) { "fr-ca" } + let(:i18n_route) { "/locale/#{locale}" } + let(:user) { create(:user) } + + it "renders a user index if there is a user with the username successfully" do + expect(get: "#{i18n_route}/#{user.username}").to route_to( + controller: "stories", + action: "index", + username: user.username, + locale: "fr-ca", + ) + end + + it "renders a user's story successfully" do + expect(get: "#{i18n_route}/ben/this-is-a-slug").to route_to( + controller: "stories", + action: "show", + slug: "this-is-a-slug", + username: "ben", + locale: "fr-ca", + ) + end + + it "renders homepage successfully" do + expect(get: i18n_route).to route_to( + controller: "stories", + action: "index", + locale: "fr-ca", + ) + end +end