diff --git a/package-lock.json b/package-lock.json index 0926803..a249f5c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6,6 +6,7 @@ "": { "name": "dropbox-api-v2-explorer", "dependencies": { + "highlight.js": "^11.11.1", "react": "^19.2.7", "react-dom": "^19.2.7" }, @@ -1607,6 +1608,15 @@ "node": ">=8" } }, + "node_modules/highlight.js": { + "version": "11.11.1", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz", + "integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/html-escaper": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", diff --git a/package.json b/package.json index 71dd7b6..79586d7 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "typescript": "^6.0.3" }, "dependencies": { + "highlight.js": "^11.11.1", "react": "^19.2.7", "react-dom": "^19.2.7" } diff --git a/scripts/build.js b/scripts/build.js index ac7a39f..fc63dd6 100644 --- a/scripts/build.js +++ b/scripts/build.js @@ -21,9 +21,12 @@ const copyStaticFiles = async () => { }; const buildOptions = { - entryPoints: [path.join(sourceDirectory, 'main.ts')], + entryPoints: { + all: path.join(sourceDirectory, 'main.ts'), + highlight: path.join(rootDirectory, 'node_modules', 'highlight.js', 'styles', 'github.css'), + }, bundle: true, - outfile: path.join(buildDirectory, 'all.js'), + outdir: buildDirectory, platform: 'browser', sourcemap: true, target: ['es2015'], diff --git a/src/index.html b/src/index.html index d1e762a..a1522e8 100644 --- a/src/index.html +++ b/src/index.html @@ -1,5 +1,6 @@ + Dropbox API Explorer diff --git a/src/team/index.html b/src/team/index.html index d2c59ce..08021f7 100644 --- a/src/team/index.html +++ b/src/team/index.html @@ -1,5 +1,6 @@ + Dropbox API Explorer diff --git a/src/utils.ts b/src/utils.ts index 95322ba..e77f19f 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -11,9 +11,18 @@ */ import * as react from 'react'; -import { ReactNode } from 'react'; +import hljs from 'highlight.js/lib/core'; +import bash from 'highlight.js/lib/languages/bash'; +import http from 'highlight.js/lib/languages/http'; +import json from 'highlight.js/lib/languages/json'; +import python from 'highlight.js/lib/languages/python'; import * as cookie from './cookie'; +hljs.registerLanguage('bash', bash); +hljs.registerLanguage('http', http); +hljs.registerLanguage('json', json); +hljs.registerLanguage('python', python); + type MappingFn = (key: string, value: any, i: number) => react.DetailedReactHTMLElement; const ce = react.createElement; @@ -589,31 +598,39 @@ react.DetailedReactHTMLElement => { export const escapeUnicode = (s: string): string => s.replace(/[\u007f-\uffff]/g, (c: string) => `\\u${(`0000${c.charCodeAt(0).toString(16)}`).slice(-4)}`); -// Used to get highlight.js to syntax-highlight the codeview and response areas. -// Source: https://github.com/akiran/react-highlight/blob/main/src/index.jsx +// Converts the nested React elements used by the code viewers back to source text. +const reactNodeToText = (node: react.ReactNode): string => { + if (typeof node === 'string' || typeof node === 'number') { + return String(node); + } + if (Array.isArray(node)) { + return node.map(reactNodeToText).join(''); + } + if (react.isValidElement<{ children?: react.ReactNode }>(node)) { + return reactNodeToText(node.props.children); + } + return ''; +}; + interface HltProps { - className: string; - children: react.ClassicElement> -} -export class Highlight extends react.Component> { - defaultProps = { className: '' }; - - // TODO: fix this highlighting it breaks updates - // componentDidMount = () => this.highlightCode(); - // componentDidUpdate = () => this.highlightCode(); - - // highlightCode = () => [].forEach.call( - // (reactDom.findDOMNode(this)).querySelectorAll('pre code'), - // (node: Node) => hljs.highlightBlock(node) - // ); - - public render(): ReactNode { - return react.createElement('pre', { className: this.props.className }, - react.createElement('code', { className: this.props.className }, - this.props.children)); - } + className?: string; + children: react.ReactNode } +// Highlight source before rendering so highlight.js never mutates React-managed DOM. +export const Highlight = ({ className = '', children }: HltProps): react.ReactElement => { + const source = reactNodeToText(children); + const highlighted = className === '' + ? hljs.highlightAuto(source) + : hljs.highlight(source, { language: className, ignoreIllegals: true }); + + return react.createElement('pre', { className: 'hljs' }, + react.createElement('code', { + className: className === '' ? undefined : `language-${className}`, + dangerouslySetInnerHTML: { __html: highlighted.value }, + })); +}; + // Utility functions for getting the headers for an API call // The headers for an RPC-like endpoint HTTP request diff --git a/test/unit/codeview.test.ts b/test/unit/codeview.test.ts index efca696..b7cfda4 100644 --- a/test/unit/codeview.test.ts +++ b/test/unit/codeview.test.ts @@ -14,7 +14,8 @@ test('HTTP code view renders a hostname without a URL scheme', () => { const request = renderToStaticMarkup( formats.http.renderRPCLike(endpoint, '', {}, []), ); + const requestText = request.replace(/<[^>]+>/g, ''); - assert.match(request, /Host: api\.dropboxapi\.com\n/); - assert.doesNotMatch(request, /Host: https:\/\//); + assert.match(requestText, /Host: api\.dropboxapi\.com\n/); + assert.doesNotMatch(requestText, /Host: https:\/\//); }); diff --git a/test/unit/utils.test.ts b/test/unit/utils.test.ts index d5c1fcd..8fdd8c4 100644 --- a/test/unit/utils.test.ts +++ b/test/unit/utils.test.ts @@ -1,5 +1,7 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; +import { createElement } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; import * as Utils from '../../src/utils'; test('Endpoint can be constructed', () => { @@ -17,3 +19,18 @@ test('Endpoint can be constructed', () => { assert.equal(endpoint.ns, 'users'); assert.equal(endpoint.name, 'get_current_account'); }); + +test('Highlight renders updated source without DOM mutation', () => { + const renderJson = (value: boolean): string => renderToStaticMarkup( + createElement(Utils.Highlight, { className: 'json' }, + createElement('span', null, JSON.stringify({ enabled: value }))), + ); + + const enabled = renderJson(true); + const disabled = renderJson(false); + + assert.match(enabled, /hljs-attr/); + assert.match(enabled, /hljs-keyword[^>]*>true/); + assert.match(disabled, /hljs-keyword[^>]*>false/); + assert.doesNotMatch(disabled, />true