Skip to content

Commit c235471

Browse files
authored
Merge pull request #32 from PureStackStudio/unify-code-languages-for-highlighters
Unified all three languages, 95 extensions, and shared aliases for both highlighters.
2 parents 65ab55d + 22c7370 commit c235471

6 files changed

Lines changed: 195 additions & 79 deletions

File tree

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import hljs from 'highlight.js'
2+
import { describe, expect, it, vi } from 'vitest'
3+
import { LANGUAGE_BY_EXTENSION } from './codeLanguages'
4+
import { createMdxHighlighter, DEFAULT_MDX_CODE_LANGS } from './highlight'
5+
import { createHljsHighlighter } from './highlightjs'
6+
7+
describe('MDX code languages', () => {
8+
it('renders every inferred language with the default Shiki grammars', async () => {
9+
const highlighter = await createMdxHighlighter()
10+
const languages = new Set([
11+
...DEFAULT_MDX_CODE_LANGS,
12+
...Object.values(LANGUAGE_BY_EXTENSION),
13+
])
14+
for (const language of languages) {
15+
expect(await highlighter.codeToHtml('example', language)).toContain(
16+
'<pre',
17+
)
18+
}
19+
})
20+
21+
it('uses a registered highlight.js grammar for every default language', () => {
22+
const engine = hljs.newInstance()
23+
for (const language of hljs.listLanguages()) {
24+
const grammar = hljs.getLanguage(language)
25+
if (grammar) engine.registerLanguage(language, () => grammar)
26+
}
27+
const highlight = vi.spyOn(engine, 'highlight')
28+
const highlighter = createHljsHighlighter(engine)
29+
for (const language of DEFAULT_MDX_CODE_LANGS) {
30+
highlight.mockClear()
31+
highlighter.codeToHtml('example', language)
32+
expect(highlight, language).toHaveBeenCalledOnce()
33+
}
34+
})
35+
36+
it.each([
37+
[' PY3 ', 'python', 'print(42)'],
38+
['cs', 'csharp', 'public class Example {}'],
39+
['c++', 'cpp', 'int main() {}'],
40+
['pwsh', 'powershell', 'Write-Host "Hello"'],
41+
['yml', 'yaml', 'answer: 42'],
42+
['shellscript', 'bash', 'echo "Hello"'],
43+
['objectivec', 'objective-c', '@interface Example'],
44+
['dos', 'bat', 'echo Hello'],
45+
['gql', 'graphql', 'query { user { id } }'],
46+
])('resolves %s with both highlighters', async (alias, language, code) => {
47+
// Loading by the alias must also work when defaults are overridden.
48+
const shiki = await createMdxHighlighter(undefined, [alias])
49+
expect(await shiki.codeToHtml(code, alias)).toBe(
50+
await shiki.codeToHtml(code, language),
51+
)
52+
const highlighter = createHljsHighlighter()
53+
const html = highlighter.codeToHtml(code, alias)
54+
expect(html).toBe(highlighter.codeToHtml(code, language))
55+
expect(html).toContain('<span')
56+
})
57+
})
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
interface CodeLanguage {
2+
aliases?: string[]
3+
extensions?: string[]
4+
}
5+
6+
// Use Shiki names (including its aliases) as the common language identifiers.
7+
const codeLanguages: Record<string, CodeLanguage> = {
8+
bash: {
9+
aliases: ['sh', 'shell', 'shellscript', 'zsh'],
10+
extensions: ['.sh', '.bash', '.zsh'],
11+
},
12+
bat: { aliases: ['batch', 'cmd', 'dos'], extensions: ['.bat', '.cmd'] },
13+
c: { extensions: ['.c', '.h'] },
14+
cmake: { extensions: ['.cmake'] },
15+
cpp: {
16+
aliases: ['c++', 'cc', 'cxx', 'h++', 'hpp', 'hxx'],
17+
extensions: ['.cc', '.cpp', '.cxx', '.hh', '.hpp', '.hxx'],
18+
},
19+
csharp: { aliases: ['c#', 'cs'], extensions: ['.cs', '.csx'] },
20+
css: { extensions: ['.css'] },
21+
dart: { extensions: ['.dart'] },
22+
diff: { aliases: ['patch'], extensions: ['.diff', '.patch'] },
23+
dockerfile: { aliases: ['docker'], extensions: ['.dockerfile'] },
24+
elixir: { aliases: ['ex', 'exs'], extensions: ['.ex', '.exs'] },
25+
erlang: { aliases: ['erl'], extensions: ['.erl', '.hrl'] },
26+
fsharp: { aliases: ['f#', 'fs'], extensions: ['.fs', '.fsi', '.fsx'] },
27+
go: { aliases: ['golang'], extensions: ['.go'] },
28+
graphql: { aliases: ['gql'], extensions: ['.graphql', '.gql'] },
29+
groovy: { extensions: ['.groovy', '.gradle'] },
30+
html: { aliases: ['htm'], extensions: ['.html', '.htm'] },
31+
http: { aliases: ['https'], extensions: ['.http', '.rest'] },
32+
ini: { aliases: ['properties'], extensions: ['.ini', '.cfg', '.properties'] },
33+
java: { extensions: ['.java'] },
34+
javascript: {
35+
aliases: ['js', 'cjs', 'mjs'],
36+
extensions: ['.js', '.cjs', '.mjs'],
37+
},
38+
json: { extensions: ['.json'] },
39+
jsonc: { extensions: ['.jsonc'] },
40+
jsx: { extensions: ['.jsx'] },
41+
kotlin: { aliases: ['kt', 'kts'], extensions: ['.kt', '.kts'] },
42+
less: { extensions: ['.less'] },
43+
lua: { extensions: ['.lua'] },
44+
makefile: { aliases: ['make', 'mk'], extensions: ['.mk', '.mak'] },
45+
markdown: {
46+
aliases: ['md', 'mkd', 'mdown'],
47+
extensions: ['.md', '.markdown', '.mkd', '.mdown'],
48+
},
49+
mdx: { aliases: ['rmdx'], extensions: ['.mdx', '.rmdx'] },
50+
'objective-c': { aliases: ['objc', 'objectivec'], extensions: ['.m'] },
51+
perl: { aliases: ['pl', 'pm'], extensions: ['.pl', '.pm'] },
52+
php: { extensions: ['.php', '.phtml'] },
53+
powershell: {
54+
aliases: ['ps', 'ps1', 'pwsh'],
55+
extensions: ['.ps1', '.psm1', '.psd1'],
56+
},
57+
python: {
58+
aliases: ['py', 'py3', 'python3'],
59+
extensions: ['.py', '.pyw', '.pyi'],
60+
},
61+
r: { extensions: ['.r'] },
62+
ruby: {
63+
aliases: ['rb', 'gemspec'],
64+
extensions: ['.rb', '.rake', '.gemspec'],
65+
},
66+
rust: { aliases: ['rs'], extensions: ['.rs'] },
67+
scala: { extensions: ['.scala', '.sc'] },
68+
scss: { extensions: ['.scss'] },
69+
sql: { extensions: ['.sql'] },
70+
swift: { extensions: ['.swift'] },
71+
text: { aliases: ['txt', 'plaintext'], extensions: ['.txt', '.text'] },
72+
toml: { extensions: ['.toml'] },
73+
'ts-tags': { aliases: ['lit'] },
74+
tsx: { extensions: ['.tsx'] },
75+
typescript: {
76+
aliases: ['ts', 'cts', 'mts'],
77+
extensions: ['.ts', '.cts', '.mts'],
78+
},
79+
xml: {
80+
aliases: ['svg', 'xhtml'],
81+
extensions: ['.xml', '.svg', '.xhtml', '.xsd', '.xsl', '.xslt'],
82+
},
83+
yaml: { aliases: ['yml'], extensions: ['.yaml', '.yml'] },
84+
}
85+
86+
export const DEFAULT_CODE_LANGUAGES = Object.keys(codeLanguages)
87+
88+
const languageAliases = new Map(
89+
Object.entries(codeLanguages).flatMap(([language, { aliases = [] }]) =>
90+
aliases.map((alias) => [alias, language] as const),
91+
),
92+
)
93+
94+
export const LANGUAGE_BY_EXTENSION: Record<string, string> = Object.fromEntries(
95+
Object.entries(codeLanguages).flatMap(([language, { extensions = [] }]) =>
96+
extensions.map((extension) => [extension, language]),
97+
),
98+
)
99+
100+
export function normalizeCodeLanguage(value: string): string {
101+
const normalized = value.trim().toLowerCase()
102+
return languageAliases.get(normalized) ?? normalized
103+
}
Lines changed: 7 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { createHighlighter, type Highlighter } from 'shiki'
2+
import { DEFAULT_CODE_LANGUAGES, normalizeCodeLanguage } from './codeLanguages'
23

