Skip to content

Commit

Permalink
Add support for detecting Stimulus Controllers in npm packages
Browse files Browse the repository at this point in the history
  • Loading branch information
marcoroth committed Feb 11, 2024
1 parent cd66fdd commit 57f81ff
Show file tree
Hide file tree
Showing 14 changed files with 449 additions and 64 deletions.
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@
"dev": "yarn watch",
"clean": "rimraf dist",
"prerelease": "yarn build",
"install:fixtures": "node scripts/setupFixtures.mjs",
"postinstall": "yarn install:fixtures",
"pretest": "yarn install:fixtures",
"test": "vitest"
},
"dependencies": {
Expand Down
13 changes: 13 additions & 0 deletions scripts/setupFixtures.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import path from "path"
import { glob } from "glob"
import { execSync } from "child_process"

const fixtures = await glob("test/fixtures/**/package.json", { ignore: "**/**/node_modules/**" })

fixtures.forEach(async fixturesPath => {
const fixtureFolder = path.dirname(fixturesPath)

console.log(`Installing packages for fixture: ${fixtureFolder}`)

execSync(`cd ${fixtureFolder} && yarn install && cd -`)
})
7 changes: 7 additions & 0 deletions src/packages/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { Project } from '../project'

import * as tailwindcssStimulusComponents from "./tailwindcss-stimulus-components"

export async function detectPackages(project: Project) {
await tailwindcssStimulusComponents.analyze(project)
}
27 changes: 27 additions & 0 deletions src/packages/tailwindcss-stimulus-components.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import path from "path"
import { glob } from "glob"

import type { NodeModule } from "../types"
import { Project } from '../project'
import { hasDepedency, findPackagePath } from "./util"

export async function analyze(project: Project) {
const packageName = "tailwindcss-stimulus-components"
const hasPackage = await hasDepedency(project.projectPath, packageName)
const packagePath = await findPackagePath(project.projectPath, packageName)

if (!hasPackage || !packagePath) return

const basePath = path.join(packagePath, "src")
const files = await glob(`${basePath}/**/*.js`)

const detectedModule: NodeModule = {
name: packageName,
path: packagePath,
controllerRoots: [basePath]
}

project.detectedNodeModules.push(detectedModule)

await project.readControllerFiles(files)
}
43 changes: 43 additions & 0 deletions src/packages/util.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import path from "path"

import { folderExists } from "../util"

export async function findPackagePath(startPath: string, packageName: string): Promise<string|null> {
const nodeModulesPath = await findNodeModulesPath(startPath)

if (!nodeModulesPath) return null

return path.join(nodeModulesPath, packageName)
}

export async function findNodeModulesPath(startPath: string): Promise<string|null> {
const findFolder = async (splits: string[]): Promise<string|null> => {
if (splits.length == 0) return null

let possiblePath = path.join(...splits, "node_modules")

if (!possiblePath.startsWith("/")) possiblePath = `/${possiblePath}`

const exists = await folderExists(possiblePath)

if (exists) return possiblePath

return findFolder(splits.slice(0, splits.length - 1))
}

return await findFolder(startPath.split("/"))
}

export function nodeModulesPathFor(nodeModulesPath: string, packageName: string): string {
return path.join(nodeModulesPath, packageName)
}

export async function hasDepedency(projectPath: string, packageName: string): Promise<boolean> {
const nodeModulesPath = await findNodeModulesPath(projectPath)

if (!nodeModulesPath) return false

const packagePath = nodeModulesPathFor(nodeModulesPath, packageName)

return await folderExists(packagePath)
}
2 changes: 1 addition & 1 deletion src/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,7 @@ export class Parser {
type = value.name
defaultValue = defaultValuesForType[type]
} else {
const properties = property.value.properties
const properties = property.value.properties || []

const convertArrayExpression = (
value: NodeElement | PropertyValue
Expand Down
76 changes: 53 additions & 23 deletions src/project.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,13 @@
import { ControllerDefinition } from "./controller_definition"
import { Parser } from "./parser"
import { resolvePathWhenFileExists, nestedFolderSort } from "./util"
import { detectPackages } from "./packages"
import type { NodeModule } from "./types"

import path from "path"
import { promises as fs } from "fs"
import { glob } from "glob"

const fileExists = (path: string) => {
return new Promise<string|null>((resolve, reject) =>
fs.stat(path).then(() => resolve(path)).catch(() => reject())
)
}

interface ControllerFile {
filename: string
content: string
Expand All @@ -21,16 +19,32 @@ export class Project {
static readonly javascriptEndings = ["js", "mjs", "cjs", "jsx"]
static readonly typescriptEndings = ["ts", "mts", "tsx"]

public detectedNodeModules: Array<NodeModule> = []
public controllerDefinitions: ControllerDefinition[] = []

private controllerFiles: Array<ControllerFile> = []
private parser: Parser = new Parser(this)


static calculateControllerRoots(filenames: string[]) {
const controllerRoots: string[] = [];
let controllerRoots: string[] = [];

filenames = filenames.sort(nestedFolderSort)

const findClosest = (basename: string) => {
const splits = basename.split("/")

for (let i = 0; i < splits.length + 1; i++) {
const possbilePath = splits.slice(0, i).join("/")

if (controllerRoots.includes(possbilePath) && possbilePath !== basename) {
return possbilePath
}
}
}

filenames.forEach(filename => {
const splits = filename.split("/")
const splits = path.dirname(filename).split("/")
const controllersIndex = splits.indexOf("controllers")

if (controllersIndex !== -1) {
Expand All @@ -39,10 +53,22 @@ export class Project {
if (!controllerRoots.includes(controllerRoot)) {
controllerRoots.push(controllerRoot)
}
} else {
const controllerRoot = splits.slice(0, splits.length).join("/")
const found = findClosest(controllerRoot)

if (found) {
const index = controllerRoots.indexOf(controllerRoot)
if (index !== -1) controllerRoots.splice(index, 1)
} else {
if (!controllerRoots.includes(controllerRoot)) {
controllerRoots.push(controllerRoot)
}
}
}
})

return controllerRoots.sort();
return controllerRoots.sort(nestedFolderSort)
}

constructor(projectPath: string) {
Expand All @@ -64,13 +90,13 @@ export class Project {

return this.controllerRoots.flatMap(root => endings.map(
ending => `${root}/${ControllerDefinition.controllerPathForIdentifier(identifier, ending)}`
))
)).sort(nestedFolderSort)
}

async findControllerPathForIdentifier(identifier: string): Promise<string|null> {
const possiblePaths = this.possibleControllerPathsForIdentifier(identifier)
const promises = possiblePaths.map((path: string) => fileExists(`${this.projectPath}/${path}`))
const possiblePath = await Promise.any(promises).catch(() => null)
const promises = possiblePaths.map((path: string) => resolvePathWhenFileExists(`${this.projectPath}/${path}`))
const possiblePath = Array.from(await Promise.all(promises)).find(promise => promise)

return (possiblePath) ? this.relativePath(possiblePath) : null
}
Expand All @@ -80,9 +106,8 @@ export class Project {
}

get controllerRoots() {
const roots = Project.calculateControllerRoots(
this.controllerFiles.map(file => this.relativePath(file.filename))
)
const relativePaths = this.controllerFiles.map(file => this.relativePath(file.filename))
const roots = Project.calculateControllerRoots(relativePaths).sort(nestedFolderSort)

return (roots.length > 0) ? roots : [this.controllerRootFallback]
}
Expand All @@ -91,7 +116,8 @@ export class Project {
this.controllerFiles = []
this.controllerDefinitions = []

await this.readControllerFiles()
await this.readControllerFiles(await this.getControllerFiles())
await detectPackages(this)

this.controllerFiles.forEach((file: ControllerFile) => {
this.controllerDefinitions.push(this.parser.parseController(file.content, file.filename))
Expand All @@ -105,13 +131,7 @@ export class Project {
return relativeRoots.find(root => relativePath.startsWith(root)) || this.controllerRootFallback
}

private async readControllerFiles() {
const endings = `${Project.javascriptEndings.join(",")},${Project.typescriptEndings.join(",")}`

const controllerFiles = await glob(`${this.projectPath}/**/*_controller.{${endings}}`, {
ignore: `${this.projectPath}/node_modules/**/*`,
})

async readControllerFiles(controllerFiles: string[]) {
await Promise.allSettled(
controllerFiles.map(async (filename: string) => {
const content = await fs.readFile(filename, "utf8")
Expand All @@ -120,4 +140,14 @@ export class Project {
})
)
}

private async getControllerFiles(): Promise<string[]> {
return await glob(`${this.projectPath}/**/*controller${this.fileEndingsGlob}`, {
ignore: `${this.projectPath}/node_modules/**/*`,
})
}

get fileEndingsGlob(): string {
return `.{${Project.javascriptEndings.join(",")},${Project.typescriptEndings.join(",")}}`
}
}
6 changes: 6 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,9 @@ export interface PropertyElement {
value: PropertyValue
properties: PropertyElement[]
}

export interface NodeModule {
name: string
path: string
controllerRoots: string[]
}
20 changes: 20 additions & 0 deletions src/util.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import path from "path"
import { promises as fs } from "fs"

export function camelize(string: string) {
return string.replace(/(?:[_-])([a-z0-9])/g, (_, char) => char.toUpperCase())
Expand All @@ -8,6 +9,25 @@ export function dasherize(value: string) {
return value.replace(/([A-Z])/g, (_, char) => `-${char.toLowerCase()}`)
}

export async function resolvePathWhenFileExists(path: string): Promise<string|null> {
const exists = await folderExists(path)

return exists ? path : null
}

export async function fileExists(path: string): Promise<boolean> {
return folderExists(path)
}

export async function folderExists(path: string): Promise<boolean> {
return new Promise(resolve =>
fs
.stat(path)
.then(() => resolve(true))
.catch(() => resolve(false))
)
}

export function nestedFolderSort(a: string, b: string) {
const aLength = a.split("/").length
const bLength = b.split("/").length
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"name": "tailwindcss-stimulus-components",
"version": "1.0.0",
"main": "index.js",
"license": "MIT",
"dependencies": {
"tailwindcss-stimulus-components": "^4.0.4"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
# yarn lockfile v1


tailwindcss-stimulus-components@^4.0.4:
version "4.0.4"
resolved "https://registry.yarnpkg.com/tailwindcss-stimulus-components/-/tailwindcss-stimulus-components-4.0.4.tgz#1df5f2a488aa89365561bb33357095cd59ed831a"
integrity sha512-xNlMs1WufKiTMQtVklwHfrR/iuPVaFA0Mk5uefRnHztmr7w4g6BzKAWHyfte60pjhcQbmlbshHMOZiq/dkXnhw==
38 changes: 38 additions & 0 deletions test/packages/tailwindcss-stimulus-components.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { describe, test, expect } from "vitest"
import { Project } from "../../src"

const project = new Project(`${process.cwd()}/test/fixtures/packages/tailwindcss-stimulus-components`)

describe("packages", () => {
describe("tailwindcss-stimulus-components", () => {
test("detects controllers", async () => {
expect(project.controllerDefinitions.length).toEqual(0)

await project.analyze()

expect(project.detectedNodeModules.map(module => module.name)).toEqual(["tailwindcss-stimulus-components"])
expect(project.controllerRoots).toEqual(["node_modules/tailwindcss-stimulus-components/src"])
expect(project.controllerDefinitions.length).toEqual(11)
expect(project.controllerDefinitions.map(controller => controller.identifier).sort()).toEqual([
"alert",
"autosave",
"color-preview",
"dropdown",
"index",
"modal",
"popover",
"slideover",
"tabs",
"toggle",
"transition",
])

const controller = project.controllerDefinitions.find(controller => controller.identifier === "modal")

expect(controller.targets).toEqual(["container", "background"])
expect(Object.keys(controller.values)).toEqual(["open", "restoreScroll"])
expect(controller.values.open.type).toEqual("Boolean")
expect(controller.values.restoreScroll.type).toEqual("Boolean")
})
})
})
Loading

0 comments on commit 57f81ff

Please sign in to comment.