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
2 changes: 1 addition & 1 deletion engine/app/helpers/coplan/markdown_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,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 = 28
RENDER_CACHE_VERSION = 29

# Matches `[@username](mention:username)` where the bracket text and link
# target encode the same username. Username allows letters, digits, dots,
Expand Down
7 changes: 7 additions & 0 deletions engine/app/helpers/coplan/plans_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Comment thread
HamptonMakes marked this conversation as resolved.
Resolved
# 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
Expand Down
34 changes: 34 additions & 0 deletions engine/app/javascript/controllers/coplan/editor_controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -956,6 +957,39 @@ 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()
// 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}` }
}
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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -250,7 +250,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
}
Expand Down
12 changes: 12 additions & 0 deletions spec/helpers/plans_helper_spec.rb
Original file line number Diff line number Diff line change
@@ -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) }

Expand Down
29 changes: 29 additions & 0 deletions spec/requests/plan_content_editing_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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(/<span[^>]*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)

Expand Down
51 changes: 51 additions & 0 deletions spec/system/editor_recovery_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,57 @@ 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[: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)
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.")
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

[
[ "# 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
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 })
Expand Down
14 changes: 14 additions & 0 deletions spec/system/references_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading