[
  {
    "path": ".ameba.yml",
    "content": "# This configuration file was generated by `ameba --gen-config`\n# on 2026-04-02 23:47:16 UTC using Ameba version 1.6.4.\n# The point is for the user to remove these configuration records\n# one by one as the reported problems are removed from the code base.\n\n# Problems found: 92\n# Run `ameba --only Lint/UselessAssign` for details\nLint/UselessAssign:\n  Description: Disallows useless variable assignments\n  ExcludeTypeDeclarations: true\n  Enabled: true\n  Severity: Warning\n\n# Problems found: 9\n# Run `ameba --only Lint/Typos` for details\nLint/Typos:\n  Description: Reports typos found in source files\n  FailOnError: false\n  Excluded:\n    - spec/lucky/text_helpers/truncate_spec.cr\n    - spec/lucky/text_helpers/excerpts_spec.cr\n    - spec/lucky/secure_headers_spec.cr\n  Enabled: true\n  Severity: Warning\n"
  },
  {
    "path": ".crystal-version",
    "content": "1.15.1\n"
  },
  {
    "path": ".github/FUNDING.yml",
    "content": "# These are supported funding model platforms\n\ngithub: [paulcsmith, jwoertink]\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/bug_report.md",
    "content": "---\nname: Bug report\nabout: Create a report to help us improve\ntitle: ''\nlabels: bug\nassignees: ''\n\n---\n\n**Describe the bug**\nA clear and concise description of what the bug is.\n\n**To Reproduce**\nSteps to reproduce the behavior:\n\n> Example:\n\n> 1. Generate a Lucky project '...'\n> 2. Generate a route with '...'\n> 3. See error 'paste error message'\n\n**Expected behavior**\nA clear and concise description of what you expected to happen.\n\n**Screenshots/code**\nIf applicable, add screenshots/code samples to help explain your problem.\n\n**Versions (please complete the following information):**\n - Lucky version (check in shard.lock):\n - Crystal version (`crystal --version`):\n - OS:\n\n**Additional context**\nAdd any other context about the problem here.\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/config.yml",
    "content": "blank_issues_enabled: true\ncontact_links:\n  - name: Request Feature\n    url: https://github.com/luckyframework/lucky/discussions/new?category_id=66231\n    about: Request a feature in the discussion forum\n  - name: Visit Forum\n    url: https://github.com/luckyframework/lucky/discussions\n    about: Ask questions and discuss with other community members\n  - name: Chat\n    url: https://luckyframework.org/chat\n    about: Chat with the community\n"
  },
  {
    "path": ".github/workflows/ci.yml",
    "content": "name: Lucky CI\n\non:\n  push:\n    branches: [main]\n  pull_request:\n    branches: \"*\"\n\njobs:\n  check_format:\n    strategy:\n      fail-fast: false\n    runs-on: ubuntu-latest\n    continue-on-error: false\n    steps:\n      - uses: actions/checkout@v6\n      - uses: crystal-lang/install-crystal@v1\n        with:\n          crystal: latest\n      - name: Install shards\n        run: shards install\n      - name: Format\n        run: crystal tool format --check\n      - name: Lint\n        run: ./bin/ameba\n  specs:\n    strategy:\n      fail-fast: false\n      matrix:\n        os:\n          - ubuntu-latest\n          - macos-latest\n          - windows-latest\n        shard_file:\n          - shard.yml\n        crystal_version:\n          - 1.16.3\n          - latest\n        experimental:\n          - false\n        include:\n          - shard_file: shard.edge.yml\n            crystal_version: latest\n            experimental: true\n            os: ubuntu-latest\n          - shard_file: shard.override.yml\n            crystal_version: nightly\n            experimental: true\n            os: ubuntu-latest\n    runs-on: ${{ matrix.os }}\n    continue-on-error: ${{ matrix.experimental }}\n    steps:\n      - uses: actions/checkout@v6\n      - uses: crystal-lang/install-crystal@v1\n        with:\n          crystal: ${{matrix.crystal_version}}\n      - name: Install shards\n        run: shards install --skip-postinstall --skip-executables\n        env:\n          SHARDS_OVERRIDE: ${{ matrix.shard_file }}\n      - name: Run tests\n        run: crystal spec\n"
  },
  {
    "path": ".github/workflows/docs.yml",
    "content": "name: Deploy docs\n\non:\n  push:\n    branches: [main]\n\njobs:\n  deploy:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v6\n        with:\n          persist-credentials: false\n      - uses: crystal-lang/install-crystal@v1\n        with:\n          crystal: latest\n      - name: \"Install shards\"\n        run: shards install\n      - name: \"Generate docs\"\n        run: crystal docs\n      - name: Deploy to GitHub Pages\n        uses: peaceiris/actions-gh-pages@v4\n        with:\n          github_token: ${{ secrets.GITHUB_TOKEN }}\n          publish_dir: ./docs\n"
  },
  {
    "path": ".gitignore",
    "content": "/docs/\n/libs/\n/lib/\n/bin/ameba\n/bin/ameba.*\n/tmp/\n/.shards/\nserver\n\n# Libraries don't need dependency lock\n# Dependencies will be locked in application that uses them\n/shard.lock\n\n.DS_Store\n\n"
  },
  {
    "path": ".prettierrc",
    "content": "{\n  \"printWidth\": 80,\n  \"tabWidth\": 2,\n  \"singleQuote\": true,\n  \"semi\": false,\n  \"bracketSpacing\": false,\n  \"quoteProps\": \"consistent\",\n  \"trailingComma\": \"none\",\n  \"arrowParens\": \"avoid\"\n}\n"
  },
  {
    "path": "CHANGELOG.md",
    "content": "### Changes in 1.4.0\n\n- Fixed: `Lucky::Response` require for defining `debug_message` on custom response types. [#1927](https://github.com/luckyframework/lucky/pull/1927)\n- Fixed: Compilation error on Windows. [#1929](https://github.com/luckyframework/lucky/pull/1929)\n- Updated: `Lucky::UploadedFile` now checks for the file being empty. [#1942](https://github.com/luckyframework/lucky/pull/1942)\n- Updated: `Lucky::BaseHTTPClient` now allows `IO` in `exec_raw`. [#1944](https://github.com/luckyframework/lucky/pull/1944)\n- Added: new Rate Limiting for actions. [#1945](https://github.com/luckyframework/lucky/pull/1945)\n- Fixed: params nil value when passing a query param key without a value. [#1946](https://github.com/luckyframework/lucky/pull/1946)\n- Refactor: added new `Lucky::Serialzable` module to replace `Lucky::Serializer` class allowing you to make your serializers structs if you wanted. [#1947](https://github.com/luckyframework/lucky/pull/1947)\n- Fixed: params no longer raise when passing empty nested params. [#1950](https://github.com/luckyframework/lucky/pull/1950)\n- Updated: Added method override to allow passing a NamedTuple to `Lucky::BaseHTTPClient#exec`. [#1952](https://github.com/luckyframework/lucky/pull/1952)\n- Fixed: error with `send_json` spec method when your response had the `status` key. [#1954](https://github.com/luckyframework/lucky/pull/1954)\n- Updated: content-type check to match the RFC with performance improvement. [#1955](https://github.com/luckyframework/lucky/pull/1955)\n- Updated: avoid intermediate strings for performance improvement. [#1958](https://github.com/luckyframework/lucky/pull/1958)\n- Updated: added lots of extra types throughout the codebase. [#1959](https://github.com/luckyframework/lucky/pull/1959)\n- Updated: All bash scripts are now written in Crystal for better cross-platform support. [#875 in LuckyCLI](https://github.com/luckyframework/lucky_cli/pull/875)\n- Fixed: PG issue on Windows. [#1071 in Avram](https://github.com/luckyframework/avram/pull/1071)\n- Added: support for Crystal 1.16 by removing variables that use `?`. [#1083 in Avram](https://github.com/luckyframework/avram/pull/1083)\n- Added: null sorting to `order_by`. [#1081 in Avram](https://github.com/luckyframework/avram/pull/1081)\n- Fixed: issue with nested array params passed to operation attributes. [#1085 in Avram](https://github.com/luckyframework/avram/pull/1085)\n- Fixed: operations now raise an error when using the wrong callback for that operation. [#1086 in Avram](https://github.com/luckyframework/avram/pull/1086)\n- Updated: Using the query bulk update will now update `updated_at`. [#1091 in Avram](https://github.com/luckyframework/avram/pull/1091)\n- Refactor: all query `where_*` methods are now moved to the `join_*` methods. [#1090 in Avram](https://github.com/luckyframework/avram/pull/1090)\n- Added: ability to set separate reader and writer databases. [#1089 in Avram](https://github.com/luckyframework/avram/pull/1089)\n- Added: new table locking mechanism. [#1076 in Avram](https://github.com/luckyframework/avram/pull/1076)\n- Added: more performance improvements by avoiding intermediate strings in Avram. [#1095 in Avram](https://github.com/luckyframework/avram/pull/1095)\n- Added: more types in Avram. [#1094 in Avram](https://github.com/luckyframework/avram/pull/1094)\n- Added: types to Wordsmith. [#28 in Wordsmith](https://github.com/luckyframework/wordsmith/pull/28)\n- Updated: Wordsmith codebase to remove redundant to_s calls. [#31 in Wordsmith](https://github.com/luckyframework/wordsmith/pull/31)\n- Added: new `IO` method overloads to Wordsmith. [#32 in Wordsmith](https://github.com/luckyframework/wordsmith/pull/32)\n- Fixed: `LuckyEnv` now raises when it catches duplicate keys in your .env file. [#36 in LuckyEnv](https://github.com/luckyframework/lucky_env/pull/36)\n- Added: dynamic environment aware .env loading. [#37 in LuckyEnv](https://github.com/luckyframework/lucky_env/pull/37)\n- Added: type-safe ENV method generation. [#39 in LuckyEnv](https://github.com/luckyframework/lucky_env/pull/39)\n\n\n\n### Changes in 1.3.0 (2024-11-02)\n\n- Fixed: re-compilation time not resetting. [#1894](https://github.com/luckyframework/lucky/pull/1894)\n- Fixed: missing `--with-page` flag in `gen.action.browser` task help text. [#1895](https://github.com/luckyframework/lucky/pull/1895)\n- Refactor: lucky routes to pull from the router instead of holding a second array in memory. [#1898](https://github.com/luckyframework/lucky/pull/1898)\n- Updated: error message when booting Lucky and the watcher config is missing. [#1896](https://github.com/luckyframework/lucky/pull/1896)\n- Fixed: compatibility with Crystal 1.13.x and later. [#1900](https://github.com/luckyframework/lucky/pull/1900)\n- Fixed: invalid query string for array params. [#1908](https://github.com/luckyframework/lucky/pull/1908)\n- Refactor: make `form_method` public. [#1915](https://github.com/luckyframework/lucky/pull/1915)\n- Added: new `MaximumRequestSizeHandler`. [#1916](https://github.com/luckyframework/lucky/pull/1916)\n- Fixed: deprecation warning for Crystal 1.13. [#1918](https://github.com/luckyframework/lucky/pull/1918)\n- Fixed: compilation error with updated ExceptionPage. [#1910](https://github.com/luckyframework/lucky/pull/1910)\n- Updated: to latest ExceptionPage. [#1921](https://github.com/luckyframework/lucky/pull/1921)\n- Added: support for Windows with the core `Lucky` shard. [#1919](https://github.com/luckyframework/lucky/pull/1919)\n- Refactor: all built-in tasks are no longer precompiled. [#1919](https://github.com/luckyframework/lucky/pull/1919)\n- Updated: url link to PostCSS in generated webpack.mix. [65f2d3df in LuckyCLI](https://github.com/luckyframework/lucky_cli/commit/65f2d3dfd0c572ba8a8015c36499ca2f61c742a8)\n- Added: ability to run uncompiled tasks from Crystal files. [#871 in LuckyCLI](https://github.com/luckyframework/lucky_cli/pull/871)\n- Refactor: generated page templates with clear args. [#935 in Avram](https://github.com/luckyframework/avram/pull/935)\n- Fixed: issue with type casting on `select_min/max`. [#1040 in Avram](https://github.com/luckyframework/avram/pull/1040)\n- Added: `have_any` criteria method for array queries. [#1042 in Avram](https://github.com/luckyframework/avram/pull/1042)\n- Added: `if_exists` option to DROP TABLE queries. [#1043 in Avram](https://github.com/luckyframework/avram/pull/1043)\n- Added: support for camel case or snake case on `gen.migration` task. [#1046 in Avram](https://github.com/luckyframework/avram/pull/1046)\n- Added: array param options for raw where query values. [#1044 in Avram](https://github.com/luckyframework/avram/pull/1044)\n- Updated: error message when there's issues connecting to your DB. [#1047 in Avram](https://github.com/luckyframework/avram/pull/1047)\n- Updated: DeleteOperations to run in a transaction. [#1055 in Avram](https://github.com/luckyframework/avram/pull/1055)\n- Added: `have_error` expectation for specs with SaveOperations. [#1062 in Avram](https://github.com/luckyframework/avram/pull/1062)\n- Refactor: use `postgres` as the default database for `db.create` and `db.drop` tasks. [#1058 in Avram](https://github.com/luckyframework/avram/pull/1058)\n- Refactor: all calls to tables and column names are now quoted in constructed SQL queries. Allows for defining columns that may conflict with reserved SQL words. [#1059 in Avram](https://github.com/luckyframework/avram/pull/1059)\n- Added: `validate_url_format` for operation validations. [#1065 in Avram](https://github.com/luckyframework/avram/pull/1065)\n- Refactor: built-in db related tasks are no longer precompiled. [#1069 in Avram](https://github.com/luckyframework/avram/pull/1069)\n- Refacor: built-in `gen.email` task is no longer precompiled. [#95 in Carbon](https://github.com/luckyframework/carbon/pull/95)\n\n\n\n### Changes in 1.2.0 (2024-04-21)\n\n- Updated: exception page which includes syntax highlighting now. [#1850](https://github.com/luckyframework/lucky/pull/1850)\n- Refactor: Action call body with a clear compile error and ability to do short-circuit returns. [#1857](https://github.com/luckyframework/lucky/pull/1857)\n- Added: new `-n` flag to `lucky gen.secret_key` to configure key size. [#1856](https://github.com/luckyframework/lucky/pull/1856)\n- Refactor: routes with query params optimization. [#1854](https://github.com/luckyframework/lucky/pull/1854)\n- Deprecated: Lucky now prefers Crystal 1.10 or later\n- Added: overload to `distance_of_time_in_words` that takes a `Time::Span`. [#1860](https://github.com/luckyframework/lucky/pull/1860)\n- Added: compilation time to terminal output during development. [#1855](https://github.com/luckyframework/lucky/pull/1855)\n- Refactor: lucky gen tasks are now using [LuckyTemplate](https://github.com/luckyframework/lucky_template) for Windows compatibility. [#1861](https://github.com/luckyframework/lucky/pull/1861)\n- Added: `Date` header to response output. [#1866](https://github.com/luckyframework/lucky/pull/1866)\n- Added: LuckyCLI releases now include binary packages in the release since v1.1.1\n- Fixed: entering `Y` or `y` in LuckyCLI Wizard. [#857 in LuckyCLI](https://github.com/luckyframework/lucky_cli/pull/857)\n- Updated: Generated Github Actions CI file with latest actions versions. Nexploit NPM is no longer required for SecTester. [#859 in LuckyCLI](https://github.com/luckyframework/lucky_cli/pull/859)\n- Added: new Scoop package installer for installing LuckyCLI on Windows. [Scoop it up](https://github.com/luckyframework/scoop-bucket)\n- Fixed: a few compatibility issues with CockroachDB. [#980 in Avram](https://github.com/luckyframework/avram/pull/980)\n- Added: `attrs` override for boolean attributes on `select_input` and `multi_select_input`. [#981 in Avram](https://github.com/luckyframework/avram/pull/981)\n- Updated: DB connections now support a retry. [#990 in Avram](https://github.com/luckyframework/avram/pull/990)\n- Updated: `select_input` and `multi_select_input` to include the `id` attribute. [#992 in Avram](https://github.com/luckyframework/avram/pull/992)\n- Added: `if_not_exists` and `if_exists` options to migration `create` and `alter` macros. [#993 in Avram](https://github.com/luckyframework/avram/pull/993)\n- Added: block overload for `preload` `has_one` methods. [#994 in Avram](https://github.com/luckyframework/avram/pull/994)\n- Fixed: how the `verify_connection` task runs to catch some edge cases of it not working. [#995 in Avram](https://github.com/luckyframework/avram/pull/995)\n- Updated: migrator commands no longer use `Process.run` with the `shell` option. [#997 in Avram](https://github.com/luckyframework/avram/pull/997)\n- **Breaking change** Refactor: all Avram models now use `DB::Serializable` instead of the deprecated `DB.mapping`. [#996 in Avram](https://github.com/luckyframework/avram/pull/996)\n- Updated: all \"UPDATE\" statements with a subquery are now wrapped allowing bulk updates when a join exists. [#998 in Avram](https://github.com/luckyframework/avram/pull/998)\n- Added: an option to `add_belongs_to` to disable adding an index. [#1002 in Avram](https://github.com/luckyframework/avram/pull/1002)\n- Added: `create_sequence` and `drop_sequence` migration methods. [#1003 in Avram](https://github.com/luckyframework/avram/pull/1003)\n- Added: `refresh_view` query method and materialized view option for materialized view models. [#1004 in Avram](https://github.com/luckyframework/avram/pull/1004)\n- Added: support for `String` primary keys in models. [#1000 in Avram](https://github.com/luckyframework/avram/pull/1000)\n- Added: new `String`, `Int`, and `Float` criteria methods. `length`, `reverse`, `abs`, `ceil`, and `floor` respectively. [#1010 in Avram](https://github.com/luckyframework/avram/pull/1010)\n- Added: an option to specify an alias for join tables with `Avram::Join`. [#1013 in Avram](https://github.com/luckyframework/avram/pull/1013)\n- Added: support for `Array(SomeEnum)` columns. [#1009 in Avram](https://github.com/luckyframework/avram/pull/1009)\n- Added: support for custom column converters. [#1009 in Avram](https://github.com/luckyframework/avram/pull/1009)\n- Added: new `base_query_class` arg for `has_many` associations to specify which query class to use when an association is called. [#1006 in Avram](https://github.com/luckyframework/avram/pull/1006)\n- Added: new `JSON::Any` critera methods `has_key`, `has_any_keys`, and `has_all_keys`. [#1015 in Avram](https://github.com/luckyframework/avram/pull/1015)\n- Added: more new `JSON::Any` criteria methods `includes` and `in`. [#1016 in Avram](https://github.com/luckyframework/avram/pull/1016)\n- Added: new `IGNORE` constant which is an instance of `Avram::Nothing` used for ignoring column updates in operations. [#1018 in Avram](https://github.com/luckyframework/avram/pull/1018)\n- Added: new `String` criteria methods to convert to `to_tsquery` and `to_tsvector` along with a `match` for simple full-text search. [#1019 in Avram](https://github.com/luckyframework/avram/pull/1019)\n- Added: new Firefox driver for LuckyFlow. [#162 in LuckyFlow](https://github.com/luckyframework/lucky_flow/pull/162)\n- Added: support for attachments in Carbon. [#88 in Carbon](https://github.com/luckyframework/carbon/pull/88)\n- Fixed: Carbon emails can be JSON serialized. [#92 in Carbon](https://github.com/luckyframework/carbon/pull/92)\n- Fixed: the location where Carbon emails are generated with `lucky gen.email`. [#93 in Carbon](https://github.com/luckyframework/carbon/pull/93)\n- Updated: CarbonSMTP Adapter support on Email shard dependency. [#18 in CarbonSMTPAdapter](https://github.com/luckyframework/carbon_smtp_adapter/pull/18)\n- Added: support for attachments in CarbonSMTPAdapter. [#20 in CarbonSMTPAdapter](https://github.com/luckyframework/carbon_smtp_adapter/pull/20)\n\n\n\n### Changes in 1.1.0 (2023-10-29)\n\n- Fixed: generated docs on `match` macro. [#1790](https://github.com/luckyframework/lucky/pull/1790)\n- Fixed: memoized memthods that use an external arg name. [#1817](https://github.com/luckyframework/lucky/pull/1817)\n- Added: configurable timeout for browser reload in development. [#1822](https://github.com/luckyframework/lucky/pull/1822)\n- Added: config option to silence stacktraces from logs. [#1821](https://github.com/luckyframework/lucky/pull/1821)\n- Added: new `--with-page` flag for `lucky gen.action.browser` task. [#1819](https://github.com/luckyframework/lucky/pull/1819)\n- Refactor: `RequestBodyReader` with a performance boost in actions. [#1826](https://github.com/luckyframework/lucky/pull/1826)\n- Refactor: action route generation with a performance boost. [#1829](https://github.com/luckyframework/lucky/pull/1829)\n- Refactor: `html` macro for actions with simpler code. [#1836](https://github.com/luckyframework/lucky/pull/1836)\n- Added: config option to configure default redirect statuses. [#1838](https://github.com/luckyframework/lucky/pull/1838)\n- Refactor: The `lucky` CLI no longer compiles when running `-h`. [#771 in LuckyCLI](https://github.com/luckyframework/lucky_cli/pull/771)\n- Added: A new `lucky tasks` CLI command added to display available tasks.\n- Fixed: The `lucky dev` command now shows in the CLI help menu.\n- Updated: generated apps `docker-compose.yml`. [#799 in LuckyCLI](https://github.com/luckyframework/lucky_cli/pull/799)\n- Refactor: LuckyCLI app generation no longer ueses the `crystal init app` command. [#800 in LuckyCLI](https://github.com/luckyframework/lucky_cli/pull/800)\n- Added: initial work on getting the LuckyCLI to work on Windows. [#804 in LuckyCLI](https://github.com/luckyframework/lucky_cli/pull/804)\n- Refactor: pg client tools are no longer a requirement for Avram. [#942 in Avram](https://github.com/luckyframework/avram/pull/942)\n- Fixed: queries not logging when turning up logger level. [#945 in Avram](https://github.com/luckyframework/avram/pull/945)\n- Fixed: error message when using `validate_numeric` and passing a float. [#948 in Avram](https://github.com/luckyframework/avram/pull/948)\n- Added: JSON serialized Array columns. [#949 in Avram](https://github.com/luckyframework/avram/pull/949)\n- Updated: to PG v0.27.0. [#951 in Avram](https://github.com/luckyframework/avram/pull/951)\n- Fixed: saving `nil` values when using Upsert operations. [#957 in Avram](https://github.com/luckyframework/avram/pull/957)\n- Refactor: escape hatch to allow storing empty strings from param values. [#956 in Avram](https://github.com/luckyframework/avram/pull/956)\n- Updated: the `validate_size_of` validation can now validate arrays. [#960 in Avram](https://github.com/luckyframework/avram/pull/960)\n- Added: PG extension `PG::NumericFloatConverter` for converting `Float64` values from custom SQL. [#958 in Avram](https://github.com/luckyframework/avram/pull/958)\n- Refactor: SaveOperation `insert` and `update` with small performance boost. [#962 in Avram](https://github.com/luckyframework/avram/pull/962)\n- Added: new `X_preloaded?` methods to check if an association has already been preloaded. [#961 in Avram](https://github.com/luckyframework/avram/pull/961)\n- **Breaking change** Removed: Avram's initialized logging and subscribing to Pulsar events by default. Each app will add this in to control when/if it happens. [#967 in Avram](https://github.com/luckyframework/avram/pull/967)\n- Fixed: handing bytes outside of ASCII range for `bytea` columns. [#975 in Avram](https://github.com/luckyframework/avram/pull/975)\n- Added: support for other postgres index types other than btree. [#971 in Avram](https://github.com/luckyframework/avram/pull/971)\n- Updated: SecTester to v1.6 with new Crystal repeater. [#30 in LuckySecTester](https://github.com/luckyframework/lucky_sec_tester/pull/30)\n- Updated: Carbon is now supported on Windows. [#82 in Carbon](https://github.com/luckyframework/carbon/pull/82)\n- Updated: LuckyFlow is now supported on Windows. [#156 in LuckyFlow](https://github.com/luckyframework/lucky_flow/pull/156)\n- Fixed: support for Chrome v115+ with chromedriver in LuckyFlow. [#159 in LuckyFlow](https://github.com/luckyframework/lucky_flow/pull/159)\n- Added: Brand new `LuckyHXML` shard for generating [Hyperview](https://hyperview.org/) apps from Lucky. [View Shard](https://github.com/luckyframework/lucky_hxml)\n- Added: Brand new `LuckyTemplate` shard for better template generation across platforms. [View Shard](https://github.com/luckyframework/lucky_template)\n- Updated: LuckyTask is now supported on Windows. [#14 in LuckyTask](https://github.com/luckyframework/lucky_task/pull/14)\n- **Breaking change** Refacor: LuckyTask API to avoid conflicts with common names used for CLI args. [#25 in LuckyTask](https://github.com/luckyframework/lucky_task/pull/25)\n- Updated: Habitat is now supported on Windows. [#80 in Habitat](https://github.com/luckyframework/habitat/pull/80)\n- Updated: LuckyEnv is now supported on Windows. [#28 in LuckyEnv](https://github.com/luckyframework/lucky_env/pull/28)\n- Added: ability to reference previous ENV vars as variables in the `.env` file. [#31 in LuckyEnv](https://github.com/luckyframework/lucky_env/pull/31)\n- Updated: LuckyRouter is now supported on Windows. [#63 in LuckyRouter](https://github.com/luckyframework/lucky_router/pull/63)\n- Updated: LuckyCache is now supported on Windows. [#13 in LuckyCache](https://github.com/luckyframework/lucky_cache/pull/13)\n- Updated: Pulsar is now supported on Windows. [#22 in Pulsar](https://github.com/luckyframework/pulsar/pull/22)\n- Updated: Wordsmith is now supported on Windows. [#25 in Wordsmith](https://github.com/luckyframework/wordsmith/pull/25)\n\n\n### Changes in 1.0.0 (2023-03-12)\n\n- Added: Inline SVG files. [#1761](https://github.com/luckyframework/lucky/pull/1761)\n- Added: `attrs` option to `sumbit` tag helper. [#1759](https://github.com/luckyframework/lucky/pull/1759)\n- Fixed: `previous_url` helper when the referrer is the current page. [#1743](https://github.com/luckyframework/lucky/pull/1743)\n- Fixed: issue with SSE Watcher for live reloading. [#1748](https://github.com/luckyframework/lucky/pull/1748)\n- Fixed: issue with nil values in JSON params. [#1762](https://github.com/luckyframework/lucky/pull/1762)\n- Fixed: issue with block HTML tags using a custom wrapper. [#1763](https://github.com/luckyframework/lucky/pull/1763)\n- Removed: the optional `SkipUniqueRoute` check. All routes must be unique. [#1764](https://github.com/luckyframework/lucky/pull/1764)\n- Updated: Accept header handling for more robustness. [#1769](https://github.com/luckyframework/lucky/pull/1769)\n- Updated: Crystal minimum supported version to be 1.6.0 or later. [#1774](https://github.com/luckyframework/lucky/pull/1774)\n- Updated: the Lucky watcher to always use the specified host in development. [#1786](https://github.com/luckyframework/lucky/pull/1786)\n- Added: ENV `DEV_HOST` for overriding the development host with an ENV var. [#1788](https://github.com/luckyframework/lucky/pull/1788)\n- Updated: generated Dockerfile for development with several fixes. [#787 in LuckyCLI](https://github.com/luckyframework/lucky_cli/pull/787), [#793 in LuckyCLI](https://github.com/luckyframework/lucky_cli/pull/793)\n- Updated: generated `current_user` method is memoized by default now. [#508 in LuckyCLI](https://github.com/luckyframework/lucky_cli/pull/508)\n- Updated: generated apps are compliant with Crystal 1.7+ [#790 in LuckyCLI](https://github.com/luckyframework/lucky_cli/pull/790)\n- Fixed: casting issue with AvramParams and Array values. [#895 in Avram](https://github.com/luckyframework/avram/pull/895)\n- Fixed: multi select options with Arrays from columns. [#897 in Avram](https://github.com/luckyframework/avram/pull/897)\n- Fixed: submitting only a file in a form with no other permitted params. [#894 in Avram](https://github.com/luckyframework/avram/pull/894)\n- Updated: avoid refetching associations that have already been preloaded. [#901 in Avram](https://github.com/luckyframework/avram/pull/901)\n- **Breaking change** Updated: Always pass the record to DeleteOperation blocks. [#887 in Avram](https://github.com/luckyframework/avram/pull/887)\n- Updated: more support for CockroachDB with `.any?` query method. [#900 in Avram](https://github.com/luckyframework/avram/pull/900)\n- Added: new `updated?` and `created?` SaveOperation methods. [#904 in Avram](https://github.com/luckyframework/avram/pull/904)\n- Added: new function criteria with new `trim`, and `as_date` query methods. [#912 in Avram](https://github.com/luckyframework/avram/pull/912)\n- Updated: more support for CockroachDB with `select_sum` query method. [#921 in Avram](https://github.com/luckyframework/avram/pull/921)\n- Updated: better Int column support for CockroachDB. [#920 in Avram](https://github.com/luckyframework/avram/pull/920)\n- Fixed: compilation issue with preloading optional associations. [#925 in Avram](https://github.com/luckyframework/avram/pull/925)\n- Updated: Authentic with better primary key agnostic support. [#77 in Authentic](https://github.com/luckyframework/authentic/pull/77)\n- Updated: Small performance boost with the router. [#60 in LuckyRouter](https://github.com/luckyframework/lucky_router/pull/60)\n- Updated: to lastest Bright security SecTester. [#27 in LuckySecTester](https://github.com/luckyframework/lucky_sec_tester/pull/27)\n\n\n### Changes in 1.0.0-rc1 (2022-10-2)\n\n- **Breaking change** Removed: Avram is no longer a dependency of Lucky. [#1620](https://github.com/luckyframework/lucky/pull/1620)\n- Fixed: path output when generating a component. [#1694](https://github.com/luckyframework/lucky/pull/1694)\n- Updated: Lucky to run on Crystal 1.4 or later as a minimum. [#1696](https://github.com/luckyframework/lucky/pull/1696)\n- Removed: the deprecated verifier code. [#1697](https://github.com/luckyframework/lucky/pull/1697)\n- Added: new Live-Reload for `lucky watch` command as an alternative to browsersync. [#1693](https://github.com/luckyframework/lucky/pull/1693)\n- Updated: Error actions now have access to `params`. [#1716](https://github.com/luckyframework/lucky/pull/1716)\n- Updated: `lucky exec` allowing you to set your editor by `$EDITOR` env. [#1715](https://github.com/luckyframework/lucky/pull/1715)\n- Added: ability to set custom content-type responses in actions. [#1719](https://github.com/luckyframework/lucky/pull/1719)\n- Updated: exception page so javascript is no longer required for it. [#1723](https://github.com/luckyframework/lucky/pull/1723)\n- Updated: the error message when you forget to add a type to args in a `memoize` method. [#1726](https://github.com/luckyframework/lucky/pull/1726)\n- Added: new `exec_raw` escape hatch method for `ApiClient` to submit raw param data. [#1728](https://github.com/luckyframework/lucky/pull/1728)\n- Fixed: `memoize` methods can now end in `?` or `!`. [#1727](https://github.com/luckyframework/lucky/pull/1727)\n- Added: Experimental option to have type-safe assets built using [Vitejs](https://vitejs.dev/). [#1729](https://github.com/luckyframework/lucky/pull/1729)\n- **Breaking change** Removed: default integration with Turbolinks. [#1737](https://github.com/luckyframework/lucky/pull/1737)\n- Updated: generated apps using LuckyFlow are compatible with latest Flow updated. [#760 in Lucky CLI](https://github.com/luckyframework/lucky_cli/pull/760)\n- Updated: generated apps that specify \"no auth\" no longer include Authentic. [#761 in Lucky CLI](https://github.com/luckyframework/lucky_cli/pull/761)\n- Updated: generated apps now have the ability to remove Avram. [#764 in Lucky CLI](https://github.com/luckyframework/lucky_cli/pull/764)\n- Removed: the tasks file generation task. [#770 in Lucky CLI](https://github.com/luckyframework/lucky_cli/pull/770)\n- Fixed: issue with generated apps that would try to encrypt bad passwords. [#773 in Lucky CLI](https://github.com/luckyframework/lucky_cli/pull/773)\n- Updated: the error message when generating an app in a directory that doesn't exist. [#772 in Lucky CLI](https://github.com/luckyframework/lucky_cli/pull/772)\n- Fixed: issue with generated Dockerfile causing it to not boot. [#775 in Lucky CLI](https://github.com/luckyframework/lucky_cli/pull/775)\n- Updated: generated apps no longer include Turbolinks by default. [#779 in Lucky CLI](https://github.com/luckyframework/lucky_cli/pull/779)\n- **Breaking change** Added: Lucky as an optional extension to Avram. [#772 in Avram](https://github.com/luckyframework/avram/pull/772)\n- **Breaking change** Added: Array param support to operations and changed how `Avram::Params` is constructed. [#847 in Avram](https://github.com/luckyframework/avram/pull/847)\n- Updated: compile-time catch when specifying both `default` and `fill_existing_with` values in migrations. [#852 in Avram](https://github.com/luckyframework/avram/pull/852)\n- Added: new `change_default` method for migrations to change the default value of a column. [#853 in Avram](https://github.com/luckyframework/avram/pull/853)\n- Updated: references to `uuid-ossp` in favor of `pgcrypto`. [#855 in Avram](https://github.com/luckyframework/avram/pull/855)\n- Added: the ability to use the `@[Flags]` annotation and `Int64` for enums. [#856 in Avram](https://github.com/luckyframework/avram/pull/856)\n- Added: new `extract()` method for timestamp queries. [#861 in Avram](https://github.com/luckyframework/avram/pull/861)\n- Added: new `Bytes` column support for `bytea` columns. [#860 in Avram](https://github.com/luckyframework/avram/pull/860)\n- **Breaking change** Updated: named args to take precedence over param values when using Operations. [#862 in Avram](https://github.com/luckyframework/avram/pull/862)\n- Added: new `grouped_checkbox` method for use with Array attributes. [#863 in Avram](https://github.com/luckyframework/avram/pull/863)\n- Fixed: `change_type` so it can change from one nilable type to another nilable type. [#869 in Avram](https://github.com/luckyframework/avram/pull/869)\n- Updated: Factory callbacks now return `self`. [#866 in Avram](https://github.com/luckyframework/avram/pull/866)\n- Fixed: JSON::Any columns that are nilable to return nil properly. [#878 in Avram](https://github.com/luckyframework/avram/pull/878)\n- Updated: the arg names to `validate_numeric` to avoid confusion. [#867 in Avram](https://github.com/luckyframework/avram/pull/867)\n- Added: the ability to customize the session key used for sign-in with Authentic. [#75 in Authentic](https://github.com/luckyframework/authentic/pull/75)\n- **Breaking change** Added: new Driver registration for LuckyFlow allowing for custom web drivers. [#133 in LuckyFlow](https://github.com/luckyframework/lucky_flow/pull/133)\n- **Breaking change** Updated: LuckyFlow to not depend directly on Selenium. [#135 in LuckyFlow](https://github.com/luckyframework/lucky_flow/pull/135)\n- Added: `find_xpath` method for LuckyFlow drivers. [#141 in LuckyFlow](https://github.com/luckyframework/lucky_flow/pull/141)\n- Added: \"Webless\" driver support to LuckyFlow. [#137 in LuckyFlow](https://github.com/luckyframework/lucky_flow/pull/137)\n- **Breaking change** Updated: and abstracted LuckyFlow extensions for Lucky, Avram, and Authentic. [#152 in LuckyFlow](https://github.com/luckyframework/lucky_flow/pull/152)\n- Added: the ability to add mailer layouts for HTML templates. [#70 in Carbon](https://github.com/luckyframework/carbon/pull/70)\n- Added: `before_send` and `after_send` callbacks for emails with delivery short circuiting. [#72 in Carbon](https://github.com/luckyframework/carbon/pull/72)\n- Fixed: issue when generating a new email and using the word \"Email\" duplicating it. [#74 in Carbon](https://github.com/luckyframework/carbon/pull/74)\n- Updated: LuckySecTester is officially released with v0.1.0. [View Shard](https://github.com/luckyframework/lucky_sec_tester)\n- Added: new `ws` CLI utility with Wordsmith allowing you to process words from the CLI. [#23 in Wordsmith](https://github.com/luckyframework/wordsmith/pull/23)\n\n\n### Changes in 0.30.1 (2022-04-17)\n\n- Fixed: compilation issue when using the `Lucky::Subdomain` module. [#1686](https://github.com/luckyframework/lucky/pull/1686)\n\n### Changes in 0.30.0 (2022-04-07)\n\n- Fixed: logging empty request_id [#1630](https://github.com/luckyframework/lucky/pull/1630)\n- Updated: references to community chat and general housekeeping [#1633](https://github.com/luckyframework/lucky/pull/1633)\n- Updated: error message on incorrect usage of `default_format` [#1638](https://github.com/luckyframework/lucky/pull/1638)\n- Updated: error message on incorrect usage of routes [#1639](https://github.com/luckyframework/lucky/pull/1639)\n- Added: new `previous_url` page helper method. [#1641](https://github.com/luckyframework/lucky/pull/1641)\n- Added: ability to make requests to actions directly in specs. [#1644](https://github.com/luckyframework/lucky/pull/1644)\n- Fixed: `time_ago_in_words` always rounding down. [#1651](https://github.com/luckyframework/lucky/pull/1651)\n- Updated: Turbolinks redirect support to allow for replace in the future. [#1650](https://github.com/luckyframework/lucky/pull/1650)\n- Updated: compiling \"done\" notice to be more prominent. [#1653](https://github.com/luckyframework/lucky/pull/1653)\n- Added: ability to enable/disable forgery protection app-wide. [#1657](https://github.com/luckyframework/lucky/pull/1657)\n- **Breaking change** Fixed: parsing JSON values for params. [#1661](https://github.com/luckyframework/lucky/pull/1661)\n- **Breaking change** Updated: Lucky::ForceSSLHandler middleware to match X-Forwarded-Proto == https exactly, which previously accepted uppercase and only required https to be part of the value [#1662](https://github.com/luckyframework/lucky/pull/1662)\n- **Breaking change** Updated: the primary branch name from \"master\" to \"main\". [#1667](https://github.com/luckyframework/lucky/pull/1667)\n- Added: new Content-Security-Policy header module. [#1673](https://github.com/luckyframework/lucky/pull/1673)\n- Added: new `remote_ip` method with a config to customize the header lookup. [#1675](https://github.com/luckyframework/lucky/pull/1675)\n- **Breaking change** Updated: the remote_ip lookup to use the last valid IP instead of the first in the `X-Forward-For` list. [also #1675](https://github.com/luckyframework/lucky/pull/1675)\n- Updated: `MessageVerifier` to handle different types of token data. [#1674](https://github.com/luckyframework/lucky/pull/1674)\n- Added: `[]` and `[]=` Methods to `Lucky::CookieJar`. [#1678](https://github.com/luckyframework/lucky/pull/1678)\n- Fixed: issue with generated CI not connecting to postgres. [#719 in Lucky CLI](https://github.com/luckyframework/lucky_cli/pull/719)\n- Added: built-in process runner [Nox](https://github.com/matthewmcgarvey/nox). [#710 in Lucky CLI](https://github.com/luckyframework/lucky_cli/pull/710)\n- **Breaking change** Removed: built-in support for other process runners like Overmind through `lucky dev`. [#720](https://github.com/luckyframework/lucky_cli/pull/720)\n- Removed: references to TravisCI from old Crystal setup. [#722 in Lucky CLI](https://github.com/luckyframework/lucky_cli/pull/722)\n- Updated: default watcher port to use 3000 instead of 5000. [#727 in Lucky CLI](https://github.com/luckyframework/lucky_cli/pull/727)\n- Added: Docker setup for local development out-of-the-box. [#738 in Lucky CLI](https://github.com/luckyframework/lucky_cli/pull/738)\n- Fixed: ameba linting issue with new Lucky apps. [#739 in Lucky CLI](https://github.com/luckyframework/lucky_cli/pull/739)\n- Updated: default target name to be `app` instead of your app's name for easier building with Docker. [#742 in Lucky CLI](https://github.com/luckyframework/lucky_cli/pull/742)\n- Added: new integration with Bright Security (formerly Neuralegion) SecTester for Security specs out-of-the-box. [#743 in Lucky CLI](https://github.com/luckyframework/lucky_cli/pull/743)\n- Added: `random_order` for queries. [#765 in Avram](https://github.com/luckyframework/avram/pull/765)\n- Removed: old compile-time deprecation errors. [#744 in Avram](https://github.com/luckyframework/avram/pull/774)\n- Updated: running DB specs in a transaction instead of truncating. [#780 in Avram](https://github.com/luckyframework/avram/pull/780)\n- Added: `group_count` method for grouped count queries. [#778 in Avram](https://github.com/luckyframework/avram/pull/778)\n- Added: `AvramSlugify` is now integrated as `Avram::Slugify`. [#786 in Avram](https://github.com/luckyframework/avram/pull/786)\n- Fixed: JSON parsing to/from the DB when the value was a String but should have been a JSON object. [#806 in Avram](https://github.com/luckyframework/avram/pull/806)\n- Fixed: issue with some query cache methods not returning correctly. [#810 in Avram](https://github.com/luckyframework/avram/pull/810)\n- Added: helpful error when required association is not loaded in preload. [#812 in Avram](https://github.com/luckyframework/avram/pull/812)\n- Added: default time values in SQL for the default timestamps. [#820 in Avram](https://github.com/luckyframework/avram/pull/820)\n- Added: a unique index to the migrations table to avoid duplicate migration versions. [#815 in Avram](https://github.com/luckyframework/avram/pull/815)\n- Added: properties to error classes for better error customization with I18n. [#823 in Avram](https://github.com/luckyframework/avram/pull/823)\n- Added: new `generate` method for `Avram::Slugify` to generate a slug without setting a value. [#821 in Avram](https://github.com/luckyframework/avram/pull/821)\n- Added: `validate_format_of` and `validate_uniqueness_of` to `Avram::I18Backend` for better error message support. [#830 in Avram](https://github.com/luckyframework/avram/pull/830)\n- Added: new [LuckySecTester](https://github.com/luckyframework/lucky_sec_tester) shard as a thing wrapper around the [NeuraLegion SecTester](https://github.com/NeuraLegion/sec_tester)\n- Added: new task to generate email templates. [#60 in Carbon](https://github.com/luckyframework/carbon/pull/60)\n- Added: new `size` method to LuckyCache. [#10 in LuckyCache](https://github.com/luckyframework/lucky_cache/pull/10)\n- Added: `clear_subscribers` method to Pulsar to clear events of subscribers. [#18 in Pulsar](https://github.com/luckyframework/pulsar/pull/18)\n- Added: extra log metadata when using `emit()` with Dexter. [#45 in Dexter](https://github.com/luckyframework/dexter/pull/45)\n- Added: support for passing unsubscribe group data to Sendgrid. [#8 in Carbon Sendgrid Adapter](https://github.com/luckyframework/carbon_sendgrid_adapter/pull/8)\n\n\n### v0.29 (2021-11-30)\n\n- Fixed: the binary name generated for the `gen.secret_key` task. [#1556](https://github.com/luckyframework/lucky/pull/1556)\n- Updated: the `html_with_status` to allow Numbers, Symbols, and HTTP::Status. [#1568](https://github.com/luckyframework/lucky/pull/1568)\n- Updated: the `lucky routes` task to print within a table (again). Added a new flag option to print params. [#1569](https://github.com/luckyframework/lucky/pull/1569)\n- Added: new `path_without_query_params` Action method. [#1572](https://github.com/luckyframework/lucky/pull/1572)\n- Added: option to set a different manifest filename when not using laravel-mix. [#1578](https://github.com/luckyframework/lucky/pull/1578)\n- Fixed: an issue when passing optional params that were not a String. [#1582](https://github.com/luckyframework/lucky/pull/1582)\n- Fixed: Ameba always being installed even when your app doesn't depend on it. [#1589](https://github.com/luckyframework/lucky/pull/1589), [#736 in Avram](https://github.com/luckyframework/avram/pull/736), [#62 in Carbon](https://github.com/luckyframework/carbon/pull/62)\n- Added: support for using namespaces when generating resources from the CLI. [#1588](https://github.com/luckyframework/lucky/pull/1588)\n- Added: DeleteOperation when generating a model from the CLI. [#1594](https://github.com/luckyframework/lucky/pull/1594)\n- Fixed: verifying secret messages that may contain \"--\". [#1596](https://github.com/luckyframework/lucky/pull/1596)\n- Removed: `route` and `nested_route` macro helpers. [#1597](https://github.com/luckyframework/lucky/pull/1597)\n- Fixed: the use of boolean attributes rendering invalid values in HTML. [#1598](https://github.com/luckyframework/lucky/pull/1598)\n- Added: new Pulsar event to fire when an HTTP request is fully completed. [#1601](https://github.com/luckyframework/lucky/pull/1601)\n- Updated: the `lucky watch` task to be less greedy on recompiles. [#1604](https://github.com/luckyframework/lucky/pull/1604)\n- Added: the `ajax` desired client format option for actions. [#1603](https://github.com/luckyframework/lucky/pull/1603)\n- Added: a better help message for `lucky exec` to include options. [#1602](https://github.com/luckyframework/lucky/pull/1602)\n- Added: new `previous_page`, and `next_page` Paginator helper methods. [#1611](https://github.com/luckyframework/lucky/pull/1611)\n- Fixed: writing to the response body when making a HEAD call. [#1609](https://github.com/luckyframework/lucky/pull/1609)\n- **Breaking change** Removed: the `lucky build.release` task. [#1612](https://github.com/luckyframework/lucky/pull/1612)\n- Updated: Avram is no longer required for parsing params. [#1616](https://github.com/luckyframework/lucky/pull/1616)\n- **Breaking change** Removed: support for Crystal versions below 1.0.0. [#1618](https://github.com/luckyframework/lucky/pull/1618)\n- **Breaking change** Updated: the `Lucky::BaseAppServer#listen` method to be abstract. [#1622](https://github.com/luckyframework/lucky/pull/1622)\n- Added: new HTTP `context.request_id` and `RequestIdHandler`. [#1610](https://github.com/luckyframework/lucky/pull/1610)\n- Added: documentation clarity for the route style checking. [#668 in Lucky CLI](https://github.com/luckyframework/lucky_cli/pull/668)\n- Updated: require position for the config directory to be higher in the stack. [#676 in Lucky CLI](https://github.com/luckyframework/lucky_cli/pull/676)\n- Updated: the `load_manifest` to use \"public/mix-manifest.json\" by default. [#679 in Lucky CLI](https://github.com/luckyframework/lucky_cli/pull/679)\n- Fixed: issue when booting a Lucky app would clash with Elixir's `mix`. [#682 in Lucky CLI](https://github.com/luckyframework/lucky_cli/pull/682)\n- Fixed: the error page returning a 200 response. [#684 in Lucky CLI](https://github.com/luckyframework/lucky_cli/pull/684)\n- Updated: the GithubActions CI for generated apps to allow for testing multiple versions of Crystal. [#685 in Lucky CLI](https://github.com/luckyframework/lucky_cli/pull/685)\n- Added: a max limit for password sized in Authentic password validations. [#692 in Lucky CLI](https://github.com/luckyframework/lucky_cli/pull/692)\n- Added: special \"flow\" tags to a generated app's specs. [#693 in Lucky CLI](https://github.com/luckyframework/lucky_cli/pull/693)\n- Added: new sample \"app config\" file for generated apps. [#694 in Lucky CLI](https://github.com/luckyframework/lucky_cli/pull/694)\n- Updated: the `AppServer#listen` code to be a bit more concise. [#699 in Lucky CLI](https://github.com/luckyframework/lucky_cli/pull/699)\n- Added: new `Lucky::RequestIdHandler` to generated apps. [#700 in Lucky CLI](https://github.com/luckyframework/lucky_cli/pull/700)\n- Fixed: issue when `select_count` was called before an update causing a failure. [#715 in Avram](https://github.com/luckyframework/avram/pull/715)\n- Fixed: issue when inheritng from a SaveOperation that had attributes. [#718 in Avram](https://github.com/luckyframework/avram/pull/718)\n- Fixed: issue when passing an enum number as a String. [#720 in Avram](https://github.com/luckyframework/avram/pull/720)\n- **Breaking change** Removed: UUID generation on the Crystal side for the `id` column. [#725 in Avram](https://github.com/luckyframework/avram/pull/725)\n- Updated: the error message for unpermitted columns to be a little clearer where they come from. [#723 in Avram](https://github.com/luckyframework/avram/pull/723)\n- Added: new `includes()` query method for Array columns. [#733 in Avram](https://github.com/luckyframework/avram/pull/733)\n- Added: compile-time catches for common mistakes with attribute usage. [#738 in Avram](https://github.com/luckyframework/avram/pull/738)\n- Updated: the pending migration notice to be a lot more clear in development. [#737 in Avram](https://github.com/luckyframework/avram/pull/737)\n- Updated: `add_belongs_to` to require the `references` option when using a namespace. [#742 in Avram](https://github.com/luckyframework/avram/pull/742)\n- Updated: all validation methods to now return a Bool based on if they passed or not. [#744 in Avram](https://github.com/luckyframework/avram/pull/744)\n- Updated: the `lucky db.schema.dump` task to include the migrations table data. [#743 in Avram](https://github.com/luckyframework/avram/pull/743)\n- Fixed: missing low level DB methods. [#750 in Avram](https://github.com/luckyframework/avram/pull/750)\n- Added: new escape hatch to skip default validations, and allow blank strings to be saved. [#746](https://github.com/luckyframework/avram/pull/746)\n- Added: new `default_validations` block macro. [#751 in Avram](https://github.com/luckyframework/avram/pull/751)\n- Added: new `validate_format_of` validation method. [#752 in Avram](https://github.com/luckyframework/avram/pull/752)\n- Fixed: sort order of migrations. [#756 in Avram](https://github.com/luckyframework/avram/pull/756)\n- Added: new `Avram::I18nBackend` for setting language translations. [#757 in Avram](https://github.com/luckyframework/avram/pull/757)\n- **Breaking change** Renamed: `Operation::Status` enums to `Operation::OperationStatus`. [#759 in Avram](https://github.com/luckyframework/avram/pull/759)\n- Added: new query cache mechanism. [#763 in Avram](https://github.com/luckyframework/avram/pull/763)\n- Added: brand new [LuckyCache](https://github.com/luckyframework/lucky_cache) shard.\n- Added: Dynamic Email templates with Carbon SendGrid adapter. [#5 in Carbon SendGrid](https://github.com/luckyframework/carbon_sendgrid_adapter/pull/5)\n- Added: new `int32` and `float64` task args. [#3 in LuckyTask](https://github.com/luckyframework/lucky_task/pull/3)\n- Added: faster routing! [#54 in LuckyRouter](https://github.com/luckyframework/lucky_router/pull/54), [#55 in LuckyRouter](https://github.com/luckyframework/lucky_router/pull/55)\n- Updated: `ENV[\"LUCKY_TASK\"]` to be nilable. [#21 in LuckyTask](https://github.com/luckyframework/lucky_env/pull/21)\n\n\n### v0.28.0 (2021-07-22)\n\n- Updated: The exception page to use new backtrace shard. [#1465](https://github.com/luckyframework/lucky/pull/1465)\n- Updated: some Lucky internals with \"Spring Cleaning\". #1478, #1481, #1483, #1489, #1496, #1511, #1513, #1514, #1521, #1522, #1529, #1532, #1535, #1540, #1542, #1544, #1543\n- Updated: components so they no longer require `context` to be passed in. [#1488](https://github.com/luckyframework/lucky/pull/1488)\n- Added: new `time_from_now_in_words` text helper. [#1493](https://github.com/luckyframework/lucky/pull/1493)\n- Added: new `multipart?` action request helper. [#1495](https://github.com/luckyframework/lucky/pull/1495)\n- Updated: the generated delete action resource to use DeleteOperation. [#1497](https://github.com/luckyframework/lucky/pull/1497)\n- Added: new `raw_json` Action response method. [#1492](https://github.com/luckyframework/lucky/pull/1492)\n- Updated: `form_for` to allow passing boolean attributes. [#1506](https://github.com/luckyframework/lucky/pull/1506)\n- Added: new module to disable FLoC. [#1508](https://github.com/luckyframework/lucky/pull/1508)\n- Deprecated: `route` and `nested_route` from actions. [#1510](https://github.com/luckyframework/lucky/pull/1510)\n- Updated: performance with `params`. It's now attached to `context`. [#1509](https://github.com/luckyframework/lucky/pull/1509)\n- Updated: api actions generated will always be in the `api/` namespace. [#1512](https://github.com/luckyframework/lucky/pull/1512)\n- Updated: `Lucky::Serializer` with abstract `render` method. [#1516](https://github.com/luckyframework/lucky/pull/1516)\n- Added: new `html_with_status` method to render HTML pages with non-200 status. [#1507](https://github.com/luckyframework/lucky/pull/1507)\n- **Breaking change**: HTML fields that require an Operation Array attribute will now generate a param name appended with `[]`. [#1523](https://github.com/luckyframework/lucky/pull/1523)\n- Added: new `multi_select_input`. [#1518](https://github.com/luckyframework/lucky/pull/1518)\n- Added: the ability to define Array query params in actions. [#1527](https://github.com/luckyframework/lucky/pull/1527)\n- Added: standardized route style checked. [#1536](https://github.com/luckyframework/lucky/pull/1536)\n- Added: native subdomain support for actions. [#1537](https://github.com/luckyframework/lucky/pull/1537)\n- Added: new unique route enforcer. [#1538](https://github.com/luckyframework/lucky/pull/1538)\n- Updated: Carbon sendgrid is now a separate adapter. [#624 in Lucky CLI](https://github.com/luckyframework/lucky_cli/pull/624)\n- Updated: default components no longer need `context`. [#641 in Lucky CLI](https://github.com/luckyframework/lucky_cli/pull/641)\n- Updated: some LuckyCli internals with \"Spring Cleaning\". #646, #647, #648, #651, #649, #666 (in Lucky CLI)\n- Updated: generated apps will use `LuckyEnv` now. [#650 in Lucky CLI](https://github.com/luckyframework/lucky_cli/pull/650)\n- Removed: deprecated `normalize-css` package and replaced with `modern-normalize`. [#652 in Lucky CLI](https://github.com/luckyframework/lucky_cli/pull/652)\n- Added: new generated apps will enforce route styles by default. [#653 in Lucky CLI](https://github.com/luckyframework/lucky_cli/pull/653)\n- Updated: new generated apps will use case-insensitive email columns for the `User` model. [#657 in Lucky CLI](https://github.com/luckyframework/lucky_cli/pull/657)\n- Fixed: support for Crystal 1.1.0 when generating a new app. [#644 in Lucky CLI](https://github.com/luckyframework/lucky_cli/pull/644)\n- Removed: the `Lucky::Env` module and replaced with the `LuckyEnv` shard. [#655 in Lucky CLI](https://github.com/luckyframework/lucky_cli/pull/655)\n- Fixed: passing special args to precompiled tasks. [#656 in Lucky CLI](https://github.com/luckyframework/lucky_cli/pull/656)\n- Updated: some Avram internals with \"Spring Cleaning\". #669, #681, #683, #704, #705, #708 (in Avram)\n- Updated: `validate_numeric` and `validate_size_of` methods to allow for a custom error message. [#670 in Avram](https://github.com/luckyframework/avram/pull/670)\n- Updated: `db.drop` and `db.reset` tasks with a \"quiet\" option. [#675 in Avram](https://github.com/luckyframework/avram/pull/675)\n- Fixed: edge case with migrations using `fill_existing_with: false`. [#676 in Avram](https://github.com/luckyframework/avram/pull/676)\n- Added: new `Appdatabase.listen` method to listen for `pg_notify()` calls to any number of channels. [#677 in Avram](https://github.com/luckyframework/avram/pull/677)\n- Added: `before_save` and `after_save` callbacks to `Avram::Factory`. [#678 in Avram](https://github.com/luckyframework/avram/pull/678)\n- Fixed: `add_belongs_to` to support namespaced types. [#685 in Avram](https://github.com/luckyframework/avram/pull/685)\n- Updated: how `select_count` works to allow counting with `distinct_on`. [#687 in Avram](https://github.com/luckyframework/avram/pull/687)\n- Fixed: datetime parsing from form inputs. [#693 in Avram](https://github.com/luckyframework/avram/pull/693)\n- Fixed: compile-time error when you have a typo in your constant name (i.e. Boolean instead of Bool). [#700 in Avram](https://github.com/luckyframework/avram/pull/700)\n- Updated: the `validate_uniqueness_of` method to take a full query object. [#701 in Avram](https://github.com/luckyframework/avram/pull/701)\n- Added: new `any?` and `none?` query methods to return a `Bool` value based on records existing. [#703 in Avram](https://github.com/luckyframework/avram/pull/703)\n- **Breaking change**: removed `avram_enum`. You can now just use the native Crystal `enum` type. [#698 in Avram](https://github.com/luckyframework/avram/pull/698)\n- Added: ability to use `CASCADE` when calling `truncate`. [#702 in Avram](https://github.com/luckyframework/avram/pull/702)\n- Added: new `SaveOperation.upsert` methods. [#334 in Avram](https://github.com/luckyframework/avram/pull/334)\n- **Breaking change**: renamed `DeleteOperation.destroy` to `DeleteOperation.delete`. [#707 in Avram](https://github.com/luckyframework/avram/pull/707)\n- Fixed: compile-time error when no columns are defined in a model. [#706 in Avram](https://github.com/luckyframework/avram/pull/706)\n- Added: new JSON serializable object columns. [#695 in Avram](https://github.com/luckyframework/avram/pull/695)\n- Updated: the `current_user?` method to be memoized for performance. [#64 in Authentic](https://github.com/luckyframework/authentic/pull/64)\n- Updated: Authentic to catch potential development issues when setting the `secret_key`. [#65 in Authentic](https://github.com/luckyframework/authentic/pull/65)\n- Fixed: minor performance issue when setting a slug in AvramSlugify. [#10 in AvramSlugify](https://github.com/luckyframework/avram_slugify/pull/10)\n- Updated: irregular inflects with `human -> humans`. [#14 in Wordsmith](https://github.com/luckyframework/wordsmith/pull/14)\n- Fixed: customizing inflections with Wordsmith. [#18 in Wordsmith](https://github.com/luckyframework/wordsmith/pull/18)\n- Fixed: URI decoding path parts in the Lucky Router, also a small performance gain. [#51 in Lucky Router](https://github.com/luckyframework/lucky_router/pull/51)\n- Added: the environment predicate methods from `Lucky::Env` in to `LuckyEnv`. [#13 in LuckyEnv](https://github.com/luckyframework/lucky_env/pull/13)\n- Updated: Email Previews in Breeze are easier to add in. [#41 in Breeze](https://github.com/luckyframework/breeze/pull/41)\n- Fixed: using file uploads with Breeze requests. [#43 in Breeze](https://github.com/luckyframework/breeze/pull/43)\n- Added: storing before/after pipes in Breeze. [#36 in Breeze](https://github.com/luckyframework/breeze/pull/36)\n- Fixed: issue when running specs on an app using Breeze. [#42 in Breeze](https://github.com/luckyframework/breeze/pull/42)\n\n\n### v0.27.2 (2021-04-12)\n\n- Removed: legacy ecrypted cookies handling. [#1470](https://github.com/luckyframework/lucky/pull/1470)\n- Updated: Cookies resulting in encryption failure are ignored. [#1470](https://github.com/luckyframework/lucky/pull/1470)\n\n### v0.27.1 (2021-04-09)\n\n- Fixed: support for previous versions (<= 0.26.0) of encrypted cookies. [#1467](https://github.com/luckyframework/lucky/pull/1467)\n\n### v0.27.0 (2021-04-09)\n\n- Added: support for Crystal 1.0 🥳 [#1445](https://github.com/luckyframework/lucky/pull/1445)\n- Added: Pulsar events for tracking before/after action pipes. [#1423](https://github.com/luckyframework/lucky/pull/1423)\n- Updated: `link` to raise an exception when passing both `to` and `href`. [#1428](https://github.com/luckyframework/lucky/pull/1428)\n- Added: support for more HTML standard tags. [#1433](https://github.com/luckyframework/lucky/pull/1433)\n- Added: new `mount_instance` method to mount an instance of a Component. [#1446](https://github.com/luckyframework/lucky/pull/1446)\n- Fixed: bug when using VueJS type attributes on HTML tags. [#1452](https://github.com/luckyframework/lucky/pull/1452)\n- Updated: the exception page to look like it belongs in Lucky. [#1451](https://github.com/luckyframework/lucky/pull/1451)\n- Updated: HTML tags to allow passing in a raw hash of attributes. [#1453](https://github.com/luckyframework/lucky/pull/1453)\n- Updated: the `lucky build.release` task to build in to the `bin` directory. [#1454](https://github.com/luckyframework/lucky/pull/1454)\n- Fixed: error message when passing wrong type values to HTML tags. [#1456](https://github.com/luckyframework/lucky/pull/1456)\n- Updated: `lucky_cli` dependency with new `lucky_task` shard. [#1459](https://github.com/luckyframework/lucky/pull/1459)\n- Updated: generated apps so `LuckyFlow` is only a development dependency. [#608 in Lucky CLI](https://github.com/luckyframework/lucky_cli/pull/608)\n- Updated: Auth modules in generated apps to be in an `auth/` directory. [#618 in Lucky CLI](https://github.com/luckyframework/lucky_cli/pull/618)\n- Removed: `LuckyCli::Task` in to a separate shard. [#622 in Lucky CLI](https://github.com/luckyframework/lucky_cli/pull/622)\n- Added: new `LuckyTask` shard to the ecosystem. [View LuckyTask](https://github.com/luckyframework/lucky_task)\n- Updated: `avram_enum` to allow parsing a String value. [#639 in Avram](https://github.com/luckyframework/avram/pull/639)\n- Fixed: bug when a column is defined as a `DOUBLE`, and using `Float64`. [#637 in Avram](https://github.com/luckyframework/avram/pull/637)\n- Updated: migrations to use `Int64` by default allowing for better CockroachDB support. [#641 in Avram](https://github.com/luckyframework/avram/pull/641)\n- Fixed: database transactions to not require a `Bool` return type. [#626 in Avram](https://github.com/luckyframework/avram/pull/626)\n- Removed: deprecated `raw_where` methods. [#653 in Avram](https://github.com/luckyframework/avram/pull/653)\n- Added: new `where(&)` method for wrapping queries in parenthesis. [#652 in Avram](https://github.com/luckyframework/avram/pull/652)\n- Added: new `drop_foreign_key` method. [#654 in Avram](https://github.com/luckyframework/avram/pull/654)\n- Updated: how table names are stored internally paving the way for faster compilation. [#660 in Avram](https://github.com/luckyframework/avram/pull/660)\n- Added: new `parameterize` method for string helpers. [#9 in Wordsmith](https://github.com/luckyframework/wordsmith/pull/9)\n- Added: new `Habitat.extend` macro for extending existing configuration settings. [#59 in Habitat](https://github.com/luckyframework/habitat/pull/59)\n- Added: new development dashboard shard `Breeze`. [View Breeze](https://github.com/luckyframework/breeze)\n- Added: new .env parsing shard `LuckyEnv`. [View LuckyEnv](https://github.com/luckyframework/lucky_env)\n\n\n### v0.26.0 (2021-02-06)\n\n- Updated: the compile-error for missing Page args. [#1373](https://github.com/luckyframework/lucky/pull/1373)\n- Fixed: flash messages to be discarded unless specifically kept. [#1374](https://github.com/luckyframework/lucky/pull/1374)\n- Added: generating `JSON::Any` columns from `lucky gen.model` task. [#1375](https://github.com/luckyframework/lucky/pull/1375)\n- Added: creating empty HTML tags passing in a Hash for options. [#1377](https://github.com/luckyframework/lucky/pull/1377)\n- Updated: action generators to use actual route instead of the `route` or `nested_route` methods. [#1378](https://github.com/luckyframework/lucky/pull/1378)\n- Updated: the compile-error for incorrect route helper usage. [#1372](https://github.com/luckyframework/lucky/pull/1372)\n- Updated: `lucky gen.*.resource` task to not shadow outer local variable. [#1379](https://github.com/luckyframework/lucky/pull/1379)\n- Fixed: uploaded files now have access to the tempfile before it's closed. [#1381](https://github.com/luckyframework/lucky/pull/1381)\n- Added: new `lucky gen.task` task for generating a new Cli Task. [#1322](https://github.com/luckyframework/lucky/pull/1322)\n- Added: new `params.get_all` method to get an Array of values from params. [#1389](https://github.com/luckyframework/lucky/pull/1389)\n- Removed: broken `tag()` overloads. [#1394](https://github.com/luckyframework/lucky/pull/1394)\n- Added: support for Crystal 0.36. [#1398](https://github.com/luckyframework/lucky/pull/1398)\n- Fixed: empty HTML tags adding an extra space when generated. [#1400](https://github.com/luckyframework/lucky/pull/1400)\n- Fixed: issue with wrong types for form HTML methods when passing in an Operation Attribute. [#1405](https://github.com/luckyframework/lucky/pull/1405)\n- Updated: DB logging to be a little less excited. [#589 in Lucky CLI](https://github.com/luckyframework/lucky_cli/pull/589)\n- Removed: usage of the `route` and `nested_route` methods in generated apps. [#594 in Lucky CLI](https://github.com/luckyframework/lucky_cli/pull/594)\n- Added: some inline docs to using `ENV[\"LUCKY_TASK\"]`. [#595 in Lucky CLI](https://github.com/luckyframework/lucky_cli/pull/595/files)\n- Added: ability to pass `--error-trace` flag to precompiled lucky tasks. [#596 in Lucky CLI](https://github.com/luckyframework/lucky_cli/pull/596)\n- Updated: to Laravel Mix 6 for generated apps. [#592 in Lucky CLI](https://github.com/luckyframework/lucky_cli/pull/592)\n- Fixed: issue with generated apps Github Actions CI failing. [#600 in Lucky CLI](https://github.com/luckyframework/lucky_cli/pull/600)\n- Updated: `Procfile` for generated apps to call a binary named by the project instead of \"app\". [#601 in Lucky CLI](https://github.com/luckyframework/lucky_cli/pull/601)\n- Added: a new Heroku Buildpack to build all proper targets. Accompanies #601 in Lucky CLI. [view Heroku Buildpack](https://github.com/luckyframework/heroku-buildpack-lucky)\n- Added: equality operators (`==`, `===`) to `avram_enum`. [#566 in Avram](https://github.com/luckyframework/avram/pull/566)\n- Fixed: bug in associations that returned more records than they should have. [#574 in Avram](https://github.com/luckyframework/avram/pull/574)\n- Updated: error messages during migrations that were obscured. [#577 in Avram](https://github.com/luckyframework/avram/pull/577)\n- Added: `UUID` primary keys are now generated in the database with a fallback using Crystal. [#578 in Avram](https://github.com/luckyframework/avram/pull/578)\n- Updated: `validate_size_of` to only take a `String` attribute. [#579 in Avram](https://github.com/luckyframework/avram/pull/579)\n- Added: new `validate_numeric` validation for validating numbers. [#580 in Avram](https://github.com/luckyframework/avram/pull/580)\n- Fixed: issue with `has_one` preloads not loading the correct records. [#581 in Avram](https://github.com/luckyframework/avram/pull/581)\n- Updated: compile-error for attempting to use nilable attributes. [#583 in Avram](https://github.com/luckyframework/avram/pull/583)\n- Added: new `Avram::DeleteOperation` objects for handling complex delete logic. [#573 in Avram](https://github.com/luckyframework/avram/pull/573)\n- Updated: how generics work in `Avram::Attribute`. [#586 in Avram](https://github.com/luckyframework/avram/pull/586)\n- Updated: custom type support to allow for better 3rd-party support in the future. [#587 in Avram](https://github.com/luckyframework/avram/pull/587)\n- Added: new `lucky db.console` task to enter PSQL for your app. [#592 in Avram](https://github.com/luckyframework/avram/pull/592)\n- Updated: more support and transparency for custom DB types with new `criteria` method. [#591 in Avram](https://github.com/luckyframework/avram/pull/591)\n- Fixed: using the `datetime-local` tag to persist the Time. [#603 in Avram](https://github.com/luckyframework/avram/pull/603)\n- Updated: to the latest (0.23.x) crystal-pg version. [#605 in Avram](https://github.com/luckyframework/avram/pull/605)\n- Added: support for using `citext` columns with new `case_sensitive: false` option. [#608 in Avram](https://github.com/luckyframework/avram/pull/608)\n- Added: support for `Array(UUID)` columns. [#609 in Avram](https://github.com/luckyframework/avram/pull/609)\n- Updated: SaveOperation `after_save` and `after_commit` callbacks to run even if no changes to the record are made. [#612 in Avram](https://github.com/luckyframework/avram/pull/612)\n- Removed: the `after_completed` callback in SaveOperations. [also #612 in Avram](https://github.com/luckyframework/avram/pull/612)\n- Rename: `Avram::Box` to `Avram::Factory`. [#614 in Avram](https://github.com/luckyframework/avram/pull/614)\n- Added: new composite primary keys for migrations. [#616 in Avram](https://github.com/luckyframework/avram/pull/616)\n- Fixed: issues with `has_one` in `SaveOperation` not updating the associated record when doing updates. [#596 in Avram](https://github.com/luckyframework/avram/pull/596)\n- Updated: `Avram::Operation` to not call `run` if the operation is not `valid?`. [#621 in Avram](https://github.com/luckyframework/avram/pull/621)\n- Updated: LuckyFlow ChromeDriver to run in non-headless mode (head mode?). [#112 in LuckyFlow](https://github.com/luckyframework/lucky_flow/pull/112)\n- Added: ability to use a non Chrome browser in LuckyFlow. [#112 in LuckyFlow](https://github.com/luckyframework/lucky_flow/pull/113)\n- Added: the `browser_binary` option back in which lets use specify a different Chrome based browser. [#114 in LuckyFlow](https://github.com/luckyframework/lucky_flow/pull/114)\n- Added: new `pause` method for LuckyFlow to pause execution of flow for debugging. [#117 in LuckyFlow](https://github.com/luckyframework/lucky_flow/pull/117)\n- Updated: runtime-error for duplicate defined routes. [#45 in LuckyRouter](https://github.com/luckyframework/lucky_router/pull/45)\n- Added: `have_delivered_emails` spec expectation method for `Carbo`. [#45 in Carbon](https://github.com/luckyframework/carbon/pull/45)\n\n\n### v0.25.0 (2020-12-18)\n\n- Rename: component `with_defaults` renamed to `tag_defaults` [#1262](https://github.com/luckyframework/lucky/pull/1262)\n- Fixed: send HSTS headers over HTTPS. [#1268](https://github.com/luckyframework/lucky/pull/1268)\n- Updated: `memoize` can be used on any `Object` [#1270](https://github.com/luckyframework/lucky/pull/1270)\n- Added: `tfoot()` tag method. [#1296](https://github.com/luckyframework/lucky/pull/1296)\n- Added: routes now support glob routing [#1294](https://github.com/luckyframework/lucky/pull/1294)\n- Fixed: passing a `UUID` in to a tag for text [#1280](https://github.com/luckyframework/lucky/pull/1280)\n- Fixed: calling route helper methods on actions with `route_prefix` set. [#1298](https://github.com/luckyframework/lucky/pull/1298)\n- Added: clearing cookies with specific options passed in [#966](https://github.com/luckyframework/lucky/pull/966)\n- Fixed: passing a `name` prop to a custom tag. [#1309](https://github.com/luckyframework/lucky/pull/1309)\n- Added: `blockquote()` and `cite()` tag methods. [#1317](https://github.com/luckyframework/lucky/pull/1317)\n- Added: type name in error message for action classes [#1321](https://github.com/luckyframework/lucky/pull/1321)\n- Fixed: params that use `Bool` with a default value of `false` [#1352](https://github.com/luckyframework/lucky/pull/1352)\n- Updated: generated `start_server` binary is now output to the `bin` directory instead of top-level. [#1358](https://github.com/luckyframework/lucky/pull/1358)\n- Fixed: HTTP status description in the log output. [#1362](https://github.com/luckyframework/lucky/pull/1362)\n- Updated: reverted the `DATABASE_URL` ENV. [#551 in Lucky CLI](https://github.com/luckyframework/lucky_cli/pull/551)\n- Updated: emails will print to the log in development for easier debugging. [#555 in Lucky CLI](https://github.com/luckyframework/lucky_cli/pull/555)\n- Updated: Tasks can use the `output` property for easier testing. Added an `example` option to task args. [#557 in Lucky CLI](https://github.com/luckyframework/lucky_cli/pull/557)\n- Added: New generated Lucky projects will come with Github Actions out of the box. [#559 In Lucky CLI](https://github.com/luckyframework/lucky_cli/pull/559)\n- Updated: front-end `package.json` dependencies. [#553](https://github.com/luckyframework/lucky_cli/pull/553)\n- Fixed: Signal trap is properly caught when running `lucky dev`. [#572 in Lucky CLI](https://github.com/luckyframework/lucky_cli/pull/572)\n- Updated: the built-in seed tasks to better match the common structure. [#584 in Lucky CLI](https://github.com/luckyframework/lucky_cli/pull/584)\n- Added: new `Lucky::Env.task?` method will return true if `ENV[\"LUCKY_TASK\"] = \"true\"` is set. [#576 in Lucky CLI](https://github.com/luckyframework/lucky_cli/pull/576)\n- Updated: Query objects no longer mutate which fixes calling aggregate methods without needing to `clone`. [#411 in Avram](https://github.com/luckyframework/avram/pull/411)\n- Updated: the error message when a required primary key is missing. [#454 in Avram](https://github.com/luckyframework/avram/pull/454)\n- Updated: `fill_existing_with` to be used with nilable columns. [#452 in Avram](https://github.com/luckyframework/avram/pull/452)\n- Fixed: `Bool` columns with a default `false` value. [#461 in Avram](https://github.com/luckyframework/avram/pull/461)\n- Fixed: `belongs_to` using the wrong key in some cases. [#465 in Avram](https://github.com/luckyframework/avram/pull/465)\n- Fixed: using optional Arrays in columns and migrations. [#471 in Avram](https://github.com/luckyframework/avram/pull/471)\n- Fixed: calling `to_s` or `to_i` on an enum column to get the enum's proper value. [#474 in Avram](https://github.com/luckyframework/avram/pull/474)\n- Updated: using `raw_where` will now be deprecated in favor of a unified `where`. [#460 in Avram](https://github.com/luckyframework/avram/pull/460)\n- Fixed: issues with invalid SQL with joins. [#451 in Avram](https://github.com/luckyframework/avram/pull/451)\n- Added: a whole new interface for `Avram::Operation`. [#469 in Avram](https://github.com/luckyframework/avram/pull/469)\n- Updated: `Avram::SaveOperation` callback methods `after_save` and `after_commit` work with blocks, and more. [#481 in Avram](https://github.com/luckyframework/avram/pull/481)\n- Added: a compile-time error catch when passing a raw hash in to a `SaveOperation`. [#485 in Avram](https://github.com/luckyframework/avram/pull/485)\n- Removed: `register_setup_step` macro used for hooking in to the Avram model setup. [#486 in Avram](https://github.com/luckyframework/avram/pull/486)\n- Added: new `or()` query method to perform `WHERE x OR y` SQL calls. [#442 in Avram](https://github.com/luckyframework/avram/pull/442)\n- Updated: database calls to be optimized for speed. [#491 in Avram](https://github.com/luckyframework/avram/pull/491)\n- Added: `params.has_key_for?` to check if params contains a key for an operation. [#500 in Avram](https://github.com/luckyframework/avram/pull/500)\n- Added: conditional callbacks for `Avram::SaveOperation`. [#495 in Avram](https://github.com/luckyframework/avram/pull/495)\n- Updated: the `has_many` count method to not preload when just a number is being returned. [#509 in Avram](https://github.com/luckyframework/avram/pull/509)\n- Fixed: passing a `file_attribute` as a named arg to an operation. [#514 in Avram](https://github.com/luckyframework/avram/pull/514)\n- Removed: unique filtering on `WHERE` clauses. [#518](https://github.com/luckyframework/avram/pull/518)\n- Updated: error message when using `remove` incorrectly in migrations. [#524 in Avram](https://github.com/luckyframework/avram/pull/524)\n- Added: error message when trying to generate a migration by a name that already exists. [#528 in Avram](https://github.com/luckyframework/avram/pull/528)\n- Added: new custom errors for Operation objects. [#534 in Avram](https://github.com/luckyframework/avram/pull/534)\n- Updated: `add_belongs_to` can now set a unique index. [#536 in Avram](https://github.com/luckyframework/avram/pull/536)\n- Fixed: creating records by passing in values that match the default. [#540 in Avram](https://github.com/luckyframework/avram/pull/540)\n- Updated: how `has_many through` associations are defined to fix has_many through a has_many through association. [#525 in Avram](https://github.com/luckyframework/avram/pull/525)\n- Added: new `after_completed` callback on `Avram::SaveOperation` which is called even if no updates are made. [#544 in Avram](https://github.com/luckyframework/avram/pull/544)\n- Added: `UUID` primary key checks to the SchemaEnforcer. [#546 in Avram](https://github.com/luckyframework/avram/pull/546)\n- Added: records already loaded in to memory can now preload associations. [#542 in Avram](https://github.com/luckyframework/avram/pull/542), [#553 in Avram](https://github.com/luckyframework/avram/pull/553), [#561 in Avram](https://github.com/luckyframework/avram/pull/561)\n- Added: support for models to use `VIEW`. [#555 in Avram](https://github.com/luckyframework/avram/pull/555)\n- Added: new `defaults` method for defining default query methods on Query objects. [#564 in Avram](https://github.com/luckyframework/avram/pull/564)\n- Fixed: setting two routes that use different path variable names. [#38 in LuckyRouter](https://github.com/luckyframework/lucky_router/pull/38)\n- Added: route globbing. [#40 in LuckyRouter](https://github.com/luckyframework/lucky_router/pull/40)\n- Fixed: catching when duplicate routes are defined. [#42 in LuckyRouter](https://github.com/luckyframework/lucky_router/pull/42)\n- Added: flow spec matcher method `have_current_path`. [#96 in LuckyFlow](https://github.com/luckyframework/lucky_flow/pull/96)\n- Fixed: flow spec `have_text` matcher method to check if the text is included and not exact. [#99 in LuckyFlow](https://github.com/luckyframework/lucky_flow/pull/99)\n- Added: flow method to confirm and accept javascript modal boxes. [#101 in LuckyFlow](https://github.com/luckyframework/lucky_flow/pull/101)\n- Added: flow to fill a select field. [#104 in LuckyFlow](https://github.com/luckyframework/lucky_flow/pull/104)\n- Added: flow to select multiple values from a select field. [#106 in LuckyFlow](https://github.com/luckyframework/lucky_flow/pull/106)\n- Added: flow method `element.hover` to hover over an element. [#108 in LuckyFlow](https://github.com/luckyframework/lucky_flow/pull/108)\n\n\n### v0.24.0 (2020-09-05)\n\n- Fixed: `send_text_response` default status to nil [#1214](https://github.com/luckyframework/lucky/pull/1214)\n- Added: `data` method for Actions to return file contents [#1220](https://github.com/luckyframework/lucky/pull/1220)\n- Updated: Component `m` is renamed to `mount` [#1226](https://github.com/luckyframework/lucky/pull/1226)\n- Updated: Components with UrlHelpers like `current_page?` [#1228](https://github.com/luckyframework/lucky/pull/1228)\n- Added: optional param routing [#1229](https://github.com/luckyframework/lucky/pull/1229)\n- Updated: docs on `accept_format` [#1234](https://github.com/luckyframework/lucky/pull/1234)\n- Updated: generator templates to use getter methods over instance variables [#1236](https://github.com/luckyframework/lucky/pull/1236)\n- Updated: our community to use Discord for community [chat room](https://discord.gg/HeqJUcb) [#1237](https://github.com/luckyframework/lucky/pull/1237)\n- Updated: compile-time error when path params are defined with dashes [#1238](https://github.com/luckyframework/lucky/pull/1238)\n- Updated: path helpers to render query params even if default value is passed [#1239](https://github.com/luckyframework/lucky/pull/1239)\n- Updated: `redirect_back` to disallow external referrers by default with config option [#1241](https://github.com/luckyframework/lucky/pull/1241)\n- Updated: generated api apps will use `disable_cookies` by default [#535 in Lucky CLI](https://github.com/luckyframework/lucky_cli/pull/535)\n- Fixed: generating an app with the name \"app\" will raise an error [#543 in Lucky CLI](https://github.com/luckyframework/lucky_cli/pull/543)\n- Updated: `AppClient` renamed to `ApiClient` [#534 in Lucky CLI](https://github.com/luckyframework/lucky_cli/pull/534)\n- Updated: generated projects to use `--ignore-crystal-version` flag when running `shards install`. NOTE: this is a temporary update, and will be reverted in a future release. [Read Crystal Blog](https://crystal-lang.org/2020/08/20/preparing-our-shards-for-crystal-1.0.html) [See commit](https://github.com/luckyframework/lucky_cli/pull/547/files#diff-154806d6e0faa0b1aa1e518f3bbd3647R25)\n- Added: ability to set default values on model columns [#424 in Avram](https://github.com/luckyframework/avram/pull/424)\n- Added: `file_attribute` for operations to specify a file from params [#428 in Avram](https://github.com/luckyframework/avram/pull/428)\n- Added: new `Database.delete` strategy for cleaning up data in specs [#426 in Avram](https://github.com/luckyframework/avram/pull/426)\n- Added: `create_function` and `drop_function` to create SQL functions [#427 in Avram](https://github.com/luckyframework/avram/pull/427)\n- Updated: `Avram::PostgresURL` renamed to `Avram::Credentials` with a new interface [#433 in Avram](https://github.com/luckyframework/avram/pull/433)\n- Added: `create_trigger` and `drop_trigger` to create SQL triggers [#436 in Avram](https://github.com/luckyframework/avram/pull/436)\n- Added: association `_count` method to easily return a count of a has_many association [#392 in Avram](https://github.com/luckyframework/avram/pull/392)\n- Added: new `Pulsar` shard for pub/sub style communication in Lucky [See Pulsar](https://github.com/luckyframework/pulsar)\n- Added: Pulsar instrumentation to Avram for subscribing to queries [#441 in Avram](https://github.com/luckyframework/avram/pull/441)\n- Added: support for `Array(Float64)` in databases [#443 in Avram](https://github.com/luckyframework/avram/pull/443)\n- Updated: `fill_existing_with` option on `add_belongs_to` in migrations [#444 in Avram](https://github.com/luckyframework/avram/pull/444)\n- Added: `Box.build_attributes` method to build the attributes of a model in specs [#449 in Avram](https://github.com/luckyframework/avram/pull/449)\n- Fixed: blank strings causing parse exceptions in save operations [#448 in Avram](https://github.com/luckyframework/avram/pull/448)\n- Updated: LuckyRouter with many performance and structural refactors [#28](https://github.com/luckyframework/lucky_router/pull/28), [#30](https://github.com/luckyframework/lucky_router/pull/30), [#31](https://github.com/luckyframework/lucky_router/pull/31), [#32](https://github.com/luckyframework/lucky_router/pull/32)\n\n### v0.23.1 (2020-07-07)\n\n- Fixed: generated apps using deprecated `mount` instead of `m` [#531 in Lucky CLI](https://github.com/luckyframework/lucky_cli/pull/531)\n\n### v0.23.0 (2020-06-26)\n\n- Updated: password reset tokens to be URL safe [#1118](https://github.com/luckyframework/lucky/pull/1118)\n- Added: `radio` input helper [#1125](https://github.com/luckyframework/lucky/pull/1125)\n- Added: component file paths to rendered comments in markup for development [#1126](https://github.com/luckyframework/lucky/pull/1126)\n- Added: `query_param_declarations` method to Action classes [#1122](https://github.com/luckyframework/lucky/pull/1122)\n- Fixed: generating a model that already exists now raises an error [#1127](https://github.com/luckyframework/lucky/pull/1127)\n- Added: `select_prompt` helper method [#1124](https://github.com/luckyframework/lucky/pull/1124)\n- Updated: `lucky routes` UI to now include query params [#1128](https://github.com/luckyframework/lucky/pull/1128)\n- Added: `route_prefix` method for Actions to prefix all routes [#1121](https://github.com/luckyframework/lucky/pull/1121)\n- Fixed: error when deleting cookies that don't exist [#1132](https://github.com/luckyframework/lucky/pull/1132)\n- Fixed: handling ajax form submissions with TurboLinks [#1133](https://github.com/luckyframework/lucky/pull/1133)\n- Fixed: issue with `ajax?` method not returning correct value [#1134](https://github.com/luckyframework/lucky/pull/1134)\n- Fixed: security issue by escaping HTML helpers by default [#1135](https://github.com/luckyframework/lucky/pull/1135)\n- Updated: `memoize` to allow for arguments, and `nil` and `false` values [#1139](https://github.com/luckyframework/lucky/pull/1139)\n- Updated: model generator to provide more helpful error messages [#1140](https://github.com/luckyframework/lucky/pull/1140)\n- Added: `get_raw` method to params along with striping blankspace on param `get` calls [#1144](https://github.com/luckyframework/lucky/pull/1144)\n- Removed: `mount` with deprecation in favor of new `m` method.\n- Added: `m` helper method as a `mount` replacement with a new interface. [#1151](https://github.com/luckyframework/lucky/pull/1151)\n- Updated: `String#squish` method to be faster [#1159](https://github.com/luckyframework/lucky/pull/1159)\n- Removed: `Lucky::SessionHandler` and `Lucky::FlashHandler`. [#518 in Lucky CLI](https://github.com/luckyframework/lucky_cli/pull/518)\n- Fixed: issue with session cookies not being written at the right time. [#1160](https://github.com/luckyframework/lucky/pull/1160)\n- Added: `template` HTML method for `<template>` tags. [#1164](https://github.com/luckyframework/lucky/pull/1164)\n- Fixed: flash messages being lost during multiple redirects. [#1169](https://github.com/luckyframework/lucky/pull/1169)\n- Added: `redirect_back` for actions to redirect back to previous referrer [#1168](https://github.com/luckyframework/lucky/pull/1168)\n- Added: `component` method to render a Component directly from an Action [#1172](https://github.com/luckyframework/lucky/pull/1172)\n- Added: `canonical_link` HTML helper method. [#1182](https://github.com/luckyframework/lucky/pull/1182)\n- Added: `disable_cookies` macro to stop cookies from being written on a specific action [#1180](https://github.com/luckyframework/lucky/pull/1180)\n- Fixed: setting `samesite` on cookies in your `Lucky::CookieJar` `on_set` [#1183](https://github.com/luckyframework/lucky/pull/1183)\n- Fixed: compilation bug in generated page when running `lucky gen.page` [#1191](https://github.com/luckyframework/lucky/pull/1191)\n- Added: `multipart: true` option to `form_for` to set multipart enctype [#1200](https://github.com/luckyframework/lucky/pull/1200)\n- Added: `Lucky.root` method to raise compile-time error directing people to use `Dir.current` instead. [#1206](https://github.com/luckyframework/lucky/pull/1206)\n- Added: native CLI args to `LuckyCli::Task`. [#466 in Lucky CLI](https://github.com/luckyframework/lucky_cli/pull/466)\n- Updated: generated projects to disable StaticFileHandler directory listing by default. [#510 in Lucky CLI](https://github.com/luckyframework/lucky_cli/pull/510)\n- Updated: error action to return a 404 for `Avra::RecordNotFoundError` [#524 in Lucky CLI](https://github.com/luckyframework/lucky_cli/pull/524)\n- Fixed: `select_count` failing when postgres returns no counts. [#357 in Avram](https://github.com/luckyframework/avram/pull/357)\n- Added: support for postgres extensions with `enable_extension`, `disable_extension`, and `update_extension`. [#356 in Avram](https://github.com/luckyframework/avram/pull/356)\n- Added: enum support for models with `avram_enum` macro. [#339 in Avram](https://github.com/luckyframework/avram/pull/339)\n- Fixed: the error message when using `remove` in migrations, and not passing a Symbol.\n- Added: `rename` and `rename_belongs_to` in migrations [#366 in Avram](https://github.com/luckyframework/avram/pull/366)\n- Added: new `lucky db.setup` task which runs `db.create` and `db.migrate`. [#361 in Avram](https://github.com/luckyframework/avram/pull/361)\n- Added: ability to set a custom index name for table indices. [#386 in Avram](https://github.com/luckyframework/avram/pull/386)\n- Fixed: using a custom primary key name of type `UUID`. [#401 in Avram](https://github.com/luckyframework/avram/pull/401)\n- Added: checking for a connection to the PostgreSQL engine before running the `lucky db.create` task. [#397 in Avram](https://github.com/luckyframework/avram/pull/397)\n- Fixed: logging issues related to Crystal 0.35.0. [#31 in Dexter](https://github.com/luckyframework/dexter/pull/31)\n- Updated: which selenium library was being used for LuckyFlow. [#76 in LuckyFlow](https://github.com/luckyframework/lucky_flow/pull/76)\n- Added: initial work to support using other browsers aside from Chrome in LuckyFlow. [#79 in LuckyFlow](https://github.com/luckyframework/lucky_flow/pull/79), [#88 in LuckyFlow](https://github.com/luckyframework/lucky_flow/pull/88)\n- Added: support to auto fetch latest webdrivers in LuckyFlow. [#80 in LuckyFlow](https://github.com/luckyframework/lucky_flow/pull/80)\n- Fixed: issue with really long stacktrace in LuckyFlow. [#83 in LuckyFlow](https://github.com/luckyframework/lucky_flow/pull/83)\n- Added: `have_text` expectation method for Flow specs. [#87 in LuckyFlow](https://github.com/luckyframework/lucky_flow/pull/87)\n- Added: optional path param routing. [#18 in LuckyRouter](https://github.com/luckyframework/lucky_router/pull/18)\n- Update: routing to ensure matching dynamic fragments all work. [#23 in LuckyRouter](https://github.com/luckyframework/lucky_router/pull/23)\n- Added: a little bit of speed to the routing lookup. [#26 in LuckyRouter](https://github.com/luckyframework/lucky_router/pull/26)\n- Added: a new `validation` option to Habitat settings. [#49 in Habitat](https://github.com/luckyframework/habitat/pull/49)\n- Renamed: the internal Habitat `Settings` class to `HabitatSettings` to avoid name conflicts in some Lucky apps. [#48 in Habitat](https://github.com/luckyframework/habitat/pull/48)\n- Fixed: bug when setting a default value in a Habitat setting that could potentially raise an exception. [#51 in Habitat](https://github.com/luckyframework/habitat/pull/51)\n\n\n### v0.22.0 (2020-06-17)\n\n- Added: support for Crystal 0.35.0\n\n### v0.21.0 (2020-04-19)\n\n- Added: support for Crystal 0.34.0 `Log` class [#506 in Lucky CLI](https://github.com/luckyframework/lucky_cli/pull/506/files)\n- Added: `paginate_array` for paginating Arrays [#1108](https://github.com/luckyframework/lucky/pull/1108)\n- Improve error logging [#1114](https://github.com/luckyframework/lucky/pull/1114)\n- Improve http status logging [#1114](https://github.com/luckyframework/lucky/pull/1114)\n- Upgraded: Dexter to v0.2.0\n  - Type-safe log configuration\n  - New JSON formatter\n  - Helpers for testing logs\n- Fix for issues with the system check in Procfile.dev [#505 in Lucky CLI](https://github.com/luckyframework/lucky_cli/pull/505)\n\n### v0.20.0 (2020-04-08)\n\n- Added: support for Crystal 0.34.0\n- Fixed: error on some generated pages from missing sourcemap [#1019](https://github.com/luckyframework/lucky/pull/1019)\n- Updated: `options_for_select` to accept more types [#295](https://github.com/luckyframework/lucky/pull/295)\n- Added: ability to pass boolean attrs in link helper methods [#1032](https://github.com/luckyframework/lucky/pull/1032)\n- Removed: setting `needs` with `?`. Lucky now generates a method ending in `?` for you when the type is `Bool` [#1034](https://github.com/luckyframework/lucky/pull/1034)\n- Added: `needs` on pages can now be accessed by a method and not just instance variable [#1034](https://github.com/luckyframework/lucky/pull/1034)\n- Removed: `link` helper method with a `String` path. [#1035](https://github.com/luckyframework/lucky/pull/1035)\n- Added: new `Lucky::CookieNotFoundError` class. [#1038](https://github.com/luckyframework/lucky/pull/1038)\n- Added: `cookies.deleted?()` method for checking if a cookie has been deleted. [#1040](https://github.com/luckyframework/lucky/pull/1040)\n- Added: new `Lucky::Paginator` component with built-in styles for different different CSS frameworks. [#1020](https://github.com/luckyframework/lucky/pull/1020)\n- Fixed: `needs` accidentally overwriting methods of the same name. [#1046](https://github.com/luckyframework/lucky/pull/1046)\n- Updated: `label_for` to be a little more flexible with `nil` text. [#1047](https://github.com/luckyframework/lucky/pull/1047)\n- Updated: resource generator to be a little easier to read and digest. [#1050](https://github.com/luckyframework/lucky/pull/1050)\n- Updated: development `ENV` now uses `ENV[\"DEV_PORT\"]` instead of `ENV[\"PORT\"]` to fix issues with process managers. [#1051](https://github.com/luckyframework/lucky/pull/1051)\n- Added: new `Lucky::CatchUnpermittedAttribute` mixin for `Shared::Field` component. [#1052](https://github.com/luckyframework/lucky/pull/1052)\n- Added: new methods in Actions for accessing params from different sources like `from_json`, `from_query`, `from_form`, and `from_multipart`. [#1053](https://github.com/luckyframework/lucky/pull/1053)\n- Updated: generated pages to have some default text pointing to the location of the file to edit. [#1057](https://github.com/luckyframework/lucky/pull/1057)\n- Fixed: incorrect pluralization of resources on `NewPage`. [#1058](https://github.com/luckyframework/lucky/pull/1058)\n- Updated: all action \"callbacks\" are officially named \"pipes\". All pipes only log when halted by default. [#1062](https://github.com/luckyframework/lucky/pull/1062)\n- Updated: the `lucky dev` watcher does not print which file changes because you know you just changed that file. [#1065](https://github.com/luckyframework/lucky/pull/1065)\n- Added: a new HTTP handler to set the `request.remote_address` if the `X-Forwarded-For` header is set. [#1059](https://github.com/luckyframework/lucky/pull/1059)\n- Added: a `current_page?` helper method for pages. [#1074](https://github.com/luckyframework/lucky/pull/1074)\n- Added: `FormFields` component for generated resources. [#1081](https://github.com/luckyframework/lucky/pull/1081)\n- Updated: all HTML tag methods explicitly return `Nil` now. [#1083](https://github.com/luckyframework/lucky/pull/1083)\n- Updated: page markup to render directly to the IO instead of creating an additional string. [#1084](https://github.com/luckyframework/lucky/pull/1084)\n- Added: `String#squish` method. [#1085](https://github.com/luckyframework/lucky/pull/1085)\n- Updated: error message from returning invalid type in Actions. [#1086](https://github.com/luckyframework/lucky/pull/1086)\n- Added: ability to set custom directory when generating a new Lucky project [See LuckyCli](https://github.com/luckyframework/lucky_cli/pull/464)\n- Added: ability to set your postgres DB port with ENV var. [See LuckyCli](https://github.com/luckyframework/lucky_cli/pull/469)\n- Added: a `robots.txt` file to generated web apps by default. [See LuckyCli](https://github.com/luckyframework/lucky_cli/pull/472)\n- Added: new compiling spinner graphic for a cleaner UX. [See LuckyCli](https://github.com/luckyframework/lucky_cli/pull/481)\n- Updated: some comments on the generated main app file. [See LuckyCli](https://github.com/luckyframework/lucky_cli/pull/484)\n- Added: lots of internal documentation. (many small commits to LuckyCli)\n- Updated: generated `UserSerializer` to inherit from `BaseSerializer`. [See LuckyCli](https://github.com/luckyframework/lucky_cli/pull/489)\n- Updated: cookies to default to `http_only`. [See LuckyCli](https://github.com/luckyframework/lucky_cli/pull/491)\n- Updated: node dependencies in generated web apps. [See LuckyCli](https://github.com/luckyframework/lucky_cli/pull/493)\n- Added: new `system_check` script along with some refactors to make checking that your app is setup a lot easier. [See LuckyCli](https://github.com/luckyframework/lucky_cli/pull/482)\n- Removed: ability to pass a raw hash to an `Avram::SaveOperation`. [See Avram](https://github.com/luckyframework/avram/pull/312)\n- Added: ability to `skip_schema_enforcer` for certain models. [See Avram](https://github.com/luckyframework/avram/pull/314)\n- Added: `Avram::Model#reload` to reload all of the attributes that may have been updated since the instance was created. [See Avram](https://github.com/luckyframework/avram/pull/324)\n- Added: `Query#reset_where` to reset the WHERE clause on a specific column. [See Avram](https://github.com/luckyframework/avram/pull/325)\n- Added: logging queries that fail. [See Avram](https://github.com/luckyframework/avram/pull/326)\n- Fixed: using `fill_existing_with` when you already had data in your table. [See Avram](https://github.com/luckyframework/avram/pull/328)\n- Added: bulk updating records straight from a query object. [See Avram](https://github.com/luckyframework/avram/pull/329)\n- Added: new \"soft delete\" feature. [See Avram](https://github.com/luckyframework/avram/pull/323)\n- Fixed: saving empty array columns when the column can't be `nil`, but it can be `[]`. [See Avram](https://github.com/luckyframework/avram/pull/330)\n- Updated: `SaveOperation.new` to set attributes directly. [See Avram](https://github.com/luckyframework/avram/pull/332)\n- Removed: the `on` option for `needs` in `SaveOperation`. [See Avram](https://github.com/luckyframework/avram/pull/332)\n- Fixed: connecting to databases running on a unix domain socket. [See Avram](https://github.com/luckyframework/avram/pull/333)\n- Added: new shard for turning an Avram column in to a URL slug. [AvramSlugify](https://github.com/luckyframework/avram_slugify)\n\n### v0.19.0 (2020-02-29)\n\n- Added: missing docs for time helpers [#943](https://github.com/luckyframework/lucky/pull/943)\n- Added: HTML boolean attributes to checkbox and textarea helpers [#955](https://github.com/luckyframework/lucky/pull/955)\n- Fixed: generated templates with proper naming conventions [#956](https://github.com/luckyframework/lucky/pull/956)\n- Added: `to_param` for `UUID` allowing UUID to be passed in params [#945](https://github.com/luckyframework/lucky/pull/945)\n- Updated: watcher error message to be a little less abrupt [#968](https://github.com/luckyframework/lucky/pull/968)\n- Updated: generated migrations using the `table_for` macro [#970](https://github.com/luckyframework/lucky/pull/970)\n- Fixed: using `with_defaults` when the tag has content [#972](https://github.com/luckyframework/lucky/pull/972)\n- Added: `any?` and `empty?` to `flash` [#977](https://github.com/luckyframework/lucky/pull/977)\n- Fixed: allowing `false` values for `needs` [#979](https://github.com/luckyframework/lucky/pull/979)\n- Updated: `needs` to now infer a value of `nil` when the type is nilable [#980](https://github.com/luckyframework/lucky/pull/980)\n- Fixed: allowing the `-h` flag for the watch task [#958](https://github.com/luckyframework/lucky/pull/958)\n- Added: gzip response for assets when it's configured [#983](https://github.com/luckyframework/lucky/pull/983)\n- Added: Lucky API docs are now generated from the CI which is deployed to Github pages [#989](https://github.com/luckyframework/lucky/pull/989)\n- Fixed: when using `needs` with different values in random order and Lucky would not compile [#993](https://github.com/luckyframework/lucky/pull/993)\n- Added: more context to the resource generator [See commit](https://github.com/luckyframework/lucky/commit/ae7301750c9b49c99d5b530ddc93cda91e73f288)\n- Added: ability to pass Crystal's `--error-tace` flag to `lucky watch` [#957](https://github.com/luckyframework/lucky/pull/957)\n- Fixed: generating resource.browser when using a `JSON::Any` column type [#997](https://github.com/luckyframework/lucky/pull/997)\n- Fixed: issue when using HTML boolean attributes with custom tags [#1010](https://github.com/luckyframework/lucky/pull/1010)\n- Added: the option to define columns in the model generator [#1009](https://github.com/luckyframework/lucky/pull/1009)\n- Updated: permitting columns generated from the resource generator [#1014](https://github.com/luckyframework/lucky/pull/1014)\n- Added: new `to_prepared_sql` method to generate fully prepared sql for debugging [See Avram](https://github.com/luckyframework/avram/pull/264)\n- Fixed: cloning distinct queries [See Avram](https://github.com/luckyframework/avram/pull/285)\n- Added: new predicate methods variants for boolean columns [See Avram](https://github.com/luckyframework/avram/pull/300)\n- Added: new `changed?`, `changes`, and `original_value` methods for attributes in Operations [See Avram](https://github.com/luckyframework/avram/pull/295)\n- Updated: `validate_size_of` and `validate_inclusion_of` to allow `nil` values [See Avram](https://github.com/luckyframework/avram/pull/299)\n- Updated: error messages on some callbacks [See Avram](https://github.com/luckyframework/avram/pull/282)\n- Fixed: `select_sum` when the column is any number type [See Avram](https://github.com/luckyframework/avram/pull/304)\n- Fixed: issues with `has_one` when your model is namespaced, and how it's queried [See Avram](https://github.com/luckyframework/avram/pull/263)\n- Fixed: aggregate query methods to work on all number types [See Avram](https://github.com/luckyframework/avram/pull/307)\n- Fixed: bug when using a Box that had no columns [See Avram](https://github.com/luckyframework/avram/pull/310)\n- Updated: preloads to only call when there are parent records. This is a query optimization update. [See Avram](https://github.com/luckyframework/avram/pull/306)\n\n\n### v0.18.3 (2020-02-17)\n\n- Added: support for Crystal 0.33.0\n\n### v0.18.2 (2019-12-13)\n\n- Added: support for Crystal 0.32.0\n\n### v0.18.1 (2019-10-18)\n\n- Fixed: debug page in development with reset context\n- Updated: lucky exec works more like a REPL\n- Updated: Log time measured with monotonic\n- Fixed: Record deletion when primary key is UUID\n- Fixed: Setting empty array as default to array column\n- Added: Overflow cast catch from Int64 to Int32\n- Fixed: UUID primary key issue in SaveOperation\n- Fixed: required attribute validations on custom before_save callbacks\n- Added: New `reset_limit` query method\n- Added: New `reset_offset` query method\n\n### v0.18.0 (2019-10-03)\n\n- Added: support for Crystal 0.31.1\n- Fixed: how accept / content-type headers are handled [#869](https://github.com/luckyframework/lucky/pull/869)\n- Added: `ParamParsingError` for when parsing JSON params fails [#874](https://github.com/luckyframework/lucky/pull/874)\n- Updated: `Lucky::BaseHTTPClient` [#875](https://github.com/luckyframework/lucky/pull/875)\n- Updated: shell scripts for POSIX compliance [#879](https://github.com/luckyframework/lucky/pull/879)\n- Added: `date_input`, `time_input`, `datetime_input` [#877](https://github.com/luckyframework/lucky/pull/877)\n- Added: support for HTTP `PATCH` [#885](https://github.com/luckyframework/lucky/pull/885)\n- Added: `abbr` HTML tag [#886](https://github.com/luckyframework/lucky/pull/886)\n- Fixed: missing primary_key and timestamps in generated migrations [#888](https://github.com/luckyframework/lucky/pull/888)\n- Fixed: `pluralize` to take any Int [#890](https://github.com/luckyframework/lucky/pull/890)\n- Fixed: generation of migrations with resource [see Commit](https://github.com/luckyframework/lucky/commit/31848d916bdba9d2e6333e508ae2e95d9788263a)\n- Rename: `Lucky::HttpRespondable` to `Lucky::RenderableError` [see Commit](https://github.com/luckyframework/lucky/commit/026f2e3bf9c1085376537c27bc2a28bfde590eb1)\n- Fixed: `accepts_format`, and a few other mime type issues [#896](https://github.com/luckyframework/lucky/pull/896)\n- Fixed: default curl requests to server not responding properly [#899](https://github.com/luckyframework/lucky/pull/899)\n- Rename: `handle_error` to `render` in `ErrorAction` [#903](https://github.com/luckyframework/lucky/pull/903)\n- Rename: `render` to `html` in Actions [#905](https://github.com/luckyframework/lucky/pull/905)\n- Update: error message when missing type declaration for `needs` [#907](https://github.com/luckyframework/lucky/pull/907)\n- Fixed: model generation allowing for non alphanumeric characters [#910](https://github.com/luckyframework/lucky/pull/910)\n- Updated: make more errors renderable [#911](https://github.com/luckyframework/lucky/pull/911)\n- Fixed: help messages now display for precompiled tasks [#923](https://github.com/luckyframework/lucky/pull/923)\n- Updated: default help messages for tasks [#923](https://github.com/luckyframework/lucky/pull/923)\n- Fixed: issue with precompile tasks running in some directories [#924](https://github.com/luckyframework/lucky/pull/924)\n- Added: SQL logging [see Avram](https://github.com/luckyframework/avram/pull/213)\n- Updated: error message when postgres isn't running [see Avram](https://github.com/luckyframework/avram/pull/218)\n- Updated: `Box.create_pair` allows for setting attributes, and returns instances [see Avram](https://github.com/luckyframework/avram/pull/215)\n- Added: ability to `clone` a query [see Avram](https://github.com/luckyframework/avram/pull/214)\n- Fixed: `add_belongs_to` in alter statement using wrong Int size [see Avram](https://github.com/luckyframework/avram/pull/224)\n- Fixed: incorrect error message from `SaveOperation` updates in 0.17 [see Avram](https://github.com/luckyframework/avram/pull/225)\n- Added: `between` query method [see Avram](https://github.com/luckyframework/avram/pull/227)\n- Added: ordering queries by `NULLS FIRST` and `NULLS LAST` [see Avram](https://github.com/luckyframework/avram/pull/228)\n- Fixed: missing attributes from SaveOperation [see Avram](https://github.com/luckyframework/avram/pull/232)\n- Added: `db.schema.restore` and `db.schema.dump` tasks [see Avram](https://github.com/luckyframework/avram/pull/216)\n- Added: `group` query method for doing GROUP BY [see Avram](https://github.com/luckyframework/avram/pull/234)\n- Updated: SchemaEnforcer [see Avram](https://github.com/luckyframework/avram/pull/237)\n- Fixed: issue when calling `before` in SaveOperation [see Avram](https://github.com/luckyframework/avram/pull/240)\n- Added: JWT auth generation for API apps [see LuckyCli](https://github.com/luckyframework/lucky_cli/pull/395)\n- Updated: Serializers to be smarter with collections [see LuckyCli](https://github.com/luckyframework/lucky_cli/pull/397)\n- Updated: webpack to ignore `node_modules` directory [see LuckyCli](https://github.com/luckyframework/lucky_cli/pull/401)\n- Removed: cli `lucky init` task args [see LuckyCli](https://github.com/luckyframework/lucky_cli/pull/420)\n- Added: new `lucky init.custom` task to take args as `init` did before.\n- Fixed: `lucky init` to catch invalid project names properly.\n- Added: support for `browser_binary` in LuckyFlow [see LuckyFlow](https://github.com/luckyframework/lucky_flow/pull/59)\n\n\n### v0.17 (2019-08-13)\n\n- Rename: `Avram::BaseForm` to `Avram::SaveOperation` [see Avram](https://github.com/luckyframework/avram/pull/104)\n- Rename: `Avram::Field` to `Avram::Attribute` [see Avram](https://github.com/luckyframework/avram/commit/d3503a161670077c1d7b14484382132ea3ab423d)\n- Update: `number_to_currency` now returns `String` instead of writing to the view directly. [#809](https://github.com/luckyframework/lucky/pull/809)\n- Fixed: bug in running `build.release` task.\n- Update: mounted components render comments to show start and end of component. [#817](https://github.com/luckyframework/lucky/pull/817)\n- Revert: returning `String` for `highlight` helper. [#818](https://github.com/luckyframework/lucky/pull/818)\n- Update: text helpers that write to the view moved to their own module. [#820](https://github.com/luckyframework/lucky/pull/820)\n- Rename: `fillable` to `permit_columns`. [see Avram](https://github.com/luckyframework/avram/commit/b32b5a9b53688762e22c063ebad9f858cba636c0)\n- Added: `skip_if` option to `LogHandler`. [#824](https://github.com/luckyframework/lucky/pull/824)\n- Rename: `Lucky::Exposeable` to `Lucky::Exposable`. [#827](https://github.com/luckyframework/lucky/pull/827)\n- Rename: `Lucky::Routeable` to `Lucky::Routable`. [#827](https://github.com/luckyframework/lucky/pull/827)\n- Added: `memoize` macro. [#832](https://github.com/luckyframework/lucky/pull/832)\n- Added: `table_for` macro. [see Avram](https://github.com/luckyframework/avram/pull/127)\n- Added: `xml` render method for Actions. [#838](https://github.com/luckyframework/lucky/pull/838)\n- Rename: `text` render action to `plain_text`. [#838](https://github.com/luckyframework/lucky/pull/838)\n- Update: `responsive_meta_tag` to be flexible. [#835](https://github.com/luckyframework/lucky/pull/835)\n- Added: `Int16#to_param` and `Int64#to_param`.\n- Fixed: `append/replace_class` with no default. [#842](https://github.com/luckyframework/lucky/pull/842)\n- Added: multi database support. [see Avram](https://github.com/luckyframework/avram/pull/136)\n- Rename: `form_name` to `param_key`. [see Avram](https://github.com/luckyframework/avram/pull/140)\n- Fixed: 3rd party shards versions. [#855](https://github.com/luckyframework/lucky/pull/855)\n- Added: JSON support. [see Avram](https://github.com/luckyframework/avram/pull/108)\n- Update: calling `first` ensures proper order by. [see Avram](https://github.com/luckyframework/avram/pull/118)\n- Update: specifying primary keys is more explicit now. [see Avram](https://github.com/luckyframework/avram/commit/c6fe426a455fc1bf397d0b3b32069a97cd89d2df)\n- Added: custom primary key name support. [see Avram](https://github.com/luckyframework/avram/commit/a97c2b7dba359dda775bc587458a3d00571979e9)\n- Added: column and primary key support for `Int16`. [see Avram](https://github.com/luckyframework/avram/pull/131)\n- Rename: `Query.destroy_all` to `Query.truncate`. [see Avram](https://github.com/luckyframework/avram/pull/134)\n- Fixed: model inference with table names. [see Avram](https://github.com/luckyframework/avram/pull/144)\n- Rename: `virtual` to `attribute`. [see Avram](https://github.com/luckyframework/avram/pull/112)\n- Rename: `VirtualForm` to `Operation`. [see Avram](https://github.com/luckyframework/avram/commit/daaf55955c8131dea8533584720257ca444f23a7)\n- Added: support for `Array` fields. [see Avram](https://github.com/luckyframework/avram/pull/151)\n- Rename: association query methods now prefixed with `where_`. [see Avram](https://github.com/luckyframework/avram/commit/f298b8a2be2b0d9b753f33517093c72c261cd148)\n- Added: query method to bulk delete. [see Avram](https://github.com/luckyframework/avram/pull/169)\n- Update: association query methods no longer take a block. [see Avram](https://github.com/luckyframework/avram/commit/a8112f3b0abca05c06da0c3ba3f599dc6b06110b)\n- Added: support for polymorphic associations. [see Avram](https://github.com/luckyframework/avram/pull/165)\n- Added: `db.rollback_to` task. [see Avram](https://github.com/luckyframework/avram/pull/133)\n- Added: `db.migrations.status` task. [see Avram](https://github.com/luckyframework/avram/pull/135)\n- Added: `db.verify_connection` task. [see Avram](https://github.com/luckyframework/avram/pull/167)\n- Fixed: calling `lucky -v` from a lucky project failed. [see CLI](https://github.com/luckyframework/lucky_cli/pull/387)\n- Update: name convention for operations to be `VerbNoun`. [see CLI](https://github.com/luckyframework/lucky_cli/pull/386)\n- Added: `change_type` macro for migrations. [see Avram](https://github.com/luckyframework/avram/pull/209)\n\n### v0.16 (2019-08-03)\n\n- Added: support for Crystal 0.30.0\n\n### v0.15 (2019-06-12)\n\n- Removed `Lucky::Action::Status`. Use Crystal's `HTTP::Status` enum. [#769](https://github.com/luckyframework/lucky/pull/769)\n- CookieOverflowError is now checked when the cookie is set instead of later in middleware. [#761](https://github.com/luckyframework/lucky/pull/761)\n- Crystal 0.29.0 support added\n- Rename `Lucky::BaseApp` to `Lucky::BaseAppServer`\n- Rename `Sentry` to `LuckySentry`\n- **Breaking change** - Many text helpers now return a `String` instead of appending to the view (`cycle`, `excerpt`, `highlight`, `pluralize`, `time_ago_in_words`, `to_sentence`, `word_wrap`) [#781](https://github.com/luckyframework/lucky/pull/781)\n- Added new asset host option [#795](https://github.com/luckyframework/lucky/pull/795)\n- Added new secure header modules [#735](https://github.com/luckyframework/lucky/pull/735)\n- Added fallback routing [#731](https://github.com/luckyframework/lucky/pull/731)\n- Updated SSL Handler with HSTS option [#734](https://github.com/luckyframework/lucky/pull/734)\n- Components are now classes instead of modules [#714](https://github.com/luckyframework/lucky/pull/714)\n- Fixed `BaseHTTPClient` params [#726](https://github.com/luckyframework/lucky/pull/726)\n- Fixed passing `Symbol` for statuses in redirects [#730](https://github.com/luckyframework/lucky/pull/730)\n- More helpful errors [#733](https://github.com/luckyframework/lucky/pull/733), [#732](https://github.com/luckyframework/lucky/pull/732)\n\n\n### v.0.14 (2019-04-18)\n\n- Crystal 0.28.0 support added\n\n\n### v0.13 (2019-02-27)\n\n- Use [`Dexter`](https://github.com/luckyframework/dexter) as the logger. https://github.com/luckyframework/lucky_cli/pull/300 and https://github.com/luckyframework/lucky_cli/pull/299\n\n- Move scripts from `bin` to `script`. Ignore all of `bin` directory in `.gitignore`. See https://github.com/luckyframework/lucky_cli/pull/288 and https://github.com/luckyframework/lucky_cli/pull/301\n\n- `App` in `src/app.cr` should now inherit from `Lucky::BaseApp`. See https://github.com/luckyframework/lucky_cli/pull/287/files for an example.\n\n- Prefix id params with the resource name [#659](https://github.com/luckyframework/lucky/issues/659)\n\n- Added Action#url_without_query_params [#662](https://github.com/luckyframework/lucky/pull/662)\n\n- Added `Lucky::AssetHelpers.load_manifest` so that API apps don't need a blank manifest to compile.\n\n- Pages ignore unused exposures [#666](https://github.com/luckyframework/lucky/issues/666)\n\n- `unexpose` and `unexpose_if_exposed` have been removed because they are no\nlonger necessary now that pages ignore unused exposures.\n\n- `is` in queries has been renamed to `eq`. For example: `UserQuery.new.name.not.is(\"Emily\")` should now be `UserQuery.new.name.not.eq(\"Emily\")`. If passing in something that could be `Nil`, one must use `nilable_eq` instead. [avram#46](https://github.com/luckyframework/avram/pull/46)\n"
  },
  {
    "path": "CODE_OF_CONDUCT.md",
    "content": "# Open Source Code of Conduct\n\nIn order to foster an inclusive, kind, harassment-free, and cooperative community, the Lucky team enforces this code of conduct on our open source projects.\n\n## Summary\n\nHarassment in code and discussion or violation of physical boundaries is completely unacceptable anywhere in Lucky's codebases, issue trackers, chatrooms, mailing lists, meetups, and other events. Violators will be warned by the core team. Repeat violations will result in being blocked or banned by the core team at or before the 3rd violation.\n\n## In detail\n\nHarassment includes offensive verbal comments related to gender identity, gender expression, sexual orientation, disability, physical appearance, body size, race, religion, sexual images, deliberate intimidation, stalking, sustained disruption, and unwelcome sexual attention.\n\nIndividuals asked to stop any harassing behavior are expected to comply immediately.\n\nMaintainers are also subject to the anti-harassment policy.\n\nIf anyone engages in harassing behavior, including maintainers, we may take appropriate action, up to and including warning the offender, deletion of comments, removal from the project’s codebase and communication systems, and escalation to GitHub support.\n\nIf you are being harassed, notice that someone else is being harassed, or have any other concerns, please contact a member of the core team on Twitter on privately on Discord (@Paul Smith).\n\nWe expect everyone to follow these rules anywhere in Lucky's codebases, issue trackers, chatrooms, and mailing lists.\n\nFinally, don't forget that it is human to make mistakes! We all do. Let’s work together to help each other, resolve issues, and learn from the mistakes that we will all inevitably make from time to time.\n\n## Avoid using negative emoji reactions\n\nAvoid using the 👎 or 😕 emoji for comments or PRs. It does not provide\nenough value because it is not clear\n**why** you are confused or what you disagree with. Instead, write a comment\nexplaining what you found confusing or that you don't like.\n\nSome example responses instead of emoji:\n\n- \"I understand what you're trying to say, but I think that it will cause X problem.\"\n- \"I'm not sure I understand, could you include a gist explaining the use-case?\"\n\n## Thanks\n\nThanks to the CocoaPods Code of Conduct, Bundler Code of Conduct, JSConf Code of Conduct, and Contributor Covenant for inspiration and ideas.\n\n## License\n\nTo the extent possible under law, the Lucky team has waived all copyright and related or neighboring rights to the Code of Conduct. This work is published from the United States.\n"
  },
  {
    "path": "CONTRIBUTING.md",
    "content": "# Contributing to Lucky\n\nWe love pull requests from everyone. By participating in this project, you\nagree to abide by the project [code of conduct].\n\n[code of conduct]: https://github.com/luckyframework/lucky/blob/main/CODE_OF_CONDUCT.md\n\nHere are some ways _you_ can contribute:\n\n- by using alpha, beta, and prerelease versions\n- by reporting bugs\n- by suggesting new features\n- by writing or editing documentation\n- by writing specifications\n- by writing code ( **no patch is too small** : fix typos, add comments, clean up inconsistent whitespace )\n- by refactoring code\n- by closing [issues][]\n- by reviewing patches\n\n[issues]: https://github.com/luckyframework/lucky/issues\n\n## Submitting an Issue\n\n- We use the [GitHub issue tracker][issues] to track bugs and features.\n- Before submitting a bug report or feature request, check to make sure it hasn't\n  already been submitted.\n- When submitting a bug report, please include a [Gist][] that includes a stack\n  trace and any details that may be necessary to reproduce the bug, including\n  your Crystal version, and operating system. Ideally, a bug report\n  should include a pull request with failing specs.\n\n[gist]: https://gist.github.com/\n\n## Cleaning Up Issues\n\n- Issues that have no response from the submitter will be closed after 30 days.\n- Issues will be closed once they're assumed to be fixed or answered. If the\n  maintainer is wrong, it can be opened again.\n- If your issue is closed by mistake, please understand and explain the issue.\n  We will happily reopen the issue.\n\n## Setting Up Local Environment\n\n1. Fork it ( <https://github.com/luckyframework/lucky/fork> )\n1. Create your feature branch (git checkout -b my-new-feature)\n1. Install docker: <https://docs.docker.com/compose/install/>\n1. Run `script/setup` to build the Docker containers with everything you need.\n1. Make your changes\n1. Make sure specs pass: `script/test`.\n1. Add a note to the CHANGELOG\n1. Commit your changes (git commit -am 'Add some feature')\n1. Push to the branch (git push origin my-new-feature)\n1. Create a new Pull Request\n\n> Run specific tests with `script/test <path_to_spec>`\n\n## Submitting a Pull Request\n\n1. [Fork][fork] the [official repository][repo].\n2. [Create a topic branch.][branch]\n3. Implement your feature or bug fix.\n4. Add, commit, and push your changes.\n5. [Submit a pull request.][pr]\n\n## Notes\n\n- Please add tests if you changed code. Contributions without tests won't be accepted.\n- If you don't know how to add tests, please put in a PR and leave a comment\n  asking for help. We love helping!\n- Please don't update the Gem version.\n\n[repo]: https://github.com/luckyframework/lucky/\n[fork]: https://help.github.com/articles/fork-a-repo/\n[branch]: https://help.github.com/articles/creating-and-deleting-branches-within-your-repository/\n[pr]: https://help.github.com/articles/using-pull-requests/\n\nInspired by <https://github.com/middleman/middleman-heroku/blob/master/CONTRIBUTING.md>\n"
  },
  {
    "path": "Dockerfile",
    "content": "FROM crystallang/crystal:latest\nWORKDIR /data\n\nRUN apt-get update && \\\n  apt-get install -y curl libreadline-dev unzip && \\\n  curl -fsSL https://bun.sh/install | bash && \\\n  ln -s /root/.bun/bin/bun /usr/local/bin/bun && \\\n  # Cleanup leftovers\n  apt-get clean && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*\n\nCOPY . /data\n"
  },
  {
    "path": "LICENSE",
    "content": "The MIT License (MIT)\n\nCopyright (c) 2017 Paul Smith\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in\nall copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN\nTHE SOFTWARE.\n"
  },
  {
    "path": "PULL_REQUEST_TEMPLATE.md",
    "content": "## Purpose\nDescribe the feature or issue and link to the related issue.\nIf no issue has been opened about this, be sure to open an issue first to discuss the need for this PR.\n\n## Description\nPlease include any relevant code samples or screen shots that may help to overview of this PR.\nLink to specific lines of code, or examples if you need to.\n\n## Checklist\n* [ ] - An issue already exists detailing the issue/or feature request that this PR fixes\n* [ ] - All specs are formatted with `crystal tool format spec src`\n* [ ] - Inline documentation has been added and/or updated\n* [ ] - Lucky builds on docker with `./script/setup`\n* [ ] - All builds and specs pass on docker with `./script/test`\n"
  },
  {
    "path": "README.md",
    "content": "[![github banner-short](https://user-images.githubusercontent.com/22394/26989908-dd99cc2c-4d22-11e7-9576-c6aeada2bd63.png)](http://luckyframework.org)\n\n[![Version](https://img.shields.io/github/tag/luckyframework/lucky.svg?maxAge=360&label=version)](https://github.com/luckyframework/lucky/releases/latest)\n[![License](https://img.shields.io/github/license/luckyframework/lucky.svg)](https://github.com/luckyframework/lucky/blob/main/LICENSE)\n\n[![API Documentation Website](https://img.shields.io/website?down_color=red&down_message=Offline&label=API%20Documentation&up_message=Online&url=https%3A%2F%2Fluckyframework.github.io%2Flucky%2F)](https://luckyframework.github.io/lucky)\n[![Lucky Guides Website](https://img.shields.io/website?down_color=red&down_message=Offline&label=Lucky%20Guides&up_message=Online&url=https%3A%2F%2Fluckyframework.org%2Fguides)](https://luckyframework.org/guides)\n\n[![Discord](https://img.shields.io/discord/743896265057632256)](https://discord.gg/HeqJUcb)\n\nThe goal: prevent bugs, forget about most performance issues, and spend more\ntime on code instead of debugging and fixing tests.\n\nIn summary, make writing stunning web applications fast, fun, and easy.\n\n## Coming from Rails?\n\n- [Ruby on Rails to Lucky on Crystal: Blazing fast, fewer bugs, and even more fun.\n  ](https://hackernoon.com/ruby-on-rails-to-lucky-on-crystal-blazing-fast-fewer-bugs-and-even-more-fun-104010913fec)\n\n## Try Lucky\n\nLucky has a [fresh new set of guides](https://luckyframework.org/guides/) that\nmake it easy to get started.\n\nFeel free to say hi or ask questions on our\n[chat room](https://luckyframework.org/chat).\n\nOr you can copy a real working app with [Lucky JumpStart](https://github.com/stephendolan/lucky_jumpstart/).\n\n## Installing Lucky\n\nTo install Lucky, read the [Installing Lucky](https://luckyframework.org/guides/getting-started/installing) guides for your Operating System.\nThe guide will walk you through installing a command-line utility used for generating new Lucky applications.\n\n## Keep up-to-date\n\nKeep up to date by following [@luckyframework](https://twitter.com/luckyframework) on Twitter.\n\n## Documentation\n\n[API (main)](https://luckyframework.github.io/lucky/)\n\n## What's it look like?\n\n### JSON endpoint:\n\n```crystal\nclass Api::Users::Show < ApiAction\n  get \"/api/users/:user_id\" do\n    user = UserQuery.find(user_id)\n    json UserSerializer.new(user)\n  end\nend\n```\n\n- If you want you can set up custom routes like `get \"/sign_in\"` for non REST routes.\n- A `user_id` method is generated because there is a `user_id` route parameter.\n- Use `json` to render JSON. [Extract\n  serializers](https://luckyframework.org/guides/writing-json-apis/#respond-with-json)\n  for reusable JSON responses.\n\n### Database models\n\n```crystal\n# Set up the model\nclass User < BaseModel\n  table do\n    column last_active_at : Time\n    column last_name : String\n    column nickname : String?\n  end\nend\n```\n\n- Sets up the columns that you’d like to use, along with their types\n- You can add `?` to the type when the column can be `nil` . Crystal will then\n  help you remember not to call methods on it that won't work.\n- Lucky will set up presence validations for required fields\n  (`last_active_at` and `last_name` since they are not marked as nilable).\n\n### Querying the database\n\n```crystal\n# Add some methods to help query the database\nclass UserQuery < User::BaseQuery\n  def recently_active\n    last_active_at.gt(1.week.ago)\n  end\n\n  def sorted_by_last_name\n    last_name.lower.desc_order\n  end\nend\n\n# Query the database\nUserQuery.new.recently_active.sorted_by_last_name\n```\n\n- `User::BaseQuery` is automatically generated when you define a model. Inherit\n  from it to customize queries.\n- Set up named scopes with instance methods.\n- Lucky sets up methods for all the columns so that if you mistype a column\n  name it will tell you at compile-time.\n- Use the `lower` method on a `String` column to make sure Postgres sorts\n  everything in lowercase.\n- Use `gt` to get users last active greater than 1 week ago. Lucky has lots\n  of powerful abstractions for creating complex queries, and type specific\n  methods (like `lower`).\n\n### Rendering HTML:\n\n```crystal\nclass Users::Index < BrowserAction\n  get \"/users\" do\n    users = UserQuery.new.sorted_by_last_name\n    render IndexPage, users: users\n  end\nend\n\nclass Users::IndexPage < MainLayout\n  needs users : UserQuery\n\n  def content\n    render_new_user_button\n    render_user_list\n  end\n\n  private def render_new_user_button\n    link \"New User\", to: Users::New\n  end\n\n  private def render_user_list\n    ul class: \"user-list\" do\n      users.each do |user|\n        li do\n          link user.name, to: Users::Show.with(user.id)\n          text \" - \"\n          text user.nickname || \"No Nickname\"\n        end\n      end\n    end\n  end\nend\n```\n\n- `needs users : UserQuery` tells the compiler that it must be passed users\n  of the type `UserQuery`.\n- If you forget to pass something that a page needs, it will let you know at\n  compile time. **Fewer bugs and faster debugging**.\n- Write tags with Crystal methods. Tags are automatically closed and\n  whitespace is removed.\n- Easily extract named methods since pages are made of regular classes and\n  methods. **This makes your HTML pages incredibly easy to read.**\n- Link to other pages with ease. Just use the action name: `Users::New`. Pass\n  params using `with`: `Users::Show.with(user.id)`. No more trying to remember path\n  helpers and whether the helper is pluralized or not - If you forget to pass a\n  param to a route, Lucky will let you know at compile-time.\n- Since we defined `column nickname : String?` as nilable, Lucky would fail\n  to compile the page if you just did `text user.nickname` since it disallows\n  printing `nil`. So instead we add a fallback `\"No Nickname\"`. **No more\n  accidentally printing empty text in HTML!**\n\n## Testing\n\nYou need to make sure to install the Crystal dependencies.\n\n1. Run `shards install`\n1. Run `crystal spec` from the project root.\n\n## Contributing\n\nSee [CONTRIBUTING.md](CONTRIBUTING.md)\n\n### Lucky to have you!\n\nWe love all of the community members that have put in hard work to make Lucky better.\nIf you're one of those people, we want to give you a t-shirt!\n\nTo get a shirt, we ask that you have made a significant contribution to Lucky.\nThis includes things like submitting PRs with bug fixes and feature implementations, helping other members\nwork through problems, and deploying real world applications using Lucky!\n\nTo claim your shirt, [fill in this form](https://forms.gle/w3PJ4pww8WDAuJov5).\n\n## Contributors\n\n[paulcsmith](https://github.com/paulcsmith) Paul Smith - Original Creator of Lucky\n\n<a href=\"https://github.com/luckyframework/lucky/graphs/contributors\">\n  <img src=\"https://contrib.rocks/image?repo=luckyframework/lucky\" />\n</a>\n\nMade with [contrib.rocks](https://contrib.rocks).\n\n## Thanks & attributions\n\n- SessionHandler, CookieHandler and FlashHandler are based on [Amber](https://github.com/amberframework/amber). Thank you to the Amber team!\n- Thanks to Rails for inspiring many of the ideas that are easy to take for\n  granted. Convention over configuration, removing boilerplate, and most\n  importantly - focusing on developer happiness.\n- Thanks to Phoenix, Ecto and Elixir for inspiring Avram's save operations,\n  Lucky's single base actions and pipes, and focusing on helpful error\n  messages.\n- `lucky watch` based heavily on [Sentry](https://github.com/samueleaton/sentry). Thanks [@samueleaton](https://github.com/samueleaton)!\n"
  },
  {
    "path": "UPGRADE_NOTES.md",
    "content": "## Upgrading from 1.3.0 to 1.4.0\n\nFor a full diff of necessary changes, please see [LuckyDiff](https://luckydiff.com?from=1.3.0&to=1.4.0).\n\n- Upgrade Lucky CLI (homebrew)\n\n```\nbrew update\nbrew upgrade lucky\n```\n\n- Upgrade Lucky CLI (Linux)\n\n> Remove the existing Lucky binary and follow the Linux\n> instructions in this section\n> https://luckyframework.org/guides/getting-started/installing#on-linux\n\n- Update versions in `shard.yml`\n  - Lucky should be `~> 1.4.0`\n  - Avram should be `~> 1.4.0`\n  - Authentic should be `~> 1.0.2`\n  - LuckyEnv should be `~> 0.3.0`\n  - LuckySecTester should be `~> 0.3.3`\n\n- Run `shards update`\n\n- Upgrade Lucky CLI on Windows (Scoop)\n\n```\nscoop bucket add lucky https://github.com/luckyframework/scoop-bucket\nscoop install lucky\n```\n\n### General updates\n\n- Update: your BaseSerializer and remove inheritance then add `include Lucky::Serializable`. [See PR](https://github.com/luckyframework/lucky/pull/1947)\n- Remove: all setup bash scripts in favor of Crystal versions. [See PR](https://github.com/luckyframework/lucky_cli/pull/875)\n- Remove: any local variable, or argument passed to a method that has a `?` for Crystal 1.16 compatibility. [See PR](https://github.com/luckyframework/avram/pull/1083)\n- Update: any call to `where_*` join methods in favor of `join_*` join methods. [See PR](https://github.com/luckyframework/avram/pull/1090)\n\n```crystal\n# Update this\nUserQuery.new.where_subscriptions(SubscriptionQuery.new)\n# to this\nUserQuery.new.join_subscriptions(SubscriptionQuery.new)\n```\n\n### Optional update\n\n- Update: to Crystal 1.16 or later and use type-safe ENV methods. [See PR](https://github.com/luckyframework/lucky_env/pull/39)\n```crystal\nLuckyEnv.init_env(\".env\")\nLuckyEnv.load(\".env\")\n\nENV[\"DEV_PORT\"] #=> \"3000\"\nLuckyEnv.dev_port #=> 3000\n```\n\n## Upgrading from 1.2.0 to 1.3.0\n\nFor a full diff of necessary changes, please see [LuckyDiff](https://luckydiff.com?from=1.2.0&to=1.3.0).\n\n- Upgrade Lucky CLI (homebrew)\n\n```\nbrew update\nbrew upgrade lucky\n```\n\n- Upgrade Lucky CLI (Linux)\n\n> Remove the existing Lucky binary and follow the Linux\n> instructions in this section\n> https://luckyframework.org/guides/getting-started/installing#on-linux\n\n- Update versions in `shard.yml`\n  - Lucky should be `~> 1.3.0`\n  - Avram should be `~> 1.3.0`\n  - Authentic should be `~> 1.0.1`\n  - Carbon should be `~> 0.6.0`\n  - Carbon Adapter should be `~> 0.6.0`\n\n- Run `shards update`\n\n- Upgrade Lucky CLI on Windows (Scoop)\n\n```\nscoop bucket add lucky https://github.com/luckyframework/scoop-bucket\nscoop install lucky\n```\n\n### General updates\n\nNo required updates needed for this release.\n\n### Optional update\n\n- Update: to Crystal 1.14\n- Update: All previously pre-compiled tasks should now show in your `./bin/` as Crystal files. Build these to run them as compiled.\n\n```\ncrystal build --release bin/lucky.gen.secret_key.cr -o bin/lucky.gen.secret_key\ncrystal build --release bin/lucky.watch.cr -o bin/lucky.watch\ncrystal build --release bin/lucky.exec.cr -o bin/lucky.exec\n# ... etc...\n```\n\n## Upgrading from 1.1.0 to 1.2.0\n\nFor a full diff of necessary changes, please see [LuckyDiff](https://luckydiff.com?from=1.1.0&to=1.2.0).\n\n- Upgrade Lucky CLI (homebrew)\n\n```\nbrew update\nbrew upgrade lucky\n```\n\n- Upgrade Lucky CLI (Linux)\n\n> Remove the existing Lucky binary and follow the Linux\n> instructions in this section\n> https://luckyframework.org/guides/getting-started/installing#on-linux\n\n- Update versions in `shard.yml`\n  - Lucky should be `~> 1.2.0`\n  - Avram should be `~> 1.2.0`\n  - Carbon should be `~> 0.5.1`\n  - Carbon Adapter should be `~> 0.5.0`\n  - LuckyFlow should be `~> 0.10.0`\n\n- Run `shards update`\n\n- Upgrade Lucky CLI on Windows (Scoop)\n\n```\nscoop bucket add lucky https://github.com/luckyframework/scoop-bucket\nscoop install lucky\n```\n\n### General updates\n\n- Add: the annotation `@[DB::Field(ignore: true)]` to any instance variables you've added to your models. [See PR](https://github.com/luckyframework/avram/pull/996)\n- Remove: the Nexploit NPM package from your Github Actions if you're using SecTester. [See PR](https://github.com/luckyframework/lucky_sec_tester/pull/34)\n\n### Optional update\n\n- Replace: any use of `Avram::Nothing.new` with `IGNORE`. [See PR](https://github.com/luckyframework/avram/pull/1018)\n\n## Upgrading from 1.0.0 to 1.1.0\n\nFor a full diff of necessary changes, please see [LuckyDiff](https://luckydiff.com?from=1.0.0&to=1.1.0).\n\n- Upgrade Lucky CLI (homebrew)\n\n```\nbrew update\nbrew upgrade lucky\n```\n\n- Upgrade Lucky CLI (Linux)\n\n> Remove the existing Lucky binary and follow the Linux\n> instructions in this section\n> https://luckyframework.org/guides/getting-started/installing#on-linux\n\n- Update versions in `shard.yml`\n  - Lucky should be `~> 1.1.0`\n  - Avram should be `~> 1.1.0`\n  - Authentic should be `~> 1.0.0`\n  - Carbon should be `~> 0.4.0`\n  - LuckyTask should be `~> 0.3.0`\n  - LuckyFlow should be `~> 0.9.2`\n  - LuckySecTester should be `~> 0.3.2`\n\n\n- Run `shards update`\n\n### General updates\n\n- Add: `Avram.initialize_logging` to your `config/log.cr` file near the bottom. [See PR](https://github.com/luckyframework/avram/pull/967)\n- Update: all LuckyTask tasks. [See PR](https://github.com/luckyframework/lucky_task/pull/25)\n```crystal\n# All help_message instance methods are macro calls\ndef help_message\n  \"my help message\"\nend\n\n# is now\nhelp_message \"my help message\"\n\n# Calls to `name`, `summary`, or `help_message` from your task `call` method are now classes.\ndef call\n  # `name` is now\n  self.class.task_name\n  # `summary` is now\n  self.class.task_summary\n  # `help_message` is now\n  self.class.task_help_message\nend\n```\n\n### Optional update\n\n- Add: `allow_blank: true` on String columns you want to allow empty strings to be saved. [See PR](https://github.com/luckyframework/avram/pull/956)\n```crystal\nclass Post < BaseModel\n  table do\n    column title : String\n\n    # Field is required, but storing \"\" is ok\n    column sub_title : String, allow_blank: true\n  end\nend\n```\n\n## Upgrading from 1.0.0-rc1 to 1.0.0\n\nFor a full diff of necessary changes, please see [LuckyDiff](https://luckydiff.com?from=1.0.0-rc1&to=1.0.0).\n\n- Upgrade Lucky CLI (homebrew)\n\n```\nbrew update\nbrew upgrade lucky\n```\n\n- Upgrade Lucky CLI (Linux)\n\n> Remove the existing Lucky binary and follow the Linux\n> instructions in this section\n> https://luckyframework.org/guides/getting-started/installing#on-linux\n\n- Update versions in `shard.yml`\n  - Lucky should be `~> 1.0.0`\n  - Avram should be `~> 1.0.0`\n  - Authentic should be `~> 1.0.0`\n\n- Run `shards update`\n\n### General updates\n\n- Update: to at least Crystal 1.6 or later.\n- Update: Any use of a `DeleteOperation` that assumed the record could be nil should now assume the record will always exist in the block. [See PR for more details](https://github.com/luckyframework/avram/pull/887)\n\n### Optional update\n\n## Upgrading from 0.30 to 1.0.0-rc1\n\nFor a full diff of necessary changes, please see [LuckyDiff](https://luckydiff.com?from=0.30.0&to=1.0.0-rc1).\n\n- Upgrade Lucky CLI (homebrew)\n\n```\nbrew update\nbrew upgrade lucky\n```\n\n- Upgrade Lucky CLI (Linux)\n\n> Remove the existing Lucky binary and follow the Linux\n> instructions in this section\n> https://luckyframework.org/guides/getting-started/installing#on-linux\n\n- Update versions in `shard.yml`\n  - Lucky should be `~> 1.0.0-rc1`\n  - Avram should be `~> 1.0.0-rc1`\n  - Authentic should be `~> 0.9.0`\n  - Carbon (and carbon_sendgrid_adapter) should be `~> 0.3.0`\n  - LuckyFlow should be `~> 0.9.0`\n\n- Run `shards update`\n\n### General updates\n\n- Add: Avram to your `shard.yml` as a dependency.\n- Add: `require \"avram/lucky\"` to `src/shards.cr` right below `require \"lucky\"`. [See PR](https://github.com/luckyframework/avram/pull/772)\n- Add: `require \"avram/lucky/tasks\"` to `tasks.cr` right below `require \"lucky/tasks/**\"`. [See PR](https://github.com/luckyframework/lucky_cli/pull/764)\n- Update: to Crystal 1.4 or later.\n- Add: `include Lucky::RedirectableTurbolinksSupport` in your `BrowserAction` if you are using turbolinks.\n- Add: `live_reload_connect_tag` to your `src/components/shared/layout_head.cr` and `reload_port: 3001` to your `config/watch.yml` file for live browser reloading. [See this PR](https://github.com/luckyframework/lucky_cli/pull/767) and [this PR](https://github.com/luckyframework/lucky/pull/1693)\n- Update: `Avram::Params.new()` now takes `Hash(String, Array(String))` instead of `Hash(String, String)`. [See PR](https://github.com/luckyframework/avram/pull/847)\n- Update: arg names in `validate_numeric` from `less_than` and `greater_than` to `at_least` and `no_more_than`. [See PR](https://github.com/luckyframework/avram/pull/867)\n- Update: your LuckyFlow configuration...\n```crystal\n# spec/spec_helper.cr\n# ...\nrequire \"spec\"\n# ...\nrequire \"lucky_flow\"\nrequire \"lucky_flow/ext/lucky\"\nrequire \"lucky_flow/ext/avram\"\n# ...\n\n# spec/setup/configure_lucky_flow.cr\n# ...\nLuckyFlow::Spec.setup\n```\n\n### Optional updates\n\n- Update the `lucky_sec_tester` shard to version `0.1.0`\n- Replace turbolinks with [Turbo](https://turbo.hotwired.dev/)\n- Replace laravel-mix with [Vite](https://vitejs.dev/)\n\n\n## Upgrading from 0.29 to 0.30\n\nFor a full diff of necessary changes, please see [LuckyDiff](https://luckydiff.com?from=0.29.0&to=0.30.0).\n\n- Upgrade Lucky CLI (homebrew)\n\n```\nbrew update\nbrew upgrade lucky\n```\n\n- Upgrade Lucky CLI (Linux)\n\n> Remove the existing Lucky binary and follow the Linux\n> instructions in this section\n> https://luckyframework.org/guides/getting-started/installing#on-linux\n\n- Update versions in `shard.yml`\n  - Lucky should be `~> 0.30.0`\n  - Authentic should be `~> 0.8.2`\n  - LuckyFlow should be `~> 0.7.3` *NOTE*: 0.8.0 is released, but may not be compatible yet\n\n- Run `shards update`\n\n### General updates\n\n- Update: `spec/support/api_client.cr` with `app AppServer.new` defined in the class.\n```crystal\nclass ApiClient < Lucky::BaseHTTPClient\n  app AppServer.new\n\n  def initialize\n    super\n    headers(\"Content-Type\": \"application/json\")\n  end\n\n  def self.auth(user : User)\n    new.headers(\"Authorization\": UserToken.generate(user))\n  end\nend\n```\n- Update: the `request.remote_ip` method now pulls from the last (instead of first) valid IP in the `X-Forwarded-For` list. [See PR for details](https://github.com/luckyframework/lucky/pull/1675)\n- Update: All primary repo branches are now `main`. Adjust any references accordingly.\n- Update: `./script/system_check` and remove mentions of `ensure_process_runner_installed`. Nox is built-in [See PR for details](https://github.com/luckyframework/lucky_cli/pull/720)\n\n\n### Optional updates\n\n- Update: uses of `AvramSlugify` to `Avram::Slugify`. [See PR for details](https://github.com/luckyframework/avram/pull/786)\n- Update: specs to use transactions instead of truncate. [See PR for details](https://github.com/luckyframework/avram/pull/780)\n```crystal\n# in spec/spec_helper.cr\nrequire \"./setup/**\"\n\n# Add this line\nAvram::SpecHelper.use_transactional_specs(AppDatabase)\n\ninclude Carbon::Expectations\ninclude Lucky::RequestExpectations\ninclude LuckyFlow::Expectations\n```\n- Remove: the `spec/setup/clean_database.cr` file. This accompanies the transactional specs update\n- Update: the `spec/setup/start_app_server.cr` file. This file is no longer needed if your action specs make standard calls, and are not using LuckyFlow. [See PR for details](https://github.com/luckyframework/lucky/pull/1644)\n\n\n## Upgrading from 0.28 to 0.29\n\nFor a full diff of necessary changes, please see [LuckyDiff](https://luckydiff.com?from=0.28.2&to=0.29.0).\n\n- Upgrade Lucky CLI (homebrew)\n\n```\nbrew update\nbrew upgrade lucky\n```\n\n- Upgrade Lucky CLI (Linux)\n\n> Remove the existing Lucky binary and follow the Linux\n> instructions in this section\n> https://luckyframework.org/guides/getting-started/installing#on-linux\n\n- Update versions in `shard.yml`\n  - Crystal should be `\">= 1.0.0\"`\n  - Lucky should be `~> 0.29.0`\n  - Authentic should be `~> 0.8.1`\n  - Caron SendgidAdapter should be `~> 0.2.0` if you're using SendGrid\n  - LuckyEnv should be `~> 0.1.4`\n  - LuckyTask should be `~> 0.1.1`\n  - JWT should be `~> 1.6.0`\n\n- Run `shards update`\n\n### General updates\n\n- Remove: any usage of the `lucky build.release` task. Use `shards build --release --production` instead. [See PR for details](https://github.com/luckyframework/lucky/pull/1612)\n- Update: to Crystal version 1.0.0 or greater. Versions below 1.0 are no longer supported. [See PR for details](https://github.com/luckyframework/lucky/pull/1618)\n- Update: your `AppServer` in `src/app_server.cr` to have a `listen` method defined. This method is now abstract on `Lucky::BaseAppServer`. [See PR for details](https://github.com/luckyframework/lucky/pull/1622)\n- Update: if you use UUID for primary keys in your models, ensure you've added the \"pgcrypto\" extension to your DB. The `id` value will no longer be generated on the Crystal side. [See PR for details](https://github.com/luckyframework/avram/pull/725)\n- Update: any usage of the `Status` enums in your SaveOperations to be `OperationStatus`. [See PR for details](https://github.com/luckyframework/avram/pull/759)\n- Remove: any usage of `route` or `nested_route` from your actions, and replace them with the actual route. (Optionally, you can use the [Legacy Routing Shard](https://github.com/matthewmcgarvey/lucky_legacy_routing)) [See PR for details](https://github.com/luckyframework/lucky/pull/1597)\n- Update: your `src/app.cr`, and move the requires for `config/server` and `config/**` to the top of the require stack. [See PR for details](https://github.com/luckyframework/lucky_cli/pull/676)\n- Update: your `package.json` (Full Apps only) to use `yarn run mix` instead of just `mix`. [See PR for details](https://github.com/luckyframework/lucky_cli/pull/682)\n- Update: your `src/app_server.cr` middleware stack with `Lucky::RequestIdHandler.new` at the top of the stack before `Lucky::ForceSSLHandler.new`. [See PR for details](https://github.com/luckyframework/lucky_cli/pull/700)\n- Update: any usage of `add_belongs_to` with namespaced models to specify the `references` option. [See PR for details](https://github.com/luckyframework/avram/pull/742)\n- Update: the `error_html` method in `src/actions/errors/show.cr`. Replace the following code\n```diff\n- html Errors::ShowPage, message: message, status: status\n+ html_with_status Errors::ShowPage, status, message: message, status_code: status\n```\n- Rename: the `status` variable to `status_code` in `src/pages/errors/show_page.cr`\n\n\n### Optional updates\n\n- Add: a new config `Lucky::RequestIdHandler` in `config/server.cr` to set a request ID.\n```crystal\n#...\n\nLucky::RequestIdHandler.configure do |settings|\n  settings.set_request_id = ->(context : HTTP::Server::Context) {\n    UUID.random.to_s\n  }\nend\n```\n- Add: query cache to `config/database.cr`. [See PR for details](https://github.com/luckyframework/avram/pull/763)\n```crystal\nAvram.configure do |settings|\n  settings.database_to_migrate = AppDatabase\n\n  # In production, allow lazy loading (N+1).\n  # In development and test, raise an error if you forget to preload associations\n  settings.lazy_load_enabled = LuckyEnv.production?\n\n  # Disable query cache during tests\n  settings.query_cache_enabled = !LuckyEnv.test?\nend\n```\n\n\n## Upgrading from 0.27 to 0.28\n\nFor a full diff of necessary changes, please see [LuckyDiff](https://luckydiff.com?from=0.27.2&to=0.28.0).\n\n- Upgrade Lucky CLI (homebrew)\n\n```\nbrew update\nbrew upgrade lucky\n```\n\n- Upgrade Lucky CLI (Linux)\n\n> Remove the existing Lucky binary and follow the Linux\n> instructions in this section\n> https://luckyframework.org/guides/getting-started/installing#on-linux\n\n- Update versions in `shard.yml`\n  - Crystal should be `\">= 1.0.0\"`\n  - Lucky should be `~> 0.28.0`\n  - Authentic should be `~> 0.8.0`\n  - Carbon should be `~> 0.2.0`\n  - Caron SendgidAdapter should be `~> 0.1.0` if you're using SendGrid\n  - Dotenv should be replaced with [LuckyEnv ~> 0.1.3](https://github.com/luckyframework/lucky_env)\n\n- Run `shards update`\n\n### General updates\n\n- Remove: `needs context : HTTP::Server::Context` from any component, as well as passing it in to the `mount()` for the components. [See PR for details](https://github.com/luckyframework/lucky/pull/1488)\n- Rename: all `DeleteOperation.destroy` calls to `DeleteOperation.delete`\n- Update: `avram_enum` to use the Crystal `enum`. [See PR for details](https://github.com/luckyframework/avram/pull/698)\n```diff\n# Models get this update\n- avram_enum State do\n+ enum State\n    Started\n    Ended\n  end\n\n# Factories get this update\n- state Thing::State.new(:started)\n+ state Thing::State::Started\n\n# Operations get this update\n- SaveThing.create(state: Thing::State.new(:started)) do |op, t|\n+ SaveThing.create(state: Thing::State::Started) do |op, t|\n\n# Queries get this update\n- ThingQuery.new.state(Thing::State.new(:started).value)\n+ ThingQuery.new.state(Thing::State::Started)\n```\n- Update: your `config/env.cr` to this.\n```crystal\n# Environments are managed using `LuckyEnv`. By default, development, production\n# and test are supported.\n\n# If you need additional environment support, add it here\n# LuckyEnv.add_env :staging\n```\n- Update: any use of `Lucky::Env` to use `LuckyEnv`. (e.g. `Lucky::Env.test?` -> `LuckyEnv.test?`). [See PR for details](https://github.com/luckyframework/lucky_cli/pull/655)\n- Update: any use of `Lucky::Env.name` to use `LuckyEnv.environment`.\n- Update: any use of `route` or `nested_route`, and replace them with the generated routes. Use `lucky routes` to view all generated routes. If you still need this, you can use the [Lucky Legacy Routing](https://github.com/matthewmcgarvey/lucky_legacy_routing) shard.\n- Add: the [luckyframework/carbon_sendgrid_adapter](https://github.com/luckyframework/carbon_sendgrid_adapter) shard if you're using Sendgrid to send mail. Be sure to `require \"carbon_sendgrid_adapter\"` in `config/email.cr`.\n\n\n### Optional updates\n\n- Update: all routes to use underscore (`_`) instead of dash (`-`) as word separator. Include the `Lucky::EnforceUnderscoredRoute` module in your base actions. (e.g. `/this-route` -> `/this_route`)\n```crystal\nclass BrowserAction < Lucky::Action\n  include Lucky::EnforceUnderscoredRoute\n  # ...\nend\n```\n- Update: `send_text_response()` responses if you're passing a raw JSON string to use `raw_json()` instead.\n- Add: `include Lucky::SecureHeaders::DisableFLoC` to your `BrowserAction` to disable FLoC.\n```crystal\nclass BrowserAction < Lucky::Action\n  include Lucky::SecureHeaders::DisableFLoC\n  # ...\nend\n```\n- Remove: `normalize-scss` from your `package.json` and replace with `modern-normalize` if you're using `normalize-scss`.\n- Update: any query where you write code like `if SomeQuery.new.first?` to `if SomeQuery.new.any?`. `.any?` returns a Bool instead of loading the whole object which has a small performance gain.\n- Add: the [Breeze](https://github.com/luckyframework/breeze) shard to your development workflow!\n\n\n## Upgrading from 0.26 to 0.27\n\nFor a full diff of necessary changes, please see [LuckyDiff](https://luckydiff.com?from=0.26.0&to=0.27.0).\n\n- Upgrade Lucky CLI (homebrew)\n\n```\nbrew update\nbrew upgrade lucky\n```\n\n- Upgrade Lucky CLI (Linux)\n\n> Remove the existing Lucky binary and follow the Linux\n> instructions in this section\n> https://luckyframework.org/guides/getting-started/installing#on-linux\n\n- Update versions in `shard.yml`\n  - Crystal should be `\">= 0.36.1, < 2.0.0\"`\n  - Lucky should be `~> 0.27.0`\n  - Authentic should be `~> 0.7.3`\n  - Carbon should be `~> 0.1.4`\n  - Dotenv should be `~> 1.0.0` or replace with [LuckyEnv 0.1.0](https://github.com/luckyframework/lucky_env)\n  - LuckyFlow should be `~> 0.7.3`\n  - JWT (if you use Auth) should be `~> 1.5.1`\n  - LuckyTask needs to be added as a dependency\n    ```\n    lucky_task:\n      github: luckyframework/lucky_task\n      version: ~> 0.1.0\n    ```\n\n- Run `shards update`\n\n### General updates\n\n- Add: the new `lucky_task` shard as a dependency.\n- Update: your `tasks.cr` file with the new require, and module name change:\n  ```crystal\n  # tasks.cr\n  ENV[\"LUCKY_TASK\"] = \"true\"\n  # Load Lucky and the app (actions, models, etc.)\n  require \"./src/app\"\n  require \"lucky_task\"\n\n  require \"./tasks/**\"\n  require \"./db/migrations/**\"\n  require \"lucky/tasks/**\"\n\n  LuckyTask::Runner.run\n  ```\n- Update: all tasks in your `tasks/` directory to inherit from `LuckyTask::Task` instead of `LuckyCli::Task`. (e.g. `Db::Seed::RequiredData < LuckyCli::Task` -> `Db::Seed::RequiredData < LuckyTask::Task`)\n- Update: your `config/cookies.cr` with a default cookie path of `\"/\"`.\n  ```crystal\n  Lucky::CookieJar.configure do |settings|\n    settings.on_set = ->(cookie : HTTP::Cookie) {\n      # ... other defaults\n\n      # Add this line. See ref: https://github.com/crystal-lang/crystal/pull/10491\n      cookie.path(\"/\")\n    }\n  end\n  ```\n\n### Optional updates\n\n- Update: to Crystal 1.0.0. You can continue to use Crystal 0.36.1 if you need.\n- Update: `LuckyFlow` to be a `development_dependency`.\n\n\n## Upgrading from 0.25 to 0.26\n\nFor a full diff of necessary changes, please see [LuckyDiff](https://luckydiff.com?from=0.25.0&to=0.26.0).\n\n- Upgrade Lucky CLI (homebrew)\n\n```\nbrew update\nbrew upgrade lucky\n```\n\n- Upgrade Lucky CLI (Linux)\n\n> Remove the existing Lucky binary and follow the Linux\n> instructions in this section\n> https://luckyframework.org/guides/getting-started/installing#on-linux\n\n- Update versions in `shard.yml`\n  - Crystal should be `0.36.1`\n  - Lucky should be `~> 0.26.0`\n  - Authentic should be `~> 0.7.2`\n  - LuckyFlow should be `~> 0.7.2`\n\n- Run `shards update`\n\n### General updates\n\n- Update: your `Procfile` web to point to `./bin/YOUR APP NAME` instead of `./app`. NOTE: this is dependant on how you deploy your app, so only required if you use the heroku_buildpack for Lucky. [read more](https://github.com/luckyframework/lucky_cli/pull/601) and [more](https://github.com/luckyframework/heroku-buildpack-crystal/pull/11)\n- Update: any references directly to an `Avram::Attribute(T)` generic. e.g. `Avram::Attribute(String?)` -> `Avram::Attribute(String)`. [read more](https://github.com/luckyframework/avram/pull/586)\n- Update: any custom database types to include the class method `adapter` that returns the `Lucky` constant. [read more](https://github.com/luckyframework/avram/pull/587)\n- Update: any custom database types to include the class method `criteria(query : T, column) forall T`. [read more](https://github.com/luckyframework/avram/pull/591)\n- Remove: any call to `after_completed` in a SaveOperation. The `after_save` and `after_commit` now run even if no change is updated. [read more](https://github.com/luckyframework/avram/pull/612)\n- Rename: all `Avram::Box` classes, filenames, and the `spec/support/boxes` directory (sorry 😬) to `Avram::Factory`, etc.... e.g. `UserBox` -> `UserFactory` [read more](https://github.com/luckyframework/avram/pull/614). [view discussion](https://github.com/luckyframework/lucky/discussions/1282)\n- Notice: the `Avram::Operation` now avoids calling `run` if there were validation errors in any `before_run`. This may change some of your logic, or create surprised. [read more](https://github.com/luckyframework/avram/pull/621)\n\n\n### Optional updates\n\n- Update: any calls made in Github CI config to `lucky db.create_required_seeds` to `lucky db.seed.required_data`. [read more](https://github.com/luckyframework/lucky_cli/pull/600)\n- Update: any use of `route` or `nested_route` in your actions to explicitly specify the route. This isn't deprecated, yet, but will be in a future version and eventually removed.\n- Add: `DB::Log.level = :info` to your `config/log.cr` file to quiet the excessive \"Executing query\" notices\n- Update: your Laravel Mix to version 6. [read more](https://github.com/luckyframework/lucky_cli/pull/592)\n- Add: a new migration to have UUID primary keys generated from the database for existing tables. [read more](https://github.com/luckyframework/avram/pull/578)\n```crystal\n# in a new migration file\ndef migrate\n  enable_extension \"pgcrypto\"\n  execute(\"ALTER TABLE products ALTER COLUMN id SET DEFAULT gen_random_uuid();\")\n  execute(\"ALTER TABLE users ALTER COLUMN id SET DEFAULT gen_random_uuid();\")\nend\n```\n- Remove: all calls to `flash.keep` in your actions. [read more](https://github.com/luckyframework/lucky/pull/1374)\n\n## Upgrading from 0.24 to 0.25\n\nFor a full diff of necessary changes, please see [LuckyDiff](https://luckydiff.com?from=0.24.0&to=0.25.0).\n\n- Upgrade Lucky CLI (homebrew)\n\n```\nbrew update\nbrew upgrade lucky\n```\n\n- Upgrade Lucky CLI (Linux)\n\n> Remove the existing Lucky binary and follow the Linux\n> instructions in this section\n> https://luckyframework.org/guides/getting-started/installing#on-linux\n\n- Update versions in `shard.yml`\n  - Crystal should be `0.35.1`\n  - Lucky should be `~> 0.25.0`\n  - Authentic should be `~> 0.7.1`\n  - LuckyFlow should be `~> 0.7.1`\n\n- Run `shards update`\n\n### General updates\n\n- Update: all `Avram::Operation` to implement the new interface.\n  - Your main instance method should be called `run`\n  - The `run` method should return just the value you need. No more `yield self, thing` / `yield self, nil`.\n  - Call the operation with `MyOperation.run(params)` instead of `MyOperation.new(params).submit`\n  - The `MyOperation.run` class method takes a block that yields the operation, and your return value. Similar to `SaveOperation`.\n\n  ```crystal\n  # Before Update\n  class RequestPasswordReset < Avram::Operation\n    #...\n    def submit\n      if valid?\n        yield self, user\n      else\n        yield self, nil\n      end\n    end\n  end\n\n  # Use in your Action\n  RequestPasswordReset.new(params).submit do |operation, user|\n  end\n\n  # After Update\n  class RequestPasswordReset < Avram::Operation\n    #...\n    def run\n      if valid?\n        user\n      else\n        nil\n      end\n    end\n  end\n\n  # Use in your Action\n  RequestPasswordReset.run(params) do |operation, user|\n  end\n  ```\n- Rename: all usage of `with_defaults` to `tag_defaults`\n- Update: query objects to no longer rely on mutating the query.\n  ```crystal\n  # Before update\n  q = UserQuery.new\n  q.age.gte(21)\n  q.to_sql #=> SELECT * FROM users WHERE age >= 21\n\n  # After update\n  q = UserQuery.new\n  q.age.gte(21)\n  q.to_sql #=> SELECT * FROM users\n  ```\n- Rename: all usage of `raw_where` to `where`\n- Update: query objects that set a default query in the initializer to use the `defaults` method.\n  ```crystal\n  # Before update\n  class UserQuery < User::BaseQuery\n    def initialize\n      admin(false)\n    end\n  end\n\n  UserQuery.new.to_sql #=> SELECT * FROM users WHERE admin = false\n\n  # After update\n  class UserQuery < User::BaseQuery\n    def initialize\n      defaults &.admin(false)\n    end\n  end\n\n  UserQuery.new.to_sql #=> SELECT * FROM users WHERE admin = false\n  ```\n- Update: any `has_many through` model association to include the new assocation chain.\n  ```crystal\n  # Before update\n  has_many posts : Post\n  has_many comments : Comment, through: :posts\n\n  # After update\n  # The first in the array is the association you're going through\n  # The second is that through's association.\n  has_many posts : Post\n  has_many comments : Comment, through: [:posts, :comments]\n  ```\n- Update: any query that used a `where_XXX` on a `belongs_to` from the pluralized name to singularized.\n  ```crystal\n  # assuming Post belongs_to User\n\n  # Before update\n  PostQuery.new.where_users(UserQuery.new)\n\n  # After update\n  PostQuery.new.where_user(UserQuery.new) # Notice the 'where_user' is single now\n  ```\n\n### Optional updates\n\n- Update: any mention of `DB_URL` that we told you to use should actually be `DATABASE_URL`\n- Remove: any include for `include Lucky::Memoizable`. This is now included in `Object` and available everywhere\n- Update: HTML tags that display a `UUID` no longer need to cast to String. `link uuid, to: Whatever`\n- Remove: any `start_server` or `start_server.dwarf` files in the top-level directory. These are now built to your `bin/`\n- Update: `config/email.cr` to include a case for development to print emails.\n  ```crystal\n  # config/email.cr\n  BaseEmail.configure do |settings|\n    if Lucky::Env.production?\n      # ...\n    elsif Lucky::Env.development?\n      settings.adapter = Carbon::DevAdapter.new(print_emails: true)\n    else\n      # ...\n    end\n  end\n  ```\n- Update: any `call(io : IO)` method in your tasks, and use the `output` property instead for testing. [read more](https://github.com/luckyframework/lucky_cli/pull/557)\n- Update: your `package.json` with all the latest front-end updates. [read more](https://github.com/luckyframework/lucky_cli/pull/553)\n- Rename: your seed tasks `tasks/create_required_seeds.cr` -> `tasks/db/seed/required_data.cr`, and `tasks/create_sample_seeds.cr` -> `tasks/db/seed/sample_data.cr`\n- Update: `config/log.cr` to silence some of the query logging with `DB::Log.level = :info`.\n\n\n## Upgrading from 0.23 to 0.24\n\nFor a full diff of necessary changes, please see [LuckyDiff](https://luckydiff.com?from=0.23.0&to=0.24.0).\n\n- Upgrade Lucky CLI (homebrew)\n\n```\nbrew update\nbrew upgrade lucky\n```\n\n- Upgrade Lucky CLI (Linux)\n\n> Remove the existing Lucky binary and follow the Linux\n> instructions in this section\n> https://luckyframework.org/guides/getting-started/installing#on-linux\n\n- Update versions in `shard.yml`\n  - Crystal should be `0.35.1`\n  - Lucky should be `~> 0.24.0`\n  - Authentic should be `~> 0.7.0`\n\n- Run `shards update`\n\n### General updates\n\n- Rename: all instances of the `m` method to `mount`. e.g. `m Shared::Footer, year: 2020` -> `mount Shared::Footer, year: 2020`.\n- Update: `config/database.cr` with new `Avram::Credentials`.\n```crystal\nAppDatabase.configure do |settings|\n  if Lucky::Env.production?\n    settings.credentials = Avram::Credentials.parse(ENV[\"DATABASE_URL\"])\n  else\n    settings.credentials = Avram::Credentials.parse?(ENV[\"DATABASE_URL\"]?) || Avram::Credentials.new(\n      database: database_name,\n      hostname: ENV[\"DB_HOST\"]? || \"localhost\",\n      # NOTE: This was changed from `String` to `Int32`\n      port: ENV[\"DB_PORT\"]?.try(&.to_i) || 5432,\n      username: ENV[\"DB_USERNAME\"]? || \"postgres\",\n      password: ENV[\"DB_PASSWORD\"]? || \"postgres\"\n    )\n  end\nend\n```\n- Rename: all instances of `AppClient` to `ApiClient` in your `spec/` directory.\n- Update: `script/setup` with `shards install --ignore-crystal-version`. Alternatively, you can set a global `SHARDS_OPTS=--ignore-crystal-version` environment variable\n\n### Optional updates\n\n- Update: `redirect_back` with `allow_external: true` argument if you need to allow external referers\n- Update: your database credentials with the new `query` option to pass query string options\n```crystal\n# config/database.cr\nsettings.credentials = Avram::Credentials.new(\n  database: database_name,\n  hostname: ENV[\"DB_HOST\"]? || \"localhost\",\n  port: ENV[\"DB_PORT\"]?.try(&.to_i) || 5432,\n  username: ENV[\"DB_USERNAME\"]? || \"postgres\",\n  password: ENV[\"DB_PASSWORD\"]? || \"postgres\",\n  # This option is new\n  query: \"initial_pool_size=5&max_pool_size=20\"\n)\n```\n- Add: `disable_cookies` to `ApiAction` in `src/actions/api_action.cr`.\n\n## Upgrading from 0.22 to 0.23\n\nFor a full diff of necessary changes, please see [LuckyDiff](https://luckydiff.com?from=0.22.0&to=0.23.0).\n\n- Upgrade Lucky CLI (homebrew)\n\n```\nbrew update\nbrew upgrade lucky\n```\n\n- Upgrade Lucky CLI (Linux)\n\n> Remove the existing Lucky binary and follow the Linux\n> instructions in this section\n> https://luckyframework.org/guides/getting-started/installing#on-linux\n\n- Update versions in `shard.yml`\n  - Crystal should be `0.35.0`\n  - Lucky should be `~> 0.23.0`\n  - Authentic should be `~> 0.6.1`\n  - LuckyFlow should be `~> 0.7.0`\n  - jwt should be `~> 1.4.2`\n\n- Run `shards update`\n\n### General updates\n\n- Update: `params.get` now strips white space. If you need the raw value, use `params.get_raw`.\n- Rename: `mount` to `m` in all pages that use components. **Note: This was reverted in the next version**\n- Update: all mounted components to use new signature `mount(MyComponent.new(x: 1, y: 2))` -> `m(MyComponent, x: 1, y:2)`.\n- Remove: `Lucky::SessionHandler` and `Lucky::FlashHandler` from `src/app_server.cr`\n\n### Optional updates\n\n- Add: `Avram::RecordNotFoundError` to the `dont_report` array in `src/actions/errors/show.cr`\n- Update: `def render(error : Lucky::RouteNotFoundError` to `def render(error : Lucky::RouteNotFoundError | Avram::RecordNotFoundError)` in `src/actions/errors/show.cr`.\n- Update: any CLI tasks that use `ARGV` to use the native args [See implementation](https://github.com/luckyframework/lucky_cli/pull/466)\n\n\n## Upgrading from 0.21 to 0.22\n\nFor a full diff of necessary changes, please see [LuckyDiff](https://luckydiff.com?from=0.21.0&to=0.22.0).\n\n- Upgrade Lucky CLI (homebrew)\n\n```\nbrew update\nbrew upgrade lucky\n```\n\n- Upgrade Lucky CLI (Linux)\n\n> Remove the existing Lucky binary and follow the Linux\n> instructions in this section\n> https://luckyframework.org/guides/getting-started/installing#on-linux\n\n- Update versions in `shard.yml`\n  - Crystal should be `0.35.0`\n  - Lucky should be `~> 0.22.0`\n  - Authentic should be `~> 0.6.0`\n  - jwt should be `~> 1.4.2`\n\n- Run `shards update`\n\n## Upgrading from 0.20 to 0.21\n\nFor a full diff of necessary changes, please see [LuckyDiff](https://luckydiff.com?from=0.20.0&to=0.21.0).\n\n- Upgrade Lucky CLI (homebrew)\n\n```\nbrew update\nbrew upgrade lucky\n```\n\n- Upgrade Lucky CLI (Linux)\n\n> Remove the existing Lucky binary and follow the Linux\n> instructions in this section\n> https://luckyframework.org/guides/getting-started/installing#on-linux\n\n- Update versions in `shard.yml`\n  - Crystal should be `0.34.0`\n  - Lucky should be `~> 0.21.0`\n  - Authentic should be `~> 0.5.4`\n  - LuckyFlow should be `~> 0.6.3`\n\n- Run `shards update`\n\n### General updates\n\n- Rename: `config/logger.cr` to `config/log.cr`\n- Update: `config/log.cr` to use the new `Log`. [See implementation](https://github.com/luckyframework/lucky_cli/blob/v0.21.0/src/web_app_skeleton/config/log.cr#L1)\n- Update: `Procfile.dev` and update the `system_check` to `script/system_check && sleep 100000`.\n- Update: all `Lucky.logger.{level}(\"message\")` calls to use the new Crystal Log `Log.{level} { \"message\" }`\n- Remove: the following lines from `config/database.cr`\n```crystal\n# Uncomment the next line to log all SQL queries\n# settings.query_log_level = ::Logger::Severity::DEBUG\n```\n\n### Updating `Lucky.logger`\n\nBefore this version, you would log data like this:\n\n```crystal\nLucky.logger.debug(\"Logging some message\")\nLucky.logger.info({path: @context.request.path})\n```\n\nNow, you would write this like:\n\n```crystal\n# Use the Crystal std-lib log for simple String messages\nLog.debug { \"Logging some message\" }\n\n# Use the Dexter extension for logging key/value data\nLog.dexter.info { {path: @context.request.path} }\n```\n\n\n## Upgrading from 0.19 to 0.20\n\nFor a full diff of necessary changes, please see [LuckyDiff](https://luckydiff.com?from=0.19.0&to=0.20.0).\n\n- Update `.crystal-version` file to `0.34.0`\n- Upgrade to crystal 0.34.0\n- Upgrade Lucky CLI (homebrew)\n\n```\nbrew update\nbrew upgrade crystal-lang # Make sure you're up-to-date. Requires 0.34.0\nbrew upgrade lucky\n```\n\n- Upgrade Lucky CLI (Linux)\n\n> Remove the existing Lucky binary and follow the Linux\n> instructions in this section\n> https://luckyframework.org/guides/getting-started/installing#on-linux\n\n- Update versions in `shard.yml`\n  - Crystal should be `0.34.0`\n  - Lucky should be `~> 0.20.0`\n  - Authentic should be `~> 0.5.2`\n  - LuckyFlow should be `~> 0.6.2`\n- Run `shards update`\n\n### General updates\n\n- Update: `link` no longer accepts a `String` path or URL, it must be an Action. Change `link()` to an `a` tag with an `href` (`a \"Google\", href: \"https://google.com\"`), or use an action class with `link` (`link \"Home\", to: \"/\" ` becomes `link(\"Home\", to: Home::Index)`.\n- Remove: the `?` from any `needs` using a predicate method. e.g. `needs signed_in? : Bool` -> `needs signed_in : Bool`. Lucky now automatically creates a method ending with `?` for `needs` with a `Bool` type.\n- Update: your development `ENV[\"PORT\"]` to be `ENV[\"DEV_PORT\"]` if you need to customize the port your local server is running on.\n- Update: all `SaveOperation` classes where a raw hash is being passed in. e.g. `MyOperation.new({\"name\" => \"Gary\"})` -> `MyOperation.new(name: \"Gary\")`, or if you must use a hash, wrap it in params first: `MyOperation.new(Avram::Params.new({\"name\" => \"Gary\"})`\n- Remove: the `on:` option from `needs` inside every Operation class. e.g. `needs created_by : String, on: :create` -> `needs created_by : String`. You will need to explicitly pass these when calling `new`, `create`, and `update`.\n\n\n### Optional updates\n\n- Update: all instance variables called from a `needs` on a page or component can now just use the method of that name. e.g. `@current_user` -> `current_user`\n- Add: `include Lucky::CatchUnpermittedAttribute` to the `class Shared::Field(T)` in `src/components/shared/field.cr`. This will raise a nicer error if you forget to permit a column in your SaveOperation\n- Add: the new `Lucky::RemoteIpHandler.new` to your app handlers in `src/app_server.cr` just before `Lucky::RouteHandler.new`.\n- Add: `robots.txt` to your `public/` directory.\n  ```\n  User-agent: *\n  Disallow:\n  ```\n- Update: `UserSerializer` to inherit from the `BaseSerializer` if it doesn't already.\n- Add: `cookie.http_only(true)` to your `config/cookies.cr` file. This goes inside your `settings.on_set` block.\n- Update: your node dependencies where needed\n- Update: the `setup` script in `script/setup`. [See implementation](https://github.com/luckyframework/lucky_cli/tree/ee7699bddde50b80e495a89edb442b754f627239/src/web_app_skeleton/script/setup.ecr). Be sure to remove the ECR tags.\n- Add: this line `system_check: script/system_check && $SHELL` to your `Procfile.dev`\n- Add: the new `system_check` script in `script/system_check`. Note: you may need to `chmod +x script/system_check`. [See implementation](https://github.com/luckyframework/lucky_cli/tree/ee7699bddde50b80e495a89edb442b754f627239/src/web_app_skeleton/script/system_check.ecr). Be sure to remove the ECR tags.\n- Add: the new `function_helpers` script in `script/helpers/function_helpers`. [See implementation](https://github.com/luckyframework/lucky_cli/tree/ee7699bddde50b80e495a89edb442b754f627239/src/web_app_skeleton/script/helpers/function_helpers)\n- Add: the new `text_helpers` script in `script/helpers/text_helpers`. [See implementation](https://github.com/luckyframework/lucky_cli/tree/ee7699bddde50b80e495a89edb442b754f627239/src/web_app_skeleton/script/helpers/text_helpers)\n\n\n## Upgrading from 0.18 to 0.19\n\nFor a full diff of necessary changes, please see [LuckyDiff](https://luckydiff.com?from=0.18.0&to=0.19.0).\n\n- Update `.crystal-version` file to `0.33.0`\n- Upgrade to crystal 0.33.0\n- Upgrade Lucky CLI (homebrew)\n\n```\nbrew update\nbrew upgrade crystal-lang # Make sure you're up-to-date. Requires 0.33.0\nbrew upgrade lucky\n```\n\n- Upgrade Lucky CLI (Linux)\n\n> Remove the existing Lucky binary and follow the Linux\n> instructions in this section\n> https://luckyframework.org/guides/getting-started/installing#on-linux\n\n- Update versions in `shard.yml`\n  - Crystal should be `0.33.0`\n  - Lucky should be `~> 0.19.0`\n  - Authentic should be `~> 0.5.1`\n  - LuckyFlow should be `~> 0.6.2`\n- Run `shards update`\n\n### Recommended (but optional) changes\n\n\n#### GZip static assets\n\n* [Add the compression plugin](https://github.com/luckyframework/lucky_cli/commit/8bc002ab51cb13e67f515c4de977766f96825a18#diff-73db280623fcd1a64ac1ab76c8700dbc) to `package.json`\n* Make [these changes](https://github.com/luckyframework/lucky_cli/commit/8bc002ab51cb13e67f515c4de977766f96825a18#diff-cd19e42e70bfbcf2a12480b0b6b1f590)\n  to your `webpack.mix.js` file\n* In `src/app_server.cr` add `Lucky::StaticCompressionHandler.new(\"./public\", file_ext: \"gz\", content_encoding: \"gzip\")` above the `Lucky::StaticFileHandler.new`.\n\n#### GZip text responses\n\n* Make [these changes](https://github.com/luckyframework/lucky_cli/commit/8bc002ab51cb13e67f515c4de977766f96825a18#diff-83ca1a783e82ef6f0d38f400b7c1eaa1) to `config/server.cr` to gzip text responses.\n\n## Upgrading from 0.17 to 0.18\n\nFor a full diff of necessary changes, please see [LuckyDiff](https://luckydiff.com?from=0.17.0&to=0.18.0).\n\n- Upgrade to crystal 0.31.1\n- Upgrade Lucky CLI (homebrew)\n\n```\nbrew update\nbrew upgrade crystal-lang # Make sure you're up-to-date. Requires 0.31.1\nbrew upgrade lucky\n```\n\n- Upgrade Lucky CLI (Linux)\n\n> Remove the existing Lucky binary and follow the Linux\n> instructions in this section\n> https://luckyframework.org/guides/getting-started/installing#on-linux\n\n- Update `.crystal-version` to `0.31.1`\n\n- Update versions in `shard.yml`\n  - Crystal should be `0.31.1`\n  - Lucky should be `~> 0.18`\n  - Authentic should be `~> 0.5.0`\n  - LuckyFlow should be `~> 0.6.0`\n- Run `shards update`\n\n### General updates\n\n- Rename: all `render` calls in actions to `html`.\n- Update: the `src/actions/errors/show.cr` file to the new format:\n\n  ```crystal\n  class Errors::Show < Lucky::ErrorAction\n    DEFAULT_MESSAGE = \"Something went wrong.\"\n    default_format :html\n    dont_report [Lucky::RouteNotFoundError]\n\n    def render(error : Lucky::RouteNotFoundError)\n      if html?\n        error_html \"Sorry, we couldn't find that page.\", status: 404\n      else\n        error_json \"Not found\", status: 404\n      end\n    end\n\n    # When the request is JSON and an InvalidOperationError is raised, show a\n    # helpful error with the param that is invalid, and what was wrong with it.\n    def render(error : Avram::InvalidOperationError)\n      if html?\n        error_html DEFAULT_MESSAGE, status: 500\n      else\n        error_json \\\n          message: error.renderable_message,\n          details: error.renderable_details,\n          param: error.invalid_attribute_name,\n          status: 400\n      end\n    end\n\n    # Always keep this below other 'render' methods or it may override your\n    # custom 'render' methods.\n    def render(error : Lucky::RenderableError)\n      if html?\n        error_html DEFAULT_MESSAGE, status: error.renderable_status\n      else\n        error_json error.renderable_message, status: error.renderable_status\n      end\n    end\n\n    # If none of the 'render' methods return a response for the raised Exception,\n    # Lucky will use this method.\n    def default_render(error : Exception) : Lucky::Response\n      if html?\n        error_html DEFAULT_MESSAGE, status: 500\n      else\n        error_json DEFAULT_MESSAGE, status: 500\n      end\n    end\n\n    private def error_html(message : String, status : Int)\n      context.response.status_code = status\n      html Errors::ShowPage, message: message, status: status\n    end\n\n    private def error_json(message : String, status : Int, details = nil, param = nil)\n      json ErrorSerializer.new(message: message, details: details, param: param), status: status\n    end\n\n    private def report(error : Exception) : Nil\n      # Send to Rollbar, send an email, etc.\n    end\n  end\n  ```\n\n- Rename: `title` to `message` in `src/pages/errors/show_page.cr`.\n- Add: `BaseSerializer` to `src/serializers/`.\n\n  ```crystal\n  abstract class BaseSerializer < Lucky::Serializer\n    def self.for_collection(collection : Enumerable, *args, **named_args)\n      collection.map do |object|\n        new(object, *args, **named_args)\n      end\n    end\n  end\n  ```\n\n- Add: `require \"./serializers/base_serializer\"` to your `src/app.cr` above `require \"./serializers/**\"`\n- Optional: Update all serializers to inherit from `BaseSerializer`. Also merge Show/Index serializers in to a single class.\n\n  ```crystal\n  # Merge these two classes\n  class Users::IndexSerializer < Lucky::Serializer\n  end\n\n  class Users::ShowSerializers < Lucky::Serializer\n  end\n\n  # in to this class\n  class UserSerializer < BaseSerializer\n    # Same contents as Users::ShowSerializer\n    # Calls to Users::IndexSerializer now become:\n    #\n    #    UserSerializer.for_collection(users)\n  end\n  ```\n\n- Rename: `Errors::ShowSerializer` to `ErrorSerializer`\n- Update: `ErrorSerializer` to inherit from the new `BaseSerializer`\n- Update: `ErrorSerializer` contents with:\n\n  ```crystal\n  class ErrorSerializer < BaseSerializer\n    def initialize(\n      @message : String,\n      @details : String? = nil,\n      @param : String? = nil # If there was a problem with a specific param\n    )\n    end\n\n    def render\n      {message: @message, param: @param, details: @details}\n    end\n  end\n\n  ```\n- Add: `Avram::SchemaEnforcer.ensure_correct_column_mappings!` to `src/start_server.cr` below `Avram::Migrator::Runner.new.ensure_migrated!`.\n- Update: any mention to renamed errors in [this commit](https://github.com/luckyframework/lucky/pull/911/files#diff-02d01a64649367eb50f82f303c2d07e2R248). You can likely ignore this as most people do not rescue these specific errors.\n- Add: `accepted_formats [:json]` to `ApiAction` in `src/actions/api_action.cr`.\n\n  ```crystal\n  abstract class ApiAction < Lucky::Action\n    accepted_formats [:json]\n  end\n  ```\n\n- Add: `accepted_formats [:html, :json], default: :html` to `BrowserAction` in `src/actions/browser_action.cr`\n\n  ```crystal\n  abstract class BrowserAction < Lucky::Action\n    accepted_formats [:html, :json], default: :html\n  end\n  ```\n\n- Update: `src/app_server.cr` with explicit return type on the `middleware` method.\n```crystal\n# Add return type here\ndef middleware : Array(HTTP::Handler)\n  [\n    # ...\n  ] of HTTP::Handler # Add this or app will fail to compile\nend\n```\n\n- Add: `include Lucky::RequestExpectations` to `spec/spec_helper.cr` below `include Carbon::Expectations`\n- Add: `Avram::SchemaEnforcer.ensure_correct_column_mappings!` to `spec/spec_helper.cr` below `Avram::Migrator::Runner.new.ensure_migrated!`\n- Update: Change `at_exit do` in `spec/setup/start_app_server.cr` to `Spec.after_suite do`\n\n## Upgrading from 0.16 to 0.17\n\n- Ensure you've upgraded to crystal 0.30.1\n- Upgrade Lucky CLI (homebrew)\n\n```\nbrew update\nbrew upgrade crystal-lang # Make sure you're up-to-date. Requires 0.30.1\nbrew upgrade lucky\n```\n\n- Upgrade Lucky CLI (Linux)\n\n- Update `.crystal-version` to `0.30.1`\n\n> Remove the existing Lucky binary and follow the Linux\n> instructions in this section\n> https://luckyframework.org/guides/getting-started/installing#on-linux\n\n- Update versions in `shard.yml`\n  - Lucky should be `~> 0.17`\n- Run `shards update`\n\n### Example upgrade\n\nIf you're not sure about an upgrade step, or simply want to look at an example, see the [lucky_bits upgrade](https://github.com/edwardloveall/lucky_bits/commit/47473a19084f1781062ce3767e3fbcf527c11e4d).\n\n### General updates\n- Rename: Action rendering method `text` to `plain_text`.\n- Update: use of `number_to_currency` now returns a String instead of writing to the view directly.\n- Delete: `config/static_file_handler.cr`.\n- Add: a new `Lucky::LogHandler` configure to the bottom of `config/logger.cr`.\n- Update: `Avram::Repo.configure` to `Avram.configure` in `config/logger.cr`.\n<details>\n  <summary>config/logger.cr</summary>\n\n  ```crystal\n  require \"file_utils\"\n\n  logger =\n    if Lucky::Env.test?\n      # Logs to `tmp/test.log` so you can see what's happening without having\n      # a bunch of log output in your specs results.\n      FileUtils.mkdir_p(\"tmp\")\n      Dexter::Logger.new(\n        io: File.new(\"tmp/test.log\", mode: \"w\"),\n        level: Logger::Severity::DEBUG,\n        log_formatter: Lucky::PrettyLogFormatter\n      )\n    elsif Lucky::Env.production?\n      # This sets the log formatter to JSON so you can parse the logs with\n      # services like Logentries or Logstash.\n      #\n      # If you want logs like in development use `Lucky::PrettyLogFormatter`.\n      Dexter::Logger.new(\n        io: STDOUT,\n        level: Logger::Severity::INFO,\n        log_formatter: Dexter::Formatters::JsonLogFormatter\n      )\n    else\n      # For development, log everything to STDOUT with the pretty formatter.\n      Dexter::Logger.new(\n        io: STDOUT,\n        level: Logger::Severity::DEBUG,\n        log_formatter: Lucky::PrettyLogFormatter\n      )\n    end\n\n  Lucky.configure do |settings|\n    settings.logger = logger\n  end\n\n  Lucky::LogHandler.configure do |settings|\n    # Skip logging static assets in development\n    if Lucky::Env.development?\n      settings.skip_if = ->(context : HTTP::Server::Context) {\n        context.request.method.downcase == \"get\" &&\n        context.request.resource.starts_with?(/\\/css\\/|\\/js\\/|\\/assets\\//)\n      }\n    end\n  end\n\n  Avram.configure do |settings|\n    settings.logger = logger\n  end\n  ```\n</details>\n\n- Update: `script/setup` to include the new postgres checks.\n\n```diff\n# This must go *after* the 'shards install' step\n+ printf \"\\n▸ Checking that postgres is installed\\n\"\n+ check_postgres | indent\n+ printf \"✔ Done\\n\" | indent\n\n+ printf \"\\n▸ Verifying postgres connection\\n\"\n+ lucky db.verify_connection | indent\n\nprintf \"\\n▸ Setting up the database\\n\"\nlucky db.create | indent\n```\n\n### Database updates\n- Add: a new `AppDatabase` class in `src/app_database.cr` that inherits from `Avram::Database`.\n\n```crystal\nclass AppDatabase < Avram::Database\nend\n```\n\n- Add: `require \"./app_database\"` to `src/app.cr` right below the `require \"./shards\"`.\n- Rename: `Avram::Repo.configure` to `AppDatabase.configure` in `config/database.cr`.\n- Add: `Avram.configure` block.\n<details>\n  <summary>config/database.cr</summary>\n\n  ```crystal\n  database_name = \"...\"\n\n  AppDatabase.configure do |settings|\n    if Lucky::Env.production?\n      settings.url = ENV.fetch(\"DATABASE_URL\")\n    else\n      settings.url = ENV[\"DATABASE_URL\"]? || Avram::PostgresURL.build(\n        database: database_name,\n        hostname: ENV[\"DB_HOST\"]? || \"localhost\",\n        # Some common usernames are \"postgres\", \"root\", or your system username (run 'whoami')\n        username: ENV[\"DB_USERNAME\"]? || \"postgres\",\n        # Some Postgres installations require no password. Use \"\" if that is the case.\n        password: ENV[\"DB_PASSWORD\"]? || \"postgres\"\n      )\n    end\n  end\n\n  Avram.configure do |settings|\n    settings.database_to_migrate = AppDatabase\n\n    # this is moved from your old `Avram::Repo.configure` block.\n    settings.lazy_load_enabled = Lucky::Env.production?\n  end\n  ```\n</details>\n\n- Move: the `settings.lazy_load_enabled` from `AppDatabase.configure` to `Avram.configure` block.\n- Add: a `database` class method to `src/models/base_model.cr` that returns `AppDatabase`.\n```crystal\nabstract class BaseModel < Avram::Model\n  def self.database : Avram::Database.class\n    AppDatabase\n  end\nend\n```\n- Update: `Avram::Repo` to `AppDatabase` in `spec/setup/clean_database.cr`.\n- Avram no longer automatically adds a timestamp and primary key to migrations.\n  Add a primary key and timestamps to your old migrations.\n\n  > Also note that the syntax for a UUID has changed. You use\n  > `primary_key id : UUID` instead of an option on 'create'\n\n  ```crystal\n  def migrate\n    create :users do\n      # Add these to your 'create' statements in your migrations\n      primary_key id : Int64 # Or 'UUID' if using UUID\n      add_timestamps\n    end\n  end\n  ```\n\n- Note: Avram now defaults primary keys to `Int64` instead of `Int32`. You\ncan use the `change_type` macro to migrate your **primary keys and foreign keys**\nto `Int64` if you need. Run `lucky gen.migration UpdatePrimaryKeyTypes`.\n```crystal\nclass UpdatePrimaryKeyTypesV20190723233131 < Avram::Migrator::Migration::V1\n  def migrate\n    alter table_for(User) do\n      change_type id : Int64\n    end\n    alter table_for(Post) do\n      change_type id : Int64\n      change_type user_id : Int64\n    end\n  end\nend\n```\n- Update: models now default the primary key to `Int64`. This can be\noverridden if your tables uses a different column type for your primary keys,\nsuch as Int32 or UUID\n\n```crystal\nabstract class BaseModel < Avram::Model\n  macro default_columns\n    primary_key id : UUID\n    timestamps\n  end\nend\n```\n\nThis also means that any model that uses `UUID` for a primary key can remove the `primary_key_type` option\n\n```crystal\nclass User < BaseModel\n  # 0.16 and earlier\n  table :users, primary_key_type: :uuid do\n    column email : String\n  end\n\n  # Now with 0.17 it will use the 'default_columns' from the 'BaseModel'\n  table :users do\n    column email : String\n  end\nend\n```\n\n### Updating queries\n- Rename: `Query.new.destroy_all` to `Query.truncate`. (e.g. `UserQuery.new.destroy_all` => `UserQuery.truncate`)\n- Rename: all association query methods from the association name to `where_{association_name}`. (e.g. `UserQuery.new.posts` => `UserQuery.new.where_posts`)\n- Update: all association query methods no longer take a block. Pass the query in as an argument. (e.g. `UserQuery.new.posts { |post_query| }` => `UserQuery.new.where_posts(PostQuery.new)`)\n- Update: `where_{association_name}` methods no longer need to be preceded by a `join_{assoc}`, unless you need a custom join (i.e. `left_join_{assoc}`). If you use a custom join, you will need to add the `auto_inner_join: false` option to your `where_{assoc}` method.\n\n### Moving forms to operations\n- Rename: the `src/forms` directory to `src/operations`.\n- Update: `require \"./forms/mixins/**\"` and `require \"./forms/**\"` to `require \"./operations/mixins/**\"` and `require \"./operations/**\"` respectively in `src/app.cr`\n- Rename: `BaseForm` to `SaveOperation` in `src/operations`. (e.g. `User::BaseForm` => `User::SaveOperation`)\n- Rename: `fillable` to `permit_columns`\n- Rename: form class names to new naming convention. (e.g. `class UserForm < User::SaveOperation` => `class SaveUser < User::SaveOperation`). This step is optional, but still recommended to avoid future confusion.\n- Rename: `Avram::VirtualForm` to `Avram::Operation`.\n- Rename: virtual form class names to new naming convention VerbNoun. (e.g. `class SignInForm < Avram::Operation` => `class SignInUser < Avram::Operation`).\n- Rename: `virtual` to `attribute`.\n- Update: all `SaveOperation` classes to call `before_save prepare`. The `prepare` method is no longer called by default, which allows you to rename this method as well.\n- Update: `FillableField` to `PermittedAttribute` in `src/components/shared/`. Check `field.cr` and `field_errors.cr`.\n- Update: all authentic classes and modules to use new operation setup. This may require renaming some files to fit the `VerbNoun` `verb_noun.cr` convention.\n<details>\n  <summary>Files in src/operations/</summary>\n\n  ```diff\n  # src/operations/mixins/password_validations.cr\n  module PasswordValidations\n  +  macro included\n  +    before_save run_password_validations\n  +  end\n    #...\n  end\n\n\n  # src/operations/request_password_reset.cr\n  - class RequestPasswordReset < Avram::VirtualForm\n  + class RequestPasswordReset < Avram::Operation\n    #...\n  end\n\n\n  # src/operations/reset_password.cr\n  - def prepare\n  -   run_password_validations\n  + before do\n      Authentic.copy_and_encrypt password, to: encrypted_password\n\n\n  # src/operations/sign_in_user.cr\n  - class SignInUser < Avram::VirtualOperation\n  + class SignInUser < Avram::Operation\n\n\n  # src/operations/sign_up_user.cr\n  - def prepare\n  + before_save do\n      validate_uniqueness_of email\n  -   run_password_validations\n  ```\n</details>\n\n- Update `sign_in_user.cr` to match [the new template](https://github.com/luckyframework/lucky_cli/blob/c45e1860751bba25a16120402f93e7537c0be5b5/src/base_authentication_app_skeleton/src/operations/sign_in_user.cr).\n- Rename the `FindAuthenticatable` mixin to `UserFromEmail`, again the Lucky CLI [template](https://github.com/luckyframework/lucky_cli/blob/c45e1860751bba25a16120402f93e7537c0be5b5/src/base_authentication_app_skeleton/src/operations/mixins/user_from_email.cr) is a helpful guide.\n\n## Upgrading from 0.15 to 0.16\n\n- Upgrade to crystal 0.30.0\n\nNo updates to Lucky itself are required. There may be Crystal 0.30.0 related changes you may need to make.\n\n## Upgrading from 0.14 to 0.15\n\n- Upgrade to crystal 0.29.0\n- Upgrade Lucky CLI (macOS)\n\n```\nbrew update\nbrew upgrade crystal-lang # Make sure you're up-to-date. Requires 0.29.0\nbrew upgrade lucky\n```\n\n- Upgrade Lucky CLI (Linux)\n\n- Update `.crystal-version` to `0.29.0`\n\n> Remove the existing Lucky binary and follow the Linux\n> instructions in this section\n> https://luckyframework.org/guides/getting-started/installing#on-linux\n\n- Update versions in `shard.yml`\n  - Lucky should be `~> 0.15`\n\n- Run `shards update`\n\n- Rename `src/server.cr` to `src/start_server.cr`.\n- Edit `src/start_server.cr` by changing\n    * `app` to `app_server` and `App` to `AppServer`.\n    * delete the line that starts with `puts \"Listening on`\n- Update `src/{your app name}.cr` to require `./start_server`\n- Rename `src/dependencies.cr` to `src/shards.cr`\n- Move the `App` class to a new file in `src/app_server.cr`\n- Rename `App` to `AppServer` and rename `Lucky::BaseApp` to `Lucky::BaseAppServer` in your new `src/app_server.cr`\n- Update `src/app.cr` to require new `./app_server` file\n- Update `src/app.cr` to require new `./shards` file\n- Replace usages of `Lucky::Action::Status::` with the respective crystal `HTTP::Status::`\n\n## Upgrading from 0.13 to 0.14\n\n- Upgrade to crystal 0.28.0\n- Create new file `config/force_ssl_handler.cr` with the following content:\n\n```crystal\nLucky::ForceSSLHandler.configure do |settings|\n  settings.enabled = Lucky::Env.production?\nend\n```\n\n## Upgrading from 0.12 to 0.13\n\n- Upgrade Lucky CLI (macOS)\n\n```\nbrew update\nbrew upgrade crystal-lang # Make sure you're up-to-date. Requires 0.27.2\nbrew upgrade lucky\n```\n\n- Upgrade Lucky CLI (Linux)\n\n- Update `.crystal-version` to `0.27.2`\n\n> Remove the existing Lucky binary and follow the Linux\n> instructions in this section\n> https://luckyframework.org/guides/installing/#install-lucky\n\n- Update versions in `shard.yml`\n  - Lucky should be `~> 0.13`\n  - LuckyFlow should be `~> 0.4`\n  - Authentic should be `~> 0.3`\n\n- Run `shards update`\n\n- Find and replace `LuckyRecord` with `Avram`\n\n- Add `Lucky::AssetHelpers.load_manifest` below `require \"dependencies\"` in `src/app.cr` for browser apps. Skip for API only apps.\n\n- `Query#preload` with a query now includes the association name -> [`Query#preload_{{ assoc_name }}`](https://github.com/luckyframework/lucky_record/pull/307)\n\n- Remove `unexpose` and `unexpose_if_exposed` from your actions. Pages now\n  ignore unused exposures so these methods have been removed.\n\n- Change `require \"lucky_record\"` to `require \"avram\"` in `src/dependencies`\n\n- Rename `config/log_handler.cr` to `config/logger.cr`\n\n- Replace `config/logger.cr` with this:\n\n```crystal\nrequire \"file_utils\"\n\nlogger =\n  if Lucky::Env.test?\n    # Logs to `tmp/test.log` so you can see what's happening without having\n    # a bunch of log output in your specs results.\n    FileUtils.mkdir_p(\"tmp\")\n    Dexter::Logger.new(\n      io: File.new(\"tmp/test.log\", mode: \"w\"),\n      level: Logger::Severity::DEBUG,\n      log_formatter: Lucky::PrettyLogFormatter\n    )\n  elsif Lucky::Env.production?\n    # This sets the log formatter to JSON so you can parse the logs with\n    # services like Logentries or Logstash.\n    #\n    # If you want logs like in development use `Lucky::PrettyLogFormatter`.\n    Dexter::Logger.new(\n      io: STDOUT,\n      level: Logger::Severity::INFO,\n      log_formatter: Dexter::Formatters::JsonLogFormatter\n    )\n  else\n    # For development, log everything to STDOUT with the pretty formatter.\n    Dexter::Logger.new(\n      io: STDOUT,\n      level: Logger::Severity::DEBUG,\n      log_formatter: Lucky::PrettyLogFormatter\n    )\n  end\n\nLucky.configure do |settings|\n  settings.logger = logger\nend\n\nAvram::Repo.configure do |settings|\n  settings.logger = logger\nend\n```\n\n- If using `is` in queries, rename the calls to `eq`\n\n- App in `src/app.cr` should now inherit from `Lucky::BaseApp`. See [the changes you need to make](https://github.com/luckyframework/lucky_cli/commit/7794306c55b8e00ded0d816def5cd62dc6fe4367).\n\n- Move `bin/setup` to `script/setup`\n\n- In your `README` replace `bin/setup` with `script/setup`\n\n- Replace `bin/lucky` in your `.gitignore` with just `/bin/`. Lucky projects\n  should now put bash scripts in `/script`. Binaries go in `/bin/` and are\n  ignored.\n- `id` in actions using `route` now have the underscored version of the\n  resource name prepended. You'll need to rename your `id` calls to\n  `<resource_name>_id`.\n\n```crystal\n# Example from v0.12\nclass Users::Show < BrowserAction\n  route do\n    # Using the 'id' param\n    UserQuery.find(id)\n  end\nend\n\n# Would now be\nclass Users::Show < BrowserAction\n  route do\n    # Now it is 'user_id'\n    UserQuery.find(user_id)\n  end\nend\n```\n\n- Make changes to [laravel.mix](https://github.com/luckyframework/lucky_cli/commit/88ad5af5b40f3a29c4abcb0581db505019d7003f#diff-cd19e42e70bfbcf2a12480b0b6b1f590)\n\n- Make changes to [package.json](https://github.com/luckyframework/lucky_cli/commit/88ad5af5b40f3a29c4abcb0581db505019d7003f#diff-73db280623fcd1a64ac1ab76c8700dbc)\n\n- Run `yarn install`\n\nAnd you should now be good to go!\n\n## Upgrading from 0.11 to 0.12\n\n- Upgrade Lucky CLI (macOS)\n\n```\nbrew update\nbrew upgrade crystal-lang # Make sure you're up-to-date. Requires 0.27\nbrew upgrade lucky\n```\n\n- Upgrade Lucky CLI (Linux)\n\n> Remove the existing Lucky binary and follow the Linux\n> instructions in this section\n> https://luckyframework.org/guides/installing/#install-lucky\n\n> Use your package manager to update Crystal to v0.27\n\n- In `db/migrations`, change `LuckyMigrator::Migration` -> `LuckyRecord::Migrator::Migration` for every migration\n\n- Remove `lucky_migrator` from `shard.yml`\n\n- Remove `lucky_migrator` from `src/dependencies`\n\n- Remove the `LuckyMigrator.configure` block from `config/database.cr`\n\n- Configuration now requires passing an argument. Find and replace `.configure do` with `.configure do |settings|` in all files in `config`\n\n- Update `config/session.cr`\n\n  - Change `Lucky::Session::Store.configure` to `Lucky::Session.configure do |settings|`\n\n  - Change your session key because signing/encryption has changed. For example: add `_0_12_0` to the end of the key.\n\n  - Remove `settings.secret = Lucky::Server.settings.secret_key_base`\n\n- If using `cookies[]` anywhere in your app, change the key you use. Lucky now signs and encrypts all cookies. Old cookies will not decrypt properly.\n\n- Change `session[]=` and `cookies[]=` to `session|cookies.set|get`\n\n- Change `session|cookies.destroy` to `session/cookies.clear`\n\n- `cookies.unset(:key)` and `delete.unset(:key)` should be `cookies|session.delete(:key)`\n\n- Remove `unexpose current_user` from `src/actions/home/index.cr`\n\n- `Query#count` has been renamed to `Query#select_count`. For example: `UserQuery.new.count` is now `UserQuery.new.select_count`\n\n- Change `flash.danger` to `flash.failure` in your actions.\n\n- Update `Lucky::Flash::Handler` to `Lucky::FlashHandler` in `src/app.cr`\n\n- Update usages of `Lucky::Response` to `Lucky::TextResponse`\n\n- Update usages of `LuckyInflector::Inflector` to `Wordsmith::Inflector`\n\n- Remove `config/session.cr` and copy [`config/cookies.cr`](https://github.com/luckyframework/lucky_cli/blob/baaeeb0b8c7a410625320af394437f8665442664/src/web_app_skeleton/config/cookies.cr.ecr)\n\n- Replace `config/email.cr` with [this one](https://github.com/luckyframework/lucky_cli/blob/baaeeb0b8c7a410625320af394437f8665442664/src/web_app_skeleton/config/email.cr).\n\n- Add this line to `spec_helper.cr` (around line 19) -> `LuckyRecord::Migrator::Runner.new.ensure_migrated!`\n\n- In `config/server.cr`, copy the new block starting at [`line 15`](https://github.com/luckyframework/lucky_cli/blob/baaeeb0b8c7a410625320af394437f8665442664/src/web_app_skeleton/config/server.cr.ecr#L15-L23).\n\n- Update shard versions in `shard.yml`:\n\n  - Lucky `~> 0.12`\n  - LuckyRecord `~> 0.7`\n  - Authentic `~> 0.2`\n  - LuckyFlow `~> 0.3`\n\n- Change `.crystal-version` to `0.27.0`\n\n- Run `shards update` to install the new shards\n\n## Upgrading from 0.10 to 0.11\n\n- Upgrade Lucky CLI (macOS)\n\n```\nbrew update\nbrew upgrade crystal-lang # Make sure you're up-to-date\nbrew upgrade lucky\n```\n\n- Upgrade Lucky CLI (Linux)\n\n> Remove the existing Lucky binary and follow the Linux\n> instructions in this section\n> https://luckyframework.org/guides/installing/#install-lucky\n\n> Use your package manager to update Crystal to v0.25\n\n- Update `.crystal-version` to `0.25.0`\n\n- Change `crystal deps` to `shards install` in `bin/setup`\n\n- Update `lucky_flow` and `lucky_migrator` in `shard.yml`\n\n  - `lucky_flow` should now be `0.2`\n  - `lucky_migrator` should now be `0.6`\n\n- Remove any cached shards: rm -rf ~/.cache/shards\n\n  > This is to address a bug in shards: https://github.com/crystal-lang/shards/issues/211\n\n- Run `shards update`\n\n- Find all instances of `nested_action` and replace with `nested_route`\n\n- Find all instances of `action` and replace with `route` in your actions\n\n  > To make it easier to only change the right thing, search for `action do` and\n  > replace with `route do`. This will make it fairly easy to find and replace\n  > across your whole project.\n\n- Move static assets from `static/assets` to `public/assets`\n\n- Move `static/js` to `src/js`\n\n- Move `static/css` to `src/css`\n\n- Remove `/public` from `.gitignore`\n\n- Add these to `.gitignore`\n\n  - `/public/mix-manifest.json`\n  - `/public/js`\n  - `/public/css`\n\n- Update `src/app.cr` lines:\n\n  - Remove host and port: https://github.com/luckyframework/lucky_cli/blob/ce677b8aefbbef2f06587d835795cbb59c5801dd/src/web_app_skeleton/src/app.cr.ecr#L25\n  - Add `bind_tcp` with host and port: https://github.com/luckyframework/lucky_cli/blob/ce677b8aefbbef2f06587d835795cbb59c5801dd/src/web_app_skeleton/src/app.cr.ecr#L50\n\n- Update webpack config to match this: https://github.com/luckyframework/lucky_cli/blob/ce677b8aefbbef2f06587d835795cbb59c5801dd/src/browser_app_skeleton/webpack.mix.js#L12-L37\n\n- Calls to the `asset` method no longer require prefixing `/assets`. You may not\n  be using this. The compiler will complain and help you find the right asset if\n  you need to update this.\n\n### Upgrading from 0.8 to 0.10\n\n> Note: Lucky skipped version 0.9 so that Lucky and Lucky CLI are on the same version.\n\n- Upgrade Lucky CLI\n\nOn macOS:\n\n```\nbrew update\nbrew upgrade crystal-lang # Make sure you're up-to-date\nbrew upgrade lucky\n```\n\nIf you are on Linux, remove the existing Lucky binary and follow the Linux\ninstructions in this section\nhttps://luckyframework.org/guides/installing/#install-lucky\n\n- View the upgrade diff and make changes to your app\n\nIn previous upgrade guides (below) every change is listed individually. This was\ntime consuming and error-prone. Now,\nyou can [view all changes in this GitHub commit](https://github.com/luckyframework/upgrade-diffs/commit/c279b0d0c0b9936301c5ea93fd25a549c9cd4c06).\n\n- Ensure node version is at least 6.0 `node -v`. Install a newer version if\n  yours is older.\n\n- Move files in `src/pipes` to `src/actions/mixins`\n\n- Change `allow` to `fillable` in forms\n\n- Change `allow_virtual` to `virtual` in forms\n\n- Run `shards update`\n\n- Run `bin/setup` to run new migrations, Laravel Mix and seeds file\n\n> If you have any problems or want to add extra details please open an issue or\n> Pull Request. Thank you!\n\n### Upgrading from 0.7 to 0.8\n\n- Upgrade Lucky CLI\n\nOn macOS:\n\n```\nbrew update\nbrew upgrade crystal-lang\nbrew upgrade lucky\n```\n\nIf you are on Linux, remove the existing Lucky binary and follow the Linux\ninstructions in this section:\nhttps://luckyframework.org/guides/installing/#install-lucky\n\n- Update dependencies in `shard.yml`\n\n```yml\ndependencies:\n  lucky:\n    github: luckyframework/lucky\n    version: \"~> 0.8.0\"\n  lucky_migrator:\n    github: luckyframework/lucky_migrator\n    version: ~> 0.4.0\n```\n\nThen run `shards update`\n\n- Update `config/server.cr`\n\nYou can probably copy this as-is, but if you have made customizations to your\n`config/server.cr` then you'll need to customize this:\n\n```crystal\nLucky::Server.configure do |settings|\n  if Lucky::Env.production?\n    settings.secret_key_base = secret_key_from_env\n    settings.host = \"0.0.0.0\"\n    settings.port = ENV[\"PORT\"].to_i\n  else\n    settings.secret_key_base = \"<%= secret_key_base %>\"\n    # Change host/port in config/watch.yml\n    # Alternatively, you can set the PORT env to set the port\n    settings.host = Lucky::ServerSettings.host\n    settings.port = Lucky::ServerSettings.port\n  end\nend\n\nprivate def secret_key_from_env\n  ENV[\"SECRET_KEY_BASE\"]? || raise_missing_secret_key_in_production\nend\n\nprivate def raise_missing_secret_key_in_production\n  raise \"Please set the SECRET_KEY_BASE environment variable. You can generate a secret key with 'lucky gen.secret_key'\"\nend\n```\n\n- Add `config/watch.yml`\n\nThis is used by the watcher so it knows what port the server is running on.\n\n```yaml\nhost: 0.0.0.0\nport: 5000\n```\n\n- Update `config/database.cr`\n\nPut this inside of the `LuckyRecord::Repo.configure do |settings|` block:\n\n```\n# In development and test, raise an error if you forget to preload associations\nsettings.lazy_load_enabled = Lucky::Env.production?\n```\n\nSee a full example here: https://github.com/luckyframework/lucky_cli/blob/a25472cc7461b1803735d086e57a632f92f93a1c/src/web_app_skeleton/config/database.cr.ecr\n\n- You will need to preload associations now:\n\nThis will make N+1 queries a thing of the past.\n\n```crystal\n# Will now raise a runtime error in dev/test\npost = PostQuery.new.find(id)\npost.comments # Must preload comments\n\n# Now, you need to preload the comments\npost = PostQuery.new.preload_comments.find(id)\npost.comments\n```\n\n- Rename `field` to `column` in your models. For example\n\n```crystal\nclass Post < BaseModel\n  table :posts do\n    column title : String # was \"field title : String\" previously\n  end\nend\n```\n\n- Optionally include `responsive_meta_tag` in `MainLayout`\n\nYou can include this in `head` to make your app layout responsive.\n\n- Change `abstract def inner` to `abstract def content` in `MainLayout`\n\n- Change method call to `inner` to `content` in the render method of `MainLayout`\n\n- Change instances of `def inner` to `def content` in Pages\n\n- Change form `needs` to use `on: :create`\n\n`needs` in forms should now use `on: :save` if you want the old behavior.\n\nSee https://luckyframework.org/guides/saving-with-forms/#passing-extra-data-to-forms for more info\n\n- Must pass extra params using `create` or `update`\n\nYou can no longer pass params to `Form#new`. You must pass them in the\n`create` or `update`.\n\n```crystal\nUserForm.new(name: \"Jane\").save!\nUserForm.create!(name: \"Jane\")\n```\n\nMore info at https://luckyframework.org/guides/saving-with-forms/#passing-data-without-route-params\n\n- Change calls from `form.save_succeeded?` to `form.saved?`\n\n- Trap int in src/server.cr\n\nAdd this to your `src/server.cr` before `server.listen`\n\n```crystal\nSignal::INT.trap do\n  server.close\nend\n```\n\n- Add `bin/lucky/` to `.gitignore`\n\n```\n# Add to .gitignore\nbin/lucky/\n```\n\n- Add nice HTML error page\n\nCopy contents of the linked file to `src/pages/errors/show_page.cr`\nhttps://github.com/luckyframework/lucky_cli/blob/a25472cc7461b1803735d086e57a632f92f93a1c/src/web_app_skeleton/src/pages/errors/show_page.cr\n\n- Add default `Error::ShowSerializer`\n\nThis is used for serializing errors to JSON. Add this to\n`src/serializers/errors/show_serializer.cr`\n\n```crystal\n# This is the default error serializer generated by Lucky.\n# Feel free to customize it in any way you like.\nclass Errors::ShowSerializer < Lucky::Serializer\n  def initialize(@message : String, @details : String? = nil)\n  end\n\n  def render\n    {message: @message, details: @details}\n  end\nend\n```\n\n- Update `Errors::Show` action\n\nThe error handling action now supports more errors and renders better output.\n\nCopy the contents of the linked file to `src/actions/errors/show.cr`\nhttps://github.com/luckyframework/lucky_cli/blob/a25472cc7461b1803735d086e57a632f92f93a1c/src/web_app_skeleton/src/actions/errors/show.cr\n\n- Require serializers\n\nAdd the following to `src/app.cr`.\n\n```crystal\nrequire \"./serializers/**\"\n```\n\n### Upgrading from 0.6 to 0.7\n\n- Update to Crystal v0.24.1. Lucky will fail on earlier versions\n\n```\nbrew update\nbrew upgrade crystal-lang\nbrew upgrade lucky\n```\n\nIf you are on Linux, remove the existing Lucky binary and follow the Linux instructions in this section: https://luckyframework.org/guides/installing/#install-lucky\n\n- Update dependencies in `shard.yml`\n\n```yml\ndependencies:\n  lucky:\n    github: luckyframework/lucky\n    version: \"~> 0.7.0\"\n  lucky_migrator:\n    github: luckyframework/lucky_migrator\n    version: ~> 0.4.0\n```\n\nThen run `shards update`\n\n- Configure the domain to use for the RouteHelper:\n\n```crystal\n# Add to config/route_helper.cr\nLucky::RouteHelper.configure do |settings|\n  if Lucky::Env.production?\n    # The APP_DOMAIN is something like https://myapp.com\n    settings.domain = ENV.fetch(\"APP_DOMAIN\")\n  else\n    settings.domain = \"http:://localhost:3001\"\n  end\nend\n```\n\n- Add `csrf_meta_tags` to your `MainLayout`\n\n```crystal\n# src/pages/main_layout.cr\n# Somewhere in the head tag:\ncsrf_meta_tags\n```\n\n- Remove `needs flash` from `MainLayout`\n\n```crystal\n# Delete this line\nneeds flash : Lucky::Flash::Store\n```\n\n- Remove `expose flash` from `BrowserAction` and add forgery protection\n\n```crystal\n# src/actions/browser_action.cr\nabstract class BrowserAction < Lucky::Action\n  include Lucky::ProtectFromForgery\nend\n```\n\n- Change `Shared::FlashComponent` to get the flash from `@context`\n\n```crystal\n# src/components/shared/flash_component.cr\n# Change this:\n@flash.each\n# To:\n@context.flash.each\n```\n\n- Add `*.dwarf` to the .gitignore\n\n```\n# Add to .gitignore\n*.dwarf\n```\n"
  },
  {
    "path": "bin/lucky.exec.cr",
    "content": "require \"lucky/tasks/exec\"\n\nLucky::Exec.new.print_help_or_call(ARGV)\n"
  },
  {
    "path": "bin/lucky.gen.action.api.cr",
    "content": "require \"lucky/tasks/gen/action/api\"\n\nGen::Action::Api.new.print_help_or_call(ARGV)\n"
  },
  {
    "path": "bin/lucky.gen.action.browser.cr",
    "content": "require \"lucky/tasks/gen/action/browser\"\n\nGen::Action::Browser.new.print_help_or_call(ARGV)\n"
  },
  {
    "path": "bin/lucky.gen.action.cr",
    "content": "require \"colorize\"\n\nputs <<-ERROR\n    Missing 'browser' or 'api' after 'gen.action'\n\n    For actions used in a browser (HTML, redirects)...\n\n        #{\"lucky gen.action.browser\".colorize.green.bold}\n\n    For an API endpoint (JSON, XML, GraphQL)...\n\n        #{\"lucky gen.action.api\".colorize.green.bold}\n\n\n    ERROR\n"
  },
  {
    "path": "bin/lucky.gen.component.cr",
    "content": "require \"lucky/tasks/gen/component\"\n\nGen::Component.new.print_help_or_call(ARGV)\n"
  },
  {
    "path": "bin/lucky.gen.page.cr",
    "content": "require \"lucky/tasks/gen/page\"\n\nGen::Page.new.print_help_or_call(ARGV)\n"
  },
  {
    "path": "bin/lucky.gen.secret_key.cr",
    "content": "require \"lucky/tasks/gen/secret_key\"\n\nGen::SecretKey.new.print_help_or_call(ARGV)\n"
  },
  {
    "path": "bin/lucky.gen.task.cr",
    "content": "require \"lucky/tasks/gen/task\"\n\nGen::Task.new.print_help_or_call(ARGV)\n"
  },
  {
    "path": "bin/lucky.watch.cr",
    "content": "require \"lucky/tasks/watch\"\n\nWatch.new.print_help_or_call(ARGV)\n"
  },
  {
    "path": "bunfig.toml",
    "content": "[test]\nroot = \"./spec/bun\"\n"
  },
  {
    "path": "docker-compose.yml",
    "content": "services:\n  app:\n    build:\n      context: .\n      dockerfile: Dockerfile\n    environment:\n      SHARDS_CACHE_PATH: /data/.shards\n    volumes:\n      - .:/data\n    command: sleep infinity\n"
  },
  {
    "path": "public/bun-manifest.json",
    "content": "{\n  \"images/logo.png\": \"images/logo-8dc912a1.png\",\n  \"images/lucky_logo.png\": \"images/lucky_logo-8dc912a1.png\",\n  \"js/app.js\": \"js/app-eb1157b7.js\",\n  \"css/app.css\": \"css/app-4b2f41d7.css\"\n}\n"
  },
  {
    "path": "public/mix-manifest.json",
    "content": "{\n  \"/images/logo.png\": \"/images/logo-with-hash.png\",\n  \"/assets/images/inside-assets-folder.png\":\n    \"/assets/images/inside-assets-folder.png\"\n}\n"
  },
  {
    "path": "public/vite-manifest.json",
    "content": "{\n  \"src/images/lucky_logo.png\": {\n    \"file\": \"images/lucky_logo.a54cc67e.png\",\n    \"src\": \"src/images/lucky_logo.png\"\n  },\n  \"src/assets/js/app.js\": {\n    \"file\": \"assets/js/app.a54cc67e.js\",\n    \"src\": \"src/assets/js/app.js\"\n  }\n}\n"
  },
  {
    "path": "script/docs",
    "content": "#!/bin/sh -eu\n\nCOMPOSE=\"docker compose run --rm app\"\n\nprintf \"\\ngenerating docs with 'crystal docs'\\n\\n\"\n$COMPOSE crystal docs\n"
  },
  {
    "path": "script/setup",
    "content": "#!/bin/sh -eu\n\nif ! command -v docker compose >/dev/null; then\n  printf 'Docker and docker compose are not installed.\\n'\n  printf 'See https://docs.docker.com/compose/install/ for install instructions.\\n'\n  exit 1\nfi\n\ndocker compose build\n\ndocker compose run app shards update\n"
  },
  {
    "path": "script/test",
    "content": "#!/bin/sh -eu\n\nCOMPOSE=\"docker compose run --remove-orphans --rm app\"\n\nprintf \"\\nrunning specs with 'crystal spec'\\n\\n\"\n$COMPOSE crystal spec \"$@\"\n\nprintf \"\\nrunning bun tests with 'bun test'\\n\\n\"\n$COMPOSE bun test\n\nif [ $# = 0 ]; then\n  printf \"\\nChecking that tasks build correctly\\n\\n\"\n  $COMPOSE shards build\n\n  printf \"\\nChecking code formatting\\n\\n\"\n  if ! $COMPOSE crystal tool format --check src spec >/dev/null; then\n    printf \"\\nCode is not formatted.\\n\"\n    printf \"\\nFormat the code with: docker compose run --rm app crystal tool format src spec\\n\\n\"\n    exit 1\n  fi\nfi\n"
  },
  {
    "path": "shard.edge.yml",
    "content": "dependencies:\n  lucky_task:\n    github: luckyframework/lucky_task\n    branch: main\n  habitat:\n    github: luckyframework/habitat\n    branch: main\n  wordsmith:\n    github: luckyframework/wordsmith\n    branch: main\n  avram:\n    github: luckyframework/avram\n    branch: main\n  lucky_router:\n    github: luckyframework/lucky_router\n    branch: main\n  shell-table:\n    github: luckyframework/shell-table.cr\n    branch: main\n  cry:\n    github: luckyframework/cry\n    branch: main\n  exception_page:\n    github: crystal-loot/exception_page\n    branch: master\n  dexter:\n    github: luckyframework/dexter\n    branch: main\n  pulsar:\n    github: luckyframework/pulsar\n    branch: main\n  teeplate:\n    github: luckyframework/teeplate\n    branch: main\n"
  },
  {
    "path": "shard.override.yml",
    "content": "# dependencies:\n#   habitat:\n#     github: luckyframework/habitat\n#     branch: main\n"
  },
  {
    "path": "shard.yml",
    "content": "name: lucky\nversion: 1.4.0\n\ncrystal: '>= 1.16.3'\n\nauthors:\n  - Paul Smith <paulcsmith0218@gmail.com>\n\nexecutables:\n  - lucky.exec.cr\n  - lucky.watch.cr\n  - lucky.gen.action.cr\n  - lucky.gen.action.browser.cr\n  - lucky.gen.action.api.cr\n  - lucky.gen.page.cr\n  - lucky.gen.component.cr\n  - lucky.gen.task.cr\n  - lucky.gen.secret_key.cr\n\ndependencies:\n  lucky_task:\n    github: luckyframework/lucky_task\n    version: ~> 0.3.0\n  habitat:\n    github: luckyframework/habitat\n    version: ~> 0.4.9\n  wordsmith:\n    github: luckyframework/wordsmith\n    version: ~> 0.5.0\n  lucky_router:\n    github: luckyframework/lucky_router\n    version: ~> 0.6.0\n  shell-table:\n    github: luckyframework/shell-table.cr\n    version: ~> 0.9.3\n  cry:\n    github: luckyframework/cry\n    version: ~> 0.4.3\n  exception_page:\n    github: crystal-loot/exception_page\n    version: ~> 0.5.0\n  dexter:\n    github: luckyframework/dexter\n    version: ~> 0.3.4\n  pulsar:\n    github: luckyframework/pulsar\n    version: ~> 0.2.3\n  lucky_template:\n    github: luckyframework/lucky_template\n    version: ~> 0.2.0\n  lucky_cache:\n    github: luckyframework/lucky_cache\n    version: ~> 0.2.0\n\ndevelopment_dependencies:\n  lucky_env:\n    github: luckyframework/lucky_env\n    version: ~> 0.3.0\n  ameba:\n    github: crystal-ameba/ameba\n    version: ~> 1.6.4\n\nlicense: MIT\n"
  },
  {
    "path": "spec/bun/config_spec.cr",
    "content": "require \"../spec_helper\"\n\ndescribe LuckyBun::Config do\n  it \"uses defaults without a config file\" do\n    config = LuckyBun::Config.from_json(\"{}\")\n\n    config.dev_server.host.should eq(\"127.0.0.1\")\n    config.dev_server.port.should eq(3002)\n    config.dev_server.secure?.should be_false\n    config.dev_server.ws_url.should eq(\"ws://127.0.0.1:3002\")\n    config.entry_points.css.should eq(%w[src/css/app.css])\n    config.entry_points.js.should eq(%w[src/js/app.js])\n    config.manifest_path.should eq(\"public/bun-manifest.json\")\n    config.out_dir.should eq(\"public/assets\")\n    config.public_path.should eq(\"/assets\")\n    config.static_dirs.should eq(%w[src/images src/fonts])\n  end\n\n  it \"accepts string entry points\" do\n    config = LuckyBun::Config.from_json(\n      %({\"entryPoints\": {\"js\": \"src/js/app.ts\", \"css\": \"src/css/app.css\"}})\n    )\n\n    config.entry_points.js.should eq(%w[src/js/app.ts])\n    config.entry_points.css.should eq(%w[src/css/app.css])\n  end\n\n  it \"accepts array entry points\" do\n    config = LuckyBun::Config.from_json(\n      %({\"entryPoints\": {\"js\": [\"src/js/app.js\", \"src/js/admin.js\"]}})\n    )\n\n    config.entry_points.js.should eq(%w[src/js/app.js src/js/admin.js])\n  end\n\n  it \"supports secure websocket url\" do\n    config = LuckyBun::Config.from_json(%({\"devServer\": {\"secure\": true}}))\n\n    config.dev_server.ws_protocol.should eq(\"wss\")\n    config.dev_server.ws_url.should eq(\"wss://127.0.0.1:3002\")\n  end\nend\n"
  },
  {
    "path": "spec/bun/lucky.test.js",
    "content": "import {describe, test, expect, beforeEach, afterAll} from 'bun:test'\nimport {mkdirSync, writeFileSync, rmSync, existsSync, readFileSync} from 'fs'\nimport {join} from 'path'\nimport LuckyBun from '../../src/bun/lucky.js'\n\nconst TEST_DIR = join(process.cwd(), '.test-tmp')\n\nbeforeEach(() => {\n  rmSync(TEST_DIR, {recursive: true, force: true})\n  mkdirSync(TEST_DIR, {recursive: true})\n  LuckyBun.manifest = {}\n  LuckyBun.config = null\n  LuckyBun.plugins = []\n  LuckyBun.debug = false\n  LuckyBun.prod = false\n  LuckyBun.dev = false\n  LuckyBun.root = TEST_DIR\n})\n\nafterAll(() => {\n  rmSync(TEST_DIR, {recursive: true, force: true})\n})\n\nfunction createFile(relativePath, content = '') {\n  const fullPath = join(TEST_DIR, relativePath)\n  mkdirSync(join(fullPath, '..'), {recursive: true})\n  writeFileSync(fullPath, content)\n  return fullPath\n}\n\nfunction readOutput(relativePath) {\n  return readFileSync(join(TEST_DIR, 'public/assets', relativePath), 'utf-8')\n}\n\nasync function setupProject(files = {}, configOverrides = {}) {\n  for (const [path, content] of Object.entries(files)) createFile(path, content)\n  if (configOverrides && Object.keys(configOverrides).length)\n    createFile('config/bun.json', JSON.stringify(configOverrides))\n  LuckyBun.loadConfig()\n  await LuckyBun.loadPlugins()\n}\n\nasync function buildCSS(files, configOverrides) {\n  await setupProject(files, configOverrides)\n  await LuckyBun.buildCSS()\n  return readOutput('css/app.css')\n}\n\nasync function buildJS(files, configOverrides) {\n  await setupProject(files, configOverrides)\n  await LuckyBun.buildJS()\n  return readOutput('js/app.js')\n}\n\ndescribe('flags', () => {\n  test('sets known flags and ignores undefined values', () => {\n    LuckyBun.flags({dev: true})\n    expect(LuckyBun.dev).toBe(true)\n\n    LuckyBun.flags({prod: true})\n    expect(LuckyBun.prod).toBe(true)\n\n    LuckyBun.flags({debug: true})\n    expect(LuckyBun.debug).toBe(true)\n\n    LuckyBun.dev = true\n    LuckyBun.flags({prod: false})\n    expect(LuckyBun.dev).toBe(true)\n    expect(LuckyBun.prod).toBe(false)\n  })\n})\n\ndescribe('deepMerge', () => {\n  test('deep merges objects, replaces arrays and nulls', () => {\n    expect(LuckyBun.deepMerge({a: 1, b: 2}, {b: 3, c: 4})).toEqual({\n      a: 1,\n      b: 3,\n      c: 4\n    })\n    expect(\n      LuckyBun.deepMerge({outer: {a: 1, b: 2}}, {outer: {b: 3, c: 4}})\n    ).toEqual({outer: {a: 1, b: 3, c: 4}})\n    expect(LuckyBun.deepMerge({arr: [1, 2]}, {arr: [3, 4, 5]})).toEqual({\n      arr: [3, 4, 5]\n    })\n    expect(LuckyBun.deepMerge({a: {nested: 1}}, {a: null})).toEqual({a: null})\n  })\n})\n\ndescribe('loadConfig', () => {\n  test('uses defaults without a config file', () => {\n    LuckyBun.loadConfig()\n    expect(LuckyBun.config.outDir).toBe('public/assets')\n    expect(LuckyBun.config.watchDirs).toEqual(['src/js', 'src/css', 'src/images', 'src/fonts'])\n    expect(LuckyBun.config.entryPoints.js).toEqual(['src/js/app.js'])\n    expect(LuckyBun.config.devServer.port).toBe(3002)\n    expect(LuckyBun.config.plugins).toEqual({\n      css: ['aliases', 'cssGlobs'],\n      js: ['aliases', 'jsGlobs']\n    })\n  })\n\n  test('merges user config with defaults', () => {\n    createFile(\n      'config/bun.json',\n      JSON.stringify({outDir: 'dist', devServer: {port: 4000}})\n    )\n\n    LuckyBun.loadConfig()\n\n    expect(LuckyBun.config.outDir).toBe('dist')\n    expect(LuckyBun.config.devServer.port).toBe(4000)\n    expect(LuckyBun.config.devServer.host).toBe('127.0.0.1')\n    expect(LuckyBun.config.entryPoints.js).toEqual(['src/js/app.js'])\n  })\n\n  test('merges watchDirs from user config', () => {\n    createFile(\n      'config/bun.json',\n      JSON.stringify({watchDirs: ['src/js', 'src/css']})\n    )\n\n    LuckyBun.loadConfig()\n\n    expect(LuckyBun.config.watchDirs).toEqual(['src/js', 'src/css'])\n  })\n\n  test('merges listenHost into devServer config', () => {\n    createFile(\n      'config/bun.json',\n      JSON.stringify({devServer: {listenHost: '0.0.0.0'}})\n    )\n\n    LuckyBun.loadConfig()\n\n    expect(LuckyBun.config.devServer.listenHost).toBe('0.0.0.0')\n    expect(LuckyBun.config.devServer.host).toBe('127.0.0.1')\n  })\n\n  test('user can override plugins', () => {\n    createFile(\n      'config/bun.json',\n      JSON.stringify({\n        plugins: {css: ['cssAliases'], js: ['config/bun/banner.js']}\n      })\n    )\n    LuckyBun.loadConfig()\n\n    expect(LuckyBun.config.plugins.css).toEqual(['cssAliases'])\n    expect(LuckyBun.config.plugins.js).toEqual(['config/bun/banner.js'])\n  })\n})\n\ndescribe('fingerprint', () => {\n  test('returns plain filename in dev mode', () => {\n    expect(LuckyBun.fingerprint('app', '.js', 'content')).toBe('app.js')\n  })\n\n  test('returns consistent, content-dependent hashes in prod mode', () => {\n    LuckyBun.prod = true\n    const hash = LuckyBun.fingerprint('app', '.js', 'content')\n    expect(hash).toMatch(/^app-[a-f0-9]{8}\\.js$/)\n    expect(LuckyBun.fingerprint('app', '.js', 'content')).toBe(hash)\n    expect(LuckyBun.fingerprint('app', '.js', 'different')).not.toBe(hash)\n  })\n})\n\ndescribe('IGNORE_PATTERNS', () => {\n  test('ignores editor artifacts and system files but allows normal files', () => {\n    const ignores = f => LuckyBun.IGNORE_PATTERNS.some(p => p.test(f))\n\n    for (const f of [\n      '.#file.js',\n      'file.swp',\n      'file.swo',\n      'file.tmp',\n      '#file.js#',\n      '.DS_Store',\n      '12345'\n    ])\n      expect(ignores(f)).toBe(true)\n    for (const f of ['app.js', 'styles.css', 'image.png'])\n      expect(ignores(f)).toBe(false)\n  })\n})\n\ndescribe('buildAssets', () => {\n  test('builds JS files', async () => {\n    await buildJS({'src/js/app.js': 'console.log(\"test\")'})\n\n    expect(LuckyBun.manifest['js/app.js']).toBe('js/app.js')\n    expect(existsSync(join(TEST_DIR, 'public/assets/js/app.js'))).toBe(true)\n  })\n\n  test('builds CSS files', async () => {\n    await buildCSS({'src/css/app.css': 'body { color: pink }'})\n\n    expect(LuckyBun.manifest['css/app.css']).toBe('css/app.css')\n    expect(existsSync(join(TEST_DIR, 'public/assets/css/app.css'))).toBe(true)\n  })\n\n  test('fingerprints in prod mode', async () => {\n    LuckyBun.prod = true\n    await setupProject({'src/js/app.js': 'console.log(\"prod\")'})\n    await LuckyBun.buildJS()\n\n    expect(LuckyBun.manifest['js/app.js']).toMatch(/^js\\/app-[a-f0-9]{8}\\.js$/)\n  })\n\n  test('warns on missing entry point and continues', async () => {\n    await setupProject()\n    // No src/js/app.js created — should not throw\n    await LuckyBun.buildJS()\n\n    expect(LuckyBun.manifest['js/app.js']).toBeUndefined()\n  })\n\n  test('accepts a string entry point', async () => {\n    await setupProject(\n      {'src/js/app.js': 'console.log(\"single\")'},\n      {entryPoints: {js: 'src/js/app.js'}}\n    )\n    await LuckyBun.buildJS()\n\n    expect(LuckyBun.manifest['js/app.js']).toBe('js/app.js')\n  })\n\n  test('builds multiple JS entry points', async () => {\n    await buildJS(\n      {\n        'src/js/app.js': 'console.log(\"app\")',\n        'src/js/admin.js': 'console.log(\"admin\")'\n      },\n      {entryPoints: {js: ['src/js/app.js', 'src/js/admin.js']}}\n    )\n\n    expect(LuckyBun.manifest['js/app.js']).toBe('js/app.js')\n    expect(LuckyBun.manifest['js/admin.js']).toBe('js/admin.js')\n  })\n\n  test('builds TypeScript files', async () => {\n    await setupProject(\n      {'src/js/app.ts': 'const msg: string = \"hello\"\\nconsole.log(msg)'},\n      {entryPoints: {js: ['src/js/app.ts']}}\n    )\n    await LuckyBun.buildJS()\n\n    expect(LuckyBun.manifest['js/app.js']).toBe('js/app.js')\n    expect(existsSync(join(TEST_DIR, 'public/assets/js/app.js'))).toBe(true)\n    expect(readOutput('js/app.js')).toContain('hello')\n  })\n\n  test('builds TSX files', async () => {\n    await setupProject(\n      {\n        'src/js/app.tsx': [\n          'function App(): string { return \"tsx works\" }',\n          'console.log(App())'\n        ].join('\\n')\n      },\n      {entryPoints: {js: ['src/js/app.tsx']}}\n    )\n    await LuckyBun.buildJS()\n\n    expect(LuckyBun.manifest['js/app.js']).toBe('js/app.js')\n    expect(existsSync(join(TEST_DIR, 'public/assets/js/app.js'))).toBe(true)\n  })\n\n  test('builds multiple CSS entry points', async () => {\n    await buildCSS(\n      {\n        'src/css/app.css': 'body { color: red }',\n        'src/css/admin.css': 'body { color: blue }'\n      },\n      {entryPoints: {css: ['src/css/app.css', 'src/css/admin.css']}}\n    )\n\n    expect(LuckyBun.manifest['css/app.css']).toBe('css/app.css')\n    expect(LuckyBun.manifest['css/admin.css']).toBe('css/admin.css')\n  })\n})\n\ndescribe('copyStaticAssets', () => {\n  async function copyAssets(files = {}, config = {}) {\n    await setupProject(files, config)\n    await LuckyBun.copyStaticAssets()\n  }\n\n  test('copies images and fonts, preserving nested structure', async () => {\n    await copyAssets({\n      'src/images/logo.png': 'fake-image-data',\n      'src/images/icons/arrow.svg': '<svg/>',\n      'src/fonts/Inter.woff2': 'fake-font-data'\n    })\n\n    expect(LuckyBun.manifest['images/logo.png']).toBe('images/logo.png')\n    expect(LuckyBun.manifest['images/icons/arrow.svg']).toBeDefined()\n    expect(LuckyBun.manifest['fonts/Inter.woff2']).toBe('fonts/Inter.woff2')\n    expect(existsSync(join(TEST_DIR, 'public/assets/images/logo.png'))).toBe(\n      true\n    )\n    expect(\n      existsSync(join(TEST_DIR, 'public/assets/images/icons/arrow.svg'))\n    ).toBe(true)\n  })\n\n  test('fingerprints static assets in prod mode', async () => {\n    LuckyBun.prod = true\n    await copyAssets({'src/images/logo.png': 'fake-image-data'})\n\n    expect(LuckyBun.manifest['images/logo.png']).toMatch(\n      /^images\\/logo-[a-f0-9]{8}\\.png$/\n    )\n  })\n\n  test('skips missing static directories', async () => {\n    await copyAssets()\n\n    expect(Object.keys(LuckyBun.manifest)).toHaveLength(0)\n  })\n})\n\ndescribe('cleanOutDir', () => {\n  test('removes output directory and does not throw if already absent', async () => {\n    createFile('public/assets/js/old.js', 'old')\n    await setupProject()\n    LuckyBun.cleanOutDir()\n\n    expect(existsSync(join(TEST_DIR, 'public/assets'))).toBe(false)\n    expect(() => LuckyBun.cleanOutDir()).not.toThrow()\n  })\n})\n\ndescribe('writeManifest', () => {\n  test('writes manifest JSON', async () => {\n    await setupProject()\n    LuckyBun.manifest = {'js/app.js': 'js/app-abc123.js'}\n    await LuckyBun.writeManifest()\n    const content = readFileSync(\n      join(TEST_DIR, LuckyBun.config.manifestPath),\n      'utf-8'\n    )\n\n    expect(JSON.parse(content)).toEqual({'js/app.js': 'js/app-abc123.js'})\n  })\n})\n\ndescribe('outDir', () => {\n  test('throws if config not loaded', () => {\n    LuckyBun.config = null\n\n    expect(() => LuckyBun.outDir).toThrow('Config is not loaded')\n  })\n\n  test('returns full path when config loaded', () => {\n    LuckyBun.loadConfig()\n\n    expect(LuckyBun.outDir).toBe(join(TEST_DIR, 'public/assets'))\n  })\n})\n\ndescribe('loadPlugins', () => {\n  test('loads default plugins', async () => {\n    LuckyBun.loadConfig()\n    await LuckyBun.loadPlugins()\n\n    expect(LuckyBun.plugins).toHaveLength(2)\n    expect(\n      LuckyBun.plugins.find(p => p.name === 'css-transforms')\n    ).toBeDefined()\n    expect(LuckyBun.plugins.find(p => p.name === 'js-transforms')).toBeDefined()\n  })\n\n  test('loads no plugins when config is empty', async () => {\n    createFile('config/bun.json', JSON.stringify({plugins: {}}))\n    LuckyBun.loadConfig()\n    await LuckyBun.loadPlugins()\n\n    expect(LuckyBun.plugins).toHaveLength(0)\n  })\n\n  test('handles unknown built-in plugin gracefully', async () => {\n    createFile(\n      'config/bun.json',\n      JSON.stringify({plugins: {css: ['nonExistent']}})\n    )\n    LuckyBun.loadConfig()\n    await LuckyBun.loadPlugins()\n\n    expect(LuckyBun.plugins).toHaveLength(0)\n  })\n\n  test('loads custom plugin from path', async () => {\n    createFile(\n      'config/bun/uppercase.js',\n      `export default function() {\n        return content => content.toUpperCase()\n      }`\n    )\n    createFile(\n      'config/bun.json',\n      JSON.stringify({plugins: {css: ['config/bun/uppercase.js']}})\n    )\n    LuckyBun.loadConfig()\n    await LuckyBun.loadPlugins()\n\n    expect(LuckyBun.plugins).toHaveLength(1)\n    expect(LuckyBun.plugins[0].name).toBe('css-transforms')\n  })\n})\n\ndescribe('aliases plugin', () => {\n  test('replaces $/ references with root path in CSS url()', async () => {\n    const content = await buildCSS({\n      'src/css/app.css': [\n        \"body { background: url('$/src/images/bg.png'); }\",\n        \".icon { background: url('$/src/images/icon.svg'); }\"\n      ].join('\\n'),\n      'src/images/bg.png': 'fake',\n      'src/images/icon.svg': '<svg/>'\n    })\n\n    // The alias is resolved and Bun inlines the assets as data URIs\n    expect(content).not.toContain('$/')\n    expect(content).toContain('url(')\n  })\n\n  test('replaces $/ references in JS imports', async () => {\n    const content = await buildJS({\n      'src/js/app.js': \"import utils from '$/lib/utils.js'\\nconsole.log(utils)\",\n      'lib/utils.js': 'export default 42'\n    })\n\n    expect(content).not.toContain('$/')\n    expect(content).toContain('42')\n  })\n\n  test('replaces $/ references in CSS @import', async () => {\n    const content = await buildCSS({\n      'src/css/app.css': \"@import '$/lib/reset.css';\",\n      'lib/reset.css': '* { margin: 0 }'\n    })\n\n    expect(content).not.toContain('$/')\n    expect(content).toContain('margin')\n  })\n\n  test('leaves non-alias urls untouched', async () => {\n    const content = await buildCSS({\n      'src/css/app.css':\n        \"body { background: url('https://example.com/bg.png'); }\"\n    })\n\n    expect(content).toContain('https://example.com/bg.png')\n  })\n\n  test('replaces $/ references in TypeScript imports', async () => {\n    await setupProject(\n      {\n        'src/js/app.ts': \"import utils from '$/lib/utils.ts'\\nconsole.log(utils)\",\n        'lib/utils.ts': 'const val: number = 99\\nexport default val'\n      },\n      {entryPoints: {js: ['src/js/app.ts']}}\n    )\n    await LuckyBun.buildJS()\n    const content = readOutput('js/app.js')\n\n    expect(content).not.toContain('$/')\n    expect(content).toContain('99')\n  })\n\n  test('leaves non-alias imports untouched', async () => {\n    const content = await buildJS({\n      'src/js/app.js': \"import {x} from './utils.js'\\nconsole.log(x)\",\n      'src/js/utils.js': 'export const x = 42'\n    })\n\n    expect(content).toContain('42')\n  })\n\n  test('resolves $/ inside prefixed strings like glob:$/', async () => {\n    const aliases = (await import('../../src/bun/plugins/aliases.js')).default\n    const transform = aliases({root: '/root'})\n    const result = transform(\"import c from 'glob:$/lib/components/*.js'\")\n\n    expect(result).toBe(\"import c from 'glob:/root/lib/components/*.js'\")\n  })\n\n  test('does not replace $/ inside regex literals', async () => {\n    const aliases = (await import('../../src/bun/plugins/aliases.js')).default\n    const transform = aliases({root: '/root'})\n    const input = \"s.replace(/.*components\\\\//, '').replace(/_component$/, '')\"\n    const result = transform(input)\n\n    expect(result).toBe(input)\n  })\n\n  test('does not match $/ preceded by a word character', async () => {\n    const content = await buildJS({\n      'src/js/app.js': [\n        \"const el = document.querySelector('div')\",\n        \"const path = '/api/test'\",\n        'console.log(el, path)'\n      ].join('\\n')\n    })\n\n    expect(content).not.toContain(TEST_DIR)\n  })\n})\n\ndescribe('cssGlobs plugin', () => {\n  test('expands glob @import with flat wildcard', async () => {\n    const content = await buildCSS({\n      'src/css/app.css': \"@import './components/*.css';\",\n      'src/css/components/button.css': '.button { color: red }',\n      'src/css/components/card.css': '.card { color: blue }'\n    })\n\n    expect(content).toContain('.button')\n    expect(content).toContain('.card')\n  })\n\n  test('expands glob @import with ** recursive wildcard', async () => {\n    const content = await buildCSS({\n      'src/css/app.css': \"@import './components/**/*.css';\",\n      'src/css/components/button.css': '.button { color: red }',\n      'src/css/components/forms/input.css': '.input { color: green }',\n      'src/css/components/forms/select.css': '.select { color: blue }'\n    })\n\n    expect(content).toContain('.button')\n    expect(content).toContain('.input')\n    expect(content).toContain('.select')\n  })\n\n  test('does not import the file itself', async () => {\n    const content = await buildCSS({\n      'src/css/app.css': \"@import './*.css';\",\n      'src/css/other.css': '.other { color: red }'\n    })\n\n    expect(content).toContain('.other')\n  })\n\n  test('handles glob matching no files', async () => {\n    await buildCSS({\n      'src/css/app.css': \"@import './empty/**/*.css';\",\n      'src/css/empty/.gitkeep': ''\n    })\n  })\n\n  test('preserves non-glob imports', async () => {\n    const content = await buildCSS({\n      'src/css/app.css':\n        \"@import './reset.css';\\n@import './components/*.css';\",\n      'src/css/reset.css': '* { margin: 0 }',\n      'src/css/components/button.css': '.button { color: red }'\n    })\n\n    expect(content).toContain('margin')\n    expect(content).toContain('.button')\n  })\n\n  test('expands globs in deterministic sorted order', async () => {\n    const content = await buildCSS({\n      'src/css/app.css': \"@import './components/*.css';\",\n      'src/css/components/zebra.css': '.zebra { order: 3 }',\n      'src/css/components/alpha.css': '.alpha { order: 1 }',\n      'src/css/components/middle.css': '.middle { order: 2 }'\n    })\n    const alphaPos = content.indexOf('.alpha')\n    const middlePos = content.indexOf('.middle')\n    const zebraPos = content.indexOf('.zebra')\n\n    expect(alphaPos).toBeLessThan(middlePos)\n    expect(middlePos).toBeLessThan(zebraPos)\n  })\n})\n\ndescribe('jsGlobs plugin', () => {\n  const jsGlobsConfig = {plugins: {js: ['jsGlobs']}}\n\n  function jsApp(...lines) {\n    return {'src/js/app.js': lines.join('\\n')}\n  }\n\n  async function buildJSGlobs(files) {\n    return buildJS(files, jsGlobsConfig)\n  }\n\n  test('expands glob import into named exports', async () => {\n    const content = await buildJSGlobs({\n      ...jsApp(\n        \"import components from 'glob:./components/*.js'\",\n        'console.log(components)'\n      ),\n      'src/js/components/modal.js': 'export default function modal() {}',\n      'src/js/components/dropdown.js': 'export default function dropdown() {}'\n    })\n\n    expect(content).toContain('modal')\n    expect(content).toContain('dropdown')\n  })\n\n  test('expands recursive glob with relative path keys', async () => {\n    const content = await buildJSGlobs({\n      ...jsApp(\n        \"import controllers from 'glob:./controllers/**/*.js'\",\n        'console.log(Object.keys(controllers))'\n      ),\n      'src/js/controllers/nav.js': 'export default function nav() {}',\n      'src/js/controllers/forms/input.js': 'export default function input() {}'\n    })\n\n    expect(content).toContain('nav')\n    expect(content).toContain('forms/input')\n  })\n\n  test('avoids naming clashes for same-named files in different dirs', async () => {\n    const content = await buildJSGlobs({\n      ...jsApp(\n        \"import modules from 'glob:./components/**/*.js'\",\n        'console.log(Object.keys(modules))'\n      ),\n      'src/js/components/nav.js': 'export default function nav() {}',\n      'src/js/components/admin/nav.js': 'export default function adminNav() {}'\n    })\n\n    expect(content).toContain('nav')\n    expect(content).toContain('admin/nav')\n  })\n\n  test('handles glob matching no files', async () => {\n    const content = await buildJSGlobs({\n      ...jsApp(\n        \"import components from 'glob:./components/*.js'\",\n        'console.log(components)'\n      ),\n      'src/js/components/.gitkeep': ''\n    })\n\n    expect(content).toBeDefined()\n  })\n\n  test('leaves non-glob imports untouched', async () => {\n    const content = await buildJSGlobs({\n      ...jsApp(\n        \"import {something} from './utils.js'\",\n        'console.log(something)'\n      ),\n      'src/js/utils.js': 'export const something = 42'\n    })\n\n    expect(content).toContain('42')\n  })\n\n  test('handles multiple glob imports', async () => {\n    const content = await buildJSGlobs({\n      ...jsApp(\n        \"import data from 'glob:./data/*.js'\",\n        \"import stores from 'glob:./stores/*.js'\",\n        'console.log(data, stores)'\n      ),\n      'src/js/data/counter.js': 'export default function counter() {}',\n      'src/js/stores/auth.js': 'export default function auth() {}'\n    })\n\n    expect(content).toContain('counter')\n    expect(content).toContain('auth')\n  })\n\n  test('avoids variable collisions across multiple globs with same filenames', async () => {\n    const content = await buildJSGlobs({\n      ...jsApp(\n        \"import components from 'glob:./components/*.js'\",\n        \"import widgets from 'glob:./widgets/*.js'\",\n        'console.log(components, widgets)'\n      ),\n      'src/js/components/theme.js':\n        'export default function componentTheme() { return \"component\" }',\n      'src/js/widgets/theme.js':\n        'export default function widgetTheme() { return \"widget\" }'\n    })\n\n    expect(content).toContain('component')\n    expect(content).toContain('widget')\n  })\n\n  test('expands globs in deterministic sorted order', async () => {\n    const content = await buildJSGlobs({\n      ...jsApp(\n        \"import components from 'glob:./components/*.js'\",\n        'for (const [k, v] of Object.entries(components)) console.log(k)'\n      ),\n      'src/js/components/zebra.js': 'export default function zebra() {}',\n      'src/js/components/alpha.js': 'export default function alpha() {}',\n      'src/js/components/middle.js': 'export default function middle() {}'\n    })\n    const alphaPos = content.indexOf('alpha')\n    const middlePos = content.indexOf('middle')\n    const zebraPos = content.indexOf('zebra')\n\n    expect(alphaPos).toBeLessThan(middlePos)\n    expect(middlePos).toBeLessThan(zebraPos)\n  })\n})\n\ndescribe('plugin pipeline', () => {\n  test('css plugins run in configured order', async () => {\n    const content = await buildCSS({\n      'src/css/app.css':\n        \"@import './components/*.css';\\nbody { background: url('$/src/images/bg.png'); }\",\n      'src/css/components/button.css': '.button { color: red }',\n      'src/images/bg.png': 'fake'\n    })\n\n    expect(content).not.toContain('$/')\n    expect(content).toContain('.button')\n  })\n\n  test('disabling all plugins still builds valid output', async () => {\n    const css = await buildCSS(\n      {'src/css/app.css': 'body { color: red }'},\n      {plugins: {}}\n    )\n    expect(css).toContain('color')\n\n    const js = await buildJS(\n      {'src/js/app.js': 'console.log(\"hello\")'},\n      {plugins: {}}\n    )\n    expect(js).toContain('hello')\n  })\n})\n\ndescribe('full build', () => {\n  test('runs the complete build pipeline', async () => {\n    await setupProject({\n      'src/js/app.js': 'console.log(\"built\")',\n      'src/css/app.css': 'body { color: red }',\n      'src/images/logo.png': 'fake-image'\n    })\n    LuckyBun.cleanOutDir()\n    await LuckyBun.copyStaticAssets()\n    await LuckyBun.buildJS()\n    await LuckyBun.buildCSS()\n    await LuckyBun.writeManifest()\n\n    expect(LuckyBun.manifest['js/app.js']).toBeDefined()\n    expect(LuckyBun.manifest['css/app.css']).toBeDefined()\n    expect(LuckyBun.manifest['images/logo.png']).toBeDefined()\n    expect(existsSync(join(TEST_DIR, LuckyBun.config.manifestPath))).toBe(true)\n  })\n\n  test('clean build removes previous output', async () => {\n    createFile('public/assets/js/stale.js', 'old stuff')\n    await setupProject({'src/js/app.js': 'console.log(\"fresh\")'})\n    LuckyBun.cleanOutDir()\n    await LuckyBun.buildJS()\n\n    expect(existsSync(join(TEST_DIR, 'public/assets/js/stale.js'))).toBe(false)\n    expect(existsSync(join(TEST_DIR, 'public/assets/js/app.js'))).toBe(true)\n  })\n})\n\ndescribe('prettyManifest', () => {\n  test('formats manifest entries and handles empty manifest', () => {\n    LuckyBun.manifest = {\n      'js/app.js': 'js/app-abc123.js',\n      'css/app.css': 'css/app-def456.css'\n    }\n    const output = LuckyBun.prettyManifest()\n    expect(output).toContain('js/app.js → js/app-abc123.js')\n    expect(output).toContain('css/app.css → css/app-def456.css')\n\n    LuckyBun.manifest = {}\n    expect(LuckyBun.prettyManifest()).toContain('\\n')\n  })\n})\n"
  },
  {
    "path": "spec/charms/cookie_spec.cr",
    "content": "require \"../spec_helper\"\n\ndescribe HTTP::Cookie do\n  describe \"setters\" do\n    it \"can chain and set values\" do\n      time = Time.utc\n      cookie = test_cookie\n        .name(\"session_id\")\n        .value(\"1\")\n        .path(\"/cookies\")\n        .expires(time)\n        .domain(\"luckyframework.org\")\n        .secure(true)\n        .http_only(true)\n        .samesite(:lax)\n\n      cookie.should be_a(HTTP::Cookie)\n      cookie.name.should eq(\"session_id\")\n      cookie.value.should eq(\"1\")\n      cookie.path.should eq(\"/cookies\")\n      cookie.expires.should eq(time)\n      cookie.domain.should eq(\"luckyframework.org\")\n      cookie.secure.should be_true\n      cookie.http_only.should be_true\n      cookie.samesite.to_s.should eq \"Lax\"\n    end\n  end\n\n  describe \"#permanent\" do\n    it \"sets expiration 20 years from now\" do\n      cookie = test_cookie.permanent\n\n      cookie.expires.as(Time).should be_close(20.years.from_now, 1.minute)\n    end\n  end\nend\n\nprivate def test_cookie\n  HTTP::Cookie.new(name: \"name\", value: \"lucky\")\nend\n"
  },
  {
    "path": "spec/charms/hash_spec.cr",
    "content": "require \"../spec_helper\"\n\ndescribe \"Hash charm\" do\n  describe \"get\" do\n    it \"gets the string key whether you pass a symbol or string\" do\n      hash = {\"foo\" => \"bar\"}\n\n      hash.get(:foo).should eq \"bar\"\n      hash.get(\"foo\").should eq \"bar\"\n    end\n\n    it \"returns nil if the key is missing\" do\n      hash = {\"foo\" => \"bar\"}\n\n      hash.get(:missing).should be_nil\n      hash.get(\"missing\").should be_nil\n    end\n  end\n\n  describe \"get!\" do\n    it \"gets the string key whether you pass a symbol or string\" do\n      hash = {\"foo\" => \"bar\"}\n\n      hash.get!(:foo).should eq \"bar\"\n      hash.get!(\"foo\").should eq \"bar\"\n    end\n\n    it \"raises KeyError if the key is missing\" do\n      hash = {\"foo\" => \"bar\"}\n\n      expect_raises(KeyError) do\n        hash.get!(:missing)\n      end\n      expect_raises(KeyError) do\n        hash.get!(\"missing\")\n      end\n    end\n  end\nend\n"
  },
  {
    "path": "spec/charms/string_spec.cr",
    "content": "require \"../spec_helper\"\n\ndescribe \"String charm\" do\n  describe \"squish\" do\n    it \"squishes the text by removing newlines and extra whitespace\" do\n      og_string = \" foo   bar    \\n   \\t   boo\"\n\n      og_string.squish.should eq(\"foo bar boo\")\n      og_string.should eq(\" foo   bar    \\n   \\t   boo\")\n    end\n\n    it \"squishes the text by removing ascii/unicode whitespace\" do\n      og_string = \"\\u1680 \\v\\v\\v\\v\\v\\r\\r\\r\\r hello foo bar\\n\\u00A0\\t\\t\\u00A0\\u1680\\u1680   \"\n\n      og_string.squish.should eq(\"hello foo bar\")\n    end\n  end\nend\n"
  },
  {
    "path": "spec/charms/uuid_spec.cr",
    "content": "require \"../spec_helper\"\n\ndescribe \"UUID charm\" do\n  describe \"to_param\" do\n    it \"gets the value as a string\" do\n      uuid = UUID.new(\"87b3042b-9b9a-41b7-8b15-a93d3f17025e\")\n      uuid.to_param.should eq \"87b3042b-9b9a-41b7-8b15-a93d3f17025e\"\n      uuid.to_param.class.should eq String\n    end\n  end\nend\n"
  },
  {
    "path": "spec/fixtures/plain_text",
    "content": "lucky\n"
  },
  {
    "path": "spec/lucky/action_cookies_and_sessions_spec.cr",
    "content": "require \"../spec_helper\"\n\ninclude ContextHelper\n\nclass Cookies::Index < TestAction\n  get \"/cookies\" do\n    cookies.set :my_cookie, \"cookie\"\n    session.set :my_session, \"session\"\n\n    plain_text \"#{cookies.get(:my_cookie)} - #{session.get(:my_session)}\"\n  end\nend\n\nclass PreCookies::Index < TestAction\n  get \"/pre_cookies\" do\n    plain_text \"#{cookies.get?(:my_cookie)}\"\n  end\nend\n\nclass FlashCookies::Index < TestAction\n  get \"/flash\" do\n    flash.success = \"You did it!\"\n\n    plain_text \"#{flash.success}\"\n  end\nend\n\nclass CookiesDisabled::Index < TestAction\n  disable_cookies\n\n  get \"/no_cookies\" do\n    cookies.set :my_cookie, \"cookie\"\n\n    plain_text \"\"\n  end\nend\n\ndescribe Lucky::Action do\n  context \"with cookies enabled\" do\n    describe \"reading set cookies and sessions\" do\n      it \"can set and read cookies\" do\n        response = Cookies::Index.new(build_context, params).call\n\n        response.enable_cookies.should be_true\n        response.context.cookies.get(\"my_cookie\").should eq(\"cookie\")\n        response.context.session.get(\"my_session\").should eq(\"session\")\n        response.body.should eq \"cookie - session\"\n      end\n    end\n\n    describe \"reading a cookie value that isn't there\" do\n      it \"will initialize the cookies object and not crash\" do\n        response = PreCookies::Index.new(build_context, params).call\n\n        response.body.should eq \"\"\n      end\n    end\n\n    describe \"setting and reading the flash\" do\n      it \"will initialize the cookies object and not crash\" do\n        response = FlashCookies::Index.new(build_context, params).call\n\n        response.body.should eq \"You did it!\"\n      end\n    end\n  end\n\n  context \"with cookies disabled\" do\n    it \"does not set a cookies header\" do\n      response = CookiesDisabled::Index.new(build_context, params).call\n      response.print\n\n      response.context.response.headers.has_key?(\"Set-Cookie\").should be_false\n    end\n  end\nend\n"
  },
  {
    "path": "spec/lucky/action_pipes_spec.cr",
    "content": "require \"../spec_helper\"\n\ninclude ContextHelper\n\nclass PipeFromActionMacro::Index < TestAction\n  before set_before_cookie\n\n  get \"/pipe_from_action_macro\" do\n    plain_text \"Body\"\n  end\n\n  def set_before_cookie\n    cookies.set(\"before\", \"before\")\n    continue\n  end\nend\n\nabstract class InheritablePipes < TestAction\n  before set_before_cookie\n  after overwrite_after_cookie\n\n  def set_before_cookie\n    cookies.set(\"before\", \"before\")\n    continue\n  end\n\n  def overwrite_after_cookie\n    cookies.set(\"after\", \"after\")\n    continue\n  end\nend\n\nclass Pipes::Skipped < InheritablePipes\n  skip set_before_cookie, overwrite_after_cookie\n\n  get \"/skipped_pipes\" do\n    plain_text \"Body\"\n  end\nend\n\nclass Pipes::Index < InheritablePipes\n  before set_second_before_cookie\n  after set_second_after_cookie\n\n  get \"/pipes\" do\n    cookies.set(\"after\", \"This should be overwritten by the after pipe\")\n    plain_text \"not_from_pipe\"\n  end\n\n  def set_second_before_cookie\n    cookies.set(\"second_before\", \"second_before\")\n    continue\n  end\n\n  def set_second_after_cookie\n    cookies.set(\"second_after\", \"second_after\")\n    continue\n  end\nend\n\nclass Pipes::HaltedBefore < TestAction\n  before redirect_me\n  before should_not_be_reached\n\n  get \"/before_pipes\" do\n    plain_text \"this should not be reached\"\n  end\n\n  def redirect_me\n    redirect to: \"/redirected_in_before\"\n  end\n\n  def should_not_be_reached\n    cookies.set(\"before\", \"nope\")\n    continue\n  end\nend\n\nclass Pipes::HaltedAfter < TestAction\n  after redirect_me\n  after should_not_be_reached\n\n  get \"/after_pipes\" do\n    plain_text \"this should not be reached\"\n  end\n\n  def redirect_me\n    redirect to: \"/redirected_in_after\"\n  end\n\n  def should_not_be_reached\n    cookies.set(\"after\", \"nope\")\n    continue\n  end\nend\n\nclass Pipes::OrderDependent < TestAction\n  getter pipe_data\n  @pipe_data = [] of String\n\n  before dog\n  before cat\n  after red\n  after yellow\n\n  get \"/order_dependent\" do\n    plain_text \"rendered\"\n  end\n\n  def dog\n    @pipe_data << \"dog\"\n    continue\n  end\n\n  def cat\n    @pipe_data << \"cat\"\n    continue\n  end\n\n  def red\n    @pipe_data << \"red\"\n    continue\n  end\n\n  def yellow\n    @pipe_data << \"yellow\"\n    continue\n  end\nend\n\ndescribe Lucky::Action do\n  it \"works with actions that use the `action` macro\" do\n    response = PipeFromActionMacro::Index.new(build_context, params).call\n    response.context.cookies.get(\"before\").should eq \"before\"\n  end\n\n  it \"can skip pipes\" do\n    response = Pipes::Skipped.new(build_context, params).call\n    response.context.cookies.get?(\"before\").should be_nil\n    response.context.cookies.get?(\"after\").should be_nil\n  end\n\n  describe \"handles before pipes\" do\n    it \"runs through all the pipes if no Lucky::Response is returned\" do\n      Lucky::ContinuedPipeLog.dexter.temp_config do |log_io|\n        response = Pipes::Index.new(build_context, params).call\n\n        log = log_io.to_s\n        response.body.should eq \"not_from_pipe\"\n        log.should contain(\"before\")\n        log.should contain(\"second_before\")\n        log.should contain(\"after\")\n        log.should contain(\"overwrite_after_cookie\")\n        response.context.cookies.get(\"before\").should eq \"before\"\n        response.context.cookies.get(\"second_before\").should eq \"second_before\"\n        response.context.cookies.get(\"after\").should eq \"after\"\n        response.context.cookies.get(\"second_after\").should eq \"second_after\"\n      end\n    end\n\n    it \"halts before pipes if a Lucky::Response is returned\" do\n      Lucky::Log.dexter.temp_config do |log_io|\n        response = Pipes::HaltedBefore.new(build_context, params).call\n\n        log = log_io.to_s\n        response.body.should eq \"\"\n        response.context.response.status_code.should eq 302\n        response.context.response.headers[\"Location\"].should eq \"/redirected_in_before\"\n        log.should contain(\"halted_by\")\n        log.should contain(\"redirect_me\")\n        response.context.cookies.get?(\"before\").should be_nil\n      end\n    end\n\n    it \"halts after pipes if a Lucky::Response is returned\" do\n      Lucky::Log.dexter.temp_config do |log_io|\n        response = Pipes::HaltedAfter.new(build_context, params).call\n\n        log = log_io.to_s\n        response.body.should eq \"\"\n        response.context.response.status_code.should eq 302\n        response.context.response.headers[\"Location\"].should eq \"/redirected_in_after\"\n        response.context.cookies.get?(\"after\").should be_nil\n        log.should contain(\"halted_by\")\n        log.should contain(\"redirect_me\")\n      end\n    end\n\n    it \"renders the pipes in the order they were defined\" do\n      action = Pipes::OrderDependent.new(build_context, params)\n      response = action.call\n\n      response.body.should eq \"rendered\"\n      action.pipe_data[0].should eq(\"dog\")\n      action.pipe_data[1].should eq(\"cat\")\n      action.pipe_data[2].should eq(\"red\")\n      action.pipe_data[3].should eq(\"yellow\")\n    end\n  end\n\n  describe \"events\" do\n    it \"publishes an event when continued\" do\n      events = [] of Lucky::Events::PipeEvent\n      Lucky::Events::PipeEvent.subscribe do |event|\n        events << event\n      end\n      Pipes::Index.new(build_context, params).call\n      pipe_names = events.map(&.name)\n      pipe_names.should contain(\"set_before_cookie\")\n      pipe_names.should contain(\"overwrite_after_cookie\")\n      pipe_names.should contain(\"set_second_before_cookie\")\n      pipe_names.should contain(\"set_second_after_cookie\")\n    end\n\n    it \"publishes an event on before when halted\" do\n      events = [] of Lucky::Events::PipeEvent\n      Lucky::Events::PipeEvent.subscribe do |event|\n        events << event\n      end\n      Pipes::HaltedBefore.new(build_context, params).call\n      halted_pipe = events.find! { |e| e.name == \"redirect_me\" }\n      halted_pipe.continued.should eq false\n      halted_pipe.position.to_s.should eq \"Before\"\n      halted_pipe.before?.should eq true\n    end\n\n    it \"publishes an event on after when halted\" do\n      events = [] of Lucky::Events::PipeEvent\n      Lucky::Events::PipeEvent.subscribe do |event|\n        events << event\n      end\n      Pipes::HaltedAfter.new(build_context, params).call\n      halted_pipe = events.find! { |e| e.name == \"redirect_me\" }\n      halted_pipe.continued.should eq false\n      halted_pipe.position.to_s.should eq \"After\"\n      halted_pipe.after?.should eq true\n    end\n  end\nend\n"
  },
  {
    "path": "spec/lucky/action_redirect_spec.cr",
    "content": "require \"../spec_helper\"\n\ninclude ContextHelper\n\nclass RedirectAction < TestAction\n  include Lucky::RedirectableTurbolinksSupport\n\n  get \"/redirect_test\" do\n    plain_text \"does not matter\"\n  end\nend\n\nclass ActionWithPrefix < TestAction\n  route_prefix \"/prefix\"\n\n  get \"/redirect_test2\" do\n    plain_text \"does not matter\"\n  end\nend\n\ndescribe Lucky::Action do\n  it \"redirects\" do\n    action = RedirectAction.new(build_context, params)\n    action.redirect to: \"/somewhere\"\n    should_redirect(action, to: \"/somewhere\", status: 302)\n\n    action = RedirectAction.new(build_context, params)\n    action.redirect to: RedirectAction.route\n    should_redirect(action, to: RedirectAction.path, status: 302)\n\n    action = RedirectAction.new(build_context, params)\n    action.redirect to: RedirectAction\n    should_redirect(action, to: RedirectAction.path, status: 302)\n\n    action = RedirectAction.new(build_context, params)\n    action.redirect to: ActionWithPrefix\n    should_redirect(action, to: \"/prefix/redirect_test2\", status: 302)\n  end\n\n  it \"redirects with custom status\" do\n    action = RedirectAction.new(build_context, params)\n    action.redirect to: \"/somewhere\", status: 301\n    should_redirect(action, to: \"/somewhere\", status: 301)\n\n    action = RedirectAction.new(build_context, params)\n    action.redirect to: \"/somewhere\", status: HTTP::Status::MOVED_PERMANENTLY\n    should_redirect(action, to: \"/somewhere\", status: 301)\n\n    action = RedirectAction.new(build_context, params)\n    action.redirect to: \"/somewhere\", status: :moved_permanently\n    should_redirect(action, to: \"/somewhere\", status: 301)\n  end\n\n  it \"redirects with a globally configured custom status\" do\n    Lucky::Redirectable.temp_config(redirect_status: 303) do\n      action = RedirectAction.new(build_context, params)\n      action.redirect to: \"/somewhere\"\n      should_redirect(action, to: \"/somewhere\", status: 303)\n\n      action = RedirectAction.new(build_context, params)\n      action.redirect to: RedirectAction.route\n      should_redirect(action, to: RedirectAction.path, status: 303)\n\n      action = RedirectAction.new(build_context, params)\n      action.redirect to: RedirectAction\n      should_redirect(action, to: RedirectAction.path, status: 303)\n\n      action = RedirectAction.new(build_context, params)\n      action.redirect to: ActionWithPrefix\n      should_redirect(action, to: \"/prefix/redirect_test2\", status: 303)\n    end\n  end\n\n  describe \"#redirect_back\" do\n    it \"redirects to referer if present\" do\n      request = build_request(\"POST\")\n      request.headers[\"Referer\"] = \"https://example.com/coming/from\"\n      action = RedirectAction.new(build_context(request), params)\n      action.redirect_back fallback: \"/fallback\"\n      should_redirect(action, to: \"https://example.com/coming/from\", status: 302)\n    end\n\n    it \"redirects to fallback if referer missing\" do\n      request = build_request(\"POST\")\n      action = RedirectAction.new(build_context(request), params)\n      action.redirect_back fallback: \"/fallback\"\n      should_redirect(action, to: \"/fallback\", status: 302)\n\n      action = RedirectAction.new(build_context, params)\n      action.redirect_back fallback: RedirectAction.route\n      should_redirect(action, to: RedirectAction.path, status: 302)\n\n      action = RedirectAction.new(build_context, params)\n      action.redirect_back fallback: RedirectAction\n      should_redirect(action, to: RedirectAction.path, status: 302)\n\n      action = RedirectAction.new(build_context, params)\n      action.redirect_back fallback: RedirectAction, status: HTTP::Status::MOVED_PERMANENTLY\n      should_redirect(action, to: RedirectAction.path, status: 301)\n\n      action = RedirectAction.new(build_context, params)\n      action.redirect_back fallback: RedirectAction, status: 301\n      should_redirect(action, to: RedirectAction.path, status: 301)\n    end\n\n    it \"redirects back with the globally configured status code\" do\n      Lucky::Redirectable.temp_config(redirect_status: 303) do\n        request = build_request(\"POST\")\n        action = RedirectAction.new(build_context(request), params)\n        action.redirect_back fallback: \"/fallback\"\n        should_redirect(action, to: \"/fallback\", status: 303)\n\n        action = RedirectAction.new(build_context, params)\n        action.redirect_back fallback: RedirectAction.route\n        should_redirect(action, to: RedirectAction.path, status: 303)\n\n        action = RedirectAction.new(build_context, params)\n        action.redirect_back fallback: RedirectAction\n        should_redirect(action, to: RedirectAction.path, status: 303)\n      end\n    end\n\n    it \"redirects to fallback if referer is external\" do\n      request = build_request(\"POST\")\n      request.headers[\"Referer\"] = \"https://external.com/coming/from\"\n      action = RedirectAction.new(build_context(request), params)\n      action.redirect_back fallback: \"/fallback\"\n      should_redirect(action, to: \"/fallback\", status: 302)\n    end\n\n    it \"redirects to referer if referer is external and allowed\" do\n      request = build_request(\"POST\")\n      request.headers[\"Referer\"] = \"https://external.com/coming/from\"\n      action = RedirectAction.new(build_context(request), params)\n      action.redirect_back fallback: \"/fallback\", allow_external: true\n      should_redirect(action, to: \"https://external.com/coming/from\", status: 302)\n    end\n\n    it \"redirects to fallback if referer path matches current request path\" do\n      request = HTTP::Request.new(\"POST\", \"/redirect_test\")\n      request.headers[\"Host\"] = \"example.com\"\n      request.headers[\"Referer\"] = \"https://example.com/redirect_test\"\n      action = RedirectAction.new(build_context(request), params)\n      action.redirect_back fallback: \"/fallback\"\n      should_redirect(action, to: \"/fallback\", status: 302)\n    end\n\n    it \"redirects to fallback if referer path matches current request path with query params\" do\n      request = HTTP::Request.new(\"POST\", \"/redirect_test?foo=bar\")\n      request.headers[\"Host\"] = \"example.com\"\n      request.headers[\"Referer\"] = \"https://example.com/redirect_test\"\n      action = RedirectAction.new(build_context(request), params)\n      action.redirect_back fallback: \"/fallback\"\n      should_redirect(action, to: \"/fallback\", status: 302)\n    end\n  end\n\n  it \"turbolinks redirects after a XHR POST form submission\" do\n    request = build_request(\"POST\")\n    request.headers[\"Accept\"] = \"text/javascript, application/javascript, application/ecmascript, application/x-ecmascript, */*; q=0.01\"\n    request.headers[\"X-Requested-With\"] = \"XmlHttpRequest\"\n    context = build_context(\"/\", request: request)\n\n    action = RedirectAction.new(context, params)\n    response = action.redirect to: \"/somewhere\", status: 302\n    should_redirect(action, to: \"/somewhere\", status: 200)\n    action.context.response.headers.has_key?(\"Turbolinks-Location\").should be_false\n    response.body.should eq %[Turbolinks.clearCache();\\nTurbolinks.visit(\"/somewhere\", {\"action\": \"replace\"})]\n  end\n\n  it \"set a cookie for redirects occurring during a turbolinks GET request\" do\n    request = build_request\n    request.headers[\"Turbolinks-Referrer\"] = \"/\"\n    context = build_context(\"/\", request: request)\n\n    action = RedirectAction.new(context, params)\n    response = action.redirect to: \"/somewhere\", status: 302\n    should_redirect(action, to: \"/somewhere\", status: 302)\n    context.response.headers.has_key?(\"Turbolinks-Location\").should be_false\n    response.body.should eq \"\"\n    # should remember redirect to\n    context.cookies.get?(:_turbolinks_location).should eq \"/somewhere\"\n  end\n\n  it \"restore turbolinks redirect target\" do\n    context = build_context\n    context.cookies.set(:_turbolinks_location, \"/somewhere\")\n\n    RedirectAction.new(context, params).call\n    context.response.status_code.should eq 200\n    context.response.headers[\"Turbolinks-Location\"].should eq \"/somewhere\"\n    context.cookies.deleted?(:_turbolinks_location).should be_true\n  end\n\n  it \"keeps flash messages for the next action\" do\n    context = build_context_with_flash({success: \"Keep me!\"}.to_json)\n\n    action = RedirectAction.new(context, params)\n    response = action.redirect to: \"/somewhere\", status: 302\n    response.print\n    flash = Lucky::FlashStore.from_session(response.context.session)\n    flash.success.should eq(\"Keep me!\")\n  end\nend\n\nprivate def should_redirect(action, to path, status)\n  action.context.response.headers[\"Location\"].should eq path\n  action.context.response.status_code.should eq status\nend\n"
  },
  {
    "path": "spec/lucky/action_rendering_spec.cr",
    "content": "require \"../spec_helper\"\n\ninclude ContextHelper\n\nclass Rendering::IndexPage\n  include Lucky::HTMLPage\n\n  needs title : String\n  needs arg2 : String\n\n  def render\n    text @title\n  end\nend\n\nclass Rendering::Index < TestAction\n  get \"/rendering\" do\n    html title: \"Anything\", arg2: \"testing multiple args\"\n  end\nend\n\nclass Rendering::Show::WithStatus < TestAction\n  get \"/rendering/:nothing\" do\n    gather_status = 419\n    html_with_status IndexPage, gather_status, title: \"Closing Time\", arg2: \"You don't have to go home but you can't stay here\"\n  end\nend\n\nclass Rendering::Show::WithSymbolStatus < TestAction\n  get \"/rendering1/:nothing\" do\n    html_with_status IndexPage, :unauthorized, title: \"Closing Time\", arg2: \"You don't have to go home but you can't stay here\"\n  end\nend\n\nclass Rendering::Show::WithEnumStatus < TestAction\n  get \"/rendering2/:nothing\" do\n    html_with_status IndexPage, HTTP::Status::UNPROCESSABLE_ENTITY, title: \"Closing Time\", arg2: \"You don't have to go home but you can't stay here\"\n  end\nend\n\nclass Namespaced::Rendering::Index < TestAction\n  get \"/namespaced/rendering\" do\n    html ::Rendering::IndexPage, title: \"Anything\", arg2: \"testing multiple args\"\n  end\nend\n\nclass Rendering::JSON::Index < TestAction\n  get \"/rendering/json\" do\n    json({name: \"Paul\"})\n  end\nend\n\nclass Rendering::JSON::WithRawStringBody < TestAction\n  get \"/foo\" do\n    raw_json(\"{\\\"name\\\":\\\"Paul\\\"}\")\n  end\nend\n\nclass Rendering::JSON::WithRawStringBodyWithStatus < TestAction\n  get \"/bar\" do\n    raw_json(\"{\\\"name\\\":\\\"Paul\\\"}\", status: 201)\n  end\nend\n\nclass Rendering::JSON::WithStatus < TestAction\n  get \"/foo1\" do\n    json({name: \"Paul\"}, status: 201)\n  end\nend\n\nclass Rendering::JSON::WithSymbolStatus < TestAction\n  get \"/foo2\" do\n    json({name: \"Paul\"}, status: :created)\n  end\nend\n\nclass Rendering::HeadOnly < TestAction\n  get \"/foo3\" do\n    head status: 204\n  end\nend\n\nclass Rendering::HeadOnly::WithSymbolStatus < TestAction\n  get \"/foo4\" do\n    head status: :no_content\n  end\nend\n\nclass Rendering::Text::Index < TestAction\n  get \"/rendering/text\" do\n    plain_text \"Anything\"\n  end\nend\n\nclass Rendering::Text::WithStatus < TestAction\n  get \"/foo5\" do\n    plain_text \"Anything\", status: 201\n  end\nend\n\nclass Rendering::Text::WithSymbolStatus < TestAction\n  get \"/foo6\" do\n    plain_text \"Anything\", status: :created\n  end\nend\n\nclass Rendering::Xml::Index < TestAction\n  get \"/foo7\" do\n    xml \"<anything />\"\n  end\nend\n\nclass Rendering::Xml::WithStatus < TestAction\n  get \"/foo8\" do\n    xml \"<anything />\", status: 418\n  end\nend\n\nclass Rendering::Xml::WithSymbolStatus < TestAction\n  get \"/foo9\" do\n    xml \"<anything />\", status: :im_a_teapot\n  end\nend\n\nclass Rendering::File < TestAction\n  get \"/file\" do\n    file \"spec/fixtures/lucky_logo.png\"\n  end\nend\n\nclass Rendering::File::Inline < TestAction\n  get \"/foo10\" do\n    file \"spec/fixtures/lucky_logo.png\", disposition: \"inline\"\n  end\nend\n\nclass Rendering::File::CustomFilename < TestAction\n  get \"/foo11\" do\n    file \"spec/fixtures/lucky_logo.png\",\n      disposition: \"attachment\",\n      filename: \"custom.png\"\n  end\nend\n\nclass Rendering::File::CustomContentType < TestAction\n  get \"/foo12\" do\n    file \"spec/fixtures/plain_text\",\n      disposition: \"attachment\",\n      filename: \"custom.html\",\n      content_type: \"text/html\"\n  end\nend\n\nclass Rendering::File::Missing < TestAction\n  get \"/foo13\" do\n    file \"new_file_who_dis\"\n  end\nend\n\nprivate class PlainTestComponent < Lucky::BaseComponent\n  def render\n    h1 \"Plain Component\"\n  end\nend\n\nprivate class ComplexTestComponent < Lucky::BaseComponent\n  needs title : String\n\n  def render\n    text @title\n    img src: asset(\"images/logo.png\")\n    mount(PlainTestComponent)\n  end\nend\n\nclass Rendering::PlainComponent < TestAction\n  get \"/foo14\" do\n    component PlainTestComponent\n  end\nend\n\nclass Rendering::ComplexComponent < TestAction\n  get \"/foo15\" do\n    component ComplexTestComponent, title: \"Getting Complex\"\n  end\nend\n\nclass Rendering::PlainComponentWithCustomStatus < TestAction\n  get \"/foo16\" do\n    component PlainTestComponent, status: :partial_content\n  end\nend\n\ndescribe Lucky::Action do\n  describe \"rendering HTML pages\" do\n    it \"render assigns\" do\n      response = Rendering::Index.new(build_context, params).call\n\n      response.body.to_s.should contain \"Anything\"\n      response.debug_message.to_s.should contain \"Rendering::IndexPage\"\n      response.status.should eq 200\n    end\n\n    it \"renders with a different status code\" do\n      response = Rendering::Show::WithStatus.new(build_context, params).call\n\n      response.body.to_s.should contain \"Closing Time\"\n      response.debug_message.to_s.should contain \"Rendering::IndexPage\"\n      response.status.should eq 419\n\n      status = Rendering::Show::WithSymbolStatus.new(build_context, params).call.status\n      status.should eq 401\n\n      status = Rendering::Show::WithEnumStatus.new(build_context, params).call.status\n      status.should eq 422\n    end\n  end\n\n  describe \"rendering Components\" do\n    it \"renders a simple component\" do\n      response = Rendering::PlainComponent.new(build_context, params).call\n\n      response.body.to_s.should eq \"<h1>Plain Component</h1>\"\n    end\n\n    it \"renders a complex component\" do\n      response = Rendering::ComplexComponent.new(build_context, params).call\n\n      body = response.body.to_s\n      body.should contain \"<h1>Plain Component</h1>\"\n      body.should contain \"Getting Complex\"\n      body.should contain \"images/logo-with-hash.png\"\n    end\n\n    it \"renders a component with a HTTP::Status\" do\n      response = Rendering::PlainComponentWithCustomStatus.new(build_context, params).call\n      response.status.should eq 206\n    end\n  end\n\n  # See issue https://github.com/luckyframework/lucky/issues/678\n  it \"renders page classes when prefixed with ::\" do\n    response = Namespaced::Rendering::Index.new(build_context, params).call\n    response.body.to_s.should contain \"Anything\"\n  end\n\n  it \"renders JSON\" do\n    response = Rendering::JSON::Index.new(build_context, params).call\n    response.body.to_s.should eq %({\"name\":\"Paul\"})\n    response.status.should eq 200\n\n    response = Rendering::JSON::WithRawStringBody.new(build_context, params).call\n    response.body.to_s.should eq %({\"name\":\"Paul\"})\n    response.status.should eq 200\n\n    response = Rendering::JSON::WithRawStringBodyWithStatus.new(build_context, params).call\n    response.body.to_s.should eq %({\"name\":\"Paul\"})\n    response.status.should eq 201\n\n    status = Rendering::JSON::WithStatus.new(build_context, params).call.status\n    status.should eq 201\n\n    status = Rendering::JSON::WithSymbolStatus.new(build_context, params).call.status\n    status.should eq 201\n  end\n\n  it \"renders XML\" do\n    response = Rendering::Xml::Index.new(build_context, params).call\n    response.body.to_s.should eq %(<anything />)\n    response.status.should eq 200\n\n    status = Rendering::Xml::WithStatus.new(build_context, params).call.status\n    status.should eq 418\n\n    status = Rendering::Xml::WithSymbolStatus.new(build_context, params).call.status\n    status.should eq 418\n  end\n\n  it \"renders head response with no body\" do\n    response = Rendering::HeadOnly.new(build_context, params).call\n    response.body.to_s.should eq \"\"\n    response.status.should eq 204\n\n    response = Rendering::HeadOnly::WithSymbolStatus.new(build_context, params).call\n    response.status.should eq 204\n  end\n\n  it \"renders text\" do\n    response = Rendering::Text::Index.new(build_context, params).call\n    response.body.to_s.should eq \"Anything\"\n    response.status.should eq 200\n\n    response = Rendering::Text::WithStatus.new(build_context, params).call\n    response.body.to_s.should eq \"Anything\"\n    response.status.should eq 201\n\n    response = Rendering::Text::WithSymbolStatus.new(build_context, params).call\n    response.body.to_s.should eq \"Anything\"\n    response.status.should eq 201\n  end\n\n  it \"renders files\" do\n    response = Rendering::File.new(build_context, params).call\n    response.status.should eq 200\n    response.disposition.should eq \"attachment\"\n    response.content_type.should eq \"image/png\"\n\n    response = Rendering::File::Inline.new(build_context, params).call\n    response.status.should eq 200\n    response.disposition.should eq \"inline\"\n    response.content_type.should eq \"image/png\"\n\n    response = Rendering::File::CustomFilename.new(build_context, params).call\n    response.status.should eq 200\n    response.disposition.should eq %(attachment; filename=\"custom.png\")\n\n    response = Rendering::File::CustomContentType.new(build_context, params).call\n    response.status.should eq 200\n    response.content_type.should eq \"text/html\"\n  end\nend\n"
  },
  {
    "path": "spec/lucky/action_route_params_spec.cr",
    "content": "require \"../spec_helper\"\n\ninclude ContextHelper\n\nprivate class TestParamAction < TestAction\n  get \"/test/:param_1/:param_2\" do\n    plain_text \"test\"\n  end\nend\n\nprivate class TestOptionalParamAction < TestAction\n  get \"/test_complex_posts/:required/?:optional_1/?:optional_2\" do\n    plain_text \"test\"\n  end\nend\n\nprivate class TestGlobAction < TestAction\n  get \"/test_complex_posts_glob/*\" do\n    plain_text \"test\"\n  end\nend\n\nprivate class TestNamedGlobAction < TestAction\n  get \"/test_complex_posts_named_glob/*:leftover\" do\n    plain_text \"test\"\n  end\nend\n\ndescribe \"Automatically generated param helpers\" do\n  it \"generates helpers for all route params\" do\n    action = TestParamAction.new(build_context, {\"param_1\" => \"param_1_value\", \"param_2\" => \"param_2_value\"})\n    action.param_1.should eq \"param_1_value\"\n    action.param_2.should eq \"param_2_value\"\n    typeof(action.param_1).should eq String\n    typeof(action.param_2).should eq String\n  end\n\n  it \"generates helpers for optional route params\" do\n    action = TestOptionalParamAction.new(build_context, {\"required\" => \"1\", \"optional_1\" => \"2\"})\n    action.required.should eq \"1\"\n    action.optional_1.should eq \"2\"\n    action.optional_2.should eq nil\n    typeof(action.optional_1).should eq String?\n    typeof(action.optional_2).should eq String?\n  end\n\n  it \"generates helper for unnamed glob\" do\n    action = TestGlobAction.new(build_context, {\"glob\" => \"globbed/path\"})\n    action.glob.should eq \"globbed/path\"\n\n    action = TestGlobAction.new(build_context, {} of String => String)\n    action.glob.should be_nil\n\n    typeof(action.glob).should eq String?\n  end\n\n  it \"generates helper for named glob\" do\n    action = TestNamedGlobAction.new(build_context, {\"leftover\" => \"globbed/path\"})\n    action.leftover.should eq \"globbed/path\"\n\n    action = TestNamedGlobAction.new(build_context, {} of String => String)\n    action.leftover.should be_nil\n\n    typeof(action.leftover).should eq String?\n  end\nend\n\ndescribe \"Glob route URL building\" do\n  it \"builds URL with unnamed glob param using .with\" do\n    TestGlobAction.with(glob: \"some/path\").path.should eq \"/test_complex_posts_glob/some/path\"\n  end\n\n  it \"builds URL with named glob param using .with\" do\n    TestNamedGlobAction.with(leftover: \"some/path\").path.should eq \"/test_complex_posts_named_glob/some/path\"\n  end\n\n  it \"builds URL without glob param\" do\n    TestGlobAction.with.path.should eq \"/test_complex_posts_glob\"\n    TestNamedGlobAction.with.path.should eq \"/test_complex_posts_named_glob\"\n  end\n\n  it \"builds URL with nil glob param\" do\n    TestGlobAction.with(glob: nil).path.should eq \"/test_complex_posts_glob\"\n    TestNamedGlobAction.with(leftover: nil).path.should eq \"/test_complex_posts_named_glob\"\n  end\n\n  it \"URL-encodes special characters in glob segments\" do\n    TestGlobAction.with(glob: \"path with spaces/and more\").path.should eq \"/test_complex_posts_glob/path+with+spaces/and+more\"\n  end\n\n  it \"normalizes consecutive slashes in glob value\" do\n    TestGlobAction.with(glob: \"a//b\").path.should eq \"/test_complex_posts_glob/a/b\"\n  end\n\n  it \"strips leading slashes from glob value\" do\n    TestGlobAction.with(glob: \"/leading/slash\").path.should eq \"/test_complex_posts_glob/leading/slash\"\n  end\n\n  it \"builds URL with glob using .route\" do\n    TestNamedGlobAction.route(leftover: \"some/path\").path.should eq \"/test_complex_posts_named_glob/some/path\"\n  end\n\n  it \"builds path_without_query_params with glob\" do\n    TestNamedGlobAction.path_without_query_params(leftover: \"some/path\").should eq \"/test_complex_posts_named_glob/some/path\"\n  end\n\n  it \"builds url_without_query_params with glob\" do\n    Lucky::RouteHelper.temp_config(base_uri: \"example.com\") do\n      TestNamedGlobAction.url_without_query_params(leftover: \"some/path\").should eq \"example.com/test_complex_posts_named_glob/some/path\"\n    end\n  end\n\n  it \"returns correct RouteHelper with glob\" do\n    route_helper = TestGlobAction.with(glob: \"a/b/c\")\n    route_helper.path.should eq \"/test_complex_posts_glob/a/b/c\"\n  end\nend\n"
  },
  {
    "path": "spec/lucky/action_spec.cr",
    "content": "require \"../spec_helper\"\n\ninclude ContextHelper\n\nclass CustomRoutes::Index < TestAction\n  get \"/so_custom\" do\n    plain_text \"test\"\n  end\nend\n\nclass CustomRoutes::Put < TestAction\n  put \"/so_custom\" do\n    plain_text \"test\"\n  end\nend\n\nclass CustomRoutes::Post < TestAction\n  post \"/so_custom\" do\n    plain_text \"test\"\n  end\nend\n\nclass CustomRoutes::Patch < TestAction\n  patch \"/so_custom\" do\n    plain_text \"test\"\n  end\nend\n\nclass CustomRoutes::Trace < TestAction\n  trace \"/so_custom\" do\n    plain_text \"test\"\n  end\nend\n\nclass CustomRoutes::Delete < TestAction\n  delete \"/so_custom\" do\n    plain_text \"test\"\n  end\nend\n\nclass CustomRoutes::Match < TestAction\n  match :options, \"/so_custom\" do\n    plain_text \"test\"\n  end\nend\n\nclass Tests::IndexPage\n  include Lucky::HTMLPage\n\n  def render\n    text \"Rendered from Tests::IndexPage\"\n  end\nend\n\nclass Tests::Index < TestAction\n  get \"/tests\" do\n    html\n  end\nend\n\nclass Tests::New < TestAction\n  get \"/tests/new\" do\n    plain_text \"test\"\n  end\nend\n\nclass Tests::Edit < TestAction\n  get \"/tests/:test_id/edit\" do\n    plain_text \"test\"\n  end\nend\n\nclass Tests::Show < TestAction\n  get \"/tests/:test_id\" do\n    plain_text \"test\"\n  end\nend\n\nclass Tests::Delete < TestAction\n  delete \"/tests/:test_id\" do\n    plain_text \"test\"\n  end\nend\n\nclass Tests::Update < TestAction\n  put \"/tests/:test_id\" do\n    plain_text \"test\"\n  end\nend\n\nclass Tests::Create < TestAction\n  post \"/tests\" do\n    plain_text \"test\"\n  end\nend\n\nclass AliasedRoute::Index < TestAction\n  get \"/aliased\", \"/:locale/aliased\" do\n    plain_text \"aliased test\"\n  end\nend\n\nclass AliasedRoute::New < TestAction\n  get \"/aliased/new\", \"/:locale/aliased/new\" do\n    plain_text \"aliased test\"\n  end\nend\n\nclass AliasedRoute::Edit < TestAction\n  get \"/aliased/:test_id/edit\", \"/:locale/aliased/:test_id/edit\" do\n    plain_text \"aliased test\"\n  end\nend\n\nclass AliasedRoute::Show < TestAction\n  get \"/aliased/:test_id\", \"/:locale/aliased/:test_id\" do\n    plain_text \"aliased test\"\n  end\nend\n\nclass AliasedRoute::Delete < TestAction\n  delete \"/aliased/:test_id\", \"/:locale/aliased/:test_id\" do\n    plain_text \"aliased test\"\n  end\nend\n\nclass AliasedRoute::Update < TestAction\n  put \"/aliased/:test_id\", \"/:locale/aliased/:test_id\" do\n    plain_text \"aliased test\"\n  end\nend\n\nclass AliasedRoute::Create < TestAction\n  post \"/aliased\", \"/:locale/aliased\" do\n    plain_text \"aliased test\"\n  end\nend\n\nclass PlainText::Index < TestAction\n  get \"/plain_text\" do\n    plain_text \"plain\"\n  end\nend\n\nclass RequiredParams::Index < TestAction\n  param required_page : Int32\n  # This is to test that the default value of 'false' is not treated as 'nil'\n  param bool_with_false_default : Bool = false\n\n  get \"/required_params\" do\n    plain_text \"required param: #{required_page} #{bool_with_false_default}\"\n  end\nend\n\nabstract class BaseActionWithParams < TestAction\n  param inherit_me : String\nend\n\nclass InheritedParams::Index < BaseActionWithParams\n  get \"/inherited_params\" do\n    plain_text \"inherited param: #{inherit_me}\"\n  end\nend\n\nclass OptionalParams::Index < TestAction\n  param page : Int32?\n  param with_default : String? = \"default\"\n  param with_int_default : Int32? = 1\n  param with_int_never_nil : Int32 = 1337\n  # This is to test that the default value of 'false' is not treated as 'nil'\n  param bool_with_false_default : Bool? = false\n  # This is to test that an explicit 'nil' can be assigned for nilable types\n  param nilable_with_explicit_nil : Int32? = nil\n  param nilable_array_with_default : Array(String)? = [] of String\n  param with_array_default : Array(Int32) = [26, 37, 44]\n  # This is to test passing with no value at all still treats it as 'nil'\n  param optional_bool_with_no_default : Bool?\n\n  get \"/optional_params\" do\n    plain_text \"optional param: #{page} #{with_int_default} #{with_int_never_nil}\"\n  end\nend\n\nclass ParamsWithDefaultParamsLast < TestAction\n  param has_default : String = \"default\"\n  param has_nil_default : String?\n  param no_default : String\n\n  get \"/args_with_defaults\" do\n    plain_text \"doesn't matter\"\n  end\nend\n\nclass OptionalRouteParams::Index < TestAction\n  get \"/complex_posts/:required/?:optional_1/?:optional_2\" do\n    opt = params.get?(:optional_1)\n    plain_text \"test #{required} #{optional_1} #{optional_2} #{opt}\"\n  end\nend\n\nclass Tests::ActionWithPrefix < TestAction\n  route_prefix \"/prefix\"\n\n  get \"/so_custom2\" do\n    plain_text \"doesn't matter\"\n  end\nend\n\nclass Aliases::ActionWithPrefix < TestAction\n  route_prefix \"/prefix\"\n\n  get \"/so_custom_aliased\", \"/:scope/so_custom_aliased\" do\n    plain_text \"doesn't matter\"\n  end\nend\n\nclass Tests::HtmlActionWithCustomContentType < TestAction\n  get \"/tests/new_action_with_custom_html_content_type\" do\n    html(Tests::IndexPage)\n  end\n\n  def html_content_type\n    \"text/html; charset=utf-8\"\n  end\nend\n\nclass Tests::JsonActionWithCustomContentType < TestAction\n  param override_content_type : String?\n  get \"/tests/new_action_with_custom_json_content_type\" do\n    if ct = override_content_type.presence\n      raw_json(\"{}\", content_type: ct)\n    else\n      raw_json(\"{}\")\n    end\n  end\n\n  def json_content_type\n    \"application/json; charset=utf-8\"\n  end\nend\n\nclass Tests::XmlActionWithCustomContentType < TestAction\n  get \"/tests/new_action_with_custom_xml_content_type\" do\n    xml(\"<code></code>\")\n  end\n\n  def xml_content_type\n    \"special/xml; charset=utf-8\"\n  end\nend\n\nclass Tests::PlainActionWithCustomContentType < TestAction\n  get \"/tests/new_action_with_custom_plain_content_type\" do\n    plain_text(\"nothing special\")\n  end\n\n  def plain_content_type\n    \"very/plain; charset=utf-8\"\n  end\nend\n\nprivate class SimplleTestComponent < Lucky::BaseComponent\n  def render\n    text \"hi\"\n  end\nend\n\nclass Tests::ComponentActionWithCustomContentType < TestAction\n  get \"/tests/new_action_with_custom_component_content_type\" do\n    component SimplleTestComponent\n  end\n\n  def html_content_type\n    \"text/html; charset=utf-8\"\n  end\nend\n\nclass Tests::MultiConditionWithEarlyReturn < TestAction\n  get \"/tests/multi_condition_with_early_return\" do\n    data = {check: 1}\n    if data\n      return json(data)\n    end\n\n    raw_json(\"{}\")\n  end\nend\n\ndescribe Lucky::Action do\n  it \"has a url helper\" do\n    Lucky::RouteHelper.temp_config(base_uri: \"example.com\") do\n      Tests::Index.url.should eq \"example.com/tests\"\n      Tests::ActionWithPrefix.url.should eq \"example.com/prefix/so_custom2\"\n    end\n  end\n\n  it \"allows for early returns\" do\n    response = Tests::MultiConditionWithEarlyReturn.new(build_context, params).call\n    response.body.to_s.should eq \"{\\\"check\\\":1}\"\n  end\n\n  describe \".url_without_query_params\" do\n    it \"returns url without declared non-nil query params\" do\n      Lucky::RouteHelper.temp_config(base_uri: \"example.com\") do\n        RequiredParams::Index.url_without_query_params.should eq \"example.com/required_params\"\n      end\n    end\n\n    it \"returns url with (required) path params\" do\n      Lucky::RouteHelper.temp_config(base_uri: \"example.com\") do\n        Tests::Edit.url_without_query_params(1).should eq \"example.com/tests/1/edit\"\n      end\n    end\n\n    it \"returns url with optional path params\" do\n      Lucky::RouteHelper.temp_config(base_uri: \"example.com\") do\n        OptionalRouteParams::Index.url_without_query_params(1).should eq \"example.com/complex_posts/1\"\n        OptionalRouteParams::Index.url_without_query_params(1, 2).should eq \"example.com/complex_posts/1/2\"\n        OptionalRouteParams::Index.url_without_query_params(1, 2, 3).should eq \"example.com/complex_posts/1/2/3\"\n      end\n    end\n  end\n\n  describe \".path_without_query_params\" do\n    it \"returns path without declared non-nil query params\" do\n      Lucky::RouteHelper.temp_config(base_uri: \"example.com\") do\n        RequiredParams::Index.path_without_query_params.should eq \"/required_params\"\n      end\n    end\n\n    it \"returns path with (required) path params\" do\n      Lucky::RouteHelper.temp_config(base_uri: \"example.com\") do\n        Tests::Edit.path_without_query_params(1).should eq \"/tests/1/edit\"\n      end\n    end\n\n    it \"returns path with optional path params\" do\n      Lucky::RouteHelper.temp_config(base_uri: \"example.com\") do\n        OptionalRouteParams::Index.path_without_query_params(1).should eq \"/complex_posts/1\"\n        OptionalRouteParams::Index.path_without_query_params(1, 2).should eq \"/complex_posts/1/2\"\n        OptionalRouteParams::Index.path_without_query_params(1, 2, 3).should eq \"/complex_posts/1/2/3\"\n      end\n    end\n  end\n\n  describe \"routing\" do\n    it \"creates URL helpers for the resourceful actions\" do\n      Tests::Index.path.should eq \"/tests\"\n      Tests::Index.route.method.should eq :get\n      Tests::New.path.should eq \"/tests/new\"\n      Tests::New.route.method.should eq :get\n      Tests::Edit.path(\"test-id\").should eq \"/tests/test-id/edit\"\n      Tests::Edit.with(\"test-id\").method.should eq :get\n      Tests::Show.path(\"test-id\").should eq \"/tests/test-id\"\n      Tests::Show.with(\"test-id\").method.should eq :get\n      Tests::Delete.path(\"test-id\").should eq \"/tests/test-id\"\n      Tests::Delete.with(\"test-id\").method.should eq :delete\n      Tests::Update.path(\"test-id\").should eq \"/tests/test-id\"\n      Tests::Update.with(\"test-id\").method.should eq :put\n      Tests::Create.path.should eq \"/tests\"\n      Tests::Create.route.method.should eq :post\n      Tests::ActionWithPrefix.path.should eq \"/prefix/so_custom2\"\n    end\n\n    it \"creates URL helpers for the resourceful actions with aliases\" do\n      AliasedRoute::Index.path.should eq \"/aliased\"\n      AliasedRoute::Index.with(locale: \"es\").path.should eq \"/es/aliased\"\n      AliasedRoute::Index.route.method.should eq :get\n      AliasedRoute::Index.with(locale: \"nl\").path.should eq \"/nl/aliased\"\n      AliasedRoute::New.path.should eq \"/aliased/new\"\n      AliasedRoute::New.with(locale: \"fr\").path.should eq \"/fr/aliased/new\"\n      AliasedRoute::Edit.path(\"test-id\").should eq \"/aliased/test-id/edit\"\n      AliasedRoute::Edit.with(locale: \"nl\", test_id: \"test-id\").path.should eq \"/nl/aliased/test-id/edit\"\n      AliasedRoute::Show.path(\"test-id\").should eq \"/aliased/test-id\"\n      AliasedRoute::Show.with(locale: \"en-GB\", test_id: \"test-id\").path.should eq \"/en-GB/aliased/test-id\"\n      AliasedRoute::Delete.path(\"test-id\").should eq \"/aliased/test-id\"\n      AliasedRoute::Delete.with(locale: \"fr\", test_id: \"test-id\").path.should eq \"/fr/aliased/test-id\"\n      AliasedRoute::Update.path(\"test-id\").should eq \"/aliased/test-id\"\n      AliasedRoute::Update.with(locale: \"de\", test_id: \"test-id\").path.should eq \"/de/aliased/test-id\"\n      AliasedRoute::Create.path.should eq \"/aliased\"\n      AliasedRoute::Create.with(locale: \"en\").path.should eq \"/en/aliased\"\n      Aliases::ActionWithPrefix.path.should eq \"/prefix/so_custom_aliased\"\n      Aliases::ActionWithPrefix.with(scope: \"the-scope\").path.should eq \"/prefix/the-scope/so_custom_aliased\"\n    end\n\n    it \"escapes path params\" do\n      Tests::Edit.path(\"test/id\").should eq \"/tests/test%2Fid/edit\"\n      Tests::Edit.with(\"test/id\").path.should eq \"/tests/test%2Fid/edit\"\n    end\n\n    it \"adds routes to the router\" do\n      assert_route_added?(:get, \"/tests\", Tests::Index)\n      assert_route_added?(:get, \"/tests/new\", Tests::New)\n      assert_route_added?(:get, \"/tests/:test_id/edit\", Tests::Edit)\n      assert_route_added?(:get, \"/tests/:test_id\", Tests::Show)\n      assert_route_added?(:delete, \"/tests/:test_id\", Tests::Delete)\n      assert_route_added?(:put, \"/tests/:test_id\", Tests::Update)\n      assert_route_added?(:post, \"/tests\", Tests::Create)\n    end\n\n    it \"allows setting custom routes\" do\n      assert_route_not_added?(:get, \"/custom_routes\")\n\n      assert_route_added?(:get, \"/so_custom\", CustomRoutes::Index)\n      assert_route_added?(:put, \"/so_custom\", CustomRoutes::Put)\n      assert_route_added?(:post, \"/so_custom\", CustomRoutes::Post)\n      assert_route_added?(:patch, \"/so_custom\", CustomRoutes::Patch)\n      assert_route_added?(:trace, \"/so_custom\", CustomRoutes::Trace)\n      assert_route_added?(:delete, \"/so_custom\", CustomRoutes::Delete)\n      assert_route_added?(:options, \"/so_custom\", CustomRoutes::Match)\n    end\n\n    it \"works with optional routing paths\" do\n      route = OptionalRouteParams::Index.with(required: \"1\")\n      route.path.should eq \"/complex_posts/1\"\n      route.method.should eq :get\n\n      route2 = OptionalRouteParams::Index.with(required: \"1\", optional_1: \"2\")\n      route2.path.should eq \"/complex_posts/1/2\"\n\n      route3 = OptionalRouteParams::Index.with(required: \"1\", optional_1: \"2\", optional_2: \"3\")\n      route3.path.should eq \"/complex_posts/1/2/3\"\n    end\n  end\n\n  describe \"rendering\" do\n    it \"renders plain text\" do\n      response = PlainText::Index.new(build_context, params).call\n      response.body.to_s.should eq \"plain\"\n      response.content_type.should eq \"text/plain\"\n    end\n\n    it \"infer the correct HTML page to render\" do\n      response = Tests::Index.new(build_context, params).call\n      response.body.to_s.should contain \"Rendered from Tests::IndexPage\"\n      response.content_type.should eq \"text/html\"\n    end\n\n    it \"uses a custom content_type for this html action\" do\n      response = Tests::HtmlActionWithCustomContentType.new(build_context, params).call\n      response.content_type.should eq \"text/html; charset=utf-8\"\n    end\n\n    it \"uses a custom content_type for this component action\" do\n      response = Tests::ComponentActionWithCustomContentType.new(build_context, params).call\n      response.content_type.should eq \"text/html; charset=utf-8\"\n    end\n\n    it \"uses a custom content_type for this json action\" do\n      response = Tests::JsonActionWithCustomContentType.new(build_context, params).call\n      response.content_type.should eq \"application/json; charset=utf-8\"\n\n      response = Tests::JsonActionWithCustomContentType.new(build_context(path: \"/tests/new_action_with_custom_json_content_type?override_content_type=cats/dogs\"), params).call\n      response.content_type.should eq \"cats/dogs\"\n    end\n\n    it \"uses a custom content_type for this xml action\" do\n      response = Tests::XmlActionWithCustomContentType.new(build_context, params).call\n      response.content_type.should eq \"special/xml; charset=utf-8\"\n    end\n\n    it \"uses a custom content_type for this plain action\" do\n      response = Tests::PlainActionWithCustomContentType.new(build_context, params).call\n      response.content_type.should eq \"very/plain; charset=utf-8\"\n    end\n\n    it \"renders with optional path params\" do\n      response = OptionalRouteParams::Index.new(build_context(\"/complex_posts/1/2/3\"), {\"required\" => \"1\", \"optional_1\" => \"2\", \"optional_2\" => \"3\"}).call\n      response.body.to_s.should eq(\"test 1 2 3 2\")\n    end\n  end\n\n  describe \".query_param_declarations\" do\n    it \"returns an empty array\" do\n      PlainText::Index.query_param_declarations.size.should eq 0\n    end\n\n    it \"returns required param declarations\" do\n      RequiredParams::Index.query_param_declarations.size.should eq 2\n      RequiredParams::Index.query_param_declarations.should contain \"required_page : Int32\"\n      RequiredParams::Index.query_param_declarations.should contain \"bool_with_false_default : Bool\"\n    end\n\n    it \"returns optional param declarations\" do\n      OptionalParams::Index.query_param_declarations.size.should eq 9\n      OptionalParams::Index.query_param_declarations.should contain \"bool_with_false_default : Bool | ::Nil\"\n    end\n  end\n\n  describe \"params\" do\n    it \"can get params\" do\n      action = PlainText::Index.new(build_context(path: \"/?q=test\"), params)\n      action.params.get(:q).should eq \"test\"\n    end\n\n    it \"can get manually defined required params\" do\n      action = RequiredParams::Index.new(build_context(path: \"/?required_page=1\"), params)\n      action.required_page.should eq 1\n    end\n\n    it \"adds named arguments to the path\" do\n      RequiredParams::Index.path(required_page: 7).should eq \"/required_params?required_page=7\"\n      RequiredParams::Index.path(required_page: 7, bool_with_false_default: true).should eq \"/required_params?required_page=7&bool_with_false_default=true\"\n    end\n\n    it \"adds named arguments to the route\" do\n      RequiredParams::Index.route(required_page: 7).path.should eq \"/required_params?required_page=7\"\n    end\n\n    it \"raises for missing required params\" do\n      action = RequiredParams::Index.new(build_context(path: \"\"), params)\n      expect_raises(Lucky::MissingParamError) { action.required_page }\n    end\n\n    it \"can inherit params\" do\n      InheritedParams::Index.path(inherit_me: \"inherited\").should eq \"/inherited_params?inherit_me=inherited\"\n    end\n  end\n\n  it \"can add anchors to routes (and escapes them)\" do\n    Tests::Index.path(anchor: \"#foo\").should eq \"/tests#%23foo\"\n    Tests::Index.route(anchor: \"#foo\").path.should eq \"/tests#%23foo\"\n    Tests::Index.url(anchor: \"#foo\").ends_with?(\"/tests#%23foo\").should be_true\n  end\n\n  describe \"params with defaults\" do\n    it \"are put at the end of the arg list so the program compiles\" do\n      ParamsWithDefaultParamsLast.with(no_default: \"Yay!\")\n    end\n  end\n\n  describe \"optional params\" do\n    it \"are not required in the route helper\" do\n      path = OptionalParams::Index.path\n      path.should eq(\"/optional_params\")\n    end\n\n    it \"is initialized to nil\" do\n      action = OptionalParams::Index.new(build_context(path: \"\"), params)\n      action.page.should eq nil\n    end\n\n    it \"is fetched if present\" do\n      action = OptionalParams::Index.new(build_context(path: \"/?page=3\"), params)\n      action.page.should eq 3\n    end\n\n    it \"can be used within the action\" do\n      response = OptionalParams::Index.new(build_context(path: \"/?page=3\"), params).call\n      response.body.to_s.should eq \"optional param: 3 1 1337\"\n    end\n\n    it \"can specify a default value\" do\n      action = OptionalParams::Index.new(build_context(path: \"\"), params)\n      action.with_default.should eq \"default\"\n    end\n\n    it \"can specify nil as the default value\" do\n      action = OptionalParams::Index.new(build_context(path: \"\"), params)\n      action.nilable_with_explicit_nil.should eq nil\n    end\n\n    it \"overrides the default if present\" do\n      action = OptionalParams::Index.new(build_context(path: \"/?with_int_never_nil=42\"), params)\n      action.with_int_never_nil.should eq 42\n    end\n\n    it \"is added as optional argument to the path\" do\n      OptionalParams::Index.path(page: 7).should eq \"/optional_params?page=7\"\n      OptionalParams::Index.path(page: 7, with_default: \"/other\").should eq \"/optional_params?page=7&with_default=%2Fother\"\n    end\n\n    it \"is added to the path if the value matches default and is explicitly given\" do\n      OptionalParams::Index.path(with_default: \"default\").should eq \"/optional_params?with_default=default\"\n    end\n\n    it \"is not added to the path param has default value but not given\" do\n      OptionalParams::Index.path.should eq \"/optional_params\"\n    end\n\n    it \"is added as optional argument to the route\" do\n      OptionalParams::Index.route(page: 7).path.should eq \"/optional_params?page=7\"\n      OptionalParams::Index.route(page: 7, with_default: \"/other\").path.should eq \"/optional_params?page=7&with_default=%2Fother\"\n    end\n\n    it \"raises when the optional param cannot be parsed into the desired type\" do\n      expect_raises Lucky::InvalidParamError do\n        OptionalParams::Index.new(build_context(path: \"/?page=no_int\"), params()).call\n      end\n    end\n\n    it \"raises when we cannot parse the non-optional param into the desired type\" do\n      expect_raises Lucky::InvalidParamError, \"Required param 'with_int_never_nil' with value 'no_int' couldn't be parsed to a 'Int32'\" do\n        OptionalParams::Index.new(build_context(path: \"/?with_int_never_nil=no_int\"), params()).call\n      end\n    end\n\n    it \"allows nilable arrays with defaults\" do\n      action = OptionalParams::Index.new(build_context(path: \"/?page=3\"), params)\n      action.nilable_array_with_default.should eq([] of String)\n    end\n\n    it \"sets a value to a nilable array\" do\n      action = OptionalParams::Index.new(build_context(path: \"/?nilable_array_with_default[]=1&nilable_array_with_default[]=2\"), params)\n      action.nilable_array_with_default.should eq([\"1\", \"2\"])\n    end\n\n    it \"allows required arrays with defaults\" do\n      action = OptionalParams::Index.new(build_context(path: \"/?with_array_default=2222222\"), params)\n      action.with_array_default.should eq([26, 37, 44])\n    end\n\n    it \"returns nil when the key is passed with no value for an optional param\" do\n      action = OptionalParams::Index.new(build_context(path: \"/?optional_bool_with_no_default\"), params)\n      action.optional_bool_with_no_default.should eq(nil)\n    end\n  end\nend\n"
  },
  {
    "path": "spec/lucky/asset_helpers_spec.cr",
    "content": "require \"../spec_helper\"\n\nmodule Shared::ComponentWithAsset\n  def asset_inside_component\n    asset(\"images/logo.png\")\n  end\n\n  def dynamic_asset_inside_component(interpolated = \"logo\")\n    dynamic_asset(\"images/#{interpolated}.png\")\n  end\n\n  def vite_asset_inside_component\n    asset(\"images/lucky_logo.png\")\n  end\nend\n\nprivate class TestPage\n  include Lucky::AssetHelpers\n  include Shared::ComponentWithAsset\n\n  def asset_path\n    asset(\"images/logo.png\")\n  end\n\n  def strips_prefixed_asset_path\n    asset(\"images/inside-assets-folder.png\")\n  end\n\n  def dynamic_asset_path(interpolated = \"logo\")\n    dynamic_asset(\"images/#{interpolated}.png\")\n  end\n\n  def missing_dynamic_asset_path\n    dynamic_asset(\"woops!.png\")\n  end\n\n  def asset_path_from_vite\n    asset(\"images/lucky_logo.png\")\n  end\nend\n\ndescribe Lucky::AssetHelpers do\n  describe \"compile time asset helper\" do\n    it \"returns the fingerprinted path\" do\n      Lucky::AssetHelpers.asset(\"images/logo.png\").should eq \"/images/logo-with-hash.png\"\n    end\n\n    it \"works when included in another class\" do\n      TestPage.new.asset_path.should eq \"/images/logo-with-hash.png\"\n    end\n\n    it \"works when used from an included module\" do\n      TestPage.new.asset_inside_component.should eq \"/images/logo-with-hash.png\"\n    end\n\n    it \"strips the prefixed '/assets/ in path\" do\n      TestPage.new.strips_prefixed_asset_path.should eq \"/assets/images/inside-assets-folder.png\"\n    end\n\n    it \"prepends the asset_host configuration option\" do\n      Lucky::Server.temp_config(asset_host: \"https://production.com\") do\n        TestPage.new.asset_path.should eq \"https://production.com/images/logo-with-hash.png\"\n      end\n    end\n  end\n\n  describe \"dynamic asset helper\" do\n    it \"returns the fingerprinted path\" do\n      TestPage.new.dynamic_asset_path.should eq \"/images/logo-with-hash.png\"\n    end\n\n    it \"works inside included module\" do\n      TestPage.new.dynamic_asset_inside_component.should eq \"/images/logo-with-hash.png\"\n    end\n\n    it \"raises a helpful error\" do\n      expect_raises Exception, \"Missing asset: woops!.png\" do\n        TestPage.new.missing_dynamic_asset_path\n      end\n    end\n\n    it \"prepends the asset_host configuration option\" do\n      Lucky::Server.temp_config(asset_host: \"https://production.com\") do\n        TestPage.new.dynamic_asset_path.should eq \"https://production.com/images/logo-with-hash.png\"\n      end\n    end\n  end\n\n  describe \"testing with vite manifest\" do\n    it \"returns the fingerprinted path\" do\n      Lucky::AssetHelpers.asset(\"images/lucky_logo.png\").should eq \"/images/lucky_logo.a54cc67e.png\"\n    end\n\n    it \"works when included in another class\" do\n      TestPage.new.asset_path_from_vite.should eq \"/images/lucky_logo.a54cc67e.png\"\n    end\n\n    it \"works when used from an included module\" do\n      TestPage.new.vite_asset_inside_component.should eq \"/images/lucky_logo.a54cc67e.png\"\n    end\n\n    it \"prepends the asset_host configuration option\" do\n      Lucky::Server.temp_config(asset_host: \"https://production.com\") do\n        TestPage.new.asset_path_from_vite.should eq \"https://production.com/images/lucky_logo.a54cc67e.png\"\n      end\n    end\n\n    it \"returns the fingerprinted path\" do\n      TestPage.new.dynamic_asset_path(\"lucky_logo\").should eq \"/images/lucky_logo.a54cc67e.png\"\n    end\n\n    it \"works inside included module\" do\n      TestPage.new.dynamic_asset_inside_component(\"lucky_logo\").should eq \"/images/lucky_logo.a54cc67e.png\"\n    end\n\n    it \"raises a helpful error\" do\n      expect_raises Exception, \"Missing asset: woops!.png\" do\n        TestPage.new.missing_dynamic_asset_path\n      end\n    end\n\n    it \"prepends the asset_host configuration option\" do\n      Lucky::Server.temp_config(asset_host: \"https://production.com\") do\n        TestPage.new.dynamic_asset_path(\"lucky_logo\").should eq \"https://production.com/images/lucky_logo.a54cc67e.png\"\n      end\n    end\n  end\nend\n"
  },
  {
    "path": "spec/lucky/assignable_spec.cr",
    "content": "require \"../spec_helper\"\n\ninclude ContextHelper\n\nclass BasePage\n  include Lucky::HTMLPage\n\n  needs name : String\nend\n\nclass AdminPage < BasePage\n  needs admin_name : String\nend\n\nclass PageOne < BasePage\n  needs title : String\n  needs second : String\n\n  def render\n  end\nend\n\nclass PageTwo < BasePage\n  needs title : String\n\n  def render\n  end\nend\n\nclass PageThree < AdminPage\n  needs title : String\n\n  def render\n  end\nend\n\nclass PageWithQuestionMark\n  include Lucky::HTMLPage\n  needs signed_in : Bool\n\n  def render\n    text signed_in?.to_s\n  end\nend\n\nclass PageWithDefaultsFirst\n  include Lucky::HTMLPage\n  needs required : String\n  needs nothing : Bool = false\n  needs extra_css : String?\n  needs extra_html : String? = nil\n  needs optional_metaclass : String.class | Nil\n  needs status : String = \"special\"\n  needs title : String\n\n  def render\n    text \"#{@status} #{@title}\"\n  end\nend\n\nclass PageWithMetaclass\n  include Lucky::HTMLPage\n  needs string_class : String.class\n  needs access_me_with_a_getter : String = \"called from an auto-generated getter\"\n\n  def render\n    text access_me_with_a_getter\n  end\nend\n\nclass OverrideGetterPage\n  include Lucky::HTMLPage\n  needs name : String = \"Oops! Not set\"\n\n  def render\n    text name\n  end\n\n  def name\n    \"Joe\"\n  end\nend\n\nclass NonPageClass\n  include Lucky::Assignable\n\n  needs param : String\nend\n\nclass InheritedNonPageClass < NonPageClass\n  needs other_param : String\nend\n\ndescribe \"Assigns within multiple pages with the same name\" do\n  it \"should only appear once in the initializer\" do\n    PageOne.new build_context, title: \"foo\", name: \"Paul\", second: \"second\"\n    PageTwo.new build_context, title: \"foo\", name: \"Paul\"\n    PageThree.new build_context, name: \"Paul\", admin_name: \"Pablo\", title: \"Admin\"\n    PageWithQuestionMark.new(build_context, signed_in: true).perform_render.to_s.should contain(\"true\")\n    PageWithDefaultsFirst.new(build_context, required: \"thing\", title: \"foo\").perform_render.to_s.should contain(\"special foo\")\n    PageWithMetaclass.new(build_context, string_class: String)\n      .perform_render.to_s.should contain(\"called from an auto-generated getter\")\n    OverrideGetterPage.new(build_context).perform_render.to_s.should eq(\"Joe\")\n    NonPageClass.new(param: \"foo\").param.should eq(\"foo\")\n    InheritedNonPageClass.new(param: \"foo\", other_param: \"bar\").other_param.should eq(\"bar\")\n  end\nend\n"
  },
  {
    "path": "spec/lucky/base_http_client_spec.cr",
    "content": "require \"../spec_helper\"\n\nclass HelloWorldAction < TestAction\n  accepted_formats [:plain_text]\n\n  param codes : Array(String)?\n\n  post \"/hello\" do\n    plain_text \"world\"\n  end\nend\n\nclass ArrayParamAction < TestAction\n  accepted_formats [:plain_text]\n\n  param codes : Array(String)\n\n  post \"/array_param\" do\n    plain_text codes.join(\"--\")\n  end\nend\n\nclass MyClient < Lucky::BaseHTTPClient\n  app TestServer.new\nend\n\ndescribe Lucky::BaseHTTPClient do\n  describe \"headers\" do\n    it \"sets headers and allows chaining\" do\n      MyClient.new\n        .headers(accept: \"text/plain\")\n        .headers(content_type: \"application/json\")\n        .headers(\"Foo\": \"bar\")\n        .exec(HelloWorldAction)\n\n      request = TestServer.last_request\n      request.headers[\"accept\"].should eq(\"text/plain\")\n      request.headers[\"content-type\"].should eq(\"application/json\")\n      request.headers[\"Foo\"].should eq(\"bar\")\n    end\n  end\n\n  describe \"exec\" do\n    describe \"with Lucky::Action class\" do\n      it \"uses the method and path\" do\n        response = MyClient.new.exec(HelloWorldAction)\n\n        request = TestServer.last_request\n        request.path.should eq \"/hello\"\n        request.method.should eq(\"POST\")\n        request.body.to_s.should eq(\"{}\")\n        response.body.should eq \"world\"\n      end\n\n      it \"allows passing params\" do\n        response = MyClient.new.exec(HelloWorldAction, foo: \"bar\")\n\n        request = TestServer.last_request\n        request.body.to_s.should eq({foo: \"bar\"}.to_json)\n      end\n\n      it \"allows passing a NamedTuple\" do\n        params = {foo: \"bar\"}\n        response = MyClient.new.exec(HelloWorldAction, params)\n\n        request = TestServer.last_request\n        request.body.to_s.should eq({foo: \"bar\"}.to_json)\n      end\n\n      it \"works with array query params\" do\n        response = MyClient.new.exec ArrayParamAction.with(codes: [\"ab\", \"xy\"])\n        response.body.should eq \"ab--xy\"\n\n        request = TestServer.last_request\n        request.query.should eq(\"codes%5B%5D=ab&codes%5B%5D=xy\")\n      end\n    end\n\n    describe \"with a Lucky::RouteHelper\" do\n      it \"uses the method and path\" do\n        response = MyClient.new.exec(HelloWorldAction.route)\n\n        request = TestServer.last_request\n        request.path.should eq \"/hello\"\n        request.method.should eq(\"POST\")\n        request.body.to_s.should eq(\"{}\")\n        response.body.should eq \"world\"\n      end\n\n      it \"allows passing params\" do\n        response = MyClient.new.exec(HelloWorldAction.route, foo: \"bar\")\n\n        request = TestServer.last_request\n        request.body.to_s.should eq({foo: \"bar\"}.to_json)\n      end\n    end\n  end\n\n  describe \"exec_raw\" do\n    describe \"with Lucky::Action class\" do\n      it \"allows passing raw strings\" do\n        test_data = <<-JSON\n          { \"event_id\": \"1\"}\n          { \"type\": \"event\"}\n          { \"event_id\": \"2\", \"type\": \"event\", \"platform\": \"\"}\n        JSON\n        response = MyClient.new.exec_raw(HelloWorldAction, test_data)\n\n        request = TestServer.last_request\n        request.body.to_s.should eq(test_data)\n      end\n    end\n\n    describe \"with a Lucky::RouteHelper\" do\n      it \"allows passing raw strings\" do\n        test_data = <<-JSON\n          { \"event_id\": \"1\"}\n          { \"type\": \"event\"}\n          { \"event_id\": \"2\", \"type\": \"event\", \"platform\": \"\"}\n        JSON\n        response = MyClient.new.exec_raw(HelloWorldAction.route, test_data)\n\n        request = TestServer.last_request\n        request.body.to_s.should eq(test_data)\n      end\n    end\n  end\n\n  {% for method in [:put, :patch, :post, :delete, :get, :options] %}\n    describe \"\\#{{method.id}}\" do\n      it \"sends correct request to correct uri and gives the correct response\" do\n        response = MyClient.new.{{method.id}}(\n          path: \"hello\",\n          foo: \"bar\"\n        )\n\n        request = TestServer.last_request\n        request.method.should eq({{ method.id.stringify }}.upcase)\n        request.path.should eq \"hello\"\n        request.body.to_s.should eq({foo: \"bar\"}.to_json)\n      end\n\n      it \"works without params\" do\n        response = MyClient.new.{{method.id}}(path: \"hello\")\n\n        request = TestServer.last_request\n        request.method.should eq({{ method.id.stringify }}.upcase)\n        request.path.should eq \"hello\"\n        request.body.to_s.should eq(\"{}\")\n      end\n    end\n  {% end %}\n\n  describe \"head\" do\n    it \"sends the correct request to the correct uri and gets an empty response body\" do\n      response = MyClient.new.head(\n        path: \"hello\",\n        foo: \"bar\"\n      )\n\n      request = TestServer.last_request\n      request.method.should eq(\"HEAD\")\n      request.path.should eq \"hello\"\n    end\n    it \"works without params\" do\n      response = MyClient.new.head(path: \"hello\")\n\n      request = TestServer.last_request\n      request.method.should eq(\"HEAD\")\n      request.path.should eq \"hello\"\n      request.body.to_s.should eq(\"{}\")\n    end\n  end\nend\n"
  },
  {
    "path": "spec/lucky/base_tags_spec.cr",
    "content": "require \"../spec_helper\"\ninclude ContextHelper\n\nprivate class TestPage\n  include Lucky::HTMLPage\n\n  def render\n  end\nend\n\nclass MySpecialClass\n  include Lucky::AllowedInTags\n\n  def to_s\n    \"it works\"\n  end\nend\n\ndescribe Lucky::BaseTags do\n  it \"renders para tag as <p>\" do\n    view(&.para(\"foo\")).should contain \"<p>foo</p>\"\n  end\n\n  it \"renders allowed types in tags\" do\n    view(&.para(42)).should contain \"<p>42</p>\"\n    view(&.para(MySpecialClass.new)).should contain \"<p>it works</p>\"\n    view(&.para(1_i64)).should contain \"<p>1</p>\"\n    view(&.para({\"class\" => \"empty-content\"})).should contain \"<p class=\\\"empty-content\\\"></p>\"\n    view(&.hr).should contain \"<hr>\"\n\n    # These throw compile-time error messages\n    # view(&.h1(nil, class: \"text\"))\n    # view(&.h1(Time.utc, class: \"text\"))\n  end\n\n  it \"renders nested video with source tags and proper attributes\" do\n    view do |page|\n      page.video(attrs: [:autoplay, :controls, :loop], poster: \"https://luckyframework.org/nothing.png\") do\n        page.source(src: \"https://luckyframework.org/nothing.mp4\", type: \"video/mp4\")\n      end\n    end.should contain %{<video poster=\"https://luckyframework.org/nothing.png\" autoplay controls loop><source src=\"https://luckyframework.org/nothing.mp4\" type=\"video/mp4\"></video>}\n\n    view(&.video(id: \"player\", \"data-stream\": \"https://luckyframework.org/demo.mp4\")).should eq %{<video id=\"player\" data-stream=\"https://luckyframework.org/demo.mp4\"></video>}\n  end\n\n  it \"renders a button with a disabled boolean attribute\" do\n    view(&.button(\"text\", attrs: [:disabled])).should contain \"<button disabled>text</button>\"\n  end\n\n  it \"renders an input with autofocus boolean attribute\" do\n    view(&.input(attrs: [:autofocus], type: \"text\")).to_s.should contain %{<input type=\"text\" autofocus>}\n  end\n\n  describe \"#style\" do\n    it \"renders a style tag\" do\n      view(&.style(\"body { font-size: 2em; }\")).should contain <<-HTML\n      <style>body { font-size: 2em; }</style>\n      HTML\n    end\n  end\nend\n\nprivate def view(&)\n  TestPage.new(build_context).tap do |page|\n    yield page\n  end.view.to_s\nend\n"
  },
  {
    "path": "spec/lucky/component_spec.cr",
    "content": "require \"../spec_helper\"\n\ninclude ContextHelper\n\nprivate class TestComponent < Lucky::BaseComponent\n  def render\n    text \"TestComponent\"\n  end\nend\n\nprivate class ComplexTestComponent < Lucky::BaseComponent\n  needs title : String\n\n  def render\n    text @title\n    img src: asset(\"images/logo.png\")\n    mount(TestComponent)\n  end\nend\n\nprivate class ComplexInstanceTestComponent < Lucky::BaseComponent\n  needs title : String\n\n  def render\n    text @title\n    img src: asset(\"images/logo.png\")\n    component = TestComponent.new\n    mount_instance(component)\n  end\nend\n\nprivate class ComponentWithBlock < Lucky::BaseComponent\n  needs name : String\n\n  def render(&)\n    yield @name\n  end\nend\n\nprivate class ComponentWithBlockAndNoBlockArgs < Lucky::BaseComponent\n  def render(&)\n    yield\n  end\nend\n\nprivate class NoAction < TestAction\n  get \"/nothing_to_do\" do\n    plain_text \"blip\"\n  end\nend\n\nprivate class ComponentWithAForm < Lucky::BaseComponent\n  def render\n    form_for NoAction do\n    end\n  end\nend\n\nprivate class TestMountPage\n  include Lucky::HTMLPage\n\n  def render\n    mount(ComplexTestComponent, title: \"passed_in_title\")\n    mount(ComponentWithBlockAndNoBlockArgs) do\n      text \"Block without args\"\n    end\n    mount(ComponentWithBlock, \"Jane\") do |name|\n      text name.upcase\n    end\n    mount(ComponentWithAForm)\n    view\n  end\nend\n\nprivate class TestMountInstancePage\n  include Lucky::HTMLPage\n\n  def render\n    component = ComplexInstanceTestComponent.new(title: \"passed_in_title\")\n    mount_instance(component)\n\n    component = ComponentWithBlockAndNoBlockArgs.new\n    mount_instance(component) do\n      text \"Block without args\"\n    end\n\n    component = ComponentWithBlock.new(\"Jane\")\n    mount_instance(component) do |name|\n      text name.upcase\n    end\n\n    view\n  end\nend\n\ndescribe \"components rendering\" do\n  it \"renders to a page\" do\n    contents = TestMountPage.new(context_with_csrf).render.to_s\n\n    contents.should contain(\"passed_in_title\")\n    contents.should contain(\"TestComponent\")\n    contents.should contain(\"/images/logo-with-hash.png\")\n    contents.should contain(\"JANE\")\n    contents.should contain(\"Block without args\")\n    contents.should_not contain(\"<!--\")\n  end\n\n  it \"accepts exact arguments that match 'needs' declarations\" do\n    # Components should work fine when passed exact arguments\n    component = ComplexTestComponent.new(title: \"test\")\n    component.render_to_string.should contain(\"test\")\n  end\n\n  it \"does not accept unused arguments (compile-time validation)\" do\n    # NOTE: Components now fail at compile time if passed unused arguments.\n    # This test can't demonstrate the failure since it would prevent compilation,\n    # but the following would fail to compile:\n    # ComplexTestComponent.new(title: \"test\", unused_arg: \"fail\")\n    # Error: no parameter named 'unused_arg'\n\n    # This ensures we maintain the expected behavior\n    component = ComplexTestComponent.new(title: \"test\")\n    component.render_to_string.should contain(\"test\")\n  end\n\n  it \"renders to a string\" do\n    html = ComplexTestComponent.new(title: \"passed_in_title\").render_to_string\n\n    html.should contain(\"passed_in_title\")\n  end\n\n  it \"prints a comment when configured to do so\" do\n    Lucky::HTMLPage.temp_config(render_component_comments: true) do\n      contents = TestMountPage.new(context_with_csrf).render.to_s\n      contents.should contain(\"<!-- BEGIN: ComplexTestComponent #{component_path} -->\")\n      contents.should contain(\"<!-- END: ComplexTestComponent -->\")\n      contents.should contain(\"<!-- BEGIN: ComponentWithBlock #{component_path} -->\")\n      contents.should contain(\"<!-- END: ComponentWithBlock -->\")\n    end\n  end\n\n  context \"mounted instance\" do\n    it \"renders to a page\" do\n      contents = TestMountInstancePage.new(build_context).render.to_s\n\n      contents.should contain(\"passed_in_title\")\n      contents.should contain(\"TestComponent\")\n      contents.should contain(\"/images/logo-with-hash.png\")\n      contents.should contain(\"JANE\")\n      contents.should contain(\"Block without args\")\n      contents.should_not contain(\"<!--\")\n    end\n\n    it \"renders to a string\" do\n      html = ComplexInstanceTestComponent.new(title: \"passed_in_title\").render_to_string\n\n      html.should contain(\"passed_in_title\")\n    end\n\n    it \"prints a comment when configured to do so\" do\n      Lucky::HTMLPage.temp_config(render_component_comments: true) do\n        contents = TestMountInstancePage.new(build_context).render.to_s\n        contents.should contain(\"<!-- BEGIN: ComplexInstanceTestComponent #{component_path} -->\")\n        contents.should contain(\"<!-- END: ComplexInstanceTestComponent -->\")\n        contents.should contain(\"<!-- BEGIN: ComponentWithBlock #{component_path} -->\")\n        contents.should contain(\"<!-- END: ComponentWithBlock -->\")\n      end\n    end\n  end\n\n  it \"uses context from being mounted\" do\n    contents = TestMountPage.new(context_with_csrf).render.to_s\n    contents.should contain <<-HTML\n    input type=\"hidden\" name=\"_csrf\"\n    HTML\n  end\nend\n\nprivate def component_path : String\n  Path.new(\"spec\", \"lucky\", \"component_spec.cr\").to_s\nend\n\nprivate def context_with_csrf : HTTP::Server::Context\n  context = build_context\n  context.session.set(Lucky::ProtectFromForgery::SESSION_KEY, \"my_token\")\n  context\nend\n"
  },
  {
    "path": "spec/lucky/cookies/cookie_jar_spec.cr",
    "content": "require \"../../spec_helper\"\n\ndescribe Lucky::CookieJar do\n  it \"sets and gets with indifferent access\" do\n    jar = Lucky::CookieJar.empty_jar\n\n    jar.set(:symbol_key, \"symbol key\")\n    jar.set(\"string_key\", \"string key\")\n    jar[:another_symbol] = \"symbol key\"\n    jar[\"another_string\"] = \"string key\"\n\n    jar.get(:symbol_key).should eq(\"symbol key\")\n    jar.get(\"symbol_key\").should eq(\"symbol key\")\n    jar.get(\"string_key\").should eq(\"string key\")\n    jar.get(:string_key).should eq(\"string key\")\n    jar[:another_symbol].should eq(\"symbol key\")\n    jar[\"another_string\"].should eq(\"string key\")\n  end\n\n  it \"sets and gets raw HTTP::Cookie object with indifferent access\" do\n    value = \"Nestle_Tollhouse\"\n    jar = Lucky::CookieJar.empty_jar\n\n    jar.set_raw(\"cookie\", value)\n    jar.set_raw(:symbol, \"symbol_value\")\n\n    jar.get_raw(:cookie).should be_a(HTTP::Cookie)\n    jar.get_raw(\"symbol\").value.should eq(\"symbol_value\")\n    jar.get_raw(:cookie).value.should eq(value)\n    jar.get_raw(\"cookie\").value.should eq(value)\n    jar.get_raw?(:cookie).as(HTTP::Cookie).value.should eq(value)\n    jar.get_raw?(\"cookie\").as(HTTP::Cookie).value.should eq(value)\n    jar.get_raw?(:missing).should be_nil\n    jar.get_raw?(\"missing\").should be_nil\n  end\n\n  it \"raises a nicer error for invalid cookie values\" do\n    value = \"Double,Chocolate\"\n    jar = Lucky::CookieJar.empty_jar\n\n    expect_raises(Lucky::InvalidCookieValueError, \"Cookie value for 'cookie' is invalid\") do\n      jar.set_raw(\"cookie\", value)\n    end\n  end\n\n  it \"raises CookieNotFoundError when getting a raw cookie that doesn't exist\" do\n    jar = Lucky::CookieJar.empty_jar\n\n    expect_raises Lucky::CookieNotFoundError, \"No cookie found with the key: 'snickerdoodle'\" do\n      jar.get_raw(:snickerdoodle)\n    end\n  end\n\n  it \"raises CookieNotFoundError when getting an encrypted cookie that doesn't exist\" do\n    jar = Lucky::CookieJar.empty_jar\n\n    expect_raises Lucky::CookieNotFoundError, \"No cookie found with the key: 'snickerdoodle'\" do\n      jar.get(:snickerdoodle)\n    end\n  end\n\n  it \"catches values with old or incorrect keys and returns nil\" do\n    jar_with_old_secret = Lucky::CookieJar.empty_jar\n    Lucky::Server.temp_config(secret_key_base: \"a\" * 32) do\n      jar_with_old_secret.set(:name, \"value\")\n    end\n    value_encrypted_with_old_jar = jar_with_old_secret.get_raw(:name).value\n    jar = Lucky::CookieJar.empty_jar\n\n    jar.set_raw(:name, value_encrypted_with_old_jar)\n\n    jar.get?(:name).should be_nil\n  end\n\n  it \"returns nil if fails to decrypt value\" do\n    jar = Lucky::CookieJar.empty_jar\n\n    jar.set_raw(:name, \"Jane\")\n\n    jar.get?(:name).should be_nil\n  end\n\n  it \"parses encrypted cookies as expected\" do\n    # meant to be a regression test to make sure we don't\n    # accidentally break cookie decryption\n    #\n    # this cookie was created with Lucky 1.5\n    cookie_key = \"cookie_key\"\n    cookie_value = \"bHVja3k=--WyJuaEd0U1poaVRPeUdkTlRSMVVKSURBTUc2bGgyN1l1d3RUZE1rZnR4OVNaVG1vbmNkUU9UT1ZlNzZzWmoySlhjIiwiMHFLa3ZFS1RBMFRMaTZsTFZwT1Z3NFNGMUVzPSJd\"\n    cookies = HTTP::Cookies.new\n    cookies[cookie_key] = cookie_value\n    jar = Lucky::CookieJar.from_request_cookies(cookies)\n\n    JSON.parse(jar.get(cookie_key)).should eq({\"key\" => \"value\", \"abc\" => \"123\"})\n  end\n\n  it \"returns nil when a valid cookie from a former Lucky version is decrypted\" do\n    # Previous versions of Lucky had a vulnerability\n    # and the encrypted cookie can't be decrypted\n    #\n    # this cookie was created with Lucky 0.27\n    cookie_key = \"cookie_key\"\n    cookie_value = \"bHVja3k=--hY71kbRfob4pb9NS7wJpWKOBRhF+kwYPsHRQQanyXzGSKsCO6MIHCZfRBxDRqqm6\"\n    cookies = HTTP::Cookies.new\n    cookies[cookie_key] = cookie_value\n    jar = Lucky::CookieJar.from_request_cookies(cookies)\n    jar.get?(cookie_key).should eq(nil)\n  end\n\n  describe \"#set\" do\n    it \"only sets the name, http_only, and value if no 'on_set' block is set\" do\n      Lucky::CookieJar.temp_config(on_set: nil) do\n        jar = Lucky::CookieJar.empty_jar\n\n        jar.set(:message, \"Help I'm trapped in a cookie jar\")\n\n        jar.get(:message).should eq(\"Help I'm trapped in a cookie jar\")\n        message = jar.get_raw(:message)\n        message.http_only.should be_true\n        message.expires.should be_nil\n        message.path.should be_nil\n        message.domain.should be_nil\n        message.secure.should be_false\n      end\n    end\n\n    it \"calls 'on_set' block if set\" do\n      time = 1.day.from_now\n      block = ->(new_cookie : HTTP::Cookie) {\n        new_cookie.expires(time)\n        new_cookie.domain(\"example.com\")\n      }\n\n      Lucky::CookieJar.temp_config(on_set: block) do\n        jar = Lucky::CookieJar.empty_jar\n\n        jar.set(:message, \"Help I'm trapped in a cookie jar\")\n\n        message = jar.get_raw(:message)\n        message.expires.should eq(time)\n        message.domain.should eq(\"example.com\")\n      end\n    end\n\n    it \"returns a cookie so you can override cookie settings\" do\n      time = 1.day.from_now\n      jar = Lucky::CookieJar.empty_jar\n\n      jar.set(:tabs_or_spaces, \"stop it\").http_only(false).expires(time)\n\n      jar.get_raw(:tabs_or_spaces).http_only.should be_false\n      jar.get_raw(:tabs_or_spaces).expires.as(Time).should eq(time)\n    end\n\n    it \"raises an error if the cookie is > 4096 bytes\" do\n      expect_raises(Lucky::CookieOverflowError) do\n        jar = Lucky::CookieJar.empty_jar\n        jar.set_raw(:overflow, \"x\" * 4097) # \"overflow=x...x; HttpOnly\",\n      end\n    end\n  end\n\n  describe \"delete\" do\n    # https://stackoverflow.com/questions/5285940/correct-way-to-delete-cookies-server-side\n    it \"expires the cookie and sets the value to an empty string\" do\n      jar = Lucky::CookieJar.empty_jar\n      jar.set(:rules, \"no fighting!\")\n      jar.get_raw(:rules).expired?.should_not be_true\n\n      jar.delete(:rules)\n\n      jar.get_raw(:rules).expired?.should be_true\n      jar.get_raw(:rules).value.should eq(\"\")\n    end\n\n    it \"deletes a valid cookie with a block\" do\n      jar = Lucky::CookieJar.empty_jar\n      jar.set(:rules, \"no fighting!\").domain(\"brawl.co\")\n\n      jar.delete(:rules) do |cookie|\n        cookie.domain(\"brawl.co\")\n      end\n\n      jar.deleted?(:rules).should be_true\n    end\n\n    it \"ignores an invalid cookie when trying to delete\" do\n      jar = Lucky::CookieJar.empty_jar\n      jar.set(:rules, \"no fighting!\").domain(\"brawl.co\")\n\n      jar.delete(:burritos) do |cookie|\n        cookie.domain(\"brawl.co\")\n      end\n\n      jar.deleted?(:rules).should be_false\n    end\n  end\n\n  describe \"deleted?\" do\n    it \"returns true when the cookie looks like a deleted cookie\" do\n      jar = Lucky::CookieJar.empty_jar\n      jar.set(:go, \"now!\")\n      jar.deleted?(:go).should be_false\n\n      jar.delete(:go)\n\n      jar.deleted?(:go).should be_true\n    end\n\n    it \"returns false when the cookie doesn't even exist\" do\n      jar = Lucky::CookieJar.empty_jar\n      jar.deleted?(:non).should be_false\n    end\n  end\n\n  describe \"#clear\" do\n    it \"deletes all the cookies in the jar\" do\n      jar = Lucky::CookieJar.empty_jar\n      jar.set(:name, \"Edward\")\n      jar.set(:age, \"Super Old\")\n\n      jar.clear\n\n      name = jar.get_raw(:name)\n      age = jar.get_raw(:age)\n      name.value.should eq(\"\")\n      age.value.should eq(\"\")\n      name.expired?.should be_true\n      age.expired?.should be_true\n    end\n\n    it \"deletes cookies with options\" do\n      headers = HTTP::Headers.new\n      headers[\"Cookie\"] = \"name=Rick%20James\"\n      cookies = HTTP::Cookies.from_client_headers(headers)\n      jar = Lucky::CookieJar.from_request_cookies(cookies)\n\n      jar.clear do |cookie|\n        cookie.path(\"/\")\n          .http_only(true)\n          .secure(true)\n          .domain(\".example.com\")\n      end\n\n      name = jar.get_raw(:name)\n      name.value.should eq(\"\")\n      name.path.should eq(\"/\")\n      name.domain.should eq(\".example.com\")\n      name.secure.should be_true\n      name.expired?.should be_true\n    end\n  end\nend\n"
  },
  {
    "path": "spec/lucky/cookies/flash_store_spec.cr",
    "content": "require \"../../spec_helper\"\n\ndescribe Lucky::FlashStore do\n  describe \".from_session\" do\n    it \"creates a flash store from the json in a session\" do\n      flash_store = Lucky::FlashStore.from_session(build_session({\"some_key\" => \"some_value\"}))\n\n      flash_store.get(\"some_key\").should eq \"some_value\"\n    end\n\n    it \"raises an error when flash JSON is invalid\" do\n      message = <<-MESSAGE\n      The flash messages (stored as JSON) failed to parse in a JSON parser.\n      Here's what it tries to parse:\n\n      not_valid_json=invalid\n      MESSAGE\n\n      expect_raises(Lucky::InvalidFlashJSONError, message) do\n        Lucky::FlashStore.from_session(build_invalid_session)\n      end\n    end\n\n    it \"does not persist values from session into the next request\" do\n      flash_store = Lucky::FlashStore.from_session(build_session({\"some_key\" => \"some_value\"}))\n\n      next_flash(flash_store).should be_empty\n    end\n  end\n\n  context \"shortcuts\" do\n    it \"has failure\" do\n      flash_store = Lucky::FlashStore.new\n\n      flash_store.failure?.should be_nil\n      flash_store.failure = \"Failure\"\n      flash_store.failure.should eq(\"Failure\")\n      flash_store.failure?.should eq(\"Failure\")\n      flash_store.get(:failure).should eq(\"Failure\")\n    end\n\n    it \"has info\" do\n      flash_store = Lucky::FlashStore.new\n\n      flash_store.info?.should be_nil\n      flash_store.info = \"Info\"\n      flash_store.info.should eq(\"Info\")\n      flash_store.info?.should eq(\"Info\")\n      flash_store.get(:info).should eq(\"Info\")\n    end\n\n    it \"has success\" do\n      flash_store = Lucky::FlashStore.new\n\n      flash_store.success?.should be_nil\n      flash_store.success = \"Success\"\n      flash_store.success.should eq(\"Success\")\n      flash_store.success?.should eq(\"Success\")\n      flash_store.get(:success).should eq(\"Success\")\n    end\n  end\n\n  describe \"#keep\" do\n    it \"carries messages over set from session and set during current request\" do\n      flash_store = build_flash_store({\"name\" => \"Paul\"})\n      flash_store.set(:info, \"Success\")\n\n      flash_store.keep.should be_nil\n      next_flash = next_flash(flash_store)\n\n      next_flash[\"name\"]?.should eq(\"Paul\")\n      next_flash[\"info\"]?.should eq(\"Success\")\n    end\n  end\n\n  describe \"#each\" do\n    it \"returns the list of key/value pairs\" do\n      flash_store = build_flash_store({\n        \"some_key\"  => \"some_value\",\n        \"other_key\" => \"other_value\",\n      })\n\n      test = Hash(String, String).new\n      flash_store.each { |k, v| test[k] = v }\n      test.size.should eq 2\n      test[\"some_key\"].should eq \"some_value\"\n      test[\"other_key\"].should eq \"other_value\"\n    end\n  end\n\n  describe \"#any?\" do\n    it \"returns true if there are key/value pairs\" do\n      flash_store = build_flash_store({\"some_key\" => \"some_value\"})\n\n      # ameba:disable Performance/AnyInsteadOfEmpty\n      flash_store.any?.should be_true\n    end\n\n    it \"returns false if there are no key/value pairs\" do\n      flash_store = build_flash_store\n\n      # ameba:disable Performance/AnyInsteadOfEmpty\n      flash_store.any?.should be_false\n    end\n  end\n\n  describe \"#empty?\" do\n    it \"returns false if there are key/value pairs\" do\n      flash_store = build_flash_store({\"some_key\" => \"some_value\"})\n\n      flash_store.empty?.should be_false\n    end\n\n    it \"returns true if there are no key/value pairs\" do\n      flash_store = build_flash_store\n\n      flash_store.empty?.should be_true\n    end\n  end\n\n  describe \"#set\" do\n    it \"sets values from symbols and strings\" do\n      flash_store = build_flash_store\n\n      flash_store.set(:name, \"Paul\")\n      flash_store.set(\"dungeons\", \"dragons\")\n\n      flash_store.get(\"name\").should eq(\"Paul\")\n      flash_store.get(\"dungeons\").should eq(\"dragons\")\n    end\n\n    it \"overwrites existing values\" do\n      flash_store = build_flash_store({\"name\" => \"Paul\"})\n\n      flash_store.set(:name, \"Pauline\")\n\n      flash_store.get(:name).should eq(\"Pauline\")\n    end\n\n    it \"is not persisted into the next request\" do\n      flash_store = build_flash_store\n\n      flash_store.set(:success, \"Message saved again!\")\n\n      next_flash(flash_store).should be_empty\n    end\n  end\n\n  describe \"#get\" do\n    it \"retrieves values from session and set during current request\" do\n      flash_store = build_flash_store({\"cookie thief\" => \"Edward\"})\n      flash_store.set(:baker, \"Paul\")\n\n      flash_store.get(\"baker\").should eq(\"Paul\")\n      flash_store.get(\"cookie thief\").should eq(\"Edward\")\n    end\n\n    it \"retrieves for both symbols and strings\" do\n      flash_store = build_flash_store({\"baker\" => \"Paul\"})\n\n      flash_store.get(\"baker\").should eq(\"Paul\")\n      flash_store.get(:baker).should eq(\"Paul\")\n    end\n\n    it \"works for kept messages\" do\n      flash_store = build_flash_store\n      flash_store.set(\"baker\", \"Paul\")\n      flash_store.keep\n\n      flash_store.get(:baker).should eq(\"Paul\")\n      next_flash(flash_store)[\"baker\"]?.should eq(\"Paul\")\n    end\n  end\n\n  describe \"#to_json\" do\n    it \"returns JSON for kept flash messages\" do\n      flash_store = build_flash_store\n      flash_store.set(:next, \"should carry over\")\n      flash_store.keep\n\n      result = flash_store.to_json\n\n      result.should eq({next: \"should carry over\"}.to_json)\n    end\n  end\n\n  describe \"#clear\" do\n    it \"clears out all flash messages\" do\n      flash_store = build_flash_store\n      flash_store.set(:name, \"Paul\")\n\n      flash_store.clear\n\n      flash_store.get?(:name).should be_nil\n      next_flash(flash_store).should be_empty\n    end\n  end\nend\n\nprivate def build_flash_store(session_values = {} of String => String)\n  Lucky::FlashStore.from_session(build_session(session_values))\nend\n\nprivate def build_session(values = {} of String => String)\n  Lucky::Session.new.tap(&.set(Lucky::FlashStore::SESSION_KEY, values.to_json))\nend\n\nprivate def build_invalid_session\n  Lucky::Session.new.tap(&.set(Lucky::FlashStore::SESSION_KEY, \"not_valid_json=invalid\"))\nend\n\nprivate def next_flash(flash_store : Lucky::FlashStore) : Hash(String, JSON::Any)\n  JSON.parse(flash_store.to_json).as_h\nend\n"
  },
  {
    "path": "spec/lucky/cookies/session_spec.cr",
    "content": "require \"../../spec_helper\"\n\ndescribe Lucky::Session do\n  it \"gets and sets with indifferent access\" do\n    store = Lucky::Session.new\n\n    store.set(:symbol_key, \"symbol key\")\n    store.set(\"string_key\", \"string key\")\n\n    store.get(:symbol_key).should eq(\"symbol key\")\n    store.get(\"symbol_key\").should eq(\"symbol key\")\n    store.get(\"string_key\").should eq(\"string key\")\n    store.get(:string_key).should eq(\"string key\")\n  end\n\n  describe \"#delete\" do\n    it \"removes the key and value from the session\" do\n      store = Lucky::Session.new\n      store.set(:best_number, \"over 9000\")\n\n      store.delete(:best_number)\n\n      store.get?(:best_number).should be_nil\n    end\n  end\n\n  describe \"#clear\" do\n    it \"sets the store to an empty hash\" do\n      store = Lucky::Session.new\n      store.set(:name, \"Edward\")\n\n      store.clear\n\n      store.get?(:name).should be_nil\n    end\n  end\nend\n"
  },
  {
    "path": "spec/lucky/custom_tags_spec.cr",
    "content": "require \"../spec_helper\"\n\ninclude ContextHelper\n\nprivate class TestPage\n  include Lucky::HTMLPage\n\n  def render\n  end\nend\n\ndescribe Lucky::CustomTags do\n  it \"renders tag in a variety of ways\" do\n    view(&.tag(\"foo-tag\", \"content\"))\n      .to_s.should contain \"<foo-tag>content</foo-tag>\"\n    view(&.tag(\"foo-tag\", 1))\n      .to_s.should contain \"<foo-tag>1</foo-tag>\"\n    view(&.tag(\"foo-tag\", \"content\", class: \"my-class\"))\n      .to_s.should contain %(<foo-tag class=\"my-class\">content</foo-tag>)\n    view(&.tag(\"foo-tag\", 1, class: \"my-class\"))\n      .to_s.should contain %(<foo-tag class=\"my-class\">1</foo-tag>)\n    view(&.tag(\"foo-tag\", \"content\", data_confirm: \"true\"))\n      .to_s.should contain %(<foo-tag data-confirm=\"true\">content</foo-tag>)\n    view(&.tag(\"foo-tag\"))\n      .to_s.should contain \"<foo-tag></foo-tag>\"\n    view(&.tag(\"foo-tag\", class: \"my-class\"))\n      .to_s.should contain %(<foo-tag class=\"my-class\"></foo-tag>)\n    view(&.tag(\"foo-tag\", {\"class\" => \"my-class\"}))\n      .to_s.should contain %(<foo-tag class=\"my-class\"></foo-tag>)\n    view(&.tag(\"foo-tag\", attrs: [:ng_strict_di], ng_app: \"ngAppStrictDemo\", name: \"JSApp\"))\n      .to_s.should contain %(<foo-tag ng-app=\"ngAppStrictDemo\" name=\"JSApp\" ng-strict-di></foo-tag>)\n\n    view do |page|\n      page.tag(\"foo-tag\") do\n        page.text \"content\"\n      end\n    end.should contain \"<foo-tag>content</foo-tag>\"\n\n    view do |page|\n      page.tag(\"foo-tag\", class: \"my-class\") do\n        page.text \"content\"\n      end\n    end.should contain %(<foo-tag class=\"my-class\">content</foo-tag>)\n\n    view do |page|\n      page.tag \"script\", [:async], data_counter: \"https://counter.co\", src: \"count.js\" do\n      end\n    end.should contain %(<script data-counter=\"https://counter.co\" src=\"count.js\" async></script>)\n  end\n\n  it \"has a method for empty tags\" do\n    view(&.empty_tag(\"br\")).should eq \"<br>\"\n  end\nend\n\nprivate def view(&)\n  TestPage.new(build_context).tap do |page|\n    yield page\n  end.view.to_s\nend\n"
  },
  {
    "path": "spec/lucky/data_response_spec.cr",
    "content": "require \"../spec_helper\"\n\ninclude ContextHelper\n\ndescribe Lucky::FileResponse do\n  describe \"#print\" do\n    describe \"status_code\" do\n      it \"uses the default status if none is set\" do\n        context = build_context\n        print_data_response(context)\n\n        context.response.status_code.should eq Lucky::TextResponse::DEFAULT_STATUS\n      end\n\n      it \"uses the passed in status\" do\n        context = build_context\n        print_data_response(context, status: 300)\n\n        context.response.status_code.should eq 300\n      end\n\n      it \"uses the response status if it's set, and Lucky::TextResponse status is nil\" do\n        context = build_context\n        context.response.status_code = 300\n        print_data_response(context)\n\n        context.response.status_code.should eq 300\n      end\n    end\n\n    describe \"content_length\" do\n      it \"calculates from a bytesize of the data\" do\n        context = build_context\n        data = \"Lucky is awesome 🤟\"\n        print_data_response(context, data: data)\n\n        context.response.headers[\"Content-Length\"].should eq data.bytesize.to_s\n      end\n    end\n\n    describe \"content_type\" do\n      it \"uses the default content_type when no extension is present\" do\n        context = build_context\n        print_data_response(context)\n\n        context.response.headers[\"Content-Type\"].should eq \"application/octet-stream\"\n      end\n\n      it \"uses the provided content_type\" do\n        context = build_context\n        print_data_response(context, content_type: \"text/plain\")\n\n        context.response.headers[\"Content-Type\"].should eq \"text/plain\"\n      end\n    end\n\n    describe \"disposition\" do\n      it \"is 'attachment' by default\" do\n        context = build_context\n        print_data_response(context)\n\n        context.response.headers[\"Content-Disposition\"].should eq \"attachment\"\n      end\n\n      it \"can be changed to 'inline'\" do\n        context = build_context\n        print_data_response(context, disposition: \"inline\")\n\n        context.response.headers[\"Content-Disposition\"].should eq \"inline\"\n      end\n\n      it \"can set the downloaded file's name\" do\n        context = build_context\n        print_data_response(context, filename: \"logo.png\")\n\n        context.response.headers[\"Content-Disposition\"].should eq %(attachment; filename=\"logo.png\")\n      end\n    end\n  end\nend\n\nprivate def print_data_response(context : HTTP::Server::Context,\n                                data : String = \"Lucky is awesome\",\n                                content_type : String = \"application/octet-stream\",\n                                disposition : String = \"attachment\",\n                                filename : String? = nil,\n                                status : Int32? = nil)\n  response = Lucky::DataResponse.new(context,\n    data,\n    content_type,\n    disposition: disposition,\n    filename: filename,\n    status: status)\n  response.print\nend\n"
  },
  {
    "path": "spec/lucky/dev_asset_cache_handler_spec.cr",
    "content": "require \"../spec_helper\"\n\ninclude ContextHelper\n\ndescribe Lucky::DevAssetCacheHandler do\n  it \"sets no-cache headers for static assets\" do\n    %w[/main.css /main.js /font.woff2 /image.png].each do |path|\n      context = build_context(path: path)\n      handler = Lucky::DevAssetCacheHandler.new(enabled: true)\n      handler.next = ->(_ctx : HTTP::Server::Context) { }\n      handler.call(context)\n\n      context.response.headers[\"Cache-Control\"]\n        .should eq(\"no-store, no-cache, must-revalidate\")\n      context.response.headers[\"Expires\"].should eq(\"0\")\n    end\n  end\n\n  it \"does not set no-cache headers for non-static assets\" do\n    %w[/some/page].each do |path|\n      context = build_context(path: path)\n      handler = Lucky::DevAssetCacheHandler.new(enabled: true)\n      handler.next = ->(_ctx : HTTP::Server::Context) { }\n      handler.call(context)\n\n      context.response.headers[\"Cache-Control\"]?.should be_nil\n      context.response.headers[\"Expires\"]?.should be_nil\n    end\n  end\n\n  it \"does nothing if the handler is disabled\" do\n    context = build_context(path: \"/main.css\")\n    handler = Lucky::DevAssetCacheHandler.new(enabled: false)\n    handler.next = ->(_ctx : HTTP::Server::Context) { }\n    handler.call(context)\n\n    context.response.headers[\"Cache-Control\"]?.should be_nil\n    context.response.headers[\"Expires\"]?.should be_nil\n  end\nend\n"
  },
  {
    "path": "spec/lucky/error_handling_spec.cr",
    "content": "require \"../spec_helper\"\n\ninclude ContextHelper\n\nprivate class CustomError < Exception\nend\n\nprivate class StealthError < Exception\nend\n\nprivate class UnhandledError < Exception\nend\n\n# Can't be private because if it is Crystal won't let us use temp_config\nclass FakeErrorAction < Lucky::ErrorAction\n  default_format :html\n  dont_report [StealthError]\n\n  Habitat.create do\n    setting output : IO = IO::Memory.new\n  end\n\n  def render(error : CustomError) : Lucky::Response\n    head status: 404\n  end\n\n  def default_render(error : Exception) : Lucky::Response\n    plain_text \"This is not a debug page\", status: 500\n  end\n\n  def report(error : Exception) : Nil\n    settings.output.print(\"Reported: #{error.class.name}\")\n  end\nend\n\ndescribe \"Error handling\" do\n  describe \"reporting\" do\n    it \"calls the report method on the error action\" do\n      io = IO::Memory.new\n      FakeErrorAction.temp_config(output: io) do\n        handle_error(error: CustomError.new) do |_context, _output|\n          io.to_s.should eq(\"Reported: CustomError\")\n        end\n      end\n    end\n\n    it \"can skip reporting some errors\" do\n      io = IO::Memory.new\n      FakeErrorAction.temp_config(output: io) do\n        handle_error(error: StealthError.new) do |_context, _output|\n          io.to_s.should eq(\"\")\n        end\n      end\n    end\n  end\n\n  describe \"ErrorAction\" do\n    describe \"show_debug_output setting is true\" do\n      it \"renders debug output if request accepts HTML\" do\n        handle_error(format: :html, show_debug_output: true, status_code: 500) do |context, output|\n          context.response.headers[\"Content-Type\"].should eq(\"text/html\")\n          output.should contain(\"code-explorer\")\n          output.should contain(\"Error 500\")\n          context.response.status_code.should eq(500)\n        end\n      end\n\n      it \"does not render debug output if request is not HTML\" do\n        handle_error(format: :json, show_debug_output: true, status_code: 500) do |context, output|\n          context.response.headers[\"Content-Type\"].should eq(\"text/plain\")\n          output.should_not contain(\"code-explorer\")\n          output.should contain(\"This is not a debug page\")\n          context.response.status_code.should eq(500)\n        end\n      end\n\n      it \"renders debug page with the error's status\" do\n        handle_error(format: :html, show_debug_output: true, error: CustomError.new, status_code: 404) do |context, output|\n          context.response.headers[\"Content-Type\"].should eq(\"text/html\")\n          output.should contain(\"code-explorer\")\n          output.should contain(\"Error 404\")\n          context.response.status_code.should eq(404)\n        end\n      end\n    end\n\n    describe \"show_debug_output setting is false\" do\n      it \"does not render debug output\" do\n        handle_error(format: :json, show_debug_output: false, status_code: 500) do |context, output|\n          context.response.headers[\"Content-Type\"].should eq(\"text/plain\")\n          output.should contain(\"This is not a debug page\")\n          context.response.status_code.should eq(500)\n        end\n      end\n    end\n  end\n\n  describe \"ErrorHandler\" do\n    it \"does nothing if no errors are raised\" do\n      error_handler = Lucky::ErrorHandler.new(action: FakeErrorAction)\n      error_handler.next = ->(_ctx : HTTP::Server::Context) { }\n\n      error_handler.call(build_context)\n    end\n\n    it \"handles the error with an overloaded 'render' method if defined\" do\n      handle_error(error: CustomError.new, status_code: 404) do |context, _output|\n        context.response.headers[\"Content-Type\"].should eq(\"\")\n        context.response.status_code.should eq(404)\n      end\n    end\n\n    it \"falls back to 'default_render' if there is no 'render' method for the exception\" do\n      handle_error(error: UnhandledError.new, status_code: 500) do |context, output|\n        output.should contain(\"This is not a debug page\")\n        context.response.headers[\"Content-Type\"].should eq(\"text/plain\")\n        context.response.status_code.should eq(500)\n      end\n    end\n  end\nend\n\nprivate def handle_error(\n  format : Symbol = :html,\n  show_debug_output : Bool = false,\n  error : Exception = UnhandledError.new,\n  status_code : Int32 = 200,\n  &\n)\n  Lucky::ErrorHandler.temp_config(show_debug_output: show_debug_output) do\n    error_handler = Lucky::ErrorHandler.new(action: FakeErrorAction)\n    error_handler.next = ->(_ctx : HTTP::Server::Context) { raise error }\n    io = IO::Memory.new\n    context = build_context_with_io(io)\n    context._clients_desired_format = format\n    context.response.status_code = status_code\n\n    context = error_handler.call(context).as(HTTP::Server::Context)\n\n    context.response.close\n    yield context, io.to_s\n  end\nend\n"
  },
  {
    "path": "spec/lucky/errors_spec.cr",
    "content": "require \"../spec_helper\"\n\ninclude ContextHelper\n\ndescribe \"Errors\" do\n  describe Lucky::InvalidParamError do\n    it \"is renderable\" do\n      error = Lucky::InvalidParamError.new(\n        param_name: \"page\",\n        param_value: \"select%201+1\",\n        param_type: \"Int32\")\n      error.should be_a(Lucky::RenderableError)\n      error.renderable_message.should contain(\"couldn't be parsed\")\n      error.renderable_status.should eq 422\n    end\n  end\n\n  describe Lucky::NotAcceptableError do\n    it \"is renderable\" do\n      error = Lucky::NotAcceptableError.new(\n        request: build_request,\n        format: :any,\n        action_name: \"Things::Index\",\n        accepted_formats: [:any])\n      error.should be_a(Lucky::RenderableError)\n      error.renderable_message.should contain(\"Accept header\")\n      error.renderable_status.should eq 406\n    end\n  end\n\n  describe Lucky::UnknownAcceptHeaderError do\n    it \"is renderable\" do\n      error = Lucky::UnknownAcceptHeaderError.new(request: build_request)\n      error.should be_a(Lucky::RenderableError)\n      error.renderable_message.should contain(\"Unrecognized Accept header\")\n      error.renderable_status.should eq 406\n    end\n  end\n\n  describe Lucky::ParamParsingError do\n    it \"is renderable\" do\n      error = Lucky::ParamParsingError.new(request: build_request)\n      error.should be_a(Lucky::RenderableError)\n      error.renderable_message.should contain(\"There was a problem parsing the JSON\")\n      error.renderable_status.should eq 400\n    end\n  end\n\n  describe Lucky::InvalidParamError do\n    it \"is renderable\" do\n      error = Lucky::InvalidParamError.new(\n        param_name: \"age\",\n        param_value: \"not an int\",\n        param_type: \"Int32\"\n      )\n      error.should be_a(Lucky::RenderableError)\n      error.renderable_message.should contain(\"Required param 'age'\")\n      error.renderable_status.should eq 422\n    end\n  end\n\n  describe Lucky::MissingParamError do\n    it \"is renderable\" do\n      error = Lucky::MissingParamError.new(param_name: \"age\")\n      error.should be_a(Lucky::RenderableError)\n      error.renderable_message.should contain(\"Missing parameter: 'age'\")\n      error.renderable_status.should eq 400\n    end\n  end\n\n  describe Lucky::MissingNestedParamError do\n    it \"is renderable\" do\n      error = Lucky::MissingNestedParamError.new(nested_key: \"user\")\n      error.should be_a(Lucky::RenderableError)\n      error.renderable_message.should contain(\"Missing param key: 'user'\")\n      error.renderable_status.should eq 400\n    end\n  end\nend\n"
  },
  {
    "path": "spec/lucky/expose_spec.cr",
    "content": "require \"../spec_helper\"\n\ninclude ContextHelper\n\nclass OnlyExpose < TestAction\n  expose name\n\n  get \"/expose\" do\n    html OnlyExposePage\n  end\n\n  def name\n    \"Paul\"\n  end\nend\n\nclass OnlyExposePage\n  include Lucky::HTMLPage\n\n  needs name : String\n\n  def render\n  end\nend\n\nabstract class BaseExposureAction < TestAction\n  expose :expose_one\n\n  def expose_one\n    \"expose_one\"\n  end\nend\n\nabstract class InheritedExposureAction < BaseExposureAction\n  expose :expose_two\n\n  def expose_two\n    \"expose_two\"\n  end\nend\n\nclass MultipleExposeAndAssigns < InheritedExposureAction\n  expose :expose_three\n\n  get \"/multi_expose\" do\n    html arg1: \"arg1\", arg2: \"arg2\"\n    html MultipleExposeAndAssignsPage, arg1: \"arg1\", arg2: \"arg2\"\n  end\n\n  def expose_three\n    \"expose_three\"\n  end\nend\n\nclass MultipleExposeAndAssignsPage\n  include Lucky::HTMLPage\n\n  needs expose_one : String\n  needs expose_two : String\n  needs expose_three : String\n  needs arg1 : String\n  needs arg2 : String\n\n  def render\n  end\nend\n\ndescribe \"exposures\" do\n  it \"works without explicit assigns\" do\n    OnlyExpose.new(build_context, params).call\n    MultipleExposeAndAssigns.new(build_context, params).call\n  end\nend\n"
  },
  {
    "path": "spec/lucky/fallback_action_spec.cr",
    "content": "require \"../spec_helper\"\n\ninclude ContextHelper\n\n# TestFallbackAction::Index is defined in spec/support/test_fallback_action.cr\n# so that it can be used in other tests without causing conflicts.\ndescribe \"fallback routing\" do\n  it \"renders from a fallback\" do\n    response = TestFallbackAction::Index.new(build_context, params).call\n    response.body.should eq \"You found me\"\n    response.status.should eq 200\n  end\n\n  it \"does not generate route helpers\" do\n    TestFallbackAction::Index.responds_to?(:route).should be_false\n    TestFallbackAction::Index.responds_to?(:with).should be_false\n    TestFallbackAction::Index.responds_to?(:url).should be_false\n    TestFallbackAction::Index.responds_to?(:path).should be_false\n    TestFallbackAction::Index.responds_to?(:url_without_query_params).should be_false\n  end\nend\n"
  },
  {
    "path": "spec/lucky/file_response_spec.cr",
    "content": "require \"../spec_helper\"\n\ninclude ContextHelper\n\ndescribe Lucky::FileResponse do\n  describe \"#print\" do\n    describe \"file is missing\" do\n      it \"raises an exception\" do\n        context = build_context\n\n        expect_raises Lucky::MissingFileError, /^Cannot read file/ do\n          print_file_response(context, file: \"nope\")\n        end\n      end\n    end\n\n    describe \"status_code\" do\n      it \"uses the default status if none is set\" do\n        context = build_context\n\n        print_file_response(context, file: \"lucky_logo.png\")\n\n        context.response.status_code.should eq Lucky::TextResponse::DEFAULT_STATUS\n      end\n\n      it \"uses the passed in status\" do\n        context = build_context\n\n        print_file_response(context, file: \"lucky_logo.png\", status: 300)\n\n        context.response.status_code.should eq 300\n      end\n\n      it \"uses the response status if it's set, and Lucky::TextResponse status is nil\" do\n        context = build_context\n        context.response.status_code = 300\n\n        print_file_response(context, file: \"lucky_logo.png\")\n\n        context.response.status_code.should eq 300\n      end\n    end\n\n    describe \"content_type\" do\n      it \"uses the default content_type when no extension is present\" do\n        context = build_context\n\n        print_file_response(context, file: \"plain_text\")\n\n        context.response.headers[\"Content-Type\"].should eq \"application/octet-stream\"\n      end\n\n      it \"uses the provided content_type\" do\n        context = build_context\n\n        print_file_response(context, file: \"plain_text\", content_type: \"text/plain\")\n\n        context.response.headers[\"Content-Type\"].should eq \"text/plain\"\n      end\n\n      it \"uses the content_type from the file's extension\" do\n        context = build_context\n        print_file_response(context, file: \"lucky_logo.png\")\n        context.response.headers[\"Content-Type\"].should eq \"image/png\"\n      end\n    end\n\n    describe \"disposition\" do\n      it \"is 'attachment' by default\" do\n        context = build_context\n        print_file_response(context, file: \"lucky_logo.png\")\n        context.response.headers[\"Content-Disposition\"].should eq \"attachment\"\n      end\n\n      it \"can be changed to 'inline'\" do\n        context = build_context\n        print_file_response(context, file: \"lucky_logo.png\", disposition: \"inline\")\n        context.response.headers[\"Content-Disposition\"].should eq \"inline\"\n      end\n\n      it \"can set the downloaded file's name\" do\n        context = build_context\n        print_file_response(context, \"lucky_logo.png\", filename: \"logo.png\")\n        context.response.headers[\"Content-Disposition\"]\n          .should eq %(attachment; filename=\"logo.png\")\n      end\n    end\n  end\nend\n\nprivate def print_file_response(context : HTTP::Server::Context,\n                                file : String,\n                                content_type : String? = nil,\n                                disposition : String = \"attachment\",\n                                filename : String? = nil,\n                                status : Int32? = nil)\n  response = Lucky::FileResponse.new(context,\n    fixture_file(file),\n    content_type,\n    disposition: disposition,\n    filename: filename,\n    status: status)\n  response.print\nend\n\nprivate def fixture_file(file : String)\n  \"spec/fixtures/#{file}\"\nend\n"
  },
  {
    "path": "spec/lucky/force_ssl_handler_spec.cr",
    "content": "require \"../spec_helper\"\nrequire \"http/server\"\n\ninclude ContextHelper\n\ndescribe Lucky::ForceSSLHandler do\n  context \"when the handler is disabled\" do\n    it \"simply serves the request\" do\n      context = build_ssl_context(ssl: false)\n      Lucky::ForceSSLHandler.temp_config(enabled: false) do\n        run_force_ssl_handler context\n      end\n\n      context.response.status_code.should eq 200\n      context.response.headers[\"Location\"]?.should be_nil\n    end\n  end\n\n  context \"when the handler is enabled\" do\n    context \"when the request is using SSL\" do\n      context \"when HSTS is not configured\" do\n        it \"simply serves the request\" do\n          context = build_ssl_context(ssl: true)\n          run_force_ssl_handler context\n\n          context.response.status_code.should eq 200\n          context.response.headers[\"Location\"]?.should be_nil\n        end\n      end\n\n      context \"when HSTS is configured\" do\n        it \"adds an appropriate Strict-Transport-Security header to the response\" do\n          context = build_ssl_context(ssl: true)\n          with_strict_transport_security({max_age: 180.days, include_subdomains: false}) do\n            run_force_ssl_handler context\n            context.response.headers[\"Strict-Transport-Security\"].should eq \"max-age=15552000\"\n          end\n\n          context = build_ssl_context(ssl: true)\n          with_strict_transport_security({max_age: 180.days, include_subdomains: true}) do\n            run_force_ssl_handler context\n            context.response.headers[\"Strict-Transport-Security\"].should eq \"max-age=15552000; includeSubDomains\"\n          end\n\n          context = build_ssl_context(ssl: true)\n          # Should work with Time::MonthSpan, which is returned when using 'year'\n          with_strict_transport_security({max_age: 1.year, include_subdomains: false}) do\n            run_force_ssl_handler context\n            context.response.headers[\"Strict-Transport-Security\"].should eq \"max-age=31104000\"\n          end\n        end\n      end\n    end\n\n    context \"when the request is not using SSL\" do\n      it \"redirects to an SSL version of the request\" do\n        context = build_ssl_context(ssl: false)\n        run_force_ssl_handler context\n\n        context.response.status_code.should eq 308\n        context.response.headers[\"Location\"].should eq \"https://example.com/path\"\n      end\n\n      it \"redirects using custom status\" do\n        context = build_ssl_context(ssl: false)\n        Lucky::ForceSSLHandler.temp_config(redirect_status: 302) do\n          run_force_ssl_handler context\n        end\n\n        context.response.status_code.should eq 302\n        context.response.headers[\"Location\"].should eq \"https://example.com/path\"\n      end\n    end\n  end\nend\n\nprivate def with_strict_transport_security(args, &)\n  Lucky::ForceSSLHandler.temp_config(strict_transport_security: args) do\n    yield\n  end\nend\n\nprivate def run_force_ssl_handler(context)\n  handler = Lucky::ForceSSLHandler.new\n  handler.next = ->(_ctx : HTTP::Server::Context) { }\n  handler.call(context)\nend\n\nprivate def build_ssl_context(ssl : Bool) : HTTP::Server::Context\n  build_context(path: \"/path\").tap do |context|\n    context.request.headers[\"X-Forwarded-Proto\"] = ssl ? \"https\" : \"http\"\n    context.request.headers[\"Host\"] = \"example.com\"\n  end\nend\n"
  },
  {
    "path": "spec/lucky/forgery_protection_helpers_spec.cr",
    "content": "require \"../spec_helper\"\n\ninclude ContextHelper\n\nprivate class TestPage\n  include Lucky::HTMLPage\n\n  def render\n  end\nend\n\ndescribe Lucky::ForgeryProtectionHelpers do\n  it \"renders a hidden input\" do\n    context = build_context\n    context.session.set(Lucky::ProtectFromForgery::SESSION_KEY, \"my_token\")\n\n    view(context, &.csrf_hidden_input).should contain <<-HTML\n    <input type=\"hidden\" name=\"#{Lucky::ProtectFromForgery::PARAM_KEY}\" value=\"my_token\">\n    HTML\n  end\n\n  it \"renders a meta tag for Rails UJS (and other JS that may need it)\" do\n    context = build_context\n    context.session.set(Lucky::ProtectFromForgery::SESSION_KEY, \"my_token\")\n    rendered = view(context, &.csrf_meta_tags)\n\n    rendered.should contain <<-HTML\n    <meta name=\"csrf-param\" content=\"#{Lucky::ProtectFromForgery::PARAM_KEY}\">\n    HTML\n    rendered.should contain <<-HTML\n    <meta name=\"csrf-token\" content=\"my_token\">\n    HTML\n  end\nend\n\nprivate def view(context, &)\n  TestPage.new(context).tap do |page|\n    yield page\n  end.view.to_s\nend\n"
  },
  {
    "path": "spec/lucky/form_helpers_spec.cr",
    "content": "require \"../spec_helper\"\n\ninclude ContextHelper\n\nclass FormHelpers::Index < TestAction\n  get \"/form_helpers\" { plain_text \"foo \" }\nend\n\nclass FormHelpers::Update < TestAction\n  put \"/form_helpers/:id\" { plain_text \"foo \" }\nend\n\nclass FormHelpers::Create < TestAction\n  post \"/form_helpers\" { plain_text \"foo\" }\nend\n\nprivate class TestPage\n  include Lucky::HTMLPage\n\n  def render\n  end\n\n  def inferred_put_form\n    form_for FormHelpers::Update.with(\"fake_id\") do\n      text \"foo\"\n    end\n  end\n\n  def inferred_post_form\n    form_for FormHelpers::Create do\n      text \"foo\"\n    end\n  end\n\n  def inferred_get_form\n    form_for FormHelpers::Index do\n      text \"foo\"\n    end\n  end\n\n  def form_with_html_options\n    form_for FormHelpers::Create, class: \"cool-form\" do\n      text \"foo\"\n    end\n  end\n\n  def form_with_multipart\n    form_for FormHelpers::Create, multipart: true do\n      text \"foo\"\n    end\n  end\n\n  def form_with_multipart_false\n    form_for FormHelpers::Create, multipart: false do\n      text \"foo\"\n    end\n  end\n\n  def form_with_bool_attr\n    form_for FormHelpers::Create, attrs: [:novalidate], class: \"even-cooler-form\" do\n      text \"foo\"\n    end\n  end\n\n  def form_wrapper(action : Lucky::Action.class, &)\n    form_for action do\n      yield\n    end\n  end\nend\n\ndescribe Lucky::FormHelpers do\n  it \"renders a form tag\" do\n    without_csrf_protection do\n      view(&.inferred_put_form).should contain <<-HTML\n      <form action=\"/form_helpers/fake_id\" method=\"post\"><input type=\"hidden\" name=\"_method\" value=\"put\">foo</form>\n      HTML\n\n      view(&.inferred_post_form).should contain <<-HTML\n      <form action=\"/form_helpers\" method=\"post\">foo</form>\n      HTML\n\n      view(&.inferred_get_form).should contain <<-HTML\n      <form action=\"/form_helpers\" method=\"get\">foo</form>\n      HTML\n\n      view(&.form_with_html_options).should contain <<-HTML\n      <form action=\"/form_helpers\" method=\"post\" class=\"cool-form\">foo</form>\n      HTML\n\n      form = view(&.form_for(FormHelpers::Index) { })\n      form.should contain <<-HTML\n      <form action=\"/form_helpers\" method=\"get\"></form>\n      HTML\n\n      form = view(&.form_for(FormHelpers::Index, class: \"form-block\") { })\n      form.should contain <<-HTML\n      <form action=\"/form_helpers\" method=\"get\" class=\"form-block\"></form>\n      HTML\n\n      view(&.form_with_bool_attr).should contain <<-HTML\n      <form action=\"/form_helpers\" method=\"post\" class=\"even-cooler-form\" novalidate>foo</form>\n      HTML\n\n      form = view do |page|\n        page.form_wrapper(FormHelpers::Create) do\n          page.text(\"purple\")\n        end\n      end\n\n      form.should contain <<-HTML\n      <form action=\"/form_helpers\" method=\"post\">purple</form>\n      HTML\n    end\n  end\n\n  it \"protects the form with a CSRF token\" do\n    context_with_csrf = build_context\n    context_with_csrf.session.set(Lucky::ProtectFromForgery::SESSION_KEY, \"my_token\")\n\n    form = view(context_with_csrf, &.form_for(FormHelpers::Index) { })\n\n    form.should contain <<-HTML\n    <form action=\"/form_helpers\" method=\"get\"><input type=\"hidden\" name=\"#{Lucky::ProtectFromForgery::PARAM_KEY}\" value=\"my_token\"></form>\n    HTML\n  end\n\n  it \"converts the multipart argument\" do\n    without_csrf_protection do\n      view(&.form_with_multipart).should contain <<-HTML\n      <form action=\"/form_helpers\" method=\"post\" enctype=\"multipart/form-data\">foo</form>\n      HTML\n\n      view(&.form_with_multipart_false).should contain <<-HTML\n      <form action=\"/form_helpers\" method=\"post\">foo</form>\n      HTML\n    end\n  end\n\n  it \"renders submit input\" do\n    view(&.submit(\"Save\")).should contain <<-HTML\n    <input type=\"submit\" value=\"Save\">\n    HTML\n\n    view(&.submit(\"Save\", class: \"cool\")).should contain <<-HTML\n    <input type=\"submit\" value=\"Save\" class=\"cool\">\n    HTML\n  end\n\n  it \"renders submit input with attributes\" do\n    view(&.submit(\"Save\", attrs: [:disabled])).should contain <<-HTML\n    <input type=\"submit\" value=\"Save\" disabled>\n    HTML\n\n    view(&.submit(\"Save\", class: \"cool\", attrs: [:hidden, :disabled])).should contain <<-HTML\n    <input type=\"submit\" value=\"Save\" class=\"cool\" hidden disabled>\n    HTML\n  end\nend\n\nprivate def without_csrf_protection(&)\n  Lucky::FormHelpers.temp_config(include_csrf_tag: false) do\n    yield\n  end\nend\n\nprivate def view(context : HTTP::Server::Context = build_context, &)\n  TestPage.new(context).tap do |page|\n    yield page\n  end.view.to_s\nend\n"
  },
  {
    "path": "spec/lucky/format_edge_cases_spec.cr",
    "content": "require \"../spec_helper\"\n\ninclude ContextHelper\n\nprivate class EdgeCaseFormatAction < TestAction\n  accepted_formats [:html, :json, :csv], default: :html\n\n  get \"/test/:id\" do\n    plain_text \"format: #{clients_desired_format}, id: #{id}\"\n  end\nend\n\ndescribe \"Format Detection Edge Cases\" do\n  describe \"URL parsing edge cases\" do\n    it \"handles multiple dots in filename correctly\" do\n      # Should extract only the final extension\n      Lucky::MimeType.extract_format_from_path(\"/reports/file.backup.csv\").should eq Lucky::Format::Csv\n      Lucky::MimeType.extract_format_from_path(\"/data/export.final.json\").should eq Lucky::Format::Json\n      Lucky::MimeType.extract_format_from_path(\"/styles/theme.dark.css\").should eq Lucky::Format::Css\n    end\n\n    it \"handles edge case extensions\" do\n      # Empty extension\n      Lucky::MimeType.extract_format_from_path(\"/reports/file.\").should be_nil\n\n      # No extension\n      Lucky::MimeType.extract_format_from_path(\"/reports/file\").should be_nil\n\n      # Very long extension (should be nil since not registered)\n      Lucky::MimeType.extract_format_from_path(\"/file.superlongextension\").should be_nil\n\n      # Single character extensions\n      Lucky::MimeType.extract_format_from_path(\"/file.x\").should be_nil\n    end\n\n    it \"handles case sensitivity correctly\" do\n      # Extensions should be case insensitive\n      Lucky::MimeType.extract_format_from_path(\"/reports/file.CSV\").should eq Lucky::Format::Csv\n      Lucky::MimeType.extract_format_from_path(\"/reports/file.Json\").should eq Lucky::Format::Json\n      Lucky::MimeType.extract_format_from_path(\"/reports/file.HTML\").should eq Lucky::Format::Html\n      Lucky::MimeType.extract_format_from_path(\"/reports/file.XML\").should eq Lucky::Format::Xml\n    end\n\n    it \"handles complex query parameters\" do\n      Lucky::MimeType.extract_format_from_path(\"/reports/123.csv?foo=bar&baz=qux&nested[key]=value\").should eq Lucky::Format::Csv\n      Lucky::MimeType.extract_format_from_path(\"/api/data.json?callback=func&v=1.0\").should eq Lucky::Format::Json\n      Lucky::MimeType.extract_format_from_path(\"/file.html?\").should eq Lucky::Format::Html\n      Lucky::MimeType.extract_format_from_path(\"/file.xml?#fragment\").should eq Lucky::Format::Xml\n    end\n\n    it \"does not match extensions in query string domains\" do\n      # Should not detect .com, .org, .net, etc. from domains in query strings\n      Lucky::MimeType.extract_format_from_path(\"/path/report.csv?affiliate=site.com/path.html\").should eq Lucky::Format::Csv\n      Lucky::MimeType.extract_format_from_path(\"/login?redirect_to=https://example.com\").should be_nil\n      Lucky::MimeType.extract_format_from_path(\"/api/data?url=http://site.org/file.pdf\").should be_nil\n      Lucky::MimeType.extract_format_from_path(\"/report?source=domain.net&format=json\").should be_nil\n\n      # Should still match valid extensions before the query string\n      Lucky::MimeType.extract_format_from_path(\"/file.json?redirect=site.com\").should eq Lucky::Format::Json\n      Lucky::MimeType.extract_format_from_path(\"/data.xml?callback=example.org/api\").should eq Lucky::Format::Xml\n    end\n\n    it \"handles special characters and edge case paths\" do\n      # URL encoded extensions\n      Lucky::MimeType.extract_format_from_path(\"/reports/file%2Ecsv\").should be_nil # encoded dot\n\n      # Paths that look like extensions but aren't\n      Lucky::MimeType.extract_format_from_path(\"/reports/.csv\").should eq Lucky::Format::Csv # hidden file with extension\n      Lucky::MimeType.extract_format_from_path(\"/reports/csv\").should be_nil                 # no dot\n      Lucky::MimeType.extract_format_from_path(\"/reports.csv/file\").should be_nil            # extension in directory name\n    end\n\n    it \"handles unusual but valid paths\" do\n      # Root level files\n      Lucky::MimeType.extract_format_from_path(\"/file.json\").should eq Lucky::Format::Json\n\n      # Deep nested paths\n      Lucky::MimeType.extract_format_from_path(\"/very/deep/nested/path/to/file.csv\").should eq Lucky::Format::Csv\n\n      # Files with numbers and special chars in name\n      Lucky::MimeType.extract_format_from_path(\"/file-123_test.json\").should eq Lucky::Format::Json\n      Lucky::MimeType.extract_format_from_path(\"/file%20name.csv\").should eq Lucky::Format::Csv\n    end\n  end\n\n  describe \"Route matching edge cases\" do\n    it \"handles routes with parameters that contain dots\" do\n      context = build_context(path: \"/test/user.email@example.com.json\")\n      context._url_format = Lucky::Format::Json\n\n      action = EdgeCaseFormatAction.new(context, {\"id\" => \"user.email@example.com\"})\n      action.accepts?(:json).should be_true\n      action.url_format.should eq Lucky::Format::Json\n    end\n\n    it \"handles very long URLs with formats\" do\n      long_id = \"a\" * 1000\n      path = \"/test/#{long_id}.csv\"\n\n      Lucky::MimeType.extract_format_from_path(path).should eq Lucky::Format::Csv\n    end\n\n    it \"handles Unicode and international characters\" do\n      # These should not break the format detection (though they won't match known formats)\n      Lucky::MimeType.extract_format_from_path(\"/file.файл\").should be_nil\n      Lucky::MimeType.extract_format_from_path(\"/file.josé\").should be_nil\n      Lucky::MimeType.extract_format_from_path(\"/file.日本\").should be_nil\n    end\n  end\n\n  describe \"Format validation edge cases\" do\n    it \"handles URL format not in accepted formats\" do\n      context = build_context(path: \"/test/123.xml\")\n      context._url_format = Lucky::Format::Xml\n\n      action = EdgeCaseFormatAction.new(context, {\"id\" => \"123\"})\n\n      # Should detect XML format but action only accepts html, json, csv\n      action.url_format.should eq Lucky::Format::Xml\n      action.accepts?(:xml).should be_true # This will be true since clients_desired_format returns the URL format\n\n      # But the action's accepted formats validation should catch this\n      # (This would be caught by Lucky's existing format validation system)\n    end\n\n    it \"handles format precedence correctly\" do\n      context = build_context(path: \"/test/123.csv\")\n      context.request.headers[\"Accept\"] = \"application/json, text/html\"\n      context._url_format = Lucky::Format::Csv\n\n      action = EdgeCaseFormatAction.new(context, {\"id\" => \"123\"})\n\n      # URL format should take precedence over Accept header\n      action.accepts?(:csv).should be_true\n      action.accepts?(:json).should be_false\n      action.accepts?(:html).should be_false\n    end\n\n    it \"handles missing Accept header gracefully\" do\n      context = build_context(path: \"/test/123\")\n      # Don't set Accept header at all\n      context.request.headers.delete(\"Accept\")\n\n      action = EdgeCaseFormatAction.new(context, {\"id\" => \"123\"})\n\n      # Should fall back to default format\n      action.accepts?(:html).should be_true # html is the default\n    end\n  end\n\n  describe \"Custom format edge cases\" do\n    it \"handles custom format registration and detection\" do\n      # Register a custom format\n      Lucky::FormatRegistry.register(\"PDF\", \"pdf\", \"application/pdf\")\n\n      # Should detect the custom format\n      format = Lucky::MimeType.extract_format_from_path(\"/report.pdf\")\n      format.should be_a(Lucky::FormatRegistry::CustomFormat)\n\n      if custom_format = format.as?(Lucky::FormatRegistry::CustomFormat)\n        custom_format.name.should eq \"PDF\"\n        custom_format.extension.should eq \"pdf\"\n        custom_format.mime_type.should eq \"application/pdf\"\n      end\n\n      # Clean up\n      Lucky::FormatRegistry.custom_formats.delete(\"PDF\")\n    end\n\n    it \"handles multiple custom formats with different extensions\" do\n      # Register different custom formats\n      Lucky::FormatRegistry.register(\"PDF\", \"pdf\", \"application/pdf\")\n      Lucky::FormatRegistry.register(\"DOC\", \"doc\", \"application/msword\")\n\n      pdf_format = Lucky::MimeType.extract_format_from_path(\"/report.pdf\")\n      doc_format = Lucky::MimeType.extract_format_from_path(\"/document.doc\")\n\n      pdf_format.should be_a(Lucky::FormatRegistry::CustomFormat)\n      doc_format.should be_a(Lucky::FormatRegistry::CustomFormat)\n\n      if pdf_custom = pdf_format.as?(Lucky::FormatRegistry::CustomFormat)\n        pdf_custom.name.should eq \"PDF\"\n      end\n\n      if doc_custom = doc_format.as?(Lucky::FormatRegistry::CustomFormat)\n        doc_custom.name.should eq \"DOC\"\n      end\n\n      # Clean up\n      Lucky::FormatRegistry.custom_formats.delete(\"PDF\")\n      Lucky::FormatRegistry.custom_formats.delete(\"DOC\")\n    end\n  end\n\n  describe \"HTTP header edge cases\" do\n    it \"handles malformed Accept headers gracefully when URL format is present\" do\n      context = build_context(path: \"/test/123\")\n      context.request.headers[\"Accept\"] = \"invalid-header-format\"\n      context._url_format = Lucky::Format::Json # Set URL format to trigger graceful handling\n\n      action = EdgeCaseFormatAction.new(context, {\"id\" => \"123\"})\n\n      # Should use URL format, not fall back to malformed Accept header\n      action.accepts?(:json).should be_true\n    end\n\n    it \"raises error for malformed Accept headers when no URL format\" do\n      context = build_context(path: \"/test/123\")\n      context.request.headers[\"Accept\"] = \"invalid-header-format\"\n      # Don't set URL format - should preserve original Lucky behavior\n\n      action = EdgeCaseFormatAction.new(context, {\"id\" => \"123\"})\n\n      # Should raise error as in original Lucky behavior\n      expect_raises Lucky::UnknownAcceptHeaderError do\n        action.accepts?(:html)\n      end\n    end\n\n    it \"handles very complex Accept headers\" do\n      context = build_context(path: \"/test/123\")\n      complex_accept = \"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7\"\n      context.request.headers[\"Accept\"] = complex_accept\n\n      action = EdgeCaseFormatAction.new(context, {\"id\" => \"123\"})\n\n      # Should parse the complex header correctly\n      action.accepts?(:html).should be_true\n    end\n\n    it \"handles empty Accept header\" do\n      context = build_context(path: \"/test/123\")\n      context.request.headers[\"Accept\"] = \"\"\n\n      action = EdgeCaseFormatAction.new(context, {\"id\" => \"123\"})\n\n      # Should fall back to default\n      action.accepts?(:html).should be_true\n    end\n  end\n\n  describe \"Performance and security edge cases\" do\n    it \"handles extremely long paths efficiently\" do\n      # Very long path with format at the end\n      long_path = \"/very/long/path/\" + (\"segment/\" * 1000) + \"file.json\"\n\n      # Should still extract format efficiently\n      start_time = {% if compare_versions(Crystal::VERSION, \"1.19.0\") < 0 %}Time.monotonic{% else %}Time.instant{% end %}\n      format = Lucky::MimeType.extract_format_from_path(long_path)\n      end_time = {% if compare_versions(Crystal::VERSION, \"1.19.0\") < 0 %}Time.monotonic{% else %}Time.instant{% end %}\n\n      format.should eq Lucky::Format::Json\n      (end_time - start_time).should be < 0.1.seconds # Should be very fast\n    end\n\n    it \"handles path traversal attempts safely\" do\n      # These should not cause security issues, just fail format detection\n      Lucky::MimeType.extract_format_from_path(\"../../../etc/passwd.csv\").should eq Lucky::Format::Csv\n      Lucky::MimeType.extract_format_from_path(\"/reports/../admin.json\").should eq Lucky::Format::Json\n      Lucky::MimeType.extract_format_from_path(\"/reports/..%2F..%2Fadmin.xml\").should eq Lucky::Format::Xml\n    end\n\n    it \"handles null bytes and control characters\" do\n      # Should not crash on unusual input\n      Lucky::MimeType.extract_format_from_path(\"/file\\u0000.csv\").should eq Lucky::Format::Csv\n      Lucky::MimeType.extract_format_from_path(\"/file\\t.json\").should eq Lucky::Format::Json\n      Lucky::MimeType.extract_format_from_path(\"/file\\n.xml\").should eq Lucky::Format::Xml\n    end\n  end\n\n  describe \"Integration edge cases\" do\n    it \"works with nil context gracefully\" do\n      # This tests that we don't crash if context is somehow nil\n      # (Though this shouldn't happen in practice)\n      EdgeCaseFormatAction.allocate\n\n      # Should not crash when trying to access format methods\n      # (This would be caught by Crystal's type system anyway)\n    end\n\n    it \"handles concurrent format detection\" do\n      # Test that format detection is thread-safe\n      contexts = [] of HTTP::Server::Context\n\n      10.times do |i|\n        context = build_context(path: \"/test/#{i}.json\")\n        context._url_format = Lucky::Format::Json\n        contexts << context\n      end\n\n      # All should detect JSON format correctly\n      contexts.each do |context|\n        action = EdgeCaseFormatAction.new(context, {\"id\" => \"test\"})\n        action.accepts?(:json).should be_true\n      end\n    end\n  end\nend\n"
  },
  {
    "path": "spec/lucky/format_integration_spec.cr",
    "content": "require \"../spec_helper\"\n\ninclude ContextHelper\n\nprivate class TestReportsAction < TestAction\n  accepted_formats [:html, :json, :csv], default: :html\n\n  get \"/reports/:id\" do\n    case clients_desired_format\n    when :html\n      plain_text \"HTML Report for #{id}\"\n    when :json\n      plain_text %({\"report_id\": \"#{id}\", \"format\": \"json\"})\n    when :csv\n      plain_text \"id,name\\n#{id},Test Report\"\n    else\n      plain_text \"Unknown format\"\n    end\n  end\nend\n\ndescribe \"Format Integration\" do\n  it \"handles URL format extensions correctly\" do\n    # Test CSV format from URL extension\n    context = build_context(path: \"/reports/123.csv\")\n\n    # The route handler should extract the format and strip it for route matching\n    Lucky::RouteHandler.new.call(context)\n\n    # Verify the format was extracted\n    context._url_format.should eq(Lucky::Format::Csv)\n  end\n\n  it \"routes correctly with format stripped from path\" do\n    # Test that /reports/123.csv routes to /reports/:id\n    context = build_context(path: \"/reports/123.csv\")\n\n    # Manually set the URL format as the route handler would\n    context._url_format = Lucky::Format::Csv\n\n    action = TestReportsAction.new(context, {\"id\" => \"123\"})\n\n    # Should detect CSV format from URL\n    action.accepts?(:csv).should be_true\n    action.accepts?(:html).should be_false\n  end\n\n  it \"falls back to Accept header when no URL format\" do\n    context = build_context(path: \"/reports/123\")\n    context.request.headers[\"Accept\"] = \"application/json\"\n    action = TestReportsAction.new(context, {\"id\" => \"123\"})\n\n    # Should detect JSON format from Accept header\n    action.accepts?(:json).should be_true\n    action.accepts?(:csv).should be_false\n  end\n\n  # testing https://github.com/luckyframework/lucky/issues/1999\n  it \"doesn't modify the original path\" do\n    context = build_context(path: \"/js/main.js\")\n    handler = Lucky::RouteHandler.new\n    handler.next = ->(ctx : HTTP::Server::Context) {\n      ctx.request.path.should eq(\"/js/main.js\")\n    }\n    result = handler.call(context)\n    result.should eq(nil)\n  end\n\n  it \"supports multiple format extensions\" do\n    Lucky::MimeType.extract_format_from_path(\"/reports/123.html\").should eq(Lucky::Format::Html)\n    Lucky::MimeType.extract_format_from_path(\"/users/456.json\").should eq(Lucky::Format::Json)\n    Lucky::MimeType.extract_format_from_path(\"/data/export.xml\").should eq(Lucky::Format::Xml)\n    Lucky::MimeType.extract_format_from_path(\"/styles/main.css\").should eq(Lucky::Format::Css)\n  end\nend\n"
  },
  {
    "path": "spec/lucky/html_page_spec.cr",
    "content": "require \"../spec_helper\"\n\ninclude ContextHelper\n\nclass TestRender\n  include Lucky::HTMLPage\n\n  def render : String\n    render_complicated_html\n    view.to_s\n  end\n\n  private def render_complicated_html\n    header({class: \"header\"}) do\n      style \"body { font-size: 2em; }\"\n      text \"my text\"\n      h1 \"h1\"\n      br\n      div class: \"empty-contents\"\n      br({class: \"br\"})\n      br class: \"br\"\n      img({src: \"src\"})\n      h2 \"A bit smaller\", {class: \"peculiar\"}\n      h6 class: \"h6\" do\n        small \"super tiny\", class: \"so-small\"\n        span \"wow\"\n      end\n    end\n  end\nend\n\nclass UnsafePage\n  include Lucky::HTMLPage\n\n  def render\n    text \"<script>not safe</span>\"\n    view.to_s\n  end\nend\n\nabstract class MainLayout\n  include Lucky::HTMLPage\n\n  def render\n    title page_title\n\n    body do\n      inner\n    end\n    view.to_s\n  end\n\n  abstract def inner\n  abstract def page_title\nend\n\nclass InnerPage < MainLayout\n  needs foo : String\n\n  def inner\n    text \"Inner text\"\n    text @foo\n  end\n\n  def page_title\n    \"A great title\"\n  end\nend\n\nclass LessNeedyDefaultsPage < MainLayout\n  needs a_string : String = \"string default\"\n  needs bool : Bool = false\n  needs nil_default : String? = nil\n  needs inferred_nil_default : String?\n  needs inferred_nil_default2 : String | Nil\n\n  def inner\n    div @a_string\n    div(\"bool default\") if @bool == false\n    div(\"nil default\") if @nil_default.nil?\n    div(\"inferred nil default\") if @inferred_nil_default.nil?\n    div(\"inferred nil default 2\") if @inferred_nil_default2.nil?\n  end\n\n  def page_title\n    \"Boolean Default\"\n  end\nend\n\ndescribe Lucky::HTMLPage do\n  describe \"tags that contain contents\" do\n    it \"can be called with various arguments\" do\n      view(&.header(\"text\")).should eq %(<header>text</header>)\n      view(&.header(\"text\", {class: \"stuff\"})).should eq %(<header class=\"stuff\">text</header>)\n      view(&.header(\"text\", class: \"stuff\")).should eq %(<header class=\"stuff\">text</header>)\n    end\n\n    it \"dasherizes attribute names\" do\n      view(&.header(\"text\", data_foo: \"stuff\")).should eq %(<header data-foo=\"stuff\">text</header>)\n    end\n  end\n\n  describe \"empty tags\" do\n    it \"can be called with various arguments\" do\n      view(&.br).should eq %(<br>)\n      view(&.img(src: \"my_src\")).should eq %(<img src=\"my_src\">)\n      view(&.img({src: \"my_src\"})).should eq %(<img src=\"my_src\">)\n      view(&.img({:src => \"my_src\"})).should eq %(<img src=\"my_src\">)\n    end\n  end\n\n  describe \"HTML escaping\" do\n    it \"escapes text\" do\n      UnsafePage.new(build_context).render.should eq \"&lt;script&gt;not safe&lt;/span&gt;\"\n    end\n\n    it \"escapes HTML attributes\" do\n      unsafe = \"<span>bad news</span>\"\n      escaped = \"&lt;span&gt;bad news&lt;/span&gt;\"\n      view(&.img(src: unsafe)).should eq %(<img src=\"#{escaped}\">)\n      view(&.img({src: unsafe})).should eq %(<img src=\"#{escaped}\">)\n      view(&.img({:src => unsafe})).should eq %(<img src=\"#{escaped}\">)\n    end\n  end\n\n  it \"renders complicated HTML syntax\" do\n    TestRender.new(build_context).render.should be_a(String)\n  end\n\n  it \"can render raw strings\" do\n    view(&.raw(\"<safe>\")).should eq \"<safe>\"\n  end\n\n  describe \"can be used to render layouts\" do\n    it \"renders layouts and needs\" do\n      InnerPage.new(build_context, foo: \"bar\").render.should contain %(<title>A great title</title>)\n      InnerPage.new(build_context, foo: \"bar\").render.should contain %(<body>Inner textbar</body>)\n    end\n  end\n\n  describe \"needs with defaults\" do\n    it \"allows default values to needs\" do\n      LessNeedyDefaultsPage.new(build_context).render.should contain %(<div>string default</div>)\n    end\n\n    it \"allows false as default value to needs\" do\n      LessNeedyDefaultsPage.new(build_context).render.should contain %(<div>bool default</div>)\n    end\n\n    it \"allows nil as default value to needs\" do\n      LessNeedyDefaultsPage.new(build_context).render.should contain %(<div>nil default</div>)\n    end\n\n    it \"infers the default value from nilable needs\" do\n      LessNeedyDefaultsPage.new(build_context).render.should contain %(<div>inferred nil default</div>)\n    end\n\n    it \"infers the default value from nilable needs\" do\n      LessNeedyDefaultsPage.new(build_context).render.should contain %(<div>inferred nil default 2</div>)\n    end\n  end\n\n  it \"accepts extra arguments so pages are more flexible with exposures\" do\n    InnerPage.new(build_context, foo: \"bar\", ignore_me: true)\n  end\nend\n\nprivate def view(&)\n  TestRender.new(build_context).tap do |page|\n    yield page\n  end.view.to_s\nend\n"
  },
  {
    "path": "spec/lucky/http_method_override_handler_spec.cr",
    "content": "require \"../spec_helper\"\n\ninclude ContextHelper\n\ndescribe Lucky::HttpMethodOverrideHandler do\n  describe \"#call\" do\n    it \"leaves GET and POST as-is\" do\n      should_handle \"GET\", overridden_method: \"\", and_return: \"GET\"\n      should_handle \"POST\", overridden_method: \"\", and_return: \"POST\"\n    end\n\n    it \"overrides when POST with overridden PATCH, PUT or DELETE\" do\n      should_handle \"POST\", overridden_method: \"patch\", and_return: \"PATCH\"\n      should_handle \"POST\", overridden_method: \"put\", and_return: \"PUT\"\n      should_handle \"POST\", overridden_method: \"delete\", and_return: \"DELETE\"\n    end\n\n    it \"leaves as-is when GET with overridden method\" do\n      should_handle \"GET\", overridden_method: \"delete\", and_return: \"GET\"\n    end\n\n    it \"works when there is no overridden method\" do\n      should_handle \"GET\", overridden_method: nil, and_return: \"GET\"\n    end\n\n    it \"continues if request body contains malformed json\" do\n      request = build_request \"GET\", body: \"{ \\\"bad_json\\\": 123\", content_type: \"application/json\"\n\n      Lucky::HttpMethodOverrideHandler.new.call(build_context(request: request))\n\n      request.method.should eq \"GET\"\n    end\n  end\nend\n\nprivate def should_handle(original_method, overridden_method, and_return expected_method)\n  request = if overridden_method\n              build_request original_method, body: \"_method=#{overridden_method}\"\n            else\n              build_request original_method\n            end\n  Lucky::HttpMethodOverrideHandler.new.call(build_context(request: request))\n  request.method.should eq expected_method\nend\n"
  },
  {
    "path": "spec/lucky/infer_page_spec.cr",
    "content": "require \"../spec_helper\"\n\nclass Rendering::CustomPage\n  include Lucky::HTMLPage\n\n  needs title : String\n  needs arg2 : String\n\n  def render\n    text @title\n  end\nend\n\nclass Rendering::Foo < TestAction\n  get \"/rendering_foo\" do\n    html Rendering::CustomPage, title: \"EditPage\", arg2: \"testing_multiple_args\"\n  end\nend\n\nclass Rendering::WithinSameNameSpace < TestAction\n  get \"/in_namespace\" do\n    html CustomPage, title: \"WithinSameNameSpace\", arg2: \"testing_multiple_args\"\n  end\nend\n\ndescribe Lucky::Action do\n  it \"renders fully qualified pages\" do\n    body = Rendering::Foo.new(build_context, params).call.body.to_s\n\n    body.should contain \"EditPage\"\n  end\n\n  it \"renders within the same namespace\" do\n    body = Rendering::WithinSameNameSpace.new(build_context, params).call.body.to_s\n\n    body.should contain \"WithinSameNameSpace\"\n  end\nend\n"
  },
  {
    "path": "spec/lucky/link_helpers_spec.cr",
    "content": "require \"../spec_helper\"\n\nclass LinkHelpers::Index < TestAction\n  get \"/link_helpers\" { plain_text \"foo\" }\nend\n\nclass LinkHelpers::Create < TestAction\n  post \"/link_helpers\" { plain_text \"foo\" }\nend\n\nprivate class TestPage\n  include Lucky::HTMLPage\n\n  def render\n  end\n\n  def http_get_route\n    link \"Test\", to: LinkHelpers::Index\n  end\n\n  def non_http_get_route\n    link \"Test\", to: LinkHelpers::Create\n  end\n\n  def non_http_get_route_with_options\n    link \"Test\", to: LinkHelpers::Create, something_custom: \"foo\"\n  end\n\n  def http_get_route_with_block\n    link to: LinkHelpers::Index do\n      text \"Hello\"\n    end\n  end\n\n  def http_get_route_without_text\n    link to: LinkHelpers::Index\n  end\n\n  def http_get_route_with_text_and_attrs\n    link \"Text\", to: LinkHelpers::Index, attrs: [:disabled]\n  end\n\n  def http_get_route_with_attrs_no_text\n    link to: LinkHelpers::Index, attrs: [:disabled]\n  end\n\n  def http_get_route_with_block_and_attrs\n    link to: LinkHelpers::Index, attrs: [:disabled] do\n      text \"Hello\"\n    end\n  end\nend\n\ndescribe Lucky::LinkHelpers do\n  it \"renders a link tag\" do\n    view(&.http_get_route).should contain %(<a href=\"/link_helpers\">Test</a>)\n    view(&.non_http_get_route).should contain %(<a href=\"/link_helpers\" data-method=\"post\">Test</a>)\n    view(&.non_http_get_route_with_options)\n      .should contain %(<a href=\"/link_helpers\" data-method=\"post\" something-custom=\"foo\">Test</a>)\n  end\n\n  it \"renders a link tag with an action\" do\n    view(&.link(\"Test\", to: LinkHelpers::Index)).should contain <<-HTML\n    <a href=\"/link_helpers\">Test</a>\n    HTML\n\n    link = view(&.link(to: LinkHelpers::Index, class: \"link\") { })\n\n    link.should contain <<-HTML\n    <a href=\"/link_helpers\" class=\"link\"></a>\n    HTML\n  end\n\n  it \"renders a link tag with a block\" do\n    view(&.http_get_route_with_block).should contain <<-HTML\n    <a href=\"/link_helpers\">Hello</a>\n    HTML\n  end\n\n  it \"renders a link tag without text\" do\n    view(&.http_get_route_without_text).should contain <<-HTML\n    <a href=\"/link_helpers\"></a>\n    HTML\n  end\n\n  it \"renders a link with uuid\" do\n    uuid = UUID.random\n    view(&.link uuid, to: LinkHelpers::Index).should contain \"<a href=\\\"/link_helpers\\\">#{uuid}</a>\"\n  end\n\n  it \"renders a link with a special data attribute\" do\n    view(&.link(to: LinkHelpers::Index, \"data-is-useless\": true)).should contain <<-HTML\n    <a href=\"/link_helpers\" data-is-useless=\"true\"></a>\n    HTML\n\n    view(&.link(to: LinkHelpers::Index, \"data-num\": 4)).should contain <<-HTML\n    <a href=\"/link_helpers\" data-num=\"4\"></a>\n    HTML\n  end\n\n  it \"renders a link with boolean attrs\" do\n    view(&.http_get_route_with_text_and_attrs).should contain <<-HTML\n    <a href=\"/link_helpers\" disabled>Text</a>\n    HTML\n\n    view(&.http_get_route_with_attrs_no_text).should contain <<-HTML\n    <a href=\"/link_helpers\" disabled></a>\n    HTML\n\n    view(&.http_get_route_with_block_and_attrs).should contain <<-HTML\n    <a href=\"/link_helpers\" disabled>Hello</a>\n    HTML\n  end\nend\n\nprivate def view(&)\n  TestPage.new(build_context).tap do |page|\n    yield page\n  end.view.to_s\nend\n"
  },
  {
    "path": "spec/lucky/log_handler_spec.cr",
    "content": "require \"../spec_helper\"\nrequire \"http/server\"\n\ninclude ContextHelper\n\ndescribe Lucky::LogHandler do\n  it \"logs the start and end of the request\" do\n    called = false\n    log_io = IO::Memory.new\n    context = build_context_with_io(log_io)\n\n    call_log_handler_with(log_io, context) { called = true }\n\n    log_output = log_io.to_s\n    log_output.should contain(%(\"method\" => \"GET\"))\n    log_output.should contain(%(\"path\" => \"/\"))\n    log_output.should contain(%(\"status\" => 200))\n    log_output.should contain(%(\"duration\"))\n    called.should be_true\n  end\n\n  it \"skips logging if skip_if function returns true\" do\n    Lucky::LogHandler.temp_config(skip_if: ->(_context : HTTP::Server::Context) { true }) do\n      called = false\n      log_io = IO::Memory.new\n      context = build_context_with_io(log_io)\n\n      call_log_handler_with(log_io, context) { called = true }\n\n      log_io.to_s.should eq(\"\")\n      called.should be_true\n    end\n  end\n\n  it \"logs errors\" do\n    log_io = IO::Memory.new\n    context = build_context_with_io(log_io)\n\n    expect_raises(Exception, \"an error\") do\n      call_log_handler_with(log_io, context) { raise \"an error\" }\n    end\n    log_output = log_io.to_s\n    log_output.should contain(\"an error\")\n  end\n\n  it \"publishes the request_complete_event\" do\n    Lucky::Events::RequestCompleteEvent.subscribe do |event|\n      event.duration.should_not be_nil\n    end\n\n    called = false\n    log_io = IO::Memory.new\n    context = build_context_with_io(log_io)\n\n    call_log_handler_with(log_io, context) { called = true }\n\n    called.should be_true\n  end\nend\n\nprivate def call_log_handler_with(io : IO, context : HTTP::Server::Context, &block)\n  Lucky::Log.dexter.temp_config(io) do\n    handler = Lucky::LogHandler.new\n    handler.next = ->(_ctx : HTTP::Server::Context) { block.call }\n    handler.call(context)\n  end\nend\n"
  },
  {
    "path": "spec/lucky/maximum_request_size_handler_spec.cr",
    "content": "require \"../spec_helper\"\nrequire \"http/server\"\n\ninclude ContextHelper\n\ndescribe Lucky::MaximumRequestSizeHandler do\n  context \"when the handler is disabled\" do\n    it \"simply serves the request\" do\n      context = build_small_request_context(\"/path\")\n      Lucky::MaximumRequestSizeHandler.temp_config(enabled: false) do\n        run_request_size_handler(context)\n      end\n      context.response.status.should eq(HTTP::Status::OK)\n    end\n  end\n\n  context \"when the handler is enabled\" do\n    it \"with a small request, serve the request\" do\n      context = build_small_request_context(\"/path\")\n      Lucky::MaximumRequestSizeHandler.temp_config(enabled: true) do\n        run_request_size_handler(context)\n      end\n      context.response.status.should eq(HTTP::Status::OK)\n    end\n\n    it \"with a large request, deny the request\" do\n      context = build_large_request_context(\"/path\")\n      Lucky::MaximumRequestSizeHandler.temp_config(\n        enabled: true,\n        max_size: 10,\n      ) do\n        run_request_size_handler(context)\n      end\n      context.response.status.should eq(HTTP::Status::PAYLOAD_TOO_LARGE)\n    end\n\n    it \"allows larger request bodies for specific actions\" do\n      context = build_request_context_with_body(\"/__max_request_size/large\", 50_000, \"POST\")\n      Lucky::MaximumRequestSizeHandler.temp_config(\n        enabled: true,\n        max_size: 10_000,\n      ) do\n        run_request_size_handler(context)\n      end\n      context.response.status.should eq(HTTP::Status::OK)\n    end\n\n    it \"enforces smaller limits on specific actions\" do\n      context = build_request_context_with_body(\"/__max_request_size/small\", 1_000, \"POST\")\n      Lucky::MaximumRequestSizeHandler.temp_config(\n        enabled: true,\n        max_size: 10_000,\n      ) do\n        run_request_size_handler(context)\n      end\n      context.response.status.should eq(HTTP::Status::PAYLOAD_TOO_LARGE)\n    end\n  end\nend\n\nprivate def run_request_size_handler(context)\n  handler = Lucky::MaximumRequestSizeHandler.new\n  handler.next = ->(_ctx : HTTP::Server::Context) { }\n  handler.call(context)\nend\n\nprivate def build_small_request_context(path : String) : HTTP::Server::Context\n  build_context(path: path)\nend\n\nprivate def build_large_request_context(path : String) : HTTP::Server::Context\n  build_context(path: path).tap do |context|\n    context.request.headers[\"Content-Length\"] = \"1000000\"\n    context.request.body = \"a\" * 1000000\n  end\nend\n\nprivate def build_request_context_with_body(path : String, bytes : Int32, method : String = \"POST\") : HTTP::Server::Context\n  request = HTTP::Request.new(method, path)\n  request.headers[\"Content-Length\"] = bytes.to_s\n  request.body = \"a\" * bytes\n  build_context(path: path, request: request)\nend\n\nprivate class LargeUploadAction < Lucky::Action\n  set_request_body_limit 50_000\n\n  def call : Lucky::Response\n    plain_text \"ok\"\n  end\nend\n\nprivate class SmallUploadAction < Lucky::Action\n  set_request_body_limit 500\n\n  def call : Lucky::Response\n    plain_text \"ok\"\n  end\nend\n\nLucky.router.add :post, \"/__max_request_size/large\", LargeUploadAction\nLucky.router.add :post, \"/__max_request_size/small\", SmallUploadAction\n"
  },
  {
    "path": "spec/lucky/memoize_spec.cr",
    "content": "require \"../spec_helper\"\n\ninclude ContextHelper\n\n# NOTE: The Memoizable module is included in the Object charm\n# which gives all Objects access to `memoize`\nprivate class ObjectWithMemoizedMethods\n  getter times_method_1_called = 0\n  getter times_method_2_called = 0\n  getter times_method_3_called = 0\n  getter times_method_4_called = 0\n  getter times_method_5_called = 0\n\n  memoize def method_1 : String\n    @times_method_1_called += 1\n    \"method_1\"\n  end\n\n  memoize def method_2 : Int32?\n    @times_method_2_called += 1\n    nil\n  end\n\n  memoize def method_3(arg_a : String, arg_b : String = \"default-arg-b\") : String\n    @times_method_3_called += 1\n    arg_a + \", \" + arg_b\n  end\n\n  memoize def method_4? : Bool\n    @times_method_4_called += 1\n    true\n  end\n\n  memoize def method_5! : String\n    @times_method_5_called += 1\n    \"Boom!\"\n  end\n\n  memoize def method_6(for string : String) : String\n    string.upcase\n  end\nend\n\ndescribe \"memoizations\" do\n  it \"only calls the method body once\" do\n    object = ObjectWithMemoizedMethods.new\n\n    object.method_1.should eq \"method_1\"\n    2.times { object.method_1.should eq(\"method_1\") }\n    object.times_method_1_called.should eq 1\n  end\n\n  it \"can cache a nil result\" do\n    object = ObjectWithMemoizedMethods.new\n\n    object.method_2.should be_nil\n    2.times { object.method_2.should be_nil }\n    object.times_method_2_called.should eq 1\n  end\n\n  it \"caches based on argument equality\" do\n    object = ObjectWithMemoizedMethods.new\n\n    object.method_3(\"arg-a\", \"arg-b\").should eq(\"arg-a, arg-b\")\n    2.times { object.method_3(\"arg-a\", \"arg-b\").should eq(\"arg-a, arg-b\") }\n    object.times_method_3_called.should eq 1\n\n    object.method_3(\"arg-a\", \"arg-c\").should eq(\"arg-a, arg-c\")\n    2.times { object.method_3(\"arg-a\", \"arg-c\").should eq(\"arg-a, arg-c\") }\n    object.times_method_3_called.should eq 2\n  end\n\n  it \"handles default arguments\" do\n    object = ObjectWithMemoizedMethods.new\n\n    object.method_3(\"arg-a\", \"default-arg-b\").should eq(\"arg-a, default-arg-b\")\n    object.method_3(\"arg-a\", \"default-arg-b\").should eq(\"arg-a, default-arg-b\")\n    object.method_3(\"arg-a\").should eq(\"arg-a, default-arg-b\")\n    object.times_method_3_called.should eq 1\n  end\n\n  it \"handles calling with named arguments\" do\n    object = ObjectWithMemoizedMethods.new\n\n    object.method_3(\"arg-a\", \"arg-b\").should eq(\"arg-a, arg-b\")\n    object.method_3(\"arg-a\", arg_b: \"arg-b\").should eq(\"arg-a, arg-b\")\n    object.method_3(arg_a: \"arg-a\", arg_b: \"arg-b\").should eq(\"arg-a, arg-b\")\n    object.method_3(arg_b: \"arg-b\", arg_a: \"arg-a\").should eq(\"arg-a, arg-b\")\n    object.times_method_3_called.should eq 1\n  end\n\n  it \"does not hold on to result of previous calls\" do\n    object = ObjectWithMemoizedMethods.new\n\n    object.method_3(\"arg-a\", \"arg-b\").should eq(\"arg-a, arg-b\")\n    object.method_3(\"arg-a\", \"arg-c\").should eq(\"arg-a, arg-c\")\n    object.method_3(\"arg-a\", \"arg-b\").should eq(\"arg-a, arg-b\")\n    object.times_method_3_called.should eq 3\n  end\n\n  it \"works with predicate methods\" do\n    object = ObjectWithMemoizedMethods.new\n\n    object.method_4?.should eq(true)\n    object.method_4?.should eq(true)\n    object.method_4?.should eq(true)\n    object.times_method_4_called.should eq(1)\n  end\n\n  it \"Works with bang methods\" do\n    object = ObjectWithMemoizedMethods.new\n\n    object.method_5!.should eq(\"Boom!\")\n    object.method_5!.should eq(\"Boom!\")\n    object.method_5!.should eq(\"Boom!\")\n    object.times_method_5_called.should eq(1)\n  end\n\n  it \"calls uncached with predicate and bang methods\" do\n    object = ObjectWithMemoizedMethods.new\n\n    object.method_4__uncached?.should eq(true)\n    object.method_4__uncached?.should eq(true)\n    object.method_4__uncached?.should eq(true)\n    object.times_method_4_called.should eq(3)\n\n    object.method_5__uncached!.should eq(\"Boom!\")\n    object.method_5__uncached!.should eq(\"Boom!\")\n    object.method_5__uncached!.should eq(\"Boom!\")\n    object.times_method_5_called.should eq(3)\n  end\n\n  it \"allows for external arg names\" do\n    object = ObjectWithMemoizedMethods.new\n\n    object.method_6(\"boom\").should eq(\"BOOM\")\n    object.method_6(for: \"memoize\").should eq(\"MEMOIZE\")\n  end\nend\n"
  },
  {
    "path": "spec/lucky/mime_type_spec.cr",
    "content": "require \"../spec_helper\"\n\ninclude ContextHelper\n\ndescribe Lucky::MimeType do\n  describe \"determine_clients_desired_format\" do\n    it \"returns the format for the 'Accept' header\" do\n      format = determine_format(\"accept\": \"application/json\", \"X-Requested-With\": \"XmlHttpRequest\")\n      format.should eq(:json)\n    end\n\n    it \"returns the format for the 'Accept' header if it is not the default browser header \" do\n      format = determine_format(\"accept\": \"application/json\")\n      format.should eq(:json)\n    end\n\n    it \"returns 'nil' if there is a non-browser 'Accept' header, but Lucky doesn't understand it\" do\n      format = determine_format(\"accept\": \"wut/is-this\")\n      format.should be_nil\n    end\n\n    it \"returns the 'default_format' if the 'Accept' header accepts anything '*/*'\" do\n      format = determine_format(default_format: :csv, \"accept\": \"*/*\")\n      format.should eq(:csv)\n    end\n\n    describe \"when the 'Accept' header is the default browser header\" do\n      it \"returns :html if :html is an accepted format\" do\n        default_browser_header = \"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8\"\n        format = determine_format(default_format: :csv, headers: {\"accept\": default_browser_header}, accepted_formats: [:html, :csv])\n        format.should eq(:html)\n      end\n\n      it \"returns the 'default_format' if :html is NOT an accepted format\" do\n        default_browser_header = \"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8\"\n        format = determine_format(default_format: :csv, \"accept\": default_browser_header)\n        format.should eq(:csv)\n      end\n    end\n\n    it \"falls back to 'default_format' if no accept header\" do\n      format = determine_format(default_format: :csv)\n      format.should eq(:csv)\n    end\n\n    describe \"when the 'Accept' header accepts all images\" do\n      before_each do\n        Lucky::MimeType.register \"image/png\", :png\n        Lucky::MimeType.register \"image/x-icon\", :ico\n      end\n\n      after_each do\n        Lucky::MimeType.deregister \"image/png\"\n        Lucky::MimeType.deregister \"image/x-icon\"\n      end\n\n      it \"returns the default accepted mime type that matches the prefix\" do\n        any_image = \"image/*;q=0.8\"\n        format = determine_format(default_format: :ico, headers: {\"accept\": any_image}, accepted_formats: [:png, :ico])\n        format.should eq(:png)\n      end\n    end\n\n    describe \"when the 'Accept' header accepts anything with a lower quality factor\" do\n      # Test for https://github.com/luckyframework/lucky/issues/1766\n      it \"returns an accepted format\" do\n        accept = \"*/*; q=0.5, application/xml\"\n        format = determine_format(default_format: :html, headers: {\"accept\": accept}, accepted_formats: [:json])\n        format.should eq(:json)\n      end\n    end\n  end\n\n  describe Lucky::MimeType::MediaRange do\n    it \"accepts valid values\" do\n      [\n        {\"*/*\", Lucky::MimeType::MediaRange.new(\"*\", \"*\", 1000)},\n        {\"image/*\", Lucky::MimeType::MediaRange.new(\"image\", \"*\", 1000)},\n        {\"text/plain\", Lucky::MimeType::MediaRange.new(\"text\", \"plain\", 1000)},\n      ].each do |test|\n        Lucky::MimeType::MediaRange.parse(test[0]).should eq(test[1])\n      end\n    end\n\n    it \"rejects invalid values\" do\n      [\n        {\"*/image\", \"*/image is not a valid media range\"},\n        {\"asdf\", \"asdf is not a valid media range\"},\n        {\"text/plain; q=1.9\", \"qvalue 1.9 is not within 0 to 1.0\"},\n        {\"text/plain; q=1.2.3\", \"1.2.3 is not a valid qvalue\"},\n      ].each do |range, message|\n        expect_raises(Lucky::MimeType::InvalidMediaRange, message) do\n          Lucky::MimeType::MediaRange.parse(range)\n        end\n      end\n    end\n\n    it \"accepts parameters\" do\n      expected = Lucky::MimeType::MediaRange.new(\"text\", \"plain\", 1000)\n      [\n        \"text/plain;format=flowed\",\n        \"text/plain\\t; format=flowed\",\n        \"text/plain;format=fixed\",\n        \"text/plain; format=fixed\",\n        \"text/plain \\t; \\tformat=fixed\",\n        \"text/plain;format=fixed;charset=UTF-8\",\n      ].each do |input|\n        Lucky::MimeType::MediaRange.parse(input).should eq(expected)\n      end\n    end\n\n    it \"ignores case\" do\n      expected = Lucky::MimeType::MediaRange.new(\"text\", \"html\", 1000)\n      [\n        \"text/html;charset=utf-8\",\n        \"Text/HTML;Charset=\\\"utf-8\\\"\",\n        \"text/html; charset=\\\"utf-8\\\"\",\n        \"text/html;charset=UTF-8\",\n      ].each do |input|\n        Lucky::MimeType::MediaRange.parse(input).should eq(expected)\n      end\n    end\n\n    it \"parses the qvalue\" do\n      [\n        {\"*/*; q=0\", Lucky::MimeType::MediaRange.new(\"*\", \"*\", 0)},\n        {\"*/*; q=1\", Lucky::MimeType::MediaRange.new(\"*\", \"*\", 1000)},\n        {\"*/*; q=0.1\", Lucky::MimeType::MediaRange.new(\"*\", \"*\", 100)},\n        {\"image/*; q=0.12\", Lucky::MimeType::MediaRange.new(\"image\", \"*\", 120)},\n        {\"text/plain; q=0.123\", Lucky::MimeType::MediaRange.new(\"text\", \"plain\", 123)},\n        {\"text/plain;format=fixed;q=0.4\", Lucky::MimeType::MediaRange.new(\"text\", \"plain\", 400)},\n        # qvalue must be last so is ignored if not\n        {\"text/plain;q=0.4;format=fixed\", Lucky::MimeType::MediaRange.new(\"text\", \"plain\", 1000)},\n      ].each do |test|\n        Lucky::MimeType::MediaRange.parse(test[0]).should eq(test[1])\n      end\n    end\n  end\n\n  describe Lucky::MimeType::AcceptList do\n    it \"is empty when the Accept value is nil\" do\n      Lucky::MimeType::AcceptList.new(nil).list.should be_empty\n    end\n\n    it \"accepts single values\" do\n      expected = [Lucky::MimeType::MediaRange.new(\"text\", \"html\", 1000)]\n      Lucky::MimeType::AcceptList.new(\"text/html\").list.should eq(expected)\n    end\n\n    it \"accepts multiple values\" do\n      expected = [\n        Lucky::MimeType::MediaRange.new(\"audio\", \"basic\", 1000),\n        Lucky::MimeType::MediaRange.new(\"audio\", \"*\", 200),\n      ]\n      Lucky::MimeType::AcceptList.new(\"audio/*; q=0.2, audio/basic\").list.should eq(expected)\n    end\n\n    it \"sorts multiple values by qvalue\" do\n      expected = [\n        Lucky::MimeType::MediaRange.new(\"text\", \"html\", 1000),\n        Lucky::MimeType::MediaRange.new(\"text\", \"x-c\", 1000),\n        Lucky::MimeType::MediaRange.new(\"text\", \"x-dvi\", 800),\n        Lucky::MimeType::MediaRange.new(\"text\", \"plain\", 500),\n      ]\n      Lucky::MimeType::AcceptList.new(\"text/plain; q=0.5, text/html, text/x-dvi; q=0.8, text/x-c\").list.should eq(expected)\n    end\n\n    it \"parses a default browser Accept value\" do\n      expected = [\n        Lucky::MimeType::MediaRange.new(\"text\", \"html\", 1000),\n        Lucky::MimeType::MediaRange.new(\"application\", \"xhtml+xml\", 1000),\n        Lucky::MimeType::MediaRange.new(\"image\", \"avif\", 1000),\n        Lucky::MimeType::MediaRange.new(\"image\", \"webp\", 1000),\n        Lucky::MimeType::MediaRange.new(\"application\", \"xml\", 900),\n        Lucky::MimeType::MediaRange.new(\"*\", \"*\", 800),\n      ]\n      # Value is from Firefox requesting a web page\n      Lucky::MimeType::AcceptList.new(\"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8\").list.should eq(expected)\n    end\n\n    it \"skips invalid media ranges\" do\n      expected = [\n        Lucky::MimeType::MediaRange.new(\"audio\", \"basic\", 1000),\n      ]\n      Lucky::MimeType::AcceptList.new(\"*/invalid; q=0.2, audio/basic\").list.should eq(expected)\n    end\n  end\nend\n\nprivate def determine_format(default_format = :ics, **headers)\n  determine_format(default_format, headers, accepted_formats: [] of Symbol)\nend\n\nprivate def determine_format(default_format, headers, accepted_formats)\n  headers = headers.to_h.transform_keys(&.to_s.as(String))\n  request = build_request\n  request.headers.merge!(headers)\n  Lucky::MimeType.determine_clients_desired_format(\n    request,\n    default_format: default_format,\n    accepted_formats: accepted_formats\n  )\nend\n"
  },
  {
    "path": "spec/lucky/namespaced_action_spec.cr",
    "content": "require \"../spec_helper\"\n\nclass Admin::MultiWord::Users::Show < TestAction\n  get \"/admin/multi_word/users/:user_id\" do\n    plain_text \"plain\"\n  end\nend\n\ndescribe Lucky::Action do\n  describe \"routing\" do\n    it \"creates URL helpers for the resourceful actions\" do\n      Admin::MultiWord::Users::Show\n        .path(\"foo\")\n        .should eq \"/admin/multi_word/users/foo\"\n      Admin::MultiWord::Users::Show\n        .with(\"foo\").path\n        .should eq \"/admin/multi_word/users/foo\"\n    end\n\n    it \"adds routes to the router\" do\n      assert_route_added?(:get, \"/admin/multi_word/users/:user_id\", Admin::MultiWord::Users::Show)\n    end\n  end\nend\n"
  },
  {
    "path": "spec/lucky/number_to_currency_spec.cr",
    "content": "require \"../spec_helper\"\nrequire \"html\"\n\nprivate class TestPage\n  include Lucky::HTMLPage\n\n  def render\n  end\nend\n\ndescribe Lucky::NumberToCurrency do\n  describe \"number_to_currency\" do\n    it \"accepts Float\" do\n      view.number_to_currency(29.92).should eq(HTML.escape(\"$29.92\"))\n    end\n\n    it \"accepts String\" do\n      view.number_to_currency(\"92.29\").should eq(HTML.escape(\"$92.29\"))\n    end\n\n    it \"accepts Integer\" do\n      view.number_to_currency(92).should eq(HTML.escape(\"$92.00\"))\n    end\n\n    describe \"options\" do\n      it \"accepts precision\" do\n        view.number_to_currency(29.929_123, precision: 3).should eq(HTML.escape(\"$29.929\"))\n      end\n\n      it \"accepts unit\" do\n        view.number_to_currency(29.92, unit: \"CAD\").should eq \"CAD29.92\"\n      end\n\n      it \"accepts separator\" do\n        view.number_to_currency(29.92, separator: \"-\").should eq(HTML.escape(\"$29-92\"))\n      end\n\n      it \"accepts delimiter\" do\n        view.number_to_currency(1_234_567_890.29, delimiter: \"-\").should eq(HTML.escape(\"$1-234-567-890.29\"))\n      end\n\n      it \"accepts delimiter pattern\" do\n        view.number_to_currency(1_230_000, delimiter_pattern: /(\\d+?)(?=(\\d\\d)+(\\d)(?!\\d))/).should eq(HTML.escape(\"$12,30,000.00\"))\n      end\n\n      it \"accepts format\" do\n        view.number_to_currency(\"1234567890.50\", format: \"%n ** %u\").should eq(HTML.escape(\"1,234,567,890.50 ** $\"))\n      end\n\n      it \"accepts negative format\" do\n        view.number_to_currency(\"-1234567890.50\", negative_format: \"%n ** - %u\").should eq(HTML.escape(\"1,234,567,890.50 ** - $\"))\n      end\n    end\n  end\nend\n\nprivate def view\n  TestPage.new(build_context)\nend\n"
  },
  {
    "path": "spec/lucky/paginator/backend_helpers_spec.cr",
    "content": "require \"../../spec_helper\"\n\nclass Paginatable\n  include Lucky::Paginator::BackendHelpers\n  include ContextHelper\n\n  def initialize(@page : String? = nil)\n  end\n\n  def call_array\n    paginate_array([1]*50)\n  end\n\n  def params\n    Params.new(@page)\n  end\n\n  def context\n    build_context\n  end\n\n  class Params\n    getter page\n\n    def initialize(@page : String?)\n    end\n\n    def get?(_value) : String?\n      @page\n    end\n  end\nend\n\nclass PaginatableWithOverriddenMethods < Paginatable\n  def paginator_page : Int32\n    2\n  end\n\n  def paginator_per_page : Int32\n    10\n  end\nend\n\ndescribe Lucky::Paginator::BackendHelpers do\n  it \"accept array with default\" do\n    pages, records = Paginatable.new.call_array\n\n    pages.page.should eq(1)\n    pages.per_page.should eq(25)\n    pages.total.should eq(2)\n    records.size.should eq(25)\n  end\n\n  it \"uses array with the 'page' param if given\" do\n    pages, records = Paginatable.new(page: \"2\").call_array\n\n    pages.page.should eq(2)\n    pages.per_page.should eq(25)\n    pages.total.should eq(2)\n    records.size.should eq(25)\n  end\n\n  it \"return empty array if page is overflowed\" do\n    pages, records = Paginatable.new(page: \"3\").call_array\n\n    pages.page.should eq(3)\n    pages.per_page.should eq(25)\n    pages.total.should eq(2)\n    records.size.should eq(0)\n  end\n\n  it \"allows overriding 'paginator_page' and 'paginator_per_page'\" do\n    pages, records = PaginatableWithOverriddenMethods.new.call_array\n\n    pages.page.should eq(2)\n    pages.per_page.should eq(10)\n    pages.total.should eq(5)\n    records.size.should eq(10)\n  end\nend\n"
  },
  {
    "path": "spec/lucky/paginator/components_spec.cr",
    "content": "require \"../../spec_helper\"\n\ndescribe \"Lucky::Paginator Components\" do\n  it \"compiles and renders the components successfully\" do\n    pages = Lucky::Paginator.new(page: 5, item_count: 10, per_page: 1, full_path: \"/\")\n\n    html = Lucky::Paginator::SimpleNav.new(pages).render_to_string\n    html.should contain(%(role=\"navigation\"))\n\n    html = Lucky::Paginator::BootstrapNav.new(pages).render_to_string\n    html.should contain(%(class=\"pagination\"))\n\n    html = Lucky::Paginator::BulmaNav.new(pages).render_to_string\n    html.should contain(%(class=\"pagination\"))\n  end\nend\n"
  },
  {
    "path": "spec/lucky/paginator/paginator_spec.cr",
    "content": "require \"../../spec_helper\"\n\nprivate def build_pages(page = 1, per_page = 1, item_count = 1, full_path = \"/items\") : Lucky::Paginator\n  Lucky::Paginator.new(\n    page: page,\n    per_page: per_page,\n    item_count: item_count,\n    full_path: full_path)\nend\n\ndescribe Lucky::Paginator do\n  describe \"#page\" do\n    it \"returns the current page\" do\n      build_pages(page: 2).page.should eq(2)\n    end\n\n    it \"makes sure the page is not 0 or less\" do\n      build_pages(page: 0).page.should eq(1)\n      build_pages(page: -1).page.should eq(1)\n    end\n  end\n\n  describe \"#one_page?\" do\n    it \"returns true if there is just one page\" do\n      build_pages(per_page: 1, item_count: 1).one_page?.should be_true\n    end\n\n    it \"returns false if there is more than one page\" do\n      build_pages(per_page: 1, item_count: 2).one_page?.should be_false\n    end\n  end\n\n  describe \"#offset\" do\n    it \"returns the number of records to skip\" do\n      build_pages(page: 2, per_page: 10, item_count: 20).offset.should eq(10)\n      build_pages(page: 1, per_page: 10, item_count: 20).offset.should eq(0)\n    end\n  end\n\n  describe \"#total\" do\n    it \"returns a rounded total pages\" do\n      build_pages(per_page: 10, item_count: 0).total.should eq(0)\n      build_pages(per_page: 10, item_count: 1).total.should eq(1)\n      build_pages(per_page: 10, item_count: 15).total.should eq(2)\n    end\n  end\n\n  describe \"#last_page?\" do\n    it \"returns true if the current page is the last one\" do\n      build_pages(page: 2, per_page: 1, item_count: 2).last_page?.should eq(true)\n      build_pages(page: 1, per_page: 1, item_count: 1).last_page?.should eq(true)\n    end\n\n    it \"returns false if the current page is not the last one\" do\n      build_pages(page: 1, per_page: 1, item_count: 2).last_page?.should eq(false)\n    end\n  end\n\n  describe \"#first_page?\" do\n    it \"returns true if the current page is the first one\" do\n      build_pages(page: 1, per_page: 1, item_count: 1).first_page?.should eq(true)\n    end\n\n    it \"otherwise returns false\" do\n      build_pages(page: 2, per_page: 1, item_count: 2).first_page?.should eq(false)\n    end\n  end\n\n  describe \"#overflowed?\" do\n    it \"returns true if the current page is past the last page\" do\n      build_pages(page: 2, per_page: 1, item_count: 1).overflowed?.should eq(true)\n    end\n\n    it \"otherwise returns false\" do\n      build_pages(page: 1, per_page: 1, item_count: 1).overflowed?.should eq(false)\n    end\n  end\n\n  describe \"#item_range\" do\n    it \"return the range of items on the current page\" do\n      item_range = build_pages(page: 1, per_page: 5, item_count: 20).item_range\n      item_range.begin.should eq(1)\n      item_range.end.should eq(5)\n\n      item_range = build_pages(page: 2, per_page: 5, item_count: 12).item_range\n      item_range.begin.should eq(6)\n      item_range.end.should eq(10)\n\n      item_range = build_pages(page: 3, per_page: 5, item_count: 12).item_range\n      item_range.begin.should eq(11)\n      item_range.end.should eq(12)\n    end\n  end\n\n  describe \"#previous_page\" do\n    it \"returns the next page\" do\n      build_pages(page: 0, per_page: 3, item_count: 10).previous_page.should be_nil\n      build_pages(page: 1, per_page: 3, item_count: 10).previous_page.should be_nil\n      build_pages(page: 3, per_page: 3, item_count: 10).previous_page.should eq(2)\n      build_pages(page: 4, per_page: 3, item_count: 10).previous_page.should eq(3)\n    end\n  end\n\n  describe \"#next_page\" do\n    it \"returns the next page\" do\n      build_pages(page: 1, per_page: 3, item_count: 10).next_page.should eq(2)\n      build_pages(page: 3, per_page: 3, item_count: 10).next_page.should eq(4)\n      build_pages(page: 4, per_page: 3, item_count: 10).next_page.should be_nil\n      build_pages(page: 9, per_page: 3, item_count: 10).next_page.should be_nil\n    end\n  end\n\n  describe \"#path_to_page\" do\n    it \"adds a query param to the path\" do\n      path = build_pages(full_path: \"/comments\").path_to_page(1)\n\n      path.should eq(\"/comments?page=1\")\n    end\n\n    it \"appends to query param if some are already set\" do\n      path = build_pages(full_path: \"/comments?filter=published\").path_to_page(1)\n\n      path.should eq(\"/comments?filter=published&page=1\")\n    end\n\n    it \"overwrites page query param\" do\n      path = build_pages(full_path: \"/comments?page=1\").path_to_page(2)\n\n      path.should eq(\"/comments?page=2\")\n    end\n  end\n\n  describe \"#path_to_next\" do\n    it \"returns the path to the next page if there is a next page\" do\n      path = build_pages(page: 1, per_page: 1, item_count: 2).path_to_next\n\n      path.to_s.should end_with(\"?page=2\")\n    end\n\n    it \"returns nil if there is not next page\" do\n      path = build_pages(page: 1, per_page: 1, item_count: 1).path_to_next\n\n      path.should be_nil\n    end\n  end\n\n  describe \"#path_to_previous\" do\n    it \"returns the path to the previous page if there is a previous page\" do\n      path = build_pages(page: 2, per_page: 1, item_count: 2).path_to_previous\n\n      path.to_s.should end_with(\"?page=1\")\n    end\n\n    it \"returns nil if there is not previous page\" do\n      path = build_pages(page: 1, per_page: 1, item_count: 1).path_to_previous\n\n      path.should be_nil\n    end\n  end\n\n  describe \"#series\" do\n    it \"allows customizing the series\" do\n      pages = build_pages(page: 20, per_page: 1, item_count: 40)\n\n      series = pages.series(begin: 2, left_of_current: 2, right_of_current: 2, end: 2)\n\n      series.should eq([\n        Lucky::Paginator::Page.new(pages, 1),\n        Lucky::Paginator::Page.new(pages, 2),\n        Lucky::Paginator::Gap.new,\n        Lucky::Paginator::Page.new(pages, 18),\n        Lucky::Paginator::Page.new(pages, 19),\n        Lucky::Paginator::CurrentPage.new(pages, 20),\n        Lucky::Paginator::Page.new(pages, 21),\n        Lucky::Paginator::Page.new(pages, 22),\n        Lucky::Paginator::Gap.new,\n        Lucky::Paginator::Page.new(pages, 39),\n        Lucky::Paginator::Page.new(pages, 40),\n      ])\n    end\n\n    it \"doesn't add gaps when begining/ending are next to current pages\" do\n      pages = build_pages(page: 4, per_page: 1, item_count: 7)\n\n      series = pages.series(begin: 1, left_of_current: 2, right_of_current: 2, end: 1)\n\n      series.should eq([\n        Lucky::Paginator::Page.new(pages, 1),\n        Lucky::Paginator::Page.new(pages, 2),\n        Lucky::Paginator::Page.new(pages, 3),\n        Lucky::Paginator::CurrentPage.new(pages, 4),\n        Lucky::Paginator::Page.new(pages, 5),\n        Lucky::Paginator::Page.new(pages, 6),\n        Lucky::Paginator::Page.new(pages, 7),\n      ])\n    end\n\n    it \"adds gaps when there is just 1 page in between middle and edges\" do\n      pages = build_pages(page: 4, per_page: 1, item_count: 7)\n\n      series = pages.series(begin: 1, left_of_current: 1, right_of_current: 1, end: 1)\n\n      series.should eq([\n        Lucky::Paginator::Page.new(pages, 1),\n        Lucky::Paginator::Gap.new,\n        Lucky::Paginator::Page.new(pages, 3),\n        Lucky::Paginator::CurrentPage.new(pages, 4),\n        Lucky::Paginator::Page.new(pages, 5),\n        Lucky::Paginator::Gap.new,\n        Lucky::Paginator::Page.new(pages, 7),\n      ])\n    end\n\n    it \"when 'begin' or 'end' is 0 it leaves of the pages and gap\" do\n      pages = build_pages(page: 20, per_page: 1, item_count: 40)\n\n      series = pages.series(begin: 0, left_of_current: 1, right_of_current: 1, end: 0)\n\n      series.should eq([\n        Lucky::Paginator::Page.new(pages, 19),\n        Lucky::Paginator::CurrentPage.new(pages, 20),\n        Lucky::Paginator::Page.new(pages, 21),\n      ])\n    end\n\n    it \"when 'left/right_of_current' are 0 it keeps the gap between begin/end\" do\n      pages = build_pages(page: 20, per_page: 1, item_count: 40)\n\n      series = pages.series(begin: 1, left_of_current: 0, right_of_current: 0, end: 1)\n\n      series.should eq([\n        Lucky::Paginator::Page.new(pages, 1),\n        Lucky::Paginator::Gap.new,\n        Lucky::Paginator::CurrentPage.new(pages, 20),\n        Lucky::Paginator::Gap.new,\n        Lucky::Paginator::Page.new(pages, 40),\n      ])\n    end\n\n    it \"when all options are 0 it generates just a current page\" do\n      pages = build_pages(page: 20, per_page: 1, item_count: 40)\n\n      series = pages.series(begin: 0, left_of_current: 0, right_of_current: 0, end: 0)\n\n      series.should eq([\n        Lucky::Paginator::CurrentPage.new(pages, 20),\n      ])\n    end\n\n    it \"returns the correct series when at the last page\" do\n      pages = build_pages(page: 40, per_page: 1, item_count: 40)\n\n      series = pages.series(begin: 0, left_of_current: 1, right_of_current: 1, end: 1)\n\n      series.should eq([\n        Lucky::Paginator::Page.new(pages, 39),\n        Lucky::Paginator::CurrentPage.new(pages, 40),\n      ])\n    end\n\n    it \"returns the correct series when near the last page\" do\n      pages = build_pages(page: 39, per_page: 1, item_count: 40)\n\n      series = pages.series(begin: 0, left_of_current: 1, right_of_current: 2, end: 1)\n\n      series.should eq([\n        Lucky::Paginator::Page.new(pages, 38),\n        Lucky::Paginator::CurrentPage.new(pages, 39),\n        Lucky::Paginator::CurrentPage.new(pages, 40),\n      ])\n    end\n\n    it \"returns the correct series when at the first page\" do\n      pages = build_pages(page: 1, per_page: 1, item_count: 40)\n\n      series = pages.series(begin: 1, left_of_current: 0, right_of_current: 3, end: 1)\n\n      series.should eq([\n        Lucky::Paginator::CurrentPage.new(pages, 1),\n        Lucky::Paginator::Page.new(pages, 2),\n        Lucky::Paginator::Page.new(pages, 3),\n        Lucky::Paginator::Page.new(pages, 4),\n        Lucky::Paginator::Gap.new,\n        Lucky::Paginator::Page.new(pages, 40),\n      ])\n    end\n\n    it \"returns the correct series when near the first page\" do\n      pages = build_pages(page: 2, per_page: 1, item_count: 40)\n\n      series = pages.series(begin: 1, left_of_current: 2, right_of_current: 0, end: 1)\n\n      series.should eq([\n        Lucky::Paginator::Page.new(pages, 1),\n        Lucky::Paginator::CurrentPage.new(pages, 2),\n        Lucky::Paginator::Gap.new,\n        Lucky::Paginator::Page.new(pages, 40),\n      ])\n    end\n  end\nend\n"
  },
  {
    "path": "spec/lucky/param_parser_spec.cr",
    "content": "require \"../spec_helper\"\n\nenum TimeComponent\n  Year\n  Month\n  Day\n  Hour\n  Minute\n  Second\nend\n\nstruct FormattedTime\n  property label : String\n  property value : String\n  property components : Array(TimeComponent)\n\n  def initialize(@label : String, @value : String, excluded_components : Array(TimeComponent) = [] of TimeComponent)\n    @components = [\n      TimeComponent::Year,\n      TimeComponent::Month,\n      TimeComponent::Day,\n      TimeComponent::Hour,\n      TimeComponent::Minute,\n      TimeComponent::Second,\n    ] - excluded_components\n  end\nend\n\nmacro numbers_tests(klass, input, output)\n  describe \"parse {{ klass }}\" do\n    it \"turns string into number\" do\n      Lucky::ParamParser.parse({{ input }}, {{ klass }}).should eq({{ output }})\n    end\n\n    it \"returns nil if param not number\" do\n      Lucky::ParamParser.parse(\"abc\", {{ klass }}).should be_nil\n    end\n\n    it \"returns nil if param blank\" do\n      Lucky::ParamParser.parse(\"\", {{ klass }}).should be_nil\n    end\n  end\nend\n\ndescribe Lucky::ParamParser do\n  numbers_tests(Int16, \"1\", 1_i16)\n  numbers_tests(Int32, \"12\", 12)\n  numbers_tests(Int64, \"144\", 144_i64)\n  numbers_tests(Float64, \"1.23\", 1.23_f64)\n\n  describe \"parse String\" do\n    it \"does not change the value\" do\n      Lucky::ParamParser.parse(\"foo\", String).should eq(\"foo\")\n    end\n  end\n\n  describe \"parse Bool\" do\n    it \"parses forms of true\" do\n      Lucky::ParamParser.parse(\"true\", Bool).should be_true\n      Lucky::ParamParser.parse(\"1\", Bool).should be_true\n    end\n\n    it \"parses forms of false\" do\n      Lucky::ParamParser.parse(\"false\", Bool).should be_false\n      Lucky::ParamParser.parse(\"0\", Bool).should be_false\n    end\n\n    it \"returns nil for other values\" do\n      Lucky::ParamParser.parse(\"asdf\", Bool).should be_nil\n    end\n  end\n\n  describe \"parse UUID\" do\n    it \"parses uuid string\" do\n      uuid = \"0881a13e-e283-45a0-9dba-6d05463eec45\"\n\n      Lucky::ParamParser.parse(uuid, UUID).should eq(UUID.new(uuid))\n    end\n\n    it \"returns nil if not uuid\" do\n      Lucky::ParamParser.parse(\"INVALID\", UUID).should be_nil\n    end\n  end\n\n  describe \"parse Time\" do\n    it \"parses various formats successfully\" do\n      time = Time.utc\n      [\n        FormattedTime.new(\"ISO 8601\", time.to_s(\"%FT%X%z\")),\n        FormattedTime.new(\"RFC 2822\", time.to_rfc2822),\n        FormattedTime.new(\"RFC 3339\", time.to_rfc3339),\n        FormattedTime.new(\"DateTime HTML Input\", time.to_s(\"%Y-%m-%dT%H:%M:%S\")),\n        FormattedTime.new(\"DateTime HTML Input (no seconds)\", time.to_s(\"%Y-%m-%dT%H:%M\"), excluded_components: [TimeComponent::Second]),\n        FormattedTime.new(\"HTTP Date\", time.to_s(\"%a, %d %b %Y %H:%M:%S GMT\")),\n      ].each do |formatted_time|\n        result = Lucky::ParamParser.parse(formatted_time.value, Time)\n\n        result.should_not be_nil\n        result = result.as(Time)\n        result.year.should eq(time.year) if formatted_time.components.includes? TimeComponent::Year\n        result.month.should eq(time.month) if formatted_time.components.includes? TimeComponent::Month\n        result.day.should eq(time.day) if formatted_time.components.includes? TimeComponent::Day\n        result.hour.should eq(time.hour) if formatted_time.components.includes? TimeComponent::Hour\n        result.minute.should eq(time.minute) if formatted_time.components.includes? TimeComponent::Minute\n        result.second.should eq(time.second) if formatted_time.components.includes? TimeComponent::Second\n      end\n    end\n\n    it \"returns nil if unable to parse\" do\n      Lucky::ParamParser.parse(\"INVALID\", Time).should be_nil\n    end\n  end\n\n  describe \"parse Array(T)\" do\n    it \"handles strings\" do\n      Lucky::ParamParser.parse([\"a\", \"b\"], Array(String)).should eq([\"a\", \"b\"])\n    end\n\n    it \"handles numbers\" do\n      Lucky::ParamParser.parse([\"1\", \"2\"], Array(Int32)).should eq([1, 2])\n    end\n\n    it \"handles bools\" do\n      Lucky::ParamParser.parse([\"1\", \"0\", \"true\"], Array(Bool)).should eq([true, false, true])\n    end\n  end\nend\n"
  },
  {
    "path": "spec/lucky/params_spec.cr",
    "content": "require \"../spec_helper\"\n\ninclude ContextHelper\ninclude MultipartHelper\n\ndescribe Lucky::Params do\n  describe \"#from_query\" do\n    it \"returns the HTTP::Params for the query params\" do\n      request = build_request\n      request.query = \"q=test\"\n\n      params = Lucky::Params.new(request)\n\n      params.from_query.should be_a(HTTP::Params)\n      params.from_query[\"q\"].should eq(\"test\")\n    end\n  end\n\n  describe \"#from_json\" do\n    it \"returns a JSON::Any object\" do\n      request = build_request(body: {page: 1}.to_json)\n\n      params = Lucky::Params.new(request)\n\n      params.from_json.should be_a(JSON::Any)\n      params.from_json[\"page\"].as_i.should eq(1)\n    end\n  end\n\n  describe \"#from_form_data\" do\n    it \"returns HTTP::Params based on the request body\" do\n      request = build_request body: \"name=Ben\",\n        content_type: \"application/x-www-form-urlencoded\"\n\n      params = Lucky::Params.new(request)\n\n      params.from_form_data.should be_a(HTTP::Params)\n      params.from_form_data[\"name\"].should eq(\"Ben\")\n    end\n  end\n\n  describe \"#from_multipart\" do\n    it \"returns a Tuple with form data in first position\" do\n      request = build_multipart_request form_parts: {\"from\" => \"multipart\"}\n\n      params = Lucky::Params.new(request)\n\n      params.from_multipart.first[\"from\"].should eq(\"multipart\")\n    end\n\n    it \"returns a Tuple with files in second position\" do\n      request = build_multipart_request file_parts: {\n        \"avatar\" => \"file_contents\",\n      }\n\n      params = Lucky::Params.new(request)\n\n      file = params.from_multipart.last[\"avatar\"]\n      file.should be_a(Lucky::UploadedFile)\n      File.read(file.path).should eq \"file_contents\"\n    end\n  end\n\n  it \"works when parsing params twice\" do\n    request = build_request body: \"from=form\",\n      content_type: \"application/x-www-form-urlencoded\",\n      fixed_length: true\n\n    params = Lucky::Params.new(request)\n\n    params.get?(:from).should eq \"form\"\n    params.get?(:from).should eq \"form\"\n  end\n\n  it \"works when parsing multipart params twice\" do\n    request = build_multipart_request form_parts: {\n      \"user\" => {\n        \"name\" => \"Paul\",\n        \"age\"  => \"28\",\n      },\n    }\n\n    params = Lucky::Params.new(request)\n\n    params.nested?(:user).should eq({\"name\" => \"Paul\", \"age\" => \"28\"})\n    params.nested?(:user).should eq({\"name\" => \"Paul\", \"age\" => \"28\"})\n  end\n\n  it \"works when parsing json params twice\" do\n    request = build_request body: {page: 1}.to_json,\n      content_type: \"application/json\",\n      fixed_length: true\n\n    params = Lucky::Params.new(request)\n\n    params.get?(:page).should eq \"1\"\n    params.get?(:page).should eq \"1\"\n  end\n\n  describe \"all\" do\n    it \"gives preference to body params if query param is also present\" do\n      request = build_request body: \"from=form\",\n        content_type: \"application/x-www-form-urlencoded\"\n      request.query = \"from=query\"\n\n      params = Lucky::Params.new(request)\n\n      params.get?(:from).should eq \"form\"\n    end\n\n    it \"raises an exception if parsing fails\" do\n      invalid_json = \"//\"\n      request = build_request body: invalid_json,\n        content_type: \"application/json\"\n\n      params = Lucky::Params.new(request)\n\n      expect_raises Lucky::ParamParsingError do\n        params.get?(:page).should eq \"1\"\n      end\n    end\n  end\n\n  describe \"when route params are passed in\" do\n    it \"gets the param from the route params\" do\n      request = build_request body: \"id=from_form\",\n        content_type: \"application/x-www-form-urlencoded\"\n      route_params = {\"id\" => \"from_route\"}\n\n      params = Lucky::Params.new(request, route_params)\n\n      params.get?(:id).should eq \"from_route\"\n    end\n  end\n\n  describe \"get\" do\n    it \"strips whitespace around values\" do\n      request = build_request body: \"\", content_type: \"\"\n      request.query = \"email= paul@luckyframework.org &name= Paul \"\n\n      params = Lucky::Params.new(request)\n\n      params.get?(:email).should eq \"paul@luckyframework.org\"\n      params.get?(:name).should eq \"Paul\"\n    end\n\n    it \"raises if missing a param and using get version\" do\n      request = build_request body: \"\", content_type: \"application/x-www-form-urlencoded\"\n\n      params = Lucky::Params.new(request)\n\n      expect_raises Lucky::MissingParamError do\n        params.get(:missing)\n      end\n    end\n\n    it \"returns nil if using get? version\" do\n      request = build_request body: \"\", content_type: \"application/x-www-form-urlencoded\"\n\n      params = Lucky::Params.new(request)\n\n      params.get?(:missing).should be_nil\n    end\n  end\n\n  describe \"get_raw\" do\n    it \"parses form encoded params\" do\n      request = build_request body: \"page=1&foo=bar\",\n        content_type: \"application/x-www-form-urlencoded\"\n\n      params = Lucky::Params.new(request)\n\n      params.get_raw?(:page).should eq \"1\"\n      params.get_raw?(:foo).should eq \"bar\"\n    end\n\n    it \"parses JSON params\" do\n      request = build_request body: {page: 1, foo: \"bar\"}.to_json,\n        content_type: \"application/json\"\n\n      params = Lucky::Params.new(request)\n\n      params.get_raw?(:page).should eq \"1\"\n      params.get_raw?(:foo).should eq \"bar\"\n    end\n\n    it \"handles empty JSON body\" do\n      request = build_request body: \"\",\n        content_type: \"application/json\"\n\n      params = Lucky::Params.new(request)\n\n      # Should not raise\n      params.get_raw?(:anything)\n    end\n\n    it \"handles JSON with charset directive in Content-Type header\" do\n      request = build_request body: {page: 1, foo: \"bar\"}.to_json,\n        content_type: \"application/json;charset=UTF-8\"\n\n      params = Lucky::Params.new(request)\n\n      params.get_raw?(:page).should eq \"1\"\n      params.get_raw?(:foo).should eq \"bar\"\n    end\n\n    it \"parses query params\" do\n      request = build_request body: \"\", content_type: \"\"\n      request.query = \"page=1&id=1\"\n\n      params = Lucky::Params.new(request)\n\n      params.get_raw?(:page).should eq \"1\"\n      params.get_raw?(:id).should eq \"1\"\n    end\n\n    it \"parses params in multipart requests\" do\n      request = build_multipart_request form_parts: {\"from\" => \"multipart\"}\n\n      params = Lucky::Params.new(request)\n\n      params.get_raw(:from).should eq \"multipart\"\n    end\n\n    it \"raises if missing a param and using get_raw version\" do\n      request = build_request body: \"\", content_type: \"application/x-www-form-urlencoded\"\n\n      params = Lucky::Params.new(request)\n\n      expect_raises Lucky::MissingParamError do\n        params.get_raw(:missing)\n      end\n    end\n\n    it \"returns nil if using get_raw? version\" do\n      request = build_request body: \"\", content_type: \"application/x-www-form-urlencoded\"\n\n      params = Lucky::Params.new(request)\n\n      params.get_raw?(:missing).should be_nil\n    end\n\n    it \"does not strip whitespace around values\" do\n      request = build_request body: \"\", content_type: \"\"\n      request.query = \"email= paul@luckyframework.org  &name= Paul &age=28 \"\n\n      params = Lucky::Params.new(request)\n\n      params.get_raw?(:email).should eq \" paul@luckyframework.org  \"\n      params.get_raw?(:name).should eq \" Paul \"\n      params.get_raw?(:age).should eq \"28 \"\n    end\n  end\n\n  describe \"#get_all\" do\n    it \"raises if no values found\" do\n      request = build_request body: \"\", content_type: \"application/x-www-form-urlencoded\"\n\n      params = Lucky::Params.new(request)\n\n      expect_raises Lucky::MissingParamError do\n        params.get_all(:missing)\n      end\n    end\n\n    it \"does not return values from route params\" do\n      request = build_request body: \"\", content_type: \"application/x-www-form-urlencoded\"\n      route_params = {\"id\" => \"from_route\"}\n\n      params = Lucky::Params.new(request, route_params)\n\n      expect_raises Lucky::MissingParamError do\n        params.get_all(:id)\n      end\n    end\n\n    it \"returns array from json if found\" do\n      request = build_request body: {labels: [\"crystal\", \"lucky\"]}.to_json, content_type: \"application/json\"\n\n      params = Lucky::Params.new(request)\n\n      params.get_all(:labels).should eq([\"crystal\", \"lucky\"])\n    end\n\n    it \"returns value to string in array if json value is not array\" do\n      request = build_request body: {titles: \"not a list\"}.to_json, content_type: \"application/json\"\n\n      params = Lucky::Params.new(request)\n\n      params.get_all(:titles).should eq([\"not a list\"])\n    end\n\n    it \"returns multipart params if found\" do\n      request = build_multipart_request form_parts: {\"from\" => [\"asher\", \"lila\"]}\n\n      params = Lucky::Params.new(request)\n\n      params.get_all(:from).should eq([\"asher\", \"lila\"])\n    end\n\n    it \"returns form encoded params if found\" do\n      request = build_request body: \"tags[]=funny&tags[]=complex\", content_type: \"application/x-www-form-urlencoded\"\n\n      params = Lucky::Params.new(request)\n\n      params.get_all(:tags).should eq([\"funny\", \"complex\"])\n    end\n\n    it \"returns query params if found\" do\n      request = build_request body: \"\", content_type: \"\"\n      request.query = \"referrers[]=social&referrers[]=email\"\n\n      params = Lucky::Params.new(request)\n\n      params.get_all(:referrers).should eq([\"social\", \"email\"])\n    end\n\n    it \"requires params to end with square brackets\" do\n      request = build_request body: \"\", content_type: \"\"\n      request.query = \"names=declan&names=nora\"\n\n      params = Lucky::Params.new(request)\n\n      expect_raises Lucky::MissingParamError do\n        params.get_all(:names)\n      end\n    end\n  end\n\n  describe \"#get_all?\" do\n    it \"returns nil if values not found\" do\n      request = build_request body: \"\", content_type: \"application/x-www-form-urlencoded\"\n\n      params = Lucky::Params.new(request)\n\n      params.get_all?(:missing).should be_nil\n    end\n  end\n\n  describe \"nested\" do\n    it \"gets nested form encoded params\" do\n      request = build_request body: \"user:name=paul&user:twitter_handle=@paulcsmith&something:else=1\",\n        content_type: \"application/x-www-form-urlencoded\"\n\n      params = Lucky::Params.new(request)\n\n      params.nested?(:user).should eq({\"name\" => \"paul\", \"twitter_handle\" => \"@paulcsmith\"})\n    end\n\n    it \"gets nested JSON params\" do\n      request = build_request body: {user: {name: \"Paul\", age: 28}}.to_json,\n        content_type: \"application/json\"\n      request.query = \"from=query\"\n\n      params = Lucky::Params.new(request)\n\n      params.nested?(:user).should eq({\"name\" => \"Paul\", \"age\" => \"28\"})\n    end\n\n    it \"gets empty JSON params when nested key is missing\" do\n      request = build_request body: \"{}\",\n        content_type: \"application/json\"\n      request.query = \"from=query\"\n\n      params = Lucky::Params.new(request)\n\n      params.nested?(:user).should eq({} of String => JSON::Any)\n    end\n\n    it \"handles JSON with charset directive in Content-Type header\" do\n      request = build_request body: {user: {name: \"Paul\", age: 28}}.to_json,\n        content_type: \"application/json; charset=UTF-8\"\n\n      params = Lucky::Params.new(request)\n\n      params.nested?(:user).should eq({\"name\" => \"Paul\", \"age\" => \"28\"})\n    end\n\n    it \"gets nested JSON params mixed with query params\" do\n      request = build_request body: {user: {name: \"Bunyan\", age: 102}}.to_json,\n        content_type: \"application/json\"\n      request.query = \"user:active=true\"\n\n      params = Lucky::Params.new(request)\n\n      params.nested?(:user).should eq({\"name\" => \"Bunyan\", \"age\" => \"102\", \"active\" => \"true\"})\n    end\n\n    it \"gets nested JSON containing nested JSON\" do\n      request = build_request body: {user: {name: \"Paul\", address: {home: \"1600 Pennsylvania Ave\"}}}.to_json,\n        content_type: \"application/json\"\n      request.query = \"from=query\"\n\n      params = Lucky::Params.new(request)\n\n      params.nested?(:user).should eq({\"name\" => \"Paul\", \"address\" => \"{\\\"home\\\":\\\"1600 Pennsylvania Ave\\\"}\"})\n    end\n\n    it \"gets nested multipart params\" do\n      request = build_multipart_request form_parts: {\n        \"user\" => {\n          \"name\" => \"Paul\",\n          \"age\"  => \"28\",\n        },\n      }\n\n      params = Lucky::Params.new(request)\n\n      params.nested(:user).should eq({\"name\" => \"Paul\", \"age\" => \"28\"})\n    end\n\n    it \"gets nested query params\" do\n      request = build_request body: \"filter:toppings=left_beef&filter:type=none\", content_type: \"\"\n      request.query = \"filter:query=pizza&sort=desc\"\n      params = Lucky::Params.new(request)\n      params.nested?(\"filter\").should eq({\"type\" => \"none\", \"query\" => \"pizza\", \"toppings\" => \"left_beef\"})\n    end\n\n    it \"returns an empty hash when no nested is found\" do\n      request = build_request body: \"\", content_type: \"\"\n      request.query = \"a=1\"\n      params = Lucky::Params.new(request)\n      params.nested?(\"a\").empty?.should eq true\n    end\n\n    it \"gets nested params after unescaping\" do\n      request = build_request body: \"user%3Aname=paul\",\n        content_type: \"application/x-www-form-urlencoded\"\n\n      params = Lucky::Params.new(request)\n\n      params.nested?(:user).should eq({\"name\" => \"paul\"})\n    end\n\n    it \"raises if nested params are missing\" do\n      request = build_request body: \"\",\n        content_type: \"application/x-www-form-urlencoded\"\n\n      params = Lucky::Params.new(request)\n\n      expect_raises Lucky::MissingNestedParamError do\n        params.nested(:missing)\n      end\n    end\n\n    it \"does not raise if nested key is set but empty\" do\n      request = build_request body: {user: NamedTuple.new}.to_json,\n        content_type: \"application/json\"\n\n      params = Lucky::Params.new(request)\n      params.nested(:user).should eq(Hash(String, String).new)\n    end\n\n    it \"returns empty hash if nested_params are missing\" do\n      request = build_request body: \"\",\n        content_type: \"application/x-www-form-urlencoded\"\n\n      params = Lucky::Params.new(request)\n\n      params.nested?(:missing).should eq({} of String => String)\n    end\n  end\n\n  describe \"nested_arrays\" do\n    it \"gets nested arrays from form encoded params\" do\n      request = build_request body: \"user:name=paul&user:langs[]=ruby&user:langs[]=elixir\",\n        content_type: \"application/x-www-form-urlencoded\"\n\n      params = Lucky::Params.new(request)\n\n      params.nested_arrays?(:user).should eq({\"langs\" => [\"ruby\", \"elixir\"]})\n    end\n\n    it \"gets nested arrays from JSON params\" do\n      request = build_request body: {user: {name: \"Paul\", langs: [\"ruby\", \"elixir\"]}}.to_json,\n        content_type: \"application/json\"\n      request.query = \"from=query\"\n\n      params = Lucky::Params.new(request)\n\n      params.nested_arrays?(:user).should eq({\"langs\" => [\"ruby\", \"elixir\"]})\n    end\n\n    it \"gets empty JSON params when nested key is missing\" do\n      request = build_request body: \"{}\",\n        content_type: \"application/json\"\n      request.query = \"from=query\"\n\n      params = Lucky::Params.new(request)\n\n      params.nested_arrays?(:user).should eq({} of String => JSON::Any)\n    end\n\n    it \"handles JSON with charset directive in Content-Type header\" do\n      request = build_request body: {user: {name: \"Paul\", langs: [\"ruby\", \"elixir\"]}}.to_json,\n        content_type: \"application/json; charset=UTF-8\"\n\n      params = Lucky::Params.new(request)\n\n      params.nested_arrays?(:user).should eq({\"langs\" => [\"ruby\", \"elixir\"]})\n    end\n\n    it \"gets nested array JSON params mixed with query params\" do\n      request = build_request body: {user: {name: \"Bunyan\", tags: [\"tall\"]}}.to_json,\n        content_type: \"application/json\"\n      request.query = \"user:tags[]=tale\"\n\n      params = Lucky::Params.new(request)\n\n      params.nested_arrays?(:user).should eq({\"tags\" => [\"tall\", \"tale\"]})\n    end\n\n    it \"gets nested arrays from multipart params\" do\n      request = build_multipart_request form_parts: {\n        \"user:name\" => \"Paul\", \"user:langs\" => [\"ruby\", \"elixir\"],\n      }\n\n      params = Lucky::Params.new(request)\n\n      params.nested_arrays?(:user).should eq({\"langs\" => [\"ruby\", \"elixir\"]})\n    end\n\n    it \"gets nested arrays from query params\" do\n      request = build_request body: \"filter:toppings[]=sausage\", content_type: \"\"\n      request.query = \"filter:toppings[]=black_olive\"\n      params = Lucky::Params.new(request)\n      params.nested_arrays?(\"filter\").should eq({\"toppings\" => [\"sausage\", \"black_olive\"]})\n    end\n\n    it \"returns an empty hash when no nested array is found\" do\n      request = build_request body: \"\", content_type: \"\"\n      request.query = \"a[]=1\"\n      params = Lucky::Params.new(request)\n      params.nested_arrays?(\"a\").empty?.should eq true\n    end\n\n    it \"gets nested array params after unescaping\" do\n      request = build_request body: \"post%3Atags[]=coding\",\n        content_type: \"application/x-www-form-urlencoded\"\n\n      params = Lucky::Params.new(request)\n\n      params.nested_arrays?(:post).should eq({\"tags\" => [\"coding\"]})\n    end\n\n    it \"raises if nested array params are missing\" do\n      request = build_request body: \"\",\n        content_type: \"application/x-www-form-urlencoded\"\n\n      params = Lucky::Params.new(request)\n\n      expect_raises Lucky::MissingNestedParamError do\n        params.nested_arrays(:missing)\n      end\n    end\n\n    it \"returns empty hash if nested array params are missing\" do\n      request = build_request body: \"\",\n        content_type: \"application/x-www-form-urlencoded\"\n\n      params = Lucky::Params.new(request)\n\n      params.nested_arrays?(:missing).should eq({} of String => Array(String))\n    end\n  end\n\n  describe \"get_file\" do\n    it \"gets files\" do\n      request = build_multipart_request file_parts: {\n        \"welcome_file\" => \"welcome file contents\",\n      }\n\n      params = Lucky::Params.new(request)\n\n      file = params.get_file(:welcome_file)\n      file.is_a?(Lucky::UploadedFile).should eq(true)\n      File.read(file.path).should eq \"welcome file contents\"\n    end\n\n    it \"gets files alongside params\" do\n      request = build_multipart_request(\n        form_parts: {\n          \"from\" => \"multipart\",\n        },\n        file_parts: {\n          \"with\" => \"a file\",\n        }\n      )\n\n      params = Lucky::Params.new(request)\n\n      file = params.get_file(:with)\n      File.read(file.path).should eq \"a file\"\n    end\n\n    it \"raises if missing a param and using get_file version\" do\n      request = build_multipart_request form_parts: {\"this\" => \"that\"}\n\n      params = Lucky::Params.new(request)\n\n      expect_raises Lucky::MissingParamError do\n        params.get_file(:missing)\n      end\n    end\n\n    it \"returns nil if using get_file? version\" do\n      request = build_multipart_request form_parts: {\"this\" => \"that\"}\n\n      params = Lucky::Params.new(request)\n\n      params.get_file?(:missing).should be_nil\n    end\n  end\n\n  describe \"nested_file\" do\n    it \"gets multipart nested params\" do\n      request = build_multipart_request file_parts: {\n        \"user\" => {\n          \"avatar_file\" => \"binary_image_content\",\n        },\n      }\n\n      params = Lucky::Params.new(request)\n\n      file = params.nested_file(:user)[\"avatar_file\"]\n      File.read(file.path).should eq \"binary_image_content\"\n    end\n\n    it \"raises if nested files are missing and using nested_file! version\" do\n      request = build_multipart_request form_parts: {\"this\" => \"that\"}\n\n      params = Lucky::Params.new(request)\n\n      expect_raises Lucky::MissingNestedParamError do\n        params.nested_file(:missing)\n      end\n    end\n\n    it \"returns empty hash if nested files are missing and using nested_file? version\" do\n      request = build_multipart_request form_parts: {\"this\" => \"that\"}\n\n      params = Lucky::Params.new(request)\n\n      params.nested_file?(:missing).should eq({} of String => File)\n    end\n  end\n\n  describe \"nested_array_files\" do\n    it \"gets multipart nested array params\" do\n      request = build_multipart_request file_parts: {\n        \"user:photos\" => [\"cat\", \"dog\"],\n      }\n\n      params = Lucky::Params.new(request)\n\n      files = params.nested_array_files(:user)[\"photos\"]\n      files.size.should eq(2)\n      File.read(files[0].path).should eq(\"cat\")\n      File.read(files[1].path).should eq(\"dog\")\n    end\n\n    it \"raises if nested array files are missing\" do\n      request = build_multipart_request form_parts: {\"this\" => \"that\"}\n\n      params = Lucky::Params.new(request)\n\n      expect_raises Lucky::MissingNestedParamError do\n        params.nested_array_files(:missing)\n      end\n    end\n\n    it \"returns empty hash if nested array files are missing\" do\n      request = build_multipart_request form_parts: {\"this\" => \"that\"}\n\n      params = Lucky::Params.new(request)\n\n      params.nested_array_files?(:missing).should eq({} of String => Array(Lucky::UploadedFile))\n    end\n  end\n\n  describe \"many_nested\" do\n    it \"gets nested form encoded params\" do\n      request = build_request body: \"users[0]:name=paul&users[1]:twitter_handle=@paulamason&users[0]:twitter_handle=@paulsmith&users[1]:name=paula&something:else=1\",\n        content_type: \"application/x-www-form-urlencoded\"\n\n      params = Lucky::Params.new(request)\n\n      params.many_nested?(:users).should eq([\n        {\"name\" => \"paul\", \"twitter_handle\" => \"@paulsmith\"},\n        {\"twitter_handle\" => \"@paulamason\", \"name\" => \"paula\"},\n      ])\n    end\n\n    it \"gets nested JSON params\" do\n      request = build_request(\n        body: {\n          users: [\n            {name: \"Paul\", age: 28},\n            {name: \"Paula\", age: 43},\n          ],\n        }.to_json,\n        content_type: \"application/json\"\n      )\n      request.query = \"from=query\"\n\n      params = Lucky::Params.new(request)\n\n      params.many_nested?(:users).should eq([\n        {\"name\" => \"Paul\", \"age\" => \"28\"},\n        {\"name\" => \"Paula\", \"age\" => \"43\"},\n      ])\n    end\n\n    it \"gets empty JSON params when nested key is missing\" do\n      request = build_request body: \"{}\",\n        content_type: \"application/json\"\n      request.query = \"from=query\"\n\n      params = Lucky::Params.new(request)\n\n      params.many_nested?(:users).should eq([] of Hash(String, String))\n    end\n\n    it \"handles JSON with charset directive in Content-Type header\" do\n      request = build_request body: {users: [{name: \"Paul\", age: 28}]}.to_json,\n        content_type: \"application/json; charset=UTF-8\"\n\n      params = Lucky::Params.new(request)\n\n      params.many_nested?(:users).should eq([{\"name\" => \"Paul\", \"age\" => \"28\"}])\n    end\n\n    it \"gets nested JSON params mixed with query params\" do\n      request = build_request body: {users: [{name: \"Bunyan\", age: 102}]}.to_json,\n        content_type: \"application/json\"\n      request.query = \"users[0]:active=true\"\n\n      params = Lucky::Params.new(request)\n\n      params.many_nested?(:users).should eq([\n        {\"name\" => \"Bunyan\", \"age\" => \"102\", \"active\" => \"true\"},\n      ])\n    end\n\n    it \"gets nested multipart params\" do\n      request = build_multipart_request form_parts: {\n        \"users\" => [\n          {\"name\" => \"Paul\", \"age\" => \"28\"},\n          {\"name\" => \"Paula\", \"age\" => \"32\"},\n        ],\n      }\n\n      params = Lucky::Params.new(request)\n\n      params.many_nested(:users).should eq([\n        {\"name\" => \"Paul\", \"age\" => \"28\"},\n        {\"name\" => \"Paula\", \"age\" => \"32\"},\n      ])\n    end\n\n    it \"gets nested query params\" do\n      request = build_request body: \"filters[0]:toppings=left_beef&filters[1]:type=none\", content_type: \"\"\n      request.query = \"filters[0]:query=pizza&sort=desc\"\n      params = Lucky::Params.new(request)\n      params.many_nested?(\"filters\").should eq([\n        {\"query\" => \"pizza\", \"toppings\" => \"left_beef\"},\n        {\"type\" => \"none\"},\n      ])\n    end\n\n    it \"returns an empty array when no nested is found\" do\n      request = build_request body: \"\", content_type: \"\"\n      request.query = \"a=1\"\n      params = Lucky::Params.new(request)\n      params.many_nested?(\"a\").empty?.should eq true\n    end\n\n    it \"gets nested params after unescaping\" do\n      request = build_request body: \"users%5B0%5D%3Aname=paul\",\n        content_type: \"application/x-www-form-urlencoded\"\n\n      params = Lucky::Params.new(request)\n\n      params.many_nested?(:users).should eq([{\"name\" => \"paul\"}])\n    end\n\n    it \"raises if nested params are missing\" do\n      request = build_request body: \"\",\n        content_type: \"application/x-www-form-urlencoded\"\n\n      params = Lucky::Params.new(request)\n\n      expect_raises Lucky::MissingNestedParamError do\n        params.many_nested(:missing)\n      end\n    end\n\n    it \"returns empty array if nested_params are missing\" do\n      request = build_request body: \"\",\n        content_type: \"application/x-www-form-urlencoded\"\n\n      params = Lucky::Params.new(request)\n\n      params.many_nested?(:missing).should eq([] of Hash(String, String))\n    end\n  end\n\n  describe \"to_h\" do\n    it \"returns a hash for query_params\" do\n      request = build_request body: \"\", content_type: \"\"\n      request.query = \"filter:name=trombone&page=1&per=50\"\n      params = Lucky::Params.new(request).to_h\n      params.should eq({\"filter\" => {\"name\" => \"trombone\"}, \"page\" => \"1\", \"per\" => \"50\"})\n    end\n\n    it \"returns a hash for body_params\" do\n      request = build_request body: \"filter%3Aname=tuba&page=1&per=50\",\n        content_type: \"application/x-www-form-urlencoded\"\n\n      params = Lucky::Params.new(request).to_h\n      params.should eq({\"filter\" => {\"name\" => \"tuba\"}, \"page\" => \"1\", \"per\" => \"50\"})\n    end\n\n    it \"returns a hash for multipart_params\" do\n      request = build_multipart_request form_parts: {\"filter\" => {\"name\" => \"baritone\"}}\n\n      params = Lucky::Params.new(request).to_h\n      params.should eq({\"filter\" => {\"name\" => \"baritone\"}})\n    end\n\n    it \"returns a hash for json_params\" do\n      request = build_request body: {filter: {name: \"euphonium\"}}.to_json,\n        content_type: \"application/json\"\n      request.query = \"page=1&per=50\"\n\n      params = Lucky::Params.new(request).to_h\n      params.should eq({\"filter\" => {\"name\" => \"euphonium\"}, \"page\" => \"1\", \"per\" => \"50\"})\n    end\n  end\n\n  describe \"Setting route_params later\" do\n    it \"returns the correct values for get?\" do\n      request = build_request body: \"\", content_type: \"\"\n      route_params = {\"id\" => \"from_route\"}\n      params = Lucky::Params.new(request)\n\n      params.get?(:id).should eq nil\n\n      params.route_params = route_params\n      params.get?(:id).should eq \"from_route\"\n    end\n  end\nend\n"
  },
  {
    "path": "spec/lucky/pretty_log_formatter_spec.cr",
    "content": "require \"../spec_helper\"\n\ninclude ContextHelper\n\ndescribe Lucky::PrettyLogFormatter do\n  context \"special cases\" do\n    it \"pretty formats data for the start of an HTTP request\" do\n      io = IO::Memory.new\n      format(io, {method: \"GET\", path: \"/foo\", request_id: nil})\n\n      io.to_s.chomp.should start_with(\"\\nGET #{\"/foo\".colorize.underline}\")\n    end\n\n    it \"pretty formats data for the end of an HTTP request\" do\n      io = IO::Memory.new\n      format(io, {status: 200, duration: \"1.4ms\", request_id: nil})\n\n      io.to_s.chomp.should start_with(\" #{\"▸\".colorize.dim} Sent #{\"200 OK\".colorize.bold} (1.4ms)\")\n    end\n\n    it \"includes the request_id in the start of an HTTP request\" do\n      io = IO::Memory.new\n      format(io, {method: \"GET\", path: \"/foo\", request_id: \"abc123\"})\n\n      io.to_s.chomp.should start_with(\"\\nGET #{\"/foo\".colorize.underline} (#{\"abc123\".colorize.dim})\")\n    end\n\n    it \"includes the request_id in the end of an HTTP request\" do\n      io = IO::Memory.new\n      format(io, {status: 200, duration: \"1.4ms\", request_id: \"abc123\"})\n\n      io.to_s.chomp.should start_with(\" #{\"▸\".colorize.dim} Sent #{\"200 OK\".colorize.bold} (1.4ms) (#{\"abc123\".colorize.dim})\")\n    end\n\n    context \"when request_id is empty\" do\n      it \"does not include empty () in the end of an HTTP request\" do\n        io = IO::Memory.new\n        format(io, {status: 200, duration: \"1.4ms\", request_id: \"\"})\n\n        io.to_s.chomp.should eq(\" #{\"▸\".colorize.dim} Sent #{\"200 OK\".colorize.bold} (1.4ms)\")\n      end\n    end\n  end\n\n  context \"when given data that is not the start/end of an HTTP request \" do\n    it \"prints message text with an arrow\" do\n      io = IO::Memory.new\n\n      format(io, data: nil, message: \"some text\")\n\n      io.to_s.chomp.should eq \" #{\"▸\".colorize.dim} some text\"\n    end\n\n    it \"humanizes keys in key value pairs and prints with an arrow\" do\n      io = IO::Memory.new\n      format(io, {failed_to_save: \"SignUpForm\"})\n\n      io.to_s.chomp.should eq(\" #{\"▸\".colorize.dim} Failed to save #{\"SignUpForm\".colorize.bold}\")\n    end\n\n    it \"formats multiple key value pairs\" do\n      io = IO::Memory.new\n      format(io, {first_thing: \"one\", second_thing: \"two\"})\n\n      io.to_s.chomp.should eq(\" #{\"▸\".colorize.dim} First thing #{\"one\".colorize.bold}. Second thing #{\"two\".colorize.bold}\")\n    end\n  end\n\n  it \"uses a red arrow for ERRORS and above\" do\n    io = IO::Memory.new\n\n    format(io, severity: Log::Severity::Error, data: {message: \"anything\"})\n\n    io.to_s.chomp.should start_with(\" #{\"▸\".colorize.red} Message\")\n  end\n\n  it \"uses a yellow arrow for warnings and colors the first value\" do\n    io = IO::Memory.new\n    format(io, severity: Log::Severity::Warn, data: {first: \"message\", second: \"message\"})\n\n    io.to_s.chomp.should eq(\" #{\"▸\".colorize.yellow} First #{\"message\".colorize.yellow.bold}. Second #{\"message\".colorize.bold}\")\n  end\n\n  it \"formats exceptions\" do\n    io = IO::Memory.new\n    ex = RuntimeError.new(\"Oops that wasn't supposed to happen\")\n\n    format(io, severity: Log::Severity::Error, data: nil, exception: ex)\n\n    io.to_s.should start_with(\" #{\"▸\".colorize.red}\")\n    io.to_s.should contain(\" #{ex.class.name} \".colorize.bold.on_red.to_s)\n  end\nend\n\nprivate def format(io, data : NamedTuple?, message : String = \"\", severity = Log::Severity::Info, exception : Exception? = nil)\n  Log.with_context do\n    Log.context.set(local: data) if data\n\n    entry = Log::Entry.new \\\n      source: \"lucky-test\",\n      message: message,\n      severity: severity,\n      data: Log::Metadata.build(Log::Metadata.empty),\n      exception: exception\n\n    Lucky::PrettyLogFormatter.new(\n      entry: entry,\n      io: io\n    ).call\n  end\nend\n"
  },
  {
    "path": "spec/lucky/protect_from_forgery_spec.cr",
    "content": "require \"../spec_helper\"\n\ninclude ContextHelper\n\nclass ProtectedAction::Index < TestAction\n  include Lucky::ProtectFromForgery\n\n  get \"/protected_action\" { plain_text \"Passed\" }\nend\n\ndescribe Lucky::ProtectFromForgery do\n  it \"sets a CSRF token if none was set\" do\n    context = build_context(method: \"GET\")\n\n    ProtectedAction::Index.new(context, params).call\n\n    (context.session.get(\"X-CSRF-TOKEN\").size > 30).should be_true\n  end\n\n  it \"continues if the token in the parameter is correct\" do\n    context = build_context(method: \"POST\")\n    context.session.set(\"X-CSRF-TOKEN\", \"my_token\")\n    params = {\"_csrf\" => \"my_token\"}\n\n    response = ProtectedAction::Index.new(context, params).call\n\n    response.status.should eq(200)\n    response.body.should eq(\"Passed\")\n  end\n\n  it \"continues if the token in the header is correct\" do\n    context = build_context(method: \"POST\")\n    context.session.set(\"X-CSRF-TOKEN\", \"my_token\")\n    context.request.headers[\"X-CSRF-TOKEN\"] = \"my_token\"\n\n    response = ProtectedAction::Index.new(context, params).call\n\n    response.status.should eq(200)\n    response.body.should eq(\"Passed\")\n  end\n\n  it \"halts with 403 if the header token is incorrect\" do\n    context = build_context(method: \"POST\")\n    context.session.set(\"X-CSRF-TOKEN\", \"my_token\")\n    context.request.headers[\"X-CSRF-TOKEN\"] = \"incorrect\"\n\n    response = ProtectedAction::Index.new(context, params).call\n\n    response.status.should eq(403)\n    response.body.should eq(\"\")\n  end\n\n  it \"halts with 403 if the param token is incorrect\" do\n    context = build_context(method: \"POST\")\n    context.session.set(\"X-CSRF-TOKEN\", \"my_token\")\n    params = {\"_csrf\" => \"incorrect\"}\n\n    response = ProtectedAction::Index.new(context, params).call\n\n    response.status.should eq(403)\n    response.body.should eq(\"\")\n  end\n\n  it \"halts with 403 if there is no token\" do\n    context = build_context(method: \"POST\")\n    context.session.set(\"X-CSRF-TOKEN\", \"my_token\")\n\n    response = ProtectedAction::Index.new(context, params).call\n\n    response.status.should eq(403)\n    response.body.should eq(\"\")\n  end\n\n  it \"halts with 403 if no CSRF token in the session\" do\n    context = build_context(method: \"POST\")\n\n    response = ProtectedAction::Index.new(context, params).call\n\n    response.status.should eq(403)\n    response.body.should eq(\"\")\n  end\n\n  it \"lets allowed HTTP methods through without a token\" do\n    Lucky::ProtectFromForgery::ALLOWED_METHODS.each do |http_method|\n      context = build_context(method: http_method)\n\n      response = ProtectedAction::Index.new(context, params).call\n\n      response.status.should eq(200)\n      response.body.should eq(\"Passed\")\n    end\n  end\nend\n"
  },
  {
    "path": "spec/lucky/quick_def_spec.cr",
    "content": "require \"../spec_helper\"\n\nprivate class QuickDefClass\n  quick_def :page_title, \"My page title\"\n  quick_def page_title_without_symbol, \"My page title\"\nend\n\ndescribe \"Object.quick_def\" do\n  it \"creates an instance method\" do\n    QuickDefClass.new.page_title.should eq \"My page title\"\n    QuickDefClass.new.page_title_without_symbol.should eq \"My page title\"\n  end\nend\n"
  },
  {
    "path": "spec/lucky/rate_limit_spec.cr",
    "content": "require \"../spec_helper\"\n\ninclude ContextHelper\n\nclass RateLimitRoutes::Index < TestAction\n  include Lucky::RateLimit\n\n  get \"/rate_limit\" do\n    plain_text \"hello\"\n  end\n\n  def rate_limit : NamedTuple(to: Int32, within: Time::Span)\n    {to: 1, within: 1.minute}\n  end\n\n  private def rate_limit_identifier : String\n    \"test-key\"\n  end\nend\n\nclass RateLimitRoutesWithMacro::Index < TestAction\n  include Lucky::RateLimit\n  rate_limit to: 1, within: 1.minute\n\n  get \"/rate_limit_2\" do\n    plain_text \"hello\"\n  end\n\n  private def rate_limit_identifier : String\n    \"test-key\"\n  end\nend\n\ndescribe Lucky::RateLimit do\n  describe \"RateLimit\" do\n    it \"when request count is less than the rate limit\" do\n      with_memory_store do\n        headers = HTTP::Headers.new\n        headers[\"X_FORWARDED_FOR\"] = \"127.0.0.1\"\n        request = HTTP::Request.new(\"GET\", \"/rate_limit\", body: \"\", headers: headers)\n        context = build_context(request)\n\n        route = RateLimitRoutes::Index.new(context, params).call\n        route.context.response.status.should eq(HTTP::Status::OK)\n      end\n    end\n\n    it \"when request count is over the rate limit\" do\n      with_memory_store do\n        headers = HTTP::Headers.new\n        headers[\"X_FORWARDED_FOR\"] = \"127.0.0.1\"\n        request = HTTP::Request.new(\"GET\", \"/rate_limit_2\", body: \"\", headers: headers)\n        context = build_context(request)\n\n        10.times do\n          RateLimitRoutesWithMacro::Index.new(context, params).call\n        end\n\n        route = RateLimitRoutesWithMacro::Index.new(context, params).call\n        route.context.response.status.should eq(HTTP::Status::TOO_MANY_REQUESTS)\n      end\n    end\n  end\nend\n\nprivate def with_memory_store(&)\n  LuckyCache.temp_config(storage: LuckyCache::MemoryStore.new) do\n    yield\n  end\nend\n"
  },
  {
    "path": "spec/lucky/remote_ip_handler_spec.cr",
    "content": "require \"../spec_helper\"\n\ninclude ContextHelper\n\ndescribe Lucky::RemoteIpHandler do\n  describe \"getting the remote_address\" do\n    it \"returns nil when no remote IP is found\" do\n      context = build_context(path: \"/path\")\n\n      run_remote_ip_handler(context)\n      context.request.remote_address.should eq nil\n      context.request.remote_ip.should eq \"\"\n    end\n\n    it \"returns the X_FORWARDED_FOR address\" do\n      headers = HTTP::Headers.new\n      headers[\"X_FORWARDED_FOR\"] = \"127.0.0.1,1.2.3.4\"\n      request = HTTP::Request.new(\"GET\", \"/remote-ip\", body: \"\", headers: headers)\n      context = build_context(request)\n\n      run_remote_ip_handler(context)\n      context.request.remote_address.should be_a(Socket::IPAddress)\n      context.request.remote_address.should eq Socket::IPAddress.new(\"1.2.3.4\", 0)\n      context.request.remote_ip.should eq(\"1.2.3.4\")\n    end\n\n    it \"returns request.remote_address if X_FORWARDED_FOR is not valid\" do\n      headers = HTTP::Headers.new\n      headers[\"X_FORWARDED_FOR\"] = \"not-a-socket\"\n      request = HTTP::Request.new(\"GET\", \"/remote-ip\", body: \"\", headers: headers)\n      request.remote_address = Socket::IPAddress.new(\"255.255.255.255\", 0)\n      context = build_context(request)\n\n      run_remote_ip_handler(context)\n      context.request.remote_address.should be_a(Socket::IPAddress)\n      context.request.remote_address.should eq Socket::IPAddress.new(\"255.255.255.255\", 0)\n      context.request.remote_ip.should eq(\"255.255.255.255\")\n    end\n\n    it \"returns nil if the X_FORWARDED_FOR is an empty string, and no default remote_address is found\" do\n      headers = HTTP::Headers.new\n      headers[\"X_FORWARDED_FOR\"] = \"\"\n      request = HTTP::Request.new(\"GET\", \"/remote-ip\", body: \"\", headers: headers)\n      context = build_context(request)\n\n      run_remote_ip_handler(context)\n      context.request.remote_address.should eq nil\n      context.request.remote_ip.should eq \"\"\n    end\n\n    it \"returns the original remote_address\" do\n      request = HTTP::Request.new(\"GET\", \"/remote-ip\", body: \"\", headers: HTTP::Headers.new)\n      request.remote_address = Socket::IPAddress.new(\"255.255.255.255\", 0)\n      context = build_context(request)\n\n      run_remote_ip_handler(context)\n      context.request.remote_address.should be_a(Socket::IPAddress)\n      context.request.remote_address.should eq Socket::IPAddress.new(\"255.255.255.255\", 0)\n      context.request.remote_ip.should eq(\"255.255.255.255\")\n    end\n  end\nend\n\nprivate def run_remote_ip_handler(context)\n  handler = Lucky::RemoteIpHandler.new\n  handler.next = ->(_ctx : HTTP::Server::Context) { }\n  handler.call(context)\nend\n"
  },
  {
    "path": "spec/lucky/render_if_defined_spec.cr",
    "content": "require \"../spec_helper\"\n\ninclude ContextHelper\n\nabstract class LayoutWithOptionalSidebar\n  include Lucky::HTMLPage\n\n  def render : String\n    render_if_defined :sidebar\n    view.to_s\n  end\nend\n\nprivate class PageWithSidebar < LayoutWithOptionalSidebar\n  def sidebar\n    text \"In the sidebar\"\n  end\nend\n\nprivate class PageWithoutSidebar < LayoutWithOptionalSidebar\nend\n\ndescribe \"render_if_defined\" do\n  it \"renders if the method is defined, otherwise it does nothing\" do\n    page_with_sidebar = PageWithSidebar.new(build_context)\n    page_without_sidebar = PageWithoutSidebar.new(build_context)\n\n    page_with_sidebar.render.to_s.should eq \"In the sidebar\"\n    page_without_sidebar.render.to_s.should eq \"\"\n  end\nend\n"
  },
  {
    "path": "spec/lucky/request_expectations_spec.cr",
    "content": "require \"../spec_helper\"\n\ninclude Lucky::RequestExpectations\n\ndescribe Lucky::RequestExpectations do\n  it \"fails if the status is incorrect\" do\n    failure_message = \"Expected status of 200. Instead got 201.\"\n    expect_raises Spec::AssertionFailed, failure_message do\n      response = build_response(201)\n      response.should send_json(200)\n    end\n  end\n\n  it \"fails if the JSON is not parseable\" do\n    failure_message = \"Response body is not valid JSON.\"\n    expect_raises Spec::AssertionFailed, failure_message do\n      response = build_response(200, body: \"this is not json\")\n      response.should send_json(200)\n    end\n  end\n\n  it \"fails if an expected key is missing\" do\n    failure_message = /Expected response to have JSON key \"admin\"/\n    expect_raises Spec::AssertionFailed, failure_message do\n      response = build_response(200, {foo: \"bar\"}.to_json)\n      response.should send_json(200, admin: true)\n    end\n  end\n\n  it \"fails if expected value is 'nil' and actual response key is missing\" do\n    failure_message = /Expected response to have JSON key \"name\"/\n    expect_raises Spec::AssertionFailed, failure_message do\n      response = build_response(200, \"{}\")\n      response.should send_json(200, name: nil)\n    end\n  end\n\n  it \"fails if key is present but value is incorrect\" do\n    failure_message = /JSON response was incorrect/\n    expect_raises Spec::AssertionFailed, failure_message do\n      response = build_response(200, {admin: \"true\"}.to_json)\n      response.should send_json(200, admin: true)\n    end\n  end\n\n  it \"passes if the response is exactly the same\" do\n    response = build_response(200, {status: \"success\"}.to_json)\n    response.should send_json(200, {status: \"success\"})\n  end\n\n  it \"passes if nested values are JSON objects\" do\n    response = build_response(200, {name: \"Paul\", comment: {id: 1}}.to_json)\n    response.should send_json(200, name: \"Paul\", comment: {id: 1})\n  end\n\n  it \"passes if the response has matching keys/values and ignores extra keys/values\" do\n    response = build_response(200, {name: \"Paul\", dont_care: \"about this\"}.to_json)\n    response.should send_json(200, name: \"Paul\")\n  end\n\n  it \"passes if responses are different and we're expecting that\" do\n    response = build_response(201, {name: \"Paul\"}.to_json)\n    response.should_not send_json(200, name: \"Paul\")\n\n    failure_message = /Didn't expect JSON response to match/\n    expect_raises Spec::AssertionFailed, failure_message do\n      response = build_response(200, {admin: true}.to_json)\n      response.should_not send_json(200, admin: true)\n    end\n  end\nend\n\nprivate def build_response(status : Int32, body : String = \"{}\")\n  HTTP::Client::Response.new(\n    status_code: status,\n    body: body\n  )\nend\n"
  },
  {
    "path": "spec/lucky/request_type_helper_spec.cr",
    "content": "require \"../spec_helper\"\n\nprivate class FakeAction\n  include Lucky::RequestTypeHelpers\n  default_format :my_default_format\n\n  property context : HTTP::Server::Context = ContextHelper.build_context\n  class_property _accepted_formats = [] of Symbol\n\n  delegate request, to: context\nend\n\ndescribe Lucky::RequestTypeHelpers do\n  it \"determines the format from 'Accept' header correctly\" do\n    Lucky::MimeType.accept_header_formats.each do |header, format|\n      override_accept_header header.to_s do |action|\n        action.accepts?(format).should be_true\n      end\n    end\n  end\n\n  it \"uses the default if no header is set\" do\n    override_accept_header \"\" do |action|\n      action.accepts?(:my_default_format).should be_true\n    end\n  end\n\n  it \"doesn't use the default if header is given\" do\n    override_accept_header \"application/json\" do |action|\n      action.accepts?(:my_default_format).should be_false\n    end\n  end\n\n  it \"raises if the format is unknown\" do\n    expect_raises Lucky::UnknownAcceptHeaderError do\n      override_accept_header \"wut\" do |action|\n        action.accepts?(:blow_up)\n      end\n    end\n  end\n\n  it \"checks if client accepts JSON\" do\n    override_format :json, &.json?.should(be_true)\n    override_format :foo, &.json?.should(be_false)\n  end\n\n  it \"checks if client accepts HTML\" do\n    override_format :html, &.html?.should(be_true)\n    override_format :foo, &.html?.should(be_false)\n  end\n\n  it \"checks if client accepts XML\" do\n    override_format :xml, &.xml?.should(be_true)\n    override_format :foo, &.xml?.should(be_false)\n  end\n\n  it \"checks if client accepts plain text\" do\n    override_format :plain_text, &.plain_text?.should(be_true)\n    override_format :foo, &.plain_text?.should(be_false)\n  end\n\n  it \"checks if request send via AJAX\" do\n    action = FakeAction.new\n    action.context._clients_desired_format = :ajax\n    action.ajax?.should(be_false)\n\n    action.context.request.headers[\"X-Requested-With\"] = \"XMLHttpRequest\"\n    action.ajax?.should(be_true)\n  end\n\n  it \"checks if request is multipart\" do\n    action = FakeAction.new\n    action.multipart?.should(be_false)\n\n    action.context.request.headers[\"Content-Type\"] = \"multipart/form-data; boundary=\"\n    action.multipart?.should(be_true)\n  end\nend\n\nprivate def override_format(format : Symbol?, &)\n  action = FakeAction.new\n  action.context._clients_desired_format = format\n  yield action\nend\n\nprivate def override_accept_header(accept_header : String, &)\n  action = FakeAction.new\n  action.context.request.headers[\"accept\"] = accept_header\n  yield action\nend\n"
  },
  {
    "path": "spec/lucky/root_spec.cr",
    "content": "require \"../spec_helper\"\n\nclass RootAction < TestAction\n  get \"/\" do\n    plain_text \"Hello there\"\n  end\nend\n\ndescribe \"root helpers\" do\n  it \"renders as /\" do\n    RootAction.path.should eq \"/\"\n    RootAction.route.path.should eq \"/\"\n  end\nend\n"
  },
  {
    "path": "spec/lucky/route_handler_format_spec.cr",
    "content": "require \"../spec_helper\"\n\ninclude ContextHelper\n\ndescribe \"Route Handler Format Integration\" do\n  describe \"path manipulation edge cases\" do\n    it \"handles route handler path stripping correctly\" do\n      original_path = \"/reports/123.csv\"\n\n      # The route handler should extract format but we can't easily test the internal\n      # path modification without refactoring. Let's test the format extraction directly.\n\n      # Verify format extraction works correctly\n      format = Lucky::MimeType.extract_format_from_path(original_path)\n      format.should eq Lucky::Format::Csv\n\n      # Verify path stripping regex works correctly\n      path_without_format = original_path.sub(/\\.[a-zA-Z0-9]+(?:\\?.*)?$/, \"\")\n      path_without_format.should eq \"/reports/123\"\n    end\n\n    it \"handles complex paths with route handler\" do\n      test_cases = [\n        {\"/api/v1/users/123.json\", \"/api/v1/users/123\", Lucky::Format::Json},\n        {\"/reports/sales-2023.csv\", \"/reports/sales-2023\", Lucky::Format::Csv},\n        {\"/assets/styles/main.css\", \"/assets/styles/main\", Lucky::Format::Css},\n        {\"/data/export.xml?version=2\", \"/data/export\", Lucky::Format::Xml},\n      ]\n\n      test_cases.each do |original, expected_stripped, expected_format|\n        # Test format extraction\n        Lucky::MimeType.extract_format_from_path(original).should eq expected_format\n\n        # Test path stripping\n        stripped = original.sub(/\\.[a-zA-Z0-9]+(?:\\?.*)?$/, \"\")\n        stripped.should eq expected_stripped\n      end\n    end\n\n    it \"preserves query parameters when stripping format\" do\n      path = \"/reports/123.csv?foo=bar&baz=qux\"\n\n      # Should extract format correctly\n      Lucky::MimeType.extract_format_from_path(path).should eq Lucky::Format::Csv\n\n      # Should strip format but preserve query params for routing\n      # Note: The actual route handler strips format for route matching,\n      # but query params should be preserved in the original request\n\n      context = build_context(path: path)\n      context.request.query.should eq \"foo=bar&baz=qux\"\n    end\n\n    it \"handles paths without formats gracefully\" do\n      paths_without_formats = [\n        \"/reports/123\",\n        \"/api/users\",\n        \"/data/export\",\n        \"/\",\n        \"/assets/styles/main\",\n        \"/complex/path/with/segments\",\n      ]\n\n      paths_without_formats.each do |path|\n        Lucky::MimeType.extract_format_from_path(path).should be_nil\n\n        # Path stripping should not modify paths without formats\n        stripped = path.sub(/\\.[a-zA-Z0-9]+(?:\\?.*)?$/, \"\")\n        stripped.should eq path\n      end\n    end\n  end\n\n  describe \"HTTP::Request edge cases\" do\n    it \"handles request creation for route matching\" do\n      # Test that we can create requests with different paths for route matching\n      original_path = \"/reports/123.csv\"\n      stripped_path = \"/reports/123\"\n      method = \"GET\"\n\n      # Create original request\n      original_request = HTTP::Request.new(method, original_path)\n      original_request.path.should eq original_path\n\n      # Create modified request for route matching\n      modified_request = HTTP::Request.new(method, stripped_path)\n      modified_request.path.should eq stripped_path\n\n      # Both requests should have same method but different paths\n      original_request.method.should eq method\n      modified_request.method.should eq method\n      original_request.path.should_not eq modified_request.path\n    end\n\n    it \"handles edge case HTTP methods with formats\" do\n      methods = [\"GET\", \"POST\", \"PUT\", \"DELETE\", \"PATCH\", \"HEAD\", \"OPTIONS\"]\n\n      methods.each do |method|\n        request = HTTP::Request.new(method, \"/api/data.json\")\n        context = build_context(request)\n        Lucky::MimeType.extract_format_from_path(context.request.path).should eq Lucky::Format::Json\n      end\n    end\n  end\n\n  describe \"Routing integration scenarios\" do\n    it \"handles nested routes with formats\" do\n      nested_paths = [\n        \"/api/v1/users/123/posts/456.json\",\n        \"/admin/reports/sales/2023/january.csv\",\n        \"/assets/styles/main.css\", # Use CSS instead of SVG (which isn't registered)\n        \"/api/v2/resources/type/subtype.xml\",\n      ]\n\n      nested_paths.each do |path|\n        format = Lucky::MimeType.extract_format_from_path(path)\n        format.should_not be_nil\n\n        # Should strip format for routing\n        stripped = path.sub(/\\.[a-zA-Z0-9]+(?:\\?.*)?$/, \"\")\n        stripped.should_not eq path # Should have been modified\n        stripped.includes?(\".\").should be_false\n      end\n    end\n\n    it \"handles conflicting route patterns gracefully\" do\n      # Test scenarios where routes might conflict\n      # e.g., /users/:id vs /users/:id.format\n\n      test_cases = [\n        \"/users/123\",      # Should match /users/:id\n        \"/users/123.json\", # Should also match /users/:id (with format stripped)\n        \"/posts/abc\",      # Should match /posts/:slug\n        \"/posts/abc.xml\",  # Should also match /posts/:slug (with format stripped)\n      ]\n\n      test_cases.each do |path|\n        # Extract format if present\n        format = Lucky::MimeType.extract_format_from_path(path)\n\n        # Strip format for routing\n        routing_path = path.sub(/\\.[a-zA-Z0-9]+(?:\\?.*)?$/, \"\")\n\n        # Both should route to the same pattern\n        if path.includes?(\".\")\n          format.should_not be_nil\n          routing_path.should_not eq path\n        else\n          format.should be_nil\n          routing_path.should eq path\n        end\n      end\n    end\n  end\n\n  describe \"Error handling scenarios\" do\n    it \"handles extremely malformed paths gracefully\" do\n      malformed_paths = [\n        \"\",\n        \".\",\n        \".json\",\n        \"/.csv\",\n        \"/..xml\",\n        \"/path/.\",\n        \"/path/.json\",\n        \"/path/file..csv\",\n        \"/path/file.json.xml\",\n        \"not-a-url.json\",\n      ]\n\n      malformed_paths.each do |path|\n        # Should not crash on malformed input\n        Lucky::MimeType.extract_format_from_path(path)\n        # Some may return nil, some may return a format, but none should crash\n      end\n    end\n\n    it \"handles memory pressure scenarios\" do\n      # Test with many simultaneous format detections\n      1000.times do |i|\n        path = \"/test/file#{i}.json\"\n        Lucky::MimeType.extract_format_from_path(path).should eq Lucky::Format::Json\n      end\n    end\n\n    it \"handles unicode and international paths\" do\n      international_paths = [\n        \"/测试/file.json\",\n        \"/тест/файл.csv\",\n        \"/δοκιμή/αρχείο.xml\",\n        \"/テスト/ファイル.html\",\n        \"/🎉/🎊.json\",\n      ]\n\n      international_paths.each do |path|\n        # Should extract format correctly regardless of path content\n        format = Lucky::MimeType.extract_format_from_path(path)\n        format.should_not be_nil\n      end\n    end\n  end\nend\n"
  },
  {
    "path": "spec/lucky/route_helper_format_spec.cr",
    "content": "require \"../spec_helper\"\n\ninclude ContextHelper\n\nclass FormatBlog::Index < TestAction\n  accepted_formats [:html, :rss], default: :html\n\n  get \"/format_blog\" do\n    plain_text \"blog\"\n  end\nend\n\nclass FormatBlog::Post::Show < TestAction\n  accepted_formats [:html, :rss, :json], default: :html\n\n  get \"/format_blog/posts/:id\" do\n    plain_text \"post\"\n  end\nend\n\ndescribe \"Route helper format\" do\n  describe \"Lucky::RouteHelper.resolve_extension\" do\n    it \"resolves known format symbols\" do\n      Lucky::RouteHelper.resolve_extension(:rss).should eq(\"rss\")\n      Lucky::RouteHelper.resolve_extension(:json).should eq(\"json\")\n      Lucky::RouteHelper.resolve_extension(:html).should eq(\"html\")\n    end\n\n    it \"resolves plain_text to txt\" do\n      Lucky::RouteHelper.resolve_extension(:plain_text).should eq(\"txt\")\n    end\n  end\n\n  describe \"Lucky::RouteHelper.insert_extension\" do\n    it \"appends the extension to the path\" do\n      Lucky::RouteHelper.insert_extension(\"/blog\", \"rss\").should eq(\"/blog.rss\")\n    end\n\n    it \"inserts the extension before query params\" do\n      Lucky::RouteHelper.insert_extension(\"/blog?page=1\", \"rss\").should eq(\"/blog.rss?page=1\")\n    end\n\n    it \"inserts the extension before anchors\" do\n      Lucky::RouteHelper.insert_extension(\"/blog#top\", \"rss\").should eq(\"/blog.rss#top\")\n    end\n\n    it \"returns the path unchanged for empty extensions\" do\n      Lucky::RouteHelper.insert_extension(\"/blog\", \"\").should eq(\"/blog\")\n    end\n  end\n\n  describe \".as_*\" do\n    it \"generates a path with the format extension\" do\n      FormatBlog::Index.as_rss.path.should eq(\"/format_blog.rss\")\n    end\n\n    it \"generates a url with the format extension\" do\n      FormatBlog::Index.as_rss.url.should eq(\"luckyframework.org/format_blog.rss\")\n    end\n  end\n\n  describe \".as_*.with()\" do\n    it \"appends the format after path params\" do\n      FormatBlog::Post::Show.as_rss.with(id: 123).path.should eq(\"/format_blog/posts/123.rss\")\n    end\n\n    it \"works with different formats\" do\n      FormatBlog::Post::Show.as_json.with(id: 456).path.should eq(\"/format_blog/posts/456.json\")\n    end\n  end\n\n  describe \".with().as_*\" do\n    it \"appends the format after path params\" do\n      FormatBlog::Post::Show.with(id: 123).as_rss.path.should eq(\"/format_blog/posts/123.rss\")\n    end\n  end\nend\n"
  },
  {
    "path": "spec/lucky/route_helper_spec.cr",
    "content": "require \"../spec_helper\"\n\ninclude ContextHelper\n\nclass TestSubdomainAction < TestAction\n  get \"/dashboard\" do\n    plain_text \"admin dashboard\"\n  end\nend\n\nclass TestParamAction < TestAction\n  param page : Int32 = 1\n\n  get \"/posts\" do\n    plain_text \"posts page #{page}\"\n  end\nend\n\ndescribe Lucky::RouteHelper do\n  describe \"url\" do\n    it \"returns the host + path\" do\n      Lucky::RouteHelper.temp_config(base_uri: \"example.com\") do\n        route = Lucky::RouteHelper.new(:get, \"/users\")\n\n        route.url.should eq(\"example.com/users\")\n      end\n    end\n\n    it \"returns the subdomain + host + path when subdomain is provided\" do\n      Lucky::RouteHelper.temp_config(base_uri: \"https://example.com\") do\n        route = Lucky::RouteHelper.new(:get, \"/users\", \"admin\")\n\n        route.url.should eq(\"https://admin.example.com/users\")\n      end\n    end\n\n    it \"replaces existing subdomain when subdomain is provided\" do\n      Lucky::RouteHelper.temp_config(base_uri: \"https://www.example.com\") do\n        route = Lucky::RouteHelper.new(:get, \"/users\", \"admin\")\n\n        route.url.should eq(\"https://admin.example.com/users\")\n      end\n    end\n\n    it \"handles port numbers correctly with subdomains\" do\n      Lucky::RouteHelper.temp_config(base_uri: \"http://example.com:3000\") do\n        route = Lucky::RouteHelper.new(:get, \"/users\", \"admin\")\n\n        route.url.should eq(\"http://admin.example.com:3000/users\")\n      end\n    end\n\n    it \"works without subdomain when none is provided\" do\n      Lucky::RouteHelper.temp_config(base_uri: \"https://example.com\") do\n        route = Lucky::RouteHelper.new(:get, \"/users\", nil)\n\n        route.url.should eq(\"https://example.com/users\")\n      end\n    end\n  end\n\n  describe \".with subdomain support\" do\n    it \"generates URLs with subdomains using .with() method\" do\n      Lucky::RouteHelper.temp_config(base_uri: \"https://example.com\") do\n        route = TestSubdomainAction.with(subdomain: \"admin\")\n\n        route.url.should eq(\"https://admin.example.com/dashboard\")\n        route.path.should eq(\"/dashboard\")\n      end\n    end\n\n    it \"generates URLs with subdomains and params using .with() method\" do\n      Lucky::RouteHelper.temp_config(base_uri: \"https://example.com\") do\n        route = TestParamAction.with(page: 2, subdomain: \"blog\")\n\n        route.url.should eq(\"https://blog.example.com/posts?page=2\")\n        route.path.should eq(\"/posts?page=2\")\n      end\n    end\n\n    it \"generates URLs without subdomain when not specified in .with()\" do\n      Lucky::RouteHelper.temp_config(base_uri: \"https://example.com\") do\n        route = TestSubdomainAction.with\n\n        route.url.should eq(\"https://example.com/dashboard\")\n        route.path.should eq(\"/dashboard\")\n      end\n    end\n\n    it \"handles anchors with subdomains\" do\n      Lucky::RouteHelper.temp_config(base_uri: \"https://example.com\") do\n        route = TestSubdomainAction.with(subdomain: \"admin\", anchor: \"top\")\n\n        route.url.should eq(\"https://admin.example.com/dashboard#top\")\n      end\n    end\n\n    it \"replaces existing subdomain in base_uri when subdomain is specified\" do\n      Lucky::RouteHelper.temp_config(base_uri: \"https://www.example.com\") do\n        route = TestSubdomainAction.with(subdomain: \"admin\")\n\n        route.url.should eq(\"https://admin.example.com/dashboard\")\n      end\n    end\n  end\nend\n"
  },
  {
    "path": "spec/lucky/route_not_found_error_spec.cr",
    "content": "require \"../spec_helper\"\n\ninclude ContextHelper\n\ndescribe Lucky::RouteNotFoundError do\n  it \"has getter for the context\" do\n    context = build_context(path: \"/foo/bar\")\n\n    error = Lucky::RouteNotFoundError.new(context)\n\n    error.context.should eq context\n  end\nend\n"
  },
  {
    "path": "spec/lucky/route_not_found_handler_spec.cr",
    "content": "require \"../spec_helper\"\n\ninclude ContextHelper\n\n# TestFallbackAction::Index is defined in spec/support/test_fallback_action.cr\n# so that it can be used in other tests without causing conflicts.\ndescribe Lucky::RouteNotFoundHandler do\n  it \"raises a Lucky::RouteNotFoundError\" do\n    context = build_context(path: \"/foo/bar\")\n    context.request.method = \"POST\"\n\n    expect_raises(Lucky::RouteNotFoundError, \"POST /foo/bar\") do\n      error_handler = Lucky::RouteNotFoundHandler.new\n      error_handler.next = ->(_ctx : HTTP::Server::Context) { }\n      error_handler.call(context)\n    end\n  end\n\n  it \"has the fallback_action set from a fallback route\" do\n    Lucky::RouteNotFoundHandler.fallback_action.should eq TestFallbackAction::Index\n  end\n\n  it \"responds with a fallback action\" do\n    output = IO::Memory.new\n    context = build_context_with_io(output, path: \"/non-existent\")\n    context.request.method = \"GET\"\n    handler = Lucky::RouteNotFoundHandler.new\n    handler.next = ->(_ctx : HTTP::Server::Context) { }\n\n    handler.call(context)\n\n    context.response.close\n    output.to_s.should contain \"You found me\"\n  end\n\n  it \"still raises a Lucky::RouteNotFoundError for non GET requests\" do\n    output = IO::Memory.new\n    context = build_context_with_io(output, path: \"/non-existent\")\n    context.request.method = \"POST\"\n    handler = Lucky::RouteNotFoundHandler.new\n    handler.next = ->(_ctx : HTTP::Server::Context) { }\n\n    expect_raises(Lucky::RouteNotFoundError) do\n      handler.call(context)\n    end\n  end\nend\n"
  },
  {
    "path": "spec/lucky/route_prefix_spec.cr",
    "content": "require \"../spec_helper\"\n\nclass PrefixedActions < TestAction\n  route_prefix \"/api/v1\"\n\n  get \"/prefixed_get\" do\n    plain_text \"im prefixed!\"\n  end\n\n  post \"/prefixed_post/:id\" do\n    plain_text \"im prefixed!\"\n  end\n\n  put \"/prefixed_put/:id\" do\n    plain_text \"im prefixed!\"\n  end\n\n  patch \"/prefixed_patch/:id\" do\n    plain_text \"im prefixed!\"\n  end\n\n  trace \"/prefixed_trace/:id\" do\n    plain_text \"im prefixed!\"\n  end\n\n  delete \"/prefixed_delete/:id\" do\n    plain_text \"im prefixed!\"\n  end\n\n  match :options, \"/prefixed_match_options\" do\n    plain_text \"im prefixed!\"\n  end\nend\n\nabstract class TestApiPrefixAction < TestAction\n  route_prefix \"/parent_api\"\nend\n\nclass ChildApiWithParentPrefix < TestApiPrefixAction\n  get \"/has_parent_prefix\" do\n    plain_text \"child route prefixed\"\n  end\nend\n\nclass ChildApiWithOwnPrefix < TestApiPrefixAction\n  route_prefix \"/child_api\"\n\n  get \"/has_own_prefix\" do\n    plain_text \"child route prefixed\"\n  end\nend\n\nmodule ApiPrefixModule\n  macro included\n    route_prefix \"/module_prefix\"\n  end\nend\n\nclass ActionIncludingModulePrefix < TestAction\n  include ApiPrefixModule\n\n  get \"/has_module_prefix\" do\n    plain_text \"child route prefixed\"\n  end\nend\n\nclass ActionNotIncludingModulePrefix < TestAction\n  get \"/no_prefix\" do\n    plain_text \"no prefix\"\n  end\nend\n\ndescribe \"prefixing routes\" do\n  it \"prefixes the URL helpers for the resourceful actions\" do\n    assert_route_added?(:get, \"/api/v1/prefixed_get\", PrefixedActions)\n    assert_route_added?(:put, \"/api/v1/prefixed_put/:id\", PrefixedActions)\n    assert_route_added?(:post, \"/api/v1/prefixed_post/:id\", PrefixedActions)\n    assert_route_added?(:patch, \"/api/v1/prefixed_patch/:id\", PrefixedActions)\n    assert_route_added?(:trace, \"/api/v1/prefixed_trace/:id\", PrefixedActions)\n    assert_route_added?(:delete, \"/api/v1/prefixed_delete/:id\", PrefixedActions)\n    assert_route_added?(:options, \"/api/v1/prefixed_match_options\", PrefixedActions)\n  end\n\n  it \"correctly prefixes through inheritance\" do\n    assert_route_added?(:get, \"/parent_api/has_parent_prefix\", ChildApiWithParentPrefix)\n    assert_route_added?(:get, \"/child_api/has_own_prefix\", ChildApiWithOwnPrefix)\n  end\n\n  it \"correctly prefixes action through included modules\" do\n    assert_route_added?(:get, \"/module_prefix/has_module_prefix\", ActionIncludingModulePrefix)\n    assert_route_added?(:get, \"/no_prefix\", ActionNotIncludingModulePrefix)\n  end\nend\n"
  },
  {
    "path": "spec/lucky/router_spec.cr",
    "content": "require \"../spec_helper\"\n\ndescribe Lucky::Router do\n  it \"routes based on the method name and path\" do\n    Lucky.router.add :get, \"/router-test1\", Lucky::Action\n\n    Lucky.router.find_action(:get, \"/router-test1\").should_not be_nil\n    Lucky.router.find_action(\"get\", \"/router-test1\").should_not be_nil\n    Lucky.router.find_action(:post, \"/router-test1\").should be_nil\n  end\n\n  it \"finds the associated get route by a head method\" do\n    Lucky.router.add :get, \"/router-test2\", Lucky::Action\n\n    Lucky.router.find_action(:head, \"/router-test2\").should_not be_nil\n    Lucky.router.find_action(\"head\", \"/router-test2\").should_not be_nil\n  end\n\n  it \"finds the route with an optional parts\" do\n    Lucky.router.add :get, \"/complex_path/:required/?:optional_a/?:optional_b\", Lucky::Action\n\n    Lucky.router.find_action(:get, \"/complex_path/1/2/3\").should_not be_nil\n    Lucky.router.find_action(:get, \"/complex_path/1/2\").should_not be_nil\n    Lucky.router.find_action(:get, \"/complex_path/1\").should_not be_nil\n  end\n\n  describe \"#list_routes\" do\n    it \"returns list of routes\" do\n      Lucky.router.add :get, \"/users\", Lucky::Action\n      Lucky.router.add :put, \"/clients/:client_id\", Lucky::Action\n\n      routes = Lucky.router.list_routes\n\n      routes.should contain({\"/users\", \"get\", Lucky::Action})\n      routes.should contain({\"/clients/:client_id\", \"put\", Lucky::Action})\n    end\n  end\nend\n"
  },
  {
    "path": "spec/lucky/secure_headers_spec.cr",
    "content": "require \"../spec_helper\"\n\ninclude ContextHelper\n\nclass FrameGuardRoutes::WithSameorigin < TestAction\n  include Lucky::SecureHeaders::SetFrameGuard\n\n  get \"/secure_path1\" do\n    plain_text \"test\"\n  end\n\n  def frame_guard_value : String\n    \"sameorigin\"\n  end\nend\n\nclass FrameGuardRoutes::WithDeny < TestAction\n  include Lucky::SecureHeaders::SetFrameGuard\n\n  get \"/secure_path2\" do\n    plain_text \"test\"\n  end\n\n  def frame_guard_value : String\n    \"deny\"\n  end\nend\n\nclass FrameGuardRoutes::WithURL < TestAction\n  include Lucky::SecureHeaders::SetFrameGuard\n\n  get \"/secure_path3\" do\n    plain_text \"test\"\n  end\n\n  def frame_guard_value : String\n    \"https://tacotrucks.food\"\n  end\nend\n\nclass FrameGuardRoutes::WithBadValue < TestAction\n  include Lucky::SecureHeaders::SetFrameGuard\n\n  get \"/secure_path4\" do\n    plain_text \"test\"\n  end\n\n  def frame_guard_value : String\n    \"hax0rz\"\n  end\nend\n\nclass XSSGuardRoutes::Index < TestAction\n  include Lucky::SecureHeaders::SetXSSGuard\n\n  get \"/secure_path5\" do\n    plain_text \"test\"\n  end\nend\n\nclass SniffGuardRoutes::Index < TestAction\n  include Lucky::SecureHeaders::SetSniffGuard\n\n  get \"/secure_path6\" do\n    plain_text \"test\"\n  end\nend\n\nclass FLoCGGuardRoutes::Index < TestAction\n  include Lucky::SecureHeaders::DisableFLoC\n\n  get \"/secure_path7\" do\n    plain_text \"test\"\n  end\nend\n\nclass CSPGuardRoutes::Index < TestAction\n  include Lucky::SecureHeaders::SetCSPGuard\n\n  get \"/secure_path8\" do\n    plain_text \"test\"\n  end\n\n  def csp_guard_value : String\n    \"script-src 'self'\"\n  end\nend\n\ndescribe Lucky::SecureHeaders do\n  describe \"SetFrameGuard\" do\n    it \"sets the X-Frame-Options header with sameorigin\" do\n      route = FrameGuardRoutes::WithSameorigin.new(build_context, params).call\n      route.context.response.headers[\"X-Frame-Options\"].should eq \"sameorigin\"\n    end\n\n    it \"sets the X-Frame-Options header with deny\" do\n      route = FrameGuardRoutes::WithDeny.new(build_context, params).call\n      route.context.response.headers[\"X-Frame-Options\"].should eq \"deny\"\n    end\n\n    it \"sets the X-Frame-Options header to allow from tacotrucks\" do\n      route = FrameGuardRoutes::WithURL.new(build_context, params).call\n      route.context.response.headers[\"X-Frame-Options\"].should eq \"allow-from https://tacotrucks.food\"\n    end\n\n    it \"throws an error when given a bad value\" do\n      expect_raises(Exception, \"You set frame_guard_value to hax0rz\") do\n        FrameGuardRoutes::WithBadValue.new(build_context, params).call\n      end\n    end\n  end\n\n  describe \"SetXSSGuard\" do\n    it \"sets the X-XSS-Protection for a modern browser\" do\n      route = XSSGuardRoutes::Index.new(build_context, params).call\n      route.context.response.headers[\"X-XSS-Protection\"].should eq \"1; mode=block\"\n    end\n\n    it \"disables the X-XSS-Protection header on older IE browsers\" do\n      request = HTTP::Request.new(\"GET\", \"/so_custom\")\n      request.headers[\"User-Agent\"] = \"Mozilla/4.0 (compatible; MSIE 8.0; Windows NT 6.0; Trident/4.0)\"\n      route = XSSGuardRoutes::Index.new(build_context(\"/so_custom\", request), params).call\n      route.context.response.headers[\"X-XSS-Protection\"].should eq \"0\"\n    end\n  end\n\n  describe \"SetSniffGuard\" do\n    it \"sets the X-Content-Type-Options to nosniff\" do\n      route = SniffGuardRoutes::Index.new(build_context, params).call\n      route.context.response.headers[\"X-Content-Type-Options\"].should eq \"nosniff\"\n    end\n  end\n\n  describe \"DisableFLoC\" do\n    it \"sets the Permissions-Policy to interest-cohort=()\" do\n      route = FLoCGGuardRoutes::Index.new(build_context, params).call\n      route.context.response.headers[\"Permissions-Policy\"].should eq \"interest-cohort=()\"\n    end\n  end\n\n  describe \"SetCSPGuard\" do\n    it \"sets the Content-Security-Policy header\" do\n      route = CSPGuardRoutes::Index.new(build_context, params).call\n      route.context.response.headers[\"Content-Security-Policy\"].should eq \"script-src 'self'\"\n    end\n  end\nend\n"
  },
  {
    "path": "spec/lucky/serializable_spec.cr",
    "content": "require \"../spec_helper\"\n\nprivate abstract struct BaseSerializerStruct\n  include Lucky::Serializable\nend\n\nprivate struct FoodSerializer < BaseSerializerStruct\n  def initialize(@name : String)\n  end\n\n  def render\n    {name: @name}\n  end\nend\n\nprivate abstract class BaseSerializerClass\n  include Lucky::Serializable\nend\n\nprivate class DrinksSerializer < BaseSerializerClass\n  def initialize(@name : String)\n  end\n\n  def render\n    {name: @name}\n  end\nend\n\ndescribe Lucky::Serializable do\n  context \"with structs\" do\n    describe \"#to_json\" do\n      it \"calls to_json on the render data\" do\n        FoodSerializer.new(\"tacos\").to_json.should eq(%({\"name\":\"tacos\"}))\n      end\n    end\n  end\n\n  context \"with classes\" do\n    describe \"#to_json\" do\n      it \"calls to_json on the render data\" do\n        DrinksSerializer.new(\"water\").to_json.should eq(%({\"name\":\"water\"}))\n      end\n    end\n  end\nend\n"
  },
  {
    "path": "spec/lucky/specialty_tags_spec.cr",
    "content": "require \"../spec_helper\"\n\ninclude ContextHelper\n\nprivate class TestPage\n  include Lucky::HTMLPage\n\n  def render\n  end\nend\n\ndescribe Lucky::SpecialtyTags do\n  it \"renders doctype\" do\n    view(&.html_doctype).should contain <<-HTML\n    <!DOCTYPE html>\n    HTML\n  end\n\n  it \"renders css link tag\" do\n    view(&.css_link(\"app.css\")).should eq <<-HTML\n    <link href=\"app.css\" rel=\"stylesheet\" media=\"screen\">\n    HTML\n\n    view(&.css_link(\"app.css\", rel: \"preload\", media: \"print\")).should eq <<-HTML\n    <link href=\"app.css\" rel=\"preload\" media=\"print\">\n    HTML\n  end\n\n  it \"cache-busts non-fingerprinted local css links\" do\n    html = view(&.css_link(\"/assets/css/app.css\"))\n\n    html.should contain \"bust=\"\n  end\n\n  it \"does not cache-bust fingerprinted local css links\" do\n    html = view(&.css_link(\"/assets/css/app-5e6f7a8b.css\"))\n\n    html.should_not contain \"bust=\"\n  end\n\n  it \"does not cache-bust external css links\" do\n    html = view(&.css_link(\"https://fonts.googleapis.com/css?family=Inter\"))\n\n    html.should_not contain \"bust=\"\n  end\n\n  it \"does not cache-bust protocol-relative css links\" do\n    html = view(&.css_link(\"//cdn.example.com/style.css\"))\n\n    html.should_not contain \"bust=\"\n  end\n\n  it \"does not cache-bust css links outside the asset path\" do\n    html = view(&.css_link(\"/other/path/style.css\"))\n\n    html.should_not contain \"bust=\"\n  end\n\n  it \"renders js link tag\" do\n    view(&.js_link(\"app.js\")).should contain <<-HTML\n    <script src=\"app.js\"></script>\n    HTML\n\n    view(&.js_link(\"app.js\", foo: \"bar\")).should contain <<-HTML\n    <script src=\"app.js\" foo=\"bar\"></script>\n    HTML\n  end\n\n  it \"render utf8 meta tag\" do\n    view(&.utf8_charset).should contain <<-HTML\n    <meta charset=\"utf-8\">\n    HTML\n  end\n\n  it \"renders responsive meta tag\" do\n    view(&.responsive_meta_tag).should contain <<-HTML\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n    HTML\n\n    view(&.responsive_meta_tag(width: 600)).should contain <<-HTML\n    <meta name=\"viewport\" content=\"initial-scale=1, width=600\">\n    HTML\n\n    view(&.responsive_meta_tag(height: 600)).should contain <<-HTML\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1, height=600\">\n    HTML\n  end\n\n  it \"renders canonical link tag\" do\n    view(&.canonical_link(\"https://it.is/here\")).should contain <<-HTML\n    <link href=\"https://it.is/here\" rel=\"canonical\">\n    HTML\n  end\n\n  it \"renders bun reload script in development\" do\n    html = view(&.bun_reload_connect_tag)\n\n    html.should contain \"<script>\"\n    html.should contain \"new WebSocket\"\n    html.should contain \"ws://127.0.0.1:3002\"\n  end\n\n  it \"uses bust param for css hmr\" do\n    html = view(&.bun_reload_connect_tag)\n\n    html.should contain \"searchParams.set('bust'\"\n  end\n\n  it \"does not render bun reload script in production\" do\n    ENV[\"LUCKY_ENV\"] = \"production\"\n    html = view(&.bun_reload_connect_tag)\n\n    html.should_not contain \"<script>\"\n  ensure\n    ENV[\"LUCKY_ENV\"] = \"development\"\n  end\n\n  it \"renders proper non-breaking space entity\" do\n    view(&.nbsp).should contain <<-HTML\n    &nbsp;\n    HTML\n\n    view(&.nbsp(3)).should contain <<-HTML\n    &nbsp;&nbsp;&nbsp;\n    HTML\n  end\nend\n\nprivate def view(&)\n  TestPage.new(build_context).tap do |page|\n    yield page\n  end.view.to_s\nend\n"
  },
  {
    "path": "spec/lucky/static_compression_handler_spec.cr",
    "content": "require \"../spec_helper\"\nrequire \"http\"\n\ninclude ContextHelper\n\nprivate PATH = \"example.css\"\n\ndescribe Lucky::StaticCompressionHandler do\n  it \"calls next when not enabled\" do\n    context = build_context(path: PATH)\n    context.request.headers[\"Accept-Encoding\"] = \"gzip\"\n    next_called = false\n\n    call_handler_with(context) { next_called = true }\n\n    next_called.should be_true\n    context.response.headers[\"Content-Encoding\"]?.should_not eq \"gzip\"\n  end\n\n  it \"calls next when content type isn't in Lucky::Server.gzip_content_types\" do\n    Lucky::Server.temp_config(gzip_enabled: true, gzip_content_types: %w(text/html)) do\n      context = build_context(path: PATH)\n      context.request.headers[\"Accept-Encoding\"] = \"gzip\"\n      next_called = false\n\n      call_handler_with(context) { next_called = true }\n\n      next_called.should be_true\n      context.response.headers[\"Content-Encoding\"]?.should_not eq \"gzip\"\n    end\n  end\n\n  it \"delivers the precompressed file when enabled\" do\n    Lucky::Server.temp_config(gzip_enabled: true) do\n      output = IO::Memory.new\n      context = build_context_with_io(output, path: PATH)\n\n      context.request.method = \"GET\"\n      context.request.headers[\"Accept-Encoding\"] = \"gzip\"\n\n      next_called = false\n      call_handler_with(context) { next_called = true }\n\n      next_called.should be_false\n\n      context.response.headers[\"Content-Encoding\"].should eq \"gzip\"\n      context.response.headers[\"Etag\"].should eq etag\n      output.close\n      output.to_s.ends_with?(File.read(gzip_path)).should be_true\n    end\n  end\n\n  it \"calls next when Accept-Encoding doesn't include gzip\" do\n    Lucky::Server.temp_config(gzip_enabled: true) do\n      context = build_context(path: PATH)\n      context.request.headers[\"Accept-Encoding\"] = \"whatever\"\n      next_called = false\n\n      call_handler_with(context) { next_called = true }\n\n      next_called.should be_true\n      context.response.headers[\"Content-Encoding\"]?.should_not eq \"gzip\"\n    end\n  end\n\n  it \"sends NOT_MODIFIED when file hasn't been modified\" do\n    Lucky::Server.temp_config(gzip_enabled: true) do\n      first_context = build_context(path: PATH)\n      first_context.request.method = \"GET\"\n      first_context.request.headers[\"Accept-Encoding\"] = \"gzip\"\n\n      call_handler_with(first_context) { }\n\n      last_modified = HTTP.parse_time(first_context.response.headers[\"Last-Modified\"]).as(Time)\n\n      context = build_context(path: PATH)\n      context.request.headers[\"Accept-Encoding\"] = \"gzip\"\n      context.request.headers[\"If-Modified-Since\"] = HTTP.format_time(last_modified + 1.hour)\n      next_called = false\n\n      call_handler_with(context) { next_called = true }\n\n      next_called.should be_false\n      context.response.status.should eq HTTP::Status::NOT_MODIFIED\n    end\n  end\nend\n\nprivate def public_dir\n  File.expand_path(\"spec/fixtures\")\nend\n\nprivate def gzip_path\n  File.join(public_dir, \"#{PATH}.gz\")\nend\n\nprivate def etag\n  %{W/\"#{last_modified.to_unix}\"}\nend\n\nprivate def last_modified\n  File.info(gzip_path).modification_time\nend\n\nprivate def call_handler_with(context : HTTP::Server::Context, &block)\n  handler = Lucky::StaticCompressionHandler.new(public_dir: public_dir)\n  handler.next = ->(_ctx : HTTP::Server::Context) { block.call }\n  handler.call(context)\n  context.response.close\nend\n"
  },
  {
    "path": "spec/lucky/static_file_handler_spec.cr",
    "content": "require \"../spec_helper\"\n\ninclude ContextHelper\n\ndescribe Lucky::StaticFileHandler do\n  it \"shows static files in logs\" do\n    context = build_context\n    called = false\n\n    call_file_handler_with(context) { called = true }\n\n    called.should be_true\n  end\nend\n\nprivate def call_file_handler_with(context : HTTP::Server::Context, &block)\n  handler = Lucky::StaticFileHandler.new(public_dir: \"/foo\")\n  handler.next = ->(_ctx : HTTP::Server::Context) { block.call }\n  handler.call(context)\nend\n"
  },
  {
    "path": "spec/lucky/subdomain_spec.cr",
    "content": "require \"../spec_helper\"\n\ninclude ContextHelper\n\nabstract class BaseAction < Lucky::Action\n  # https://github.com/luckyframework/lucky/issues/1685\n  include Lucky::ProtectFromForgery\n  include Lucky::Subdomain\n  accepted_formats [:html], default: :html\nend\n\nclass Simple::Index < BaseAction\n  require_subdomain\n\n  get \"/simple\" do\n    plain_text subdomain\n  end\nend\n\nclass OptionalSubdomain::Index < BaseAction\n  get \"/optional\" do\n    plain_text subdomain? || \"none\"\n  end\nend\n\nclass Specific::Index < BaseAction\n  require_subdomain \"foo\"\n\n  get \"/specific\" do\n    plain_text subdomain\n  end\nend\n\nclass Regex::Index < BaseAction\n  require_subdomain /www\\d/\n\n  get \"/regex\" do\n    plain_text subdomain\n  end\nend\n\nclass Multiple::Index < BaseAction\n  require_subdomain [\"test\", \"staging\", /(prod|production)/]\n\n  get \"/multiple\" do\n    plain_text subdomain\n  end\nend\n\ndescribe Lucky::Subdomain do\n  it \"handles general subdomain expectation\" do\n    request = build_request(host: \"foo.example.com\")\n    response = Simple::Index.new(build_context(request), params).call\n    response.body.should eq \"foo\"\n  end\n\n  it \"handles optional subdomain\" do\n    request = build_request(host: \"qa.example.com\")\n    response = OptionalSubdomain::Index.new(build_context(request), params).call\n    response.body.should eq \"qa\"\n\n    request = build_request(host: \"example.com\")\n    response = OptionalSubdomain::Index.new(build_context(request), params).call\n    response.body.should eq \"none\"\n  end\n\n  it \"raises error if subdomain missing\" do\n    request = build_request(host: \"example.com\")\n    expect_raises(Lucky::InvalidSubdomainError) do\n      Simple::Index.new(build_context(request), params).call\n    end\n  end\n\n  it \"handles specific subdomain expectation\" do\n    request = build_request(host: \"foo.example.com\")\n    response = Specific::Index.new(build_context(request), params).call\n    response.body.should eq \"foo\"\n  end\n\n  it \"raises error if subdomain does not match specific\" do\n    request = build_request(host: \"admin.example.com\")\n    expect_raises(Lucky::InvalidSubdomainError) do\n      Specific::Index.new(build_context(request), params).call\n    end\n  end\n\n  it \"handles regex subdomain expectation\" do\n    request = build_request(host: \"www4.example.com\")\n    response = Regex::Index.new(build_context(request), params).call\n    response.body.should eq \"www4\"\n  end\n\n  it \"raises error if subdomain does not match regex\" do\n    request = build_request(host: \"4www.example.com\")\n    expect_raises(Lucky::InvalidSubdomainError) do\n      Regex::Index.new(build_context(request), params).call\n    end\n  end\n\n  it \"handles multiple options for expectation\" do\n    request = build_request(host: \"test.example.com\")\n    response = Multiple::Index.new(build_context(request), params).call\n    response.body.should eq \"test\"\n\n    request = build_request(host: \"staging.example.com\")\n    response = Multiple::Index.new(build_context(request), params).call\n    response.body.should eq \"staging\"\n\n    request = build_request(host: \"prod.example.com\")\n    response = Multiple::Index.new(build_context(request), params).call\n    response.body.should eq \"prod\"\n\n    request = build_request(host: \"production.example.com\")\n    response = Multiple::Index.new(build_context(request), params).call\n    response.body.should eq \"production\"\n  end\n\n  it \"raises error if subdomain does not match any expectations\" do\n    request = build_request(host: \"development.example.com\")\n    expect_raises(Lucky::InvalidSubdomainError) do\n      Multiple::Index.new(build_context(request), params).call\n    end\n  end\n\n  it \"has configuration for urls with larger tld length\" do\n    Lucky::Subdomain.temp_config(tld_length: 2) do\n      request = build_request(host: \"foo.example.co.uk\")\n      response = Simple::Index.new(build_context(request), params).call\n      response.body.should eq \"foo\"\n    end\n  end\n\n  it \"will fail if using ip address\" do\n    request = build_request(host: \"development.127.0.0.1:3000\")\n    expect_raises(Lucky::InvalidSubdomainError) do\n      Simple::Index.new(build_context(request), params).call\n    end\n  end\n\n  it \"will not fail if using localhost and port with tld length set to 0\" do\n    Lucky::Subdomain.temp_config(tld_length: 0) do\n      request = build_request(host: \"foo.localhost:3000\")\n      response = Simple::Index.new(build_context(request), params).call\n      response.body.should eq \"foo\"\n    end\n  end\nend\n"
  },
  {
    "path": "spec/lucky/support/message_encrypter_spec.cr",
    "content": "require \"../../spec_helper\"\n\ndescribe Lucky::MessageEncryptor do\n  describe \"#encrypt\" do\n    it \"raises a helpful error if the secret_key_base is not a valid key\" do\n      encryptor = Lucky::MessageEncryptor.new(\"definitely not a valid key\")\n\n      expect_raises(Lucky::MessageEncryptor::InvalidSecretKeyBase) do\n        encryptor.encrypt(\"anything\")\n      end\n    end\n  end\n\n  describe \"#decrypt\" do\n    it \"raises a helpful error if the secret_key_base is not a valid key\" do\n      encryptor = Lucky::MessageEncryptor.new(\"definitely not a valid key\")\n      expect_raises(Lucky::MessageEncryptor::InvalidSecretKeyBase) do\n        encryptor.decrypt(irrelevant_data)\n      end\n    end\n  end\nend\n\nprivate def irrelevant_data\n  data = IO::Memory.new\n  data << Base64.strict_encode(\"irrelevant\")\n  data.to_slice\nend\n"
  },
  {
    "path": "spec/lucky/support/message_verifier_spec.cr",
    "content": "require \"../../spec_helper\"\n\ndescribe Lucky::MessageVerifier do\n  describe \"#verify\" do\n    it \"is valid\" do\n      verifier = Lucky::MessageVerifier.new(\"supersecretsquirrel\", :sha256)\n      signed_message = verifier.generate(\"abc123\")\n      verifier.verify(signed_message).should eq(\"abc123\")\n    end\n\n    it \"returns valid data for new tokens\" do\n      # Token: \"#{48.hours.from_now.to_unix}:#{UUID.random}\"\n      new_token = \"WyJNVFkwTmpBNU1qY3dNanBpWTJabE5tUXpPQzB3TTJFMUxUUXhaamd0WWprek9DMWtNR001Tm1JNE4yWTRPVEU9IiwiRUprZ3ZIUEtxNG9EdVV1azlFZWQ0ZkJCWFlVPSJd\"\n      verifier = Lucky::MessageVerifier.new(secret_key)\n      verifier.verify(new_token).should eq(\"1646092702:bcfe6d38-03a5-41f8-b938-d0c96b87f891\")\n    end\n\n    it \"still works with some more complex data\" do\n      verifier = Lucky::MessageVerifier.new(secret_key, :sha256)\n      signed_message = verifier.generate(\"#{Time.utc(2022, 1, 15, 10, 12).to_unix}:some_special_dude@hotmail.com:b211cbb5-3cc0-475a-9ebe-45f3fd2fe650\")\n      verifier.verify(signed_message).should eq(\"1642241520:some_special_dude@hotmail.com:b211cbb5-3cc0-475a-9ebe-45f3fd2fe650\")\n    end\n\n    it \"fails with an invalid token\" do\n      broken_token = \"bleepbloop\"\n      verifier = Lucky::MessageVerifier.new(secret_key)\n      expect_raises(Lucky::InvalidSignatureError) do\n        verifier.verify(broken_token)\n      end\n    end\n  end\n\n  describe \"#verify_raw\" do\n    it \"is valid\" do\n      verifier = Lucky::MessageVerifier.new(\"supersecretsquirrel\", :sha256)\n      signed_message = verifier.generate(\"abc123\")\n      String.new(verifier.verify_raw(signed_message)).should eq(\"abc123\")\n    end\n\n    it \"returns valid data for new tokens\" do\n      # Token: \"#{48.hours.from_now.to_unix}:#{UUID.random}\"\n      new_token = \"WyJNVFkwTmpBNU1qY3dNanBpWTJabE5tUXpPQzB3TTJFMUxUUXhaamd0WWprek9DMWtNR001Tm1JNE4yWTRPVEU9IiwiRUprZ3ZIUEtxNG9EdVV1azlFZWQ0ZkJCWFlVPSJd\"\n      verifier = Lucky::MessageVerifier.new(secret_key)\n      String.new(verifier.verify_raw(new_token)).should eq(\"1646092702:bcfe6d38-03a5-41f8-b938-d0c96b87f891\")\n    end\n\n    it \"still works with some more complex data\" do\n      verifier = Lucky::MessageVerifier.new(secret_key, :sha256)\n      signed_message = verifier.generate(\"#{Time.utc(2022, 1, 15, 10, 12).to_unix}:some_special_dude@hotmail.com:b211cbb5-3cc0-475a-9ebe-45f3fd2fe650\")\n      String.new(verifier.verify_raw(signed_message)).should eq(\"1642241520:some_special_dude@hotmail.com:b211cbb5-3cc0-475a-9ebe-45f3fd2fe650\")\n    end\n\n    it \"fails with an invalid token\" do\n      broken_token = \"bleepbloop\"\n      verifier = Lucky::MessageVerifier.new(secret_key)\n      expect_raises(Lucky::InvalidSignatureError) do\n        verifier.verify_raw(broken_token)\n      end\n    end\n  end\nend\n\nprivate def secret_key : String\n  \"mFClXIwWbxfqJwnJ/rXXFK02kO5z8wY2P8mjozsEQDk=\"\nend\n"
  },
  {
    "path": "spec/lucky/tag_defaults_spec.cr",
    "content": "require \"../spec_helper\"\n\nprivate class TestTagDefaultsPage\n  include Lucky::HTMLPage\n\n  def render\n    tag_defaults do |tag_builder|\n      tag_builder.div \"text content\"\n    end\n\n    tag_defaults class: \"default\" do |tag_builder|\n      tag_builder.div \"text content\"\n    end\n\n    tag_defaults class: \"default\" do |tag_builder|\n      tag_builder.div \"text content\", append_class: \"appended classes\"\n    end\n\n    tag_defaults class: \"default\" do |tag_builder|\n      tag_builder.div \"text content\", replace_class: \"replaced\"\n    end\n\n    tag_defaults id: \"foo\" do |tag_builder|\n      tag_builder.div \"text content\", append_class: \"appended-without-default\"\n    end\n\n    tag_defaults id: \"foo\" do |tag_builder|\n      tag_builder.div \"text content\", replace_class: \"replaced-without-default\"\n    end\n\n    tag_defaults do |tag_builder|\n      tag_builder.div do\n        text \"block content\"\n      end\n    end\n\n    tag_defaults do |tag_builder|\n      tag_builder.div \"@click\": \"onclick($event)\" do\n        text \"block content\"\n      end\n    end\n\n    view\n  end\nend\n\ninclude ContextHelper\n\ndescribe \"tag_defaults\" do\n  it \"renders the component\" do\n    contents = TestTagDefaultsPage.new(build_context).render.to_s\n\n    contents.should contain %(<div>text content</div>)\n    contents.should contain %(<div class=\"default\">text content</div>)\n    contents.should contain %(<div class=\"default appended classes\">text content</div>)\n    contents.should contain %(<div class=\"replaced\">text content</div>)\n    contents.should contain %(<div id=\"foo\" class=\"appended-without-default\">text content</div>)\n    contents.should contain %(<div id=\"foo\" class=\"replaced-without-default\">text content</div>)\n    contents.should contain %(<div>block content</div>)\n    contents.should contain %(<div @click=\"onclick($event)\">block content</div>)\n  end\nend\n"
  },
  {
    "path": "spec/lucky/text_helpers/cycle_spec.cr",
    "content": "require \"./text_helpers_spec\"\n\ndescribe Lucky::TextHelpers do\n  Spec.before_each do\n    view.reset_cycles\n  end\n\n  describe \"cycle\" do\n    describe Lucky::TextHelpers::Cycle do\n      it \"cycles when converted to a string\" do\n        value = Lucky::TextHelpers::Cycle.new(\"one\", 2, \"3\")\n        value.to_s.should eq \"one\"\n        value.to_s.should eq \"2\"\n        value.to_s.should eq \"3\"\n        value.to_s.should eq \"one\"\n        value.reset\n        value.to_s.should eq \"one\"\n        value.to_s.should eq \"2\"\n        value.to_s.should eq \"3\"\n      end\n    end\n\n    it \"cycles\" do\n      view.cycle(\"one\", 2, \"3\").should eq \"one\"\n      view.cycle(\"one\", 2, \"3\").should eq \"2\"\n      view.cycle(\"one\", 2, \"3\").should eq \"3\"\n      view.cycle(\"one\", 2, \"3\").should eq \"one\"\n      view.cycle(\"one\", 2, \"3\").should eq \"2\"\n      view.cycle(\"one\", 2, \"3\").should eq \"3\"\n    end\n\n    it \"cycles with array\" do\n      array = [1, 2, 3]\n      view.cycle(array).should eq \"1\"\n      view.cycle(array).should eq \"2\"\n      view.cycle(array).should eq \"3\"\n    end\n\n    it \"cycle resets with new values\" do\n      view.cycle(\"even\", \"odd\").should eq \"even\"\n      view.cycle(\"even\", \"odd\").should eq \"odd\"\n      view.cycle(\"even\", \"odd\").should eq \"even\"\n      view.cycle(1, 2, 3).should eq \"1\"\n      view.cycle(1, 2, 3).should eq \"2\"\n      view.cycle(1, 2, 3).should eq \"3\"\n      view.cycle(1, 2, 3).should eq \"1\"\n    end\n\n    it \"cycles named cycles\" do\n      view.cycle(1, 2, 3, name: \"numbers\").should eq \"1\"\n      view.cycle(\"red\", \"blue\", name: \"colors\").should eq \"red\"\n      view.cycle(1, 2, 3, name: \"numbers\").should eq \"2\"\n      view.cycle(\"red\", \"blue\", name: \"colors\").should eq \"blue\"\n      view.cycle(1, 2, 3, name: \"numbers\").should eq \"3\"\n      view.cycle(\"red\", \"blue\", name: \"colors\").should eq \"red\"\n    end\n\n    it \"gets current cycle with default name\" do\n      view.cycle(\"even\", \"odd\")\n      view.current_cycle.should eq \"even\"\n      view.cycle(\"even\", \"odd\")\n      view.current_cycle.should eq \"odd\"\n      view.cycle(\"even\", \"odd\")\n      view.current_cycle.should eq \"even\"\n    end\n\n    it \"gets current cycle with named cycles\" do\n      view.cycle(\"red\", \"blue\", name: \"colors\")\n      view.current_cycle(\"colors\").should eq \"red\"\n      view.cycle(\"red\", \"blue\", name: \"colors\")\n      view.current_cycle(\"colors\").should eq \"blue\"\n      view.cycle(\"red\", \"blue\", name: \"colors\")\n      view.current_cycle(\"colors\").should eq \"red\"\n    end\n\n    it \"gets current cycle with no exceptions\" do\n      view.current_cycle.should be_nil\n      view.current_cycle(\"colors\").should be_nil\n    end\n\n    it \"gets current cycle with more than two names\" do\n      view.cycle(1, 2, 3)\n      view.current_cycle.should eq \"1\"\n      view.cycle(1, 2, 3)\n      view.current_cycle.should eq \"2\"\n      view.cycle(1, 2, 3)\n      view.current_cycle.should eq \"3\"\n      view.cycle(1, 2, 3)\n      view.current_cycle.should eq \"1\"\n    end\n\n    it \"cycles with default named\" do\n      view.cycle(1, 2, 3).should eq \"1\"\n      view.cycle(1, 2, 3, name: \"default\").should eq \"2\"\n      view.cycle(1, 2, 3).should eq \"3\"\n    end\n\n    it \"resets cycle\" do\n      view.cycle(1, 2, 3).should eq \"1\"\n      view.cycle(1, 2, 3).should eq \"2\"\n      view.reset_cycle\n      view.cycle(1, 2, 3).should eq \"1\"\n    end\n\n    it \"resets unknown cycle\" do\n      view.reset_cycle(\"colors\")\n    end\n\n    it \"resets named cycle\" do\n      view.cycle(1, 2, 3, name: \"numbers\").should eq \"1\"\n      view.cycle(\"red\", \"blue\", name: \"colors\").should eq \"red\"\n      view.reset_cycle(\"numbers\")\n      view.cycle(1, 2, 3, name: \"numbers\").should eq \"1\"\n      view.cycle(\"red\", \"blue\", name: \"colors\").should eq \"blue\"\n      view.cycle(1, 2, 3, name: \"numbers\").should eq \"2\"\n      view.cycle(\"red\", \"blue\", name: \"colors\").should eq \"red\"\n    end\n  end\nend\n"
  },
  {
    "path": "spec/lucky/text_helpers/excerpts_spec.cr",
    "content": "require \"./text_helpers_spec\"\n\ndescribe Lucky::TextHelpers do\n  describe \"excerpt\" do\n    it \"excerpts\" do\n      view.excerpt(\"This is a beautiful morning\", \"beautiful\", radius: 5).should eq \"...is a beautiful morn...\"\n      view.excerpt(\"This is a beautiful morning\", \"this\", radius: 5).should eq \"This is a...\"\n      view.excerpt(\"This is a beautiful morning\", \"morning\", radius: 5).should eq \"...iful morning\"\n      view.excerpt(\"This is a beautiful morning\", \"day\").should eq \"\"\n    end\n\n    it \"excerpts with regex\" do\n      view.excerpt(\"This is a beautiful! morning\", \"beautiful\", radius: 5).should eq \"...is a beautiful! mor...\"\n      view.excerpt(\"This is a beautiful? morning\", \"beautiful\", radius: 5).should eq \"...is a beautiful? mor...\"\n      view.excerpt(\"This is a beautiful? morning\", /\\bbeau\\w*\\b/i, radius: 5).should eq \"...is a beautiful? mor...\"\n      view.excerpt(\"This is a beautiful? morning\", /\\b(beau\\w*)\\b/i, radius: 5).should eq \"...is a beautiful? mor...\"\n      view.excerpt(\"This day was challenging for judge Allen and his colleagues.\", /\\ballen\\b/i, radius: 5).should eq \"...udge Allen and...\"\n      view.excerpt(\"This day was challenging for judge Allen and his colleagues.\", /\\ballen\\b/i, radius: 1, separator: \" \").should eq \"...judge Allen and...\"\n      view.excerpt(\"This day was challenging for judge Allen and his colleagues.\", /\\b(\\w*allen\\w*)\\b/i, radius: 5).should eq \"...was challenging for...\"\n    end\n\n    it \"excerpts in borderline cases\" do\n      view.excerpt(\"\", \"\", radius: 0).should eq \"\"\n      view.excerpt(\"a\", \"a\", radius: 0).should eq \"a\"\n      view.excerpt(\"abc\", \"b\", radius: 0).should eq \"...b...\"\n      view.excerpt(\"abc\", \"b\", radius: 1).should eq \"abc\"\n      view.excerpt(\"abcd\", \"b\", radius: 1).should eq \"abc...\"\n      view.excerpt(\"zabc\", \"b\", radius: 1).should eq \"...abc\"\n      view.excerpt(\"zabcd\", \"b\", radius: 1).should eq \"...abc...\"\n      view.excerpt(\"zabcd\", \"b\", radius: 2).should eq \"zabcd\"\n\n      # excerpt strips the resulting string before ap-/prepending excerpt_string.\n      # whether this behavior is meaningful when excerpt_string is not to be\n      # appended is questionable.\n      view.excerpt(\"  zabcd  \", \"b\", radius: 4).should eq \"zabcd\"\n      view.excerpt(\"z  abc  d\", \"b\", radius: 1).should eq \"...abc...\"\n    end\n\n    it \"excerpts with omission\" do\n      view.excerpt(\"This is a beautiful morning\", \"beautiful\", omission: \"[...]\", radius: 5).should eq \"[...]is a beautiful morn[...]\"\n      view.excerpt(\"This is the ultimate supercalifragilisticexpialidoceous very looooooooooooooooooong looooooooooooong beautiful morning with amazing sunshine and awesome temperatures. So what are you gonna do about it?\", \"very\", omission: \"[...]\").should eq \"This is the ultimate supercalifragilisticexpialidoceous very looooooooooooooooooong looooooooooooong beautiful morning with amazing sunshine and awesome tempera[...]\"\n    end\n\n    it \"excerpts with separator\" do\n      view.excerpt(\"This is a very beautiful morning\", \"very\", separator: \" \", radius: 1).should eq \"...a very beautiful...\"\n      view.excerpt(\"This is a very beautiful morning\", \"this\", separator: \" \", radius: 1).should eq \"This is...\"\n      view.excerpt(\"This is a very beautiful morning\", \"morning\", separator: \" \", radius: 1).should eq \"...beautiful morning\"\n      view.excerpt(\"my very\\nvery\\nvery long\\nstring\", \"long\", separator: \"\\n\", radius: 0).should eq \"...very long...\"\n      view.excerpt(\"my very\\nvery\\nvery long\\nstring\", \"long\", separator: \"\\n\", radius: 1).should eq \"...very\\nvery long\\nstring\"\n      view.excerpt(\"This is a beautiful morning\", \"a\", separator: \"\").should eq view.excerpt(\"This is a beautiful morning\", \"a\")\n    end\n  end\nend\n"
  },
  {
    "path": "spec/lucky/text_helpers/highlight_spec.cr",
    "content": "require \"../../spec_helper\"\n\ninclude ContextHelper\n\nclass HighlightTestPage\n  include Lucky::HTMLPage\n\n  def test_highlight\n    highlight \"This is a beautiful morning, but also a beautiful day\", \"beautiful\" { |word|\n      # you can't use HTMLPage here since they append to 'view' rather than return in-place\n      # the block highlight expects is passed to gsub which expects to get a string returned\n      \"<span data-highlight-word=\\\"#{word}\\\" data-color=\\\"yellow\\\" data-español=\\\"bello\\\">#{word}</span>\"\n    }\n  end\nend\n\ndescribe Lucky::TextHelpers do\n  describe \"highlight\" do\n    it \"highlights\" do\n      view(&.highlight(\"This is a beautiful morning\", \"beautiful\")).should eq \"This is a <mark>beautiful</mark> morning\"\n      view(&.highlight(\"This is a beautiful morning, but also a beautiful day\", \"beautiful\")).should eq \"This is a <mark>beautiful</mark> morning, but also a <mark>beautiful</mark> day\"\n      view(&.highlight(\"This is a beautiful morning, but also a beautiful day\", \"beautiful\", highlighter: \"<b>\\\\1</b>\")).should eq \"This is a <b>beautiful</b> morning, but also a <b>beautiful</b> day\"\n      view(&.highlight(\"This text is not changed because we supplied an empty phrase\", \"\")).should eq \"This text is not changed because we supplied an empty phrase\"\n    end\n\n    it \"does not highlight empty text\" do\n      view(&.highlight(\"   \", \"blank text is returned verbatim\")).should eq \"   \"\n    end\n\n    it \"highlights with regexp\" do\n      view(&.highlight(\"This is a beautiful! morning\", \"beautiful!\")).should eq \"This is a <mark>beautiful!</mark> morning\"\n      view(&.highlight(\"This is a beautiful! morning\", \"beautiful! morning\")).should eq \"This is a <mark>beautiful! morning</mark>\"\n      view(&.highlight(\"This is a beautiful? morning\", \"beautiful? morning\")).should eq \"This is a <mark>beautiful? morning</mark>\"\n    end\n\n    it \"highlights accepts regexp\" do\n      view(&.highlight(\"This day was challenging for judge Allen and his colleagues.\", /\\ballen\\b/i)).should eq \"This day was challenging for judge <mark>Allen</mark> and his colleagues.\"\n    end\n\n    it \"highlights with multiple phrases in one pass\" do\n      view(&.highlight(\"wow em\", %w(wow em), highlighter: \"<em>\\\\1</em>\")).should eq %(<em>wow</em> <em>em</em>)\n    end\n\n    it \"escapes HTML by default\" do\n      view(&.highlight(\"<span>wow</span>\", \"wow\")).should eq %(&lt;span&gt;<mark>wow</mark>&lt;/span&gt;)\n    end\n\n    it \"allows unescaped HTML\" do\n      view(&.highlight(\"<p>This is a beautiful morning, but also a beautiful day</p>\", \"beautiful\", escape: false)).should eq \"<p>This is a <mark>beautiful</mark> morning, but also a <mark>beautiful</mark> day</p>\"\n      view(&.highlight(\"<p>This is a <em>beautiful</em> morning, but also a beautiful day</p>\", \"beautiful\", escape: false)).should eq \"<p>This is a <em><mark>beautiful</mark></em> morning, but also a <mark>beautiful</mark> day</p>\"\n      view(&.highlight(\"<p>This is a <em class=\\\"error\\\">beautiful</em> morning, but also a beautiful <span class=\\\"last\\\">day</span></p>\", \"beautiful\", escape: false)).should eq \"<p>This is a <em class=\\\"error\\\"><mark>beautiful</mark></em> morning, but also a <mark>beautiful</mark> <span class=\\\"last\\\">day</span></p>\"\n      view(&.highlight(\"<p class=\\\"beautiful\\\">This is a beautiful morning, but also a beautiful day</p>\", \"beautiful\", escape: false)).should eq \"<p class=\\\"beautiful\\\">This is a <mark>beautiful</mark> morning, but also a <mark>beautiful</mark> day</p>\"\n      view(&.highlight(\"<p>This is a beautiful <a href=\\\"http://example.com/beautiful\\#top?what=beautiful%20morning&when=now+then\\\">morning</a>, but also a beautiful day</p>\", \"beautiful\", escape: false)).should eq \"<p>This is a <mark>beautiful</mark> <a href=\\\"http://example.com/beautiful\\#top?what=beautiful%20morning&when=now+then\\\">morning</a>, but also a <mark>beautiful</mark> day</p>\"\n      view(&.highlight(\"<div>abc div</div>\", \"div\", highlighter: \"<b>\\\\1</b>\", escape: false)).should eq \"<div>abc <b>div</b></div>\"\n    end\n\n    it \"highlights with block\" do\n      view(&.highlight(\"one two three\", [\"one\", \"two\", \"three\"]) { |word| \"<b>#{word}</b>\" })\n        .should eq \"<b>one</b> <b>two</b> <b>three</b>\"\n      view(&.test_highlight)\n        .should eq \"This is a <span data-highlight-word=\\\"beautiful\\\" data-color=\\\"yellow\\\" data-español=\\\"bello\\\">beautiful</span> morning, but also a <span data-highlight-word=\\\"beautiful\\\" data-color=\\\"yellow\\\" data-español=\\\"bello\\\">beautiful</span> day\"\n    end\n  end\nend\n\ndef view(&)\n  HighlightTestPage.new(build_context).tap do |page|\n    yield page\n  end.view.to_s\nend\n"
  },
  {
    "path": "spec/lucky/text_helpers/pluralize_spec.cr",
    "content": "require \"./text_helpers_spec\"\n\ndescribe Lucky::TextHelpers do\n  describe \"pluralize\" do\n    it \"pluralizes words\" do\n      view.pluralize(1, \"count\").should eq \"1 count\"\n      view.pluralize(2, \"count\").should eq \"2 counts\"\n      view.pluralize(1000000000000, \"count\").should eq \"1000000000000 counts\"\n      view.pluralize(\"1\", \"count\").should eq \"1 count\"\n      view.pluralize(\"2\", \"count\").should eq \"2 counts\"\n      view.pluralize(\"1,066\", \"count\").should eq \"1,066 counts\"\n      view.pluralize(\"1.25\", \"count\").should eq \"1.25 counts\"\n      view.pluralize(\"1.0\", \"count\").should eq \"1.0 count\"\n      view.pluralize(\"1.00\", \"count\").should eq \"1.00 count\"\n      view.pluralize(2, \"count\", \"counters\").should eq \"2 counters\"\n      view.pluralize(nil, \"count\", \"counters\").should eq \"0 counters\"\n      view.pluralize(2, \"count\", plural: \"counters\").should eq \"2 counters\"\n      view.pluralize(nil, \"count\", plural: \"counters\").should eq \"0 counters\"\n      view.pluralize(2, \"person\").should eq \"2 people\"\n      view.pluralize(10, \"buffalo\").should eq \"10 buffaloes\"\n      view.pluralize(1, \"berry\").should eq \"1 berry\"\n      view.pluralize(12, \"berry\").should eq \"12 berries\"\n    end\n  end\nend\n"
  },
  {
    "path": "spec/lucky/text_helpers/simple_format_spec.cr",
    "content": "require \"./text_helpers_spec\"\n\nclass TextHelperTestPage\n  def test_simple_format_with_block\n    simple_format(\"my cool test\\n\\nis great\") do |formatted_text|\n      para formatted_text, class: \"this-is-a-custom-class\"\n    end\n  end\n\n  def test_simple_format_without_block\n    simple_format(\"my cool test\\n\\nis great\")\n  end\n\n  def test_simple_format_with_div\n    simple_format(\"\") do |txt|\n      div txt\n    end\n  end\n\n  def test_simple_format_with_custom_wrapper_and_multi_line_breaks\n    simple_format(\"We want to put a wrapper...\\n\\n...right there.\") do |txt|\n      div txt\n    end\n  end\nend\n\ndescribe Lucky::TextHelpers do\n  describe \"simple_format\" do\n    it \"simple_formats\" do\n      view.tap(&.simple_format(\"\")).render.should eq \"<p></p>\"\n\n      view.tap(&.simple_format(\"crazy\\r\\n cross\\r platform linebreaks\")).render\n        .should eq \"<p>crazy\\n<br > cross\\n<br > platform linebreaks</p>\"\n      view.tap(&.simple_format(\"A paragraph\\n\\nand another one!\")).render\n        .should eq \"<p>A paragraph</p>\\n\\n<p>and another one!</p>\"\n      view.tap(&.simple_format(\"A paragraph\\n With a newline\")).render\n        .should eq \"<p>A paragraph\\n<br > With a newline</p>\"\n\n      view.tap(&.simple_format(\"A\\nB\\nC\\nD\")).render\n        .should eq \"<p>A\\n<br >B\\n<br >C\\n<br >D</p>\"\n\n      view.tap(&.simple_format(\"A\\r\\n  \\nB\\n\\n\\r\\n\\t\\nC\\nD\")).render\n        .should eq \"<p>A\\n<br >  \\n<br >B</p>\\n\\n<p>\\t\\n<br >C\\n<br >D</p>\"\n\n      view.tap(&.simple_format(\"This is a classy test\", class: \"test\")).render\n        .should eq \"<p class=\\\"test\\\">This is a classy test</p>\"\n      view.tap(&.simple_format(\"para 1\\n\\npara 2\", class: \"test\")).render\n        .should eq %Q(<p class=\"test\">para 1</p>\\n\\n<p class=\"test\">para 2</p>)\n    end\n\n    it \"simple_formats with custom wrapper\" do\n      view.tap(&.test_simple_format_with_div).render.should eq \"<div></div>\"\n    end\n\n    it \"simple_formats with custom wrapper and multi line breaks\" do\n      view.tap(&.test_simple_format_with_custom_wrapper_and_multi_line_breaks)\n        .render\n        .should eq \"<div>We want to put a wrapper...</div>\\n\\n<div>...right there.</div>\"\n    end\n\n    it \"escapes html by default\" do\n      text = \"<b>Ok</b>\"\n\n      view.tap(&.simple_format(text)).render\n        .should eq(\"<p>&lt;b&gt;Ok&lt;/b&gt;</p>\")\n    end\n\n    it \"allows raw HTML\" do\n      text = \"<b>Ok</b>\"\n\n      view.tap(&.simple_format(text, escape: false)).render\n        .should eq(\"<p><b>Ok</b></p>\")\n    end\n\n    it \"simple_formats without modifying the html options\" do\n      html_options = {class: \"foobar\"}\n      passed_html_options = html_options.dup\n      view.simple_format(\"some text\", **passed_html_options)\n      passed_html_options.should eq html_options\n    end\n\n    it \"should\" do\n      view.tap(&.test_simple_format_without_block).render\n        .should eq \"<p>my cool test</p>\\n\\n<p>is great</p>\"\n      view.tap(&.test_simple_format_with_block).render\n        .should eq \"<p class=\\\"this-is-a-custom-class\\\">my cool test</p>\\n\\n<p class=\\\"this-is-a-custom-class\\\">is great</p>\"\n    end\n  end\nend\n"
  },
  {
    "path": "spec/lucky/text_helpers/svg_inliner_spec.cr",
    "content": "require \"./text_helpers_spec\"\n\n@[Lucky::SvgInliner::Path(\"spec/fixtures\")]\nmodule Lucky::SvgInliner\nend\n\nclass TextHelperTestPage\n  def with_inlined_svg\n    inline_svg(\"lucky_logo.svg\")\n  end\n\n  def with_inlined_and_styled_svg\n    inline_svg(\"lucky_logo.svg\", false)\n  end\n\n  def with_additional_attributes\n    inline_svg(\"lucky_logo.svg\", data_very: \"lucky\")\n  end\n\n  def with_original_styles_and_additional_attributes\n    inline_svg(\"lucky_logo.svg\", strip_styling: false, data_very: \"lucky\")\n  end\n\n  def without_file_extension\n    inline_svg(\"lucky_logo\")\n  end\nend\n\ndescribe Lucky::SvgInliner do\n  describe \".inline_svg\" do\n    it \"inlines an svg in a page\" do\n      inlined_svg = view.tap(&.with_inlined_svg).render\n      inlined_svg.should start_with %(<svg data-inline-svg=\"lucky_logo.svg\")\n      inlined_svg.should_not contain %(<?xml version=\"1.0\" encoding=\"UTF-8\"?>)\n      inlined_svg.should_not contain %(<!-- lucky logo -->)\n      inlined_svg.should_not contain \"\\n\"\n      inlined_svg.should_not contain %(fill=\"none\" stroke=\"#2a2a2a\" class=\"logo\")\n    end\n\n    it \"strips the xml declaration\" do\n      inlined_svg = view.tap(&.with_inlined_svg).render\n      inlined_svg.should_not contain %(<?xml version=\"1.0\" encoding=\"UTF-8\"?>)\n    end\n\n    it \"strips comments\" do\n      inlined_svg = view.tap(&.with_inlined_svg).render\n      inlined_svg.should_not contain %(<!-- lucky logo -->)\n    end\n\n    it \"strips newlines\" do\n      inlined_svg = view.tap(&.with_inlined_svg).render\n      inlined_svg.should_not contain \"\\n\"\n    end\n\n    it \"strips styling attributes by default\" do\n      inlined_svg = view.tap(&.with_inlined_svg).render\n      inlined_svg.should_not contain %(fill=\"none\" stroke=\"#2a2a2a\" class=\"logo\")\n    end\n\n    it \"allows inlining an svg without stripping its styling attributes\" do\n      inlined_svg = view.tap(&.with_inlined_and_styled_svg).render\n      inlined_svg.should start_with %(<svg data-inline-svg-styled=\"lucky_logo.svg\")\n      inlined_svg.should contain %(fill=\"none\" stroke=\"#2a2a2a\" class=\"logo\")\n    end\n\n    it \"accepts additional arguments for arbitrary attributes\" do\n      inlined_svg = view.tap(&.with_additional_attributes).render\n      inlined_svg.should contain %(data-very=\"lucky\")\n    end\n\n    it \"does not render the strip_styling option as attribute\" do\n      inlined_svg = view.tap(&.with_original_styles_and_additional_attributes).render\n      inlined_svg.should contain %(data-very=\"lucky\")\n      inlined_svg.should_not contain %(strip-styling=\"false\")\n    end\n\n    it \"allows passing the svg path name without an extension\" do\n      inlined_svg = view.tap(&.without_file_extension).render\n      inlined_svg.should start_with %(<svg data-inline-svg=\"lucky_logo.svg\")\n    end\n  end\nend\n"
  },
  {
    "path": "spec/lucky/text_helpers/text_helpers_spec.cr",
    "content": "require \"../../spec_helper\"\n\ninclude ContextHelper\n\nclass TextHelperTestPage\n  include Lucky::HTMLPage\n\n  def render\n    view.to_s\n  end\nend\n\ndef view\n  TextHelperTestPage.new(build_context)\nend\n"
  },
  {
    "path": "spec/lucky/text_helpers/to_sentence_spec.cr",
    "content": "require \"./text_helpers_spec\"\n\nclass CustomEnumerable\n  include Enumerable(Int32)\n\n  def each(&)\n    yield 1\n    yield 2\n    yield 3\n  end\nend\n\ndescribe Lucky::TextHelpers do\n  describe \"to_sentence\" do\n    it \"correctly handles an empty list\" do\n      list = [] of String\n\n      view.to_sentence(list).should eq \"\"\n    end\n\n    it \"correctly handles a list of one\" do\n      list = [\"cat\"]\n\n      view.to_sentence(list).should eq \"cat\"\n    end\n\n    it \"creates a sentence from a list of two\" do\n      list = [\"cat\", \"dog\"]\n\n      view.to_sentence(list).should eq \"cat and dog\"\n    end\n\n    it \"creates a sentence from a list of three or more\" do\n      list = [\"cat\", \"dog\", \"elephant\", \"fox\"]\n\n      view.to_sentence(list).should eq \"cat, dog, elephant, and fox\"\n    end\n\n    it \"works correctly when the list is a tuple\" do\n      list = {\"cat\", \"dog\", \"elephant\"}\n\n      view.to_sentence(list).should eq \"cat, dog, and elephant\"\n    end\n\n    it \"works correctly when the list is a custom enumerable\" do\n      list = CustomEnumerable.new\n\n      view.to_sentence(list).should eq \"1, 2, and 3\"\n    end\n\n    it \"uses the provided word connector when given\" do\n      list = {\"cat\", \"dog\", \"elephant\", \"fox\"}\n\n      view.to_sentence(list, word_connector: \" + \").should eq \"cat + dog + elephant, and fox\"\n    end\n\n    it \"uses the provided two word connector when given\" do\n      list = {\"cat\", \"dog\"}\n\n      view.to_sentence(list, two_word_connector: \" with \").should eq \"cat with dog\"\n    end\n\n    it \"uses the provided last word connector when given\" do\n      list = {\"cat\", \"dog\", \"elephant\"}\n\n      view.to_sentence(list, last_word_connector: \", or \").should eq \"cat, dog, or elephant\"\n    end\n  end\nend\n"
  },
  {
    "path": "spec/lucky/text_helpers/truncate_spec.cr",
    "content": "require \"./text_helpers_spec\"\n\nclass TextHelperTestPage\n  def test_truncate\n    truncate \"Hello World\", length: 8 do\n      a \"Continue\", href: \"#\"\n    end\n  end\n\n  def text_truncate_with_block_invoked\n    truncate(\"Here is a long test and I need a continue to read link\", length: 27) do\n      a \"Continue\", href: \"#\"\n    end\n  end\n\n  def text_truncate_without_block_invoked\n    truncate(\"Hello World\", length: 12) do\n      a \"Continue\", href: \"#\"\n    end\n  end\nend\n\ndescribe Lucky::TextHelpers do\n  describe \"truncate\" do\n    it \"truncates\" do\n      view.tap(&.truncate(\"Hello World!\", length: 12)).render\n        .should eq \"Hello World!\"\n      view.tap(&.truncate(\"Hello World!!\", length: 12)).render\n        .should eq \"Hello Wor...\"\n    end\n\n    it \"escapes the text by default\" do\n      view.tap(&.truncate(\"<span>escape me</span>\", length: 12)).render\n        .should eq \"&lt;span&gt;esc...\"\n    end\n\n    it \"allows leaving the text unescaped\" do\n      view.tap(&.truncate(\"<span>leave me as-is</span>\", length: 12, escape: false)).render\n        .should eq \"<span>lea...\"\n    end\n\n    it \"truncates with default length of 30\" do\n      str = \"This is a string that will go longer then the default truncate length of 30\"\n      view.tap(&.truncate(str)).render.should eq str[0...27] + \"...\"\n    end\n\n    it \"truncates with options\" do\n      view.tap(&.truncate(\"This is a string that will go longer then the default truncate length of 30\", omission: \"[...]\")).render\n        .should eq \"This is a string that wil[...]\"\n      view.tap(&.truncate(\"Hello World!\", length: 10)).render\n        .should eq \"Hello W...\"\n      view.tap(&.truncate(\"Hello World!\", omission: \"[...]\", length: 10)).render\n        .should eq \"Hello[...]\"\n      view.tap(&.truncate(\"Hello Big World!\", omission: \"[...]\", length: 13, separator: \" \")).render\n        .should eq \"Hello[...]\"\n      view.tap(&.truncate(\"Hello Big World!\", omission: \"[...]\", length: 14, separator: \" \")).render\n        .should eq \"Hello Big[...]\"\n      view.tap(&.truncate(\"Hello Big World!\", omission: \"[...]\", length: 15, separator: \" \")).render\n        .should eq \"Hello Big[...]\"\n    end\n\n    it \"truncates with link options\" do\n      view.tap(&.text_truncate_with_block_invoked).render\n        .should eq \"Here is a long test and ...<a href=\\\"#\\\">Continue</a>\"\n      view.tap(&.text_truncate_without_block_invoked).render\n        .should eq \"Hello World\"\n    end\n  end\nend\n"
  },
  {
    "path": "spec/lucky/text_helpers/word_wrap_spec.cr",
    "content": "require \"./text_helpers_spec\"\n\ndescribe Lucky::TextHelpers do\n  describe \"word_word\" do\n    it \"word wraps\" do\n      view.word_wrap(\"my very very very long string\", line_width: 15).should eq \"my very very\\nvery long\\nstring\"\n    end\n\n    it \"word wraps with extra newlines\" do\n      view.word_wrap(\"my very very very long string\\n\\nwith another line\", line_width: 15).should eq \"my very very\\nvery long\\nstring\\n\\nwith another\\nline\"\n    end\n\n    it \"word wraps with custom break sequence\" do\n      view.word_wrap(\"1234567890 \" * 3, line_width: 2, break_sequence: \"\\r\\n\").should eq \"1234567890\\r\\n1234567890\\r\\n1234567890\"\n    end\n  end\nend\n"
  },
  {
    "path": "spec/lucky/text_response_spec.cr",
    "content": "require \"../spec_helper\"\n\ninclude ContextHelper\n\n# Monkey patch HTTP::Server::Response to allow accessing the response body directly.\nclass HTTP::Server::Response\n  getter body_io : IO = IO::Memory.new\n\n  def write(slice : Bytes) : Nil\n    @body_io.write slice\n\n    previous_def\n  end\nend\n\ndescribe Lucky::TextResponse do\n  describe \"#print\" do\n    context \"flash\" do\n      it \"writes the flash to the session\" do\n        context = build_context\n        context.flash.success = \"Yay!\"\n        context.flash.keep\n        flash_json = {success: \"Yay!\"}.to_json\n\n        print_response_with_body(context)\n\n        context.session.get(Lucky::FlashStore::SESSION_KEY).should eq(flash_json)\n      end\n\n      it \"only keeps the flash for one request\" do\n        context_1 = build_context\n        now_json = {success: \"Yay!\"}.to_json\n        context_1.session.set(Lucky::FlashStore::SESSION_KEY, now_json)\n        next_json = context_1.flash.to_json\n\n        context_1.flash.success.should eq(\"Yay!\")\n\n        print_response_with_body(context_1)\n\n        context_2 = build_context\n        context_2.session.set(Lucky::FlashStore::SESSION_KEY, next_json)\n\n        context_2.flash.success.should eq(\"\")\n      end\n\n      it \"keeps the flash for the next request\" do\n        context_1 = build_context\n        context_1.flash.success = \"Yay!\"\n        context_1.flash.keep\n        next_json = context_1.flash.to_json\n\n        print_response_with_body(context_1)\n\n        context_2 = build_context\n        context_2.session.set(Lucky::FlashStore::SESSION_KEY, next_json)\n\n        context_2.flash.success.should eq(\"Yay!\")\n      end\n    end\n\n    context \"cookies\" do\n      it \"sets a cookie\" do\n        context = build_context\n        context.cookies.set(:email, \"test@example.com\")\n\n        print_response_with_body(context)\n\n        context.response.headers.has_key?(\"Set-Cookie\").should be_true\n        context.response.headers[\"Set-Cookie\"].should contain(\"email=\")\n      end\n\n      it \"persist cookies across multiple requests using response headers from Lucky and request headers from the browser\" do\n        context_1 = build_context\n        context_1.cookies.set(:email, \"test@example.com\")\n\n        print_response_with_body(context_1)\n\n        browser_request = build_request\n        cookie_header = context_1.response.cookies.map do |cookie|\n          cookie.to_cookie_header\n        end.join(\", \")\n        browser_request.headers.add(\"Cookie\", cookie_header)\n        context_2 = build_context(\"/\", request: browser_request)\n\n        context_2.cookies.get(:email).should eq \"test@example.com\"\n      end\n\n      it \"only writes updated cookies to the response\" do\n        request = build_request\n        # set initial cookies via header\n        request.headers.add(\"Cookie\", \"cookie1=value1; cookie2=value2\")\n        context = build_context(\"/\", request: request)\n        context.cookies.set_raw(:cookie2, \"updated2\")\n\n        print_response_with_body(context)\n\n        context.response.headers[\"Set-Cookie\"].should contain(\"cookie2=updated2\")\n        context.response.headers[\"Set-Cookie\"].should_not contain(\"cookie1\")\n      end\n\n      it \"sets a session\" do\n        context = build_context\n        context.session.set(:email, \"test@example.com\")\n\n        print_response_with_body(context)\n\n        context.response.headers.has_key?(\"Set-Cookie\").should be_true\n        context.response.headers[\"Set-Cookie\"].should contain(\"_app_session\")\n      end\n\n      it \"persists the session across multiple requests\" do\n        context_1 = build_context\n        context_1.session.set(:email, \"test@example.com\")\n\n        print_response_with_body(context_1)\n\n        request = build_request\n        cookie_header = context_1.response.cookies.map do |cookie|\n          cookie.to_cookie_header\n        end.join(\"; \")\n        request.headers.add(\"Cookie\", cookie_header)\n        context_2 = build_context(\"/\", request: request)\n        print_response_with_body(context_2)\n\n        context_2.session.get(:email).should eq(\"test@example.com\")\n      end\n\n      it \"writes all the proper headers when a cookie is set\" do\n        context = build_context\n        context\n          .cookies\n          .set(:yo, \"lo\")\n          .path(\"/awesome\")\n          .expires(Time.utc(2000, 1, 1))\n          .domain(\"luckyframework.org\")\n          .secure(true)\n          .http_only(true)\n\n        print_response_with_body(context)\n\n        header = context.response.headers[\"Set-Cookie\"]\n        header.should contain(\"path=/awesome\")\n        header.should contain(\"expires=Sat, 01 Jan 2000\")\n        header.should contain(\"domain=luckyframework.org\")\n        header.should contain(\"Secure\")\n        header.should contain(\"HttpOnly\")\n      end\n\n      it \"allows for cookies to be disabled\" do\n        context = build_context\n        context.session.set(:email, \"test@example.com\")\n\n        print_response_with_body(context, enable_cookies: false)\n\n        context.response.headers.has_key?(\"Set-Cookie\").should be_false\n      end\n    end\n\n    context \"status\" do\n      it \"uses the default status if none is set\" do\n        context = build_context\n        print_response(context, status: nil)\n        context.response.status_code.should eq Lucky::TextResponse::DEFAULT_STATUS\n      end\n\n      it \"uses the passed in status\" do\n        context = build_context\n        print_response(context, status: 300)\n        context.response.status_code.should eq 300\n      end\n\n      it \"uses the response status if it's set, and Lucky::TextResponse status is nil\" do\n        context = build_context\n        context.response.status_code = 300\n        print_response(context, status: nil)\n        context.response.status_code.should eq 300\n      end\n\n      it \"prints no body with a head call\" do\n        context = build_context(\"HEAD\")\n        print_response_with_body(context, \"Body\", status: nil)\n        context.request.method.should eq \"HEAD\"\n        context.response.body_io.to_s.should eq(\"\")\n        context.response.status_code.should eq 200\n      end\n    end\n\n    context \"compression\" do\n      it \"gzips if enabled\" do\n        Lucky::Server.temp_config(gzip_enabled: true) do\n          output = IO::Memory.new\n          context = build_context_with_io(output)\n          context.request.headers[\"Accept-Encoding\"] = \"gzip\"\n\n          print_response_with_body(context, status: 200, body: \"some body\")\n          context.response.close\n\n          context.response.headers[\"Content-Encoding\"].should eq \"gzip\"\n          expected_io = IO::Memory.new\n          Compress::Gzip::Writer.open(expected_io, &.print(\"some body\"))\n          output.to_s.ends_with?(expected_io.to_s).should be_true\n        end\n      end\n\n      it \"doesn't gzip when content type isn't in Lucky::Server.gzip_content_types\" do\n        Lucky::Server.temp_config(gzip_enabled: true) do\n          output = IO::Memory.new\n          context = build_context_with_io(output)\n          context.request.headers[\"Accept-Encoding\"] = \"gzip\"\n\n          print_response_with_body(context, status: 200, body: \"some body\", content_type: \"foo/bar\")\n          context.response.close\n\n          context.response.headers[\"Content-Encoding\"]?.should_not eq \"gzip\"\n          output.to_s.ends_with?(\"some body\").should be_true\n        end\n      end\n    end\n  end\nend\n\nprivate def print_response(context : HTTP::Server::Context, status : Int32?)\n  print_response_with_body(context, \"\", status)\nend\n\nprivate def print_response_with_body(\n  context : HTTP::Server::Context,\n  body = \"\",\n  status = 200,\n  content_type = \"text/html\",\n  enable_cookies = true,\n)\n  Lucky::TextResponse.new(\n    context,\n    content_type,\n    body,\n    status: status,\n    enable_cookies: enable_cookies\n  ).print\nend\n"
  },
  {
    "path": "spec/lucky/time_helpers_spec.cr",
    "content": "require \"../spec_helper\"\n\ninclude ContextHelper\n\nprivate class TestPage\n  include Lucky::HTMLPage\n  include Lucky::TimeHelpers\nend\n\ndescribe Lucky::TimeHelpers do\n  describe \"distance_of_time_in_words\" do\n    it \"reports the approximate distance in time between two Time\" do\n      from_time = Time.local\n      view.distance_of_time_in_words(from_time, from_time + 1.second).should eq \"a second\"\n      view.distance_of_time_in_words(from_time, from_time + 10.seconds).should eq \"10 seconds\"\n      view.distance_of_time_in_words(from_time, from_time + 1.minute).should eq \"a minute\"\n      view.distance_of_time_in_words(from_time, from_time + 2.minutes).should eq \"2 minutes\"\n      view.distance_of_time_in_words(from_time, from_time + 35.minutes).should eq \"35 minutes\"\n      view.distance_of_time_in_words(from_time, from_time + 45.minutes).should eq \"about an hour\"\n      view.distance_of_time_in_words(from_time, from_time + 50.minutes).should eq \"about an hour\"\n      view.distance_of_time_in_words(from_time, from_time + 1.hour).should eq \"an hour\"\n      view.distance_of_time_in_words(from_time, from_time + 110.minutes).should eq \"almost 2 hours\"\n      view.distance_of_time_in_words(from_time, from_time + 350.minutes).should eq \"almost 6 hours\"\n      view.distance_of_time_in_words(from_time, from_time + 2.hours).should eq \"2 hours\"\n      view.distance_of_time_in_words(from_time, from_time + 1.day).should eq \"a day\"\n      view.distance_of_time_in_words(from_time, from_time + 10.days).should eq \"10 days\"\n      view.distance_of_time_in_words(from_time, from_time + 1.month).should eq \"about a month\"\n      view.distance_of_time_in_words(from_time, from_time + 10.months).should eq \"10 months\"\n      view.distance_of_time_in_words(from_time, from_time + 12.months).should eq \"about a year\"\n      view.distance_of_time_in_words(from_time, from_time + 2.years).should eq \"over 2 years\"\n      view.distance_of_time_in_words(from_time, from_time + 10.years).should eq \"almost 10 years\"\n    end\n\n    it \"takes a Time::Span\" do\n      span = 4.minutes\n      view.distance_of_time_in_words(span).should eq \"4 minutes\"\n    end\n  end\n\n  describe \"time_ago_in_words\" do\n    it \"returns the distance from now\" do\n      view.time_ago_in_words(Time.local - 13.months).should eq \"about a year\"\n    end\n  end\n\n  describe \"time_from_now_in_words\" do\n    it \"returns the distance between now and future date\" do\n      view.time_from_now_in_words(Time.local + 13.months).should eq \"about a year\"\n    end\n  end\nend\n\nprivate def view\n  TestPage.new(build_context)\nend\n"
  },
  {
    "path": "spec/lucky/uploaded_file_spec.cr",
    "content": "require \"../spec_helper\"\n\ninclude ContextHelper\ninclude MultipartHelper\n\ndescribe Lucky::UploadedFile do\n  describe \"#name\" do\n    it \"returns the form data part name\" do\n      uploaded_file.name.should eq(\"welcome_file\")\n    end\n  end\n\n  describe \"#tempfile\" do\n    it \"returns the tempfile\" do\n      uploaded_file.tempfile.should be_a(File)\n    end\n\n    it \"can be read\" do\n      uploaded_file.tempfile.gets_to_end.should eq(\"welcome file contents\")\n    end\n  end\n\n  describe \"#path\" do\n    it \"returns the file path\" do\n      uploaded_file.path.starts_with?(Dir.tempdir).should be_true\n      uploaded_file.path.ends_with?(\"welcome_file\").should be_true\n    end\n  end\n\n  describe \"#filename\" do\n    it \"returns the original file from the metadata object\" do\n      uploaded_file.filename.should eq(\"welcome_file\")\n    end\n\n    it \"generates random filename for files without names\" do\n      nameless_uploaded_file.filename.should_not be_empty\n    end\n  end\n\n  describe \"#blank?\" do\n    it \"tests if the file name is blank\" do\n      uploaded_file.blank?.should be_false\n      empty_uploaded_file.blank?.should be_true\n    end\n  end\nend\n\nprivate def empty_uploaded_file\n  Lucky::Params.new(build_multipart_request(file_parts: {\n    \"empty_file\" => \"\",\n  })).get_file(:empty_file)\nend\n\nprivate def nameless_uploaded_file\n  Lucky::Params.new(build_multipart_request(file_parts: {\n    \"\" => \"nameless file contents\",\n  })).from_multipart[1][\"\"]\nend\n\nprivate def uploaded_file\n  Lucky::Params.new(build_multipart_request(file_parts: {\n    \"welcome_file\" => \"welcome file contents\",\n  })).get_file(:welcome_file)\nend\n"
  },
  {
    "path": "spec/lucky/url_format_spec.cr",
    "content": "require \"../spec_helper\"\n\ninclude ContextHelper\n\nprivate class FakeActionWithFormat\n  include Lucky::RequestTypeHelpers\n  default_format :html\n\n  property context : HTTP::Server::Context = ContextHelper.build_context\n  class_property _accepted_formats = [:html, :json, :csv, :xml] of Symbol\n\n  delegate request, to: context\nend\n\ndescribe \"URL Format Detection\" do\n  it \"extracts format from URL path\" do\n    Lucky::MimeType.extract_format_from_path(\"/reports/123.csv\").should eq Lucky::Format::Csv\n    Lucky::MimeType.extract_format_from_path(\"/reports/123.json\").should eq Lucky::Format::Json\n    Lucky::MimeType.extract_format_from_path(\"/reports/123.xml\").should eq Lucky::Format::Xml\n    Lucky::MimeType.extract_format_from_path(\"/reports/123.html\").should eq Lucky::Format::Html\n  end\n\n  it \"returns nil for unknown formats\" do\n    Lucky::MimeType.extract_format_from_path(\"/reports/123.unknown\").should be_nil\n    Lucky::MimeType.extract_format_from_path(\"/reports/123\").should be_nil\n  end\n\n  it \"handles query parameters correctly\" do\n    Lucky::MimeType.extract_format_from_path(\"/reports/123.csv?param=value\").should eq Lucky::Format::Csv\n    Lucky::MimeType.extract_format_from_path(\"/reports/123.json?foo=bar&baz=qux\").should eq Lucky::Format::Json\n  end\n\n  it \"uses URL format over Accept header\" do\n    context = build_context\n    context.request.headers[\"Accept\"] = \"application/json\"\n    context._url_format = Lucky::Format::Csv\n\n    action = FakeActionWithFormat.new\n    action.context = context\n    action.accepts?(:csv).should be_true\n    action.accepts?(:json).should be_false\n  end\n\n  it \"falls back to Accept header when no URL format\" do\n    context = build_context\n    context.request.headers[\"Accept\"] = \"application/json\"\n    # Don't set _url_format, should fallback to Accept header\n\n    action = FakeActionWithFormat.new\n    action.context = context\n    action.accepts?(:json).should be_true\n    action.accepts?(:csv).should be_false\n  end\nend\n"
  },
  {
    "path": "spec/lucky/url_helpers_spec.cr",
    "content": "require \"../spec_helper\"\n\ninclude ContextHelper\n\ndescribe Lucky::UrlHelpers do\n  describe \"#current_page?\" do\n    context \"given a string\" do\n      it \"tests if a path matches the request path or not\" do\n        view_for(\"/\").current_page?(\"/\").should be_true\n        view_for(\"/action\").current_page?(\"/gum\").should be_false\n        view_for(\"/action\").current_page?(\"/action\").should be_true\n        view_for(\"/action\").current_page?(\"/action/\").should be_true\n        view_for(\"/action/\").current_page?(\"/action\").should be_true\n        view_for(\"/action/\").current_page?(\"/action/\").should be_true\n      end\n\n      it \"tests if the path of a url matches request path or not\" do\n        view_for(\"/\")\n          .current_page?(\"https://example.com/\")\n          .should be_true\n        view_for(\"/action\")\n          .current_page?(\"https://example.com/action\")\n          .should be_true\n        view_for(\"/action\", host_with_port: \"example.io\")\n          .current_page?(\"https://example.com/action\")\n          .should be_false\n        view_for(\"/action\", host_with_port: \"example.com:3000\")\n          .current_page?(\"https://example.com/action\")\n          .should be_false\n        view_for(\"/action\", host_with_port: \"example.com:3000\")\n          .current_page?(\"https://example.com:3000/action\")\n          .should be_true\n        view_for(\"/action\", host_with_port: \"example.com:3000\")\n          .current_page?(\"http://example.com:3000/action\")\n          .should be_true\n      end\n\n      it \"only tests positive for get and head requests\" do\n        view_for(\"/get\", \"GET\").current_page?(\"/get\").should be_true\n        view_for(\"/head\", \"HEAD\").current_page?(\"/head\").should be_true\n        view_for(\"/post\", \"POST\").current_page?(\"/post\").should be_false\n        view_for(\"/put\", \"PUT\").current_page?(\"/put\").should be_false\n        view_for(\"/patch\", \"PATCH\").current_page?(\"/patch\").should be_false\n        view_for(\"/delete\", \"DELETE\").current_page?(\"/delete\").should be_false\n      end\n\n      it \"ignores query parameters by default\" do\n        view_for(\"/action?order=desc&page=1\").current_page?(\"/action\")\n          .should be_true\n        view_for(\"/action\").current_page?(\"/action?order=desc&page=1\")\n          .should be_true\n        view_for(\"/action?order=desc&page=1\").current_page?(\"/action/123\")\n          .should be_false\n      end\n\n      it \"deals with escaped characters in query params\" do\n        view_for(\"/pages?description=Some%20d%C3%A9scription\")\n          .current_page?(\"/pages?description=Some déscription\", check_query_params: true)\n          .should be_true\n        view_for(\"/pages?description=Some%20d%C3%A9scription\")\n          .current_page?(\"/pages?description=Some%20d%C3%A9scription\", check_query_params: true)\n          .should be_true\n      end\n\n      it \"checks query params if explicitly required\" do\n        view_for(\"/action?order=desc&page=1\")\n          .current_page?(\"/action?order=desc&page=1\", check_query_params: true)\n          .should be_true\n        view_for(\"/action\")\n          .current_page?(\"/action\", check_query_params: true)\n          .should be_true\n        view_for(\"/action\")\n          .current_page?(\"/action?order=desc&page=1\", check_query_params: true)\n          .should be_false\n        view_for(\"/action?order=desc&page=1\")\n          .current_page?(\"/action\", check_query_params: true)\n          .should be_false\n      end\n\n      it \"does not care about the order of query params\" do\n        view_for(\"/action?order=desc&page=1\")\n          .current_page?(\"/action?order=desc&page=1\", check_query_params: true)\n          .should be_true\n        view_for(\"/action?order=desc&page=1\")\n          .current_page?(\"/action?page=1&order=desc\", check_query_params: true)\n          .should be_true\n      end\n\n      it \"ignores anchors\" do\n        view_for(\"/pages/123\").current_page?(\"/pages/123#section\")\n          .should be_true\n        view_for(\"/pages/123#section\").current_page?(\"/pages/123\")\n          .should be_true\n        view_for(\"/pages/123#section\").current_page?(\"/pages/123#section\")\n          .should be_true\n        view_for(\"/pages/123\")\n          .current_page?(\"/pages/123#section\", check_query_params: true)\n          .should be_true\n      end\n    end\n\n    context \"given a browser action\" do\n      it \"tests if the path matches or not\" do\n        view_for(\"/pages/123\").current_page?(Pages::Show.with(123))\n          .should be_true\n        view_for(\"/pages/123\").current_page?(Pages::Show.with(12))\n          .should be_false\n        view_for(\"/pages\").current_page?(Pages::Index)\n          .should be_true\n        view_for(\"/pages\")\n          .current_page?(Pages::Index.with(page: 2))\n          .should be_true\n        view_for(\"/pages?page=2\")\n          .current_page?(Pages::Index)\n          .should be_true\n      end\n\n      it \"checks query params if explicitly required\" do\n        view_for(\"/pages\")\n          .current_page?(Pages::Index, check_query_params: true)\n          .should be_true\n        view_for(\"/pages?page=2\")\n          .current_page?(Pages::Index.with(page: 2), check_query_params: true)\n          .should be_true\n        view_for(\"/pages\")\n          .current_page?(Pages::Index.with(page: 2), check_query_params: true)\n          .should be_false\n        view_for(\"/pages?page=2\")\n          .current_page?(Pages::Index, check_query_params: true)\n          .should be_false\n      end\n\n      it \"ignores anchors\" do\n        view_for(\"/pages/123\")\n          .current_page?(Pages::Show.with(123, anchor: \"section\"))\n          .should be_true\n        view_for(\"/pages/123#section\")\n          .current_page?(Pages::Show.with(123))\n          .should be_true\n        view_for(\"/pages/123#section\")\n          .current_page?(Pages::Show.with(123, anchor: \"section\"))\n          .should be_true\n        view_for(\"/pages/123\")\n          .current_page?(Pages::Show.with(123, anchor: \"section\"), check_query_params: true)\n          .should be_true\n      end\n    end\n  end\n\n  describe \"#previous_url\" do\n    it \"returns the previous url from referer header when present\" do\n      view_for(\"/pages/456\", headers: {\"Referer\" => \"http://luckyframework.org/pages/123\"})\n        .previous_url(Pages::Index)\n        .should eq \"http://luckyframework.org/pages/123\"\n    end\n\n    it \"falls back to passed Lucky::Action when referer is the current page\" do\n      view_for(\"/pages/456\", headers: {\"Referer\" => \"http://luckyframework.org/pages/456\"})\n        .previous_url(Pages::Index)\n        .should eq \"/pages\"\n    end\n\n    it \"falls back to passed Lucky::Action when referer path matches current page with query params\" do\n      view_for(\"/pages/456?edit=true\", headers: {\"Referer\" => \"http://luckyframework.org/pages/456\"})\n        .previous_url(Pages::Index)\n        .should eq \"/pages\"\n    end\n\n    it \"falls back to passed Lucky::Action when referer header is not present\" do\n      view_for(\"/pages/123\")\n        .previous_url(Pages::Index)\n        .should eq \"/pages\"\n    end\n\n    it \"falls back to passed Lucky::RouteHelper when referer header is not present\" do\n      view_for(\"/pages/123\")\n        .previous_url(Pages::Show.with(456))\n        .should eq \"/pages/456\"\n    end\n  end\nend\n\nprivate def view_for(\n  path : String,\n  method : String = \"GET\",\n  host_with_port : String = \"example.com\",\n  headers : Hash(String, String) = {} of String => String,\n)\n  request = HTTP::Request.new(method, path)\n  request.headers[\"Host\"] = host_with_port\n  headers.each do |header, value|\n    request.headers[header] = value\n  end\n  TestPage.new(build_context(path: path, request: request))\nend\n\nprivate class TestPage\n  include Lucky::HTMLPage\nend\n\nclass Pages::Index < TestAction\n  param page : Int32 = 1\n\n  get \"/pages\" do\n    plain_text \"I'm just a list of pages\"\n  end\nend\n\nclass Pages::Show < TestAction\n  get \"/pages/:id\" do\n    plain_text \"I'm just a page\"\n  end\nend\n"
  },
  {
    "path": "spec/lucky/verify_accepts_format_spec.cr",
    "content": "require \"../spec_helper\"\n\ninclude ContextHelper\n\nprivate class ActionThatAcceptsHtml < Lucky::Action\n  accepted_formats [:html], default: :html\n\n  get \"/test\" do\n    plain_text \"yay\"\n  end\n\n  property clients_desired_format : Symbol = :foo\nend\n\n# Test that the default is the first format if there is just one format.\nprivate class ActionWithImplicitDefault < Lucky::Action\n  accepted_formats [:html]\n\n  get \"/test2\" do\n    plain_text \"yay\"\n  end\n\n  property clients_desired_format : Symbol = :foo\nend\n\nprivate class ActionThatAcceptsAnyFormat < Lucky::Action\n  get \"/test3\" do\n    plain_text \"yay\"\n  end\n\n  property clients_desired_format : Symbol = :foo\nend\n\nprivate class ActionWithUnrecognizedFormat < Lucky::Action\n  accepted_formats [:wut_is_this]\n\n  get \"/test4\" do\n    plain_text \"not yay\"\n  end\n\n  property clients_desired_format : Symbol = :foo\nend\n\nprivate class ChildHtmlAction < ActionThatAcceptsHtml\nend\n\ndescribe Lucky::VerifyAcceptsFormat do\n  it \"child inherits accepted_formats from parent\" do\n    override_format ChildHtmlAction, :html do |action|\n      action.call.body.should eq(\"yay\")\n    end\n\n    expect_raises Lucky::NotAcceptableError do\n      override_format ChildHtmlAction, :not_accepted do |action|\n        action.call\n      end\n    end\n  end\n\n  it \"lets the request through if the format is accepted\" do\n    override_format ActionThatAcceptsHtml, :html do |action|\n      action.call.body.should eq(\"yay\")\n    end\n  end\n\n  it \"raises an error if the format is not accepted\" do\n    expect_raises Lucky::NotAcceptableError do\n      override_format ActionThatAcceptsHtml, :not_accepted do |action|\n        action.call\n      end\n    end\n  end\n\n  it \"lets any format through if accepted_formats is not set\" do\n    override_format ActionThatAcceptsAnyFormat, :will_accept_anything do |action|\n      action.call.body.should eq(\"yay\")\n    end\n  end\n\n  it \"raises if given a format that Lucky doesn't recognize\" do\n    expect_raises Exception, \":wut_is_this\" do\n      override_format ActionWithUnrecognizedFormat, :not_used do |action|\n        action.call\n      end\n    end\n  end\nend\n\nprivate def override_format(action, format : Symbol, &)\n  action = action.new(build_context, params)\n  action.clients_desired_format = format\n  yield action\nend\n"
  },
  {
    "path": "spec/lucky/welcome_page_spec.cr",
    "content": "require \"../spec_helper\"\n\ninclude ContextHelper\n\ndescribe Lucky::WelcomePage do\n  it \"compiles successfully\" do\n    Lucky::WelcomePage.new(context: build_context).tap(&.render).view.to_s\n      .should contain(\"Welcome to Lucky\")\n  end\nend\n"
  },
  {
    "path": "spec/spec_helper.cr",
    "content": "require \"spec\"\nrequire \"lucky_env\"\nrequire \"../src/lucky\"\nrequire \"../tasks/**\"\nrequire \"./support/**\"\n\ninclude RoutesHelper\n\nPulsar.enable_test_mode!\n\n# Load default Bun manifest\nLucky::AssetHelpers.load_manifest(from: :bun)\n# Load legacy Laravel Mix manifest\nLucky::AssetHelpers.load_manifest(from: :mix)\n# Load alternative Vite manifest\nLucky::AssetHelpers.load_manifest(\"./public/vite-manifest.json\", from: :vite)\n\nSpec.before_each do\n  ARGV.clear\nend\n\nLog.dexter.configure(:none)\n\nLucky::Session.configure do |settings|\n  settings.key = \"_app_session\"\nend\n\nLucky::Server.configure do |settings|\n  settings.secret_key_base = \"EPzB4/PA/JZxEhISPr7Ad5X+G73exX+qg8IKFjqwdx0=\"\n  settings.host = \"0.0.0.0\"\n  settings.port = 8080\nend\n\nLucky::RouteHelper.configure do |settings|\n  settings.base_uri = \"luckyframework.org\"\nend\n\nLucky::ErrorHandler.configure do |settings|\n  settings.show_debug_output = false\nend\n\nLucky::ForceSSLHandler.configure do |settings|\n  settings.enabled = true\nend\n\nHabitat.raise_if_missing_settings!\n"
  },
  {
    "path": "spec/support/cleanup_helper.cr",
    "content": "require \"file_utils\"\n\nmodule CleanupHelper\n  private def cleanup\n    FileUtils.rm_rf(\"./tmp\")\n  end\n\n  private def with_cleanup(&)\n    Dir.mkdir_p(\"./tmp\")\n    Dir.cd(\"./tmp\")\n    yield\n  ensure\n    Dir.cd(\"..\")\n    cleanup\n  end\nend\n"
  },
  {
    "path": "spec/support/context_helper.cr",
    "content": "module ContextHelper\n  extend self\n\n  private def build_request(\n    method = \"GET\",\n    body = \"\",\n    content_type = \"\",\n    fixed_length : Bool = false,\n    host = \"example.com\",\n  ) : HTTP::Request\n    headers = HTTP::Headers.new\n    headers.add(\"Content-Type\", content_type)\n    headers.add(\"Host\", host)\n    if fixed_length\n      body = HTTP::FixedLengthContent.new(IO::Memory.new(body), body.size)\n    end\n    HTTP::Request.new(method, \"/\", body: body, headers: headers)\n  end\n\n  def build_context(\n    path = \"/\",\n    request : HTTP::Request? = nil,\n  ) : HTTP::Server::Context\n    build_context_with_io(IO::Memory.new, path: path, request: request)\n  end\n\n  def build_context(request : HTTP::Request) : HTTP::Server::Context\n    build_context(path: \"/\", request: request)\n  end\n\n  private def build_context(method : String) : HTTP::Server::Context\n    build_context_with_io(\n      IO::Memory.new,\n      path: \"/\",\n      request: build_request(method)\n    )\n  end\n\n  private def build_context_with_io(\n    io : IO,\n    path = \"/\",\n    request = nil,\n  ) : HTTP::Server::Context\n    request = request || HTTP::Request.new(\"GET\", path)\n    response = HTTP::Server::Response.new(io)\n    HTTP::Server::Context.new request, response\n  end\n\n  private def build_context_with_flash(flash : String)\n    build_context.tap do |context|\n      context.session.set(Lucky::FlashStore::SESSION_KEY, flash)\n    end\n  end\n\n  private def params\n    {} of String => String\n  end\nend\n"
  },
  {
    "path": "spec/support/exec_template.cr.template",
    "content": "# Just for testing\n"
  },
  {
    "path": "spec/support/generator_helper.cr",
    "content": "module GeneratorHelper\n  private def generate(generator : Class, args : Array(String) = [] of String) : IO\n    task = generator.new\n    task.output = IO::Memory.new\n    task.print_help_or_call(args: args)\n    task.output\n  end\n\n  private def should_create_files_with_contents(io : IO, **files_and_contents)\n    files_and_contents.each do |file_location, file_contents|\n      File.read(Path[file_location.to_s].normalize.to_s).should contain(file_contents)\n      io.to_s.should contain(Path[file_location.to_s].normalize.to_s)\n    end\n  end\n\n  private def should_generate_migration(named name : String)\n    Dir.new(\"./db/migrations\").any?(&.ends_with?(name)).should be_true\n  end\n\n  private def should_generate_migration(named name : String, with content : String)\n    filename = Dir.new(\"./db/migrations\").find(&.ends_with?(name))\n    filename.should_not be_nil\n    File.read(\"./db/migrations/#{filename}\").should contain(content)\n  end\n\n  private def should_have_generated(text : String, inside : String)\n    File.read(inside).should contain(text)\n  end\nend\n"
  },
  {
    "path": "spec/support/multipart_helper.cr",
    "content": "module MultipartHelper\n  alias Parts = Hash(String, String | Hash(String, String) | Array(Hash(String, String)) | Array(String))\n\n  BLANK_PART = {} of String => String\n\n  private def build_multipart_request(form_parts : Parts = BLANK_PART,\n                                      file_parts : Parts = BLANK_PART)\n    form_io, content_type = IO::Memory.new, \"\"\n    HTTP::FormData.build(form_io) do |formdata|\n      content_type = formdata.content_type\n      form_parts.each do |key, value|\n        multipart_form_part(formdata, key, value)\n      end\n      file_parts.each do |key, value|\n        multipart_file_part(formdata, key, value)\n      end\n    end\n    build_request(method: \"POST\", body: form_io.to_s, content_type: content_type)\n  end\n\n  private def multipart_form_part(formdata : HTTP::FormData::Builder, name : String, value : String)\n    formdata.field(name, value)\n  end\n\n  private def multipart_form_part(formdata : HTTP::FormData::Builder, name : String, value : Hash(String, String))\n    value.each do |key, nested_value|\n      nested_name = name + \":\" + key\n      multipart_form_part(formdata, nested_name, nested_value)\n    end\n  end\n\n  private def multipart_form_part(formdata : HTTP::FormData::Builder, name : String, value : Array(Hash(String, String)))\n    value.each_with_index do |nested_part, index|\n      nested_part.each do |nested_key, nested_value|\n        nested_name = \"#{name}[#{index}]:#{nested_key}\"\n        multipart_form_part(formdata, nested_name, nested_value)\n      end\n    end\n  end\n\n  private def multipart_form_part(formdata : HTTP::FormData::Builder, name : String, value : Array(String))\n    value.each do |val|\n      formdata.field(name + \"[]\", val)\n    end\n  end\n\n  private def multipart_file_part(formdata : HTTP::FormData::Builder, name : String, value : String)\n    file_io = IO::Memory.new(value)\n    metadata = HTTP::FormData::FileMetadata.new(filename: name)\n    headers = HTTP::Headers{\"Content-Type\" => \"text/plain\"}\n    formdata.file(name, file_io, metadata, headers)\n  end\n\n  private def multipart_file_part(formdata : HTTP::FormData::Builder, name : String, value : Hash(String, String))\n    value.each do |key, nested_value|\n      nested_name = name + \":\" + key\n      multipart_file_part(formdata, nested_name, nested_value)\n    end\n  end\n\n  private def multipart_file_part(formdata : HTTP::FormData::Builder, name : String, value : Array(String))\n    value.each do |val|\n      multipart_file_part(formdata, name + \"[]\", val)\n    end\n  end\nend\n"
  },
  {
    "path": "spec/support/raw_log_formatter.cr",
    "content": "struct RawLogFormatter < Dexter::BaseFormatter\n  def call : Nil\n    io << data\n  end\nend\n"
  },
  {
    "path": "spec/support/routes_helper.cr",
    "content": "module RoutesHelper\n  private def assert_route_added?(method, path, expected_action)\n    result = Lucky.router.find_action(method, path)\n    result.should_not be_nil\n    result.as(LuckyRouter::Match).payload.should eq expected_action\n  end\n\n  private def assert_route_not_added?(method, path)\n    result = Lucky.router.find_action(method, path)\n    result.should be_nil\n  end\nend\n"
  },
  {
    "path": "spec/support/test_action.cr",
    "content": "abstract class TestAction < Lucky::Action\n  include Lucky::EnforceUnderscoredRoute\n  accepted_formats [:html], default: :html\nend\n"
  },
  {
    "path": "spec/support/test_fallback_action.cr",
    "content": "# This file depends on spec/test_action.cr being available first, so the name matters.\n# If the name changes you may need to require ./spec/test_action.cr\nclass TestFallbackAction::Index < TestAction\n  fallback do\n    plain_text \"You found me\"\n  end\nend\n"
  },
  {
    "path": "spec/support/test_server.cr",
    "content": "class TestServer < Lucky::BaseAppServer\n  class_setter last_request : HTTP::Request?\n\n  def self.last_request : HTTP::Request\n    @@last_request.as(HTTP::Request)\n  end\n\n  def middleware : Array(HTTP::Handler)\n    [\n      LastRequestHandler.new,\n      Lucky::RouteHandler.new,\n    ] of HTTP::Handler\n  end\n\n  def listen\n    raise \"unimplemented\"\n  end\n\n  def last_request : HTTP::Request\n    self.class.last_request.as(HTTP::Request)\n  end\n\n  class LastRequestHandler\n    include HTTP::Handler\n\n    def call(context)\n      TestServer.last_request = context.request\n      call_next(context)\n    end\n  end\nend\n"
  },
  {
    "path": "spec/tasks/exec_spec.cr",
    "content": "require \"../spec_helper\"\n\ndescribe Lucky::Exec do\n  {% if flag?(:win32) %}\n    pending \"Cry on Windows needs updating\"\n  {% else %}\n    it \"runs the editor\" do\n      with_test_template do\n        Lucky::Exec.new.print_help_or_call(args: [\"--once\", \"--editor\", %(echo '5 + 5' >)])\n\n        newest_code.should eq <<-CODE\n      5 + 5\n\n      CODE\n      end\n    end\n  {% end %}\nend\n\nprivate def with_test_template(&)\n  Lucky::Exec.temp_config(template_path: Path[\"spec/support/exec_template.cr.template\"].normalize.to_s) do\n    yield\n  end\nend\n\nprivate def newest_code\n  Cry::Logs.new.newest.code\nend\n"
  },
  {
    "path": "spec/tasks/gen/action_spec.cr",
    "content": "require \"../../spec_helper\"\n\ninclude CleanupHelper\ninclude GeneratorHelper\n\ndescribe Gen::Action do\n  it \"generates a basic browser action\" do\n    with_cleanup do\n      valid_action_name = \"Users::Index\"\n      io = generate Gen::Action::Browser, args: [valid_action_name]\n\n      filename = \"./src/actions/users/index.cr\"\n      should_have_generated \"#{valid_action_name} < BrowserAction\", inside: filename\n\n      io.to_s.should contain(valid_action_name)\n      io.to_s.should contain(Path[\"src/actions/users\"].normalize.to_s)\n    end\n  end\n\n  describe \"with_page\" do\n    it \"generates the action and a page for Browser action\" do\n      with_cleanup do\n        valid_action_name = \"Users::Index\"\n        io = generate Gen::Action::Browser, args: [valid_action_name, \"--with-page\"]\n\n        action_filename = \"./src/actions/users/index.cr\"\n        page_filename = \"./src/pages/users/index_page.cr\"\n        should_have_generated \"#{valid_action_name} < BrowserAction\", inside: action_filename\n        should_have_generated \"#{valid_action_name}Page < MainLayout\", inside: page_filename\n\n        io.to_s.should contain(valid_action_name)\n        io.to_s.should contain(Path[\"src/actions/users\"].normalize.to_s)\n        io.to_s.should contain(Path[\"src/pages/users\"].normalize.to_s)\n      end\n    end\n\n    it \"does not generate a page for Api action\" do\n      with_cleanup do\n        valid_action_name = \"Users::Index\"\n        io = generate Gen::Action::Api, args: [valid_action_name, \"--with-page\"]\n\n        filename = \"./src/actions/api/users/index.cr\"\n        should_have_generated \"#{valid_action_name} < ApiAction\", inside: filename\n\n        io.to_s.should contain(valid_action_name)\n        io.to_s.should contain(Path[\"src/actions/api/users\"].normalize.to_s)\n        io.to_s.should contain(\"No page generated for ApiActions\")\n      end\n    end\n  end\n\n  it \"generates a basic api action\" do\n    with_cleanup do\n      valid_action_name = \"Users::Index\"\n      io = generate Gen::Action::Api, args: [valid_action_name]\n\n      filename = \"./src/actions/api/users/index.cr\"\n      should_have_generated \"#{valid_action_name} < ApiAction\", inside: filename\n\n      io.to_s.should contain(valid_action_name)\n      io.to_s.should contain(Path[\"src/actions/api/users\"].normalize.to_s)\n    end\n  end\n\n  it \"generates nested browser and api actions\" do\n    with_cleanup do\n      valid_nested_action_name = \"Users::Announcements::Index\"\n      io = generate Gen::Action::Browser, args: [valid_nested_action_name]\n\n      filename = \"src/actions/users/announcements/index.cr\"\n      should_have_generated \"#{valid_nested_action_name} < BrowserAction\", inside: filename\n      should_have_generated %(get \"/users/announcements\"), inside: filename\n\n      io.to_s.should contain(valid_nested_action_name)\n      io.to_s.should contain(Path[\"src/actions/users/announcements\"].normalize.to_s)\n    end\n\n    with_cleanup do\n      valid_nested_action_name = \"Users::Announcements::Index\"\n      io = generate Gen::Action::Api, args: [valid_nested_action_name]\n\n      filename = \"src/actions/api/users/announcements/index.cr\"\n      should_have_generated \"#{valid_nested_action_name} < ApiAction\", inside: filename\n\n      io.to_s.should contain(valid_nested_action_name)\n      io.to_s.should contain(Path[\"src/actions/api/users/announcements\"].normalize.to_s)\n    end\n  end\n\n  it \"fails if called with non-resourceful action name\" do\n    io = generate Gen::Action::Browser, args: [\"Users::HostedEvents\"]\n\n    io.to_s.should contain \"Could not infer route for Users::HostedEvents\"\n  end\n\n  it \"raises an error if given no arguments\" do\n    expect_raises(Exception, /action_name is required/) do\n      generate Gen::Action::Browser\n    end\n  end\n\n  it \"displays an error if given only one class\" do\n    with_cleanup do\n      io = generate Gen::Action::Browser, args: [\"Users\"]\n\n      io.to_s.should contain(\"That's not a valid Action.\")\n    end\n  end\n\n  it \"generates the correct template\" do\n    template = Lucky::ActionTemplate.new(\n      name: \"User::Index\",\n      action: \"index\",\n      inherit_from: \"BrowserAction\",\n      route: %(get \"/users\")\n    )\n    folder = template.template_folder\n    LuckyTemplate.snapshot(folder).has_key?(\"src/actions/user/index.cr\").should eq(true)\n  end\nend\n"
  },
  {
    "path": "spec/tasks/gen/component_spec.cr",
    "content": "require \"../../spec_helper\"\n\ninclude CleanupHelper\ninclude GeneratorHelper\n\ndescribe Gen::Component do\n  it \"generates a component\" do\n    with_cleanup do\n      valid_name = \"Users::Row\"\n\n      task = Gen::Component.new\n      task.output = IO::Memory.new\n      task.print_help_or_call(args: [valid_name])\n\n      should_create_files_with_contents task.output,\n        \"./src/components/users/row.cr\": valid_name\n    end\n  end\n\n  it \"generates a root component\" do\n    with_cleanup do\n      valid_name = \"Root\"\n\n      task = Gen::Component.new\n      task.output = IO::Memory.new\n      task.print_help_or_call(args: [valid_name])\n\n      should_create_files_with_contents task.output,\n        \"./src/components/root.cr\": valid_name\n    end\n  end\n\n  it \"displays an error if given no arguments\" do\n    task = Gen::Component.new\n    task.output = IO::Memory.new\n    task.print_help_or_call(args: [] of String)\n\n    task.output.to_s.should contain(\"Component name is required.\")\n  end\n\n  it \"displays an error if not given a class\" do\n    with_cleanup do\n      task = Gen::Component.new\n      task.output = IO::Memory.new\n      task.print_help_or_call(args: [\"mycomponent\"])\n\n      task.output.to_s.should contain(\"Component name should be camel case\")\n    end\n  end\nend\n"
  },
  {
    "path": "spec/tasks/gen/page_spec.cr",
    "content": "require \"../../spec_helper\"\n\ninclude CleanupHelper\ninclude GeneratorHelper\n\ndescribe Gen::Page do\n  it \"generates a page\" do\n    with_cleanup do\n      valid_page_name = \"Users::IndexPage\"\n      io = generate Gen::Page, args: [valid_page_name]\n\n      should_create_files_with_contents io,\n        \"./src/pages/users/index_page.cr\": valid_page_name\n    end\n  end\n\n  it \"generates a root page\" do\n    with_cleanup do\n      valid_page_name = \"::IndexPage\"\n      io = generate Gen::Page, args: [valid_page_name]\n\n      should_create_files_with_contents io,\n        \"./src/pages/index_page.cr\": valid_page_name\n    end\n  end\n\n  it \"displays an error if given no arguments\" do\n    expect_raises(Exception, /page_class is required/) do\n      generate Gen::Page\n    end\n  end\n\n  it \"displays an error if given only one class\" do\n    with_cleanup do\n      invalid_page_name = \"Users\"\n      io = generate Gen::Page, args: [invalid_page_name]\n\n      io.to_s.should contain(\"That's not a valid Page.\")\n    end\n  end\n\n  it \"displays an error if missing ending 'Page'\" do\n    with_cleanup do\n      invalid_page_name = \"Users::Index\"\n      io = generate Gen::Page, args: [invalid_page_name]\n\n      io.to_s.should contain(\"That's not a valid Page.\")\n    end\n  end\nend\n"
  },
  {
    "path": "spec/tasks/gen/secret_key_base_spec.cr",
    "content": "require \"../../spec_helper\"\n\ndescribe Gen::SecretKey do\n  it \"outputs a new secret key base\" do\n    task = Gen::SecretKey.new\n    task.output = IO::Memory.new\n    task.print_help_or_call(args: [] of String)\n\n    (task.output.to_s.size >= 32).should be_true\n  end\n\n  it \"outputs a larger size when configured\" do\n    task = Gen::SecretKey.new\n    task.output = IO::Memory.new\n    task.print_help_or_call(args: [\"-n 64\"])\n\n    (task.output.to_s.size >= 64).should be_true\n  end\nend\n"
  },
  {
    "path": "spec/tasks/gen/task_spec.cr",
    "content": "require \"../../spec_helper\"\n\ninclude CleanupHelper\ninclude GeneratorHelper\n\nprivate def run_with_args(args : Array(String))\n  generator = Gen::Task.new\n  generator.output = IO::Memory.new\n  generator.print_help_or_call args\n  generator.output\nend\n\ndescribe Gen::Task do\n  it \"generates a task\" do\n    with_cleanup do\n      output = run_with_args [\"search.reindex\"]\n      output.to_s.should contain(Path[\"./tasks/search/reindex.cr\"].normalize.to_s)\n      should_create_files_with_contents output,\n        \"./tasks/search/reindex.cr\": \"Search::Reindex\"\n    end\n  end\n\n  it \"displays an error if no name is provided\" do\n    expect_raises Exception, /task_name is required/ do\n      run_with_args [] of String\n    end\n  end\n\n  it \"displays an error if the name is formatted as a class\" do\n    run_with_args([\"GenericTask\"]).to_s.should contain(\"needs to be formatted with dot notation\")\n    run_with_args([\"genericTask\"]).to_s.should contain(\"needs to be formatted with dot notation\")\n  end\n\n  it \"uses a default summary if none is provided\" do\n    with_cleanup do\n      output = run_with_args [\"generic_task\"]\n      should_create_files_with_contents output,\n        \"./tasks/generic_task.cr\": \"summary \\\"generic task\\\"\"\n    end\n  end\n\n  it \"uses the provided summary\" do\n    with_cleanup do\n      output = run_with_args [\"generic_task\", \"--task-summary\", \"this is the summary\"]\n      should_create_files_with_contents output,\n        \"./tasks/generic_task.cr\": \"summary \\\"this is the summary\\\"\"\n    end\n  end\nend\n"
  },
  {
    "path": "spec/tasks/routes_spec.cr",
    "content": "require \"../spec_helper\"\n\n# Test action with params for routes JSON output\nprivate class RoutesTaskTestAction < TestAction\n  param page : Int32?\n  param search : String?\n\n  get \"/routes_task_test\" do\n    plain_text \"test\"\n  end\nend\n\ndescribe Routes do\n  describe \"Format JSON\" do\n    it \"outputs routes as JSON\" do\n      task = Routes.new\n      task.output = IO::Memory.new\n      task.print_help_or_call(args: [\"--format=json\"])\n\n      output = task.output.to_s\n      json = JSON.parse(output)\n\n      json.should be_a(JSON::Any)\n      json.as_a.should_not be_empty\n    end\n\n    it \"includes method, path, action, and params in JSON output\" do\n      task = Routes.new\n      task.output = IO::Memory.new\n      task.print_help_or_call(args: [\"--with-params\", \"-f\", \"json\"])\n\n      output = task.output.to_s\n      json = JSON.parse(output)\n      routes = json.as_a\n\n      # Find our test action in the routes\n      test_route = routes.find! { |route| route[\"action\"].as_s == \"RoutesTaskTestAction\" }\n\n      test_route[\"method\"].as_s.should eq \"GET\"\n      test_route[\"path\"].as_s.should eq \"/routes_task_test\"\n      test_route[\"action\"].as_s.should eq \"RoutesTaskTestAction\"\n\n      params = test_route[\"params\"].as_a\n      params.size.should eq 2\n\n      page_param = params.find! { |param| param[\"name\"].as_s == \"page\" }\n      page_param[\"type\"].as_s.should contain \"Int32\"\n\n      search_param = params.find! { |param| param[\"name\"].as_s == \"search\" }\n      search_param[\"type\"].as_s.should contain \"String\"\n    end\n\n    it \"excludes HEAD routes from JSON output\" do\n      task = Routes.new\n      task.output = IO::Memory.new\n      task.print_help_or_call(args: [\"--format=json\"])\n\n      output = task.output.to_s\n      json = JSON.parse(output)\n      routes = json.as_a\n\n      head_routes = routes.select { |route| route[\"method\"].as_s == \"HEAD\" }\n      head_routes.should be_empty\n    end\n  end\n\n  describe \"default table output\" do\n    it \"still works without format flag\" do\n      task = Routes.new\n      task.output = IO::Memory.new\n      task.print_help_or_call(args: [] of String)\n\n      output = task.output.to_s\n      output.should contain \"Verb\"\n      output.should contain \"URI\"\n      output.should contain \"Action\"\n    end\n  end\nend\n"
  },
  {
    "path": "src/bun/bake.js",
    "content": "import LuckyBun from './lucky.js'\n\nLuckyBun.flags({\n  debug: process.argv.includes('--debug'),\n  dev: process.argv.includes('--dev'),\n  prod: process.argv.includes('--prod')\n})\n\nawait LuckyBun.bake()\n"
  },
  {
    "path": "src/bun/config.cr",
    "content": "require \"json\"\n\nmodule LuckyBun\n  struct Config\n    include JSON::Serializable\n\n    CONFIG_PATH = \"./config/bun.json\"\n\n    @[JSON::Field(key: \"manifestPath\")]\n    getter manifest_path : String = \"public/bun-manifest.json\"\n\n    @[JSON::Field(key: \"outDir\")]\n    getter out_dir : String = \"public/assets\"\n\n    @[JSON::Field(key: \"publicPath\")]\n    getter public_path : String = \"/assets\"\n\n    @[JSON::Field(key: \"staticDirs\")]\n    getter static_dirs : Array(String) = %w[src/images src/fonts]\n\n    @[JSON::Field(key: \"entryPoints\")]\n    getter entry_points : EntryPoints = EntryPoints.from_json(\"{}\")\n\n    @[JSON::Field(key: \"devServer\")]\n    getter dev_server : DevServer = DevServer.from_json(\"{}\")\n\n    struct EntryPoints\n      include JSON::Serializable\n\n      @[JSON::Field(converter: LuckyBun::Config::StringOrArray)]\n      getter js : Array(String) = %w[src/js/app.js]\n\n      @[JSON::Field(converter: LuckyBun::Config::StringOrArray)]\n      getter css : Array(String) = %w[src/css/app.css]\n    end\n\n    module StringOrArray\n      def self.from_json(pull : JSON::PullParser) : Array(String)\n        case pull.kind\n        when .string? then [pull.read_string]\n        else               Array(String).new(pull)\n        end\n      end\n\n      def self.to_json(value : Array(String), json : JSON::Builder) : Nil\n        value.to_json(json)\n      end\n    end\n\n    struct DevServer\n      include JSON::Serializable\n\n      getter host : String = \"127.0.0.1\"\n      getter port : Int32 = 3002\n      getter? secure : Bool = false\n\n      def ws_protocol : String\n        secure? ? \"wss\" : \"ws\"\n      end\n\n      def ws_url : String\n        \"#{ws_protocol}://#{host}:#{port}\"\n      end\n    end\n\n    class_getter instance : Config { load }\n\n    def self.load : Config\n      Config.from_json(File.read(File.expand_path(CONFIG_PATH)))\n    rescue File::NotFoundError\n      Config.from_json(\"{}\")\n    end\n  end\nend\n"
  },
  {
    "path": "src/bun/lucky.js",
    "content": "import {mkdirSync, readFileSync, existsSync, rmSync, watch} from 'fs'\nimport {join, dirname, basename, extname} from 'path'\nimport {Glob} from 'bun'\nimport {resolvePlugins} from './plugins/index.js'\n\nexport default {\n  CONFIG_PATH: 'config/bun.json',\n  IGNORE_PATTERNS: [\n    /^\\d+$/,\n    /^\\.#/,\n    /\\.swp$/,\n    /\\.swo$/,\n    /\\.tmp$/,\n    /^#.*#$/,\n    /\\.DS_Store$/\n  ],\n\n  root: process.cwd(),\n  config: null,\n  manifest: {},\n  debug: false,\n  dev: false,\n  prod: false,\n  wsClients: new Set(),\n  watchTimers: new Map(),\n  plugins: [],\n\n  flags({debug, dev, prod}) {\n    if (debug != null) this.debug = debug\n    if (dev != null) this.dev = dev\n    if (prod != null) this.prod = prod\n  },\n\n  deepMerge(target, source) {\n    const result = {...target}\n    for (const k of Object.keys(source))\n      result[k] =\n        source[k] && typeof source[k] === 'object' && !Array.isArray(source[k])\n          ? this.deepMerge(target[k] || {}, source[k])\n          : source[k]\n    return result\n  },\n\n  loadConfig() {\n    const defaults = {\n      entryPoints: {js: ['src/js/app.js'], css: ['src/css/app.css']},\n      plugins: {css: ['aliases', 'cssGlobs'], js: ['aliases', 'jsGlobs']},\n      watchDirs: ['src/js', 'src/css', 'src/images', 'src/fonts'],\n      staticDirs: ['src/images', 'src/fonts'],\n      outDir: 'public/assets',\n      publicPath: '/assets',\n      manifestPath: 'public/bun-manifest.json',\n      devServer: {host: '127.0.0.1', port: 3002, secure: false}\n    }\n\n    try {\n      const json = readFileSync(join(this.root, this.CONFIG_PATH), 'utf-8')\n      const user = JSON.parse(json)\n      this.config = this.deepMerge(defaults, user)\n      if (user.plugins != null) this.config.plugins = user.plugins\n    } catch {\n      this.config = defaults\n    }\n  },\n\n  async loadPlugins() {\n    this.plugins = await resolvePlugins(this.config.plugins, {\n      root: this.root,\n      config: this.config,\n      dev: this.dev,\n      prod: this.prod,\n      manifest: this.manifest\n    })\n  },\n\n  get outDir() {\n    if (this.config == null) throw new Error(' ✖ Config is not loaded')\n\n    return join(this.root, this.config.outDir)\n  },\n\n  fingerprint(name, ext, content) {\n    if (!this.prod) return `${name}${ext}`\n\n    const hash = Bun.hash(content).toString(16).slice(0, 8)\n    return `${name}-${hash}${ext}`\n  },\n\n  async buildAssets(type, options = {}) {\n    const outDir = join(this.outDir, type)\n    mkdirSync(outDir, {recursive: true})\n\n    const raw = this.config.entryPoints[type]\n    const entries = Array.isArray(raw) ? raw : [raw]\n    const ext = `.${type}`\n\n    for (const entry of entries) {\n      const entryPath = join(this.root, entry)\n      const entryName = basename(entry).replace(/\\.(ts|js|tsx|jsx|css)$/, '')\n\n      if (!existsSync(entryPath)) {\n        console.warn(` ▸ Missing entry point ${entry}, continuing...`)\n        continue\n      }\n\n      let result\n      try {\n        result = await Bun.build({\n          entrypoints: [entryPath],\n          minify: this.prod,\n          plugins: this.plugins,\n          ...options\n        })\n      } catch (err) {\n        console.error(` ▸ Failed to build ${entry}`)\n        if (err.errors) for (const e of err.errors) console.error(e)\n        else console.error(err)\n        continue\n      }\n\n      if (!result.success) {\n        console.error(` ▸ Failed to build ${entry}`)\n        for (const log of result.logs) console.error(log)\n        continue\n      }\n\n      const output = result.outputs.find(o => o.path.endsWith(ext))\n      if (!output) {\n        console.error(` ▸ No ${type.toUpperCase()} output for ${entry}`)\n        continue\n      }\n\n      const content = await output.text()\n      const fileName = this.fingerprint(entryName, ext, content)\n      await Bun.write(join(outDir, fileName), content)\n\n      this.manifest[`${type}/${entryName}${ext}`] = `${type}/${fileName}`\n    }\n  },\n\n  async buildJS() {\n    await this.buildAssets('js', {\n      target: 'browser',\n      format: 'iife',\n      sourcemap: this.dev ? 'inline' : 'none'\n    })\n  },\n\n  async buildCSS() {\n    await this.buildAssets('css')\n  },\n\n  async copyStaticAssets() {\n    const glob = new Glob('**/*.*')\n\n    for (const dir of this.config.staticDirs) {\n      const fullDir = join(this.root, dir)\n      if (!existsSync(fullDir)) continue\n\n      const assetType = basename(dir)\n      const destDir = join(this.outDir, assetType)\n\n      for await (const file of glob.scan({cwd: fullDir, onlyFiles: true})) {\n        const srcPath = join(fullDir, file)\n        const content = await Bun.file(srcPath).arrayBuffer()\n\n        const ext = extname(file)\n        const name = file.slice(0, -ext.length) || file\n        const fileName = this.fingerprint(name, ext, new Uint8Array(content))\n        const destPath = join(destDir, fileName)\n\n        mkdirSync(dirname(destPath), {recursive: true})\n        await Bun.write(destPath, content)\n\n        this.manifest[`${assetType}/${file}`] = `${assetType}/${fileName}`\n      }\n    }\n  },\n\n  cleanOutDir() {\n    rmSync(this.outDir, {recursive: true, force: true})\n  },\n\n  async writeManifest() {\n    const manifestFullPath = join(this.root, this.config.manifestPath)\n    mkdirSync(dirname(manifestFullPath), {recursive: true})\n    await Bun.write(manifestFullPath, JSON.stringify(this.manifest, null, 2))\n  },\n\n  async build() {\n    const env = this.prod ? 'production' : 'development'\n    console.log(`Building manifest for ${env}...`)\n    const start = performance.now()\n    this.loadConfig()\n    await this.loadPlugins()\n    this.cleanOutDir()\n    await this.copyStaticAssets()\n    await this.buildJS()\n    await this.buildCSS()\n    await this.writeManifest()\n    const ms = Math.round(performance.now() - start)\n    console.log(`DONE  Built successfully in ${ms} ms`, this.prettyManifest())\n  },\n\n  prettyManifest() {\n    const lines = Object.entries(this.manifest)\n      .map(([key, value]) => `  ${key} → ${value}`)\n      .join('\\n')\n    return `\\n${lines}\\n\\n`\n  },\n\n  reload(type = 'full') {\n    setTimeout(() => {\n      const message = JSON.stringify({type})\n      for (const client of this.wsClients) {\n        try {\n          client.send(message)\n        } catch {\n          this.wsClients.delete(client)\n        }\n      }\n    }, 50)\n  },\n\n  async watch() {\n    const handler = (event, filename) => {\n      if (!filename) return\n\n      let normalizedFilename = filename.replace(/\\\\/g, '/')\n\n      // Vim backup files (e.g. app.css~) signal the original file changed\n      if (normalizedFilename.endsWith('~'))\n        normalizedFilename = normalizedFilename.slice(0, -1)\n\n      const base = basename(normalizedFilename)\n      const ext = extname(base).slice(1)\n\n      if (this.IGNORE_PATTERNS.some(pattern => pattern.test(base))) return\n\n      // Debounce multiple events for the same file (e.g. actual save + backup)\n      if (this.watchTimers.has(normalizedFilename)) return\n      this.watchTimers.set(\n        normalizedFilename,\n        setTimeout(() => {\n          this.watchTimers.delete(normalizedFilename)\n        }, 100)\n      )\n\n      console.log(` ▸ ${normalizedFilename} changed`)\n      ;(async () => {\n        try {\n          if (ext === 'css') await this.buildCSS()\n          else if (['js', 'ts', 'jsx', 'tsx'].includes(ext))\n            await this.buildJS()\n          else if (base.includes('.')) await this.copyStaticAssets()\n\n          await this.writeManifest()\n          this.reload(ext === 'css' ? 'css' : 'full')\n        } catch (err) {\n          console.error(' ✖ Build error:', err.message)\n          if (err.errors) for (const e of err.errors) console.error(e)\n        }\n      })()\n    }\n\n    for (const dir of this.config.watchDirs) {\n      const fullDir = join(this.root, dir)\n      if (!existsSync(fullDir)) {\n        console.warn(` ▸ Watch directory ${dir} does not exist, skipping...`)\n        continue\n      }\n      watch(fullDir, {recursive: true}, handler)\n    }\n\n    console.log('Beginning to watch your project')\n  },\n\n  async serve() {\n    await this.build()\n    await this.watch()\n\n    const {host, listenHost, port, secure} = this.config.devServer\n    const hostname = listenHost || (secure ? '0.0.0.0' : host)\n    const debug = this.debug\n    const wsClients = this.wsClients\n\n    Bun.serve({\n      hostname,\n      port,\n      fetch(req, server) {\n        if (server.upgrade(req)) return\n        return new Response('LuckyBun WebSocket Server', {status: 200})\n      },\n      websocket: {\n        open(ws) {\n          wsClients.add(ws)\n          if (debug) console.log(` ▸ Client connected (${wsClients.size})\\n\\n`)\n        },\n        close(ws) {\n          wsClients.delete(ws)\n          if (debug) console.log(` ▸ Client disconnected (${wsClients.size})\\n\\n`)\n        },\n        message() {}\n      }\n    })\n\n    const protocol = secure ? 'wss' : 'ws'\n    console.log(`\\n\\n    🔌 Live reload at ${protocol}://${host}:${port}\\n\\n`)\n  },\n\n  async bake() {\n    this.dev ? await this.serve() : await this.build()\n  }\n}\n"
  },
  {
    "path": "src/bun/plugins/aliases.js",
    "content": "const REGEX = /(url\\(\\s*['\"]?|(?<!\\w)['\"](?:glob:)?)\\$\\//g\n\n// Resolves `$/` root aliases in CSS url() references and JS/CSS imports.\n// e.g. url('$/src/images/foo.png') → url('/absolute/root/src/images/foo.png')\n//      import x from '$/lib/utils.js' → import x from '/absolute/root/lib/utils.js'\n//      @import '$/src/css/reset.css' → @import '/absolute/root/src/css/reset.css'\nexport default function aliases({root}) {\n  return content => content.replace(REGEX, `$1${root}/`)\n}\n"
  },
  {
    "path": "src/bun/plugins/cssGlobs.js",
    "content": "import {dirname, relative, resolve, join} from 'path'\nimport {Glob} from 'bun'\n\nconst REGEX = /@import\\s+['\"]([^'\"]*\\*[^'\"]*)['\"]\\s*;/g\n\n// Expands glob patterns in CSS @import statements.\n// e.g. @import './components/**/*.css' → individual @import lines.\nexport default function cssGlobs() {\n  return async (content, args) => {\n    const fileDir = dirname(args.path)\n    const replacements = []\n\n    for (const [fullMatch, pattern] of content.matchAll(REGEX)) {\n      const lastSlash = pattern.lastIndexOf('/', pattern.indexOf('*'))\n      const basePath = lastSlash > 0 ? pattern.slice(0, lastSlash) : '.'\n      const baseDir = resolve(fileDir, basePath)\n      const glob = new Glob(pattern.slice(lastSlash + 1))\n      const files = []\n\n      for await (const file of glob.scan({cwd: baseDir, onlyFiles: true})) {\n        const absPath = join(baseDir, file)\n        if (absPath === args.path) continue\n        const relPath = relative(fileDir, absPath)\n        files.push(relPath.startsWith('.') ? relPath : `./${relPath}`)\n      }\n\n      files.sort()\n\n      if (!files.length) console.warn(`  CSS glob matched no files: ${pattern}`)\n\n      replacements.push({\n        fullMatch,\n        expanded: files.map(f => `@import '${f}';`).join('\\n'),\n        count: files.length,\n        pattern\n      })\n    }\n\n    for (const {fullMatch, expanded, count, pattern} of replacements) {\n      const s = count !== 1 ? 's' : ''\n      console.log(`  CSS glob: ${pattern} → ${count} file${s}`)\n      content = content.replace(fullMatch, expanded)\n    }\n\n    return content\n  }\n}\n"
  },
  {
    "path": "src/bun/plugins/index.js",
    "content": "import {join} from 'path'\nimport aliases from './aliases.js'\nimport cssGlobs from './cssGlobs.js'\nimport jsGlobs from './jsGlobs.js'\n\nconst builtins = {aliases, cssGlobs, jsGlobs}\nconst TYPE_REGEXES = {\n  css: /\\.css$/,\n  js: /\\.(js|ts|jsx|tsx)$/\n}\n\n// Combines transform functions into a single Bun plugin for a given file type.\nfunction transformPipeline(type, transforms) {\n  const filter = TYPE_REGEXES[type]\n\n  return {\n    name: `${type}-transforms`,\n    setup(build) {\n      build.onLoad({filter}, async args => {\n        let content = await Bun.file(args.path).text()\n        for (const transform of transforms)\n          content = await transform(content, args)\n        const ext = args.path.split('.').pop()\n        return {contents: content, loader: ext}\n      })\n    }\n  }\n}\n\n// Resolves a plugin name or path into a transform function.\nasync function loadFactory(name, root) {\n  if (builtins[name]) return builtins[name]\n\n  try {\n    const mod = await import(join(root, name))\n    console.log(` ▸ Loaded custom plugin: ${name}`)\n    return mod.default || mod\n  } catch (err) {\n    console.error(` ✖ Failed to load plugin \"${name}\": ${err.message}`)\n  }\n}\n\n// Resolves plugin names into transforms for a single type.\nasync function resolveType(names, context) {\n  const transforms = []\n  const plugins = []\n\n  for (const name of names) {\n    const factory = await loadFactory(name, context.root)\n    if (typeof factory !== 'function') {\n      if (factory != null)\n        console.error(` ✖ Plugin \"${name}\" does not export a function`)\n      continue\n    }\n\n    const result = factory(context)\n    if (typeof result === 'function') transforms.push(result)\n    else if (result?.setup) plugins.push(result)\n    else console.error(` ✖ Plugin \"${name}\" returned an invalid value`)\n  }\n\n  return {transforms, plugins}\n}\n\n// Resolves plugin config into Bun plugin instances.\nexport async function resolvePlugins(pluginConfig, context) {\n  const bunPlugins = []\n\n  if (!pluginConfig || typeof pluginConfig !== 'object') return bunPlugins\n\n  for (const [type, names] of Object.entries(pluginConfig)) {\n    if (!Array.isArray(names)) continue\n\n    if (!TYPE_REGEXES[type]) {\n      console.error(` ✖ Unknown plugin type \"${type}\"`)\n      continue\n    }\n\n    const {transforms, plugins} = await resolveType(names, context)\n    bunPlugins.push(...plugins)\n    if (transforms.length)\n      bunPlugins.unshift(transformPipeline(type, transforms))\n  }\n\n  return bunPlugins\n}\n"
  },
  {
    "path": "src/bun/plugins/jsGlobs.js",
    "content": "import {dirname, extname} from 'path'\nimport {Glob} from 'bun'\n\nconst REGEX = /import\\s+(\\w+)\\s+from\\s+['\"]glob:([^'\"]+)['\"]/g\n\n// Compiles an object with a file path => default export mapping from a glob\n// pattern in JS import statements.\n// e.g. import components from 'glob:./components/**/*.js'\n//\n// ... will generate ...\n//\n// import _glob_components_theme from './components/theme.js'\n// import _glob_components_shared_tooltip from './components/shared/tooltip.js'\n// const components = {\n//   'theme': _glob_components_theme,\n//   'shared/tooltip': _glob_components_shared_tooltip\n// }\nexport default function jsGlobs() {\n  return (content, args) => {\n    return content.replace(REGEX, (_, binding, pattern) => {\n      const dir = dirname(args.path)\n      const cleanPattern = pattern.replace(/^\\.\\//, '')\n      const baseDir = cleanPattern.slice(0, cleanPattern.search(/[*?{[]|$/))\n        .replace(/\\/$/, '')\n      const glob = new Glob(cleanPattern)\n      const files = Array.from(glob.scanSync({cwd: dir})).sort()\n\n      if (!files.length) return `const ${binding} = {}`\n\n      const imports = []\n      const entries = []\n\n      for (const file of files) {\n        const ext = extname(file)\n        const relative = baseDir ? file.slice(baseDir.length + 1) : file\n        const key = relative.slice(0, -ext.length)\n        const prefix = baseDir ? `${baseDir}_` : ''\n        const safe = `_glob_${prefix}${key}`.replace(/[^a-zA-Z0-9]/g, '_')\n        imports.push(`import ${safe} from './${file}'`)\n        entries.push(`  '${key}': ${safe}`)\n      }\n\n      return [\n        ...imports,\n        `const ${binding} = {`,\n        entries.join(',\\n'),\n        '}'\n      ].join('\\n')\n    })\n  }\n}\n"
  },
  {
    "path": "src/charms/bool_extensions.cr",
    "content": "require \"../lucky/allowed_in_tags\"\n\nstruct Bool\n  include ::Lucky::AllowedInTags\nend\n"
  },
  {
    "path": "src/charms/cookie.cr",
    "content": "class HTTP::Cookie\n  def name(value : String) : HTTP::Cookie\n    self.name = value\n    self\n  end\n\n  def value(string : String) : HTTP::Cookie\n    self.value = string\n    self\n  end\n\n  def path(value : String) : HTTP::Cookie\n    self.path = value\n    self\n  end\n\n  def expires(value : Time) : HTTP::Cookie\n    self.expires = value\n    self\n  end\n\n  def domain(value : String) : HTTP::Cookie\n    self.domain = value\n    self\n  end\n\n  def permanent : HTTP::Cookie\n    expires(20.years.from_now)\n  end\n\n  def secure(value : Bool) : HTTP::Cookie\n    self.secure = value\n    self\n  end\n\n  def http_only(value : Bool) : HTTP::Cookie\n    self.http_only = value\n    self\n  end\n\n  def samesite(value : HTTP::Cookie::SameSite) : HTTP::Cookie\n    self.samesite = value\n    self\n  end\nend\n"
  },
  {
    "path": "src/charms/hash_extensions.cr",
    "content": "class Hash(K, V)\n  # Return the **nilable** value of a hash key\n  #\n  # This returns a value stored in a hash. The key can be specified as a String\n  # or Symbol. Internally this works by converting Symbols to Strings. See the\n  # code below for an example. It returns `nil` if the value doesn't exist:\n  #\n  # ```\n  # hash = {\"name\" => \"Karin\"}\n  # hash.get(:name)  # => \"Karin\" : (String | Nil)\n  # hash.get(\"name\") # => \"Karin\" : (String | Nil)\n  # hash.get(:asdf)  # => nil : (String | Nil)\n  # ```\n  def get(key : String | Symbol) : V?\n    self[key.to_s]?\n  end\n\n  # Return the value of a hash key\n  #\n  # This returns a value stored in a hash. The key can be specified as a String\n  # or Symbol. Internally this works by converting Symbols to Strings. See the\n  # code below for an example. It throws a `KeyError` if the value doesn't\n  # exist:\n  #\n  # ```\n  # hash = {\"name\" => \"Karin\"}\n  # hash.get(:name)  # => \"Karin\" : String\n  # hash.get(\"name\") # => \"Karin\" : String\n  # hash.get(:asdf)  # => KeyError\n  # ```\n  def get!(key : String | Symbol) : V\n    self[key.to_s]\n  end\nend\n"
  },
  {
    "path": "src/charms/int16_extensions.cr",
    "content": "require \"../lucky/allowed_in_tags\"\n\nstruct Int16\n  include ::Lucky::AllowedInTags\n\n  def to_param : String\n    self.to_s\n  end\nend\n"
  },
  {
    "path": "src/charms/int32_extensions.cr",
    "content": "require \"../lucky/allowed_in_tags\"\n\nstruct Int32\n  include ::Lucky::AllowedInTags\n\n  def to_param : String\n    self.to_s\n  end\nend\n"
  },
  {
    "path": "src/charms/int64_extensions.cr",
    "content": "require \"../lucky/allowed_in_tags\"\n\nstruct Int64\n  include ::Lucky::AllowedInTags\n\n  def to_param : String\n    self.to_s\n  end\nend\n"
  },
  {
    "path": "src/charms/object.cr",
    "content": "class Object\n  include ::Lucky::QuickDef\n  include ::Lucky::Memoizable\n\n  def blank? : Bool\n    if self.responds_to?(:empty?)\n      self.empty?\n    else\n      false\n    end\n  end\n\n  def present? : Bool\n    !blank?\n  end\nend\n\nstruct Char\n  def blank? : Bool\n    case ord\n    when 9, 0xa, 0xb, 0xc, 0xd, 0x20, 0x85, 0xa0, 0x1680, 0x180e,\n         0x2000, 0x2001, 0x2002, 0x2003, 0x2004, 0x2005, 0x2006,\n         0x2007, 0x2008, 0x2009, 0x200a, 0x2028, 0x2029, 0x202f,\n         0x205f, 0x3000\n      true\n    else\n      false\n    end\n  end\nend\n\nstruct Nil\n  def blank? : Bool\n    true\n  end\nend\n"
  },
  {
    "path": "src/charms/request_extensions.cr",
    "content": "class HTTP::Request\n  # This is an alternative to `remote_address`\n  # since that casts to `Socket::Address`, and not all\n  # subclasses have an `address` method to give you the value.\n  # ```\n  # request.remote_address.as?(Socket::IPAddress).try(&.address)\n  # ```\n  property remote_ip : String = \"\"\nend\n"
  },
  {
    "path": "src/charms/static_file_handler.cr",
    "content": "require \"http/server\"\n\nclass Lucky::StaticFileHandler < HTTP::StaticFileHandler\n  def call(context : HTTP::Server::Context)\n    super(context)\n  end\nend\n"
  },
  {
    "path": "src/charms/string_extensions.cr",
    "content": "class String\n  def to_param : String\n    self\n  end\n\n  # Returns a new string with whitespace and newlines squish to a single space.\n  #\n  # `String#squish` strips whitespace at the end of the string, and changes\n  # consecutive whitespace groups into one space each. For example, it will\n  # replace newlines with a single space and convert multiple spaces to just one\n  # space.\n  def squish : String\n    if ascii_only?\n      squish_ascii\n    else\n      squish_unicode\n    end\n  end\n\n  # Optimized for ASCII using String#ascii_whitespace?\n  private def squish_ascii : String\n    String.build(size) do |str|\n      print_blank = false\n      each_char do |chr|\n        if chr.ascii_whitespace?\n          if print_blank\n            str << ' '\n            print_blank = false\n          end\n        else\n          print_blank = true\n          str << chr\n        end\n      end\n    end.strip\n  end\n\n  private def squish_unicode : String\n    String.build(size) do |str|\n      print_blank = false\n      each_char do |chr|\n        if chr.whitespace?\n          if print_blank\n            str << ' '\n            print_blank = false\n          end\n        else\n          print_blank = true\n          str << chr\n        end\n      end\n    end.strip\n  end\nend\n"
  },
  {
    "path": "src/charms/uuid_extensions.cr",
    "content": "struct UUID\n  include ::Lucky::AllowedInTags\n\n  def to_param : String\n    self.to_s\n  end\nend\n"
  },
  {
    "path": "src/lucky/action.cr",
    "content": "require \"./*\"\n\nabstract class Lucky::Action\n  getter context : HTTP::Server::Context\n  getter route_params : Hash(String, String)\n\n  def initialize(@context : HTTP::Server::Context, @route_params : Hash(String, String))\n    context.params.route_params = @route_params\n  end\n\n  abstract def call\n\n  include Lucky::ActionDelegates\n  include Lucky::RequestTypeHelpers\n  include Lucky::Exposable\n  include Lucky::Routable\n  include Lucky::Renderable\n  include Lucky::ParamHelpers\n  include Lucky::ActionPipes\n  include Lucky::RequestBodyLimit\n  include Lucky::Redirectable\n  include Lucky::VerifyAcceptsFormat\nend\n"
  },
  {
    "path": "src/lucky/action_delegates.cr",
    "content": "# :nodoc:\nmodule Lucky::ActionDelegates\n  macro included\n    delegate flash, cookies, session, response, request, to: context\n  end\nend\n"
  },
  {
    "path": "src/lucky/action_pipes.cr",
    "content": "module Lucky::ActionPipes\n  # :nodoc:\n  class Continue\n  end\n\n  # Skips before or after pipes\n  #\n  # ```\n  # skip require_sign_in, require_organization\n  # ```\n  macro skip(*pipes)\n    {% for pipe in pipes %}\n      {% if BEFORE_PIPES.includes?(pipe.id) || AFTER_PIPES.includes?(pipe.id) %}\n        {% SKIPPED_PIPES << pipe.id %}\n      {% else %}\n        {% pipe.raise <<-ERROR.lines.join(\" \")\n        Can't skip '#{pipe}' because the pipe is not used.\n        Check the spelling of the pipe that you are trying to skip.\n        ERROR\n        %}\n      {% end %}\n    {% end %}\n  end\n\n  # :nodoc:\n  macro included\n    AFTER_PIPES   = [] of Symbol\n    BEFORE_PIPES  = [] of Symbol\n    SKIPPED_PIPES = [] of Symbol\n\n    macro inherited\n      AFTER_PIPES   = [] of Symbol\n      BEFORE_PIPES  = [] of Symbol\n      SKIPPED_PIPES = [] of Symbol\n\n      inherit_pipes\n    end\n  end\n\n  # :nodoc:\n  macro inherit_pipes\n    \\{% for v in @type.ancestors.first.constant :BEFORE_PIPES %}\n      \\{% BEFORE_PIPES << v %}\n    \\{% end %}\n\n    \\{% for v in @type.ancestors.first.constant :AFTER_PIPES %}\n      \\{% AFTER_PIPES << v %}\n    \\{% end %}\n\n    \\{% for v in @type.ancestors.first.constant :SKIPPED_PIPES %}\n      \\{% SKIPPED_PIPES << v %}\n    \\{% end %}\n  end\n\n  # Run a method before an action is called\n  #\n  # Methods will run in the order that each `before` is defined. Also, each\n  # method must return a `Lucky::Response` like `redirect`, `html`, `json`,\n  # etc, or call `continue`:\n  #\n  # ```\n  # class Users::Destroy < BrowserAction\n  #   before check_if_signed_in\n  #   before confirm_destroy\n  #\n  #   delete \"/:user_id\" do\n  #     # destroy the user :(\n  #   end\n  #\n  #   def check_if_signed_in\n  #     if current_user.nil?\n  #       redirect to: SignInPage\n  #     else\n  #       continue\n  #     end\n  #   end\n  #\n  #   def confirm_destroy\n  #     # confirm that the user should be destroyed\n  #     continue\n  #   end\n  # end\n  # ```\n  macro before(method_name)\n    {% BEFORE_PIPES << method_name.id %}\n  end\n\n  # Run a method after an action ends\n  #\n  # `after` isn't as common as `before` but can still be useful. One example\n  # would be to log a successful transaction to analytics. Methods will run in\n  # the order that each `after` is defined. Also, each method must return\n  # either a `Lucky::Response` like `redirect`, `html`, `json`, etc, or call\n  # `continue`:\n  #\n  # ```\n  # class Purchases::Create < BrowserAction\n  #   after log_transaction\n  #\n  #   post \"/purchases\" do\n  #     # purchase the product\n  #   end\n  #\n  #   def log_transaction\n  #     # send the purchase to analytics\n  #     continue\n  #   end\n  # end\n  # ```\n  macro after(method_name)\n    {% AFTER_PIPES << method_name.id %}\n  end\n\n  # :nodoc:\n  macro run_before_pipes\n    {% pipes = BEFORE_PIPES.reject { |pipe| SKIPPED_PIPES.includes?(pipe) } %}\n\n    {% for pipe_method in pipes %}\n      pipe_result = {{ pipe_method }}\n      ensure_pipe_return_response_or_continue(pipe_result)\n      # Pipe {{ pipe_method }} should return a Lucky::Response or Lucky::ActionPipes::Continue\n      # Do this by using `continue` or one of rendering methods like `html` or `redirect`\n      #\n      #   def {{ pipe_method }}\n      #     cookies[\"name\"] = \"John\"\n      #     continue # or redirect, render\n      #   end\n\n      if pipe_result.is_a?(Lucky::Response)\n        publish_before_event(\"{{ pipe_method.id }}\", continued: false)\n        Lucky::ActionPipes.log_halted_pipe(\"{{ pipe_method.id }}\")\n        return pipe_result\n      else\n        publish_before_event(\"{{ pipe_method.id }}\", continued: true)\n        Lucky::ActionPipes.log_continued_pipe(\"{{ pipe_method.id }}\")\n      end\n    {% end %}\n  end\n\n  # :nodoc:\n  macro run_after_pipes\n    {% pipes = AFTER_PIPES.reject { |pipe| SKIPPED_PIPES.includes?(pipe) } %}\n\n    {% for pipe_method in pipes %}\n      pipe_result = {{ pipe_method }}\n\n      ensure_pipe_return_response_or_continue(pipe_result)\n      # Pipe {{ pipe_method }} should return a Lucky::Response or Lucky::ActionPipes::Continue\n      # Do this by using `continue` or one of rendering methods like `html` or `redirect`\n      #\n      #   def {{ pipe_method }}\n      #     cookies[\"name\"] = \"John\"\n      #     continue # or redirect, render\n      #   end\n\n      if pipe_result.is_a?(Lucky::Response)\n        publish_after_event(\"{{ pipe_method.id }}\", continued: false)\n        Lucky::ActionPipes.log_halted_pipe(\"{{ pipe_method.id }}\")\n        return pipe_result\n      else\n        publish_after_event(\"{{ pipe_method.id }}\", continued: true)\n        Lucky::ActionPipes.log_continued_pipe(\"{{ pipe_method.id }}\")\n      end\n    {% end %}\n  end\n\n  # :nodoc:\n  def self.log_halted_pipe(pipe_method_name : String) : Nil\n    Lucky::Log.dexter.warn { {halted_by: pipe_method_name} }\n  end\n\n  # :nodoc:\n  def self.log_continued_pipe(pipe_method_name : String) : Nil\n    Lucky::ContinuedPipeLog.dexter.info { {ran_pipe: pipe_method_name} }\n  end\n\n  # :nodoc:\n  def ensure_pipe_return_response_or_continue(pipe_result : Lucky::Response | Lucky::ActionPipes::Continue)\n  end\n\n  # Call this in a pipe to continue to the next pipe or action\n  def continue : Lucky::ActionPipes::Continue\n    Lucky::ActionPipes::Continue.new\n  end\n\n  private def publish_before_event(pipe_name : String, continued : Bool)\n    Lucky::Events::PipeEvent.publish(\n      name: pipe_name,\n      position: Lucky::Events::PipeEvent::Position::Before,\n      continued: continued\n    )\n  end\n\n  private def publish_after_event(pipe_name : String, continued : Bool)\n    Lucky::Events::PipeEvent.publish(\n      name: pipe_name,\n      position: Lucky::Events::PipeEvent::Position::After,\n      continued: continued\n    )\n  end\nend\n"
  },
  {
    "path": "src/lucky/allowed_in_tags.cr",
    "content": "# Include this module in a type to allow it to be output in tags\n#\n# Lucky already includes this in a few common types like `Int` and `Bool`.\n# Typically this is enough but if you have a type you want to allow in tags, you\n# can do so.\n#\n# For example:\n#\n# ```\n# class EmailAddress\n#   include Lucky::AllowedInTags\n#\n#   def initialize(@value : String)\n#   end\n#\n#   def to_s(io)\n#     io.puts @value\n#   end\n# end\n# ```\n#\n# Now an `EmailAddress` can be used for tag content without calling `to_s`:\n#\n# ```\n# h1 EmailAddress.new(\"myemail.com\")\n# ```\nmodule Lucky::AllowedInTags\nend\n"
  },
  {
    "path": "src/lucky/asset_helpers.cr",
    "content": "# Methods for returning the path to assets\n#\n# These methods will return fingerprinted paths, check assets at compile time,\n# and allow for setting a CDN.\n#\n# For an in-depth guide check: https://luckyframework.org/guides/frontend/asset-handling\n#\nmodule Lucky::AssetHelpers\n  ASSET_MANIFEST = {} of String => String\n  CONFIG         = {has_loaded_manifest: false}\n\n  # Loads the asset manifest at compile time.\n  #\n  # Call this once in src/app.cr:\n  #\n  # ```\n  # # Bun (default):\n  # Lucky::AssetHelpers.load_manifest\n  #\n  # # Laravel Mix:\n  # Lucky::AssetHelpers.load_manifest(from: :mix)\n  #\n  # # Vite:\n  # Lucky::AssetHelpers.load_manifest(from: :vite)\n  #\n  # # Custom manifest path:\n  # Lucky::AssetHelpers.load_manifest(\"public/custom-manifest.json\", from: :mix)\n  # ```\n  #\n  # NOTE: The custom manifest path is only considered by Mix or Vite. Bun's is\n  # defined in the shared `config/bun.json`.\n  #\n  macro load_manifest(manifest_file = \"\", from = :bun)\n    {{ run \"../run_macros/asset_manifest_builder\", from, manifest_file }}\n    {% CONFIG[:has_loaded_manifest] = true %}\n  end\n\n  # Returns the string path to an asset.\n  #\n  # ```\n  # # In a page or component:\n  # # Will find the asset in `public/assets/images/logo.png`\n  # img src: asset(\"images/logo.png\")\n  #\n  # # Can also be used elsewhere by prepending Lucky::AssetHelpers\n  # Lucky::AssetHelpers.asset(\"images/logo.png\")\n  # ```\n  #\n  # Note that assets are checked at compile time so if it is not found, Lucky\n  # will let you know. It will also let you know if you had a typo and suggest an\n  # asset that is close to what you typed.\n  #\n  # NOTE: This macro requires a `StringLiteral`. That means you cannot\n  # interpolate strings like this: `asset(\"images/icon-#{service_name}.png\")`.\n  # Instead use `dynamic_asset` if you need string interpolation.\n  #\n  macro asset(path)\n    {% unless CONFIG[:has_loaded_manifest] %}\n      {% raise \"No manifest loaded. Call 'Lucky::AssetHelpers.load_manifest'\" %}\n    {% end %}\n\n    {% if path.is_a?(StringLiteral) %}\n      {% if ::Lucky::AssetHelpers::ASSET_MANIFEST[path] %}\n        Lucky::Server.settings.asset_host + {{ ::Lucky::AssetHelpers::ASSET_MANIFEST[path] }}\n      {% else %}\n        {% asset_paths = ::Lucky::AssetHelpers::ASSET_MANIFEST.keys.join(\",\") %}\n        {{ run \"../run_macros/missing_asset\", path, asset_paths }}\n      {% end %}\n    {% elsif path.is_a?(StringInterpolation) %}\n      {% raise <<-ERROR\n      \\n\n      The 'asset' macro doesn't work with string interpolation\n\n      Try this...\n\n        ▸ Use the 'dynamic_asset' method instead\n\n      ERROR\n      %}\n    {% else %}\n      {% raise <<-ERROR\n      \\n\n      The 'asset' macro requires a literal string like \"my-logo.png\", instead got: #{path}\n\n      Try this...\n\n        ▸ If you're using a variable, switch to a literal string\n        ▸ If you can't use a literal string, use the 'dynamic_asset' method instead\n\n      ERROR\n      %}\n    {% end %}\n  end\n\n  # Returns the string path to an asset (allows string interpolation).\n  #\n  # ```\n  # # In a page or component\n  # # Will find the asset in `public/assets/images/logo.png`\n  # img src: dynamic_asset(\"images/logo.png\")\n  #\n  # # Can also be used elsewhere by prepending Lucky::AssetHelpers\n  # Lucky::AssetHelpers.dynamic_asset(\"images/logo.png\")\n  # ```\n  #\n  # NOTE: This method does *not* check assets at compile time. The asset path\n  # is found at runtime so it is possible the asset does not exist. Be sure to\n  # manually test that the asset is returned as expected.\n  #\n  def dynamic_asset(path : String) : String\n    if fingerprinted_path = Lucky::AssetHelpers::ASSET_MANIFEST[path]?\n      Lucky::Server.settings.asset_host + fingerprinted_path\n    else\n      raise \"Missing asset: #{path}\"\n    end\n  end\n\n  # Returns all the CSS entrypoints from the manifest.\n  #\n  # NOTE: This method is used by the CSS HMR implementation for Bun.\n  #\n  def self.css_entry_points : Array(String)\n    ASSET_MANIFEST.keys.select(&.ends_with?(\".css\"))\n  end\nend\n"
  },
  {
    "path": "src/lucky/assignable.cr",
    "content": "module Lucky::Assignable\n  # Declare what a class needs in order to be initialized.\n  #\n  # This will declare an instance variable and getter automatically. It will\n  # also add arguments to an `initialize` method at the end of compilation.\n  #\n  # ### Examples\n  #\n  # ```\n  # class Users::IndexPage < MainLayout\n  #   # This page needs a `User` or it will fail to compile\n  #   # You can access it with `@user` or the getter method `user`\n  #   needs user : User\n  #\n  #   # This page can take an optional `ProductQuery`. This means you can\n  #   # Leave `products` off when rendering from an Action.\n  #   needs products : ProductQuery?\n  #\n  #   # When using a `Bool` Lucky will generate a method ending with `?`\n  #   # So in this case you can call `should_show_sidebar?` in the page.\n  #   needs should_show_sidebar : Bool = true\n  # end\n  # ```\n  macro needs(*type_declarations)\n    {% for declaration in type_declarations %}\n      {% unless declaration.is_a?(TypeDeclaration) %}\n        {% raise \"'needs' expects a type declaration like 'name : String', instead got: '#{declaration}'\" %}\n      {% end %}\n\n      # Ensure that the needs variable name has not been previously defined.\n      {% previous_declaration = ASSIGNS.find { |decl| decl.var == declaration.var } %}\n      {% if previous_declaration %}\n        {% raise <<-ERROR\n          \\n\n          Duplicate needs definition: '#{declaration}' defined in #{declaration.filename}:#{declaration.line_number}:#{declaration.column_number}\n          This needs is already defined as '#{previous_declaration}' in #{previous_declaration.filename}:#{previous_declaration.line_number}:#{previous_declaration.column_number}\n          ERROR\n        %}\n      {% end %}\n\n      {% if declaration.type.stringify == \"Bool\" %}\n        getter? {{ declaration.var }}\n      {% else %}\n        getter {{ declaration.var }}\n      {% end %}\n\n      {% ASSIGNS << declaration %}\n    {% end %}\n  end\n\n  # :nodoc:\n  macro inherit_assigns\n    macro included\n      inherit_assigns\n    end\n\n    macro inherited\n      inherit_assigns\n    end\n\n    {% if !@type.has_constant?(:ASSIGNS) %}\n      ASSIGNS = [] of Nil\n      {% verbatim do %}\n        {% if @type.ancestors.first %}\n          {% for declaration in @type.ancestors.first.constant(:ASSIGNS) %}\n            {% ASSIGNS << declaration %}\n          {% end %}\n        {% end %}\n      {% end %}\n    {% end %}\n  end\n\n  macro setup_initializer_hook\n    macro finished\n      generate_needy_initializer\n    end\n\n    macro included\n      setup_initializer_hook\n    end\n\n    macro inherited\n      setup_initializer_hook\n    end\n  end\n\n  macro generate_needy_initializer\n    {% if !@type.abstract? %}\n      {% sorted_assigns = ASSIGNS.sort_by { |dec|\n           has_explicit_value =\n             dec.type.is_a?(Metaclass) ||\n               dec.type.types.any? { |type|\n                 (type.is_a?(Metaclass) || type.is_a?(ProcNotation) || type.is_a?(Generic)) ? false : type.names.includes?(Nil.id)\n               } ||\n               !dec.value.is_a?(Nop)\n           has_explicit_value ? 1 : 0\n         } %}\n\n      # Check if this is a BaseComponent - if so, don't accept unused exposures\n      {% is_component = @type.ancestors.any? { |ancestor| ancestor.stringify == \"Lucky::BaseComponent\" } %}\n\n      def initialize(\n        {% for declaration in sorted_assigns %}\n          {% var = declaration.var %}\n          {% type = declaration.type %}\n          {% value = declaration.value %}\n          {% value = nil if type.stringify.ends_with?(\"Nil\") && value.nil? %}\n          @{{ var.id }} : {{ type }}{% if !value.is_a?(Nop) %} = {{ value }}{% end %},\n        {% end %}\n        {% unless is_component %}**unused_exposures{% end %}\n        )\n      end\n    {% end %}\n  end\n\n  setup_initializer_hook\n  inherit_assigns\nend\n"
  },
  {
    "path": "src/lucky/base_app_server.cr",
    "content": "# The Base class for creating an app server in Lucky\nabstract class Lucky::BaseAppServer\n  private getter server\n\n  abstract def middleware : Array(HTTP::Handler)\n  abstract def listen\n\n  def initialize\n    @server = HTTP::Server.new(middleware)\n  end\n\n  # :nodoc:\n  def host : String\n    Lucky::Server.settings.host\n  end\n\n  # :nodoc:\n  def port : Int32\n    Lucky::Server.settings.port\n  end\n\n  # :nodoc:\n  def close : Nil\n    server.close\n  end\nend\n"
  },
  {
    "path": "src/lucky/base_component.cr",
    "content": "require \"./html_builder\"\n\nabstract class Lucky::BaseComponent\n  include Lucky::HTMLBuilder\n\n  macro inherited\n    # Returns the relative file location to the\n    # project root. e.g. src/components/my_component.cr\n    def self.file_location\n      __FILE__.gsub(Dir.current, \"\").byte_slice(1)\n    end\n  end\n\n  private def view : IO\n    @view || raise \"No view was set. Use 'mount' or call 'render_to_string'.\"\n  end\n\n  # :nodoc:\n  def view(@view : IO) : self\n    # This is used by Lucky::MountComponent to set the view.\n    self\n  end\n\n  private def context : HTTP::Server::Context\n    @context || raise \"No context was set in #{self.class.name}. Use 'mount' or set it with 'context(@context)' before rendering.\"\n  end\n\n  def context(@context : HTTP::Server::Context?) : self\n    # This is used by Lucky::MountComponent to set the context.\n    self\n  end\n\n  def render_to_string : String\n    String.build do |io|\n      view(io)\n      render\n    end\n  end\nend\n"
  },
  {
    "path": "src/lucky/base_http_client.cr",
    "content": "require \"http/client\"\n\n# A client for making HTTP requests\n#\n# Makes it easy to pass params, use Lucky route helpers, and chain header methods.\nabstract class Lucky::BaseHTTPClient\n  @@app : Lucky::BaseAppServer?\n  private getter client : HTTP::Client\n\n  def self.app(@@app : Lucky::BaseAppServer)\n  end\n\n  def initialize(@client : HTTP::Client = build_client)\n  end\n\n  private def build_client : HTTP::Client\n    if app = @@app\n      Client.from_app(app)\n    else\n      HTTP::Client.new(Lucky::Server.settings.host, port: Lucky::Server.settings.port)\n    end\n  end\n\n  {% for method in [:get, :put, :patch, :post, :exec, :delete, :options, :head] %}\n    def self.{{ method.id }}(*args, **named_args)\n      new.{{ method.id }}(*args, **named_args)\n    end\n  {% end %}\n\n  # Set headers for requests\n  #\n  # ```\n  # # `content_type` will be normalized to `content-type`\n  # AppClient.new.headers(content_type: \"application/json\")\n  #\n  # # You can also use string keys if you want\n  # AppClient.new.headers(\"Content-Type\": \"application/json\")\n  # ```\n\n  # The header call is chainable and returns the client:\n  #\n  # ```\n  # # content_type will be normalized to `content-type`\n  # AppClient.new\n  #   .headers(content_type: \"application/json\")\n  #   .headers(accept: \"text/plain\")\n  #   .get(\"/some-path\")\n  # ```\n  #\n  # You can also set up headers in `initialize` or in instance methods:\n  #\n  # ```\n  # class AppClient < Lucky::BaseHTTPClient\n  #   def initialize\n  #     headers(content_type: \"application/json\")\n  #   end\n  #\n  #   def accept_plain_text\n  #     headers(accept: \"text/plain\")\n  #   end\n  # end\n  #\n  # AppClient.new\n  #   .accept_plain_text\n  #   .get(\"/some-path\")\n  # ```\n  def headers(**header_values) : self\n    @client.before_request do |request|\n      header_values.each do |key, value|\n        request.headers[key.to_s.gsub(\"-\", \"_\")] = value.to_s\n      end\n    end\n    self\n  end\n\n  # Sends a request with the path and method from a Lucky::Action\n  #\n  # ```\n  # # Make a request without body params\n  # AppClient.new.exec Users::Index\n  #\n  # # Make a request with body params\n  # AppClient.new.exec Users::Create, user: {email: \"paul@example.com\"}\n  #\n  # # Actions that require path params work like normal\n  # AppClient.new.exec Users::Show.with(user.id)\n  # ```\n  def exec(action : Lucky::Action.class, **params) : HTTP::Client::Response\n    exec(action.route, params)\n  end\n\n  # See docs for `exec`\n  def exec(route_helper : Lucky::RouteHelper, **params) : HTTP::Client::Response\n    exec(route_helper, params)\n  end\n\n  # See docs for `exec`\n  def exec(action : Lucky::Action.class, params : NamedTuple) : HTTP::Client::Response\n    exec(action.route, **params)\n  end\n\n  # See docs for `exec`\n  def exec(route_helper : Lucky::RouteHelper, params : NamedTuple) : HTTP::Client::Response\n    @client.exec(method: route_helper.method.to_s.upcase, path: route_helper.path, body: params.to_json)\n  end\n\n  # `exec_raw` works the same as `exec`, but allows you to pass in a raw string.\n  # This is used as an escape hatch as the `string` could be unsafe, or formatted\n  # in a custom format.\n  def exec_raw(action : Lucky::Action.class, body : HTTP::Client::BodyType) : HTTP::Client::Response\n    exec_raw(action.route, body)\n  end\n\n  # See docs for `exec_raw`\n  def exec_raw(route_helper : Lucky::RouteHelper, body : HTTP::Client::BodyType) : HTTP::Client::Response\n    @client.exec(method: route_helper.method.to_s.upcase, path: route_helper.path, body: body)\n  end\n\n  {% for method in [:put, :patch, :post, :delete, :get, :options, :head] %}\n    def {{ method.id }}(path : String, **params) : HTTP::Client::Response\n      {{ method.id }}(path, params)\n    end\n\n    def {{ method.id }}(path : String, params : NamedTuple) : HTTP::Client::Response\n      @client.{{ method.id }}(path, form: params.to_json)\n    end\n  {% end %}\n\n  # HTTP::Client that sends requests into the wrapped HTTP::Handler\n  # instead of making actual HTTP requests\n  private class Client < HTTP::Client\n    @host = \"\"\n    @port = -1\n\n    def self.from_app(app : Lucky::BaseAppServer) : self\n      self.new(HTTP::Server.build_middleware(app.middleware))\n    end\n\n    def initialize(@app : HTTP::Handler)\n    end\n\n    def exec_internal(request : HTTP::Request) : HTTP::Client::Response\n      set_defaults(request)\n      run_before_request_callbacks(request)\n      buffer = IO::Memory.new\n      response = HTTP::Server::Response.new(buffer)\n      context = HTTP::Server::Context.new(request, response)\n\n      @app.call(context)\n      response.close\n\n      HTTP::Client::Response.from_io(buffer.rewind)\n    end\n  end\nend\n"
  },
  {
    "path": "src/lucky/base_log_formatter.cr",
    "content": "abstract class Lucky::BaseLogFormatter\n  abstract def format(\n    severity : ::Logger::Severity,\n    timestamp : Time,\n    progname : String,\n    data : NamedTuple,\n    io : IO,\n  ) : Nil\nend\n"
  },
  {
    "path": "src/lucky/context_extensions.cr",
    "content": "class HTTP::Server::Context\n  # :nodoc:\n  #\n  # This is used to store the client's accepted/desired format\n  # That way if there is an error, the Errors::Show action will\n  # use the same format that the Action used without trying\n  # to figure it out again.\n  property _clients_desired_format : Symbol? = nil\n\n  # :nodoc:\n  #\n  # This stores the format extracted from the URL path (e.g., .csv, .json)\n  # This takes precedence over Accept header-based format detection\n  property _url_format : Lucky::Format | Lucky::FormatRegistry::CustomFormat | Nil = nil\n\n  # :nodoc:\n  #\n  # This value should be unique between each request.\n  # Use this to help group logging output to a single request.\n  # It can be set through the `RequestIdHandler` config.\n  property request_id : String? = nil\n\n  @_cookies : Lucky::CookieJar?\n\n  def cookies : Lucky::CookieJar\n    @_cookies ||= Lucky::CookieJar.from_request_cookies(request.cookies)\n  end\n\n  @_session : Lucky::Session?\n\n  def session : Lucky::Session\n    @_session ||= Lucky::Session.from_cookie_jar(cookies)\n  end\n\n  @_flash : Lucky::FlashStore?\n\n  def flash : Lucky::FlashStore\n    @_flash ||= Lucky::FlashStore.from_session(session)\n  end\n\n  @_params : Lucky::Params?\n\n  def params : Lucky::Params\n    @_params ||= Lucky::Params.new(request)\n  end\nend\n"
  },
  {
    "path": "src/lucky/cookies/cookie_jar.cr",
    "content": "class Lucky::CookieJar\n  MAX_COOKIE_SIZE         = 4096\n  LUCKY_ENCRYPTION_PREFIX = Base64.strict_encode(\"lucky\") + \"--\"\n  alias Key = String | Symbol\n  private property cookies : HTTP::Cookies\n  private property set_cookies : HTTP::Cookies\n\n  Habitat.create do\n    setting on_set : (HTTP::Cookie -> HTTP::Cookie)?\n  end\n\n  def self.from_request_cookies(cookies : HTTP::Cookies) : Lucky::CookieJar\n    new(cookies)\n  end\n\n  def self.empty_jar : Lucky::CookieJar\n    new\n  end\n\n  private def initialize\n    @cookies = HTTP::Cookies.new\n    @set_cookies = HTTP::Cookies.new\n  end\n\n  private def initialize(@cookies : HTTP::Cookies)\n    @set_cookies = HTTP::Cookies.new\n  end\n\n  def raw : HTTP::Cookies\n    cookies\n  end\n\n  def updated : HTTP::Cookies\n    set_cookies\n  end\n\n  # Delete all cookies.\n  def clear : Nil\n    clear { }\n  end\n\n  # Delete cookies with a block to add specific options.\n  #\n  # jar.clear do |cookie|\n  #   cookie.path(\"/\")\n  #         .http_only(true)\n  #         .secure(true)\n  # end\n  def clear(& : HTTP::Cookie ->) : Nil\n    cookies.each do |cookie|\n      yield cookie\n      delete cookie.name\n    end\n  end\n\n  # https://tools.ietf.org/search/rfc6265#page-8\n  # to remove a cookie, the server returns a Set-Cookie header\n  # with an expiration date in the past. The server will be successful\n  # in removing the cookie only if the Path and the Domain attribute in\n  # the Set-Cookie header match the values used when the cookie was\n  # created.\n  def delete(key : Key) : Nil\n    if cookie = cookies[key.to_s]?\n      cookie.expires(1.year.ago).value(\"\")\n      set_cookies[key.to_s] = cookie\n    end\n  end\n\n  # Delete a specific cookie by name `key`. Yield that cookie\n  # to the block so you can add additional options like domain, path, etc...\n  def delete(key : Key, &) : Nil\n    if cookie = cookies[key.to_s]?\n      yield cookie\n      delete cookie.name\n    end\n  end\n\n  # Returns `true` if the cookie has been expired, and has no value.\n  # Will return `false` if the cookie does not exist, or is valid.\n  def deleted?(key : Key) : Bool\n    if cookie = cookies[key.to_s]?\n      cookie.expired? && cookie.value == \"\"\n    else\n      false\n    end\n  end\n\n  def get_raw(key : Key) : HTTP::Cookie\n    get_raw?(key) || raise CookieNotFoundError.new(key)\n  end\n\n  def get_raw?(key : Key) : HTTP::Cookie?\n    cookies[key.to_s]?\n  end\n\n  def get(key : Key) : String\n    get?(key) || raise CookieNotFoundError.new(key)\n  end\n\n  def [](key : Key) : String\n    get(key)\n  end\n\n  def get?(key : Key) : String?\n    cookies[key.to_s]?.try do |cookie|\n      decrypt(cookie.value, cookie.name)\n    end\n  rescue OpenSSL::Cipher::Error\n    nil\n  end\n\n  def []?(key : Key) : String?\n    get?(key)\n  end\n\n  def set(key : Key, value : String) : HTTP::Cookie\n    set_raw key, encrypt(value)\n  end\n\n  def []=(key : Key, value : String) : HTTP::Cookie\n    set(key, value)\n  end\n\n  def set_raw(key : Key, value : String) : HTTP::Cookie\n    raw_cookie = HTTP::Cookie.new(\n      name: key.to_s,\n      value: value,\n      http_only: true,\n    ).tap do |cookie|\n      settings.on_set.try(&.call(cookie))\n    end\n    if raw_cookie.to_set_cookie_header.bytesize > MAX_COOKIE_SIZE\n      raise Lucky::CookieOverflowError.new(\"size of '#{key}' cookie is too big\")\n    end\n    cookies[key.to_s] = set_cookies[key.to_s] = raw_cookie\n  rescue IO::Error\n    raise InvalidCookieValueError.new(key)\n  end\n\n  private def encrypt(raw_value : String) : String\n    encrypted = encryptor.encrypt_and_sign(raw_value)\n\n    String.build do |value|\n      value << LUCKY_ENCRYPTION_PREFIX\n      value << encrypted\n    end\n  end\n\n  private def decrypt(cookie_value : String, cookie_name : String) : String?\n    return unless encrypted_with_lucky?(cookie_value)\n\n    base_64_encrypted_part = cookie_value.lchop(LUCKY_ENCRYPTION_PREFIX)\n    String.new(encryptor.verify_and_decrypt(base_64_encrypted_part))\n  rescue e\n    # an error happened while decrypting the cookie\n    # we will treat that as if no cookie was passed\n  end\n\n  private def encrypted_with_lucky?(value : String) : Bool\n    value.starts_with?(LUCKY_ENCRYPTION_PREFIX)\n  end\n\n  @_encryptor : Lucky::MessageEncryptor?\n\n  private def encryptor : Lucky::MessageEncryptor\n    @_encryptor ||= Lucky::MessageEncryptor.new(secret_key)\n  end\n\n  private def secret_key : String\n    Lucky::Server.settings.secret_key_base\n  end\nend\n"
  },
  {
    "path": "src/lucky/cookies/flash_store.cr",
    "content": "class Lucky::FlashStore\n  SESSION_KEY = \"_flash\"\n  alias Key = String | Symbol\n\n  private getter flashes = {} of String => String\n  private getter discard = [] of String\n\n  delegate any?, each, empty?, to: flashes\n\n  def self.from_session(session : Lucky::Session) : Lucky::FlashStore\n    new.from_session(session)\n  end\n\n  def from_session(session : Lucky::Session) : Lucky::FlashStore\n    session.get?(SESSION_KEY).try do |json|\n      JSON.parse(json).as_h.each do |key, value|\n        set(key, value.as_s)\n      end\n    end\n    self\n  rescue e : JSON::ParseException\n    raise Lucky::InvalidFlashJSONError.new(session.get?(SESSION_KEY))\n  end\n\n  def keep : Nil\n    discard.clear\n  end\n\n  {% for shortcut in [:failure, :info, :success] %}\n    def {{ shortcut.id }} : String\n      get?(:{{ shortcut.id }}) || \"\"\n    end\n\n    def {{ shortcut.id }}? : String?\n      get?(:{{ shortcut.id }})\n    end\n\n    def {{ shortcut.id }}=(message : String) : String\n      set(:{{ shortcut.id }}, message)\n    end\n  {% end %}\n\n  def to_json : String\n    flashes.reject(discard).to_json\n  end\n\n  def clear : Nil\n    flashes.clear\n    discard.clear\n  end\n\n  def set(key : Key, value : String) : String\n    discard << key.to_s\n    flashes[key.to_s] = value\n  end\n\n  def get(key : Key) : String\n    flashes[key.to_s]\n  end\n\n  def get?(key : Key) : String?\n    flashes[key.to_s]?\n  end\nend\n"
  },
  {
    "path": "src/lucky/cookies/session.cr",
    "content": "class Lucky::Session\n  alias Key = String | Symbol\n  private property store = {} of String => String\n\n  delegate to_json, clear, to: store\n\n  Habitat.create do\n    setting key : String\n  end\n\n  def self.from_cookie_jar(cookie_jar : Lucky::CookieJar) : Lucky::Session\n    new.tap do |session|\n      cookie_jar.get?(settings.key).try do |contents|\n        JSON.parse(contents).as_h.each do |key, value|\n          session.set key, value.as_s\n        end\n      end\n    end\n  end\n\n  def delete(key : Key) : String?\n    store.delete key.to_s\n  end\n\n  def set(key : Key, value : String) : String\n    store[key.to_s] = value\n  end\n\n  def get(key : Key) : String\n    get?(key) || raise \"No key for '#{key}' in session\"\n  end\n\n  def get?(key : Key) : String?\n    store[key.to_s]?\n  end\nend\n"
  },
  {
    "path": "src/lucky/data_response.cr",
    "content": "# Return a data for the request.\n#\n# `data` can be used to return contents of the IO as a file to the browser, or\n# render the contents of the IO inline to a web browser. Options for the\n# method:\n#\n# * `data` - first argument, _required_. The data that should be sent.\n# * `content_type` - defaults to \"application/octet-stream\".\n# * `disposition` - default \"attachment\" (downloads file), or \"inline\"\n#   (renders file in browser).\n# * `filename` - default `nil`. When overridden and paired with\n#   `disposition: \"attachment\"` this will download file with the provided\n#   filename.\n# * status - `Int32` - the HTTP status code to\n#   return with.\n#\n# Examples:\n#\n# ```\n# class Reports::MyReport < ApiAction\n#   get \"/reports/my_report\" do\n#     result = CSV.build do |csv|\n#       csv.row \"one\", \"two\"\n#       csv.row \"three\"\n#     end\n\n#     data result, filename: \"my_report.csv\"\n#   end\n# end\n# ```\nclass Lucky::DataResponse < Lucky::Response\n  DEFAULT_STATUS = 200\n\n  getter context, data, content_type, filename, headers\n  getter debug_message : String?\n\n  def initialize(@context : HTTP::Server::Context,\n                 @data : String,\n                 @content_type : String = \"application/octet-stream\",\n                 @disposition : String = \"attachment\",\n                 @filename : String? = nil,\n                 @status : Int32? = nil,\n                 @debug_message : String? = nil)\n  end\n\n  def print : Nil\n    set_response_headers\n    context.response.print data\n  end\n\n  def status : Int\n    @status || context.response.status_code || DEFAULT_STATUS\n  end\n\n  private def set_response_headers : Nil\n    context.response.content_length = data.bytesize\n    context.response.content_type = content_type\n    context.response.status_code = status\n    context.response.headers[\"Accept-Ranges\"] = \"bytes\"\n    context.response.headers[\"X-Content-Type-Options\"] = \"nosniff\"\n    context.response.headers[\"Content-Transfer-Encoding\"] = \"binary\"\n    context.response.headers[\"Content-Disposition\"] = disposition\n  end\n\n  private def custom_filename? : Bool\n    !!filename\n  end\n\n  def disposition : String\n    if custom_filename?\n      %(#{@disposition}; filename=\"#{filename}\")\n    else\n      @disposition\n    end\n  end\nend\n"
  },
  {
    "path": "src/lucky/dev_asset_cache_handler.cr",
    "content": "# Makes sure browser cache for assets is busted at every request in development.\nclass Lucky::DevAssetCacheHandler\n  include HTTP::Handler\n\n  ASSET_EXTENSIONS = %w[\n    .js\n    .css\n    .map\n    .json\n    .png\n    .jpg\n    .jpeg\n    .gif\n    .svg\n    .webp\n    .woff\n    .woff2\n    .ttf\n    .eot\n  ]\n\n  def initialize(@enabled : Bool)\n  end\n\n  def call(context : HTTP::Server::Context) : Nil\n    if @enabled && asset_request?(context.request.path)\n      context.response.headers[\"Cache-Control\"] = \"no-store, no-cache, must-revalidate\"\n      context.response.headers[\"Expires\"] = \"0\"\n    end\n\n    call_next(context)\n  end\n\n  private def asset_request?(path : String) : Bool\n    ASSET_EXTENSIONS.any? { |ext| path.ends_with?(ext) }\n  end\nend\n"
  },
  {
    "path": "src/lucky/enforce_underscored_route.cr",
    "content": "# Include this in your actions to enforce underscores are used in your paths\n#\n# This is purely to help maintain consistency in your app and can be removed if\n# desired.\nmodule Lucky::EnforceUnderscoredRoute\n  macro enforce_route_style(path, action)\n    {% if path.includes?(\"-\") %}\n      {% raise <<-ERROR\n      #{path} defined in '#{action}' should use an underscore.\n\n      In '#{action}'\n\n        ▸ Change #{path}\n        ▸ To #{path.gsub(/-/, \"_\")}\n\n      Or, skip the style check for this action\n\n          class #{action}\n        +  include Lucky::SkipRouteStyleCheck\n          end\n\n      Or, skip checking all actions by removing 'Lucky::EnforceUnderscoredRoute'\n\n          # Remove from both BrowserAction and ApiAction\n          class BrowserAction/ApiAction\n        -  include Lucky::EnforceUnderscoredRoute\n          end\n\n\n      ERROR\n      %}\n    {% end %}\n  end\nend\n"
  },
  {
    "path": "src/lucky/error_action.cr",
    "content": "require \"./*\"\n\nabstract class Lucky::ErrorAction\n  include Lucky::ActionDelegates\n  include Lucky::ParamHelpers\n  include Lucky::Renderable\n  include Lucky::Redirectable\n  include Lucky::Exposable\n\n  macro inherited\n    include Lucky::RequestTypeHelpers\n  end\n\n  getter context\n\n  def _dont_report : Array(Exception.class)\n    [] of Exception.class\n  end\n\n  macro dont_report(exception_classes)\n    {% if exception_classes.is_a?(ArrayLiteral) %}\n      def _dont_report : Array(Exception.class)\n        {{ exception_classes }} of Exception.class\n      end\n    {% else %}\n      {% exception_classes.raise \"dont_report expects an array of Exception classes.\" %}\n    {% end %}\n  end\n\n  def initialize(@context : HTTP::Server::Context)\n  end\n\n  # :nodoc:\n  # Accept all formats. ErrorAction should *always* work\n  class_getter _accepted_formats = [] of Symbol\n\n  abstract def default_render(error : Exception) : Lucky::Response\n  abstract def report(error : Exception) : Nil\n\n  def perform_action(error : Exception)\n    # Always get the rendered error because it also includes the HTTP status.\n    # We need the HTTP status to use in the debug page.\n    response = render(error) || default_render(error)\n    ensure_response_is_returned(response)\n\n    if html? && Lucky::ErrorHandler.settings.show_debug_output\n      response = render_exception_page(error, response.status)\n    end\n\n    response.print\n\n    if !_dont_report.includes?(error.class)\n      report(error)\n    end\n  end\n\n  private def render(error : Exception) : Nil\n  end\n\n  private def ensure_response_is_returned(response : Lucky::Response) : Lucky::Response\n    response\n  end\n\n  private def ensure_response_is_returned(response)\n    {% raise <<-ERROR\n      You must return a Lucky::Response from 'render' in your error action.\n\n      You can do that by using head, render, redirect, json, text, etc.\n\n      Example:\n\n        def render(error : Exception) : Lucky::Response\n          # Returns a Lucky::Response\n          # Could also be render, json, text, etc.\n          head status: 500\n        end\n      ERROR\n    %}\n  end\n\n  def render_exception_page(error : Exception, status : Int) : Lucky::Response\n    exception_page = Lucky::ExceptionPage.new(\n      error,\n      context.request.method,\n      context.request.path,\n      context.response.status,\n      \"Error #{context.response.status.to_i} #{context.response.status.description}\",\n      context.request.query_params,\n      context.response.headers,\n      context.response.cookies,\n      error.message,\n    )\n    send_text_response(\n      body: exception_page.to_s,\n      content_type: \"text/html\",\n      status: status\n    )\n  end\nend\n"
  },
  {
    "path": "src/lucky/error_handler.cr",
    "content": "class Lucky::ErrorHandler\n  include HTTP::Handler\n\n  Habitat.create do\n    setting show_debug_output : Bool\n    setting log_error_exception : Bool = true\n  end\n\n  private getter action\n\n  def initialize(@action : Lucky::ErrorAction.class)\n  end\n\n  def call(context : HTTP::Server::Context)\n    call_next(context)\n  rescue error : Exception\n    call_error_action(context, error)\n  end\n\n  private def call_error_action(context : HTTP::Server::Context, error : Exception) : HTTP::Server::Context\n    Lucky::Log.error(exception: error) { \"\" } if settings.log_error_exception\n    action.new(context).perform_action(error)\n    context\n  end\nend\n"
  },
  {
    "path": "src/lucky/errors.cr",
    "content": "module Lucky\n  # = Lucky Errors\n  #\n  # Generic Lucky Record exception class.\n  class Error < Exception\n  end\n\n  # Raised when a route could not be found\n  class RouteNotFoundError < Error\n    getter context\n\n    def initialize(@context : HTTP::Server::Context)\n      super \"Could not find route matching #{@context.request.method} #{@context.request.path}\"\n    end\n  end\n\n  class ParamParsingError < Error\n    include Lucky::RenderableError\n\n    getter request\n\n    def initialize(@request : HTTP::Request)\n      super \"Failed to parse the request parameters.\"\n    end\n\n    def renderable_status : Int32\n      400\n    end\n\n    def renderable_message : String\n      \"There was a problem parsing the JSON params. Please check that it is formed correctly.\"\n    end\n  end\n\n  class UnknownAcceptHeaderError < Error\n    include Lucky::RenderableError\n\n    getter request\n\n    def initialize(@request : HTTP::Request)\n      accept_header = request.headers[\"accept\"]?\n      super <<-TEXT\n      Lucky couldn't figure out what format the client accepts.\n\n          The client's Accept header: '#{accept_header}'\n\n      You can teach Lucky how to handle this header:\n\n          #{\"# Add this in config/mime_types.cr\".colorize.dim}\n          Lucky::MimeType.register \"#{accept_header}\", :custom_format\n\n      Or use one of these headers Lucky knows about:\n\n          #{Lucky::MimeType.known_accept_headers.join(\", \")}\n\n\n      TEXT\n    end\n\n    def renderable_status : Int32\n      406\n    end\n\n    def renderable_message : String\n      \"Unrecognized Accept header '#{request.headers[\"Accept\"]?}'.\"\n    end\n  end\n\n  class NotAcceptableError < Error\n    include Lucky::RenderableError\n\n    getter request\n\n    def initialize(@request : HTTP::Request, action_name : String, format : Symbol, accepted_formats : Array(Symbol))\n      super <<-TEXT\n      The request wants :#{format}, but #{action_name} does not accept it.\n\n      Accepted formats: #{accepted_formats.map(&.to_s).join(\", \")}\n\n      Try this...\n\n        ▸ Add :#{format} to 'accepted_formats' in #{action_name} or its parent class.\n        ▸ Make your request using one of the accepted formats.\n\n\n      TEXT\n    end\n\n    def renderable_status : Int32\n      406\n    end\n\n    def renderable_message : String\n      \"Accept header '#{request.headers[\"Accept\"]?}' is not accepted.\"\n    end\n  end\n\n  # Raised when storing more than 4K of session data.\n  class CookieOverflowError < Error\n  end\n\n  # Raised when getting a cookie that doesn't exist.\n  class CookieNotFoundError < Error\n    include Lucky::RenderableError\n\n    getter :key\n\n    def initialize(@key : String | Symbol)\n    end\n\n    def message : String\n      \"No cookie found with the key: '#{key}'\"\n    end\n\n    def renderable_status : Int32\n      400\n    end\n\n    def renderable_message : String\n      message\n    end\n  end\n\n  # Crystal raises `Invalid cookie value (IO::Error)` by default.\n  # This provides a nicer error\n  class InvalidCookieValueError < Error\n    getter :key\n\n    def initialize(@key : String | Symbol)\n    end\n\n    def message : String\n      <<-ERROR\n      Cookie value for '#{key}' is invalid.\n\n      Be sure the value does not contain any blank characters,\n      comma, double quote, semicolon, or double backslash.\n\n      See https://tools.ietf.org/html/rfc6265#section-4.1.1 for valid\n      characters\n      ERROR\n    end\n  end\n\n  class InvalidSignatureError < Error\n  end\n\n  class InvalidMessageError < Error\n  end\n\n  class InvalidParamError < Error\n    include Lucky::RenderableError\n\n    getter :param_name, :param_value, :param_type\n\n    def initialize(@param_name : String, @param_value : String, @param_type : String)\n    end\n\n    def message : String?\n      \"Required param '#{param_name}' with value '#{param_value}' couldn't be parsed to a '#{param_type}'\"\n    end\n\n    def renderable_status : Int32\n      HTTP::Status::UNPROCESSABLE_ENTITY.value\n    end\n\n    def renderable_message : String\n      message\n    end\n  end\n\n  class MissingParamError < Error\n    include Lucky::RenderableError\n\n    getter :param_name\n\n    def initialize(@param_name : String)\n    end\n\n    def message : String\n      \"Missing parameter: '#{param_name}'\"\n    end\n\n    def renderable_status : Int32\n      400\n    end\n\n    def renderable_message : String\n      message\n    end\n  end\n\n  class MissingNestedParamError < Error\n    include Lucky::RenderableError\n\n    getter :nested_key\n\n    def initialize(@nested_key : String | Symbol)\n    end\n\n    def message : String\n      \"Missing param key: '#{nested_key}'\"\n    end\n\n    def renderable_status : Int32\n      400\n    end\n\n    def renderable_message : String\n      message\n    end\n  end\n\n  class MissingFileError < Error\n    getter :path\n\n    def initialize(@path : String)\n    end\n\n    def message : String\n      \"Cannot read file #{path}\"\n    end\n  end\n\n  class InvalidFlashJSONError < Error\n    getter bad_json\n\n    def initialize(@bad_json : String?)\n    end\n\n    def message : String?\n      <<-MESSAGE\n      The flash messages (stored as JSON) failed to parse in a JSON parser.\n      Here's what it tries to parse:\n\n      #{bad_json}\n      MESSAGE\n    end\n  end\n\n  class InvalidSubdomainError < Error\n    def initialize(@host : String?, @expected : Lucky::Subdomain::Matcher)\n    end\n\n    def message : String\n      if @host.nil?\n        \"Expected to find a subdomain but did not find a hostname on the request.\"\n      elsif @expected == true\n        \"Expected request to have a subdomain but did not find one.\"\n      else\n        <<-MESSAGE\n          Expected subdomain matcher(s): #{@expected}\n          Did not match host: #{@host}\n        MESSAGE\n      end\n    end\n  end\n\n  class MissingRateLimitIdentifier < Error\n  end\nend\n"
  },
  {
    "path": "src/lucky/events/pipe_event.cr",
    "content": "class Lucky::Events::PipeEvent < Pulsar::Event\n  getter :name, :position, :continued\n\n  enum Position\n    Before\n    After\n  end\n\n  def initialize(\n    @name : String,\n    @position : Position,\n    @continued : Bool,\n  )\n  end\n\n  def before? : Bool\n    position == Position::Before\n  end\n\n  def after? : Bool\n    position == Position::After\n  end\nend\n"
  },
  {
    "path": "src/lucky/events/request_complete_event.cr",
    "content": "class Lucky::Events::RequestCompleteEvent < Pulsar::Event\n  getter duration : Time::Span\n\n  def initialize(@duration : Time::Span)\n  end\nend\n"
  },
  {
    "path": "src/lucky/exception_page.cr",
    "content": "class Lucky::ExceptionPage < ExceptionPage\n  def styles : ExceptionPage::Styles\n    Styles.new(accent: lucky_green, logo_uri: lucky_logo)\n  end\n\n  def project_url : String\n    \"https://luckyframework.org/\"\n  end\n\n  private def lucky_green : String\n    \"#20c17d\"\n  end\n\n  private def lucky_logo : String\n    \"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAALoAAACvCAYAAABKFcTZAAAAAXNSR0IB2cksfwAAAAlwSFlzAAALEwAACxMBAJqcGAAAEzpJREFUeJztnQt0VdWZx7Ha0ZmxOn3aztxz7k0IaAjvCEFwjVpbR2s72i4VrZ127GrvCeEtVnkUjENFUEE459xXHtwQQoAg8jjnXpLGmYxTEK0gigK2tUl4FEEKhoe8Atmzz82jCYTkPs7+vnPu2f+1fmu5utouuPvX3X32/va3+/ThYRZCyFXuUPW3PP7oKFHWvyP6oj8QFf1hUY38h1vWf+RWot92B6LD3EpNRm5o2xex/7w8PL1mcHnNPxriCnLk1y5F0yh7BEU/45I1Eh96M/3376b/uVcFVZtL/8fw/Rxf3fXYfy8enj6ZQa2fIOszBVn7PRX0QvxSxwcV/zz97/6dS9XnZMjaEOy/L4+Dcov6+ldFVX/GpejvmS12HLwrypFJxp8B+3fgSdO4fXo2lTtEZ9jTCIJ3nell7ZygRJYZ/4+C/bvwpEliyxNFW0clb8EW/DJal0vlmWqkP/bvxGPTuEqqv+JSIouNdTK60HEIT/+civFBjP278dgoLp/2OBXnGLrACS9p9AZR3XQ39u/HY/GIfv3LdJmyClvY1Gd4vSirInoD9u/JY8G4/NE76Cx+AF1S09D3GwdV2L8rj4UiKJEnbLEWTxBjh8g4gcX+fXmQYxzRU8nnYQvJmIuiqk/B/q15kFJIyBdEWa+wgIgwqNoS4++M/bvzAKZ1JteWossHT7nxd8f+/XmAQtfjPgtIh4Kg6s9j//48ADEqA7Flw2bw1mW/wB4HHoYRlOiDljzKB2RATQXJqw8039YQvBd7PHgYxKgHEWT9OLZomPRfu5qMrA/EyKsPnhi9r2Qg9rjwmBijBoR+fH6ILRommcvXkhEfBztEj8neENx+J6m7Bnt8eEwK/fhchC0aJmLRepK7J9RF8g4aQzOxx4fHhIh+PZfF7R+7IPg2kGE7SruXvJWzoxqLs7HHiSeFPFxFrqZLlu3YsmEyeEtZT5K3LWECW/lhko0jqJEnsUXDJLtmRa+Sd9AQmIo9XjxJpH+o7mt0bd6ELRsWWWuq4pc8RrDpzoOhr2GPG0+CEWTdjy0bFp6lr5ERf7zCx2fPs7ofe9x4EkjsIrOsN2MLh4Hg20iG7yxJXPLY3nqgmX+Y2iguRYtiC4fFoM29f3z28mEaxR4/njgiqpHvYsuGxc3aypQk75B9b/C72OPI00OMLTL6AboTWzgMujv5TFr0+uBOvt1o4Yiq7sUWDgMxtIHk7i4yRfK/LWFCXuzx5OkmN5du/pIga4ewpQNH2UiGvrPUVMnbZvVDY46Ufgl7XHkuiQPufnbLgNoK0yX/26wenIc9rjydkhmqFRNr0ZweZFUleiiUsOhnbj8YErHHl6ctVPJKbOmg8ZSsJ7f+IYlDocRlr8QeXx4aOugjHXdrSNHIsHd7rEg0j4Zgy237AyOxx9nxccn6FnTxgBnwW3br8u4Yvde/BXucHR1RjTyCLR00fSvXgEpuMPPUdFJG8h/BHm9HJisavVZQtHps8SARgxtI7h5z98t7w3uskCwlXhIm3voomXgt9rg7LrHnVSwgHyRDtoZBJf/RoQWklEgx0Vtll57BHndHJWtp9OtOqzU3q44lXr69TyaBiwUdkrfRVEUmfh17/B0TumQJYIsHSUbZa2TEx3CSj2rwkwXnplwqefusHsAef0dEDEYHOOmyc6y+/INi0Nn8qRMzu5W8ba1+oYJIA7A9SPsIsr4JWz5IBr6xDFTynxx5/oqSd5rVN2F7kNYRfNF7sMWDpN+a1aCS33vgFVLUkt+r6AZlxHsPtg9pmVjrCln/AFs+KNwhmCP+dm5vVMkrzRPjkrwV6YMqUnU1thdpF1GOSNjyQQK9lTj7818lIHk7+RK2F2kVp9Wa37x+Fajk7YdCSXBoA3ma16ybFZeivYAtHxTu4nXJtapIkgc/eYmUJid5+y7MC9h+pEUy1IjbSbXmLG4LXYl/3asS9cL4pCVvE/3MCjLeje2J7UMlX4ktHxTQp5/Pnk5mXd6t7CuxPbF1PGokzym15p7Sdabd4o+Hn/91rimSt4neUk68edi+2DZ0bf4mtoAgKBvhLlLUt+6XF7dIponett34JrYvtoygRMeiCwhE9qYEut6myOhGH1l4fpLJknfIPhbbG1slVmsu6w3YAkLgiRVswS1Zpp2YxUjyWGlAA69ZTyCiEpmOLSAIikaGv5dcQ9BkeOTwfGaSd1qvT8f2xxaJ1Zo75PW4W6KVYJLf1X19OYtZ/TivWY8jgqoHsQWEwLMUbpclr8FP5p6ZxlzyTrN6ENsjS0dUq3OcUms+ZBvcwZCU/BF/sqJfKCPjc7B9smycUmveH7CW5f6/LCQlxOytxLiWMLxmvbu4fJF7sQWEwF0EV35rXIl7+fxkcMk7yc6fYe+ctmcSHfGy86A3U3uRIhEmNs1Bk7xtCfMhr1nvFEGJ5GMLCEHiL8Ulzw8OLkypKtHEWT0f2y9LJKsiegP9AD2MLSFrRD9c86HbGv1kEbPTz4RFP1xBJt6A7Rl6XLI+H1tCCHL+txxsNp9y/Nfogl8i+3xsz1DjlFrzzBWvgkn+wMGXu3TXsgKOr1mnH6CrsCVkjaAa737C9GUZTZcsiV1wBp3VV2H7hhKPPzoKW0IIICsTnzzOrmDLHNnHjcL2DjxUgq3YErLGXbyejPgTzJ65cfcTW+Q4ZvWt2N6BRlD1R7ElhGDwVpg98zGNKlnSPAFd5Phkz38U2z+QeMJ117kUvRFbQtb0XQ3XsH+qxXZZeqExTAqvw/aQeURVm4EtIWtiH6AfwuyZG7UsVttl6R1pBraHTNM3UPMNJ9SaZ9fAfIAatSwvnsOrZUkWo2a9nOR/A9tHZqFLlhC2hKzxlKwD62UuHXsOXdoUZvUQto9MIgT0gU6oNR/yFkzPxO/sXxJ351srYtSs01l9ILaXpkdQ9GpsCVnTj/FLzp157vRT6LKmLrtUje2lqXEr0fuwJWSN4NtAcnfBnIA+/uk8dEnNYhkZdx+2n6akra/5LmwRWQP1AWr0S/QBXHKGQ9qVFjXrgqqPw5aQNbETUKCLzk+fnGEBOc2ljHjHYXuaUmK15rL+KbaIrBm0BeadoR/a4Jg/ST61dc26qEQWYEvImswKmBLc2xp8lq1MNIkF2L4mFU9gk4euzc9ii8iaoUDNQfOBW1bAI50Nk3wPtrcJh0q+GltC1vR/DebVuLv22XvPPAHZV2N7m1DcPn00toSsMR66hboDOuvzpy0gIRQFo7H9jSuEkKtciv4WtoisgdpO/OEnL1pAPlDeMhzC9rjXCHLkMWwJWQO1ndjagMgat/khKSPSY9ge95hYrbms7cUWkTWDNsNsJ/7iqJ2LtpInTLx7LV2zTj9AZ2JLyBqo7cQ79skk2DIOXTpE2Wdi+9xtMopfv4mKcAJbRNZAbSdOPzkdXTZkTlSSyTdhe31Z6AdoEbaErOn3Ksx24r8ffBlbMksQJlIRttdd4lIig9K91rz1ehz76sS8hgB5yYa3htiI7r1AGYTtd0cEWavBFpE1UI/dPmHi+5/pABW9BtvvWNyq9j1sCZnP5v6N5NY97Puz3N7oI/60KsE1S3bpe6iS31lXd42g6LuxRWRNdjXM4dCkptnoUlmU3XWk8Bo00ankBdgSskYMrScj/sj+cOju/UtIMXFCPUty0Fm9AEXyzFDtjVSEI9gisibnf5aDzOazTj2DLpPFOVJFvDeCiy6q2ovYErLGUwrzRKLxOoUFRLI8dFZ/EVRyt1KT4YRac6ibQy+cnYoukT2QzpaTCRlgotO1eRW2hKzJLF8LIvljh1+wgED2IUy8VSCSi0p0DLaEEAx9h/2Dt0Z14uL0vh7HhDKSP4ap5EadMJ3N38aWkDV9V8IUbjm1OjFV6Fr9baY16y4l8mNsCUFm8+3sZ3OjP0uAHw6lILv3x0wkd0qted9VMH3Np1r8GRarw6xmnS5ZZmFLCMGwHSXMJTcahJbYrqe59aBr9VmmSh6rNVe0k9gSsiYLqEkorzU3C+mkqTXroqwVY0sIwfD32M/m/3bgFRu+UGFdyoi32BTJM/ybBlMJLmJLyJp+a2AuVcx2VOsKCKSLy8i4wSmLTtfmtdgSgszmAI/efv8v/KifEbUpSe6W9fuxBQSZzYE6bs09Mw1biLSFLmHuT0pyo9acfoDuwZaQOQqdzT9gP5s7sBERKGHi3ZNUzbqgRsajSwhA/3Uws/n8c1PQZUh36Kw+PiHJnVJrbszmEM+xjD08H10Ch5BYzbpLiTyLLiHIbL6KueR5DX6y0IFt5fCQnotL8hxf3fWCrB/FlhCCXIC1+U+OPG+BwXcUTWEy5Z96n83lyK+wBYSg31r2a/NRDQGyuHkC9sA7jjDJL+xRcqNwS5C1Q9gSQjDsffaz+c+O/AZ90B1KU49rdafstEA8fMsvVeDS4w6MS9F3YEsIMpvvYN8o9Kd8NkclTKRt3Uvuq87CFhACiHpzPptbg277NoqqNgNbQgiGbmc/m/OdFmtAZ/WF3ey2aO9iS8iazEr2d0GNbrhp/h6ojZD+3PUjNBTtiy0hyGwOcLOfz+bWoksfGCf0UMxczr5PC5/NrUcZkbydRS/BFpE1Q7aGmYv++Kfz0AeW05UuDY8ERduOLSJLMsKvAczmfrKI17RYDqNbQEzynKpdfyfI2jlsGVky8I1yPps7FCp6SxUpuL6POxAdhi0iS9xFAI/f0rU5r1C0LmFSMKKPqOo/w5aRJQNer2A+mz/C680tDf0g/anxCO40bBlZIQY2gLxWwds+Wxu6fHnW2HH5DbaQrLglUslc8gc+eQl9IDm9iS4tNp5OVLGFZIHxLmju7iLmoheefgp9IDm9ie4t6yPKegW2lCyAuCZ334FF6IPIiUd0ab1RmhvBlpIFw3eyby83gz+wZRdqqehaFFtKs8lazb4U1+iIW4o/gJw4oEsXPS3fIxr6DvtS3GknZqIPICdepDXG9mIptphmAvHI1h17ZVLcwjvi2gdpudHDZTG2nGYy8Hfsn0yc0DTHAoPHiRf6MRro41L1OdhymoU7xP64//ZGHwleHIc+eJxERPdOp0uXyGPYgppFds0K5rP5L4/+F/rAcRJFGtvHrVYPxxbUDIwDols/CjGV3LhYsYQ3JLIdsaIuowUdtqRmANEVl7/ybE+qyNSvtPVz0fZhi5oqEO8P8eItW/KnzlfpVmKLmgqZK9jf7ufPstiTWJ1LR08XWfsltqypMBjgPugsftxvS7pejrZxuwtPyTrmkt+1jz+Aa1fKSUH2pX0XG7GlTYac/17OXPRJTbPRB4yTDNJHl3Xqouv0l7ClTRTBz/4G0ZhGlR8Q2RS6bJl7mehtD+eiy5sIN29cyXw2/zk/ILItV3xoly5f3sOWNxGGvc92SzGPd8W1M7u7lbx1+RKZii1vvPStYL+lyG/325cwkfKvKHpWRfQGQdY+w5Y4HgZtZl+lyF96ti1HNeL9hyuKHpvVVW0utsS9IQJUKd5zYDH2YHGShH6EzutRciO3qK9/1SXrp7Bl7onsTeyrFJ88Pgt9wDiJEybeU2FS8M1eRTdCRZ+PLfMVUTaS3F1s21jEthRb+JaiHaGz+Zy4JDcSe1RX0Q+gS90NWavZvyj3n3+diz5gnMShH6AHel2bXxpR0R/Glro7hvyefV3Ly+cnow8aJ3FiPRaTiSBrNdhid8ZTyr6uhbeYsyd0Nq8mhFyVlOgZasRNlzDHsAVvZ0At+7qWWaeeRh80TsIcXUkm/HNSknfM6mrkAWzBDQRVI7f+ge1VuTv3KbxK0YYsI9JDKUneIbusv4Itev+17K/KFXzG21jYjTDxBk2R3EhuaNsX6RLmDUzRWT+daLz2rFwYjz5wnISorSOF15gmupHMUO2NVLj3MSSHuFzxKL/4bDOkXVXEe6OpkrfHHar+lqBo9dCiD/gt++dZeF2LrdgfJvkeJpK3x+WrzgLtGqBoJHcP25NQozOuBQaPEwd0TV7f5TVolskqjrroB+ouCNEhTkIn8l6KNkH6qIJMdIFI3h7Rr3/ZJetbWIvO+oa/8RHq4x+hlidMpLcryeSbQCVvj6vqzb8XVD3MSnLjjdCRf2ZbjjuWX66wAVIoSiZeiyJ557S+VWp+aS9EOS5/bMu60PX4mTIiPYHtd5e4fXq2S9F3mCl67ofFTCU3+rWU8pNQS0KXKlvLSf5AbK+7zcNV5GpR0SbTD9XjqUredyX7O6EFnz2LPqCcywQ/Xka84wkp/AK2z72mbb99BZ3hW5IVfdDmMqaS58VOQnkLaOsgNVOWLSP5/4Ltb8IR1eqcVuG1C4lIHnvanPGd0IcOLbDA4HKo3GeNehWwvXGWMQ6ZBFUPxruk6b+e/WO4sz/n5bjInKAsSrm81opp2458NPa2aQ+z/NBtbAu47tgn83JcJLnp7L2C8mCYFF6H7SNIjG4Dok97iM7yfir3R+2SQxRweY8VYg+4Q5CaqdTvU0rpPz/gGLl7iscX+aaobro7p6ayIK8hsGRkQ6B6ZENwB+XjvPrAYfqvnTZL9IXnJ1lAgnRAOrk0VmDl3Rkm0v/Rf95gPHW4lORLxrtBljjkacv/A4JY669Mf5/DAAAAAElFTkSuQmCC\"\n  end\nend\n"
  },
  {
    "path": "src/lucky/exposable.cr",
    "content": "module Lucky::Exposable\n  macro included\n    EXPOSURES = [] of Symbol\n\n    macro inherited\n      EXPOSURES = [] of Symbol\n\n      inherit_exposures\n    end\n  end\n\n  # :nodoc:\n  macro inherit_exposures\n    \\{% for v in @type.ancestors.first.constant :EXPOSURES %}\n      \\{% EXPOSURES << v %}\n    \\{% end %}\n  end\n\n  # Sends the result of a method to the page as if it was passed as an argument.\n  #\n  # Imagine having data that is used by many actions across your app, such\n  # as the current user. It can get tedious to pass that data for every action.\n  # The `expose` macro will make sure that whatever data you need is passed\n  # automatically.\n  #\n  # Here's what things might look like without `expose`:\n  #\n  # ```\n  # class BrowserAction\n  #   def current_user\n  #     # some way to find the current user\n  #   end\n  # end\n  # ```\n  #\n  # Each action must pass `current_user` manually. Note that each action\n  # inherits from `BrowserAction` and therefore has access to `current_user`:\n  #\n  # ```\n  # class Messages::Index < BrowserAction\n  #   get \"/messages\" do\n  #     html IndexPage, current_user: current_user\n  #   end\n  # end\n  #\n  # class Messages::New < BrowserAction\n  #   get \"/messages/new\" do\n  #     html NewPage, current_user: current_user\n  #   end\n  # end\n  # ```\n  #\n  # Passing `current_user: current_user` every time gets pretty old. Enter\n  # `expose`:\n  #\n  # ```\n  # class BrowserAction\n  #   expose current_user\n  #\n  #   def current_user\n  #     # some way to find the current user\n  #   end\n  # end\n  # ```\n  #\n  # Now our actions are much nicer, especially when we start to have multiple\n  # arguments for each action:\n  #\n  # ```\n  # class Messages::Index < BrowserAction\n  #   get \"/messages\" do\n  #     html IndexPage\n  #   end\n  # end\n  #\n  # class Messages::New < BrowserAction\n  #   get \"/messages/new\" do\n  #     html NewPage\n  #   end\n  # end\n  # ```\n  #\n  # ## Exposing private methods\n  #\n  # Also useful is the ability to make a private method available:\n  #\n  # ```\n  # class Messages::Show < BrowserAction\n  #   expose message\n  #\n  #   get \"/messages/:id\" do\n  #     html ShowPage\n  #   end\n  #\n  #   private def message\n  #     MessageQuery.find(id)\n  #   end\n  # end\n  # ```\n  #\n  # Using `expose` here will pass `message` to the `ShowPage`, while keeping the\n  # method private. Without `expose` the action would look like this:\n  #\n  # ```\n  # get \"/messages/:id\" do\n  #   html ShowPage, message: message\n  # end\n  # ```\n  macro expose(method_name)\n    {% method_name_str = method_name.stringify %}\n    {% if method_name_str.ends_with?('?') || method_name_str.ends_with?('!') %}\n      {% method_name.raise <<-ERROR\n\n      Methods ending in '?' or '!' cannot be exposed to pages.\n      #{@type.name} called `expose #{method_name_str.id}`\n\n      Try this...\n\n        ▸ Define your method without ? or ! then...\n        ▸ expose #{method_name_str.gsub(/[!?]$/, \"\").id}\n      ERROR\n      %}\n    {% end %}\n    {% EXPOSURES << method_name.id %}\n  end\nend\n"
  },
  {
    "path": "src/lucky/file_response.cr",
    "content": "# Return a file's contents for the request.\n#\n# `file` can be used to return a file and it's contents to the browser, or\n# render the contents of the file inline to a web browser. Options for the\n# method:\n#\n# * `path` - first argument, _required_. The path to the file.\n# * `content_type` - defaults to the mime-type that corresponds to the file's\n#   extension.\n# * `disposition` - default \"attachment\" (downloads file), or \"inline\"\n#   (renders file in browser).\n# * `filename` - default `nil`. When overridden and paired with\n#   `disposition: \"attachment\"` this will download file with the provided\n#   filename.\n# * status - `Int32` - the HTTP status code to\n#   return with.\n#\n# Examples:\n#\n# ```\n# class Rendering::File < Lucky::Action\n#   get \"/file\" do\n#     file \"spec/fixtures/lucky_logo.png\"\n#   end\n# end\n# ```\n#\n# For a plain text file with no extension, have it downloaded with the file\n# named \"custom.html\" and the content_type \"text/html\":\n#\n# ```\n# class Rendering::File::CustomContentType < Lucky::Action\n#   get \"/foo\" do\n#     file \"spec/fixtures/plain_text\",\n#       disposition: \"attachment\",\n#       filename: \"custom.html\",\n#       content_type: \"text/html\"\n#   end\n# end\n# ```\nclass Lucky::FileResponse < Lucky::Response\n  DEFAULT_STATUS = 200\n\n  getter context, path, filename, headers\n  getter debug_message : String?\n\n  def initialize(@context : HTTP::Server::Context,\n                 @path : String,\n                 @content_type : String? = nil,\n                 @disposition : String = \"attachment\",\n                 @filename : String? = nil,\n                 @status : Int32? = nil,\n                 @debug_message : String? = nil)\n  end\n\n  def print : Nil\n    raise Lucky::MissingFileError.new(path) unless file_exists?\n\n    set_response_headers\n    context.response.status_code = status\n    File.open(full_path) { |file| IO.copy(file, context.response) }\n  end\n\n  def status : Int\n    @status || context.response.status_code || DEFAULT_STATUS\n  end\n\n  private def set_response_headers : Nil\n    context.response.content_length = File.size(full_path)\n    context.response.content_type = content_type\n    context.response.headers[\"Accept-Ranges\"] = \"bytes\"\n    context.response.headers[\"X-Content-Type-Options\"] = \"nosniff\"\n    context.response.headers[\"Content-Transfer-Encoding\"] = \"binary\"\n    context.response.headers[\"Content-Disposition\"] = disposition\n  end\n\n  private def custom_filename? : Bool\n    !!filename\n  end\n\n  def content_type : String\n    @content_type || content_type_from_file\n  end\n\n  def disposition : String\n    if custom_filename?\n      %(#{@disposition}; filename=\"#{filename}\")\n    else\n      @disposition\n    end\n  end\n\n  private def content_type_from_file : String\n    extension = File.extname(path)\n\n    {\n      \".css\"   => \"text/css\",\n      \".gif\"   => \"image/gif\",\n      \".htm\"   => \"text/html\",\n      \".html\"  => \"text/html\",\n      \".ico\"   => \"image/x-icon\",\n      \".jpg\"   => \"image/jpeg\",\n      \".jpeg\"  => \"image/jpeg\",\n      \".js\"    => \"application/javascript\",\n      \".json\"  => \"application/json\",\n      \".mp4\"   => \"video/mp4\",\n      \".otf\"   => \"application/font-sfnt\",\n      \".ttf\"   => \"application/font-sfnt\",\n      \".png\"   => \"image/png\",\n      \".svg\"   => \"image/svg+xml\",\n      \".txt\"   => \"text/plain\",\n      \".webm\"  => \"video/webm\",\n      \".woff\"  => \"application/font-woff\",\n      \".woff2\" => \"font/woff2\",\n      \".xml\"   => \"application/xml\",\n      \"\"       => \"application/octet-stream\",\n    }[extension]\n  end\n\n  private def full_path : String\n    File.expand_path(path, Dir.current)\n  end\n\n  private def file_exists? : Bool\n    File.file?(full_path) && File::Info.readable?(full_path)\n  end\nend\n"
  },
  {
    "path": "src/lucky/force_ssl_handler.cr",
    "content": "# Redirects HTTP requests to HTTPS\n#\n# Uses the `X-Forwarded-Proto` header to determine whether the request was\n# made securely. Heroku uses this by default, and so do many other servers.\n# If the header is not present, handler will treat the request as insecure.\n#\n# ### Options\n#\n# *Redirect Status* - The ForceSSLHandler will use a 308 permanent redirect\n# status so the browser knows to request the the secure version every time. You\n# Can use a different status code if you prefer.\n\n# *Enabled* - The handler can be enabled/disabled. This is helpful for working\n# in a local development environment.\n#\n# *Strict-Transport-Security* - Settings to configure the ['Strict-Transport-Security' header](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Strict-Transport-Security)\n#\n# ```\n# # Usually in config/force_ssl_handler.cr\n# Lucky::ForceSSLHandler.configure do |settings|\n#   settings.redirect_status = 303\n#   settings.enabled = false\n#   settings.strict_transport_security = {max_age: 1.year, include_subdomains: true}\n# end\n# ```\nclass Lucky::ForceSSLHandler\n  include HTTP::Handler\n\n  Habitat.create do\n    setting redirect_status : Int32 = HTTP::Status::PERMANENT_REDIRECT.value\n    setting enabled : Bool\n    setting strict_transport_security : NamedTuple(max_age: Time::Span | Time::MonthSpan, include_subdomains: Bool)?\n  end\n\n  def call(context : HTTP::Server::Context)\n    if disabled?\n      call_next(context)\n    elsif secure?(context)\n      add_transport_header_if_enabled(context)\n      call_next(context)\n    else\n      redirect_to_secure_version(context)\n    end\n  end\n\n  private def disabled? : Bool\n    !settings.enabled\n  end\n\n  private def secure?(context) : Bool\n    context.request.headers[\"X-Forwarded-Proto\"]? == \"https\"\n  end\n\n  private def redirect_to_secure_version(context : HTTP::Server::Context) : Nil\n    context.response.status_code = settings.redirect_status\n    context.response.headers[\"Location\"] =\n      \"#{URI.new(\"https\", context.request.headers[\"Host\"]?)}#{context.request.resource}\"\n  end\n\n  # Read more about [Strict-Transport-Security](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Strict-Transport-Security)\n  private def add_transport_header_if_enabled(context : HTTP::Server::Context) : Nil\n    settings.strict_transport_security.try do |header|\n      sts_value = String.build do |io|\n        max_age = ensure_time_span(header[:max_age])\n        io << \"max-age=\" << max_age.total_seconds.to_i\n        io << \"; includeSubDomains\" if header[:include_subdomains]\n      end\n      context.response.headers[\"Strict-Transport-Security\"] = sts_value\n    end\n  end\n\n  private def ensure_time_span(span : Time::Span) : Time::Span\n    span\n  end\n\n  # 1.year returns a Time::MonthSpan. We need to convert it to a Time::Span\n  private def ensure_time_span(span : Time::MonthSpan) : Time::Span\n    months = span.value\n    (months * 30).days\n  end\nend\n"
  },
  {
    "path": "src/lucky/form_data.cr",
    "content": "class Lucky::FormData\n  getter params = MultiValueStorage(String).new\n  getter files = MultiValueStorage(Lucky::UploadedFile).new\n\n  def add(part : HTTP::FormData::Part)\n    case part.headers\n    when .includes_word?(\"Content-Disposition\", \"filename\")\n      files.add(part.name, Lucky::UploadedFile.new(part))\n    else\n      params.add(part.name, part.body.gets_to_end)\n    end\n  end\n\n  # Simpler, generic implementation of HTTP::Params\n  class MultiValueStorage(T)\n    include Enumerable({String, T})\n\n    private getter storage : Hash(String, Array(T))\n\n    def initialize\n      @storage = {} of String => Array(T)\n    end\n\n    def []?(key : String) : T?\n      storage[key]?.try(&.first?)\n    end\n\n    def fetch_all(key : String) : Array(T)\n      storage.fetch(key) { [] of T }\n    end\n\n    def add(key : String, value : T)\n      storage[key] ||= [] of T\n      storage[key] << value\n    end\n\n    def each(&)\n      storage.each do |name, values|\n        values.each do |value|\n          yield({name, value})\n        end\n      end\n    end\n  end\nend\n"
  },
  {
    "path": "src/lucky/form_data_parser.cr",
    "content": "# :nodoc:\nclass Lucky::FormDataParser\n  getter body : String\n  getter request : HTTP::Request\n\n  def initialize(@body : String, @request : HTTP::Request)\n  end\n\n  def form_data : Lucky::FormData\n    body_io = IO::Memory.new(body)\n    form_data = Lucky::FormData.new\n    boundary = MIME::Multipart.parse_boundary(request.headers[\"Content-Type\"]).to_s\n\n    HTTP::FormData.parse(body_io, boundary) do |part|\n      form_data.add(part)\n    end\n    form_data\n  end\nend\n"
  },
  {
    "path": "src/lucky/format.cr",
    "content": "# Format enum for handling different content types and file extensions\nenum Lucky::Format\n  Html\n  Json\n  Xml\n  Csv\n  Js\n  PlainText\n  Yaml\n  Rss\n  Atom\n  Ics\n  Css\n  Ajax\n  MultipartForm\n  UrlEncodedForm\n\n  # Convert format to file extension\n  def to_extension : String\n    case self\n    in .html?             then \"html\"\n    in .json?             then \"json\"\n    in .xml?              then \"xml\"\n    in .csv?              then \"csv\"\n    in .js?               then \"js\"\n    in .plain_text?       then \"txt\"\n    in .yaml?             then \"yaml\"\n    in .rss?              then \"rss\"\n    in .atom?             then \"atom\"\n    in .ics?              then \"ics\"\n    in .css?              then \"css\"\n    in .ajax?             then \"\" # Ajax doesn't have a file extension\n    in .multipart_form?   then \"\" # Form data doesn't have a file extension\n    in .url_encoded_form? then \"\" # Form data doesn't have a file extension\n    end\n  end\n\n  # Convert format to MIME type\n  def to_mime_type : String\n    case self\n    in .html?             then \"text/html\"\n    in .json?             then \"application/json\"\n    in .xml?              then \"application/xml\"\n    in .csv?              then \"text/csv\"\n    in .js?               then \"text/javascript\"\n    in .plain_text?       then \"text/plain\"\n    in .yaml?             then \"application/x-yaml\"\n    in .rss?              then \"application/rss+xml\"\n    in .atom?             then \"application/atom+xml\"\n    in .ics?              then \"text/calendar\"\n    in .css?              then \"text/css\"\n    in .ajax?             then \"text/plain\"\n    in .multipart_form?   then \"multipart/form-data\"\n    in .url_encoded_form? then \"application/x-www-form-urlencoded\"\n    end\n  end\n\n  # Parse format from file extension\n  # ameba:disable Metrics/CyclomaticComplexity\n  def self.from_extension(extension : String) : Format?\n    case extension.downcase\n    when \"html\", \"htm\" then Html\n    when \"json\"        then Json\n    when \"xml\"         then Xml\n    when \"csv\"         then Csv\n    when \"js\"          then Js\n    when \"txt\"         then PlainText\n    when \"yaml\", \"yml\" then Yaml\n    when \"rss\"         then Rss\n    when \"atom\"        then Atom\n    when \"ics\", \"ical\" then Ics\n    when \"css\"         then Css\n    else                    nil\n    end\n  end\n\n  # Parse format from MIME type\n  # ameba:disable Metrics/CyclomaticComplexity\n  def self.from_mime_type(mime_type : String) : Format?\n    case mime_type.downcase\n    when \"text/html\"                         then Html\n    when \"application/json\", \"text/json\"     then Json\n    when \"application/xml\"                   then Xml\n    when \"text/csv\"                          then Csv\n    when \"text/javascript\"                   then Js\n    when \"text/plain\"                        then PlainText\n    when \"application/x-yaml\", \"text/yaml\"   then Yaml\n    when \"application/rss+xml\"               then Rss\n    when \"application/atom+xml\"              then Atom\n    when \"text/calendar\"                     then Ics\n    when \"text/css\"                          then Css\n    when \"multipart/form-data\"               then MultipartForm\n    when \"application/x-www-form-urlencoded\" then UrlEncodedForm\n    else                                          nil\n    end\n  end\nend\n"
  },
  {
    "path": "src/lucky/format_registry.cr",
    "content": "# Registry for custom formats that extends the built-in Format enum\nmodule Lucky::FormatRegistry\n  # Storage for custom format mappings\n  class_getter custom_formats = {} of String => CustomFormat\n\n  # Represents a custom format that users can register\n  struct CustomFormat\n    getter name : String\n    getter extension : String\n    getter mime_type : String\n\n    def initialize(@name : String, @extension : String, @mime_type : String)\n    end\n\n    def to_s(io : IO) : Nil\n      io << name\n    end\n  end\n\n  # Register a custom format\n  def self.register(name : String, extension : String, mime_type : String) : Nil\n    custom_formats[name] = CustomFormat.new(name, extension, mime_type)\n  end\n\n  # Find format by extension (checks both built-in and custom formats)\n  def self.from_extension(extension : String) : Lucky::Format | CustomFormat | Nil\n    # Try built-in formats first\n    if format = Lucky::Format.from_extension(extension)\n      return format\n    end\n\n    # Check custom formats\n    custom_formats.each_value do |custom_format|\n      return custom_format if custom_format.extension.downcase == extension.downcase\n    end\n\n    nil\n  end\n\n  # Find format by MIME type (checks both built-in and custom formats)\n  def self.from_mime_type(mime_type : String) : Lucky::Format | CustomFormat | Nil\n    # Try built-in formats first\n    if format = Lucky::Format.from_mime_type(mime_type)\n      return format\n    end\n\n    # Check custom formats\n    custom_formats.each_value do |custom_format|\n      return custom_format if custom_format.mime_type.downcase == mime_type.downcase\n    end\n\n    nil\n  end\n\n  # Get all known extensions (built-in + custom)\n  def self.known_extensions : Array(String)\n    built_in = Lucky::Format.values.map(&.to_extension).reject(&.empty?)\n    custom = custom_formats.values.map(&.extension)\n    (built_in + custom).uniq\n  end\n\n  # Get all known MIME types (built-in + custom)\n  def self.known_mime_types : Array(String)\n    built_in = Lucky::Format.values.map(&.to_mime_type)\n    custom = custom_formats.values.map(&.mime_type)\n    (built_in + custom).uniq\n  end\nend\n"
  },
  {
    "path": "src/lucky/html_builder.cr",
    "content": "require \"./tags/**\"\nrequire \"./page_helpers/**\"\nrequire \"./mount_component\"\n\nmodule Lucky::HTMLBuilder\n  include Lucky::BaseTags\n  include Lucky::CustomTags\n  include Lucky::LinkHelpers\n  include Lucky::FormHelpers\n  include Lucky::SpecialtyTags\n  include Lucky::Assignable\n  include Lucky::AssetHelpers\n  include Lucky::NumberToCurrency\n  include Lucky::TextHelpers\n  include Lucky::HTMLTextHelpers\n  include Lucky::UrlHelpers\n  include Lucky::TimeHelpers\n  include Lucky::ForgeryProtectionHelpers\n  include Lucky::MountComponent\n  include Lucky::HelpfulParagraphError\n  include Lucky::RenderIfDefined\n  include Lucky::TagDefaults\n  include Lucky::LiveReloadTag\n  include Lucky::BunReloadTag\n  include Lucky::SvgInliner\n\n  abstract def view : IO\n\n  def perform_render : IO\n    render\n    view\n  end\nend\n"
  },
  {
    "path": "src/lucky/html_page.cr",
    "content": "require \"./html_builder\"\n\nmodule Lucky::HTMLPage\n  include Lucky::HTMLBuilder\n\n  Habitat.create do\n    setting render_component_comments : Bool = false\n  end\n\n  getter view : IO = IO::Memory.new\n  needs context : HTTP::Server::Context\n\n  def to_s(io)\n    io << view\n  end\nend\n"
  },
  {
    "path": "src/lucky/http_method_override_handler.cr",
    "content": "class Lucky::HttpMethodOverrideHandler\n  include HTTP::Handler\n\n  def call(context : HTTP::Server::Context)\n    http_method = overridden_http_method(context)\n\n    if http_method && override_allowed?(context, http_method)\n      context.request.method = http_method\n    end\n\n    call_next(context)\n  end\n\n  private def override_allowed?(context : HTTP::Server::Context, http_method : String) : Bool\n    (context.request.method == \"POST\") && [\"PATCH\", \"PUT\", \"DELETE\"].includes?(http_method)\n  end\n\n  private def overridden_http_method(context : HTTP::Server::Context) : String?\n    context.params.get?(:_method).try(&.upcase)\n  rescue Lucky::ParamParsingError\n    nil\n  end\nend\n"
  },
  {
    "path": "src/lucky/json_body_parser.cr",
    "content": "# :nodoc:\nclass Lucky::JsonBodyParser\n  getter body : String\n  getter request : HTTP::Request\n\n  def initialize(@body : String, @request : HTTP::Request)\n  end\n\n  def parsed_json : JSON::Any\n    if body.blank?\n      JSON::Any.new({} of String => JSON::Any)\n    else\n      JSON.parse(body)\n    end\n  rescue JSON::ParseException\n    raise Lucky::ParamParsingError.new(@request)\n  end\nend\n"
  },
  {
    "path": "src/lucky/log_handler.cr",
    "content": "require \"colorize\"\n\nclass Lucky::LogHandler\n  include HTTP::Handler\n  # These constants are used here and in the PrettyLogFormatter to make sure\n  # that the formatter looks for the right keys!\n  REQUEST_START_KEYS = {\n    method:     \"method\",\n    path:       \"path\",\n    request_id: \"request_id\",\n  }\n\n  REQUEST_END_KEYS = {\n    status:     \"status\",\n    duration:   \"duration\",\n    request_id: \"request_id\",\n  }\n\n  Habitat.create do\n    setting skip_if : Proc(HTTP::Server::Context, Bool)?\n  end\n\n  delegate logger, to: Lucky\n\n  def call(context : HTTP::Server::Context)\n    should_skip_logging = settings.skip_if.try &.call(context)\n\n    if should_skip_logging\n      call_next(context)\n    else\n      log_request_start(context)\n\n      duration = Time.measure do\n        call_next(context)\n      end\n\n      log_request_end(context, duration: duration)\n      Lucky::Events::RequestCompleteEvent.publish(duration)\n    end\n  rescue e\n    log_exception(context, Time.utc, e)\n    raise e\n  end\n\n  private def log_request_start(context : HTTP::Server::Context) : Nil\n    Lucky::Log.dexter.info do\n      {\n        REQUEST_START_KEYS[:method]     => context.request.method,\n        REQUEST_START_KEYS[:path]       => context.request.resource,\n        REQUEST_START_KEYS[:request_id] => context.request_id,\n      }\n    end\n  end\n\n  private def log_request_end(context : HTTP::Server::Context, duration : Time::Span) : Nil\n    Lucky::Log.dexter.info do\n      {\n        REQUEST_END_KEYS[:status]     => context.response.status_code,\n        REQUEST_END_KEYS[:duration]   => Lucky::LoggerHelpers.elapsed_text(duration),\n        REQUEST_END_KEYS[:request_id] => context.request_id,\n      }\n    end\n  end\n\n  private def log_exception(context : HTTP::Server::Context, time : Time, e : Exception) : Nil\n    Lucky::Log.error(exception: e) { \"\" }\n  end\nend\n"
  },
  {
    "path": "src/lucky/logger_helpers.cr",
    "content": "module Lucky::LoggerHelpers\n  def self.colored_http_status(status_code : Int32) : String\n    http_status = HTTP::Status.from_value?(status_code)\n    status_name = http_status.try(&.description) || \"\"\n    message = \"#{status_code} #{status_name}\".colorize.bold\n\n    case status_code\n    when 400..499\n      message.yellow\n    when 500..599\n      message.red\n    else\n      message\n    end.to_s\n  end\n\n  def self.elapsed_text(elapsed : Time::Span) : String\n    minutes = elapsed.total_minutes\n    return \"#{minutes.round(2)}m\" if minutes >= 1\n\n    seconds = elapsed.total_seconds\n    return \"#{seconds.round(2)}s\" if seconds >= 1\n\n    millis = elapsed.total_milliseconds\n    return \"#{millis.round(2)}ms\" if millis >= 1\n\n    \"#{(millis * 1000).round(2)}µs\"\n  end\nend\n"
  },
  {
    "path": "src/lucky/maximum_request_size_handler.cr",
    "content": "# Allows a maximum request size to be set for incoming requests.\n#\n# Configure the max_size to the maximum size in bytes that you\n# want to allow.\n#\n# ```\n# Lucky::MaximumRequestSizeHandler.configure do |settings|\n#   settings.enabled = true\n#   settings.max_size = 1_048_576 # 1MB\n# end\n# ```\n\nclass Lucky::MaximumRequestSizeHandler\n  include HTTP::Handler\n\n  Habitat.create do\n    setting enabled : Bool = false\n    setting max_size : Int64 = 1_048_576_i64 # 1MB\n  end\n\n  def call(context : HTTP::Server::Context)\n    return call_next(context) unless settings.enabled\n\n    max_size = request_limit_for(context)\n\n    body_size = 0_i64\n    body = IO::Memory.new\n\n    begin\n      buffer = Bytes.new(8192) # 8KB buffer\n      while read_bytes = context.request.body.try(&.read(buffer))\n        body_size += read_bytes\n        body.write(buffer[0, read_bytes])\n\n        if body_size > max_size\n          context.response.status = HTTP::Status::PAYLOAD_TOO_LARGE\n          context.response.print(\"Request entity too large\")\n          return context\n        end\n\n        break if read_bytes < buffer.size # End of body\n      end\n    rescue IO::Error\n      context.response.status = HTTP::Status::BAD_REQUEST\n      context.response.print(\"Error reading request body\")\n      return context\n    end\n\n    # Reset the request body for downstream handlers\n    context.request.body = IO::Memory.new(body.to_s)\n\n    call_next(context)\n  end\n\n  private def request_limit_for(context : HTTP::Server::Context) : Int64\n    matched_action_limit(context) || settings.max_size\n  end\n\n  private def matched_action_limit(context : HTTP::Server::Context) : Int64?\n    find_matching_action(context).try do |match|\n      action_class = match.payload\n      if action_class.responds_to?(:request_body_limit)\n        action_class.request_body_limit\n      end\n    end\n  end\n\n  private def find_matching_action(context : HTTP::Server::Context)\n    method = context.request.method\n    path = context.request.path\n\n    if Lucky::MimeType.extract_format_from_path(path)\n      path = path.sub(/^([^?]*)\\.[a-zA-Z0-9]+(\\?.*)?$/, \"\\\\1\\\\2\")\n    end\n\n    Lucky.router.find_action(method, path)\n  end\nend\n"
  },
  {
    "path": "src/lucky/memoizable.cr",
    "content": "module Lucky::Memoizable\n  # Caches the return value of the method. Helpful for expensive methods that are called more than once.\n  #\n  # To memoize a method, prefix it with `memoize`:\n  #\n  # ```\n  # class BrowserAction\n  #   memoize def current_user : User\n  #     # Get the current user\n  #   end\n  # end\n  # ```\n  #\n  # This will fetch the user record on the first `current_user` call,\n  # then each subsequent call returns the user record.\n  #\n  # The `memoize` method will raise a compile time exception if you forget to include\n  # a return type for your method, or if any arguments are missing a type.\n  # The result of a set of arguments is only kept until the passed arguments change.\n  # Once they change, passing previous arguments will re-run the memoized method.\n  # Equality (==) is used for checking on argument updates.\n  macro memoize(method_def)\n    {% raise \"You must define a return type for memoized methods\" if method_def.return_type.is_a?(Nop) %}\n    {%\n      raise \"All arguments must have an explicit type restriction for memoized methods\" if method_def.args.any? &.restriction.is_a?(Nop)\n    %}\n\n    {%\n      special_ending = nil\n      safe_method_name = method_def.name\n    %}\n\n    {%\n      if method_def.name.ends_with?('?')\n        special_ending = \"?\"\n        safe_method_name = method_def.name.tr(\"?\", \"\")\n      elsif method_def.name.ends_with?('!')\n        special_ending = \"!\"\n        safe_method_name = method_def.name.tr(\"!\", \"\")\n      end\n    %}\n\n    @__memoized_{{safe_method_name}} : Tuple(\n      {{ method_def.return_type }},\n      {% for arg in method_def.args %}\n        {{ arg.restriction }},\n      {% end %}\n    )?\n\n    # Returns uncached value\n    def {{ safe_method_name }}__uncached{% if special_ending %}{{ special_ending.id }}{% end %}(\n      {% for arg in method_def.args %}\n        {% if arg.name == arg.internal_name %}\n          {{ arg.name }} : {{ arg.restriction }},\n        {% else %}\n          {{ arg.name }} {{ arg.internal_name }} : {{ arg.restriction }},\n        {% end %}\n      {% end %}\n    ) : {{ method_def.return_type }}\n      {{ method_def.body }}\n    end\n\n    # Checks the passed arguments against the memoized args\n    # and runs the method body if it is the very first call\n    # or the arguments do not match\n    def {{ safe_method_name }}__tuple_cached{% if special_ending %}{{ special_ending.id }}{% end %}(\n      {% for arg in method_def.args %}\n        {% if arg.name == arg.internal_name %}\n          {{ arg.name }} : {{ arg.restriction }},\n        {% else %}\n          {{ arg.name }} {{ arg.internal_name }} : {{ arg.restriction }},\n        {% end %}\n      {% end %}\n    ) : Tuple(\n      {{ method_def.return_type }},\n      {% for arg in method_def.args %}\n        {{ arg.restriction }},\n      {% end %}\n    )\n      {% for arg, index in method_def.args %}\n        @__memoized_{{ safe_method_name }} = nil if {{arg.internal_name}} != @__memoized_{{ safe_method_name }}.try &.at({{index}} + 1)\n      {% end %}\n      @__memoized_{{ safe_method_name }} ||= -> do\n        result = {{ safe_method_name }}__uncached{% if special_ending %}{{ special_ending.id }}{% end %}(\n          {% for arg in method_def.args %}\n            {{arg.internal_name}},\n          {% end %}\n        )\n        {\n          result,\n          {% for arg in method_def.args %}\n            {{arg.internal_name}},\n          {% end %}\n        }\n      end.call.not_nil!\n    end\n\n    # Returns cached value\n    def {{ method_def.name }}(\n      {% for arg in method_def.args %}\n        {% has_default = arg.default_value || arg.default_value == false || arg.default_value == nil %}\n        {% if arg.name == arg.internal_name %}\n          {{ arg.name }} : {{ arg.restriction }}{% if has_default %} = {{ arg.default_value }}{% end %},\n        {% else %}\n          {{ arg.name }} {{ arg.internal_name }} : {{ arg.restriction }}{% if has_default %} = {{ arg.default_value }}{% end %},\n        {% end %}\n      {% end %}\n    ) : {{ method_def.return_type }}\n      {{ safe_method_name }}__tuple_cached{% if special_ending %}{{ special_ending.id }}{% end %}(\n        {% for arg in method_def.args %}\n          {{arg.internal_name}},\n        {% end %}\n      ).first\n    end\n  end\nend\n"
  },
  {
    "path": "src/lucky/mime_type.cr",
    "content": "# :nodoc:\nclass Lucky::MimeType\n  alias Format = Symbol\n  alias AcceptHeaderSubstring = String\n  class_getter accept_header_formats = {} of MediaType => Format\n\n  struct MediaType\n    property subtype : String\n    property type : String\n\n    def initialize(@type : String, @subtype : String)\n    end\n\n    def to_s(io : IO)\n      io << type << '/' << subtype\n    end\n  end\n\n  register \"text/html\", :html\n  register \"application/json\", :json\n  register \"text/json\", :json\n  register \"application/jsonrequest\", :json\n  register \"text/javascript\", :js\n  # It's a JS request, but not JSON, or XML\n  register \"text/plain\", :ajax\n  register \"application/xml\", :xml\n  register \"application/rss+xml\", :rss\n  register \"application/atom+xml\", :atom\n  register \"application/x-yaml\", :yaml\n  register \"text/yaml\", :yaml\n  register \"text/csv\", :csv\n  register \"text/css\", :css\n  register \"text/calendar\", :ics\n  register \"text/plain\", :plain_text\n  register \"multipart/form-data\", :multipart_form\n  register \"application/x-www-form-urlencoded\", :url_encoded_form\n\n  def self.known_accept_headers : Array(String)\n    accept_header_formats.keys.map(&.to_s)\n  end\n\n  def self.known_formats : Array(Symbol)\n    accept_header_formats.values.uniq!\n  end\n\n  def self.registered?(format : Symbol) : Bool\n    known_formats.includes?(format)\n  end\n\n  def self.register(accept_header_substring : AcceptHeaderSubstring, format : Format) : Nil\n    type, subtype = accept_header_substring.split(\"/\", 2)\n    if type && subtype\n      accept_header_formats[MediaType.new(type, subtype)] = format\n    else\n      raise \"#{accept_header_substring} is not a valid media type\"\n    end\n  end\n\n  def self.deregister(accept_header_substring : AcceptHeaderSubstring) : Nil\n    type, subtype = accept_header_substring.split(\"/\", 2)\n    accept_header_formats.delete({type, subtype})\n  end\n\n  # :nodoc:\n  def self.determine_clients_desired_format(request, default_format : Symbol, accepted_formats : Array(Symbol))\n    DetermineClientsDesiredFormat.new(request, default_format, accepted_formats).call\n  end\n\n  # Extract format from URL path (e.g., \"/reports/123.csv\" -> Format::Csv)\n  def self.extract_format_from_path(path : String) : Lucky::Format | Lucky::FormatRegistry::CustomFormat | Nil\n    # Only match extensions in the path portion (before any query string)\n    if match = path.match(/^[^?]*\\.([a-zA-Z0-9]+)(?:\\?|$)/)\n      extension = match[1]\n      Lucky::FormatRegistry.from_extension(extension)\n    end\n  end\n\n  class InvalidMediaRange < Exception\n  end\n\n  private class DetermineClientsDesiredFormat\n    private getter request, default_format, accepted_formats\n\n    def initialize(@request : HTTP::Request, @default_format : Symbol, @accepted_formats : Array(Symbol))\n    end\n\n    def call : Symbol?\n      if accept = accept_header\n        from_accept_header(accept)\n      else\n        default_format\n      end\n    end\n\n    private def from_accept_header(accept : String) : Symbol?\n      # If the request accepts anything with no particular preference, return\n      # the default format\n      if accept == \"*/*\"\n        default_format\n      else\n        accept_list = AcceptList.new(accept_header)\n        accept_list.find_match(Lucky::MimeType.accept_header_formats, accepted_formats, default_format)\n      end\n    end\n\n    private def accept_header : String?\n      accept = request.headers[\"Accept\"]?\n\n      if accept && !accept.empty?\n        accept\n      end\n    end\n  end\n\n  class AcceptList\n    getter list : Array(MediaRange)\n\n    ACCEPT_SEP = /[ \\t]*,[ \\t]*/\n\n    # Parses the value of an Accept header and returns an array of MediaRanges sorted by\n    # quality value.\n    def self.parse(accept : String) : Array(MediaRange)\n      list = accept.split(ACCEPT_SEP).compact_map do |range|\n        begin\n          MediaRange.parse(range)\n        rescue ex : InvalidMediaRange\n          Log.debug { \"invalid media range in Accept: #{accept} - #{ex}\" }\n          nil\n        end\n      end\n      list.unstable_sort_by! { |range| -range.qvalue.to_i32 }\n    end\n\n    def initialize(accept : String?)\n      if accept && !accept.empty?\n        @list = AcceptList.parse(accept)\n      else\n        @list = [] of MediaRange\n      end\n    end\n\n    # Find a matching accepted format by accept list priority\n    def find_match(known_formats : Hash(MediaType, Format), accepted_formats : Array(Symbol), default_format : Symbol) : Symbol?\n      # If we find a match in the things we accept then pick one of those\n      formats_in_common = known_formats.select { |_media, format| accepted_formats.includes?(format) }\n      unless formats_in_common.empty?\n        self.list.each do |media_range|\n          if match = formats_in_common.find { |media, _format| media_range.matches?(media) }\n            return match[1]\n          end\n        end\n      end\n\n      # Otherwise if the client doesn't just accept anything then try to find something they\n      # do accept in the list of known formats\n      unless includes_catch_all?\n        self.list.each do |media_range|\n          if match = known_formats.find { |media, _format| media_range.matches?(media) }\n            return match[1]\n          end\n        end\n\n        # No known formats match the ones requested\n        return nil\n      end\n\n      # Finally the client accepts anything so use the default format\n      default_format\n    end\n\n    def includes_catch_all?\n      @list.any? &.catch_all?\n    end\n  end\n\n  class MediaRange\n    TOKEN      = /[!#$%&'*+.^_`|~0-9A-Za-z-]+/\n    MEDIA_TYPE = /^(#{TOKEN})\\/(#{TOKEN})$/\n    PARAM_SEP  = /[ \\t]*;[ \\t]*/\n    QVALUE_RE  = /^[qQ]=([01][0-9.]*)$/\n\n    getter type, subtype, qvalue\n\n    def initialize(type : String, @subtype : String, qvalue : UInt16)\n      if type == \"*\" && @subtype != \"*\"\n        raise InvalidMediaRange.new(\"#{type}/#{@subtype} is not a valid media range\")\n      end\n      unless (0..1000).includes?(qvalue)\n        raise InvalidMediaRange.new(\"qvalue #{qvalue.to_f32 / 1000f32} is not within 0 to 1.0\")\n      end\n\n      @type = type\n      @qvalue = qvalue\n    end\n\n    # Parse a single media range with optional parameters\n    # https://httpwg.org/specs/rfc9110.html#field.accept\n    def self.parse(input : String)\n      parameters = input.split(PARAM_SEP)\n      media = parameters.shift\n\n      # For now we're only interested in the weight, which must be the last parameter\n      qvalue = MediaRange.parse_qvalue(parameters.last?)\n\n      if media =~ MEDIA_TYPE\n        type = $1\n        subtype = $2\n        MediaRange.new(type.downcase, subtype.downcase, qvalue)\n      else\n        raise InvalidMediaRange.new(\"#{input} is not a valid media range\")\n      end\n    end\n\n    def self.parse_qvalue(parameter : String?) : UInt16\n      if parameter && parameter =~ QVALUE_RE\n        # qvalues start with 0 or 1 and can have up to three digits after the\n        # decimal point. To avoid needing to deal with floats, the value is\n        # multiplied by 1000 and then handled as an integer.\n        begin\n          ($1.to_f32 * 1000).round.to_u16\n        rescue ArgumentError | OverflowError\n          raise InvalidMediaRange.new(\"#{parameter} is not a valid qvalue\")\n        end\n      else\n        1000u16\n      end\n    end\n\n    def ==(other)\n      @type == other.type &&\n        @subtype == other.subtype &&\n        @qvalue == other.qvalue\n    end\n\n    def matches?(media : MediaType) : Bool\n      @type == \"*\" || (@type == media.type && self.class.match_type?(@subtype, media.subtype))\n    end\n\n    def catch_all? : Bool\n      @type == \"*\" && @subtype == \"*\"\n    end\n\n    protected def self.match_type?(pattern, value) : Bool\n      pattern == \"*\" || pattern == value\n    end\n  end\nend\n"
  },
  {
    "path": "src/lucky/mount_component.cr",
    "content": "module Lucky::MountComponent\n  # Appends the `component` to the view.\n  #\n  # When `Lucky::HTMLPage.settings.render_component_comments` is\n  # set to `true`, it will render HTML comments showing where the component\n  # starts and ends.\n  #\n  # ```\n  # mount(MyComponent)\n  # mount(MyComponent, with_args: 123)\n  # ```\n  def mount(component : Lucky::BaseComponent.class, *args, **named_args) : Nil\n    print_component_comment(component) do\n      component.new(*args, **named_args).view(view).context(@context).render\n    end\n  end\n\n  # Appends the `component` to the view. Takes a block, and yields the\n  # args passed to the component.\n  #\n  # When `Lucky::HTMLPage.settings.render_component_comments` is\n  # set to `true`, it will render HTML comments showing where the component\n  # starts and ends.\n  #\n  # ```\n  # mount(MyComponent, name: \"Jane\") do |name|\n  #   text name.upcase\n  # end\n  # ```\n  def mount(component : Lucky::BaseComponent.class, *args, **named_args, &) : Nil\n    print_component_comment(component) do\n      component.new(*args, **named_args).view(view).context(@context).render do |*yield_args|\n        yield *yield_args\n      end\n    end\n  end\n\n  # :nodoc:\n  def mount(_component : Lucky::BaseComponent, *args, **named_args) : Nil\n    {% raise <<-ERROR\n        'mount' requires a component class, not an instance of a component.\n\n        Try this...\n\n           ▸ mount MyComponent\n           ▸ mount_instance MyComponent.new\n        ERROR\n    %}\n  end\n\n  # :nodoc:\n  def mount(_component : Lucky::BaseComponent, *args, **named_args, &) : Nil\n    {% raise <<-ERROR\n        'mount' requires a component class, not an instance of a component.\n\n        Try this...\n\n           ▸ mount MyComponent\n           ▸ mount_instance MyComponent.new\n        ERROR\n    %}\n  end\n\n  # :nodoc:\n  def mount_instance(_component : Lucky::BaseComponent.class) : Nil\n    {% raise <<-ERROR\n        'mount_instance' requires an instance of a component, not component class.\n\n        Try this...\n\n           ▸ mount MyComponent\n           ▸ mount_instance MyComponent.new\n        ERROR\n    %}\n  end\n\n  # :nodoc:\n  def mount_instance(_component : Lucky::BaseComponent.class, &) : Nil\n    {% raise <<-ERROR\n        'mount_instance' requires an instance of a component, not component class.\n\n        Try this...\n\n           ▸ mount MyComponent\n           ▸ mount_instance MyComponent.new\n        ERROR\n    %}\n  end\n\n  # Appends the `component` to the view.\n  # The `component` is a previously initialized instance of a component.\n  #\n  # When `Lucky::HTMLPage.settings.render_component_comments` is\n  # set to `true`, it will render HTML comments showing where the component\n  # starts and ends.\n  #\n  # ```\n  # component = MyComponent.new(name: \"Jane\")\n  # mount_instance(component)\n  # ```\n  def mount_instance(component : Lucky::BaseComponent) : Nil\n    print_component_comment(component.class) do\n      component.view(view).context(@context).render\n    end\n  end\n\n  # Appends the `component` to the view. Takes a block, and yields the\n  # args passed to the component.\n  # The `component` is a previously initialized instance of a component.\n  #\n  # When `Lucky::HTMLPage.settings.render_component_comments` is\n  # set to `true`, it will render HTML comments showing where the component\n  # starts and ends.\n  #\n  # ```\n  # component = MyComponent.new(name: \"Jane\")\n  # mount_instance(component) do |name|\n  #   text name.upcase\n  # end\n  # ```\n  def mount_instance(component : Lucky::BaseComponent, &) : Nil\n    print_component_comment(component.class) do\n      component.view(view).context(@context).render do |*yield_args|\n        yield *yield_args\n      end\n    end\n  end\n\n  private def print_component_comment(component : Lucky::BaseComponent.class, &) : Nil\n    if Lucky::HTMLPage.settings.render_component_comments\n      raw \"<!-- BEGIN: #{component.name} #{component.file_location} -->\"\n      yield\n      raw \"<!-- END: #{component.name} -->\"\n    else\n      yield\n    end\n  end\nend\n"
  },
  {
    "path": "src/lucky/page_helpers/helperful_paragraph_error.cr",
    "content": "# :nodoc:\nmodule Lucky::HelpfulParagraphError\n  macro p(_arg, **args)\n    {% raise <<-ERROR\n      `p` is not available on Lucky pages. This is because it's not clear whether you want to print something out or use a `p` HTML tag.\n\n      Instead try:\n        * The `para` method if you want to use an HTML paragraph.\n        * The `pp` method to pretty print information for debugging.\n      ERROR\n    %}\n  end\nend\n"
  },
  {
    "path": "src/lucky/page_helpers/html_text_helpers.cr",
    "content": "# These helper methods will write directly to the view.\nmodule Lucky::HTMLTextHelpers\n  # Shortens text after a length point and inserts content afterward\n  #\n  # **Note: This method writes HTML directly to the page. It does not return a\n  # String.**\n  #\n  # This is ideal if you want an action associated with shortened text, like\n  # \"Read more\".\n  #\n  # * `length` (default: `30`) will control the maximum length of the text,\n  # including the `omission`.\n  # * `omission` (default: `...`) will insert itself at the end of the\n  # truncated text.\n  # * `separator` (default: nil) is where words are cut off. This is often\n  # overridden to break on word boundaries by setting the separator to a space\n  # `\" \"`. Keep in mind this, may cause your text to be truncated before your\n  # `length` value if the `length` - `omission` is before the `separator`.\n  # * `escape` (default: true) weather or not to HTML escape the truncated\n  # string.\n  # * `blk` (default: nil) A block to run after the text has been truncated.\n  # Often used to add an action to read more text, like a \"Read more\" link.\n  #\n  # ```\n  # truncate(\"Four score and seven years ago\", length: 20) do\n  #   link \"Read more\", to: \"#\"\n  # end\n  # ```\n  # outputs:\n  # ```html\n  # \"Four score and se...<a href=\"#\">Read more</a>\"\n  # ```\n  def truncate(text : String, length : Int32 = 30, omission : String = \"...\", separator : String | Nil = nil, escape : Bool = true, blk : Nil | Proc = nil) : Nil\n    content = truncate_text(text, length, omission, separator)\n    raw(escape ? HTML.escape(content) : content)\n    blk.call if !blk.nil? && text.size > length\n  end\n\n  def truncate(text : String, length : Int32 = 30, omission : String = \"...\", separator : String | Nil = nil, escape : Bool = true, &block : -> _) : Nil\n    truncate(text, length, omission, separator, escape, blk: block)\n  end\n\n  # Wrap phrases to make them stand out\n  #\n  # This will wrap all the phrases inside a piece of `text` specified by the\n  # `phrases` array. The default is to wrap each with the `<mark>` element.\n  # This can be customized with the `highlighter` argument.\n  #\n  # **Note: This method writes HTML directly to the page. It does not return a\n  # String**\n  #\n  # ```\n  # highlight(\"Crystal is type-safe and compiled.\", phrases: [\"type-safe\", \"compiled\"])\n  # ```\n  # outputs:\n  # ```html\n  # Crystal is <mark>type-safe</mark> and <mark>compiled</mark>.\n  # ```\n  #\n  # **With a custom highlighter**\n  #\n  # ```\n  # highlight(\n  #   \"You're such a nice and attractive person.\",\n  #   phrases: [\"nice\", \"attractive\"],\n  #   highlighter: \"<strong>\\\\1</strong>\"\n  # )\n  # ```\n  # outputs:\n  # ```html\n  # You're such a <strong>nice</strong> and <strong>attractive</strong> person.\n  # ```\n  def highlight(text : String, phrases : Array(String | Regex), highlighter : Proc | String = \"<mark>\\\\1</mark>\", escape : Bool = true) : Nil\n    text = escape ? HTML.escape(text) : text\n\n    if text.blank? || phrases.all?(&.to_s.blank?)\n      raw(text || \"\")\n    else\n      match = phrases.join('|') do |phrase|\n        phrase.is_a?(Regex) ? phrase.to_s : Regex.escape(phrase.to_s)\n      end\n\n      if highlighter.is_a?(Proc)\n        raw text.gsub(/(#{match})(?![^<]*?>)/i, &highlighter)\n      else\n        raw text.gsub(/(#{match})(?![^<]*?>)/i, highlighter)\n      end\n    end\n  end\n\n  # Highlight a single phrase\n  #\n  # Exactly the same as the `highlight` that takes multiple phrases, but with a\n  # singular `phrase` argument for readability.\n  # ```\n  def highlight(text : String, phrases : Array(String | Regex), escape : Bool = false, &block : String -> _) : Nil\n    highlight(text, phrases, highlighter: block, escape: escape)\n  end\n\n  def highlight(text : String, phrase : String | Regex, highlighter : Proc | String = \"<mark>\\\\1</mark>\", escape : Bool = true) : Nil\n    phrases = [phrase] of String | Regex\n    highlight(text, phrases, highlighter: highlighter, escape: escape)\n  end\n\n  def highlight(text : String, phrase : String | Regex, escape : Bool = true, &block : String -> _) : Nil\n    phrases = [phrase] of String | Regex\n    highlight(text, phrases, highlighter: block, escape: escape)\n  end\n\n  # Wraps text in whatever you'd like based on line breaks\n  #\n  # **Note: This method writes HTML directly to the page. It does not return a\n  # String**\n  #\n  # ```\n  # simple_format(\"foo\\n\\nbar\\n\\nbaz\") do |paragraph|\n  #   text paragraph\n  #   hr\n  # end\n  # ```\n  # outputs:\n  # ```html\n  # foo<hr>\n  #\n  # bar<hr>\n  #\n  # baz<hr>\n  # ```\n  def simple_format(text : String, & : String -> _) : Nil\n    paragraphs = split_paragraphs(text)\n\n    paragraphs = [\"\"] if paragraphs.empty?\n\n    paragraphs.each do |paragraph|\n      yield paragraph\n      raw \"\\n\\n\" unless paragraph == paragraphs.last\n    end\n    view\n  end\n\n  # Wraps text in paragraphs based on line breaks\n  #\n  # ```\n  # simple_format(\"foo\\n\\nbar\\n\\nbaz\")\n  # ```\n  # outputs:\n  # ```html\n  # <p>foo</p>\n  #\n  # <p>bar</p>\n  #\n  # <p>baz</p>\n  # ```\n  def simple_format(text : String, escape : Bool = true, **html_options) : Nil\n    text = escape ? HTML.escape(text) : text\n\n    simple_format(text) do |formatted_text|\n      para(html_options) do\n        raw formatted_text\n      end\n    end\n  end\nend\n"
  },
  {
    "path": "src/lucky/page_helpers/number_to_currency.cr",
    "content": "module Lucky::NumberToCurrency\n  DEFAULT_PRECISION       = 2\n  DEFAULT_UNIT            = \"$\"\n  DEFAULT_SEPARATOR       = \".\"\n  DEFAULT_DELIMITER       = \",\"\n  DEFAULT_DELIMITER_REGEX = /(\\d)(?=(\\d\\d\\d)+(?!\\d))/\n  DEFAULT_FORMAT          = \"%u%n\"\n\n  def number_to_currency(value : Float | Int32 | String,\n                         precision : Int32 = DEFAULT_PRECISION,\n                         unit : String = DEFAULT_UNIT,\n                         separator : String = DEFAULT_SEPARATOR,\n                         delimiter : String = DEFAULT_DELIMITER,\n                         delimiter_pattern : Regex = DEFAULT_DELIMITER_REGEX,\n                         format : String = DEFAULT_FORMAT,\n                         negative_format : String = DEFAULT_FORMAT) : String\n    value = value.to_s\n\n    if value.to_f.sign == -1\n      format = negative_format if negative_format != DEFAULT_FORMAT\n      value = value.to_f.abs.to_s\n    end\n\n    value = \"%.#{precision}f\" % value\n\n    left, right = value.split(\".\")\n    left = left.gsub(delimiter_pattern) do |digit_to_delimit|\n      \"#{digit_to_delimit}#{delimiter}\"\n    end\n\n    number = \"#{left}#{separator}#{right}\"\n\n    format.gsub(\"%n\", number).gsub(\"%u\", unit)\n  end\nend\n"
  },
  {
    "path": "src/lucky/page_helpers/render_if_defined.cr",
    "content": "module Lucky::RenderIfDefined\n  macro render_if_defined(method_name)\n    if self.responds_to?(:{{ method_name.id }})\n      self.{{ method_name.id }}()\n    end\n  end\nend\n"
  },
  {
    "path": "src/lucky/page_helpers/svg_inliner.cr",
    "content": "annotation Lucky::SvgInliner::Path\nend\nannotation Lucky::SvgInliner::StripRegex\nend\n\n@[Lucky::SvgInliner::Path(\"src/svgs\")]\n@[Lucky::SvgInliner::StripRegex(/(class|fill|stroke|stroke-width|style)=\"[^\"]+\" ?/)]\nmodule Lucky::SvgInliner\n  macro inline_svg(path, strip_styling = true, **named_args)\n    {%\n      svgs_path = Lucky::SvgInliner.annotation(Lucky::SvgInliner::Path).args.first\n      regex = Lucky::SvgInliner.annotation(Lucky::SvgInliner::StripRegex).args.first\n      path = \"#{path.id}.svg\" unless path.ends_with?(\".svg\")\n      full_path = \"#{svgs_path.id}/#{path.id}\"\n\n      raise \"SVG file #{full_path.id} is missing\" unless file_exists?(full_path)\n\n      # Strip the XML declaration, comments, and whitespace\n      svg = read_file(full_path)\n        .gsub(/<\\?xml[^>]+>/, \"\")\n        .gsub(/<!--[^>]+>/, \"\")\n        .gsub(/\\n\\s*/, \" \")\n        .strip\n\n      # Strip styling using the given regex, if required\n      svg = svg.gsub(regex, \"\") if strip_styling\n      modifier = strip_styling ? \"\" : \"-styled\"\n\n      # Build new attributes for svg tag\n      attributes = [%(data-inline-svg#{modifier.id}=\"#{path.id}\")]\n      named_args.each do |name, value|\n        attributes << %(#{name.stringify.gsub(/_/, \"-\").id}=\"#{value.id}\")\n      end\n    %}\n\n    raw {{svg.gsub(/<svg/, %(<svg #{attributes.join(\" \").id}))}}\n  end\nend\n"
  },
  {
    "path": "src/lucky/page_helpers/text_helpers.cr",
    "content": "# These helper methods will return a `String`.\nmodule Lucky::TextHelpers\n  @@_cycles = Hash(String, Cycle).new\n\n  # Shorten text after a length point.\n  #\n  # Unlike `truncate`, this method can be used inside of other tags because it\n  # returns a String. See `truncate` method for argument documentation.\n  #\n  # ```\n  # link \"#\" do\n  #   text truncate_text(\"Four score and seven years ago\", length: 27)\n  # end\n  # ```\n  # outputs:\n  # ```html\n  # <a href=\\\"#\\\">Four score and se...</a>\n  # ```\n  def truncate_text(text : String, length : Int32 = 30, omission : String = \"...\", separator : String | Nil = nil) : String\n    return text unless text.size > length\n\n    length_with_room_for_omission = length - omission.size\n    stop = \\\n       if separator\n         text.rindex(separator, length_with_room_for_omission) || length_with_room_for_omission\n       else\n         length_with_room_for_omission\n       end\n\n    \"#{text[0, stop]}#{omission}\"\n  end\n\n  # Grab a window of longer string\n  #\n  # You'll need to specify a `phrase` to center on, either a Regex or a String.\n  #\n  # Optionally:\n  # * A `radius` (default: `100`) which controls how many units out from the\n  # `phrase` on either side the excerpt will be.\n  # * A `separator` (default `\"\"`) which controls what the `radius` will count.\n  # The unit by default is any character, which means the default is 100\n  # character from the `phrase` in either direction. For example, an excerpt of # 10 words would use a `radius` of 10 and a `separator` of `\" \"`.\n  # * An `omission` string (default: `\"...\"`), which prepends and appends to\n  # the excerpt.\n  #\n  # ```\n  # lyrics = \"We represent the Lolly pop Guild, The Lolly pop Guild\"\n  # excerpt(text, phrase: \"Guild\", radius: 10)\n  # ```\n  # outputs:\n  # ```html\n  # ...Lolly pop Guild, The Loll...\n  # ```\n  def excerpt(text : String, phrase : Regex | String, separator : String = \"\", radius : Int32 = 100, omission : String = \"...\") : String\n    if text.nil? || text.to_s.blank?\n      return \"\"\n    end\n\n    case phrase\n    when Regex\n      regex = phrase\n    else\n      regex = /#{Regex.escape(phrase.to_s)}/i\n    end\n\n    return \"\" unless matches = text.match(regex)\n    phrase = matches[0]\n\n    unless separator.empty?\n      text.split(separator).each do |value|\n        if value.match(regex)\n          phrase = value\n          break\n        end\n      end\n    end\n\n    first_part, second_part = text.split(phrase, 2)\n\n    prefix, first_part = cut_excerpt_part(:first, first_part, separator, radius, omission)\n    postfix, second_part = cut_excerpt_part(:second, second_part, separator, radius, omission)\n\n    affix = [first_part, separator, phrase, separator, second_part].join.strip\n    [prefix, affix, postfix].join\n  end\n\n  # It pluralizes `singular` unless `count` is 1. You can specify the `plural` option\n  # to override the chosen plural word.\n  def pluralize(count : Int | String | Nil, singular : String, plural = nil) : String\n    word = if (count == 1) || (count =~ /^1(\\.0+)?$/)\n             singular\n           else\n             plural || Wordsmith::Inflector.pluralize(singular)\n           end\n\n    \"#{count || 0} #{word}\"\n  end\n\n  def word_wrap(text : String, line_width : Int32 = 80, break_sequence : String = \"\\n\") : String\n    text = text.split(\"\\n\").map do |line|\n      line.size > line_width ? line.gsub(/(.{1,#{line_width}})(\\s+|$)/, \"\\\\1#{break_sequence}\").strip : line\n    end\n    text.join(break_sequence)\n  end\n\n  # Creates a comma-separated sentence from the provided `Enumerable` *list*\n  # and appends it to the view.\n  #\n  # #### Options:\n  #\n  # The following options allow you to specify how the sentence is constructed:\n  #   - *word_connector* - A string used to join the elements in *list*s\n  # containing three or more elements (Default is \", \")\n  #   - *two_word_connector* - A string used to join the elements in *list*s\n  # containing exactly two elements (Default is \" and \")\n  #   - *last_word_connector* - A string used to join the last element in\n  # *list*s containing three or more elements (Default is \", and \")\n  #\n  # #### Examples:\n  #\n  #     to_sentence([] of String)            # => \"\"\n  #     to_sentence([1])                     # => \"1\"\n  #     to_sentence([\"one\", \"two\"])          # => \"one and two\"\n  #     to_sentence({\"one\", \"two\", \"three\"}) # => \"one, two, and three\"\n  #\n  #     to_sentence([\"one\", \"two\", \"three\"], word_connector: \" + \")\n  #     # => one + two, and three\n  #\n  #     to_sentence(Set{\"a\", \"z\"}, two_word_connector: \" to \")\n  #     # => a to z\n  #\n  #     to_sentence(1..3, last_word_connector: \", or \")\n  #     # => 1, 2, or 3\n  #\n  # NOTE: By default `#to_sentence` will include a\n  # [serial comma](https://en.wikipedia.org/wiki/Serial_comma). This can be\n  # overridden like so:\n  #\n  #     to_sentence([\"one\", \"two\", \"three\"], last_word_connector: \" and \")\n  #     # => one, two and three\n  def to_sentence(list : Enumerable,\n                  word_connector : String = \", \",\n                  two_word_connector : String = \" and \",\n                  last_word_connector : String = \", and \") : String\n    list = list.to_a\n\n    if list.size < 3\n      return list.join(two_word_connector)\n    end\n\n    \"#{list[0..-2].join(word_connector)}#{last_word_connector}#{list.last}\"\n  end\n\n  private def normalize_values(values : Enumerable) : Array(String)\n    string_values = Array(String).new\n    values.each { |v| string_values << v.to_s }\n    string_values\n  end\n\n  def cycle(values : Array, name = \"default\") : String\n    values = normalize_values(values)\n    cycle = get_cycle(name)\n    unless cycle && cycle.values == values\n      cycle = set_cycle(name, Cycle.new(values))\n    end\n    cycle.to_s\n  end\n\n  def cycle(*values, name : String = \"default\") : String\n    values = normalize_values(values)\n    cycle(values, name: name)\n  end\n\n  def current_cycle(name : String = \"default\") : String?\n    cycle = get_cycle(name)\n    cycle.current_value if cycle\n  end\n\n  def reset_cycle(name : String = \"default\") : Int32?\n    cycle = get_cycle(name)\n    cycle.reset if cycle\n  end\n\n  class Cycle\n    @values : Array(String)\n    getter :values\n    @index = 0\n\n    def initialize(*values)\n      string_values = Array(String).new\n      values.each { |v| string_values << v.to_s }\n      @values = string_values\n      reset\n    end\n\n    def initialize(values : Array(String))\n      @values = Array(String).new\n      @values = values\n      reset\n    end\n\n    def reset : Int32\n      @index = 0\n    end\n\n    def current_value : String\n      @values[previous_index]?.to_s\n    end\n\n    def to_s(io : IO)\n      io << @values[@index]?\n      @index = next_index\n    end\n\n    private def next_index : Int32\n      step_index(1)\n    end\n\n    private def previous_index : Int32\n      step_index(-1)\n    end\n\n    private def step_index(n : Int32) : Int32\n      (@index + n) % @values.size\n    end\n  end\n\n  def reset_cycles : Hash(String, Cycle)\n    @@_cycles = Hash(String, Cycle).new\n  end\n\n  private def get_cycle(name : String) : Cycle?\n    @@_cycles[name]?\n  end\n\n  private def set_cycle(name : String, cycle_object : Cycle) : Cycle\n    @@_cycles[name] = cycle_object\n  end\n\n  private def cut_excerpt_part(part_position : Symbol, part : String | Nil, separator : String, radius : Int32, omission : String)\n    return \"\", \"\" if part.nil?\n\n    part = part.split(separator)\n    part.delete(\"\")\n    affix = part.size > radius ? omission : \"\"\n\n    part = if part_position == :first\n             drop_index = [part.size - radius, 0].max\n             part[drop_index..-1]\n           else\n             part.first(radius)\n           end\n\n    return affix, part.join(separator)\n  end\n\n  private def split_paragraphs(text : String)\n    return Array(String).new if text.blank?\n\n    text.to_s.gsub(/\\r\\n?/, \"\\n\").split(/\\n\\n+/).map do |line|\n      line.gsub(/([^\\n]\\n)(?=[^\\n])/, \"\\\\1<br >\") || line\n    end\n  end\nend\n"
  },
  {
    "path": "src/lucky/page_helpers/time_helpers.cr",
    "content": "module Lucky::TimeHelpers\n  # Returns a `String` with approximate distance in time between `from` and `to`.\n  #\n  # ```\n  # distance_of_time_in_words(Time.utc(2019, 8, 14, 10, 0, 0), Time.utc(2019, 8, 14, 10, 0, 5))\n  # # => \"5 seconds\"\n  # distance_of_time_in_words(Time.utc(2019, 8, 14, 10, 0), Time.utc(2019, 8, 14, 10, 25))\n  # # => \"25 minutes\"\n  # distance_of_time_in_words(Time.utc(2019, 8, 14, 10), Time.utc(2019, 8, 14, 11))\n  # # => \"an hour\"\n  # distance_of_time_in_words(Time.utc(2019, 8, 14), Time.utc(2019, 8, 16))\n  # # => \"2 days\"\n  # distance_of_time_in_words(Time.utc(2019, 8, 14), Time.utc(2019, 10, 4))\n  # # => \"about a month\"\n  # distance_of_time_in_words(Time.utc(2019, 8, 14), Time.utc(2061, 10, 4))\n  # # => \"almost 42 years\"\n  # ```\n  def distance_of_time_in_words(from : Time, to : Time) : String\n    distance_of_time_in_words(to - from)\n  end\n\n  # :ditto:\n  def distance_of_time_in_words(span : Time::Span) : String\n    minutes = span.minutes\n    seconds = span.seconds\n    hours = span.hours\n    days = span.days\n\n    return distance_in_days(days) if days != 0\n    return distance_in_hours(hours, minutes) if hours != 0\n    return distance_in_minutes(minutes) if minutes != 0\n\n    distance_in_seconds(seconds)\n  end\n\n  # Returns a `String` with approximate distance in time between `from` and current moment.\n\n  def time_ago_in_words(from : Time) : String\n    distance_of_time_in_words(from, Time.utc)\n  end\n\n  # Returns a `String` with approximate distance in time between current moment and future date.\n  #\n  # ```\n  # time_from_now_in_words(Time.utc(2022, 8, 30)) # => \"about a year\"\n  # # gives the same result as:\n  # distance_of_time_in_words(Time.utc, Time.utc(2022, 8, 30)) # => \"about a year\"\n  # ```\n  #\n  # See more examples in `#distance_of_time_in_words`.\n  def time_from_now_in_words(to : Time) : String\n    distance_of_time_in_words(Time.utc, to)\n  end\n\n  private def distance_in_days(distance : Int) : String\n    case distance\n    when 1...27   then distance == 1 ? \"a day\" : \"#{distance} days\"\n    when 27...60  then \"about a month\"\n    when 60...365 then \"#{(distance / 30).round.to_i} months\"\n    when 365...730\n      \"about a year\"\n    when 730...1460\n      \"over #{(distance / 365).round.to_i} years\"\n    else\n      \"almost #{(distance / 365).round.to_i} years\"\n    end\n  end\n\n  private def distance_in_hours(hours : Int32, minutes : Int32) : String\n    if minutes >= 45\n      \"almost #{hours + 1} hours\"\n    elsif hours == 1\n      \"an hour\"\n    else\n      \"#{hours} hours\"\n    end\n  end\n\n  private def distance_in_minutes(distance : Int32) : String\n    case distance\n    when 1      then \"a minute\"\n    when 2...45 then \"#{distance} minutes\"\n    else\n      \"about an hour\"\n    end\n  end\n\n  private def distance_in_seconds(distance : Int32) : String\n    distance == 1 ? \"a second\" : \"#{distance} seconds\"\n  end\nend\n"
  },
  {
    "path": "src/lucky/page_helpers/url_helpers.cr",
    "content": "module Lucky::UrlHelpers\n  # Tests if the given path matches the current request path.\n  #\n  # ```\n  # # Let's say we are visiting https://example.com/shop/products?order=desc&page=1\n  # current_page?(\"/shop/checkout\")\n  # # => false\n  # current_page?(\"/shop/products\")\n  # # => true\n  # current_page?(\"/shop/products/\")\n  # # => true\n  # current_page?(\"/shop/products?order=desc&page=1\")\n  # # => true\n  # current_page?(\"/shop/products\", check_query_params: true)\n  # # => false\n  # current_page?(\"/shop/products?order=desc&page=1\", check_query_params: true)\n  # # => true\n  # current_page?(\"https://example.com/shop/products\")\n  # # => true\n  # current_page?(\"https://example.io/shop/products\")\n  # # => false\n  # current_page?(\"https://example.com/shop/products\", check_query_params: true)\n  # # => false\n  # current_page?(\"https://example.com/shop/products?order=desc&page=1\")\n  # # => true\n  # ```\n  def current_page?(\n    value : String,\n    check_query_params : Bool = false,\n  ) : Bool\n    request = context.request\n\n    return false unless {\"GET\", \"HEAD\"}.includes?(request.method)\n\n    uri = URI.parse(value)\n    request_uri = URI.parse(request.resource)\n    path = uri.path\n    resource = request_uri.path\n\n    unless path == \"/\"\n      path = path.chomp(\"/\")\n      resource = resource.chomp(\"/\")\n    end\n\n    if check_query_params\n      path += comparable_query_params(uri.query_params)\n      resource += comparable_query_params(request_uri.query_params)\n    end\n\n    if value.match(/^\\w+:\\/\\//)\n      host_with_port = uri.port ? \"#{uri.host}:#{uri.port}\" : uri.host\n      \"#{host_with_port}#{path}\" == \"#{request.headers[\"Host\"]?}#{resource}\"\n    else\n      path == resource\n    end\n  end\n\n  # Tests if the given path matches the current request path.\n  #\n  # ```\n  # # Visiting https://example.com/pages/123\n  # current_page?(Pages::Show.with(123))\n  # # => true\n  # current_page?(Posts::Show.with(123))\n  # # => false\n  # # Visiting https://example.com/pages\n  # current_page?(Pages::Index)\n  # # => true\n  # current_page?(Blog::Index)\n  # # => false\n  # # Visiting https://example.com/pages?page=2\n  # current_page?(Pages::Index.with)\n  # # => true\n  # current_page?(Pages::Index.with(page: 2))\n  # # => true\n  # current_page?(Pages::Index.with, check_query_params: true)\n  # # => false\n  # current_page?(Pages::Index.with(page: 2), check_query_params: true)\n  # # => true\n  # ```\n  def current_page?(\n    action : Lucky::Action.class | Lucky::RouteHelper,\n    check_query_params : Bool = false,\n  ) : Bool\n    current_page?(action.path, check_query_params)\n  end\n\n  # Returns the url of the page that issued the request (the referrer)\n  # if possible, otherwise redirects to the provided default fallback\n  # location.\n  #\n  # The referrer information is pulled from the 'Referer' header on\n  # the request. This is an optional header, and if the request\n  # is missing this header the *fallback* will be used.\n  #\n  # Ex. within a Lucky Page, previous_url can be used to provide an href\n  # to an anchor element that would allow the user to go back.\n  # ```\n  # a \"Back\", href: previous_url(fallback: Users::Index)\n  # ```\n  def previous_url(fallback : Lucky::Action.class | Lucky::RouteHelper) : String\n    request = context.request\n\n    if referrer_uri = referrer_header\n      referrer_path = URI.parse(referrer_uri).path\n      return fallback.path if request.path == referrer_path\n      return referrer_uri\n    end\n\n    fallback.path\n  end\n\n  private def comparable_query_params(query_params : HTTP::Params) : String\n    URI.decode(query_params.map(&.join).sort!.join)\n  end\n\n  private def referrer_header : String?\n    request = context.request\n    return unless request.headers.has_key?(\"Referer\")\n\n    referrer = request.headers[\"Referer\"]\n    referrer if referrer.is_a?(String)\n  end\nend\n"
  },
  {
    "path": "src/lucky/paginator/backend_helpers.cr",
    "content": "module Lucky::Paginator::BackendHelpers\n  # Call this in your actions to paginate an array.\n  #\n  # This method will return a `Lucky::Paginator` object and the requested page\n  # of items.\n  #\n  # ## Examples\n  #\n  # ```\n  # class ListItems::Index < BrowserAction\n  #   get \"/items\" do\n  #     # The 'Array' will just show items for the requested page\n  #     pages, items = paginate_array([1, 2, 3])\n  #     render IndexPage, pages: pages, items: items\n  #   end\n  # end\n  #\n  # class Users::IndexPage < MainLayout\n  #   needs pages : Lucky::Paginator\n  #   needs items : Array(Int32)\n  #\n  #   def content\n  #     # Render pagination links for the 'items' Array\n  #     mount Lucky::Paginator::SimpleNav, @pages\n  #   end\n  # end\n  # ```\n  def paginate_array(\n    items : Array(T),\n    per_page : Int32 = paginator_per_page,\n  ) : Tuple(Paginator, Array(T)) forall T\n    pages = Paginator.new \\\n      page: paginator_page,\n      per_page: per_page,\n      item_count: items.size,\n      full_path: context.request.resource\n\n    return {pages, Array(T).new} if pages.overflowed?\n\n    updated_items = items[pages.offset...pages.offset + pages.per_page]\n    {pages, updated_items}\n  end\n\n  # Returns the page that was request, or `1`\n  #\n  # By default this method looks for a `page` param. It can be given as a\n  # query param, or in the body. If no `page` param is given the page will be `1`.\n  #\n  # You can override this method in your action in any way you'd like.\n  #\n  # ## Example\n  #\n  # ```\n  # abstract class ApiAction < Lucky::Action\n  #   include Lucky::Paginator::BackendHelpers\n  #\n  #   def paginator_page : Int32\n  #     # Will use the \"Page\" header or fallback to default if missing.\n  #     request.headers[\"Page\"]? || super\n  #   end\n  # end\n  # ```\n  def paginator_page : Int32\n    params.get?(:page).try(&.to_i) || 1\n  end\n\n  # The number of records to display per page. Defaults to `25`\n  #\n  # You can override this in your actions\n  #\n  # ## Example\n  #\n  # ```\n  # abstract class BrowserAction < Lucky::Action\n  #   include Lucky::Paginator::BackendHelpers\n  #\n  #   # Set to a new static value\n  #   def paginator_per_page : Int32\n  #     50 # defaults to 25\n  #   end\n  #\n  #   # Or you could allow setting the number from a param\n  #   def paginator_per_page : Int32\n  #     params.get?(:per_page).try(&.to_i) || 25\n  #   end\n  # end\n  # ```\n  def paginator_per_page : Int32\n    # Override this to set something custom or allow a param to be set\n    25\n  end\nend\n"
  },
  {
    "path": "src/lucky/paginator/components/bootstrap_nav.cr",
    "content": "# Pagination component using Bootstrap styles\n#\n# https://getbootstrap.com/docs/4.0/components/pagination/\nclass Lucky::Paginator::BootstrapNav < Lucky::BaseComponent\n  needs pages : Lucky::Paginator\n\n  def render\n    nav aria_label: \"pagination\", role: \"navigation\" do\n      ul class: \"pagination\", aria_label: \"pagination\" do\n        previous_link\n        page_links\n        next_link\n      end\n    end\n  end\n\n  def page_links\n    @pages.series(begin: 1, left_of_current: 1, right_of_current: 1, end: 1).each do |item|\n      render_page_item(item)\n    end\n  end\n\n  def render_page_item(page : Lucky::Paginator::Page)\n    li class: \"page-item\" do\n      a page.number, href: page.path, class: \"page-link\"\n    end\n  end\n\n  def render_page_item(page : Lucky::Paginator::CurrentPage)\n    li class: \"page-item active disabled\" do\n      a page.number, href: page.path, class: \"page-link\"\n    end\n  end\n\n  def render_page_item(gap : Lucky::Paginator::Gap)\n    li class: \"page-item\" do\n      a class: \"page-link disabled\" { raw \"&hellip;\" }\n    end\n  end\n\n  def previous_link\n    li class: \"page-item #{\"disabled\" if @pages.first_page?}\" do\n      a \"Previous\", href: @pages.path_to_previous.to_s, class: \"page-link\"\n    end\n  end\n\n  def next_link\n    li class: \"page-item #{\"disabled\" if @pages.last_page?}\" do\n      a \"Next\", href: @pages.path_to_next.to_s, class: \"page-link\"\n    end\n  end\nend\n"
  },
  {
    "path": "src/lucky/paginator/components/bulma_nav.cr",
    "content": "# Pagination component using Bulma Pagination styles\n#\n# https://bulma.io/documentation/components/pagination/\nclass Lucky::Paginator::BulmaNav < Lucky::BaseComponent\n  needs pages : Lucky::Paginator\n\n  def render\n    nav aria_label: \"pagination\", class: \"pagination\", role: \"navigation\" do\n      ul class: \"pagination-list\" do\n        previous_link\n        page_links\n        next_link\n      end\n    end\n  end\n\n  def page_links\n    @pages.series(begin: 1, left_of_current: 1, right_of_current: 1, end: 1).each do |item|\n      render_page_item(item)\n    end\n  end\n\n  def render_page_item(page : Lucky::Paginator::Page)\n    li { a page.number, href: page.path, class: \"pagination-link\" }\n  end\n\n  def render_page_item(page : Lucky::Paginator::CurrentPage)\n    li { a page.number, href: page.path, class: \"pagination-link is-current\" }\n  end\n\n  def render_page_item(gap : Lucky::Paginator::Gap)\n    li do\n      span class: \"pagination-ellipsis\" { raw \"&hellip;\" }\n    end\n  end\n\n  def previous_link\n    li { a \"Previous\", href: @pages.path_to_previous.to_s, class: \"pagination-previous\" }\n  end\n\n  def next_link\n    li { a \"Next\", href: @pages.path_to_next.to_s, class: \"pagination-next\" }\n  end\nend\n"
  },
  {
    "path": "src/lucky/paginator/components/simple_nav.cr",
    "content": "# Pagination component with raw html and no styling\n#\n# Typically you would copy paste this component source into your app\n# and modify it to suite your needs.\nclass Lucky::Paginator::SimpleNav < Lucky::BaseComponent\n  needs pages : Lucky::Paginator\n\n  def render\n    nav aria_label: \"pagination\", role: \"navigation\" do\n      ul do\n        previous_link\n        page_links\n        next_link\n      end\n    end\n  end\n\n  def page_links\n    @pages.series(begin: 1, left_of_current: 1, right_of_current: 1, end: 1).each do |item|\n      render_page_item(item)\n    end\n  end\n\n  def render_page_item(page : Lucky::Paginator::Page)\n    li do\n      a page.number, href: page.path\n    end\n  end\n\n  def render_page_item(page : Lucky::Paginator::CurrentPage)\n    li do\n      text page.number\n    end\n  end\n\n  def render_page_item(gap : Lucky::Paginator::Gap)\n    li \"...\"\n  end\n\n  def previous_link\n    if prev_path = @pages.path_to_previous\n      li { a \"Previous\", href: prev_path }\n    else\n      li \"Previous\"\n    end\n  end\n\n  def next_link\n    if path_to_next = @pages.path_to_next\n      li { a \"Next\", href: path_to_next }\n    else\n      li \"Next\"\n    end\n  end\nend\n"
  },
  {
    "path": "src/lucky/paginator/current_page.cr",
    "content": "require \"./page\"\n\nclass Lucky::Paginator::CurrentPage < Lucky::Paginator::Page\nend\n"
  },
  {
    "path": "src/lucky/paginator/gap.cr",
    "content": "# :nodoc:\n#\n# Used to represent a gap in a pagination series\nclass Lucky::Paginator::Gap\n  def ==(other : self) : Bool\n    true # All gaps are equal to each other\n  end\nend\n"
  },
  {
    "path": "src/lucky/paginator/page.cr",
    "content": "class Lucky::Paginator::Page\n  def_equals number\n  getter number\n\n  def initialize(@pages : Lucky::Paginator, @number : Int32 | Int64)\n  end\n\n  def path : String\n    @pages.path_to_page(number)\n  end\nend\n"
  },
  {
    "path": "src/lucky/paginator/paginator.cr",
    "content": "class Lucky::Paginator\n  @page : Int32\n  getter per_page : Int32\n  getter item_count : Int32 | Int64\n  getter full_path : String\n\n  alias SeriesItem = Gap | Page | CurrentPage\n\n  def initialize(@page, @per_page, @item_count, @full_path)\n  end\n\n  # Returns the current page. Return `1` if the passed in `page` is lower than `1`.\n  def page : Int32\n    if @page < 1\n      @page = 1\n    else\n      # Later: Make option to raise if 'overflowed?'\n      @page\n    end\n  end\n\n  # Returns `true` if there is just one page.\n  def one_page? : Bool\n    total == 1\n  end\n\n  def offset : Int32\n    per_page * (page - 1)\n  end\n\n  # Returns the total number of pages.\n  def total : Int64\n    (item_count / per_page).ceil.to_i64\n  end\n\n  # Returns `true` if current `page` is the last one.\n  def last_page? : Bool\n    page == total\n  end\n\n  # Returns `true` if the current `page` is the first one.\n  def first_page? : Bool\n    page == 1\n  end\n\n  # Returns `true` if the current `page` is past the last page.\n  def overflowed? : Bool\n    page > total\n  end\n\n  # Returns the `Range` of items on this page.\n  #\n  # For example if you have 50 records, showing 20 per page, and\n  # you are on the 2nd page this method will return a range of 21-40.\n  #\n  # You can get the beginning and end by calling `begin` or `end` on the\n  # returned `Range`.\n  def item_range : Range\n    starting_item_number = ((page - 1) * per_page) + 1\n    ending_item_number = [(starting_item_number + per_page - 1), item_count].min\n    Range.new(starting_item_number, ending_item_number)\n  end\n\n  # Returns the previous page number or nil if the current page is the first one.\n  def previous_page : Int32?\n    page - 1 unless first_page?\n  end\n\n  # Returns the next page number or nil if the current page is the last one.\n  def next_page : Int32?\n    page + 1 unless last_page? || overflowed?\n  end\n\n  # Returns the path with a 'page' query param for the previous page.\n  #\n  # Return nil if there is no previous page\n  def path_to_previous : String?\n    if page_number = previous_page\n      path_to_page(page_number)\n    end\n  end\n\n  # Returns the path with a 'page' query param for the previous page.\n  #\n  # Return nil if there is no previous page\n  def path_to_next : String?\n    if page_number = next_page\n      path_to_page(page_number)\n    end\n  end\n\n  # Generate a page with the 'page' query param set to the passed in `page_number`.\n  #\n  # ## Examples\n  #\n  # ```\n  # pages = Paginator.new(\n  #   page: 1,\n  #   per_page: 25,\n  #   item_count: 70,\n  #   full_path: \"/comments\"\n  # )\n  # pages.path_to_page(2) # \"/comments?page=2\"\n  # ```\n  def path_to_page(page_number : Int) : String\n    uri = URI.parse(full_path)\n    query_params = uri.query_params\n    query_params[\"page\"] = page_number.to_s\n    uri.query = query_params.to_s\n    uri.to_s\n  end\n\n  # Returns a series of pages and gaps\n  #\n  # This method calculates a series of pages and gaps based on how many pages\n  # there are, and what the current page is. It uses the\n  # `begin|left_of_current|right_of_current|end` arguments to customize the returned\n  # series of pages and gaps. The series is made up of `Lucky::Paginator::Gap`,\n  # `Lucky::Paginator::Page` and `Lucky::Paginator::CurrentPage` objects.\n  #\n  # The best way to describe how this works is with an example. Let's say you\n  # have 10 pages of items and you are requesting page 5.\n  #\n  # > Note we will simplify the objects by using integers and \"..\" in place of the\n  # > `Gap|Page|CurrentPage` objects. We'll show an example with the real\n  # objects further down\n  #\n  # ```\n  # series = pages.series(begin: 1, left_of_current: 1, right_of_current: 1, end: 1)\n  # series # [1, .., 4, 5, 6, .., 10]\n\n  # # All args default to 0 so you can leave them off. That means `begin|end`\n  # # are 0 in this example.\n  # series = pages.series(left_of_current: 1, right_of_current: 1)\n  # series # [4, 5, 6]\n\n  # # The current page is always shown\n  # series = pages.series(begin: 2, end: 2)\n  # series # [1, 2, .., 5, .., 9, 10]\n\n  # # The `series` method is smart and will not add gaps if there is no gap.\n  # # It will also not add items past the current page.\n  # series = pages.series(begin: 6)\n  # series # [1, 2, 3, 4, 5]\n  # ```\n  #\n  # As mentioned above the **actual** objects in the Array are made up of\n  # `Lucky::Paginator::Gap`, `Lucky::Paginator::Page`, and\n  # `Lucky::Paginator::CurrentPage` objects.\n  #\n  # ```\n  # pages.series(begin: 1, end: 1)\n  # # Returns:\n  # # [\n  # #   Lucky::Paginator::Page(1),\n  # #   Lucky::Paginator::Gap,\n  # #   Lucky::Paginator::CurrentPage(5),\n  # #   Lucky::Paginator::Gap,\n  # #   Lucky::Paginator::Page(10),\n  # # ]\n  # ```\n  #\n  # The `Page` and `CurrentPage` objects have a `number` and `path` method.\n  # `Page#number` returns the number of the page as an Int. The `Page#path` method\n  # Return the path to the next page.\n  #\n  # The `Gap` object has no methods or instance variables. It is there to\n  # represent a \"gap\" of pages.\n  #\n  # These objects make it easy to use [method # overloading](https://crystal-lang.org/reference/syntax_and_semantics/overloading.html)\n  # or `is_a?` to determine how to render each item.\n  #\n  # Here's a quick example:\n  #\n  # ```\n  # pages.series(begin: 1, end: 1).each do |item|\n  #   case item\n  #   when Lucky::Paginator::CurrentPage | Lucky::Paginator::Page\n  #     pp! item.number # Int32 representing the page number\n  #     pp! item.path   # \"/items?page=2\"\n  #   when Lucky::Paginator::Gap\n  #     puts \"...\"\n  #   end\n  # end\n  # ```\n  #\n  # Or use method overloading. This will show an example using Lucky's HTML methods:\n  #\n  # ```\n  # class PageNav < BaseComponent\n  #   needs pages : Lucky::Paginator\n  #\n  #   def render\n  #     pages.series(begin: 1, end: 1).each do |item|\n  #       page_item(item)\n  #     end\n  #   end\n  #\n  #   def page_item(page : Lucky::Paginator::CurrentPage)\n  #     # If it is the current page, just display text and no link\n  #     text page.number\n  #   end\n  #\n  #   def page_item(page : Lucky::Paginator::CurrentPage)\n  #     a page.number, href: page.path\n  #   end\n  #\n  #   def page_item(gap : Lucky::Paginator::Gap)\n  #     text \"..\"\n  #   end\n  # end\n  # ```\n  def series(\n    begin beginning : Int32 = 0,\n    left_of_current : Int32 = 0,\n    right_of_current : Int32 = 0,\n    end ending : Int32 = 0,\n  ) : Array(SeriesItem)\n    middle_pages = build_middle_of_series(left_of_current, right_of_current)\n    beginning_and_middle_pages = add_beginning_pages(middle_pages, beginning)\n    add_ending_pages(beginning_and_middle_pages, ending)\n  end\n\n  private def build_middle_of_series(\n    left_of_current : Int32,\n    right_of_current : Int32,\n  ) : Array(SeriesItem)\n    arr = [] of SeriesItem\n\n    # If given `left_of_current: 2` this would yield `2` then `1`\n    # If 0 it will not yield anything\n    left_of_current.downto(1) do |i|\n      # And page number would be `page - 2` then `page - 1`\n      # So if you are on page `10` you'd have `8`, then `9`\n      page_number = page - i\n      # Don't add a 0 or - page.\n      if page_number >= 1\n        arr << Page.new(self, page_number)\n      end\n    end\n\n    # Always add the current page\n    arr << CurrentPage.new(self, page)\n\n    # If given `right_of_current: 2` it would yield `1` then `2`\n    # If 0 it will not yield anything\n    1.upto(right_of_current) do |i|\n      # This would be `page + 1` then `page + 2`, etc.\n      # So if you are on page `10` and there are `15` pages you'd have `11` then `12`\n      page_number = page + i # + 1\n      # Don't add the page if it is greater than the total pages\n      # So if you're on page `10` with a total of `10` pages it will not add pages\n      # to the right\n      if page_number <= total\n        arr << Page.new(self, page_number)\n      end\n    end\n\n    arr\n  end\n\n  private def add_beginning_pages(arr : Array(SeriesItem), beginning : Int32) : Array(SeriesItem)\n    first_page_in_middle_section = (arr.first.as(Page).number)\n\n    # If beginning is set to 2 and the first page in the middle section is 4 add Gap: 2, .., 4\n    # If beginning is set to 2 and the first page in the middle section is 3, don't: 2, 3\n    if beginning > 0 && (beginning + 1) < first_page_in_middle_section\n      arr.unshift Gap.new\n    end\n\n    # If beginning is 2 it'll count from 2 down to 1. So it'll yield 2, then 1\n    beginning.downto(1) do |i|\n      page_number = i\n      if page_number < first_page_in_middle_section\n        # Unshift prepends the item to the beginning of the series\n        # Since we are counting down this would look something like:\n        #\n        # [3, 4, 5].unshift(2) # => [2, 3, 4, 5]\n        # [2, 3, 4, 5].unshift(1) # => [1, 2, 3, 4, 5]\n        arr.unshift Page.new(self, page_number)\n      end\n    end\n\n    arr\n  end\n\n  private def add_ending_pages(arr : Array(SeriesItem), ending : Int32) : Array(SeriesItem)\n    last_page_in_middle_section = (arr.last.as(Page).number)\n\n    # First determine the smallest ending page\n    # So if total pages is 20 and ending is 2 you'd get 18\n    smallest_ending_page = total - ending\n    # If smallest ending page is 18 and last page in middle is 20 then do add a gap: 18, .., 20\n    # If smallest ending page is 18 and last page in middle is 19 don't add a gap: 19, 20\n    if ending > 0 && smallest_ending_page > last_page_in_middle_section\n      arr << Gap.new\n    end\n\n    # If ending is 2 it'll count from 2 down to 1. So it'll yield 2, then 1\n    ending.downto(1) do |i|\n      # So if there are 10 total pages this would be `10 + 1 - 2` then `10 + 1 -1`\n      # Giving you `9` then `10`\n      page_number = total + 1 - i\n      if page_number > last_page_in_middle_section\n        arr << Page.new(self, page_number)\n      end\n    end\n\n    arr\n  end\nend\n"
  },
  {
    "path": "src/lucky/param_helpers.cr",
    "content": "module Lucky::ParamHelpers\n  memoize def params : Lucky::Params\n    context.params\n  end\nend\n"
  },
  {
    "path": "src/lucky/param_parser.cr",
    "content": "module Lucky::ParamParser\n  TIME_FORMATS = [\n    Time::Format::ISO_8601_DATE_TIME,\n    Time::Format::RFC_2822,\n    Time::Format::RFC_3339,\n    # HTML datetime-local inputs are basically RFC 3339 without the timezone:\n    # https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/datetime-local\n    Time::Format.new(\"%Y-%m-%dT%H:%M:%S\", Time::Location::UTC),\n    Time::Format.new(\"%Y-%m-%dT%H:%M\", Time::Location::UTC),\n    # Dates and times go last, otherwise it will parse strings with both\n    # dates *and* times incorrectly.\n    Time::Format::HTTP_DATE,\n    Time::Format::ISO_8601_DATE,\n    Time::Format::ISO_8601_TIME,\n  ]\n\n  def self.parse(param : String, klass : String.class) : String\n    param\n  end\n\n  def self.parse(param : String, klass : Int16.class) : Int16?\n    param.to_i16?\n  end\n\n  def self.parse(param : String, klass : Int32.class) : Int32?\n    param.to_i?\n  end\n\n  def self.parse(param : String, klass : Int64.class) : Int64?\n    param.to_i64?\n  end\n\n  def self.parse(param : String, klass : Float64.class) : Float64?\n    param.to_f?\n  end\n\n  def self.parse(param : String, klass : Bool.class) : Bool?\n    if %w(true 1).includes? param\n      true\n    elsif %w(false 0).includes? param\n      false\n    else\n      nil\n    end\n  end\n\n  def self.parse(param : String, klass : UUID.class) : UUID?\n    UUID.new(param)\n  rescue\n    nil\n  end\n\n  def self.parse(param : String, klass : Time.class) : Time?\n    TIME_FORMATS.each do |format|\n      begin\n        parsed = format.parse(param)\n        return parsed if parsed\n      rescue e : Time::Format::Error\n        nil\n      end\n    end\n  end\n\n  # Returns `Array(T)` if all params in `param` are properly cast\n  def self.parse(param : Array(String), klass : Array(T).class) : Array(T)? forall T\n    casts = param.compact_map { |val| parse(val, T) }\n\n    casts.size == param.size ? casts.as(Array(T)) : nil\n  end\nend\n"
  },
  {
    "path": "src/lucky/params.cr",
    "content": "class Lucky::Params\n  # :nodoc:\n  private getter request : HTTP::Request\n  # :nodoc:\n  private getter route_params : Hash(String, String)\n  setter :route_params\n\n  # Create a new params object\n  #\n  # The params object is initialized with an `HTTP::Request` and a hash of\n  # params. The request object has many optional parameters. See Crystal's\n  # [HTTP::Request](https://crystal-lang.org/api/latest/HTTP/Request.html)\n  # class for more details.\n  #\n  # ```\n  # request = HTTP::Request.new(\"GET\", \"/\")\n  # route_params = {\"token\" => \"123\"}\n  #\n  # Lucky::Params.new(request, route_params)\n  # ```\n  def initialize(@request : HTTP::Request, @route_params : Hash(String, String) = empty_params)\n  end\n\n  # Parses the request body as `JSON::Any` or raises `Lucky::ParamParsingError` if JSON is invalid.\n  #\n  # ```\n  # # {\"page\": 1}\n  # params.from_json[\"page\"].as_i # 1\n  # # {\"users\": [{\"name\": \"Skyler\"}]}\n  # params.from_json[\"users\"][0][\"name\"].as_s # \"Skyler\"\n  # ```\n  #\n  # See the crystal docs on\n  # [`JSON::Any`](https://crystal-lang.org/api/JSON/Any.html) for more on using\n  # JSON in Crystal.\n  #\n  # > You can also get JSON params with `Lucky::Params#get/nested`. Sometimes\n  # > `Lucky::Params` are not flexible enough. In those cases this method opens\n  # > the possibility to do just about anything with JSON.\n  def from_json : JSON::Any\n    parsed_json\n  end\n\n  # Returns just the query params as `URI::Params`\n  #\n  # Returns a `URI::Params` object for only the query params. This method is rarely\n  # helpful since you can get query params with `get`, but if you do need raw\n  # access to the query params this is the way to get them.\n  #\n  # ```\n  # params.from_query[\"search\"] # Will return the \"search\" query param\n  # ```\n  #\n  # See the docs on [`HTTP::Params`](https://crystal-lang.org/api/HTTP/Params.html) for more information.\n  def from_query : URI::Params\n    request.query_params\n  end\n\n  # Returns x-www-form-urlencoded body params as `URI::Params`\n  #\n  # Returns a `URI::Params` object for the request body. This method is rarely\n  # helpful since you can get query params with `get`, but if you do need raw\n  # access to the body params this is the way to get them.\n  #\n  # ```\n  # params.from_form_data[\"name\"]\n  # ```\n  #\n  # See the docs on [`URI::Params`](https://crystal-lang.org/api/URI/Params.html) for more information.\n  def from_form_data : URI::Params\n    form_params\n  end\n\n  # Returns multipart params and files.\n  #\n  # Return a Tuple with a hash of params and a hash of `Lucky::UploadedFile`.\n  # This method is rarely helpful since you can get params with `get` and files\n  # with `get_file`, but if you need something more custom you can use this method\n  # to get better access to the raw params.\n  #\n  # ```\n  # form_params = params.from_multipart.last # Hash(String, String)\n  # form_params[\"name\"]                      # \"Kyle\"\n  #\n  # files = params.from_multipart.last # Hash(String, Lucky::UploadedFile)\n  # files[\"avatar\"]                    # Lucky::UploadedFile\n  # ```\n  def from_multipart : Tuple(Hash(String, String), Hash(String, Lucky::UploadedFile))\n    form_data = parse_form_data\n    {form_data.params.to_h, form_data.files.to_h}\n  end\n\n  # Retrieve a trimmed value from the params hash, raise if key is absent\n  #\n  # If no key is found a `Lucky::MissingParamError` will be raised:\n  #\n  # ```\n  # params.get(\"name\")    # \"Paul\" : String\n  # params.get(\"page\")    # \"1\" : String\n  # params.get(\"missing\") # Missing parameter: missing\n  # ```\n  def get(key) : String\n    get_raw(key).strip\n  end\n\n  # Retrieve a trimmed value from the params hash, return nil if key is absent\n  #\n  # ```\n  # params.get?(\"missing\") # nil : (String | Nil)\n  # params.get?(\"page\")    # \"1\" : (String | Nil)\n  # params.get?(\"name\")    # \"Paul\" : (String | Nil)\n  # ```\n  def get?(key : String | Symbol) : String?\n    if value = get_raw?(key)\n      value.strip\n    end\n  end\n\n  # Retrieve a raw, untrimmed value from the params hash, raise if key is absent\n  #\n  # If no key is found a `Lucky::MissingParamError` will be raised:\n  #\n  # ```\n  # params.get_raw(\"name\")    # \" Paul \" : String\n  # params.get_raw(\"page\")    # \"1\" : String\n  # params.get_raw(\"missing\") # Missing parameter: missing\n  # ```\n  def get_raw(key) : String\n    get_raw?(key) || raise Lucky::MissingParamError.new(key.to_s)\n  end\n\n  # Retrieve a raw, untrimmed value from the params hash, return nil if key is\n  # absent\n  #\n  # ```\n  # params.get_raw?(\"missing\") # nil : (String | Nil)\n  # params.get_raw?(\"page\")    # \"1\" : (String | Nil)\n  # params.get_raw?(\"name\")    # \" Paul \" : (String | Nil)\n  # ```\n  def get_raw?(key : String | Symbol) : String?\n    route_params[key.to_s]? || body_param(key.to_s) || query_params[key.to_s]?\n  end\n\n  # Retrieve values for a given key\n  #\n  # Checks in places that could provide multiple values and returns first with values:\n  # - JSON body\n  # - multipart params\n  # - form encoded params\n  # - query params\n  #\n  # For all params locations it appends square brackets\n  # so searching for \"emails\" in query params will look for values with a key of \"emails[]\"\n  #\n  # If no key is found a `Lucky::MissingParamError` will be raised\n  #\n  # ```\n  # params.get_all(:names)    # [\"Paul\", \"Johnny\"] : Array(String)\n  # params.get_all(\"missing\") # Missing parameter: missing\n  # ```\n  def get_all(key : String | Symbol) : Array(String)\n    get_all?(key) || raise Lucky::MissingParamError.new(key.to_s)\n  end\n\n  # Retrieve values for a given key, return nil if key is absent\n  #\n  # ```\n  # params.get_all(:names)    # [\"Paul\", \"Johnny\"] : (Array(String) | Nil)\n  # params.get_all(\"missing\") # nil : (Array(String) | Nil)\n  # ```\n  def get_all?(key : String | Symbol) : Array(String)?\n    key = key.to_s\n\n    body_values = if json?\n                    get_all_json(key)\n                  elsif multipart?\n                    get_all_params(multipart_params, key)\n                  else\n                    get_all_params(form_params, key)\n                  end\n\n    body_values || get_all_params(query_params, key)\n  end\n\n  # Retrieve a file from the params hash, raise if key is absent\n  #\n  # If no key is found a `Lucky::MissingParamError` will be raised:\n  #\n  # ```\n  # params.get_file(\"missing\") # Raise: Missing parameter: missing\n  #\n  # file = params.get_file(\"avatar_file\") # Lucky::UploadedFile\n  # file.name                             # avatar.png\n  # file.metadata                         # HTTP::FormData::FileMetadata\n  # file.tempfile.read                    # Get the file contents\n  # ```\n  def get_file(key) : Lucky::UploadedFile\n    get_file?(key) || raise Lucky::MissingParamError.new(key.to_s)\n  end\n\n  # Retrieve a file from the params hash, return nil if key is absent\n  #\n  # ```\n  # params.get_file?(\"missing\") # nil\n  #\n  # file = params.get_file?(\"avatar_file\") # Lucky::UploadedFile\n  # file.not_nil!.name                     # avatar.png\n  # file.not_nil!.metadata                 # HTTP::FormData::FileMetadata\n  # file.not_nil!.tempfile.read            # Get the file contents\n  # ```\n  def get_file?(key : String | Symbol) : Lucky::UploadedFile?\n    multipart_files[key.to_s]?\n  end\n\n  def get_all_files(key : String | Symbol) : Array(Lucky::UploadedFile)\n    get_all_files?(key) || raise Lucky::MissingParamError.new(key.to_s)\n  end\n\n  def get_all_files?(key : String | Symbol) : Array(Lucky::UploadedFile)\n    multipart_files.fetch_all(key.to_s)\n  end\n\n  # Retrieve a nested value from the params\n  #\n  # Nested params often appear in JSON requests or Form submissions. If no key\n  # is found a `Lucky::MissingParamError` will be raised:\n  #\n  # ```\n  # body = \"user:name=Alesia&user:age=35&page=1\"\n  # request = HTTP::Request.new(\"POST\", \"/\", body: body)\n  # params = Lucky::Params.new(request)\n  #\n  # params.nested(\"user\")    # {\"name\" => \"Alesia\", \"age\" => \"35\"}\n  # params.nested(\"missing\") # Missing parameter: missing\n  # ```\n  def nested(nested_key : String | Symbol) : Hash(String, String)\n    maybe_nested(nested_key) || raise Lucky::MissingNestedParamError.new(nested_key)\n  end\n\n  # Retrieve a nested value from the params\n  #\n  # Nested params often appear in JSON requests or Form submissions. If no key\n  # is found an empty hash will be returned:\n  #\n  # ```\n  # body = \"user:name=Alesia&user:age=35&page=1\"\n  # request = HTTP::Request.new(\"POST\", \"/\", body: body)\n  # params = Lucky::Params.new(request)\n  #\n  # params.nested(\"user\")    # {\"name\" => \"Alesia\", \"age\" => \"35\"}\n  # params.nested(\"missing\") # {}\n  # ```\n  def nested?(nested_key : String | Symbol) : Hash(String, String)\n    maybe_nested(nested_key) || empty_params\n  end\n\n  # Retrieve a nested array from the params\n  #\n  # Nested params often appear in JSON requests or Form submissions. If no key\n  # is found a `Lucky::MissingParamError` will be raised:\n  #\n  # ```\n  # params.nested_array(\"tags\")    # {\"tags\" => [\"Lucky\", \"Crystal\"]}\n  # params.nested_array(\"missing\") # Missing parameter: missing\n  # ```\n  def nested_arrays(nested_key : String | Symbol) : Hash(String, Array(String))\n    maybe_nested_arrays(nested_key) || raise Lucky::MissingNestedParamError.new(nested_key)\n  end\n\n  def nested_arrays?(nested_key : String | Symbol) : Hash(String, Array(String))\n    maybe_nested_arrays(nested_key) || Hash(String, Array(String)).new\n  end\n\n  # Retrieve a nested file from the params\n  #\n  # Nested params often appear in JSON requests or Form submissions. If no key\n  # is found a `Lucky::MissingParamError` will be raised:\n  #\n  # ```\n  # params.nested_file?(\"file\")    # Lucky::UploadedFile\n  # params.nested_file?(\"missing\") # {}\n  # ```\n  def nested_file(nested_key : String | Symbol) : Hash(String, Lucky::UploadedFile)\n    maybe_nested_file(nested_key) || raise Lucky::MissingNestedParamError.new(nested_key)\n  end\n\n  # Retrieve a nested file from the params\n  #\n  # Nested params often appear in JSON requests or Form submissions. If no key\n  # is found an empty hash will be returned:\n  #\n  # ```\n  # params.nested_file(\"file\")    # Lucky::UploadedFile\n  # params.nested_file(\"missing\") # Missing parameter: missing\n  # ```\n  def nested_file?(nested_key : String | Symbol) : Hash(String, Lucky::UploadedFile)\n    maybe_nested_file(nested_key) || empty_file_params\n  end\n\n  def nested_array_files(nested_key : String | Symbol) : Hash(String, Array(Lucky::UploadedFile))\n    maybe_nested_array_files(nested_key) || raise Lucky::MissingNestedParamError.new(nested_key)\n  end\n\n  def nested_array_files?(nested_key : String | Symbol) : Hash(String, Array(Lucky::UploadedFile))\n    maybe_nested_array_files(nested_key) || Hash(String, Array(Lucky::UploadedFile)).new\n  end\n\n  # Retrieve nested values from the params\n  #\n  # Nested params often appear in JSON requests or Form submissions. If no key\n  # is found a `Lucky::MissingParamError` will be raised:\n  #\n  # ```\n  # body = \"users[0]:name=Alesia&users[0]:age=35&users[1]:name=Bob&users[1]:age=40&page=1\"\n  # request = HTTP::Request.new(\"POST\", \"/\", body: body)\n  # params = Lucky::Params.new(request)\n  #\n  # params.many_nested(\"users\")\n  # # [{\"name\" => \"Alesia\", \"age\" => \"35\"}, { \"name\" => \"Bob\", \"age\" => \"40\" }]\n  # params.many_nested(\"missing\") # Missing parameter: missing\n  # ```\n  def many_nested(nested_key : String | Symbol) : Array(Hash(String, String))\n    maybe_many_nested(nested_key) || raise Lucky::MissingNestedParamError.new(nested_key)\n  end\n\n  # Retrieve nested values from the params\n  #\n  # Nested params often appear in JSON requests or Form submissions. If no key\n  # is found an empty array will be returned:\n  #\n  # ```\n  # body = \"users[0]:name=Alesia&users[0]:age=35&users[1]:name=Bob&users[1]:age=40&page=1\"\n  # request = HTTP::Request.new(\"POST\", \"/\", body: body)\n  # params = Lucky::Params.new(request)\n  #\n  # params.nested(\"users\")\n  # # [{\"name\" => \"Alesia\", \"age\" => \"35\"}, { \"name\" => \"Bob\", \"age\" => \"40\" }]\n  # params.nested(\"missing\") # []\n  # ```\n  def many_nested?(nested_key : String | Symbol) : Array(Hash(String, String))\n    maybe_many_nested(nested_key) || Array(Hash(String, String)).new\n  end\n\n  # Converts the params in to a `Hash(String, String)`\n  #\n  # ```\n  # request.query = \"filter:name=trombone&page=1&per=50\"\n  # params = Lucky::Params.new(request)\n  # params.to_h # {\"filter\" => {\"name\" => \"trombone\"}, \"page\" => \"1\", \"per\" => \"50\"}\n  # ```\n  def to_h\n    if json?\n      parsed_json.as_h.merge(query_params.to_h)\n    else\n      hash = {} of String => String | Hash(String, String)\n      params = body_params.to_h.merge(query_params.to_h)\n      params.map do |key, value|\n        keys = key.split(':')\n        is_nested = keys.size > 1\n        if is_nested\n          hash[keys.first] = nested(keys.first)\n        else\n          hash[key] = value.as(String)\n        end\n      end\n      hash\n    end\n  end\n\n  private def maybe_nested(nested_key : String | Symbol) : Hash(String, String)?\n    if json?\n      body_params = nested_json_params(nested_key.to_s)\n    else\n      body_params = nested_form_params(nested_key.to_s)\n    end\n\n    query_params = nested_query_params(nested_key.to_s)\n\n    return if body_params.nil? && query_params.nil?\n    return body_params if query_params.nil?\n    return query_params if body_params.nil?\n\n    body_params.merge!(query_params)\n  end\n\n  private def maybe_nested_arrays(nested_key : String | Symbol) : Hash(String, Array(String))?\n    if json?\n      body_params = nested_array_json_params(nested_key.to_s)\n    else\n      body_params = nested_array_form_params(nested_key.to_s)\n    end\n\n    query_params = nested_array_query_params(nested_key.to_s)\n\n    return if body_params.nil? && query_params.nil?\n    return body_params if query_params.nil?\n    return query_params if body_params.nil?\n\n    body_params.merge!(query_params) { |_k, v1, v2| v1 + v2 }\n  end\n\n  private def maybe_nested_file(nested_key : String | Symbol) : Hash(String, Lucky::UploadedFile)?\n    nested_file_params(nested_key.to_s)\n  end\n\n  private def maybe_nested_array_files(nested_key : String | Symbol) : Hash(String, Array(Lucky::UploadedFile))?\n    nested_array_file_params(nested_key.to_s)\n  end\n\n  private def maybe_many_nested(nested_key : String | Symbol) : Array(Hash(String, String))?\n    zipped_many_nested_params(nested_key.to_s).try &.map do |param_a, param_b|\n      (param_a || {} of String => String).merge(param_b || {} of String => String)\n    end\n  end\n\n  private def nested_json_params(nested_key : String) : Hash(String, String)?\n    parsed_json[nested_key]?.try do |nested_key_json|\n      empty_params.tap do |nested_params|\n        nested_key_json.as_h.each do |key, value|\n          nested_params[key.to_s] = stringify_json_value(value)\n        end\n      end\n    end\n  end\n\n  private def nested_array_json_params(nested_key : String) : Hash(String, Array(String))?\n    parsed_json[nested_key]?.try do |nested_key_json|\n      nested_params = Hash(String, Array(String)).new.tap do |params|\n        nested_key_json.as_h.each do |key, value|\n          if array_value = value.as_a?\n            params[key.to_s] = array_value.map { |array_val| stringify_json_value(array_val) }\n          end\n        end\n      end\n\n      nested_params.empty? ? nil : nested_params\n    end\n  end\n\n  private def nested_form_params(nested_key : String) : Hash(String, String)?\n    nested_key = \"#{nested_key}:\"\n    source = multipart? ? multipart_params : form_params\n\n    nested_params = empty_params.tap do |params|\n      source.each do |key, value|\n        if key.starts_with?(nested_key)\n          params[key.lchop(nested_key)] = value\n        end\n      end\n    end\n\n    nested_params.empty? ? nil : nested_params\n  end\n\n  private def nested_array_form_params(nested_key : String) : Hash(String, Array(String))?\n    nested_key = \"#{nested_key}:\"\n    source = multipart? ? multipart_params : form_params\n\n    nested_params = Hash(String, Array(String)).new.tap do |params|\n      source.each do |key, value|\n        if key.starts_with?(nested_key) && key.ends_with?(\"[]\")\n          new_key = key.lchop(nested_key).rchop(\"[]\")\n          params[new_key.to_s] ||= [] of String\n          params[new_key.to_s] << value\n        end\n      end\n    end\n\n    nested_params.empty? ? nil : nested_params\n  end\n\n  private def nested_query_params(nested_key : String) : Hash(String, String)?\n    nested_key = \"#{nested_key}:\"\n\n    nested_params = empty_params.tap do |params|\n      params = query_params.each do |key, value|\n        if key.starts_with? nested_key\n          params[key.lchop(nested_key)] = value\n        end\n      end\n    end\n\n    nested_params.empty? ? nil : nested_params\n  end\n\n  private def nested_array_query_params(nested_key : String) : Hash(String, Array(String))?\n    nested_key = \"#{nested_key}:\"\n\n    nested_params = Hash(String, Array(String)).new.tap do |params|\n      query_params.each do |key, value|\n        if key.starts_with?(nested_key) && key.ends_with?(\"[]\")\n          new_key = key.lchop(nested_key).rchop(\"[]\")\n          params[new_key.to_s] ||= [] of String\n          params[new_key.to_s] << value\n        end\n      end\n    end\n\n    nested_params.empty? ? nil : nested_params\n  end\n\n  private def nested_file_params(nested_key : String) : Hash(String, Lucky::UploadedFile)?\n    nested_key = \"#{nested_key}:\"\n\n    nested_params = empty_file_params.tap do |params|\n      multipart_files.each do |key, value|\n        if key.starts_with? nested_key\n          params[key.lchop(nested_key)] = value\n        end\n      end\n    end\n\n    nested_params.empty? ? nil : nested_params\n  end\n\n  private def nested_array_file_params(nested_key : String) : Hash(String, Array(Lucky::UploadedFile))?\n    nested_key = \"#{nested_key}:\"\n\n    nested_params = Hash(String, Array(Lucky::UploadedFile)).new.tap do |params|\n      multipart_files.each do |key, value|\n        if key.starts_with?(nested_key) && key.ends_with?(\"[]\")\n          new_key = key.lchop(nested_key).rchop(\"[]\")\n          params[new_key.to_s] ||= [] of Lucky::UploadedFile\n          params[new_key.to_s] << value\n        end\n      end\n    end\n\n    nested_params.empty? ? nil : nested_params\n  end\n\n  private def zipped_many_nested_params(nested_key : String)\n    body_params = many_nested_body_params(nested_key)\n    query_params = many_nested_query_params(nested_key)\n\n    if body_params.size > query_params.size\n      nested_params = body_params.zip?(query_params)\n    else\n      nested_params = query_params.zip?(body_params)\n    end\n\n    nested_params.empty? ? nil : nested_params\n  end\n\n  private def many_nested_body_params(nested_key : String) : Array(Hash(String, String))\n    if json?\n      many_nested_json_params(nested_key.to_s)\n    else\n      many_nested_form_params(nested_key.to_s)\n    end\n  end\n\n  private def many_nested_json_params(nested_key : String) : Array(Hash(String, String))\n    many_nested_params = [] of Hash(String, String)\n    nested_key_json = parsed_json[nested_key]? || JSON.parse(\"[]\")\n\n    nested_key_json.as_a.each do |nested_values|\n      nested_params = {} of String => String\n      nested_values.as_h.each do |key, value|\n        nested_params[key.to_s] = stringify_json_value(value)\n      end\n\n      many_nested_params << nested_params\n    end\n\n    many_nested_params\n  end\n\n  private def many_nested_form_params(nested_key : String) : Array(Hash(String, String))\n    source = multipart? ? multipart_params : form_params\n    many_nested_hash_params(source.to_h, nested_key)\n  end\n\n  private def many_nested_query_params(nested_key : String) : Array(Hash(String, String))\n    many_nested_hash_params(query_params.to_h, nested_key)\n  end\n\n  private def many_nested_hash_params(hash : Hash(String, String), nested_key : String) : Array(Hash(String, String))\n    nested_key = \"#{nested_key}[\"\n    matcher = /^#{Regex.escape(nested_key)}(?<index>\\d+)\\]:(?<nested_key>.+)$/\n    many_nested_params = Hash(String, Hash(String, String)).new do |new_hash, key|\n      new_hash[key] ||= {} of String => String\n    end\n\n    hash.each do |key, value|\n      if key.starts_with? nested_key\n        key.match(matcher).try do |match|\n          many_nested_params[match[\"index\"]][match[\"nested_key\"]] = value\n        end\n      end\n    end\n\n    many_nested_params.values\n  end\n\n  private def body_param(key : String)\n    if json?\n      parsed_json[key]?.try { |value| stringify_json_value(value) }\n    elsif multipart?\n      multipart_params[key]?\n    else\n      form_params[key]?\n    end\n  end\n\n  private def body_params\n    if json?\n      parsed_json.as_h\n    elsif multipart?\n      multipart_params\n    else\n      form_params\n    end\n  end\n\n  private memoize def form_params : URI::Params\n    URI::Params.parse(body)\n  end\n\n  private def multipart_params : Lucky::FormData::MultiValueStorage(String)\n    parse_form_data.params\n  end\n\n  private def multipart_files : Lucky::FormData::MultiValueStorage(Lucky::UploadedFile)\n    parse_form_data.files\n  end\n\n  private def json? : Bool?\n    content_type.try &.downcase.starts_with?(\"application/json\")\n  end\n\n  private def multipart? : Bool?\n    content_type.try &.downcase.starts_with?(\"multipart/form-data\")\n  end\n\n  private def content_type : String?\n    request.headers[\"Content-Type\"]?\n  end\n\n  private memoize def parse_form_data : Lucky::FormData\n    Lucky::FormDataParser.new(body, request).form_data\n  end\n\n  private memoize def parsed_json : JSON::Any\n    Lucky::JsonBodyParser.new(body, request).parsed_json\n  end\n\n  memoize def body : String\n    Lucky::RequestBodyReader.new(request).body\n  end\n\n  private def empty_params : Hash(String, String)\n    {} of String => String\n  end\n\n  private def empty_file_params : Hash(String, Lucky::UploadedFile)\n    {} of String => Lucky::UploadedFile\n  end\n\n  private def query_params : URI::Params\n    request.query_params\n  end\n\n  private def get_all_json(key : String) : Array(String)?\n    val = parsed_json[key]?\n    return nil if val.nil?\n\n    val.as_a?.try(&.map(&.to_s)) || [val.to_s]\n  end\n\n  private def get_all_params(params, key : String) : Array(String)?\n    vals = params.fetch_all(key + \"[]\")\n    if !vals.empty?\n      vals\n    else\n      nil\n    end\n  end\n\n  private def stringify_json_value(value : JSON::Any) : String\n    if value.raw.nil?\n      \"\"\n    else\n      value.as_s? || value.to_json\n    end\n  end\nend\n"
  },
  {
    "path": "src/lucky/pretty_log_formatter.cr",
    "content": "struct Lucky::PrettyLogFormatter < Dexter::BaseFormatter\n  ENTRY_FORMATTERS = [\n    RequestStartedFormatter,\n    RequestEndedFormatter,\n    ExceptionFormatter,\n    AnyOtherDataFormatter,\n  ]\n\n  def call : Nil\n    ENTRY_FORMATTERS.each do |entry_formatter|\n      formatter = entry_formatter.new(io, entry)\n\n      if formatter.should_format?\n        formatter.write\n        break\n      end\n    end\n  end\n\n  private abstract class EntryFormatter\n    private getter io, entry\n    delegate severity, to: entry\n\n    def initialize(@io : IO, @entry : ::Log::Entry)\n    end\n\n    abstract def should_format? : Bool\n\n    abstract def write : Nil\n\n    def local_context : Hash(String, ::Log::Metadata::Value)\n      res = Hash(String, ::Log::Metadata::Value).new\n\n      entry.context[:local]?.try &.as_h.each do |key, value|\n        res[key.to_s] = value\n      end\n\n      res\n    end\n\n    private def add_arrow : Nil\n      io << \" #{arrow} \"\n    end\n\n    private def arrow : Colorize::Object(String)\n      arrow = \"▸\"\n\n      case severity.value\n      when ::Log::Severity::Warn.value\n        arrow.colorize.yellow\n      when .>= ::Log::Severity::Error.value\n        arrow.colorize.red\n      else\n        arrow.colorize.dim\n      end\n    end\n\n    private def add_request_id : Nil\n      if id = local_context[\"request_id\"].to_s.presence\n        io << \" (#{id.colorize.dim})\"\n      end\n    end\n  end\n\n  private class RequestStartedFormatter < EntryFormatter\n    def should_format? : Bool\n      (Lucky::LogHandler::REQUEST_START_KEYS.values.to_a - local_context.keys).empty?\n    end\n\n    def write : Nil\n      io << \"\\n#{local_context[\"method\"]} #{local_context[\"path\"].colorize.underline}\"\n      add_request_id\n    end\n  end\n\n  private class RequestEndedFormatter < EntryFormatter\n    def should_format? : Bool\n      (Lucky::LogHandler::REQUEST_END_KEYS.values.to_a - local_context.keys).empty?\n    end\n\n    def write : Nil\n      add_arrow\n      http_status = Lucky::LoggerHelpers.colored_http_status(local_context[\"status\"].as_i)\n      io << \"Sent #{http_status} (#{local_context[\"duration\"]})\"\n      add_request_id\n    end\n  end\n\n  private class ExceptionFormatter < EntryFormatter\n    def should_format? : Bool\n      entry.exception.present?\n    end\n\n    def write : Nil\n      add_arrow\n      entry.exception.try do |ex|\n        io << \" #{ex.class.name} \".colorize.bold.on_red\n        if ex.message.try(&.lines)\n          io << \"\\n\"\n          ex.message.try(&.lines).try(&.each do |line|\n            io << \"\\n     \"\n            io << line\n          end)\n        end\n        if backtrace = ex.backtrace?\n          io << \"\\n\\n   \"\n          io << \" Backtrace \".colorize.bold.black.on_white\n          io << \"\\n\"\n          backtrace.each do |trace_line|\n            trace_line = trace_line.colorize.dim unless trace_line.starts_with?(/src|spec/)\n            io << \"\\n     #{trace_line}\"\n          end\n          io << \"\\n\"\n        end\n      end\n    end\n  end\n\n  private class AnyOtherDataFormatter < EntryFormatter\n    private property index = 0\n\n    def should_format? : Bool\n      true\n    end\n\n    def write : Nil\n      add_arrow\n      io << \"#{entry.message}\" unless entry.message.empty?\n\n      io << local_context.map do |key, value|\n        \"#{Wordsmith::Inflector.humanize(key)} #{colored(value.to_s)}\".tap do\n          self.index += 1\n        end\n      end.join(\". \")\n    end\n\n    private def colored(value : String) : Colorize::Object(String)\n      if printing_first_value_of_warning?\n        value.colorize.bold.yellow\n      else\n        value.colorize.bold\n      end\n    end\n\n    private def printing_first_value_of_warning? : Bool\n      severity.value == ::Log::Severity::Warn.value && index.zero?\n    end\n  end\nend\n"
  },
  {
    "path": "src/lucky/protect_from_forgery.cr",
    "content": "# Protect from CSRF attacks\n#\n# This module is automatically included in `BrowserAction` to protect from CSRF\n# attacks.\nmodule Lucky::ProtectFromForgery\n  ALLOWED_METHODS = %w(GET HEAD OPTIONS TRACE)\n  SESSION_KEY     = \"X-CSRF-TOKEN\"\n  PARAM_KEY       = \"_csrf\"\n\n  macro included\n    before protect_from_forgery\n  end\n\n  Habitat.create do\n    setting allow_forgery_protection : Bool = true\n  end\n\n  # :nodoc:\n  def self.get_token(context : HTTP::Server::Context) : String\n    context.session.get(SESSION_KEY)\n  end\n\n  private def protect_from_forgery\n    set_session_csrf_token\n    if !Lucky::ProtectFromForgery.settings.allow_forgery_protection? || request_does_not_require_protection? || valid_csrf_token?\n      continue\n    else\n      forbid_access_because_of_bad_token\n    end\n  end\n\n  private def set_session_csrf_token : Nil\n    session.get?(SESSION_KEY) ||\n      session.set(SESSION_KEY, Random::Secure.urlsafe_base64(32))\n  end\n\n  private def request_does_not_require_protection? : Bool\n    ALLOWED_METHODS.includes? request.method\n  end\n\n  private def valid_csrf_token? : Bool\n    session_token == user_provided_token\n  end\n\n  private def session_token : String\n    session.get(SESSION_KEY)\n  end\n\n  private def user_provided_token : String?\n    params.get?(PARAM_KEY) || request.headers[SESSION_KEY]?\n  end\n\n  private def forbid_access_because_of_bad_token : Lucky::Response\n    head :forbidden\n  end\nend\n"
  },
  {
    "path": "src/lucky/quick_def.cr",
    "content": "module Lucky::QuickDef\n  # Quickly create a method with a simple return value\n  #\n  # ```\n  # # Instead of:\n  # def name\n  #   \"Kylo\"\n  # end\n  #\n  # # You could use quick_def:\n  # quick_def :name, \"Kylo\"\n  # ```\n  macro quick_def(method_name, value)\n    def {{ method_name.id }}\n      {{ value }}\n    end\n  end\nend\n"
  },
  {
    "path": "src/lucky/rate_limit.cr",
    "content": "# Adds in action level rate limiting. Limit the request rate of a specific\n# action by including this module, then define the `rate_limit` method to configure.\n# For convenience, you can also use the `rate_limit` macro.\n#\n# ```\n# class Reports::Index < ApiAction\n#   include Lucky::RateLimit\n#   rate_limit to: 5, within: 1.minute\n#\n#   get \"/reports\"\n#     plain_text \"ok\"\n#   end\n# end\n# ```\n#\n# By default, the `rate_limit_identifier` uses the IP address. You can override this method\n# to define a different strategy.\nmodule Lucky::RateLimit\n  macro included\n    before enforce_rate_limit\n  end\n\n  # Defines the rate limit limiting to `to` requests within `within` time span.\n  # ```\n  # def rate_limit : NamedTuple(to: Int32, within: Time::Span)\n  #   {to: 5, within: 1.minute}\n  # end\n  # ```\n  abstract def rate_limit : NamedTuple(to: Int32, within: Time::Span)\n\n  # Convenience macro to define the required `rate_limit` method\n  # ```\n  # rate_limit to: 5, within: 1.minute\n  # ```\n  macro rate_limit(to, within)\n    def rate_limit : NamedTuple(to: Int32, within: Time::Span)\n      {to: {{to}}, within: {{within}}}\n    end\n  end\n\n  private def enforce_rate_limit\n    cache = LuckyCache.settings.storage\n    count = cache.fetch(rate_limit_key, as: Int32, expires_in: rate_limit[\"within\"]) { 0 }\n    cache.write(rate_limit_key, expires_in: rate_limit[\"within\"]) { count + 1 }\n\n    if count > rate_limit[\"to\"]\n      context.response.status = HTTP::Status::TOO_MANY_REQUESTS\n      context.response.headers[\"Retry-After\"] = rate_limit[\"within\"].to_s\n      plain_text(\"Rate limit exceeded\")\n    else\n      continue\n    end\n  end\n\n  private def rate_limit_key : String\n    klass = {{ @type.stringify.downcase.gsub(/::/, \":\") }}\n    \"ratelimit:#{klass}:#{rate_limit_identifier}\"\n  end\n\n  private def rate_limit_identifier : String\n    context.request.remote_ip.presence || raise Lucky::MissingRateLimitIdentifier.new(\"The rate limit identifier was not found. Override the `rate_limit_identifier` method or ensure the IP address exists.\")\n  end\nend\n"
  },
  {
    "path": "src/lucky/redirectable.cr",
    "content": "# Redirect the request\n#\n# There are multiple ways to redirect inside of an action. The most common ways are to redirect to a `Lucky::Action` class, or a URL/path `String`. Both use the `redirect` method:\n#\n# ```\n# redirect to: Users::Index\n# redirect to: Users::Show.with(user.id)\n# redirect to: \"https://luckyframework.org/\"\n# redirect to: \"/users\"\n# ```\n#\n# By default, the method will set the status code to `302` A.K.A. \"Found\". If you want to customize the status code, you can pass it directly:\n#\n# ```\n# redirect to: Users::Index, status: 301\n#\n# # or use the built-in enum value\n# redirect to: Users::Index, status: HTTP::Status::MOVED_PERMANENTLY\n# ```\n#\n# Alternatively, the status code can also be configured globally through the `redirect_status` setting:\n#\n# ```\n# Lucky::Redirectable.configure do |config|\n#   config.redirect_status = 303\n#\n#   # or using a built-in enum value\n#   config.redirect_status = HTTP::Status::SEE_OTHER.value\n# end\n# ```\n#\n# You can find a list of all possible statuses [here](https://crystal-lang.org/api/latest/HTTP/Status.html).\n#\n# Internally, all the different methods in this module eventually use the\n# method that takes a `String`. However, it's recommended you pass a\n# `Lucky::Action` class if possible because it guarantees runtime safety.\nmodule Lucky::Redirectable\n  Habitat.create do\n    setting redirect_status : Int32 = HTTP::Status::FOUND.value\n  end\n\n  # Redirect back with a `Lucky::Action` fallback\n  #\n  # ```\n  # redirect_back fallback: Users::Index\n  # ```\n  def redirect_back(\n    *,\n    fallback : Lucky::Action.class,\n    status = Lucky::Redirectable.settings.redirect_status,\n    allow_external = false,\n  ) : Lucky::TextResponse\n    redirect_back fallback: fallback.route, status: status, allow_external: allow_external\n  end\n\n  # Redirect back with a `Lucky::RouteHelper` fallback\n  #\n  # ```\n  # redirect_back fallback: Users::Show.with(user.id)\n  # ```\n  def redirect_back(\n    *,\n    fallback : Lucky::RouteHelper,\n    status = Lucky::Redirectable.settings.redirect_status,\n    allow_external = false,\n  ) : Lucky::TextResponse\n    redirect_back fallback: fallback.path, status: status, allow_external: allow_external\n  end\n\n  # Redirect back with a human friendly status\n  #\n  # ```\n  # redirect_back fallback: \"/users\", status: HTTP::Status::MOVED_PERMANENTLY\n  # ```\n  def redirect_back(\n    *,\n    fallback : String,\n    status : HTTP::Status,\n    allow_external = false,\n  ) : Lucky::TextResponse\n    redirect_back fallback: fallback, status: status.value, allow_external: allow_external\n  end\n\n  # Redirects the browser to the page that issued the request (the referrer)\n  # if possible, otherwise redirects to the provided default fallback\n  # location.\n  #\n  # The referrer information is pulled from the 'Referer' header on\n  # the request. This is an optional header, and if the request\n  # is missing this header the *fallback* will be used.\n  #\n  # ```\n  # redirect_back fallback: \"/users\"\n  # ```\n  #\n  # A redirect status can be specified\n  #\n  # ```\n  # redirect_back fallback: \"/home\", status: 301\n  # ```\n  #\n  # External referrers are ignored by default.\n  # It is determined by comparing the referer header to the request host.\n  # They can be explicitly allowed if necessary\n  #\n  # redirect_back fallback: \"/home\", allow_external: true\n  #\n  # If the referer path matches the current request path, the fallback\n  # will be used to avoid redirecting back to the same page.\n  def redirect_back(\n    *,\n    fallback : String,\n    status : Int32 = Lucky::Redirectable.settings.redirect_status,\n    allow_external : Bool = false,\n  ) : Lucky::TextResponse\n    referer = request.headers[\"Referer\"]?\n\n    if referer && (allow_external || allowed_host?(referer))\n      referer_path = URI.parse(referer).path\n      request_path = request.path\n      if request_path == referer_path\n        redirect to: fallback, status: status\n      else\n        redirect to: referer, status: status\n      end\n    else\n      redirect to: fallback, status: status\n    end\n  end\n\n  # Redirect using a `Lucky::RouteHelper`\n  #\n  # ```\n  # redirect to: Users::Show.with(user.id), status: 301\n  # ```\n  def redirect(\n    to route : Lucky::RouteHelper,\n    status = Lucky::Redirectable.settings.redirect_status,\n  ) : Lucky::TextResponse\n    redirect to: route.path, status: status\n  end\n\n  # Redirect to a `Lucky::Action`\n  #\n  # ```\n  # redirect to: Users::Index\n  # ```\n  def redirect(\n    to action : Lucky::Action.class,\n    status = Lucky::Redirectable.settings.redirect_status,\n  ) : Lucky::TextResponse\n    redirect to: action.route, status: status\n  end\n\n  # Redirect to the given path, with a human friendly status\n  #\n  # ```\n  # redirect to: \"/users\", status: HTTP::Status::MOVED_PERMANENTLY\n  # ```\n  def redirect(to path : String, status : HTTP::Status) : Lucky::TextResponse\n    redirect(path, status.value)\n  end\n\n  # Redirect to the given path, with an optional `Int32` status\n  #\n  # ```\n  # redirect to: \"/users\"\n  # redirect to: \"/users/1\", status: 301\n  # ```\n  # Note: It's recommended to use the method above that accepts a human friendly version of the status\n  def redirect(\n    to path : String,\n    status : Int32 = Lucky::Redirectable.settings.redirect_status,\n  ) : Lucky::TextResponse\n    # flash messages are not consumed here, so keep them for the next action\n    flash.keep\n    context.response.headers.add \"Location\", path\n    context.response.status_code = status\n    Lucky::TextResponse.new(context, \"\", \"\")\n  end\n\n  # :nodoc:\n  def redirect(to page_instead_of_action : Lucky::HTMLPage.class, **unused_args)\n    {% raise \"You accidentally redirected to a Lucky::HTMLPage instead of a Lucky::Action\" %}\n  end\n\n  private def allowed_host?(referer : String) : Bool\n    if referer_host = URI.parse(referer).hostname\n      referer_host == request.hostname\n    else\n      false\n    end\n  end\nend\n"
  },
  {
    "path": "src/lucky/redirectable_turbolinks_support.cr",
    "content": "# Set \"Turbolinks-Location\" from session\n# Needs to change browser address bar at last request, see https://github.com/turbolinks/turbolinks#following-redirects\n#\n# This pipe extracted Lucky::Redirectable, because Lucky::Redirectable included to Lucky::ErrorAction\n# but Lucky::ErrorAction not have pipe support\nmodule Lucky::RedirectableTurbolinksSupport\n  # Overrides Lucky::Redirectable redirect's method\n  def redirect(\n    to path : String,\n    status : Int32 = Lucky::Redirectable.settings.redirect_status,\n  ) : Lucky::TextResponse\n    # flash messages are not consumed here, so keep them for the next action\n    flash.keep\n    if ajax? && request.method != \"GET\"\n      context.response.headers.add \"Location\", path\n\n      # do not enable form disabled elements for XHR redirects, see https://github.com/rails/rails/pull/31441\n      context.response.headers.add \"X-Xhr-Redirect\", path\n\n      Lucky::TextResponse.new(context,\n        \"text/javascript\",\n        %[Turbolinks.clearCache();\\nTurbolinks.visit(#{path.to_json}, {\"action\": \"replace\"})],\n        status: 200)\n    else\n      if request.headers[\"Turbolinks-Referrer\"]?\n        store_turbolinks_location_in_session(path)\n      end\n      # ordinary redirect\n      context.response.headers.add \"Location\", path\n      context.response.status_code = status\n      Lucky::TextResponse.new(context, \"\", \"\")\n    end\n  end\n\n  private def store_turbolinks_location_in_session(path : String)\n    cookies.set(:_turbolinks_location, path).http_only(true)\n  end\n\n  macro included\n    before set_turbolinks_location_header_from_session\n  end\n\n  private def set_turbolinks_location_header_from_session\n    if turbolinks_location = cookies.get?(:_turbolinks_location)\n      cookies.delete(:_turbolinks_location)\n      # change browser address bar at last request, see https://github.com/turbolinks/turbolinks#following-redirects\n      response.headers[\"Turbolinks-Location\"] = turbolinks_location\n    end\n    continue\n  end\nend\n"
  },
  {
    "path": "src/lucky/remote_ip_handler.cr",
    "content": "# Sets the HTTP::Request#remote_address value as `Socket::IPAddress?`\n# to the value of the last IP in the `X-Forwarded-For`\n# header, or fallback to the default `remote_address`.\n# https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Forwarded-For\n#\n# This will also set a `remote_ip` String value as `String` which will be\n# either the raw `remote_address` value, or an empty string.\n#\n# This Handler does a \"best guess\" for the IP which is generally good\n# enough. If you require IP based Authentication, then you may want\n# to handle this on your own as there will be edge cases when related\n# to mobile clients on the go, and potential IP spoofing attacks.\n# More detailed info: https://adam-p.ca/blog/2022/03/x-forwarded-for/\nclass Lucky::RemoteIpHandler\n  include HTTP::Handler\n\n  Habitat.create do\n    setting ip_header_name : String = \"X-Forwarded-For\"\n  end\n\n  def call(context : HTTP::Server::Context)\n    context.request.remote_address = fetch_remote_ip(context)\n    if ip_value = context.request.remote_address.as?(Socket::IPAddress).try(&.address.presence)\n      context.request.remote_ip = ip_value\n    end\n    call_next(context)\n  end\n\n  private def fetch_remote_ip(context : HTTP::Server::Context) : Socket::Address?\n    request = context.request\n    header = settings.ip_header_name\n    remote_ip = request.headers[header]?.try(&.split(',').last?).presence\n\n    if remote_ip\n      begin\n        Socket::IPAddress.new(remote_ip.to_s, 0)\n      rescue Socket::Error\n        # if the x_forwarded is not a valid ip address we fallback to request.remote_address\n        request.remote_address\n      end\n    else\n      request.remote_address\n    end\n  end\nend\n"
  },
  {
    "path": "src/lucky/renderable.cr",
    "content": "module Lucky::Renderable\n  # Render a page and pass it data\n  #\n  # `html` is used to pass data to a page and render it. Each key/value pair\n  # must match up with each `needs` declarations for that page. For example, if\n  # we have a page like this:\n  #\n  # ```\n  # class Users::IndexPage < MainLayout\n  #   needs users : UserQuery\n  #\n  #   def content\n  #     @users.each do |user|\n  #       # ...\n  #     end\n  #   end\n  # end\n  # ```\n  #\n  # Our action must pass a `users` key to the `html` method like this:\n  #\n  # ```\n  # class Users::Index < BrowserAction\n  #   get \"/users\" do\n  #     html IndexPage, users: UserQuery.new\n  #   end\n  # end\n  # ```\n  #\n  # Note also that each piece of data is merged with any `expose` declarations:\n  #\n  # ```\n  # class Users::Index < BrowserAction\n  #   expose current_user\n  #\n  #   get \"/users\" do\n  #     # Users::IndexPage receives users AND current_user\n  #     html IndexPage users: UserQuery.new\n  #   end\n  #\n  #   private def current_user\n  #     # ...\n  #   end\n  # end\n  # ```\n  macro html(page_class = nil, _with_status_code = 200, **assigns)\n    {% page_class = page_class || parse_type(\"#{@type.name}Page\") %}\n    {% ancestors = page_class.resolve.ancestors %}\n    {% if ancestors.includes?(Lucky::Action) %}\n      {% page_class.raise \"You accidentally rendered an action (#{page_class}) instead of an HTMLPage in the #{@type.name} action. Did you mean #{page_class}Page?\" %}\n    {% elsif !ancestors.includes?(Lucky::HTMLPage) %}\n      {% page_class.raise \"Couldn't render #{page_class} in #{@type.name} because it is not an HTMLPage\" %}\n    {% end %}\n\n    # Found in {{ @type.name }}\n    view = {{ page_class }}.new(\n      context: context,\n      {% for key, value in assigns %}\n        {{ key }}: {{ value }},\n      {% end %}\n      {% for key in EXPOSURES %}\n        {{ key }}: {{ key }},\n      {% end %}\n    )\n    Lucky::TextResponse.new(\n      context,\n      html_content_type,\n      view.perform_render,\n      status: {{ _with_status_code }},\n      debug_message: log_message(view),\n      enable_cookies: enable_cookies?\n    )\n  end\n\n  # Render an HTMLPage with a status other than 200\n  #\n  # The status can either be a Number, a HTTP::Status, or a Symbol that corresponds to the HTTP::Status.\n  #\n  # ```\n  # class SecretAgents::Index < BrowserAction\n  #   get \"/shhhh\" do\n  #     html_with_status IndexPage, 472, message: \"This page can only be seen with special goggles\"\n  #   end\n  # end\n  # ```\n  # See Crystal's\n  # [HTTP::Status](https://crystal-lang.org/api/latest/HTTP/Status.html)\n  # enum for more available http status codes.\n  macro html_with_status(page_class, status, **assigns)\n    {% if status.is_a?(SymbolLiteral) %}\n      html {{ page_class }}, _with_status_code: HTTP::Status::{{ status.upcase.id }}.value, {{ assigns.double_splat }}\n    {% elsif status.is_a?(Path) && status.names.join(\"::\").starts_with?(\"HTTP::Status::\") %}\n      html {{ page_class }}, _with_status_code: {{ status.resolve }}, {{ assigns.double_splat }}\n    {% else %}\n      html {{ page_class }}, _with_status_code: {{ status }}, {{ assigns.double_splat }}\n    {% end %}\n  end\n\n  # Disable cookies\n  #\n  # When `disable_cookies` is used, no `Set-Cookie` header will be written to\n  # the response.\n  #\n  # ```\n  # class Events::Show < ApiAction\n  #   disable_cookies\n  #\n  #   get \"/events/:id\" do\n  #     ...\n  #   end\n  # end\n  # ```\n  #\n  macro disable_cookies\n    private def enable_cookies? : Bool\n      false\n    end\n  end\n\n  private def enable_cookies? : Bool\n    true\n  end\n\n  private def log_message(view) : String\n    \"Rendered #{view.class.colorize.bold}\"\n  end\n\n  # :nodoc:\n  def perform_action : Nil\n    response = call\n    handle_response(response)\n  end\n\n  private def handle_response(response : Lucky::Response) : Nil\n    log_response(response)\n    response.print\n  end\n\n  private def handle_response(_response : Nil)\n    {%\n      raise <<-ERROR\n\n\n      An action returned Nil\n\n      But it should return a Lucky::Response.\n\n      Try this...\n\n        ▸ Return a response with html, redirect, or json at the end of your action.\n        ▸ Ensure all conditionals (like if/else) return a response with html, redirect, json, etc.\n\n      For example...\n\n        get \"/admin/users\" do\n          # Make sure there is a response in all conditional branches\n          if current_user.admin?\n            html IndexPage, users: UserQuery.new\n          else\n            redirect Home::Index\n          end\n        end\n\n      ERROR\n    %}\n  end\n\n  private def handle_response(_response : T) forall T\n    {%\n      raise <<-ERROR\n\n\n      An action returned #{T}\n\n      But it should return a Lucky::Response\n\n      Try this...\n\n        ▸ Return a response with html, redirect, or json at the end of your action.\n        ▸ Ensure all conditionals (like if/else) return a response with html, redirect, json, etc.\n\n      For example...\n\n        get \"/users\" do\n          # Return a response with json, redirect, html, etc.\n          html IndexPage, users: UserQuery.new\n        end\n\n      ERROR\n    %}\n  end\n\n  private def log_response(response : Lucky::Response) : Nil\n    response.debug_message.try do |message|\n      Lucky::Log.debug { message }\n    end\n  end\n\n  # The default global content-type header for HTML\n  def html_content_type : String\n    \"text/html\"\n  end\n\n  # The default global content-type header for JSON\n  def json_content_type : String\n    \"application/json\"\n  end\n\n  # The default global content-type header for XML\n  def xml_content_type : String\n    \"text/xml\"\n  end\n\n  # The default global content-type header for Plain text\n  def plain_content_type : String\n    \"text/plain\"\n  end\n\n  def file(\n    path : String,\n    content_type : String? = nil,\n    disposition : String = \"attachment\",\n    filename : String? = nil,\n    status : Int32? = nil,\n  ) : Lucky::FileResponse\n    Lucky::FileResponse.new(context, path, content_type, disposition, filename, status)\n  end\n\n  def file(\n    path : String,\n    content_type : String? = nil,\n    disposition : String = \"attachment\",\n    filename : String? = nil,\n    status : HTTP::Status = HTTP::Status::OK,\n  ) : Lucky::FileResponse\n    file(path, content_type, disposition, filename, status.value)\n  end\n\n  def data(\n    data : String,\n    content_type : String = \"application/octet-stream\",\n    disposition : String = \"attachment\",\n    filename : String? = nil,\n    status : Int32? = nil,\n  ) : Lucky::DataResponse\n    Lucky::DataResponse.new(context, data, content_type, disposition, filename, status)\n  end\n\n  def send_text_response(\n    body : String,\n    content_type : String,\n    status : Int32? = nil,\n  ) : Lucky::TextResponse\n    Lucky::TextResponse.new(\n      context,\n      content_type,\n      body,\n      status: status,\n      enable_cookies: enable_cookies?\n    )\n  end\n\n  def plain_text(body : String, status : Int32? = nil, content_type : String = plain_content_type) : Lucky::TextResponse\n    send_text_response(body, content_type, status)\n  end\n\n  def plain_text(body : String, status : HTTP::Status, content_type : String = plain_content_type) : Lucky::TextResponse\n    plain_text(body, status: status.value)\n  end\n\n  def head(status : Int32) : Lucky::TextResponse\n    send_text_response(body: \"\", content_type: \"\", status: status)\n  end\n\n  def head(status : HTTP::Status) : Lucky::TextResponse\n    head(status.value)\n  end\n\n  # allows json-compatible string to be returned directly\n  def raw_json(body : String, status : Int32? = nil, content_type : String = json_content_type) : Lucky::TextResponse\n    send_text_response(body, content_type, status)\n  end\n\n  def raw_json(body : String, status : HTTP::Status, content_type : String = json_content_type) : Lucky::TextResponse\n    raw_json(body, status: status.value, content_type: content_type)\n  end\n\n  # :nodoc:\n  def json(body : String, status : Int32? = nil, content_type : String = json_content_type) : Lucky::TextResponse\n    {%\n      raise <<-ERROR\n\n      Looks like your trying to pass a string to json response.\n\n      Use `raw_json(body, ...)` instead.\n\n      NOTE: `raw_json` doesn't validate JSON string validity/integrity, use at your own risk.\n\n      ERROR\n    %}\n  end\n\n  def json(body, status : Int32? = nil, content_type : String = json_content_type) : Lucky::TextResponse\n    raw_json(body.to_json, status, content_type)\n  end\n\n  def json(body, status : HTTP::Status, content_type : String = json_content_type) : Lucky::TextResponse\n    json(body, status: status.value, content_type: content_type)\n  end\n\n  def xml(body : String, status : Int32? = nil, content_type : String = xml_content_type) : Lucky::TextResponse\n    send_text_response(body, content_type, status)\n  end\n\n  def xml(body, status : HTTP::Status, content_type : String = xml_content_type) : Lucky::TextResponse\n    xml(body, status: status.value, content_type: content_type)\n  end\n\n  # Render a Component as an HTML response.\n  #\n  # ```\n  # get \"/foo\" do\n  #   component MyComponent, with: :args\n  # end\n  # ```\n  def component(comp : Lucky::BaseComponent.class, status : Int32? = nil, **named_args) : Lucky::TextResponse\n    send_text_response(\n      comp.new(**named_args).context(context).render_to_string,\n      html_content_type,\n      status\n    )\n  end\n\n  def component(comp : Lucky::BaseComponent.class, status : HTTP::Status, **named_args) : Lucky::TextResponse\n    component(comp, status.value, **named_args)\n  end\nend\n"
  },
  {
    "path": "src/lucky/renderable_error.cr",
    "content": "module Lucky::RenderableError\n  abstract def renderable_status : Int32\n  abstract def renderable_message : String\nend\n"
  },
  {
    "path": "src/lucky/request_body_limit.cr",
    "content": "module Lucky::RequestBodyLimit\n  macro included\n    def self.request_body_limit : Int64?\n      nil\n    end\n  end\n\n  macro set_request_body_limit(bytes)\n    def self.request_body_limit : Int64?\n      ({{ bytes }}).to_i64\n    end\n  end\n\n  macro clear_request_body_limit\n    def self.request_body_limit : Int64?\n      nil\n    end\n  end\nend\n"
  },
  {
    "path": "src/lucky/request_body_reader.cr",
    "content": "# :nodoc:\nclass Lucky::RequestBodyReader\n  getter request : HTTP::Request\n\n  def initialize(@request : HTTP::Request)\n  end\n\n  # Returns the body of the `request` and\n  # reassigns the value back to the request body\n  # to allow for re-reading\n  def body : String\n    contents = request.body.try(&.gets_to_end) || \"\"\n    request.body = IO::Memory.new(contents)\n    contents\n  end\nend\n"
  },
  {
    "path": "src/lucky/request_expectations.cr",
    "content": "# Expectations for writing specs for HTTP requests and responses\nmodule Lucky::RequestExpectations\n  # Test that the HTTP response has the expected status and JSON body\n  #\n  # ```\n  # user = UserFactory.create\n  #\n  # response = AppClient.new.exec(Users::Show.with(user.id))\n  #\n  # response.should send_json(200, {status: \"success\"})\n  # response.should send_json(200, name: user.name, age: user.age)\n  # ```\n  def send_json(status, **expected)\n    send_json(status, expected)\n  end\n\n  def send_json(status, expected : NamedTuple)\n    SendJsonExpectation.new(status, expected.to_json)\n  end\n\n  # :nodoc:\n  struct SendJsonExpectation\n    private getter expected_status : Int32\n    private getter expected_json : JSON::Any\n\n    def initialize(@expected_status, expected_json : String)\n      @expected_json = JSON.parse(expected_json)\n    end\n\n    def match(actual_response : HTTP::Client::Response) : Bool\n      actual_json = JSON.parse(actual_response.body)\n\n      actual_response.status_code == expected_status &&\n        actual_response_includes_expected_json?(actual_json)\n    rescue JSON::ParseException\n      false\n    end\n\n    private def actual_response_includes_expected_json?(actual_json) : Bool\n      expected_json.as_h.all? do |expected_key, expected_value|\n        actual_json.as_h.has_key?(expected_key) &&\n          actual_json.as_h[expected_key] == expected_value\n      end\n    end\n\n    def failure_message(actual_response : HTTP::Client::Response) : String\n      if actual_response.status_code != expected_status\n        \"Expected status of #{expected_status}. Instead got #{actual_response.status_code}.\"\n      else\n        incorrect_response_body_message(actual_response)\n      end\n    rescue JSON::ParseException\n      \"Response body is not valid JSON.\"\n    end\n\n    private def incorrect_response_body_message(actual_response : HTTP::Client::Response) : String\n      actual_json = JSON.parse(actual_response.body).as_h\n\n      expected_json.as_h.each { |expected_key, expected_value|\n        if !actual_json.has_key?(expected_key)\n          break <<-TEXT\n          Expected response to have JSON key #{expected_key.dump}, but it was not present.\n\n          Response keys: #{actual_json.keys.map(&.dump).join(\", \")}\n          TEXT\n        elsif actual_json[expected_key]? != expected_value\n          break <<-TEXT\n          JSON response was incorrect.\n\n          Expected #{expected_key.dump} to be:\n\n            #{expected_value.inspect}\n\n          Instead got:\n\n            #{actual_json[expected_key].inspect}\n          TEXT\n        end\n      }.to_s\n    end\n\n    def negative_failure_message(actual_value) : String\n      \"Didn't expect JSON response to match, but it was the same.\"\n    end\n  end\nend\n"
  },
  {
    "path": "src/lucky/request_id_handler.cr",
    "content": "# Sets the HTTP::Server::Context#request_id value\n#\n# Configure the `set_request_id` Proc to return a\n# new `String` value on each request. This can be\n# used to group logs and such that may be ran asynchronously.\n#\n# ```\n# Lucky::RequestIdHandler.configure do |settings|\n#   settings.set_request_id = ->(context : HTTP::Server::Context) {\n#     UUID.random.to_s\n#   }\n# end\n# ```\nclass Lucky::RequestIdHandler\n  include HTTP::Handler\n\n  Habitat.create do\n    setting set_request_id : Proc(HTTP::Server::Context, String)? = nil\n  end\n\n  def call(context : HTTP::Server::Context)\n    context.request_id = settings.set_request_id.try &.call(context)\n\n    call_next(context)\n  end\nend\n"
  },
  {
    "path": "src/lucky/request_type_helpers.cr",
    "content": "# These helpers check HTTP headers to determine \"request MIME type\".\n#\n# Generally the `Accept` header is checked, but some check other headers, such as `X-Requested-With`.\nmodule Lucky::RequestTypeHelpers\n  private def default_format\n    {% raise <<-TEXT\n    Must set 'accepted_formats' or 'default_format' in #{@type} (or its parent class).\n\n    Example of 'accepted_formats' (recommended):\n\n      abstract class MyBaseAction < Lucky::Action\n        accepted_formats [:html, :json], default: :html\n      end\n\n    Example of 'default_format' (typically used only in Errors::Show):\n\n      class Errors::Show < Lucky::ErrorAction\n        default_format :html\n      end\n\n\n    TEXT\n    %}\n  end\n\n  # If Lucky doesn't find a format then default to the given format\n  #\n  # ```\n  # default_format :html\n  # ```\n  macro default_format(format)\n    private def default_format : Symbol\n      {{ format }}\n    end\n  end\n\n  private def clients_desired_format : Symbol\n    context._clients_desired_format ||= determine_clients_desired_format\n  end\n\n  private def determine_clients_desired_format : Symbol\n    # Check URL format first (e.g., /reports/123.csv)\n    if url_format = context._url_format\n      convert_url_format_to_symbol(url_format)\n    else\n      # No URL format - use original Lucky behavior (raise on unknown Accept header)\n      # This preserves existing error handling behavior when no URL format override exists\n      Lucky::MimeType.determine_clients_desired_format(request, default_format, self.class._accepted_formats) ||\n        raise Lucky::UnknownAcceptHeaderError.new(request)\n    end\n  end\n\n  private def convert_url_format_to_symbol(url_format : Lucky::Format | Lucky::FormatRegistry::CustomFormat) : Symbol\n    case url_format\n    when Lucky::Format\n      convert_enum_format_to_symbol(url_format)\n    when Lucky::FormatRegistry::CustomFormat\n      convert_custom_format_to_symbol(url_format)\n    else\n      # Fallback to Accept header if URL format is somehow invalid\n      # Since URL format failed, gracefully handle Accept header errors too\n      determine_format_from_accept_header_with_fallback\n    end\n  end\n\n  private def convert_enum_format_to_symbol(format : Lucky::Format) : Symbol\n    case format\n    in .html?             then :html\n    in .json?             then :json\n    in .xml?              then :xml\n    in .csv?              then :csv\n    in .js?               then :js\n    in .plain_text?       then :plain_text\n    in .yaml?             then :yaml\n    in .rss?              then :rss\n    in .atom?             then :atom\n    in .ics?              then :ics\n    in .css?              then :css\n    in .ajax?             then :ajax\n    in .multipart_form?   then :multipart_form\n    in .url_encoded_form? then :url_encoded_form\n    end\n  end\n\n  private def convert_custom_format_to_symbol(format : Lucky::FormatRegistry::CustomFormat) : Symbol\n    # For custom formats, we need to manually handle the symbol creation\n    # Since Crystal doesn't have dynamic symbol creation, we'll use a macro or fallback\n    name = format.name.underscore.tr(\"-\", \"_\")\n    case name\n    when \"pdf\"  then :pdf\n    when \"zip\"  then :zip\n    when \"doc\"  then :doc\n    when \"docx\" then :docx\n    when \"xls\"  then :xls\n    when \"xlsx\" then :xlsx\n    when \"ppt\"  then :ppt\n    when \"pptx\" then :pptx\n    else\n      # If we can't convert to a known symbol, fall back to default behavior\n      # But since we have a URL format, gracefully handle Accept header errors\n      determine_format_from_accept_header_with_fallback\n    end\n  end\n\n  private def determine_format_from_accept_header_with_fallback : Symbol\n    Lucky::MimeType.determine_clients_desired_format(request, default_format, self.class._accepted_formats) ||\n      raise Lucky::UnknownAcceptHeaderError.new(request)\n  rescue Lucky::UnknownAcceptHeaderError\n    default_format\n  end\n\n  # Check whether the request wants the passed in format\n  def accepts?(format : Symbol) : Bool\n    clients_desired_format == format\n  end\n\n  # Get the detected format as an enum (if available)\n  # Returns nil if the format came from Accept header or is unknown\n  def url_format : Lucky::Format | Lucky::FormatRegistry::CustomFormat | Nil\n    context._url_format\n  end\n\n  # Check if the request is JSON\n  #\n  # This tests if the request type is `application/json`\n  def json? : Bool\n    accepts?(:json)\n  end\n\n  # Check if the request is HTML\n  #\n  # Browsers typically send vague Accept headers. Because of that this will return `true` when:\n  #\n  #  * The `accepted_formats` includes `:html`\n  #  * And the `Accept` header is the browser default. For example `text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8\n  def html? : Bool\n    accepts?(:html)\n  end\n\n  # Check if the request is XML\n  #\n  # This tests if the request type is `application/xml`\n  def xml? : Bool\n    accepts?(:xml)\n  end\n\n  # Check if the request is plain text\n  #\n  # This tests if the `Accept` header type is `text/plain` or\n  # with the optional character set per W3 RFC1341 7.1\n  def plain_text? : Bool\n    accepts?(:plain_text)\n  end\n\n  # Check if the request is AJAX\n  #\n  # This tests if the `X-Requested-With` header is `XMLHttpRequest`\n  def ajax? : Bool\n    request.headers[\"X-Requested-With\"]?.try(&.downcase) == \"xmlhttprequest\"\n  end\n\n  # Check if the request is multipart\n  #\n  # This tests if the `Content-Type` header is `multipart/form-data`\n  def multipart? : Bool\n    !!request.headers[\"Content-Type\"]?.try(&.downcase.starts_with?(\"multipart/form-data\"))\n  end\nend\n"
  },
  {
    "path": "src/lucky/response.cr",
    "content": "abstract class Lucky::Response\n  abstract def print\n  abstract def status : Int\n  abstract def debug_message : String?\nend\n"
  },
  {
    "path": "src/lucky/routable.cr",
    "content": "# Methods for routing HTTP requests and their parameters to actions.\nmodule Lucky::Routable\n  macro included\n    ROUTE_SETTINGS = {prefix: \"\"}\n\n    macro included\n      inherit_route_settings\n    end\n\n    macro inherited\n      ROUTE_SETTINGS = {prefix: \"\"}\n      inherit_route_settings\n    end\n  end\n\n  macro inherit_route_settings\n    \\{% for k, v in @type.ancestors.first.constant :ROUTE_SETTINGS %}\n      \\{% ROUTE_SETTINGS[k] = v %}\n    \\{% end %}\n  end\n\n  macro fallback\n    Lucky::RouteNotFoundHandler.fallback_action = {{ @type.name.id }}\n    setup_call_method({{ yield }})\n  end\n\n  # Sets the prefix for all routes defined by the match\n  # and http method (get, put, post, etc..) macros\n  macro route_prefix(prefix)\n    {% unless prefix.starts_with?(\"/\") %}\n      {% prefix.raise %(Prefix must start with a slash. Example: \"/#{prefix.id}\") %}\n    {% end %}\n    {% ROUTE_SETTINGS[:prefix] = prefix %}\n  end\n\n  {% for http_method in [:get, :put, :post, :patch, :trace, :delete] %}\n    # Define a route that responds to a {{ http_method.id.upcase }} request\n    #\n    # Use these methods if you need a custom path or are using a non-restful\n    # route. For example:\n    #\n    # ```\n    # class Profile::ImageUpload\n    #   {{ http_method.id }} \"/profile/image/:id\" do\n    #     # action code here\n    #   end\n    # end\n    # ```\n    #\n    # will respond to an `HTTP {{ http_method.id.upcase }}` request.\n    #\n    # **See also** our guides for more information and examples:\n    # * [Routing](https://luckyframework.org/guides/http-and-routing/routing-and-params#routing)\n    macro {{ http_method.id }}(path, *path_aliases)\n      match(:{{ http_method.id }}, \\{{ path }}) do\n        \\{{ yield }}\n      end\n\n      setup_path_aliases({{ http_method.id }}, \\{{ path_aliases.splat }})\n    end\n  {% end %}\n\n  # :nodoc:\n  macro setup_path_aliases(method, *path_aliases)\n    {% for path in path_aliases %}\n      {% unless path.starts_with?(\"/\") %}\n        {% path.raise %(Path alias must start with a slash. Example: \"/#{path.id}\") %}\n      {% end %}\n      add_route(:{{ method }}, {{ path }}, {{ @type.name.id }})\n    {% end %}\n  end\n\n  # Define a route with a custom HTTP method.\n  #\n  # Use this method if you need to match a route with a custom HTTP method (verb).\n  # For example:\n  #\n  # ```\n  # class Profile::Show\n  #   match :options, \"/profile\" do\n  #     # action code here\n  #   end\n  # end\n  # ```\n  # Will respond to an `HTTP OPTIONS` request.\n  macro match(method, path)\n    {% unless path.starts_with?(\"/\") %}\n      {% path.raise %(Path must start with a slash. Example: \"/#{path.id}\") %}\n    {% end %}\n\n    {% unless method == method.downcase %}\n      {% method.raise \"HTTP methods should be lower-case symbols. Use #{method.downcase} instead of #{method}.\" %}\n    {% end %}\n\n    add_route({{method}}, {{ path }}, {{ @type.name.id }})\n\n    setup_call_method({{ yield }})\n  end\n\n  # :nodoc:\n  macro setup_call_method(body)\n    # Return a response with `html`, `redirect`, or `json` at the end of your action.\n    # Ensure all conditionals (like if/else) return a response with html, redirect, json, etc.\n    private def action_call_body : Lucky::Response\n      {{ body }}\n    end\n\n    def call : Lucky::Response\n      # Ensure clients_desired_format is cached by calling it\n      clients_desired_format\n\n      %pipe_result = run_before_pipes\n\n      %response = if %pipe_result.is_a?(Lucky::Response)\n        %pipe_result\n      else\n        action_call_body\n      end\n\n      %pipe_result = run_after_pipes\n\n      if %pipe_result.is_a?(Lucky::Response)\n        %pipe_result\n      else\n        %response\n      end\n    end\n  end\n\n  # Implement this macro in your action to check the path for a particular style.\n  #\n  # By default Lucky ships with a `Lucky::EnforceUnderscoredRoute` that is included\n  # in your `BrowserAction` and `ApiAction` (as of Lucky 0.28)\n  #\n  # See the docs for `Lucky::EnforceUnderscoredRoute` to learn how to use it or disable it.\n  macro enforce_route_style(path, action)\n    # no-op by default\n  end\n\n  private NORMALIZED_ROUTES = {} of _ => _\n\n  # :nodoc:\n  macro enforce_route_uniqueness(method, original_path)\n    # Regex for capturing the param part for normalization\n    #\n    # So \"/users/:user_id\" is changed to \"/users/:normalized\"\n    {% normalized_path = original_path.gsub(/(\\:\\w*)/, \":normalized\") %}\n    {% normalized_key = \"#{method.id} #{normalized_path.id}\" %}\n\n    {% if already_used_route = NORMALIZED_ROUTES[normalized_key] %}\n      {% raise <<-ERROR\n      #{original_path} in '#{@type.name}' collides with the path in '#{already_used_route[:action]}'\n\n      Try this...\n\n        ▸ Change the paths in one of the actions to something unique\n        ▸ Run `lucky routes` to verify all of your route paths\n\n      ERROR\n      %}\n    {% else %}\n      {% NORMALIZED_ROUTES[normalized_key] = {\n           normalized_path: normalized_path,\n           original_path:   original_path,\n           method:          method,\n           action:          @type.name,\n         } %}\n    {% end %}\n  end\n\n  # :nodoc:\n  macro add_route(method, path, action)\n    {% path = ROUTE_SETTINGS[:prefix] + path %}\n\n    enforce_route_style({{ path }}, {{ @type.name.id }})\n    enforce_route_uniqueness({{method}}, {{ path }})\n\n    Lucky.router.add({{ method }}, {{ path }}, {{ @type.name.id }})\n    {% path_parts = path.split('/').reject(&.empty?) %}\n    {% path_params = path_parts.select(&.starts_with?(':')) %}\n    {% optional_path_params = path_parts.select(&.starts_with?(\"?:\")) %}\n    {% glob_param = path_parts.select(&.starts_with?(\"*\")) %}\n    {% if glob_param.size > 1 %}\n      {% glob_param.raise \"Only one glob can be in a path, but found more than one.\" %}\n    {% end %}\n    {% glob_param = glob_param.first %}\n    {% if glob_param && path_parts.last != glob_param %}\n      {% glob_param.raise \"Glob param must be defined at the end of the path.\" %}\n    {% end %}\n\n    {% for param in path_params %}\n      {% if param.includes?(\"-\") %}\n        {% param.raise \"Path variables must only use underscores. Use #{param.gsub(/-/, \"_\")} instead of #{param}.\" %}\n      {% end %}\n      {% part = param.gsub(/:/, \"\").id %}\n      def {{ part }} : String\n        params.get(:{{ part }})\n      end\n    {% end %}\n\n    {% for param in optional_path_params %}\n      {% if param.includes?(\"-\") %}\n        {% param.raise \"Optional path variables must only use underscores. Use #{param.gsub(/-/, \"_\")} instead of #{param}.\" %}\n      {% end %}\n      {% part = param.gsub(/^\\?:/, \"\").id %}\n      def {{ part }} : String?\n        params.get?(:{{ part }})\n      end\n    {% end %}\n\n    {% if glob_param %}\n      {% if glob_param.includes?(\"-\") %}\n        {% glob_param.raise \"Named globs must only use underscores. Use #{glob_param.gsub(/-/, \"_\")} instead of #{glob_param}.\" %}\n      {% end %}\n      {% part = nil %}\n      {% if glob_param.starts_with?(\"*:\") %}\n        {% part = glob_param.gsub(/\\*:/, \"\") %}\n      {% elsif glob_param == \"*\" %}\n        {% part = \"glob\" %}\n      {% else %}\n        {% glob_param.raise \"Invalid glob format #{glob_param}.\" %}\n      {% end %}\n      def {{ part.id }} : String?\n        params.get?({{ part.id.symbolize }})\n      end\n    {% end %}\n\n    # Extract glob param name for use in URL building methods\n    {% glob_param_name = nil %}\n    {% if glob_param %}\n      {% if glob_param.starts_with?(\"*:\") %}\n        {% glob_param_name = glob_param.gsub(/\\*:/, \"\") %}\n      {% elsif glob_param == \"*\" %}\n        {% glob_param_name = \"glob\" %}\n      {% end %}\n    {% end %}\n\n    class RouteHelper < Lucky::RouteHelper\n      {% if @type.has_constant?(:ACCEPTED_FORMAT_SYMBOLS) %}\n        {% for ext in @type.constant(:ACCEPTED_FORMAT_SYMBOLS) %}\n          def as_{{ ext.id }} : Lucky::RouteHelper\n            extension = Lucky::RouteHelper.resolve_extension({{ ext }})\n            Lucky::RouteHelper.new(\n              method,\n              Lucky::RouteHelper.insert_extension(path, extension),\n              subdomain\n            )\n          end\n        {% end %}\n      {% end %}\n    end\n\n    struct FormatBuilder\n      getter extension : String\n\n      def initialize(@extension : String)\n      end\n\n      def with(*args, **named_args) : Lucky::RouteHelper\n        route = {{ @type.name.id }}.with(*args, **named_args)\n        Lucky::RouteHelper.new(\n          route.method,\n          Lucky::RouteHelper.insert_extension(route.path, @extension),\n          route.subdomain\n        )\n      end\n\n      def path : String\n        route = {{ @type.name.id }}.route\n        Lucky::RouteHelper.insert_extension(route.path, @extension)\n      end\n\n      def url : String\n        route = {{ @type.name.id }}.route\n        Lucky::RouteHelper.new(\n          route.method,\n          Lucky::RouteHelper.insert_extension(route.path, @extension),\n          route.subdomain\n        ).url\n      end\n    end\n\n    {% if @type.has_constant?(:ACCEPTED_FORMAT_SYMBOLS) %}\n      {% for ext in @type.constant(:ACCEPTED_FORMAT_SYMBOLS) %}\n        def self.as_{{ ext.id }} : FormatBuilder\n          FormatBuilder.new(Lucky::RouteHelper.resolve_extension({{ ext }}))\n        end\n      {% end %}\n    {% end %}\n\n    def self.path(*args, **named_args) : String\n      route(*args, **named_args).path\n    end\n\n    def self.url(*args, **named_args) : String\n      route(*args, **named_args).url\n    end\n\n    def self.url_without_query_params(\n    {% for param in path_params %}\n      {{ param.gsub(/:/, \"\").id }},\n    {% end %}\n    {% for param in optional_path_params %}\n      {{ param.gsub(/^\\?:/, \"\").id }} = nil,\n    {% end %}\n    {% if glob_param_name %}\n      {{ glob_param_name.id }} = nil,\n    {% end %}\n    subdomain : String? = nil\n    ) : String\n      path = path_from_parts(\n        {% for param in path_params %}\n          {{ param.gsub(/:/, \"\").id }},\n        {% end %}\n        {% for param in optional_path_params %}\n          {{ param.gsub(/^\\?:/, \"\").id }},\n        {% end %}\n        {% if glob_param_name %}\n          {{ glob_param_name.id }},\n        {% end %}\n      )\n      Lucky::RouteHelper.new({{ method }}, path, subdomain).url\n    end\n\n    def self.path_without_query_params(\n    {% for param in path_params %}\n      {{ param.gsub(/:/, \"\").id }},\n    {% end %}\n    {% for param in optional_path_params %}\n      {{ param.gsub(/^\\?:/, \"\").id }} = nil,\n    {% end %}\n    {% if glob_param_name %}\n      {{ glob_param_name.id }} = nil,\n    {% end %}\n    subdomain : String? = nil\n    ) : String\n      path = path_from_parts(\n        {% for param in path_params %}\n          {{ param.gsub(/:/, \"\").id }},\n        {% end %}\n        {% for param in optional_path_params %}\n          {{ param.gsub(/^\\?:/, \"\").id }},\n        {% end %}\n        {% if glob_param_name %}\n          {{ glob_param_name.id }},\n        {% end %}\n      )\n      Lucky::RouteHelper.new({{ method }}, path, subdomain).path\n    end\n\n    {% params_with_defaults = PARAM_DECLARATIONS.select do |decl|\n         !decl.value.is_a?(Nop) || decl.type.is_a?(Union) && decl.type.resolve.nilable?\n       end %}\n    {% params_without_defaults = PARAM_DECLARATIONS.reject do |decl|\n         params_with_defaults.includes? decl\n       end %}\n\n    def self.route(\n    # required path variables\n    {% for param in path_params %}\n      {{ param.gsub(/:/, \"\").id }},\n    {% end %}\n\n    # required params\n    {% for param in params_without_defaults %}\n      {{ param }},\n    {% end %}\n\n    # params with a default value set are always nilable\n    {% for param in params_with_defaults %}\n      {{ param.var }} = nil,\n    {% end %}\n\n    # optional path variables are nilable\n    {% for param in optional_path_params %}\n      {{ param.gsub(/^\\?:/, \"\").id }} = nil,\n    {% end %}\n\n    # glob param is optional\n    {% if glob_param_name %}\n      {{ glob_param_name.id }} = nil,\n    {% end %}\n    anchor : String? = nil,\n    subdomain : String? = nil\n    ) : RouteHelper\n      path = String.build do |io|\n        path_from_parts(\n          io,\n          {% for param in path_params %}\n            {{ param.gsub(/:/, \"\").id }},\n          {% end %}\n          {% for param in optional_path_params %}\n            {{ param.gsub(/^\\?:/, \"\").id }},\n          {% end %}\n          {% if glob_param_name %}\n            {{ glob_param_name.id }},\n          {% end %}\n        )\n\n        query_params = URI::Params.build do |builder|\n          {% for param in PARAM_DECLARATIONS %}\n            _param = {{ param.var }}\n\n            # add query param if given and not nil\n            unless _param.nil?\n              if _param.is_a?(Array)\n                builder.add(\"{{ param.var }}[]\", _param.map(&.to_s))\n              else\n                builder.add(\"{{ param.var }}\", _param.to_s)\n              end\n            end\n          {% end %}\n        end\n\n        unless query_params.empty?\n          io << '?'\n          io << query_params\n        end\n\n        anchor.try do |value|\n          io << '#'\n          URI.encode_www_form(value, io)\n        end\n      end\n\n      RouteHelper.new({{ method }}, path.presence || \"/\", subdomain)\n    end\n\n    def self.with(\n      # required path variables\n      {% for param in path_params %}\n        {{ param.gsub(/:/, \"\").id }},\n      {% end %}\n\n      # required params\n      {% for param in params_without_defaults %}\n        {{ param }},\n      {% end %}\n\n      # params with a default value set are always nilable\n      {% for param in params_with_defaults %}\n        {{ param.var }} = nil,\n      {% end %}\n\n      # optional path variables are nilable\n      {% for param in optional_path_params %}\n        {{ param.gsub(/^\\?:/, \"\").id }} = nil,\n      {% end %}\n\n      # glob param is optional\n      {% if glob_param_name %}\n        {{ glob_param_name.id }} = nil,\n      {% end %}\n      anchor : String? = nil,\n      subdomain : String? = nil\n    ) : RouteHelper\n      \\{% begin %}\n      route(\n        \\{% for arg in @def.args %}\n          \\{{ arg.name }}: \\{{ arg.internal_name }},\n        \\{% end %}\n      )\n      \\{% end %}\n    end\n\n    private def self.path_from_parts(\n      io : IO,\n      {% for param in path_params %}\n        {{ param.gsub(/:/, \"\").id }},\n      {% end %}\n      {% for param in optional_path_params %}\n        {{ param.gsub(/^\\?:/, \"\").id }},\n      {% end %}\n      {% if glob_param_name %}\n        {{ glob_param_name.id }},\n      {% end %}\n    ) : Nil\n      {% for part in path_parts %}\n        {% if part.starts_with?(\"?:\") %}\n          if {{ part.gsub(/^\\?:/, \"\").id }}\n            io << '/'\n            URI.encode_www_form({{ part.gsub(/^\\?:/, \"\").id }}.to_param, io)\n          end\n        {% elsif part.starts_with?(':') %}\n          io << '/'\n          URI.encode_www_form({{ part.gsub(/:/, \"\").id }}.to_param, io)\n        {% elsif part.starts_with?(\"*\") %}\n          # glob param handled separately below\n        {% else %}\n          io << '/'\n          URI.encode_www_form({{ part }}, io)\n        {% end %}\n      {% end %}\n      {% if glob_param_name %}\n        if _glob_value = {{ glob_param_name.id }}\n          _glob_value.to_param.split('/').each do |segment|\n            unless segment.empty?\n              io << '/'\n              URI.encode_www_form(segment, io)\n            end\n          end\n        end\n      {% end %}\n    end\n\n    private def self.path_from_parts(\n        {% for param in path_params %}\n          {{ param.gsub(/:/, \"\").id }},\n        {% end %}\n        {% for param in optional_path_params %}\n          {{ param.gsub(/^\\?:/, \"\").id }},\n        {% end %}\n        {% if glob_param_name %}\n          {{ glob_param_name.id }},\n        {% end %}\n    ) : String\n      path = String.build do |io|\n        path_from_parts(\n          io,\n          {% for param in path_params %}\n            {{ param.gsub(/:/, \"\").id }},\n          {% end %}\n          {% for param in optional_path_params %}\n            {{ param.gsub(/^\\?:/, \"\").id }},\n          {% end %}\n          {% if glob_param_name %}\n            {{ glob_param_name.id }},\n          {% end %}\n        )\n      end\n\n      path.presence || \"/\"\n    end\n  end\n\n  macro included\n    PARAM_DECLARATIONS = [] of Crystal::Macros::TypeDeclaration\n\n    @@query_param_declarations : Array(String) = [] of String\n    class_getter query_param_declarations : Array(String)\n\n    macro inherited\n      inherit_param_declarations\n    end\n  end\n\n  # :nodoc:\n  macro inherit_param_declarations\n    PARAM_DECLARATIONS = [] of Crystal::Macros::TypeDeclaration\n\n    \\{% for param_declaration in @type.ancestors.first.constant :PARAM_DECLARATIONS %}\n      \\{% PARAM_DECLARATIONS << param_declaration %}\n    \\{% end %}\n  end\n\n  # Access query and POST parameters\n  #\n  # When a query parameter or POST data is passed to an action, it is stored in\n  # the params object. But accessing the param directly from the params object\n  # isn't type safe. Enter `param`. It checks the given param's type and makes\n  # it easily available inside the action.\n  #\n  # ```\n  # class Posts::Index < BrowserAction\n  #   param page : Int32?\n  #\n  #   get \"/posts\" do\n  #     plain_text \"Posts - Page #{page || 1}\"\n  #   end\n  # end\n  # ```\n  #\n  # To generate a link with a param, use the `with` method:\n  # `Posts::Index.with(10).path` which will generate `/posts?page=10`. Visiting\n  # that path would render the above action like this:\n  #\n  # ```text\n  # Posts - Page 10\n  # ```\n  #\n  # This works behind the scenes by creating a `page` method in the action to\n  # access the parameter.\n  #\n  # **Note:** Params can also have a default, but then their routes will not\n  # include the parameter in the query string. Using the `with(10)` method for a\n  # param like this:\n  # `param page : Int32 = 1` will only generate `/posts`.\n  #\n  # These parameters are also typed. The path `/posts?page=ten` will raise a\n  # `Lucky::InvalidParamError` error because `ten` is a String not an\n  # Int32.\n  #\n  # Additionally, if the param is non-optional it will raise the\n  # `Lucky::MissingParamError` error if the required param is absent\n  # when making a request:\n  #\n  # ```\n  # class UserConfirmations::New < BrowserAction\n  #   param token : String # this param is required!\n  #\n  #   get \"/user_confirmations/new\" do\n  #     # confirm the user with their `token`\n  #   end\n  # end\n  # ```\n  #\n  # When visiting this page, the path _must_ contain the token parameter:\n  # `/user_confirmations/new?token=abc123`\n  macro param(type_declaration)\n    {% unless type_declaration.is_a?(TypeDeclaration) %}\n      {% raise \"'param' expects a type declaration like 'name : String', instead got: '#{type_declaration}'\" %}\n    {% end %}\n\n    {% PARAM_DECLARATIONS << type_declaration %}\n    @@query_param_declarations << \"{{ type_declaration.var }} : {{ type_declaration.type }}\"\n\n    getter {{ type_declaration.var }} : {{ type_declaration.type }} do\n      {% is_nilable_type = type_declaration.type.resolve.nilable? %}\n      {% base_type = is_nilable_type ? type_declaration.type.types.first : type_declaration.type %}\n      {% is_array = base_type.is_a?(Generic) %}\n      {% type = is_array ? base_type.type_vars.first : base_type %}\n\n      {% if is_array %}\n      val = params.get_all?(:{{ type_declaration.var.id }})\n      {% else %}\n      val = params.get?(:{{ type_declaration.var.id }}).presence\n      {% end %}\n\n      if val.nil?\n        default_or_nil = {{ type_declaration.value.is_a?(Nop) ? nil : type_declaration.value }}\n        {% if is_nilable_type %}\n          return default_or_nil\n        {% else %}\n          if default_or_nil.nil?\n            raise Lucky::MissingParamError.new(\"{{ type_declaration.var.id }}\")\n          else\n            return default_or_nil\n          end\n        {% end %}\n      end\n\n      result = Lucky::ParamParser.parse(val, {{ base_type }})\n\n      if result.nil?\n        raise Lucky::InvalidParamError.new(\n          param_name: \"{{ type_declaration.var.id }}\",\n          param_value: val.to_s,\n          param_type: \"{{ type }}\"\n        )\n      end\n\n      result\n    end\n  end\nend\n"
  },
  {
    "path": "src/lucky/route_handler.cr",
    "content": "require \"colorize\"\n\nclass Lucky::RouteHandler\n  include HTTP::Handler\n\n  def call(context : HTTP::Server::Context)\n    original_path = context.request.path\n\n    # Extract format from URL path and strip it for route matching\n    if url_format = Lucky::MimeType.extract_format_from_path(original_path)\n      context._url_format = url_format\n      # Create a modified request with format-stripped path for route matching\n      path_without_format = original_path.sub(/^([^?]*)\\.[a-zA-Z0-9]+(\\?.*)?$/, \"\\\\1\\\\2\")\n      lookup_request_path = path_without_format\n    else\n      lookup_request_path = original_path\n    end\n\n    handler = Lucky.router.find_action(context.request.method, lookup_request_path)\n    if handler\n      Lucky::Log.dexter.debug { {handled_by: handler.payload.to_s} }\n      handler.payload.new(context, handler.params).perform_action\n    else\n      call_next(context)\n    end\n  end\nend\n"
  },
  {
    "path": "src/lucky/route_helper.cr",
    "content": "class Lucky::RouteHelper\n  Habitat.create do\n    setting base_uri : String\n  end\n\n  getter method : Symbol\n  getter path : String\n  getter subdomain : String?\n\n  def initialize(@method : Symbol, @path : String, @subdomain : String? = nil)\n  end\n\n  def url : String\n    if subdomain\n      build_subdomain_url\n    else\n      settings.base_uri + path\n    end\n  end\n\n  private def build_subdomain_url : String\n    uri = URI.parse(settings.base_uri)\n    host = uri.host || raise \"URI host cannot be nil\"\n    subdomain_value = subdomain || raise \"Subdomain cannot be nil in build_subdomain_url\"\n\n    # Replace the existing subdomain or add one\n    host_parts = host.split('.')\n    if subdomain_exists_in_host?(host_parts)\n      host_parts[0] = subdomain_value\n    else\n      host_parts.unshift(subdomain_value)\n    end\n\n    new_host = host_parts.join('.')\n    uri.host = new_host\n    uri.to_s + path\n  end\n\n  private def subdomain_exists_in_host?(host_parts : Array(String)) : Bool\n    # If we have more than 2 parts (subdomain.domain.tld), assume subdomain exists\n    # This is a simple heuristic and could be made more sophisticated\n    host_parts.size > 2\n  end\n\n  def self.resolve_extension(ext : Symbol) : String\n    if format = Lucky::Format.parse?(ext.to_s)\n      format.to_extension\n    elsif custom = Lucky::FormatRegistry.custom_formats[ext.to_s]?\n      custom.extension\n    else\n      ext.to_s\n    end\n  end\n\n  def self.insert_extension(path : String, extension : String) : String\n    return path if extension.empty?\n\n    if index = (path.index('?') || path.index('#'))\n      \"#{path[0...index]}.#{extension}#{path[index..]}\"\n    else\n      \"#{path}.#{extension}\"\n    end\n  end\n\n  def_equals @method, @path, @subdomain\nend\n"
  },
  {
    "path": "src/lucky/route_inferrer.cr",
    "content": "require \"wordsmith\"\n\nclass Lucky::RouteInferrer\n  getter? nested_route : Bool\n  getter action_class_name : String\n\n  def initialize(@action_class_name : String, @nested_route : Bool = false)\n  end\n\n  def generate_inferred_route : String\n    %(#{http_method} \"#{path}\")\n  end\n\n  private def http_method : Symbol\n    case action_name\n    when \"delete\"\n      :delete\n    when \"create\"\n      :post\n    when \"update\"\n      :put\n    else\n      :get\n    end\n  end\n\n  private def path : String\n    '/' + all_pieces.join('/')\n  end\n\n  private def all_pieces : Array(String)\n    (namespace_pieces + parent_resource_pieces + resource_pieces).reject(&.empty?)\n  end\n\n  private def resource : String\n    action_pieces[-2]\n  end\n\n  private def action_name : String\n    action_pieces.last\n  end\n\n  private def namespace_pieces : Array(String)\n    _namespace_pieces = action_pieces.reject { |piece| piece == action_name || piece == resource }\n    if nested_route?\n      _namespace_pieces.reject { |piece| piece == parent_resource_name }\n    else\n      _namespace_pieces\n    end\n  end\n\n  private def resource_pieces : Array(String)\n    case action_name\n    when \"index\", \"create\"\n      [resource]\n    when \"new\"\n      [resource, \"new\"]\n    when \"edit\"\n      [resource, resource_id_param_name, \"edit\"]\n    when \"show\", \"update\", \"delete\"\n      [resource, resource_id_param_name]\n    else\n      resource_error\n    end\n  end\n\n  private def resource_id_param_name : String\n    \":#{Wordsmith::Inflector.singularize(resource)}_id\"\n  end\n\n  private def resource_error\n    examples = \"Users::Index # Index, Show, New, Create, Edit, Update, or Delete\"\n\n    raise <<-ERROR\n      Could not infer route for #{action_class_name}\n\n      Got:\n        #{action_class_name} (missing a known resourceful action)\n\n      Expected something like:\n        #{examples}\n    ERROR\n  end\n\n  private def parent_resource_pieces : Array(String)\n    if nested_route?\n      singularized_param_name = \":#{Wordsmith::Inflector.singularize(parent_resource_name)}_id\"\n      [parent_resource_name, singularized_param_name]\n    else\n      [] of String\n    end\n  end\n\n  private def parent_resource_name : String\n    action_pieces[-3]\n  end\n\n  private def action_pieces : Array(String)\n    action_class_name.split(\"::\").map(&.underscore)\n  end\nend\n"
  },
  {
    "path": "src/lucky/route_not_found_handler.cr",
    "content": "# This HTTP::Handler takes in the current `context`,\n# then checks to see if a `fallback_action` has been defined to render that action first.\n# If no fallback has been defined, then it will raise a `Lucky::RouteNotFoundError` exception.\n#\n# This handler should be used after the `Lucky::RouteHandler`.\n#\n# See `Lucky::Routable.fallback` for implementing the `fallback_action`.\nclass Lucky::RouteNotFoundHandler\n  include HTTP::Handler\n  class_property fallback_action : Lucky::Action.class | Nil\n\n  def call(context : HTTP::Server::Context)\n    if has_fallback?(context)\n      Lucky::Log.dexter.debug { {handled_by_fallback: fallback_action.name.to_s} }\n      fallback_action.new(context, {} of String => String).perform_action\n    else\n      raise Lucky::RouteNotFoundError.new(context)\n    end\n  end\n\n  private def has_fallback?(context) : Bool\n    !!@@fallback_action && context.request.method.upcase == \"GET\"\n  end\n\n  private def fallback_action : Lucky::Action.class\n    Lucky::RouteNotFoundHandler.fallback_action.as(Lucky::Action.class)\n  end\nend\n"
  },
  {
    "path": "src/lucky/router.cr",
    "content": "# :nodoc:\nclass Lucky::Router\n  @matcher = LuckyRouter::Matcher(Lucky::Action.class).new\n\n  # Array of path, method, and payload\n  def list_routes : Array(Tuple(String, String, Lucky::Action.class))\n    @matcher.list_routes\n  end\n\n  def add(method : Symbol, path : String, action : Lucky::Action.class) : Nil\n    @matcher.add(method.to_s, path, action)\n  end\n\n  def find_action(method : Symbol | String, path : String) : LuckyRouter::Match(Lucky::Action.class)?\n    @matcher.match method.to_s.downcase, path\n  end\n\n  def find_action(request : HTTP::Request) : LuckyRouter::Match(Lucky::Action.class)?\n    find_action(request.method, request.path)\n  end\nend\n"
  },
  {
    "path": "src/lucky/secure_headers/disable_floc.cr",
    "content": "module Lucky\n  module SecureHeaders\n    # This module disables Google FLoC by setting the\n    # [Permissions-Policy](https://github.com/WICG/floc) HTTP header to `interest-cohort=()`.\n    #\n    # This header is a part of Google's Federated Learning of Cohorts (FLoC) which is used\n    # to track browsing history instead of using 3rd-party cookies.\n    #\n    # Include this module in the actions you want to disable this feature.\n\n    # ```\n    # class BrowserAction < Lucky::Action\n    #   include Lucky::SecureHeaders::DisableFLoC\n    # end\n    # ```\n    module DisableFLoC\n      macro included\n        before set_floc_guard_header\n      end\n\n      private def set_floc_guard_header\n        unless context.response.headers[\"Permissions-Policy\"]?\n          context.response.headers[\"Permissions-Policy\"] = floc_guard_value\n        end\n        continue\n      end\n\n      private def floc_guard_value : String\n        \"interest-cohort=()\"\n      end\n    end\n  end\nend\n"
  },
  {
    "path": "src/lucky/secure_headers/set_csp_guard.cr",
    "content": "module Lucky\n  module SecureHeaders\n    # This module sets the HTTP header [Content-Security-Policy](https://wiki.owasp.org/index.php/OWASP_Secure_Headers_Project#csp).\n    # It's job is to prevent a wide range of attacks like Cross-Site Scripting.\n    #\n    # Include this module in the actions you want to add this to.\n    # A required method `csp_guard_value` must be defined\n    # ```\n    # class BrowserAction < Lucky::Action\n    #   include Lucky::SecureHeaders::SetCSPGuard\n    #\n    #   def csp_guard_value : String\n    #     \"script-src 'self'\"\n    #   end\n    # end\n    # ```\n    module SetCSPGuard\n      macro included\n        before set_csp_guard_header\n      end\n\n      abstract def csp_guard_value : String\n\n      private def set_csp_guard_header\n        context.response.headers[\"Content-Security-Policy\"] = csp_guard_value\n        continue\n      end\n    end\n  end\nend\n"
  },
  {
    "path": "src/lucky/secure_headers/set_frame_guard.cr",
    "content": "module Lucky\n  module SecureHeaders\n    # This module sets the HTTP header [X-Frame-Options](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Frame-Options).\n    # It's job is responsible for deciding which site can call your site from within a frame.\n    # For more information, read up on [Clickjacking](https://en.wikipedia.org/wiki/Clickjacking).\n    #\n    # Include this module in the actions you want to add this to.\n    # A required method `frame_guard_value` must be defined`\n    # ```\n    # class BrowserAction < Lucky::Action\n    #   include Lucky::SecureHeaders::SetFrameGuard\n    #\n    #   def frame_guard_value : String\n    #     \"deny\"\n    #   end\n    # end\n    # ```\n    #\n    # ### Options\n    # The `frame_guard_value` method must be defined and return a `String`\n    # It can have one of 3 String values:\n    # - `\"sameorigin\"`\n    # - `\"deny\"`\n    # - a valid URL e.g. `\"https://mysite.com\"`\n    module SetFrameGuard\n      macro included\n        before set_frame_guard_header\n      end\n\n      abstract def frame_guard_value : String\n\n      private def set_frame_guard_header\n        context.response.headers[\"X-Frame-Options\"] = check_frame_guard_value!(frame_guard_value)\n        continue\n      end\n\n      private def check_frame_guard_value!(value : String) : String\n        v = value.downcase\n        case v\n        when \"sameorigin\", \"deny\"\n          v\n        when /^https?:\\/\\/\\w+./\n          \"allow-from #{v}\"\n        else\n          raise <<-MESSAGE\n\n          You set frame_guard_value to #{value}, but it must be one of these options:\n\n            - \"sameorigin\"\n            - \"deny\"\n            - A valid URL\n          MESSAGE\n        end\n      end\n    end\n  end\nend\n"
  },
  {
    "path": "src/lucky/secure_headers/set_sniff_guard.cr",
    "content": "module Lucky\n  module SecureHeaders\n    # This module sets the HTTP header [X-Content-Type-Options](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Content-Type-Options).\n    # It's job is responsible for disabling mime type sniffing.\n    # For more information, read up on [MIME type security](https://docs.microsoft.com/en-us/previous-versions/windows/internet-explorer/ie-developer/compatibility/gg622941(v=vs.85)).\n    #\n    # Include this module in the actions you want to add this to.\n    # ```\n    # class BrowserAction < Lucky::Action\n    #   include Lucky::SecureHeaders::SetSniffGuard\n    # end\n    # ```\n    module SetSniffGuard\n      macro included\n        before set_sniff_guard_header\n      end\n\n      private def set_sniff_guard_header\n        context.response.headers[\"X-Content-Type-Options\"] = \"nosniff\"\n        continue\n      end\n    end\n  end\nend\n"
  },
  {
    "path": "src/lucky/secure_headers/set_xss_guard.cr",
    "content": "module Lucky\n  module SecureHeaders\n    # This module sets the HTTP header\n    # [X-XSS-Protection](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-XSS-Protection).\n    # It's job is responsible for telling the browser to not render a page if\n    # it detects cross-site scripting. Lucky disables this header for Internet\n    # Explorer version < 9 for you as per recommendations. Read more on\n    # [Microsoft](https://blogs.msdn.microsoft.com/ieinternals/2011/01/31/controlling-the-xss-filter/).\n    #\n    # Include this module in the actions you want to add this to.\n\n    # ```\n    # class BrowserAction < Lucky::Action\n    #   include Lucky::SecureHeaders::SetXSSGuard\n    # end\n    # ```\n    module SetXSSGuard\n      macro included\n        before set_xss_guard_header\n      end\n\n      private def set_xss_guard_header\n        context.response.headers[\"X-XSS-Protection\"] = xss_guard_value\n        continue\n      end\n\n      private def xss_guard_value : String\n        useragent = context.request.headers.fetch(\"User-Agent\", \"\").downcase\n        value = \"1; mode=block\"\n        useragent.match(/msie\\s+(\\d+)/).try { |match|\n          value = \"0\" if match[1].to_i < 9\n        }\n        value\n      end\n    end\n  end\nend\n"
  },
  {
    "path": "src/lucky/serializable.cr",
    "content": "require \"uuid/json\"\n\nmodule Lucky::Serializable\n  abstract def render\n\n  def to_json(io)\n    render.to_json(io)\n  end\nend\n"
  },
  {
    "path": "src/lucky/server.cr",
    "content": "# Class for configuring server settings\n#\n# The settings created here can be customized in each Lucky app by modifying them in your config/server.cr\nclass Lucky::Server\n  Habitat.create do\n    setting secret_key_base : String\n    setting host : String\n    setting port : Int32\n    setting asset_host : String = \"\"\n    setting gzip_enabled : Bool = false\n    setting gzip_content_types : Array(String) = %w(\n      application/json\n      application/javascript\n      application/xml\n      font/otf\n      font/ttf\n      font/woff\n      font/woff2\n      image/svg+xml\n      text/css\n      text/csv\n      text/html\n      text/javascript\n      text/plain\n    )\n  end\nend\n"
  },
  {
    "path": "src/lucky/server_settings.cr",
    "content": "require \"yaml\"\n\nmodule Lucky::ServerSettings\n  YAML_SETTINGS_PATH = \"./config/watch.yml\"\n\n  extend self\n\n  # The host for your local development.\n  # Depending on your setup, you may need `localhost`, `127.0.0.1`, or `0.0.0.0`\n  def host : String\n    ENV[\"DEV_HOST\"]? || settings[\"host\"].as_s\n  end\n\n  # The port to run your local dev server\n  def port : Int32\n    ENV[\"DEV_PORT\"]?.try(&.to_i) || settings[\"port\"].as_i\n  end\n\n  # This is the port the dev watcher service will run on\n  def reload_port : Int32\n    ENV[\"RELOAD_PORT\"]?.try(&.to_i) || settings[\"reload_port\"]?.try(&.as_i) || 3001\n  end\n\n  # Watch additional paths for changes\n  def reload_watch_paths : Array(String)\n    settings[\"extra_watch_paths\"]?.try(&.as_a.map(&.as_s)) || [] of String\n  end\n\n  @@__settings : YAML::Any? = nil\n\n  private def settings : YAML::Any\n    if @@__settings.nil?\n      @@__settings = YAML.parse(yaml_settings_file)\n    else\n      @@__settings.as(YAML::Any)\n    end\n  end\n\n  private def yaml_settings_file : String\n    if File.exists?(YAML_SETTINGS_PATH)\n      File.read YAML_SETTINGS_PATH\n    else\n      <<-ERROR\n      Expected config file for the watcher at #{YAML_SETTINGS_PATH}.\n\n      Try this...\n\n        ▸ If this is Production, be sure to set LUCKY_ENV=production\n        ▸ If this is Development, ensure the #{YAML_SETTINGS_PATH} file exists\n\n      ERROR\n    end\n  end\nend\n"
  },
  {
    "path": "src/lucky/skip_route_style_check.cr",
    "content": "# Include this in an action to skip any style checks on routes\nmodule Lucky::SkipRouteStyleCheck\n  macro enforce_route_style(*args, **named_args)\n    # no-op\n  end\nend\n"
  },
  {
    "path": "src/lucky/static_compression_handler.cr",
    "content": "# Middleware that serves static files that have been pre-compressed.\n# There can be multiple instances and the first in the middleware stack will take precedence.\n# For example, if you want to serve brotli compressed assets for browsers that support it and\n# serve gzip assets for those that don't you would do something like this in your middleware\n# in `src/app_server.cr`:\n#\n# ```\n# [\n#   # ...\n#   Lucky::StaticCompressionHandler.new(\"./public\", file_ext: \"br\", content_encoding: \"br\"),\n#   Lucky::StaticCompressionHandler.new(\"./public\", file_ext: \"gz\", content_encoding: \"gzip\"),\n#   # ...\n# ]\n# ```\nclass Lucky::StaticCompressionHandler\n  include HTTP::Handler\n\n  def initialize(@public_dir : String, @file_ext = \"gz\", @content_encoding = \"gzip\")\n  end\n\n  def call(context : HTTP::Server::Context)\n    original_path = context.request.path.to_s\n    request_path = URI.decode(original_path)\n    file_path = File.join(@public_dir, request_path)\n    compressed_path = \"#{file_path}.#{@file_ext}\"\n    content_type = MIME.from_filename(file_path, \"application/octet-stream\")\n\n    if !should_compress?(file_path, content_type, compressed_path, context.request.headers)\n      call_next(context)\n      return\n    end\n\n    context.response.headers[\"Content-Encoding\"] = @content_encoding\n\n    last_modified = modification_time(compressed_path)\n    add_cache_headers(context.response.headers, last_modified)\n\n    if cache_request?(context, last_modified)\n      context.response.status = :not_modified\n      return\n    end\n\n    context.response.content_type = content_type\n    context.response.content_length = File.size(compressed_path)\n    File.open(compressed_path) do |file|\n      IO.copy(file, context.response)\n    end\n  end\n\n  private def should_compress?(file_path, content_type, compressed_path, request_headers) : Bool\n    Lucky::Server.settings.gzip_enabled &&\n      request_headers.includes_word?(\"Accept-Encoding\", @content_encoding) &&\n      Lucky::Server.settings.gzip_content_types.any? { |ctype| content_type.starts_with?(ctype) } &&\n      File.exists?(compressed_path)\n  end\n\n  private def add_cache_headers(response_headers : HTTP::Headers, last_modified : Time) : Nil\n    response_headers[\"Etag\"] = etag(last_modified)\n    response_headers[\"Last-Modified\"] = HTTP.format_time(last_modified)\n  end\n\n  private def cache_request?(context : HTTP::Server::Context, last_modified : Time) : Bool\n    # According to RFC 7232:\n    # A recipient must ignore If-Modified-Since if the request contains an If-None-Match header field\n    if if_none_match = context.request.if_none_match\n      match = {\"*\", context.response.headers[\"Etag\"]}\n      if_none_match.any? { |etag| match.includes?(etag) }\n    elsif if_modified_since = context.request.headers[\"If-Modified-Since\"]?\n      header_time = HTTP.parse_time(if_modified_since)\n      # File mtime probably has a higher resolution than the header value.\n      # An exact comparison might be slightly off, so we add 1s padding.\n      # Static files should generally not be modified in subsecond intervals, so this is perfectly safe.\n      # This might be replaced by a more sophisticated time comparison when it becomes available.\n      !!(header_time && last_modified <= header_time + 1.second)\n    else\n      false\n    end\n  end\n\n  private def etag(modification_time)\n    %{W/\"#{modification_time.to_unix}\"}\n  end\n\n  private def modification_time(file_path)\n    File.info(file_path).modification_time\n  end\nend\n"
  },
  {
    "path": "src/lucky/subdomain.cr",
    "content": "module Lucky::Subdomain\n  # Taken from https://github.com/rails/rails/blob/afc6abb674b51717dac39ea4d9e2252d7e40d060/actionpack/lib/action_dispatch/http/url.rb#L8\n  IP_HOST_REGEXP = /\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}$/\n\n  Habitat.create do\n    # tld_length is the number of Top Level Domain segments separated by periods\n    # the default is 1 because most domains end in \".com\" or \".org\"\n    # The tld_length should be changed to 2 when you have a \".co.uk\" domain for example\n    # It can also be changed to 0 for local development so that you can use `tenant.localhost:3000`\n    setting tld_length : Int32 = 1\n  end\n\n  alias Matcher = String | Regex | Bool | Array(String | Regex) | Array(String) | Array(Regex)\n\n  # Sets up a subdomain requirement for an action\n  #\n  # ```\n  # require_subdomain                                    # subdomain required but can be anything\n  # require_subdomain \"admin\"                            # subdomain required and must equal \"admin\"\n  # require_subdomain /(dev|qa|prod)/                    # subdomain required and must match regex\n  # require_subdomain [\"tenant1\", \"tenant2\", /tenant\\d/] # subdomain required and must match one of the items in the array\n  # ```\n  #\n  # The subdomain can then be accessed from within the route block by calling `subdomain`.\n  #\n  # If you don't want to require a subdomain but still want to check if one is passed\n  # you can still call `subdomain?` without using `require_subdomain`.\n  # Just know that `subdomain?` is nilable.\n  macro require_subdomain(matcher = true)\n    before _match_subdomain\n\n    private def subdomain : String\n      subdomain?.not_nil!\n    end\n\n    private def _match_subdomain\n      _match_subdomain({{ matcher }})\n    end\n  end\n\n  private def subdomain : String\n    {% raise \"No subdomain available without calling `require_subdomain` first.\" %}\n  end\n\n  private def subdomain? : String?\n    host = request.hostname\n    return if host.nil? || IP_HOST_REGEXP.matches?(host)\n\n    parts = host.split('.')\n    parts.pop(Lucky::Subdomain.settings.tld_length + 1)\n\n    parts.empty? ? nil : parts.join(\".\")\n  end\n\n  private def _match_subdomain(matcher : Matcher)\n    expected = [matcher].flatten.compact\n    return continue if expected.empty?\n\n    actual = subdomain?\n    result = expected.any? do |expected_subdomain|\n      case expected_subdomain\n      when true\n        actual.present?\n      when Symbol\n        actual.to_s == expected_subdomain.to_s\n      else\n        expected_subdomain === actual\n      end\n    end\n\n    if result\n      continue\n    else\n      raise InvalidSubdomainError.new(host: request.hostname, expected: matcher)\n    end\n  end\nend\n"
  },
  {
    "path": "src/lucky/support/message_encryptor.cr",
    "content": "require \"json\"\nrequire \"openssl/cipher\"\nrequire \"./message_verifier\"\n\nmodule Lucky\n  class MessageEncryptor\n    getter verifier : MessageVerifier\n\n    def initialize(@secret : String, @cipher_algorithm = \"aes-256-cbc\", @digest = :sha1)\n      @verifier = MessageVerifier.new(@secret, digest: @digest)\n      @block_size = 16\n    end\n\n    # Encrypt and sign a message. We need to sign the message in order to avoid\n    # padding attacks. Reference: https://en.wikipedia.org/wiki/Padding_oracle_attack.\n    def encrypt_and_sign(value : Slice(UInt8)) : String\n      verifier.generate(encrypt(value))\n    end\n\n    def encrypt_and_sign(value : String) : String\n      encrypt_and_sign(value.to_slice)\n    end\n\n    # Verify and Decrypt a message. We need to verify the message in order to\n    # avoid padding attacks. Reference: https://en.wikipedia.org/wiki/Padding_oracle_attack.\n    def verify_and_decrypt(value : String) : Bytes\n      decrypt(verifier.verify_raw(value))\n    end\n\n    def encrypt(value) : Bytes\n      cipher = OpenSSL::Cipher.new(@cipher_algorithm)\n      cipher.encrypt\n      set_cipher_key(cipher)\n\n      # Rely on OpenSSL for the initialization vector\n      iv = cipher.random_iv\n\n      encrypted_data = IO::Memory.new\n      encrypted_data.write(cipher.update(value))\n      encrypted_data.write(cipher.final)\n      encrypted_data.write(iv)\n\n      encrypted_data.to_slice\n    end\n\n    def decrypt(value : Bytes) : Bytes\n      cipher = OpenSSL::Cipher.new(@cipher_algorithm)\n      data = value[0, value.size - @block_size]\n      iv = value[value.size - @block_size, @block_size]\n\n      cipher.decrypt\n      set_cipher_key(cipher)\n      cipher.iv = iv\n\n      decrypted_data = IO::Memory.new\n      decrypted_data.write cipher.update(data)\n      decrypted_data.write cipher.final\n      decrypted_data.to_slice\n    end\n\n    private def set_cipher_key(cipher) : Nil\n      cipher.key = @secret\n    rescue error : ArgumentError\n      raise InvalidSecretKeyBase.new(<<-MESSAGE\n        The secret key is invalid:\n\n          #{error.message}\n\n        You can generate a new key using 'lucky gen.secret_key' in your terminal:\n\n          ▸ lucky gen.secret_key\n\n        Usually the key is set in 'config/server.cr'\n\n          Lucky::Server.configure do |settings|\n            settings.secret_key_base = \"your-new-key\"\n          end\n\n\n        MESSAGE\n      )\n    end\n\n    class InvalidSecretKeyBase < Exception\n    end\n  end\nend\n"
  },
  {
    "path": "src/lucky/support/message_verifier.cr",
    "content": "require \"openssl/hmac\"\nrequire \"crypto/subtle\"\n\nmodule Lucky\n  class MessageVerifier\n    def initialize(@secret : String, @digest = :sha1)\n    end\n\n    def valid_message?(data : String, digest : String) : Bool\n      data.size > 0 && digest.size > 0 && Crypto::Subtle.constant_time_compare(digest, generate_digest(data))\n    end\n\n    def verified(signed_message : String) : String?\n      json_data = ::Base64.decode_string(signed_message)\n      data, digest = Tuple(String, String).from_json(json_data)\n\n      if valid_message?(data.to_s, digest.to_s)\n        String.new(decode(data.to_s))\n      end\n    rescue e : Base64::Error | JSON::ParseException\n      nil\n    end\n\n    def verify(signed_message : String) : String\n      verified(signed_message) || raise(InvalidSignatureError.new)\n    end\n\n    def verify_raw(signed_message : String) : Bytes\n      json_data = ::Base64.decode_string(signed_message)\n      data, digest = Tuple(String, String).from_json(json_data)\n\n      if (data && digest).nil?\n        raise(InvalidSignatureError.new)\n      end\n\n      if valid_message?(data.to_s, digest.to_s)\n        decode(data.to_s)\n      else\n        raise(InvalidSignatureError.new)\n      end\n    rescue e : Base64::Error | JSON::ParseException\n      raise(InvalidSignatureError.new)\n    end\n\n    def generate(value : String | Bytes) : String\n      data = encode(value)\n      encode({data, generate_digest(data)}.to_json)\n    end\n\n    private def encode(data : String | Bytes) : String\n      ::Base64.urlsafe_encode(data)\n    end\n\n    private def decode(data : String) : Bytes\n      ::Base64.decode(data)\n    end\n\n    private def generate_digest(data) : String\n      encode(OpenSSL::HMAC.digest(OpenSSL::Algorithm.parse(@digest.to_s), @secret, data))\n    end\n  end\nend\n"
  },
  {
    "path": "src/lucky/tags/_check_tag_content.cr",
    "content": "# :nodoc:\nmodule Lucky::CheckTagContent\n  # If a tag has a nested tag (`IO`) or nil\n  # then return that content.\n  # ```\n  # div { }\n  # div { div { } }\n  # div { text \"\" }\n  # ```\n  private def check_tag_content!(content : IO?)\n    content\n  end\n\n  # A tag can only have another tag or nil as a\n  # nested value. Anything else should raise a compile-time error\n  # ```\n  # div { \"this will fail\" }\n  # ```\n  private def check_tag_content!(content)\n    {%\n      raise <<-MESSAGE\n\n      A tag in #{@type} has a nested String, but it must return a tag or `text`.\n\n      If you want to display text, try this:\n\n        div do\n          text \"my string\"\n        end\n      MESSAGE\n    %}\n  end\nend\n"
  },
  {
    "path": "src/lucky/tags/base_tags.cr",
    "content": "module Lucky::BaseTags\n  include Lucky::CheckTagContent\n  TAGS = %i(\n    a\n    abbr\n    address\n    area\n    article\n    aside\n    b\n    bdi\n    bdo\n    blockquote\n    body\n    button\n    caption\n    cite\n    code\n    col\n    colgroup\n    data\n    datalist\n    del\n    details\n    dfn\n    dialog\n    div\n    dd\n    dl\n    dt\n    em\n    embed\n    fieldset\n    figcaption\n    figure\n    footer\n    form\n    h1\n    h2\n    h3\n    h4\n    h5\n    h6\n    head\n    header\n    html\n    i\n    iframe\n    ins\n    kbd\n    label\n    legend\n    li\n    main\n    map\n    mark\n    menuitem\n    meter\n    nav\n    noscript\n    object\n    ol\n    optgroup\n    option\n    output\n    param\n    picture\n    pre\n    progress\n    q\n    rp\n    rt\n    ruby\n    s\n    samp\n    script\n    section\n    slot\n    small\n    span\n    strong\n    sub\n    summary\n    sup\n    table\n    tbody\n    td\n    template\n    textarea\n    tfoot\n    th\n    thead\n    time\n    title\n    tr\n    track\n    u\n    ul\n    video\n    wbr\n  )\n  RENAMED_TAGS     = {\"para\": \"p\", \"select_tag\": \"select\"}\n  EMPTY_TAGS       = %i(img br hr input meta source)\n  EMPTY_HTML_ATTRS = {} of String => String\n\n  macro generate_tag_methods(method_name, tag)\n    # Generates a `&lt;{{method_name.id}}&gt;&lt;/{{method_name.id}}&gt;` tag.\n    #\n    # * The *content* argument is either a `String`, or any type that has included `Lucky::AllowedInTags`. This is the content that goes inside of the tag.\n    # * The *options* argument is a `Hash(String, String)` of any HTML attribute that has a key/value like `class`, `id`, `type`, etc...\n    # * The *attrs* argument is an `Array(Symbol)` for specifying [Boolean Attributes](https://html.spec.whatwg.org/multipage/common-microsyntaxes.html#boolean-attributes) such as `required`, `disabled`, `autofocus`, etc...\n    #\n    # ```\n    # {{method_name.id}}(\"Sample\", {\"class\" => \"cls-1 red\"}, [:required]) #=> <{{method_name.id}} class=\"cls-1 red\" required>Sample</{{method_name.id}}>\n    # ```\n    def {{method_name.id}}(\n        content : Lucky::AllowedInTags | String = \"\",\n        options = EMPTY_HTML_ATTRS,\n        attrs : Array(Symbol) = [] of Symbol,\n        **other_options\n      ) : Nil\n      boolean_attrs = build_boolean_attrs(attrs)\n      merged_options = merge_options(other_options, options)\n      tag_attrs = build_tag_attrs(merged_options)\n      view << \"<{{tag.id}}\" << tag_attrs << boolean_attrs << \">\" << HTML.escape(content.to_s) << \"</{{tag.id}}>\"\n    end\n\n    def {{method_name.id}}(content : Lucky::AllowedInTags | String) : Nil\n      view << \"<{{tag.id}}>\" << HTML.escape(content.to_s) << \"</{{tag.id}}>\"\n    end\n\n    def {{method_name.id}}(\n        content : Nil,\n        options = EMPTY_HTML_ATTRS,\n        attrs : Array(Symbol) = [] of Symbol,\n        **other_options\n      ) : Nil\n      \\{%\n        raise <<-ERROR\n          HTML tags content must be a String or Lucky::AllowedInTags object, not nil.\n\n          Try this...\n\n            if value = some_nilable_value\n              {{method_name.id}}(value, class: \"header\")\n            end\n\n          ERROR\n          %}\n    end\n\n    def {{method_name.id}}(\n        content : Time,\n        options = EMPTY_HTML_ATTRS,\n        attrs : Array(Symbol) = [] of Symbol,\n        **other_options\n      ) : Nil\n      \\{%\n        raise <<-ERROR\n          HTML tags content must be a String or Lucky::AllowedInTags object.\n          {{method_name.id}} received a Time object which has an ambiguous display format.\n\n          Try this...\n\n            {{method_name.id}}(current_time.to_s(\"%F\"), html_opts)\n\n          ERROR\n          %}\n    end\n\n    def {{method_name.id}}(options, **other_options) : Nil\n      {{ method_name.id }}(\"\", options, **other_options)\n    end\n\n    def {{method_name.id}}(options = EMPTY_HTML_ATTRS, **other_options) : Nil\n      merged_options = merge_options(other_options, options)\n      tag_attrs = build_tag_attrs(merged_options)\n      view << \"<{{tag.id}}\" << tag_attrs << \">\"\n      check_tag_content!(yield)\n      view << \"</{{tag.id}}>\"\n    end\n\n    def {{method_name.id}}(attrs : Array(Symbol), options = EMPTY_HTML_ATTRS, **other_options) : Nil\n      boolean_attrs = build_boolean_attrs(attrs)\n      merged_options = merge_options(other_options, options)\n      tag_attrs = build_tag_attrs(merged_options)\n      view << \"<{{tag.id}}\" << tag_attrs << boolean_attrs << \">\"\n      check_tag_content!(yield)\n      view << \"</{{tag.id}}>\"\n    end\n\n    def {{method_name.id}} : Nil\n      view << \"<{{tag.id}}>\"\n      check_tag_content!(yield)\n      view << \"</{{tag.id}}>\"\n    end\n  end\n\n  {% for tag in TAGS %}\n    generate_tag_methods(method_name: {{tag.id}}, tag: {{tag.id}})\n  {% end %}\n\n  {% for name, tag in RENAMED_TAGS %}\n    generate_tag_methods(method_name: {{name}}, tag: {{tag}})\n  {% end %}\n\n  {% for tag in EMPTY_TAGS %}\n    # Generates a `&lt;{{tag.id}}&gt;` tag.\n    def {{tag.id}} : Nil\n      view << %(<{{tag.id}}>)\n    end\n\n    def {{tag.id}}(options = EMPTY_HTML_ATTRS, **other_options) : Nil\n      {{tag.id}}([] of Symbol, options, **other_options)\n    end\n\n    # Generates a `&lt;{{tag.id}}&gt;` tag.\n    #\n    # * The *attrs* argument is an `Array(Symbol)` for specifying [Boolean Attributes](https://html.spec.whatwg.org/multipage/common-microsyntaxes.html#boolean-attributes) such as `required`, `disabled`, `autofocus`, etc...\n    # * The *options* argument is a `Hash(String, String)` of any HTML attribute that has a key/value like `class`, `id`, `type`, etc...\n    #\n    # ```\n    # {{tag.id}}([:required], {\"class\" => \"cls-1\"}) #=> <{{tag.id}} class=\"cls-1\" required>\n    # ```\n    def {{tag.id}}(attrs : Array(Symbol), options = EMPTY_HTML_ATTRS, **other_options) : Nil\n      bool_attrs = build_boolean_attrs(attrs)\n      merged_options = merge_options(other_options, options)\n      tag_attrs = build_tag_attrs(merged_options)\n      view << %(<{{tag.id}}#{tag_attrs}#{bool_attrs}>)\n    end\n  {% end %}\n\n  # Outputs *content* and escapes it.\n  #\n  # ```\n  # text(\"Hello\") # => Hello\n  # text(\"<div>\") # => &lt;div&gt;\n  # ```\n  def text(content : String | Lucky::AllowedInTags) : Nil\n    view << HTML.escape(content.to_s)\n  end\n\n  # Generates a `&lt;style&gt;&lt;/style&gt;` block for adding inline CSS\n  #\n  # ```\n  # style(\"a { color: red; }\") # => <style>a { color: red; }</style>\n  # ```\n  def style(styles : String) : Nil\n    view << \"<style>#{styles}</style>\"\n  end\n\n  private def build_tag_attrs(options)\n    return \"\" if options.empty?\n\n    tag_attrs = String.build do |attrs|\n      options.each do |key, value|\n        attrs << \" \" << Wordsmith::Inflector.dasherize(key.to_s) << \"=\\\"\"\n        attrs << HTML.escape(value.to_s)\n        attrs << \"\\\"\"\n      end\n    end\n  end\n\n  private def build_boolean_attrs(options)\n    return \"\" if options.empty?\n\n    String.build do |attrs|\n      options.each do |value|\n        attrs << \" \" << Wordsmith::Inflector.dasherize(value.to_s)\n      end\n    end\n  end\n\n  private def merge_options(html_options, tag_attrs)\n    options = {} of String => String | Lucky::AllowedInTags\n    tag_attrs.each do |key, value|\n      options[key.to_s] = value\n    end\n\n    html_options.each do |key, value|\n      next if key == :boolean_attrs\n      options[key.to_s] = value\n    end\n\n    options\n  end\nend\n"
  },
  {
    "path": "src/lucky/tags/bun_reload_tag.cr",
    "content": "module Lucky::BunReloadTag\n  # Renders a live reload tag which connects to Bun's WebSocket server.\n  #\n  # NOTE: This tag only generates output in development, so there is no need to\n  # render it conditionally.\n  #\n  def bun_reload_connect_tag\n    return unless LuckyEnv.development?\n\n    tag \"script\" do\n      raw <<-JS\n      (() => {\n        const cssPaths = #{bun_reload_connect_css_files.to_json};\n        const ws = new WebSocket('#{LuckyBun::Config.instance.dev_server.ws_url}')\n        let connected = false\n\n        ws.onmessage = (event) => {\n          const data = JSON.parse(event.data)\n\n          if (data.type === 'css') {\n            document.querySelectorAll('link[rel=\"stylesheet\"]').forEach(link => {\n              const linkPath = new URL(link.href).pathname.split('?')[0]\n              if (cssPaths.some(p => linkPath.startsWith(p))) {\n                const url = new URL(link.href)\n                url.searchParams.set('bust', Date.now())\n                link.href = url.toString()\n              }\n            })\n            console.log('▸ CSS reloaded')\n          } else if (data.type === 'error') {\n            console.error('✖ Build error:', data.message)\n          } else {\n            console.log('▸ Reloading...')\n            location.reload()\n          }\n        }\n\n        ws.onopen = () => {\n          connected = true\n          console.log('▸ Live reload connected')\n        }\n        ws.onclose = () => {\n          if (connected) setTimeout(() => location.reload(), 2000)\n        }\n      })()\n      JS\n    end\n  end\n\n  # Collects all CSS entrypoints at their public paths.\n  private def bun_reload_connect_css_files : Array(String)\n    Lucky::AssetHelpers.css_entry_points.map do |key|\n      File.join(LuckyBun::Config.instance.public_path, key)\n    end\n  end\nend\n"
  },
  {
    "path": "src/lucky/tags/custom_tags.cr",
    "content": "module Lucky::CustomTags\n  include Lucky::CheckTagContent\n  EMPTY_HTML_ATTRS = {} of String => String\n\n  def tag(\n    tag_name : String,\n    content : Lucky::AllowedInTags | String? = \"\",\n    options = EMPTY_HTML_ATTRS,\n    attrs : Array(Symbol) = [] of Symbol,\n    **other_options,\n  ) : Nil\n    merged_options = merge_options(other_options, options)\n\n    tag(tag_name, attrs, merged_options) do\n      text content\n    end\n  end\n\n  def tag(\n    tag_name : String,\n    options = EMPTY_HTML_ATTRS,\n    **other_options,\n  ) : Nil\n    tag(tag_name, \"\", options, **other_options)\n  end\n\n  def tag(tag_name : String, attrs : Array(Symbol) = [] of Symbol, options = EMPTY_HTML_ATTRS, **other_options, &) : Nil\n    merged_options = merge_options(other_options, options)\n    tag_attrs = build_tag_attrs(merged_options)\n    boolean_attrs = build_boolean_attrs(attrs)\n    view << \"<#{tag_name}\" << tag_attrs << boolean_attrs << \">\"\n    check_tag_content!(yield)\n    view << \"</#{tag_name}>\"\n  end\n\n  # Outputs a custom tag with no tag closing.\n  # `empty_tag(\"br\")` => `<br>`\n  def empty_tag(tag_name : String, options = EMPTY_HTML_ATTRS, **other_options) : Nil\n    merged_options = merge_options(other_options, options)\n    tag_attrs = build_tag_attrs(merged_options)\n    view << \"<#{tag_name}\" << tag_attrs << \">\"\n  end\nend\n"
  },
  {
    "path": "src/lucky/tags/forgery_protection_helpers.cr",
    "content": "module Lucky::ForgeryProtectionHelpers\n  # Generate a hidden input with the request CSRF token\n  #\n  # This input is automatically generated when using\n  # `Lucky::FormHelpers#form_for`. It creates a hidden input with the CSRF\n  # token. THis ensures that the form is safe. If you try to submit a form\n  # without a CSRF token it will fail with a 403 forbidden status code.\n  def csrf_hidden_input : Nil\n    input type: \"hidden\",\n      name: ProtectFromForgery::PARAM_KEY,\n      value: ProtectFromForgery.get_token(context)\n  end\n\n  # Meta tags used for submitting AJAX links and forms\n  #\n  # These tags are automatically added to MainLayout when generating a new\n  # project. They are used by Rails UJS to safely submit forms and non-GET AJAX\n  # requests\n  def csrf_meta_tags : Nil\n    meta name: \"csrf-param\",\n      content: ProtectFromForgery::PARAM_KEY\n    meta name: \"csrf-token\",\n      content: ProtectFromForgery.get_token(context)\n  end\nend\n"
  },
  {
    "path": "src/lucky/tags/form_helpers.cr",
    "content": "module Lucky::FormHelpers\n  Habitat.create do\n    setting include_csrf_tag : Bool = true\n  end\n\n  def form_for(route : Lucky::RouteHelper, attrs : Array(Symbol) = [] of Symbol, **html_options, &) : Nil\n    form attrs, build_form_options(route, html_options) do\n      csrf_hidden_input if Lucky::FormHelpers.settings.include_csrf_tag\n      method_override_input(route)\n      yield\n    end\n  end\n\n  def form_for(route action : Lucky::Action.class, attrs : Array(Symbol) = [] of Symbol, **html_options, &) : Nil\n    form_for action.route, attrs, **html_options do\n      yield\n    end\n  end\n\n  def submit(text : String, attrs : Array(Symbol) = [] of Symbol, **html_options) : Nil\n    input attrs, merge_options(html_options, {\"type\" => \"submit\", \"value\" => text})\n  end\n\n  def form_method(route) : String\n    if route.method == :get\n      \"get\"\n    else\n      \"post\"\n    end\n  end\n\n  private def build_form_options(route, html_options) : Hash\n    options = merge_options(html_options, {\n      \"action\" => route.path,\n      \"method\" => form_method(route),\n    })\n    options[\"enctype\"] = \"multipart/form-data\" if options.delete(\"multipart\")\n\n    options\n  end\n\n  private def method_override_input(route) : Nil\n    unless [:post, :get].includes? route.method\n      input type: \"hidden\", name: \"_method\", value: route.method.to_s\n    end\n  end\nend\n"
  },
  {
    "path": "src/lucky/tags/link_helpers.cr",
    "content": "module Lucky::LinkHelpers\n  def link(text, to : Lucky::RouteHelper, attrs : Array(Symbol) = [] of Symbol, **html_options) : Nil\n    link(**html_options, to: to, attrs: attrs) do\n      text text\n    end\n  end\n\n  def link(to : Lucky::RouteHelper, attrs : Array(Symbol) = [] of Symbol, **html_options) : Nil\n    link(**html_options, to: to, attrs: attrs) { }\n  end\n\n  def link(to : Lucky::RouteHelper, href : String, **html_options, &) : Nil\n    {%\n      raise <<-ERROR\n      'link' cannot be called with an href.\n\n      Use 'a()' or remove the href argument.\n\n      Example:\n\n        a href: \"/\" do\n        end\n\n        link to: Home::Index do\n        end\n\n      ERROR\n    %}\n  end\n\n  def link(to : Lucky::RouteHelper, attrs : Array(Symbol) = [] of Symbol, **html_options, &) : Nil\n    a attrs, merge_options(html_options, link_to_href(to)) do\n      yield\n    end\n  end\n\n  def link(text, to : Lucky::Action.class, attrs : Array(Symbol) = [] of Symbol, **html_options) : Nil\n    link(**html_options, to: to, attrs: attrs) do\n      text text\n    end\n  end\n\n  def link(to : Lucky::Action.class, attrs : Array(Symbol) = [] of Symbol, **html_options) : Nil\n    link(**html_options, to: to, attrs: attrs) { }\n  end\n\n  def link(to : Lucky::Action.class, attrs : Array(Symbol) = [] of Symbol, **html_options, &) : Nil\n    link(**html_options, to: to.route, attrs: attrs) do\n      yield\n    end\n  end\n\n  private def link_to_href(route)\n    if route.method == :get\n      {\"href\" => route.path}\n    else\n      {\"href\" => route.path, \"data_method\" => route.method.to_s}\n    end\n  end\n\n  def link(text, to : String, attrs : Array(Symbol) = [] of Symbol, **html_options)\n    {%\n      raise <<-ERROR\n      'link' no longer supports passing a String to 'to'.\n\n      Use 'a()' or pass an Action class instead.\n\n      Example:\n\n        a \"Home\", href: \"/\"\n        link \"Home\", to: Home::Index\n\n      ERROR\n    %}\n  end\n\n  def link(to : String, attrs : Array(Symbol) = [] of Symbol, **html_options, &)\n    {%\n      raise <<-ERROR\n      'link' no longer supports passing a String to 'to'.\n\n      Use 'a()' or pass an Action class instead.\n\n      Example:\n\n        a href: \"/\" do\n        end\n\n        link to: Home::Index do\n        end\n\n      ERROR\n    %}\n    yield\n  end\nend\n"
  },
  {
    "path": "src/lucky/tags/live_reload_tag.cr",
    "content": "module Lucky::LiveReloadTag\n  def live_reload_connect_tag(ms : Int32 = 1000) : Nil\n    {% if flag?(:livereloadws) %}\n      tag \"script\" do\n        raw <<-JS\n        (function() {\n          var ws = new WebSocket(\"ws://#{Lucky::ServerSettings.host}:#{Lucky::ServerSettings.reload_port}\");\n          ws.onmessage = function() {\n            setTimeout(function() {\n              location.reload();\n            }, #{ms});\n          };\n        })();\n        JS\n      end\n    {% elsif flag?(:livereloadsse) %}\n      tag \"script\" do\n        raw <<-JS\n        (function() {\n          var stream = new EventSource(\"http://#{Lucky::ServerSettings.host}:#{Lucky::ServerSettings.reload_port}\");\n          stream.onmessage = function() {\n            setTimeout(function() {\n              location.reload();\n            }, #{ms});\n          };\n        })();\n        JS\n      end\n    {% end %}\n  end\nend\n"
  },
  {
    "path": "src/lucky/tags/specialty_tags.cr",
    "content": "module Lucky::SpecialtyTags\n  # Generates an HTML5 doctype tag.\n  def html_doctype : Nil\n    view << \"<!DOCTYPE html>\"\n  end\n\n  # Generates a link tag for a stylesheet at the path *href*.\n  #\n  # Additional tag attributes can be passed in keyword arguments via *options*.\n  def css_link(href, **options) : Nil\n    href = build_css_link_href_with_timestamp(href)\n    options = {href: href, rel: \"stylesheet\", media: \"screen\"}.merge(options)\n    empty_tag \"link\", **options\n  end\n\n  # Generates a script tag for a file at path *src*.\n  #\n  # Additional tag attributes can be passed in as keyword arguments via\n  # *options*.\n  def js_link(src, **options) : Nil\n    options = {src: src}.merge(options)\n    tag \"script\", **options\n  end\n\n  # Generates a meta tag to specify the character encoding as UTF-8.\n  #\n  # It is highly encouraged to specify the character encoding as early in a\n  # page's `<head>` as possible as some browsers only look at the first 1024\n  # bytes to determine the encoding.\n  def utf8_charset : Nil\n    meta charset: \"utf-8\"\n  end\n\n  # Generates a meta tag telling browsers to render the page as wide as the\n  # device screen/window and at an initial scale of 1.\n  #\n  # Optional keyword arguments can be used to override these defaults, as well\n  # as specify additional properties. Please refer to [MDN's documentation on\n  # the viewport meta tag](https://developer.mozilla.org/en-US/docs/Mozilla/Mobile/Viewport_meta_tag)\n  # for usage details.\n  def responsive_meta_tag(**options) : Nil\n    options = {width: \"device-width\", initial_scale: \"1\"}.merge(options)\n    meta name: \"viewport\", content: build_viewport_properties(options)\n  end\n\n  # Generates a canonical link tag to specify the \"canonical\" or \"preferred\"\n  # version of a page.\n  def canonical_link(href : String) : Nil\n    empty_tag \"link\", href: href, rel: \"canonical\"\n  end\n\n  # Adds *string* directly to the rendered HTML with no escaping.\n  #\n  # For example,\n  # ```\n  # raw \"<hopefully-something-safe>\" # Renders \"<hopefully-something-safe>\"\n  # ```\n  #\n  # For custom elements, it's recommended to use the `tag` method.\n  #\n  # NOTE: Should **never** be used to render unescaped user-generated data, as\n  # this can leave one vulnerable to [cross-site scripting\n  # attacks](https://en.wikipedia.org/wiki/Cross-site_scripting).\n  def raw(string : String) : Nil\n    view << string\n  end\n\n  # Generates an escaped HTML `&nbsp;` entity for the number of times specified\n  # by `how_many`. By default it generates 1 non-breaking space character.\n  #\n  # ```\n  # link \"Home\", to: Home::Index\n  # span do\n  #   nbsp\n  #   text \"|\"\n  #   nbsp\n  # end\n  # link \"About\", to: About::Index\n  # ```\n  # Would generate `<a href=\"/\">Home</a><span>&nbsp;|&nbsp;</span><a href=\"/about\">About</a>`\n  def nbsp(how_many : Int32 = 1) : Nil\n    how_many.times { raw(\"&nbsp;\") }\n    view\n  end\n\n  private def build_css_link_href_with_timestamp(href) : String\n    config = LuckyBun::Config.instance\n    return href unless href.starts_with?(config.public_path)\n    return href if href =~ /-[0-9a-f]{8}\\.css$/\n\n    String.build do |io|\n      file_path = href.sub(config.public_path, config.out_dir)\n      io << href\n      io << (href.includes?('?') ? '&' : '?')\n      io << \"bust=\"\n      if File.exists?(file_path)\n        io << File.info(file_path).modification_time.to_unix\n      else\n        io << Time.utc.to_unix\n      end\n    end\n  end\n\n  private def build_viewport_properties(options) : String\n    String.build do |attrs|\n      options.each_with_index do |key, value, index|\n        attrs << \", \" if index > 0\n        attrs << Wordsmith::Inflector.dasherize(key.to_s) << \"=\"\n        attrs << value\n      end\n    end\n  end\nend\n"
  },
  {
    "path": "src/lucky/tags/tag_defaults.cr",
    "content": "# Set up defaults arguments for HTML tags.\n#\n# This is automatically included in Pages and Components.\nmodule Lucky::TagDefaults\n  # This is typically used in components and helper methods to set up defaults for\n  # reusable components.\n  #\n  # Example in a page or component:\n  #\n  #    tag_defaults field: form.email, class: \"input\" do |tag_builder|\n  #      tag_builder.email_input placeholder: \"Email\"\n  #    end\n  #\n  # Is the same as:\n  #\n  #     email_input field: form.email, class: \"input\", placeholder: \"Email\"\n  def tag_defaults(**named_args, &)\n    OptionMerger.new(page_context: self, named_args: named_args).run do |tag_builder|\n      yield tag_builder\n    end\n  end\n\n  class OptionMerger(T, V)\n    def initialize(@page_context : T, @named_args : V)\n    end\n\n    def run(&)\n      yield self\n    end\n\n    macro method_missing(call)\n      overridden_html_class = nil\n\n      {% named_args = call.named_args %}\n      {% if named_args %}\n        {% if call.named_args.any? { |arg| arg.name == :class } %}\n          {% raise <<-ERROR\n\n\n          Use 'replace_class' or 'append_class' instead of 'class'.\n\n          Correct example:\n\n              tag_defaults class: \"default\" do |tag_builder|\n                # Use 'replace_class' or 'append_class' here\n                tag_builder.div replace_class: \"replaced\"\n              end\n\n          Incorrect example:\n\n              tag_defaults class: \"default\" do |tag_builder|\n                # Won't work with 'class'\n                tag_builder.div class: \"replaced\"\n              end\n\n          -----------------\n\n          ERROR\n          %}\n        {% end %}\n\n        {% appended_class_arg = call.named_args.find { |arg| arg.name == :append_class } %}\n        {% if appended_class_arg %}\n          original_class = if klass = @named_args[:class]?\n            # Append an empty space if there is a default class that\n            # we are appending to\n            \"#{klass} \"\n          else\n            # Otherwise leave it empty\n            \"\"\n          end\n\n          overridden_html_class = \"#{original_class}#{{{ appended_class_arg.value }}}\"\n        {% end %}\n        {% named_args = named_args.reject { |arg| arg.name == :append_class } %}\n\n        {% replace_class_arg = call.named_args.find { |arg| arg.name == :replace_class } %}\n        {% if replace_class_arg %}\n          overridden_html_class = \"#{{{ replace_class_arg.value }}}\"\n        {% end %}\n        {% named_args = named_args.reject { |arg| arg.name == :replace_class } %}\n      {% end %}\n\n      nargs = @named_args{% if named_args %}.merge({{ named_args.splat }})\n\n      # If there is no default class and we want to append/replace one, then\n      # the compiler blows up because the @named_args type is a Union. Where\n      # one type has the 'class' key and the other doesn't.\n      #\n      # We fix that by making sure there is always a class key if we try to\n      # append/replace a class.\n      {% if appended_class_arg || replace_class_arg %}\n        nargs = nargs.merge(class: \"\")\n      {% end %}\n\n      if overridden_html_class\n        nargs = nargs.merge(class: overridden_html_class)\n      end\n      {% end %}\n\n      args = Tuple.new({% if call.args %}\n        {% for arg in call.args %}\n          {{ arg }},\n        {% end %}\n      {% end %})\n\n      @page_context.{{ call.name }} *args, **nargs  {{ call.block }}\n    end\n  end\nend\n"
  },
  {
    "path": "src/lucky/text_response.cr",
    "content": "{% if !flag?(:without_zlib) %}\n  require \"compress/gzip\"\n{% end %}\n\n# Writes the *content_type*, *status*, and *body* to the *context* for text responses.\n#\n# There are two settings in `Lucky::Server.settings` that determine if\n# the text response is gzip encoded; `Lucky::Server.settings.gzip_enabled` and `Lucky::Server.settings.gzip_content_types`.\n# These settings can be adjusted in your Lucky app under config/server.cr\nclass Lucky::TextResponse < Lucky::Response\n  DEFAULT_STATUS = 200\n\n  getter context, content_type, body, enable_cookies\n  getter debug_message : String?\n\n  def initialize(@context : HTTP::Server::Context,\n                 @content_type : String,\n                 @body : String | IO,\n                 @status : Int32? = nil,\n                 @debug_message : String? = nil,\n                 @enable_cookies : Bool = true)\n  end\n\n  def print : Nil\n    if enable_cookies\n      write_flash\n      write_session\n      write_cookies\n    end\n    context.response.content_type = content_type\n    context.response.status_code = status\n    context.response.headers.add \"Date\", HTTP.format_time(Time.utc)\n    gzip if should_gzip?\n    context.response.print(body) if should_print?\n  rescue e : IO::Error\n    Lucky::Log.error(exception: e) { \"Broken Pipe: Maybe the client navigated away?\" }\n  end\n\n  def status : Int\n    @status || context.response.status_code || DEFAULT_STATUS\n  end\n\n  private def gzip : Nil\n    context.response.headers[\"Content-Encoding\"] = \"gzip\"\n    context.response.output = Compress::Gzip::Writer.new(context.response.output, sync_close: true)\n  end\n\n  private def should_gzip? : Bool\n    {% if flag?(:without_zlib) %}\n      false\n    {% else %}\n      Lucky::Server.settings.gzip_enabled &&\n        context.request.headers.includes_word?(\"Accept-Encoding\", \"gzip\") &&\n        Lucky::Server.settings.gzip_content_types.includes?(content_type)\n    {% end %}\n  end\n\n  private def should_print? : Bool\n    context.request.method.downcase != \"head\"\n  end\n\n  private def write_flash : Nil\n    context.session.set(\n      Lucky::FlashStore::SESSION_KEY,\n      context.flash.to_json\n    )\n  end\n\n  private def write_session : Nil\n    context.cookies.set(\n      Lucky::Session.settings.key,\n      context.session.to_json\n    )\n  end\n\n  private def write_cookies : Nil\n    response = context.response\n\n    context.cookies.updated.each do |cookie|\n      response.cookies[cookie.name] = cookie\n    end\n\n    response.cookies.add_response_headers(response.headers)\n  end\nend\n"
  },
  {
    "path": "src/lucky/uploaded_file.cr",
    "content": "# This class represents an uploaded file from a form\nclass Lucky::UploadedFile\n  private getter part : HTTP::FormData::Part\n\n  getter filename : String\n  getter tempfile : File\n\n  delegate name, creation_time, modification_time, read_time, size, to: @part\n\n  # Creates an UploadedFile using a HTTP::FormData::Part.\n  #\n  # The new file will be assigned the **name** of the\n  # provided HTTP::FormData::Part and the **tempfile** will\n  # be assigned the body of the HTTP::FormData::Part\n  def initialize(@part : HTTP::FormData::Part)\n    @filename = @part.filename.presence || Random.new.hex(12)\n    @tempfile = File.tempfile(filename)\n\n    File.open(@tempfile.path, \"w\") do |tempfile|\n      IO.copy(@part.body, tempfile)\n    end\n  end\n\n  # Returns the path of the File as a String\n  #\n  # ```\n  # uploaded_file_object.path # => String\n  # ```\n  def path : String\n    @tempfile.path\n  end\n\n  # Tests if the file size is zero.\n  #\n  # ```\n  # uploaded_file_object.blank? # => Bool\n  # ```\n  def blank? : Bool\n    tempfile.size.zero?\n  end\n\n  # Avram::Uploadable needs to be updated when this is removed\n  @[Deprecated(\"`metadata` deprecated. Each method on metadata is accessible directly on Lucky::UploadedFile\")]\n  def metadata : HTTP::FormData::FileMetadata\n    HTTP::FormData::FileMetadata.new(filename, creation_time, modification_time, read_time, size)\n  end\nend\n"
  },
  {
    "path": "src/lucky/verify_accepts_format.cr",
    "content": "# Configure what types of formats your action responds to\nmodule Lucky::VerifyAcceptsFormat\n  abstract def clients_desired_format : Symbol\n\n  macro included\n    before verify_accepted_format\n\n    def self._accepted_formats : Array(Symbol)\n      [] of Symbol\n    end\n  end\n\n  # Set the single format that the Action accepts.\n  #\n  # Same as `accepted_formats` but this one only accepts one format and no\n  # default. If you pass an empty array or more than one format, you must\n  # use the other `accepted_formats` so you can tell Lucky what the `default`\n  # format should be.\n  #\n  # If something other than the accepted formats are requested, Lucky will raise\n  # a `Lucky::NotAcceptableError` error.\n  #\n  # ```\n  # # Default is set to :html since there is just one format\n  # accepted_formats [:html]\n  #\n  # # Raises at compile time because Lucky needs to know which format is the default\n  # accepted_formats [:html, :json]\n  #\n  # # If more than one format is accepted, you must provide the default explicitly\n  # accepted_formats [:html, :json], default: :html\n  # ```\n  macro accepted_formats(formats)\n    {% if !formats.is_a?(ArrayLiteral) %}\n      {% raise \"#{@type} 'accepted_formats' should be an array of Symbols. Example: [:html, :json]\" %}\n    {% end %}\n\n    {% if formats.size == 1 %}\n      accepted_formats {{ formats }}, default: {{ formats.first }}\n    {% else %}\n      {% formats.raise \"#{@type} must pass a default to 'accepted_formats'. Example: accepted_formats [:html, :json], default: :html\" %}\n    {% end %}\n  end\n\n  # Set what formats the Action accepts.\n  #\n  # If something other than the accepted formats are requested, Lucky will raise\n  # a `Lucky::NotAcceptableError` error.\n  #\n  # ```\n  # accepted_formats [:html, :json], default: :json\n  # ```\n  macro accepted_formats(formats, default)\n    {% if !formats.is_a?(ArrayLiteral) %}\n      {% formats.raise \"#{@type} 'accepted_formats' should be an array of Symbols. Example: [:html, :json]\" %}\n    {% end %}\n\n    {% if !default.is_a?(SymbolLiteral) %}\n      {% formats.raise \"#{@type} default format should be a symbol. Example: :html\" %}\n    {% end %}\n\n    ACCEPTED_FORMAT_SYMBOLS = {{ formats }}\n\n    def self._accepted_formats : Array(Symbol)\n      ACCEPTED_FORMAT_SYMBOLS\n    end\n    default_format {{ default }}\n  end\n\n  private def verify_accepted_format\n    verify_all_formats_recognized!\n\n    if all_formats_allowed? || self.class._accepted_formats.includes?(clients_desired_format)\n      continue\n    else\n      raise Lucky::NotAcceptableError.new(\n        request: request,\n        action_name: self.class.name,\n        format: clients_desired_format,\n        accepted_formats: self.class._accepted_formats\n      )\n    end\n  end\n\n  private def verify_all_formats_recognized! : Nil\n    find_unrecognized_format.try do |unrecognized_format|\n      raise <<-TEXT\n      #{self.class.name} accepts an unrecognized format :#{unrecognized_format}\n\n      You can teach Lucky how to handle this format:\n\n          # Add this in config/mime_types.cr\n          Lucky::MimeType.register \"text/custom\", :#{unrecognized_format}\n\n      Or use one of these formats Lucky knows about:\n\n          #{Lucky::MimeType.known_formats.join(\", \")}\n\n\n      TEXT\n    end\n  end\n\n  @_find_unrecognized_format : Symbol?\n\n  private def find_unrecognized_format : Symbol?\n    @_find_unrecognized_format ||= self.class._accepted_formats.find do |format|\n      !Lucky::MimeType.registered?(format)\n    end\n  end\n\n  private def all_formats_allowed? : Bool\n    self.class._accepted_formats.empty?\n  end\nend\n"
  },
  {
    "path": "src/lucky/version.cr",
    "content": "module Lucky\n  macro set_version\n    VERSION = {{ `shards version \"#{__DIR__}\"`.chomp.stringify.downcase }}\n  end\n\n  set_version\nend\n"
  },
  {
    "path": "src/lucky/welcome_page.cr",
    "content": "# This is the welcome page shown to users when they first initialize a new Lucky project\nclass Lucky::WelcomePage\n  include Lucky::HTMLPage\n  include Lucky::LiveReloadTag\n  include Lucky::BunReloadTag\n\n  SIGN_UP_ACTION = SignUps::New\n\n  # Accept a context and all other exposed data\n  def initialize(@context : HTTP::Server::Context, *args, **named_args)\n  end\n\n  macro render_auth_button\n    {% if SIGN_UP_ACTION.resolve? %}\n      a \"View your new app\", href: {{ SIGN_UP_ACTION }}.path, class: \"btn\"\n    {% end %}\n  end\n\n  # Renders the Welcome Page\n  def render\n    html_doctype\n    html lang: \"en\" do\n      head do\n        utf8_charset\n        title \"Welcome to Lucky\"\n        load_lato_font\n        normalize_styles\n        welcome_page_styles\n        live_reload_connect_tag\n      end\n\n      body do\n        div class: \"container\" do\n          lucky_logo\n          render_buttons\n          render_help\n        end\n      end\n    end\n  end\n\n  private def lucky_logo\n    raw %(<img src=\"#{lucky_logo_data_uri}\" class=\"lucky-logo\">)\n  end\n\n  private def render_buttons\n    div class: \"container__buttons\" do\n      a \"Check out the guides\",\n        href: \"https://luckyframework.org/guides\",\n        class: \"btn btn--blue\"\n      render_auth_button\n    end\n  end\n\n  private def render_help\n    h1 \"Not sure where to start? Here are some ideas:\", class: \"headline\"\n\n    table class: \"help-table\" do\n      tbody do\n        tr do\n          td \"Change what this page renders\", class: \"left-column\"\n          td class: \"right-column\" do\n            code \"src/actions/home/index.cr\", class: \"code\"\n          end\n        end\n        tr do\n          td \"Generate a model, set of actions, and HTML\", class: \"left-column\"\n          td class: \"right-column\" do\n            code \"lucky gen.resource.browser Post title:String\", class: \"code\"\n          end\n        end\n        tr do\n          td \"Ask for ideas in our chatroom\", class: \"left-column\"\n          td class: \"right-column\" do\n            a chat_url, href: chat_url, target: \"_blank\"\n          end\n        end\n      end\n    end\n  end\n\n  private def chat_url\n    \"https://luckyframework.org/chat\"\n  end\n\n  private def welcome_page_styles\n    style <<-CSS\n      .code {\n        font-family: Menlo, \"Roboto Mono\", \"Courier New\", monospace;\n        font-size: 14px;\n        background: rgba(255, 255, 255, 0.2);\n        border-radius: 3px;\n        padding: 3px 6px;\n      }\n\n      .help-table {\n        margin-left: auto;\n        margin-right: auto;\n        font-size: 17px;\n      }\n\n      .help-table td {\n        padding: 10px 0;\n      }\n\n      .help-table a {\n        color: #3de69f;\n      }\n\n      .left-column {\n        text-align: right;\n        width: 50%;\n      }\n\n      .left-column:after {\n        content: '→';\n        margin: 0 5px;\n        font-weight: bold;\n        opacity: 0.6;\n      }\n\n      .right-column {\n        text-align: left;\n        width: 50%;\n      }\n\n      body {\n        background-color: #002748;\n        color: #fff;\n        font-family: 'Lato', system-ui, BlinkMacSystemFont, -apple-system, Segoe UI, Roboto, Oxygen, Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif;\n        border-top: 3px solid #9EFF66;\n      }\n\n      .container {\n        margin: 0 auto;\n        max-width: 1024px;\n        margin-top: 50px;\n        text-align: center;\n      }\n\n      .container__buttons {\n        width: 100%;\n      }\n\n      .container__buttons .btn:first-child {\n        margin-right: 20px;\n      }\n\n      .lucky-logo {\n        max-width: 750px;\n        margin-bottom: 40px;\n      }\n\n      .headline {\n        font-weight: 400;\n        font-size: 20px;\n        color: #AAB5BF;\n        margin-top: 90px;\n        margin-bottom: 30px;\n      }\n\n      .btn {\n        text-shadow: 0px 1px 1px rgba(0, 0, 0, 0.3);\n        box-sizing: border-box;\n        display: inline-block;\n        border-radius: 500px;\n        text-transform: uppercase;\n        font-weight: bold;\n        height: 50px;\n        font-size: 14px;\n        color: #fff;\n        padding: 15px 30px;\n        text-decoration: none;\n        letter-spacing: 1px;\n        display: inline-block;\n        transition: 0.1s ease-in-out all;\n        box-shadow: 0 3px 30px 0 rgba(0, 0, 0, 0.2);\n        background: #20c17d;\n        background-image: linear-gradient(-180deg, #3de69f 0%, #22c37f 100%);\n        transform: scale(1);\n      }\n\n      .btn:hover {\n        transform: scale(1.03);\n        box-shadow: 0 12px 30px 0 rgba(0, 0, 0, 0.3);\n      }\n\n      .btn--blue {\n        box-shadow: none;\n        background: #1c92b3;\n        background-image: linear-gradient(-180deg, #47c4ff 0%, #2ba4ec 100%)\n      }\n\n    CSS\n  end\n\n  private def load_lato_font\n    css_link \"https://fonts.googleapis.com/css?family=Lato:400,900\"\n  end\n\n  private def normalize_styles\n    style <<-CSS\n      /*! normalize.css v7.0.0 | MIT License | github.com/necolas/normalize.css */html{line-height:1.15;-ms-text-size-adjust:100%;-webkit-text-size-adjust:100%}body{margin:0}article,aside,footer,header,nav,section{display:block}h1{font-size:2em;margin:.67em 0}figcaption,figure,main{display:block}figure{margin:1em 40px}hr{box-sizing:content-box;height:0;overflow:visible}pre{font-family:monospace,monospace;font-size:1em}a{background-color:transparent;-webkit-text-decoration-skip:objects}abbr[title]{border-bottom:none;text-decoration:underline;text-decoration:underline dotted}b,strong{font-weight:inherit}b,strong{font-weight:bolder}code,kbd,samp{font-family:monospace,monospace;font-size:1em}dfn{font-style:italic}mark{background-color:#ff0;color:#000}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}audio,video{display:inline-block}audio:not([controls]){display:none;height:0}img{border-style:none}svg:not(:root){overflow:hidden}button,input,optgroup,select,textarea{font-family:sans-serif;font-size:100%;line-height:1.15;margin:0}button,input{overflow:visible}button,select{text-transform:none}[type=reset],[type=submit],button,html [type=button]{-webkit-appearance:button}[type=button]::-moz-focus-inner,[type=reset]::-moz-focus-inner,[type=submit]::-moz-focus-inner,button::-moz-focus-inner{border-style:none;padding:0}[type=button]:-moz-focusring,[type=reset]:-moz-focusring,[type=submit]:-moz-focusring,button:-moz-focusring{outline:1px dotted ButtonText}fieldset{padding:.35em .75em .625em}legend{box-sizing:border-box;color:inherit;display:table;max-width:100%;padding:0;white-space:normal}progress{display:inline-block;vertical-align:baseline}textarea{overflow:auto}[type=checkbox],[type=radio]{box-sizing:border-box;padding:0}[type=number]::-webkit-inner-spin-button,[type=number]::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}[type=search]::-webkit-search-cancel-button,[type=search]::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}details,menu{display:block}summary{display:list-item}canvas{display:inline-block}template{display:none}[hidden]{display:none}\n    CSS\n  end\n\n  private def raw(string)\n    view << string\n  end\n\n  private def lucky_logo_data_uri\n    \"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAA5sAAAEtCAYAAACGZ3RRAAAABGdBTUEAALGPC/xhBQAAQABJREFUeAHt3QWclNX6wPFnYmtmZ7boFFgaaWlsEBVb7MD2igko2FgoXsXuQrEDr52IXgPrqqAiIKIo3csG2/s/ZxH/C2xMvDUzv/P5rOzOvO+J73nBfeaUCAkBBBBAAAEEEEAAAQQQQAABBBBAAAEEEEAAAQQQQAABBBBAAAEEEEAAAQQQQAABBBBAAAEEEEAAAQQQQAABBBBAAAEEEEAgWgFXtBnscH/X0R2lqmIfqXL1EqnqrL7ai0uCIq6AVFUl73AtPyCAAAIIIIAAAggggAACCFgv4HKVqlgtX6pki4rVlqqvReKqmicuzxz55c1fjapQ9MFm99G9pKLiZBVMHqMq1dqoipEPAggggAACCCCAAAIIIICA5QJ/icv1ong8M+XnN+dFU3rkwWb30QOkonyaiob3jqYC3IsAAggggAACCCCAAAIIIOBAAZd8LB7vJBV0fh1J7cIPNrsfmivlpVNVYWMiKZB7EEAAAQQQQAABBBBAAAEEYkrgJfEmXyE/v74knFp7Qr547yleSU27Va3JfErds3vI93EhAggggAACCCCAAAIIIIBALAt0V3Hgv6RRxwzpcdJH8sfHlaE0JrSRzS5H5IhsfUlNmd0nlEy5BgEEEEAAAQQQQAABBBBAIA4FXDJHJG2MLHx1Q0OtazjY7HZId7U28/XqnWUbyo33EUAAAQQQQAABBBBAAAEE4lxA7WDr8R4qC974ub6Guut7U7oeNEIqy+cSaNarxJsIIIAAAggggAACCCCAQAIJqCMudZyo48V6Ut0jm3pEU2dQVRWo537eQgABBBBAAAEEEEAAAQQQSEQBlytf3N7BdY1w1h5s6jWaVcVqe1sVsZIQQAABBBBAAAEEEEAAAQQQqFVATal1pQ6obQ3nrtNo9a6zejMgAs1aKXkRAQQQQAABBBBAAAEEEEBgu4AeoFTxY3Ucuf21bX/uevRJ9fEmcsKOl/ETAggggAACCCCAAAIIIIAAArUKtJPCFX5Zv+T9mu/uOI22+6G5UlG6QB1xklTzIr5HAAEEEEAAAQQQQAABBBBAoE4Bl5SJJ7mb/Pz6ku3X7DiNtrx0KoHmdhr+RAABBBBAAAEEEEAAAQQQCElAD1jqeLJG+v+Rze6jB0h5+Vc13uNbBBBAAAEEEEAAAQQQQAABBEIX8HoHys9vqs1mRf5/ZLOifFroOXAlAggggAACCCCAAAIIIIAAAjsJ1Igrt41sdh/dS41q/rDTZfyIAAIIIIAAAggggAACCCCAQHgCXm9vNbo5b9vIZkXFyeHdzdUIIIAAAggggAACCCCAAAII1CLwd3y5Ldisqjqmlkt4CQEEEEAAAQQQQAABBBBAAIHwBP6OL13SdXRHqSxfHN7dXI0AAggggAACCCCAAAIIIIBAHQJubye3VFXsU8fbvIwAAggggAACCCCAAAIIIIBA+AIqzlTBpqtX+HdyBwIIIIAAAggggAACCCCAAAJ1CKg4U63ZrOpcx9u8jAACCCCAAAIIIIAAAggggEAEAlWddbDZPoI7uQUBBBBAAAEEEEAAAQQQQACBOgSq2rvFJcE63uVlBBBAAAEEEEAAAQQQQAABBMIXUHGmGtl0BcK/kzsQQAABBBBAAAEEEEAAAQQQqEvAFVAbBFUl1/U2ryOAAAIIIIAAAggggAACCCAQtoCKM9XIJgkBBBBAAAEEEEAAAQQQQAABYwUINo31JDcEEEAAAQQQQAABBBBAAAElQLDJY4AAAggggAACCCCAAAIIIGC4AMGm4aRkiAACCCCAAAIIIIAAAgggQLDJM4AAAggggAACCCCAAAIIIGC4AMGm4aRkiAACCCCAAAIIIIAAAgggQLDJM4AAAggggAACCCCAAAIIIGC4AMGm4aRkiAACCCCAAAIIIIAAAgggQLDJM4AAAggggAACCCCAAAIIIGC4AMGm4aRkiAACCCCAAAIIIIAAAgggQLDJM4AAAggggAACCCCAAAIIIGC4AMGm4aRkiAACCCCAAAIIIIAAAgggQLDJM4AAAggggAACCCCAAAIIIGC4AMGm4aRkiAACCCCAAAIIIIAAAgggQLDJM4AAAggggAACCCCAAAIIIGC4AMGm4aRkiAACCCCAAAIIIIAAAgggQLDJM4AAAggggAACCCCAAAIIIGC4AMGm4aRkiAACCCCAAAIIIIAAAgggQLDJM4AAAggggAACCCCAAAIIIGC4AMGm4aRkiAACCCCAAAIIIIAAAgggQLDJM4AAAggggAACCCCAAAIIIGC4AMGm4aRkiAACCCCAAAIIIIAAAgggQLDJM4AAAggggAACCCCAAAIIIGC4AMGm4aRkiAACCCCAAAIIIIAAAgggQLDJM4AAAggggAACCCCAAAIIIGC4AMGm4aRkiAACCCCAAAIIIIAAAgggQLDJM4AAAggggAACCCCAAAIIIGC4AMGm4aRkiAACCCCAAAIIIIAAAgggQLDJM4AAAggggAACCCCAAAIIIGC4AMGm4aRkiAACCCCAAAIIIIAAAgggQLDJM4AAAggggAACCCCAAAIIIGC4AMGm4aRkiAACCCCAAAIIIIAAAggg4IUAAacKuFTF3H6feANBcXm94kpKEpfHU/1VVVYmlSWlUllaIpXFJVJRWCBVlZVObQr1QgABBBBAAAEEEEAg4QQINhOuy53ZYHeSV5KbNJPk5uqraVPxZmZUB5migsuQUmWVlOdtlrKNG6V80yYpXbNGSlaskEoVlJIQQAABBBBAAAEEEEDAegGCTevNKfFvgSQVUKZ1yJW09u0kqXFjEVcUs7rdLvFmZVV//QOsRjpL16yW4r+WS/HSpVK6YcM/b/ENAggggAACCCCAAAIImCvgks6jqswtgtwR+H8BT2qq+Lt1k7ROnSQpJ+f/37Dgu7J166Ro4UIpWrxYKoqLLSiRIhBAAAEEEEAAAQQQSFwBgs3E7XtLW56kRh3Te/USf+fOImr9pa2pokKKliyR/G+/kbLNebZWhcIRQAABBBBAAAEEEIhXAYLNeO1Zh7RLT5XNGDJEUtu1d0iNalSjqlKNcv6qgs5vVdC5ucYbfIsAAggggAACCCCAAALRChBsRivI/bUKuFNTJNh/gKTv3kNtKRvFWsxaczf4RRV0Fv70s+TN/UJtKFRucOZkhwACCCCAAAIIIIBAYgoQbCZmv5vaal/nTpI1fE9xpaSYWo7RmVdsyZdNc+ZI8fK/jM6a/BBAAAEEEEAAAQQQSDgBgs2E63LzGuxWwWXW3ntJWm5H8wqxIOfCBQsk7/PP1RmepRaURhEIIIAAAggggAACCMSnAMFmfPar5a1KadlSckaMELffb3nZZhRYUVAgG997T0pWrzYje/JEAAEEEEAAAQQQQCDuBRy+mC7u/eOigf6uXaXxoYfGTaCpO8WTni6NDztMfO0duLFRXDw1NAIBBBBAAAEEEEAg3gUINuO9h01sn0vlnTFosGTtu6/zNwGKxEEd0ZI9apQE1JEtJAQQQAABBBBAAAEEEAhPwOYDD8OrLFc7R8Dlckn2fvtLmtoMKK6TamfGsGHiCQYl77PPpKqqKq6bS+MQQAABBBBAAAEEEDBKgJFNoyQTKB89opm1zz7xH2jW6NP0nj0la7/9RLedhAACCCCAAAIIIIAAAg0LEGw2bMQVOwlk7rWn+NQ6zURLvs6dJThoUKI1m/YigAACCCCAAAIIIBCRAMFmRGyJe1PGwIHi77F7wgIE+vWT7D3bJ2z7aTgCCCCAAAIIIIAAAqEKEGyGKsV1ktauvQT6909oiWDnPMm9rIdk9muS0A40HgEEEEAAAQQQQACBhgQINhsS4v1qgaTMTMnef7+E1vC1LJL0jvnqXBSX5F6xh/h2Cya0B41HAAEEEEAAAQQQQKA+AYLN+nR4r1rAneSVnAMPFFdycsKKJGeVSkavTf+03+3zSvsJfcWlAk8SAggggAACCCCAAAII7CpAsLmrCa/sJBBU6zS92dk7vZo4P7pTKySr/3pxuXc89sSXmyHNx3RMHAhaigACCCCAAAIIIIBAGAIEm2FgJeKlyU0aiz72I1GTy1MpOXtsEE9KZa0ELU/oJGmtA7W+x4sIIIAAAggggAACCCSyAMFmIvd+A213udyStfc+oob0Grgyft/O6r1JkjJK62ygK8kj7cf3FpeL6bR1IvEGAggggAACCCCAQEIKJG4UkZDdHV6j03v1lKTGjcO7KY6uDnTeIqnNtzbYIn+XbGl6ePsGr+MCBBBAAAEEEEAAAQQSSYBgM5F6O4y2elLTJLjHHmHcEV+X6iAz0HFLyI1qeWIXSQom7gZKIUNxIQIIIIAAAggggEDCCBBsJkxXh9fQ4MABCbv7rDe9TLJq7DwbipzH75WWp3QJ5VKuQQABBBBAAAEEEEAgIQQINhOim8NrZFJWlvi7dQ/vpji52uWpkuz+G8XlrX1DoPqa2WTUbmwWVB8Q7yGAAAIIIIAAAggklADBZkJ1d2iNzRg2VMSdmBveZPbaKHpkM6Kkztxsc3ZiBukReXETAggggAACCCCAQFwLEGzGdfeG37jU1q0ltU3b8G+Mgzv87QokrUXDGwLV19SM/k0lo2/ibqpUnw3vIYAAAggggAACCCSWAMFmYvV3va3Vx3dkDlWjmgmYkrNKJdg1z5CWtzmzB0ehGCJJJggggAACCCCAAAKxLECwGcu9Z3Dd/d26iTcnx+BcnZ+dO6VSsvptUMeJVhlS2bR2QWk8KjFHhw0BJBMEEEAAAQQQQACBuBAg2IyLboy+Ee7kZAkOGBB9RrGWg0ttCNR3g3hSKwyteauTu4jH5zU0TzJDAAEEEEAAAQQQQCCWBAg2Y6m3TKxroG8/cft8JpbgzKyDXbZIck6J4ZXzZqVIi2M6GZ4vGSKAAAIIIIAAAgggECsCBJux0lMm1tMbCEigdy8TS3Bm1qnNtkp6h3zTKtfsyA6S0iTNtPzJGAEEEEAAAQQQQAABJwsQbDq5dyyqW8bgwSIej0WlOaMYr69CMnttMrUyriS3tD6No1BMRSZzBBBAAAEEEEAAAccKEGw6tmusqVhykyaS1rGjNYU5pRR1hGiWWqfpTqo0vUbZe7eU9M6ZppdDAQgggAACCCCAAAIIOE2AYNNpPWJxfTKHDbe4RPuLC3bOk6TMUssq0u7cHpaVRUEIIIAAAiJudZRX0O+Tlk1ypG2LJtXfq88ZSQgggAACFguwXabF4E4qzpebK8nNmzmpSqbXJaVRsaTnmrdOs7YGHLJngaSN8snX7xbV9javIYAAAgiEKZAZ8EuX9q2lc7tW/3x12q2lNM7OkHRfmvh9qbvkWF5eIZu25MvGzQWyMU/9+ffXkmUr5av5C+WbHxer1wp2uY8XEEAAAQQiFyDYjNwupu90qTWaGUPUWs0ESu7kSsnsY+46zZ05e2eukVz/RsmcmCnfz94qZWXGnOW5czn8HL1AcpJXurRrHX1GDeRQXlEhC377s4Gr4uPtFk2ypVFmhumN0UHD8jXrTS8n2gLatWwqATXaZnZatnKN5BXE14dbWcF02WuP3WWfgb3UV0/p0Wk3canRy3CS1+tRwWhm9Vdd9/36x4rqwPPr+Yvlq3kL5YeFS6W0rLyuyx35ulX/lunG//TrH1JZZc//1/TI9W7q75TdadW6jbJuU94u1eie20Y8buv3wyguLZXF6jmOpeRxu6V7rj3nk8eiVyz1ra6rSzqPsudfiViTirP6Bvv2laDeGCiBUs4e6yWlabFlLW7l2yIHN12q/pJt+yv28m2b5e1Ht1hWPgWFJ9ChdXNZ8sHj4d0UwdUbN+dLzqBjIrgz9m659+rzZNyJh5he8SdeeV9Ov/IO08uJtoD3Hr1RRg7rF202Dd5//Phb5Pm3P2nwOqdf0KppIznxkH1kzKjh0qdbB3GrX0itTvkqaH/l/c9l5muz5eOv59sWWIXTbqv+LdN1yux/lC0fbAT8afLxU7dK3+654dAYfu2ipctl+IkTaw02777yXLng5MMML7OhDCsrK2XQMZfINz8tbuhSx7x/8SmHyx1XnGNLfS6//Qm55ZEXbSk7UQq1/l/uRJF1cDs9aWkS6Gf+LzxOIvC3K7A00Ez3lsmIxsv+CTS1xcHnBCWYZf2nnE7qB+qCAAII1CegR6tOO3KEfDTjFlk250m5ZeLp0q9HR1sCTV3PQLpPxqr6zH7y7/pMOE2NwLSprwm8Z7KAHrl95e6rbA80l69eJyPPuKLWQFMTXHXnU7LChhkX+kOZ+68dV71u2eSuMCR7va76+gtPNiSvcDP5afEfcvsTr4R7G9eHKUCwGSZYPFweHDBAXMnJ8dCUkNqQFCyTYNfNIV1rxEVuV5UcoEY0U9w7Tr1KC7jl8AvNn1JoRBvIAwEEELBSoHWzRnLXFefKqs+ekcenjpd9BvWyLcCsq92tmjWWSWcdIz+9+ZB8N+seOe/40aIDH5J1Anri9BPq+RgxtK91hdZS0oZNW+SAM66SP1etq+XdbS9tKSySC298oM73zXyj/+6d5NzjDjazCMPynn752dUf6hiWYYgZVanp32dfc5eUqbXcJHMFCDbN9XVc7knZ2eLv3s1x9TKrQi5PlWT12yAuC5/0YTnLpXFy7eul9jrGLy07JJnVXPJFAAEEYkqgc7uW8vhNl8hvHzwhF55ymPjSdt3Yx4kN6tMtV+5To0eL331Uxh6xv1qbZ+H/ZJwIYlGdbpt0lpygplbbmQqLitVMpWtCWns/64Mv5M05X9lS3ZsuOVWaqA2znJwOGNZXjjlwT1uq+NDzb8vcHxbaUnaiFcq/jgnW45lDh6qVuonT7Rk9NonXv+MIo5ld3imwUboF6t6oxOVxybGTs8ysAnkjgAACjhfQ6zGfvW2SLHjrYTntqJGSFKMjhG3VBjVP3DxBfnzjATlyxBDHu8dyBSeefpSMP+1IW5tQWlomR15wg9pEalHI9bjgxvtFB6hWp0y1qda/LzvT6mJDLi8lKUnuueq8kK838sLValOny6c/YWSW5FWPQOJEHfUgJMpbqW3aSIr6SpSU1rxIfK1rH2E0wyA7uVj2zPmrwax7DE+VHkNj49P7BhvDBQgggEAYAnra6WQ1FXXhO4/I8aP3dtxU2TCassOlXTu0kVfuuVq+fulO2X9w7x3e44foBU46ZF+59dIzos8oihz0xjunTLpN3v/8u7By+WPFWply79Nh3WPUxaccvr8M79fdqOwMzWfSWWOkozquyI508dSHZHN+oR1FJ2SZBJsJ0u0uNZqZOUSNaiZI8qRUSEZP69ZpJrsrqtdpel2VIQkfOylL/ZIV3rb9IWXMRQgggIBDBfSUuR9ff0BuVpvs1HYOpkOrHVa19ti9s3zwxM0y89aJojc7IkUvMFKtz3x86iVhH3UTfck75nDBDQ/IC+/8d8cXQ/zpzidflXnqGB070v3Xni9ej7N+3c9t01wuP+dYOzjk3U+/jbgfbalwHBTqrKcvDkCd2gR/t67izcl2avUMr1dmz03iTgot8DOi8H3UzrMZ3pKQs2rZKUn2HOMP+XouRAABBGJVQK9nvE1N53v30ZukU7tWsdqMsOp90qH7yQ+v3SdD+nQN6z4u3lGgnzraRO88a/c062vvnin3P/fmjpUL46fyiko599p7RI+OWp30mbQXn3qE1cXWW9496lis1BTrN6os2los5025t9668abxAgSbxps6Lke32nlW70CbKMnfptDSY056Z66Rdr5dD3RuyPvwCzIkzc9fwYaceB8BBGJXIDsjXQWZN8oEtd4u0VK7Vs3kv0//W6acfyIbCEXQ+fq80LcfvkHS1ZmadqZ7Zr4m19//bNRV+HLeQnnw+beizieSDK4dd6LoddJOSEcfMExGDe9vS1Wuv+9Z+X3FGlvKTuRC+U03AXo/0K+vuH2JMZ3Hk1YuwW7hB36RPgYt0gpkYNbKiG4PNvKoHe0CEd3LTQgggIDTBXZXIyrfvHy37D+kj9Oralr9PB6PXHv+SfLpM7dJO7WZECk0gaY5mfLeYzdKE/WnnenZN+bIRTc9aFgVrpg+Q1at3WhYfqFmpAP2O9QRI3angI31+HHR75ypadMDQLBpE7xVxXoDAQn0SpzNCrJ6bxKX15ppKqmeCtm/8R8SzcrLkacGpVELzmmz6u8D5SCAgDUCY0YNl7nP3yHt1egUSWSwmk6rp9XuO7AXHA0IpPtS5a2HrpcObVo0cKW5b7/z329k7OW3S5WBxeQVFMnFU40LXsOp2tHq76Re/2pnmqI+eNHn1Vqd9Jma51x7t+jpzCTrBQg2rTe3tMSMwYNF1CeriZD87QokOSf0dZPRmuh1mj5PWVTZeFNccvQEZ5+DFVUDuRkBBBJKwO1yydRLxsqLd14Rt5sARdqhwXS/vP3I9XLYvoMizSLu70vyeqrXaPbr0dHWts79/hc5+sKbpKy8wvB6vPjup6IDWTvSvWqtpD5yxI7Us1M7ufDkw+wounr6Mmdq2kJfXSjBpn32ppec0rSppHW09x9s0xv5dwH6LM1gV+umz/YIrpe2acaUN+Agv3Toaf1Ceav6hnIQQCAxBDLSffLGg1Ns22UyFpRT1B4KL6sNb04+dN9YqK6lddSzhJ6YOl5GDutnabk7F/bT4j9k9LnXSFGxeR9en3/9/aI3q7E66aNGLjvzaKuLrZ4Bdv+UceJVHyZYnarP1LydMzWtdq9ZHsFmTY04+z5j2LA4a1EdzXFVSVafjeJyGznZpY6y1Mv6PM3B2cvrviDcd9T/YY+7PHF2Cg6Xh+sRQMD5Anrzka9fuksO2itxNqOLtFf0L9xPTpso5594SKRZxOV9+hzNE20Owv9YvkZGnXmVbMwrMNV46fLVojersSNdce5x0l5tXmVlOu2okTK0rz3nfeo1t3r6Msk+AYJN++xNLdmX21GSm1n7j4mpDaon80BuviRlltZzhXFved2VMqLJ7+JRAa6RqUOfZBlwYGJs4mSkG3khgID9Ao2zMtTZklMT5lgTI8RdarqxPv7hqn8dZ0R2MZ/H+LFHyMQzrB9xqwm3dsNmGXnGFbJi7YaaL5v2/fQZs0SPolqd9JEjd1/1L8uKzckMyLSJp1tWXs2C9HRlPW2ZZK8Awaa9/qaU7lJrNKvXapqSu7My9QbLJL1jvmWVGpy9UrKSzJn6MmZCljpLLJrthixjoCAEEECgWiDo91UfbdKlfWtEIhC44aJTZdKZYyK4M35uOWH03nLbpLNsbdCWgkI58Kyr5Ndlke0uH0nl9XpQffam3rzG6nTw3gPk8P3Unh4WpFsmnC6N1AdSVqfqMzWvu9fqYimvFgGCzVpQYv0lvfusJ5gAR2qouCy7t3XTZ3fz50n3wDrTHo+cVh4ZOTYB+s00QTJGAAErBdLUCMmbD10nfbvnWlls3JU1dfxYGa1++U/ENEIdi6PXaeqRXrtScUmpHHbedfLdgt8sr8Ln3y+QR158x/JydYF3XXmu+FJTTC17iNqF+YyjDzC1jLoyv+7eZ+SPFWvrepvXLRQg2LQQ24qiPGlpos/VTISU3iFf9MimFcnvLZN9Gv1pelEHnxOUYJb1C+hNbxgFIIBA3AnMvPVSGd6/R9y1y+oGud1ueea2y6Rrgo0O9+3WoXrn2eRke3ZH1f1cUVEhJ0yYJh9//aPV3f5PeZPV5jVr1m/652ervmnToolcfd7xphXn9bjl/mvPt+WDhPmLloqepkxyhgDBpjP6wbBaBAcOFJfa7S7ekze9XAKdtljSTJdan7mvOuYkxV1uenmp6W454iLrp5uY3jAKQACBuBKYcNqRctQBCbIJnQU9p49Fef2BKZIVTLegNPuL6KDOX3374RskoHYwtjOdc8098uqHX9hZBdm0pUAuufkhW+ow4bSjTPuQ4/wTD5VeXdpb3q7KykrR/cqZmpbT11kgwWadNLH3RlJ2tvi7dY29ikdQ44yemyzbfbZ3xlppmWrdutA9x/ilZa59n/RG0B3cggACCSQwvF930euwSMYK5LZtUX0+qUeNdMZzapKdUb3Ot2mjLFubOfm2x+WxV96ztQ7bC3/urU/k/c/+t/1Hy/5MSvLKvdecZ3h5LZvkyPUXnmx4vqFk+ODzb8mX8xaGcinXWCQQ3/+iWYTolGIyhw4VFYE5pTqm1cPXplBSsktMy79mxo1TimSPrFU1XzL9e5fHJcdOsvd/wqY3kgIQQCAmBZrmZMoLd1xhy3l5MQkWZqX3V2sYb5t0Zph3xc7l6b5Ueeuh60UH1namfz/6skx79CU7q7BL2eOuv0/0+lGr076DeovepMnINP3ys20ZtV61dqNcMX2GkU0hLwME4j8yMQApFrJIbdNGUtRXvCdPaoUEu+ZZ0ky3mj67T+M/xS3W7xTXY3iq7D4s1ZJ2UggCCCAQqsBT6nzI5k04FzhUr0iuu+iUw0WPHsdbSlLni75811XSf/dOtjbtiVfel0m3PWZrHWorfMmfq+TGB56r7S3TX7td7Qasd5Y2Ih0wrK8cc+CeRmQVdh4XTeVMzbDRLLiBYNMCZLOLcKnRzOpRTbMLckD+wR6bxZ1UaUlN9shaLdlJWy0pq7ZC9Oim223fDn211YnXEEAgcQVOOmRfGTmsX+ICWNRyvTPrA1MuEB2cxUvS/yd77KZL5IDh9j4/r82eK2ddfZcNHyGH1pP/fuxlWbBkWWgXG3hVs8bZcsNFp0SdY0pSktxzlfHTckOp2NuffC0vcaZmKFSWX+O1vEQKNFzA372beNV6zXhPqc23Sloza4K/pqlF0jtjta2kLTomyV7H+GXO8wW21oPCEUAAgeyMdJl+ub1nIdbXC+XqzMIv1DESvy5bIavWbpKVazfIqnUbq//ML9wqmUG/2nwnINkZAWndvJHsoUbX9FerZo3ry9a297p3bCvjxx7puKmekYJMm3iGnHzYfpHebsh9n3w9X4675GapUBvIODWVlpXLv6bcKx/PvNXyXVzHnThanpj1vvywcGnEPJPOGiMdd2sZ8f2R3qjP1NTTkEnOFCDYdGa/hFwrt9p5Njgg/s/ncnsrJUONalqRPG41fbbRMnHCmOLhF2TKl28UydZC5/7P0Yo+oQwEELBX4N+XnimNszPtrcROpReoIPLdT7+V12Z/KW+pUQ29q2e4qXnjLDlwzz1Ej9ruNWB3NZvEORO+rhl3gjz/9ieybOXacJvlqOsvOfUIufTMo22t0/cLllSfpVlcas1xadE09r/f/iSPq42Lzjh6VDTZhH2vx+OpPqpk6PHjIxr5zW3TXC4/59iwyzXihin3Ps2ZmkZAmpSHc/5VNamB8Z5toF8/cauzNeM9BbrliSelwpJmDsxaKZlJxZaU1VAhgRy3jD432NBlvI8AAgiYJqDXD55u08HstTVqW+AwRRoNOlbGXDxVnn7jo4gCTZ33qnWb1C/278u+YydLm71PUZuLPCHrN1mzL0Btbav5mi8tVe692p4piTXrEc33xx+8l9w+2d4R8SXLVsqBZ10teQVF0TTF0nsnqZ1y12205gP2mg0b3KerCnIPqPlSyN/fo57V1BTrj96bp0Zi75jxasj15ELrBQg2rTc3rERvICCBXr0My8+pGSXnlIhf7UBrRWqeWii7B9dZUVTIZYw4JSCNWjAJIWQwLkQAAUMFpo4fa2h+kWb2+/LVcuLEadLvyAvk9Y++kpIyY0epVqiptzc//KK03/80ueaupyQv35r/79TnMXqfgXLYvoPqu8Sx7+0/uLfMuHmC5dNBa4KsXLNBRp5+hazZYH3gVrMe4X6/YXO+TLjlkXBvM+R6faxRTmYgrLyOVmfujhreP6x7jLh425mad3OmphGYJuZBsGkirtlZZwwZLKKmPcRzcqkprZm7b7KkiV5V1t6N9fRZ63efra+B3hSXjJnorOlr9dWX9xBAIH4E9lPHIgzr18PWBm3YtEUuuukB6XLgWfLsmx+b/i+0XuN5g9oVtP3+Y+X5tz62re1L/1olF974gHw493vb6hBpwX26dpBZ91wtycn2nRm9KS9fRp11lfy+Yk2kzbD1vpmvfyQfffmD5XXIyQqGdY5uwJ8md6ijTuxIDzz3lnw1f5EdRVNmGAIEm2FgOenSlGbNJC23o5OqZEpd0jvkize93JS8d850UNYKyfBac37nzmU39PMeB/mkQ6+Uhi7jfQQQQMBQgSkXnGhofuFmpqfI9T3yfLl75uuiN0+xMm3MK5DjJ0yTky691dJRzi9/+EWOvvBG6XTAmXLP069L4VZn/n+prr5o36qZvP3w9bacs7i9TnrDmEPOnSI/Lv5j+0sx+ed5190rJaXWn72pp9IO6tUlJLNrx51oy0ZbetSaMzVD6iLbLyLYtL0LIqtAxtChkd0YQ3d50iokPTf8DR8iaWKLtALp4bDpszu34/jLGd3c2YSfEUDAPIF9B/aydVTzlfc+E71ZyZ+r7F3a8Mwbc6T34efJL7/9aRq2ng446/3Pq9s7+Ljx8or63sm7ptYF0TgrQ9599EbRR2nYlfTOxGMumiqfq92JYz0t+n2FTH3wBcuboY/fuf/aceJpYMOsnp3aiT4X1o6kz9TcUhg763DtMHJKmQSbTumJMOrh69hRktXIZrwnvfusy2P+LqxJ7orq3Wed7tm+d4oMPMjv9GpSPwQQiBMBu3aWrKqqkin3PK0ChpscM6r3x4q1MvzEifLNj8ZO2dMjcPc980b1KOZRajTzi+9/idmnx5+WIm89fJ0tR19sR9PPzmmXT5e3//vN9pdi/s9pj7wki5Yut7wdfbrlij4Opa6kd+y/f8o48dpwHuxbH38tL6sPo0ixIUCwGRv99E8tXWqNZsbgwf/8HK/fpDQtltSm1pypOSBrtQS81k9TiaTvjp6QIUnJTjiUJZLacw8CCMSKQNsWTWQ/tcGLHemca+6W6+57xvS1meG2TW/asu+pk2XOl/PCvXWX61erM0CvuvNJab33yXL+DffLb2p9ZiynJBVwvHz3Vers0s62NuOSmx+q3p3Y1koYXLjeCOtf191jcK6hZXf9hadIs0ZZtV582lEjZWjf7rW+Z+aLhUWcqWmmrxl5E2yaoWpinoHevcSjdqGN56Q3Bcrobs2mQI1Tihw/fbZmX+e09MrIsfHd/zXby/cIIGCPwKmH72/LLqJ3PvmqPPLSu/Y0OoRSC9QvuoePuz7iKbU//7pMTr9iuuy271i56cHnRa8LjfWkP/589MaLbdmNtKbdTWpTp7ueeq3mS3Hz/Zyv5suTr35geXsyAn65fdKuR9fo3WqnTTzd8vroAvWZmrF+9qwtcDYWSrBpI364RXvUeZqBvv3CvS3mrk/vqDYF8pl/pqbbVSV7NfrTcbvPNtRhB58dlGA2f3UbcuJ9BBCITEAHD2OPHBHZzVHc9d6n/5OJ0x6NIgdrbtXrxA791xTZqEY6Q00ffvG9HHjmVbL7IefKE7M+MPzYllDrYcZ1N084TU5RH07YmR5Uu5JepY6rieek/27onZmtTiccso/sM7DnDsXq41EaqfW5Vie9YZj+QIoUWwL8xhpD/RUcOFBcydYfmGslkddXLukdrPnHtFfGOmmUbM1UXSMNU9PdcsRFbBZkpCl5IYDA/wvsrX6xbKd2FLUy6TVpx14yNWY2xVny56rq+uo1gnWlMrV77szXZkvvw86TEeqsx3c/+5/jpgbXVfdQX7/41CNk0lnHhHq5Kde9+M5/Zdz195mSt5MyXb95i0y81Z6zN++7ZpzoqdI6DenTVfRutVYnvYnW2VffxZmaVsMbUB7BpgGIVmSRlJMt/m5drSjK1jKqNwWy4KkMJpVKv8zYXSOz59F+adXRvvPLbH1IKBwBBEwVOPbAvUzNf+fM9e6hR5x/veQVxNbOkh/O/UEee/m9nZsjm7cUyK2PviTt9hsrp0y6TeYt+n2Xa+LlhSkXnGRrUz74/Ds5+bJ/S2U9Qb+tFTS48Cdf/VA++Xq+wbk2nF3XDm1k/Ngjxetxq11qz7dliv39z74pX/+4uOHKcoXjBCz4td5xbY7JCmUOGSbiiu/uSmu2VVKaFFvSP8Nz/hKvy/ydbs1qjMvjkmMvq33Rvlllki8CCCSGwIihfSxt6MMvvi2/LP3L0jKNKmzSbY/Juo2bq7P7Y/kauWTqQ9Wb/ky67XFZsXaDUcWQTy0CX89fJEdecIPl56/WUhXLXtLj6P+acq+UlpZZVub2gq4+7wS1TvMM6dWl/faXLPtTn6l55R1PWlYeBRkr4DU2O3IzQyCtTVtJadPajKwdk6c+4iTYPc+S+nRM3ySt06yZqmtmg7oPT5Wew9Nk/qexNxXYTBfyRgCByAXatWwq7Vs3jzyDMO8sKNwq19/3bJh3OedyvcHPGVfeKWkpyTF7NqZzNEOviT7z9OCzrxG9YVOiJf3BzDQ1cq6DPyuT35cq40870soi/ynrwpse4EzNfzRi75v4HiqLvf7YpcYuNZqZMXTILq/H2wvpuQXiSSs3vVmpngoZkmP9eVVmNeyYSZniduvtPEgIIIBA9AIjhvaNPpMwcrj9iVdkzYZtI4Nh3OaoS9+Y85W8+O6nMbPe1FF4EVTmr1Xr5IAzrhS9hjFR01S1k/Gvf6xIiOa/qf5+vfL+5wnR1nhtJMGmw3vW372beLOzHV7L6KrnSatQmwKFvqtfNKUNyl4haW7zg9po6hjOvS1yk2TvY/zh3MK1CCCAQJ0C+w+xbgrtmvWb5LbHX6mzLryBwM4C6zflyUi12dJfq9fv/FZC/VysptEmwqZInKkZH481waaD+9Gtdp4NDhjg4BoaU7Vgtzy1HLXuHf2MKUWkZVqBdEmPvzU0h12YIWl+/iob9ZyQDwKJLDC8n3WHtOvNdRJxGmQiP1/RtF1PuT7orGtk4e/xMzspGo8P1HE6T78+O5osHH/vtffMlD/VSDYptgX4DdXB/Rfs31/c6mzNeE7J2SWS1tz8HQg9ajOgPdWZmvGYAtkeGf2vYDw2jTYhgICFAlnBdGnW2LqZNP/5cK6FraOoWBbQG+LoHYu/+YndSGv244RbHgnrvNea9zr9+x9++U2dqfkfp1eT+oUgQLAZApIdl3iDAUnvueMhunbUw+wy9aimFUmfqZnhLbGiKFvKGHFKQBq13HYGli0VoFAEEIh5gS7tW1nWBr275LcEDpZ5x3JB+nzFk9TxJvqoGdKOAms35oneETne0rYzNe9mHXScdCzBpkM7MmOw2hTIE9/Bg69VkSRnlpreA35vqfTNXG16OXYW4E12yZiJWXZWgbIRQCDGBfRZelal1z/6UsxfPGFVayjHTIGr7nxSXlIbMJFqF9DT0T/730+1vxmjr973zBuMYsdo39VWbYLN2lRsfi2leTNJy821uRbmFu/yVEmgqzWjmkNyVsT0mZqh9sQeB/okt09KqJdzHQIIILCDQJd21h2x9Z/ZX+xQNj8gUJfAIfsMEq+HX1fr8tl+9mZZWXxsfrhizXq5Un3AQIofAf72Oqwv9SEWGUOHOaxWxlcnPTdfPCkVxme8U44t0/Klgy+2t9XfqUn1/njc5EzhIJR6iXgTAQTqEOjUrmUd7xj7clVVlXz81Y/GZkpucSswuE9Xue6Ck+O2fUY07Kdfl8m/H3vZiKxsz+PCGx+QfLUZFCl+BAg2HdaXaR07SnLTpg6rlbHVseqoE7erSobG0ZmaofRC+14pMvBgXyiXcg0CCCCwg4DeIMiKpI+vKCkrs6IoyogTgclnHyP7DIz/fSyi6a4bH3hOlv61KposbL/3DTW9ftYHzHqwvSMMrgDBpsGg0WTnUms0MwYPjiaLmLg3qKbPWnHUSY/gOslOKo4JEyMrefSETElSazhJCCCAQDgCAb81u5+vWrcxnGpxLQLidrvl6Vsvk0aZ7Lxe1+OwtaRUxl13X11vO/51fabm+Tfc7/h6UsHwBQg2wzcz7Y5A797iCQRMy98JGVcfddLC/KNO0rxl0j8rvjcFqqs/s1t45YDT4vs5qqvtvI4AApELBNOtmRWxci3BZuS9lLh3tmiaI0/cPD5xAUJo+buf/U+ef+vjEK503iXX3P0UZ2o6r1sMqRHBpiGM0Wfi8fkk0Ldv9Bk5PAerjjoZnLVSkl3mrwl1KvdBZwUlIye+dzN2qj31QiBWBQJ+i4JNdewJCYFIBEbvM1AuPPnQSG5NmHsuuflh2bylIKba+/2CJXLXU6/FVJ2pbOgCBJuhW5l6ZXDgQHElJ5taht2Zp6kRTSuOOmmWWiid0hP7k/PUdLcccVGG3V1O+QggEEMCVo1sMo02hh4KB1b11kvPkN5d2juwZs6o0ur1m2Ty7Y87ozIh1KL6TM1rOFMzBKqYvYRg0wFdl5STI/6uXRxQE/OqoNdoBrpsMa+Av3N2qaWKw3P+Mr2cWChg+FF+adUpKRaqSh0RQMABAhUVlZbUwutl1oUl0HFaSIr6YP656ZPFn8ZRX3V18SMvvitzv/+lrrcd9fq9T78h3/70q6PqRGWMFSDYNNYzotwyhwwRtWNORPfGyk2+toXi9Zl/BlS3wHrJSWbLbP1cuDwuOfayzFh5RKgnAgjYLJBfaP56et3EFk1ybG4pxce6QJf2reXuK/8V680wrf6V6nihc6+9R8rLnb2cSJ+pedVdT5rmQMbOEIjvCMcZxvXWIq1tW0lp06bea2L9TZdXjWrmmj+qmeyulD2yYnvbb6P7uvuwNOm5pzU7TBpdd/JDAAFrBbYUWBVsZlvbMEqLS4HTjz5Ajjtor7hsmxGNmr/4d5k+Y5YRWZmWxwU3cKamabgOyphg08bOcLldkjF0qI01sKbo9Pb54k4xf3pW38zVkuo2f/TUGjXjSjl2UqaoU3VICCCAQL0CVh2k3rwxwWa9HcGbIQs8eN0F0q5lfJ9NHjJGLRded+8z8sfyNbW8Y/9Lr8/+Ul79kDM17e8J82tAsGm+cZ0l+Lv1EG9WVp3vx8Mb7pQK0cGm2SmQVCq7Z6wzu5iYzL95hyTZ6xiOQonJzqPSCFgoYN3IJtNoLezWuC4qI+CXZ2+fJF4Pv87W1tFFxSXq7Ernnb1ZULjVkfWqzZDXohfgb2f0hhHl4FYL3IMD94jo3li6KdAxX/Q0WrPTIHXUiUfMHz01ux1m5X/4hUHxpavdk0gIxKFAuj82poqn+5xdz/WbzV/uoB+/zGC6ZFh0pmccPu40aSeBQb27yg0XnbLTq/y4XeCtT76Rl979dPuPjvjzmrtnyl+r1zuiLlTCfAGCTfONay0h2L+/uFOd/YtHrRUP40Wvv1x8bQrDuCOyS5umFkkH/6bIbk6Qu9KzPDL6XDYLSpDudkwzrdqcIlONbsRCygxaU8+y8siWEyxautwyxgOG9bOsLAqKf4HLzhwj+w3qHf8NjbCFF099ULYUmP/7WCjV++7nJXL3TM7UDMUqXq4h2LShJ72BoKT37GlDydYWqY860UeemJ2GZFv3C5LZbTEz//1PSZfGLb1mFkHeCOwgsH5z3g4/m/WDVUFctPXPCloznX1DhCOUvyz9M9omhnz/YfsNDvlaLkSgIQG32y0zb71UGmdxvnRtVivXbpQrps+o7S1LX9Nnap5zLWdqWorugMIINm3ohIyh6qiTON+xJTmzVNKam7+zYQf/Zmma4oxP62x4lMIq0pvskjGXMroZFhoXRyWwfpNF0zID6VHV06qbrQqK122MLMhfuNS6M4oP2msPSeK8TasevYQop3mTbJlxy/iEaGskjXzgubfk6/mLIrnVsHvumfk6Z2oaphk7GRFsWtxXKc2aS1qHDhaXan1xwa6R/bITTk3drioZmL0ynFsS/tr+o3zSsQ8HYSf8g2ARgFXBZlaG84PNlKQkSUu15u9epO6Lfl8uVep8PiuSXre5Z//drSiKMhJI4KC9BsjFpxyeQC0Ovanbz96sqLDn7M3lq9fJ1Xc/FXqFuTJuBAg2LexKvT1L5vBhFpZoT1EpjUskOafE9MJ7BNdJ0Gt+OaY3xOICjrs8S9gqyGL0BC1u/SbzP3TStDmZQXH6us3cts0teQp0sBjpNNrCrSXyxwrrjkk4YoSa5UNCwGCBaRNPlz5d4/9D/UjYvv/lN7nzyf9EcmvU93CmZtSEMZsBwaaFXZfWqZMkNWliYYn2FBXoZP7UuVRPhfTLtO6XInskzSm1Xc9kGTjaZ07m5IpADYFIp3PWyCKkb10ulwzo2Tmka+26aFCvrpYUvXlLgZRXRL4z98dfzbeknrqQsUeMkOaNsywrj4ISQyA5OUmenz5Z/GnWzCSINdUp9z4tf65ca2m1X5s9V/6jvkiJKUCwaVG/u9QazYxBgywqzb5iUpoUS3KW+aON/TJXSYo7sh0X7dNxTslHj8+UJLWGk4SAmQKRTueMpE4DnR5s9u4SSbPCvifaAP+DL74Lu8xIb/D7UmXK+SdFejv3OVhABzOr1KY0dqVO7VrJvVePs6t4R5dbUFQsF974gGV11GdqXnDD/ZaVR0HOEyDYtKhPAn36iCdgzU6EFjWp1mKCnc2fNhdMKpXuQc5nqrUDQnwxu4VXRp0eDPFqLkMgMgE9ndOqNYADe1kTzEUmITLIovpFG+DPnvuDZX2mLU8/6gDp3K5lpKyOuO+iUw6Tm8ePlVZNGzmiPnZXYuPmfBl15lVy8mX/Fr37qF1p7JEj5ITRe9tVvKPLfe2jL2XOl/MsqeP0GbM4U9MSaecWQrBpQd94fD4J9O1rQUn2FpHabKskZZSZXon+alTTLdZsYmF6Y2ws4KCzgpKR47GxBhQd7wJ6OmdevjW7RetgzqOOP3BiylYbGHXLbWNJ1dZFuU52rdrJ9sfFv1tSV12IV+1Ie/P40ywrz+iC2rdqVl3/yWcfK7/PniHP3T5JnD7KbrRBzfy2FpfIIf+6Vn5ROxvP/vIHueXhF2u+bfn3D0w5X3QfkXYVWL3emvPJ16zfvGvhvJJQAs78P3OcdUHGwIHiUjsRxnsKWrBWMyu5WDqmW/MPZLz3V4rfJUdezJlk8d7Pdrcv2mmdodY/Jysoh+7rzKUKpx05UvQ5gFYkI7zf+9S6qbTa5IgRQ2W/Qb2t4DG8jPuuHffPLsM6cD7u4L3lyxfvlLnPT5djD9xTvB5r+t3whkWQod7l9LjxN8sX3//yz93X3jNT5tb4+Z83LPommO6X59T6TY7ZsQicYhCoRSBx/hWspfFWvJSc00h8Xa3ZGMKK9tRVhj5T0xs0f1RzQNYqtZMqo5p19UO4rw87Kl1ad0oO9zauRyBkgR8WLg352mgvHHfi6GizMPx+t9q86LwTrKvXD2q3yWjTM298FG0WYd//wh2Xx9wI1JlHHyCjhvevta2DeneV51Wb9GjnpDPHiB7djvd03nX3yesffbVDM/XshhMm3iJ64yq7kt487MaLT7WreMpFIOEFCDZNfgQyhg0VUb9sxHsKdDZ/B9omKUXSzsd0DCOfJZf6F+DYyZlGZkleCOwgYNW6IF3ofoP7OG7930F77SHtW1tz7Ik2+MiAdVjzFv0u3y9YorOzLOmR6dcfmCIBf5plZUZT0NA+3eS+axregKZVs8ZyizqK46+PZ8qDUy6Qru1bR1OsY++dcs/T8vCL79Ravz9WrJWzrr6r1vesevHSM46WEUP6WFUc5SCAQA0Bgs0aGEZ/m9a2raS0amV0to7LL62VGtVMN39n2IHZKx3X9nioULchqdJrr9R4aAptcKDAR1/9YGmtJpx2lKXl1VeY/phx4unW1Ufv/qnXyhmRnpj1gRHZhJVH945t5dnbJokeDXZyatO8scy692rRR2yEmnxpqXLOcQfJz289JO8+cqOMGtYvbs47fviFt+W6+56pl+Ll9z4TfZ1dSR+P9NS0idIkm6UjdvUB5SauAMGmSX3vcrukelTTpPwdk636nSDQ0fxRzZZp+dIyNd8xzY63ihw7KUvU6TwkBAwXWPT7CkuPQDhzzCjHjGCcf9KhsteAnoab1pXhR2pDFqPSs2/OkdJS85dG7Fzf0fsMlOmTz3ZsINa6WSN5/7Gp0iQnshkhOug5YHg/eefRG2XB2w/LuSoA9aXG7nmQ+vxEPX02lHTx1Ifk51+XhXKpKdc0a5wtM26Z4Nhny5RGkykCDhAg2DSpE/zde4g3M8uk3J2TrU+PavoZ1XROj0RWk2btk2TvY+N/TVFkOtwVrcCcr6zZYl/XU/8yP+PmCbavkeuhRuluvfSMaOnCuv8jA503qOMrZn3weVjlG3XxRaceLi/ddaX405wVhOkjWj5/brp0bm/MjKUuakrtA2pq7fJPZsotE04THcjGWjrzyjulIsTjTbaWlFZvIKR3rLUrHbjnHnLJ2CPsKp5yEUhIAYJNE7rdnZwswQF7mJCzw7LUo5oW7EDbzp8nTZKLHNb4+KvOYRdkiC/d2dPX4k89MVpkxDrCcKRaNM2Rh6+/yLYRDD1SpaeDpqZYu/mWkSOb2nvqQy9YeuZmzT4+6oBh1YFd2xZNar5s2/d6jeanz9wmrdUUWqNTVkZAJp11jCz9cIY8r3ZOtepMViPaUVYe3ofNP6mRzUtufsiIoiPOQx+10697bsT3cyMCCIQnQLAZnldIV6f36i3u1NjY5CCkBtVxka9VoXjSwvsfTR1Z1fmyy1UlA7JYq1knkIFvpGd5ZORpQQNzJCsEtglYObK53VwHK3o30BSLj51q1ihLPpl5q+zeud32qljy59K/VoneiMXI9OPiP2TW+/aMbup29OrSXr55+S4Z1re7kc0KKy99dMn1F54snzx9qzTOjmzqbKgF6qNTjj1oL5n7wh3ypfo6/uC94vLolIdeeEdeUWs47Up6re1zt0+WdB97FdjVB5SbWAIEmwb3t1v9YhPoad0aHYOrH1Z2gVzz11DqMzWzkorDqhcXRy4w4pSA+AL8sxC5IHfWJrB0+WpZtmJNbW+Z+tox6pzDD56YatmU2u65baqDhP67dzK1XbVlbtbosd74parKvuOmdID38cxp8uiNF0nLJjm1Nd201/RUaD1t9urzTlBr2q1d1D6wVxd5VgVEf3z0ZPV5naY10qaMz7zqTlv+Tdje3I67tZT7rz1/+4/8iQACJgrwW6XBuP4ePcQVw4v9Q+VIa1kkHpPXaqo9lqR/5qpQq8R1BgikqUBz5KkBA3IiCwR2FJg917jNa3bMuf6fhvfvoQLAO+VgdQSJWSk5ySvnHT9627TPlk3NKqbefD/84vt634/0TbtHN3W9daB3xtGj5Nf3H6te25gZ8EfanJDuy23TXJ6+9VKZ99r9os9otDM1V5va/LpshZ1VMKXszfmF6vzNaVJeXmFK/qFkevJh+8lJh+wbyqVcgwACUQh4o7iXW3cScKn/IQZ6997p1fj8MT3X/B1oO6dvkKC3ND4BHdyqESrYfH/GFikqsG80w8E8UVctSU2V08cexHIqLSuXcDejuf+5N+X0ow+wpdl6FOPNh66XL75bIFfeOUM+/vpHQ+qhp1ieevgINfJ1vLS1KcjUDVmxZr28+uEXhrSptkyuuvNJOUTtEhvOUR+15RPta2nqg1y9tvGsMQfKnU++Ki+9+6ks/H15tNlW36+PW9lb7Rx86hH7ywmj9xE9pdUJ6cHn35LvFvzmhKoYXocvvv9Frr1nptx0yVjD8w41w/uvHSdfzvtFlvzJB9uhmnEdAuEKEGyGK1bP9f5u3cTt89VzRXy8ldZsqyQFzF2r6VZrNftkWj/tLj56KLpW6NHNwYf6ZfazBdFlxN21CgTSfdXHHtT6Zoy8uGHTFmk0+Niwavu/n5fIB59/JyOG9g3rPiMvHtK3m8x56lb5ev4ieffTb1V9vpev5i+UsjBGV/Somg5K9lcHxI/ee4CtQeZ2m9sfnyX6AwCzkg7obnnkRblm3IlmFRFWvtmZAbn+olOqvxarur02+0v1NVfm/tddJVoAAB3mSURBVPCLVIYx5Vf3Zf8eHeUA9eHP8aP3lpZNnbUb7LqNm+WqO58KyybWLr7l4Rdlv8G9Zd9B9nxQr/891us3hxw/Pqx/B2LNmfoiYKcAwaaB+jrYTISU3tH8tZqd0jeqUU37tkdPhH6sr43Djkwn2KwPiPciErj54RdsDTa3V1pPjdRfOnjKLygSPcKycu0GWa+C6PWb8mT95i1SWFSs1noGJCcrII0yM6RRVlD0CKnexdLq9Xvb613bnzrwf/jFt2t7y9DXpj74glo7uJdhx34YVblO7VrJpWceXf21UR3XojdKWrl2Y3V/rlq37c/8wq2SGfRLVjAgWRnp0koFlXuodbW5bVtUH5VjVF2MzmfSbY/Lpi3x/aGf/nDgpEtvrZ6ybPYGTHX1j15jPVWNrl7678fquoTXEUAgCgGCzSjwat7qzciQpEbO+lS0Zv2M+j6lSbEkZZg7tVWPavZlVNOoLoson7Y9kqVVpyRZvtj6Q90jqjA3xYTAnK/my1fzFore/MQpSY9sHDA8dqc13z3zNSncav4HcyVlZXLulLurR4ad0nc710OPeOqveEh6yveMWR/EQ1MabMOqdZtk7OTpaqr7dbYF/xNOP0o+nPu9vPfZdw3WlwsQQCA8ATYICs+rzqt9uR3qfC+e3gh0NH+tZq7agZZRTfufmmFHpNtfCWoQdwJ62hzJGAE9KnvP068bk1kIuei1ro+8+E4IV3JJNAIVFRVy3nX3SiKtmn/7v99Ur8ONxi2ae11qze6Tt0yUpjnmHm8TTR25F4FYFSDYNKjn0jrkGpSTc7NJblQiyVnmjmqqf++lX+Zq5yIkUM367B//Z8UmUHc6pql6bd2CJcscU59YrsiDz79t+TTLi256UOYvWhrLbI6v+6W3PibzFv3u+HoaXcHJtz8h//vpV6OzDTm/puqMXB1wql9DSAggYKAAwaYBmNVTaBs3NiAnZ2cRtGBUs6Naq5nBWk1HPAiNW3ulcUtm2juiM+KoEnq0ZtojL8VRi+xpSnFJqUyfMcvywreqco+64EbJU0dXkIwXeOo/H8odaqfdREx6k6vjJ9xSvY7arvbrKfUTzzjKruIpF4G4FCDYNKBbU1u3MSAXZ2ehRzSTc8xdF8SopvOege5DU51XKWoU8wLPvfWx/Pbnyphvh50N0NNZV6/fZEsV9DERp10+3Zay47nQb35cJOdcc3c8N7HBtv26bKWaQnxfg9eZecFNF4+VPXp0MrMI8kYgoQQINg3o7uQm8b8xUMCCczVz/ZsY1TTgeTQyi25DCDaN9CSvbQL6qJHjx0+T0lI2oIrkmfj512WipxzamfS5nreo3YVJxgisUR8cHDHuBinm74Q8/cZHokd47UpJSV55bvokCfhZSmJXH1BufAkQbBrQn0mN4nsKbVKgTFKaFhsgVXcWLrUDLWs16/ax6532PZPtKppy41zgm58Wy8RbH43zVhrfvAJ1jMfRF94oRcXmzjQJpeZXTJ+RMDumhuIR6TX6Qxc9NXmFOn6HtE1g3PX3iT5D1a7UoU0Luf/acXYVT7kIxJUAwWaU3enyeCQpOzvKXJx9u7+9+ed85fo3S2aSuQGts5WdWbvs5l5J9bFdgjN7J/ZrpXdSffndT2O/IRa24Kyr75KFNv4SXrOpev3tmVfdKbPe/7zmy3wfhkCVOmfy3Cn3yOffLwjjrvi/tECdc6vXb9o5++GkQ/eTUw7bL/6xaSECJgsQbEYJXB1oqoAzXpMntULSWhaZ2zwVy/RlB1pzjSPNXfVN8/aMbkbKx30NC5xx5Z2yRK3TIjUscP+zb8jzb3/S8IUWXlFRWSknTJgmH3zO+YThsper6eSnTrpNnkiQ8zTD9fluwW9y2W2PhXubodffd8046di2haF5khkCiSZAsBlljyfl5ESZg7Nv16OaLrf+/Nq8tJsvT7IY1TQPOMqcW3RgR9ooCbm9HoEthUUy5qKbRO+uSqpb4NsfF8slNz9c9wU2vlNSViZHnH+9vPfp/2ysRWwVXVJaWv3cz3z9o9iquMW1veup1+TNOV9ZXOr/F5eu1m0+d/tkSVbrOEkIIBCZAMFmZG7/3OVOjd8NVNxJleJvY/4U2j4Za/7x5BvnCTRqxf9kndcr8VWjHxYuFX1+I6l2gU15+TLm4ptEHw3h1FS4tUQOOvtqufVRjrVpqI8K1RTR0edcK/9RZ86SGhbQOx+vXGPfetZ+PTrKzeNPa7iiXIEAArUKEGzWyhL6i+6UlNAvjrErfW0KxeU1d1SzeVqBNE3hvDYnPxr+IP9MOLl/4qVuD6ujPB56/u14aY5h7diqNgLSa9f+WLHWsDzNyqhSrT+cdNvjaqfhW6RoK2vwa3PevKVARpx+uXw494fa3ua1WgTWb94iJ112q1SqKdt2pUvGHiEHDu9vV/GUi0BMC/BbZJTdF6/Bpp4662/HqGaUj0dc3O4LqoWbJAQsENAbpVx5xwzRm6aQRFav2yh7n3yZvPdZbK2H1OtKhx4/QZatYNZKzedYe+j+nPvDwpov830IAnO+mi83Pfh8CFeac4lLHQQ+45YJ0qxRljkFkCsCcSxAsBll57qT43NkU28KpDcHMjPlJG+VNmlbzCyCvA0Q8AXjdwMsA3jIwmCBqQ+9IMdecrPoEb1ETvMXLZUBYy6Sr9VazVhMemp0/6MulDlfzovF6hte5+fe/Fh6HXaezFv0u+F5J0qG1937jHz+3c+2NbdJTqY8NW2i8PGrbV1AwTEqQLAZZce5UuMz2EzvYP6oZu9MPvWO8vGz5PbkNP7Xagk0hfwj8JI6DkWPAOmRvURMekMUPTL41+r1Md18Pf1x5BlXyF1P/iem2xFN5fMLiuSUy/4tJ0ycJnnqe1LkAtt3PtZrmO1KI4b2lcvOHGNX8ZSLQEwKEGxG221x+Ht4atNi8aaXRStT7/3BpFLJ9W+q9xredIZAeSlTGp3RE4lVCz2iN/CYi+XHBBsJumPGLDnsvOtEnzMYD6m8olIuvvkhGTv59oTbcXju979I78PHCTvOGvck/7lqXfXZrsblGH5ON1x0igzYvVP4N3IHAgkqQLAZZcdXOXh3wEiblt7e/KmtvdQOtHEYp0dK7uj7SosJNh3dQXFcOf2L5dATJsjbn3wdx63c1jR95uI519wt4295RPRGO/GWnvzPh9LtoLPl9dlfxlvTdmlPmfq94Pr7npE9T5ooS5ev3uV9XohOYNYHX8gDz70ZXSZR3J2kjkF5bvpkCfp9UeTCrQgkjgDBZpR9XRVnZ8MlZ5ZKco65592lecqkS7p925hH2eUJd3sZI5sJ1+dOanB+4VY59F/XyXlT7rX1+AMzTT784nsZfNwlonfkjef0u9og57Bx18nBZ18jS5atjLum6t1Sn1HnZnZVQfW19zwtelSXZI7AeHXmrJ2zHtq3bi4PTDnfnMaRKwJxJkCwGWWHVqrDrOMp+SzYgXb3jPXiccXfJ/fx9BzUbEtJIX1V04PvrRfQa7UeeP4tyR15ukz692OycbN9a7aMbP2XP/wi+506WR2FcYV8+9OvRmbt6Lze/u830mP0uXL57U/Ihk3mz6SxAuONj76snjJ7klqf+dtfq6woMqHLKC4tk+NsPmLnhEP2kbFH7J/Q/UDjEQhFgGAzFKV6rqksiY91NbqJnpQKSWu+tZ7WRv9WsrtSegTWRZ8ROVgmsGGlcw+StwyBghwhsFXNJLn1sZel/f5j5cb7n5MCNeoZi0mPyBx23hQ1mjlePvoqMXdrLVEf1N7yyIvSdt9T5JKpD8ny1bH5/4WP1ZEcQ9So9KFqne2Pi/+IxccxZuu84Lc/5aKbHrS1/vdePU467dbS1jpQOAJOFyDYjLKHyjdvjjIH59zu261Q9PmaZqaugQ2S7Db3SBUz65+Iea9bTrCZiP3u5DbrXT2vvvsp6TDitOqdTktKzZ36b5TFb3+ulBPVrqR605jXP/rKqGxjOp/CrSVy51P/UX15upx55Z2yYMkyx7enUG3e9PTrs2XfUyfJPuqLczPt67JHX35PXnznv7ZVwO9LlefV+s1ktY6ThAACtQvwt6N2l5BfjZdgUweZ/rbmHneizkSWHsG1IdtyoTME1v3FhwPO6AlqsbPA2o151TudTlc7uP7r+IPlmAP3FL2WykmpoqJC9IH0z7zxkQpQPmIdXx2dU6o21Xnslfeqv/p1z5WTD91Pjjt4L2naKKuOO6x9Wa/HnD33B5n52myZ9cHnooNkkjMEzr76LtlD7Q7brlUzWyrUp1uuTJt4ulyi1pGSEEBgVwGCzV1NwnolXoLNtJZbxZ1s7mYG7X2bJeCNjRGIsB6COL943V/xtS45zrsrIZund629fPqM6q/+PTrKsSroPPqA4bJbq6a2eOgA89Nvf5YX3vlEXnnvc1m3Kc+WesRqof/7eYnorwnTHpERQ/pUf4iw76Be0raltf2pA8wfflkqz731sTz75hxZuTYxz311+nOkZzqcMGGafPrMbeL1emyp7sWnHiF6o6+3PvnGlvIpFAEnC7ik8yhz5006ufUG1a35qaeKJz3doNzsyabJnmvEGzQ3qDiixWJpmlJoTwMpNSKBtcvKZfIB8bdrZEQY3BRzAm1bNJGhfbvJkD7dqv/cvdNu4vEY/8toXn6hfPnDQvn8u5/V1wL5av5CRr5MeFraqWBz30G9ZZ+BPWV4/x7SRvWvkUmvG/16/mL5at5C+frHRdWbNsXLeadGOpEXAgggEI4AwWY4WnVcmzNypKR17FjHu85/OTmnRBoNNndzhiYpRXJki0XOx6CGOwh88WqBPHo5n+bvgMIPMSvgS01R02ybVU+303+2b9Vcfd9UsjLSxZeaKv409eVLqf4zyeuVrSUlUlikvrYWS1FxcfX3K9dukN/V2Yn6/MSlatfRpX+tVpvbrI/LszGd3tH+tBS1OUsr6dyuVfUmLfrPRllBCajzDwP+tL+/fGq0yy2b8gpkY17+31/qe7Wj8faff/tzVfUHBIxcOr3HqR8CCMSiANNoDei1kuXLYzrYTG9v/jECvTJYq2nAo2Z5Fku+Z9qz5egUaJpAUXGJ/PTrsuov0wohY8sE9LrJ73/5rfrLskIpCAEEEEAgLAF2ow2Lq/aLi1esqP2NGHjV6yuX1KbmHt/iV+s02/njZ9feGOhWw6q4+Fs2wTAMk4wQQAABBBBAAIEEEyDYNKDDy/PypCLf/NFBA6q6Sxb+dubuQKsL3D24XtzC0uBd8B3+wuql5bJyqbnreB1OQPUQQAABBBBAAAEEohAg2IwCr+atW5csqfljTHzv8laKr7W5G/YkqTM1uwXWx4QHldxR4Nt3i3Z8gZ8QQAABBBBAAAEEEAhDgGAzDKz6Li1atLi+tx35nq9Vkbi85o44dg5slGQVcJJiT+Abgs3Y6zRqjAACCCCAAAIIOEiAYNOgzijdsF7KNmwwKDdrsvG1NXdU0+Wqkp5Bc3e5tUYq8UpZ9VuZ/LWYzYESr+dpMQIIIIAAAgggYJwAwaZxllL0y0IDczM3q5TsEkkKmLser61viwS9bDBjbk+ak/uHM2NzDbI5GuSKAAIIIIAAAgggEIkAwWYkanXcU/jLAqlS57LFQvLtZu6opjbYnVHNWHgUdqlj4eZK+eI/5j8fuxTMCwgggAACCCCAAAJxJUCwaWB3VpaWSsGPPxqYozlZuVMqJLXZVnMy/zvXzOQSaZnK6JipyCZl/skLBVJSbO5aXpOqTrYIIIAAAggggAACDhIg2DS4MwrmzZOqsnKDczU2O3+bQnG5zQ0megRYq2lsr1mTW2lRlXzAFFprsCkFAQQQQAABBBCIcwGCTYM7uKK4WI1uzjc4VwOzU5v2+FSwaWbSx510UrvQkmJP4J1Ht0jeenYPjr2eo8YIIIAAAggggIDzBAg2TeiT/G+/lcpCcwO6SKud2qRYPGnmBhMd0zdJssvcMiJtP/fVLbB5TYW8+/iWui/gHQQQQAABBBBAAAEEwhAg2AwDK9RLK8vKZPNnn4V6uaXX+duZHwT3CK63tE0UZozAK9M3s1bTGEpyQQABBBBAAAEEEFACBJsmPQZFS5ZIyZ9/mpR7ZNl6/eWS0qg4sptDvKt5WoFkJ5m7+VCIVeGyMAR+/rRYvnjN/A8iwqgSlyKAAAIIIIAAAgjEuADBpokduOnjjx11FIqvTYGJrd2WNRsDmU5seAH6qJPHr9wg5m4ZZXi1yRABBBBAAAEEEEDA4QIEmyZ2UHl+vmycPdvEEkLP2qV62temKPQbIrjS5y2Xdv68CO7kFjsFnrp2o2xayxpbO/uAshFAAAEEEEAAgXgUINg0uVe3/v676ONQ7E5pzYvEnVRpajW6qeNO3IyPmWpsdOafPF8g37xn7ocQRteZ/BBAAAEEEEAAAQRiQ4Bg04J+yvviCyldudKCkuouwuzjTtzqSJWugQ11V4B3HCew4ItiefoGjqhxXMdQIQQQQAABBBBAIE4ECDYt6MiqykpZ/9ZbUrbBnl1avb5ySc4pMbWlu/nyxO8pM7UMMjdOYOWSMrn/wnVSwexZ41DJCQEEEEAAAQQQQGAHAYLNHTjM+6GytFTWv/GGVORbf46h2aOaWq07x52Y9/AYnPOm1eVy5zlrpaiALYEMpiU7BBBAAAEEEEAAgRoCBJs1MMz+tqKwSNa99rpUFJi/K+w/bXGJpLU2d01eMKlUWqbm/1Mk3zhXYP3ycrnlxLWyfgVDms7tJWqGAAIIIIAAAgjEhwDBpsX9WJ6XJ+teeUXKN1qzVi61yVbxpJgbWLBW0+KHKMLiVi/dFmiuW1EeYQ7chgACCCCAAAIIIIBA6AIEm6FbGXZluRrZXDtrlpSuWm1YnnVlZPYUWr0xUJd0Ngaqy98pr/8+v1SmnbxGNq4h0HRKn1APBBBAAAEEEEAg3gUINm3q4cqSEln3+mtStHChaTXwpFZIahNzNwZq69siaWwMZFofGpHxJy8UqKmzayRvg7kj3EbUlTwQQAABBBBAAAEE4kfAGz9Nib2WVJWXy8bZs6VkxQrJ3HMvcSUZ2x2+1oUiauTRzNQ1YM8Ou2a2KV7yLi+pkpnXbZRPZ6nngIQAAggggAACCCCAgMUCxkY3Flc+XoorVKObpWvWSPbIkZLUqJFhzfKZvDGQ31sqrdPYGMiwDjMwo6U/lMiMqzfK8l85jsZAVrJCAAEEEEAAAQQQCEOAYDMMLDMvLdu0Sda++JKk9+whwQEDxZWcHFVxKY1LxKPO1zQzdQ1sFJeYO3JqZv3jMe/igkp5ZfpmmfNcgVTSNfHYxbQJAQQQQAABBBCIGQGCTQd1VVVVpeTPmy9FS5ZI5pChktapU8S187U293gVl5qeyy60EXeP4TdWqs8VvnyzsDrQ3LSWtZmGA5MhAggggAACCCCAQNgCBJthk5l/gz6Pc8MHH0jS//4nwX79JK1jrlp7GfpeTu6kSkltVmxqRfX0Wb+n1NQyyLxhgfLSKvlcrcl8+5EtwpEmDXtxBQIIIIAAAggggIB1AgSb1lmHXVKZOotTB53er7+WQJ8+4uvYMaTptanNt6rY1Nw5lIxqht2dht6gp8v+9+UCee/xfGEk01BaMkMAAQQQQAABBBAwSMAlnUeZG5UYVFGyUYObXq+ktWsnvi6dJbV16zpHOxsNWSfJ2eYdeZLmLZOTW/8sbtZrWvpY6gBz3pyt8s27RfLjp8VSpkY1SQgggAACCCCAAAIIOFWAkU2n9kwt9dJHpRT9+mv1lyc1VVJatZSUlq2qv7xZmdV3eNWmQGYGmrqQzukbCTRr6R+jX9LrMFf+Vip//Fgq33+0VX76jADTaGPyQwABBBBAAAEEEDBPgGDTPFtTc64oLlYbCf1W/aUL8vh8kpSdI77dfFKVnyyprdIlKStFPGnebV8+r5qC6zGkTjrYJEUvUFJYJUX5FVKUV6n+rJJC9edmtbnPnwtLZdnPZbJ8UamUlTF6Gb00OSCAAAIIIIAAAgjYIcA0WjvUKRMBBBBAAAEEEEAAAQQQiHOB0Lc4jXMImocAAggggAACCCCAAAIIIGCcAMGmcZbkhAACCCCAAAIIIIAAAggg8LcAwSaPAgIIIIAAAggggAACCCCAgOECBJuGk5IhAggggAACCCCAAAIIIIAAwSbPAAIIIIAAAggggAACCCCAgOECBJuGk5IhAggggAACCCCAAAIIIIAAwSbPAAIIIIAAAggggAACCCCAgOECBJuGk5IhAggggAACCCCAAAIIIIAAwSbPAAIIIIAAAggggAACCCCAgOECBJuGk5IhAggggAACCCCAAAIIIIAAwSbPAAIIIIAAAggggAACCCCAgOECBJuGk5IhAggggAACCCCAAAIIIIAAwSbPAAIIIIAAAggggAACCCCAgOECBJuGk5IhAggggAACCCCAAAIIIIAAwSbPAAIIIIAAAggggAACCCCAgOECBJuGk5IhAggggAACCCCAAAIIIIAAwSbPAAIIIIAAAggggAACCCCAgOECBJuGk5IhAggggAACCCCAAAIIIIAAwSbPAAIIIIAAAggggAACCCCAgOECBJuGk5IhAggggAACCCCAAAIIIIAAwSbPAAIIIIAAAggggAACCCCAgOECBJuGk5IhAggggAACCCCAAAIIIIAAwSbPAAIIIIAAAggggAACCCCAgOECBJuGk5IhAggggAACCCCAAAIIIIAAwSbPAAIIIIAAAggggAACCCCAgOECBJuGk5IhAggggAACCCCAAAIIIIAAwSbPAAIIIIAAAggggAACCCCAgOECBJuGk5IhAggggAACCCCAAAIIIIAAwSbPAAIIIIAAAggggAACCCCAgOECBJuGk5IhAggggAACCCCAAAIIIIAAwSbPAAIIIIAAAggggAACCCCAgOECBJuGk5IhAggggAACCCCAAAIIIIAAwSbPAAIIIIAAAggggAACCCCAgOECBJuGk5IhAggggAACCCCAAAIIIIAAwSbPAAIIIIAAAggggAACCCCAgOECBJuGk5IhAggggAACCCCAAAIIIIAAwSbPAAIIIIAAAggggAACCCCAgOECBJuGk5IhAggggAACCCCAAAIIIIAAwSbPAAIIIIAAAggggAACCCCAgOECBJuGk5IhAggggAACCCCAAAIIIICAW1yuUhgQQAABBBBAAAEEEEAAAQQQMExAxZlqZLMq37AMyQgBBBBAAAEEEEAAAQQQQAABFWe6pUq2IIEAAggggAACCCCAAAIIIICAYQIqzlQjm66lhmVIRggggAACCCCAAAIIIIAAAgioOFMHm4uQQAABBBBAAAEEEEAAAQQQQMA4AdcitUFQ1TzjMiQnBBBAAAEEEEAAAQQQQACBhBdQcaYKNj1zEh4CAAQQQAABBBBAAAEEEEAAAeMEVJzpqs6t86g/1Z+tjcuZnBBAAAEEEEAAAQQQQAABBBJU4C9Z9G4btWZTJZfrxQRFoNkIIIAAAggggAACCCCAAAJGCvwdX24LNj2emUbmTV4IIIAAAggggAACCCCAAAIJKvB3fLkt2Pz5zXniko8TlIJmI4AAAggggAACCCCAAAIIGCGg40odX6q0LdjU33m8k/QfJAQQQAABBBBAAAEEEEAAAQQiEqgRV/5/sPnzm1+rzF6KKENuQgABBBBAAAEEEEAAAQQQSHSBl9Sopo4rq9P/B5v6R2/yFWo6bdm2t/gvAggggAACCCCAAAIIIIAAAiEI6DhSx5M1kqfG9yLrFm2URh0z1GtDdnidHxBAAAEEEEAAAQQQQAABBBCoS8DlulN+eev5mm/vOLKp32k2cLIa3ZxT8yK+RwABBBBAAAEEEEAAAQQQQKBWAR0/6jhyp+Ta6edtP3Y5IkeqitVc26r2tb7PiwgggAACCCCAAAIIIIAAAgiIa6m4UgfIwlc37IxRe7Cpr+p2SHepLJ8rVVWBnW/iZwQQQAABBBBAAAEEEEAAgQQXcLnyxe0dLAve+Lk2iV2n0W6/St/gch2lvvK3v8SfCCCAAAIIIIAAAggggAACCFTHiTperCPQ1EJ1j2xu99MjnBXlrzOldjsIfyKAAAIIIIAAAggggAACiSygps56vIfWF2hqnbpHNrfbVY9wqjm4bBq0XYQ/EUAAAQQQQAABBBBAAIHEFNBxoV6jWc+I5naYhoNNfaVe7Nls0Eg1VHo753Bup+NPBBBAAAEEEEAAAQQQQCBBBPQ5mjoe1HFhLZsB1abwf8cL658sP0TgAAAAAElFTkSuQmCC\"\n  end\nend\n"
  },
  {
    "path": "src/lucky.cr",
    "content": "require \"wordsmith\"\nrequire \"exception_page\"\nrequire \"habitat\"\nrequire \"cry\"\nrequire \"dexter\"\nrequire \"pulsar\"\nrequire \"lucky_cache\"\nrequire \"./lucky/memoizable\"\nrequire \"./lucky/quick_def\"\nrequire \"./charms/*\"\nrequire \"http/server\"\nrequire \"lucky_router\"\nrequire \"./bun/*\"\nrequire \"./lucky/events/*\"\nrequire \"./lucky/support/*\"\nrequire \"./lucky/renderable_error\"\nrequire \"./lucky/errors\"\nrequire \"./lucky/response\"\nrequire \"./lucky/cookies/*\"\nrequire \"./lucky/secure_headers/*\"\nrequire \"./lucky/route_helper\"\nrequire \"./lucky/*\"\nrequire \"./lucky/paginator/paginator\"\nrequire \"./lucky/paginator/*\"\nrequire \"./lucky/paginator/components/*\"\n\nmodule Lucky\n  ROUTER = Lucky::Router.new\n\n  Log              = ::Log.for(\"lucky\")\n  ContinuedPipeLog = Log.for(\"continued_pipe_log\")\n\n  # Use Dir.current to return the root folder of your Lucky application.\n  #\n  # In some frameworks there is a method called `root` that returns the root directory of the project.\n  # In Crystal there is a built-in method for this: `Dir.current`. This method exists purely to help new users\n  # find `Dir.current`. If you call `Lucky.root` it will raise a compile-time error directing you to use `Dir.current`\n  def self.root\n    {% raise \"Please use Crystal's 'Dir.current' to return the root folder of your Lucky application.\" %}\n  end\n\n  def self.router : Lucky::Router\n    ROUTER\n  end\nend\n"
  },
  {
    "path": "src/run_macros/asset_manifest_builder.cr",
    "content": "require \"json\"\nrequire \"colorize\"\nrequire \"../bun/config\"\n\nstruct AssetManifestBuilder\n  enum Source\n    Bun\n    Mix\n    Vite\n  end\n\n  property retries = 0\n\n  @manifest_path : String\n  @source : Source\n  @max_retries : Int32\n  @retry_after : Float64\n  @bun_config : LuckyBun::Config?\n\n  def initialize(@source : Source = Source::Bun, manifest_file : String = \"\")\n    @manifest_path = resolve_manifest_path(manifest_file)\n\n    # These values can be configured at compile time via environment variables:\n    # - LUCKY_ASSET_MANIFEST_RETRY_COUNT: Number of times to retry (default: 20)\n    # - LUCKY_ASSET_MANIFEST_RETRY_DELAY: Delay between retries in seconds (default: 0.25)\n    @max_retries = ENV[\"LUCKY_ASSET_MANIFEST_RETRY_COUNT\"]?.try(&.to_i) || 20\n    @retry_after = ENV[\"LUCKY_ASSET_MANIFEST_RETRY_DELAY\"]?.try(&.to_f) || 0.25\n  end\n\n  # Tries to build a manifest from the chosen bundler and retries several times\n  # when it fails.\n  def build_with_retry\n    retry_or_raise_error unless File.exists?(@manifest_path)\n\n    case @source\n    in .bun?  then build_bun_manifest\n    in .mix?  then build_mix_manifest\n    in .vite? then build_vite_manifest\n    end\n  end\n\n  # Tracks retries and raises if maximum allowed retries are exceeded.\n  private def retry_or_raise_error\n    raise_missing_manifest_error unless retries < @max_retries\n\n    self.retries += 1\n    sleep @retry_after\n    build_with_retry\n  end\n\n  # Builds an internal asset manifest from Bun's generated manifest file.\n  #\n  # NOTE: Bun's manifest uses values are filenames relative to the output\n  # directory, so we need to prepend the public_path from `LuckyBun::Config`\n  # (defaults to \"/assets\").\n  #\n  private def build_bun_manifest\n    config = bun_config\n    JSON.parse(File.read(@manifest_path)).as_h.each do |key, value|\n      path = File.join(config.public_path, value.as_s)\n      puts %({% ::Lucky::AssetHelpers::ASSET_MANIFEST[\"#{key}\"] = \"#{path}\" %})\n    end\n  end\n\n  # Builds an internal asset manifest from Laravel Mix's generated manifest\n  # file. Keys are prefixed with \"/\" and optionally \"assets/\" that we strip.\n  #\n  private def build_mix_manifest\n    JSON.parse(File.read(@manifest_path)).as_h.each do |key, value|\n      clean_key = key.gsub(/^\\//, \"\").gsub(/^assets\\//, \"\")\n      puts %({% ::Lucky::AssetHelpers::ASSET_MANIFEST[\"#{clean_key}\"] = \"#{value.as_s}\" %})\n    end\n  end\n\n  # Builds an internal asset manifest from Vite's generated manifest files.\n  #\n  # NOTE: Vite has two manifest formats:\n  # Dev manifest (from vite-plugin-dev-manifest):\n  #   `{ \"url\": \"http://localhost:5173/\", \"inputs\": { \"src/js/app.js\": \"src/js/app.js\" } }`\n  # Production manifest:\n  #   `{ \"src/js/app.js\": { \"file\": \"assets/app.abc123.js\", \"src\": \"src/js/app.js\" } }`\n  #\n  private def build_vite_manifest\n    manifest = JSON.parse(File.read(@manifest_path))\n\n    if manifest.as_h.has_key?(\"url\") && manifest.as_h.has_key?(\"inputs\")\n      build_vite_dev_manifest(manifest)\n    else\n      build_vite_prod_manifest(manifest)\n    end\n  end\n\n  # Builds an internal asset manifest from Vite's generated development\n  # manifest file.\n  private def build_vite_dev_manifest(manifest)\n    base_url = manifest[\"url\"].as_s\n    manifest[\"inputs\"].as_h.each do |_, value|\n      path = value.as_s\n      clean_key = path.starts_with?(\"src/\") ? path[4..] : path\n      puts %({% ::Lucky::AssetHelpers::ASSET_MANIFEST[\"#{clean_key}\"] = \"#{base_url}#{path}\" %})\n    end\n  end\n\n  # Builds an internal asset manifest from Vite's generated production manifest\n  # file.\n  private def build_vite_prod_manifest(manifest)\n    manifest.as_h.each do |key, value|\n      next if key.starts_with?(\"_\")\n\n      if value.as_h.has_key?(\"src\")\n        clean_key = key.starts_with?(\"src/\") ? key[4..] : key\n        puts %({% ::Lucky::AssetHelpers::ASSET_MANIFEST[\"#{clean_key}\"] = \"/#{value[\"file\"].as_s}\" %})\n      end\n    end\n  end\n\n  # Resolves the full path of the asset manifest file based on the selected\n  # bundler, with a fallback to defaults.\n  private def resolve_manifest_path(file : String) : String\n    path = case @source\n           in .bun?\n             bun_config.manifest_path\n           in .mix?\n             file.blank? ? \"./public/mix-manifest.json\" : file\n           in .vite?\n             file.blank? ? \"./public/.vite/manifest.json\" : file\n           end\n\n    File.expand_path(path)\n  end\n\n  # Loads and memoizes the shared config between Bun and Lucky.\n  private def bun_config : LuckyBun::Config\n    @bun_config ||= LuckyBun::Config.load\n  end\n\n  # Renders a helpful message and raises an error if the asset manifest file\n  # could not be found.\n  private def raise_missing_manifest_error\n    message = case @source\n              in .bun?\n                <<-ERROR\n                #{\"Manifest not found:\".colorize(:red)} #{@manifest_path}\n\n                #{\"Make sure you have compiled your assets:\".colorize(:yellow)}\n                  bun run dev     # start development server with watcher\n                  bun run build   # normal build\n                  bun run prod    # minified and fingerprinted build\n\n                ERROR\n              in .mix?\n                <<-ERROR\n                #{\"Manifest not found:\".colorize(:red)} #{@manifest_path}\n\n                #{\"Make sure you have compiled your assets:\".colorize(:yellow)}\n                  yarn run mix         # development build\n                  yarn run mix watch   # development build with watcher\n                  yarn run mix --production  # production build\n\n                ERROR\n              in .vite?\n                <<-ERROR\n                #{\"Manifest not found:\".colorize(:red)} #{@manifest_path}\n\n                #{\"Make sure you have compiled your assets:\".colorize(:yellow)}\n                  npx vite        # start development server\n                  npx vite build  # production build\n\n                ERROR\n              end\n\n    puts message\n    raise \"Asset manifest not found\"\n  end\nend\n\nbegin\n  source = AssetManifestBuilder::Source.parse(ARGV[0]? || \"bun\")\n  manifest_file = ARGV[1]? || \"\"\n\n  AssetManifestBuilder.new(source, manifest_file).build_with_retry\nrescue e\n  puts e.message.try(&.colorize(:red))\n  raise e\nend\n"
  },
  {
    "path": "src/run_macros/missing_asset.cr",
    "content": "require \"colorize\"\nrequire \"json\"\nrequire \"levenshtein\"\n\nmissing_asset = ARGV.first\nasset_paths = ARGV[1].split(\",\")\n\nbest_match = Levenshtein::Finder.find missing_asset, asset_paths, tolerance: 4\n\nputs %(\"#{missing_asset}\" does not exist in the manifest.).colorize(:red)\n\nif best_match\n  puts %(Did you mean \"#{best_match}\"?).colorize(:yellow).bold\nelse\n  puts \"Make sure the asset exists and you have compiled your assets.\".colorize(:red)\n  puts \"If you recently added a static asset or font try stopping the server (ctrl-c) and starting it again (lucky dev).\".colorize(:red)\nend\n\nraise \"There was a problem finding the asset\"\n"
  },
  {
    "path": "tasks/exec.cr",
    "content": "require \"cry\"\nrequire \"habitat\"\nrequire \"lucky_task\"\n\nclass Lucky::Exec < LuckyTask::Task\n  name \"exec\"\n  summary \"Execute code. Use this in place of a console/REPL\"\n  help_message <<-TEXT\n  #{task_summary}\n\n    Options:\n      --editor=EDITOR, -e EDITOR    Use the EDITOR for editing code\n      --back=NUMBER, -b NUMBER      Load code NUMBER sessions back\n      --once, -o                    Only run this code once then exit\n\n    example: lucky exec -e emacs -b 3 -o\n\n    Run this task with 'lucky exec [OPTIONS]'\n  TEXT\n\n  arg :editor, \"Which editor to use\", shortcut: \"-e\", optional: true\n  arg :back, \"Load code from this many sessions back. Default is 1.\",\n    shortcut: \"-b\",\n    optional: true,\n    format: /^\\d+/\n  switch :once, \"Don't loop. Only run once.\", shortcut: \"-o\"\n\n  Habitat.create do\n    setting editor : String = \"vim\"\n    setting template_path : String = Path[\"#{__DIR__}/exec_template.cr.template\"].normalize.to_s\n  end\n\n  def call\n    editor_to_use = editor || ENV[\"EDITOR\"]? || settings.editor\n    repeat = !once?\n    sessions_back = (back || 1).to_i\n\n    Cry::CodeRunner.new(\n      code: \"\",\n      editor: editor_to_use,\n      repeat: repeat,\n      back: sessions_back,\n      template: settings.template_path\n    ).run\n  end\nend\n"
  },
  {
    "path": "tasks/exec_template.cr.template",
    "content": "require \"../../src/app.cr\"\n\n# You have access to all your app's code here.\n#\n# This file will be executed when the editor is closed.\n#\n# If you want to see the result of an expression, use 'pp!'\n#\n#   pp! UserQuery.first # Print the first user\n"
  },
  {
    "path": "tasks/gen/action/action_generator.cr",
    "content": "require \"ecr\"\nrequire \"colorize\"\nrequire \"lucky_template\"\nrequire \"../../../src/lucky/route_inferrer\"\n\nclass Lucky::ActionTemplate\n  @name : String\n  @action : String\n  @inherit_from : String\n  @route : String\n  @save_path : String\n\n  def initialize(@name, @action, @inherit_from, @route)\n    @save_path = @name.split(\"::\").map(&.underscore.downcase)[0..-2].join('/')\n  end\n\n  def render(path : Path)\n    LuckyTemplate.write!(path, template_folder)\n  end\n\n  def template_folder\n    LuckyTemplate.create_folder do |root_dir|\n      root_dir.add_folder(Path[\"src/actions/#{@save_path}\"]) do |actions_dir|\n        actions_dir.add_file(\"#{@action}.cr\") do |io|\n          ECR.embed(\"#{__DIR__}/../templates/action/action.cr.ecr\", io)\n        end\n      end\n    end\n  end\nend\n\nmodule Gen::ActionGenerator\n  private def render_action_template(io, inherit_from : String)\n    if valid?\n      Lucky::ActionTemplate.new(action_name, action, inherit_from, route).render(Path[\".\"])\n      io.puts success_message\n    else\n      io.puts @error.colorize(:red)\n    end\n  end\n\n  private def valid?\n    name_is_present && name_matches_format && route_generated_from_action_name\n  end\n\n  private def name_is_present\n    @error = \"Action name is required. Example: lucky gen.action Users::Index\"\n    action_name.presence\n  end\n\n  private def name_matches_format\n    @error = \"That's not a valid Action. Example: lucky gen.action Users::Index\"\n    action_name.includes?(\"::\")\n  end\n\n  private def route_generated_from_action_name\n    route\n    true\n  rescue ex\n    @error = ex.message\n    false\n  end\n\n  @route : String?\n\n  private def route\n    @route ||= Lucky::RouteInferrer.new(action_class_name: action_name).generate_inferred_route\n  end\n\n  private def action\n    path_args.last\n  end\n\n  private def output_path\n    Path[\"./src/actions/#{path}\"].normalize\n  end\n\n  private def path\n    path_args[0..-2].join(\"/\")\n  end\n\n  private def path_args\n    action_name.split(\"::\").map(&.underscore).map(&.downcase)\n  end\n\n  private def success_message\n    \"Done generating #{action_name.colorize.green} in #{output_path.colorize.green}\"\n  end\nend\n"
  },
  {
    "path": "tasks/gen/action/api.cr",
    "content": "require \"lucky_task\"\nrequire \"./action_generator\"\n\nclass Gen::Action::Api < LuckyTask::Task\n  include Gen::ActionGenerator\n\n  summary \"Generate a new api action\"\n  help_message <<-TEXT\n  #{task_summary}\n\n  Example:\n\n    lucky gen.action.api Api::Users::Index\n  TEXT\n\n  positional_arg :action_name, \"The name of the action\"\n  switch :with_page, \"This flag is used with gen.action.browser Only\"\n\n  def call\n    render_action_template(output, inherit_from: \"ApiAction\")\n    if with_page?\n      output.puts \"No page generated for ApiActions\".colorize.red\n    end\n  end\n\n  private def action_name\n    name = previous_def\n    if name.downcase.starts_with?(\"api\")\n      name\n    else\n      \"Api::#{name}\"\n    end\n  end\nend\n"
  },
  {
    "path": "tasks/gen/action/browser.cr",
    "content": "require \"lucky_task\"\nrequire \"./action_generator\"\nrequire \"../page\"\n\nclass Gen::Action::Browser < LuckyTask::Task\n  include Gen::ActionGenerator\n\n  summary \"Generate a new browser action\"\n  help_message <<-TEXT\n  #{task_summary}\n\n  Optionally, you can pass the --with-page flag to generate\n  a page for the Action.\n\n  Example:\n\n    lucky gen.action.browser Users::Index --with-page\n  TEXT\n\n  positional_arg :action_name, \"The name of the action\"\n  switch :with_page, \"Generate a Page matching this Action\"\n\n  def call\n    render_action_template(output, inherit_from: \"BrowserAction\")\n    if with_page?\n      page_task = Gen::Page.new\n      page_task.output = output\n      page_task.print_help_or_call(args: [\"#{action_name}Page\"])\n    end\n  end\nend\n"
  },
  {
    "path": "tasks/gen/component.cr",
    "content": "require \"ecr\"\nrequire \"lucky_task\"\nrequire \"lucky_template\"\nrequire \"colorize\"\n\nclass Lucky::ComponentTemplate\n  @filename : String\n  @class : String\n  @output_path : Path\n\n  def initialize(@filename, @class, @output_path)\n  end\n\n  def render(path : Path)\n    LuckyTemplate.write!(path, template_folder)\n  end\n\n  def template_folder\n    LuckyTemplate.create_folder do |root_dir|\n      root_dir.add_file(Path[\"#{@output_path}/#{@filename}.cr\"]) do |io|\n        ECR.embed(\"#{__DIR__}/templates/component/component.cr.ecr\", io)\n      end\n    end\n  end\nend\n\nclass Gen::Component < LuckyTask::Task\n  summary \"Generate a new HTML component\"\n  help_message <<-TEXT\n  #{task_summary}\n\n  Example:\n\n    lucky gen.component SettingsMenu\n  TEXT\n\n  positional_arg :component_class, \"The name of the component\"\n\n  def call\n    if error\n      output.puts error.colorize(:red)\n    else\n      Lucky::ComponentTemplate.new(component_filename, component_class, output_path).render(Path[\".\"])\n      output.puts success_message\n    end\n  end\n\n  private def error\n    missing_name_error || invalid_format_error\n  end\n\n  private def missing_name_error\n    # Doing this because `component_class` will raise an exception if the value is missing\n    # but the error message would say \"component_class is missing\" which isn't as nice of\n    # an error message. This lets the UI remain the same until this whole deal can be refactored\n    component_class\n    nil\n  rescue\n    \"Component name is required.\"\n  end\n\n  private def invalid_format_error\n    if component_class.camelcase != component_class\n      \"Component name should be camel case. Example: lucky gen.component #{component_class.camelcase}\"\n    end\n  end\n\n  private def component_filename\n    component_class.split(\"::\").last.underscore.downcase\n  end\n\n  private def output_path\n    parts = component_class.split(\"::\")\n    parts.pop\n    Path[\"./src/components/#{parts.map(&.underscore.downcase).join('/')}\"].normalize\n  end\n\n  private def output_path_with_filename\n    File.join(output_path, component_filename + \".cr\")\n  end\n\n  private def success_message\n    \"Done generating #{component_class.colorize.green} in #{output_path_with_filename.colorize.green}\"\n  end\nend\n"
  },
  {
    "path": "tasks/gen/page.cr",
    "content": "require \"ecr\"\nrequire \"lucky_task\"\nrequire \"lucky_template\"\nrequire \"colorize\"\n\nclass Lucky::PageTemplate\n  @page_filename : String\n  @page_class : String\n  @output_path : Path\n\n  def initialize(@page_filename, @page_class, @output_path)\n  end\n\n  def render(path : Path)\n    LuckyTemplate.write!(path, template_folder)\n  end\n\n  def template_folder\n    LuckyTemplate.create_folder do |root_dir|\n      root_dir.add_file(Path[\"#{@output_path}/#{@page_filename}.cr\"]) do |io|\n        ECR.embed(\"#{__DIR__}/templates/page/page.cr.ecr\", io)\n      end\n    end\n  end\nend\n\nclass Gen::Page < LuckyTask::Task\n  summary \"Generate a new HTML page\"\n  help_message <<-TEXT\n  #{task_summary}\n\n  Example:\n\n    lucky gen.page Users::IndexPage\n  TEXT\n\n  positional_arg :page_class, \"The name of the page\"\n\n  def call\n    if error\n      output.puts error.colorize(:red)\n    else\n      Lucky::PageTemplate.new(page_filename, page_class, output_path).render(Path[\".\"])\n      output.puts success_message\n    end\n  end\n\n  private def error\n    missing_name_error || invalid_page_format_error\n  end\n\n  private def missing_name_error\n    if page_class.nil?\n      \"Page name is required.\"\n    end\n  end\n\n  private def invalid_page_format_error\n    if !page_class.includes?(\"::\") || !page_class.ends_with?(\"Page\")\n      \"That's not a valid Page. Example: lucky gen.page Users::IndexPage\"\n    end\n  end\n\n  private def page_filename\n    page_class.split(\"::\").last.underscore.downcase\n  end\n\n  private def output_path\n    page_parts = page_class.split(\"::\")\n    page_parts.pop\n    Path[\"./src/pages/#{page_parts.map(&.underscore.downcase).join('/')}\"].normalize\n  end\n\n  private def output_path_with_filename\n    File.join(output_path, page_filename + \".cr\")\n  end\n\n  private def success_message\n    \"Done generating #{page_class.colorize.green} in #{output_path_with_filename.colorize.green}\"\n  end\nend\n"
  },
  {
    "path": "tasks/gen/secret_key.cr",
    "content": "require \"lucky_task\"\n\nclass Gen::SecretKey < LuckyTask::Task\n  summary \"Generate a new secret key\"\n\n  int32 :number, \"n random bytes used to encode into base64.\", shortcut: \"-n\", default: 32\n\n  def call\n    output.puts Random::Secure.base64(number)\n  end\nend\n"
  },
  {
    "path": "tasks/gen/task.cr",
    "content": "require \"ecr\"\nrequire \"lucky_task\"\nrequire \"lucky_template\"\nrequire \"colorize\"\n\nclass Lucky::TaskTemplate\n  @save_path : String\n\n  def initialize(\n    @task_filename : String,\n    @task_name : String,\n    @summary : String,\n  )\n    @save_path = @task_name.split(\"::\").map(&.underscore.downcase)[0..-2].join('/')\n  end\n\n  def render(path : Path)\n    LuckyTemplate.write!(path, template_folder)\n  end\n\n  def template_folder\n    LuckyTemplate.create_folder do |root_dir|\n      save_path = @save_path.presence.nil? ? \"tasks\" : \"tasks/#{@save_path}\"\n      root_dir.add_folder(Path[save_path]) do |tasks_dir|\n        tasks_dir.add_file(@task_filename) do |io|\n          ECR.embed(\"#{__DIR__}/templates/task/task.cr.ecr\", io)\n        end\n      end\n    end\n  end\nend\n\nclass Gen::Task < LuckyTask::Task\n  summary \"Generate a lucky command line task\"\n  help_message <<-TEXT\n  #{task_summary}\n\n  Example:\n    lucky gen.task email.monthly_update\n\n  See Also: https://luckyframework.org/guides/command-line-tasks/custom-tasks\n  TEXT\n\n  arg :task_summary, \"The -h help text for the task\", optional: true\n  positional_arg :task_name, \"The name of the task to generate\"\n\n  def call\n    errors = error_messages\n    if !errors.empty?\n      errors.each do |err|\n        output.puts err.colorize.red\n      end\n    else\n      Lucky::TaskTemplate\n        .new(task_filename, rendered_task_name, rendered_summary)\n        .render(Path[\".\"])\n\n      output.puts <<-TEXT\n      Generated #{output_path.join(task_filename).colorize.green}\n\n      Run it with:\n\n      lucky #{task_name}\n      TEXT\n    end\n  end\n\n  def error_messages\n    messages = [] of String\n    messages << \"Task name is expected\" if task_name.blank?\n    unless task_name.underscore == task_name\n      messages << \"Task name needs to be formatted with dot notation: namespace.task_name\"\n    end\n    messages\n  end\n\n  private def relative_path\n    Path[task_name.split('.')[0...-1]]\n  end\n\n  private def task_filename\n    task_name.split('.')[-1] + \".cr\"\n  end\n\n  private def rendered_task_name\n    task_name.split('.').map(&.camelcase).join(\"::\")\n  end\n\n  private def rendered_summary\n    task_summary || task_name.gsub(/[._]/, ' ')\n  end\n\n  private def output_path\n    Path[\".\", \"tasks\", relative_path]\n  end\nend\n"
  },
  {
    "path": "tasks/gen/templates/action/action.cr.ecr",
    "content": "class <%= @name %> < <%= @inherit_from %>\n  <%= @route %> do\n    plain_text \"Render something in <%= @name %>\"\n  end\nend\n"
  },
  {
    "path": "tasks/gen/templates/component/component.cr.ecr",
    "content": "class <%= @class %> < BaseComponent\n  def render\n  end\nend\n"
  },
  {
    "path": "tasks/gen/templates/page/page.cr.ecr",
    "content": "class <%= @page_class %> < MainLayout\n  def content\n    h1 \"Modify this page at <%= @output_path %>\"\n  end\nend\n"
  },
  {
    "path": "tasks/gen/templates/task/task.cr.ecr",
    "content": "class <%= @task_name %> < LuckyTask::Task\n  summary \"<%= @summary %>\"\n\n  def call\n    # Execute your task here\n  end\nend\n"
  },
  {
    "path": "tasks/routes.cr",
    "content": "require \"lucky_task\"\nrequire \"colorize\"\nrequire \"shell-table\"\nrequire \"json\"\n\nclass Routes < LuckyTask::Task\n  summary \"Show all the routes for the app\"\n  help_message <<-TEXT\n  #{task_summary}\n\n  Optionally, you can pass the --with-params flag (-p) to print out\n  the available params for each Action.\n\n  example: lucky routes --with-params\n\n  You can also output routes as JSON using the --format flag (-f).\n\n  example: lucky routes --format=json\n\n  Routing documentation:\n\n      https://luckyframework.org/guides/http-and-routing/routing-and-params\n  TEXT\n\n  switch :with_params, \"Include action params with each route\", shortcut: \"-p\"\n  arg :format, \"Output format (table or json)\", shortcut: \"-f\", optional: true\n\n  def call\n    routes = Lucky.router.list_routes\n\n    formatted = case format\n                when \"json\"\n                  build_json_from_routes(routes)\n                else\n                  build_table_from_routes(routes)\n                end\n\n    output.puts formatted\n  end\n\n  private def build_table_from_routes(routes : Array(Tuple(String, String, Lucky::Action.class))) : String\n    rows = [] of Array(String)\n\n    routes.each do |path, method, action|\n      # skip HEAD routes\n      # LuckyRouter creates these routes from any GET routes submitted\n      # This could be an issue if users define their own HEAD routes\n      next if method.upcase == \"HEAD\"\n\n      row = [] of String\n      row << method.upcase\n      row << path.colorize.green.to_s\n      row << action.name\n      rows << row\n\n      if with_params?\n        action.query_param_declarations.each do |param|\n          param_row = [] of String\n          param_row << \" \"\n          param_row << \" #{dim_arrow} #{param}\"\n          param_row << \" \"\n          rows << param_row\n        end\n      end\n    end\n\n    table = ShellTable.new(\n      labels: [\"Verb\", \"URI\", \"Action\"],\n      label_color: :white,\n      rows: rows\n    )\n\n    <<-TEXT\n    #{print_banner_message}\n\n    #{table}\n    TEXT\n  end\n\n  private def build_json_from_routes(routes : Array(Tuple(String, String, Lucky::Action.class))) : String\n    info = [] of RouteInfo\n    routes.each do |path, method, action|\n      next if method.upcase == \"HEAD\"\n\n      params = if with_params?\n                 action.query_param_declarations.map do |param|\n                   # param format is \"name : Type\" - split and trim\n                   parts = param.split(\" : \", 2)\n                   ParamInfo.new(name: parts[0], type: parts[1]? || \"String\")\n                 end\n               else\n                 [] of ParamInfo\n               end\n\n      info << RouteInfo.new(\n        method: method.upcase,\n        path: path,\n        action: action.name,\n        params: params\n      )\n    end\n\n    info.to_pretty_json\n  end\n\n  private record ParamInfo, name : String, type : String do\n    include JSON::Serializable\n  end\n\n  private record RouteInfo, method : String, path : String, action : String, params : Array(ParamInfo) do\n    include JSON::Serializable\n  end\n\n  private def dim_arrow : Colorize::Object(String)\n    \"▸\".colorize.dim\n  end\n\n  private def print_banner_message : Colorize::Object(String)\n    <<-TEXT.colorize.dim\n\n    Routing documentation:\n\n      https://luckyframework.org/guides/http-and-routing/routing-and-params\n    TEXT\n  end\nend\n"
  },
  {
    "path": "tasks/watch.cr",
    "content": "require \"lucky_task\"\nrequire \"colorize\"\nrequire \"yaml\"\nrequire \"http\"\nrequire \"../src/lucky/server_settings\"\nrequire \"../src/lucky/page_helpers/time_helpers\"\n\n# Based on the sentry shard with some modifications to output and build process.\nmodule LuckySentry\n  FILE_TIMESTAMPS = {} of String => String # {file => timestamp}\n\n  # Base Watcher class\n  abstract class Watcher\n    abstract def start : Nil\n    abstract def reload : Nil\n    abstract def running? : Bool\n    abstract def running_at : String?\n  end\n\n  # Watcher using WebSockets to reload browser\n  class WebSocketWatcher < Watcher\n    @captured_sockets = [] of HTTP::WebSocket\n    @server : HTTP::Server\n\n    def initialize\n      handler = HTTP::WebSocketHandler.new do |socket|\n        @captured_sockets << socket\n\n        socket.on_close do\n          @captured_sockets.delete(socket)\n        end\n      end\n      @server = HTTP::Server.new([handler])\n    end\n\n    def start : Nil\n      @server.bind_tcp(Lucky::ServerSettings.host, Lucky::ServerSettings.reload_port)\n      spawn { @server.listen }\n    end\n\n    def reload : Nil\n      @captured_sockets.each do |socket|\n        socket.send(\"data: update\")\n        socket.close\n      end\n    end\n\n    def running? : Bool\n      @server.listening?\n    end\n\n    def running_at : Nil\n    end\n  end\n\n  # Watcher using ServerSentEvents (SSE) to reload browser\n  class ServerSentEventWatcher < Watcher\n    @captured_contexts = [] of HTTP::Server::Context\n    @server : HTTP::Server\n\n    def initialize\n      @server = HTTP::Server.new do |context|\n        context.response.headers.merge!({\n          \"Access-Control-Allow-Origin\" => \"*\",\n          \"Content-Type\"                => \"text/event-stream\",\n          \"Connection\"                  => \"keep-alive\",\n          \"Cache-Control\"               => \"no-cache\",\n        })\n        context.response.status_code = 200\n\n        @captured_contexts << context\n\n        # SSE start\n        loop do\n          break if context.response.closed?\n          sleep 0.1\n        end\n        # SSE stop\n      end\n    end\n\n    def start : Nil\n      @server.bind_tcp(Lucky::ServerSettings.host, Lucky::ServerSettings.reload_port)\n      spawn { @server.listen }\n    end\n\n    def reload : Nil\n      while context = @captured_contexts.shift?\n        context.response.print \"data: update\\n\\n\"\n        context.response.flush\n        context.response.close\n      end\n    end\n\n    def running? : Bool\n      @server.listening?\n    end\n\n    def running_at : Nil\n    end\n  end\n\n  # Watcher using browsersync to reload browser\n  class BrowsersyncWatcher < Watcher\n    @options : String\n    @is_running : Bool = false\n\n    def initialize\n      host_url = \"http://#{Lucky::ServerSettings.host}:#{Lucky::ServerSettings.port}\"\n      @options = [\"-c\", \"bs-config.js\", \"--port\", Lucky::ServerSettings.reload_port, \"-p\", host_url].join(\" \")\n    end\n\n    def start : Nil\n      spawn do\n        Process.run \\\n          \"RUNNING_IN_BROWSERSYNC=true yarn run browser-sync start #{@options}\",\n          output: STDOUT,\n          error: STDERR,\n          shell: true\n      end\n      @is_running = true\n    end\n\n    def reload : Nil\n      if running?\n        Process.run \\\n          \"yarn run browser-sync reload --port #{Lucky::ServerSettings.reload_port}\",\n          output: STDOUT,\n          error: STDERR,\n          shell: true\n      end\n    end\n\n    def running? : Bool\n      @is_running\n    end\n\n    def running_at : String\n      \"http://#{Lucky::ServerSettings.host}:#{Lucky::ServerSettings.reload_port}\"\n    end\n  end\n\n  class ProcessRunner\n    include LuckyTask::TextHelpers\n    include Lucky::TimeHelpers\n\n    getter build_processes = [] of Process\n    getter app_processes = [] of Process\n    getter! watcher : Watcher\n    property successful_compilations\n    property app_built\n    property? reload_browser\n\n    @app_built : Bool = false\n    @successful_compilations : Int32 = 0\n    @build_started : Time::Span\n\n    def initialize(@build_commands : Array(String), @run_commands : Array(String), @files : Array(String), @reload_browser : Bool, @watcher : Watcher?)\n      @build_started = Time.monotonic\n    end\n\n    private def build_app_processes_and_start\n      @build_processes.clear\n      @build_commands.each do |command|\n        @build_processes << Process.new(command, shell: true, output: STDOUT, error: STDERR)\n      end\n      build_processes_copy = @build_processes.dup\n      spawn do\n        build_statuses = build_processes_copy.map(&.wait)\n        success = build_statuses.all?(&.success?)\n        if build_processes == build_processes_copy # if this build was not aborted in #stop_all_processes\n          start_all_processes(success)\n        end\n      end\n    end\n\n    private def create_app_processes\n      @app_processes.clear\n      @run_commands.each do |command|\n        @app_processes << Process.new(command, shell: false, output: STDOUT, error: STDERR)\n      end\n\n      @successful_compilations += 1\n      if reload_browser?\n        reload_or_start_watcher\n      end\n      if @successful_compilations == 1\n        spawn do\n          sleep(0.3)\n          print_running_at\n        end\n      end\n    end\n\n    private def reload_or_start_watcher\n      if @successful_compilations == 1\n        start_watcher\n      else\n        reload_watcher\n      end\n    end\n\n    private def start_watcher\n      watcher.start unless watcher.running?\n    end\n\n    private def print_running_at\n      STDOUT.puts \"\"\n      STDOUT.puts running_at_background\n      STDOUT.puts running_at_message.colorize.on_cyan.black\n      STDOUT.puts running_at_background\n      STDOUT.puts \"\"\n    end\n\n    private def running_at_background\n      extra_space_for_emoji = 1\n      (\" \" * (running_at_message.size + extra_space_for_emoji)).colorize.on_cyan\n    end\n\n    private def running_at_message\n      \"   🎉 App running at #{running_at}   \"\n    end\n\n    private def running_at\n      if reload_browser?\n        watcher.running_at || original_url\n      else\n        original_url\n      end\n    end\n\n    private def original_url\n      \"http://#{Lucky::ServerSettings.host}:#{Lucky::ServerSettings.port}\"\n    end\n\n    private def reload_watcher\n      watcher.reload\n    end\n\n    private def get_timestamp(file : String)\n      File.info(file).modification_time.to_s(\"%Y%m%d%H%M%S\")\n    end\n\n    def restart_app\n      build_in_progress = @build_processes.any?(&.exists?)\n      stop_all_processes\n      puts build_in_progress ? \"Recompiling...\" : \"\\nCompiling...\"\n      @build_started = Time.monotonic\n      build_app_processes_and_start\n    end\n\n    private def stop_all_processes\n      @build_processes.each do |process|\n        unless process.terminated?\n          # kill child process, because we started build process with shell option\n          Process.run(\"pkill -P #{process.pid}\", shell: true)\n          process.terminate\n        end\n      end\n      @app_processes.each do |process|\n        process.terminate unless process.terminated?\n      end\n    end\n\n    private def start_all_processes(build_success : Bool)\n      if build_success\n        self.app_built = true\n        create_app_processes\n\n        elapsed_time = Time.monotonic - @build_started\n        message = String.build do |io|\n          io << \" DONE \".colorize.on_cyan.black\n          io << \" Compiled successfully in \".colorize.cyan\n          io << distance_of_time_in_words(elapsed_time).colorize.cyan\n        end\n\n        puts message\n      elsif !app_built\n        print_error_message\n      end\n    end\n\n    private def print_error_message\n      if successful_compilations.zero?\n        puts <<-ERROR\n\n        #{\"---\".colorize.dim}\n\n        Feeling stuck? Try this...\n\n          ▸  Run setup: #{\"script/setup\".colorize.bold}\n          ▸  Reinstall shards: #{\"rm -rf lib bin && shards install\".colorize.bold}\n          ▸  Ask for help: #{\"https://luckyframework.org/chat\".colorize.bold}\n        ERROR\n      end\n    end\n\n    def scan_files\n      file_changed = false\n      app_processes = @app_processes\n      files = @files\n      Dir.glob(files) do |file|\n        timestamp = get_timestamp(file)\n        if FILE_TIMESTAMPS[file]? && FILE_TIMESTAMPS[file] != timestamp\n          FILE_TIMESTAMPS[file] = timestamp\n          file_changed = true\n        elsif FILE_TIMESTAMPS[file]?.nil?\n          FILE_TIMESTAMPS[file] = timestamp\n          file_changed = true if app_processes.none?(&.terminated?)\n        end\n      end\n\n      restart_app if file_changed # (file_changed || app_processes.empty?)\n    end\n  end\nend\n\nclass Watch < LuckyTask::Task\n  summary \"Start and recompile project when files change\"\n  switch :error_trace, \"Show full error trace\"\n\n  switch :reload_browser, \"Reloads browser on changes\",\n    shortcut: \"-r\"\n\n  arg :watcher, \"Watcher type for reloading browser\",\n    shortcut: \"-w\",\n    optional: true,\n    format: /(sse|browsersync)/\n\n  def call\n    build_commands = %w(crystal build ./src/start_server.cr -o bin/start_server)\n    files = [\"./src/**/*.cr\", \"./src/**/*.ecr\", \"./config/**/*.cr\", \"./shard.lock\"]\n    watcher_class = nil\n\n    if reload_browser?\n      case watcher\n      when \"sse\"\n        build_commands << \"-Dlivereloadsse\"\n        watcher_class = LuckySentry::ServerSentEventWatcher.new\n        files.concat(Lucky::ServerSettings.reload_watch_paths)\n      when \"browsersync\"\n        watcher_class = LuckySentry::BrowsersyncWatcher.new\n      else\n        build_commands << \"-Dlivereloadws\"\n        watcher_class = LuckySentry::WebSocketWatcher.new\n        files.concat(Lucky::ServerSettings.reload_watch_paths)\n      end\n    end\n\n    build_commands << \"--error-trace\" if error_trace?\n    build_commands = [build_commands.join(\" \")]\n    run_commands = %w(./bin/start_server)\n\n    process_runner = LuckySentry::ProcessRunner.new(\n      files: files,\n      build_commands: build_commands,\n      run_commands: run_commands,\n      reload_browser: reload_browser?,\n      watcher: watcher_class\n    )\n\n    puts \"Beginning to watch your project\"\n\n    loop do\n      process_runner.scan_files\n      sleep 0.1\n    end\n  end\nend\n"
  },
  {
    "path": "tasks.cr",
    "content": "require \"lucky_task\"\nrequire \"./src/lucky\"\nrequire \"./tasks/**\"\n\nLuckyTask::Runner.run\n"
  }
]