Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 20 additions & 4 deletions packages/create/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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) {
Expand Down
1 change: 1 addition & 0 deletions packages/create/src/utils/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand Down
10 changes: 8 additions & 2 deletions packages/create/src/utils/manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -39,19 +44,20 @@ 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 */
export const BAKED_GROUPS: Record<ManifestGroupKey, ManifestGroup> = {
"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",
Expand Down
11 changes: 11 additions & 0 deletions packages/create/tests/manifest.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"]);
Expand All @@ -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");
});
Expand Down
108 changes: 108 additions & 0 deletions packages/create/tests/tsonly.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof import("@clack/prompts")>();
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<typeof import("../src/create-solid-v2")>()),
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();
});
Loading