diff --git a/packages/create/src/index.ts b/packages/create/src/index.ts index dadf851..ae7fd98 100644 --- a/packages/create/src/index.ts +++ b/packages/create/src/index.ts @@ -160,14 +160,23 @@ export const createSolid = (version: string) => } const isV2 = useV2 === "v2"; - // Don't offer javascript if `projectType` is library - useJS ??= projectType === "library" ? false : !(await cancelable(p.confirm({ message: "Use Typescript?" }))); - - if (!projectType) return; const group = projectType === "library" ? undefined : resolveGroup(manifest, groupKeyFor(projectType, isV2)); const template_opts: ManifestTemplate[] = group ? group.templates : LIBRARY_TEMPLATES.map((name) => ({ name })); + + // TypeScript-only templates (manifest `tsOnly`, e.g. with-tsrx): the JS conversion + // would delete files the template depends on (tsconfig.json carries its editor and + // compiler wiring), so never offer or perform it + const tsOnlyNotice = (name: string) => + p.log.info(`The ${name} template is TypeScript-only — continuing with TypeScript`); + if (template && template_opts.find((t) => t.name === template)?.tsOnly) { + if (useJS) tsOnlyNotice(template); + useJS = false; + } + + // Don't offer javascript if `projectType` is library + useJS ??= projectType === "library" ? false : !(await cancelable(p.confirm({ message: "Use Typescript?" }))); const availableTemplates = template_opts.filter((t) => (useJS ? t : !t.name.startsWith("js"))); // clack's autocomplete always focuses options[0] when the search box is empty (it only // honors `initialValue` for multi-select), so the manifest's `default`-flagged template @@ -192,6 +201,13 @@ export const createSolid = (version: string) => if (!template) return; const chosenTemplate = template_opts.find((t) => t.name === template); + // A tsOnly template picked from the interactive list after the user chose + // JavaScript: tell them and continue as TypeScript + if (chosenTemplate?.tsOnly && useJS) { + tsOnlyNotice(template); + useJS = false; + } + // SSR flip: only offered on Solid 2.0 templates that support it (e.g. "basic") let enableSSR = false; if (projectType === "solid" && chosenTemplate?.ssrToggle) { diff --git a/packages/create/src/utils/constants.ts b/packages/create/src/utils/constants.ts index 6a0ecb6..614a29b 100644 --- a/packages/create/src/utils/constants.ts +++ b/packages/create/src/utils/constants.ts @@ -124,6 +124,7 @@ export const SOLID_V2_TEMPLATES = [ "with-sass", "with-tailwindcss", "with-tanstack-router", + "with-tsrx", "with-unocss", "with-vitest-browser-mode", ] as const satisfies string[]; diff --git a/packages/create/src/utils/manifest.ts b/packages/create/src/utils/manifest.ts index 6855e68..7f2dc6d 100644 --- a/packages/create/src/utils/manifest.ts +++ b/packages/create/src/utils/manifest.ts @@ -18,6 +18,11 @@ export type ManifestTemplate = { default?: boolean; /** Offer the "Enable server-side rendering?" prompt for this template */ ssrToggle?: boolean; + /** + * Template only works as TypeScript (e.g. with-tsrx, whose tsconfig.json carries + * its editor/compiler wiring) — never offer or perform the JS conversion + */ + tsOnly?: boolean; }; export type ManifestGroup = { @@ -39,11 +44,12 @@ export type ManifestGroupKey = "solid" | "start-v2" | "start-v1" | "vanilla"; export const MANIFEST_URL = "https://raw.githubusercontent.com/solidjs/templates/HEAD/templates.json"; const MANIFEST_TIMEOUT_MS = 2000; -const asTemplates = (names: readonly string[], defaultName?: string, ssrToggle?: string): ManifestTemplate[] => +const asTemplates = (names: readonly string[], defaultName?: string, ssrToggle?: string, tsOnly?: string): ManifestTemplate[] => names.map((name) => ({ name, ...(name === defaultName ? { default: true } : {}), ...(name === ssrToggle ? { ssrToggle: true } : {}), + ...(name === tsOnly ? { tsOnly: true } : {}), })); /** Baked-in fallback, used whenever the manifest can't be fetched or parsed */ @@ -51,7 +57,7 @@ export const BAKED_GROUPS: Record = { "solid": { label: "Solid 2.0", path: "solid-v2", - templates: asTemplates(SOLID_V2_TEMPLATES, "basic", "basic"), + templates: asTemplates(SOLID_V2_TEMPLATES, "basic", "basic", "with-tsrx"), }, "start-v2": { label: "SolidStart 2", diff --git a/packages/create/tests/manifest.test.ts b/packages/create/tests/manifest.test.ts index ae78e1b..acb2ce8 100644 --- a/packages/create/tests/manifest.test.ts +++ b/packages/create/tests/manifest.test.ts @@ -40,6 +40,14 @@ it("drops malformed groups and template entries but keeps the rest", () => { expect(manifest!.groups["solid"].templates).toEqual([{ name: "basic" }]); }); +it("preserves per-template flags like tsOnly", () => { + const manifest = parseManifest({ + version: 1, + groups: { solid: { path: "solid-v2", templates: [{ name: "with-tsrx", tsOnly: true }] } }, + }); + expect(manifest!.groups["solid"].templates).toEqual([{ name: "with-tsrx", tsOnly: true }]); +}); + it("resolves groups from the manifest, falling back to baked-in lists", () => { const manifest = parseManifest(validManifest); expect(resolveGroup(manifest, "solid").templates.map((t) => t.name)).toEqual(["basic", "bare"]); @@ -59,12 +67,15 @@ it("resolves groups from the manifest, falling back to baked-in lists", () => { "with-sass", "with-tailwindcss", "with-tanstack-router", + "with-tsrx", "with-unocss", "with-vitest-browser-mode", ]); const basic = baked.templates.find((t) => t.name === "basic")!; expect(basic.default).toBe(true); expect(basic.ssrToggle).toBe(true); + // with-tsrx must never be offered as JavaScript (mirrors the manifest's tsOnly flag) + expect(baked.templates.find((t) => t.name === "with-tsrx")!.tsOnly).toBe(true); expect(resolveGroup(undefined, "start-v2").path).toBe("solid-start-v2"); expect(resolveGroup(undefined, "start-v1").path).toBe("solid-start-v1"); }); diff --git a/packages/create/tests/tsonly.test.ts b/packages/create/tests/tsonly.test.ts new file mode 100644 index 0000000..ee26852 --- /dev/null +++ b/packages/create/tests/tsonly.test.ts @@ -0,0 +1,108 @@ +import { runCommand } from "citty"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { mkdirSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import type { CreateSolidV2Args } from "../src/create-solid-v2"; + +const { autocomplete, confirm, logInfo, createSolidV2 } = vi.hoisted(() => ({ + autocomplete: vi.fn(), + confirm: vi.fn(), + logInfo: vi.fn(), + createSolidV2: vi.fn(), +})); + +vi.mock("@clack/prompts", async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + autocomplete, + confirm, + log: { ...original.log, info: logInfo }, + }; +}); + +// The scaffold itself (network download + conversion) is covered by template.test.ts; +// here only the prompt flow and the transpile flag passed down are under test. +vi.mock("../src/create-solid-v2", async (importOriginal) => ({ + ...(await importOriginal()), + createSolidV2, +})); + +import { createSolid } from "../src"; + +const destinations: string[] = []; +const scratch = (name: string) => { + const destination = join("test", name); + rmSync(destination, { recursive: true, force: true }); + destinations.push(destination); + return destination; +}; + +beforeEach(() => { + // Unroutable manifest URL: falls back to the baked-in lists, so these tests + // also assert the baked with-tsrx entry carries the tsOnly flag + process.env.SOLID_CLI_TEMPLATES_MANIFEST_URL = "http://127.0.0.1:1/templates.json"; + // The command writes .gitignore into the destination after scaffolding + createSolidV2.mockImplementation(async ({ destination }: CreateSolidV2Args) => + mkdirSync(destination, { recursive: true }), + ); +}); + +afterEach(() => { + delete process.env.SOLID_CLI_TEMPLATES_MANIFEST_URL; + vi.clearAllMocks(); + for (const destination of destinations.splice(0)) rmSync(destination, { recursive: true, force: true }); +}); + +const transpileArg = () => createSolidV2.mock.calls[0][1]; +const noticeCalls = () => logInfo.mock.calls.filter(([message]) => /TypeScript-only/.test(String(message))); + +it("skips the TypeScript prompt when a tsOnly template is passed as an argument", async () => { + const destination = scratch("tsonly-arg"); + + await runCommand(createSolid("test"), { rawArgs: [destination, "--solid", "-t", "with-tsrx"] }); + + expect(confirm).not.toHaveBeenCalled(); + expect(createSolidV2).toHaveBeenCalledOnce(); + expect(transpileArg()).toBeFalsy(); +}); + +it("prints a notice and scaffolds TypeScript when --js is passed for a tsOnly template", async () => { + const destination = scratch("tsonly-js-flag"); + + await runCommand(createSolid("test"), { rawArgs: [destination, "--solid", "-t", "with-tsrx", "--js"] }); + + expect(confirm).not.toHaveBeenCalled(); + expect(noticeCalls()).toHaveLength(1); + expect(noticeCalls()[0][0]).toContain("with-tsrx"); + expect(transpileArg()).toBeFalsy(); +}); + +it("forces TypeScript when a tsOnly template is picked interactively after choosing JavaScript", async () => { + const destination = scratch("tsonly-interactive"); + autocomplete.mockResolvedValueOnce("with-tsrx"); + + await runCommand(createSolid("test"), { rawArgs: [destination, "--solid", "--js"] }); + + expect(noticeCalls()).toHaveLength(1); + expect(transpileArg()).toBeFalsy(); +}); + +it("leaves non-tsOnly templates unaffected: --js still converts and no notice is printed", async () => { + const destination = scratch("tsonly-unaffected-js"); + + await runCommand(createSolid("test"), { rawArgs: [destination, "--solid", "-t", "bare", "--js"] }); + + expect(noticeCalls()).toHaveLength(0); + expect(transpileArg()).toBe(true); +}); + +it("leaves non-tsOnly templates unaffected: the TypeScript prompt is still offered", async () => { + const destination = scratch("tsonly-unaffected-prompt"); + confirm.mockResolvedValueOnce(true); // "Use Typescript?" -> yes + + await runCommand(createSolid("test"), { rawArgs: [destination, "--solid", "-t", "bare"] }); + + expect(confirm).toHaveBeenCalledWith({ message: "Use Typescript?" }); + expect(transpileArg()).toBeFalsy(); +});