diff options
Diffstat (limited to 'packages/create-astro/src/actions/template.ts')
-rw-r--r-- | packages/create-astro/src/actions/template.ts | 169 |
1 files changed, 169 insertions, 0 deletions
diff --git a/packages/create-astro/src/actions/template.ts b/packages/create-astro/src/actions/template.ts new file mode 100644 index 000000000..cf58d90a8 --- /dev/null +++ b/packages/create-astro/src/actions/template.ts @@ -0,0 +1,169 @@ +import type { Context } from './context.js'; + +import fs from 'node:fs'; +import path from 'node:path'; +import { color } from '@astrojs/cli-kit'; +import { downloadTemplate } from '@bluwy/giget-core'; +import { error, info, title } from '../messages.js'; + +export async function template( + ctx: Pick<Context, 'template' | 'prompt' | 'yes' | 'dryRun' | 'exit' | 'tasks'>, +) { + if (!ctx.template && ctx.yes) ctx.template = 'basics'; + + if (ctx.template) { + await info('tmpl', `Using ${color.reset(ctx.template)}${color.dim(' as project template')}`); + } else { + const { template: tmpl } = await ctx.prompt({ + name: 'template', + type: 'select', + label: title('tmpl'), + message: 'How would you like to start your new project?', + initial: 'basics', + choices: [ + { value: 'basics', label: 'A basic, helpful starter project', hint: '(recommended)' }, + { value: 'blog', label: 'Use blog template' }, + { value: 'starlight', label: 'Use docs (Starlight) template' }, + { value: 'minimal', label: 'Use minimal (empty) template' }, + ], + }); + ctx.template = tmpl; + } + + if (ctx.dryRun) { + await info('--dry-run', `Skipping template copying`); + } else if (ctx.template) { + ctx.tasks.push({ + pending: 'Template', + start: 'Template copying...', + end: 'Template copied', + while: () => + copyTemplate(ctx.template!, ctx as Context).catch((e) => { + if (e instanceof Error) { + error('error', e.message); + process.exit(1); + } else { + error('error', 'Unable to clone template.'); + process.exit(1); + } + }), + }); + } else { + ctx.exit(1); + } +} + +// some files are only needed for online editors when using astro.new. Remove for create-astro installs. +const FILES_TO_REMOVE = ['CHANGELOG.md', '.codesandbox']; +const FILES_TO_UPDATE = { + 'package.json': (file: string, overrides: { name: string }) => + fs.promises.readFile(file, 'utf-8').then((value) => { + // Match first indent in the file or fallback to `\t` + const indent = /(^\s+)/m.exec(value)?.[1] ?? '\t'; + return fs.promises.writeFile( + file, + JSON.stringify( + Object.assign(JSON.parse(value), Object.assign(overrides, { private: undefined })), + null, + indent, + ), + 'utf-8', + ); + }), +}; + +export function getTemplateTarget(tmpl: string, ref = 'latest') { + // Handle Starlight templates + if (tmpl.startsWith('starlight')) { + const [, starter = 'basics'] = tmpl.split('/'); + return `github:withastro/starlight/examples/${starter}`; + } + + // Handle third-party templates + const isThirdParty = tmpl.includes('/'); + if (isThirdParty) return tmpl; + + // Handle Astro templates + if (ref === 'latest') { + // `latest` ref is specially handled to route to a branch specifically + // to allow faster downloads. Otherwise giget has to download the entire + // repo and only copy a sub directory + return `github:withastro/astro#examples/${tmpl}`; + } else { + return `github:withastro/astro/examples/${tmpl}#${ref}`; + } +} + +async function copyTemplate(tmpl: string, ctx: Context) { + const templateTarget = getTemplateTarget(tmpl, ctx.ref); + // Copy + if (!ctx.dryRun) { + try { + await downloadTemplate(templateTarget, { + force: true, + cwd: ctx.cwd, + dir: '.', + }); + + // Modify the README file to reflect the correct package manager + if (ctx.packageManager !== 'npm') { + const readmePath = path.resolve(ctx.cwd, 'README.md'); + const readme = fs.readFileSync(readmePath, 'utf8'); + + // `run` is removed since it's optional in other package managers + const updatedReadme = readme + .replace(/\bnpm run\b/g, ctx.packageManager) + .replace(/\bnpm\b/g, ctx.packageManager); + + fs.writeFileSync(readmePath, updatedReadme); + } + } catch (err: any) { + // Only remove the directory if it's most likely created by us. + if (ctx.cwd !== '.' && ctx.cwd !== './' && !ctx.cwd.startsWith('../')) { + try { + fs.rmdirSync(ctx.cwd); + } catch (_) { + // Ignore any errors from removing the directory, + // make sure we throw and display the original error. + } + } + + if (err.message?.includes('404')) { + throw new Error(`Template ${color.reset(tmpl)} ${color.dim('does not exist!')}`); + } + + if (err.message) { + error('error', err.message); + } + try { + // The underlying error is often buried deep in the `cause` property + // This is in a try/catch block in case of weirdnesses in accessing the `cause` property + if ('cause' in err) { + // This is probably included in err.message, but we can log it just in case it has extra info + error('error', err.cause); + if ('cause' in err.cause) { + // Hopefully the actual fetch error message + error('error', err.cause?.cause); + } + } + } catch {} + throw new Error(`Unable to download template ${color.reset(tmpl)}`); + } + + // Post-process in parallel + const removeFiles = FILES_TO_REMOVE.map(async (file) => { + const fileLoc = path.resolve(path.join(ctx.cwd, file)); + if (fs.existsSync(fileLoc)) { + return fs.promises.rm(fileLoc, { recursive: true }); + } + }); + const updateFiles = Object.entries(FILES_TO_UPDATE).map(async ([file, update]) => { + const fileLoc = path.resolve(path.join(ctx.cwd, file)); + if (fs.existsSync(fileLoc)) { + return update(fileLoc, { name: ctx.projectName! }); + } + }); + + await Promise.all([...removeFiles, ...updateFiles]); + } +} |