Skip to content

Commit d655f10

Browse files
committed
fix: harden language normalization and add tests
Use Object.hasOwn instead of `in` operator in code block language matching to avoid prototype property collisions. Extract the EMPTY_VALUE sentinel into a shared constant to eliminate duplication across CodeMirrorEditor, ChangeCodeMirrorLanguage, and the normalization function. Add unit tests for normalizeCodeBlockLanguages covering both record and array formats.
1 parent e32701f commit d655f10

4 files changed

Lines changed: 100 additions & 7 deletions

File tree

src/plugins/codemirror/CodeMirrorEditor.tsx

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,11 @@ import { indentWithTab } from '@codemirror/commands'
1212
import { basicLight } from 'cm6-theme-basic-light'
1313
import { basicSetup } from 'codemirror'
1414
import { $setSelection } from 'lexical'
15-
import { codeBlockLanguages$, codeMirrorAutoLoadLanguageSupport$, codeMirrorExtensions$ } from '.'
15+
import { EMPTY_VALUE, codeBlockLanguages$, codeMirrorAutoLoadLanguageSupport$, codeMirrorExtensions$ } from '.'
1616
import { useCodeMirrorRef } from '../sandpack/useCodeMirrorRef'
1717
import { Select } from '../toolbar/primitives/select'
1818

1919
export const COMMON_STATE_CONFIG_EXTENSIONS: Extension[] = []
20-
const EMPTY_VALUE = '__EMPTY_VALUE__'
2120

