.ts
TypeScript
(application/typescript)
// 1st-party
import { ReactIsland } from "@ethicdevs/react-monolith";
// 3rd-party
import React, { useCallback, useEffect, useMemo, useState } from "react";
import Prism from "prismjs";
import styled, { css } from "styled-components";
// import { fetch } from "cross-fetch";
// app
import type { AppThemeScheme, WithThemeSchemeProp } from "../types";
import { NamedColors } from "../utils/style";
import { ClientSideRouterEvents } from "./InstantRouterIndicator";
import { escapeHtmlCode } from "../utils/shared/escapeHtmlCode";

interface CodeProps {
  code: string;
  language: string;
  [x: string]: unknown;
}

function getLineStartsAt(maybeGitdiff: string): {
  before: number;
  after: number;
} {
  if (maybeGitdiff == null) {
    return {
      before: 0,
      after: 0,
    };
  }
  const gitdiffLinesRegExp = /@@ -([\d]+),[\d]+ \+[\d]+,([\d]+) @@/i;
  const matches = gitdiffLinesRegExp.exec(maybeGitdiff);
  if (matches == null || Array.isArray(matches) === false) {
    return {
      before: 0,
      after: 0,
    };
  }
  const [_, beforeLineNumber, afterLineNumber] = matches;
  return {
    before: parseInt(beforeLineNumber, 10) - 1,
    after: parseInt(afterLineNumber, 10) - 1,
  };
}

const Code: ReactIsland<CodeProps & WithThemeSchemeProp> = ({
  code,
  language,
  themeScheme,
  ...props
}) => {
  const codeBlockLineStartsAt = useMemo(() => getLineStartsAt(code), [code]);
  const lineStartAt = codeBlockLineStartsAt.before;

  const computeSyntaxHighlightingSSR = useCallback(() => {
    let innerHtml = {
      __html: escapeHtmlCode(code),
    };

    if (
      "languages" in Prism &&
      Prism.languages != null &&
      typeof Prism.languages === "object" &&
      language in Prism.languages &&
      Prism.languages[language] != null
    ) {
      innerHtml.__html = Prism.highlight(
        code,
        Prism.languages[language],
        language
      );
    }

    return innerHtml;
  }, [code, language]);

  const appendLineNumbers = useCallback(
    (inHtml: {
      __html: string;
    }): {
      __html: string;
    } => {
      const copyInnerHtml = { __html: inHtml.__html };
      const linesCount = copyInnerHtml.__html.split("\n").length;
      copyInnerHtml.__html += `\n<span aria-hidden="true" class="line-numbers-rows">`;
      for (let i = linesCount; i > 0; i--) {
        const lineNumber = lineStartAt + (linesCount - i + 1);
        copyInnerHtml.__html += `<a id="l-${lineNumber}" href="#l-${lineNumber}" data-line-number="${lineNumber}"></a>`;
      }
      copyInnerHtml.__html += `</span>`;
      return copyInnerHtml;
    },
    [lineStartAt]
  );

  const [innerHtml, setInnerHtml] = useState<{ __html: string }>(
    appendLineNumbers(computeSyntaxHighlightingSSR())
  );

  const getHighlightedCodeInnerHtml = useCallback(async (): Promise<{
    __html: string;
  }> => {
    let inHtml;
    if (typeof window === "undefined") {
      inHtml = computeSyntaxHighlightingSSR();
    } else {
      const { protocol, hostname, port } = new URL(window.location.href);
      const res = await fetch(
        `${protocol}//${hostname}:${port}/api/syntax/highlight/html`,
        {
          method: "POST",
          headers: {
            Accept: "application/json",
            "Content-Type": "application/json",
          },
          body: JSON.stringify({
            code,
            language,
            theme_scheme: themeScheme,
          }),
        }
      );
      const jsonRes = await res.json();
      inHtml = {
        __html: jsonRes.html,
      };
    }
    inHtml = appendLineNumbers(inHtml);
    return inHtml;
  }, [
    code,
    language,
    themeScheme,
    appendLineNumbers,
    computeSyntaxHighlightingSSR,
  ]);

  const getHighlightedCodeAsync = useCallback(async () => {
    try {
      const innerHtmlGenerated = await getHighlightedCodeInnerHtml();
      setInnerHtml(innerHtmlGenerated);
    } catch (err) {
      // console.error((err as Error).message);
    }
  }, [getHighlightedCodeInnerHtml, setInnerHtml]);

  const onClientSideRouterLoadComplete = useCallback(() => {
    getHighlightedCodeAsync();
  }, [getHighlightedCodeAsync]);

  useEffect(() => {
    onClientSideRouterLoadComplete();
    document.addEventListener(
      ClientSideRouterEvents.NAVIGATED,
      onClientSideRouterLoadComplete
    );
    return () => {
      document.removeEventListener(
        ClientSideRouterEvents.NAVIGATED,
        onClientSideRouterLoadComplete
      );
    };
  }, []);

  // the extra space before language is important so SSR matches CSR
  return (
    <div className="line-numbers" style={{ width: "100%" }}>
      <StylePreTag
        data-language={language}
        data-start={lineStartAt}
        className={` language-${language} line-numbers`}
        themeScheme={themeScheme}
        style={{ counterReset: "linenumber 0" }}
      >
        <StyledCodeTag {...props} dangerouslySetInnerHTML={innerHtml} />
      </StylePreTag>
    </div>
  );
};

