Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion packages/transform/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

# @solid-devtools/transform

Vite plugin for transforming SolidJS code in development to enchance solid-devtools usage.
Vite plugin for transforming SolidJS code in development to enhance solid-devtools usage.

## Getting Started

Expand Down Expand Up @@ -44,12 +44,15 @@ interface DevtoolsPluginOptions {
wrapStores?: boolean
/** Inject location attributes to jsx templates */
jsxLocation?: boolean
/** Add automatic name when creating signals, memos, stores, or mutables */
name?: boolean
}

// in vite.config.ts plugins array:
devtoolsPlugin({
wrapStores: true,
jsxLocation: true,
name: true,
})
```

Expand Down
6 changes: 5 additions & 1 deletion packages/transform/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,21 @@ import { PluginItem, transformAsync } from "@babel/core"
import { PluginOption } from "vite"
import { getFileExtension } from "./utils"
import jsxLocationPlugin from "./jsxLocation"
import namePlugin from "./name"
import wrapStoresPlugin from "./wrapStores"

export interface DevtoolsPluginOptions {
/** Wrap store creation to observe changes */
wrapStores?: boolean
/** Inject location attributes to jsx templates */
jsxLocation?: boolean
/** Add automatic name when creating signals, memos, stores, or mutables */
name?: boolean
}

// This export is used for configuration.
const devtoolsPlugin = (options: DevtoolsPluginOptions = {}): PluginOption => {
const { wrapStores = false, jsxLocation = false } = options
const { wrapStores = false, jsxLocation = false, name = false } = options

let enablePlugin = false
let projectRoot = process.cwd()
Expand All @@ -37,6 +40,7 @@ const devtoolsPlugin = (options: DevtoolsPluginOptions = {}): PluginOption => {

// plugins that should only run on .tsx/.jsx files in development
if (jsxLocation && isJSX) plugins.push(jsxLocationPlugin)
if (name) plugins.push(namePlugin) // must come before wrapStores
if (wrapStores) plugins.push(wrapStoresPlugin)

if (plugins.length === 0) return
Expand Down
154 changes: 154 additions & 0 deletions packages/transform/src/name.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
import { PluginObj } from "@babel/core"
import * as t from "@babel/types"

const nameId = t.identifier("name")

type Comparable = t.Identifier | t.V8IntrinsicIdentifier |
t.PrivateName | t.Expression
function equal(a: Comparable, b: Comparable): boolean {
if (a.type !== b.type) return false
switch (a.type) {
case "Identifier":
case "V8IntrinsicIdentifier":
return a.name === (b as t.Identifier).name
case "PrivateName":
return a.id === (b as t.PrivateName).id
case "MemberExpression":
return equal(a.object, (b as t.MemberExpression).object) &&
equal(a.property, (b as t.MemberExpression).property)
default: // other type of Expression
return false
}
}

type Source = "createSignal" | "createMemo" | "createStore" | "createMutable"

const optionsArg: Record<Source, number> = {
createSignal: 1,
createMemo: 2,
createStore: 1,
createMutable: 1,
}

let sources: Record<Source, Comparable[]>

const namePlugin: PluginObj<any> = {
name: "@solid-devtools/name",
visitor: {
Program() {
sources = {
createSignal: [],
createMemo: [],
createStore: [],
createMutable: [],
}
},

// Track imported references to createSignal/createMemo/createStore/createMutable
ImportDeclaration(path) {
const node = path.node
const source = node.source.value
let targets: Source[]
switch (source) {
case "solid-js":
targets = ["createSignal", "createMemo"];
break;
case "solid-js/store":
targets = ["createStore", "createMutable"];
break;
default:
return;
}
for (const s of node.specifiers) {
switch (s.type) {
// import * as local from "solid-js"
case "ImportNamespaceSpecifier":
for (let target of targets) {
sources[target].push(
t.memberExpression(s.local, t.identifier(target))
)
}
break
// import { createSignal } from "solid-js"
// import { createSignal as local } from "solid-js"
// import { "createSignal" as local } from "solid-js"
case "ImportSpecifier":
let target: Source
switch (s.imported.type) {
case "Identifier":
if (!targets.includes(s.imported.name)) continue
target = s.imported.name as Source
break
case "StringLiteral":
if (!targets.includes(s.imported.value)) continue
target = s.imported.value as Source
break
default:
continue
}
sources[target].push(s.local)
break
}
}
},

VariableDeclaration(path) {
const declarations = path.node.declarations
for (let declaration of declarations) {
// Check initializer is a call to createSignal/createMemo/createStore/createMutable
const init = declaration.init
if (!init) continue
if (init.type !== "CallExpression") continue
let target: Source | undefined
for (let [someTarget, someSources] of Object.entries(sources) as [Source, Comparable[]][]) {
if (someSources.some(source => equal(init.callee, source))) {
target = someTarget
break
}
}
if (!target) continue

// Check declaration is either identifier or [identifier, ...]
const id = declaration.id
let name
switch (id.type) {
case "Identifier":
name = id.name
break
case "ArrayPattern":
if (!id.elements.length) continue
const first = id.elements[0]
if (!first) continue
if (first.type !== "Identifier") continue
name = first.name
break
default:
continue
}

// Modify call to include name in options
const nameProperty = t.objectProperty(nameId, t.stringLiteral(name))
const argIndex = optionsArg[target]
while (init.arguments.length < argIndex) {
init.arguments.push(t.identifier("undefined"))
}
if (init.arguments.length === argIndex) { // no options argument
init.arguments.push(t.objectExpression([nameProperty]))
} else { // existing options argument
const options = init.arguments[argIndex]
if (options.type !== "ObjectExpression") continue
// Check there isn't already a "name" property
if (options.properties.some((property) =>
property.type === "ObjectProperty" &&
property.key.type === "Identifier" &&
property.key.name === nameId.name)) continue
if (options.type !== "ObjectExpression") continue
options.properties.unshift(nameProperty)
break
}
}
},
},
}

export default namePlugin
164 changes: 164 additions & 0 deletions packages/transform/test/name.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
import plugin from "../src/name"
import wrapPlugin from "../src/wrapStores"
import { assertTransform } from "./utils"

// Positive tests
for (let [create, module, extraArg] of [
["createSignal", "solid-js"],
["createMemo", "solid-js", "1"],
["createStore", "solid-js/store"],
["createMutable", "solid-js/store"],
]) {
extraArg = extraArg ? "undefined, " : "";
describe(create, () => {
for (let [type, importStatement, creator] of [
["named import", `import { ${create} } from "${module}";`, create],
["renamed import", `import { ${create} as foo } from "${module}";`, "foo"],
["namespace import", `import * as foo from "${module}";`, `foo.${create}`],
]) {
describe(type, () => {
test("no default value", () => {
const src = `${importStatement}
const signal = ${creator}();`

const expectedOutput = `${importStatement}
const signal = ${creator}(undefined, ${extraArg}{
name: "signal"
});`

assertTransform(src, expectedOutput, plugin)
})

test("default value", () => {
const src = `${importStatement}
const signal = ${creator}(5);`

const expectedOutput = `${importStatement}
const signal = ${creator}(5, ${extraArg}{
name: "signal"
});`

assertTransform(src, expectedOutput, plugin)
})

test("empty options", () => {
const src = `${importStatement}
const rest = {};
const signal = ${creator}(5, ${extraArg}{});`

const expectedOutput = `${importStatement}
const rest = {};
const signal = ${creator}(5, ${extraArg}{
name: "signal"
});`

assertTransform(src, expectedOutput, plugin)
})

test("options excluding name", () => {
const src = `${importStatement}
const rest = {};
const signal = ${creator}(5, ${extraArg}{ equals: false, ...rest });`

const expectedOutput = `${importStatement}
const rest = {};
const signal = ${creator}(5, ${extraArg}{
name: "signal",
equals: false,
...rest
});`

assertTransform(src, expectedOutput, plugin)
})

test("options including name", () => {
const src = `${importStatement}
const rest = {};
const signal = ${creator}(5, ${extraArg}{ equals: false, name: "foo", ...rest });`

const expectedOutput = `${importStatement}
const rest = {};
const signal = ${creator}(5, ${extraArg}{
equals: false,
name: "foo",
...rest
});`

assertTransform(src, expectedOutput, plugin)
})

test("array of length 1", () => {
const src = `${importStatement}
const [signal] = ${creator}(5);`

const expectedOutput = `${importStatement}
const [signal] = ${creator}(5, ${extraArg}{
name: "signal"
});`

assertTransform(src, expectedOutput, plugin)
})

test("array of length 2", () => {
const src = `${importStatement}
const [signal, setSignal] = ${creator}(5);`

const expectedOutput = `${importStatement}
const [signal, setSignal] = ${creator}(5, ${extraArg}{
name: "signal"
});`

assertTransform(src, expectedOutput, plugin)
})
})
}
})
}

// Negative tests
for (let [create, module] of [
["createSignal", "solid-js/store"],
["createMemo", "solid-js/store"],
["createStore", "solid-js"],
["createMutable", "solid-js"],
]) {
describe(create, () => {
test(`no import`, () => {
const src = `const signal = ${create}();`

assertTransform(src, src, plugin)
})

test(`incorrect import`, () => {
const src = `import { ${create} } from "${module}";
const signal = ${create}();`

assertTransform(src, src, plugin)
})
})
}

describe("wrapStores interaction", () => {
test("named import", () => {
const src = `import { createStore } from "solid-js/store";
const signal = createStore();`

const expectedOutput = `import { createStore as $sdt_createStore0 } from "solid-js/store";

const createStore = (obj, options) => {
let wrappedObj = obj;

if (typeof window.$sdt_wrapStore === "function") {
wrappedObj = window.$sdt_wrapStore(obj);
}

return $sdt_createStore0(wrappedObj, options);
};

const signal = createStore(undefined, {
name: "signal"
});`

assertTransform(src, expectedOutput, plugin, wrapPlugin)
})
});
17 changes: 17 additions & 0 deletions packages/transform/test/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { PluginObj, traverse } from "@babel/core"
import { parse } from "@babel/parser"
import generate from "@babel/generator"

export function assertTransform(src: string, expectedOutput: string, ...plugins: PluginObj<any>[]) {
const ast = parse(src, {
sourceType: "module",
plugins: ["jsx"],
})

for (const plugin of plugins) {
traverse(ast, plugin.visitor)
}
const res = generate(ast)

expect(res.code).toBe(expectedOutput)
}
Loading