Skip to content

Commit a8db90f

Browse files
committed
feat: change the approach to also use DocServiceBuilder#injectedScripts
--- Related: #5900 **Motivation:** This change addresses feedback from multiple users requesting the ability to customize the appearance of the documentation service. The customizable elements now include: - Header title - Header bar background color - Color for the `GotoSelect` component - Browser tab title - Browser tab favicon **Modifications:** Backend - In `DocServiceBuilder`, add a `Map` to store relevant data and a builder method for the documentation title. - In `DocService`, modify the constructor to include a new attribute. - In Static Nested `SpecificationLoader`, adjust the constructor and the `generateServiceSpecification` method. - In `ServiceSpecification`, create a private `Map` and add Getter and Setter methods. - In `DocServiceInjectedScriptsUtil`, add methods to generate customization scripts. - In `DocServiceInjectedScriptsUtilTest`, develop tests for the above logic. Frontend - In `lib/specification.tsx`, include a `Record<String, String>` attribute in the `SpecificationData` interface. - In `containers/App/index.tsx`, retrieve the title and apply it to the app's titles. Add JS hook class. - In `components/GotoSelect/index.tsx`, add JS hook class for custom behavior. **Result:** - Closes: #5900 We can now customize the appearance of: - Header title - Header bar background color - Color for the `GotoSelect` component - Browser tab title - Browser tab favicon
1 parent 001ae90 commit a8db90f

File tree

8 files changed

+277
-145
lines changed

8 files changed

+277
-145
lines changed

core/src/main/java/com/linecorp/armeria/server/docs/DocServiceBuilder.java

Lines changed: 0 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -572,67 +572,6 @@ public DocServiceBuilder webAppTitle(String webAppTitle) {
572572
return this;
573573
}
574574

