Skip to content

Commit 2dff37a

Browse files
Add rich reference previews and back matter (#177)
* Add inline reference previews Connect footnote citations and explicit section links to instant hover and click previews, and document the authoring contract for agents. Amp-Thread-ID: https://ampcode.com/threads/T-01a00633-0812-75de-9a33-a2283280078f Co-authored-by: Amp <amp@ampcode.com> * Stop section previews before footnotes Amp-Thread-ID: https://ampcode.com/threads/T-01a00633-0812-75de-9a33-a2283280078f Co-authored-by: Amp <amp@ampcode.com> * Unify reference navigation and display Let reference clicks jump directly to their targets, keep previews on hover and focus, and avoid repeating auto-extracted citation links in back matter. Amp-Thread-ID: https://ampcode.com/threads/T-01a00633-0812-75de-9a33-a2283280078f Co-authored-by: Amp <amp@ampcode.com> * Restore top-layer reference previews Keep hover cards clear of the document's overflow boundary while retaining direct click navigation to each reference target. Amp-Thread-ID: https://ampcode.com/threads/T-01a00633-0812-75de-9a33-a2283280078f Co-authored-by: Amp <amp@ampcode.com> * Open external plan links in new tabs Amp-Thread-ID: https://ampcode.com/threads/T-01a00633-0812-75de-9a33-a2283280078f Co-authored-by: Amp <amp@ampcode.com> * Simplify reference preview cards Amp-Thread-ID: https://ampcode.com/threads/T-01a00633-0812-75de-9a33-a2283280078f Co-authored-by: Amp <amp@ampcode.com> * Show source identity in citation previews Use the structured reference classifier to display each source title, type, and domain, and clarify that explicit entries are additional resources. Amp-Thread-ID: https://ampcode.com/threads/T-01a00633-0812-75de-9a33-a2283280078f Co-authored-by: Amp <amp@ampcode.com> * Unify citations with structured references Amp-Thread-ID: https://ampcode.com/threads/T-01a00633-0812-75de-9a33-a2283280078f Co-authored-by: Amp <amp@ampcode.com> * Refine unified reference design Amp-Thread-ID: https://ampcode.com/threads/T-01a00633-0812-75de-9a33-a2283280078f Co-authored-by: Amp <amp@ampcode.com> --------- Co-authored-by: Amp <amp@ampcode.com>
1 parent 35a689f commit 2dff37a

22 files changed

Lines changed: 1288 additions & 117 deletions

engine/app/assets/stylesheets/coplan/application.css

Lines changed: 292 additions & 44 deletions
Large diffs are not rendered by default.

engine/app/controllers/coplan/plans_controller.rb

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,9 @@ def show
149149
.order(:created_at)
150150
@my_folders = current_user.library.folders.order(:name).to_a
151151
@threads = @plan.comment_threads.with_kept_comments.includes(:comments, :created_by_user).order(:created_at)
152+
# The reader view joins auto-extracted resources to their Markdown
153+
# citations by URL, then lists the remaining resources in the same
154+
# References section.
152155
@references = @plan.references.order(reference_type: :asc, created_at: :desc)
153156
@attachments = @plan.attachments_attachments.includes(:blob).order(created_at: :desc)
154157
# Order matters: compute the one-time "changed since you last looked"

engine/app/controllers/coplan/references_controller.rb

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -79,21 +79,33 @@ def set_plan
7979

8080
def render_references_stream
8181
references = @plan.references.reload.order(reference_type: :asc, created_at: :desc)
82+
back_matter = helpers.plan_citation_back_matter(@plan, references)
83+
listed_references = helpers.listed_plan_references(references, back_matter[:cited_urls])
84+
reference_count = back_matter[:count] + listed_references.size
8285
render turbo_stream: [
86+
turbo_stream.replace(
87+
"plan-citations",
88+
partial: "coplan/plans/citations",
89+
locals: { plan: @plan, back_matter: back_matter }
90+
),
8391
turbo_stream.replace(
8492
"plan-references",
8593
partial: "coplan/plans/references",
86-
locals: { references: references, plan: @plan }
94+
locals: {
95+
references: listed_references,
96+
plan: @plan,
97+
has_citations: back_matter[:count].positive?
98+
}
8799
),
88100
turbo_stream.replace(
89101
"references-count",
90-
html: helpers.content_tag(:span, references.size, class: "section-count", id: "references-count")
102+
html: helpers.content_tag(:span, reference_count, class: "section-count", id: "references-count")
91103
),
92104
# The document outline shows the same count; without this it goes
93105
# stale the moment a reference is added or removed.
94106
turbo_stream.replace(
95107
"nav-references-count",
96-
html: helpers.content_tag(:span, references.size, class: "section-count", id: "nav-references-count")
108+
html: helpers.content_tag(:span, reference_count, class: "section-count", id: "nav-references-count")
97109
)
98110
]
99111
end

engine/app/helpers/coplan/markdown_helper.rb

Lines changed: 101 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ module MarkdownHelper
1616
section
1717
].freeze
1818

