feat: initial commit

This commit is contained in:
Helen Lin 2021-11-04 15:22:30 -07:00
parent 0323f74cda
commit 3d9b6c626d
863 changed files with 117883 additions and 0 deletions

4
.alexignore Normal file
View File

@ -0,0 +1,4 @@
docs/code_of_conduct.md
packages/hydrogen/src/docs/hooks.md
packages/hydrogen/src/docs/components.md
packages/hydrogen/src/docs/hydrogen-sdk.md

19
.alexrc.js Normal file
View File

@ -0,0 +1,19 @@
module.exports = {
allow: [
'hook',
'hooks',
'execute',
'invalid',
'failure',
'cracks',
'knives',
'simple',
'obvious',
'just',
'easy',
'period',
'of-course',
'special',
'dive',
],
};

121
.eslintrc.js Normal file
View File

@ -0,0 +1,121 @@
// @ts-check
const {defineConfig} = require('eslint-define-config');
module.exports = defineConfig({
root: true,
plugins: ['eslint-plugin-tsdoc'],
extends: [
'plugin:node/recommended',
'plugin:react/recommended',
'plugin:react-hooks/recommended',
],
settings: {
react: {
version: 'detect',
},
},
parser: '@typescript-eslint/parser',
parserOptions: {
sourceType: 'module',
ecmaVersion: 2020,
},
rules: {
'tsdoc/syntax': 'warn',
'no-debugger': ['error'],
'node/no-missing-import': [
'error',
{
allowModules: ['types', 'testUtils', '@shopify/hydrogen'],
tryExtensions: ['.ts', '.js', '.jsx', '.tsx', '.d.ts'],
},
],
'node/no-missing-require': [
'error',
{
tryExtensions: ['.ts', '.js', '.jsx', '.tsx', '.d.ts'],
},
],
'node/no-extraneous-import': [
'error',
{
allowModules: [
'@shopify/hydrogen',
'@testing-library/react',
'@testing-library/user-event',
'@shopify/react-testing',
],
},
],
'node/no-extraneous-require': [
'error',
{
allowModules: ['@shopify/hydrogen'],
},
],
'node/no-deprecated-api': 'off',
'node/no-unpublished-import': 'off',
'node/no-unpublished-require': 'off',
'node/no-unsupported-features/es-syntax': 'off',
'node/no-unsupported-features/es-builtins': [
'error',
// We need to manually specify a min-version since we can't use `engine`
{
version: '>=14.0.0',
ignores: [],
},
],
'node/no-unsupported-features/node-builtins': [
'error',
// We need to manually specify a min-version since we can't use `engine`
{
version: '>=14.0.0',
ignores: [],
},
],
'no-process-exit': 'off',
'prefer-const': [
'warn',
{
destructuring: 'all',
},
],
'react/prop-types': 'off',
},
overrides: [
{
files: ['packages/playground/**'],
rules: {
'node/no-extraneous-import': 'off',
'node/no-extraneous-require': 'off',
},
},
{
files: ['packages/create-hydrogen-app/template-*/**'],
rules: {
'node/no-missing-import': 'off',
},
},
{
files: [
'packages/dev/**',
'packages/localdev/**',
'packages/playground/**',
],
rules: {
'react/react-in-jsx-scope': 'off',
'react/prop-types': 'off',
},
},
{
files: ['**/*.example.*'],
rules: {
'react/react-in-jsx-scope': 'off',
'react-hooks/exhaustive-deps': 'off',
'react/jsx-key': 'off',
'react-hooks/rules-of-hooks': 'off',
'node/no-extraneous-import': 'off',
'node/no-missing-import': 'off',
},
},
],
});

31
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View File

@ -0,0 +1,31 @@
---
name: Bug report
about: Report a reproducible bug to help us improve
title: '[BUG]'
labels: bug
assignees: ''
---
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behaviour:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error '....'
**Expected behaviour**
A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots to help explain your problem.
**Additional context**
Add any other context about the problem here. eg.
- Hydrogen version
- Node version
- Device details

11
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View File

@ -0,0 +1,11 @@
blank_issues_enabled: true
contact_links:
- name: Ideas + Feature requests
url: https://github.com/Shopify/hydrogen/discussions/categories/ideas-feature-requests
about: Upvote existing ideas + feature requests, or start a new discussion with yours.
- name: Help
url: https://github.com/Shopify/hydrogen/discussions/categories/help
about: See if your question was already answered, or start a new discussion.
- name: Feedback
url: https://github.com/Shopify/hydrogen/discussions/categories/feedback
about: We <3 feedback.

18
.github/PULL_REQUEST_TEMPLATE.md vendored Normal file
View File

