From b67f0f10dbf44249e41953a66472fe45178f69b5 Mon Sep 17 00:00:00 2001 From: Jamie Henson Date: Wed, 17 Jun 2026 14:16:14 +0100 Subject: [PATCH] refactor(docs): localise syntax-highlighter util + registry Vendor syntax-highlighter.js (highlight + line-highlight helpers) and syntax-highlighter-registry.js (the highlight.js language set) into src/utilities/ (DX-1128). Promote highlight.js + highlightjs-curl to direct deps (were transitive). Repoint CodeBlock + tests + setupTests off @ably/ui/core/utils/syntax-highlighter*. Base of the code-rendering chain (PR E / Code + CodeSnippet builds on this). Also firms up the highlight.js dependency that PR1's vendored syntax-highlighter.css relies on. eslint clean. Refs: DX-1128 --- package.json | 2 + src/components/Markdown/CodeBlock.test.tsx | 4 +- src/components/Markdown/CodeBlock.tsx | 4 +- src/utilities/syntax-highlighter-registry.js | 63 ++++++ src/utilities/syntax-highlighter.js | 210 +++++++++++++++++++ 5 files changed, 279 insertions(+), 4 deletions(-) create mode 100644 src/utilities/syntax-highlighter-registry.js create mode 100644 src/utilities/syntax-highlighter.js diff --git a/package.json b/package.json index 984740d459..f969f33922 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.1.1", "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 b0494fccc7..1a17e9fe11 100644 --- a/src/components/Markdown/CodeBlock.tsx +++ b/src/components/Markdown/CodeBlock.tsx @@ -7,8 +7,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/utilities/syntax-highlighter-registry.js b/src/utilities/syntax-highlighter-registry.js new file mode 100644 index 0000000000..0d7e4bfbc7 --- /dev/null +++ b/src/utilities/syntax-highlighter-registry.js @@ -0,0 +1,63 @@ +// 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 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: '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.js b/src/utilities/syntax-highlighter.js new file mode 100644 index 0000000000..7cb6e50a20 --- /dev/null +++ b/src/utilities/syntax-highlighter.js @@ -0,0 +1,210 @@ +import hljs from 'highlight.js/lib/core'; + +// Map certain frameworks, protocols etc to available langauage packs +const languageToHighlightKey = (lang) => { + let id; + + 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) => { + register.forEach(({ key, module }) => hljs.registerLanguage(key, module)); +}; + +const highlightSnippet = (languageKeyword, snippet) => { + 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 + * + * @param {string} languageString - the language, e.g. "javascript" + * @param {string} [meta] - string containing highlight specs, e.g. 'highlight="+1-3,-5,7"' + * @returns {{ lang: string, highlights: Record }} + */ +const parseLineHighlights = (languageString, meta) => { + if (!meta) { + return { lang: languageString, highlights: {} }; + } + + const match = meta.match(/highlight=["']?([^"']+)["']?/); + if (!match) { + return { lang: languageString, highlights: {} }; + } + + const spec = match[1]; + const highlights = {}; + + const tokens = spec.split(','); + for (const token of tokens) { + const trimmed = token.trim(); + if (!trimmed) continue; + + let type = '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. + * + * @param {string} html - HTML string produced by highlight.js + * @returns {string[]} one HTML fragment per source line + */ +const splitHtmlLines = (html) => { + const rawLines = html.split('\n'); + const result = []; + let openTags = []; + + for (const rawLine of rawLines) { + let line = openTags.join('') + rawLine; + + // Process open/close tags in document order + const tagPattern = /<(\/?)span([^>]*)>/g; + let m; + 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 = { + addition: 'code-line-addition', + removal: 'code-line-removal', + highlight: 'code-line-highlight', +}; + +export { + highlightSnippet, + languageToHighlightKey, + LINE_HIGHLIGHT_CLASSES, + parseLineHighlights, + registerDefaultLanguages, + splitHtmlLines, +};