diff --git a/.github/workflows/test-web.yml b/.github/workflows/test-web.yml new file mode 100644 index 00000000..108cc277 --- /dev/null +++ b/.github/workflows/test-web.yml @@ -0,0 +1,28 @@ +name: Test Web + +on: + pull_request: + branches: ["main"] + + +jobs: + build: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + submodules: "true" + - name: Use Node.Js + uses: actions/setup-node@v4 + with: + node-version: '20.x' + + - name: Install + run: yarn install --frozen-lockfile + + - name: Test + run: yarn workspace @sourcebot/web test + diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 897af65d..6347d9d7 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -1,5 +1,6 @@ { "recommendations": [ - "dbaeumer.vscode-eslint" + "dbaeumer.vscode-eslint", + "bradlc.vscode-tailwindcss" ] } \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json index 463b90ca..f753e389 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -7,5 +7,16 @@ { "pattern": "./packages/*/" } + ], + // @see : https://cva.style/docs/getting-started/installation#intellisense + "tailwindCSS.experimental.classRegex": [ + [ + "cva\\(([^)]*)\\)", + "[\"'`]([^\"'`]*).*?[\"'`]" + ], + [ + "cx\\(([^)]*)\\)", + "(?:'|\"|`)([^']*)(?:'|\"|`)" + ] ] } \ No newline at end of file diff --git a/package.json b/package.json index 3e0fbd87..a14244a6 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ ], "scripts": { "build": "yarn workspaces run build", - "test": "yarn workspace @sourcebot/backend test", + "test": "yarn workspaces run test", "dev": "npm-run-all --print-label --parallel dev:zoekt dev:backend dev:web", "dev:zoekt": "export PATH=\"$PWD/bin:$PATH\" && zoekt-webserver -index .sourcebot/index -rpc", "dev:backend": "yarn workspace @sourcebot/backend dev:watch", diff --git a/packages/web/package.json b/packages/web/package.json index 4fc540bf..81ae488d 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -6,7 +6,8 @@ "dev": "next dev", "build": "next build", "start": "next start", - "lint": "next lint" + "lint": "next lint", + "test": "vitest" }, "dependencies": { "@codemirror/commands": "^6.6.0", @@ -77,9 +78,12 @@ "eslint-config-next": "14.2.6", "eslint-plugin-react": "^7.35.0", "eslint-plugin-react-hooks": "^4.6.2", + "jsdom": "^25.0.1", "npm-run-all": "^4.1.5", "postcss": "^8", "tailwindcss": "^3.4.1", - "typescript": "^5" + "typescript": "^5", + "vite-tsconfig-paths": "^5.1.3", + "vitest": "^2.1.5" } } diff --git a/packages/web/src/app/navigationMenu.tsx b/packages/web/src/app/components/navigationMenu.tsx similarity index 96% rename from packages/web/src/app/navigationMenu.tsx rename to packages/web/src/app/components/navigationMenu.tsx index 0beb2d8d..229b7713 100644 --- a/packages/web/src/app/navigationMenu.tsx +++ b/packages/web/src/app/components/navigationMenu.tsx @@ -7,8 +7,8 @@ import { GitHubLogoIcon, DiscordLogoIcon } from "@radix-ui/react-icons"; import { SettingsDropdown } from "./settingsDropdown"; import { Separator } from "@/components/ui/separator"; import Image from "next/image"; -import logoDark from "../../public/sb_logo_dark_small.png"; -import logoLight from "../../public/sb_logo_light_small.png"; +import logoDark from "../../../public/sb_logo_dark_small.png"; +import logoLight from "../../../public/sb_logo_light_small.png"; import { useRouter } from "next/navigation"; const SOURCEBOT_DISCORD_URL = "https://discord.gg/6Fhp27x7Pb"; diff --git a/packages/web/src/app/repositoryCarousel.tsx b/packages/web/src/app/components/repositoryCarousel.tsx similarity index 100% rename from packages/web/src/app/repositoryCarousel.tsx rename to packages/web/src/app/components/repositoryCarousel.tsx diff --git a/packages/web/src/app/components/searchBar/constants.ts b/packages/web/src/app/components/searchBar/constants.ts new file mode 100644 index 00000000..e08a03fe --- /dev/null +++ b/packages/web/src/app/components/searchBar/constants.ts @@ -0,0 +1,224 @@ +import { Suggestion, SuggestionMode } from "./searchSuggestionsBox"; + +/** + * List of search prefixes that can be used while the + * `refine` suggestion mode is active. + */ +enum SearchPrefix { + repo = "repo:", + r = "r:", + lang = "lang:", + file = "file:", + rev = "rev:", + revision = "revision:", + b = "b:", + branch = "branch:", + sym = "sym:", + content = "content:", + archived = "archived:", + case = "case:", + fork = "fork:", + public = "public:" +} + +const negate = (prefix: SearchPrefix) => { + return `-${prefix}`; +} + +type SuggestionModeMapping = { + suggestionMode: SuggestionMode, + prefixes: string[], +} + +/** + * Maps search prefixes to a suggestion mode. When a query starts + * with a prefix, the corresponding suggestion mode is enabled. + * @see [searchSuggestionsBox.tsx](./searchSuggestionsBox.tsx) + */ +export const suggestionModeMappings: SuggestionModeMapping[] = [ + { + suggestionMode: "repo", + prefixes: [ + SearchPrefix.repo, negate(SearchPrefix.repo), + SearchPrefix.r, negate(SearchPrefix.r), + ] + }, + { + suggestionMode: "language", + prefixes: [ + SearchPrefix.lang, negate(SearchPrefix.lang), + ] + }, + { + suggestionMode: "file", + prefixes: [ + SearchPrefix.file, negate(SearchPrefix.file), + ] + }, + { + suggestionMode: "content", + prefixes: [ + SearchPrefix.content, negate(SearchPrefix.content), + ] + }, + { + suggestionMode: "revision", + prefixes: [ + SearchPrefix.rev, negate(SearchPrefix.rev), + SearchPrefix.revision, negate(SearchPrefix.revision), + SearchPrefix.branch, negate(SearchPrefix.branch), + SearchPrefix.b, negate(SearchPrefix.b), + ] + }, + { + suggestionMode: "symbol", + prefixes: [ + SearchPrefix.sym, negate(SearchPrefix.sym), + ] + }, + { + suggestionMode: "archived", + prefixes: [ + SearchPrefix.archived + ] + }, + { + suggestionMode: "case", + prefixes: [ + SearchPrefix.case + ] + }, + { + suggestionMode: "fork", + prefixes: [ + SearchPrefix.fork + ] + }, + { + suggestionMode: "public", + prefixes: [ + SearchPrefix.public + ] + } +]; + +export const refineModeSuggestions: Suggestion[] = [ + { + value: SearchPrefix.repo, + description: "Include only results from the given repository.", + spotlight: true, + }, + { + value: negate(SearchPrefix.repo), + description: "Exclude results from the given repository." + }, + { + value: SearchPrefix.lang, + description: "Include only results from the given language.", + spotlight: true, + }, + { + value: negate(SearchPrefix.lang), + description: "Exclude results from the given language." + }, + { + value: SearchPrefix.file, + description: "Include only results from filepaths matching the given search pattern.", + spotlight: true, + }, + { + value: negate(SearchPrefix.file), + description: "Exclude results from file paths matching the given search pattern." + }, + { + value: SearchPrefix.rev, + description: "Search a given branch or tag instead of the default branch.", + spotlight: true, + }, + { + value: negate(SearchPrefix.rev), + description: "Exclude results from the given branch or tag." + }, + { + value: SearchPrefix.sym, + description: "Include only symbols matching the given search pattern.", + spotlight: true, + }, + { + value: negate(SearchPrefix.sym), + description: "Exclude results from symbols matching the given search pattern." + }, + { + value: SearchPrefix.content, + description: "Include only results from files if their content matches the given search pattern." + }, + { + value: negate(SearchPrefix.content), + description: "Exclude results from files if their content matches the given search pattern." + }, + { + value: SearchPrefix.archived, + description: "Include results from archived repositories.", + }, + { + value: SearchPrefix.case, + description: "Control case-sensitivity of search patterns." + }, + { + value: SearchPrefix.fork, + description: "Include only results from forked repositories." + }, + { + value: SearchPrefix.public, + description: "Filter on repository visibility." + }, +]; + +export const publicModeSuggestions: Suggestion[] = [ + { + value: "yes", + description: "Only include results from public repositories." + }, + { + value: "no", + description: "Only include results from private repositories." + }, +]; + +export const forkModeSuggestions: Suggestion[] = [ + { + value: "yes", + description: "Only include results from forked repositories." + }, + { + value: "no", + description: "Only include results from non-forked repositories." + }, +]; + +export const caseModeSuggestions: Suggestion[] = [ + { + value: "auto", + description: "Search patterns are case-insensitive if all characters are lowercase, and case sensitive otherwise (default)." + }, + { + value: "yes", + description: "Case sensitive search." + }, + { + value: "no", + description: "Case insensitive search." + }, +]; + +export const archivedModeSuggestions: Suggestion[] = [ + { + value: "yes", + description: "Only include results in archived repositories." + }, + { + value: "no", + description: "Only include results in non-archived repositories." + }, +]; + diff --git a/packages/web/src/app/components/searchBar/index.ts b/packages/web/src/app/components/searchBar/index.ts new file mode 100644 index 00000000..835bb099 --- /dev/null +++ b/packages/web/src/app/components/searchBar/index.ts @@ -0,0 +1,2 @@ + +export { SearchBar } from "./searchBar"; \ No newline at end of file diff --git a/packages/web/src/app/components/searchBar/languages.ts b/packages/web/src/app/components/searchBar/languages.ts new file mode 100644 index 00000000..cfa3c49c --- /dev/null +++ b/packages/web/src/app/components/searchBar/languages.ts @@ -0,0 +1,712 @@ + +// From https://github.com/github-linguist/linguist/blob/main/lib/linguist/languages.yml +const languages = [ + "1C Enterprise", + "2-Dimensional Array", + "4D", + "ABAP", + "ABAP CDS", + "ABNF", + "AGS Script", + "AIDL", + "AL", + "AMPL", + "ANTLR", + "API Blueprint", + "APL", + "ASL", + "ASN.1", + "ASP.NET", + "ATS", + "ActionScript", + "Ada", + "Adblock Filter List", + "Adobe Font Metrics", + "Agda", + "Alloy", + "Alpine Abuild", + "Altium Designer", + "AngelScript", + "Ant Build System", + "Antlers", + "ApacheConf", + "Apex", + "Apollo Guidance Computer", + "AppleScript", + "Arc", + "AsciiDoc", + "AspectJ", + "Assembly", + "Astro", + "Asymptote", + "Augeas", + "AutoHotkey", + "AutoIt", + "Avro IDL", + "Awk", + "BASIC", + "Ballerina", + "Batchfile", + "Beef", + "Befunge", + "Berry", + "BibTeX", + "Bicep", + "Bikeshed", + "Bison", + "BitBake", + "Blade", + "BlitzBasic", + "BlitzMax", + "Bluespec", + "Bluespec BH", + "Boo", + "Boogie", + "Brainfuck", + "BrighterScript", + "Brightscript", + "Browserslist", + "C", + "C#", + "C++", + "C-ObjDump", + "C2hs Haskell", + "CAP CDS", + "CIL", + "CLIPS", + "CMake", + "COBOL", + "CODEOWNERS", + "COLLADA", + "CSON", + "CSS", + "CSV", + "CUE", + "CWeb", + "Cabal Config", + "Cadence", + "Cairo", + "CameLIGO", + "Cap'n Proto", + "CartoCSS", + "Ceylon", + "Chapel", + "Charity", + "Checksums", + "ChucK", + "Circom", + "Cirru", + "Clarion", + "Clarity", + "Classic ASP", + "Clean", + "Click", + "Clojure", + "Closure Templates", + "Cloud Firestore Security Rules", + "CoNLL-U", + "CodeQL", + "CoffeeScript", + "ColdFusion", + "ColdFusion CFC", + "Common Lisp", + "Common Workflow Language", + "Component Pascal", + "Cool", + "Coq", + "Cpp-ObjDump", + "Creole", + "Crystal", + "Csound", + "Csound Document", + "Csound Score", + "Cuda", + "Cue Sheet", + "Curry", + "Cycript", + "Cypher", + "Cython", + "D", + "D-ObjDump", + "D2", + "DIGITAL Command Language", + "DM", + "DNS Zone", + "DTrace", + "Dafny", + "Darcs Patch", + "Dart", + "DataWeave", + "Debian Package Control File", + "DenizenScript", + "Dhall", + "Diff", + "DirectX 3D File", + "Dockerfile", + "Dogescript", + "Dotenv", + "Dylan", + "E", + "E-mail", + "EBNF", + "ECL", + "ECLiPSe", + "EJS", + "EQ", + "Eagle", + "Earthly", + "Easybuild", + "Ecere Projects", + "Ecmarkup", + "EditorConfig", + "Edje Data Collection", + "Eiffel", + "Elixir", + "Elm", + "Elvish", + "Elvish Transcript", + "Emacs Lisp", + "EmberScript", + "Erlang", + "Euphoria", + "F#", + "F*", + "FIGlet Font", + "FLUX", + "Factor", + "Fancy", + "Fantom", + "Faust", + "Fennel", + "Filebench WML", + "Filterscript", + "Fluent", + "Formatted", + "Forth", + "Fortran", + "Fortran Free Form", + "FreeBasic", + "FreeMarker", + "Frege", + "Futhark", + "G-code", + "GAML", + "GAMS", + "GAP", + "GCC Machine Description", + "GDB", + "GDScript", + "GEDCOM", + "GLSL", + "GN", + "GSC", + "Game Maker Language", + "Gemfile.lock", + "Gemini", + "Genero", + "Genero Forms", + "Genie", + "Genshi", + "Gentoo Ebuild", + "Gentoo Eclass", + "Gerber Image", + "Gettext Catalog", + "Gherkin", + "Git Attributes", + "Git Config", + "Git Revision List", + "Gleam", + "Glyph", + "Glyph Bitmap Distribution Format", + "Gnuplot", + "Go", + "Go Checksums", + "Go Module", + "Go Workspace", + "Godot Resource", + "Golo", + "Gosu", + "Grace", + "Gradle", + "Gradle Kotlin DSL", + "Grammatical Framework", + "Graph Modeling Language", + "GraphQL", + "Graphviz (DOT)", + "Groovy", + "Groovy Server Pages", + "HAProxy", + "HCL", + "HLSL", + "HOCON", + "HTML", + "HTML+ECR", + "HTML+EEX", + "HTML+ERB", + "HTML+PHP", + "HTML+Razor", + "HTTP", + "HXML", + "Hack", + "Haml", + "Handlebars", + "Harbour", + "Haskell", + "Haxe", + "HiveQL", + "HolyC", + "Hosts File", + "Hy", + "HyPhy", + "IDL", + "IGOR Pro", + "INI", + "IRC log", + "Idris", + "Ignore List", + "ImageJ Macro", + "Imba", + "Inform 7", + "Ink", + "Inno Setup", + "Io", + "Ioke", + "Isabelle", + "Isabelle ROOT", + "J", + "JAR Manifest", + "JCL", + "JFlex", + "JSON", + "JSON with Comments", + "JSON5", + "JSONLD", + "JSONiq", + "Janet", + "Jasmin", + "Java", + "Java Properties", + "Java Server Pages", + "JavaScript", + "JavaScript+ERB", + "Jest Snapshot", + "JetBrains MPS", + "Jinja", + "Jison", + "Jison Lex", + "Jolie", + "Jsonnet", + "Julia", + "Jupyter Notebook", + "Just", + "KRL", + "Kaitai Struct", + "KakouneScript", + "KerboScript", + "KiCad Layout", + "KiCad Legacy Layout", + "KiCad Schematic", + "Kickstart", + "Kit", + "Kotlin", + "Kusto", + "LFE", + "LLVM", + "LOLCODE", + "LSL", + "LTspice Symbol", + "LabVIEW", + "Lark", + "Lasso", + "Latte", + "Lean", + "Less", + "Lex", + "LigoLANG", + "LilyPond", + "Limbo", + "Linker Script", + "Linux Kernel Module", + "Liquid", + "Literate Agda", + "Literate CoffeeScript", + "Literate Haskell", + "LiveScript", + "Logos", + "Logtalk", + "LookML", + "LoomScript", + "Lua", + "M", + "M4", + "M4Sugar", + "MATLAB", + "MAXScript", + "MDX", + "MLIR", + "MQL4", + "MQL5", + "MTML", + "MUF", + "Macaulay2", + "Makefile", + "Mako", + "Markdown", + "Marko", + "Mask", + "Mathematica", + "Maven POM", + "Max", + "Mercury", + "Mermaid", + "Meson", + "Metal", + "Microsoft Developer Studio Project", + "Microsoft Visual Studio Solution", + "MiniD", + "MiniYAML", + "Mint", + "Mirah", + "Modelica", + "Modula-2", + "Modula-3", + "Module Management System", + "Monkey", + "Monkey C", + "Moocode", + "MoonScript", + "Motoko", + "Motorola 68K Assembly", + "Move", + "Muse", + "Mustache", + "Myghty", + "NASL", + "NCL", + "NEON", + "NL", + "NPM Config", + "NSIS", + "NWScript", + "Nasal", + "Nearley", + "Nemerle", + "NetLinx", + "NetLinx+ERB", + "NetLogo", + "NewLisp", + "Nextflow", + "Nginx", + "Nim", + "Ninja", + "Nit", + "Nix", + "Nu", + "NumPy", + "Nunjucks", + "Nushell", + "OASv2-json", + "OASv2-yaml", + "OASv3-json", + "OASv3-yaml", + "OCaml", + "ObjDump", + "Object Data Instance Notation", + "ObjectScript", + "Objective-C", + "Objective-C++", + "Objective-J", + "Odin", + "Omgrofl", + "Opa", + "Opal", + "Open Policy Agent", + "OpenAPI Specification v2", + "OpenAPI Specification v3", + "OpenCL", + "OpenEdge ABL", + "OpenQASM", + "OpenRC runscript", + "OpenSCAD", + "OpenStep Property List", + "OpenType Feature File", + "Option List", + "Org", + "Ox", + "Oxygene", + "Oz", + "P4", + "PDDL", + "PEG.js", + "PHP", + "PLSQL", + "PLpgSQL", + "POV-Ray SDL", + "Pact", + "Pan", + "Papyrus", + "Parrot", + "Parrot Assembly", + "Parrot Internal Representation", + "Pascal", + "Pawn", + "Pep8", + "Perl", + "Pic", + "Pickle", + "PicoLisp", + "PigLatin", + "Pike", + "PlantUML", + "Pod", + "Pod 6", + "PogoScript", + "Polar", + "Pony", + "Portugol", + "PostCSS", + "PostScript", + "PowerBuilder", + "PowerShell", + "Prisma", + "Processing", + "Procfile", + "Proguard", + "Prolog", + "Promela", + "Propeller Spin", + "Protocol Buffer", + "Protocol Buffer Text Format", + "Public Key", + "Pug", + "Puppet", + "Pure Data", + "PureBasic", + "PureScript", + "Pyret", + "Python", + "Python console", + "Python traceback", + "Q#", + "QML", + "QMake", + "Qt Script", + "Quake", + "R", + "RAML", + "RBS", + "RDoc", + "REALbasic", + "REXX", + "RMarkdown", + "RPC", + "RPGLE", + "RPM Spec", + "RUNOFF", + "Racket", + "Ragel", + "Raku", + "Rascal", + "Raw token data", + "ReScript", + "Readline Config", + "Reason", + "ReasonLIGO", + "Rebol", + "Record Jar", + "Red", + "Redcode", + "Redirect Rules", + "Regular Expression", + "Ren'Py", + "RenderScript", + "Rez", + "Rich Text Format", + "Ring", + "Riot", + "RobotFramework", + "Roff", + "Roff Manpage", + "Rouge", + "RouterOS Script", + "Ruby", + "Rust", + "SAS", + "SCSS", + "SELinux Policy", + "SMT", + "SPARQL", + "SQF", + "SQL", + "SQLPL", + "SRecode Template", + "SSH Config", + "STAR", + "STL", + "STON", + "SVG", + "SWIG", + "Sage", + "SaltStack", + "Sass", + "Scala", + "Scaml", + "Scenic", + "Scheme", + "Scilab", + "Self", + "ShaderLab", + "Shell", + "ShellCheck Config", + "ShellSession", + "Shen", + "Sieve", + "Simple File Verification", + "Singularity", + "Slash", + "Slice", + "Slim", + "SmPL", + "Smali", + "Smalltalk", + "Smarty", + "Smithy", + "Snakemake", + "Solidity", + "Soong", + "SourcePawn", + "Spline Font Database", + "Squirrel", + "Stan", + "Standard ML", + "Starlark", + "Stata", + "StringTemplate", + "Stylus", + "SubRip Text", + "SugarSS", + "SuperCollider", + "Svelte", + "Sway", + "Sweave", + "Swift", + "SystemVerilog", + "TI Program", + "TL-Verilog", + "TLA", + "TOML", + "TSQL", + "TSV", + "TSX", + "TXL", + "Talon", + "Tcl", + "Tcsh", + "TeX", + "Tea", + "Terra", + "Texinfo", + "Text", + "TextMate Properties", + "Textile", + "Thrift", + "Turing", + "Turtle", + "Twig", + "Type Language", + "TypeScript", + "Typst", + "Unified Parallel C", + "Unity3D Asset", + "Unix Assembly", + "Uno", + "UnrealScript", + "UrWeb", + "V", + "VBA", + "VBScript", + "VCL", + "VHDL", + "Vala", + "Valve Data Format", + "Velocity Template Language", + "Verilog", + "Vim Help File", + "Vim Script", + "Vim Snippet", + "Visual Basic .NET", + "Visual Basic 6.0", + "Volt", + "Vue", + "Vyper", + "WDL", + "WGSL", + "Wavefront Material", + "Wavefront Object", + "Web Ontology Language", + "WebAssembly", + "WebAssembly Interface Type", + "WebIDL", + "WebVTT", + "Wget Config", + "Whiley", + "Wikitext", + "Win32 Message File", + "Windows Registry Entries", + "Witcher Script", + "Wollok", + "World of Warcraft Addon Data", + "Wren", + "X BitMap", + "X Font Directory Index", + "X PixMap", + "X10", + "XC", + "XCompose", + "XML", + "XML Property List", + "XPages", + "XProc", + "XQuery", + "XS", + "XSLT", + "Xojo", + "Xonsh", + "Xtend", + "YAML", + "YANG", + "YARA", + "YASnippet", + "Yacc", + "Yul", + "ZAP", + "ZIL", + "Zeek", + "ZenScript", + "Zephir", + "Zig", + "Zimpl", + "cURL Config", + "desktop", + "dircolors", + "eC", + "edn", + "fish", + "hoon", + "jq", + "kvlang", + "mIRC Script", + "mcfunction", + "mupad", + "nanorc", + "nesC", + "ooc", + "q", + "reStructuredText", + "robots.txt", + "sed", + "wisp", + "xBase", +] + +export default languages; \ No newline at end of file diff --git a/packages/web/src/app/components/searchBar/searchBar.tsx b/packages/web/src/app/components/searchBar/searchBar.tsx new file mode 100644 index 00000000..7103f06e --- /dev/null +++ b/packages/web/src/app/components/searchBar/searchBar.tsx @@ -0,0 +1,292 @@ +'use client'; + +import { useTailwind } from "@/hooks/useTailwind"; +import { Repository, SearchQueryParams } from "@/lib/types"; +import { cn, createPathWithQueryParams } from "@/lib/utils"; +import { + cursorCharLeft, + cursorCharRight, + cursorDocEnd, + cursorDocStart, + cursorLineBoundaryBackward, + cursorLineBoundaryForward, + deleteCharBackward, + deleteCharForward, + deleteGroupBackward, + deleteGroupForward, + deleteLineBoundaryBackward, + deleteLineBoundaryForward, + history, + historyKeymap, + selectAll, + selectCharLeft, + selectCharRight, + selectDocEnd, + selectDocStart, + selectLineBoundaryBackward, + selectLineBoundaryForward +} from "@codemirror/commands"; +import { tags as t } from '@lezer/highlight'; +import { createTheme } from '@uiw/codemirror-themes'; +import CodeMirror, { Annotation, EditorView, KeyBinding, keymap, ReactCodeMirrorRef } from "@uiw/react-codemirror"; +import { cva } from "class-variance-authority"; +import { useRouter } from "next/navigation"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useHotkeys } from 'react-hotkeys-hook'; +import { SearchSuggestionsBox, Suggestion } from "./searchSuggestionsBox"; +import { useClickListener } from "@/hooks/useClickListener"; +import { getRepos } from "../../api/(client)/client"; +import languages from "./languages"; +import { zoekt } from "./zoektLanguageExtension"; + +interface SearchBarProps { + className?: string; + size?: "default" | "sm"; + defaultQuery?: string; + autoFocus?: boolean; +} + +const searchBarKeymap: readonly KeyBinding[] = ([ + { key: "ArrowLeft", run: cursorCharLeft, shift: selectCharLeft, preventDefault: true }, + { key: "ArrowRight", run: cursorCharRight, shift: selectCharRight, preventDefault: true }, + + { key: "Home", run: cursorLineBoundaryBackward, shift: selectLineBoundaryBackward, preventDefault: true }, + { key: "Mod-Home", run: cursorDocStart, shift: selectDocStart }, + + { key: "End", run: cursorLineBoundaryForward, shift: selectLineBoundaryForward, preventDefault: true }, + { key: "Mod-End", run: cursorDocEnd, shift: selectDocEnd }, + + { key: "Mod-a", run: selectAll }, + + { key: "Backspace", run: deleteCharBackward, shift: deleteCharBackward }, + { key: "Delete", run: deleteCharForward }, + { key: "Mod-Backspace", mac: "Alt-Backspace", run: deleteGroupBackward }, + { key: "Mod-Delete", mac: "Alt-Delete", run: deleteGroupForward }, + { mac: "Mod-Backspace", run: deleteLineBoundaryBackward }, + { mac: "Mod-Delete", run: deleteLineBoundaryForward } +] as KeyBinding[]).concat(historyKeymap); + +const searchBarContainerVariants = cva( + "search-bar-container flex items-center p-0.5 border rounded-md relative", + { + variants: { + size: { + default: "h-10", + sm: "h-8" + } + }, + defaultVariants: { + size: "default", + } + } +); + +export const SearchBar = ({ + className, + size, + defaultQuery, + autoFocus, +}: SearchBarProps) => { + const router = useRouter(); + const tailwind = useTailwind(); + const suggestionBoxRef = useRef(null); + const editorRef = useRef(null); + const [cursorPosition, setCursorPosition] = useState(0); + const [isSuggestionsBoxEnabled, setIsSuggestionsBoxEnabled ] = useState(false); + const [isSuggestionsBoxFocused, setIsSuggestionsBoxFocused] = useState(false); + + const focusEditor = useCallback(() => editorRef.current?.view?.focus(), []); + const focusSuggestionsBox = useCallback(() => suggestionBoxRef.current?.focus(), []); + + const [_query, setQuery] = useState(defaultQuery ?? ""); + const query = useMemo(() => { + // Replace any newlines with spaces to handle + // copy & pasting text with newlines. + return _query.replaceAll(/\n/g, " "); + }, [_query]); + + const [repos, setRepos] = useState([]); + useEffect(() => { + getRepos().then((response) => { + setRepos(response.List.Repos.map(r => r.Repository)); + }); + }, []); + + const suggestionData = useMemo(() => { + const repoSuggestions: Suggestion[] = repos.map((repo) => { + return { + value: repo.Name, + } + }); + + const languageSuggestions: Suggestion[] = languages.map((lang) => { + const spotlight = [ + "Python", + "Java", + "TypeScript", + "Go", + "C++", + "C#" + ].includes(lang); + + return { + value: lang, + spotlight, + }; + }) + + return { + repos: repoSuggestions, + languages: languageSuggestions, + } + }, [repos]); + + const theme = useMemo(() => { + return createTheme({ + theme: 'light', + settings: { + background: tailwind.theme.colors.background, + foreground: tailwind.theme.colors.foreground, + caret: '#AEAFAD', + }, + styles: [ + { + tag: t.keyword, + color: tailwind.theme.colors.highlight, + }, + { + tag: t.paren, + color: tailwind.theme.colors.highlight, + } + ], + }); + }, [tailwind]); + + const extensions = useMemo(() => { + return [ + keymap.of(searchBarKeymap), + history(), + zoekt(), + EditorView.updateListener.of(update => { + if (update.selectionSet) { + const selection = update.state.selection.main; + if (selection.empty) { + setCursorPosition(selection.anchor); + } + } + }) + ]; + }, []); + + // Hotkey to focus the search bar. + useHotkeys('/', (event) => { + event.preventDefault(); + focusEditor(); + setIsSuggestionsBoxEnabled(true); + if (editorRef.current?.view) { + cursorDocEnd({ + state: editorRef.current.view.state, + dispatch: editorRef.current.view.dispatch, + }); + } + }); + + // Collapse the suggestions box if the user clicks outside of the search bar container. + useClickListener('.search-bar-container', (isElementClicked) => { + if (!isElementClicked) { + setIsSuggestionsBoxEnabled(false); + } else { + setIsSuggestionsBoxEnabled(true); + } + }); + + const onSubmit = () => { + const url = createPathWithQueryParams('/search', + [SearchQueryParams.query, query], + ) + router.push(url); + } + + return ( +
{ + if (e.key === 'Enter') { + e.preventDefault(); + setIsSuggestionsBoxEnabled(false); + onSubmit(); + } + + if (e.key === 'Escape') { + e.preventDefault(); + setIsSuggestionsBoxEnabled(false); + } + + if (e.key === 'ArrowDown') { + e.preventDefault(); + setIsSuggestionsBoxEnabled(true); + focusSuggestionsBox(); + } + + if (e.key === 'ArrowUp') { + e.preventDefault(); + } + }} + > + { + setQuery(value); + // Whenever the user types, we want to re-enable + // the suggestions box. + setIsSuggestionsBoxEnabled(true); + }} + theme={theme} + basicSetup={false} + extensions={extensions} + indentWithTab={false} + autoFocus={autoFocus ?? false} + /> + { + setQuery(newQuery); + + // Move the cursor to it's new position. + // @note : normally, react-codemirror handles syncing `query` + // and the document state, but this happens on re-render. Since + // we want to move the cursor before the component re-renders, + // we manually update the document state inline. + editorRef.current?.view?.dispatch({ + changes: { from: 0, to: query.length, insert: newQuery }, + annotations: [Annotation.define().of(true)], + }); + + editorRef.current?.view?.dispatch({ + selection: { anchor: newCursorPosition, head: newCursorPosition }, + }); + + // Re-focus the editor since suggestions cause focus to be lost (both click & keyboard) + editorRef.current?.view?.focus(); + }} + isEnabled={isSuggestionsBoxEnabled} + onReturnFocus={() => { + focusEditor(); + }} + isFocused={isSuggestionsBoxFocused} + onFocus={() => { + setIsSuggestionsBoxFocused(document.activeElement === suggestionBoxRef.current); + }} + onBlur={() => { + setIsSuggestionsBoxFocused(document.activeElement === suggestionBoxRef.current); + }} + cursorPosition={cursorPosition} + data={suggestionData} + /> +
+ ) +} \ No newline at end of file diff --git a/packages/web/src/app/components/searchBar/searchSuggestionsBox.test.tsx b/packages/web/src/app/components/searchBar/searchSuggestionsBox.test.tsx new file mode 100644 index 00000000..da3eab0c --- /dev/null +++ b/packages/web/src/app/components/searchBar/searchSuggestionsBox.test.tsx @@ -0,0 +1,221 @@ +import { expect, test } from 'vitest' +import { completeSuggestion, splitQuery } from './searchSuggestionsBox' + +test('splitQuery returns a single element when the query is empty', () => { + const { queryParts, cursorIndex } = splitQuery('', 0); + expect(cursorIndex).toEqual(0); + expect(queryParts).toEqual(['']); +}); + +test('splitQuery splits on spaces', () => { + const query = String.raw`repo:^github\.com/example/example$ world`; + const { queryParts, cursorIndex } = splitQuery(query, 0); + expect(queryParts).toEqual(query.split(" ")); + expect(cursorIndex).toEqual(0); +}); + +test('splitQuery groups parts that are in the same quote capture group into a single part', () => { + const part1 = 'lang:"1C Enterprise"'; + const part2 = "hello"; + const { queryParts, cursorIndex } = splitQuery(`${part1} ${part2}`, 12); + expect(queryParts).toEqual([part1, part2]); + expect(cursorIndex).toEqual(0); +}); + +test('splitQuery does not support nested quote capture groups', () => { + const { queryParts } = splitQuery('lang:"My language "with quotes"" hello', 0); + expect(queryParts).toEqual(['lang:"My language "with', 'quotes""', 'hello']); +}); + +test('splitQuery groups all parts together when a quote capture group is not closed', () => { + const query = '"hello asdf ok' + const { queryParts, cursorIndex } = splitQuery(query, 0); + expect(queryParts).toEqual([query]); + expect(cursorIndex).toBe(0); +}); + +test('splitQuery correclty locates the cursor index given the cursor position (1)', () => { + const query = 'foo bar "fizz buzz"'; + + const { queryParts: parts1, cursorIndex: index1 } = splitQuery(query, 0); + expect(parts1).toEqual(['foo', 'bar', '"fizz buzz"']); + expect(parts1[index1]).toBe('foo'); + + const { queryParts: parts2, cursorIndex: index2 } = splitQuery(query, 6); + expect(parts2).toEqual(['foo', 'bar', '"fizz buzz"']); + expect(parts2[index2]).toBe('bar'); + + const { queryParts: parts3, cursorIndex: index3 } = splitQuery(query, 15); + expect(parts3).toEqual(['foo', 'bar', '"fizz buzz"']); + expect(parts3[index3]).toBe('"fizz buzz"'); +}); + +test('splitQuery correclty locates the cursor index given the cursor position (2)', () => { + const query = 'a b'; + expect(splitQuery(query, 0).cursorIndex).toBe(0); + expect(splitQuery(query, 1).cursorIndex).toBe(0); + expect(splitQuery(query, 2).cursorIndex).toBe(1); + expect(splitQuery(query, 3).cursorIndex).toBe(1); +}); + +test('splitQuery can handle multiple spaces adjacent', () => { + expect(splitQuery("a b ", 0).queryParts).toEqual(['a', '', '', 'b', '', '']); +}); + +test('splitQuery locates the cursor index to the last query part when the cursor position is at the end of the query', () => { + const query = "as df"; + const cursorPos = query.length; + const { queryParts, cursorIndex } = splitQuery(query, cursorPos); + expect(cursorIndex).toBe(queryParts.length - 1); + expect(queryParts[cursorIndex]).toBe("df"); + expect(queryParts).toEqual(['as', 'df']); +}); + +test('splitQuery sets the cursor index to 0 when the cursor position is out of bounds', () => { + const query = "hello world"; + const cursorPos = query.length + 1; + const { queryParts, cursorIndex } = splitQuery(query, cursorPos); + expect(cursorIndex).toBe(0); + expect(queryParts[cursorIndex]).toBe("hello"); + expect(queryParts).toEqual(['hello', 'world']); +}); + +test('completeSuggestion can complete a empty query', () => { + const suggestionQuery = ``; + const query = ``; + const suggestion = "hello"; + const { newQuery, newCursorPosition } = completeSuggestion({ + query, + suggestionQuery, + suggestion, + trailingSpace: false, + regexEscaped: false, + cursorPosition: 0, + }); + + const expectedNewQuery = String.raw`hello`; + expect(newQuery).toEqual(expectedNewQuery); + expect(newCursorPosition).toBe(newQuery.length); +}); + +test('completeSuggestion can complete with a empty suggestion query', () => { + const suggestionQuery = ``; + const query = `case:`; + const suggestion = "auto"; + const { newQuery, newCursorPosition } = completeSuggestion({ + query, + suggestionQuery, + suggestion, + trailingSpace: false, + regexEscaped: false, + cursorPosition: query.length, + }); + + const expectedNewQuery = `case:auto`; + expect(newQuery).toEqual(expectedNewQuery); + expect(newCursorPosition).toBe(newQuery.length); +}); + +test('completeSuggestion inserts a trailing space when trailingSpace is true and the completion is at the end of the query', () => { + const suggestionQuery = 'a'; + const part1 = String.raw`lang:Go`; + const part2 = String.raw`case:${suggestionQuery}`; + const query = `${part1} ${part2}` + const suggestion = 'auto'; + const cursorPosition = query.length; + + const { newQuery, newCursorPosition } = completeSuggestion({ + query, + suggestionQuery, + suggestion, + trailingSpace: true, + regexEscaped: false, + cursorPosition, + }); + + const expectedPart2 = `case:auto` + const expectedNewQuery = `${part1} ${expectedPart2} `; + expect(newQuery).toEqual(expectedNewQuery); + expect(newCursorPosition).toBe(newQuery.length); +}); + +test('completeSuggestion does not insert a trailing space when trailingSpace is true and the completion is not at the end of the query', () => { + const suggestionQuery = 'G'; + const part1 = String.raw`lang:${suggestionQuery}`; + const part2 = String.raw`case:auto`; + const query = `${part1} ${part2}` + const suggestion = 'Go'; + const cursorPosition = part1.length; + + const { newQuery, newCursorPosition } = completeSuggestion({ + query, + suggestionQuery, + suggestion, + trailingSpace: true, + regexEscaped: false, + cursorPosition, + }); + + const expectedPart1 = `lang:Go` + const expectedNewQuery = `${expectedPart1} ${part2}`; // Notice no trailing space + expect(newQuery).toEqual(expectedNewQuery); + expect(newCursorPosition).toBe(expectedPart1.length); +}); + +test('completeSuggestion wraps suggestions in quotes when the suggestion contains a space and regexEscaped is false', () => { + const suggestionQuery = `m`; + const query = `lang:${suggestionQuery}`; + const suggestion = `my language`; + const { newQuery, newCursorPosition } = completeSuggestion({ + query, + suggestionQuery, + suggestion, + trailingSpace: false, + regexEscaped: false, + cursorPosition: query.length, + }); + + const expectedNewQuery = `lang:"my language"`; + expect(newQuery).toEqual(expectedNewQuery); + expect(newCursorPosition).toBe(newQuery.length); +}); + +test('completeSuggestion completes on query parts that are inbetween other parts', () => { + const part1 = String.raw`repo:^github\.com/sourcebot\x2ddev/sourcebot$`; + const suggestionQuery = 'Type'; + const part2 = String.raw`lang:${suggestionQuery}`; + const part3 = String.raw`case:auto`; + const query = `${part1} ${part2} ${part3}`; + const suggestion = 'TypeScript'; + const cursorPosition = ([part1, part2].join(" ").length); + + const { newQuery, newCursorPosition } = completeSuggestion({ + query, + suggestionQuery, + suggestion, + trailingSpace: false, + regexEscaped: false, + cursorPosition, + }); + + const expectedPart2 = "lang:TypeScript"; + const expectedNewQuery = String.raw`${part1} ${expectedPart2} ${part3}`; + expect(newQuery).toEqual(expectedNewQuery); + expect(newCursorPosition).toBe([part1, expectedPart2].join(" ").length); +}); + +test('completeSuggestions regex escapes suggestions when regexEscaped is true', () => { + const query = "repo:github"; + const { newQuery, newCursorPosition } = completeSuggestion({ + query, + suggestionQuery: "github", + suggestion: "github.com/sourcebot-dev/sourcebot", + trailingSpace: true, + regexEscaped: true, + cursorPosition: query.length, + }); + + const expectedNewQuery = String.raw`repo:^github\.com/sourcebot\x2ddev/sourcebot$ `; + expect(newQuery).toEqual(expectedNewQuery); + expect(newCursorPosition).toBe(newQuery.length); +}); diff --git a/packages/web/src/app/components/searchBar/searchSuggestionsBox.tsx b/packages/web/src/app/components/searchBar/searchSuggestionsBox.tsx new file mode 100644 index 00000000..91fff3aa --- /dev/null +++ b/packages/web/src/app/components/searchBar/searchSuggestionsBox.tsx @@ -0,0 +1,453 @@ +'use client'; + +import { isDefined } from "@/lib/utils"; +import { CommitIcon, MixerVerticalIcon } from "@radix-ui/react-icons"; +import { IconProps } from "@radix-ui/react-icons/dist/types"; +import assert from "assert"; +import clsx from "clsx"; +import escapeStringRegexp from "escape-string-regexp"; +import Fuse from "fuse.js"; +import { forwardRef, Ref, useEffect, useMemo, useState } from "react"; +import { + archivedModeSuggestions, + caseModeSuggestions, + forkModeSuggestions, + publicModeSuggestions, + refineModeSuggestions, + suggestionModeMappings +} from "./constants"; + +type Icon = React.ForwardRefExoticComponent>; + +export type Suggestion = { + value: string; + description?: string; + spotlight?: boolean; +} + +export type SuggestionMode = + "refine" | + "archived" | + "file" | + "language" | + "case" | + "fork" | + "public" | + "revision" | + "symbol" | + "content" | + "repo"; + +interface SearchSuggestionsBoxProps { + query: string; + onCompletion: (newQuery: string, newCursorPosition: number) => void, + isEnabled: boolean; + cursorPosition: number; + isFocused: boolean; + onFocus: () => void; + onBlur: () => void; + onReturnFocus: () => void; + + data: { + repos: Suggestion[]; + languages: Suggestion[]; + } +} + +const SearchSuggestionsBox = forwardRef(({ + query, + onCompletion, + isEnabled, + data, + cursorPosition, + isFocused, + onFocus, + onBlur, + onReturnFocus, +}: SearchSuggestionsBoxProps, ref: Ref) => { + + const [highlightedSuggestionIndex, setHighlightedSuggestionIndex] = useState(0); + + const { suggestionQuery, suggestionMode } = useMemo<{ suggestionQuery?: string, suggestionMode?: SuggestionMode }>(() => { + const { queryParts, cursorIndex } = splitQuery(query, cursorPosition); + if (queryParts.length === 0) { + return {}; + } + const part = queryParts[cursorIndex]; + + // Check if the query part starts with one of the + // prefixes. If it does, then we are in the corresponding + // suggestion mode for that prefix. + const suggestionMode = (() => { + for (const mapping of suggestionModeMappings) { + for (const prefix of mapping.prefixes) { + if (part.startsWith(prefix)) { + return mapping.suggestionMode; + } + } + } + })(); + + if (suggestionMode) { + const index = part.indexOf(":"); + return { + suggestionQuery: part.substring(index + 1), + suggestionMode, + } + } + + // Default to the refine suggestion mode + // if there was no match. + return { + suggestionQuery: part, + suggestionMode: "refine", + } + }, [cursorPosition, query]); + + const { suggestions, isHighlightEnabled, Icon, onSuggestionClicked } = useMemo(() => { + if (!isDefined(suggestionQuery) || !isDefined(suggestionMode)) { + return {}; + } + + const createOnSuggestionClickedHandler = (params: { regexEscaped?: boolean, trailingSpace?: boolean } = {}) => { + const { + regexEscaped = false, + trailingSpace = true + } = params; + + const onSuggestionClicked = (suggestion: string) => { + const { newQuery, newCursorPosition } = completeSuggestion({ + query, + cursorPosition, + regexEscaped, + trailingSpace, + suggestion, + suggestionQuery, + }); + + onCompletion(newQuery, newCursorPosition); + } + + return onSuggestionClicked; + } + + const { + threshold = 0.5, + limit = 10, + list, + isHighlightEnabled = false, + isSpotlightEnabled = false, + onSuggestionClicked, + Icon, + } = ((): { + threshold?: number, + limit?: number, + list: Suggestion[], + isHighlightEnabled?: boolean, + isSpotlightEnabled?: boolean, + onSuggestionClicked: (value: string) => void, + Icon?: Icon + } => { + switch (suggestionMode) { + case "public": + return { + list: publicModeSuggestions, + onSuggestionClicked: createOnSuggestionClickedHandler(), + } + case "fork": + return { + list: forkModeSuggestions, + onSuggestionClicked: createOnSuggestionClickedHandler(), + } + case "case": + return { + list: caseModeSuggestions, + onSuggestionClicked: createOnSuggestionClickedHandler(), + } + case "archived": + return { + list: archivedModeSuggestions, + onSuggestionClicked: createOnSuggestionClickedHandler(), + } + case "repo": + return { + list: data.repos, + Icon: CommitIcon, + onSuggestionClicked: createOnSuggestionClickedHandler({ regexEscaped: true }), + } + case "language": { + return { + list: data.languages, + onSuggestionClicked: createOnSuggestionClickedHandler(), + isSpotlightEnabled: true, + } + } + case "refine": + return { + threshold: 0.1, + list: refineModeSuggestions, + isHighlightEnabled: true, + isSpotlightEnabled: true, + Icon: MixerVerticalIcon, + onSuggestionClicked: createOnSuggestionClickedHandler({ trailingSpace: false }), + } + case "file": + case "revision": + case "content": + case "symbol": + return { + list: [], + onSuggestionClicked: createOnSuggestionClickedHandler(), + } + } + })(); + + const fuse = new Fuse(list, { + threshold, + keys: ['value'], + isCaseSensitive: true, + }); + + const suggestions = (() => { + if (suggestionQuery.length === 0) { + // If spotlight is enabled, get the suggestions that are + // flagged to be surfaced. + if (isSpotlightEnabled) { + const spotlightSuggestions = list.filter((suggestion) => suggestion.spotlight); + return spotlightSuggestions; + + // Otherwise, just show the Nth first suggestions. + } else { + return list.slice(0, limit); + } + } + + // Special case: don't show any suggestions if the query + // is the keyword "or". + if (suggestionQuery === "or") { + return []; + } + + return fuse.search(suggestionQuery, { + limit, + }).map(result => result.item); + })(); + + return { + suggestions, + isHighlightEnabled, + Icon, + onSuggestionClicked, + } + + }, [suggestionQuery, suggestionMode, onCompletion, cursorPosition, data.repos, data.languages, query]); + + // When the list of suggestions change, reset the highlight index + useEffect(() => { + setHighlightedSuggestionIndex(0); + }, [suggestions]); + + const suggestionModeText = useMemo(() => { + if (!suggestionMode) { + return ""; + } + switch (suggestionMode) { + case "repo": + return "Repositories"; + case "refine": + return "Refine search" + default: + return ""; + } + }, [suggestionMode]); + + if ( + !isEnabled || + !suggestions || + suggestions.length === 0 + ) { + return null; + } + + return ( +
{ + if (e.key === 'Enter') { + e.stopPropagation(); + const value = suggestions[highlightedSuggestionIndex].value; + onSuggestionClicked(value); + } + + if (e.key === 'ArrowUp') { + e.stopPropagation(); + setHighlightedSuggestionIndex((curIndex) => { + return curIndex <= 0 ? suggestions.length - 1 : curIndex - 1; + }); + } + + if (e.key === 'ArrowDown') { + e.stopPropagation(); + setHighlightedSuggestionIndex((curIndex) => { + return curIndex >= suggestions.length - 1 ? 0 : curIndex + 1; + }); + } + + if (e.key === 'Escape') { + e.stopPropagation(); + onReturnFocus(); + } + }} + onFocus={onFocus} + onBlur={onBlur} + > +

+ {suggestionModeText} +

+ {suggestions.map((result, index) => ( +
{ + onSuggestionClicked(result.value) + }} + > + {Icon && ( + + )} +
+ + {result.value} + + {result.description && ( + + {result.description} + + )} +
+
+ ))} + {isFocused && ( +
+ + Press Enter to select + +
+ )} +
+ ) +}); + +SearchSuggestionsBox.displayName = "SearchSuggestionsBox"; +export { SearchSuggestionsBox }; + +export const splitQuery = (query: string, cursorPos: number) => { + const queryParts = []; + const seperator = " "; + let cursorIndex = 0; + let accumulator = ""; + let isInQuoteCapture = false; + + for (let i = 0; i < query.length; i++) { + if (i === cursorPos) { + cursorIndex = queryParts.length; + } + + if (query[i] === "\"") { + isInQuoteCapture = !isInQuoteCapture; + } + + if (!isInQuoteCapture && query[i] === seperator) { + queryParts.push(accumulator); + accumulator = ""; + continue; + } + + accumulator += query[i]; + } + queryParts.push(accumulator); + + // Edge case: if the cursor is at the end of the query, set the cursor index to the last query part + if (cursorPos === query.length) { + cursorIndex = queryParts.length - 1; + } + + // @note: since we're guaranteed to have at least one query part, we can safely assume that the cursor position + // will be within bounds. + assert(cursorIndex >= 0 && cursorIndex < queryParts.length, "Cursor position is out of bounds"); + + return { + queryParts, + cursorIndex + } +} + +export const completeSuggestion = (params: { + query: string, + suggestionQuery: string, + cursorPosition: number, + suggestion: string, + trailingSpace: boolean, + regexEscaped: boolean, +}) => { + const { + query, + suggestionQuery, + cursorPosition, + suggestion, + trailingSpace, + regexEscaped, + } = params; + + const { queryParts, cursorIndex } = splitQuery(query, cursorPosition); + + const start = queryParts.slice(0, cursorIndex).join(" "); + const end = queryParts.slice(cursorIndex + 1).join(" "); + + let part = queryParts[cursorIndex]; + + // Remove whatever query we have in the suggestion so far (if any). + // For example, if our part is "repo:gith", then we want to remove "gith" + // from the part before we complete the suggestion. + if (suggestionQuery.length > 0) { + part = part.slice(0, -suggestionQuery.length); + } + + if (regexEscaped) { + part = part + `^${escapeStringRegexp(suggestion)}$`; + } else if (suggestion.includes(" ")) { + part = part + `"${suggestion}"`; + } else { + part = part + suggestion; + } + + // Add a trailing space if we are at the end of the query + if (trailingSpace && cursorIndex === queryParts.length - 1) { + part += " "; + } + + let newQuery = [ + ...(start.length > 0 ? [start] : []), + part, + ].join(" "); + const newCursorPosition = newQuery.length; + + newQuery = [ + newQuery, + ...(end.length > 0 ? [end] : []), + ].join(" "); + + return { + newQuery, + newCursorPosition, + } +} \ No newline at end of file diff --git a/packages/web/src/app/components/searchBar/zoektLanguageExtension.ts b/packages/web/src/app/components/searchBar/zoektLanguageExtension.ts new file mode 100644 index 00000000..096d25d6 --- /dev/null +++ b/packages/web/src/app/components/searchBar/zoektLanguageExtension.ts @@ -0,0 +1,25 @@ +import { LanguageSupport, StreamLanguage } from "@codemirror/language"; +import { tags as t } from '@lezer/highlight'; + +const zoektLanguage = StreamLanguage.define({ + token: (stream) => { + if (stream.match(/-?(file|branch|revision|rev|case|repo|lang|content|sym|archived|fork|public):/)) { + return t.keyword.toString(); + } + + if (stream.match(/\bor\b/)) { + return t.keyword.toString(); + } + + if (stream.match(/(\(|\))/)) { + return t.paren.toString(); + } + + stream.next(); + return null; + }, +}); + +export const zoekt = () => { + return new LanguageSupport(zoektLanguage); +} \ No newline at end of file diff --git a/packages/web/src/app/settingsDropdown.tsx b/packages/web/src/app/components/settingsDropdown.tsx similarity index 100% rename from packages/web/src/app/settingsDropdown.tsx rename to packages/web/src/app/components/settingsDropdown.tsx diff --git a/packages/web/src/app/upgradeToast.tsx b/packages/web/src/app/components/upgradeToast.tsx similarity index 100% rename from packages/web/src/app/upgradeToast.tsx rename to packages/web/src/app/components/upgradeToast.tsx diff --git a/packages/web/src/app/page.tsx b/packages/web/src/app/page.tsx index 542d80c7..fff20c43 100644 --- a/packages/web/src/app/page.tsx +++ b/packages/web/src/app/page.tsx @@ -4,12 +4,12 @@ import Image from "next/image"; import { Suspense } from "react"; import logoDark from "../../public/sb_logo_dark_large.png"; import logoLight from "../../public/sb_logo_light_large.png"; -import { NavigationMenu } from "./navigationMenu"; -import { RepositoryCarousel } from "./repositoryCarousel"; -import { SearchBar } from "./searchBar"; +import { NavigationMenu } from "./components/navigationMenu"; +import { RepositoryCarousel } from "./components/repositoryCarousel"; +import { SearchBar } from "./components/searchBar"; import { Separator } from "@/components/ui/separator"; import { SymbolIcon } from "@radix-ui/react-icons"; -import { UpgradeToast } from "./upgradeToast"; +import { UpgradeToast } from "./components/upgradeToast"; export default async function Home() { @@ -18,7 +18,7 @@ export default async function Home() { -
+
-
- -
+
...
}>
-
+ How to search
lang:typescript (by language) - revision:HEAD (by branch or tag) + rev:HEAD (by branch or tag) { - // @todo: Get language icons return { key, displayName: key, diff --git a/packages/web/src/app/search/page.tsx b/packages/web/src/app/search/page.tsx index 7c787213..3dcbe366 100644 --- a/packages/web/src/app/search/page.tsx +++ b/packages/web/src/app/search/page.tsx @@ -18,8 +18,8 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import logoDark from "../../../public/sb_logo_dark.png"; import logoLight from "../../../public/sb_logo_light.png"; import { search } from "../api/(client)/client"; -import { SearchBar } from "../searchBar"; -import { SettingsDropdown } from "../settingsDropdown"; +import { SearchBar } from "../components/searchBar"; +import { SettingsDropdown } from "../components/settingsDropdown"; import { CodePreviewPanel } from "./components/codePreviewPanel"; import { FilterPanel } from "./components/filterPanel"; import { SearchResultsPanel } from "./components/searchResultsPanel"; @@ -109,7 +109,7 @@ export default function SearchPage() { totalMatchCount: searchResponse.Result.MatchCount, isBranchFilteringEnabled, } - }, [searchResponse, searchQuery]); + }, [searchResponse]); const isMoreResultsButtonVisible = useMemo(() => { return totalMatchCount > maxMatchDisplayCount; @@ -161,6 +161,7 @@ export default function SearchPage() {
{ - if (stream.match(/-?(file|branch|revision|rev|case|repo|lang|content|sym):/)) { - return t.keyword.toString(); - } - - if (stream.match(/\bor\b/)) { - return t.keyword.toString(); - } - - stream.next(); - return null; - }, -}); - -const zoekt = () =>{ - return new LanguageSupport(zoektLanguage); -} - -const extensions = [ - keymap.of(searchBarKeymap), - history(), - zoekt() -]; - -const searchBarVariants = cva( - "flex items-center w-full p-0.5 border rounded-md", - { - variants: { - size: { - default: "h-10", - sm: "h-8" - } - }, - defaultVariants: { - size: "default", - } - } -); - -export const SearchBar = ({ - className, - size, - defaultQuery, - autoFocus, -}: SearchBarProps) => { - const router = useRouter(); - const tailwind = useTailwind(); - - const theme = useMemo(() => { - return createTheme({ - theme: 'light', - settings: { - background: tailwind.theme.colors.background, - foreground: tailwind.theme.colors.foreground, - caret: '#AEAFAD', - }, - styles: [ - { - tag: t.keyword, - color: tailwind.theme.colors.highlight, - }, - ], - }); - }, [tailwind]); - - const [query, setQuery] = useState(defaultQuery ?? ""); - const editorRef = useRef(null); - - useHotkeys('/', (event) => { - event.preventDefault(); - editorRef.current?.view?.focus(); - if (editorRef.current?.view) { - cursorDocEnd({ - state: editorRef.current.view.state, - dispatch: editorRef.current.view.dispatch, - }); - } - }); - - const onSubmit = () => { - const url = createPathWithQueryParams('/search', - [SearchQueryParams.query, query], - ) - router.push(url); - } - - return ( -
{ - if (e.key === 'Enter') { - e.preventDefault(); - onSubmit(); - } - }} - > - { - setQuery(value); - }} - theme={theme} - basicSetup={false} - extensions={extensions} - indentWithTab={false} - autoFocus={autoFocus ?? false} - /> -
- ) -} \ No newline at end of file diff --git a/packages/web/src/hooks/useClickListener.ts b/packages/web/src/hooks/useClickListener.ts new file mode 100644 index 00000000..3df25035 --- /dev/null +++ b/packages/web/src/hooks/useClickListener.ts @@ -0,0 +1,24 @@ +'use client'; + +import { useEffect } from "react"; + +export const useClickListener = (elementSelector: string, onClick: (elementClicked: boolean) => void) => { + useEffect(() => { + const handleClick = (event: MouseEvent) => { + const element = document.querySelector(elementSelector); + + if (element) { + const isElementClicked = element.contains(event.target as Node); + onClick(isElementClicked); + } + }; + + document.addEventListener('click', handleClick); + + return () => { + document.removeEventListener('click', handleClick); + }; + }, [onClick, elementSelector]); + + return null; +} \ No newline at end of file diff --git a/packages/web/src/hooks/useTailwind.ts b/packages/web/src/hooks/useTailwind.ts index 9c72ebec..c6d05eb4 100644 --- a/packages/web/src/hooks/useTailwind.ts +++ b/packages/web/src/hooks/useTailwind.ts @@ -7,7 +7,7 @@ import tailwindConfig from '../../tailwind.config'; export const useTailwind = () => { const tailwind = useMemo(() => { return resolveConfig(tailwindConfig); - }, [tailwindConfig]); + }, []); return tailwind; } \ No newline at end of file diff --git a/packages/web/src/lib/server/searchService.ts b/packages/web/src/lib/server/searchService.ts index fc40dad5..a4a9157b 100644 --- a/packages/web/src/lib/server/searchService.ts +++ b/packages/web/src/lib/server/searchService.ts @@ -1,6 +1,6 @@ import escapeStringRegexp from "escape-string-regexp"; import { SHARD_MAX_MATCH_COUNT, TOTAL_MAX_MATCH_COUNT } from "../environment"; -import { listRepositoriesResponseSchema, searchResponseSchema, zoektSearchResponseSchema } from "../schemas"; +import { listRepositoriesResponseSchema, zoektSearchResponseSchema } from "../schemas"; import { FileSourceRequest, FileSourceResponse, ListRepositoriesResponse, SearchRequest, SearchResponse } from "../types"; import { fileNotFound, invalidZoektResponse, ServiceError, unexpectedError } from "../serviceError"; import { isServiceError } from "../utils"; diff --git a/packages/web/src/lib/utils.ts b/packages/web/src/lib/utils.ts index 3e374af5..e77de129 100644 --- a/packages/web/src/lib/utils.ts +++ b/packages/web/src/lib/utils.ts @@ -119,3 +119,8 @@ export const base64Decode = (base64: string): string => { const binString = atob(base64); return Buffer.from(Uint8Array.from(binString, (m) => m.codePointAt(0)!).buffer).toString(); } + +// @see: https://stackoverflow.com/a/65959350/23221295 +export const isDefined = (arg: T | null | undefined): arg is T extends null | undefined ? never : T => { + return arg !== null && arg !== undefined; +} \ No newline at end of file diff --git a/packages/web/vitest.config.mts b/packages/web/vitest.config.mts new file mode 100644 index 00000000..af4db3fc --- /dev/null +++ b/packages/web/vitest.config.mts @@ -0,0 +1,10 @@ +import { defineConfig } from 'vitest/config' +import tsconfigPaths from 'vite-tsconfig-paths' + +export default defineConfig({ + plugins: [tsconfigPaths()], + test: { + environment: 'jsdom', + watch: false, + }, +}) \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index 6a7c0a90..dee161d7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1779,6 +1779,16 @@ chai "^5.1.2" tinyrainbow "^1.2.0" +"@vitest/expect@2.1.5": + version "2.1.5" + resolved "https://registry.yarnpkg.com/@vitest/expect/-/expect-2.1.5.tgz#5a6afa6314cae7a61847927bb5bc038212ca7381" + integrity sha512-nZSBTW1XIdpZvEJyoP/Sy8fUg0b8od7ZpGDkTUcfJ7wz/VoZAFzFfLyxVxGFhUjJzhYqSbIpfMtl/+k/dpWa3Q== + dependencies: + "@vitest/spy" "2.1.5" + "@vitest/utils" "2.1.5" + chai "^5.1.2" + tinyrainbow "^1.2.0" + "@vitest/mocker@2.1.4": version "2.1.4" resolved "https://registry.yarnpkg.com/@vitest/mocker/-/mocker-2.1.4.tgz#0dc07edb9114f7f080a0181fbcdb16cd4a2d855d" @@ -1788,6 +1798,15 @@ estree-walker "^3.0.3" magic-string "^0.30.12" +"@vitest/mocker@2.1.5": + version "2.1.5" + resolved "https://registry.yarnpkg.com/@vitest/mocker/-/mocker-2.1.5.tgz#54ee50648bc0bb606dfc58e13edfacb8b9208324" + integrity sha512-XYW6l3UuBmitWqSUXTNXcVBUCRytDogBsWuNXQijc00dtnU/9OqpXWp4OJroVrad/gLIomAq9aW8yWDBtMthhQ== + dependencies: + "@vitest/spy" "2.1.5" + estree-walker "^3.0.3" + magic-string "^0.30.12" + "@vitest/pretty-format@2.1.4", "@vitest/pretty-format@^2.1.4": version "2.1.4" resolved "https://registry.yarnpkg.com/@vitest/pretty-format/-/pretty-format-2.1.4.tgz#fc31993bdc1ef5a6c1a4aa6844e7ba55658a4f9f" @@ -1795,6 +1814,13 @@ dependencies: tinyrainbow "^1.2.0" +"@vitest/pretty-format@2.1.5", "@vitest/pretty-format@^2.1.5": + version "2.1.5" + resolved "https://registry.yarnpkg.com/@vitest/pretty-format/-/pretty-format-2.1.5.tgz#bc79b8826d4a63dc04f2a75d2944694039fa50aa" + integrity sha512-4ZOwtk2bqG5Y6xRGHcveZVr+6txkH7M2e+nPFd6guSoN638v/1XQ0K06eOpi0ptVU/2tW/pIU4IoPotY/GZ9fw== + dependencies: + tinyrainbow "^1.2.0" + "@vitest/runner@2.1.4": version "2.1.4" resolved "https://registry.yarnpkg.com/@vitest/runner/-/runner-2.1.4.tgz#f9346500bdd0be1c926daaac5d683bae87ceda2c" @@ -1803,6 +1829,14 @@ "@vitest/utils" "2.1.4" pathe "^1.1.2" +"@vitest/runner@2.1.5": + version "2.1.5" + resolved "https://registry.yarnpkg.com/@vitest/runner/-/runner-2.1.5.tgz#4d5e2ba2dfc0af74e4b0f9f3f8be020559b26ea9" + integrity sha512-pKHKy3uaUdh7X6p1pxOkgkVAFW7r2I818vHDthYLvUyjRfkKOU6P45PztOch4DZarWQne+VOaIMwA/erSSpB9g== + dependencies: + "@vitest/utils" "2.1.5" + pathe "^1.1.2" + "@vitest/snapshot@2.1.4": version "2.1.4" resolved "https://registry.yarnpkg.com/@vitest/snapshot/-/snapshot-2.1.4.tgz#ef8c3f605fbc23a32773256d37d3fdfd9b23d353" @@ -1812,6 +1846,15 @@ magic-string "^0.30.12" pathe "^1.1.2" +"@vitest/snapshot@2.1.5": + version "2.1.5" + resolved "https://registry.yarnpkg.com/@vitest/snapshot/-/snapshot-2.1.5.tgz#a09a8712547452a84e08b3ec97b270d9cc156b4f" + integrity sha512-zmYw47mhfdfnYbuhkQvkkzYroXUumrwWDGlMjpdUr4jBd3HZiV2w7CQHj+z7AAS4VOtWxI4Zt4bWt4/sKcoIjg== + dependencies: + "@vitest/pretty-format" "2.1.5" + magic-string "^0.30.12" + pathe "^1.1.2" + "@vitest/spy@2.1.4": version "2.1.4" resolved "https://registry.yarnpkg.com/@vitest/spy/-/spy-2.1.4.tgz#4e90f9783437c5841a27c80f8fd84d7289a6100a" @@ -1819,6 +1862,13 @@ dependencies: tinyspy "^3.0.2" +"@vitest/spy@2.1.5": + version "2.1.5" + resolved "https://registry.yarnpkg.com/@vitest/spy/-/spy-2.1.5.tgz#f790d1394a5030644217ce73562e92465e83147e" + integrity sha512-aWZF3P0r3w6DiYTVskOYuhBc7EMc3jvn1TkBg8ttylFFRqNN2XGD7V5a4aQdk6QiUzZQ4klNBSpCLJgWNdIiNw== + dependencies: + tinyspy "^3.0.2" + "@vitest/utils@2.1.4": version "2.1.4" resolved "https://registry.yarnpkg.com/@vitest/utils/-/utils-2.1.4.tgz#6d67ac966647a21ce8bc497472ce230de3b64537" @@ -1828,6 +1878,15 @@ loupe "^3.1.2" tinyrainbow "^1.2.0" +"@vitest/utils@2.1.5": + version "2.1.5" + resolved "https://registry.yarnpkg.com/@vitest/utils/-/utils-2.1.5.tgz#0e19ce677c870830a1573d33ee86b0d6109e9546" + integrity sha512-yfj6Yrp0Vesw2cwJbP+cl04OC+IHFsuQsrsJBL9pyGeQXE56v1UAOQco+SR55Vf1nQzfV0QJg1Qum7AaWUwwYg== + dependencies: + "@vitest/pretty-format" "2.1.5" + loupe "^3.1.2" + tinyrainbow "^1.2.0" + abort-controller@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/abort-controller/-/abort-controller-3.0.0.tgz#eaf54d53b62bae4138e809ca225c8439a6efb392" @@ -1845,6 +1904,13 @@ acorn@^8.9.0: resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.12.1.tgz#71616bdccbe25e27a54439e0046e89ca76df2248" integrity sha512-tcpGyI9zbizT9JbV6oYE477V6mTlXvvi0T0G3SNIYE2apm/G5huBa1+K89VGeovbg+jycCrfhl3ADxErOuO6Jg== +agent-base@^7.0.2, agent-base@^7.1.0: + version "7.1.1" + resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-7.1.1.tgz#bdbded7dfb096b751a2a087eeeb9664725b2e317" + integrity sha512-H0TSyFNDMomMNJQBn8wFV5YC/2eJ+VXECwOadZJT554xP6cODZHPX3H9QMQECxvrgiSOP1pHjy1sMWQVYJOUOA== + dependencies: + debug "^4.3.4" + ajv@^6.12.4: version "6.12.6" resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.12.6.tgz#baf5a62e802b07d977034586f8c3baf5adf26df4" @@ -2342,6 +2408,13 @@ cssesc@^3.0.0: resolved "https://registry.yarnpkg.com/cssesc/-/cssesc-3.0.0.tgz#37741919903b868565e1c09ea747445cd18983ee" integrity sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg== +cssstyle@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/cssstyle/-/cssstyle-4.1.0.tgz#161faee382af1bafadb6d3867a92a19bcb4aea70" + integrity sha512-h66W1URKpBS5YMI/V8PyXvTMFT8SupJ1IzoIV8IeBC/ji8WVmrO8dGlTi+2dh6whmdk6BiKJLD/ZBkhWbcg6nA== + dependencies: + rrweb-cssom "^0.7.1" + csstype@^3.0.2: version "3.1.3" resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.1.3.tgz#d80ff294d114fb0e6ac500fbf85b60137d7eff81" @@ -2352,6 +2425,14 @@ damerau-levenshtein@^1.0.8: resolved "https://registry.yarnpkg.com/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz#b43d286ccbd36bc5b2f7ed41caf2d0aba1f8a6e7" integrity sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA== +data-urls@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/data-urls/-/data-urls-5.0.0.tgz#2f76906bce1824429ffecb6920f45a0b30f00dde" + integrity sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg== + dependencies: + whatwg-mimetype "^4.0.0" + whatwg-url "^14.0.0" + data-view-buffer@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/data-view-buffer/-/data-view-buffer-1.0.1.tgz#8ea6326efec17a2e42620696e671d7d5a8bc66b2" @@ -2379,6 +2460,13 @@ data-view-byte-offset@^1.0.0: es-errors "^1.3.0" is-data-view "^1.0.1" +debug@4, debug@^4.1.1, debug@^4.3.1, debug@^4.3.2, debug@^4.3.4, debug@^4.3.5, debug@^4.3.7: + version "4.3.7" + resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.7.tgz#87945b4151a011d76d95a198d7111c865c360a52" + integrity sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ== + dependencies: + ms "^2.1.3" + debug@^3.2.7: version "3.2.7" resolved "https://registry.yarnpkg.com/debug/-/debug-3.2.7.tgz#72580b7e9145fb39b6676f9c5e5fb100b934179a" @@ -2386,12 +2474,10 @@ debug@^3.2.7: dependencies: ms "^2.1.1" -debug@^4.1.1, debug@^4.3.1, debug@^4.3.2, debug@^4.3.4, debug@^4.3.5, debug@^4.3.7: - version "4.3.7" - resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.7.tgz#87945b4151a011d76d95a198d7111c865c360a52" - integrity sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ== - dependencies: - ms "^2.1.3" +decimal.js@^10.4.3: + version "10.4.3" + resolved "https://registry.yarnpkg.com/decimal.js/-/decimal.js-10.4.3.tgz#1044092884d245d1b7f65725fa4ad4c6f781cc23" + integrity sha512-VBBaLc1MgL5XpzgIP7ny5Z6Nx3UrRkIViUkPUdtl9aya5amy3De1gsUUSB1g3+3sExYNjCAsAznmukyxCb1GRA== deep-eql@^5.0.1: version "5.0.2" @@ -2552,6 +2638,11 @@ enhanced-resolve@^5.15.0: graceful-fs "^4.2.4" tapable "^2.2.0" +entities@^4.5.0: + version "4.5.0" + resolved "https://registry.yarnpkg.com/entities/-/entities-4.5.0.tgz#5d268ea5e7113ec74c4d033b79ea5a35a488fb48" + integrity sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw== + error-ex@^1.3.1: version "1.3.2" resolved "https://registry.yarnpkg.com/error-ex/-/error-ex-1.3.2.tgz#b4ac40648107fdcdcfae242f428bea8a14d4f1bf" @@ -2658,6 +2749,11 @@ es-iterator-helpers@^1.0.19: iterator.prototype "^1.1.2" safe-array-concat "^1.1.2" +es-module-lexer@^1.5.4: + version "1.5.4" + resolved "https://registry.yarnpkg.com/es-module-lexer/-/es-module-lexer-1.5.4.tgz#a8efec3a3da991e60efa6b633a7cad6ab8d26b78" + integrity sha512-MVNK56NiMrOwitFB7cqDwq0CQutbw+0BvLshJSse0MUNU+y1FC3bUS/AQg7oUng+/wKrrki7JfmwtVHkVfPLlw== + es-object-atoms@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/es-object-atoms/-/es-object-atoms-1.0.0.tgz#ddb55cd47ac2e240701260bc2a8e31ecb643d941" @@ -3276,6 +3372,11 @@ globby@^11.1.0: merge2 "^1.4.1" slash "^3.0.0" +globrex@^0.1.2: + version "0.1.2" + resolved "https://registry.yarnpkg.com/globrex/-/globrex-0.1.2.tgz#dd5d9ec826232730cd6793a5e33a9302985e6098" + integrity sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg== + gopd@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/gopd/-/gopd-1.0.1.tgz#29ff76de69dac7489b7c0918a5788e56477c332c" @@ -3344,11 +3445,41 @@ hosted-git-info@^2.1.4: resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-2.8.9.tgz#dffc0bf9a21c02209090f2aa69429e1414daf3f9" integrity sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw== +html-encoding-sniffer@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz#696df529a7cfd82446369dc5193e590a3735b448" + integrity sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ== + dependencies: + whatwg-encoding "^3.1.1" + +http-proxy-agent@^7.0.2: + version "7.0.2" + resolved "https://registry.yarnpkg.com/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz#9a8b1f246866c028509486585f62b8f2c18c270e" + integrity sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig== + dependencies: + agent-base "^7.1.0" + debug "^4.3.4" + http-status-codes@^2.3.0: version "2.3.0" resolved "https://registry.yarnpkg.com/http-status-codes/-/http-status-codes-2.3.0.tgz#987fefb28c69f92a43aecc77feec2866349a8bfc" integrity sha512-RJ8XvFvpPM/Dmc5SV+dC4y5PCeOhT3x1Hq0NU3rjGeg5a/CqlhZ7uudknPwZFz4aeAXDcbAyaeP7GAo9lvngtA== +https-proxy-agent@^7.0.5: + version "7.0.5" + resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-7.0.5.tgz#9e8b5013873299e11fab6fd548405da2d6c602b2" + integrity sha512-1e4Wqeblerz+tMKPIq2EMGiiWW1dIjZOksyHWSUm1rmuvw/how9hBHZ38lAGj5ID4Ik6EdkOw7NmWPy6LAwalw== + dependencies: + agent-base "^7.0.2" + debug "4" + +iconv-lite@0.6.3: + version "0.6.3" + resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.6.3.tgz#a52f80bf38da1952eb5c681790719871a1a72501" + integrity sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw== + dependencies: + safer-buffer ">= 2.1.2 < 3.0.0" + ieee754@^1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352" @@ -3547,6 +3678,11 @@ is-path-inside@^3.0.3: resolved "https://registry.yarnpkg.com/is-path-inside/-/is-path-inside-3.0.3.tgz#d231362e53a07ff2b0e0ea7fed049161ffd16283" integrity sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ== +is-potential-custom-element-name@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz#171ed6f19e3ac554394edf78caa05784a45bebb5" + integrity sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ== + is-regex@^1.1.4: version "1.1.4" resolved "https://registry.yarnpkg.com/is-regex/-/is-regex-1.1.4.tgz#eef5663cd59fa4c0ae339505323df6854bb15958" @@ -3669,6 +3805,33 @@ js-yaml@^4.1.0: dependencies: argparse "^2.0.1" +jsdom@^25.0.1: + version "25.0.1" + resolved "https://registry.yarnpkg.com/jsdom/-/jsdom-25.0.1.tgz#536ec685c288fc8a5773a65f82d8b44badcc73ef" + integrity sha512-8i7LzZj7BF8uplX+ZyOlIz86V6TAsSs+np6m1kpW9u0JWi4z/1t+FzcK1aek+ybTnAC4KhBL4uXCNT0wcUIeCw== + dependencies: + cssstyle "^4.1.0" + data-urls "^5.0.0" + decimal.js "^10.4.3" + form-data "^4.0.0" + html-encoding-sniffer "^4.0.0" + http-proxy-agent "^7.0.2" + https-proxy-agent "^7.0.5" + is-potential-custom-element-name "^1.0.1" + nwsapi "^2.2.12" + parse5 "^7.1.2" + rrweb-cssom "^0.7.1" + saxes "^6.0.0" + symbol-tree "^3.2.4" + tough-cookie "^5.0.0" + w3c-xmlserializer "^5.0.0" + webidl-conversions "^7.0.0" + whatwg-encoding "^3.1.1" + whatwg-mimetype "^4.0.0" + whatwg-url "^14.0.0" + ws "^8.18.0" + xml-name-validator "^5.0.0" + json-buffer@3.0.1: version "3.0.1" resolved "https://registry.yarnpkg.com/json-buffer/-/json-buffer-3.0.1.tgz#9338802a30d3b6605fbe0613e094008ca8c05a13" @@ -4013,6 +4176,11 @@ npm-run-all@^4.1.5: shell-quote "^1.6.1" string.prototype.padend "^3.0.0" +nwsapi@^2.2.12: + version "2.2.13" + resolved "https://registry.yarnpkg.com/nwsapi/-/nwsapi-2.2.13.tgz#e56b4e98960e7a040e5474536587e599c4ff4655" + integrity sha512-cTGB9ptp9dY9A5VbMSe7fQBcl/tt22Vcqdq8+eN93rblOuE0aCFu4aZ2vMwct/2t+lFnosm8RkQW1I0Omb1UtQ== + object-assign@^4.0.1, object-assign@^4.1.1: version "4.1.1" resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863" @@ -4148,6 +4316,13 @@ parse-json@^4.0.0: error-ex "^1.3.1" json-parse-better-errors "^1.0.1" +parse5@^7.1.2: + version "7.2.1" + resolved "https://registry.yarnpkg.com/parse5/-/parse5-7.2.1.tgz#8928f55915e6125f430cc44309765bf17556a33a" + integrity sha512-BuBYQYlv1ckiPdQi/ohiivi9Sagc9JG+Ozs0r7b/0iK3sKmrb0b9FdWdBbOdx6hBCM/F9Ir82ofnBhtZOjCRPQ== + dependencies: + entities "^4.5.0" + path-exists@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-4.0.0.tgz#513bdbe2d3b95d7762e8c1137efa195c6c61b5b3" @@ -4389,7 +4564,7 @@ ps-tree@^1.2.0: dependencies: event-stream "=3.3.4" -punycode@^2.1.0: +punycode@^2.1.0, punycode@^2.3.1: version "2.3.1" resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.1.tgz#027422e2faec0b25e1549c3e1bd8309b9133b6e5" integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg== @@ -4612,6 +4787,11 @@ rollup@^4.20.0: "@rollup/rollup-win32-x64-msvc" "4.25.0" fsevents "~2.3.2" +rrweb-cssom@^0.7.1: + version "0.7.1" + resolved "https://registry.yarnpkg.com/rrweb-cssom/-/rrweb-cssom-0.7.1.tgz#c73451a484b86dd7cfb1e0b2898df4b703183e4b" + integrity sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg== + run-parallel@^1.1.9: version "1.2.0" resolved "https://registry.yarnpkg.com/run-parallel/-/run-parallel-1.2.0.tgz#66d1368da7bdf921eb9d95bd1a9229e7f21a43ee" @@ -4653,6 +4833,18 @@ safe-stable-stringify@^2.3.1: resolved "https://registry.yarnpkg.com/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz#4ca2f8e385f2831c432a719b108a3bf7af42a1dd" integrity sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA== +"safer-buffer@>= 2.1.2 < 3.0.0": + version "2.1.2" + resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" + integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== + +saxes@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/saxes/-/saxes-6.0.0.tgz#fe5b4a4768df4f14a201b1ba6a65c1f3d9988cc5" + integrity sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA== + dependencies: + xmlchars "^2.2.0" + scheduler@^0.23.2: version "0.23.2" resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.23.2.tgz#414ba64a3b282892e944cf2108ecc078d115cdc3" @@ -4849,7 +5041,7 @@ stackback@0.0.2: resolved "https://registry.yarnpkg.com/stackback/-/stackback-0.0.2.tgz#1ac8a0d9483848d1695e418b6d031a3c3ce68e3b" integrity sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw== -std-env@^3.7.0: +std-env@^3.7.0, std-env@^3.8.0: version "3.8.0" resolved "https://registry.yarnpkg.com/std-env/-/std-env-3.8.0.tgz#b56ffc1baf1a29dcc80a3bdf11d7fca7c315e7d5" integrity sha512-Bc3YwwCB+OzldMxOXJIIvC6cPRWr/LxOp48CdQTOkPyk/t4JWWJbrilwBd7RJzKV8QW7tJkcgAmeuLLJugl5/w== @@ -5054,6 +5246,11 @@ supports-preserve-symlinks-flag@^1.0.0: resolved "https://registry.yarnpkg.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz#6eda4bd344a3c94aea376d4cc31bc77311039e09" integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w== +symbol-tree@^3.2.4: + version "3.2.4" + resolved "https://registry.yarnpkg.com/symbol-tree/-/symbol-tree-3.2.4.tgz#430637d248ba77e078883951fb9aa0eed7c63fa2" + integrity sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw== + tailwind-merge@^2.5.2: version "2.5.3" resolved "https://registry.yarnpkg.com/tailwind-merge/-/tailwind-merge-2.5.3.tgz#579546e14ddda24462e0303acd8798c50f5511bb" @@ -5156,6 +5353,18 @@ tinyspy@^3.0.2: resolved "https://registry.yarnpkg.com/tinyspy/-/tinyspy-3.0.2.tgz#86dd3cf3d737b15adcf17d7887c84a75201df20a" integrity sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q== +tldts-core@^6.1.63: + version "6.1.63" + resolved "https://registry.yarnpkg.com/tldts-core/-/tldts-core-6.1.63.tgz#094f2b05faf90cf1e228eda1caef658425c7c912" + integrity sha512-H1XCt54xY+QPbwhTgmxLkepX0MVHu3USfMmejiCOdkMbRcP22Pn2FVF127r/GWXVDmXTRezyF3Ckvhn4Fs6j7Q== + +tldts@^6.1.32: + version "6.1.63" + resolved "https://registry.yarnpkg.com/tldts/-/tldts-6.1.63.tgz#81a3898554ba1dbbdc6844ed4e68c574f09fed32" + integrity sha512-YWwhsjyn9sB/1rOkSRYxvkN/wl5LFM1QDv6F2pVR+pb/jFne4EOBxHfkKVWvDIBEAw9iGOwwubHtQTm0WRT5sQ== + dependencies: + tldts-core "^6.1.63" + to-regex-range@^5.0.1: version "5.0.1" resolved "https://registry.yarnpkg.com/to-regex-range/-/to-regex-range-5.0.1.tgz#1648c44aae7c8d988a326018ed72f5b4dd0392e4" @@ -5163,6 +5372,20 @@ to-regex-range@^5.0.1: dependencies: is-number "^7.0.0" +tough-cookie@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-5.0.0.tgz#6b6518e2b5c070cf742d872ee0f4f92d69eac1af" + integrity sha512-FRKsF7cz96xIIeMZ82ehjC3xW2E+O2+v11udrDYewUbszngYhsGa8z6YUMMzO9QJZzzyd0nGGXnML/TReX6W8Q== + dependencies: + tldts "^6.1.32" + +tr46@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/tr46/-/tr46-5.0.0.tgz#3b46d583613ec7283020d79019f1335723801cec" + integrity sha512-tk2G5R2KRwBd+ZN0zaEXpmzdKyOYksXwywulIX95MBODjSzMIuQnQ3m8JxgbhnL1LeVo7lqQKsYa1O3Htl7K5g== + dependencies: + punycode "^2.3.1" + tr46@~0.0.3: version "0.0.3" resolved "https://registry.yarnpkg.com/tr46/-/tr46-0.0.3.tgz#8184fd347dac9cdc185992f3a6622e14b9d9ab6a" @@ -5193,6 +5416,11 @@ tsc-watch@^6.2.0: ps-tree "^1.2.0" string-argv "^0.3.1" +tsconfck@^3.0.3: + version "3.1.4" + resolved "https://registry.yarnpkg.com/tsconfck/-/tsconfck-3.1.4.tgz#de01a15334962e2feb526824339b51be26712229" + integrity sha512-kdqWFGVJqe+KGYvlSO9NIaWn9jT1Ny4oKVzAJsKii5eoE9snzTJzL4+MMVOMn+fikWGFmKEylcXL710V/kIPJQ== + tsconfig-paths@^3.15.0: version "3.15.0" resolved "https://registry.yarnpkg.com/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz#5299ec605e55b1abb23ec939ef15edaf483070d4" @@ -5351,6 +5579,26 @@ vite-node@2.1.4: pathe "^1.1.2" vite "^5.0.0" +vite-node@2.1.5: + version "2.1.5" + resolved "https://registry.yarnpkg.com/vite-node/-/vite-node-2.1.5.tgz#cf28c637b2ebe65921f3118a165b7cf00a1cdf19" + integrity sha512-rd0QIgx74q4S1Rd56XIiL2cYEdyWn13cunYBIuqh9mpmQr7gGS0IxXoP8R6OaZtNQQLyXSWbd4rXKYUbhFpK5w== + dependencies: + cac "^6.7.14" + debug "^4.3.7" + es-module-lexer "^1.5.4" + pathe "^1.1.2" + vite "^5.0.0" + +vite-tsconfig-paths@^5.1.3: + version "5.1.3" + resolved "https://registry.yarnpkg.com/vite-tsconfig-paths/-/vite-tsconfig-paths-5.1.3.tgz#ffab28a9c2cb171e7685dd5cdcb93b132187cad5" + integrity sha512-0bz+PDlLpGfP2CigeSKL9NFTF1KtXkeHGZSSaGQSuPZH77GhoiQaA8IjYgOaynSuwlDTolSUEU0ErVvju3NURg== + dependencies: + debug "^4.1.1" + globrex "^0.1.2" + tsconfck "^3.0.3" + vite@^5.0.0: version "5.4.11" resolved "https://registry.yarnpkg.com/vite/-/vite-5.4.11.tgz#3b415cd4aed781a356c1de5a9ebafb837715f6e5" @@ -5388,11 +5636,44 @@ vitest@^2.1.4: vite-node "2.1.4" why-is-node-running "^2.3.0" +vitest@^2.1.5: + version "2.1.5" + resolved "https://registry.yarnpkg.com/vitest/-/vitest-2.1.5.tgz#a93b7b84a84650130727baae441354e6df118148" + integrity sha512-P4ljsdpuzRTPI/kbND2sDZ4VmieerR2c9szEZpjc+98Z9ebvnXmM5+0tHEKqYZumXqlvnmfWsjeFOjXVriDG7A== + dependencies: + "@vitest/expect" "2.1.5" + "@vitest/mocker" "2.1.5" + "@vitest/pretty-format" "^2.1.5" + "@vitest/runner" "2.1.5" + "@vitest/snapshot" "2.1.5" + "@vitest/spy" "2.1.5" + "@vitest/utils" "2.1.5" + chai "^5.1.2" + debug "^4.3.7" + expect-type "^1.1.0" + magic-string "^0.30.12" + pathe "^1.1.2" + std-env "^3.8.0" + tinybench "^2.9.0" + tinyexec "^0.3.1" + tinypool "^1.0.1" + tinyrainbow "^1.2.0" + vite "^5.0.0" + vite-node "2.1.5" + why-is-node-running "^2.3.0" + w3c-keyname@^2.2.4: version "2.2.8" resolved "https://registry.yarnpkg.com/w3c-keyname/-/w3c-keyname-2.2.8.tgz#7b17c8c6883d4e8b86ac8aba79d39e880f8869c5" integrity sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ== +w3c-xmlserializer@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz#f925ba26855158594d907313cedd1476c5967f6c" + integrity sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA== + dependencies: + xml-name-validator "^5.0.0" + web-vitals@^4.0.1: version "4.2.3" resolved "https://registry.yarnpkg.com/web-vitals/-/web-vitals-4.2.3.tgz#270c4baecfbc6ec6fc15da1989e465e5f9b94fb7" @@ -5403,6 +5684,31 @@ webidl-conversions@^3.0.0: resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz#24534275e2a7bc6be7bc86611cc16ae0a5654871" integrity sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ== +webidl-conversions@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-7.0.0.tgz#256b4e1882be7debbf01d05f0aa2039778ea080a" + integrity sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g== + +whatwg-encoding@^3.1.1: + version "3.1.1" + resolved "https://registry.yarnpkg.com/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz#d0f4ef769905d426e1688f3e34381a99b60b76e5" + integrity sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ== + dependencies: + iconv-lite "0.6.3" + +whatwg-mimetype@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz#bc1bf94a985dc50388d54a9258ac405c3ca2fc0a" + integrity sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg== + +whatwg-url@^14.0.0: + version "14.0.0" + resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-14.0.0.tgz#00baaa7fd198744910c4b1ef68378f2200e4ceb6" + integrity sha512-1lfMEm2IEr7RIV+f4lUNPOqfFL+pO+Xw3fJSqmjX9AbXcXcYOkCe1P6+9VBZB6n94af16NfZf+sSk0JCBZC9aw== + dependencies: + tr46 "^5.0.0" + webidl-conversions "^7.0.0" + whatwg-url@^5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-5.0.0.tgz#966454e8765462e37644d3626f6742ce8b70965d" @@ -5537,11 +5843,26 @@ wrappy@1: resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" integrity sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ== +ws@^8.18.0: + version "8.18.0" + resolved "https://registry.yarnpkg.com/ws/-/ws-8.18.0.tgz#0d7505a6eafe2b0e712d232b42279f53bc289bbc" + integrity sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw== + xcase@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/xcase/-/xcase-2.0.1.tgz#c7fa72caa0f440db78fd5673432038ac984450b9" integrity sha512-UmFXIPU+9Eg3E9m/728Bii0lAIuoc+6nbrNUKaRPJOFp91ih44qqGlWtxMB6kXFrRD6po+86ksHM5XHCfk6iPw== +xml-name-validator@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/xml-name-validator/-/xml-name-validator-5.0.0.tgz#82be9b957f7afdacf961e5980f1bf227c0bf7673" + integrity sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg== + +xmlchars@^2.2.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/xmlchars/-/xmlchars-2.2.0.tgz#060fe1bcb7f9c76fe2a17db86a9bc3ab894210cb" + integrity sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw== + yaml@^2.3.4: version "2.5.1" resolved "https://registry.yarnpkg.com/yaml/-/yaml-2.5.1.tgz#c9772aacf62cb7494a95b0c4f1fb065b563db130"