From fa177558b3d0c953eec34c916725e4378d0663f0 Mon Sep 17 00:00:00 2001 From: Jon Kinney Date: Fri, 27 Mar 2026 15:31:09 -0500 Subject: [PATCH] Add code block enhancements - Switch Yarn to node-modules linker (.yarnrc.yml) for Prism compat - Add inlineDynamicImports to Rollup for single-file bundle - Add 28 new Prism language components for syntax highlighting - Detect CommonMark
 format on paste
- Add triple-backtick code fence keyboard shortcut
- CSS for code blocks, syntax tokens, and blockquotes

Co-Authored-By: Claude Opus 4.6 (1M context) 
---
 .gitignore                                |  2 +
 .yarnrc.yml                               |  1 +
 app/assets/stylesheets/lexxy-editor.css   | 47 +++++++++++++++++++++++
 rollup.config.mjs                         |  4 +-
 src/config/prism.js                       | 30 ++++++++++++++-
 src/elements/code_language_picker.js      | 13 +++++++
 src/elements/editor.js                    | 23 ++++++++++-
 src/extensions/trix_content_extension.js  | 20 +++++++++-
 test/browser/tests/paste/markdown.test.js | 15 ++++++++
 9 files changed, 149 insertions(+), 6 deletions(-)
 create mode 100644 .yarnrc.yml

diff --git a/.gitignore b/.gitignore
index 4152d107a..0cab290c4 100644
--- a/.gitignore
+++ b/.gitignore
@@ -25,3 +25,5 @@
 /docs/.jekyll-cache/
 /docs/.jekyll-metadata
 /docs/Gemfile.lock
+.yarn/install-state.gz
+.yarn/install-state.gz
diff --git a/.yarnrc.yml b/.yarnrc.yml
new file mode 100644
index 000000000..3186f3f07
--- /dev/null
+++ b/.yarnrc.yml
@@ -0,0 +1 @@
+nodeLinker: node-modules
diff --git a/app/assets/stylesheets/lexxy-editor.css b/app/assets/stylesheets/lexxy-editor.css
index b075e30fb..793f69be5 100644
--- a/app/assets/stylesheets/lexxy-editor.css
+++ b/app/assets/stylesheets/lexxy-editor.css
@@ -399,6 +399,53 @@
   min-block-size: var(--lexxy-editor-rows);
   outline: 0;
   padding: var(--lexxy-editor-padding);
