Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

adds a library generator #5

Merged
merged 1 commit into from
Nov 24, 2023
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
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@
"dev": "pnpm --filter web run dev",
"build": "pnpm --filter web run build",
"preview": "pnpm --filter web run preview",
"typecheck": "tsc --noEmit"
"typecheck": "tsc --noEmit",
"create:lib": "pnpm --filter @tools/create-lib generate"
},
"devDependencies": {
"@types/node": "^20.9.4",
Expand Down
17 changes: 17 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions pnpm-workspace.yaml
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
packages:
- "apps/*"
- "libs/*"
- "tools/*"
13 changes: 13 additions & 0 deletions tools/create-lib/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"name": "@tools/create-lib",
"version": "0.0.1",
"type": "module",
"scripts": {
"generate": "npx tsx src/generate.ts"
},
"devDependencies": {
"@schemastore/package": "^0.0.10",
"@schemastore/tsconfig": "^0.0.11"
},
"main": "src/generate.ts"
}
200 changes: 200 additions & 0 deletions tools/create-lib/src/generate.ts
Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ah yes, exactly 200 lines ✨🤏🏻

Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
// walk all of the files in the template folder and dynamically import them

import fs from "node:fs"
import path from "node:path"
import { fileURLToPath } from "node:url"

import type { GenerateFunction } from "./types/GenerateFunction"
import type { CreateLibOptions } from "./types/CreateLibOptions"
import { argv } from "node:process"

const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
class ArgumentError extends Error {}

await main()

interface FileContent {
fileName: string
content: string
subFolder?: string
}

async function main() {
try {
const options = getOptionsFromArgs()
const templates = await getTemplatesFromFolder(options)

createLibFromTemplateContent(templates, options)
} catch (error) {
if (error instanceof ArgumentError) {
console.error("ERROR: " + error.message + "\n")
process.exit(1)
}
throw error
}
}

function getOptionsWithDefaults(libName: string) {
return {
libName,
packageName: `@libs/${toKebabCase(libName)}`,
mainFileName: "src/exports.ts",
templateFolder: "template",
isPrivate: true,
version: "0.0.0",
libFolderName: toKebabCase(libName),
libFunctionName: toLowerCamelCase(libName),
} satisfies CreateLibOptions
}

function getOptionsFromArgs(): Required<CreateLibOptions> {
const args = argv.slice(2)
if (args.length === 0) {
throw new ArgumentError("No arguments provided. Expected: <libName> [description] [version] [public|private]")
}
const libName = args[0]
const description = args[1]
const version = args[2]
const isPrivate = args[3] !== "public"

if (version && !/[^\d+.\d+.\d+$]/.test(version)) {
throw new ArgumentError("Version (argument 3) must be in the format of x.x.x")
}

return {
...getOptionsWithDefaults(libName),
description,
version,
isPrivate,
}
}

type Formatter = (options: CreateLibOptions, filePath: URL) => Promise<string>
async function getTemplatesFromFolder(
options: CreateLibOptions,
folderName?: string,
depth = 0,
): Promise<
(FileContent & {
depth: number
})[]
> {
const formatters: [string | RegExp, Formatter][] = [
[/\.template\..+$/, generateContentFromTextFile],
[".ts", generateContentFromTsFile],
]

const templateDir = path.join(__dirname, options.templateFolder ?? "template", folderName ?? "")
const files = fs.readdirSync(templateDir)
const templateStructure = await Promise.all(
files.map(async (fileName) => {
const filePath = path.join(templateDir, fileName)
const modulePath = new URL(`file://${filePath}`)
const fileLocation = folderName ? path.join(folderName, fileName) : fileName
if (await isFileDirectory(filePath)) {
return await getTemplatesFromFolder(options, fileLocation, depth + 1)
}
return {
...(await generateTemplateFromFile(options, formatters, modulePath, fileLocation)),
subFolder: folderName,
depth,
}
}),
)
const templates = templateStructure.flat()
templates.sort((a, b) => {
if (a.depth !== b.depth) {
return a.depth - b.depth
}
return a.fileName.localeCompare(b.fileName)
})
return templates
}

async function isFileDirectory(filePath: string): Promise<boolean> {
const stat = await fs.promises.stat(filePath)
return stat.isDirectory()
}

async function generateTemplateFromFile(
options: CreateLibOptions,
formatters: [string | RegExp, Formatter][],
modulePath: URL,
fileName: string,
): Promise<FileContent> {
const matchesExtension = (extension: string | RegExp) => {
if (extension instanceof RegExp) {
const match = fileName.match(extension)
if (match) {
return match[0]
}
} else if (fileName.endsWith(extension)) {
return extension
}
return null
}
for (const [extension, formatter] of formatters) {
const extensionMatch = matchesExtension(extension)
if (extensionMatch) {
return {
fileName: formatFileName(fileName, options, extensionMatch),
content: await formatter(options, modulePath),
}
}
}
throw new Error(`No formatter found for ${fileName}`)
}

