diff --git a/.env b/.env deleted file mode 100644 index b8a9baa..0000000 --- a/.env +++ /dev/null @@ -1,8 +0,0 @@ -# PRODUCTION_DB_NAME=project_daedalus_production # Legacy Cloud SQL/Cloud Run — no longer used -# PRODUCTION_DB_USER=daedalus # Legacy Cloud SQL/Cloud Run — no longer used -# CLOUD_SQL_CONNECTION_NAME=projectdaedalus-fb09f:us-central1:project-daedalus # Legacy Cloud SQL/Cloud Run — no longer used -GOOGLE_PROJECT_ID=projectdaedalus-fb09f -STORAGE_BUCKET_NAME=project-daedalus-public -GOOGLE_REGION=us-central1 -# GOOGLE_SERVICE_NAME=project-daedalus # Legacy Cloud SQL/Cloud Run — no longer used -# GOOGLE_INSTANCE_NAME=project-daedalus # Legacy Cloud SQL/Cloud Run — no longer used diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..7a853a0 --- /dev/null +++ b/.env.example @@ -0,0 +1,6 @@ +# Copy this file to .env and fill in your values +# cp .env.example .env + +GOOGLE_PROJECT_ID=your-firebase-project-id +STORAGE_BUCKET_NAME=your-storage-bucket-name +GOOGLE_REGION=us-central1 diff --git a/.gitignore b/.gitignore index d047613..6b33d91 100644 --- a/.gitignore +++ b/.gitignore @@ -38,8 +38,16 @@ /app/assets/builds/* !/app/assets/builds/.keep +.env .env.* +!.env.example # Claude Code local settings .claude/ + +# Serena MCP plugin +.serena/ + +# Playwright MCP plugin +.playwright-mcp/ firebase-debug.log diff --git a/.ruby-version b/.ruby-version index 7921bd0..7bcbb38 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -3.4.8 +3.4.9 diff --git a/.serena/.gitignore b/.serena/.gitignore deleted file mode 100644 index 14d86ad..0000000 --- a/.serena/.gitignore +++ /dev/null @@ -1 +0,0 @@ -/cache diff --git a/.serena/project.yml b/.serena/project.yml deleted file mode 100644 index 8206bd1..0000000 --- a/.serena/project.yml +++ /dev/null @@ -1,87 +0,0 @@ -# list of languages for which language servers are started; choose from: -# al bash clojure cpp csharp csharp_omnisharp -# dart elixir elm erlang fortran fsharp -# go groovy haskell java julia kotlin -# lua markdown nix pascal perl php -# powershell python python_jedi r rego ruby -# ruby_solargraph rust scala swift terraform toml -# typescript typescript_vts yaml zig -# Note: -# - For C, use cpp -# - For JavaScript, use typescript -# - For Free Pascal / Lazarus, use pascal -# Special requirements: -# - csharp: Requires the presence of a .sln file in the project folder. -# - pascal: Requires Free Pascal Compiler (fpc) and optionally Lazarus. -# When using multiple languages, the first language server that supports a given file will be used for that file. -# The first language is the default language and the respective language server will be used as a fallback. -# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored. -languages: -- ruby - -# the encoding used by text files in the project -# For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings -encoding: "utf-8" - -# whether to use the project's gitignore file to ignore files -# Added on 2025-04-07 -ignore_all_files_in_gitignore: true - -# list of additional paths to ignore -# same syntax as gitignore, so you can use * and ** -# Was previously called `ignored_dirs`, please update your config if you are using that. -# Added (renamed) on 2025-04-07 -ignored_paths: [] - -# whether the project is in read-only mode -# If set to true, all editing tools will be disabled and attempts to use them will result in an error -# Added on 2025-04-18 -read_only: false - -# list of tool names to exclude. We recommend not excluding any tools, see the readme for more details. -# Below is the complete list of tools for convenience. -# To make sure you have the latest list of tools, and to view their descriptions, -# execute `uv run scripts/print_tool_overview.py`. -# -# * `activate_project`: Activates a project by name. -# * `check_onboarding_performed`: Checks whether project onboarding was already performed. -# * `create_text_file`: Creates/overwrites a file in the project directory. -# * `delete_lines`: Deletes a range of lines within a file. -# * `delete_memory`: Deletes a memory from Serena's project-specific memory store. -# * `execute_shell_command`: Executes a shell command. -# * `find_referencing_code_snippets`: Finds code snippets in which the symbol at the given location is referenced. -# * `find_referencing_symbols`: Finds symbols that reference the symbol at the given location (optionally filtered by type). -# * `find_symbol`: Performs a global (or local) search for symbols with/containing a given name/substring (optionally filtered by type). -# * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes. -# * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file. -# * `initial_instructions`: Gets the initial instructions for the current project. -# Should only be used in settings where the system prompt cannot be set, -# e.g. in clients you have no control over, like Claude Desktop. -# * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol. -# * `insert_at_line`: Inserts content at a given line in a file. -# * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol. -# * `list_dir`: Lists files and directories in the given directory (optionally with recursion). -# * `list_memories`: Lists memories in Serena's project-specific memory store. -# * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building). -# * `prepare_for_new_conversation`: Provides instructions for preparing for a new conversation (in order to continue with the necessary context). -# * `read_file`: Reads a file within the project directory. -# * `read_memory`: Reads the memory with the given name from Serena's project-specific memory store. -# * `remove_project`: Removes a project from the Serena configuration. -# * `replace_lines`: Replaces a range of lines within a file with new content. -# * `replace_symbol_body`: Replaces the full definition of a symbol. -# * `restart_language_server`: Restarts the language server, may be necessary when edits not through Serena happen. -# * `search_for_pattern`: Performs a search for a pattern in the project. -# * `summarize_changes`: Provides instructions for summarizing the changes made to the codebase. -# * `switch_modes`: Activates modes by providing a list of their names -# * `think_about_collected_information`: Thinking tool for pondering the completeness of collected information. -# * `think_about_task_adherence`: Thinking tool for determining whether the agent is still on track with the current task. -# * `think_about_whether_you_are_done`: Thinking tool for determining whether the task is truly completed. -# * `write_memory`: Writes a named memory (for future reference) to Serena's project-specific memory store. -excluded_tools: [] - -# initial prompt for the project. It will always be given to the LLM upon activating the project -# (contrary to the memories, which are loaded on demand). -initial_prompt: "" - -project_name: "project_daedalus" -included_optional_tools: [] diff --git a/app/controllers/mods_controller.rb b/app/controllers/mods_controller.rb index 41858dd..e0a2bbc 100644 --- a/app/controllers/mods_controller.rb +++ b/app/controllers/mods_controller.rb @@ -1,33 +1,22 @@ # frozen_string_literal: true class ModsController < ApplicationController + include PaginationHelper + before_action :authors, only: %i[index show] before_action :mods, only: %i[index show] before_action :set_session, only: %i[index] def index - @mods = find_mods_by_author(sanitize(params[:author])) if params[:author].present? - - # If we're given a mod ID, try to find it and redirect to the mod's page - if @mods.empty? && params[:author].present? - @mod = mods.find { |mod| mod.id == params[:author] } - return redirect_to mod_detail_path(author: @mod.author_slug, slug: @mod.slug) if @mod.present? - end - - # Perform a search if we have a query - @mods = find_mods(sanitize(params[:query])) if params[:query].present? + @filtered = false - # Sort the mods if we have a sort key - # Disabled for now, as it's redundant with the search - # params[:sort].present? && Mod::SORTKEYS.include?(params[:sort]) && @mods.sort_by! { |mod| [mod.send(sanitize(params[:sort])), mod.name] } + filter_by_author + return if performed? + filter_by_query @total_mods = @mods.size - - if turbo_frame_request? - render partial: "mods", locals: { mods: @mods } - else - render :index - end + paginate_mods unless @filtered + render_index end def show @@ -46,6 +35,39 @@ def show private + def filter_by_author + return if params[:author].blank? + + @mods = find_mods_by_author(sanitize(params[:author])) + @filtered = true + + # If no mods found by author slug, try to find by mod ID and redirect + return unless @mods.empty? + + @mod = mods.find { |mod| mod.id == params[:author] } + redirect_to mod_detail_path(author: @mod.author_slug, slug: @mod.slug) if @mod.present? + end + + def filter_by_query + return if params[:query].blank? + + @mods = find_mods(sanitize(params[:query])) + @filtered = true + end + + def paginate_mods + @pagination = paginate_array(@mods, page: params[:page]) + @mods = @pagination.items + end + + def render_index + if turbo_frame_request? + render partial: "mods", locals: { mods: @mods } + else + render :index + end + end + def find_mods(query) # Escape regex special characters to prevent injection escaped_query = Regexp.escape(query) diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index c4956b4..0d38801 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -14,15 +14,23 @@ def block_code(code, language) def markdown(text) return if text.blank? - coderayified = CodeRayify.new(filter_html: true, hard_wrap: true) - - options = { - fenced_code_blocks: true, - no_intra_emphasis: true, - autolink: true, - lax_html_blocks: true - } - markdown_to_html = Redcarpet::Markdown.new(coderayified, options) - sanitize(markdown_to_html.render(text)) + sanitize(markdown_renderer.render(text)) + end + + private + + def markdown_renderer + @markdown_renderer ||= begin + coderayified = CodeRayify.new(filter_html: true, hard_wrap: true) + + options = { + fenced_code_blocks: true, + no_intra_emphasis: true, + autolink: true, + lax_html_blocks: true + } + + Redcarpet::Markdown.new(coderayified, options) + end end end diff --git a/app/helpers/pagination_helper.rb b/app/helpers/pagination_helper.rb new file mode 100644 index 0000000..cc6988a --- /dev/null +++ b/app/helpers/pagination_helper.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +module PaginationHelper + DEFAULT_PER_PAGE = 20 + + # Paginates an array and returns the current page slice + def paginate_array(collection, page:, per_page: DEFAULT_PER_PAGE) + page = [page.to_i, 1].max + total = collection.size + total_pages = (total / per_page.to_f).ceil + page = [page, total_pages].min if total_pages.positive? + + offset = (page - 1) * per_page + items = collection[offset, per_page] || [] + + PaginationResult.new( + items: items, current_page: page, total_pages: total_pages, + total_count: total, per_page: per_page + ) + end + + class PaginationResult + attr_reader :items, :current_page, :total_pages, :total_count, :per_page + + def initialize(items:, current_page:, total_pages:, total_count:, per_page:) + @items = items + @current_page = current_page + @total_pages = total_pages + @total_count = total_count + @per_page = per_page + end + + def first_page? + current_page <= 1 + end + + def last_page? + current_page >= total_pages + end + + def paginated? + total_pages > 1 + end + + def previous_page + current_page - 1 unless first_page? + end + + def next_page + current_page + 1 unless last_page? + end + + # Returns an array of page numbers with ellipsis markers (nil) for gaps + def page_range(window: 2) + return (1..total_pages).to_a if total_pages <= (window * 2) + 5 + + pages = [] + pages << 1 + + left = [current_page - window, 2].max + right = [current_page + window, total_pages - 1].min + + pages << nil if left > 2 + (left..right).each { |p| pages << p } + pages << nil if right < total_pages - 1 + + pages << total_pages + pages + end + end +end diff --git a/app/javascript/controllers/mods_controller.js b/app/javascript/controllers/mods_controller.js index a9f54cc..63ba14c 100644 --- a/app/javascript/controllers/mods_controller.js +++ b/app/javascript/controllers/mods_controller.js @@ -37,7 +37,7 @@ export default class extends Controller { clearTimeout(this.timeout) this.timeout = setTimeout(() => { event.target.form.requestSubmit(); - }, 200) + }, 400) } submit(event) { diff --git a/app/javascript/controllers/theme_controller.js b/app/javascript/controllers/theme_controller.js new file mode 100644 index 0000000..174fb30 --- /dev/null +++ b/app/javascript/controllers/theme_controller.js @@ -0,0 +1,29 @@ +import { Controller } from "@hotwired/stimulus" + +// Manages dark/light theme toggle with localStorage persistence. +// Falls back to OS preference when no saved preference exists. +export default class extends Controller { + static targets = ["icon"] + + connect() { + this.updateIcon() + } + + toggle() { + const html = document.documentElement + if (html.classList.contains("dark")) { + html.classList.remove("dark") + localStorage.setItem("theme", "light") + } else { + html.classList.add("dark") + localStorage.setItem("theme", "dark") + } + this.updateIcon() + } + + updateIcon() { + if (!this.hasIconTarget) return + const isDark = document.documentElement.classList.contains("dark") + this.iconTarget.innerHTML = isDark ? "\uD83C\uDF19" : "\u2600\uFE0F" + } +} diff --git a/app/models/concerns/displayable.rb b/app/models/concerns/displayable.rb index 14dc06e..5abff8b 100644 --- a/app/models/concerns/displayable.rb +++ b/app/models/concerns/displayable.rb @@ -19,6 +19,8 @@ def details end def author_slug + return "unknown" unless author + author.parameterize end diff --git a/app/models/concerns/firestorable.rb b/app/models/concerns/firestorable.rb index 92ebfb1..e1494b5 100644 --- a/app/models/concerns/firestorable.rb +++ b/app/models/concerns/firestorable.rb @@ -7,24 +7,26 @@ module Firestorable included do def self.firestore - credentials = Rails.application.credentials.firebase_keyfile + @firestore ||= begin + credentials = Rails.application.credentials.firebase_keyfile - if credentials.nil? - raise <<~ERROR - Firebase credentials not configured. Please add firebase_keyfile to your Rails credentials. + if credentials.nil? + raise <<~ERROR + Firebase credentials not configured. Please add firebase_keyfile to your Rails credentials. - To configure credentials, run: - EDITOR=nano rails credentials:edit + To configure credentials, run: + EDITOR=nano rails credentials:edit - Then add: - firebase_keyfile: - type: service_account - project_id: your-project-id - # ... other Firebase credentials - ERROR - end + Then add: + firebase_keyfile: + type: service_account + project_id: your-project-id + # ... other Firebase credentials + ERROR + end - Google::Cloud::Firestore.new(credentials: credentials.to_h) + Google::Cloud::Firestore.new(credentials: credentials.to_h) + end end end end diff --git a/app/models/tool.rb b/app/models/tool.rb index 1db43fb..06c3344 100644 --- a/app/models/tool.rb +++ b/app/models/tool.rb @@ -43,7 +43,9 @@ def self.expire_cache end def filename - url.split("/").last + return if url.blank? + + url.split("?").first.split("/").last end def name_slug diff --git a/app/views/layouts/_header.html.erb b/app/views/layouts/_header.html.erb index 369e3e1..3ae9325 100644 --- a/app/views/layouts/_header.html.erb +++ b/app/views/layouts/_header.html.erb @@ -3,7 +3,7 @@ <%= image_tag "daedalus-logo.png", width: "64", alt: "Daedalus Logo" %> <%= link_to "Project Daedalus", home_path, class: "drop-shadow-md text-2xl font-bold leading-7 no-underline text-icarus-500 sm:truncate sm:text-4xl sm:py-1 sm:tracking-tight" %> -
+
@@ -13,5 +13,10 @@ +
+ +
diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index 022e8e6..4898c3b 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -15,6 +15,21 @@ <%= stylesheet_link_tag "tailwind", "inter-font", "data-turbo-track": "reload" %> <%= stylesheet_link_tag "application", "data-turbo-track": "reload" %> <%= javascript_importmap_tags %> + @@ -27,5 +42,6 @@ <%= yield %> + diff --git a/app/views/mods/_mod.html.erb b/app/views/mods/_mod.html.erb index e9482c0..2832aa5 100644 --- a/app/views/mods/_mod.html.erb +++ b/app/views/mods/_mod.html.erb @@ -1,16 +1,16 @@ - <%= mod.name %> - + data-mods-path-param="<%= mod_detail_path(author: mod.author_slug, slug: mod.slug) %>"> + <%= mod.name %> + <% if type = mod.preferred_type %> <% end %> @@ -26,4 +26,4 @@ <% end %> <%= truncate(mod.description.to_s, length: 120) %> - \ No newline at end of file + diff --git a/app/views/mods/_mods.html.erb b/app/views/mods/_mods.html.erb index 40b7b2d..5147fe1 100644 --- a/app/views/mods/_mods.html.erb +++ b/app/views/mods/_mods.html.erb @@ -1,12 +1,15 @@ <%= turbo_frame_tag "mods" do %>
-
+
+
<%= @total_mods %> mods<%= " (page #{@pagination.current_page} of #{@pagination.total_pages})" if @pagination&.paginated? %>
+
+
- - - + + + @@ -23,5 +26,6 @@
NameDownloadNameDownload
+ <%= render "pagination" %>
-<% end %> \ No newline at end of file +<% end %> diff --git a/app/views/mods/_pagination.html.erb b/app/views/mods/_pagination.html.erb new file mode 100644 index 0000000..d4cd799 --- /dev/null +++ b/app/views/mods/_pagination.html.erb @@ -0,0 +1,49 @@ +<% if @pagination&.paginated? %> + +<% end %> diff --git a/config/environments/production.rb b/config/environments/production.rb index 391e08d..d790609 100644 --- a/config/environments/production.rb +++ b/config/environments/production.rb @@ -20,7 +20,7 @@ # Ensures that a master key has been made available in either ENV["RAILS_MASTER_KEY"] # or in config/master.key. This key is used to decrypt credentials (and other encrypted files). - # config.require_master_key = true + config.require_master_key = true # Disable serving static files from the `/public` folder by default since # Apache or NGINX already handles this. diff --git a/config/initializers/content_security_policy.rb b/config/initializers/content_security_policy.rb index 53538c1..322c5f4 100644 --- a/config/initializers/content_security_policy.rb +++ b/config/initializers/content_security_policy.rb @@ -1,27 +1,25 @@ # frozen_string_literal: true -# Be sure to restart your server when you modify this file. - # Define an application-wide content security policy. # See the Securing Rails Applications Guide for more information: # https://guides.rubyonrails.org/security.html#content-security-policy-header -# Rails.application.configure do -# config.content_security_policy do |policy| -# policy.default_src :self, :https -# policy.font_src :self, :https, :data -# policy.img_src :self, :https, :data -# policy.object_src :none -# policy.script_src :self, :https -# policy.style_src :self, :https -# # Specify URI for violation reports -# # policy.report_uri "/csp-violation-report-endpoint" -# end -# -# # Generate session nonces for permitted importmap and inline scripts -# config.content_security_policy_nonce_generator = ->(request) { request.session.id.to_s } -# config.content_security_policy_nonce_directives = %w(script-src) -# -# # Report violations without enforcing the policy. -# # config.content_security_policy_report_only = true -# end +Rails.application.configure do + config.content_security_policy do |policy| + policy.default_src :self, :https + policy.font_src :self, :https, :data + policy.img_src :self, :https, :data + policy.object_src :none + policy.script_src :self, :https + policy.style_src :self, :https, :unsafe_inline + policy.connect_src :self, :https + end + + # Generate session nonces for permitted importmap and inline scripts + config.content_security_policy_nonce_generator = ->(request) { request.session.id.to_s } + config.content_security_policy_nonce_directives = %w[script-src] + + # Report violations without enforcing the policy initially. + # Remove this line once the policy has been verified in production. + config.content_security_policy_report_only = true +end diff --git a/config/tailwind.config.js b/config/tailwind.config.js index 913cb8b..2beaa15 100644 --- a/config/tailwind.config.js +++ b/config/tailwind.config.js @@ -1,6 +1,7 @@ const defaultTheme = require("tailwindcss/defaultTheme") module.exports = { + darkMode: "class", content: [ "./public/*.html", "./app/helpers/**/*.rb", diff --git a/spec/helpers/pagination_helper_spec.rb b/spec/helpers/pagination_helper_spec.rb new file mode 100644 index 0000000..5308667 --- /dev/null +++ b/spec/helpers/pagination_helper_spec.rb @@ -0,0 +1,123 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe PaginationHelper do + include described_class + + let(:items) { (1..50).to_a } + + describe "#paginate_array" do + it "returns the first page by default" do + result = paginate_array(items, page: 1) + expect(result.items).to eq((1..20).to_a) + expect(result.current_page).to eq(1) + expect(result.total_pages).to eq(3) + expect(result.total_count).to eq(50) + end + + it "returns the correct page slice" do + result = paginate_array(items, page: 2) + expect(result.items).to eq((21..40).to_a) + expect(result.current_page).to eq(2) + end + + it "returns the last page with remaining items" do + result = paginate_array(items, page: 3) + expect(result.items).to eq((41..50).to_a) + end + + it "clamps page to 1 when given 0 or negative" do + result = paginate_array(items, page: 0) + expect(result.current_page).to eq(1) + + result = paginate_array(items, page: -5) + expect(result.current_page).to eq(1) + end + + it "clamps page to last page when exceeding total" do + result = paginate_array(items, page: 999) + expect(result.current_page).to eq(3) + expect(result.items).to eq((41..50).to_a) + end + + it "respects custom per_page" do + result = paginate_array(items, page: 1, per_page: 10) + expect(result.items).to eq((1..10).to_a) + expect(result.total_pages).to eq(5) + end + + it "handles empty collection" do + result = paginate_array([], page: 1) + expect(result.items).to eq([]) + expect(result.total_pages).to eq(0) + expect(result.total_count).to eq(0) + end + end + + describe described_class::PaginationResult do + subject(:result) do + described_class.new( + items: [], current_page: current_page, total_pages: 5, total_count: 100, per_page: 20 + ) + end + + context "when on the first page" do + let(:current_page) { 1 } + + it { is_expected.to be_first_page } + it { is_expected.not_to be_last_page } + it { is_expected.to be_paginated } + it { expect(result.previous_page).to be_nil } + it { expect(result.next_page).to eq(2) } + end + + context "when on a middle page" do + let(:current_page) { 3 } + + it { is_expected.not_to be_first_page } + it { is_expected.not_to be_last_page } + it { expect(result.previous_page).to eq(2) } + it { expect(result.next_page).to eq(4) } + end + + context "when on the last page" do + let(:current_page) { 5 } + + it { is_expected.not_to be_first_page } + it { is_expected.to be_last_page } + it { expect(result.previous_page).to eq(4) } + it { expect(result.next_page).to be_nil } + end + + context "with a single page" do + subject(:result) do + described_class.new( + items: [], current_page: 1, total_pages: 1, total_count: 5, per_page: 20 + ) + end + + it { is_expected.not_to be_paginated } + end + + describe "#page_range" do + it "returns all pages when total is small" do + result = described_class.new( + items: [], current_page: 1, total_pages: 5, total_count: 100, per_page: 20 + ) + expect(result.page_range).to eq([1, 2, 3, 4, 5]) + end + + it "includes ellipsis for large page counts" do + result = described_class.new( + items: [], current_page: 10, total_pages: 20, total_count: 400, per_page: 20 + ) + range = result.page_range + expect(range.first).to eq(1) + expect(range.last).to eq(20) + expect(range).to include(nil) # ellipsis markers + expect(range).to include(10) # current page + end + end + end +end diff --git a/spec/support/firestore.rb b/spec/support/firestore.rb index f1859cc..1f7d8a9 100644 --- a/spec/support/firestore.rb +++ b/spec/support/firestore.rb @@ -15,4 +15,17 @@ ) end end + + # Reset memoized Firestore clients after each test to prevent + # RSpec test doubles from leaking across examples via the + # class-level @firestore ||= memoization in Firestorable. + config.after do + [Mod, Tool].each do |klass| + klass.instance_variable_set(:@firestore, nil) if klass.instance_variable_defined?(:@firestore) + end + if defined?(SiteContent) && SiteContent.respond_to?(:instance_variable_set) && + SiteContent.instance_variable_defined?(:@firestore) + SiteContent.instance_variable_set(:@firestore, nil) + end + end end diff --git a/spec/views/mods/_mod.html.erb_spec.rb b/spec/views/mods/_mod.html.erb_spec.rb index 5e5a94b..2afc838 100644 --- a/spec/views/mods/_mod.html.erb_spec.rb +++ b/spec/views/mods/_mod.html.erb_spec.rb @@ -51,8 +51,8 @@ it "includes correct mod_detail_path in data attribute" do render partial: "mods/mod", locals: { mod: mod } - # Path uses mod.author (which gets URL encoded), not author_slug - expect(rendered).to include('data-mods-path-param="/mods/Test%20Author/test-mod"') + # Path uses mod.author_slug (parameterized) for cleaner URLs + expect(rendered).to include('data-mods-path-param="/mods/test-author/test-mod"') end it "triggers download Stimulus action on button click" do