Files
homarr/packages/custom-widgets/src/workbench/code-language.ts
T

142 lines
5.6 KiB
TypeScript

import { snippetCompletion } from "@codemirror/autocomplete";
import type { Completion, CompletionSource } from "@codemirror/autocomplete";
import { jsxLanguage } from "@codemirror/lang-javascript";
import { hoverTooltip } from "@codemirror/view";
import {
customJsxAuthoringCatalog,
customJsxCatalogComponentByName,
getCustomJsxComponentProps,
} from "../core/component-catalog";
import { customJsxTablerIconNames } from "../core/tabler-icons";
const enabledCatalogComponents = customJsxAuthoringCatalog.components.filter(
(component) => component.safety !== "denied",
);
const componentCompletions: Completion[] = enabledCatalogComponents.map((component) => ({
label: component.name,
type: "class",
detail: `${component.package} · ${component.category} · ${component.safety}`,
info: component.description ?? component.documentationUrl,
}));
export function getCustomJsxComponentPropCompletions(componentName: string): Completion[] {
return getCustomJsxComponentProps(componentName)
.filter((prop) => prop.name !== "key")
.map((prop) => ({
label: `${prop.name}=`,
type: "property",
detail: `${prop.type}${prop.required ? " · required" : ""}`,
info: [prop.description, prop.literalValues?.length ? `Values: ${prop.literalValues.join(", ")}` : undefined]
.filter(Boolean)
.join("\n"),
}));
}
const iconCompletions: Completion[] = customJsxTablerIconNames.map((name) => ({
label: `name="${name}"`,
type: "enum",
detail: "Safe Tabler icon",
}));
const safeBlockCompletions: Completion[] = [
snippetCompletion("map((item) => {\n\tconst value = ${};\n\treturn ${};\n})", {
label: "map block",
detail: "Safe immutable collection callback",
type: "snippet",
}),
snippetCompletion("(() => {\n\tconst value = ${};\n\treturn ${};\n})()", {
label: "derived value IIFE",
detail: "Safe page-level derived value",
type: "snippet",
}),
];
export function getCustomJsxLocalConstCompletions(source: string, cursor: number): Completion[] {
const wrapped = `<>${source}</>`;
const target = cursor + 2;
let activeNode = jsxLanguage.parser.parse(wrapped).resolveInner(target, -1);
while (activeNode.name !== "Block") {
const parent = activeNode.parent;
if (!parent) return [];
activeNode = parent;
}
const completions: Completion[] = [];
for (let statement = activeNode.firstChild; statement; statement = statement.nextSibling) {
if (statement.name !== "VariableDeclaration" || statement.from >= target) continue;
if (statement.firstChild?.name !== "const") continue;
const definitions = statement.getChildren("VariableDefinition");
definitions.forEach((definition, index) => {
const nextDefinition = definitions[index + 1];
const declaratorFinished = nextDefinition ? nextDefinition.from <= target : statement.to < target;
if (!declaratorFinished) return;
completions.push({
label: wrapped.slice(definition.from, definition.to),
type: "variable",
detail: "Safe local const",
});
});
}
return completions;
}
export const createCustomJsxCompletionSource =
(additional: Completion[]): CompletionSource =>
(context) => {
const word = context.matchBefore(/[A-Za-z](?:[\w."'_-]|\[|\])*/u);
if (!word && !context.explicit) return null;
const source = context.state.doc.toString();
const localCompletions = getCustomJsxLocalConstCompletions(source, context.pos);
const componentName = activeOpeningComponent(source, context.pos);
const contextualCompletions = componentName
? [
...getCustomJsxComponentPropCompletions(componentName),
...(componentName === "TablerIcon" ? iconCompletions : []),
]
: componentCompletions;
return {
from: word?.from ?? context.pos,
options: [...localCompletions, ...safeBlockCompletions, ...contextualCompletions, ...additional],
};
};
export const customJsxComponentHover = hoverTooltip((view, position) => {
const source = view.state.doc.toString();
const token = dottedTokenAt(source, position);
if (!token) return null;
const component = customJsxCatalogComponentByName.get(token.value);
const activeComponentName = activeOpeningComponent(source, position);
const prop = activeComponentName
? getCustomJsxComponentProps(activeComponentName).find((candidate) => candidate.name === token.value)
: undefined;
if ((!component || component.safety === "denied") && !prop) return null;
return {
pos: token.from,
end: token.to,
above: true,
create: () => {
const dom = document.createElement("div");
dom.style.padding = "6px 8px";
dom.textContent = prop
? `${activeComponentName}.${prop.name}: ${prop.type}${prop.required ? " · required" : ""}${prop.description ? ` — ${prop.description}` : ""}`
: `${component?.name} · ${component?.package} · ${component?.category}${component?.description ? ` — ${component.description}` : ""}`;
return { dom };
},
};
});
function activeOpeningComponent(source: string, position: number): string | undefined {
const match = source.slice(0, position).match(/<([A-Z][A-Za-z0-9.]*)\b[^<>]*$/u);
const name = match?.[1];
return name && customJsxCatalogComponentByName.get(name)?.safety !== "denied" ? name : undefined;
}
function dottedTokenAt(source: string, position: number) {
let from = position;
let to = position;
while (from > 0 && /[A-Za-z0-9._-]/u.test(source[from - 1] ?? "")) from -= 1;
while (to < source.length && /[A-Za-z0-9._-]/u.test(source[to] ?? "")) to += 1;
return from === to ? null : { from, to, value: source.slice(from, to) };
}