function formatFileName(fileName: string, options: CreateLibOptions, extension: string): string {
const fileNameWithoutExtension = fileName.endsWith(extension)
? fileName.slice(0, fileName.length - extension.length)
: fileName
return replaceTemplateStrings(fileNameWithoutExtension, options)
}

async function generateContentFromTextFile(options: CreateLibOptions, filePath: URL): Promise<string> {
const template = fs.readFileSync(filePath, "utf-8")
return replaceTemplateStrings(template, options)
}

async function generateContentFromTsFile(options: CreateLibOptions, filePath: URL): Promise<string> {
const module = (await import(filePath.toString())) as { default: GenerateFunction }
return module.default(options)
}

function replaceTemplateStrings<K extends string>(template: string, options: Partial<Record<K, unknown>>): string {
return template.replace(/__(.+?)__/g, (match, key: K) => {
const replacement = options[key]
if (typeof replacement === "string") {
return replacement
}
return match
})
}

function createLibFromTemplateContent(content: FileContent[], options: Required<CreateLibOptions>): void {
for (const { fileName, content: fileContent } of content) {
const filePath = path.join(getWorkspaceRoot(), "libs", options.libFolderName, fileName)
const folder = path.dirname(filePath)
fs.mkdirSync(folder, { recursive: true })
fs.writeFileSync(filePath, fileContent)
}
}

function getWorkspaceRoot() {
return path.join(__dirname, "..", "..", "..")
}

function toKebabCase(string: string): string {
return string
.replace(/[\s_]+/g, "-")
.toLowerCase()
.replace(/[^a-z0-9-]/g, "")
}

function toLowerCamelCase(string: string): string {
return string
.replace(/(?:^\w|[A-Z]|\b\w)/g, (letter, index) => (index === 0 ? letter.toLowerCase() : letter.toUpperCase()))
.replace(/[^a-zA-Z0-9_]/g, "")
}
7 changes: 7 additions & 0 deletions tools/create-lib/src/template/README.md.template.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# __libName__

Generated by the create-lib tool. You can make your own lib using:

```sh
pnpm create:lib "Your lib name here" "optional description"
```
20 changes: 20 additions & 0 deletions tools/create-lib/src/template/package.json.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { JSONSchemaForNPMPackageJsonFiles as PackageJson } from "@schemastore/package"
import { CreateLibOptions } from "../types/CreateLibOptions"

export default function generate({
packageName,
description,
version = "0.0.0",
isPrivate = true,
mainFileName = "src/exports.ts",
}: CreateLibOptions) {
const packageJson: PackageJson = {
name: packageName,
version: version || "0.0.0",
description: description,
private: isPrivate,
main: mainFileName ?? "src/exports.ts",
}

return JSON.stringify(packageJson, null, 2)
}
5 changes: 5 additions & 0 deletions tools/create-lib/src/template/src/__mainFileName__.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { CreateLibOptions } from "../../types/CreateLibOptions"

export default function generate(_options: CreateLibOptions) {
return 'export * from "./lib/hello"'
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export function __libFunctionName__(): string {
return "Hello __libName__!"
}
10 changes: 10 additions & 0 deletions tools/create-lib/src/template/tsconfig.json.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { JSONSchemaForTheTypeScriptCompilerSConfigurationFile as TsConfig } from "@schemastore/tsconfig"
import { CreateLibOptions } from "../types/CreateLibOptions"

export default function generate(_options: CreateLibOptions) {
const config: TsConfig = {
extends: "../../tsconfig.json",
include: ["src", "../../types/**/*.d.ts"],
}
return JSON.stringify(config, null, 2)
}
33 changes: 33 additions & 0 deletions tools/create-lib/src/types/CreateLibOptions.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
export interface CreateLibOptions {
libName: string
/**
* @default "@libs/{libName}"
*/
packageName?: string
description?: string
/**
* @default "0.0.0"
*/
version?: string
/**
* @default "true"
*/
isPrivate?: boolean
/**
* @default "src/exports.ts"
*/
mainFileName?: string
/**
* @default "template"
*/
templateFolder?: string
/**
* By default, this will format the libName to kebab-case.
* No subfolders may be created within the lib folder.
*/
libFolderName?: string
/**
* By default, this will format the libName to lowerCamelCase.
*/
libFunctionName?: string
}
1 change: 1 addition & 0 deletions tools/create-lib/src/types/GenerateFunction.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export type GenerateFunction = (options: CreateLibOptions) => string
4 changes: 4 additions & 0 deletions tools/create-lib/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"extends": "../../tsconfig.json",
"include": ["src", "../../types/**/*.d.ts"]
}
4 changes: 3 additions & 1 deletion tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
"noFallthroughCasesInSwitch": true,

"esModuleInterop": true
}
}