├── .prettierignore ├── assets └── done.mp3 ├── .npmignore ├── src ├── api │ └── index.js ├── generators │ ├── project │ │ ├── templates │ │ │ ├── _non_sfc │ │ │ │ └── src │ │ │ │ │ └── components │ │ │ │ │ ├── Worm │ │ │ │ │ └── styles.scss │ │ │ │ │ ├── ErrorBox │ │ │ │ │ └── styles.scss │ │ │ │ │ └── App │ │ │ │ │ └── styles.scss │ │ │ ├── _common │ │ │ │ ├── .prettierrc │ │ │ │ ├── tsconfig.json │ │ │ │ ├── README.md │ │ │ │ ├── _.gitignore │ │ │ │ ├── .editorconfig │ │ │ │ ├── package.json │ │ │ │ ├── public │ │ │ │ │ └── index.html │ │ │ │ ├── LICENSE │ │ │ │ └── src │ │ │ │ │ └── components │ │ │ │ │ └── Worm │ │ │ │ │ └── worm.svg │ │ │ ├── basic │ │ │ │ └── src │ │ │ │ │ ├── components │ │ │ │ │ ├── Worm │ │ │ │ │ │ ├── index.test.ts │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── App │ │ │ │ │ │ ├── index.test.ts │ │ │ │ │ │ └── index.ts │ │ │ │ │ └── ErrorBox │ │ │ │ │ │ └── index.ts │ │ │ │ │ └── index.ts │ │ │ ├── svelte │ │ │ │ └── src │ │ │ │ │ ├── components │ │ │ │ │ ├── Worm │ │ │ │ │ │ ├── Worm.svelte │ │ │ │ │ │ └── Worm.test.ts │ │ │ │ │ └── App │ │ │ │ │ │ ├── App.test.ts │ │ │ │ │ │ └── App.svelte │ │ │ │ │ └── index.ts │ │ │ ├── react │ │ │ │ └── src │ │ │ │ │ ├── components │ │ │ │ │ ├── Worm │ │ │ │ │ │ ├── index.tsx │ │ │ │ │ │ └── index.test.tsx │ │ │ │ │ ├── ErrorBox │ │ │ │ │ │ └── index.tsx │ │ │ │ │ └── App │ │ │ │ │ │ ├── index.test.tsx │ │ │ │ │ │ └── index.tsx │ │ │ │ │ └── index.tsx │ │ │ └── preact │ │ │ │ └── src │ │ │ │ ├── components │ │ │ │ ├── Worm │ │ │ │ │ ├── index.tsx │ │ │ │ │ └── index.test.tsx │ │ │ │ ├── ErrorBox │ │ │ │ │ └── index.tsx │ │ │ │ └── App │ │ │ │ │ ├── index.tsx │ │ │ │ │ └── index.test.tsx │ │ │ │ └── index.tsx │ │ └── index.js │ └── component │ │ ├── templates │ │ ├── _non_sfc │ │ │ └── styles.scss │ │ ├── basic │ │ │ ├── component-with-d3.test.ts │ │ │ ├── component.test.ts │ │ │ ├── component.ts │ │ │ └── component-with-d3.ts │ │ ├── svelte │ │ │ ├── component.test.ts │ │ │ ├── component-with-d3.test.ts │ │ │ ├── component.svelte │ │ │ └── component-with-d3.svelte │ │ ├── react │ │ │ ├── component.test.tsx │ │ │ ├── component-with-d3.test.tsx │ │ │ ├── component.tsx │ │ │ └── component-with-d3.tsx │ │ └── preact │ │ │ ├── component-with-d3.test.tsx │ │ │ ├── component.test.tsx │ │ │ ├── component.tsx │ │ │ └── component-with-d3.tsx │ │ └── index.js ├── constants.js ├── utils │ ├── color.js │ ├── async.js │ ├── audio.js │ ├── branding.js │ ├── structures.js │ ├── npm.js │ ├── text.js │ ├── logging.js │ ├── ftp.js │ ├── remote.js │ └── git.js ├── config │ ├── jest │ │ ├── transformer-svelte-preprocess.js │ │ ├── index.js │ │ ├── transformer-svelte.js │ │ └── transformer-default.js │ ├── build.js │ ├── webpackDevServer.js │ ├── project.js │ ├── babel.js │ ├── deploy.js │ ├── serve.js │ └── webpack.js ├── cli │ ├── clean │ │ ├── constants.js │ │ └── index.js │ ├── test │ │ ├── constants.js │ │ └── index.js │ ├── sign-cert │ │ ├── constants.js │ │ └── index.js │ ├── serve │ │ ├── constants.js │ │ └── index.js │ ├── generate │ │ ├── constants.js │ │ └── index.js │ ├── build │ │ ├── constants.js │ │ └── index.js │ ├── deploy │ │ ├── constants.js │ │ └── index.js │ ├── release │ │ ├── constants.js │ │ └── index.js │ ├── constants.js │ └── index.js └── bin │ └── aunty.js ├── .prettierrc ├── .gitignore ├── .vscode └── settings.json ├── .eslintrc ├── .editorconfig ├── ts ├── config.json └── custom.d.ts ├── LICENSE ├── .github └── workflows │ └── tests.yml ├── CONTRIBUTING.md ├── scripts └── postinstall.js ├── package.json ├── test └── test.js └── README.md /.prettierignore: -------------------------------------------------------------------------------- 1 | **/templates/**/* 2 | -------------------------------------------------------------------------------- /assets/done.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/abcnews/aunty/HEAD/assets/done.mp3 -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .editorconfig 2 | .eslintrc 3 | .prettierrc 4 | CONTRIBUTING.md 5 | assets 6 | -------------------------------------------------------------------------------- /src/api/index.js: -------------------------------------------------------------------------------- 1 | const pkg = require('../../package'); 2 | 3 | module.exports = { 4 | version: pkg.version 5 | }; 6 | -------------------------------------------------------------------------------- /src/generators/project/templates/_non_sfc/src/components/Worm/styles.scss: -------------------------------------------------------------------------------- 1 | .root { 2 | width: 240px; 3 | height: auto; 4 | } 5 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "arrowParens": "avoid", 3 | "printWidth": 120, 4 | "singleQuote": true, 5 | "trailingComma": "none" 6 | } 7 | -------------------------------------------------------------------------------- /src/generators/project/templates/_common/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "arrowParens": "avoid", 3 | "printWidth": 120, 4 | "singleQuote": true, 5 | "trailingComma": "none" 6 | } 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # dependencies 2 | node_modules 3 | 4 | # logs 5 | npm-debug.log 6 | 7 | # misc 8 | .DS_Store 9 | 10 | # jest temporary folder 11 | .jest-test-projects 12 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "eslint.enable": true, 3 | "files.associations": { 4 | "**/templates/**/*": "plaintext" 5 | }, 6 | "git.ignoreLimitWarning": true 7 | } 8 | -------------------------------------------------------------------------------- /src/generators/component/templates/_non_sfc/styles.scss: -------------------------------------------------------------------------------- 1 | .root { 2 | border: 1px solid rgb(255, 115, 0); 3 | padding: 20px; 4 | text-align: center; 5 | color: black; 6 | } 7 | -------------------------------------------------------------------------------- /src/generators/project/templates/_common/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@abcnews/aunty/ts/config.json", 3 | "include": ["src/**/*", "node_modules/@abcnews/aunty/ts/*"] 4 | } 5 | -------------------------------------------------------------------------------- /src/generators/project/templates/_common/README.md: -------------------------------------------------------------------------------- 1 | # <%= projectName %> 2 | 3 | A project generated from [aunty](https://github.com/abcnews/aunty)'s `<%= projectType %>` project template. 4 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["prettier"], 3 | "parser": "babel-eslint", 4 | "parserOptions": { 5 | "ecmaVersion": 2019, 6 | "ecmaFeatures": { 7 | "jsx": true 8 | } 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/generators/project/templates/_common/_.gitignore: -------------------------------------------------------------------------------- 1 | # dependencies 2 | /node_modules 3 | 4 | # environment 5 | .env 6 | 7 | # output 8 | /<%= OUTPUT_DIRECTORY_NAME %> 9 | 10 | # misc 11 | .DS_Store 12 | npm-debug.log 13 | -------------------------------------------------------------------------------- /src/generators/project/templates/basic/src/components/Worm/index.test.ts: -------------------------------------------------------------------------------- 1 | import Worm from './index'; 2 | 3 | test('it renders', () => { 4 | const component = new Worm(); 5 | 6 | expect(component.el.src).toContain('worm.svg'); 7 | }); 8 | -------------------------------------------------------------------------------- /src/generators/component/templates/basic/component-with-d3.test.ts: -------------------------------------------------------------------------------- 1 | import <%= className %> from '.'; 2 | 3 | test('it renders', () => { 4 | const component = new <%= className %>(); 5 | 6 | expect(component.el.innerHTML).toContain(' from '.'; 2 | 3 | test('it renders', () => { 4 | const component = new <%= className %>(); 5 | 6 | expect(component.el.innerHTML).toContain('Find me in'); 7 | }); 8 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = false 10 | 11 | [*.md] 12 | trim_trailing_whitespace = false 13 | -------------------------------------------------------------------------------- /src/generators/project/templates/_common/.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = false 10 | 11 | [*.md] 12 | trim_trailing_whitespace = false 13 | -------------------------------------------------------------------------------- /src/constants.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | OUTPUT_DIRECTORY_NAME: '.aunty', 3 | BUILD_DIRECTORY_NAME: 'build', 4 | DEPLOY_FILE_NAME: 'deploy.json', 5 | PROJECT_CONFIG_FILE_NAME: 'aunty.config.js', 6 | INTERNAL_TEST_HOST: 'master-news-web.news-web-developer.presentation-layer.abc-prod.net.au' 7 | }; 8 | -------------------------------------------------------------------------------- /src/utils/color.js: -------------------------------------------------------------------------------- 1 | // External 2 | const chalk = require('chalk'); 3 | 4 | module.exports = Object.assign(chalk, { 5 | bad: chalk.red, 6 | cmd: chalk.yellow, 7 | hvy: chalk.bold, 8 | ok: chalk.green, 9 | opt: chalk.cyan, 10 | req: chalk.magenta, 11 | sec: chalk.bold.underline 12 | }); 13 | -------------------------------------------------------------------------------- /src/generators/project/templates/svelte/src/components/Worm/Worm.svelte: -------------------------------------------------------------------------------- 1 | lang="ts"<% } %>> 2 | import worm from './worm.svg'; 3 | 4 | 5 | 6 | 7 | 13 | -------------------------------------------------------------------------------- /src/config/jest/transformer-svelte-preprocess.js: -------------------------------------------------------------------------------- 1 | const svelte = require('svelte/compiler'); 2 | const sveltePreprocess = require('svelte-preprocess'); 3 | 4 | const { src, filename } = process.env; 5 | 6 | svelte.preprocess(src, sveltePreprocess(), { filename }).then(r => { 7 | process.stdout.write(r.code); 8 | }); 9 | -------------------------------------------------------------------------------- /src/cli/clean/constants.js: -------------------------------------------------------------------------------- 1 | // Ours 2 | const { cmd, hvy } = require('../../utils/color'); 3 | const { inlineList } = require('../../utils/text'); 4 | 5 | module.exports.MESSAGES = { 6 | clean: paths => `Clean: 7 | ┗ ${hvy('paths')} ${inlineList(paths)}`, 8 | usage: name => `Usage: ${cmd(`aunty ${name}`)} 9 | ` 10 | }; 11 | -------------------------------------------------------------------------------- /src/generators/project/templates/react/src/components/Worm/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styles from './styles.scss'; 3 | import worm from './worm.svg'; 4 | 5 | const Worm<% if (isTS) { %>: React.FC<% } %> = () => { 6 | return ; 7 | }; 8 | 9 | export default Worm; 10 | -------------------------------------------------------------------------------- /src/generators/component/templates/svelte/component.test.ts: -------------------------------------------------------------------------------- 1 | import { render } from '@testing-library/svelte' 2 | import <%= className %> from './<%= className %>.svelte'; 3 | 4 | describe('<%= className %>', () => { 5 | it('renders a snapshot', () => { 6 | const { container } = render(<%= className %>); 7 | 8 | expect(container).toMatchSnapshot(); 9 | }); 10 | }); 11 | -------------------------------------------------------------------------------- /src/generators/component/templates/svelte/component-with-d3.test.ts: -------------------------------------------------------------------------------- 1 | import { render } from '@testing-library/svelte' 2 | import <%= className %> from './<%= className %>.svelte'; 3 | 4 | describe('<%= className %>', () => { 5 | it('renders a snapshot', () => { 6 | const { container } = render(<%= className %>); 7 | 8 | expect(container).toMatchSnapshot(); 9 | }); 10 | }); 11 | -------------------------------------------------------------------------------- /src/generators/project/templates/basic/src/components/Worm/index.ts: -------------------------------------------------------------------------------- 1 | import styles from './styles.scss'; 2 | import worm from './worm.svg'; 3 | 4 | export default class Worm {<% if (isTS) { %> 5 | el: HTMLImageElement; 6 | <% } %> 7 | constructor() { 8 | this.el = document.createElement('img'); 9 | this.el.className = styles.root; 10 | this.el.src = worm; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/generators/project/templates/preact/src/components/Worm/index.tsx: -------------------------------------------------------------------------------- 1 | import { h<% if (isTS) { %>, type FunctionalComponent<% } %> } from 'preact'; 2 | import styles from './styles.scss'; 3 | import worm from './worm.svg'; 4 | 5 | const Worm<% if (isTS) { %>: FunctionalComponent<% } %> = () => { 6 | return ; 7 | }; 8 | 9 | export default Worm; 10 | -------------------------------------------------------------------------------- /src/generators/component/templates/basic/component.ts: -------------------------------------------------------------------------------- 1 | import styles from './styles.scss'; 2 | 3 | export default class <%= className %> {<% if (isTS) { %> 4 | el: Element; 5 | <% } %> 6 | constructor() { 7 | this.el = document.createElement('div'); 8 | this.el.className = styles.root; 9 | this.el.innerHTML = `Find me in src/components/<%= className %>`; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/generators/component/templates/svelte/component.svelte: -------------------------------------------------------------------------------- 1 | lang="ts"<% } %>> 2 | 3 | 4 | 5 |
6 | Find me in 7 | src/components/<%= className %> 8 |
9 | 10 | 18 | -------------------------------------------------------------------------------- /src/generators/project/templates/react/src/components/Worm/index.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import renderer from 'react-test-renderer'; 3 | 4 | import Worm from '.'; 5 | 6 | describe('Worm', () => { 7 | test('It renders', () => { 8 | const component = renderer.create(); 9 | 10 | let tree = component.toJSON(); 11 | expect(tree).toMatchSnapshot(); 12 | }); 13 | }); 14 | -------------------------------------------------------------------------------- /src/generators/project/templates/basic/src/components/App/index.test.ts: -------------------------------------------------------------------------------- 1 | import App from './index';<% if (isTS) { %> 2 | import type { AppProps } from './index';<% } %> 3 | 4 | test('it renders', () => { 5 | const props<% if (isTS) { %>: AppProps<% } %> = { x: 42, y: 'text', z: true }; 6 | const component = new App(props); 7 | 8 | expect(component.el.innerHTML).toContain(JSON.stringify(props)); 9 | }); 10 | -------------------------------------------------------------------------------- /src/generators/project/templates/_non_sfc/src/components/ErrorBox/styles.scss: -------------------------------------------------------------------------------- 1 | .root { 2 | position: fixed; 3 | overflow: auto; 4 | left: 0; 5 | top: 0; 6 | z-index: 10000; 7 | box-sizing: border-box; 8 | margin: 0; 9 | padding: 2rem; 10 | width: 100vw; 11 | height: 100vh; 12 | background-color: #900; 13 | color: #fff; 14 | font-family: Menlo, Consolas, monospace; 15 | font-size: large; 16 | } 17 | -------------------------------------------------------------------------------- /src/generators/component/templates/react/component.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import renderer from 'react-test-renderer'; 3 | 4 | import <%= className %> from '.'; 5 | 6 | describe('<%= className %>', () => { 7 | test('It renders', () => { 8 | const component = renderer.create(<<%= className %> />); 9 | 10 | let tree = component.toJSON(); 11 | expect(tree).toMatchSnapshot(); 12 | }); 13 | }); 14 | -------------------------------------------------------------------------------- /src/generators/component/templates/react/component-with-d3.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import renderer from 'react-test-renderer'; 3 | 4 | import <%= className %> from '.'; 5 | 6 | describe('<%= className %>', () => { 7 | test('It renders', () => { 8 | const component = renderer.create(<<%= className %> />); 9 | 10 | let tree = component.toJSON(); 11 | expect(tree).toMatchSnapshot(); 12 | }); 13 | }); 14 | -------------------------------------------------------------------------------- /src/generators/project/templates/preact/src/components/Worm/index.test.tsx: -------------------------------------------------------------------------------- 1 | import { h } from 'preact'; 2 | import render from 'preact-render-to-string'; 3 | import htmlLooksLike from 'html-looks-like'; 4 | 5 | import Worm from '.'; 6 | 7 | describe('Worm', () => { 8 | test('It renders', () => { 9 | const actual = render(); 10 | const expected = ``; 11 | 12 | htmlLooksLike(actual, expected); 13 | }); 14 | }); 15 | -------------------------------------------------------------------------------- /src/utils/async.js: -------------------------------------------------------------------------------- 1 | const pack = (module.exports.pack = promise => promise.then(result => [null, result]).catch(err => [err])); 2 | 3 | module.exports.packs = fn => (...fnArgs) => pack(fn(...fnArgs)); 4 | 5 | const throws = (module.exports.throws = packed => { 6 | if (packed[0]) { 7 | throw packed[0]; 8 | } 9 | 10 | return packed; 11 | }); 12 | 13 | module.exports.unpack = (packed, ignoreErrors) => (ignoreErrors ? packed[1] : throws(packed)[1]); 14 | -------------------------------------------------------------------------------- /src/generators/component/templates/react/component.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styles from './styles.scss'; 3 | <% if (isTS) { %> 4 | type <%= className %>Props = {} 5 | <% } %> 6 | const <%= className %><% if (isTS) { %>: React.FC<<%= className %>Props><% } %> = () => { 7 | return ( 8 |
9 | Find me in src/components/<%= className %> 10 |
11 | ); 12 | } 13 | 14 | export default <%= className %>; 15 | -------------------------------------------------------------------------------- /src/generators/component/templates/preact/component-with-d3.test.tsx: -------------------------------------------------------------------------------- 1 | import { h } from 'preact'; 2 | import render from 'preact-render-to-string'; 3 | import htmlLooksLike from 'html-looks-like'; 4 | 5 | import <%= className %> from '.'; 6 | 7 | describe('<%= className %>', () => { 8 | test('It renders', () => { 9 | const actual = render(<<%= className %> />); 10 | const expected = ` 11 |
12 | `; 13 | 14 | htmlLooksLike(actual, expected); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /src/cli/test/constants.js: -------------------------------------------------------------------------------- 1 | // Ours 2 | const { cmd, opt, sec } = require('../../utils/color'); 3 | 4 | module.exports.MESSAGES = { 5 | usage: name => `Usage: ${cmd(`aunty ${name}`)} ${opt('[options]')} -- ${opt('[jest_options]')} 6 | 7 | ${sec('Options')} 8 | 9 | ${opt('-d')}, ${opt('--dry')} Output the generated jest configuration, then exit 10 | 11 | ${sec('Environment variables')} 12 | 13 | • Tests will assume you have set ${cmd('NODE_ENV=test')}, unless you specify otherwise. 14 | ` 15 | }; 16 | -------------------------------------------------------------------------------- /src/generators/project/templates/react/src/components/ErrorBox/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from 'react'; 2 | import styles from './styles.scss'; 3 | <% if (isTS) { %> 4 | type ErrorBoxProps = { 5 | error: Error; 6 | } 7 | <% } %> 8 | const ErrorBox<% if (isTS) { %>: React.FC<% } %> = ({ error }) => { 9 | useEffect(() => console.log(error), []); 10 | 11 | return
{`${String(error)}\n\n${error.stack}`}
; 12 | }; 13 | 14 | export default ErrorBox; 15 | -------------------------------------------------------------------------------- /src/generators/project/templates/svelte/src/components/Worm/Worm.test.ts: -------------------------------------------------------------------------------- 1 | import { render } from '@testing-library/svelte'; 2 | import Worm from './Worm.svelte'; 3 | 4 | describe('Worm', () => { 5 | it('should render correct contents', () => { 6 | const { container } = render(Worm); 7 | 8 | expect(container.querySelector('img')).toBeDefined(); 9 | }); 10 | 11 | it('renders a snapshot', () => { 12 | const { container } = render(Worm); 13 | 14 | expect(container).toMatchSnapshot(); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /src/generators/component/templates/preact/component.test.tsx: -------------------------------------------------------------------------------- 1 | import { h } from 'preact'; 2 | import render from 'preact-render-to-string'; 3 | import htmlLooksLike from 'html-looks-like'; 4 | 5 | import <%= className %> from '.'; 6 | 7 | describe('<%= className %>', () => { 8 | test('It renders', () => { 9 | const actual = render(<<%= className %> />); 10 | const expected = ` 11 |
12 | Find me in {{ ... }} 13 |
14 | `; 15 | 16 | htmlLooksLike(actual, expected); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /src/generators/component/templates/preact/component.tsx: -------------------------------------------------------------------------------- 1 | import { h<% if (isTS) { %>, FunctionalComponent<% } %> } from 'preact'; 2 | import styles from './styles.scss'; 3 | <% if (isTS) { %> 4 | type <%= className %>Props = {} 5 | <% } %> 6 | const <%= className %><% if (isTS) { %>: FunctionalComponent<<%= className %>Props><% } %> = () => { 7 | return ( 8 |
9 | Find me in src/components/<%= className %> 10 |
11 | ); 12 | }; 13 | 14 | export default <%= className %>; 15 | -------------------------------------------------------------------------------- /src/generators/project/templates/_common/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "<%= projectNameSlug %>", 3 | "version": "1.0.0-pre", 4 | "description": "A project generated from aunty's <%= projectType %> project template.", 5 | "license": "MIT", 6 | "private": true, 7 | "contributors": [ 8 | "<%= authorName %> <<%= authorEmail %>>" 9 | ], 10 | "aunty": { 11 | "type": "<%= projectType %>" 12 | }, 13 | "scripts": { 14 | "start": "aunty serve", 15 | "dev": "aunty serve", 16 | "test": "aunty test" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/generators/project/templates/react/src/components/App/index.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import renderer from 'react-test-renderer'; 3 | import App from '.';<% if (isTS) { %> 4 | import type { AppProps } from '.';<% } %> 5 | 6 | describe('App', () => { 7 | test('It renders', () => { 8 | const props<% if (isTS) { %>: AppProps<% } %> = { x: 42, y: 'text', z: true }; 9 | const component = renderer.create(); 10 | 11 | let tree = component.toJSON(); 12 | expect(tree).toMatchSnapshot(); 13 | }); 14 | }); 15 | -------------------------------------------------------------------------------- /src/generators/project/templates/react/src/components/App/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Worm from '../Worm'; 3 | import styles from './styles.scss'; 4 | <% if (isTS) { %> 5 | export type AppProps = { 6 | x: number; 7 | y: string; 8 | z: boolean; 9 | } 10 | <% } %> 11 | const App<% if (isTS) { %>: React.FC<% } %> = ({ x, y, z }) => { 12 | return ( 13 |
14 | 15 |
{JSON.stringify({ x, y, z })}
16 |

<%= projectName %>

17 |
18 | ); 19 | }; 20 | 21 | export default App; 22 | -------------------------------------------------------------------------------- /src/generators/project/templates/preact/src/components/ErrorBox/index.tsx: -------------------------------------------------------------------------------- 1 | import { h<% if (isTS) { %>, type FunctionalComponent<% } %> } from 'preact'; 2 | import { useEffect } from 'preact/hooks'; 3 | import styles from './styles.scss'; 4 | <% if (isTS) { %> 5 | type ErrorBoxProps = { 6 | error: Error; 7 | } 8 | <% } %> 9 | const ErrorBox<% if (isTS) { %>: FunctionalComponent<% } %> = ({ error }) => { 10 | useEffect(() => console.log(error), []); 11 | 12 | return
{`${String(error)}\n\n${error.stack}`}
; 13 | }; 14 | 15 | export default ErrorBox; 16 | -------------------------------------------------------------------------------- /src/generators/project/templates/svelte/src/components/App/App.test.ts: -------------------------------------------------------------------------------- 1 | import { render } from '@testing-library/svelte'; 2 | import App from './App.svelte'; 3 | 4 | const props = { x: 42, y: 'text', z: true }; 5 | 6 | describe('App', () => { 7 | it('should render correct contents', () => { 8 | const { container } = render(App, {...props}); 9 | 10 | expect(container.textContent).toContain(JSON.stringify(props)); 11 | }); 12 | 13 | it('renders a snapshot', () => { 14 | const { container } = render(App, {...props}); 15 | 16 | expect(container).toMatchSnapshot(); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /src/config/jest/index.js: -------------------------------------------------------------------------------- 1 | // Ours 2 | const { merge } = require('../../utils/structures'); 3 | const { getProjectConfig } = require('../project'); 4 | 5 | module.exports.getJestConfig = () => { 6 | const { jest: projectJestConfig, root } = getProjectConfig(); 7 | const defaultTransformerPath = require.resolve('./transformer-default'); 8 | 9 | return merge( 10 | { 11 | rootDir: root, 12 | verbose: true, 13 | transform: { 14 | '^.+\\.svelte$': require.resolve('./transformer-svelte'), 15 | '.*': defaultTransformerPath 16 | } 17 | }, 18 | projectJestConfig 19 | ); 20 | }; 21 | -------------------------------------------------------------------------------- /src/generators/project/templates/preact/src/components/App/index.tsx: -------------------------------------------------------------------------------- 1 | import { h<% if (isTS) { %>, type FunctionalComponent<% } %> } from 'preact'; 2 | import Worm from '../Worm'; 3 | import styles from './styles.scss'; 4 | <% if (isTS) { %> 5 | export type AppProps = { 6 | x: number; 7 | y: string; 8 | z: boolean; 9 | } 10 | <% } %> 11 | const App<% if (isTS) { %>: FunctionalComponent<% } %> = ({ x, y, z }) => { 12 | return ( 13 |
14 | 15 |
{JSON.stringify({ x, y, z })}
16 |

<%= projectName %>

17 |
18 | ); 19 | }; 20 | 21 | export default App; 22 | -------------------------------------------------------------------------------- /ts/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | "lib": ["esnext", "es2015", "dom"], 5 | "allowJs": false, 6 | "checkJs": false, 7 | "skipLibCheck": true, 8 | "module": "esnext", 9 | "moduleResolution": "node", 10 | "noImplicitAny": false, 11 | "jsx": "preserve", 12 | "sourceMap": true, 13 | "strict": true, 14 | "esModuleInterop": true, 15 | "forceConsistentCasingInFileNames": true, 16 | "allowSyntheticDefaultImports": true, 17 | "verbatimModuleSyntax": true, 18 | "resolveJsonModule": true, 19 | "isolatedModules": true, 20 | "strictNullChecks": true 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/generators/project/templates/basic/src/components/App/index.ts: -------------------------------------------------------------------------------- 1 | import Worm from '../Worm'; 2 | import styles from './styles.scss'; 3 | <% if (isTS) { %> 4 | export type AppProps = { 5 | x: number; 6 | y: string; 7 | z: boolean; 8 | } 9 | <% } %> 10 | export default class App {<% if (isTS) { %> 11 | el: Element; 12 | <% } %> 13 | constructor({ x, y, z }<% if (isTS) { %>: AppProps<% } %>) { 14 | this.el = document.createElement('div'); 15 | this.el.className = styles.root; 16 | this.el.innerHTML = ` 17 | ${new Worm().el.outerHTML} 18 |
${JSON.stringify({ x, y, z })}
19 |

<%= projectName %>

20 | `; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/generators/project/templates/basic/src/components/ErrorBox/index.ts: -------------------------------------------------------------------------------- 1 | import styles from './styles.scss'; 2 | <% if (isTS) { %> 3 | type ErrorBoxProps = { 4 | error: Error; 5 | } 6 | <% } %> 7 | export default class ErrorBox {<% if (isTS) { %> 8 | el: Element; 9 | <% } %> 10 | constructor({ error }<% if (isTS) { %>: ErrorBoxProps<% } %>) { 11 | const el = (this.el = document.createElement('pre')); 12 | 13 | el.className = styles.root; 14 | el.textContent = `${String(error)}\n\n${error.stack}`; 15 | 16 | (function logOnMount() { 17 | if (!el.parentNode) { 18 | return setTimeout(logOnMount, 100); 19 | } 20 | 21 | console.error(error); 22 | })(); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/utils/audio.js: -------------------------------------------------------------------------------- 1 | // Native 2 | const { execSync, spawn } = require('child_process'); 3 | const { join } = require('path'); 4 | 5 | const ANNOUNCEMENT_FILENAME = join(__dirname, '../../assets/done.mp3'); 6 | 7 | function has(cmd) { 8 | try { 9 | execSync('which ' + cmd + ' 2>/dev/null 2>/dev/null'); 10 | return true; 11 | } catch (err) { 12 | return false; 13 | } 14 | } 15 | 16 | module.exports.announce = () => { 17 | const args = [ANNOUNCEMENT_FILENAME]; 18 | let bin = 'play'; 19 | 20 | if (process.platform == 'darwin') { 21 | bin = 'afplay'; 22 | } 23 | 24 | if (has('mplayer')) { 25 | bin = 'mplayer'; 26 | args.unshift('-really-quiet'); 27 | } 28 | 29 | spawn(bin, args); 30 | }; 31 | -------------------------------------------------------------------------------- /src/generators/project/templates/preact/src/components/App/index.test.tsx: -------------------------------------------------------------------------------- 1 | import { h } from 'preact'; 2 | import render from 'preact-render-to-string'; 3 | import htmlLooksLike from 'html-looks-like'; 4 | import App from '.';<% if (isTS) { %> 5 | import type { AppProps } from '.';<% } %> 6 | 7 | describe('App', () => { 8 | test('It renders', () => { 9 | const props<% if (isTS) { %>: AppProps<% } %> = { x: 42, y: 'text', z: true }; 10 | const actual = render(); 11 | const expected = ` 12 |
13 | {{ ... }} 14 |
${JSON.stringify(props)}
15 |

<%= projectName %>

16 |
17 | `; 18 | 19 | htmlLooksLike(actual, expected); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /src/bin/aunty.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | if (!require('import-local')(__filename)) { 4 | (async () => { 5 | const importLazy = require('import-lazy')(require); 6 | const { cli } = require('../cli'); 7 | const { createErrorLogo } = importLazy('../utils/branding'); 8 | const { error, log } = importLazy('../utils/logging'); 9 | 10 | function exit(err) { 11 | if (err) { 12 | log(createErrorLogo()); 13 | error(err); 14 | process.exit(1); 15 | } 16 | 17 | process.exit(0); 18 | } 19 | 20 | process.on('uncaughtException', exit); 21 | process.on('unhandledRejection', exit); 22 | 23 | const [err] = await cli(process.argv.slice(2)); 24 | 25 | exit(err); 26 | })(); 27 | } 28 | -------------------------------------------------------------------------------- /src/generators/project/templates/_non_sfc/src/components/App/styles.scss: -------------------------------------------------------------------------------- 1 | .root { 2 | display: flex; 3 | flex-direction: column; 4 | justify-content: center; 5 | align-items: center; 6 | width: 100%; 7 | height: 100%; 8 | min-height: 320px; 9 | background-color: #<% if (isTS) { %>3178c7<% } else { %>f0db4e<% } %>; 10 | color: #<% if (isTS) { %>fff<% } else { %>000<% } %>; 11 | text-align: center; 12 | 13 | h1 { 14 | margin: 0; 15 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif !important; 16 | font-size: 24px !important; 17 | font-weight: normal !important; 18 | line-height: normal !important; 19 | letter-spacing: normal !important; 20 | } 21 | } 22 | 23 | 24 | -------------------------------------------------------------------------------- /src/cli/test/index.js: -------------------------------------------------------------------------------- 1 | // External 2 | const importLazy = require('import-lazy')(require); 3 | const jest = importLazy('jest'); 4 | 5 | // Ours 6 | const { command } = require('../'); 7 | const { getJestConfig } = require('../../config/jest'); 8 | const { dry } = require('../../utils/logging'); 9 | const { MESSAGES } = require('./constants'); 10 | 11 | module.exports = command( 12 | { 13 | name: 'test', 14 | nodeEnv: 'test', 15 | usage: MESSAGES.usage 16 | }, 17 | async argv => { 18 | const config = getJestConfig(); 19 | const jestArgs = ['--config', JSON.stringify(config)].concat(argv['--']); 20 | 21 | if (argv.dry) { 22 | return dry({ 23 | 'Jest config': config, 24 | 'Jest args': jestArgs 25 | }); 26 | } 27 | 28 | await jest.run(jestArgs); 29 | } 30 | ); 31 | -------------------------------------------------------------------------------- /src/generators/component/templates/basic/component-with-d3.ts: -------------------------------------------------------------------------------- 1 | import { select<% if (isTS) { %>, Selection<% } %> } from 'd3-selection'; 2 | import styles from './styles.scss'; 3 | 4 | export default class <%= className %> {<% if (isTS) { %> 5 | el: Element; 6 | svg: Selection; 7 | g: Selection; 8 | rect: Selection; 9 | <% } %> 10 | constructor() { 11 | this.el = document.createElement('div'); 12 | this.el.className = styles.root; 13 | 14 | this.svg = select(this.el) 15 | .append('svg') 16 | .attr('width', 400) 17 | .attr('height', 300); 18 | 19 | this.g = this.svg.append('g').attr('fill', 'black'); 20 | 21 | this.rect = this.g 22 | .append('rect') 23 | .attr('x', 0) 24 | .attr('y', 0) 25 | .attr('rx', 3) 26 | .attr('ry', 3) 27 | .attr('width', '100%') 28 | .attr('height', '100%'); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/generators/project/templates/_common/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | <%= projectName %> 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 |
<% if (isOdyssey) { %> 16 | 17 | <% } %> 20 | 21 | 22 | -------------------------------------------------------------------------------- /src/generators/project/templates/svelte/src/index.ts: -------------------------------------------------------------------------------- 1 | import acto from '@abcnews/alternating-case-to-object'; 2 | import { <% if (isOdyssey) { %>whenOdysseyLoaded<% } else { %>whenDOMReady<% } %> } from '@abcnews/env-utils'; 3 | import { getMountValue, selectMounts } from '@abcnews/mount-utils';<% if (isTS) { %> 4 | import type { Mount } from '@abcnews/mount-utils';<% } %> 5 | import App from './components/App/App.svelte'; 6 | import { mount } from 'svelte'; 7 | 8 | let appMountEl<% if (isTS) { %>: Mount<% } %>; 9 | let appProps; 10 | 11 | <% if (isOdyssey) { %>whenOdysseyLoaded<% } else { %>whenDOMReady<% } %>.then(() => { 12 | [appMountEl] = selectMounts('<%= projectNameFlat %>'); 13 | 14 | if (appMountEl) { 15 | appProps = acto(getMountValue(appMountEl)); 16 | 17 | mount(App, { 18 | target: appMountEl, 19 | props: appProps 20 | }); 21 | } 22 | }); 23 | 24 | if (process.env.NODE_ENV === 'development') { 25 | console.debug(`[<%= projectName %>] public path: ${__webpack_public_path__}`); 26 | } 27 | -------------------------------------------------------------------------------- /src/config/jest/transformer-svelte.js: -------------------------------------------------------------------------------- 1 | // Native 2 | const { execSync } = require('child_process'); 3 | const { basename } = require('path'); 4 | 5 | // External 6 | const importLazy = require('import-lazy')(require); 7 | const svelte = importLazy('svelte/compiler'); 8 | 9 | module.exports = { 10 | process(src, filename) { 11 | const preprocessor = require.resolve('./transformer-svelte-preprocess.js'); 12 | 13 | const processed = execSync(`node --unhandled-rejections=strict --abort-on-uncaught-exception ${preprocessor}`, { 14 | env: { PATH: process.env.PATH, src, filename } 15 | }).toString(); 16 | 17 | const result = svelte.compile(processed, { 18 | filename: basename(filename), 19 | css: true, 20 | accessors: true, 21 | dev: true, 22 | format: 'cjs' 23 | }); 24 | 25 | const esInterop = 'Object.defineProperty(exports, "__esModule", { value: true });'; 26 | 27 | return { 28 | code: result.js.code + esInterop, 29 | map: result.js.map 30 | }; 31 | } 32 | }; 33 | -------------------------------------------------------------------------------- /src/cli/clean/index.js: -------------------------------------------------------------------------------- 1 | // External 2 | const importLazy = require('import-lazy')(require); 3 | const del = importLazy('del'); 4 | 5 | // Ours 6 | const { getProjectConfig } = require('../../config/project'); 7 | const { OUTPUT_DIRECTORY_NAME } = require('../../constants'); 8 | const { dry, info, spin } = require('../../utils/logging'); 9 | const { command } = require('../'); 10 | const { MESSAGES } = require('./constants'); 11 | 12 | module.exports = command( 13 | { 14 | name: 'clean', 15 | usage: MESSAGES.usage 16 | }, 17 | async argv => { 18 | const { root } = getProjectConfig(); 19 | const globs = [OUTPUT_DIRECTORY_NAME]; 20 | 21 | if (argv.dry) { 22 | return dry({ 23 | 'Deletion paths': { globs, cwd: root } 24 | }); 25 | } 26 | 27 | let spinner; 28 | 29 | if (!argv.quiet) { 30 | info(MESSAGES.clean(globs)); 31 | spinner = spin(`Cleaning`); 32 | } 33 | 34 | await del(globs, { cwd: root }); 35 | 36 | if (!argv.quiet) { 37 | spinner.succeed('Cleaned'); 38 | } 39 | } 40 | ); 41 | -------------------------------------------------------------------------------- /src/config/jest/transformer-default.js: -------------------------------------------------------------------------------- 1 | // Native 2 | const path = require('path'); 3 | 4 | // External 5 | const importLazy = require('import-lazy')(require); 6 | const babelJest = importLazy('babel-jest'); 7 | 8 | // Ours 9 | const { getBabelConfig } = require('../babel'); 10 | 11 | const MEDIA_RESOURCE_PATTERN = /\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$/; 12 | const STYLE_RESOURCE_PATTERN = /\.s?css$/; 13 | const STYLE_RESOURCE_REPLACEMENT = 'module.exports = new Proxy({}, { get: (styles, method) => method });'; 14 | 15 | module.exports = { 16 | process(src, filename, config) { 17 | // Mock media & style resources 18 | if (filename.match(MEDIA_RESOURCE_PATTERN)) { 19 | return 'module.exports = ' + JSON.stringify(path.basename(filename)) + ';'; 20 | } else if (filename.match(STYLE_RESOURCE_PATTERN)) { 21 | return STYLE_RESOURCE_REPLACEMENT; 22 | } 23 | 24 | // Run everything else through babel 25 | return babelJest.createTransformer(getBabelConfig()).process(src, filename, config, { sourceMaps: false }); 26 | } 27 | }; 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2020 ABC News 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/generators/project/templates/svelte/src/components/App/App.svelte: -------------------------------------------------------------------------------- 1 | lang="ts"<% } %>> 2 | import Worm from '../Worm/Worm.svelte'; 3 | 4 | export let x<% if (isTS) { %>: number<% } %>; 5 | export let y<% if (isTS) { %>: string<% } %>; 6 | export let z<% if (isTS) { %>: boolean<% } %>; 7 | 8 | 9 |
10 | 11 |
{JSON.stringify({ x, y, z })}
12 |

<%= projectName %>

13 |
14 | 15 | 38 | -------------------------------------------------------------------------------- /src/config/build.js: -------------------------------------------------------------------------------- 1 | // Native 2 | const { join } = require('path'); 3 | 4 | // External 5 | const mem = require('mem'); 6 | 7 | // Ours 8 | const { BUILD_DIRECTORY_NAME, OUTPUT_DIRECTORY_NAME } = require('../constants'); 9 | const { combine } = require('../utils/structures'); 10 | const { getProjectConfig } = require('./project'); 11 | 12 | const PROJECT_TYPES_CONFIG = { 13 | svelte: { 14 | useCSSModules: false 15 | } 16 | }; 17 | const DEFAULT_ENTRY_FILE_NAME = 'index'; 18 | const DEFAULT_SOURCE_DIRECTORY_NAME = 'src'; 19 | const DEFAULT_STATIC_DIRECTORY_NAME = 'public'; 20 | 21 | module.exports.getBuildConfig = mem(() => { 22 | const { build: projectBuildConfig, type } = getProjectConfig(); 23 | 24 | return combine( 25 | { 26 | entry: DEFAULT_ENTRY_FILE_NAME, 27 | from: DEFAULT_SOURCE_DIRECTORY_NAME, 28 | to: join(OUTPUT_DIRECTORY_NAME, BUILD_DIRECTORY_NAME), 29 | staticDir: DEFAULT_STATIC_DIRECTORY_NAME, 30 | addModernJS: false, 31 | includedDependencies: [], 32 | extractCSS: false, 33 | useCSSModules: true, 34 | showDeprecations: false 35 | }, 36 | PROJECT_TYPES_CONFIG[type], 37 | projectBuildConfig 38 | ); 39 | }); 40 | -------------------------------------------------------------------------------- /src/generators/project/templates/_common/LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) <%= new Date().getFullYear() %> ABC News 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/cli/sign-cert/constants.js: -------------------------------------------------------------------------------- 1 | // Ours 2 | const { cmd, opt, sec } = require('../../utils/color'); 3 | 4 | module.exports.MESSAGES = { 5 | opensslArgs: (host, keyout, out) => [ 6 | 'req', 7 | '-newkey', 8 | 'rsa:2048', 9 | '-x509', 10 | '-nodes', 11 | '-keyout', 12 | keyout, 13 | '-new', 14 | '-out', 15 | out, 16 | '-subj', 17 | `/CN=${host}`, 18 | '-reqexts', 19 | 'SAN', 20 | '-extensions', 21 | 'SAN', 22 | '-config', 23 | `<(cat /System/Library/OpenSSL/openssl.cnf <(printf '[SAN]\nsubjectAltName=DNS:${host}'))`, 24 | '-sha256', 25 | '-days', 26 | '3650' 27 | ], 28 | manual: path => `You should install this certificate in your Mac OS Keychain: 29 | ${path} 30 | `, 31 | platform: `Sorry, this command is only supported on MacOS (for now)`, 32 | usage: name => `Usage: ${cmd(`aunty ${name}`)} ${opt('[options]')} 33 | 34 | ${sec('Options')} 35 | 36 | ${opt('-d')}, ${opt('--dry')} Output the assumed host, file paths & openssl command that would have run, then exit 37 | 38 | ${sec('Environment variables')} 39 | 40 | • You can override the host the certificate would be generated for by setting ${cmd('AUNTY_HOST')} 41 | ` 42 | }; 43 | -------------------------------------------------------------------------------- /src/utils/branding.js: -------------------------------------------------------------------------------- 1 | // Ours 2 | const { blue, cyan, dim, green, hvy, magenta, red, yellow } = require('./color'); 3 | const { zipTemplateLiterals } = require('./text'); 4 | 5 | const COLORS = [blue, cyan, green, magenta, yellow]; 6 | 7 | function pickRandomColor() { 8 | return COLORS[Math.floor(Math.random() * COLORS.length)]; 9 | } 10 | 11 | const createLogo = (module.exports.createLogo = color => 12 | (color || pickRandomColor())(` 13 | ⣾${dim('⢷')}⡾⢷${dim('⡾')}⣷ 14 | ⢿⡾${dim('⢷⡾')}⢷⡿ `)); 15 | 16 | module.exports.createCommandLogo = (commandName, isDry) => 17 | zipTemplateLiterals([ 18 | createLogo(), 19 | ` 20 | ${dim('aunty')} 21 | ${hvy(commandName)}${isDry ? ` ${cyan('[dry]')}` : ''}` 22 | ]); 23 | 24 | module.exports.createErrorLogo = () => 25 | zipTemplateLiterals([ 26 | createLogo(red), 27 | ` 28 | ${red(dim('ERROR'))} 29 | ${red(hvy('ЯOЯЯƎ'))}` 30 | ]); 31 | 32 | module.exports.SPINNER_FRAMES = [ 33 | '⣏⠀⠀', 34 | '⡟⠀⠀', 35 | '⠟⠄⠀', 36 | '⠛⡄⠀', 37 | '⠙⣄⠀', 38 | '⠘⣤⠀', 39 | '⠐⣤⠂', 40 | '⠀⣤⠃', 41 | '⠀⣠⠋', 42 | '⠀⢠⠛', 43 | '⠀⠠⠻', 44 | '⠀⠀⢻', 45 | '⠀⠀⣹', 46 | '⠀⠀⣼', 47 | '⠀⠐⣴', 48 | '⠀⠘⣤', 49 | '⠀⠙⣄', 50 | '⠀⠛⡄', 51 | '⠠⠛⠄', 52 | '⢠⠛⠀', 53 | '⣠⠋⠀', 54 | '⣤⠃⠀', 55 | '⣦⠂⠀', 56 | '⣧⠀⠀' 57 | ]; 58 | -------------------------------------------------------------------------------- /src/utils/structures.js: -------------------------------------------------------------------------------- 1 | const deepmerge = require('deepmerge'); 2 | 3 | /** 4 | * Merge a function or object into the target object 5 | * @param target an object to merge into 6 | * @param source an object to merge with `target`, or a function that takes `target` as an argument 7 | * @param isDeep use deepmerge to merge 8 | * @returns 9 | */ 10 | const combine = (target, source, isDeep) => 11 | typeof source === 'function' 12 | ? source(target) 13 | : typeof source === 'object' 14 | ? isDeep === true 15 | ? deepmerge(target, source, { clone: false }) 16 | : Object.assign(target, source) 17 | : target; 18 | 19 | const combineDeep = (memo, source) => combine(memo, source, true); 20 | 21 | /** 22 | * Shallow merge an array of objects. Earlier values overwritten by later values 23 | * @param sources Array of objects and functions to be merged using `combine` 24 | * @see combine 25 | */ 26 | module.exports.combine = (...sources) => sources.reduce(combine, {}); 27 | 28 | /** 29 | * Deep merge an array of objects. Earlier values overwritten by later values. 30 | * @param sources Array of objects and functions to be merged using `combine` 31 | * @see combine 32 | */ 33 | module.exports.merge = (...sources) => sources.reduce(combineDeep, {}); 34 | -------------------------------------------------------------------------------- /src/generators/project/templates/_common/src/components/Worm/worm.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /src/cli/serve/constants.js: -------------------------------------------------------------------------------- 1 | // Ours 2 | const { cmd, hvy, opt, sec } = require('../../utils/color'); 3 | 4 | module.exports.BUNDLE_ANALYZER_CONFIG = { 5 | analyzerHost: '127.0.0.1', 6 | logLevel: 'error', 7 | openAnalyzer: false 8 | }; 9 | 10 | module.exports.MESSAGES = { 11 | port: ({ port, errorType }) => `Port ${port} could not be used (${errorType}). Attempting to use port ${port + 1}`, 12 | analysis: ({ analyzerHost, analyzerPort }) => `http://${analyzerHost}:${analyzerPort}`, 13 | serve: ({ bundleAnalysisPath, hot, publicPath }) => `Serve (${hvy(process.env.NODE_ENV)}):${ 14 | bundleAnalysisPath 15 | ? ` 16 | ┣ ${hvy('bundle analysis')}: ${bundleAnalysisPath}` 17 | : '' 18 | } 19 | ┣ ${hvy('hot')}: ${cmd(hot ? 'yes' : 'no')} 20 | ┗ ${hvy('publicPath')}: ${publicPath}`, 21 | // TODO: Add aunty config section to usage 22 | usage: name => `Usage: ${cmd(`aunty ${name}`)} ${opt('[options]')} 23 | 24 | ${sec('Options')} 25 | 26 | ${opt('-d')}, ${opt('--dry')} Output the generated webpack & dev server configuration, then exit 27 | 28 | ${sec('Environment variables')} 29 | 30 | • Builds will assume you have set ${cmd('NODE_ENV=development')}, unless you specify otherwise. 31 | • You can override the host and port the server listens on by setting ${cmd('AUNTY_HOST')} and ${cmd('AUNTY_PORT')}. 32 | ` 33 | }; 34 | -------------------------------------------------------------------------------- /src/cli/generate/constants.js: -------------------------------------------------------------------------------- 1 | // Ours 2 | const { cmd, hvy, opt, req, sec } = require('../../utils/color'); 3 | 4 | module.exports.OPTIONS = { 5 | boolean: ['announce'], 6 | alias: { 7 | announce: 'a' 8 | } 9 | }; 10 | 11 | const GENERATOR_ALIASES = (module.exports.GENERATOR_ALIASES = { 12 | c: 'component', 13 | p: 'project' 14 | }); 15 | 16 | module.exports.GENERATORS = new Set(Object.values(GENERATOR_ALIASES)); 17 | 18 | module.exports.MESSAGES = { 19 | generatorDoesNotExist: name => `The generator '${name}' does not exist.`, 20 | noDryRuns: `Generators don't have dry runs (yet). Please run without the ${opt('--dry')} flag.`, 21 | usage: `Usage: ${cmd('aunty generate')} ${req('')} ${opt('[options]')} -- ${opt('[generator_options]')} 22 | 23 | ${sec('Options')} 24 | 25 | ${opt('-h')}, ${opt('--help')} Output the available generators, and exit. 26 | 27 | ${sec('Generators')} 28 | 29 | ${hvy('Project creation')} 30 | 31 | ${req('project')} Create a new project (aliased by ${cmd('aunty new')} and ${cmd('aunty init')}). 32 | 33 | ${hvy('Project additions')} 34 | 35 | ${req('component')} Add a component (and tests) to ${req('src/components/')} for your project type. 36 | 37 | Run ${cmd('aunty generate')} ${req('')} ${opt('--help')} for details of each generator's options/arguments. 38 | ` 39 | }; 40 | -------------------------------------------------------------------------------- /src/generators/component/templates/svelte/component-with-d3.svelte: -------------------------------------------------------------------------------- 1 | lang="ts"<% } %>> 2 | export let color<% if (isTS) { %>: string<% } %> = 'black'; 3 | 4 | import { select } from 'd3-selection';<% if (isTS) { %> 5 | import type { Selection } from 'd3-selection';<% } %> 6 | import { afterUpdate, onMount } from 'svelte'; 7 | 8 | let root<% if (isTS) { %>: HTMLDivElement<% } %>; 9 | let svg<% if (isTS) { %>: Selection<% } %>; 10 | let g<% if (isTS) { %>: Selection<% } %>; 11 | let rect<% if (isTS) { %>: Selection<% } %>; 12 | 13 | onMount(() => { 14 | svg = select(root) 15 | .append('svg') 16 | .attr('width', 400) 17 | .attr('height', 300); 18 | 19 | g = svg.append('g').attr('fill', color); 20 | 21 | rect = g 22 | .append('rect') 23 | .attr('x', 0) 24 | .attr('y', 0) 25 | .attr('rx', 3) 26 | .attr('ry', 3) 27 | .attr('width', '100%') 28 | .attr('height', '100%'); 29 | }); 30 | 31 | afterUpdate(() => { 32 | g.attr('fill', color); 33 | }); 34 | 35 | 36 |
37 | 38 | 46 | 47 | -------------------------------------------------------------------------------- /src/cli/build/constants.js: -------------------------------------------------------------------------------- 1 | // Ours 2 | const { cmd, hvy, opt, sec, req } = require('../../utils/color'); 3 | 4 | module.exports.OPTIONS = { 5 | boolean: ['local'], 6 | string: ['id'], 7 | alias: { 8 | id: 'i', 9 | local: 'l' 10 | } 11 | }; 12 | 13 | // TODO: Add aunty config section to usage 14 | 15 | module.exports.MESSAGES = { 16 | build: ({ id, publicPaths }) => `Build (${hvy(process.env.NODE_ENV)}):${ 17 | id 18 | ? ` 19 | ┣ ${hvy('id')}: ${req(id)}` 20 | : '' 21 | } 22 | ┗ ${hvy('publicPaths')}:${publicPaths 23 | .map( 24 | (publicPath, index) => ` 25 | ${index === publicPaths.length - 1 ? '┗' : '┣'} ${hvy(`[${index}]`)}: ${req(publicPath)}` 26 | ) 27 | .join('')}`, 28 | usage: name => `Usage: ${cmd(`aunty ${name}`)} ${opt('[options]')} 29 | 30 | ${sec('Options')} 31 | 32 | ${opt('-d')}, ${opt('--dry')} Output the generated webpack (& deploy) configuration, then exit 33 | ${opt('-l')}, ${opt('--local')} Only build for local purposes; don't output deploy configuration 34 | ${opt('-i NAME')}, ${opt('--id=NAME')} Id for this build (can be used in deploy 'to' path) ${opt( 35 | `[default: ${cmd('git branch')}]` 36 | )} 37 | 38 | ${sec('Environment variables')} 39 | 40 | • Builds will assume you have set ${cmd('NODE_ENV=production')}, unless you specify otherwise. 41 | `, 42 | multipleErrors: errors => `Multiple build errors: 43 | 44 | ${errors.join('\n\n')} 45 | ` 46 | }; 47 | -------------------------------------------------------------------------------- /src/config/webpackDevServer.js: -------------------------------------------------------------------------------- 1 | // Native 2 | const { join } = require('path'); 3 | 4 | // Ours 5 | const { combine } = require('../utils/structures'); 6 | const { getBuildConfig } = require('./build'); 7 | const { getProjectConfig } = require('./project'); 8 | const { getServeConfig } = require('./serve'); 9 | 10 | module.exports.getWebpackDevServerConfig = async () => { 11 | const { root, webpackDevServer: projectWebpackDevServerConfig } = getProjectConfig(); 12 | const { staticDir } = getBuildConfig(); 13 | const { host, hot, https, port } = await getServeConfig(); 14 | 15 | return combine( 16 | { 17 | allowedHosts: 'all', 18 | client: { 19 | logging: 'warn', 20 | overlay: true, 21 | webSocketURL: `ws${https ? 's' : ''}://${host}:${port}/ws` 22 | }, 23 | devMiddleware: { 24 | publicPath: `http${https ? 's' : ''}://${host}:${port}/` 25 | }, 26 | headers: { 27 | 'Access-Control-Allow-Origin': '*' 28 | }, 29 | host: '0.0.0.0', 30 | hot, 31 | port, 32 | server: https 33 | ? typeof https === 'object' 34 | ? { 35 | type: 'https', 36 | options: https 37 | } 38 | : 'https' 39 | : 'http', 40 | static: (Array.isArray(staticDir) ? staticDir : [staticDir]).map(dirName => ({ 41 | directory: join(root, dirName) 42 | })) 43 | }, 44 | projectWebpackDevServerConfig 45 | ); 46 | }; 47 | -------------------------------------------------------------------------------- /src/cli/sign-cert/index.js: -------------------------------------------------------------------------------- 1 | // Native 2 | const { spawnSync } = require('child_process'); 3 | 4 | // External 5 | const importLazy = require('import-lazy')(require); 6 | const makeDir = importLazy('make-dir'); 7 | 8 | // Ours 9 | const { DEFAULT_HOST, SERVER_CERT_FILENAME, SERVER_KEY_FILENAME, getSSLPath } = require('../../config/serve'); 10 | const { dry, info, spin } = require('../../utils/logging'); 11 | const { command } = require('../'); 12 | const { MESSAGES } = require('./constants'); 13 | 14 | module.exports = command( 15 | { 16 | name: 'sign-cert', 17 | usage: MESSAGES.usage 18 | }, 19 | async argv => { 20 | if (process.platform !== 'darwin') { 21 | throw new Error(MESSAGES.platform); 22 | } 23 | 24 | const host = process.env.AUNTY_HOST || DEFAULT_HOST; 25 | const files = { 26 | cert: getSSLPath(host, SERVER_CERT_FILENAME), 27 | key: getSSLPath(host, SERVER_KEY_FILENAME) 28 | }; 29 | const opensslArgs = MESSAGES.opensslArgs(host, files.key, files.cert); 30 | const cmd = `openssl ${opensslArgs.join(' ')}`; 31 | 32 | if (argv.dry) { 33 | return dry({ 34 | 'OpenSSL config': { 35 | host, 36 | files, 37 | cmd 38 | } 39 | }); 40 | } 41 | 42 | let spinner = spin(`Creating SSL certificate`); 43 | 44 | await makeDir(getSSLPath(host)); 45 | spawnSync('bash', ['-c', cmd]); 46 | 47 | spinner.succeed('SSL certificate created'); 48 | info(MESSAGES.manual(files.cert)); 49 | } 50 | ); 51 | -------------------------------------------------------------------------------- /src/generators/project/templates/react/src/index.tsx: -------------------------------------------------------------------------------- 1 | import acto from '@abcnews/alternating-case-to-object'; 2 | import { <% if (isOdyssey) { %>whenOdysseyLoaded<% } else { %>whenDOMReady<% } %> } from '@abcnews/env-utils'; 3 | import { getMountValue, selectMounts } from '@abcnews/mount-utils'; 4 | import React from 'react'; 5 | import { createRoot<% if (isTS) { %>, type Root<% } %> } from 'react-dom/client'; 6 | import App from './components/App';<% if (isTS) { %> 7 | import type { AppProps } from './components/App';<% } %> 8 | 9 | let root<% if (isTS) { %>: Root<% } %>; 10 | let appProps<% if (isTS) { %>: AppProps<% } %>; 11 | 12 | function renderApp() { 13 | root.render(); 14 | } 15 | 16 | <% if (isOdyssey) { %>whenOdysseyLoaded<% } else { %>whenDOMReady<% } %>.then(() => { 17 | const [appMountEl] = selectMounts('<%= projectNameFlat %>'); 18 | 19 | if (appMountEl) { 20 | root = createRoot(appMountEl); 21 | appProps = acto(getMountValue(appMountEl))<% if (isTS) { %> as AppProps<% } %>; 22 | renderApp(); 23 | } 24 | }); 25 | 26 | if (module.hot) { 27 | module.hot.accept('./components/App', () => { 28 | try { 29 | renderApp(); 30 | } catch (err<% if (isTS) { %>: any<% } %>) { 31 | import('./components/ErrorBox').then(({ default: ErrorBox }) => { 32 | root.render(); 33 | }); 34 | } 35 | }); 36 | } 37 | 38 | if (process.env.NODE_ENV === 'development') { 39 | console.debug(`[<%= projectName %>] public path: ${__webpack_public_path__}`); 40 | } 41 | -------------------------------------------------------------------------------- /src/generators/component/templates/react/component-with-d3.tsx: -------------------------------------------------------------------------------- 1 | import { select<% if (isTS) { %>, Selection<% } %> } from 'd3-selection'; 2 | import React, { useEffect, useRef } from 'react'; 3 | import styles from './styles.scss'; 4 | <% if (isTS) { %> 5 | type <%= className %>Props = { 6 | color?: string; 7 | } 8 | <% } %> 9 | const <%= className %><% if (isTS) { %>: React.FC<<%= className %>Props><% } %> = ({ color = 'black' }) => { 10 | const root = useRef<% if (isTS) { %><% } %>(null); 11 | const svg = useRef<% if (isTS) { %> | null><% } %>(null); 12 | const g = useRef<% if (isTS) { %> | null><% } %>(null); 13 | const rect = useRef<% if (isTS) { %> | null><% } %>(null); 14 | 15 | useEffect(() => { 16 | svg.current = select(root.current) 17 | .append('svg') 18 | .attr('width', 400) 19 | .attr('height', 300); 20 | 21 | g.current = svg.current.append('g'); 22 | 23 | rect.current = g.current 24 | .append('rect') 25 | .attr('x', 0) 26 | .attr('y', 0) 27 | .attr('rx', 3) 28 | .attr('ry', 3) 29 | .attr('width', '100%') 30 | .attr('height', '100%'); 31 | }, []); 32 | 33 | useEffect(() => { 34 | if (!g.current) { 35 | return; 36 | } 37 | 38 | g.current.attr('fill', color); 39 | }, [color]); 40 | 41 | return
; 42 | }; 43 | 44 | export default <%= className %>; 45 | -------------------------------------------------------------------------------- /src/generators/project/templates/preact/src/index.tsx: -------------------------------------------------------------------------------- 1 | import acto from '@abcnews/alternating-case-to-object'; 2 | import { <% if (isOdyssey) { %>whenOdysseyLoaded<% } else { %>whenDOMReady<% } %> } from '@abcnews/env-utils'; 3 | import { getMountValue, selectMounts } from '@abcnews/mount-utils';<% if (isTS) { %> 4 | import type { Mount } from '@abcnews/mount-utils';<% } %> 5 | import { h, render } from 'preact'; 6 | import App from './components/App';<% if (isTS) { %> 7 | import type { AppProps } from './components/App';<% } %> 8 | 9 | let appMountEl<% if (isTS) { %>: Mount<% } %>; 10 | let appProps<% if (isTS) { %>: AppProps<% } %>; 11 | 12 | function renderApp() { 13 | render(, appMountEl); 14 | } 15 | 16 | <% if (isOdyssey) { %>whenOdysseyLoaded<% } else { %>whenDOMReady<% } %>.then(() => { 17 | [appMountEl] = selectMounts('<%= projectNameFlat %>'); 18 | 19 | if (appMountEl) { 20 | appProps = acto(getMountValue(appMountEl))<% if (isTS) { %> as AppProps<% } %>; 21 | renderApp(); 22 | } 23 | }); 24 | 25 | if (module.hot) { 26 | module.hot.accept('./components/App', () => { 27 | try { 28 | renderApp(); 29 | } catch (err<% if (isTS) { %>: any<% } %>) { 30 | import('./components/ErrorBox').then(({ default: ErrorBox }) => { 31 | if (appMountEl) { 32 | render(, appMountEl); 33 | } 34 | }); 35 | } 36 | }); 37 | } 38 | 39 | if (process.env.NODE_ENV === 'development') { 40 | require('preact/debug'); 41 | console.debug(`[<%= projectName %>] public path: ${__webpack_public_path__}`); 42 | } 43 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests CI 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | test: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v4 10 | - name: Test using Node.js 11 | uses: actions/setup-node@v4 12 | with: 13 | node-version: '18' 14 | - run: npm install 15 | - run: npm test 16 | 17 | - name: Tests ✅ 18 | if: ${{ success() }} 19 | run: | 20 | curl --request POST \ 21 | --url https://api.github.com/repos/${{ github.repository }}/statuses/${{ github.sha }} \ 22 | --header 'authorization: Bearer ${{ secrets.GITHUB_TOKEN }}' \ 23 | --header 'content-type: application/json' \ 24 | --data '{ 25 | "context": "tests", 26 | "state": "success", 27 | "description": "Tests passed", 28 | "target_url": "https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}" 29 | }' 30 | 31 | - name: Tests 🚨 32 | if: ${{ failure() }} 33 | run: | 34 | curl --request POST \ 35 | --url https://api.github.com/repos/${{ github.repository }}/statuses/${{ github.sha }} \ 36 | --header 'authorization: Bearer ${{ secrets.GITHUB_TOKEN }}' \ 37 | --header 'content-type: application/json' \ 38 | --data '{ 39 | "context": "tests", 40 | "state": "failure", 41 | "description": "Tests failed", 42 | "target_url": "https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}" 43 | }' 44 | -------------------------------------------------------------------------------- /src/config/project.js: -------------------------------------------------------------------------------- 1 | // Global 2 | const { existsSync } = require('fs'); 3 | const { join } = require('path'); 4 | 5 | // External 6 | const importLazy = require('import-lazy')(require); 7 | const guessRootPath = importLazy('guess-root-path'); 8 | const mem = require('mem'); 9 | 10 | // Ours 11 | const { hvy } = require('../utils/color'); 12 | const { pretty } = require('../utils/logging'); 13 | const { combine } = require('../utils/structures'); 14 | const { PROJECT_CONFIG_FILE_NAME } = require('../constants'); 15 | 16 | function ensureProjectConfigShape(x) { 17 | return typeof x === 'object' ? x : typeof x === 'string' ? { type: x } : {}; 18 | } 19 | 20 | module.exports.getProjectConfig = mem(() => { 21 | const root = guessRootPath(); 22 | 23 | if (root === null) { 24 | throw new Error(`Aunty doesn't work if your project doesn't have a ${hvy('package.json')} file.`); 25 | } 26 | 27 | let pkg; 28 | 29 | try { 30 | pkg = require(`${root}/package.json`); 31 | } catch (err) { 32 | throw pretty(err); 33 | } 34 | 35 | let projectConfigModule; 36 | 37 | try { 38 | projectConfigModule = require(`${root}/${PROJECT_CONFIG_FILE_NAME}`); 39 | } catch (err) { 40 | // The standalone config file is optional, but it may have syntax problems 41 | if (err.code !== 'MODULE_NOT_FOUND') { 42 | throw pretty(err); 43 | } 44 | } 45 | 46 | const hasTS = existsSync(join(root, 'tsconfig.json')); 47 | 48 | return combine( 49 | { 50 | root, 51 | pkg, 52 | hasTS 53 | }, 54 | ensureProjectConfigShape(pkg.aunty), 55 | ensureProjectConfigShape(projectConfigModule) 56 | ); 57 | }); 58 | -------------------------------------------------------------------------------- /src/generators/component/templates/preact/component-with-d3.tsx: -------------------------------------------------------------------------------- 1 | import { select<% if (isTS) { %>, Selection<% } %> } from 'd3-selection'; 2 | import { h<% if (isTS) { %>, FunctionalComponent<% } %> } from 'preact'; 3 | import { useEffect, useRef } from 'preact/hooks'; 4 | import styles from './styles.scss'; 5 | <% if (isTS) { %> 6 | type <%= className %>Props = { 7 | color?: string; 8 | } 9 | <% } %> 10 | const <%= className %><% if (isTS) { %>: FunctionalComponent<<%= className %>Props><% } %> = ({ color = 'black' }) => { 11 | const root = useRef<% if (isTS) { %><% } %>(null); 12 | const svg = useRef<% if (isTS) { %> | null><% } %>(null); 13 | const g = useRef<% if (isTS) { %> | null><% } %>(null); 14 | const rect = useRef<% if (isTS) { %> | null><% } %>(null); 15 | 16 | useEffect(() => { 17 | svg.current = select(root.current) 18 | .append('svg') 19 | .attr('width', 400) 20 | .attr('height', 300); 21 | 22 | g.current = svg.current.append('g'); 23 | 24 | rect.current = g.current 25 | .append('rect') 26 | .attr('x', 0) 27 | .attr('y', 0) 28 | .attr('rx', 3) 29 | .attr('ry', 3) 30 | .attr('width', '100%') 31 | .attr('height', '100%'); 32 | }, []); 33 | 34 | useEffect(() => { 35 | if (!g.current) { 36 | return; 37 | } 38 | 39 | g.current.attr('fill', color); 40 | }, [color]); 41 | 42 | return
; 43 | }; 44 | 45 | export default <%= className %>; 46 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ## Contributing 2 | 3 | To contribute to the development of **aunty**, clone the project: 4 | 5 | ```bash 6 | git clone git@github.com:abcnews/aunty.git 7 | ``` 8 | 9 | ...then, from the project directory, run: 10 | 11 | ```bash 12 | npm link 13 | ``` 14 | 15 | This will link the globally-available `aunty` command to your clone. 16 | 17 | To revert to your original global install, run: 18 | 19 | ```bash 20 | npm unlink 21 | ``` 22 | 23 | ## Releasing new versions of `@abcnews/aunty` 24 | 25 | Releases are managed by `release-it`. To release a new version of aunty from the default branch, run: 26 | 27 | ``` 28 | npm run release 29 | ``` 30 | 31 | By default this will do the following: 32 | 33 | 1. Bump the `patch` version in `package.json` (and `package-lock.json` if it exists) 34 | 2. Commit and tag that version. 35 | 3. Push the tag & commit to GitHub 36 | 4. Publish to npm 37 | 38 | If you want to cut a minor or major release, run either of the following commands instead: 39 | 40 | ``` 41 | npm run release -- minor 42 | npm run release -- major 43 | ``` 44 | 45 | If you're ever unsure about what will happen, you can perform a dry run (which logs to the console) by running: 46 | 47 | ``` 48 | npm run release -- --dry-run 49 | ``` 50 | 51 | View the [`release-it` docs](https://www.npmjs.com/package/release-it) for full usage examples, including pre-release and npm tag management. 52 | 53 | ## Style 54 | 55 | This project's codebase should be managed with [eslint](https://github.com/eslint/eslint) and [prettier](https://github.com/prettier/prettier). You should configure your editor to take advantage of this to maintain the code style specifed in `.eslintrc` and `.prettierrc`. If your editor has a format-on-save option and a Prettier plugin, even better! 56 | -------------------------------------------------------------------------------- /src/cli/deploy/constants.js: -------------------------------------------------------------------------------- 1 | // Ours 2 | const { cmd, hvy, opt, req, sec } = require('../../utils/color'); 3 | const { styleLastSegment } = require('../../utils/text'); 4 | 5 | const VALID_TYPES = (module.exports.VALID_TYPES = new Map()); 6 | VALID_TYPES.set('ftp', { 7 | REQUIRED_PROPERTIES: ['from', 'to', 'type', 'username', 'password', 'host'] 8 | }); 9 | VALID_TYPES.set('ssh', { 10 | REQUIRED_PROPERTIES: ['from', 'to', 'type', 'username', 'host'] 11 | }); 12 | 13 | module.exports.MESSAGES = { 14 | deploy: ({ from, host, to, type }) => `Deploy (${hvy(type)}): 15 | ┣ ${hvy('from')}: ${styleLastSegment(from, req)} 16 | ┣ ${hvy('to')}: ${styleLastSegment(to, req)} 17 | ┗ ${hvy('host')}: ${req(host)}`, 18 | deployed: publicPath => `Deployed at ${sec(publicPath)}`, 19 | deploying: 'Deploying', 20 | missingProperty: prop => `Missing required property: '${hvy(prop)}'`, 21 | noDeployConfigFile: 22 | 'No deploy configuration file was found, or its format was unrecognisable. Please re-build the project.', 23 | noFromDirectory: from => `Directory specified by 'from' property does not exist: ${hvy(from)}`, 24 | publicURL: url => `Public URL: ${hvy(url)}`, 25 | unrecognisedType: type => 26 | `${type ? 'Unrecognised' : 'No'} deploy type${type ? `: ${hvy(type)}` : ''}. Acceptable types are: ${Array.from( 27 | VALID_TYPES.keys() 28 | ) 29 | .map(x => hvy(x)) 30 | .join(', ')}`, 31 | 32 | // TODO: Add aunty config section to usage 33 | usage: name => `Usage: ${cmd(`aunty ${name}`)} ${opt('[options]')} 34 | 35 | ${sec('Options')} 36 | 37 | ${opt('-d')}, ${opt('--dry')} Output the deploy configuration, then exit 38 | ${opt('-f')}, ${opt('--force')} Do not prompt to overwrite ftp deploy directory ${opt('[default: false]')} 39 | ` 40 | }; 41 | -------------------------------------------------------------------------------- /src/generators/project/templates/basic/src/index.ts: -------------------------------------------------------------------------------- 1 | import acto from '@abcnews/alternating-case-to-object'; 2 | import { <% if (isOdyssey) { %>whenOdysseyLoaded<% } else { %>whenDOMReady<% } %> } from '@abcnews/env-utils'; 3 | import { getMountValue, selectMounts } from '@abcnews/mount-utils';<% if (isTS) { %> 4 | import type { Mount } from '@abcnews/mount-utils';<% } %> 5 | import App from './components/App';<% if (isTS) { %> 6 | import type { AppProps } from './components/App';<% } %> 7 | 8 | let appMountEl<% if (isTS) { %>: Mount<% } %>; 9 | let appProps<% if (isTS) { %>: AppProps<% } %>; 10 | 11 | function renderApp() { 12 | render(new App(appProps).el, appMountEl); 13 | } 14 | 15 | <% if (isOdyssey) { %>whenOdysseyLoaded<% } else { %>whenDOMReady<% } %>.then(() => { 16 | [appMountEl] = selectMounts('<%= projectNameFlat %>'); 17 | 18 | if (appMountEl) { 19 | appProps = acto(getMountValue(appMountEl))<% if (isTS) { %> as AppProps<% } %>; 20 | renderApp(); 21 | } 22 | }); 23 | 24 | if (module.hot) { 25 | module.hot.accept('./components/App', () => { 26 | try { 27 | renderApp(); 28 | } catch (err) { 29 | import('./components/ErrorBox').then(({ default: ErrorBox }) => { 30 | render(new ErrorBox({ error: err<% if (isTS) { %> as Error<% } %> }).el, appMountEl); 31 | }); 32 | } 33 | }); 34 | } 35 | 36 | if (process.env.NODE_ENV === 'development') { 37 | console.debug(`[<%= projectName %>] public path: ${__webpack_public_path__}`); 38 | } 39 | 40 | function render(el<% if (isTS) { %>: Element<% } %>, parentEl<% if (isTS) { %>: Element | null<% } %>) { 41 | if (parentEl === null) { 42 | throw new Error('parentEl is not an Element'); 43 | } 44 | 45 | while (parentEl.firstElementChild) { 46 | parentEl.removeChild(parentEl.firstElementChild); 47 | } 48 | 49 | parentEl.appendChild(el); 50 | } 51 | -------------------------------------------------------------------------------- /scripts/postinstall.js: -------------------------------------------------------------------------------- 1 | /* 2 | Aunty v13 changes how build artefacts are created. When building a project, 3 | pre-v13, a .build folder and .deploy files were created inside your project 4 | directory. In v13, this was changed to a single .aunty directory, which 5 | contains a build filder and deploy.json file. 6 | 7 | This script runs after aunty is installed inside a project directory, and 8 | if the project was upgraded from a pre-v13 aunty, it will delete any old 9 | build artefacts, and update the .gitignore file to make sure the .aunty 10 | directory isn't committed, and remove the .build & .deploy references. 11 | */ 12 | 13 | const { existsSync, readFileSync, rmSync, rmdirSync, writeFileSync } = require('fs'); 14 | const { join } = require('path'); 15 | 16 | const INSTALLATION_DIRECTORY = process.env.INIT_CWD || '.'; 17 | 18 | const PRE_AUNTY_13_GITIGNORE_PATTERN = /\/\.build\n\/\.deploy/; 19 | const POST_AUNTY_13_GITIGNORE_REPLACEMENT = `/.aunty`; 20 | 21 | const PRE_AUNTY_13_BUILD_DIR_PATH = join(INSTALLATION_DIRECTORY, '.build'); 22 | const PRE_AUNTY_13_DEPLOY_CONFIG_FILE_PATH = join(INSTALLATION_DIRECTORY, '.deploy'); 23 | const GITIGNORE_PATH = join(INSTALLATION_DIRECTORY, '.gitignore'); 24 | 25 | if (existsSync(PRE_AUNTY_13_BUILD_DIR_PATH)) { 26 | rmdirSync(PRE_AUNTY_13_BUILD_DIR_PATH, { recursive: true }); 27 | } 28 | 29 | if (existsSync(PRE_AUNTY_13_DEPLOY_CONFIG_FILE_PATH)) { 30 | rmSync(PRE_AUNTY_13_DEPLOY_CONFIG_FILE_PATH); 31 | } 32 | 33 | if (existsSync(GITIGNORE_PATH)) { 34 | const fileContents = readFileSync(GITIGNORE_PATH, 'utf8'); 35 | 36 | if (PRE_AUNTY_13_GITIGNORE_PATTERN.test(fileContents)) { 37 | const updatedFileContents = fileContents.replace( 38 | PRE_AUNTY_13_GITIGNORE_PATTERN, 39 | POST_AUNTY_13_GITIGNORE_REPLACEMENT 40 | ); 41 | 42 | writeFileSync(GITIGNORE_PATH, updatedFileContents); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/utils/npm.js: -------------------------------------------------------------------------------- 1 | // Native 2 | const path = require('path'); 3 | 4 | // External 5 | const execa = require('execa'); 6 | 7 | // Ours 8 | const { getProjectConfig } = require('../config/project'); 9 | const { SPINNER_FRAMES } = require('./branding'); 10 | const { bgRed, cmd, ok } = require('./color'); 11 | const { log: _log, spin } = require('./logging'); 12 | 13 | const NPM_INSTALL_SPINNER_FRAMES = SPINNER_FRAMES.map(x => ` ${cmd(x)}`); 14 | 15 | /** 16 | * Take a list of dependencies and filter out any that are already in the package.json 17 | * @param {Array} dependencies 18 | * @param {boolean} isDev 19 | */ 20 | const onlyNewDependencies = (dependencies, isDev) => { 21 | try { 22 | const { pkg } = getProjectConfig(); 23 | const existingDependencies = Object.keys(pkg[isDev ? 'devDependencies' : 'dependencies...']); 24 | 25 | return dependencies.filter(dep => !existingDependencies.includes(dep) && dep !== null); 26 | } catch (ex) { 27 | // nothing 28 | } 29 | 30 | return dependencies; 31 | }; 32 | 33 | /** 34 | * Install dependencies (only if needed) 35 | * @param {Array} dependencies 36 | * @param {Array} args 37 | */ 38 | module.exports.installDependencies = async (dependencies, args, log = _log) => { 39 | args = args || []; 40 | dependencies = onlyNewDependencies(dependencies, args.includes('--save-dev')); 41 | 42 | if (dependencies.length === 0) { 43 | return; 44 | } 45 | 46 | const spinner = spin( 47 | `${bgRed.white('npm')} installing ${dependencies.length}${ 48 | args.includes('--save-dev') ? ' development' : '' 49 | } dependenc${dependencies.length == 1 ? 'y' : 'ies'}`, 50 | { frames: NPM_INSTALL_SPINNER_FRAMES } 51 | ); 52 | 53 | args = ['install', '--silent', '--no-progress'].concat(args).concat(dependencies); 54 | 55 | await execa('npm', args); 56 | 57 | spinner.stop(); 58 | dependencies.forEach(x => log(`${ok('installed')} ${x}`)); 59 | }; 60 | -------------------------------------------------------------------------------- /src/utils/text.js: -------------------------------------------------------------------------------- 1 | const EMPTY = ''; 2 | const NEWLINE = '\n'; 3 | const SLASH = '/'; 4 | const SPACE = ' '; 5 | 6 | const identity = x => x; 7 | 8 | const getLongest = items => items.reduce((a, b) => (a.length > b.length ? a : b)); 9 | 10 | module.exports.indented = (str, indent = 2) => str.split(`${NEWLINE}`).join(`${NEWLINE}${SPACE.repeat(indent)}`); 11 | 12 | module.exports.bulleted = strs => strs.map(str => `• ${str}`).join(NEWLINE); 13 | 14 | module.exports.inlineList = strs => 15 | strs.reduce((acc, str, index) => [acc, str].join(index === strs.length - 1 ? ' & ' : ', ')); 16 | 17 | const padding = (module.exports.padding = (str, len, char = SPACE) => 18 | char.repeat(len > str.length ? len - str.length : 0)); 19 | 20 | module.exports.padLeft = (str, len, char = SPACE) => padding(str, len, char) + String(str); 21 | 22 | const padRight = (module.exports.padRight = (str, len, char = SPACE) => String(str) + padding(str, len, char)); 23 | 24 | module.exports.listPairs = (x, style = identity) => { 25 | const keys = Object.keys(x); 26 | const longest = getLongest(keys).length; 27 | 28 | return keys 29 | .map(key => { 30 | return `${style(padRight(key, longest))} ${x[key]}`; 31 | }) 32 | .join(NEWLINE); 33 | }; 34 | 35 | module.exports.zipTemplateLiterals = (literals, numSpacesBetween) => { 36 | const literalsLines = literals.map(literal => literal.split(NEWLINE)); 37 | 38 | return literalsLines[0].reduce( 39 | (memo, _, index) => 40 | [memo, NEWLINE].join(literalsLines.map(lines => lines[index]).join(SPACE.repeat(numSpacesBetween))), 41 | EMPTY 42 | ); 43 | }; 44 | 45 | module.exports.styleLastSegment = (str, style = identity, separator = SLASH) => { 46 | return str 47 | .split(separator) 48 | .map((segment, index, segments) => { 49 | return index === segments.length - 1 ? style(segment) : segment; 50 | }) 51 | .join(separator); 52 | }; 53 | 54 | /** 55 | * Return a sluggified version of the string 56 | * 57 | * @param {string} input - The string to convert 58 | * @returns {string} 59 | */ 60 | module.exports.sluggify = input => 61 | input 62 | .toLowerCase() 63 | .replace(/\s/g, '-') 64 | .replace(/[^0-9a-z\-\_]/g, ''); 65 | -------------------------------------------------------------------------------- /src/utils/logging.js: -------------------------------------------------------------------------------- 1 | // Global 2 | const { error, log, time, timeEnd } = console; 3 | 4 | // Native 5 | const { inspect } = require('util'); 6 | 7 | // External 8 | const logSymbols = require('log-symbols'); 9 | const ora = require('ora'); 10 | 11 | // Ours 12 | const { SPINNER_FRAMES } = require('./branding'); 13 | const { cmd, opt, sec } = require('./color'); 14 | 15 | inspect.styles.name = 'blue'; 16 | 17 | const DEBUG_SYMBOL = cmd('»'); 18 | const UTIL_INSPECT_OPTIONS = { colors: true, depth: null }; 19 | 20 | module.exports.debug = (...args) => log(...[DEBUG_SYMBOL].concat(args)); 21 | module.exports.error = (...args) => error(...[logSymbols.error].concat(args)); 22 | module.exports.info = (...args) => log(...[logSymbols.info].concat(args)); 23 | module.exports.log = log; 24 | module.exports.success = (...args) => log(...[logSymbols.success].concat(args)); 25 | module.exports.warn = (...args) => error(...[logSymbols.warning].concat(args)); 26 | 27 | module.exports.timer = name => { 28 | const label = `${DEBUG_SYMBOL} ${opt(name)} time`; 29 | 30 | time(label); 31 | 32 | return () => timeEnd(label); 33 | }; 34 | 35 | // Can be used as a funtion or a tagged template literal; 36 | const pretty = (module.exports.pretty = (inputs, ...values) => { 37 | if (values.length === 0 || !Array.isArray(inputs) || inputs.filter(str => typeof str !== 'string').length > 0) { 38 | values = [...arguments]; 39 | inputs = values.map(() => ''); 40 | } 41 | 42 | const pairWithValue = (str, index) => { 43 | const value = values[index]; 44 | const valueStr = value == null ? '' : typeof value === 'string' ? value : inspect(value, UTIL_INSPECT_OPTIONS); 45 | 46 | return str + valueStr; 47 | }; 48 | 49 | return inputs.map(pairWithValue).join(''); 50 | }); 51 | 52 | module.exports.dry = config => { 53 | Object.keys(config).forEach(key => 54 | log( 55 | `${opt('[dry]')} ${sec(key)} 56 | 57 | ${pretty`${config[key]}`} 58 | ` 59 | ) 60 | ); 61 | }; 62 | 63 | module.exports.spin = (text, { color = 'cyan', frames = SPINNER_FRAMES } = {}) => { 64 | const spinner = ora({ 65 | color, 66 | spinner: { 67 | frames, 68 | interval: 80 69 | }, 70 | text 71 | }); 72 | 73 | return spinner.start(); 74 | }; 75 | -------------------------------------------------------------------------------- /src/cli/generate/index.js: -------------------------------------------------------------------------------- 1 | // External 2 | const importLazy = require('import-lazy')(require); 3 | const yeoman = importLazy('yeoman-environment'); 4 | 5 | // Ours 6 | const { pack } = require('../../utils/async'); 7 | const { announce } = require('../../utils/audio'); 8 | const { createCommandLogo } = require('../../utils/branding'); 9 | const { log } = require('../../utils/logging'); 10 | const { command } = require('../'); 11 | const { GENERATOR_ALIASES, GENERATORS, MESSAGES, OPTIONS } = require('./constants'); 12 | 13 | const generate = async argv => { 14 | if (argv.dry) { 15 | throw MESSAGES.noDryRuns; 16 | } 17 | 18 | const generatorName = GENERATOR_ALIASES[argv._[0]] || argv._[0]; 19 | 20 | // If we didn't supply a known generator name, blow up, unless we wanted 21 | // the generate command's usage message (in which case, print it, then exit). 22 | 23 | if (!GENERATORS.has(generatorName)) { 24 | if (!generatorName || generatorName.indexOf('-') === 0 || argv.help) { 25 | return log(MESSAGES.usage); 26 | } 27 | 28 | throw MESSAGES.generatorDoesNotExist(generatorName); 29 | } 30 | 31 | log(createCommandLogo(`generate ${generatorName}`)); 32 | 33 | // If we're in a test environment, insert the auto adapter with the provided answers 34 | const env = global.auntyYeomanAnswers 35 | ? yeoman.createEnv([], {}, new (require('yeoman-automation-adapter').AutoAdapter)(global.auntyYeomanAnswers, true)) 36 | : yeoman.createEnv(); 37 | 38 | // Register the generator 39 | 40 | const generatorPath = require.resolve(`../../generators/${generatorName}`); 41 | 42 | env.register(generatorPath, generatorName); 43 | 44 | // Run the generator, including known arguments 45 | 46 | const runArgs = [generatorName] 47 | .concat(argv['--']) 48 | .concat(argv.help ? ['--help'] : []) 49 | .join(' '); 50 | 51 | const [err] = await pack(env.run(runArgs)); 52 | 53 | if (err) { 54 | throw err; 55 | } 56 | 57 | if (argv.announce) { 58 | announce(); 59 | } 60 | }; 61 | 62 | module.exports = command( 63 | { 64 | name: 'generate', 65 | hasSubcommands: true, 66 | options: OPTIONS 67 | }, 68 | generate 69 | ); 70 | 71 | // Expose this for the tests 72 | module.exports._testGenerate = generate; 73 | -------------------------------------------------------------------------------- /src/utils/ftp.js: -------------------------------------------------------------------------------- 1 | // External 2 | const ftp = require('basic-ftp'); 3 | const { to: wrap } = require('await-to-js'); 4 | const { probe } = require('tcp-ping-sync'); 5 | 6 | const { addProfileProperties, addKnownProfileProperties } = require('../config/deploy'); 7 | const { INTERNAL_TEST_HOST } = require('../constants'); 8 | 9 | /** 10 | * Check if a project exists on FTP 11 | * @param {string} projectNameSlug 12 | * @returns {Promise} 13 | */ 14 | const projectExists = async projectNameSlug => { 15 | // If not on internal network FTP won't connect, so bail out 16 | const isOnInternalNetwork = probe(INTERNAL_TEST_HOST); 17 | if (!isOnInternalNetwork) throw new Error('Not on internal network'); 18 | 19 | const config = addProfileProperties(addKnownProfileProperties({})); 20 | const { host, username: user, password, to } = config; 21 | const [baseDir] = to.split(''); 22 | 23 | if (!host || !user || !password) throw new Error('Missing FTP credentials'); 24 | 25 | const client = new ftp.Client(); 26 | 27 | try { 28 | await client.access({ 29 | host, 30 | user, 31 | password, 32 | secure: false 33 | }); 34 | await client.cd(baseDir); 35 | const list = await client.list(); 36 | 37 | for (const item of list) { 38 | if (projectNameSlug === item.name) return true; 39 | } 40 | } catch (err) { 41 | throw err; 42 | } 43 | 44 | client.close(); 45 | 46 | return false; 47 | }; 48 | 49 | /** 50 | * Quick FTP check if deployment dir exists 51 | * @param {string} deployToDir - Remote dir to check 52 | * @returns {Promise} 53 | */ 54 | const deploymentExists = async deployToDir => { 55 | const config = addProfileProperties(addKnownProfileProperties({})); 56 | const { host, username: user, password, to } = config; 57 | 58 | const client = new ftp.Client(); 59 | 60 | const [accessErr] = await wrap( 61 | client.access({ 62 | host, 63 | user, 64 | password, 65 | secure: false 66 | }) 67 | ); 68 | if (accessErr) throw accessErr; 69 | 70 | const [cdError] = await wrap(client.cd(deployToDir)); 71 | if (cdError) { 72 | throw cdError; 73 | } 74 | 75 | client.close(); 76 | 77 | return false; 78 | }; 79 | 80 | module.exports = { projectExists, deploymentExists }; 81 | -------------------------------------------------------------------------------- /src/config/babel.js: -------------------------------------------------------------------------------- 1 | // External 2 | const importLazy = require('import-lazy')(require); 3 | const mem = require('mem'); 4 | const semver = importLazy('semver'); 5 | 6 | // Ours 7 | const { merge } = require('../utils/structures'); 8 | const { getProjectConfig } = require('./project'); 9 | 10 | const PROJECT_TYPES_CONFIG = { 11 | preact: { 12 | plugins: [ 13 | [ 14 | require.resolve('@babel/plugin-transform-react-jsx'), 15 | { 16 | pragma: 'h' 17 | } 18 | ] 19 | ] 20 | }, 21 | react: { 22 | presets: [require.resolve('@babel/preset-react')] 23 | } 24 | }; 25 | 26 | module.exports.getBabelConfig = mem( 27 | ({ isModernJS } = {}) => { 28 | const { babel: projectBabelConfig, pkg, hasTS, type } = getProjectConfig(); 29 | 30 | let corejs = '3'; 31 | 32 | // Minor version should be specified, if possible 33 | // https://babeljs.io/docs/en/babel-preset-env#corejs 34 | if (pkg.dependencies && pkg.dependencies['core-js']) { 35 | const corejsSemVer = semver.coerce(pkg.dependencies['core-js']); 36 | 37 | corejs = `${corejsSemVer.major}.${corejsSemVer.minor}`; 38 | } 39 | 40 | return merge( 41 | { 42 | presets: [ 43 | [ 44 | require.resolve('@babel/preset-env'), 45 | { 46 | targets: { 47 | browsers: isModernJS 48 | ? ['Chrome >= 80', 'Safari >= 12.1', 'iOS >= 12.3', 'Firefox >= 72', 'Edge >= 18'] 49 | : pkg.browserslist || ['> 1% in AU', 'Firefox ESR', 'IE 11'] 50 | }, 51 | useBuiltIns: 'entry', 52 | corejs, 53 | modules: process.env.NODE_ENV === 'test' ? 'commonjs' : false 54 | } 55 | ] 56 | ].concat( 57 | hasTS 58 | ? [ 59 | [ 60 | require.resolve('@babel/preset-typescript'), 61 | { 62 | onlyRemoveTypeImports: true 63 | } 64 | ] 65 | ] 66 | : [] 67 | ), 68 | plugins: [require.resolve('@babel/plugin-syntax-dynamic-import')] 69 | }, 70 | PROJECT_TYPES_CONFIG[type], 71 | projectBabelConfig 72 | ); 73 | }, 74 | { cacheKey: ({ isModernJS }) => isModernJS } 75 | ); 76 | -------------------------------------------------------------------------------- /ts/custom.d.ts: -------------------------------------------------------------------------------- 1 | // JSX intrinsic elements support (for preact) 2 | 3 | declare namespace JSX { 4 | interface IntrinsicElements { 5 | [elemName: string]: any; 6 | } 7 | } 8 | 9 | // Webpack loaders module declarations 10 | 11 | declare module '*.svelte' { 12 | const component: any; 13 | export default component; 14 | } 15 | 16 | type CSSModule = { 17 | [className: string]: string; 18 | }; 19 | 20 | type OptionalCSSModule = void | CSSModule; 21 | 22 | declare module '*.css' { 23 | const optionalCSSModule: CSSModule; 24 | export default optionalCSSModule; 25 | } 26 | 27 | declare module '*.scss' { 28 | const optionalCSSModule: CSSModule; 29 | export default optionalCSSModule; 30 | } 31 | 32 | declare module '*.jpg' { 33 | const url: string; 34 | export default url; 35 | } 36 | 37 | declare module '*.png' { 38 | const url: string; 39 | export default url; 40 | } 41 | 42 | declare module '*.gif' { 43 | const url: string; 44 | export default url; 45 | } 46 | 47 | declare module '*.mp4' { 48 | const url: string; 49 | export default url; 50 | } 51 | 52 | declare module '*.m4v' { 53 | const url: string; 54 | export default url; 55 | } 56 | 57 | declare module '*.flv' { 58 | const url: string; 59 | export default url; 60 | } 61 | 62 | declare module '*.mp3' { 63 | const url: string; 64 | export default url; 65 | } 66 | 67 | declare module '*.wav' { 68 | const url: string; 69 | export default url; 70 | } 71 | 72 | declare module '*.m4a' { 73 | const url: string; 74 | export default url; 75 | } 76 | 77 | declare module '*.woff' { 78 | const url: string; 79 | export default url; 80 | } 81 | 82 | declare module '*.woff2' { 83 | const url: string; 84 | export default url; 85 | } 86 | 87 | declare module '*.ttf' { 88 | const url: string; 89 | export default url; 90 | } 91 | 92 | declare module '*.eot' { 93 | const url: string; 94 | export default url; 95 | } 96 | 97 | declare module '*.svg' { 98 | const url: string; 99 | export default url; 100 | } 101 | 102 | declare module '*.html' { 103 | const html: string; 104 | export default html; 105 | } 106 | 107 | declare module '*.webp' { 108 | const webp: string; 109 | export default webp; 110 | } 111 | 112 | declare module '*.webm' { 113 | const webm: string; 114 | export default webm; 115 | } 116 | 117 | declare module '*.avif' { 118 | const avif: string; 119 | export default avif; 120 | } -------------------------------------------------------------------------------- /src/utils/remote.js: -------------------------------------------------------------------------------- 1 | // External 2 | const importLazy = require('import-lazy')(require); 3 | const FTPDeploy = importLazy('ftp-deploy'); 4 | const pify = importLazy('pify'); 5 | const rsyncwrapper = importLazy('rsyncwrapper'); 6 | 7 | // Ours 8 | const { packs } = require('./async'); 9 | const { dim } = require('./color'); 10 | const { combine } = require('./structures'); 11 | const { padLeft } = require('./text'); 12 | 13 | const DEFAULT_FTP_CONFIG = { 14 | port: 21 15 | }; 16 | 17 | const DEFAULT_RSYNC_CONFIG = { 18 | ssh: true, 19 | recursive: true 20 | }; 21 | 22 | module.exports.ftp = packs( 23 | ({ files, from, host, password, port, to, username }, spinner) => 24 | new Promise((resolve, reject) => { 25 | const ftpDeploy = new FTPDeploy(); 26 | const originalSpinnerText = spinner ? spinner.text : null; 27 | 28 | if (spinner) { 29 | ftpDeploy.on('uploaded', data => { 30 | const numFilesTransferred = data.transferredFileCount - 1; // `ftp-deploy` starts counting from 1 for some reason 31 | const filesTransferred = padLeft(numFilesTransferred, data.totalFilesCount.toString().length, ' '); 32 | const filename = numFilesTransferred === data.totalFilesCount ? '' : ` ${data.filename}`; 33 | 34 | spinner.text = `${originalSpinnerText} ${dim(`(${filesTransferred}/${data.totalFilesCount})${filename}`)}`; 35 | }); 36 | } 37 | 38 | // [1] The `ftp-deploy` package logs when it connects, and doesn't allow us to 39 | // make it quiet. While this task runs, temporarily re-map `console.log`. 40 | const _log = console.log; 41 | console.log = () => {}; 42 | 43 | ftpDeploy.deploy( 44 | combine(DEFAULT_FTP_CONFIG, { 45 | host, 46 | port: port || 21, 47 | user: username, 48 | password, 49 | localRoot: from, 50 | remoteRoot: to, 51 | include: [files], 52 | exclude: [], 53 | deleteRoot: false 54 | }), 55 | err => { 56 | // * [1] 57 | console.log = _log; 58 | 59 | if (err) { 60 | return reject(err); 61 | } 62 | 63 | if (spinner) { 64 | spinner.text = originalSpinnerText; 65 | } 66 | 67 | return resolve(); 68 | } 69 | ); 70 | }) 71 | ); 72 | 73 | module.exports.rsync = packs(({ files, from, host, to, username }) => 74 | pify(rsyncwrapper)( 75 | combine(DEFAULT_RSYNC_CONFIG, { 76 | src: `${from}/${Array.isArray(files) ? files[0] : files}`, 77 | dest: `${username}@${host}:${to}`, 78 | args: [`--rsync-path="mkdir -p ${to} && rsync"`] 79 | }) 80 | ) 81 | ); 82 | -------------------------------------------------------------------------------- /src/cli/release/constants.js: -------------------------------------------------------------------------------- 1 | // Ours 2 | const { cmd, hvy, ok, opt, sec } = require('../../utils/color'); 3 | 4 | const FORCE_REMINDER = `Use the ${opt('--force')} option to ignore warnings or release without tagging.`; 5 | const VALID_BUMPS = (module.exports.VALID_BUMPS = new Set(['major', 'minor', 'patch', 'prerelease'])); 6 | 7 | const OPTIONS = (module.exports.OPTIONS = { 8 | string: ['bump', 'git-remote'], 9 | default: { 10 | 'git-remote': 'origin' 11 | } 12 | }); 13 | 14 | module.exports.MESSAGES = { 15 | bumpQuestion: dry => 16 | `${ok('?')} ${hvy(`What package version bump ${dry ? 'would this release have been' : 'is this release'}?`)}`, 17 | changes: (tag, tags, changelog) => 18 | changelog.length 19 | ? `${tags.length ? `Here's what's changed since ${hvy(tag)}` : 'Here are the initial changes'}: 20 | 21 | ${changelog.join('\n')} 22 | ` 23 | : `${cmd('ℹ')} Nothing has changed since ${hvy(tag)}\n`, 24 | createCommit: (from, to) => `Bump version ${hvy(from)} → ${hvy(to)}`, 25 | createTag: tag => `Create tag ${hvy(tag)}`, 26 | forceReminder: FORCE_REMINDER, 27 | hasChanges: `You shouldn't release builds which may contain un-committed changes! ${FORCE_REMINDER}`, 28 | hasTag: (tag, isTagOnHead) => 29 | `The tag ${hvy(tag)} already exists${ 30 | isTagOnHead ? '' : ` and your current HEAD doesn't point to it` 31 | }! ${FORCE_REMINDER}`, 32 | invalidBump: bump => 33 | `You supplied an invalid bump value ${hvy(bump)}. It can be: ${hvy(Array.from(VALID_BUMPS).join('|'))}`, 34 | notDefaultBranch: (label, defaultBranch) => 35 | `You are trying to release from ${hvy(label)}. You should only release from ${hvy(defaultBranch)}`, 36 | notRepo: `You can't tag a release or deploy using a tag name becase this project isn't a git repo.`, 37 | pushCommit: remote => `Push version bump to remote ${hvy(remote)}`, 38 | pushTag: (tag, remote) => `Push tag ${hvy(tag)} to remote ${hvy(remote)}`, 39 | usage: name => `Usage: ${cmd(`aunty ${name}`)} ${opt('[options]')} 40 | 41 | ${sec('Options')} 42 | 43 | ${opt(`--bump={${Array.from(VALID_BUMPS).join('|')}}`)} Preselect the package version bump for this release ${opt( 44 | '[default: PROMPT]' 45 | )} 46 | ${opt('--git-remote')} Git remote to push release tags to (if it exists) ${opt( 47 | `[default: "${OPTIONS.default['git-remote']}"]` 48 | )} 49 | ${opt('-d')}, ${opt('--dry')} Output the release version & git remote, then exit 50 | ${opt('-f')}, ${opt('--force')} Ignore warnings & don't bump version ${opt('[default: false]')} 51 | 52 | ${sec('Examples')} 53 | 54 | ${cmd('aunty release')} 55 | 1. Ensure working directory is in a deployable state 56 | 2. Prompt for version bump choice and determine release version 57 | 3. Run \`${cmd('aunty build')} ${opt('--id=')}\` 58 | 4. Commit & tag new version, pushing to existing git remote 59 | 5. Run \`${cmd('aunty deploy')}\` 60 | 61 | ${cmd(`aunty release ${opt('--bump=major')}`)} 62 | As above, skipping (2). 63 | 64 | ${cmd(`aunty release ${opt('--force')}`)} 65 | As above, skipping (1, 2 & 4). 66 | ` 67 | }; 68 | -------------------------------------------------------------------------------- /src/cli/build/index.js: -------------------------------------------------------------------------------- 1 | // Native 2 | const { join } = require('path'); 3 | 4 | // External 5 | const importLazy = require('import-lazy')(require); 6 | const pify = importLazy('pify'); 7 | const webpack = importLazy('webpack'); 8 | const writeJsonFile = importLazy('write-json-file'); 9 | 10 | // Ours 11 | const { getDeployConfig } = require('../../config/deploy'); 12 | const { getProjectConfig } = require('../../config/project'); 13 | const { getWebpackConfig } = require('../../config/webpack'); 14 | const { DEPLOY_FILE_NAME, OUTPUT_DIRECTORY_NAME } = require('../../constants'); 15 | const { packs, throws, unpack } = require('../../utils/async'); 16 | const { dry, info, spin, warn } = require('../../utils/logging'); 17 | const { combine } = require('../../utils/structures'); 18 | const { command } = require('../'); 19 | const cleanCommand = require('../clean'); 20 | const { MESSAGES, OPTIONS } = require('./constants'); 21 | 22 | const build = async argv => { 23 | const { root } = getProjectConfig(); 24 | const webpackConfig = getWebpackConfig(); 25 | let deployConfig; 26 | 27 | if (!argv.local) { 28 | deployConfig = getDeployConfig({ id: argv.id }); 29 | webpackConfig.forEach(config => { 30 | config.output.publicPath = deployConfig.targets[0].publicPath; 31 | }); 32 | } 33 | 34 | if (argv.dry) { 35 | return dry( 36 | combine( 37 | { 38 | 'Webpack config': webpackConfig 39 | }, 40 | deployConfig 41 | ? { 42 | 'Deploy config': deployConfig 43 | } 44 | : {} 45 | ) 46 | ); 47 | } 48 | 49 | throws(await cleanCommand(['--quiet'])); 50 | 51 | info( 52 | MESSAGES.build({ 53 | id: deployConfig ? deployConfig.id : null, 54 | publicPaths: deployConfig ? deployConfig.targets.map(x => x.publicPath) : [webpackConfig[0].output.publicPath] 55 | }) 56 | ); 57 | 58 | let spinner = spin('Building'); 59 | const startTime = Date.now(); 60 | const compiler = webpack(webpackConfig); 61 | const stats = unpack(await packs(pify(compiler.run.bind(compiler)))()); 62 | 63 | if (stats.hasErrors()) { 64 | const errors = stats.toJson({}, true).errors; 65 | 66 | spinner.fail(); 67 | 68 | if (errors.length > 1) { 69 | throw MESSAGES.multipleErrors(errors.map(error => error.message)); 70 | } 71 | 72 | throw errors[0]; 73 | } 74 | 75 | if (stats.hasWarnings()) { 76 | spinner.warn(); 77 | stats.toJson({}, true).warnings.forEach(warning => warn(warning.message)); 78 | } else { 79 | spinner.succeed(`Built in ${((Date.now() - startTime) / 1000).toFixed(2)}s`); 80 | } 81 | 82 | if (deployConfig) { 83 | spinner = spin('Creating deploy configuration'); 84 | writeJsonFile.sync(join(root, OUTPUT_DIRECTORY_NAME, DEPLOY_FILE_NAME), deployConfig); 85 | spinner.succeed('Created deploy configuration'); 86 | } 87 | }; 88 | 89 | module.exports = command( 90 | { 91 | name: 'build', 92 | nodeEnv: 'production', 93 | options: OPTIONS, 94 | usage: MESSAGES.usage 95 | }, 96 | build 97 | ); 98 | 99 | // Expose this for the tests 100 | module.exports._testBuild = build; 101 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@abcnews/aunty", 3 | "version": "16.0.1", 4 | "description": "A toolkit for working with ABC News projects", 5 | "repository": "abcnews/aunty", 6 | "license": "MIT", 7 | "contributors": [ 8 | "Colin Gourlay ", 9 | "Simon Elvery ", 10 | "Joshua Byrd ", 11 | "Ash Kyd ", 12 | "Nathan Hoad" 13 | ], 14 | "scripts": { 15 | "postinstall": "node scripts/postinstall.js", 16 | "release": "release-it", 17 | "test": "jest test/test.js" 18 | }, 19 | "files": [ 20 | "assets", 21 | "scripts", 22 | "src", 23 | "ts" 24 | ], 25 | "publishConfig": { 26 | "access": "public" 27 | }, 28 | "bin": { 29 | "aunty": "src/bin/aunty.js", 30 | "nt": "src/bin/aunty.js" 31 | }, 32 | "main": "src/api", 33 | "engines": { 34 | "node": ">=16" 35 | }, 36 | "dependencies": { 37 | "@babel/core": "^7.11.4", 38 | "@babel/plugin-syntax-dynamic-import": "^7.2.0", 39 | "@babel/plugin-transform-react-jsx": "^7.3.0", 40 | "@babel/preset-env": "^7.6.0", 41 | "@babel/preset-react": "^7.0.0", 42 | "@babel/preset-typescript": "^7.10.4", 43 | "await-to-js": "^3.0.0", 44 | "babel-core": "^7.0.0-bridge.0", 45 | "babel-jest": "^26.1.0", 46 | "babel-loader": "^8.2.2", 47 | "basic-ftp": "^5.0.2", 48 | "chalk": "^4.1.2", 49 | "cli-select": "^1.1.2", 50 | "copy-webpack-plugin": "^11.0.0", 51 | "css-loader": "^6.7.1", 52 | "csv-loader": "^3.0.3", 53 | "deepmerge": "^4.0.0", 54 | "del": "^6.0.0", 55 | "dotenv": "^16.0.3", 56 | "dotenv-webpack": "^8.0.1", 57 | "execa": "^5.0.0", 58 | "fork-ts-checker-webpack-plugin": "^6.2.1", 59 | "ftp-deploy": "^2.3.8", 60 | "get-all-paths": "^1.0.1", 61 | "guess-root-path": "^1.0.0", 62 | "i": "^0.3.6", 63 | "import-lazy": "^4.0.0", 64 | "import-local": "^3.0.2", 65 | "jest": "^26.4.1", 66 | "load-json-file": "^6.2.0", 67 | "log-symbols": "^4.0.0", 68 | "make-dir": "^3.0.0", 69 | "mem": "^8.1.0", 70 | "mini-css-extract-plugin": "^1.4.1", 71 | "minimist": "^1.2.5", 72 | "node-fetch": "^2.6.0", 73 | "ora": "^5.0.0", 74 | "pify": "^5.0.0", 75 | "prettier": "^2.0.5", 76 | "requireg": "^0.2.2", 77 | "rsyncwrapper": "^3.0.1", 78 | "sass": "^1.96.0", 79 | "sass-loader": "^16.0.6", 80 | "semver": "^7.3.2", 81 | "style-loader": "^2.0.0", 82 | "svelte": "^5.17.4", 83 | "svelte-loader": "^3.2.4", 84 | "svelte-preprocess": "^5.1.1", 85 | "tcp-ping-sync": "^1.0.0", 86 | "ts-loader": "^9.5.1", 87 | "typescript": "^5.3.2", 88 | "update-notifier": "^6.0.2", 89 | "webpack": "^5.33.2", 90 | "webpack-bundle-analyzer": "^4.5.0", 91 | "webpack-dev-server": "^4.6.0", 92 | "write-json-file": "^4.2.0", 93 | "yeoman-environment": "^3.2.0", 94 | "yeoman-generator": "^5.2.0" 95 | }, 96 | "devDependencies": { 97 | "@types/jest": "^29.5.10", 98 | "babel-eslint": "^10.1.0", 99 | "eslint": "^7.7.0", 100 | "eslint-config-prettier": "^8.2.0", 101 | "release-it": "^15.1.4", 102 | "yeoman-automation-adapter": "^2.0.0" 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /src/cli/constants.js: -------------------------------------------------------------------------------- 1 | // Ours 2 | const { createLogo } = require('../utils/branding'); 3 | const { cmd, dim, hvy, opt, req, sec } = require('../utils/color'); 4 | 5 | const COMMAND_ALIASES = (module.exports.COMMAND_ALIASES = { 6 | b: 'build', 7 | c: 'clean', 8 | d: 'deploy', 9 | g: 'generate', 10 | r: 'release', 11 | s: 'serve', 12 | sc: 'sign-cert', 13 | t: 'test' 14 | }); 15 | 16 | module.exports.COMMANDS = new Set(Object.values(COMMAND_ALIASES)); 17 | 18 | module.exports.DEFAULTS = { 19 | name: '__command__', 20 | options: { 21 | '--': true, 22 | boolean: ['dry', 'force', 'help', 'quiet'], 23 | string: [], 24 | alias: { 25 | dry: 'd', 26 | force: 'f', 27 | help: 'h', 28 | quiet: 'q' 29 | } 30 | }, 31 | usage: name => `Usage: ${cmd('aunty')} ${cmd(name)} ${opt('[options]')} 32 | ` 33 | }; 34 | 35 | module.exports.DRY_VARIATIONS = ['--dry', '-d']; 36 | module.exports.HELP_VARIATIONS = ['--help', '-h', 'h', 'help']; 37 | module.exports.VERSION_VARIATIONS = ['--version', '-v']; 38 | 39 | module.exports.MESSAGES = { 40 | version: versionNumber => ` 41 | ${cmd('aunty')} v${versionNumber}`, 42 | unrecognised: commandName => `Unrecognised command: ${req(commandName)}`, 43 | usage: isProject => `${createLogo()} 44 | 45 | Usage: ${cmd('aunty')} ${req('')} ${opt('[options]')} ${opt('[command_options]')} 46 | 47 | ${sec('Options')} 48 | 49 | ${opt('-v')}, ${opt('--version')} Print ${hvy('aunty')}'s version 50 | 51 | ${sec('Project creation commands')} 52 | 53 | ${ 54 | !isProject 55 | ? `${cmd('aunty new')} ${req('')} 56 | Create a project in a new directory 57 | 58 | ${cmd('aunty init')} 59 | Create a project in the current directory` 60 | : dim(`[available outside project directory]`) 61 | } 62 | 63 | ${sec('Development commands')} 64 | 65 | ${ 66 | isProject 67 | ? `${cmd('aunty generate')} ${req('')} 68 | Generate code for your project or Core Media 69 | 70 | ${cmd('aunty clean')} 71 | Delete the current project's build output directories. 72 | 73 | ${cmd('aunty build')} 74 | Clean & build the current project. 75 | 76 | ${cmd('aunty serve')} 77 | Build & serve the current project, re-building as files change 78 | 79 | ${cmd('aunty test')} 80 | Run any tests in the current project.` 81 | : dim(`[available inside project directory]`) 82 | } 83 | 84 | ${sec('Deployment commands')} 85 | 86 | ${ 87 | isProject 88 | ? `${cmd('aunty deploy')} 89 | Deploy the current project. 90 | 91 | ${cmd('aunty release')} 92 | Build, version bump, then deploy the current project.` 93 | : dim(`[available inside project directory]`) 94 | } 95 | 96 | ${sec('Helper commands')} 97 | 98 | ${cmd('aunty help')} ${req('')} 99 | Display complete help for this ${req('command')}. 100 | 101 | ${cmd('aunty sign-cert')} # Mac OS only (for now) 102 | Create a consistent SSL certificate for the dev server 103 | ` 104 | }; 105 | 106 | const NEW_SHORTHAND_EXPANSION = ['generate', 'project', '--']; 107 | const INIT_SHORTHAND_EXPANSION = NEW_SHORTHAND_EXPANSION.concat(['--here']); 108 | 109 | module.exports.SHORTHANDS = { 110 | i: INIT_SHORTHAND_EXPANSION, 111 | init: INIT_SHORTHAND_EXPANSION, 112 | n: NEW_SHORTHAND_EXPANSION, 113 | new: NEW_SHORTHAND_EXPANSION 114 | }; 115 | -------------------------------------------------------------------------------- /src/cli/serve/index.js: -------------------------------------------------------------------------------- 1 | // External 2 | const importLazy = require('import-lazy')(require); 3 | const webpack = importLazy('webpack'); 4 | const WebpackDevServer = importLazy('webpack-dev-server'); 5 | const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer'); 6 | 7 | // Ours 8 | const { getServeConfig } = require('../../config/serve'); 9 | const { getWebpackConfig } = require('../../config/webpack'); 10 | const { getWebpackDevServerConfig } = require('../../config/webpackDevServer'); 11 | const { throws } = require('../../utils/async'); 12 | const { dry, info, spin } = require('../../utils/logging'); 13 | const { combine } = require('../../utils/structures'); 14 | const { command } = require('../'); 15 | const cleanCommand = require('../clean'); 16 | const { BUNDLE_ANALYZER_CONFIG, MESSAGES } = require('./constants'); 17 | 18 | module.exports = command( 19 | { 20 | name: 'serve', 21 | nodeEnv: 'development', 22 | usage: MESSAGES.usage 23 | }, 24 | async argv => { 25 | const { hasBundleAnalysis, port } = await getServeConfig(); 26 | const webpackConfig = getWebpackConfig(); 27 | const webpackDevServerConfig = await getWebpackDevServerConfig(); 28 | const { hot, devMiddleware } = webpackDevServerConfig; 29 | const { publicPath } = devMiddleware; 30 | const bundleAnalyzerConfig = hasBundleAnalysis 31 | ? combine(BUNDLE_ANALYZER_CONFIG, { 32 | analyzerPort: +port + Math.floor(port / 1000) * 100 // e.g. 8000 -> 8800 33 | }) 34 | : null; 35 | 36 | webpackConfig.forEach((config, index) => { 37 | config.output.publicPath = publicPath; 38 | 39 | config.infrastructureLogging = { 40 | level: 'warn' 41 | }; 42 | 43 | if (bundleAnalyzerConfig) { 44 | config.plugins.push( 45 | new BundleAnalyzerPlugin( 46 | combine(bundleAnalyzerConfig, { 47 | analyzerPort: bundleAnalyzerConfig.analyzerPort + index * 10 // e.g. 8800, 8810... 48 | }) 49 | ) 50 | ); 51 | } 52 | }); 53 | 54 | if (argv.dry) { 55 | return dry({ 56 | 'Webpack config': webpackConfig, 57 | 'WebpackDevServer config': webpackDevServerConfig, 58 | ...(bundleAnalyzerConfig 59 | ? { 60 | 'BundleAnalyzerPlugin config': bundleAnalyzerConfig 61 | } 62 | : {}) 63 | }); 64 | } 65 | 66 | throws(await cleanCommand(['--quiet'])); 67 | 68 | info( 69 | MESSAGES.serve({ 70 | bundleAnalysisPath: bundleAnalyzerConfig ? MESSAGES.analysis(bundleAnalyzerConfig) : null, 71 | hot, 72 | publicPath 73 | }) 74 | ); 75 | 76 | const compiler = webpack(webpackConfig); 77 | const server = new WebpackDevServer(webpackDevServerConfig, compiler); 78 | const [gracefullyInterrupt, restore] = gracefullyHandleLogging(); 79 | 80 | return new Promise((resolve, reject) => { 81 | server.startCallback(err => { 82 | if (err) { 83 | return reject(err); 84 | } 85 | 86 | spinner = spin('Server running'); 87 | gracefullyInterrupt(spinner); 88 | }); 89 | 90 | process.on('SIGINT', () => { 91 | spinner.clear(); 92 | server.stopCallback(() => { 93 | if (spinner) { 94 | spinner.succeed('Server closed'); 95 | } 96 | 97 | resolve(); 98 | }); 99 | }); 100 | }).finally(() => restore()); 101 | } 102 | ); 103 | 104 | const gracefullyHandleLogging = () => { 105 | const METHODS = ['debug', 'error', 'info', 'log', 'warn']; 106 | const reference = {}; 107 | 108 | for (let method of METHODS) { 109 | reference[method] = console[method]; 110 | } 111 | 112 | return [ 113 | spinner => { 114 | for (let method of METHODS) { 115 | console[method] = (...args) => { 116 | spinner.clear(); 117 | reference[method](...args); 118 | spinner.start(); 119 | }; 120 | } 121 | }, 122 | () => { 123 | for (let method of METHODS) { 124 | console[method] = reference[method]; 125 | } 126 | } 127 | ]; 128 | }; 129 | -------------------------------------------------------------------------------- /src/cli/index.js: -------------------------------------------------------------------------------- 1 | // Env 2 | require('dotenv').config(); 3 | 4 | // Native 5 | const { resolve } = require('path'); 6 | 7 | // External 8 | const importLazy = require('import-lazy')(require); 9 | const minimist = importLazy('minimist'); 10 | 11 | // Ours 12 | const pkg = require('../../package'); 13 | const { getProjectConfig } = require('../config/project'); 14 | const { pack, packs, throws } = require('../utils/async'); 15 | const { createCommandLogo } = require('../utils/branding'); 16 | const { log, timer } = require('../utils/logging'); 17 | const { merge } = require('../utils/structures'); 18 | const { 19 | COMMAND_ALIASES, 20 | COMMANDS, 21 | DEFAULTS, 22 | DRY_VARIATIONS, 23 | HELP_VARIATIONS, 24 | MESSAGES, 25 | SHORTHANDS, 26 | VERSION_VARIATIONS 27 | } = require('./constants'); 28 | 29 | module.exports.cli = packs(async args => { 30 | const __DEBUG__stopTimer = timer('CLI'); 31 | 32 | const updateNotifier = (await import('update-notifier')).default; 33 | 34 | updateNotifier({ pkg, updateCheckInterval: 36e5 }).notify(); 35 | 36 | // If we just want aunty's version number, print it, then exit 37 | 38 | if (args.some(arg => VERSION_VARIATIONS.includes(arg))) { 39 | return log(MESSAGES.version(pkg.version)); 40 | } 41 | 42 | // Normalise the arguments (see function) and split them 43 | 44 | args = normalise(args); 45 | const [commandName, ...commandArgs] = args; 46 | 47 | // If we didn't supply a known command name, blow up, unless we wanted 48 | // aunty's usage message (in which case, print it, then exit). 49 | 50 | if (!COMMANDS.has(commandName)) { 51 | if (!commandName || args.includes(HELP_VARIATIONS[0])) { 52 | let isProject = true; 53 | 54 | try { 55 | const { type } = getProjectConfig(); 56 | 57 | isProject = !!type; 58 | } catch (e) { 59 | isProject = false; 60 | } 61 | 62 | return log(MESSAGES.usage(isProject)); 63 | } 64 | 65 | throw MESSAGES.unrecognised(commandName); 66 | } 67 | 68 | // Import the command 69 | 70 | const __DEBUG__stopImportTimer = timer(`CLI.import(${commandName})`); 71 | 72 | const commandFn = require(resolve(__dirname, `./${commandName}`)); 73 | 74 | if (process.env.AUNTY_DEBUG) { 75 | __DEBUG__stopImportTimer(); 76 | } 77 | 78 | // Execute the command with the remaining arguments 79 | 80 | const __DEBUG__stopExecuteTimer = timer(`CLI.execute(${commandName})`); 81 | 82 | throws(await commandFn(commandArgs, true)); 83 | 84 | if (process.env.AUNTY_DEBUG) { 85 | __DEBUG__stopExecuteTimer(); 86 | __DEBUG__stopTimer(); 87 | } 88 | }); 89 | 90 | const normalise = args => { 91 | // 1) Move any dry argument variation to second position, as '--dry' 92 | 93 | const dryArgIndex = args.findIndex(arg => DRY_VARIATIONS.includes(arg)); 94 | 95 | if (dryArgIndex > -1) { 96 | args.splice(dryArgIndex, 1); 97 | args.splice(1, 0, DRY_VARIATIONS[0]); 98 | } 99 | 100 | // 2) Move any help argument variation to second position, as '--help' 101 | 102 | const helpArgIndex = args.findIndex(arg => HELP_VARIATIONS.includes(arg)); 103 | 104 | if (helpArgIndex > -1) { 105 | args.splice(helpArgIndex, 1); 106 | args.splice(1, 0, HELP_VARIATIONS[0]); 107 | } 108 | 109 | // 3) If the first argument is a command alias, expand it 110 | 111 | args[0] = COMMAND_ALIASES[args[0]] || args[0]; 112 | 113 | // 4) If the first argument is a shorthand, expand it 114 | 115 | const shorthand = SHORTHANDS[args[0]]; 116 | 117 | if (shorthand) { 118 | args.splice.apply(args, [0, 1].concat(shorthand)); 119 | } 120 | 121 | // 5) Return the normalised arguments 122 | 123 | return args; 124 | }; 125 | 126 | module.exports.command = ({ name, nodeEnv, options, usage, hasSubcommands }, fn) => { 127 | name = name || DEFAULTS.name; 128 | options = merge(DEFAULTS.options, options); 129 | usage = usage || DEFAULTS.usage; 130 | 131 | return packs(async (args = [], isEntryCommand) => { 132 | const argv = minimist(args, options); 133 | 134 | if (!hasSubcommands) { 135 | if (argv.help) { 136 | return log(typeof usage === 'function' ? usage(name) : usage); 137 | } 138 | 139 | if (isEntryCommand) { 140 | log(createCommandLogo(name, argv.dry)); 141 | } 142 | } 143 | 144 | if (!process.env.NODE_ENV && nodeEnv) { 145 | process.env.NODE_ENV = nodeEnv; 146 | } 147 | 148 | let [err] = await pack(fn(argv)); 149 | 150 | if (err) { 151 | throw err; 152 | } 153 | }); 154 | }; 155 | -------------------------------------------------------------------------------- /src/utils/git.js: -------------------------------------------------------------------------------- 1 | // Native 2 | const { spawnSync } = require('child_process'); 3 | 4 | // External 5 | const execa = require('execa'); 6 | const fetch = require('node-fetch'); 7 | const { compare, valid } = require('semver'); 8 | 9 | // Ours 10 | const { pack } = require('./async'); 11 | const { spin } = require('./logging'); 12 | const { combine } = require('./structures'); 13 | 14 | const PATTERNS = { 15 | ACTIVE_BRANCH: /\*\s+([^\n]+)/, 16 | REMOTE_HEAD_BRANCH: /HEAD branch: ([\w-]+)/, 17 | DETACHED_HEAD: /\(HEAD detached at ([\w]+)\)/ 18 | }; 19 | 20 | const GIT_DEFAULT_INIT_DEFAULT_BRANCH = 'master'; 21 | 22 | function git(args = [], options = {}) { 23 | args = typeof args === 'string' ? args.split(' ') : args; 24 | 25 | return execa('git', args, options); 26 | } 27 | 28 | const GIT_SYNC_DEFAULTS = { 29 | encoding: 'utf-8' 30 | }; 31 | 32 | function gitSync(args = '', options = {}) { 33 | args = typeof args === 'string' ? args.split(' ') : args; 34 | 35 | return spawnSync('git', args, combine(GIT_SYNC_DEFAULTS, options)); 36 | } 37 | 38 | module.exports.isRepo = async () => !(await pack(git('rev-parse --git-dir')))[0]; 39 | 40 | module.exports.isRepoSync = () => !gitSync('rev-parse --git-dir').stderr; 41 | 42 | const getConfigValue = (module.exports.getConfigValue = async key => (await git(`config --get ${key}`)).stdout); 43 | 44 | const getRemotes = (module.exports.getRemotes = async () => 45 | new Set((await git('remote')).stdout.split('\n').filter(x => x))); 46 | 47 | module.exports.hasChanges = async () => (await git('status -s')).stdout.length > 0; 48 | 49 | const _parseLabel = stdout => { 50 | const [, branch] = stdout.match(PATTERNS.ACTIVE_BRANCH) || [null, 'uncommitted']; 51 | const [, detachedHeadCommit] = branch.match(PATTERNS.DETACHED_HEAD) || []; 52 | 53 | return detachedHeadCommit || branch; 54 | }; 55 | 56 | module.exports.getCurrentLabel = async () => { 57 | return _parseLabel((await git('branch')).stdout); 58 | }; 59 | 60 | module.exports.getCurrentLabelSync = () => { 61 | return _parseLabel(gitSync('branch').stdout); 62 | }; 63 | 64 | module.exports.commitAll = message => git(['commit', '-a', '-m', `${message}`]); 65 | 66 | module.exports.push = () => git('push'); 67 | 68 | module.exports.createTag = tag => git(['tag', '-a', tag, '-m', `Tagging version ${tag}`]); 69 | 70 | module.exports.pushTag = (remote, tag) => git(['push', remote, tag]); 71 | 72 | module.exports.createRepo = async cwd => { 73 | await git('init', { cwd }); 74 | await git('add .', { cwd }); 75 | return git(['commit', '-m', 'Initial commit'], { cwd }); 76 | }; 77 | 78 | /** 79 | * Look up the current package.json version of a repo's default branch 80 | */ 81 | module.exports.getGithubVersion = async (repo, defaultBranch) => { 82 | const spinner = spin(`Fetching latest version of ${repo}`); 83 | const p = await fetch(`https://raw.githubusercontent.com/${repo}/${defaultBranch}/package.json`).then(r => r.json()); 84 | 85 | spinner.stop(); 86 | 87 | return p.version; 88 | }; 89 | 90 | module.exports.getDefaultBranch = async remote => { 91 | const remotes = await getRemotes(); 92 | let localDefaultBranch; 93 | 94 | if (remotes.has(remote)) { 95 | // If we have a valid remote, find out which local branch is configured to pull from the remote default branch 96 | try { 97 | const remoteShowStdout = (await git(`remote show ${remote}`)).stdout; 98 | const remoteDefaultBranch = (remoteShowStdout.match(PATTERNS.REMOTE_HEAD_BRANCH) || [])[1]; 99 | if (remoteDefaultBranch) { 100 | localDefaultBranch = (remoteShowStdout.match( 101 | new RegExp(`([\\w-]+)\\s+merges with remote ${remoteDefaultBranch}`) 102 | ) || [])[1]; 103 | } 104 | } catch (err) {} 105 | } 106 | 107 | if (!localDefaultBranch) { 108 | // git v2.28 allows you to set `init.defaultBranch` in your global config. 109 | // Assume we used this for our local branch (before we set up a remote) 110 | try { 111 | localDefaultBranch = (await getConfigValue(`--global init.defaultBranch`)).trim(); 112 | } catch (err) {} 113 | } 114 | 115 | // Return a determined local default branch or git's default branch name (master, for now) 116 | return localDefaultBranch || GIT_DEFAULT_INIT_DEFAULT_BRANCH; 117 | }; 118 | 119 | module.exports.getSemverTags = async () => (await git('tag')).stdout.split('\n').filter(valid).sort(compare); 120 | 121 | const hasTag = async tag => !(await pack(git(`show-ref --tags --verify refs/tags/${tag}`)))[0]; 122 | 123 | module.exports.getChangelog = async tag => 124 | (await git(`log ${(await hasTag(tag)) ? `${tag}..HEAD ` : ''}--oneline --color`)).stdout 125 | .split('\n') 126 | .filter(x => x) 127 | .reverse(); 128 | -------------------------------------------------------------------------------- /src/config/deploy.js: -------------------------------------------------------------------------------- 1 | // Native 2 | const { join } = require('path'); 3 | 4 | // External 5 | const importLazy = require('import-lazy')(require); 6 | const loadJsonFile = importLazy('load-json-file'); 7 | 8 | // Ours 9 | const { VALID_TYPES } = require('../cli/deploy/constants'); 10 | const { isRepoSync, getCurrentLabelSync } = require('../utils/git'); 11 | const { warn } = require('../utils/logging'); 12 | const { combine } = require('../utils/structures'); 13 | const { getBuildConfig } = require('./build'); 14 | const { getProjectConfig } = require('./project'); 15 | 16 | const DEFAULT_PROFILES_FILE_PATH = join( 17 | process.env.HOME || process.env.HOMEPATH || process.env.USERPROFILE, 18 | '.abc-credentials' 19 | ); 20 | 21 | const KNOWN_PROFILES_CONFIG = { 22 | contentftp: { 23 | to: '/www/res/sites/news-projects//', 24 | resolvePublicPath: config => `${config.to.replace('/www', 'https://www.abc.net.au')}/` 25 | } 26 | }; 27 | 28 | const MESSAGES = { 29 | incomplete: 'Your deploy configuration may be missing required properties.', 30 | noProfile: () => `Your profiles file doesn't contain a "${profile}" profile. ${MESSAGES.incomplete}`, 31 | noProfilesFile: path => `Could not load a profiles file from "${path}". ${MESSAGES.incomplete}` 32 | }; 33 | 34 | module.exports.getDeployConfig = ({ id } = {}) => { 35 | let { deploy: projectDeployTargetsConfigs } = getProjectConfig(); 36 | const { to } = getBuildConfig(); 37 | 38 | id = id || (isRepoSync() && getCurrentLabelSync()) || 'unversioned'; 39 | 40 | // Ensure projectDeployTargetsConfigs is not a primitive 41 | if (typeof projectDeployTargetsConfigs !== 'object') { 42 | projectDeployTargetsConfigs = {}; 43 | } 44 | 45 | // ## DEPRECATED ## 46 | // Convert old profile key-based config objects to array 47 | else if (!Array.isArray(projectDeployTargetsConfigs)) { 48 | const projectDeployTargetsConfigsKeys = Object.keys(projectDeployTargetsConfigs); 49 | const expectedProps = VALID_TYPES.get('ftp').REQUIRED_PROPERTIES; 50 | // We assume that if we find an prop that references an object and 51 | // isn't in the expected props for an FTP target, it was a profile name 52 | if ( 53 | projectDeployTargetsConfigsKeys.filter( 54 | key => typeof projectDeployTargetsConfigs[key] === 'object' && !expectedProps.includes(key) 55 | ).length > 0 56 | ) { 57 | projectDeployTargetsConfigs = projectDeployTargetsConfigsKeys.map(profile => 58 | combine({ profile }, projectDeployTargetsConfigs[profile]) 59 | ); 60 | } 61 | } 62 | // ## END DEPRECATED ## 63 | 64 | // Ensure projectDeployConfig is an array 65 | if (!Array.isArray(projectDeployTargetsConfigs)) { 66 | projectDeployTargetsConfigs = [projectDeployTargetsConfigs]; 67 | } 68 | 69 | const deployTargetConfigInputProperties = { 70 | id, 71 | from: to, 72 | files: '**' 73 | }; 74 | 75 | return { 76 | id, 77 | targets: projectDeployTargetsConfigs.map(projectDeployTargetConfig => 78 | combine( 79 | deployTargetConfigInputProperties, 80 | addKnownProfileProperties, 81 | projectDeployTargetConfig, 82 | resolveProperties, 83 | removeExtraneousProperties 84 | ) 85 | ) 86 | }; 87 | }; 88 | 89 | const addKnownProfileProperties = config => { 90 | const profile = config.profile || 'contentftp'; // default 91 | 92 | return combine(config, { profile }, KNOWN_PROFILES_CONFIG[profile]); 93 | }; 94 | 95 | function resolveProperties(config) { 96 | const { pkg, root } = getProjectConfig(); 97 | const { name } = pkg; 98 | // Package name may be in `@scope/name` format 99 | const unscopedName = name.split('/').reverse()[0]; 100 | 101 | config.from = join(root, config.from); 102 | config.to = config.to.replace('', unscopedName).replace('', config.id); 103 | config.publicPath = typeof config.resolvePublicPath === 'function' ? config.resolvePublicPath(config) : '/'; 104 | 105 | return config; 106 | } 107 | 108 | function removeExtraneousProperties(config) { 109 | delete config.id; 110 | delete config.resolvePublicPath; 111 | 112 | return Object.keys(config) 113 | .sort() 114 | .reduce((memo, prop) => { 115 | memo[prop] = config[prop]; 116 | 117 | return memo; 118 | }, {}); 119 | } 120 | 121 | module.exports.addProfileProperties = config => { 122 | const { profile } = config; 123 | const profilesFilePath = config.profilesFilePath || DEFAULT_PROFILES_FILE_PATH; 124 | let profiles; 125 | 126 | try { 127 | profiles = loadJsonFile.sync(profilesFilePath); 128 | } catch (err) { 129 | warn(MESSAGES.noProfilesFile(profilesFilePath)); 130 | 131 | return config; 132 | } 133 | 134 | const profileProps = profiles[profile]; 135 | 136 | if (!profileProps) { 137 | warn(MESSAGES.noProfile(profile)); 138 | } 139 | 140 | return combine(config, profileProps); 141 | }; 142 | 143 | module.exports.addKnownProfileProperties = addKnownProfileProperties; -------------------------------------------------------------------------------- /test/test.js: -------------------------------------------------------------------------------- 1 | const { _testGenerate } = require('../src/cli/generate'); 2 | const path = require('path'); 3 | const fs = require('fs/promises'); 4 | const mem = require('mem'); 5 | const { execSync } = require('child_process'); 6 | const { getBuildConfig } = require('../src/config/build'); 7 | const { getBabelConfig } = require('../src/config/babel'); 8 | const { getProjectConfig } = require('../src/config/project'); 9 | 10 | /** 11 | * Magic argv. You can get this by console.logging from the running app 12 | */ 13 | const argv = { 14 | _: ['project'], 15 | dry: false, 16 | d: false, 17 | force: false, 18 | f: false, 19 | help: false, 20 | h: false, 21 | quiet: false, 22 | q: false, 23 | announce: false, 24 | a: false, 25 | '--': [] 26 | }; 27 | 28 | /** The .jest-test-projects folder in the aunty project root where all our test projects will be generated/built */ 29 | const tempRoot = path.resolve(__dirname, '../.jest-test-projects/'); 30 | 31 | /** 32 | * same as rm -rf, supressing errors if the file doesn't exist 33 | */ 34 | async function rmRecursive(pathToDelete) { 35 | if (!pathToDelete.startsWith(tempRoot)) { 36 | throw new Error('Root path must be a child of the tests root folder'); 37 | } 38 | try { 39 | await fs.rm(pathToDelete, { recursive: true }); 40 | } catch (e) { 41 | if (!e.message.includes('ENOENT')) { 42 | throw e; 43 | } 44 | } 45 | } 46 | 47 | // clean and create working directory 48 | beforeAll(async () => { 49 | // Generate is largely limited by npm & network speed. 50 | // Sometimes it's only a few seconds, sometimes it's minutes. 51 | jest.setTimeout(5 * 60 * 1000); 52 | 53 | // Clean everything up 54 | await rmRecursive(tempRoot); 55 | await fs.mkdir(tempRoot); 56 | 57 | // Link local aunty to global 58 | const linkOutput = execSync('npm link'); 59 | console.log('Running: npm link', linkOutput.toString()); 60 | }); 61 | 62 | // Reset mocks 63 | const oldEnv = process.env.NODE_ENV; 64 | afterAll(async () => { 65 | // Clean everything up 66 | process.env.NODE_ENV = oldEnv; 67 | await rmRecursive(tempRoot); 68 | }); 69 | 70 | ['basic', 'react', 'preact', 'svelte'].forEach(template => { 71 | describe(`${template} project`, () => { 72 | [true, false].forEach(hasTypescript => { 73 | describe(hasTypescript ? 'with typescript' : 'without typescript', () => { 74 | [false, true].forEach(hasOdyssey => { 75 | describe(hasOdyssey ? 'with odyssey' : 'without odyssey', () => { 76 | const projectName = [ 77 | 'project', 78 | template, 79 | hasTypescript ? 'typescript' : 'js', 80 | hasOdyssey ? 'odyssey' : 'standalone' 81 | ].join('-'); 82 | 83 | /** The path of the generated project inside the tempRoot folder */ 84 | const generatedProjectRoot = path.join(tempRoot, projectName); 85 | 86 | beforeEach(() => { 87 | // We must be in development mode to install devDependencies 88 | process.env.NODE_ENV = 'development'; 89 | 90 | // Clear memoised functions between runs, otherwise weird things happen 91 | mem.clear(getBuildConfig); 92 | mem.clear(getBabelConfig); 93 | mem.clear(getProjectConfig); 94 | }); 95 | 96 | it('should generate a project', async () => { 97 | const answers = { 98 | projectName, 99 | template, 100 | typescript: hasTypescript, 101 | odyssey: hasOdyssey 102 | }; 103 | 104 | global.auntyYeomanAnswers = answers; 105 | await rmRecursive(generatedProjectRoot); 106 | process.chdir(tempRoot); 107 | 108 | // Generate the new project. If this fails, this promise will throw. 109 | await _testGenerate(argv); 110 | delete global.auntyYeomanAnswers; 111 | }); 112 | 113 | it('should build the generated project', async () => { 114 | process.chdir(generatedProjectRoot); 115 | 116 | // execSync will throw on non-zero exit code 117 | 118 | { 119 | const output = execSync('npm link @abcnews/aunty'); 120 | console.log('Running: npm link @abcnews/aunty', output.toString()); 121 | } 122 | 123 | { 124 | const output = execSync('npx aunty build'); 125 | console.log('Running: npx aunty build', output.toString()); 126 | } 127 | 128 | const fileList = await fs.readdir(path.join(generatedProjectRoot, '.aunty/build')); 129 | 130 | // Other files may exist in the fileList but this should be enough of a smoke test 131 | expect(fileList.includes('index.html')); 132 | expect(fileList.includes('index.js')); 133 | expect(fileList.includes('index.js.map')); 134 | }); 135 | }); 136 | }); 137 | }); 138 | }); 139 | }); 140 | }); 141 | -------------------------------------------------------------------------------- /src/cli/deploy/index.js: -------------------------------------------------------------------------------- 1 | // Native 2 | const { existsSync, statSync } = require('fs'); 3 | const { join } = require('path'); 4 | 5 | // External 6 | const importLazy = require('import-lazy')(require); 7 | const loadJsonFile = importLazy('load-json-file'); 8 | const { deploymentExists } = require('../../utils/ftp'); 9 | const { to: wrap } = require('await-to-js'); 10 | const cliSelect = importLazy('cli-select'); 11 | 12 | // Ours 13 | const { command } = require('../'); 14 | const { addProfileProperties } = require('../../config/deploy'); 15 | const { getProjectConfig } = require('../../config/project'); 16 | const { DEPLOY_FILE_NAME, OUTPUT_DIRECTORY_NAME } = require('../../constants'); 17 | const { throws } = require('../../utils/async'); 18 | const { dry, info, spin, warn, log, error } = require('../../utils/logging'); 19 | const { ftp, rsync } = require('../../utils/remote'); 20 | const { MESSAGES, VALID_TYPES } = require('./constants'); 21 | const { dim, opt, hvy, req } = require('../../utils/color'); 22 | 23 | module.exports = command( 24 | { 25 | name: 'deploy', 26 | usage: MESSAGES.usage 27 | }, 28 | async argv => { 29 | const { root } = getProjectConfig(); 30 | let deployConfig; 31 | 32 | // 1) Load the deploy configuration (created by the build process) 33 | 34 | try { 35 | deployConfig = loadJsonFile.sync(join(root, OUTPUT_DIRECTORY_NAME, DEPLOY_FILE_NAME)); 36 | } catch (err) { 37 | throw new Error(MESSAGES.noDeployConfigFile); 38 | } 39 | 40 | let { id, targets } = deployConfig; 41 | 42 | if (!Array.isArray(targets)) { 43 | throw new Error(MESSAGES.noDeployConfigFile); 44 | } 45 | 46 | // 2) Add profile properties (type, host, port, username, password) to each target 47 | 48 | targets = targets.map(addProfileProperties); 49 | 50 | // 3) Validate the deploy configuration 51 | 52 | for (let target of targets) { 53 | const { from, type } = target; 54 | 55 | // 3.1) Check profile's 'type' is valid 56 | if (!VALID_TYPES.has(type)) { 57 | throw MESSAGES.unrecognisedType(type); 58 | } 59 | 60 | // 3.2) Check all required properties are present 61 | VALID_TYPES.get(type).REQUIRED_PROPERTIES.forEach(prop => { 62 | if (target[prop] == null) { 63 | throw MESSAGES.missingProperty(prop); 64 | } 65 | }); 66 | 67 | // 3.3) Check 'from' directory exists 68 | if (!existsSync(from) || !statSync(from).isDirectory()) { 69 | throw MESSAGES.noFromDirectory(from); 70 | } 71 | 72 | // 3.4) For SSH targets, give the `to` directory a trailing slash 73 | if (target.type === 'ssh') { 74 | target.to = target.to.replace(/\/?$/, '/'); 75 | } 76 | } 77 | 78 | // 4a) Log config 79 | 80 | if (argv.dry) { 81 | return dry({ 82 | 'Deploy config': { id, targets } 83 | }); 84 | } 85 | 86 | // 4b) Deploy 87 | 88 | for (let target of targets) { 89 | const { publicPath, type, to } = target; 90 | let shouldOverwrite = false; 91 | 92 | // Check if the deployment already exists 93 | if (argv.force) shouldOverwrite = true; 94 | else if (type === 'ftp') { 95 | const [checkErr] = await wrap(deploymentExists(to)); 96 | 97 | if (checkErr) { 98 | if (checkErr.code === 550) { 99 | // Directory doesn't exist. This is actually good though. OK to write. 100 | shouldOverwrite = true; 101 | } else { 102 | error('An FTP error ocurred'); 103 | } 104 | } else { 105 | warn('Destination directory exists. OK to overwrite?'); 106 | log(` ┗ ${hvy('dir')}: ${req(to)}`); 107 | 108 | const overwriteSelection = ( 109 | await cliSelect({ 110 | defaultValue: 1, 111 | selected: opt('❯'), 112 | unselected: ' ', 113 | values: [ 114 | { label: 'Yes', choice: true }, 115 | { label: 'No', choice: false } 116 | ], 117 | valueRenderer: ({ label }, selected) => (selected ? opt(label) : label) 118 | }) 119 | ).value; 120 | shouldOverwrite = overwriteSelection.choice; 121 | log(`${dim(`Destination overwrite: ${shouldOverwrite}`)}\n`); 122 | } 123 | } 124 | 125 | // Overwrite by default if ssh for now 126 | if (type === 'ssh') shouldOverwrite = true; 127 | 128 | if (shouldOverwrite) { 129 | info(MESSAGES.deploy(target)); 130 | 131 | const spinner = spin('Deploying'); 132 | 133 | try { 134 | if (type === 'ftp') { 135 | throws(await ftp(target, spinner)); 136 | } else if (type === 'ssh') { 137 | throws(await rsync(target)); 138 | } 139 | } catch (err) { 140 | spinner.fail('Deployment failed'); 141 | 142 | throw err; 143 | } 144 | 145 | spinner.succeed(MESSAGES.deployed(publicPath)); 146 | } else { 147 | log('Exiting'); 148 | } 149 | } 150 | } 151 | ); 152 | -------------------------------------------------------------------------------- /src/cli/release/index.js: -------------------------------------------------------------------------------- 1 | // Native 2 | const { existsSync } = require('fs'); 3 | const { join } = require('path'); 4 | 5 | // External 6 | const importLazy = require('import-lazy')(require); 7 | const cliSelect = importLazy('cli-select'); 8 | const loadJsonFile = importLazy('load-json-file'); 9 | const semver = importLazy('semver'); 10 | const writeJsonFile = importLazy('write-json-file'); 11 | 12 | // Ours 13 | const { getProjectConfig } = require('../../config/project'); 14 | const { throws } = require('../../utils/async'); 15 | const { dim, opt } = require('../../utils/color'); 16 | const { 17 | commitAll, 18 | createTag, 19 | getChangelog, 20 | getCurrentLabel, 21 | getDefaultBranch, 22 | getRemotes, 23 | getSemverTags, 24 | hasChanges, 25 | isRepo, 26 | push, 27 | pushTag 28 | } = require('../../utils/git'); 29 | const { dry, log, spin } = require('../../utils/logging'); 30 | const { combine } = require('../../utils/structures'); 31 | const { command } = require('../'); 32 | const buildCommand = require('../build'); 33 | const deployCommand = require('../deploy'); 34 | const { MESSAGES, OPTIONS, VALID_BUMPS } = require('./constants'); 35 | 36 | module.exports = command( 37 | { 38 | name: 'release', 39 | options: OPTIONS, 40 | usage: MESSAGES.usage 41 | }, 42 | async argv => { 43 | const { pkg, root } = getProjectConfig(); 44 | 45 | // 1) Ensure the project is a git repo and determine remotes 46 | 47 | if (!(await isRepo())) { 48 | throw MESSAGES.notRepo; 49 | } 50 | 51 | const remotes = await getRemotes(); 52 | const remote = argv['git-remote']; 53 | 54 | // 2) Ensure the default branch is checked out (skippable) 55 | 56 | const label = await getCurrentLabel(); 57 | const defaultBranch = await getDefaultBranch(remote); 58 | 59 | if (!argv.force && label !== defaultBranch) { 60 | throw MESSAGES.notDefaultBranch(label, defaultBranch); 61 | } 62 | 63 | // 3) Ensure the project no un-committed changes (skippable) 64 | 65 | if (!argv.force && (await hasChanges())) { 66 | throw MESSAGES.hasChanges; 67 | } 68 | 69 | // 4) Determine the release version 70 | 71 | if (argv.bump && !VALID_BUMPS.has(argv.bump)) { 72 | throw MESSAGES.invalidBump(argv.bump); 73 | } 74 | 75 | const pkgVersion = pkg.version; 76 | const isPrerelease = semver.prerelease(pkgVersion); 77 | let bump = !argv.force && VALID_BUMPS.has(argv.bump) && argv.bump; 78 | 79 | if (!argv.force && !isPrerelease && !bump) { 80 | log(MESSAGES.changes(pkgVersion, await getSemverTags(), await getChangelog(pkgVersion))); 81 | log(MESSAGES.bumpQuestion(argv.dry)); 82 | 83 | const bumpSelection = ( 84 | await cliSelect({ 85 | defaultValue: 0, 86 | selected: opt('❯'), 87 | unselected: ' ', 88 | values: [...VALID_BUMPS].reverse().map(bump => ({ 89 | bump, 90 | label: `${bump.replace(/^\w/, c => c.toUpperCase())} (${semver.inc(pkgVersion, bump)})` 91 | })), 92 | valueRenderer: ({ label }, selected) => (selected ? opt(label) : label) 93 | }) 94 | ).value; 95 | bump = bumpSelection.bump; 96 | log(`${dim(bumpSelection.label)}\n`); 97 | } 98 | 99 | const version = 100 | (isPrerelease || bump) && semver.valid(pkgVersion) 101 | ? isPrerelease 102 | ? pkgVersion.split('-')[0] 103 | : semver.inc(pkgVersion, bump) 104 | : pkgVersion; 105 | 106 | if (argv.dry) { 107 | return dry({ 108 | Release: { 109 | bump, 110 | version, 111 | 'git-remote': remote 112 | } 113 | }); 114 | } 115 | 116 | // 5) Build with the new version to ensure there are no errors 117 | 118 | throws(await buildCommand(['--id', version])); 119 | 120 | // 6) Bump the project's version number (optional, skippable) 121 | 122 | let spinner; 123 | 124 | if (!argv.force && version !== pkgVersion) { 125 | spinner = spin(MESSAGES.createCommit(pkgVersion, version)); 126 | updateJsonFile(join(root, 'package.json'), { version }); 127 | updateJsonFile(join(root, 'package-lock.json'), { version }); 128 | await commitAll(version); 129 | spinner.succeed(); 130 | 131 | if (remotes.has(remote)) { 132 | spinner = spin(MESSAGES.pushCommit(remote)); 133 | await push(); 134 | spinner.succeed(); 135 | } 136 | } 137 | 138 | // 7) Tag a new release (skippable) 139 | 140 | if (!argv.force) { 141 | spinner = spin(MESSAGES.createTag(version)); 142 | await createTag(version); 143 | spinner.succeed(); 144 | 145 | if (remotes.has(remote)) { 146 | spinner = spin(MESSAGES.pushTag(version, remote)); 147 | await pushTag(remote, version); 148 | spinner.succeed(); 149 | } 150 | } 151 | 152 | // 8) Deploy 153 | 154 | throws(await deployCommand()); 155 | } 156 | ); 157 | 158 | const updateJsonFile = (path, source) => { 159 | if (existsSync(path)) { 160 | writeJsonFile.sync(path, combine(loadJsonFile.sync(path), source)); 161 | } 162 | }; 163 | -------------------------------------------------------------------------------- /src/config/serve.js: -------------------------------------------------------------------------------- 1 | // Native 2 | const { homedir, hostname } = require('os'); 3 | const { readFileSync } = require('fs'); 4 | const { join } = require('path'); 5 | const { Socket } = require('net'); 6 | 7 | // External 8 | const { probe } = require('tcp-ping-sync'); 9 | 10 | // Ours 11 | const { combine } = require('../utils/structures'); 12 | const { getProjectConfig } = require('./project'); 13 | const { info } = require('../utils/logging'); 14 | const { MESSAGES } = require('../cli/serve/constants'); 15 | const { INTERNAL_TEST_HOST } = require('../constants'); 16 | 17 | const HOME_DIR = homedir(); 18 | const SSL_DIR = '.aunty/ssl'; 19 | const SERVER_CERT_FILENAME = (module.exports.SERVER_CERT_FILENAME = 'server.crt'); 20 | const SERVER_KEY_FILENAME = (module.exports.SERVER_KEY_FILENAME = 'server.key'); 21 | const INTERNAL_SUFFIX = '.aus.aunty.abc.net.au'; 22 | const DEFAULT_HOST = (module.exports.DEFAULT_HOST = probe(INTERNAL_TEST_HOST) 23 | ? `${hostname().toLowerCase().split('.')[0]}${INTERNAL_SUFFIX}` // hostname _may_ include INTERNAL_SUFFIX 24 | : 'localhost'); 25 | const DEFAULT_PORT = 8000; 26 | 27 | const addEnvironmentVariables = config => { 28 | if (process.env.AUNTY_HOST) { 29 | config.host = process.env.AUNTY_HOST; 30 | } 31 | 32 | if (process.env.AUNTY_PORT) { 33 | config.port = process.env.AUNTY_PORT; 34 | } 35 | 36 | return config; 37 | }; 38 | 39 | const getSSLPath = (module.exports.getSSLPath = (host, name) => join(HOME_DIR, SSL_DIR, host, name || '.')); 40 | 41 | /** 42 | * Set config.https to cert & key generated with `aunty sign-cert` (if they both exist) 43 | * We expect them to be in: ~/.aunty/ssl//server.{cert|key} 44 | */ 45 | const addUserSSLConfig = config => { 46 | if (config.https === true) { 47 | try { 48 | config.https = { 49 | cert: readFileSync(getSSLPath(config.host, SERVER_CERT_FILENAME)), 50 | key: readFileSync(getSSLPath(config.host, SERVER_KEY_FILENAME)) 51 | }; 52 | } catch (e) {} 53 | } 54 | 55 | return config; 56 | }; 57 | 58 | /** 59 | * Find an open port, or keep incrementing until we get one 60 | */ 61 | const findPort = async (port, max = port + 100, host = '0.0.0.0') => { 62 | return new Promise((resolve, reject) => { 63 | const socket = new Socket(); 64 | 65 | const next = errorType => { 66 | socket.destroy(); 67 | info(MESSAGES.port({ port, errorType })); 68 | if (port <= max) resolve(findPort(port + 1, max, host)); 69 | else reject(new Error('Could not find an available port')); 70 | }; 71 | 72 | const found = () => { 73 | socket.destroy(); 74 | resolve(port); 75 | }; 76 | 77 | // Port is taken if connection can be made 78 | socket.once('connect', () => next('in use')); 79 | 80 | // Port is open if connection attempt times out 81 | socket.setTimeout(500); 82 | socket.once('timeout', found); 83 | 84 | // If an error occurs, it's assumed the port is available. 85 | socket.once('error', e => { 86 | // If the connection is refused, it's assumed nothing is listening and the port is available. 87 | if (e.code === 'ECONNREFUSED') { 88 | found(); 89 | } else if (e.code === 'ENOTFOUND' && e.syscall === 'getaddrinfo' && e.hostname?.includes(INTERNAL_SUFFIX)) { 90 | console.error( 91 | [ 92 | 'Could not resolve hostname ' + e.hostname, 93 | 'You appear to be on the ABC network without a hostname attached to your computer.', 94 | "Aunty can't continue. Consider reconnecting, or add your hostname to your hosts file.", 95 | '' 96 | ].join('\n') 97 | ); 98 | process.exit(1); 99 | } else { 100 | // Not sure what to do with other errors. Log the code & keep seeking a free port. 101 | next(`error code ${e.code}`); 102 | } 103 | }); 104 | 105 | socket.connect(port, host); 106 | }); 107 | }; 108 | 109 | const _getServeConfig = async () => { 110 | const { serve } = getProjectConfig(); 111 | 112 | const config = combine( 113 | { 114 | hasBundleAnalysis: false, 115 | host: DEFAULT_HOST, 116 | hot: process.env.NODE_ENV === 'development', 117 | https: true, 118 | port: DEFAULT_PORT 119 | }, 120 | serve, 121 | addEnvironmentVariables, 122 | addUserSSLConfig 123 | ); 124 | const port = await findPort(config.port, config.port + 100, config.host); 125 | config.port = port; 126 | 127 | return config; 128 | }; 129 | 130 | let _serveConfigPromiseSingleton; 131 | 132 | // getServeConfig is called twice during server startup, because the `serve` 133 | // command calls it directly, then indirectly, via getWebpackDevServerConfig. 134 | // Because it won't change during a single `serve` command, and because we 135 | // don't want to waste time doing port lookups with `findPort` multiple times 136 | // we just cache the promise created on the first run, and return that later. 137 | 138 | module.exports.getServeConfig = async () => { 139 | if (!_serveConfigPromiseSingleton) { 140 | _serveConfigPromiseSingleton = _getServeConfig(); 141 | } 142 | 143 | return _serveConfigPromiseSingleton; 144 | }; 145 | -------------------------------------------------------------------------------- /src/generators/component/index.js: -------------------------------------------------------------------------------- 1 | // External 2 | const Inflect = require('i')(); 3 | const Generator = require('yeoman-generator'); 4 | 5 | // Ours 6 | const { getProjectConfig } = require('../../config/project'); 7 | const { cmd, hvy, opt } = require('../../utils/color'); 8 | const { success } = require('../../utils/logging'); 9 | const { installDependencies } = require('../../utils/npm'); 10 | const { combine } = require('../../utils/structures'); 11 | 12 | /** 13 | * Generate a Component 14 | */ 15 | module.exports = class extends Generator { 16 | constructor(args, opts) { 17 | super(args, { 18 | ...opts, 19 | localConfigOnly: true 20 | }); 21 | 22 | this.argument('name', { required: false }); 23 | 24 | this.option('template', { 25 | description: 'Type of project [basic|preact|react|svelte]' 26 | }); 27 | this.option('d3', { description: 'This component will use D3' }); 28 | 29 | this.dependencies = []; 30 | this.devDependencies = []; 31 | } 32 | 33 | usage() { 34 | return `${cmd('aunty generate component')} -- ${opt('[options] []')}`; 35 | } 36 | 37 | initializing() { 38 | try { 39 | const { root, type, hasTS } = getProjectConfig(); 40 | 41 | process.chdir(root); 42 | this.destinationRoot(root); 43 | this.options.template = type; 44 | this.options.typescript = hasTS; 45 | } catch (err) {} 46 | } 47 | 48 | async prompting() { 49 | let prompts = []; 50 | 51 | if (!this.options.template) { 52 | prompts.push({ 53 | type: 'list', 54 | name: 'template', 55 | message: 'What type of project is it again?', 56 | choices: [ 57 | { name: 'Preact', value: 'preact' }, 58 | { name: 'Basic', value: 'basic' }, 59 | { name: 'React', value: 'react' }, 60 | { name: 'Svelte', value: 'svelte' } 61 | ] 62 | }); 63 | } 64 | 65 | if (!this.options.name) { 66 | prompts.push({ 67 | type: 'input', 68 | name: 'name', 69 | message: 'Component name?', 70 | default: 'NewComponent' 71 | }); 72 | } 73 | 74 | if (!this.options.d3) { 75 | prompts.push({ 76 | type: 'confirm', 77 | name: 'd3', 78 | message: 'Is this a D3 component?', 79 | default: false 80 | }); 81 | } 82 | 83 | if (prompts.length > 0) { 84 | const answers = await this.prompt(prompts); 85 | this.options = combine(this.options, answers); 86 | } 87 | 88 | this.options.name = Inflect.camelize(this.options.name.replace(' ', '_')); 89 | } 90 | 91 | writing() { 92 | const isSFC = this.options.template === 'svelte'; 93 | const isJSX = this.options.template === 'preact' || this.options.template === 'react'; 94 | const sourceName = `component${this.options.d3 ? '-with-d3' : ''}`; 95 | const sourceScriptExt = isJSX ? 'tsx' : 'ts'; 96 | const destinationScriptExt = this.options.typescript ? sourceScriptExt : 'js'; 97 | const context = { 98 | className: this.options.name, 99 | isTS: this.options.typescript 100 | }; 101 | 102 | if (isSFC) { 103 | this.fs.copyTpl( 104 | this.templatePath(this.options.template, `${sourceName}.${this.options.template}`), 105 | this.destinationPath(`src/components/${this.options.name}/${this.options.name}.${this.options.template}`), 106 | context 107 | ); 108 | } else { 109 | this.fs.copyTpl( 110 | this.templatePath(this.options.template, `${sourceName}.${sourceScriptExt}`), 111 | this.destinationPath(`src/components/${this.options.name}/index.${destinationScriptExt}`), 112 | context, 113 | { globOptions: { noext: true } } 114 | ); 115 | this.fs.copy( 116 | this.templatePath(`_non_sfc/styles.scss`), 117 | this.destinationPath(`src/components/${this.options.name}/styles.scss`), 118 | context 119 | ); 120 | } 121 | 122 | this.fs.copyTpl( 123 | this.templatePath(this.options.template, `${sourceName}.test.${sourceScriptExt}`), 124 | this.destinationPath( 125 | `src/components/${this.options.name}/${isSFC ? this.options.name : 'index'}.test.${destinationScriptExt}` 126 | ), 127 | context, 128 | { globOptions: { noext: true } } 129 | ); 130 | } 131 | 132 | async install() { 133 | if (this.options.typescript) { 134 | this.devDependencies.push('@types/jest', '@types/webpack-env'); 135 | } 136 | 137 | if (this.options.d3) { 138 | this.dependencies.push('d3-selection'); 139 | 140 | if (this.options.typescript) { 141 | this.devDependencies.push('@types/d3-selection'); 142 | } 143 | } 144 | 145 | switch (this.options.template) { 146 | case 'preact': 147 | this.devDependencies.push('html-looks-like', 'preact-render-to-string'); 148 | this.dependencies.push('preact'); 149 | break; 150 | case 'react': 151 | this.devDependencies.push( 152 | 'react-test-renderer', 153 | ...(this.options.typescript ? ['@types/react', '@types/react-dom', '@types/react-test-renderer'] : []) 154 | ); 155 | this.dependencies.push('react', 'react-dom'); 156 | break; 157 | case 'svelte': 158 | this.devDependencies.push('@testing-library/svelte'); 159 | this.dependencies.push('svelte'); 160 | break; 161 | default: 162 | break; 163 | } 164 | 165 | await installDependencies(this.devDependencies.sort(), ['--save-dev'], this.log); 166 | await installDependencies(this.dependencies.sort(), null, this.log); 167 | } 168 | 169 | end() { 170 | success(`Created ${hvy(this.options.name)} component`); 171 | } 172 | }; 173 | -------------------------------------------------------------------------------- /src/generators/project/index.js: -------------------------------------------------------------------------------- 1 | // Native 2 | const path = require('path'); 3 | 4 | // External 5 | const getAllPaths = require('get-all-paths'); 6 | const makeDir = require('make-dir'); 7 | const requireg = require('requireg'); 8 | const Generator = require('yeoman-generator'); 9 | const { to: wrap } = require('await-to-js'); 10 | const importLazy = require('import-lazy')(require); 11 | 12 | // Ours 13 | const { OUTPUT_DIRECTORY_NAME } = require('../../constants'); 14 | const { cmd, hvy, opt } = require('../../utils/color'); 15 | const { success } = require('../../utils/logging'); 16 | const { installDependencies } = require('../../utils/npm'); 17 | const { combine } = require('../../utils/structures'); 18 | const { sluggify } = require('../../utils/text'); 19 | const { projectExists } = require('../../utils/ftp'); 20 | const { log, warn, info } = importLazy('../../utils/logging'); 21 | 22 | module.exports = class extends Generator { 23 | constructor(args, opts) { 24 | super(args, { 25 | ...opts, 26 | localConfigOnly: true 27 | }); 28 | 29 | this.argument('name', { 30 | description: `Project name. Create this directory if ${opt('--here')} is not specified`, 31 | required: false 32 | }); 33 | this.option('here', { description: `Assume ${opt('')} is current working directory`, type: Boolean }); 34 | this.option('template', { description: 'Type of project [basic|preact|react|svelte]', type: String }); 35 | } 36 | 37 | usage() { 38 | return `${cmd('aunty generate project')} -- ${opt('[options] []')} 39 | 40 | Two shorthands are available - ${cmd('aunty new')} and ${cmd('aunty init')} - for quick project creation. 41 | 42 | Shorthand examples (assuming xyz is your project name): 43 | 44 | ${cmd('aunty new')} ==> ${cmd('aunty generate project')} 45 | ${cmd('aunty new')} ${opt('xyz')} ==> ${cmd('aunty generate project')} -- ${opt('xyz')} 46 | ${cmd('aunty init')} ==> ${cmd('aunty generate project')} -- ${opt('--here')} 47 | `; 48 | } 49 | 50 | async prompting() { 51 | let prompts = []; 52 | 53 | if (this.options.here) { 54 | const currentDirectory = path.basename(process.cwd()); 55 | 56 | info(`Info: Using currect directory name as project name:`, currentDirectory); 57 | log('Checking project name. This may take a few seconds...'); 58 | 59 | const [err, exists] = await wrap(projectExists(sluggify(currentDirectory))); 60 | 61 | if (exists) 62 | warn( 63 | 'Warning: Project with the same name detected externally. ' + 64 | 'Danger of data loss if you continue. ' + 65 | 'Press ctrl+c to exit and rename project directory.' 66 | ); 67 | 68 | if (err) 69 | warn( 70 | 'Warning: Unable to check if project name already exists, most likely ' + 71 | 'due to a connection or credentials error. Please check manually before deploying.\n' 72 | ); 73 | 74 | this.options.projectName = currentDirectory; 75 | } else { 76 | prompts.push({ 77 | type: 'input', 78 | name: 'projectName', 79 | message: 'What is your project called?', 80 | default: this.options.projectName || 'New Project', 81 | validate: async input => { 82 | log('\nChecking project name. This may take a few seconds...'); 83 | 84 | const [err, exists] = await wrap(projectExists(sluggify(input))); 85 | 86 | if (exists) 87 | return ( 88 | 'Error: Project seems to aleady exist on the FTP server and is in ' + 89 | 'danger of being overwritten. Please try a different name.' 90 | ); 91 | 92 | if (err) 93 | warn( 94 | 'Warning: Unable to check if project name already exists, most likely ' + 95 | 'due to a connection or credentials error. Please check manually before deploying.\n' 96 | ); 97 | 98 | return true; 99 | } 100 | }); 101 | } 102 | 103 | if (!this.options.template) { 104 | prompts.push({ 105 | type: 'list', 106 | name: 'template', 107 | message: 'What type of project is it?', 108 | choices: [ 109 | { name: 'Basic', value: 'basic' }, 110 | { name: 'Preact', value: 'preact' }, 111 | { name: 'React', value: 'react' }, 112 | { name: 'Svelte', value: 'svelte' } 113 | ] 114 | }); 115 | } 116 | 117 | prompts.push({ 118 | type: 'confirm', 119 | name: 'typescript', 120 | message: 'Will you be authoring your project in TypeScript?', 121 | default: true 122 | }); 123 | 124 | prompts.push({ 125 | type: 'confirm', 126 | name: 'odyssey', 127 | message: 'Will this project render components inside an Odyssey?', 128 | default: false 129 | }); 130 | 131 | const answers = await this.prompt(prompts); 132 | 133 | this.options = combine(this.options, answers); 134 | 135 | this.options.projectName = this.options.projectName.replace(/[^\w\-\_\s]/g, ''); 136 | 137 | this.options.projectNameSlug = sluggify(this.options.projectName); 138 | 139 | this.options.projectNameFlat = this.options.projectNameSlug.replace(/-/g, ''); 140 | 141 | if (this.options.here) { 142 | this.options.path = process.cwd(); 143 | } else { 144 | this.options.path = process.cwd() + '/' + this.options.projectNameSlug; 145 | } 146 | } 147 | 148 | async configuring() { 149 | const directory = this.options.path; 150 | 151 | await makeDir(directory); 152 | process.chdir(directory); 153 | this.destinationRoot(directory); 154 | } 155 | 156 | writing() { 157 | const context = { 158 | OUTPUT_DIRECTORY_NAME, 159 | projectName: this.options.projectName, 160 | projectNameSlug: this.options.projectNameSlug, 161 | projectNameFlat: this.options.projectNameFlat, 162 | projectType: this.options.template, 163 | isTS: this.options.typescript, 164 | isOdyssey: this.options.odyssey, 165 | authorName: this.user.git.name(), 166 | authorEmail: this.user.git.email() 167 | }; 168 | 169 | const hasSFCs = this.options.template === 'svelte'; 170 | const templateDirs = [this.options.template, '_common'].concat(hasSFCs ? [] : ['_non_sfc']); 171 | const templateDirPaths = templateDirs.map(dir => this.templatePath(dir)); 172 | const pathExclusions = [].concat(this.options.typescript ? [] : ['tsconfig.json']); 173 | const pathReplacements = templateDirPaths 174 | .map(dirPath => [`${dirPath}/`, '']) 175 | .concat(this.options.typescript ? [] : [[/\.tsx?/, '.js']], [['_.', '.']]); 176 | 177 | getAllPaths(...templateDirPaths).forEach(filePath => { 178 | if (pathExclusions.some(exclusion => filePath.includes(exclusion))) { 179 | return; 180 | } 181 | 182 | this.fs.copyTpl( 183 | filePath, 184 | this.destinationPath( 185 | pathReplacements.reduce((filePath, replacement) => filePath.replace(...replacement), filePath) 186 | ), 187 | context 188 | ); 189 | }); 190 | } 191 | 192 | async install() { 193 | let auntyVersion; 194 | 195 | try { 196 | auntyVersion = requireg('@abcnews/aunty/package.json').version; 197 | } catch (ex) { 198 | // Nothing 199 | } 200 | 201 | const devDependencies = [`@abcnews/aunty${auntyVersion ? `@${auntyVersion}` : ''}`].concat( 202 | this.options.typescript ? ['@types/jest', '@types/webpack-env'] : [] 203 | ); 204 | const dependencies = ['@abcnews/alternating-case-to-object', '@abcnews/env-utils', '@abcnews/mount-utils']; 205 | 206 | switch (this.options.template) { 207 | case 'preact': 208 | devDependencies.push('html-looks-like', 'preact-render-to-string'); 209 | dependencies.push('preact'); 210 | break; 211 | case 'react': 212 | devDependencies.push( 213 | 'react-test-renderer', 214 | ...(this.options.typescript ? ['@types/react', '@types/react-dom', '@types/react-test-renderer'] : []) 215 | ); 216 | dependencies.push('react', 'react-dom'); 217 | break; 218 | case 'svelte': 219 | devDependencies.push('@testing-library/svelte'); 220 | dependencies.push('svelte@5'); 221 | break; 222 | default: 223 | break; 224 | } 225 | 226 | const allDependencies = [].concat(devDependencies).concat(dependencies); 227 | const projectDirectoryName = this.options.path.split('/').reverse()[0]; 228 | 229 | if (allDependencies.includes(projectDirectoryName)) { 230 | throw new Error( 231 | `npm will refuse to install a package ("${projectDirectoryName}") which matches the project directory name.` 232 | ); 233 | } 234 | 235 | await installDependencies(devDependencies.sort(), ['--save-dev'], this.log); 236 | await installDependencies(dependencies.sort(), null, this.log); 237 | } 238 | 239 | end() { 240 | const where = this.options.here ? 'the current directory' : `./${this.options.projectNameSlug}`; 241 | 242 | success(`Created ${hvy(this.options.projectName)} project in ${hvy(where)}`); 243 | } 244 | }; 245 | -------------------------------------------------------------------------------- /src/config/webpack.js: -------------------------------------------------------------------------------- 1 | // Native 2 | const { existsSync } = require('fs'); 3 | const { join, resolve } = require('path'); 4 | 5 | // External 6 | const importLazy = require('import-lazy')(require); 7 | const CopyPlugin = importLazy('copy-webpack-plugin'); 8 | const Dotenv = importLazy('dotenv-webpack'); 9 | const ForkTsCheckerWebpackPlugin = importLazy('fork-ts-checker-webpack-plugin'); 10 | const MiniCssExtractPlugin = importLazy('mini-css-extract-plugin'); 11 | const sveltePreprocess = importLazy('svelte-preprocess'); 12 | const EnvironmentPlugin = importLazy('webpack/lib/EnvironmentPlugin'); 13 | 14 | // Ours 15 | const { combine, merge } = require('../utils/structures'); 16 | const { getBabelConfig } = require('./babel'); 17 | const { getBuildConfig } = require('./build'); 18 | const { getProjectConfig } = require('./project'); 19 | 20 | const JSX_RESOLVE_EXTENSIONS = ['.jsx', '.tsx']; 21 | 22 | /** 23 | * Project types to override the Webpack config. 24 | */ 25 | const PROJECT_TYPES_CONFIG = { 26 | preact: { 27 | resolve: { 28 | alias: { 29 | react: 'preact', 30 | 'react-dom': 'preact/compat' 31 | }, 32 | 33 | extensions: JSX_RESOLVE_EXTENSIONS 34 | } 35 | }, 36 | react: { 37 | resolve: { 38 | extensions: JSX_RESOLVE_EXTENSIONS 39 | } 40 | }, 41 | 42 | /** 43 | * Svelte uses a function to modify the existing config, rather than just merging in. 44 | * @see combine 45 | */ 46 | svelte: config => { 47 | config.resolve = { 48 | extensions: [...config.resolve.extensions, '.svelte'], 49 | mainFields: ['svelte', 'browser', 'module', 'main'], 50 | conditionNames: ['svelte', 'browser', 'import'] 51 | }; 52 | 53 | const { include, loader, options } = getHintedRule(config, 'scripts'); 54 | const extractCSS = getHintedRule(config, 'styles').use[0].loader === MiniCssExtractPlugin.loader; 55 | 56 | include.push(/(node_modules\/svelte)/); 57 | 58 | // options from https://github.com/sveltejs/svelte-loader 59 | config.module.rules.push( 60 | ...[ 61 | { 62 | test: /\.svelte\.ts$/, 63 | use: [ 64 | { 65 | loader, 66 | options 67 | }, 68 | { loader: require.resolve('ts-loader'), options: { transpileOnly: true } }, 69 | { 70 | loader: require.resolve('svelte-loader'), 71 | options: { 72 | dev: config.mode === 'development', 73 | emitCss: extractCSS, 74 | preprocess: sveltePreprocess() 75 | } 76 | } 77 | ] 78 | }, 79 | { 80 | test: /(? { 121 | const { root } = getProjectConfig(); 122 | const { addModernJS, showDeprecations } = getBuildConfig(); 123 | const customWebpackConfigFilePath = join(root, WEBPACK_CONFIG_FILE); 124 | 125 | if (showDeprecations) { 126 | process.traceDeprecation = true; 127 | } else { 128 | process.noDeprecation = true; 129 | } 130 | 131 | let config; 132 | 133 | // If the project has a webpack config file, use it, otherwise create our own 134 | if (existsSync(customWebpackConfigFilePath)) { 135 | config = require(customWebpackConfigFilePath); 136 | 137 | if (!Array.isArray(config)) { 138 | config = [config]; 139 | } 140 | 141 | // Ensure functions are resolved to objects 142 | config = config.map(combine); 143 | } else { 144 | config = [createWebpackConfig()]; 145 | 146 | // Duplicate the config if the project expects an additional modern JS build 147 | if (addModernJS) { 148 | config.push(createWebpackConfig({ isModernJS: true })); 149 | } 150 | } 151 | 152 | return config; 153 | }; 154 | 155 | const createEntriesDictionary = (root, from, entry) => 156 | (Array.isArray(entry) ? entry : [entry]).reduce( 157 | (memo, _entry) => ({ ...memo, [_entry]: [join(root, from, _entry)] }), 158 | {} 159 | ); 160 | 161 | const resolveIncludedDependencies = (includedDependencies, root) => { 162 | if (!Array.isArray(includedDependencies)) { 163 | return []; 164 | } 165 | 166 | return includedDependencies.map(packageNameOrPattern => { 167 | if (typeof packageNameOrPattern === 'string') { 168 | return resolve(root, 'node_modules', packageNameOrPattern); 169 | } 170 | 171 | if (packageNameOrPattern instanceof RegExp) { 172 | return new RegExp(join('node_modules', packageNameOrPattern.source), packageNameOrPattern.flags); 173 | } 174 | 175 | return null; 176 | }); 177 | }; 178 | 179 | function createWebpackConfig({ isModernJS } = {}) { 180 | const { pkg, root, hasTS, type, webpack: projectWebpackConfig } = getProjectConfig(); 181 | const { entry, extractCSS, from, includedDependencies, staticDir, to, useCSSModules } = getBuildConfig(); 182 | const isProd = process.env.NODE_ENV === 'production'; 183 | const hasEnvFile = existsSync(join(root, '.env')); 184 | const hasEnvExampleFile = existsSync(join(root, '.env.example')); 185 | 186 | const config = merge( 187 | { 188 | mode: isProd ? 'production' : 'development', 189 | target: isModernJS ? 'web' : ['web', 'es5'], 190 | cache: true, 191 | entry: createEntriesDictionary(root, from, entry), 192 | devtool: 'source-map', 193 | output: { 194 | path: join(root, to), 195 | publicPath: '/', 196 | filename: isModernJS ? '[name]_modern.js' : '[name].js' 197 | }, 198 | resolve: { 199 | extensions: ['.mjs', '.js', '.json', '.ts'] 200 | }, 201 | module: { 202 | rules: [ 203 | { 204 | /** 205 | * hints are used by PROJECT_TYPES_CONFIGs to quickly select the right config. 206 | * @see PROJECT_TYPES_CONFIG 207 | */ 208 | __hint__: 'scripts', 209 | test: hasTS ? /\.m?[jt]sx?$/ : /\.m?jsx?$/, 210 | include: [resolve(root, from)].concat(resolveIncludedDependencies(includedDependencies, root)), 211 | loader: require.resolve('babel-loader'), 212 | options: getBabelConfig({ isModernJS }) 213 | }, 214 | { 215 | __hint__: 'styles', 216 | test: /\.(css|scss)$/, 217 | use: [ 218 | extractCSS 219 | ? { 220 | loader: MiniCssExtractPlugin.loader 221 | } 222 | : { 223 | loader: require.resolve('style-loader') 224 | }, 225 | { 226 | loader: require.resolve('css-loader'), 227 | options: { 228 | modules: useCSSModules && { 229 | exportLocalsConvention: 'camelCase', 230 | localIdentContext: __dirname, 231 | // ^^^ https://github.com/webpack-contrib/css-loader/issues/413#issuecomment-299578180 232 | localIdentHashSalt: `${pkg.name}@${pkg.version}`, 233 | localIdentName: `${isProd ? '' : '[folder]-[name]__[local]--'}[hash:base64:6]` 234 | }, 235 | sourceMap: !isProd 236 | } 237 | }, 238 | { 239 | loader: require.resolve('sass-loader') 240 | } 241 | ] 242 | }, 243 | { 244 | __hint__: 'data', 245 | test: /\.[tc]sv$/, 246 | loader: 'csv-loader', 247 | options: { 248 | dynamicTyping: true, 249 | header: true, 250 | skipEmptyLines: true 251 | } 252 | }, 253 | { 254 | test: /\.(jpg|png|gif|mp4|m4v|flv|mp3|wav|m4a|eot|ttf|woff|woff2|webm|webp|avif)$/, 255 | type: 'asset' 256 | }, 257 | { 258 | test: /\.svg$/, 259 | resourceQuery: { not: [/raw/] }, 260 | type: 'asset' 261 | }, 262 | { 263 | resourceQuery: /raw/, 264 | type: 'asset/source' 265 | }, 266 | { 267 | test: /\.html$/, 268 | type: 'asset/source' 269 | } 270 | ] 271 | }, 272 | plugins: [ 273 | new EnvironmentPlugin(Object.keys(process.env)), 274 | hasEnvFile || hasEnvExampleFile 275 | ? new Dotenv({ 276 | safe: hasEnvExampleFile 277 | }) 278 | : null, 279 | hasTS 280 | ? new ForkTsCheckerWebpackPlugin({ 281 | logger: { infrastructure: 'silent', issues: 'silent' }, 282 | typescript: { 283 | diagnosticOptions: { 284 | semantic: true, 285 | syntactic: true 286 | } 287 | } 288 | }) 289 | : null, 290 | extractCSS 291 | ? new MiniCssExtractPlugin({ 292 | filename: `[name].css` 293 | }) 294 | : null, 295 | new CopyPlugin({ 296 | patterns: (Array.isArray(staticDir) ? staticDir : [staticDir]).map(dirName => ({ 297 | from: join(root, dirName) 298 | })) 299 | }) 300 | ].filter(x => x), 301 | optimization: { 302 | moduleIds: isProd ? 'deterministic' : 'named' 303 | } 304 | }, 305 | PROJECT_TYPES_CONFIG[type], 306 | projectWebpackConfig 307 | ); 308 | 309 | if (isProd) { 310 | config.optimization.minimize = true; 311 | } 312 | 313 | // Cleanup hints 314 | config.module.rules.forEach(rule => { 315 | if (rule.__hint__) { 316 | delete rule.__hint__; 317 | } 318 | }); 319 | 320 | return config; 321 | } 322 | 323 | function getHintedRule(config, hint) { 324 | return config.module.rules.find(rule => rule.__hint__ === hint); 325 | } 326 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # @abcnews/aunty 2 | 3 | A toolkit for working with ABC News projects 4 | 5 | ## Installation 6 | 7 | To use the CLI to create new projects, install the latest aunty release globally: 8 | 9 | ```bash 10 | npm install --global @abcnews/aunty 11 | ``` 12 | 13 | Projects based on aunty's project templates already have aunty listed as a local dependency, locked to the version used to create it. 14 | 15 | ## Usage 16 | 17 | For usage instructions, run `aunty` with no arguments, or for details on specific commands, run: 18 | 19 | ```bash 20 | aunty help 21 | ``` 22 | 23 | The CLI contains four types of command, grouped by purpose: 24 | 25 | - Creating new projects (`new`, `init`) 26 | - Generating stuff (`generate`) like components 27 | - Developing projects (`clean`, `build`, `serve`, `test`) 28 | - Deploying (un)versioned projects (`deploy`, `release`) 29 | 30 | ### Starting projects 31 | 32 | When creating new projects, you should be using the global **aunty**: 33 | 34 | ```bash 35 | /some/parent/directory $ aunty new 36 | ``` 37 | 38 | or, from within a (preferably empty) directory: 39 | 40 | ```bash 41 | /some/parent/directory/my-project $ aunty init 42 | ``` 43 | 44 | ### Developing projects 45 | 46 | When working inside a project directory that has the aunty dependency installed, you'll automatically be running that local `aunty`: 47 | 48 | ```bash 49 | /some/parent/directory/my-project $ aunty [options] 50 | ``` 51 | 52 | This ensures that any changes to future versions of aunty won't impact your project, and you can manually update the local aunty when you're ready to accommodate those changes. 53 | 54 | Project-level commands can use an optional configuration, which you can either export from a project-level `aunty.config.js` file: 55 | 56 | ```js 57 | module.exports = { 58 | type: '', 59 | // aunty command configuration 60 | build: {…}, 61 | serve: {…}, 62 | deploy: [{…}], 63 | // internal tools configuration 64 | babel: {…}, 65 | jest: {…}, 66 | webpack: {…}, 67 | webpackDevServer: {…} 68 | }; 69 | ``` 70 | 71 | ...or add to your `package.json` file as an `aunty` property: 72 | 73 | ```js 74 | "aunty": { 75 | "type": "", 76 | "build": {…}, 77 | "serve": {…}, 78 | "deploy": [{…}], 79 | "babel": {…}, 80 | "jest": {…}, 81 | "webpack": {…}, 82 | "webpackDevServer": {…} 83 | } 84 | ``` 85 | 86 | For example to include a package that needs to be pre-processed, include in `aunty.config.js` 87 | 88 | ```js 89 | module.exports = { 90 | build: { 91 | includedDependencies: ["runed"], 92 | }, 93 | }; 94 | ``` 95 | 96 | Supported project `type`s (currently: `basic`, `preact`, `react` & `svelte`) have their own default build configuration, but you can override it by extending your project configuration. 97 | 98 | The `build`, `serve` and `deploy` properties allow you to override the default settings for those respective commands. Their respective properties (and default values) are documented below. 99 | 100 | Aunty uses some tools internally, which you can also provide custom configuration for. If you supply an object for the `babel`, `jest`, `webpack`, and/or `webpackDevServer` properties, that object will be merged into the default configuration. Optionally, you can supply a function (for any property), which will be passed the default configuration for you to manually modify and return. 101 | 102 | If you're looking to see what the default configuration is for any command (and their internal tools), or the impact of your additions, you can always perform a dry run of the command by using the `--dry` (or `-d`) flag: 103 | 104 | ```bash 105 | /some/parent/directory/my-project $ aunty serve --dry 106 | ``` 107 | 108 | Overrides should be used sparingly, as the advantages of using a single-dependency toolkit are most apparent when we don't deviate far from the defaults. 109 | 110 | If you don't need to override any of the project defaults, your entire aunty configuration can be a string containing the project type, as a shorthand for `{type: ""}`. `type` is the only required property in your aunty configuration. 111 | 112 | #### `build` config properties 113 | 114 | | property | default | description | 115 | | ---------------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | 116 | | `entry` | `"index"` | The entry file for your project (extension should be unspecified). You can optionally supply an array for multiple entry points, which will result in multiple outputs. | 117 | | `from` | `"src"` | The source directory that aunty will look for your entry file(s) in. | 118 | | `to` | `".aunty/build"` | The destination directory for your compiled and static assets. | 119 | | `staticDir` | `"public"` | The directory you store static assets in. You can optionally supply an array of directories, which will be merged at build time. | 120 | | `addModernJS` | `false` | Setting this to true will enable a 2nd output file for each entry file named `{name}.modern.js`, which is skips browserlist-based feature polyfilling | 121 | | `includedDependencies` | `[]` | Any packages (defined by name string or name-matching `RegExp`s) you add to this array will be transpiled in the same manner as the project source. | 122 | | `extractCSS` | `false` | Setting this to true will create a separate `{name}.css` output for each input, rather than bundling it with the JS (for dynamic `