├── .github └── workflows │ └── build.yml ├── .gitignore ├── .local.dic ├── .remarkignore ├── .remarkrc.js ├── .vscode ├── launch.json ├── settings.json └── tasks.json ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── package.json ├── src ├── assets │ └── downloads │ │ ├── data │ │ └── api │ │ │ ├── rentals.json │ │ │ └── rentals │ │ │ ├── downtown-charm.json │ │ │ ├── grand-old-mansion.json │ │ │ └── urban-living.json │ │ ├── style.css │ │ └── teaching-tomster.png ├── bin │ └── generate.ts ├── lib │ ├── index.ts │ ├── plugins │ │ ├── do-not-edit │ │ │ ├── index.ts │ │ │ ├── options.ts │ │ │ └── walker.ts │ │ ├── retina-images │ │ │ ├── index.ts │ │ │ ├── options.ts │ │ │ └── walker.ts │ │ ├── run-code-blocks │ │ │ ├── cfg.ts │ │ │ ├── commands.ts │ │ │ ├── directives │ │ │ │ ├── checkpoint.ts │ │ │ │ ├── command.ts │ │ │ │ ├── file │ │ │ │ │ ├── copy.ts │ │ │ │ │ ├── create.ts │ │ │ │ │ ├── patch.ts │ │ │ │ │ └── show.ts │ │ │ │ ├── ignore.ts │ │ │ │ ├── pause.ts │ │ │ │ ├── screenshot.ts │ │ │ │ └── server │ │ │ │ │ ├── start.ts │ │ │ │ │ └── stop.ts │ │ │ ├── index.ts │ │ │ ├── lineno.ts │ │ │ ├── options.ts │ │ │ ├── parse-args.ts │ │ │ ├── parse-error.ts │ │ │ ├── server.ts │ │ │ ├── servers.ts │ │ │ └── walker.ts │ │ ├── todo-links │ │ │ ├── index.ts │ │ │ └── walker.ts │ │ └── zoey-says │ │ │ ├── index.ts │ │ │ ├── markdown-to-html.ts │ │ │ └── walker.ts │ └── walker.ts ├── markdown │ └── tutorial │ │ ├── acceptance-test.md │ │ ├── autocomplete-component.md │ │ ├── deploying.md │ │ ├── ember-cli.md │ │ ├── ember-data.md │ │ ├── hbs-helper.md │ │ ├── index.md │ │ ├── installing-addons.md │ │ ├── model-hook.md │ │ ├── part-1 │ │ ├── 01-orientation.md │ │ ├── 02-building-pages.md │ │ ├── 03-automated-testing.md │ │ ├── 04-component-basics.md │ │ ├── 05-more-about-components.md │ │ ├── 06-interactive-components.md │ │ ├── 07-reusable-components.md │ │ ├── 08-working-with-data.md │ │ ├── index.md │ │ └── recap.md │ │ ├── part-2 │ │ ├── 09-route-params.md │ │ ├── 10-service-injection.md │ │ ├── 11-ember-data.md │ │ ├── 12-provider-components.md │ │ ├── index.md │ │ └── recap.md │ │ ├── routes-and-templates.md │ │ ├── service.md │ │ ├── simple-component.md │ │ └── subroutes.md └── types │ ├── remark-frontmatter.d.ts │ └── remark-parse-yaml.d.ts ├── tsconfig.json ├── tslint.json └── yarn.lock /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | on: 3 | pull_request: {} 4 | push: 5 | branches: 6 | - main 7 | schedule: 8 | - cron: '0 0 * * *' 9 | workflow_dispatch: {} 10 | 11 | jobs: 12 | build: 13 | name: Build (${{ matrix.channel }}) 14 | runs-on: ubuntu-22.04 # Update when chromium bug mentioned https://github.com/puppeteer/puppeteer/issues/12818 is resolved 15 | env: 16 | CI: 'true' 17 | strategy: 18 | fail-fast: false 19 | matrix: 20 | channel: 21 | - release 22 | - beta 23 | steps: 24 | - name: Set up Git 25 | run: | 26 | git config --global user.name "Tomster" 27 | git config --global user.email "tomster@emberjs.com" 28 | - name: Checkout 29 | uses: actions/checkout@v4 30 | - name: Set up Node 31 | uses: actions/setup-node@v4 32 | with: 33 | node-version: '18' 34 | cache: 'yarn' 35 | - name: Install dependencies (apt-get) 36 | run: | 37 | sudo apt-get update -y 38 | sudo apt-get install -y tree 39 | - name: Install dependencies (yarn) 40 | run: | 41 | if [[ "$EMBER_RELEASE_CHANNEL" == "beta" ]]; then 42 | yarn upgrade ember-cli@beta 43 | else 44 | yarn upgrade ember-cli 45 | fi 46 | 47 | yarn install 48 | env: 49 | EMBER_RELEASE_CHANNEL: ${{ matrix.channel }} 50 | - name: Lint (markdown source) 51 | run: yarn lint:md:src 52 | - name: Lint (typescript) 53 | run: yarn lint:ts 54 | - name: Compile (typescript) 55 | run: yarn tsc 56 | - name: Generate 57 | run: yarn generate 58 | env: 59 | # This is needed for external PRs to build, since secrets are not 60 | # available there. Get your own Mapbox token for local development 61 | # at: https://account.mapbox.com/access-tokens 62 | MAPBOX_ACCESS_TOKEN: 'pk.eyJ1IjoiZW1iZXJqcyIsImEiOiJjazBydnZjb2wwYXA5M2Rwc3IydGF2eXM0In0.EQiBFsRi9Jm70XFPiXnoHg' 63 | - name: Lint (markdown output) 64 | run: yarn lint:md:dist 65 | - name: Prune artifacts 66 | if: always() 67 | working-directory: dist/code/super-rentals 68 | run: git clean -dfX 69 | - name: Upload artifacts (assets) 70 | uses: actions/upload-artifact@v4 71 | if: always() 72 | with: 73 | name: assets (${{ matrix.channel }}) 74 | path: dist/assets 75 | - name: Upload artifacts (markdown) 76 | uses: actions/upload-artifact@v4 77 | if: always() 78 | with: 79 | name: markdown (${{ matrix.channel }}) 80 | path: dist/markdown 81 | - name: Upload artifacts (code) 82 | uses: actions/upload-artifact@v4 83 | if: always() 84 | with: 85 | name: code (${{ matrix.channel }}) 86 | path: dist/code/super-rentals 87 | include-hidden-files: true 88 | - name: Notify Discord # This is a step rather than a job because of the matrix 89 | uses: sarisia/actions-status-discord@v1 90 | if: ${{ failure() && github.event_name == 'schedule' }} 91 | with: 92 | webhook: ${{ secrets.CORE_META_WEBHOOK }} 93 | status: "Failure" 94 | title: "Super Rentals Tutorial Build ${{ matrix.channel }}" 95 | color: 0xcc0000 96 | url: "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" 97 | username: GitHub Actions 98 | 99 | deploy-guides: 100 | # This job deploys the markdown output to the super-rentals-tutorial branch 101 | # of the ember-learn/guides-source repository, along with the necessary 102 | # assets (downloads and screenshots). 103 | # 104 | # For the markdown, we can simply copy over the output into the appropriate 105 | # folder in guides-source. We start by emptying out the folder to account 106 | # for any deleted or renamed chapters. 107 | # 108 | # For the downloads, it is also mostly a matter of copying them into the 109 | # right location. As with the markdown content, we empty out the folder 110 | # beforehand to account for deleted and renamed files. 111 | # 112 | # The JSON data used in the tutorial (for mocking an API server) is stored 113 | # as loose files in this repository, to make them easier to change and 114 | # review. However, we ultimately want to provide it for download as a zip 115 | # file, so we need to zip up the contents. 116 | # 117 | # However, even if the JSON content didn't actually change, the raw bytes 118 | # of the zip file may be different from the current version committed to 119 | # git, due to differences in compression settings, etc. To ensure we don't 120 | # churn the git blobs for no reason, we use the zipcmp utility to compare 121 | # the contents before adding the zip file. We also run it through advzip to 122 | # improve the compression ratio. 123 | # 124 | # Finally, screenshots also have the same problems as the zip file. Even 125 | # if the screenshot didn't visually change, the raw bytes are likely to be 126 | # different due to micro differences in pixel colors, etc. To account for 127 | # this, we use the perceptualdiff utility to compare them against what is 128 | # in git before adding them. We also run them through optipng and advpng to 129 | # improve the compression ratio. This can be quite slow, so only do it if 130 | # we detected visual changes in the screenshots. 131 | # 132 | # Once all the necessary parts are added, we simply commit and push to the 133 | # super-rentals-tutorial branch of the ember-learn/guides-source repository 134 | # if there are any changes in its content. 135 | # 136 | name: Deploy to ember-learn/guides-source (super-rentals-tutorial branch) 137 | needs: build 138 | runs-on: ubuntu-latest 139 | if: github.ref == 'refs/heads/main' 140 | steps: 141 | - name: Set up Git 142 | run: | 143 | git config --global user.name "Tomster" 144 | git config --global user.email "tomster@emberjs.com" 145 | - name: Set up SSH 146 | uses: webfactory/ssh-agent@v0.4.1 147 | with: 148 | ssh-private-key: ${{ secrets.GUIDES_SOURCE_DEPLOY_KEY }} 149 | - name: Install dependencies 150 | run: | 151 | sudo apt-get update -y 152 | sudo apt-get install -y zipcmp advancecomp optipng perceptualdiff 153 | - name: Download artifacts (assets) 154 | uses: actions/download-artifact@v4 155 | with: 156 | name: assets (release) 157 | path: assets 158 | - name: Download artifacts (markdown) 159 | uses: actions/download-artifact@v4 160 | with: 161 | name: markdown (release) 162 | path: markdown 163 | - name: Clone 164 | run: | 165 | set -euo pipefail 166 | IFS=$'\n\t' 167 | 168 | if ! git clone git@github.com:ember-learn/guides-source --depth 1 -b super-rentals-tutorial; then 169 | git clone git@github.com:ember-learn/guides-source --depth 1 -b master 170 | cd guides-source 171 | git checkout -b super-rentals-tutorial 172 | fi 173 | - name: Add markdown 174 | working-directory: guides-source 175 | run: | 176 | mkdir -p guides/release/tutorial/ 177 | rm -rf guides/release/tutorial/* 178 | cp -r ../markdown/* guides/release/ 179 | git add guides/release 180 | - name: Add downloads 181 | working-directory: guides-source 182 | run: | 183 | set -euo pipefail 184 | IFS=$'\n\t' 185 | 186 | mkdir -p public/downloads/ 187 | rm -rf public/downloads/* 188 | cp -r ../assets/downloads/* public/downloads/ 189 | 190 | pushd public/downloads/data/ 191 | zip -r current.zip . 192 | popd 193 | 194 | if ! (git checkout -- public/downloads/data.zip 2>&1 && zipcmp public/downloads/data.zip public/downloads/data/current.zip); then 195 | mv public/downloads/data/current.zip public/downloads/data.zip 196 | advzip -z -q -4 public/downloads/data.zip 197 | fi 198 | 199 | rm -rf public/downloads/data 200 | 201 | git add public/downloads 202 | - name: Add screenshots 203 | working-directory: guides-source 204 | run: | 205 | set -euo pipefail 206 | IFS=$'\n\t' 207 | 208 | mkdir -p tmp/prev 209 | git checkout-index -f -a --prefix=tmp/prev/ 210 | 211 | mkdir -p public/images/tutorial 212 | rm -rf public/images/tutorial/* 213 | 214 | function add_screenshot { 215 | local src="$1"; 216 | local prev=$(echo -n "$1" | sed "s|../assets/|tmp/prev/public/|"); 217 | local dest=$(echo -n "$1" | sed "s|../assets/|public/|"); 218 | local diff=$(echo -n "$1" | sed "s|../assets/|tmp/perceptualdiff/|" | sed "s|@2x.png|.ppm|"); 219 | 220 | mkdir -p "$(dirname "$dest")" 221 | mkdir -p "$(dirname "$diff")" 222 | 223 | if [[ -f "$prev" ]]; then 224 | if git diff --no-index -- "$prev" "$src" > /dev/null 2>&1; then 225 | echo "$dest (unchanged)" 226 | cp "$prev" "$dest" 227 | return 0 228 | elif perceptualdiff --output "$diff" --downsample 2 --colorfactor 0.5 --threshold 1000 "$prev" "$src" > /dev/null; then 229 | echo "$dest (unchanged)" 230 | cp "$prev" "$dest" 231 | return 0 232 | fi 233 | fi 234 | 235 | cp "$src" "$dest" 236 | optipng -q -o5 "$dest" 237 | advpng -z -q -4 "$dest" 238 | 239 | local before=$(wc -c < "$src") 240 | local after=$(wc -c < "$dest") 241 | local percentage=$(( $after * 100 / $before )) 242 | 243 | echo "$dest ($percentage%)" 244 | return 0 245 | } 246 | 247 | export -f add_screenshot 248 | find ../assets/images -type f -name "*.png" | xargs -n 1 -P 2 -I {} bash -c 'add_screenshot "$@"' _ {} 249 | 250 | git add public/images 251 | - name: Upload artifacts (perceptualdiff) 252 | uses: actions/upload-artifact@v4 253 | if: always() 254 | with: 255 | name: perceptualdiff (release) 256 | path: guides-source/tmp/perceptualdiff 257 | - name: Commit 258 | working-directory: guides-source 259 | run: | 260 | set -euo pipefail 261 | IFS=$'\n\t' 262 | 263 | if [[ "$GITHUB_EVENT_NAME" == "schedule" ]]; then 264 | COMMIT_TITLE="[CRON] $(date +"%A %b %d, %Y")" 265 | else 266 | COMMIT_TITLE="$( 267 | echo -n "$ORIGINAL_COMMIT_MESSAGE" | \ 268 | sed -E "s|^Merge pull request #(.+) from .+$|Merge $GITHUB_REPOSITORY#\1|" 269 | )" 270 | fi 271 | 272 | COMMIT_MESSAGE="$COMMIT_TITLE 273 | 274 | --- 275 | 276 | Commit: $GITHUB_REPOSITORY@$GITHUB_SHA 277 | Script: https://github.com/$GITHUB_REPOSITORY/blob/$GITHUB_SHA/.github/workflows/build.yml 278 | Logs: https://github.com/$GITHUB_REPOSITORY/commit/$GITHUB_SHA/checks" 279 | 280 | git commit --allow-empty -m "$COMMIT_MESSAGE" 281 | env: 282 | ORIGINAL_COMMIT_MESSAGE: ${{ github.event.commits[0].message }} 283 | - name: Push 284 | working-directory: guides-source 285 | run: | 286 | set -euo pipefail 287 | IFS=$'\n\t' 288 | 289 | if git diff --exit-code HEAD~; then 290 | echo "Nothing to push" 291 | else 292 | git push -u origin super-rentals-tutorial 293 | fi 294 | 295 | deploy-code-preserving-history: 296 | # This job deploys the built app to the super-rentals-tutorial-output 297 | # branch of the ember-learn/super-rentals repository. This branch preserves 298 | # the commit history of the tutorial flow (i.e. one git commit per chapter), 299 | # and is force-pushed on every build. We add an empty commit on top as sort 300 | # of a "cover page" to preserve the metadata (links to the commit, build 301 | # logs, etc). 302 | # 303 | name: Deploy to ember-learn/super-rentals (super-rentals-tutorial-output branch) 304 | needs: build 305 | runs-on: ubuntu-latest 306 | if: github.ref == 'refs/heads/main' 307 | steps: 308 | - name: Set up Git 309 | run: | 310 | git config --global user.name "Tomster" 311 | git config --global user.email "tomster@emberjs.com" 312 | - name: Set up SSH 313 | uses: webfactory/ssh-agent@v0.4.1 314 | with: 315 | ssh-private-key: ${{ secrets.SUPER_RENTALS_DEPLOY_KEY }} 316 | - name: Download artifacts 317 | uses: actions/download-artifact@v4 318 | with: 319 | name: code (release) 320 | path: . 321 | - name: Commit 322 | run: | 323 | set -euo pipefail 324 | IFS=$'\n\t' 325 | 326 | if [[ "$GITHUB_EVENT_NAME" == "schedule" ]]; then 327 | COMMIT_TITLE="[CRON] $(date +"%A %b %d, %Y")" 328 | else 329 | COMMIT_TITLE="$( 330 | echo -n "$ORIGINAL_COMMIT_MESSAGE" | \ 331 | sed -E "s|^Merge pull request #(.+) from .+$|Merge $GITHUB_REPOSITORY#\1|" 332 | )" 333 | fi 334 | 335 | COMMIT_MESSAGE="$COMMIT_TITLE 336 | 337 | --- 338 | 339 | Commit: $GITHUB_REPOSITORY@$GITHUB_SHA 340 | Script: https://github.com/$GITHUB_REPOSITORY/blob/$GITHUB_SHA/.github/workflows/build.yml 341 | Logs: https://github.com/$GITHUB_REPOSITORY/commit/$GITHUB_SHA/checks" 342 | 343 | git commit --allow-empty -m "$COMMIT_MESSAGE" 344 | env: 345 | ORIGINAL_COMMIT_MESSAGE: ${{ github.event.commits[0].message }} 346 | - name: Push 347 | run: | 348 | git remote add super-rentals git@github.com:ember-learn/super-rentals.git 349 | git push -f super-rentals master:super-rentals-tutorial-output 350 | 351 | deploy-code-flattened: 352 | # This job deploys the built app to the main branch of the 353 | # ember-learn/super-rentals repository. This branch does not preserve 354 | # the commit history of the tutorial flow. Instead, it squashes the changes 355 | # into a single commit on the main branch. This preserves 356 | # a linear history of changes to the built app over time, either due to 357 | # changes to the generator blueprints, or changes to the tutorial content 358 | # itself (e.g. refactoring to use new ember features). A lot of times, the 359 | # built app's source code remains stable and so there may be no changes to 360 | # push here. 361 | # 362 | name: Deploy to ember-learn/super-rentals (main branch) 363 | needs: build 364 | runs-on: ubuntu-latest 365 | if: github.ref == 'refs/heads/main' 366 | steps: 367 | - name: Set up Git 368 | run: | 369 | git config --global user.name "Tomster" 370 | git config --global user.email "tomster@emberjs.com" 371 | - name: Set up SSH 372 | uses: webfactory/ssh-agent@v0.4.1 373 | with: 374 | ssh-private-key: ${{ secrets.SUPER_RENTALS_DEPLOY_KEY }} 375 | - name: Download artifacts 376 | uses: actions/download-artifact@v4 377 | with: 378 | name: code (release) 379 | path: output 380 | - name: Clone 381 | run: git clone git@github.com:ember-learn/super-rentals --depth 1 382 | - name: Commit 383 | working-directory: super-rentals 384 | run: | 385 | set -euo pipefail 386 | IFS=$'\n\t' 387 | 388 | git rm -rf '*' 389 | 390 | if [[ "$GITHUB_EVENT_NAME" == "schedule" ]]; then 391 | COMMIT_TITLE="[CRON] $(date +"%A %b %d, %Y")" 392 | else 393 | COMMIT_TITLE="$( 394 | echo -n "$ORIGINAL_COMMIT_MESSAGE" | \ 395 | sed -E "s|^Merge pull request #(.+) from .+$|Merge $GITHUB_REPOSITORY#\1|" 396 | )" 397 | fi 398 | 399 | COMMIT_MESSAGE="$COMMIT_TITLE 400 | 401 | --- 402 | 403 | Commit: $GITHUB_REPOSITORY@$GITHUB_SHA 404 | Script: https://github.com/$GITHUB_REPOSITORY/blob/$GITHUB_SHA/.github/workflows/build.yml 405 | Logs: https://github.com/$GITHUB_REPOSITORY/commit/$GITHUB_SHA/checks" 406 | 407 | git commit --allow-empty -m "$COMMIT_MESSAGE" 408 | git remote add output ../output 409 | git fetch output 410 | git merge --squash --no-commit --allow-unrelated-histories output/master 411 | git reset HEAD~ -- README.md app.json 412 | git commit --allow-empty --amend --no-edit 413 | env: 414 | ORIGINAL_COMMIT_MESSAGE: ${{ github.event.commits[0].message }} 415 | - name: Push 416 | working-directory: super-rentals 417 | run: | 418 | set -euo pipefail 419 | IFS=$'\n\t' 420 | 421 | if git diff --exit-code origin/main; then 422 | echo "Nothing to push" 423 | else 424 | git push 425 | fi 426 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules 3 | tmp 4 | *.log 5 | -------------------------------------------------------------------------------- /.local.dic: -------------------------------------------------------------------------------- 1 | blockquote 2 | callouts 3 | focusability 4 | GIFs 5 | Hm 6 | invoker 7 | invokers 8 | Mapbox 9 | misspelt 10 | nav-bar 11 | *NPM 12 | page-crafter 13 | param 14 | params 15 | parameterizing 16 | PRs 17 | pre-determined 18 | pre-populating 19 | prepend 20 | presentational 21 | repo 22 | repos 23 | Runnable 24 | runnable 25 | RunDOC 26 | source-readibility 27 | Splattributes 28 | swappable 29 | syntaxes 30 | triple-backtick 31 | unstyled 32 | untracked 33 | Voilà 34 | voilà 35 | yay 36 | EmberData 37 | RequestManager 38 | centric -------------------------------------------------------------------------------- /.remarkignore: -------------------------------------------------------------------------------- 1 | CONTRIBUTING.md 2 | README.md 3 | -------------------------------------------------------------------------------- /.remarkrc.js: -------------------------------------------------------------------------------- 1 | // .remarkrc.js 2 | /* eslint-env node */ 3 | const unified = require("unified"); 4 | const read = require("fs").readFileSync; 5 | const ember = require("ember-dictionary"); 6 | 7 | exports.plugins = [ 8 | [ 9 | require("remark-retext"), 10 | unified().use({ 11 | plugins: [ 12 | [require("retext-contractions"), { straight: true }], 13 | require("retext-english"), 14 | require("retext-indefinite-article"), 15 | require("retext-repeated-words"), 16 | require("retext-syntax-urls"), 17 | [ 18 | require("retext-spell"), 19 | { 20 | dictionary: ember, 21 | personal: read("./.local.dic") 22 | } 23 | ] 24 | ] 25 | }) 26 | ], 27 | "remark-preset-lint-consistent", 28 | "remark-preset-lint-recommended", 29 | ["remark-lint-list-item-indent", "space"] 30 | ]; 31 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "node", 9 | "request": "launch", 10 | "name": "Launch Program", 11 | "program": "${workspaceFolder}/dist/bin/generate.js", 12 | "console": "integratedTerminal", 13 | "preLaunchTask": "compile" 14 | } 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "node_modules/typescript/lib", 3 | "files.exclude": { 4 | "**/.git": true, 5 | "**/.svn": true, 6 | "**/.hg": true, 7 | "**/CVS": true, 8 | "**/.DS_Store": true, 9 | "node_modules": true 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | // See https://go.microsoft.com/fwlink/?LinkId=733558 3 | // for the documentation about the tasks.json format 4 | "version": "2.0.0", 5 | "tasks": [ 6 | { 7 | "label": "compile", 8 | "type": "shell", 9 | "command": "yarn", 10 | "args": ["compile"], 11 | "problemMatcher": [ 12 | "$tslint5", 13 | "$tsc" 14 | ] 15 | } 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Thank you for your interest in improving the Super Rentals Tutorial! This repository contains the logic to generate the prose markdown for our tutorial, as well as the code for the tutorial _app_ itself. 4 | 5 | Please note that all of the content to generate markdown and code are located in individual chapter files in `/src/markdown`. Any modifications/additions should occur there. 6 | 7 | When building this tutorial you may notice that you have a `/dist/markdown` generated locally. Please note that this directory is _not_ committed, and contains the output of your locally-generated tutorial content. Please be mindful not to modify anything in the `/dist` directory as it is git ignored and any changes there will not impact what is generate! Instead, make your changes inside of the `/src` directory only. 8 | 9 | ## Making changes to the tutorial prose 10 | 11 | If you are modifying or adding new prose to the tutorial, please be sure to follow the formatting style guide below! Following these conventions will make it easier to review your contribution. 😊 12 | 13 | ### Formatting style guide 14 | 15 | * When naming chapters, be sure that they are prefixed with two digits before the chapter name to ensure that they are ordered correctly. 16 | * For example: `01-orientation.md`. 17 | * When referring to filenames, be sure to specify the full relative path to a file. This is to make it easy to copy-paste the filename when people use the tutorial. 18 | * For example: _"Let's start by creating a new file at `app/components/jumbo.hbs`."_ 19 | * When referring to other chapters, link to the chapter using its relative path in the tutorial. Please be sure to **include the trailing slash** in the path so that the markdown will be linted correctly in the guides repo. 20 | * For example: `We learned about this in a [previous chapter](../02-building-pages/)`. 21 | * When referring to important key terms, be sure to _italicize_ the term and link to a definition for it. This highlighting only needs to be done once, ideally on the first instance of the term. Note that, when using the "TODO" format (a placeholder for a link to be added in the future), we used `[]` rather than `()` to wrap the link markdown. This ensures that the generated output does not have broken links. 22 | * For example: `*[route][TODO: add a link to route]*`. 23 | * When referring to component names, keywords, helpers, or HTML elements, be sure to use code markup. 24 | * For example: _"Inside of this file, we have the `` component and the `{{outlet}}` keyword."_ 25 | * When using the "Zoey says" callouts, be sure to use the blockquote formatting, ensuring that an extra `>` is included between the "Zoey says..." text and the actual note content. This is required in order to properly convert the markdown into HTML. 26 | * For example: 27 | ``` 28 | > Zoey says... 29 | > 30 | > Here's a very helpful comment! 31 | ``` 32 | 33 | ## Making changes to the tutorial code 34 | 35 | The workflow for updating the Super Rentals Tutorial involves working within two directories of this project: `/src/markdown` and `/dist`. The editable prose lives in `src/markdown`; any changes that are made to the prose should be done there. The tutorial uses the content in `/src/markdown` to generate the prose markdown that is output into `/dist/markdown`. Interspersed with the prose are commands that are used to generate the code for the _actual_ tutorial app, which is outputted at `/dist/code/super-rentals`. 36 | 37 | Once you have made your changes in the tutorial prose or code, you can run a few different commands to verify that your changes render as expected. 38 | 39 | * To regenerate all of the chapters (and rebuild the app), run `yarn build`. This will `rm -rf` anything previously generated in the `/dist` directory. 40 | * To generate a single chapter without removing all the previously generated content, run `yarn generate src/markdown/07-your-chapter-to-edit-here.md`. This will rebuild _just_ the chapter that is specified without deleting everything else in the `/dist` directory. Please note that, before running this command, the _current state_ of the `/dist/code` directory has to match the precise state of what it would be after running the generate command on the previous chapter. In other words, in order for the generate command to work correctly, the state of the code in `/dist/code` should match exactly the code that would be generated by `yarn generate src/markdown/06-the-previous-chapter.md` before it is run for `src/markdown/07-your-chapter-to-edit-here.md` . 41 | 42 | ### Code changes 43 | 44 | To make code changes, you will first need to locate the chapter you wish to edit in `/src/markdown`. Depending on the type of code change you'd like to make, you will need to use different commands. 45 | 46 | #### Running commands 47 | 48 | If you'd like to run a command, you will need to use the `run: command`. Be sure to include `cwd=super-rentals` so that the file is generated in the app's directory. 49 | 50 | For example: 51 | 52 | ```run:command hidden=true cwd=super-rentals 53 | ember generate component rental 54 | ``` 55 | 56 | Note that running a command by default will include the output of that command in the final markdown. To avoid output in markdown, you can use `hidden=true` or `captureOutput=false` as appropriate, as detailed in the [README](./README.md). 57 | 58 | #### Creating files 59 | 60 | To create a file, you will need to use the `run:file:create` command. Be sure to specify the language, the filename, as well as the `cwd` directory. 61 | 62 | For example: 63 | 64 | ```run:file:create lang=handlebars cwd=super-rentals filename=app/templates/about.hbs 65 |

