diff --git a/libs/create-qwikdev-astro/README.md b/libs/create-qwikdev-astro/README.md index 1c98ef2..8c73195 100644 --- a/libs/create-qwikdev-astro/README.md +++ b/libs/create-qwikdev-astro/README.md @@ -43,16 +43,17 @@ | Name | Description | | :--------------------------------------| :----------------------------------------| - | `--help` (`-h`) | Display available flags. | - | `--dry-run` | Walk through steps without executing. | - | `--force` / `--no-force` (`-f` / `--no-f`) | Overwrite target directory if it exists. | - | `--add` / `--no-add` (`-a` / `--no-a`) | Add QwikDev/astro to existing project. | - | `--install` / `--no-install` (`-i` / `--no-i`) | Install dependencies. | + | `--help` (`-h`) | Display available flags. | + | `--template` (`-t`) | Start from an Astro template. | | `--biome` / `--no-biome` | Prefer Biome to ESLint/Prettier. | + | `--install` / `--no-install` (`-i` / `--no-i`) | Install dependencies. | | `--git` / `--no-git` | Initialize Git repository. | | `--ci` / `--no-ci` | Add CI workflow. | | `--yes` (`-y`) | Skip all prompts by accepting defaults. | | `--no` (`-n`) | Skip all prompts by declining defaults. | + | `--add` / `--no-add` (`-a` / `--no-a`) | Add QwikDev/astro to existing project. | + | `--force` / `--no-force` (`-f` / `--no-f`) | Overwrite target directory if it exists. | + | `--dry-run` | Walk through steps without executing. | ### 📦 API @@ -78,14 +79,15 @@ export type Definition = { destination: string; adapter?: "deno" | "node" | "none"; - force?: boolean; - add?: boolean; - install?: boolean; + template?: string; biome?: boolean; + install?: boolean; git?: boolean; ci?: boolean; yes?: boolean; no?: boolean; + add?: boolean; + force?: boolean; dryRun?: boolean; }; ``` @@ -96,14 +98,15 @@ export const defaultDefinition = { destination: "./qwik-astro-app", adapter: "none", - force: undefined, - add: undefined, + template: "", install: undefined, biome: undefined, git: undefined, ci: undefined, yes: undefined, no: undefined, + add: undefined, + force: undefined, dryRun: undefined } as const; ``` diff --git a/libs/create-qwikdev-astro/src/app.ts b/libs/create-qwikdev-astro/src/app.ts index e5b7551..9318496 100644 --- a/libs/create-qwikdev-astro/src/app.ts +++ b/libs/create-qwikdev-astro/src/app.ts @@ -1,13 +1,14 @@ import fs, { cpSync } from "node:fs"; import path from "node:path"; -import { copySync, ensureDirSync } from "fs-extra/esm"; +import { copySync, ensureDirSync, pathExistsSync } from "fs-extra/esm"; import pkg from "../package.json"; import { ensureString } from "./console"; import { type Definition as BaseDefinition, Program } from "./core"; -import { $, $pmInstall, $pmX } from "./process"; +import { $, $pmCreate, $pmInstall, $pmX } from "./process"; import { __dirname, clearDir, + deepMergeJsonFile, getPackageJson, getPackageManager, notEmptyDir, @@ -22,12 +23,13 @@ import { export type Definition = BaseDefinition & { destination: string; adapter: Adapter; - force?: boolean; + template?: string; install?: boolean; biome?: boolean; git?: boolean; ci?: boolean; add?: boolean; + force?: boolean; dryRun?: boolean; }; @@ -37,15 +39,16 @@ export type UserDefinition = Partial; export const defaultDefinition = { destination: "./qwik-astro-app", adapter: "none", - force: undefined, - install: undefined, + template: undefined, biome: undefined, + install: undefined, git: undefined, ci: undefined, yes: undefined, no: undefined, - dryRun: undefined, - add: undefined + add: undefined, + force: undefined, + dryRun: undefined } as const; export type Adapter = "node" | "deno" | "none"; @@ -84,6 +87,12 @@ export class Application extends Program { desc: "Server adapter", choices: ["deno", "node", "none"] }) + .argument("template", { + alias: "t", + type: "string", + default: defaultDefinition.template, + desc: "Start from an Astro template" + }) .option("add", { alias: "a", type: "boolean", @@ -148,6 +157,7 @@ export class Application extends Program { return { destination: definition.destination, adapter: definition.adapter, + template: definition.template ?? "", force: definition.force ?? (definition.add ? false : !!definition.yes && !definition.no), add: !!definition.add, @@ -166,7 +176,7 @@ export class Application extends Program { definition.destination === defaultDefinition.destination ? await this.scanString( `Where would you like to create your new project? ${this.gray( - `(Use '.' or 'qwik-astro-app' for current directory)` + `(Use '.' for current directory)` )}`, definition.destination ) @@ -176,14 +186,13 @@ export class Application extends Program { const outDir = resolveAbsoluteDir(destination.trim()); const exists = notEmptyDir(outDir); - const add = - definition.add === undefined - ? exists && - !!(await this.scanBoolean( - definition, - "Do you want to add @QwikDev/astro to your existing project?" - )) - : definition.add; + const add = !!(definition.add === undefined && !definition.force + ? exists && + (await this.scanBoolean( + definition, + "Do you want to add @QwikDev/astro to your existing project?" + )) + : definition.add); const force = definition.force === undefined @@ -196,13 +205,24 @@ export class Application extends Program { )}" already exists and is not empty. What would you like to overwrite it?`, false )) - : false; + : definition.force; + + const template: string = + definition.template === undefined && + (await this.scanBoolean(definition, "Would you like to use a template?")) + ? await this.scanString("What template would you like to use?", "") + : (definition.template ?? ""); const ask = !exists || add || force; let adapter: Adapter; - if (ask && (!add || force) && definition.adapter === defaultDefinition.adapter) { + if ( + !template && + ask && + (!add || force) && + definition.adapter === defaultDefinition.adapter + ) { const adapterInput = ((await this.scanBoolean( definition, @@ -270,6 +290,7 @@ export class Application extends Program { return { destination, adapter, + template, biome, ci, install, @@ -305,7 +326,7 @@ export class Application extends Program { } } - async runCreate(input: Input) { + async prepareDir(input: Input) { const outDir = input.outDir; if (notEmptyDir(outDir)) { @@ -323,6 +344,12 @@ export class Application extends Program { process.exit(1); } } + } + + async runCreate(input: Input) { + const outDir = input.outDir; + + await this.prepareDir(input); let starterKit = input.adapter; @@ -337,12 +364,83 @@ export class Application extends Program { this.copyGitignore(input); } + async runTemplate(input: Input) { + const args = [ + "astro", + input.destination, + "--", + "--skip-houston", + "--template", + input.template, + "--add", + "@qwikdev/astro", + input.install ? "--install" : "--no-install", + input.git ? "--git" : "--no-git" + ]; + + if (input.dryRun) { + args.push("--dry-run"); + } + + await this.prepareDir(input); + await $pmCreate(args.join(" "), process.cwd()); + + const outDir = input.outDir; + const stubPath = path.join( + __dirname, + "..", + "stubs", + "templates", + `none${input.biome ? "-biome" : ""}` + ); + + const configFiles = input.biome + ? ["biome.json"] + : [".eslintignore", ".eslintrc.cjs", ".prettierignore", "prettier.config.cjs"]; + + const vscodeDir = path.join(stubPath, ".vscode"); + const vscodeFiles = ["extensions.json", "launch.json"]; + + const projectPackageJsonFile = path.join(outDir, "package.json"); + const projectTsconfigJsonFile = path.join(outDir, "tsconfig.json"); + const templatePackageJsonFile = path.join(stubPath, "package.json"); + const templateTsconfigJsonFile = path.join(stubPath, "tsconfig.json"); + + this.step(`Copying template files into ${this.bgBlue(` ${outDir} `)} ... 🐇`); + + for (const vscodeFile of vscodeFiles) { + const vscodeFilePath = path.join(vscodeDir, vscodeFile); + const projectVscodeFilePath = path.join(outDir, ".vscode", vscodeFile); + + pathExistsSync(projectVscodeFilePath) + ? deepMergeJsonFile(projectVscodeFilePath, vscodeFilePath, true) + : cpSync(vscodeFilePath, projectVscodeFilePath, { + force: true + }); + } + + for (const configFile of configFiles) { + cpSync(path.join(stubPath, configFile), path.join(outDir, configFile), { + force: true + }); + } + + deepMergeJsonFile(projectPackageJsonFile, templatePackageJsonFile, true); + deepMergeJsonFile(projectTsconfigJsonFile, templateTsconfigJsonFile, true); + + return input.install; + } + async start(input: Input): Promise { - this.intro(`Let's create a ${this.bgBlue(" QwikDev/astro App ")} ✨`); + this.intro( + `Let's create a ${this.bgBlue(" QwikDev")}${this.bgMagenta("Astro")} App ✨` + ); let ranInstall: boolean; - if (input.add) { + if (input.template) { + ranInstall = await this.runTemplate(input); + } else if (input.add) { ranInstall = await this.runInstall(input); await this.runAdd(input); } else { @@ -445,10 +543,10 @@ export class Application extends Program { try { if (!input.dryRun) { const res = []; - res.push(await $("git", ["init"], outDir).install); - res.push(await $("git", ["add", "-A"], outDir).install); + res.push(await $("git", ["init"], outDir).process); + res.push(await $("git", ["add", "-A"], outDir).process); res.push( - await $("git", ["commit", "-m", "Initial commit 🎉"], outDir).install + await $("git", ["commit", "-m", "Initial commit 🎉"], outDir).process ); if (res.some((r) => r === false)) { diff --git a/libs/create-qwikdev-astro/src/process.ts b/libs/create-qwikdev-astro/src/process.ts index 6550d0f..8e3daa5 100644 --- a/libs/create-qwikdev-astro/src/process.ts +++ b/libs/create-qwikdev-astro/src/process.ts @@ -14,7 +14,7 @@ export const isPackageManagerInstalled = (packageManager: string) => { export function $(cmd: string, args: string[], cwd: string) { let child: ChildProcess; - const install = new Promise((resolve) => { + const process = new Promise((resolve) => { try { child = spawn(cmd, args, { cwd, @@ -42,7 +42,7 @@ export function $(cmd: string, args: string[], cwd: string) { } }; - return { abort, install }; + return { abort, process }; } export const $pm = async ( @@ -52,7 +52,15 @@ export const $pm = async ( ) => { const packageManager = getPackageManager(); args = Array.isArray(args) ? args : [args]; - if (["exec", "dlx"].includes(args[0])) { + + if (args[0] === "create" && packageManager === "deno") { + const packageName = args[1]; + const parts = packageName.split("/", 2); + const createCommand = parts[1] + ? `npm:${parts[0]}/create-${parts[1]}` + : `npm:create-${parts[0]}`; + args = ["run", "-A", createCommand, ...args.slice(2)]; + } else if (["exec", "dlx"].includes(args[0])) { switch (packageManager) { case "pnpm": case "yarn": @@ -97,6 +105,10 @@ export const $pmRun = async (script: string, cwd: string) => { await $pm(["run", ...script.split(/\s+/)], cwd); }; +export const $pmCreate = async (script: string, cwd: string) => { + await $pm(["create", ...script.split(/\s+/)], cwd); +}; + export const $pmExec = async (command: string, cwd: string) => { await $pm(["exec", ...command.split(/\s+/)], cwd); }; diff --git a/libs/create-qwikdev-astro/src/utils.ts b/libs/create-qwikdev-astro/src/utils.ts index 3383e34..c39d9be 100644 --- a/libs/create-qwikdev-astro/src/utils.ts +++ b/libs/create-qwikdev-astro/src/utils.ts @@ -7,6 +7,47 @@ import detectPackageManager from "which-pm-runs"; export const __filename = getModuleFilename(); export const __dirname = path.dirname(__filename); +export function deepMergeJsonFile( + targetJsonPath: string, + sourceJsonPath: string, + replace = false +): T { + const deepMerge = deepMergeJson( + fileGetContents(targetJsonPath), + fileGetContents(sourceJsonPath) + ); + + if (replace) { + putJson(targetJsonPath, deepMerge); + } + + return deepMerge; +} + +export function deepMergeJson(targetJson: string, sourceJson: string): T { + return deepMerge(JSON.parse(targetJson), JSON.parse(sourceJson)) as unknown as T; +} + +export function deepMerge(target: T, source: Partial): T { + for (const key of Object.keys(source) as (keyof T)[]) { + const targetValue = target[key]; + const sourceValue = source[key] as Partial; + + if (isObject(targetValue) && isObject(sourceValue)) { + target[key] = deepMerge(targetValue, sourceValue); + } else if (Array.isArray(targetValue) && Array.isArray(sourceValue)) { + target[key] = Array.from(new Set([...targetValue, ...sourceValue])) as any; + } else { + target[key] = sourceValue as T[keyof T]; + } + } + return target; +} + +function isObject(item: unknown): item is Record { + return item !== null && typeof item === "object" && !Array.isArray(item); +} + export function getModuleFilename(): string { const error = new Error(); const stack = error.stack; @@ -140,7 +181,11 @@ export function getPackageJson(dir: string): Record { } export function setPackageJson(dir: string, json: Record) { - filePutContents(getPackageJsonPath(dir), JSON.stringify(json, null, 2)); + putJson(getPackageJsonPath(dir), json); +} + +export function putJson(path: string, json: T) { + filePutContents(path, JSON.stringify(json, null, 2)); } export function updatePackageName(newName: string, dir = __dirname): void { diff --git a/libs/create-qwikdev-astro/tests/api.spec.ts b/libs/create-qwikdev-astro/tests/api.spec.ts index 621f295..d1ada1f 100644 --- a/libs/create-qwikdev-astro/tests/api.spec.ts +++ b/libs/create-qwikdev-astro/tests/api.spec.ts @@ -14,6 +14,8 @@ enum input { which_destination, use_adapter, which_adapter, + use_template, + what_template, biome, install, ci, @@ -27,6 +29,8 @@ const questions = { [input.which_destination]: "Where would you like to create your new project?", [input.use_adapter]: "Would you like to use a server adapter?", [input.which_adapter]: "Which adapter do you prefer?", + [input.use_template]: "Would you like to use a template?", + [input.what_template]: "What template would you like to use?", [input.biome]: "Would you prefer Biome over ESLint/Prettier?", [input.install]: `Would you like to install ${getPackageManager()} dependencies?`, [input.ci]: "Would you like to add CI workflow?", @@ -39,6 +43,8 @@ const questions = { const answers = { [input.which_destination]: [".", projectName], [input.use_adapter]: [true, false], + [input.what_template]: ["qwik", "astro"], + [input.use_template]: [true, false], [input.which_adapter]: ["none", "node", "deno"], [input.biome]: [true, false], [input.install]: [true, false], @@ -64,6 +70,7 @@ test.group("default definition", () => { definition.has( "destination", "adapter", + "template", "force", "install", "biome", @@ -82,9 +89,15 @@ test.group("default definition", () => { test("adapter", ({ assert }) => { assert.isTrue(definition.get("adapter").isString()); + assert.isTrue(definition.get("adapter").equals("none")); assert.isTrue(definition.get("adapter").equals(defaultDefinition.adapter)); }); + test("template", ({ assert }) => { + assert.isTrue(definition.get("template").isUndefined()); + assert.isTrue(definition.get("template").equals(defaultDefinition.template)); + }); + test("force", ({ assert }) => { assert.isTrue(definition.get("force").isUndefined()); assert.isTrue(definition.get("force").equals(defaultDefinition.force)); @@ -142,6 +155,15 @@ test.group("arguments", () => { assert.isTrue(definition.get("adapter").isString()); assert.isTrue(definition.get("adapter").equals("deno")); }); + + test("template argument", ({ assert }) => { + let definition = tester.parse([projectName, "--template", "minimal"]); + assert.isTrue(definition.get("template").isString()); + assert.isTrue(definition.get("template").equals("minimal")); + + definition = tester.parse(["my-qwik-astro-app", "--template", "blog"]); + assert.isTrue(definition.get("template").equals("blog")); + }); }); test.group("options", () => { @@ -345,6 +367,16 @@ for (const [key, choices] of Object.entries(answers)) { } break; + case input.what_template: + if ( + ( + await tester.scanBoolean(parsed.definition, questions[input.use_template]) + ).isTrue() + ) { + assert.isTrue(definition.get("template").equals(answer)); + } + break; + case input.biome: assert.isTrue(definition.get("biome").equals(answer)); break; diff --git a/libs/create-qwikdev-astro/tests/cli.spec.ts b/libs/create-qwikdev-astro/tests/cli.spec.ts index 1586046..0a3628c 100644 --- a/libs/create-qwikdev-astro/tests/cli.spec.ts +++ b/libs/create-qwikdev-astro/tests/cli.spec.ts @@ -18,10 +18,18 @@ const setup = () => { return () => emptyDirSync(root); }; +const templateDirs = [".vscode", "src"]; + +const templateFiles = [ + ".vscode/launch.json", + ".vscode/extensions.json", + "package.json", + "tsconfig.json" +]; + const generatedDirs = [ - ".vscode", + ...templateDirs, "public", - "src", "src/assets", "src/components", "src/layouts", @@ -30,8 +38,7 @@ const generatedDirs = [ ]; const generatedFiles = [ - ".vscode/extensions.json", - ".vscode/launch.json", + ...templateFiles, "public/favicon.svg", "src/assets/astro.svg", "src/assets/qwik.svg", @@ -43,12 +50,11 @@ const generatedFiles = [ "src/env.d.ts", ".gitignore", "README.md", - "astro.config.ts", - "package.json", - "tsconfig.json" + "astro.config.ts" ] as const; type GeneratedOptions = Partial<{ + template: boolean; biome: boolean; install: boolean; ci: boolean; @@ -57,7 +63,7 @@ type GeneratedOptions = Partial<{ const getGeneratedFiles = (options: GeneratedOptions = {}): string[] => { const files = [ - ...generatedFiles, + ...(options.template ? templateFiles : generatedFiles), ...(options.biome ? ["biome.json"] : [".eslintignore", ".eslintrc.cjs", ".prettierignore", "prettier.config.cjs"]) @@ -85,7 +91,7 @@ const getGeneratedFiles = (options: GeneratedOptions = {}): string[] => { }; const getGeneratedDirs = (options: GeneratedOptions = {}): string[] => { - const dirs = generatedDirs; + const dirs = options.template ? templateDirs : generatedDirs; /* if (options.install) { @@ -136,6 +142,19 @@ test.group(`create ${integration} app`, (group) => { biome: true }); }); + + test("with template", async (context) => { + return testRun(["--template", "minimal"], context, { + template: true + }); + }).disableTimeout(); + + test("with template and using Biome", async (context) => { + return testRun(["--template", "minimal", "--biome"], context, { + template: true, + biome: true + }); + }).disableTimeout(); }); test.group(`create ${integration} with yes and no options`, (group) => {