34
export interface MdxCodeHighlighter {
45
codeToHtml: (code: string, lang?: string) => string | Promise<string>
@@ -20,38 +21,8 @@ export const DEFAULT_MDX_CODE_THEMES: MdxCodeThemes = {
2021
dark: 'github-dark',
2122
}
2223

23-
export const DEFAULT_MDX_CODE_LANGS: MdxCodeLangs = [
24-
'bash',
25-
'sh',
26-
'shell',
27-
'javascript',
28-
'js',
29-
'typescript',
30-
'ts',
31-
'ts-tags',
32-
'jsx',
33-
'tsx',
34-
'json',
35-
'html',
36-
'css',
37-
'markdown',
38-
'mdx',
39-
'yaml',
40-
'yml',
41-
'toml',
42-
'diff',
43-
'csharp',
44-
'cpp',
45-
'c',
46-
'java',
47-
'python',
48-
'ruby',
49-
'go',
50-
'rust',
51-
'xml',
52-
]
24+
export const DEFAULT_MDX_CODE_LANGS: MdxCodeLangs = DEFAULT_CODE_LANGUAGES
5325

54-
const TYPESCRIPT_LANGS = new Set(['typescript', 'ts', 'cts', 'mts'])
5526
const TYPESCRIPT_WITH_TAGS_LANG = 'ts-tags'
5627

5728
export async function createMdxHighlighter(
@@ -78,28 +49,23 @@ export async function createMdxHighlighter(
7849
}
7950

8051
function resolveLoadedLanguages(langs: string[]) {
81-
const resolved = dedupe(langs.map(normalizeLanguageName))
52+
const resolved = dedupe(langs.map(normalizeCodeLanguage))
8253
if (
83-
resolved.some((lang) => TYPESCRIPT_LANGS.has(lang)) &&
84-
!resolved.includes(TYPESCRIPT_WITH_TAGS_LANG) &&
85-
!resolved.includes('lit')
54+
resolved.includes('typescript') &&
55+
!resolved.includes(TYPESCRIPT_WITH_TAGS_LANG)
8656
) {
8757
resolved.push(TYPESCRIPT_WITH_TAGS_LANG)
8858
}
8959
return resolved
9060
}
9161

9262
function resolveHighlightLanguage(lang: string | undefined) {
93-
const normalized = normalizeLanguageName(lang ?? 'text')
94-
return TYPESCRIPT_LANGS.has(normalized)
63+
const normalized = normalizeCodeLanguage(lang ?? 'text')
64+
return normalized === 'typescript'
9565
? TYPESCRIPT_WITH_TAGS_LANG
9666
: normalized || 'text'
9767
}
9868

99-
function normalizeLanguageName(value: string) {
100-
return value.trim().toLowerCase()
101-
}
102-
10369
function dedupe(values: string[]) {
10470
return Array.from(new Set(values.filter((value) => value.length > 0)))
10571
}

‎packages/ts-ssg/src/mdx/highlightjs.ts‎

Lines changed: 12 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,26 @@
11
import hljs, { type HLJSApi } from 'highlight.js'
22

3+
import { normalizeCodeLanguage } from './codeLanguages'
34
import type { MdxCodeHighlighter } from './highlight'
45

5-
const languageAliases: Record<string, string> = {
6-
js: 'javascript',
7-
ts: 'typescript',
8-
sh: 'bash',
9-
shell: 'bash',
10-
yml: 'yaml',
11-
md: 'markdown',
12-
}
6+
// Use highlight.js names, or the closest grammar for Shiki-only languages.
7+
const hljsLanguages = new Map([
8+
['jsonc', 'json'],
9+
['mdx', 'markdown'],
10+
['objective-c', 'objectivec'],
11+
['ts-tags', 'typescript'],
12+
])
1313

1414
export function createHljsHighlighter(
1515
highlighter: HLJSApi = hljs,
1616
): MdxCodeHighlighter {
1717
return {
1818
codeToHtml: (code: string, lang?: string) => {
19-
const normalizedLang = normalizeLanguage(lang)
20-
if (normalizedLang && highlighter.getLanguage(normalizedLang)) {
19+
const normalizedLang = normalizeCodeLanguage(lang ?? '')
20+
const highlightLang = hljsLanguages.get(normalizedLang) ?? normalizedLang
21+
if (highlightLang && highlighter.getLanguage(highlightLang)) {
2122
const highlighted = highlighter.highlight(code, {
22-
language: normalizedLang,
23+
language: highlightLang,
2324
ignoreIllegals: true,
2425
})
2526
return `<pre class="hljs"><code class="hljs language-${highlighted.language}">${highlighted.value}</code></pre>`
@@ -32,13 +33,6 @@ export function createHljsHighlighter(
3233
}
3334
}
3435

35-
function normalizeLanguage(lang: string | undefined): string | undefined {
36-
if (!lang) return undefined
37-
const normalized = lang.trim().toLowerCase()
38-
if (!normalized) return undefined
39-
return languageAliases[normalized] ?? normalized
40-
}
41-
4236
function escapeHtml(value: string): string {
4337
return value
4438
.replaceAll('&', '&amp;')

‎packages/ts-ssg/src/mdx/imports.test.ts‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,10 @@ describe('expandImports', () => {
1212
const files: Record<string, string> = {
1313
'guides/demo.ts': 'export const answer = 42\n',
1414
'guides/styles.css': '.demo { color: red; }\n',
15+
'guides/example.PY': 'print(42)\n',
16+
'guides/example.cs': 'public class Example {}\n',
17+
'guides/example.ps1': 'Write-Host "Hello"\n',
18+
'guides/example.sql': 'SELECT 42;\n',
1519
'shared/fence.md': 'Use a fence:\r\n\r\n```ts\r\nconst x = 1\r\n```\r\n',
1620
'guides/data.unknown': 'plain',
1721
'shared/_note.mdx':
@@ -58,6 +62,17 @@ describe('expandImports', () => {
5862
)
5963
})
6064

65+
it.each([
66+
['example.PY', 'python', 'print(42)'],
67+
['example.cs', 'csharp', 'public class Example {}'],
68+
['example.ps1', 'powershell', 'Write-Host "Hello"'],
69+
['example.sql', 'sql', 'SELECT 42;'],
70+
])('infers the language for %s', async (file, language, code) => {
71+
expect(await expand(`<import-codeblock src="./${file}"/>`)).toBe(
72+
`\n\`\`\`${language}\n${code}\n\`\`\`\n`,
73+
)
74+
})
75+
6176
it('resolves src from the page, reports each import, and keeps line endings uniform', async () => {
6277
const imports: string[] = []
6378

‎packages/ts-ssg/src/mdx/imports.ts‎

Lines changed: 1 addition & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import remarkParse from 'remark-parse'
77
import { unified } from 'unified'
88
import { isSharedContentFile } from '../discover/content'
99
import { isContentExt } from '../discover/contentExtensions'
10+
import { LANGUAGE_BY_EXTENSION } from './codeLanguages'
1011
import { findMarkupTags } from './regorMarkup'
1112

1213
const IMPORT_TAGS = {
@@ -28,26 +29,6 @@ type ImportTagName = keyof typeof IMPORT_TAGS
2829

2930
const IMPORT_TAG_NAMES = Object.keys(IMPORT_TAGS) as ImportTagName[]
3031

31-
const LANGUAGE_BY_EXTENSION: Record<string, string> = {
32-
'.cjs': 'javascript',
33-
'.css': 'css',
34-
'.cts': 'typescript',
35-
'.html': 'html',
36-
'.js': 'javascript',
37-
'.json': 'json',
38-
'.jsx': 'jsx',
39-
'.md': 'markdown',
40-
'.mdx': 'mdx',
41-
'.mjs': 'javascript',
42-
'.mts': 'typescript',
43-
'.rmdx': 'mdx',
44-
'.sh': 'bash',
45-
'.ts': 'typescript',
46-
'.tsx': 'tsx',
47-
'.yaml': 'yaml',
48-
'.yml': 'yaml',
49-
}
50-
5132
const parser = unified().use(remarkParse).use(remarkGfm)
5233

5334
export interface ImportOptions {

0 commit comments

Comments
 (0)