diff --git a/app/containers/codeArea/highlighting.js b/app/containers/codeArea/highlighting.js index c68b7780..e2911a08 100644 --- a/app/containers/codeArea/highlighting.js +++ b/app/containers/codeArea/highlighting.js @@ -1,5 +1,19 @@ import HighlightJS from 'highlight.js' +// Highlight.js emits escaped text and spans. Keep each table cell's markup +// balanced while carrying all active scopes (including nested ones) across lines. +export function splitHighlightedLines (html) { + const openSpans = [] + return html.split(/\r?\n/).map(line => { + const prefix = openSpans.join('') + for (const [tag] of line.matchAll(/]*>|<\/span>/g)) { + if (tag === '') openSpans.pop() + else openSpans.push(tag) + } + return prefix + line + ''.repeat(openSpans.length) + }) +} + function shouldUsePhpTemplate (content, language) { return typeof language === 'string' && language.toLowerCase() === 'php' && diff --git a/app/containers/codeArea/index.js b/app/containers/codeArea/index.js index 35289610..d897564f 100644 --- a/app/containers/codeArea/index.js +++ b/app/containers/codeArea/index.js @@ -5,7 +5,7 @@ import AsciiDoc from '../../utilities/asciidoc' import Markdown from '../../utilities/markdown' import React, { Component } from 'react' import electronBridge from '../../utilities/electronBridge' -import { adaptedLanguage, highlightContent } from './highlighting' +import { adaptedLanguage, highlightContent, splitHighlightedLines } from './highlighting' import { getHighlightTheme } from '../../utilities/themeManager' import { t } from '../../utilities/i18n' @@ -252,22 +252,7 @@ export default class CodeArea extends Component { let lineNumber = 0 const highlightedContent = highlightContent(content, language) - /* - Highlight.js wraps comment blocks inside . - However, when the multi-line comment block is broken down into different - table rows, only the first row, which is appended by the tag, is - highlighted. The following code fixes it by appending to each line - of the comment block. - */ - const commentPattern = /(.|\n)*?\*\/\s*<\/span>/g - const adaptedHighlightedContent = highlightedContent.replace(commentPattern, data => { - return data.replace(/\r?\n/g, () => { - // Chromium is smart enough to add the closing - return "\n" - }) - }) - - const contentTable = adaptedHighlightedContent.split(/\r?\n/).map(lineContent => { + const contentTable = splitHighlightedLines(highlightedContent).map(lineContent => { return ` ${lineContent === '' ? '\n' : lineContent} diff --git a/app/renderFixtures.js b/app/renderFixtures.js index 555fa6ee..e26b62d9 100644 --- a/app/renderFixtures.js +++ b/app/renderFixtures.js @@ -179,7 +179,17 @@ const gists = { filename: 'parser.py', language: 'Python', raw_url: 'https://gist.githubusercontent.com/lepton-fixture/mock/raw/parser.py', - content: 'def parse(value):\n return value.strip()\n' + content: [ + 'def parse(value):', + ' """Normalize a value.', + '', + ' Preserve multiline docstring highlighting.', + ' """', + " example = '''first line", + "second line'''", + ' return value.strip()', + '' + ].join('\n') } }, '2026-06-28T14:30:00Z' @@ -329,6 +339,14 @@ function getFixtureOverrides (name) { tags: [] } } + case 'python-multiline': + return { + activeGist: 'fixture-gist-2', + activeGistFilters: { + language: Prefixed('Python'), + tags: [] + } + } case 'jupyter-notebook': return { activeGist: 'fixture-gist-6', diff --git a/tests/containers/codeArea.test.js b/tests/containers/codeArea.test.js index 730e77cd..5e1549f4 100644 --- a/tests/containers/codeArea.test.js +++ b/tests/containers/codeArea.test.js @@ -2,7 +2,8 @@ import { describe, expect, it } from 'vitest' import { adaptedLanguage, - highlightContent + highlightContent, + splitHighlightedLines } from '../../app/containers/codeArea/highlighting' const cSource = '#include\n\nint main(void) {\n printf("Hello World\\n");\n return 0;\n}' @@ -13,6 +14,36 @@ const typedReturnPythonSource = [ ].join('\n') describe('CodeArea syntax highlighting', () => { + it.each(['"""', "'''"])('preserves multiline Python strings delimited by %s', quote => { + const source = `message = ${quote}first\nsecond\nthird${quote}\nprint(message)` + const lines = splitHighlightedLines(highlightContent(source, 'python')) + + expect(lines).toHaveLength(4) + expect(lines[1]).toBe('second') + expect(lines[2]).toMatch(/^third.*<\/span>$/) + expect(lines[3]).not.toContain('hljs-string') + }) + + it('preserves multiline comments and stops highlighting after the closing delimiter', () => { + const lines = splitHighlightedLines(highlightContent('/* first\nsecond\n*/\nint answer = 42;', 'c')) + expect(lines[1]).toBe('second') + expect(lines[2]).toBe('*/') + expect(lines[3]).not.toContain('hljs-comment') + }) + + it('preserves nested scopes, escaped text, CRLF, blank lines and trailing newlines', () => { + const html = 'a<b>\r\n\r\ncd\r\ne\r\n' + expect(splitHighlightedLines(html)).toEqual([ + 'a<b>', + '', + 'cd', + 'e', + '' + ]) + expect(splitHighlightedLines('')).toEqual(['']) + expect(splitHighlightedLines('plain\n\ntext\n')).toEqual(['plain', '', 'text', '']) + }) + it('normalizes GitHub language names for Highlight.js', () => { expect(adaptedLanguage('main.c', 'C')).toBe('c') expect(adaptedLanguage('main.cpp', 'C++')).toBe('cpp') diff --git a/tests/smoke/electron-render-smoke-main.js b/tests/smoke/electron-render-smoke-main.js index 3d592197..ef980ecb 100644 --- a/tests/smoke/electron-render-smoke-main.js +++ b/tests/smoke/electron-render-smoke-main.js @@ -1390,6 +1390,32 @@ async function assertFixtureImageAttachments (window) { } } +async function assertPythonMultilineHighlighting (window) { + if (process.env.LEPTON_RENDER_FIXTURE !== 'python-multiline') return + const rows = await window.webContents.executeJavaScript(` + Array.from(document.querySelectorAll('.code-table tr')).map(row => { + const cell = row.cells[1] + const string = cell.querySelector('.hljs-string') + return { + text: cell.textContent, + stringText: string && string.textContent, + color: string && getComputedStyle(string).color, + height: row.getBoundingClientRect().height, + line: row.cells[0].getAttribute('data-pseudo-content') + } + }) + `) + const stringRows = [1, 2, 3, 4, 5, 6] + if (rows.length !== 9 || + rows.some((row, index) => row.line !== String(index + 1) || row.height <= 0) || + stringRows.some(index => rows[index].stringText === null || rows[index].color !== rows[1].color) || + rows[3].stringText !== ' Preserve multiline docstring highlighting.' || + rows[6].stringText !== "second line'''" || + rows[7].stringText !== null) { + throw new Error('Multiline Python highlighting or line layout was lost: ' + JSON.stringify(rows)) + } +} + async function main () { let window @@ -1402,6 +1428,7 @@ async function main () { await waitForFixtureUi(window) await applyFixtureFilterClicks(window) assertFixtureRendererState(await getRendererState(window)) + await assertPythonMultilineHighlighting(window) await assertFixtureLoginModeSwitch(window) await assertFixturePageFind(window) if (process.env.LEPTON_RENDER_FIXTURE === 'new-draft') { diff --git a/tests/smoke/electron-render-smoke.js b/tests/smoke/electron-render-smoke.js index cca72ab9..9ec5adc9 100644 --- a/tests/smoke/electron-render-smoke.js +++ b/tests/smoke/electron-render-smoke.js @@ -38,6 +38,11 @@ function createTempHome (locale = 'en', configOverrides = {}) { } const RENDER_FIXTURES = [ + { + name: 'python-multiline', + selector: '.code-table .hljs-string', + text: 'Preserve multiline docstring highlighting.' + }, { name: 'active', selector: '.active-layout .snippet-box',