diff --git a/.changeset/config.json b/.changeset/config.json new file mode 100644 index 0000000..571893d --- /dev/null +++ b/.changeset/config.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://unpkg.com/@changesets/config@3.0.0/schema.json", + "changelog": [ + "@changesets/changelog-github", + { + "repo": "owner/web-learning-kit-generator" + } + ], + "commit": false, + "fixed": [], + "linked": [], + "access": "public", + "baseBranch": "main", + "updateInternalDependencies": "patch", + "ignore": [] +} diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..1014ba7 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,12 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +indent_style = space +indent_size = 2 +trim_trailing_whitespace = true + +[*.md] +trim_trailing_whitespace = false diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..abf22e5 --- /dev/null +++ b/.env.example @@ -0,0 +1,5 @@ +# Set to true to automatically open BrowserSync in the default browser +BROWSERSYNC_OPEN=false + +# Set to true to skip imagemin optimization in the images task +SKIP_IMAGE_OPTIMIZATION=false diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..a1925a7 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,15 @@ +## Summary +- What changed? +- Why was this needed? + +## Validation +- [ ] `npm run typecheck` +- [ ] `npm test` +- [ ] `npm run lint` (if available in environment) + +## Generated project impact +- [ ] No generated output behavior changes +- [ ] Generated output behavior changed (describe below) + +## Notes +Include any migration or follow-up notes here. diff --git a/README.md b/README.md index ddc67ae..65a604d 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,18 @@ npm run typecheck npm test ``` +Environment toggles (optional): + +```bash +# keep BrowserSync from opening a browser +BROWSERSYNC_OPEN=false + +# skip image optimization for faster local loops +SKIP_IMAGE_OPTIMIZATION=true +``` + +See `.env.example` for supported toggles. + ## Project structure ```text @@ -151,6 +163,7 @@ If your goal is to make this starter more production-realistic for learners, imp - Add one-click workflows for GitHub Pages / Netlify / Vercel static output. 2. **Release automation** - Semantic versioning + changelog generation. + - Track with Changesets config (`.changeset/config.json`). 3. **Performance checks** - Add Lighthouse CI or static asset budget checks. diff --git a/_gulp/gulpSetup.ts b/_gulp/gulpSetup.ts index 03884b9..943a783 100644 --- a/_gulp/gulpSetup.ts +++ b/_gulp/gulpSetup.ts @@ -1,53 +1,71 @@ -import { exec } from 'child_process'; +import { spawn } from 'child_process'; import { writeFile } from 'fs/promises'; import { copyVendorCSS, createProjectFiles, createProjectStructure } from './modules/fileSetup'; import { generateGulpfile } from './modules/gulpfileGenerator'; -import { confirmProjectDeletion, promptUser } from './modules/setupQuestions'; import { parseSetupOptions } from './modules/setupCliOptions'; +import { confirmProjectDeletion, promptUser } from './modules/setupQuestions'; import { assertUserChoices } from './modules/userChoicesValidation'; import { UserChoices } from './types'; -import { deleteDirectory, fileExists } from './utils/fileSystem'; +import { deleteProjectDirectory, fileExists } from './utils/fileSystem'; import { logger } from './utils/logger'; +async function prepareProjectDirectories(autoConfirm: boolean): Promise { + const projectExists = fileExists('src') || fileExists('dist'); + if (!projectExists) { + return true; + } + + const shouldDelete = autoConfirm ? true : await confirmProjectDeletion(); + if (!shouldDelete) { + logger.info('Project setup canceled. Exiting...'); + return false; + } + + deleteProjectDirectory('src'); + deleteProjectDirectory('dist'); + return true; +} + +async function resolveUserChoices(shouldPrompt: boolean, preselectedChoices?: UserChoices): Promise { + const rawChoices = shouldPrompt ? await promptUser() : preselectedChoices; + return assertUserChoices(rawChoices); +} + +async function scaffoldProject(choices: UserChoices): Promise { + await writeFile('_gulp/user-choices.json', JSON.stringify(choices, null, 2)); + + createProjectStructure(choices); + createProjectFiles(choices); + copyVendorCSS(choices); + generateGulpfile(choices); +} + +function startDevServer(): void { + const child = spawn('npm', ['start'], { + stdio: 'inherit', + shell: true, + }); + + child.on('error', (error) => { + logger.error(`Error starting development server: ${error.message}`); + }); +} + async function setup(): Promise { try { const parsedOptions = parseSetupOptions(process.argv.slice(2)); - const projectExists = fileExists('src') || fileExists('dist'); - if (projectExists) { - const shouldDelete = parsedOptions.autoConfirm ? true : await confirmProjectDeletion(); - if (!shouldDelete) { - logger.info('Project setup canceled. Exiting...'); - return; - } - deleteDirectory('src'); - deleteDirectory('dist'); + const shouldContinue = await prepareProjectDirectories(parsedOptions.autoConfirm); + if (!shouldContinue) { + return; } - const rawChoices = parsedOptions.shouldPrompt ? await promptUser() : parsedOptions.choices; - const choices: UserChoices = assertUserChoices(rawChoices); - - await writeFile('_gulp/user-choices.json', JSON.stringify(choices, null, 2)); - - createProjectStructure(choices); - createProjectFiles(choices); - copyVendorCSS(choices); - generateGulpfile(choices); + const choices = await resolveUserChoices(parsedOptions.shouldPrompt, parsedOptions.choices); + await scaffoldProject(choices); logger.success('Setup complete. Gulpfile has been generated.'); logger.info('Starting development server...'); - - exec('npm start', (error, stdout, stderr) => { - if (error) { - logger.error(`Error: ${error.message}`); - return; - } - if (stderr) { - logger.error(`Stderr: ${stderr}`); - return; - } - console.log(stdout); - }); + startDevServer(); } catch (error: unknown) { logger.error(`An error occurred during setup: ${(error as Error).message}`); } diff --git a/_gulp/templates/gulpfile/__snapshots__/js-sass-html.gulpfile.snap b/_gulp/templates/gulpfile/__snapshots__/js-sass-html.gulpfile.snap index 9e52313..827f126 100644 --- a/_gulp/templates/gulpfile/__snapshots__/js-sass-html.gulpfile.snap +++ b/_gulp/templates/gulpfile/__snapshots__/js-sass-html.gulpfile.snap @@ -14,10 +14,9 @@ const del = require('del'); const plumber = require('gulp-plumber'); const sourcemaps = require('gulp-sourcemaps'); const gulpif = require('gulp-if'); -const pug = null; -const tsify = null; - const production = process.env.NODE_ENV === 'production'; +const openBrowser = process.env.BROWSERSYNC_OPEN === 'true'; +const skipImageOptimization = process.env.SKIP_IMAGE_OPTIMIZATION === 'true'; async function clean() { await del(['dist']); @@ -66,7 +65,7 @@ function markup() { function images() { return src('src/img/**/*') - .pipe(imagemin()) + .pipe(gulpif(!skipImageOptimization, imagemin())) .pipe(dest('dist/img')); } @@ -75,7 +74,8 @@ function serve(cb) { server: { baseDir: './dist' }, - open: true + open: openBrowser, + notify: false }); cb(); } diff --git a/_gulp/templates/gulpfile/__snapshots__/js-sass-pug.gulpfile.snap b/_gulp/templates/gulpfile/__snapshots__/js-sass-pug.gulpfile.snap index 7b4bd8c..7d16f25 100644 --- a/_gulp/templates/gulpfile/__snapshots__/js-sass-pug.gulpfile.snap +++ b/_gulp/templates/gulpfile/__snapshots__/js-sass-pug.gulpfile.snap @@ -15,9 +15,9 @@ const plumber = require('gulp-plumber'); const sourcemaps = require('gulp-sourcemaps'); const gulpif = require('gulp-if'); const pug = require('gulp-pug'); -const tsify = null; - const production = process.env.NODE_ENV === 'production'; +const openBrowser = process.env.BROWSERSYNC_OPEN === 'true'; +const skipImageOptimization = process.env.SKIP_IMAGE_OPTIMIZATION === 'true'; async function clean() { await del(['dist']); @@ -67,7 +67,7 @@ function markup() { function images() { return src('src/img/**/*') - .pipe(imagemin()) + .pipe(gulpif(!skipImageOptimization, imagemin())) .pipe(dest('dist/img')); } @@ -76,7 +76,8 @@ function serve(cb) { server: { baseDir: './dist' }, - open: true + open: openBrowser, + notify: false }); cb(); } diff --git a/_gulp/templates/gulpfile/__snapshots__/js-scss-html.gulpfile.snap b/_gulp/templates/gulpfile/__snapshots__/js-scss-html.gulpfile.snap index 4e27b4b..047467b 100644 --- a/_gulp/templates/gulpfile/__snapshots__/js-scss-html.gulpfile.snap +++ b/_gulp/templates/gulpfile/__snapshots__/js-scss-html.gulpfile.snap @@ -14,10 +14,9 @@ const del = require('del'); const plumber = require('gulp-plumber'); const sourcemaps = require('gulp-sourcemaps'); const gulpif = require('gulp-if'); -const pug = null; -const tsify = null; - const production = process.env.NODE_ENV === 'production'; +const openBrowser = process.env.BROWSERSYNC_OPEN === 'true'; +const skipImageOptimization = process.env.SKIP_IMAGE_OPTIMIZATION === 'true'; async function clean() { await del(['dist']); @@ -66,7 +65,7 @@ function markup() { function images() { return src('src/img/**/*') - .pipe(imagemin()) + .pipe(gulpif(!skipImageOptimization, imagemin())) .pipe(dest('dist/img')); } @@ -75,7 +74,8 @@ function serve(cb) { server: { baseDir: './dist' }, - open: true + open: openBrowser, + notify: false }); cb(); } diff --git a/_gulp/templates/gulpfile/__snapshots__/js-scss-pug.gulpfile.snap b/_gulp/templates/gulpfile/__snapshots__/js-scss-pug.gulpfile.snap index 76627a6..4bec678 100644 --- a/_gulp/templates/gulpfile/__snapshots__/js-scss-pug.gulpfile.snap +++ b/_gulp/templates/gulpfile/__snapshots__/js-scss-pug.gulpfile.snap @@ -15,9 +15,9 @@ const plumber = require('gulp-plumber'); const sourcemaps = require('gulp-sourcemaps'); const gulpif = require('gulp-if'); const pug = require('gulp-pug'); -const tsify = null; - const production = process.env.NODE_ENV === 'production'; +const openBrowser = process.env.BROWSERSYNC_OPEN === 'true'; +const skipImageOptimization = process.env.SKIP_IMAGE_OPTIMIZATION === 'true'; async function clean() { await del(['dist']); @@ -67,7 +67,7 @@ function markup() { function images() { return src('src/img/**/*') - .pipe(imagemin()) + .pipe(gulpif(!skipImageOptimization, imagemin())) .pipe(dest('dist/img')); } @@ -76,7 +76,8 @@ function serve(cb) { server: { baseDir: './dist' }, - open: true + open: openBrowser, + notify: false }); cb(); } diff --git a/_gulp/templates/gulpfile/__snapshots__/ts-sass-html.gulpfile.snap b/_gulp/templates/gulpfile/__snapshots__/ts-sass-html.gulpfile.snap index 523e0f8..1c9deb6 100644 --- a/_gulp/templates/gulpfile/__snapshots__/ts-sass-html.gulpfile.snap +++ b/_gulp/templates/gulpfile/__snapshots__/ts-sass-html.gulpfile.snap @@ -14,10 +14,10 @@ const del = require('del'); const plumber = require('gulp-plumber'); const sourcemaps = require('gulp-sourcemaps'); const gulpif = require('gulp-if'); -const pug = null; const tsify = require('tsify'); - const production = process.env.NODE_ENV === 'production'; +const openBrowser = process.env.BROWSERSYNC_OPEN === 'true'; +const skipImageOptimization = process.env.SKIP_IMAGE_OPTIMIZATION === 'true'; async function clean() { await del(['dist']); @@ -66,7 +66,7 @@ function markup() { function images() { return src('src/img/**/*') - .pipe(imagemin()) + .pipe(gulpif(!skipImageOptimization, imagemin())) .pipe(dest('dist/img')); } @@ -75,7 +75,8 @@ function serve(cb) { server: { baseDir: './dist' }, - open: true + open: openBrowser, + notify: false }); cb(); } diff --git a/_gulp/templates/gulpfile/__snapshots__/ts-sass-pug.gulpfile.snap b/_gulp/templates/gulpfile/__snapshots__/ts-sass-pug.gulpfile.snap index 9a604bb..d960b11 100644 --- a/_gulp/templates/gulpfile/__snapshots__/ts-sass-pug.gulpfile.snap +++ b/_gulp/templates/gulpfile/__snapshots__/ts-sass-pug.gulpfile.snap @@ -16,8 +16,9 @@ const sourcemaps = require('gulp-sourcemaps'); const gulpif = require('gulp-if'); const pug = require('gulp-pug'); const tsify = require('tsify'); - const production = process.env.NODE_ENV === 'production'; +const openBrowser = process.env.BROWSERSYNC_OPEN === 'true'; +const skipImageOptimization = process.env.SKIP_IMAGE_OPTIMIZATION === 'true'; async function clean() { await del(['dist']); @@ -67,7 +68,7 @@ function markup() { function images() { return src('src/img/**/*') - .pipe(imagemin()) + .pipe(gulpif(!skipImageOptimization, imagemin())) .pipe(dest('dist/img')); } @@ -76,7 +77,8 @@ function serve(cb) { server: { baseDir: './dist' }, - open: true + open: openBrowser, + notify: false }); cb(); } diff --git a/_gulp/templates/gulpfile/__snapshots__/ts-scss-html.gulpfile.snap b/_gulp/templates/gulpfile/__snapshots__/ts-scss-html.gulpfile.snap index 3afd681..bb21aaf 100644 --- a/_gulp/templates/gulpfile/__snapshots__/ts-scss-html.gulpfile.snap +++ b/_gulp/templates/gulpfile/__snapshots__/ts-scss-html.gulpfile.snap @@ -14,10 +14,10 @@ const del = require('del'); const plumber = require('gulp-plumber'); const sourcemaps = require('gulp-sourcemaps'); const gulpif = require('gulp-if'); -const pug = null; const tsify = require('tsify'); - const production = process.env.NODE_ENV === 'production'; +const openBrowser = process.env.BROWSERSYNC_OPEN === 'true'; +const skipImageOptimization = process.env.SKIP_IMAGE_OPTIMIZATION === 'true'; async function clean() { await del(['dist']); @@ -66,7 +66,7 @@ function markup() { function images() { return src('src/img/**/*') - .pipe(imagemin()) + .pipe(gulpif(!skipImageOptimization, imagemin())) .pipe(dest('dist/img')); } @@ -75,7 +75,8 @@ function serve(cb) { server: { baseDir: './dist' }, - open: true + open: openBrowser, + notify: false }); cb(); } diff --git a/_gulp/templates/gulpfile/__snapshots__/ts-scss-pug.gulpfile.snap b/_gulp/templates/gulpfile/__snapshots__/ts-scss-pug.gulpfile.snap index a746896..0d39db1 100644 --- a/_gulp/templates/gulpfile/__snapshots__/ts-scss-pug.gulpfile.snap +++ b/_gulp/templates/gulpfile/__snapshots__/ts-scss-pug.gulpfile.snap @@ -16,8 +16,9 @@ const sourcemaps = require('gulp-sourcemaps'); const gulpif = require('gulp-if'); const pug = require('gulp-pug'); const tsify = require('tsify'); - const production = process.env.NODE_ENV === 'production'; +const openBrowser = process.env.BROWSERSYNC_OPEN === 'true'; +const skipImageOptimization = process.env.SKIP_IMAGE_OPTIMIZATION === 'true'; async function clean() { await del(['dist']); @@ -67,7 +68,7 @@ function markup() { function images() { return src('src/img/**/*') - .pipe(imagemin()) + .pipe(gulpif(!skipImageOptimization, imagemin())) .pipe(dest('dist/img')); } @@ -76,7 +77,8 @@ function serve(cb) { server: { baseDir: './dist' }, - open: true + open: openBrowser, + notify: false }); cb(); } diff --git a/_gulp/templates/gulpfile/sections.ts b/_gulp/templates/gulpfile/sections.ts index daf9975..63a5314 100644 --- a/_gulp/templates/gulpfile/sections.ts +++ b/_gulp/templates/gulpfile/sections.ts @@ -1,6 +1,9 @@ import { GulpTemplateContext } from './types'; export function importsSection({ choices }: GulpTemplateContext): string { + const pugImport = choices.markup === 'Pug' ? "const pug = require('gulp-pug');\n" : ''; + const tsifyImport = choices.script === 'TypeScript' ? "const tsify = require('tsify');\n" : ''; + return `const { src, dest, watch, series, parallel } = require('gulp'); const sass = require('gulp-sass')(require('sass')); const autoprefixer = require('gulp-autoprefixer'); @@ -17,10 +20,9 @@ const del = require('del'); const plumber = require('gulp-plumber'); const sourcemaps = require('gulp-sourcemaps'); const gulpif = require('gulp-if'); -const pug = ${choices.markup === 'Pug' ? "require('gulp-pug')" : 'null'}; -const tsify = ${choices.script === 'TypeScript' ? "require('tsify')" : 'null'}; - -const production = process.env.NODE_ENV === 'production';`; +${pugImport}${tsifyImport}const production = process.env.NODE_ENV === 'production'; +const openBrowser = process.env.BROWSERSYNC_OPEN === 'true'; +const skipImageOptimization = process.env.SKIP_IMAGE_OPTIMIZATION === 'true';`; } export function cleanSection(): string { @@ -81,7 +83,7 @@ export function markupSection({ choices, markupFolder, markupExtension }: GulpTe export function imagesSection(): string { return `function images() { return src('src/img/**/*') - .pipe(imagemin()) + .pipe(gulpif(!skipImageOptimization, imagemin())) .pipe(dest('dist/img')); }`; } @@ -92,7 +94,8 @@ export function devServerSection({ styleGlob, scriptGlob, markupGlob, imageGlob server: { baseDir: './dist' }, - open: true + open: openBrowser, + notify: false }); cb(); } diff --git a/_gulp/utils/fileSystem.test.ts b/_gulp/utils/fileSystem.test.ts index ffd4af6..2ef1172 100644 --- a/_gulp/utils/fileSystem.test.ts +++ b/_gulp/utils/fileSystem.test.ts @@ -7,6 +7,7 @@ import { copyFile, createDirectory, deleteDirectory, + deleteProjectDirectory, fileExists, writeFile, } from './fileSystem'; @@ -47,3 +48,17 @@ test('copyFile copies source content into destination path', () => { deleteDirectory(tmpRoot); }); + +test('deleteProjectDirectory only allows src/dist under the provided root', () => { + const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'wlk-safe-delete-')); + const srcDir = path.join(tmpRoot, 'src'); + createDirectory(srcDir); + + deleteProjectDirectory('src', tmpRoot); + assert.equal(fileExists(srcDir), false); + + assert.throws(() => deleteProjectDirectory('node_modules', tmpRoot), /Unsafe delete target/); + assert.throws(() => deleteProjectDirectory('../src', tmpRoot), /Unsafe delete target/); + + deleteDirectory(tmpRoot); +}); diff --git a/_gulp/utils/fileSystem.ts b/_gulp/utils/fileSystem.ts index c696a42..f96420b 100644 --- a/_gulp/utils/fileSystem.ts +++ b/_gulp/utils/fileSystem.ts @@ -23,4 +23,30 @@ export function deleteDirectory(dir: string): void { if (fs.existsSync(dir)) { fs.rmSync(dir, { recursive: true, force: true }); } -} \ No newline at end of file +} + +function assertSafeProjectDeletePath(dir: string, cwd: string): string { + const allowedDirs = new Set(['src', 'dist']); + const resolvedCwd = path.resolve(cwd); + const resolvedTarget = path.resolve(cwd, dir); + const targetBasename = path.basename(resolvedTarget); + + if (!allowedDirs.has(targetBasename)) { + throw new Error(`Unsafe delete target: ${dir}. Only src/dist can be deleted.`); + } + + const relative = path.relative(resolvedCwd, resolvedTarget); + const escapesProjectRoot = relative.startsWith('..') || path.isAbsolute(relative); + if (escapesProjectRoot) { + throw new Error(`Unsafe delete target outside project root: ${dir}`); + } + + return resolvedTarget; +} + +export function deleteProjectDirectory(dir: string, cwd: string = process.cwd()): void { + const safeTarget = assertSafeProjectDeletePath(dir, cwd); + if (fs.existsSync(safeTarget)) { + fs.rmSync(safeTarget, { recursive: true, force: true }); + } +}