Skip to content

Commit f478509

Browse files
Godefroypetyosi
andauthored
fix: render code blocks with unknown language as plain text (#927)
* fix: render code blocks with unknown language as plain text - CodeMirror descriptor no longer restricts to configured languages, so unknown languages fall back to a plain-text editor instead of crashing - Inject unknown language as a temporary Select item so the dropdown reflects the current value - Normalize null language/meta to '' in MdastCodeVisitor so the declared string type holds at runtime (fixes the no-language code block case) - Handle empty-string canonical in array format of normalizeCodeBlockLanguages via EMPTY_VALUE sentinel (mirrors record format); enables a "Plain text" entry with alias: [''] - Add CodeMirrorUnknownLanguage Ladle example demonstrating the behavior - Add unit tests for unknown language lookups and empty canonical mapping * fix: keep code block language selectors in sync --------- Co-authored-by: Petyo Ivanov <underlog@gmail.com>
1 parent 93ae1ef commit f478509

7 files changed

Lines changed: 177 additions & 21 deletions

File tree

docs/code-blocks.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,9 @@ codeMirrorPlugin({
123123
})
124124
```
125125

126-
With the array format, all aliases and extensions are recognized when matching code blocks, but the language select dropdown shows only one entry per language. This is especially useful when your markdown may use different identifiers for the same language (e.g. `js` vs `javascript`).
126+
With the array format, all aliases and extensions are recognized when resolving code block languages, but the language select dropdown shows only one entry per language. This is especially useful when your markdown may use different identifiers for the same language (e.g. `js` vs `javascript`).
127+
128+
If a fenced code block has no `meta`, the CodeMirror editor acts as the default editor even when its language is not listed in `codeBlockLanguages`. In that case, the block falls back to plain-text editing and the unknown language is shown as a temporary item in the language picker so the user can keep it or switch to a configured language.
127129

128130
You can also pass the `languages` array from `@codemirror/language-data` directly to support all CodeMirror languages:
129131

src/examples/codemirror-languages.tsx

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import React from 'react'
2-
import { MDXEditor, codeBlockPlugin, codeMirrorPlugin } from '../'
2+
import { ChangeCodeMirrorLanguage, ConditionalContents, MDXEditor, codeBlockPlugin, codeMirrorPlugin, toolbarPlugin } from '../'
33
import { languages } from '@codemirror/language-data'
44
import { markdown } from '@codemirror/lang-markdown'
55
import { EditorView, keymap } from '@codemirror/view'
@@ -62,6 +62,21 @@ Markdown support is loaded:
6262
\`\`\`
6363
`
6464

65+
function codeBlockLanguageToolbar() {
66+
return toolbarPlugin({
67+
toolbarContents: () => (
68+
<ConditionalContents
69+
options={[
70+
{ when: (editor) => editor?.editorType === 'codeblock', contents: () => <ChangeCodeMirrorLanguage /> },
71+
{
72+
fallback: () => <span>Focus a code block to edit its language from the toolbar.</span>
73+
}
74+
]}
75+
/>
76+
)
77+
})
78+
}
79+
6580
/**
6681
* Uses the CodeMirror `languages` array directly from `@codemirror/language-data`.
6782
* All aliases and extensions are recognized in the language select.
@@ -72,6 +87,7 @@ export function CodeMirrorLanguageData() {
7287
onChange={console.log}
7388
markdown={aliasSampleMarkdown}
7489
plugins={[
90+
codeBlockLanguageToolbar(),
7591
codeBlockPlugin(),
7692
codeMirrorPlugin({
7793
codeBlockLanguages: languages
@@ -91,6 +107,7 @@ export function CodeMirrorLanguageArray() {
91107
onChange={console.log}
92108
markdown={aliasSampleMarkdown}
93109
plugins={[
110+
codeBlockLanguageToolbar(),
94111
codeBlockPlugin(),
95112
codeMirrorPlugin({
96113
codeBlockLanguages: [
@@ -112,6 +129,7 @@ export function CodeMirrorLanguageWithSupport() {
112129
onChange={console.log}
113130
markdown={supportSampleMarkdown}
114131
plugins={[
132+
codeBlockLanguageToolbar(),
115133
codeBlockPlugin(),
116134
codeMirrorPlugin({
117135
codeBlockLanguages: [
@@ -136,6 +154,7 @@ export function CodeMirrorExtensions() {
136154
onChange={console.log}
137155
markdown={simpleSampleMarkdown}
138156
plugins={[
157+
codeBlockLanguageToolbar(),
139158
codeBlockPlugin(),
140159
codeMirrorPlugin({
141160
codeBlockLanguages: { js: 'JavaScript', ts: 'TypeScript' },
@@ -154,6 +173,50 @@ export function CodeMirrorExtensions() {
154173
)
155174
}
156175

176+
const unknownLanguageSampleMarkdown = `
177+
Known \`js\` language:
178+
179+
\`\`\`js
180+
const x = 1
181+
\`\`\`
182+
183+
Unknown \`brainfuck\` language (rendered as plain text, no crash):
184+
185+
\`\`\`brainfuck
186+
++++++++[>++++[>++>+++>+++>+<<<<-]>+>+>->>+[<]<-]>>.>---.+++++++..+++.
187+
\`\`\`
188+
189+
No language at all:
190+
191+
\`\`\`
192+
plain content
193+
\`\`\`
194+
`
195+
196+
/**
197+
* A code block whose language is not in the configured list falls back to a plain-text CodeMirror editor
198+
* instead of crashing. The unknown language is shown as-is in the language select so the user can keep or change it.
199+
*/
200+
export function CodeMirrorUnknownLanguage() {
201+
return (
202+
<MDXEditor
203+
onChange={console.log}
204+
markdown={unknownLanguageSampleMarkdown}
205+
plugins={[
206+
codeBlockLanguageToolbar(),
207+
codeBlockPlugin(),
208+
codeMirrorPlugin({
209+
codeBlockLanguages: [
210+
{ name: 'Plain text', alias: [''] },
211+
{ name: 'JavaScript', alias: ['js', 'javascript'] }
212+
],
213+
autoLoadLanguageSupport: false
214+
})
215+
]}
216+
/>
217+
)
218+
}
219+
157220
/**
158221
* Uses the legacy `Record<string, string>` format. Still works as before.
159222
*/
@@ -163,6 +226,7 @@ export function CodeMirrorLanguageRecord() {
163226
onChange={console.log}
164227
markdown={aliasSampleMarkdown}
165228
plugins={[
229+
codeBlockLanguageToolbar(),
166230
codeBlockPlugin(),
167231
codeMirrorPlugin({
168232
codeBlockLanguages: { js: 'JavaScript', javascript: 'JavaScript', ts: 'TypeScript', typescript: 'TypeScript' }

src/plugins/codeblock/MdastCodeVisitor.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,8 @@ export const MdastCodeVisitor: MdastImportVisitor<Mdast.Code> = {
1414
actions.addAndStepInto(
1515
$createCodeBlockNode({
1616
code: mdastNode.value,
17-
language: mdastNode.lang!,
18-
meta: mdastNode.meta!
17+
language: mdastNode.lang ?? '',
18+
meta: mdastNode.meta ?? ''
1919
})
2020
)
2121
}

src/plugins/codemirror/CodeMirrorEditor.tsx

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ 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 { EMPTY_VALUE, codeBlockLanguages$, codeMirrorAutoLoadLanguageSupport$, codeMirrorExtensions$ } from '.'
15+
import { EMPTY_VALUE, codeBlockLanguages$, codeMirrorAutoLoadLanguageSupport$, codeMirrorExtensions$, getCodeBlockLanguageSelectData } from '.'
1616
import { useCodeMirrorRef } from '../sandpack/useCodeMirrorRef'
1717
import { Select } from '../toolbar/primitives/select'
1818

@@ -34,6 +34,11 @@ export const CodeMirrorEditor = ({ language, nodeKey, code, focusEmitter }: Code
3434
const editorViewRef = React.useRef<EditorView | null>(null)
3535
const elRef = React.useRef<HTMLDivElement | null>(null)
3636

37+
const { value: selectValue, items: selectItems } = React.useMemo(
38+
() => getCodeBlockLanguageSelectData(codeBlockLanguages, language),
39+
[codeBlockLanguages, language]
40+
)
41+
3742
const setCodeRef = React.useRef(setCode)
3843
setCodeRef.current = setCode
3944
codeMirrorRef.current = {
@@ -104,7 +109,7 @@ export const CodeMirrorEditor = ({ language, nodeKey, code, focusEmitter }: Code
104109
<div className={styles.codeMirrorToolbar}>
105110
<Select
106111
disabled={readOnly}
107-
value={codeBlockLanguages.keyMap[language] ?? language}
112+
value={selectValue}
108113
onChange={(language) => {
109114
parentEditor.update(() => {
110115
lexicalNode.setLanguage(language === EMPTY_VALUE ? '' : language)
@@ -117,7 +122,7 @@ export const CodeMirrorEditor = ({ language, nodeKey, code, focusEmitter }: Code
117122
}}
118123
triggerTitle={t('codeBlock.selectLanguage', 'Select code block language')}
119124
placeholder={t('codeBlock.inlineLanguage', 'Language')}
120-
items={codeBlockLanguages.items}
125+
items={selectItems}
121126
/>
122127
<button
123128
className={styles.iconButton}

src/plugins/codemirror/index.tsx

Lines changed: 28 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -57,10 +57,13 @@ export function normalizeCodeBlockLanguages(input: Record<string, string> | Code
5757

5858
if (Array.isArray(input)) {
5959
for (const lang of input) {
60-
// The canonical key is the first alias, or the lowercased name
61-
const canonical = lang.alias?.[0] ?? lang.name.toLowerCase()
60+
// The canonical key is the first alias, or the lowercased name.
61+
// Empty string canonical (e.g. Plain text with alias: ['']) is stored as EMPTY_VALUE
62+
// because Radix Select does not accept items with an empty string value.
63+
const rawCanonical = lang.alias?.[0] ?? lang.name.toLowerCase()
64+
const canonical = rawCanonical || EMPTY_VALUE
6265
items.push({ value: canonical, label: lang.name })
63-
keyMap[canonical] = canonical
66+
keyMap[rawCanonical] = canonical
6467
if (lang.alias) {
6568
for (const alias of lang.alias) {
6669
keyMap[alias] = canonical
@@ -91,6 +94,25 @@ export function normalizeCodeBlockLanguages(input: Record<string, string> | Code
9194
return { items, keyMap, supportMap }
9295
}
9396

97+
/**
98+
* Resolves the current language to the select value and items used by CodeMirror language pickers.
99+
* Unknown languages are added as a temporary item so the picker remains in sync with the code block value.
100+
*/
101+
export function getCodeBlockLanguageSelectData(
102+
normalized: NormalizedCodeBlockLanguages,
103+
language: string
104+
): { value: string; items: { value: string; label: string }[] } {
105+
const value = normalized.keyMap[language] ?? language
106+
if (!value || normalized.items.some((item) => item.value === value)) {
107+
return { value, items: normalized.items }
108+
}
109+
110+
return {
111+
value,
112+
items: [...normalized.items, { value, label: language }]
113+
}
114+
}
115+
94116
/**
95117
* The normalized code block languages used by the CodeMirror editor.
96118
* @group CodeMirror
@@ -180,10 +202,10 @@ export const codeMirrorPlugin = realmPlugin<{
180202
}
181203
})
182204

183-
function buildCodeBlockDescriptor(normalized: NormalizedCodeBlockLanguages): CodeBlockEditorDescriptor {
205+
function buildCodeBlockDescriptor(_normalized: NormalizedCodeBlockLanguages): CodeBlockEditorDescriptor {
184206
return {
185-
match(language, meta) {
186-
return Object.hasOwn(normalized.keyMap, language ?? '') && !meta
207+
match(_language, meta) {
208+
return !meta
187209
},
188210
priority: 1,
189211
Editor: CodeMirrorEditor

src/plugins/toolbar/components/ChangeCodeMirrorLanguage.tsx

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ 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 { EMPTY_VALUE, codeBlockLanguages$ } from '../../codemirror'
5+
import { EMPTY_VALUE, codeBlockLanguages$, getCodeBlockLanguageSelectData } from '../../codemirror'
66
import { activeEditor$, editorInFocus$, useTranslation } from '../../core'
77
import { Select } from '.././primitives/select'
88

@@ -22,15 +22,12 @@ export const ChangeCodeMirrorLanguage = () => {
2222
}
2323

2424
const rawLanguage = codeBlockNode.getLanguage()
25-
let currentLanguage = codeBlockLanguages.keyMap[rawLanguage] ?? rawLanguage
26-
if (currentLanguage === '') {
27-
currentLanguage = EMPTY_VALUE
28-
}
25+
const { value: currentLanguage, items } = getCodeBlockLanguageSelectData(codeBlockLanguages, rawLanguage)
2926
return (
3027
<div className={styles.selectWithLabel}>
3128
<label>{t('codeBlock.language', 'Code block language')}</label>
3229
<Select
33-
value={currentLanguage}
30+
value={currentLanguage || EMPTY_VALUE}
3431
onChange={(language) => {
3532
theEditor?.update(() => {
3633
codeBlockNode.setLanguage(language === EMPTY_VALUE ? '' : language)
@@ -43,7 +40,7 @@ export const ChangeCodeMirrorLanguage = () => {
4340
}}
4441
triggerTitle={t('codeBlock.selectLanguage', 'Select code block language')}
4542
placeholder={t('codeBlock.language', 'Code block language')}
46-
items={codeBlockLanguages.items}
43+
items={items}
4744
/>
4845
</div>
4946
)

src/test/codemirror.test.ts

Lines changed: 67 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, expect, it } from 'vitest'
2-
import { normalizeCodeBlockLanguages, EMPTY_VALUE, type CodeBlockLanguageSupport } from '../plugins/codemirror'
2+
import { getCodeBlockLanguageSelectData, normalizeCodeBlockLanguages, EMPTY_VALUE, type CodeBlockLanguageSupport } from '../plugins/codemirror'
33

44
describe('normalizeCodeBlockLanguages', () => {
55
describe('record format', () => {
@@ -68,6 +68,18 @@ describe('normalizeCodeBlockLanguages', () => {
6868
const result = normalizeCodeBlockLanguages([{ name: 'JavaScript', alias: ['js'] }])
6969
expect(result.keyMap.javascript).toBe('js')
7070
})
71+
72+
it('maps empty-string canonical to EMPTY_VALUE sentinel', () => {
73+
const result = normalizeCodeBlockLanguages([
74+
{ name: 'Plain text', alias: [''] },
75+
{ name: 'JavaScript', alias: ['js'] }
76+
])
77+
expect(result.items).toEqual([
78+
{ value: EMPTY_VALUE, label: 'Plain text' },
79+
{ value: 'js', label: 'JavaScript' }
80+
])
81+
expect(result.keyMap['']).toBe(EMPTY_VALUE)
82+
})
7183
})
7284

7385
describe('supportMap', () => {
@@ -107,4 +119,58 @@ describe('normalizeCodeBlockLanguages', () => {
107119
expect(result.keyMap).toEqual({})
108120
})
109121
})
122+
123+
describe('unknown language', () => {
124+
it('returns undefined from keyMap for an unknown language (record format)', () => {
125+
const result = normalizeCodeBlockLanguages({ js: 'JavaScript' })
126+
expect(result.keyMap.brainfuck).toBeUndefined()
127+
expect(Object.hasOwn(result.keyMap, 'brainfuck')).toBe(false)
128+
})
129+
130+
it('returns undefined from keyMap for an unknown language (array format)', () => {
131+
const result = normalizeCodeBlockLanguages([{ name: 'JavaScript', alias: ['js'], extensions: ['js', 'mjs'] }])
132+
expect(result.keyMap.brainfuck).toBeUndefined()
133+
expect(Object.hasOwn(result.keyMap, 'brainfuck')).toBe(false)
134+
})
135+
136+
it('returns undefined from supportMap for an unknown language', () => {
137+
const mockSupport: CodeBlockLanguageSupport = { extension: [] }
138+
const result = normalizeCodeBlockLanguages([{ name: 'JavaScript', alias: ['js'], support: mockSupport }])
139+
expect(result.supportMap.brainfuck).toBeUndefined()
140+
})
141+
142+
it('does not include an unknown language in items', () => {
143+
const result = normalizeCodeBlockLanguages([{ name: 'JavaScript', alias: ['js'] }])
144+
expect(result.items.find((item) => item.value === 'brainfuck')).toBeUndefined()
145+
})
146+
})
147+
})
148+
149+
describe('getCodeBlockLanguageSelectData', () => {
150+
it('returns configured items for a known language', () => {
151+
const normalized = normalizeCodeBlockLanguages([{ name: 'JavaScript', alias: ['js'] }])
152+
expect(getCodeBlockLanguageSelectData(normalized, 'js')).toEqual({
153+
value: 'js',
154+
items: [{ value: 'js', label: 'JavaScript' }]
155+
})
156+
})
157+
158+
it('adds a temporary item for an unknown language', () => {
159+
const normalized = normalizeCodeBlockLanguages([{ name: 'JavaScript', alias: ['js'] }])
160+
expect(getCodeBlockLanguageSelectData(normalized, 'brainfuck')).toEqual({
161+
value: 'brainfuck',
162+
items: [
163+
{ value: 'js', label: 'JavaScript' },
164+
{ value: 'brainfuck', label: 'brainfuck' }
165+
]
166+
})
167+
})
168+
169+
it('keeps the empty-language sentinel intact', () => {
170+
const normalized = normalizeCodeBlockLanguages([{ name: 'Plain text', alias: [''] }])
171+
expect(getCodeBlockLanguageSelectData(normalized, '')).toEqual({
172+
value: EMPTY_VALUE,
173+
items: [{ value: EMPTY_VALUE, label: 'Plain text' }]
174+
})
175+
})
110176
})

0 commit comments

Comments
 (0)