Skip to content

Commit 8ed38f6

Browse files
committed
Enable JSX support in frontend
This enables frontend to use JSX to generate DOM nodes using the `jsx-dom` module [1]. JSX is inherently safer than concatenating HTML strings because it defaults to HTML escaping. Also it is easier to work with and more powerful than template strings. To demonstrate JSX usage, I've rewritten the context popup feature to use it, no functional changes there. [1]: https://github.com/proteriax/jsx-dom
1 parent c636ef8 commit 8ed38f6

File tree

9 files changed

+119
-42
lines changed

9 files changed

+119
-42
lines changed

.eslintrc

+2
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ ignorePatterns:
99
parserOptions:
1010
sourceType: module
1111
ecmaVersion: 2021
12+
ecmaFeatures:
13+
jsx: true
1214

1315
plugins:
1416
- eslint-plugin-unicorn

package-lock.json

+27
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

+1
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
"font-awesome": "4.7.0",
2121
"jquery": "3.6.0",
2222
"jquery.are-you-sure": "1.9.0",
23+
"jsx-dom": "7.0.0-beta.5",
2324
"less": "4.1.1",
2425
"less-loader": "8.1.1",
2526
"license-checker-webpack-plugin": "0.2.1",

web_src/js/components.js

+17
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import {contrastColor} from './utils.js';
2+
3+
// These components might look like React components but they are
4+
// not. They return DOM nodes via JSX transformation using jsx-dom.
5+
// https://github.com/proteriax/jsx-dom
6+
7+
export function Label({label}) {
8+
const backgroundColor = `#${label.color}`;
9+
const color = contrastColor(backgroundColor);
10+
const style = `color: ${color}; background-color: ${backgroundColor}`;
11+
12+
return (
13+
<div class="ui label" style={style}>
14+
{label.name}
15+
</div>
16+
);
17+
}

web_src/js/features/contextpopup.js

+26-32
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import {htmlEscape} from 'escape-goat';
2-
import {svg} from '../svg.js';
1+
import {Label} from '../components.js';
2+
import {SVG} from '../svg.js';
33

44
const {AppSubUrl} = window.config;
55

@@ -22,55 +22,49 @@ function issuePopup(owner, repo, index, $element) {
2222
body = `${body.substring(0, 85)}...`;
2323
}
2424

25-
let labels = '';
26-
for (let i = 0; i < issue.labels.length; i++) {
27-
const label = issue.labels[i];
28-
const red = parseInt(label.color.substring(0, 2), 16);
29-
const green = parseInt(label.color.substring(2, 4), 16);
30-
const blue = parseInt(label.color.substring(4, 6), 16);
31-
let color = '#ffffff';
32-
if ((red * 0.299 + green * 0.587 + blue * 0.114) > 125) {
33-
color = '#000000';
34-
}
35-
labels += `<div class="ui label" style="color: ${color}; background-color:#${label.color};">${htmlEscape(label.name)}</div>`;
36-
}
37-
if (labels.length > 0) {
38-
labels = `<p>${labels}</p>`;
39-
}
40-
41-
let octicon, color;
25+
let icon, color;
4226
if (issue.pull_request !== null) {
4327
if (issue.state === 'open') {
4428
color = 'green';
45-
octicon = 'octicon-git-pull-request'; // Open PR
29+
icon = 'octicon-git-pull-request'; // Open PR
4630
} else if (issue.pull_request.merged === true) {
4731
color = 'purple';
48-
octicon = 'octicon-git-merge'; // Merged PR
32+
icon = 'octicon-git-merge'; // Merged PR
4933
} else {
5034
color = 'red';
51-
octicon = 'octicon-git-pull-request'; // Closed PR
35+
icon = 'octicon-git-pull-request'; // Closed PR
5236
}
5337
} else if (issue.state === 'open') {
5438
color = 'green';
55-
octicon = 'octicon-issue-opened'; // Open Issue
39+
icon = 'octicon-issue-opened'; // Open Issue
5640
} else {
5741
color = 'red';
58-
octicon = 'octicon-issue-closed'; // Closed Issue
42+
icon = 'octicon-issue-closed'; // Closed Issue
5943
}
6044

6145
$element.popup({
6246
variation: 'wide',
6347
delay: {
6448
show: 250
6549
},
66-
html: `
67-
<div>
68-
<p><small>${htmlEscape(issue.repository.full_name)} on ${createdAt}</small></p>
69-
<p><span class="${color}">${svg(octicon)}</span> <strong>${htmlEscape(issue.title)}</strong> #${index}</p>
70-
<p>${htmlEscape(body)}</p>
71-
${labels}
72-
</div>
73-
`
50+
html: (
51+
<div>
52+
<p><small>{issue.repository.full_name} on {createdAt}</small></p>
53+
<p>
54+
<span class={color}><SVG name={icon}/></span>
55+
<strong class="mx-2">{issue.title}</strong>
56+
#{index}
57+
</p>
58+
<p>{body}</p>
59+
{issue.labels && issue.labels.length && (
60+
<p>
61+
{issue.labels.map((label) => (
62+
<Label label={label}/>
63+
))}
64+
</p>
65+
)}
66+
</div>
67+
)
7468
});
7569
});
7670
}

