Skip to content
Merged
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
8 changes: 8 additions & 0 deletions docs/HOST_APP_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,14 @@

Coplan is a Rails engine that manages collaborative planning documents. It owns its own `CoPlan::User` model and handles authentication internally via a callback you configure.

## Rendered-content caching

Rendered plan bodies, comments, previews, and citations use standard Rails
caching with a 15-minute expiry. Content edits change the cache key immediately.
Renderer code changes appear as entries expire; no version bump or release
configuration is required. The host's normal cache store and fragment-caching
settings apply.

## User settings and host integrations

Coplan owns the settings shown on its Settings page. The app theme, light/dark
Expand Down
6 changes: 0 additions & 6 deletions engine/app/helpers/coplan/markdown_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -42,12 +42,6 @@ module MarkdownHelper
# (ordered-list or blockquoted tasks) stay disabled.
TASK_LINE_PATTERN = /\A\s*[*+-]\s+\[[ xX]\]\s/

# Fragment caches of rendered markdown are keyed on content SHA plus this
# version. Bump it whenever the rendering pipeline changes output for the
# same input (new tags, attribute changes, checkbox wiring, etc.), or
# stale HTML will be served from cache.
RENDER_CACHE_VERSION = 32

# Matches `[@username](mention:username)` where the bracket text and link
# target encode the same username. Username allows letters, digits, dots,
# dashes, and underscores. The pattern must round-trip exactly so that
Expand Down
4 changes: 2 additions & 2 deletions engine/app/helpers/coplan/plans_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -159,9 +159,9 @@ def plan_content_preview(plan, limit: 200)
# full Commonmarker + Nokogiri parse per plan without an AI summary.
# The document body itself is fetched only on a cache miss — list pages
# preload just the stub (id + sha), never the MEDIUMTEXT columns.
cache_key = [ "coplan/plan-preview", MarkdownHelper::RENDER_CACHE_VERSION, plan.id,
cache_key = [ "coplan/plan-preview", plan.id,
stub.content_sha256 || plan.current_revision ]
plain = Rails.cache.fetch(cache_key) do
plain = Rails.cache.fetch(cache_key, expires_in: 15.minutes) do
markdown_to_plain_text(PlanVersion.where(id: stub.id).pick(:content_markdown))
end
return nil if plain.blank?
Expand Down
3 changes: 1 addition & 2 deletions engine/app/helpers/coplan/references_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -43,10 +43,9 @@ def plan_citation_back_matter(plan, references)
signature = [ plan.current_revision, reference_digest ]
cached = Rails.cache.fetch([
"coplan/plan-citation-back-matter",
MarkdownHelper::RENDER_CACHE_VERSION,
plan.id,
*signature
]) do
], expires_in: 15.minutes) do
result = build_plan_citation_back_matter(plan, references)
[ result[:html].to_s, result[:cited_urls].to_a, result[:count] ]
end
Expand Down
3 changes: 1 addition & 2 deletions engine/app/views/coplan/comments/_comment.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,7 @@
<span class="comment__meta"><%= time_ago_in_words(comment.created_at) %> ago<% if comment.updated_at > comment.created_at %><span class="comment__edited"> · edited</span><% end %></span>
</div>
<div class="comment__body" data-coplan--comment-actions-target="body">
<%# The record version invalidates edits; the renderer version invalidates cached HTML. %>
<%= cache(["comment-body-v2", CoPlan::MarkdownHelper::RENDER_CACHE_VERSION, comment], skip_digest: true) do %>
<%= cache(["comment-body-v2", comment], expires_in: 15.minutes, skip_digest: true) do %>
<%# footnote_prefix keeps footnote ids unique per comment — the plan
body and every other comment render in the same DOM. Comment
images don't expand: the comments panel and footnote previews
Expand Down
5 changes: 2 additions & 3 deletions engine/app/views/coplan/plans/_content_body.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,7 @@
Cached per content SHA: rendering is Commonmarker + two Nokogiri passes +
sanitize, and this partial is re-rendered on every show AND every
content-mutation broadcast. skip_digest avoids the template-digest cost
on write paths; the explicit RENDER_CACHE_VERSION key handles renderer
changes instead. -%>
<% cache [CoPlan::EmbedDomain.order(:hostname).pluck(:hostname), request&.host, "coplan/plan-content-body-with-title", CoPlan::MarkdownHelper::RENDER_CACHE_VERSION, plan.id, plan.current_plan_version&.content_sha256 || plan.current_revision], skip_digest: true do %>
on write paths; expiry picks up renderer changes within 15 minutes. -%>
<% cache [CoPlan::EmbedDomain.order(:hostname).pluck(:hostname), request&.host, "coplan/plan-content-body-with-title", plan.id, plan.current_plan_version&.content_sha256 || plan.current_revision], expires_in: 15.minutes, skip_digest: true do %>
<%= render_plan_document(plan.current_content) %>
<% end %>
15 changes: 15 additions & 0 deletions spec/helpers/plans_helper_spec.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
require "rails_helper"

