Skip to content

Commit

Permalink
Scaffold examples (#1608)
Browse files Browse the repository at this point in the history
* Refactor symlink in init.test

* Add diff feature to dev command

* Add example diff

* Fix watcher when deleting files

* Allow importing from current dir and skeleton template at the same time using aliases

* Support --diff in build command

* Add build script to example

* Add turbo dependency

* Merge main

* Remove examples/diff

* Use --diff in examples/third-party-queries-caching

* Update package-lock

* Remove unnecessary files

* Refactor mergePackageJson

* Refactor mergePackageJson, move to another file

* Merge package.json with --diff flag

* Refactor

* Rename file, refactor

* Changesets

* Fix abort logic

* Support examples for scaffolding

* Fix test for unknown templates

* Rename variables in test

* Add test for example scaffolding

* Minor refactor

* Changesets

* Update flag description

* Fix transpilation detection

* Update utility location

* Fix name mismatch
  • Loading branch information
frandiox authored Jan 11, 2024
1 parent 9e3d88d commit 92840e5
Show file tree
Hide file tree
Showing 9 changed files with 208 additions and 81 deletions.
5 changes: 5 additions & 0 deletions .changeset/ten-singers-rhyme.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@shopify/cli-hydrogen': minor
---

Support scaffolding projects based on examples in Hydrogen repo using the `--template` flag. Example: `npm create @shopify/hydrogen@latest -- --template multipass`.
2 changes: 1 addition & 1 deletion examples/optimistic-cart-ui/app/components/Cart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import {CartForm, Image, Money} from '@shopify/hydrogen';
import type {CartLineUpdateInput} from '@shopify/hydrogen/storefront-api-types';
import {Link} from '@remix-run/react';
import type {CartApiQueryFragment} from 'storefrontapi.generated';
import {useVariantUrl} from '~/utils';
import {useVariantUrl} from '~/lib/variants';

// 1. Import the OptimisticInput and useOptimisticData hooks from @shopify/hydrogen
import {OptimisticInput, useOptimisticData} from '@shopify/hydrogen';
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/oclif.manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -405,7 +405,7 @@
"template": {
"name": "template",
"type": "option",
"description": "Sets the template to use. Pass `demo-store` for a fully-featured store template or `hello-world` for a barebones project.",
"description": "Scaffolds project based on an existing template or example from the Hydrogen repository.",
"multiple": false
},
"install-deps": {
Expand Down
160 changes: 122 additions & 38 deletions packages/cli/src/commands/hydrogen/init.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {describe, it, expect, vi, beforeEach} from 'vitest';
import {runInit} from './init.js';
import {exec} from '@shopify/cli-kit/node/system';
import {mockAndCaptureOutput} from '@shopify/cli-kit/node/testing/output';
import {readAndParsePackageJson} from '@shopify/cli-kit/node/node-package-manager';
import {
fileExists,
isDirectory,
Expand Down Expand Up @@ -33,6 +34,9 @@ vi.mock('../../lib/template-downloader.js', async () => ({
templatesDir: fileURLToPath(
new URL('../../../../../templates', import.meta.url),
),
examplesDir: fileURLToPath(
new URL('../../../../../examples', import.meta.url),
),
}),
}));

Expand Down Expand Up @@ -131,6 +135,10 @@ describe('init', () => {

describe('remote templates', () => {
it('throws for unknown templates', async () => {
const processExit = vi
.spyOn(process, 'exit')
.mockImplementationOnce((() => {}) as any);

await inTemporaryDirectory(async (tmpDir) => {
await expect(
runInit({
Expand All @@ -139,8 +147,13 @@ describe('init', () => {
language: 'ts',
template: 'https://github.com/some/repo',
}),
).rejects.toThrow('supported');
).resolves;
});

expect(outputMock.error()).toMatch('--template');
expect(processExit).toHaveBeenCalledWith(1);

processExit.mockRestore();
});

it('creates basic projects', async () => {
Expand All @@ -152,24 +165,24 @@ describe('init', () => {
template: 'hello-world',
});

const helloWorldFiles = await glob('**/*', {
const templateFiles = await glob('**/*', {
cwd: getSkeletonSourceDir().replace('skeleton', 'hello-world'),
ignore: ['**/node_modules/**', '**/dist/**'],
});
const projectFiles = await glob('**/*', {cwd: tmpDir});
const nonAppFiles = helloWorldFiles.filter(
const resultFiles = await glob('**/*', {cwd: tmpDir});
const nonAppFiles = templateFiles.filter(
(item) => !item.startsWith('app/'),
);

expect(projectFiles).toEqual(expect.arrayContaining(nonAppFiles));
expect(resultFiles).toEqual(expect.arrayContaining(nonAppFiles));

expect(projectFiles).toContain('app/root.tsx');
expect(projectFiles).toContain('app/entry.client.tsx');
expect(projectFiles).toContain('app/entry.server.tsx');
expect(projectFiles).not.toContain('app/components/Layout.tsx');
expect(resultFiles).toContain('app/root.tsx');
expect(resultFiles).toContain('app/entry.client.tsx');
expect(resultFiles).toContain('app/entry.server.tsx');
expect(resultFiles).not.toContain('app/components/Layout.tsx');

// Skip routes:
expect(projectFiles).not.toContain('app/routes/_index.tsx');
expect(resultFiles).not.toContain('app/routes/_index.tsx');

await expect(readFile(`${tmpDir}/package.json`)).resolves.toMatch(
`"name": "hello-world"`,
Expand All @@ -189,6 +202,77 @@ describe('init', () => {
});
});

it('applies diff for examples', async () => {
await inTemporaryDirectory(async (tmpDir) => {
const exampleName = 'third-party-queries-caching';

await runInit({
path: tmpDir,
git: false,
language: 'ts',
template: exampleName,
});

const templatePath = getSkeletonSourceDir();
const examplePath = templatePath
.replace('templates', 'examples')
.replace('skeleton', exampleName);

// --- Test file diff
const ignore = ['**/node_modules/**', '**/dist/**'];
const resultFiles = await glob('**/*', {ignore, cwd: tmpDir});
const templateFiles = await glob('**/*', {ignore, cwd: templatePath});
const exampleFiles = await glob('**/*', {ignore, cwd: examplePath});

expect(resultFiles).toEqual(
expect.arrayContaining([
...new Set([...templateFiles, ...exampleFiles]),
]),
);

// --- Test package.json merge
const templatePkgJson = await readAndParsePackageJson(
`${templatePath}/package.json`,
);
const examplePkgJson = await readAndParsePackageJson(
`${examplePath}/package.json`,
);
const resultPkgJson = await readAndParsePackageJson(
`${tmpDir}/package.json`,
);

expect(resultPkgJson.name).toMatch(exampleName);

expect(resultPkgJson.scripts).toEqual(
expect.objectContaining(templatePkgJson.scripts),
);

expect(resultPkgJson.dependencies).toEqual(
expect.objectContaining({
...templatePkgJson.dependencies,
...examplePkgJson.dependencies,
}),
);
expect(resultPkgJson.devDependencies).toEqual(
expect.objectContaining({
...templatePkgJson.devDependencies,
...examplePkgJson.devDependencies,
}),
);
expect(resultPkgJson.peerDependencies).toEqual(
expect.objectContaining({
...templatePkgJson.peerDependencies,
...examplePkgJson.peerDependencies,
}),
);

// --- Keeps original tsconfig.json
expect(await readFile(joinPath(templatePath, 'tsconfig.json'))).toEqual(
await readFile(joinPath(tmpDir, 'tsconfig.json')),
);
});
});

it('transpiles projects to JS', async () => {
await inTemporaryDirectory(async (tmpDir) => {
await runInit({
Expand All @@ -198,15 +282,15 @@ describe('init', () => {
template: 'hello-world',
});

const helloWorldFiles = await glob('**/*', {
const templateFiles = await glob('**/*', {
cwd: getSkeletonSourceDir().replace('skeleton', 'hello-world'),
ignore: ['**/node_modules/**', '**/dist/**'],
});
const projectFiles = await glob('**/*', {cwd: tmpDir});
const resultFiles = await glob('**/*', {cwd: tmpDir});

expect(projectFiles).toEqual(
expect(resultFiles).toEqual(
expect.arrayContaining(
helloWorldFiles
templateFiles
.filter((item) => !item.endsWith('.d.ts'))
.map((item) =>
item
Expand Down Expand Up @@ -239,24 +323,24 @@ describe('init', () => {
mockShop: true,
});

const skeletonFiles = await glob('**/*', {
const templateFiles = await glob('**/*', {
cwd: getSkeletonSourceDir(),
ignore: ['**/node_modules/**', '**/dist/**'],
});
const projectFiles = await glob('**/*', {cwd: tmpDir});
const nonAppFiles = skeletonFiles.filter(
const resultFiles = await glob('**/*', {cwd: tmpDir});
const nonAppFiles = templateFiles.filter(
(item) => !item.startsWith('app/'),
);

expect(projectFiles).toEqual(expect.arrayContaining(nonAppFiles));
expect(resultFiles).toEqual(expect.arrayContaining(nonAppFiles));

expect(projectFiles).toContain('app/root.tsx');
expect(projectFiles).toContain('app/entry.client.tsx');
expect(projectFiles).toContain('app/entry.server.tsx');
expect(projectFiles).toContain('app/components/Layout.tsx');
expect(resultFiles).toContain('app/root.tsx');
expect(resultFiles).toContain('app/entry.client.tsx');
expect(resultFiles).toContain('app/entry.server.tsx');
expect(resultFiles).toContain('app/components/Layout.tsx');

// Skip routes:
expect(projectFiles).not.toContain('app/routes/_index.tsx');
expect(resultFiles).not.toContain('app/routes/_index.tsx');

// Not modified:
await expect(readFile(`${tmpDir}/server.ts`)).resolves.toEqual(
Expand Down Expand Up @@ -292,14 +376,14 @@ describe('init', () => {
await inTemporaryDirectory(async (tmpDir) => {
await runInit({path: tmpDir, git: false, routes: true, language: 'ts'});

const skeletonFiles = await glob('**/*', {
const templateFiles = await glob('**/*', {
cwd: getSkeletonSourceDir(),
ignore: ['**/node_modules/**', '**/dist/**'],
});
const projectFiles = await glob('**/*', {cwd: tmpDir});
const resultFiles = await glob('**/*', {cwd: tmpDir});

expect(projectFiles).toEqual(expect.arrayContaining(skeletonFiles));
expect(projectFiles).toContain('app/routes/_index.tsx');
expect(resultFiles).toEqual(expect.arrayContaining(templateFiles));
expect(resultFiles).toContain('app/routes/_index.tsx');

// Not modified:
await expect(readFile(`${tmpDir}/server.ts`)).resolves.toEqual(
Expand All @@ -321,15 +405,15 @@ describe('init', () => {
await inTemporaryDirectory(async (tmpDir) => {
await runInit({path: tmpDir, git: false, routes: true, language: 'js'});

const skeletonFiles = await glob('**/*', {
const templateFiles = await glob('**/*', {
cwd: getSkeletonSourceDir(),
ignore: ['**/node_modules/**', '**/dist/**'],
});
const projectFiles = await glob('**/*', {cwd: tmpDir});
const resultFiles = await glob('**/*', {cwd: tmpDir});

expect(projectFiles).toEqual(
expect(resultFiles).toEqual(
expect.arrayContaining(
skeletonFiles
templateFiles
.filter((item) => !item.endsWith('.d.ts'))
.map((item) =>
item
Expand All @@ -339,7 +423,7 @@ describe('init', () => {
),
);

expect(projectFiles).toContain('app/routes/_index.jsx');
expect(resultFiles).toContain('app/routes/_index.jsx');

// No types but JSDocs:
await expect(readFile(`${tmpDir}/server.js`)).resolves.toMatch(
Expand Down Expand Up @@ -454,8 +538,8 @@ describe('init', () => {
routes: true,
});

const projectFiles = await glob('**/*', {cwd: tmpDir});
expect(projectFiles).toContain('app/routes/_index.tsx');
const resultFiles = await glob('**/*', {cwd: tmpDir});
expect(resultFiles).toContain('app/routes/_index.tsx');

// Injects styles in Root
const serverFile = await readFile(`${tmpDir}/server.ts`);
Expand All @@ -479,8 +563,8 @@ describe('init', () => {
routes: true,
});

const projectFiles = await glob('**/*', {cwd: tmpDir});
expect(projectFiles).toContain('app/routes/_index.tsx');
const resultFiles = await glob('**/*', {cwd: tmpDir});
expect(resultFiles).toContain('app/routes/_index.tsx');

// Injects styles in Root
const serverFile = await readFile(`${tmpDir}/server.ts`);
Expand All @@ -504,9 +588,9 @@ describe('init', () => {
routes: true,
});

const projectFiles = await glob('**/*', {cwd: tmpDir});
const resultFiles = await glob('**/*', {cwd: tmpDir});
// Adds locale to the path
expect(projectFiles).toContain('app/routes/($locale)._index.tsx');
expect(resultFiles).toContain('app/routes/($locale)._index.tsx');

// Injects styles in Root
const serverFile = await readFile(`${tmpDir}/server.ts`);
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/commands/hydrogen/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ export default class Init extends Command {
}),
template: Flags.string({
description:
'Sets the template to use. Pass `demo-store` for a fully-featured store template or `hello-world` for a barebones project.',
'Scaffolds project based on an existing template or example from the Hydrogen repository.',
env: 'SHOPIFY_HYDROGEN_FLAG_TEMPLATE',
}),
'install-deps': commonFlags.installDeps,
Expand Down
9 changes: 7 additions & 2 deletions packages/cli/src/lib/onboarding/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -690,12 +690,15 @@ export async function renderProjectReady(

export function createAbortHandler(
controller: AbortController,
project: {directory: string},
project?: {directory: string},
) {
return async function abort(error: AbortError): Promise<never> {
controller.abort();

if (typeof project !== 'undefined') {
// Give time to hide prompts before showing error
await Promise.resolve();

if (project?.directory) {
await rmdir(project!.directory, {force: true}).catch(() => {});
}

Expand All @@ -710,6 +713,8 @@ export function createAbortHandler(
console.error(error);
}

// This code runs asynchronously so throwing here
// turns into an unhandled rejection. Exit process instead:
process.exit(1);
};
}
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/lib/onboarding/local.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ import {ALIAS_NAME, getCliCommand} from '../shell.js';
import {CSS_STRATEGY_NAME_MAP} from '../setups/css/index.js';

/**
* Flow for setting up a project from the locally bundled starter template (hello-world).
* Flow for setting up a project from the locally bundled starter template (skeleton).
*/
export async function setupLocalStarterTemplate(
options: InitOptions,
Expand Down
Loading

0 comments on commit 92840e5

Please sign in to comment.