/*
* 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, { ComponentType, FC, ReactChild, ReactNode, useMemo } from "react";
import useSyntaxHighlighting from "./useSyntaxHighlighting";
import type { RefractorElement } from "refractor";
import type { Element } from "hast";
import type { MarkerBounds, RefractorNode } from "./types";
import { SplitAndReplace } from "@scm-manager/ui-text";
type LineWrapperType = ComponentType<{ lineNumber: number }>;
type MarkerConfig = MarkerBounds & {
wrapper: ComponentType;
};
type RendererType = ComponentType<{ children: ReactChild[] }>;
type Replacement = {
textToReplace: string;
replacement: ReactNode;
replaceAll: boolean;
};
const DEFAULT_RENDERER = React.Fragment;
const DEFAULT_LINE_WRAPPER: LineWrapperType = ({ children }) => <>{children}>;
export type Props = {
value: string;
language?: string;
lineWrapper?: LineWrapperType;
markerConfig?: MarkerConfig;
nodeLimit?: number;
renderer?: RendererType;
as?: ComponentType;
};
function mapWithDepth(depth: number, lineWrapper?: LineWrapperType, markerReplacement?: ComponentType) {
return function mapChildrenWithDepth(child: RefractorNode, i: number) {
return mapChild(child, i, depth, lineWrapper, markerReplacement);
};
}
const isRefractorElement = (node: RefractorNode): node is RefractorElement => "tagName" in node;
function mapChild(
childNode: RefractorNode,
i: number,
depth: number,
LineWrapper?: LineWrapperType,
MarkerReplacement?: ComponentType
): ReactChild {
if (isRefractorElement(childNode)) {
const child = childNode as Element;
const className =
child.properties &&
(Array.isArray(child.properties.className) ? child.properties.className.join(" ") : child.properties.className);
if (child.properties) {
if (LineWrapper) {
const line = child.properties["data-line-number"];
if (line) {
return (
{childNode.children && childNode.children.map(mapWithDepth(depth + 1, LineWrapper, MarkerReplacement))}
);
}
}
if (MarkerReplacement) {
const isMarked = child.properties["data-marked"];
if (isMarked) {
return (
{childNode.children && childNode.children.map(mapWithDepth(depth + 1, LineWrapper, MarkerReplacement))}
);
}
}
}
return React.createElement(
child.tagName,
Object.assign({ key: `fract-${depth}-${i}` }, child.properties, { className }),
childNode.children && childNode.children.map(mapWithDepth(depth + 1, LineWrapper, MarkerReplacement))
);
}
return {childNode.value};
}
function escapeRegExp(str: string) {
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); // $& means the whole matched string
}
const stripAndReplace = (value: string, { start: preTag, end: postTag, wrapper: Wrapper }: MarkerConfig) => {
const strippedValue = value
.replace(new RegExp(escapeRegExp(preTag), "g"), "")
.replace(new RegExp(escapeRegExp(postTag), "g"), "");
let content = value;
const result: string[] = [];
while (content.length > 0) {
const start = content.indexOf(preTag);
const end = content.indexOf(postTag);
if (start >= 0 && end > 0) {
const item = content.substring(start + preTag.length, end);
if (!result.includes(item)) {
result.push(item);
}
content = content.substring(end + postTag.length);
} else {
break;
}
}
result.sort((a, b) => b.length - a.length);
return {
strippedValue,
replacements: result.map((textToReplace) => ({
textToReplace,
replacement: {textToReplace},
replaceAll: true,
})),
};
};
const markContent = (value: string, markerConfig?: MarkerConfig) =>
markerConfig === undefined ? { strippedValue: value, replacements: [] } : stripAndReplace(value, markerConfig);
const createFallbackContent = (
value: string,
lineWrapper?: LineWrapperType,
renderer?: RendererType,
replacements: Replacement[] = []
): ReactNode => {
if (lineWrapper || renderer) {
const Renderer = renderer ?? DEFAULT_RENDERER;
const LineWrapper = lineWrapper ?? DEFAULT_LINE_WRAPPER;
return (
{value.split("\n").map((line, i) => (
{replacements.length ? (
s} />
) : (
line
)}
))}
);
}
return s} />;
};
const useMarkedContent = (value: string, markerConfig?: MarkerConfig) => {
return useMemo(() => {
const markedContent = markContent(value, markerConfig);
return {
...markedContent,
markedTexts: markedContent.replacements.length
? markedContent.replacements.map((replacement) => replacement.textToReplace)
: undefined,
};
}, [value, markerConfig]);
};
const useFallbackContent = (
strippedValue: string,
replacements: Replacement[],
lineWrapper?: LineWrapperType,
renderer?: RendererType
) => {
return useMemo(
() => createFallbackContent(strippedValue, lineWrapper, renderer, replacements),
[strippedValue, lineWrapper, renderer, replacements]
);
};
type RootWrapperProps = {
language: string;
as?: ComponentType;
};
const RootWrapper: FC = ({ language, children, as: AsComponent }) => {
if (!AsComponent) {
return (
{children}
);
}
return {children};
};
const SyntaxHighlighter = ({
value,
lineWrapper,
language = "text",
markerConfig,
nodeLimit = 10000,
renderer,
as,
}: Props) => {
const { strippedValue, replacements, markedTexts } = useMarkedContent(value, markerConfig);
const { isLoading, tree, error } = useSyntaxHighlighting({
value: strippedValue,
language,
nodeLimit,
groupByLine: !!lineWrapper || !!renderer,
markedTexts: markedTexts,
});
const fallbackContent = useFallbackContent(strippedValue, replacements, lineWrapper, renderer);
const Renderer = renderer ?? DEFAULT_RENDERER;
// we do not expose the error for now, because we have no idea how to display it
if (isLoading || !tree || error) {
return (
{fallbackContent}
);
}
return (
{tree?.map(mapWithDepth(0, lineWrapper, markerConfig?.wrapper))}
);
};
export default SyntaxHighlighter;