@ -0,0 +1,18 @@
<!-- Thank you for contributing! -->
### Description
<!-- Insert your description here and provide info about what issue this PR is solving -->
### Additional context
<!-- e.g. is there anything you'd like reviewers to focus on? -->
---
### Before submitting the PR, please make sure you do the following:
- [ ] Add your change under the `Unreleased` heading in the package's `CHANGELOG.md`
- [ ] Read the [Contributing Guidelines](https://github.com/shopify/hydrogen/blob/main/docs/contributing.md)
- [ ] Provide a description in this PR that addresses **what** the PR is solving, or reference the issue that it solves (e.g. `fixes #123`)
- [ ] (Shopifolk only) Open a PR in the Shopify Dev Docs with updates to the Hydrogen documentation, if needed

92
.github/commit-convention.md vendored Normal file
View File

@ -0,0 +1,92 @@
## Git Commit Message Convention
> This is adapted from [Angular's commit convention](https://github.com/conventional-changelog/conventional-changelog/tree/master/packages/conventional-changelog-angular).
#### TL;DR:
Messages must be matched by the following regex:
<!-- prettier-ignore -->
```js
/^(revert: )?(feat|fix|docs|dx|refactor|perf|test|workflow|build|ci|chore|types|wip|release|deps)(\(.+\))?: .{1,50}/
```
#### Examples
Appears under "Features" header, `dev` subheader:
```
feat(dev): add 'comments' option
```
Appears under "Bug Fixes" header, `dev` subheader, with a link to issue #28:
```
fix(dev): fix dev error
close #28
```
Appears under "Performance Improvements" header, and under "Breaking Changes" with the breaking change explanation:
```
perf(build): remove 'foo' option
BREAKING CHANGE: The 'foo' option has been removed.
```
The following commit and commit `667ecc1` do not appear in the changelog if they are under the same release. If not, the revert commit appears under the "Reverts" header.
```
revert: feat(compiler): add 'comments' option
This reverts commit 667ecc1654a317a13331b17617d973392f415f02.
```
### Full Message Format
A commit message consists of a **header**, **body** and **footer**. The header has a **type**, **scope** and **subject**:
```
<type>(<scope>): <subject>
<BLANK LINE>
<body>
<BLANK LINE>
<footer>
```
The **header** is mandatory and the **scope** of the header is optional.
### Revert
If the commit reverts a previous commit, it should begin with `revert: `, followed by the header of the reverted commit. In the body, it should say: `This reverts commit <hash>.`, where the hash is the SHA of the commit being reverted.
### Type
If the prefix is `feat`, `fix` or `perf`, it will appear in the changelog. However, if there is any [BREAKING CHANGE](#footer), the commit will always appear in the changelog.
Other prefixes are up to your discretion. Suggested prefixes are `docs`, `chore`, `style`, `refactor`, and `test` for non-changelog related tasks.
### Scope
The scope could be anything specifying the place of the commit change. For example `dev`, `build`, `starter`, `cli` etc...
### Subject
The subject contains a succinct description of the change:
- use the imperative, present tense: "change" not "changed" nor "changes"
- don't capitalize the first letter
- no dot (.) at the end
### Body
Just as in the **subject**, use the imperative, present tense: "change" not "changed" nor "changes".
The body should include the motivation for the change and contrast this with previous behavior.
### Footer
The footer should contain any information about **Breaking Changes** and is also the place to
reference GitHub issues that this commit **Closes**.
**Breaking Changes** should start with the word `BREAKING CHANGE:` with a space or two newlines. The rest of the commit message is then used for this.

57
.github/workflows/tests_and_lint.yml vendored Normal file
View File

@ -0,0 +1,57 @@
name: Tests and Lint
on:
push:
pull_request:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [14.x, 15.x, 16.x]
name: Node.js ${{ matrix.node-version }}
steps:
- name: Checkout the code
uses: actions/checkout@v2
- name: Setup Node.js
uses: actions/setup-node@v1
with:
node-version: ${{ matrix.node-version }}
- name: Get the yarn cache directory path
id: yarn-cache-dir-path
run: echo "::set-output name=dir::$(yarn cache dir)"
- name: Get the cache for yarn
uses: actions/cache@v2
id: yarn-cache
with:
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
path: ${{ steps.yarn-cache-dir-path.outputs.dir }}
restore-keys: |
${{ runner.os }}-yarn-
- name: Install the packages
run: yarn install --frozen-lockfile
- name: Lint the framework code
run: yarn lint
- name: Lint the template code
run: yarn lint
working-directory: ./packages/dev
- name: Build the code
run: yarn build
- name: Run the unit tests
run: yarn test
- name: Run the E2E tests
run: yarn test-e2e

125
.gitignore vendored Normal file
View File

@ -0,0 +1,125 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variables file
.env
.env.test
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
# User localdev package
packages/localdev
.DS_Store
temp
snow-devil
artifacts

1
.graphqlrc.yml Normal file
View File

@ -0,0 +1 @@
schema: "packages/hydrogen/graphql.schema.json"

1
.nvmrc Normal file
View File

@ -0,0 +1 @@
v16.5.0

6
.prettierrc.js Normal file
View File

@ -0,0 +1,6 @@
module.exports = {
...require('@shopify/prettier-config'),
tabWidth: 2,
printWidth: 80,
trailingComma: 'es5',
};

7
LICENSE.md Normal file
View File

@ -0,0 +1,7 @@
Copyright 2021-present, Shopify Inc.
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

29
dev.yml Normal file
View File

@ -0,0 +1,29 @@
# This is only used for CI. You should not need to run `dev up` locally.
up:
- node:
version: v16.5.0
yarn: 1.22.5
commands:
server:
run: yarn dev-server
desc: 'Start the web server'
lib:
run: yarn dev-lib
desc: 'Start the library dev server'
build:
run: yarn build
desc: Run the build process for lib and dev'
test:
run: yarn test;
desc: 'Run tests using Jest'
coverage:
run: yarn test:coverage; open coverage/index.html;
desc: 'Run tests using Jest, collect coverage and open in-browser coverage report'
open:
app: http://localhost:3000

63
docs/code_of_conduct.md Normal file
View File

@ -0,0 +1,63 @@
# Contributor Code of Conduct
## Our Pledge
We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, caste, color, religion, or sexual identity and orientation.
We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community.
## Our Standards
Examples of behavior that contributes to a positive environment for our community include:
* Demonstrating empathy and kindness toward other people
* Being respectful of differing opinions, viewpoints, and experiences
* Giving and gracefully accepting constructive feedback
* Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience
* Focusing on what is best not just for us as individuals, but for the overall community
Examples of unacceptable behavior include:
* The use of sexualized language or imagery, and sexual attention or advances of any kind
* Trolling, insulting or derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others private information, such as a physical or email address, without their explicit permission
* Other conduct which could reasonably be considered inappropriate in a professional setting
## Enforcement Responsibilities
Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful.
Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate.
## Scope
This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be reported [here](https://www.shopify.com/legal/report-aup-violation), or
by contacting the community leaders responsible for enforcement at <opensource@shopify.com>. All complaints will be reviewed and investigated promptly and fairly.
All community leaders are obligated to respect the privacy and security of the reporter of any incident.
## Enforcement Guidelines
Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct:
### 1. Correction
Community Impact: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community.
Consequence: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested.
### 2. Warning
Community Impact: A violation through a single incident or series of actions.
Consequence: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban.
### 3. Temporary Ban
Community Impact: A serious violation of community standards, including sustained inappropriate behavior.
Consequence: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban.
### 4. Permanent Ban
Community Impact: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals.
Consequence: A permanent ban from any sort of public interaction within the community.
## Attribution
This Code of Conduct is adapted from the Contributor Covenant, version 2.0, available at https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.

184
docs/contributing.md Normal file
View File

@ -0,0 +1,184 @@
# Contributing to Hydrogen
**Requirements:**
- Node v14+
- Yarn
```bash
git clone git@github.com:Shopify/hydrogen.git
yarn
# Start the library dev server first
yarn dev-lib
# In a new tab, start the dev server
yarn dev-server
```
Visit the dev environment at http://localhost:3000.
To make changes to the starter template, edit the files in `packages/dev`.
To modify Hydrogen framework, components, and hooks, edit the files in `packages/hydrogen`.
You can [inspect Vite plugin](https://github.com/antfu/vite-plugin-inspect) transformations by visiting `http://localhost:3000/__inspect`.
## Context
Hydrogen is a Yarn v1 monorepo. It consists of several key packages:
- `hydrogen`: The Hydrogen React framework & SDK
- `dev`: The starter template and local development playground
- `create-hydrogen-app`: The CLI used to scaffold new projects
- `playground`: Test cases used for both manual testing and automated end-to-end tests
For more information, check out the following resources:
- [Decision Log](./contributing/decisions.md)
- [Principles & Assumptions](./contributing/principles.md)
## Formatting and Linting
Hydrogen uses ESLint for linting and Prettier for code formatting.
[Yorkie](https://github.com/yyx990803/yorkie) is used to install a Git precommit hook, which lints and formats staged commits automatically.
To manually lint and format:
```bash
yarn lint
yarn format
```
## Commit Messages
Commit messages must follow the [commit message convention](../.github/commit-convention.md) so that changelogs can be more easily generated. Commit messages are automatically validated before commit (by invoking [Git Hooks](https://git-scm.com/docs/githooks) via [yorkie](https://github.com/yyx990803/yorkie)).
## Headless components
If you are building or making changes to a component, be sure to read [What are headless components?](./contributing/headlesscomponents.md) and [How to build headless components](./contributing/howtobuildheadless.md).
## GraphQL Types
If you make changes to or add any new `.graphql` files within Hydrogen, you will need to run the following commands in order to generate the type definitions and Graphql documents for the newly added/updated files:
```bash
cd packages/hydrogen
yarn graphql-types
```
## Running a local version of Hydrogen in a Hydrogen app
> Caution:
> You must use `yarn` for all commands, due to issues with NPM and dependencies, even if the prompt tells you to use `npm`.
Follow these instructions to create your own Hydrogen app using the local development version of the Hydrogen framework.
Before running any commands, be sure to build the Hydrogen lib with `yarn dev-lib` or `yarn build`.
```bash
cd packages/create-hydrogen-app && yarn link
```
This makes the executable `create-hydrogen` available globally.
Next, choose an option below.
### Option 1: `localdev` package
This option creates a new Hydrogen app similar to `dev` directly in the monorepo under `packages/localdev`. This directory is ignored in git, so your changes will not be tracked.
```terminal
create-hydrogen packages/localdev
# when prompted, use `localdev` for the package name
```
Then run your app:
```terminal
yarn workspace localdev dev
```
### Option 2: Standalone package
> Caution:
> This requires you to have a directory structure on your machine like `~/src/github.com/Shopify/*`, and it requires you to create your custom Hydrogen app in a namespace similar to `~/src/github.com/<namespace>/<your hydrogen app here>`.
1. In the directory you want to create your Hydrogen app, run `LOCAL=true create-hydrogen` and answer the prompts.
1. Run `cd <your app>`.
1. Run `yarn` or `npm i --legacy-peer-deps`.
1. Optional. Replace default `shopify.config.js` with your own storefront credentials.
1. Run `yarn dev` or `npm run dev` to start your dev server.
1. Open the dev server in your browser at http://localhost:3000.
If you make changes to core Hydrogen packages, then you'll need to delete `node_modules`, install dependencies again and start the server as mentioned above.
## Testing
Hydrogen is tested with unit tests for components, hooks and utilities. It is also tested with a suite of end-to-end tests inspired by [Vite's playground tests](https://github.com/vitejs/vite/tree/main/packages/playground).
Run unit tests with:
```bash
yarn test
# Optionally watch for changes
yarn test --watch
```
Run end-to-end tests with:
```bash
yarn test-e2e
# Optionally watch for changes
yarn test-e2e --watch
```
### Debugging tests in Github Actions
Tests that fail **only** in CI can be difficult and time-consuming to debug. If you find yourself in this situation, you can use [tmate](https://tmate.io/) to pause the Github Action on a given step and `ssh` into the container. Once in the container you can use `vim`, inspect the file system and try determining what might be diverging from running tests on your local computer and leading to the failure.
- Add the following `step` in your Github Actions workflow:
```yaml
- name: Setup tmate session
uses: mxschmitt/action-tmate@v3
```
- Commit and push your changes to Github.
- The testing Github Action will run automatically and you will see it paused with both a Web Shell address and SSH address.
![tmate](./images/tmate.png)
- Copy and paste the SSH address into your terminal.
### End-to-end tests
End-to-end tests are powered by [Playwright and Chromium](https://playwright.dev/). They are modeled closely after how [Vite handles E2E tests](https://github.com/vitejs/vite/tree/main/packages/playground).
Each mini-project under `packages/playground` contains a tests folder. You are welcome to modify an existing project or add a new project if it represents a different framework scenario, e.g. using a specific CSS framework or integration.
You can run a single E2E test by passing a keyword, which is matched using regex, e.g. `yarn test-e2e server` will run the `server-components` test.
## Releasing new versions
To release a new version of Hydrogen NPM packages, Shopifolk should follow these instructions:
First, ensure all changes have been merged to `main`.
Then:
```bash
git checkout main && git pull
yarn bump-version
```
> **Important**: Until our official release, we will only release `minor` and `patch` updates. This means that breaking changes will be included in minor releases. Once we officially launch Hydrogen, we'll switch to `1.0.0` and follow a normal semantic release pattern.
When finished, push up your changes.
Next, visit the Shipit page for Hydrogen and click **Deploy**.
After Shipit has released your version, visit the [releases page on GitHub](https://github.com/Shopify/hydrogen/releases), click on the version number you just released, and select "Create release from tag." Then, select "Auto-generate release notes." At this point, edit the release notes as you see fit (e.g. call out any breaking changes or upgrade guides). Finally, click "Publish release."

View File

@ -0,0 +1,4 @@
{
"label": "👩‍💻 Contributing",
"position": 3
}

View File

@ -0,0 +1,22 @@
---
title: 'Decision Log'
sidebar_position: 3
---
This document contains all the decisions that were taken since the beginning of this project. This is a living document and should be updated accordingly.
- Use [Vite](https://vitejs.dev/) for the framework. Fast, modern, leans into the ES module support of modern browsers rather than creating a giant bundle a la Webpack.
- Use [Tailwind](https://tailwindcss.com) for styles.
- Use [urql](https://formidable.com/open-source/urql/docs/) for making GraphQL queries.
- Avoid creating a custom `hydrogen` CLI tool for development and building. Instead, use existing `vite` CLI included with the framework for building, and provide Hydrogen middleware as a plugin.
- Hydrogen React components are exported and usable by other React frameworks, like Next.js and Gatsby.
- Hydrogen React components are specific to Shopify and not generic to web applications. E.g. `LayoutGrid` would never be a component provided by Hydrogen, but `ProductOptions` would.
- `shopify.config.js` is used for Shopify-specific config, like Storefront domain and accelerated checkout options.
- Use a custom `Link` component wrapped around [`ReactRouter`](https://reactrouter.com/) to fetch props from the server on page navigation.
- `ShopifyProvider` is a wrapper component providing helpful context to the rest of the application and Hydrogen components.
- Tobi will demo Hydrogen at Unite, but we will not be releasing Hydrogen yet to developers at Unite.
- While Vite is an implementation detail of Hydrogen, we should not define Hydrogen as merely a Vite plugin.
- In our Hydrogen template page components, chunks of GraphQL `QUERY` will go at the very bottom of each file. This reduces visual noise where the component is defined, and queries do not change as frequently. Additionally, we aren't using dedicated `.graphql` files in the starter template like we use in larger React projects at Shopify. Keeping the queries directly in the React components keeps the learning curve small.
- We chose to build a custom Codegen plugin to add comments to GraphQL fragment exports containing the contexts of the fragments themselves. This is useful for starter templates and seeing what is contained within a given fragment using VSCode's type helper.
- We will adopt a Server Components strategy for Hydrogen, modeled after [React Server Components](https://reactjs.org/blog/2020/12/21/data-fetching-with-react-server-components.html). We'll ship early with a similar version with plans to adopt the official version when it lands in React.
- We will use [@shopify/react-testing](https://github.com/Shopify/quilt/tree/main/packages/react-testing) instead of React Testing Library because Shopifolk are more familiar with this, and we believe it has more features that we can use out of the box. Additionally, we will name tests `<name>.test.ts` instead of `spec`, in a folder `tests` instead of `__tests__`.

View File

@ -0,0 +1,31 @@
# What are headless components?
At their core, headless components reflect the separation of concerns between UI and business logic. Headless components should contain all the business logic for the commerce concept they encompass, and leave all customization related to styling (CSS) and voice/tone (content) to the consumer. They should provide sensible defaults, and also make it easy to diverge from these defaults via customization.
## Styleless but styleable
A headless component should never provide visual styles. A headless component should focus on handling data (parsing, processing, etc) and providing limited, sensible markup. Any behaviour related to styling should be only for passing the styling through to its own HTML elements or their children.
When rendered by default, without any class names or styles, a headless component should only take on the styles provided natively by the browser. All styling should be provided by consumers of the headless component, either via the `className` prop or style attribute on the headless component.
Another way to think about this is: A storefront consisting of only headless components should be functional and render nicely when a stylesheet is not provided.
## Sensible defaults
Headless components should always render sensible defaults for semantic markup and localization.
Different HTML elements exist for a reason and, in the absence of styling, headless components should lean on semantic HTML elements for providing meaning and hierarchy. For example, if a headless component must render an image, it should output an `img` tag; a date, a `time` tag; a group of related sentences, a `p` tag.
The default display for many concepts relies on the country and language context in which it is used, and headless components should always ensure they are outputting defaults in a localized manner. This involves making use of built-in Javascript objects such an `Intl` for displaying currency and measurements, and `Date` for displaying dates and times.
## Easily customizable
Headless components should be just as easily customizable as if the developer was coding it with raw HTML and Javascript themselves. Developers should be able to add event listeners, aria attributes, and custom data attributes, unless these attributes need to be specifically controlled by the headless component (for example, the `AddToCartButton` must control the `onClick` attribute in order to add an item to the cart).
Even though headless components provide sensible defaults, there are many use cases where developers need to diverge from the default provided. In this case, customization can be supported via render props.
Keep in mind when it comes to customization: “Common things should be easy; uncommon things should be possible” (credit on this quote goes to Ash Furrow). The most common thing should be accomplished by sensible defaults (described in the previous section), uncommon things should be accomplished by customization via render props. Only in rare/edge cases should developers of Hydrogen need to abandon the use of a component entirely and build their own implementation of it.
## Never include hardcoded content (strings)
Headless components should never include hardcoded content (for example, strings like “Add to cart” and “Remove from cart”). Content is similar to visual styles in that it can be highly customizable in tone, audience, language, etc. Instead of providing content themselves, content should always be provided to the headless component by the consumer via either children or props.

View File

@ -0,0 +1,25 @@
# How to build headless components
> ⚠️ Ensure you've read [What are headless components?](./headlesscomponents.md) before attempting to build any components yourself.
## Understand the concept and primitives
Consider what commerce concepts and/or data objects youll be working with for the component, how they are represented in the [Shopify Storefront API](https://shopify.dev/api/storefront), which data is essential for them, and which resources use them.
For example, if you want to build a component to handle [prices and money](https://shopify.dev/api/storefront/reference/common-objects/moneyv2), its important to know the amount and the currency code. Its also important to know that there are prices associated with [ProductVariants](https://shopify.dev/api/storefront/reference/products/productvariant#fields-2021-07), and [price ranges](https://shopify.dev/api/storefront/reference/products/productpricerange) for [Products](https://shopify.dev/api/storefront/reference/products/product). This helps you determine which essential data or props are needed (for example, `amount` and `currencyCode`), how many components need to be built, and at which level of abstraction they need to be built (for example, a base `Money` component, or a `SelectedVariantPrice` component that makes use of the `Money` component).
## Determine sensible defaults
Consider what a sensible default would be for the component. Look at various ecommerce websites and check if there is a common pattern for how this information is rendered, both on Shopify and non-Shopify storefronts. Browse through the [Liquid documentation](https://shopify.dev/api/liquid) to see if there are any [Liquid filters](https://shopify.dev/api/liquid/filters) available. If there are filters available, see what those render by default and what customizations they support. Lastly, chat with UX folks to see what the best default should be.
## Prioritize developer experience
Hydrogen must be fun and easy to use, with good ergonomics. Developer experience is very important.
Always consider how _you_ would want to use this component if you were a developer building a custom storefront. Is the API easy to use? Which props can I pass? What are their names? Should I need to pass these props by default, or can I make use of a context to make the component easier to use? What if I dont want to use the default? How can I customize it? Is this easy to use with JSX?
Developers should be **delighted** when they use headless components. To quote Tobi Lütke: “Delight works by taking your experience minus your expectation, and if the end result is a positive number, you are delighted by that margin.”
## Code, code, code
Start building. Its likely that you might discover while hacking away that you dont need a component at all: you might only need a hook (for example, `useProductOptions`) or some utilities (`flattenConnection`). On the other hand, you might discover that you need more components than you initially anticipated.

View File

@ -0,0 +1,33 @@
---
title: 'Principles & Assumptions'
sidebar_position: 2
---
For years, Shopify merchants and developers have been building "headless" web storefront solutions in the cracks and margins of our platform. The people who are at the core of our business have been left to fend for themselves, bombarded by dozens of choices for JavaScript frameworks, style libraries, component frameworks, and data-fetching solutions.
Opinionated design is core to Shopify and its success, and we believe we can pave the way to a new standard for custom storefronts across the industry.
That's why we're introducing **Hydrogen** to the world.
## Headless, the Good Parts
In the same way that Liquid storefronts are "Web Native Online Store, the Good Parts," Hydrogen provides an opinionated way to build custom storefronts.
Hydrogen's design is inspired by the [Ruby on Rails Doctrine](https://rubyonrails.org/doctrine/). If you're a developer building Hydrogen, you should read this doctrine to understand the underlying principles behind Hydrogen.
- Hydrogen optimizes for **programmer happiness** by rethinking the way web applications are built.
- Hydrogen provides **sharp knives** by allowing developers to write custom GraphQL queries without much hand-holding or abstracted safety nets.
- Hydrogen pushes **progressively forward**, setting the stage for when new server-side rendering patterns like React Server Components are introduced.
- Hydrogen provides the **best dishes on the menu**: React for components, React Hooks for managing state, headless and stylable commerce components for managing complex interactions, and Tailwind for styles.
Taking this approach to build Hydrogen might run counter to the way the JavaScript ecosystem works today. For example, a platform like Shopify might advocate for plugins in every single popular framework and integration out there to attempt to supplement the endlessly-growing list of choices.
**Shopify wants to part with this tradition**. Hydrogen is an opinionated product allowing Shopify merchants to build custom web storefronts. It doesn't aim to seamlessly integrate with the way other frameworks do things; rather, it provides a powerful SDK which allows developers to take the goodness of Hydrogen wherever they want to go. Hydrogen is a toolkit: developers can use the whole thing, or they can use the parts to snap into other things they want to use.
## Assumptions
- **Merchants with existing headless (React) web solutions can use Hydrogen components when they are released**. The unstyled components built for Shopify storefronts can replace custom-built components.
- **Merchants with existing headless (React) web solutions will NOT use the Hydrogen starter kit**, unless they feel like rebuilding their app.
- **Hydrogen components are built with React**, but we may support other frameworks like Vue in the future.
- **Hydrogen's starter kit is built using React**, and we have NO plans to build a similar starter kit for other frameworks like Vue.
- **Hydrogen will not offer a vanilla JavaScript SDK solution**. Hydrogen is built for React and will eventually offer components for other frameworks. Users building their web app without a supported framework will make GraphQL calls directly using modern web libraries like `fetch`.

View File

@ -0,0 +1,4 @@
{
"label": "🧱 Framework",
"position": 5
}

View File

@ -0,0 +1,120 @@
---
title: Deploying to Production
sidebar_position: 8
---
To run the Hydrogen dev environment in production, first build the framework and then the dev project. You can run Hydrogen on the following platforms:
### Platform: Node.js
Runs the Hydrogen dev project on the port specified as `$PORT`, defaulting to `8080`.
```bash
yarn build
yarn workspace dev serve
```
Visit the project running at http://localhost:8080.
### Platform: Docker
Runs the Hydrogen dev project on the port specified as `$PORT`, defaulting to `8080`.
Inside the `hydrogen/packages/dev` directory:
```bash
docker build .
docker run -p 8080
```
Visit the project running at http://localhost:8080.
### Platform: Cloudflare Workers
First, [create a Hydrogen app locally](https://shopify.dev/custom-storefronts/hydrogen/getting-started).
Then, create a `wrangler.toml` in the root of your project:
```toml
name = "PROJECT_NAME"
type = "javascript"
account_id = ""
workers_dev = true
route = ""
zone_id = ""
[site]
bucket = "dist/client"
entry-point = "."
[build]
upload.format = "service-worker"
command = "yarn && yarn build"
```
Install Cloudflare's KV asset handler:
```bash
npm install @cloudflare/kv-asset-handler
```
And update your `worker.js` to pass an `assetHandler` to `handleEvent`:
```js
import handleEvent from '@shopify/hydrogen/worker';
import entrypoint from './src/entry-server.jsx';
// eslint-disable-next-line node/no-missing-import
import indexHtml from './dist/client/index.html?raw';
import {getAssetFromKV} from '@cloudflare/kv-asset-handler';
async function assetHandler(event, url) {
const response = await getAssetFromKV(event, {});
if (response.status < 400) {
const filename = url.pathname.split('/').pop();
const maxAge =
filename.split('.').length > 2
? 31536000 // hashed asset, will never be updated
: 86400; // favico and other public assets
response.headers.append('cache-control', `public, max-age=${maxAge}`);
}
return response;
}
addEventListener('fetch', (event) => {
try {
event.respondWith(
handleEvent(event, {
entrypoint,
indexTemplate: indexHtml,
assetHandler,
cache: caches.default,
context: event,
})
);
} catch (error) {
event.respondWith(
new Response(error.message || error.toString(), {
status: 500,
})
);
}
});
```
Update `package.json` to include a `main` key:
```
"main": "dist/worker/worker.js"
```
Then you can deploy your project with [Wrangler](https://developers.cloudflare.com/workers/cli-wrangler/install-update):
```bash
CF_ACCOUNT_ID=<YOUR_CLOUDFLARE_ACCT_ID> wrangler publish
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

BIN
docs/images/tmate.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

1
docs/welcome.md Normal file
View File

@ -0,0 +1 @@
Documentation for Hydrogen is now available at https://shopify.dev/custom-storefronts/hydrogen.

20
jest-e2e.config.ts Normal file
View File

@ -0,0 +1,20 @@
// eslint-disable-next-line node/no-extraneous-import
import type {Config} from '@jest/types';
const config: Config.InitialOptions = {
preset: 'ts-jest',
testMatch: ['**/playground/**/*.(spec|test).[jt]s?(x)'],
testTimeout: process.env.CI ? 30000 : 10000,
watchPathIgnorePatterns: ['<rootDir>/temp'],
globalSetup: './scripts/jest-e2e-setup.js',
globalTeardown: './scripts/jest-e2e-teardown.js',
testEnvironment: './scripts/jest-e2e-env.js',
setupFilesAfterEnv: ['./scripts/jest-e2e-setup-test.ts'],
globals: {
'ts-jest': {
tsconfig: './packages/playground/tsconfig.json',
},
},
};
export default config;

4
jest-setup.ts Normal file
View File

@ -0,0 +1,4 @@
import '@shopify/react-testing';
import '@shopify/react-testing/matchers';
import '@testing-library/jest-dom';
import './scripts/polyfillWebRuntime';

36
jest.config.ts Normal file
View File

@ -0,0 +1,36 @@
// eslint-disable-next-line node/no-extraneous-import
import type {Config} from '@jest/types';
const config: Config.InitialOptions = {
preset: 'ts-jest',
testMatch: [
'**/*.(spec|test).[jt]s?(x)',
'!**/*/dist/**/*',
'!**/*/fixtures/**/*',
],
testPathIgnorePatterns: ['<rootDir>/packages/playground/*'],
testTimeout: process.env.CI ? 30000 : 10000,
watchPathIgnorePatterns: ['<rootDir>/temp', 'fixtures'],
setupFilesAfterEnv: ['<rootDir>/jest-setup.ts'],
globals: {
'ts-jest': {
tsconfig: {
jsx: 'react',
esModuleInterop: true,
lib: ['ESNext', 'DOM'],
target: 'es6',
},
},
},
collectCoverageFrom: [
'packages/hydrogen/**/*.{ts,tsx}',
'!packages/hydrogen/**/*.d.{ts,tsx}',
'!packages/hydrogen/src/graphql/**/*',
'!packages/hydrogen/node_modules/**/*',
'!packages/hydrogen/dist/**/*',
],
coverageDirectory: 'coverage',
coverageReporters: ['html-spa', 'text-summary'],
};
export default config;

10
lerna.json Normal file
View File

@ -0,0 +1,10 @@
{
"lerna": "4.0.0",
"packages": [
"packages/*"
],
"version": "0.5.8",
"npmClient": "yarn",
"useWorkspaces": true,
"command": {}
}

93
package.json Normal file
View File

@ -0,0 +1,93 @@
{
"name": "hydrogen-monorepo",
"private": true,
"workspaces": {
"packages": [
"packages/*",
"packages/playground/*"
]
},
"engines": {
"node": ">=12.0.0"
},
"scripts": {
"dev-lib": "yarn workspace @shopify/hydrogen dev",
"dev-server": "VITE_INSPECT=1 yarn workspace dev dev",
"build": "run-s build-lib build-dev build-cli",
"build-lib": "yarn workspace @shopify/hydrogen build",
"build-cli": "yarn workspace @shopify/hydrogen-cli build",
"build-dev": "yarn workspace dev build",
"lint": "run-p lint:js lint:language",
"lint:js": "eslint --no-error-on-unmatched-pattern --ext .js,.ts,.jsx,.tsx packages/*/src",
"lint:language": "alex .",
"format": "prettier --write packages/*/src/**",
"test": "jest",
"test:coverage": "jest --coverage",
"test-e2e": "jest --config jest-e2e.config.ts",
"bump-version": "lerna version --force-publish create-hydrogen-app",
"version": "node -p \"'export const LIB_VERSION = \\'' + require('./lerna.json').version + '\\';'\" > packages/hydrogen/src/version.ts && git add packages/hydrogen/src/version.ts && node scripts/update-changelogs-on-version.js",
"hydrogen": "./packages/cli/bin/hydrogen",
"h2": "./packages/cli/bin/hydrogen",
"update-docs-on-version": "ts-node --project ./tsconfig.json ./scripts/update-docs-on-version.ts",
"generate-docs": "ts-node --project ./tsconfig.json ./scripts/generate-docs.ts",
"generate-docs:debug": "node --inspect-brk ./node_modules/.bin/ts-node --project ./tsconfig.json ./scripts/generate-docs.ts"
},
"devDependencies": {
"@shopify/prettier-config": "^1.1.2",
"@shopify/react-testing": "^3.2.0",
"@testing-library/jest-dom": "^5.11.10",
"@testing-library/react": "^11.2.6",
"@testing-library/user-event": "^13.1.2",
"@types/estree": "^0.0.50",
"@types/faker": "^5.5.7",
"@types/fs-extra": "^9.0.12",
"@types/jest": "^26.0.22",
"@typescript-eslint/parser": "^4.20.0",
"alex": "^9.1.0",
"chalk": "^4.1.2",
"eslint": "^7.23.0",
"eslint-define-config": "^1.0.7",
"eslint-plugin-node": "^11.1.0",
"eslint-plugin-react": "^7.23.2",
"eslint-plugin-react-hooks": "^4.2.0",
"eslint-plugin-tsdoc": "^0.2.14",
"faker": "^5.5.3",
"fs-extra": "^10.0.0",
"jest": "^26.6.3",
"lerna": "^4.0.0",
"lint-staged": "^10.5.4",
"node-fetch": "^2.6.1",
"npm-run-all": "^4.1.5",
"playwright-chromium": "^1.13.0",
"prettier": "^2.2.1",
"sirv": "^1.0.12",
"ts-jest": "^26.5.4",
"ts-node": "^10.2.1",
"typescript": "^4.2.3",
"vite": "^2.6.0",
"yorkie": "^2.0.0"
},
"gitHooks": {
"pre-commit": "lint-staged",
"commit-msg": "node scripts/verify-commit.js"
},
"lint-staged": {
"*.{js,jsx}": [
"prettier --write"
],
"*.{ts,tsx}": [
"eslint",
"prettier --parser=typescript --write"
],
"*.html": [
"prettier --write"
],
"*.md": [
"prettier --write"
]
},
"resolutions": {
"unified": "9.2.2"
},
"version": "0.0.0"
}

57
packages/cli/CHANGELOG.md Normal file
View File

@ -0,0 +1,57 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/)
and adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html).
## Unreleased
- Add create component command [#806](https://github.com/Shopify/hydrogen/pull/806)
- Add init command [#791](https://github.com/Shopify/hydrogen/pull/791)
## 0.5.8 - 2021-11-04
- No updates. Transitive dependency bump.
## 0.5.1 - 2021-11-02
- No updates. Transitive dependency bump.
## 0.5.0 - 2021-11-01
- No updates. Transitive dependency bump.
## 0.4.2 - 2021-10-29
- No updates. Transitive dependency bump.
## 0.4.0 - 2021-10-27
- No updates. Transitive dependency bump.
## 0.3.0 - 2021-10-20
- No updates. Transitive dependency bump.
## 0.2.1 - 2021-10-12
- No updates. Transitive dependency bump.
## 0.2.0 - 2021-10-08
- No updates. Transitive dependency bump.
## 0.1.2 - 2021-09-30
- Fixed error when creating a PWA app
- Fix no-lint setting from generating an invalid script.
## 0.1.0 - 2021-09-23
- No updates. Transitive dependency bump.
## 1.0.0-alpha.22 - 2021-09-22
- No updates. Transitive dependency bump.

29
packages/cli/README.md Normal file
View File

@ -0,0 +1,29 @@
<!-- This file is generated from the source code. Edit the files in /packages/cli and run 'yarn generate-docs' at the root of this repo. -->
## `@shopify/hydrogen-cli`
`hydrogen/cli` provides interactive project scaffolding for hydrogen apps and other useful commands to help developers build on `@shopify/hydrogen`.
Note: The CLI does not currently provide a full starter template. Run npx create-hydrogen-app instead to scaffold a new project with the starter template. To contribute to the starter template, update files in packages/dev.
## Installation
The `@shopify/hydrogen-cli` is installable globally or as a project dependency.
```bash
yarn global add @shopify/hydrogen-cli
```
After installation, you will have access to the `h2` binary in your command line. You can verify that it is properly installed by running `h2 version`, which should present you with the currently installed version.
```bash
h2 version
```
### Upgrading
To upgrade the global `@shopify/hydrogen-cli` package, you need to run:
```bash
yarn global upgrade --latest @shopify/hydrogen-cli
```

2
packages/cli/bin/hydrogen Executable file
View File

@ -0,0 +1,2 @@
#!/usr/bin/env node
require('../dist');

View File

@ -0,0 +1,21 @@
## Installation
The `@shopify/hydrogen-cli` is installable globally or as a project dependency.
```bash
yarn global add @shopify/hydrogen-cli
```
After installation, you will have access to the `h2` binary in your command line. You can verify that it is properly installed by running `h2 version`, which should present you with the currently installed version.
```bash
h2 version
```
### Upgrading
To upgrade the global `@shopify/hydrogen-cli` package, you need to run:
```bash
yarn global upgrade --latest @shopify/hydrogen-cli
```

View File

@ -0,0 +1,5 @@
## `@shopify/hydrogen-cli`
`hydrogen/cli` provides interactive project scaffolding for hydrogen apps and other useful commands to help developers build on `@shopify/hydrogen`.
Note: The CLI does not currently provide a full starter template. Run npx create-hydrogen-app instead to scaffold a new project with the starter template. To contribute to the starter template, update files in packages/dev.

61
packages/cli/package.json Normal file
View File

@ -0,0 +1,61 @@
{
"name": "@shopify/hydrogen-cli",
"version": "0.5.8",
"description": "Command line interface for hydrogen",
"license": "MIT",
"engines": {
"node": ">=12.0.0"
},
"publishConfig": {
"access": "public",
"@shopify:registry": "https://registry.npmjs.org"
},
"main": "./dist/index.js",
"bin": {
"hydrogen": "./bin/hydrogen",
"h2": "./bin/hydrogen"
},
"scripts": {
"test": "jest",
"dev": "yarn build -w",
"build": "tsc -p . ",
"prepack": "yarn build && node scripts/copy-templates.js",
"hydrogen": "./packages/cli/bin/hydrogen",
"h2": "./packages/cli/bin/hydrogen"
},
"repository": {
"type": "git",
"url": "git+https://github.com/Shopify/hydrogen.git",
"directory": "packages/cli"
},
"dependencies": {
"@types/debug": "^4.1.7",
"chalk": "^4.1.2",
"change-case": "^4.1.2",
"cosmiconfig": "^7.0.1",
"debug": "^4.3.2",
"fs-extra": "^10.0.0",
"glob": "^7.2.0",
"inquirer": "^8.1.2",
"minimist": "^1.2.5"
},
"devDependencies": {
"@types/change-case": "^2.3.1",
"@types/faker": "^5.5.7",
"@types/fs-extra": "^9.0.12",
"@types/inquirer": "^7.3.3",
"@types/minimist": "^1.2.2",
"change-case": "^4.1.2",
"faker": "^5.5.3",
"get-port": "^5.1.1",
"inquirer": "^8.1.2",
"playwright-chromium": "^1.13.0",
"sirv": "^1.0.14",
"typescript": "^4.2.3",
"vite": "^2.6.0"
},
"files": [
"dist",
"bin"
]
}

View File

@ -0,0 +1,36 @@
/* eslint-disable no-console, consistent-return */
const {resolve, join} = require('path');
const fs = require('fs-extra');
const glob = require('glob');
function copyFiles() {
glob(
resolve(__dirname, '../../create-hydrogen-app/template-hydrogen/*'),
{
dot: true,
},
(globErr, files) => {
if (globErr) {
return console.error(globErr);
}
files.forEach((file) => {
if (file.includes('node_modules')) {
return;
}
const dest = file.replace(
'create-hydrogen-app/template-hydrogen',
'cli/dist/commands/init/templates/template-hydrogen'
);
fs.copy(resolve(file), resolve(dest), (copyErr) => {
if (copyErr) {
console.error(file, dest, copyErr);
}
});
});
}
);
}
copyFiles();

View File

@ -0,0 +1,5 @@
<!-- This file is generated from the source code. Edit the files in /Users/matt/src/github.com/Shopify/hydrogen/packages/cli/src/commands and run 'yarn generate-docs' at the root of this repo. -->
| | |
| --------------------------------------------------------- | ---------------------------------- |
| <a href="/api/hydrogen/cli/commands/version">version</a> | Print the version of hydrogen-cli. |

View File

@ -0,0 +1,9 @@
<!-- This file is generated from the source code. Edit the files in /packages/cli/src/commands/create/app and run 'yarn generate-docs' at the root of this repo. -->
Configure, modify and scaffold new `@shopify/hydrogen` apps.
## Example code
```bash
h2 create app
```

View File

@ -0,0 +1,114 @@
import {Env} from '../../../types';
import {Feature, ifFeature} from '../../../utilities/feature';
/**
* Configure, modify and scaffold new `@shopify/hydrogen` apps.
*/
export async function app(env: Env<{name: string}>) {
const {ui, fs, workspace, context} = env;
const {name} = context || {};
const features = await ui.ask<Feature>('Select the features you would like', {
choices: [
Feature.Pwa,
Feature.Eslint,
Feature.Stylelint,
Feature.Tailwind,
Feature.GraphQL,
Feature.Prettier,
Feature.CustomServer,
],
name: 'features',
multiple: true,
});
const storeDomain = await ui.ask('What is your myshopify.com store domain?', {
default: 'hydrogen-preview.myshopify.com',
name: 'storeDomain',
});
const storefrontToken = await ui.ask('What is your storefront token?', {
default: '3b580e70970c4528da70c98e097c2fa0',
name: 'storeFrontToken',
});
const templateArgs = {
ifFeature: ifFeature(features),
features,
storeDomain: storeDomain?.replace(/^https?:\/\//i, ''),
storefrontToken,
name,
};
await Promise.all([
render('shopify.config.js', './templates/shopify-config-js'),
render('index.html', './templates/index-html'),
render('vite.config.js', './templates/vite-config-js'),
render('src/index.css', './templates/index-css'),
render('src/App.server.jsx', './templates/App-server-jsx'),
render('src/entry-client.jsx', './templates/entry-client-jsx'),
render('src/entry-server.jsx', './templates/entry-server-jsx'),
render('src/pages/Index.server.jsx', './templates/Index-server-jsx'),
render('src/pages/About.server.jsx', './templates/About-server-jsx'),
render('src/components/Link.client.jsx', './templates/Link-client-jsx'),
]);
if (features.includes(Feature.CustomServer)) {
await render('server.js', './templates/server-js');
workspace.install('express');
}
if (features.includes(Feature.Stylelint)) {
await render('.stylelintrc.js', './templates/stylelintrc-js');
workspace.install('stylelint', {dev: true});
workspace.install('@shopify/stylelint-plugin', {dev: true});
}
if (features.includes(Feature.Eslint)) {
await render('.eslintrc.js', './templates/eslintrc-js');
workspace.install('eslint');
// TODO: Replace with hydrogen plugin when available
workspace.install('@shopify/eslint-plugin');
}
if (features.includes(Feature.Tailwind)) {
await render('tailwind.config.js', './templates/tailwind-config-js');
await render('postcss.config.js', './templates/postcss-config-js');
workspace.install('autoprefixer', {dev: true});
workspace.install('postcss', {dev: true});
workspace.install('tailwindcss', {dev: true});
workspace.install('@tailwindcss/typography', {dev: true});
}
if (features.includes(Feature.Pwa)) {
await render('public/sw.js', './templates/sw-js');
workspace.install('vite-plugin-pwa', {version: '^0.8.1'});
}
if (features.includes(Feature.Prettier)) {
await render('postcss.config.js', './templates/postcss-config-js');
workspace.install('prettier');
workspace.install('@shopify/prettier-config', {dev: true});
}
if (features.includes(Feature.GraphQL)) {
workspace.install('graphql-tag');
}
workspace.install('react', {version: '18.0.0-alpha-e6be2d531'});
workspace.install('react-dom', {version: '18.0.0-alpha-e6be2d531'});
workspace.install('react-router-dom', {version: '^5.2.0'});
workspace.install('@shopify/hydrogen');
workspace.install('vite', {dev: true, version: '^2.6.0'});
workspace.install('@vitejs/plugin-react-refresh', {
dev: true,
version: '^1.3.2',
});
async function render(path: string, templatePath: string) {
fs.write(
fs.join(workspace.root(), path),
(await import(templatePath)).default(templateArgs)
);
}
}

View File

@ -0,0 +1 @@
h2 create app

View File

@ -0,0 +1 @@
export {app as default} from './app';

View File

@ -0,0 +1,14 @@
export default function () {
return `
export default function About() {
return (
<div className="Page">
<h1>
About
</h1>
<p>Share your journey.</p>
</div>
);
}
`;
}

View File

@ -0,0 +1,30 @@
export default function () {
return `
import {
ShopifyServerProvider,
DefaultRoutes,
} from '@shopify/hydrogen';
import {Switch} from 'react-router-dom';
import {Suspense} from 'react';
import shopifyConfig from '../shopify.config';
export default function App({...serverState}) {
const pages = import.meta.globEager('./pages/**/*.server.[jt]sx');
return (
<ShopifyServerProvider shopifyConfig={shopifyConfig} {...serverState}>
<Suspense fallback="Loading...">
<Switch>
<DefaultRoutes
pages={pages}
serverState={serverState}
fallback={() => <div>Not found</div>}
/>
</Switch>
</Suspense>
</ShopifyServerProvider>
);
}
`;
}

View File

@ -0,0 +1,66 @@
import {Feature} from '../../../../utilities/feature';
import {TemplateOptions} from '../../../../types';
export default function ({features, name}: TemplateOptions) {
const featuresMarkup = features.length
? `
<h2>Features:</h2>
<ul>
${features
.map(
(feature) =>
`<li ${
features.includes(Feature.Tailwind)
? `className="text-blue-700"`
: ''
}>${feature}</li>`
)
.join('')}
</ul>
`
: '';
return `
import Link from '../components/Link.client';
export default function Index() {
return (
<div className="Page">
<p>
<Link className="link" to="/about">
About
</Link>
</p>
<h1>
Welcome to{' '}
<a target="_blank" href="https://github.com/Shopify/hydrogen">
Hydrogen
</a>{' '}
💦
</h1>
<p>
Hydrogen is a{' '}
<a target="_blank" href="https://reactjs.org/">
React
</a>{' '}
framework and a SDK for building custom{' '}
<a target="_blank" href="https://shopify.com">
Shopify
</a>{' '}
storefronts.
</p>
<p>
Get started by editing <strong>pages/Index.server.jsx</strong>
<br /> or reading our{' '}
<a target="_blank" href="https://hydrogen.docs.shopify.io/">
getting started guide
</a>
.
</p>
<h2 className="appName">${name}</h2>
${featuresMarkup}
</div>
);
}
`;
}

View File

@ -0,0 +1,11 @@
export default function () {
return `
import {Link as ReactRouterLink} from 'react-router-dom';
export default function Link(props) {
return (
<ReactRouterLink {...props} />
);
}
`;
}

View File

@ -0,0 +1,21 @@
import {TemplateOptions} from 'types';
import {Feature} from '../../../../utilities/feature';
export default function ({ifFeature}: TemplateOptions) {
return `
import renderHydrogen from '@shopify/hydrogen/entry-client';
${ifFeature(Feature.Pwa, `import {registerSW} from 'virtual:pwa-register';`)}
import {ShopifyProvider} from '@shopify/hydrogen/client';
import shopifyConfig from '../shopify.config';
function ClientApp({children}) {
${ifFeature(Feature.Pwa, 'registerSW()')}
return (
<ShopifyProvider shopifyConfig={shopifyConfig}>{children}</ShopifyProvider>
);
}
export default renderHydrogen(ClientApp);
`;
}

View File

@ -0,0 +1,11 @@
export default function () {
return `
import renderHydrogen from '@shopify/hydrogen/entry-server';
import App from './App.server';
export default renderHydrogen(App, ({url}) => {
// Custom hook
});
`;
}

View File

@ -0,0 +1,45 @@
export default function () {
return `
module.exports = {
extends: [
'plugin:@shopify/typescript',
'plugin:@shopify/react',
'plugin:@shopify/node',
'plugin:@shopify/prettier',
],
rules: {
'@shopify/jsx-no-hardcoded-content': 'off',
'@shopify/jsx-no-complex-expressions': 'off',
/**
* React overrides
*/
'react/react-in-jsx-scope': 'off',
'react/jsx-filename-extension': ['error', {extensions: ['.tsx', '.jsx']}],
'react/prop-types': 'off',
/**
* Import overrides
*/
'import/no-unresolved': ['error', {ignore: ['@shopify/hydrogen']}],
/**
* ESlint overrides
*/
'no-use-before-define': 'off',
'no-warning-comments': 'off',
/**
* jsx-a11y overrides
*/
'jsx-a11y/click-events-have-key-events': 'off',
'jsx-a11y/no-noninteractive-element-interactions': 'off',
// These two rules result in a significant number of false positives so we
// need to keep them disabled.
'jsx-a11y/label-has-for': 'off',
'jsx-a11y/control-has-associated-label': 'off',
},
};
`;
}

View File

@ -0,0 +1,31 @@
import {TemplateOptions} from 'types';
import {Feature} from '../../../../utilities/feature';
export default function ({ifFeature}: TemplateOptions) {
return `
${ifFeature(Feature.Tailwind, '@tailwind utilities;')}
* {
font-size: 24px;
line-height: 1.3;
padding: 0;
margin: 0;
color: black;
}
p,
ul,
h1 {
padding-bottom: 24px;
}
.Page {
font-family: sans-serif;
max-width: 24em;
padding: 48px;
}
li {
list-style-position: inside;
}
`;
}

View File

@ -0,0 +1,29 @@
import {TemplateOptions} from 'types';
import {Feature} from '../../../../utilities/feature';
export default function ({ifFeature}: TemplateOptions) {
return `
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/src/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
${ifFeature(
Feature.Pwa,
`
<link rel="apple-touch-icon" href="/snowdevil-192w.png" />
<meta name="theme-color" content="#2ec6b9" />
`
)}
<title>Hydrogen App</title>
<link rel="stylesheet" href="/src/index.css" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/entry-client.jsx"></script>
</body>
</html>
`;
}

View File

@ -0,0 +1,10 @@
export default function () {
return `
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
`;
}

View File

@ -0,0 +1,43 @@
export default function () {
return `
// @ts-check
const fs = require('fs');
const path = require('path');
const express = require('express');
// TODO: Make it so we don't have to call \`.default\` at the end of this
const hydrogenMiddleware = require('@shopify/hydrogen/middleware').default;
const resolve = (p) => path.resolve(__dirname, p);
async function createServer() {
const indexProd = fs.readFileSync(resolve('dist/client/index.html'), 'utf-8');
const app = express();
app.use(require('compression')());
app.use(
require('serve-static')(resolve('dist/client'), {
index: false,
}),
);
app.use(
'*',
hydrogenMiddleware({
getServerEntrypoint: () =>
require('./dist/server/entry-server.js').default,
indexTemplate: indexProd,
}),
);
return {app};
}
createServer().then(({app}) => {
const port = process.env.PORT || 8080;
app.listen(port, () => {
console.log(\`Hydrogen running at http://localhost:\${port}\`);
});
});
`;
}

View File

@ -0,0 +1,14 @@
import {TemplateOptions} from 'types';
export default function ({storeDomain, storefrontToken}: TemplateOptions) {
return `
const shopifyConfig = {
locale: 'en-us',
storeDomain: '${storeDomain}',
storefrontToken: '${storefrontToken}',
graphqlApiVersion: 'unstable',
};
export default shopifyConfig
`;
}

View File

@ -0,0 +1,36 @@
import {Feature} from '../../../../utilities/feature';
import {TemplateOptions} from '../../../../types';
export default function ({features, ifFeature}: TemplateOptions) {
const prettier = features.includes(Feature.Prettier);
const tailwindRules = ifFeature(
Feature.Tailwind,
`'at-rule-no-unknown': [
true,
{
ignoreAtRules: ['tailwind'],
},
],`
);
const extendedConfigs = [
`'@shopify/stylelint-plugin'`,
prettier && `'@shopify/stylelint-plugin/prettier'`,
];
return `
module.exports = {
extends: [${extendedConfigs.join(', ')}],
rules: {
${tailwindRules}
'selector-type-no-unknown': [
true,
{
ignoreTypes: ['model-viewer'],
},
],
},
};
`;
}

View File

@ -0,0 +1,40 @@
export default function () {
return `
import {CacheableResponsePlugin} from 'workbox-cacheable-response';
import {CacheFirst} from 'workbox-strategies';
import {ExpirationPlugin} from 'workbox-expiration';
import {registerRoute} from 'workbox-routing';
import {precacheAndRoute} from 'workbox-precaching';
// self.__WB_MANIFEST is default injection point - DO NOT REMOVE
precacheAndRoute(self.__WB_MANIFEST);
// By default, vite-plugin-pwa will generate a manifest file that
// will cache all resulting production build files.
// However, it will not have caching strategy for any files
// outside of that list.
// We can define additional routes or setup push messaging here
// Cache for images from the same origin and cdn.shopify.com
registerRoute(
({sameOrigin, url, request}) => {
return (
request.destination === 'image' &&
(sameOrigin || url.hostname === 'cdn.shopify.com')
);
},
new CacheFirst({
cacheName: 'images',
plugins: [
new CacheableResponsePlugin({
statuses: [0, 200],
}),
new ExpirationPlugin({
maxEntries: 60,
maxAgeSeconds: 30 * 24 * 60 * 60, // 30 Days
}),
],
}),
);
`;
}

View File

@ -0,0 +1,13 @@
export default function () {
return `
module.exports = {
purge: ['./index.html', './src/**/*.{js,jsx,ts,tsx}'],
mode: 'jit',
darkMode: false, // or 'media' or 'class'
variants: {
extend: {},
},
plugins: [require('@tailwindcss/typography')],
};
`;
}

View File

@ -0,0 +1,45 @@
import {Feature} from '../../../../utilities/feature';
import {TemplateOptions} from '../../../../types';
export default function ({features, ifFeature}: TemplateOptions) {
const pwaImport = ifFeature(
Feature.Pwa,
`const VitePWA = require('vite-plugin-pwa').VitePWA;`
);
const pwaPluginMarkup = ifFeature(
Feature.Pwa,
`
, VitePWA({
base: '/',
registerType: 'auto',
strategies: 'injectManifest',
manifest: {
name: 'Hydrogen PWA',
short_name: 'hydrogen-pwa',
theme_color: '#2ec6b9',
icons: [
{
src: '/snowdevil-512w.png',
sizes: '512x512',
type: 'image/png',
purpose: 'any maskable',
},
],
},
}),
`
);
return `
const hydrogen = require('@shopify/hydrogen/plugin').default;
${pwaImport}
/**
* @type {import('vite').UserConfig}
*/
module.exports = {
plugins: [hydrogen({})${pwaPluginMarkup}],
};
`;
}

View File

@ -0,0 +1,18 @@
import {withCli} from '../../../../testing';
describe.skip('create', () => {
it('scaffolds a basic app with default snow-devil name', async () => {
await withCli(async ({run}) => {
const app = await run('create');
await app.withServer(async ({page}) => {
await page.view('/');
await page.screenshot('home.png');
const heading = await page.textContent('h1');
expect(heading).toContain('Welcome to Hydrogen');
});
});
}, 30000);
});

View File

@ -0,0 +1,69 @@
import {pascalCase} from 'change-case';
import {Env} from '../../../types';
export enum ComponentType {
Client = 'React client component',
Shared = 'React shared component',
Server = 'React server component',
}
/**
* Scaffold a new React component.
*/
export async function component(env: Env) {
const {ui, fs, workspace} = env;
const name = await ui.ask('What do you want to name this component?', {
validate: validateComponentName,
default: 'ProductCard',
name: 'name',
});
const extension = (await workspace.isTypeScript) ? 'tsx' : 'jsx';
const type = await ui.ask<ComponentType>('What type of component is this?', {
choices: [ComponentType.Client, ComponentType.Server, ComponentType.Shared],
name: 'type',
});
const path = fs.join(
workspace.root(),
workspace.componentsDirectory,
componentName(name, type, extension)
);
fs.write(
path,
(await import('./templates/component-jsx')).default({
name,
path: fs.join(
workspace.componentsDirectory,
componentName(name, type, extension)
),
})
);
}
function validateComponentName(name: string) {
const suggested = pascalCase(name);
if (name === suggested) {
return true;
}
return `Invalid component name. Try ${suggested} instead.`;
}
function getReactComponentTypeSuffix(component: ComponentType) {
switch (component) {
case ComponentType.Client:
return 'client';
case ComponentType.Server:
return 'server';
default:
return null;
}
}
function componentName(name: string, type: ComponentType, extension: string) {
return [name, getReactComponentTypeSuffix(type), extension]
.filter((fp) => fp)
.join('.');
}

View File

@ -0,0 +1 @@
export {component as default} from './component';

View File

@ -0,0 +1,9 @@
export default function ({name, path}: {name: string; path: string}) {
return `
export function ${name}() {
return (
<div>${name} component at \`${path}\`</div>
);
}
`;
}

View File

@ -0,0 +1,62 @@
import {withCli} from '../../../../testing';
describe('component', () => {
it('scaffolds a basic JSX component with a name', async () => {
await withCli(async ({run, fs}) => {
await run('create component', {
name: 'Button',
});
expect(await fs.read('src/components/Button.client.jsx')).toBe(
`export function Button() {
return (
<div>Button component at \`src/components/Button.client.jsx\`</div>
);
}
`
);
});
});
it('scaffolds a basic TSX component with a name when a tsconfig exists', async () => {
await withCli(async ({run, fs}) => {
await fs.write('tsconfig.json', JSON.stringify({}, null, 2));
await run('create component', {
name: 'ProductCard',
});
expect(await fs.read('src/components/ProductCard.client.tsx')).toBe(
`export function ProductCard() {
return (
<div>ProductCard component at \`src/components/ProductCard.client.tsx\`</div>
);
}
`
);
});
});
it('uses the components directory from the hydrogen config', async () => {
const componentsDirectory = 'foo/bar/baz';
await withCli(async ({run, fs}) => {
await fs.write(
'.hydrogenrc.json',
JSON.stringify({componentsDirectory}, null, 2)
);
await run('create component', {
name: 'ProductCard',
});
expect(
await fs.read(`${componentsDirectory}/ProductCard.client.jsx`)
).toBe(
`export function ProductCard() {
return (
<div>ProductCard component at \`${componentsDirectory}/ProductCard.client.jsx\`</div>
);
}
`
);
});
});
});

View File

@ -0,0 +1 @@
export {init as default} from './init';

View File

@ -0,0 +1,117 @@
import {join, relative} from 'path';
import {readdirSync, copy} from 'fs-extra';
import {yellow, underline} from 'chalk';
import {Env} from '../../types';
import app from '../create/app';
export enum Template {
None = 'None',
Default = 'Default Hydrogen starter',
}
/**
* Create a new `@shopify/hydrogen` app.
*/
export async function init(env: Env) {
const {ui, fs, workspace} = env;
const name = await ui.ask('What do you want to name this app?', {
validate: validateProjectName,
default: 'snow-devil',
name: 'name',
});
workspace.name(name);
if (await fs.exists(workspace.root())) {
const overwrite = await ui.ask(
`${workspace.root()} is not an empty directory. Do you want to remove the existing files and continue?`,
{boolean: true, name: 'overwrite', default: false}
);
if (overwrite) {
await fs.empty(workspace.root());
}
}
const template = await ui.ask<Template>(
'Would you like to start from a template?',
{
choices: [Template.Default, Template.None],
name: 'template',
}
);
if (template === Template.None) {
const context = {name};
await app({ui, fs, workspace, context});
}
if (template === Template.Default) {
const templateDir = join(__dirname, 'templates', 'template-hydrogen');
const files = readdirSync(templateDir);
for await (const file of files) {
const srcPath = fs.join(templateDir, file);
const destPath = fs.join(workspace.root(), file);
await copy(srcPath, destPath);
}
if (process.env.LOCAL) {
workspace.install('@shopify/hydrogen', {
version: 'file:../../Shopify/hydrogen/packages/hydrogen',
});
}
}
console.log();
workspace.commit().then(() => finish({ui, workspace}));
}
async function finish({ui, workspace}: Pick<Env, 'ui' | 'workspace'>) {
console.log();
ui.say(
`${underline('Success!')} Created app in ${yellow(
`/${relative(process.cwd(), workspace.root())}`
)}.`
);
ui.say(`Run the following commands to get started:`);
console.log();
if (workspace.root() !== process.cwd()) {
ui.say([
[
` • cd ${relative(process.cwd(), workspace.root())}`,
'change into the project directory',
],
]);
}
const usesYarn = workspace.packageManager === 'yarn' || process.env.LOCAL;
ui.say([
[
`${usesYarn ? `yarn` : `npm install --legacy-peer-deps`}`,
' install the dependencies',
],
[`${usesYarn ? `yarn` : `npm run`} dev`, ' start the dev server'],
]);
console.log();
console.log();
console.log();
}
function validateProjectName(name: string) {
const packageNameRegExp =
/^(?:@[a-z0-9-*~][a-z0-9-*._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/;
if (packageNameRegExp.test(name)) {
return true;
}
const suggested = name
.trim()
.toLowerCase()
.replace(/\s+/g, '-')
.replace(/^[._]/, '')
.replace(/[^a-z0-9-~]+/g, '-');
return `Invalid package.json name. Try ${suggested} instead.`;
}

View File

@ -0,0 +1,9 @@
<!-- This file is generated from the source code. Edit the files in /packages/cli/src/commands/version and run 'yarn generate-docs' at the root of this repo. -->
Print the installed version of the `@shopify/hydrogen-cli`.
## Example code
```bash
h2 version
```

View File

@ -0,0 +1 @@
h2 version

View File

@ -0,0 +1 @@
export {version as default} from './version';

View File

@ -0,0 +1,9 @@
import {Env} from '../../types';
/**
* Print the installed version of the `@shopify/hydrogen-cli`.
*/
export async function version({ui}: Pick<Env, 'ui'>) {
const packageJson = require('../../../package.json');
ui.say(`v${packageJson.version}`);
}

View File

@ -0,0 +1,22 @@
import {cosmiconfig} from 'cosmiconfig';
import debug from 'debug';
const logger = debug('hydrogen');
export async function loadConfig() {
logger('Loading config...');
const configExplorer = cosmiconfig('hydrogen');
const config = await configExplorer.search();
if (!config) {
logger('No config found');
return null;
}
logger(`Config found at ${config.filepath}`);
logger(config.config);
return config.config;
}

73
packages/cli/src/fs/fs.ts Normal file
View File

@ -0,0 +1,73 @@
import {resolve, join, isAbsolute, dirname, relative} from 'path';
import {readFile, writeFile, mkdirp, pathExists, emptyDir} from 'fs-extra';
import {formatFile} from '../utilities';
export interface FileResult {
path: string;
overwritten: boolean;
}
export class Fs {
root: string;
files = new Map<string, string>();
readCache = new Map<string, string>();
constructor(root: string) {
this.root = resolve(root);
}
join(...segments: string[]) {
return join(...segments);
}
async read(path: string) {
const fullPath = this.fullPath(path);
if (this.files.has(fullPath)) {
return this.files.get(fullPath)!;
}
if (this.readCache.has(fullPath)) {
return this.readCache.get(fullPath)!;
}
const contents = await readFile(fullPath, 'utf8');
this.readCache.set(fullPath, contents);
return contents;
}
async exists(path: string) {
return await pathExists(this.fullPath(path));
}
async *commit(): AsyncIterableIterator<FileResult> {
for (const [path, contents] of this.files.entries()) {
const exists = await this.exists(path);
if (!exists) {
await mkdirp(dirname(path));
}
await writeFile(path, formatFile(contents));
yield {path: this.relativePath(path), overwritten: exists};
}
}
write(path: string, contents: string) {
this.files.set(this.fullPath(path), contents);
}
fullPath(path: string) {
return isAbsolute(path) ? path : this.join(this.root, path);
}
relativePath(path: string) {
return isAbsolute(path) ? relative(this.root, path) : path;
}
async empty(dir: string) {
await emptyDir(dir);
}
}

View File

@ -0,0 +1 @@
export {Fs, FileResult} from './fs';

43
packages/cli/src/index.ts Normal file
View File

@ -0,0 +1,43 @@
import debug from 'debug';
import {parseCliArguments, InputError, UnknownError} from './utilities';
import {Cli} from './ui';
import {Workspace} from './workspace';
import {Fs} from './fs';
import {loadConfig} from './config';
const logger = debug('hydrogen');
(async () => {
const rawInputs = process.argv.slice(2);
const {command, root} = parseCliArguments(rawInputs);
const ui = new Cli();
const config = await loadConfig();
const workspace = new Workspace({root, ...(config || {})});
const fs = new Fs(root);
if (!command) {
ui.say(`Missing command input`, {error: true});
throw new InputError();
}
try {
const module = await import(`./commands/${command}`);
await module.default({ui, fs, workspace});
} catch (error) {
logger(error);
throw new UnknownError();
}
for await (const file of fs.commit()) {
ui.printFile(file);
}
await workspace.commit();
})().catch((error) => {
logger(error);
process.exitCode = 1;
});

View File

@ -0,0 +1 @@
export * from './testing';

View File

@ -0,0 +1,369 @@
import {resolve, dirname, join} from 'path';
import {paramCase} from 'change-case';
import {promisify} from 'util';
import {spawn, exec} from 'child_process';
import {
readFile,
mkdirp,
writeFile,
pathExists,
emptyDir,
remove,
} from 'fs-extra';
import playwright from 'playwright-chromium';
import {createServer as createNodeServer} from 'http';
import {
createServer as createViteServer,
build,
ViteDevServer,
UserConfig,
} from 'vite';
import sirv from 'sirv';
import getPort from 'get-port';
const INPUT_TIMEOUT = 500;
const execPromise = promisify(exec);
type Command = 'create' | 'create component';
type Input = Record<string, string | boolean | null>;
interface App {
withServer: (
runner: (context: ServerContext) => Promise<void>
) => Promise<void>;
}
interface Context {
fs: Sandbox;
run(command: Command, input?: Input): Promise<App>;
}
interface Page {
view(path: string): Promise<void>;
screenshot(path: string): Promise<void>;
textContent: playwright.Page['textContent'];
click: playwright.Page['click'];
}
interface ServerContext {
page: Page;
}
interface Server {
start(directory: string): Promise<string>;
stop(): Promise<void>;
}
interface Options {
debug?: true;
}
interface ServerOptions extends Options {
dev?: true;
}
export enum KeyInput {
Down = '\x1B\x5B\x42',
Up = '\x1B\x5B\x41',
Enter = '\x0D',
Space = '\x20',
No = 'n',
Yes = 'y',
}
const hydrogenCli = resolve(__dirname, '../../', 'bin', 'hydrogen');
const fixtureRoot = resolve(__dirname, '../fixtures');
export async function withCli(
runner: (context: Context) => void,
options?: Options
) {
const name = paramCase(expect.getState().currentTestName);
const directory = join(fixtureRoot, name);
const fs = await createSandbox(directory);
try {
await runner({
fs,
run: async (command: Command, input: Input) => {
const appName =
input && typeof input.name === 'string' ? input.name : 'snow-devil';
const result = await runCliCommand(directory, command, input);
if (options?.debug) {
console.log(result);
}
return {
withServer: async function withServer(
runner: (context: ServerContext) => Promise<void>,
serverOptions?: ServerOptions
) {
const launchOptions = serverOptions?.debug
? {
headless: false,
}
: {};
let count = 1;
const server = await createServer(serverOptions);
const browser = await playwright.chromium.launch(launchOptions);
const context = await browser.newContext();
const playrightPage = await context.newPage();
try {
const appDirectory = join(directory, appName);
await runInstall(appDirectory);
const url = await server?.start(appDirectory);
const page = {
view: async (path: string) => {
const finalUrl = new URL(path, url);
await playrightPage.goto(finalUrl.toString());
},
screenshot: async (suffix?: string) => {
await playrightPage.screenshot({
path: `artifacts/${count++}-${name}${
suffix ? `-${suffix}` : ''
}.png`,
});
},
textContent: (el: string) => playrightPage.textContent(el),
click: (el: string) => playrightPage.click(el),
};
await runner({page});
} finally {
await browser.close();
await context.close();
await playrightPage.close();
await server.stop();
}
},
};
},
});
} catch (error) {
console.log(error);
if (!options?.debug) {
await fs.cleanup();
}
} finally {
if (!options?.debug) {
await fs.cleanup();
}
}
}
async function createSandbox(directory: string) {
await mkdirp(directory);
await writeFile(join(directory, '.gitignore'), '*');
return new Sandbox(directory);
}
async function runCliCommand(
directory: string,
command: Command,
input: Input
): Promise<Result> {
const result = new Result();
const userInput = inputFrom(input, command);
const childProcess = await spawn(hydrogenCli, command.split(' '), {
cwd: directory,
env: {...process.env},
});
return new Promise((resolve, reject) => {
incrementallyPassInputs(userInput);
function onError(err: any) {
childProcess.stdin.end();
result.error = err.data;
reject(result);
}
childProcess.stdout.on('data', (data) => {
result.stdout.push(data.toString());
});
childProcess.on('error', onError);
childProcess.on('close', () => {
result.success = true;
resolve(result);
});
});
function incrementallyPassInputs(inputs: string[]) {
if (inputs.length === 0) {
childProcess.stdin.end();
return;
}
setTimeout(() => {
childProcess.stdin.write(inputs[0]);
incrementallyPassInputs(inputs.slice(1));
}, INPUT_TIMEOUT);
}
}
async function runInstall(directory: string) {
return execPromise('yarn', {
cwd: directory,
env: {...process.env},
});
}
// @ts-ignore
async function createBuild(directory: string) {
const clientOptions: UserConfig = {
root: directory,
build: {
outDir: `dist/client`,
manifest: true,
},
};
const serverOptions: UserConfig = {
root: directory,
build: {
outDir: `dist/server`,
ssr: 'src/entry-server.jsx',
},
};
await Promise.all([build(clientOptions), build(serverOptions)]);
}
async function createServer(options?: ServerOptions): Promise<Server> {
let server: ViteDevServer | null = null;
return {
start: async (directory) => {
server = await createViteServer({
root: directory,
configFile: resolve(directory, 'vite.config.js'),
});
await server.listen();
const base = server.config.base === '/' ? '' : server.config.base;
const url = `http://localhost:${server.config.server.port}${base}`;
return url;
},
async stop() {
if (!server) {
console.log('Attempted to stop the server, but it does not exist.');
return;
}
await server.close();
},
};
}
// @ts-ignore
async function createStaticServer(directory: string): Promise<Server> {
const serve = sirv(resolve(directory, 'dist'));
const httpServer = createNodeServer(serve);
const port = await getPort();
return {
start: () => {
return new Promise((resolve, reject) => {
httpServer.on('error', reject);
httpServer.listen(port, () => {
httpServer.removeListener('error', reject);
resolve(`http://localhost:${port}`);
});
});
},
stop: async () => {
httpServer.close();
},
};
}
class Sandbox {
constructor(public readonly root: string) {}
resolvePath(...parts: string[]) {
return resolve(this.root, ...parts);
}
async write(file: string, contents: string) {
const filePath = this.resolvePath(file);
await mkdirp(dirname(filePath));
await writeFile(filePath, contents, {encoding: 'utf8'});
}
async read(file: string) {
const filePath = this.resolvePath(file);
if (!(await pathExists(filePath))) {
throw new Error(`Tried to read ${filePath}, but it could not be found.`);
}
return readFile(filePath, 'utf8');
}
async exists(file: string) {
const filePath = this.resolvePath(file);
return pathExists(filePath);
}
async cleanup() {
await emptyDir(this.root);
await remove(this.root);
}
}
class Result {
success: boolean = false;
error: Error | null = null;
stderr: string[] = [];
stdout: string[] = [];
constructor() {}
get inspect() {
return {
success: this.success,
error: this.error,
stderr: this.stderr.join(''),
stdout: this.stdout.join(''),
};
}
}
function inputFrom(input: Input, command: Command): string[] {
const length = 10;
const result: string[] = Array.from({length}, () => KeyInput.Enter);
if (input == null) {
return result;
}
Object.values(input).forEach((value, index) => {
const keyStrokes = [];
if (typeof value === 'string') {
keyStrokes.push(value);
}
if (value === false) {
keyStrokes.push(KeyInput.No);
}
if (value === true) {
keyStrokes.push(KeyInput.Yes);
}
result[index] = [...keyStrokes, KeyInput.Enter].join('');
});
return result;
}

20
packages/cli/src/types.ts Normal file
View File

@ -0,0 +1,20 @@
import {Workspace} from './workspace';
import {Ui} from './ui';
import {Fs} from './fs';
import {Feature} from './utilities/feature';
export interface Env<Context = {}> {
ui: Ui;
workspace: Workspace;
fs: Fs;
context?: Context;
}
export interface TemplateOptions {
ifFeature(feature: Feature, output: string): string;
features: Feature[];
storeDomain: string;
storefrontToken: string;
name: string;
}

View File

@ -0,0 +1 @@
export {Ui, Cli} from './ui';

123
packages/cli/src/ui/ui.ts Normal file
View File

@ -0,0 +1,123 @@
import inquirer from 'inquirer';
import chalk from 'chalk';
import {FileResult} from '../fs';
interface BaseOptions {
validate?: (input: string) => boolean | string;
default?: string | number | boolean;
name?: string;
}
interface ChoiceQuestionOptions<T = {value: string}> extends BaseOptions {
choices: T[];
}
interface BooleanQuestionOptions extends BaseOptions {
boolean: true;
}
interface CheckboxQuestionOptions<T = {value: any}> extends BaseOptions {
choices: T[];
multiple: true;
}
type CombinedOptions<T> = Partial<BooleanQuestionOptions> &
Partial<ChoiceQuestionOptions<T>> &
Partial<CheckboxQuestionOptions<T>>;
interface ConstructorOptions {
debug?: boolean;
}
interface SayOptions {
error?: boolean;
}
export interface Ui {
ask<T>(message: string, options: CheckboxQuestionOptions<T>): Promise<T[]>;
ask<T = string>(
message: string,
options: ChoiceQuestionOptions<T>
): Promise<T>;
ask(message: string, options: BooleanQuestionOptions): Promise<boolean>;
ask<T = string>(message: string, options?: BaseOptions): Promise<T>;
say(message: string | [string, string][], options?: SayOptions): void;
}
export class Cli implements Ui {
debug: boolean;
readonly indent = ` `;
readonly prefix = chalk.bold.yellow.underline`h2`;
constructor({debug}: ConstructorOptions | undefined = {}) {
this.debug = debug || false;
}
async ask<T = string>(
message: string,
options: CheckboxQuestionOptions<T>
): Promise<T[]>;
async ask<T = string>(
message: string,
options: ChoiceQuestionOptions<T>
): Promise<T>;
async ask(message: string, options: BooleanQuestionOptions): Promise<boolean>;
async ask<T = string>(message: string, options?: BaseOptions): Promise<T>;
async ask<T>(message: string, options: CombinedOptions<T> = {}) {
const name = options.name ?? 'question';
const normalizedQuestion: any = {
message,
name,
validate: options.validate,
default: options.default,
};
if (options?.choices) {
normalizedQuestion.choices = options.choices;
normalizedQuestion.type = options.multiple ? 'checkbox' : 'list';
}
if (options?.boolean) {
normalizedQuestion.type = 'confirm';
}
const result = await inquirer.prompt<{
[key: string]: any;
}>(normalizedQuestion);
return result[name];
}
say(message: string | [string, string?][], options: SayOptions = {}) {
if (Array.isArray(message)) {
message.forEach((msg) => {
const [label, ...values] = msg;
const spacer = [this.indent, this.indent].join('');
console.log(chalk.cyan([spacer, label, ...values].join(spacer)));
});
return;
}
const styledMessage = options.error ? chalk.redBright`${message}` : message;
const type = options.error ? chalk.black.bgRedBright` error ` : '';
console.log(
[
this.indent,
this.prefix,
this.indent,
type,
this.indent,
styledMessage,
].join('')
);
}
printFile({path, overwritten}: FileResult) {
const overwrote = overwritten
? chalk.redBright``
: chalk.greenBright``;
this.say([overwrote, path].join(''));
}
}

View File

@ -0,0 +1,3 @@
export class NotImplementedError extends Error {}
export class InputError extends Error {}
export class UnknownError extends Error {}

View File

@ -0,0 +1,19 @@
export enum Feature {
Pwa = 'Progressive Web App (PWA)',
Eslint = 'JavaScript/TypeScript linting (ESlint)',
Stylelint = 'CSS linting (Stylelint)',
Tailwind = 'Tailwind CSS',
GraphQL = 'Graphql',
Prettier = 'Prettier',
CustomServer = 'Custom server (express)',
}
export interface FeatureOption {
name: string;
value: Feature;
}
export function ifFeature(allFeatures: Feature[]) {
return (testFeature: Feature, output: string) =>
allFeatures.includes(testFeature) ? output : '';
}

View File

@ -0,0 +1,42 @@
import {join, resolve} from 'path';
import minimist from 'minimist';
export * from './error';
export * from './feature';
export {merge} from './merge';
const DEFAULT_SUBCOMMANDS = {
create: 'app',
version: '',
};
export function parseCliArguments(rawInputs?: string[]) {
const inputs = minimist(rawInputs || []);
const command = inputs._[0];
const root =
command === 'create' && inputs._[2]
? join(process.cwd(), inputs._[2])
: process.cwd();
const subcommand =
inputs._[1] || DEFAULT_SUBCOMMANDS[command as 'create' | 'version'];
const {debug} = inputs;
return {
root: resolve(root),
mode: debug ? 'debug' : 'default',
command: [command, subcommand].join('/'),
};
}
export function formatFile(file: string) {
const match = file.match(/^[^\S\n]*(?=\S)/gm);
const indent = match && Math.min(...match.map((el) => el.length));
if (indent) {
const regexp = new RegExp(`^.{${indent}}`, 'gm');
return file.replace(regexp, '').trim() + '\n';
}
return file.trim() + '\n';
}

View File

@ -0,0 +1,55 @@
export const deepCopy = <T>(obj: T): T => {
if (typeof obj === 'object') {
const copyArray = (arr: any[]): any => arr.map((val) => deepCopy(val));
if (obj instanceof Array) return copyArray(obj);
const newObj = {} as T;
for (const key in obj) {
const val = obj[key];
if (val instanceof Array) {
newObj[key] = copyArray(val);
} else if (typeof val === 'object') {
newObj[key] = deepCopy(val);
} else {
newObj[key] = val;
}
}
return newObj;
}
return obj;
};
/**
* Does a shallow merge of object `from` to object `to`.
* Traverses each of the keys in Object `from`, allowing for:
*
* * If the value of a key is an array, it will be concatenated
* onto `to`.
* * If the value of a key is an object it will extend `to` the
* key/values of that object.
*/
export function merge<
F extends object,
T extends object,
R extends F & T = F & T
>(from: F, to: T): R {
const mergedInto = deepCopy(to) as R;
for (const key in from) {
const curKey = key as unknown as keyof R;
const hasKey = mergedInto.hasOwnProperty(key);
const fromVal = from[key];
if (Array.isArray(fromVal)) {
if (!hasKey || !(mergedInto[curKey] instanceof Array))
mergedInto[curKey] = [] as unknown as R[typeof curKey];
(mergedInto[curKey] as unknown as Array<any>).push(...fromVal);
} else if (typeof fromVal === 'object') {
if (!hasKey || !(typeof mergedInto[curKey] === 'object'))
mergedInto[curKey] = {} as unknown as R[typeof curKey];
Object.assign(mergedInto[curKey], fromVal);
} else {
mergedInto[curKey] = fromVal as unknown as R[typeof curKey];
}
}
return mergedInto;
}

View File

@ -0,0 +1 @@
export {Workspace} from './workspace';

View File

@ -0,0 +1,169 @@
import {join} from 'path';
import {writeFile, readFile, pathExists} from 'fs-extra';
import {promisify} from 'util';
import childProcess from 'child_process';
import {formatFile, merge} from '../utilities';
const exec = promisify(childProcess.exec);
interface DependencyOptions {
dev?: boolean;
version?: string;
}
interface Dependencies {
dependencies: {
[key: string]: string;
};
devDependencies: {
[key: string]: string;
};
}
interface Config {
typescript?: boolean;
componentsDirectory: string;
}
const DEFAULT_CONFIG = {
componentsDirectory: './src/components',
};
export class Workspace {
dependencies = new Map<string, DependencyOptions>();
config: Config;
_name?: string;
_root: string;
constructor({root, ...config}: Partial<Config> & {root: string}) {
this._root = root;
this.config = {
...DEFAULT_CONFIG,
...config,
};
}
async commit() {
await this.gitInit();
const additionalScripts: Record<string, string> = {};
const additionalConfigs: Record<string, string> = {};
const dependencies = Array.from(
this.dependencies.entries()
).reduce<Dependencies>(
(acc, [name, {dev, version}]) => {
if (dev) {
acc.devDependencies[name] = version || 'latest';
} else {
acc.dependencies[name] = version || 'latest';
}
return acc;
},
{dependencies: {}, devDependencies: {}}
);
const baseScripts = {
dev: 'vite',
build: 'yarn build:client && yarn build:server',
'build:client': 'vite build --outDir dist/client --manifest',
'build:server':
'vite build --outDir dist/server --ssr src/entry-server.jsx',
};
const linters = [
this.dependencies.has('eslint') &&
'eslint --no-error-on-unmatched-pattern --ext .js,.ts,.jsx,.tsx src',
this.dependencies.has('stylelint') &&
'stylelint ./src/**/*.{css,sass,scss}',
].filter((script) => script);
if (linters.length) {
additionalScripts['lint'] = linters.join(' && ');
}
if (this.dependencies.has('@shopify/prettier-config')) {
additionalConfigs['prettier'] = '@shopify/prettier-config';
}
const existingPackageJson = (await pathExists(
join(this.root(), 'package.json')
))
? JSON.parse(
await readFile(join(this.root(), 'package.json'), {encoding: 'utf8'})
)
: {};
const packageJson = JSON.stringify(
merge(
{
name: this.name(),
scripts: {
...baseScripts,
...additionalScripts,
},
...dependencies,
...additionalConfigs,
},
existingPackageJson
),
null,
2
);
await writeFile(join(this.root(), 'package.json'), packageJson);
}
get packageManager() {
return /yarn/.test(process.env.npm_execpath || '') ? 'yarn' : 'npm';
}
get isTypeScript() {
return this.config.typescript || this.hasFile('tsconfig.json');
}
get componentsDirectory() {
return this.config.componentsDirectory;
}
hasFile(path: string) {
return pathExists(path);
}
name(name?: string) {
if (name) {
this._name = name;
this._root = join(this._root, this._name);
}
return this._name || ``;
}
root() {
return this._root;
}
install(dependency: string, options: DependencyOptions = {}) {
if (this.dependencies.has(dependency)) {
return;
}
this.dependencies.set(dependency, options);
}
async gitInit() {
await exec(`git init`, {cwd: this._root});
// TODO: Change the branch name to main and commit files
await writeFile(
join(this._root, '.gitignore'),
formatFile(`
node_modules
.DS_Store
dist
dist-ssr
*.local
`)
);
}
}

View File

@ -0,0 +1,19 @@
{
"compilerOptions": {
"baseUrl": "src",
"rootDir": "src",
"declaration": true,
"jsx": "react",
"target": "ES2018",
"outDir": "./dist",
"module": "commonjs",
"moduleResolution": "node",
"lib": ["ESNext", "DOM"],
"types": ["jest", "node"],
"strict": true,
"noUnusedLocals": true,
"esModuleInterop": true
},
"include": ["src/**/*.ts"],
"references": []
}

View File

@ -0,0 +1,89 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/)
and adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html).
<!-- ## Unreleased -->
## 0.5.8 - 2021-11-04
- No updates. Transitive dependency bump.
## 0.5.7 - 2021-11-02
- No updates. Transitive dependency bump.
## 0.5.6 - 2021-11-02
- No updates. Transitive dependency bump.
## 0.5.5 - 2021-11-02
- No updates. Transitive dependency bump.
## 0.5.4 - 2021-11-02
- No updates. Transitive dependency bump.
## 0.5.0 - 2021-11-01
- No updates. Transitive dependency bump.
## 0.4.3 - 2021-10-29
- feat: add opinionated defaults for caching
## 0.4.2 - 2021-10-29
- fix: create-hydrogen-app creation script
- fix: pick first template (if only one exists) [#746](https://github.com/Shopify/hydrogen/pull/746)
## 0.4.1 - 2021-10-27
- No updates. Transitive dependency bump.
## 0.4.0 - 2021-10-27
- fix: `yarn create hydrogen-app` command
### Changed
- `CartProvider` is now a client-only concern. [#631](https://github.com/Shopify/hydrogen/pull/631)
## 0.3.0 - 2021-10-20
- No updates. Transitive dependency bump.
## 0.2.1 - 2021-10-12
- No updates. Transitive dependency bump.
## 0.2.0 - 2021-10-08
- No updates. Transitive dependency bump.
## 0.1.3 - 2021-10-04
- No updates. Transitive dependency bump.
## 0.1.2 - 2021-09-30
- Bump `vite` to ^2.6.0
## 0.1.1 - 2021-09-24
- No updates. Transitive dependency bump.
## 0.1.0 - 2021-09-23
- fix: support starter template homepage without at least three products
## 1.0.0-alpha.23 - 2021-09-22
- fix: reduce NPM package size by skipping `node_modules` etc
## 1.0.0-alpha.22 - 2021-09-22
- No updates. Transitive dependency bump.

View File

@ -0,0 +1,13 @@
# create-hydrogen-app
Create a new [Hydrogen](https://www.npmjs.com/package/@shopify/hydrogen) project:
```bash
npm init hydrogen-app@latest
```
Or with yarn:
```bash
yarn create hydrogen-app
```

View File

@ -0,0 +1,208 @@
#!/usr/bin/env node
// @ts-check
// Inspired by and borrowed from https://github.com/vitejs/vite/blob/main/packages/create-app/index.js
const fs = require('fs');
const path = require('path');
const argv = require('minimist')(process.argv.slice(2));
const {prompt} = require('enquirer');
const {yellow} = require('kolorist');
const {copy} = require('./scripts/utils.js');
const cwd = process.cwd();
const TEMPLATES = ['hydrogen'];
const renameFiles = {
_gitignore: '.gitignore',
};
async function init() {
let targetDir = argv._[0];
if (!targetDir) {
/**
* @type {{ projectName: string }}
*/
const {projectName} = await prompt({
type: 'input',
name: 'projectName',
message: `Project name:`,
initial: 'hydrogen-app',
});
targetDir = projectName;
}
const packageName = await getValidPackageName(targetDir);
const root = path.join(cwd, targetDir);
console.log(`\nScaffolding Hydrogen app in ${root}...`);
if (!fs.existsSync(root)) {
fs.mkdirSync(root, {recursive: true});
} else {
const existing = fs.readdirSync(root);
if (existing.length) {
/**
* @type {{ yes: boolean }}
*/
const {yes} = await prompt({
type: 'confirm',
name: 'yes',
initial: 'Y',
message:
`Target directory ${targetDir} is not empty.\n` +
`Remove existing files and continue?`,
});
if (yes) {
emptyDir(root);
} else {
return;
}
}
}
const firstAndOnlyTemplate =
TEMPLATES && TEMPLATES.length && TEMPLATES.length === 1 && TEMPLATES[0];
// Determine template
let template = argv.t || argv.template || firstAndOnlyTemplate;
let message = 'Select a template:';
let isValidTemplate = false;
// --template expects a value
if (typeof template === 'string') {
isValidTemplate = TEMPLATES.includes(template);
message = `${template} isn't a valid template. Please choose from below:`;
}
if (!template || !isValidTemplate) {
/**
* @type {{ t: string }}
*/
const {t} = await prompt({
type: 'select',
name: 't',
message,
choices: TEMPLATES,
});
template = t;
}
const templateDir = path.join(__dirname, `template-${template}`);
const write = (file, content) => {
const targetPath = renameFiles[file]
? path.join(root, renameFiles[file])
: path.join(root, file);
if (content) {
fs.writeFileSync(targetPath, content);
} else {
copy(path.join(templateDir, file), targetPath);
}
};
const files = fs.readdirSync(templateDir);
const skipFiles = ['package.json', 'node_modules', 'dist'];
for (const file of files.filter((f) => !skipFiles.includes(f))) {
write(file);
}
const pkg = require(path.join(templateDir, `package.json`));
pkg.name = packageName;
/**
* When the user is running a LOCAL version of hydrogen external from the
* monorepo, they expect to use the local version of the library instead
* of the registry version. We need to use a file reference here because
* yarn fails to link scoped packages.
**/
if (process.env.LOCAL) {
pkg.dependencies['@shopify/hydrogen'] =
'file:../../Shopify/hydrogen/packages/hydrogen';
}
/**
* Rewrite some scripts to strip out custom environment variables
* we add for use in the monorepo (LOCAL_DEV).
*/
for (const scriptName of ['dev']) {
const match = pkg.scripts[scriptName].match(/(vite( .*)?)$/);
if (match) {
pkg.scripts[scriptName] = match[0];
}
}
write('package.json', JSON.stringify(pkg, null, 2));
const pkgManager = /yarn/.test(process.env.npm_execpath) ? 'yarn' : 'npm';
console.log(`\nDone. Now:\n`);
console.log(
` Update ${yellow(
packageName + '/shopify.config.js'
)} with the values for your storefront. If you want to test your Hydrogen app using the demo store, you can skip this step.`
);
console.log(`\nand then run:\n`);
if (root !== cwd) {
console.log(` cd ${path.relative(cwd, root)}`);
}
/**
* The LOCAL option only works with Yarn due to issues with NPM
* and symlinking yarn monorepos.
*/
const usesYarn = pkgManager === 'yarn' || process.env.LOCAL;
console.log(` ${usesYarn ? `yarn` : `npm install --legacy-peer-deps`}`);
console.log(` ${usesYarn ? `yarn dev` : `npm run dev`}`);
console.log();
}
async function getValidPackageName(projectName) {
const packageNameRegExp =
/^(?:@[a-z0-9-*~][a-z0-9-*._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/;
if (packageNameRegExp.test(projectName)) {
return projectName;
} else {
const suggestedPackageName = projectName
.trim()
.toLowerCase()
.replace(/\s+/g, '-')
.replace(/^[._]/, '')
.replace(/[^a-z0-9-~]+/g, '-');
/**
* @type {{ inputPackageName: string }}
*/
const {inputPackageName} = await prompt({
type: 'input',
name: 'inputPackageName',
message: `Package name:`,
initial: suggestedPackageName,
validate: (input) =>
packageNameRegExp.test(input) ? true : 'Invalid package.json name',
});
return inputPackageName;
}
}
function emptyDir(dir) {
if (!fs.existsSync(dir)) {
return;
}
for (const file of fs.readdirSync(dir)) {
const abs = path.resolve(dir, file);
if (fs.lstatSync(abs).isDirectory()) {
emptyDir(abs);
fs.rmdirSync(abs);
} else {
fs.unlinkSync(abs);
}
}
}
init().catch((e) => {
console.error(e);
});

View File

@ -0,0 +1,27 @@
{
"name": "create-hydrogen-app",
"publishConfig": {
"access": "public",
"@shopify:registry": "https://registry.npmjs.org"
},
"version": "0.5.8",
"main": "index.js",
"license": "MIT",
"bin": {
"create-hydrogen": "index.js",
"create-hydrogen-app": "index.js"
},
"files": [
"index.js",
"scripts",
"template-*"
],
"scripts": {
"prepack": "node ./scripts/tmp-copy-template-from-dev.js"
},
"dependencies": {
"enquirer": "^2.3.6",
"kolorist": "^1.4.0",
"minimist": "^1.2.5"
}
}

View File

@ -0,0 +1,21 @@
// @ts-check
/**
* This is a temporary script meant to copy `packages/dev` to `./template-hydrogen`
* while we are actively developing H2 in `dev`. Eventually, the template will just
* live in this folder.
*/
const path = require('path');
const fs = require('fs');
const {copy} = require('./utils');
const devPath = path.resolve(__dirname, '..', '..', 'dev');
const templatePath = path.resolve(__dirname, '..', './template-hydrogen');
const skipFiles = ['node_modules', 'dist'];
// Remove the symlink and replace it with a folder
fs.unlinkSync(templatePath);
fs.mkdirSync(templatePath, {recursive: true});
copy(devPath, templatePath, skipFiles);

View File

@ -0,0 +1,29 @@
// @ts-check
const fs = require('fs');
const path = require('path');
function copyDir(srcDir, destDir, skipFiles = []) {
fs.mkdirSync(destDir, {recursive: true});
for (const file of fs.readdirSync(srcDir)) {
const srcFile = path.resolve(srcDir, file);
const destFile = path.resolve(destDir, file);
copy(srcFile, destFile, skipFiles);
}
}
function copy(src, dest, skipFiles = []) {
if (skipFiles.some((file) => src.includes(file))) return;
const stat = fs.statSync(src);
if (stat.isDirectory()) {
copyDir(src, dest, skipFiles);
} else {
fs.copyFileSync(src, dest);
}
}
module.exports = {
copy,
copyDir,
};

View File

@ -0,0 +1 @@
../dev

41
packages/dev/.eslintrc.js Normal file
View File

@ -0,0 +1,41 @@
module.exports = {
extends: [
'plugin:@shopify/typescript',
'plugin:@shopify/react',
'plugin:@shopify/node',
'plugin:@shopify/prettier',
],
rules: {
'@shopify/jsx-no-hardcoded-content': 'off',
'@shopify/jsx-no-complex-expressions': 'off',
/**
* React overrides
*/
'react/react-in-jsx-scope': 'off',
'react/jsx-filename-extension': ['error', {extensions: ['.tsx', '.jsx']}],
'react/prop-types': 'off',
/**
* Import overrides
*/
'import/no-unresolved': ['error', {ignore: ['@shopify/hydrogen']}],
/**
* ESlint overrides
*/
'no-use-before-define': 'off',
'no-warning-comments': 'off',
/**
* jsx-a11y overrides
*/
'jsx-a11y/click-events-have-key-events': 'off',
'jsx-a11y/no-noninteractive-element-interactions': 'off',
// These two rules result in a significant number of false positives so we
// need to keep them disabled.
'jsx-a11y/label-has-for': 'off',
'jsx-a11y/control-has-associated-label': 'off',
},
};

View File

@ -0,0 +1,17 @@
module.exports = {
extends: ['@shopify/stylelint-plugin', '@shopify/stylelint-plugin/prettier'],
rules: {
'at-rule-no-unknown': [
true,
{
ignoreAtRules: ['tailwind', 'layer'],
},
],
'selector-type-no-unknown': [
true,
{
ignoreTypes: ['model-viewer'],
},
],
},
};

3
packages/dev/.vscode/extensions.json vendored Normal file
View File

@ -0,0 +1,3 @@
{
"recommendations": ["graphql.vscode-graphql", "bradlc.vscode-tailwindcss"]
}

15
packages/dev/Dockerfile Normal file
View File

@ -0,0 +1,15 @@
FROM node:14 AS build-env
ADD . /app
WORKDIR /app
RUN npm install
RUN npm run build
FROM gcr.io/distroless/nodejs:14 AS run-env
ENV NODE_ENV production
COPY --from=build-env /app /app
EXPOSE 8080
WORKDIR /app
CMD ["server.js"]

31
packages/dev/README.md Normal file
View File

@ -0,0 +1,31 @@
# Hydrogen App
Hydrogen is a React framework and SDK that you can use to build fast and dynamic Shopify custom storefronts.
[Check out the docs](https://shopify.dev/custom-storefronts/hydrogen)
## Getting started
**Requirements:**
- Node v14+
- Yarn
```bash
yarn
yarn dev
```
Remember to update `shopify.config.js` with your shop's domain and Storefront API token!
## Building for production
```bash
yarn build
```
Then, you can run a local `server.js` using the production build with:
```bash
yarn serve
```

80
packages/dev/_gitignore Normal file
View File

@ -0,0 +1,80 @@
# THIS IS A STUB FOR NEW HYDROGEN APPS
# THIS WILL EVENTUALLY MOVE TO A /TEMPLATE-* FOLDER
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variables file
.env
.env.test
# Serverless directories
.serverless/
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
# Vite output
dist

20
packages/dev/index.html Normal file
View File

@ -0,0 +1,20 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/src/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Hydrogen App</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Fira+Code:wght@500&family=Roboto:wght@400;500;900&display=swap"
rel="stylesheet"
/>
<link rel="stylesheet" href="/src/index.css" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/entry-client.jsx"></script>
</body>
</html>

Some files were not shown because too many files have changed in this diff Show More