From 65378425527b718a48a8b211669ef7b1f6cd8725 Mon Sep 17 00:00:00 2001 From: Hampton Lintorn-Catlin Date: Fri, 6 Nov 2026 13:11:50 -0600 Subject: [PATCH 1/4] Preserve legacy draft titles and numbered title anchors Signed-off-by: Hampton Lintorn-Catlin --- engine/app/helpers/coplan/plans_helper.rb | 7 ++++ .../controllers/coplan/editor_controller.js | 28 +++++++++++++ .../coplan/reference_preview_controller.js | 3 +- spec/helpers/plans_helper_spec.rb | 12 ++++++ spec/system/editor_recovery_spec.rb | 42 +++++++++++++++++++ spec/system/references_spec.rb | 14 +++++++ 6 files changed, 105 insertions(+), 1 deletion(-) diff --git a/engine/app/helpers/coplan/plans_helper.rb b/engine/app/helpers/coplan/plans_helper.rb index 7436d32c..97a4a35b 100644 --- a/engine/app/helpers/coplan/plans_helper.rb +++ b/engine/app/helpers/coplan/plans_helper.rb @@ -8,6 +8,13 @@ def render_plan_document(content) fragment = Nokogiri::HTML.fragment(render_content_regions(content.to_s)) heading = fragment.at_css(".markdown-rendered:first-child > h1:first-child") if heading + if heading["id"].present? && heading["id"] != "plan-title" + # Numbered-section links and previews must survive title decoration. + alias_anchor = Nokogiri::XML::Node.new("span", fragment) + alias_anchor["id"] = heading["id"] + alias_anchor["data-heading-alias"] = "" + heading.prepend_child(alias_anchor) + end heading["id"] = "plan-title" heading["class"] = [ heading["class"], "plan-title__heading" ].compact.join(" ") end diff --git a/engine/app/javascript/controllers/coplan/editor_controller.js b/engine/app/javascript/controllers/coplan/editor_controller.js index 431f49bf..7fd7fff4 100644 --- a/engine/app/javascript/controllers/coplan/editor_controller.js +++ b/engine/app/javascript/controllers/coplan/editor_controller.js @@ -31,6 +31,7 @@ export default class extends Controller { this.merge = merge this.richModule = rich this.InputOrigins = origins.InputOrigins + this.recoverLegacyNewDocumentTitle(rich) this.inputOrigins = new origins.InputOrigins(this.textareaTarget.value, this.recoveredDraft?.data?.inputOrigins) this.mode = "rich" this.richEditor = rich.createRichDocument(this.surfaceTarget, this.textareaTarget.value, @@ -956,6 +957,33 @@ export default class extends Controller { if (draft.reviewRequired) { this.blocked = true; this.fail("Recovered draft needs review before syncing.") } } catch {} } + recoverLegacyNewDocumentTitle(rich) { + if (!this.isNew || !this.recoveredDraft) return + // Older browser drafts stored the name separately. Preserve canonical H1s + // (including deliberately empty ones) and repair only titleless snapshots. + const normalize = snapshot => { + if (!snapshot || typeof snapshot.content !== "string" || typeof snapshot.title !== "string") return snapshot + const title = snapshot.title.replace(/\s+/g, " ").trim() + const first = rich.parseDocument(snapshot.content).firstChild + if (!title || (first?.type.name === "heading" && first.attrs.level === 1)) return snapshot + const escaped = title.replace(/([\\`*_{}\[\]<>()#!|~&])/g, "\\$1") + return { ...snapshot, content: `# ${escaped}\n\n${snapshot.content}` } + } + const original = this.recoveredDraft.data + const recovered = normalize(original) + this.recoveredDraft.data = recovered + this.textareaTarget.value = recovered.content + this.base = normalize(this.base) + if (this.creationSnapshot) { + const before = this.creationSnapshot.content + this.creationSnapshot = normalize(this.creationSnapshot) + if (this.creationInputSources && before !== this.creationSnapshot.content) { + const origins = new this.InputOrigins(this.creationSnapshot.content, { source: before, runs: this.creationInputSources.runs }) + this.creationInputSources = origins.payload() + } + } + } + async reviewLegacyDraft(event) { if (!this.legacyDraft || !this.richEditor) return const button = event.currentTarget diff --git a/engine/app/javascript/controllers/coplan/reference_preview_controller.js b/engine/app/javascript/controllers/coplan/reference_preview_controller.js index f3dd1b39..05daf579 100644 --- a/engine/app/javascript/controllers/coplan/reference_preview_controller.js +++ b/engine/app/javascript/controllers/coplan/reference_preview_controller.js @@ -249,7 +249,8 @@ export default class extends Controller { if (!href?.startsWith("#") || href.length === 1) return null try { - return document.getElementById(decodeURIComponent(href.slice(1))) + const target = document.getElementById(decodeURIComponent(href.slice(1))) + return target?.hasAttribute("data-heading-alias") ? target.closest("h1, h2, h3, h4, h5, h6") : target } catch { return null } diff --git a/spec/helpers/plans_helper_spec.rb b/spec/helpers/plans_helper_spec.rb index 508272f8..cea91907 100644 --- a/spec/helpers/plans_helper_spec.rb +++ b/spec/helpers/plans_helper_spec.rb @@ -1,6 +1,18 @@ require "rails_helper" RSpec.describe CoPlan::PlansHelper, type: :helper do + describe "#render_plan_document" do + before { helper.extend(CoPlan::SlideshowsHelper) } + it "preserves the numbered title fragment alongside the canonical title anchor" do + html = helper.render_plan_document("# 1 Overview\n\nOpening text. See [overview](#section-1).\n\n## 2 Details\n\nDetails.") + doc = Nokogiri::HTML.fragment(html) + expect(doc.at_css("h1#plan-title")).to be_present + expect(doc.at_css("#section-1")).to be_present + expect(doc.css("[id]").map { |node| node["id"] }.uniq.size).to eq(doc.css("[id]").size) + expect(doc.at_css('a[href="#section-1"]')["class"]).to include("reference-anchor--section") + end + end + describe "#plan_content_preview" do let(:plan) { create(:plan, :considering) } diff --git a/spec/system/editor_recovery_spec.rb b/spec/system/editor_recovery_spec.rb index 6b435fc1..7c357697 100644 --- a/spec/system/editor_recovery_spec.rb +++ b/spec/system/editor_recovery_spec.rb @@ -21,6 +21,48 @@ def rich find('[aria-label="Document body"]', wait: 20) end + [ false, true ].each do |failed_create| + it "recovers the separate legacy title from a #{failed_create ? 'failed-create snapshot' : 'new-document draft'}" do + visit new_plan_path + rich + scope = find('[data-coplan--editor-draft-scope-value]', visible: :all)['data-coplan--editor-draft-scope-value'] + snapshot = { content: "Recovered body.", title: "A *literal* legacy title", tags: "" } + draft = snapshot.merge(base: { content: "", title: "", tags: "", revision: 0 }, revision: 0, creationKey: SecureRandom.uuid) + if failed_create + draft[:creationSnapshot] = snapshot + draft[:content] += " Newer typing." + end + store("coplan-rich-draft-#{author.id}-new-#{scope}-legacy", draft) + page.refresh + expect(rich).to have_css("h1", text: "A *literal* legacy title", exact_text: true) + rich.click + page.driver.browser.action.key_down(mod).send_keys("s").key_up(mod).perform + expect(page).to have_css('[data-coplan--editor-target="statusText"]', text: "All changes saved", wait: 15, visible: :all) + created = author.created_plans.find_by!(title: "A *literal* legacy title") + expect(created.current_content).to include("# A \\*literal\\* legacy title", "Recovered body.") + if failed_create + expect(page).to have_css('[data-coplan--editor-target="statusText"]', text: "All changes saved ยท v2", wait: 15, visible: :all) + expect(created.reload.current_content).to include("Newer typing.") + end + end + end + + [ [ "# Current title", "Current title" ], [ "Current title\n===", "Current title" ], [ "#", "" ] ].each do |heading, title| + it "keeps the recovered new document's canonical heading #{heading.inspect}" do + visit new_plan_path + rich + scope = find('[data-coplan--editor-draft-scope-value]', visible: :all)['data-coplan--editor-draft-scope-value'] + content = "#{heading}\n\nBody." + store("coplan-rich-draft-#{author.id}-new-#{scope}-canonical", { + content: content, title: "Stale metadata", tags: "", revision: 0, + base: { content: "", title: "", tags: "", revision: 0 } + }) + page.refresh + expect(rich).to have_css("h1", count: 1) + expect(find(".ProseMirror > h1:first-child").text).to eq(title) + end + end + [ 1, 0 ].each do |revision| it "retains a legacy revision #{revision} draft while showing a conflict state" do store(legacy_key, { content: "Legacy content", revision: revision }) diff --git a/spec/system/references_spec.rb b/spec/system/references_spec.rb index 1129acd3..65933b20 100644 --- a/spec/system/references_spec.rb +++ b/spec/system/references_spec.rb @@ -20,6 +20,20 @@ expect(page).to have_current_path(root_path) end + it "previews and follows a numbered section link to the document title" do + document = CoPlan::Plans::Create.call(user: user, content: "# 1 Overview\n\nOpening title section text. See [overview](#section-1).\n\n# 2 Details\n\nDifferent section.", actor_type: "human") + visit plan_page_path(document) + expect(page).to have_css("h1#plan-title", text: "1 Overview") + expect(page).to have_css("#plan-title #section-1", visible: :all) + link = find('a.reference-anchor--section[href="#section-1"]') + link.hover + expect(page).to have_css(".reference-preview__title", text: "1 Overview", visible: true) + expect(page).to have_css(".reference-preview__body", text: "Opening title section text", visible: true) + expect(page).to have_no_css(".reference-preview__body", text: "Different section", visible: true) + link.click + expect(page).to have_current_path(/#section-1\z/, url: true) + end + # References live under the document as a footnote section โ€” same page as # the content, no tabs. def open_add_reference_modal From 8f0343bf0288b2f1da0939c9eaae42f8f19001ce Mon Sep 17 00:00:00 2001 From: Hampton Lintorn-Catlin Date: Fri, 6 Nov 2026 13:15:13 -0600 Subject: [PATCH 2/4] Verify recovered creation keeps UTF-16 input attribution Signed-off-by: Hampton Lintorn-Catlin --- spec/system/editor_recovery_spec.rb | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/spec/system/editor_recovery_spec.rb b/spec/system/editor_recovery_spec.rb index 7c357697..2ae2b9dd 100644 --- a/spec/system/editor_recovery_spec.rb +++ b/spec/system/editor_recovery_spec.rb @@ -26,10 +26,11 @@ def rich visit new_plan_path rich scope = find('[data-coplan--editor-draft-scope-value]', visible: :all)['data-coplan--editor-draft-scope-value'] - snapshot = { content: "Recovered body.", title: "A *literal* legacy title", tags: "" } + snapshot = { content: "Recovered ๐Ÿ˜€ body.", title: "A *literal* legacy title", tags: "" } draft = snapshot.merge(base: { content: "", title: "", tags: "", revision: 0 }, revision: 0, creationKey: SecureRandom.uuid) if failed_create draft[:creationSnapshot] = snapshot + draft[:creationInputSources] = { runs: [ [ snapshot[:content].encode(Encoding::UTF_16LE).bytesize / 2, "human" ] ] } draft[:content] += " Newer typing." end store("coplan-rich-draft-#{author.id}-new-#{scope}-legacy", draft) @@ -39,10 +40,15 @@ def rich page.driver.browser.action.key_down(mod).send_keys("s").key_up(mod).perform expect(page).to have_css('[data-coplan--editor-target="statusText"]', text: "All changes saved", wait: 15, visible: :all) created = author.created_plans.find_by!(title: "A *literal* legacy title") - expect(created.current_content).to include("# A \\*literal\\* legacy title", "Recovered body.") + expect(created.current_content).to include("# A \\*literal\\* legacy title", "Recovered ๐Ÿ˜€ body.") if failed_create expect(page).to have_css('[data-coplan--editor-target="statusText"]', text: "All changes saved ยท v2", wait: 15, visible: :all) expect(created.reload.current_content).to include("Newer typing.") + first = created.plan_versions.find_by!(revision: 1) + provenance = first.writing_provenance + start = first.content_markdown.index(snapshot[:content]) + runs = CoPlan::Plans::WritingProvenance.slice(provenance.fetch("runs"), start, start + snapshot[:content].length) + expect(runs.map { |_, key| provenance.fetch("sources").fetch(key).fetch("kind") }).to all(eq("human")) end end end From 32f04e1d459242cceb159d6b4fbc835bfb78e29a Mon Sep 17 00:00:00 2001 From: Hampton Lintorn-Catlin Date: Fri, 6 Nov 2026 13:20:03 -0600 Subject: [PATCH 3/4] Keep canonical recovered titles after leading blank lines Signed-off-by: Hampton Lintorn-Catlin --- .../javascript/controllers/coplan/editor_controller.js | 10 ++++++++-- spec/system/editor_recovery_spec.rb | 5 ++++- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/engine/app/javascript/controllers/coplan/editor_controller.js b/engine/app/javascript/controllers/coplan/editor_controller.js index 7fd7fff4..afcd0ed9 100644 --- a/engine/app/javascript/controllers/coplan/editor_controller.js +++ b/engine/app/javascript/controllers/coplan/editor_controller.js @@ -964,8 +964,14 @@ export default class extends Controller { const normalize = snapshot => { if (!snapshot || typeof snapshot.content !== "string" || typeof snapshot.title !== "string") return snapshot const title = snapshot.title.replace(/\s+/g, " ").trim() - const first = rich.parseDocument(snapshot.content).firstChild - if (!title || (first?.type.name === "heading" && first.attrs.level === 1)) return snapshot + // Blank lines are preserved nodes in the rich parser, but do not precede + // the title semantically. Strip only whole blank lines, never indentation. + const content = snapshot.content.replace(/^(?:[ \t]*\r?\n)+/, "") + const first = rich.parseDocument(content).firstChild + if (first?.type.name === "heading" && first.attrs.level === 1) { + return content === snapshot.content ? snapshot : { ...snapshot, content } + } + if (!title) return snapshot const escaped = title.replace(/([\\`*_{}\[\]<>()#!|~&])/g, "\\$1") return { ...snapshot, content: `# ${escaped}\n\n${snapshot.content}` } } diff --git a/spec/system/editor_recovery_spec.rb b/spec/system/editor_recovery_spec.rb index 2ae2b9dd..6ede958d 100644 --- a/spec/system/editor_recovery_spec.rb +++ b/spec/system/editor_recovery_spec.rb @@ -53,7 +53,10 @@ def rich end end - [ [ "# Current title", "Current title" ], [ "Current title\n===", "Current title" ], [ "#", "" ] ].each do |heading, title| + [ + [ "# Current title", "Current title" ], [ "Current title\n===", "Current title" ], [ "#", "" ], + [ "\n# Current title", "Current title" ], [ " \t\n\nCurrent title\n===", "Current title" ], [ "\n#", "" ] + ].each do |heading, title| it "keeps the recovered new document's canonical heading #{heading.inspect}" do visit new_plan_path rich From 2b7be57e045fbce1e79e1cabc57f12327841a329 Mon Sep 17 00:00:00 2001 From: Hampton Lintorn-Catlin Date: Fri, 6 Nov 2026 23:32:16 -0600 Subject: [PATCH 4/4] Invalidate cached titles for numbered anchors Signed-off-by: Hampton Lintorn-Catlin --- engine/app/helpers/coplan/markdown_helper.rb | 2 +- spec/requests/plan_content_editing_spec.rb | 29 ++++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/engine/app/helpers/coplan/markdown_helper.rb b/engine/app/helpers/coplan/markdown_helper.rb index ff59a2af..959beda3 100644 --- a/engine/app/helpers/coplan/markdown_helper.rb +++ b/engine/app/helpers/coplan/markdown_helper.rb @@ -35,7 +35,7 @@ module MarkdownHelper # 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 = 24 + RENDER_CACHE_VERSION = 25 # Matches `[@username](mention:username)` where the bracket text and link # target encode the same username. Username allows letters, digits, dots, diff --git a/spec/requests/plan_content_editing_spec.rb b/spec/requests/plan_content_editing_spec.rb index 167d64f8..fe68bb5f 100644 --- a/spec/requests/plan_content_editing_spec.rb +++ b/spec/requests/plan_content_editing_spec.rb @@ -28,6 +28,35 @@ end describe "GET content_body" do + it "invalidates pre-alias rendered fragments 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) + 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(/]*data-heading-alias[^>]*><\/span>/, "") + cache.write(fragment_key, stale_html) + 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) + 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 + end + it "returns the latest rendered body and revision without caching" do get content_body_plan_path(plan)