Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions app/containers/codeArea/highlighting.js
Original file line number Diff line number Diff line change
@@ -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\b[^>]*>|<\/span>/g)) {
if (tag === '</span>') openSpans.pop()
else openSpans.push(tag)
}
return prefix + line + '</span>'.repeat(openSpans.length)
})
}

function shouldUsePhpTemplate (content, language) {
return typeof language === 'string' &&
language.toLowerCase() === 'php' &&
Expand Down
19 changes: 2 additions & 17 deletions app/containers/codeArea/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -252,22 +252,7 @@ export default class CodeArea extends Component {
let lineNumber = 0
const highlightedContent = highlightContent(content, language)

/*
Highlight.js wraps comment blocks inside <span class='hljs-comment'></span>.
However, when the multi-line comment block is broken down into different
table rows, only the first row, which is appended by the <span> tag, is
highlighted. The following code fixes it by appending <span> to each line
of the comment block.
*/
const commentPattern = /<span class="hljs-comment">(.|\n)*?\*\/\s*<\/span>/g
const adaptedHighlightedContent = highlightedContent.replace(commentPattern, data => {
return data.replace(/\r?\n/g, () => {
// Chromium is smart enough to add the closing </span>
return "\n<span class='hljs-comment'>"
})
})

const contentTable = adaptedHighlightedContent.split(/\r?\n/).map(lineContent => {
const contentTable = splitHighlightedLines(highlightedContent).map(lineContent => {
return `<tr>
<td class='line-number' data-pseudo-content=${++lineNumber}></td>
<td>${lineContent === '' ? '\n' : lineContent}</td>
Expand Down
20 changes: 19 additions & 1 deletion app/renderFixtures.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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',
Expand Down
33 changes: 32 additions & 1 deletion tests/containers/codeArea.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ import { describe, expect, it } from 'vitest'

import {
adaptedLanguage,
highlightContent
highlightContent,
splitHighlightedLines
} from '../../app/containers/codeArea/highlighting'

const cSource = '#include<stdio.h>\n\nint main(void) {\n printf("Hello World\\n");\n return 0;\n}'
Expand All @@ -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('<span class="hljs-string">second</span>')
expect(lines[2]).toMatch(/^<span class="hljs-string">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('<span class="hljs-comment">second</span>')
expect(lines[2]).toBe('<span class="hljs-comment">*/</span>')
expect(lines[3]).not.toContain('hljs-comment')
})

it('preserves nested scopes, escaped text, CRLF, blank lines and trailing newlines', () => {
const html = '<span class="outer">a<span class="inner">&lt;b&gt;\r\n\r\nc</span>d\r\ne</span>\r\n'
expect(splitHighlightedLines(html)).toEqual([
'<span class="outer">a<span class="inner">&lt;b&gt;</span></span>',
'<span class="outer"><span class="inner"></span></span>',
'<span class="outer"><span class="inner">c</span>d</span>',
'<span class="outer">e</span>',
''
])
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')
Expand Down
27 changes: 27 additions & 0 deletions tests/smoke/electron-render-smoke-main.js
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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') {
Expand Down
5 changes: 5 additions & 0 deletions tests/smoke/electron-render-smoke.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Loading