button]:rounded-none [&>button]:font-normal [&>button]:px-3 [&>*:first-child]:rounded-l-md [&>*:last-child]:rounded-r-md', {
13 | '[&>*:not(:last-child)]:border-r-0': variant === 'outline',
14 | }, className)}
15 | />
16 | )
17 | }
18 |
--------------------------------------------------------------------------------
/src/components/cell/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { Question as QuestionIcon } from '@phosphor-icons/react'
3 | import clsx from 'clsx'
4 | import type { MdnLinkKey } from '@/utils/links'
5 | import mdnLinks from '@/utils/links'
6 | import { Button } from '@/components/ui/button'
7 | import {
8 | Tooltip,
9 | TooltipContent,
10 | TooltipProvider,
11 | TooltipTrigger,
12 | } from '@/components/ui/tooltip'
13 |
14 | type ItemProps = {
15 | label: string
16 | children: React.ReactNode
17 | }
18 | function CellItem({ label, children }: ItemProps) {
19 | return (
20 |
21 |
{label}
22 | {children}
23 |
24 | )
25 | }
26 |
27 | type Props = {
28 | className?: string
29 | label: string
30 | mdnLinkKey?: MdnLinkKey
31 | rightIcon?: React.ReactNode
32 | rightTooltip?: string
33 | onRightIconClick?: () => void
34 | children: React.ReactNode
35 | }
36 | function Cell({
37 | className,
38 | label,
39 | mdnLinkKey,
40 | children,
41 | rightIcon,
42 | rightTooltip,
43 | onRightIconClick,
44 | }: Props) {
45 | return (
46 |
47 |
48 |
{label}
49 | {mdnLinkKey && (
50 |
51 |
52 |
53 | )}
54 | {rightIcon && (
55 |
56 |
57 |
58 |
61 |
62 |
63 | {rightTooltip}
64 |
65 |
66 |
67 | )}
68 |
69 |
{children}
70 |
71 | )
72 | }
73 |
74 | Cell.Item = CellItem
75 | export default Cell
76 |
--------------------------------------------------------------------------------
/src/components/header/index.tsx:
--------------------------------------------------------------------------------
1 | import { memo } from 'react'
2 | import { Link, NavLink } from 'react-router-dom'
3 | import clsx from 'clsx'
4 | import { useTranslation } from 'react-i18next'
5 | import { GitHubLogoIcon } from '@radix-ui/react-icons'
6 | import { LanguageSelect } from '@/components/language-select'
7 | import { ModeToggle } from '@/components/mode-toggle'
8 | import { Logo } from '@/components/logo'
9 |
10 | function navLinkClassName({ isActive }: { isActive: boolean }) {
11 | return clsx('transition-colors hover:text-foreground/80 text-sm', isActive ? 'text-foreground' : 'text-foreground/60')
12 | }
13 |
14 | const Header = memo(() => {
15 | const { t } = useTranslation()
16 | return (
17 |
18 |
19 |
20 |
21 |
22 | Regex Vis
23 |
24 |
25 |
29 | {t('Home')}
30 |
31 |
35 | {t('Samples')}
36 |
37 |
38 |
51 |
52 | )
53 | })
54 |
55 | export default Header
56 |
--------------------------------------------------------------------------------
/src/components/language-select/index.tsx:
--------------------------------------------------------------------------------
1 | import { useTranslation } from 'react-i18next'
2 | import {
3 | Select,
4 | SelectContent,
5 | SelectGroup,
6 | SelectItem,
7 | SelectTrigger,
8 | SelectValue,
9 | } from '@/components/ui/select'
10 |
11 | export function LanguageSelect() {
12 | const { i18n } = useTranslation()
13 | const language = i18n.language
14 |
15 | return (
16 |
29 | )
30 | }
31 |
--------------------------------------------------------------------------------
/src/components/legend-item/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { useTranslation } from 'react-i18next'
3 |
4 | type Props = {
5 | name: string
6 | infos: {
7 | desc: string
8 | Icon: React.ReactNode
9 | }[]
10 | }
11 | const LegendItem: React.FC
= ({ name, infos }) => {
12 | const { t } = useTranslation()
13 | return (
14 |
15 |
16 | {t(name)}
17 | :
18 |
19 | {infos.map(({ Icon, desc }) => (
20 |
21 | {Icon}
22 | {t(desc)}
23 |
24 | ))}
25 |
26 | )
27 | }
28 |
29 | export default LegendItem
30 |
--------------------------------------------------------------------------------
/src/components/logo/index.tsx:
--------------------------------------------------------------------------------
1 | type Props = React.ComponentProps<'svg'>
2 |
3 | export function Logo(props: Props) {
4 | return (
5 |
20 | )
21 | }
22 |
--------------------------------------------------------------------------------
/src/components/mode-toggle/index.tsx:
--------------------------------------------------------------------------------
1 | import { MoonIcon, SunIcon } from '@radix-ui/react-icons'
2 | import { useTheme } from '@/components/theme-provider'
3 |
4 | export function ModeToggle() {
5 | const { theme, setTheme } = useTheme()
6 |
7 | const onClick = () => {
8 | setTheme(theme === 'dark' ? 'light' : 'dark')
9 | }
10 |
11 | return (
12 |
13 | {theme === 'dark' ? : }
14 |
15 | )
16 | }
17 |
--------------------------------------------------------------------------------
/src/components/range-input/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { Trash as TrashIcon } from '@phosphor-icons/react'
3 | import clsx from 'clsx'
4 | import { Input } from '@/components/ui/input'
5 | import { useFocus } from '@/utils/hooks/use-focus'
6 | import { useHover } from '@/utils/hooks/use-hover'
7 |
8 | export type Range = {
9 | start: string
10 | end: string
11 | }
12 |
13 | type Prop = {
14 | className?: string
15 | value: Range
16 | startPlaceholder?: string
17 | endPlaceholder?: string
18 | removable?: boolean
19 | onChange: (value: Range) => void
20 | onRemove?: () => void
21 | }
22 | export const RangeInput: React.FC = ({
23 | className,
24 | value,
25 | startPlaceholder = '',
26 | endPlaceholder = '',
27 | removable = true,
28 | onChange,
29 | onRemove,
30 | }) => {
31 | const { hovered, hoverProps } = useHover()
32 | const { focused, focusProps } = useFocus()
33 | const removeBtnVisible = hovered || focused
34 |
35 | const onStartChange = (start: string) => {
36 | onChange({ start, end: value.end })
37 | }
38 | const onEndChange = (end: string) => {
39 | onChange({ start: value.start, end })
40 | }
41 |
42 | return (
43 |
70 | )
71 | }
72 |
--------------------------------------------------------------------------------
/src/components/show-more/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { useLocalStorage } from 'react-use'
3 | import { useTranslation } from 'react-i18next'
4 | import { CaretDown as CaretDownIcon } from '@phosphor-icons/react'
5 | import clsx from 'clsx'
6 |
7 | type Props = {
8 | id: string
9 | children: React.ReactNode
10 | }
11 | function ShowMore({ id, children }: Props) {
12 | const { t } = useTranslation()
13 | const [expanded, setExpanded] = useLocalStorage(id, false)
14 | const handleClick = () => setExpanded(!expanded)
15 | return (
16 | <>
17 | {expanded && children}
18 |
19 |
20 | {expanded ? t('show less') : t('show more')}
21 |
22 |
23 |
24 | >
25 | )
26 | }
27 |
28 | export default ShowMore
29 |
--------------------------------------------------------------------------------
/src/components/test-item/index.tsx:
--------------------------------------------------------------------------------
1 | import { useMemo } from 'react'
2 | import { Check as CheckIcon, Trash as TrashIcon, X as XIcon } from '@phosphor-icons/react'
3 | import { Textarea } from '@/components/ui/textarea'
4 |
5 | type Props = {
6 | value: string
7 | regExp: RegExp
8 | onChange: (value: string) => void
9 | onRemove: () => void
10 | }
11 |
12 | function TestItem({ value, regExp, onChange, onRemove }: Props) {
13 | const isPass = useMemo(() => regExp.test(value), [value, regExp])
14 |
15 | const onKeyDown = (e: React.KeyboardEvent) => {
16 | e.stopPropagation()
17 | }
18 |
19 | return (
20 |
21 |
27 |
28 | {isPass ? : }
29 |
30 |
31 |
32 | )
33 | }
34 |
35 | export default TestItem
36 |
--------------------------------------------------------------------------------
/src/components/theme-provider/index.tsx:
--------------------------------------------------------------------------------
1 | import { createContext, useContext, useLayoutEffect, useMemo, useState } from 'react'
2 |
3 | type Theme = 'dark' | 'light'
4 |
5 | type ThemeProviderProps = {
6 | children: React.ReactNode
7 | defaultTheme?: Theme
8 | storageKey?: string
9 | }
10 |
11 | type ThemeProviderState = {
12 | theme: Theme
13 | setTheme: (theme: Theme) => void
14 | }
15 |
16 | const initialState: ThemeProviderState = {
17 | theme: 'light',
18 | setTheme: () => null,
19 | }
20 |
21 | const ThemeProviderContext = createContext(initialState)
22 |
23 | // credit: pacocoursey/next-themes
24 | // https://github.com/pacocoursey/next-themes/blob/bf0c5a45eaf6fb2b336a6b93840e4ec572bc08c8/next-themes/src/index.tsx#L218-L236
25 | const disableTransition = () => {
26 | const css = document.createElement('style')
27 | css.appendChild(
28 | document.createTextNode(
29 | `*,*::before,*::after{-webkit-transition:none!important;-moz-transition:none!important;-o-transition:none!important;-ms-transition:none!important;transition:none!important}`,
30 | ),
31 | )
32 | document.head.appendChild(css)
33 |
34 | return () => {
35 | // Force restyle
36 | ;(() => window.getComputedStyle(document.body))()
37 |
38 | // Wait for next tick before removing
39 | setTimeout(() => {
40 | document.head.removeChild(css)
41 | }, 1)
42 | }
43 | }
44 |
45 | export function ThemeProvider({
46 | children,
47 | storageKey = 'theme',
48 | ...props
49 | }: ThemeProviderProps) {
50 | const [theme, setTheme] = useState(
51 | () => localStorage.getItem(storageKey) === 'dark' ? 'dark' : 'light',
52 | )
53 |
54 | useLayoutEffect(() => {
55 | const root = window.document.documentElement
56 | const enableTransition = disableTransition()
57 | root.classList.remove('light', 'dark')
58 | root.classList.add(theme)
59 | enableTransition()
60 | }, [theme])
61 |
62 | const value = useMemo(() => ({
63 | theme,
64 | setTheme: (theme: Theme) => {
65 | localStorage.setItem(storageKey, theme)
66 | setTheme(theme)
67 | },
68 | }), [theme, storageKey])
69 |
70 | return (
71 |
72 | {children}
73 |
74 | )
75 | }
76 |
77 | export function useTheme() {
78 | const context = useContext(ThemeProviderContext)
79 |
80 | if (context === undefined)
81 | throw new Error('useTheme must be used within a ThemeProvider')
82 |
83 | return context
84 | }
85 |
--------------------------------------------------------------------------------
/src/components/ui/alert.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 | import { type VariantProps, cva } from 'class-variance-authority'
3 |
4 | import { cn } from '@/utils'
5 |
6 | const alertVariants = cva(
7 | 'relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7',
8 | {
9 | variants: {
10 | variant: {
11 | default: 'bg-background text-foreground',
12 | destructive:
13 | 'border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive',
14 | },
15 | },
16 | defaultVariants: {
17 | variant: 'default',
18 | },
19 | },
20 | )
21 |
22 | const Alert = React.forwardRef<
23 | HTMLDivElement,
24 | React.HTMLAttributes & VariantProps
25 | >(({ className, variant, ...props }, ref) => (
26 |
32 | ))
33 | Alert.displayName = 'Alert'
34 |
35 | const AlertTitle = React.forwardRef<
36 | HTMLParagraphElement,
37 | React.HTMLAttributes
38 | >(({ className, ...props }, ref) => (
39 |
44 | ))
45 | AlertTitle.displayName = 'AlertTitle'
46 |
47 | const AlertDescription = React.forwardRef<
48 | HTMLParagraphElement,
49 | React.HTMLAttributes
50 | >(({ className, ...props }, ref) => (
51 |
56 | ))
57 | AlertDescription.displayName = 'AlertDescription'
58 |
59 | export { Alert, AlertTitle, AlertDescription }
60 |
--------------------------------------------------------------------------------
/src/components/ui/button.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 | import { Slot } from '@radix-ui/react-slot'
3 | import { type VariantProps, cva } from 'class-variance-authority'
4 |
5 | import { cn } from '@/utils'
6 |
7 | const buttonVariants = cva(
8 | 'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50',
9 | {
10 | variants: {
11 | variant: {
12 | default:
13 | 'bg-primary text-primary-foreground shadow hover:bg-primary/90',
14 | destructive:
15 | 'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90',
16 | outline:
17 | 'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground',
18 | secondary:
19 | 'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80',
20 | ghost: 'hover:bg-accent hover:text-accent-foreground',
21 | link: 'text-primary underline-offset-4 hover:underline',
22 | },
23 | size: {
24 | default: 'h-9 px-4 py-2',
25 | sm: 'h-8 rounded-md px-3 text-xs',
26 | lg: 'h-10 rounded-md px-8',
27 | icon: 'h-9 w-9',
28 | },
29 | },
30 | defaultVariants: {
31 | variant: 'default',
32 | size: 'default',
33 | },
34 | },
35 | )
36 |
37 | export interface ButtonProps
38 | extends React.ButtonHTMLAttributes,
39 | VariantProps {
40 | asChild?: boolean
41 | }
42 |
43 | const Button = React.forwardRef(
44 | ({ className, variant, size, asChild = false, ...props }, ref) => {
45 | const Comp = asChild ? Slot : 'button'
46 | return (
47 |
52 | )
53 | },
54 | )
55 | Button.displayName = 'Button'
56 |
57 | export { Button, buttonVariants }
58 |
--------------------------------------------------------------------------------
/src/components/ui/checkbox-group.tsx:
--------------------------------------------------------------------------------
1 | import type { ComponentProps, ReactNode } from 'react'
2 | import { createContext, useCallback, useContext, useMemo } from 'react'
3 | import { Checkbox } from './checkbox'
4 |
5 | const CheckboxGroupContext = createContext<{
6 | value: string[]
7 | onChange: (value: string, checked: boolean) => void
8 | }>({
9 | value: [],
10 | onChange: () => {},
11 | })
12 |
13 | type GroupProps = {
14 | value: string[]
15 | onChange: (value: string[]) => void
16 | children: ReactNode
17 | }
18 |
19 | export function CheckboxGroup(props: GroupProps) {
20 | const { value, onChange, children } = props
21 |
22 | const onItemChange = useCallback((itemValue: string, checked: boolean) => {
23 | if (checked) {
24 | onChange([...value, itemValue])
25 | } else {
26 | const index = value.indexOf(itemValue)
27 | if (index > -1) {
28 | const nextValue = [...value]
29 | nextValue.splice(index, 1)
30 | onChange(nextValue)
31 | }
32 | }
33 | }, [value, onChange])
34 |
35 | const provideValue = useMemo(() => ({ value, onChange: onItemChange }), [value, onItemChange])
36 | return (
37 |
38 | {children}
39 |
40 | )
41 | }
42 |
43 | type ItemProps = {
44 | value: string
45 | } & Omit, 'value'>
46 | export function CheckboxItem(props: ItemProps) {
47 | const { value: itemValue, ...rest } = props
48 | const { value, onChange } = useContext(CheckboxGroupContext)
49 |
50 | const checked = value.includes(itemValue)
51 | const onCheckedChange = (checked: boolean) => onChange(itemValue, checked)
52 | return
53 | }
54 |
--------------------------------------------------------------------------------
/src/components/ui/checkbox.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 | import * as CheckboxPrimitive from '@radix-ui/react-checkbox'
3 | import { CheckIcon } from '@radix-ui/react-icons'
4 |
5 | import { cn } from '@/utils'
6 |
7 | const Checkbox = React.forwardRef<
8 | React.ElementRef,
9 | React.ComponentPropsWithoutRef
10 | >(({ className, ...props }, ref) => (
11 |
19 |
22 |
23 |
24 |
25 | ))
26 | Checkbox.displayName = CheckboxPrimitive.Root.displayName
27 |
28 | export { Checkbox }
29 |
--------------------------------------------------------------------------------
/src/components/ui/input.tsx:
--------------------------------------------------------------------------------
1 | import React, { useCallback, useContext, useEffect, useState } from 'react'
2 | import { useDebounceCallback } from 'usehooks-ts'
3 | import { ValidationContext } from '@/components/validation'
4 | import { useFocus } from '@/utils/hooks'
5 | import { cn } from '@/utils'
6 | import { useLatest } from '@/utils/hooks/use-latest'
7 |
8 | export type InputProps = Omit, 'onChange'> & {
9 | errorPath?: string
10 | onChange: (value: string) => void
11 | }
12 |
13 | const DEBOUNCE_DELAY = 500
14 |
15 | const Input = React.forwardRef(
16 | ({ className, value, errorPath = '', onChange, ...rest }, ref) => {
17 | const [text, setText] = useState(value)
18 | const latestOnChange = useLatest(onChange)
19 | const errorPaths = useContext(ValidationContext)
20 | const isError = errorPaths.includes(errorPath)
21 |
22 | const onTextChange = useCallback((text: string) => {
23 | latestOnChange.current(text)
24 | }, [latestOnChange])
25 |
26 | const debouncedOnTextChange = useDebounceCallback(onTextChange, DEBOUNCE_DELAY)
27 |
28 | const onInputChange = useCallback((e: React.ChangeEvent) => {
29 | const text = e.target.value
30 | setText(text)
31 | debouncedOnTextChange(text)
32 | }, [debouncedOnTextChange])
33 |
34 | const { focused, focusProps } = useFocus({
35 | onFocus: () => setText(value),
36 | // flush debouncedChange on blur
37 | onBlur: debouncedOnTextChange.flush,
38 | })
39 |
40 | // flush debouncedChange on unmount
41 | useEffect(() => {
42 | return () => {
43 | debouncedOnTextChange.flush()
44 | }
45 | }, [debouncedOnTextChange])
46 |
47 | return (
48 |
61 | )
62 | },
63 | )
64 | Input.displayName = 'Input'
65 |
66 | export { Input }
67 |
--------------------------------------------------------------------------------
/src/components/ui/label.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 | import * as LabelPrimitive from '@radix-ui/react-label'
3 | import { type VariantProps, cva } from 'class-variance-authority'
4 |
5 | import { cn } from '@/utils'
6 |
7 | const labelVariants = cva(
8 | 'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
9 | )
10 |
11 | export const Label = React.forwardRef<
12 | React.ElementRef,
13 | React.ComponentPropsWithoutRef &
14 | VariantProps
15 | >(({ className, ...props }, ref) => (
16 |
21 | ))
22 | Label.displayName = LabelPrimitive.Root.displayName
23 |
--------------------------------------------------------------------------------
/src/components/ui/scroll-area.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 | import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area'
3 |
4 | import { cn } from '@/utils'
5 |
6 | const ScrollBar = React.forwardRef<
7 | React.ElementRef,
8 | React.ComponentPropsWithoutRef
9 | >(({ className, orientation = 'vertical', ...props }, ref) => (
10 |
23 |
24 |
25 | ))
26 | ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
27 |
28 | const ScrollArea = React.forwardRef<
29 | React.ElementRef,
30 | React.ComponentPropsWithoutRef
31 | >(({ className, children, ...props }, ref) => (
32 |
37 |
38 | {children}
39 |
40 |
41 |
42 |
43 | ))
44 | ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
45 |
46 | export { ScrollArea, ScrollBar }
47 |
--------------------------------------------------------------------------------
/src/components/ui/tabs.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 | import * as TabsPrimitive from '@radix-ui/react-tabs'
3 |
4 | import { cn } from '@/utils'
5 |
6 | const Tabs = TabsPrimitive.Root
7 |
8 | const TabsList = React.forwardRef<
9 | React.ElementRef,
10 | React.ComponentPropsWithoutRef
11 | >(({ className, ...props }, ref) => (
12 |
20 | ))
21 | TabsList.displayName = TabsPrimitive.List.displayName
22 |
23 | const TabsTrigger = React.forwardRef<
24 | React.ElementRef,
25 | React.ComponentPropsWithoutRef
26 | >(({ className, ...props }, ref) => (
27 |
35 | ))
36 | TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
37 |
38 | const TabsContent = React.forwardRef<
39 | React.ElementRef,
40 | React.ComponentPropsWithoutRef
41 | >(({ className, ...props }, ref) => (
42 |
50 | ))
51 | TabsContent.displayName = TabsPrimitive.Content.displayName
52 |
53 | export { Tabs, TabsList, TabsTrigger, TabsContent }
54 |
--------------------------------------------------------------------------------
/src/components/ui/textarea.tsx:
--------------------------------------------------------------------------------
1 | import React, { useCallback } from 'react'
2 | import { useDebounceCallback } from 'usehooks-ts'
3 | import { useLatest } from '@/utils/hooks/use-latest'
4 |
5 | import { cn } from '@/utils'
6 |
7 | const DEBOUNCE_DELAY = 500
8 |
9 | export type TextareaProps =
10 | Omit, 'onChange' | 'value'> & {
11 | onChange: (value: string) => void
12 | }
13 |
14 | const Textarea = React.forwardRef(
15 | ({ className, onChange, ...props }, ref) => {
16 | const latestOnChange = useLatest(onChange)
17 |
18 | const onTextChange = useCallback((text: string) => {
19 | latestOnChange.current(text)
20 | }, [latestOnChange])
21 |
22 | const debouncedOnTextChange = useDebounceCallback(onTextChange, DEBOUNCE_DELAY)
23 |
24 | const onInputChange = useCallback((e: React.ChangeEvent) => {
25 | debouncedOnTextChange(e.target.value)
26 | }, [debouncedOnTextChange])
27 |
28 | return (
29 |
38 | )
39 | },
40 | )
41 | Textarea.displayName = 'Textarea'
42 |
43 | export { Textarea }
44 |
--------------------------------------------------------------------------------
/src/components/ui/toaster.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Toast,
3 | ToastClose,
4 | ToastDescription,
5 | ToastProvider,
6 | ToastTitle,
7 | ToastViewport,
8 | } from '@/components/ui/toast'
9 | import { useToast } from '@/components/ui/use-toast'
10 |
11 | export function Toaster() {
12 | const { toasts } = useToast()
13 |
14 | return (
15 |
16 | {toasts.map(({ id, title, description, action, ...props }) => {
17 | return (
18 |
19 |
20 | {title && {title}}
21 | {description && (
22 | {description}
23 | )}
24 |
25 | {action}
26 |
27 |
28 | )
29 | })}
30 |
31 |
32 | )
33 | }
34 |
--------------------------------------------------------------------------------
/src/components/ui/toggle.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 | import * as TogglePrimitive from '@radix-ui/react-toggle'
3 | import { type VariantProps, cva } from 'class-variance-authority'
4 |
5 | import { cn } from '@/utils'
6 |
7 | const toggleVariants = cva(
8 | 'inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors hover:bg-muted hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground',
9 | {
10 | variants: {
11 | variant: {
12 | default: 'bg-transparent',
13 | outline:
14 | 'border border-input bg-transparent shadow-sm hover:bg-accent hover:text-accent-foreground',
15 | },
16 | size: {
17 | default: 'h-9 px-3',
18 | sm: 'h-8 px-2',
19 | lg: 'h-10 px-3',
20 | },
21 | },
22 | defaultVariants: {
23 | variant: 'default',
24 | size: 'default',
25 | },
26 | },
27 | )
28 |
29 | const Toggle = React.forwardRef<
30 | React.ElementRef,
31 | React.ComponentPropsWithoutRef &
32 | VariantProps
33 | >(({ className, variant, size, ...props }, ref) => (
34 |
39 | ))
40 |
41 | Toggle.displayName = TogglePrimitive.Root.displayName
42 |
43 | export { Toggle, toggleVariants }
44 |
--------------------------------------------------------------------------------
/src/components/ui/tooltip.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 | import * as TooltipPrimitive from '@radix-ui/react-tooltip'
3 |
4 | import { cn } from '@/utils'
5 |
6 | const TooltipProvider = TooltipPrimitive.Provider
7 |
8 | const Tooltip = TooltipPrimitive.Root
9 |
10 | const TooltipTrigger = TooltipPrimitive.Trigger
11 |
12 | const TooltipContent = React.forwardRef<
13 | React.ElementRef,
14 | React.ComponentPropsWithoutRef
15 | >(({ className, sideOffset = 4, ...props }, ref) => (
16 |
25 | ))
26 | TooltipContent.displayName = TooltipPrimitive.Content.displayName
27 |
28 | export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
29 |
--------------------------------------------------------------------------------
/src/components/ui/use-toast.ts:
--------------------------------------------------------------------------------
1 | // Inspired by react-hot-toast library
2 | import * as React from 'react'
3 |
4 | import type {
5 | ToastActionElement,
6 | ToastProps,
7 | } from '@/components/ui/toast'
8 |
9 | const TOAST_LIMIT = 1
10 | const TOAST_REMOVE_DELAY = 1000000
11 |
12 | type ToasterToast = ToastProps & {
13 | id: string
14 | title?: React.ReactNode
15 | description?: React.ReactNode
16 | action?: ToastActionElement
17 | }
18 |
19 | const _actionTypes = {
20 | ADD_TOAST: 'ADD_TOAST',
21 | UPDATE_TOAST: 'UPDATE_TOAST',
22 | DISMISS_TOAST: 'DISMISS_TOAST',
23 | REMOVE_TOAST: 'REMOVE_TOAST',
24 | } as const
25 |
26 | let count = 0
27 |
28 | function genId() {
29 | count = (count + 1) % Number.MAX_SAFE_INTEGER
30 | return count.toString()
31 | }
32 |
33 | type ActionType = typeof _actionTypes
34 |
35 | type Action =
36 | | {
37 | type: ActionType['ADD_TOAST']
38 | toast: ToasterToast
39 | }
40 | | {
41 | type: ActionType['UPDATE_TOAST']
42 | toast: Partial
43 | }
44 | | {
45 | type: ActionType['DISMISS_TOAST']
46 | toastId?: ToasterToast['id']
47 | }
48 | | {
49 | type: ActionType['REMOVE_TOAST']
50 | toastId?: ToasterToast['id']
51 | }
52 |
53 | type State = {
54 | toasts: ToasterToast[]
55 | }
56 |
57 | const toastTimeouts = new Map>()
58 |
59 | function addToRemoveQueue(toastId: string) {
60 | if (toastTimeouts.has(toastId)) {
61 | return
62 | }
63 |
64 | const timeout = setTimeout(() => {
65 | toastTimeouts.delete(toastId)
66 | dispatch({
67 | type: 'REMOVE_TOAST',
68 | toastId,
69 | })
70 | }, TOAST_REMOVE_DELAY)
71 |
72 | toastTimeouts.set(toastId, timeout)
73 | }
74 |
75 | export function reducer(state: State, action: Action): State {
76 | switch (action.type) {
77 | case 'ADD_TOAST':
78 | return {
79 | ...state,
80 | toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
81 | }
82 |
83 | case 'UPDATE_TOAST':
84 | return {
85 | ...state,
86 | toasts: state.toasts.map(t =>
87 | t.id === action.toast.id ? { ...t, ...action.toast } : t,
88 | ),
89 | }
90 |
91 | case 'DISMISS_TOAST': {
92 | const { toastId } = action
93 |
94 | // ! Side effects ! - This could be extracted into a dismissToast() action,
95 | // but I'll keep it here for simplicity
96 | if (toastId) {
97 | addToRemoveQueue(toastId)
98 | } else {
99 | state.toasts.forEach((toast) => {
100 | addToRemoveQueue(toast.id)
101 | })
102 | }
103 |
104 | return {
105 | ...state,
106 | toasts: state.toasts.map(t =>
107 | t.id === toastId || toastId === undefined
108 | ? {
109 | ...t,
110 | open: false,
111 | }
112 | : t,
113 | ),
114 | }
115 | }
116 | case 'REMOVE_TOAST':
117 | if (action.toastId === undefined) {
118 | return {
119 | ...state,
120 | toasts: [],
121 | }
122 | }
123 | return {
124 | ...state,
125 | toasts: state.toasts.filter(t => t.id !== action.toastId),
126 | }
127 | }
128 | }
129 |
130 | const listeners: Array<(state: State) => void> = []
131 |
132 | let memoryState: State = { toasts: [] }
133 |
134 | function dispatch(action: Action) {
135 | memoryState = reducer(memoryState, action)
136 | listeners.forEach((listener) => {
137 | listener(memoryState)
138 | })
139 | }
140 |
141 | type Toast = Omit
142 |
143 | function toast({ ...props }: Toast) {
144 | const id = genId()
145 |
146 | const update = (props: ToasterToast) =>
147 | dispatch({
148 | type: 'UPDATE_TOAST',
149 | toast: { ...props, id },
150 | })
151 | const dismiss = () => dispatch({ type: 'DISMISS_TOAST', toastId: id })
152 |
153 | dispatch({
154 | type: 'ADD_TOAST',
155 | toast: {
156 | ...props,
157 | id,
158 | open: true,
159 | onOpenChange: (open) => {
160 | if (!open)
161 | dismiss()
162 | },
163 | },
164 | })
165 |
166 | return {
167 | id,
168 | dismiss,
169 | update,
170 | }
171 | }
172 |
173 | function useToast() {
174 | const [state, setState] = React.useState(memoryState)
175 |
176 | React.useEffect(() => {
177 | listeners.push(setState)
178 | return () => {
179 | const index = listeners.indexOf(setState)
180 | if (index > -1) {
181 | listeners.splice(index, 1)
182 | }
183 | }
184 | }, [state])
185 |
186 | return {
187 | ...state,
188 | toast,
189 | dismiss: (toastId?: string) => dispatch({ type: 'DISMISS_TOAST', toastId }),
190 | }
191 | }
192 |
193 | export { useToast, toast }
194 |
--------------------------------------------------------------------------------
/src/components/validation/index.tsx:
--------------------------------------------------------------------------------
1 | import { createContext, memo, useCallback, useMemo, useState } from 'react'
2 | import type { ZodIssue, ZodType, z } from 'zod'
3 | import { useTranslation } from 'react-i18next'
4 |
5 | export const ValidationContext = createContext<(string | number)[]>([])
6 |
7 | type Props = {
8 | className?: string
9 | children: (value: T, onChange: (value: T) => void) => React.ReactNode
10 | defaultValue: T
11 | onChange: (value: O) => void
12 | schema: ZodType
13 | }
14 |
15 | function InnerValidation(props: Props) {
16 | const {
17 | className,
18 | children,
19 | defaultValue,
20 | onChange,
21 | schema,
22 | } = props
23 | const [innerValue, setInnerValue] = useState(() => defaultValue)
24 | const [errors, setErrors] = useState([])
25 | const { t } = useTranslation()
26 |
27 | const onValueChange = useCallback((value: T) => {
28 | setInnerValue(value)
29 | const result = schema.safeParse(value)
30 | setErrors(result.error?.errors ?? [])
31 | if (result.success) {
32 | onChange(result.data)
33 | }
34 | }, [setInnerValue, onChange, schema])
35 |
36 | const message = useMemo(() => errors.length > 0 ? errors[0].message : '', [errors])
37 |
38 | const errorPaths = useMemo(() => {
39 | const paths: (string | number)[] = []
40 | errors.forEach((error) => {
41 | if (error.path.length === 0) {
42 | paths.push('')
43 | } else {
44 | paths.push(...error.path)
45 | }
46 | })
47 | return paths
48 | }, [errors])
49 |
50 | return (
51 |
52 |
53 | {children(innerValue, onValueChange)}
54 | {message &&
{t(message)}
}
55 |
56 |
57 | )
58 | }
59 |
60 | export const Validation = memo(InnerValidation) as typeof InnerValidation
61 |
--------------------------------------------------------------------------------
/src/constants/index.ts:
--------------------------------------------------------------------------------
1 | import resolveConfig from 'tailwindcss/resolveConfig'
2 | import tailwindConfig from 'tailwind.config'
3 |
4 | const fullTailwindConfig = resolveConfig(tailwindConfig)
5 |
6 | // graph
7 | export const GRAPH_TEXT_FONT_SIZE = 16
8 | export const GRAPH_NODE_PADDING_VERTICAL = 2
9 | export const GRAPH_NODE_PADDING_HORIZONTAL = 10
10 | export const GRAPH_NODE_BORDER_RADIUS = 5
11 | export const GRAPH_NODE_MARGIN_VERTICAL = 15
12 | export const GRAPH_NODE_MARGIN_HORIZONTAL = 25
13 | export const GRAPH_GROUP_NODE_PADDING_VERTICAL = 15
14 | export const GRAPH_CHOICE_PADDING_HORIZONTAL = 25
15 | export const GRAPH_CHOICE_PADDING_VERTICAL = 10
16 | export const GRAPH_ROOT_RADIUS = 5
17 | export const GRAPH_NODE_MIN_WIDTH = 20
18 | export const GRAPH_NODE_MIN_HEIGHT = 26
19 | export const GRAPH_ICON_SIZE = 18
20 | export const GRAPH_QUANTIFIER_MEASURE_HEIGHT = 16
21 | export const GRAPH_NAME_MEASURE_HEIGHT = 16
22 | export const GRAPH_QUANTIFIER_TEXT_FONTSIZE = 14
23 | export const GRAPH_NAME_TEXT_FONTSIZE = 14
24 | export const GRAPH_QUANTIFIER_HEIGHT = Math.max(
25 | GRAPH_QUANTIFIER_TEXT_FONTSIZE * 1.5,
26 | GRAPH_ICON_SIZE,
27 | )
28 | export const GRAPH_NAME_HEIGHT = Math.max(
29 | GRAPH_NAME_TEXT_FONTSIZE * 1.5,
30 | GRAPH_ICON_SIZE,
31 | )
32 | export const GRAPH_QUOTE_PADDING = 2
33 |
34 | export const GRAPH_PADDING_VERTICAL = 50
35 | export const GRAPH_PADDING_HORIZONTAL = 50
36 | export const GRAPH_WITHOUT_ROOT_PADDING_VERTICAL = 5
37 | export const GRAPH_WITHOUT_ROOT_PADDING_HORIZONTAL = 5
38 |
39 | // storage key
40 | export const STORAGE_TEST_CASES = 'test-cases'
41 | export const STORAGE_GRAPH_TIP_VISIBLE = 'graph-tip-visible'
42 |
43 | // url search param
44 | export const SEARCH_PARAM_REGEX = 'r'
45 | export const SEARCH_PARAM_TESTS = 't'
46 |
47 | export const REGEX_FONT_FAMILY = fullTailwindConfig.theme.fontFamily.mono.join(', ')
48 |
--------------------------------------------------------------------------------
/src/global.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | @layer base {
6 | :root {
7 | --background: 0 0% 100%;
8 | --foreground: 240 10% 3.9%;
9 | --card: 0 0% 100%;
10 | --card-foreground: 240 10% 3.9%;
11 | --popover: 0 0% 100%;
12 | --popover-foreground: 240 10% 3.9%;
13 | --primary: 240 5.9% 10%;
14 | --primary-foreground: 0 0% 98%;
15 | --secondary: 240 4.8% 95.9%;
16 | --secondary-foreground: 240 5.9% 10%;
17 | --muted: 240 4.8% 95.9%;
18 | --muted-foreground: 240 3.8% 46.1%;
19 | --accent: 240 4.8% 95.9%;
20 | --accent-foreground: 240 5.9% 10%;
21 | --destructive: 0 84.2% 60.2%;
22 | --destructive-foreground: 0 0% 98%;
23 | --border: 240 5.9% 90%;
24 | --input: 240 5.9% 90%;
25 | --ring: 240 10% 3.9%;
26 | --radius: 0.5rem;
27 | --chart-1: 12 76% 61%;
28 | --chart-2: 173 58% 39%;
29 | --chart-3: 197 37% 24%;
30 | --chart-4: 43 74% 66%;
31 | --chart-5: 27 87% 67%;
32 | --graph: #3f3f46;
33 | --graph-group: #a1a1aa;
34 | --graph-bg: #fafafa;
35 | }
36 |
37 | .dark {
38 | --background: 240 10% 3.9%;
39 | --foreground: 0 0% 98%;
40 | --card: 240 10% 3.9%;
41 | --card-foreground: 0 0% 98%;
42 | --popover: 240 10% 3.9%;
43 | --popover-foreground: 0 0% 98%;
44 | --primary: 0 0% 98%;
45 | --primary-foreground: 240 5.9% 10%;
46 | --secondary: 240 3.7% 15.9%;
47 | --secondary-foreground: 0 0% 98%;
48 | --muted: 240 3.7% 15.9%;
49 | --muted-foreground: 240 5% 64.9%;
50 | --accent: 240 3.7% 15.9%;
51 | --accent-foreground: 0 0% 98%;
52 | --destructive: 0 62.8% 30.6%;
53 | --destructive-foreground: 0 0% 98%;
54 | --border: 240 3.7% 15.9%;
55 | --input: 240 3.7% 15.9%;
56 | --ring: 240 4.9% 83.9%;
57 | --chart-1: 220 70% 50%;
58 | --chart-2: 160 60% 45%;
59 | --chart-3: 30 80% 55%;
60 | --chart-4: 280 65% 60%;
61 | --chart-5: 340 75% 55%;
62 | --graph: #d4d4d8;
63 | --graph-group: #52525b;
64 | --graph-bg: #111111;
65 | }
66 | }
67 |
68 | @layer base {
69 | * {
70 | @apply border-border;
71 | }
72 | body {
73 | @apply bg-background text-foreground;
74 | }
75 | }
--------------------------------------------------------------------------------
/src/i18n.ts:
--------------------------------------------------------------------------------
1 | import i18n from 'i18next'
2 | import { initReactI18next } from 'react-i18next'
3 |
4 | import Backend from 'i18next-http-backend'
5 | import LanguageDetector from 'i18next-browser-languagedetector'
6 |
7 | i18n
8 | .use(Backend)
9 | .use(LanguageDetector)
10 | .use(initReactI18next)
11 | .init({
12 | fallbackLng: 'en',
13 | interpolation: {
14 | escapeValue: false,
15 | },
16 | detection: {
17 | order: ['localStorage', 'sessionStorage'],
18 | },
19 | })
20 |
21 | export default i18n
22 |
--------------------------------------------------------------------------------
/src/index.tsx:
--------------------------------------------------------------------------------
1 | import { StrictMode } from 'react'
2 | import { createRoot } from 'react-dom/client'
3 | import * as Sentry from '@sentry/react'
4 | import App from './App'
5 | import './i18n'
6 | import './global.css'
7 |
8 | if (import.meta.env.SENTRY_DSN) {
9 | Sentry.init({
10 | dsn: import.meta.env.SENTRY_DSN,
11 | })
12 | }
13 |
14 | const root = createRoot(document.getElementById('root')!)
15 | root.render(
16 |
17 |
18 | ,
19 | )
20 |
--------------------------------------------------------------------------------
/src/logo.svg:
--------------------------------------------------------------------------------
1 |
16 |
--------------------------------------------------------------------------------
/src/modules/editor/edit-tab.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from 'react'
2 | import { useAtomValue } from 'jotai'
3 | import ContentEditor from './features/content'
4 | import Group from './features/group'
5 | import Expression from './features/expression'
6 | import Quantifier from './features/quantifier'
7 | import LookAround from './features/look-around'
8 | import Insert from './features/insert'
9 | import type { NodesInfo } from './utils'
10 | import { genInitialNodesInfo, getInfoFromNodes } from './utils'
11 | import type { AST } from '@/parser'
12 | import { getNodesByIds } from '@/parser/visit'
13 | import { astAtom, selectedIdsAtom } from '@/atom'
14 |
15 | function EditTab() {
16 | const [nodes, setNodes] = useState([])
17 | const selectedIds = useAtomValue(selectedIdsAtom)
18 | const ast = useAtomValue(astAtom)
19 |
20 | useEffect(() => {
21 | if (selectedIds.length === 0) {
22 | return setNodes([])
23 | }
24 | setNodes(getNodesByIds(ast, selectedIds).nodes)
25 | }, [ast, selectedIds])
26 |
27 | const [nodesInfo, setNodesInfo] = useState(genInitialNodesInfo())
28 |
29 | const {
30 | id,
31 | regex,
32 | startIndex,
33 | endIndex,
34 | group,
35 | content,
36 | hasQuantifier,
37 | quantifier,
38 | lookAround,
39 | } = nodesInfo
40 |
41 | useEffect(() => {
42 | const nodesInfo = getInfoFromNodes(ast, nodes)
43 | setNodesInfo(nodesInfo)
44 | }, [ast, nodes])
45 |
46 | return (
47 |
48 |
49 |
50 | {content && (
51 |
52 | )}
53 | {group && }
54 | {hasQuantifier && }
55 | {lookAround && (
56 |
57 | )}
58 |
59 | )
60 | }
61 |
62 | export default EditTab
63 |
--------------------------------------------------------------------------------
/src/modules/editor/features/content/back-ref.tsx:
--------------------------------------------------------------------------------
1 | import React, { useMemo } from 'react'
2 | import { useAtomValue, useSetAtom } from 'jotai'
3 | import { useTranslation } from 'react-i18next'
4 | import Cell from '@/components/cell'
5 | import { groupNamesAtom, updateContentAtom } from '@/atom'
6 | import {
7 | Select,
8 | SelectContent,
9 | SelectGroup,
10 | SelectItem,
11 | SelectTrigger,
12 | SelectValue,
13 | } from '@/components/ui/select'
14 |
15 | type Props = {
16 | reference: string
17 | }
18 | const BackRef: React.FC = ({ reference }) => {
19 | const { t } = useTranslation()
20 | const groupNames = useAtomValue(groupNamesAtom)
21 | const updateContent = useSetAtom(updateContentAtom)
22 |
23 | const options = useMemo(() => {
24 | if (groupNames.includes(reference)) {
25 | return groupNames
26 | }
27 | return [reference, ...groupNames]
28 | }, [groupNames, reference])
29 |
30 | const onChange = (value: string | string[]) =>
31 | updateContent({ kind: 'backReference', ref: value as string })
32 |
33 | return (
34 |
35 |
55 |
56 | )
57 | }
58 |
59 | export default BackRef
60 |
--------------------------------------------------------------------------------
/src/modules/editor/features/content/class-character.tsx:
--------------------------------------------------------------------------------
1 | import React, { useMemo } from 'react'
2 | import { useSetAtom } from 'jotai'
3 | import { useTranslation } from 'react-i18next'
4 | import { z } from 'zod'
5 | import { Input } from '@/components/ui/input'
6 | import Cell from '@/components/cell'
7 | import type { CharacterClassKey } from '@/parser'
8 | import { characterClassTextMap } from '@/parser'
9 | import { updateContentAtom } from '@/atom'
10 | import {
11 | Select,
12 | SelectContent,
13 | SelectGroup,
14 | SelectItem,
15 | SelectTrigger,
16 | SelectValue,
17 | } from '@/components/ui/select'
18 | import { Validation } from '@/components/validation'
19 |
20 | const classOptions: { value: CharacterClassKey, text: string }[] = []
21 | for (const key in characterClassTextMap) {
22 | classOptions.push({
23 | value: key as CharacterClassKey,
24 | text: characterClassTextMap[key as CharacterClassKey],
25 | })
26 | }
27 |
28 | const xhhSchema = z.string().regex(/^\\x[0-9a-fA-F]{2}$/)
29 | const uhhhhSchema = z.string().regex(/^\\u[0-9a-fA-F]{4}$/)
30 |
31 | type Props = {
32 | value: string
33 | }
34 | const ClassCharacter: React.FC = ({ value }) => {
35 | const { t } = useTranslation()
36 | const updateContent = useSetAtom(updateContentAtom)
37 |
38 | const classKind = useMemo(() => {
39 | if (xhhSchema.safeParse(value).success) {
40 | return '\\xhh'
41 | } else if (uhhhhSchema.safeParse(value).success) {
42 | return '\\uhhhh'
43 | }
44 | return value
45 | }, [value])
46 |
47 | const handleSelectChange = (value: string) => {
48 | value = value as string
49 | if (value === '\\xhh') {
50 | value = '\\x00'
51 | } else if (value === '\\uhhhh') {
52 | value = '\\u0000'
53 | }
54 | updateContent({
55 | kind: 'class',
56 | value,
57 | })
58 | }
59 |
60 | const onInputChange = (value: string) =>
61 | updateContent({
62 | kind: 'class',
63 | value,
64 | })
65 |
66 | return (
67 |
68 |
69 |
87 | {classKind === '\\xhh' && (
88 |
89 | {(value: string, onChange: (value: string) => void) => (
90 |
95 | )}
96 |
97 | )}
98 | {classKind === '\\uhhhh' && (
99 |
100 | {(value: string, onChange: (value: string) => void) => (
101 |
106 | )}
107 |
108 | )}
109 |
110 |
111 | )
112 | }
113 |
114 | export default ClassCharacter
115 |
--------------------------------------------------------------------------------
/src/modules/editor/features/content/helper.ts:
--------------------------------------------------------------------------------
1 | import type { CharacterClassKey } from '@/parser'
2 | import { characterClassTextMap } from '@/parser'
3 |
4 | export const characterOptions = [
5 | {
6 | label: 'Simple string',
7 | value: 'string',
8 | },
9 | {
10 | label: 'Character class',
11 | value: 'class',
12 | },
13 | {
14 | label: 'Character range',
15 | value: 'ranges',
16 | },
17 | ]
18 | export const backRefOption = {
19 | label: 'Back reference',
20 | value: 'backReference',
21 | }
22 |
23 | export const beginningAssertionOption = {
24 | label: 'Beginning Assertion',
25 | value: 'beginningAssertion',
26 | }
27 |
28 | export const endAssertionOption = {
29 | label: 'End Assertion',
30 | value: 'endAssertion',
31 | }
32 |
33 | export const wordBoundaryAssertionOption = {
34 | label: 'Word Boundary Assertion',
35 | value: 'wordBoundaryAssertion',
36 | }
37 |
38 | const classOptions: { value: CharacterClassKey, text: string }[] = []
39 | for (const key in characterClassTextMap) {
40 | classOptions.push({
41 | value: key as CharacterClassKey,
42 | text: characterClassTextMap[key as CharacterClassKey],
43 | })
44 | }
45 |
46 | export { classOptions }
47 |
--------------------------------------------------------------------------------
/src/modules/editor/features/content/index.tsx:
--------------------------------------------------------------------------------
1 | import React, { useMemo } from 'react'
2 | import { useTranslation } from 'react-i18next'
3 | import { Question as QuestionIcon } from '@phosphor-icons/react'
4 | import { useAtomValue, useSetAtom } from 'jotai'
5 | import { nanoid } from 'nanoid'
6 | import SimpleString from './simple-string'
7 | import ClassCharacter from './class-character'
8 | import BackRef from './back-ref'
9 | import WordBoundary from './word-boundary'
10 | import {
11 | backRefOption,
12 | beginningAssertionOption,
13 | characterOptions,
14 | endAssertionOption,
15 | wordBoundaryAssertionOption,
16 | } from './helper'
17 | import Ranges from './ranges'
18 | import { astAtom, groupNamesAtom, updateContentAtom } from '@/atom'
19 | import mdnLinks, { isMdnLinkKey } from '@/utils/links'
20 | import type { AST } from '@/parser'
21 | import Cell from '@/components/cell'
22 | import {
23 | Select,
24 | SelectContent,
25 | SelectGroup,
26 | SelectItem,
27 | SelectTrigger,
28 | SelectValue,
29 | } from '@/components/ui/select'
30 |
31 | type Prop = {
32 | content: AST.Content
33 | id: string
34 | quantifier: AST.Quantifier | null
35 | }
36 | const ContentEditor: React.FC = ({ content, id, quantifier }) => {
37 | const { t } = useTranslation()
38 | const groupNames = useAtomValue(groupNamesAtom)
39 | const ast = useAtomValue(astAtom)
40 | const updateContent = useSetAtom(updateContentAtom)
41 | const { kind } = content
42 |
43 | const options = useMemo(() => {
44 | const options = [...characterOptions, wordBoundaryAssertionOption]
45 | if (groupNames.length !== 0 || kind === 'backReference') {
46 | options.push(backRefOption)
47 | }
48 | if (
49 | (ast.body.length > 0 && ast.body[0].id === id)
50 | || kind === 'beginningAssertion'
51 | ) {
52 | options.push(beginningAssertionOption)
53 | }
54 | if (
55 | (ast.body.length > 0 && ast.body[ast.body.length - 1].id === id)
56 | || kind === 'endAssertion'
57 | ) {
58 | options.push(endAssertionOption)
59 | }
60 | return options
61 | }, [groupNames, kind, ast, id])
62 |
63 | const onTypeChange = (type: string | string[]) => {
64 | let payload: AST.Content
65 | switch (type) {
66 | case 'string':
67 | payload = { kind: 'string', value: '' }
68 | break
69 | case 'class':
70 | payload = { kind: 'class', value: '' }
71 | break
72 | case 'ranges':
73 | payload = {
74 | kind: 'ranges',
75 | ranges: [{ id: nanoid(), from: '', to: '' }],
76 | negate: false,
77 | }
78 | break
79 | case 'backReference':
80 | payload = { kind: 'backReference', ref: '1' }
81 | break
82 | case 'beginningAssertion':
83 | case 'endAssertion':
84 | payload = { kind: type }
85 | break
86 | case 'wordBoundaryAssertion':
87 | payload = { kind: 'wordBoundaryAssertion', negate: false }
88 | break
89 | default:
90 | return
91 | }
92 | updateContent(payload)
93 | }
94 |
95 | return (
96 |
97 |
98 |
99 |
113 | {isMdnLinkKey(content.kind) && (
114 |
115 |
116 |
117 | )}
118 |
119 |
120 |
121 | {content.kind === 'string' && (
122 |
123 | )}
124 | {content.kind === 'ranges' && (
125 |
126 | )}
127 | {content.kind === 'class' && }
128 | {content.kind === 'backReference' && (
129 |
130 | )}
131 | {content.kind === 'wordBoundaryAssertion' && (
132 |
133 | )}
134 | |
135 | )
136 | }
137 |
138 | export default ContentEditor
139 |
--------------------------------------------------------------------------------
/src/modules/editor/features/content/simple-string.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { useTranslation } from 'react-i18next'
3 | import { useSetAtom } from 'jotai'
4 | import { RocketIcon } from '@radix-ui/react-icons'
5 | import { Input } from '@/components/ui/input'
6 | import Cell from '@/components/cell'
7 | import type { AST } from '@/parser'
8 | import { updateContentAtom } from '@/atom'
9 | import { useToast } from '@/components/ui/use-toast'
10 | import { Alert, AlertDescription } from '@/components/ui/alert'
11 |
12 | type Props = {
13 | value: string
14 | quantifier: AST.Quantifier | null
15 | }
16 | const SimpleString: React.FC = ({ value, quantifier }) => {
17 | const { t } = useTranslation()
18 | const updateContent = useSetAtom(updateContentAtom)
19 | const { toast } = useToast()
20 |
21 | const handleChange = (value: string) => {
22 | if (value.length > 1 && quantifier) {
23 | toast({ description: 'Group selection automatically' })
24 | }
25 | updateContent({
26 | kind: 'string',
27 | value,
28 | })
29 | }
30 |
31 | return (
32 |
33 |
34 |
35 |
36 |
37 | {t('The input will be escaped automatically.')}
38 |
39 |
40 |
41 |
42 |
43 | )
44 | }
45 |
46 | export default SimpleString
47 |
--------------------------------------------------------------------------------
/src/modules/editor/features/content/word-boundary.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { useTranslation } from 'react-i18next'
3 | import { useSetAtom } from 'jotai'
4 | import Cell from '@/components/cell'
5 | import { updateContentAtom } from '@/atom'
6 | import { Checkbox } from '@/components/ui/checkbox'
7 |
8 | type Props = {
9 | negate: boolean
10 | }
11 | const WordBoundary: React.FC = ({ negate }) => {
12 | const { t } = useTranslation()
13 | const updateContent = useSetAtom(updateContentAtom)
14 | const onCheckedChange = (negate: boolean) => {
15 | updateContent({
16 | kind: 'wordBoundaryAssertion',
17 | negate,
18 | })
19 | }
20 |
21 | return (
22 |
23 |
31 |
32 | )
33 | }
34 |
35 | export default WordBoundary
36 |
--------------------------------------------------------------------------------
/src/modules/editor/features/expression/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { useTranslation } from 'react-i18next'
3 | import Cell from '@/components/cell'
4 |
5 | type Prop = {
6 | regex: string
7 | startIndex: number
8 | endIndex: number
9 | }
10 | const Expression: React.FC = ({ regex, startIndex, endIndex }) => {
11 | const { t } = useTranslation()
12 | return (
13 |
14 |
15 | {regex.slice(0, startIndex)}
16 | {regex.slice(startIndex, endIndex)}
17 | {regex.slice(endIndex)}
18 |
19 | |
20 | )
21 | }
22 |
23 | export default Expression
24 |
--------------------------------------------------------------------------------
/src/modules/editor/features/group/index.tsx:
--------------------------------------------------------------------------------
1 | import { useTranslation } from 'react-i18next'
2 | import { useSetAtom } from 'jotai'
3 | import { SelectionSlash } from '@phosphor-icons/react'
4 | import { Input } from '@/components/ui/input'
5 | import type { AST } from '@/parser'
6 | import Cell from '@/components/cell'
7 | import { updateGroupAtom } from '@/atom'
8 | import {
9 | Select,
10 | SelectContent,
11 | SelectGroup,
12 | SelectItem,
13 | SelectTrigger,
14 | SelectValue,
15 | } from '@/components/ui/select'
16 |
17 | type GroupSelectProps = {
18 | group: AST.Group
19 | }
20 | export const groupOptions = [
21 | {
22 | value: 'capturing',
23 | label: 'Capturing group',
24 | },
25 | {
26 | value: 'nonCapturing',
27 | label: 'Non-capturing group',
28 | },
29 | {
30 | value: 'namedCapturing',
31 | label: 'Named capturing group',
32 | },
33 | ]
34 |
35 | function GroupSelect({ group }: GroupSelectProps) {
36 | const { t } = useTranslation()
37 | const updateGroup = useSetAtom(updateGroupAtom)
38 | const { kind } = group
39 |
40 | const handleGroupChange = (kind: AST.GroupKind, name = '') => {
41 | let payload: AST.Group
42 | switch (kind) {
43 | case 'capturing':
44 | payload = { kind, name: '', index: 0 }
45 | break
46 | case 'namedCapturing':
47 | if (!name) {
48 | name = 'name'
49 | }
50 | payload = { kind, name, index: 0 }
51 | break
52 | case 'nonCapturing':
53 | payload = { kind: 'nonCapturing' }
54 | break
55 | }
56 | updateGroup(payload)
57 | }
58 |
59 | const handleGroupNameChange = (value: string) =>
60 | handleGroupChange(kind, value)
61 |
62 | const onSelectChange = (value: string) =>
63 | handleGroupChange(value as AST.GroupKind)
64 |
65 | const unGroup = () => updateGroup(null)
66 |
67 | return (
68 | |
72 | )}
73 | rightTooltip={t('UnGroup')}
74 | onRightIconClick={unGroup}
75 | className="space-y-2"
76 | >
77 |
91 | {group.kind === 'namedCapturing' && (
92 |
93 | {t('Group\'s name')}
94 |
99 |
100 | )}
101 |
102 | )
103 | }
104 |
105 | export default GroupSelect
106 |
--------------------------------------------------------------------------------
/src/modules/editor/features/look-around/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { useTranslation } from 'react-i18next'
3 | import { useSetAtom } from 'jotai'
4 | import { SelectionSlash } from '@phosphor-icons/react'
5 | import Cell from '@/components/cell'
6 | import { updateLookAroundAtom } from '@/atom'
7 | import {
8 | Select,
9 | SelectContent,
10 | SelectGroup,
11 | SelectItem,
12 | SelectTrigger,
13 | SelectValue,
14 | } from '@/components/ui/select'
15 | import { Checkbox } from '@/components/ui/checkbox'
16 |
17 | type Props = {
18 | kind: 'lookahead' | 'lookbehind'
19 | negate: boolean
20 | }
21 | const LookAround: React.FC = ({ kind, negate }) => {
22 | const { t } = useTranslation()
23 | const updateLookAround = useSetAtom(updateLookAroundAtom)
24 | const onKindChange = (value: string) =>
25 | updateLookAround({
26 | kind: value as 'lookahead' | 'lookbehind',
27 | negate,
28 | })
29 | const onNegateChange = (negate: boolean) => {
30 | updateLookAround({
31 | kind,
32 | negate,
33 | })
34 | }
35 | const unLookAround = () => updateLookAround(null)
36 |
37 | return (
38 | |
42 | )}
43 | rightTooltip={t('Cancel assertion')}
44 | onRightIconClick={unLookAround}
45 | >
46 |
47 |
65 |
66 |
74 |
75 |
76 |
77 | )
78 | }
79 |
80 | export default LookAround
81 |
--------------------------------------------------------------------------------
/src/modules/editor/index.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect } from 'react'
2 | import { useTranslation } from 'react-i18next'
3 | import { useAtomValue, useSetAtom } from 'jotai'
4 | import { useUpdateEffect } from 'react-use'
5 | import { useEventListener } from 'usehooks-ts'
6 | import clsx from 'clsx'
7 | import EditTab from './edit-tab'
8 | import LegendTab from './legend-tab'
9 | import TestTab from './test-tab'
10 | import { useCurrentState } from '@/utils/hooks'
11 | import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
12 | import {
13 | redoAtom,
14 | removeAtom,
15 | selectedIdsAtom,
16 | undoAtom,
17 | } from '@/atom'
18 | import { ScrollArea } from '@/components/ui/scroll-area'
19 | import {
20 | Tooltip,
21 | TooltipContent,
22 | TooltipProvider,
23 | TooltipTrigger,
24 | } from '@/components/ui/tooltip'
25 |
26 | export type Tab = 'legend' | 'edit' | 'test'
27 | type Props = {
28 | defaultTab: Tab
29 | collapsed: boolean
30 | }
31 | function Editor({ defaultTab, collapsed }: Props) {
32 | const selectedIds = useAtomValue(selectedIdsAtom)
33 | const remove = useSetAtom(removeAtom)
34 | const undo = useSetAtom(undoAtom)
35 | const redo = useSetAtom(redoAtom)
36 |
37 | const [tabValue, setTabValue, tabValueRef] = useCurrentState(defaultTab)
38 |
39 | const { t } = useTranslation()
40 |
41 | useUpdateEffect(() => {
42 | setTabValue(defaultTab)
43 | }, [defaultTab])
44 |
45 | useEffect(() => {
46 | if (selectedIds.length > 0 && tabValueRef.current !== 'edit') {
47 | setTabValue('edit')
48 | }
49 | if (selectedIds.length === 0 && tabValueRef.current === 'edit') {
50 | setTabValue('legend')
51 | }
52 | }, [selectedIds, tabValueRef, setTabValue])
53 |
54 | const editDisabled = selectedIds.length === 0
55 |
56 | useEventListener('keydown', (e: Event) => {
57 | const event = e as KeyboardEvent
58 | const tagName = (event.target as HTMLElement)?.tagName
59 | if (tagName === 'INPUT' || tagName === 'TEXTAREA') {
60 | return
61 | }
62 | const { key } = event
63 | if (key === 'Backspace' || key === 'Delete') {
64 | e.preventDefault()
65 | return remove()
66 | }
67 | const metaKey = event.ctrlKey || event.metaKey
68 | if (metaKey && event.shiftKey && key === 'z') {
69 | e.preventDefault()
70 | return redo()
71 | }
72 | if (metaKey && key === 'z') {
73 | e.preventDefault()
74 | return undo()
75 | }
76 | })
77 |
78 | return (
79 | setTabValue(value as Tab)}
82 | className={clsx('flex flex-col h-[calc(100vh-64px)] py-4 border-l transition-width', collapsed ? 'w-[0px]' : 'w-[305px]')}
83 | >
84 |
85 |
86 |
87 | {t('Legends')}
88 |
96 | {editDisabled
97 | ? (
98 |
99 | {t('Edit')}
100 |
101 | )
102 | : t('Edit')}
103 |
104 | {t('Test')}
105 |
106 |
107 |
108 |
109 |
110 |
111 |
112 |
113 |
114 |
115 |
116 |
117 |
118 |
119 |
120 | {t('You have to select nodes first')}
121 |
122 |
123 |
124 |
125 | )
126 | }
127 |
128 | export default Editor
129 |
--------------------------------------------------------------------------------
/src/modules/editor/legend-tab.tsx:
--------------------------------------------------------------------------------
1 | import { useTranslation } from 'react-i18next'
2 | import legends from './legends'
3 |
4 | function Legend() {
5 | const { t } = useTranslation()
6 | return (
7 |
8 | {/* TODO move the tip to the graph */}
9 | {/*
10 |
11 | {t('You can select nodes by dragging or clicking')}
12 |
*/}
13 | {legends.map(({ name, infos }) => (
14 |
15 |
{t(name)}
16 |
17 | {infos.map(({ Icon, desc }) => (
18 |
19 | {Icon}
20 | {t(desc)}
21 |
22 | ))}
23 |
24 |
25 | ))}
26 |
27 | )
28 | }
29 |
30 | export default Legend
31 |
--------------------------------------------------------------------------------
/src/modules/editor/legends.tsx:
--------------------------------------------------------------------------------
1 | import SimpleGraph from '@/modules/graph/simple-graph'
2 |
3 | const legends = [
4 | {
5 | name: 'Characters',
6 | infos: [
7 | {
8 | Icon: ,
9 | desc: 'Direct match characters',
10 | },
11 | ],
12 | },
13 | {
14 | name: 'Character classes',
15 | infos: [
16 | {
17 | Icon: ,
18 | desc: 'Distinguish different types of characters',
19 | },
20 | ],
21 | },
22 | {
23 | name: 'Ranges',
24 | infos: [
25 | {
26 | Icon: ,
27 | desc: 'Matches any one of the enclosed characters',
28 | },
29 | {
30 | Icon: ,
31 | desc: 'Matches anything that is not enclosed in the brackets',
32 | },
33 | ],
34 | },
35 | {
36 | name: 'Choice',
37 | infos: [
38 | {
39 | Icon: ,
40 | desc: `Matches either "x" or "y"`,
41 | },
42 | ],
43 | },
44 | {
45 | name: 'Quantifier',
46 | infos: [
47 | {
48 | Icon: ,
49 | desc: 'Indicate numbers of characters or expressions to match',
50 | },
51 | ],
52 | },
53 | {
54 | name: 'Group',
55 | infos: [
56 | {
57 | Icon: ,
58 | desc: 'Matches x and remembers the match',
59 | },
60 | {
61 | Icon: ,
62 | desc: `Matches "x" but does not remember the match`,
63 | },
64 | {
65 | Icon: ,
66 | desc: `Matches "x" and stores it on the groups property of the returned matches under the name specified by `,
67 | },
68 | ],
69 | },
70 | {
71 | name: 'Back reference',
72 | infos: [
73 | {
74 | Icon: ,
75 | desc: 'A back reference to match group #1',
76 | },
77 | {
78 | Icon: '} />,
79 | desc: `A back reference to match group #Name`,
80 | },
81 | ],
82 | },
83 | {
84 | name: 'Assertion',
85 | infos: [
86 | {
87 | Icon: ,
88 | desc: 'Matches the beginning of input',
89 | },
90 | {
91 | Icon: ,
92 | desc: `Matches "x" only if "x" is followed by "y"`,
93 | },
94 | ],
95 | },
96 | ]
97 |
98 | export default legends
99 |
--------------------------------------------------------------------------------
/src/modules/editor/test-tab.tsx:
--------------------------------------------------------------------------------
1 | import React, { useMemo, useState } from 'react'
2 | import { useTranslation } from 'react-i18next'
3 | import { useAtomValue } from 'jotai'
4 | import { useLocalStorage } from 'react-use'
5 | import produce from 'immer'
6 | import { useCopyToClipboard } from 'usehooks-ts'
7 | import { Link as LinkIcon, Plus as PlusIcon } from '@phosphor-icons/react'
8 | import { nanoid } from 'nanoid'
9 | import TestItem from '@/components/test-item'
10 | import { gen } from '@/parser'
11 | import { astAtom } from '@/atom'
12 | import { genPermalink } from '@/utils/helpers'
13 | import { STORAGE_TEST_CASES } from '@/constants'
14 | import { useToast } from '@/components/ui/use-toast'
15 | import { Button } from '@/components/ui/button'
16 |
17 | type Case = {
18 | value: string
19 | id: string
20 | }
21 |
22 | function TestTab() {
23 | const { t } = useTranslation()
24 | const [casesInStorages, setCasesInStorages] = useLocalStorage(STORAGE_TEST_CASES, [''])
25 | const [cases, setCases] = useState<{
26 | value: string
27 | id: string
28 | }[]>(() => casesInStorages?.map(value => ({ value, id: nanoid() })) ?? [])
29 |
30 | const ast = useAtomValue(astAtom)
31 | const regExp = useMemo(() => {
32 | const regex = gen(ast, { literal: false, escapeBackslash: false })
33 | return new RegExp(regex, ast.flags.join(''))
34 | }, [ast])
35 |
36 | const { toast } = useToast()
37 | const [, copy] = useCopyToClipboard()
38 |
39 | const saveCases = (cases: Case[]) => {
40 | setCases(cases)
41 | setCasesInStorages(cases.map(({ value }) => value))
42 | }
43 |
44 | const handleCopyPermalink = () => {
45 | const permalink = genPermalink(cases.map(({ value }) => value))
46 | copy(permalink)
47 | toast({ description: t('Permalink copied.') })
48 | }
49 |
50 | const handleChange = (value: string, index: number) => {
51 | saveCases(
52 | produce(cases!, (draft) => {
53 | draft[index].value = value
54 | }),
55 | )
56 | }
57 |
58 | const handleRemove = (index: number) => {
59 | saveCases(
60 | produce(cases!, (draft) => {
61 | draft.splice(index, 1)
62 | }),
63 | )
64 | }
65 |
66 | const handleAdd = () => {
67 | saveCases(
68 | produce(cases!, (draft) => {
69 | draft.push({
70 | value: '',
71 | id: nanoid(),
72 | })
73 | }),
74 | )
75 | }
76 |
77 | return (
78 |
79 |
80 | {cases!.map(({ value, id }, index) => (
81 |
82 | handleChange(value, index)}
86 | onRemove={() => handleRemove(index)}
87 | />
88 |
89 | ))}
90 |
91 |
92 |
95 |
98 |
99 |
100 | )
101 | }
102 |
103 | export default TestTab
104 |
--------------------------------------------------------------------------------
/src/modules/editor/utils.ts:
--------------------------------------------------------------------------------
1 | import type { AST } from '@/parser'
2 | import { checkQuantifier, genWithSelected } from '@/parser'
3 |
4 | export type NodesInfo = {
5 | id: string
6 | regex: string
7 | startIndex: number
8 | endIndex: number
9 | group: AST.Group | null
10 | lookAround: { kind: 'lookahead' | 'lookbehind', negate: boolean } | null
11 | content: AST.Content | null
12 | hasQuantifier: boolean
13 | quantifier: AST.Quantifier | null
14 | }
15 |
16 | export const genInitialNodesInfo = (): NodesInfo => ({
17 | regex: '',
18 | startIndex: 0,
19 | endIndex: 0,
20 | group: null,
21 | lookAround: null,
22 | content: null,
23 | hasQuantifier: false,
24 | quantifier: null,
25 | id: '',
26 | })
27 |
28 | const getGroupInfo = (nodes: AST.Node[]): AST.Group | null => {
29 | if (nodes.length !== 1) {
30 | return null
31 | }
32 | const node = nodes[0]
33 | if (node.type !== 'group') {
34 | return null
35 | }
36 | if (node.kind === 'namedCapturing' || node.kind === 'capturing') {
37 | return { kind: node.kind, name: node.name, index: node.index }
38 | }
39 | return { kind: node.kind }
40 | }
41 |
42 | const getContentInfo = (nodes: AST.Node[]): AST.Content | null => {
43 | if (nodes.length === 1) {
44 | const node = nodes[0]
45 | switch (node.type) {
46 | case 'character':
47 | if (node.kind === 'ranges') {
48 | return { kind: node.kind, ranges: node.ranges, negate: node.negate }
49 | }
50 | return { kind: node.kind, value: node.value }
51 | case 'backReference':
52 | return { kind: 'backReference', ref: node.ref }
53 | case 'boundaryAssertion':
54 | if (node.kind === 'word') {
55 | return { kind: 'wordBoundaryAssertion', negate: node.negate }
56 | } else if (node.kind === 'beginning') {
57 | return { kind: 'beginningAssertion' }
58 | } else {
59 | return { kind: 'endAssertion' }
60 | }
61 | }
62 | }
63 | return null
64 | }
65 |
66 | const getQuantifierInfo = (
67 | nodes: AST.Node[],
68 | ): { hasQuantifier: boolean, quantifier: AST.Quantifier | null } => {
69 | if (nodes.length === 1) {
70 | const node = nodes[0]
71 | if (checkQuantifier(node)) {
72 | return { hasQuantifier: true, quantifier: node.quantifier }
73 | }
74 | }
75 | return { hasQuantifier: false, quantifier: null }
76 | }
77 |
78 | const getLookAroundInfo = (
79 | nodes: AST.Node[],
80 | ): { kind: 'lookahead' | 'lookbehind', negate: boolean } | null => {
81 | if (nodes.length === 1) {
82 | const node = nodes[0]
83 | if (node.type === 'lookAroundAssertion') {
84 | const { kind, negate } = node
85 | return { kind, negate }
86 | }
87 | }
88 | return null
89 | }
90 |
91 | const getIdFromNodes = (nodes: AST.Node[]): string => {
92 | if (nodes.length === 1) {
93 | return nodes[0].id
94 | }
95 | return ''
96 | }
97 |
98 | export function getInfoFromNodes(ast: AST.Regex, nodes: AST.Node[]): NodesInfo {
99 | if (nodes.length === 0) {
100 | return genInitialNodesInfo()
101 | }
102 | const { regex, startIndex, endIndex } = genWithSelected(ast, [
103 | nodes[0].id,
104 | nodes[nodes.length - 1].id,
105 | ])
106 | const group = getGroupInfo(nodes)
107 | const content = getContentInfo(nodes)
108 | const quantifierInfo = getQuantifierInfo(nodes)
109 | const lookAround = getLookAroundInfo(nodes)
110 | const id = getIdFromNodes(nodes)
111 | return {
112 | id,
113 | regex,
114 | startIndex,
115 | endIndex,
116 | group,
117 | content,
118 | lookAround,
119 | ...quantifierInfo,
120 | }
121 | }
122 |
--------------------------------------------------------------------------------
/src/modules/graph/choice.tsx:
--------------------------------------------------------------------------------
1 | import React, { useMemo } from 'react'
2 | import { useAtomValue } from 'jotai'
3 | import Nodes from './nodes'
4 | import StartConnect from './start-connect'
5 | import EndConnect from './end-connect'
6 | import Content from './content'
7 | import { DEFAULT_SIZE } from './measure'
8 | import { sizeMapAtom } from '@/atom'
9 | import {
10 | GRAPH_CHOICE_PADDING_VERTICAL,
11 | GRAPH_NODE_MARGIN_VERTICAL,
12 | } from '@/constants'
13 | import type { AST } from '@/parser'
14 |
15 | type Props = {
16 | x: number
17 | y: number
18 | node: AST.ChoiceNode
19 | selected: boolean
20 | }
21 |
22 | const ChoiceNode = React.memo(({ x, y, selected, node }: Props) => {
23 | const { id, branches } = node
24 | const sizeMap = useAtomValue(sizeMapAtom)
25 |
26 | const boxSize = useMemo(
27 | () => (sizeMap.get(node) || DEFAULT_SIZE).box,
28 | [node, sizeMap],
29 | )
30 | const boxes = useMemo(() => {
31 | let curY = y + GRAPH_CHOICE_PADDING_VERTICAL
32 |
33 | return branches.map((branch) => {
34 | if (!sizeMap.has(branch)) {
35 | return { x: 0, y: 0, width: 0, height: 0 }
36 | }
37 |
38 | const [branchWidth, branchHeight] = sizeMap.get(branch)!.box
39 | const branchX = x + (boxSize[0] - branchWidth) / 2
40 | const branchY = curY
41 | curY += branchHeight + GRAPH_NODE_MARGIN_VERTICAL
42 | return {
43 | width: branchWidth,
44 | height: branchHeight,
45 | x: branchX,
46 | y: branchY,
47 | }
48 | })
49 | }, [branches, x, y, sizeMap, boxSize])
50 | return (
51 |
60 | {branches.map((branch, index) => {
61 | const {
62 | x: branchX,
63 | y: branchY,
64 | width: branchWidth,
65 | height: branchHeight,
66 | } = boxes[index]
67 | return (
68 |
69 |
73 |
81 |
85 |
86 | )
87 | })}
88 |
89 | )
90 | })
91 | ChoiceNode.displayName = 'ChoiceName'
92 | export default ChoiceNode
93 |
--------------------------------------------------------------------------------
/src/modules/graph/content.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { useAtomValue, useSetAtom } from 'jotai'
3 | import { isPrimaryGraphAtom, selectNodeAtom } from '@/atom'
4 | import { GRAPH_NODE_BORDER_RADIUS } from '@/constants'
5 |
6 | type Props = { id: string, selected: boolean } & React.ComponentProps<'rect'>
7 |
8 | function Content({ id, selected, children, ...restProps }: Props) {
9 | const selectNode = useSetAtom(selectNodeAtom)
10 | const isPrimaryGraph = useAtomValue(isPrimaryGraphAtom)
11 | const handleClick = (e: React.MouseEvent) => {
12 | e.stopPropagation()
13 | if (isPrimaryGraph) {
14 | selectNode(id)
15 | }
16 | }
17 | return (
18 |
19 |
20 | {selected && (
21 |
27 |
28 | )}
29 | {children}
30 |
31 | )
32 | }
33 |
34 | Content.displayName = 'Content'
35 | export default Content
36 |
--------------------------------------------------------------------------------
/src/modules/graph/end-connect.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import MidConnect from './mid-connect'
3 |
4 | type Props = {
5 | start: [number, number]
6 | end: [number, number]
7 | }
8 | const EndConnect: React.FC = React.memo((props) => {
9 | const { start, end } = props
10 | if (Math.abs(start[1] - end[1]) < 0.5) {
11 | return
12 | }
13 | const M = `M${start[0]},${start[1]}`
14 | const L1 = `L${end[0] - 20},${start[1]}`
15 | const L3 = `L${end[0]},${end[1]}`
16 | let A1 = ''
17 | let L2 = ''
18 | let A2 = ''
19 | if (end[1] > start[1]) {
20 | A1 = `A5 5 0 0 1, ${end[0] - 15},${start[1] + 5}`
21 | L2 = `L${end[0] - 15},${end[1] - 5}`
22 | A2 = `A5 5 0 0 0, ${end[0] - 10},${end[1]}`
23 | } else {
24 | A1 = `A5 5 0 0 0, ${end[0] - 15},${start[1] - 5}`
25 | L2 = `L${end[0] - 15},${end[1] + 5}`
26 | A2 = `A5 5 0 0 1, ${end[0] - 10},${end[1]}`
27 | }
28 | const path = M + L1 + A1 + L2 + A2 + L3
29 | return
30 | })
31 | EndConnect.displayName = 'EndConnect'
32 |
33 | export default EndConnect
34 |
--------------------------------------------------------------------------------
/src/modules/graph/group-like.tsx:
--------------------------------------------------------------------------------
1 | import { useAtomValue } from 'jotai'
2 | import { NameAndQuantifier } from './name-quantifier'
3 | import Nodes from './nodes'
4 | import MidConnect from './mid-connect'
5 | import Content from './content'
6 | import { useSize } from './utils'
7 | import { sizeMapAtom } from '@/atom'
8 | import {
9 | GRAPH_GROUP_NODE_PADDING_VERTICAL,
10 | GRAPH_NODE_BORDER_RADIUS,
11 | GRAPH_NODE_MARGIN_HORIZONTAL,
12 | } from '@/constants'
13 | import type { AST } from '@/parser'
14 |
15 | type Props = {
16 | x: number
17 | y: number
18 | node: AST.Node
19 | selected: boolean
20 | }
21 |
22 | function GroupLikeNode({ x, y, node, selected }: Props) {
23 | const sizeMap = useAtomValue(sizeMapAtom)
24 | const size = useSize(node, sizeMap)
25 | const { box: boxSize, content: contentSize } = size
26 |
27 | if (node.type !== 'group' && node.type !== 'lookAroundAssertion') {
28 | return null
29 | }
30 |
31 | const { id, children: nodeChildren } = node
32 | const centerY = y + boxSize[1] / 2
33 | const contentX = x + (boxSize[0] - contentSize[0]) / 2
34 | const contentY = y + (boxSize[1] - contentSize[1]) / 2
35 | return (
36 | <>
37 |
38 |
49 | {nodeChildren.length > 0 && (
50 | <>
51 |
55 |
62 | >
63 | )}
64 |
71 |
72 | >
73 | )
74 | }
75 | GroupLikeNode.displayName = 'GroupLikeGroup'
76 | export default GroupLikeNode
77 |
--------------------------------------------------------------------------------
/src/modules/graph/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { useSetAtom } from 'jotai'
3 | import { ExclamationTriangleIcon } from '@radix-ui/react-icons'
4 | import ASTGraph from './ast-graph'
5 | import type { AST } from '@/parser'
6 | import { selectNodesByBoxAtom } from '@/atom'
7 | import { useDragSelect } from '@/utils/hooks'
8 | import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
9 |
10 | type Props = {
11 | regex: string
12 | ast: AST.Regex
13 | errorMsg?: string | null
14 | }
15 |
16 | const Graph: React.FC = ({ ast, errorMsg = null }) => {
17 | const selectNodesByBox = useSetAtom(selectNodesByBoxAtom)
18 |
19 | const [bindings, Selection] = useDragSelect({
20 | disabled: !!errorMsg,
21 | className: 'rounded bg-blue-500/50 border border-blue-500',
22 | onSelect: box => selectNodesByBox(box),
23 | })
24 |
25 | return (
26 |
27 | {errorMsg
28 | ? (
29 |
30 |
31 | Error
32 |
33 | {errorMsg}
34 |
35 |
36 | )
37 | : (
38 | <>
39 | {ast.body.length > 0 &&
}
40 | {Selection}
41 | >
42 | )}
43 |
44 | )
45 | }
46 |
47 | export default Graph
48 |
--------------------------------------------------------------------------------
/src/modules/graph/mid-connect.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 |
3 | type Props = {
4 | start: [number, number]
5 | end: [number, number]
6 | }
7 | const MidConnect: React.FC = React.memo((props) => {
8 | const { start, end } = props
9 | const path = `M${start[0]},${start[1]}L${end[0]},${end[1]}`
10 | return
11 | })
12 | MidConnect.displayName = 'MidConnect'
13 |
14 | export default MidConnect
15 |
--------------------------------------------------------------------------------
/src/modules/graph/name-quantifier.tsx:
--------------------------------------------------------------------------------
1 | import { useTranslation } from 'react-i18next'
2 | import QuantifierNode from './quantifier'
3 | import MidConnect from './mid-connect'
4 | import { getNameText } from './utils'
5 | import type { NodeSize } from './measure'
6 | import {
7 | GRAPH_NAME_HEIGHT,
8 | GRAPH_NAME_TEXT_FONTSIZE,
9 | GRAPH_QUANTIFIER_HEIGHT,
10 | GRAPH_QUANTIFIER_TEXT_FONTSIZE,
11 | } from '@/constants'
12 | import type { AST } from '@/parser'
13 | import { getQuantifier } from '@/parser'
14 |
15 | type Props = {
16 | node: AST.Node
17 | x: number
18 | y: number
19 | size: NodeSize
20 | }
21 | // name
22 | // --------
23 | // | content |
24 | // --------
25 | // quantifier
26 | export function NameAndQuantifier(props: Props) {
27 | const { t } = useTranslation()
28 | const { x, y, node, size } = props
29 | const quantifier = getQuantifier(node)
30 | const name = getNameText(node, t)
31 | const { box: boxSize, content: contentSize } = size
32 |
33 | const contentX = x + (boxSize[0] - contentSize[0]) / 2
34 | const contentY = y + (boxSize[1] - contentSize[1]) / 2
35 | const centerY = y + boxSize[1] / 2
36 | return (
37 | <>
38 | {contentX !== x && (
39 | <>
40 |
41 |
45 | >
46 | )}
47 | {name && (
48 |
55 | {name}
56 |
57 | )}
58 | {quantifier && (
59 |
66 |
67 |
68 | )}
69 | >
70 | )
71 | }
72 |
--------------------------------------------------------------------------------
/src/modules/graph/nodes.tsx:
--------------------------------------------------------------------------------
1 | import type { ReactNode } from 'react'
2 | import React, { useEffect, useMemo } from 'react'
3 | import { useAtomValue } from 'jotai'
4 | import ChoiceNode from './choice'
5 | import SimpleNode from './simple-node'
6 | import GroupLikeNode from './group-like'
7 | import MidConnect from './mid-connect'
8 | import { DEFAULT_SIZE } from './measure'
9 | import { useSize } from './utils'
10 | import {
11 | isPrimaryGraphAtom,
12 | nodesBoxMap,
13 | selectedIdsAtom,
14 | sizeMapAtom,
15 | } from '@/atom'
16 | import { GRAPH_NODE_MARGIN_HORIZONTAL } from '@/constants'
17 | import type * as AST from '@/parser/ast'
18 |
19 | type Props = {
20 | id: string
21 | index: number
22 | x: number
23 | y: number
24 | nodes: AST.Node[]
25 | }
26 |
27 | const Nodes = React.memo(({ id, index, x, y, nodes }: Props) => {
28 | const sizeMap = useAtomValue(sizeMapAtom)
29 | const selectedIds = useAtomValue(selectedIdsAtom)
30 | const isPrimaryGraph = useAtomValue(isPrimaryGraphAtom)
31 | const [, boxHeight] = useSize(nodes, sizeMap).box
32 |
33 | const boxes = useMemo(() => {
34 | let curX = x
35 | return nodes.map((node) => {
36 | const [nodeWidth, nodeHeight] = (sizeMap.get(node) || DEFAULT_SIZE).box
37 | const nodeX = curX
38 | const nodeY = y + (boxHeight - nodeHeight) / 2
39 | curX += nodeWidth + GRAPH_NODE_MARGIN_HORIZONTAL
40 | return {
41 | x1: nodeX,
42 | y1: nodeY,
43 | x2: nodeX + nodeWidth,
44 | y2: nodeY + nodeHeight,
45 | }
46 | })
47 | }, [boxHeight, x, y, nodes, sizeMap])
48 |
49 | const contentBoxes = useMemo(() => {
50 | let curX = x
51 | return nodes.map((node) => {
52 | const { box: boxSize, content: contentSize }
53 | = sizeMap.get(node) || DEFAULT_SIZE
54 | const nodeX = curX + (boxSize[0] - contentSize[0]) / 2
55 | const nodeY = y + (boxHeight - contentSize[1]) / 2
56 | curX += boxSize[0] + GRAPH_NODE_MARGIN_HORIZONTAL
57 | return {
58 | x1: nodeX,
59 | y1: nodeY,
60 | x2: nodeX + contentSize[0],
61 | y2: nodeY + contentSize[1],
62 | }
63 | })
64 | }, [boxHeight, x, y, nodes, sizeMap])
65 |
66 | useEffect(() => {
67 | if (isPrimaryGraph) {
68 | nodesBoxMap.set(`${id}-${index}`, contentBoxes)
69 | }
70 | return () => {
71 | nodesBoxMap.delete(`${id}-${index}`)
72 | }
73 | }, [index, id, contentBoxes, isPrimaryGraph])
74 |
75 | const startSelectedIndex = useMemo(
76 | () => nodes.findIndex(node => node.id === selectedIds[0]),
77 | [selectedIds, nodes],
78 | )
79 |
80 | const connectY = y + boxHeight / 2
81 |
82 | return (
83 | <>
84 | {nodes.map((node, index) => {
85 | const { id } = node
86 | const box = boxes[index]
87 | const selected
88 | = startSelectedIndex >= 0
89 | && index >= startSelectedIndex
90 | && index < startSelectedIndex + selectedIds.length
91 | let Node: ReactNode = null
92 | switch (node.type) {
93 | case 'choice':
94 | Node = (
95 |
101 | )
102 | break
103 | case 'group':
104 | case 'lookAroundAssertion':
105 | Node = (
106 |
112 | )
113 | break
114 | case 'root':
115 | Node = null
116 | break
117 | default:
118 | Node = (
119 |
125 | )
126 | }
127 | const Connect = index >= 1 && (
128 |
132 | )
133 | return (
134 |
135 | {Connect}
136 | {Node}
137 |
138 | )
139 | })}
140 | >
141 | )
142 | })
143 | Nodes.displayName = 'Nodes'
144 |
145 | export default Nodes
146 |
--------------------------------------------------------------------------------
/src/modules/graph/quantifier.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { Infinity as InfinityIcon } from '@phosphor-icons/react'
3 | import { getQuantifierText } from './utils'
4 | import type { AST } from '@/parser'
5 | import { GRAPH_ICON_SIZE } from '@/constants'
6 |
7 | type Props = {
8 | quantifier: AST.Quantifier
9 | }
10 |
11 | const QuantifierNode = React.memo((props: Props) => {
12 | const { quantifier } = props
13 |
14 | const hasInfinity = quantifier.max === Infinity
15 | const text = getQuantifierText(quantifier)
16 |
17 | return (
18 |
19 |
38 |
{text}
39 | {hasInfinity &&
}
40 |
41 | )
42 | })
43 | QuantifierNode.displayName = 'QuantifierNode'
44 | export default QuantifierNode
45 |
--------------------------------------------------------------------------------
/src/modules/graph/root-nodes.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import MidConnect from './mid-connect'
3 | import { GRAPH_NODE_MARGIN_HORIZONTAL, GRAPH_ROOT_RADIUS } from '@/constants'
4 |
5 | type RootNodeProps = {
6 | cx: number
7 | cy: number
8 | }
9 | const RootNode = React.memo(({ cx, cy }: RootNodeProps) => (
10 |
16 | ))
17 | RootNode.displayName = 'RootNode'
18 |
19 | type RootNodesProps = {
20 | x: number
21 | width: number
22 | centerY: number
23 | }
24 | const RootNodes = React.memo(({ x, width, centerY }: RootNodesProps) => (
25 | <>
26 |
27 |
31 |
38 |
39 | >
40 | ))
41 | RootNodes.displayName = 'RootNodes'
42 |
43 | export default RootNodes
44 |
--------------------------------------------------------------------------------
/src/modules/graph/simple-graph.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import type { Atom } from 'jotai'
3 | import { Provider } from 'jotai'
4 | import { useHydrateAtoms } from 'jotai/utils'
5 | import ASTGraph from './ast-graph'
6 | import { isPrimaryGraphAtom, sizeMapAtom } from '@/atom'
7 | import { parse } from '@/parser'
8 |
9 | type Props = {
10 | regex: string
11 | }
12 |
13 | const initialValues: (readonly [Atom, unknown])[] = [
14 | [sizeMapAtom, new Map()],
15 | [isPrimaryGraphAtom, false],
16 | ]
17 |
18 | function HydrateAtoms({ initialValues, children }: { initialValues: any, children: React.ReactNode }) {
19 | // initialising on state with prop on render here
20 | useHydrateAtoms(initialValues)
21 | return children
22 | }
23 |
24 | const SimpleGraph = React.memo(({ regex }: Props) => {
25 | const ast = parse(regex)
26 | if (ast.type === 'error') {
27 | return null
28 | }
29 | return (
30 |
31 |
32 |
33 |
34 |
35 | )
36 | })
37 |
38 | export default SimpleGraph
39 |
--------------------------------------------------------------------------------
/src/modules/graph/simple-node.tsx:
--------------------------------------------------------------------------------
1 | import { useAtomValue } from 'jotai'
2 | import { NameAndQuantifier } from './name-quantifier'
3 | import Content from './content'
4 | import TextNode from './text'
5 | import { useSize } from './utils'
6 | import { sizeMapAtom } from '@/atom'
7 | import {
8 | GRAPH_NODE_BORDER_RADIUS,
9 | GRAPH_NODE_PADDING_HORIZONTAL,
10 | GRAPH_NODE_PADDING_VERTICAL,
11 | GRAPH_TEXT_FONT_SIZE,
12 | } from '@/constants'
13 | import type { AST } from '@/parser'
14 |
15 | type Props = {
16 | x: number
17 | y: number
18 | node:
19 | | AST.CharacterNode
20 | | AST.BackReferenceNode
21 | | AST.BeginningBoundaryAssertionNode
22 | | AST.EndBoundaryAssertionNode
23 | | AST.WordBoundaryAssertionNode
24 | selected: boolean
25 | }
26 |
27 | function SimpleNode({ x, y, node, selected }: Props) {
28 | const sizeMap = useAtomValue(sizeMapAtom)
29 | const size = useSize(node, sizeMap)
30 | const { box: boxSize, content: contentSize } = size
31 | const contentX = x + (boxSize[0] - contentSize[0]) / 2
32 | const contentY = y + (boxSize[1] - contentSize[1]) / 2
33 | return (
34 | <>
35 |
36 |
48 |
55 |
56 |
57 |
58 | >
59 | )
60 | }
61 |
62 | SimpleNode.displayName = 'SimpleNode'
63 | export default SimpleNode
64 |
--------------------------------------------------------------------------------
/src/modules/graph/start-connect.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import MidConnect from './mid-connect'
3 |
4 | type Props = {
5 | start: [number, number]
6 | end: [number, number]
7 | }
8 | const StartConnect: React.FC = React.memo((props) => {
9 | const { start, end } = props
10 | if (Math.abs(start[1] - end[1]) < 0.5) {
11 | return
12 | }
13 | const M = `M${start[0]},${start[1]}`
14 | const L1 = `L${start[0] + 10},${start[1]}`
15 | const L3 = `L${end[0]},${end[1]}`
16 | let A1 = ''
17 | let L2 = ''
18 | let A2 = ''
19 | if (end[1] > start[1]) {
20 | A1 = `A5 5 0 0 1, ${start[0] + 15},${start[1] + 5}`
21 | L2 = `L${start[0] + 15},${end[1] - 5}`
22 | A2 = `A5 5 0 0 0, ${start[0] + 20},${end[1]}`
23 | } else {
24 | A1 = `A5 5 0 0 0, ${start[0] + 15},${start[1] - 5}`
25 | L2 = `L${start[0] + 15},${end[1] + 5}`
26 | A2 = `A5 5 0 0 1, ${start[0] + 20},${end[1]}`
27 | }
28 | const path = M + L1 + A1 + L2 + A2 + L3
29 | return
30 | })
31 | StartConnect.displayName = 'StartConnect'
32 |
33 | export default StartConnect
34 |
--------------------------------------------------------------------------------
/src/modules/graph/utils.ts:
--------------------------------------------------------------------------------
1 | import { useMemo } from 'react'
2 | import type { TFunction } from 'react-i18next'
3 | import type { NodeSize } from './measure'
4 | import { DEFAULT_SIZE } from './measure'
5 | import type { AST, CharacterClassKey } from '@/parser'
6 | import { characterClassTextMap } from '@/parser'
7 |
8 | const assertionTextMap = {
9 | beginning: 'Begins with',
10 | end: 'Ends with',
11 | lookahead: ['Followed by:', 'Not followed by:'],
12 | lookbehind: ['Preceded by:', 'Not preceded by:'],
13 | word: ['WordBoundary', 'NonWordBoundary'],
14 | }
15 |
16 | export const getNameText = (node: AST.Node, t: TFunction): string | null => {
17 | switch (node.type) {
18 | case 'character':
19 | if (node.kind === 'ranges') {
20 | return t(node.negate ? 'None of' : 'One of')
21 | }
22 | break
23 | case 'group':
24 | if (node.kind === 'capturing' || node.kind === 'namedCapturing') {
25 | return `${t('Group')} #${node.name}`
26 | }
27 | break
28 | case 'lookAroundAssertion': {
29 | const { kind, negate } = node
30 | return t(assertionTextMap[kind][negate ? 1 : 0])
31 | }
32 | default:
33 | break
34 | }
35 | return null
36 | }
37 |
38 | export const getBackReferenceText = (
39 | node: AST.BackReferenceNode,
40 | t: TFunction,
41 | ) => `${t('Back reference')} #${node.ref}`
42 |
43 | export const tryCharacterClassText = (key: string): [string, boolean] => {
44 | if (key === '') {
45 | return ['Empty', true]
46 | } else if (key in characterClassTextMap) {
47 | return [characterClassTextMap[key as CharacterClassKey], true]
48 | } else {
49 | return [`"${key}"`, false]
50 | }
51 | }
52 |
53 | export const getBoundaryAssertionText = (
54 | node:
55 | | AST.BeginningBoundaryAssertionNode
56 | | AST.EndBoundaryAssertionNode
57 | | AST.WordBoundaryAssertionNode,
58 | t: TFunction,
59 | ) => {
60 | let text = ''
61 | if (node.kind === 'word') {
62 | const negate = node.negate
63 | text = assertionTextMap.word[negate ? 1 : 0]
64 | } else {
65 | const kind = node.kind
66 | text = assertionTextMap[kind]
67 | }
68 | return t(text)
69 | }
70 |
71 | export const useSize = (
72 | node: AST.Node | AST.Node[],
73 | sizeMap: Map,
74 | ) => useMemo(() => sizeMap.get(node) || DEFAULT_SIZE, [node, sizeMap])
75 |
76 | export const getQuantifierText = (quantifier: AST.Quantifier): string => {
77 | const { min, max } = quantifier
78 | const minText = min
79 | const maxText = max === Infinity ? '' : max
80 | if (min === max) {
81 | return ` ${minText}`
82 | }
83 | return ` ${minText} - ${maxText}`
84 | }
85 |
--------------------------------------------------------------------------------
/src/modules/home/regex-input.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { useTranslation } from 'react-i18next'
3 | import { Link2Icon } from '@radix-ui/react-icons'
4 | import clsx from 'clsx'
5 | import { Input } from '@/components/ui/input'
6 | import {
7 | Tooltip,
8 | TooltipContent,
9 | TooltipProvider,
10 | TooltipTrigger,
11 | } from '@/components/ui/tooltip'
12 | import { Button } from '@/components/ui/button'
13 | import { CheckboxGroup, CheckboxItem } from '@/components/ui/checkbox-group'
14 |
15 | type Props = {
16 | regex: string
17 | flags: string[]
18 | literal: boolean
19 | onChange: (regex: string) => void
20 | onFlagsChange: (flags: string[]) => void
21 | onCopy: () => void
22 | className?: string
23 | }
24 |
25 | const FLAGS = [{
26 | value: 'g',
27 | label: 'Global search',
28 | }, {
29 | value: 'i',
30 | label: 'Case-insensitive',
31 | }, {
32 | value: 'm',
33 | label: 'Multi-line',
34 | }, {
35 | value: 's',
36 | label: 'Allows . to match newline',
37 | }]
38 |
39 | const RegexInput: React.FC = ({
40 | regex,
41 | flags,
42 | literal,
43 | onChange,
44 | onFlagsChange,
45 | onCopy,
46 | className,
47 | }) => {
48 | const { t } = useTranslation()
49 | const handleFlagsChange = (flags: string[]) => {
50 | onFlagsChange(flags)
51 | }
52 | const flagStr = flags.join('')
53 | const flagShow = !literal && flagStr
54 |
55 | const handleKeyDown = (e: React.KeyboardEvent) => {
56 | e.stopPropagation()
57 | }
58 | return (
59 |
60 |
61 |
62 |
70 | {flagShow &&
{flagStr}}
71 |
72 |
73 |
74 |
81 |
82 |
83 | {t('Copy permalink')}
84 |
85 |
86 |
87 |
88 | {regex !== '' && (
89 |
90 |
91 |
95 | {FLAGS.map(({ value, label }) => {
96 | return (
97 |
106 | )
107 | })}
108 |
109 |
110 | )}
111 |
112 |
113 | )
114 | }
115 | export default RegexInput
116 |
--------------------------------------------------------------------------------
/src/modules/playground/index.tsx:
--------------------------------------------------------------------------------
1 | import type { AST } from '@/parser'
2 | import { parse } from '@/parser'
3 | import ASTGraph from '@/modules/graph/ast-graph'
4 |
5 | const r = '[a-z]'
6 | const ast = parse(r) as AST.Regex
7 |
8 | const Playground = () => {
9 | return (
10 |
11 | )
12 | }
13 | export default Playground
14 |
--------------------------------------------------------------------------------
/src/modules/samples/index.tsx:
--------------------------------------------------------------------------------
1 | import { Link } from 'react-router-dom'
2 | import { useTranslation } from 'react-i18next'
3 | import SimpleGraph from '@/modules/graph/simple-graph'
4 | import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area'
5 |
6 | const samples = [
7 | { desc: '1. Whole Numbers', label: '/^\\d+$/', regex: '^\\d+$' },
8 | {
9 | desc: '2. Decimal Numbers',
10 | label: '/^\\d*\\.\\d+$/',
11 | regex: '^\\d*\\.\\d+$',
12 | },
13 | {
14 | desc: '3. Whole + Decimal Numbers',
15 | label: '/^\\d*(\\.\\d+)?$/',
16 | regex: '^\\d*(\\.\\d+)?$',
17 | },
18 | {
19 | desc: '4. Negative, Positive Whole + Decimal Numbers',
20 | label: '/^-?\\d*(\\.\\d+)?$/',
21 | regex: '^-?\\d*(\\.\\d+)?$',
22 | },
23 | {
24 | desc: '5. Url',
25 | label:
26 | '/^https?:\\/\\/(www\\.)?[-a-zA-Z0-9@:%._\\+~#=]{2,256}\\.[a-z]{2,6}\\b([-a-zA-Z0-9@:%_\\+.~#()?&//=]*)$/',
27 | regex:
28 | '^https?:\\/\\/(www\\.)?[-a-zA-Z0-9@:%._\\+~#=]{2,256}\\.[a-z]{2,6}\\b([-a-zA-Z0-9@:%_\\+.~#()?&//=]*)$',
29 | },
30 | {
31 | desc: '6. Date Format YYYY-MM-dd',
32 | label: '/^[12]\\d{3}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])$/',
33 | regex: '^[12]\\d{3}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])$',
34 | },
35 | ]
36 | function Samples() {
37 | const { t } = useTranslation()
38 | return (
39 |
40 |
41 |
42 | {samples.map(({ desc, label, regex }) => {
43 | const linkTo = `/?r=${encodeURIComponent(`/${regex}/`)}`
44 | return (
45 |
46 |
47 | {t(desc)}
48 | :
49 | {label}
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 | )
59 | })}
60 |
61 |
62 |
63 | )
64 | }
65 |
66 | export default Samples
67 |
--------------------------------------------------------------------------------
/src/parser/__tests__/backslash.test.ts:
--------------------------------------------------------------------------------
1 | import { expect, it } from 'vitest'
2 | import { removeBackslash } from '../backslash'
3 |
4 | it('remove backslash', () => {
5 | expect(removeBackslash('\\n')).toBe('\\n')
6 | expect(removeBackslash('\\\\n')).toBe('\\n')
7 | expect(removeBackslash('\\.')).toBe('.')
8 | expect(removeBackslash('\\\\.')).toBe('\\.')
9 | expect(() => removeBackslash('\\')).toThrow('Invalid escape sequence')
10 | })
11 |
--------------------------------------------------------------------------------
/src/parser/__tests__/flag.test.ts:
--------------------------------------------------------------------------------
1 | import { expect, it } from 'vitest'
2 | import parse from '../parse'
3 | import type * as AST from '../ast'
4 |
5 | it('parse should return error when receiving invalid flags', () => {
6 | const expected = {
7 | type: 'error',
8 | message: `Invalid flags supplied to RegExp constructor 'z'`,
9 | }
10 | const result = parse('/(?:)/z', { idGenerator: () => '' })
11 | expect(result).toEqual(expected)
12 | })
13 |
14 | it('parse should return correct ast when receiving flags', () => {
15 | const expected: AST.Regex = {
16 | id: '',
17 | type: 'regex',
18 | body: [
19 | {
20 | id: '',
21 | type: 'group',
22 | kind: 'nonCapturing',
23 | children: [],
24 | quantifier: null,
25 | },
26 | ],
27 | flags: ['g'],
28 | literal: true,
29 | escapeBackslash: false,
30 | }
31 | const result = parse('/(?:)/g', { idGenerator: () => '' })
32 | expect(result).toEqual(expected)
33 | })
34 |
--------------------------------------------------------------------------------
/src/parser/ast.ts:
--------------------------------------------------------------------------------
1 | export type Regex = {
2 | id: string
3 | type: 'regex'
4 | body: Node[]
5 | flags: Flag[]
6 | literal: boolean
7 | escapeBackslash: boolean
8 | }
9 |
10 | export type RegexError = {
11 | type: 'error'
12 | message: string
13 | }
14 |
15 | export type Flag = 'd' | 'g' | 'i' | 'm' | 's' | 'u' | 'y'
16 |
17 | export type Quantifier =
18 | | {
19 | kind: '?'
20 | min: 0
21 | max: 1
22 | greedy: boolean
23 | }
24 | | {
25 | kind: '*'
26 | min: 0
27 | max: typeof Infinity
28 | greedy: boolean
29 | }
30 | | {
31 | kind: '+'
32 | min: 1
33 | max: typeof Infinity
34 | greedy: boolean
35 | }
36 | | {
37 | kind: 'custom'
38 | min: number
39 | max: number
40 | greedy: boolean
41 | }
42 |
43 | export type NodeBase = {
44 | id: string
45 | type: string
46 | }
47 |
48 | export type StringCharacterNode = {
49 | type: 'character'
50 | kind: 'string'
51 | value: string
52 | quantifier: Quantifier | null
53 | } & NodeBase
54 |
55 | export type Range = {
56 | id: string
57 | from: string
58 | to: string
59 | }
60 | export type RangesCharacterNode = {
61 | type: 'character'
62 | kind: 'ranges'
63 | ranges: Range[]
64 | negate: boolean
65 | quantifier: Quantifier | null
66 | } & NodeBase
67 |
68 | export type ClassCharacterNode = {
69 | type: 'character'
70 | kind: 'class'
71 | value: string
72 | quantifier: Quantifier | null
73 | } & NodeBase
74 |
75 | export type RangesCharacter = {
76 | kind: 'ranges'
77 | ranges: Range[]
78 | negate: boolean
79 | }
80 |
81 | export type CharacterNode =
82 | | StringCharacterNode
83 | | RangesCharacterNode
84 | | ClassCharacterNode
85 |
86 | export type GroupKind = 'capturing' | 'nonCapturing' | 'namedCapturing'
87 |
88 | export type Group =
89 | | {
90 | kind: 'nonCapturing'
91 | }
92 | | { kind: 'namedCapturing' | 'capturing', name: string, index: number }
93 |
94 | export type NonCapturingGroupNode = {
95 | type: 'group'
96 | kind: 'nonCapturing'
97 | children: Node[]
98 | quantifier: Quantifier | null
99 | } & NodeBase
100 |
101 | export type NamedCapturingGroupNode = {
102 | type: 'group'
103 | kind: 'namedCapturing'
104 | children: Node[]
105 | name: string
106 | index: number
107 | quantifier: Quantifier | null
108 | } & NodeBase
109 |
110 | export type CapturingGroupNode = {
111 | type: 'group'
112 | kind: 'capturing'
113 | children: Node[]
114 | name: string
115 | index: number
116 | quantifier: Quantifier | null
117 | } & NodeBase
118 |
119 | export type GroupNode =
120 | | NonCapturingGroupNode
121 | | NamedCapturingGroupNode
122 | | CapturingGroupNode
123 |
124 | export type ChoiceNode = {
125 | type: 'choice'
126 | branches: Node[][]
127 | } & NodeBase
128 |
129 | export type BeginningBoundaryAssertionNode = {
130 | type: 'boundaryAssertion'
131 | kind: 'beginning'
132 | } & NodeBase
133 | export type EndBoundaryAssertionNode = {
134 | type: 'boundaryAssertion'
135 | kind: 'end'
136 | } & NodeBase
137 |
138 | export type WordBoundaryAssertionNode = {
139 | type: 'boundaryAssertion'
140 | kind: 'word'
141 | negate: boolean
142 | } & NodeBase
143 |
144 | export type LookAround = {
145 | kind: 'lookahead' | 'lookbehind'
146 | negate: boolean
147 | }
148 | export type LookAroundAssertionNode = {
149 | type: 'lookAroundAssertion'
150 | kind: 'lookahead' | 'lookbehind'
151 | negate: boolean
152 | children: Node[]
153 | } & NodeBase
154 |
155 | export type AssertionNode =
156 | | BeginningBoundaryAssertionNode
157 | | EndBoundaryAssertionNode
158 | | WordBoundaryAssertionNode
159 | | LookAroundAssertionNode
160 |
161 | export type BackReferenceNode = {
162 | type: 'backReference'
163 | ref: string
164 | quantifier: Quantifier | null
165 | } & NodeBase
166 |
167 | export type Content =
168 | | { kind: 'string' | 'class', value: string }
169 | | { kind: 'ranges', ranges: Range[], negate: boolean }
170 | | { kind: 'backReference', ref: string }
171 | | { kind: 'beginningAssertion' | 'endAssertion' }
172 | | { kind: 'wordBoundaryAssertion', negate: boolean }
173 |
174 | export type RootNode = {
175 | type: 'root'
176 | } & NodeBase
177 |
178 | export type Node =
179 | | CharacterNode
180 | | GroupNode
181 | | ChoiceNode
182 | | RootNode
183 | | AssertionNode
184 | | BackReferenceNode
185 |
186 | export type ParentNode =
187 | | Regex
188 | | GroupNode
189 | | ChoiceNode
190 | | LookAroundAssertionNode
191 |
--------------------------------------------------------------------------------
/src/parser/backslash.ts:
--------------------------------------------------------------------------------
1 | // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String
2 | // Support Escape sequences:
3 | // \0 \n \r \t \b \f \v \uhhhh \xXX \u{X}…\u{XXXXXX}
4 | import { escapeSequences as escapeSequencesPattern } from './patterns'
5 |
6 | export const removeBackslash = (str: string): string => {
7 | let result = ''
8 | let index = 0
9 | while (index < str.length) {
10 | if (str[index] === '\\') {
11 | const matches = str.slice(index).match(escapeSequencesPattern)
12 | if (matches) {
13 | result += matches[0]
14 | index += matches[0].length
15 | } else {
16 | if (index === str.length - 1) {
17 | throw new Error('Invalid escape sequence')
18 | }
19 | result += str[index + 1]
20 | index += 2
21 | }
22 | continue
23 | }
24 | result += str[index]
25 | index++
26 | }
27 | return result
28 | }
29 |
--------------------------------------------------------------------------------
/src/parser/character-class.ts:
--------------------------------------------------------------------------------
1 | export const characterClassTextMap = {
2 | '.': 'Any character',
3 | '\\d': 'Any digit',
4 | '\\D': 'Non-digit',
5 | '\\w': 'Any alphanumeric',
6 | '\\W': 'Non-alphanumeric',
7 | '\\s': 'White space',
8 | '\\S': 'Non-white space',
9 | '\\t': 'Horizontal tab',
10 | '\\r': 'Carriage return',
11 | '\\n': 'Linefeed',
12 | '\\v': 'Vertical tab',
13 | '\\f': 'Form-feed',
14 | '[\\b]': 'Backspace',
15 | '\\0': 'NUL',
16 | '\\cH': '\\b Backspace',
17 | '\\cI': '\\t Horizontal Tab',
18 | '\\cJ': '\\n Line Feed',
19 | '\\cK': '\\v Vertical Tab',
20 | '\\cL': '\\f Form Feed',
21 | '\\cM': '\\r Carriage Return',
22 | '\\xhh': 'ASCII symbol',
23 | '\\uhhhh': 'Unicode symbol',
24 | }
25 | export type CharacterClassKey = keyof typeof characterClassTextMap
26 |
--------------------------------------------------------------------------------
/src/parser/dict.ts:
--------------------------------------------------------------------------------
1 | import type * as AST from './ast'
2 | import { TokenType } from './token'
3 |
4 | export const token = new Map(
5 | Object.entries({
6 | '(': TokenType.GroupStart,
7 | ')': TokenType.GraphEnd,
8 | '[': TokenType.RangeStart,
9 | '|': TokenType.Choice,
10 | }),
11 | )
12 |
13 | export const lookAround: Map = new Map(
14 | Object.entries({
15 | '?=': { kind: 'lookahead', negate: false },
16 | '?!': { kind: 'lookahead', negate: true },
17 | '?<=': { kind: 'lookbehind', negate: false },
18 | '? 0) {
12 | this.headId = selectedIds[0]
13 | this.tailId = selectedIds[selectedIds.length - 1]
14 | }
15 | }
16 |
17 | genNode(node: AST.Node) {
18 | if (node.id === this.headId) {
19 | this.startIndex = this.regex.length
20 | }
21 | super.genNode(node)
22 | if (node.id === this.tailId) {
23 | this.endIndex = this.regex.length
24 | }
25 | }
26 | }
27 |
28 | export const genWithSelected = (ast: AST.Regex, selectedIds: string[]) => {
29 | const codeGen = new CodeGenWithSelected(ast, selectedIds)
30 | const regex = codeGen.gen()
31 | return { regex, startIndex: codeGen.startIndex, endIndex: codeGen.endIndex }
32 | }
33 |
--------------------------------------------------------------------------------
/src/parser/index.ts:
--------------------------------------------------------------------------------
1 | import * as AST from './ast'
2 |
3 | export { default as parse } from './parse'
4 | export { default as gen } from './gen'
5 | export * from './visit'
6 | export * from './modifiers'
7 | export * from './character-class'
8 | export * from './gen-with-selected'
9 | export * from './utils'
10 | export { AST }
11 |
--------------------------------------------------------------------------------
/src/parser/modifiers/choice.ts:
--------------------------------------------------------------------------------
1 | import { nanoid } from 'nanoid'
2 | import type * as AST from '../ast'
3 | import { visit } from '../visit'
4 | import { replaceFromLists } from './replace'
5 |
6 | const nonCapturingGroupIt = (nodeList: AST.Node[], node: AST.ChoiceNode) => {
7 | const groupNode: AST.GroupNode = {
8 | id: nanoid(),
9 | type: 'group',
10 | kind: 'nonCapturing',
11 | children: [node],
12 | quantifier: null,
13 | }
14 | replaceFromLists(nodeList, [node], [groupNode])
15 | }
16 |
17 | export const makeChoiceValid = (ast: AST.Regex) => {
18 | let valid = true
19 | visit(
20 | ast,
21 | (
22 | node: AST.Node,
23 | nodeList: AST.Node[],
24 | _index: number,
25 | parent: AST.ParentNode,
26 | ) => {
27 | if (node.type === 'choice') {
28 | switch (parent.type) {
29 | case 'regex':
30 | case 'choice':
31 | case 'group':
32 | case 'lookAroundAssertion': {
33 | if (nodeList.length > 1) {
34 | nonCapturingGroupIt(nodeList, node)
35 | valid = false
36 | }
37 | break
38 | }
39 | }
40 | }
41 | },
42 | )
43 | return valid
44 | }
45 |
--------------------------------------------------------------------------------
/src/parser/modifiers/content.ts:
--------------------------------------------------------------------------------
1 | import { nanoid } from 'nanoid'
2 | import type * as AST from '../ast'
3 | import { getNodeById } from '../visit'
4 |
5 | const updateContent = (ast: AST.Regex, id: string, content: AST.Content) => {
6 | let nextSelectedId = id
7 | const { node, nodeList, index } = getNodeById(ast, id)
8 |
9 | const quantifier = node.type === 'character' ? node.quantifier : null
10 | switch (content.kind) {
11 | case 'ranges':
12 | {
13 | const { kind, negate, ranges } = content
14 | nodeList[index] = {
15 | id,
16 | type: 'character',
17 | kind,
18 | negate,
19 | ranges,
20 | quantifier,
21 | }
22 | }
23 | break
24 | case 'string': {
25 | const { kind, value } = content
26 | if (
27 | node.type === 'character'
28 | && node.kind === 'string'
29 | && value.length > 1
30 | && quantifier
31 | ) {
32 | const id = nanoid()
33 | nodeList[index] = {
34 | id,
35 | type: 'group',
36 | kind: 'nonCapturing',
37 | children: [{ ...node, value, quantifier: null }],
38 | quantifier,
39 | }
40 | nextSelectedId = id
41 | } else {
42 | nodeList[index] = { id, type: 'character', kind, value, quantifier }
43 | }
44 | break
45 | }
46 | case 'class': {
47 | const { kind, value } = content
48 | nodeList[index] = { id, type: 'character', kind, value, quantifier }
49 | break
50 | }
51 | case 'backReference': {
52 | const { ref } = content
53 | nodeList[index] = { id, type: 'backReference', ref, quantifier }
54 | break
55 | }
56 | case 'beginningAssertion': {
57 | nodeList[index] = { id, type: 'boundaryAssertion', kind: 'beginning' }
58 | break
59 | }
60 | case 'endAssertion': {
61 | nodeList[index] = { id, type: 'boundaryAssertion', kind: 'end' }
62 | break
63 | }
64 | case 'wordBoundaryAssertion': {
65 | nodeList[index] = {
66 | id,
67 | type: 'boundaryAssertion',
68 | kind: 'word',
69 | negate: content.negate,
70 | }
71 | break
72 | }
73 | }
74 | return nextSelectedId
75 | }
76 |
77 | export default updateContent
78 |
--------------------------------------------------------------------------------
/src/parser/modifiers/flags.ts:
--------------------------------------------------------------------------------
1 | import type * as AST from '../ast'
2 |
3 | export const updateFlags = (ast: AST.Regex, flags: string[]) => {
4 | ast.flags = flags as AST.Flag[]
5 | }
6 |
--------------------------------------------------------------------------------
/src/parser/modifiers/group.ts:
--------------------------------------------------------------------------------
1 | import { nanoid } from 'nanoid'
2 | import type * as AST from '../ast'
3 | import { getNodeById, getNodesByIds } from '../visit'
4 | import { replaceFromLists } from './replace'
5 |
6 | function unGroup(nodeList: AST.Node[], selectNode: AST.GroupNode) {
7 | const { children } = selectNode
8 | replaceFromLists(nodeList, [selectNode], children!)
9 | return children.map(({ id }) => id)
10 | }
11 |
12 | export const updateGroup = (
13 | ast: AST.Regex,
14 | selectedIds: string[],
15 | group: AST.Group | null,
16 | ) => {
17 | let nextSelectedIds: string[] = selectedIds
18 | const { node, nodeList, index } = getNodeById(ast, selectedIds[0])
19 | if (node.type === 'group') {
20 | if (group === null) {
21 | nextSelectedIds = unGroup(nodeList, node)
22 | } else {
23 | const { id, type, children, quantifier } = node
24 | const groupNode: AST.GroupNode = {
25 | id,
26 | type,
27 | children,
28 | quantifier,
29 | ...group,
30 | }
31 | nodeList[index] = groupNode
32 | }
33 | }
34 | return nextSelectedIds
35 | }
36 |
37 | export const groupSelected = (
38 | ast: AST.Regex,
39 | selectedIds: string[],
40 | group: AST.Group,
41 | ) => {
42 | const id = nanoid()
43 | let groupNode: AST.GroupNode
44 | const { nodes, nodeList } = getNodesByIds(ast, selectedIds)
45 | switch (group.kind) {
46 | case 'capturing':
47 | groupNode = {
48 | id,
49 | type: 'group',
50 | kind: 'capturing',
51 | children: [],
52 | name: '',
53 | index: 0,
54 | quantifier: null,
55 | }
56 | break
57 | case 'nonCapturing':
58 | groupNode = {
59 | id,
60 | type: 'group',
61 | kind: 'nonCapturing',
62 | children: [],
63 | quantifier: null,
64 | }
65 | break
66 | case 'namedCapturing':
67 | groupNode = {
68 | id,
69 | type: 'group',
70 | kind: 'namedCapturing',
71 | children: [],
72 | name: group.name,
73 | index: 0,
74 | quantifier: null,
75 | }
76 | break
77 | }
78 | groupNode.children = nodes
79 | replaceFromLists(nodeList, nodes, [groupNode])
80 | return [id]
81 | }
82 |
--------------------------------------------------------------------------------
/src/parser/modifiers/index.ts:
--------------------------------------------------------------------------------
1 | export * from './remove'
2 | export * from './insert'
3 | export * from './group'
4 | export { default as updateContent } from './content'
5 | export * from './quantifier'
6 | export * from './lookaround'
7 | export * from './flags'
8 | export * from './choice'
9 |
--------------------------------------------------------------------------------
/src/parser/modifiers/insert.ts:
--------------------------------------------------------------------------------
1 | import { nanoid } from 'nanoid'
2 | import type * as AST from '../ast'
3 | import { getNodesByIds } from '../visit'
4 | import { replaceFromLists } from './replace'
5 |
6 | type InsertDirection = 'prev' | 'next' | 'branch'
7 |
8 | const genNode = (): AST.CharacterNode => {
9 | return {
10 | id: nanoid(),
11 | type: 'character',
12 | kind: 'string',
13 | value: '',
14 | quantifier: null,
15 | }
16 | }
17 |
18 | const genChoiceNode = (): AST.ChoiceNode => {
19 | return {
20 | id: nanoid(),
21 | type: 'choice',
22 | branches: [],
23 | }
24 | }
25 |
26 | export const insertAroundSelected = (
27 | ast: AST.Regex,
28 | selectedIds: string[],
29 | direction: InsertDirection,
30 | ) => {
31 | if (selectedIds.length === 0) {
32 | return
33 | }
34 | const newNode = genNode()
35 | const { nodes, nodeList, index, parent } = getNodesByIds(ast, selectedIds)
36 |
37 | if (direction === 'prev') {
38 | nodeList.splice(index, 0, newNode)
39 | } else if (direction === 'next') {
40 | nodeList.splice(index + selectedIds.length, 0, newNode)
41 | } else {
42 | if (nodes.length === 1 && nodes[0].type === 'choice') {
43 | nodes[0].branches.push([newNode])
44 | } else if (nodeList.length === nodes.length && parent.type === 'choice') {
45 | parent.branches.push([newNode])
46 | } else {
47 | const choiceNode = genChoiceNode()
48 | choiceNode.branches = [nodes, [newNode]]
49 | replaceFromLists(nodeList, nodes, [choiceNode])
50 | }
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/src/parser/modifiers/lookaround.ts:
--------------------------------------------------------------------------------
1 | import { nanoid } from 'nanoid'
2 | import type * as AST from '../ast'
3 | import { getNodeById, getNodesByIds } from '../visit'
4 | import { replaceFromLists } from './replace'
5 |
6 | export const updateLookAroundAssertion = (
7 | ast: AST.Regex,
8 | selectedIds: string[],
9 | lookAround: {
10 | kind: 'lookahead' | 'lookbehind'
11 | negate: boolean
12 | },
13 | ) => {
14 | const { node, nodeList, index } = getNodeById(ast, selectedIds[0])
15 | if (node.type === 'lookAroundAssertion') {
16 | const { id, type, children } = node
17 | const { kind, negate } = lookAround
18 | const lookAroundAssertionNode: AST.LookAroundAssertionNode = {
19 | id,
20 | type,
21 | children,
22 | kind,
23 | negate,
24 | }
25 | nodeList[index] = lookAroundAssertionNode
26 | }
27 | }
28 |
29 | export const lookAroundAssertionSelected = (
30 | ast: AST.Regex,
31 | selectedIds: string[],
32 | kind: 'lookahead' | 'lookbehind',
33 | ) => {
34 | const id = nanoid()
35 | const { nodes, nodeList } = getNodesByIds(ast, selectedIds)
36 | const lookAroundAssertionNode: AST.LookAroundAssertionNode = {
37 | id,
38 | type: 'lookAroundAssertion',
39 | kind,
40 | negate: false,
41 | children: nodes,
42 | }
43 | replaceFromLists(nodeList, nodes, [lookAroundAssertionNode])
44 | return [id]
45 | }
46 |
47 | export const unLookAroundAssertion = (
48 | ast: AST.Regex,
49 | selectedIds: string[],
50 | ) => {
51 | let nextSelectedIds: string[] = selectedIds
52 | const { node, nodeList } = getNodeById(ast, selectedIds[0])
53 | if (node.type === 'lookAroundAssertion') {
54 | const { children } = node
55 | replaceFromLists(nodeList, [node], children)
56 | nextSelectedIds = children.map(({ id }) => id)
57 | }
58 |
59 | return nextSelectedIds
60 | }
61 |
--------------------------------------------------------------------------------
/src/parser/modifiers/quantifier.ts:
--------------------------------------------------------------------------------
1 | import { nanoid } from 'nanoid'
2 | import type * as AST from '../ast'
3 | import { getNodeById } from '../visit'
4 | import { replaceFromLists } from './replace'
5 | import { checkQuantifier } from '@/parser'
6 |
7 | export const updateQuantifier = (
8 | ast: AST.Regex,
9 | selectedId: string,
10 | quantifier: AST.Quantifier | null,
11 | ) => {
12 | let nextSelectedId = selectedId
13 | const { node, nodeList } = getNodeById(ast, selectedId)
14 | if (
15 | node.type === 'character'
16 | && node.kind === 'string'
17 | && node.value.length > 1
18 | ) {
19 | const groupNode: AST.GroupNode = {
20 | id: nanoid(),
21 | type: 'group',
22 | kind: 'nonCapturing',
23 | children: [node],
24 | quantifier,
25 | }
26 | nextSelectedId = groupNode.id
27 | replaceFromLists(nodeList, [node], [groupNode])
28 | } else if (checkQuantifier(node)) {
29 | node.quantifier = quantifier
30 | }
31 | return nextSelectedId
32 | }
33 |
--------------------------------------------------------------------------------
/src/parser/modifiers/remove.ts:
--------------------------------------------------------------------------------
1 | import type * as AST from '../ast'
2 | import { replaceFromLists } from './replace'
3 |
4 | type Path = { node: AST.Node, nodeList: AST.Node[] }
5 | const visit = (
6 | ast: AST.Regex | AST.Node[],
7 | id: string,
8 | paths: Path[] = [],
9 | callback: () => void,
10 | ): true | void => {
11 | const nodes = Array.isArray(ast) ? ast : ast.body
12 | for (let i = 0; i < nodes.length; i++) {
13 | const node = nodes[i]
14 | if (node.id === id) {
15 | paths.push({ node, nodeList: nodes })
16 | callback()
17 | return true
18 | }
19 |
20 | if (
21 | node.type === 'choice'
22 | || node.type === 'group'
23 | || node.type === 'lookAroundAssertion'
24 | ) {
25 | paths.push({ nodeList: nodes, node })
26 | if (node.type === 'choice') {
27 | const branches = node.branches
28 | for (let i = 0; i < branches.length; i++) {
29 | if (visit(branches[i], id, paths, callback)) {
30 | return true
31 | }
32 | }
33 | } else {
34 | if (visit(node.children, id, paths, callback)) {
35 | return true
36 | }
37 | }
38 | paths.pop()
39 | }
40 | }
41 | }
42 |
43 | const removeFromList = (nodeList: AST.Node[], ids: string[]) => {
44 | const index = nodeList.findIndex(({ id }) => id === ids[0])
45 | if (index === -1) {
46 | return
47 | }
48 | nodeList.splice(index, ids.length)
49 | }
50 |
51 | export const removeSelected = (ast: AST.Regex, selectedIds: string[]) => {
52 | if (selectedIds.length === 0) {
53 | return
54 | }
55 |
56 | const paths: Path[] = []
57 | visit(ast, selectedIds[0], paths, () => {
58 | const { nodeList } = paths.pop()!
59 | removeFromList(nodeList, selectedIds)
60 | while (paths.length !== 0) {
61 | const { node, nodeList } = paths.pop()!
62 | if (
63 | (node.type === 'group' || node.type === 'lookAroundAssertion')
64 | && node.children.length === 0
65 | ) {
66 | removeFromList(nodeList, [node.id])
67 | }
68 | if (node.type === 'choice') {
69 | node.branches = node.branches.filter(branch => branch.length > 0)
70 | if (node.branches.length === 1) {
71 | replaceFromLists(nodeList, [node], node.branches[0])
72 | }
73 | return
74 | }
75 | }
76 | })
77 | }
78 |
--------------------------------------------------------------------------------
/src/parser/modifiers/replace.ts:
--------------------------------------------------------------------------------
1 | import type * as AST from '../ast'
2 |
3 | export function replaceFromLists(
4 | nodeList: AST.Node[],
5 | oldNodes: AST.Node[],
6 | newNodes: AST.Node[],
7 | ) {
8 | const start = oldNodes[0]
9 | const startIndex = nodeList.findIndex(({ id }) => id === start.id)
10 | if (startIndex === -1) {
11 | return
12 | }
13 | nodeList.splice(startIndex, oldNodes.length, ...newNodes)
14 | }
15 |
--------------------------------------------------------------------------------
/src/parser/parse.ts:
--------------------------------------------------------------------------------
1 | import type { Options } from './parser'
2 | import { Parser } from './parser'
3 |
4 | function parse(regex: string, options: Options = {}) {
5 | const parser = new Parser(regex, options)
6 | return parser.parse()
7 | }
8 |
9 | export default parse
10 |
--------------------------------------------------------------------------------
/src/parser/patterns.ts:
--------------------------------------------------------------------------------
1 | export const flag = /[gimsuy]/
2 | export const cX = /^([A-Z])/i
3 | export const xhh = /^([0-9A-F]{2})/i
4 | export const uhhhh = /^([0-9A-F]{4})/i
5 | export const digit = /^(\d+)/
6 | export const comma = /^,/
7 | export const lookAround = /^(\?=|\?!|\?<=|\?/
10 | export const quantifier = /^\{(\d+)(,|,(\d+))?\}/
11 | export const specialCharacterInLiteral = /[/()[|\\.^$?+*]|\{(\d+)(,|,(\d+))?\}/
12 | export const specialCharacter = /[()[|\\.^$?+*]|\{(\d+)(,|,(\d+))?\}/
13 | export const characterClass
14 | = /^\\(?:[dDwWsStrnvf0]|c[A-Za-z]|x[0-9A-Fa-f]{2}|u[0-9A-Fa-f]{4})/
15 | export const backReference = /^\\(\d+|k<(\w+)>)/
16 | export const escapeSequences
17 | = /^\\(?:[0nrtbfv]|u[0-9A-Fa-f]{4}|x[0-9A-Fa-f]{2}|u\{[0-9A-Fa-f]{1,6}\})/
18 |
--------------------------------------------------------------------------------
/src/parser/token.ts:
--------------------------------------------------------------------------------
1 | type Span = {
2 | start: number
3 | end: number
4 | }
5 | export enum TokenType {
6 | RegexBodyStart,
7 | RegexBodyEnd,
8 | NormalCharacter,
9 | GroupStart,
10 | GraphEnd,
11 | RangeStart,
12 | RangeEnd,
13 | Choice,
14 | CharacterClass,
15 | EscapedChar,
16 | Assertion,
17 | BackReference,
18 | }
19 | export type Token = {
20 | type: TokenType
21 | span: Span
22 | }
23 |
--------------------------------------------------------------------------------
/src/parser/utils.ts:
--------------------------------------------------------------------------------
1 | import type * as AST from './ast'
2 |
3 | export const getQuantifier = (node: AST.Node) =>
4 | (node.type === 'character' || node.type === 'group' || node.type === 'backReference') ? node.quantifier : null
5 |
6 | export const checkQuantifier = (node: AST.Node): node is (AST.CharacterNode | AST.GroupNode | AST.BackReferenceNode) =>
7 | (node.type === 'character' || node.type === 'group' || node.type === 'backReference')
8 |
--------------------------------------------------------------------------------
/src/parser/visit.ts:
--------------------------------------------------------------------------------
1 | import type * as AST from './ast'
2 |
3 | export function visit(
4 | node: AST.Regex | AST.Node,
5 | callback: (
6 | node: AST.Node,
7 | nodeList: AST.Node[],
8 | index: number,
9 | parent: AST.ParentNode
10 | ) => void | boolean,
11 | ) {
12 | let found = false
13 | const _callback = (
14 | node: AST.Node,
15 | nodeList: AST.Node[],
16 | index: number,
17 | parent: AST.ParentNode,
18 | ) => {
19 | if (found) {
20 | return true
21 | }
22 | return callback(node, nodeList, index, parent)
23 | }
24 |
25 | const _visit = (node: AST.Regex | AST.Node) => {
26 | switch (node.type) {
27 | case 'regex':
28 | case 'group':
29 | case 'lookAroundAssertion':{
30 | const children = node.type === 'regex' ? node.body : node.children
31 | for (let index = 0; index < children.length; index++) {
32 | const child = children[index]
33 | if (_callback(child, children, index, node)) {
34 | found = true
35 | return
36 | }
37 | _visit(child)
38 | }
39 | break
40 | }
41 | case 'choice':{
42 | const { branches } = node
43 | for (const branch of branches) {
44 | for (let index = 0; index < branch.length; index++) {
45 | const child = branch[index]
46 | if (_callback(child, branch, index, node)) {
47 | found = true
48 | return
49 | }
50 | _visit(child)
51 | }
52 | }
53 | break
54 | }
55 | default:
56 | break
57 | }
58 | }
59 |
60 | _visit(node)
61 | }
62 |
63 | export const visitNodes = (
64 | node: AST.Regex | AST.Node,
65 | callback: (id: string, index: number, nodes: AST.Node[]) => void | boolean,
66 | ) => {
67 | let found = false
68 | const _callback = (id: string, index: number, nodes: AST.Node[]) => {
69 | if (found) {
70 | return true
71 | }
72 | return callback(id, index, nodes)
73 | }
74 |
75 | const _visitNodes = (node: AST.Regex | AST.Node) => {
76 | switch (node.type) {
77 | case 'regex':
78 | case 'group':
79 | case 'lookAroundAssertion':{
80 | const children = node.type === 'regex' ? node.body : node.children
81 | if (_callback(node.id, 0, children)) {
82 | found = true
83 | return
84 | }
85 | for (const child of children) {
86 | _visitNodes(child)
87 | }
88 | break
89 | }
90 | case 'choice':{
91 | const { branches } = node
92 | for (let index = 0; index < branches.length; index++) {
93 | const branch = branches[index]
94 | if (_callback(node.id, index, branch)) {
95 | found = true
96 | return
97 | }
98 | for (const child of branch) {
99 | _visitNodes(child)
100 | }
101 | }
102 | break
103 | }
104 | default:
105 | break
106 | }
107 | }
108 |
109 | _visitNodes(node)
110 | }
111 |
112 | export function getNodeById(
113 | ast: AST.Regex,
114 | id: string,
115 | ): {
116 | node: AST.Node
117 | parent: AST.ParentNode
118 | nodeList: AST.Node[]
119 | index: number
120 | } {
121 | let ret: {
122 | node: AST.Node
123 | parent: AST.ParentNode
124 | nodeList: AST.Node[]
125 | index: number
126 | } | null = null
127 | visit(ast, (node, nodeList, index, parent) => {
128 | if (node.id === id) {
129 | ret = {
130 | node,
131 | parent,
132 | nodeList,
133 | index,
134 | }
135 | return true
136 | }
137 | })
138 | if (ret === null) {
139 | throw new Error(`Node with id "${id}" not found.`)
140 | }
141 | return ret!
142 | }
143 |
144 | export function getNodesByIds(
145 | ast: AST.Regex,
146 | ids: string[],
147 | ): {
148 | nodes: AST.Node[]
149 | parent: AST.ParentNode
150 | nodeList: AST.Node[]
151 | index: number
152 | } {
153 | const { parent, nodeList, index } = getNodeById(ast, ids[0])
154 | return {
155 | nodes: nodeList.slice(index, index + ids.length),
156 | parent,
157 | nodeList,
158 | index,
159 | }
160 | }
161 |
162 | export function lrd(
163 | node: AST.Regex | AST.Node,
164 | callback: (node: AST.Regex | AST.Node) => void,
165 | ) {
166 | switch (node.type) {
167 | case 'regex':
168 | node.body.forEach(child => lrd(child, callback))
169 | break
170 | case 'group':
171 | case 'lookAroundAssertion':
172 | node.children.forEach(child => lrd(child, callback))
173 | break
174 | case 'choice':
175 | node.branches.forEach((branch) => {
176 | branch.forEach(child => lrd(child, callback))
177 | })
178 | break
179 | default:
180 | break
181 | }
182 | callback(node)
183 | }
184 |
--------------------------------------------------------------------------------
/src/routes.tsx:
--------------------------------------------------------------------------------
1 | import { Routes as ReactRouters, Route } from 'react-router-dom'
2 | import Home from './modules/home'
3 | import Samples from './modules/samples'
4 | import Playground from '@/modules/playground'
5 |
6 | const isDev = import.meta.env.MODE === 'development'
7 |
8 | export default function Routes() {
9 | return (
10 |
11 | } />
12 | } />
13 | {isDev && } />}
14 |
15 | )
16 | }
17 |
--------------------------------------------------------------------------------
/src/utils.ts:
--------------------------------------------------------------------------------
1 | import { type ClassValue, clsx } from 'clsx'
2 | import { twMerge } from 'tailwind-merge'
3 |
4 | export function cn(...inputs: ClassValue[]) {
5 | return twMerge(clsx(inputs))
6 | }
7 |
--------------------------------------------------------------------------------
/src/utils/helpers/index.ts:
--------------------------------------------------------------------------------
1 | export function genPermalink(tests: string[] = []) {
2 | const url = new URL(window.location.href)
3 | if (tests.length > 0) {
4 | url.searchParams.set('t', JSON.stringify(tests))
5 | }
6 | return url.toString()
7 | }
8 |
--------------------------------------------------------------------------------
/src/utils/hooks/index.ts:
--------------------------------------------------------------------------------
1 | export { useDragSelect } from './use-drag-select'
2 | export { useCurrentState } from './use-current-state'
3 | export { useFocus } from './use-focus'
4 |
--------------------------------------------------------------------------------
/src/utils/hooks/use-current-state.ts:
--------------------------------------------------------------------------------
1 | // MIT License
2 |
3 | // Copyright (c) 2020 Geist UI
4 |
5 | // Permission is hereby granted, free of charge, to any person obtaining a copy
6 | // of this software and associated documentation files (the "Software"), to deal
7 | // in the Software without restriction, including without limitation the rights
8 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | // copies of the Software, and to permit persons to whom the Software is
10 | // furnished to do so, subject to the following conditions:
11 |
12 | // The above copyright notice and this permission notice shall be included in all
13 | // copies or substantial portions of the Software.
14 |
15 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | // SOFTWARE.
22 | import type {
23 | Dispatch,
24 | MutableRefObject,
25 | SetStateAction,
26 | } from 'react'
27 | import {
28 | useCallback,
29 | useEffect,
30 | useRef,
31 | useState,
32 | } from 'react'
33 |
34 | export type CurrentStateType = [
35 | S,
36 | Dispatch>,
37 | MutableRefObject,
38 | ]
39 |
40 | export const useCurrentState = (
41 | initialState: S | (() => S),
42 | ): CurrentStateType => {
43 | const [state, setState] = useState(() => {
44 | return typeof initialState === 'function'
45 | ? (initialState as () => S)()
46 | : initialState
47 | })
48 | const ref = useRef(initialState as S)
49 |
50 | useEffect(() => {
51 | ref.current = state
52 | }, [state])
53 |
54 | const setValue = useCallback((val: SetStateAction) => {
55 | const result
56 | = typeof val === 'function'
57 | ? (val as (prevState: S) => S)(ref.current)
58 | : val
59 | ref.current = result
60 | setState(result)
61 | }, [])
62 |
63 | return [state, setValue, ref]
64 | }
65 |
--------------------------------------------------------------------------------
/src/utils/hooks/use-debounce-change.ts:
--------------------------------------------------------------------------------
1 | import type { ChangeEvent } from 'react'
2 | import { useState } from 'react'
3 | import { useDebounceCallback } from 'usehooks-ts'
4 |
5 | export function useDebounceChange(debounced: boolean, value: string, onChange: (value: string) => void) {
6 | const [text, setText] = useState(value)
7 |
8 | const debouncedOnChange = useDebounceCallback(onChange, 300)
9 |
10 | const handleChange = (e: ChangeEvent) => {
11 | const value = e.target.value
12 | if (debounced) {
13 | setText(value)
14 | debouncedOnChange(value)
15 | } else {
16 | onChange(value)
17 | }
18 | }
19 | return {
20 | value: debounced ? text : value,
21 | onChange: handleChange,
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/src/utils/hooks/use-drag-select.tsx:
--------------------------------------------------------------------------------
1 | import clsx from 'clsx'
2 | import { useRef, useState } from 'react'
3 | import { useEvent } from 'react-use'
4 |
5 | function captureClick(e: MouseEvent) {
6 | e.stopPropagation()
7 | window.removeEventListener('click', captureClick, true)
8 | }
9 |
10 | type Options = {
11 | disabled?: boolean
12 | className?: string
13 | onSelect: (box: { x1: number, y1: number, x2: number, y2: number }) => void
14 | }
15 | export function useDragSelect({
16 | disabled = false,
17 | className = '',
18 | onSelect,
19 | }: Options) {
20 | const dragging = useRef(false)
21 | const moving = useRef(false)
22 | const start = useRef<[number, number]>([0, 0])
23 | const [rect, setRect] = useState({ x: 0, y: 0, width: 0, height: 0 })
24 |
25 | const handleMouseUp = () => {
26 | if (!dragging.current) {
27 | return
28 | }
29 | // should fire onMouseMove at least once
30 | if (!moving.current) {
31 | dragging.current = false
32 | return
33 | }
34 | const { x, y, width, height } = rect
35 | if (width > 5 && height > 5) {
36 | // prevent click event on node
37 | window.addEventListener('click', captureClick, true)
38 | onSelect({ x1: x, y1: y, x2: x + width, y2: y + height })
39 | }
40 | dragging.current = false
41 | moving.current = false
42 | setRect({ x: 0, y: 0, width: 0, height: 0 })
43 | }
44 |
45 | useEvent('mouseup', handleMouseUp)
46 |
47 | const bindings = {
48 | onMouseDown: (e: React.MouseEvent) => {
49 | if (disabled) {
50 | return
51 | }
52 | const { offsetX, offsetY } = e.nativeEvent
53 | dragging.current = true
54 | start.current = [offsetX, offsetY]
55 | },
56 | onMouseMove: (e: React.MouseEvent) => {
57 | if (!dragging.current) {
58 | return
59 | }
60 | moving.current = true
61 | const { offsetX, offsetY } = e.nativeEvent
62 | const x = offsetX > start.current[0] ? start.current[0] : offsetX
63 | const y = offsetY > start.current[1] ? start.current[1] : offsetY
64 | const width = Math.abs(offsetX - start.current[0])
65 | const height = Math.abs(offsetY - start.current[1])
66 | setRect({ x, y, width, height })
67 | },
68 | }
69 | const { x, y, width, height } = rect
70 | const Selection = width > 0 && height > 0 && (
71 |
80 |
81 | )
82 | return [bindings, Selection]
83 | }
84 |
--------------------------------------------------------------------------------
/src/utils/hooks/use-focus.ts:
--------------------------------------------------------------------------------
1 | import { type FocusEvent, useState } from 'react'
2 |
3 | type FocusOptions = {
4 | onFocus?: (e: FocusEvent) => void
5 | onBlur?: (e: FocusEvent) => void
6 | }
7 | export function useFocus(options: FocusOptions = {}) {
8 | const { onFocus, onBlur } = options
9 | const [focused, setFocused] = useState(false)
10 |
11 | return {
12 | focusProps: {
13 | onFocus: (e: FocusEvent) => {
14 | setFocused(true)
15 | onFocus?.(e)
16 | },
17 | onBlur: (e: FocusEvent) => {
18 | setFocused(false)
19 | onBlur?.(e)
20 | },
21 | },
22 | focused,
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/src/utils/hooks/use-hover.ts:
--------------------------------------------------------------------------------
1 | import { useState } from 'react'
2 |
3 | export function useHover() {
4 | const [hovered, setHovered] = useState(false)
5 | return {
6 | hoverProps: {
7 | onMouseEnter: () => setHovered(true),
8 | onMouseLeave: () => setHovered(false),
9 | },
10 | hovered,
11 | }
12 | }
13 |
--------------------------------------------------------------------------------
/src/utils/hooks/use-latest.ts:
--------------------------------------------------------------------------------
1 | import { useRef } from 'react'
2 |
3 | export const useLatest = (value: T) => {
4 | const ref = useRef(value)
5 | ref.current = value
6 | return ref
7 | }
8 |
--------------------------------------------------------------------------------
/src/utils/links/index.tsx:
--------------------------------------------------------------------------------
1 | const mdnLinks = {
2 | group:
3 | 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions/Groups_and_Ranges',
4 | lookAround:
5 | 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions/Assertions#other_assertions',
6 | ranges:
7 | 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions/Groups_and_Ranges',
8 | class:
9 | 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions/Character_Classes',
10 | backReference:
11 | 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions/Groups_and_Ranges#types',
12 | wordBoundaryAssertion:
13 | 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions/Assertions#boundary-type_assertions',
14 | quantifier:
15 | 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions/Quantifiers',
16 | }
17 | export type MdnLinkKey = keyof typeof mdnLinks
18 |
19 | export function isMdnLinkKey(key: string): key is MdnLinkKey {
20 | return [
21 | 'group',
22 | 'lookAround',
23 | 'ranges',
24 | 'class',
25 | 'backReference',
26 | 'wordBoundaryAssertion',
27 | 'quantifier',
28 | ].includes(key)
29 | }
30 | export default mdnLinks
31 |
--------------------------------------------------------------------------------
/src/vite-envd.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/tailwind.config.ts:
--------------------------------------------------------------------------------
1 | import type { Config } from 'tailwindcss'
2 | import tailwindcssAnimatePlugin from 'tailwindcss-animate'
3 |
4 | export default {
5 | darkMode: ['class'],
6 | content: [
7 | './pages/**/*.{ts,tsx}',
8 | './components/**/*.{ts,tsx}',
9 | './app/**/*.{ts,tsx}',
10 | './src/**/*.{ts,tsx}',
11 | ],
12 | prefix: '',
13 | theme: {
14 | container: {
15 | center: true,
16 | padding: '2rem',
17 | screens: {
18 | '2xl': '1400px',
19 | },
20 | },
21 | extend: {
22 | colors: {
23 | 'border': 'hsl(var(--border))',
24 | 'input': 'hsl(var(--input))',
25 | 'ring': 'hsl(var(--ring))',
26 | 'background': 'hsl(var(--background))',
27 | 'foreground': 'hsl(var(--foreground))',
28 | 'primary': {
29 | DEFAULT: 'hsl(var(--primary))',
30 | foreground: 'hsl(var(--primary-foreground))',
31 | },
32 | 'secondary': {
33 | DEFAULT: 'hsl(var(--secondary))',
34 | foreground: 'hsl(var(--secondary-foreground))',
35 | },
36 | 'destructive': {
37 | DEFAULT: 'hsl(var(--destructive))',
38 | foreground: 'hsl(var(--destructive-foreground))',
39 | },
40 | 'muted': {
41 | DEFAULT: 'hsl(var(--muted))',
42 | foreground: 'hsl(var(--muted-foreground))',
43 | },
44 | 'accent': {
45 | DEFAULT: 'hsl(var(--accent))',
46 | foreground: 'hsl(var(--accent-foreground))',
47 | },
48 | 'popover': {
49 | DEFAULT: 'hsl(var(--popover))',
50 | foreground: 'hsl(var(--popover-foreground))',
51 | },
52 | 'card': {
53 | DEFAULT: 'hsl(var(--card))',
54 | foreground: 'hsl(var(--card-foreground))',
55 | },
56 | 'graph': 'var(--graph)',
57 | 'graph-group': 'var(--graph-group)',
58 | 'graph-bg': 'var(--graph-bg)',
59 | },
60 | borderRadius: {
61 | lg: 'var(--radius)',
62 | md: 'calc(var(--radius) - 2px)',
63 | sm: 'calc(var(--radius) - 4px)',
64 | },
65 | keyframes: {
66 | 'accordion-down': {
67 | from: { height: '0' },
68 | to: { height: 'var(--radix-accordion-content-height)' },
69 | },
70 | 'accordion-up': {
71 | from: { height: 'var(--radix-accordion-content-height)' },
72 | to: { height: '0' },
73 | },
74 | },
75 | animation: {
76 | 'accordion-down': 'accordion-down 0.2s ease-out',
77 | 'accordion-up': 'accordion-up 0.2s ease-out',
78 | },
79 | transitionProperty: {
80 | width: 'width',
81 | },
82 | },
83 | },
84 | plugins: [tailwindcssAnimatePlugin],
85 | } satisfies Config
86 |
--------------------------------------------------------------------------------
/tests/__mocks__/analytics.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | Analytics: () => null,
3 | }
4 |
--------------------------------------------------------------------------------
/tests/__mocks__/css.js:
--------------------------------------------------------------------------------
1 | module.exports = {}
2 |
--------------------------------------------------------------------------------
/tests/__mocks__/react-i18next.js:
--------------------------------------------------------------------------------
1 | const React = require("react")
2 | const reactI18next = require("react-i18next")
3 |
4 | const hasChildren = (node) =>
5 | node && (node.children || (node.props && node.props.children))
6 |
7 | const getChildren = (node) =>
8 | node && node.children ? node.children : node.props && node.props.children
9 |
10 | const renderNodes = (reactNodes) => {
11 | if (typeof reactNodes === "string") {
12 | return reactNodes
13 | }
14 |
15 | return Object.keys(reactNodes).map((key, i) => {
16 | const child = reactNodes[key]
17 | const isElement = React.isValidElement(child)
18 |
19 | if (typeof child === "string") {
20 | return child
21 | }
22 | if (hasChildren(child)) {
23 | const inner = renderNodes(getChildren(child))
24 | return React.cloneElement(child, { ...child.props, key: i }, inner)
25 | }
26 | if (typeof child === "object" && !isElement) {
27 | return Object.keys(child).reduce(
28 | (str, childKey) => `${str}${child[childKey]}`,
29 | ""
30 | )
31 | }
32 |
33 | return child
34 | })
35 | }
36 |
37 | const useMock = [(k) => k, {}]
38 | useMock.t = (k) => k
39 | useMock.i18n = {}
40 |
41 | module.exports = {
42 | // this mock makes sure any components using the translate HoC receive the t function as a prop
43 | withTranslation: () => (Component) => (props) =>
44 | k} {...props} />,
45 | Trans: ({ children }) =>
46 | Array.isArray(children) ? renderNodes(children) : renderNodes([children]),
47 | Translation: ({ children }) => children((k) => k, { i18n: {} }),
48 | useTranslation: () => useMock,
49 |
50 | // mock if needed
51 | I18nextProvider: reactI18next.I18nextProvider,
52 | initReactI18next: reactI18next.initReactI18next,
53 | setDefaults: reactI18next.setDefaults,
54 | getDefaults: reactI18next.getDefaults,
55 | setI18n: reactI18next.setI18n,
56 | getI18n: reactI18next.getI18n,
57 | }
58 |
--------------------------------------------------------------------------------
/tests/__mocks__/svgrMock.js:
--------------------------------------------------------------------------------
1 | module.exports = { ReactComponent: "icon-mock" }
2 |
--------------------------------------------------------------------------------
/tests/index.test.tsx:
--------------------------------------------------------------------------------
1 | import { fireEvent, getByText, render, screen } from '@testing-library/react'
2 | import { act } from 'react'
3 | import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
4 | import '@testing-library/dom'
5 | import App from '../src/App'
6 |
7 | describe('app', () => {
8 | beforeEach(() => {
9 | vi.useFakeTimers()
10 | })
11 | afterEach(() => {
12 | vi.restoreAllMocks()
13 | })
14 |
15 | it('renders graph after inputting', async () => {
16 | render()
17 | expect(screen.getAllByTestId('graph').length).toBe(13)
18 |
19 | await act(async () => {
20 | const input = screen.getAllByTestId('regex-input')[0]
21 | fireEvent.change(input, { target: { value: 'abc' } })
22 | vi.advanceTimersByTime(500)
23 | })
24 |
25 | expect(screen.getAllByTestId('graph').length).toBe(14)
26 | })
27 |
28 | it('updates graph after editing', async () => {
29 | render()
30 |
31 | await act(async () => {
32 | const input = screen.getByTestId('regex-input')
33 | fireEvent.change(input, { target: { value: 'abc' } })
34 | vi.advanceTimersByTime(500)
35 |
36 | fireEvent.click(getByText(screen.getAllByTestId('graph')[0], 'abc'), {
37 | bubbles: true,
38 | })
39 | })
40 |
41 | await act(async () => {
42 | const input = screen
43 | .getByTestId('edit-tab')
44 | .querySelector('input[type=\'text\']')
45 | fireEvent.change(input!, { target: { value: 'abcd' } })
46 | vi.advanceTimersByTime(500)
47 | })
48 |
49 | expect((screen.getByTestId('regex-input') as HTMLInputElement).value).toBe(
50 | 'abcd',
51 | )
52 | expect(
53 | getByText(screen.getAllByTestId('graph')[0], 'abcd'),
54 | ).toBeInTheDocument()
55 | })
56 | })
57 |
--------------------------------------------------------------------------------
/tests/setup.ts:
--------------------------------------------------------------------------------
1 | import { afterEach } from 'vitest'
2 | import { cleanup } from '@testing-library/react'
3 | import '@testing-library/jest-dom/vitest'
4 | import 'vitest-canvas-mock'
5 |
6 | afterEach(() => {
7 | cleanup()
8 | })
9 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es5",
4 | "jsx": "react-jsx",
5 | "lib": ["dom", "dom.iterable", "esnext"],
6 | "baseUrl": ".",
7 | "module": "esnext",
8 | "moduleResolution": "node",
9 | "paths": {
10 | "@/*": ["./src/*"],
11 | "tests/*": ["../tests/*"]
12 | },
13 | "resolveJsonModule": true,
14 | "types": ["vite/client"],
15 | "allowJs": true,
16 | "strict": true,
17 | "noFallthroughCasesInSwitch": true,
18 | "noEmit": true,
19 | "allowSyntheticDefaultImports": true,
20 | "esModuleInterop": true,
21 | "forceConsistentCasingInFileNames": true,
22 | "isolatedModules": true,
23 | "skipLibCheck": true
24 | },
25 | "include": ["src", "tests", "tailwind.config.ts"]
26 | }
27 |
--------------------------------------------------------------------------------
/vercel.json:
--------------------------------------------------------------------------------
1 | {
2 | "rewrites": [{ "source": "/(.*)", "destination": "/" }]
3 | }
4 |
--------------------------------------------------------------------------------
/vite.config.ts:
--------------------------------------------------------------------------------
1 | ///
2 | import path from 'node:path'
3 | import { defineConfig } from 'vite'
4 | import react from '@vitejs/plugin-react-swc'
5 | import tsconfigPaths from 'vite-tsconfig-paths'
6 | import { ViteEjsPlugin } from 'vite-plugin-ejs'
7 |
8 | export default defineConfig({
9 | base: '/',
10 | plugins: [react(), tsconfigPaths(), ViteEjsPlugin()],
11 | define: {
12 | // eslint-disable-next-line node/prefer-global/process
13 | SENTRY_DSN: JSON.stringify(process.env.SENTRY_DSN),
14 | },
15 | resolve: {
16 | alias: {
17 | 'tailwind.config': path.resolve(__dirname, 'tailwind.config.ts'),
18 | },
19 | },
20 | optimizeDeps: {
21 | include: [
22 | 'tailwind.config.ts',
23 | ],
24 | },
25 | test: {
26 | environment: 'jsdom',
27 | globals: true,
28 | setupFiles: './tests/setup.ts',
29 | },
30 | })
31 |
--------------------------------------------------------------------------------