diff --git a/.changeset/slow-buses-build.md b/.changeset/slow-buses-build.md new file mode 100644 index 00000000..a60b8945 --- /dev/null +++ b/.changeset/slow-buses-build.md @@ -0,0 +1,5 @@ +--- +"@tanstack/create": patch +--- + +Add a Worker-safe edge export backed by a build-time generated template and add-on manifest. diff --git a/.gitignore b/.gitignore index d5537a9b..c43a2b60 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,5 @@ my-app coverage playwright-report test-results + +packages/create/src/generated/ diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 1cff18ef..f9b9bb21 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -44,6 +44,45 @@ tanstack create --list-add-ons --framework React --json tanstack create --addon-details drizzle --framework React --json ``` +### Worker-safe programmatic generation + +Use `@tanstack/create/edge` in Cloudflare Workers and other runtimes that do not expose a Node package filesystem. The default `@tanstack/create` export is still the Node/CLI path and scans framework templates from disk. + +```ts +import { + createApp, + createMemoryEnvironment, + finalizeAddOns, + getFrameworkById, + populateAddOnOptionsDefaults, +} from '@tanstack/create/edge' + +const framework = getFrameworkById('react')! +const chosenAddOns = await finalizeAddOns(framework, 'file-router', [ + 'tanstack-query', + 'cloudflare', +]) +const addOnOptions = populateAddOnOptionsDefaults(chosenAddOns) +const { environment, output } = createMemoryEnvironment('/app') + +await createApp(environment, { + projectName: 'app', + targetDir: '/app', + framework, + mode: 'file-router', + typescript: true, + tailwind: true, + packageManager: 'pnpm', + git: false, + install: false, + intent: false, + chosenAddOns, + addOnOptions, +}) + +// output.files contains generated files for ZIP creation. +``` + --- ## tanstack add diff --git a/eslint.config.js b/eslint.config.js index 5f043377..8a2ff957 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -10,6 +10,7 @@ export default [ ignores: [ '**/assets/**', '**/dist/**', + '**/src/generated/**', ], }, ...tanstackConfig, diff --git a/packages/create/package.json b/packages/create/package.json index 03757eb0..de410147 100644 --- a/packages/create/package.json +++ b/packages/create/package.json @@ -5,12 +5,32 @@ "type": "module", "main": "./dist/index.js", "types": "./dist/types/index.d.ts", + "exports": { + ".": { + "types": "./dist/types/index.d.ts", + "import": "./dist/index.js", + "default": "./dist/index.js" + }, + "./edge": { + "types": "./dist/types/edge.d.ts", + "import": "./dist/edge.js", + "default": "./dist/edge.js" + }, + "./manifest": { + "types": "./dist/types/manifest.d.ts", + "import": "./dist/manifest.js", + "default": "./dist/manifest.js" + }, + "./dist/*": "./dist/*", + "./package.json": "./package.json" + }, "scripts": { - "build": "tsc && npm run copy-assets", + "build": "npm run generate-manifest && tsc && npm run copy-assets", + "generate-manifest": "node ./scripts/generate-manifest.mjs", "copy-assets": "node -e \"const fs=require('fs');const path=require('path');function copyDir(src,dest){if(!fs.existsSync(dest))fs.mkdirSync(dest,{recursive:true});for(const entry of fs.readdirSync(src,{withFileTypes:true})){const srcPath=path.join(src,entry.name);const destPath=path.join(dest,entry.name);if(entry.isDirectory())copyDir(srcPath,destPath);else fs.copyFileSync(srcPath,destPath)}}['react','solid'].forEach(fw=>{['add-ons','toolchains','hosts','examples','project'].forEach(dir=>{const src='src/frameworks/'+fw+'/'+dir;const dest='dist/frameworks/'+fw+'/'+dir;if(fs.existsSync(src))copyDir(src,dest)})})\"", - "dev": "tsc --watch", - "test": "eslint ./src && vitest run", - "test:watch": "vitest", + "dev": "npm run generate-manifest && tsc --watch", + "test": "npm run generate-manifest && eslint ./src && vitest run", + "test:watch": "npm run generate-manifest && vitest", "test:coverage": "vitest run --coverage" }, "repository": { diff --git a/packages/create/scripts/generate-manifest.mjs b/packages/create/scripts/generate-manifest.mjs new file mode 100644 index 00000000..3a6041e4 --- /dev/null +++ b/packages/create/scripts/generate-manifest.mjs @@ -0,0 +1,407 @@ +import { + existsSync, + mkdirSync, + readFileSync, + readdirSync, + writeFileSync, +} from 'node:fs' +import { dirname, extname, join, relative, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +const packageDir = resolve(dirname(fileURLToPath(import.meta.url)), '..') +const frameworksDir = resolve(packageDir, 'src/frameworks') +const outputFile = resolve(packageDir, 'src/generated/create-manifest.ts') + +const binaryExtensions = new Set(['.png', '.jpg', '.jpeg', '.gif', '.svg', '.ico']) +const templateRenderers = new Map() + +const frameworkMetadata = { + react: { + id: 'react', + name: 'React', + description: 'Templates for React', + version: '0.1.0', + supportedModes: { + 'file-router': { + displayName: 'File Router', + description: 'TanStack Start with file-based routing', + forceTypescript: true, + }, + }, + }, + solid: { + id: 'solid', + name: 'Solid', + description: 'Solid templates for Tanstack Router Applications', + version: '0.1.0', + supportedModes: { + 'file-router': { + displayName: 'File Router', + description: 'TanStack Start with file-based routing', + forceTypescript: true, + }, + }, + }, +} + +function readJson(file) { + return JSON.parse(readFileSync(file, 'utf8')) +} + +function readTemplateFile(file) { + if (binaryExtensions.has(extname(file))) { + return `base64::${readFileSync(file).toString('base64')}` + } + + const contents = readFileSync(file, 'utf8').toString() + if (file.endsWith('.ejs')) { + registerTemplate(contents) + } + + return contents +} + +function toCleanPath(file, baseDir) { + return relative(baseDir, file).replace(/\\/g, '/') +} + +function findFilesRecursively(baseDir) { + const files = {} + + if (!existsSync(baseDir)) { + return files + } + + function visit(dir) { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const file = resolve(dir, entry.name) + if (entry.isDirectory()) { + visit(file) + } else { + files[toCleanPath(file, baseDir)] = readTemplateFile(file) + } + } + } + + visit(baseDir) + + return files +} + +function scanProjectDirectory(frameworkDir) { + const projectDirectory = join(frameworkDir, 'project') + const baseDirectory = join(projectDirectory, 'base') + const basePackagePath = join(baseDirectory, 'package.json') + const optionalPackagesPath = join(projectDirectory, 'packages.json') + + return { + base: findFilesRecursively(baseDirectory), + basePackageJSON: existsSync(basePackagePath) ? readJson(basePackagePath) : {}, + optionalPackages: existsSync(optionalPackagesPath) + ? readJson(optionalPackagesPath) + : {}, + } +} + +function scanCatalogDirectory(addOnsBase) { + if (!existsSync(addOnsBase)) { + return [] + } + + const addOns = [] + + for (const entry of readdirSync(addOnsBase, { withFileTypes: true })) { + if (!entry.isDirectory()) { + continue + } + + const addOnDir = join(addOnsBase, entry.name) + const info = readJson(join(addOnDir, 'info.json')) + + let packageAdditions = {} + let packageTemplate + const packageJsonPath = join(addOnDir, 'package.json') + const packageTemplatePath = join(addOnDir, 'package.json.ejs') + if (existsSync(packageJsonPath)) { + packageAdditions = readJson(packageJsonPath) + } else if (existsSync(packageTemplatePath)) { + packageTemplate = readFileSync(packageTemplatePath, 'utf8') + registerTemplate(packageTemplate) + } + + let readme + let readmeIsEjs = false + const readmePath = join(addOnDir, 'README.md') + const readmeTemplatePath = join(addOnDir, 'README.md.ejs') + if (existsSync(readmePath)) { + readme = readFileSync(readmePath, 'utf8') + } else if (existsSync(readmeTemplatePath)) { + readme = readFileSync(readmeTemplatePath, 'utf8') + registerTemplate(readme) + readmeIsEjs = true + } + + let smallLogo + const smallLogoPath = join(addOnDir, 'small-logo.svg') + if (existsSync(smallLogoPath)) { + smallLogo = readFileSync(smallLogoPath, 'utf8') + } + + addOns.push({ + ...info, + id: entry.name, + version: info.version ?? '0.0.0', + packageAdditions, + packageTemplate, + readme, + readmeIsEjs, + files: findFilesRecursively(join(addOnDir, 'assets')), + deletedFiles: info.deletedFiles ?? [], + smallLogo, + }) + } + + return addOns +} + +function createFramework(frameworkId) { + const frameworkDir = join(frameworksDir, frameworkId) + const project = scanProjectDirectory(frameworkDir) + + return { + ...frameworkMetadata[frameworkId], + ...project, + addOns: [ + ...scanCatalogDirectory(join(frameworkDir, 'add-ons')), + ...scanCatalogDirectory(join(frameworkDir, 'toolchains')), + ...scanCatalogDirectory(join(frameworkDir, 'examples')), + ...scanCatalogDirectory(join(frameworkDir, 'hosts')), + ], + } +} + +function getTemplateKey(template) { + let hash = 0x811c9dc5 + for (let i = 0; i < template.length; i++) { + hash ^= template.charCodeAt(i) + hash = Math.imul(hash, 0x01000193) >>> 0 + } + + return `${hash.toString(16).padStart(8, '0')}:${template.length}` +} + +function registerTemplate(template) { + const key = getTemplateKey(template) + if (!templateRenderers.has(key)) { + templateRenderers.set(key, compileTemplate(template)) + } +} + +function stripSemicolon(code) { + return code.replace(/;(\s*$)/, '$1') +} + +function compileTemplate(template) { + const regex = /<%([=_#-]?|_)?([\s\S]*?)([-_]?%>)/g + let cursor = 0 + let trimLeadingWhitespace = false + const lines = [] + + function appendText(value) { + if (!value) { + return + } + lines.push(` __append(${JSON.stringify(value)})`) + } + + for (const match of template.matchAll(regex)) { + let text = template.slice(cursor, match.index) + if (trimLeadingWhitespace) { + text = text.replace(/^\s*\r?\n?/, '') + trimLeadingWhitespace = false + } + if (match[1] === '_') { + text = text.replace(/\s*$/, '') + } + appendText(text) + + const marker = match[1] || '' + const code = match[2] + const close = match[3] + + if (marker === '=') { + lines.push(` __append(__escapeXML(${stripSemicolon(code.trim())}))`) + } else if (marker === '-') { + lines.push(` __append(${stripSemicolon(code.trim())})`) + } else if (marker !== '#') { + lines.push(code) + } + + trimLeadingWhitespace = close.startsWith('-') || close.startsWith('_') + cursor = match.index + match[0].length + } + + let tail = template.slice(cursor) + if (trimLeadingWhitespace) { + tail = tail.replace(/^\s*\r?\n?/, '') + } + appendText(tail) + + return lines.join('\n') +} + +function createTemplateRendererSource() { + const entries = Array.from(templateRenderers.entries()).sort(([a], [b]) => + a.localeCompare(b), + ) + + const functions = entries + .map(([key, body]) => { + const functionName = `__render_${key.replace(/[^a-zA-Z0-9_$]/g, '_')}` + return `function ${functionName}(context: TemplateRenderContext) { + const { + packageManager, + projectName, + typescript, + tailwind, + js, + jsx, + fileRouter, + codeRouter, + routerOnly, + includeExamples, + addOnEnabled, + addOnOption, + addOns, + integrations, + routes, + getPackageManagerAddScript, + getPackageManagerRunScript, + getPackageManagerExecuteScript, + relativePath, + integrationImportContent, + integrationImportCode, + renderTemplate, + ignoreFile, + } = context + let __output = '' + const __append = (value: unknown) => { + if (value !== undefined && value !== null) { + __output += String(value) + } + } +${body} + return __output +}` + }) + .join('\n\n') + + const mapEntries = entries + .map(([key]) => { + const functionName = `__render_${key.replace(/[^a-zA-Z0-9_$]/g, '_')}` + return ` ${JSON.stringify(key)}: ${functionName},` + }) + .join('\n') + + return `type TemplateRecord = Record +type TemplateAddOn = TemplateRecord & { + integrations?: Array + routes?: Array +} + +type TemplateRenderContext = { + [key: string]: any + packageManager: any + projectName: any + typescript: any + tailwind: any + js: any + jsx: any + fileRouter: any + codeRouter: any + routerOnly: any + includeExamples: any + addOnEnabled: Record + addOnOption: Record + addOns: Array + integrations: Array + routes: Array + getPackageManagerAddScript: (...args: Array) => string + getPackageManagerRunScript: (...args: Array) => string + getPackageManagerExecuteScript: (...args: Array) => string + relativePath: (...args: Array) => string + integrationImportContent: (...args: Array) => string + integrationImportCode: (...args: Array) => string + renderTemplate: (content: string) => string + ignoreFile: () => never +} + +type TemplateRenderer = (context: TemplateRenderContext) => string | undefined + +function __escapeXML(value: unknown) { + if (value === undefined || value === null) { + return '' + } + return String(value).replace(/[&<>'"]/g, (character) => { + switch (character) { + case '&': + return '&' + case '<': + return '<' + case '>': + return '>' + case '"': + return '"' + case "'": + return ''' + default: + return character + } + }) +} + +export function getManifestTemplateKey(template: string) { + let hash = 0x811c9dc5 + for (let i = 0; i < template.length; i++) { + hash ^= template.charCodeAt(i) + hash = Math.imul(hash, 0x01000193) >>> 0 + } + + return \`\${hash.toString(16).padStart(8, '0')}:\${template.length}\` +} + +${functions} + +const templateRenderers: Record = { +${mapEntries} +} + +export function renderManifestTemplate( + template: string, + context: TemplateRenderContext, +) { + const key = getManifestTemplateKey(template) + const renderer = templateRenderers[key] + if (!renderer) { + throw new Error(\`Template \${key} was not precompiled into the manifest\`) + } + return renderer(context) ?? '' +} +` +} + +const manifest = [createFramework('react'), createFramework('solid')] + +mkdirSync(dirname(outputFile), { recursive: true }) +writeFileSync( + outputFile, + `// Generated by scripts/generate-manifest.mjs. Do not edit by hand.\n` + + `import type { ManifestFrameworkDefinition } from '../manifest-types.js'\n\n` + + createTemplateRendererSource() + + '\n' + + `export const createManifestFrameworks = (): Array => ${JSON.stringify( + manifest, + null, + 2, + )}\n`, +) diff --git a/packages/create/src/edge-add-ons.ts b/packages/create/src/edge-add-ons.ts new file mode 100644 index 00000000..a039811d --- /dev/null +++ b/packages/create/src/edge-add-ons.ts @@ -0,0 +1,138 @@ +import { AddOnCompiledSchema } from './types.js' + +import type { AddOn, Framework } from './types.js' + +export function getAllAddOns(framework: Framework, mode: string): Array { + return framework + .getAddOns() + .filter((a) => a.modes.includes(mode)) + .sort((a, b) => { + const aPriority = a.priority ?? 0 + const bPriority = b.priority ?? 0 + return bPriority - aPriority + }) +} + +export async function finalizeAddOns( + framework: Framework, + mode: string, + chosenAddOnIDs: Array, +): Promise> { + const finalAddOnIDs = new Set(chosenAddOnIDs) + const addOns = getAllAddOns(framework, mode) + + for (const addOnID of finalAddOnIDs) { + let addOn: AddOn | undefined + const localAddOn = + addOns.find((a) => a.id === addOnID) ?? + addOns.find((a) => a.id.toLowerCase() === addOnID.toLowerCase()) + + if (localAddOn) { + addOn = localAddOn + if (localAddOn.id !== addOnID) { + finalAddOnIDs.delete(addOnID) + finalAddOnIDs.add(localAddOn.id) + } + } else if (addOnID.startsWith('http')) { + addOn = await loadRemoteAddOn(addOnID) + addOns.push(addOn) + } else { + const suggestion = findClosestAddOn(addOnID, addOns) + throw new Error( + `Add-on ${addOnID} not found${suggestion ? `. Did you mean "${suggestion}"?` : ''}`, + ) + } + + for (const dependsOn of addOn.dependsOn || []) { + const dep = addOns.find((a) => a.id === dependsOn) + if (!dep) { + throw new Error(`Dependency ${dependsOn} not found`) + } + finalAddOnIDs.add(dep.id) + } + } + + return [...finalAddOnIDs].map((id) => addOns.find((a) => a.id === id)!) +} + +export function populateAddOnOptionsDefaults( + chosenAddOns: Array, +): Record> { + const addOnOptions: Record> = {} + + for (const addOn of chosenAddOns) { + if (addOn.options) { + const defaults: Record = {} + for (const [optionKey, optionDef] of Object.entries(addOn.options)) { + defaults[optionKey] = optionDef.default + } + addOnOptions[addOn.id] = defaults + } + } + + return addOnOptions +} + +export async function loadRemoteAddOn(url: string): Promise { + const response = await fetch(url) + const jsonContent = await response.json() + const checked = AddOnCompiledSchema.safeParse(jsonContent) + + if (!checked.success) { + throw new Error(`Invalid add-on: ${url}`) + } + + const addOn = { + ...checked.data, + id: url, + } + + return { + ...addOn, + getFiles: () => Promise.resolve(Object.keys(addOn.files)), + getFileContents: (path: string) => Promise.resolve(addOn.files[path]), + getDeletedFiles: () => Promise.resolve(addOn.deletedFiles), + } +} + +function findClosestAddOn( + input: string, + addOns: Array, +): string | undefined { + const inputLower = input.toLowerCase() + let bestMatch: string | undefined + let bestDistance = Infinity + + for (const addOn of addOns) { + const distance = levenshtein(inputLower, addOn.id.toLowerCase()) + if (distance < bestDistance) { + bestDistance = distance + bestMatch = addOn.id + } + } + + if (bestMatch && bestDistance <= Math.max(Math.floor(input.length / 2), 2)) { + return bestMatch + } + + return undefined +} + +function levenshtein(a: string, b: string): number { + const m = a.length + const n = b.length + let prev = Array.from({ length: n + 1 }, (_, j) => j) + + for (let i = 1; i <= m; i++) { + const curr = [i] + for (let j = 1; j <= n; j++) { + curr[j] = + a[i - 1] === b[j - 1] + ? prev[j - 1] + : 1 + Math.min(prev[j], curr[j - 1], prev[j - 1]) + } + prev = curr + } + + return prev[n] +} diff --git a/packages/create/src/edge-config-file.ts b/packages/create/src/edge-config-file.ts new file mode 100644 index 00000000..8d68e774 --- /dev/null +++ b/packages/create/src/edge-config-file.ts @@ -0,0 +1,35 @@ +import { CONFIG_FILE } from './constants.js' +import { joinPaths } from './edge-path.js' + +import type { Environment, Options } from './types.js' + +export type PersistedOptions = Omit< + Partial, + 'addOns' | 'chosenAddOns' | 'framework' | 'starter' | 'targetDir' +> & { + framework: string + version: number + chosenAddOns: Array + starter?: string +} + +function createPersistedOptions(options: Options): PersistedOptions { + const { chosenAddOns, framework, targetDir: _targetDir, ...rest } = options + return { + ...rest, + version: 1, + framework: framework.id, + chosenAddOns: chosenAddOns.map((addOn) => addOn.id), + starter: options.starter?.id ?? undefined, + } +} + +export async function writeConfigFileToEnvironment( + environment: Environment, + options: Options, +) { + await environment.writeFile( + joinPaths(options.targetDir, CONFIG_FILE), + JSON.stringify(createPersistedOptions(options), null, 2), + ) +} diff --git a/packages/create/src/edge-create-app.ts b/packages/create/src/edge-create-app.ts new file mode 100644 index 00000000..77d3d65d --- /dev/null +++ b/packages/create/src/edge-create-app.ts @@ -0,0 +1,594 @@ +import { createPackageJSON } from './edge-package-json.js' +import { createTemplateFile } from './edge-template-file.js' +import { formatCommand } from './utils.js' +import { isBase64, isDemoFilePath } from './edge-file-helpers.js' +import { basenamePath, joinPaths, normalizePath } from './edge-path.js' +import { resolvePackageJSONLatest } from './npm-resolver.js' +import { writeConfigFileToEnvironment } from './edge-config-file.js' +import { + getPackageManagerExecuteCommand, + getPackageManagerScriptCommand, + packageManagerInstall, + translateExecuteCommand, +} from './package-manager.js' + +import type { Environment, FileBundleHandler, Options } from './types.js' + +function stripExamplesFromOptions(options: Options): Options { + if (options.includeExamples !== false) { + return options + } + + const chosenAddOns = options.chosenAddOns + .filter((addOn) => addOn.type !== 'example') + .map((addOn) => { + const filteredRoutes = (addOn.routes || []).filter( + (route) => + !isDemoFilePath(route.path) && + !(route.url && route.url.startsWith('/demo')), + ) + + const filteredIntegrations = (addOn.integrations || []).filter( + (integration) => !isDemoFilePath(integration.path), + ) + + return { + ...addOn, + routes: filteredRoutes, + integrations: filteredIntegrations, + getFiles: async () => { + const files = await addOn.getFiles() + return files.filter((file) => !isDemoFilePath(file)) + }, + getDeletedFiles: async () => { + const deletedFiles = await addOn.getDeletedFiles() + return deletedFiles.filter((file) => !isDemoFilePath(file)) + }, + } + }) + + return { + ...options, + chosenAddOns, + } +} + +async function writeFiles(environment: Environment, options: Options) { + const templateFileFromContent = createTemplateFile(environment, options) + + async function writeFileBundle(bundle: FileBundleHandler) { + const files = await bundle.getFiles() + + for (const file of files) { + const contents = await bundle.getFileContents(file) + + if (isBase64(contents)) { + await environment.writeFileBase64( + joinPaths(options.targetDir, file), + contents, + ) + } else { + await templateFileFromContent(file, contents) + } + } + + const deletedFiles = await bundle.getDeletedFiles() + for (const file of deletedFiles) { + await environment.deleteFile(joinPaths(options.targetDir, file)) + } + } + + environment.startStep({ + id: 'write-framework-files', + type: 'file', + message: 'Writing framework files...', + }) + await writeFileBundle(options.framework) + environment.finishStep('write-framework-files', 'Framework files written') + + let wroteAddonFiles = false + for (const type of ['add-on', 'example', 'toolchain', 'deployment']) { + for (const phase of ['setup', 'add-on', 'example']) { + for (const addOn of options.chosenAddOns.filter( + (addOn) => addOn.phase === phase && addOn.type === type, + )) { + environment.startStep({ + id: 'write-addon-files', + type: 'file', + message: `Writing ${addOn.name} files...`, + }) + await writeFileBundle(addOn) + wroteAddonFiles = true + } + } + } + if (wroteAddonFiles) { + environment.finishStep('write-addon-files', 'Add-on files written') + } + + if (options.starter) { + environment.startStep({ + id: 'write-starter-files', + type: 'file', + message: 'Writing starter files...', + }) + await writeFileBundle(options.starter) + environment.finishStep('write-starter-files', 'Starter files written') + } + + environment.startStep({ + id: 'write-package-json', + type: 'file', + message: 'Writing package.json...', + }) + const packageJSON = await resolvePackageJSONLatest(createPackageJSON(options)) + await environment.writeFile( + joinPaths(options.targetDir, './package.json'), + JSON.stringify(packageJSON, null, 2), + ) + environment.finishStep('write-package-json', 'Package.json written') + + environment.startStep({ + id: 'write-config-file', + type: 'file', + message: 'Writing config file...', + }) + await writeConfigFileToEnvironment(environment, options) + environment.finishStep('write-config-file', 'Config file written') +} + +async function runSpecialSteps( + environment: Environment, + options: Options, + specialSteps: Array, +) { + if (!specialSteps.length) { + return + } + + environment.startStep({ + id: 'special-steps', + type: 'command', + message: 'Running special steps...', + }) + + for (const step of specialSteps) { + if (step === 'rimraf-node-modules') { + await environment.rimraf(joinPaths(options.targetDir, 'node_modules')) + for (const lockFile of [ + 'package-lock.json', + 'yarn.lock', + 'pnpm-lock.yaml', + ]) { + const lockFilePath = joinPaths(options.targetDir, lockFile) + if (environment.exists(lockFilePath)) { + await environment.deleteFile(lockFilePath) + } + } + continue + } + + if (step === 'post-init-script') { + const packageJsonPath = joinPaths(options.targetDir, 'package.json') + if (!environment.exists(packageJsonPath)) { + environment.warn( + 'Warning', + 'No package.json found, skipping post-create-init script', + ) + continue + } + + const packageJson = JSON.parse(await environment.readFile(packageJsonPath)) + const postCreateInit = packageJson.scripts?.['post-create-init'] + if (postCreateInit) { + const { command, args } = getPackageManagerScriptCommand( + options.packageManager, + ['post-create-init'], + ) + await environment.execute(command, args, options.targetDir, { + inherit: true, + }) + } + continue + } + + environment.error(`Special step ${step} not found`) + } + + environment.finishStep('special-steps', 'Special steps complete') +} + +async function installShadcnComponents( + environment: Environment, + targetDir: string, + options: Options, +) { + if (!options.chosenAddOns.find((a) => a.id === 'shadcn')) { + return + } + + const shadcnComponents = new Set() + for (const addOn of options.chosenAddOns) { + if (addOn.shadcnComponents) { + for (const component of addOn.shadcnComponents) { + shadcnComponents.add(component) + } + } + } + if (options.starter?.shadcnComponents) { + for (const component of options.starter.shadcnComponents) { + shadcnComponents.add(component) + } + } + + if (shadcnComponents.size > 0) { + environment.startStep({ + id: 'install-shadcn-components', + type: 'command', + message: `Installing shadcn components (${Array.from(shadcnComponents).join(', ')})...`, + }) + + const { command, args } = getPackageManagerExecuteCommand( + options.packageManager, + 'shadcn@latest', + ['add', '--silent', '--yes', ...Array.from(shadcnComponents)], + ) + await environment.execute(command, args, normalizePath(targetDir)) + + environment.finishStep( + 'install-shadcn-components', + 'Shadcn components installed', + ) + } +} + +async function setupIntent( + environment: Environment, + targetDir: string, + options: Options, +) { + if (!options.intent) { + return + } + + environment.startStep({ + id: 'setup-intent', + type: 'command', + message: 'Setting up TanStack Intent skill mappings...', + }) + + const { command, args } = getPackageManagerExecuteCommand( + options.packageManager, + '@tanstack/intent', + ['install', '--map'], + ) + await environment.execute(command, args, normalizePath(targetDir)) + environment.finishStep('setup-intent', 'TanStack Intent configured') +} + +async function runCommandsAndInstallDependencies( + environment: Environment, + options: Options, +) { + const s = environment.spinner() + + if (options.git) { + s.start('Initializing git repository...') + environment.startStep({ + id: 'initialize-git-repository', + type: 'command', + message: 'Initializing git repository...', + }) + + await environment.execute('git', ['init'], normalizePath(options.targetDir)) + + environment.finishStep( + 'initialize-git-repository', + 'Initialized git repository', + ) + s.stop('Initialized git repository') + } + + const specialSteps = new Set() + for (const addOn of options.chosenAddOns) { + for (const step of addOn.createSpecialSteps || []) { + specialSteps.add(step) + } + } + if (specialSteps.size) { + await runSpecialSteps(environment, options, Array.from(specialSteps)) + } + + if (options.install !== false) { + s.start(`Installing dependencies via ${options.packageManager}...`) + environment.startStep({ + id: 'install-dependencies', + type: 'package-manager', + message: `Installing dependencies via ${options.packageManager}...`, + }) + await packageManagerInstall( + environment, + options.targetDir, + options.packageManager, + ) + environment.finishStep('install-dependencies', 'Installed dependencies') + s.stop('Installed dependencies') + } else { + s.start('Skipping dependency installation...') + environment.startStep({ + id: 'skip-dependencies', + type: 'info', + message: 'Skipping dependency installation...', + }) + environment.finishStep('skip-dependencies', 'Dependency installation skipped') + s.stop('Dependency installation skipped') + } + + const postInitSpecialSteps = new Set() + for (const addOn of options.chosenAddOns) { + for (const step of addOn.postInitSpecialSteps || []) { + postInitSpecialSteps.add(step) + } + } + if (postInitSpecialSteps.size) { + await runSpecialSteps( + environment, + options, + Array.from(postInitSpecialSteps), + ) + } + + for (const phase of ['setup', 'add-on', 'example']) { + for (const addOn of options.chosenAddOns.filter( + (addOn) => + addOn.phase === phase && addOn.command && addOn.command.command, + )) { + s.start(`Running commands for ${addOn.name}...`) + const translated = translateExecuteCommand(options.packageManager, { + command: addOn.command!.command, + args: addOn.command!.args || [], + }) + const cmd = formatCommand(translated) + environment.startStep({ + id: 'run-commands', + type: 'command', + message: cmd, + }) + await environment.execute( + translated.command, + translated.args, + options.targetDir, + { inherit: true }, + ) + environment.finishStep('run-commands', 'Setup commands complete') + s.stop(`${addOn.name} commands complete`) + } + } + + if ( + options.starter && + options.starter.command && + options.starter.command.command + ) { + s.start(`Setting up starter ${options.starter.name}...`) + const starterTranslated = translateExecuteCommand(options.packageManager, { + command: options.starter.command.command, + args: options.starter.command.args || [], + }) + const cmd = formatCommand(starterTranslated) + environment.startStep({ + id: 'run-starter-command', + type: 'command', + message: cmd, + }) + + await environment.execute( + starterTranslated.command, + starterTranslated.args, + options.targetDir, + { inherit: true }, + ) + + environment.finishStep('run-starter-command', 'Starter command complete') + s.stop(`${options.starter.name} commands complete`) + } + + await installShadcnComponents(environment, options.targetDir, options) + await setupIntent(environment, options.targetDir, options) + + if (shouldGenerateRoutes(options)) { + s.start('Generating route tree...') + const command = getPackageManagerScriptCommand(options.packageManager, [ + 'generate-routes', + ]) + const cmd = formatCommand(command) + environment.startStep({ + id: 'generate-routes', + type: 'command', + message: cmd, + }) + await environment.execute( + command.command, + command.args, + options.targetDir, + { + inherit: true, + }, + ) + environment.finishStep('generate-routes', 'Route tree generated') + s.stop('Route tree generated') + } +} + +function shouldGenerateRoutes(options: Options) { + return ( + options.install !== false && + options.mode === 'file-router' && + (options.framework.id === 'react' || options.framework.id === 'solid') + ) +} + +async function seedEnvValues(environment: Environment, options: Options) { + const envVarValues = options.envVarValues || {} + const entries = Object.entries(envVarValues) + if (entries.length === 0) return + + const envLocalPath = joinPaths(options.targetDir, '.env.local') + if (!environment.exists(envLocalPath)) { + return + } + + let envContents = await environment.readFile(envLocalPath) + for (const [key, value] of entries) { + const escapedValue = value.replace(/\n/g, '\\n') + const nextLine = `${key}=${escapedValue}` + const pattern = new RegExp(`^${key}=.*$`, 'm') + + if (pattern.test(envContents)) { + envContents = envContents.replace(pattern, nextLine) + } else { + envContents += `${envContents.endsWith('\n') ? '' : '\n'}${nextLine}\n` + } + } + + await environment.writeFile(envLocalPath, envContents) +} + +async function writeEnvExample(environment: Environment, options: Options) { + const envExamplePath = joinPaths(options.targetDir, '.env.example') + const existing = environment.exists(envExamplePath) + ? await environment.readFile(envExamplePath) + : '' + + const declared = new Set() + for (const match of existing.matchAll(/^([A-Z_][A-Z0-9_]*)=/gm)) { + declared.add(match[1]) + } + + const sections: Array = [] + for (const addOn of options.chosenAddOns) { + const lines: Array = [] + for (const envVar of addOn.envVars || []) { + if (declared.has(envVar.name)) continue + declared.add(envVar.name) + if (envVar.description) { + const required = envVar.required ? ' (required)' : '' + lines.push(`# ${envVar.description}${required}`) + } + lines.push(`${envVar.name}=`) + } + if (lines.length > 0) { + sections.push(`# ${addOn.name}\n${lines.join('\n')}`) + } + } + + if (sections.length === 0) return + + const additions = sections.join('\n\n') + const newContent = existing + ? `${existing.trimEnd()}\n\n${additions}\n` + : `${additions}\n` + + await environment.writeFile(envExamplePath, newContent) +} + +const SHIPPING_CATEGORIES = new Set(['auth', 'database', 'orm', 'deploy']) + +function buildNextSteps(options: Options): string { + const collectedEnv = new Set(Object.keys(options.envVarValues || {})) + const listedEnvVars = new Set() + const envVarLines: Array = [] + const docLines: Array = [] + + for (const addOn of options.chosenAddOns) { + if (addOn.link && addOn.category && SHIPPING_CATEGORIES.has(addOn.category)) { + docLines.push(` - ${addOn.name} (${addOn.category}): ${addOn.link}`) + } + + for (const envVar of addOn.envVars || []) { + if (listedEnvVars.has(envVar.name)) continue + listedEnvVars.add(envVar.name) + const required = envVar.required ? ' (required)' : '' + const status = collectedEnv.has(envVar.name) + ? ' - already set from your input' + : ' - needs a value' + const desc = envVar.description ? ` - ${envVar.description}` : '' + envVarLines.push(` - ${envVar.name}${required}${desc}${status}`) + } + } + + const sections: Array = [] + if (envVarLines.length > 0) { + sections.push( + `Environment variables (review/fill in .env.local before deploying):\n${envVarLines.join('\n')}`, + ) + } + if (docLines.length > 0) { + sections.push(`Docs for the integrations you picked:\n${docLines.join('\n')}`) + } + if (options.intent) { + sections.push( + `Working with an AI agent? Your agent config (AGENTS.md / CLAUDE.md) was wired up by TanStack Intent\nwith explicit skill mappings for the libraries you installed. Try asking your agent:\n - "migrate this Next.js page to TanStack Start"\n - "add a protected /dashboard route"\n - "show me how to use TanStack Router search params"`, + ) + } + + return sections.length > 0 ? `\nNext steps:\n\n${sections.join('\n\n')}\n` : '' +} + +function report(environment: Environment, options: Options) { + const warnings: Array = [] + for (const addOn of options.chosenAddOns) { + if (addOn.warning) { + warnings.push(addOn.warning) + } + } + + if (warnings.length > 0) { + environment.warn('Warnings', warnings.join('\n')) + } + + let errorStatement = '' + if (environment.getErrors().length) { + errorStatement = ` + +Errors were encountered during the creation of your app: + +${environment.getErrors().join('\n')}` + } + + const targetDir = normalizePath(options.targetDir) + const isCurrentDirectory = targetDir === '.' + const locationMessage = isCurrentDirectory + ? `Your ${environment.appName} app is ready.` + : `Your ${environment.appName} app is ready in '${basenamePath(targetDir)}'.` + const cdInstruction = isCurrentDirectory + ? '' + : `% cd ${options.projectName} +` + + const nextSteps = buildNextSteps(options) + + environment.outro( + `${locationMessage} + +Use the following commands to start your app: +${cdInstruction}% ${formatCommand( + getPackageManagerScriptCommand(options.packageManager, ['dev']), + )} +${nextSteps} +Please read the README.md file for information on testing, styling, adding routes, etc.${errorStatement}`, + ) +} + +export async function createApp(environment: Environment, options: Options) { + const effectiveOptions = stripExamplesFromOptions(options) + + environment.startRun() + await writeFiles(environment, effectiveOptions) + await seedEnvValues(environment, effectiveOptions) + await writeEnvExample(environment, effectiveOptions) + await runCommandsAndInstallDependencies(environment, effectiveOptions) + environment.finishRun() + + report(environment, effectiveOptions) +} diff --git a/packages/create/src/edge-environment.ts b/packages/create/src/edge-environment.ts new file mode 100644 index 00000000..408c62fa --- /dev/null +++ b/packages/create/src/edge-environment.ts @@ -0,0 +1,175 @@ +import { cleanUpFileArray, cleanUpFiles } from './edge-file-helpers.js' +import { + basenamePath, + dirnamePath, + joinPaths, + normalizePath, +} from './edge-path.js' + +import type { Environment } from './types.js' + +export interface MemoryEnvironmentOutput { + files: Record + deletedFiles: Array + commands: Array<{ command: string; args: Array }> +} + +function hasDirectory(files: Record, path: string) { + const directory = normalizePath(path) + const prefix = directory.endsWith('/') ? directory : `${directory}/` + return Object.keys(files).some((file) => file.startsWith(prefix)) +} + +function createMissingDirectoryError(path: string) { + return new Error(`Directory not found: ${path}`) +} + +export function createMemoryEnvironment(returnPathsRelativeTo: string = '') { + const output: MemoryEnvironmentOutput = { + files: {}, + commands: [], + deletedFiles: [], + } + const files: Record = {} + let errors: Array = [] + + const environment: Environment = { + startRun: () => { + errors = [] + output.files = {} + output.commands = [] + output.deletedFiles = [] + }, + finishRun: () => { + output.files = Object.keys(files).reduce>( + (acc, file) => { + acc[file] = files[file] + return acc + }, + {}, + ) + + if (returnPathsRelativeTo.length) { + output.files = cleanUpFiles(output.files, returnPathsRelativeTo) + output.deletedFiles = cleanUpFileArray( + output.deletedFiles, + returnPathsRelativeTo, + ) + } + }, + getErrors: () => errors, + + appendFile: (path: string, contents: string) => { + const normalized = normalizePath(path) + files[normalized] = `${files[normalized] ?? ''}${contents}` + return Promise.resolve() + }, + copyFile: (from: string, to: string) => { + const normalizedFrom = normalizePath(from) + const normalizedTo = normalizePath(to) + if (!(normalizedFrom in files)) { + throw new Error(`File not found: ${from}`) + } + files[normalizedTo] = files[normalizedFrom] + return Promise.resolve() + }, + writeFile: (path: string, contents: string) => { + files[normalizePath(path)] = contents + return Promise.resolve() + }, + writeFileBase64: (path: string, base64Contents: string) => { + files[normalizePath(path)] = base64Contents + return Promise.resolve() + }, + execute: (command: string, args: Array) => { + output.commands.push({ + command, + args, + }) + return Promise.resolve({ stdout: '' }) + }, + deleteFile: (path: string) => { + const normalized = normalizePath(path) + output.deletedFiles.push(normalized) + delete files[normalized] + return Promise.resolve() + }, + + exists: (path: string) => { + const normalized = normalizePath(path) + return normalized in files || hasDirectory(files, normalized) + }, + isDirectory: (path: string) => hasDirectory(files, path), + readFile: (path: string) => { + const normalized = normalizePath(path) + if (!(normalized in files)) { + throw new Error(`File not found: ${path}`) + } + return Promise.resolve(files[normalized]) + }, + readdir: (path: string) => { + const normalized = normalizePath(path) + const directory = normalized === '.' ? '' : normalized + const prefix = directory ? `${directory}/` : '' + + if (directory && !hasDirectory(files, directory)) { + throw createMissingDirectoryError(path) + } + + const entries = new Set() + for (const file of Object.keys(files)) { + if (!file.startsWith(prefix)) { + continue + } + + const rest = file.slice(prefix.length) + const entry = rest.split('/')[0] + if (entry) { + entries.add(entry) + } + } + + return Promise.resolve(Array.from(entries)) + }, + rimraf: (path: string) => { + const normalized = normalizePath(path) + const prefix = normalized.endsWith('/') ? normalized : `${normalized}/` + for (const file of Object.keys(files)) { + if (file === normalized || file.startsWith(prefix)) { + delete files[file] + } + } + return Promise.resolve() + }, + + appName: 'TanStack', + + startStep: () => {}, + finishStep: () => {}, + + intro: () => {}, + outro: () => {}, + info: () => {}, + error: (_title?: string, message?: string) => { + if (message) { + errors.push(message) + } + }, + warn: () => {}, + confirm: () => Promise.resolve(true), + spinner: () => ({ + start: () => {}, + stop: () => {}, + }), + } + + return { + environment, + output, + paths: { + basename: basenamePath, + dirname: dirnamePath, + join: joinPaths, + }, + } +} diff --git a/packages/create/src/edge-file-helpers.ts b/packages/create/src/edge-file-helpers.ts new file mode 100644 index 00000000..3e6afe27 --- /dev/null +++ b/packages/create/src/edge-file-helpers.ts @@ -0,0 +1,112 @@ +import { basenamePath, extnamePath } from './edge-path.js' +import { hasDrive, stripDrive } from './utils.js' + +const BINARY_EXTENSIONS = ['.png', '.jpg', '.jpeg', '.gif', '.svg', '.ico'] + +export function isBinaryFile(path: string): boolean { + return BINARY_EXTENSIONS.includes(extnamePath(path)) +} + +export function isBase64(content: string): boolean { + return content.startsWith('base64::') +} + +export function toCleanPath(absolutePath: string, baseDir: string): string { + const normalizedPath = absolutePath.replace(/\\/g, '/') + const normalizedBase = baseDir.replace(/\\/g, '/') + let cleanPath = normalizedPath + if (normalizedPath.startsWith(normalizedBase)) { + cleanPath = normalizedPath.slice(normalizedBase.length) + } else if (hasDrive(normalizedPath) !== hasDrive(normalizedBase)) { + const pathNoDrive = stripDrive(normalizedPath) + const baseNoDrive = stripDrive(normalizedBase) + if (pathNoDrive.startsWith(baseNoDrive)) { + cleanPath = pathNoDrive.slice(baseNoDrive.length) + } + } + if (cleanPath.startsWith('/')) { + cleanPath = cleanPath.slice(1) + } + return cleanPath +} + +export function relativePath( + from: string, + to: string, + stripExtension: boolean = false, +) { + const normalized = from.replace(/\\/g, '/') + const cleanedFrom = normalized.startsWith('./') + ? normalized.slice(2) + : normalized + const cleanedTo = to.startsWith('./') ? to.slice(2) : to + + const fromSegments = cleanedFrom.split('/') + const toSegments = cleanedTo.split('/') + + fromSegments.pop() + toSegments.pop() + + let commonIndex = 0 + while ( + commonIndex < fromSegments.length && + commonIndex < toSegments.length && + fromSegments[commonIndex] === toSegments[commonIndex] + ) { + commonIndex++ + } + + const upLevels = fromSegments.length - commonIndex + const downLevels = toSegments.slice(commonIndex) + const target = stripExtension ? to.replace(extnamePath(to), '') : to + + if (upLevels === 0 && downLevels.length === 0) { + return `./${basenamePath(target)}` + } else if (upLevels === 0 && downLevels.length > 0) { + return `./${downLevels.join('/')}/${basenamePath(target)}` + } else { + const relative = [...Array(upLevels).fill('..'), ...downLevels].join('/') + return `${relative}/${basenamePath(target)}` + } +} + +export function isDemoFilePath(path?: string): boolean { + if (!path) return false + const normalized = path.replace(/\\/g, '/') + + if ( + normalized.includes('/routes/demo/') || + normalized.includes('/routes/example/') + ) { + return true + } + + const filename = normalized.split('/').pop() || '' + return ( + filename.startsWith('demo.') || + filename.startsWith('demo-') || + filename.startsWith('example.') || + filename.startsWith('example-') + ) +} + +export function cleanUpFiles( + files: Record, + targetDir?: string, +) { + return Object.keys(files).reduce>((acc, file) => { + if (basenamePath(file) !== '.cta.json') { + acc[targetDir ? toCleanPath(file, targetDir) : file] = files[file] + } + return acc + }, {}) +} + +export function cleanUpFileArray(files: Array, targetDir?: string) { + return files.reduce>((acc, file) => { + if (basenamePath(file) !== '.cta.json') { + acc.push(targetDir ? toCleanPath(file, targetDir) : file) + } + return acc + }, []) +} diff --git a/packages/create/src/edge-frameworks.ts b/packages/create/src/edge-frameworks.ts new file mode 100644 index 00000000..84d65e74 --- /dev/null +++ b/packages/create/src/edge-frameworks.ts @@ -0,0 +1,54 @@ +import { createManifestFrameworks } from './generated/create-manifest.js' + +import type { + AddOn, + AddOnCompiled, + Framework, + FrameworkDefinition, +} from './types.js' + +function createAddOn(addOn: AddOnCompiled): AddOn { + return { + ...addOn, + getFiles: () => Promise.resolve(Object.keys(addOn.files)), + getFileContents: (path: string) => Promise.resolve(addOn.files[path]), + getDeletedFiles: () => Promise.resolve(addOn.deletedFiles), + } +} + +export function createFrameworkFromManifest( + framework: Omit & { + addOns: Array + }, +): Framework { + const addOns = framework.addOns.map(createAddOn) + const { addOns: _addOns, base, ...rest } = framework + + return { + ...rest, + getFiles: () => Promise.resolve(Object.keys(base)), + getFileContents: (path: string) => Promise.resolve(base[path]), + getDeletedFiles: () => Promise.resolve([]), + getAddOns: () => addOns, + } +} + +const frameworks = createManifestFrameworks().map(createFrameworkFromManifest) + +export function getFrameworkById(id: string) { + if (id === 'react-cra') { + return frameworks.find((framework) => framework.id === 'react') + } + + return frameworks.find((framework) => framework.id === id) +} + +export function getFrameworkByName(name: string) { + return frameworks.find( + (framework) => framework.name.toLowerCase() === name.toLowerCase(), + ) +} + +export function getFrameworks() { + return frameworks +} diff --git a/packages/create/src/edge-package-json.ts b/packages/create/src/edge-package-json.ts new file mode 100644 index 00000000..abeef840 --- /dev/null +++ b/packages/create/src/edge-package-json.ts @@ -0,0 +1,212 @@ +import { render } from './edge-render.js' +import { formatCommand, sortObject } from './utils.js' +import { getPackageManagerExecuteCommand } from './package-manager.js' + +import type { Options } from './types.js' + +export function mergePackageJSON( + packageJSON: Record, + overlayPackageJSON?: Record, +) { + const packageDependencies = + packageJSON.dependencies && typeof packageJSON.dependencies === 'object' + ? (packageJSON.dependencies as Record) + : {} + const overlayDependencies = + overlayPackageJSON?.dependencies && + typeof overlayPackageJSON.dependencies === 'object' + ? (overlayPackageJSON.dependencies as Record) + : {} + const packageDevDependencies = + packageJSON.devDependencies && typeof packageJSON.devDependencies === 'object' + ? (packageJSON.devDependencies as Record) + : {} + const overlayDevDependencies = + overlayPackageJSON?.devDependencies && + typeof overlayPackageJSON.devDependencies === 'object' + ? (overlayPackageJSON.devDependencies as Record) + : {} + const packageScripts = + packageJSON.scripts && typeof packageJSON.scripts === 'object' + ? (packageJSON.scripts as Record) + : {} + const overlayScripts = + overlayPackageJSON?.scripts && typeof overlayPackageJSON.scripts === 'object' + ? (overlayPackageJSON.scripts as Record) + : {} + + const mergedPackageJSON: Record = { + ...packageJSON, + ...(overlayPackageJSON || {}), + dependencies: { + ...packageDependencies, + ...overlayDependencies, + }, + devDependencies: { + ...packageDevDependencies, + ...overlayDevDependencies, + }, + scripts: { + ...packageScripts, + ...overlayScripts, + }, + } + + const packagePnpm = + packageJSON.pnpm && typeof packageJSON.pnpm === 'object' + ? (packageJSON.pnpm as Record) + : undefined + const overlayPnpm = + overlayPackageJSON?.pnpm && typeof overlayPackageJSON.pnpm === 'object' + ? (overlayPackageJSON.pnpm as Record) + : undefined + + const baseOnlyBuiltDependencies = Array.isArray( + packagePnpm?.onlyBuiltDependencies, + ) + ? packagePnpm.onlyBuiltDependencies + : [] + const overlayOnlyBuiltDependencies = Array.isArray( + overlayPnpm?.onlyBuiltDependencies, + ) + ? overlayPnpm.onlyBuiltDependencies + : [] + + const onlyBuiltDependencies = [ + ...new Set([ + ...baseOnlyBuiltDependencies, + ...overlayOnlyBuiltDependencies, + ]), + ] + + if (packagePnpm || overlayPnpm) { + mergedPackageJSON.pnpm = { + ...packagePnpm, + ...overlayPnpm, + } + + if (onlyBuiltDependencies.length) { + const mergedPnpm = mergedPackageJSON.pnpm as Record + mergedPnpm.onlyBuiltDependencies = onlyBuiltDependencies + } + } + + return mergedPackageJSON +} + +export function createPackageJSON(options: Options) { + const packageManager = options.packageManager + + function getPackageManagerExecuteScript( + pkg: string, + args: Array = [], + ) { + return formatCommand(getPackageManagerExecuteCommand(packageManager, pkg, args)) + } + + let packageJSON: Record = { + ...(JSON.parse( + JSON.stringify(options.framework.basePackageJSON), + ) as Record), + name: options.projectName, + } + + const additions: Array | undefined> = [ + options.framework.optionalPackages.typescript, + options.framework.optionalPackages.tailwindcss, + options.mode ? options.framework.optionalPackages[options.mode] : undefined, + ] + for (const addition of additions.filter( + (addition): addition is Record => Boolean(addition), + )) { + packageJSON = mergePackageJSON(packageJSON, addition) + } + + for (const addOn of options.chosenAddOns) { + let addOnPackageJSON = addOn.packageAdditions as + | Record + | undefined + + if (addOn.packageTemplate) { + const templateValues = { + packageManager: options.packageManager, + projectName: options.projectName, + typescript: true, + tailwind: true, + js: 'ts', + jsx: 'tsx', + fileRouter: options.mode === 'file-router', + codeRouter: options.mode === 'code-router', + routerOnly: options.routerOnly === true, + addOnEnabled: options.chosenAddOns.reduce>( + (acc, addon) => { + acc[addon.id] = true + return acc + }, + {}, + ), + addOnOption: options.addOnOptions, + addOns: options.chosenAddOns, + getPackageManagerExecuteScript, + } + + try { + addOnPackageJSON = JSON.parse(render(addOn.packageTemplate, templateValues)) + } catch (error) { + console.error( + `Error processing package.json.ejs for add-on ${addOn.id}:`, + error, + ) + } + } + + packageJSON = mergePackageJSON(packageJSON, addOnPackageJSON) + } + + if (options.starter) { + packageJSON = mergePackageJSON( + packageJSON, + options.starter.packageAdditions as Record | undefined, + ) + } + + const dependencies = packageJSON.dependencies as + | Record + | undefined + const devDependencies = packageJSON.devDependencies as + | Record + | undefined + const scripts = packageJSON.scripts as Record | undefined + + if (options.routerOnly) { + if (options.framework.id === 'react') { + delete dependencies?.['@tanstack/react-start'] + delete dependencies?.['@tanstack/react-router-ssr-query'] + packageJSON.devDependencies = { + ...(devDependencies ?? {}), + '@tanstack/router-plugin': + devDependencies?.['@tanstack/router-plugin'] ?? 'latest', + } + } + + if (options.framework.id === 'solid') { + delete dependencies?.['@tanstack/solid-start'] + delete dependencies?.['@tanstack/solid-router-ssr-query'] + delete scripts?.start + packageJSON.devDependencies = { + ...(devDependencies ?? {}), + '@tanstack/router-plugin': + devDependencies?.['@tanstack/router-plugin'] ?? 'latest', + } + } + } + + packageJSON.dependencies = sortObject( + (packageJSON.dependencies ?? {}) as Record, + ) + packageJSON.devDependencies = sortObject( + (packageJSON.devDependencies ?? {}) as Record, + ) + + return packageJSON +} diff --git a/packages/create/src/edge-path.ts b/packages/create/src/edge-path.ts new file mode 100644 index 00000000..3c37b6ab --- /dev/null +++ b/packages/create/src/edge-path.ts @@ -0,0 +1,77 @@ +export function normalizePath(path: string): string { + const normalized = path.replace(/\\/g, '/') + const isAbsolute = normalized.startsWith('/') + const parts: Array = [] + + for (const part of normalized.split('/')) { + if (!part || part === '.') { + continue + } + if (part === '..') { + if (parts.length && parts[parts.length - 1] !== '..') { + parts.pop() + } else if (!isAbsolute) { + parts.push(part) + } + continue + } + parts.push(part) + } + + const joined = parts.join('/') + if (isAbsolute) { + return joined ? `/${joined}` : '/' + } + + return joined || '.' +} + +export function joinPaths(...paths: Array): string { + const filtered = paths.filter( + (path): path is string => typeof path === 'string' && path.length > 0, + ) + if (!filtered.length) { + return '.' + } + + return normalizePath(filtered.join('/')) +} + +export function basenamePath(path: string): string { + const normalized = normalizePath(path) + if (normalized === '/') { + return '' + } + + return normalized.split('/').pop() ?? '' +} + +export function dirnamePath(path: string): string { + const normalized = normalizePath(path) + if (normalized === '/') { + return '/' + } + + const parts = normalized.split('/') + parts.pop() + + if (!parts.length) { + return '.' + } + + if (parts.length === 1 && parts[0] === '') { + return '/' + } + + return parts.join('/') || '.' +} + +export function extnamePath(path: string): string { + const basename = basenamePath(path) + const index = basename.lastIndexOf('.') + if (index <= 0) { + return '' + } + + return basename.slice(index) +} diff --git a/packages/create/src/edge-render.ts b/packages/create/src/edge-render.ts new file mode 100644 index 00000000..99ed3a70 --- /dev/null +++ b/packages/create/src/edge-render.ts @@ -0,0 +1,32 @@ +import { renderManifestTemplate } from './generated/create-manifest.js' + +export function render(template: string, data?: Record) { + return renderManifestTemplate(template, { + packageManager: undefined, + projectName: undefined, + typescript: undefined, + tailwind: undefined, + js: undefined, + jsx: undefined, + fileRouter: undefined, + codeRouter: undefined, + routerOnly: undefined, + includeExamples: undefined, + addOnEnabled: {}, + addOnOption: {}, + addOns: [], + integrations: [], + routes: [], + getPackageManagerAddScript: () => '', + getPackageManagerRunScript: () => '', + getPackageManagerExecuteScript: () => '', + relativePath: () => '', + integrationImportContent: () => '', + integrationImportCode: () => '', + renderTemplate: () => '', + ignoreFile: () => { + throw new Error('ignoreFile') + }, + ...(data ?? {}), + }) +} diff --git a/packages/create/src/edge-template-file.ts b/packages/create/src/edge-template-file.ts new file mode 100644 index 00000000..ceee227c --- /dev/null +++ b/packages/create/src/edge-template-file.ts @@ -0,0 +1,204 @@ +import { render } from './edge-render.js' +import { relativePath } from './edge-file-helpers.js' +import { joinPaths } from './edge-path.js' +import { formatCommand } from './utils.js' +import { + getPackageManagerExecuteCommand, + getPackageManagerInstallCommand, + getPackageManagerScriptCommand, +} from './package-manager.js' + +import type { + AddOn, + Environment, + Integration, + IntegrationWithSource, + Options, +} from './types.js' + +function convertDotFilesAndPaths(path: string) { + return path + .split('/') + .map((segment) => segment.replace(/^_dot_/, '.')) + .join('/') +} + +function normalizeSourceExtension(target: string, typescript: boolean) { + if (!typescript) { + return target + } + + const normalizedTarget = target.replace(/\\/g, '/') + + if (!normalizedTarget.startsWith('src/')) { + return target + } + + if (normalizedTarget.endsWith('.js')) { + return `${target.slice(0, -3)}.ts` + } + + if (normalizedTarget.endsWith('.jsx')) { + return `${target.slice(0, -4)}.tsx` + } + + return target +} + +export function createTemplateFile(environment: Environment, options: Options) { + function getPackageManagerAddScript( + packageName: string, + isDev: boolean = false, + ) { + return formatCommand( + getPackageManagerInstallCommand( + options.packageManager, + packageName, + isDev, + ), + ) + } + function getPackageManagerRunScript( + scriptName: string, + args: Array = [], + ) { + return formatCommand( + getPackageManagerScriptCommand(options.packageManager, [ + scriptName, + ...args, + ]), + ) + } + function getPackageManagerExecuteScript( + pkg: string, + args: Array = [], + ) { + return formatCommand( + getPackageManagerExecuteCommand(options.packageManager, pkg, args), + ) + } + + class IgnoreFileError extends Error { + constructor() { + super('ignoreFile') + this.name = 'IgnoreFileError' + } + } + + const integrations: Array = [] + for (const addOn of options.chosenAddOns) { + if (addOn.integrations) { + for (const integration of addOn.integrations) { + integrations.push({ + ...integration, + _sourceId: addOn.id, + _sourceName: addOn.name, + }) + } + } + } + + const routes: Array['routes'][number]> = [] + for (const addOn of options.chosenAddOns) { + if (addOn.routes) { + routes.push(...addOn.routes) + } + } + + const addOnEnabled = options.chosenAddOns.reduce>( + (acc, addOn) => { + acc[addOn.id] = true + return acc + }, + {}, + ) + + return async function templateFile(file: string, content: string) { + const localRelativePath = (path: string, stripExtension: boolean = false) => + relativePath(file, path, stripExtension) + + const integrationImportContent = (integration: Integration) => + integration.import || + `import ${integration.jsName} from '${localRelativePath(integration.path || '')}'` + + const integrationImportCode = (integration: Integration) => + integration.code || integration.jsName + + const templateValues = { + packageManager: options.packageManager, + projectName: options.projectName, + typescript: true, + tailwind: true, + js: 'ts', + jsx: 'tsx', + fileRouter: options.mode === 'file-router', + codeRouter: options.mode === 'code-router', + routerOnly: options.routerOnly === true, + includeExamples: options.includeExamples !== false, + addOnEnabled, + addOnOption: options.addOnOptions, + addOns: options.chosenAddOns, + integrations, + routes, + + getPackageManagerAddScript, + getPackageManagerRunScript, + getPackageManagerExecuteScript, + + relativePath: (path: string, stripExtension: boolean = false) => + relativePath(file, path, stripExtension), + + integrationImportContent, + integrationImportCode, + + renderTemplate: (templateContent: string) => { + return render(templateContent, templateValues) + }, + + ignoreFile: () => { + throw new IgnoreFileError() + }, + } + + let ignoreFile = false + + if (file.endsWith('.ejs')) { + try { + content = render(content, templateValues) + } catch (error) { + if (error instanceof IgnoreFileError) { + ignoreFile = true + } else { + const message = error instanceof Error ? error.message : String(error) + environment.error(`EJS error in file ${file}`, message) + throw error + } + } + } + + if (ignoreFile) { + return + } + + let target = convertDotFilesAndPaths(file.replace('.ejs', '')) + target = normalizeSourceExtension(target, options.typescript) + + const prefixMatch = target.match(/^(.+\/)?__([^_]+)__(.+)$/) + if (prefixMatch) { + const [, directory, , filename] = prefixMatch + target = (directory || '') + filename + } + + let append = false + if (target.endsWith('.append')) { + append = true + target = target.replace('.append', '') + } + + if (append) { + await environment.appendFile(joinPaths(options.targetDir, target), content) + } else { + await environment.writeFile(joinPaths(options.targetDir, target), content) + } + } +} diff --git a/packages/create/src/edge.ts b/packages/create/src/edge.ts new file mode 100644 index 00000000..fc65f7f1 --- /dev/null +++ b/packages/create/src/edge.ts @@ -0,0 +1,43 @@ +export { createApp } from './edge-create-app.js' +export { + createMemoryEnvironment, + type MemoryEnvironmentOutput, +} from './edge-environment.js' +export { + getFrameworkById, + getFrameworkByName, + getFrameworks, +} from './edge-frameworks.js' +export { + finalizeAddOns, + getAllAddOns, + loadRemoteAddOn, + populateAddOnOptionsDefaults, +} from './edge-add-ons.js' +export { createSerializedOptions } from './options.js' +export { CONFIG_FILE } from './constants.js' +export { + DEFAULT_PACKAGE_MANAGER, + SUPPORTED_PACKAGE_MANAGERS, + getPackageManagerExecuteCommand, + getPackageManagerInstallCommand, + getPackageManagerScriptCommand, + translateExecuteCommand, +} from './package-manager.js' + +export type { + AddOn, + AddOnOption, + AddOnOptions, + AddOnSelectOption, + AddOnSelection, + Environment, + FileBundleHandler, + Framework, + FrameworkDefinition, + Options, + SerializedOptions, + Starter, + StarterCompiled, +} from './types.js' +export type { PackageManager } from './package-manager.js' diff --git a/packages/create/src/manifest-types.ts b/packages/create/src/manifest-types.ts new file mode 100644 index 00000000..64883bd1 --- /dev/null +++ b/packages/create/src/manifest-types.ts @@ -0,0 +1,8 @@ +import type { AddOnCompiled, FrameworkDefinition } from './types.js' + +export type ManifestFrameworkDefinition = Omit< + FrameworkDefinition, + 'addOns' +> & { + addOns: Array +} diff --git a/packages/create/src/manifest.ts b/packages/create/src/manifest.ts new file mode 100644 index 00000000..0c646a1f --- /dev/null +++ b/packages/create/src/manifest.ts @@ -0,0 +1 @@ +export * from './edge.js' diff --git a/packages/create/src/types.ts b/packages/create/src/types.ts index 3f385510..ef4106ce 100644 --- a/packages/create/src/types.ts +++ b/packages/create/src/types.ts @@ -72,8 +72,9 @@ export const AddOnBaseSchema = z.object({ z.object({ url: z.string().optional(), name: z.string().optional(), - path: z.string(), - jsName: z.string(), + path: z.string().optional(), + jsName: z.string().optional(), + icon: z.string().optional(), }), ) .optional(), @@ -84,6 +85,7 @@ export const AddOnBaseSchema = z.object({ scripts: z.record(z.string(), z.string()).optional(), }) .optional(), + variables: z.array(z.unknown()).optional(), shadcnComponents: z.array(z.string()).optional(), dependsOn: z.array(z.string()).optional(), smallLogo: z.string().optional(), @@ -130,7 +132,7 @@ export const IntegrationSchema = z.object({ export const AddOnInfoSchema = AddOnBaseSchema.extend({ modes: z.array(z.string()), integrations: z.array(IntegrationSchema).optional(), - phase: z.enum(['setup', 'add-on']), + phase: z.enum(['setup', 'add-on', 'example']), readme: z.string().optional(), readmeIsEjs: z.boolean().optional(), }) diff --git a/packages/create/tests/edge-import.test.ts b/packages/create/tests/edge-import.test.ts new file mode 100644 index 00000000..814effc7 --- /dev/null +++ b/packages/create/tests/edge-import.test.ts @@ -0,0 +1,31 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +const blockedModules = [ + 'node:fs', + 'node:fs/promises', + 'node:path', + 'node:url', + 'execa', +] + +describe('@tanstack/create/edge import', () => { + afterEach(() => { + for (const moduleName of blockedModules) { + vi.doUnmock(moduleName) + } + vi.resetModules() + }) + + it('does not import Node-only modules', async () => { + vi.resetModules() + for (const moduleName of blockedModules) { + vi.doMock(moduleName, () => { + throw new Error(`${moduleName} is unavailable`) + }) + } + + const edge = await import('../src/edge.js') + + expect(edge.getFrameworkById('react')?.id).toBe('react') + }) +}) diff --git a/packages/create/tests/edge-manifest.test.ts b/packages/create/tests/edge-manifest.test.ts new file mode 100644 index 00000000..090e3e8a --- /dev/null +++ b/packages/create/tests/edge-manifest.test.ts @@ -0,0 +1,168 @@ +import { describe, expect, it, vi } from 'vitest' + +import { + createApp, + createMemoryEnvironment, + finalizeAddOns, + getAllAddOns, + getFrameworkById, + populateAddOnOptionsDefaults, +} from '../src/edge.js' +import { getAllAddOns as getAllNodeAddOns } from '../src/add-ons.js' +import { createFrameworkDefinition as createReactFrameworkDefinition } from '../src/frameworks/react/index.js' +import { createFrameworkDefinition as createSolidFrameworkDefinition } from '../src/frameworks/solid/index.js' + +import type { + AddOn, + Framework, + FrameworkDefinition, + Options, +} from '../src/types.js' + +function frameworkFromDefinition(definition: FrameworkDefinition): Framework { + const { addOns, base, ...rest } = definition + + return { + ...rest, + getFiles: () => Promise.resolve(Object.keys(base)), + getFileContents: (path: string) => Promise.resolve(base[path]), + getDeletedFiles: () => Promise.resolve([]), + getAddOns: () => addOns, + } +} + +async function materializeFiles(bundle: { + getFiles: () => Promise> + getFileContents: (path: string) => Promise +}) { + const files: Record = {} + for (const file of (await bundle.getFiles()).sort()) { + files[file] = await bundle.getFileContents(file) + } + return files +} + +async function materializeAddOn(addOn: AddOn) { + const { + getFiles: _getFiles, + getFileContents: _getFileContents, + getDeletedFiles: _getDeletedFiles, + files: _files, + deletedFiles: _deletedFiles, + ...metadata + } = addOn + + return JSON.parse( + JSON.stringify({ + ...metadata, + files: await materializeFiles(addOn), + deletedFiles: (await addOn.getDeletedFiles()).sort(), + }), + ) +} + +async function materializeAddOns(addOns: Array) { + const materialized = await Promise.all(addOns.map(materializeAddOn)) + return materialized.sort((a, b) => a.id.localeCompare(b.id)) +} + +describe('@tanstack/create/edge manifest', () => { + it.each([ + ['react', createReactFrameworkDefinition], + ['solid', createSolidFrameworkDefinition], + ])('matches the Node-scanned %s framework catalog', async (frameworkId, scan) => { + const nodeDefinition = scan() + const nodeFramework = frameworkFromDefinition(nodeDefinition) + const edgeFramework = getFrameworkById(frameworkId) + + expect(edgeFramework).toBeDefined() + expect(edgeFramework?.id).toBe(nodeDefinition.id) + expect(edgeFramework?.name).toBe(nodeDefinition.name) + expect(edgeFramework?.description).toBe(nodeDefinition.description) + expect(edgeFramework?.version).toBe(nodeDefinition.version) + expect(edgeFramework?.supportedModes).toEqual(nodeDefinition.supportedModes) + expect(edgeFramework?.basePackageJSON).toEqual( + nodeDefinition.basePackageJSON, + ) + expect(edgeFramework?.optionalPackages).toEqual( + nodeDefinition.optionalPackages, + ) + expect(await materializeFiles(edgeFramework!)).toEqual(nodeDefinition.base) + expect(await materializeAddOns(edgeFramework!.getAddOns())).toEqual( + await materializeAddOns(nodeFramework.getAddOns()), + ) + }) + + it('returns the same React add-ons as the Node filesystem-backed path', () => { + const nodeFramework = frameworkFromDefinition(createReactFrameworkDefinition()) + const edgeFramework = getFrameworkById('react') + + expect(edgeFramework).toBeDefined() + expect(getAllAddOns(edgeFramework!, 'file-router').map((addOn) => addOn.id)) + .toEqual( + getAllNodeAddOns(nodeFramework, 'file-router').map((addOn) => addOn.id), + ) + }) + + it('generates a React app from the manifest-backed catalog', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => { + return new Response(JSON.stringify({ version: '1.0.0' }), { + status: 200, + }) + }), + ) + + const framework = getFrameworkById('react') + expect(framework).toBeDefined() + + const featureIds = getAllAddOns(framework!, 'file-router').map( + (addOn) => addOn.id, + ) + expect(featureIds).toContain('tanstack-query') + expect(featureIds).toContain('clerk') + expect(featureIds).toContain('cloudflare') + + const chosenAddOns = await finalizeAddOns(framework!, 'file-router', [ + 'tanstack-query', + 'clerk', + 'cloudflare', + 'biome', + ]) + const addOnOptions = populateAddOnOptionsDefaults(chosenAddOns) + const { environment, output } = createMemoryEnvironment('/worker-app') + + await createApp(environment, { + projectName: 'worker-app', + targetDir: '/worker-app', + framework: framework!, + mode: 'file-router', + typescript: true, + tailwind: true, + packageManager: 'pnpm', + git: false, + install: false, + intent: false, + chosenAddOns, + addOnOptions, + includeExamples: false, + } satisfies Options) + + const packageJSON = JSON.parse(output.files['package.json']) + + expect(packageJSON.scripts.dev).toBe('vite dev --port 3000') + expect(packageJSON.scripts.deploy).toBe( + 'pnpm run build && wrangler deploy', + ) + expect(packageJSON.dependencies).toHaveProperty('@tanstack/react-start') + expect(packageJSON.dependencies).toHaveProperty('@tanstack/react-query') + expect(packageJSON.dependencies).toHaveProperty('@clerk/clerk-react') + expect(packageJSON.devDependencies).toHaveProperty('wrangler') + expect(output.files['wrangler.jsonc']).toContain('tanstack-start-app') + expect(output.files['.env.example']).toContain( + 'VITE_CLERK_PUBLISHABLE_KEY=', + ) + expect(output.files['src/routes/index.tsx']).toContain('createFileRoute') + }) +})