575-
/**
576-
* Sets the header bar background color of the web application to be used in the documentation service.
577-
* @param headerBarBackgroundColor The hexadecimal color code for the header bar background.
578-
* Must not be null or empty, and must match the pattern "#RRGGBB".
579-
* @return The current {@link DocServiceBuilder} instance for method chaining.
580-
*/
581-
public DocServiceBuilder headerBarBackgroundColor(String headerBarBackgroundColor) {
582-
final String headerBarBackgroundColorKey = "headerBarBackgroundColor";
583-
final Integer headerBarBackgroundColorMaxSize = 7;
584-
final String hexColorPattern = "^#([0-9a-fA-F]{6})$";
585-
requireNonNull(headerBarBackgroundColor, headerBarBackgroundColorKey);
586-
checkArgument(!headerBarBackgroundColor.trim().isEmpty(), "%s is empty.", headerBarBackgroundColorKey);
587-
checkArgument(headerBarBackgroundColor.length() <= headerBarBackgroundColorMaxSize,
588-
"%s length exceeds %s.", headerBarBackgroundColorKey, headerBarBackgroundColorMaxSize);
589-
checkArgument(Pattern.compile(hexColorPattern).matcher(headerBarBackgroundColor).matches(),
590-
"%s not in hex format: %s.", headerBarBackgroundColorKey, headerBarBackgroundColor);
591-
docServiceExtraInfo.putIfAbsent(headerBarBackgroundColorKey, headerBarBackgroundColor);
592-
return this;
593-
}
594-
595-
/**
596-
* Sets the background color of the 'Goto' header bar component used in the documentation service.
597-
* @param headerGotoBackgroundColor The hexadecimal color code
598-
* Must not be null or empty, and must match the pattern "#RRGGBB".
599-
* @return The current {@link DocServiceBuilder} instance for method chaining.
600-
*/
601-
public DocServiceBuilder headerGotoBackgroundColor(String headerGotoBackgroundColor) {
602-
final String headerGotoBackgroundColorKey = "headerGotoBackgroundColor";
603-
final Integer headerGotoBackgroundColorMaxSize = 7;
604-
final String hexColorPattern = "^#([0-9a-fA-F]{6})$";
605-
requireNonNull(headerGotoBackgroundColor, headerGotoBackgroundColorKey);
606-
checkArgument(!headerGotoBackgroundColor.trim().isEmpty(),
607-
"%s is empty.", headerGotoBackgroundColorKey);
608-
checkArgument(headerGotoBackgroundColor.length() <= headerGotoBackgroundColorMaxSize,
609-
"%s length exceeds %s.", headerGotoBackgroundColorKey, headerGotoBackgroundColorMaxSize);
610-
checkArgument(Pattern.compile(hexColorPattern).matcher(headerGotoBackgroundColor).matches(),
611-
"%s not in hex format: %s.", headerGotoBackgroundColorKey, headerGotoBackgroundColor);
612-
docServiceExtraInfo.putIfAbsent(headerGotoBackgroundColorKey, headerGotoBackgroundColor);
613-
return this;
614-
}
615-
616-
/**
617-
* Sets the buttons color of the 'DebugPage' component used in the documentation service.
618-
* @param debugFormButtonsColor The hexadecimal color code
619-
* Must not be null or empty, and must match the pattern "#RRGGBB".
620-
* @return The current {@link DocServiceBuilder} instance for method chaining.
621-
*/
622-
public DocServiceBuilder debugFormButtonsColor(String debugFormButtonsColor) {
623-
final String debugFormButtonsColorKey = "debugFormButtonsColor";
624-
final Integer debugFormButtonsColorMaxSize = 7;
625-
final String hexColorPattern = "^#([0-9a-fA-F]{6})$";
626-
requireNonNull(debugFormButtonsColor, debugFormButtonsColorKey);
627-
checkArgument(!debugFormButtonsColor.trim().isEmpty(), "%s is empty.", debugFormButtonsColorKey);
628-
checkArgument(debugFormButtonsColor.length() <= debugFormButtonsColorMaxSize,
629-
"%s length exceeds %s.", debugFormButtonsColorKey, debugFormButtonsColorMaxSize);
630-
checkArgument(Pattern.compile(hexColorPattern).matcher(debugFormButtonsColor).matches(),
631-
"%s not in hex format: %s.", debugFormButtonsColorKey, debugFormButtonsColor);
632-
docServiceExtraInfo.putIfAbsent(debugFormButtonsColorKey, debugFormButtonsColor);
633-
return this;
634-
}
635-
636575
/**
637576
* Returns a newly-created {@link DocService} based on the properties of this builder.
638577
*/
Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
/*
2+
* Copyright 2025 LINE Corporation
3+
*
4+
* LINE Corporation licenses this file to you under the Apache License,
5+
* version 2.0 (the "License"); you may not use this file except in compliance
6+
* with the License. You may obtain a copy of the License at:
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
12+
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
13+
* License for the specific language governing permissions and limitations
14+
* under the License.
15+
*/
16+
package com.linecorp.armeria.server.docs;
17+
18+
import static com.google.common.base.Preconditions.checkArgument;
19+
import static java.util.Objects.requireNonNull;
20+
21+
import java.util.regex.Pattern;
22+
23+
import com.google.common.collect.ImmutableSet;
24+
25+
/**
26+
* Util class for DocServiceBuilder#injectedScripts method.
27+
*/
28+
public final class DocServiceInjectedScriptsUtil {
29+
30+
private static final String HEX_COLOR_PATTERN = "^#([0-9a-fA-F]{6}|[0-9a-fA-F]{3})$";
31+
private static final int MAX_COLOR_LENGTH = 7;
32+
private static final String SAFE_DOM_HOOK = "data-js-target";
33+
private static final ImmutableSet<String> ALLOWED_FAVICON_EXTENSIONS =
34+
ImmutableSet.of(".ico", ".png", ".svg");
35+
36+
/**
37+
* Returns a js script to change the title background color.
38+
*
39+
* @param color the color string to set
40+
* @return the js script
41+
*/
42+
public static String withTitleBackground(String color) {
43+
final String titleBackgroundKey = "titleBackground";
44+
final String targetAttr = "main-app-bar";
45+
validateHexColor(color, titleBackgroundKey);
46+
47+
return buildStyleScript(color, targetAttr);
48+
}
49+
50+
/**
51+
* Returns a js script to change the goto component background color.
52+
*
53+
* @param color the color string to set
54+
* @return the js script
55+
*/
56+
public static String withGotoBackground(String color) {
57+
final String gotoBackgroundKey = "gotoBackground";
58+
final String targetAttr = "goto-app-bar";
59+
validateHexColor(color, gotoBackgroundKey);
60+
61+
return buildStyleScript(color, targetAttr);
62+
}
63+
64+
/**
65+
* Returns a js script to change the web favicon.
66+
*
67+
* @param url the url string to set
68+
* @return the js script
69+
*/
70+
public static String withFavicon(String url) {
71+
final String faviconKey = "favicon";
72+
validateFaviconUrl(url, faviconKey);
73+
74+
return buildFaviconScript(escapeForJavaScriptUrl(url));
75+
}
76+
77+
private DocServiceInjectedScriptsUtil() {}
78+
79+
/**
80+
* Validates the favicon url.
81+
*
82+
* @param url the url string to validate
83+
* @param key the name used in error messages
84+
*/
85+
private static void validateFaviconUrl(String url, String key) {
86+
requireNonNull(url, key);
87+
checkArgument(!url.trim().isEmpty(), "%s is empty.", key);
88+
checkArgument(hasValidFaviconExtension(url), "%s extension not allowed.",key);
89+
}
90+
91+
/**
92+
* Validates the favicon extension.
93+
*
94+
* @param url the url string
95+
* @return the result of validation
96+
*/
97+
private static boolean hasValidFaviconExtension(String url) {
98+
final String lowerUrl = url.toLowerCase();
99+
return ALLOWED_FAVICON_EXTENSIONS.stream()
100+
.anyMatch(lowerUrl::endsWith);
101+
}
102+
103+
/**
104+
* Escapes special characters in a string to safely embed it in a JavaScript string literal.
105+
*
106+
* @param url the input string to escape
107+
* @return the escaped string
108+
*/
109+
private static String escapeForJavaScriptUrl(String url) {
110+
final StringBuilder escaped = new StringBuilder(url.length());
111+
112+
for (char c : url.toCharArray()) {
113+
switch (c) {
114+
case '\\':
115+
escaped.append("\\\\");
116+
break;
117+
case '\'':
118+
escaped.append("\\'");
119+
break;
120+
case '"':
121+
escaped.append("\\\"");
122+
break;
123+
case ';':
124+
case '\n':
125+
case '\r':
126+
break;
127+
default:
128+
escaped.append(c);
129+
}
130+
}
131+
132+
return escaped.toString();
133+
}
134+
135+
/**
136+
* Validates that the given color is a non-null, non-empty, character hex color string.
137+
*
138+
* @param color the color string to validate
139+
* @param key the name used in error messages
140+
*/
141+
private static void validateHexColor(String color, String key) {
142+
requireNonNull(color, key);
143+
checkArgument(!color.trim().isEmpty(), "%s is empty.", key);
144+
checkArgument(color.length() <= MAX_COLOR_LENGTH,
145+
"%s length exceeds %s.", key, MAX_COLOR_LENGTH);
146+
checkArgument(Pattern.matches(HEX_COLOR_PATTERN, color),
147+
"%s not in hex format: %s.", key, color);
148+
}
149+
150+
/**
151+
* Builds a JavaScript snippet that sets the background color of a DOM element.
152+
*
153+
* @param color the background color in hex format
154+
* @param targetAttr the value of the target attribute to match
155+
* @return a JavaScript string that applies the background color to the element
156+
*/
157+
private static String buildStyleScript(String color, String targetAttr) {
158+
return "{\n" +
159+
" const element = document.querySelector('[" + SAFE_DOM_HOOK + "=\"" + targetAttr + "\"]');\n" +
160+
" if (element) {\n" +
161+
" element.style.backgroundColor = '" + color + "';\n" +
162+
" }\n}\n";
163+
}
164+
165+
/**
166+
* Builds a JavaScript snippet that sets the new favicon.
167+
*
168+
* @param url the url string to set
169+
* @return a JavaScript string that applies the favicon change
170+
*/
171+
private static String buildFaviconScript(String url) {
172+
return "{\n" +
173+
" let link = document.querySelector('link[rel~=\"icon\"]');\n" +
174+
" if (link) {\n" +
175+
" document.head.removeChild(link);\n" +
176+
" }\n" +
177+
" link = document.createElement('link');\n" +
178+
" link.rel = 'icon';\n" +
179+
" link.type = 'image/x-icon';\n" +
180+
" link.href = '" + url + "';\n" +
181+
" document.head.appendChild(link);\n" +
182+
"}\n";
183+
}
184+
}
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
/*
2+
* Copyright 2025 LINE Corporation
3+
*
4+
* LINE Corporation licenses this file to you under the Apache License,
5+
* version 2.0 (the "License"); you may not use this file except in compliance
6+
* with the License. You may obtain a copy of the License at:
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
12+
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
13+
* License for the specific language governing permissions and limitations
14+
* under the License.
15+
*/
16+
package com.linecorp.armeria.server.docs;
17+
18+
import static org.assertj.core.api.Assertions.assertThat;
19+
import static org.assertj.core.api.Assertions.assertThatThrownBy;
20+
21+
import org.junit.jupiter.api.Test;
22+
23+
public class DocServiceInjectedScriptsUtilTest {
24+
25+
@Test
26+
void withTitleBackground_givenValidColor_returnsScriptWithColor() {
27+
28+
final String color = "#ff0089";
29+
final String absentColor = "#ff9dc3";
30+
31+
final String result = DocServiceInjectedScriptsUtil.withTitleBackground(color);
32+
33+
assertThat(result).contains(color).doesNotContain(absentColor);
34+
}
35+
36+
@Test
37+
void withTitleBackground_givenInvalidColor_throwsException() {
38+
39+
final String color = "#1234567";
40+
41+
assertThatThrownBy(() -> DocServiceInjectedScriptsUtil.withGotoBackground(color))
42+
.isInstanceOf(IllegalArgumentException.class)
43+
.hasMessageContaining("length exceeds");
44+
}
45+
46+
@Test
47+
void withGotoBackground_givenValidColor_returnsScriptWithColor() {
48+
49+
final String color = "#ff9dc3";
50+
final String absentColor = "#ff0089";
51+
52+
final String result = DocServiceInjectedScriptsUtil.withGotoBackground(color);
53+
54+
assertThat(result).contains(color).doesNotContain(absentColor);
55+
}
56+
57+
@Test
58+
void withFavicon_givenValidUrl_returnsScriptWithUrl() {
59+
60+
final String url = "https://armeria.dev/static/icon.png";
61+
final String absentUrl = "https://line.com/static/icon.png";
62+
63+
final String result = DocServiceInjectedScriptsUtil.withFavicon(url);
64+
65+
assertThat(result).contains(url).doesNotContain(absentUrl);
66+
}
67+
68+
@Test
69+
void withFavicon_givenInvalidUrl_throwsException() {
70+
71+
final String url = "https://armeria.dev/static/icon.js";
72+
73+
assertThatThrownBy(() -> DocServiceInjectedScriptsUtil.withFavicon(url))
74+
.isInstanceOf(IllegalArgumentException.class)
75+
.hasMessageContaining("extension not allowed");
76+
}
77+
78+
@Test
79+
void withFavicon_givenEvilUrl_returnsScriptWithUrl() {
80+
81+
final String url = "https://line.com/static/ico\\n';mal\"ici;ous\n\r.png";
82+
final String expectedUrl = "https://line.com/static/ico\\\\n\\'mal\\\"icious.png";
83+
84+
final String result = DocServiceInjectedScriptsUtil.withFavicon(url);
85+
86+
assertThat(result).contains(expectedUrl).doesNotContain(url);
87+
}
88+
}

docs-client/src/components/GotoSelect/index.tsx

Lines changed: 1 addition & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,6 @@ import React, { ChangeEvent, useCallback, useMemo, useRef } from 'react';
2323

2424
import { Specification } from '../../lib/specification';
2525
import { SelectOption } from '../../lib/types';
26-
import { getValidHexColor } from '../../lib/colors';
2726

2827
type Option = SelectOption & { group: string };
2928

@@ -122,17 +121,8 @@ const GotoSelect: React.FunctionComponent<GotoSelectProps> = ({
122121
[navigateTo],
123122
);
124123

125-
const extraInfo = specification.getDocServiceExtraInfo();
126-
const headerGotoBackgroundColor = getValidHexColor(
127-
extraInfo,
128-
'headerGotoBackgroundColor',
129-
);
130-
131124
return (
132-
<div
133-
className={classes.root}
134-
style={{ backgroundColor: headerGotoBackgroundColor }}
135-
>
125+
<div className={classes.root} data-js-target="goto-app-bar">
136126
<Autocomplete
137127
autoHighlight
138128
blurOnSelect

0 commit comments

Comments
 (0)