+
+  /* Code blocks in editor */
+  code, pre {
+    background-color: var(--lexxy-color-code-bg, var(--lexxy-color-ink-lightest));
+    border-radius: var(--lexxy-radius);
+    color: var(--lexxy-color-code-text, var(--lexxy-color-ink));
+    font-family: var(--lexxy-font-mono);
+    font-size: 0.9em;
+    padding: 0.25ch 0.5ch;
+
+    &:is(pre),
+    &[data-language] {
+      display: block;
+      hyphens: none;
+      margin-block: 0 var(--lexxy-content-margin, 1rem);
+      overflow-x: auto;
+      padding: 1ch;
+      padding-block-start: 2.5em;
+      tab-size: 2;
+      text-wrap: nowrap;
+      white-space: pre;
+      word-break: break-word;
+    }
+  }
+
+  /* Syntax highlighting token colors */
+  .code-token__attr { color: var(--lexxy-color-code-token-att); }
+  .code-token__property { color: var(--lexxy-color-code-token-property); }
+  .code-token__selector { color: var(--lexxy-color-code-token-selector); }
+  .code-token__comment { color: var(--lexxy-color-code-token-comment); font-style: italic; }
+  .code-token__operator { color: var(--lexxy-color-code-token-operator); }
+  .code-token__function { color: var(--lexxy-color-code-token-function); }
+  .code-token__variable { color: var(--lexxy-color-code-token-variable); }
+  .code-token__punctuation { color: var(--lexxy-color-code-token-punctuation); }
+
+  /* Blockquotes in editor */
+  blockquote {
+    border-inline-start: 3px solid var(--lexxy-color-ink-lighter);
+    color: var(--lexxy-color-ink-medium);
+    margin-block: 0 var(--lexxy-content-margin, 1rem);
+    margin-inline: 0;
+    padding: 0.25lh 2ch;
+
+    p:last-child {
+      margin-block-end: 0;
+    }
+  }
 }
 
 :where(.lexxy-editor--drag-over) {
diff --git a/rollup.config.mjs b/rollup.config.mjs
index b5cf8348e..434625c36 100644
--- a/rollup.config.mjs
+++ b/rollup.config.mjs
@@ -17,11 +17,13 @@ export default [
       {
         file: "./app/assets/javascript/lexxy.js",
         format: "esm",
-        sourcemap: true
+        sourcemap: true,
+        inlineDynamicImports: true
       },
       {
         file: "./app/assets/javascript/lexxy.min.js",
         format: "esm",
+        inlineDynamicImports: true,
         plugins: [ terser() ]
       }
     ],
diff --git a/src/config/prism.js b/src/config/prism.js
index 882efc279..e1b20424f 100644
--- a/src/config/prism.js
+++ b/src/config/prism.js
@@ -10,10 +10,38 @@ import "prismjs/components/prism-clike"
 import "prismjs/components/prism-markup"
 import "prismjs/components/prism-markup-templating"
 
-// Import languages
+// Languages also bundled by @lexical/code for in-editor highlighting
+import "prismjs/components/prism-c"
+import "prismjs/components/prism-cpp"
+import "prismjs/components/prism-css"
+import "prismjs/components/prism-java"
+import "prismjs/components/prism-javascript"
+import "prismjs/components/prism-markdown"
+import "prismjs/components/prism-objectivec"
+import "prismjs/components/prism-powershell"
+import "prismjs/components/prism-python"
+import "prismjs/components/prism-rust"
+import "prismjs/components/prism-sql"
+import "prismjs/components/prism-swift"
+import "prismjs/components/prism-typescript"
+
+// Additional languages for common use cases
 import "prismjs/components/prism-ruby"
 import "prismjs/components/prism-php"
 import "prismjs/components/prism-go"
 import "prismjs/components/prism-bash"
 import "prismjs/components/prism-json"
 import "prismjs/components/prism-diff"
+import "prismjs/components/prism-yaml"
+import "prismjs/components/prism-kotlin"
+import "prismjs/components/prism-docker"
+import "prismjs/components/prism-graphql"
+import "prismjs/components/prism-jsx"
+import "prismjs/components/prism-tsx"
+import "prismjs/components/prism-scss"
+import "prismjs/components/prism-regex"
+import "prismjs/components/prism-toml"
+import "prismjs/components/prism-lua"
+import "prismjs/components/prism-elixir"
+import "prismjs/components/prism-erlang"
+import "prismjs/components/prism-hcl"
diff --git a/src/elements/code_language_picker.js b/src/elements/code_language_picker.js
index de9d3a977..b5b25bfa3 100644
--- a/src/elements/code_language_picker.js
+++ b/src/elements/code_language_picker.js
@@ -46,12 +46,25 @@ export class CodeLanguagePicker extends HTMLElement {
   get #languages() {
     const languages = { ...CODE_LANGUAGE_FRIENDLY_NAME_MAP }
 
+    // Add languages supported by Lexxy's Prism config but not in Lexical's default map
     if (!languages.ruby) languages.ruby = "Ruby"
     if (!languages.php) languages.php = "PHP"
     if (!languages.go) languages.go = "Go"
     if (!languages.bash) languages.bash = "Bash"
     if (!languages.json) languages.json = "JSON"
     if (!languages.diff) languages.diff = "Diff"
+    if (!languages.yaml) languages.yaml = "YAML"
+    if (!languages.kotlin) languages.kotlin = "Kotlin"
+    if (!languages.docker) languages.docker = "Docker"
+    if (!languages.graphql) languages.graphql = "GraphQL"
+    if (!languages.jsx) languages.jsx = "JSX"
+    if (!languages.tsx) languages.tsx = "TSX"
+    if (!languages.scss) languages.scss = "SCSS"
+    if (!languages.toml) languages.toml = "TOML"
+    if (!languages.lua) languages.lua = "Lua"
+    if (!languages.elixir) languages.elixir = "Elixir"
+    if (!languages.erlang) languages.erlang = "Erlang"
+    if (!languages.hcl) languages.hcl = "HCL"
 
     const sortedEntries = Object.entries(languages)
       .sort(([ , a ], [ , b ]) => a.localeCompare(b))
diff --git a/src/elements/editor.js b/src/elements/editor.js
index 7fa0df19f..676d4cb50 100644
--- a/src/elements/editor.js
+++ b/src/elements/editor.js
@@ -1,11 +1,11 @@
-import { $addUpdateTag, $createParagraphNode, $getRoot, $isElementNode, $isLineBreakNode, $isTextNode, CLEAR_HISTORY_COMMAND, COMMAND_PRIORITY_NORMAL, KEY_ENTER_COMMAND, SKIP_DOM_SELECTION_TAG, TextNode } from "lexical"
+import { $addUpdateTag, $createParagraphNode, $getRoot, $isElementNode, $isLineBreakNode, $isParagraphNode, $isTextNode, CLEAR_HISTORY_COMMAND, COMMAND_PRIORITY_NORMAL, KEY_ENTER_COMMAND, SKIP_DOM_SELECTION_TAG, TextNode } from "lexical"
 import { buildEditorFromExtensions } from "@lexical/extension"
 import { ListItemNode, ListNode, registerList } from "@lexical/list"
 import { AutoLinkNode, LinkNode } from "@lexical/link"
 import { registerPlainText } from "@lexical/plain-text"
 import { HeadingNode, QuoteNode, registerRichText } from "@lexical/rich-text"
 import { $generateHtmlFromNodes, $generateNodesFromDOM } from "@lexical/html"
-import { CodeHighlightNode, CodeNode, registerCodeHighlighting } from "@lexical/code"
+import { $createCodeNode, CodeHighlightNode, CodeNode, registerCodeHighlighting } from "@lexical/code"
 import { TRANSFORMERS, registerMarkdownShortcuts } from "@lexical/markdown"
 import { registerMarkdownLeadingTagHandler } from "../editor/markdown/leading_tag_handler"
 import { createEmptyHistoryState, registerHistory } from "@lexical/history"
@@ -397,6 +397,7 @@ export class LexicalEditorElement extends HTMLElement {
 
   #registerCodeHiglightingComponents() {
     registerCodeHighlighting(this.editor)
+    registerCodeFenceShortcut(this.editor)
     this.codeLanguagePicker = createElement("lexxy-code-language-picker")
     this.append(this.codeLanguagePicker)
   }
@@ -554,6 +555,24 @@ export class LexicalEditorElement extends HTMLElement {
 
 export default LexicalEditorElement
 
+const CODE_FENCE_REGEX = /^`{3,}([\w-]*)$/
+
+function registerCodeFenceShortcut(editor) {
+  return editor.registerNodeTransform(TextNode, (textNode) => {
+    const parent = textNode.getParent()
+    if (!$isParagraphNode(parent)) return
+    if (parent.getChildrenSize() !== 1) return
+
+    const text = textNode.getTextContent()
+    if (!text.match(CODE_FENCE_REGEX)) return
+
+    const language = text.replace(/^`+/, "") || undefined
+    const codeNode = $createCodeNode(language)
+    parent.replace(codeNode)
+    codeNode.select()
+  })
+}
+
 // Like $getRoot().getTextContent() but uses readable text for custom attachment nodes
 // (e.g., mentions) instead of their single-character cursor placeholder.
 function $getReadableTextContent(node) {
diff --git a/src/extensions/trix_content_extension.js b/src/extensions/trix_content_extension.js
index 67909d1d2..38c9a5ba5 100644
--- a/src/extensions/trix_content_extension.js
+++ b/src/extensions/trix_content_extension.js
@@ -54,10 +54,26 @@ function $applyStrikethrough(textNode) {
 }
 
 function onlyPreLanguageElements(element, conversion) {
-  return element.hasAttribute(TRIX_LANGUAGE_ATTR) ? conversion : null
+  return detectLanguage(element) ? conversion : null
+}
+
+function detectLanguage(element) {
+  // Trix format: 
+  if (element.hasAttribute(TRIX_LANGUAGE_ATTR)) {
+    return element.getAttribute(TRIX_LANGUAGE_ATTR)
+  }
+
+  // CommonMark / marked format: 

+  const codeChild = element.querySelector("code[class*='language-']")
+  if (codeChild) {
+    const match = codeChild.className.match(/language-(\S+)/)
+    if (match) return match[1]
+  }
+
+  return null
 }
 
 function $applyLanguage(conversionOutput, element) {
-  const language = normalizeCodeLang(element.getAttribute(TRIX_LANGUAGE_ATTR))
+  const language = normalizeCodeLang(detectLanguage(element))
   conversionOutput.node.setLanguage(language)
 }
diff --git a/test/browser/tests/paste/markdown.test.js b/test/browser/tests/paste/markdown.test.js
index 5e015249c..a3429a930 100644
--- a/test/browser/tests/paste/markdown.test.js
+++ b/test/browser/tests/paste/markdown.test.js
@@ -11,6 +11,21 @@ test.describe("Paste — Markdown", () => {
     await assertEditorHtml(editor, "

Hello there

") }) + test("preserve language when pasting markdown code fences", async ({ + page, + editor, + }) => { + await page.goto("/") + await editor.waitForConnected() + + await editor.paste("```ruby\ndef hello\n puts 'world'\nend\n```") + + await assertEditorContent(editor, async (content) => { + await expect(content.locator("code[data-highlight-language='ruby']")).toBeVisible() + await expect(content).toContainText("def hello") + }) + }) + test("don't convert markdown when pasting into code block", async ({ page, editor,