brut 0.5.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.gitignore +4 -0
- data/CHANGELOG.md +7 -0
- data/Dockerfile.dx +19 -0
- data/Gemfile.lock +1 -1
- data/README.md +19 -0
- data/assets/YouTubeThumb.pxd +0 -0
- data/bin/build +86 -0
- data/bin/ci +36 -0
- data/bin/docs +39 -9
- data/bin/publish +61 -0
- data/bin/setup +6 -0
- data/brut-css/bin/build +19 -0
- data/brut-css/bin/ci +19 -0
- data/brut-css/bin/docs +19 -0
- data/brut-css/bin/publish +21 -0
- data/brut-css/bin/setup +1 -0
- data/brut-css/package-lock.json +2 -2
- data/brut-css/package.json +1 -1
- data/brut-js/bin/build +15 -6
- data/brut-js/bin/docs +25 -0
- data/brut-js/bin/publish +21 -0
- data/brut-js/bin/setup +1 -0
- data/brut-js/dx +1 -0
- data/brut-js/package-lock.json +2 -2
- data/brut-js/package.json +1 -1
- data/brut.gemspec +2 -2
- data/brutrb.com/bin/setup +1 -0
- data/brutrb.com/getting-started.md +3 -0
- data/brutrb.com/overview.md +6 -0
- data/brutrb.com/tutorial.md +7 -3
- data/docs/404.html +2 -2
- data/docs/adrs.html +3 -3
- data/docs/ai.html +3 -3
- data/docs/assets/{app.D6BuVHo9.js → app.DyQLb4Ot.js} +1 -1
- data/docs/assets/chunks/@localSearchIndexroot.CmtZyrFA.js +1 -0
- data/docs/assets/chunks/{VPLocalSearchBox.BpvHMbx6.js → VPLocalSearchBox.T1iA-eJx.js} +1 -1
- data/docs/assets/chunks/{theme.wlAOvi2f.js → theme.ChwsbWjK.js} +2 -2
- data/docs/assets/{components.md.iLiv2E9X.js → components.md.DHh-NwKs.js} +3 -3
- data/docs/assets/{configuration.md.DmuAdsli.js → configuration.md.D8Wz3oJU.js} +1 -1
- data/docs/assets/{forms.md.D8aa_qI-.js → forms.md.BRE85eju.js} +1 -1
- data/docs/assets/{getting-started.md.DLplsDUd.js → getting-started.md.2ioiTe-B.js} +6 -3
- data/docs/assets/{getting-started.md.DLplsDUd.lean.js → getting-started.md.2ioiTe-B.lean.js} +1 -1
- data/docs/assets/overview.md.DlKiRRG_.js +1 -0
- data/docs/assets/overview.md.DlKiRRG_.lean.js +1 -0
- data/docs/assets/tutorial.md.BIb7XT6j.js +1 -0
- data/docs/assets/tutorial.md.BIb7XT6j.lean.js +1 -0
- data/docs/assets.html +3 -3
- data/docs/brut-js.html +3 -3
- data/docs/business-logic.html +3 -3
- data/docs/cli.html +3 -3
- data/docs/components.html +7 -7
- data/docs/configuration.html +5 -5
- data/docs/css.html +3 -3
- data/docs/custom-element-tests.html +3 -3
- data/docs/database-access.html +3 -3
- data/docs/database-schema.html +3 -3
- data/docs/deployment.html +3 -3
- data/docs/dev-environment.html +3 -3
- data/docs/dir-structure.html +3 -3
- data/docs/doc-conventions.html +3 -3
- data/docs/end-to-end-tests.html +3 -3
- data/docs/features.html +3 -3
- data/docs/flash-and-session.html +3 -3
- data/docs/form-constraints.html +3 -3
- data/docs/forms.html +5 -5
- data/docs/getting-started.html +9 -6
- data/docs/handlers.html +3 -3
- data/docs/hashmap.json +1 -1
- data/docs/hooks.html +3 -3
- data/docs/i18n.html +3 -3
- data/docs/index.html +3 -3
- data/docs/instrumentation.html +3 -3
- data/docs/javascript.html +3 -3
- data/docs/jobs.html +3 -3
- data/docs/keyword-injection.html +3 -3
- data/docs/layouts.html +3 -3
- data/docs/lsp.html +3 -3
- data/docs/markdown-examples.html +3 -3
- data/docs/middleware.html +3 -3
- data/docs/overview.html +5 -5
- data/docs/pages.html +3 -3
- data/docs/recipes/alternate-layouts.html +3 -3
- data/docs/recipes/authentication.html +3 -3
- data/docs/recipes/blank-layouts.html +3 -3
- data/docs/recipes/custom-flash.html +3 -3
- data/docs/recipes/indexed-forms.html +3 -3
- data/docs/recipes/migrations.html +3 -3
- data/docs/recipes/text-field-component.html +3 -3
- data/docs/roadmap.html +3 -3
- data/docs/routes.html +3 -3
- data/docs/security.html +3 -3
- data/docs/seed-data.html +3 -3
- data/docs/space-time-continuum.html +3 -3
- data/docs/tutorial.html +5 -5
- data/docs/unit-tests.html +3 -3
- data/docs/why.html +3 -3
- data/lib/brut/framework/mcp.rb +1 -1
- data/lib/brut/front_end/components/form_tag.rb +2 -2
- data/lib/brut/version.rb +1 -1
- data/mkbrut/.gitignore +16 -0
- data/mkbrut/CODE_OF_CONDUCT.txt +100 -0
- data/mkbrut/Gemfile +3 -0
- data/mkbrut/Gemfile.lock +19 -0
- data/mkbrut/LICENSE.txt +370 -0
- data/mkbrut/README.md +145 -0
- data/mkbrut/Rakefile +2 -0
- data/mkbrut/bin/build +36 -0
- data/mkbrut/bin/ci +19 -0
- data/mkbrut/bin/docs +19 -0
- data/mkbrut/bin/publish +129 -0
- data/mkbrut/bin/rake +16 -0
- data/mkbrut/bin/setup +30 -0
- data/mkbrut/brut-welcome.png +0 -0
- data/mkbrut/deploy/.dockerignore +2 -0
- data/mkbrut/deploy/Dockerfile +25 -0
- data/mkbrut/exe/mkbrut +5 -0
- data/mkbrut/lib/mkbrut/app.rb +79 -0
- data/mkbrut/lib/mkbrut/app_id.rb +8 -0
- data/mkbrut/lib/mkbrut/app_name.rb +29 -0
- data/mkbrut/lib/mkbrut/app_options.rb +36 -0
- data/mkbrut/lib/mkbrut/base.rb +57 -0
- data/mkbrut/lib/mkbrut/cli.rb +107 -0
- data/mkbrut/lib/mkbrut/erb_binding_delegate.rb +20 -0
- data/mkbrut/lib/mkbrut/internet_identifier.rb +32 -0
- data/mkbrut/lib/mkbrut/invalid_identifier.rb +4 -0
- data/mkbrut/lib/mkbrut/ops/add_css_import.rb +42 -0
- data/mkbrut/lib/mkbrut/ops/add_i18n_message.rb +74 -0
- data/mkbrut/lib/mkbrut/ops/add_method.rb +48 -0
- data/mkbrut/lib/mkbrut/ops/append_to_file.rb +20 -0
- data/mkbrut/lib/mkbrut/ops/base_op.rb +21 -0
- data/mkbrut/lib/mkbrut/ops/copy_file.rb +12 -0
- data/mkbrut/lib/mkbrut/ops/insert_code_in_method.rb +58 -0
- data/mkbrut/lib/mkbrut/ops/insert_route.rb +52 -0
- data/mkbrut/lib/mkbrut/ops/mkdir.rb +13 -0
- data/mkbrut/lib/mkbrut/ops/prism_parsing_op.rb +70 -0
- data/mkbrut/lib/mkbrut/ops/render_template.rb +26 -0
- data/mkbrut/lib/mkbrut/ops/skip_file.rb +10 -0
- data/mkbrut/lib/mkbrut/ops.rb +16 -0
- data/mkbrut/lib/mkbrut/organization.rb +5 -0
- data/mkbrut/lib/mkbrut/prefix.rb +26 -0
- data/mkbrut/lib/mkbrut/prefixed_io.rb +16 -0
- data/mkbrut/lib/mkbrut/segments/bare_bones.rb +185 -0
- data/mkbrut/lib/mkbrut/segments/demo.rb +121 -0
- data/mkbrut/lib/mkbrut/segments/heroku.rb +30 -0
- data/mkbrut/lib/mkbrut/segments/sidekiq.rb +3 -0
- data/mkbrut/lib/mkbrut/segments.rb +8 -0
- data/mkbrut/lib/mkbrut/version.rb +3 -0
- data/mkbrut/lib/mkbrut/versions.rb +13 -0
- data/mkbrut/lib/mkbrut.rb +18 -0
- data/mkbrut/mkbrut.gemspec +32 -0
- data/mkbrut/templates/Base/.dockerignore +25 -0
- data/mkbrut/templates/Base/.env.development.erb +60 -0
- data/mkbrut/templates/Base/.env.test.erb +8 -0
- data/mkbrut/templates/Base/.gitignore +31 -0
- data/mkbrut/templates/Base/.projections.json +59 -0
- data/mkbrut/templates/Base/Dockerfile.dx +205 -0
- data/mkbrut/templates/Base/Gemfile.erb +53 -0
- data/mkbrut/templates/Base/Procfile.development +5 -0
- data/mkbrut/templates/Base/Procfile.test +1 -0
- data/mkbrut/templates/Base/README.md +4 -0
- data/mkbrut/templates/Base/README.md.erb +40 -0
- data/mkbrut/templates/Base/app/bootstrap.rb +61 -0
- data/mkbrut/templates/Base/app/config/i18n/en/1_defaults.rb +128 -0
- data/mkbrut/templates/Base/app/config/i18n/en/2_app.rb +24 -0
- data/mkbrut/templates/Base/app/public/static/manifest.json.erb +33 -0
- data/mkbrut/templates/Base/app/src/app.rb.erb +37 -0
- data/mkbrut/templates/Base/app/src/back_end/data_models/app_data_model.rb +5 -0
- data/mkbrut/templates/Base/app/src/back_end/data_models/db.rb +19 -0
- data/mkbrut/templates/Base/app/src/back_end/data_models/migrations/20240101130000_citext.rb +6 -0
- data/mkbrut/templates/Base/app/src/back_end/data_models/seed/seed_data.rb +9 -0
- data/mkbrut/templates/Base/app/src/front_end/components/app_component.rb +8 -0
- data/mkbrut/templates/Base/app/src/front_end/components/custom_element_registration.rb.erb +7 -0
- data/mkbrut/templates/Base/app/src/front_end/css/index.css +2 -0
- data/mkbrut/templates/Base/app/src/front_end/css/svgs.css +12 -0
- data/mkbrut/templates/Base/app/src/front_end/forms/app_form.rb +4 -0
- data/mkbrut/templates/Base/app/src/front_end/handlers/app_handler.rb +4 -0
- data/mkbrut/templates/Base/app/src/front_end/images/LogoPylon.png +0 -0
- data/mkbrut/templates/Base/app/src/front_end/images/LogoTransit.png +0 -0
- data/mkbrut/templates/Base/app/src/front_end/images/apple-touch-icon-120x120.png +0 -0
- data/mkbrut/templates/Base/app/src/front_end/images/apple-touch-icon-152x152.png +0 -0
- data/mkbrut/templates/Base/app/src/front_end/images/apple-touch-icon-167x167.png +0 -0
- data/mkbrut/templates/Base/app/src/front_end/images/apple-touch-icon-180x180.png +0 -0
- data/mkbrut/templates/Base/app/src/front_end/images/favicon.ico +0 -0
- data/mkbrut/templates/Base/app/src/front_end/images/icon.png +0 -0
- data/mkbrut/templates/Base/app/src/front_end/images/mkicons.sh +6 -0
- data/mkbrut/templates/Base/app/src/front_end/js/index.js +6 -0
- data/mkbrut/templates/Base/app/src/front_end/layouts/default_layout.rb.erb +73 -0
- data/mkbrut/templates/Base/app/src/front_end/pages/app_page.rb +11 -0
- data/mkbrut/templates/Base/app/src/front_end/pages/home_page.rb +62 -0
- data/mkbrut/templates/Base/app/src/front_end/support/app_session.rb +6 -0
- data/mkbrut/templates/Base/app/src/front_end/svgs/README.md +5 -0
- data/mkbrut/templates/Base/app/src/front_end/svgs/comment-button.svg +59 -0
- data/mkbrut/templates/Base/bin/README.md.erb +5 -0
- data/mkbrut/templates/Base/bin/build-assets +7 -0
- data/mkbrut/templates/Base/bin/ci +39 -0
- data/mkbrut/templates/Base/bin/console +31 -0
- data/mkbrut/templates/Base/bin/db +9 -0
- data/mkbrut/templates/Base/bin/dbconsole +51 -0
- data/mkbrut/templates/Base/bin/dev +25 -0
- data/mkbrut/templates/Base/bin/release +26 -0
- data/mkbrut/templates/Base/bin/run +86 -0
- data/mkbrut/templates/Base/bin/scaffold +9 -0
- data/mkbrut/templates/Base/bin/setup +256 -0
- data/mkbrut/templates/Base/bin/startup-message +65 -0
- data/mkbrut/templates/Base/bin/test +9 -0
- data/mkbrut/templates/Base/bin/test-server +29 -0
- data/mkbrut/templates/Base/bin/watch-and-build-assets +37 -0
- data/mkbrut/templates/Base/config.ru +16 -0
- data/mkbrut/templates/Base/docker-compose.dx.yml +92 -0
- data/mkbrut/templates/Base/dx/README.md +28 -0
- data/mkbrut/templates/Base/dx/bash_customizations +12 -0
- data/mkbrut/templates/Base/dx/bash_customizations.local +8 -0
- data/mkbrut/templates/Base/dx/build +107 -0
- data/mkbrut/templates/Base/dx/docker-compose.env.erb +25 -0
- data/mkbrut/templates/Base/dx/dx.sh.lib +137 -0
- data/mkbrut/templates/Base/dx/exec +68 -0
- data/mkbrut/templates/Base/dx/prune +19 -0
- data/mkbrut/templates/Base/dx/show-help-in-app-container-then-wait.sh +38 -0
- data/mkbrut/templates/Base/dx/start +30 -0
- data/mkbrut/templates/Base/dx/stop +23 -0
- data/mkbrut/templates/Base/package.json.erb +37 -0
- data/mkbrut/templates/Base/puma.config.rb +53 -0
- data/mkbrut/templates/Base/specs/e2e/home_page.spec.rb.erb +23 -0
- data/mkbrut/templates/Base/specs/front_end/js/SpecHelper.js +24 -0
- data/mkbrut/templates/Base/specs/front_end/pages/home_page.spec.rb +22 -0
- data/mkbrut/templates/Base/specs/lint_factories.spec.rb +7 -0
- data/mkbrut/templates/Base/specs/spec_helper.rb +78 -0
- data/mkbrut/templates/Base/specs/support.rb +2 -0
- data/mkbrut/templates/segments/BareBones/app/src/front_end/handlers/trigger_exception_handler.rb +24 -0
- data/mkbrut/templates/segments/BareBones/app/src/front_end/js/Example.js.erb +49 -0
- data/mkbrut/templates/segments/BareBones/specs/front_end/handlers/trigger_exception_handler.spec.rb +41 -0
- data/mkbrut/templates/segments/BareBones/specs/front_end/js/Example.spec.js.erb +38 -0
- data/mkbrut/templates/segments/Demo/app/src/back_end/data_models/db/guestbook_message.rb +3 -0
- data/mkbrut/templates/segments/Demo/app/src/back_end/data_models/migrations/20250628194124_guestbook.rb +14 -0
- data/mkbrut/templates/segments/Demo/app/src/front_end/components/flash_component.rb +36 -0
- data/mkbrut/templates/segments/Demo/app/src/front_end/css/constraint-violations.css +18 -0
- data/mkbrut/templates/segments/Demo/app/src/front_end/css/fonts.css +19 -0
- data/mkbrut/templates/segments/Demo/app/src/front_end/fonts/monaspace-xenon.ttf +0 -0
- data/mkbrut/templates/segments/Demo/app/src/front_end/forms/guestbook_message_form.rb +4 -0
- data/mkbrut/templates/segments/Demo/app/src/front_end/handlers/guestbook_message_handler.rb +64 -0
- data/mkbrut/templates/segments/Demo/app/src/front_end/pages/guestbook_page/message_component.rb +41 -0
- data/mkbrut/templates/segments/Demo/app/src/front_end/pages/guestbook_page.rb +43 -0
- data/mkbrut/templates/segments/Demo/app/src/front_end/pages/new_guestbook_message_page.rb +64 -0
- data/mkbrut/templates/segments/Demo/specs/back_end/data_models/db/guestbook_message.spec.rb +5 -0
- data/mkbrut/templates/segments/Demo/specs/e2e/guest_message.spec.rb +54 -0
- data/mkbrut/templates/segments/Demo/specs/factories/db/guestbook_message.factory.rb +7 -0
- data/mkbrut/templates/segments/Demo/specs/front_end/components/flash_component.spec.rb +5 -0
- data/mkbrut/templates/segments/Demo/specs/front_end/handlers/guestbook_message_handler.spec.rb +122 -0
- data/mkbrut/templates/segments/Demo/specs/front_end/pages/guestbook_page/message_component.spec.rb +5 -0
- data/mkbrut/templates/segments/Demo/specs/front_end/pages/guestbook_page.spec.rb +52 -0
- data/mkbrut/templates/segments/Demo/specs/front_end/pages/new_guestbook_message_page.spec.rb +5 -0
- data/mkbrut/templates/segments/Heroku/bin/deploy +11 -0
- data/mkbrut/templates/segments/Heroku/deploy/Dockerfile +125 -0
- data/mkbrut/templates/segments/Heroku/deploy/docker-entrypoint +15 -0
- data/mkbrut/templates/segments/Heroku/deploy/heroku_config.rb +26 -0
- metadata +185 -21
- data/docs/assets/chunks/@localSearchIndexroot.COP2Bcmp.js +0 -1
- data/docs/assets/overview.md.iMnwLO4x.js +0 -1
- data/docs/assets/overview.md.iMnwLO4x.lean.js +0 -1
- data/docs/assets/tutorial.md.BYXj4cOu.js +0 -1
- data/docs/assets/tutorial.md.BYXj4cOu.lean.js +0 -1
- /data/docs/assets/{components.md.iLiv2E9X.lean.js → components.md.DHh-NwKs.lean.js} +0 -0
- /data/docs/assets/{configuration.md.DmuAdsli.lean.js → configuration.md.D8Wz3oJU.lean.js} +0 -0
- /data/docs/assets/{forms.md.D8aa_qI-.lean.js → forms.md.BRE85eju.lean.js} +0 -0
@@ -0,0 +1,38 @@
|
|
1
|
+
#!/bin/bash
|
2
|
+
|
3
|
+
set -e
|
4
|
+
|
5
|
+
# Ideally, the message below is shown after everything starts up. We can't
|
6
|
+
# achieve this using healthchecks because the interval for a healtcheck is
|
7
|
+
# also an initial delay, and we don't really want to do healthchecks on
|
8
|
+
# our DB or Redis every 2 seconds. So, we sleep just a bit to let
|
9
|
+
# the other containers start up and vomit out their output first.
|
10
|
+
sleep 2
|
11
|
+
# Output some helpful messaging when invoking `dx/start` (which itself is
|
12
|
+
# a convenience script for `docker compose up`.
|
13
|
+
#
|
14
|
+
# Adding this to work around the mild inconvenience of the `app` container's
|
15
|
+
# entrypoint generating no output.
|
16
|
+
#
|
17
|
+
cat <<-'PROMPT'
|
18
|
+
|
19
|
+
|
20
|
+
|
21
|
+
🎉 Dev Environment Initialized! 🎉
|
22
|
+
|
23
|
+
ℹ️ To use this environment, open a new terminal and run
|
24
|
+
|
25
|
+
dx/exec bash
|
26
|
+
|
27
|
+
🕹 Use `ctrl-c` to exit.
|
28
|
+
|
29
|
+
|
30
|
+
|
31
|
+
PROMPT
|
32
|
+
|
33
|
+
# Using `sleep infinity` instead of `tail -f /dev/null`. This may be a
|
34
|
+
# performance improvement based on the conversation on a semi-related
|
35
|
+
# StackOverflow page.
|
36
|
+
#
|
37
|
+
# @see https://stackoverflow.com/a/41655546
|
38
|
+
sleep infinity
|
@@ -0,0 +1,30 @@
|
|
1
|
+
#!/bin/bash
|
2
|
+
|
3
|
+
set -e
|
4
|
+
|
5
|
+
SCRIPT_DIR=$( cd -- "$( dirname -- "${0}" )" > /dev/null 2>&1 && pwd )
|
6
|
+
|
7
|
+
. "${SCRIPT_DIR}/dx.sh.lib"
|
8
|
+
require_command "docker"
|
9
|
+
load_docker_compose_env
|
10
|
+
|
11
|
+
usage_on_help "Starts all services, including a container in which to run your app" "" "" "" "${@}"
|
12
|
+
|
13
|
+
log "🚀" "Starting docker-compose.dx.yml"
|
14
|
+
|
15
|
+
BUILD=--build
|
16
|
+
if [ "${1}" == "--no-build" ]; then
|
17
|
+
BUILD=
|
18
|
+
fi
|
19
|
+
|
20
|
+
docker \
|
21
|
+
compose \
|
22
|
+
--file docker-compose.dx.yml \
|
23
|
+
--project-name "${PROJECT_NAME}" \
|
24
|
+
--env-file "${ENV_FILE}" \
|
25
|
+
up \
|
26
|
+
"${BUILD}" \
|
27
|
+
--timestamps \
|
28
|
+
--force-recreate
|
29
|
+
|
30
|
+
# vim: ft=bash
|
@@ -0,0 +1,23 @@
|
|
1
|
+
#!/bin/bash
|
2
|
+
|
3
|
+
set -e
|
4
|
+
|
5
|
+
SCRIPT_DIR=$( cd -- "$( dirname -- "${0}" )" > /dev/null 2>&1 && pwd )
|
6
|
+
|
7
|
+
. "${SCRIPT_DIR}/dx.sh.lib"
|
8
|
+
require_command "docker"
|
9
|
+
load_docker_compose_env
|
10
|
+
|
11
|
+
usage_on_help "Stops all services, the container in which to run your app and removes any volumes" "" "" "" "${@}"
|
12
|
+
|
13
|
+
log "🚀" "Stopping docker-compose.dx.yml"
|
14
|
+
|
15
|
+
docker \
|
16
|
+
compose \
|
17
|
+
--file docker-compose.dx.yml \
|
18
|
+
--project-name "${PROJECT_NAME}" \
|
19
|
+
--env-file "${ENV_FILE}" \
|
20
|
+
down \
|
21
|
+
--volumes
|
22
|
+
|
23
|
+
# vim: ft=bash
|
@@ -0,0 +1,37 @@
|
|
1
|
+
{
|
2
|
+
"name": "<%= app_name %>",
|
3
|
+
"type": "module",
|
4
|
+
"license": "UNLICENSED",
|
5
|
+
"dependencies": {
|
6
|
+
"brut-css": "<%= versions.brut_css_version_specifier %>",
|
7
|
+
"brut-js": "<%= versions.brut_js_version_specifier %>"
|
8
|
+
},
|
9
|
+
"devDependencies": {
|
10
|
+
"chokidar-cli": "^3.0.0",
|
11
|
+
"esbuild": "^0.20.2",
|
12
|
+
"jsdom": "^25.0.1",
|
13
|
+
"mocha": "^10.7.3",
|
14
|
+
"playwright": "1.50.1",
|
15
|
+
"typescript": "^5.8.3",
|
16
|
+
"typescript-language-server": "^4.3.4",
|
17
|
+
"vscode-langservers-extracted": "^4.10.0"
|
18
|
+
},
|
19
|
+
"notes": {
|
20
|
+
"note": "This section provides documentation on what the various information in here is for",
|
21
|
+
"type": "Needs to be 'module' so that the web components unit tests work properly",
|
22
|
+
"dependencies": {
|
23
|
+
"brut-css": "Basic CSS library to get you started. See Brut's docs for how to use or remove",
|
24
|
+
"brut-js": "Autonomous custom elements and utilities. See Brut's docs for how to use and why you should not remove"
|
25
|
+
},
|
26
|
+
"devDependencies": {
|
27
|
+
"chokidar-cli": "Used to watch files and and rebuild stuff on changes",
|
28
|
+
"esbuild": "Bundles JS and CSS - you may need this in dependencies depending on how you deploy",
|
29
|
+
"jsdom": "Used for unit testing custom elements",
|
30
|
+
"mocha": "Used for unit testing custom elements",
|
31
|
+
"playwright": "Used for end-to-end testing. This version is locked and must be the same as the one in the Gemfile for playwright-ruby-client.",
|
32
|
+
"typescript": "Needed for the JS and CSS LSP servers",
|
33
|
+
"typescript-language-server": "Provides a JS LSP server",
|
34
|
+
"vscode-langservers-extracted": "Despite the name, provides a CSS LSP server"
|
35
|
+
}
|
36
|
+
}
|
37
|
+
}
|
@@ -0,0 +1,53 @@
|
|
1
|
+
# Mostly based on Heroku's guidance:
|
2
|
+
# https://devcenter.heroku.com/articles/deploying-rails-applications-with-the-puma-web-server
|
3
|
+
|
4
|
+
# Ensures the logger is setup and availbale as early as possible
|
5
|
+
require "semantic_logger"
|
6
|
+
|
7
|
+
# workers should be set to the # of cores. Heroku
|
8
|
+
# recommends the following, with 2 as a default if you don't have a good
|
9
|
+
# reason to change it.
|
10
|
+
workers Integer(ENV["WEB_CONCURRENCY"] || 2)
|
11
|
+
|
12
|
+
# Heroku recommens 5 as a default, with the ability
|
13
|
+
# to change it as needed.
|
14
|
+
threads_count = Integer(ENV["PUMA_MAX_THREADS"] || 5)
|
15
|
+
|
16
|
+
# This actually sets the min and max # of threads.
|
17
|
+
# Heroku recommends setting them both to the thread_count
|
18
|
+
# above.
|
19
|
+
threads threads_count, threads_count
|
20
|
+
|
21
|
+
# Indicate that the app should be loaded before any
|
22
|
+
# forking or thread creation. Despite the bang,
|
23
|
+
# this doesn't actually do anything - it just sets configuration
|
24
|
+
preload_app!
|
25
|
+
|
26
|
+
# Support IPv6 by binding to host `::` instead of `0.0.0.0`
|
27
|
+
port(ENV["PORT"] || 3000, "::")
|
28
|
+
|
29
|
+
# Turn off keepalive support for better long tails response time with Router 2.0
|
30
|
+
# Remove this line when https://github.com/puma/puma/issues/3487 is closed, and the fix is released
|
31
|
+
if respond_to?(:enable_keep_alives)
|
32
|
+
enable_keep_alives(false)
|
33
|
+
end
|
34
|
+
|
35
|
+
# Commenting this out as a) we don't default DefaultRackup
|
36
|
+
# and b) I don't exactly know what it means or does.
|
37
|
+
# rackup DefaultRackup if defined?(DefaultRackup)
|
38
|
+
|
39
|
+
# Set the environment based on RACK_ENV
|
40
|
+
environment ENV["RACK_ENV"]
|
41
|
+
|
42
|
+
before_fork do
|
43
|
+
# Per http://sequel.jeremyevans.net/rdoc/files/doc/fork_safety_rdoc.html
|
44
|
+
# we must disconnect before forking. Sequel will reconnect as needed.
|
45
|
+
Sequel::DATABASES.each(&:disconnect)
|
46
|
+
end
|
47
|
+
|
48
|
+
on_worker_boot do
|
49
|
+
# Per https://logger.rocketjob.io/forking.html we want to reopen
|
50
|
+
# this to avoid issues
|
51
|
+
SemanticLogger.reopen
|
52
|
+
end
|
53
|
+
|
@@ -0,0 +1,23 @@
|
|
1
|
+
require "spec_helper"
|
2
|
+
|
3
|
+
RSpec.describe "The home page works" do
|
4
|
+
it "shows a welcome message" do
|
5
|
+
# Instead of hard-coding URLs, you can use the
|
6
|
+
# page classes to navigate to their routing.
|
7
|
+
page.goto(HomePage.routing)
|
8
|
+
|
9
|
+
# The Brut::FrontEnd::Components::PageIdentifier component
|
10
|
+
# renders a <meta> tag that Brut's `be_page_for` will look for.
|
11
|
+
# This is a useful check that you ended up on the page you meant to.
|
12
|
+
expect(page).to be_page_for(HomePage)
|
13
|
+
|
14
|
+
# In Playwright, elements are "located" asynchronously. What is returned
|
15
|
+
# here is an object that will look for an <h1> only when an expectation
|
16
|
+
# is made on it.
|
17
|
+
h1 = page.locator("h1")
|
18
|
+
|
19
|
+
# This will actually locate the <h1> (waiting for some time for it to
|
20
|
+
# show up if it's not there), and then assert its text content.
|
21
|
+
expect(h1).to have_text("Welcome to Brut")
|
22
|
+
end
|
23
|
+
end
|
@@ -0,0 +1,24 @@
|
|
1
|
+
import { createTestBasedOnHTML } from "brut-js/testing"
|
2
|
+
|
3
|
+
import path from "node:path"
|
4
|
+
import fs from "node:fs"
|
5
|
+
|
6
|
+
const __dirname = import.meta.dirname
|
7
|
+
|
8
|
+
const appRoot = path.resolve(__dirname,"..","..","..","app")
|
9
|
+
const publicRoot = path.resolve(appRoot,"public")
|
10
|
+
const assetMetadataFilePath = path.resolve(appRoot,"config","asset_metadata.json")
|
11
|
+
const assetMetadata = JSON.parse(fs.readFileSync(assetMetadataFilePath))
|
12
|
+
|
13
|
+
const withHTML = (html) => {
|
14
|
+
return createTestBasedOnHTML({
|
15
|
+
html,
|
16
|
+
assetMetadata,
|
17
|
+
publicRoot
|
18
|
+
})
|
19
|
+
}
|
20
|
+
|
21
|
+
export {
|
22
|
+
withHTML,
|
23
|
+
}
|
24
|
+
|
@@ -0,0 +1,22 @@
|
|
1
|
+
require "spec_helper"
|
2
|
+
|
3
|
+
RSpec.describe HomePage do
|
4
|
+
it "should show the H1" do
|
5
|
+
# Page specs should evaluate the generated HTML, so
|
6
|
+
# the generate_and_parse help will accept an instaniated
|
7
|
+
# page (or components), generate its HTML, then use Nokogiri
|
8
|
+
# to parse it, return the result.
|
9
|
+
result = generate_and_parse(described_class.new)
|
10
|
+
|
11
|
+
# e! is provided by Brut::SpecSupport::EnhancedNode which
|
12
|
+
# delegates everything to the underlying Nokogiri::XML::Node
|
13
|
+
# while adding a few methods. e! requires that exactly
|
14
|
+
# one element match the given CSS selector, then returns it.
|
15
|
+
#
|
16
|
+
# Thus, this expectation will fail if:
|
17
|
+
# * there is no <h1> (and the error message would indcate this)
|
18
|
+
# * there is is more than one <h1> (and the error message would indcate this)
|
19
|
+
# * the only <h1>'s text is not exactly "Welcome to Brut"
|
20
|
+
expect(result.e!("h1").text).to eq("Welcome to Brut")
|
21
|
+
end
|
22
|
+
end
|
@@ -0,0 +1,78 @@
|
|
1
|
+
ENV["RACK_ENV"] = "test"
|
2
|
+
require_relative "../app/bootstrap"
|
3
|
+
Bootstrap.new.bootstrap!
|
4
|
+
|
5
|
+
require "brut/spec_support"
|
6
|
+
|
7
|
+
require "nokogiri"
|
8
|
+
require "playwright"
|
9
|
+
require "playwright/test"
|
10
|
+
require "confidence_check/for_rspec"
|
11
|
+
require "with_clues"
|
12
|
+
|
13
|
+
require_relative "support"
|
14
|
+
|
15
|
+
RSpec.configure do |config|
|
16
|
+
# This configuration has two main parts.
|
17
|
+
#
|
18
|
+
# The first part is here: a call to Brut::SpecSupport::RSpecSetup,
|
19
|
+
# which will set configuration values required for Brut's spec
|
20
|
+
# helpers and other APIs. In generally, you do not want to change
|
21
|
+
# this configuration.
|
22
|
+
rspec_setup = Brut::SpecSupport::RSpecSetup.new(rspec_config: config)
|
23
|
+
rspec_setup.setup!
|
24
|
+
|
25
|
+
# The second part is here and is RSpec configuration that you may want
|
26
|
+
# to change. Changing the configuration below here should not break
|
27
|
+
# any of Brut's internal behavior around tests. The values
|
28
|
+
# and configuration options set are a recommended default, but you
|
29
|
+
# can certainly change it to suit your needs.
|
30
|
+
|
31
|
+
|
32
|
+
# Confidence Check allows you to wrap test expectations
|
33
|
+
# with confidence_check as a way to indicate those expectations are
|
34
|
+
# checking that the test is setup properly and not testing
|
35
|
+
# the behavior of the code under test
|
36
|
+
config.include ConfidenceCheck::ForRSpec
|
37
|
+
|
38
|
+
# With Clues allows you to wrap expectations in
|
39
|
+
# a with_clues block that will provide more details
|
40
|
+
# about the failure and make it easier to diagnose
|
41
|
+
# why a test failed.
|
42
|
+
config.include WithClues::Method
|
43
|
+
config.include FactoryBot::Syntax::Methods
|
44
|
+
|
45
|
+
config.expect_with :rspec do |expectations|
|
46
|
+
expectations.include_chain_clauses_in_custom_matcher_descriptions = true
|
47
|
+
end
|
48
|
+
|
49
|
+
config.mock_with :rspec do |mocks|
|
50
|
+
mocks.verify_partial_doubles = true
|
51
|
+
end
|
52
|
+
|
53
|
+
# Not that you should be using shared contexts, but if you are, please
|
54
|
+
# see https://rubydoc.info/gems/rspec-core/3.13.5/RSpec/Core/Configuration#shared_context_metadata_behavior-instance_method
|
55
|
+
config.shared_context_metadata_behavior = :apply_to_host_groups
|
56
|
+
|
57
|
+
config.filter_run_when_matching :focus
|
58
|
+
|
59
|
+
# Can't find docs on how this path is resolved, so disabling
|
60
|
+
# config.example_status_persistence_file_path = "spec/examples.txt"
|
61
|
+
|
62
|
+
config.disable_monkey_patching!
|
63
|
+
|
64
|
+
config.warnings = ENV.fetch("RSPEC_WARNINGS","false") == "true"
|
65
|
+
|
66
|
+
if config.files_to_run.one?
|
67
|
+
config.default_formatter = "doc"
|
68
|
+
end
|
69
|
+
|
70
|
+
if ENV["RSPEC_PROFILE_EXAMPLES"]
|
71
|
+
config.profile_examples = ENV["RSPEC_PROFILE_EXAMPLES"].to_i
|
72
|
+
end
|
73
|
+
|
74
|
+
config.order = :random
|
75
|
+
|
76
|
+
Kernel.srand config.seed
|
77
|
+
end
|
78
|
+
|
data/mkbrut/templates/segments/BareBones/app/src/front_end/handlers/trigger_exception_handler.rb
ADDED
@@ -0,0 +1,24 @@
|
|
1
|
+
# Allows manually triggering an exception so you can see
|
2
|
+
# the exception handling mechanism execute without waiting
|
3
|
+
# for a real exception to happen.
|
4
|
+
class TriggerExceptionHandler < AppHandler
|
5
|
+
# These arguments are query string parameters that
|
6
|
+
# can be given to the URL connected to this handler.
|
7
|
+
# See https://brutrb.com/keyword-injection.html for more details.
|
8
|
+
def initialize(message: "no message provided", status: nil, key: nil)
|
9
|
+
@message = message
|
10
|
+
@status = status
|
11
|
+
@key = key
|
12
|
+
end
|
13
|
+
|
14
|
+
def handle
|
15
|
+
if @key != Brut.container.trigger_exception_key
|
16
|
+
http_status(404)
|
17
|
+
elsif @status
|
18
|
+
http_status(@status)
|
19
|
+
else
|
20
|
+
raise @message
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
@@ -0,0 +1,49 @@
|
|
1
|
+
import { BaseCustomElement } from "brut-js"
|
2
|
+
|
3
|
+
// This is very similar to a vanilla on-spec
|
4
|
+
// implementation of an autonomous custom element.
|
5
|
+
// The only difference is BaseCustomElement (which
|
6
|
+
// extends HTMLElement) provides a few convenience
|
7
|
+
// methods to make it easier to build your own custom
|
8
|
+
// elements.
|
9
|
+
class Example extends BaseCustomElement {
|
10
|
+
// tagName allows BaseCustomElement's define() method to
|
11
|
+
// define your custom element
|
12
|
+
static tagName = "<%= prefix %>-example"
|
13
|
+
|
14
|
+
static observedAttributes = [
|
15
|
+
"transform",
|
16
|
+
"show-warnings", // recognized by BaseCustomElement to allow
|
17
|
+
// for debugguing in the browser console,
|
18
|
+
// but clean consoles in production.
|
19
|
+
]
|
20
|
+
|
21
|
+
#transform = "upper"
|
22
|
+
|
23
|
+
// Called by BaseCustomElement's attributeChangedCallback.
|
24
|
+
transformChangedCallback({newValue}) {
|
25
|
+
this.#transform = newValue
|
26
|
+
}
|
27
|
+
|
28
|
+
// Called by connectCallback and attributeChangedCallback, this
|
29
|
+
// method should make whatever changes are necessary based on the current
|
30
|
+
// state of the element.
|
31
|
+
update() {
|
32
|
+
const content = this.textContent
|
33
|
+
if (this.#transform == "upper") {
|
34
|
+
this.textContent = content.toLocaleUpperCase()
|
35
|
+
}
|
36
|
+
else if (this.#transform == "lower") {
|
37
|
+
this.textContent = content.toLocaleLowerCase()
|
38
|
+
}
|
39
|
+
else {
|
40
|
+
// Example of debugging. if show-warnings is not set, this message
|
41
|
+
// is not shown in the console. If show-warnings IS set (including
|
42
|
+
// when you override it in production using the browser's devtools)
|
43
|
+
// this message WILL be shown
|
44
|
+
this.logger.info("We only support upper or lower, but got %s",this.#transform)
|
45
|
+
}
|
46
|
+
}
|
47
|
+
}
|
48
|
+
|
49
|
+
export default Example
|
data/mkbrut/templates/segments/BareBones/specs/front_end/handlers/trigger_exception_handler.spec.rb
ADDED
@@ -0,0 +1,41 @@
|
|
1
|
+
require "spec_helper"
|
2
|
+
|
3
|
+
RSpec.describe TriggerExceptionHandler do
|
4
|
+
describe "#handle!" do
|
5
|
+
context "key is correct" do
|
6
|
+
context "http status given" do
|
7
|
+
it "returns that status" do
|
8
|
+
handler = described_class.new(status: 401, key: "test-trigger-exception")
|
9
|
+
result = handler.handle!
|
10
|
+
expect(result).to have_returned_http_status(401)
|
11
|
+
end
|
12
|
+
end
|
13
|
+
context "no http status given" do
|
14
|
+
context "message provided" do
|
15
|
+
it "raises with that message" do
|
16
|
+
handler = described_class.new(message: "test message", key: "test-trigger-exception")
|
17
|
+
expect {
|
18
|
+
handler.handle!
|
19
|
+
}.to raise_error("test message")
|
20
|
+
end
|
21
|
+
end
|
22
|
+
context "no message provided" do
|
23
|
+
it "raises with a default message" do
|
24
|
+
handler = described_class.new(key: "test-trigger-exception")
|
25
|
+
expect {
|
26
|
+
handler.handle!
|
27
|
+
}.to raise_error("no message provided")
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
32
|
+
context "key is incorrect" do
|
33
|
+
it "returns 404" do
|
34
|
+
handler = described_class.new(status: 401, message: "test message")
|
35
|
+
result = handler.handle!
|
36
|
+
expect(result).to have_returned_http_status(404)
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
@@ -0,0 +1,38 @@
|
|
1
|
+
import { withHTML } from "./SpecHelper.js"
|
2
|
+
|
3
|
+
// Autonomouse custom element support is very basic.
|
4
|
+
// In this spec, we use `withHTML` which was defined in SpecHelper.js,
|
5
|
+
// which ultimately uses `createTestBasedOnHTML` from BrutJS.
|
6
|
+
// This will configure JSDom with the HTML you provide, then execute
|
7
|
+
// your test as if that HTML is the document.
|
8
|
+
describe("<<%= prefix %>-example>", () => {
|
9
|
+
|
10
|
+
// The example custom element will transform its contents to
|
11
|
+
// either upper or lower case. In this test, we assert that, by
|
12
|
+
// default, it transforms to upper case. To do that,
|
13
|
+
// create an HTML document you can see below. The element
|
14
|
+
// will have been connected (connectedCallback will have been
|
15
|
+
// called) by the time the test executes. Thus,
|
16
|
+
// document.querySelector(...) will find the element and its
|
17
|
+
// .textContent will have already been transformed.
|
18
|
+
withHTML(`
|
19
|
+
<<%= prefix %>-example>This is some Text</<%= prefix %>-example>
|
20
|
+
`).test("upper case by default", ({document,assert}) => {
|
21
|
+
const element = document.querySelector("<%= prefix %>-example")
|
22
|
+
assert.equal(element.textContent,"THIS IS SOME TEXT")
|
23
|
+
})
|
24
|
+
|
25
|
+
withHTML(`
|
26
|
+
<<%= prefix %>-example transform="lower">This is some Text</<%= prefix %>-example>
|
27
|
+
`).test("lower case when asked", ({document,assert}) => {
|
28
|
+
const element = document.querySelector("<%= prefix %>-example")
|
29
|
+
assert.equal(element.textContent,"this is some text")
|
30
|
+
// Remember that setAttribute is synchronous, so by the time
|
31
|
+
// its done executing, attributeChangedCallback will have been called,
|
32
|
+
// and thus the element's `update()` method will have been called and
|
33
|
+
// its textContent transformed.
|
34
|
+
element.setAttribute("transform","upper")
|
35
|
+
assert.equal(element.textContent,"THIS IS SOME TEXT")
|
36
|
+
})
|
37
|
+
})
|
38
|
+
|
@@ -0,0 +1,14 @@
|
|
1
|
+
Sequel.migration do
|
2
|
+
up do
|
3
|
+
create_table :guestbook_messages,
|
4
|
+
comment: "Messages people have left in the guestbook",
|
5
|
+
external_id: true do
|
6
|
+
|
7
|
+
column :name, :text
|
8
|
+
column :message, :text
|
9
|
+
column :ip_address, :inet
|
10
|
+
|
11
|
+
key [ :ip_address ]
|
12
|
+
end
|
13
|
+
end
|
14
|
+
end
|
@@ -0,0 +1,36 @@
|
|
1
|
+
class FlashComponent < AppComponent
|
2
|
+
def initialize(flash:)
|
3
|
+
@flash = flash
|
4
|
+
end
|
5
|
+
|
6
|
+
CLASSES = [
|
7
|
+
"f-1",
|
8
|
+
"fw-bold",
|
9
|
+
"w-50",
|
10
|
+
"mv-3",
|
11
|
+
"shadow-3",
|
12
|
+
"pa-3",
|
13
|
+
"ba",
|
14
|
+
"br-3",
|
15
|
+
"tc"
|
16
|
+
]
|
17
|
+
COLORS = [
|
18
|
+
"%{color}-300",
|
19
|
+
"bg-%{color}-800",
|
20
|
+
"bc-%{color}-700",
|
21
|
+
]
|
22
|
+
|
23
|
+
def view_template
|
24
|
+
if @flash.notice?
|
25
|
+
div(role: "status",
|
26
|
+
class: CLASSES + COLORS.map { it % { color: "blue" } }) do
|
27
|
+
t(@flash.notice)
|
28
|
+
end
|
29
|
+
elsif @flash.alert?
|
30
|
+
div(role: "alert",
|
31
|
+
class: CLASSES + COLORS.map { it % { color: "orange" } }) do
|
32
|
+
t(@flash.alert)
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
@@ -0,0 +1,18 @@
|
|
1
|
+
/* Hide <brut-cv> tags so that we don't see
|
2
|
+
* client-side errors before the form is submitted */
|
3
|
+
brut-cv {
|
4
|
+
display: none;
|
5
|
+
color: var(--red-400);
|
6
|
+
}
|
7
|
+
|
8
|
+
/* Server-side errors are always visible */
|
9
|
+
brut-cv[server-side] {
|
10
|
+
display: block;
|
11
|
+
}
|
12
|
+
|
13
|
+
/* Only when a form submission is attempted do
|
14
|
+
* we show the <brut-cv>s that are not server-side,
|
15
|
+
* and thus client-side */
|
16
|
+
brut-form[submitted-invalid] brut-cv {
|
17
|
+
display: block;
|
18
|
+
}
|
@@ -0,0 +1,19 @@
|
|
1
|
+
/* Example of font-management. */
|
2
|
+
@font-face {
|
3
|
+
font-family: "Monaspace Xenon";
|
4
|
+
/** url is relative to this file. When bin/build-assets runs,
|
5
|
+
* esbuild will see this and modify it to work with the bundle
|
6
|
+
* that is ultimately produced */
|
7
|
+
src: url("../fonts/monaspace-xenon.ttf") format("truetype");
|
8
|
+
font-display: swap;
|
9
|
+
}
|
10
|
+
|
11
|
+
:root {
|
12
|
+
/* Sets the mono font for BrutCSS */
|
13
|
+
--ff-mono: 'Monaspace Xenon', monospace;
|
14
|
+
}
|
15
|
+
|
16
|
+
code {
|
17
|
+
/* Uses the mono font for <code> elements */
|
18
|
+
font-family: var(--ff-mono);
|
19
|
+
}
|
@@ -0,0 +1,64 @@
|
|
1
|
+
class GuestbookMessageHandler < AppHandler
|
2
|
+
def initialize(form:, flash:, rack_request_ip:, session:)
|
3
|
+
@form = form
|
4
|
+
@flash = flash
|
5
|
+
@rack_request_ip = rack_request_ip
|
6
|
+
@session = session
|
7
|
+
end
|
8
|
+
def handle
|
9
|
+
# If client-side constraint violation checking was skipped,
|
10
|
+
# but there ARE such violations, the form will be able to check
|
11
|
+
# that server-side and return true for #constraint_violations?
|
12
|
+
if @form.valid?
|
13
|
+
save_message
|
14
|
+
end
|
15
|
+
|
16
|
+
if @form.constraint_violations?
|
17
|
+
@flash.alert = :guestbook_not_saved
|
18
|
+
return NewGuestbookMessagePage.new(form: @form,
|
19
|
+
session: @session)
|
20
|
+
else
|
21
|
+
@flash.notice = :guestbook_saved
|
22
|
+
redirect_to(
|
23
|
+
GuestbookPage,
|
24
|
+
anchor: @session.guestbook_message&.external_id
|
25
|
+
)
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
private
|
30
|
+
|
31
|
+
def save_message
|
32
|
+
# While we don't recommend business logic be placed
|
33
|
+
# inside handlers, we're leaving it here to avoid
|
34
|
+
# anchoring you on any particular way of managing
|
35
|
+
# the logic in your app.
|
36
|
+
if @form.message.split(/\s+/).length < 2
|
37
|
+
@form.server_side_constraint_violation(
|
38
|
+
input_name: "message",
|
39
|
+
key: :not_enough_words
|
40
|
+
)
|
41
|
+
else
|
42
|
+
if DB::GuestbookMessage.find(ip_address: @rack_request_ip)
|
43
|
+
@form.server_side_constraint_violation(
|
44
|
+
input_name: "name",
|
45
|
+
key: :already_posted
|
46
|
+
)
|
47
|
+
else
|
48
|
+
begin
|
49
|
+
guestbook_message = DB::GuestbookMessage.create(
|
50
|
+
name: @form.name,
|
51
|
+
message: @form.message,
|
52
|
+
ip_address: @rack_request_ip,
|
53
|
+
)
|
54
|
+
@session.signed_guestbook(guestbook_message)
|
55
|
+
rescue Sequel::UniqueConstraintViolation => ex
|
56
|
+
@form.server_side_constraint_violation(
|
57
|
+
input_name: "name",
|
58
|
+
key: :already_posted
|
59
|
+
)
|
60
|
+
end
|
61
|
+
end
|
62
|
+
end
|
63
|
+
end
|
64
|
+
end
|