2221
export const CodeMirrorEditor = ({ language, nodeKey, code, focusEmitter }: CodeBlockEditorProps) => {
2322
const t = useTranslation()

src/plugins/codemirror/index.tsx

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,11 @@ import { CodeBlockEditorDescriptor, appendCodeBlockEditorDescriptor$, insertCode
44
import { CodeMirrorEditor } from './CodeMirrorEditor'
55
import { Extension } from '@codemirror/state'
66

7+
/**
8+
* @internal
9+
*/
10+
export const EMPTY_VALUE = '__EMPTY_VALUE__'
11+
712
/**
813
* A code block language entry with a name and optional aliases/extensions.
914
* Compatible with CodeMirror's `LanguageDescription` from `@codemirror/language-data`.
@@ -61,9 +66,9 @@ export function normalizeCodeBlockLanguages(input: Record<string, string> | Code
6166
for (const [key, label] of Object.entries(input)) {
6267
if (!(label in firstKeyByLabel)) {
6368
firstKeyByLabel[label] = key
64-
items.push({ value: key || '__EMPTY_VALUE__', label })
69+
items.push({ value: key || EMPTY_VALUE, label })
6570
}
66-
keyMap[key] = firstKeyByLabel[label] || '__EMPTY_VALUE__'
71+
keyMap[key] = firstKeyByLabel[label] || EMPTY_VALUE
6772
}
6873
}
6974

@@ -162,7 +167,7 @@ export const codeMirrorPlugin = realmPlugin<{
162167
function buildCodeBlockDescriptor(normalized: NormalizedCodeBlockLanguages): CodeBlockEditorDescriptor {
163168
return {
164169
match(language, meta) {
165-
return (language ?? '') in normalized.keyMap && !meta
170+
return Object.hasOwn(normalized.keyMap, language ?? '') && !meta
166171
},
167172
priority: 1,
168173
Editor: CodeMirrorEditor

src/plugins/toolbar/components/ChangeCodeMirrorLanguage.tsx

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,10 @@ import { useCellValues } from '@mdxeditor/gurx'
22
import React from 'react'
33
import styles from '../../../styles/ui.module.css'
44
import { $isCodeBlockNode } from '../../codeblock/CodeBlockNode'
5-
import { codeBlockLanguages$ } from '../../codemirror'
5+
import { EMPTY_VALUE, codeBlockLanguages$ } from '../../codemirror'
66
import { activeEditor$, editorInFocus$, useTranslation } from '../../core'
77
import { Select } from '.././primitives/select'
88

9-
const EMPTY_VALUE = '__EMPTY_VALUE__'
109
/**
1110
* A component that allows the user to change the code block language of the current selection.
1211
* For this component to work, you must enable the `codeMirrorPlugin` for the editor.

src/test/codemirror.test.ts

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import { describe, expect, it } from 'vitest'
2+
import { normalizeCodeBlockLanguages, EMPTY_VALUE } from '../plugins/codemirror'
3+
4+
describe('normalizeCodeBlockLanguages', () => {
5+
describe('record format', () => {
6+
it('produces items and identity keyMap for simple input', () => {
7+
const result = normalizeCodeBlockLanguages({ js: 'JavaScript', css: 'CSS' })
8+
expect(result.items).toEqual([
9+
{ value: 'js', label: 'JavaScript' },
10+
{ value: 'css', label: 'CSS' }
11+
])
12+
expect(result.keyMap).toEqual({ js: 'js', css: 'css' })
13+
})
14+
15+
it('deduplicates entries with the same label', () => {
16+
const result = normalizeCodeBlockLanguages({
17+
js: 'JavaScript',
18+
javascript: 'JavaScript',
19+
css: 'CSS'
20+
})
21+
expect(result.items).toEqual([
22+
{ value: 'js', label: 'JavaScript' },
23+
{ value: 'css', label: 'CSS' }
24+
])
25+
expect(result.keyMap['js']).toBe('js')
26+
expect(result.keyMap['javascript']).toBe('js')
27+
})
28+
29+
it('handles empty string key using EMPTY_VALUE sentinel', () => {
30+
const result = normalizeCodeBlockLanguages({ '': 'Unspecified', js: 'JavaScript' })
31+
expect(result.items).toEqual([
32+
{ value: EMPTY_VALUE, label: 'Unspecified' },
33+
{ value: 'js', label: 'JavaScript' }
34+
])
35+
expect(result.keyMap['']).toBe(EMPTY_VALUE)
36+
})
37+
})
38+
39+
describe('array format', () => {
40+
it('uses first alias as canonical key', () => {
41+
const result = normalizeCodeBlockLanguages([
42+
{ name: 'JavaScript', alias: ['js', 'javascript'] },
43+
{ name: 'CSS', alias: ['css'] }
44+
])
45+
expect(result.items).toEqual([
46+
{ value: 'js', label: 'JavaScript' },
47+
{ value: 'css', label: 'CSS' }
48+
])
49+
expect(result.keyMap['js']).toBe('js')
50+
expect(result.keyMap['javascript']).toBe('js')
51+
expect(result.keyMap['css']).toBe('css')
52+
})
53+
54+
it('falls back to lowercased name when no aliases', () => {
55+
const result = normalizeCodeBlockLanguages([{ name: 'Python' }])
56+
expect(result.items).toEqual([{ value: 'python', label: 'Python' }])
57+
expect(result.keyMap['python']).toBe('python')
58+
})
59+
60+
it('maps extensions into keyMap', () => {
61+
const result = normalizeCodeBlockLanguages([
62+
{ name: 'TypeScript', alias: ['ts', 'typescript'], extensions: ['ts', 'mts'] }
63+
])
64+
expect(result.keyMap['ts']).toBe('ts')
65+
expect(result.keyMap['typescript']).toBe('ts')
66+
expect(result.keyMap['mts']).toBe('ts')
67+
})
68+
69+
it('maps lowercased name into keyMap', () => {
70+
const result = normalizeCodeBlockLanguages([
71+
{ name: 'JavaScript', alias: ['js'] }
72+
])
73+
expect(result.keyMap['javascript']).toBe('js')
74+
})
75+
})
76+
77+
describe('empty input', () => {
78+
it('handles empty record', () => {
79+
const result = normalizeCodeBlockLanguages({})
80+
expect(result.items).toEqual([])
81+
expect(result.keyMap).toEqual({})
82+
})
83+
84+
it('handles empty array', () => {
85+
const result = normalizeCodeBlockLanguages([])
86+
expect(result.items).toEqual([])
87+
expect(result.keyMap).toEqual({})
88+
})
89+
})
90+
})

0 commit comments

Comments
 (0)