diff --git a/db/seeds/development.rb b/db/seeds/development.rb index b14d764..8fd0a1f 100644 --- a/db/seeds/development.rb +++ b/db/seeds/development.rb @@ -80,6 +80,10 @@ module DevelopmentSeed { key: "long-mobile-toc", author: "priya", type: "Product Brief", title: "The complete guide to launching shared workspaces across web, iOS, and Android", tags: %w[collaboration mobile launch], visibility: "published", folder: "Product/Launches/Shared workspace", long: true + }, + { + key: "code-walkthrough", author: "sam", type: "Design Doc", title: "Order discount engine: implementation walkthrough", + tags: %w[pricing api design], visibility: "published", folder: "Engineering/Active projects", fixture: :code_walkthrough } ].freeze @@ -116,6 +120,127 @@ module DevelopmentSeed | Success | 61.2% | 62.9% | | Errors | 18.4% | 17.7% | MARKDOWN + code_walkthrough: <<~'MARKDOWN', + How the discount engine decides what every line item costs. Each stage is + shown in the language it ships in — service code, client hook, schema, + rollout commands — so this doc doubles as a demo of syntax highlighting. + + ## Where discounts happen + + ```mermaid + flowchart LR + POS[POS client] --> API + API --> Engine[Discount engine] + Engine --> Rules[(Rule store)] + Engine --> Ledger[(Price ledger)] + ``` + + ## The engine core + + The engine walks each cart once, folding applicable rules into a final + per-line price. Rules never see the running total — that keeps them pure + and independently testable. + + ```ruby + # Applies every eligible rule to a cart, cheapest-first. + class DiscountEngine + MAX_STACK = 3 + + def initialize(rules:, clock: Time) + @rules = rules.sort_by(&:priority) + @clock = clock + end + + def price(cart) + cart.line_items.map do |item| + applied = @rules + .select { |rule| rule.eligible?(item, at: @clock.now) } + .first(MAX_STACK) + + total = applied.reduce(item.amount) { |amount, rule| rule.apply(amount) } + PricedItem.new(item:, total:, applied_rules: applied.map(&:code)) + end + end + end + ``` + + ## Client hook + + The POS reads priced carts through a small hook — no pricing logic + client-side, ever. + + ```typescript + interface PricedItem { + name: string; + total: number; + appliedRules: string[]; + } + + export function usePricedCart(cartId: string): PricedItem[] { + const { data, error } = useSWR(`/api/carts/${cartId}/pricing`); + if (error) throw new PricingUnavailableError(cartId); + return data ?? []; + } + ``` + + ## Rule storage + + ```sql + CREATE TABLE discount_rules ( + id CHAR(36) PRIMARY KEY, + code VARCHAR(64) NOT NULL UNIQUE, + priority INT NOT NULL DEFAULT 100, + percent_off DECIMAL(5, 2) NOT NULL, + starts_at DATETIME NOT NULL, + ends_at DATETIME + ); + + -- Most queries are "which rules are live right now?" + CREATE INDEX idx_rules_window ON discount_rules (starts_at, ends_at); + ``` + + ## Rollout + + Ship dark, then ramp by merchant cohort: + + ```bash + bin/rails discounts:backfill_rules DRY_RUN=1 + bin/rails discounts:backfill_rules + curl -sf "$API/flags/discount-engine" -d 'cohort=1' | jq .rollout + ``` + + The flag change that turned it on for the pilot cohort: + + ```diff + flags: + discount-engine: + - enabled: false + + enabled: true + + cohorts: [pilot-merchants] + ``` + + ## Ledger spot-check + + Raw output pasted straight from the console — no language tag, so no + header and no highlighting: + + ``` + cart 8842 → "espresso" base 450 applied [SUMMER10, LOYALTY5] total 384 + cart 8842 → "croissant" base 375 applied [] total 375 + cart 8842 → "cold brew" base 525 applied [SUMMER10] total 472 + ``` + + As the payments team put it during review: + + > Pricing bugs are the only bugs customers find before your tests do. + > Fold the ledger check into CI before cohort 3, not after. + + ## Open questions + + - Should `MAX_STACK` be a merchant setting instead of a constant? + - The ledger write is synchronous — acceptable at pilot volume, but see + the latency budget before cohort 3. + MARKDOWN spanish: "## Problema\n\nLas personas nuevas necesitan saber qué paso completar.\n\n## Resultado\n\nUna lista breve muestra el siguiente paso.", japanese: "## 目標\n\n障害の影響を小さくし、復旧までの時間を短縮します。\n\n## 次のステップ\n\n復旧手順を自動で検証します。", arabic: "## الملخص\n\nتقارن هذه المذكرة بين الجلسات قصيرة العمر وتدوير الرموز.\n\n## الخطوة التالية\n\nتشغيل تجربة محكومة لقياس الأمان." @@ -220,7 +345,8 @@ def document_content(definition) fixture = CONTENT_FIXTURES[definition[:fixture]] parts = [ "# #{definition.fetch(:title)}" ] - if %i[spanish japanese arabic].include?(definition[:fixture]) + # Fixtures that are complete document bodies — no lorem filler around them. + if %i[spanish japanese arabic code_walkthrough].include?(definition[:fixture]) parts << fixture return parts.join("\n\n") end diff --git a/engine/app/assets/stylesheets/coplan/application.css b/engine/app/assets/stylesheets/coplan/application.css index 189855c..ad37943 100644 --- a/engine/app/assets/stylesheets/coplan/application.css +++ b/engine/app/assets/stylesheets/coplan/application.css @@ -1,5 +1,33 @@ /* CoPlan — Design Tokens + Base Styles */ +/* Hack — code font (regular, bold, italic; bold-italic is synthesized). + Self-declared instead of the upstream hack.css so we get + font-display: swap — code must render immediately in a fallback mono + font rather than blocking on the download. */ +@font-face { + font-family: "Hack"; + src: url("https://cdn.jsdelivr.net/npm/hack-font@3.3.0/build/web/fonts/hack-regular-subset.woff2") format("woff2"); + font-weight: 400; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: "Hack"; + src: url("https://cdn.jsdelivr.net/npm/hack-font@3.3.0/build/web/fonts/hack-bold-subset.woff2") format("woff2"); + font-weight: 700; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: "Hack"; + src: url("https://cdn.jsdelivr.net/npm/hack-font@3.3.0/build/web/fonts/hack-italic-subset.woff2") format("woff2"); + font-weight: 400; + font-style: italic; + font-display: swap; +} + :root { color-scheme: light; @@ -37,6 +65,22 @@ --color-tag-active-bg: #374151; --color-tag-active-hover-bg: #1f2937; --color-code-bg: #f6f8fa; + /* Code blocks (fenced): block background + the terminal-style language + header. Inline code keeps --color-code-bg. */ + --code-block-bg: #f6f8fa; + --code-header-bg: #e9edf3; + --code-header-text: #57606a; + /* Syntax highlighting tokens (highlight.js classes) — GitHub Light */ + --code-comment: #6a737d; + --code-keyword: #d73a49; + --code-entity: #6f42c1; + --code-constant: #005cc5; + --code-string: #032f62; + --code-tag: #22863a; + --code-addition-fg: #22863a; + --code-addition-bg: #f0fff4; + --code-deletion-fg: #b31d28; + --code-deletion-bg: #ffeef0; --color-overlay: rgba(0, 0, 0, 0.2); --color-focus-ring: rgba(37, 99, 235, 0.12); @@ -81,7 +125,7 @@ /* Typography */ --font-sans: 'Lexend', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; - --font-mono: "SF Mono", SFMono-Regular, ui-monospace, Menlo, monospace; + --font-mono: "Hack", "SF Mono", SFMono-Regular, ui-monospace, Menlo, monospace; --text-sm: 0.875rem; --text-base: 1rem; --text-lg: 1.125rem; @@ -150,6 +194,22 @@ --color-tag-active-bg: #475569; --color-tag-active-hover-bg: #64748b; --color-code-bg: #0f172a; + --code-block-bg: #0d0d0f; + --code-header-bg: #17171a; + --code-header-text: #8b8f98; + /* Syntax highlighting tokens — One Dark-inspired. Warmer than GitHub + Dark (which paints constants and strings in two shades of blue — + too much on our already-blue slate background). */ + --code-comment: #7f848e; + --code-keyword: #c678dd; + --code-entity: #61afef; + --code-constant: #d19a66; + --code-string: #98c379; + --code-tag: #e06c75; + --code-addition-fg: #98c379; + --code-addition-bg: rgba(152, 195, 121, 0.14); + --code-deletion-fg: #e06c75; + --code-deletion-bg: rgba(224, 108, 117, 0.14); --color-overlay: rgba(2, 6, 23, 0.4); --color-focus-ring: rgba(96, 165, 250, 0.24); --color-status-considering-bg: rgba(245, 158, 11, 0.16); @@ -227,6 +287,21 @@ --color-tag-active-bg: #475569; --color-tag-active-hover-bg: #64748b; --color-code-bg: #0f172a; + --code-block-bg: #0d0d0f; + --code-header-bg: #17171a; + --code-header-text: #8b8f98; + /* Syntax highlighting tokens — One Dark-inspired (see the media-query + dark block above). */ + --code-comment: #7f848e; + --code-keyword: #c678dd; + --code-entity: #61afef; + --code-constant: #d19a66; + --code-string: #98c379; + --code-tag: #e06c75; + --code-addition-fg: #98c379; + --code-addition-bg: rgba(152, 195, 121, 0.14); + --code-deletion-fg: #e06c75; + --code-deletion-bg: rgba(224, 108, 117, 0.14); --color-overlay: rgba(2, 6, 23, 0.4); --color-focus-ring: rgba(96, 165, 250, 0.24); --color-status-considering-bg: rgba(245, 158, 11, 0.16); @@ -1389,7 +1464,7 @@ img.avatar { } .markdown-rendered pre { - background: var(--color-code-bg); + background: var(--code-block-bg); border: 1px solid var(--color-border); border-radius: var(--radius); padding: var(--space-md); @@ -1400,6 +1475,32 @@ img.avatar { line-height: 1.5; } +/* Terminal-style header on fenced code blocks that declare a language: + traffic-light dots + the language name from the lang attribute. Pure CSS + (attr() in a pseudo-element) so it adds no DOM text — comment-anchor + matching against textContent never sees it. Mermaid blocks are excluded; + they get replaced by rendered diagrams. */ +.markdown-rendered pre[lang]:not([lang="mermaid"])::before { + content: attr(lang); + display: block; + position: sticky; /* stay pinned when the code scrolls horizontally */ + left: 0; + margin: calc(-1 * var(--space-md)) calc(-1 * var(--space-md)) var(--space-md); + padding: 0.5em var(--space-md) 0.5em 4.6em; + background-color: var(--code-header-bg); + background-image: + radial-gradient(circle at 1.1em 50%, #ff5f57 0.3em, transparent 0.36em), + radial-gradient(circle at 2.2em 50%, #febc2e 0.3em, transparent 0.36em), + radial-gradient(circle at 3.3em 50%, #28c840 0.3em, transparent 0.36em); + border-bottom: 1px solid var(--color-border); + border-radius: calc(var(--radius) - 1px) calc(var(--radius) - 1px) 0 0; + color: var(--code-header-text); + font-size: 0.75rem; + line-height: 1.2; + letter-spacing: 0.04em; + user-select: none; +} + .markdown-rendered code { font-family: var(--font-mono); font-size: 0.875em; @@ -1411,6 +1512,77 @@ img.avatar { border-radius: 3px; } +/* Syntax highlighting — highlight.js token classes mapped onto the + --code-* theme variables (GitHub Light/Dark palettes above). Applied by + syntax_highlight_controller, which loads grammars on demand. */ +.markdown-rendered .hljs-comment, +.markdown-rendered .hljs-quote { + color: var(--code-comment); +} + +.markdown-rendered .hljs-keyword, +.markdown-rendered .hljs-doctag, +.markdown-rendered .hljs-template-tag, +.markdown-rendered .hljs-type { + color: var(--code-keyword); +} + +.markdown-rendered .hljs-title, +.markdown-rendered .hljs-title.class_, +.markdown-rendered .hljs-title.function_, +.markdown-rendered .hljs-section { + color: var(--code-entity); +} + +.markdown-rendered .hljs-number, +.markdown-rendered .hljs-literal, +.markdown-rendered .hljs-symbol, +.markdown-rendered .hljs-variable, +.markdown-rendered .hljs-template-variable, +.markdown-rendered .hljs-attr, +.markdown-rendered .hljs-attribute, +.markdown-rendered .hljs-built_in, +.markdown-rendered .hljs-operator, +.markdown-rendered .hljs-meta, +.markdown-rendered .hljs-selector-attr, +.markdown-rendered .hljs-selector-pseudo, +.markdown-rendered .hljs-link { + color: var(--code-constant); +} + +.markdown-rendered .hljs-string, +.markdown-rendered .hljs-regexp, +.markdown-rendered .hljs-char.escape_ { + color: var(--code-string); +} + +.markdown-rendered .hljs-name, +.markdown-rendered .hljs-tag, +.markdown-rendered .hljs-selector-tag, +.markdown-rendered .hljs-selector-id, +.markdown-rendered .hljs-selector-class, +.markdown-rendered .hljs-bullet { + color: var(--code-tag); +} + +.markdown-rendered .hljs-addition { + color: var(--code-addition-fg); + background: var(--code-addition-bg); +} + +.markdown-rendered .hljs-deletion { + color: var(--code-deletion-fg); + background: var(--code-deletion-bg); +} + +.markdown-rendered .hljs-emphasis { + font-style: italic; +} + +.markdown-rendered .hljs-strong { + font-weight: 600; +} + .markdown-rendered .mermaid-diagram { position: relative; display: flex; diff --git a/engine/app/helpers/coplan/markdown_helper.rb b/engine/app/helpers/coplan/markdown_helper.rb index 2c595fa..701f68f 100644 --- a/engine/app/helpers/coplan/markdown_helper.rb +++ b/engine/app/helpers/coplan/markdown_helper.rb @@ -34,7 +34,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 = 1 + RENDER_CACHE_VERSION = 2 # Matches `[@username](mention:username)` where the bracket text and link # target encode the same username. Username allows letters, digits, dots, @@ -56,7 +56,7 @@ def render_markdown(content, interactive: true, footnote_prefix: nil) sanitized = sanitize(with_chips, tags: ALLOWED_TAGS, attributes: ALLOWED_ATTRIBUTES) result = interactive ? make_checkboxes_interactive(sanitized, content) : sanitized result = scope_footnote_ids(result, footnote_prefix) if footnote_prefix - tag.div(result.html_safe, class: "markdown-rendered", data: { controller: "coplan--mermaid" }) + tag.div(result.html_safe, class: "markdown-rendered", data: { controller: "coplan--mermaid coplan--syntax-highlight" }) end # Replaces `@username` produced by diff --git a/engine/app/javascript/controllers/coplan/syntax_highlight_controller.js b/engine/app/javascript/controllers/coplan/syntax_highlight_controller.js new file mode 100644 index 0000000..a7aab8e --- /dev/null +++ b/engine/app/javascript/controllers/coplan/syntax_highlight_controller.js @@ -0,0 +1,142 @@ +import { Controller } from "@hotwired/stimulus" + +// Syntax-highlights fenced code blocks (`
`) with
+// highlight.js. The core library and each language grammar are loaded from
+// the CDN on demand — a page with no code blocks loads nothing, and a page
+// with only Ruby loads only the Ruby grammar. Unknown languages are left as
+// plain text.
+//
+// Highlighting rewrites the code element's innerHTML (token s) but
+// preserves its textContent exactly, so comment-anchor matching still works.
+// Any anchor s already inside a block are destroyed by the rewrite,
+// so a bubbling `coplan:highlight-settled` event is dispatched when done —
+// the text-selection controller listens and re-applies highlights, the same
+// contract the Mermaid controller uses.
+
+// The /+esm endpoint is required: the raw files in the npm package re-export
+// from CommonJS modules, which browsers can't import. jsDelivr's +esm builds
+// proper ESM bundles with default exports.
+const HLJS_VERSION = "11.11.1"
+const CDN_BASE = `https://cdn.jsdelivr.net/npm/highlight.js@${HLJS_VERSION}/lib`
+
+// Fence tags whose highlight.js grammar lives under a different file name.
+// Each grammar module registers its own aliases once loaded, but the file
+// we import must be the canonical name.
+const LANGUAGE_FILES = {
+  js: "javascript", jsx: "javascript", mjs: "javascript", cjs: "javascript",
+  ts: "typescript", tsx: "typescript", mts: "typescript", cts: "typescript",
+  html: "xml", xhtml: "xml", svg: "xml", plist: "xml",
+  sh: "bash", zsh: "bash",
+  console: "shell", shellsession: "shell",
+  yml: "yaml",
+  rb: "ruby", gemspec: "ruby", irb: "ruby",
+  py: "python",
+  golang: "go",
+  "c++": "cpp", cc: "cpp", cxx: "cpp", hpp: "cpp", hh: "cpp",
+  "c#": "csharp", cs: "csharp",
+  "f#": "fsharp", fs: "fsharp",
+  kt: "kotlin", kts: "kotlin",
+  rs: "rust",
+  ps: "powershell", ps1: "powershell",
+  docker: "dockerfile",
+  proto: "protobuf",
+  objc: "objectivec", "objective-c": "objectivec",
+  md: "markdown", mkdown: "markdown",
+  pl: "perl",
+  hs: "haskell",
+  gql: "graphql",
+  tex: "latex",
+  text: "plaintext", txt: "plaintext", plain: "plaintext"
+}
+
+let hljsPromise
+const languagePromises = new Map()
+
+function loadHljs() {
+  // Don't cache a rejected import — a transient CDN failure would otherwise
+  // disable highlighting for the rest of the Turbo session.
+  hljsPromise ||= import(`${CDN_BASE}/core/+esm`)
+    .then(module => module.default)
+    .catch(error => {
+      hljsPromise = null
+      throw error
+    })
+  return hljsPromise
+}
+
+// Resolves a fence tag to a registered grammar name, importing the grammar
+// module on first use. Returns null when the language isn't recognized.
+async function loadLanguage(hljs, lang) {
+  const name = LANGUAGE_FILES[lang] || lang
+  // Grammar file names are strictly [a-z0-9-]. The fence tag comes from
+  // untrusted plan content — anything else must never reach the CDN URL.
+  if (!/^[a-z0-9-]{1,42}$/.test(name)) return null
+  if (hljs.getLanguage(name)) return name
+
+  if (!languagePromises.has(name)) {
+    languagePromises.set(name,
+      import(`${CDN_BASE}/languages/${name}/+esm`)
+        .then(module => {
+          hljs.registerLanguage(name, module.default)
+          return name
+        })
+        .catch(() => {
+          // Unknown language or transient failure — don't cache it, so a
+          // later page view can retry.
+          languagePromises.delete(name)
+          return null
+        }))
+  }
+  return languagePromises.get(name)
+}
+
+export default class extends Controller {
+  connect() {
+    this.highlightBlocks()
+  }
+
+  async highlightBlocks() {
+    const blocks = Array.from(
+      this.element.querySelectorAll('pre[lang]:not([lang="mermaid"]) > code:not(.hljs)')
+    )
+
+    try {
+      if (blocks.length > 0) {
+        const hljs = await loadHljs()
+
+        // Phase 1: resolve all grammars concurrently without touching the
+        // DOM. Rewriting blocks one-by-one as grammars arrive would destroy
+        // comment anchor marks and leave them missing until the slowest
+        // grammar settled.
+        const jobs = await Promise.all(blocks.map(async code => {
+          const lang = code.parentElement.getAttribute("lang").toLowerCase()
+          return { code, name: await loadLanguage(hljs, lang) }
+        }))
+
+        // Phase 2: rewrite every block in one synchronous pass, then
+        // dispatch the settled event. highlightAnchors runs synchronously
+        // from that event, so no frame paints without the anchor marks.
+        for (const { code, name } of jobs) {
+          if (!name || !this.element.contains(code)) continue
+
+          // hljs.highlight (not highlightElement): the input is the block's
+          // plain text, the output is escaped token HTML with identical
+          // textContent, and no console noise about pre-existing markup.
+          const { value } = hljs.highlight(code.textContent, { language: name })
+          code.innerHTML = value
+          code.classList.add("hljs")
+        }
+      }
+    } catch {
+      // CDN unreachable — code blocks stay as readable plain text.
+    } finally {
+      // Always dispatched, even with zero code blocks: live updates replace
+      // the whole .markdown-rendered wrapper, and this reconnect event is
+      // what tells the text-selection controller to re-anchor comment marks
+      // in the new content.
+      if (this.element.isConnected) {
+        this.element.dispatchEvent(new CustomEvent("coplan:highlight-settled", { bubbles: true }))
+      }
+    }
+  }
+}
diff --git a/engine/app/javascript/controllers/coplan/text_selection_controller.js b/engine/app/javascript/controllers/coplan/text_selection_controller.js
index 12e9678..12a03ae 100644
--- a/engine/app/javascript/controllers/coplan/text_selection_controller.js
+++ b/engine/app/javascript/controllers/coplan/text_selection_controller.js
@@ -245,7 +245,10 @@ export default class extends Controller {
     this._showThreadPopoverFor(event.currentTarget, "pinned")
   }
 
