Skip to content
This repository has been archived by the owner on Sep 4, 2023. It is now read-only.

Commit

Permalink
feat(core): extractAtRange / extractJsxElementProps
Browse files Browse the repository at this point in the history
  • Loading branch information
astahmer committed Feb 28, 2023
1 parent 83bfd0a commit 484db8c
Show file tree
Hide file tree
Showing 4 changed files with 311 additions and 0 deletions.
5 changes: 5 additions & 0 deletions .changeset/lemon-buses-sneeze.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@box-extractor/core": minor
---

feat(core): extractAtRange / extractJsxElementProps
91 changes: 91 additions & 0 deletions packages/box-extractor/src/extractor/extractAtRange.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { createLogger } from "@box-extractor/logger";
import { JsxOpeningElement, JsxSelfClosingElement, Node, SourceFile, ts } from "ts-morph";

import { extractCallExpressionValues } from "./extractCallExpressionValues";
import { extractJsxAttributeIdentifierValue } from "./extractJsxAttributeIdentifierValue";
import { extractJsxSpreadAttributeValues } from "./extractJsxSpreadAttributeValues";
import { box, BoxNode } from "./type-factory";

const logger = createLogger("box-ex:extractor:extractAtRange");

export const extractAtRange = (source: SourceFile, line: number, column: number) => {
const node = getTsNodeAtPosition(source, line, column);
logger({ line, column, node: node?.getKindName() });
if (!node) return;

// pointing directly at the node
if (Node.isJsxOpeningElement(node) || Node.isJsxSelfClosingElement(node)) {
return extractJsxElementProps(node);
}

if (Node.isCallExpression(node)) {
// TODO box.function(node) ?
return extractCallExpressionValues(node, "all");
}

// pointing at the name
const parent = node.getParent();

if (parent && Node.isIdentifier(node)) {
logger({ line, column, parent: parent?.getKindName() });

if (Node.isJsxOpeningElement(parent) || Node.isJsxSelfClosingElement(parent)) {
return extractJsxElementProps(parent);
}

if (Node.isPropertyAccessExpression(parent)) {
const grandParent = parent.getParent();
if (Node.isJsxOpeningElement(grandParent) || Node.isJsxSelfClosingElement(grandParent)) {
return extractJsxElementProps(grandParent);
}
}

if (Node.isCallExpression(parent)) {
// TODO box.function(node) ?
return extractCallExpressionValues(parent, "all");
}
}
};

export const extractJsxElementProps = (node: JsxOpeningElement | JsxSelfClosingElement) => {
const tagName = node.getTagNameNode().getText();
const jsxAttributes = node.getAttributes();
logger.scoped("jsx", { tagName, jsxAttributes: jsxAttributes.length });

const props = new Map<string, BoxNode>();
jsxAttributes.forEach((attrNode) => {
if (Node.isJsxAttribute(attrNode)) {
const nameNode = attrNode.getNameNode();
const maybeValue =
extractJsxAttributeIdentifierValue(attrNode.getNameNode()) ?? box.unresolvable(nameNode, []);
props.set(nameNode.getText(), maybeValue);
return;
}

if (Node.isJsxSpreadAttribute(attrNode)) {
// increment count since there might be conditional
// so it doesn't override the whole spread prop
let count = 0;
const propSizeAtThisPoint = props.size;
const getSpreadPropName = () => `_SPREAD_${propSizeAtThisPoint}_${count++}`;

const spreadPropName = getSpreadPropName();
const maybeValue = extractJsxSpreadAttributeValues(attrNode, "all") ?? box.unresolvable(attrNode, []);
props.set(spreadPropName, maybeValue);
}
});

// TODO box.component(node) ?
return { type: "component", node, tagName, props };
};

export const getTsNodeAtPosition = (sourceFile: SourceFile, line: number, column: number) => {
const pos = ts.getPositionOfLineAndCharacter(
sourceFile.compilerNode,
// TS uses 0-based line and char #s
line - 1,
column - 1
);

return sourceFile.getDescendantAtPos(pos);
};
1 change: 1 addition & 0 deletions packages/box-extractor/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export { ensureAbsolute } from "./extensions-helpers";
export { extract, query } from "./extractor/extract";
export { extractAtRange, extractJsxElementProps } from "./extractor/extractAtRange";
export { extractCallExpressionValues } from "./extractor/extractCallExpressionValues";
export { extractFunctionFrom, isImportedFrom } from "./extractor/extractFunctionFrom";
export { extractJsxAttributeIdentifierValue } from "./extractor/extractJsxAttributeIdentifierValue";
Expand Down
214 changes: 214 additions & 0 deletions packages/box-extractor/tests/extractAtRange.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
import { Project, SourceFile, ts } from "ts-morph";
import { afterEach, expect, test } from "vitest";
import { extractAtRange, getTsNodeAtPosition } from "../src/extractor/extractAtRange";
import type { ExtractOptions } from "../src/extractor/types";

const createProject = () => {
return new Project({
compilerOptions: {
jsx: ts.JsxEmit.React,
jsxFactory: "React.createElement",
jsxFragmentFactory: "React.Fragment",
module: ts.ModuleKind.ESNext,
target: ts.ScriptTarget.ESNext,
noUnusedParameters: false,
declaration: false,
noEmit: true,
emitDeclaratio: false,
// allowJs: true,
// useVirtualFileSystem: true,
},
// tsConfigFilePath: tsConfigPath,
skipAddingFilesFromTsConfig: true,
skipFileDependencyResolution: true,
skipLoadingLibFiles: true,
});
};

