generator-luau
Version:
A Yeoman generator for Luau projects
1,581 lines (1,420 loc) • 42.1 kB
JavaScript
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 };