mirror of
https://github.com/scm-manager/scm-manager.git
synced 2025-11-12 00:15:44 +01:00
Use more accurate language detection for syntax highlighting (#1891)
Updated spotter to version 4 in order to get prism syntax mode for detected coding languages. Expose syntax modes of coding languages as headers on content endpoint and as fields on diff dto. Remove leading line break on search result fragments. Use mark instead of span or strong for highlighted search results. Add option to use syntax highlighting in TextHitField component. Co-authored-by: Matthias Thieroff <matthias.thieroff@cloudogu.com>
This commit is contained in:
@@ -1,2 +1,4 @@
|
|||||||
- type: changed
|
- type: changed
|
||||||
description: Keep whole lines for code highlighting in search ([#1871](https://github.com/scm-manager/scm-manager/pull/1871))
|
description: Keep whole lines for code highlighting in search ([#1871](https://github.com/scm-manager/scm-manager/pull/1871))
|
||||||
|
- type: changed
|
||||||
|
description: Use more accurate language detection for syntax highlighting ([#1891](https://github.com/scm-manager/scm-manager/pull/1891))
|
||||||
|
|||||||
@@ -127,7 +127,7 @@ ext {
|
|||||||
webResources: 'com.github.sdorra:web-resources:1.1.1',
|
webResources: 'com.github.sdorra:web-resources:1.1.1',
|
||||||
|
|
||||||
// content type detection
|
// content type detection
|
||||||
spotter: 'com.github.sdorra:spotter-core:3.0.1',
|
spotter: 'com.cloudogu.spotter:spotter-core:4.0.0',
|
||||||
tika: 'org.apache.tika:tika-core:1.25',
|
tika: 'org.apache.tika:tika-core:1.25',
|
||||||
|
|
||||||
// restart on unix
|
// restart on unix
|
||||||
|
|||||||
@@ -24,6 +24,8 @@
|
|||||||
|
|
||||||
package sonia.scm.io;
|
package sonia.scm.io;
|
||||||
|
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.Map;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -68,4 +70,14 @@ public interface ContentType {
|
|||||||
* @return programming language or empty
|
* @return programming language or empty
|
||||||
*/
|
*/
|
||||||
Optional<String> getLanguage();
|
Optional<String> getLanguage();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a map of syntax modes such as codemirror, ace or prism.
|
||||||
|
*
|
||||||
|
* @return map of syntax modes
|
||||||
|
* @since 2.28.0
|
||||||
|
*/
|
||||||
|
default Map<String, String> getSyntaxModes() {
|
||||||
|
return Collections.emptyMap();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,9 @@
|
|||||||
|
|
||||||
package sonia.scm.io;
|
package sonia.scm.io;
|
||||||
|
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ContentTypeResolver is able to detect the {@link ContentType} of files based on their path and (optinally) a few starting bytes. These files do not have to be real files on the file system, but can be hypothetical constructs ("What content type is most probable for a file named like this").
|
* ContentTypeResolver is able to detect the {@link ContentType} of files based on their path and (optinally) a few starting bytes. These files do not have to be real files on the file system, but can be hypothetical constructs ("What content type is most probable for a file named like this").
|
||||||
*
|
*
|
||||||
@@ -35,7 +38,6 @@ public interface ContentTypeResolver {
|
|||||||
* Detects the {@link ContentType} of the given path, by only using path based strategies.
|
* Detects the {@link ContentType} of the given path, by only using path based strategies.
|
||||||
*
|
*
|
||||||
* @param path path of the file
|
* @param path path of the file
|
||||||
*
|
|
||||||
* @return {@link ContentType} of path
|
* @return {@link ContentType} of path
|
||||||
*/
|
*/
|
||||||
ContentType resolve(String path);
|
ContentType resolve(String path);
|
||||||
@@ -43,10 +45,19 @@ public interface ContentTypeResolver {
|
|||||||
/**
|
/**
|
||||||
* Detects the {@link ContentType} of the given path, by using path and content based strategies.
|
* Detects the {@link ContentType} of the given path, by using path and content based strategies.
|
||||||
*
|
*
|
||||||
* @param path path of the file
|
* @param path path of the file
|
||||||
* @param contentPrefix first few bytes of the content
|
* @param contentPrefix first few bytes of the content
|
||||||
*
|
|
||||||
* @return {@link ContentType} of path and content prefix
|
* @return {@link ContentType} of path and content prefix
|
||||||
*/
|
*/
|
||||||
ContentType resolve(String path, byte[] contentPrefix);
|
ContentType resolve(String path, byte[] contentPrefix);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a map of syntax highlighting modes such as ace, codemirror or prism by language.
|
||||||
|
* @param language name of the coding language
|
||||||
|
* @return map of syntax highlighting modes
|
||||||
|
* @since 2.28.0
|
||||||
|
*/
|
||||||
|
default Map<String, String> findSyntaxModesByLanguage(String language) {
|
||||||
|
return Collections.emptyMap();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,9 @@ import { ApiResultWithFetching } from "./base";
|
|||||||
export type ContentType = {
|
export type ContentType = {
|
||||||
type: string;
|
type: string;
|
||||||
language?: string;
|
language?: string;
|
||||||
|
aceMode?: string;
|
||||||
|
codemirrorMode?: string;
|
||||||
|
prismMode?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
function getContentType(url: string): Promise<ContentType> {
|
function getContentType(url: string): Promise<ContentType> {
|
||||||
@@ -35,6 +38,9 @@ function getContentType(url: string): Promise<ContentType> {
|
|||||||
return {
|
return {
|
||||||
type: response.headers.get("Content-Type") || "application/octet-stream",
|
type: response.headers.get("Content-Type") || "application/octet-stream",
|
||||||
language: response.headers.get("X-Programming-Language") || undefined,
|
language: response.headers.get("X-Programming-Language") || undefined,
|
||||||
|
aceMode: response.headers.get("X-Syntax-Mode-Ace") || undefined,
|
||||||
|
codemirrorMode: response.headers.get("X-Syntax-Mode-Codemirror") || undefined,
|
||||||
|
prismMode: response.headers.get("X-Syntax-Mode-Prism") || undefined,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,9 +33,10 @@ export type Replacement = {
|
|||||||
type Props = {
|
type Props = {
|
||||||
text: string;
|
text: string;
|
||||||
replacements: Replacement[];
|
replacements: Replacement[];
|
||||||
|
textWrapper?: (s: string) => ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
const textWrapper = (s: string) => {
|
const defaultTextWrapper = (s: string) => {
|
||||||
const first = s.startsWith(" ") ? <> </> : "";
|
const first = s.startsWith(" ") ? <> </> : "";
|
||||||
const last = s.endsWith(" ") ? <> </> : "";
|
const last = s.endsWith(" ") ? <> </> : "";
|
||||||
return (
|
return (
|
||||||
@@ -47,7 +48,7 @@ const textWrapper = (s: string) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const SplitAndReplace: FC<Props> = ({ text, replacements }) => {
|
const SplitAndReplace: FC<Props> = ({ text, replacements, textWrapper = defaultTextWrapper }) => {
|
||||||
const parts = textSplitAndReplace<ReactNode>(text, replacements, textWrapper);
|
const parts = textSplitAndReplace<ReactNode>(text, replacements, textWrapper);
|
||||||
if (parts.length === 0) {
|
if (parts.length === 0) {
|
||||||
return <>{parts[0]}</>;
|
return <>{parts[0]}</>;
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ storiesOf("SyntaxHighlighter", module)
|
|||||||
))
|
))
|
||||||
.add("Go", () => (
|
.add("Go", () => (
|
||||||
<Spacing>
|
<Spacing>
|
||||||
<SyntaxHighlighter language="golang" value={GoHttpServer} />
|
<SyntaxHighlighter language="go" value={GoHttpServer} />
|
||||||
</Spacing>
|
</Spacing>
|
||||||
))
|
))
|
||||||
.add("Javascript", () => (
|
.add("Javascript", () => (
|
||||||
|
|||||||
67
scm-ui/ui-components/src/__resources__/ContentSearchHit.ts
Normal file
67
scm-ui/ui-components/src/__resources__/ContentSearchHit.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
/*
|
||||||
|
* MIT License
|
||||||
|
*
|
||||||
|
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
* of this software and associated documentation files (the "Software"), to deal
|
||||||
|
* in the Software without restriction, including without limitation the rights
|
||||||
|
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
* copies of the Software, and to permit persons to whom the Software is
|
||||||
|
* furnished to do so, subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
* SOFTWARE.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Hit } from "@scm-manager/ui-types";
|
||||||
|
|
||||||
|
export const javaHit: Hit = {
|
||||||
|
score: 2.5,
|
||||||
|
fields: {
|
||||||
|
content: {
|
||||||
|
highlighted: true,
|
||||||
|
fragments: [
|
||||||
|
"import org.slf4j.LoggerFactory;\n\nimport java.util.Date;\nimport java.util.HashMap;\nimport java.util.Map;\nimport java.util.concurrent.TimeUnit;\n\n/**\n * Jwt implementation of {@link <|[[--AccessTokenBuilder--]]|>}.\n * \n * @author Sebastian Sdorra\n * @since 2.0.0\n */\npublic final class <|[[--JwtAccessTokenBuilder--]]|> implements <|[[--AccessTokenBuilder--]]|> {\n\n /**\n * the logger for <|[[--JwtAccessTokenBuilder--]]|>\n */\n private static final Logger LOG = LoggerFactory.getLogger(<|[[--JwtAccessTokenBuilder.class--]]|>);\n \n private final KeyGenerator keyGenerator; \n private final SecureKeyResolver keyResolver; \n \n private String subject;\n private String issuer;\n",
|
||||||
|
" private final Map<String,Object> custom = Maps.newHashMap();\n \n <|[[--JwtAccessTokenBuilder--]]|>(KeyGenerator keyGenerator, SecureKeyResolver keyResolver) {\n this.keyGenerator = keyGenerator;\n this.keyResolver = keyResolver;\n }\n\n @Override\n public <|[[--JwtAccessTokenBuilder--]]|> subject(String subject) {\n",
|
||||||
|
' public <|[[--JwtAccessTokenBuilder--]]|> custom(String key, Object value) {\n Preconditions.checkArgument(!Strings.isNullOrEmpty(key), "null or empty value not allowed");\n Preconditions.checkArgument(value != null, "null or empty value not allowed");\n'
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
_links: {}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const bashHit: Hit = {
|
||||||
|
score: 2.5,
|
||||||
|
fields: {
|
||||||
|
content: {
|
||||||
|
highlighted: true,
|
||||||
|
fragments: [
|
||||||
|
'# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\n# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\n# SOFTWARE.\n#\n\n<|[[--getent--]]|> group scm >/dev/null || groupadd -r scm\n<|[[--getent--]]|> passwd scm >/dev/null || \\\n useradd -r -g scm -M \\\n -s /sbin/nologin -d /var/lib/scm \\\n -c "user for the scm-server process" scm\nexit 0\n\n'
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
_links: {}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const markdownHit: Hit = {
|
||||||
|
score: 2.5,
|
||||||
|
fields: {
|
||||||
|
content: {
|
||||||
|
highlighted: true,
|
||||||
|
fragments: [
|
||||||
|
"---\ntitle: SCM-Manager v2 Test <|[[--Cases--]]|>\n---\n\nDescribes the expected behaviour for SCMM v2 REST Resources using manual tests.\n\nThe following states general test <|[[--cases--]]|> per HTTP Method and en expected return code as well as exemplary curl calls.\nResource-specifics are stated \n\n## Test <|[[--Cases--]]|>\n\n### GET\n\n- Collection Resource (e.g. `/users`)\n - Without parameters -> 200\n - Parameters\n - `?pageSize=1` -> Only one embedded element, pageTotal reflects the correct number of pages, `last` link points to last page.\n - `?pageSize=1&page=1` -> `next` link points to page 0 ; `prev` link points to page 2\n - `?sortBy=admin` -> Sorted by `admin` field of embedded objects\n - `?sortBy=admin&desc=true` -> Invert sorting\n- Individual Resource (e.g. `/users/scmadmin`)\n - Exists -> 200\n",
|
||||||
|
"\n### DELETE\n\n- existing -> 204\n- not existing -> 204\n- without permission -> 401\n\n## Exemplary calls & Resource specific test <|[[--cases--]]|>\n\nIn order to extend those tests to other Resources, have a look at the rest docs. Note that the Content Type is specific to each resource as well.\n"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
_links: {}
|
||||||
|
};
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -35,7 +35,7 @@ describe("syntax highlighter", () => {
|
|||||||
expect(java).toBe("java");
|
expect(java).toBe("java");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should return text if language is undefied", () => {
|
it("should return text if language is undefined", () => {
|
||||||
const lang = determineLanguage();
|
const lang = determineLanguage();
|
||||||
expect(lang).toBe("text");
|
expect(lang).toBe("text");
|
||||||
});
|
});
|
||||||
@@ -45,8 +45,4 @@ describe("syntax highlighter", () => {
|
|||||||
expect(lang).toBe("text");
|
expect(lang).toBe("text");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should use alias go for golang", () => {
|
|
||||||
const go = determineLanguage("golang");
|
|
||||||
expect(go).toBe("go");
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -22,20 +22,11 @@
|
|||||||
* SOFTWARE.
|
* SOFTWARE.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// this aliases are only to map from spotter detection to prismjs
|
|
||||||
const languageAliases: { [key: string]: string } = {
|
|
||||||
golang: "go",
|
|
||||||
};
|
|
||||||
|
|
||||||
export const defaultLanguage = "text";
|
export const defaultLanguage = "text";
|
||||||
|
|
||||||
export const determineLanguage = (language?: string) => {
|
export const determineLanguage = (language?: string) => {
|
||||||
if (!language) {
|
if (!language) {
|
||||||
return defaultLanguage;
|
return defaultLanguage;
|
||||||
}
|
}
|
||||||
const lang = language.toLowerCase();
|
return language.toLowerCase();
|
||||||
if (languageAliases[lang]) {
|
|
||||||
return languageAliases[lang];
|
|
||||||
}
|
|
||||||
return lang;
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -26,35 +26,46 @@ import styled from "styled-components";
|
|||||||
// @ts-ignore we have no typings for react-diff-view
|
// @ts-ignore we have no typings for react-diff-view
|
||||||
import { Diff, useTokenizeWorker } from "react-diff-view";
|
import { Diff, useTokenizeWorker } from "react-diff-view";
|
||||||
import { FileDiff } from "@scm-manager/ui-types";
|
import { FileDiff } from "@scm-manager/ui-types";
|
||||||
import { determineLanguage } from "../languages";
|
|
||||||
|
|
||||||
// @ts-ignore no types for css modules
|
// @ts-ignore no types for css modules
|
||||||
import theme from "../syntax-highlighting.module.css";
|
import theme from "../syntax-highlighting.module.css";
|
||||||
|
import { determineLanguage } from "../languages";
|
||||||
|
|
||||||
const DiffView = styled(Diff)`
|
const DiffView = styled(Diff)`
|
||||||
/* align line numbers */
|
/* align line numbers */
|
||||||
|
|
||||||
& .diff-gutter {
|
& .diff-gutter {
|
||||||
text-align: right;
|
text-align: right;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* column sizing */
|
/* column sizing */
|
||||||
|
|
||||||
> colgroup .diff-gutter-col {
|
> colgroup .diff-gutter-col {
|
||||||
width: 3.25rem;
|
width: 3.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* prevent following content from moving down */
|
/* prevent following content from moving down */
|
||||||
|
|
||||||
> .diff-gutter:empty:hover::after {
|
> .diff-gutter:empty:hover::after {
|
||||||
font-size: 0.7rem;
|
font-size: 0.7rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* smaller font size for code and
|
/* smaller font size for code and
|
||||||
ensure same monospace font throughout whole scmm */
|
ensure same monospace font throughout whole scmm */
|
||||||
|
|
||||||
& .diff-line {
|
& .diff-line {
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
font-family: "Courier New", Monaco, Menlo, "Ubuntu Mono", "source-code-pro", monospace;
|
font-family: "Courier New", Monaco, Menlo, "Ubuntu Mono", "source-code-pro", monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* comment padding for sidebyside view */
|
/* comment padding for sidebyside view */
|
||||||
|
|
||||||
&.split .diff-widget-content .is-indented-line {
|
&.split .diff-widget-content .is-indented-line {
|
||||||
padding-left: 3.25rem;
|
padding-left: 3.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* comment padding for combined view */
|
/* comment padding for combined view */
|
||||||
|
|
||||||
&.unified .diff-widget-content .is-indented-line {
|
&.unified .diff-widget-content .is-indented-line {
|
||||||
padding-left: 6.5rem;
|
padding-left: 6.5rem;
|
||||||
}
|
}
|
||||||
@@ -71,10 +82,17 @@ type Props = {
|
|||||||
className?: string;
|
className?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const findSyntaxHighlightingLanguage = (file: FileDiff) => {
|
||||||
|
if (file.syntaxModes) {
|
||||||
|
return file.syntaxModes["prism"] || file.syntaxModes["codemirror"] || file.syntaxModes["ace"] || file.language;
|
||||||
|
}
|
||||||
|
return file.language;
|
||||||
|
};
|
||||||
|
|
||||||
const TokenizedDiffView: FC<Props> = ({ file, viewType, className, children }) => {
|
const TokenizedDiffView: FC<Props> = ({ file, viewType, className, children }) => {
|
||||||
const { tokens } = useTokenizeWorker(tokenize, {
|
const { tokens } = useTokenizeWorker(tokenize, {
|
||||||
hunks: file.hunks,
|
hunks: file.hunks,
|
||||||
language: determineLanguage(file.language),
|
language: determineLanguage(findSyntaxHighlightingLanguage(file))
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ const commitImplementMain = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const source: AnnotatedSource = {
|
const source: AnnotatedSource = {
|
||||||
language: "golang",
|
language: "go",
|
||||||
lines: [
|
lines: [
|
||||||
{
|
{
|
||||||
lineNumber: 1,
|
lineNumber: 1,
|
||||||
|
|||||||
@@ -46,10 +46,15 @@ const createAdapter = (theme: { [key: string]: string }): RefractorAdapter => {
|
|||||||
import(
|
import(
|
||||||
/* webpackChunkName: "tokenizer-refractor-[request]" */
|
/* webpackChunkName: "tokenizer-refractor-[request]" */
|
||||||
`refractor/lang/${lang}`
|
`refractor/lang/${lang}`
|
||||||
).then((loadedLanguage) => {
|
)
|
||||||
refractor.register(loadedLanguage.default);
|
.then(loadedLanguage => {
|
||||||
callback();
|
refractor.register(loadedLanguage.default);
|
||||||
});
|
callback();
|
||||||
|
})
|
||||||
|
.catch(e => {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log(`failed to load refractor language ${lang}: ${e}`);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -58,7 +63,7 @@ const createAdapter = (theme: { [key: string]: string }): RefractorAdapter => {
|
|||||||
const runHook = (name: string, env: RunHookEnv) => {
|
const runHook = (name: string, env: RunHookEnv) => {
|
||||||
originalRunHook.apply(name, env);
|
originalRunHook.apply(name, env);
|
||||||
if (env.classes) {
|
if (env.classes) {
|
||||||
env.classes = env.classes.map((className) => theme[className] || className);
|
env.classes = env.classes.map(className => theme[className] || className);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
// @ts-ignore hooks are not in the type definition
|
// @ts-ignore hooks are not in the type definition
|
||||||
@@ -67,7 +72,7 @@ const createAdapter = (theme: { [key: string]: string }): RefractorAdapter => {
|
|||||||
return {
|
return {
|
||||||
isLanguageRegistered,
|
isLanguageRegistered,
|
||||||
loadLanguage,
|
loadLanguage,
|
||||||
...refractor,
|
...refractor
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ const HighlightedFragment: FC<Props> = ({ value }) => {
|
|||||||
if (start > 0) {
|
if (start > 0) {
|
||||||
result.push(content.substring(0, start));
|
result.push(content.substring(0, start));
|
||||||
}
|
}
|
||||||
result.push(<strong>{content.substring(start + PRE_TAG.length, end)}</strong>);
|
result.push(<mark>{content.substring(start + PRE_TAG.length, end)}</mark>);
|
||||||
content = content.substring(end + POST_TAG.length);
|
content = content.substring(end + POST_TAG.length);
|
||||||
} else {
|
} else {
|
||||||
result.push(content);
|
result.push(content);
|
||||||
|
|||||||
130
scm-ui/ui-components/src/search/SyntaxHighlightedFragment.tsx
Normal file
130
scm-ui/ui-components/src/search/SyntaxHighlightedFragment.tsx
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
/*
|
||||||
|
* MIT License
|
||||||
|
*
|
||||||
|
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
* of this software and associated documentation files (the "Software"), to deal
|
||||||
|
* in the Software without restriction, including without limitation the rights
|
||||||
|
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
* copies of the Software, and to permit persons to whom the Software is
|
||||||
|
* furnished to do so, subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
* SOFTWARE.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { FC, ReactNode, useEffect, useMemo, useState } from "react";
|
||||||
|
import createAdapter from "../repos/refractorAdapter";
|
||||||
|
// @ts-ignore no types for css modules
|
||||||
|
import theme from "../syntax-highlighting.module.css";
|
||||||
|
import SplitAndReplace, { Replacement } from "../SplitAndReplace";
|
||||||
|
import { AST, RefractorNode } from "refractor";
|
||||||
|
import { determineLanguage } from "../languages";
|
||||||
|
|
||||||
|
const PRE_TAG = "<|[[--";
|
||||||
|
const POST_TAG = "--]]|>";
|
||||||
|
const PRE_TAG_REGEX = /<\|\[\[--/g;
|
||||||
|
const POST_TAG_REGEX = /--]]\|>/g;
|
||||||
|
|
||||||
|
const adapter = createAdapter(theme);
|
||||||
|
|
||||||
|
function createReplacement(textToReplace: string): Replacement {
|
||||||
|
return {
|
||||||
|
textToReplace,
|
||||||
|
replacement: <mark>{textToReplace}</mark>,
|
||||||
|
replaceAll: true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapWithDepth(depth: number, replacements: Replacement[]) {
|
||||||
|
return function mapChildrenWithDepth(child: RefractorNode, i: number) {
|
||||||
|
return mapChild(child, i, depth, replacements);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAstElement(node: RefractorNode): node is AST.Element {
|
||||||
|
return (node as AST.Element).tagName !== undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapChild(child: RefractorNode, i: number, depth: number, replacements: Replacement[]): ReactNode {
|
||||||
|
if (isAstElement(child)) {
|
||||||
|
const className =
|
||||||
|
child.properties && Array.isArray(child.properties.className)
|
||||||
|
? child.properties.className.join(" ")
|
||||||
|
: child.properties.className;
|
||||||
|
|
||||||
|
return React.createElement(
|
||||||
|
child.tagName,
|
||||||
|
Object.assign({ key: `fract-${depth}-${i}` }, child.properties, { className }),
|
||||||
|
child.children && child.children.map(mapWithDepth(depth + 1, replacements))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SplitAndReplace key={`fract-${depth}-${i}`} text={child.value} replacements={replacements} textWrapper={s => s} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
value: string;
|
||||||
|
language: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const stripAndReplace = (value: string) => {
|
||||||
|
const strippedValue = value.replace(PRE_TAG_REGEX, "").replace(POST_TAG_REGEX, "");
|
||||||
|
let content = value;
|
||||||
|
|
||||||
|
const result: string[] = [];
|
||||||
|
while (content.length > 0) {
|
||||||
|
const start = content.indexOf(PRE_TAG);
|
||||||
|
const end = content.indexOf(POST_TAG);
|
||||||
|
if (start >= 0 && end > 0) {
|
||||||
|
const item = content.substring(start + PRE_TAG.length, end);
|
||||||
|
if (!result.includes(item)) {
|
||||||
|
result.push(item);
|
||||||
|
}
|
||||||
|
content = content.substring(end + POST_TAG.length);
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result.sort((a, b) => b.length - a.length);
|
||||||
|
|
||||||
|
return {
|
||||||
|
strippedValue,
|
||||||
|
replacements: result.map(createReplacement)
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const SyntaxHighlightedFragment: FC<Props> = ({ value, language }) => {
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const determinedLanguage = determineLanguage(language);
|
||||||
|
const { strippedValue, replacements } = useMemo(() => stripAndReplace(value), [value]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
adapter.loadLanguage(determinedLanguage, () => {
|
||||||
|
setIsLoading(false);
|
||||||
|
});
|
||||||
|
}, [determinedLanguage]);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <SplitAndReplace text={strippedValue} replacements={replacements} textWrapper={s => s} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const refractorNodes = adapter.highlight(strippedValue, determinedLanguage);
|
||||||
|
const highlightedFragment = refractorNodes.map(mapWithDepth(0, replacements));
|
||||||
|
|
||||||
|
return <>{highlightedFragment}</>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SyntaxHighlightedFragment;
|
||||||
54
scm-ui/ui-components/src/search/TextHitField.stories.tsx
Normal file
54
scm-ui/ui-components/src/search/TextHitField.stories.tsx
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
/*
|
||||||
|
* MIT License
|
||||||
|
*
|
||||||
|
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
* of this software and associated documentation files (the "Software"), to deal
|
||||||
|
* in the Software without restriction, including without limitation the rights
|
||||||
|
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
* copies of the Software, and to permit persons to whom the Software is
|
||||||
|
* furnished to do so, subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
* SOFTWARE.
|
||||||
|
*/
|
||||||
|
import React from "react";
|
||||||
|
import { storiesOf } from "@storybook/react";
|
||||||
|
import { bashHit, javaHit, markdownHit } from "../__resources__/ContentSearchHit";
|
||||||
|
import TextHitField from "./TextHitField";
|
||||||
|
|
||||||
|
storiesOf("TextHitField", module)
|
||||||
|
.add("Default", () => (
|
||||||
|
<pre>
|
||||||
|
<TextHitField hit={javaHit} field={"content"} />
|
||||||
|
</pre>
|
||||||
|
))
|
||||||
|
.add("Java SyntaxHighlighting", () => (
|
||||||
|
<pre>
|
||||||
|
<TextHitField hit={javaHit} field={"content"} syntaxHighlightingLanguage="java" />
|
||||||
|
</pre>
|
||||||
|
))
|
||||||
|
.add("Bash SyntaxHighlighting", () => (
|
||||||
|
<pre>
|
||||||
|
<TextHitField hit={bashHit} field={"content"} syntaxHighlightingLanguage="bash" />
|
||||||
|
</pre>
|
||||||
|
))
|
||||||
|
.add("Markdown SyntaxHighlighting", () => (
|
||||||
|
<pre>
|
||||||
|
<TextHitField hit={markdownHit} field={"content"} syntaxHighlightingLanguage="markdown" />
|
||||||
|
</pre>
|
||||||
|
))
|
||||||
|
.add("Unknown SyntaxHighlighting", () => (
|
||||||
|
<pre>
|
||||||
|
<TextHitField hit={bashHit} field={"content"} syntaxHighlightingLanguage="__unknown__" />
|
||||||
|
</pre>
|
||||||
|
));
|
||||||
@@ -26,35 +26,51 @@ import React, { FC } from "react";
|
|||||||
import { HighlightedHitField, Hit } from "@scm-manager/ui-types";
|
import { HighlightedHitField, Hit } from "@scm-manager/ui-types";
|
||||||
import HighlightedFragment from "./HighlightedFragment";
|
import HighlightedFragment from "./HighlightedFragment";
|
||||||
import { isHighlightedHitField } from "./fields";
|
import { isHighlightedHitField } from "./fields";
|
||||||
|
import SyntaxHighlightedFragment from "./SyntaxHighlightedFragment";
|
||||||
|
|
||||||
|
type HighlightedTextFieldProps = {
|
||||||
|
field: HighlightedHitField;
|
||||||
|
syntaxHighlightingLanguage?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const HighlightedTextField: FC<HighlightedTextFieldProps> = ({ field, syntaxHighlightingLanguage }) => {
|
||||||
|
const separator = syntaxHighlightingLanguage ? "...\n" : " ... ";
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{field.fragments.map((fragment, i) => (
|
||||||
|
<React.Fragment key={fragment}>
|
||||||
|
{separator}
|
||||||
|
{syntaxHighlightingLanguage ? (
|
||||||
|
<SyntaxHighlightedFragment value={fragment} language={syntaxHighlightingLanguage} />
|
||||||
|
) : (
|
||||||
|
<HighlightedFragment value={fragment} />
|
||||||
|
)}
|
||||||
|
{i + 1 >= field.fragments.length ? separator : null}
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
hit: Hit;
|
hit: Hit;
|
||||||
field: string;
|
field: string;
|
||||||
truncateValueAt?: number;
|
truncateValueAt?: number;
|
||||||
|
syntaxHighlightingLanguage?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type HighlightedTextFieldProps = {
|
const TextHitField: FC<Props> = ({
|
||||||
field: HighlightedHitField;
|
hit,
|
||||||
};
|
field: fieldName,
|
||||||
|
children,
|
||||||
const HighlightedTextField: FC<HighlightedTextFieldProps> = ({ field }) => (
|
syntaxHighlightingLanguage,
|
||||||
<>
|
truncateValueAt = 0
|
||||||
{field.fragments.map((fr, i) => (
|
}) => {
|
||||||
<React.Fragment key={fr}>
|
|
||||||
{" ... "}
|
|
||||||
<HighlightedFragment value={fr} />
|
|
||||||
{i + 1 >= field.fragments.length ? " ... " : null}
|
|
||||||
</React.Fragment>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
|
|
||||||
const TextHitField: FC<Props> = ({ hit, field: fieldName, children, truncateValueAt = 0 }) => {
|
|
||||||
const field = hit.fields[fieldName];
|
const field = hit.fields[fieldName];
|
||||||
if (!field) {
|
if (!field) {
|
||||||
return <>{children}</>;
|
return <>{children}</>;
|
||||||
} else if (isHighlightedHitField(field)) {
|
} else if (isHighlightedHitField(field)) {
|
||||||
return <HighlightedTextField field={field} />;
|
return <HighlightedTextField field={field} syntaxHighlightingLanguage={syntaxHighlightingLanguage} />;
|
||||||
} else {
|
} else {
|
||||||
let value = field.value;
|
let value = field.value;
|
||||||
if (value === "") {
|
if (value === "") {
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ export type FileDiff = {
|
|||||||
oldRevision?: string;
|
oldRevision?: string;
|
||||||
type: FileChangeType;
|
type: FileChangeType;
|
||||||
language?: string;
|
language?: string;
|
||||||
|
syntaxModes?: { [mode: string]: string };
|
||||||
// TODO does this property exists?
|
// TODO does this property exists?
|
||||||
isBinary?: boolean;
|
isBinary?: boolean;
|
||||||
_links?: Links;
|
_links?: Links;
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ const SwitchableMarkdownViewer: FC<Props> = ({ file, basePath }) => {
|
|||||||
{renderMarkdown ? (
|
{renderMarkdown ? (
|
||||||
<MarkdownViewer content={content || ""} basePath={basePath} permalink={permalink} />
|
<MarkdownViewer content={content || ""} basePath={basePath} permalink={permalink} />
|
||||||
) : (
|
) : (
|
||||||
<SyntaxHighlighter language="MARKDOWN" value={content || ""} permalink={permalink} />
|
<SyntaxHighlighter language="markdown" value={content || ""} permalink={permalink} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import React, { FC } from "react";
|
|||||||
import { File, Link, Repository } from "@scm-manager/ui-types";
|
import { File, Link, Repository } from "@scm-manager/ui-types";
|
||||||
import { Annotate, ErrorNotification, Loading } from "@scm-manager/ui-components";
|
import { Annotate, ErrorNotification, Loading } from "@scm-manager/ui-components";
|
||||||
import { useAnnotations, useContentType } from "@scm-manager/ui-api";
|
import { useAnnotations, useContentType } from "@scm-manager/ui-api";
|
||||||
|
import { determineSyntaxHighlightingLanguage } from "../utils/files";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
file: File;
|
file: File;
|
||||||
@@ -52,7 +53,8 @@ const AnnotateView: FC<Props> = ({ file, repository, revision }) => {
|
|||||||
return <Loading />;
|
return <Loading />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <Annotate source={{ ...annotation, language: contentType.language }} repository={repository} />;
|
const language = determineSyntaxHighlightingLanguage(contentType);
|
||||||
|
return <Annotate source={{ ...annotation, language }} repository={repository} />;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default AnnotateView;
|
export default AnnotateView;
|
||||||
|
|||||||
@@ -31,7 +31,8 @@ import { File, Link, Repository } from "@scm-manager/ui-types";
|
|||||||
import { ErrorNotification, Loading, PdfViewer } from "@scm-manager/ui-components";
|
import { ErrorNotification, Loading, PdfViewer } from "@scm-manager/ui-components";
|
||||||
import SwitchableMarkdownViewer from "../components/content/SwitchableMarkdownViewer";
|
import SwitchableMarkdownViewer from "../components/content/SwitchableMarkdownViewer";
|
||||||
import styled from "styled-components";
|
import styled from "styled-components";
|
||||||
import { useContentType } from "@scm-manager/ui-api";
|
import { ContentType, useContentType } from "@scm-manager/ui-api";
|
||||||
|
import {determineSyntaxHighlightingLanguage} from "../utils/files";
|
||||||
|
|
||||||
const NoSpacingSyntaxHighlighterContainer = styled.div`
|
const NoSpacingSyntaxHighlighterContainer = styled.div`
|
||||||
& pre {
|
& pre {
|
||||||
@@ -46,6 +47,8 @@ type Props = {
|
|||||||
revision: string;
|
revision: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const SourcesView: FC<Props> = ({ file, repository, revision }) => {
|
const SourcesView: FC<Props> = ({ file, repository, revision }) => {
|
||||||
const { data: contentTypeData, error, isLoading } = useContentType((file._links.self as Link).href);
|
const { data: contentTypeData, error, isLoading } = useContentType((file._links.self as Link).href);
|
||||||
|
|
||||||
@@ -59,7 +62,8 @@ const SourcesView: FC<Props> = ({ file, repository, revision }) => {
|
|||||||
|
|
||||||
let sources;
|
let sources;
|
||||||
|
|
||||||
const { type: contentType, language } = contentTypeData;
|
const language = determineSyntaxHighlightingLanguage(contentTypeData);
|
||||||
|
const contentType = contentTypeData.type;
|
||||||
const basePath = `/repo/${repository.namespace}/${repository.name}/code/sources/${revision}/`;
|
const basePath = `/repo/${repository.namespace}/${repository.name}/code/sources/${revision}/`;
|
||||||
if (contentType.startsWith("image/")) {
|
if (contentType.startsWith("image/")) {
|
||||||
sources = <ImageViewer file={file} />;
|
sources = <ImageViewer file={file} />;
|
||||||
|
|||||||
@@ -22,6 +22,7 @@
|
|||||||
* SOFTWARE.
|
* SOFTWARE.
|
||||||
*/
|
*/
|
||||||
import { File } from "@scm-manager/ui-types";
|
import { File } from "@scm-manager/ui-types";
|
||||||
|
import { ContentType } from "@scm-manager/ui-api";
|
||||||
|
|
||||||
export const isRootPath = (path: string) => {
|
export const isRootPath = (path: string) => {
|
||||||
return path === "" || path === "/";
|
return path === "" || path === "/";
|
||||||
@@ -40,3 +41,7 @@ export const isEmptyDirectory = (file: File) => {
|
|||||||
}
|
}
|
||||||
return (file._embedded?.children?.length || 0) === 0;
|
return (file._embedded?.children?.length || 0) === 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const determineSyntaxHighlightingLanguage = (data: ContentType) => {
|
||||||
|
return data.prismMode || data.codemirrorMode || data.aceMode || data.language;
|
||||||
|
};
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ import java.io.IOException;
|
|||||||
import java.io.InputStream;
|
import java.io.InputStream;
|
||||||
import java.io.OutputStream;
|
import java.io.OutputStream;
|
||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
|
import java.util.Locale;
|
||||||
|
|
||||||
public class ContentResource {
|
public class ContentResource {
|
||||||
|
|
||||||
@@ -211,6 +212,11 @@ public class ContentResource {
|
|||||||
contentType.getLanguage().ifPresent(
|
contentType.getLanguage().ifPresent(
|
||||||
language -> responseBuilder.header(ProgrammingLanguages.HEADER, language)
|
language -> responseBuilder.header(ProgrammingLanguages.HEADER, language)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
contentType.getSyntaxModes().forEach((mode, lang) -> {
|
||||||
|
String modeName = mode.substring(0, 1).toUpperCase(Locale.ENGLISH) + mode.substring(1);
|
||||||
|
responseBuilder.header(ProgrammingLanguages.HEADER_SYNTAX_MODE_PREFIX + modeName, lang);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private byte[] getHead(String revision, String path, RepositoryService repositoryService) throws IOException {
|
private byte[] getHead(String revision, String path, RepositoryService repositoryService) throws IOException {
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import lombok.Data;
|
|||||||
import lombok.EqualsAndHashCode;
|
import lombok.EqualsAndHashCode;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
@Data
|
@Data
|
||||||
@EqualsAndHashCode(callSuper = false)
|
@EqualsAndHashCode(callSuper = false)
|
||||||
@@ -63,6 +64,7 @@ public class DiffResultDto extends HalRepresentation {
|
|||||||
private String oldMode;
|
private String oldMode;
|
||||||
private String type;
|
private String type;
|
||||||
private String language;
|
private String language;
|
||||||
|
private Map<String, String> syntaxModes;
|
||||||
private List<HunkDto> hunks;
|
private List<HunkDto> hunks;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ package sonia.scm.api.v2.resources;
|
|||||||
|
|
||||||
import com.google.inject.Inject;
|
import com.google.inject.Inject;
|
||||||
import de.otto.edison.hal.Links;
|
import de.otto.edison.hal.Links;
|
||||||
|
import sonia.scm.io.ContentType;
|
||||||
import sonia.scm.io.ContentTypeResolver;
|
import sonia.scm.io.ContentTypeResolver;
|
||||||
import sonia.scm.repository.Repository;
|
import sonia.scm.repository.Repository;
|
||||||
import sonia.scm.repository.api.DiffFile;
|
import sonia.scm.repository.api.DiffFile;
|
||||||
@@ -155,8 +156,10 @@ class DiffResultToDiffResultDtoMapper {
|
|||||||
dto.setOldPath(oldPath);
|
dto.setOldPath(oldPath);
|
||||||
dto.setOldRevision(file.getOldRevision());
|
dto.setOldRevision(file.getOldRevision());
|
||||||
|
|
||||||
Optional<String> language = contentTypeResolver.resolve(path).getLanguage();
|
ContentType contentType = contentTypeResolver.resolve(path);
|
||||||
|
Optional<String> language = contentType.getLanguage();
|
||||||
language.ifPresent(dto::setLanguage);
|
language.ifPresent(dto::setLanguage);
|
||||||
|
dto.setSyntaxModes(contentType.getSyntaxModes());
|
||||||
|
|
||||||
List<DiffResultDto.HunkDto> hunks = new ArrayList<>();
|
List<DiffResultDto.HunkDto> hunks = new ArrayList<>();
|
||||||
for (Hunk hunk : file) {
|
for (Hunk hunk : file) {
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ final class ProgrammingLanguages {
|
|||||||
|
|
||||||
static final String HEADER = "X-Programming-Language";
|
static final String HEADER = "X-Programming-Language";
|
||||||
|
|
||||||
|
static final String HEADER_SYNTAX_MODE_PREFIX = "X-Syntax-Mode-";
|
||||||
|
|
||||||
private ProgrammingLanguages() {
|
private ProgrammingLanguages() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,15 +24,18 @@
|
|||||||
|
|
||||||
package sonia.scm.io;
|
package sonia.scm.io;
|
||||||
|
|
||||||
|
import com.cloudogu.spotter.Language;
|
||||||
|
import com.google.common.collect.ImmutableMap;
|
||||||
|
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.Map;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
public class DefaultContentType implements ContentType {
|
public class DefaultContentType implements ContentType {
|
||||||
|
|
||||||
private static final String DEFAULT_LANG_MODE = "text";
|
private final com.cloudogu.spotter.ContentType contentType;
|
||||||
|
|
||||||
private final com.github.sdorra.spotter.ContentType contentType;
|
DefaultContentType(com.cloudogu.spotter.ContentType contentType) {
|
||||||
|
|
||||||
DefaultContentType(com.github.sdorra.spotter.ContentType contentType) {
|
|
||||||
this.contentType = contentType;
|
this.contentType = contentType;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,9 +61,23 @@ public class DefaultContentType implements ContentType {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Optional<String> getLanguage() {
|
public Optional<String> getLanguage() {
|
||||||
return contentType.getLanguage().map(language -> {
|
return contentType.getLanguage().map(Language::getName);
|
||||||
Optional<String> aceMode = language.getAceMode();
|
}
|
||||||
return aceMode.orElseGet(() -> language.getCodemirrorMode().orElse(DEFAULT_LANG_MODE));
|
|
||||||
});
|
@Override
|
||||||
|
public Map<String, String> getSyntaxModes() {
|
||||||
|
Optional<Language> language = contentType.getLanguage();
|
||||||
|
if (language.isPresent()) {
|
||||||
|
return syntaxMode(language.get());
|
||||||
|
}
|
||||||
|
return Collections.emptyMap();
|
||||||
|
}
|
||||||
|
|
||||||
|
static Map<String, String> syntaxMode(Language language) {
|
||||||
|
ImmutableMap.Builder<String, String> builder = ImmutableMap.builder();
|
||||||
|
language.getAceMode().ifPresent(mode -> builder.put("ace", mode));
|
||||||
|
language.getCodemirrorMode().ifPresent(mode -> builder.put("codemirror", mode));
|
||||||
|
language.getPrismMode().ifPresent(mode -> builder.put("prism", mode));
|
||||||
|
return builder.build();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,17 +24,32 @@
|
|||||||
|
|
||||||
package sonia.scm.io;
|
package sonia.scm.io;
|
||||||
|
|
||||||
import com.github.sdorra.spotter.ContentTypeDetector;
|
import com.cloudogu.spotter.ContentTypeDetector;
|
||||||
import com.github.sdorra.spotter.Language;
|
import com.cloudogu.spotter.Language;
|
||||||
|
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
public final class DefaultContentTypeResolver implements ContentTypeResolver {
|
public final class DefaultContentTypeResolver implements ContentTypeResolver {
|
||||||
|
|
||||||
|
private static final Language[] BOOST = new Language[]{
|
||||||
|
// GCC Machine Description uses .md as extension, but markdown is much more likely
|
||||||
|
Language.MARKDOWN,
|
||||||
|
// XML uses .rs as extension, but rust is much more likely
|
||||||
|
Language.RUST,
|
||||||
|
// XML is also returned by content type boost strategy, but rust is really much more likely
|
||||||
|
Language.RUST,
|
||||||
|
};
|
||||||
|
|
||||||
private static final ContentTypeDetector PATH_BASED = ContentTypeDetector.builder()
|
private static final ContentTypeDetector PATH_BASED = ContentTypeDetector.builder()
|
||||||
.defaultPathBased().boost(Language.MARKDOWN)
|
.defaultPathBased()
|
||||||
|
.boost(BOOST)
|
||||||
.bestEffortMatch();
|
.bestEffortMatch();
|
||||||
|
|
||||||
private static final ContentTypeDetector PATH_AND_CONTENT_BASED = ContentTypeDetector.builder()
|
private static final ContentTypeDetector PATH_AND_CONTENT_BASED = ContentTypeDetector.builder()
|
||||||
.defaultPathAndContentBased().boost(Language.MARKDOWN)
|
.defaultPathAndContentBased()
|
||||||
|
.boost(BOOST)
|
||||||
.bestEffortMatch();
|
.bestEffortMatch();
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -46,4 +61,13 @@ public final class DefaultContentTypeResolver implements ContentTypeResolver {
|
|||||||
public DefaultContentType resolve(String path, byte[] contentPrefix) {
|
public DefaultContentType resolve(String path, byte[] contentPrefix) {
|
||||||
return new DefaultContentType(PATH_AND_CONTENT_BASED.detect(path, contentPrefix));
|
return new DefaultContentType(PATH_AND_CONTENT_BASED.detect(path, contentPrefix));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Map<String, String> findSyntaxModesByLanguage(String language) {
|
||||||
|
Optional<Language> byName = Language.getByName(language);
|
||||||
|
if (byName.isPresent()) {
|
||||||
|
return DefaultContentType.syntaxMode(byName.get());
|
||||||
|
}
|
||||||
|
return Collections.emptyMap();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -90,11 +90,21 @@ public final class LuceneHighlighter {
|
|||||||
int index = content.indexOf(raw);
|
int index = content.indexOf(raw);
|
||||||
|
|
||||||
int start = content.lastIndexOf('\n', index);
|
int start = content.lastIndexOf('\n', index);
|
||||||
if (start < 0) {
|
|
||||||
start = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
String snippet = content.substring(start, index) + fragment;
|
String snippet;
|
||||||
|
if (start == index) {
|
||||||
|
// fragment starts with a linebreak
|
||||||
|
snippet = fragment.substring(1);
|
||||||
|
} else {
|
||||||
|
if (start < 0) {
|
||||||
|
// no leading linebreak
|
||||||
|
start = 0;
|
||||||
|
} else if (start < content.length()) {
|
||||||
|
// skip linebreak
|
||||||
|
start++;
|
||||||
|
}
|
||||||
|
snippet = content.substring(start, index) + fragment;
|
||||||
|
}
|
||||||
|
|
||||||
int end = content.indexOf('\n', index + raw.length());
|
int end = content.indexOf('\n', index + raw.length());
|
||||||
if (end < 0) {
|
if (end < 0) {
|
||||||
|
|||||||
@@ -141,7 +141,7 @@ public class ContentResourceTest {
|
|||||||
Response response = contentResource.get(NAMESPACE, REPO_NAME, REV, "SomeGoCode.go", null, null);
|
Response response = contentResource.get(NAMESPACE, REPO_NAME, REV, "SomeGoCode.go", null, null);
|
||||||
assertEquals(200, response.getStatus());
|
assertEquals(200, response.getStatus());
|
||||||
|
|
||||||
assertEquals("golang", response.getHeaderString("X-Programming-Language"));
|
assertEquals("Go", response.getHeaderString("X-Programming-Language"));
|
||||||
assertEquals("text/x-go", response.getHeaderString("Content-Type"));
|
assertEquals("text/x-go", response.getHeaderString("Content-Type"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,10 +152,22 @@ public class ContentResourceTest {
|
|||||||
Response response = contentResource.get(NAMESPACE, REPO_NAME, REV, "Dockerfile", null, null);
|
Response response = contentResource.get(NAMESPACE, REPO_NAME, REV, "Dockerfile", null, null);
|
||||||
assertEquals(200, response.getStatus());
|
assertEquals(200, response.getStatus());
|
||||||
|
|
||||||
assertEquals("dockerfile", response.getHeaderString("X-Programming-Language"));
|
assertEquals("Dockerfile", response.getHeaderString("X-Programming-Language"));
|
||||||
assertEquals("text/plain", response.getHeaderString("Content-Type"));
|
assertEquals("text/plain", response.getHeaderString("Content-Type"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void shouldRecognizeSyntaxModes() throws Exception {
|
||||||
|
mockContentFromResource("SomeGoCode.go");
|
||||||
|
|
||||||
|
Response response = contentResource.get(NAMESPACE, REPO_NAME, REV, "SomeGoCode.go", null, null);
|
||||||
|
assertEquals(200, response.getStatus());
|
||||||
|
|
||||||
|
assertEquals("golang", response.getHeaderString("X-Syntax-Mode-Ace"));
|
||||||
|
assertEquals("go", response.getHeaderString("X-Syntax-Mode-Codemirror"));
|
||||||
|
assertEquals("go", response.getHeaderString("X-Syntax-Mode-Prism"));
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void shouldHandleRandomByteFile() throws Exception {
|
public void shouldHandleRandomByteFile() throws Exception {
|
||||||
mockContentFromResource("JustBytes");
|
mockContentFromResource("JustBytes");
|
||||||
@@ -190,6 +202,7 @@ public class ContentResourceTest {
|
|||||||
assertEquals("application/octet-stream", response.getHeaderString("Content-Type"));
|
assertEquals("application/octet-stream", response.getHeaderString("Content-Type"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@SuppressWarnings("UnstableApiUsage")
|
||||||
private void mockContentFromResource(String fileName) throws Exception {
|
private void mockContentFromResource(String fileName) throws Exception {
|
||||||
URL url = Resources.getResource(fileName);
|
URL url = Resources.getResource(fileName);
|
||||||
mockContent(fileName, Resources.toByteArray(url));
|
mockContent(fileName, Resources.toByteArray(url));
|
||||||
|
|||||||
@@ -60,11 +60,16 @@ class DiffResultToDiffResultDtoMapperTest {
|
|||||||
DiffResultDto dto = mapper.mapForRevision(REPOSITORY, createResult(), "123");
|
DiffResultDto dto = mapper.mapForRevision(REPOSITORY, createResult(), "123");
|
||||||
|
|
||||||
List<DiffResultDto.FileDto> files = dto.getFiles();
|
List<DiffResultDto.FileDto> files = dto.getFiles();
|
||||||
assertAddedFile(files.get(0), "A.java", "abc", "java");
|
assertAddedFile(files.get(0), "A.java", "abc", "Java");
|
||||||
assertModifiedFile(files.get(1), "B.ts", "abc", "def", "typescript");
|
assertModifiedFile(files.get(1), "B.ts", "abc", "def", "TypeScript");
|
||||||
assertDeletedFile(files.get(2), "C.go", "ghi", "golang");
|
DiffResultDto.FileDto cGo = files.get(2);
|
||||||
assertRenamedFile(files.get(3), "typo.ts", "okay.ts", "def", "fixed", "typescript");
|
assertDeletedFile(cGo, "C.go", "ghi", "Go");
|
||||||
assertCopiedFile(files.get(4), "good.ts", "better.ts", "def", "fixed", "typescript");
|
assertThat(cGo.getSyntaxModes())
|
||||||
|
.containsEntry("ace", "golang")
|
||||||
|
.containsEntry("codemirror", "go")
|
||||||
|
.containsEntry("prism", "go");
|
||||||
|
assertRenamedFile(files.get(3), "typo.ts", "okay.ts", "def", "fixed", "TypeScript");
|
||||||
|
assertCopiedFile(files.get(4), "good.ts", "better.ts", "def", "fixed", "TypeScript");
|
||||||
|
|
||||||
DiffResultDto.HunkDto hunk = files.get(1).getHunks().get(0);
|
DiffResultDto.HunkDto hunk = files.get(1).getHunks().get(0);
|
||||||
assertHunk(hunk, "@@ -3,4 1,2 @@", 1, 2, 3, 4);
|
assertHunk(hunk, "@@ -3,4 1,2 @@", 1, 2, 3, 4);
|
||||||
|
|||||||
@@ -24,15 +24,15 @@
|
|||||||
|
|
||||||
package sonia.scm.io;
|
package sonia.scm.io;
|
||||||
|
|
||||||
import org.assertj.core.api.Assertions;
|
|
||||||
import org.junit.jupiter.api.Nested;
|
import org.junit.jupiter.api.Nested;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.junit.jupiter.params.ParameterizedTest;
|
import org.junit.jupiter.params.ParameterizedTest;
|
||||||
import org.junit.jupiter.params.provider.ValueSource;
|
import org.junit.jupiter.params.provider.ValueSource;
|
||||||
|
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
import static org.assertj.core.api.AssertionsForClassTypes.assertThat;
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
class DefaultContentTypeResolverTest {
|
class DefaultContentTypeResolverTest {
|
||||||
|
|
||||||
@@ -84,42 +84,45 @@ class DefaultContentTypeResolverTest {
|
|||||||
"% Which does not start with markdown"
|
"% Which does not start with markdown"
|
||||||
);
|
);
|
||||||
ContentType contentType = contentTypeResolver.resolve("somedoc.md", content.getBytes(StandardCharsets.UTF_8));
|
ContentType contentType = contentTypeResolver.resolve("somedoc.md", content.getBytes(StandardCharsets.UTF_8));
|
||||||
Assertions.assertThat(contentType.getLanguage()).contains("markdown");
|
assertThat(contentType.getLanguage()).contains("Markdown");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void shouldResolveMarkdownWithoutContent() {
|
void shouldResolveMarkdownWithoutContent() {
|
||||||
ContentType contentType = contentTypeResolver.resolve("somedoc.md");
|
ContentType contentType = contentTypeResolver.resolve("somedoc.md");
|
||||||
Assertions.assertThat(contentType.getLanguage()).contains("markdown");
|
assertThat(contentType.getLanguage()).contains("Markdown");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void shouldResolveMarkdownEvenWithDotsInFilename() {
|
void shouldResolveMarkdownEvenWithDotsInFilename() {
|
||||||
ContentType contentType = contentTypeResolver.resolve("somedoc.1.1.md");
|
ContentType contentType = contentTypeResolver.resolve("somedoc.1.1.md");
|
||||||
Assertions.assertThat(contentType.getLanguage()).contains("markdown");
|
assertThat(contentType.getLanguage()).contains("Markdown");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void shouldResolveDockerfile() {
|
void shouldResolveDockerfile() {
|
||||||
ContentType contentType = contentTypeResolver.resolve("Dockerfile");
|
ContentType contentType = contentTypeResolver.resolve("Dockerfile");
|
||||||
Assertions.assertThat(contentType.getLanguage()).contains("dockerfile");
|
assertThat(contentType.getLanguage()).contains("Dockerfile");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nested
|
||||||
|
class GetSyntaxModesTests {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void shouldReturnAceModeIfPresent() {
|
void shouldReturnEmptyMapOfModesWithoutLanguage() {
|
||||||
assertThat(contentTypeResolver.resolve("app.go").getLanguage()).contains("golang"); // codemirror is just go
|
Map<String, String> syntaxModes = contentTypeResolver.resolve("app.exe").getSyntaxModes();
|
||||||
assertThat(contentTypeResolver.resolve("App.java").getLanguage()).contains("java"); // codemirror is clike
|
assertThat(syntaxModes).isEmpty();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void shouldReturnCodemirrorIfAceModeIsMissing() {
|
void shouldReturnMapOfModes() {
|
||||||
assertThat(contentTypeResolver.resolve("index.ecr").getLanguage()).contains("htmlmixed");
|
Map<String, String> syntaxModes = contentTypeResolver.resolve("app.rs").getSyntaxModes();
|
||||||
}
|
assertThat(syntaxModes)
|
||||||
|
.containsEntry("ace", "rust")
|
||||||
@Test
|
.containsEntry("codemirror", "rust")
|
||||||
void shouldReturnTextIfNoModeIsPresent() {
|
.containsEntry("prism", "rust");
|
||||||
assertThat(contentTypeResolver.resolve("index.hxml").getLanguage()).contains("text");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,8 +47,6 @@ import static org.mockito.Mockito.when;
|
|||||||
@ExtendWith(MockitoExtension.class)
|
@ExtendWith(MockitoExtension.class)
|
||||||
class LuceneHighlighterTest {
|
class LuceneHighlighterTest {
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void shouldHighlightText() throws InvalidTokenOffsetsException, IOException {
|
void shouldHighlightText() throws InvalidTokenOffsetsException, IOException {
|
||||||
StandardAnalyzer analyzer = new StandardAnalyzer();
|
StandardAnalyzer analyzer = new StandardAnalyzer();
|
||||||
@@ -80,6 +78,15 @@ class LuceneHighlighterTest {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldNotStartHighlightedFragmentWithLineBreak() throws IOException, InvalidTokenOffsetsException {
|
||||||
|
String[] snippets = highlightCode("GameOfLife.java", "die");
|
||||||
|
|
||||||
|
assertThat(snippets).hasSize(1).allSatisfy(
|
||||||
|
snippet -> assertThat(snippet).doesNotStartWith("\n")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void shouldHighlightCodeInTsx() throws IOException, InvalidTokenOffsetsException {
|
void shouldHighlightCodeInTsx() throws IOException, InvalidTokenOffsetsException {
|
||||||
String[] snippets = highlightCode("Button.tsx", "inherit");
|
String[] snippets = highlightCode("Button.tsx", "inherit");
|
||||||
|
|||||||
Reference in New Issue
Block a user