diff --git a/package.json b/package.json index a38d1f11b9..c08eed1baa 100644 --- a/package.json +++ b/package.json @@ -84,6 +84,8 @@ "gatsby-transformer-remark": "6.16.0", "gatsby-transformer-sharp": "5.16.0", "gatsby-transformer-yaml": "5.16.0", + "highlight.js": "^11.11.1", + "highlightjs-curl": "^1.3.0", "htmr": "^1.0.2", "js-yaml": "^4.2.0", "lodash": "^4.18.1", diff --git a/src/components/Markdown/CodeBlock.test.tsx b/src/components/Markdown/CodeBlock.test.tsx index 661cac410c..fe2b547700 100644 --- a/src/components/Markdown/CodeBlock.test.tsx +++ b/src/components/Markdown/CodeBlock.test.tsx @@ -24,7 +24,7 @@ const mockHighlightSnippet = jest.fn(); const mockParseLineHighlights = jest.fn(); const mockSplitHtmlLines = jest.fn(); -jest.mock('@ably/ui/core/utils/syntax-highlighter', () => ({ +jest.mock('src/utilities/syntax-highlighter', () => ({ highlightSnippet: (...args: any[]) => mockHighlightSnippet(...args), LINE_HIGHLIGHT_CLASSES: { addition: 'code-line-addition', @@ -36,7 +36,7 @@ jest.mock('@ably/ui/core/utils/syntax-highlighter', () => ({ registerDefaultLanguages: jest.fn(), })); -jest.mock('@ably/ui/core/utils/syntax-highlighter-registry', () => ({ +jest.mock('src/utilities/syntax-highlighter-registry', () => ({ __esModule: true, default: [], })); diff --git a/src/components/Markdown/CodeBlock.tsx b/src/components/Markdown/CodeBlock.tsx index 8d6cb72767..370a654891 100644 --- a/src/components/Markdown/CodeBlock.tsx +++ b/src/components/Markdown/CodeBlock.tsx @@ -6,8 +6,8 @@ import { registerDefaultLanguages, parseLineHighlights, splitHtmlLines, -} from '@ably/ui/core/utils/syntax-highlighter'; -import languagesRegistry from '@ably/ui/core/utils/syntax-highlighter-registry'; +} from 'src/utilities/syntax-highlighter'; +import languagesRegistry from 'src/utilities/syntax-highlighter-registry'; registerDefaultLanguages(languagesRegistry); diff --git a/src/globals.d.ts b/src/globals.d.ts index e2937d470e..a96fe825ae 100644 --- a/src/globals.d.ts +++ b/src/globals.d.ts @@ -1 +1,8 @@ declare module '*.png'; + +// highlightjs-curl ships no types; it's a highlight.js LanguageFn. +declare module 'highlightjs-curl/src/languages/curl' { + import type { LanguageFn } from 'highlight.js'; + const curl: LanguageFn; + export default curl; +} diff --git a/src/utilities/syntax-highlighter-registry.ts b/src/utilities/syntax-highlighter-registry.ts new file mode 100644 index 0000000000..3a562d7f03 --- /dev/null +++ b/src/utilities/syntax-highlighter-registry.ts @@ -0,0 +1,64 @@ +// This file can be used in the browser, but because of the weight of all the language +// definitions, preferably it should be used on the server. + +import type { LanguageFn } from 'highlight.js'; +import bash from 'highlight.js/lib/languages/bash'; +import cpp from 'highlight.js/lib/languages/cpp'; +import csharp from 'highlight.js/lib/languages/csharp'; +import css from 'highlight.js/lib/languages/css'; +import dart from 'highlight.js/lib/languages/dart'; +import dos from 'highlight.js/lib/languages/dos'; +import diff from 'highlight.js/lib/languages/diff'; +import erlang from 'highlight.js/lib/languages/erlang'; +import elixir from 'highlight.js/lib/languages/elixir'; +import plaintext from 'highlight.js/lib/languages/plaintext'; +import go from 'highlight.js/lib/languages/go'; +import http from 'highlight.js/lib/languages/http'; +import java from 'highlight.js/lib/languages/java'; +import javascript from 'highlight.js/lib/languages/javascript'; +import typescript from 'highlight.js/lib/languages/typescript'; +import json from 'highlight.js/lib/languages/json'; +import objectivec from 'highlight.js/lib/languages/objectivec'; +import php from 'highlight.js/lib/languages/php'; +import python from 'highlight.js/lib/languages/python'; +import ruby from 'highlight.js/lib/languages/ruby'; +import swift from 'highlight.js/lib/languages/swift'; +import kotlin from 'highlight.js/lib/languages/kotlin'; +import sql from 'highlight.js/lib/languages/sql'; +import xml from 'highlight.js/lib/languages/xml'; +import yaml from 'highlight.js/lib/languages/yaml'; +import curl from 'highlightjs-curl/src/languages/curl'; + +const registry: { label: string; key: string; module: LanguageFn }[] = [ + { label: 'Text', key: 'text', module: plaintext }, + { label: 'JS', key: 'javascript', module: javascript }, + { label: 'TS', key: 'typescript', module: typescript }, + { label: 'Java', key: 'java', module: java }, + { label: 'Ruby', key: 'ruby', module: ruby }, + { label: 'Python', key: 'python', module: python }, + { label: 'PHP', key: 'php', module: php }, + { label: 'Shell', key: 'bash', module: bash }, + { label: 'C#', key: 'cs', module: csharp }, + { label: 'CSS', key: 'css', module: css }, + { label: 'Go', key: 'go', module: go }, + { label: 'HTML', key: 'xml', module: xml }, + { label: 'HTTP', key: 'http', module: http }, + { label: 'C++', key: 'cpp', module: cpp }, + { label: 'Dart', key: 'dart', module: dart }, + { label: 'Swift', key: 'swift', module: swift }, + { label: 'Kotlin', key: 'kotlin', module: kotlin }, + { label: 'Objective C', key: 'objectivec', module: objectivec }, + { label: 'Node.js', key: 'javascript', module: javascript }, + { label: 'JSON', key: 'json', module: json }, + { label: 'DOS', key: 'dos', module: dos }, + { label: 'YAML', key: 'yaml', module: yaml }, + { label: 'Erlang', key: 'erlang', module: erlang }, + { label: 'Elixir', key: 'elixir', module: elixir }, + { label: 'Diff', key: 'diff', module: diff }, + { label: 'SQL', key: 'sql', module: sql }, + { label: 'cURL', key: 'curl', module: curl }, + { label: 'HTML', key: 'html', module: xml }, + { label: 'XML', key: 'xml', module: xml }, +]; + +export default registry; diff --git a/src/utilities/syntax-highlighter.ts b/src/utilities/syntax-highlighter.ts new file mode 100644 index 0000000000..83802a0d7e --- /dev/null +++ b/src/utilities/syntax-highlighter.ts @@ -0,0 +1,216 @@ +import hljs from 'highlight.js/lib/core'; +import type { LanguageFn } from 'highlight.js'; + +type LineHighlightType = 'addition' | 'removal' | 'highlight'; + +// Map certain frameworks, protocols etc to available language packs +const languageToHighlightKey = (lang?: string): string => { + let id: string | undefined; + + if (!lang) { + lang = 'text'; + } + + switch (lang.toLowerCase()) { + case 'android': + id = 'java'; + break; + + case '.net': + case 'net': + case 'dotnet': + case 'csharp': + case 'c#': + id = 'cs'; + break; + + case 'objc': + case 'objective c': + id = 'objectivec'; + break; + + case 'laravel': + id = 'php'; + break; + + case 'flutter': + id = 'dart'; + break; + + case 'node.js': + case 'js': + id = 'javascript'; + break; + + case 'ts': + id = 'typescript'; + break; + + case 'kotlin': + case 'kt': + id = 'kotlin'; + break; + + case 'shell': + case 'fh': + case 'sh': + id = 'bash'; + break; + + case 'https': + case 'http': + case 'txt': + case 'plaintext': + id = 'text'; + break; + + case 'cmd': + case 'bat': + id = 'dos'; + break; + + case 'yml': + id = 'yaml'; + break; + + case 'erl': + id = 'erlang'; + break; + + case 'patch': + id = 'diff'; + break; + + case 'svg': + id = 'xml'; + break; + + default: + break; + } + + return id || lang; +}; + +const registerDefaultLanguages = (register: { key: string; module: LanguageFn }[]): void => { + register.forEach(({ key, module }) => hljs.registerLanguage(key, module)); +}; + +const highlightSnippet = (languageKeyword: string | undefined, snippet: string): string | undefined => { + const language = languageToHighlightKey(languageKeyword); + if (typeof snippet !== 'string' || !snippet || !language) { + return; + } + + return hljs.highlight(snippet, { language }).value; +}; + +/** + * Parse line highlight specifications from a meta string. + * + * Syntax: `highlight="+1-3,-5,7"` + * - `+` prefix: addition (green) + * - `-` prefix: removal (red) + * - no prefix: neutral highlight (blue) + * - `N-M`: inclusive line range + * - comma-separated for multiple specs + */ +const parseLineHighlights = ( + languageString: string, + meta?: string, +): { lang: string; highlights: Record } => { + if (!meta) { + return { lang: languageString, highlights: {} }; + } + + const match = meta.match(/highlight=["']?([^"']+)["']?/); + if (!match) { + return { lang: languageString, highlights: {} }; + } + + const spec = match[1]; + const highlights: Record = {}; + + const tokens = spec.split(','); + for (const token of tokens) { + const trimmed = token.trim(); + if (!trimmed) { + continue; + } + + let type: LineHighlightType = 'highlight'; + let rangePart = trimmed; + + if (trimmed.startsWith('+')) { + type = 'addition'; + rangePart = trimmed.slice(1); + } else if (trimmed.startsWith('-')) { + type = 'removal'; + rangePart = trimmed.slice(1); + } + + const rangeMatch = rangePart.match(/^(\d+)(?:-(\d+))?$/); + if (!rangeMatch) { + continue; + } + + const start = parseInt(rangeMatch[1], 10); + const end = rangeMatch[2] ? parseInt(rangeMatch[2], 10) : start; + + for (let i = start; i <= end; i++) { + highlights[i] = type; + } + } + + return { lang: languageString, highlights }; +}; + +/** + * Split highlighted HTML by newlines, repairing any spans that cross + * line boundaries so each line fragment is valid HTML. + */ +const splitHtmlLines = (html: string): string[] => { + const rawLines = html.split('\n'); + const result: string[] = []; + const openTags: string[] = []; + + for (const rawLine of rawLines) { + let line = openTags.join('') + rawLine; + + // Process open/close tags in document order + const tagPattern = /<(\/?)span([^>]*)>/g; + let m: RegExpExecArray | null; + while ((m = tagPattern.exec(rawLine)) !== null) { + if (m[1] === '/') { + openTags.pop(); + } else { + openTags.push(m[0]); + } + } + + // Close any tags still open so this line is valid HTML + for (let i = 0; i < openTags.length; i++) { + line += ''; + } + + result.push(line); + } + + return result; +}; + +const LINE_HIGHLIGHT_CLASSES: Record = { + addition: 'code-line-addition', + removal: 'code-line-removal', + highlight: 'code-line-highlight', +}; + +export { + highlightSnippet, + languageToHighlightKey, + LINE_HIGHLIGHT_CLASSES, + parseLineHighlights, + registerDefaultLanguages, + splitHtmlLines, +}; +export type { LineHighlightType };