export const getThemedCodeCss = (themeScheme: AppThemeScheme): JSX.Element => (
  <>
    {themeScheme === "light" ? (
      <style
        dangerouslySetInnerHTML={{
          __html: `code[class*=language-],pre[class*=language-]{color:#000;background:0 0;text-align:left;white-space:pre;word-spacing:normal;word-break:normal;word-wrap:normal;padding-left:3.5em;line-height:1.5;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-hyphens:none;-moz-hyphens:none;-ms-hyphens:none;hyphens:none}code[class*=language-] ::-moz-selection,code[class*=language-]::-moz-selection,pre[class*=language-] ::-moz-selection,pre[class*=language-]::-moz-selection{text-shadow:none;background:#b3d4fc}code[class*=language-] ::selection,code[class*=language-]::selection,pre[class*=language-] ::selection,pre[class*=language-]::selection{text-shadow:none;background:#b3d4fc}@media print{code[class*=language-],pre[class*=language-]{text-shadow:none}}pre[class*=language-]{padding:1em;margin:.5em 0;overflow-y:hidden;overflow-x:auto}:not(pre)>code[class*=language-],pre[class*=language-]{background:#f5f2f0}:not(pre)>code[class*=language-]{padding:.1em;border-radius:.3em;white-space:normal}.token.cdata,.token.comment,.token.doctype,.token.prolog{color:#708090}.token.punctuation{color:#999}.token.namespace{opacity:.7}.token.boolean,.token.constant,.token.deleted,.token.number,.token.property,.token.symbol,.token.tag{color:#905}.token.attr-name,.token.builtin,.token.char,.token.inserted,.token.selector,.token.string{color:#690}.language-css .token.string,.style .token.string,.token.entity,.token.operator,.token.url{color:#9a6e3a;background:hsla(0,0%,100%,.5)}.token.atrule,.token.attr-value,.token.keyword{color:#07a}.token.class-name,.token.function{color:#dd4a68}.token.important,.token.regex,.token.variable{color:#e90}.token.bold,.token.important{font-weight:700}.token.italic{font-style:italic}.token.entity{cursor:help}`,
        }}
      />
    ) : (
      <style
        dangerouslySetInnerHTML={{
          __html: `code[class*=language-],pre[class*=language-]{color:#fff;background:0 0;text-align:left;white-space:pre;word-spacing:normal;word-break:normal;word-wrap:normal;padding-left:3.5em;line-height:1.5;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-hyphens:none;-moz-hyphens:none;-ms-hyphens:none;hyphens:none}@media print{code[class*=language-],pre[class*=language-]{text-shadow:none}}:not(pre)>code[class*=language-],pre[class*=language-]{background:#4c3f33}pre[class*=language-]{padding:1em;margin:.5em 0;overflow-y:hidden;overflow-x:auto;border:.3em solid #7a6651;border-radius:.5em;box-shadow:1px 1px .5em #000 inset}:not(pre)>code[class*=language-]{padding:.15em .2em .05em;border-radius:.3em;border:.13em solid #7a6651;box-shadow:1px 1px .3em -.1em #000 inset;white-space:normal}.token.cdata,.token.comment,.token.doctype,.token.prolog{color:#997f66}.token.punctuation{opacity:.7}.token.namespace{opacity:.7}.token.boolean,.token.constant,.token.number,.token.property,.token.symbol,.token.tag{color:#d1939e}.token.attr-name,.token.builtin,.token.char,.token.inserted,.token.selector,.token.string{color:#bce051}.language-css .token.string,.style .token.string,.token.entity,.token.operator,.token.url,.token.variable{color:#f4b73d}.token.atrule,.token.attr-value,.token.keyword{color:#d1939e}.token.important,.token.regex{color:#e90}.token.bold,.token.important{font-weight:700}.token.italic{font-style:italic}.token.entity{cursor:help}.token.deleted{color:red}`,
        }}
      />
    )}
    <style
      dangerouslySetInnerHTML={{
        __html: `pre.line-numbers {
  display: flex;
  flex-flow: row nowrap;
  position: relative;
	padding-left: 3.8em;
	counter-reset: linenumber;
}

pre.line-numbers > code {
	position: relative;
  display: block;
	padding-left: 3.5em;
  padding-bottom: 0;
  white-space: inherit;
}

.line-numbers .line-numbers-rows {
	position: absolute;
	top: 0;
  bottom: 0;
	left: 0;
	font-size: 100%;
	width: 3em; /* works for line-numbers below 1000 lines */
	letter-spacing: -1px;
	border-right: 1px solid ${NamedColors.BORDER_DEFAULT[themeScheme]};

	-webkit-user-select: none;
	-moz-user-select: none;
	-ms-user-select: none;
	user-select: none;
}

.line-numbers-rows > a {
  display: block;
  counter-increment: linenumber;
  font-weight: normal;
}

.line-numbers-rows > a:before {
  content: attr(data-line-number);
  color: ${NamedColors.TEXT_MUTED[themeScheme]};
  display: block;
  padding-right: 0.4em;
  text-align: right;
}
`,
      }}
    />
  </>
);

const StylePreTag = styled.pre<WithThemeSchemeProp>`
  width: 100%;
  margin: 0 !important;
  padding: 0 !important;

  box-shadow: none !important;
  text-shadow: none !important;

  border-radius: 8px;

  ${({ themeScheme }) => css`
    background-color: ${NamedColors.CARD[themeScheme]} !important;
    border: 1px solid ${NamedColors.BORDER_DEFAULT[themeScheme]} !important;
  `};
`;

export const StyledCodeTag = styled.code`
  display: block;

  min-height: 20px;
  width: 100%;
  padding: 4px 8px;

  font-size: 16px;
  border-radius: 4px !important;
`;

Code.displayName = "Code";
export default Code;