RSpec.describe CoPlan::PlansHelper, type: :helper do
include ActiveSupport::Testing::TimeHelpers

after { travel_back }

describe "#render_plan_document" do
before { helper.extend(CoPlan::SlideshowsHelper) }
it "preserves the numbered title fragment alongside the canonical title anchor" do
Expand All @@ -16,6 +20,17 @@
describe "#plan_content_preview" do
let(:plan) { create(:plan, :considering) }

it "reuses previews for 15 minutes" do
allow(Rails).to receive(:cache).and_return(ActiveSupport::Cache::MemoryStore.new)
allow(helper).to receive(:markdown_to_plain_text).and_return("First rendering", "New rendering")

expect(helper.plan_content_preview(plan)).to eq("First rendering")
expect(helper.plan_content_preview(plan)).to eq("First rendering")
travel 15.minutes + 1.second
expect(helper.plan_content_preview(plan)).to eq("New rendering")
expect(helper).to have_received(:markdown_to_plain_text).twice
end

it "strips markdown formatting and returns a plain-text preview" do
set_version_fixture(plan.current_plan_version,
content_markdown: "# Heading\n\nA **bold** intro with [a link](https://example.com)."
Expand Down
16 changes: 16 additions & 0 deletions spec/helpers/references_helper_spec.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
require "rails_helper"

RSpec.describe CoPlan::ReferencesHelper, type: :helper do
include ActiveSupport::Testing::TimeHelpers

after { travel_back }

describe "#plan_citation_back_matter" do
let(:user) { create(:coplan_user) }
let(:plan) do
Expand All @@ -20,6 +24,18 @@
plan
end

it "reuses citations for 15 minutes" do
allow(Rails).to receive(:cache).and_return(ActiveSupport::Cache::MemoryStore.new)
allow(helper).to receive(:render_markdown).and_call_original

original = helper.plan_citation_back_matter(plan, plan.references)
expect(helper.plan_citation_back_matter(plan, plan.references)).to eq(original)
expect(helper).to have_received(:render_markdown).once
travel 15.minutes + 1.second
expect(helper.plan_citation_back_matter(plan, plan.references)).to eq(original)
expect(helper).to have_received(:render_markdown).twice
end

it "joins Markdown citation context to structured source identity" do
reference = plan.references.find_by!(url: "https://docs.google.com/spreadsheets/d/sample")
reference.update!(title: "Rollout experiment results")
Expand Down
15 changes: 10 additions & 5 deletions spec/requests/comments_spec.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
require "rails_helper"

RSpec.describe "Comments", type: :request do
include ActiveSupport::Testing::TimeHelpers

after { travel_back }

let(:alice) { create(:coplan_user, :admin) }
let(:plan) { create(:plan, :considering, created_by_user: alice) }
let(:thread_record) { create(:comment_thread, plan: plan, plan_version: plan.current_plan_version, created_by_user: alice) }
Expand Down Expand Up @@ -61,26 +65,24 @@
ActionController::Base.perform_caching = original_perform_caching
end

it "refreshes cached comment links when the Markdown renderer version changes" do
it "refreshes cached comment links after 15 minutes" do
comment = create(:comment, comment_thread: thread_record, author_id: alice.id,
body_markdown: "[Open chat](codex://threads/123)")
original_caching = CoPlan::ApplicationController.perform_caching
CoPlan::ApplicationController.perform_caching = true
memory = ActiveSupport::Cache::MemoryStore.new
allow(Rails).to receive(:cache).and_return(memory)
allow_any_instance_of(CoPlan::ApplicationController).to receive(:cache_store).and_return(memory)
renderer_version = CoPlan::MarkdownHelper::RENDER_CACHE_VERSION
link_scrubber = CoPlan::LinkScrubber
render_comment = -> { CoPlan::ApplicationController.render(partial: "coplan/comments/comment", locals: { comment: comment }) }

stub_const("CoPlan::MarkdownHelper::RENDER_CACHE_VERSION", renderer_version - 1)
stub_const("CoPlan::LinkScrubber", Rails::HTML::PermitScrubber)
expect(render_comment.call).not_to include('href="codex://threads/123"')
stub_const("CoPlan::LinkScrubber", link_scrubber)
# The old renderer's cached fragment really is still being served.
expect(render_comment.call).not_to include('href="codex://threads/123"')

stub_const("CoPlan::MarkdownHelper::RENDER_CACHE_VERSION", renderer_version)
travel 15.minutes + 1.second
expect(render_comment.call).to include('href="codex://threads/123"')
ensure
CoPlan::ApplicationController.perform_caching = original_caching
Expand All @@ -104,7 +106,10 @@ def update_comment(body = "Revised **answer**")
headers: { "Accept" => "text/vnd.turbo-stream.html" }
end

it "updates the author's comment and broadcasts a requestless replacement" do
it "updates the author's cached comment and broadcasts a requestless replacement" do
allow_any_instance_of(CoPlan::ApplicationController).to receive(:perform_caching).and_return(true)
allow_any_instance_of(CoPlan::ApplicationController).to receive(:cache_store).and_return(ActiveSupport::Cache::MemoryStore.new)
expect(CoPlan::ApplicationController.render(partial: "coplan/comments/comment", locals: { comment: comment })).to include("Original")
allow(CoPlan::Broadcaster).to receive(:replace_to)
expect(CoPlan::Broadcaster).to receive(:replace_to).with(plan,
target: ActionView::RecordIdentifier.dom_id(comment), partial: "coplan/comments/comment", locals: { comment: comment })
Expand Down
37 changes: 21 additions & 16 deletions spec/requests/plan_content_editing_spec.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
require "rails_helper"

RSpec.describe "Plan content editing (web UI)", type: :request do
include ActiveSupport::Testing::TimeHelpers

after { travel_back }

let(:author) { create(:coplan_user) }
let(:other_user) { create(:coplan_user) }
let(:plan) { create(:plan, :considering, created_by_user: author) }
Expand Down Expand Up @@ -28,30 +32,23 @@
end

describe "GET content_body" do
it "invalidates pre-alias rendered fragments without changing the plan" do
it "refreshes stale rendered fragments after 15 minutes without changing the plan" do
set_version_fixture(plan.current_plan_version, content_markdown: "# 1 Overview\n\nOpening text.\n")
cache = ActiveSupport::Cache::MemoryStore.new
allow_any_instance_of(CoPlan::PlansController).to receive(:perform_caching).and_return(true)
allow_any_instance_of(CoPlan::PlansController).to receive(:cache_store).and_return(cache)
written_keys = []
allow(cache).to receive(:write).and_wrap_original do |write, key, *args, **options|
written_keys << key
write.call(key, *args, **options)
# Simulate an older renderer's output, retaining the real cache expiry.
strip_alias = true
allow(cache).to receive(:write).and_wrap_original do |write, key, value, options|
value = value.gsub(/<span[^>]*data-heading-alias[^>]*><\/span>/, "") if strip_alias
write.call(key, value, options)
end
renderer_version = CoPlan::MarkdownHelper::RENDER_CACHE_VERSION

# Simulate a persistent fragment written by the renderer before title aliases.
stub_const("CoPlan::MarkdownHelper::RENDER_CACHE_VERSION", 24)
get content_body_plan_path(plan)
expect(response).to have_http_status(:ok)
fragment_key = written_keys.find { |key| key.to_s.include?("coplan/plan-content-body-with-title") }
expect(fragment_key).to be_present
stale_html = response.body.gsub(/<span[^>]*data-heading-alias[^>]*><\/span>/, "")
cache.write(fragment_key, stale_html)
strip_alias = false
get content_body_plan_path(plan)
expect(Nokogiri::HTML.fragment(response.body).at_css("#section-1")).to be_nil

stub_const("CoPlan::MarkdownHelper::RENDER_CACHE_VERSION", renderer_version)
travel 15.minutes + 1.second
get content_body_plan_path(plan)
expect(response).to have_http_status(:ok)
expect(Nokogiri::HTML.fragment(response.body).at_css("h1#plan-title #section-1")).to be_present
Expand Down Expand Up @@ -92,7 +89,12 @@
end

describe "PATCH update_content" do
it "creates a new human-authored version through ReplaceContent" do
it "creates a new human-authored version and immediately refreshes cached content" do
allow_any_instance_of(CoPlan::PlansController).to receive(:perform_caching).and_return(true)
allow_any_instance_of(CoPlan::PlansController).to receive(:cache_store).and_return(ActiveSupport::Cache::MemoryStore.new)
get content_body_plan_path(plan)
expect(response.body).to include("Original body.")

expect {
patch update_content_plan_path(plan), params: {
content: "# Plan\n\nEdited body.\n",
Expand All @@ -108,6 +110,9 @@
expect(version.actor_type).to eq("human")
expect(version.actor_id).to eq(author.id)
expect(version.change_summary).to eq("Tightened wording")
get content_body_plan_path(plan)
expect(response.body).to include("Edited body.")
expect(response.body).not_to include("Original body.")
end

it "defaults the change summary" do
Expand Down
Loading