UNPKG

generator-luau

Version:
1,581 lines (1,420 loc) 42.1 kB
import Generator from 'yeoman-generator'; import path from 'path'; import { format } from 'prettier-package-json'; import axios from 'axios'; import { request } from '@octokit/request'; import { dump } from 'js-yaml'; import { format as format$1 } from 'prettier'; const VERSION_CACHE = { '@jsdotlua/jest': '3.10.0', '@jsdotlua/jest-globals': '3.10.0', npmluau: '0.1.2', }; function fetchScopedPackageVersion(packageName) { const scope = packageName.substring(1, packageName.indexOf('/')); return axios .get(`https://registry.npmjs.com/-/v1/search`, { params: { text: `scope:${scope} ${packageName}`, }, }) .then((res) => res.data?.objects[0]?.package?.version) } function fetchPackageVersion(packageName) { return axios .get(`https://registry.npmjs.org/${packageName}/latest`) .then((res) => res.data?.version) } async function fetchLatestNpmPackageVersion(packageName) { const version = VERSION_CACHE[packageName]; if (version !== undefined) { return version } const fetchedVersion = ( packageName.startsWith('@') ? fetchScopedPackageVersion(packageName) : fetchPackageVersion(packageName) ).catch((err) => { console.warn(`fetching '${packageName}' version on npm errored: ${err}`); return null }); if (fetchedVersion) { VERSION_CACHE[packageName] = fetchedVersion; } return fetchedVersion } async function createDependencies(...packages) { const versions = await Promise.all( packages.map(async (name) => ({ name, version: await fetchLatestNpmPackageVersion(name), })) ); return versions.reduce((dependencies, { name, version }) => { if (version) { dependencies[name] = '^' + version; } else { console.warn( `skip dependency '${name}'. You can manually add it with ` + `'npm install ${name}' or 'yarn add ${name}'` ); } return dependencies }, {}) } class PackageJsonBuilder { constructor(name) { this._base = { name, keywords: ['luau'], }; this._dependencies = {}; this._devDependencies = {}; this._scripts = {}; } static from(packageJson) { const { name, ...definitions } = packageJson; return new PackageJsonBuilder(name).merge(definitions) } merge({ scripts, dependencies, devDependencies, ...definitions }) { Object.assign(this._base, definitions); if (scripts) { this.addScripts(scripts); } if (devDependencies) { this.addDevDependencies(devDependencies); } if (dependencies) { this.addDependencies(dependencies); } return this } addScripts(scripts) { Object.assign(this._scripts, scripts); return this } addDevDependencies(dependencies) { Object.assign(this._devDependencies, dependencies); return this } addDependencies(dependencies) { Object.assign(this._dependencies, dependencies); return this } build() { return Object.assign( {}, { scripts: this._scripts, dependencies: this._dependencies, devDependencies: this._devDependencies, }, this._base ) } } const extractPackageName = (packageName) => { const firstSlash = packageName.indexOf('/'); if (firstSlash) { return { name: packageName.slice(firstSlash + 1), scope: packageName.slice(0, firstSlash), full: packageName, } } return { name: packageName, scope: null, full: packageName, } }; class LuauPackageGenerator extends Generator { constructor(args, opts) { super(args, opts); this._name = extractPackageName(opts.name); this._authorName = opts.authorName; this._authorEmail = opts.authorEmail; this._githubOwner = opts.githubOwner; this._luaExtension = opts.luaExtension; this._useJest = opts.useJest; this._luaEnvironment = opts.luaEnvironment; this._licenseFileName = opts.licenseFileName; this._location = opts.location; this._isWorkspace = opts.location != null; this._canSetupMultipleProjects = this._isWorkspace; } async prompting() { this._projects = [ await this._setupProject( this._name.full, path.basename(this.destinationRoot()) ), ]; let askAgain = this._canSetupMultipleProjects; while (askAgain) { const { setupNewProject } = await this.prompt([ { type: 'confirm', name: 'setupNewProject', message: 'Would you like to setup another project?', default: false, }, ]); if (setupNewProject) { this._projects.push(await this._setupProject()); } else { askAgain = false; } } this.packages = this._projects.map(({ projectName }) => projectName); } async _setupProject(name, defaultName) { const { projectName } = name ? { projectName: name } : await this.prompt([ { type: 'input', name: 'projectName', message: 'What is the project name?', default: defaultName, }, ]); const { useReact } = await this.prompt([ { type: 'confirm', name: 'useReact', message: 'Would you like to use React Lua?', when: this._luaEnvironment === 'roblox', default: false, }, ]); return { projectName, useReact, } } async writing() { const luaExtension = this._luaExtension; const luaEnvironment = this._luaEnvironment; const licenseName = this.licenseName ?? 'UNLICENSED'; await Promise.all( this._projects.map(async ({ projectName, useReact }) => { const projectNameInfo = extractPackageName(projectName); const getLocation = this._location ? (...subpath) => this.destinationPath( this._location, projectNameInfo.name, ...subpath ) : (...subpath) => this.destinationPath(...subpath); const packageJsonLocation = getLocation('package.json'); const packageBuilder = this.fs.exists(packageJsonLocation) ? PackageJsonBuilder.from(this.fs.readJSON(packageJsonLocation)) : new PackageJsonBuilder(projectNameInfo.full); const repositoryBaseUrl = this._isWorkspace ? `https://github.com/${this._githubOwner}/${this._name.name}` : `https://github.com/${this._githubOwner}/${projectNameInfo.name}`; const gitRepoUrl = `git+${repositoryBaseUrl}.git`; const entryPoint = `src/init.${luaExtension}`; packageBuilder.merge({ version: '0.1.0', main: entryPoint, description: '', author: `${this._authorName} <${this._authorEmail}>`, repository: this._isWorkspace ? { type: 'git', url: gitRepoUrl, directory: `${this._location}/${projectNameInfo.name}`, } : { type: 'git', url: gitRepoUrl }, homepage: `${repositoryBaseUrl}#readme`, }); this.fs.write(getLocation(entryPoint), 'return {}\n'); this.fs.copyTpl( this.templatePath('README.md'), getLocation('README.md'), { projectName: projectNameInfo.name, fullProjectName: projectNameInfo.full, repoName: this._name.name, licenseName, licenseFileName: this._licenseFileName, licensePath: `${this._location ? '../../' : ''}${this._licenseFileName}`, githubOwner: this._githubOwner, } ); const copyFiles = { 'CHANGELOG.md': 'CHANGELOG.md', npm_ignore: '.npmignore', }; Object.entries(copyFiles).forEach(([templateName, destinationName]) => { this.fs.copyTpl( this.templatePath(templateName), this.destinationPath(destinationName) ); }); if (useReact) { if (luaEnvironment === 'roblox') { packageBuilder.addDependencies( await createDependencies( '@jsdotlua/react', '@jsdotlua/react-roblox' ) ); } else { packageBuilder.addDependencies( await createDependencies('@jsdotlua/react') ); } } if (this._useJest) { packageBuilder.addDevDependencies( await createDependencies('@jsdotlua/jest', '@jsdotlua/jest-globals') ); const testFilePath = `init.test.${luaExtension}`; this.fs.copyTpl( this.templatePath('init.test.lua'), getLocation(`src/__tests__/${testFilePath}`) ); } this.fs.write( packageJsonLocation, format(packageBuilder.build()) ); }) ); } } const TOOLS = { selene: { owner: 'Kampfkarren', repo: 'selene' }, stylua: { owner: 'JohnnyMorganz', repo: 'StyLua' }, 'luau-lsp': { owner: 'JohnnyMorganz', repo: 'luau-lsp' }, darklua: { owner: 'seaofvoices', repo: 'darklua' }, rojo: { owner: 'rojo-rbx', repo: 'rojo' }, tarmac: { owner: 'rojo-rbx', repo: 'tarmac' }, 'run-in-roblox': { owner: 'rojo-rbx', repo: 'run-in-roblox' }, lune: { owner: 'lune-org', repo: 'lune' }, mantle: { owner: 'blake-mealey', repo: 'mantle' }, }; const TOOL_NAMES = Object.keys(TOOLS); class ForemanGenerator extends Generator { constructor(args, opts) { super(args, opts); this.opts = opts; } initializing() { this._foremanTools = new Set(['selene', 'stylua', 'luau-lsp', 'darklua']); TOOL_NAMES.forEach((tool) => { if (this.opts[tool] === true) { this._foremanTools.add(tool); } }); } async prompting() { this.promptResults = await this.prompt([ { type: 'checkbox', name: 'tools', choices: TOOL_NAMES.filter((tool) => !this._foremanTools.has(tool)), }, ]); this.promptResults.tools.forEach((tool) => { this._foremanTools.add(tool); }); } async fetchForemanToolVersions() { const requestWithAuth = request.defaults({}); this._foremanToolsVersions = ( await Promise.all( Array.from(this._foremanTools).map(async (name) => { if (!(name in TOOLS)) { this.log(`unable to find owner and repo for tool '${name}'`); return null } const { owner, repo } = TOOLS[name]; const version = await requestWithAuth( 'GET /repos/{owner}/{repo}/releases', { owner, repo, headers: { 'X-GitHub-Api-Version': '2022-11-28', }, } ) .then((releases) => { const tagName = releases.data[0].tag_name; if (tagName.startsWith('v')) { return tagName.slice(1) } return tagName }) .catch((err) => { this.log( `fetching ${name} version at ${owner}/${repo} errored: ${err}\n` + ` visit https://github.com/${owner}/${repo}/releases to find the latest version` ); return null }); if (version === null) { return { name, owner, repo, version: '*', } } return { name, owner, repo, version, } }) ) ).filter((tool) => tool !== null); this._foremanToolsVersions.sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0 ); this.toolVersions = Object.fromEntries( this._foremanToolsVersions .filter(({ version }) => version !== '*') .map(({ name, version }) => [name, version]) ); } writing() { this.fs.write( this.destinationPath('foreman.toml'), ['[tools]'] .concat( this._foremanToolsVersions.map( ({ name, owner, repo, version }) => `${name} = { github = "${owner}/${repo}", version = "=${version}"}` ) ) .join('\n') + '\n' ); } install() { const cwd = this.destinationRoot(); const execOptions = { cwd }; this.spawnSync('foreman', ['install'], execOptions); if (this._foremanTools.has('lune')) { this.spawnSync('lune', ['setup'], execOptions); } } } const licenses = [ { name: 'Apache 2.0', value: 'Apache-2.0' }, { name: 'MIT', value: 'MIT' }, { name: 'Mozilla Public License 2.0', value: 'MPL-2.0' }, { name: 'BSD 2-Clause (FreeBSD) License', value: 'BSD-2-Clause-FreeBSD' }, { name: 'BSD 3-Clause (NewBSD) License', value: 'BSD-3-Clause' }, { name: 'Internet Systems Consortium (ISC) License', value: 'ISC' }, { name: 'GNU AGPL 3.0', value: 'AGPL-3.0' }, { name: 'GNU GPL 3.0', value: 'GPL-3.0' }, { name: 'GNU LGPL 3.0', value: 'LGPL-3.0' }, { name: 'Unlicense', value: 'Unlicense' }, { name: 'No License (Copyrighted)', value: 'UNLICENSED' }, ]; class GeneratorLicense extends Generator { constructor(args, opts) { super(args, opts); this.option('name', { type: String, desc: 'Name of the license owner', required: false, }); this.option('email', { type: String, desc: 'Email of the license owner', required: false, }); this.option('website', { type: String, desc: 'Website of the license owner', required: false, }); this.option('year', { type: String, desc: 'Year(s) to include on the license', required: false, defaults: new Date().getFullYear(), }); this.option('licensePrompt', { type: String, desc: 'License prompt text', defaults: 'Which license do you want to use?', hide: true, required: false, }); this.option('defaultLicense', { type: String, desc: 'Default license', required: false, }); this.option('license', { type: String, desc: 'Select a license, so no license prompt will happen, in case you want to handle it outside of this generator', required: false, }); this.option('output', { type: String, desc: 'Set the output file for the generated license', required: false, defaults: 'LICENSE', }); this.option('publish', { type: Boolean, desc: 'Publish the package', required: false, }); } async prompting() { this.gitc = { user: { name: await this.git.name(), email: await this.git.email(), }, }; const prompts = [ { name: 'name', message: 'What name should the license use?', default: this.options.name || this.gitc.user.name, when: this.options.name === undefined, }, { name: 'email', message: 'What email should the license use? (optional)', default: null, when: this.options.email === undefined, }, { name: 'website', message: 'What website should the license use? (optional)', default: this.options.website ?? '', when: this.options.website === undefined, }, { type: 'list', name: 'license', message: this.options.licensePrompt, default: this.options.defaultLicense, when: !this.options.license || licenses.find((x) => x.value === this.options.license) === undefined, choices: licenses, }, ]; const props = await this.prompt(prompts); this.props = { name: this.options.name, email: this.options.email, website: this.options.website, license: this.options.license, ...props, }; } writing() { // License file const filename = this.props.license + '.txt'; let author = this.props.name.trim(); if (this.props.email) { author += ' <' + this.props.email.trim() + '>'; } if (this.props.website) { author += ' (' + this.props.website.trim() + ')'; } this.fs.copyTpl( this.templatePath(filename), this.destinationPath(this.options.output), { year: this.options.year, author: author, } ); } } const WORKFLOW_DISPATCH = { inputs: { release_tag: { description: 'The version to release starting with `v`', required: true, type: 'string', }, release_ref: { description: 'The branch, tag or SHA to checkout (default to latest)', default: '', type: 'string', }, }, }; const buildProjectSetup = ({ packageManager }) => { const steps = []; const isYarn = packageManager === 'yarn'; if (isYarn) { steps.push({ name: 'Enable corepack', run: 'corepack enable', }); } steps.push({ uses: 'actions/setup-node@v4', with: { 'node-version': 'latest', 'registry-url': 'https://registry.npmjs.org', cache: packageManager, 'cache-dependency-path': packageManager === 'yarn' ? 'yarn.lock' : 'package-lock.json', }, }); if (isYarn) { steps.push({ name: 'Install packages', run: 'yarn install --immutable', }); steps.push({ name: 'Run npmluau', run: 'yarn run prepare', }); } else { steps.push({ name: 'Install packages', run: 'npm ci', }); } return steps }; const buildPublishSteps = (options) => { const { packageManager, useWorkspaces } = options; const steps = [ { uses: 'actions/checkout@v4', }, ...buildProjectSetup(options), ]; if (packageManager === 'yarn') { steps.push({ name: 'Authenticate yarn', run: 'yarn config set npmAlwaysAuth true\nyarn config set npmAuthToken $NPM_AUTH_TOKEN', env: { NPM_AUTH_TOKEN: '${{ secrets.NPM_TOKEN }}', }, }); if (useWorkspaces) { steps.push({ name: 'Publish to npm', run: 'yarn workspaces foreach --all --no-private npm publish --access public --tolerate-republish', }); } else { steps.push({ name: 'Publish to npm', run: 'yarn npm publish --access public', }); } } else { if (useWorkspaces) { console.warn('publishing workspace packages is not supported'); } else { steps.push({ name: 'Publish to npm', run: 'npm publish', env: { NODE_AUTH_TOKEN: '${{ secrets.NPM_TOKEN }}', }, }); } } return steps }; const buildTestWorkflow = (options) => { const { packageManager, hasBuildScripts } = options; const buildSteps = hasBuildScripts ? [{ name: 'Build assets', run: `${packageManager} run build` }] : []; return { name: 'Tests', on: { push: { branches: ['main'] }, pull_request: { branches: ['main'] }, }, jobs: { test: { name: 'Run tests', 'runs-on': 'ubuntu-latest', steps: [ { uses: 'actions/checkout@v4', }, { uses: 'CompeyDev/setup-rokit@v0.1.2', }, ...buildProjectSetup(options), { name: 'Run linter', run: `${packageManager} run lint`, }, { name: 'Verify code style', run: `${packageManager} run style-check`, }, ...buildSteps, ], }, }, } }; // options: { // packageManager: "yarn" | "npm", // useWorkspaces: bool, // artifacts: { name: string, path: string, assetType: string }[] // } const buildReleaseWorkflow = (options) => { const { packageManager, artifacts } = options; const jobs = { 'publish-package': { name: 'Publish package', 'runs-on': 'ubuntu-latest', steps: buildPublishSteps(options), }, 'create-release': { needs: 'publish-package', name: 'Create release', 'runs-on': 'ubuntu-latest', outputs: { upload_url: '${{ steps.create_release.outputs.upload_url }}', }, steps: [ { uses: 'actions/checkout@v4' }, { name: 'Create tag', run: 'git fetch --tags --no-recurse-submodules\nif [ ! $(git tag -l ${{ inputs.release_tag }}) ]; then\n git tag ${{ inputs.release_tag }}\n git push origin ${{ inputs.release_tag }}\nfi\n', }, { name: 'Create release', id: 'create_release', uses: 'softprops/action-gh-release@v1', env: { GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}', }, with: { tag_name: '${{ inputs.release_tag }}', name: '${{ inputs.release_tag }}', draft: false, }, }, ], }, }; if (artifacts.length > 0) { jobs['build-assets'] = { needs: 'create-release', name: 'Add assets', 'runs-on': 'ubuntu-latest', strategy: { 'fail-fast': false, matrix: { include: artifacts.map((info) => ({ 'artifact-name': info.name, path: info.path, 'asset-type': info.assetType, })), }, }, steps: [ { uses: 'actions/checkout@v4' }, { uses: 'CompeyDev/setup-rokit@v0.1.2', }, ...buildProjectSetup(options), { name: 'Build assets', run: `${packageManager} run build`, }, { name: 'Upload asset', uses: 'actions/upload-artifact@v4', with: { name: '${{ matrix.artifact-name }}', path: '${{ matrix.path }}', }, }, { name: 'Add asset to Release', uses: 'actions/upload-release-asset@v1', env: { GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}', }, with: { upload_url: '${{ needs.create-release.outputs.upload_url }}', asset_path: '${{ matrix.path }}', asset_name: '${{ matrix.artifact-name }}', asset_content_type: '${{ matrix.asset-type }}', }, }, ], }; } return { name: 'Release', on: { workflow_dispatch: WORKFLOW_DISPATCH, }, permissions: { contents: 'write', }, jobs, } }; const buildRules = (options) => { const { devGlobal, luaEnvironment } = options; return [ { rule: 'inject_global_value', identifier: 'LUA_ENV', value: luaEnvironment, }, { rule: 'inject_global_value', identifier: 'DEV', value: devGlobal, }, { rule: 'inject_global_value', identifier: '__DEV__', value: devGlobal, }, 'compute_expression', 'remove_unused_if_branch', 'filter_after_early_return', 'convert_index_to_field', 'remove_unused_while', 'remove_empty_do', 'remove_method_definition', ] }; const buildDarkluaConfig = (options) => ({ rules: [ 'remove_comments', 'remove_spaces', { rule: 'convert_require', current: 'path', target: { name: 'roblox', rojo_sourcemap: 'sourcemap.json', indexing_style: 'wait_for_child', }, }, ...buildRules(options), ], }); const buildBundleDarkluaConfig = (options) => ({ bundle: { require_mode: 'path', }, rules: [ 'remove_types', 'remove_comments', 'remove_spaces', ...buildRules(options), ], }); const buildRojoPlaceConfig = ({ name, enableHttpService = true, autoLoadCharacters = false, services = {}, }) => { const config = { name, tree: { $className: 'DataModel', ...services, }, }; if (enableHttpService) { config.tree.HttpService = { $properties: { HttpEnabled: true, }, }; } if (autoLoadCharacters !== null) { config.tree.Players = { $properties: { CharacterAutoLoads: autoLoadCharacters, }, }; } return config }; const formatYaml = async (value) => await format$1( dump(value).replaceAll(/\n +-|\n[^ ]/gm, (match) => '\n' + match), { parser: 'yaml' } ); const LICENSE_FILE_NAME = 'LICENSE.txt'; const extractFileNameInfo = (name, extensions) => { if (extensions.some((extension) => name.endsWith(`.${extension}`))) { const index = name.lastIndexOf('.'); return { name: name.slice(0, index), extension: name.slice(index + 1), full: name, } } return { name, extension: extensions[0], full: `${name}.${extensions[0]}`, } }; class LuauGenerator extends Generator { constructor(args, opts) { super(args, opts, { customInstallTask: () => {}, }); this.argument('packageName', { type: String }); if (this.options.packageName) { this.destinationRoot(this.options.packageName); } } async prompting() { const gitc = { name: await this.git.name(), email: await this.git.email(), }; this.promptResults = await this.prompt([ { type: 'input', name: 'projectName', message: 'What is the project name?', default: path.basename(this.destinationRoot()), }, { type: 'input', name: 'authorName', message: 'Who is the author?', default: gitc.name, }, { type: 'input', name: 'authorEmail', message: "What is the author's email?", default: gitc.email, }, { type: 'input', name: 'githubOwner', message: 'Who is the GitHub owner?', default: (results) => results.authorName, }, { type: 'input', name: 'codeOfConductContact', message: 'What is the contact email for the code of conduct?', default: (results) => results.authorEmail, }, { type: 'confirm', name: 'useWorkspaces', message: 'Would you like setup workspaces?', default: false, }, { type: 'list', name: 'packageManager', message: 'Choose package manager', default: 'yarn', choices: [ { name: 'yarn', value: 'yarn' }, { name: 'npm', value: 'npm' }, ], }, { type: 'list', name: 'luaEnvironment', message: 'Choose a Lua environment', default: 'roblox', choices: [ 'roblox', { name: 'lune', description: 'A standalone Luau runtime (https://github.com/lune-org/lune)', }, ], }, { type: 'confirm', name: 'useJest', message: `Would you like to setup Jest?`, default: true, when: (results) => results.luaEnvironment === 'roblox', }, { type: 'list', name: 'luaExtension', message: 'Choose Lua extension', default: 'luau', choices: [ { name: 'lua', value: 'lua' }, { name: 'luau', value: 'luau' }, ], }, { type: 'confirm', name: 'buildSingleFile', message: `Would you like bundle the project into a single file?`, default: false, when: (results) => !results.useWorkspaces, }, { type: 'input', name: 'singleFileName', message: 'Specify a name for the bundled file:', default: (results) => { const nameInfo = extractPackageName(results.projectName); return `${nameInfo.name}.${results.luaExtension}` }, when: (results) => results.buildSingleFile, }, { type: 'confirm', name: 'buildRobloxModel', message: `Would you like to build a Roblox model file?`, default: false, when: (results) => results.luaEnvironment === 'roblox' && !results.useWorkspaces, }, { type: 'input', name: 'robloxModelName', message: 'Specify a name for the Roblox model file:', default: (results) => { const nameInfo = extractPackageName(results.projectName); return nameInfo.name }, when: (results) => results.buildRobloxModel, }, { type: 'confirm', name: 'buildDebugAssets', message: `Would you like to build debug assets with development globals defined as true?`, default: true, when: (results) => results.buildRobloxModel || results.buildSingleFile, }, ]); this._foremanGenerator = await this.composeWith( { Generator: ForemanGenerator, path: '../foreman/index.js', }, { rojo: this.promptResults.luaEnvironment === 'roblox', ['run-in-roblox']: this.promptResults.useJest, lune: this.promptResults.luaEnvironment === 'lune', } ); this._packageGenerator = await this.composeWith( { Generator: LuauPackageGenerator, path: '../package/index.js', }, { name: this.promptResults.projectName, authorName: this.promptResults.authorName, authorEmail: this.promptResults.authorEmail, githubOwner: this.promptResults.githubOwner, location: this.promptResults.useWorkspaces ? 'packages' : null, luaExtension: this.promptResults.luaExtension, useJest: this.promptResults.useJest, luaEnvironment: this.promptResults.luaEnvironment, licenseFileName: LICENSE_FILE_NAME, } ); this._licenseGenerator = await this.composeWith( { Generator: GeneratorLicense, path: '../license/index.js', }, { defaultLicense: 'MIT', output: this.destinationPath(LICENSE_FILE_NAME), } ); } async writing() { const { useWorkspaces, packageManager, codeOfConductContact, projectName, luaEnvironment, useJest, luaExtension, buildSingleFile, singleFileName, buildRobloxModel, robloxModelName, buildDebugAssets, } = this.promptResults; const projectNameInfo = extractPackageName(projectName); const isRobloxEnv = luaEnvironment === 'roblox'; const licenseName = this._licenseGenerator.props.license; this._packageGenerator.licenseName = licenseName; const robloxModelNameInfo = robloxModelName && extractFileNameInfo(robloxModelName, ['rbxm', 'rbxmx']); const singleFileNameInfo = singleFileName && extractFileNameInfo(singleFileName, [ luaExtension, luaExtension === 'lua' ? 'luau' : 'lua', ]); const copyFiles = [ '.styluaignore', 'stylua.toml', 'selene.toml', '.vscode/extensions.json', '.github/pull_request_template.md', ]; if (packageManager === 'yarn') { copyFiles.push('.yarnrc.yml'); } const copyFilesRename = { git_ignore: '.gitignore', git_attributes: '.gitattributes', }; copyFiles.forEach((name) => { this.fs.copyTpl(this.templatePath(name), this.destinationPath(name)); }); Object.entries(copyFilesRename).forEach( ([templateName, destinationName]) => { this.fs.copyTpl( this.templatePath(templateName), this.destinationPath(destinationName) ); } ); const luauConfig = { languageMode: 'strict', lintErrors: true, lint: { '*': true, }, aliases: { pkg: './node_modules/.luau-aliases', }, }; const luneVersion = this._foremanGenerator.toolVersions.lune; if (luneVersion) { const luneAliasPath = `~/.lune/.typedefs/${luneVersion}/`; luauConfig.aliases.lune = luneAliasPath; } this.fs.writeJSON(this.destinationPath('.luaurc'), luauConfig); this.fs.writeJSON(this.destinationPath('.vscode/settings.json'), { 'luau-lsp.sourcemap.autogenerate': false, 'luau-lsp.completion.imports.requireStyle': 'alwaysRelative', 'luau-lsp.platform.type': isRobloxEnv ? 'roblox' : 'standard', }); const mainFolderName = useWorkspaces ? 'packages' : 'src'; this.fs.copyTpl( this.templatePath( isRobloxEnv ? 'scripts/roblox-analyze.sh' : 'scripts/analyze.sh' ), this.destinationPath('scripts/analyze.sh'), { root: mainFolderName } ); const buildScripts = []; if (buildSingleFile || buildRobloxModel) { this.fs.copyTpl( this.templatePath('scripts/remove-tests.sh'), this.destinationPath('scripts/remove-tests.sh') ); } const releaseArtifacts = []; if (buildSingleFile) { const darkluaConfigPath = '.darklua-bundle.json'; this.fs.writeJSON( this.destinationPath(darkluaConfigPath), buildBundleDarkluaConfig({ devGlobal: false, luaEnvironment, }) ); const bundleName = singleFileNameInfo.full; this.fs.copyTpl( this.templatePath('scripts/build-single-file.sh'), this.destinationPath('scripts/build-single-file.sh'), { packageManager, entryPoint: `src/init.${luaExtension}` } ); buildScripts.push( `scripts/build-single-file.sh ${darkluaConfigPath} build/${bundleName}` ); releaseArtifacts.push({ name: bundleName, path: `build/${bundleName}`, assetType: 'text/plain', }); if (buildDebugAssets) { const darkluaDevConfigPath = '.darklua-bundle-dev.json'; this.fs.writeJSON( this.destinationPath(darkluaDevConfigPath), buildBundleDarkluaConfig({ devGlobal: true, luaEnvironment, }) ); buildScripts.push( `scripts/build-single-file.sh ${darkluaDevConfigPath} build/debug/${bundleName}` ); releaseArtifacts.push({ name: `${singleFileNameInfo.name}-dev.${singleFileNameInfo.extension}`, path: `build/debug/${bundleName}`, assetType: 'text/plain', }); } } if (buildRobloxModel) { const modelProjectJson = 'model.project.json'; this.fs.writeJSON(this.destinationPath(modelProjectJson), { name: projectNameInfo.name, tree: { $path: 'src', node_modules: { $path: 'node_modules' }, }, }); const darkluaConfigPath = '.darklua.json'; this.fs.writeJSON( this.destinationPath(darkluaConfigPath), buildDarkluaConfig({ devGlobal: false, luaEnvironment, }) ); const INSTALL_PRODUCTION = { yarn: ['yarn workspaces focus --production', 'yarn dlx npmluau'], npm: [ 'npm install --omit dev', 'npm exec --yes --package npmluau -- npmluau', ], }; this.fs.copyTpl( this.templatePath('scripts/build-roblox-model.sh'), this.destinationPath('scripts/build-roblox-model.sh'), { installProduction: INSTALL_PRODUCTION[packageManager].join('\n'), rojoConfig: modelProjectJson, } ); buildScripts.push( `scripts/build-roblox-model.sh ${darkluaConfigPath} build/${robloxModelNameInfo.full}` ); releaseArtifacts.push({ name: `${robloxModelNameInfo.full}`, path: `build/${robloxModelNameInfo.full}`, assetType: 'application/octet-stream', }); if (buildDebugAssets) { const darkluaDevConfigPath = '.darklua-dev.json'; this.fs.writeJSON( this.destinationPath(darkluaDevConfigPath), buildDarkluaConfig({ devGlobal: true, luaEnvironment, }) ); buildScripts.push( `scripts/build-roblox-model.sh ${darkluaDevConfigPath} build/debug/${robloxModelNameInfo.full}` ); releaseArtifacts.push({ name: `${robloxModelNameInfo.name}-dev.${robloxModelNameInfo.extension}`, path: `build/debug/${robloxModelNameInfo.full}`, assetType: 'application/octet-stream', }); } } const hasBuildScripts = buildScripts.length > 0; if (hasBuildScripts) { this.fs.copyTpl( this.templatePath('scripts/build.sh'), this.destinationPath('scripts/build.sh'), { scripts: buildScripts.join('\n') } ); } this.fs.write( this.destinationPath('.github/workflows/test.yml'), await formatYaml(buildTestWorkflow({ packageManager, hasBuildScripts })) ); this.fs.write( this.destinationPath('.github/workflows/release.yml'), await formatYaml( buildReleaseWorkflow({ packageManager, useWorkspaces, artifacts: releaseArtifacts, }) ) ); this.fs.copyTpl( this.templatePath('selene_defs.yml'), this.destinationPath('selene_defs.yml'), { seleneBase: isRobloxEnv ? 'roblox' : 'lua51' } ); const packageBuilder = new PackageJsonBuilder( useWorkspaces ? 'workspace' : projectName ); if (useWorkspaces) { packageBuilder.merge({ workspaces: [mainFolderName + '/*'], private: true, }); } else { packageBuilder.merge({ license: licenseName }); } this.fs.copyTpl( this.templatePath('CODE_OF_CONDUCT.md'), this.destinationPath('CODE_OF_CONDUCT.md'), { contact: codeOfConductContact } ); packageBuilder.addDevDependencies(await createDependencies('npmluau')); packageBuilder.addScripts({ prepare: 'npmluau', lint: `sh ./scripts/analyze.sh && selene ${mainFolderName}`, 'lint:luau': 'sh ./scripts/analyze.sh', 'lint:selene': `selene ${mainFolderName}`, format: 'stylua .', 'style-check': 'stylua . --check', 'verify-pack': packageManager === 'yarn' ? 'yarn workspaces foreach -A --no-private pack --dry-run' : 'npm pack --dry-run', clean: 'rm -rf node_modules build' + (isRobloxEnv ? ' temp' : ''), }); if (hasBuildScripts) { packageBuilder.addScripts({ build: 'sh ./scripts/build.sh', }); } if (useJest) { const testRojoProjectFile = 'test-place.project.json'; const replicatedStorageDefinition = { node_modules: { $path: './node_modules' }, }; const jestRoots = []; const jestConfigPath = `jest.config.${luaExtension}`; if (useWorkspaces) { jestRoots.push( ...this._packageGenerator.packages.map((name) => [ 'ReplicatedStorage:FindFirstChild("node_modules")', ...name .split('/') .map((instanceName) => `:FindFirstChild("${instanceName}")`), ].join('') ) ); } else { jestRoots.push('ReplicatedStorage:FindFirstChild("TestTarget")'); replicatedStorageDefinition.TestTarget = { $path: './src', 'jest.config': { $path: `./${jestConfigPath}` }, }; } this.fs.writeJSON( this.destinationPath(testRojoProjectFile), buildRojoPlaceConfig({ name: 'test-place', services: { ReplicatedStorage: replicatedStorageDefinition, ServerScriptService: { RunTests: { $path: `./scripts/roblox-test.server.${luaExtension}`, }, }, }, }) ); const darkluaConfig = '.darklua-tests.json'; this.fs.writeJSON( this.destinationPath(darkluaConfig), buildDarkluaConfig({ devGlobal: false, luaEnvironment, }) ); const runRobloxTestPath = `scripts/roblox-test.server.${luaExtension}`; const folders = useWorkspaces ? ['node_modules', runRobloxTestPath] : [jestConfigPath, runRobloxTestPath, 'node_modules', 'src']; this.fs.copyTpl( this.templatePath('scripts/roblox-test.sh'), this.destinationPath('scripts/roblox-test.sh'), { packageManager, testRojoProjectFile, darkluaConfig, folders, luaExtension, } ); this.fs.copyTpl( this.templatePath('scripts/roblox-test.server.lua'), this.destinationPath(runRobloxTestPath), { jestRoots: jestRoots.join(', ') } ); if (useWorkspaces) { this._packageGenerator.packages.forEach((name) => { this.fs.copyTpl( this.templatePath('jest.config.lua'), this.destinationPath(`${mainFolderName}/${name}/${jestConfigPath}`) ); }); } else { this.fs.copyTpl( this.templatePath('jest.config.lua'), this.destinationPath(jestConfigPath) ); } packageBuilder.addScripts({ 'test:roblox': 'sh ./scripts/roblox-test.sh', }); } if (packageManager === 'yarn') { if (useWorkspaces) { packageBuilder.addScripts({ 'verify-pack': 'yarn workspaces foreach -A --no-private pack --dry-run', }); } else { packageBuilder.addScripts({ 'verify-pack': 'yarn pack --dry-run', }); } } else { packageBuilder.addScripts({ 'verify-pack': 'npm pack --dry-run', }); } this.fs.write( this.destinationPath('package.json'), format(packageBuilder.build()) ); } install() { const { packageManager } = this.promptResults; const cwd = this.destinationRoot(); const execOptions = { cwd }; if (packageManager === 'yarn') { this.spawnSync('yarn', ['set', 'version', 'stable'], execOptions); } this.spawnSync(packageManager, ['install'], execOptions); this.spawnSync(packageManager, ['run', 'prepare'], execOptions); this.spawnSync('stylua', ['.'], execOptions); } } export { LuauGenerator as default };