From 7e6f76d67561bda679039037d8494569ab62f9dd Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Tue, 11 Aug 2026 14:25:00 -0700 Subject: [PATCH 1/3] feat: Solid 2.0 template support - New "Solid 2.0" project type (--solid) scaffolding the solid-v2/* templates; listed first but not preselected while core is in beta - Optional streaming-SSR flip (--ssr / prompt, default No) on templates that support it: ssr: true in vite.config.ts, the generic production server.js (verbatim from solid-v2/fullstack), and a matching start script - JS variants via the existing sucrase conversion, adapted for turnkey templates (no index.html rewrite, .ts refs in vite.config retargeted, .d.ts dropped, minimal jsconfig) - templates.json manifest read from the templates repo HEAD (2s timeout, silent fallback to the baked-in lists) now drives template names, subdir paths and per-template flags, so template additions/reorganizations no longer require a CLI release - Template downloads can be pinned to a templates-repo ref per release (TEMPLATES_REF, SOLID_CLI_TEMPLATES_REF override) Co-authored-by: Cursor --- .changeset/solid-v2-templates.md | 12 ++ packages/create/src/create-solid-v2.ts | 49 +++++++ packages/create/src/create-start.ts | 22 ++- packages/create/src/create-vanilla.ts | 17 ++- packages/create/src/index.ts | 84 ++++++++--- packages/create/src/utils/constants.ts | 44 +++++- packages/create/src/utils/download.ts | 19 +++ packages/create/src/utils/manifest.ts | 119 ++++++++++++++++ packages/create/src/utils/ssr-flip.ts | 133 ++++++++++++++++++ packages/create/src/utils/ts-conversion.ts | 8 +- .../fixtures/solid-v2-basic/package.json | 30 ++++ .../fixtures/solid-v2-basic/vite.config.ts | 32 +++++ packages/create/tests/manifest.test.ts | 80 +++++++++++ packages/create/tests/ssr-flip.test.ts | 48 +++++++ packages/create/tests/template.test.ts | 9 +- scripts/gen-ssr-flip-server.mjs | 15 ++ vitest.config.ts | 7 +- 17 files changed, 677 insertions(+), 51 deletions(-) create mode 100644 .changeset/solid-v2-templates.md create mode 100644 packages/create/src/create-solid-v2.ts create mode 100644 packages/create/src/utils/download.ts create mode 100644 packages/create/src/utils/manifest.ts create mode 100644 packages/create/src/utils/ssr-flip.ts create mode 100644 packages/create/tests/fixtures/solid-v2-basic/package.json create mode 100644 packages/create/tests/fixtures/solid-v2-basic/vite.config.ts create mode 100644 packages/create/tests/manifest.test.ts create mode 100644 packages/create/tests/ssr-flip.test.ts create mode 100644 scripts/gen-ssr-flip-server.mjs diff --git a/.changeset/solid-v2-templates.md b/.changeset/solid-v2-templates.md new file mode 100644 index 0000000..c2477d5 --- /dev/null +++ b/.changeset/solid-v2-templates.md @@ -0,0 +1,12 @@ +--- +"create-solid": minor +"@solid-cli/create": minor +--- + +Solid 2.0 template support + +- New "Solid 2.0" top-level project type scaffolding the `solid-v2/*` templates from solidjs/templates (basic, bare, fullstack, fullstack-tanstack, with-\*). Listed first but not preselected while Solid 2.0 core is in beta; existing flags (`-s`, `-v`, `-l`, `--v2`) keep their meaning, plus new `--solid` and `--ssr` flags. +- Optional streaming SSR on templates that support it (currently `basic`): a scaffold-time flip that sets `ssr: true` in `vite.config.ts`, adds the generic production `server.js`, and points the `start` script at it. Defaults to No. +- JavaScript variants of the Solid 2.0 templates via the existing sucrase TS→JS conversion (no `index.html` rewrite; `.ts`/`.tsx` references inside `vite.config` are retargeted, `.d.ts` files dropped, minimal `jsconfig.json`). +- Template lists, subdir paths and per-template flags are now read from a `templates.json` manifest at the templates repo HEAD (2s timeout), with silent fallback to the baked-in lists — so new templates and future repo reorganizations no longer require a CLI release. +- Template tarball downloads can be pinned to a templates-repo ref per CLI release (`TEMPLATES_REF`, overridable via `SOLID_CLI_TEMPLATES_REF`). diff --git a/packages/create/src/create-solid-v2.ts b/packages/create/src/create-solid-v2.ts new file mode 100644 index 0000000..c4366d4 --- /dev/null +++ b/packages/create/src/create-solid-v2.ts @@ -0,0 +1,49 @@ +import { join } from "node:path"; +import { existsSync, writeFileSync } from "node:fs"; +import { readFile, writeFile } from "node:fs/promises"; +import { handleTSConversion } from "./utils/ts-conversion"; +import { GIT_IGNORE, JS_CONFIG_SOLID_V2, SolidV2Template } from "./utils/constants"; +import { downloadTemplate } from "./utils/download"; +import { applySsrFlip } from "./utils/ssr-flip"; + +export type CreateSolidV2Args = { + template: SolidV2Template | (string & {}); + destination: string; + /** Subdir prefix inside the templates repo (from templates.json), defaults to "solid-v2" */ + path?: string; +}; + +export const createSolidV2 = (args: CreateSolidV2Args, transpile?: boolean, ssr?: boolean) => { + if (transpile) { + return createSolidV2JS(args, ssr); + } + return createSolidV2TS(args, ssr); +}; + +export const createSolidV2TS = async ({ template, destination, path = "solid-v2" }: CreateSolidV2Args, ssr?: boolean) => { + await downloadTemplate(`${path}/${template}`, destination); + if (ssr) await applySsrFlip(destination); +}; + +export const createSolidV2JS = async (args: CreateSolidV2Args, ssr?: boolean) => { + // Create typescript project in `/.project` + // then transpile this to javascript and clean up. + // The SSR flip runs inside the temp dir, before conversion, so the config + // edit happens on the `.ts` source (server.js is already plain JS). + const tempDir = join(args.destination, ".project"); + await createSolidV2TS({ ...args, destination: tempDir }, ssr); + await handleTSConversion(tempDir, args.destination, JS_CONFIG_SOLID_V2); + // Solid 2.0 templates have no `index.html` (turnkey entries), but their vite + // config references `.ts`/`.tsx` files by path (setup files, middleware, test + // globs) — retarget those to the transpiled `.js`/`.jsx` output + const viteConfigPath = join(args.destination, "vite.config.js"); + if (existsSync(viteConfigPath)) { + const viteConfig = (await readFile(viteConfigPath)) + .toString() + .replace(/\.tsx(?=['"])/g, ".jsx") + .replace(/\.ts(?=['"])/g, ".js"); + await writeFile(viteConfigPath, viteConfig); + } + // Add .gitignore + writeFileSync(join(args.destination, ".gitignore"), GIT_IGNORE); +}; diff --git a/packages/create/src/create-start.ts b/packages/create/src/create-start.ts index 579f4dc..0b4bc29 100644 --- a/packages/create/src/create-start.ts +++ b/packages/create/src/create-start.ts @@ -1,29 +1,25 @@ -import { downloadRepo, GithubFetcher } from "@begit/core"; import { join } from "path"; import { writeFileSync } from "fs"; import { handleTSConversion } from "./utils/ts-conversion"; import { GIT_IGNORE, StartTemplate, StartTemplateV2 } from "./utils/constants"; +import { downloadTemplate } from "./utils/download"; export type CreateStartArgs = { - template: StartTemplate | StartTemplateV2; + template: StartTemplate | StartTemplateV2 | (string & {}); destination: string; + /** Subdir prefix inside the templates repo (from templates.json), defaults to "solid-start-v2"/"solid-start-v1" */ + path?: string; }; -export const createStartTS = ({ template, destination }: CreateStartArgs, v2?: boolean) => { - const subdir = v2 ? `solid-start-v2/${template}` : `solid-start-v1/${template}`; - return downloadRepo( - { - repo: { owner: "solidjs", name: "templates", subdir }, - dest: destination, - }, - GithubFetcher, - ); +export const createStartTS = ({ template, destination, path }: CreateStartArgs, v2?: boolean) => { + const prefix = path ?? (v2 ? "solid-start-v2" : "solid-start-v1"); + return downloadTemplate(`${prefix}/${template}`, destination); }; -export const createStartJS = async ({ template, destination }: CreateStartArgs, v2?: boolean) => { +export const createStartJS = async ({ template, destination, path }: CreateStartArgs, v2?: boolean) => { // Create typescript project in `/.project` // then transpile this to javascript and clean up const tempDir = join(destination, ".project"); - await createStartTS({ template, destination: tempDir }, v2); + await createStartTS({ template, destination: tempDir, path }, v2); await handleTSConversion(tempDir, destination); // Add .gitignore writeFileSync(join(destination, ".gitignore"), GIT_IGNORE); diff --git a/packages/create/src/create-vanilla.ts b/packages/create/src/create-vanilla.ts index 0cebd73..a27d564 100644 --- a/packages/create/src/create-vanilla.ts +++ b/packages/create/src/create-vanilla.ts @@ -1,13 +1,15 @@ -import { downloadRepo, GithubFetcher } from "@begit/core"; import { join } from "node:path"; import { writeFileSync } from "node:fs"; import { readFile } from "node:fs/promises"; import { handleTSConversion } from "./utils/ts-conversion"; import { GIT_IGNORE, VanillaTemplate } from "./utils/constants"; +import { downloadTemplate } from "./utils/download"; export type CreateVanillaArgs = { - template: VanillaTemplate; + template: VanillaTemplate | (string & {}); destination: string; + /** Subdir prefix inside the templates repo (from templates.json), defaults to "vanilla" */ + path?: string; }; export const createVanilla = (args: CreateVanillaArgs, transpile?: boolean) => { if (transpile) { @@ -16,18 +18,15 @@ export const createVanilla = (args: CreateVanillaArgs, transpile?: boolean) => { return createVanillaTS(args); }; -export const createVanillaTS = async ({ template, destination }: CreateVanillaArgs) => { - return await downloadRepo( - { repo: { owner: "solidjs", name: "templates", subdir: `vanilla/${template}` }, dest: destination }, - GithubFetcher, - ); +export const createVanillaTS = async ({ template, destination, path = "vanilla" }: CreateVanillaArgs) => { + return await downloadTemplate(`${path}/${template}`, destination); }; -export const createVanillaJS = async ({ template, destination }: CreateVanillaArgs) => { +export const createVanillaJS = async ({ template, destination, path }: CreateVanillaArgs) => { // Create typescript project in `/.project` // then transpile this to javascript and clean up const tempDir = join(destination, ".project"); - await createVanillaTS({ template, destination: tempDir }); + await createVanillaTS({ template, destination: tempDir, path }); await handleTSConversion(tempDir, destination); // Replace `index.tsx` with `index.jsx` in `index.html` const indexPath = join(destination, "index.html"); diff --git a/packages/create/src/index.ts b/packages/create/src/index.ts index 4a7cf87..8c3bffd 100644 --- a/packages/create/src/index.ts +++ b/packages/create/src/index.ts @@ -3,13 +3,22 @@ import { createVanilla } from "./create-vanilla"; import * as p from "@clack/prompts"; import { cancelable, spinnerify } from "@solid-cli/utils/ui"; import { createStart } from "./create-start"; -import { getTemplatesList, GIT_IGNORE, isValidTemplate, PROJECT_TYPES, ProjectType } from "./utils/constants"; +import { createSolidV2 } from "./create-solid-v2"; +import { GIT_IGNORE, isValidTemplate, LIBRARY_TEMPLATES, PROJECT_TYPES, ProjectType } from "./utils/constants"; +import { fetchTemplatesManifest, groupKeyFor, ManifestTemplate, resolveGroup } from "./utils/manifest"; import { detectPackageManager } from "@solid-cli/utils/package-manager"; import { existsSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { createLibrary } from "./create-library"; import { readFile, writeFile } from "node:fs/promises"; -export { createVanilla, createStart, createLibrary }; +export { createVanilla, createStart, createLibrary, createSolidV2 }; + +const PROJECT_TYPE_LABELS: Record = { + solid: "Solid 2.0 (Beta)", + start: "SolidStart (Solid 1.x)", + vanilla: "SolidJS + Vite (Solid 1.x)", + library: "Library", +}; export const createSolid = (version: string) => defineCommand({ @@ -41,6 +50,11 @@ export const createSolid = (version: string) => alias: "t", description: "Template name", }, + "solid": { + type: "boolean", + required: false, + description: "Create a Solid 2.0 project", + }, "solidstart": { type: "boolean", required: false, @@ -64,6 +78,11 @@ export const createSolid = (version: string) => alias: "v", description: "Create a vanilla (SolidJS + Vite) project", }, + "ssr": { + type: "boolean", + required: false, + description: "Enable server-side rendering (Solid 2.0 templates that support it)", + }, "ts": { type: "boolean", required: false, @@ -81,9 +100,11 @@ export const createSolid = (version: string) => templatePositional, "project-name": projectNameOptional, "template": templateOptional, + solid, solidstart, library, vanilla, + ssr, ts, js, v2, @@ -92,25 +113,30 @@ export const createSolid = (version: string) => // Show prompts for any unknown arguments let projectName = projectNamePositional ?? projectNameOptional; let template = templatePositional ?? templateOptional; - let projectType: ProjectType | undefined = solidstart - ? "start" - : vanilla - ? "vanilla" - : library - ? "library" - : undefined; + let projectType: ProjectType | undefined = solid + ? "solid" + : solidstart + ? "start" + : vanilla + ? "vanilla" + : library + ? "library" + : undefined; // False if user has selected ts, true if they have selected js, and undefined if they've done neither let useJS = ts ? !ts : js ? js : undefined; + // Live template lists/paths from the templates repo; undefined falls back to the baked-in lists + const manifest = await fetchTemplatesManifest(); projectName ??= await cancelable( p.text({ message: "Project Name", placeholder: "solid-project", defaultValue: "solid-project" }), ); projectType ??= await cancelable( p.select({ message: "What type of project would you like to create?", + // Solid 2.0 is listed first but not preselected while core is in beta initialValue: "start", options: PROJECT_TYPES.map((t) => ({ value: t, - label: t === "start" ? "SolidStart" : t === "vanilla" ? "SolidJS + Vite" : "Library", + label: PROJECT_TYPE_LABELS[t], })), }), ); @@ -137,26 +163,48 @@ export const createSolid = (version: string) => useJS ??= projectType === "library" ? false : !(await cancelable(p.confirm({ message: "Use Typescript?" }))); if (!projectType) return; - const template_opts = getTemplatesList(projectType, isV2); + const group = projectType === "library" ? undefined : resolveGroup(manifest, groupKeyFor(projectType, isV2)); + const template_opts: ManifestTemplate[] = group + ? group.templates + : LIBRARY_TEMPLATES.map((name) => ({ name })); template ??= await cancelable( p.select({ message: "Which template would you like to use?", - initialValue: "ts", + initialValue: (template_opts.find((t) => t.default) ?? template_opts[0])?.name, options: template_opts - .filter((s) => (useJS ? s : !s.startsWith("js"))) - .map((s: string) => ({ label: s, value: s })), + .filter((t) => (useJS ? t : !t.name.startsWith("js"))) + .map((t) => ({ label: t.name, value: t.name })), }), ); if (!template) return; + const chosenTemplate = template_opts.find((t) => t.name === template); + + // SSR flip: only offered on Solid 2.0 templates that support it (e.g. "basic") + let enableSSR = false; + if (projectType === "solid" && chosenTemplate?.ssrToggle) { + enableSSR = + ssr ?? + (await cancelable( + p.confirm({ message: "Enable server-side rendering (streaming SSR)?", initialValue: false }), + )); + } else if (ssr) { + p.log.warn(`--ssr is not supported for this template and will be ignored`); + } // Need to transpile if the user wants Jabascript, but their selected template isn't Javascript const transpileToJS = useJS && !template.startsWith("js"); - if (projectType === "start" && isValidTemplate("start", template, isV2)) { + if (projectType === "solid" && chosenTemplate) { + await spinnerify({ + startText: "Creating project", + finishText: "Project created 🎉", + fn: () => createSolidV2({ template, destination: projectName, path: group?.path }, transpileToJS, enableSSR), + }); + } else if (projectType === "start" && chosenTemplate) { await spinnerify({ startText: "Creating project", finishText: "Project created 🎉", - fn: () => createStart({ template, destination: projectName }, transpileToJS, isV2), + fn: () => createStart({ template, destination: projectName, path: group?.path }, transpileToJS, isV2), }); } else if (projectType === "library" && isValidTemplate("library", template)) { await spinnerify({ @@ -164,11 +212,11 @@ export const createSolid = (version: string) => finishText: "Project created 🎉", fn: () => createLibrary({ destination: projectName }), }); - } else if (projectType === "vanilla" && isValidTemplate(projectType, template)) { + } else if (projectType === "vanilla" && chosenTemplate) { await spinnerify({ startText: "Creating project", finishText: "Project created 🎉", - fn: () => createVanilla({ template, destination: projectName }, transpileToJS), + fn: () => createVanilla({ template, destination: projectName, path: group?.path }, transpileToJS), }); } else { p.log.error(`Template ${template} is not valid for project type ${projectType}`); diff --git a/packages/create/src/utils/constants.ts b/packages/create/src/utils/constants.ts index d4130ee..f7bac15 100644 --- a/packages/create/src/utils/constants.ts +++ b/packages/create/src/utils/constants.ts @@ -38,10 +38,18 @@ export const JS_CONFIG = { }, }; +// The solid-v2 templates don't use the `~/*` path alias +export const JS_CONFIG_SOLID_V2 = { + compilerOptions: { + jsx: "preserve", + jsxImportSource: "solid-js", + }, +}; + // Supported templates /**Supported Vanilla Templates */ -const VANILLA_TEMPLATES = [ +export const VANILLA_TEMPLATES = [ "basic", "bare", "with-vitest", @@ -63,7 +71,7 @@ export type VanillaTemplate = (typeof VANILLA_TEMPLATES)[number]; * @description This list is hardcoded. But templates are fetched from another github repo. * @see https://github.com/solidjs/templates/tree/main/solid-start */ -const START_TEMPLATES = [ +export const START_TEMPLATES = [ "basic", "bare", "with-solidbase", @@ -83,7 +91,7 @@ const START_TEMPLATES = [ export type StartTemplate = (typeof START_TEMPLATES)[number]; -const START_TEMPLATES_V2 = [ +export const START_TEMPLATES_V2 = [ "basic", "bare", "with-auth", @@ -102,11 +110,31 @@ const START_TEMPLATES_V2 = [ export type StartTemplateV2 = (typeof START_TEMPLATES_V2)[number]; +/** + * Solid 2.0 templates (templates repo: `solid-v2/`) + * @see https://github.com/solidjs/templates/tree/main/solid-v2 + */ +export const SOLID_V2_TEMPLATES = [ + "basic", + "bare", + "fullstack", + "fullstack-tanstack", + "with-bootstrap", + "with-sass", + "with-tailwindcss", + "with-tanstack-router", + "with-unocss", + "with-vitest-browser-mode", +] as const satisfies string[]; +export type SolidV2Template = (typeof SOLID_V2_TEMPLATES)[number]; + /**Supported Library Templates */ export const LIBRARY_TEMPLATES = ["solid-lib-starter"] as const satisfies string[]; export type LibraryTemplate = (typeof LIBRARY_TEMPLATES)[number]; -export const PROJECT_TYPES = ["start", "vanilla", "library"] as const satisfies string[]; +// "solid" (Solid 2.0) is listed first, but "start" remains the preselected +// default while Solid 2.0 core is in beta +export const PROJECT_TYPES = ["solid", "start", "vanilla", "library"] as const satisfies string[]; export type ProjectType = (typeof PROJECT_TYPES)[number]; /** @@ -114,14 +142,17 @@ export type ProjectType = (typeof PROJECT_TYPES)[number]; * @param projectType type of project */ export function getTemplatesList(projectType: "vanilla", v2?: boolean): VanillaTemplate[]; +export function getTemplatesList(projectType: "solid", v2?: boolean): SolidV2Template[]; export function getTemplatesList(projectType: "start", v2?: boolean): StartTemplate[] | StartTemplateV2[]; export function getTemplatesList(projectType: "library", v2?: boolean): LibraryTemplate[]; export function getTemplatesList( projectType: ProjectType, v2?: boolean, -): VanillaTemplate[] | StartTemplate[] | StartTemplateV2[] | LibraryTemplate[]; +): VanillaTemplate[] | SolidV2Template[] | StartTemplate[] | StartTemplateV2[] | LibraryTemplate[]; export function getTemplatesList(projectType: ProjectType, v2?: boolean) { - if (projectType === "start") { + if (projectType === "solid") { + return SOLID_V2_TEMPLATES as unknown as SolidV2Template[]; + } else if (projectType === "start") { if (v2) { return START_TEMPLATES_V2 as unknown as StartTemplateV2[]; } @@ -139,6 +170,7 @@ export function getTemplatesList(projectType: ProjectType, v2?: boolean) { * @returns the template string if it is valid, undefined if not */ export function isValidTemplate(type: "vanilla", maybe_template: string): maybe_template is VanillaTemplate; +export function isValidTemplate(type: "solid", maybe_template: string): maybe_template is SolidV2Template; export function isValidTemplate( type: "start", maybe_template: string, diff --git a/packages/create/src/utils/download.ts b/packages/create/src/utils/download.ts new file mode 100644 index 0000000..0788521 --- /dev/null +++ b/packages/create/src/utils/download.ts @@ -0,0 +1,19 @@ +import { downloadRepo, GithubFetcher } from "@begit/core"; + +export const TEMPLATES_REPO = { owner: "solidjs", name: "templates" } as const; + +/** + * Optional ref (tag, sha or branch) of solidjs/templates that scaffold downloads + * are pinned to. Set this at release time (e.g. to a `cli-x.y` tag) so a published + * CLI version keeps scaffolding exactly what it was tested against, immune to + * later reorganizations of the templates repo. `undefined` means live HEAD of the + * default branch, which is the historical behavior. + */ +export const TEMPLATES_REF: string | undefined = undefined; + +/** `SOLID_CLI_TEMPLATES_REF` overrides the baked ref, for testing against branches/forks */ +export const templatesRef = () => process.env.SOLID_CLI_TEMPLATES_REF || TEMPLATES_REF; + +/** Downloads `subdir` of the solidjs/templates repo (at the pinned ref, if any) into `destination` */ +export const downloadTemplate = (subdir: string, destination: string) => + downloadRepo({ repo: { ...TEMPLATES_REPO, subdir, hash: templatesRef() }, dest: destination }, GithubFetcher); diff --git a/packages/create/src/utils/manifest.ts b/packages/create/src/utils/manifest.ts new file mode 100644 index 0000000..6855e68 --- /dev/null +++ b/packages/create/src/utils/manifest.ts @@ -0,0 +1,119 @@ +import { + SOLID_V2_TEMPLATES, + START_TEMPLATES, + START_TEMPLATES_V2, + VANILLA_TEMPLATES, + ProjectType, +} from "./constants"; + +/** + * `templates.json` manifest published at the root of the solidjs/templates repo. + * When reachable, it is the source of truth for template names, subdir paths and + * per-template flags, so the templates repo can add/reorganize templates without + * requiring a CLI release. The baked-in lists in `constants.ts` are the fallback. + */ +export type ManifestTemplate = { + name: string; + /** Preselected in the template prompt */ + default?: boolean; + /** Offer the "Enable server-side rendering?" prompt for this template */ + ssrToggle?: boolean; +}; + +export type ManifestGroup = { + label?: string; + /** Subdir prefix inside the templates repo, e.g. "solid-v2" */ + path: string; + status?: string; + templates: ManifestTemplate[]; +}; + +export type TemplatesManifest = { + version: number; + groups: Record; +}; + +/** Manifest groups the CLI understands (library comes from a different repo and stays baked-in) */ +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[] => + names.map((name) => ({ + name, + ...(name === defaultName ? { default: true } : {}), + ...(name === ssrToggle ? { ssrToggle: true } : {}), + })); + +/** Baked-in fallback, used whenever the manifest can't be fetched or parsed */ +export const BAKED_GROUPS: Record = { + "solid": { + label: "Solid 2.0", + path: "solid-v2", + templates: asTemplates(SOLID_V2_TEMPLATES, "basic", "basic"), + }, + "start-v2": { + label: "SolidStart 2", + path: "solid-start-v2", + templates: asTemplates(START_TEMPLATES_V2, "basic"), + }, + "start-v1": { + label: "SolidStart 1", + path: "solid-start-v1", + templates: asTemplates(START_TEMPLATES, "basic"), + }, + "vanilla": { + label: "SolidJS + Vite", + path: "vanilla", + templates: asTemplates(VANILLA_TEMPLATES, "basic"), + }, +}; + +/** + * Validates an untrusted parsed JSON value into a `TemplatesManifest`. + * Returns `undefined` for anything unusable (unknown major version, wrong shape), + * dropping malformed groups/entries rather than failing outright. + */ +export const parseManifest = (raw: unknown): TemplatesManifest | undefined => { + if (!raw || typeof raw !== "object") return undefined; + const manifest = raw as TemplatesManifest; + if (manifest.version !== 1) return undefined; + if (!manifest.groups || typeof manifest.groups !== "object") return undefined; + const groups: Record = {}; + for (const [key, group] of Object.entries(manifest.groups)) { + if (!group || typeof group !== "object") continue; + if (typeof group.path !== "string" || !Array.isArray(group.templates)) continue; + const templates = group.templates.filter( + (t): t is ManifestTemplate => !!t && typeof t === "object" && typeof t.name === "string", + ); + if (templates.length === 0) continue; + groups[key] = { ...group, templates }; + } + if (Object.keys(groups).length === 0) return undefined; + return { version: 1, groups }; +}; + +/** + * Fetches the live manifest from the templates repo (HEAD, small file, short timeout). + * Any failure — offline, timeout, 404, malformed JSON, unknown version — resolves to + * `undefined` so callers silently fall back to the baked-in lists. + */ +export const fetchTemplatesManifest = async (): Promise => { + try { + const url = process.env.SOLID_CLI_TEMPLATES_MANIFEST_URL || MANIFEST_URL; + const res = await fetch(url, { signal: AbortSignal.timeout(MANIFEST_TIMEOUT_MS) }); + if (!res.ok) return undefined; + return parseManifest(await res.json()); + } catch { + return undefined; + } +}; + +/** Maps the CLI's project type (+ SolidStart version) to a manifest group key */ +export const groupKeyFor = (projectType: Exclude, startV2?: boolean): ManifestGroupKey => + projectType === "start" ? (startV2 ? "start-v2" : "start-v1") : projectType; + +/** Resolves a group from the manifest, falling back to the baked-in lists */ +export const resolveGroup = (manifest: TemplatesManifest | undefined, key: ManifestGroupKey): ManifestGroup => + manifest?.groups[key] ?? BAKED_GROUPS[key]; diff --git a/packages/create/src/utils/ssr-flip.ts b/packages/create/src/utils/ssr-flip.ts new file mode 100644 index 0000000..08b59d6 --- /dev/null +++ b/packages/create/src/utils/ssr-flip.ts @@ -0,0 +1,133 @@ +import { existsSync } from "node:fs"; +import { readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { log } from "@clack/prompts"; + +/** + * Flips a client-mode Solid 2.0 template (e.g. `solid-v2/basic`) into streaming SSR. + * The delta to the `solid-v2/fullstack` template's server posture is exactly three files: + * 1. `vite.config.ts` — add `ssr: true` to the `solid({ start: true, ... })` call + * 2. `server.js` — the generic production node server (verbatim from `solid-v2/fullstack`) + * 3. `package.json` — point the `start` script at that server + */ + +export const SSR_ANCHOR = "solid({ start: true"; +/** The template documents the flip with this hint; drop it once the flip is applied */ +export const SSR_HINT_COMMENT = " // add `ssr: true` for streaming SSR"; +export const SSR_START_SCRIPT = "node --env-file-if-exists=.env server.js"; + +/** + * Production node server for turnkey SSR apps. Verbatim copy of + * `solid-v2/fullstack/server.js` from the templates repo — fully generic: it only + * imports node builtins plus the built server bundle's `handleRequest`. + * Regenerate with `node scripts/gen-ssr-flip-server.mjs ` + * when the template changes. + */ +// @generated-server-js-start +export const SERVER_JS = `// The entire production server for a turnkey SSR app: static client assets +// plus one import — the built server bundle's \`handleRequest\`, an +// adapter-agnostic web \`Request -> Response\` handler that streams the SSR +// render, resolves hashed client assets through the build manifest, and +// (with serverFunctions enabled) serves the \`/_server\` endpoint too. The +// node <-> web plumbing below is the only glue; on a web-native platform +// (workers, Deno, Bun.serve) \`handleRequest\` is used directly. +import { createServer } from 'node:http'; +import { readFileSync } from 'node:fs'; +import { Readable } from 'node:stream'; +import { fileURLToPath } from 'node:url'; +import path from 'node:path'; +import { handleRequest } from './dist/server/server.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const port = process.env.PORT || 3000; + +const MIME = { + '.js': 'application/javascript', + '.css': 'text/css', + '.html': 'text/html', + '.json': 'application/json', + '.ico': 'image/x-icon', + '.svg': 'image/svg+xml', +}; + +function webRequest(req) { + const url = new URL(req.url || '/', \`http://\${req.headers.host || \`localhost:\${port}\`}\`); + const method = req.method || 'GET'; + const body = method === 'GET' || method === 'HEAD' ? undefined : Readable.toWeb(req); + return new Request(url, { + method, + headers: req.headers, + body, + ...(body ? { duplex: 'half' } : {}), + }); +} + +const server = createServer(async (req, res) => { + const url = req.url || '/'; + + // Static client assets first. + if (url !== '/' && !url.includes('..')) { + try { + const content = readFileSync(path.resolve(__dirname, 'dist/client' + url.split('?')[0])); + res.setHeader('Content-Type', MIME[path.extname(url)] || 'application/octet-stream'); + res.end(content); + return; + } catch { + // Fall through to the handler (SSR routes, /_server, ...). + } + } + + try { + const response = await handleRequest(webRequest(req)); + res.statusCode = response.status; + const cookies = response.headers.getSetCookie?.(); + response.headers.forEach((value, key) => { + if (key !== 'set-cookie') res.setHeader(key, value); + }); + if (cookies?.length) res.setHeader('set-cookie', cookies); + if (response.body) { + for await (const chunk of response.body) res.write(chunk); + } + res.end(); + } catch (e) { + console.error(e); + res.statusCode = 500; + res.end(e.message); + } +}); + +server.listen(port, () => { + console.log(\`Server running at http://localhost:\${port}\`); +}); +`; +// @generated-server-js-end + +/** + * Applies the SSR flip to a scaffolded template directory (before any TS→JS conversion, + * so the config edit happens on the `.ts` source). + * + * If the `vite.config.ts` anchor is missing (template drifted), the flip is aborted + * with a warning instead of writing a broken config, leaving a working client-mode app. + */ +export const applySsrFlip = async (dir: string): Promise => { + const viteConfigPath = join(dir, "vite.config.ts"); + const abort = (reason: string) => { + log.warn(`Skipping SSR setup: ${reason}. The project was created in client mode.`); + return false; + }; + if (!existsSync(viteConfigPath)) return abort("no vite.config.ts found"); + const viteConfig = (await readFile(viteConfigPath)).toString(); + if (!viteConfig.includes(SSR_ANCHOR)) return abort("unrecognized vite.config.ts"); + + await writeFile( + viteConfigPath, + viteConfig.replace(SSR_ANCHOR, `${SSR_ANCHOR}, ssr: true`).replace(SSR_HINT_COMMENT, ""), + ); + await writeFile(join(dir, "server.js"), SERVER_JS); + + const packageJsonPath = join(dir, "package.json"); + const packageJson = JSON.parse((await readFile(packageJsonPath)).toString()); + packageJson.scripts = { ...packageJson.scripts, start: SSR_START_SCRIPT }; + await writeFile(packageJsonPath, JSON.stringify(packageJson, null, 2) + "\n"); + return true; +}; diff --git a/packages/create/src/utils/ts-conversion.ts b/packages/create/src/utils/ts-conversion.ts index 10d9c37..e0bd5a0 100644 --- a/packages/create/src/utils/ts-conversion.ts +++ b/packages/create/src/utils/ts-conversion.ts @@ -12,7 +12,9 @@ const convertToJS = async (file: Dirent, startPath: string) => { mkdirSync(dest, { recursive: true }); recurseFiles(resolve(startPath, file.name), convertToJS); } else if (file.isFile()) { - if (src.endsWith(".ts") || src.endsWith(".tsx")) { + if (src.endsWith(".d.ts")) { + // Type declarations have no JS counterpart + } else if (src.endsWith(".ts") || src.endsWith(".tsx")) { let { code } = transform(await readFileToString(src), { transforms: ["typescript", "jsx"], jsxRuntime: "preserve", @@ -26,9 +28,9 @@ const convertToJS = async (file: Dirent, startPath: string) => { } } }; -export const handleTSConversion = async (tempDir: string, projectName: string) => { +export const handleTSConversion = async (tempDir: string, projectName: string, jsConfig: object = JS_CONFIG) => { await rm(resolve(tempDir, "tsconfig.json")); - writeFileSync(resolve(projectName, "jsconfig.json"), JSON.stringify(JS_CONFIG, null, 2), { flag: "wx" }); + writeFileSync(resolve(projectName, "jsconfig.json"), JSON.stringify(jsConfig, null, 2), { flag: "wx" }); // Convert all ts files in temp directory into js recurseFiles(tempDir, convertToJS); diff --git a/packages/create/tests/fixtures/solid-v2-basic/package.json b/packages/create/tests/fixtures/solid-v2-basic/package.json new file mode 100644 index 0000000..d351538 --- /dev/null +++ b/packages/create/tests/fixtures/solid-v2-basic/package.json @@ -0,0 +1,30 @@ +{ + "name": "example-basic", + "version": "0.0.0", + "description": "", + "type": "module", + "scripts": { + "start": "vite", + "dev": "vite", + "build": "vite build", + "serve": "vite preview", + "test": "vitest" + }, + "license": "MIT", + "devDependencies": { + "@solidjs/testing-library": "1.0.0-beta.2", + "@testing-library/jest-dom": "^6.6.3", + "filesystem-routing": "0.2.1", + "jsdom": "^25.0.1", + "typescript": "^5.9.2", + "vite": "^8.1.5", + "vite-plugin-solid": "3.0.0-next.24", + "vitest": "^4.0.0" + }, + "dependencies": { + "@solidjs/meta": "1.0.0-next.1", + "@solidjs/router": "2.0.0-next.15", + "@solidjs/web": "2.0.0-beta.33", + "solid-js": "2.0.0-beta.33" + } +} diff --git a/packages/create/tests/fixtures/solid-v2-basic/vite.config.ts b/packages/create/tests/fixtures/solid-v2-basic/vite.config.ts new file mode 100644 index 0000000..554fb1d --- /dev/null +++ b/packages/create/tests/fixtures/solid-v2-basic/vite.config.ts @@ -0,0 +1,32 @@ +import { fileRoutes } from 'filesystem-routing/vite'; +import { defineConfig } from 'vitest/config'; +import solid from 'vite-plugin-solid'; + +export default defineConfig({ + // Turnkey client mode: no index.html and no mount file — the plugin + // generates the entries around src/App.tsx, wrapped in src/Document.tsx + // (or a built-in shell). `vite build` prerenders the shell into + // dist/client/index.html and emits a purely static dist/client. + plugins: [ + // `extensions` makes vite-plugin-solid also compile the `?pick=` route + // modules the fileRoutes plugin emits (their ids end in a query string). + solid({ start: true, extensions: ['.jsx', '.tsx'] }), // add `ssr: true` for streaming SSR + fileRoutes(), + ], + server: { + port: 3000, + }, + test: { + environment: 'jsdom', + globals: false, + setupFiles: ['./vitest-setup.ts'], + // if you have few tests, try commenting this + // out to improve performance: + isolate: false, + }, + build: { + target: 'esnext', + // Keep images as asset files instead of inlining them into the JS bundle. + assetsInlineLimit: 0, + }, +}); diff --git a/packages/create/tests/manifest.test.ts b/packages/create/tests/manifest.test.ts new file mode 100644 index 0000000..ae78e1b --- /dev/null +++ b/packages/create/tests/manifest.test.ts @@ -0,0 +1,80 @@ +import { afterEach, expect, it } from "vitest"; +import { fetchTemplatesManifest, parseManifest, resolveGroup } from "../src/utils/manifest"; + +const validManifest = { + version: 1, + groups: { + solid: { + label: "Solid 2.0", + path: "solid-v2", + templates: [{ name: "basic", default: true, ssrToggle: true }, { name: "bare" }], + }, + }, +}; + +it("parses a valid manifest", () => { + const manifest = parseManifest(validManifest); + expect(manifest).toBeDefined(); + expect(manifest!.groups["solid"].path).toBe("solid-v2"); + expect(manifest!.groups["solid"].templates.map((t) => t.name)).toEqual(["basic", "bare"]); +}); + +it("rejects unknown manifest versions and malformed documents", () => { + expect(parseManifest({ ...validManifest, version: 2 })).toBeUndefined(); + expect(parseManifest(null)).toBeUndefined(); + expect(parseManifest("nonsense")).toBeUndefined(); + expect(parseManifest({ version: 1 })).toBeUndefined(); + expect(parseManifest({ version: 1, groups: { solid: { templates: "not-an-array" } } })).toBeUndefined(); +}); + +it("drops malformed groups and template entries but keeps the rest", () => { + const manifest = parseManifest({ + version: 1, + groups: { + solid: { path: "solid-v2", templates: [{ name: "basic" }, { notAName: true }, "bare"] }, + broken: { path: 42, templates: [{ name: "x" }] }, + }, + }); + expect(manifest).toBeDefined(); + expect(Object.keys(manifest!.groups)).toEqual(["solid"]); + expect(manifest!.groups["solid"].templates).toEqual([{ name: "basic" }]); +}); + +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"]); + + // Groups missing from the manifest fall back per-group + expect(resolveGroup(manifest, "vanilla").path).toBe("vanilla"); + + // No manifest at all: everything comes from the baked-in lists + const baked = resolveGroup(undefined, "solid"); + expect(baked.path).toBe("solid-v2"); + expect(baked.templates.map((t) => t.name)).toEqual([ + "basic", + "bare", + "fullstack", + "fullstack-tanstack", + "with-bootstrap", + "with-sass", + "with-tailwindcss", + "with-tanstack-router", + "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); + expect(resolveGroup(undefined, "start-v2").path).toBe("solid-start-v2"); + expect(resolveGroup(undefined, "start-v1").path).toBe("solid-start-v1"); +}); + +afterEach(() => { + delete process.env.SOLID_CLI_TEMPLATES_MANIFEST_URL; +}); + +it("silently returns undefined when the manifest fetch fails", async () => { + // Unroutable address: connection is refused immediately + process.env.SOLID_CLI_TEMPLATES_MANIFEST_URL = "http://127.0.0.1:1/templates.json"; + expect(await fetchTemplatesManifest()).toBeUndefined(); +}); diff --git a/packages/create/tests/ssr-flip.test.ts b/packages/create/tests/ssr-flip.test.ts new file mode 100644 index 0000000..bdaa739 --- /dev/null +++ b/packages/create/tests/ssr-flip.test.ts @@ -0,0 +1,48 @@ +import { beforeEach, expect, it } from "vitest"; +import { mkdtempSync, copyFileSync, existsSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { applySsrFlip, SERVER_JS, SSR_HINT_COMMENT, SSR_START_SCRIPT } from "../src/utils/ssr-flip"; + +const fixtures = fileURLToPath(new URL("./fixtures/solid-v2-basic/", import.meta.url)); + +let dir: string; +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "ssr-flip-")); + copyFileSync(join(fixtures, "vite.config.ts"), join(dir, "vite.config.ts")); + copyFileSync(join(fixtures, "package.json"), join(dir, "package.json")); +}); + +it("flips the basic template to streaming SSR", async () => { + expect(await applySsrFlip(dir)).toBe(true); + + const viteConfig = readFileSync(join(dir, "vite.config.ts")).toString(); + expect(viteConfig).toContain("solid({ start: true, ssr: true"); + expect(viteConfig).not.toContain(SSR_HINT_COMMENT); + + // The production server is a verbatim copy of solid-v2/fullstack/server.js + expect(readFileSync(join(dir, "server.js")).toString()).toBe(SERVER_JS); + + const packageJson = JSON.parse(readFileSync(join(dir, "package.json")).toString()); + expect(packageJson.scripts.start).toBe(SSR_START_SCRIPT); + // The other scripts are untouched + expect(packageJson.scripts.dev).toBe("vite"); + expect(packageJson.scripts.build).toBe("vite build"); +}); + +it("aborts without writing anything when the vite config anchor is missing", async () => { + const drifted = `import { defineConfig } from "vite";\nexport default defineConfig({});\n`; + writeFileSync(join(dir, "vite.config.ts"), drifted); + const packageJsonBefore = readFileSync(join(dir, "package.json")).toString(); + + expect(await applySsrFlip(dir)).toBe(false); + + expect(readFileSync(join(dir, "vite.config.ts")).toString()).toBe(drifted); + expect(existsSync(join(dir, "server.js"))).toBe(false); + expect(readFileSync(join(dir, "package.json")).toString()).toBe(packageJsonBefore); +}); + +it("aborts when there is no vite.config.ts", async () => { + expect(await applySsrFlip(mkdtempSync(join(tmpdir(), "ssr-flip-empty-")))).toBe(false); +}); diff --git a/packages/create/tests/template.test.ts b/packages/create/tests/template.test.ts index 6fb0520..bb6fc0c 100644 --- a/packages/create/tests/template.test.ts +++ b/packages/create/tests/template.test.ts @@ -1,5 +1,5 @@ import { expect, it } from "vitest"; -import { createVanilla } from "../src"; +import { createSolidV2, createVanilla } from "../src"; import { existsSync } from "fs"; it("downloads and extracts the basic template", async () => { await createVanilla({ template: "basic", destination: "./test/ts" }, false); @@ -7,3 +7,10 @@ it("downloads and extracts the basic template", async () => { const appTsx = existsSync("./test/ts/src/App.tsx"); expect(appTsx).toBe(true); }); + +it("downloads and extracts the solid-v2 basic template", async () => { + await createSolidV2({ template: "basic", destination: "./test/solid-v2" }, false); + + const appTsx = existsSync("./test/solid-v2/src/App.tsx"); + expect(appTsx).toBe(true); +}); diff --git a/scripts/gen-ssr-flip-server.mjs b/scripts/gen-ssr-flip-server.mjs new file mode 100644 index 0000000..c1f199e --- /dev/null +++ b/scripts/gen-ssr-flip-server.mjs @@ -0,0 +1,15 @@ +// One-off generator: embeds solid-v2/fullstack/server.js into ssr-flip.ts as an +// escaped template literal, guaranteeing the scaffolded file byte-matches the template. +import { readFileSync, writeFileSync } from "node:fs"; + +const [src, dest] = process.argv.slice(2); +const content = readFileSync(src, "utf8"); +const escaped = content.replace(/\\/g, "\\\\").replace(/`/g, "\\`").replace(/\$\{/g, "\\${"); + +const target = readFileSync(dest, "utf8"); +const updated = target.replace( + /(\/\/ @generated-server-js-start\nexport const SERVER_JS = `)[\s\S]*?(`;\n\/\/ @generated-server-js-end)/, + `$1${escaped}$2`, +); +writeFileSync(dest, updated); +console.log("embedded", content.length, "bytes"); diff --git a/vitest.config.ts b/vitest.config.ts index bef45c6..3bc10e7 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,4 +1,9 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ - test: { globalSetup: ["./setup.ts"] }, + test: { + globalSetup: ["./setup.ts"], + // "test" is where template-download tests scaffold projects; + // the scaffolded templates carry test files of their own + exclude: ["**/node_modules/**", "**/test/**"], + }, }); From 45341c35170c4a59a1f78ca49f8af962b3b561f2 Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Tue, 11 Aug 2026 16:08:53 -0700 Subject: [PATCH 2/3] chore: pin TEMPLATES_REF to templates main (c3032d9) carrying solid-v2 + templates.json Co-authored-by: Cursor --- packages/create/src/utils/download.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/create/src/utils/download.ts b/packages/create/src/utils/download.ts index 0788521..e972998 100644 --- a/packages/create/src/utils/download.ts +++ b/packages/create/src/utils/download.ts @@ -9,7 +9,7 @@ export const TEMPLATES_REPO = { owner: "solidjs", name: "templates" } as const; * later reorganizations of the templates repo. `undefined` means live HEAD of the * default branch, which is the historical behavior. */ -export const TEMPLATES_REF: string | undefined = undefined; +export const TEMPLATES_REF: string | undefined = "c3032d95cd6d6ab4782eb64902a12829a36d0731"; /** `SOLID_CLI_TEMPLATES_REF` overrides the baked ref, for testing against branches/forks */ export const templatesRef = () => process.env.SOLID_CLI_TEMPLATES_REF || TEMPLATES_REF; From 57f4fa097862eddbbe85103f77434a143a1144c6 Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Tue, 11 Aug 2026 21:48:15 -0700 Subject: [PATCH 3/3] chore: bump TEMPLATES_REF to templates main (b3d888d) for browser-mode fixes Picks up two template fixes: solid-v2/with-vitest-browser-mode sets environment: 'node' explicitly so `pnpm test` exits 0, and vanilla/with-vitest-browser-mode's corrupted import specifier is repaired. Co-authored-by: Cursor --- packages/create/src/utils/download.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/create/src/utils/download.ts b/packages/create/src/utils/download.ts index e972998..a62a6e5 100644 --- a/packages/create/src/utils/download.ts +++ b/packages/create/src/utils/download.ts @@ -9,7 +9,7 @@ export const TEMPLATES_REPO = { owner: "solidjs", name: "templates" } as const; * later reorganizations of the templates repo. `undefined` means live HEAD of the * default branch, which is the historical behavior. */ -export const TEMPLATES_REF: string | undefined = "c3032d95cd6d6ab4782eb64902a12829a36d0731"; +export const TEMPLATES_REF: string | undefined = "b3d888d309c7173feee2b4cb3ddba8e788af559b"; /** `SOLID_CLI_TEMPLATES_REF` overrides the baked ref, for testing against branches/forks */ export const templatesRef = () => process.env.SOLID_CLI_TEMPLATES_REF || TEMPLATES_REF;