-  handleMermaidSettled() {
+  // Fired after an async client-side content transform (Mermaid render,
+  // syntax highlighting) has rewritten part of the plan body — re-anchor
+  // comment highlights against the new DOM.
+  handleContentSettled() {
     this.highlightAnchors()
     if (this._pendingThreadId) this._openLinkedThread()
   }
diff --git a/engine/app/views/coplan/agent_instructions/show.text.erb b/engine/app/views/coplan/agent_instructions/show.text.erb
index 0f7dc5a..39b3a70 100644
--- a/engine/app/views/coplan/agent_instructions/show.text.erb
+++ b/engine/app/views/coplan/agent_instructions/show.text.erb
@@ -73,6 +73,20 @@ flowchart LR
 
 The web UI renders these blocks as diagrams. Keep the Mermaid source in the plan so humans and agents can review and edit it alongside the surrounding Markdown.
 
+#### Code blocks
+
+Always tag fenced code blocks with a language so the web UI can syntax-highlight them:
+
+````markdown
+```ruby
+def total(order) = order.line_items.sum(&:amount)
+```
+````
+
+Use highlight.js **canonical** language names — any language in the highlight.js distribution works, with its grammar loaded on demand. Common aliases (`js`, `ts`, `rb`, `py`, `golang`, `yml`, `sh`, `html`, `c++`, `c#`) also resolve, but prefer canonical names: `ruby`, `python`, `javascript`, `typescript`, `go`, `java`, `kotlin`, `swift`, `rust`, `c`, `cpp`, `csharp`, `sql`, `json`, `yaml`, `xml` (covers HTML), `css`, `graphql`, `protobuf`, `bash` (for scripts), `shell` (for terminal sessions with `$` prompts), `diff`, `http`, `plaintext`.
+
+Unrecognized tags render as plain text, so never invent one — use `plaintext` for logs and other unhighlightable content, and `mermaid` only for diagrams (see above).
+
 #### Rich formatting
 
 Beyond standard Markdown (tables, task lists, strikethrough), plans support:
diff --git a/engine/app/views/coplan/plans/show.html.erb b/engine/app/views/coplan/plans/show.html.erb
index 3c36aa4..5567840 100644
--- a/engine/app/views/coplan/plans/show.html.erb
+++ b/engine/app/views/coplan/plans/show.html.erb
@@ -74,7 +74,7 @@
         <%# Selection-to-comment is scoped to this inner wrapper: the back
             matter below shares the column but must not offer comment
             anchors — they'd never resolve against the document body. %>
-        
+
diff --git a/engine/app/views/layouts/coplan/application.html.erb b/engine/app/views/layouts/coplan/application.html.erb index d6aa32f..d669b62 100644 --- a/engine/app/views/layouts/coplan/application.html.erb +++ b/engine/app/views/layouts/coplan/application.html.erb @@ -17,6 +17,8 @@ <%= yield :head %> + <%# Serves the Hack code font, highlight.js grammars, and Mermaid %> + <%= stylesheet_link_tag "coplan/application", "data-turbo-track": "reload" %> <%= javascript_importmap_tags %> diff --git a/spec/helpers/markdown_helper_spec.rb b/spec/helpers/markdown_helper_spec.rb index 8bc8054..3e4e6e9 100644 --- a/spec/helpers/markdown_helper_spec.rb +++ b/spec/helpers/markdown_helper_spec.rb @@ -29,7 +29,7 @@ it "marks rendered markdown for Mermaid enhancement" do html = helper.render_markdown("```mermaid\ngraph LR\n A --> B\n```") - expect(html).to include('data-controller="coplan--mermaid"') + expect(html).to include('data-controller="coplan--mermaid coplan--syntax-highlight"') expect(html).to include('
')
       expect(html).to include("graph LR")
     end