mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-10-12 07:58:49 +00:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 13cddb1a22 | |||
| fe471dcbca |
@@ -10,18 +10,19 @@ interface EmojiData {
|
||||
}
|
||||
|
||||
const emojis = emojisRaws as Record<string, EmojiData>;
|
||||
const output: Record<string, string> = {};
|
||||
const sequences: string[] = [];
|
||||
|
||||
Object.entries(emojis).forEach(([key, value]) => {
|
||||
const emoji = value.code_points?.fully_qualified;
|
||||
if (emoji && key !== emoji) {
|
||||
output[key] = emoji;
|
||||
} else if (!emoji) {
|
||||
// The base form is always the fully-qualified one minus its `fe0f`/`200d` joiners, so only
|
||||
// the qualified sequence is shipped and the lookup key is derived at runtime.
|
||||
sequences.push(emoji);
|
||||
}
|
||||
});
|
||||
|
||||
fs.mkdirSync(path.resolve(__dirname, 'dist'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.resolve(__dirname, 'dist/index.ts'),
|
||||
`export const emojiCodepoints: Record<string, string> = ${JSON.stringify(output, null, 4)};`
|
||||
`export const emojiSequences = ${JSON.stringify(sequences.join('\n'))};\n`
|
||||
);
|
||||
|
||||
@@ -8,7 +8,8 @@ import type { GitBookAssistant } from '@gitbook/browser-types';
|
||||
import { Icon, type IconName } from '@gitbook/icons';
|
||||
|
||||
import { useAIChatController, useAIChatState } from '.';
|
||||
import { AIChatIcon, AISearchIcon, getAIChatName } from '../AIChat';
|
||||
import { AIChatIcon, AISearchIcon } from '../AIChat/AIChatIcon';
|
||||
import { getAIChatName } from '../AIChat/name';
|
||||
import { useIntegrationAssistants } from '../Integrations';
|
||||
import { useSetSearchState } from '../Search/useSearch';
|
||||
import { isAIChatEnabled, isAISearchEnabled } from '@/components/utils/isAIChatEnabled';
|
||||
|
||||
@@ -37,8 +37,8 @@ import { AIChatMessages } from './AIChatMessages';
|
||||
import { AIChatQueuedMessage } from './AIChatQueuedMessage';
|
||||
import { AIChatResizeHandle } from './AIChatResizeHandle';
|
||||
import AIChatSuggestedQuestions from './AIChatSuggestedQuestions';
|
||||
import { getAIChatName } from './name';
|
||||
import { t, tString, useLanguage } from '@/intl/client';
|
||||
import type { TranslationLanguage } from '@/intl/translations';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
|
||||
export function AIChat() {
|
||||
@@ -337,9 +337,3 @@ function AIChatError(props: { chatController: AIChatController }) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function getAIChatName(language: TranslationLanguage, trademark: boolean) {
|
||||
return trademark
|
||||
? tString(language, 'ai_chat_assistant_name')
|
||||
: tString(language, 'ai_chat_assistant_name_unbranded');
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export * from './AIChat';
|
||||
export * from './name';
|
||||
export * from './AIChatButton';
|
||||
export * from './AIChatIcon';
|
||||
export * from './AIResponseFeedback';
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { tString } from '@/intl/client';
|
||||
import type { TranslationLanguage } from '@/intl/translations';
|
||||
|
||||
export function getAIChatName(language: TranslationLanguage, trademark: boolean) {
|
||||
return trademark
|
||||
? tString(language, 'ai_chat_assistant_name')
|
||||
: tString(language, 'ai_chat_assistant_name_unbranded');
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { getCodeTextFromId } from './utils';
|
||||
import { useAIChatController, useAIConfig } from '@/components/AI';
|
||||
import { AIChatIcon } from '@/components/AIChat';
|
||||
import { AIChatIcon } from '@/components/AIChat/AIChatIcon';
|
||||
import { Button } from '@/components/primitives';
|
||||
import { isAIChatEnabled } from '@/components/utils/isAIChatEnabled';
|
||||
import { t, useLanguage } from '@/intl/client';
|
||||
|
||||
@@ -6,11 +6,10 @@ import { useDebounceCallback } from 'usehooks-ts';
|
||||
import type { CustomizationThemedCodeTheme, DocumentBlockCode } from '@gitbook/api';
|
||||
|
||||
import type { BlockProps } from '../Block';
|
||||
import { type InlineExpressionVariables, useEvaluateInlineExpression } from '../InlineExpression';
|
||||
import type { InlineExpressionVariables } from '../InlineExpression/types';
|
||||
import { CodeBlockRenderer } from './CodeBlockRenderer';
|
||||
import type { HighlightTheme, RenderedInline } from './highlight-tokens';
|
||||
import { plainHighlight } from './plain-highlight';
|
||||
import { useAdaptiveVisitor } from '@/components/Adaptive';
|
||||
import { useInViewportListener } from '@/components/hooks/useInViewportListener';
|
||||
import { useScrollListener } from '@/components/hooks/useScrollListener';
|
||||
import { Button, ToggleChevron } from '@/components/primitives';
|
||||
@@ -20,6 +19,9 @@ import { type ClassValue, tcls } from '@/lib/tailwind';
|
||||
export type ClientBlockProps = Pick<BlockProps<DocumentBlockCode>, 'block' | 'style'> & {
|
||||
inlines: RenderedInline[];
|
||||
inlineExprVariables: InlineExpressionVariables;
|
||||
/** Only supplied for blocks that contain one: evaluating needs `@gitbook/expr`, which is a
|
||||
* quarter of a megabyte of JS parser we keep out of the bundle otherwise. */
|
||||
evaluateInlineExpression?: (expression: string) => string;
|
||||
mode: BlockProps<DocumentBlockCode>['context']['mode'];
|
||||
themes?: CustomizationThemedCodeTheme;
|
||||
embedded?: boolean;
|
||||
@@ -32,17 +34,11 @@ export const CODE_BLOCK_DEFAULT_COLLAPSED_LINE_COUNT = 10;
|
||||
* It allows us to defer some load to avoid blocking the rendering of the whole page with block highlighting.
|
||||
*/
|
||||
export function ClientCodeBlock(props: ClientBlockProps) {
|
||||
const { block, mode, style, inlines, inlineExprVariables, themes, embedded } = props;
|
||||
const { block, mode, style, inlines, evaluateInlineExpression, themes, embedded } = props;
|
||||
const blockRef = useRef<HTMLDivElement>(null);
|
||||
const isInViewportRef = useRef(false);
|
||||
const [isInViewport, setIsInViewport] = useState(false);
|
||||
|
||||
const getAdaptiveVisitorClaims = useAdaptiveVisitor();
|
||||
const visitorClaims = getAdaptiveVisitorClaims();
|
||||
const evaluateInlineExpression = useEvaluateInlineExpression({
|
||||
visitorClaims,
|
||||
variables: inlineExprVariables,
|
||||
});
|
||||
const plainTheme = useMemo(
|
||||
() => plainHighlight(block, inlines, { evaluateInlineExpression, themes }),
|
||||
[block, inlines, evaluateInlineExpression, themes]
|
||||
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
'use client';
|
||||
|
||||
import { ClientCodeBlock, type ClientBlockProps } from './ClientCodeBlock';
|
||||
import { useAdaptiveVisitor } from '@/components/Adaptive';
|
||||
import { useEvaluateInlineExpression } from '@/components/DocumentView/InlineExpression/useEvaluateInlineExpression';
|
||||
|
||||
/**
|
||||
* Code block whose content contains inline expressions, so it needs the expression runtime.
|
||||
* Kept in its own module: importing it pulls `@gitbook/expr` and its JS parser.
|
||||
*/
|
||||
export function ClientCodeBlockWithExpressions(props: ClientBlockProps) {
|
||||
const getAdaptiveVisitorClaims = useAdaptiveVisitor();
|
||||
const evaluateInlineExpression = useEvaluateInlineExpression({
|
||||
visitorClaims: getAdaptiveVisitorClaims(),
|
||||
variables: props.inlineExprVariables,
|
||||
});
|
||||
|
||||
return <ClientCodeBlock {...props} evaluateInlineExpression={evaluateInlineExpression} />;
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
'use client';
|
||||
|
||||
import dynamic from 'next/dynamic';
|
||||
|
||||
import type { ClientBlockProps } from './ClientCodeBlock';
|
||||
|
||||
// `ssr: true` keeps the evaluated code in the server-rendered HTML; the point of the boundary is to
|
||||
// move `@gitbook/expr` (~254 KB with its parser) out of the route's eager entry.
|
||||
const ClientCodeBlockWithExpressions = dynamic(
|
||||
() =>
|
||||
import('./ClientCodeBlockWithExpressions').then(
|
||||
(mod) => mod.ClientCodeBlockWithExpressions
|
||||
),
|
||||
{ ssr: true }
|
||||
);
|
||||
|
||||
export function ClientCodeBlockWithExpressionsLazy(props: ClientBlockProps) {
|
||||
return <ClientCodeBlockWithExpressions {...props} />;
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
import type { BlockProps } from '../Block';
|
||||
import { Blocks } from '../Blocks';
|
||||
import { ClientCodeBlock } from './ClientCodeBlock';
|
||||
import { ClientCodeBlockWithExpressionsLazy } from './ClientCodeBlockWithExpressionsLazy';
|
||||
import { CodeBlockRenderer } from './CodeBlockRenderer';
|
||||
import { highlight } from './highlight';
|
||||
import { type RenderedInline, getInlines } from './highlight-tokens';
|
||||
@@ -121,8 +122,11 @@ export async function CodeBlock(
|
||||
{isMermaid ? (
|
||||
<MermaidCodeBlockLazy
|
||||
{...clientProps}
|
||||
hasInlineExpression={hasInlineExpression}
|
||||
mermaidRuntimeURL={getAssetURL(MERMAID_RUNTIME_PATH)}
|
||||
/>
|
||||
) : hasInlineExpression ? (
|
||||
<ClientCodeBlockWithExpressionsLazy {...clientProps} />
|
||||
) : (
|
||||
<ClientCodeBlock {...clientProps} />
|
||||
)}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useSta
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import { type ClientBlockProps, ClientCodeBlock } from './ClientCodeBlock';
|
||||
import { ClientCodeBlockWithExpressions } from './ClientCodeBlockWithExpressions';
|
||||
import { getPlainCodeBlock } from './highlight-tokens';
|
||||
import { MermaidPanZoomControls } from './MermaidPanZoomControls';
|
||||
import { useHasBeenInViewport } from '@/components/hooks/useHasBeenInViewport';
|
||||
@@ -20,6 +21,7 @@ import { tcls } from '@/lib/tailwind';
|
||||
export function MermaidCodeBlock(
|
||||
props: ClientBlockProps & {
|
||||
mermaidRuntimeURL: string;
|
||||
hasInlineExpression: boolean;
|
||||
}
|
||||
) {
|
||||
const { block, mode, style, mermaidRuntimeURL } = props;
|
||||
@@ -169,7 +171,11 @@ export function MermaidCodeBlock(
|
||||
}, []);
|
||||
|
||||
if (error) {
|
||||
return <ClientCodeBlock {...props} />;
|
||||
return props.hasInlineExpression ? (
|
||||
<ClientCodeBlockWithExpressions {...props} />
|
||||
) : (
|
||||
<ClientCodeBlock {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
// The live diagram subtree. It is portaled into a stable host that moves between the
|
||||
|
||||
@@ -11,6 +11,8 @@ const MermaidCodeBlock = dynamic(
|
||||
{ ssr: true }
|
||||
);
|
||||
|
||||
export function MermaidCodeBlockLazy(props: ClientBlockProps & { mermaidRuntimeURL: string }) {
|
||||
export function MermaidCodeBlockLazy(
|
||||
props: ClientBlockProps & { mermaidRuntimeURL: string; hasInlineExpression: boolean }
|
||||
) {
|
||||
return <MermaidCodeBlock {...props} />;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { DocumentInlineEmoji } from '@gitbook/api';
|
||||
|
||||
import type { InlineProps } from './Inline';
|
||||
import { Emoji as EmojiPrimitive } from '@/components/primitives';
|
||||
import { Emoji as EmojiPrimitive } from '@/components/primitives/Emoji';
|
||||
|
||||
export function Emoji(props: InlineProps<DocumentInlineEmoji>) {
|
||||
const { inline } = props;
|
||||
|
||||
@@ -3,7 +3,7 @@ import * as React from 'react';
|
||||
import type { DocumentInlineExpression } from '@gitbook/api';
|
||||
|
||||
import type { InlineProps } from '../Inline';
|
||||
import { InlineExpressionValue } from './InlineExpressionValue';
|
||||
import { InlineExpressionValueLazy } from './InlineExpressionValueLazy';
|
||||
|
||||
/**
|
||||
* Render an inline expression.
|
||||
@@ -25,7 +25,7 @@ export function InlineExpression(props: InlineProps<DocumentInlineExpression>) {
|
||||
|
||||
return (
|
||||
<React.Suspense fallback={null}>
|
||||
<InlineExpressionValue expression={data.expression} variables={variables} />
|
||||
<InlineExpressionValueLazy expression={data.expression} variables={variables} />
|
||||
</React.Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
'use client';
|
||||
|
||||
import dynamic from 'next/dynamic';
|
||||
|
||||
import type { InlineExpressionVariables } from './types';
|
||||
|
||||
// `ssr: true` keeps the evaluated value in the server-rendered HTML; the boundary exists to move
|
||||
// `@gitbook/expr` and its JS parser out of the route's eager entry.
|
||||
const InlineExpressionValue = dynamic(
|
||||
() => import('./InlineExpressionValue').then((mod) => mod.InlineExpressionValue),
|
||||
{ ssr: true }
|
||||
);
|
||||
|
||||
export function InlineExpressionValueLazy(props: {
|
||||
expression: string;
|
||||
variables: InlineExpressionVariables;
|
||||
}) {
|
||||
return <InlineExpressionValue {...props} />;
|
||||
}
|
||||
@@ -1,3 +1,2 @@
|
||||
export * from './InlineExpression';
|
||||
export * from './types';
|
||||
export * from './useEvaluateInlineExpression';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Emoji } from '../primitives';
|
||||
import { Emoji } from '../primitives/Emoji';
|
||||
import { Image } from '@/components/utils';
|
||||
import type { GitBookSiteContext } from '@/lib/context';
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { RevisionPage } from '@gitbook/api';
|
||||
import { Icon, type IconName } from '@gitbook/icons';
|
||||
|
||||
import { Emoji } from '@/components/primitives';
|
||||
import { Emoji } from '@/components/primitives/Emoji';
|
||||
import { type ClassValue, tcls } from '@/lib/tailwind';
|
||||
|
||||
export function PageIcon(props: {
|
||||
|
||||
@@ -5,8 +5,9 @@ import React from 'react';
|
||||
|
||||
import { Icon } from '@gitbook/icons';
|
||||
|
||||
import { AIResponseFeedback, AISearchIcon } from '../AIChat';
|
||||
import { AISearchIcon } from '../AIChat/AIChatIcon';
|
||||
import { HoldMessage } from '../AIChat/AIChatMessages';
|
||||
import { AIResponseFeedback } from '../AIChat/AIResponseFeedback';
|
||||
import { getInsightsSession, useTrackEvent } from '../Insights';
|
||||
import { Button, Link } from '../primitives';
|
||||
import { useSearchAskContext } from './SearchAskContext';
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useHotkeys } from 'react-hotkeys-hook';
|
||||
|
||||
import { CustomizationSearchStyle } from '@gitbook/api';
|
||||
|
||||
import { AIChatButton } from '../AIChat';
|
||||
import { AIChatButton } from '../AIChat/AIChatButton';
|
||||
import { useIsMobile } from '../hooks/useIsMobile';
|
||||
import { Button, Popover } from '../primitives';
|
||||
import { KeyboardShortcut } from '../primitives/KeyboardShortcut';
|
||||
|
||||
@@ -3,7 +3,7 @@ import React, { useEffect, useRef } from 'react';
|
||||
|
||||
import { Icon } from '@gitbook/icons';
|
||||
|
||||
import { AISearchIcon } from '../AIChat';
|
||||
import { AISearchIcon } from '../AIChat/AIChatIcon';
|
||||
import { Input } from '../primitives';
|
||||
import { t, tString, useLanguage } from '@/intl/client';
|
||||
|
||||
|
||||
@@ -10,7 +10,8 @@ import { Icon, type IconName } from '@gitbook/icons';
|
||||
import { useAI } from '../AI';
|
||||
import { CurrentPageProvider, useCurrentContent } from '../hooks';
|
||||
import { PreservePageLayout } from '../PageBody/PreservePageLayout';
|
||||
import { Button, Emoji, SkeletonList, StyledLink, SuspenseLoadedHint } from '../primitives';
|
||||
import { Button, SkeletonList, StyledLink, SuspenseLoadedHint } from '../primitives';
|
||||
import { Emoji } from '../primitives/Emoji';
|
||||
import { Input } from '../primitives/Input';
|
||||
import { useSetSearchState } from '../Search';
|
||||
import { fetchSiteIndex } from '../Search/site-index';
|
||||
|
||||
@@ -5,7 +5,8 @@ import { CustomizationHeaderPreset, CustomizationSearchStyle } from '@gitbook/ap
|
||||
import { AdaptiveVisitorContextProvider } from '../Adaptive';
|
||||
import { AIChatProvider } from '../AI';
|
||||
import type { RenderAIMessageOptions } from '../AI';
|
||||
import { AIChat, AskAITextSelection } from '../AIChat';
|
||||
import { AIChat } from '../AIChat/AIChat';
|
||||
import { AskAITextSelection } from '../AIChat/AskAITextSelection';
|
||||
import { Announcement } from '../Announcement';
|
||||
import { SpacesDropdown, TranslationsDropdown } from '../Header/SpacesDropdown';
|
||||
import { CurrentContentProvider } from '../hooks';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { RevisionTag } from '@gitbook/api';
|
||||
import { Icon, type IconName } from '@gitbook/icons';
|
||||
|
||||
import { Emoji } from '@/components/primitives';
|
||||
import { Emoji } from '@/components/primitives/Emoji';
|
||||
import { backgroundColorToStyle, textColorToStyle } from '@/lib/colors';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ export * from './Skeleton';
|
||||
export * from './Link';
|
||||
export * from './StyledLink';
|
||||
export * from './DateRelative';
|
||||
export * from './Emoji';
|
||||
export * from './LoadingPane';
|
||||
export * from './Tooltip';
|
||||
export * from './Popover';
|
||||
|
||||
@@ -10,7 +10,7 @@ import type {
|
||||
JSONDocument,
|
||||
} from '@gitbook/api';
|
||||
|
||||
import { Emoji } from '@/components/primitives';
|
||||
import { Emoji } from '@/components/primitives/Emoji';
|
||||
|
||||
export interface DocumentSection {
|
||||
id: string;
|
||||
|
||||
@@ -1,23 +1,38 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
|
||||
import { emojiSequences } from '@gitbook/emoji-codepoints';
|
||||
|
||||
import { getEmojiForCode } from './emojis';
|
||||
|
||||
describe('getEmojiForCode', () => {
|
||||
it('should return the emoji character for the given emoji code', () => {
|
||||
it('resolves every base sequence to its fully-qualified emoji', () => {
|
||||
for (const sequence of emojiSequences.split('\n')) {
|
||||
const base = sequence
|
||||
.split('-')
|
||||
.filter((part) => part !== 'fe0f' && part !== '200d')
|
||||
.join('-');
|
||||
const expected = String.fromCodePoint(
|
||||
...sequence.split('-').map((part) => Number.parseInt(part, 16))
|
||||
);
|
||||
expect(getEmojiForCode(base)).toBe(expected);
|
||||
}
|
||||
});
|
||||
|
||||
it('passes through codes that need no qualification', () => {
|
||||
expect(getEmojiForCode('1f600')).toBe('😀');
|
||||
expect(getEmojiForCode('1F600')).toBe('😀');
|
||||
});
|
||||
|
||||
it('should handle complex codes', () => {
|
||||
expect(getEmojiForCode('1f935-2642')).toEqual('🤵♂️');
|
||||
expect(getEmojiForCode('1f3c3-2642')).toEqual('🏃♂️');
|
||||
expect(getEmojiForCode('1f468-2696')).toEqual('👨⚖️');
|
||||
it('handles empty and invalid codes', () => {
|
||||
expect(getEmojiForCode('')).toBe('');
|
||||
expect(getEmojiForCode('not-a-code')).toBe('');
|
||||
});
|
||||
|
||||
it('should return an empty string if invalid', () => {
|
||||
expect(getEmojiForCode('invalid')).toEqual('');
|
||||
});
|
||||
|
||||
it('should handle one with uppercase chars', () => {
|
||||
expect(getEmojiForCode('1f4A5')).toEqual('💥');
|
||||
it('never resolves Object prototype keys through the index', () => {
|
||||
// `constructor` parses as the hex number 0xc, so it falls through to the raw-codepoint
|
||||
// path exactly as before — what matters is that it resolves no emoji.
|
||||
expect(getEmojiForCode('constructor')).toBe('\f');
|
||||
expect(getEmojiForCode('prototype')).toBe('');
|
||||
expect(getEmojiForCode('toString')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,27 @@
|
||||
import { emojiCodepoints } from '@gitbook/emoji-codepoints';
|
||||
import { emojiSequences } from '@gitbook/emoji-codepoints';
|
||||
|
||||
/**
|
||||
* Base form of a codepoint sequence: the qualified one without its variation selectors and joiners.
|
||||
*/
|
||||
function getBaseSequence(sequence: string): string {
|
||||
return sequence
|
||||
.split('-')
|
||||
.filter((part) => part !== 'fe0f' && part !== '200d')
|
||||
.join('-');
|
||||
}
|
||||
|
||||
let qualifiedByBase: Map<string, string> | null = null;
|
||||
|
||||
// The index is ~1700 entries; building it on first use keeps it off the critical path for the many
|
||||
// pages that render no emoji at all.
|
||||
function getQualifiedByBase(): Map<string, string> {
|
||||
if (!qualifiedByBase) {
|
||||
qualifiedByBase = new Map(
|
||||
emojiSequences.split('\n').map((sequence) => [getBaseSequence(sequence), sequence])
|
||||
);
|
||||
}
|
||||
return qualifiedByBase;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the emoji character for the given emoji code.
|
||||
@@ -10,9 +33,7 @@ export function getEmojiForCode(code: string): string {
|
||||
|
||||
code = code.toLowerCase();
|
||||
|
||||
const fullCode =
|
||||
// use hasOwn to prevent codes like "constructor" or "prototype" from being resolved to the emoji codepoints object prototype
|
||||
(Object.hasOwn(emojiCodepoints, code) ? emojiCodepoints[code] : undefined) ?? code;
|
||||
const fullCode = getQualifiedByBase().get(code) ?? code;
|
||||
const codePoints = fullCode.split('-').map((elt) => Number.parseInt(elt, 16));
|
||||
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user