create-astro UI (#164)

* refactor: improve create-astro layout, build script

* feat(create-astro): v0.1.0

* docs(create-astro): add README

* feat(create-astro): add meta files to starter templates
This commit is contained in:
Nate Moore 2021-05-03 12:15:13 -05:00 committed by GitHub
parent 467820996f
commit ed631329e7
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
44 changed files with 2870 additions and 1699 deletions

View file

@ -0,0 +1,7 @@
---
'create-astro': minor
---
Added **interactive mode** with a fesh new UI.
Included a new **blank** starter to get up and running even faster.

View file

@ -0,0 +1,36 @@
# create-astro
## Scaffolding for Astro projects
**With NPM:**
```bash
npm init astro
```
**With Yarn:**
```bash
yarn create astro
```
`create-astro` automatically runs in _interactive_ mode, but you can also specify your project name and template with command line arguments.
```bash
# npm 6.x
npm init astro my-astro-project --template blank
# npm 7+, extra double-dash is needed:
npm init astro my-astro-project -- --template blank
# yarn
yarn create astro my-astro-project --template blank
```
To see all available options, use the `--help` flag.
### Templates
The following templates are included:
- `starter`
- `blank`
Feel free to [open a PR](https://github.com/snowpackjs/astro/pulls) to add additional templates.

View file

@ -1,4 +1,3 @@
#!/usr/bin/env node
import cli from './index.js';
import cli from './dist/index.js';
cli(process.argv);

View file

@ -1,47 +0,0 @@
import * as fs from 'fs';
import { resolve } from 'path';
import decompress from 'decompress';
import { fileURLToPath, URL } from 'url';
import { join } from 'node:path';
const log = (...args) => console.log(' ', ...args);
export default async function createAstro(argv) {
const [name] = argv.slice(2);
const templateRoot = fileURLToPath(new URL('../create-astro/templates', import.meta.url));
if (!name) {
log();
log(`npm init astro <dest>`);
log(`Provide a destination!`);
process.exit(0);
}
log();
const dest = resolve(process.cwd(), name);
const relDest = name.slice(0, 2) === './' ? name : `./${name}`;
if (isEmpty(relDest)) {
await decompress(fs.readFileSync(join(templateRoot, 'starter.tar.gz')), dest);
log(`Your Astro project has been scaffolded at "${relDest}"`);
log();
log(`Next steps:`);
log();
log(` cd ${relDest}`);
log(` npm install`);
log(` npm run start`);
}
}
function isEmpty(path) {
try {
const files = fs.readdirSync(resolve(process.cwd(), path));
if (files.length > 0) {
log(`It looks like "${path}" isn't empty!`);
return false;
} else {
log(`Scaffolding Astro project at "${path}"`);
return true;
}
} catch (err) {
if (err.code !== 'ENOENT') throw err;
}
return true;
}

View file

@ -2,19 +2,29 @@
"name": "create-astro",
"version": "0.0.9",
"type": "module",
"main": "./index.js",
"exports": {
".": "./create-astro.js"
},
"bin": {
"create-astro": "./create-astro.js"
},
"scripts": {
"build": "echo 'build'"
"build": "astro-scripts build src/index.tsx",
"postbuild": "astro-scripts copy 'src/templates/**' --tgz"
},
"files": [
"templates",
"bin.js",
"index.js"
"dist",
"create-astro.js"
],
"bin": {
"create-astro": "./bin.js"
},
"dependencies": {
"decompress": "^4.2.1"
"decompress": "^4.2.1",
"ink": "^3.0.8",
"ink-select-input": "^4.2.0",
"ink-text-input": "^4.0.1",
"react": "~17.0.2",
"source-map-support": "^0.5.19"
},
"devDependencies": {
"astro-scripts": "0.0.1"
}
}

View file

@ -0,0 +1,93 @@
import React, {FC, useEffect} from 'react';
import { prepareTemplate, isEmpty, emptyDir } from '../utils';
import Header from './Header';
import Install from './Install';
import ProjectName from './ProjectName';
import Template from './Template';
import Confirm from './Confirm';
import Finalize from './Finalize';
interface Context {
use: 'npm'|'yarn';
run: boolean;
projectExists?: boolean;
force?: boolean;
projectName?: string;
template?: string;
templates: string[];
ready?: boolean;
}
const getStep = ({ projectName, projectExists: exists, template, force, ready }: Context) => {
switch (true) {
case !projectName: return {
key: 'projectName',
Component: ProjectName
};
case projectName && exists === true && typeof force === 'undefined': return {
key: 'force',
Component: Confirm
}
case (exists === false || force) && !template: return {
key: 'template',
Component: Template
};
case !ready: return {
key: 'install',
Component: Install
};
default: return {
key: 'final',
Component: Finalize
}
}
}
const App: FC<{ context: Context }> = ({ context }) => {
const [state, setState] = React.useState(context);
const step = React.useRef(getStep(context));
const onSubmit = (value: string|boolean) => {
const { key } = step.current;
const newState = { ...state, [key]: value };
step.current = getStep(newState)
setState(newState)
}
useEffect(() => {
let isSubscribed = true
if (state.projectName && typeof state.projectExists === 'undefined') {
const newState = { ...state, projectExists: !isEmpty(state.projectName) };
step.current = getStep(newState)
if (isSubscribed) {
setState(newState);
}
}
if (state.projectName && (state.projectExists === false || state.force) && state.template) {
if (state.force) emptyDir(state.projectName);
prepareTemplate(context.use, state.template, state.projectName).then(() => {
if (isSubscribed) {
setState(v => {
const newState = {...v, ready: true };
step.current = getStep(newState);
return newState;
});
}
});
}
return () => {
isSubscribed = false;
}
}, [state]);
const { Component } = step.current;
return (
<>
<Header context={state}/>
<Component context={state} onSubmit={onSubmit} />
</>
)
};
export default App;

View file

@ -0,0 +1,49 @@
import React, { FC } from 'react';
import { Box, Text, useInput, useApp } from 'ink';
import Spacer from './Spacer';
import Select from './Select';
const Confirm: FC<{ message?: any; context: any; onSubmit: (value: boolean) => void }> = ({ message, context: { projectName }, onSubmit }) => {
const { exit } = useApp();
const handleSubmit = (v: boolean) => {
if (!v) return exit();
onSubmit(v);
};
return (
<>
<Box display="flex">
{!message ? (
<>
<Text color="#FFBE2D">{'[uh-oh]'}</Text>
<Text>
{' '}
It appears <Text color="#17C083">./{projectName}</Text> is not empty. Overwrite?
</Text>
</>
) : (
message
)}
</Box>
<Box display="flex">
<Spacer width={6} />
<Select
items={[
{
value: false,
label: 'no'
},
{
value: true,
label: 'yes',
description: <Text color="#FF1639">overwrite</Text>,
},
]}
onSelect={handleSubmit}
/>
</Box>
</>
);
};
export default Confirm;

View file

@ -0,0 +1,5 @@
import React from 'react';
import { Text } from 'ink';
import { isWin } from '../utils';
export default ({ children }) => isWin() ? null : <Text>{children}</Text>

View file

@ -0,0 +1,9 @@
import React, { FC } from 'react';
import { Box, Text } from 'ink';
import { isDone } from '../utils';
const Exit: FC<{ didError?: boolean }> = ({ didError }) => isDone ? null : <Box marginTop={1} display="flex">
<Text color={didError ? "#FF1639" : "#FFBE2D"}>[abort]</Text>
<Text> astro cancelled</Text>
</Box>
export default Exit;

View file

@ -0,0 +1,27 @@
import React, { FC, useEffect } from 'react';
import { Box, Text } from 'ink';
import { cancelProcessListeners } from '../utils';
const Finalize: FC<{ context: any }> = ({ context: { use, projectName } }) => {
useEffect(() => {
cancelProcessListeners();
process.exit(0);
}, []);
return <>
<Box display="flex">
<Text color="#17C083">{'[ yes ]'}</Text>
<Text> Project initialized at <Text color="#3894FF">./{projectName}</Text></Text>
</Box>
<Box display="flex" marginY={1}>
<Text dimColor>{'[ tip ]'}</Text>
<Box display="flex" marginLeft={1} flexDirection="column">
<Text>Get started by running</Text>
<Text color="#3894FF">cd ./{projectName}</Text>
<Text color="#3894FF">{use} start</Text>
</Box>
</Box>
</>;
};
export default Finalize;

View file

@ -0,0 +1,20 @@
import React from 'react';
import { Box, Text } from 'ink';
const getMessage = ({ projectName, template }) => {
switch (true) {
case !projectName: return <Text dimColor>Gathering mission details</Text>;
case !template: return <Text dimColor>Optimizing navigational system</Text>;
default: return <Text color="black" backgroundColor="white"> {projectName} </Text>
}
}
const Header: React.FC<{ context: any }> = ({ context }) => (
<Box width={48} display="flex" marginY={1}>
<Text backgroundColor="#882DE7" color="white">{' astro '}</Text>
<Box marginLeft={1}>
{getMessage(context)}
</Box>
</Box>
)
export default Header;

View file

@ -0,0 +1,62 @@
import React, { FC } from 'react';
import { Box, Text } from 'ink';
import { ARGS, ARG } from '../config';
const Type: FC<{ type: any, enum?: string[] }> = ({ type, enum: e }) => {
if (type === Boolean) {
return <>
<Text color="#3894FF">true</Text>
<Text dimColor>|</Text>
<Text color="#3894FF">false</Text>
</>
}
if (e?.length > 0) {
return <>
{e.map((item, i, { length: len}) => {
if (i !== len - 1) {
return <Box key={item}>
<Text color="#17C083">{item}</Text>
<Text dimColor>|</Text>
</Box>
}
return <Text color="#17C083" key={item}>{item}</Text>
})}
</>
}
return <Text color="#3894FF">string</Text>;
}
const Command: FC<{ name: string, info: ARG }> = ({ name, info: { alias, description, type, enum: e } }) => {
return (
<Box display="flex" alignItems="flex-start">
<Box width={24} display="flex" flexGrow={0}>
<Text color="whiteBright">--{name}</Text>{alias && <Text dimColor> -{alias}</Text>}
</Box>
<Box width={24}>
<Type type={type} enum={e} />
</Box>
<Box>
<Text>{description}</Text>
</Box>
</Box>
);
}
const Help: FC<{ context: any }> = ({ context: { templates }}) => {
return (
<>
<Box width={48} display="flex" marginY={1}>
<Text backgroundColor="#882DE7" color="white">{' astro '}</Text>
<Box marginLeft={1}>
<Text color="black" backgroundColor="white"> help </Text>
</Box>
</Box>
<Box marginBottom={1} marginLeft={2} display="flex" flexDirection="column">
{Object.entries(ARGS).map(([name, info]) => <Command key={name} name={name} info={name === 'template' ? { ...info, enum: templates.map(({ value }) => value) } : info} /> )}
</Box>
</>
)
};
export default Help;

View file

@ -0,0 +1,19 @@
import React, { FC } from 'react';
import { Box, Text } from 'ink';
import Spacer from './Spacer';
import Spinner from './Spinner';
const Install: FC<{ context: any }> = ({ context: { use } }) => {
return <>
<Box display="flex">
<Spinner/>
<Text> Initiating launch sequence...</Text>
</Box>
<Box>
<Spacer />
<Text color="white" dimColor>(aka running <Text color="#17C083">{use === 'npm' ? 'npm install' : 'yarn'}</Text>)</Text>
</Box>
</>;
};
export default Install;

View file

@ -0,0 +1,24 @@
import React, { FC } from 'react';
import { Box, Text } from 'ink';
import Spacer from './Spacer';
import TextInput from 'ink-text-input';
// @ts-expect-error
const { default: Input } = TextInput;
const ProjectName: FC<{ onSubmit: (value: string) => void }> = ({ onSubmit }) => {
const [value, setValue] = React.useState('');
const handleSubmit = (v: string) => onSubmit(v);
return <>
<Box display="flex">
<Text color="#17C083">{'[query]'}</Text>
<Text> What is your project name?</Text>
</Box>
<Box display="flex">
<Spacer />
<Input value={value} onChange={setValue} onSubmit={handleSubmit} placeholder="my-project" />
</Box>
</>;
};
export default ProjectName;

View file

@ -0,0 +1,32 @@
import SelectInput from 'ink-select-input';
import React, { FC } from 'react';
import { Text, Box } from 'ink';
// @ts-expect-error
const { default: Select } = SelectInput;
interface Props {
isSelected?: boolean;
label: string;
description?: string;
}
const Indicator: FC<Props> = ({ isSelected }) => isSelected ? <Text color="#3894FF">[ </Text> : <Text> </Text>
const Item: FC<Props> = ({isSelected = false, label, description }) => (
<Box display="flex">
<Text color={isSelected ? '#3894FF' : 'white'} dimColor={!isSelected}>{label}</Text>
{isSelected && description && typeof description === 'string' && <Text> {description}</Text>}
{isSelected && description && typeof description !== 'string' && <Box marginLeft={1}>{description}</Box>}
</Box>
);
interface SelectProps {
items: { value: string|number|boolean, label: string, description?: any }[]
onSelect(value: string|number|boolean): void;
}
const CustomSelect: FC<SelectProps> = ({ items, onSelect }) => {
const handleSelect = ({ value }) => onSelect(value);
return (
<Select indicatorComponent={Indicator} itemComponent={Item} items={items} onSelect={handleSelect} />
)
}
export default CustomSelect;

View file

@ -0,0 +1,5 @@
import React, { FC } from 'react';
import { Box } from 'ink';
const Spacer: FC<{ width?: number }> = ({ width = 8 }) => <Box width={width} />
export default Spacer;

View file

@ -0,0 +1,200 @@
import React, { FC, useEffect, useState } from 'react';
import { Box, Text } from 'ink';
const Spinner: FC<{ type?: keyof typeof spinners }> = ({ type = 'countdown' }) => {
const { interval, frames } = spinners[type];
const [i, setI] = useState(0);
useEffect(() => {
const _ = setInterval(() => {
setI(v => (v < frames.length - 1) ? v + 1 : 0)
}, interval)
return () => clearInterval(_);
}, [])
return frames[i]
}
const spinners = {
countdown: {
interval: 80,
frames: [
<Box display="flex">
<Text backgroundColor="#882DE7">{' '}</Text>
</Box>,
<Box display="flex">
<Text backgroundColor="#882DE7">{' '}</Text>
</Box>,
<Box display="flex">
<Text backgroundColor="#882DE7">{' '}</Text>
</Box>,
<Box display="flex">
<Text backgroundColor="#882DE7">{' '}</Text>
</Box>,
<Box display="flex">
<Text backgroundColor="#882DE7">{' '}</Text>
</Box>,
<Box display="flex">
<Text backgroundColor="#6858F1">{' '}</Text>
<Text backgroundColor="#882DE7">{' '}</Text>
</Box>,
<Box display="flex">
<Text backgroundColor="#5076F9">{' '}</Text>
<Text backgroundColor="#6858F1">{' '}</Text>
<Text backgroundColor="#882DE7">{' '}</Text>
</Box>,
<Box display="flex">
<Text backgroundColor="#3894FF">{' '}</Text>
<Text backgroundColor="#5076F9">{' '}</Text>
<Text backgroundColor="#6858F1">{' '}</Text>
<Text backgroundColor="#882DE7">{' '}</Text>
</Box>,
<Box display="flex">
<Text backgroundColor="#2CA5D2">{' '}</Text>
<Text backgroundColor="#3894FF">{' '}</Text>
<Text backgroundColor="#5076F9">{' '}</Text>
<Text backgroundColor="#6858F1">{' '}</Text>
<Text backgroundColor="#882DE7">{' '}</Text>
</Box>,
<Box display="flex">
<Text backgroundColor="#23B1AF">{' '}</Text>
<Text backgroundColor="#2CA5D2">{' '}</Text>
<Text backgroundColor="#3894FF">{' '}</Text>
<Text backgroundColor="#5076F9">{' '}</Text>
<Text backgroundColor="#6858F1">{' '}</Text>
<Text backgroundColor="#882DE7">{' '}</Text>
</Box>,
<Box display="flex">
<Text backgroundColor="#17C083">{' '}</Text>
<Text backgroundColor="#23B1AF">{' '}</Text>
<Text backgroundColor="#2CA5D2">{' '}</Text>
<Text backgroundColor="#3894FF">{' '}</Text>
<Text backgroundColor="#5076F9">{' '}</Text>
<Text backgroundColor="#6858F1">{' '}</Text>
<Text backgroundColor="#882DE7">{' '}</Text>
</Box>,
<Box display="flex">
<Text backgroundColor="#17C083">{' '}</Text>
<Text backgroundColor="#23B1AF">{' '}</Text>
<Text backgroundColor="#2CA5D2">{' '}</Text>
<Text backgroundColor="#3894FF">{' '}</Text>
<Text backgroundColor="#5076F9">{' '}</Text>
<Text backgroundColor="#6858F1">{' '}</Text>
</Box>,
<Box display="flex">
<Text backgroundColor="#17C083">{' '}</Text>
<Text backgroundColor="#23B1AF">{' '}</Text>
<Text backgroundColor="#2CA5D2">{' '}</Text>
<Text backgroundColor="#3894FF">{' '}</Text>
<Text backgroundColor="#5076F9">{' '}</Text>
</Box>,
<Box display="flex">
<Text backgroundColor="#17C083">{' '}</Text>
<Text backgroundColor="#23B1AF">{' '}</Text>
<Text backgroundColor="#2CA5D2">{' '}</Text>
<Text backgroundColor="#3894FF">{' '}</Text>
</Box>,
<Box display="flex">
<Text backgroundColor="#17C083">{' '}</Text>
<Text backgroundColor="#23B1AF">{' '}</Text>
<Text backgroundColor="#2CA5D2">{' '}</Text>
</Box>,
<Box display="flex">
<Text backgroundColor="#17C083">{' '}</Text>
<Text backgroundColor="#23B1AF">{' '}</Text>
</Box>,
<Box display="flex">
<Text backgroundColor="#17C083">{' '}</Text>
</Box>,
<Box display="flex">
<Text backgroundColor="#17C083">{' '}</Text>
</Box>,
<Box display="flex">
<Text backgroundColor="#17C083">{' '}</Text>
</Box>,
<Box display="flex">
<Text backgroundColor="#17C083">{' '}</Text>
</Box>,
<Box display="flex">
<Text backgroundColor="#17C083">{' '}</Text>
</Box>,
<Box display="flex">
<Text backgroundColor="#17C083">{' '}</Text>
</Box>,
<Box display="flex">
<Text backgroundColor="#23B1AF">{' '}</Text>
<Text backgroundColor="#17C083">{' '}</Text>
</Box>,
<Box display="flex">
<Text backgroundColor="#2CA5D2">{' '}</Text>
<Text backgroundColor="#23B1AF">{' '}</Text>
<Text backgroundColor="#17C083">{' '}</Text>
</Box>,
<Box display="flex">
<Text backgroundColor="#3894FF">{' '}</Text>
<Text backgroundColor="#2CA5D2">{' '}</Text>
<Text backgroundColor="#23B1AF">{' '}</Text>
<Text backgroundColor="#17C083">{' '}</Text>
</Box>,
<Box display="flex">
<Text backgroundColor="#5076F9">{' '}</Text>
<Text backgroundColor="#3894FF">{' '}</Text>
<Text backgroundColor="#2CA5D2">{' '}</Text>
<Text backgroundColor="#23B1AF">{' '}</Text>
<Text backgroundColor="#17C083">{' '}</Text>
</Box>,
<Box display="flex">
<Text backgroundColor="#6858F1">{' '}</Text>
<Text backgroundColor="#5076F9">{' '}</Text>
<Text backgroundColor="#3894FF">{' '}</Text>
<Text backgroundColor="#2CA5D2">{' '}</Text>
<Text backgroundColor="#23B1AF">{' '}</Text>
<Text backgroundColor="#17C083">{' '}</Text>
</Box>,
<Box display="flex">
<Text backgroundColor="#882DE7">{' '}</Text>
<Text backgroundColor="#6858F1">{' '}</Text>
<Text backgroundColor="#5076F9">{' '}</Text>
<Text backgroundColor="#3894FF">{' '}</Text>
<Text backgroundColor="#2CA5D2">{' '}</Text>
<Text backgroundColor="#23B1AF">{' '}</Text>
<Text backgroundColor="#17C083">{' '}</Text>
</Box>,
<Box display="flex">
<Text backgroundColor="#882DE7">{' '}</Text>
<Text backgroundColor="#6858F1">{' '}</Text>
<Text backgroundColor="#5076F9">{' '}</Text>
<Text backgroundColor="#3894FF">{' '}</Text>
<Text backgroundColor="#2CA5D2">{' '}</Text>
<Text backgroundColor="#23B1AF">{' '}</Text>
</Box>,
<Box display="flex">
<Text backgroundColor="#882DE7">{' '}</Text>
<Text backgroundColor="#6858F1">{' '}</Text>
<Text backgroundColor="#5076F9">{' '}</Text>
<Text backgroundColor="#3894FF">{' '}</Text>
<Text backgroundColor="#2CA5D2">{' '}</Text>
</Box>,
<Box display="flex">
<Text backgroundColor="#882DE7">{' '}</Text>
<Text backgroundColor="#6858F1">{' '}</Text>
<Text backgroundColor="#5076F9">{' '}</Text>
<Text backgroundColor="#3894FF">{' '}</Text>
</Box>,
<Box display="flex">
<Text backgroundColor="#882DE7">{' '}</Text>
<Text backgroundColor="#6858F1">{' '}</Text>
<Text backgroundColor="#5076F9">{' '}</Text>
</Box>,
<Box display="flex">
<Text backgroundColor="#882DE7">{' '}</Text>
<Text backgroundColor="#6858F1">{' '}</Text>
</Box>,
<Box display="flex">
<Text backgroundColor="#882DE7">{' '}</Text>
</Box>,
]
}
}
export default Spinner;

View file

@ -0,0 +1,23 @@
import React, { FC } from 'react';
import { Box, Text } from 'ink';
import Spacer from './Spacer';
import Select from './Select';
const Template: FC<{ context: any, onSubmit: (value: string) => void }> = ({ context: { templates }, onSubmit }) => {
const items = templates.map(({ title: label, ...rest }) => ({ ...rest, label }));
return (
<>
<Box display="flex">
<Text color="#17C083">{'[query]'}</Text>
<Text> Which template should be used?</Text>
</Box>
<Box display="flex">
<Spacer width={6} />
<Select items={items} onSelect={onSubmit} />
</Box>
</>
);
};
export default Template;

View file

@ -0,0 +1,6 @@
import React, { FC } from 'react';
import { Text } from 'ink';
import pkg from '../../package.json';
const Version: FC = () => <Text color="#17C083">v{pkg.version}</Text>;
export default Version;

View file

@ -0,0 +1,49 @@
import type * as arg from 'arg';
export interface ARG {
type: any;
description: string;
enum?: string[];
alias?: string;
}
export const ARGS: Record<string, ARG> = {
'template': {
type: String,
description: 'specifies template to use'
},
'use': {
type: String,
enum: ['npm', 'yarn'],
description: 'specifies package manager to use'
},
'run': {
type: Boolean,
description: 'should dependencies be installed automatically?'
},
'force': {
type: Boolean,
alias: 'f',
description: 'should existing files be overwritten?'
},
'version': {
type: Boolean,
alias: 'v',
description: 'prints current version'
},
'help': {
type: Boolean,
alias: 'h',
description: 'prints this message'
}
}
export const args = Object.entries(ARGS).reduce((acc, [name, info]) => {
const key = `--${name}`;
const spec = { ...acc, [key]: info.type };
if (info.alias) {
spec[`-${info.alias}`] = key;
}
return spec
}, {} as arg.Spec);

View file

@ -0,0 +1,46 @@
import 'source-map-support/register.js';
import React from 'react';
import App from './components/App';
import Version from './components/Version';
import Exit from './components/Exit';
import {render} from 'ink';
import { getTemplates, addProcessListeners } from './utils';
import { args as argsConfig } from './config';
import arg from 'arg';
import Help from './components/Help';
/** main `create-astro` CLI */
export default async function createAstro() {
const args = arg(argsConfig);
const projectName = args._[0];
if (args['--version']) {
return render(<Version />);
}
const templates = await getTemplates();
if (args['--help']) {
return render(<Help context={{ templates }} />)
}
const pkgManager = /yarn/.test(process.env.npm_execpath) ? 'yarn' : 'npm';
const use = (args['--use'] ?? pkgManager) as 'npm'|'yarn';
const template = args['--template'];
const force = args['--force'];
const run = args['--run'] ?? true;
const app = render(<App context={{ projectName, template, templates, force, run, use }} />);
const onError = () => {
if (app) app.clear();
render(<Exit didError />);
}
const onExit = () => {
if (app) app.clear();
render(<Exit />);
}
addProcessListeners([
['uncaughtException', onError],
['exit', onExit],
['SIGINT', onExit],
['SIGTERM', onExit],
])
}

View file

@ -0,0 +1,24 @@
# Welcome to [Astro](https://astro.build)
> 🧑‍🚀 **Seasoned astronaut?** Delete this file. Have fun!
## 🚀 Project Structure
Inside of your Astro project, you'll see the following folders and files:
```
/
├── public/
├── src/
│ └── pages/
│ └── index.astro
└── package.json
```
Astro looks for `.astro` or `.md.astro` files in the `src/pages/` directory. Each page is exposed as a route based on its file name.
Any static assets, like images, can be placed in the `public/` directory.
## 👀 Want to learn more?
Feel free to check [our documentation](https://github.com/snowpackjs/astro) or jump into our [Discord server](https://discord.gg/EsGdSGen).

View file

@ -0,0 +1,18 @@
# build output
dist
# dependencies
node_modules/
.snowpack/
# logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# environment variables
.env
.env.production
# macOS-specific files
.DS_Store

View file

@ -0,0 +1,4 @@
{
"title": "Blank",
"description": "a bare-bones, ultra-minimal template"
}

View file

@ -1,6 +1,5 @@
{
"name": "@example/starter",
"private": true,
"name": "TODO",
"version": "0.0.1",
"scripts": {
"start": "astro dev",
@ -8,8 +7,5 @@
},
"devDependencies": {
"astro": "0.0.9"
},
"snowpack": {
"workspaceRoot": "../.."
}
}

View file

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

View file

@ -0,0 +1,15 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Astro</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
</head>
<body>
<main>
<h1>Welcome to <a href="https://astro.build/">Astro</a></h1>
</main>
</body>
</html>

View file

@ -0,0 +1,18 @@
# build output
dist
# dependencies
node_modules/
.snowpack/
# logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# environment variables
.env
.env.production
# macOS-specific files
.DS_Store

View file

@ -0,0 +1,5 @@
{
"title": "Getting Started",
"description": "a friendly starting point for new astronauts",
"rank": 999
}

View file

@ -0,0 +1,11 @@
{
"name": "TODO",
"version": "0.0.1",
"scripts": {
"start": "astro dev",
"build": "astro build"
},
"devDependencies": {
"astro": "0.0.9"
}
}

View file

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

View file

@ -0,0 +1,11 @@
<svg width="256" height="256" fill="none" xmlns="http://www.w3.org/2000/svg">
<style>
#flame { fill: #FF5D01; }
#a { fill: #000014; }
@media (prefers-color-scheme: dark) {
#a { fill: #fff; }
}
</style>
<path id="a" fill-rule="evenodd" clip-rule="evenodd" d="M163.008 18.929c1.944 2.413 2.935 5.67 4.917 12.181l43.309 142.27a180.277 180.277 0 00-51.778-17.53l-28.198-95.29a3.67 3.67 0 00-7.042.01l-27.857 95.232a180.225 180.225 0 00-52.01 17.557l43.52-142.281c1.99-6.502 2.983-9.752 4.927-12.16a15.999 15.999 0 016.484-4.798c2.872-1.154 6.271-1.154 13.07-1.154h31.085c6.807 0 10.211 0 13.086 1.157a16.004 16.004 0 016.487 4.806z" />
<path id="flame" fill-rule="evenodd" clip-rule="evenodd" d="M168.19 180.151c-7.139 6.105-21.39 10.268-37.804 10.268-20.147 0-37.033-6.272-41.513-14.707-1.602 4.835-1.961 10.367-1.961 13.902 0 0-1.056 17.355 11.015 29.426 0-6.268 5.081-11.349 11.349-11.349 10.743 0 10.731 9.373 10.721 16.977v.679c0 11.542 7.054 21.436 17.086 25.606a23.27 23.27 0 01-2.339-10.2c0-11.008 6.463-15.107 13.974-19.87 5.976-3.79 12.616-8.001 17.192-16.449a31.024 31.024 0 003.743-14.82c0-3.299-.513-6.479-1.463-9.463z" />
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View file

@ -0,0 +1,147 @@
import { ChildProcess, spawn } from 'child_process';
import { promises as fs, readdirSync, existsSync, lstatSync, rmdirSync, unlinkSync } from 'fs';
import { basename, resolve } from 'path';
import { fileURLToPath, URL } from 'url';
import decompress from 'decompress';
const listeners = new Map();
export async function addProcessListeners(handlers: [NodeJS.Signals|string, NodeJS.SignalsListener][]) {
for (const [event,handler] of handlers) {
listeners.set(event, handler);
process.once(event as NodeJS.Signals, handler);
}
}
export async function cancelProcessListeners() {
for (const [event, handler] of listeners.entries()) {
process.off(event, handler);
listeners.delete(event);
}
}
export async function getTemplates() {
const templatesRoot = fileURLToPath(new URL('./templates', import.meta.url));
const templateFiles = await fs.readdir(templatesRoot, 'utf8');
const templates = templateFiles.filter(t => t.endsWith('.tgz'));
const metafile = templateFiles.find(t => t.endsWith('meta.json'));
const meta = await fs.readFile(resolve(templatesRoot, metafile)).then(r => JSON.parse(r.toString()));
return templates.map(template => {
const value = basename(template, '.tgz');
if (meta[value]) return { ...meta[value], value };
return { value };
}).sort((a, b) => {
const aRank = a.rank ?? 0;
const bRank = b.rank ?? 0;
if (aRank > bRank) return -1;
if (bRank > aRank) return 1;
return 0;
});
}
const childrenProcesses: ChildProcess[] = [];
export let isDone = false;
export async function rewriteFiles(projectName: string) {
const dest = resolve(projectName);
const tasks = [];
tasks.push(fs.rename(resolve(dest, '_gitignore'), resolve(dest, '.gitignore')));
tasks.push(
fs.readFile(resolve(dest, 'package.json'))
.then(res => JSON.parse(res.toString()))
.then(json => JSON.stringify({ ...json, name: getValidPackageName(projectName) }, null, 2))
.then(res => fs.writeFile(resolve(dest, 'package.json'), res))
);
return Promise.all(tasks);
}
export async function prepareTemplate(use: 'npm'|'yarn', name: string, dest: string) {
const projectName = dest;
dest = resolve(dest);
const template = fileURLToPath(new URL(`./templates/${name}.tgz`, import.meta.url));
await decompress(template, dest);
await rewriteFiles(projectName);
try {
await run(use, use === 'npm' ? 'i' : null, dest);
} catch (e) {
cleanup(true);
}
isDone = true;
return;
}
export function cleanup(didError = false) {
killChildren();
setTimeout(() => {
process.exit(didError ? 1 : 0);
}, 200);
}
export function killChildren() {
childrenProcesses.forEach(p => p.kill('SIGINT'));
}
export function run(pkgManager: 'npm'|'yarn', command: string, projectPath: string, stdio: any = 'ignore'): Promise<void> {
return new Promise((resolve, reject) => {
const p = spawn(pkgManager, command ? [command] : [], {
shell: true,
stdio,
cwd: projectPath,
});
p.once('exit', () => resolve());
p.once('error', reject);
childrenProcesses.push(p);
});
}
export function isWin() {
return process.platform === 'win32';
}
export function isEmpty(path) {
try {
const files = readdirSync(resolve(path));
if (files.length > 0) {
return false;
} else {
return true;
}
} catch (err) {
if (err.code !== 'ENOENT') throw err;
}
return true;
}
export function emptyDir(dir) {
dir = resolve(dir);
if (!existsSync(dir)) {
return
}
for (const file of readdirSync(dir)) {
const abs = resolve(dir, file)
if (lstatSync(abs).isDirectory()) {
emptyDir(abs)
rmdirSync(abs)
} else {
unlinkSync(abs)
}
}
}
export function getValidPackageName(projectName: string) {
const packageNameRegExp = /^(?:@[a-z0-9-*~][a-z0-9-*._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/
if (packageNameRegExp.test(projectName)) {
return projectName
}
return projectName
.trim()
.toLowerCase()
.replace(/\s+/g, '-')
.replace(/^[._]/, '')
.replace(/[^a-z0-9-~]+/g, '-');
}

View file

@ -1,11 +1,74 @@
import { promises as fs } from 'fs';
import { resolve, dirname } from 'path';
import glob from 'tiny-glob';
import { promises as fs, readFileSync } from 'fs';
import { resolve, dirname, sep, join } from 'path';
import arg from 'arg';
import glob from 'globby';
import tar from 'tar';
export default async function copy(pattern, ...args) {
const files = await glob(pattern, { filesOnly: true });
/** @type {import('arg').Spec} */
const spec = {
'--tgz': Boolean,
};
export default async function copy() {
let { _: patterns, ['--tgz']: isCompress } = arg(spec);
patterns = patterns.slice(1);
if (isCompress) {
const files = await glob(patterns, { gitignore: true });
const rootDir = resolveRootDir(files);
const destDir = rootDir.replace(/^[^/]+/, 'dist');
const templates = files.reduce((acc, curr) => {
const name = curr.replace(rootDir, '').slice(1).split(sep)[0];
if (acc[name]) {
acc[name].push(resolve(curr));
} else {
acc[name] = [resolve(curr)];
}
return acc;
}, {});
let meta = {};
return Promise.all(
Object.entries(templates).map(([template, files]) => {
const cwd = resolve(join(rootDir, template));
const dest = join(destDir, `${template}.tgz`);
const metafile = files.find(f => f.endsWith('meta.json'));
if (metafile) {
files = files.filter(f => f !== metafile);
meta[template] = JSON.parse(readFileSync(metafile).toString());
}
return fs.mkdir(dirname(dest), { recursive: true }).then(() => tar.create({
gzip: true,
portable: true,
file: dest,
cwd,
}, files.map(f => f.replace(cwd, '').slice(1))));
})
).then(() => {
if (Object.keys(meta).length > 0) {
return fs.writeFile(resolve(destDir, 'meta.json'), JSON.stringify(meta, null, 2));
}
});
}
const files = await glob(patterns);
await Promise.all(files.map(file => {
const dest = resolve(file.replace(/^[^/]+/, 'dist'));
return fs.mkdir(dirname(dest), { recursive: true }).then(() => fs.copyFile(resolve(file), dest))
}));
}
function resolveRootDir(files) {
return files
.reduce((acc, curr) => {
const currParts = curr.split(sep);
if (acc.length === 0) return currParts;
const result = [];
currParts.forEach((part, i) => {
if (acc[i] === part) result.push(part);
});
return result;
}, [])
.join(sep);
}

View file

@ -8,6 +8,9 @@
"astro-scripts": "./index.js"
},
"dependencies": {
"esbuild": "^0.11.16"
"arg": "^5.0.0",
"esbuild": "^0.11.16",
"globby": "^11.0.3",
"tar": "^6.1.0"
}
}

3434
yarn.lock

File diff suppressed because it is too large Load diff