diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index fac3bf7d245..d86fe39a77f 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -67,8 +67,10 @@ import { import { remarkNormalizeListItemIndentation } from "../markdown-list-indentation"; import { normalizeMarkdownLinkDestination, + resolveInlineCodeFileLinkMeta, resolveMarkdownFileLinkMeta, rewriteMarkdownFileUriHref, + type MarkdownFileLinkMeta, } from "../markdown-links"; import { readLocalApi } from "../localApi"; import { cn } from "../lib/utils"; @@ -162,7 +164,7 @@ const CHAT_MARKDOWN_SANITIZE_SCHEMA = { attributes: { ...defaultSchema.attributes, "*": (defaultSchema.attributes?.["*"] ?? []).filter((attribute) => attribute !== "title"), - code: [...(defaultSchema.attributes?.code ?? []), "dataCodeMeta"], + code: [...(defaultSchema.attributes?.code ?? []), "dataCodeMeta", "dataInlineCode"], }, protocols: { ...defaultSchema.protocols, @@ -174,6 +176,7 @@ const CHAT_MARKDOWN_REMARK_PLUGINS = [ remarkGfm, remarkNormalizeListItemIndentation, remarkPreserveCodeMeta, + remarkTagInlineCode, ] satisfies NonNullable; const CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS = [ @@ -181,6 +184,7 @@ const CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS = [ remarkNormalizeListItemIndentation, remarkBreaks, remarkPreserveCodeMeta, + remarkTagInlineCode, ] satisfies NonNullable; const CHAT_MARKDOWN_REHYPE_PLUGINS = [ @@ -253,6 +257,33 @@ function remarkPreserveCodeMeta() { }; } +/** + * Fenced code also lands on the `code` component, and inline vs block is no + * longer distinguishable there once both render `` — so inline spans are + * tagged on the mdast, where the distinction still exists. Code inside a link + * label stays untagged: linkifying it would nest an anchor inside the link's + * anchor and steal its clicks. + */ +function remarkTagInlineCode() { + return (tree: MarkdownAstNode) => { + const visit = (node: MarkdownAstNode, insideLink: boolean) => { + if (node.type === "inlineCode" && !insideLink) { + node.data = { + ...node.data, + hProperties: { + ...node.data?.hProperties, + dataInlineCode: "", + }, + }; + } + const childInsideLink = insideLink || node.type === "link" || node.type === "linkReference"; + node.children?.forEach((child) => visit(child, childInsideLink)); + }; + + visit(tree, false); + }; +} + function nodeToPlainText(node: ReactNode): string { if (typeof node === "string" || typeof node === "number") { return String(node); @@ -276,11 +307,17 @@ function extractCodeBlock( const onlyChild = childNodes[0]; if ( - !isValidElement<{ className?: string; children?: ReactNode }>(onlyChild) || - onlyChild.type !== "code" + !isValidElement<{ className?: string; children?: ReactNode; node?: { tagName?: string } }>( + onlyChild, + ) ) { return null; } + // With a custom `code` component the child's type is that component, not + // the "code" tag — the hast node react-markdown attaches still names it. + if (onlyChild.type !== "code" && onlyChild.props.node?.tagName !== "code") { + return null; + } return { className: onlyChild.props.className, @@ -816,6 +853,21 @@ function buildFileLinkParentSuffixByPath(filePaths: ReadonlyArray): Map< return suffixByPath; } +const FENCED_CODE_SEGMENT_PATTERN = /(```[\s\S]*?(?:```|$))/; +const INLINE_CODE_SPAN_PATTERN = /`([^`\n]+)`/g; + +function extractInlineCodeSpans(text: string): string[] { + const spans: string[] = []; + const segments = text.split(FENCED_CODE_SEGMENT_PATTERN); + for (let index = 0; index < segments.length; index += 2) { + for (const match of (segments[index] ?? "").matchAll(INLINE_CODE_SPAN_PATTERN)) { + const span = match[1]?.trim(); + if (span) spans.push(span); + } + } + return spans; +} + function extractMarkdownLinkHrefs(text: string): string[] { const hrefs: string[] = []; for (const match of text.matchAll(MARKDOWN_LINK_HREF_PATTERN)) { @@ -1281,10 +1333,24 @@ function ChatMarkdown({ } return metaByHref; }, [cwd, text]); + const inlineCodeFileLinkMetaByText = useMemo(() => { + const metaByText = new Map(); + for (const span of extractInlineCodeSpans(text)) { + if (metaByText.has(span)) continue; + const meta = resolveInlineCodeFileLinkMeta(span, cwd); + if (meta) { + metaByText.set(span, meta); + } + } + return metaByText; + }, [cwd, text]); const fileLinkParentSuffixByPath = useMemo(() => { - const filePaths = [...markdownFileLinkMetaByHref.values()].map((meta) => meta.filePath); + const filePaths = [ + ...[...markdownFileLinkMetaByHref.values()].map((meta) => meta.filePath), + ...[...inlineCodeFileLinkMetaByText.values()].map((meta) => meta.filePath), + ]; return buildFileLinkParentSuffixByPath(filePaths); - }, [markdownFileLinkMetaByHref]); + }, [inlineCodeFileLinkMetaByText, markdownFileLinkMetaByHref]); const markdownUrlTransform = useCallback((href: string) => { return rewriteMarkdownFileUriHref(href) ?? defaultUrlTransform(href); }, []); @@ -1339,8 +1405,49 @@ function ChatMarkdown({ }, [createAssetUrl, openPreview, preparedConnection, threadRef], ); - const markdownComponents = useMemo( - () => ({ + const markdownComponents = useMemo(() => { + const fileLinkChip = ( + fileLinkMeta: MarkdownFileLinkMeta, + copyMarkdown: string, + className?: string, + ) => { + const parentSuffix = fileLinkParentSuffixByPath.get(fileLinkMeta.filePath); + const labelParts = [fileLinkMeta.basename]; + if (typeof parentSuffix === "string" && parentSuffix.length > 0) { + labelParts.push(parentSuffix); + } + if (fileLinkMeta.line) { + labelParts.push( + `L${fileLinkMeta.line}${fileLinkMeta.column ? `:C${fileLinkMeta.column}` : ""}`, + ); + } + + return ( + openMarkdownFileInPreview(fileLinkMeta.filePath) + : undefined + } + className={className} + /> + ); + }; + + return { p({ node: _node, children, ...props }) { return

{renderSkillInlineMarkdownChildren(children, skills)}

; }, @@ -1455,39 +1562,26 @@ function ChatMarkdown({ ); } - const parentSuffix = fileLinkParentSuffixByPath.get(fileLinkMeta.filePath); - const labelParts = [fileLinkMeta.basename]; - if (typeof parentSuffix === "string" && parentSuffix.length > 0) { - labelParts.push(parentSuffix); - } - if (fileLinkMeta.line) { - labelParts.push( - `L${fileLinkMeta.line}${fileLinkMeta.column ? `:C${fileLinkMeta.column}` : ""}`, - ); + return fileLinkChip( + fileLinkMeta, + `[${fileLinkMeta.basename}](${normalizedHref})`, + props.className, + ); + }, + code({ node, children, className, ...props }) { + if (node?.properties?.dataInlineCode != null) { + const codeText = nodeToPlainText(children); + const fileLinkMeta = + inlineCodeFileLinkMetaByText.get(codeText.trim()) ?? + resolveInlineCodeFileLinkMeta(codeText, cwd); + if (fileLinkMeta) { + return fileLinkChip(fileLinkMeta, `\`${codeText}\``); + } } - return ( - openMarkdownFileInPreview(fileLinkMeta.filePath) - : undefined - } - className={props.className} - /> + + {children} + ); }, table({ node: _node, ...props }) { @@ -1524,22 +1618,23 @@ function ChatMarkdown({ ); }, - }), - [ - diffThemeName, - fileLinkParentSuffixByPath, - isStreaming, - markdownFileLinkMetaByHref, - onTaskListChange, - openInPreferredEditor, - openExternalLinkInPreview, - openMarkdownFileInPreview, - resolvedTheme, - skills, - text, - threadRef, - ], - ); + }; + }, [ + cwd, + diffThemeName, + fileLinkParentSuffixByPath, + inlineCodeFileLinkMetaByText, + isStreaming, + markdownFileLinkMetaByHref, + onTaskListChange, + openInPreferredEditor, + openExternalLinkInPreview, + openMarkdownFileInPreview, + resolvedTheme, + skills, + text, + threadRef, + ]); return (
; } - if (!isValidElement<{ children?: ReactNode }>(child)) { + if (!isValidElement<{ children?: ReactNode; node?: { tagName?: string } }>(child)) { return child; } - if (child.type === "code" || child.type === "a") { + // Custom react-markdown components replace the intrinsic type, so also + // check the hast node they carry. + const markdownTagName = typeof child.type === "string" ? child.type : child.props.node?.tagName; + if (markdownTagName === "code" || markdownTagName === "a") { return child; } if (!("children" in child.props)) { diff --git a/apps/web/src/markdown-links.test.ts b/apps/web/src/markdown-links.test.ts index 5691ffa8895..9fc29613867 100644 --- a/apps/web/src/markdown-links.test.ts +++ b/apps/web/src/markdown-links.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vite-plus/test"; import { + resolveInlineCodeFileLinkMeta, resolveMarkdownFileLinkMeta, resolveMarkdownFileLinkTarget, rewriteMarkdownFileUriHref, @@ -127,3 +128,148 @@ describe("resolveMarkdownFileLinkTarget", () => { expect(resolveMarkdownFileLinkTarget("/chat/settings")).toBeNull(); }); }); + +describe("resolveInlineCodeFileLinkMeta", () => { + it("links relative paths with file extensions", () => { + expect( + resolveInlineCodeFileLinkMeta(".plans/worktree-management-v1.md", "/Users/julius/project"), + ).toMatchObject({ + targetPath: "/Users/julius/project/.plans/worktree-management-v1.md", + basename: "worktree-management-v1.md", + }); + }); + + it("links absolute posix paths", () => { + expect(resolveInlineCodeFileLinkMeta("/Users/julius/project/AGENTS.md")).toMatchObject({ + targetPath: "/Users/julius/project/AGENTS.md", + }); + expect(resolveInlineCodeFileLinkMeta("/usr/local/bin/tool")).toMatchObject({ + targetPath: "/usr/local/bin/tool", + }); + expect(resolveInlineCodeFileLinkMeta("/workspace/Makefile")).toMatchObject({ + basename: "Makefile", + }); + expect(resolveInlineCodeFileLinkMeta("/chat/settings")).toBeNull(); + }); + + it("links windows drive paths", () => { + expect(resolveInlineCodeFileLinkMeta("C:\\Users\\mike\\project\\src\\main.ts")).toMatchObject({ + basename: "main.ts", + }); + }); + + it("links relative paths with line positions", () => { + expect( + resolveInlineCodeFileLinkMeta("src/processRunner.ts:71", "/Users/julius/project"), + ).toMatchObject({ + targetPath: "/Users/julius/project/src/processRunner.ts:71", + line: 71, + }); + }); + + it("links bare filenames only when a line suffix marks them as file references", () => { + expect(resolveInlineCodeFileLinkMeta("script.ts:10", "/Users/julius/project")).toMatchObject({ + targetPath: "/Users/julius/project/script.ts:10", + line: 10, + }); + expect(resolveInlineCodeFileLinkMeta("AGENTS.md", "/Users/julius/project")).toBeNull(); + }); + + it("links extensionless bare filenames with a line suffix", () => { + expect(resolveInlineCodeFileLinkMeta("Makefile:12", "/Users/julius/project")).toMatchObject({ + targetPath: "/Users/julius/project/Makefile:12", + basename: "Makefile", + line: 12, + }); + expect(resolveInlineCodeFileLinkMeta("Dockerfile:8:2", "/Users/julius/project")).toMatchObject({ + line: 8, + column: 2, + }); + expect(resolveInlineCodeFileLinkMeta("Makefile:12")).toBeNull(); + }); + + it("does not treat arbitrary name:digits shapes as files", () => { + expect(resolveInlineCodeFileLinkMeta("error:1", "/Users/julius/project")).toBeNull(); + expect(resolveInlineCodeFileLinkMeta("TODO:12", "/Users/julius/project")).toBeNull(); + expect(resolveInlineCodeFileLinkMeta("exit:0", "/Users/julius/project")).toBeNull(); + expect(resolveInlineCodeFileLinkMeta("port:3000", "/Users/julius/project")).toBeNull(); + expect(resolveInlineCodeFileLinkMeta("http:80", "/Users/julius/project")).toBeNull(); + }); + + it("links dot-prefixed relative paths without extensions", () => { + expect( + resolveInlineCodeFileLinkMeta("./scripts/deploy", "/Users/julius/project"), + ).toMatchObject({ + basename: "deploy", + }); + }); + + it("links relative windows-style paths by normalizing backslashes", () => { + expect(resolveInlineCodeFileLinkMeta("src\\main.ts", "/Users/julius/project")).toMatchObject({ + targetPath: "/Users/julius/project/src/main.ts", + basename: "main.ts", + }); + expect( + resolveInlineCodeFileLinkMeta(".\\scripts\\deploy", "/Users/julius/project"), + ).toMatchObject({ + basename: "deploy", + }); + }); + + it("ignores hosts, ports, and versions", () => { + expect(resolveInlineCodeFileLinkMeta("127.0.0.1:3000", "/Users/julius/project")).toBeNull(); + expect(resolveInlineCodeFileLinkMeta("localhost:3000", "/Users/julius/project")).toBeNull(); + expect( + resolveInlineCodeFileLinkMeta("example.com/index.html", "/Users/julius/project"), + ).toBeNull(); + expect(resolveInlineCodeFileLinkMeta("example.com:8080", "/Users/julius/project")).toBeNull(); + expect(resolveInlineCodeFileLinkMeta("10.0.0.1:80:1", "/Users/julius/project")).toBeNull(); + expect( + resolveInlineCodeFileLinkMeta("localhost/index.html", "/Users/julius/project"), + ).toBeNull(); + expect( + resolveInlineCodeFileLinkMeta("example.uk/index.html", "/Users/julius/project"), + ).toBeNull(); + }); + + it("still links files whose extension merely resembles a tld", () => { + expect(resolveInlineCodeFileLinkMeta("script.ts:10", "/Users/julius/project")).not.toBeNull(); + expect(resolveInlineCodeFileLinkMeta("src/setup.sh:3", "/Users/julius/project")).not.toBeNull(); + expect(resolveInlineCodeFileLinkMeta("Makefile.in:12", "/Users/julius/project")).not.toBeNull(); + expect( + resolveInlineCodeFileLinkMeta("conf.d/nginx.conf", "/Users/julius/project"), + ).not.toBeNull(); + }); + + it("prefers file over country-code host when a line suffix is present", () => { + expect(resolveInlineCodeFileLinkMeta("script.pl:10", "/Users/julius/project")).toMatchObject({ + targetPath: "/Users/julius/project/script.pl:10", + line: 10, + }); + expect(resolveInlineCodeFileLinkMeta("model.pt:3", "/Users/julius/project")).not.toBeNull(); + expect( + resolveInlineCodeFileLinkMeta("example.pl/index.html", "/Users/julius/project"), + ).toBeNull(); + expect(resolveInlineCodeFileLinkMeta("example.com:8080", "/Users/julius/project")).toBeNull(); + }); + + it("ignores commands, flags, and expressions", () => { + expect(resolveInlineCodeFileLinkMeta("git worktree list --porcelain")).toBeNull(); + expect(resolveInlineCodeFileLinkMeta("node.meta", "/Users/julius/project")).toBeNull(); + expect(resolveInlineCodeFileLinkMeta("pnpm install", "/Users/julius/project")).toBeNull(); + expect(resolveInlineCodeFileLinkMeta("src/**/*.ts", "/Users/julius/project")).toBeNull(); + }); + + it("ignores extension-less relative segments like git refs and directories", () => { + expect(resolveInlineCodeFileLinkMeta("origin/main", "/Users/julius/project")).toBeNull(); + expect(resolveInlineCodeFileLinkMeta("apps/web", "/Users/julius/project")).toBeNull(); + }); + + it("ignores external urls", () => { + expect(resolveInlineCodeFileLinkMeta("https://example.com/docs.html")).toBeNull(); + }); + + it("ignores relative paths without a cwd to resolve against", () => { + expect(resolveInlineCodeFileLinkMeta(".plans/worktree-management-v1.md")).toBeNull(); + }); +}); diff --git a/apps/web/src/markdown-links.ts b/apps/web/src/markdown-links.ts index 1e24de8bb1d..a6dba941b8a 100644 --- a/apps/web/src/markdown-links.ts +++ b/apps/web/src/markdown-links.ts @@ -9,6 +9,8 @@ const RELATIVE_FILE_PATH_PATTERN = /^[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)+(?::\d const RELATIVE_FILE_NAME_PATTERN = /^[A-Za-z0-9._-]+\.[A-Za-z0-9_-]+(?::\d+){0,2}$/; const POSITION_SUFFIX_PATTERN = /:\d+(?::\d+)?$/; const POSITION_ONLY_PATTERN = /^\d+(?::\d+)?$/; +// Standard OS and dev-container roots; deliberately excludes app-route-ish +// prefixes like /app/ or /chat/ so SPA routes never read as files. const POSIX_FILE_ROOT_PREFIXES = [ "/Users/", "/home/", @@ -20,6 +22,20 @@ const POSIX_FILE_ROOT_PREFIXES = [ "/Volumes/", "/private/", "/root/", + "/usr/", + "/bin/", + "/sbin/", + "/lib/", + "/lib64/", + "/srv/", + "/dev/", + "/proc/", + "/sys/", + "/run/", + "/boot/", + "/media/", + "/workspace/", + "/workspaces/", ] as const; export interface MarkdownFileLinkMeta { @@ -170,6 +186,178 @@ export function resolveMarkdownFileLinkTarget( return resolvePathLinkTarget(pathWithPosition, cwd); } +const INLINE_CODE_DISQUALIFIER_PATTERN = /[\s`]/; +const PATH_SEPARATOR_PATTERN = /[\\/]/; +const FILE_EXTENSION_PATTERN = /\.[A-Za-z0-9_-]+$/; +const NUMERIC_DOTTED_PATTERN = /^\d+(?:\.\d+)+$/; +const BARE_EXTENSIONLESS_POSITION_PATTERN = /^[A-Za-z0-9_-]+(?::\d+){1,2}$/; +// Any `Name:digits` shape also matches `error:1`, `port:3000`, `TODO:12`, so +// extensionless linking is limited to conventional filenames. +const EXTENSIONLESS_FILE_NAMES = new Set([ + "Makefile", + "makefile", + "GNUmakefile", + "Dockerfile", + "Containerfile", + "Justfile", + "justfile", + "Rakefile", + "Gemfile", + "Procfile", + "Brewfile", + "Caddyfile", + "Vagrantfile", + "Jenkinsfile", + "Podfile", + "Fastfile", + "BUILD", + "WORKSPACE", + "LICENSE", + "LICENCE", + "COPYING", + "NOTICE", + "AUTHORS", + "CONTRIBUTORS", + "CHANGELOG", + "README", + "CODEOWNERS", +]); +const SINGLE_LABEL_HOSTNAMES = new Set(["localhost"]); +// Allowlists, not full public-suffix detection: treating every dotted first +// segment as a host would swallow real paths like `conf.d/x.conf` or +// `Makefile.in:12`. Extensions that double as filename suffixes (`sh`, `md`, +// `ts`, `rs`, `in`, ...) are deliberately absent from both sets. +const GENERIC_HOSTNAME_TLDS = new Set([ + "com", + "net", + "org", + "io", + "dev", + "app", + "ai", + "co", + "edu", + "gov", + "mil", + "info", + "biz", + "xyz", + "me", + "tv", + "cc", + "gg", + "chat", + "cloud", + "site", + "online", + "tech", + "store", + "link", +]); +// Country codes collide with file extensions (`.pl` Perl, `.pt` PyTorch, +// `.es` ES modules), so they only count as host evidence when the candidate +// lacks a :line suffix — an explicit line reference marks a file and wins. +const COUNTRY_HOSTNAME_TLDS = new Set([ + "uk", + "de", + "fr", + "nl", + "se", + "no", + "fi", + "dk", + "pl", + "ch", + "at", + "be", + "es", + "it", + "pt", + "eu", + "us", + "ca", + "au", + "nz", + "jp", + "kr", + "cn", + "br", + "ru", + "mx", + "ie", + "cz", + "tr", + "sg", + "hk", +]); + +/** `127.0.0.1`, `localhost`, `example.com`, `1.2.3` — hosts and versions, not files. */ +function looksLikeHostname(segment: string, hasPosition: boolean): boolean { + if (segment.startsWith(".")) return false; + const lowered = segment.toLowerCase(); + if (SINGLE_LABEL_HOSTNAMES.has(lowered)) return true; + if (NUMERIC_DOTTED_PATTERN.test(segment)) return true; + const labels = lowered.split("."); + const lastLabel = labels[labels.length - 1]; + if (labels.length < 2 || lastLabel === undefined) return false; + if (GENERIC_HOSTNAME_TLDS.has(lastLabel)) return true; + return !hasPosition && COUNTRY_HOSTNAME_TLDS.has(lastLabel); +} + +/** + * Inline code spans mostly hold identifiers, commands, and refs (`node.meta`, + * `origin/main`) rather than deliberate link destinations, so auto-linking + * them demands stronger path evidence than an explicit markdown link does: + * an unambiguous path prefix, a file extension, or a :line suffix. + */ +export function resolveInlineCodeFileLinkMeta( + codeText: string, + cwd?: string, +): MarkdownFileLinkMeta | null { + const trimmed = codeText.trim(); + if (trimmed.length === 0 || INLINE_CODE_DISQUALIFIER_PATTERN.test(trimmed)) return null; + + // Windows drive/UNC paths keep their backslashes; any other backslashes are + // relative Windows-style paths, which neither the shape checks nor the + // downstream resolver understand — normalize them to forward slashes. + const candidate = + WINDOWS_DRIVE_PATH_PATTERN.test(trimmed) || WINDOWS_UNC_PATH_PATTERN.test(trimmed) + ? trimmed + : trimmed.replaceAll("\\", "/"); + + const hasPosition = POSITION_SUFFIX_PATTERN.test(candidate); + if (!hasPosition && !PATH_SEPARATOR_PATTERN.test(candidate)) return null; + + const hasExplicitPathShape = + RELATIVE_PATH_PREFIX_PATTERN.test(candidate) || + candidate.startsWith("/") || + WINDOWS_DRIVE_PATH_PATTERN.test(candidate) || + WINDOWS_UNC_PATH_PATTERN.test(candidate); + if (!hasExplicitPathShape) { + const withoutPosition = candidate.replace(POSITION_SUFFIX_PATTERN, ""); + const firstSegment = withoutPosition.split("/")[0] ?? withoutPosition; + if (looksLikeHostname(firstSegment, hasPosition)) return null; + if (!hasPosition && !FILE_EXTENSION_PATTERN.test(basenameOfPath(withoutPosition))) { + return null; + } + } + + const resolved = resolveMarkdownFileLinkMeta(candidate, cwd); + if (resolved) return resolved; + + // `Makefile:12` — conventional extensionless names fail the generic + // markdown-link candidate patterns, but here the :line suffix already + // marked the span as a file reference. + if ( + cwd && + BARE_EXTENSIONLESS_POSITION_PATTERN.test(candidate) && + EXTENSIONLESS_FILE_NAMES.has(candidate.replace(POSITION_SUFFIX_PATTERN, "")) + ) { + return buildFileLinkMetaFromTarget(resolvePathLinkTarget(candidate, cwd), cwd); + } + return null; +} + function basenameOfPath(path: string): string { const separatorIndex = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\")); return separatorIndex >= 0 ? path.slice(separatorIndex + 1) : path; @@ -194,7 +382,10 @@ export function resolveMarkdownFileLinkMeta( ): MarkdownFileLinkMeta | null { const targetPath = resolveMarkdownFileLinkTarget(href, cwd); if (!targetPath) return null; + return buildFileLinkMetaFromTarget(targetPath, cwd); +} +function buildFileLinkMetaFromTarget(targetPath: string, cwd?: string): MarkdownFileLinkMeta { const { path, line, column } = splitPathAndPosition(targetPath); const parsedLine = line ? Number.parseInt(line, 10) : Number.NaN; const parsedColumn = column ? Number.parseInt(column, 10) : Number.NaN;