├── .credo.exs ├── .dockerignore ├── .formatter.exs ├── .github ├── CODEOWNERS └── workflows │ ├── ci.yml │ ├── common-config.yml │ └── deploy.yml ├── .gitignore ├── .tool-versions ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── ROCKET-VALIDATOR.md ├── app.json ├── assets ├── css │ └── app.css ├── js │ ├── app.js │ ├── dark-mode.js │ ├── initialize-theme.js │ ├── menu-toggle.js │ └── pickup.js ├── package-lock.json ├── package.json ├── postcss.config.js ├── static │ ├── favicon.ico │ └── robots.txt └── tailwind.config.js ├── config ├── config.exs ├── dev.exs ├── lessons.exs ├── prod.exs ├── redirects.exs ├── releases.exs └── test.exs ├── fly.toml ├── lib ├── mix │ └── tasks │ │ ├── school_house.gen.rss.ex │ │ └── school_house.gen.sitemap.ex ├── school_house.ex ├── school_house │ ├── application.ex │ ├── conferences.ex │ ├── content │ │ ├── conference.ex │ │ ├── lesson.ex │ │ ├── podcast.ex │ │ └── post.ex │ ├── lessons.ex │ ├── locale_info.ex │ ├── podcasts.ex │ └── posts.ex ├── school_house_web.ex └── school_house_web │ ├── channels │ └── user_socket.ex │ ├── controllers │ ├── fallback_controller.ex │ ├── lesson_controller.ex │ ├── page_controller.ex │ ├── post_controller.ex │ └── report_controller.ex │ ├── endpoint.ex │ ├── gettext.ex │ ├── live │ ├── conferences_live.ex │ └── conferences_live.html.heex │ ├── plugs │ ├── redirect_plug.ex │ └── set_locale_plug.ex │ ├── router.ex │ ├── telemetry.ex │ ├── templates │ ├── error │ │ ├── error.html.heex │ │ └── translation_missing.html.heex │ ├── layout │ │ ├── _dark_mode_toggle.html.heex │ │ ├── _footer.html.heex │ │ ├── _header.html.heex │ │ ├── _lesson_menu.html.heex │ │ ├── _locale_menu.html.heex │ │ ├── app.html.heex │ │ ├── live.html.heex │ │ └── root.html.heex │ ├── lesson │ │ ├── _advanced.html.heex │ │ ├── _basics.html.heex │ │ ├── _data_processing.html.heex │ │ ├── _ecto.html.heex │ │ ├── _intermediate.html.heex │ │ ├── _misc.html.heex │ │ ├── _pagination.html.heex │ │ ├── _section_header.html.heex │ │ ├── _storage.html.heex │ │ ├── _testing.html.heex │ │ ├── index.html.heex │ │ └── lesson.html.heex │ ├── page │ │ ├── _newsletter.html.heex │ │ ├── get_involved.html.heex │ │ ├── index.html.heex │ │ ├── podcasts.html.heex │ │ ├── privacy.html.heex │ │ └── why.html.heex │ ├── post │ │ ├── _post_preview.html.heex │ │ ├── index.html.heex │ │ └── post.html.heex │ └── report │ │ ├── _coming_soon.html.heex │ │ ├── _lesson.html.heex │ │ ├── _missing.html.heex │ │ ├── _section.html.heex │ │ └── report.html.heex │ └── views │ ├── error_helpers.ex │ ├── error_view.ex │ ├── html_helpers.ex │ ├── layout_view.ex │ ├── lesson_view.ex │ ├── page_view.ex │ ├── post_view.ex │ └── report_view.ex ├── mix.exs ├── mix.lock ├── priv └── gettext │ ├── ar │ └── LC_MESSAGES │ │ └── default.po │ ├── bg │ └── LC_MESSAGES │ │ └── default.po │ ├── bn │ └── LC_MESSAGES │ │ └── default.po │ ├── de │ └── LC_MESSAGES │ │ └── default.po │ ├── default.pot │ ├── el │ └── LC_MESSAGES │ │ └── default.po │ ├── en │ └── LC_MESSAGES │ │ └── default.po │ ├── es │ └── LC_MESSAGES │ │ └── default.po │ ├── fa │ └── LC_MESSAGES │ │ └── default.po │ ├── fr │ └── LC_MESSAGES │ │ └── default.po │ ├── id │ └── LC_MESSAGES │ │ └── default.po │ ├── it │ └── LC_MESSAGES │ │ └── default.po │ ├── ja │ └── LC_MESSAGES │ │ └── default.po │ ├── ko │ └── LC_MESSAGES │ │ └── default.po │ ├── ms │ └── LC_MESSAGES │ │ └── default.po │ ├── no │ └── LC_MESSAGES │ │ └── default.po │ ├── pl │ └── LC_MESSAGES │ │ └── default.po │ ├── pt │ └── LC_MESSAGES │ │ └── default.po │ ├── ru │ └── LC_MESSAGES │ │ └── default.po │ ├── sk │ └── LC_MESSAGES │ │ └── default.po │ ├── ta │ └── LC_MESSAGES │ │ └── default.po │ ├── th │ └── LC_MESSAGES │ │ └── default.po │ ├── tr │ └── LC_MESSAGES │ │ └── default.po │ ├── uk │ └── LC_MESSAGES │ │ └── default.po │ └── vi │ └── LC_MESSAGES │ └── default.po ├── rel ├── env.sh.eex ├── overlays │ └── bin │ │ └── server ├── remote.vm.args.eex └── vm.args.eex └── test ├── school_house ├── conferences_test.exs ├── content │ └── lesson_test.exs ├── lessons_test.exs ├── locales_test.exs └── posts_test.exs ├── school_house_web ├── controllers │ ├── lesson_controller_test.exs │ ├── page_controller_test.exs │ └── post_controller_test.exs ├── live │ └── conference_live_test.exs └── views │ ├── error_view_test.exs │ ├── html_helpers_test.exs │ └── layout_view_test.exs ├── support ├── channel_case.ex ├── conn_case.ex └── content │ ├── conferences │ ├── test_in_person_conference.md │ └── test_online_conference.md │ ├── lessons │ ├── en │ │ ├── basics │ │ │ ├── basics.md │ │ │ ├── collections.md │ │ │ └── enum.md │ │ └── intermediate │ │ │ └── erlang.md │ ├── es │ │ └── basics │ │ │ ├── basics.md │ │ │ └── collections.md │ └── ko │ │ └── basics │ │ └── collections.md │ └── posts │ ├── 2021-06-13-test_blog_post.md │ └── 2021-08-11-another_test_blog_post.md └── test_helper.exs /.credo.exs: -------------------------------------------------------------------------------- 1 | # This file contains the configuration for Credo and you are probably reading 2 | # this after creating it with `mix credo.gen.config`. 3 | # 4 | # If you find anything wrong or unclear in this file, please report an 5 | # issue on GitHub: https://github.com/rrrene/credo/issues 6 | # 7 | %{ 8 | # 9 | # You can have as many configs as you like in the `configs:` field. 10 | configs: [ 11 | %{ 12 | # 13 | # Run any config using `mix credo -C `. If no config name is given 14 | # "default" is used. 15 | # 16 | name: "default", 17 | # 18 | # These are the files included in the analysis: 19 | files: %{ 20 | # 21 | # You can give explicit globs or simply directories. 22 | # In the latter case `**/*.{ex,exs}` will be used. 23 | # 24 | included: [ 25 | "lib/", 26 | "src/", 27 | "test/", 28 | "web/", 29 | "apps/*/lib/", 30 | "apps/*/src/", 31 | "apps/*/test/", 32 | "apps/*/web/" 33 | ], 34 | excluded: [~r"/_build/", ~r"/deps/", ~r"/node_modules/", ~r"test/support/"] 35 | }, 36 | # 37 | # Load and configure plugins here: 38 | # 39 | plugins: [], 40 | # 41 | # If you create your own checks, you must specify the source files for 42 | # them here, so they can be loaded by Credo before running the analysis. 43 | # 44 | requires: [], 45 | # 46 | # If you want to enforce a style guide and need a more traditional linting 47 | # experience, you can change `strict` to `true` below: 48 | # 49 | strict: false, 50 | # 51 | # To modify the timeout for parsing files, change this value: 52 | # 53 | parse_timeout: 5000, 54 | # 55 | # If you want to use uncolored output by default, you can change `color` 56 | # to `false` below: 57 | # 58 | color: true, 59 | # 60 | # You can customize the parameters of any check by adding a second element 61 | # to the tuple. 62 | # 63 | # To disable a check put `false` as second element: 64 | # 65 | # {Credo.Check.Design.DuplicatedCode, false} 66 | # 67 | checks: [ 68 | # 69 | ## Consistency Checks 70 | # 71 | {Credo.Check.Consistency.ExceptionNames, []}, 72 | {Credo.Check.Consistency.LineEndings, []}, 73 | {Credo.Check.Consistency.ParameterPatternMatching, []}, 74 | {Credo.Check.Consistency.SpaceAroundOperators, []}, 75 | {Credo.Check.Consistency.SpaceInParentheses, []}, 76 | {Credo.Check.Consistency.TabsOrSpaces, []}, 77 | 78 | # 79 | ## Design Checks 80 | # 81 | # You can customize the priority of any check 82 | # Priority values are: `low, normal, high, higher` 83 | # 84 | {Credo.Check.Design.AliasUsage, [priority: :low, if_nested_deeper_than: 2, if_called_more_often_than: 0]}, 85 | # You can also customize the exit_status of each check. 86 | # If you don't want TODO comments to cause `mix credo` to fail, just 87 | # set this value to 0 (zero). 88 | # 89 | {Credo.Check.Design.TagTODO, [exit_status: 2]}, 90 | {Credo.Check.Design.TagFIXME, []}, 91 | 92 | # 93 | ## Readability Checks 94 | # 95 | {Credo.Check.Readability.AliasOrder, []}, 96 | {Credo.Check.Readability.FunctionNames, []}, 97 | {Credo.Check.Readability.LargeNumbers, []}, 98 | {Credo.Check.Readability.MaxLineLength, [priority: :low, max_length: 120]}, 99 | {Credo.Check.Readability.ModuleAttributeNames, []}, 100 | {Credo.Check.Readability.ModuleDoc, []}, 101 | {Credo.Check.Readability.ModuleNames, []}, 102 | {Credo.Check.Readability.ParenthesesInCondition, []}, 103 | {Credo.Check.Readability.ParenthesesOnZeroArityDefs, []}, 104 | {Credo.Check.Readability.PredicateFunctionNames, []}, 105 | {Credo.Check.Readability.PreferImplicitTry, []}, 106 | {Credo.Check.Readability.RedundantBlankLines, []}, 107 | {Credo.Check.Readability.Semicolons, []}, 108 | {Credo.Check.Readability.SpaceAfterCommas, []}, 109 | {Credo.Check.Readability.StringSigils, []}, 110 | {Credo.Check.Readability.TrailingBlankLine, []}, 111 | {Credo.Check.Readability.TrailingWhiteSpace, []}, 112 | {Credo.Check.Readability.UnnecessaryAliasExpansion, []}, 113 | {Credo.Check.Readability.VariableNames, []}, 114 | 115 | # 116 | ## Refactoring Opportunities 117 | # 118 | {Credo.Check.Refactor.CondStatements, []}, 119 | {Credo.Check.Refactor.CyclomaticComplexity, []}, 120 | {Credo.Check.Refactor.FunctionArity, []}, 121 | {Credo.Check.Refactor.LongQuoteBlocks, []}, 122 | # {Credo.Check.Refactor.MapInto, []}, 123 | {Credo.Check.Refactor.MatchInCondition, []}, 124 | {Credo.Check.Refactor.NegatedConditionsInUnless, []}, 125 | {Credo.Check.Refactor.NegatedConditionsWithElse, []}, 126 | {Credo.Check.Refactor.Nesting, []}, 127 | {Credo.Check.Refactor.UnlessWithElse, []}, 128 | {Credo.Check.Refactor.WithClauses, []}, 129 | 130 | # 131 | ## Warnings 132 | # 133 | {Credo.Check.Warning.ApplicationConfigInModuleAttribute, []}, 134 | {Credo.Check.Warning.BoolOperationOnSameValues, []}, 135 | {Credo.Check.Warning.ExpensiveEmptyEnumCheck, []}, 136 | {Credo.Check.Warning.IExPry, []}, 137 | {Credo.Check.Warning.IoInspect, []}, 138 | # {Credo.Check.Warning.LazyLogging, []}, 139 | {Credo.Check.Warning.MixEnv, false}, 140 | {Credo.Check.Warning.OperationOnSameValues, []}, 141 | {Credo.Check.Warning.OperationWithConstantResult, []}, 142 | {Credo.Check.Warning.RaiseInsideRescue, []}, 143 | {Credo.Check.Warning.UnusedEnumOperation, []}, 144 | {Credo.Check.Warning.UnusedFileOperation, []}, 145 | {Credo.Check.Warning.UnusedKeywordOperation, []}, 146 | {Credo.Check.Warning.UnusedListOperation, []}, 147 | {Credo.Check.Warning.UnusedPathOperation, []}, 148 | {Credo.Check.Warning.UnusedRegexOperation, []}, 149 | {Credo.Check.Warning.UnusedStringOperation, []}, 150 | {Credo.Check.Warning.UnusedTupleOperation, []}, 151 | {Credo.Check.Warning.UnsafeExec, []}, 152 | 153 | # 154 | # Checks scheduled for next check update (opt-in for now, just replace `false` with `[]`) 155 | 156 | # 157 | # Controversial and experimental checks (opt-in, just replace `false` with `[]`) 158 | # 159 | {Credo.Check.Consistency.MultiAliasImportRequireUse, false}, 160 | {Credo.Check.Consistency.UnusedVariableNames, false}, 161 | {Credo.Check.Design.DuplicatedCode, false}, 162 | {Credo.Check.Readability.AliasAs, false}, 163 | {Credo.Check.Readability.BlockPipe, false}, 164 | {Credo.Check.Readability.ImplTrue, false}, 165 | {Credo.Check.Readability.MultiAlias, false}, 166 | {Credo.Check.Readability.SeparateAliasRequire, false}, 167 | {Credo.Check.Readability.SinglePipe, []}, 168 | {Credo.Check.Readability.Specs, false}, 169 | {Credo.Check.Readability.StrictModuleLayout, false}, 170 | {Credo.Check.Readability.WithCustomTaggedTuple, false}, 171 | {Credo.Check.Refactor.ABCSize, false}, 172 | {Credo.Check.Refactor.AppendSingleItem, false}, 173 | {Credo.Check.Refactor.DoubleBooleanNegation, false}, 174 | {Credo.Check.Refactor.ModuleDependencies, false}, 175 | {Credo.Check.Refactor.NegatedIsNil, false}, 176 | {Credo.Check.Refactor.PipeChainStart, false}, 177 | {Credo.Check.Refactor.VariableRebinding, false}, 178 | {Credo.Check.Warning.LeakyEnvironment, false}, 179 | {Credo.Check.Warning.MapGetUnsafePass, false}, 180 | {Credo.Check.Warning.UnsafeToAtom, false} 181 | 182 | # 183 | # Custom checks can be created using `mix credo.gen.check`. 184 | # 185 | ] 186 | } 187 | ] 188 | } 189 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | # This file excludes paths from the Docker build context. 2 | # 3 | # By default, Docker's build context includes all files (and folders) in the 4 | # current directory. Even if a file isn't copied into the container it is still sent to 5 | # the Docker daemon. 6 | # 7 | # There are multiple reasons to exclude files from the build context: 8 | # 9 | # 1. Prevent nested folders from being copied into the container (ex: exclude 10 | # /assets/node_modules when copying /assets) 11 | # 2. Reduce the size of the build context and improve build time (ex. /build, /deps, /doc) 12 | # 3. Avoid sending files containing sensitive information 13 | # 14 | # More information on using .dockerignore is available here: 15 | # https://docs.docker.com/engine/reference/builder/#dockerignore-file 16 | 17 | .dockerignore 18 | 19 | # Ignore git, but keep git HEAD and refs to access current commit hash if needed: 20 | # 21 | # $ cat .git/HEAD | awk '{print ".git/"$2}' | xargs cat 22 | # d0b8727759e1e0e7aa3d41707d12376e373d5ecc 23 | .git 24 | !.git/HEAD 25 | !.git/refs 26 | 27 | # Common development/test artifacts 28 | /cover/ 29 | /doc/ 30 | /test/ 31 | /tmp/ 32 | .elixir_ls 33 | 34 | # Mix artifacts 35 | /_build/ 36 | /deps/ 37 | *.ez 38 | 39 | # Generated on crash by the VM 40 | erl_crash.dump 41 | 42 | # Static artifacts - These should be fetched and built inside the Docker image 43 | /assets/node_modules/ 44 | /priv/static/assets/ 45 | /priv/static/cache_manifest.json 46 | 47 | # GitHub hosting files 48 | /.github/ 49 | 50 | # Fly.io hosting files 51 | fly.toml 52 | heroku.yml 53 | -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | [ 2 | import_deps: [:phoenix], 3 | inputs: ["*.{ex,exs}", "{config,lib,test}/**/*.{ex,exs}"], 4 | subdirectories: [], 5 | line_length: 120 6 | ] 7 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @elixirschool/developers 2 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Continuous Integration 3 | 4 | on: 5 | pull_request: 6 | types: 7 | - opened 8 | - reopened 9 | - synchronize 10 | push: 11 | branches: 12 | - main 13 | 14 | env: 15 | CACHE_VERSION: v1 16 | ELIXIR_VERSION: '1.14.1' 17 | MIX_ENV: test 18 | OTP_VERSION: '25' 19 | 20 | concurrency: 21 | group: ${{ github.head_ref || github.run_id }} 22 | cancel-in-progress: true 23 | 24 | jobs: 25 | Build: 26 | runs-on: ubuntu-latest 27 | 28 | steps: 29 | - uses: actions/checkout@v3 30 | - uses: docker/setup-buildx-action@v1 31 | - uses: docker/build-push-action@v3 32 | 33 | Credo: 34 | runs-on: ubuntu-latest 35 | 36 | steps: 37 | - uses: actions/checkout@v3 38 | - id: beam 39 | uses: erlef/setup-beam@v1 40 | with: 41 | otp-version: ${{ env.OTP_VERSION }} 42 | elixir-version: ${{ env.ELIXIR_VERSION }} 43 | - id: cache 44 | uses: actions/cache@v3 45 | with: 46 | key: | 47 | ${{ env.CACHE_VERSION }}-${{ steps.beam.outputs.elixir-version }}-${{ steps.beam.outputs.otp-version }}-credo-${{ hashFiles('mix.lock') }}-${{ github.ref }} 48 | restore-keys: | 49 | ${{ env.CACHE_VERSION }}-${{ steps.beam.outputs.elixir-version }}-${{ steps.beam.outputs.otp-version }}-credo-${{ hashFiles('mix.lock') }}-${{ format('refs/heads/{0}', github.event.repository.default_branch) }} 50 | ${{ env.CACHE_VERSION }}-${{ steps.beam.outputs.elixir-version }}-${{ steps.beam.outputs.otp-version }}-credo- 51 | ${{ env.CACHE_VERSION }}-${{ steps.beam.outputs.elixir-version }}-${{ steps.beam.outputs.otp-version }}- 52 | path: | 53 | _build 54 | deps 55 | - if: steps.cache.outputs.cache-hit != 'true' 56 | run: mix deps.get 57 | - run: mix credo 58 | 59 | Dialyzer: 60 | runs-on: ubuntu-latest 61 | 62 | steps: 63 | - uses: actions/checkout@v3 64 | - id: beam 65 | uses: erlef/setup-beam@v1 66 | with: 67 | otp-version: ${{ env.OTP_VERSION }} 68 | elixir-version: ${{ env.ELIXIR_VERSION }} 69 | - id: cache 70 | uses: actions/cache@v3 71 | with: 72 | key: | 73 | ${{ env.CACHE_VERSION }}-${{ steps.beam.outputs.elixir-version }}-${{ steps.beam.outputs.otp-version }}-dialyzer-${{ hashFiles('mix.lock') }}-${{ github.ref }} 74 | restore-keys: | 75 | ${{ env.CACHE_VERSION }}-${{ steps.beam.outputs.elixir-version }}-${{ steps.beam.outputs.otp-version }}-dialyzer-${{ hashFiles('mix.lock') }}-${{ format('refs/heads/{0}', github.event.repository.default_branch) }} 76 | ${{ env.CACHE_VERSION }}-${{ steps.beam.outputs.elixir-version }}-${{ steps.beam.outputs.otp-version }}-dialyzer- 77 | ${{ env.CACHE_VERSION }}-${{ steps.beam.outputs.elixir-version }}-${{ steps.beam.outputs.otp-version }}- 78 | path: | 79 | _build 80 | deps 81 | - if: steps.cache.outputs.cache-hit != 'true' 82 | run: mix deps.get 83 | - run: mix dialyzer 84 | 85 | Format: 86 | runs-on: ubuntu-latest 87 | 88 | steps: 89 | - uses: actions/checkout@v3 90 | - id: beam 91 | uses: erlef/setup-beam@v1 92 | with: 93 | otp-version: ${{ env.OTP_VERSION }} 94 | elixir-version: ${{ env.ELIXIR_VERSION }} 95 | - id: cache 96 | uses: actions/cache@v3 97 | with: 98 | key: | 99 | ${{ env.CACHE_VERSION }}-${{ steps.beam.outputs.elixir-version }}-${{ steps.beam.outputs.otp-version }}-format-${{ hashFiles('mix.lock') }}-${{ github.ref }} 100 | restore-keys: | 101 | ${{ env.CACHE_VERSION }}-${{ steps.beam.outputs.elixir-version }}-${{ steps.beam.outputs.otp-version }}-format-${{ hashFiles('mix.lock') }}-${{ format('refs/heads/{0}', github.event.repository.default_branch) }} 102 | ${{ env.CACHE_VERSION }}-${{ steps.beam.outputs.elixir-version }}-${{ steps.beam.outputs.otp-version }}-format- 103 | ${{ env.CACHE_VERSION }}-${{ steps.beam.outputs.elixir-version }}-${{ steps.beam.outputs.otp-version }}- 104 | path: | 105 | _build 106 | deps 107 | - if: steps.cache.outputs.cache-hit != 'true' 108 | run: mix deps.get 109 | - run: mix format --check-formatted 110 | 111 | Test: 112 | runs-on: ubuntu-latest 113 | 114 | steps: 115 | - uses: actions/checkout@v3 116 | - id: beam 117 | uses: erlef/setup-beam@v1 118 | with: 119 | otp-version: ${{ env.OTP_VERSION }} 120 | elixir-version: ${{ env.ELIXIR_VERSION }} 121 | - id: cache 122 | uses: actions/cache@v3 123 | with: 124 | key: | 125 | ${{ env.CACHE_VERSION }}-${{ steps.beam.outputs.elixir-version }}-${{ steps.beam.outputs.otp-version }}-test-${{ hashFiles('mix.lock') }}-${{ github.ref }} 126 | restore-keys: | 127 | ${{ env.CACHE_VERSION }}-${{ steps.beam.outputs.elixir-version }}-${{ steps.beam.outputs.otp-version }}-test-${{ hashFiles('mix.lock') }}-${{ format('refs/heads/{0}', github.event.repository.default_branch) }} 128 | ${{ env.CACHE_VERSION }}-${{ steps.beam.outputs.elixir-version }}-${{ steps.beam.outputs.otp-version }}-test- 129 | ${{ env.CACHE_VERSION }}-${{ steps.beam.outputs.elixir-version }}-${{ steps.beam.outputs.otp-version }}- 130 | path: | 131 | _build 132 | deps 133 | - if: steps.cache.outputs.cache-hit != 'true' 134 | run: mix deps.get 135 | - run: mix compile --warnings-as-errors 136 | - run: mix test 137 | -------------------------------------------------------------------------------- /.github/workflows/common-config.yml: -------------------------------------------------------------------------------- 1 | # This file is synced with beam-community/common-config. Any changes will be overwritten. 2 | 3 | name: Common Config 4 | 5 | on: 6 | push: 7 | branches: 8 | - main 9 | paths: 10 | - .github/workflows/common-config.yaml 11 | repository_dispatch: 12 | types: 13 | - common-config 14 | schedule: 15 | - cron: "8 12 8 * *" 16 | workflow_dispatch: {} 17 | 18 | concurrency: 19 | group: Common Config 20 | 21 | jobs: 22 | Sync: 23 | runs-on: ubuntu-latest 24 | 25 | steps: 26 | - name: Checkout 27 | uses: actions/checkout@v4 28 | with: 29 | token: ${{ secrets.GH_PERSONAL_ACCESS_TOKEN }} 30 | persist-credentials: true 31 | 32 | - name: Setup Node 33 | uses: actions/setup-node@v3 34 | with: 35 | node-version: 18 36 | 37 | - name: Setup Elixir 38 | uses: beam-community/actions-elixir/setup@v1 39 | with: 40 | github-token: ${{ secrets.GH_PERSONAL_ACCESS_TOKEN }} 41 | elixir-version: "1.15" 42 | otp-version: "26.0" 43 | 44 | - name: Sync 45 | uses: beam-community/actions-sync@v1 46 | with: 47 | commit-message: "chore: sync files with beam-community/common-config" 48 | pr-enabled: true 49 | pr-labels: common-config 50 | pr-title: "chore: sync files with beam-community/common-config" 51 | pr-token: ${{ secrets.GH_PERSONAL_ACCESS_TOKEN }} 52 | sync-auth: doomspork:${{ secrets.GH_PERSONAL_ACCESS_TOKEN }} 53 | sync-branch: latest 54 | sync-repository: github.com/beam-community/common-config.git 55 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Deploy 3 | 4 | on: 5 | workflow_dispatch: 6 | push: 7 | branches: 8 | - main 9 | 10 | jobs: 11 | Docker: 12 | if: ${{ github.repository_owner == 'elixirschool' }} 13 | runs-on: ubuntu-latest 14 | 15 | outputs: 16 | image: ${{ steps.outputs.outputs.image }} 17 | tag: ${{ steps.outputs.outputs.tag }} 18 | 19 | permissions: 20 | contents: read 21 | packages: write 22 | 23 | steps: 24 | - name: Checkout 25 | uses: actions/checkout@v3 26 | 27 | - id: metadata 28 | name: Get Metadata 29 | uses: docker/metadata-action@v5 30 | with: 31 | flavor: | 32 | latest=auto 33 | github-token: ${{ secrets.GITHUB_TOKEN }} 34 | images: | 35 | ghcr.io/${{ github.repository }} 36 | tags: | 37 | type=sha 38 | type=semver,pattern={{version}} 39 | type=semver,pattern={{major}}.{{minor}} 40 | type=semver,pattern={{major}} 41 | type=ref,event=branch 42 | type=ref,event=pr 43 | 44 | - name: Set up Docker Buildx 45 | uses: docker/setup-buildx-action@v3 46 | 47 | - name: Login (GHCR) 48 | uses: docker/login-action@v2 49 | with: 50 | registry: ghcr.io 51 | username: ${{ github.actor }} 52 | password: ${{ secrets.GITHUB_TOKEN }} 53 | 54 | - id: build 55 | name: Build 56 | uses: docker/build-push-action@v6 57 | with: 58 | cache-from: type=gha 59 | cache-to: type=gha,mode=max 60 | labels: ${{ steps.metadata.outputs.labels }} 61 | platforms: linux/amd64 62 | push: true 63 | tags: ${{ steps.metadata.outputs.tags }} 64 | 65 | - id: outputs 66 | name: Get Outputs 67 | uses: actions/github-script@v7 68 | env: 69 | BUILD_OUTPUT: ${{ steps.metadata.outputs.json }} 70 | with: 71 | script: | 72 | const metadata = JSON.parse(process.env.BUILD_OUTPUT) 73 | const shaUrl = metadata.tags.find((t) => t.includes(':sha-')) 74 | 75 | if (shaUrl == null) { 76 | core.error('Unable to find sha tag of image') 77 | } else { 78 | const [image, tag] = shaUrl.split(':') 79 | core.setOutput('image', image) 80 | core.setOutput('tag', tag) 81 | } 82 | 83 | Deploy: 84 | if: ${{ github.repository_owner == 'elixirschool' }} 85 | runs-on: ubuntu-latest 86 | needs: [Docker] 87 | 88 | steps: 89 | - name: Checkout 90 | uses: actions/checkout@v3 91 | 92 | - name: Install (flyctl) 93 | uses: superfly/flyctl-actions/setup-flyctl@master 94 | 95 | - run: flyctl deploy --yes --image "${{ needs.Docker.outputs.image }}:${{ needs.Docker.outputs.tag }}" 96 | env: 97 | FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} 98 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by https://www.toptal.com/developers/gitignore/api/elixir,vim,linux,osx,phoenix 2 | # Edit at https://www.toptal.com/developers/gitignore?templates=elixir,vim,linux,osx,phoenix 3 | 4 | ### Elixir ### 5 | /_build 6 | /cover 7 | /deps 8 | /doc 9 | /.fetch 10 | erl_crash.dump 11 | *.ez 12 | *.beam 13 | /config/*.secret.exs 14 | .elixir_ls/ 15 | /content 16 | /assets/static/images 17 | /assets/static/sitemap* 18 | /priv/static 19 | 20 | ### Elixir Patch ### 21 | 22 | ### Linux ### 23 | *~ 24 | 25 | # temporary files which can be created if a process still has a handle open of a deleted file 26 | .fuse_hidden* 27 | 28 | # KDE directory preferences 29 | .directory 30 | 31 | # Linux trash folder which might appear on any partition or disk 32 | .Trash-* 33 | 34 | # .nfs files are created when an open file is removed but is still being accessed 35 | .nfs* 36 | 37 | ### OSX ### 38 | # General 39 | .DS_Store 40 | .AppleDouble 41 | .LSOverride 42 | 43 | # Icon must end with two \r 44 | Icon 45 | 46 | 47 | # Thumbnails 48 | ._* 49 | 50 | # Files that might appear in the root of a volume 51 | .DocumentRevisions-V100 52 | .fseventsd 53 | .Spotlight-V100 54 | .TemporaryItems 55 | .Trashes 56 | .VolumeIcon.icns 57 | .com.apple.timemachine.donotpresent 58 | 59 | # Directories potentially created on remote AFP share 60 | .AppleDB 61 | .AppleDesktop 62 | Network Trash Folder 63 | Temporary Items 64 | .apdisk 65 | 66 | ### Phoenix ### 67 | # gitignore template for Phoenix projects 68 | # website: http://www.phoenixframework.org/ 69 | # 70 | # Recommended template: Elixir.gitignore 71 | 72 | # Temporary files 73 | /tmp 74 | 75 | # Static artifacts 76 | /node_modules 77 | /assets/node_modules 78 | 79 | # Installer-related files 80 | /installer/_build 81 | /installer/tmp 82 | /installer/doc 83 | /installer/deps 84 | 85 | ### Vim ### 86 | # Swap 87 | [._]*.s[a-v][a-z] 88 | !*.svg # comment out if you don't need vector files 89 | [._]*.sw[a-p] 90 | [._]s[a-rt-v][a-z] 91 | [._]ss[a-gi-z] 92 | [._]sw[a-p] 93 | 94 | # Session 95 | Session.vim 96 | Sessionx.vim 97 | 98 | # Temporary 99 | .netrwhist 100 | # Auto-generated tag files 101 | tags 102 | # Persistent undo 103 | [._]*.un~ 104 | 105 | # End of https://www.toptal.com/developers/gitignore/api/elixir,vim,linux,osx,phoenix 106 | -------------------------------------------------------------------------------- /.tool-versions: -------------------------------------------------------------------------------- 1 | elixir 1.15 2 | erlang 25.0 3 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Find eligible builder and runner images on Docker Hub. We use Ubuntu/Debian instead of 2 | # Alpine to avoid DNS resolution issues in production. 3 | # 4 | # https://hub.docker.com/r/hexpm/elixir/tags?page=1&name=ubuntu 5 | # https://hub.docker.com/_/ubuntu?tab=tags 6 | # 7 | # 8 | # This file is based on these images: 9 | # 10 | # - https://hub.docker.com/r/hexpm/elixir/tags - for the build image 11 | # - https://hub.docker.com/_/debian?tab=tags&page=1&name=bullseye-20220801-slim - for the release image 12 | # - https://pkgs.org/ - resource for finding needed packages 13 | # - Ex: hexpm/elixir:1.13.4-erlang-25.0.4-debian-bullseye-20220801-slim 14 | # 15 | ARG ELIXIR_VERSION=1.15.8 16 | ARG OTP_VERSION=25.0.4 17 | ARG DEBIAN_VERSION=bullseye-20240722-slim 18 | 19 | ARG BUILDER_IMAGE="hexpm/elixir:${ELIXIR_VERSION}-erlang-${OTP_VERSION}-debian-${DEBIAN_VERSION}" 20 | ARG RUNNER_IMAGE="debian:${DEBIAN_VERSION}" 21 | 22 | FROM ${BUILDER_IMAGE} as builder 23 | 24 | ARG DEPLOY_DOMAIN="https://elixirschool.com" 25 | 26 | # install build dependencies 27 | RUN apt-get update -y && apt-get install -y build-essential git npm --fix-missing --no-install-recommends \ 28 | && apt-get clean && rm -f /var/lib/apt/lists/*_* 29 | 30 | # prepare build dir 31 | WORKDIR /app 32 | 33 | # install hex + rebar 34 | RUN mix local.hex --force && \ 35 | mix local.rebar --force 36 | 37 | # set build ENV 38 | ENV MIX_ENV="prod" 39 | 40 | # install mix dependencies 41 | COPY mix.exs mix.lock ./ 42 | RUN mix deps.get --only $MIX_ENV 43 | RUN mkdir config 44 | 45 | # copy compile-time config files before we compile dependencies 46 | # to ensure any relevant config change will trigger the dependencies 47 | # to be re-compiled. 48 | COPY config/config.exs config/lessons.exs config/redirects.exs config/${MIX_ENV}.exs config/ 49 | RUN mix deps.compile 50 | 51 | COPY priv priv 52 | 53 | COPY lib lib 54 | 55 | COPY assets assets 56 | 57 | COPY Makefile Makefile 58 | 59 | # install npm dependencies 60 | RUN cd assets && npm ci 61 | 62 | # install content from remote repository 63 | RUN make content 64 | 65 | # generate rss and sitemap static files 66 | RUN mix school_house.gen.rss ${DEPLOY_DOMAIN} 67 | RUN mix school_house.gen.sitemap ${DEPLOY_DOMAIN} 68 | 69 | # compile assets 70 | RUN mix assets.deploy 71 | 72 | # Compile the release 73 | RUN mix compile 74 | 75 | # Changes to config/runtime.exs don't require recompiling the code 76 | COPY config/releases.exs config/ 77 | 78 | COPY rel rel 79 | RUN mix release 80 | 81 | # start a new build stage so that the final image will only contain 82 | # the compiled release and other runtime necessities 83 | FROM ${RUNNER_IMAGE} 84 | 85 | RUN apt-get update -y && apt-get install -y libstdc++6 openssl libncurses5 locales \ 86 | && apt-get clean && rm -f /var/lib/apt/lists/*_* 87 | 88 | # Set the locale 89 | RUN sed -i '/en_US.UTF-8/s/^# //g' /etc/locale.gen && locale-gen 90 | 91 | ENV LANG en_US.UTF-8 92 | ENV LANGUAGE en_US:en 93 | ENV LC_ALL en_US.UTF-8 94 | 95 | WORKDIR "/app" 96 | RUN chown nobody /app 97 | 98 | # set runner ENV 99 | ENV MIX_ENV="prod" 100 | 101 | # Appended by flyctl 102 | ENV ECTO_IPV6=true 103 | ENV ERL_AFLAGS="-proto_dist inet6_tcp" 104 | 105 | # Only copy the final release from the build stage 106 | COPY --from=builder --chown=nobody:root /app/_build/${MIX_ENV}/rel/school_house ./ 107 | COPY --from=builder --chown=nobody:root /app/priv/static ./static 108 | 109 | USER nobody 110 | 111 | CMD ["/app/bin/server"] 112 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: $(MAKECMDGOALS) content 2 | 3 | setup: content 4 | mix do setup, compile, assets.deploy 5 | 6 | content: 7 | rm -rf assets/static/images 8 | 9 | # Clone from live repo 10 | rm -rf content && git clone --branch main --single-branch --depth 1 https://github.com/elixirschool/elixirschool.git content 11 | 12 | # If you are testing Elixir School guides, you can comment the line above and uncomment the one below, updating PATH_TO_YOUR_LOCAL_REPO 13 | # rsync -av /PATH_TO_YOUR_LOCAL_REPO/elixirschool/ ./content --exclude .git 14 | 15 | mv content/images assets/static/images 16 | 17 | build: 18 | docker build . 19 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # School House 2 | 3 | [![Continuous Integration](https://github.com/elixirschool/school_house/actions/workflows/ci.yml/badge.svg)](https://github.com/elixirschool/school_house/actions/workflows/ci.yml) [![Deploy](https://github.com/elixirschool/school_house/actions/workflows/deploy.yml/badge.svg)](https://github.com/elixirschool/school_house/actions/workflows/deploy.yml) 4 | 5 | School House is the new era of [elixirschool.com](https://elixirschool.com) now powered by Elixir and Phoenix :tada: 6 | 7 | By leveraging Dashbit's [NimblePublisher](https://github.com/dashbitco/nimble_publisher) and some restructing of the existing lessons we're able to use the lessons so many have contributed to while delivering them in an improved experience! 8 | 9 | ## Development 10 | 11 | To get up and running all we need is a single command: 12 | 13 | ```shell 14 | $ make setup 15 | ``` 16 | 17 | This will fetch dependencies, download lessons and blog posts from the [external repository](https://github.com/elixirschool/elixirschool), and compile the project. 18 | 19 | Then start the phoenix server with: 20 | 21 | ```shell 22 | $ mix phx.server 23 | ``` 24 | -------------------------------------------------------------------------------- /ROCKET-VALIDATOR.md: -------------------------------------------------------------------------------- 1 | # Rocket Validator 2 | 3 | We're using [Rocket Validator](https://rocketvalidator.com) to check [Elixir School](https://elixirschool.com) for HTML and accessibility issues. 4 | 5 | This document is a quick guide on how to use that service, for full documentation please refer to [https://docs.rocketvalidator.com](https://docs.rocketvalidator.com) 6 | 7 | ## Rocket Validator (RV) in a nutshell 8 | 9 | RV is an automated web crawler that finds the internal web pages on a site from a starting URL, and checks each web page found using: 10 | 11 | * [W3C HTML Validator](https://validator.w3.org/nu/) to check for HTML issues. 12 | * [Axe-core](https://github.com/dequelabs/axe-core) to check for accessibility issues. 13 | 14 | Both checkers are free and open source, and can be used on individual URLs. 15 | 16 | Rocket Validator is a web crawler and reporting tool that lets you automatically check thousands of URLs (or just a few) with a single click. 17 | 18 | The current site for Beta Elixir School has 1,040 web pages. Imagine if you had to check each web page manually, both for HTML and accessibility issues. That's 2,080 checks needed! 19 | 20 | RV lets us automate this site-wide checking and also schedule periodic checks to constantly monitor for new issues. 21 | 22 | ## Create your RV account 23 | 24 | RV is a paid service but Elixir School contributors have a free Pro account to work on this site. If you're an Elixir School contributor you just need to [sign up](https://rocketvalidator.com/registration/new) for a free account, and contact [jaime@rocketvalidator.com](mailto:jaime@rocketvalidator.com) to have your account upgraded to Pro. 25 | 26 | ## Create your first report 27 | 28 | Once you're logged in at RV, you can [create a new report](https://rocketvalidator.com/s/new). You just need to enter a starting URL (https://beta.elixirschool.com), and click on **Start validation**. 29 | 30 | Your site validation report will be created, and the crawler will find the internal web pages, and validate each one for HTML and accessibility issues. 31 | 32 | On the generated report, you can browse the results per each page found, or go to the **Common HTML issues** or **Common accessibility issues** tabs to find the issues found on the site, grouped by the kind of issue. The reports give you details of the affected pages, and the affected elements within each page. 33 | 34 | ## How to validate your local server 35 | 36 | RV can validate any site that has public URLs, and that includes your local development server if you use something like [ngrok](https://ngrok.com) to create a temporary public URL for your dev server. 37 | 38 | Here's a [guide](https://docs.rocketvalidator.com/how-to-validate-your-local-server/) with more info but in short here's what you do: 39 | 40 | 1. Launch the phoenix app, like in `iex -S mix phx.server`. 41 | 2. Launch ngrok telling it to expose port 4000 and give it a meaningful URL, like `ngrok http 4000 --subdomain elixirschool-joe`. 42 | 3. This will create a temporary public URL https://elixirschool-joe.ngrok.io that you can use to validate your dev site on RV. 43 | 44 | Just remember to change `joe` by your name or whatever other string you want, but keep `elixirschool` somewhere on the URL to make it easier working with muting rules. 45 | 46 | ## Muting issues 47 | 48 | There are some issues that we can decide not to fix, for example HTML markup that is not correct by current standards, but is required by a third party tool, so it's out of our scope. Muting rules in RV lets us hide these issues on the reports. You can read the [muting guide](https://docs.rocketvalidator.com/muting/) to see how it works, but basically a muting rule needs: 49 | 50 | * A string to match URLs. It can be a whole URL if you want to be specific, or just a substring like `elixirschool`, which is recommended as it will match both on the beta, staging and production sites, as well as your ngrok instances if you include that on the name. 51 | * A string to match the issue message. A substring is enough, for example matching on `Attribute “phx-` will hide all issues regarding about invalid attributes set by Phoenix. 52 | 53 | Each RV user defines their own muting rules, here are the rules [that we've agreed to mute](https://rocketvalidator.com/domains/elixirschool.com?tab=mutings&auth=171c6160-f0a2-49d9-b83f-065c15c8a072). 54 | 55 | ## Shared Domain Stats 56 | 57 | RV generates daily domain stats based on the reports you run, which can be shared by RV users. We're using the ones shared from @jaimeiniesta's account as a central reference: 58 | 59 | [Latest Stats and Reports for Beta Elixir School](https://rocketvalidator.com/domains/elixirschool.com?auth=171c6160-f0a2-49d9-b83f-065c15c8a072) 60 | 61 | ## Scheduling Reports 62 | 63 | Reports can be run automatically a schedule. For example in @jaimeniesta's account there's a weekly schedule to run a full report every Monday. 64 | 65 | [Read about Scheduling](https://docs.rocketvalidator.com/scheduling/) 66 | 67 | ## Deploy Hooks 68 | 69 | Reports can also be triggered automatically after a server deploy. This can be useful in staging deploys for example. 70 | 71 | [Read about Deploy Hooks](https://docs.rocketvalidator.com/deploy-hooks/) 72 | 73 | -------------------------------------------------------------------------------- /app.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Elixir School", 3 | "website": "https://elixirschool.com/", 4 | "repository": "https://github.com/elixirschool/school_house", 5 | "stack": "container" 6 | } 7 | -------------------------------------------------------------------------------- /assets/js/app.js: -------------------------------------------------------------------------------- 1 | import "phoenix_html" 2 | import { Socket } from "phoenix" 3 | import topbar from "topbar" 4 | import { LiveSocket } from "phoenix_live_view" 5 | import Alpine from "alpinejs"; 6 | 7 | window.Alpine = Alpine; 8 | Alpine.start(); 9 | 10 | let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content") 11 | let liveSocket = new LiveSocket("/live", Socket, { 12 | params: { 13 | _csrf_token: csrfToken 14 | }, 15 | dom: { 16 | onBeforeElUpdated(from, to) { 17 | if (from._x_dataStack) { 18 | window.Alpine.clone(from, to); 19 | } 20 | }, 21 | }, }) 22 | 23 | // Show progress bar on live navigation and form submits 24 | topbar.config({ 25 | barColors: { 26 | '0': "rgba(237, 233, 254)", 27 | '0.5': "rgba(167, 139, 250)", 28 | '1.0': "rgba(76, 29, 149)" 29 | }, 30 | shadowColor: "rgba(76, 29, 149, 0.3)" 31 | }) 32 | window.addEventListener("phx:page-loading-start", info => topbar.show()) 33 | window.addEventListener("phx:page-loading-stop", info => topbar.hide()) 34 | 35 | // connect if there are any LiveViews on the page 36 | liveSocket.connect() 37 | 38 | // expose liveSocket on window for web console debug logs and latency simulation: 39 | // >> liveSocket.enableDebug() 40 | // >> liveSocket.enableLatencySim(1000) 41 | window.liveSocket = liveSocket 42 | 43 | import "./pickup" 44 | import "./dark-mode" 45 | import "./menu-toggle" 46 | -------------------------------------------------------------------------------- /assets/js/dark-mode.js: -------------------------------------------------------------------------------- 1 | const darkModeToggleContainer = document.getElementById( 2 | 'dark-mode-toggle-container' 3 | ) 4 | const darkModeToggleInput = document.getElementById('dark-mode-toggle') 5 | 6 | // set up dark mode toggle 7 | function setDarkMode(on) { 8 | if (on) { 9 | darkModeToggleInput.checked = true 10 | document.documentElement.classList.add('dark') 11 | document.getElementById('sun-icon').classList.add('hidden') 12 | document.getElementById('moon-icon').classList.remove('hidden') 13 | } else { 14 | darkModeToggleInput.checked = false 15 | document.documentElement.classList.remove('dark') 16 | document.getElementById('moon-icon').classList.add('hidden') 17 | document.getElementById('sun-icon').classList.remove('hidden') 18 | } 19 | } 20 | 21 | // Will prefer dark mode, if the user has set it on their device. 22 | const userPrefersDarkMode = 23 | window.matchMedia && 24 | window.matchMedia('(prefers-color-scheme: dark)').matches 25 | 26 | // If the user has taken an active choice to set mode, which is stored 27 | // in local storage, use that. Otherwise, prefer user device preference. 28 | if (localStorage.theme) { 29 | setDarkMode(localStorage.theme === 'dark') 30 | } else if ( 31 | userPrefersDarkMode || 32 | document.documentElement.classList.contains('dark') 33 | ) { 34 | setDarkMode(true) 35 | } else { 36 | setDarkMode(false) 37 | } 38 | 39 | darkModeToggleContainer.addEventListener('click', function () { 40 | if (darkModeToggleInput.checked) { 41 | localStorage.theme = 'light' 42 | setDarkMode(false) 43 | } else { 44 | localStorage.theme = 'dark' 45 | setDarkMode(true) 46 | } 47 | }) 48 | 49 | // remove preload class after the page laods so the styles 50 | // will transition smoothly when switching between dark and 51 | // light mode. Without the preload class, the transition will 52 | // happen on page load if dark mode is enabled 53 | setTimeout(() => { 54 | document.body.classList.remove('preload') 55 | }, 200) 56 | -------------------------------------------------------------------------------- /assets/js/initialize-theme.js: -------------------------------------------------------------------------------- 1 | if ( 2 | localStorage.getItem('theme') === 'dark' || 3 | (!('theme' in localStorage) && 4 | window.matchMedia('(prefers-color-scheme: dark)').matches) 5 | ) { 6 | document.documentElement.classList.add('dark'); 7 | } 8 | -------------------------------------------------------------------------------- /assets/js/menu-toggle.js: -------------------------------------------------------------------------------- 1 | const menuToggleButton = document.getElementById("menu-toggle-button"); 2 | 3 | menuToggleButton.addEventListener('click', function(ev) { 4 | const nav = document.getElementById('nav'); 5 | nav.classList.toggle('-ml-64'); 6 | }) -------------------------------------------------------------------------------- /assets/js/pickup.js: -------------------------------------------------------------------------------- 1 | const msg = document.querySelector("#pick-up") 2 | const msgPhone = document.querySelector("#pick-up-phone") 3 | 4 | const btn = msg.querySelector("a") 5 | const btnPhone = msgPhone.querySelector("a") 6 | 7 | const closeBtn = msg.querySelector("button") 8 | const closePhone = msgPhone.querySelector("button") 9 | 10 | const overlay = document.querySelector("#pick-up-overlay") 11 | 12 | const pickupFrom = JSON.parse(localStorage.getItem("pick-up")) 13 | 14 | const urlParams = new URLSearchParams(window.location.search); 15 | 16 | let isOn = false; 17 | 18 | function close() { 19 | isOn = false; 20 | 21 | msg.classList.add("md:hidden") 22 | 23 | msgPhone.classList.remove("is-visible") 24 | overlay.classList.remove("is-on") 25 | 26 | disableScroll() 27 | } 28 | 29 | function disableScroll() { 30 | 31 | document.body.style["overflow-y"] = window.innerWidth < MIN_WIDTH_OF_PHONES && isOn ? "hidden" : "auto" 32 | 33 | } 34 | 35 | closeBtn.onclick = close 36 | closePhone.onclick = close 37 | overlay.onclick = close 38 | 39 | const MIN_WIDTH_OF_PHONES = 768; // px 40 | 41 | function updateMessage() { 42 | if (pickupFrom && !window.location.pathname.includes("/lessons")) { 43 | 44 | isOn = true 45 | 46 | msg.classList.remove("md:hidden") 47 | btn.href = pickupFrom.from+"?pickup=true" 48 | btnPhone.href = pickupFrom.from + "?pickup=true" 49 | 50 | overlay.classList.add("is-on") 51 | msgPhone.classList.add("is-visible") 52 | 53 | disableScroll() 54 | } else if (pickupFrom && window.location.pathname === pickupFrom.from && urlParams.get("pickup") == "true") { 55 | window.scrollTo(0, pickupFrom.scroll) 56 | } else { 57 | localStorage.setItem("pick-up", JSON.stringify({ from: window.location.pathname, scroll: window.scrollY })) 58 | } 59 | } 60 | 61 | window.onload = updateMessage 62 | 63 | if (window.location.pathname.includes("/lessons")) { 64 | window.addEventListener("scroll", () => { 65 | localStorage.setItem("pick-up", JSON.stringify({ from: window.location.pathname, scroll: window.scrollY })) 66 | }) 67 | } 68 | 69 | window.addEventListener("resize", disableScroll) 70 | -------------------------------------------------------------------------------- /assets/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "scripts": { 3 | "deploy": "NODE_ENV=production postcss css/app.css -o ../priv/static/assets/app.css" 4 | }, 5 | "prettier": { 6 | "singleQuote": true, 7 | "trailingComma": "es5", 8 | "tabWidth": 4, 9 | "semi": false 10 | }, 11 | "devDependencies": { 12 | "autoprefixer": "^10.4.12", 13 | "flag-icons": "^7.5.0", 14 | "postcss": "^8.4.17", 15 | "postcss-cli": "^8.3.1", 16 | "postcss-import": "^14.0.2", 17 | "postcss-url": "^10.1.3", 18 | "tailwindcss": "^3.1.8", 19 | "topbar": "^1.0.1" 20 | }, 21 | "dependencies": { 22 | "alpinejs": "^3.2.3", 23 | "@tailwindcss/aspect-ratio": "^0.4.2", 24 | "@tailwindcss/forms": "^0.5.3", 25 | "@tailwindcss/typography": "^0.5.7" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /assets/postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | 'postcss-import': {}, 4 | 'postcss-nested': {}, 5 | tailwindcss: {}, 6 | autoprefixer: {}, 7 | 'postcss-url': { 8 | url: 'copy', 9 | assetsPath: 'flags', 10 | }, 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /assets/static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elixirschool/school_house/d9aa60910d0b7a7622d1bd7212ccb6948fb0e813/assets/static/favicon.ico -------------------------------------------------------------------------------- /assets/static/robots.txt: -------------------------------------------------------------------------------- 1 | # See http://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file 2 | # 3 | # To ban all spiders from the entire site uncomment the next two lines: 4 | # User-agent: * 5 | # Disallow: / 6 | -------------------------------------------------------------------------------- /assets/tailwind.config.js: -------------------------------------------------------------------------------- 1 | const colors = require('tailwindcss/colors') 2 | 3 | module.exports = { 4 | content: [ 5 | '../lib/school_house_web/**/*.ex', 6 | '../lib/school_house_web/**/*.heex', 7 | './js/**/*.js', 8 | ], 9 | darkMode: 'class', // or 'media' or 'class' 10 | theme: { 11 | extend: { 12 | backgroundColor: (theme) => ({ 13 | ...theme('colors'), 14 | nav: { 15 | DEFAULT: theme('colors.brand-gray-300'), 16 | dark: theme('colors.brand-gray-800'), 17 | }, 18 | body: { 19 | DEFAULT: colors.white, 20 | dark: theme('colors.brand-gray-700'), 21 | }, 22 | purple: { 23 | DEFAULT: theme('colors.brand-purple-800'), 24 | dark: theme('colors.brand-purple-100'), 25 | }, 26 | footer: { 27 | DEFAULT: theme('colors.brand-gray-200'), 28 | dark: theme('colors.brand-gray-800'), 29 | }, 30 | }), 31 | colors: { 32 | // purple 33 | 'brand-purple-100': '#caa2f5', 34 | 'brand-purple-200': '#cfbae6', 35 | 'brand-purple-300': '#967ab4', 36 | 'brand-purple-800': '#7c6f89', 37 | 38 | // gray 39 | 'brand-gray-100': '#f9fafb', 40 | 'brand-gray-200': '#f5f6f7', 41 | 'brand-gray-300': '#f4f6f7', 42 | 'brand-gray-400': '#A1A1AA', 43 | 'brand-gray-500': '#9fa3a6', 44 | 'brand-gray-550': '#717171', 45 | 'brand-gray-600': '#666666', 46 | 'brand-gray-650': '#4a4a4a', 47 | 'brand-gray-700': '#3d4449', 48 | 'brand-gray-750': '#3c4349', 49 | 'brand-gray-800': '#31373b', 50 | 'brand-gray-900': '#22272e', 51 | 52 | // red 53 | 'brand-red-300': '#f56a6a', 54 | 'brand-red-500': '#c0394d', 55 | }, 56 | container: { 57 | center: true, 58 | }, 59 | margin: { 60 | 'half-screen': '-50vw', 61 | }, 62 | textColor: (theme) => ({ 63 | primary: { 64 | DEFAULT: theme('colors.brand-gray-750'), 65 | dark: theme('colors.brand-gray-200'), 66 | }, 67 | heavy: { 68 | DEFAULT: theme('colors.brand-gray-800'), 69 | dark: colors.white, 70 | }, 71 | light: { 72 | DEFAULT: theme('colors.brand-gray-650'), 73 | dark: theme('colors.brand-gray-300'), 74 | }, 75 | lighter: { 76 | DEFAULT: theme('colors.brand-gray-550'), 77 | dark: theme('colors.brand-gray-500'), 78 | }, 79 | purple: { 80 | DEFAULT: theme('colors.brand-purple-800'), 81 | dark: theme('colors.brand-purple-100'), 82 | }, 83 | }), 84 | transitionProperty: { 85 | margin: 'margin', 86 | }, 87 | typography: (theme) => ({ 88 | DEFAULT: { 89 | css: { 90 | color: theme('colors.brand-gray-750'), 91 | fontSize: '1.08rem', 92 | maxWidth: 'inherit', 93 | pre: { 94 | 'background-color': theme('colors.brand-gray-100'), 95 | color: theme('colors.brand-gray-700'), 96 | }, 97 | h1: { 98 | color: theme('colors.brand-gray-750'), 99 | fontSize: '3.5rem', 100 | marginTop: '0', 101 | marginBottom: '0', 102 | fontWeight: 700, 103 | lineHeight: 1, 104 | }, 105 | h2: { 106 | color: theme('colors.brand-gray-750'), 107 | }, 108 | h3: { 109 | color: theme('colors.brand-gray-750'), 110 | }, 111 | a: { 112 | color: theme('colors.brand-purple-800'), 113 | '&:hover': { 114 | 'background-color': theme( 115 | 'colors.brand-purple-800' 116 | ), 117 | color: colors.white, 118 | }, 119 | textUnderlinePosition: 'under', 120 | textUnderlineOffset: '2px', 121 | }, 122 | 'code::before': { 123 | content: '""', 124 | }, 125 | 'code::after': { 126 | content: '""', 127 | }, 128 | code: { 129 | color: theme('colors.brand-gray-750'), 130 | 'background-color': theme('colors.brand-gray-300'), 131 | 'border-radius': '6px', 132 | display: 'inline-block', 133 | padding: '2px 4px', 134 | whitespace: 'no-wrap', 135 | }, 136 | 'ul li': { 137 | marginTop: '0px', 138 | marginBottom: '0px', 139 | }, 140 | 'ul ul': { 141 | marginTop: '0px', 142 | marginBottom: '0px', 143 | }, 144 | }, 145 | }, 146 | dark: { 147 | css: { 148 | color: theme('colors.brand-gray-200'), 149 | pre: { 150 | 'background-color': theme('colors.brand-gray-900'), 151 | }, 152 | h1: { 153 | color: theme('colors.brand-gray-200'), 154 | }, 155 | h2: { 156 | color: theme('colors.brand-gray-200'), 157 | }, 158 | h3: { 159 | color: theme('colors.brand-gray-200'), 160 | }, 161 | h4: { 162 | color: theme('colors.brand-gray-200'), 163 | }, 164 | a: { 165 | color: theme('colors.brand-purple-100'), 166 | '&:hover': { 167 | 'background-color': theme( 168 | 'colors.brand-purple-100' 169 | ), 170 | color: colors.white, 171 | }, 172 | }, 173 | 'a code': { 174 | color: theme('colors.brand-gray-750'), 175 | }, 176 | 'code.makeup': { 177 | 'background-color': 'transparent', 178 | }, 179 | strong: { 180 | color: theme('colors.brand-gray-200'), 181 | }, 182 | 'ul li': { 183 | marginTop: '0px', 184 | marginBottom: '0px', 185 | }, 186 | 'ul ul': { 187 | marginTop: '0px', 188 | marginBottom: '0px', 189 | }, 190 | blockquote: { 191 | color: theme('colors.brand-gray-300'), 192 | }, 193 | thead: { 194 | color: theme('colors.brand-gray-300'), 195 | }, 196 | code: { 197 | color: theme('colors.brand-gray-750'), 198 | }, 199 | 'pre code': { 200 | color: colors.white, 201 | }, 202 | }, 203 | }, 204 | }), 205 | }, 206 | }, 207 | variants: { 208 | extend: { typography: ['dark'] }, 209 | }, 210 | plugins: [ 211 | require('@tailwindcss/typography'), 212 | require('@tailwindcss/forms'), 213 | require('@tailwindcss/aspect-ratio'), 214 | ], 215 | } 216 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | # This file is responsible for configuring your application 2 | # and its dependencies with the aid of the Mix.Config module. 3 | # 4 | # This configuration file is loaded before any dependency and 5 | # is restricted to this project. 6 | 7 | # General application configuration 8 | import Config 9 | 10 | config :school_house, 11 | lesson_dir: "content/lessons", 12 | blog_dir: "content/posts/**/*.md", 13 | podcast_dir: "content/podcasts/*.md", 14 | conference_dir: "content/conferences/*.md" 15 | 16 | # Configures the endpoint 17 | config :school_house, SchoolHouseWeb.Endpoint, 18 | url: [host: "localhost"], 19 | secret_key_base: "nkt+Wx4IwiwuGAAwhadih8PBxcacSurWEq1mWbLUEiRuA6s+Vtl075yHVnyhxWeV", 20 | render_errors: [view: SchoolHouseWeb.ErrorView, accepts: ~w(html json), layout: false], 21 | pubsub_server: SchoolHouse.PubSub, 22 | live_view: [signing_salt: "zU0Cq05z"] 23 | 24 | # Configures Elixir's Logger 25 | config :logger, :console, 26 | format: "$time $metadata[$level] $message\n", 27 | metadata: [:request_id], 28 | level: :debug 29 | 30 | # Use Jason for JSON parsing in Phoenix 31 | config :phoenix, :json_library, Jason 32 | 33 | # You should add locales here and in SchoolHouse.LocaleInfo 34 | config :school_house, SchoolHouseWeb.Gettext, 35 | default_locale: "en", 36 | locales: ~w(ar bg bn de el en es fa fr id it ja ko ms no pl pt ru sk ta th tr uk vi zh-hans zh-hant) 37 | 38 | # Configure esbuild (the version is required) 39 | config :esbuild, 40 | version: "0.12.18", 41 | default: [ 42 | args: ~w(js/app.js js/initialize-theme.js --bundle --target=es2016 --outdir=../priv/static/assets), 43 | cd: Path.expand("../assets", __DIR__), 44 | env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)} 45 | ] 46 | 47 | import_config "lessons.exs" 48 | import_config "redirects.exs" 49 | 50 | # Import environment specific config. This must remain at the bottom 51 | # of this file so it overrides the configuration defined above. 52 | import_config "#{Mix.env()}.exs" 53 | -------------------------------------------------------------------------------- /config/dev.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | # For development, we disable any cache and enable 4 | # debugging and code reloading. 5 | # 6 | # The watchers configuration can be used to run external 7 | # watchers to your application. For example, we use it 8 | # with webpack to recompile .js and .css sources. 9 | config :school_house, SchoolHouseWeb.Endpoint, 10 | http: [port: 4000], 11 | debug_errors: true, 12 | code_reloader: true, 13 | check_origin: false, 14 | watchers: [ 15 | esbuild: {Esbuild, :install_and_run, [:default, ~w(--sourcemap=inline --watch)]}, 16 | npx: [ 17 | "tailwindcss", 18 | "--input=css/app.css", 19 | "--output=../priv/static/assets/app.css", 20 | "--postcss", 21 | "--watch", 22 | cd: Path.expand("../assets", __DIR__) 23 | ] 24 | ] 25 | 26 | # ## SSL Support 27 | # 28 | # In order to use HTTPS in development, a self-signed 29 | # certificate can be generated by running the following 30 | # Mix task: 31 | # 32 | # mix phx.gen.cert 33 | # 34 | # Note that this task requires Erlang/OTP 20 or later. 35 | # Run `mix help phx.gen.cert` for more information. 36 | # 37 | # The `http:` config above can be replaced with: 38 | # 39 | # https: [ 40 | # port: 4001, 41 | # cipher_suite: :strong, 42 | # keyfile: "priv/cert/selfsigned_key.pem", 43 | # certfile: "priv/cert/selfsigned.pem" 44 | # ], 45 | # 46 | # If desired, both `http:` and `https:` keys can be 47 | # configured to run both http and https servers on 48 | # different ports. 49 | 50 | # Watch static and templates for browser reloading. 51 | config :school_house, SchoolHouseWeb.Endpoint, 52 | live_reload: [ 53 | patterns: [ 54 | ~r"priv/static/.*(js|css|png|jpeg|jpg|gif|svg)$", 55 | ~r"priv/gettext/.*(po)$", 56 | ~r"lib/school_house_web/(live|views)/.*(ex)$", 57 | ~r"lib/school_house_web/templates/.*(eex)$", 58 | ~r"lessons/*/.*(md)$" 59 | ] 60 | ] 61 | 62 | # Do not include metadata nor timestamps in development logs 63 | config :logger, :console, format: "[$level] $message\n" 64 | 65 | # Set a higher stacktrace during development. Avoid configuring such 66 | # in production as building large stacktraces may be expensive. 67 | config :phoenix, :stacktrace_depth, 20 68 | 69 | # Initialize plugs at runtime for faster development compilation 70 | config :phoenix, :plug_init_mode, :runtime 71 | -------------------------------------------------------------------------------- /config/lessons.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | config :school_house, 4 | future_lessons: [ 5 | :doctests, 6 | :flow, 7 | :broadway, 8 | :querying_advanced, 9 | :cachex, 10 | :redix 11 | ] 12 | 13 | config :school_house, 14 | lessons: [ 15 | basics: [ 16 | :basics, 17 | :collections, 18 | :enum, 19 | :pattern_matching, 20 | :control_structures, 21 | :functions, 22 | :pipe_operator, 23 | :modules, 24 | :mix, 25 | :sigils, 26 | :documentation, 27 | :comprehensions, 28 | :strings, 29 | :date_time, 30 | :iex_helpers 31 | ], 32 | intermediate: [ 33 | :mix_tasks, 34 | :erlang, 35 | :error_handling, 36 | :escripts, 37 | :concurrency 38 | ], 39 | advanced: [ 40 | :otp_concurrency, 41 | :otp_supervisors, 42 | :otp_distribution, 43 | :metaprogramming, 44 | :umbrella_projects, 45 | :typespec, 46 | :behaviours, 47 | :protocols 48 | ], 49 | testing: [ 50 | :basics, 51 | :doctests, 52 | :bypass, 53 | :mox, 54 | :stream_data 55 | ], 56 | data_processing: [ 57 | :genstage, 58 | :flow, 59 | :broadway 60 | ], 61 | ecto: [ 62 | :basics, 63 | :changesets, 64 | :associations, 65 | :querying_basics, 66 | :querying_advanced 67 | ], 68 | storage: [ 69 | :ets, 70 | :mnesia, 71 | :cachex, 72 | :redix 73 | ], 74 | misc: [ 75 | :benchee, 76 | :plug, 77 | :eex, 78 | :debugging, 79 | :nerves, 80 | :guardian, 81 | :poolboy, 82 | :distillery, 83 | :nimble_publisher 84 | ] 85 | ] 86 | -------------------------------------------------------------------------------- /config/prod.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | # For production, don't forget to configure the url host 3 | # to something meaningful, Phoenix uses this information 4 | # when generating URLs. 5 | # 6 | # Note we also include the path to a cache manifest 7 | # containing the digested version of static files. This 8 | # manifest is generated by the `mix phx.digest` task, 9 | # which you should run after static files are built and 10 | # before starting your production server. 11 | config :school_house, SchoolHouseWeb.Endpoint, 12 | http: [port: {:system, "PORT"}], 13 | url: [scheme: "https", host: "elixirschool.com", port: 443], 14 | cache_static_manifest: "priv/static/cache_manifest.json" 15 | 16 | # Do not print debug messages in production 17 | config :logger, level: :info 18 | 19 | # ## SSL Support 20 | # 21 | # To get SSL working, you will need to add the `https` key 22 | # to the previous section and set your `:url` port to 443: 23 | # 24 | # config :school_house, SchoolHouseWeb.Endpoint, 25 | # ... 26 | # url: [host: "example.com", port: 443], 27 | # https: [ 28 | # port: 443, 29 | # cipher_suite: :strong, 30 | # keyfile: System.get_env("SOME_APP_SSL_KEY_PATH"), 31 | # certfile: System.get_env("SOME_APP_SSL_CERT_PATH"), 32 | # transport_options: [socket_opts: [:inet6]] 33 | # ] 34 | # 35 | # The `cipher_suite` is set to `:strong` to support only the 36 | # latest and more secure SSL ciphers. This means old browsers 37 | # and clients may not be supported. You can set it to 38 | # `:compatible` for wider support. 39 | # 40 | # `:keyfile` and `:certfile` expect an absolute path to the key 41 | # and cert in disk or a relative path inside priv, for example 42 | # "priv/ssl/server.key". For all supported SSL configuration 43 | # options, see https://hexdocs.pm/plug/Plug.SSL.html#configure/1 44 | # 45 | # We also recommend setting `force_ssl` in your endpoint, ensuring 46 | # no data is ever sent via http, always redirecting to https: 47 | # 48 | # config :school_house, SchoolHouseWeb.Endpoint, 49 | # force_ssl: [hsts: true] 50 | # 51 | # Check `Plug.SSL` for all available options in `force_ssl`. 52 | 53 | # Finally import the config/prod.secret.exs which loads secrets 54 | # and configuration from environment variables. 55 | -------------------------------------------------------------------------------- /config/redirects.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | config :school_house, 4 | redirects: %{ 5 | ~r/^\/$/ => "/en", 6 | ~r/lessons\/basics\/pattern-matching\/?$/ => "lessons/basics/pattern_matching", 7 | ~r/lessons\/basics\/control-structures\/?$/ => "lessons/basics/control_structures", 8 | ~r/lessons\/basics\/pipe-operator\/?$/ => "lessons/basics/pipe_operator", 9 | ~r/lessons\/basics\/date-time\/?$/ => "lessons/basics/date_time", 10 | ~r/lessons\/basics\/mix-tasks\/?$/ => "lessons/intermediate/mix_tasks", 11 | ~r/lessons\/basics\/iex-helpers\/?$/ => "lessons/basics/iex_helpers", 12 | ~r/lessons\/basics\/testing\/?$/ => "lessons/testing/basics", 13 | ~r/lessons\/advanced\/erlang\/?$/ => "lessons/intermediate/erlang", 14 | ~r/lessons\/advanced\/error-handling\/?$/ => "lessons/intermediate/error_handling", 15 | ~r/lessons\/advanced\/escripts\/?$/ => "lessons/intermediate/escripts", 16 | ~r/lessons\/advanced\/concurrency\/?$/ => "lessons/intermediate/concurrency", 17 | ~r/lessons\/advanced\/otp-concurrency\/?$/ => "lessons/advanced/otp_concurrency", 18 | ~r/lessons\/advanced\/otp-supervisors\/?$/ => "lessons/advanced/otp_supervisors", 19 | ~r/lessons\/advanced\/otp-distribution\/?$/ => "lessons/advanced/otp_distribution", 20 | ~r/lessons\/advanced\/umbrella-projects\/?$/ => "lessons/advanced/umbrella_projects", 21 | ~r/lessons\/advanced\/gen-stage\/?$/ => "lessons/data_processing/genstage", 22 | ~r/lessons\/ecto\/querying\/?$/ => "lessons/ecto/querying_basics", 23 | ~r/lessons\/specifics\/plug\/?$/ => "lessons/misc/plug", 24 | ~r/lessons\/specifics\/eex\/?$/ => "lessons/misc/eex", 25 | ~r/lessons\/specifics\/ets\/?$/ => "lessons/storage/ets", 26 | ~r/lessons\/specifics\/mnesia\/?$/ => "lessons/storage/mnesia", 27 | ~r/lessons\/specifics\/debugging\/?$/ => "lessons/misc/debugging", 28 | ~r/lessons\/specifics\/nerves\/?$/ => "lessons/misc/nerves", 29 | ~r/lessons\/libraries\/guardian\/?$/ => "lessons/misc/guardian", 30 | ~r/lessons\/libraries\/poolboy\/?$/ => "lessons/misc/poolboy", 31 | ~r/lessons\/libraries\/benchee\/?$/ => "lessons/misc/benchee", 32 | ~r/lessons\/libraries\/bypass\/?$/ => "lessons/testing/bypass", 33 | ~r/lessons\/libraries\/distillery\/?$/ => "lessons/misc/distillery", 34 | ~r/lessons\/libraries\/stream-data\/?$/ => "lessons/testing/stream_data", 35 | ~r/lessons\/libraries\/nimble-publisher\/?$/ => "lessons/misc/nimble_publisher", 36 | ~r/lessons\/libraries\/mox\/?$/ => "lessons/testing/mox", 37 | ~r/^\/cb/ => "/zh-hans", 38 | ~r/^\/tw/ => "/zh-hant", 39 | ~r/^\/my/ => "/me", 40 | ~r/^\/jp/ => "/ja", 41 | ~r/^\/gr/ => "/el" 42 | } 43 | -------------------------------------------------------------------------------- /config/releases.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | secret_key_base = 4 | System.get_env("SECRET_KEY_BASE") || 5 | raise """ 6 | environment variable SECRET_KEY_BASE is missing. 7 | You can generate one by calling: mix phx.gen.secret 8 | """ 9 | 10 | live_view_salt = 11 | System.get_env("LIVE_VIEW_SALT") || 12 | raise """ 13 | environment variable LIVE_VIEW_SALT is missing. 14 | You can generate one by calling: mix phx.gen.secret 15 | """ 16 | 17 | app_name = System.get_env("FLY_APP_NAME", "elixirschool") 18 | 19 | host = 20 | case app_name do 21 | "elixirschool" -> "elixirschool.com" 22 | app_name -> "#{app_name}.fly.dev" 23 | end 24 | 25 | config :school_house, SchoolHouseWeb.Endpoint, 26 | http: [ 27 | port: String.to_integer(System.get_env("PORT", "4000")), 28 | transport_options: [socket_opts: [:inet6]] 29 | ], 30 | secret_key_base: secret_key_base, 31 | url: [scheme: "https", host: host, port: 443], 32 | live_view: [signing_salt: live_view_salt], 33 | server: true 34 | 35 | config :libcluster, 36 | topologies: [ 37 | fly6pn: [ 38 | strategy: Cluster.Strategy.DNSPoll, 39 | config: [ 40 | polling_interval: 5_000, 41 | query: "#{app_name}.internal", 42 | node_basename: app_name 43 | ] 44 | ] 45 | ] 46 | -------------------------------------------------------------------------------- /config/test.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | # We don't run a server during test. If one is required, 4 | # you can enable the server option below. 5 | config :school_house, SchoolHouseWeb.Endpoint, 6 | http: [port: 4002], 7 | server: false 8 | 9 | # Print only warnings and errors during test 10 | config :logger, level: :warn 11 | 12 | config :school_house, 13 | lesson_dir: "test/support/content/lessons", 14 | blog_dir: "test/support/content/posts/**/*.md", 15 | conference_dir: "test/support/content/conferences/**/*.md" 16 | 17 | config :school_house, 18 | lessons: [ 19 | basics: [ 20 | :basics, 21 | :collections, 22 | :functions, 23 | :enum 24 | ], 25 | intermediate: [ 26 | :mix_tasks, 27 | :erlang 28 | ] 29 | ] 30 | 31 | config :school_house, 32 | future_lessons: [ 33 | :mix_tasks, 34 | :functions 35 | ] 36 | -------------------------------------------------------------------------------- /fly.toml: -------------------------------------------------------------------------------- 1 | app = "elixirschool" 2 | 3 | kill_signal = "SIGINT" 4 | kill_timeout = 5 5 | processes = [] 6 | 7 | [env] 8 | PORT = 4000 9 | 10 | [experimental] 11 | allowed_public_ports = [] 12 | auto_rollback = true 13 | 14 | [[services]] 15 | http_checks = [] 16 | internal_port = 4000 17 | processes = ["app"] 18 | protocol = "tcp" 19 | script_checks = [] 20 | 21 | [services.concurrency] 22 | hard_limit = 1_000 23 | soft_limit = 750 24 | type = "connections" 25 | 26 | [[services.ports]] 27 | force_https = true 28 | handlers = ["http"] 29 | port = 80 30 | 31 | [[services.ports]] 32 | handlers = ["tls", "http"] 33 | port = 443 34 | 35 | [[services.tcp_checks]] 36 | grace_period = "5s" 37 | interval = "15s" 38 | restart_limit = 0 39 | timeout = "2s" 40 | 41 | [[statics]] 42 | guest_path = "/app/static" 43 | url_prefix = "/" 44 | -------------------------------------------------------------------------------- /lib/mix/tasks/school_house.gen.rss.ex: -------------------------------------------------------------------------------- 1 | defmodule Mix.Tasks.SchoolHouse.Gen.Rss do 2 | use Mix.Task 3 | 4 | @moduledoc """ 5 | Generate an RSS feed from posts 6 | """ 7 | 8 | alias SchoolHouse.Posts 9 | alias SchoolHouseWeb.Router.Helpers 10 | 11 | @destination "priv/static/feed.xml" 12 | 13 | def run([uri_string]) do 14 | uri_string 15 | |> parse_uri() 16 | |> generate() 17 | end 18 | 19 | def parse_uri(uri_string) do 20 | case URI.parse(uri_string) do 21 | %{host: nil} -> 22 | raise ArgumentError, message: "no URI with host given" 23 | 24 | uri -> 25 | uri 26 | end 27 | end 28 | 29 | def generate(uri) do 30 | items = 31 | 0..(Posts.pages() - 1) 32 | |> Enum.flat_map(&Posts.page/1) 33 | |> Enum.map_join(&link_xml(&1, uri)) 34 | 35 | document = """ 36 | 37 | 38 | 39 | #{items} 40 | 41 | 42 | """ 43 | 44 | write_to_priv_file!(@destination, document) 45 | end 46 | 47 | defp write_to_priv_file!(file_path, contents) do 48 | full_file_path = Application.app_dir(:school_house, file_path) 49 | 50 | full_file_path 51 | |> Path.dirname() 52 | |> File.mkdir_p!() 53 | 54 | File.write!(full_file_path, contents) 55 | end 56 | 57 | defp link_xml(post, uri) do 58 | link = Helpers.post_url(uri, :show, post.slug) 59 | 60 | """ 61 | 62 | #{post.title_text} 63 | #{post.excerpt} 64 | #{Calendar.strftime(post.date, "%a, %d %B %Y 00:00:00 +0000")} 65 | #{link} 66 | #{link} 67 | 68 | """ 69 | end 70 | end 71 | -------------------------------------------------------------------------------- /lib/mix/tasks/school_house.gen.sitemap.ex: -------------------------------------------------------------------------------- 1 | defmodule Mix.Tasks.SchoolHouse.Gen.Sitemap do 2 | use Mix.Task 3 | 4 | @moduledoc """ 5 | Generate a complete sitemap including all locale URLs 6 | """ 7 | 8 | alias SchoolHouse.{Lessons, Posts} 9 | alias SchoolHouse.LocaleInfo 10 | alias SchoolHouseWeb.Router.Helpers 11 | 12 | @destination "priv/static/sitemap.xml" 13 | @destination_dark_mode "priv/static/sitemap_dark_mode.xml" 14 | 15 | def run([uri_string]) do 16 | uri_string 17 | |> parse_uri() 18 | |> generate() 19 | end 20 | 21 | def parse_uri(uri_string) do 22 | case URI.parse(uri_string) do 23 | %{host: nil} -> 24 | raise ArgumentError, message: "no URI with host given" 25 | 26 | uri -> 27 | uri 28 | end 29 | end 30 | 31 | def generate(uri) do 32 | links = 33 | :light 34 | |> all_links(uri) 35 | |> Enum.map_join(&link_xml/1) 36 | 37 | dark_mode_links = 38 | :dark 39 | |> all_links(uri) 40 | |> Enum.map_join(&link_xml/1) 41 | 42 | write_to_priv_file!(@destination, generate_document(links)) 43 | write_to_priv_file!(@destination_dark_mode, generate_document(dark_mode_links)) 44 | end 45 | 46 | defp write_to_priv_file!(file_path, contents) do 47 | full_file_path = Application.app_dir(:school_house, file_path) 48 | 49 | full_file_path 50 | |> Path.dirname() 51 | |> File.mkdir_p!() 52 | 53 | File.write!(full_file_path, contents) 54 | end 55 | 56 | defp generate_document(links) do 57 | """ 58 | 59 | 60 | #{links} 61 | 62 | """ 63 | end 64 | 65 | defp link_xml(url), do: "#{url}" 66 | 67 | defp all_links(theme, uri) do 68 | top_level_pages = [{:post, :index}, {:page, :privacy}] 69 | 70 | Enum.map(top_level_pages, &create_helper_link(&1, theme, uri)) ++ 71 | post_links(theme, uri) ++ Enum.flat_map(LocaleInfo.list(), &locale_links(&1, theme, uri)) 72 | end 73 | 74 | defp locale_links(locale, theme, uri), do: page_links(locale, theme, uri) ++ lesson_links(locale, theme, uri) 75 | 76 | defp page_links(locale, theme, uri) do 77 | pages = [ 78 | {:live, SchoolHouseWeb.ConferencesLive}, 79 | {:page, :index}, 80 | {:page, :podcasts}, 81 | {:page, :why}, 82 | {:page, :get_involved}, 83 | {:report, :index} 84 | ] 85 | 86 | Enum.map(pages, &create_helper_link(&1, locale, theme, uri)) 87 | end 88 | 89 | defp lesson_links(locale, theme, uri) do 90 | config = Application.get_env(:school_house, :lessons) 91 | 92 | translated_lesson_links = 93 | for {section, lessons} <- config, lesson <- lessons, translated_lesson?(section, lesson, locale) do 94 | apply_theme(theme, &Helpers.lesson_url/6, [uri, :lesson, locale, section, lesson]) 95 | end 96 | 97 | section_indexes = 98 | for section <- Keyword.keys(config) do 99 | apply_theme(theme, &Helpers.lesson_url/5, [uri, :index, locale, section]) 100 | end 101 | 102 | section_indexes ++ translated_lesson_links 103 | end 104 | 105 | defp translated_lesson?(section, lesson, locale) do 106 | case Lessons.get(section, lesson, locale) do 107 | {:ok, _} -> true 108 | _ -> false 109 | end 110 | end 111 | 112 | defp post_links(theme, uri) do 113 | 0..(Posts.pages() - 1) 114 | |> Enum.flat_map(&Posts.page/1) 115 | |> Enum.map(fn post -> 116 | apply_theme(theme, &Helpers.post_url/4, [uri, :show, post.slug]) 117 | end) 118 | end 119 | 120 | defp create_helper_link({:page, page}, theme, uri) do 121 | apply_theme(theme, &Helpers.page_url/3, [uri, page]) 122 | end 123 | 124 | defp create_helper_link({:post, page}, theme, uri) do 125 | apply_theme(theme, &Helpers.post_url/3, [uri, page]) 126 | end 127 | 128 | defp create_helper_link({:page, page}, locale, theme, uri) do 129 | apply_theme(theme, &Helpers.page_url/4, [uri, page, locale]) 130 | end 131 | 132 | defp create_helper_link({:live, page}, locale, theme, uri) do 133 | apply_theme(theme, &Helpers.live_url/4, [uri, page, locale]) 134 | end 135 | 136 | defp create_helper_link({:report, page}, locale, theme, uri) do 137 | apply_theme(theme, &Helpers.report_url/4, [uri, page, locale]) 138 | end 139 | 140 | defp apply_theme(:dark, routes_helper_fn, args) do 141 | apply(routes_helper_fn, args ++ [[ui: :dark]]) 142 | end 143 | 144 | defp apply_theme(:light, routes_helper_fn, args) do 145 | apply(routes_helper_fn, args ++ [[]]) 146 | end 147 | end 148 | -------------------------------------------------------------------------------- /lib/school_house.ex: -------------------------------------------------------------------------------- 1 | defmodule SchoolHouse do 2 | @moduledoc """ 3 | SchoolHouse keeps the contexts that define your domain 4 | and business logic. 5 | 6 | Contexts are also responsible for managing your data, regardless 7 | if it comes from the database, an external API or others. 8 | """ 9 | end 10 | -------------------------------------------------------------------------------- /lib/school_house/application.ex: -------------------------------------------------------------------------------- 1 | defmodule SchoolHouse.Application do 2 | # See https://hexdocs.pm/elixir/Application.html 3 | # for more information on OTP Applications 4 | @moduledoc false 5 | 6 | use Application 7 | 8 | def start(_type, _args) do 9 | topologies = Application.get_env(:libcluster, :topologies) || [] 10 | 11 | children = [ 12 | {Cluster.Supervisor, [topologies, [name: SchoolHouse.ClusterSupervisor]]}, 13 | # Start the Telemetry supervisor 14 | SchoolHouseWeb.Telemetry, 15 | # Start the PubSub system 16 | {Phoenix.PubSub, name: SchoolHouse.PubSub}, 17 | # Start the Endpoint (http/https) 18 | SchoolHouseWeb.Endpoint 19 | # Start a worker by calling: SchoolHouse.Worker.start_link(arg) 20 | # {SchoolHouse.Worker, arg} 21 | ] 22 | 23 | # See https://hexdocs.pm/elixir/Supervisor.html 24 | # for other strategies and supported options 25 | opts = [strategy: :one_for_one, name: SchoolHouse.Supervisor] 26 | Supervisor.start_link(children, opts) 27 | end 28 | 29 | # Tell Phoenix to update the endpoint configuration 30 | # whenever the application is updated. 31 | def config_change(changed, _new, removed) do 32 | SchoolHouseWeb.Endpoint.config_change(changed, removed) 33 | :ok 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /lib/school_house/conferences.ex: -------------------------------------------------------------------------------- 1 | defmodule SchoolHouse.Conferences do 2 | @moduledoc false 3 | 4 | use NimblePublisher, 5 | build: SchoolHouse.Content.Conference, 6 | from: Application.compile_env!(:school_house, :conference_dir), 7 | as: :conferences 8 | 9 | @ordered_conferences Enum.sort(@conferences, &(Date.compare(&1.date, &2.date) == :gt)) 10 | @online Enum.filter(@ordered_conferences, &is_nil(&1.location)) 11 | @by_countries Enum.group_by(@ordered_conferences, & &1.country) 12 | 13 | def list, do: @ordered_conferences 14 | 15 | def countries do 16 | list() 17 | |> Enum.map(& &1.country) 18 | |> Enum.reject(&is_nil/1) 19 | end 20 | 21 | def online, do: @online 22 | 23 | def by_country(country) do 24 | Map.get(@by_countries, country, []) 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /lib/school_house/content/conference.ex: -------------------------------------------------------------------------------- 1 | defmodule SchoolHouse.Content.Conference do 2 | @moduledoc false 3 | 4 | @enforce_keys [:name, :link, :date] 5 | defstruct [ 6 | :name, 7 | :link, 8 | :date, 9 | :series, 10 | :location, 11 | :country 12 | ] 13 | 14 | def build(_filename, attrs, _body) do 15 | struct!(__MODULE__, Map.to_list(attrs)) 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/school_house/content/lesson.ex: -------------------------------------------------------------------------------- 1 | defmodule SchoolHouse.Content.Lesson do 2 | @moduledoc """ 3 | Encapsulates an individual lesson and handles parsing the originating markdown file 4 | """ 5 | 6 | @headers_regex ~r/<(h\d)>(["\p{L}\p{M}\s?!\.\/\d]+)(<\/\1>)/iu 7 | @enforce_keys [:body, :section, :locale, :name, :title, :version] 8 | 9 | defstruct [ 10 | :body, 11 | :excerpt, 12 | :locale, 13 | :name, 14 | :next, 15 | :previous, 16 | :redirect_from, 17 | :section, 18 | :table_of_contents, 19 | :title, 20 | :version 21 | ] 22 | 23 | def build(filename, attrs, body) do 24 | [locale, section, name] = 25 | filename 26 | |> Path.rootname() 27 | |> Path.split() 28 | |> Enum.take(-3) 29 | 30 | filename_attrs = [ 31 | body: add_table_of_contents_links(body), 32 | locale: locale, 33 | excerpt: convert_meta(attrs.excerpt, "excerpt"), 34 | name: String.to_atom(name), 35 | section: String.to_atom(section), 36 | table_of_contents: table_of_contents_html(body), 37 | version: parse_version(attrs.version) 38 | ] 39 | 40 | struct!(__MODULE__, Map.to_list(attrs) ++ filename_attrs) 41 | end 42 | 43 | defp add_table_of_contents_links(body) do 44 | toc_html = table_of_contents_html(body) 45 | 46 | body = String.replace(body, "{% include toc.html %}", toc_html) 47 | 48 | replace_counted(@headers_regex, body, fn _, header, name, _, count -> 49 | fragment = link_fragment(name, count) 50 | 51 | Phoenix.View.render_to_string(SchoolHouseWeb.LessonView, "_section_header.html", 52 | fragment: fragment, 53 | header: header, 54 | name: name 55 | ) 56 | end) 57 | end 58 | 59 | defp link_fragment(name, index) do 60 | name = 61 | name 62 | |> String.trim() 63 | |> String.downcase() 64 | 65 | name 66 | |> String.replace(~r/[^\p{L}\s]/u, "") 67 | |> String.replace(" ", "-") 68 | |> Kernel.<>("-#{index}") 69 | end 70 | 71 | defp page_link(name, index) do 72 | "#{name}" 73 | end 74 | 75 | defp parse_version(string) do 76 | [major, minor, patch] = 77 | string 78 | |> String.split(".") 79 | |> Enum.map(&String.to_integer/1) 80 | 81 | {major, minor, patch} 82 | end 83 | 84 | defp build_table(matches, acc \\ []) 85 | 86 | defp build_table([], []) do 87 | "" 88 | end 89 | 90 | defp build_table([], acc) do 91 | "" 92 | end 93 | 94 | defp build_table([{size, name, index} | matches], acc) do 95 | {children, remaining} = Enum.split_while(matches, fn {s, _, _} -> s > size end) 96 | children = build_table(children) 97 | 98 | section_link = 99 | name 100 | |> String.trim() 101 | |> page_link(index) 102 | 103 | build_table(remaining, ["
  • #{section_link}#{children}
  • " | acc]) 104 | end 105 | 106 | defp table_of_contents_html(body) do 107 | @headers_regex 108 | |> Regex.scan(body) 109 | |> Enum.with_index() 110 | |> Enum.map(fn {[_, "h" <> size, name, _], index} -> {String.to_integer(size), String.trim(name), index} end) 111 | |> build_table() 112 | |> String.replace_leading(" 137 | replacement.(m1, m2, m3, m4, counter) 138 | end 139 | 140 | new = Regex.replace(regex, input, replace_wrapper, global: false) 141 | replace_counted_helper(regex, input, new, replacement, counter + 1) 142 | end 143 | end 144 | -------------------------------------------------------------------------------- /lib/school_house/content/podcast.ex: -------------------------------------------------------------------------------- 1 | defmodule SchoolHouse.Content.Podcast do 2 | @moduledoc false 3 | 4 | @enforce_keys [:about, :active, :logo, :name, :website] 5 | defstruct [ 6 | :about, 7 | :active, 8 | :language, 9 | :logo, 10 | :name, 11 | :website 12 | ] 13 | 14 | def build(_filename, attrs, _body) do 15 | struct!(__MODULE__, Map.to_list(attrs)) 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/school_house/content/post.ex: -------------------------------------------------------------------------------- 1 | defmodule SchoolHouse.Content.Post do 2 | @moduledoc """ 3 | Encapsulates an individual post and handles parsing the originating markdown file 4 | """ 5 | 6 | @enforce_keys [:author, :author_link, :body, :date, :excerpt, :slug, :tags, :title, :title_text] 7 | defstruct [ 8 | :author, 9 | :author_link, 10 | :body, 11 | :date, 12 | :excerpt, 13 | :slug, 14 | :tags, 15 | :title, 16 | :title_text 17 | ] 18 | 19 | @doc """ 20 | A helper function leveraged used by NimblePublisher to create each of our Post structs provided the filename, file's metadata, and the file body. Generates the post slug by trimming the date from filenames to match the legacy Jekyll blog post format. 21 | 22 | Examples 23 | 24 | iex> build("2021_06_15_really_smart_blog_post.md", %{}, "") 25 | %Post{slug: "really_smart_blog_post"} 26 | 27 | """ 28 | def build(filename, attrs, body) do 29 | date_prefix_length = 11 30 | 31 | slug = 32 | filename 33 | |> Path.basename(".md") 34 | |> String.slice(date_prefix_length..-1) 35 | 36 | {title_text, attrs} = Map.pop!(attrs, :title) 37 | {excerpt, attrs} = Map.pop!(attrs, :excerpt) 38 | 39 | attrs = 40 | attrs 41 | |> Map.put(:title_text, title_text) 42 | |> Map.put(:title, Earmark.as_html!(title_text)) 43 | |> Map.put(:excerpt, Earmark.as_html!(excerpt)) 44 | 45 | struct!(__MODULE__, [body: body, slug: slug] ++ Map.to_list(attrs)) 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /lib/school_house/lessons.ex: -------------------------------------------------------------------------------- 1 | defmodule SchoolHouse.Lessons do 2 | @moduledoc """ 3 | Implements NimblePublisher and functions for retrieving lessons 4 | """ 5 | 6 | alias SchoolHouse.Content.Lesson 7 | alias SchoolHouse.LocaleInfo 8 | 9 | @ordering Application.compile_env!(:school_house, :lessons) 10 | @future_lessons Application.compile_env!(:school_house, :future_lessons) 11 | @filtered_lessons Enum.map(@ordering, fn {section_name, lessons} -> 12 | {section_name, Enum.filter(lessons, &(not Enum.member?(@future_lessons, &1)))} 13 | end) 14 | @locales LocaleInfo.list() 15 | 16 | for locale <- @locales do 17 | path = Path.join([Application.compile_env(:school_house, :lesson_dir), locale, "**/*.md"]) 18 | 19 | contents = 20 | quote do 21 | use NimblePublisher, 22 | build: SchoolHouse.Content.Lesson, 23 | from: unquote(path), 24 | as: :lessons, 25 | highlighters: [:makeup_elixir, :makeup_erlang] 26 | 27 | @lesson_map Enum.into(@lessons, %{}, &{"#{&1.section}/#{&1.name}", &1}) 28 | 29 | def get(section, name) do 30 | Map.get(@lesson_map, "#{section}/#{name}") 31 | end 32 | end 33 | 34 | __MODULE__ 35 | |> Module.concat(String.capitalize(locale)) 36 | |> Module.create(contents, Macro.Env.location(__ENV__)) 37 | end 38 | 39 | def exists?(section, name, locale) do 40 | locale in @locales and nil != locale_lessons(locale).get(section, name) 41 | end 42 | 43 | def coming_soon?(name), do: name in @future_lessons 44 | 45 | defp locale_lessons(locale) do 46 | Module.concat(__MODULE__, String.capitalize(locale)) 47 | end 48 | 49 | def get(section, name, locale) when locale in @locales do 50 | with nil <- locale_lessons(locale).get(section, name), 51 | true <- translation?(locale), 52 | %Lesson{} <- locale_lessons("en").get(section, name) do 53 | {:error, :translation_not_found, locale} 54 | else 55 | %Lesson{} = lesson -> {:ok, populate_surrounding_lessons(lesson)} 56 | _ -> {:error, :not_found} 57 | end 58 | end 59 | 60 | def get(_section, _name, _locale) do 61 | {:error, :not_found} 62 | end 63 | 64 | def translation_report(locale) do 65 | :school_house 66 | |> Application.get_env(:lessons) 67 | |> Enum.reduce(%{}, §ion_report(&1, locale, &2)) 68 | end 69 | 70 | defp section_report({section, lessons}, locale, acc) do 71 | lesson_statuses = Enum.map(lessons, &lesson_status(section, &1, locale)) 72 | Map.put(acc, section, lesson_statuses) 73 | end 74 | 75 | def lesson_status(section, name, locale) do 76 | original = locale_lessons("en").get(section, name) 77 | 78 | case locale_lessons(locale).get(section, name) do 79 | nil -> 80 | %{lesson: nil, original: original, name: name, status: :missing} 81 | 82 | %Lesson{} = lesson -> 83 | %{lesson: lesson, original: original, name: name, status: compare_versions(lesson, original)} 84 | end 85 | end 86 | 87 | defp compare_versions(%{version: {translation_major, translation_minor, translation_patch}}, %{ 88 | version: {major, minor, patch} 89 | }) do 90 | cond do 91 | major > translation_major -> :major 92 | minor > translation_minor -> :minor 93 | patch > translation_patch -> :patch 94 | true -> :complete 95 | end 96 | end 97 | 98 | def list(section, locale) do 99 | section = String.to_existing_atom(section) 100 | 101 | config = 102 | :school_house 103 | |> Application.get_env(:lessons) 104 | |> Keyword.get(section) 105 | 106 | lessons = 107 | for lesson <- config do 108 | locale_lessons(locale).get(section, lesson) 109 | end 110 | 111 | Enum.reject(lessons, &is_nil/1) 112 | end 113 | 114 | defp translation?(locale), do: "en" != locale 115 | 116 | defp populate_surrounding_lessons(%{locale: locale, name: _name, section: _section} = lesson) do 117 | {previous_section, previous_lesson} = previous_lesson(lesson) 118 | {next_section, next_lesson} = next_lesson(lesson) 119 | 120 | previous = locale_lessons(locale).get(previous_section, previous_lesson) 121 | next = locale_lessons(locale).get(next_section, next_lesson) 122 | 123 | %{lesson | previous: previous, next: next} 124 | end 125 | 126 | defp previous_lesson(%{section: section} = lesson) do 127 | {section_index, section_lessons} = get_section_from_lesson(lesson) 128 | 129 | if section_index == 0 do 130 | {previous_section, lessons} = previous_section(section) 131 | {previous_section, List.last(lessons)} 132 | else 133 | {previous, _} = List.pop_at(section_lessons, section_index - 1) 134 | {section, previous} 135 | end 136 | end 137 | 138 | defp next_lesson(%{section: section} = lesson) do 139 | {section_index, section_lessons} = get_section_from_lesson(lesson) 140 | 141 | if section_index == Enum.count(section_lessons) - 1 do 142 | {next_section, lessons} = next_section(section) 143 | {next_section, List.first(lessons)} 144 | else 145 | {next, _} = List.pop_at(section_lessons, section_index + 1) 146 | {section, next} 147 | end 148 | end 149 | 150 | defp previous_section(section) do 151 | case Enum.find_index(@filtered_lessons, fn {section_name, _} -> 152 | section_name == section 153 | end) do 154 | 0 -> {nil, []} 155 | n -> Enum.at(@filtered_lessons, n - 1) 156 | end 157 | end 158 | 159 | defp next_section(section) do 160 | last_section_index = Enum.count(@filtered_lessons) - 1 161 | 162 | case Enum.find_index(@filtered_lessons, fn {section_name, _} -> 163 | section_name == section 164 | end) do 165 | ^last_section_index -> {nil, []} 166 | n -> Enum.at(@filtered_lessons, n + 1) 167 | end 168 | end 169 | 170 | defp get_section_from_lesson(%{name: name, section: section}) do 171 | section_lessons = @filtered_lessons[section] 172 | index = Enum.find_index(section_lessons, &(&1 == name)) 173 | 174 | {index, section_lessons} 175 | end 176 | end 177 | -------------------------------------------------------------------------------- /lib/school_house/locale_info.ex: -------------------------------------------------------------------------------- 1 | defmodule SchoolHouse.LocaleInfo do 2 | @moduledoc """ 3 | Represents metadata for a supported locale. 4 | """ 5 | @enforce_keys [:code, :title, :original_name, :flag_icon] 6 | defstruct [:code, :title, :original_name, :flag_icon] 7 | 8 | @doc """ 9 | Returns the default locale. 10 | """ 11 | def default_locale do 12 | "en" 13 | end 14 | 15 | @doc """ 16 | Returns a map of all supported LocaleInfo structs. 17 | """ 18 | def map do 19 | %{ 20 | "ar" => %__MODULE__{ 21 | code: "ar", 22 | title: "Arabic", 23 | original_name: "العربية", 24 | flag_icon: "sa" 25 | }, 26 | "bg" => %__MODULE__{ 27 | code: "bg", 28 | title: "Bulgarian", 29 | original_name: "Български", 30 | flag_icon: "bg" 31 | }, 32 | "bn" => %__MODULE__{ 33 | code: "bn", 34 | title: "Bengali", 35 | original_name: "বাংলা", 36 | flag_icon: "bd" 37 | }, 38 | "de" => %__MODULE__{ 39 | code: "de", 40 | title: "German", 41 | original_name: "Deutsch", 42 | flag_icon: "de" 43 | }, 44 | "el" => %__MODULE__{ 45 | code: "el", 46 | title: "Greek", 47 | original_name: "Ελληνικά", 48 | flag_icon: "gr" 49 | }, 50 | "en" => %__MODULE__{ 51 | code: "en", 52 | title: "English", 53 | original_name: "English", 54 | flag_icon: "gb" 55 | }, 56 | "es" => %__MODULE__{ 57 | code: "es", 58 | title: "Spanish", 59 | original_name: "Español", 60 | flag_icon: "es" 61 | }, 62 | "fa" => %__MODULE__{ 63 | code: "fa", 64 | title: "Persian", 65 | original_name: "فارسی", 66 | flag_icon: "ir" 67 | }, 68 | "fr" => %__MODULE__{ 69 | code: "fr", 70 | title: "French", 71 | original_name: "Français", 72 | flag_icon: "fr" 73 | }, 74 | "id" => %__MODULE__{ 75 | code: "id", 76 | title: "Indonesian", 77 | original_name: "Bahasa Indonesia", 78 | flag_icon: "id" 79 | }, 80 | "it" => %__MODULE__{ 81 | code: "it", 82 | title: "Italian", 83 | original_name: "Italiano", 84 | flag_icon: "it" 85 | }, 86 | "ja" => %__MODULE__{ 87 | code: "ja", 88 | title: "Japanese", 89 | original_name: "日本語", 90 | flag_icon: "jp" 91 | }, 92 | "ko" => %__MODULE__{ 93 | code: "ko", 94 | title: "Korean", 95 | original_name: "한국어", 96 | flag_icon: "kr" 97 | }, 98 | "ms" => %__MODULE__{ 99 | code: "ms", 100 | title: "Malay", 101 | original_name: "Bahasa Melayu", 102 | flag_icon: "my" 103 | }, 104 | "no" => %__MODULE__{ 105 | code: "no", 106 | title: "Norwegian", 107 | original_name: "Norsk", 108 | flag_icon: "no" 109 | }, 110 | "pl" => %__MODULE__{ 111 | code: "pl", 112 | title: "Polish", 113 | original_name: "Polski", 114 | flag_icon: "pl" 115 | }, 116 | "pt" => %__MODULE__{ 117 | code: "pt", 118 | title: "Portuguese", 119 | original_name: "Português", 120 | flag_icon: "pt" 121 | }, 122 | "ru" => %__MODULE__{ 123 | code: "ru", 124 | title: "Russian", 125 | original_name: "Русский", 126 | flag_icon: "ru" 127 | }, 128 | "sk" => %__MODULE__{ 129 | code: "sk", 130 | title: "Slovak", 131 | original_name: "Slovenčina", 132 | flag_icon: "sk" 133 | }, 134 | "ta" => %__MODULE__{ 135 | code: "ta", 136 | title: "Tamil", 137 | original_name: "தமிழ்", 138 | flag_icon: "in" 139 | }, 140 | "th" => %__MODULE__{ 141 | code: "th", 142 | title: "Thai", 143 | original_name: "ไทย", 144 | flag_icon: "th" 145 | }, 146 | "tr" => %__MODULE__{ 147 | code: "tr", 148 | title: "Turkish", 149 | original_name: "Türkçe", 150 | flag_icon: "tr" 151 | }, 152 | "uk" => %__MODULE__{ 153 | code: "uk", 154 | title: "Ukrainian", 155 | original_name: "Українською", 156 | flag_icon: "ua" 157 | }, 158 | "vi" => %__MODULE__{ 159 | code: "vi", 160 | title: "Vietnamese", 161 | original_name: "Tiếng Việt", 162 | flag_icon: "vn" 163 | }, 164 | "zh-hans" => %__MODULE__{ 165 | code: "zh-hans", 166 | title: "Chinese (Simplified)", 167 | original_name: "简体中文", 168 | flag_icon: "cn" 169 | }, 170 | "zh-hant" => %__MODULE__{ 171 | code: "zh-hant", 172 | title: "Chinese (Traditional)", 173 | original_name: "繁體中文", 174 | flag_icon: "tw" 175 | } 176 | } 177 | end 178 | 179 | @doc """ 180 | Returns a list of all supported locale codes. 181 | """ 182 | def list do 183 | locales = map() 184 | Map.keys(locales) 185 | end 186 | end 187 | -------------------------------------------------------------------------------- /lib/school_house/podcasts.ex: -------------------------------------------------------------------------------- 1 | defmodule SchoolHouse.Podcasts do 2 | @moduledoc false 3 | 4 | use NimblePublisher, 5 | build: SchoolHouse.Content.Podcast, 6 | from: Application.compile_env!(:school_house, :podcast_dir), 7 | as: :podcasts, 8 | highlighters: [:makeup_elixir, :makeup_erlang] 9 | 10 | def list, do: @podcasts 11 | end 12 | -------------------------------------------------------------------------------- /lib/school_house/posts.ex: -------------------------------------------------------------------------------- 1 | defmodule SchoolHouse.Posts do 2 | @moduledoc """ 3 | Stores our posts in a module attribute and provides mechanisms to retrieve them 4 | """ 5 | alias SchoolHouse.Content.Post 6 | 7 | use NimblePublisher, 8 | build: Post, 9 | from: Application.compile_env!(:school_house, :blog_dir), 10 | as: :posts, 11 | highlighters: [:makeup_elixir, :makeup_erlang] 12 | 13 | @paged_posts @posts |> Enum.sort(&(Date.compare(&1.date, &2.date) == :gt)) |> Enum.chunk_every(20) 14 | @posts_by_slug Enum.into(@posts, %{}, fn %{slug: slug} = post -> 15 | {slug, post} 16 | end) 17 | 18 | def get(slug) do 19 | case Map.get(@posts_by_slug, slug) do 20 | nil -> {:error, :not_found} 21 | %Post{} = post -> {:ok, post} 22 | end 23 | end 24 | 25 | def get_by_tag(tag) do 26 | tag = String.downcase(tag) 27 | 28 | case Enum.filter(@posts, &filter_by_tag(&1, tag)) do 29 | [] -> {:error, :not_found} 30 | posts -> {:ok, posts} 31 | end 32 | end 33 | 34 | defp filter_by_tag(post, tag) do 35 | Map.get(post, :tags, []) 36 | |> Enum.map(&String.downcase/1) 37 | |> Enum.member?(tag) 38 | end 39 | 40 | def page(n), do: Enum.at(@paged_posts, n) 41 | 42 | def pages, do: Enum.count(@paged_posts) 43 | 44 | def tag_cloud do 45 | Enum.reduce(@posts, %{}, fn %{tags: tags}, acc -> 46 | Enum.reduce(tags, acc, fn tag, accc -> 47 | count = Map.get(accc, tag, 0) + 1 48 | Map.put(accc, tag, count) 49 | end) 50 | end) 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /lib/school_house_web.ex: -------------------------------------------------------------------------------- 1 | defmodule SchoolHouseWeb do 2 | @moduledoc """ 3 | The entrypoint for defining your web interface, such 4 | as controllers, views, channels and so on. 5 | 6 | This can be used in your application as: 7 | 8 | use SchoolHouseWeb, :controller 9 | use SchoolHouseWeb, :view 10 | 11 | The definitions below will be executed for every view, 12 | controller, etc, so keep them short and clean, focused 13 | on imports, uses and aliases. 14 | 15 | Do NOT define functions inside the quoted expressions 16 | below. Instead, define any helper function in modules 17 | and import those modules here. 18 | """ 19 | 20 | def controller do 21 | quote do 22 | use Phoenix.Controller, namespace: SchoolHouseWeb 23 | 24 | import Plug.Conn 25 | import SchoolHouseWeb.Gettext 26 | alias SchoolHouseWeb.Router.Helpers, as: Routes 27 | end 28 | end 29 | 30 | def view do 31 | quote do 32 | use Phoenix.View, 33 | root: "lib/school_house_web/templates", 34 | namespace: SchoolHouseWeb 35 | 36 | use Appsignal.Phoenix.View 37 | 38 | # Import convenience functions from controllers 39 | import Phoenix.Controller, 40 | only: [get_csrf_token: 0, get_flash: 1, get_flash: 2, view_module: 1, view_template: 1] 41 | 42 | # Include shared imports and aliases for views 43 | unquote(view_helpers()) 44 | end 45 | end 46 | 47 | def live_view do 48 | quote do 49 | use Phoenix.LiveView, 50 | layout: {SchoolHouseWeb.LayoutView, :live} 51 | 52 | unquote(view_helpers()) 53 | end 54 | end 55 | 56 | def live_component do 57 | quote do 58 | use Phoenix.LiveComponent 59 | 60 | unquote(view_helpers()) 61 | end 62 | end 63 | 64 | def router do 65 | quote do 66 | use Phoenix.Router 67 | 68 | import Plug.Conn 69 | import Phoenix.Controller 70 | import Phoenix.LiveView.Router 71 | end 72 | end 73 | 74 | def channel do 75 | quote do 76 | use Phoenix.Channel 77 | import SchoolHouseWeb.Gettext 78 | end 79 | end 80 | 81 | defp view_helpers do 82 | quote do 83 | # Use all HTML functionality (forms, tags, etc) 84 | use Phoenix.HTML 85 | 86 | # Import LiveView helpers (live_render, live_component, live_patch, etc) 87 | import Phoenix.LiveView.Helpers 88 | 89 | # Import basic rendering functionality (render, render_layout, etc) 90 | import Phoenix.View 91 | 92 | import SchoolHouseWeb.ErrorHelpers 93 | import SchoolHouseWeb.Gettext 94 | import SchoolHouseWeb.HtmlHelpers 95 | alias SchoolHouseWeb.Router.Helpers, as: Routes 96 | end 97 | end 98 | 99 | @doc """ 100 | When used, dispatch to the appropriate controller/view/etc. 101 | """ 102 | defmacro __using__(which) when is_atom(which) do 103 | apply(__MODULE__, which, []) 104 | end 105 | end 106 | -------------------------------------------------------------------------------- /lib/school_house_web/channels/user_socket.ex: -------------------------------------------------------------------------------- 1 | defmodule SchoolHouseWeb.UserSocket do 2 | use Phoenix.Socket 3 | 4 | ## Channels 5 | # channel "room:*", SchoolHouseWeb.RoomChannel 6 | 7 | # Socket params are passed from the client and can 8 | # be used to verify and authenticate a user. After 9 | # verification, you can put default assigns into 10 | # the socket that will be set for all channels, ie 11 | # 12 | # {:ok, assign(socket, :user_id, verified_user_id)} 13 | # 14 | # To deny connection, return `:error`. 15 | # 16 | # See `Phoenix.Token` documentation for examples in 17 | # performing token verification on connect. 18 | @impl true 19 | def connect(_params, socket, _connect_info) do 20 | {:ok, socket} 21 | end 22 | 23 | # Socket id's are topics that allow you to identify all sockets for a given user: 24 | # 25 | # def id(socket), do: "user_socket:#{socket.assigns.user_id}" 26 | # 27 | # Would allow you to broadcast a "disconnect" event and terminate 28 | # all active sockets and channels for a given user: 29 | # 30 | # SchoolHouseWeb.Endpoint.broadcast("user_socket:#{user.id}", "disconnect", %{}) 31 | # 32 | # Returning `nil` makes this socket anonymous. 33 | @impl true 34 | def id(_socket), do: nil 35 | end 36 | -------------------------------------------------------------------------------- /lib/school_house_web/controllers/fallback_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule SchoolHouseWeb.FallbackController do 2 | use SchoolHouseWeb, :controller 3 | 4 | alias SchoolHouseWeb.ErrorView 5 | 6 | def call(conn, {:error, :not_found}) do 7 | conn 8 | |> put_status(404) 9 | |> put_view(ErrorView) 10 | |> render("404.html") 11 | end 12 | 13 | def call(conn, {:error, :translation_not_found, missing_locale}) do 14 | conn 15 | |> put_status(404) 16 | |> put_view(ErrorView) 17 | |> assign(:missing_locale, missing_locale) 18 | |> render("translation_missing.html") 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /lib/school_house_web/controllers/lesson_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule SchoolHouseWeb.LessonController do 2 | use SchoolHouseWeb, :controller 3 | 4 | alias SchoolHouse.Lessons 5 | alias SchoolHouseWeb.FallbackController 6 | 7 | action_fallback FallbackController 8 | 9 | def index(conn, %{"section" => section}) do 10 | lessons = Lessons.list(section, Gettext.get_locale(SchoolHouseWeb.Gettext)) 11 | page_title = String.capitalize(section) 12 | render(conn, "index.html", page_title: page_title, lessons: lessons, section: section) 13 | end 14 | 15 | def lesson(conn, %{"name" => name, "section" => section}) do 16 | with {:ok, lesson} <- Lessons.get(section, name, Gettext.get_locale(SchoolHouseWeb.Gettext)) do 17 | render(conn, "lesson.html", page_title: lesson.title, lesson: lesson) 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /lib/school_house_web/controllers/page_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule SchoolHouseWeb.PageController do 2 | use SchoolHouseWeb, :controller 3 | 4 | alias SchoolHouse.{Lessons, Podcasts, Posts} 5 | 6 | def index(conn, _params) do 7 | render(conn, "index.html", page_title: gettext("Home"), posts: recent_posts()) 8 | end 9 | 10 | def podcasts(conn, _params) do 11 | render(conn, "podcasts.html", page_title: gettext("Podcasts"), podcasts: Podcasts.list()) 12 | end 13 | 14 | def privacy(conn, _params) do 15 | render(conn, "privacy.html", page_title: gettext("Privacy Policy")) 16 | end 17 | 18 | def report(conn, %{"locale" => locale}) do 19 | render(conn, "report.html", page_title: gettext("Translation Report"), report: Lessons.translation_report(locale)) 20 | end 21 | 22 | def why(conn, _params) do 23 | render(conn, "why.html", page_title: gettext("Why Choose Elixir?")) 24 | end 25 | 26 | def get_involved(conn, _params) do 27 | render(conn, "get_involved.html", page_title: gettext("Get Involved")) 28 | end 29 | 30 | defp recent_posts do 31 | 0 32 | |> Posts.page() 33 | |> Enum.take(6) 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /lib/school_house_web/controllers/post_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule SchoolHouseWeb.PostController do 2 | use SchoolHouseWeb, :controller 3 | 4 | alias SchoolHouse.Content.Post 5 | alias SchoolHouse.Posts 6 | alias SchoolHouseWeb.FallbackController 7 | 8 | action_fallback FallbackController 9 | 10 | @page_title "Blog" 11 | 12 | def index(conn, params) do 13 | posts = 14 | params 15 | |> current_page() 16 | |> Posts.page() 17 | 18 | render(conn, "index.html", page_title: @page_title, posts: posts, pages: Posts.pages()) 19 | end 20 | 21 | def show(conn, %{"slug" => slug}) do 22 | with {:ok, post} <- Posts.get(slug) do 23 | render(conn, "post.html", page_title: format_page_title(post), post: post) 24 | end 25 | end 26 | 27 | def filter_by_tag(conn, %{"tag" => tag}) do 28 | with {:ok, posts_by_tag} <- Posts.get_by_tag(tag) do 29 | render(conn, "index.html", 30 | blog_heading: "Posts Tagged \"#{tag}\"", 31 | page_title: @page_title, 32 | posts: posts_by_tag, 33 | pages: Posts.pages() 34 | ) 35 | end 36 | end 37 | 38 | defp current_page(params) do 39 | value = Map.get(params, "page", "0") 40 | 41 | case Integer.parse(value) do 42 | :error -> 0 43 | {page, _remainder} -> page 44 | end 45 | end 46 | 47 | defp format_page_title(%Post{title_text: title}), do: title <> " | " <> @page_title 48 | defp format_page_title(_), do: @page_title 49 | end 50 | -------------------------------------------------------------------------------- /lib/school_house_web/controllers/report_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule SchoolHouseWeb.ReportController do 2 | use SchoolHouseWeb, :controller 3 | 4 | alias SchoolHouse.Lessons 5 | 6 | def index(conn, %{"locale" => locale}) do 7 | render(conn, "report.html", report: Lessons.translation_report(locale)) 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/school_house_web/endpoint.ex: -------------------------------------------------------------------------------- 1 | defmodule SchoolHouseWeb.Endpoint do 2 | use Phoenix.Endpoint, otp_app: :school_house 3 | 4 | # The session will be stored in the cookie and signed, 5 | # this means its contents can be read but not tampered with. 6 | # Set :encryption_salt if you would also like to encrypt it. 7 | @session_options [ 8 | store: :cookie, 9 | key: "_elixirschool_key", 10 | signing_salt: "6rdoa77b" 11 | ] 12 | 13 | socket "/socket", SchoolHouseWeb.UserSocket, 14 | websocket: true, 15 | longpoll: false 16 | 17 | socket "/live", Phoenix.LiveView.Socket, websocket: [connect_info: [session: @session_options]] 18 | 19 | # Serve at "/" the static files from "priv/static" directory. 20 | # 21 | # You should set gzip to true if you are running phx.digest 22 | # when deploying your static files in production. 23 | plug Plug.Static, 24 | at: "/", 25 | from: :school_house, 26 | gzip: true, 27 | only: ~w(assets fonts images favicon.ico robots.txt feed.xml sitemap.xml sitemap_dark_mode.xml) 28 | 29 | # Code reloading can be explicitly enabled under the 30 | # :code_reloader configuration of your endpoint. 31 | if code_reloading? do 32 | socket "/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket 33 | plug Phoenix.LiveReloader 34 | plug Phoenix.CodeReloader 35 | end 36 | 37 | plug Plug.RequestId 38 | plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint] 39 | 40 | plug Plug.Parsers, 41 | parsers: [:urlencoded, :multipart, :json], 42 | pass: ["*/*"], 43 | json_decoder: Phoenix.json_library() 44 | 45 | plug Plug.MethodOverride 46 | plug Plug.Head 47 | plug Plug.Session, @session_options 48 | plug SchoolHouseWeb.Router 49 | end 50 | -------------------------------------------------------------------------------- /lib/school_house_web/gettext.ex: -------------------------------------------------------------------------------- 1 | defmodule SchoolHouseWeb.Gettext do 2 | @moduledoc """ 3 | A module providing Internationalization with a gettext-based API. 4 | 5 | By using [Gettext](https://hexdocs.pm/gettext), 6 | your module gains a set of macros for translations, for example: 7 | 8 | import SchoolHouseWeb.Gettext 9 | 10 | # Simple translation 11 | gettext("Here is the string to translate") 12 | 13 | # Plural translation 14 | ngettext("Here is the string to translate", 15 | "Here are the strings to translate", 16 | 3) 17 | 18 | # Domain-based translation 19 | dgettext("errors", "Here is the error message to translate") 20 | 21 | See the [Gettext Docs](https://hexdocs.pm/gettext) for detailed usage. 22 | """ 23 | use Gettext, otp_app: :school_house 24 | end 25 | -------------------------------------------------------------------------------- /lib/school_house_web/live/conferences_live.ex: -------------------------------------------------------------------------------- 1 | defmodule SchoolHouseWeb.ConferencesLive do 2 | @moduledoc false 3 | 4 | use SchoolHouseWeb, :live_view 5 | 6 | alias SchoolHouse.Conferences 7 | 8 | @impl true 9 | def mount(_params, _session, socket) do 10 | conferences = Conferences.list() 11 | filters = default_filters() 12 | country_options = country_options() 13 | 14 | new_socket = 15 | socket 16 | |> assign(:conferences, conferences) 17 | |> assign(:filters, filters) 18 | |> assign(:country_options, country_options) 19 | 20 | {:ok, new_socket} 21 | end 22 | 23 | @impl true 24 | def handle_event("filter", params, socket) do 25 | filters = update_filters(params, socket.assigns.filters) 26 | conferences = filtered_conferences(filters) 27 | 28 | new_socket = 29 | socket 30 | |> assign(:conferences, conferences) 31 | |> assign(:filters, filters) 32 | 33 | {:noreply, new_socket} 34 | end 35 | 36 | defp update_filters(%{"filters" => new_filter}, filters) do 37 | Map.merge(filters, new_filter) 38 | end 39 | 40 | defp default_filters do 41 | %{"online" => "false", "country" => ""} 42 | end 43 | 44 | defp country_options do 45 | Conferences.countries() 46 | |> Enum.into(%{}, fn x -> {x, x} end) 47 | |> Enum.concat(-: "") 48 | |> Enum.reverse() 49 | end 50 | 51 | defp filtered_conferences(%{"online" => "false", "country" => ""}) do 52 | Conferences.list() 53 | end 54 | 55 | defp filtered_conferences(filters) do 56 | filters 57 | |> Map.to_list() 58 | |> Enum.flat_map(&conference_filters/1) 59 | |> Enum.uniq() 60 | end 61 | 62 | defp conference_filters({"country", ""}) do 63 | [] 64 | end 65 | 66 | defp conference_filters({"country", country}) do 67 | Conferences.by_country(country) 68 | end 69 | 70 | defp conference_filters({"online", "true"}) do 71 | Conferences.online() 72 | end 73 | 74 | defp conference_filters(_) do 75 | [] 76 | end 77 | 78 | defp filter_is_online?(%{"online" => "true"}), do: true 79 | defp filter_is_online?(%{"online" => _}), do: false 80 | 81 | defp country_filter(%{"country" => ""}), do: nil 82 | defp country_filter(%{"country" => country}), do: country 83 | end 84 | -------------------------------------------------------------------------------- /lib/school_house_web/live/conferences_live.html.heex: -------------------------------------------------------------------------------- 1 |
    2 |
    3 |
    4 |

    5 | <%= gettext("Elixir Conferences")%> 6 |

    7 |

    8 | <%= gettext("Check out all of the conferences that focus on Elixir!")%> 9 |

    10 |
    11 |
    12 |
    13 | 14 |
    15 |
    16 | <.form :let={f} for={:filters} id="filter-form" class="flex-row flex items-center mb-6 px-2 text-primary dark:text-primary-dark" phx-change="filter"> 17 |

    <%= gettext("Filter")%>

    18 |
    19 | <%= checkbox f, :online, id: "online-only", checked: filter_is_online?(@filters) %> 20 | 21 |
    22 |
    23 | 24 | <%= select f, :country, @country_options, selected: country_filter(@filters), class: "w-40 pl-3 pr-6 appearance-none bg-nav dark:bg-nav-dark", id: "country-select" %> 25 |
    26 | 27 |
    28 | 29 | 30 | 31 | 34 | 37 | 40 | 41 | 42 | 43 | <%= for conference <- @conferences do %> 44 | 45 | 48 | 51 | 54 | 55 | <% end %> 56 | 57 |
    32 | <%= gettext("Name")%> 33 | 35 | <%= gettext("Location")%> 36 | 38 | <%= gettext("Date")%> 39 |
    46 | <%= conference.name %> 47 | 49 |

    <%= conference.location %>

    50 |
    52 |

    <%= conference.date %>

    53 |
    58 |
    59 |
    60 |
    61 | -------------------------------------------------------------------------------- /lib/school_house_web/plugs/redirect_plug.ex: -------------------------------------------------------------------------------- 1 | defmodule SchoolHouseWeb.RedirectPlug do 2 | @moduledoc """ 3 | Handle redirecting users based on a collection of patterns 4 | """ 5 | import Plug.Conn 6 | 7 | require Logger 8 | 9 | @redirects Application.compile_env!(:school_house, :redirects) 10 | 11 | @spec init(any) :: any 12 | def init(opts), do: opts 13 | 14 | @spec call(Plug.Conn.t(), any) :: Plug.Conn.t() 15 | def call(conn, _opts) do 16 | redirect_to = redirect_to_path(conn) 17 | 18 | if is_nil(redirect_to) do 19 | conn 20 | else 21 | Logger.debug("Redirect from #{conn.request_path} to #{redirect_to}") 22 | 23 | conn 24 | |> Phoenix.Controller.redirect(to: redirect_to) 25 | |> halt() 26 | end 27 | end 28 | 29 | defp redirect_to_path(conn) do 30 | with {pattern, replacement} <- Enum.find(@redirects, &path_matcher(&1, conn.request_path)) do 31 | Regex.replace(pattern, conn.request_path, replacement) 32 | end 33 | end 34 | 35 | defp path_matcher({pattern, _replacement}, request_path), do: Regex.match?(pattern, request_path) 36 | end 37 | -------------------------------------------------------------------------------- /lib/school_house_web/plugs/set_locale_plug.ex: -------------------------------------------------------------------------------- 1 | defmodule SchoolHouseWeb.SetLocalePlug do 2 | @moduledoc """ 3 | Set the process' locale given the path params value 4 | """ 5 | 6 | import Plug.Conn, only: [put_status: 2, halt: 1] 7 | import Phoenix.Controller, only: [put_view: 2, render: 2] 8 | alias SchoolHouse.LocaleInfo 9 | 10 | @spec init(any) :: any 11 | def init(opts), do: opts 12 | 13 | @spec call(Plug.Conn.t(), any) :: Plug.Conn.t() 14 | def call(conn, _opts) do 15 | locale = Map.get(conn.path_params, "locale", default_locale()) 16 | 17 | case valid_locale?(locale) do 18 | true -> 19 | Gettext.put_locale(SchoolHouseWeb.Gettext, locale) 20 | conn 21 | 22 | false -> 23 | conn 24 | |> put_status(404) 25 | |> put_view(SchoolHouseWeb.ErrorView) 26 | |> render("404.html") 27 | |> halt() 28 | end 29 | end 30 | 31 | defp valid_locale?(locale) do 32 | Enum.member?(valid_locales(), locale) 33 | end 34 | 35 | defp default_locale do 36 | LocaleInfo.default_locale() 37 | end 38 | 39 | defp valid_locales do 40 | LocaleInfo.list() 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /lib/school_house_web/router.ex: -------------------------------------------------------------------------------- 1 | defmodule SchoolHouseWeb.Router do 2 | use SchoolHouseWeb, :router 3 | 4 | pipeline :browser do 5 | plug :accepts, ["html"] 6 | plug :fetch_session 7 | plug :fetch_live_flash 8 | plug :put_root_layout, {SchoolHouseWeb.LayoutView, :root} 9 | plug :protect_from_forgery 10 | plug :put_secure_browser_headers 11 | plug SchoolHouseWeb.RedirectPlug 12 | plug SchoolHouseWeb.SetLocalePlug 13 | end 14 | 15 | pipeline :api do 16 | plug :accepts, ["json"] 17 | end 18 | 19 | scope "/", SchoolHouseWeb do 20 | pipe_through :browser 21 | 22 | get "/", PageController, :index 23 | 24 | get "/blog", PostController, :index 25 | get "/blog/:slug", PostController, :show 26 | get "/blog/tag/:tag", PostController, :filter_by_tag 27 | 28 | get "/privacy", PageController, :privacy 29 | 30 | scope "/:locale" do 31 | get "/", PageController, :index 32 | get "/why", PageController, :why 33 | get "/get_involved", PageController, :get_involved 34 | get "/podcasts", PageController, :podcasts 35 | live "/conferences", ConferencesLive 36 | 37 | get "/report", ReportController, :index 38 | 39 | get "/lessons/:section", LessonController, :index 40 | get "/lessons/:section/:name", LessonController, :lesson 41 | end 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /lib/school_house_web/telemetry.ex: -------------------------------------------------------------------------------- 1 | defmodule SchoolHouseWeb.Telemetry do 2 | @moduledoc false 3 | use Supervisor 4 | import Telemetry.Metrics 5 | 6 | def start_link(arg) do 7 | Supervisor.start_link(__MODULE__, arg, name: __MODULE__) 8 | end 9 | 10 | @impl true 11 | def init(_arg) do 12 | children = [ 13 | # Telemetry poller will execute the given period measurements 14 | # every 10_000ms. Learn more here: https://hexdocs.pm/telemetry_metrics 15 | {:telemetry_poller, measurements: periodic_measurements(), period: 10_000} 16 | # Add reporters as children of your supervision tree. 17 | # {Telemetry.Metrics.ConsoleReporter, metrics: metrics()} 18 | ] 19 | 20 | Supervisor.init(children, strategy: :one_for_one) 21 | end 22 | 23 | def metrics do 24 | [ 25 | # Phoenix Metrics 26 | summary("phoenix.endpoint.stop.duration", 27 | unit: {:native, :millisecond} 28 | ), 29 | summary("phoenix.router_dispatch.stop.duration", 30 | tags: [:route], 31 | unit: {:native, :millisecond} 32 | ), 33 | 34 | # Database Metrics 35 | summary("elixirschool.repo.query.total_time", unit: {:native, :millisecond}), 36 | summary("elixirschool.repo.query.decode_time", unit: {:native, :millisecond}), 37 | summary("elixirschool.repo.query.query_time", unit: {:native, :millisecond}), 38 | summary("elixirschool.repo.query.queue_time", unit: {:native, :millisecond}), 39 | summary("elixirschool.repo.query.idle_time", unit: {:native, :millisecond}), 40 | 41 | # VM Metrics 42 | summary("vm.memory.total", unit: {:byte, :kilobyte}), 43 | summary("vm.total_run_queue_lengths.total"), 44 | summary("vm.total_run_queue_lengths.cpu"), 45 | summary("vm.total_run_queue_lengths.io") 46 | ] 47 | end 48 | 49 | defp periodic_measurements do 50 | [ 51 | # A module, function and arguments to be invoked periodically. 52 | # This function must call :telemetry.execute/3 and a metric must be added above. 53 | # {SchoolHouseWeb, :count_users, []} 54 | ] 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /lib/school_house_web/templates/error/error.html.heex: -------------------------------------------------------------------------------- 1 |
    2 |
    3 |

    4 | <%= @title %> 5 |

    6 |

    <%= @message %>

    7 |
    8 |
    9 | -------------------------------------------------------------------------------- /lib/school_house_web/templates/error/translation_missing.html.heex: -------------------------------------------------------------------------------- 1 |
    2 |
    3 |

    4 | <%= gettext("Translation unavailable")%> 5 |

    6 |

    <%= gettext("We're sorry, the resource you're looking for has not been translated yet.")%>

    7 |

    <%= gettext("If you speak that language, you can help translate")%> <%= link gettext("this page"), to: "https://github.com/elixirschool/elixirschool/tree/master/lessons/#{@missing_locale}", class: "text-purple dark:text-purple-dark" %>.

    8 |
    9 |
    10 | -------------------------------------------------------------------------------- /lib/school_house_web/templates/layout/_dark_mode_toggle.html.heex: -------------------------------------------------------------------------------- 1 |
    2 |
    3 | 4 |
    5 |
    6 |
    7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 |
    26 |
    27 |
    28 | -------------------------------------------------------------------------------- /lib/school_house_web/templates/layout/_footer.html.heex: -------------------------------------------------------------------------------- 1 |
    2 |

    Footer

    3 |
    4 |
    5 |
    6 |
    7 | 8 | 9 | 10 | <%= gettext("Elixir School")%> 11 |
    12 |

    13 | <%= gettext("The premier destination for learning and mastering Elixir")%> 14 |

    15 | 32 |
    33 |
    34 |
    35 |
    36 |

    37 | <%= gettext("Getting Started")%> 38 |

    39 |
      40 |
    • 41 | <%= link(gettext("Why Elixir?"), to: Routes.page_path(@conn, :why, current_locale()), class: "text-base text-light dark:text-light-dark hover:text-gray-900") %> 42 |
    • 43 |
    • 44 | <%= link(gettext("Podcasts"), to: Routes.page_path(@conn, :podcasts, current_locale()), class: "text-base text-light dark:text-light-dark hover:text-gray-900") %> 45 |
    • 46 |
    • 47 | <%= link(gettext("Conferences"), to: Routes.live_path(@conn, SchoolHouseWeb.ConferencesLive, current_locale()), class: "text-base text-light dark:text-light-dark hover:text-gray-900") %> 48 |
    • 49 |
    50 |
    51 |
    52 |

    53 | <%= gettext("Lessons") %> 54 |

    55 |
      56 |
    • 57 | <%= link(gettext("Basics"), to: Routes.lesson_path(@conn, :index, current_locale(), :basics), class: "text-base text-light dark:text-light-dark hover:text-gray-900") %> 58 |
    • 59 | 60 |
    • 61 | <%= link(gettext("Intermediate"), to: Routes.lesson_path(@conn, :index, current_locale(), :intermediate), class: "text-base text-light dark:text-light-dark hover:text-gray-900") %> 62 |
    • 63 | 64 |
    • 65 | <%= link(gettext("Advanced"), to: Routes.lesson_path(@conn, :index, current_locale(), :advanced), class: "text-base text-light dark:text-light-dark hover:text-gray-900") %> 66 |
    • 67 | 68 |
    • 69 | <%= link(gettext("Testing"), to: Routes.lesson_path(@conn, :index, current_locale(), :testing), class: "text-base text-light dark:text-light-dark hover:text-gray-900") %> 70 |
    • 71 | 72 |
    • 73 | <%= link(gettext("Data Processing"), to: Routes.lesson_path(@conn, :index, current_locale(), :data_processing), class: "text-base text-light dark:text-light-dark hover:text-gray-900") %> 74 |
    • 75 | 76 |
    • 77 | <%= link(gettext("Ecto"), to: Routes.lesson_path(@conn, :index, current_locale(), :ecto), class: "text-base text-light dark:text-light-dark hover:text-gray-900") %> 78 |
    • 79 | 80 |
    • 81 | <%= link(gettext("Storage"), to: Routes.lesson_path(@conn, :index, current_locale(), :storage), class: "text-base text-light dark:text-light-dark hover:text-gray-900") %> 82 |
    • 83 | 84 |
    • 85 | <%= link(gettext("Miscellaneous"), to: Routes.lesson_path(@conn, :index, current_locale(), :misc), class: "text-base text-light dark:text-light-dark hover:text-gray-900") %> 86 |
    • 87 |
    88 |
    89 |
    90 |
    91 |
    92 |

    93 | <%= link(gettext("Blog"), to: Routes.post_path(@conn, :index), class: "hover:text-gray-600") %> 94 |

    95 |
      96 |
    • 97 | <%= link(gettext("Announcements"), to: Routes.post_path(@conn, :filter_by_tag, "announcement"), class: "text-base text-light dark:text-light-dark hover:text-gray-900") %> 98 |
    • 99 | 100 |
    • 101 | <%= link(gettext("Today I Learned (TIL)"), to: Routes.post_path(@conn, :filter_by_tag, "til"), class: "text-base text-light dark:text-light-dark hover:text-gray-900") %> 102 |
    • 103 | 104 |
    • 105 | <%= link(gettext("Reviews"), to: Routes.post_path(@conn, :filter_by_tag, "review"), class: "text-base text-light dark:text-light-dark hover:text-gray-900") %> 106 |
    • 107 | 108 |
    109 |
    110 |
    111 |

    112 | <%= gettext("Project")%> 113 |

    114 |
      115 |
    • 116 | <%= link(gettext("Get Involved"), to: Routes.page_path(@conn, :get_involved, current_locale()), class: "text-base text-light dark:text-light-dark hover:text-gray-900") %> 117 |
    • 118 | <%= if current_locale() != "en" do %> 119 |
    • 120 | <%= link gettext("Translation Report"), to: Routes.report_path(@conn, :index, current_locale()), class: "text-base text-light dark:text-light-dark hover:text-gray-900" %> 121 |
    • 122 | <% end %> 123 |
    124 |
    125 |
    126 |
    127 |
    128 |
    129 |

    130 | © 2021 Sean Callan — 131 | @doomspork 132 |

    133 |
    134 |
    135 |
    136 | -------------------------------------------------------------------------------- /lib/school_house_web/templates/layout/_header.html.heex: -------------------------------------------------------------------------------- 1 |
    2 | 36 | 39 |
    40 | -------------------------------------------------------------------------------- /lib/school_house_web/templates/layout/_locale_menu.html.heex: -------------------------------------------------------------------------------- 1 |
    2 |
    3 | 10 |
    11 | 21 |
    22 | -------------------------------------------------------------------------------- /lib/school_house_web/templates/layout/app.html.heex: -------------------------------------------------------------------------------- 1 |
    2 | 3 | 4 | <%= @inner_content %> 5 |
    6 | -------------------------------------------------------------------------------- /lib/school_house_web/templates/layout/live.html.heex: -------------------------------------------------------------------------------- 1 |
    2 | 5 | 6 | 9 | 10 | <%= @inner_content %> 11 |
    12 | -------------------------------------------------------------------------------- /lib/school_house_web/templates/layout/root.html.heex: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | <%= live_title_tag assigns[:page_title] || "Elixir School", suffix: " · Elixir School" %> 11 | 12 | <%= load_locale_styles(view_module(@conn), assigns) %> 13 | 14 | 15 | 16 | 17 | 18 |
    19 |
    20 |
    21 |
    Do you want to pick up from where you left of?
    22 |
    23 | 24 | Take me there 25 | 26 | 29 |
    30 |
    31 | 41 | <%= render "_header.html", conn: @conn %> 42 | <%= @inner_content %> 43 | <%= render "_footer.html", conn: @conn %> 44 | 45 | 46 | -------------------------------------------------------------------------------- /lib/school_house_web/templates/lesson/_advanced.html.heex: -------------------------------------------------------------------------------- 1 |
    2 |
    3 |
    4 |

    5 | <%= gettext("Lessons") %>: <%= gettext("Advanced") %> 6 |

    7 |

    8 | <%= gettext("Taking our knowledge to the next level, these lessons get cover the advanced topics of Elixir and the BEAM.") %> 9 |

    10 |
    11 |
    12 |
    13 | -------------------------------------------------------------------------------- /lib/school_house_web/templates/lesson/_basics.html.heex: -------------------------------------------------------------------------------- 1 |
    2 |
    3 |
    4 |

    5 | <%= gettext("Lessons") %>: <%= gettext("Basics") %> 6 |

    7 |

    8 | <%= gettext("Lessons covering the foundational topics. New to Elixir? This is the place to start.") %> 9 |

    10 |
    11 |
    12 |
    13 | -------------------------------------------------------------------------------- /lib/school_house_web/templates/lesson/_data_processing.html.heex: -------------------------------------------------------------------------------- 1 |
    2 |
    3 |
    4 |

    5 | <%= gettext("Lessons") %>: <%= gettext("Data Processing") %> 6 |

    7 |

    8 | <%= gettext("Processing large amount of data concurrently comes easy to Elixir. We'll explore how to leverage libraries to make this task even easier.") %> 9 |

    10 |
    11 |
    12 |
    13 | -------------------------------------------------------------------------------- /lib/school_house_web/templates/lesson/_ecto.html.heex: -------------------------------------------------------------------------------- 1 |
    2 |
    3 |
    4 |

    5 | <%= gettext("Lessons") %>: <%= gettext("Ecto") %> 6 |

    7 |

    8 | <%= gettext("Interacting with data is a part of most applications. These lessons explore the Ecto library and how to leverage it for our database interactions.") %> 9 |

    10 |
    11 |
    12 |
    13 | -------------------------------------------------------------------------------- /lib/school_house_web/templates/lesson/_intermediate.html.heex: -------------------------------------------------------------------------------- 1 |
    2 |
    3 |
    4 |

    5 | <%= gettext("Lessons") %>: <%= gettext("Intermediate") %> 6 |

    7 |

    8 | <%= gettext("Building on upon our foundation these lessons introduce topics like concurrency, error handling, and interoperability.") %> 9 |

    10 |
    11 |
    12 |
    13 | -------------------------------------------------------------------------------- /lib/school_house_web/templates/lesson/_misc.html.heex: -------------------------------------------------------------------------------- 1 |
    2 |
    3 |
    4 |

    5 | <%= gettext("Lessons") %>: <%= gettext("Miscellaneous") %> 6 |

    7 |

    8 | . 9 |

    10 |
    11 |
    12 |
    13 | -------------------------------------------------------------------------------- /lib/school_house_web/templates/lesson/_pagination.html.heex: -------------------------------------------------------------------------------- 1 | 51 | -------------------------------------------------------------------------------- /lib/school_house_web/templates/lesson/_section_header.html.heex: -------------------------------------------------------------------------------- 1 | <%= content_tag String.to_atom(@header), class: "flex", id: @fragment do %> 2 | <%= @name %> 3 | 4 | 5 | 6 | 7 | 8 | <% end %> -------------------------------------------------------------------------------- /lib/school_house_web/templates/lesson/_storage.html.heex: -------------------------------------------------------------------------------- 1 |
    2 |
    3 |
    4 |

    5 | <%= gettext("Lessons") %>: <%= gettext("Storage") %> 6 |

    7 |

    8 | <%= gettext("Explore how interact with the BEAM's built in data storage, Redis, and others.") %> 9 |

    10 |
    11 |
    12 |
    13 | -------------------------------------------------------------------------------- /lib/school_house_web/templates/lesson/_testing.html.heex: -------------------------------------------------------------------------------- 1 |
    2 |
    3 |
    4 |

    5 | <%= gettext("Lessons") %>: <%= gettext("Testing") %> 6 |

    7 |

    8 | <%= gettext("The first step to writing fault tolerant and scalable code is writing bug free code. In these lessons we explore how best to test our Elixir code.") %> 9 |

    10 |
    11 |
    12 |
    13 | -------------------------------------------------------------------------------- /lib/school_house_web/templates/lesson/index.html.heex: -------------------------------------------------------------------------------- 1 |
    2 | <%= render("_#{@section}.html") %> 3 | 4 | <%= for lesson <- @lessons do %> 5 |
    6 |
    7 | <%= lesson_link(@conn, lesson.section, lesson.name, "text-lg font-medium leading-6 text-purple dark:text-purple-dark") do %> 8 | <%= lesson.title %> 9 | <% end %> 10 | <%= raw lesson.excerpt %> 11 |
    12 |
    13 | <% end %> 14 |
    15 | -------------------------------------------------------------------------------- /lib/school_house_web/templates/lesson/lesson.html.heex: -------------------------------------------------------------------------------- 1 |
    2 |
    3 |

    <%= @lesson.title %>

    4 |
    5 | 6 | <%= if @lesson.excerpt do %> 7 |
    <%= raw @lesson.excerpt %>
    8 | <% end %> 9 | 10 |
    11 | <%= gettext("Table of Contents")%> 12 |
    13 | <%= raw @lesson.table_of_contents %> 14 |
    15 |
    16 | <%= raw @lesson.body %> 17 | 18 |
    19 | <%= gettext("Caught a mistake or want to contribute to the lesson?")%> 20 | 21 | <%= gettext("Edit this lesson on GitHub!")%> 22 | 23 |
    24 |
    25 | 26 | <%= render("_pagination.html", conn: @conn, next: @lesson.next, previous: @lesson.previous) %> 27 | 28 |
    29 | 32 |
    33 | -------------------------------------------------------------------------------- /lib/school_house_web/templates/page/_newsletter.html.heex: -------------------------------------------------------------------------------- 1 |
    2 |
    3 |
    4 |
    5 |

    6 | <%= gettext("Sign up for our newsletter") %> 7 |

    8 |

    9 | <%= gettext("Join the Elixir School newsletter to keep up with the latest lessons, blog posts, and opportunities within the community.") %> 10 |

    11 |
    12 |
    13 |
    14 | 15 | 16 | 19 |
    20 |

    21 | <%= gettext("We care about the protection of your data. Read our") %> 22 | 23 | <%= gettext("Privacy Policy") %>. 24 | 25 |

    26 |
    27 |
    28 |
    29 |
    30 | -------------------------------------------------------------------------------- /lib/school_house_web/templates/page/get_involved.html.heex: -------------------------------------------------------------------------------- 1 |
    2 |
    3 |
    4 |

    5 | <%= gettext("Get Involved") %> 6 |

    7 |

    8 | <%= gettext("Elixir School is built by contributors, skilled and new alike! Help us make it better and extend the reach of Elixir to anyone that wants to learn. Below are a few ways to contribute.") %> 9 |

    10 |
    11 |
    12 |
    13 |
    14 |
    15 |
    16 | 19 |
    20 |

    21 | <%= gettext("Improve the Website") %> 22 |

    23 |
    24 | <%= gettext("Elixir School is an open source project, with code hosted on") %> GitHub. 25 | <%= gettext("Take a look at the") %> <%= gettext("issues") %> <%= gettext("on the repository to find a task that interests you. Create a pull request when your change is ready and after review it will be merged into the codebase!") %> 26 | <%= gettext("See something you think should be improved? Feel free to open an issue or create a pull request with the change.") %> 27 |
    28 |
    29 |
    30 |
    31 | 32 | 35 |
    36 |

    37 | <%= gettext("Translate Lessons") %> 38 |

    39 |
    40 | <%= gettext("Lessons on Elixir School are translated into 25 different languages! If there is a lesson missing a translation that you can provide, translate it and create a pull request.") %> 41 | <%= gettext("As you read lessons, if you see an opportunity for improvement go ahead and create a pull request with the change, the next reader will thank you. Steps for translating a lesson") %> 42 | <%= gettext("are available on") %> GitHub. 43 |
    44 |
    45 |
    46 |
    47 | 48 | 51 |
    52 |

    53 | <%= gettext("Contribute to the Blog") %> 54 |

    55 |
    56 | <%= gettext("Have a topic related to Elixir that you are knowledgeable about and want to share? Write a blog post and it could be featured on the Elixir School post feed.") %> 57 | <%= gettext("This is a great way to contribute and get visibility for your post! Steps for submitting a post are available on") %> 58 | GitHub. 59 |
    60 |
    61 |
    62 |
    63 |
    64 | -------------------------------------------------------------------------------- /lib/school_house_web/templates/page/podcasts.html.heex: -------------------------------------------------------------------------------- 1 |
    2 |
    3 |
    4 |

    5 | <%= gettext("Elixir Podcasts")%> 6 |

    7 |

    8 | <%= gettext("Check out all the great of Podcasts that focus on Elixir and the BEAM ecosystem!")%> 9 |

    10 |
    11 |
    12 |
    13 | 14 |
    15 |
    16 |
    17 |
    18 |
      19 | <%= for podcast <- @podcasts do %> 20 |
    • 21 |
      22 | 23 |
      24 | {"#{podcast.name} 25 |
      26 |
      27 |
      28 | <%= link(to: podcast.website) do %> 29 |

      <%= podcast.name %>

      30 | <%= if podcast.language do %> 31 |

      <%= podcast.language %>

      32 | <% end %> 33 | <% end %> 34 |
      35 |
      36 |

      <%= podcast.about %>

      37 |
      38 |
      39 |
    • 40 | <% end %> 41 |
    42 |
    43 |
    44 |
    45 |
    46 | -------------------------------------------------------------------------------- /lib/school_house_web/templates/page/privacy.html.heex: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elixirschool/school_house/d9aa60910d0b7a7622d1bd7212ccb6948fb0e813/lib/school_house_web/templates/page/privacy.html.heex -------------------------------------------------------------------------------- /lib/school_house_web/templates/post/_post_preview.html.heex: -------------------------------------------------------------------------------- 1 |
    2 |
    3 | <%= for tag <- @post.tags do %> 4 | <%= link(to: Routes.post_path(@conn, :filter_by_tag, String.downcase(tag))) do %> 5 | 6 | <%= String.downcase(tag) %> 7 | 8 | <% end %> 9 | <% end %> 10 |
    11 | <%= link(to: Routes.post_path(@conn, :show, @post.slug), class: "block mt-4 prose dark:prose-dark") do %> 12 | <%= raw @post.title %> 13 | <%= raw @post.excerpt %> 14 | <% end %> 15 |
    16 | 22 |
    23 |

    24 | 25 | <%= @post.author %> 26 | 27 |

    28 |
    29 | 32 |
    33 |
    34 |
    35 |
    36 | -------------------------------------------------------------------------------- /lib/school_house_web/templates/post/index.html.heex: -------------------------------------------------------------------------------- 1 |
    2 |
    3 |
    4 |

    5 | <%= assigns[:blog_heading] || gettext("Recent Posts") %> 6 |

    7 |

    8 | <%= gettext("Articles authored by Elixir School contributors and members of the community.") %> 9 |

    10 |
    11 |
    12 |
    13 | 14 |
    15 |
    16 | <%= for post <- @posts do %> 17 | <%= render("_post_preview.html", conn: @conn, post: post) %> 18 | <% end %> 19 |
    20 |
    21 | 22 | <%= if @conn.assigns.page_title == "Blog" do %> 23 |
    24 |
    25 | <%= for page <- 1..@pages do %> 26 | <%= link(to: Routes.post_path(@conn, :index, page: (page - 1)), class: "inline-flex items-center px-4 py-2 border text-sm font-medium rounded-md") do %> 27 | <%= page %> 28 | <% end %> 29 | <% end %> 30 |
    31 |
    32 | <% end %> 33 | -------------------------------------------------------------------------------- /lib/school_house_web/templates/post/post.html.heex: -------------------------------------------------------------------------------- 1 |
    2 |
    3 | <%= gettext("Elixir School") %> 4 |
    5 | 6 |
    7 | 10 |
    11 | 12 | <%= gettext("Presents") %> 13 | 14 |
    15 |
    16 | 17 |
    18 |

    <%= raw @post.title %>

    19 |
    20 | 21 |
    22 |

    23 | <%= gettext("By") %> <%= @post.author %> | <%= gettext("Posted") %> <%= @post.date %> 24 |

    25 |
    26 | 27 |
    28 | <%= raw @post.excerpt %> 29 |
    30 | 31 |
    32 | <%= raw @post.body %> 33 |
    34 |
    35 | -------------------------------------------------------------------------------- /lib/school_house_web/templates/report/_coming_soon.html.heex: -------------------------------------------------------------------------------- 1 | 2 | 3 | <%= @line.name %> 4 | 5 | 6 | <%= gettext("Coming Soon") %>! 7 | 8 | 9 | -------------------------------------------------------------------------------- /lib/school_house_web/templates/report/_lesson.html.heex: -------------------------------------------------------------------------------- 1 | 2 | 3 | <%= @line.original.title %> 4 | 5 | 6 | <%= @line.lesson.title %> 7 | 8 | 9 | <%= friendly_version(@line.original.version) %> 10 | 11 | 12 | <%= friendly_version(@line.lesson.version) %> 13 | 14 | 15 | -------------------------------------------------------------------------------- /lib/school_house_web/templates/report/_missing.html.heex: -------------------------------------------------------------------------------- 1 | 2 | 3 | <%= @line.original.title %> 4 | 5 | 6 | 7 | 8 | <%= friendly_version(@line.original.version) %> 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /lib/school_house_web/templates/report/_section.html.heex: -------------------------------------------------------------------------------- 1 |
    2 |
    3 |

    4 | <%= @section %> 5 |

    6 |
    7 |
    8 | 9 |
    10 |
    11 |
    12 | 13 | 14 | 15 | 18 | 21 | 24 | 27 | 28 | 29 | 30 | <%= for line <- @lessons do %> 31 | <%= cond do %> 32 | <% is_nil(line.original) -> %> 33 | <%= render("_coming_soon.html", line: line) %> 34 | <% is_nil(line.lesson) -> %> 35 | <%= render("_missing.html", line: line) %> 36 | <% true -> %> 37 | <%= render("_lesson.html", line: line) %> 38 | <% end %> 39 | <% end %> 40 | 41 |
    16 | <%= gettext("Lesson") %> 17 | 19 | <%= gettext("Translated Title") %> 20 | 22 | <%= gettext("Original Version") %> 23 | 25 | <%= gettext("Translated Version") %> 26 |
    42 |
    43 |
    44 |
    45 | -------------------------------------------------------------------------------- /lib/school_house_web/templates/report/report.html.heex: -------------------------------------------------------------------------------- 1 |
    2 |
    3 |
    4 |

    5 | <%= gettext("Translation Report") %> 6 |

    7 |
    8 |
    9 |
    10 | 11 | <%= render("_section.html", lessons: @report.basics, section: gettext("Basics")) %> 12 | <%= render("_section.html", lessons: @report.intermediate, section: gettext("Intermediate")) %> 13 | <%= render("_section.html", lessons: @report.advanced, section: gettext("Advanced")) %> 14 | <%= render("_section.html", lessons: @report.testing, section: gettext("Testing"))%> 15 | <%= render("_section.html", lessons: @report.data_processing, section: gettext("Data Processing")) %> 16 | <%= render("_section.html", lessons: @report.ecto, section: gettext("Ecto")) %> 17 | <%= render("_section.html", lessons: @report.storage, section: gettext("Storage")) %> 18 | <%= render("_section.html", lessons: @report.misc, section: gettext("Miscellaneous")) %> 19 | -------------------------------------------------------------------------------- /lib/school_house_web/views/error_helpers.ex: -------------------------------------------------------------------------------- 1 | defmodule SchoolHouseWeb.ErrorHelpers do 2 | @moduledoc """ 3 | Conveniences for translating and building error messages. 4 | """ 5 | 6 | use Phoenix.HTML 7 | 8 | @doc """ 9 | Generates tag for inlined form input errors. 10 | """ 11 | def error_tag(form, field) do 12 | Enum.map(Keyword.get_values(form.errors, field), fn error -> 13 | content_tag(:span, translate_error(error), 14 | class: "invalid-feedback", 15 | phx_feedback_for: input_id(form, field) 16 | ) 17 | end) 18 | end 19 | 20 | @doc """ 21 | Translates an error message using gettext. 22 | """ 23 | def translate_error({msg, opts}) do 24 | # When using gettext, we typically pass the strings we want 25 | # to translate as a static argument: 26 | # 27 | # # Translate "is invalid" in the "errors" domain 28 | # dgettext("errors", "is invalid") 29 | # 30 | # # Translate the number of files with plural rules 31 | # dngettext("errors", "1 file", "%{count} files", count) 32 | # 33 | # Because the error messages we show in our forms and APIs 34 | # are defined inside Ecto, we need to translate them dynamically. 35 | # This requires us to call the Gettext module passing our gettext 36 | # backend as first argument. 37 | # 38 | # Note we use the "errors" domain, which means translations 39 | # should be written to the errors.po file. The :count option is 40 | # set by Ecto and indicates we should also apply plural rules. 41 | if count = opts[:count] do 42 | Gettext.dngettext(SchoolHouseWeb.Gettext, "errors", msg, msg, count, opts) 43 | else 44 | Gettext.dgettext(SchoolHouseWeb.Gettext, "errors", msg, opts) 45 | end 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /lib/school_house_web/views/error_view.ex: -------------------------------------------------------------------------------- 1 | defmodule SchoolHouseWeb.ErrorView do 2 | use SchoolHouseWeb, :view 3 | 4 | def render("404.html", assigns) do 5 | render("error.html", 6 | conn: assigns.conn, 7 | title: gettext("Page not found"), 8 | message: gettext("The page you're looking for seems to be missing.") 9 | ) 10 | end 11 | 12 | # By default, Phoenix returns the status message from 13 | # the template name. For example, "404.html" becomes 14 | # "Not Found". 15 | def template_not_found(template, assigns) do 16 | title = Phoenix.Controller.status_message_from_template(template) 17 | 18 | render("error.html", 19 | conn: assigns.conn, 20 | title: title, 21 | message: gettext("An error occurred.") 22 | ) 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/school_house_web/views/html_helpers.ex: -------------------------------------------------------------------------------- 1 | defmodule SchoolHouseWeb.HtmlHelpers do 2 | @moduledoc """ 3 | A collection of helpers to assist in working with translations and lessons 4 | """ 5 | use Phoenix.HTML 6 | 7 | import SchoolHouseWeb.Gettext 8 | 9 | alias SchoolHouse.Lessons 10 | alias SchoolHouse.LocaleInfo 11 | alias SchoolHouseWeb.Router.Helpers, as: Routes 12 | 13 | def avatar_url(github_link), do: "#{github_link}.png" 14 | 15 | def current_locale do 16 | Gettext.get_locale(SchoolHouseWeb.Gettext) 17 | end 18 | 19 | def current_locale_info do 20 | LocaleInfo.map()[current_locale()] 21 | end 22 | 23 | def current_page_locale_path(%{request_path: request_path}, locale) do 24 | request_path 25 | |> String.replace(~r/^\/#{current_locale()}(\/|$)/, "/#{locale}/", global: false) 26 | |> String.trim_trailing("/") 27 | end 28 | 29 | def friendly_version({major, minor, patch}), do: "#{major}.#{minor}.#{patch}" 30 | 31 | def lesson_link( 32 | conn, 33 | section, 34 | name, 35 | class \\ "block hover:bg-purple hover:dark:bg-purple text-primary dark:text-primary-dark hover:text-white hover:dark:text-light-dark", 36 | do: contents 37 | ) do 38 | {destination, additional_classes} = 39 | if Lessons.exists?(section, name, current_locale()) do 40 | path = Routes.lesson_path(conn, :lesson, current_locale(), section, name) 41 | 42 | if path == Phoenix.Controller.current_path(conn, %{}) do 43 | {path, "font-bold"} 44 | else 45 | {path, ""} 46 | end 47 | else 48 | {"#", "cursor-not-allowed"} 49 | end 50 | 51 | class = 52 | if Lessons.coming_soon?(name) do 53 | "#{class} #{additional_classes} mr-2" 54 | else 55 | "#{class} #{additional_classes}" 56 | end 57 | 58 | content_tag( 59 | :span, 60 | [ 61 | link(contents, 62 | class: "#{class} #{additional_classes}", 63 | to: destination 64 | ), 65 | name |> Lessons.coming_soon?() |> maybe_coming_soon_badge() 66 | ], 67 | class: "flex flex-wrap" 68 | ) 69 | end 70 | 71 | # mod -> module 72 | # asgn -> assigns 73 | def load_locale_styles(mod, asgn) do 74 | sanitized_locale = String.replace(current_locale(), "-", "_") 75 | style_func = String.to_atom("#{sanitized_locale}_locale_styles") 76 | 77 | if function_exported?(mod, style_func, 1) do 78 | apply(mod, style_func, [asgn]) 79 | end 80 | end 81 | 82 | def maybe_coming_soon_badge(true) do 83 | content_tag( 84 | :span, 85 | gettext("Coming Soon"), 86 | class: "rounded py-px px-1 bg-purple text-xs text-white font-semibold self-center flex-shrink-0" 87 | ) 88 | end 89 | 90 | def maybe_coming_soon_badge(_), do: [] 91 | 92 | def supported_locales do 93 | locales = LocaleInfo.map() 94 | Map.values(locales) 95 | end 96 | 97 | def translation_status_css_class(%{status: :complete}), do: "bg-green-500" 98 | def translation_status_css_class(%{status: status}) when status in [:major, :missing], do: "bg-yellow-500" 99 | def translation_status_css_class(%{status: :minor}), do: "bg-yellow-400" 100 | def translation_status_css_class(%{status: :patch}), do: "bg-yellow-200" 101 | def translation_status_css_class(_line), do: "bg-white" 102 | end 103 | -------------------------------------------------------------------------------- /lib/school_house_web/views/layout_view.ex: -------------------------------------------------------------------------------- 1 | defmodule SchoolHouseWeb.LayoutView do 2 | use SchoolHouseWeb, :view 3 | import Phoenix.Component, only: [live_flash: 2] 4 | 5 | def render_dark_mode?(conn) do 6 | case Map.get(conn.query_params, "ui", nil) do 7 | "dark" -> "dark" 8 | _ -> "" 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /lib/school_house_web/views/lesson_view.ex: -------------------------------------------------------------------------------- 1 | defmodule SchoolHouseWeb.LessonView do 2 | use SchoolHouseWeb, :view 3 | import Phoenix.Component, only: [sigil_H: 2] 4 | 5 | def fa_locale_styles(assigns) do 6 | ~H""" 7 | 14 | """ 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/school_house_web/views/page_view.ex: -------------------------------------------------------------------------------- 1 | defmodule SchoolHouseWeb.PageView do 2 | use SchoolHouseWeb, :view 3 | end 4 | -------------------------------------------------------------------------------- /lib/school_house_web/views/post_view.ex: -------------------------------------------------------------------------------- 1 | defmodule SchoolHouseWeb.PostView do 2 | use SchoolHouseWeb, :view 3 | end 4 | -------------------------------------------------------------------------------- /lib/school_house_web/views/report_view.ex: -------------------------------------------------------------------------------- 1 | defmodule SchoolHouseWeb.ReportView do 2 | use SchoolHouseWeb, :view 3 | end 4 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule SchoolHouse.MixProject do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :school_house, 7 | version: "0.1.0", 8 | elixir: "~> 1.13", 9 | elixirc_paths: elixirc_paths(Mix.env()), 10 | compilers: [:phoenix] ++ Mix.compilers(), 11 | start_permanent: Mix.env() == :prod, 12 | aliases: aliases(), 13 | deps: deps(), 14 | dialyzer: [ 15 | plt_add_apps: [:mix] 16 | ], 17 | releases: releases() 18 | ] 19 | end 20 | 21 | # Configuration for the OTP application. 22 | # 23 | # Type `mix help compile.app` for more information. 24 | def application do 25 | [ 26 | mod: {SchoolHouse.Application, []}, 27 | extra_applications: [:logger, :runtime_tools] 28 | ] 29 | end 30 | 31 | def releases do 32 | [ 33 | school_house: [ 34 | include_executables_for: [:unix], 35 | cookie: "school_house" 36 | ] 37 | ] 38 | end 39 | 40 | # Specifies which paths to compile per environment. 41 | defp elixirc_paths(:test), do: ["lib", "test/support"] 42 | defp elixirc_paths(_), do: ["lib"] 43 | 44 | # Specifies your project dependencies. 45 | # 46 | # Type `mix help deps` for examples and options. 47 | defp deps do 48 | [ 49 | {:appsignal, "~> 2.7"}, 50 | {:appsignal_phoenix, "~> 2.3"}, 51 | {:gettext, "~> 0.11"}, 52 | {:jason, "~> 1.0"}, 53 | {:libcluster, "~> 3.3"}, 54 | {:locale_plug, "~> 0.1.0"}, 55 | {:makeup_elixir, ">= 0.0.0"}, 56 | {:makeup_erlang, ">= 0.0.0"}, 57 | {:nimble_publisher, "~> 0.1"}, 58 | {:phoenix, "~> 1.6.9"}, 59 | {:phoenix_html, "~> 3.2"}, 60 | {:phoenix_live_view, "~> 0.17"}, 61 | {:plug_cowboy, "~> 2.0"}, 62 | {:ssl_verify_fun, "~> 1.1.7", manager: :rebar3, override: true}, 63 | {:telemetry_metrics, "~> 0.6"}, 64 | {:telemetry_poller, "~> 0.5"}, 65 | 66 | # Dev & Test dependencies 67 | {:credo, "~> 1.7", only: [:dev, :test]}, 68 | {:dialyxir, "~> 1.0", only: [:dev, :test], runtime: false}, 69 | {:esbuild, "~> 0.2", runtime: Mix.env() == :dev}, 70 | {:floki, ">= 0.0.0", only: :test}, 71 | {:phoenix_live_reload, "~> 1.2", only: :dev} 72 | ] 73 | end 74 | 75 | # Aliases are shortcuts or tasks specific to the current project. 76 | # For example, to install project dependencies and perform other setup tasks, run: 77 | # 78 | # $ mix setup 79 | # 80 | # See the documentation for `Mix` for more info on aliases. 81 | defp aliases do 82 | [ 83 | setup: ["deps.get", "cmd --cd assets npm install"], 84 | "assets.deploy": [ 85 | "cmd --cd assets npm run deploy", 86 | "esbuild default --minify", 87 | "cmd cp -r assets/static priv", 88 | "phx.digest" 89 | ] 90 | ] 91 | end 92 | end 93 | -------------------------------------------------------------------------------- /rel/env.sh.eex: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # Sets and enables heart (recommended only in daemon mode) 4 | # case $RELEASE_COMMAND in 5 | # daemon*) 6 | # HEART_COMMAND="$RELEASE_ROOT/bin/$RELEASE_NAME $RELEASE_COMMAND" 7 | # export HEART_COMMAND 8 | # export ELIXIR_ERL_OPTIONS="-heart" 9 | # ;; 10 | # *) 11 | # ;; 12 | # esac 13 | 14 | # Set the release to work across nodes. 15 | # RELEASE_DISTRIBUTION must be "sname" (local), "name" (distributed) or "none". 16 | # export RELEASE_DISTRIBUTION=name 17 | # export RELEASE_NODE=<%= @release.name %> 18 | 19 | # Setup fly.io environment variables 20 | ip=$(grep fly-local-6pn /etc/hosts | cut -f 1) 21 | export RELEASE_DISTRIBUTION=name 22 | export RELEASE_NODE="$FLY_APP_NAME@$ip" 23 | -------------------------------------------------------------------------------- /rel/overlays/bin/server: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | cd -P -- "$(dirname -- "$0")" 3 | PHX_SERVER=true exec ./school_house start 4 | -------------------------------------------------------------------------------- /rel/remote.vm.args.eex: -------------------------------------------------------------------------------- 1 | ## Customize flags given to the VM: https://erlang.org/doc/man/erl.html 2 | ## -mode/-name/-sname/-setcookie are configured via env vars, do not set them here 3 | 4 | ## Number of dirty schedulers doing IO work (file, sockets, and others) 5 | ##+SDio 5 6 | 7 | ## Increase number of concurrent ports/sockets 8 | ##+Q 65536 9 | 10 | ## Tweak GC to run more often 11 | ##-env ERL_FULLSWEEP_AFTER 10 12 | -------------------------------------------------------------------------------- /rel/vm.args.eex: -------------------------------------------------------------------------------- 1 | ## Customize flags given to the VM: https://erlang.org/doc/man/erl.html 2 | ## -mode/-name/-sname/-setcookie are configured via env vars, do not set them here 3 | 4 | ## Number of dirty schedulers doing IO work (file, sockets, and others) 5 | ##+SDio 5 6 | 7 | ## Increase number of concurrent ports/sockets 8 | ##+Q 65536 9 | 10 | ## Tweak GC to run more often 11 | ##-env ERL_FULLSWEEP_AFTER 10 12 | -------------------------------------------------------------------------------- /test/school_house/conferences_test.exs: -------------------------------------------------------------------------------- 1 | defmodule SchoolHouse.ConferencesTest do 2 | use ExUnit.Case 3 | 4 | alias SchoolHouse.Conferences 5 | 6 | describe "list/0" do 7 | test "returns conferences ordered by date" do 8 | conferences = Conferences.list() 9 | 10 | [first_conf, second_conf | _] = conferences 11 | 12 | assert Date.compare(first_conf.date, second_conf.date) == :gt 13 | end 14 | end 15 | 16 | describe "countries/0" do 17 | test "countries in which conferences occur" do 18 | countries = Conferences.countries() 19 | 20 | assert is_list(countries) 21 | assert 1 == length(countries) 22 | end 23 | end 24 | 25 | describe "online/0" do 26 | test "returns just the online conferences" do 27 | online_conferences = Conferences.online() 28 | 29 | assert 1 == length(online_conferences) 30 | 31 | assert online_conferences 32 | |> hd() 33 | |> Map.get(:location) 34 | |> is_nil() 35 | end 36 | end 37 | 38 | describe "by_country/1" do 39 | test "returns conferences in the United States" do 40 | us_conferences = Conferences.by_country("United States") 41 | 42 | assert 1 == length(us_conferences) 43 | assert us_conferences |> hd() |> Map.get(:country) == "United States" 44 | end 45 | 46 | test "returns empty array for conferences in Brazil" do 47 | brazil_conferences = Conferences.by_country("Brazil") 48 | 49 | assert [] == brazil_conferences 50 | end 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /test/school_house/content/lesson_test.exs: -------------------------------------------------------------------------------- 1 | defmodule SchoolHouse.LessonTest do 2 | use ExUnit.Case 3 | 4 | alias SchoolHouse.{Content.Lesson, Lessons} 5 | 6 | test "generates properly nested lists for table of contents" do 7 | assert {:ok, %Lesson{table_of_contents: toc}} = Lessons.get("basics", "enum", "en") 8 | 9 | assert ~s() == 10 | toc 11 | end 12 | 13 | test "generates table of contents for accented characters in Spanish" do 14 | assert {:ok, %Lesson{table_of_contents: toc}} = Lessons.get("basics", "collections", "es") 15 | 16 | assert toc =~ "Listas" 17 | assert toc =~ "Concatenación de listas" 18 | end 19 | 20 | test "generates table of contents for Korean translation" do 21 | assert {:ok, %Lesson{table_of_contents: toc}} = Lessons.get("basics", "collections", "ko") 22 | 23 | assert toc =~ "리스트" 24 | end 25 | 26 | test "generates table of contents for an assorted subset of international characters" do 27 | assert {:ok, %Lesson{table_of_contents: toc}} = Lessons.get("basics", "basics", "en") 28 | 29 | assert toc =~ "áéíóúàèìòùâêîôûäëïöüñçÁÉÍÓÚÀÈÌÒÙÂÊÎÔÛÄËÏÖÜÑÇ" 30 | end 31 | 32 | test "generates nil as the previous link for first lesson" do 33 | assert {:ok, %Lesson{previous: previous}} = Lessons.get("basics", "basics", "en") 34 | 35 | assert is_nil(previous) 36 | end 37 | 38 | test "generates previous lesson for first lesson in section" do 39 | assert {:ok, %Lesson{previous: previous}} = Lessons.get("intermediate", "erlang", "en") 40 | 41 | assert previous.section == :basics 42 | assert previous.name == :enum 43 | end 44 | 45 | test "generates next lesson for last lesson in section" do 46 | assert {:ok, %Lesson{next: next}} = Lessons.get("basics", "enum", "en") 47 | 48 | assert next.section == :intermediate 49 | assert next.name == :erlang 50 | end 51 | 52 | test "generates nil as the next lesson for last lesson" do 53 | assert {:ok, %Lesson{next: next}} = Lessons.get("intermediate", "erlang", "en") 54 | 55 | assert is_nil(next) 56 | end 57 | 58 | test "correctly parses body" do 59 | assert {:ok, %Lesson{body: body}} = Lessons.get("basics", "enum", "en") 60 | 61 | assert body == 62 | "

    \n \nA\n \n \n \n \n \n

    \n

    \n \nAA\n \n \n \n \n \n

    \n

    \n \nAAA\n \n \n \n \n \n

    \n

    \n \nB\n \n \n \n \n \n

    \n

    \n \nAA\n \n \n \n \n \n

    \n" 63 | end 64 | end 65 | -------------------------------------------------------------------------------- /test/school_house/lessons_test.exs: -------------------------------------------------------------------------------- 1 | defmodule SchoolHouse.LessonsTest do 2 | use ExUnit.Case 3 | 4 | alias SchoolHouse.{Content.Lesson, Lessons} 5 | 6 | describe "exists?/1" do 7 | test "returns whether a lesson is available" do 8 | assert Lessons.exists?("basics", "basics", "en") 9 | refute Lessons.exists?("basics", "basics", "zz") 10 | end 11 | end 12 | 13 | describe "get/3" do 14 | test "returns a lesson for a given section, name, and locale" do 15 | assert {:ok, 16 | %Lesson{ 17 | locale: "en", 18 | title: "Collections", 19 | previous: %Lesson{title: "Basics"}, 20 | next: %Lesson{title: "Enum"}, 21 | version: {1, 3, 1} 22 | }} = Lessons.get("basics", "collections", "en") 23 | end 24 | 25 | test "returns {:error, :translation_not_found, missing_locale} for missing translation" do 26 | assert {:error, :translation_not_found, "es"} == Lessons.get("basics", "enum", "es") 27 | end 28 | 29 | test "returns {:error, :not_found} for missing lessons" do 30 | assert {:error, :not_found} == Lessons.get("none", "existent", "lesson") 31 | end 32 | end 33 | 34 | describe "locale_report/1" do 35 | test "returns a report of translated content for a locale" do 36 | %{basics: basics_report} = Lessons.translation_report("es") 37 | assert %{status: :complete} = Enum.find(basics_report, &(&1.name == :basics)) 38 | assert %{status: :missing} = Enum.find(basics_report, &(&1.name == :enum)) 39 | assert %{original: nil} = Enum.find(basics_report, &(&1.name == :functions)) 40 | end 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /test/school_house/locales_test.exs: -------------------------------------------------------------------------------- 1 | defmodule SchoolHouse.LocalesTest do 2 | use ExUnit.Case 3 | 4 | alias SchoolHouse.LocaleInfo 5 | 6 | test "Locales in LocaleInfo are same as in config" do 7 | assert LocaleInfo.list() == Application.get_env(:school_house, SchoolHouseWeb.Gettext)[:locales] 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /test/school_house/posts_test.exs: -------------------------------------------------------------------------------- 1 | defmodule SchoolHouse.PostsTest do 2 | use ExUnit.Case 3 | 4 | alias SchoolHouse.{Content.Post, Posts} 5 | 6 | describe "get/1" do 7 | test "returns a specific post by slug" do 8 | assert {:ok, %Post{title: "

    \nTitle for a post

    \n"}} = Posts.get("test_blog_post") 9 | end 10 | 11 | test "returns error if post not found" do 12 | assert {:error, :not_found} == Posts.get("unknown") 13 | end 14 | end 15 | 16 | describe "get_by_tag/1" do 17 | test "returns the correct number of posts by tag" do 18 | {:ok, posts} = Posts.get_by_tag("general") 19 | 20 | assert is_list(posts) 21 | assert 2 == length(posts) 22 | end 23 | 24 | test "returns error if no posts found" do 25 | assert {:error, :not_found} == Posts.get_by_tag("unknown") 26 | end 27 | end 28 | 29 | describe "page/1" do 30 | test "returns a specific page of posts" do 31 | posts = Posts.page(0) 32 | 33 | assert is_list(posts) 34 | assert 2 == length(posts) 35 | end 36 | end 37 | 38 | describe "pages/0" do 39 | test "returns the total number of post pages" do 40 | assert 1 == Posts.pages() 41 | end 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /test/school_house_web/controllers/lesson_controller_test.exs: -------------------------------------------------------------------------------- 1 | defmodule SchoolHouseWeb.LessonControllerTest do 2 | use SchoolHouseWeb.ConnCase 3 | 4 | @routes_map %{ 5 | {"basics", "pattern-matching"} => {"basics", "pattern_matching"}, 6 | {"basics", "control-structures"} => {"basics", "control_structures"}, 7 | {"basics", "pipe-operator"} => {"basics", "pipe_operator"}, 8 | {"basics", "date-time"} => {"basics", "date_time"}, 9 | {"basics", "mix-tasks"} => {"intermediate", "mix_tasks"}, 10 | {"basics", "iex-helpers"} => {"basics", "iex_helpers"}, 11 | {"basics", "testing"} => {"testing", "basics"}, 12 | {"advanced", "erlang"} => {"intermediate", "erlang"}, 13 | {"advanced", "error-handling"} => {"intermediate", "error_handling"}, 14 | {"advanced", "escripts"} => {"intermediate", "escripts"}, 15 | {"advanced", "concurrency"} => {"intermediate", "concurrency"}, 16 | {"advanced", "otp-concurrency"} => {"advanced", "otp_concurrency"}, 17 | {"advanced", "otp-supervisors"} => {"advanced", "otp_supervisors"}, 18 | {"advanced", "otp-distribution"} => {"advanced", "otp_distribution"}, 19 | {"advanced", "umbrella-projects"} => {"advanced", "umbrella_projects"}, 20 | {"advanced", "gen-stage"} => {"data_processing", "genstage"}, 21 | {"ecto", "querying"} => {"ecto", "querying_basics"}, 22 | {"specifics", "plug"} => {"misc", "plug"}, 23 | {"specifics", "eex"} => {"misc", "eex"}, 24 | {"specifics", "ets"} => {"storage", "ets"}, 25 | {"specifics", "mnesia"} => {"storage", "mnesia"}, 26 | {"specifics", "debugging"} => {"misc", "debugging"}, 27 | {"specifics", "nerves"} => {"misc", "nerves"}, 28 | {"libraries", "guardian"} => {"misc", "guardian"}, 29 | {"libraries", "poolboy"} => {"misc", "poolboy"}, 30 | {"libraries", "benchee"} => {"misc", "benchee"}, 31 | {"libraries", "bypass"} => {"testing", "bypass"}, 32 | {"libraries", "distillery"} => {"misc", "distillery"}, 33 | {"libraries", "stream-data"} => {"testing", "stream_data"}, 34 | {"libraries", "nimble-publisher"} => {"misc", "nimble_publisher"}, 35 | {"libraries", "mox"} => {"testing", "mox"} 36 | } 37 | 38 | describe "lesson/2" do 39 | test "renders a page for the requested lesson", %{conn: conn} do 40 | conn = get(conn, Routes.lesson_path(conn, :lesson, "en", "basics", "basics")) 41 | body = html_response(conn, 200) 42 | 43 | assert body =~ "Getting Started" 44 | end 45 | 46 | test "renders a 404 for invalid lessons", %{conn: conn} do 47 | conn = get(conn, Routes.lesson_path(conn, :lesson, "en", "non", "existent")) 48 | body = html_response(conn, 404) 49 | 50 | assert body =~ "Page not found" 51 | end 52 | 53 | test "renders a CTA for missing translations", %{conn: conn} do 54 | conn = get(conn, Routes.lesson_path(conn, :lesson, "es", "basics", "enum")) 55 | body = html_response(conn, 404) 56 | 57 | assert body =~ "Traducción no disponible" 58 | end 59 | 60 | test "renders 404 for invalid locale", %{conn: conn} do 61 | conn = get(conn, Routes.page_path(conn, :index, "klingon")) 62 | body = html_response(conn, 404) 63 | 64 | assert body =~ "Page not found" 65 | end 66 | 67 | test "redirects old routes to new routes", %{conn: conn} do 68 | Enum.each(@routes_map, fn {{section, name}, {new_section, new_name}} -> 69 | conn = get(conn, Routes.lesson_path(conn, :lesson, "en", section, name)) 70 | 71 | assert %{section: ^new_section, name: ^new_name} = redirected_params(conn) 72 | end) 73 | end 74 | end 75 | end 76 | -------------------------------------------------------------------------------- /test/school_house_web/controllers/page_controller_test.exs: -------------------------------------------------------------------------------- 1 | defmodule SchoolHouseWeb.PageControllerTest do 2 | use SchoolHouseWeb.ConnCase 3 | 4 | describe "get_involved/2" do 5 | test "renders the get involved html template", %{conn: conn} do 6 | conn = get(conn, Routes.page_path(conn, :get_involved, "en")) 7 | body = html_response(conn, 200) 8 | 9 | assert body =~ "Get Involved" 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /test/school_house_web/controllers/post_controller_test.exs: -------------------------------------------------------------------------------- 1 | defmodule SchoolHouseWeb.PostControllerTest do 2 | use SchoolHouseWeb.ConnCase 3 | 4 | describe "index/2" do 5 | test "renders a page of available blog posts", %{conn: conn} do 6 | conn = get(conn, Routes.post_path(conn, :index)) 7 | body = html_response(conn, 200) 8 | 9 | assert body =~ "Title for a post" 10 | end 11 | end 12 | 13 | describe "show/2" do 14 | test "renders a page for a specific blog posts", %{conn: conn} do 15 | conn = get(conn, Routes.post_path(conn, :show, "test_blog_post")) 16 | body = html_response(conn, 200) 17 | 18 | assert body =~ "Title for a post" 19 | assert body =~ "By Sean Callan" 20 | end 21 | 22 | test "renders a page with an error message when the blog post is not found", %{conn: conn} do 23 | conn = get(conn, Routes.post_path(conn, :show, "test_no_post_found")) 24 | body = html_response(conn, 404) 25 | 26 | assert body =~ "Page not found" 27 | end 28 | end 29 | 30 | describe "filter_by_tag/2" do 31 | test "renders a page with blog posts matching the tag", %{conn: conn} do 32 | conn = get(conn, Routes.post_path(conn, :filter_by_tag, "general")) 33 | body = html_response(conn, 200) 34 | 35 | assert body =~ "Posts Tagged "general"" 36 | assert body =~ "Title for a post" 37 | assert body =~ "Sean Callan" 38 | assert body =~ "2021-04-15" 39 | assert body =~ "Testing is important" 40 | assert body =~ "Kate Beard" 41 | assert body =~ "2021-08-11" 42 | end 43 | 44 | test "renders a page with an error message when no posts with the tag are found", %{conn: conn} do 45 | conn = get(conn, Routes.post_path(conn, :filter_by_tag, "unknown")) 46 | body = html_response(conn, 404) 47 | 48 | assert body =~ "Page not found" 49 | end 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /test/school_house_web/live/conference_live_test.exs: -------------------------------------------------------------------------------- 1 | defmodule SchoolHouseWeb.ConferencesLiveTest do 2 | use SchoolHouseWeb.ConnCase 3 | 4 | import Phoenix.LiveViewTest 5 | 6 | alias SchoolHouseWeb.ConferencesLive.TestHelpers 7 | 8 | describe "mount/3" do 9 | test "can mount live view", %{conn: conn} do 10 | {:ok, _view, html} = live_isolated(conn, SchoolHouseWeb.ConferencesLive, session: %{}) 11 | assert html =~ "Filter

    " 12 | end 13 | 14 | test "applying no filters returns both rows", %{conn: conn} do 15 | {:ok, view, _html} = live_isolated(conn, SchoolHouseWeb.ConferencesLive, session: %{}) 16 | 17 | filtered_view = TestHelpers.apply_filter(view, %{filters: %{"online" => "false", "country" => ""}}) 18 | 19 | assert filtered_view =~ "Test In Person Conference" 20 | assert filtered_view =~ "Test Online Conference" 21 | end 22 | 23 | test "applying country filter only returns one row", %{conn: conn} do 24 | {:ok, view, _html} = live_isolated(conn, SchoolHouseWeb.ConferencesLive, session: %{}) 25 | 26 | filtered_view = TestHelpers.apply_filter(view, %{filters: %{"online" => "false", "country" => "United States"}}) 27 | 28 | assert filtered_view =~ "Test In Person Conference" 29 | refute filtered_view =~ "Test Online Conference" 30 | end 31 | 32 | test "applying online filter only returns one row", %{conn: conn} do 33 | {:ok, view, _html} = live_isolated(conn, SchoolHouseWeb.ConferencesLive, session: %{}) 34 | 35 | filtered_view = TestHelpers.apply_filter(view, %{filters: %{"online" => "true", "country" => ""}}) 36 | 37 | refute filtered_view =~ "Test In Person Conference" 38 | assert filtered_view =~ "Test Online Conference" 39 | end 40 | 41 | test "applying online filter and country filter returns combined set of rows", %{conn: conn} do 42 | {:ok, view, _html} = live_isolated(conn, SchoolHouseWeb.ConferencesLive, session: %{}) 43 | 44 | filtered_view = TestHelpers.apply_filter(view, %{filters: %{"online" => "true", "country" => "United States"}}) 45 | 46 | assert filtered_view =~ "Test In Person Conference" 47 | assert filtered_view =~ "Test Online Conference" 48 | end 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /test/school_house_web/views/error_view_test.exs: -------------------------------------------------------------------------------- 1 | defmodule SchoolHouseWeb.ErrorViewTest do 2 | use SchoolHouseWeb.ConnCase, async: true 3 | 4 | # Bring render/3 and render_to_string/3 for testing custom views 5 | import Phoenix.View 6 | 7 | test "renders 404.html" do 8 | assert render_to_string(SchoolHouseWeb.ErrorView, "404.html", conn: build_conn()) =~ "Page not found" 9 | end 10 | 11 | test "renders 500.html" do 12 | assert render_to_string(SchoolHouseWeb.ErrorView, "500.html", conn: build_conn()) =~ "Internal Server Error" 13 | end 14 | 15 | test "renders translation_missing.html" do 16 | assert render_to_string(SchoolHouseWeb.ErrorView, "translation_missing.html", 17 | conn: build_conn(), 18 | missing_locale: "no" 19 | ) =~ 20 | "Translation unavailable" 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /test/school_house_web/views/html_helpers_test.exs: -------------------------------------------------------------------------------- 1 | defmodule SchoolHouseWeb.HtmlHelpersTest do 2 | use SchoolHouseWeb.ConnCase 3 | 4 | alias SchoolHouseWeb.HtmlHelpers 5 | 6 | setup do 7 | Gettext.put_locale("es") 8 | 9 | on_exit(fn -> 10 | Gettext.put_locale("en") 11 | end) 12 | end 13 | 14 | describe "avatar_url/1" do 15 | test "appends .png to GitHub profile link" do 16 | assert "https://github.com/elixirschool.png" == HtmlHelpers.avatar_url("https://github.com/elixirschool") 17 | end 18 | end 19 | 20 | describe "current_locale/0" do 21 | test "returns the current locale used by Gettext" do 22 | assert "es" == HtmlHelpers.current_locale() 23 | end 24 | end 25 | 26 | describe "current_locale_info/0" do 27 | test "returns the current locale info" do 28 | assert %SchoolHouse.LocaleInfo{code: "es", title: "Spanish", original_name: "Español", flag_icon: "es"} == 29 | HtmlHelpers.current_locale_info() 30 | end 31 | end 32 | 33 | describe "current_page_locale_path/2" do 34 | test "returns the current page path for another locale" do 35 | assert "/fr/ecto/changesets" == 36 | :get 37 | |> build_conn("/es/ecto/changesets") 38 | |> HtmlHelpers.current_page_locale_path("fr") 39 | end 40 | 41 | test "returns the home page path for another locale" do 42 | assert "/fr" == 43 | :get 44 | |> build_conn("/es") 45 | |> HtmlHelpers.current_page_locale_path("fr") 46 | end 47 | 48 | test "returns the same page path for a page without locale scope" do 49 | assert "/blog/instrumenting-phoenix" == 50 | :get 51 | |> build_conn("/blog/instrumenting-phoenix") 52 | |> HtmlHelpers.current_page_locale_path("fr") 53 | end 54 | 55 | test "doesn't break URLs starting with something similar to a locale" do 56 | assert "/essential" == 57 | :get 58 | |> build_conn("/essential") 59 | |> HtmlHelpers.current_page_locale_path("ar") 60 | end 61 | end 62 | 63 | describe "supported_locales/0" do 64 | test "returns a list of all supported locales" do 65 | locales = HtmlHelpers.supported_locales() 66 | 67 | assert is_list(locales) 68 | assert Enum.all?(locales, &assert(%SchoolHouse.LocaleInfo{} = &1)) 69 | assert 1 < length(locales) 70 | end 71 | end 72 | end 73 | -------------------------------------------------------------------------------- /test/school_house_web/views/layout_view_test.exs: -------------------------------------------------------------------------------- 1 | defmodule SchoolHouseWeb.LayoutViewTest do 2 | use SchoolHouseWeb.ConnCase, async: true 3 | 4 | describe "render_dark_mode?/1" do 5 | test "returns `dark` when dark mode query parameter is present", %{conn: conn} do 6 | assert conn 7 | |> Map.put(:query_params, %{"ui" => "dark"}) 8 | |> SchoolHouseWeb.LayoutView.render_dark_mode?() == "dark" 9 | end 10 | 11 | test "returns `` when dark mode query parameter is not present", %{conn: conn} do 12 | assert conn 13 | |> Map.put(:query_params, %{}) 14 | |> SchoolHouseWeb.LayoutView.render_dark_mode?() == "" 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /test/support/channel_case.ex: -------------------------------------------------------------------------------- 1 | defmodule SchoolHouseWeb.ChannelCase do 2 | @moduledoc """ 3 | This module defines the test case to be used by 4 | channel tests. 5 | 6 | Such tests rely on `Phoenix.ChannelTest` and also 7 | import other functionality to make it easier 8 | to build common data structures and query the data layer. 9 | 10 | Finally, if the test case interacts with the database, 11 | we enable the SQL sandbox, so changes done to the database 12 | are reverted at the end of every test. If you are using 13 | PostgreSQL, you can even run database tests asynchronously 14 | by setting `use SchoolHouseWeb.ChannelCase, async: true`, although 15 | this option is not recommended for other databases. 16 | """ 17 | 18 | use ExUnit.CaseTemplate 19 | 20 | using do 21 | quote do 22 | # Import conveniences for testing with channels 23 | import Phoenix.ChannelTest 24 | import SchoolHouseWeb.ChannelCase 25 | 26 | # The default endpoint for testing 27 | @endpoint SchoolHouseWeb.Endpoint 28 | end 29 | end 30 | 31 | setup do 32 | :ok 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /test/support/conn_case.ex: -------------------------------------------------------------------------------- 1 | defmodule SchoolHouseWeb.ConnCase do 2 | @moduledoc """ 3 | This module defines the test case to be used by 4 | tests that require setting up a connection. 5 | 6 | Such tests rely on `Phoenix.ConnTest` and also 7 | import other functionality to make it easier 8 | to build common data structures and query the data layer. 9 | 10 | Finally, if the test case interacts with the database, 11 | we enable the SQL sandbox, so changes done to the database 12 | are reverted at the end of every test. If you are using 13 | PostgreSQL, you can even run database tests asynchronously 14 | by setting `use SchoolHouseWeb.ConnCase, async: true`, although 15 | this option is not recommended for other databases. 16 | """ 17 | 18 | use ExUnit.CaseTemplate 19 | 20 | using do 21 | quote do 22 | # Import conveniences for testing with connections 23 | import Plug.Conn 24 | import Phoenix.ConnTest 25 | import SchoolHouseWeb.ConnCase 26 | 27 | alias SchoolHouseWeb.Router.Helpers, as: Routes 28 | 29 | # The default endpoint for testing 30 | @endpoint SchoolHouseWeb.Endpoint 31 | end 32 | end 33 | 34 | setup do 35 | {:ok, conn: Phoenix.ConnTest.build_conn()} 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /test/support/content/conferences/test_in_person_conference.md: -------------------------------------------------------------------------------- 1 | %{ 2 | name: "Test In Person Conference", 3 | link: "https://2021.elixirconf.com/", 4 | date: ~D[2021-06-08], 5 | location: "Austin, TX", 6 | country: "United States" 7 | } 8 | --- 9 | A Test Conference -------------------------------------------------------------------------------- /test/support/content/conferences/test_online_conference.md: -------------------------------------------------------------------------------- 1 | %{ 2 | name: "Test Online Conference", 3 | link: "https://2021.elixirconf.com/", 4 | date: ~D[2021-06-07], 5 | } 6 | --- 7 | A Test Conference -------------------------------------------------------------------------------- /test/support/content/lessons/en/basics/basics.md: -------------------------------------------------------------------------------- 1 | %{ 2 | version: "1.2.1", 3 | title: "Basics", 4 | excerpt: """ 5 | Getting started, basic data types, and basic operations. 6 | """ 7 | } 8 | --- 9 | 10 | ## Getting Started 11 | 12 | ### Installing Elixir 13 | 14 | Installation instructions for each OS can be found on elixir-lang.org in the [Installing Elixir](http://elixir-lang.org/install.html) guide. 15 | 16 | After Elixir is installed, you can easily confirm the installed version. 17 | 18 | % elixir -v 19 | Erlang/OTP {{ site.erlang.OTP }} [erts-{{ site.erlang.erts }}] [source] [64-bit] [smp:4:4] [ds:4:4:10] [async-threads:10] [hipe] [kernel-poll:false] [dtrace] 20 | 21 | Elixir {{ site.elixir.version }} 22 | 23 | ## áéíóúàèìòùâêîôûäëïöüñçÁÉÍÓÚÀÈÌÒÙÂÊÎÔÛÄËÏÖÜÑÇ 24 | 25 | This heading is a subset of unicode characters used to test the process of constructing a table of contents for each lesson. -------------------------------------------------------------------------------- /test/support/content/lessons/en/basics/collections.md: -------------------------------------------------------------------------------- 1 | %{ 2 | version: "1.3.1", 3 | title: "Collections", 4 | excerpt: """ 5 | Lists, tuples, keyword lists, and maps. 6 | """ 7 | } 8 | --- 9 | 10 | ## Lists 11 | 12 | Lists are simple collections of values which may include multiple types; lists may also include non-unique values: 13 | 14 | ```elixir 15 | iex> [3.14, :pie, "Apple"] 16 | [3.14, :pie, "Apple"] 17 | ``` 18 | 19 | Elixir implements list collections as linked lists. 20 | This means that accessing the list length is an operation that will run in linear time (`O(n)`). 21 | For this reason, it is typically faster to prepend than to append: 22 | -------------------------------------------------------------------------------- /test/support/content/lessons/en/basics/enum.md: -------------------------------------------------------------------------------- 1 | %{ 2 | version: "1.7.0", 3 | title: "Enum", 4 | excerpt: """ 5 | A set of algorithms for enumerating over enumerables. 6 | """ 7 | } 8 | --- 9 | 10 | ## A 11 | 12 | ### AA 13 | 14 | #### AAA 15 | 16 | ## B 17 | 18 | ### AA 19 | -------------------------------------------------------------------------------- /test/support/content/lessons/en/intermediate/erlang.md: -------------------------------------------------------------------------------- 1 | %{ 2 | version: "1.0.2", 3 | title: "Erlang Interoperability", 4 | excerpt: """ 5 | One of the added benefits to building on top of the Erlang VM (BEAM) is the plethora of existing libraries available to us. 6 | Interoperability allows us to leverage those libraries and the Erlang standard lib from our Elixir code. 7 | In this lesson we'll look at how to access functionality in the standard lib along with third-party Erlang packages. 8 | """ 9 | } 10 | --- 11 | 12 | ## Standard Library 13 | 14 | Erlang's extensive standard library can be accessed from any Elixir code in our application. 15 | Erlang modules are represented by lowercase atoms such as `:os` and `:timer`. 16 | 17 | Let's use `:timer.tc` to time execution of a given function: -------------------------------------------------------------------------------- /test/support/content/lessons/es/basics/basics.md: -------------------------------------------------------------------------------- 1 | %{ 2 | version: "1.2.1", 3 | title: "Básico", 4 | excerpt: """ 5 | Preparar el entorno, tipos y operaciones básicas. 6 | """ 7 | } 8 | --- 9 | 10 | ## Preparar el entorno 11 | 12 | ### Instalar Elixir 13 | 14 | Las instrucciones de instalación para cada sistema operativo pueden ser encontradas en [Elixir-lang.org](http://elixir-lang.org) en la guía [Installing Elixir](http://elixir-lang.org/install.html)(en inglés). 15 | 16 | Después de que Elixir haya sido instalado, puedes confirmar la versión instalada fácilmente. 17 | 18 | % elixir -v 19 | Erlang/OTP {{ site.erlang.OTP }} [erts-{{ site.erlang.erts }}] [source] [64-bit] [smp:4:4] [ds:4:4:10] [async-threads:10] [hipe] [kernel-poll:false] [dtrace] 20 | 21 | Elixir {{ site.elixir.version }} 22 | -------------------------------------------------------------------------------- /test/support/content/lessons/es/basics/collections.md: -------------------------------------------------------------------------------- 1 | %{ 2 | version: "1.3.1", 3 | title: "Colecciones", 4 | excerpt: """ 5 | Listas, tuplas, listas de palabras clave y mapas. 6 | """ 7 | } 8 | --- 9 | 10 | ## Listas 11 | 12 | Las listas son simples colecciones de valores, las cuales pueden incluir múltiples tipos de datos; las listas pueden incluir valores no únicos: 13 | 14 | ```elixir 15 | iex> [3.14, :pie, "Apple"] 16 | [3.14, :pie, "Apple"] 17 | ``` 18 | 19 | ## Concatenación de listas 20 | 21 | La concatenación de listas usa el operador ++/2: 22 | 23 | ```elixir 24 | iex> [1, 2] ++ [3, 4, 1] 25 | [1, 2, 3, 4, 1] 26 | ``` -------------------------------------------------------------------------------- /test/support/content/lessons/ko/basics/collections.md: -------------------------------------------------------------------------------- 1 | %{ 2 | version: "1.3.1", 3 | title: "컬렉션", 4 | excerpt: """ 5 | 리스트, 튜플, 키워드 리스트, 맵. 6 | """ 7 | } 8 | --- 9 | 10 | ## 리스트 11 | 12 | 리스트(list)는 값들의 간단한 컬렉션입니다. 리스트는 여러 타입을 포함할 수 있으며 중복된 값들도 포함할 수 있습니다. 13 | 14 | ```elixir 15 | iex> [3.14, :pie, "Apple"] 16 | [3.14, :pie, "Apple"] 17 | ``` 18 | 19 | Elixir는 연결 리스트로서 리스트 컬렉션을 구현합니다. 20 | 따라서 리스트의 길이를 구하는 것은 선형의 시간`O(n)`이 걸리며, 21 | 이러한 이유로 리스트의 앞에 값을 추가하는 것이 뒤에 추가하는 것보다 보통 빠릅니다. 22 | 23 | ```elixir 24 | iex> list = [3.14, :pie, "Apple"] 25 | [3.14, :pie, "Apple"] 26 | # 앞에 추가(빠름) 27 | iex> ["π" | list] 28 | ["π", 3.14, :pie, "Apple"] 29 | # 뒤에 추가(느림) 30 | iex> list ++ ["Cherry"] 31 | [3.14, :pie, "Apple", "Cherry"] 32 | ``` 33 | -------------------------------------------------------------------------------- /test/support/content/posts/2021-06-13-test_blog_post.md: -------------------------------------------------------------------------------- 1 | %{ 2 | author: "Sean Callan", 3 | author_link: "https://github.com/doomspork", 4 | tags: ["general"], 5 | date: ~D[2021-04-15], 6 | title: "Title for a post", 7 | excerpt: """ 8 | Excerpt from a post 9 | """ 10 | } 11 | --- 12 | 13 | Important and stimulating content 14 | -------------------------------------------------------------------------------- /test/support/content/posts/2021-08-11-another_test_blog_post.md: -------------------------------------------------------------------------------- 1 | %{ 2 | author: "Kate Beard", 3 | author_link: "https://github.com/sbinlondon", 4 | tags: ["general"], 5 | date: ~D[2021-08-11], 6 | title: "Testing is important", 7 | excerpt: """ 8 | Excerpt from a post 9 | """ 10 | } 11 | --- 12 | 13 | You should do it 14 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | 3 | defmodule SchoolHouseWeb.ConferencesLive.TestHelpers do 4 | import Phoenix.LiveViewTest 5 | 6 | def apply_filter(view, filters) do 7 | view 8 | |> element("form") 9 | |> render_change(filters) 10 | end 11 | end 12 | --------------------------------------------------------------------------------