wcb/create/index.js

195 lines
6.4 KiB
JavaScript

#!/usr/bin/env node
/**
* @license MIT <https://opensource.org/licenses/MIT>
* @author Ayo Ayco <https://ayo.ayco.io>
*
* Scaffolds a wcb (`web-component-base`) component: a publishable custom
* element with a Vite library build (ESM + UMD + types), a demo page, and
* `custom-elements.json` generation already set up — the CEM analyzer config
* with wcb's plugin, an `analyze` script, and the `customElements` field in
* `package.json`. The component class, tag, and file names are stamped from
* the project name, so no rename-me TODOs are left behind.
*
* Runs via `npm create wcb@latest [directory]` (npm resolves `create wcb` to
* this package's bin). Zero dependencies: prompts use `node:readline`.
* @see https://webcomponent.io/cem-plugin/
*/
import fs from 'node:fs'
import path from 'node:path'
import process from 'node:process'
import readline from 'node:readline/promises'
import { fileURLToPath } from 'node:url'
const DEFAULT_DIR = 'wcb-button'
/** The component identity used inside the template, rewritten on scaffold. */
const TEMPLATE_TAG = 'wcb-button'
const TEMPLATE_CLASS = 'WcbButton'
/**
* Sanitizes a directory name into a valid npm package name.
* @param {string} name the raw project directory name
* @returns {string} a lowercase, hyphenated package name
*/
function toValidPackageName(name) {
return name
.trim()
.toLowerCase()
.replace(/\s+/g, '-')
.replace(/^[._]+/, '')
.replace(/[^a-z0-9-~._]+/g, '-')
}
/**
* Derives a custom element tag name from the package name. Tag names must
* start with a lowercase letter and contain a hyphen, so a hyphen-less name
* gets an `-element` suffix (`button` → `button-element`).
* @param {string} packageName the sanitized package name
* @returns {string} a valid custom element tag name
*/
function toTagName(packageName) {
let tag = packageName
.replace(/[^a-z0-9-]+/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '')
if (!tag) return TEMPLATE_TAG
if (!/^[a-z]/.test(tag)) tag = `wc-${tag}`
if (!tag.includes('-')) tag = `${tag}-element`
return tag
}
/**
* Converts a tag name to its PascalCase class name (`my-button` → `MyButton`).
* @param {string} tag the custom element tag name
* @returns {string} the PascalCase class name
*/
function toClassName(tag) {
return tag
.split('-')
.filter(Boolean)
.map((part) => part[0].toUpperCase() + part.slice(1))
.join('')
}
/**
* Lists every file under a directory recursively.
* @param {string} dir the directory to walk
* @returns {string[]} absolute file paths
*/
function walkFiles(dir) {
return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
const full = path.join(dir, entry.name)
return entry.isDirectory() ? walkFiles(full) : [full]
})
}
/**
* Detects the package manager that launched this scaffold, so the printed
* next steps match how the user invoked it (`npm create` / `pnpm create` /
* `yarn create` / `bun create`).
* @returns {string} `npm` | `pnpm` | `yarn` | `bun`
*/
function detectPackageManager() {
const userAgent = process.env.npm_config_user_agent ?? ''
for (const pm of ['pnpm', 'yarn', 'bun'])
if (userAgent.startsWith(pm)) return pm
return 'npm'
}
/**
* Copies the template into the target directory, renaming `_gitignore` to
* `.gitignore` (npm strips `.gitignore` files from published packages, so the
* template can't ship one under its real name), then stamps the component
* identity: the template's `wcb-button` tag, `WcbButton` class, and file
* names are rewritten to the ones derived from the package name.
* @param {string} root absolute path of the target directory
* @param {string} packageName the name to write into the scaffolded `package.json`
* @returns {{tag: string, className: string}} the stamped component identity
*/
function scaffold(root, packageName) {
const templateDir = fileURLToPath(new URL('./template', import.meta.url))
fs.cpSync(templateDir, root, { recursive: true })
fs.renameSync(path.join(root, '_gitignore'), path.join(root, '.gitignore'))
const tag = toTagName(packageName)
const className = toClassName(tag)
fs.renameSync(
path.join(root, 'src', `${TEMPLATE_TAG}.ts`),
path.join(root, 'src', `${tag}.ts`)
)
for (const file of walkFiles(root)) {
const content = fs.readFileSync(file, 'utf8')
const stamped = content
.replaceAll(TEMPLATE_CLASS, className)
.replaceAll(TEMPLATE_TAG, tag)
if (stamped !== content) fs.writeFileSync(file, stamped)
}
const packageJsonPath = path.join(root, 'package.json')
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'))
packageJson.name = packageName
fs.writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2) + '\n')
return { tag, className }
}
/**
* Runs the CLI: resolves the target directory from argv or a prompt,
* refuses to overwrite existing files, scaffolds, and prints next steps.
* @returns {Promise<void>}
*/
async function main() {
const arg = process.argv[2]
if (arg === '--help' || arg === '-h') {
console.log(`Usage: npm create wcb@latest [directory]
Scaffolds a publishable web-component-base custom element in [directory]
(prompts when omitted; "." scaffolds into the current directory). The
component class, tag, and file names are derived from the directory name,
and the project comes with a Vite library build, a demo page, and
custom-elements.json generation set up.`)
return
}
let targetDir = arg
if (!targetDir) {
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
})
const answer = await rl.question(`Project directory: (${DEFAULT_DIR}) `)
rl.close()
targetDir = answer.trim() || DEFAULT_DIR
}
const root = path.resolve(process.cwd(), targetDir)
const packageName = toValidPackageName(
targetDir === '.' ? path.basename(root) : targetDir
)
if (fs.existsSync(root) && fs.readdirSync(root).length > 0) {
console.error(
`error: target directory "${targetDir}" exists and is not empty`
)
process.exit(1)
}
const { tag } = scaffold(root, packageName)
const pm = detectPackageManager()
const run = pm === 'npm' ? 'npm run' : pm
console.log(`
Scaffolded ${packageName} in ${root} — defines <${tag}>
Next steps:
cd ${targetDir}
${pm} install
${run} dev # demo page on the Vite dev server
${run} analyze # generate custom-elements.json
${run} build:lib # library build: ESM + UMD + types in dist/
`)
}
await main()