About Super Rentals

66 | ``` 67 | 68 | After creating a file, you should be sure to _add_ it shortly afterwards so that it is tracked by git, and will be committed in the process of generating the tutorial app. This can be a `hidden` command, since it is not part of the prose of the tutorial. 69 | 70 | For example: 71 | 72 | ```run:command hidden=true cwd=super-rentals 73 | git add app/templates/about.hbs 74 | ``` 75 | 76 | If you are adding a _test_ file, be sure to run `ember test --path dist` before adding the test file. This way, the generator will run the tests before adding them, and will fail in generating broken code if the tests themselves fail. The `--path` flag will speed up the test build and prevent test timeouts. 77 | 78 | #### Using `run:pause` 79 | 80 | When running commands, generating new files, or making changes to preexisting ones, you may want to see the current state of the generated app. The `run: pause` command is useful in this case. Use this command inside of your existing chapter, inserting it at points where you want the generator to stop. This is similar to running something like `pauseTest()`; it will pause the generator and allow you to see the current state of the code you have generated (located in `/dist/code/super-rentals`). 81 | 82 | For example: 83 | 84 | ```run:pause 85 | Note to self: check that `application.hbs` has the correct markup and remember to git add it! 86 | ``` 87 | 88 | Any content inside of the code blocks will be printed in your command line. 89 | 90 | #### Generating diffs 91 | 92 | _⚠️ Please note: you may find it easier to generate diffs and make edits to the tutorial by opening `/dist/code/super-rentals/code` in a separate code editor window._ 93 | 94 | Before you start make any changes to capture a diff, navigate to `/dist/code/super-rentals/code` in the terminal. Run `git diff` and make sure there are no changes and that you have an empty diff. 95 | 96 | In the case of generating diffs to render inside of the tutorial prose, you will want to use the `run: pause` command, described in the section above. Once you have put a `run: pause` at the spot in your prose where the diff needs to be captured, you can navigate to the `/dist/code/super-rentals/code` directory in your editor. In the app code, make whatever changes you need to make in whatever files need to be modified. 97 | 98 | After you have made your code changes, navigate back to `/dist/code/super-rentals/code` in the terminal. First, check that the diff is included in the current changes: 99 | 100 | ``` 101 | ➜ code git:(your-branch-name) ✗ git diff 102 | ``` 103 | 104 | Next, save your diff to some temporary file that you will open in a code editor in a moment (`my_awesome_patch` in the example below). You want to be sure to generate the diff specifically with only 1 line of context. You can do this by specifying `git diff --unified=1` or `git diff -U1`. 105 | 106 | ``` 107 | ➜ code git:(your-branch-name) ✗ git diff -U1 > my_awesome_patch 108 | ➜ code git:(your-branch-name) ✗ code my_awesome_patch 109 | ``` 110 | 111 | Once you have opened your temporary file containing your diff, edit it so that the only thing surrounding the diff is the line number additions subtractions. 112 | 113 | ``` 114 | @@ -9,2 +9,3 @@ 115 | Router.map(function() { 116 | + this.route('about'); 117 | }); 118 | ``` 119 | 120 | Finally, wrap the newly-modified diff content in the `run:file:patch` command. Be sure to specify the language of the file, the filename, and the `cwd=super-rentals` option so that the patch is applied in the app's directory. 121 | 122 | For example: 123 | 124 | ```run:file:patch lang=js cwd=super-rentals filename=app/router.js 125 | @@ -9,2 +9,3 @@ 126 | Router.map(function() { 127 | + this.route('about'); 128 | }); 129 | ``` 130 | 131 | In both cases, be sure your editor is not set to trim trailing whitespace on lines. This will cause the diff to be error with `error: corrupt patch at line`. 132 | 133 | Once you have added the diff to the prose using the `run:file:patch` command, you can remove the `run:pause` command. In order to test that your patch was successfully applied, be sure to: 134 | 135 | 1. Run `git checkout` any changes in the working directory of `/dist/code/super-rentals/code`. 136 | 2. Regenerate your modified chapter (`yarn generate src/markdown/00-your-chapter-to-edit-here.md`). 137 | 138 | Finally, you can look at your modified chapter in `/dist/markdown` and make sure that your patch renders as you expect in the markdown! 139 | 140 | #### Adding screenshots 141 | 142 | TODO: add content! 143 | 144 | #### Adding GIFs 145 | 146 | TODO: add content! 147 | 148 | #### Saving a chapter 149 | 150 | Once a chapter is ready to be published and files, diffs, and screenshots have all been added as necessary, the last step is to commit the code changes at the end of the chapter. This is done using the `run:checkpoint` command. 151 | 152 | For example: 153 | 154 | ```run:checkpoint cwd=super-rentals 155 | Chapter 3 156 | ``` 157 | 158 | This should always be the last thing at the end of every chapter, as it will lint and run tests against the code generated in the chapter. The commit message will be whatever content is in the code block. We prefer the chapter title as the content of the commit. 159 | 160 | Before opening a pull request, be sure to check the `/dist/markdown` to make sure that the markdown generated by your changes to a chapter still looks correct. Also lint the markdown by running the `lint:md` script. 161 | 162 | #### Linting 163 | 164 | Linting checks the markdown for spelling mistakes, repeated words, and markdown consistency. The linter also enforces brand and product name consistency. i.e `Ember` not `ember` or `GitHub`, not `Github`. 165 | 166 | The `super-rentals-tutorial` uses the same linting rules as the [guides](https://github.com/ember-learn/guides-source). If linting passes here it will pass when merged with the guides. Most of the linting errors, except some spelling errors, will be self-explanatory. 167 | 168 | #### Spelling Errors 169 | 170 | The spelling [dictionary](https://github.com/maxwondercorn/ember-dictionary) used by the markdown linter contains many words common to the Ember ecosystem and general technology. It won't contain every possible word that could be used in the tutorial. Some real words or acronyms will be flagged as misspelled words. 171 | 172 | Correctly spelled words flagged as misspelt can be added to the local dictionary file `.local.dic`. This will eliminate the spelling errors. Words added to the local dictionary should be in alphabetical order and all word cases added as necessary. i.e. both `params` and `Params` would need to be added if there both used in the tutorial. 173 | 174 | Finally, the tutorial will be linted using the `guides` markdown linter when the PR is created. Since the linter will use the `guides` dictionary the words in `.local.dic` must also be in the guides `.local.dic` file for linting to pass. 175 | 176 | When new words are added to the local dictionary, a separate PR must be opened on the guides to add them to the guides dictionary. If there are words that you think belong to the [Ember dictionary](https://github.com/maxwondercorn/ember-dictionary), please open a PR in that repo. 177 | 178 | #### Pull Request Merging 179 | 180 | After the pull request is merged to the `main` branch, the generated markdown and code output will be pushed to a branch on the markdown/code output will be pushed to a branch on the [guides](https://github.com/ember-learn/guides-source) and [super rentals repos](https://github.com/ember-learn/guides-source/tree/super-rentals-tutorial) so that they can be further reviewed by their maintainers before integrating into the tutorial code/app and markdown. 181 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Ember Learning Team 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "super-rentals-tutorial", 3 | "version": "0.0.0", 4 | "description": "Runnable super-rentals tutorial", 5 | "main": "index.js", 6 | "author": "Godfrey Chan", 7 | "license": "MIT", 8 | "private": true, 9 | "devDependencies": { 10 | "@actions/core": "^1.2.6", 11 | "@types/glob": "^8.0.0", 12 | "@types/image-size": "^0.8.0", 13 | "@types/mdast": "^3.0.2", 14 | "@types/mkdirp": "^1.0.2", 15 | "@types/ncp": "^2.0.1", 16 | "@types/node": "^18.11.18", 17 | "@types/unist": "^2.0.3", 18 | "@types/vfile": "^4.0.0", 19 | "chalk": "^2.4.2", 20 | "ember-cli": "*", 21 | "ember-dictionary": "^0.2.3", 22 | "image-size": "^1.0.2", 23 | "mkdirp": "^1.0.4", 24 | "ncp": "^2.0.0", 25 | "node-glob": "^1.2.0", 26 | "puppeteer": "~24.4.0", 27 | "rehype-stringify": "^6.0.0", 28 | "remark": "^11.0.1", 29 | "remark-cli": "^7.0.0", 30 | "remark-frontmatter": "^1.3.2", 31 | "remark-lint": "^6.0.5", 32 | "remark-lint-list-item-indent": "^1.0.4", 33 | "remark-parse": "^7.0.1", 34 | "remark-parse-yaml": "^0.0.3", 35 | "remark-preset-lint-consistent": "^2.0.3", 36 | "remark-preset-lint-recommended": "^3.0.3", 37 | "remark-rehype": "^5.0.0", 38 | "remark-retext": "^3.1.3", 39 | "remark-stringify": "^7.0.2", 40 | "retext-contractions": "^3.0.0", 41 | "retext-english": "^3.0.3", 42 | "retext-indefinite-article": "^1.1.7", 43 | "retext-repeated-words": "^2.0.0", 44 | "retext-spell": "^3.0.0", 45 | "retext-syntax-urls": "^1.0.2", 46 | "shx": "^0.3.2", 47 | "ts-std": "^0.7.0", 48 | "tslint": "^6.1.3", 49 | "tslint-config-prettier": "^1.18.0", 50 | "typescript": "^4.9.4", 51 | "unified": "^8.4.0" 52 | }, 53 | "scripts": { 54 | "clean": "shx rm -rf dist", 55 | "lint": "yarn lint:ts && yarn lint:md", 56 | "lint:ts": "tslint --project .", 57 | "lint:md": "remark . --frail", 58 | "lint:md:src": "yarn lint:md --ignore-pattern dist", 59 | "lint:md:dist": "yarn lint:md --ignore-pattern src", 60 | "precompile": "yarn lint:ts", 61 | "compile": "tsc", 62 | "generate": "node ./dist/bin/generate.js", 63 | "prebuild": "yarn clean && yarn compile && yarn lint:md:src", 64 | "build": "yarn generate", 65 | "postbuild": "yarn lint:md:dist" 66 | }, 67 | "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e" 68 | } 69 | -------------------------------------------------------------------------------- /src/assets/downloads/data/api/rentals.json: -------------------------------------------------------------------------------- 1 | { 2 | "data": [ 3 | { 4 | "type": "rental", 5 | "id": "grand-old-mansion", 6 | "attributes": { 7 | "title": "Grand Old Mansion", 8 | "owner": "Veruca Salt", 9 | "city": "San Francisco", 10 | "location": { 11 | "lat": 37.7749, 12 | "lng": -122.4194 13 | }, 14 | "category": "Estate", 15 | "bedrooms": 15, 16 | "image": "https://upload.wikimedia.org/wikipedia/commons/c/cb/Crane_estate_(5).jpg", 17 | "description": "This grand old mansion sits on over 100 acres of rolling hills and dense redwood forests." 18 | } 19 | }, 20 | { 21 | "type": "rental", 22 | "id": "urban-living", 23 | "attributes": { 24 | "title": "Urban Living", 25 | "owner": "Mike Teavee", 26 | "city": "Seattle", 27 | "location": { 28 | "lat": 47.6062, 29 | "lng": -122.3321 30 | }, 31 | "category": "Condo", 32 | "bedrooms": 1, 33 | "image": "https://upload.wikimedia.org/wikipedia/commons/2/20/Seattle_-_Barnes_and_Bell_Buildings.jpg", 34 | "description": "A commuters dream. This rental is within walking distance of 2 bus stops and the Metro." 35 | } 36 | }, 37 | { 38 | "type": "rental", 39 | "id": "downtown-charm", 40 | "attributes": { 41 | "title": "Downtown Charm", 42 | "owner": "Violet Beauregarde", 43 | "city": "Portland", 44 | "location": { 45 | "lat": 45.5175, 46 | "lng": -122.6801 47 | }, 48 | "category": "Apartment", 49 | "bedrooms": 3, 50 | "image": "https://upload.wikimedia.org/wikipedia/commons/f/f7/Wheeldon_Apartment_Building_-_Portland_Oregon.jpg", 51 | "description": "Convenience is at your doorstep with this charming downtown rental. Great restaurants and active night life are within a few feet." 52 | } 53 | } 54 | ] 55 | } 56 | -------------------------------------------------------------------------------- /src/assets/downloads/data/api/rentals/downtown-charm.json: -------------------------------------------------------------------------------- 1 | { 2 | "data": { 3 | "type": "rental", 4 | "id": "downtown-charm", 5 | "attributes": { 6 | "title": "Downtown Charm", 7 | "owner": "Violet Beauregarde", 8 | "city": "Portland", 9 | "location": { 10 | "lat": 45.5175, 11 | "lng": -122.6801 12 | }, 13 | "category": "Apartment", 14 | "bedrooms": 3, 15 | "image": "https://upload.wikimedia.org/wikipedia/commons/f/f7/Wheeldon_Apartment_Building_-_Portland_Oregon.jpg", 16 | "description": "Convenience is at your doorstep with this charming downtown rental. Great restaurants and active night life are within a few feet." 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/assets/downloads/data/api/rentals/grand-old-mansion.json: -------------------------------------------------------------------------------- 1 | { 2 | "data": { 3 | "type": "rental", 4 | "id": "grand-old-mansion", 5 | "attributes": { 6 | "title": "Grand Old Mansion", 7 | "owner": "Veruca Salt", 8 | "city": "San Francisco", 9 | "location": { 10 | "lat": 37.7749, 11 | "lng": -122.4194 12 | }, 13 | "category": "Estate", 14 | "bedrooms": 15, 15 | "image": "https://upload.wikimedia.org/wikipedia/commons/c/cb/Crane_estate_(5).jpg", 16 | "description": "This grand old mansion sits on over 100 acres of rolling hills and dense redwood forests." 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/assets/downloads/data/api/rentals/urban-living.json: -------------------------------------------------------------------------------- 1 | { 2 | "data": { 3 | "type": "rental", 4 | "id": "urban-living", 5 | "attributes": { 6 | "title": "Urban Living", 7 | "owner": "Mike Teavee", 8 | "city": "Seattle", 9 | "location": { 10 | "lat": 47.6062, 11 | "lng": -122.3321 12 | }, 13 | "category": "Condo", 14 | "bedrooms": 1, 15 | "image": "https://upload.wikimedia.org/wikipedia/commons/2/20/Seattle_-_Barnes_and_Bell_Buildings.jpg", 16 | "description": "A commuters dream. This rental is within walking distance of 2 bus stops and the Metro." 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/assets/downloads/style.css: -------------------------------------------------------------------------------- 1 | /* stylelint-disable no-descending-specificity, media-feature-range-notation */ 2 | @import url("https://fonts.googleapis.com/css?family=Lato:300,300italic,400,700,700italic"); 3 | 4 | /** 5 | * Base Elements 6 | */ 7 | 8 | * { 9 | margin: 0; 10 | padding: 0; 11 | } 12 | 13 | body, 14 | h1, 15 | h2, 16 | h3, 17 | h4, 18 | h5, 19 | h6, 20 | p, 21 | div, 22 | span, 23 | a, 24 | button { 25 | font-family: 26 | Lato, "Open Sans", "Helvetica Neue", "Segoe UI", Helvetica, Arial, 27 | sans-serif; 28 | line-height: 1.5; 29 | } 30 | 31 | body { 32 | background: #f3f3f3; 33 | } 34 | 35 | a { 36 | color: #016aba; 37 | text-decoration: none; 38 | } 39 | 40 | button { 41 | font-size: 100%; 42 | } 43 | 44 | p { 45 | line-height: 1.5; 46 | margin-bottom: 15px; 47 | } 48 | 49 | /** 50 | * Button 51 | */ 52 | 53 | .button { 54 | padding: 10px 30px; 55 | text-decoration: none; 56 | color: #fff; 57 | background: #016aba; 58 | border-radius: 5px; 59 | border: none; 60 | font-size: 20px; 61 | font-weight: bold; 62 | opacity: 0.9; 63 | display: inline-block; 64 | } 65 | 66 | .button:hover { 67 | opacity: 1; 68 | } 69 | 70 | /** 71 | * Body Container 72 | */ 73 | 74 | .container { 75 | max-width: 1024px; 76 | min-height: 100vh; 77 | background: #f9f9f9; 78 | margin: 0 auto; 79 | } 80 | 81 | /** 82 | * Top Navigation 83 | */ 84 | 85 | .menu { 86 | height: 4em; 87 | background-color: #e46855; 88 | } 89 | 90 | .menu h1 { 91 | position: relative; 92 | padding: 5px 0 0 8px; 93 | color: #f9f9f9; 94 | font-size: 1.8em; 95 | font-style: italic; 96 | } 97 | 98 | .menu a, 99 | .menu .links { 100 | display: inline-block; 101 | } 102 | 103 | .menu a { 104 | text-decoration: none; 105 | padding: 0 15px; 106 | color: #fff; 107 | font-size: 20px; 108 | font-weight: bold; 109 | } 110 | 111 | .menu a:hover, 112 | .menu a.active { 113 | opacity: 1; 114 | } 115 | 116 | .menu .links { 117 | padding: 0 21px; 118 | } 119 | 120 | .menu .links a { 121 | position: relative; 122 | bottom: 5px; 123 | } 124 | 125 | .rentals label span { 126 | font-size: 140%; 127 | margin: 50px auto 20px; 128 | display: block; 129 | text-align: center; 130 | font-style: italic; 131 | } 132 | 133 | .rentals form p { 134 | font-size: 80%; 135 | display: block; 136 | text-align: center; 137 | } 138 | 139 | .rentals input { 140 | padding: 11px; 141 | font-size: 18px; 142 | width: 500px; 143 | margin: 20px auto 50px; 144 | background-color: rgb(255 255 255 / 75%); 145 | border: solid 1px lightgray; 146 | display: block; 147 | } 148 | 149 | .menu input:focus { 150 | background-color: #f9f9f9; 151 | outline: none; 152 | } 153 | 154 | .menu button { 155 | margin-right: 15px; 156 | position: relative; 157 | top: -1px; 158 | left: -5px; 159 | border-top-left-radius: 0; 160 | border-bottom-left-radius: 0; 161 | background-color: #262626; 162 | cursor: pointer; 163 | opacity: 1; 164 | } 165 | 166 | .menu button:hover { 167 | background-color: #111; 168 | opacity: 1; 169 | } 170 | 171 | .menu .results { 172 | display: none; 173 | position: absolute; 174 | width: 215px; 175 | top: 54px; 176 | left: 10px; 177 | background-color: #f6f6f6; 178 | border-right: 1px solid rgb(0 0 0 / 5%); 179 | border-bottom: 1px solid rgb(0 0 0 / 5%); 180 | } 181 | 182 | .results { 183 | margin-top: -10px; 184 | } 185 | 186 | .results li { 187 | list-style: none; 188 | padding: 10px 15px; 189 | } 190 | 191 | .menu .results li:hover { 192 | background: #f3f3f3; 193 | } 194 | 195 | /** 196 | * Content Area 197 | */ 198 | 199 | .body { 200 | padding: 15px; 201 | } 202 | 203 | /** 204 | * Similar to Jumbotron 205 | */ 206 | 207 | .jumbo { 208 | padding: 50px; 209 | background: #f6f6f6; 210 | } 211 | 212 | .jumbo:hover { 213 | background-color: #f3f3f3; 214 | } 215 | 216 | .jumbo h2 { 217 | font-size: 3.2em; 218 | margin-top: -25px; 219 | } 220 | 221 | .jumbo p, 222 | .jumbo address { 223 | margin-bottom: 25px; 224 | } 225 | 226 | .jumbo img { 227 | height: 200px; 228 | position: relative; 229 | top: -25px; 230 | right: -20px; 231 | } 232 | 233 | /** 234 | * Individual Rental Listing 235 | */ 236 | 237 | .rental { 238 | margin-top: 15px; 239 | background-color: #f6f6f6; 240 | padding: 20px 25px; 241 | display: flex; 242 | justify-content: space-between; 243 | align-items: center; 244 | flex-wrap: wrap; 245 | } 246 | 247 | .rental:hover { 248 | background-color: #f3f3f3; 249 | } 250 | 251 | .rental img { 252 | border-radius: 5px; 253 | } 254 | 255 | .rental .image { 256 | flex-grow: 0; 257 | flex-basis: 150px; 258 | margin: 20px 25px; 259 | text-align: center; 260 | } 261 | 262 | .rental button.image { 263 | position: relative; 264 | cursor: pointer; 265 | border: none; 266 | background: transparent; 267 | z-index: 1; 268 | } 269 | 270 | .rental button.image:focus { 271 | outline: none; 272 | } 273 | 274 | .rental button.image::after { 275 | content: ""; 276 | position: absolute; 277 | top: 0; 278 | left: 0; 279 | width: 100%; 280 | height: 100%; 281 | z-index: -1; 282 | margin: -20px; 283 | padding: 20px; 284 | border-radius: 5px; 285 | background: #016aba; 286 | opacity: 0; 287 | transition: opacity 0.25s ease-in-out; 288 | } 289 | 290 | .rental button.image:focus::after, 291 | .rental button.image:hover::after { 292 | opacity: 0.1; 293 | } 294 | 295 | .rental .image img { 296 | max-width: 100%; 297 | } 298 | 299 | .rental .image.large { 300 | margin: 30px 25px 50px; 301 | flex-basis: 100%; 302 | } 303 | 304 | .rental .image small { 305 | display: block; 306 | margin-top: 5px; 307 | margin-bottom: -15px; 308 | text-align: center; 309 | color: #016aba; 310 | 311 | /* This is needed to fix a safari clipping issue */ 312 | position: relative; 313 | } 314 | 315 | .rental .image.large small { 316 | margin-top: 10px; 317 | margin-bottom: 0; 318 | font-size: 110%; 319 | } 320 | 321 | .rental .details { 322 | flex-basis: 50%; 323 | flex-grow: 2; 324 | display: flex; 325 | height: 150px; 326 | margin: 20px 25px; 327 | place-content: space-around space-between; 328 | flex-wrap: wrap; 329 | } 330 | 331 | .rental h3 { 332 | flex-basis: 100%; 333 | } 334 | 335 | .rental h3 a { 336 | display: inline; 337 | } 338 | 339 | .rental .detail { 340 | flex-basis: 50%; 341 | font-weight: 300; 342 | font-style: italic; 343 | white-space: nowrap; 344 | } 345 | 346 | .rental .detail span { 347 | font-weight: 400; 348 | font-style: normal; 349 | } 350 | 351 | .rental .map { 352 | flex-grow: 0; 353 | flex-basis: 150px; 354 | font-size: 0; 355 | margin: 0 25px; 356 | } 357 | 358 | .rental .map img { 359 | width: 150px; 360 | height: 150px; 361 | } 362 | 363 | .rental.detailed { 364 | background: none; 365 | align-items: flex-start; 366 | } 367 | 368 | .rental.detailed .image { 369 | flex-basis: 320px; 370 | } 371 | 372 | .rental.detailed .image.large { 373 | margin: 30px 25px 50px; 374 | flex-basis: 100%; 375 | } 376 | 377 | .rental.detailed .details { 378 | height: auto; 379 | } 380 | 381 | .rental.detailed h3 { 382 | font-size: 200%; 383 | margin-bottom: 10px; 384 | } 385 | 386 | .rental.detailed .detail { 387 | margin: 5px 0; 388 | flex-basis: 100%; 389 | flex-shrink: 2; 390 | } 391 | 392 | .rental.detailed .description { 393 | white-space: normal; 394 | flex-basis: 100%; 395 | flex-shrink: 1; 396 | } 397 | 398 | .rental.detailed .map { 399 | flex-basis: 100%; 400 | margin: 50px 25px 25px; 401 | } 402 | 403 | .rental.detailed .map img { 404 | width: 100%; 405 | height: auto; 406 | } 407 | 408 | @media only screen and (max-width: 919px) { 409 | .rental.detailed .image, 410 | .rental.detailed .image.large { 411 | margin: 30px 25px 25px; 412 | flex-basis: 100%; 413 | cursor: default; 414 | } 415 | 416 | .rental.detailed .image:hover { 417 | flex-basis: 100%; 418 | cursor: default; 419 | } 420 | 421 | .rental.detailed .image small { 422 | display: none; 423 | } 424 | 425 | .rental.detailed button.image:hover::after { 426 | opacity: 0; 427 | } 428 | 429 | .rental.detailed button.image:focus::after { 430 | opacity: 0.1; 431 | } 432 | 433 | .rental.detailed .map { 434 | margin-top: 25px; 435 | } 436 | } 437 | 438 | /** 439 | * Utilities 440 | */ 441 | 442 | .light { 443 | font-weight: 300; 444 | } 445 | 446 | .left { 447 | float: left; 448 | } 449 | 450 | .right { 451 | float: right; 452 | } 453 | 454 | .hidden { 455 | display: none; 456 | } 457 | 458 | .relative { 459 | position: relative; 460 | } 461 | 462 | .tomster { 463 | background: url("../assets/images/teaching-tomster.png"); 464 | background-size: contain; 465 | background-repeat: no-repeat; 466 | height: 200px; 467 | width: 200px; 468 | position: relative; 469 | top: -25px; 470 | } 471 | 472 | .screen-reader { 473 | position: absolute; 474 | overflow: hidden; 475 | clip: rect(0 0 0 0); 476 | height: 1px; 477 | width: 1px; 478 | margin: -1px; 479 | padding: 0; 480 | border: 0; 481 | } 482 | 483 | /* stylelint-enable no-descending-specificity, media-feature-range-notation */ 484 | -------------------------------------------------------------------------------- /src/assets/downloads/teaching-tomster.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ember-learn/super-rentals-tutorial/43f1a8e6605a13775ebce3422e6ca3b5109170eb/src/assets/downloads/teaching-tomster.png -------------------------------------------------------------------------------- /src/bin/generate.ts: -------------------------------------------------------------------------------- 1 | import * as github from '@actions/core'; 2 | import chalk from 'chalk'; 3 | import { 4 | readFile as _readFile, 5 | writeFile as _writeFile 6 | } from 'fs'; 7 | import _glob from 'glob'; 8 | import mkdirp from 'mkdirp'; 9 | import { ncp as _ncp } from 'ncp'; 10 | import { basename, dirname, join, relative, sep } from 'path'; 11 | import frontmatter from 'remark-frontmatter'; 12 | import markdown from 'remark-parse'; 13 | import yaml from 'remark-parse-yaml'; 14 | import stringify from 'remark-stringify'; 15 | import unified, { Processor } from 'unified'; 16 | import { promisify } from 'util'; 17 | import { VFileOptions } from 'vfile'; 18 | 19 | import { doNotEdit, retinaImages, runCodeBlocks, todoLinks, zoeySays } from '../lib'; 20 | 21 | const glob = promisify(_glob); 22 | const readFile = promisify(_readFile); 23 | const writeFile = promisify(_writeFile); 24 | const ncp = promisify(_ncp); 25 | 26 | // 01-orientation.md -> orientation.md 27 | function unprefix(path: string): string { 28 | let parts = path.split(sep); 29 | parts = parts.map(p => p.replace(/^[0-9]+-/, '')); 30 | return join(...parts); 31 | } 32 | 33 | // Group a related section of the logs. On CI this makes the section foldable. 34 | async function group(name: string, callback: () => Promise): Promise { 35 | if (process.env.CI) { 36 | return github.group(name, callback); 37 | } else { 38 | console.log(chalk.yellow(name)); 39 | return callback(); 40 | } 41 | } 42 | 43 | async function run(processor: Processor, inputPath: string, outputPath: string, options: VFileOptions): Promise { 44 | let contents = await readFile(inputPath, { encoding: 'utf8' }); 45 | let result = await processor.process({ ...options, contents }); 46 | await writeFile(outputPath, result.toString(), { encoding: 'utf8' }); 47 | } 48 | 49 | async function main() { 50 | let project = process.cwd(); 51 | let assetsDir = join(project, 'dist', 'assets'); 52 | let srcDir = join(project, 'src', 'markdown') 53 | let outDir = join(project, 'dist', 'markdown'); 54 | let codeDir = join(project, 'dist', 'code'); 55 | 56 | await ncp(join(project, 'src', 'assets'), assetsDir); 57 | await mkdirp(outDir, {}); 58 | await mkdirp(codeDir, {}); 59 | 60 | let pattern = process.argv[2] || join('src', 'markdown', '**', '*.md'); 61 | 62 | let inputPaths = await glob(pattern); 63 | 64 | let processor = unified() 65 | .use(markdown) 66 | .use(frontmatter) 67 | .use(runCodeBlocks, { cfg: process.env.CI ? ['ci'] : [], cwd: codeDir, assets: assetsDir }) 68 | .use(todoLinks) 69 | .use(zoeySays) 70 | .use(doNotEdit, { repo: 'ember-learn/super-rentals-tutorial' }) 71 | .use(stringify, { fences: true, listItemIndent: '1' }) 72 | .use(yaml); 73 | 74 | let outputPaths: Map = new Map(); 75 | 76 | for (let inputPath of inputPaths) { 77 | await group(`Processing ${inputPath}`, async () => { 78 | let dir = unprefix(relative(srcDir, dirname(inputPath))); 79 | let name = unprefix(basename(inputPath)); 80 | 81 | await mkdirp(join(outDir, dir), {}); 82 | 83 | let outputPath = join(outDir, dir, name); 84 | 85 | let options: VFileOptions = { 86 | path: join(dir, name), 87 | cwd: outDir, 88 | data: { originalPath: inputPath } 89 | } 90 | 91 | outputPaths.set(outputPath, options); 92 | 93 | await run(processor, inputPath, outputPath, options); 94 | }); 95 | } 96 | 97 | let postProcessor = unified() 98 | .use(markdown) 99 | .use(frontmatter) 100 | .use(retinaImages, { assets: assetsDir }) 101 | .use(stringify, { fences: true, listItemIndent: '1' }) 102 | .use(yaml); 103 | 104 | for (let [outputPath, options] of outputPaths) { 105 | await group(`Post-processing ${relative(project, outputPath)}`, async () => 106 | await run(postProcessor, outputPath, outputPath, options) 107 | ); 108 | } 109 | } 110 | 111 | main().catch(error => { 112 | console.error(error); 113 | process.exit(1); 114 | }); 115 | -------------------------------------------------------------------------------- /src/lib/index.ts: -------------------------------------------------------------------------------- 1 | export { default as doNotEdit } from './plugins/do-not-edit'; 2 | export { default as retinaImages } from './plugins/retina-images'; 3 | export { default as runCodeBlocks } from './plugins/run-code-blocks'; 4 | export { default as todoLinks } from './plugins/todo-links'; 5 | export { default as zoeySays } from './plugins/zoey-says'; 6 | -------------------------------------------------------------------------------- /src/lib/plugins/do-not-edit/index.ts: -------------------------------------------------------------------------------- 1 | import { Plugin, Transformer } from 'unified'; 2 | import { Node } from 'unist'; 3 | import { VFile } from 'vfile'; 4 | import Options from './options'; 5 | import Walker from './walker'; 6 | 7 | function attacher(options: Options): Transformer { 8 | async function transform(node: Node, file: VFile): Promise { 9 | return new Walker(options, file).walk(node); 10 | } 11 | 12 | return transform; 13 | } 14 | 15 | const plugin: Plugin<[Options]> = attacher; 16 | 17 | export default plugin; 18 | -------------------------------------------------------------------------------- /src/lib/plugins/do-not-edit/options.ts: -------------------------------------------------------------------------------- 1 | export default interface Options { 2 | repo: string; 3 | branch?: string; 4 | } 5 | -------------------------------------------------------------------------------- /src/lib/plugins/do-not-edit/walker.ts: -------------------------------------------------------------------------------- 1 | import { HTML, Root } from 'mdast'; 2 | import { Option } from 'ts-std'; 3 | import { VFile } from 'vfile'; 4 | 5 | import BaseWalker from '../../walker'; 6 | import Options from './options'; 7 | 8 | function makeComment(repo: string, branch?: string, path?: string): HTML { 9 | let url = `https://github.com/${repo}`; 10 | 11 | if (path) { 12 | branch = branch || 'master'; 13 | url = `${url}/blob/${branch}/${path}`; 14 | } 15 | 16 | let message = `Heads up! This is a generated file, do not edit directly. You can find the source at ${url}`; 17 | 18 | return { 19 | type: 'html', 20 | value: `` 21 | }; 22 | } 23 | 24 | export default class DoNotEditWalker extends BaseWalker { 25 | protected async root(node: Root): Promise { 26 | let { repo, branch } = this.options; 27 | let originalPath = originalPathFor(this.file); 28 | 29 | if (originalPath === null) { 30 | return node; 31 | } 32 | 33 | let comment = makeComment(repo, branch, originalPath); 34 | 35 | if (hasFrontMatter(node)) { 36 | let [frontMatter, ...rest] = node.children; 37 | 38 | return { 39 | ...node, 40 | children: [frontMatter, comment, ...rest] 41 | }; 42 | } else { 43 | return { 44 | ...node, 45 | children: [comment, ...node.children] 46 | }; 47 | } 48 | } 49 | } 50 | 51 | function hasData(file: VFile): file is VFile & { data: { [key: string]: any } } { 52 | return typeof file.data === 'object' && file.data !== null; 53 | } 54 | 55 | function originalPathFor(file: VFile): Option { 56 | if (hasData(file) && typeof file.data.originalPath === 'string') { 57 | return file.data.originalPath; 58 | } else { 59 | return null; 60 | } 61 | } 62 | 63 | function hasFrontMatter(node: Root): boolean { 64 | return node.children.length > 0 && node.children[0].type === 'yaml'; 65 | } 66 | -------------------------------------------------------------------------------- /src/lib/plugins/retina-images/index.ts: -------------------------------------------------------------------------------- 1 | import { Plugin, Transformer } from 'unified'; 2 | import { Node } from 'unist'; 3 | import { VFile } from 'vfile'; 4 | import Options from './options'; 5 | import Walker from './walker'; 6 | 7 | function attacher(options: Options): Transformer { 8 | async function transform(node: Node, file: VFile): Promise { 9 | return new Walker(options, file).walk(node); 10 | } 11 | 12 | return transform; 13 | } 14 | 15 | const plugin: Plugin<[Options]> = attacher; 16 | 17 | export default plugin; 18 | -------------------------------------------------------------------------------- /src/lib/plugins/retina-images/options.ts: -------------------------------------------------------------------------------- 1 | export default interface Options { 2 | assets: string; 3 | } 4 | -------------------------------------------------------------------------------- /src/lib/plugins/retina-images/walker.ts: -------------------------------------------------------------------------------- 1 | import { exists as _exists } from 'fs'; 2 | import _imageSize from 'image-size'; 3 | import { HTML, Image } from 'mdast'; 4 | import { basename, resolve } from 'path'; 5 | import { expect } from 'ts-std'; 6 | import { promisify } from 'util'; 7 | import BaseWalker from '../../walker'; 8 | import Options from './options'; 9 | 10 | const exists = promisify(_exists); 11 | const imageSize = promisify(_imageSize); 12 | 13 | function pathFor(src: string, options: Options): string { 14 | return resolve(options.assets, `.${src}`); 15 | } 16 | 17 | async function isRetinaImage(src: string, options: Options): Promise { 18 | return basename(src, '.png').endsWith('@2x') && 19 | src.startsWith('/') && 20 | await exists(pathFor(src, options)); 21 | } 22 | 23 | function attr(v: unknown): string { 24 | let escaped = String(v).replace(/[<>"]/g, c => { 25 | switch (c) { 26 | case '<': 27 | return '<'; 28 | case '>': 29 | return '>'; 30 | case '"': 31 | return '"'; 32 | default: 33 | throw new Error(`Unknown character: \`${c}\``); 34 | } 35 | }); 36 | 37 | return JSON.stringify(escaped); 38 | } 39 | 40 | 41 | async function toImgTag(node: Image, options: Options): Promise { 42 | let sizeOfImage = await imageSize(pathFor(node.url, options)); 43 | 44 | let size = expect(sizeOfImage, 'size should be present'); 45 | let width = expect(size.width, 'width should be present'); 46 | let height = expect(size.height, 'height should be present'); 47 | 48 | let widthAttr = Math.floor(width / 2); 49 | let heightAttr = Math.floor(height / 2); 50 | 51 | let attrs = []; 52 | 53 | attrs.push(`src=${attr(node.url)}`); 54 | 55 | if (node.alt) { 56 | attrs.push(`alt=${attr(node.alt)}`); 57 | } 58 | 59 | if (node.title) { 60 | attrs.push(`title=${attr(node.title)}`); 61 | } 62 | 63 | attrs.push(`width=${attr(widthAttr)}`); 64 | attrs.push(`height=${attr(heightAttr)}`); 65 | 66 | return { 67 | type: 'html', 68 | value: ``, 69 | position: node.position, 70 | data: node.data 71 | }; 72 | } 73 | 74 | export default class RetinaImages extends BaseWalker { 75 | protected async image(node: Image): Promise { 76 | if (await isRetinaImage(node.url, this.options)) { 77 | return toImgTag(node, this.options); 78 | } else { 79 | return node; 80 | } 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/lib/plugins/run-code-blocks/cfg.ts: -------------------------------------------------------------------------------- 1 | import { Option } from 'ts-std'; 2 | import ParseError from './parse-error'; 3 | 4 | export interface ConfigurationPredicate { 5 | eval(cfg: string[]): boolean; 6 | } 7 | 8 | class ConfigurationOption implements ConfigurationPredicate { 9 | static parse(input: string, lineno: Option): ConfigurationOption { 10 | if (input === '' || /\(|\)|,/.test(input)) { 11 | throw new ParseError('expecting a single cfg predicate', lineno); 12 | } 13 | 14 | return new this(input); 15 | } 16 | 17 | private constructor(private option: string) {} 18 | 19 | eval(cfg: string[]): boolean { 20 | return cfg.includes(this.option); 21 | } 22 | } 23 | 24 | class ConfigurationAll implements ConfigurationPredicate { 25 | static parse(input: string, lineno: Option): Option { 26 | let match = /^all\((.+)\)$/.exec(input); 27 | 28 | if (match) { 29 | return new this(parsePredicates(match[1], lineno)); 30 | } else { 31 | return null; 32 | } 33 | } 34 | 35 | private constructor(private predicates: ConfigurationPredicate[]) {} 36 | 37 | eval(cfg: string[]): boolean { 38 | return this.predicates.every(p => p.eval(cfg)); 39 | } 40 | } 41 | 42 | class ConfigurationAny implements ConfigurationPredicate { 43 | static parse(input: string, lineno: Option): Option { 44 | let match = /^any\((.+)\)$/.exec(input); 45 | 46 | if (match) { 47 | return new this(parsePredicates(match[1], lineno)); 48 | } else { 49 | return null; 50 | } 51 | } 52 | 53 | private constructor(private predicates: ConfigurationPredicate[]) {} 54 | 55 | eval(cfg: string[]): boolean { 56 | return this.predicates.some(p => p.eval(cfg)); 57 | } 58 | } 59 | 60 | class ConfigurationNot implements ConfigurationPredicate { 61 | static parse(input: string, lineno: Option): Option { 62 | let match = /^not\((.+)\)$/.exec(input); 63 | 64 | if (match) { 65 | return new this(parse(match[1], lineno)); 66 | } else { 67 | return null; 68 | } 69 | } 70 | 71 | private constructor(private predicate: ConfigurationPredicate) {} 72 | 73 | eval(cfg: string[]): boolean { 74 | return !this.predicate.eval(cfg); 75 | } 76 | } 77 | 78 | export default function parse(input: string, lineno: Option): ConfigurationPredicate { 79 | return ConfigurationAll.parse(input, lineno) || 80 | ConfigurationAny.parse(input, lineno) || 81 | ConfigurationNot.parse(input, lineno) || 82 | ConfigurationOption.parse(input, lineno); 83 | } 84 | 85 | function parsePredicates(input: string, lineno: Option): ConfigurationPredicate[] { 86 | let items = input.split(',').map(item => item.trim()); 87 | 88 | // Trailing comma 89 | if (items.length > 0 && items[items.length - 1] === '') { 90 | items.pop(); 91 | } 92 | 93 | if (items.length === 0) { 94 | throw new ParseError('expecting at least one cfg predicate', lineno); 95 | } 96 | 97 | return items.map(item => parse(item, lineno)); 98 | } 99 | -------------------------------------------------------------------------------- /src/lib/plugins/run-code-blocks/commands.ts: -------------------------------------------------------------------------------- 1 | import { Option } from 'ts-std'; 2 | import parseCfg, { ConfigurationPredicate } from './cfg'; 3 | import LineNo, { end, offset } from './lineno'; 4 | import ParseError from './parse-error'; 5 | 6 | export class Command { 7 | constructor(readonly command: string, readonly display: string) {} 8 | } 9 | 10 | interface PartialCommand { 11 | cfg?: ConfigurationPredicate; 12 | display?: string; 13 | command?: string; 14 | } 15 | 16 | export function parseCommands(input: string, cfg: string[], lineno: LineNo): Command[] { 17 | let commands: Command[] = []; 18 | 19 | let current: Option = null; 20 | 21 | let initialize = (): PartialCommand => { 22 | if (current === null) { 23 | current = {}; 24 | } 25 | 26 | return current; 27 | }; 28 | 29 | let finalize = (ln: LineNo) => { 30 | if (!current) { 31 | return; 32 | } 33 | 34 | if (current.command === undefined) { 35 | throw new ParseError('expecting command', ln); 36 | } 37 | 38 | if (current.command.trimRight().endsWith('\\')) { 39 | throw new ParseError('expecting command (invalid line-continuation)', ln); 40 | } 41 | 42 | if (current.cfg && !current.cfg.eval(cfg)) { 43 | current = null; 44 | return; 45 | } 46 | 47 | commands.push(new Command(current.command, current.display || current.command)); 48 | current = null; 49 | }; 50 | 51 | let lines = input.split('\n'); 52 | 53 | for (let [i, line] of lines.entries()) { 54 | let ln = offset(lineno, i + 1); 55 | 56 | if (line.trim() === '') { 57 | finalize(ln); 58 | continue; 59 | } 60 | 61 | let config = /^#\[cfg\((.+)\)\]$/.exec(line); 62 | 63 | if (config) { 64 | let partial = initialize(); 65 | 66 | if (partial.cfg !== undefined) { 67 | throw new ParseError('only one `#[cfg(...)]` allowed per command', ln); 68 | } 69 | 70 | partial.cfg = parseCfg(config[1], ln); 71 | 72 | continue; 73 | } 74 | 75 | let display = /^#\[display\((.+)\)\]$/.exec(line); 76 | 77 | if (display) { 78 | let partial = initialize(); 79 | 80 | if (partial.display !== undefined) { 81 | throw new ParseError('only one `#[display(...)]` allowed for each command', ln); 82 | } 83 | 84 | partial.display = display[1]; 85 | 86 | continue; 87 | } 88 | 89 | let unknown = /^#\[.+\]$/.exec(line); 90 | 91 | if (unknown) { 92 | throw new ParseError(`invalid directive \`${line}\``, ln); 93 | } 94 | 95 | if (line.startsWith('#')) { 96 | finalize(ln); 97 | continue; 98 | } 99 | 100 | { 101 | let partial = initialize(); 102 | 103 | if (partial.command) { 104 | partial.command = `${partial.command}\n${line}`; 105 | } else { 106 | partial.command = line; 107 | } 108 | 109 | if (!partial.command.trimRight().endsWith('\\')) { 110 | finalize(ln); 111 | } 112 | } 113 | } 114 | 115 | finalize(lines.length); 116 | 117 | return commands; 118 | } 119 | 120 | export function parseCommand(input: string, cfg: string[], lineno: LineNo): Command { 121 | let commands = parseCommands(input, cfg, lineno); 122 | 123 | if (commands.length === 1) { 124 | return commands[0]; 125 | } else { 126 | throw new ParseError(`expecting exactly 1 command, got ${commands.length}`, end(lineno)); 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /src/lib/plugins/run-code-blocks/directives/checkpoint.ts: -------------------------------------------------------------------------------- 1 | import { exec as _exec } from 'child_process'; 2 | import { Code } from 'mdast'; 3 | import { join } from 'path'; 4 | import { assert } from 'ts-std'; 5 | import { promisify } from 'util'; 6 | import Options from '../options'; 7 | import parseArgs, { ToBool, optional } from '../parse-args'; 8 | 9 | const exec = promisify(_exec); 10 | 11 | interface Args { 12 | cwd?: string; 13 | commit?: boolean; 14 | } 15 | 16 | export default async function checkpoint(node: Code, options: Options): Promise { 17 | let args = parseArgs(node, [ 18 | optional('cwd', String), 19 | optional('commit', ToBool, true) 20 | ]); 21 | 22 | let message = node.value; 23 | let [title] = message.split('\n'); 24 | 25 | let { cwd } = options; 26 | 27 | if (args.cwd) { 28 | cwd = join(cwd, args.cwd); 29 | } 30 | 31 | console.log(`$ yarn test`); 32 | 33 | await exec('yarn test', { cwd }); 34 | 35 | if (args.commit) { 36 | console.log(`$ git commit -m ${JSON.stringify(title)}`); 37 | 38 | let promise = exec('git commit -F -', { cwd }); 39 | 40 | if (!message.endsWith('\n')) { 41 | message = `${message}\n`; 42 | } 43 | 44 | promise.child.stdin!.end(message, 'utf8'); 45 | 46 | await promise; 47 | } 48 | 49 | let { stdout } = await exec('git status --short', { cwd }); 50 | 51 | assert(stdout === '', `Unexpected dirty files!\n\n${stdout}`); 52 | 53 | return null; 54 | } 55 | -------------------------------------------------------------------------------- /src/lib/plugins/run-code-blocks/directives/command.ts: -------------------------------------------------------------------------------- 1 | import { exec as _exec } from 'child_process'; 2 | import { Code } from 'mdast'; 3 | import { join } from 'path'; 4 | import { Option, assert } from 'ts-std'; 5 | import { promisify } from 'util'; 6 | import { parseCommands } from '../commands'; 7 | import Options from '../options'; 8 | import parseArgs, { ToBool, optional } from '../parse-args'; 9 | 10 | const exec = promisify(_exec); 11 | 12 | interface Args { 13 | lang?: string; 14 | hidden?: boolean; 15 | cwd?: string; 16 | captureCommand?: boolean; 17 | captureOutput?: boolean; 18 | } 19 | 20 | export default async function command(node: Code, options: Options): Promise> { 21 | let args = parseArgs(node, [ 22 | optional('lang', String, 'shell'), 23 | optional('hidden', ToBool, false), 24 | optional('cwd', String), 25 | optional('captureCommand', ToBool, true), 26 | optional('captureOutput', ToBool, true) 27 | ]); 28 | 29 | if (args.hidden) { 30 | args.captureCommand = false; 31 | args.captureOutput = false; 32 | } 33 | 34 | assert( 35 | args.hidden === false || args.captureCommand === false || args.captureOutput === false, 36 | 'At least one of `hidden`, `captureCommand` and `captureOutput` ' + 37 | 'should be enabled, otherwise, you will have an empty code block!' 38 | ); 39 | 40 | let { cwd } = options; 41 | 42 | if (args.cwd) { 43 | cwd = join(cwd, args.cwd); 44 | } 45 | 46 | let output: string[] = []; 47 | 48 | for (let { command: cmd, display } of parseCommands(node.value, options.cfg, node)) { 49 | console.log(`$ ${cmd}`); 50 | 51 | if (args.captureCommand) { 52 | output.push(`$ ${display}`); 53 | } 54 | 55 | let { stdout } = await exec(cmd, { cwd }); 56 | 57 | if (args.captureOutput) { 58 | output.push(stdout); 59 | } 60 | } 61 | 62 | if (args.hidden) { 63 | return null; 64 | } else { 65 | return { 66 | ...node, 67 | lang: args.lang, 68 | meta: undefined, 69 | value: output.join('\n').trimRight() 70 | }; 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/lib/plugins/run-code-blocks/directives/file/copy.ts: -------------------------------------------------------------------------------- 1 | import { lstat as _lstat, readFile as _readFile } from 'fs'; 2 | import { Code } from 'mdast'; 3 | import mkdirp from 'mkdirp'; 4 | import { ncp as _ncp } from 'ncp'; 5 | import { basename, dirname, join } from 'path'; 6 | import { Option } from 'ts-std'; 7 | import { promisify } from 'util'; 8 | import Options from '../../options'; 9 | import parseArgs, { ToBool, optional, required } from '../../parse-args'; 10 | 11 | const lstat = promisify(_lstat); 12 | const ncp = promisify(_ncp); 13 | const readFile = promisify(_readFile); 14 | 15 | interface Args { 16 | lang?: string; 17 | hidden?: boolean; 18 | cwd?: string; 19 | src: string; 20 | filename: string; 21 | } 22 | 23 | export default async function copyFile(node: Code, options: Options): Promise> { 24 | let args = parseArgs(node, [ 25 | optional('lang', String), 26 | optional('hidden', ToBool, false), 27 | optional('cwd', String), 28 | required('src', String), 29 | required('filename', String) 30 | ]); 31 | 32 | let src = join(options.assets, args.src); 33 | let destDir = options.cwd; 34 | let destPath: string; 35 | 36 | if (args.cwd) { 37 | destDir = join(destDir, args.cwd); 38 | } 39 | 40 | let stats = await lstat(src); 41 | let isFile = stats.isFile(); 42 | let isDirectory = stats.isDirectory(); 43 | 44 | if (isFile) { 45 | console.log(`$ cp ${join('src', 'assets', args.src)} ${ args.filename }`); 46 | destDir = join(destDir, dirname(args.filename)); 47 | destPath = join(destDir, basename(args.filename)); 48 | } else if (isDirectory) { 49 | console.log(`$ cp -r ${join('src', 'assets', args.src)} ${ args.filename }`); 50 | destDir = destPath = join(destDir, args.filename); 51 | } else { 52 | throw new Error(`\`${src}\` is neither a regular file or a directory`); 53 | } 54 | 55 | await mkdirp(destDir, {}); 56 | await ncp(src, destPath); 57 | 58 | if (args.hidden) { 59 | return null 60 | } else { 61 | let value = node.value; 62 | let meta: string | undefined; 63 | 64 | if (isFile) { 65 | value = value || await readFile(destPath, { encoding: 'utf8' }); 66 | meta = `{ data-filename="${args.filename}" }`; 67 | } else if (isDirectory && !value) { 68 | throw new Error('TODO: tree output is not yet implemented, see https://github.com/MrRaindrop/tree-cli/issues/15') 69 | } 70 | 71 | return { 72 | ...node, 73 | lang: args.lang, 74 | meta, 75 | value: value.trimRight() 76 | }; 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/lib/plugins/run-code-blocks/directives/file/create.ts: -------------------------------------------------------------------------------- 1 | import { writeFile as _writeFile } from 'fs'; 2 | import { Code } from 'mdast'; 3 | import mkdirp from 'mkdirp'; 4 | import { basename, dirname, join } from 'path'; 5 | import { Option } from 'ts-std'; 6 | import { promisify } from 'util'; 7 | import Options from '../../options'; 8 | import parseArgs, { ToBool, optional, required } from '../../parse-args'; 9 | 10 | const writeFile = promisify(_writeFile); 11 | 12 | interface Args { 13 | lang?: string; 14 | hidden?: boolean; 15 | cwd?: string; 16 | filename: string; 17 | } 18 | 19 | export default async function createFile(node: Code, options: Options): Promise> { 20 | let args = parseArgs(node, [ 21 | optional('lang', String), 22 | optional('hidden', ToBool, false), 23 | optional('cwd', String), 24 | required('filename', String) 25 | ]); 26 | 27 | let content = node.value; 28 | 29 | console.log(`$ cat - > ${args.filename}\n${content}`); 30 | 31 | let dir = options.cwd; 32 | 33 | if (args.cwd) { 34 | dir = join(dir, args.cwd); 35 | } 36 | 37 | dir = join(dir, dirname(args.filename)); 38 | 39 | await mkdirp(dir, {}); 40 | 41 | let path = join(dir, basename(args.filename)); 42 | 43 | if (!content.endsWith('\n')) { 44 | content = `${content}\n`; 45 | } 46 | 47 | await writeFile(path, content, { encoding: 'utf8' }); 48 | 49 | if (args.hidden) { 50 | return null; 51 | } else { 52 | return { 53 | ...node, 54 | lang: args.lang, 55 | meta: `{ data-filename="${args.filename}" }`, 56 | value: content.trimRight() 57 | }; 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/lib/plugins/run-code-blocks/directives/file/patch.ts: -------------------------------------------------------------------------------- 1 | import { exec as _exec } from 'child_process'; 2 | import { Code } from 'mdast'; 3 | import { join } from 'path'; 4 | import { Option, assert } from 'ts-std'; 5 | import { promisify } from 'util'; 6 | import Options from '../../options'; 7 | import parseArgs, { ToBool, optional } from '../../parse-args'; 8 | 9 | const exec = promisify(_exec); 10 | 11 | interface Args { 12 | lang?: string; 13 | hidden?: boolean; 14 | cwd?: string; 15 | filename?: string; 16 | } 17 | 18 | function formatPatch(patch: string, filename?: string): string { 19 | if (!patch.startsWith('--- ')) { 20 | assert(!!filename, `\`filename\` is required, unless it is already included in the patch`); 21 | patch = `--- a/${filename}\n+++ b/${filename}\n${patch}`; 22 | } 23 | 24 | return `${patch}\n`; 25 | } 26 | 27 | async function applyPatch(patch: string, cwd: string): Promise { 28 | let promise = exec('git apply', { cwd }); 29 | promise.child.stdin!.end(patch, 'utf-8'); 30 | await promise; 31 | } 32 | 33 | async function generateDiff(filename: string, cwd: string): Promise<{ content: string, diff: string }> { 34 | let { stdout } = await exec(`git diff -U99999 -- ${JSON.stringify(filename)}`, { cwd }); 35 | 36 | assert(!!stdout && stdout !== '\n', `Expecting diff at ${JSON.stringify(filename)}, but was identical`); 37 | 38 | let diff: string[] = []; 39 | 40 | let content = stdout.trimRight().split('\n').filter(line => !( 41 | line.startsWith('diff ') || 42 | line.startsWith('index ') || 43 | line.startsWith('--- ') || 44 | line.startsWith('+++ ') || 45 | line.startsWith('@@ ') || 46 | line === '\\ No newline at end of file' 47 | )).map((line, index) => { 48 | if (line.startsWith('+')) { 49 | diff.push(`+${index + 1}`); 50 | } else if (line.startsWith('-')) { 51 | diff.push(`-${index + 1}`); 52 | } else { 53 | assert(line.startsWith(' '), `diff lines should start with \`[ -+]\`, found ${JSON.stringify(line)}`); 54 | } 55 | 56 | return line.substr(1); 57 | }); 58 | 59 | return { 60 | content: content.join('\n'), 61 | diff: diff.join(',') 62 | }; 63 | } 64 | 65 | export default async function patchFile(node: Code, options: Options): Promise> { 66 | let args = parseArgs(node, [ 67 | optional('lang', String), 68 | optional('hidden', ToBool, false), 69 | optional('cwd', String), 70 | optional('filename', String) 71 | ]); 72 | 73 | assert(args.hidden || !!args.filename, `\`filename\` is required, unless \`hidden\` is true`); 74 | 75 | let formatted = formatPatch(node.value, args.filename); 76 | 77 | console.log(`$ git apply -\n${formatted.trimRight()}`); 78 | 79 | let cwd = options.cwd; 80 | 81 | if (args.cwd) { 82 | cwd = join(cwd, args.cwd); 83 | } 84 | 85 | await applyPatch(formatted, cwd); 86 | 87 | if (args.hidden) { 88 | return null; 89 | } else { 90 | let { content, diff } = await generateDiff(args.filename!, cwd); 91 | 92 | return { 93 | ...node, 94 | lang: args.lang, 95 | meta: `{ data-filename="${args.filename}" data-diff="${diff}" }`, 96 | value: content 97 | }; 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /src/lib/plugins/run-code-blocks/directives/file/show.ts: -------------------------------------------------------------------------------- 1 | import { lstat as _lstat, readFile as _readFile } from 'fs'; 2 | import { Code } from 'mdast'; 3 | import { join } from 'path'; 4 | import { Option } from 'ts-std'; 5 | import { promisify } from 'util'; 6 | import Options from '../../options'; 7 | import parseArgs, { optional, required } from '../../parse-args'; 8 | 9 | const lstat = promisify(_lstat); 10 | const readFile = promisify(_readFile); 11 | 12 | interface Args { 13 | lang?: string; 14 | cwd?: string; 15 | filename: string; 16 | } 17 | 18 | export default async function showFile(node: Code, options: Options): Promise> { 19 | let args = parseArgs(node, [ 20 | optional('lang', String), 21 | optional('cwd', String), 22 | required('filename', String) 23 | ]); 24 | 25 | let dir = options.cwd; 26 | 27 | if (args.cwd) { 28 | dir = join(dir, args.cwd); 29 | } 30 | 31 | let path = join(dir, args.filename); 32 | 33 | let stats = await lstat(path); 34 | let isFile = stats.isFile(); 35 | let isDirectory = stats.isDirectory(); 36 | 37 | let meta: string | undefined; 38 | let value: string; 39 | 40 | if (isFile) { 41 | console.log(`$ cat ${path}`); 42 | meta = `{ data-filename="${args.filename}" }`; 43 | value = await readFile(path, { encoding: 'utf8' }); 44 | } else if (isDirectory) { 45 | console.log(`$ tree ${path}`); 46 | throw new Error('TODO: tree output is not yet implemented, see https://github.com/MrRaindrop/tree-cli/issues/15') 47 | } else { 48 | throw new Error(`\`${path}\` is neither a regular file or a directory`); 49 | } 50 | 51 | return { 52 | ...node, 53 | lang: args.lang, 54 | meta, 55 | value: value.trimRight() 56 | }; 57 | } 58 | -------------------------------------------------------------------------------- /src/lib/plugins/run-code-blocks/directives/ignore.ts: -------------------------------------------------------------------------------- 1 | import { Code } from 'mdast'; 2 | import Options from '../options'; 3 | 4 | export default async function ignore(_node: Code, _options: Options): Promise { 5 | return null; 6 | } 7 | -------------------------------------------------------------------------------- /src/lib/plugins/run-code-blocks/directives/pause.ts: -------------------------------------------------------------------------------- 1 | import { Code } from 'mdast'; 2 | import readline from 'readline'; 3 | import { VFile } from 'vfile'; 4 | import Options from '../options'; 5 | 6 | async function prompt(message: string): Promise { 7 | console.log(`\n${message}\n`); 8 | 9 | await new Promise(resolve => { 10 | let rl = readline.createInterface({ 11 | input: process.stdin, 12 | output: process.stdout 13 | }); 14 | 15 | rl.question('Press ENTER when ready to resume', () => { 16 | rl.close(); 17 | resolve(); 18 | }); 19 | }); 20 | 21 | console.log('\nResuming\n'); 22 | } 23 | 24 | export default async function pause({ value, position }: Code, _options: Options, { path }: VFile): Promise { 25 | if (value) { 26 | console.log(value); 27 | } 28 | 29 | let location = ''; 30 | 31 | if (path) { 32 | location = `${location} at ${path}` 33 | } 34 | 35 | if (position) { 36 | location = `${location} on line ${position.start.line}`; 37 | } 38 | 39 | await prompt(`Build paused${location}...`); 40 | 41 | return null; 42 | } 43 | -------------------------------------------------------------------------------- /src/lib/plugins/run-code-blocks/directives/screenshot.ts: -------------------------------------------------------------------------------- 1 | import { exec as _exec } from 'child_process'; 2 | import { Code, Image } from 'mdast'; 3 | import mkdirp from 'mkdirp'; 4 | import { basename, extname, join, sep } from 'path'; 5 | import { ScreenshotOptions, Viewport } from 'puppeteer'; 6 | import { JSONObject, JSONValue, assert } from 'ts-std'; 7 | import { promisify } from 'util'; 8 | import { VFile } from 'vfile'; 9 | import Options from '../options'; 10 | import parseArgs, { ToBool, optional, required } from '../parse-args'; 11 | 12 | const exec = promisify(_exec); 13 | 14 | interface Args { 15 | filename: string; 16 | alt: string; 17 | width: number; 18 | height?: number; 19 | x?: number; 20 | y?: number; 21 | retina?: boolean; 22 | } 23 | 24 | function js(v: JSONValue): string { 25 | return JSON.stringify(v); 26 | } 27 | 28 | function compile(steps: string, path: string, args: Args): string { 29 | let { width, height, x, y, retina } = args; 30 | 31 | let viewport: Viewport & JSONObject = { 32 | width, 33 | height: height || 100, 34 | deviceScaleFactor: retina ? 2 : 1 35 | }; 36 | 37 | let options: ScreenshotOptions & JSONObject; 38 | 39 | if (height === 0) { 40 | options = { 41 | path, 42 | type: 'png', 43 | fullPage: true 44 | }; 45 | } else { 46 | options = { 47 | path, 48 | type: 'png', 49 | clip: { 50 | width: width!, 51 | height: height!, 52 | x: x!, 53 | y: y! 54 | } 55 | }; 56 | } 57 | 58 | let script = [ 59 | `const puppeteer = require('puppeteer'); 60 | 61 | async function main() { 62 | let browser = await puppeteer.launch(); 63 | let page = await browser.newPage(); 64 | await page.setViewport(${js(viewport)}); 65 | ` 66 | ]; 67 | 68 | for (let step of steps.split('\n')) { 69 | if (step === '' || step.startsWith('#')) { 70 | continue; 71 | } 72 | 73 | let [action, ...rest] = step.split(/(\s+)/); 74 | let params = rest.join('').trimStart().split(/,\s*/); 75 | 76 | switch (action) { 77 | case 'click': 78 | if (params[1] === 'true') { 79 | script.push(` await Promise.all([`); 80 | script.push(` page.waitForNavigation({ waitUtil: 'networkidle0' }),`); 81 | script.push(` page.click(${js(params[0])})`); 82 | script.push(` ]);`); 83 | } else { 84 | script.push(` await page.click(${js(params[0])});`); 85 | } 86 | break; 87 | case 'type': 88 | script.push(` await page.type(${js(params[0])}, ${js(params[1])});`); 89 | break; 90 | case 'eval': 91 | script.push(` await page.evaluate(${js(params[0])});`); 92 | break; 93 | case 'visit': 94 | script.push(` await page.goto(${js(params[0])}, { waitUtil: 'networkidle0' });`); 95 | break; 96 | case 'wait': 97 | script.push(` await page.waitForSelector(${js(params[0])});`); 98 | break; 99 | default: 100 | throw new Error(`Unknown action: ${action}`); 101 | } 102 | } 103 | 104 | script.push( 105 | ` 106 | await page.$$eval('img', async imgs => { 107 | for (let img of imgs) { 108 | if (!img.complete) { 109 | await new Promise((resolve, reject) => { 110 | img.onload = resolve; 111 | img.onerror = () => reject(\`failed to load \${img.src}\`); 112 | }); 113 | } else if(img.naturalHeight === 0) { 114 | return Promise.reject(\`failed to load \${img.src}\`); 115 | } 116 | } 117 | }); 118 | await page.screenshot(${js(options)}); 119 | await browser.close(); 120 | } 121 | 122 | main().catch(error => { 123 | console.error(error); 124 | process.exit(1); 125 | }); 126 | ` 127 | ); 128 | 129 | return script.join('\n'); 130 | } 131 | 132 | export default async function screenshot(node: Code, options: Options, vfile: VFile): Promise { 133 | let args = parseArgs(node, [ 134 | required('filename', String), 135 | required('alt', String), 136 | required('width', Number), 137 | optional('height', Number, 0), 138 | optional('x', Number, 0), 139 | optional('y', Number, 0), 140 | optional('retina', ToBool, false) 141 | ]); 142 | 143 | assert(!!vfile.dirname, 'unknown dirname'); 144 | assert(!!vfile.basename, 'unknown basename'); 145 | assert(extname(args.filename) === '.png', `filename must have .png extnsion (${args.filename})`); 146 | assert(args.width > 0, `width must be positive`); 147 | assert(args.height !== 0 || (args.x === 0 && args.y === 0), `cannot specify x and y for fullscreen screenshots (height=0)`); 148 | 149 | let dirs = [...vfile.dirname!.split(sep), basename(vfile.basename!, '.md')]; 150 | let dir = join(options.assets, 'images', ...dirs); 151 | 152 | let { filename } = args; 153 | 154 | if (args.retina) { 155 | filename = `${basename(filename, '.png')}@2x.png`; 156 | } 157 | 158 | await mkdirp(dir, {}); 159 | 160 | let path = join(dir, filename); 161 | 162 | let script = compile(node.value, path, args); 163 | 164 | console.log(`$ node -\n${script.trimRight()}`); 165 | 166 | let p = exec(`node -`); 167 | 168 | p.child.stdin!.write(script, 'utf8'); 169 | p.child.stdin!.end(); 170 | 171 | await p; 172 | 173 | let src = `/images/${dirs.join('/')}/${filename}`; 174 | let { alt } = args; 175 | let { position, data } = node; 176 | 177 | return { 178 | type: 'image', 179 | url: src, 180 | alt, 181 | position, 182 | data 183 | }; 184 | } 185 | -------------------------------------------------------------------------------- /src/lib/plugins/run-code-blocks/directives/server/start.ts: -------------------------------------------------------------------------------- 1 | import { Code } from 'mdast'; 2 | import { join } from 'path'; 3 | import { Option, assert } from 'ts-std'; 4 | import { parseCommand } from '../../commands'; 5 | import Options from '../../options'; 6 | import parseArgs, { ToBool, optional } from '../../parse-args'; 7 | import Servers from '../../servers'; 8 | 9 | interface Args { 10 | id?: string; 11 | lang?: string; 12 | hidden?: boolean; 13 | cwd?: string; 14 | expect?: string; 15 | timeout?: number; 16 | captureCommand?: boolean; 17 | captureOutput?: boolean; 18 | } 19 | 20 | export default async function startServer(node: Code, options: Options, servers: Servers): Promise> { 21 | let args = parseArgs(node, [ 22 | optional('id', String), 23 | optional('lang', String, 'shell'), 24 | optional('hidden', ToBool, false), 25 | optional('cwd', String), 26 | optional('expect', String), 27 | optional('timeout', Number), 28 | optional('captureCommand', ToBool, true), 29 | optional('captureOutput', ToBool) 30 | ]); 31 | 32 | if (args.hidden) { 33 | args.captureCommand = false; 34 | args.captureOutput = false; 35 | } 36 | 37 | if (args.expect || args.timeout) { 38 | if (args.captureOutput === undefined && args.hidden === false) { 39 | args.captureOutput = true; 40 | } 41 | } 42 | 43 | if (args.captureOutput) { 44 | assert( 45 | !!args.expect || !!args.timeout, 46 | 'at least one of `expect` or `timeout` must be set when using ' + 47 | '`captureOutput` in `run:server:start' 48 | ); 49 | } 50 | 51 | assert( 52 | args.hidden === false || args.captureCommand === false || args.captureOutput === false, 53 | 'At least one of `hidden`, `captureCommand` and `captureOutput` ' + 54 | 'should be enabled, otherwise, you will have an empty code block!' 55 | ); 56 | 57 | let { command, display } = parseCommand(node.value, options.cfg, node); 58 | let id = args.id || display; 59 | let { cwd } = options; 60 | 61 | if (args.cwd) { 62 | cwd = join(cwd, args.cwd); 63 | } 64 | 65 | let output: string[] = []; 66 | 67 | let server = servers.add(id, command, cwd); 68 | 69 | console.log(`$ ${command}`); 70 | 71 | if (args.captureCommand) { 72 | output.push(`$ ${display}`); 73 | } 74 | 75 | let stdout = await server.start(args.expect); 76 | 77 | if (args.captureOutput && stdout) { 78 | output.push(stdout); 79 | } 80 | 81 | if (args.hidden) { 82 | return null; 83 | } else { 84 | return { 85 | ...node, 86 | lang: args.lang, 87 | meta: undefined, 88 | value: output.join('\n').trimRight() 89 | }; 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/lib/plugins/run-code-blocks/directives/server/stop.ts: -------------------------------------------------------------------------------- 1 | import { Code } from 'mdast'; 2 | import { parseCommand } from '../../commands'; 3 | import Options from '../../options'; 4 | import parseArgs, { optional } from '../../parse-args'; 5 | import Servers from '../../servers'; 6 | 7 | interface Args { 8 | id?: string; 9 | } 10 | 11 | export default async function stopServer(node: Code, options: Options, servers: Servers): Promise { 12 | let args = parseArgs(node, [ 13 | optional('id', String) 14 | ]); 15 | 16 | let id = args.id || parseCommand(node.value, options.cfg, node).display; 17 | let server = servers.remove(id); 18 | 19 | try { 20 | console.log(`$ kill ${server.pid}`); 21 | await server.stop(); 22 | } catch (error) { 23 | await server.kill(); 24 | throw error; 25 | } 26 | 27 | return null; 28 | } 29 | -------------------------------------------------------------------------------- /src/lib/plugins/run-code-blocks/index.ts: -------------------------------------------------------------------------------- 1 | import { Plugin, Transformer } from 'unified'; 2 | import { Node } from 'unist'; 3 | import { VFile } from 'vfile'; 4 | import Options from './options'; 5 | import Walker from './walker'; 6 | 7 | function normalizeOptions(options: Options): Options { 8 | let cfg = [...options.cfg, process.platform]; 9 | 10 | if (process.platform === 'win32') { 11 | cfg.push('windows'); 12 | } else { 13 | cfg.push('unix'); 14 | } 15 | 16 | return { ...options, cfg }; 17 | } 18 | 19 | function attacher(options: Options): Transformer { 20 | async function transform(node: Node, file: VFile): Promise { 21 | return new Walker(normalizeOptions(options), file).walk(node); 22 | } 23 | 24 | return transform; 25 | } 26 | 27 | const plugin: Plugin<[Options]> = attacher; 28 | 29 | export default plugin; 30 | -------------------------------------------------------------------------------- /src/lib/plugins/run-code-blocks/lineno.ts: -------------------------------------------------------------------------------- 1 | import { Option } from 'ts-std'; 2 | import { Node } from 'unist'; 3 | 4 | type LineNo = Option; 5 | export default LineNo; 6 | 7 | export function isNumber(lineno: LineNo): lineno is number { 8 | return typeof lineno === 'number'; 9 | } 10 | 11 | export function isNode(lineno: LineNo): lineno is Node { 12 | return lineno !== null && !isNumber(lineno); 13 | } 14 | 15 | export function start(lineno: LineNo): Option { 16 | if (isNumber(lineno)) { 17 | return lineno; 18 | } else if (isNode(lineno) && lineno.position) { 19 | return lineno.position.start.line; 20 | } else { 21 | return null; 22 | } 23 | } 24 | 25 | export function end(lineno: LineNo): Option { 26 | if (isNumber(lineno)) { 27 | return lineno; 28 | } else if (isNode(lineno) && lineno.position) { 29 | return lineno.position.end.line; 30 | } else { 31 | return null; 32 | } 33 | } 34 | 35 | export function offset(lineno: LineNo, i: number): Option { 36 | let v = start(lineno); 37 | return v === null ? null : v + i; 38 | } 39 | -------------------------------------------------------------------------------- /src/lib/plugins/run-code-blocks/options.ts: -------------------------------------------------------------------------------- 1 | export default interface Options { 2 | cfg: string[]; 3 | cwd: string; 4 | assets: string; 5 | } 6 | -------------------------------------------------------------------------------- /src/lib/plugins/run-code-blocks/parse-args.ts: -------------------------------------------------------------------------------- 1 | import { Code } from 'mdast'; 2 | import { assert, dict } from 'ts-std'; 3 | import ParseError from './parse-error'; 4 | 5 | export type Transform = (input: From) => To; 6 | 7 | export type KeyTransform = [string, Transform]; 8 | 9 | export function optional(key: string, transform: Transform, defaultValue?: To): KeyTransform { 10 | return [key, input => { 11 | if (input === undefined) { 12 | return defaultValue; 13 | } else { 14 | return transform(input); 15 | } 16 | }]; 17 | } 18 | 19 | export function required(key: string, transform: Transform): KeyTransform { 20 | return [key, input => { 21 | assert(input !== undefined, `${key} is required`); 22 | return transform(input!); 23 | }]; 24 | } 25 | 26 | export function ToBool(input: string): boolean { 27 | return input === 'true'; 28 | } 29 | 30 | export type KeyTransforms = KeyTransform[]; 31 | 32 | export default function parseArgs(node: Code, transforms: KeyTransforms): Args { 33 | let { lang, meta } = node; 34 | let parsed = dict(); 35 | let validKeys = transforms.map(([key]) => key); 36 | 37 | if (meta) { 38 | let pattern = /(?:^|\s+)(\w+)=(?:([^'"\s]\S*)|(?:"([^"]*)")|(?:'([^']*)'))/g; 39 | 40 | while (pattern.lastIndex < meta.length) { 41 | let match = pattern.exec(meta); 42 | 43 | if (match === null) { 44 | throw new ParseError(`invalid arguments for \`${lang}\`: ${meta}`, node); 45 | } 46 | 47 | let [, key, v1, v2, v3] = match; 48 | let value = v1 || v2 || v3; 49 | 50 | if (!validKeys.includes(key)) { 51 | throw new ParseError(`\`${key}\` is not a valid argument for \`${lang}\``, node); 52 | } 53 | 54 | parsed[key] = value; 55 | } 56 | } 57 | 58 | let args = dict() as Partial; 59 | 60 | for (let [key, transform] of transforms) { 61 | args[key as keyof Args] = transform!(parsed[key]) as any; 62 | } 63 | 64 | return args as Args; 65 | } 66 | -------------------------------------------------------------------------------- /src/lib/plugins/run-code-blocks/parse-error.ts: -------------------------------------------------------------------------------- 1 | import LineNo, { start } from './lineno'; 2 | 3 | export default class ParseError extends Error { 4 | constructor(reason: string, lineno: LineNo) { 5 | let line = start(lineno); 6 | 7 | if (line) { 8 | super(`${reason} (on line ${line})`); 9 | } else { 10 | super(reason); 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/lib/plugins/run-code-blocks/server.ts: -------------------------------------------------------------------------------- 1 | import { ChildProcess, spawn } from 'child_process'; 2 | import { Option, assert } from 'ts-std'; 3 | 4 | const WINDOWS = process.platform === 'win32'; 5 | 6 | export enum Status { 7 | Starting = 'STARTING', 8 | Started = 'STARTED', 9 | Stopping = 'STOPPING', 10 | Stopped = 'STOPPED' 11 | } 12 | 13 | export default class Server { 14 | private status: Status = Status.Stopped; 15 | private process: Option = null; 16 | private promise: Option> = null; 17 | private _stdout: string[] = []; 18 | private _stderr: string[] = []; 19 | 20 | constructor( 21 | readonly id: string, 22 | private cmd: string, 23 | private cwd: string 24 | ) { 25 | // TODO 26 | } 27 | 28 | get pid(): null | number | undefined { 29 | return this.process && this.process.pid; 30 | } 31 | 32 | get stdout(): Option { 33 | let { _stdout } = this; 34 | 35 | if (_stdout.length === 0) { 36 | return null; 37 | } else { 38 | return _stdout.join('').trimRight(); 39 | } 40 | } 41 | 42 | get stderr(): Option { 43 | let { _stderr } = this; 44 | 45 | if (_stderr.length === 0) { 46 | return null; 47 | } else { 48 | return _stderr.join('').trimRight(); 49 | } 50 | } 51 | 52 | async start(expect: Option = null, timeout: Option = null): Promise> { 53 | assert(this.status === Status.Stopped, `Cannot start server \`${this.id}\` since it is in the ${this.status} state`); 54 | assert(this.process === null, `this.process was not null (${this.process})`); 55 | assert(this.promise === null, `this.process was not null (${this.promise})`); 56 | 57 | this.status = Status.Starting; 58 | this._stdout = []; 59 | this._stderr = []; 60 | 61 | let { id, cmd, cwd } = this; 62 | 63 | let process = this.process = spawn(cmd, { 64 | cwd, 65 | shell: true, 66 | // On Windows, if we set this to true, the stdio pipes do not work 67 | detached: !WINDOWS 68 | }); 69 | 70 | return new Promise>((resolveStart, rejectStart) => { 71 | let settled = false; 72 | 73 | let didComplete: Option<() => void> = null; 74 | let didFail: Option<(error: Error) => void> = null; 75 | 76 | let didStart = () => { 77 | assert(this.status === Status.Starting, `Not in STARTING state: ${this.status}`); 78 | assert(this.process === process, `this.process was reassigned`); 79 | assert(this.promise === null, `this.process was not null (${this.promise})`); 80 | assert(!settled, 'cannot resolve, already settled'); 81 | 82 | settled = true; 83 | 84 | this.status = Status.Started; 85 | this.promise = new Promise((resolveStop, rejectStop) => { 86 | didComplete = () => { 87 | this.status = Status.Stopped; 88 | this.process = null; 89 | this.promise = null; 90 | 91 | resolveStop(); 92 | }; 93 | 94 | didFail = error => { 95 | this.status = Status.Stopped; 96 | this.process = null; 97 | this.promise = null; 98 | 99 | rejectStop(error); 100 | }; 101 | }); 102 | 103 | resolveStart(this.stdout); 104 | }; 105 | 106 | let didFailToStart = async (error: Error) => { 107 | assert(this.status === Status.Starting, `Not in STARTING state: ${this.status}`); 108 | assert(this.process === process, `this.process was reassigned`); 109 | assert(!settled, 'cannot resolve, already settled'); 110 | 111 | settled = true; 112 | 113 | this.status = Status.Stopped; 114 | this.process = null; 115 | this.promise = null; 116 | 117 | await this.kill(); 118 | 119 | rejectStart(error); 120 | }; 121 | 122 | process.stdout.on('data', chunk => { 123 | assert(this.process === null || this.process === process, `this.process was reassigned`); 124 | this._stdout.push(chunk); 125 | 126 | if (expect && this.status === Status.Starting) { 127 | if (this.stdout!.includes(expect)) { 128 | didStart(); 129 | } 130 | } 131 | }); 132 | 133 | process.stderr.on('data', chunk => { 134 | assert(this.process === null || this.process === process, `this.process was reassigned`); 135 | this._stderr.push(chunk); 136 | }); 137 | 138 | process.on('exit', (code, signal) => { 139 | assert(this.process === process, `this.process was reassigned`); 140 | 141 | let { status } = this; 142 | 143 | // On Windows, we can only forcefully terminate at the moment 144 | if (status === Status.Stopping && (WINDOWS || code === 0 || signal === 'SIGTERM')) { 145 | assert(didComplete !== null, 'didComplete must not be null'); 146 | didComplete!(); 147 | } else { 148 | let reason = (code === null) 149 | ? `killed with ${signal}` 150 | : `exited with exit code ${code}`; 151 | 152 | let error = new Error( 153 | `Server \`${id}\` unexpectedly ${reason} while in ${status} state! 154 | 155 | ====== STDOUT ====== 156 | 157 | ${this.stdout || '(No output)'} 158 | 159 | ====== STDOUT ====== 160 | 161 | ${this.stderr || '(No output)'} 162 | 163 | ==================== 164 | ` 165 | ); 166 | 167 | if (status === Status.Starting) { 168 | didFailToStart(error); 169 | } else { 170 | assert(didFail !== null, 'didFail must not be null'); 171 | didFail!(error); 172 | } 173 | } 174 | }); 175 | 176 | if (timeout) { 177 | setTimeout(() => { 178 | assert(this.process === process, `this.process was reassigned`); 179 | 180 | if (this.status === Status.Starting) { 181 | if (expect) { 182 | didFailToStart(new Error( 183 | `Timed out while waiting for server \`${id}\` to start. 184 | Expecting to find \`${expect}\` from STDOUT, gave up after ${timeout} seconds. 185 | 186 | ====== STDOUT ====== 187 | 188 | ${this.stdout || '(No output)'} 189 | 190 | ====== STDOUT ====== 191 | 192 | ${this.stderr || '(No output)'} 193 | 194 | ==================== 195 | ` 196 | )); 197 | } else { 198 | didStart(); 199 | } 200 | } 201 | }, timeout); 202 | } 203 | 204 | if (!expect && !timeout) { 205 | setTimeout(didStart, 0); 206 | } 207 | }); 208 | } 209 | 210 | async stop(): Promise { 211 | assert(this.status === Status.Started, `Cannot stop server \`${this.id}\` since it is in the ${this.status} state`); 212 | assert(this.pid !== null, `Unknown pid for server \`${this.id}\``); 213 | 214 | this.status = Status.Stopping; 215 | this.sendKill(); 216 | 217 | await this.promise; 218 | } 219 | 220 | async kill(): Promise { 221 | let { pid, promise } = this; 222 | 223 | if (pid) { 224 | this.status = Status.Stopping; 225 | this.sendKill(true); 226 | 227 | if (promise) { 228 | try { 229 | await promise; 230 | } catch { 231 | // ignore 232 | } 233 | } 234 | } 235 | } 236 | 237 | private sendKill(force = false) { 238 | assert(this.pid !== null, 'pid cannot be null'); 239 | 240 | let pid = this.pid!; 241 | 242 | if (WINDOWS) { 243 | // On Windows, we can only forcefully terminate at the moment 244 | spawn('taskkill', ['/pid', `${pid}`, '/t', '/f']); 245 | } else { 246 | process.kill(-pid, force ? 'SIGKILL' : 'SIGTERM'); 247 | } 248 | } 249 | } 250 | -------------------------------------------------------------------------------- /src/lib/plugins/run-code-blocks/servers.ts: -------------------------------------------------------------------------------- 1 | import { Dict, assert, dict } from 'ts-std'; 2 | import Server from './server'; 3 | 4 | export type ServersCallback = (servers: Servers) => T | Promise; 5 | 6 | export default class Servers { 7 | static async run(callback: ServersCallback): Promise { 8 | let servers = new Servers(); 9 | let result: T; 10 | let error; 11 | 12 | try { 13 | result = await callback(servers); 14 | } catch (e) { 15 | error = e; 16 | } finally { 17 | let unstopped = await servers.killAll(); 18 | 19 | if (unstopped.length > 0) { 20 | error = error || new Error( 21 | 'The following server(s) were not shutdown properly.\n\n' + 22 | unstopped.map(({ id }) => `- ${id}`).join('\n') + '\n\n' + 23 | 'Did you forget to include a `run:server:stop`?' 24 | ); 25 | } 26 | } 27 | 28 | if (error) { 29 | throw error; 30 | } else { 31 | return result!; 32 | } 33 | } 34 | 35 | private storage: Dict = dict(); 36 | 37 | private constructor() {} 38 | 39 | add(id: string, cmd: string, cwd: string): Server { 40 | let server = this.storage[id]; 41 | 42 | if (server) { 43 | if (server.pid) { 44 | assert(false, `server \`${id}\` already exists (pid ${server.pid})`); 45 | } else { 46 | assert(false, `server \`${id}\` already exists`); 47 | } 48 | } 49 | 50 | return this.storage[id] = new Server(id, cmd, cwd); 51 | } 52 | 53 | has(id: string): boolean { 54 | return id in this.storage; 55 | } 56 | 57 | fetch(id: string): Server { 58 | assert(this.has(id), `server \`${id}\` does not exist`); 59 | return this.storage[id]!; 60 | } 61 | 62 | remove(id: string): Server { 63 | let server = this.fetch(id); 64 | delete this.storage[id]; 65 | return server; 66 | } 67 | 68 | private async killAll(): Promise { 69 | let { storage } = this; 70 | this.storage = dict(); 71 | 72 | let servers = Object.values(storage).map(s => s!); 73 | let promises = servers.map(s => s.kill()); 74 | 75 | for (let p of promises) { 76 | await p; 77 | } 78 | 79 | return servers; 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/lib/plugins/run-code-blocks/walker.ts: -------------------------------------------------------------------------------- 1 | import { Code, Root } from 'mdast'; 2 | import { Option, assert } from 'ts-std'; 3 | import { Node } from 'unist'; 4 | 5 | import BaseWalker from '../../walker'; 6 | 7 | import checkpoint from './directives/checkpoint'; 8 | import command from './directives/command'; 9 | import copyFile from './directives/file/copy'; 10 | import createFile from './directives/file/create'; 11 | import patchFile from './directives/file/patch'; 12 | import showFile from './directives/file/show'; 13 | import ignore from './directives/ignore'; 14 | import pause from './directives/pause'; 15 | import screenshot from './directives/screenshot'; 16 | import startServer from './directives/server/start'; 17 | import stopServer from './directives/server/stop'; 18 | 19 | import Options from './options'; 20 | import Servers from './servers'; 21 | 22 | export default class Walker extends BaseWalker { 23 | private servers: Option = null; 24 | 25 | protected async root(node: Root): Promise { 26 | assert(this.servers === null, 'servers must be null'); 27 | 28 | return Servers.run(async servers => { 29 | try { 30 | this.servers = servers; 31 | 32 | let children = await this.visit(node.children); 33 | 34 | assert(this.servers !== null, 'servers cannot be null'); 35 | assert(servers === this.servers, 'servers re-assigned'); 36 | 37 | return { ...node, children }; 38 | } finally { 39 | this.servers = null; 40 | } 41 | }); 42 | } 43 | 44 | protected async code(node: Code): Promise> { 45 | let { lang } = node; 46 | 47 | if (!lang || !lang.startsWith('run:')) { 48 | return node; 49 | } 50 | 51 | let { options } = this; 52 | 53 | switch (lang) { 54 | case 'run:checkpoint': 55 | return checkpoint(node, options); 56 | 57 | case 'run:command': 58 | return command(node, options); 59 | 60 | case 'run:file:copy': 61 | return copyFile(node, options); 62 | 63 | case 'run:file:create': 64 | return createFile(node, options); 65 | 66 | case 'run:file:patch': 67 | return patchFile(node, options); 68 | 69 | case 'run:file:show': 70 | return showFile(node, options); 71 | 72 | case 'run:ignore': 73 | return ignore(node, options); 74 | 75 | case 'run:pause': 76 | return pause(node, options, this.file); 77 | 78 | case 'run:screenshot': 79 | return screenshot(node, options, this.file); 80 | 81 | case 'run:server:start': 82 | assert(this.servers !== null, 'servers must not be null'); 83 | return startServer(node, options, this.servers!); 84 | 85 | case 'run:server:stop': 86 | assert(this.servers !== null, 'servers must not be null'); 87 | return stopServer(node, options, this.servers!); 88 | 89 | default: 90 | if (lang.startsWith('run:ignore:')) { 91 | return ignore(node, options); 92 | } else { 93 | throw assert(false, `Unknown directive \`${lang}\`.`); 94 | } 95 | } 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /src/lib/plugins/todo-links/index.ts: -------------------------------------------------------------------------------- 1 | import { Plugin, Transformer } from 'unified'; 2 | import { Node } from 'unist'; 3 | import { VFile } from 'vfile'; 4 | import Walker from './walker'; 5 | 6 | function attacher(): Transformer { 7 | async function transform(node: Node, file: VFile): Promise { 8 | return new Walker(file).walk(node); 9 | } 10 | 11 | return transform; 12 | } 13 | 14 | const plugin: Plugin<[]> = attacher; 15 | 16 | export default plugin; 17 | -------------------------------------------------------------------------------- /src/lib/plugins/todo-links/walker.ts: -------------------------------------------------------------------------------- 1 | import { Association, HTML, LinkReference, StaticPhrasingContent } from 'mdast'; 2 | import { Option } from 'ts-std'; 3 | import { VFile } from 'vfile'; 4 | import BaseWalker from '../../walker'; 5 | 6 | const TODO_PREFIX = 'TODO:'; 7 | 8 | function isLintDirective(node: HTML): boolean { 9 | return node.value === ''; 10 | } 11 | 12 | function isTodoLink(node: LinkReference): boolean { 13 | // This is a bug in `mdast`. 14 | // According to the spec, Reference extends Association. 15 | // https://github.com/DefinitelyTyped/DefinitelyTyped/pull/37882 16 | let { label } = node as LinkReference & Association; 17 | return !!label && label.startsWith(TODO_PREFIX); 18 | } 19 | 20 | export default class TodoLinksWalker extends BaseWalker { 21 | constructor(file: VFile) { 22 | super(null, file); 23 | } 24 | 25 | protected async html(node: HTML): Promise> { 26 | if (isLintDirective(node)) { 27 | return null; 28 | } else { 29 | return node; 30 | } 31 | } 32 | 33 | protected async linkReference(node: LinkReference): Promise { 34 | if (isTodoLink(node)) { 35 | return node.children; 36 | } else { 37 | return node; 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/lib/plugins/zoey-says/index.ts: -------------------------------------------------------------------------------- 1 | import { Plugin, Transformer } from 'unified'; 2 | import { Node } from 'unist'; 3 | import { VFile } from 'vfile'; 4 | import Walker from './walker'; 5 | 6 | function attacher(): Transformer { 7 | async function transform(node: Node, file: VFile): Promise { 8 | return new Walker(file).walk(node); 9 | } 10 | 11 | return transform; 12 | } 13 | 14 | const plugin: Plugin<[]> = attacher; 15 | 16 | export default plugin; 17 | -------------------------------------------------------------------------------- /src/lib/plugins/zoey-says/markdown-to-html.ts: -------------------------------------------------------------------------------- 1 | import unified from 'unified'; 2 | import { Node } from 'unist'; 3 | 4 | // @ts-ignore 5 | import html from 'rehype-stringify'; 6 | // @ts-ignore 7 | import rehype from 'remark-rehype'; 8 | 9 | const processor = unified() 10 | .use(rehype) 11 | .use(html); 12 | 13 | export default async function markdownToHTML(node: Node): Promise { 14 | let rehyped = await processor.run(node); 15 | return processor.stringify(rehyped); 16 | } 17 | -------------------------------------------------------------------------------- /src/lib/plugins/zoey-says/walker.ts: -------------------------------------------------------------------------------- 1 | import { BlockContent, Blockquote, DefinitionContent, HTML } from 'mdast'; 2 | import { Position } from 'unist'; 3 | import { VFile } from 'vfile'; 4 | import BaseWalker from '../../walker'; 5 | import html from './markdown-to-html'; 6 | 7 | const ZOEY_SAYS = 'Zoey says...'; 8 | 9 | function isZoeySays({ children }: Blockquote): boolean { 10 | if (children.length === 0) { 11 | return false; 12 | } 13 | 14 | let [firstBlock] = children; 15 | 16 | if (firstBlock.type !== 'paragraph') { 17 | return false; 18 | } 19 | 20 | let [firstParagraph] = firstBlock.children; 21 | 22 | return firstParagraph.type === 'text' && 23 | firstParagraph.value === ZOEY_SAYS; 24 | } 25 | 26 | async function render(nodes: (BlockContent | DefinitionContent)[], position?: Position): Promise { 27 | let content = []; 28 | 29 | for (let node of nodes) { 30 | content.push(await html(node)); 31 | } 32 | 33 | let value = `
34 |
35 |
36 |
Zoey says...
37 |
38 | ${content.join(' \n')} 39 |
40 |
41 | 42 |
43 |
`; 44 | 45 | return { type: 'html', value, position }; 46 | } 47 | 48 | export default class ZoeySaysWalker extends BaseWalker { 49 | constructor(file: VFile) { 50 | super(null, file); 51 | } 52 | 53 | protected async blockquote(node: Blockquote): Promise
{ 54 | if (isZoeySays(node)) { 55 | return render(node.children.slice(1), node.position); 56 | } else { 57 | return node; 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/lib/walker.ts: -------------------------------------------------------------------------------- 1 | import { Root } from 'mdast'; 2 | import { Option, assert } from 'ts-std'; 3 | import { Node, Parent } from 'unist'; 4 | import { VFile } from 'vfile'; 5 | 6 | type Handler = (this: Walker, node: Node) => Option | Promise>; 7 | 8 | function isParent(node: Node): node is Parent { 9 | return Array.isArray((node as Node & { children: unknown }).children); 10 | } 11 | 12 | export default class Walker { 13 | constructor(protected options: Options, protected file: VFile) { } 14 | 15 | [key: string]: unknown; 16 | 17 | async walk(root: Node): Promise { 18 | assert(root.type === 'root', `Cannot walk \`${root.type}\` (must be \`root\`)`); 19 | 20 | let result = await this.handle(root); 21 | 22 | if (Array.isArray(result)) { 23 | assert(result.length === 1, 'Must return a single root node'); 24 | result = result[0]; 25 | } 26 | 27 | if (result) { 28 | assert(result.type === 'root', 'Must return a root'); 29 | return result as Root; 30 | } else { 31 | return { 32 | type: 'root', 33 | children: [] 34 | }; 35 | } 36 | } 37 | 38 | protected async handle(node: Node): Promise | Node[]> { 39 | let maybeHandler = this[node.type]; 40 | 41 | if (typeof maybeHandler === 'function') { 42 | let handler = maybeHandler as Handler; 43 | return handler.call(this, node); 44 | } else if (isParent(node)) { 45 | return ({ 46 | ...node, 47 | children: await this.visit(node.children) 48 | } as Parent); 49 | } else { 50 | return node; 51 | } 52 | } 53 | 54 | protected async visit(input: Type[]): Promise { 55 | let output = []; 56 | 57 | for (let node of input) { 58 | let result = await this.handle(node); 59 | 60 | if (Array.isArray(result)) { 61 | output.push(...result); 62 | } else if (result) { 63 | output.push(result); 64 | } 65 | } 66 | 67 | return output as Type[]; 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/markdown/tutorial/acceptance-test.md: -------------------------------------------------------------------------------- 1 | --- 2 | redirect: tutorial/part-1/automated-testing/ 3 | --- 4 | -------------------------------------------------------------------------------- /src/markdown/tutorial/autocomplete-component.md: -------------------------------------------------------------------------------- 1 | --- 2 | redirect: tutorial/part-1 3 | --- 4 | -------------------------------------------------------------------------------- /src/markdown/tutorial/deploying.md: -------------------------------------------------------------------------------- 1 | --- 2 | redirect: tutorial/part-1 3 | --- 4 | -------------------------------------------------------------------------------- /src/markdown/tutorial/ember-cli.md: -------------------------------------------------------------------------------- 1 | --- 2 | redirect: tutorial/part-1/orientation/ 3 | --- 4 | -------------------------------------------------------------------------------- /src/markdown/tutorial/ember-data.md: -------------------------------------------------------------------------------- 1 | --- 2 | redirect: tutorial/part-2/ember-data/ 3 | --- 4 | -------------------------------------------------------------------------------- /src/markdown/tutorial/hbs-helper.md: -------------------------------------------------------------------------------- 1 | --- 2 | redirect: tutorial/part-1/component-basics 3 | --- 4 | -------------------------------------------------------------------------------- /src/markdown/tutorial/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | redirect: tutorial/part-1 3 | --- 4 | -------------------------------------------------------------------------------- /src/markdown/tutorial/installing-addons.md: -------------------------------------------------------------------------------- 1 | --- 2 | redirect: tutorial/part-1 3 | --- 4 | -------------------------------------------------------------------------------- /src/markdown/tutorial/model-hook.md: -------------------------------------------------------------------------------- 1 | --- 2 | redirect: tutorial/part-2/route-params 3 | --- 4 | -------------------------------------------------------------------------------- /src/markdown/tutorial/part-1/01-orientation.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | In this chapter, you will install *[Ember CLI](https://cli.emberjs.com/release/)*, use it to generate a new Ember project, and add some basic templates and styles to your new app. By the end of this chapter, you should have a landing page with Professor Tomster's cute little face featured on it: 4 | 5 | ![The Super Rentals app by the end of the chapter](/images/tutorial/part-1/orientation/styled-with-tomster@2x.png) 6 | 7 | While building your landing page, you will learn about: 8 | 9 | * Installing Ember CLI 10 | * Creating a new Ember app with Ember CLI 11 | * Starting and stopping the development server 12 | * Editing files and live reload 13 | * Working with HTML, CSS and assets in an Ember app 14 | 15 | ## Installing Ember CLI 16 | 17 | You can install the latest version of Ember CLI by running the following command. If you've already done this by following the [Quick Start](../../../getting-started/quick-start/) guide, feel free to skip ahead! 18 | 19 | ```shell 20 | $ npm install -g ember-cli 21 | ``` 22 | 23 | To verify that your installation was successful, run: 24 | 25 | ```run:command 26 | ember --version 27 | ``` 28 | 29 | If a version number is shown, you're ready to go. 30 | 31 | ## Creating a New Ember App with Ember CLI 32 | 33 | We can create a new project using Ember CLI's `new` command. It follows the pattern `ember new `. In our case, the project name would be `super-rentals`. We will also include a `--lang en` option. This sets our app's primary language to English and improves the website's [accessibility](../../../accessibility/application-considerations/). 34 | 35 | ```run:ignore 36 | Hack: make an empty package.json to convince ember-cli we are really not in an Ember project. Otherwise, we will get the "You cannot use the new command inside an ember-cli project." error when running `ember new`. 37 | ``` 38 | 39 | ```run:file:create hidden=true filename=package.json 40 | {} 41 | ``` 42 | 43 | ```run:command 44 | # We are supposed to (?) use NPM for the guides, but yarn works better 45 | # for our setup, so we pass the `--yarn` flag but change the output to 46 | # pretend we are running NPM. 47 | 48 | #[cfg(all(ci, unix))] 49 | #[display(ember new super-rentals --lang en)] 50 | ember new super-rentals --lang en --yarn \ 51 | | awk '{ gsub("Yarn", "npm"); gsub("yarn", "npm"); print }' 52 | 53 | #[cfg(not(all(ci, unix)))] 54 | ember new super-rentals --lang en --yarn 55 | ``` 56 | 57 | ```run:command hidden=true 58 | # Clean up the hack above 59 | 60 | #[cfg(unix)] 61 | rm package.json 62 | 63 | #[cfg(windows)] 64 | del package.json 65 | ``` 66 | 67 | ```run:file:patch hidden=true cwd=super-rentals filename=app/index.html 68 | @@ -14,2 +14,17 @@ 69 | 70 | + 84 | + 85 | {{content-for "head-footer"}} 86 | 87 | ``` 88 | 89 | ```run:file:patch hidden=true cwd=super-rentals filename=config/environment.js 90 | @@ -9,2 +9,3 @@ 91 | EmberENV: { 92 | + RAISE_ON_DEPRECATION: true, 93 | EXTEND_PROTOTYPES: false, 94 | ``` 95 | 96 | ```run:file:create hidden=true cwd=super-rentals filename=public/_redirects 97 | /* /index.html 200 98 | ``` 99 | 100 | ```run:file:patch hidden=true cwd=super-rentals filename=tests/index.html 101 | @@ -28,2 +28,93 @@ 102 | 103 | + 194 | 195 | ``` 196 | 197 | ```run:file:patch hidden=true cwd=super-rentals filename=.prettierignore 198 | @@ -1 +1,2 @@ 199 | +**/*.hbs 200 | # unconventional js 201 | 202 | ``` 203 | 204 | ```run:command hidden=true cwd=super-rentals 205 | yarn test 206 | git add .prettierignore 207 | git add app/index.html 208 | git add config/environment.js 209 | git add public/_redirects 210 | git add tests/index.html 211 | git commit --amend --no-edit 212 | ``` 213 | 214 | This should have created a new folder for us called `super-rentals`. We can navigate into it using the `cd` command. 215 | 216 | ```shell 217 | $ cd super-rentals 218 | ``` 219 | 220 | For the rest of the tutorial, all commands should be run within the `super-rentals` folder. This folder has the following structure: 221 | 222 | ```run:command lang=plain captureCommand=false 223 | # Tree uses UTF-8 "non-breaking space" which gets turned into   224 | # Somewhere in the guides repo's markdown pipeline there is a bug 225 | # that further turns them into &nbsp; 226 | 227 | # Also, try to hide yarn.lock from view and fake a package-lock.json 228 | 229 | #[cfg(unix)] 230 | tree super-rentals -a -I "node_modules|.git|yarn.lock|_redirects" --dirsfirst \ 231 | | sed 's/\xC2\xA0/ /g' \ 232 | | awk \ 233 | '/package\.json/ { print $1 " package.json"; print $1 " package-lock.json" } \ 234 | !/package\.json/ { print }' 235 | 236 | #[cfg(windows)] 237 | npx --quiet tree-cli --base super-rentals --ignore node_modules --ignore .git --ignore yarn.lock --ignore _redirects --directoryFirst -a -l 99 238 | ``` 239 | 240 | We'll learn about the purposes of these files and folders as we go. For now, just know that we'll spend most of our time working within the `app` folder. 241 | 242 | ## Starting and Stopping the Development Server 243 | 244 | Ember CLI comes with a lot of different commands for a variety of development tasks, such as the `ember new` command that we saw earlier. It also comes with a *development server*, which we can launch within the project with the `npm start` command: 245 | 246 | ```run:server:start cwd=super-rentals expect="Serving on http://localhost:4200/" 247 | #[cfg(all(ci, unix))] 248 | #[display(npm start)] 249 | npm start | awk '{ \ 250 | gsub("Build successful \\([0-9]+ms\\)", "Build successful (9761ms)"); \ 251 | print; \ 252 | system("") # https://unix.stackexchange.com/a/83853 \ 253 | }' 254 | 255 | #[cfg(not(all(ci, unix)))] 256 | npm start 257 | ``` 258 | 259 | The development server is responsible for compiling our app and serving it to the browsers. It may take a while to boot up. Once it's up and running, open your favorite browser and head to . You should see the following welcome page: 260 | 261 | ```run:screenshot width=1024 retina=true filename=welcome.png alt="Welcome to Ember!" 262 | visit http://localhost:4200/?deterministic 263 | ``` 264 | 265 | > Zoey says... 266 | > 267 | > The `localhost` address in the URL means that you can only access the development server from your local machine. If you would like to share your work with the world, you will have to *[deploy](https://cli.emberjs.com/release/basic-use/deploying/)* your app to the public Internet. We'll cover how to do that in Part 2 of the tutorial. 268 | 269 | You can exit out of the development server at any time by typing `Ctrl + C` into the terminal window where `npm start` is running. That is, typing the "C" key on your keyboard *while* holding down the "Ctrl" key at the same time. Once it has stopped, you can start it back up again with the same `npm start` command. We recommend having two terminal windows open: one to run the server in background, another to type other Ember CLI commands. 270 | 271 | ## Editing Files and Live Reload 272 | 273 | The development server has a feature called *live reload*, which monitors your app for file changes, automatically re-compiles everything, and refreshes any open browser pages. This comes in really handy during development, so let's give that a try! 274 | 275 | As text on the welcome page pointed out, the source code for the page is located in `app/templates/application.hbs`. Let's try to edit that file and replace it with our own content: 276 | 277 | ```run:file:patch lang=handlebars cwd=super-rentals filename=app/templates/application.hbs 278 | @@ -1,7 +1 @@ 279 | -{{page-title "SuperRentals"}} 280 | - 281 | -{{outlet}} 282 | - 283 | -{{! The following component displays Ember's default welcome message. }} 284 | - 285 | -{{! Feel free to remove this! }} 286 | \ No newline at end of file 287 | +Hello World!!! 288 | ``` 289 | 290 | Soon after saving the file, your browser should automatically refresh and render our greetings to the world. Neat! 291 | 292 | ```run:screenshot width=1024 height=250 retina=true filename=hello-world.png alt="Hello World!!!" 293 | visit http://localhost:4200/?deterministic 294 | ``` 295 | 296 | When you are done experimenting, go ahead and delete the `app/templates/application.hbs` file. We won't be needing this for a while, so let's start afresh. We can add it back later when we have a need for it. 297 | 298 | ```run:command hidden=true cwd=super-rentals 299 | git rm -f app/templates/application.hbs 300 | ``` 301 | 302 | Again, if you still have your browser tab open, your tab will automatically re-render a blank page as soon as you delete the file. This reflects the fact that we no longer have an application template in our app. 303 | 304 | ## Working with HTML, CSS and Assets in an Ember App 305 | 306 | Create a `app/templates/index.hbs` file and paste the following markup. 307 | 308 | ```run:file:create lang=handlebars cwd=super-rentals filename=app/templates/index.hbs 309 |
310 |
311 |

Welcome to Super Rentals!

312 |

We hope you find exactly what you're looking for in a place to stay.

313 |
314 | ``` 315 | 316 | If you are thinking, "Hey, that looks like HTML!", then you would be right! In their simplest form, Ember templates are really just HTML. If you are already familiar with HTML, you should feel right at home here. 317 | 318 | Of course, unlike HTML, Ember templates can do a lot more than just displaying static content. We will see that in action soon. 319 | 320 | After saving the file, your browser tab should automatically refresh, showing us the welcome message we just worked on. 321 | 322 | ```run:screenshot width=1024 height=250 retina=true filename=unstyled.png alt="Welcome to Super Rentals! (unstyled)" 323 | visit http://localhost:4200/?deterministic 324 | ``` 325 | 326 | ```run:command hidden=true cwd=super-rentals 327 | git add app/templates/index.hbs 328 | ``` 329 | 330 | Before we do anything else, let's add some styling to our app. We spend enough time staring at the computer screen as it is, so we must protect our eyesight against unstyled markup! 331 | 332 | Fortunately, our designer sent us some CSS to use, so we can download the stylesheet file and copy it into `app/styles/app.css`. This file has all the styles we need for building the rest of the app. 333 | 334 | ```run:file:copy lang=css src=downloads/style.css cwd=super-rentals filename=app/styles/app.css 335 | @import url(https://fonts.googleapis.com/css?family=Lato:300,300italic,400,700,700italic); 336 | 337 | /** 338 | * Base Elements 339 | */ 340 | 341 | * { 342 | margin: 0; 343 | padding: 0; 344 | } 345 | 346 | body, 347 | h1, 348 | h2, 349 | h3, 350 | h4, 351 | h5, 352 | h6, 353 | p, 354 | div, 355 | span, 356 | a, 357 | button { 358 | font-family: 'Lato', 'Open Sans', 'Helvetica Neue', 'Segoe UI', Helvetica, Arial, sans-serif; 359 | line-height: 1.5; 360 | } 361 | 362 | body { 363 | background: #f3f3f3; 364 | } 365 | 366 | /* ...snip... */ 367 | ``` 368 | 369 | > Zoey says... 370 | > 371 | > The CSS file is pretty long, so we didn't show the entire file here. Be sure to use the link above to download the complete file! 372 | 373 | If you are familiar with CSS, feel free to customize these styles to your liking! Just keep in mind that you may see some visual differences going forward, should you choose to do so. 374 | 375 | When you are ready, save the CSS file; our trusty development server should pick it up and refresh our page right away. No more unstyled content! 376 | 377 | ```run:screenshot width=1024 retina=true filename=styled.png alt="Welcome to Super Rentals! (styled)" 378 | visit http://localhost:4200/?deterministic 379 | ``` 380 | 381 | ```run:command hidden=true cwd=super-rentals 382 | git add app/styles/app.css 383 | ``` 384 | 385 | To match the mockup from our designer, we will also need to download the `teaching-tomster.png` image, which was referenced from our CSS file: 386 | 387 | ```css { data-filename=app/styles/app.css } 388 | .tomster { 389 | background: url(../assets/images/teaching-tomster.png); 390 | /* ...snip... */ 391 | } 392 | ``` 393 | 394 | As we learned earlier, the Ember convention is to place your source code in the `app` folder. For other assets like images and fonts, the convention is to put them in the `public` folder. We will follow this convention by downloading the image file and saving it into `public/assets/images/teaching-tomster.png`. 395 | 396 | ```run:file:copy hidden=true src=downloads/teaching-tomster.png cwd=super-rentals filename=public/assets/images/teaching-tomster.png 397 | ``` 398 | 399 | Both Ember CLI and the development server understand these folder conventions and will automatically make these files available to the browser. 400 | 401 | You can confirm this by navigating to 402 | `http://localhost:4200/assets/images/teaching-tomster.png`. The image should also show up in the welcome page we have been working on. You may need to do a manual refresh for the browser to pick up the new file. 403 | 404 | ```run:command hidden=true cwd=super-rentals 405 | git add public/assets/images/teaching-tomster.png 406 | ``` 407 | 408 | ```run:screenshot width=1024 retina=true filename=styled-with-tomster.png alt="Welcome to Super Rentals! (with Tomster)" 409 | visit http://localhost:4200/?deterministic 410 | ``` 411 | 412 | ```run:server:stop 413 | npm start 414 | ``` 415 | 416 | ```run:checkpoint cwd=super-rentals 417 | Chapter 1 418 | ``` 419 | -------------------------------------------------------------------------------- /src/markdown/tutorial/part-1/02-building-pages.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ```run:server:start hidden=true cwd=super-rentals expect="Serving on http://localhost:4200/" 4 | npm start 5 | ``` 6 | 7 | In this chapter, you will build the first few pages of your Ember app and set up links between them. By the end of this chapter, you should have two new pages – an about page and a contact page. These pages will be linked to from your landing page: 8 | 9 | ![The Super Rentals app (homepage) by the end of the chapter](/images/tutorial/part-1/building-pages/index-with-link@2x.png) 10 | 11 | ![The Super Rentals app (about page) by the end of the chapter](/images/tutorial/part-1/building-pages/about-with-link@2x.png) 12 | 13 | ![The Super Rentals app (contact page) by the end of the chapter](/images/tutorial/part-1/building-pages/contact-with-link@2x.png) 14 | 15 | While building these pages, you will learn about: 16 | 17 | * Defining routes 18 | * Using route templates 19 | * Customizing URLs 20 | * Linking pages with the `` component 21 | * Passing arguments and attributes to components 22 | 23 | ## Defining Routes 24 | 25 | With our [first page](../orientation/) down, let's add another one! 26 | 27 | This time, we would like the page to be served on the `/about` URL. In order to do this, we will need to tell Ember about our plan to add a page at that location. Otherwise, Ember will think we have visited an invalid URL! 28 | 29 | The place to manage what pages are available is the *[router][TODO: link to router]*. Go ahead and open `app/router.js` and make the following change: 30 | 31 | ```run:file:patch lang=js cwd=super-rentals filename=app/router.js 32 | @@ -8,2 +8,4 @@ 33 | 34 | -Router.map(function () {}); 35 | +Router.map(function () { 36 | + this.route('about'); 37 | +}); 38 | ``` 39 | 40 | This adds a *[route](../../../routing/defining-your-routes/)* named "about", which is served at the `/about` URL by default. 41 | 42 | ```run:command hidden=true cwd=super-rentals 43 | git add app/router.js 44 | ``` 45 | 46 | ## Using Route Templates 47 | 48 | With that in place, we can create a new `app/templates/about.hbs` template with the following content: 49 | 50 | ```run:file:create lang=handlebars cwd=super-rentals filename=app/templates/about.hbs 51 |
52 |
53 |

About Super Rentals

54 |

55 | The Super Rentals website is a delightful project created to explore Ember. 56 | By building a property rental site, we can simultaneously imagine traveling 57 | AND building Ember applications. 58 |

59 |
60 | ``` 61 | 62 | To see this in action, navigate to `http://localhost:4200/about`. 63 | 64 | ```run:screenshot width=1024 retina=true filename=about.png alt="About page" 65 | visit http://localhost:4200/about?deterministic 66 | ``` 67 | 68 | ```run:command hidden=true cwd=super-rentals 69 | git add app/templates/about.hbs 70 | ``` 71 | 72 | With that, our second page is done! 73 | 74 | ## Defining Routes with Custom Paths 75 | 76 | We're on a roll! While we're at it, let's add our third page. This time, things are a little bit different. Everyone at the company calls this the "contact" page. However, the old website we are replacing already has a similar page, which is served at the legacy URL `/getting-in-touch`. 77 | 78 | We want to keep the existing URLs for the new website, but we don't want to have to type `getting-in-touch` all over the new codebase! Fortunately, we can have the best of both worlds: 79 | 80 | ```run:file:patch lang=js cwd=super-rentals filename=app/router.js 81 | @@ -10,2 +10,3 @@ 82 | this.route('about'); 83 | + this.route('contact', { path: '/getting-in-touch' }); 84 | }); 85 | ``` 86 | 87 | Here, we added the `contact` route, but explicitly specified a path for the route. This allows us to keep the legacy URL, but use the new, shorter name for the route, as well as the template filename. 88 | 89 | ```run:command hidden=true cwd=super-rentals 90 | git add app/router.js 91 | ``` 92 | 93 | Speaking of the template, let's create that as well. We'll add a `app/templates/contact.hbs` file: 94 | 95 | ```run:file:create lang=handlebars cwd=super-rentals filename=app/templates/contact.hbs 96 |
97 |
98 |

Contact Us

99 |

100 | Super Rentals Representatives would love to help you
101 | choose a destination or answer any questions you may have. 102 |

103 |
104 | Super Rentals HQ 105 |

106 | 1212 Test Address Avenue
107 | Testington, OR 97233 108 |

109 | +1 (503) 555-1212
110 | superrentalsrep@emberjs.com 111 |
112 |
113 | ``` 114 | 115 | Ember comes with strong *[conventions][TODO: link to conventions]* and sensible defaults—if we were starting from scratch, we wouldn't mind the default `/contact` URL. However, if the defaults don't work for us, it is no problem at all to customize Ember for our needs! 116 | 117 | Once you have added the route and the template above, we should have the new page available to us at `http://localhost:4200/getting-in-touch`. 118 | 119 | ```run:screenshot width=1024 retina=true filename=contact.png alt="Contact page" 120 | visit http://localhost:4200/getting-in-touch?deterministic 121 | ``` 122 | 123 | ```run:command hidden=true cwd=super-rentals 124 | git add app/templates/contact.hbs 125 | ``` 126 | 127 | ## Linking Pages with the `` Component 128 | 129 | We just put so much effort into making these pages, we need to make sure people can find them! The way we do that on the web is by using *[hyperlinks](https://developer.mozilla.org/docs/Learn/HTML/Introduction_to_HTML/Creating_hyperlinks)*, or *links* for short. 130 | 131 | Since Ember offers great support for URLs out-of-the-box, we *could* just link our pages together using the `` tag with the appropriate `href`. However, clicking on those links would require the browser to make a *[full-page refresh][TODO: link to full page refresh]*, which means that it would have to make a trip back to the server to fetch the page, and then load everything from scratch again. 132 | 133 | With Ember, we can do better than that! Instead of the plain-old `` tag, Ember provides an alternative called ``. For example, here is how you would use it on the pages we just created: 134 | 135 | ```run:file:patch lang=handlebars cwd=super-rentals filename=app/templates/index.hbs 136 | @@ -4,2 +4,3 @@ 137 |

We hope you find exactly what you're looking for in a place to stay.

138 | + About Us 139 | 140 | ``` 141 | 142 | ```run:file:patch lang=handlebars cwd=super-rentals filename=app/templates/about.hbs 143 | @@ -8,2 +8,3 @@ 144 |

145 | + Contact Us 146 | 147 | ``` 148 | 149 | ```run:file:patch lang=handlebars cwd=super-rentals filename=app/templates/contact.hbs 150 | @@ -16,2 +16,3 @@ 151 | 152 | + About 153 | 154 | ``` 155 | 156 | There is quite a bit going on here, so let's break it down. 157 | 158 | `` is an example of a *[component](../../../components/introducing-components/)* in Ember—you can tell them apart from regular HTML tags because they start with an uppercase letter. Along with regular HTML tags, components are a key building block that we can use to build up an app's user interface. 159 | 160 | We have a lot more to say about components later, but for now, you can think of them as a way to provide *[custom tags][TODO: link to custom tags]* to supplement the built-in ones that came with the browser. 161 | 162 | The `@route=...` part is how we pass *[arguments](../../../components/component-arguments-and-html-attributes/)* into the component. Here, we use this argument to specify *which* route we want to link to. (Note that this should be the *name* of the route, not the path, which is why we specified `"about"` instead of `"/about"`, and `"contact"` instead of `"/getting-in-touch"`.) 163 | 164 | In addition to arguments, components can also take the usual HTML attributes as well. In our example, we added a `"button"` class for styling purposes, but we could also specify other attributes as we see fit, such as the [ARIA](https://webaim.org/techniques/aria/) [`role` attribute](https://developer.mozilla.org/docs/Web/Accessibility/ARIA/Roles). These are passed without the `@` symbol (`class=...` as opposed to `@class=...`), so that Ember will know they are just regular HTML attributes. 165 | 166 | Under the hood, the `` component generates a regular `
` tag for us with the appropriate `href` for the specific route. This `` tag works just fine with *[screen readers](https://webaim.org/projects/screenreadersurvey/)*, as well as allowing our users to bookmark the link or open it in a new tab. 167 | 168 | However, when clicking on one of these special links, Ember will intercept the click, render the content for the new page, and update the URL—all performed locally without having to wait for the server, thus avoiding a full page refresh. 169 | 170 | 171 | 172 | ```run:screenshot width=1024 retina=true filename=index-with-link.png alt="Index page after adding the link" 173 | visit http://localhost:4200/?deterministic 174 | ``` 175 | 176 | ```run:screenshot width=1024 retina=true filename=about-with-link.png alt="About page after adding the link" 177 | visit http://localhost:4200/about?deterministic 178 | ``` 179 | 180 | ```run:screenshot width=1024 retina=true filename=contact-with-link.png alt="Contact page after adding the link" 181 | visit http://localhost:4200/getting-in-touch?deterministic 182 | ``` 183 | 184 | ```run:command hidden=true cwd=super-rentals 185 | git add app/templates/index.hbs 186 | git add app/templates/about.hbs 187 | git add app/templates/contact.hbs 188 | ``` 189 | 190 | We will learn more about how all of this works soon. In the meantime, go ahead and click on the link in the browser. Did you notice how snappy that was? 191 | 192 | Congratulations, you are well on your way to becoming a master page-crafter! 193 | 194 | ```run:server:stop 195 | npm start 196 | ``` 197 | 198 | ```run:checkpoint cwd=super-rentals 199 | Chapter 2 200 | ``` 201 | -------------------------------------------------------------------------------- /src/markdown/tutorial/part-1/03-automated-testing.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ```run:server:start hidden=true cwd=super-rentals expect="Serving on http://localhost:4200/" 4 | npm start 5 | ``` 6 | 7 | In this chapter, you will use Ember's built-in testing framework to write some automated tests for your app. By the end of this chapter, we will have an automated test suite that we can run to ensure our app is working correctly: 8 | 9 | ![The Super Rentals test suite by the end of the chapter](/images/tutorial/part-1/automated-testing/pass-2@2x.png) 10 | 11 | In the process, you will learn about: 12 | 13 | * The purpose of automated testing 14 | * Writing acceptance tests 15 | * Using generators in Ember CLI 16 | * Testing with the QUnit test framework 17 | * Working with Ember's test helpers 18 | * Practicing the testing workflow 19 | 20 | ## The Purpose of Automated Testing 21 | 22 | We accomplished a lot in the last few chapters! Let's recap—we started with a blank canvas, added a few pages of content, styled everything to look pretty, dropped in a picture of Tomster, added links between our pages and amazingly, everything worked together flawlessly! 23 | 24 | But do we *really* know that everything is actually working? Sure, we clicked around a bit to confirm that things look as expected. But do we feel confident that we checked *every* page after the most recent change that we made? 25 | 26 | After all, most of us have experienced (or heard horror stories about) making a Small Tweak™ in one area of the app that inadvertently broke *everything else* when we weren't looking. 27 | 28 | Maybe we can write a checklist somewhere of all the things to check after making changes to our site. But surely, this will get out of hand as we add more features to our app. It is also going to get old really quickly—repetitive tasks like that are best left to robots. 29 | 30 | Hmm, robots. That's an idea. What if we can write this checklist and just get the computer to check everything for us? I think we just invented the idea of *[automated testing](../../../testing/)*! Okay, maybe we were not the first to come up with the concept, but we independently discovered it so we still deserve some credit. 31 | 32 | ## Adding Acceptance Tests with Generators 33 | 34 | Once we are done patting ourselves on the back, go ahead and run the following command in the terminal: 35 | 36 | ```run:command cwd=super-rentals 37 | ember generate acceptance-test super-rentals 38 | ``` 39 | 40 | This is called a *[generator](https://cli.emberjs.com/release/basic-use/cli-commands/#generatemorefiles)* command in Ember CLI. Generators automatically create files for us based on Ember's conventions and populate them with the appropriate boilerplate content, similar to how `ember new` initially created a skeleton app for us. It typically follows the pattern `ember generate `, where `` is the kind of thing we are generating, and `` is what we want to call it. 41 | 42 | In this case, we generated an *[acceptance test](../../../testing/test-types/#toc_application-tests)* located at `tests/acceptance/super-rentals-test.js`. 43 | 44 | ```run:command hidden=true cwd=super-rentals 45 | git add tests/acceptance/super-rentals-test.js 46 | ``` 47 | 48 | Generators aren't required; we *could* have created the file ourselves which would have accomplished the exact same thing. But, generators certainly save us a lot of typing. Go ahead and take a peek at the acceptance test file and see for yourself. 49 | 50 | > Zoey says... 51 | > 52 | > Want to save even more typing? `ember generate ...` can be shortened into `ember g ...`. That's 7 fewer characters! 53 | 54 | ## Writing Acceptance Tests 55 | 56 | Acceptance tests, also known as *application tests*, are one of a few types of automated testing at our disposal in Ember. We will learn about the other types later, but what makes acceptance tests unique is that they test our app from the user's perspective—they are an automated version of the "click around and see if it works" testing we did earlier, which is exactly what we need. 57 | 58 | Let's open the generated test file and replace the boilerplate test with our own: 59 | 60 | ```run:file:patch lang=js cwd=super-rentals filename=tests/acceptance/super-rentals-test.js 61 | @@ -1,3 +1,3 @@ 62 | import { module, test } from 'qunit'; 63 | -import { visit, currentURL } from '@ember/test-helpers'; 64 | +import { click, visit, currentURL } from '@ember/test-helpers'; 65 | import { setupApplicationTest } from 'super-rentals/tests/helpers'; 66 | @@ -7,6 +7,12 @@ 67 | 68 | - test('visiting /super-rentals', async function (assert) { 69 | - await visit('/super-rentals'); 70 | - 71 | - assert.strictEqual(currentURL(), '/super-rentals'); 72 | + test('visiting /', async function (assert) { 73 | + await visit('/'); 74 | + 75 | + assert.strictEqual(currentURL(), '/'); 76 | + assert.dom('h2').hasText('Welcome to Super Rentals!'); 77 | + 78 | + assert.dom('.jumbo a.button').hasText('About Us'); 79 | + await click('.jumbo a.button'); 80 | + 81 | + assert.strictEqual(currentURL(), '/about'); 82 | }); 83 | ``` 84 | 85 | First, we instruct the test robot to navigate to the `/` URL of our app by using the `visit` *test helper* provided by Ember. This is akin to us typing `http://localhost:4200/` in the browser's address bar and hitting the `enter` key. 86 | 87 | Because the page is going to take some time to load, this is known as an *[async](https://developer.mozilla.org/docs/Learn/JavaScript/Asynchronous/Concepts)* (short for *asynchronous*) step, so we will need to tell the test robot to wait by using JavaScript's `await` keyword. That way, it will wait until the page completely finishes loading before moving on to the next step. 88 | 89 | This is almost always the behavior we want, so we will almost always use `await` and `visit` as a pair. This applies to other kinds of simulated interaction too, such as clicking on a button or a link, as they all take time to complete. Even though sometimes these actions may seem imperceptibly fast to us, we have to remember that our test robot has really, really fast hands, as we will see in a moment. 90 | 91 | After navigating to the `/` URL and waiting for things to settle, we check that the current URL matches the URL that we expect (`/`). We can use the `currentURL` test helper here, as well as `equal` *[assertion](https://github.com/emberjs/ember-test-helpers/blob/master/API.md)*. This is how we encode our "checklist" into code—by specifying, or asserting how things *should* behave, we will be alerted if our app does *not* behave in the way that we expect. 92 | 93 | Next, we confirmed that the page has an `

` tag that contains the text "Welcome to Super Rentals!". Knowing this is true means that we can be quite certain that the correct template has been rendered, without errors. 94 | 95 | Then, we looked for a link with the text `About Us`, located using the *[CSS selector](https://developer.mozilla.org/docs/Learn/CSS/Building_blocks/Selectors)* `.jumbo a.button`. This is the same syntax we used in our stylesheet, which means "look inside the tag with the `jumbo` class for an `` tag with the `button` class." This matches up with the HTML structure in our template. 96 | 97 | Once the existence of this element on the page was confirmed, we told the test robot to click on this link. As mentioned above, this is a user interaction, so it needs to be `await`-ed. 98 | 99 | Finally, we asserted that clicking on the link should bring us to the `/about` URL. 100 | 101 | > Zoey says... 102 | > 103 | > Here, we are writing the tests in a framework called QUnit, which is where the functions `module`, `test` and `assert` come from. We also have additional helpers like `click`, `visit`, and `currentURL` provided by the `@ember/test-helpers` package. You can tell what comes from which package based on the `import` paths at the top of the file. Knowing this will be helpful when you need to search for documentation on the Internet or ask for help. 104 | 105 | ```run:command hidden=true cwd=super-rentals 106 | ember test --path dist 107 | git add tests/acceptance/super-rentals-test.js 108 | ``` 109 | 110 | We can put our automated test into motion by running the *[test server][TODO: link to test server]* using the `ember test --server` command, or `ember t -s` for short. This server behaves much like the development server, but it is explicitly running for our tests. It may automatically open a browser window and take you to the test UI, or you can open `http://localhost:7357/` yourself. 111 | 112 | If you watch really carefully, you can see our test robot roaming around our app and clicking links: 113 | 114 | 115 | 116 | ```run:screenshot width=1024 height=512 retina=true filename=pass.png alt="All tests passing" 117 | visit http://localhost:4200/tests?nocontainer&nolint&deterministic 118 | wait #qunit-banner.qunit-pass 119 | ``` 120 | 121 | It happens really quickly though—blink and you might miss it! In fact, I had to slow this animation down by a hundred times just so you can see it in action. I told you the robot has really, really fast hands! 122 | 123 | As much as I enjoy watching this robot hard at work, the important thing here is that the test we wrote has *[passed][TODO: link to passed]*, meaning everything is working exactly as we expect and the test UI is all green and happy. If you want, you can go to `index.hbs`, delete the `` component and see what things look like when we have *[a failing test][TODO: link to a failing test]*. 124 | 125 | ```run:file:patch hidden=true cwd=super-rentals filename=app/templates/index.hbs 126 | @@ -4,3 +4,2 @@ 127 |

We hope you find exactly what you're looking for in a place to stay.

128 | - About Us 129 | 130 | ``` 131 | 132 | ```run:screenshot width=1024 height=768 retina=true filename=fail.png alt="A failing test" 133 | visit http://localhost:4200/tests?nocontainer&nolint&deterministic 134 | wait #qunit-banner.qunit-fail 135 | ``` 136 | 137 | Don't forget to put that line back in when you are done! 138 | 139 | ```run:command hidden=true cwd=super-rentals 140 | git checkout app/templates/index.hbs 141 | ember test --path dist 142 | ``` 143 | 144 | ## Practicing the Testing Workflow 145 | 146 | Let's practice what we learned by adding tests for the remaining pages: 147 | 148 | ```run:file:patch lang=js cwd=super-rentals filename=tests/acceptance/super-rentals-test.js 149 | @@ -18,2 +18,26 @@ 150 | }); 151 | + 152 | + test('visiting /about', async function (assert) { 153 | + await visit('/about'); 154 | + 155 | + assert.strictEqual(currentURL(), '/about'); 156 | + assert.dom('h2').hasText('About Super Rentals'); 157 | + 158 | + assert.dom('.jumbo a.button').hasText('Contact Us'); 159 | + await click('.jumbo a.button'); 160 | + 161 | + assert.strictEqual(currentURL(), '/getting-in-touch'); 162 | + }); 163 | + 164 | + test('visiting /getting-in-touch', async function (assert) { 165 | + await visit('/getting-in-touch'); 166 | + 167 | + assert.strictEqual(currentURL(), '/getting-in-touch'); 168 | + assert.dom('h2').hasText('Contact Us'); 169 | + 170 | + assert.dom('.jumbo a.button').hasText('About'); 171 | + await click('.jumbo a.button'); 172 | + 173 | + assert.strictEqual(currentURL(), '/about'); 174 | + }); 175 | }); 176 | ``` 177 | 178 | As with the development server, the test UI should automatically reload and rerun the entire test suite as you save the files. It is recommended that you keep this page open as you develop your app. That way, you will get immediate feedback if you accidentally break something. 179 | 180 | ```run:screenshot width=1024 height=512 retina=true filename=pass-2.png alt="Tests still passing with the new tests" 181 | visit http://localhost:4200/tests?nocontainer&nolint&deterministic 182 | wait #qunit-banner.qunit-pass 183 | ``` 184 | 185 | ```run:command hidden=true cwd=super-rentals 186 | ember test --path dist 187 | git add tests/acceptance/super-rentals-test.js 188 | ``` 189 | 190 | For the rest of the tutorial, we will continue to add more automated tests as we develop new features. Testing is optional but highly recommended. Tests don't affect the functionality of your app, they just protect it from *[regressions][TODO: link to regressions]*, which is just a fancy way of saying "accidental breakages." 191 | 192 | If you are in a hurry, you can skip over the testing sections in this tutorial and still be able to follow along with everything else. But don't you find it super satisfying—*oddly satisfying*—to watch a robot click on things really, really fast? 193 | 194 | ```run:server:stop 195 | npm start 196 | ``` 197 | 198 | ```run:checkpoint cwd=super-rentals 199 | Chapter 3 200 | ``` 201 | -------------------------------------------------------------------------------- /src/markdown/tutorial/part-1/05-more-about-components.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ```run:server:start hidden=true cwd=super-rentals expect="Serving on http://localhost:4200/" 4 | npm start 5 | ``` 6 | 7 | It's time to finally work on the rentals listing: 8 | 9 | ![The Super Rentals app by the end of the chapter](/images/tutorial/part-1/more-about-components/rental-image@2x.png) 10 | 11 | While building this list of rental properties, you will learn about: 12 | 13 | * Generating components 14 | * Organizing code with namespaced components 15 | * Forwarding HTML attributes with `...attributes` 16 | * Determining the appropriate amount of test coverage 17 | 18 | ## Generating Components 19 | 20 | Let's start by creating the `` component. This time, we will use the component generator to create the template and test file for us: 21 | 22 | ```run:command cwd=super-rentals 23 | ember generate component rental 24 | ``` 25 | 26 | The generator created two new files for us, a component template at `app/components/rental.hbs`, and a component test file at `tests/integration/components/rental-test.js`. 27 | 28 | ```run:command hidden=true cwd=super-rentals 29 | ember test --path dist 30 | git add app/components/rental.hbs 31 | git add tests/integration/components/rental-test.js 32 | ``` 33 | 34 | We will start by editing the template. Let's *[hard-code](https://en.wikipedia.org/wiki/Hard_coding)* the details for one rental property for now, and replace it with the real data from the server later on. 35 | 36 | ```run:file:patch lang=handlebars cwd=super-rentals filename=app/components/rental.hbs 37 | @@ -1,1 +1,17 @@ 38 | -{{yield}} 39 | \ No newline at end of file 40 | +
41 | +
42 | +

Grand Old Mansion

43 | +
44 | + Owner: Veruca Salt 45 | +
46 | +
47 | + Type: Standalone 48 | +
49 | +
50 | + Location: San Francisco 51 | +
52 | +
53 | + Number of bedrooms: 15 54 | +
55 | +
56 | +
57 | ``` 58 | 59 | Then, we will write a test to ensure all of the details are present. We will replace the boilerplate test generated for us with our own assertions, just like we did for the `` component earlier: 60 | 61 | ```run:file:patch lang=js cwd=super-rentals filename=tests/integration/components/rental-test.js 62 | @@ -8,18 +8,11 @@ 63 | 64 | - test('it renders', async function (assert) { 65 | - // Set any properties with this.set('myProperty', 'value'); 66 | - // Handle any actions with this.set('myAction', function(val) { ... }); 67 | - 68 | - await render(hbs``); 69 | - 70 | - assert.dom().hasText(''); 71 | - 72 | - // Template block usage: 73 | - await render(hbs` 74 | - 75 | - template block text 76 | - 77 | - `); 78 | - 79 | - assert.dom().hasText('template block text'); 80 | + test('it renders information about a rental property', async function (assert) { 81 | + await render(hbs``); 82 | + 83 | + assert.dom('article').hasClass('rental'); 84 | + assert.dom('article h3').hasText('Grand Old Mansion'); 85 | + assert.dom('article .detail.owner').includesText('Veruca Salt'); 86 | + assert.dom('article .detail.type').includesText('Standalone'); 87 | + assert.dom('article .detail.location').includesText('San Francisco'); 88 | + assert.dom('article .detail.bedrooms').includesText('15'); 89 | }); 90 | ``` 91 | 92 | The test should pass. 93 | 94 | ```run:command hidden=true cwd=super-rentals 95 | ember test --path dist 96 | git add app/components/rental.hbs 97 | git add tests/integration/components/rental-test.js 98 | ``` 99 | 100 | ```run:screenshot width=1024 height=512 retina=true filename=pass.png alt="Tests passing with the new test" 101 | visit http://localhost:4200/tests?nocontainer&nolint&deterministic 102 | wait #qunit-banner.qunit-pass 103 | ``` 104 | 105 | Finally, let's invoke this a couple of times from our index template to populate the page. 106 | 107 | ```run:file:patch lang=handlebars cwd=super-rentals filename=app/templates/index.hbs 108 | @@ -5 +5,9 @@ 109 | 110 | + 111 | +
112 | +
    113 | +
  • 114 | +
  • 115 | +
  • 116 | +
117 | +
118 | ``` 119 | 120 | ```run:command hidden=true cwd=super-rentals 121 | ember test --path dist 122 | git add app/templates/index.hbs 123 | ``` 124 | 125 | With that, we should see the `` component showing our Grand Old Mansion three times on the page: 126 | 127 | ```run:screenshot width=1024 retina=true filename=three-old-mansions.png alt="Three Grand Old Mansions" 128 | visit http://localhost:4200/?deterministic 129 | wait .rentals li:nth-of-type(3) article.rental 130 | ``` 131 | 132 | Things are looking pretty convincing already; not bad for just a little bit of work! 133 | 134 | ## Organizing Code with Namespaced Components 135 | 136 | Next, let's add the image for the rental property. We will use the component generator for this again: 137 | 138 | ```run:command cwd=super-rentals 139 | ember generate component rental/image 140 | ``` 141 | 142 | This time, we had a `/` in the component's name. This resulted in the component being created at `app/components/rental/image.hbs`, which can be invoked as ``. 143 | 144 | ```run:command hidden=true cwd=super-rentals 145 | ember test --path dist 146 | git add app/components/rental/image.hbs 147 | git add tests/integration/components/rental/image-test.js 148 | ``` 149 | 150 | Components like these are known as *[namespaced](https://en.wikipedia.org/wiki/Namespace)* components. Namespacing allows us to organize our components by folders according to their purpose. This is completely optional—namespaced components are not special in any way. 151 | 152 | ## Forwarding HTML Attributes with `...attributes` 153 | 154 | Let's edit the component's template: 155 | 156 | ```run:file:patch lang=handlebars cwd=super-rentals filename=app/components/rental/image.hbs 157 | @@ -1,1 +1,3 @@ 158 | -{{yield}} 159 | \ No newline at end of file 160 | +
161 | + 162 | +
163 | ``` 164 | 165 | Instead of hard-coding specific values for the `src` and `alt` attributes on the `` tag, we opted for the `...attributes` keyword instead, which is also sometimes referred to as the *["splattributes"](../../../components/component-arguments-and-html-attributes/#toc_html-attributes)* syntax. This allows arbitrary HTML attributes to be passed in when invoking this component, like so: 166 | 167 | ```run:file:patch lang=handlebars cwd=super-rentals filename=app/components/rental.hbs 168 | @@ -1,2 +1,6 @@ 169 |
170 | + 174 |
175 | ``` 176 | 177 | We specified a `src` and an `alt` HTML attribute here, which will be passed along to the component and attached to the element where `...attributes` is applied in the component template. You can think of this as being similar to `{{yield}}`, but for HTML attributes specifically, rather than displayed content. In fact, we have already used this feature [earlier](../building-pages/) when we passed a `class` attribute to ``. 178 | 179 | ```run:screenshot width=1024 retina=true filename=rental-image.png alt="The component in action" 180 | visit http://localhost:4200/?deterministic 181 | wait .rentals li:nth-of-type(3) article.rental .image img 182 | ``` 183 | 184 | This way, our `` component is not coupled to any specific rental property on the site. Of course, the hard-coding problem still exists (we simply moved it to the `` component), but we will deal with that soon. We will limit all the hard-coding to the `` component, so that we will have an easier time cleaning it up when we switch to fetching real data. 185 | 186 | In general, it is a good idea to add `...attributes` to the primary element in your component. This will allow for maximum flexibility, as the invoker may need to pass along classes for styling or ARIA attributes to improve accessibility. 187 | 188 | Let's write a test for our new component! 189 | 190 | ```run:file:patch lang=js cwd=super-rentals filename=tests/integration/components/rental/image-test.js 191 | @@ -8,18 +8,15 @@ 192 | 193 | - test('it renders', async function (assert) { 194 | - // Set any properties with this.set('myProperty', 'value'); 195 | - // Handle any actions with this.set('myAction', function(val) { ... }); 196 | - 197 | - await render(hbs``); 198 | - 199 | - assert.dom().hasText(''); 200 | - 201 | - // Template block usage: 202 | - await render(hbs` 203 | - 204 | - template block text 205 | - 206 | - `); 207 | - 208 | - assert.dom().hasText('template block text'); 209 | + test('it renders the given image', async function (assert) { 210 | + await render(hbs` 211 | + 215 | + `); 216 | + 217 | + assert 218 | + .dom('.image img') 219 | + .exists() 220 | + .hasAttribute('src', '/assets/images/teaching-tomster.png') 221 | + .hasAttribute('alt', 'Teaching Tomster'); 222 | }); 223 | ``` 224 | 225 | ## Determining the Appropriate Amount of Test Coverage 226 | 227 | Finally, we should also update the tests for the `` component to confirm that we successfully invoked ``. 228 | 229 | ```run:file:patch lang=js cwd=super-rentals filename=tests/integration/components/rental-test.js 230 | @@ -17,2 +17,3 @@ 231 | assert.dom('article .detail.bedrooms').includesText('15'); 232 | + assert.dom('article .image').exists(); 233 | }); 234 | ``` 235 | 236 | Because we already tested `` extensively on its own, we can omit the details here and keep our assertion to the bare minimum. That way, we won't *also* have to update the `` tests whenever we make changes to ``. 237 | 238 | ```run:command hidden=true cwd=super-rentals 239 | ember test --path dist 240 | git add app/components/rental.hbs 241 | git add app/components/rental/image.hbs 242 | git add tests/integration/components/rental-test.js 243 | git add tests/integration/components/rental/image-test.js 244 | ``` 245 | 246 | ```run:screenshot width=1024 height=512 retina=true filename=pass-2.png alt="Tests passing with the new test" 247 | visit http://localhost:4200/tests?nocontainer&nolint&deterministic 248 | wait #qunit-banner.qunit-pass 249 | ``` 250 | 251 | ```run:server:stop 252 | npm start 253 | ``` 254 | 255 | ```run:checkpoint cwd=super-rentals 256 | Chapter 5 257 | ``` 258 | -------------------------------------------------------------------------------- /src/markdown/tutorial/part-1/06-interactive-components.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ```run:server:start hidden=true cwd=super-rentals expect="Serving on http://localhost:4200/" 4 | npm start 5 | ``` 6 | 7 | In this chapter, you will add interactivity to the page, allowing the user to click an image to enlarge or shrink it: 8 | 9 | 10 | 11 | ![The Super Rentals app by the end of the chapter (default image size)](/images/tutorial/part-1/interactive-components/rental-image-default@2x.png) 12 | 13 | ![The Super Rentals app by the end of the chapter (large image size)](/images/tutorial/part-1/interactive-components/rental-image-large@2x.png) 14 | 15 | While doing so, you will learn about: 16 | 17 | * Adding behavior to components with classes 18 | * Accessing instance states from templates 19 | * Managing state with tracked properties 20 | * Using conditionals syntaxes in templates 21 | * Responding to user interaction with actions 22 | * Invoking element modifiers 23 | * Testing user interactions 24 | 25 | ## Adding Behavior to Components with Classes 26 | 27 | So far, all the components we have written are purely *[presentational][TODO: link to presentational]*—they are simply reusable snippets of markup. That's pretty cool! But in Ember, components can do so much more. 28 | 29 | Sometimes, you want to associate some *[behavior](https://developer.mozilla.org/docs/Learn/JavaScript/Building_blocks/Events)* with your components so that they can do more interesting things. For example, `` can respond to clicks by changing the URL and navigating us to a different page. 30 | 31 | Here, we are going to do just that! We are going to implement the "View Larger" and "View Smaller" functionality, which will allow our users to click on a property's image to view a larger version, and click on it again to return to the smaller version. 32 | 33 | In other words, we want a way to *toggle* the image between one of the two *[states](../../../components/component-state-and-actions/)*. In order to do that, we need a way for the component to store two possible states, and to be aware of which state it is currently in. 34 | 35 | Ember optionally allows us to associate JavaScript code with a component for exactly this purpose. We can add a JavaScript file for our `` component by running the `component-class` generator: 36 | 37 | ```run:command cwd=super-rentals 38 | ember generate component-class rental/image 39 | ``` 40 | 41 | ```run:command hidden=true cwd=super-rentals 42 | ember test --path dist 43 | git add app/components/rental/image.js 44 | ``` 45 | 46 | This generated a JavaScript file with the same name as our component's template at `app/components/rental/image.js`. It contains a *[JavaScript class](https://javascript.info/class)*, *[inheriting](https://javascript.info/class-inheritance)* from `@glimmer/component`. 47 | 48 | > Zoey says... 49 | > 50 | > `@glimmer/component`, or *[Glimmer component](../../../upgrading/current-edition/glimmer-components/)*, is one of the several component classes available to use. They are a great starting point whenever you want to add behavior to your components. In this tutorial, we will be using Glimmer components exclusively. 51 | > 52 | > In general, Glimmer components should be used whenever possible. However, you may also see `@ember/components`, or *[classic components](https://ember-learn.github.io/ember-octane-vs-classic-cheat-sheet/)*, used in older apps. You can tell them apart by looking at their import path (which is helpful for looking up the respective documentation, as they have different and incompatible APIs). 53 | 54 | Ember will create an *[instance][TODO: link to instance]* of the class whenever our component is invoked. We can use that instance to store our state: 55 | 56 | ```run:file:patch lang=js cwd=super-rentals filename=app/components/rental/image.js 57 | @@ -3 +3,6 @@ 58 | -export default class RentalImage extends Component {} 59 | +export default class RentalImage extends Component { 60 | + constructor(...args) { 61 | + super(...args); 62 | + this.isLarge = false; 63 | + } 64 | +} 65 | ``` 66 | 67 | Here, in the *[component's constructor][TODO: link to component's constructor]*, we *[initialized][TODO: link to initialized]* the *[instance variable][TODO: link to instance variable]* `this.isLarge` with the value `false`, since this is the default state that we want for our component. 68 | 69 | ## Accessing Instance States from Templates 70 | 71 | Let's update our template to use this state we just added: 72 | 73 | ```run:file:patch lang=handlebars cwd=super-rentals filename=app/components/rental/image.hbs 74 | @@ -1,3 +1,11 @@ 75 | -
76 | - 77 | -
78 | +{{#if this.isLarge}} 79 | +
80 | + 81 | + View Smaller 82 | +
83 | +{{else}} 84 | +
85 | + 86 | + View Larger 87 | +
88 | +{{/if}} 89 | ``` 90 | 91 | In the template, we have access to the component's instance variables. The `{{#if ...}}...{{else}}...{{/if}}` *[conditionals](../../../components/conditional-content/)* syntax allows us to render different content based on a condition (in this case, the value of the instance variable `this.isLarge`). Combining these two features, we can render either the small or the large version of the image accordingly. 92 | 93 | ```run:command hidden=true cwd=super-rentals 94 | ember test --path dist 95 | git add app/components/rental/image.hbs 96 | git add app/components/rental/image.js 97 | ``` 98 | 99 | We can verify this works by temporarily changing the initial value in our JavaScript file. If we change `app/components/rental/image.js` to initialize `this.isLarge = true;` in the constructor, we should see the large version of the property image in the browser. Cool! 100 | 101 | ```run:file:patch hidden=true cwd=super-rentals filename=app/components/rental/image.js 102 | @@ -5,3 +5,3 @@ 103 | super(...args); 104 | - this.isLarge = false; 105 | + this.isLarge = true; 106 | } 107 | ``` 108 | 109 | ```run:screenshot width=1024 height=1500 retina=true filename=is-large-true.png alt=" with this.isLarge set to true" 110 | visit http://localhost:4200/?deterministic 111 | wait .rentals li:nth-of-type(3) article.rental .image.large img 112 | ``` 113 | 114 | Once we've tested this out, we can change `this.isLarge` back to `false`. 115 | 116 | ```run:command hidden=true cwd=super-rentals 117 | git checkout app/components/rental/image.js 118 | ``` 119 | 120 | Since this pattern of initializing instance variables in the constructor is pretty common, there happens to be a much more concise syntax for it: 121 | 122 | ```run:file:patch lang=js cwd=super-rentals filename=app/components/rental/image.js 123 | @@ -3,6 +3,3 @@ 124 | export default class RentalImage extends Component { 125 | - constructor(...args) { 126 | - super(...args); 127 | - this.isLarge = false; 128 | - } 129 | + isLarge = false; 130 | } 131 | ``` 132 | 133 | This does exactly the same thing as before, but it's much shorter and less to type! 134 | 135 | ```run:command hidden=true cwd=super-rentals 136 | ember test --path dist 137 | git add app/components/rental/image.js 138 | ``` 139 | 140 | Of course, our users cannot edit our source code, so we need a way for them to toggle the image size from the browser. Specifically, we want to toggle the value of `this.isLarge` whenever the user clicks on our component. 141 | 142 | ## Managing State with Tracked Properties 143 | 144 | Let's modify our class to add a *[method](../../../in-depth-topics/native-classes-in-depth/#toc_methods)* for toggling the size: 145 | 146 | ```run:file:patch lang=js cwd=super-rentals filename=app/components/rental/image.js 147 | @@ -1,5 +1,11 @@ 148 | import Component from '@glimmer/component'; 149 | +import { tracked } from '@glimmer/tracking'; 150 | +import { action } from '@ember/object'; 151 | 152 | export default class RentalImage extends Component { 153 | - isLarge = false; 154 | + @tracked isLarge = false; 155 | + 156 | + @action toggleSize() { 157 | + this.isLarge = !this.isLarge; 158 | + } 159 | } 160 | ``` 161 | 162 | We did a few things here, so let's break it down. 163 | 164 | First, we added the `@tracked` *[decorator](../../../in-depth-topics/native-classes-in-depth/#toc_decorators)* to the `isLarge` instance variable. This annotation tells Ember to monitor this variable for updates. Whenever this variable's value changes, Ember will automatically re-render any templates that depend on its value. 165 | 166 | In our case, whenever we assign a new value to `this.isLarge`, the `@tracked` annotation will cause Ember to re-evaluate the `{{#if this.isLarge}}` conditional in our template, and will switch between the two *[blocks](../../../components/conditional-content/#toc_block-if)* accordingly. 167 | 168 | > Zoey says... 169 | > 170 | > Don't worry! If you reference a variable in the template but forget to add the `@tracked` decorator, you will get a helpful development mode error when you change its value! 171 | 172 | ## Responding to User Interaction with Actions 173 | 174 | Next, we added a `toggleSize` method to our class that switches `this.isLarge` to the opposite of its current state (`false` becomes `true`, or `true` becomes `false`). 175 | 176 | Finally, we added the `@action` decorator to our method. This indicates to Ember that we intend to use this method from our template. Without this, the method will not function properly as a callback function (in this case, a click handler). 177 | 178 | > Zoey says... 179 | > 180 | > If you forget to add the `@action` decorator, you will also get a helpful error when clicking on the button in development mode! 181 | 182 | With that, it's time to wire this up in the template: 183 | 184 | ```run:file:patch lang=handlebars cwd=super-rentals filename=app/components/rental/image.hbs 185 | @@ -1,11 +1,11 @@ 186 | {{#if this.isLarge}} 187 | -
188 | +
192 | + 193 | {{else}} 194 | -
195 | +
199 | + 200 | {{/if}} 201 | ``` 202 | 203 | We changed two things here. 204 | 205 | First, since we wanted to make our component interactive, we switched the containing tag from `
` to ` 297 | -{{else}} 298 | - 303 | -{{/if}} 304 | + {{/if}} 305 | + 306 | ``` 307 | 308 | The expression version of `{{if}}` takes two arguments. The first argument is the condition. The second argument is the expression that should be evaluated if the condition is true. 309 | 310 | ```run:command hidden=true cwd=super-rentals 311 | ember test --path dist 312 | git add app/components/rental/image.hbs 313 | ``` 314 | 315 | Optionally, `{{if}}` can take a third argument for what the expression should evaluate into if the condition is false. This means we could rewrite the button label like so: 316 | 317 | ```run:file:patch lang=handlebars cwd=super-rentals filename=app/components/rental/image.hbs 318 | @@ -2,7 +2,3 @@ 319 | 320 | - {{#if this.isLarge}} 321 | - View Smaller 322 | - {{else}} 323 | - View Larger 324 | - {{/if}} 325 | + View {{if this.isLarge "Smaller" "Larger"}} 326 | 327 | ``` 328 | 329 | Whether or not this is an improvement in the clarity of our code is mostly a matter of taste. Either way, we have significantly reduced the duplication in our code, and made the important bits of logic stand out from the rest. 330 | 331 | Run the test suite one last time to confirm our refactor didn't break anything unexpectedly, and we will be ready for the next challenge! 332 | 333 | ```run:command hidden=true cwd=super-rentals 334 | ember test --path dist 335 | git add app/components/rental/image.hbs 336 | ``` 337 | 338 | ```run:screenshot width=1024 height=512 retina=true filename=pass-2.png alt="Tests still passing after the refactor" 339 | visit http://localhost:4200/tests?nocontainer&nolint&deterministic 340 | wait #qunit-banner.qunit-pass 341 | ``` 342 | 343 | ```run:server:stop 344 | npm start 345 | ``` 346 | 347 | ```run:checkpoint cwd=super-rentals 348 | Chapter 6 349 | ``` 350 | -------------------------------------------------------------------------------- /src/markdown/tutorial/part-1/index.md: -------------------------------------------------------------------------------- 1 | Welcome to the Ember Tutorial! 2 | 3 | In this tutorial, we will use Ember to build an application called Super Rentals. This will be a website for browsing interesting places to stay during your next vacation. Check out the [finished app](https://ember-super-rentals.netlify.app) to get a sense of the scope of the project. 4 | 5 | ![The finished Super Rentals app](/images/tutorial/part-2/provider-components/homepage-with-rentals-component@2x.png) 6 | 7 | Along the way, you will learn everything you need to know to build a basic Ember application. If you get stuck at any point during the tutorial, feel free to download for a complete working example. 8 | 9 | This tutorial is structured into two parts. The first part covers the following basic concepts: 10 | 11 | * Using Ember CLI 12 | * Navigating the file and folder structure of an Ember app 13 | * Building and linking between pages 14 | * Templates and components 15 | * Automated testing 16 | * Working with server data 17 | 18 | The second part of the tutorial builds upon these concepts and takes things to the next level. 19 | 20 | Let's dive right in! 21 | -------------------------------------------------------------------------------- /src/markdown/tutorial/part-1/recap.md: -------------------------------------------------------------------------------- 1 | Congratulations, you finished the first part of this tutorial! 2 | 3 | It was quite a journey! To recap, here is what you have learned: 4 | 5 | 6 | 7 |

Chapter 1

8 | 9 | * Installing Ember CLI 10 | * Creating a new Ember app with Ember CLI 11 | * Starting and stopping the development server 12 | * Editing files and live reload 13 | * Working with HTML, CSS and assets in an Ember app 14 | 15 |

Chapter 2

16 | 17 | * Defining routes 18 | * Using route templates 19 | * Customizing URLs 20 | * Linking pages with the `` component 21 | * Passing arguments and attributes to components 22 | 23 |

Chapter 3

24 | 25 | * The purpose of automated testing 26 | * Writing acceptance tests 27 | * Using generators in Ember CLI 28 | * Testing with the QUnit test framework 29 | * Working with Ember's test helpers 30 | * Practicing the testing workflow 31 | 32 |

Chapter 4

33 | 34 | * Extracting markup into components 35 | * Invoking components 36 | * Passing content to components 37 | * Yielding content with the `{{yield}}` keyword 38 | * Refactoring existing code 39 | * Writing component tests 40 | * Using the application template and `{{outlet}}`s 41 | 42 |

Chapter 5

43 | 44 | * Generating components 45 | * Organizing code with namespaced components 46 | * Forwarding HTML attributes with `...attributes` 47 | * Determining the appropriate amount of test coverage 48 | 49 |

Chapter 6

50 | 51 | * Adding behavior to components with classes 52 | * Accessing instance states from templates 53 | * Managing state with tracked properties 54 | * Using conditionals syntaxes in templates 55 | * Responding to user interaction with actions 56 | * Invoking element modifiers 57 | * Testing user interactions 58 | 59 |

Chapter 7

60 | 61 | * Managing application-level configurations 62 | * Parameterizing components with arguments 63 | * Accessing component arguments 64 | * Interpolating values in templates 65 | * Overriding HTML attributes in `...attributes` 66 | * Refactoring with getters and auto-track 67 | * Getting JavaScript values into the test context 68 | 69 |

Chapter 8

70 | 71 | * Working with route files 72 | * Returning local data from the model hook 73 | * Accessing route models from templates 74 | * Mocking server data with static JSON files 75 | * Fetching remote data from the model hook 76 | * Adapting server data 77 | * Loops and local variables in templates with `{{#each}}` 78 | 79 | That's a lot! At this point, you are well equipped to perform a wide variety of development tasks in Ember! 80 | 81 | Go ahead and take a break, or experiment with creating your own unique Ember app using the skills you just learned. 82 | 83 | When you come back, we build upon what we learned in Part 1 and take things to the next level! 84 | -------------------------------------------------------------------------------- /src/markdown/tutorial/part-2/09-route-params.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ```run:server:start hidden=true cwd=super-rentals expect="Serving on http://localhost:4200/" 4 | npm start 5 | ``` 6 | 7 | Now that we are fetching real data from our "server", let's add a new feature — dedicated pages for each of our rentals: 8 | 9 | ![The Super Rentals app (rentals page) by the end of the chapter](/images/tutorial/part-2/route-params/grand-old-mansion@2x.png) 10 | 11 | While adding these rental pages, you will learn about: 12 | * Routes with dynamic segments 13 | * Links with dynamic segments 14 | * Component tests with access to the router 15 | * Accessing parameters from dynamic segments 16 | * Sharing common setup code between tests 17 | 18 | ## Routes with Dynamic Segments 19 | 20 | It would be great for our individual rental pages to be available through predictable URLs like `/rentals/grand-old-mansion`. Also, since these pages are dedicated to individual rentals, we can show more detailed information about each property on this page. It would also be nice to be able to have a way to bookmark a rental property, and share direct links to each individual rental listing so that our users can come back to these pages later on, after they are done browsing. 21 | 22 | But first things first: we need to add a route for this new page. We can do that by adding a `rental` route to the router. 23 | 24 | ```run:file:patch lang=js cwd=super-rentals filename=app/router.js 25 | @@ -11,2 +11,3 @@ 26 | this.route('contact', { path: '/getting-in-touch' }); 27 | + this.route('rental', { path: '/rentals/:rental_id' }); 28 | }); 29 | ``` 30 | 31 | Notice that we are doing something a little different here. Instead of using the default path (`/rental`), we're specifying a custom path. Not only are we using a custom path, but we're also passing in a `:rental_id`, which is what we call a *[dynamic segment](../../../routing/defining-your-routes/#toc_dynamic-segments)*. When these routes are evaluated, the `rental_id` will be substituted with the `id` of the individual rental property that we are trying to navigate to. 32 | 33 | ```run:command hidden=true cwd=super-rentals 34 | git add app/router.js 35 | ``` 36 | 37 | ## Links with Dynamic Segments 38 | 39 | Now that we have this route in place, we can update our `` component to actually *link* to each of our detailed rental properties! 40 | 41 | ```run:file:patch lang=handlebars cwd=super-rentals filename=app/components/rental.hbs 42 | @@ -6,3 +6,7 @@ 43 |
44 | -

{{@rental.title}}

45 | +

46 | + 47 | + {{@rental.title}} 48 | + 49 | +

50 |
51 | ``` 52 | 53 | Since we know that we're linking to the `rental` route that we just created, we also know that this route requires a dynamic segment. Thus, we need to pass in a `@model` argument so that the `` component can generate the appropriate URL for that model. 54 | 55 | ```run:command hidden=true cwd=super-rentals 56 | git add app/components/rental.hbs 57 | ``` 58 | 59 | Let's see this in action. If we go back to our browser and refresh the page, we should see our links, but something isn't quite right yet! 60 | 61 | ```run:screenshot width=1024 retina=true filename=broken-links.png alt="Broken links" 62 | visit http://localhost:4200/?deterministic 63 | ``` 64 | 65 | The links are all pointing to `/rentals/undefined`. Yikes! This is because `` tries to use the `id` property from our model in order to replace the dynamic segment and generate the URL. 66 | 67 | So what's the problem here? Well, our model doesn't actually have an `id` property! So *of course* the `` component isn't going to be able to find it and use it to generate the URL. Oops! 68 | 69 | Thankfully, we can fix this pretty easily. As it turns out, the data that is returned by our server—the JSON data that lives in our `public/api` folder—actually does have an `id` attribute on it. We can double check this by going to `http://localhost:4200/api/rentals.json`. 70 | 71 | ```run:screenshot width=1024 height=512 retina=true filename=data.png alt="Our data do have an id attribute" 72 | visit http://localhost:4200/api/rentals.json 73 | ``` 74 | 75 | If we look at the JSON data here, we can see that the `id` is included right alongside the `attributes` key. So we have access to this data; the only trouble is that we're not including it in our model! Let's change our model hook in the `index` route so that it includes the `id`. 76 | 77 | ```run:file:patch lang=js cwd=super-rentals filename=app/routes/index.js 78 | @@ -14,3 +14,3 @@ 79 | return data.map((model) => { 80 | - let { attributes } = model; 81 | + let { id, attributes } = model; 82 | let type; 83 | @@ -23,3 +23,3 @@ 84 | 85 | - return { type, ...attributes }; 86 | + return { id, type, ...attributes }; 87 | }); 88 | ``` 89 | 90 | Now that we've included our model's `id`, we should see the correct URLs to each rental property on our index page after refreshing the page. 91 | 92 | ```run:command hidden=true cwd=super-rentals 93 | git add app/routes/index.js 94 | ``` 95 | 96 | ## Component Tests with Access to the Router 97 | 98 | Alright, we have just one more step left here: updating the tests. We can add an `id` to the rental that we defined in our test using `setProperties` and add an assertion for the expected URL, too. 99 | 100 | ```run:file:patch lang=js cwd=super-rentals filename=tests/integration/components/rental-test.js 101 | @@ -11,2 +11,3 @@ 102 | rental: { 103 | + id: 'grand-old-mansion', 104 | title: 'Grand Old Mansion', 105 | @@ -30,2 +31,5 @@ 106 | assert.dom('article h3').hasText('Grand Old Mansion'); 107 | + assert 108 | + .dom('article h3 a') 109 | + .hasAttribute('href', '/rentals/grand-old-mansion'); 110 | assert.dom('article .detail.owner').includesText('Veruca Salt'); 111 | ``` 112 | 113 | ```run:command hidden=true cwd=super-rentals 114 | git add tests/integration/components/rental-test.js 115 | ``` 116 | 117 | If we run the tests in the browser, everything should just pass! 118 | 119 | ```run:screenshot width=1024 height=768 retina=true filename=pass.png alt="Tests are passing" 120 | visit http://localhost:4200/tests?nocontainer&nolint&deterministic 121 | wait #qunit-banner.qunit-pass 122 | ``` 123 | 124 | ## Accessing Parameters from Dynamic Segments 125 | 126 | Awesome! We're making such great progress. 127 | 128 | Now that we have our `rental` route, let's finish up our `rental` page. The first step to doing this is making our route actually *do* something. We added the route, but we haven't actually implemented it. So let's do that first by creating the route file. 129 | 130 | ```run:file:create lang=js cwd=super-rentals filename=app/routes/rental.js 131 | import Route from '@ember/routing/route'; 132 | 133 | const COMMUNITY_CATEGORIES = ['Condo', 'Townhouse', 'Apartment']; 134 | 135 | export default class RentalRoute extends Route { 136 | async model(params) { 137 | let response = await fetch(`/api/rentals/${params.rental_id}.json`); 138 | let { data } = await response.json(); 139 | 140 | let { id, attributes } = data; 141 | let type; 142 | 143 | if (COMMUNITY_CATEGORIES.includes(attributes.category)) { 144 | type = 'Community'; 145 | } else { 146 | type = 'Standalone'; 147 | } 148 | 149 | return { id, type, ...attributes }; 150 | } 151 | } 152 | ``` 153 | 154 | We'll notice that the model hook in our `RentalRoute` is *almost* the same as our `IndexRoute`. There is one major difference between these two routes, and we can see that difference reflected here. 155 | 156 | Unlike the `IndexRoute`, we have a `params` object being passed into our model hook. This is because we need to fetch our data from the `/api/rentals/${id}.json` endpoint, *not* the `/api/rentals.json` endpoint we were previously using. We already know that the individual rental endpoints fetch a single rental object, rather than an array of them, and that the route uses a `/:rental_id` dynamic segment to figure out which rental object we're trying to fetch from the server. 157 | 158 | But how does the dynamic segment actually get to the `fetch` function? Well, we have to pass it into the function. Conveniently, we have access to the value of the `/:rental_id` dynamic segment through the `params` object. This is why we have a `params` argument in our model hook here. It is being passed through to this hook, and we use the `params.rental_id` attribute to figure out what data we want to `fetch`. 159 | 160 | Other than these minor differences though, the rest of the route is pretty much the same to what we had in our index route. 161 | 162 | ```run:command hidden=true cwd=super-rentals 163 | git add app/routes/rental.js 164 | ``` 165 | 166 | ## Displaying Model Details with a Component 167 | 168 | Next, let's make a `` component. 169 | 170 | ```run:command cwd=super-rentals 171 | ember generate component rental/detailed 172 | ``` 173 | 174 | ```run:command hidden=true cwd=super-rentals 175 | ember test --path dist 176 | git add app/components/rental/detailed.hbs 177 | git add tests/integration/components/rental/detailed-test.js 178 | ``` 179 | 180 | ```run:file:patch lang=handlebars cwd=super-rentals filename=app/components/rental/detailed.hbs 181 | @@ -1 +1,44 @@ 182 | -{{yield}} 183 | \ No newline at end of file 184 | + 185 | +

{{@rental.title}}

186 | +

Nice find! This looks like a nice place to stay near {{@rental.city}}.

187 | + 190 | +
191 | + 192 | +
193 | + 197 | + 198 | +
199 | +

About {{@rental.title}}

200 | + 201 | +
202 | + Owner: {{@rental.owner}} 203 | +
204 | +
205 | + Type: {{@rental.type}} – {{@rental.category}} 206 | +
207 | +
208 | + Location: {{@rental.city}} 209 | +
210 | +
211 | + Number of bedrooms: {{@rental.bedrooms}} 212 | +
213 | +
214 | +

{{@rental.description}}

215 | +
216 | +
217 | + 218 | + 227 | +
228 | ``` 229 | 230 | This component is similar to our `` component, except for the following differences. 231 | 232 | * It shows a banner with a share button at the top (Implementation to come later). 233 | * It shows a bigger image by default, with some additional detailed information. 234 | * It shows a bigger map. 235 | * It shows a description. 236 | 237 | ## Sharing Common Setup Code Between Tests 238 | 239 | Now that we have this template in place, we can add some tests for this new component of ours. 240 | 241 | ```run:file:patch lang=handlebars cwd=super-rentals filename=tests/integration/components/rental/detailed-test.js 242 | @@ -8,18 +8,46 @@ 243 | 244 | - test('it renders', async function (assert) { 245 | - // Set any properties with this.set('myProperty', 'value'); 246 | - // Handle any actions with this.set('myAction', function(val) { ... }); 247 | + hooks.beforeEach(function () { 248 | + this.setProperties({ 249 | + rental: { 250 | + id: 'grand-old-mansion', 251 | + title: 'Grand Old Mansion', 252 | + owner: 'Veruca Salt', 253 | + city: 'San Francisco', 254 | + location: { 255 | + lat: 37.7749, 256 | + lng: -122.4194, 257 | + }, 258 | + category: 'Estate', 259 | + type: 'Standalone', 260 | + bedrooms: 15, 261 | + image: 262 | + 'https://upload.wikimedia.org/wikipedia/commons/c/cb/Crane_estate_(5).jpg', 263 | + description: 264 | + 'This grand old mansion sits on over 100 acres of rolling hills and dense redwood forests.', 265 | + }, 266 | + }); 267 | + }); 268 | 269 | - await render(hbs``); 270 | + test('it renders a header with a share button', async function (assert) { 271 | + await render(hbs``); 272 | 273 | - assert.dom().hasText(''); 274 | + assert.dom('.jumbo').exists(); 275 | + assert.dom('.jumbo h2').containsText('Grand Old Mansion'); 276 | + assert 277 | + .dom('.jumbo p') 278 | + .containsText('a nice place to stay near San Francisco'); 279 | + assert.dom('.jumbo a.button').containsText('Share on Twitter'); 280 | + }); 281 | 282 | - // Template block usage: 283 | - await render(hbs` 284 | - 285 | - template block text 286 | - 287 | - `); 288 | + test('it renders detailed information about a rental property', async function (assert) { 289 | + await render(hbs``); 290 | 291 | - assert.dom().hasText('template block text'); 292 | + assert.dom('article').hasClass('rental'); 293 | + assert.dom('article h3').containsText('About Grand Old Mansion'); 294 | + assert.dom('article .detail.owner').containsText('Veruca Salt'); 295 | + assert.dom('article .detail.type').containsText('Standalone – Estate'); 296 | + assert.dom('article .detail.location').containsText('San Francisco'); 297 | + assert.dom('article .detail.bedrooms').containsText('15'); 298 | + assert.dom('article .image').exists(); 299 | + assert.dom('article .map').exists(); 300 | }); 301 | ``` 302 | 303 | We can use the `beforeEach` hook to share some boilerplate code, which allows us to have two tests that each focus on a different, single aspect of the component. This feels similar to other tests that we've already written—hopefully it feels easy, too! 304 | 305 | > Zoey says... 306 | > 307 | > As its name implies, the `beforeEach` hook runs *once* before each `test` function is executed. This hook is the ideal place to set up anything that might be needed by all test cases in the file. On the other hand, if you need to do any cleanup after your tests, there is an `afterEach` hook! 308 | 309 | ```run:command hidden=true cwd=super-rentals 310 | ember test --path dist 311 | git add app/components/rental/detailed.hbs 312 | git add tests/integration/components/rental/detailed-test.js 313 | ``` 314 | 315 | ```run:screenshot width=1024 height=768 retina=true filename=pass-2.png alt="Tests are passing as expected" 316 | visit http://localhost:4200/tests?nocontainer&nolint&deterministic 317 | wait #qunit-banner.qunit-pass 318 | ``` 319 | 320 | ## Adding a Route Template 321 | 322 | Finally, let's add a `rental` template to actually *invoke* our `` component, as well as adding an acceptance test for this new behavior in our app. 323 | 324 | ```run:file:create lang=handlebars cwd=super-rentals filename=app/templates/rental.hbs 325 | 326 | ``` 327 | 328 | ```run:file:patch lang=js cwd=super-rentals filename=tests/acceptance/super-rentals-test.js 329 | @@ -21,2 +21,20 @@ 330 | 331 | + test('viewing the details of a rental property', async function (assert) { 332 | + await visit('/'); 333 | + assert.dom('.rental').exists({ count: 3 }); 334 | + 335 | + await click('.rental:first-of-type a'); 336 | + assert.strictEqual(currentURL(), '/rentals/grand-old-mansion'); 337 | + }); 338 | + 339 | + test('visiting /rentals/grand-old-mansion', async function (assert) { 340 | + await visit('/rentals/grand-old-mansion'); 341 | + 342 | + assert.strictEqual(currentURL(), '/rentals/grand-old-mansion'); 343 | + assert.dom('nav').exists(); 344 | + assert.dom('h1').containsText('SuperRentals'); 345 | + assert.dom('h2').containsText('Grand Old Mansion'); 346 | + assert.dom('.rental.detailed').exists(); 347 | + }); 348 | + 349 | test('visiting /about', async function (assert) { 350 | ``` 351 | 352 | Now, when we visit `http://localhost:4200/rentals/grand-old-mansion`, this is what we see: 353 | 354 | ```run:screenshot width=1024 retina=true filename=grand-old-mansion.png alt="A dedicated page for the Grand Old Mansion" 355 | visit http://localhost:4200/rentals/grand-old-mansion?deterministic 356 | wait .rental.detailed 357 | ``` 358 | 359 | And if we run our tests now... 360 | 361 | ```run:command hidden=true cwd=super-rentals 362 | ember test --path dist 363 | git add app/templates/rental.hbs 364 | git add tests/acceptance/super-rentals-test.js 365 | ``` 366 | 367 | ```run:screenshot width=1024 height=768 retina=true filename=pass-3.png alt="All tests passing!" 368 | visit http://localhost:4200/tests?nocontainer&nolint&deterministic 369 | wait #qunit-banner.qunit-pass 370 | ``` 371 | 372 | ...they all pass! Great work! 373 | 374 | This page *looks* done, but we have a share button that doesn't actually work. We'll address this in the next chapter. 375 | 376 | ```run:server:stop 377 | npm start 378 | ``` 379 | 380 | ```run:checkpoint cwd=super-rentals 381 | Chapter 9 382 | ``` 383 | -------------------------------------------------------------------------------- /src/markdown/tutorial/part-2/index.md: -------------------------------------------------------------------------------- 1 | Hooray, you've made it to the second part of the tutorial! In the following sections, we'll build on the core concepts that we learned in the first part of the tutorial. 2 | 3 | Along the way, we'll also add some new features to our Super Rentals app. By the end of this section, we'll have implemented some search functionality and refactored a good bit of our code to use some new Ember concepts 4 | 5 | ![Search functionality in the Super Rentals app](/images/tutorial/part-2/provider-components/filtered-results@2x.png) 6 | 7 | In part two, we'll cover the following concepts: 8 | * Dynamic segments 9 | * Ember services 10 | * EmberData 11 | * Adapters and serializers 12 | * The provider component pattern 13 | 14 | We're going to cover a lot of ground, so let's get learning! 15 | -------------------------------------------------------------------------------- /src/markdown/tutorial/part-2/recap.md: -------------------------------------------------------------------------------- 1 | Congratulations, you finished the second part of the tutorial! 2 | 3 | There was a lot of concepts to cover in part two. To recap, here is what you learned: 4 | 5 |

Chapter 9

6 | 7 | * Routes with dynamic segments 8 | * Links with dynamic segments 9 | * Component tests with access to the router 10 | * Accessing parameters from dynamic segments 11 | * Sharing common setup code between tests 12 | 13 |

Chapter 10

14 | 15 | * Splattributes and the `class` attribute 16 | * The router service 17 | * Ember services vs. global variables 18 | * Mocking services in tests 19 | 20 |

Chapter 11

21 | 22 | * EmberData models 23 | * Testing models 24 | * Loading models in routes 25 | * The EmberData store 26 | * Working with adapters and serializers 27 | 28 |

Chapter 12

29 | 30 | * Using Ember's built-in `` component 31 | * The provider component pattern 32 | * Using block parameters when invoking components 33 | * Yielding data to caller components 34 | 35 | Awesome! The concepts you learned about in part 2 of the tutorial are ones that you'll find in many production-level Ember apps. You've now taken your knowledge to the next level—and you've also finished the entire tutorial, hooray! 36 | 37 | If you're curious to learn more, you can check out the rest of the guides and learn more about the concepts we've covered in even more depth! If you want to practice some of the ideas we've covered, you can also try building your own Ember app. 38 | 39 | Happy coding! 40 | -------------------------------------------------------------------------------- /src/markdown/tutorial/routes-and-templates.md: -------------------------------------------------------------------------------- 1 | --- 2 | redirect: tutorial/part-1/component-basics 3 | --- 4 | -------------------------------------------------------------------------------- /src/markdown/tutorial/service.md: -------------------------------------------------------------------------------- 1 | --- 2 | redirect: tutorial/part-2/service-injection 3 | --- 4 | -------------------------------------------------------------------------------- /src/markdown/tutorial/simple-component.md: -------------------------------------------------------------------------------- 1 | --- 2 | redirect: tutorial/part-1/component-basics 3 | --- 4 | -------------------------------------------------------------------------------- /src/markdown/tutorial/subroutes.md: -------------------------------------------------------------------------------- 1 | --- 2 | redirect: tutorial/part-2/route-params 3 | --- 4 | -------------------------------------------------------------------------------- /src/types/remark-frontmatter.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'remark-frontmatter' { 2 | import { Content } from 'mdast'; 3 | import { Plugin } from 'unified'; 4 | 5 | type Preset = 'yaml' | 'toml'; 6 | 7 | type Delimiter = string | { open: string, close: string }; 8 | 9 | type Matter = { 10 | type: Content['type'], 11 | marker: Delimiter, 12 | anywhere?: boolean 13 | } | { 14 | type: Content['type'], 15 | fence: Delimiter, 16 | anywhere?: boolean 17 | }; 18 | 19 | type Option = Preset | Matter; 20 | 21 | interface FrontmatterPlugin extends Plugin<[Option?]> {} 22 | 23 | const Plugin: FrontmatterPlugin; 24 | 25 | export default Plugin; 26 | } 27 | -------------------------------------------------------------------------------- /src/types/remark-parse-yaml.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'remark-parse-yaml' { 2 | import { Plugin } from 'unified'; 3 | 4 | interface ParseYAMLPlugin extends Plugin<[]> {} 5 | 6 | const Plugin: ParseYAMLPlugin; 7 | 8 | export default Plugin; 9 | } 10 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Basic Options */ 4 | // "incremental": true, /* Enable incremental compilation */ 5 | "target": "es2019", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019' or 'ESNEXT'. */ 6 | "module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */ 7 | // "lib": [], /* Specify library files to be included in the compilation. */ 8 | // "allowJs": true, /* Allow javascript files to be compiled. */ 9 | // "checkJs": true, /* Report errors in .js files. */ 10 | // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ 11 | // "declaration": true, /* Generates corresponding '.d.ts' file. */ 12 | // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ 13 | "sourceMap": true, /* Generates corresponding '.map' file. */ 14 | // "outFile": "./", /* Concatenate and emit output to single file. */ 15 | "outDir": "./dist/", /* Redirect output structure to the directory. */ 16 | // "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ 17 | // "composite": true, /* Enable project compilation */ 18 | // "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */ 19 | // "removeComments": true, /* Do not emit comments to output. */ 20 | // "noEmit": true, /* Do not emit outputs. */ 21 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */ 22 | // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ 23 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ 24 | 25 | /* Strict Type-Checking Options */ 26 | "strict": true, /* Enable all strict type-checking options. */ 27 | // "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ 28 | // "strictNullChecks": true, /* Enable strict null checks. */ 29 | // "strictFunctionTypes": true, /* Enable strict checking of function types. */ 30 | // "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */ 31 | // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ 32 | // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ 33 | // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ 34 | 35 | /* Additional Checks */ 36 | "noUnusedLocals": true, /* Report errors on unused locals. */ 37 | "noUnusedParameters": true, /* Report errors on unused parameters. */ 38 | "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 39 | "noFallthroughCasesInSwitch": true , /* Report errors for fallthrough cases in switch statement. */ 40 | 41 | /* Module Resolution Options */ 42 | // "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ 43 | // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ 44 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ 45 | "rootDirs": ["src"], /* List of root folders whose combined content represents the structure of the project at runtime. */ 46 | "typeRoots": [ /* List of folders to include type definitions from. */ 47 | "src/types", 48 | "node_modules/@types" 49 | ], 50 | // "types": [], /* Type declaration files to be included in compilation. */ 51 | // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 52 | "esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ 53 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ 54 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 55 | 56 | /* Source Map Options */ 57 | // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 58 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 59 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 60 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 61 | 62 | /* Experimental Options */ 63 | // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ 64 | // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["tslint:recommended","tslint-config-prettier"], 3 | "defaultSeverity": "warning", 4 | "rules": { 5 | "arrow-parens": [true, "ban-single-arg-parens"], 6 | "curly": [true, "ignore-same-line"], 7 | "forin": false, 8 | "interface-name": [true, "never-prefix"], 9 | "max-classes-per-file": false, 10 | "max-line-length": [false], 11 | "member-access": [true, "no-public"], 12 | "no-console": false, 13 | "no-duplicate-imports": true, 14 | "no-empty-interface": false, 15 | "object-literal-sort-keys": false, 16 | "ordered-imports": [ 17 | true, 18 | { 19 | "import-sources-order": "lowercase-last", 20 | "named-imports-order": "lowercase-last" 21 | } 22 | ], 23 | "prefer-const": false, 24 | "quotemark": [true, "single"], 25 | "trailing-comma": [true, { "multiline": "never", "singleline": "never" }], 26 | "variable-name": [true, "ban-keywords", "check-format", "allow-leading-underscore", "allow-pascal-case"], 27 | "whitespace": [true, 28 | "check-branch", "check-decl", "check-module", "check-operator", "check-preblock", 29 | "check-rest-spread", "check-separator", "check-type", "check-type-operator", "check-typecast" 30 | ] 31 | } 32 | } 33 | --------------------------------------------------------------------------------