let project: Project = createProject();
let fileCount = 0;

let sourceFile: SourceFile;
afterEach(() => {
if (!sourceFile) return;

if (sourceFile.wasForgotten()) return;
project.removeSourceFile(sourceFile);
});

const config: ExtractOptions["components"] = {
ColorBox: {
properties: ["color", "backgroundColor", "zIndex", "fontSize", "display", "mobile", "tablet", "desktop", "css"],
},
};
const getSourceFile = (code: string) => {
const fileName = `file${fileCount++}.tsx`;
sourceFile = project.createSourceFile(fileName, code, { scriptKind: ts.ScriptKind.TSX });
// return extract({ ast: sourceFile, components: config, ...options });
return sourceFile;
};

const codeSample = `import Cylinder from "@/cylinder";
import Box from "src/box";
import { RoundedBox } from "@react-three/drei";
import Sphere from "./sphere";
export function SceneAlt() {
return (
<>
<Box />
</>
);
}
export default function Scene() {
const oui = abc({ prop: 123 });
return (
<>
<factory.div className="aa" />
<SceneAlt />
<Box
position={[
-1.2466866852487384, 0.3325255778835592, -0.6517939595349769,
]}
rotation={[
2.1533738875424957, -0.4755261514452274, 0.22680789335122342,
]}
scale={[1, 1, 1.977327619564505]}
{...{ aaa: 123 }}
/>
<Cylinder
position={[0.47835635435693047, 0, -0.5445324755430057]}
></Cylinder>
<Sphere
scale={[0.6302165233139577, 0.6302165233139577, 0.6302165233139577]}
position={[-1.6195773093872396, 0, 1.1107193822625767]}
/>
<RoundedBox position={[1, 0, 2]}>
<meshStandardMaterial color="purple" />
</RoundedBox>
</>
);
}`;

test("getTsNodeAtPosition", () => {
const sourceFile = getSourceFile(codeSample);
let node;

node = getTsNodeAtPosition(sourceFile, 1, 1);
expect([node.getText(), node.getKindName()]).toMatchInlineSnapshot('["import", "ImportKeyword"]');

node = getTsNodeAtPosition(sourceFile, 15, 19);
expect([node.getText(), node.getKindName()]).toMatchInlineSnapshot('["abc", "Identifier"]');

node = getTsNodeAtPosition(sourceFile, 19, 12);
expect([node.getText(), node.getKindName()]).toMatchInlineSnapshot('["factory", "Identifier"]');

node = getTsNodeAtPosition(sourceFile, 20, 12);
expect([node.getText(), node.getKindName()]).toMatchInlineSnapshot('["SceneAlt", "Identifier"]');

node = getTsNodeAtPosition(sourceFile, 31, 13);
expect([node.getText(), node.getKindName()]).toMatchInlineSnapshot('["Cylinder", "Identifier"]');

node = getTsNodeAtPosition(sourceFile, 37, 14);
expect([node.getText(), node.getKindName()]).toMatchInlineSnapshot('["position", "Identifier"]');
});

test("extractAtRange", () => {
const sourceFile = getSourceFile(codeSample);
let extracted;

extracted = extractAtRange(sourceFile, 1, 1);
expect(extracted).toMatchInlineSnapshot("undefined");

extracted = extractAtRange(sourceFile, 15, 19);
expect(extracted).toMatchInlineSnapshot(`
{
stack: ["CallExpression", "ObjectLiteralExpression"],
type: "map",
node: "ObjectLiteralExpression",
value: {
prop: {
stack: ["CallExpression", "ObjectLiteralExpression", "PropertyAssignment", "NumericLiteral"],
type: "literal",
node: "NumericLiteral",
value: 123,
kind: "number",
},
},
}
`);

extracted = extractAtRange(sourceFile, 19, 12);
expect(extracted).toMatchInlineSnapshot(`
{
type: "component",
node: "JsxSelfClosingElement",
tagName: "factory.div",
props: {
className: {
stack: ["JsxAttribute", "StringLiteral"],
type: "literal",
node: "StringLiteral",
value: "aa",
kind: "string",
},
},
}
`);

extracted = extractAtRange(sourceFile, 20, 12);
expect(extracted).toMatchInlineSnapshot(`
{
type: "component",
node: "JsxSelfClosingElement",
tagName: "SceneAlt",
props: {},
}
`);

extracted = extractAtRange(sourceFile, 31, 13);
expect(extracted).toMatchInlineSnapshot(`
{
type: "component",
node: "JsxOpeningElement",
tagName: "Cylinder",
props: {
position: {
stack: ["JsxAttribute", "JsxExpression", "ArrayLiteralExpression"],
type: "list",
node: "ArrayLiteralExpression",
value: [
{
stack: ["JsxAttribute", "JsxExpression", "ArrayLiteralExpression"],
type: "literal",
node: "NumericLiteral",
value: 0.47835635435693047,
kind: "number",
},
{
stack: ["JsxAttribute", "JsxExpression", "ArrayLiteralExpression"],
type: "literal",
node: "NumericLiteral",
value: 0,
kind: "number",
},
{
stack: ["JsxAttribute", "JsxExpression", "ArrayLiteralExpression"],
type: "literal",
node: "PrefixUnaryExpression",
value: -0.5445324755430057,
kind: "number",
},
],
},
},
}
`);

extracted = extractAtRange(sourceFile, 37, 14);
expect(extracted).toMatchInlineSnapshot("undefined");
});

0 comments on commit 484db8c

Please sign in to comment.