web_src/js/svg.js

+15-7
Original file line numberDiff line numberDiff line change
@@ -35,15 +35,23 @@ export const svgs = {
3535
const parser = new DOMParser();
3636
const serializer = new XMLSerializer();
3737

38-
// retrieve a HTML string for given SVG icon name, size and additional classes
39-
export function svg(name, size = 16, className = '') {
40-
if (!(name in svgs)) return '';
41-
if (size === 16 && !className) return svgs[name];
38+
// returns <svg> DOM node for given SVG icon name, size and additional classes
39+
export function SVG({name, size = 16, className = ''}) {
40+
if (!(name in svgs)) return null;
4241

43-
const document = parser.parseFromString(svgs[name], 'image/svg+xml');
44-
const svgNode = document.firstChild;
42+
// parse as html to avoid namespace issues
43+
const document = parser.parseFromString(svgs[name], 'text/html');
44+
const svgNode = document.body.firstChild;
4545
if (size !== 16) svgNode.setAttribute('width', String(size));
4646
if (size !== 16) svgNode.setAttribute('height', String(size));
4747
if (className) svgNode.classList.add(...className.split(/\s+/));
48-
return serializer.serializeToString(svgNode);
48+
return svgNode;
49+
}
50+
51+
// returns a HTML string for given SVG icon name, size and additional classes
52+
export function svg(name, size = 16, className = '') {
53+
if (!(name in svgs)) return '';
54+
if (size === 16 && !className) return svgs[name];
55+
const svgElement = <SVG name={name} size={size} className={className}/>;
56+
return serializer.serializeToString(svgElement);
4957
}

web_src/js/utils.js

+10
Original file line numberDiff line numberDiff line change
@@ -51,3 +51,13 @@ export function mqBinarySearch(feature, minValue, maxValue, step, unit) {
5151
}
5252
return mqBinarySearch(feature, minValue, mid - step, step, unit); // feature is < mid
5353
}
54+
55+
// get a contrasting foreground color for a given 6-digit background color
56+
export function contrastColor(hex) {
57+
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
58+
if (!result) return '#fff';
59+
const r = parseInt(result[1], 16);
60+
const g = parseInt(result[2], 16);
61+
const b = parseInt(result[3], 16);
62+
return ((r * 299) + (g * 587) + (b * 114)) / 1000 > 125 ? '#000' : '#fff';
63+
}

web_src/js/utils.test.js

+12-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import {
2-
basename, extname, isObject, uniq, stripTags, joinPaths,
2+
basename, extname, isObject, uniq, stripTags, joinPaths, contrastColor,
33
} from './utils.js';
44

55
test('basename', () => {
@@ -66,3 +66,14 @@ test('uniq', () => {
6666
test('stripTags', () => {
6767
expect(stripTags('<a>test</a>')).toEqual('test');
6868
});
69+
70+
test('contrastColor', () => {
71+
expect(contrastColor('#000000')).toEqual('#fff');
72+
expect(contrastColor('#333333')).toEqual('#fff');
73+
expect(contrastColor('#ff0000')).toEqual('#fff');
74+
expect(contrastColor('#0000ff')).toEqual('#fff');
75+
expect(contrastColor('#cccccc')).toEqual('#000');
76+
expect(contrastColor('#ffffff')).toEqual('#000');
77+
expect(contrastColor('000000')).toEqual('#fff');
78+
expect(contrastColor('ffffff')).toEqual('#000');
79+
});

webpack.config.js

+9-2
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import {fileURLToPath} from 'url';
1212

1313
const {VueLoaderPlugin} = VueLoader;
1414
const {ESBuildMinifyPlugin} = EsBuildLoader;
15-
const {SourceMapDevToolPlugin} = webpack;
15+
const {SourceMapDevToolPlugin, ProvidePlugin} = webpack;
1616
const __dirname = dirname(fileURLToPath(import.meta.url));
1717
const glob = (pattern) => fastGlob.sync(pattern, {cwd: __dirname, absolute: true});
1818

@@ -122,7 +122,10 @@ export default {
122122
{
123123
loader: 'esbuild-loader',
124124
options: {
125-
target: 'es2015'
125+
target: 'es2015',
126+
loader: 'jsx',
127+
jsxFactory: 'h',
128+
jsxFragment: 'Fragment',
126129
},
127130
},
128131
],
@@ -205,6 +208,10 @@ export default {
205208
new MonacoWebpackPlugin({
206209
filename: 'js/monaco-[name].worker.js',
207210
}),
211+
new ProvidePlugin({
212+
h: ['jsx-dom', 'h'],
213+
Fragment: ['jsx-dom', 'Fragment'],
214+
}),
208215
isProduction ? new LicenseCheckerWebpackPlugin({
209216
outputFilename: 'js/licenses.txt',
210217
outputWriter: ({dependencies}) => {

0 commit comments

Comments
 (0)