import { DIGITS, LETTERS } from "#configuration/constants/syntax.constants"; import { INLINE_CODE_SELECTOR, QUOTE_MARKS, REFERENCE_CLASS_NAME, REFERENCE_NAME_ATTRIBUTE, REFERENCE_SELECTOR, STRING_TOKEN_SELECTOR, } from "#configuration/constants/anatomy.constants"; import { REFERENCE_CLASS, REFERENCE_EDGES_CLASS, REFERENCE_RELATION_CLASS, REFERENCE_TITLE_CLASS, } from "#configuration/constants/reference.constants"; import { clearChildren, createElement } from "#core/factories/element.factory"; import { definitionsNamed, resolveFolder, resolvePath, resolveSpecifier } from "#core/loaders/definition.loader"; import { folderHref, nodeHref } from "#domain/converters/source.converter"; import type { DefinitionLocation } from "#types/anatomy.types"; import { ESCAPE_KEY } from "#configuration/constants/overlay.constants"; import { LINE_MARK } from "#configuration/strings/folder.strings"; import { OPEN_CLASS } from "#configuration/constants/element.constants"; import { SOURCE_PANEL_ID } from "#core/ids/anatomy.ids"; import { createLink } from "#presentation/components/link.component"; import { placePanel } from "#presentation/components/reference.component"; const WORD_CHARS = LETTERS + DIGITS; const NO_CLASS = ""; const unquoted = function unquoted(text: string): string { const trimmed = text.trim(); const first = trimmed.charAt(0); const last = trimmed.slice(-1); return first.length > 0 && QUOTE_MARKS.includes(first) && QUOTE_MARKS.includes(last) ? trimmed.slice(1, -1) : trimmed; }; const wordsOf = function wordsOf(text: string): string[] { const parts: string[] = []; let current = ""; let word = false; for (const char of text) { const isWord = WORD_CHARS.includes(char); if (current.length > 0 && isWord !== word) { parts.push(current); current = ""; } current += char; word = isWord; } return current.length === 0 ? parts : [...parts, current]; }; const referenceLink = function referenceLink(text: string, href: string): HTMLElement { return createLink(href, REFERENCE_CLASS_NAME, [text]); }; const referenceSpan = function referenceSpan(text: string, name: string): HTMLElement { return createElement("span", { attributes: { [REFERENCE_NAME_ATTRIBUTE]: name }, className: REFERENCE_CLASS_NAME, text, }); }; const wordNode = function wordNode(part: string): Node { const locations = WORD_CHARS.includes(part.charAt(0)) ? definitionsNamed(part) : []; const [only] = locations; if (locations.length === 1 && only !== undefined) { return referenceLink(part, nodeHref(only.file, only.line)); } return locations.length > 1 ? referenceSpan(part, part) : document.createTextNode(part); }; const markWords = function markWords(node: Text): void { const pieces = wordsOf(node.data).map(wordNode); if (pieces.some((piece) => piece instanceof HTMLElement)) { node.replaceWith(...pieces); } }; const textNodesOf = function textNodesOf(code: Element): Text[] { const found: Text[] = []; const walker = document.createTreeWalker(code, NodeFilter.SHOW_TEXT); let current = walker.nextNode(); while (current !== null) { if (current instanceof Text && current.parentElement?.closest(STRING_TOKEN_SELECTOR) === null) { found.push(current); } current = walker.nextNode(); } return found; }; export const markReferences = function markReferences(code: Element, path: string): void { for (const quoted of code.querySelectorAll(STRING_TOKEN_SELECTOR)) { const resolved = resolveSpecifier(unquoted(quoted.textContent), path); if (resolved !== null) { const link = referenceLink(quoted.textContent, nodeHref(resolved, null)); link.classList.add(...quoted.classList); quoted.replaceWith(link); } } for (const node of textNodesOf(code)) { markWords(node); } }; const codeReference = function codeReference(text: string, path: string): HTMLElement | null { const file = resolvePath(text) ?? resolveSpecifier(text, path); if (file !== null) { return referenceLink(text, nodeHref(file, null)); } const folder = resolveFolder(text); if (folder !== null) { return referenceLink(text, folderHref(folder)); } const locations = definitionsNamed(text); const [only] = locations; if (locations.length === 1 && only !== undefined) { return referenceLink(text, nodeHref(only.file, only.line)); } return locations.length > 1 ? referenceSpan(text, text) : null; }; export const markDocumentReferences = function markDocumentReferences(rendered: Element, path: string): void { for (const code of rendered.querySelectorAll(INLINE_CODE_SELECTOR)) { const reference = codeReference(code.textContent, path); if (reference !== null) { code.replaceChildren(reference); } } }; const sharedPanel = function sharedPanel(): HTMLElement { const existing = document.getElementById(SOURCE_PANEL_ID); if (existing !== null) { return existing; } const panel = createElement("div", { attributes: { id: SOURCE_PANEL_ID }, className: REFERENCE_CLASS }); document.body.append(panel); const hide = function hide(): void { panel.classList.remove(OPEN_CLASS); clearChildren(panel); }; document.addEventListener("keydown", (event) => { if (event.key === ESCAPE_KEY) { hide(); } }); document.addEventListener("pointerdown", (event) => { if (!(event.target instanceof Node) || !panel.contains(event.target)) { hide(); } }); return panel; }; const candidateLink = function candidateLink(location: DefinitionLocation): HTMLElement { const link = createLink(nodeHref(location.file, location.line), NO_CLASS, [ location.file + LINE_MARK + String(location.line), ]); return createElement("div", { children: [createElement("span", { children: [link], className: REFERENCE_EDGES_CLASS })], className: REFERENCE_RELATION_CLASS, }); }; const offerCandidates = function offerCandidates( name: string, locations: readonly DefinitionLocation[], anchor: Element, ): void { const panel = sharedPanel(); clearChildren(panel); panel.append( createElement("div", { className: REFERENCE_TITLE_CLASS, text: name }), ...locations.map(candidateLink), ); panel.classList.add(OPEN_CLASS); placePanel(panel, anchor); }; const ambiguous = function ambiguous(target: EventTarget | null): Element | null { const reference = target instanceof Element ? target.closest(REFERENCE_SELECTOR) : null; const name = reference?.getAttribute(REFERENCE_NAME_ATTRIBUTE) ?? null; return name === null ? null : reference; }; export const enableSourceNavigation = function enableSourceNavigation(holder: HTMLElement): void { holder.addEventListener("click", (event) => { const reference = ambiguous(event.target); if (reference === null) { return; } const name = reference.getAttribute(REFERENCE_NAME_ATTRIBUTE) ?? ""; offerCandidates(name, definitionsNamed(name), reference); }); };