Something went wrong. Try again.
Monorepo for Tangled tangled.org
Something went wrong. Try again.
TypeScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372import { parse, type AST } from "svelte/compiler";
// the svelte ast is estree with typescript nodes mixed in, so nodes are read by their type// eslint-disable-next-line @typescript-eslint/no-explicit-anytype Node = { type: string } & Record<string, any>;type Attribute = AST.RegularElement["attributes"][number] | AST.Component["attributes"][number];type Element = AST.RegularElement | AST.Component;type Found = { id: string; source: string };type Insert = { at: number; text: string };type Seen = { line: number; id: string | null; source: string | null };type Options = { component?: string; attribute?: string };
// data-testid rides on the primitive's rest props, testid is a prop for the ones that spread noneexport const components: Record<string, "data-testid" | "testid"> = { ActorInput: "data-testid", Button: "data-testid", ButtonCard: "data-testid", Checkbox: "data-testid", DropdownItem: "data-testid", Input: "data-testid", Link: "data-testid", Radio: "data-testid", Segment: "data-testid", Textarea: "data-testid", Toggle: "data-testid", Combobox: "testid", ConfirmModal: "testid", Dropdown: "testid", MarkdownEditor: "testid", Modal: "testid", MoreMenu: "testid", Select: "testid", SplitButton: "testid"};
const interactive = new Set([ "button", "a", "input", "select", "textarea", "form", "dialog", "summary"]);const boundKeys = new Set(["value", "checked", "group", "files"]);const instanceKeys = new Set(["open", "selected", "value"]);// these spread rest onto their root, so data-component can land thereconst rootComponents = new Set([ "Card", "LayerCard", "LayerCardPrimary", "LayerCardSecondary", "ButtonGroup"]);const transparent = new Set(["IfBlock", "EachBlock", "KeyBlock", "AwaitBlock", "SvelteBoundary"]);
const cap = (word: string) => word.charAt(0).toUpperCase() + word.slice(1);const camel = (parts: string[]) => parts[0] + parts.slice(1).map(cap).join("");const slug = (text: string) => { const words = text.split(/[^A-Za-z0-9]+/).filter(Boolean); if (!words.length) return null; const [first, ...rest] = words.slice(0, 4); return first.charAt(0).toLowerCase() + first.slice(1) + rest.map(cap).join("");};
const unwrap = (node: Node | null | undefined): Node | null | undefined => { while ( node && (node.type === "ChainExpression" || node.type === "TSAsExpression" || node.type === "TSNonNullExpression" || node.type === "AwaitExpression" || (node.type === "UnaryExpression" && node.operator === "void")) ) node = node.expression ?? node.argument; return node;};
const chain = (node: Node | null | undefined): string[] | null => { node = unwrap(node); if (!node) return null; if (node.type === "Identifier") return [node.name]; if (node.type === "MemberExpression" && !node.computed) { const object = chain(node.object); return object && [...object, node.property.name]; } return null;};
const statementOf = (body: Node): Node | null => { if (body.type !== "BlockStatement") return body; if (body.body.length !== 1) return null; const [only] = body.body; return only.type === "ExpressionStatement" ? only.expression : only.argument;};
const assignment = (node: Node) => { const target = chain(node.left); if (!target) return null; const name = camel(target); const stem = name.endsWith("Open") && name.length > 4 ? name.slice(0, -4) : ""; const { right } = node; const flag = right.type === "Literal" ? right.value : undefined; const toggles = right.type === "UnaryExpression" && right.operator === "!"; if (node.operator !== "=") return `set${cap(name)}`; if (name === "open" && typeof flag === "boolean") return flag ? "open" : "close"; if (stem && typeof flag === "boolean") return `${flag ? "open" : "close"}${cap(stem)}`; if (toggles) return `toggle${cap(name)}`; if (flag === true) return `set${cap(name)}`; if (flag === false) return `clear${cap(name)}`; return `set${cap(name)}`;};
// save.run(x) calls an action() helper, so the thing named is saveconst callName = (parts: string[]) => camel(parts.length > 1 && parts.at(-1) === "run" ? parts.slice(0, -1) : parts);
const handler = (expr: Node | null | undefined) => { let node = unwrap(expr); if (!node) return null; if (node.type === "ArrowFunctionExpression" || node.type === "FunctionExpression") { node = statementOf(node.body); node = node && unwrap(node); if (node?.type === "LogicalExpression") node = unwrap(node.right); if (!node) return null; if (node.type === "AssignmentExpression") return assignment(node); if (node.type !== "CallExpression") return null; const callee = chain(node.callee); return callee && callName(callee); } const parts = chain(node); return parts && callName(parts);};
const staticText = (value: unknown) => { if (!Array.isArray(value)) return null; return value.every((part) => part.type === "Text") ? value.map((part) => part.data).join("") : null;};
const literal = (attribute: { value?: unknown }) => { const text = staticText(attribute.value); if (text !== null) return text; const expr = (attribute.value as Node | undefined)?.expression; if (expr?.type === "Literal" && typeof expr.value === "string") return expr.value; return null;};
// dynamic segments don't count, so /${handle}/issues is issuesconst lastSegment = (path: string) => { if (/^[a-z][a-z0-9+.-]*:/i.test(path)) return null; const segments = path .split("?")[0] .split("/") .filter((segment) => segment && !segment.includes("\0")); return segments.at(-1) ?? null;};
const hrefName = (attribute: Attribute) => { const value = "value" in attribute ? attribute.value : null; const text = staticText(value); if (text !== null) return slug(lastSegment(text) ?? ""); let expr = unwrap((value as Node | null)?.expression); if (expr?.type === "CallExpression" && chain(expr.callee)?.at(-1) === "resolve") expr = unwrap(expr.arguments[0]); if (!expr) return null; if (expr.type === "Literal" && typeof expr.value === "string") return slug(lastSegment(expr.value) ?? ""); if (expr.type === "TemplateLiteral") { const joined = expr.quasis.map((quasi: Node) => quasi.value.raw).join("\0"); return slug(lastSegment(joined) ?? ""); } const parts = chain(expr); return parts && camel(parts);};
// inside a primitive these just pass the caller's prop along, so they name nothingconst forwarded = new Set([ "href", "value", "checked", "group", "files", "onclick", "ref", "element"]);
const named = (value: string | null | undefined, source: string): Found | null => value && !forwarded.has(value) ? { id: value, source } : null;
const action = (name: string) => { const bare = name.match(/^on([A-Z].*)$/)?.[1] ?? name.match(/^on([a-z]+)$/)?.[1]; return bare ? bare.charAt(0).toLowerCase() + bare.slice(1) : name;};
const find = (attributes: readonly Attribute[], name: string) => attributes.find( (attribute): attribute is Extract<Attribute, { type: "Attribute" }> => attribute.type === "Attribute" && attribute.name === name );
const fromHandler = (attributes: readonly Attribute[]) => { for (const key of ["onclick", "onsubmit"]) { const attribute = find(attributes, key); const expression = (attribute?.value as Node | undefined)?.expression; const id = expression && handler(expression); const found = id && named(action(id) === "click" ? null : action(id), "handler"); if (found) return found; } return null;};
const withoutOpen = (name: string) => (name.endsWith("Open") ? name.slice(0, -4) : name);
const fromBinding = (attributes: readonly Attribute[], keys = boundKeys) => { for (const attribute of attributes) { if (attribute.type !== "BindDirective" || !keys.has(attribute.name)) continue; const parts = chain(attribute.expression as Node); const name = parts && camel(parts); if (attribute.name === "open" && name === "open") continue; const found = name && named(attribute.name === "open" ? withoutOpen(name) : name, "bind"); if (found) return found; } return null;};
const fromLiteral = (attributes: readonly Attribute[], keys: string[], source: string) => { for (const key of keys) { const attribute = find(attributes, key); const text = attribute && literal(attribute); const id = text && slug(text); if (id) return named(id, source); } return null;};
// strongest signal first, the label and placeholder ones come last because they move with the copyconst nameOf = (attributes: readonly Attribute[], instanced: boolean): Found | null => { const bound = fromBinding(attributes, instanced ? instanceKeys : boundKeys); const type = literal(find(attributes, "type") ?? {}); const href = find(attributes, "href"); const submit = type === "submit" ? { id: "submit", source: "submit" } : null; const hrefId = href && named(hrefName(href), "href"); const code = fromLiteral(attributes, ["id", "name"], "attr"); const copy = fromLiteral(attributes, ["aria-label", "label", "placeholder"], "copy"); return fromHandler(attributes) ?? bound ?? hrefId ?? submit ?? code ?? copy;};
const collectRoots = (nodes: Node[] | undefined, roots: Element[] = []) => { for (const node of nodes ?? []) { if ( node.type === "RegularElement" || (node.type === "Component" && rootComponents.has(node.name)) ) roots.push(node as Element); else if (transparent.has(node.type)) { collectRoots(node.fragment?.nodes, roots); collectRoots(node.consequent?.nodes, roots); collectRoots(node.alternate?.nodes, roots); collectRoots(node.body?.nodes, roots); collectRoots(node.fallback?.nodes, roots); collectRoots(node.pending?.nodes, roots); collectRoots(node.then?.nodes, roots); collectRoots(node.catch?.nodes, roots); } } return roots;};
const visit = (node: unknown, fn: (node: Node) => void): void => { if (!node || typeof node !== "object") return; if (Array.isArray(node)) return node.forEach((child) => visit(child, fn)); if (typeof (node as Node).type === "string") fn(node as Node); for (const [key, value] of Object.entries(node)) { if (key === "expression" || key === "metadata") continue; visit(value, fn); }};
const lineOf = (source: string, offset: number) => source.slice(0, offset).split("\n").length;
export function transform(source: string, { component, attribute = "data-testid" }: Options = {}) { const ast = parse(source, { modern: true }); const inserts: Insert[] = []; const seen: Seen[] = [];
const insert = (node: Element, name: string, value: string) => { inserts.push({ at: node.start + 1 + node.name.length, text: ` ${name}="${value}"` }); };
const roots = new Set(collectRoots(ast.fragment.nodes as Node[])); const used: Record<string, number> = {}; visit(ast.fragment, (node) => { if (node.type === "Component") used[node.name] = (used[node.name] ?? 0) + 1; }); visit(ast.fragment, (node) => { const isElement = node.type === "RegularElement"; const isComponent = node.type === "Component" && node.name in components; const anyComponent = node.type === "Component"; if (!isElement && !anyComponent) return; const element = node as Element; const { attributes } = element; const handled = isElement && find(attributes, "onclick"); const wanted = isComponent || interactive.has(element.name) || handled; const line = lineOf(source, element.start); if (component && roots.has(element) && !find(attributes, "data-component")) insert(element, "data-component", component); const target = isComponent ? components[element.name] : attribute; const written = find(attributes, attribute) ?? (target !== attribute && find(attributes, target)); if (written) { const id = literal(written) ?? "(dynamic)"; seen.push({ line, id, source: "hand" }); return; } if (!isElement && !isComponent) return; if (!wanted) return; const instanced = isComponent && target === "testid"; // a second one would need an ordinal, and ordinals drift const alone = instanced && used[element.name] === 1; const found = nameOf(attributes, instanced) ?? (alone ? { id: element.name.charAt(0).toLowerCase() + element.name.slice(1), source: "tag" } : null); seen.push({ line, id: found?.id ?? null, source: found?.source ?? null }); if (found) insert(element, target, found.id); });
return { inserts, seen };}
export function apply(source: string, inserts: Insert[]) { let out = source; for (const { at, text } of [...inserts].sort((a, b) => b.at - a.at)) { out = out.slice(0, at) + text + out.slice(at); } return out;}
const skipped = /(?:^|\/)(?:node_modules|\.storybook)\/|\.stories\.svelte$/;
export const componentName = (filename: string) => { const route = filename.match(/src\/routes\/(.*)\/\+(page|layout|error)\.svelte$/); if (route) return `${route[2]}:${route[1].replace(/\/\([^/]+\)/g, "")}`; return filename .split("/") .pop()! .replace(/\.svelte$/, "");};
export function testids(options: Options = {}) { return { name: "testids", markup({ content, filename }: { content: string; filename?: string }) { if (!filename || skipped.test(filename) || !filename.endsWith(".svelte")) return; const { inserts } = transform(content, { component: componentName(filename), ...options }); if (!inserts.length) return; return { code: apply(content, inserts) }; } };}