diff --git a/docs/DEMO_LIBRARY.md b/docs/DEMO_LIBRARY.md index fd0a6e4d..e0c75d83 100644 --- a/docs/DEMO_LIBRARY.md +++ b/docs/DEMO_LIBRARY.md @@ -65,6 +65,7 @@ shipped types, including Scratchpad, PRD, Test Plan, and Handoff. | reading-specimen | A realistic design doc for judging reading in both modes: prose, headings, lists, tasks, quote, table, footnote, image, SQL/Ruby/diff/YAML code, a long scrolling line, flowchart and sequence diagrams, plus open, agent-answered, resolved, diagram-node, table-cell, and general discussions | | document-title | One editable title; autosave keeps the URL stable until Done; title boundaries and empty-title behavior | | image-resizing | Resize and restore image widths, wrap prose from the top, Shift+Enter beside, Enter below | +| callout-gallery | `> [!SUMMARY]`, `[!NOTE]`, `[!TIP]`, `[!IMPORTANT]`, `[!WARNING]`, and `[!CAUTION]` callouts, a custom label, nested list and code, plain and escaped quotes, and both deck themes; comment inside a callout, or edit one in Editor and check Raw | | math-gallery | KaTeX math: inline, display, and fenced formulas, math in lists and tables, a six-row slide equation, dollar amounts that stay text, and invalid math shown as source; comment on the text around a formula | Flowcharts currently support source-backed node and edge comments. Other diff --git a/docs/SLIDE_SPEC.md b/docs/SLIDE_SPEC.md index 618aa29d..26f851eb 100644 --- a/docs/SLIDE_SPEC.md +++ b/docs/SLIDE_SPEC.md @@ -307,6 +307,16 @@ pattern: quote step: 1 ``` +A callout (`> [!NOTE]` and the other alert types) is not a quote. It stays +in the reading flow, so the slide falls through to `content`. + +```conformance +> [!NOTE] +> The default output has to be genuinely beautiful with zero effort. +. +pattern: content +``` + ### `table` — scaled tabular typography One table, full width, with an optional short paragraph. @@ -567,6 +577,7 @@ computed from **units** — a deterministic estimate of rendered lines: | heading | 1 | | paragraph | ⌈plain-text length / 60⌉, minimum 1, plus 1 per hard line break, plus 3 per image, plus its display-math rows | | list / list item / blockquote | sum of children, minimum 1 | +| callout (`> [!NOTE]` and the other alert types) | 1 for its label, plus the sum of its children | | code block | ⌈code lines / 1.2⌉, minimum 1 | | media code block (mermaid) | 4 | | math fence | 1 + its display-math rows | diff --git a/engine/app/assets/stylesheets/coplan/foundation.css b/engine/app/assets/stylesheets/coplan/foundation.css index 6e4dd99c..c397b979 100644 --- a/engine/app/assets/stylesheets/coplan/foundation.css +++ b/engine/app/assets/stylesheets/coplan/foundation.css @@ -143,6 +143,15 @@ body[data-writing-inspection] :is(#plan-toolbar a, .site-nav__edit) { opacity: . --color-quote-warning-bg-subtle: rgba(255, 213, 79, 0.08); --color-quote-info-border: rgba(108, 140, 255, 0.6); --color-quote-info-bg: rgba(108, 140, 255, 0.05); + /* Static document emphasis must not imply interaction or unread attention. + Note and Warning stay neutral; icons and labels distinguish their meaning. */ + --color-callout-note: var(--color-text-muted); + --color-callout-tip: var(--color-success); + --color-callout-important: #7c3aed; + --color-callout-warning: var(--color-text-muted); + --color-callout-caution: var(--color-danger); + /* Teal: neither interaction blue nor attention orange. */ + --color-callout-summary: #0d9488; --color-highlight-open-bg: rgba(108, 140, 255, 0.12); --color-highlight-open-hover-bg: rgba(108, 140, 255, 0.22); --color-highlight-open-border: rgba(108, 140, 255, 0.6); @@ -270,6 +279,8 @@ body[data-writing-inspection] :is(#plan-toolbar a, .site-nav__edit) { opacity: . --color-quote-warning-bg-subtle: rgba(245, 158, 11, 0.12); --color-quote-info-border: rgba(96, 165, 250, 0.8); --color-quote-info-bg: rgba(96, 165, 250, 0.12); + --color-callout-important: #a78bfa; + --color-callout-summary: #2dd4bf; --color-highlight-open-bg: rgba(96, 165, 250, 0.18); --color-highlight-open-hover-bg: rgba(96, 165, 250, 0.28); --color-highlight-open-border: rgba(96, 165, 250, 0.85); @@ -363,6 +374,8 @@ body[data-writing-inspection] :is(#plan-toolbar a, .site-nav__edit) { opacity: . --color-quote-warning-bg-subtle: rgba(245, 158, 11, 0.12); --color-quote-info-border: rgba(96, 165, 250, 0.8); --color-quote-info-bg: rgba(96, 165, 250, 0.12); + --color-callout-important: #a78bfa; + --color-callout-summary: #2dd4bf; --color-highlight-open-bg: rgba(96, 165, 250, 0.18); --color-highlight-open-hover-bg: rgba(96, 165, 250, 0.28); --color-highlight-open-border: rgba(96, 165, 250, 0.85); diff --git a/engine/app/assets/stylesheets/coplan/markdown.css b/engine/app/assets/stylesheets/coplan/markdown.css index f5e6bb7c..c97494f7 100644 --- a/engine/app/assets/stylesheets/coplan/markdown.css +++ b/engine/app/assets/stylesheets/coplan/markdown.css @@ -489,6 +489,87 @@ margin-bottom: var(--space-md); } +/* ── Callouts: `> [!NOTE]`, `[!TIP]`, `[!IMPORTANT]`, `[!WARNING]`, + `[!CAUTION]`, `[!SUMMARY]` ────────────────────────────────────────────── + The reader renders div.callout; the rich editor keeps a blockquote with + the same look. The label is generated content from data-callout-title, + never document text, so selections and comment anchors skip it. The icon + is a mask in ::after, painted in the type's accent. */ +.markdown-rendered :is(.callout, blockquote.document-editor__callout) { + position: relative; + margin: 0 0 var(--space-md); + padding: var(--space-sm) var(--space-md); + border: 1px solid color-mix(in srgb, var(--callout-accent) 30%, transparent); + border-left: 4px solid var(--callout-accent); + border-radius: 6px; + background: color-mix(in srgb, var(--callout-accent) 8%, transparent); + color: var(--callout-ink, var(--color-text)); +} + +.markdown-rendered :is(.callout--note, .document-editor__callout--note) { + --callout-accent: var(--color-callout-note); + --callout-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='9'/%3E%3Cpath d='M12 11v5M12 8h.01'/%3E%3C/svg%3E"); +} + +.markdown-rendered :is(.callout--tip, .document-editor__callout--tip) { + --callout-accent: var(--color-callout-tip); + --callout-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M9 18h6M10 21h4M12 3a6 6 0 0 0-3.5 10.9c.6.5 1 1.2 1 2.1h5c0-.9.4-1.6 1-2.1A6 6 0 0 0 12 3Z'/%3E%3C/svg%3E"); +} + +.markdown-rendered :is(.callout--important, .document-editor__callout--important) { + --callout-accent: var(--color-callout-important); + --callout-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M20 15a2 2 0 0 1-2 2H8l-4 4V5a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2Z'/%3E%3Cpath d='M12 7v4M12 14h.01'/%3E%3C/svg%3E"); +} + +.markdown-rendered :is(.callout--warning, .document-editor__callout--warning) { + --callout-accent: var(--color-callout-warning); + --callout-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0Z'/%3E%3Cpath d='M12 9v4M12 17h.01'/%3E%3C/svg%3E"); +} + +.markdown-rendered :is(.callout--caution, .document-editor__callout--caution) { + --callout-accent: var(--color-callout-caution); + --callout-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M7.9 2h8.2L22 7.9v8.2L16.1 22H7.9L2 16.1V7.9Z'/%3E%3Cpath d='M12 8v4M12 16h.01'/%3E%3C/svg%3E"); +} + +.markdown-rendered :is(.callout--summary, .document-editor__callout--summary) { + --callout-accent: var(--color-callout-summary); + --callout-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M4 6h16M4 12h16M4 18h10'/%3E%3C/svg%3E"); +} + +.markdown-rendered :is(.callout, blockquote.document-editor__callout)::before { + content: attr(data-callout-title); + display: block; + margin-bottom: 0.25em; + padding-left: 1.5em; + color: color-mix(in srgb, var(--callout-accent) 78%, var(--callout-ink, var(--color-text))); + font-weight: 600; + line-height: 1.5; + user-select: none; +} + +.markdown-rendered :is(.callout, blockquote.document-editor__callout)::after { + content: ""; + position: absolute; + top: calc(var(--space-sm) + 0.25em); + left: var(--space-md); + width: 1em; + height: 1em; + background: var(--callout-accent); + mask: var(--callout-icon) center / contain no-repeat; +} + +/* A deck is a fixed artifact, independent of the reader's app theme. */ +.deck .markdown-rendered .callout { + --callout-ink: var(--deck-ink); + --callout-accent: var(--deck-muted); +} + +/* The second selector outranks the reading paragraph gap above. */ +.markdown-rendered :is(.callout, blockquote.document-editor__callout) > :last-child, +:is(.plan-reader, .document-editor__body) .markdown-rendered:not(.deck-content) :is(.callout, blockquote.document-editor__callout) > p:last-child { + margin-bottom: 0; +} + .markdown-rendered table { width: 100%; border-collapse: collapse; diff --git a/engine/app/helpers/coplan/markdown_helper.rb b/engine/app/helpers/coplan/markdown_helper.rb index 90b0ad33..f9dd4c00 100644 --- a/engine/app/helpers/coplan/markdown_helper.rb +++ b/engine/app/helpers/coplan/markdown_helper.rb @@ -21,11 +21,14 @@ module MarkdownHelper # Commonmarker extensions beyond the gem defaults (tables, tasklist, # strikethrough, autolink stay on). Footnotes: `[^1]` in text plus a - # `[^1]: definition` block anywhere in the document. Math: `$inline$`, - # `$$display$$`, `` $`inline`$ `` and ```` ```math ```` fences render as - # elements carrying `data-math-style`, typeset in the browser by KaTeX. + # `[^1]: definition` block anywhere in the document. Alerts use a first + # quote line such as `[!NOTE]` (see render_callouts; `[!SUMMARY]` is + # Coplan's own, see CoPlan::Markdown). Math uses `$inline$`, + # `$$display$$`, `` $`inline`$ `` and math fences, typeset by KaTeX. # comrak's dollar rules keep currency ("$5 and $10") as text. - EXTENSION_OPTIONS = { footnotes: true, math_dollars: true, math_code: true }.freeze + EXTENSION_OPTIONS = { footnotes: true, alerts: true, math_dollars: true, math_code: true }.freeze + + CALLOUT_TYPES = %w[note tip important warning caution summary].freeze # The only elements and values comrak emits for math. Anything else # carrying the attribute (authored HTML) loses it during rendering. @@ -43,7 +46,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 = 29 + RENDER_CACHE_VERSION = 32 # Matches `[@username](mention:username)` where the bracket text and link # target encode the same username. Username allows letters, digits, dots, @@ -85,6 +88,7 @@ def render_markdown(content, interactive: true, footnote_prefix: nil, footnotes: sanitized = sanitize(with_references, scrubber: scrubber) sanitized = prepare_math(sanitized, source) sanitized = render_iframe_blocks(sanitized, content.to_s) + sanitized = render_callouts(sanitized) result = interactive ? make_checkboxes_interactive(sanitized, content, line_offset: line_offset, source_comments: source_comments, retain_sourcepos: retain_sourcepos) : sanitized unless interactive fragment = Nokogiri::HTML::DocumentFragment.parse(result) @@ -183,7 +187,9 @@ def markdown_to_plain_text(content) source = content.to_s.encode("UTF-8") source = ContentRegions::Split.call(source).canonical_source if source.include?("::: {.presentation") html = CoPlan::Markdown.to_html(source, options: { extension: EXTENSION_OPTIONS }, plugins: { syntax_highlighter: nil }) - Nokogiri::HTML::DocumentFragment.parse(html).text.squish + fragment = Nokogiri::HTML::DocumentFragment.parse(html) + fragment.css("p.markdown-alert-title").remove + fragment.text.squish end private @@ -367,6 +373,42 @@ def task_name_text(node) node.children.map { |child| task_name_text(child) }.join end + # Commonmarker renders an alert as a div with a title paragraph. The + # title moves into data-callout-title, which the stylesheet shows beside + # the type's icon, so the label adds no text to the document. Comment + # anchors and occurrence counts then match the source text, where the + # `[!NOTE]` marker is not prose either. + # + # This runs after sanitization because data-callout-title is not an + # attribute authors may write. Raw HTML can copy Commonmarker's alert + # markup, which gives the same callout the marker gives, but it cannot + # set the callout classes directly: those are removed first. + def render_callouts(html) + return html unless html.include?("callout") || html.include?("markdown-alert") + + doc = Nokogiri::HTML::DocumentFragment.parse(html) + doc.css("[class*='callout']").each do |node| + classes = node.classes.reject { |name| name.match?(/\Acallout(?:--|\z)/) } + if classes.empty? + node.remove_attribute("class") + else + node["class"] = classes.join(" ") + end + end + + doc.css("div.markdown-alert").each do |alert| + type = CALLOUT_TYPES.find { |name| alert.classes.include?("markdown-alert-#{name}") } + title = alert.element_children.first + next unless type && title&.name == "p" && title.classes == [ "markdown-alert-title" ] + + title.remove + alert["class"] = "callout callout--#{type}" + alert["data-callout-title"] = title.text.squish + end + + doc.to_html + end + # A table is a block of data, not a paragraph. Each one gets a scroll # frame — so a wide table can't run off the page the way it does bare — # and the Stimulus controller that pins its header, fades its edges, and diff --git a/engine/app/javascript/coplan/rich_document.js b/engine/app/javascript/coplan/rich_document.js index bbf80187..8babfccf 100644 --- a/engine/app/javascript/coplan/rich_document.js +++ b/engine/app/javascript/coplan/rich_document.js @@ -71,6 +71,25 @@ nodes = nodes.addBefore("paragraph", "template_note", { attrs: { source: { default: null }, snapshot: { default: null }, opening: {}, closing: {} }, toDOM: () => ["div", { class: "document-editor__template-note", "data-coplan-template-note": "", "aria-label": "Comment" }, 0] }) +// A callout is a blockquote whose first line is a GitHub alert marker such as +// `[!NOTE]`. The marker line is kept verbatim as an attribute, so the body +// edits as rich text and the marker, including its case and any custom title, +// serializes back unchanged. +const calloutMarker = /^\[!(note|tip|important|warning|caution|summary)\]([^\n]*)(?:\n|$)/i +function calloutLabel(callout, marker) { + return marker.slice(marker.indexOf("]") + 1).trim() || callout[0].toUpperCase() + callout.slice(1) +} +const blockquoteSpec = nodes.get("blockquote") +nodes = nodes.update("blockquote", { + ...blockquoteSpec, + attrs: { ...blockquoteSpec.attrs, callout: { default: null }, marker: { default: null } }, + parseDOM: [{ tag: "blockquote", getAttrs: dom => { + const match = calloutMarker.exec(dom.dataset.calloutMarker || "") + return match ? { callout: match[1].toLowerCase(), marker: dom.dataset.calloutMarker } : {} + } }], + toDOM: node => node.attrs.callout ? ["blockquote", { class: `document-editor__callout document-editor__callout--${node.attrs.callout}`, + "data-callout-title": calloutLabel(node.attrs.callout, node.attrs.marker), "data-callout-marker": node.attrs.marker }, 0] : blockquoteSpec.toDOM(node) +}) // Math keeps its delimiters in the text: the TeX is raw, editable source and // serializes verbatim. It may sit inside emphasis or links, never in code. const schema = new Schema({ nodes, marks: defaultMarkdownParser.schema.spec.marks.addToEnd("strikethrough", { @@ -205,6 +224,44 @@ tokenizer.core.ruler.after("block", "coplan_tasks", state => { inline.content = inline.content.slice(match[0].length) }) }) +tokenizer.core.ruler.after("block", "coplan_callouts", state => { + let quoteDepth = 0 + const lines = state.src.split("\n") + for (let index = 0; index < state.tokens.length; index++) { + const token = state.tokens[index] + if (token.type === "blockquote_close") quoteDepth-- + if (token.type !== "blockquote_open") continue + quoteDepth++ + const paragraph = state.tokens[index + 1] + // Commonmarker only recognizes a marker on the opening quote line. + if (!["paragraph_open", "heading_open"].includes(paragraph?.type) || paragraph.map[0] !== token.map[0]) continue + const inline = state.tokens[index + 2] + const match = inline?.type === "inline" && calloutMarker.exec(inline.content) + if (!match) continue + // Commonmarker requires exactly one space after the quote marker. + const opening = lines[paragraph.map[0]] + if (!opening.slice(0, opening.indexOf("[!")).endsWith("> ")) continue + // Markdown-it folds the marker into a setext heading, but Commonmarker + // parses the alert first. Preserve that source rather than rewrite it. + if (paragraph.type === "heading_open") { + token.meta = { preserveCallout: true } + continue + } + // An alert marker is not a paragraph for lazy continuation. Markdown-it + // folds an unquoted following line into this paragraph; keep that unusual + // source in the server-rendered preview instead of changing its meaning. + if (inline.content.slice(match[0].length) && + !new RegExp(`^(?:[ \t]*>){${quoteDepth}}`).test(lines[paragraph.map[0] + 1] || "")) { + token.meta = { preserveCallout: true } + continue + } + token.meta = { callout: match[1].toLowerCase(), marker: match[0].replace(/\n$/, "") } + inline.content = inline.content.slice(match[0].length) + // A marker on its own line, before a blank line, list or fence, leaves + // an empty paragraph. Drop it so the callout body starts at its content. + if (!inline.content && state.tokens[index + 4]?.type !== "blockquote_close") state.tokens.splice(index + 1, 3) + } +}) tokenizer.core.ruler.after("block", "coplan_tables", state => { const lines = state.src.split("\n") for (let index = 0; index < state.tokens.length; index++) { @@ -230,6 +287,7 @@ const richMarkdownParser = new MarkdownParser(schema, tokenizer, { ...defaultMarkdownParser.tokens, s: { mark: "strikethrough" }, list_item: { block: "list_item", getAttrs: token => ({ checked: token.meta?.checked ?? null }) }, + blockquote: { block: "blockquote", getAttrs: token => token.meta?.callout ? token.meta : {} }, ordered_list: { ...defaultMarkdownParser.tokens.ordered_list, getAttrs: (token, tokens, index) => ({ ...defaultMarkdownParser.tokens.ordered_list.getAttrs(token, tokens, index), listStyle: token.meta?.listStyle || null }) }, @@ -282,6 +340,16 @@ const defaultMarkdownSerializer = new MarkdownSerializer({ if (node.attrs.checked !== null) state.write(`[${node.attrs.checked ? "x" : " "}] `) state.renderContent(node) }, + blockquote: (state, node) => { + if (!node.attrs.marker) return baseMarkdownSerializer.nodes.blockquote(state, node) + state.wrapBlock("> ", null, node, () => { + state.write(node.attrs.marker) + // An empty body keeps the marker alone, as `> [!NOTE]` was written. + if (node.childCount === 1 && node.firstChild.type.name === "paragraph" && !node.firstChild.content.size) return + state.ensureNewLine() + state.renderContent(node) + }) + }, ordered_list: (state, node) => { if (!node.attrs.listStyle) return baseMarkdownSerializer.nodes.ordered_list(state, node) const start = node.attrs.order || 1 @@ -521,6 +589,7 @@ export function parseDocument(markdown) { if (start > consumed) blocks.push(preserved(source.slice(consumed, start))) const text = source.slice(start, end) try { + if (text.includes("[!") && tokenizer.parse(text, {}).some(token => token.meta?.preserveCallout)) throw new Error("preserve") const parsed = richMarkdownParser.parse(text) let unsupported = text.replace(imageTag, tag => htmlImage(tag) ? "" : tag) // Raw TeX such as "$a[ \t]?)*[ \t]*(\d{1,9})[.)](?=[ \t\r\n]|\z)/ TABLE_DELIMITER = /\A(?:[ \t]*>[ \t]?)*[ \t]*\|?[ \t]*:?-+:?[ \t]*(?:\|[ \t]*:?-+:?[ \t]*)*\|?[ \t\r\n]*\z/ SOURCE_POSITION = /\A[1-9]\d*:[1-9]\d*-[1-9]\d*:\d+\z/ + # comrak's alerts know only GitHub's five types. A `[!SUMMARY]` marker + # parses as a same-length `[!WARNING]` alert, and to_html renames the + # alerts that start on those lines, so offsets never move. + SUMMARY_MARKER = /\A((?:[ \t]*(?:>[ \t]?|(?:[-+*]|\d{1,9}[.)])[ \t]+))*[ \t]*>[ \t]?)\[!summary\]/i + SUMMARY_STANDIN = "[!WARNING]".freeze def self.parse(source, **options) normalized, = normalize(source, options: options[:options]) @@ -13,8 +18,8 @@ def self.parse(source, **options) end def self.to_html(source, **options) - normalized, markers = normalize(source, options: options[:options]) - return Commonmarker.to_html(normalized, **options) if markers.empty? + normalized, markers, summaries = normalize(source, options: options[:options]) + return Commonmarker.to_html(normalized, **options) if markers.empty? && summaries.empty? original_options = options[:options] || {} sourcepos = original_options.dig(:render, :sourcepos) @@ -26,6 +31,8 @@ def self.to_html(source, **options) next unless SOURCE_POSITION.match?(list["data-sourcepos"].to_s) && items.any? && items.all? { |item| item.name == "li" && SOURCE_POSITION.match?(item["data-sourcepos"].to_s) } + next unless items.any? { |item| markers.key?(item["data-sourcepos"].to_i) } + groups = items.chunk do |item| markers[item["data-sourcepos"].to_i]&.fetch(:type) || "decimal" end @@ -43,6 +50,14 @@ def self.to_html(source, **options) end list.remove end + summaries.each do |line, titled| + alert = doc.css("div.markdown-alert-warning[data-sourcepos]").find { |node| node["data-sourcepos"].to_i == line } + next unless alert + + alert["class"] = alert["class"].sub("markdown-alert-warning", "markdown-alert-summary") + title = alert.element_children.first + title.content = "Summary" if !titled && title&.[]("class") == "markdown-alert-title" + end doc.css("[data-sourcepos]").each { |node| node.remove_attribute("data-sourcepos") } unless sourcepos doc.to_html end @@ -50,7 +65,10 @@ def self.to_html(source, **options) def self.normalize(source, options: nil) source = source.to_s lines = source.lines - return [ source, {} ] unless lines.any? { |line| LETTERED_MARKER.match?(line) } + unless lines.any? { |line| LETTERED_MARKER.match?(line) } + normalized, summaries = normalize_summaries(source, options) + return [ normalized, {}, summaries ] + end protected_lines = Set.new table_candidates = Set.new @@ -82,8 +100,41 @@ def self.normalize(source, options: nil) order: letters.upcase.each_byte.reduce(0) { |value, char| value * 26 + char - 64 } } line.sub(LETTERED_MARKER, "#{match[1]}#{'1'.rjust(letters.length, '0')})") end.join - [ normalized, markers ] + # Open lettered list containers before checking for summary alerts. + normalized, summaries = normalize_summaries(normalized, options) + [ normalized, markers, summaries ] + end + + # Returns the source with each real summary marker swapped for its + # stand-in, and { line => custom title? } for those lines. A candidate + # comrak doesn't open an alert on (code, raw HTML, `>[!SUMMARY]`) keeps + # its original text. + def self.normalize_summaries(source, options) + return [ source, {} ] unless source.match?(/\[!summary\]/i) + + lines = source.lines + candidates = lines.each_index.select { |index| SUMMARY_MARKER.match?(lines[index]) } + return [ source, {} ] if candidates.empty? + + swapped = lines.dup + candidates.each { |index| swapped[index] = lines[index].sub(SUMMARY_MARKER) { "#{Regexp.last_match(1)}#{SUMMARY_STANDIN}" } } + starts = Set.new + collect_alert_starts(Commonmarker.parse(swapped.join, options: options || {}), starts) + summaries = {} + candidates.each do |index| + next unless starts.include?(index + 1) + + summaries[index + 1] = lines[index].sub(SUMMARY_MARKER, "").strip.present? + lines[index] = swapped[index] + end + [ lines.join, summaries ] + end + + def self.collect_alert_starts(node, starts) + starts.add(node.source_position[:start_line]) if node.type == :alert + node.each { |child| collect_alert_starts(child, starts) } end + private_class_method :normalize_summaries, :collect_alert_starts def self.collect_protected(node, lines, types:) if types.include?(node.type) diff --git a/engine/app/services/coplan/plans/markdown_text_extractor.rb b/engine/app/services/coplan/plans/markdown_text_extractor.rb index f9843e4b..767f3e5f 100644 --- a/engine/app/services/coplan/plans/markdown_text_extractor.rb +++ b/engine/app/services/coplan/plans/markdown_text_extractor.rb @@ -72,8 +72,11 @@ def build_line_byte_offsets offsets end - # Block-level node types that should be separated by newlines. - BLOCK_TYPES = %i[paragraph heading table table_row item block_quote list code_block].to_set.freeze + # Block-level node types that should be separated by newlines. An + # alert's `[!NOTE]` marker and title are not child nodes, so neither + # reaches the stripped text; the rendered callout shows its label + # through CSS for the same reason. + BLOCK_TYPES = %i[paragraph heading table table_row item block_quote alert list code_block].to_set.freeze # Recursively walks the AST, appending text content to `stripped` and # character-index mappings to `pos_map`. Inserts whitespace between diff --git a/engine/app/services/coplan/slideshows/classify.rb b/engine/app/services/coplan/slideshows/classify.rb index 755b9128..ea3b83d2 100644 --- a/engine/app/services/coplan/slideshows/classify.rb +++ b/engine/app/services/coplan/slideshows/classify.rb @@ -242,6 +242,9 @@ def units(node) 0 when :list, :item, :taskitem, :block_quote [ node.each.sum { |child| units(child) }, 1 ].max + when :alert + # The label line, then the body. + 1 + node.each.sum { |child| units(child) } else 1 end diff --git a/engine/app/views/coplan/agent_instructions/guides/markdown.text.erb b/engine/app/views/coplan/agent_instructions/guides/markdown.text.erb index 582ceb8b..da54f1bb 100644 --- a/engine/app/views/coplan/agent_instructions/guides/markdown.text.erb +++ b/engine/app/views/coplan/agent_instructions/guides/markdown.text.erb @@ -123,6 +123,38 @@ In the rich editor, task text stays directly editable. Checkboxes toggle in plac and Enter continues with an unchecked task. Strikethrough and mentions also stay inline in editable prose and lists. +## Callouts + +Use a callout for a note or warning that must stand out from the body text. Start a blockquote with one of these markers on its own first line: + +```markdown +> [!NOTE] +> Background a reader should know before they go on. + +> [!TIP] +> An easier or faster way to do something. + +> [!IMPORTANT] +> Information the reader needs to succeed. + +> [!WARNING] +> A risk the reader should act on now. + +> [!CAUTION] +> An action with a bad or irreversible result. + +> [!SUMMARY] +> - The two or three takeaways a reader needs if they read nothing else. +``` + +Each type has an icon and label. Notes and warnings use neutral accents; blue and orange remain reserved for interaction and viewer-specific attention. Slides use their deck palette. The marker is not case sensitive. To change the label, put text after the marker: `> [!WARNING] Before you migrate`. Lists, code blocks and tables work inside a callout when every line starts with `>`. Any other marker, such as `> [!FOO]`, renders as a plain blockquote. + +Use `[!SUMMARY]` for a TL;DR at the top of a long plan, such as an analysis or incident review. It is a Coplan extension; other Markdown viewers show it as an ordinary blockquote with a literal `[!SUMMARY]` marker. It is only formatting. It does not change the plan summary shown in search, link previews or Slack unfurls. + +Keep the marker on the opening quote line, with exactly one space after `>`; `>[!NOTE]` is a plain quote. Escape it as `> \[!NOTE]` to show it literally. In the rich editor, a callout whose opening paragraph includes a setext underline (`---` or `===`), or an empty callout immediately followed by unquoted text, uses the source-preview block; edit that block’s source or use Raw. + +Use callouts sparingly: one or two in a section. If everything stands out, nothing does. Do not use a callout for a quote; use a plain `>` blockquote. + ## Collapsible sections Use `
` for long appendices, raw logs, or optional detail: diff --git a/engine/db/demo/content/callout-gallery.md b/engine/db/demo/content/callout-gallery.md new file mode 100644 index 00000000..15c0a0b9 --- /dev/null +++ b/engine/db/demo/content/callout-gallery.md @@ -0,0 +1,75 @@ +> [!SUMMARY] +> - Archived invoices move to cold storage in three batches over two weeks. +> - Customers see no change; backups get shorter. +> - One decision is open: the retention window. + +> [!IMPORTANT] Decision needed +> Choose the retention window by Friday. The rest of the plan is ready: archived invoices move to cold storage in three batches over two weeks, and customers see no change. + +## Background + +Archived invoices make up most of the primary database, but fewer than one read in a thousand touches them. Moving them frees space and shortens backups. + +> [!NOTE] +> An invoice is archived ninety days after it is paid. Open and disputed invoices never move. + +## Plan + +1. Copy one batch to cold storage. +2. Compare row counts and checksums. +3. Switch reads for that batch, then delete it from the primary database. + +> [!TIP] +> Run the copy with `--dry-run` first. It prints the batch size and the expected run time without writing anything. + +> [!WARNING] +> Each batch holds a lock on the archive table for up to a minute. Schedule batches outside the evening billing run: +> +> - Batch 1: Tuesday, 02:00 UTC +> - Batch 2: Thursday, 02:00 UTC +> - Batch 3: the following Tuesday, 02:00 UTC +> +> ```sql +> SELECT COUNT(*) FROM invoices WHERE archived_at < NOW() - INTERVAL 90 DAY; +> ``` + +> [!CAUTION] +> Deleting a batch from the primary database cannot be undone. Do not delete a batch until its checksums match. + +## Comparison + +A plain blockquote stays a quote: + +> Storage is cheap; the backup window is not. + +An unknown marker also stays a plain blockquote: + +> [!QUESTION] +> Should finance review the retention window? + +An escaped known marker stays literal too: + +> \[!NOTE] +> This text explains the syntax rather than creating a note. + +## Callouts in slides + +The deck palette stays fixed when the app switches between light and dark mode. + +::: {.presentation theme="coplan"} + +# Retention rollout + +> [!TIP] +> Compare checksums before deleting the original batch. + +::: + +::: {.presentation theme="graphite"} + +# Retention rollout + +> [!WARNING] +> Schedule the archive lock outside the billing run. + +::: diff --git a/engine/lib/coplan/development_seed.rb b/engine/lib/coplan/development_seed.rb index fac53f9c..46d891b3 100644 --- a/engine/lib/coplan/development_seed.rb +++ b/engine/lib/coplan/development_seed.rb @@ -122,6 +122,9 @@ module DevelopmentSeed { key: "image-resizing", author: "hampton", type: "General", title: "Images: resize and snap to full width", tags: %w[demo images editor], visibility: "published", folder: "Demo content/Rich text", group: "Tables and rich text", showcase: "Editor corner resizing, text wrapping beside images, Enter to continue below, undo, and snap to natural full width" }, + { key: "callout-gallery", author: "hampton", type: "Design Doc", title: "Callouts: notes, tips, and warnings", + tags: %w[demo markdown], visibility: "published", folder: "Demo content/Rich text", group: "Tables and rich text", + showcase: "All six callout types including Summary, a custom label, a list and code inside a callout, and plain quotes for comparison; comment on callout text or edit it in Editor" }, { key: "math-gallery", author: "hampton", type: "Research Note", title: "Math: estimating a retry budget", tags: %w[demo markdown math], visibility: "published", folder: "Demo content/Rich text", group: "Tables and rich text", showcase: "LaTeX math typeset with KaTeX: inline, display, a math fence, math in lists and tables, dollar amounts that stay text, and invalid math shown as source" }, diff --git a/spec/helpers/markdown_helper_spec.rb b/spec/helpers/markdown_helper_spec.rb index 5c8652a6..2967f524 100644 --- a/spec/helpers/markdown_helper_spec.rb +++ b/spec/helpers/markdown_helper_spec.rb @@ -234,6 +234,174 @@ end end + describe "callouts" do + def callout_doc(markdown, **options) + Nokogiri::HTML::DocumentFragment.parse(helper.render_markdown(markdown, **options)) + end + + { + "NOTE" => "Note", "TIP" => "Tip", "IMPORTANT" => "Important", + "WARNING" => "Warning", "CAUTION" => "Caution", "SUMMARY" => "Summary" + }.each do |marker, label| + it "renders [!#{marker}] as a #{label.downcase} callout without the marker text" do + doc = callout_doc("> [!#{marker}]\n> Read this first.") + callout = doc.at_css("div.callout") + + expect(callout["class"]).to eq("callout callout--#{marker.downcase}") + expect(callout["data-callout-title"]).to eq(label) + expect(callout.text.strip).to eq("Read this first.") + expect(doc.text).not_to include("[!#{marker}]") + expect(doc.at_css("blockquote")).to be_nil + expect(doc.at_css(".markdown-alert-title")).to be_nil + end + end + + it "accepts a lowercase marker and keeps a custom title as the label" do + callout = callout_doc("> [!warning] Before you migrate\n> Back up first.").at_css("div.callout") + + expect(callout["class"]).to eq("callout callout--warning") + expect(callout["data-callout-title"]).to eq("Before you migrate") + expect(callout.text.strip).to eq("Back up first.") + end + + it "does not renumber unrelated nested lists when a summary is present" do + doc = callout_doc("> [!SUMMARY]\n> Body.\n\n- 9. Nine\n 10. Ten\n") + expect(doc.at_css("ul ol")["start"]).to eq("9") + expect(doc.at_css(".callout--summary")).to be_present + end + + it "renders summaries on list opening lines with unchanged source offsets" do + [ "-", "1.", "A)", "> -" ].each do |prefix| + source = "#{prefix} > [!SUMMARY] TL;DR\n#{' ' * prefix.length} > café 🌱" + doc = callout_doc(source) + expect(doc.at_css("li .callout--summary")&.[]("data-callout-title")).to eq("TL;DR") + text, offsets = CoPlan::Plans::MarkdownTextExtractor.new(source).call + expect(text).not_to include("SUMMARY", "WARNING", "TL;DR") + text.chars.each_with_index { |char, i| expect(source[offsets[i]]).to eq(char) if offsets[i] && offsets[i] >= 0 } + end + end + + it "keeps a custom summary title and the extractor's text" do + source = "> [!summary] TL;DR\n> Ship it." + callout = callout_doc(source).at_css("div.callout") + + expect(callout["class"]).to eq("callout callout--summary") + expect(callout["data-callout-title"]).to eq("TL;DR") + text, offsets = CoPlan::Plans::MarkdownTextExtractor.new(source).call + expect(text).to eq("Ship it.") + expect(offsets.first).to eq(source.index("Ship it.")) + end + + it "leaves [!SUMMARY] literal where it doesn't open a callout" do + doc = callout_doc("```\n> [!SUMMARY]\n```\n\n>[!SUMMARY]\n> Not a callout.") + + expect(doc.at_css("pre code").text).to eq("> [!SUMMARY]\n") + expect(doc.at_css("blockquote").text).to include("[!SUMMARY]") + expect(doc.at_css(".callout")).to be_nil + end + + it "leaves a plain blockquote unchanged" do + doc = callout_doc("> Just a quote.") + + expect(doc.at_css("blockquote p").text).to eq("Just a quote.") + expect(doc.at_css(".callout")).to be_nil + end + + it "leaves an unknown marker as a plain blockquote" do + doc = callout_doc("> [!FOO]\n> Not a callout.") + + expect(doc.at_css("blockquote").text).to include("[!FOO]") + expect(doc.at_css(".callout")).to be_nil + end + + it "keeps escaped markers and markers after an opening blank quote line literal" do + [ "> \\[!NOTE]\n> Literal.", ">\n> [!NOTE]\n> Literal." ].each do |source| + doc = callout_doc(source) + expect(doc.at_css(".callout")).to be_nil + expect(doc.at_css("blockquote").text).to include("[!NOTE]") + end + end + + it "keeps an unquoted line outside an empty callout" do + doc = callout_doc("> [!NOTE]\nOutside the callout.") + expect(doc.at_css(".callout").text.strip).to be_empty + expect(doc.at_css(".callout + p").text).to eq("Outside the callout.") + end + + it "keeps math and application links inside callouts while sanitizing unsafe HTML" do + doc = callout_doc(<<~MD) + > [!NOTE] + > Estimate $p^n$ in [the chat](codex://threads/123). + > unsafe + MD + callout = doc.at_css(".callout--note") + expect(callout.at_css('[data-math-style="inline"]').text).to eq("p^n") + expect(callout.css("a[href]").map { |a| a["href"] }).to eq([ "codex://threads/123" ]) + expect(callout.css("[onclick]")).to be_empty + end + + it "renders lists, code and nested quotes inside a callout" do + markdown = <<~MD + > [!IMPORTANT] + > Ship in this order: + > + > 1. Migrate the schema. + > 2. Deploy the readers. + > + > ```ruby + > Rollout.start! + > ``` + > + > > A quoted reminder. + MD + callout = callout_doc(markdown).at_css("div.callout--important") + + expect(callout.css("ol > li").map(&:text)).to eq([ "Migrate the schema.", "Deploy the readers." ]) + expect(callout.at_css("pre code").text).to include("Rollout.start!") + expect(callout.at_css("blockquote").text.strip).to eq("A quoted reminder.") + end + + it "renders a callout nested in a list item" do + doc = callout_doc("- Step one\n\n > [!TIP]\n > Use the dry run.") + + expect(doc.at_css("li div.callout--tip").text.strip).to eq("Use the dry run.") + end + + it "wires task checkboxes inside a callout like any other" do + callout = callout_doc("> [!NOTE]\n> - [ ] Confirm the owner").at_css("div.callout") + + expect(callout.at_css('input[type="checkbox"]')).to be_present + end + + it "does not let raw HTML set callout classes or the label attribute" do + markdown = '
Raw
' + div = callout_doc(markdown).at_css("div.keep") + + expect(div["class"]).to eq("keep") + expect(div["data-callout-title"]).to be_nil + expect(div["style"]).to be_nil + end + + it "keeps the callout in non-interactive renders" do + callout = callout_doc("> [!CAUTION]\n> Irreversible.", interactive: false).at_css("div.callout--caution") + + expect(callout["data-callout-title"]).to eq("Caution") + expect(callout["data-sourcepos"]).to be_nil + end + + it "omits generated and custom callout titles from plan-row plain text" do + expect(helper.markdown_to_plain_text("> [!WARNING]\n> Back up first.")).to eq("Back up first.") + expect(helper.markdown_to_plain_text("> [!NOTE] Custom label\n> Body.")).to eq("Body.") + expect(helper.markdown_to_plain_text("> [!UNKNOWN]\n> Body.")).to eq("[!UNKNOWN] Body.") + end + + it "leaves the marker and label out of plain-text extraction anchors" do + stripped, = CoPlan::Plans::MarkdownTextExtractor.call("Intro.\n\n> [!NOTE]\n> Read this first.\n") + + expect(stripped).to eq("Intro.\nRead this first.") + end + end + describe "line_offset" do it "shifts checkbox data-line back to document coordinates" do # The fragment is lines 6-8 of some larger document. diff --git a/spec/lib/development_seed_spec.rb b/spec/lib/development_seed_spec.rb index 0a2f2fae..212140a7 100644 --- a/spec/lib/development_seed_spec.rb +++ b/spec/lib/development_seed_spec.rb @@ -322,6 +322,30 @@ def seeded_plan(key) expect(plan.reload.current_content).to eq(resized) end + it "seeds the callout gallery once with every callout type and preserves a person's edits" do + described_class.call + plan = seeded_plan("callout-gallery") + expect(plan.title).to eq("Callouts: notes, tips, and warnings") + expect(plan.current_content).to include("> [!SUMMARY]", "> [!IMPORTANT] Decision needed", "> [!NOTE]", "> [!TIP]", "> [!WARNING]", "> [!CAUTION]") + expect(plan.current_content).to include("> [!QUESTION]", "> Storage is cheap; the backup window is not.") + expect(seeded_plan("demo-library").current_content).to include("(/#{plan.url_path})") + + html = ApplicationController.helpers.render_markdown(plan.current_content) + types = Nokogiri::HTML::DocumentFragment.parse(html).css(".callout").map { |node| node["class"].delete_prefix("callout callout--") } + expect(types).to eq(%w[summary important note tip warning caution tip warning]) + expect(plan.current_content).to include('> \\[!NOTE]', 'theme="coplan"', 'theme="graphite"') + + revision = plan.current_revision + described_class.call + expect(plan.reload.current_revision).to eq(revision) + + edited = plan.current_content.sub("> [!TIP]", "> [!NOTE]") + CoPlan::Plans::ReplaceContent.call(plan: plan, new_content: edited, + base_revision: plan.current_revision, actor_type: "human", actor_id: plan.created_by_user_id) + described_class.call + expect(plan.reload.current_content).to eq(edited) + end + it "seeds the math gallery with every math form and preserves edits on re-seed" do described_class.call plan = seeded_plan("math-gallery") diff --git a/spec/system/callouts_spec.rb b/spec/system/callouts_spec.rb new file mode 100644 index 00000000..1ae8e1e1 --- /dev/null +++ b/spec/system/callouts_spec.rb @@ -0,0 +1,254 @@ +require "rails_helper" + +RSpec.describe "Callouts", type: :system do + let(:author) { create(:coplan_user, email: "callouts@example.com") } + let(:source) do + <<~MD + # Rollout + + Back up first. + + > [!WARNING] + > Back up first. The migration cannot be undone. + + > [!tip] Faster path + > - Use the dry run. + > - Compare the counts. + + > A plain quote. + MD + end + let(:plan) do + CoPlan::Plans::Create.call(title: "Callout plan", content: source, user: author, + visibility: "draft", actor_type: "human") + end + + before do + allow(CoPlan::Ai).to receive(:available?).and_return(false) + visit sign_in_path + fill_in "Email address", with: author.email + click_button "Sign In" + expect(page).to have_current_path(root_path) + end + + def label_of(selector) + page.evaluate_script("getComputedStyle(document.querySelector(arguments[0]), '::before').content", selector) + end + + it "renders each callout with its label and lets a reader comment on text inside it" do + visit plan_page_path(plan) + content = find('[data-coplan--text-selection-target="content"]') + expect(content).to have_css(".callout.callout--warning", text: "The migration cannot be undone.") + expect(content).to have_css(".callout.callout--tip li", count: 2) + expect(content).to have_css("blockquote", text: "A plain quote.") + expect(content).to have_no_text("[!WARNING]") + expect(content).to have_no_text("[!tip]") + expect(label_of(".callout--warning")).to eq('"Warning"') + expect(label_of(".callout--tip")).to eq('"Faster path"') + + page.execute_script <<~JS + const p = document.querySelector(".callout--warning p"); + const text = p.firstChild; + const range = document.createRange(); range.setStart(text, 0); range.setEnd(text, "Back up first.".length); + const selection = window.getSelection(); selection.removeAllRanges(); selection.addRange(range); + p.dispatchEvent(new MouseEvent("mouseup", { bubbles: true })); + JS + page.driver.browser.action.send_keys("c").perform + panel = find(".comment-window:popover-open") + expect(panel).to have_css(".comment-form__quote", text: "Back up first.") + panel.find("textarea").fill_in with: "Which backup?" + panel.find("textarea").send_keys(:enter) + + expect(page).to have_css(".callout--warning mark.anchor-highlight", text: "Back up first.", wait: 10) + thread = plan.comment_threads.reload.last + expect(thread.anchor_text).to eq("Back up first.") + expect(thread.anchor_start).to eq(source.index("Back up first.", source.index("[!WARNING]"))) + end + + it "edits a summary as rich content and preserves its marker when saved" do + source = "# Analysis\n\n> [!SUMMARY] TL;DR\n> What happened." + document = CoPlan::Plans::Create.call(title: "Analysis", content: source, user: author, + visibility: "draft", actor_type: "human") + visit plan_edit_page_path(document) + expect(page).to have_no_css(".inline-editor--preparing", wait: 20) + summary = find(".ProseMirror .document-editor__callout--summary") + expect(summary["data-callout-title"]).to eq("TL;DR") + summary.find("p").click + page.driver.browser.action.send_keys(:end, " Why it happened.").perform + click_button "Raw", exact: true + expected = source + " Why it happened." + expect(find('[aria-label="Markdown source"]').text).to eq(expected) + click_link "Done" + expect(page).to have_current_path(plan_page_path(document)) + expect(document.reload.current_content).to eq(expected) + expect(page).to have_css('.callout--summary[data-callout-title="TL;DR"]', text: "Why it happened.") + end + + it "preserves a table-only callout when a cell is edited and saved" do + source = "# Findings\n\n> [!SUMMARY]\n>\n> | Finding | State |\n> | --- | --- |\n> | Retry | Ready |" + document = CoPlan::Plans::Create.call(title: "Findings", content: source, user: author, + visibility: "draft", actor_type: "human") + visit plan_edit_page_path(document) + expect(page).to have_css(".ProseMirror .document-editor__callout--summary table", text: "Ready") + click_button "Edit table" + field = find('[aria-label="Table Markdown"]') + field.fill_in with: field.value.sub("Ready", "Done") + click_button "Raw", exact: true + expect(find('[aria-label="Markdown source"]').text).to include("> [!SUMMARY]", "> | Retry | Done |") + click_link "Done" + expect(page).to have_current_path(plan_page_path(document)) + expect(document.reload.current_content).to include("> [!SUMMARY]", "> | Retry | Done |") + expect(page).to have_css(".callout--summary table", text: "Done") + end + + it "preserves math inside a callout through a rich edit and save" do + content = "# Retry budget\n\n> [!TIP] Estimate\n> Use $p^n$ for retries." + document = CoPlan::Plans::Create.call(title: "Retry budget", content: content, user: author, + visibility: "draft", actor_type: "human") + visit plan_page_path(document) + expect(page).to have_css(".callout--tip .katex") + visit plan_edit_page_path(document) + expect(page).to have_no_css(".inline-editor--preparing", wait: 20) + callout = find(".ProseMirror .document-editor__callout--tip") + expect(callout).to have_css(".document-editor__math", text: "$p^n$") + callout.find("p").click + page.driver.browser.action.send_keys(:end, " Check the estimate.").perform + click_button "Raw", exact: true + expected = content + " Check the estimate." + expect(find('[aria-label="Markdown source"]').text).to eq(expected) + click_link "Done" + expect(page).to have_current_path(plan_page_path(document)) + expect(document.reload.current_content).to eq(expected) + expect(page).to have_css(".callout--tip .katex") + expect(page).to have_css(".callout--tip", text: "Check the estimate.") + end + + it "keeps callouts readable in either deck theme regardless of app mode" do + content = %w[coplan graphite].map do |theme| + "::: {.presentation theme=\"#{theme}\"}\n\n# Callout slide\n\n> [!TIP]\n> Readable in either app mode.\n\n:::\n" + end.join("\n") + deck = CoPlan::Plans::Create.call(title: "Callout decks", content: content, user: author, + visibility: "draft", actor_type: "human") + visit plan_page_path(deck) + expect(page).to have_css(".deck .callout", count: 2) + palettes = %w[light dark].map do |mode| + page.execute_script("document.documentElement.dataset.theme = arguments[0]", mode) + page.evaluate_script(<<~JS) + Array.from(document.querySelectorAll(".deck .callout"), node => { + const style = getComputedStyle(node), slide = getComputedStyle(node.closest(".deck-slide")); + return { text: style.color, ink: slide.color, label: getComputedStyle(node, "::before").color, + border: style.borderLeftColor, background: style.backgroundColor }; + }) + JS + end + palettes.flatten.each { |palette| expect(palette["text"]).to eq(palette["ink"]) } + expect(palettes.first).to eq(palettes.last) + end + + it "keeps literal markers and empty-callout boundaries consistent with the reader", :aggregate_failures do + samples = [ + "> \\[!NOTE]\n> Literal.\n", + "> [!UNKNOWN]\n> Literal.\n", + ">\n> [!NOTE]\n> Literal.\n", + "> [!NOTE]\nOutside the callout.\n", + ">[!NOTE]\n> Literal.\n", + "> [!NOTE]\n> Literal.\n", + ">\t[!NOTE]\n> Literal.\n", + "> [!NOTE]\n> a\n> ---\n", + "> [!NOTE]\n> ---\n", + "> [!NOTE]\n> ===\n" + ] + results = page.evaluate_async_script(<<~'JS', samples) + const sources = arguments[0], done = arguments[1]; + import("coplan/rich_document").then(m => done(sources.map(source => { + const doc = m.parseDocument(source), callouts = []; + doc.descendants(node => { if (node.attrs.callout) callouts.push(node.textContent); }); + const rebuilt = doc.type.create(null, doc.content.content.map(node => node.type.name === "preserved" ? node : + node.type.create({ ...node.attrs, source: null, snapshot: null }, node.content, node.marks))); + return { callouts, roundtrip: m.serializeDocument(doc), rewritten: m.serializeDocument(rebuilt) }; + }))).catch(error => done({ error: error.message })); + JS + expect(results.map { |result| result["callouts"] }).to all(eq([])) + expect(results.map { |result| result["roundtrip"] }).to eq(samples) + results[4..6].each do |result| + html = ApplicationController.helpers.render_markdown(result["rewritten"]) + expect(Nokogiri::HTML.fragment(html).at_css(".callout")).to be_nil + end + expect(results.last(3).map { |result| result["rewritten"] }).to eq(samples.last(3)) + end + + it "preserves a setext callout while saving an edit to nearby prose" do + source = "# Setext callout\n\n> [!NOTE]\n> Heading body\n> ---\n\nAfter." + document = CoPlan::Plans::Create.call(title: "Setext callout", content: source, user: author, + visibility: "draft", actor_type: "human") + visit plan_edit_page_path(document) + expect(page).to have_css(".document-editor__block-preview .callout h2", text: "Heading body") + paragraph = find(".ProseMirror p", text: "After.", exact_text: true) + paragraph.click + paragraph.send_keys(:end, " Edited.") + click_button "Raw", exact: true + expected = source.sub("After.", "After. Edited.") + expect(find('[aria-label="Markdown source"]').text).to eq(expected.strip) + click_link "Done" + expect(page).to have_current_path(plan_page_path(document)) + expect(document.reload.current_content).to eq(expected) + expect(page).to have_css(".callout h2", text: "Heading body") + end + + it "keeps each marker unchanged through the rich editor" do + round_trips = page.evaluate_async_script(<<~'JS') + const done = arguments[0]; + import("coplan/rich_document").then(m => { + const sources = [ + "> [!NOTE]\n> Plain body.\n", "> [!TIP]\n> Tip.\n", "> [!IMPORTANT]\n> Important.\n", + "> [!WARNING]\n> Warning.\n", "> [!CAUTION]\n> Caution.\n", "> [!note] Custom title\n> Body.\n", + "> [!NOTE]\n>\n> After a blank line.\n", "> [!NOTE]\n> - one\n> - two\n", "> [!NOTE]\n", + "> [!FOO]\n> Not a callout.\n", "> Plain quote.\n", "- Item\n\n > [!TIP]\n > Nested.\n", + "> [!SUMMARY]\n> Summary.\n", "- > [!SUMMARY]\n > Nested summary.\n" + ]; + done(sources.map(source => { + const doc = m.parseDocument(source); + const quotes = []; + doc.descendants(node => { if (node.type.name === "blockquote") quotes.push(node.attrs.callout); }); + // Rebuild every block so the serializer, not the saved source, writes it. + const fresh = doc.type.create(null, doc.content.content.map(node => + node.type.create({ ...node.attrs, source: null, snapshot: null }, node.content, node.marks))); + return { source, exact: m.serializeDocument(doc) === source, rewritten: m.serializeDocument(fresh), callouts: quotes }; + })); + }).catch(error => done({ error: error.message })); + JS + expect(round_trips).to all(include("exact" => true)) + expect(round_trips.map { |entry| entry["callouts"] }).to eq([ + [ "note" ], [ "tip" ], [ "important" ], [ "warning" ], [ "caution" ], [ "note" ], + [ "note" ], [ "note" ], [ "note" ], [ nil ], [ nil ], [ "tip" ], [ "summary" ], [ "summary" ] + ]) + round_trips.first(6).each do |entry| + expect(entry["rewritten"].strip).to eq(entry["source"].strip) + end + expect(round_trips[6]["rewritten"]).to start_with("> [!NOTE]\n> After a blank line.") + expect(round_trips[7]["rewritten"]).to start_with("> [!NOTE]\n> * one\n> * two") + expect(round_trips[8]["rewritten"].strip).to eq("> [!NOTE]") + expect(round_trips[-2]["rewritten"].strip).to eq(round_trips[-2]["source"].strip) + expect(round_trips.last["rewritten"]).to include("[!SUMMARY]", "Nested summary.") + + visit plan_edit_page_path(plan) + expect(page).to have_css('.ProseMirror[contenteditable="true"]', wait: 20) + expect(page).to have_no_css(".inline-editor--preparing", wait: 20) + callout = find(".ProseMirror blockquote.document-editor__callout--warning") + expect(callout).to have_text("Back up first. The migration cannot be undone.", exact: true) + expect(page).to have_css(".ProseMirror blockquote.document-editor__callout--tip[data-callout-title='Faster path']") + expect(label_of(".document-editor__callout--warning")).to eq('"Warning"') + + callout.find("p").click + page.driver.browser.action.send_keys(:end, " Take a snapshot.").perform + expect(callout).to have_text("cannot be undone. Take a snapshot.") + click_button "Raw", exact: true + expected = source.sub("cannot be undone.", "cannot be undone. Take a snapshot.") + expect(page).to have_css('[aria-label="Markdown source"]', text: "> [!WARNING]") + expect(find('[aria-label="Markdown source"]').text).to eq(expected.strip) + click_link "Done" + expect(page).to have_current_path(plan_page_path(plan)) + expect(plan.reload.current_content).to eq(expected) + expect(page).to have_css(".callout--warning", text: "Take a snapshot.") + end +end diff --git a/spec/system/math_rendering_spec.rb b/spec/system/math_rendering_spec.rb index 182f510b..59196fb0 100644 --- a/spec/system/math_rendering_spec.rb +++ b/spec/system/math_rendering_spec.rb @@ -178,7 +178,7 @@ def comment_on_selection(body) thread = CoPlan::CommentThread.order(:created_at).last expect(thread.anchor_text).to eq("Before x after") expect(imitation.reload.current_content[thread.anchor_start...thread.anchor_end]).to eq("Before x after") - expect(all("mark.anchor-highlight").map(&:text).join).to include("x") + expect(page).to have_css("mark.anchor-highlight", text: "x") end it "renders math in a comment" do @@ -203,7 +203,11 @@ def comment_on_selection(body) thread = CoPlan::CommentThread.order(:created_at).last expect(thread.anchor_text).to eq("relation links") expect(content[thread.anchor_start...thread.anchor_end]).to eq("relation $E = mc^2$ links") - expect(all("mark.anchor-highlight", count: 2).map(&:text)).to eq([ "relation", "links" ]) + # Broadcasts can replace the marks between Selenium reads. Let Capybara + # re-query them instead of holding element handles across that update. + expect(page).to have_css("mark.anchor-highlight", count: 2) + expect(page).to have_css("mark.anchor-highlight", exact_text: "relation", count: 1) + expect(page).to have_css("mark.anchor-highlight", exact_text: "links", count: 1) expect(page).to have_no_css("[data-math-style] mark") expect(page).to have_css("span[data-math-style] .katex", count: 2)