fix(custom-widgets): harden v2 authoring
This commit is contained in:
171 files changed
+20311
-50527
No files matched your search
@@ -1,20 +1,38 @@
|
||||
import { snippetCompletion } from "@codemirror/autocomplete";
|
||||
import type { Completion, CompletionSource } from "@codemirror/autocomplete";
|
||||
import { jsxLanguage } from "@codemirror/lang-javascript";
|
||||
import { hoverTooltip } from "@codemirror/view";
|
||||
|
||||
import { customJsxComponentByName, customJsxTablerIconNames, enabledCustomJsxComponents } from "../core";
|
||||
import {
|
||||
customJsxAuthoringCatalog,
|
||||
customJsxCatalogComponentByName,
|
||||
getCustomJsxComponentProps,
|
||||
} from "../core/component-catalog";
|
||||
import { customJsxTablerIconNames } from "../core/tabler-icons";
|
||||
|
||||
const componentCompletions: Completion[] = enabledCustomJsxComponents.map((component) => ({
|
||||
const enabledCatalogComponents = customJsxAuthoringCatalog.components.filter(
|
||||
(component) => component.safety !== "denied",
|
||||
);
|
||||
|
||||
const componentCompletions: Completion[] = enabledCatalogComponents.map((component) => ({
|
||||
label: component.name,
|
||||
type: "class",
|
||||
detail: `${component.category} · ${component.safety}`,
|
||||
info: component.documentationUrl,
|
||||
detail: `${component.package} · ${component.category} · ${component.safety}`,
|
||||
info: component.description ?? component.documentationUrl,
|
||||
}));
|
||||
|
||||
const propCompletions: Completion[] = [
|
||||
...new Set(enabledCustomJsxComponents.flatMap((component) => component.supportedProps)),
|
||||
]
|
||||
.filter((prop) => prop !== "key")
|
||||
.map((prop) => ({ label: `${prop}=`, type: "property", detail: "Mantine prop" }));
|
||||
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}"`,
|
||||
@@ -22,32 +40,102 @@ const iconCompletions: Completion[] = customJsxTablerIconNames.map((name) => ({
|
||||
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: [...componentCompletions, ...propCompletions, ...iconCompletions, ...additional],
|
||||
options: [...localCompletions, ...safeBlockCompletions, ...contextualCompletions, ...additional],
|
||||
};
|
||||
};
|
||||
|
||||
export const customJsxComponentHover = hoverTooltip((view, position) => {
|
||||
const word = view.state.wordAt(position);
|
||||
if (!word) return null;
|
||||
const name = view.state.doc.sliceString(word.from, word.to);
|
||||
const component = customJsxComponentByName.get(name);
|
||||
if (!component || component.safety === "denied") return null;
|
||||
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: word.from,
|
||||
end: word.to,
|
||||
pos: token.from,
|
||||
end: token.to,
|
||||
above: true,
|
||||
create: () => {
|
||||
const dom = document.createElement("div");
|
||||
dom.style.padding = "6px 8px";
|
||||
dom.textContent = `${component.name} · ${component.category} · ${component.documentationUrl}`;
|
||||
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) };
|
||||
}
|
||||
Reference in new issue
Block a user