19-
ALLOWED_ATTRIBUTES = %w[id class lang href src alt title type checked disabled open aria-label data-line data-line-text data-action data-mention-username data-sourcepos data-footnotes data-footnote-ref data-footnote-backref data-footnote-backref-idx].freeze
19+
ALLOWED_ATTRIBUTES = %w[id class lang href src alt title target rel type checked disabled open aria-label aria-haspopup aria-expanded data-line data-line-text data-action data-mention-username data-sourcepos data-reference-type data-footnotes data-footnote-ref data-footnote-backref data-footnote-backref-idx].freeze
2020

2121
# Commonmarker extensions beyond the gem defaults (tables, tasklist,
2222
# strikethrough, autolink stay on). Footnotes: `[^1]` in text plus a
@@ -34,7 +34,7 @@ module MarkdownHelper
3434
# version. Bump it whenever the rendering pipeline changes output for the
3535
# same input (new tags, attribute changes, checkbox wiring, etc.), or
3636
# stale HTML will be served from cache.
37-
RENDER_CACHE_VERSION = 2
37+
RENDER_CACHE_VERSION = 8
3838

3939
# Matches `[@username](mention:username)` where the bracket text and link
4040
# target encode the same username. Username allows letters, digits, dots,
@@ -46,16 +46,20 @@ module MarkdownHelper
4646
# one markdown fragment (e.g. each comment) — commonmarker numbers
4747
# footnote ids from #fn-1 per document, so unprefixed fragments collide
4848
# and reference/backref links jump to the wrong footnote.
49-
def render_markdown(content, interactive: true, footnote_prefix: nil)
49+
def render_markdown(content, interactive: true, footnote_prefix: nil, footnotes: :inline)
5050
render_options = { unsafe: true }
5151
# Sourcepos is only needed to wire checkboxes to their source lines;
5252
# make_checkboxes_interactive strips it from the final output.
5353
render_options[:sourcepos] = true if interactive
5454
html = Commonmarker.to_html(content.to_s.encode("UTF-8"), options: { extension: EXTENSION_OPTIONS, render: render_options }, plugins: { syntax_highlighter: nil })
5555
with_chips = transform_mention_anchors(html)
56-
sanitized = sanitize(with_chips, tags: ALLOWED_TAGS, attributes: ALLOWED_ATTRIBUTES)
56+
with_references = transform_reference_anchors(with_chips, numbered_sections: footnote_prefix.nil?)
57+
sanitized = sanitize(with_references, tags: ALLOWED_TAGS, attributes: ALLOWED_ATTRIBUTES)
5758
result = interactive ? make_checkboxes_interactive(sanitized, content) : sanitized
5859
result = scope_footnote_ids(result, footnote_prefix) if footnote_prefix
60+
result = select_footnotes(result, footnotes)
61+
return result.html_safe if footnotes == :only
62+
5963
tag.div(result.html_safe, class: "markdown-rendered", data: { controller: "coplan--mermaid coplan--syntax-highlight" })
6064
end
6165

@@ -80,13 +84,106 @@ def transform_mention_anchors(html)
8084
doc.to_html
8185
end
8286

87+
# Numbered headings get stable fragments so agents can write explicit,
88+
# unambiguous same-document references such as
89+
# `[§3.1](#section-3-1)`. Plain `§3.1` text is deliberately not linked:
90+
# research plans also cite external laws as `RKSV §3.1`, where guessing
91+
# that the target is a CoPlan heading would create incorrect links.
92+
#
93+
# Footnote citations and valid numbered-section links opt into the shared
94+
# reference-preview controller. It reads their already-rendered targets,
95+
# so previews require no duplicate citation data or network request.
96+
def transform_reference_anchors(html, numbered_sections: true)
97+
doc = Nokogiri::HTML::DocumentFragment.parse(html)
98+
used_ids = doc.css("[id]").filter_map { |node| node["id"].presence }.to_set
99+
section_ids = Set.new
100+
101+
doc.css("a[href]").each do |anchor|
102+
next unless anchor["href"].match?(%r{\Ahttps?://}i)
103+
104+
anchor["target"] = "_blank"
105+
anchor["rel"] = "noopener noreferrer"
106+
anchor["data-reference-type"] = Reference.classify_url(anchor["href"])
107+
end
108+
109+
if numbered_sections
110+
doc.css("h1, h2, h3, h4, h5, h6").each do |heading|
111+
section_number = heading.text.squish[/\A(\d+(?:\.\d+)*)\b/, 1]
112+
next unless section_number
113+
114+
heading["id"] ||= unique_dom_id("section-#{section_number.tr('.', '-')}", used_ids)
115+
used_ids << heading["id"]
116+
section_ids << heading["id"]
117+
end
118+
end
119+
120+
doc.css("a[data-footnote-ref]").each do |anchor|
121+
enhance_reference_anchor(anchor, type: "footnote")
122+
end
123+
124+
doc.css('a[href^="#"]').each do |anchor|
125+
next if anchor["data-footnote-ref"] || anchor["data-footnote-backref"]
126+
127+
target_id = anchor["href"].delete_prefix("#")
128+
enhance_reference_anchor(anchor, type: "section") if section_ids.include?(target_id)
129+
end
130+
131+
doc.css("section[data-footnotes]").each do |section|
132+
heading = Nokogiri::XML::Node.new("h2", doc)
133+
heading["class"] = "footnotes-title"
134+
heading.content = "References"
135+
section.prepend_child(heading)
136+
end
137+
138+
doc.to_html
139+
end
140+
83141
def markdown_to_plain_text(content)
84142
html = Commonmarker.to_html(content.to_s.encode("UTF-8"), options: { extension: EXTENSION_OPTIONS }, plugins: { syntax_highlighter: nil })
85143
Nokogiri::HTML::DocumentFragment.parse(html).text.squish
86144
end
87145

88146
private
89147

148+
REFERENCE_PREVIEW_ACTIONS = [
149+
"mouseenter->coplan--reference-preview#enter",
150+
"mouseleave->coplan--reference-preview#leave",
151+
"focus->coplan--reference-preview#enter",
152+
"blur->coplan--reference-preview#leave",
153+
"click->coplan--reference-preview#follow"
154+
].join(" ").freeze
155+
156+
def unique_dom_id(base, used_ids)
157+
return base unless used_ids.include?(base)
158+
159+
suffix = 2
160+
suffix += 1 while used_ids.include?("#{base}-#{suffix}")
161+
"#{base}-#{suffix}"
162+
end
163+
164+
def enhance_reference_anchor(anchor, type:)
165+
anchor.add_class("reference-anchor")
166+
anchor.add_class("reference-anchor--#{type}")
167+
anchor["aria-haspopup"] = "dialog"
168+
anchor["aria-expanded"] = "false"
169+
anchor["data-action"] = [ anchor["data-action"], REFERENCE_PREVIEW_ACTIONS ].compact.join(" ")
170+
end
171+
172+
def select_footnotes(html, mode)
173+
return html if mode == :inline
174+
175+
doc = Nokogiri::HTML::DocumentFragment.parse(html)
176+
sections = doc.css("section[data-footnotes]")
177+
if mode == :only
178+
sections.map(&:to_html).join
179+
elsif mode == :exclude
180+
sections.remove
181+
doc.to_html
182+
else
183+
raise ArgumentError, "unknown footnote mode: #{mode.inspect}"
184+
end
185+
end
186+
90187
# Wires rendered task checkboxes to their source lines via Commonmarker's
91188
# sourcepos metadata, so the parser that decides what renders as a
92189
# checkbox is also the authority on which line it came from. A checkbox

engine/app/helpers/coplan/references_helper.rb

Lines changed: 139 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,145 @@
11
module CoPlan
22
module ReferencesHelper
3-
def reference_icon(reference_type)
3+
include MarkdownHelper
4+
5+
def reference_domain(url)
6+
URI.parse(url).host&.delete_prefix("www.")
7+
rescue URI::InvalidURIError
8+
nil
9+
end
10+
11+
def reference_type_label(reference_type, url)
12+
domain = reference_domain(url)
13+
414
case reference_type
5-
when "plan" then "📋"
6-
when "repository" then "📦"
7-
when "pull_request" then "🔀"
8-
when "document" then "📄"
9-
else "🔗"
15+
when "plan" then "CoPlan plan"
16+
when "repository" then "GitHub repository"
17+
when "pull_request" then "GitHub pull request"
18+
when "document"
19+
return "Google Sheet" if domain == "docs.google.com" && URI.parse(url).path.start_with?("/spreadsheets/")
20+
return "Google Slides" if domain == "docs.google.com" && URI.parse(url).path.start_with?("/presentation/")
21+
return "Google Doc" if domain == "docs.google.com"
22+
return "Google Drive" if domain == "drive.google.com"
23+
return "Notion" if domain&.end_with?("notion.so", "notion.site")
24+
return "Confluence" if domain&.include?("confluence")
25+
26+
"Document"
27+
else
28+
domain&.match?(/(^|\.)gov(\.|$)/) ? "Government website" : "Website"
29+
end
30+
rescue URI::InvalidURIError
31+
reference_type.humanize
32+
end
33+
34+
# Markdown owns a citation's placement and explanatory text; Reference
35+
# owns the linked resource's durable identity. Join those two projections
36+
# by URL for one reader-facing References section without duplicating the
37+
# versioned plan content in another model.
38+
def plan_citation_back_matter(plan, references)
39+
references = references.to_a
40+
reference_digest = Digest::SHA256.hexdigest(
41+
references.map { |reference| "#{reference.id}:#{reference.updated_at&.to_f}" }.join("|")
42+
)
43+
signature = [ plan.current_revision, reference_digest ]
44+
cached = Rails.cache.fetch([
45+
"coplan/plan-citation-back-matter",
46+
MarkdownHelper::RENDER_CACHE_VERSION,
47+
plan.id,
48+
*signature
49+
]) do
50+
result = build_plan_citation_back_matter(plan, references)
51+
[ result[:html].to_s, result[:cited_urls].to_a, result[:count] ]
52+
end
53+
54+
{
55+
html: cached[0].html_safe,
56+
cited_urls: cached[1].to_set.freeze,
57+
count: cached[2]
58+
}
59+
end
60+
61+
def listed_plan_references(references, cited_urls)
62+
references.reject { |reference| cited_urls.include?(reference.url) }
63+
end
64+
65+
def plan_reference_count(plan, references)
66+
back_matter = plan_citation_back_matter(plan, references)
67+
back_matter[:count] + listed_plan_references(references, back_matter[:cited_urls]).size
68+
end
69+
70+
private
71+
72+
def build_plan_citation_back_matter(plan, references)
73+
html = render_markdown(plan.current_content, footnotes: :only)
74+
doc = Nokogiri::HTML::DocumentFragment.parse(html)
75+
sections = doc.css("section[data-footnotes]")
76+
return { html: "".html_safe, cited_urls: Set.new.freeze, count: 0 } if sections.empty?
77+
78+
references_by_url = references.index_by(&:url)
79+
cited_urls = Set.new
80+
81+
sections.each do |section|
82+
section["class"] = "reference-citations"
83+
section.css(".footnotes-title").remove
84+
section.css('a[href^="http://"], a[href^="https://"]').each do |anchor|
85+
reference = references_by_url[anchor["href"]]
86+
cited_urls << anchor["href"]
87+
decorate_citation_source(anchor, reference)
88+
end
89+
end
90+
91+
{
92+
html: sections.map(&:to_html).join.html_safe,
93+
cited_urls: cited_urls.freeze,
94+
count: sections.sum { |section| section.xpath("./ol/li").size }
95+
}
96+
end
97+
98+
def decorate_citation_source(anchor, reference)
99+
type = reference&.reference_type || anchor["data-reference-type"] || Reference.classify_url(anchor["href"])
100+
domain = reference_domain(anchor["href"])
101+
title = reference&.title.presence || anchor.text.squish.presence || domain || anchor["href"]
102+
metadata = [ reference_type_label(type, anchor["href"]), domain ].compact.join(" · ")
103+
104+
anchor.add_class("citation-source")
105+
anchor["aria-label"] = "Open source: #{title} in a new tab (#{metadata})"
106+
unless terminal_citation_source?(anchor)
107+
anchor.add_class("citation-source--inline")
108+
anchor["title"] = metadata
109+
return
110+
end
111+
112+
anchor.add_class("citation-source--block")
113+
anchor.children.remove
114+
115+
content = Nokogiri::XML::Node.new("span", anchor.document)
116+
content["class"] = "citation-source__content"
117+
118+
title_node = Nokogiri::XML::Node.new("span", anchor.document)
119+
title_node["class"] = "citation-source__title"
120+
title_node.content = title
121+
122+
metadata_node = Nokogiri::XML::Node.new("span", anchor.document)
123+
metadata_node["class"] = "citation-source__meta"
124+
metadata_node.content = "#{metadata} ↗"
125+
126+
content.add_child(title_node)
127+
content.add_child(metadata_node)
128+
anchor.add_child(content)
129+
130+
punctuation = anchor.next_sibling
131+
if punctuation&.text? && punctuation.text.match?(/\A\s*[.,;:]\s*\z/)
132+
punctuation.remove
133+
end
134+
end
135+
136+
def terminal_citation_source?(anchor)
137+
anchor.xpath("following-sibling::node()").all? do |sibling|
138+
if sibling.text?
139+
sibling.text.match?(/\A\s*[.,;:]*\s*\z/)
140+
else
141+
sibling.attribute("data-footnote-backref").present?
142+
end
10143
end
11144
end
12145
end

0 commit comments

Comments
 (0)