├── .amber.yml ├── .ameba.yml ├── .crystal-version ├── .dockerignore ├── .gitignore ├── .travis.yml ├── CODE_OF_CONDUCT.md ├── Dockerfile ├── LICENSE ├── Procfile ├── README.md ├── config ├── application.cr ├── database.yml ├── deploy │ └── .gitkeep ├── initializers │ ├── database.cr │ ├── granite.cr │ ├── hashids.cr │ ├── multiauth.cr │ ├── sidekiq.cr │ └── site.cr ├── routes.cr └── site.yml ├── db ├── data │ ├── announcements.json.gz │ └── announcements.sql.gz ├── migrate.cr ├── migrations │ ├── 20170701230806_create_announcement.sql │ ├── 20170710175120_create_user.sql │ ├── 20170712100522_add_roles_to_users.sql │ ├── 20170919173941_change_timestamp_columns.sql │ └── 20171002221739_add_handle_to_users.sql ├── seeds.cr └── seeds │ ├── announcements.cr │ └── users.cr ├── docker-compose.yml ├── package-lock.json ├── package.json ├── public ├── crossdomain.xml ├── favicon.ico ├── javascripts │ ├── highlight.pack.js │ └── main.js ├── launcher-icon │ ├── launcher-icon-1x.png │ ├── launcher-icon-2x.png │ ├── launcher-icon-3x.png │ ├── launcher-icon-4x.png │ ├── launcher-icon-5x.png │ └── launcher-icon-6x.png ├── manifest.json ├── robots.txt └── stylesheets │ ├── main.css │ └── monokai-sublime.min.css ├── shard.lock ├── shard.yml ├── spec ├── controllers │ ├── announcement_controller_spec.cr │ ├── error_controller_spec.cr │ ├── oauth_controller_spec.cr │ ├── rss_controller_spec.cr │ ├── session_controller_spec.cr │ ├── spec_helper.cr │ ├── static_controller_spec.cr │ └── user_controller_spec.cr ├── helpers │ ├── page_title_helper_spec.cr │ ├── query_helper_spec.cr │ └── time_ago_helper_spec.cr ├── initializers │ ├── database_spec.cr │ └── site_spec.cr ├── models │ ├── announcement_spec.cr │ ├── spec_helper.cr │ └── user_spec.cr ├── spec_helper.cr ├── support │ ├── factories.cr │ └── matchers.cr └── workers │ └── tweet_announcement_spec.cr └── src ├── controllers ├── announcement_controller.cr ├── application_controller.cr ├── error_controller.cr ├── oauth_controller.cr ├── rss_controller.cr ├── session_controller.cr ├── static_controller.cr └── user_controller.cr ├── crystal-ann.cr ├── helpers ├── page_title_helper.cr ├── query_helper.cr └── time_ago_helper.cr ├── mailers └── .gitkeep ├── models ├── announcement.cr └── user.cr ├── sidekiq.cr ├── views ├── announcement │ ├── _entry.slang │ ├── _form.slang │ ├── edit.slang │ ├── index.slang │ ├── new.slang │ └── show.slang ├── layouts │ ├── _footer.slang │ ├── _ga.slang │ ├── _nav.slang │ ├── _profile.slang │ ├── _search.slang │ ├── _social_nav.slang │ ├── _tags.slang │ └── application.slang ├── rss │ └── show.slang ├── shared │ └── _pagination.slang ├── static │ └── about.slang └── user │ └── show.slang └── workers └── tweet_announcement.cr /.amber.yml: -------------------------------------------------------------------------------- 1 | type: app 2 | database: pg 3 | language: slang 4 | -------------------------------------------------------------------------------- /.ameba.yml: -------------------------------------------------------------------------------- 1 | # This configuration file was generated by `ameba --gen-config` 2 | # on 2017-12-23 21:39:50 +0200 using Ameba version 0.3.0. 3 | # The point is for the user to remove these configuration records 4 | # one by one as the reported problems are removed from the code base. 5 | 6 | # Problems found: 3 7 | # Run `ameba --only LargeNumbers` for details 8 | LargeNumbers: 9 | Description: Disallows usage of large numbers without underscore 10 | IntMinDigits: 5 11 | Enabled: true 12 | Excluded: 13 | - spec/controllers/oauth_controller_spec.cr 14 | -------------------------------------------------------------------------------- /.crystal-version: -------------------------------------------------------------------------------- 1 | 0.24.2 2 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | lib 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /doc/ 2 | /libs/ 3 | /lib/ 4 | /bin/ 5 | /.crystal/ 6 | /.shards/ 7 | .env 8 | crystal-ann 9 | 10 | .DS_Store 11 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | env: 2 | AMBER_ENV=test 3 | MICRATE_RUN_UP=true 4 | DATABASE_NAME=crystal_ann_test 5 | DATABASE_URL=postgres://postgres@localhost/$DATABASE_NAME 6 | 7 | language: crystal 8 | services: postgresql 9 | before_script: 10 | - psql -c "create database $DATABASE_NAME;" -U postgres 11 | install: 12 | - crystal deps 13 | script: 14 | - crystal spec 15 | - bin/ameba 16 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. 6 | 7 | ## Our Standards 8 | 9 | Examples of behavior that contributes to creating a positive environment include: 10 | 11 | * Using welcoming and inclusive language 12 | * Being respectful of differing viewpoints and experiences 13 | * Gracefully accepting constructive criticism 14 | * Focusing on what is best for the community 15 | * Showing empathy towards other community members 16 | 17 | Examples of unacceptable behavior by participants include: 18 | 19 | * The use of sexualized language or imagery and unwelcome sexual attention or advances 20 | * Trolling, insulting/derogatory comments, and personal or political attacks 21 | * Public or private harassment 22 | * Publishing others' private information, such as a physical or electronic address, without explicit permission 23 | * Other conduct which could reasonably be considered inappropriate in a professional setting 24 | 25 | ## Our Responsibilities 26 | 27 | Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. 28 | 29 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. 30 | 31 | ## Scope 32 | 33 | This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. 34 | 35 | ## Enforcement 36 | 37 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at velenhaupt@gmail.com. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. 38 | 39 | Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. 40 | 41 | ## Attribution 42 | 43 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version] 44 | 45 | [homepage]: http://contributor-covenant.org 46 | [version]: http://contributor-covenant.org/version/1/4/ 47 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # syntax=docker/dockerfile:1 2 | FROM crystallang/crystal:0.24.1 as builder 3 | 4 | RUN apt-get update 5 | 6 | WORKDIR /app 7 | COPY . /app 8 | RUN shards --production 9 | 10 | RUN mkdir bin && crystal build --release src/crystal-ann.cr -o bin/crystal_ann 11 | RUN chmod +x bin/crystal_ann 12 | 13 | CMD ["bin/crystal_ann"] 14 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2017 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: ./app 2 | worker: ./sidekiq -c 3 3 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Crystal [ANN] 2 | [![GitHub release](https://img.shields.io/github/release/crystal-community/crystal-ann.svg)](https://github.com/crystal-community/crystal-ann) 3 | [![Amber Framework](https://img.shields.io/badge/using-amber%20framework-orange.svg)](http://www.amberframework.org/) 4 | [![Twitter Follow](https://img.shields.io/twitter/follow/crystallang_ann.svg?style=social&label=Follow)](https://twitter.com/crystallang_ann) 5 | 6 |

7 | 8 | 9 |

10 | 11 | ## Notice 12 | 13 | **Crystal [ANN] is no longer maintained. Website was taken down as of March 2025**. 14 | 15 | The announcement data has been exported and preserved in the `db/data` folder: 16 | 17 | - `announcements.json.gz`: JSON format of all announcements 18 | - `announcements.sql.gz`: SQL dump of the announcements table 19 | 20 | This repository remains available for historical reference and educational purposes. 21 | 22 | ## Setup 23 | 24 | 1. [Install Crystal](https://crystal-lang.org/docs/installation/index.html) 25 | 2. [Install Amber Framework](https://docs.amberframework.org/amber/getting-started) 26 | 3. [Install Postgres](http://postgresguide.com/setup/install.html) 27 | 4. Create `crystal_ann` and `crystal_ann_test` pg databases 28 | 29 | ## Development 30 | 31 | 1. Install project dependencies: 32 | 33 | ``` 34 | $ shards install 35 | ``` 36 | 37 | 2. Run database migrations: 38 | 39 | ``` 40 | $ amber db migrate 41 | ``` 42 | 43 | 3. Seed data: 44 | 45 | ``` 46 | $ amber db seed 47 | ``` 48 | 49 | 4. Start app and watch for source changes: 50 | 51 | ``` 52 | $ amber w 53 | ``` 54 | 55 | ## Testing 56 | 57 | Migrate test database and run specs: 58 | 59 | ``` 60 | $ MICRATE_RUN_UP=true crystal spec 61 | ``` 62 | 63 | ## Docker 64 | 65 | Run the app using docker-compose 66 | 67 | ``` sh 68 | docker-compose up 69 | ``` 70 | 71 | ## Deployment to Heroku 72 | 73 | ``` 74 | $ heroku create app-name --buildpack https://github.com/crystal-lang/heroku-buildpack-crystal.git 75 | $ heroku buildpacks:add https://github.com/veelenga/heroku-buildpack-sidekiq.cr 76 | $ git push heroku master 77 | ``` 78 | 79 | And set environment variables with `heroku config:set VAR=VAL`: 80 | 81 | ``` 82 | AMBER_ENV 83 | AMBER_SESSION_SECRET 84 | 85 | MICRATE_RUN_UP 86 | REDIS_PROVIDER 87 | 88 | GITHUB_ID 89 | GITHUB_SECRET 90 | 91 | TWITTER_CONSUMER_KEY 92 | TWITTER_CONSUMER_SECRET 93 | TWITTER_ACCESS_TOKEN 94 | TWITTER_ACCESS_TOKEN_SECRET 95 | 96 | TWITTER_OAUTH_CONSUMER_KEY 97 | TWITTER_OAUTH_CONSUMER_SECRET 98 | ``` 99 | 100 | ## Contributors 101 | 102 | * [veelenga](https://github.com/veelenga) V. Elenhaupt - creator, maintainer 103 | * [hugoabonizio](https://github.com/hugoabonizio) Hugo Abonizio - contributor, maintainer 104 | * [janczer](https://github.com/janczer) Janczer - contributor 105 | * [lex111](https://github.com/lex111) Alexey Pyltsyn - contributor 106 | * [vaibhavsingh97](https://github.com/vaibhavsingh97) Vaibhav Singh - contributor 107 | * [PAPERPANKS](https://github.com/PAPERPANKS) Pankaj Kumar Gautam - contributor 108 | -------------------------------------------------------------------------------- /config/application.cr: -------------------------------------------------------------------------------- 1 | AMBER_PORT = ENV["PORT"]? || 3008 2 | AMBER_ENV = ENV["AMBER_ENV"]? || "development" 3 | 4 | require "amber" 5 | require "./initializers/*" 6 | 7 | Amber::Server.configure do |settings| 8 | settings.name = "Crystal [ANN] web application." 9 | settings.port = AMBER_PORT.to_i 10 | settings.host = "0.0.0.0" 11 | 12 | settings.session = { 13 | "key" => "crystal.ann.session", 14 | "store" => "encrypted_cookie", 15 | "expires" => 0, 16 | "secret" => ENV["AMBER_SESSION_SECRET"]? || "", 17 | } 18 | end 19 | -------------------------------------------------------------------------------- /config/database.yml: -------------------------------------------------------------------------------- 1 | default: &default 2 | database: postgresql://localhost/crystal_ann 3 | 4 | development: 5 | <<: *default 6 | 7 | staging: 8 | <<: *default 9 | 10 | production: 11 | <<: *default 12 | 13 | test: 14 | database: postgresql://localhost/crystal_ann_test 15 | -------------------------------------------------------------------------------- /config/deploy/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/crystal-community/crystal-ann/f58bb9d97a24d1fc68374fc8143b8987451f3594/config/deploy/.gitkeep -------------------------------------------------------------------------------- /config/initializers/database.cr: -------------------------------------------------------------------------------- 1 | require "yaml" 2 | 3 | struct DBSettings 4 | getter database_url 5 | 6 | def initialize(@database_url : String) 7 | end 8 | 9 | def self.load(env = AMBER_ENV) 10 | config = YAML.parse(File.read "config/database.yml")[env] 11 | DBSettings.new config["database"].as_s 12 | end 13 | end 14 | 15 | ENV["DATABASE_URL"] ||= DBSettings.load.database_url 16 | -------------------------------------------------------------------------------- /config/initializers/granite.cr: -------------------------------------------------------------------------------- 1 | Granite::ORM.settings.logger = Amber.settings.logger 2 | -------------------------------------------------------------------------------- /config/initializers/hashids.cr: -------------------------------------------------------------------------------- 1 | require "hashids" 2 | 3 | HASHIDS = Hashids.new(salt: ENV["HASHIDS_SALT"]? || "crystal-ann-dev", min_length: 5) 4 | -------------------------------------------------------------------------------- /config/initializers/multiauth.cr: -------------------------------------------------------------------------------- 1 | require "multi_auth" 2 | 3 | MultiAuth.config("github", ENV.fetch("GITHUB_ID", ""), ENV.fetch("GITHUB_SECRET", "")) 4 | MultiAuth.config("twitter", ENV.fetch("TWITTER_OAUTH_CONSUMER_KEY", ""), ENV.fetch("TWITTER_OAUTH_CONSUMER_SECRET", "")) 5 | -------------------------------------------------------------------------------- /config/initializers/sidekiq.cr: -------------------------------------------------------------------------------- 1 | require "sidekiq" 2 | 3 | Sidekiq::Client.default_context = Sidekiq::Client::Context.new 4 | -------------------------------------------------------------------------------- /config/initializers/site.cr: -------------------------------------------------------------------------------- 1 | require "yaml" 2 | 3 | struct SiteSettings 4 | getter name, description, url, force_ssl 5 | 6 | def initialize(@name : String, @description : String, @url : String, @force_ssl : Bool) 7 | end 8 | 9 | def self.load(env = AMBER_ENV) 10 | config = YAML.parse(File.read "config/site.yml")[env] 11 | 12 | SiteSettings.new config["name"].as_s, 13 | config["description"].as_s, 14 | config["url"].as_s, 15 | config["force_ssl"] == "true" 16 | end 17 | end 18 | 19 | SITE = SiteSettings.load 20 | -------------------------------------------------------------------------------- /config/routes.cr: -------------------------------------------------------------------------------- 1 | Amber::Server.configure do |app| 2 | pipeline :web do 3 | # Plug is the method to use connect a pipe (middleware) 4 | # A plug accepts an instance of HTTP::Handler 5 | plug Amber::Pipe::Error.new 6 | plug Amber::Pipe::Logger.new 7 | # plug Amber::Pipe::Flash.new 8 | plug Amber::Pipe::Session.new 9 | plug Amber::Pipe::CSRF.new 10 | plug Amber::Pipe::PoweredByAmber.new 11 | end 12 | 13 | # All static content will run these transformations 14 | pipeline :static do 15 | plug Amber::Pipe::Error.new 16 | plug Amber::Pipe::Static.new("./public") 17 | plug HTTP::CompressHandler.new 18 | end 19 | 20 | routes :static do 21 | # Each route is defined as follow 22 | # verb resource : String, controller : Symbol, action : Symbol 23 | get "/*", Amber::Controller::Static, :index 24 | get "/about", StaticController, :about 25 | end 26 | 27 | routes :web do 28 | resources "/announcements", AnnouncementController 29 | get "/announcements/random", AnnouncementController, :random 30 | get "/=:hashid", AnnouncementController, :expand 31 | get "/rss", RSSController, :show 32 | 33 | get "/oauth/new", OAuthController, :new 34 | get "/oauth/:provider", OAuthController, :authenticate 35 | delete "/sessions", SessionController, :destroy 36 | 37 | get "/me", UserController, :me 38 | get "/users/:login", UserController, :show 39 | put "/users/remove_handle", UserController, :remove_handle 40 | 41 | get "/", AnnouncementController, :index 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /config/site.yml: -------------------------------------------------------------------------------- 1 | default: &default 2 | name: Crystal [ANN] 3 | description: Announce new project, blog post, version update or any other Crystal work 4 | force_ssl: false 5 | 6 | test: 7 | <<: *default 8 | url: https://testing.com 9 | 10 | development: 11 | <<: *default 12 | url: http://localhost:3008 13 | 14 | staging: 15 | <<: *default 16 | url: https://crystal-ann-staging.herokuapp.com 17 | force_ssl: true 18 | 19 | production: 20 | <<: *default 21 | url: https://crystal-ann.com 22 | force_ssl: true 23 | -------------------------------------------------------------------------------- /db/data/announcements.json.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/crystal-community/crystal-ann/f58bb9d97a24d1fc68374fc8143b8987451f3594/db/data/announcements.json.gz -------------------------------------------------------------------------------- /db/data/announcements.sql.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/crystal-community/crystal-ann/f58bb9d97a24d1fc68374fc8143b8987451f3594/db/data/announcements.sql.gz -------------------------------------------------------------------------------- /db/migrate.cr: -------------------------------------------------------------------------------- 1 | require "../config/application" 2 | require "micrate" 3 | require "pg" 4 | 5 | if ENV["MICRATE_RUN_UP"]? 6 | Micrate::DB.connection_url = ENV["DATABASE_URL"] 7 | Micrate::Cli.setup_logger 8 | Micrate::Cli.run_up 9 | end 10 | -------------------------------------------------------------------------------- /db/migrations/20170701230806_create_announcement.sql: -------------------------------------------------------------------------------- 1 | -- +micrate Up 2 | CREATE TABLE announcements ( 3 | id BIGSERIAL PRIMARY KEY, 4 | title VARCHAR, 5 | description TEXT, 6 | type INTEGER, 7 | created_at TIMESTAMP, 8 | updated_at TIMESTAMP 9 | ); 10 | 11 | -- +micrate Down 12 | DROP TABLE IF EXISTS announcements; 13 | -------------------------------------------------------------------------------- /db/migrations/20170710175120_create_user.sql: -------------------------------------------------------------------------------- 1 | -- +micrate Up 2 | CREATE TABLE users ( 3 | id BIGSERIAL PRIMARY KEY, 4 | name VARCHAR, 5 | login VARCHAR, 6 | uid VARCHAR, 7 | provider VARCHAR, 8 | created_at TIMESTAMP, 9 | updated_at TIMESTAMP 10 | ); 11 | CREATE UNIQUE INDEX uid_provider_idx ON users (uid, provider); 12 | 13 | ALTER TABLE announcements ADD COLUMN user_id BIGSERIAL REFERENCES users (id); 14 | 15 | -- +micrate Down 16 | 17 | ALTER TABLE announcements DROP COLUMN user_id CASCADE; 18 | 19 | DROP TABLE IF EXISTS users; 20 | -------------------------------------------------------------------------------- /db/migrations/20170712100522_add_roles_to_users.sql: -------------------------------------------------------------------------------- 1 | -- +micrate Up 2 | -- SQL in section 'Up' is executed when this migration is applied 3 | 4 | ALTER TABLE users ADD COLUMN role VARCHAR DEFAULT 'user'; 5 | 6 | UPDATE users SET role='admin' WHERE login='veelenga'; 7 | 8 | -- +micrate Down 9 | -- SQL section 'Down' is executed when this migration is rolled back 10 | 11 | ALTER TABLE users DROP COLUMN role; 12 | -------------------------------------------------------------------------------- /db/migrations/20170919173941_change_timestamp_columns.sql: -------------------------------------------------------------------------------- 1 | -- +micrate Up 2 | ALTER TABLE announcements 3 | ALTER COLUMN created_at TYPE TIMESTAMP WITH TIME ZONE 4 | USING created_at AT TIME ZONE 'UTC'; 5 | 6 | ALTER TABLE announcements 7 | ALTER COLUMN updated_at TYPE TIMESTAMP WITH TIME ZONE 8 | USING updated_at AT TIME ZONE 'UTC'; 9 | 10 | ALTER TABLE users 11 | ALTER COLUMN created_at TYPE TIMESTAMP WITH TIME ZONE 12 | USING created_at AT TIME ZONE 'UTC'; 13 | 14 | ALTER TABLE users 15 | ALTER COLUMN updated_at TYPE TIMESTAMP WITH TIME ZONE 16 | USING updated_at AT TIME ZONE 'UTC'; 17 | 18 | -- +micrate Down 19 | ALTER TABLE announcements 20 | ALTER COLUMN created_at TYPE TIMESTAMP 21 | USING created_at AT TIME ZONE 'UTC'; 22 | 23 | ALTER TABLE announcements 24 | ALTER COLUMN updated_at TYPE TIMESTAMP 25 | USING updated_at AT TIME ZONE 'UTC'; 26 | 27 | ALTER TABLE users 28 | ALTER COLUMN created_at TYPE TIMESTAMP 29 | USING created_at AT TIME ZONE 'UTC'; 30 | 31 | ALTER TABLE users 32 | ALTER COLUMN updated_at TYPE TIMESTAMP 33 | USING updated_at AT TIME ZONE 'UTC'; 34 | -------------------------------------------------------------------------------- /db/migrations/20171002221739_add_handle_to_users.sql: -------------------------------------------------------------------------------- 1 | -- +micrate Up 2 | ALTER TABLE users ADD COLUMN handle VARCHAR; 3 | 4 | -- +micrate Down 5 | ALTER TABLE users DROP COLUMN handle; 6 | -------------------------------------------------------------------------------- /db/seeds.cr: -------------------------------------------------------------------------------- 1 | require "../config/application" 2 | require "../src/models/**" 3 | require "./seeds/*" 4 | 5 | Announcement.clear 6 | User.clear 7 | 8 | Seeds::Users.create_records 9 | Seeds::Announcements.create_records 10 | -------------------------------------------------------------------------------- /db/seeds/announcements.cr: -------------------------------------------------------------------------------- 1 | module Seeds::Announcements 2 | extend self 3 | 4 | def user_id 5 | user = User.find_by(:login, "veelenga") || 6 | Seeds::Users.user(login: "veelenga", name: "V. Elenhaupt", provider: "github", uid: "1111") 7 | user.id.not_nil!.to_i 8 | end 9 | 10 | def announcement(typename, **params) 11 | type = Announcement::TYPES 12 | .find { |k, v| v == typename } 13 | .try(&.first) || 0 14 | 15 | attributes = {:type => type.to_s, :user_id => user_id.to_s} of String | Symbol => String | JSON::Type 16 | Announcement.new(attributes.merge!(params.to_h)).tap do |a| 17 | a.created_at = rand(15).days.ago - rand(60).minutes 18 | a.save 19 | end 20 | end 21 | 22 | def create_records 23 | announcement "Project Update", 24 | title: "Linear algebra library based on LAPACK", 25 | description: <<-DE 26 | https://github.com/konovod/linalg 27 | 28 | Linear algebra library in Crystal, uses LAPACKE: 29 | 30 | * direct access to LAPACK methods 31 | * convenient Matrix(T) class, supports T=Float32, Float64 and Complex. 32 | * high-level interface similar to scipy.linalg or MATLAB. 33 | 34 | Killing SciPy, one module at a time. 35 | DE 36 | 37 | announcement "Blog Post", 38 | title: "Methods tap and itself in Crystal", 39 | description: <<-DE 40 | http://veelenga.com/tap-and-itself-methods-in-crystal/ 41 | 42 | Method [Object#tap](https://crystal-lang.org/api/0.23.0/Object.html#tap%28%26block%29-instance-method) in Crystal yields to the block and then returns self. 43 | [Object#itself](https://crystal-lang.org/api/0.23.0/Object.html#itself-instance-method) just returns self. 44 | Why do we need those methods? Let’s look at few examples. 45 | DE 46 | 47 | announcement "Blog Post", 48 | title: "Observer design pattern in Crystal language", 49 | description: <<-DE 50 | http://veelenga.com/observer-design-pattern-in-crystal-language/ 51 | 52 | Being a developer you probably have heart about Observer design pattern. 53 | Perhaps, you have even used it in your complex system with subscribers and notifications. 54 | This article is about how to implement Observer pattern in Crystal language and what are the common features of this language we can use there to tune it. 55 | 56 | ```crystal 57 | # Sample 58 | fighter = Fighter.new("Scorpion") 59 | 60 | fighter.add_observer(Stats.new) 61 | fighter.add_observer(DieAction.new) 62 | 63 | fighter.damage(10) 64 | # Updating stats: Scorpion's health is 90 65 | 66 | fighter.damage(30) 67 | # Updating stats: Scorpion's health is 60 68 | 69 | fighter.damage(75) 70 | # Updating stats: Scorpion's health is 0 71 | # Scorpion is dead. Fight is over! 72 | ``` 73 | DE 74 | 75 | announcement "Blog Post", 76 | title: "Make your own Shard in Crystal language", 77 | description: <<-DE 78 | http://veelenga.com/make-your-own-shard-in-crystal-language/ 79 | 80 | An easy to use tutorial that describes how to create a new shard in Crystal language. 81 | 82 | Here you will find how to add an executable, project dependencies, write tests and document your code. 83 | DE 84 | 85 | announcement "Meetup", 86 | title: "Crystal Code Camp 2017", 87 | description: <<-DE 88 | Crystal Code Camp 2017 is taking place in San Francisco. See you there ;) 89 | 90 | Checkout our home page for details: https://codecamp.crystal-lang.org/ 91 | 92 | Learn, engage and share. 93 | DE 94 | 95 | announcement "Project Update", 96 | title: "Crystal bindings to Lua and a wrapper around it", 97 | description: <<-DE 98 | ![](https://github.com/veelenga/bin/raw/master/lua.cr/logo.jpg) 99 | 100 | Crystal bindings to Lua and a wrapper around it: [https://github.com/veelenga/lua.cr](https://github.com/veelenga/lua.cr) 101 | 102 | ### Running chunk of Lua code: 103 | 104 | ```crystal 105 | Lua.run %q{ 106 | local hello_message = table.concat({ 'Hello', 'from', 'Lua!' }, ' ') 107 | print(hello_message) 108 | } # => prints 'Hello from Lua!' 109 | ``` 110 | 111 | ### Running Lua file: 112 | 113 | ```crystal 114 | p Lua.run File.new("./examples/sample.lua") # => 42.0 115 | ``` 116 | 117 | ### Evaluate a function and pass arguments in: 118 | 119 | ```crystal 120 | lua = Lua.load 121 | sum = lua.run %q{ 122 | function sum(x, y) 123 | return x + y 124 | end 125 | 126 | return sum 127 | } 128 | p sum.as(Lua::Function).call(3.2, 1) # => 4.2 129 | lua.close 130 | ``` 131 | 132 | More features coming soon. Try it, that's fun :) 133 | DE 134 | 135 | announcement "Project Update", 136 | title: " Message Queue written in Crystal, very similar to NSQ", 137 | description: <<-DE 138 | https://github.com/crystalmq/crystalmq 139 | 140 | To run this project you will need three components. 141 | 142 | * A Message Router, running with either debug set to true or false 143 | 144 | `crystal router.cr false` 145 | 146 | * A Message Consumer, with a topic and channel set 147 | 148 | `crystal tools/consumer.cr my_topic my_channel` 149 | 150 | * A Message Producer, with a topic and message 151 | 152 | `crystal tools/producer.cr my_topic "Hello World"` 153 | 154 | * If you would like to run the benchmarks, feel free to use tools/meth 155 | 156 | ``` 157 | ./meth --consumer # Will benchmark received messags (used in conjuction with producer) 158 | ./meth --producer # Will benchmark sent messages (used in conjunction with consumer) 159 | ./meth --latency # Will benchmark request latency 160 | ``` 161 | DE 162 | 163 | announcement "Project Update", 164 | title: "FANN (Fast Artifical Neural Network) binding in Crystal", 165 | description: <<-DE 166 | [Crystal bindings for the FANN C lib](https://github.com/bararchy/crystal-fann) 167 | 168 | ```crystal 169 | require "crystal-fann" 170 | ann = Crystal::Fann::Network.new 2, [2], 1 171 | 500.times do 172 | ann.train_single [1.0_f32, 0.1_f32], [0.5_f32] 173 | end 174 | 175 | result = ann.run [1.0_f32, 0.1_f32] 176 | 177 | ann.close 178 | ``` 179 | DE 180 | 181 | announcement "Other", 182 | title: "CrystalShards has reached 2000 shards!", 183 | description: <<-DE 184 | ![](https://pbs.twimg.com/media/DD6wVD-UAAEAk87.jpg) 185 | 186 | Search your project at [crystalshards.xyz](http://crystalshards.xyz/) 187 | DE 188 | 189 | announcement "Blog Post", 190 | title: "Crystal and Kemal for dynamic website", 191 | description: <<-DE 192 | ![](https://cdn-images-1.medium.com/max/1440/1*9veJjTBa0xuhyO67pJHnYQ.png) 193 | 194 | [Second part](https://medium.com/@codelessfuture/crystal-and-kemal-for-dynamic-website-9b853481c88) of Crystal and Kemal pair for creating a dynamic website. Here you will find how to work with static files and layouts. 195 | 196 | In the [first article](https://hackernoon.com/starting-a-project-with-crystal-and-kemal-90e2647e6c3b) we prepare the development environment and made our hands dirty of the first lines of code. 197 | DE 198 | 199 | announcement "Project Update", 200 | title: "Crystal 0.23.0 has been released", 201 | description: <<-DE 202 | ![](https://cloud.githubusercontent.com/assets/209371/13291809/022e2360-daf8-11e5-8be7-d02c1c8b38fb.png) 203 | 204 | [This release](https://github.com/crystal-lang/crystal/releases/tag/0.23.0) has been built with **LLVM 3.8**. 205 | 206 | As discussed in crystal-lang/omnibus-crystal#17, that means dropping support for Debian 7, and CentOS. Users on those platforms can still build from source using **LLVM 3.5**, but that's not recommended since it's buggy (see #4104). 207 | 208 | * **(breaking-change)** `Logger#formatter` takes a `Severity` instead of a `String` (See #4355, #4369, thanks @Sija) 209 | * **(breaking-change)** Removed `IO.select` (See #4392, thanks @RX14) 210 | * Added `Crystal::System::Random` namespace (See #4450, thanks @ysbaddaden) 211 | * Added `Path#resolve?` macro method (See #4370, #4408, thanks @RX14) 212 | * Added range methods to `BitArray` (See #4397, #3968, thanks @RX14) 213 | * Added some well-known HTTP Status messages (See #4419, thanks @akzhan) 214 | * Added compiler progress indicator (See #4182, thanks @RX14) 215 | * Added `System.cpu_cores` (See #4449, #4226, thanks @miketheman) 216 | * Added `separator` and `quote_char` to `CSV#each_row` (See #4448, thanks @timsu) 217 | * Added `map_with_index!` to `Pointer`, `Array` and `StaticArray` (See #4456, #3356, #3354, thanks @Nephos) 218 | * Added `headers` parameter to `HTTP::WebSocket` constructors (See #4227, #4222, thanks @adamtrilling) 219 | * `HTTP::StaticFileHandler` can disable directory listing (See #4403, #4398, thanks @joaodiogocosta) 220 | * `bin/crystal` now uses `/bin/sh` instead of `/bin/bash` (See #3809, #4410, thanks @TheLonelyGhost) 221 | * `crystal init` generates a `.editorconfig` file (See #4422, #297, thanks @akzhan) 222 | * `man` page for `crystal` command (See #2989, #1291, thanks @dread-uo) 223 | * Re-raising an exception doesn't overwrite its callstack (See #4487, #4482, thanks @akzhan) 224 | * MD5 and SHA1 documentation clearly states they are not cryptographically secure anymore (See #4426, thanks @RX14) 225 | * Fixed Crystal not reusing .o files across builds (See #4336) 226 | * Fixed `SomeClass.class.is_a?(SomeConst)` causing an "already had enclosing call" exception (See #4364, #4390, thanks @rockwyc992) 227 | * Fixed `HTTP::Params.parse` query string with two `=` gave wrong result (See #4388, #4389, thanks @akiicat) 228 | * Fixed `Class.class.is_a?(Class.class.class.class.class)` (See #4375, #4374, thanks @rockwyc992) 229 | * Fixed select hanging when sending before receive (See #3862, #3899, thanks @kostya) 230 | * Fixed "Unknown key in access token json: id_token" error in OAuth2 client (See #4437) 231 | * Fixed macro lookup conflicting with method lookup when including on top level (See #236) 232 | * Fixed Vagrant images (see #4510, #4508, thanks @Val) 233 | DE 234 | 235 | announcement "Other", 236 | title: "How to implement to_json in Crystal lang", 237 | description: <<-DE 238 | This is an example of how to implement a generic to_json method in Crystal lang: 239 | 240 | * [https://snippets.aktagon.com/snippets/799-how-to-implement-to-json-in-crystal-lang](https://snippets.aktagon.com/snippets/799-how-to-implement-to-json-in-crystal-lang) 241 | DE 242 | end 243 | end 244 | -------------------------------------------------------------------------------- /db/seeds/users.cr: -------------------------------------------------------------------------------- 1 | module Seeds::Users 2 | extend self 3 | 4 | def user(**params) 5 | User.new(params.to_h).tap &.save 6 | end 7 | 8 | def create_records 9 | user login: "veelenga", 10 | name: "V. Elenhaupt", 11 | provider: "github", 12 | uid: "111111", 13 | role: "admin" 14 | 15 | user login: "ann", 16 | name: "Ann Doe", 17 | provider: "github", 18 | uid: "22222" 19 | 20 | user login: "joe", 21 | name: "Joe Doe", 22 | provider: "github", 23 | uid: "33333" 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.9' 2 | 3 | services: 4 | app: 5 | build: 6 | context: . 7 | environment: 8 | - AMBER_ENV=production 9 | - AMBER_SESSION_SECRET= 10 | 11 | - DATABASE_URL=postgres://postgres:postgres@db:5432/crystal_ann 12 | - MICRATE_RUN_UP=true 13 | 14 | - GITHUB_ID= 15 | - GITHUB_SECRET= 16 | 17 | - TWITTER_CONSUMER_KEY= 18 | - TWITTER_CONSUMER_SECRET= 19 | - TWITTER_ACCESS_TOKEN= 20 | - TWITTER_ACCESS_TOKEN_SECRET= 21 | - TWITTER_OAUTH_CONSUMER_KEY= 22 | - TWITTER_OAUTH_CONSUMER_SECRET= 23 | 24 | depends_on: 25 | db: 26 | condition: service_healthy 27 | ports: 28 | - 3000:3008 29 | 30 | db: 31 | image: postgres:13.8-alpine 32 | environment: 33 | - POSTGRES_USER=postgres 34 | - POSTGRES_PASSWORD=postgres 35 | - POSTGRES_DB=crystal_ann 36 | volumes: 37 | - crystal_ann-db:/var/lib/postgres/data 38 | healthcheck: 39 | test: ["CMD-SHELL", "pg_isready -U postgres"] 40 | interval: 10s 41 | timeout: 10s 42 | start_period: 10s 43 | 44 | volumes: 45 | crystal_ann-db: 46 | -------------------------------------------------------------------------------- /package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "crystal-ann", 3 | "version": "1.0.0", 4 | "lockfileVersion": 1 5 | } 6 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "crystal-ann", 3 | "version": "1.0.0", 4 | "repository": "https://github.com/crystal-community/crystal-ann", 5 | "author": "V. Elenhaupt ", 6 | "license": "MIT", 7 | "private": true, 8 | "scripts": { 9 | "watch": "echo" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /public/crossdomain.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 15 | 16 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/crystal-community/crystal-ann/f58bb9d97a24d1fc68374fc8143b8987451f3594/public/favicon.ico -------------------------------------------------------------------------------- /public/javascripts/highlight.pack.js: -------------------------------------------------------------------------------- 1 | /*! highlight.js v9.12.0 | BSD3 License | git.io/hljslicense */ 2 | !function(e){var n="object"==typeof window&&window||"object"==typeof self&&self;"undefined"!=typeof exports?e(exports):n&&(n.hljs=e({}),"function"==typeof define&&define.amd&&define([],function(){return n.hljs}))}(function(e){function n(e){return e.replace(/&/g,"&").replace(//g,">")}function t(e){return e.nodeName.toLowerCase()}function r(e,n){var t=e&&e.exec(n);return t&&0===t.index}function a(e){return k.test(e)}function i(e){var n,t,r,i,o=e.className+" ";if(o+=e.parentNode?e.parentNode.className:"",t=B.exec(o))return w(t[1])?t[1]:"no-highlight";for(o=o.split(/\s+/),n=0,r=o.length;r>n;n++)if(i=o[n],a(i)||w(i))return i}function o(e){var n,t={},r=Array.prototype.slice.call(arguments,1);for(n in e)t[n]=e[n];return r.forEach(function(e){for(n in e)t[n]=e[n]}),t}function u(e){var n=[];return function r(e,a){for(var i=e.firstChild;i;i=i.nextSibling)3===i.nodeType?a+=i.nodeValue.length:1===i.nodeType&&(n.push({event:"start",offset:a,node:i}),a=r(i,a),t(i).match(/br|hr|img|input/)||n.push({event:"stop",offset:a,node:i}));return a}(e,0),n}function c(e,r,a){function i(){return e.length&&r.length?e[0].offset!==r[0].offset?e[0].offset"}function u(e){s+=""}function c(e){("start"===e.event?o:u)(e.node)}for(var l=0,s="",f=[];e.length||r.length;){var g=i();if(s+=n(a.substring(l,g[0].offset)),l=g[0].offset,g===e){f.reverse().forEach(u);do c(g.splice(0,1)[0]),g=i();while(g===e&&g.length&&g[0].offset===l);f.reverse().forEach(o)}else"start"===g[0].event?f.push(g[0].node):f.pop(),c(g.splice(0,1)[0])}return s+n(a.substr(l))}function l(e){return e.v&&!e.cached_variants&&(e.cached_variants=e.v.map(function(n){return o(e,{v:null},n)})),e.cached_variants||e.eW&&[o(e)]||[e]}function s(e){function n(e){return e&&e.source||e}function t(t,r){return new RegExp(n(t),"m"+(e.cI?"i":"")+(r?"g":""))}function r(a,i){if(!a.compiled){if(a.compiled=!0,a.k=a.k||a.bK,a.k){var o={},u=function(n,t){e.cI&&(t=t.toLowerCase()),t.split(" ").forEach(function(e){var t=e.split("|");o[t[0]]=[n,t[1]?Number(t[1]):1]})};"string"==typeof a.k?u("keyword",a.k):x(a.k).forEach(function(e){u(e,a.k[e])}),a.k=o}a.lR=t(a.l||/\w+/,!0),i&&(a.bK&&(a.b="\\b("+a.bK.split(" ").join("|")+")\\b"),a.b||(a.b=/\B|\b/),a.bR=t(a.b),a.e||a.eW||(a.e=/\B|\b/),a.e&&(a.eR=t(a.e)),a.tE=n(a.e)||"",a.eW&&i.tE&&(a.tE+=(a.e?"|":"")+i.tE)),a.i&&(a.iR=t(a.i)),null==a.r&&(a.r=1),a.c||(a.c=[]),a.c=Array.prototype.concat.apply([],a.c.map(function(e){return l("self"===e?a:e)})),a.c.forEach(function(e){r(e,a)}),a.starts&&r(a.starts,i);var c=a.c.map(function(e){return e.bK?"\\.?("+e.b+")\\.?":e.b}).concat([a.tE,a.i]).map(n).filter(Boolean);a.t=c.length?t(c.join("|"),!0):{exec:function(){return null}}}}r(e)}function f(e,t,a,i){function o(e,n){var t,a;for(t=0,a=n.c.length;a>t;t++)if(r(n.c[t].bR,e))return n.c[t]}function u(e,n){if(r(e.eR,n)){for(;e.endsParent&&e.parent;)e=e.parent;return e}return e.eW?u(e.parent,n):void 0}function c(e,n){return!a&&r(n.iR,e)}function l(e,n){var t=N.cI?n[0].toLowerCase():n[0];return e.k.hasOwnProperty(t)&&e.k[t]}function p(e,n,t,r){var a=r?"":I.classPrefix,i='',i+n+o}function h(){var e,t,r,a;if(!E.k)return n(k);for(a="",t=0,E.lR.lastIndex=0,r=E.lR.exec(k);r;)a+=n(k.substring(t,r.index)),e=l(E,r),e?(B+=e[1],a+=p(e[0],n(r[0]))):a+=n(r[0]),t=E.lR.lastIndex,r=E.lR.exec(k);return a+n(k.substr(t))}function d(){var e="string"==typeof E.sL;if(e&&!y[E.sL])return n(k);var t=e?f(E.sL,k,!0,x[E.sL]):g(k,E.sL.length?E.sL:void 0);return E.r>0&&(B+=t.r),e&&(x[E.sL]=t.top),p(t.language,t.value,!1,!0)}function b(){L+=null!=E.sL?d():h(),k=""}function v(e){L+=e.cN?p(e.cN,"",!0):"",E=Object.create(e,{parent:{value:E}})}function m(e,n){if(k+=e,null==n)return b(),0;var t=o(n,E);if(t)return t.skip?k+=n:(t.eB&&(k+=n),b(),t.rB||t.eB||(k=n)),v(t,n),t.rB?0:n.length;var r=u(E,n);if(r){var a=E;a.skip?k+=n:(a.rE||a.eE||(k+=n),b(),a.eE&&(k=n));do E.cN&&(L+=C),E.skip||(B+=E.r),E=E.parent;while(E!==r.parent);return r.starts&&v(r.starts,""),a.rE?0:n.length}if(c(n,E))throw new Error('Illegal lexeme "'+n+'" for mode "'+(E.cN||"")+'"');return k+=n,n.length||1}var N=w(e);if(!N)throw new Error('Unknown language: "'+e+'"');s(N);var R,E=i||N,x={},L="";for(R=E;R!==N;R=R.parent)R.cN&&(L=p(R.cN,"",!0)+L);var k="",B=0;try{for(var M,j,O=0;;){if(E.t.lastIndex=O,M=E.t.exec(t),!M)break;j=m(t.substring(O,M.index),M[0]),O=M.index+j}for(m(t.substr(O)),R=E;R.parent;R=R.parent)R.cN&&(L+=C);return{r:B,value:L,language:e,top:E}}catch(T){if(T.message&&-1!==T.message.indexOf("Illegal"))return{r:0,value:n(t)};throw T}}function g(e,t){t=t||I.languages||x(y);var r={r:0,value:n(e)},a=r;return t.filter(w).forEach(function(n){var t=f(n,e,!1);t.language=n,t.r>a.r&&(a=t),t.r>r.r&&(a=r,r=t)}),a.language&&(r.second_best=a),r}function p(e){return I.tabReplace||I.useBR?e.replace(M,function(e,n){return I.useBR&&"\n"===e?"
":I.tabReplace?n.replace(/\t/g,I.tabReplace):""}):e}function h(e,n,t){var r=n?L[n]:t,a=[e.trim()];return e.match(/\bhljs\b/)||a.push("hljs"),-1===e.indexOf(r)&&a.push(r),a.join(" ").trim()}function d(e){var n,t,r,o,l,s=i(e);a(s)||(I.useBR?(n=document.createElementNS("http://www.w3.org/1999/xhtml","div"),n.innerHTML=e.innerHTML.replace(/\n/g,"").replace(//g,"\n")):n=e,l=n.textContent,r=s?f(s,l,!0):g(l),t=u(n),t.length&&(o=document.createElementNS("http://www.w3.org/1999/xhtml","div"),o.innerHTML=r.value,r.value=c(t,u(o),l)),r.value=p(r.value),e.innerHTML=r.value,e.className=h(e.className,s,r.language),e.result={language:r.language,re:r.r},r.second_best&&(e.second_best={language:r.second_best.language,re:r.second_best.r}))}function b(e){I=o(I,e)}function v(){if(!v.called){v.called=!0;var e=document.querySelectorAll("pre code");E.forEach.call(e,d)}}function m(){addEventListener("DOMContentLoaded",v,!1),addEventListener("load",v,!1)}function N(n,t){var r=y[n]=t(e);r.aliases&&r.aliases.forEach(function(e){L[e]=n})}function R(){return x(y)}function w(e){return e=(e||"").toLowerCase(),y[e]||y[L[e]]}var E=[],x=Object.keys,y={},L={},k=/^(no-?highlight|plain|text)$/i,B=/\blang(?:uage)?-([\w-]+)\b/i,M=/((^(<[^>]+>|\t|)+|(?:\n)))/gm,C="
",I={classPrefix:"hljs-",tabReplace:null,useBR:!1,languages:void 0};return e.highlight=f,e.highlightAuto=g,e.fixMarkup=p,e.highlightBlock=d,e.configure=b,e.initHighlighting=v,e.initHighlightingOnLoad=m,e.registerLanguage=N,e.listLanguages=R,e.getLanguage=w,e.inherit=o,e.IR="[a-zA-Z]\\w*",e.UIR="[a-zA-Z_]\\w*",e.NR="\\b\\d+(\\.\\d+)?",e.CNR="(-?)(\\b0[xX][a-fA-F0-9]+|(\\b\\d+(\\.\\d*)?|\\.\\d+)([eE][-+]?\\d+)?)",e.BNR="\\b(0b[01]+)",e.RSR="!|!=|!==|%|%=|&|&&|&=|\\*|\\*=|\\+|\\+=|,|-|-=|/=|/|:|;|<<|<<=|<=|<|===|==|=|>>>=|>>=|>=|>>>|>>|>|\\?|\\[|\\{|\\(|\\^|\\^=|\\||\\|=|\\|\\||~",e.BE={b:"\\\\[\\s\\S]",r:0},e.ASM={cN:"string",b:"'",e:"'",i:"\\n",c:[e.BE]},e.QSM={cN:"string",b:'"',e:'"',i:"\\n",c:[e.BE]},e.PWM={b:/\b(a|an|the|are|I'm|isn't|don't|doesn't|won't|but|just|should|pretty|simply|enough|gonna|going|wtf|so|such|will|you|your|they|like|more)\b/},e.C=function(n,t,r){var a=e.inherit({cN:"comment",b:n,e:t,c:[]},r||{});return a.c.push(e.PWM),a.c.push({cN:"doctag",b:"(?:TODO|FIXME|NOTE|BUG|XXX):",r:0}),a},e.CLCM=e.C("//","$"),e.CBCM=e.C("/\\*","\\*/"),e.HCM=e.C("#","$"),e.NM={cN:"number",b:e.NR,r:0},e.CNM={cN:"number",b:e.CNR,r:0},e.BNM={cN:"number",b:e.BNR,r:0},e.CSSNM={cN:"number",b:e.NR+"(%|em|ex|ch|rem|vw|vh|vmin|vmax|cm|mm|in|pt|pc|px|deg|grad|rad|turn|s|ms|Hz|kHz|dpi|dpcm|dppx)?",r:0},e.RM={cN:"regexp",b:/\//,e:/\/[gimuy]*/,i:/\n/,c:[e.BE,{b:/\[/,e:/\]/,r:0,c:[e.BE]}]},e.TM={cN:"title",b:e.IR,r:0},e.UTM={cN:"title",b:e.UIR,r:0},e.METHOD_GUARD={b:"\\.\\s*"+e.UIR,r:0},e});hljs.registerLanguage("makefile",function(e){var i={cN:"variable",v:[{b:"\\$\\("+e.UIR+"\\)",c:[e.BE]},{b:/\$[@%{",e:"}"},n={v:[{b:/\$\d/},{b:/[\$%@](\^\w\b|#\w+(::\w+)*|{\w+}|\w+(::\w*)*)/},{b:/[\$%@][^\s\w{]/,r:0}]},i=[e.BE,r,n],o=[n,e.HCM,e.C("^\\=\\w","\\=cut",{eW:!0}),s,{cN:"string",c:i,v:[{b:"q[qwxr]?\\s*\\(",e:"\\)",r:5},{b:"q[qwxr]?\\s*\\[",e:"\\]",r:5},{b:"q[qwxr]?\\s*\\{",e:"\\}",r:5},{b:"q[qwxr]?\\s*\\|",e:"\\|",r:5},{b:"q[qwxr]?\\s*\\<",e:"\\>",r:5},{b:"qw\\s+q",e:"q",r:5},{b:"'",e:"'",c:[e.BE]},{b:'"',e:'"'},{b:"`",e:"`",c:[e.BE]},{b:"{\\w+}",c:[],r:0},{b:"-?\\w+\\s*\\=\\>",c:[],r:0}]},{cN:"number",b:"(\\b0[0-7_]+)|(\\b0x[0-9a-fA-F_]+)|(\\b[1-9][0-9_]*(\\.[0-9_]+)?)|[0_]\\b",r:0},{b:"(\\/\\/|"+e.RSR+"|\\b(split|return|print|reverse|grep)\\b)\\s*",k:"split return print reverse grep",r:0,c:[e.HCM,{cN:"regexp",b:"(s|tr|y)/(\\\\.|[^/])*/(\\\\.|[^/])*/[a-z]*",r:10},{cN:"regexp",b:"(m|qr)?/",e:"/[a-z]*",c:[e.BE],r:0}]},{cN:"function",bK:"sub",e:"(\\s*\\(.*?\\))?[;{]",eE:!0,r:5,c:[e.TM]},{b:"-\\w\\b",r:0},{b:"^__DATA__$",e:"^__END__$",sL:"mojolicious",c:[{b:"^@@.*",e:"$",cN:"comment"}]}];return r.c=o,s.c=o,{aliases:["pl","pm"],l:/[\w\.]+/,k:t,c:o}});hljs.registerLanguage("php",function(e){var c={b:"\\$+[a-zA-Z_-ÿ][a-zA-Z0-9_-ÿ]*"},i={cN:"meta",b:/<\?(php)?|\?>/},t={cN:"string",c:[e.BE,i],v:[{b:'b"',e:'"'},{b:"b'",e:"'"},e.inherit(e.ASM,{i:null}),e.inherit(e.QSM,{i:null})]},a={v:[e.BNM,e.CNM]};return{aliases:["php3","php4","php5","php6"],cI:!0,k:"and include_once list abstract global private echo interface as static endswitch array null if endwhile or const for endforeach self var while isset public protected exit foreach throw elseif include __FILE__ empty require_once do xor return parent clone use __CLASS__ __LINE__ else break print eval new catch __METHOD__ case exception default die require __FUNCTION__ enddeclare final try switch continue endfor endif declare unset true false trait goto instanceof insteadof __DIR__ __NAMESPACE__ yield finally",c:[e.HCM,e.C("//","$",{c:[i]}),e.C("/\\*","\\*/",{c:[{cN:"doctag",b:"@[A-Za-z]+"}]}),e.C("__halt_compiler.+?;",!1,{eW:!0,k:"__halt_compiler",l:e.UIR}),{cN:"string",b:/<<<['"]?\w+['"]?$/,e:/^\w+;?$/,c:[e.BE,{cN:"subst",v:[{b:/\$\w+/},{b:/\{\$/,e:/\}/}]}]},i,{cN:"keyword",b:/\$this\b/},c,{b:/(::|->)+[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*/},{cN:"function",bK:"function",e:/[;{]/,eE:!0,i:"\\$|\\[|%",c:[e.UTM,{cN:"params",b:"\\(",e:"\\)",c:["self",c,e.CBCM,t,a]}]},{cN:"class",bK:"class interface",e:"{",eE:!0,i:/[:\(\$"]/,c:[{bK:"extends implements"},e.UTM]},{bK:"namespace",e:";",i:/[\.']/,c:[e.UTM]},{bK:"use",e:";",c:[e.UTM]},{b:"=>"},t,a]}});hljs.registerLanguage("diff",function(e){return{aliases:["patch"],c:[{cN:"meta",r:10,v:[{b:/^@@ +\-\d+,\d+ +\+\d+,\d+ +@@$/},{b:/^\*\*\* +\d+,\d+ +\*\*\*\*$/},{b:/^\-\-\- +\d+,\d+ +\-\-\-\-$/}]},{cN:"comment",v:[{b:/Index: /,e:/$/},{b:/={3,}/,e:/$/},{b:/^\-{3}/,e:/$/},{b:/^\*{3} /,e:/$/},{b:/^\+{3}/,e:/$/},{b:/\*{5}/,e:/\*{5}$/}]},{cN:"addition",b:"^\\+",e:"$"},{cN:"deletion",b:"^\\-",e:"$"},{cN:"addition",b:"^\\!",e:"$"}]}});hljs.registerLanguage("javascript",function(e){var r="[A-Za-z$_][0-9A-Za-z$_]*",t={keyword:"in of if for while finally var new function do return void else break catch instanceof with throw case default try this switch continue typeof delete let yield const export super debugger as async await static import from as",literal:"true false null undefined NaN Infinity",built_in:"eval isFinite isNaN parseFloat parseInt decodeURI decodeURIComponent encodeURI encodeURIComponent escape unescape Object Function Boolean Error EvalError InternalError RangeError ReferenceError StopIteration SyntaxError TypeError URIError Number Math Date String RegExp Array Float32Array Float64Array Int16Array Int32Array Int8Array Uint16Array Uint32Array Uint8Array Uint8ClampedArray ArrayBuffer DataView JSON Intl arguments require module console window document Symbol Set Map WeakSet WeakMap Proxy Reflect Promise"},a={cN:"number",v:[{b:"\\b(0[bB][01]+)"},{b:"\\b(0[oO][0-7]+)"},{b:e.CNR}],r:0},n={cN:"subst",b:"\\$\\{",e:"\\}",k:t,c:[]},c={cN:"string",b:"`",e:"`",c:[e.BE,n]};n.c=[e.ASM,e.QSM,c,a,e.RM];var s=n.c.concat([e.CBCM,e.CLCM]);return{aliases:["js","jsx"],k:t,c:[{cN:"meta",r:10,b:/^\s*['"]use (strict|asm)['"]/},{cN:"meta",b:/^#!/,e:/$/},e.ASM,e.QSM,c,e.CLCM,e.CBCM,a,{b:/[{,]\s*/,r:0,c:[{b:r+"\\s*:",rB:!0,r:0,c:[{cN:"attr",b:r,r:0}]}]},{b:"("+e.RSR+"|\\b(case|return|throw)\\b)\\s*",k:"return throw case",c:[e.CLCM,e.CBCM,e.RM,{cN:"function",b:"(\\(.*?\\)|"+r+")\\s*=>",rB:!0,e:"\\s*=>",c:[{cN:"params",v:[{b:r},{b:/\(\s*\)/},{b:/\(/,e:/\)/,eB:!0,eE:!0,k:t,c:s}]}]},{b://,sL:"xml",c:[{b:/<\w+\s*\/>/,skip:!0},{b:/<\w+/,e:/(\/\w+|\w+\/)>/,skip:!0,c:[{b:/<\w+\s*\/>/,skip:!0},"self"]}]}],r:0},{cN:"function",bK:"function",e:/\{/,eE:!0,c:[e.inherit(e.TM,{b:r}),{cN:"params",b:/\(/,e:/\)/,eB:!0,eE:!0,c:s}],i:/\[|%/},{b:/\$[(.]/},e.METHOD_GUARD,{cN:"class",bK:"class",e:/[{;=]/,eE:!0,i:/[:"\[\]]/,c:[{bK:"extends"},e.UTM]},{bK:"constructor",e:/\{/,eE:!0}],i:/#(?!!)/}});hljs.registerLanguage("coffeescript",function(e){var c={keyword:"in if for while finally new do return else break catch instanceof throw try this switch continue typeof delete debugger super yield import export from as default await then unless until loop of by when and or is isnt not",literal:"true false null undefined yes no on off",built_in:"npm require console print module global window document"},n="[A-Za-z$_][0-9A-Za-z$_]*",r={cN:"subst",b:/#\{/,e:/}/,k:c},i=[e.BNM,e.inherit(e.CNM,{starts:{e:"(\\s*/)?",r:0}}),{cN:"string",v:[{b:/'''/,e:/'''/,c:[e.BE]},{b:/'/,e:/'/,c:[e.BE]},{b:/"""/,e:/"""/,c:[e.BE,r]},{b:/"/,e:/"/,c:[e.BE,r]}]},{cN:"regexp",v:[{b:"///",e:"///",c:[r,e.HCM]},{b:"//[gim]*",r:0},{b:/\/(?![ *])(\\\/|.)*?\/[gim]*(?=\W|$)/}]},{b:"@"+n},{sL:"javascript",eB:!0,eE:!0,v:[{b:"```",e:"```"},{b:"`",e:"`"}]}];r.c=i;var s=e.inherit(e.TM,{b:n}),t="(\\(.*\\))?\\s*\\B[-=]>",o={cN:"params",b:"\\([^\\(]",rB:!0,c:[{b:/\(/,e:/\)/,k:c,c:["self"].concat(i)}]};return{aliases:["coffee","cson","iced"],k:c,i:/\/\*/,c:i.concat([e.C("###","###"),e.HCM,{cN:"function",b:"^\\s*"+n+"\\s*=\\s*"+t,e:"[-=]>",rB:!0,c:[s,o]},{b:/[:\(,=]\s*/,r:0,c:[{cN:"function",b:t,e:"[-=]>",rB:!0,c:[o]}]},{cN:"class",bK:"class",e:"$",i:/[:="\[\]]/,c:[{bK:"extends",eW:!0,i:/[:="\[\]]/,c:[s]},s]},{b:n+":",e:":",rB:!0,rE:!0,r:0}])}});hljs.registerLanguage("ruby",function(e){var b="[a-zA-Z_]\\w*[!?=]?|[-+~]\\@|<<|>>|=~|===?|<=>|[<>]=?|\\*\\*|[-/+%^&*~`|]|\\[\\]=?",r={keyword:"and then defined module in return redo if BEGIN retry end for self when next until do begin unless END rescue else break undef not super class case require yield alias while ensure elsif or include attr_reader attr_writer attr_accessor",literal:"true false nil"},c={cN:"doctag",b:"@[A-Za-z]+"},a={b:"#<",e:">"},s=[e.C("#","$",{c:[c]}),e.C("^\\=begin","^\\=end",{c:[c],r:10}),e.C("^__END__","\\n$")],n={cN:"subst",b:"#\\{",e:"}",k:r},t={cN:"string",c:[e.BE,n],v:[{b:/'/,e:/'/},{b:/"/,e:/"/},{b:/`/,e:/`/},{b:"%[qQwWx]?\\(",e:"\\)"},{b:"%[qQwWx]?\\[",e:"\\]"},{b:"%[qQwWx]?{",e:"}"},{b:"%[qQwWx]?<",e:">"},{b:"%[qQwWx]?/",e:"/"},{b:"%[qQwWx]?%",e:"%"},{b:"%[qQwWx]?-",e:"-"},{b:"%[qQwWx]?\\|",e:"\\|"},{b:/\B\?(\\\d{1,3}|\\x[A-Fa-f0-9]{1,2}|\\u[A-Fa-f0-9]{4}|\\?\S)\b/},{b:/<<(-?)\w+$/,e:/^\s*\w+$/}]},i={cN:"params",b:"\\(",e:"\\)",endsParent:!0,k:r},d=[t,a,{cN:"class",bK:"class module",e:"$|;",i:/=/,c:[e.inherit(e.TM,{b:"[A-Za-z_]\\w*(::\\w+)*(\\?|\\!)?"}),{b:"<\\s*",c:[{b:"("+e.IR+"::)?"+e.IR}]}].concat(s)},{cN:"function",bK:"def",e:"$|;",c:[e.inherit(e.TM,{b:b}),i].concat(s)},{b:e.IR+"::"},{cN:"symbol",b:e.UIR+"(\\!|\\?)?:",r:0},{cN:"symbol",b:":(?!\\s)",c:[t,{b:b}],r:0},{cN:"number",b:"(\\b0[0-7_]+)|(\\b0x[0-9a-fA-F_]+)|(\\b[1-9][0-9_]*(\\.[0-9_]+)?)|[0_]\\b",r:0},{b:"(\\$\\W)|((\\$|\\@\\@?)(\\w+))"},{cN:"params",b:/\|/,e:/\|/,k:r},{b:"("+e.RSR+"|unless)\\s*",k:"unless",c:[a,{cN:"regexp",c:[e.BE,n],i:/\n/,v:[{b:"/",e:"/[a-z]*"},{b:"%r{",e:"}[a-z]*"},{b:"%r\\(",e:"\\)[a-z]*"},{b:"%r!",e:"![a-z]*"},{b:"%r\\[",e:"\\][a-z]*"}]}].concat(s),r:0}].concat(s);n.c=d,i.c=d;var l="[>?]>",o="[\\w#]+\\(\\w+\\):\\d+:\\d+>",u="(\\w+-)?\\d+\\.\\d+\\.\\d(p\\d+)?[^>]+>",w=[{b:/^\s*=>/,starts:{e:"$",c:d}},{cN:"meta",b:"^("+l+"|"+o+"|"+u+")",starts:{e:"$",c:d}}];return{aliases:["rb","gemspec","podspec","thor","irb"],k:r,i:/\/\*/,c:s.concat(w).concat(d)}});hljs.registerLanguage("sql",function(e){var t=e.C("--","$");return{cI:!0,i:/[<>{}*#]/,c:[{bK:"begin end start commit rollback savepoint lock alter create drop rename call delete do handler insert load replace select truncate update set show pragma grant merge describe use explain help declare prepare execute deallocate release unlock purge reset change stop analyze cache flush optimize repair kill install uninstall checksum restore check backup revoke comment",e:/;/,eW:!0,l:/[\w\.]+/,k:{keyword:"abort abs absolute acc acce accep accept access accessed accessible account acos action activate add addtime admin administer advanced advise aes_decrypt aes_encrypt after agent aggregate ali alia alias allocate allow alter always analyze ancillary and any anydata anydataset anyschema anytype apply archive archived archivelog are as asc ascii asin assembly assertion associate asynchronous at atan atn2 attr attri attrib attribu attribut attribute attributes audit authenticated authentication authid authors auto autoallocate autodblink autoextend automatic availability avg backup badfile basicfile before begin beginning benchmark between bfile bfile_base big bigfile bin binary_double binary_float binlog bit_and bit_count bit_length bit_or bit_xor bitmap blob_base block blocksize body both bound buffer_cache buffer_pool build bulk by byte byteordermark bytes cache caching call calling cancel capacity cascade cascaded case cast catalog category ceil ceiling chain change changed char_base char_length character_length characters characterset charindex charset charsetform charsetid check checksum checksum_agg child choose chr chunk class cleanup clear client clob clob_base clone close cluster_id cluster_probability cluster_set clustering coalesce coercibility col collate collation collect colu colum column column_value columns columns_updated comment commit compact compatibility compiled complete composite_limit compound compress compute concat concat_ws concurrent confirm conn connec connect connect_by_iscycle connect_by_isleaf connect_by_root connect_time connection consider consistent constant constraint constraints constructor container content contents context contributors controlfile conv convert convert_tz corr corr_k corr_s corresponding corruption cos cost count count_big counted covar_pop covar_samp cpu_per_call cpu_per_session crc32 create creation critical cross cube cume_dist curdate current current_date current_time current_timestamp current_user cursor curtime customdatum cycle data database databases datafile datafiles datalength date_add date_cache date_format date_sub dateadd datediff datefromparts datename datepart datetime2fromparts day day_to_second dayname dayofmonth dayofweek dayofyear days db_role_change dbtimezone ddl deallocate declare decode decompose decrement decrypt deduplicate def defa defau defaul default defaults deferred defi defin define degrees delayed delegate delete delete_all delimited demand dense_rank depth dequeue des_decrypt des_encrypt des_key_file desc descr descri describ describe descriptor deterministic diagnostics difference dimension direct_load directory disable disable_all disallow disassociate discardfile disconnect diskgroup distinct distinctrow distribute distributed div do document domain dotnet double downgrade drop dumpfile duplicate duration each edition editionable editions element ellipsis else elsif elt empty enable enable_all enclosed encode encoding encrypt end end-exec endian enforced engine engines enqueue enterprise entityescaping eomonth error errors escaped evalname evaluate event eventdata events except exception exceptions exchange exclude excluding execu execut execute exempt exists exit exp expire explain export export_set extended extent external external_1 external_2 externally extract failed failed_login_attempts failover failure far fast feature_set feature_value fetch field fields file file_name_convert filesystem_like_logging final finish first first_value fixed flash_cache flashback floor flush following follows for forall force form forma format found found_rows freelist freelists freepools fresh from from_base64 from_days ftp full function general generated get get_format get_lock getdate getutcdate global global_name globally go goto grant grants greatest group group_concat group_id grouping grouping_id groups gtid_subtract guarantee guard handler hash hashkeys having hea head headi headin heading heap help hex hierarchy high high_priority hosts hour http id ident_current ident_incr ident_seed identified identity idle_time if ifnull ignore iif ilike ilm immediate import in include including increment index indexes indexing indextype indicator indices inet6_aton inet6_ntoa inet_aton inet_ntoa infile initial initialized initially initrans inmemory inner innodb input insert install instance instantiable instr interface interleaved intersect into invalidate invisible is is_free_lock is_ipv4 is_ipv4_compat is_not is_not_null is_used_lock isdate isnull isolation iterate java join json json_exists keep keep_duplicates key keys kill language large last last_day last_insert_id last_value lax lcase lead leading least leaves left len lenght length less level levels library like like2 like4 likec limit lines link list listagg little ln load load_file lob lobs local localtime localtimestamp locate locator lock locked log log10 log2 logfile logfiles logging logical logical_reads_per_call logoff logon logs long loop low low_priority lower lpad lrtrim ltrim main make_set makedate maketime managed management manual map mapping mask master master_pos_wait match matched materialized max maxextents maximize maxinstances maxlen maxlogfiles maxloghistory maxlogmembers maxsize maxtrans md5 measures median medium member memcompress memory merge microsecond mid migration min minextents minimum mining minus minute minvalue missing mod mode model modification modify module monitoring month months mount move movement multiset mutex name name_const names nan national native natural nav nchar nclob nested never new newline next nextval no no_write_to_binlog noarchivelog noaudit nobadfile nocheck nocompress nocopy nocycle nodelay nodiscardfile noentityescaping noguarantee nokeep nologfile nomapping nomaxvalue nominimize nominvalue nomonitoring none noneditionable nonschema noorder nopr nopro noprom nopromp noprompt norely noresetlogs noreverse normal norowdependencies noschemacheck noswitch not nothing notice notrim novalidate now nowait nth_value nullif nulls num numb numbe nvarchar nvarchar2 object ocicoll ocidate ocidatetime ociduration ociinterval ociloblocator ocinumber ociref ocirefcursor ocirowid ocistring ocitype oct octet_length of off offline offset oid oidindex old on online only opaque open operations operator optimal optimize option optionally or oracle oracle_date oradata ord ordaudio orddicom orddoc order ordimage ordinality ordvideo organization orlany orlvary out outer outfile outline output over overflow overriding package pad parallel parallel_enable parameters parent parse partial partition partitions pascal passing password password_grace_time password_lock_time password_reuse_max password_reuse_time password_verify_function patch path patindex pctincrease pctthreshold pctused pctversion percent percent_rank percentile_cont percentile_disc performance period period_add period_diff permanent physical pi pipe pipelined pivot pluggable plugin policy position post_transaction pow power pragma prebuilt precedes preceding precision prediction prediction_cost prediction_details prediction_probability prediction_set prepare present preserve prior priority private private_sga privileges procedural procedure procedure_analyze processlist profiles project prompt protection public publishingservername purge quarter query quick quiesce quota quotename radians raise rand range rank raw read reads readsize rebuild record records recover recovery recursive recycle redo reduced ref reference referenced references referencing refresh regexp_like register regr_avgx regr_avgy regr_count regr_intercept regr_r2 regr_slope regr_sxx regr_sxy reject rekey relational relative relaylog release release_lock relies_on relocate rely rem remainder rename repair repeat replace replicate replication required reset resetlogs resize resource respect restore restricted result result_cache resumable resume retention return returning returns reuse reverse revoke right rlike role roles rollback rolling rollup round row row_count rowdependencies rowid rownum rows rtrim rules safe salt sample save savepoint sb1 sb2 sb4 scan schema schemacheck scn scope scroll sdo_georaster sdo_topo_geometry search sec_to_time second section securefile security seed segment select self sequence sequential serializable server servererror session session_user sessions_per_user set sets settings sha sha1 sha2 share shared shared_pool short show shrink shutdown si_averagecolor si_colorhistogram si_featurelist si_positionalcolor si_stillimage si_texture siblings sid sign sin size size_t sizes skip slave sleep smalldatetimefromparts smallfile snapshot some soname sort soundex source space sparse spfile split sql sql_big_result sql_buffer_result sql_cache sql_calc_found_rows sql_small_result sql_variant_property sqlcode sqldata sqlerror sqlname sqlstate sqrt square standalone standby start starting startup statement static statistics stats_binomial_test stats_crosstab stats_ks_test stats_mode stats_mw_test stats_one_way_anova stats_t_test_ stats_t_test_indep stats_t_test_one stats_t_test_paired stats_wsr_test status std stddev stddev_pop stddev_samp stdev stop storage store stored str str_to_date straight_join strcmp strict string struct stuff style subdate subpartition subpartitions substitutable substr substring subtime subtring_index subtype success sum suspend switch switchoffset switchover sync synchronous synonym sys sys_xmlagg sysasm sysaux sysdate sysdatetimeoffset sysdba sysoper system system_user sysutcdatetime table tables tablespace tan tdo template temporary terminated tertiary_weights test than then thread through tier ties time time_format time_zone timediff timefromparts timeout timestamp timestampadd timestampdiff timezone_abbr timezone_minute timezone_region to to_base64 to_date to_days to_seconds todatetimeoffset trace tracking transaction transactional translate translation treat trigger trigger_nestlevel triggers trim truncate try_cast try_convert try_parse type ub1 ub2 ub4 ucase unarchived unbounded uncompress under undo unhex unicode uniform uninstall union unique unix_timestamp unknown unlimited unlock unpivot unrecoverable unsafe unsigned until untrusted unusable unused update updated upgrade upped upper upsert url urowid usable usage use use_stored_outlines user user_data user_resources users using utc_date utc_timestamp uuid uuid_short validate validate_password_strength validation valist value values var var_samp varcharc vari varia variab variabl variable variables variance varp varraw varrawc varray verify version versions view virtual visible void wait wallet warning warnings week weekday weekofyear wellformed when whene whenev wheneve whenever where while whitespace with within without work wrapped xdb xml xmlagg xmlattributes xmlcast xmlcolattval xmlelement xmlexists xmlforest xmlindex xmlnamespaces xmlpi xmlquery xmlroot xmlschema xmlserialize xmltable xmltype xor year year_to_month years yearweek",literal:"true false null",built_in:"array bigint binary bit blob boolean char character date dec decimal float int int8 integer interval number numeric real record serial serial8 smallint text varchar varying void"},c:[{cN:"string",b:"'",e:"'",c:[e.BE,{b:"''"}]},{cN:"string",b:'"',e:'"',c:[e.BE,{b:'""'}]},{cN:"string",b:"`",e:"`",c:[e.BE]},e.CNM,e.CBCM,t]},e.CBCM,t]}});hljs.registerLanguage("http",function(e){var t="HTTP/[0-9\\.]+";return{aliases:["https"],i:"\\S",c:[{b:"^"+t,e:"$",c:[{cN:"number",b:"\\b\\d{3}\\b"}]},{b:"^[A-Z]+ (.*?) "+t+"$",rB:!0,e:"$",c:[{cN:"string",b:" ",e:" ",eB:!0,eE:!0},{b:t},{cN:"keyword",b:"[A-Z]+"}]},{cN:"attribute",b:"^\\w",e:": ",eE:!0,i:"\\n|\\s|=",starts:{e:"$",r:0}},{b:"\\n\\n",starts:{sL:[],eW:!0}}]}});hljs.registerLanguage("json",function(e){var i={literal:"true false null"},n=[e.QSM,e.CNM],r={e:",",eW:!0,eE:!0,c:n,k:i},t={b:"{",e:"}",c:[{cN:"attr",b:/"/,e:/"/,c:[e.BE],i:"\\n"},e.inherit(r,{b:/:/})],i:"\\S"},c={b:"\\[",e:"\\]",c:[e.inherit(r)],i:"\\S"};return n.splice(n.length,0,t,c),{c:n,k:i,i:"\\S"}});hljs.registerLanguage("bash",function(e){var t={cN:"variable",v:[{b:/\$[\w\d#@][\w\d_]*/},{b:/\$\{(.*?)}/}]},s={cN:"string",b:/"/,e:/"/,c:[e.BE,t,{cN:"variable",b:/\$\(/,e:/\)/,c:[e.BE]}]},a={cN:"string",b:/'/,e:/'/};return{aliases:["sh","zsh"],l:/\b-?[a-z\._]+\b/,k:{keyword:"if then else elif fi for while in do done case esac function",literal:"true false",built_in:"break cd continue eval exec exit export getopts hash pwd readonly return shift test times trap umask unset alias bind builtin caller command declare echo enable help let local logout mapfile printf read readarray source type typeset ulimit unalias set shopt autoload bg bindkey bye cap chdir clone comparguments compcall compctl compdescribe compfiles compgroups compquote comptags comptry compvalues dirs disable disown echotc echoti emulate fc fg float functions getcap getln history integer jobs kill limit log noglob popd print pushd pushln rehash sched setcap setopt stat suspend ttyctl unfunction unhash unlimit unsetopt vared wait whence where which zcompile zformat zftp zle zmodload zparseopts zprof zpty zregexparse zsocket zstyle ztcp",_:"-ne -eq -lt -gt -f -d -e -s -l -a"},c:[{cN:"meta",b:/^#![^\n]+sh\s*$/,r:10},{cN:"function",b:/\w[\w\d_]*\s*\(\s*\)\s*\{/,rB:!0,c:[e.inherit(e.TM,{b:/\w[\w\d_]*/})],r:0},e.HCM,s,a,t]}});hljs.registerLanguage("ini",function(e){var b={cN:"string",c:[e.BE],v:[{b:"'''",e:"'''",r:10},{b:'"""',e:'"""',r:10},{b:'"',e:'"'},{b:"'",e:"'"}]};return{aliases:["toml"],cI:!0,i:/\S/,c:[e.C(";","$"),e.HCM,{cN:"section",b:/^\s*\[+/,e:/\]+/},{b:/^[a-z0-9\[\]_-]+\s*=\s*/,e:"$",rB:!0,c:[{cN:"attr",b:/[a-z0-9\[\]_-]+/},{b:/=/,eW:!0,r:0,c:[{cN:"literal",b:/\bon|off|true|false|yes|no\b/},{cN:"variable",v:[{b:/\$[\w\d"][\w\d_]*/},{b:/\$\{(.*?)}/}]},b,{cN:"number",b:/([\+\-]+)?[\d]+_[\d_]+/},e.NM]}]}]}});hljs.registerLanguage("shell",function(s){return{aliases:["console"],c:[{cN:"meta",b:"^\\s{0,3}[\\w\\d\\[\\]()@-]*[>%$#]",starts:{e:"$",sL:"bash"}}]}});hljs.registerLanguage("crystal",function(e){function b(e,b){var r=[{b:e,e:b}];return r[0].c=r,r}var r="(_[uif](8|16|32|64))?",c="[a-zA-Z_]\\w*[!?=]?",i="!=|!==|%|%=|&|&&|&=|\\*|\\*=|\\+|\\+=|,|-|-=|/=|/|:|;|<<|<<=|<=|<|===|==|=|>>>=|>>=|>=|>>>|>>|>|\\[|\\{|\\(|\\^|\\^=|\\||\\|=|\\|\\||~",n="[a-zA-Z_]\\w*[!?=]?|[-+~]\\@|<<|>>|=~|===?|<=>|[<>]=?|\\*\\*|[-/+%^&*~`|]|\\[\\][=?]?",s={keyword:"abstract alias as as? asm begin break case class def do else elsif end ensure enum extend for fun if include instance_sizeof is_a? lib macro module next nil? of out pointerof private protected rescue responds_to? return require select self sizeof struct super then type typeof union uninitialized unless until when while with yield __DIR__ __END_LINE__ __FILE__ __LINE__",literal:"false nil true"},t={cN:"subst",b:"#{",e:"}",k:s},a={cN:"template-variable",v:[{b:"\\{\\{",e:"\\}\\}"},{b:"\\{%",e:"%\\}"}],k:s},l={cN:"string",c:[e.BE,t],v:[{b:/'/,e:/'/},{b:/"/,e:/"/},{b:/`/,e:/`/},{b:"%w?\\(",e:"\\)",c:b("\\(","\\)")},{b:"%w?\\[",e:"\\]",c:b("\\[","\\]")},{b:"%w?{",e:"}",c:b("{","}")},{b:"%w?<",e:">",c:b("<",">")},{b:"%w?/",e:"/"},{b:"%w?%",e:"%"},{b:"%w?-",e:"-"},{b:"%w?\\|",e:"\\|"},{b:/<<-\w+$/,e:/^\s*\w+$/}],r:0},u={cN:"string",v:[{b:"%q\\(",e:"\\)",c:b("\\(","\\)")},{b:"%q\\[",e:"\\]",c:b("\\[","\\]")},{b:"%q{",e:"}",c:b("{","}")},{b:"%q<",e:">",c:b("<",">")},{b:"%q/",e:"/"},{b:"%q%",e:"%"},{b:"%q-",e:"-"},{b:"%q\\|",e:"\\|"},{b:/<<-'\w+'$/,e:/^\s*\w+$/}],r:0},_={b:"("+i+")\\s*",c:[{cN:"regexp",c:[e.BE,t],v:[{b:"//[a-z]*",r:0},{b:"/",e:"/[a-z]*"},{b:"%r\\(",e:"\\)",c:b("\\(","\\)")},{b:"%r\\[",e:"\\]",c:b("\\[","\\]")},{b:"%r{",e:"}",c:b("{","}")},{b:"%r<",e:">",c:b("<",">")},{b:"%r/",e:"/"},{b:"%r%",e:"%"},{b:"%r-",e:"-"},{b:"%r\\|",e:"\\|"}]}],r:0},o={cN:"regexp",c:[e.BE,t],v:[{b:"%r\\(",e:"\\)",c:b("\\(","\\)")},{b:"%r\\[",e:"\\]",c:b("\\[","\\]")},{b:"%r{",e:"}",c:b("{","}")},{b:"%r<",e:">",c:b("<",">")},{b:"%r/",e:"/"},{b:"%r%",e:"%"},{b:"%r-",e:"-"},{b:"%r\\|",e:"\\|"}],r:0},w={cN:"meta",b:"@\\[",e:"\\]",c:[e.inherit(e.QSM,{cN:"meta-string"})]},f=[a,l,u,_,o,w,e.HCM,{cN:"class",bK:"class module struct",e:"$|;",i:/=/,c:[e.HCM,e.inherit(e.TM,{b:"[A-Za-z_]\\w*(::\\w+)*(\\?|\\!)?"}),{b:"<"}]},{cN:"class",bK:"lib enum union",e:"$|;",i:/=/,c:[e.HCM,e.inherit(e.TM,{b:"[A-Za-z_]\\w*(::\\w+)*(\\?|\\!)?"})],r:10},{cN:"function",bK:"def",e:/\B\b/,c:[e.inherit(e.TM,{b:n,endsParent:!0})]},{cN:"function",bK:"fun macro",e:/\B\b/,c:[e.inherit(e.TM,{b:n,endsParent:!0})],r:5},{cN:"symbol",b:e.UIR+"(\\!|\\?)?:",r:0},{cN:"symbol",b:":",c:[l,{b:n}],r:0},{cN:"number",v:[{b:"\\b0b([01_]*[01])"+r},{b:"\\b0o([0-7_]*[0-7])"+r},{b:"\\b0x([A-Fa-f0-9_]*[A-Fa-f0-9])"+r},{b:"\\b(([0-9][0-9_]*[0-9]|[0-9])(\\.[0-9_]*[0-9])?([eE][+-]?[0-9_]*[0-9])?)"+r}],r:0}];return t.c=f,a.c=f.slice(1),{aliases:["cr"],l:c,k:s,c:f}});hljs.registerLanguage("xml",function(s){var e="[A-Za-z0-9\\._:-]+",t={eW:!0,i:/`]+/}]}]}]};return{aliases:["html","xhtml","rss","atom","xjb","xsd","xsl","plist"],cI:!0,c:[{cN:"meta",b:"",r:10,c:[{b:"\\[",e:"\\]"}]},s.C("",{r:10}),{b:"<\\!\\[CDATA\\[",e:"\\]\\]>",r:10},{b:/<\?(php)?/,e:/\?>/,sL:"php",c:[{b:"/\\*",e:"\\*/",skip:!0}]},{cN:"tag",b:"|$)",e:">",k:{name:"style"},c:[t],starts:{e:"",rE:!0,sL:["css","xml"]}},{cN:"tag",b:"|$)",e:">",k:{name:"script"},c:[t],starts:{e:"",rE:!0,sL:["actionscript","javascript","handlebars","xml"]}},{cN:"meta",v:[{b:/<\?xml/,e:/\?>/,r:10},{b:/<\?\w+/,e:/\?>/}]},{cN:"tag",b:"",c:[{cN:"name",b:/[^\/><\s]+/,r:0},t]}]}});hljs.registerLanguage("markdown",function(e){return{aliases:["md","mkdown","mkd"],c:[{cN:"section",v:[{b:"^#{1,6}",e:"$"},{b:"^.+?\\n[=-]{2,}$"}]},{b:"<",e:">",sL:"xml",r:0},{cN:"bullet",b:"^([*+-]|(\\d+\\.))\\s+"},{cN:"strong",b:"[*_]{2}.+?[*_]{2}"},{cN:"emphasis",v:[{b:"\\*.+?\\*"},{b:"_.+?_",r:0}]},{cN:"quote",b:"^>\\s+",e:"$"},{cN:"code",v:[{b:"^```w*s*$",e:"^```s*$"},{b:"`.+?`"},{b:"^( {4}| )",e:"$",r:0}]},{b:"^[-\\*]{3,}",e:"$"},{b:"\\[.+?\\][\\(\\[].*?[\\)\\]]",rB:!0,c:[{cN:"string",b:"\\[",e:"\\]",eB:!0,rE:!0,r:0},{cN:"link",b:"\\]\\(",e:"\\)",eB:!0,eE:!0},{cN:"symbol",b:"\\]\\[",e:"\\]",eB:!0,eE:!0}],r:10},{b:/^\[[^\n]+\]:/,rB:!0,c:[{cN:"symbol",b:/\[/,e:/\]/,eB:!0,eE:!0},{cN:"link",b:/:\s*/,e:/$/,eB:!0}]}]}});hljs.registerLanguage("cs",function(e){var i={keyword:"abstract as base bool break byte case catch char checked const continue decimal default delegate do double enum event explicit extern finally fixed float for foreach goto if implicit in int interface internal is lock long nameof object operator out override params private protected public readonly ref sbyte sealed short sizeof stackalloc static string struct switch this try typeof uint ulong unchecked unsafe ushort using virtual void volatile while add alias ascending async await by descending dynamic equals from get global group into join let on orderby partial remove select set value var where yield",literal:"null false true"},t={cN:"string",b:'@"',e:'"',c:[{b:'""'}]},r=e.inherit(t,{i:/\n/}),a={cN:"subst",b:"{",e:"}",k:i},c=e.inherit(a,{i:/\n/}),n={cN:"string",b:/\$"/,e:'"',i:/\n/,c:[{b:"{{"},{b:"}}"},e.BE,c]},s={cN:"string",b:/\$@"/,e:'"',c:[{b:"{{"},{b:"}}"},{b:'""'},a]},o=e.inherit(s,{i:/\n/,c:[{b:"{{"},{b:"}}"},{b:'""'},c]});a.c=[s,n,t,e.ASM,e.QSM,e.CNM,e.CBCM],c.c=[o,n,r,e.ASM,e.QSM,e.CNM,e.inherit(e.CBCM,{i:/\n/})];var l={v:[s,n,t,e.ASM,e.QSM]},b=e.IR+"(<"+e.IR+"(\\s*,\\s*"+e.IR+")*>)?(\\[\\])?";return{aliases:["csharp"],k:i,i:/::/,c:[e.C("///","$",{rB:!0,c:[{cN:"doctag",v:[{b:"///",r:0},{b:""},{b:""}]}]}),e.CLCM,e.CBCM,{cN:"meta",b:"#",e:"$",k:{"meta-keyword":"if else elif endif define undef warning error line region endregion pragma checksum"}},l,e.CNM,{bK:"class interface",e:/[{;=]/,i:/[^\s:]/,c:[e.TM,e.CLCM,e.CBCM]},{bK:"namespace",e:/[{;=]/,i:/[^\s:]/,c:[e.inherit(e.TM,{b:"[a-zA-Z](\\.?\\w)*"}),e.CLCM,e.CBCM]},{cN:"meta",b:"^\\s*\\[",eB:!0,e:"\\]",eE:!0,c:[{cN:"meta-string",b:/"/,e:/"/}]},{bK:"new return throw await else",r:0},{cN:"function",b:"("+b+"\\s+)+"+e.IR+"\\s*\\(",rB:!0,e:/[{;=]/,eE:!0,k:i,c:[{b:e.IR+"\\s*\\(",rB:!0,c:[e.TM],r:0},{cN:"params",b:/\(/,e:/\)/,eB:!0,eE:!0,k:i,r:0,c:[l,e.CNM,e.CBCM]},e.CLCM,e.CBCM]}]}});hljs.registerLanguage("apache",function(e){var r={cN:"number",b:"[\\$%]\\d+"};return{aliases:["apacheconf"],cI:!0,c:[e.HCM,{cN:"section",b:""},{cN:"attribute",b:/\w+/,r:0,k:{nomarkup:"order deny allow setenv rewriterule rewriteengine rewritecond documentroot sethandler errordocument loadmodule options header listen serverroot servername"},starts:{e:/$/,r:0,k:{literal:"on off all"},c:[{cN:"meta",b:"\\s\\[",e:"\\]$"},{cN:"variable",b:"[\\$%]\\{",e:"\\}",c:["self",r]},r,e.QSM]}}],i:/\S/}});hljs.registerLanguage("css",function(e){var c="[a-zA-Z-][a-zA-Z0-9_-]*",t={b:/[A-Z\_\.\-]+\s*:/,rB:!0,e:";",eW:!0,c:[{cN:"attribute",b:/\S/,e:":",eE:!0,starts:{eW:!0,eE:!0,c:[{b:/[\w-]+\(/,rB:!0,c:[{cN:"built_in",b:/[\w-]+/},{b:/\(/,e:/\)/,c:[e.ASM,e.QSM]}]},e.CSSNM,e.QSM,e.ASM,e.CBCM,{cN:"number",b:"#[0-9A-Fa-f]+"},{cN:"meta",b:"!important"}]}}]};return{cI:!0,i:/[=\/|'\$]/,c:[e.CBCM,{cN:"selector-id",b:/#[A-Za-z0-9_-]+/},{cN:"selector-class",b:/\.[A-Za-z0-9_-]+/},{cN:"selector-attr",b:/\[/,e:/\]/,i:"$"},{cN:"selector-pseudo",b:/:(:)?[a-zA-Z0-9\_\-\+\(\)"'.]+/},{b:"@(font-face|page)",l:"[a-z-]+",k:"font-face page"},{b:"@",e:"[{;]",i:/:/,c:[{cN:"keyword",b:/\w+/},{b:/\s/,eW:!0,eE:!0,r:0,c:[e.ASM,e.QSM,e.CSSNM]}]},{cN:"selector-tag",b:c,r:0},{b:"{",e:"}",i:/\S/,c:[e.CBCM,t]}]}});hljs.registerLanguage("python",function(e){var r={keyword:"and elif is global as in if from raise for except finally print import pass return exec else break not with class assert yield try while continue del or def lambda async await nonlocal|10 None True False",built_in:"Ellipsis NotImplemented"},b={cN:"meta",b:/^(>>>|\.\.\.) /},c={cN:"subst",b:/\{/,e:/\}/,k:r,i:/#/},a={cN:"string",c:[e.BE],v:[{b:/(u|b)?r?'''/,e:/'''/,c:[b],r:10},{b:/(u|b)?r?"""/,e:/"""/,c:[b],r:10},{b:/(fr|rf|f)'''/,e:/'''/,c:[b,c]},{b:/(fr|rf|f)"""/,e:/"""/,c:[b,c]},{b:/(u|r|ur)'/,e:/'/,r:10},{b:/(u|r|ur)"/,e:/"/,r:10},{b:/(b|br)'/,e:/'/},{b:/(b|br)"/,e:/"/},{b:/(fr|rf|f)'/,e:/'/,c:[c]},{b:/(fr|rf|f)"/,e:/"/,c:[c]},e.ASM,e.QSM]},s={cN:"number",r:0,v:[{b:e.BNR+"[lLjJ]?"},{b:"\\b(0o[0-7]+)[lLjJ]?"},{b:e.CNR+"[lLjJ]?"}]},i={cN:"params",b:/\(/,e:/\)/,c:["self",b,s,a]};return c.c=[a,s,b],{aliases:["py","gyp"],k:r,i:/(<\/|->|\?)|=>/,c:[b,s,a,e.HCM,{v:[{cN:"function",bK:"def"},{cN:"class",bK:"class"}],e:/:/,i:/[${=;\n,]/,c:[e.UTM,i,{b:/->/,eW:!0,k:"None"}]},{cN:"meta",b:/^[\t ]*@/,e:/$/},{b:/\b(print|exec)\(/}]}});hljs.registerLanguage("cpp",function(t){var e={cN:"keyword",b:"\\b[a-z\\d_]*_t\\b"},r={cN:"string",v:[{b:'(u8?|U)?L?"',e:'"',i:"\\n",c:[t.BE]},{b:'(u8?|U)?R"',e:'"',c:[t.BE]},{b:"'\\\\?.",e:"'",i:"."}]},s={cN:"number",v:[{b:"\\b(0b[01']+)"},{b:"(-?)\\b([\\d']+(\\.[\\d']*)?|\\.[\\d']+)(u|U|l|L|ul|UL|f|F|b|B)"},{b:"(-?)(\\b0[xX][a-fA-F0-9']+|(\\b[\\d']+(\\.[\\d']*)?|\\.[\\d']+)([eE][-+]?[\\d']+)?)"}],r:0},i={cN:"meta",b:/#\s*[a-z]+\b/,e:/$/,k:{"meta-keyword":"if else elif endif define undef warning error line pragma ifdef ifndef include"},c:[{b:/\\\n/,r:0},t.inherit(r,{cN:"meta-string"}),{cN:"meta-string",b:/<[^\n>]*>/,e:/$/,i:"\\n"},t.CLCM,t.CBCM]},a=t.IR+"\\s*\\(",c={keyword:"int float while private char catch import module export virtual operator sizeof dynamic_cast|10 typedef const_cast|10 const for static_cast|10 union namespace unsigned long volatile static protected bool template mutable if public friend do goto auto void enum else break extern using asm case typeid short reinterpret_cast|10 default double register explicit signed typename try this switch continue inline delete alignof constexpr decltype noexcept static_assert thread_local restrict _Bool complex _Complex _Imaginary atomic_bool atomic_char atomic_schar atomic_uchar atomic_short atomic_ushort atomic_int atomic_uint atomic_long atomic_ulong atomic_llong atomic_ullong new throw return and or not",built_in:"std string cin cout cerr clog stdin stdout stderr stringstream istringstream ostringstream auto_ptr deque list queue stack vector map set bitset multiset multimap unordered_set unordered_map unordered_multiset unordered_multimap array shared_ptr abort abs acos asin atan2 atan calloc ceil cosh cos exit exp fabs floor fmod fprintf fputs free frexp fscanf isalnum isalpha iscntrl isdigit isgraph islower isprint ispunct isspace isupper isxdigit tolower toupper labs ldexp log10 log malloc realloc memchr memcmp memcpy memset modf pow printf putchar puts scanf sinh sin snprintf sprintf sqrt sscanf strcat strchr strcmp strcpy strcspn strlen strncat strncmp strncpy strpbrk strrchr strspn strstr tanh tan vfprintf vprintf vsprintf endl initializer_list unique_ptr",literal:"true false nullptr NULL"},n=[e,t.CLCM,t.CBCM,s,r];return{aliases:["c","cc","h","c++","h++","hpp"],k:c,i:"",k:c,c:["self",e]},{b:t.IR+"::",k:c},{v:[{b:/=/,e:/;/},{b:/\(/,e:/\)/},{bK:"new throw return else",e:/;/}],k:c,c:n.concat([{b:/\(/,e:/\)/,k:c,c:n.concat(["self"]),r:0}]),r:0},{cN:"function",b:"("+t.IR+"[\\*&\\s]+)+"+a,rB:!0,e:/[{;=]/,eE:!0,k:c,i:/[^\w\s\*&]/,c:[{b:a,rB:!0,c:[t.TM],r:0},{cN:"params",b:/\(/,e:/\)/,k:c,r:0,c:[t.CLCM,t.CBCM,r,s,e]},t.CLCM,t.CBCM,i]},{cN:"class",bK:"class struct",e:/[{;:]/,c:[{b://,c:["self"]},t.TM]}]),exports:{preprocessor:i,strings:r,k:c}}});hljs.registerLanguage("objectivec",function(e){var t={cN:"built_in",b:"\\b(AV|CA|CF|CG|CI|CL|CM|CN|CT|MK|MP|MTK|MTL|NS|SCN|SK|UI|WK|XC)\\w+"},_={keyword:"int float while char export sizeof typedef const struct for union unsigned long volatile static bool mutable if do return goto void enum else break extern asm case short default double register explicit signed typename this switch continue wchar_t inline readonly assign readwrite self @synchronized id typeof nonatomic super unichar IBOutlet IBAction strong weak copy in out inout bycopy byref oneway __strong __weak __block __autoreleasing @private @protected @public @try @property @end @throw @catch @finally @autoreleasepool @synthesize @dynamic @selector @optional @required @encode @package @import @defs @compatibility_alias __bridge __bridge_transfer __bridge_retained __bridge_retain __covariant __contravariant __kindof _Nonnull _Nullable _Null_unspecified __FUNCTION__ __PRETTY_FUNCTION__ __attribute__ getter setter retain unsafe_unretained nonnull nullable null_unspecified null_resettable class instancetype NS_DESIGNATED_INITIALIZER NS_UNAVAILABLE NS_REQUIRES_SUPER NS_RETURNS_INNER_POINTER NS_INLINE NS_AVAILABLE NS_DEPRECATED NS_ENUM NS_OPTIONS NS_SWIFT_UNAVAILABLE NS_ASSUME_NONNULL_BEGIN NS_ASSUME_NONNULL_END NS_REFINED_FOR_SWIFT NS_SWIFT_NAME NS_SWIFT_NOTHROW NS_DURING NS_HANDLER NS_ENDHANDLER NS_VALUERETURN NS_VOIDRETURN",literal:"false true FALSE TRUE nil YES NO NULL",built_in:"BOOL dispatch_once_t dispatch_queue_t dispatch_sync dispatch_async dispatch_once"},i=/[a-zA-Z@][a-zA-Z0-9_]*/,n="@interface @class @protocol @implementation";return{aliases:["mm","objc","obj-c"],k:_,l:i,i:""}]}]},{cN:"class",b:"("+n.split(" ").join("|")+")\\b",e:"({|$)",eE:!0,k:n,l:i,c:[e.UTM]},{b:"\\."+e.UIR,r:0}]}});hljs.registerLanguage("java",function(e){var a="[À-ʸa-zA-Z_$][À-ʸa-zA-Z_$0-9]*",t=a+"(<"+a+"(\\s*,\\s*"+a+")*>)?",r="false synchronized int abstract float private char boolean static null if const for true while long strictfp finally protected import native final void enum else break transient catch instanceof byte super volatile case assert short package default double public try this switch continue throws protected public private module requires exports do",s="\\b(0[bB]([01]+[01_]+[01]+|[01]+)|0[xX]([a-fA-F0-9]+[a-fA-F0-9_]+[a-fA-F0-9]+|[a-fA-F0-9]+)|(([\\d]+[\\d_]+[\\d]+|[\\d]+)(\\.([\\d]+[\\d_]+[\\d]+|[\\d]+))?|\\.([\\d]+[\\d_]+[\\d]+|[\\d]+))([eE][-+]?\\d+)?)[lLfF]?",c={cN:"number",b:s,r:0};return{aliases:["jsp"],k:r,i:/<\/|#/,c:[e.C("/\\*\\*","\\*/",{r:0,c:[{b:/\w+@/,r:0},{cN:"doctag",b:"@[A-Za-z]+"}]}),e.CLCM,e.CBCM,e.ASM,e.QSM,{cN:"class",bK:"class interface",e:/[{;=]/,eE:!0,k:"class interface",i:/[:"\[\]]/,c:[{bK:"extends implements"},e.UTM]},{bK:"new throw return else",r:0},{cN:"function",b:"("+t+"\\s+)+"+e.UIR+"\\s*\\(",rB:!0,e:/[{;=]/,eE:!0,k:r,c:[{b:e.UIR+"\\s*\\(",rB:!0,r:0,c:[e.UTM]},{cN:"params",b:/\(/,e:/\)/,k:r,r:0,c:[e.ASM,e.QSM,e.CNM,e.CBCM]},e.CLCM,e.CBCM]},c,{cN:"meta",b:"@[A-Za-z]+"}]}});hljs.registerLanguage("nginx",function(e){var r={cN:"variable",v:[{b:/\$\d+/},{b:/\$\{/,e:/}/},{b:"[\\$\\@]"+e.UIR}]},b={eW:!0,l:"[a-z/_]+",k:{literal:"on off yes no true false none blocked debug info notice warn error crit select break last permanent redirect kqueue rtsig epoll poll /dev/poll"},r:0,i:"=>",c:[e.HCM,{cN:"string",c:[e.BE,r],v:[{b:/"/,e:/"/},{b:/'/,e:/'/}]},{b:"([a-z]+):/",e:"\\s",eW:!0,eE:!0,c:[r]},{cN:"regexp",c:[e.BE,r],v:[{b:"\\s\\^",e:"\\s|{|;",rE:!0},{b:"~\\*?\\s+",e:"\\s|{|;",rE:!0},{b:"\\*(\\.[a-z\\-]+)+"},{b:"([a-z\\-]+\\.)+\\*"}]},{cN:"number",b:"\\b\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}(:\\d{1,5})?\\b"},{cN:"number",b:"\\b\\d+[kKmMgGdshdwy]*\\b",r:0},r]};return{aliases:["nginxconf"],c:[e.HCM,{b:e.UIR+"\\s+{",rB:!0,e:"{",c:[{cN:"section",b:e.UIR}],r:0},{b:e.UIR+"\\s",e:";|{",rB:!0,c:[{cN:"attribute",b:e.UIR,starts:b}],r:0}],i:"[^\\s\\}]"}}); -------------------------------------------------------------------------------- /public/javascripts/main.js: -------------------------------------------------------------------------------- 1 | document.addEventListener("DOMContentLoaded", (event) => { 2 | toggleSecondary(); 3 | signoutListener(); 4 | runPrettify(); 5 | runHighlighter(); 6 | }); 7 | 8 | function toggleSecondary() { 9 | var button = document.getElementsByClassName("secondary-toggle")[0]; 10 | var secondary = document.getElementsByClassName("secondary")[0]; 11 | 12 | if (secondary && button) { 13 | button.addEventListener("click", () => 14 | secondary.classList.toggle("toggled-on")); 15 | } 16 | } 17 | 18 | function signoutListener() { 19 | var button = document.getElementById("sign-out-btn"); 20 | if (button) { 21 | button.addEventListener("click", (e) => { 22 | e.preventDefault(); 23 | 24 | var form = document.createElement("form"); 25 | form.method = "POST"; 26 | form.action = "/sessions"; 27 | 28 | var method = document.createElement("input"); 29 | method.name = "_method"; 30 | method.value = "delete"; 31 | form.appendChild(method); 32 | 33 | var csrf = document.createElement("input"); 34 | csrf.type = "hidden"; 35 | csrf.name = "_csrf"; 36 | csrf.value = button.getAttribute("csrf-token"); 37 | form.appendChild(csrf); 38 | 39 | document.body.appendChild(form); 40 | form.submit(); 41 | }); 42 | } 43 | } 44 | 45 | function runPrettify() { 46 | hljs.initHighlightingOnLoad(); 47 | } 48 | 49 | function runHighlighter() { 50 | var search = document.getElementById("search-input"); 51 | if (search) { 52 | var context = document.querySelectorAll(".entry-header, .entry-content"); 53 | new Mark(context).mark(search.value); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /public/launcher-icon/launcher-icon-1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/crystal-community/crystal-ann/f58bb9d97a24d1fc68374fc8143b8987451f3594/public/launcher-icon/launcher-icon-1x.png -------------------------------------------------------------------------------- /public/launcher-icon/launcher-icon-2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/crystal-community/crystal-ann/f58bb9d97a24d1fc68374fc8143b8987451f3594/public/launcher-icon/launcher-icon-2x.png -------------------------------------------------------------------------------- /public/launcher-icon/launcher-icon-3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/crystal-community/crystal-ann/f58bb9d97a24d1fc68374fc8143b8987451f3594/public/launcher-icon/launcher-icon-3x.png -------------------------------------------------------------------------------- /public/launcher-icon/launcher-icon-4x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/crystal-community/crystal-ann/f58bb9d97a24d1fc68374fc8143b8987451f3594/public/launcher-icon/launcher-icon-4x.png -------------------------------------------------------------------------------- /public/launcher-icon/launcher-icon-5x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/crystal-community/crystal-ann/f58bb9d97a24d1fc68374fc8143b8987451f3594/public/launcher-icon/launcher-icon-5x.png -------------------------------------------------------------------------------- /public/launcher-icon/launcher-icon-6x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/crystal-community/crystal-ann/f58bb9d97a24d1fc68374fc8143b8987451f3594/public/launcher-icon/launcher-icon-6x.png -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Crystal Announcements", 3 | "short_name": "Crystal ANN", 4 | "display": "standalone", 5 | "start_url": "/?utm_source=homescreen", 6 | "background_color": "#F1F1F1", 7 | "theme_color": "#FFFFFF", 8 | "orientation": "portrait", 9 | "description": "Announce new project, blog post, version update or any other Crystal work", 10 | "icons": [{ 11 | "src": "/launcher-icon/launcher-icon-1x.png", 12 | "sizes": "48x48", 13 | "type": "image/png" 14 | }, 15 | { 16 | "src": "/launcher-icon/launcher-icon-2x.png", 17 | "sizes": "72x72", 18 | "type": "image/png" 19 | }, { 20 | "src": "/launcher-icon/launcher-icon-3x.png", 21 | "sizes": "80x80", 22 | "type": "image/png" 23 | }, { 24 | "src": "/launcher-icon/launcher-icon-4x.png", 25 | "sizes": "96x96", 26 | "type": "image/png" 27 | }, { 28 | "src": "/launcher-icon/launcher-icon-5x.png", 29 | "sizes": "144x144", 30 | "type": "image/png" 31 | }, { 32 | "src": "/launcher-icon/launcher-icon-6x.png", 33 | "sizes": "192x192", 34 | "type": "image/png" 35 | } 36 | ] 37 | } 38 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # http://www.robotstxt.org 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /public/stylesheets/monokai-sublime.min.css: -------------------------------------------------------------------------------- 1 | .hljs{display:block;overflow-x:auto;padding:0.5em;background:#23241f}.hljs,.hljs-tag,.hljs-subst{color:#f8f8f2}.hljs-strong,.hljs-emphasis{color:#a8a8a2}.hljs-bullet,.hljs-quote,.hljs-number,.hljs-regexp,.hljs-literal,.hljs-link{color:#ae81ff}.hljs-code,.hljs-title,.hljs-section,.hljs-selector-class{color:#a6e22e}.hljs-strong{font-weight:bold}.hljs-emphasis{font-style:italic}.hljs-keyword,.hljs-selector-tag,.hljs-name,.hljs-attr{color:#f92672}.hljs-symbol,.hljs-attribute{color:#66d9ef}.hljs-params,.hljs-class .hljs-title{color:#f8f8f2}.hljs-string,.hljs-type,.hljs-built_in,.hljs-builtin-name,.hljs-selector-id,.hljs-selector-attr,.hljs-selector-pseudo,.hljs-addition,.hljs-variable,.hljs-template-variable{color:#e6db74}.hljs-comment,.hljs-deletion,.hljs-meta{color:#75715e} -------------------------------------------------------------------------------- /shard.lock: -------------------------------------------------------------------------------- 1 | version: 1.0 2 | shards: 3 | amber: 4 | github: amberframework/amber 5 | version: 0.6.7 6 | 7 | ameba: 8 | github: veelenga/ameba 9 | version: 0.5.0 10 | 11 | autolink: 12 | github: crystal-community/autolink.cr 13 | version: 0.1.3 14 | 15 | baked_file_system: 16 | github: schovi/baked_file_system 17 | version: 0.9.5 18 | 19 | callback: 20 | github: mosop/callback 21 | version: 0.6.3 22 | 23 | cli: 24 | github: amberframework/cli 25 | version: 0.7.0 26 | 27 | db: 28 | github: crystal-lang/crystal-db 29 | version: 0.5.0 30 | 31 | granite_orm: 32 | github: amberframework/granite-orm 33 | version: 0.8.4 34 | 35 | hashids: 36 | github: splattael/hashids.cr 37 | version: 0.2.2 38 | 39 | kemal: 40 | github: kemalcr/kemal 41 | version: 0.22.0 42 | 43 | kemal-csrf: 44 | github: kemalcr/kemal-csrf 45 | version: 0.4.0 46 | 47 | kemal-session: 48 | github: kemalcr/kemal-session 49 | version: 0.9.0 50 | 51 | kilt: 52 | github: jeromegn/kilt 53 | version: 0.4.0 54 | 55 | micrate: 56 | github: juanedi/micrate 57 | version: 0.3.3 58 | 59 | multi_auth: 60 | github: msa7/multi_auth 61 | commit: 4b8a34be7b6ee65e1efe42f352465f6f27323092 62 | 63 | mysql: 64 | github: crystal-lang/crystal-mysql 65 | version: 0.4.0 66 | 67 | optarg: 68 | github: mosop/optarg 69 | version: 0.5.8 70 | 71 | pg: 72 | github: will/crystal-pg 73 | version: 0.14.1 74 | 75 | pool: 76 | github: ysbaddaden/pool 77 | version: 0.2.3 78 | 79 | radix: 80 | github: luislavena/radix 81 | version: 0.3.8 82 | 83 | redis: 84 | github: stefanwille/crystal-redis 85 | version: 1.9.0 86 | 87 | shell-table: 88 | github: jwaldrip/shell-table.cr 89 | version: 0.9.2 90 | 91 | sidekiq: 92 | github: mperham/sidekiq.cr 93 | commit: 26253ed561094e715f27f3d4e4c9679876d26aee 94 | 95 | slang: 96 | github: jeromegn/slang 97 | version: 1.7.1 98 | 99 | spec2: 100 | github: waterlink/spec2.cr 101 | version: 0.11.0 102 | 103 | spinner: 104 | github: askn/spinner 105 | version: 0.1.1 106 | 107 | sqlite3: 108 | github: crystal-lang/crystal-sqlite3 109 | version: 0.9.0 110 | 111 | string_inflection: 112 | github: mosop/string_inflection 113 | version: 0.2.1 114 | 115 | teeplate: 116 | github: amberframework/teeplate 117 | version: 0.5.0 118 | 119 | twitter-crystal: 120 | github: sferik/twitter-crystal 121 | version: 0.2.1 122 | 123 | webmock: 124 | github: manastech/webmock.cr 125 | commit: 7e7e7ff4d00afbb708909e1721e9f9bceddda726 126 | 127 | -------------------------------------------------------------------------------- /shard.yml: -------------------------------------------------------------------------------- 1 | name: crystal-ann 2 | version: 3 | 4 | authors: 5 | - V. Elenhaupt 6 | - Hugo Abonizio 7 | 8 | crystal: 0.24.2 9 | 10 | license: MIT 11 | 12 | dependencies: 13 | granite_orm: 14 | github: amberframework/granite-orm 15 | version: 0.8.4 16 | 17 | multi_auth: 18 | github: msa7/multi_auth 19 | commit: 4b8a34be7b6ee65e1efe42f352465f6f27323092 20 | 21 | micrate: 22 | github: juanedi/micrate 23 | version: 0.3.3 24 | 25 | sidekiq: 26 | github: mperham/sidekiq.cr 27 | commit: 26253ed561094e715f27f3d4e4c9679876d26aee 28 | 29 | amber: 30 | github: amberframework/amber 31 | version: 0.6.7 32 | 33 | twitter-crystal: 34 | github: sferik/twitter-crystal 35 | version: 0.2.1 36 | 37 | hashids: 38 | github: splattael/hashids.cr 39 | version: 0.2.2 40 | 41 | autolink: 42 | github: crystal-community/autolink.cr 43 | version: 0.1.3 44 | 45 | development_dependencies: 46 | spec2: 47 | github: waterlink/spec2.cr 48 | version: 0.11.0 49 | 50 | webmock: 51 | github: manastech/webmock.cr 52 | commit: 7e7e7ff4d00afbb708909e1721e9f9bceddda726 53 | 54 | ameba: 55 | github: veelenga/ameba 56 | version: 0.5.0 57 | -------------------------------------------------------------------------------- /spec/controllers/announcement_controller_spec.cr: -------------------------------------------------------------------------------- 1 | require "./spec_helper" 2 | 3 | describe AnnouncementController do 4 | let(:user) { user(login: "JohnDoe").tap &.save } 5 | let(:another_user) { user(login: "superman").tap &.save } 6 | let(:admin_user) { user(login: "superman", role: "admin").tap &.save } 7 | 8 | let(:title) { "Announcement Controller Sample Title" } 9 | let(:announcement) { announcement(user, title: title).tap &.save } 10 | 11 | before do 12 | Announcement.clear 13 | User.clear 14 | end 15 | 16 | describe "GET index" do 17 | before { announcement } 18 | 19 | it "renders all the announcements" do 20 | get "/announcements" 21 | expect(response.status_code).to eq 200 22 | expect(response.body.includes? title).to be_true 23 | end 24 | 25 | context "with query param" do 26 | it "can find announcements matching title or description" do 27 | a1 = announcement(user, title: "This is amazing announcement").tap &.save 28 | a2 = announcement(user, title: "This announcement is also amazing").tap &.save 29 | a3 = announcement(user, title: "This announcement is just cool").tap &.save 30 | 31 | get "/announcements", body: "query=amazing" 32 | expect(response.body.includes? a1.title.to_s).to be_true 33 | expect(response.body.includes? a2.title.to_s).to be_true 34 | expect(response.body.includes? a3.title.to_s).to be_false 35 | end 36 | end 37 | 38 | context "with type param" do 39 | it "can find announcements by type" do 40 | a1 = announcement(user, type: 0_i64).tap &.save 41 | a2 = announcement(user, type: 0_i64).tap &.save 42 | a3 = announcement(user, type: 1_i64).tap &.save 43 | 44 | get "/announcements", body: "type=#{Announcement::TYPES[0]}" 45 | expect(response.body.includes? a1.title.to_s).to be_true 46 | end 47 | end 48 | 49 | context "with user param" do 50 | it "can find announcements by user login" do 51 | user1 = user(login: "Superman").tap &.save 52 | user2 = user(login: "Batman").tap &.save 53 | 54 | a1 = announcement(user1, title: "Announcement1").tap &.save 55 | a2 = announcement(user1, title: "Announcement2").tap &.save 56 | a3 = announcement(user2, title: "Announcement3").tap &.save 57 | 58 | get "/announcements", body: "user=#{user1.login}" 59 | 60 | expect(response.body.includes? a1.title.to_s).to be_true 61 | expect(response.body.includes? a2.title.to_s).to be_true 62 | expect(response.body.includes? a3.title.to_s).to be_false 63 | end 64 | end 65 | end 66 | 67 | describe "GET show" do 68 | it "renders a single announcement" do 69 | get "/announcements/#{announcement.id}" 70 | expect(response.status_code).to eq 200 71 | expect(response.body.includes? title).to be_true 72 | end 73 | end 74 | 75 | describe "GET new" do 76 | it "render new template" do 77 | get "/announcements/new" 78 | expect(response.status_code).to eq 200 79 | expect(response.body.includes? "Make an Announcement").to be_true 80 | end 81 | 82 | context "rate limit reached" do 83 | before { login_as user; ENV["MAX_ANNS_PER_HOUR"] = "0" } 84 | after { ENV["MAX_ANNS_PER_HOUR"] = nil } 85 | 86 | it "shows rate limit message" do 87 | get "/announcements/new" 88 | expect(response.status_code).to eq 200 89 | expect(response.body.includes? "You can create up to").to be_true 90 | end 91 | end 92 | end 93 | 94 | describe "POST create" do 95 | context "when user signed-in" do 96 | before { login_as user } 97 | 98 | it "creates an announcement with valid params" do 99 | post "/announcements", body: "title=test-title&description=test-description&type=0" 100 | expect(Announcement.all.size).to eq 1 101 | end 102 | 103 | it "does not create an announcement with invalid params" do 104 | post "/announcements", body: "title=test-title" 105 | expect(Announcement.all.size).to eq 0 106 | end 107 | 108 | it "does not create an announcement if csrf token is invalid" do 109 | token = Base64.encode "invalid-token" 110 | post "/announcements", body: "title=test-title&description=test-description&type=0&_csrf=#{token}" 111 | expect(response.status_code).to eq 403 112 | expect(Announcement.all.size).to eq 0 113 | end 114 | 115 | context "and rate limit is reached" do 116 | before { ENV["MAX_ANNS_PER_HOUR"] = "0" } 117 | after { ENV["MAX_ANNS_PER_HOUR"] = nil } 118 | 119 | it "redirects to /" do 120 | post "/announcements", body: "title=test-title&description=test-description&type=0" 121 | expect(response.status_code).to eq 302 122 | expect(response).to redirect_to "/" 123 | expect(Announcement.all.size).to eq 0 124 | end 125 | end 126 | end 127 | 128 | context "when user not signed-in" do 129 | it "does not create an announcement with valid params" do 130 | post "/announcements", body: "title=test-title&description=test-description&type=0" 131 | expect(response.status_code).to eq 302 132 | expect(Announcement.all.size).to eq 0 133 | end 134 | 135 | it "redirects to /" do 136 | post "/announcements", body: "title=test-title&description=test-description&type=0" 137 | expect(response).to redirect_to "/announcements/new" 138 | end 139 | end 140 | end 141 | 142 | describe "GET edit" do 143 | context "when user not signed-in" do 144 | it "redirects to root_url" do 145 | get "/announcements/#{announcement.id}/edit" 146 | expect(response.status_code).to eq 302 147 | expect(response).to redirect_to "/" 148 | end 149 | end 150 | 151 | context "when user signed-in" do 152 | context "user can update this announcement" do 153 | before { login_as user } 154 | 155 | it "renders edit template" do 156 | get "/announcements/#{announcement.id}/edit" 157 | expect(response.status_code).to eq 200 158 | expect(response.body.includes? announcement.title.to_s).to be_true 159 | end 160 | end 161 | 162 | context "user can't update this announcement" do 163 | before { login_as another_user } 164 | 165 | it "redirects to root url" do 166 | get "/announcements/#{announcement.id}/edit" 167 | expect(response.status_code).to eq 302 168 | expect(response).to redirect_to "/" 169 | end 170 | end 171 | 172 | context "user is admin" do 173 | before { login_as admin_user } 174 | 175 | it "renders edit template" do 176 | get "/announcements/#{announcement.id}/edit" 177 | expect(response.status_code).to eq 200 178 | expect(response.body.includes? announcement.title.to_s).to be_true 179 | end 180 | end 181 | end 182 | end 183 | 184 | describe "PATCH update" do 185 | let(:valid_params) do 186 | { 187 | title: "my super cool title", 188 | description: "my super cool description", 189 | type: "1", 190 | } 191 | end 192 | let(:invalid_params) do 193 | { 194 | title: "-", 195 | description: "too short", 196 | type: "-1", 197 | } 198 | end 199 | 200 | context "when user not signed-in" do 201 | it "redirects to root url" do 202 | patch "/announcements/#{announcement.id}", body: HTTP::Params.encode(valid_params) 203 | expect(response.status_code).to eq 302 204 | expect(response).to redirect_to "/" 205 | end 206 | 207 | it "does not update announcement" do 208 | patch "/announcements/#{announcement.id}", body: HTTP::Params.encode(valid_params) 209 | expect(announcement.title).to eq title 210 | end 211 | end 212 | 213 | context "when user signed-in" do 214 | context "when user can update announcement" do 215 | before { login_as announcement.user.not_nil! } 216 | 217 | it "updates announcement if params are valid" do 218 | patch "/announcements/#{announcement.id}", body: HTTP::Params.encode(valid_params) 219 | updated = Announcement.find(announcement.try &.id).not_nil! 220 | expect(updated.title).to eq valid_params[:title] 221 | expect(updated.description).to eq valid_params[:description] 222 | expect(updated.type).to eq valid_params[:type].to_i 223 | end 224 | 225 | it "redirects to announcement#show page if params are valid" do 226 | patch "/announcements/#{announcement.id}", body: HTTP::Params.encode(valid_params) 227 | expect(response.status_code).to eq 302 228 | expect(response).to redirect_to "/announcements/#{announcement.id}" 229 | end 230 | 231 | it "does not update announcement if params are not valid" do 232 | patch "/announcements/#{announcement.id}", body: HTTP::Params.encode(invalid_params) 233 | a = Announcement.find(announcement.try &.id).not_nil! 234 | expect(a.title).to eq announcement.title 235 | expect(a.description).to eq announcement.description 236 | expect(a.type).to eq announcement.type 237 | end 238 | 239 | it "renders announcement#edit page if params are not valid" do 240 | patch "/announcements/#{announcement.id}", body: HTTP::Params.encode(invalid_params) 241 | expect(response.status_code).to eq 200 242 | end 243 | 244 | it "does not update announcement if csrf token is invalid" do 245 | token = Base64.encode "invalid-token" 246 | patch "/announcements/#{announcement.id}", body: HTTP::Params.encode(valid_params) + "&_csrf=#{token}" 247 | expect(response.status_code).to eq 403 248 | a = Announcement.find(announcement.try &.id).not_nil! 249 | expect(a.title).to eq announcement.title 250 | expect(a.description).to eq announcement.description 251 | expect(a.type).to eq announcement.type 252 | end 253 | end 254 | 255 | context "when user cannot update announcement" do 256 | before { login_as another_user } 257 | 258 | it "does not update announcement" do 259 | patch "/announcements/#{announcement.id}", body: HTTP::Params.encode(valid_params) 260 | a = Announcement.find(announcement.try &.id).not_nil! 261 | expect(a.title).to eq announcement.title 262 | expect(a.description).to eq announcement.description 263 | expect(a.type).to eq announcement.type 264 | end 265 | 266 | it "redirects to root url" do 267 | patch "/announcements/#{announcement.id}", body: HTTP::Params.encode(valid_params) 268 | expect(response.status_code).to eq 302 269 | expect(response).to redirect_to "/" 270 | end 271 | end 272 | 273 | context "when user is admin" do 274 | before { login_as admin_user } 275 | 276 | it "updates announcement if params are valid" do 277 | patch "/announcements/#{announcement.id}", body: HTTP::Params.encode(valid_params) 278 | updated = Announcement.find(announcement.try &.id).not_nil! 279 | expect(updated.title).to eq valid_params[:title] 280 | expect(updated.description).to eq valid_params[:description] 281 | expect(updated.type).to eq valid_params[:type].to_i 282 | end 283 | 284 | it "redirects to announcement#show page if params are valid" do 285 | patch "/announcements/#{announcement.id}", body: HTTP::Params.encode(valid_params) 286 | expect(response.status_code).to eq 302 287 | expect(response).to redirect_to "/announcements/#{announcement.id}" 288 | end 289 | end 290 | end 291 | end 292 | 293 | describe "DELETE destroy" do 294 | context "when user not signed-in" do 295 | it "redirects to root url" do 296 | delete "/announcements/#{announcement.id}" 297 | expect(response.status_code).to eq 302 298 | expect(response).to redirect_to "/" 299 | end 300 | 301 | it "does not delete announcement" do 302 | delete "/announcements/#{announcement.id}" 303 | expect(Announcement.find(announcement.try &.id)).not_to be_nil 304 | end 305 | end 306 | 307 | context "when user signed-in" do 308 | context "when user can update announcement" do 309 | before { login_as announcement.user.not_nil! } 310 | 311 | it "deletes announcement" do 312 | delete "/announcements/#{announcement.id}" 313 | expect(Announcement.find announcement.id).to be_nil 314 | end 315 | 316 | it "redirects to root url" do 317 | delete "/announcements/#{announcement.id}" 318 | expect(response.status_code).to eq 302 319 | expect(response).to redirect_to "/" 320 | end 321 | 322 | it "does not delete announcement if csrf token is invalid" do 323 | token = Base64.encode "invalid-token" 324 | delete "/announcements/#{announcement.id}", body: "_csrf=#{token}" 325 | expect(response.status_code).to eq 403 326 | expect(Announcement.find announcement.id).not_to be_nil 327 | end 328 | end 329 | 330 | context "when user cannot update announcement" do 331 | before { login_as another_user } 332 | 333 | it "does not delete announcement" do 334 | delete "/announcements/#{announcement.id}" 335 | expect(Announcement.find announcement.id).not_to be_nil 336 | end 337 | 338 | it "redirects to root url" do 339 | delete "/announcements/#{announcement.id}" 340 | expect(response.status_code).to eq 302 341 | expect(response).to redirect_to "/" 342 | end 343 | end 344 | 345 | context "when user is admin" do 346 | before { login_as admin_user } 347 | 348 | it "deletes announcement" do 349 | delete "/announcements/#{announcement.id}" 350 | expect(Announcement.find announcement.id).to be_nil 351 | end 352 | 353 | it "redirects to root url" do 354 | delete "/announcements/#{announcement.id}" 355 | expect(response.status_code).to eq 302 356 | expect(response).to redirect_to "/" 357 | end 358 | end 359 | end 360 | end 361 | 362 | describe "GET expand" do 363 | context "when hashid is valid" do 364 | it "redirects to announcement page" do 365 | get "/=#{announcement.hashid}" 366 | expect(response.status_code).to eq 302 367 | expect(response).to redirect_to "/announcements/#{announcement.id}" 368 | end 369 | end 370 | 371 | context "when hashid is invalid" do 372 | it "redirecs to root url if hashid present" do 373 | get "/=invalid" 374 | expect(response.status_code).to eq 302 375 | expect(response).to redirect_to "/" 376 | end 377 | 378 | it "returns 404 if hashid is missing" do 379 | get "/=" 380 | expect(response.status_code).to eq 404 381 | expect(response).not_to redirect_to "/" 382 | end 383 | end 384 | end 385 | 386 | describe "GET random" do 387 | before { announcement } 388 | 389 | it "redirects to a random announcement" do 390 | get "/announcements/random" 391 | expect(response.status_code).to eq 302 392 | expect(response).to redirect_to "/announcements/#{announcement.id}" 393 | end 394 | end 395 | end 396 | -------------------------------------------------------------------------------- /spec/controllers/error_controller_spec.cr: -------------------------------------------------------------------------------- 1 | require "./spec_helper" 2 | 3 | describe Amber::Controller::Error do 4 | describe "GET index" do 5 | it "returns 404 not found" do 6 | get "/index" 7 | expect(response.status_code).to eq 404 8 | expect(response.body).to eq "404 - Page not found" 9 | end 10 | 11 | it "returns 404 not found for any unknown request" do 12 | get "/blah-no-exists" 13 | expect(response.body).to eq "404 - Page not found" 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /spec/controllers/oauth_controller_spec.cr: -------------------------------------------------------------------------------- 1 | require "./spec_helper" 2 | require "webmock" 3 | 4 | describe OAuthController do 5 | describe "GET authenticate" do 6 | context "when provider is not known" do 7 | it "redirects to /" do 8 | get "/oauth/facebook" 9 | expect(response).to redirect_to "/" 10 | end 11 | end 12 | end 13 | 14 | context "Github" do 15 | let(:code) { "5ca3797f33399346a01c" } 16 | let(:github_id) { "roCDAM4ShR" } 17 | let(:github_secret) { "J9SYlLlO6XIk5lBqtOpoQlOhcbVEqbm5" } 18 | let(:github_access_token_response) do 19 | { 20 | access_token: "iuxcxlkbwe2342lkasdfjk2klsdj", 21 | token_type: "Bearer", 22 | expires_in: 300, 23 | refresh_token: nil, 24 | scope: "user", 25 | } 26 | end 27 | let(:github_user_response) do 28 | { 29 | login: "amber", 30 | id: 36345345, 31 | name: "Amber Framework", 32 | email: "test@email.com", 33 | bio: "Blah", 34 | } 35 | end 36 | 37 | let(:stub_github_authorize_request) do 38 | MultiAuth.config "github", github_id, github_secret 39 | 40 | body = "client_id=#{github_id}&client_secret=#{github_secret}&redirect_uri=&grant_type=authorization_code&code=#{code}" 41 | headers = {"Accept" => "application/json", "Content-type" => "application/x-www-form-urlencoded"} 42 | 43 | WebMock.stub(:post, "https://github.com/login/oauth/access_token") 44 | .with(body: body, headers: headers) 45 | .to_return(body: github_access_token_response.to_json) 46 | 47 | WebMock.stub(:get, "https://api.github.com/user").to_return(body: github_user_response.to_json) 48 | end 49 | 50 | describe "GET new" do 51 | it "redirects to github authorize uri" do 52 | get "/oauth/new" 53 | expect(response.headers["Location"].includes? "https://github.com/login/oauth/authorize").to be_true 54 | end 55 | end 56 | 57 | describe "GET authenticate" do 58 | before { Announcement.clear; User.clear } 59 | before { stub_github_authorize_request } 60 | 61 | it "creates a new user" do 62 | get "/oauth/github", body: "code=#{code}" 63 | u = User.find_by_uid_and_provider(github_user_response[:id], "github") 64 | expect(u).not_to be_nil 65 | expect(u.not_nil!.login).to eq github_user_response[:login] 66 | expect(u.not_nil!.name).to eq github_user_response[:name] 67 | end 68 | 69 | it "signs in a user" do 70 | get "/oauth/github", body: "code=#{code}" 71 | u = User.find_by_uid_and_provider(github_user_response[:id], "github") 72 | expect(session["user_id"]).to eq u.not_nil!.id.to_s 73 | end 74 | 75 | it "redirects to announcements#new" do 76 | get "/oauth/github", body: "code=#{code}" 77 | expect(response.status_code).to eq 302 78 | expect(response).to redirect_to "/announcements/new" 79 | end 80 | 81 | it "can find existing user and update attributes" do 82 | u = user( 83 | name: "Marilyn Manson", 84 | login: github_user_response[:login], 85 | uid: github_user_response[:id].to_s, 86 | provider: "github" 87 | ).tap(&.save).not_nil! 88 | 89 | get "/oauth/github", body: "code=#{code}" 90 | user = User.find(u.id) 91 | expect(user.not_nil!.name).to eq github_user_response[:name] 92 | end 93 | 94 | it "does not create a new user if such user exists" do 95 | u = user( 96 | name: "Marilyn Manson", 97 | login: github_user_response[:login], 98 | uid: github_user_response[:id].to_s, 99 | provider: "github" 100 | ).tap(&.save).not_nil! 101 | 102 | get "/oauth/github", body: "code=#{code}" 103 | expect(User.all.size).to eq 1 104 | end 105 | end 106 | end 107 | 108 | context "Twitter" do 109 | let(:user) { user(login: "JohnDoe").tap &.save } 110 | let(:twitter_consumer_key) { "consumer_key" } 111 | let(:twitter_consumer_secret) { "consumer_secret_key" } 112 | let(:twitter_access_token_response) do 113 | { 114 | oauth_token: "NPcudxy0yU5T3tBzho7iCotZ3cnetKwcTIRlX0iwRl0", 115 | oauth_token_secret: "veNRnAWe6inFuo8o2u8SLLZLjolYDmDP7SzL0YfYI", 116 | oauth_callback_confirmed: "true", 117 | } 118 | end 119 | let(:twitter_access_token_response) do 120 | { 121 | oauth_token: "7588892-kagSNqWge8gB1WwE3plnFsJHAZVfxWD7Vb57p0b4", 122 | oauth_token_secret: "PbKfYqSryyeKDWz4ebtY3o5ogNLG11WJuZBc9fQrQo", 123 | } 124 | end 125 | let(:twitter_verify_credentials_response) do 126 | { 127 | id: 38895958, 128 | name: "Sean Cook", 129 | screen_name: "theSeanCook", 130 | location: "San Francisco", 131 | url: "http://twitter.com", 132 | description: "I taught your phone that thing you like. The Mobile Partner Engineer @Twitter.", 133 | profile_image_url: "http://a0.twimg.com/profile_images/1751506047/dead_sexy_normal.JPG", 134 | email: "me@twitter.com", 135 | } 136 | end 137 | let(:twitter_authenticate_request) do 138 | { 139 | oauth_token: "token", 140 | oauth_verifier: "verifier", 141 | } 142 | end 143 | 144 | let(:stub_twitter_authorize_request) do 145 | WebMock.stub(:post, "https://api.twitter.com/oauth/request_token") 146 | .to_return(body: HTTP::Params.encode twitter_access_token_response) 147 | end 148 | let(:stub_access_token_request) do 149 | WebMock.stub(:post, "https://api.twitter.com/oauth/access_token") 150 | .to_return(body: HTTP::Params.encode twitter_access_token_response) 151 | end 152 | let(:stub_twitter_verify_credentials_request) do 153 | WebMock.stub(:get, "https://api.twitter.com/1.1/account/verify_credentials.json?include_email=true") 154 | .to_return(body: twitter_verify_credentials_response.to_json) 155 | end 156 | 157 | before do 158 | MultiAuth.config "twitter", twitter_consumer_key, twitter_consumer_secret 159 | end 160 | 161 | describe "GET new" do 162 | before { stub_twitter_authorize_request } 163 | 164 | it "redirects to twitter authorize uri" do 165 | get "/oauth/new?provider=twitter" 166 | location = response.headers["Location"] 167 | expect(location.includes? "https://api.twitter.com/oauth/authorize").to be_true 168 | end 169 | end 170 | 171 | describe "GET authenticate" do 172 | before { stub_access_token_request } 173 | before { stub_twitter_verify_credentials_request } 174 | before { Announcement.clear; User.clear } 175 | 176 | let(:request_body) { HTTP::Params.encode(twitter_authenticate_request) } 177 | 178 | context "when signed in" do 179 | before { login_as user } 180 | 181 | it "saves a twitter handle" do 182 | get "/oauth/twitter", body: request_body 183 | u = User.find(user.id) 184 | expect(u.try &.handle).to eq twitter_verify_credentials_response[:screen_name] 185 | end 186 | 187 | it "redirects to /me" do 188 | get "/oauth/twitter", body: request_body 189 | expect(response).to redirect_to "/me" 190 | end 191 | end 192 | 193 | context "when not signed in" do 194 | it "redirects to /" do 195 | get "/oauth/twitter", body: request_body 196 | expect(response).to redirect_to "/" 197 | end 198 | end 199 | end 200 | end 201 | end 202 | -------------------------------------------------------------------------------- /spec/controllers/rss_controller_spec.cr: -------------------------------------------------------------------------------- 1 | require "./spec_helper" 2 | 3 | describe RSSController do 4 | let(:count) { 5 } 5 | let(:title) { "Crystal released: 1.0." } 6 | let(:user) { user(login: "crystal-lang").tap &.save } 7 | 8 | it "returns application/xml header" do 9 | get "/rss" 10 | expect(response.headers["Content-Type"]?).to eq "application/xml" 11 | end 12 | 13 | describe "GET show" do 14 | context "when there are announcements available" do 15 | it "returns newest announcements" do 16 | count.times { |i| announcement(user, title: title + i.to_s).tap &.save } 17 | get "/rss" 18 | count.times { |i| expect(response.body.includes? title + i.to_s).to be_true } 19 | end 20 | end 21 | 22 | context "when there no announcements available" do 23 | it "renders show template" do 24 | get "/rss" 25 | expect(response.status_code).to eq 200 26 | end 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /spec/controllers/session_controller_spec.cr: -------------------------------------------------------------------------------- 1 | require "./spec_helper" 2 | 3 | describe SessionController do 4 | describe "DELETE destroy" do 5 | let(:user) { user(login: "Bro").tap &.save } 6 | before { login_as user } 7 | 8 | it "signs out user" do 9 | delete "/sessions" 10 | expect(session["user_id"]).to be_nil 11 | end 12 | 13 | it "does not sign out user if csrf is invalid" do 14 | token = Base64.encode "invalid-token" 15 | delete "/sessions", body: "_csrf=#{token}" 16 | expect(response.status_code).to eq 403 17 | expect(session["user_id"]).not_to be_nil 18 | end 19 | 20 | it "redirects to root url" do 21 | delete "/sessions" 22 | expect(response.status_code).to eq 302 23 | expect(response).to redirect_to "/" 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /spec/controllers/spec_helper.cr: -------------------------------------------------------------------------------- 1 | require "../spec_helper" 2 | require "../../config/routes" 3 | require "../../src/helpers/**" 4 | require "../../src/controllers/**" 5 | 6 | class Global 7 | @@response : HTTP::Client::Response? 8 | @@session : Amber::Router::Session::AbstractStore? 9 | @@cookies : Amber::Router::Cookies::Store? 10 | 11 | def self.response=(@@response) 12 | end 13 | 14 | def self.response 15 | @@response 16 | end 17 | 18 | def self.cookies(headers = HTTP::Headers.new) 19 | @@cookies = Amber::Router::Cookies::Store.new 20 | @@cookies.try &.update(Amber::Router::Cookies::Store.from_headers(headers)) 21 | @@cookies.not_nil! 22 | end 23 | 24 | def self.session 25 | @@session ||= Amber::Router::Session::Store.new(cookies, Amber.settings.session).build 26 | end 27 | end 28 | 29 | {% for method in %w(get head post put patch delete) %} 30 | def {{method.id}}(path, headers : HTTP::Headers? = nil, body : String? = nil) 31 | request = HTTP::Request.new("{{method.id}}".upcase, path, headers, body) 32 | request.headers["Content-Type"] = Amber::Router::ParamsParser::URL_ENCODED_FORM 33 | Global.response = process_request request 34 | end 35 | {% end %} 36 | 37 | def process_request(request) 38 | io = IO::Memory.new 39 | response = HTTP::Server::Response.new(io) 40 | context = HTTP::Server::Context.new(request, response) 41 | context.session = Global.session if Global.session 42 | context.params["_csrf"] ||= Amber::Pipe::CSRF.token(context).to_s 43 | main_handler = build_main_handler 44 | main_handler.call context 45 | response.close 46 | io.rewind 47 | client_response = HTTP::Client::Response.from_io(io, decompress: false) 48 | Global.response = client_response 49 | end 50 | 51 | def build_main_handler 52 | handler = Amber::Pipe::Pipeline.new 53 | handler.build :web do 54 | plug Amber::Pipe::Error.new 55 | plug Amber::Pipe::Session.new 56 | plug Amber::Pipe::CSRF.new 57 | end 58 | handler.build :static do 59 | plug Amber::Pipe::Error.new 60 | plug Amber::Pipe::Static.new("./public") 61 | plug HTTP::CompressHandler.new 62 | end 63 | handler.prepare_pipelines 64 | handler 65 | end 66 | 67 | def response 68 | Global.response.not_nil! 69 | end 70 | 71 | def session 72 | Global.session.not_nil! 73 | end 74 | 75 | def login_as(user : User) 76 | raise ArgumentError.new("user has to be saved") if user.id.nil? 77 | 78 | session["user_id"] = user.id.to_s 79 | end 80 | -------------------------------------------------------------------------------- /spec/controllers/static_controller_spec.cr: -------------------------------------------------------------------------------- 1 | require "./spec_helper" 2 | 3 | describe StaticController do 4 | describe "GET about" do 5 | it "renders about page" do 6 | get "/about" 7 | expect(response.status_code).to eq 200 8 | end 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /spec/controllers/user_controller_spec.cr: -------------------------------------------------------------------------------- 1 | require "./spec_helper" 2 | 3 | describe UserController do 4 | let(:user) { user(login: "UserControllerTest").tap &.save } 5 | 6 | before do 7 | Announcement.clear 8 | User.clear 9 | end 10 | 11 | describe "GET #show" do 12 | it "renders show template if user is found" do 13 | get "/users/#{user.login}" 14 | expect(response.status_code).to eq 200 15 | expect(response.body.includes? user.login.not_nil!).to be_true 16 | end 17 | 18 | it "redirects to root if user is not found" do 19 | get "/users/no-such-login" 20 | expect(response.status_code).to eq 302 21 | expect(response).to redirect_to "/" 22 | end 23 | end 24 | 25 | describe "GET #me" do 26 | it "renders user#show template if user is signed in" do 27 | login_as user 28 | get "/me" 29 | expect(response.status_code).to eq 200 30 | expect(response.body.includes? user.login.not_nil!).to be_true 31 | end 32 | 33 | it "redirects to root if user is not signed in" do 34 | get "/me" 35 | expect(response.status_code).to eq 302 36 | expect(response).to redirect_to "/" 37 | end 38 | end 39 | 40 | describe "PUT #remove_handle" do 41 | let(:user) { user(handle: "crystal_ann").tap &.save } 42 | 43 | it "removes user handle if user is signed in" do 44 | login_as user 45 | put "/users/remove_handle" 46 | expect(User.find(user.id).not_nil!.handle).to be_nil 47 | end 48 | 49 | it "does not remove handle if csrf token is invalid" do 50 | login_as user 51 | token = Base64.encode "invalid-token" 52 | put "/users/remove_handle", body: "_csrf=#{token}" 53 | expect(response.status_code).to eq 403 54 | expect(User.find(user.id).try &.handle).not_to be_nil 55 | end 56 | 57 | it "redirects to /me if user is signed in" do 58 | login_as user 59 | put "/users/remove_handle" 60 | expect(response.status_code).to eq 302 61 | expect(response).to redirect_to "/me" 62 | end 63 | 64 | it "redirects to / if user is not signed in" do 65 | put "/users/remove_handle" 66 | expect(response.status_code).to eq 302 67 | expect(response).to redirect_to "/" 68 | end 69 | end 70 | end 71 | -------------------------------------------------------------------------------- /spec/helpers/page_title_helper_spec.cr: -------------------------------------------------------------------------------- 1 | require "../spec_helper" 2 | require "../../src/helpers/page_title_helper" 3 | 4 | class DumbController 5 | include Helpers::PageTitleHelper 6 | end 7 | 8 | describe Helpers::PageTitleHelper do 9 | describe "#page_title" do 10 | it "sets page title" do 11 | controller = DumbController.new 12 | controller.page_title "my page title" 13 | expect(controller.page_title).to eq "my page title - #{SITE.name}" 14 | end 15 | 16 | it "returns default site title with suffix" do 17 | expect(DumbController.new.page_title).to eq "#{SITE.description} - #{SITE.name}" 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /spec/helpers/query_helper_spec.cr: -------------------------------------------------------------------------------- 1 | require "../spec_helper" 2 | require "../../src/helpers/query_helper" 3 | 4 | class DumbController 5 | include Helpers::QueryHelper 6 | 7 | property params = HTTP::Params.new({} of String => Array(String)) 8 | end 9 | 10 | describe Helpers::QueryHelper do 11 | describe "#to_query" do 12 | it "encodes empty params when query is empty" do 13 | expect(DumbController.new.to_query).to eq "" 14 | end 15 | 16 | it "encodes params when query is empty" do 17 | controller = DumbController.new 18 | controller.params = HTTP::Params.parse "foo=bar&qux=zoo" 19 | expect(controller.to_query).to eq "foo=bar&qux=zoo" 20 | end 21 | 22 | it "encodes params and query" do 23 | controller = DumbController.new 24 | controller.params = HTTP::Params.parse "foo=bar&qux=zoo" 25 | expect(controller.to_query page: 22).to eq "foo=bar&qux=zoo&page=22" 26 | end 27 | 28 | it "encodes params and query and overrides params values" do 29 | controller = DumbController.new 30 | controller.params = HTTP::Params.parse "foo=bar&qux=zoo&page=1" 31 | expect(controller.to_query page: 22).to eq "foo=bar&qux=zoo&page=22" 32 | end 33 | 34 | it "encodes only query when params are empty" do 35 | controller = DumbController.new 36 | expect(controller.to_query page: 22, foo: "bar").to eq "page=22&foo=bar" 37 | end 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /spec/helpers/time_ago_helper_spec.cr: -------------------------------------------------------------------------------- 1 | require "../spec_helper" 2 | require "../../src/helpers/time_ago_helper" 3 | 4 | describe Helpers::TimeAgoHelper do 5 | describe ".time_ago_in_words" do 6 | it "transforms time interval into words" do 7 | expectations = { 8 | 1.second.ago => "just now", 9 | 15.seconds.ago => "just now", 10 | 60.seconds.ago => "a minute ago", 11 | 1.minute.ago => "a minute ago", 12 | 2.minutes.ago => "2 minutes ago", 13 | 59.minutes.ago => "59 minutes ago", 14 | 1.hour.ago => "an hour ago", 15 | 5.hour.ago => "5 hours ago", 16 | 24.hours.ago => "a day ago", 17 | 2.days.ago => "2 days ago", 18 | 1.week.ago => "a week ago", 19 | 2.weeks.ago => "2 weeks ago", 20 | 1.month.ago => "a month ago", 21 | 6.weeks.ago => "a month ago", 22 | 2.months.ago => "2 months ago", 23 | 8.months.ago => "8 months ago", 24 | 12.months.ago => "a year ago", 25 | 1.year.ago => "a year ago", 26 | 18.months.ago => "a year ago", 27 | 2.years.ago => "2 years ago", 28 | 26.months.ago => "2 years ago", 29 | 10.years.ago => "10 years ago", 30 | } 31 | expectations.each do |date, result| 32 | expect(Helpers::TimeAgoHelper.time_ago_in_words(date)).to eq result 33 | end 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /spec/initializers/database_spec.cr: -------------------------------------------------------------------------------- 1 | require "../spec_helper" 2 | 3 | describe DBSettings do 4 | {% for env in %w(test development staging production) %} 5 | 6 | it "has database_url in {{env.id}} environment" do 7 | db = DBSettings.load {{env}} 8 | expect(db.database_url.blank?).to eq false 9 | end 10 | 11 | {% end %} 12 | end 13 | -------------------------------------------------------------------------------- /spec/initializers/site_spec.cr: -------------------------------------------------------------------------------- 1 | require "../spec_helper" 2 | 3 | describe SiteSettings do 4 | it "loads SITE settings" do 5 | expect(SITE).not_to be_nil 6 | end 7 | 8 | it "loads SITE.name" do 9 | expect(SITE.name.blank?).to be_false 10 | end 11 | 12 | it "loads SITE.description" do 13 | expect(SITE.description.blank?).to be_false 14 | end 15 | 16 | it "loads SITE.url" do 17 | expect(SITE.url.blank?).to be_false 18 | end 19 | 20 | {% for env in %w(test development staging production) %} 21 | let(:site) { SiteSettings.load {{env}} } 22 | 23 | it "has name in {{env.id}} environment" do 24 | expect(site.name.blank?).to be_false 25 | end 26 | 27 | it "has description in {{env.id}} environment" do 28 | expect(site.description.blank?).to be_false 29 | end 30 | 31 | it "has url in {{env.id}} environment" do 32 | expect(site.url.blank?).to be_false 33 | end 34 | {% end %} 35 | end 36 | -------------------------------------------------------------------------------- /spec/models/announcement_spec.cr: -------------------------------------------------------------------------------- 1 | require "./spec_helper" 2 | require "../../src/models/announcement.cr" 3 | 4 | describe Announcement do 5 | describe "Validation" do 6 | it "succeeds on valid parameters" do 7 | expect(announcement.valid?).to be_true 8 | end 9 | 10 | it "requires title" do 11 | expect(announcement(title: "").valid?).to be_false 12 | end 13 | 14 | it "validates minimum size of title" do 15 | expect(announcement(title: "-" * 3).valid?).to be_false 16 | end 17 | 18 | it "validates maximum size of title" do 19 | expect(announcement(title: "-" * 101).valid?).to be_false 20 | end 21 | 22 | it "requires description" do 23 | expect(announcement(description: "").valid?).to be_false 24 | end 25 | 26 | it "validates minimum size of description" do 27 | expect(announcement(description: "-" * 3).valid?).to be_false 28 | end 29 | 30 | it "validates maximum size of description" do 31 | expect(announcement(description: "-" * 4001).valid?).to be_false 32 | end 33 | 34 | it "validates type" do 35 | expect(announcement(type: -1_i64).valid?).to be_false 36 | end 37 | end 38 | 39 | describe "#typename" do 40 | it "returns the properly capitalized type name" do 41 | expect(announcement(type: 0_i64).typename).to eq "Blog post" 42 | expect(announcement(type: 1_i64).typename).to eq "Project update" 43 | expect(announcement(type: 2_i64).typename).to eq "Conference" 44 | expect(announcement(type: 3_i64).typename).to eq "Meetup" 45 | expect(announcement(type: 4_i64).typename).to eq "Podcast" 46 | expect(announcement(type: 5_i64).typename).to eq "Screencast" 47 | expect(announcement(type: 6_i64).typename).to eq "Video" 48 | expect(announcement(type: 7_i64).typename).to eq "Other" 49 | end 50 | 51 | it "raises error if type is wrong" do 52 | raise_error { announcement(type: -1_i64).typename } 53 | end 54 | end 55 | 56 | describe "#content" do 57 | it "returns html content" do 58 | expect(announcement(description: "test").content).to eq "

test

" 59 | end 60 | 61 | it "encodes html tags" do 62 | ann = announcement(description: "") 63 | expect(ann.content).to eq "

<script>console.log('hello')</script>

" 64 | end 65 | 66 | it "autolinks" do 67 | anns = [ 68 | announcement(description: "http://www.myproj.com/"), 69 | announcement(description: "Some new project at http://www.myproj.com/."), 70 | announcement(description: "(link: http://www.myproj.com/)."), 71 | announcement(description: "# Bigger example\nLorem ipsum http://www.myproj.com/."), 72 | ] 73 | results = [ 74 | "

http://www.myproj.com/

", 75 | "

Some new project at http://www.myproj.com/.

", 76 | "

(link: http://www.myproj.com/).

", 77 | "

Bigger example

\n\n

Lorem ipsum http://www.myproj.com/.

", 78 | ] 79 | anns.size.times do |i| 80 | expect(anns[i].content).to eq(results[i]) 81 | end 82 | end 83 | 84 | it "autolink/markdown edge cases" do 85 | content = "http://example.com" 86 | expect(announcement(description: content).content) 87 | .to eq "

<a href=\"hello\">http://example.com</a>

" 88 | 89 | content = "http://www.myblog.com" 90 | expect(announcement(description: content).content) 91 | .to eq "

<a href=\"http://www.myblog.com\">http://www.myblog.com</a>

" 92 | end 93 | end 94 | 95 | describe "#short_path" do 96 | it "returns short path to the announcement" do 97 | expect(announcement.tap { |a| a.id = 1_i64 }.short_path).to eq "/=D49Nz" 98 | end 99 | 100 | it "returns nil if announcement does not have id" do 101 | expect(announcement.short_path).to eq nil 102 | end 103 | end 104 | 105 | describe "#hashid" do 106 | it "returns nil if id is not present" do 107 | expect(announcement.hashid).to eq nil 108 | end 109 | 110 | it "returns hash id if id is present" do 111 | hashid = announcement.tap { |a| a.id = 1_i64 }.hashid 112 | expect(hashid).to eq "D49Nz" 113 | end 114 | end 115 | 116 | describe ".random" do 117 | let!(:ann) { announcement(user.tap &.save).tap &.save } 118 | 119 | before do 120 | Announcement.clear 121 | User.clear 122 | end 123 | 124 | it "returns random announcements" do 125 | expect(Announcement.random.not_nil!.id).to eq ann.id 126 | end 127 | end 128 | end 129 | -------------------------------------------------------------------------------- /spec/models/spec_helper.cr: -------------------------------------------------------------------------------- 1 | require "../spec_helper" 2 | -------------------------------------------------------------------------------- /spec/models/user_spec.cr: -------------------------------------------------------------------------------- 1 | require "./spec_helper" 2 | require "../../src/models/user.cr" 3 | 4 | describe User do 5 | describe "Validation" do 6 | it "succeeds on valid parameters" do 7 | expect(user.valid?).to be_true 8 | end 9 | 10 | it "requires login not to be blank" do 11 | expect(user(login: "").valid?).to be_false 12 | end 13 | 14 | it "requires uid not to be blank" do 15 | expect(user(uid: "").valid?).to be_false 16 | end 17 | 18 | it "requires provider not to be blank" do 19 | expect(user(provider: "").valid?).to be_false 20 | end 21 | 22 | it "allows role to be blank" do 23 | expect(user(role: "").valid?).to be_true 24 | end 25 | 26 | it "allows handle to be blank" do 27 | expect(user(handle: "").valid?).to be_true 28 | end 29 | end 30 | 31 | describe "#admin?" do 32 | it "returns false if roles does not equal to admin" do 33 | expect(user(role: "user").admin?).to be_false 34 | end 35 | 36 | it "returns true if roles equals to admin" do 37 | expect(user(role: "admin").admin?).to be_true 38 | end 39 | 40 | it "returns false if roles is not specified" do 41 | expect(user.admin?).to be_false 42 | end 43 | end 44 | 45 | describe "#can_update?" do 46 | it "returns true if announcement belongs to the user" do 47 | u = user.tap { |u| u.id = 1_i64 } 48 | announcement = announcement(user_id: u.id.not_nil!) 49 | expect(u.can_update? announcement).to be_true 50 | end 51 | 52 | it "returns false if announcement does not belong to the user" do 53 | announcement = announcement(user_id: 2_i64) 54 | expect(user.can_update? announcement).to be_false 55 | end 56 | 57 | it "returns true if user is admin and announcement does not belong to the user" do 58 | announcement = announcement(user_id: 2_i64) 59 | expect(user(role: "admin").can_update? announcement).to be_true 60 | end 61 | end 62 | 63 | describe "#me?" do 64 | it "returns true if this is the same user" do 65 | u = user.tap { |u| u.id = 1_i64 } 66 | expect(u.me? u).to be_true 67 | end 68 | 69 | it "returns false if this is not the same user" do 70 | u = user.tap { |u| u.id = 1_i64 } 71 | expect(u.me? user).to be_false 72 | end 73 | end 74 | 75 | describe "#avatar_url" do 76 | it "returns url to user's avatar" do 77 | expect(user.avatar_url).not_to be_nil 78 | end 79 | end 80 | 81 | describe "#github_url" do 82 | it "returns url to user's github profile" do 83 | expect(user.github_url).not_to be_nil 84 | end 85 | end 86 | 87 | describe "#twitter_url" do 88 | it "returns nil if user does not have a handle" do 89 | expect(user.twitter_url).to be_nil 90 | end 91 | 92 | it "returns url if user has a handle" do 93 | expect(user(handle: "crystal-ann").twitter_url).not_to be_nil 94 | end 95 | end 96 | 97 | describe "#total_announcements" do 98 | before do 99 | Announcement.clear 100 | User.clear 101 | end 102 | 103 | let(:user) { user(login: "john").tap &.save } 104 | 105 | it "returns number of announcements that belong to user" do 106 | an = announcement(user: user).tap &.save 107 | expect(user.total_announcements).to eq 1 108 | end 109 | 110 | it "returns 0 if there are no announcements that belong to user" do 111 | expect(user.total_announcements).to eq 0 112 | end 113 | end 114 | 115 | describe "#find_by_login" do 116 | before do 117 | Announcement.clear 118 | User.clear 119 | end 120 | 121 | let(:user) { user(login: "john").tap &.save } 122 | 123 | it "can find user by it's login" do 124 | expect(User.find_by_login(user.login)).not_to be_nil 125 | end 126 | 127 | it "returns nil if such user does not exist" do 128 | expect(User.find_by_login("bad-login")).to be_nil 129 | end 130 | end 131 | 132 | describe "#last_hour_announcements" do 133 | before do 134 | Announcement.clear 135 | User.clear 136 | end 137 | 138 | let(:user) { user(login: "john2").tap &.save } 139 | 140 | it "returns the number of announcements made by the user during the last hour" do 141 | an = announcement(user: user).tap &.save 142 | an.created_at = Time.new(2016, 2, 15, 10, 20, 30) 143 | an.save 144 | announcement(user: user).tap &.save 145 | announcement(user: user).tap &.save 146 | expect(user.last_hour_announcements).to eq 2 147 | end 148 | 149 | it "returns 0 if there are no announcements made by the user during the last hour" do 150 | expect(user.last_hour_announcements).to eq 0 151 | end 152 | end 153 | end 154 | -------------------------------------------------------------------------------- /spec/spec_helper.cr: -------------------------------------------------------------------------------- 1 | ENV["AMBER_ENV"] = "test" 2 | 3 | require "spec2" 4 | require "amber" 5 | require "../config/application" 6 | require "../db/migrate" 7 | require "./support/*" 8 | 9 | include Spec2::GlobalDSL 10 | 11 | Granite::ORM.settings.logger = ::Logger.new(nil) 12 | Sidekiq::Client.default_context.logger.level = ::Logger::UNKNOWN 13 | -------------------------------------------------------------------------------- /spec/support/factories.cr: -------------------------------------------------------------------------------- 1 | require "../../src/models/**" 2 | 3 | def user(**params) 4 | attributes = { 5 | :uid => (rand() * 10_000).to_i.to_s, 6 | :login => "johndoe", 7 | :provider => "github", 8 | } of Symbol | String => String | JSON::Type 9 | 10 | params.each { |k, v| attributes[k] = v } 11 | User.new attributes 12 | end 13 | 14 | def announcement(**params) 15 | attributes = { 16 | :title => "title", 17 | :description => "description", 18 | :type => Announcement::TYPES.keys.first.as(Int64), 19 | } of Symbol | String => String | JSON::Type 20 | 21 | params.each { |k, v| attributes[k] = v } 22 | Announcement.new attributes 23 | end 24 | 25 | def announcement(user, **params) 26 | announcement(**params).tap { |a| a.user_id = user.id } 27 | end 28 | -------------------------------------------------------------------------------- /spec/support/matchers.cr: -------------------------------------------------------------------------------- 1 | require "spec2" 2 | require "http/client/response" 3 | 4 | struct RedirectToMatcher 5 | include Spec2::Matcher 6 | 7 | getter expected : String 8 | getter actual : String? 9 | 10 | def initialize(@expected : String) 11 | end 12 | 13 | def match(response : HTTP::Client::Response?) 14 | @actual = response.try &.headers["Location"]? 15 | @expected == @actual && response.try &.status_code >= 300 16 | end 17 | 18 | def failure_message 19 | <<-MSG 20 | bad redirect 21 | \t\t expected to redirect to: "#{expected}" 22 | \t\t but redirects to: "#{actual}" 23 | MSG 24 | end 25 | 26 | def failure_message_when_negated 27 | <<-MSG 28 | bad redirect 29 | \t\t expected not to redirect to: "#{expected}" 30 | \t\t but redirects to: "#{actual}" 31 | MSG 32 | end 33 | 34 | def description 35 | "(should redirect to #{expected_to})" 36 | end 37 | end 38 | 39 | Spec2.register_matcher redirect_to do |url| 40 | RedirectToMatcher.new url 41 | end 42 | -------------------------------------------------------------------------------- /spec/workers/tweet_announcement_spec.cr: -------------------------------------------------------------------------------- 1 | require "../spec_helper" 2 | require "webmock" 3 | require "../../src/workers/tweet_announcement" 4 | 5 | describe Workers::TweetAnnouncement do 6 | subject { Workers::TweetAnnouncement.new } 7 | let(:announcement) { 8 | Announcement.new({ 9 | :id => 1_i64, 10 | :title => "First Announcement", 11 | :description => "Super cool stuff created", 12 | }) 13 | } 14 | 15 | describe "#tweet_template" do 16 | let(:template) { subject.tweet_template announcement } 17 | 18 | it "includes announcement title" do 19 | expect(template.includes? announcement.title.to_s).to be_true 20 | end 21 | 22 | it "includes SITE.url" do 23 | expect(template.includes? SITE.url).to be_true 24 | end 25 | 26 | it "includes announcement.short_path" do 27 | expect(template.includes? announcement.short_path.to_s).to be_true 28 | end 29 | 30 | it "includes user handle" do 31 | handle = "crystal_ann" 32 | announcement = announcement(user(handle: handle).tap &.save) 33 | expect(subject.tweet_template(announcement).includes? "@#{handle}").to be_true 34 | end 35 | 36 | it "includes #crystallang hashtag" do 37 | expect(template.includes? "#crystallang").to be_true 38 | end 39 | end 40 | 41 | describe "#tweet" do 42 | let(:update_response) do 43 | { 44 | id: 1, 45 | retweet_count: 0, 46 | favorited: false, 47 | truncated: false, 48 | retweeted: false, 49 | source: "Crystal ANN", 50 | created_at: "Wed Sep 05 00:37:15 +0000 2012", 51 | text: "", 52 | } 53 | end 54 | 55 | let!(:stub_statuses_update) do 56 | WebMock.stub(:post, "https://api.twitter.com/1.1/statuses/update.json") 57 | .to_return(body: update_response.to_json) 58 | end 59 | 60 | it "makes a tweet" do 61 | expect(subject.tweet(announcement)).not_to be_false 62 | end 63 | end 64 | end 65 | -------------------------------------------------------------------------------- /src/controllers/announcement_controller.cr: -------------------------------------------------------------------------------- 1 | require "./application_controller" 2 | require "../workers/tweet_announcement" 3 | 4 | class AnnouncementController < ApplicationController 5 | PER_PAGE = 10 6 | 7 | before_action do 8 | only [:create] { check_rate_limit! } 9 | end 10 | 11 | def index 12 | query, current_page, type, user_id = query_param, page_param, type_param, user_id_param 13 | total_pages = Announcement.count(query, type, user_id).fdiv(PER_PAGE).ceil.to_i 14 | 15 | announcements = Announcement.search(query, per_page: PER_PAGE, page: current_page, type: type, user_id: user_id) 16 | render("index.slang") 17 | end 18 | 19 | def show 20 | if announcement = Announcement.find params["id"] 21 | newer, older = announcement.next, announcement.prev 22 | render("show.slang") 23 | else 24 | redirect_to "/" 25 | end 26 | end 27 | 28 | def new 29 | announcement = Announcement.new 30 | render("new.slang") 31 | end 32 | 33 | def create 34 | return redirect_to("/announcements/new") unless signed_in? 35 | 36 | announcement = Announcement.new announcement_params 37 | announcement.user_id = current_user!.id 38 | 39 | if announcement.valid? && announcement.save 40 | Workers::TweetAnnouncement.new.perform(announcement.id.not_nil!) 41 | redirect_to "/" 42 | else 43 | render("new.slang") 44 | end 45 | end 46 | 47 | def edit 48 | return redirect_to("/") unless signed_in? 49 | 50 | announcement = find_announcement 51 | if announcement && can_update?(announcement) 52 | render("edit.slang") 53 | else 54 | redirect_to "/" 55 | end 56 | end 57 | 58 | def update 59 | return redirect_to("/") unless signed_in? 60 | 61 | announcement = find_announcement 62 | if announcement && can_update?(announcement) 63 | announcement.set_attributes announcement_params 64 | if announcement.valid? && announcement.save 65 | redirect_to "/announcements/#{announcement.id}" 66 | else 67 | render("edit.slang") 68 | end 69 | else 70 | redirect_to "/" 71 | end 72 | end 73 | 74 | def destroy 75 | return redirect_to("/") unless signed_in? 76 | 77 | announcement = find_announcement 78 | announcement.destroy if announcement && can_update?(announcement) 79 | redirect_to "/" 80 | end 81 | 82 | def expand 83 | if announcement = Announcement.find_by_hashid(params["hashid"]) 84 | redirect_to "/announcements/#{announcement.id}" 85 | else 86 | redirect_to "/" 87 | end 88 | end 89 | 90 | def random 91 | if announcement = Announcement.random 92 | redirect_to "/announcements/#{announcement.id}" 93 | else 94 | redirect_to "/" 95 | end 96 | end 97 | 98 | private def announcement_params 99 | params.to_h.select %w(title description type) 100 | end 101 | 102 | private def find_announcement 103 | Announcement.find params["id"] 104 | end 105 | 106 | private def query_param 107 | params["query"]?.try &.gsub /[^0-9A-Za-z_\-\s]/, "" 108 | end 109 | 110 | private def page_param 111 | [params.fetch("page", "1").to_i { 1 }, 1].max 112 | end 113 | 114 | private def type_param 115 | (type = params["type"]?) && Announcement::TYPES.key(type) { -1 } 116 | end 117 | 118 | private def user_id_param 119 | User.find_by_login(params["user"]?).try(&.id) 120 | end 121 | 122 | private def check_rate_limit! 123 | return true unless rate_limit = max_anns_per_hour 124 | if current_user!.last_hour_announcements >= rate_limit 125 | redirect_to "/" 126 | end 127 | end 128 | 129 | private def limit_reached? 130 | if rate_limit = max_anns_per_hour 131 | current_user!.last_hour_announcements + 1 > rate_limit 132 | end 133 | end 134 | end 135 | -------------------------------------------------------------------------------- /src/controllers/application_controller.cr: -------------------------------------------------------------------------------- 1 | class ApplicationController < Amber::Controller::Base 2 | include Helpers::QueryHelper 3 | include Helpers::PageTitleHelper 4 | include Helpers::TimeAgoHelper 5 | 6 | before_action do 7 | all { redirect_force_ssl } 8 | end 9 | 10 | LAYOUT = "application.slang" 11 | 12 | @current_user : User? 13 | 14 | protected def current_user 15 | return @current_user = User.find_by(:login, "veelenga") if AMBER_ENV == "development" 16 | @current_user ||= User.find_by(:id, session["user_id"]) if session["user_id"] 17 | end 18 | 19 | protected def current_user! 20 | current_user.not_nil! 21 | end 22 | 23 | protected def signed_in? 24 | current_user != nil 25 | end 26 | 27 | protected def can_update?(announcement) 28 | current_user.try &.can_update? announcement 29 | end 30 | 31 | protected def redirect_force_ssl 32 | if SITE.force_ssl && request.headers["x-forwarded-proto"]? != "https" 33 | redirect_to "https://#{request.host_with_port}#{request.path}" 34 | end 35 | end 36 | 37 | protected def max_anns_per_hour 38 | ENV["MAX_ANNS_PER_HOUR"]?.try(&.to_i) 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /src/controllers/error_controller.cr: -------------------------------------------------------------------------------- 1 | class Amber::Controller::Error < Amber::Controller::Base 2 | LAYOUT = "application.slang" 3 | 4 | def forbidden 5 | "403 - Forbidden" 6 | end 7 | 8 | def not_found 9 | "404 - Page not found" 10 | end 11 | 12 | def internal_server_error 13 | "500 - Internal server error" 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /src/controllers/oauth_controller.cr: -------------------------------------------------------------------------------- 1 | class OAuthController < ApplicationController 2 | def new 3 | redirect_to oauth(find_provider).authorize_uri 4 | end 5 | 6 | def authenticate 7 | case provider = find_provider 8 | when "github" 9 | sign_in_user provider 10 | redirect_to "/announcements/new" 11 | when "twitter" 12 | if signed_in? 13 | save_user_handle provider 14 | redirect_to "/me" 15 | else 16 | redirect_to "/" 17 | end 18 | else 19 | redirect_to "/" 20 | end 21 | end 22 | 23 | private def sign_in_user(provider) 24 | u = oauth(provider).provider.user(params.to_h) 25 | 26 | user = User.find_by_uid_and_provider(u.uid, provider) || User.new 27 | user.set_attributes( 28 | { 29 | :uid => u.uid, 30 | :login => u.nickname, 31 | :name => u.name, 32 | :provider => provider, 33 | }) 34 | 35 | session["user_id"] = user.id.to_s if user.valid? && user.save 36 | end 37 | 38 | private def save_user_handle(provider) 39 | user = current_user! 40 | 41 | u = oauth(provider).provider.user(params.to_h) 42 | user.handle = u.nickname 43 | user.save 44 | end 45 | 46 | private def find_provider 47 | params["provider"]? || "github" 48 | end 49 | 50 | private def oauth(provider) 51 | MultiAuth.make provider, "#{SITE.url}/oauth/#{provider}" 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /src/controllers/rss_controller.cr: -------------------------------------------------------------------------------- 1 | class RSSController < Amber::Controller::Base 2 | include Helpers::TimeAgoHelper 3 | 4 | def show 5 | announcements = Announcement.newest 6 | 7 | respond_with do 8 | xml render "rss/show.slang", layout: false 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /src/controllers/session_controller.cr: -------------------------------------------------------------------------------- 1 | class SessionController < ApplicationController 2 | def destroy 3 | session.delete("user_id") 4 | redirect_to "/" 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /src/controllers/static_controller.cr: -------------------------------------------------------------------------------- 1 | class StaticController < ApplicationController 2 | def about 3 | render("about.slang") 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /src/controllers/user_controller.cr: -------------------------------------------------------------------------------- 1 | class UserController < ApplicationController 2 | def show 3 | if user = User.find_by_login params["login"] 4 | render "show.slang" 5 | else 6 | redirect_to "/" 7 | end 8 | end 9 | 10 | def me 11 | if user = current_user 12 | render "show.slang" 13 | else 14 | redirect_to "/" 15 | end 16 | end 17 | 18 | def remove_handle 19 | if user = current_user 20 | user.handle = nil 21 | user.save 22 | redirect_to "/me" 23 | else 24 | redirect_to "/" 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /src/crystal-ann.cr: -------------------------------------------------------------------------------- 1 | require "amber" 2 | require "../config/*" 3 | require "./helpers/**" 4 | require "./controllers/**" 5 | require "./mailers/**" 6 | require "./models/**" 7 | require "./views/**" 8 | require "../db/migrate" 9 | 10 | Amber::Server.instance.run 11 | -------------------------------------------------------------------------------- /src/helpers/page_title_helper.cr: -------------------------------------------------------------------------------- 1 | module Helpers::PageTitleHelper 2 | @title : String? 3 | 4 | def page_title 5 | page_title_with_suffix(@title || SITE.description) 6 | end 7 | 8 | def page_title(page_title) 9 | @title = page_title 10 | end 11 | 12 | private def page_title_with_suffix(title, suffix = SITE.name) 13 | "#{title} - #{suffix}" 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /src/helpers/query_helper.cr: -------------------------------------------------------------------------------- 1 | module Helpers::QueryHelper 2 | def to_query(**query) 3 | current_params = params.to_h.dup 4 | query.each { |k, v| current_params[k.to_s] = v.to_s } 5 | HTTP::Params.encode current_params 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /src/helpers/time_ago_helper.cr: -------------------------------------------------------------------------------- 1 | module Helpers::TimeAgoHelper 2 | extend self 3 | 4 | def time_ago_in_words(time) 5 | diff = Time.now - time 6 | 7 | case diff 8 | when 0.seconds..1.minute 9 | "just now" 10 | when 1.minute..2.minutes 11 | "a minute ago" 12 | when 2.minutes..1.hour 13 | "#{diff.minutes} minutes ago" 14 | when 1.hour..2.hours 15 | "an hour ago" 16 | when 2.hours..1.day 17 | "#{diff.hours} hours ago" 18 | when 1.day..2.days 19 | "a day ago" 20 | when 2.days..1.week 21 | "#{diff.days} days ago" 22 | when 1.week..2.weeks 23 | "a week ago" 24 | when 2.weeks..4.weeks 25 | "#{diff.total_weeks.to_i} weeks ago" 26 | when 4.weeks..8.weeks 27 | "a month ago" 28 | when 8.weeks..52.weeks 29 | "#{(diff.total_weeks.to_i / 4)} months ago" 30 | when 52.weeks..104.weeks 31 | "a year ago" 32 | else 33 | "#{(diff.total_weeks.to_i / (4 * 12))} years ago" 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /src/mailers/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/crystal-community/crystal-ann/f58bb9d97a24d1fc68374fc8143b8987451f3594/src/mailers/.gitkeep -------------------------------------------------------------------------------- /src/models/announcement.cr: -------------------------------------------------------------------------------- 1 | require "granite_orm/adapter/pg" 2 | require "markdown" 3 | require "autolink" 4 | 5 | class Announcement < Granite::ORM::Base 6 | TYPES = { 7 | 0_i64 => "blog_post", 8 | 1_i64 => "project_update", 9 | 2_i64 => "conference", 10 | 3_i64 => "meetup", 11 | 4_i64 => "podcast", 12 | 5_i64 => "screencast", 13 | 6_i64 => "video", 14 | 7_i64 => "other", 15 | } 16 | 17 | adapter pg 18 | 19 | field type : Int32 20 | field title : String 21 | field user_id : Int64 22 | field description : String 23 | timestamps 24 | 25 | belongs_to :user 26 | 27 | validate :title, "is too short", 28 | ->(this : Announcement) { this.title.to_s.size >= 5 } 29 | 30 | validate :title, "is too long", 31 | ->(this : Announcement) { this.title.to_s.size <= 100 } 32 | 33 | validate :type, "is not selected", 34 | ->(this : Announcement) { TYPES.keys.includes? this.type } 35 | 36 | validate :description, "is too short", 37 | ->(this : Announcement) { this.description.to_s.size >= 10 } 38 | 39 | validate :description, "is too long", 40 | ->(this : Announcement) { this.description.to_s.size <= 4000 } 41 | 42 | def self.search(query, per_page = nil, page = 1, type = nil, user_id = nil) 43 | q = %Q{ 44 | WHERE (title ILIKE $1 OR description ILIKE $1) 45 | #{"AND type='#{type}'" if type} 46 | #{"AND user_id='#{user_id}'" if user_id} 47 | ORDER BY created_at DESC 48 | LIMIT $2 OFFSET $3 49 | } 50 | parameters = ["%#{query}%", per_page, (page - 1) * per_page] 51 | self.all q, parameters 52 | end 53 | 54 | def self.count(query, type = nil, user_id = nil) 55 | @@adapter.open do |db| 56 | db.scalar(%Q{ 57 | SELECT COUNT(*) FROM announcements 58 | WHERE (title ILIKE $1 OR description ILIKE $1) 59 | #{"AND type='#{type}'" if type} 60 | #{"AND user_id='#{user_id}'" if user_id} 61 | }, "%#{query}%").as(Int64) 62 | end 63 | end 64 | 65 | def self.newest(from = 2.weeks.ago) 66 | Announcement.all("WHERE created_at > $1 ORDER BY created_at DESC", from) 67 | end 68 | 69 | def self.typename(type) 70 | TYPES[type].split("_").join(" ").capitalize 71 | end 72 | 73 | def self.find_by_hashid(hashid) 74 | if id = (HASHIDS.decode hashid).first? 75 | Announcement.find id 76 | end 77 | end 78 | 79 | def self.random 80 | Announcement.all("ORDER BY RANDOM() LIMIT 1").first? 81 | end 82 | 83 | def next 84 | Announcement.all("WHERE created_at > $1 ORDER BY created_at LIMIT 1", created_at).first? 85 | end 86 | 87 | def prev 88 | Announcement.all("WHERE created_at < $1 ORDER BY created_at DESC LIMIT 1", created_at).first? 89 | end 90 | 91 | def hashid 92 | HASHIDS.encode([id.not_nil!]) if id 93 | end 94 | 95 | def short_path 96 | id ? "/=#{hashid}" : nil 97 | end 98 | 99 | def typename 100 | Announcement.typename(type) 101 | end 102 | 103 | def content 104 | Autolink.auto_link(Markdown.to_html(description.not_nil!)) 105 | end 106 | end 107 | -------------------------------------------------------------------------------- /src/models/user.cr: -------------------------------------------------------------------------------- 1 | require "granite_orm/adapter/pg" 2 | 3 | class User < Granite::ORM::Base 4 | adapter pg 5 | 6 | # id : Int64 primary key is created for you 7 | field name : String 8 | field login : String 9 | field uid : String 10 | field provider : String 11 | field role : String 12 | field handle : String 13 | timestamps 14 | 15 | validate :login, "can't be blank", ->(this : User) { !this.login.to_s.blank? } 16 | validate :uid, "can't be blank", ->(this : User) { !this.uid.to_s.blank? } 17 | validate :provider, "can't be blank", ->(this : User) { !this.provider.to_s.blank? } 18 | 19 | def can_update?(announcement : Announcement) 20 | admin? || announcement.user_id == id 21 | end 22 | 23 | def me?(user : User) 24 | user.id == id 25 | end 26 | 27 | def admin? 28 | role == "admin" 29 | end 30 | 31 | def avatar_url(size : Int32 = 400) 32 | "#{github_url}.png?s=#{size}" 33 | end 34 | 35 | def github_url 36 | "https://github.com/#{login}" 37 | end 38 | 39 | def twitter_url 40 | return nil if handle.to_s.blank? 41 | "https://twitter.com/#{handle}" 42 | end 43 | 44 | def total_announcements 45 | Announcement.count(nil, user_id: id) 46 | end 47 | 48 | def self.find_by_uid_and_provider(uid, provider) 49 | User.all("WHERE uid = $1 AND provider = $2 LIMIT 1", [uid, provider]).first? 50 | end 51 | 52 | def self.find_by_login(login) 53 | User.all("WHERE login = $1 LIMIT 1", login).first? 54 | end 55 | 56 | def last_hour_announcements 57 | @@adapter.open do |db| 58 | db.scalar(%Q{ 59 | SELECT COUNT(*) FROM announcements 60 | WHERE user_id = $1 AND created_at > ((NOW() at time zone 'utc') - INTERVAL '1 HOUR') 61 | }, id).as(Int64) 62 | end 63 | end 64 | end 65 | -------------------------------------------------------------------------------- /src/sidekiq.cr: -------------------------------------------------------------------------------- 1 | require "../config/application" 2 | require "./workers/**" 3 | require "sidekiq/cli" 4 | 5 | cli = Sidekiq::CLI.new 6 | server = cli.configure { } 7 | cli.run(server) 8 | -------------------------------------------------------------------------------- /src/views/announcement/_entry.slang: -------------------------------------------------------------------------------- 1 | article.hentry 2 | header.entry-header 3 | h2.entry-title 4 | a href="/announcements/#{ announcement.id }" = announcement.title 5 | 6 | div.entry-content == announcement.content 7 | 8 | footer.entry-footer 9 | span.tags-links 10 | i.fa.fa-tag 11 | a href="/?#{to_query type: Announcement::TYPES[announcement.type], page: 1}" 12 | = announcement.typename 13 | 14 | - if user = announcement.user 15 | span.byline 16 | span.author.vcard 17 | i.fa.fa-user 18 | a href="/users/#{user.login}" 19 | span.fn 20 | = user.login 21 | 22 | span.posted-on 23 | time.entry-date.published.updated 24 | i.fa.fa-clock-o 25 | a href="/announcements/#{ announcement.id }" title="#{announcement.created_at.try &.to_s "%b %d, %Y"}" 26 | = time_ago_in_words(announcement.created_at.not_nil!) 27 | 28 | span.actions 29 | - if can_update?(announcement) 30 | a href="/announcements/#{ announcement.id }/edit" 31 | i.fa.fa-pencil 32 | a href="/announcements/#{ announcement.id }?_method=delete&_csrf=#{ csrf_token }" onclick="return confirm('Are you sure?');" 33 | i.fa.fa-trash 34 | 35 | a href="https://twitter.com/share?text=#{announcement.title}&url=#{SITE.url}#{announcement.short_path}&via=crystallang_ann&related=crystallang_ann" target="_blank" title="Retweet" 36 | i.fa.fa-retweet 37 | -------------------------------------------------------------------------------- /src/views/announcement/_form.slang: -------------------------------------------------------------------------------- 1 | - if announcement.errors 2 | ul.errors 3 | - announcement.errors.each do |error| 4 | li = error.to_s 5 | 6 | - action = (announcement.id ? "/announcements/#{ announcement.id }" : "/announcements") 7 | 8 | form action="#{ action }" method="post" 9 | == csrf_tag 10 | 11 | - if announcement.id 12 | input type="hidden" name="_method" value="patch" 13 | 14 | input type="text" name="title" autofocus="true" placeholder="Title goes here..." value="#{ announcement.title }" 15 | 16 | textarea rows="10" name="description" placeholder="Describe your announcement here and include links..." = announcement.description 17 | 18 | select name="type" 19 | option value="-1" selected="selected" = "-- select type --" 20 | - Announcement::TYPES.each do |type, _| 21 | - typename = Announcement.typename type 22 | - if type == announcement.type 23 | option value="#{ type }" selected="selected" = typename 24 | - else 25 | option value="#{ type }" = typename 26 | 27 | div.actions 28 | button type="submit" Submit 29 | 30 | link rel="stylesheet" href="https://cdn.jsdelivr.net/simplemde/latest/simplemde.min.css" 31 | script src="https://cdn.jsdelivr.net/simplemde/latest/simplemde.min.js" 32 | 33 | javascript: 34 | var simplemde = new SimpleMDE(); 35 | -------------------------------------------------------------------------------- /src/views/announcement/edit.slang: -------------------------------------------------------------------------------- 1 | - page_title announcement.title 2 | 3 | div.page-content 4 | h1 Edit Announcement 5 | 6 | == render partial: "_form.slang" 7 | -------------------------------------------------------------------------------- /src/views/announcement/index.slang: -------------------------------------------------------------------------------- 1 | - if announcements.size > 0 2 | - announcements.each do |announcement| 3 | == render partial: "_entry.slang" 4 | 5 | == render partial: "shared/_pagination.slang" 6 | 7 | - else 8 | div.page-content 9 | | Sorry, nothing found matching your query. 10 | -------------------------------------------------------------------------------- /src/views/announcement/new.slang: -------------------------------------------------------------------------------- 1 | div.page-content 2 | h1 Make an Announcement 3 | 4 | - if signed_in? 5 | - if limit_reached? 6 | ul.errors 7 | li 8 | = "You can create up to #{max_anns_per_hour} announcements per hour" 9 | - else 10 | == render partial: "_form.slang" 11 | - else 12 | form action="/oauth/new" 13 | button type="submit" 14 | i.fa.fa-github 15 | | Sign in and post 16 | -------------------------------------------------------------------------------- /src/views/announcement/show.slang: -------------------------------------------------------------------------------- 1 | - page_title announcement.title 2 | 3 | == render partial: "_entry.slang" 4 | 5 | nav.navigation.post-navigation role="navigation" 6 | div.nav-links 7 | - if newer 8 | div.nav-next 9 | a href="/announcements/#{newer.id}" 10 | span.meta-nav Previous 11 | span.post-title = newer.title 12 | 13 | - if older 14 | div.nav-previous 15 | a href="/announcements/#{older.id}" 16 | span.meta-nav Next 17 | span.post-title = older.title 18 | -------------------------------------------------------------------------------- /src/views/layouts/_footer.slang: -------------------------------------------------------------------------------- 1 | div.site-info 2 | div.footer-block.copyright 3 | | © #{Time.now.year} #{SITE.name}. #{SITE.description} 4 | 5 | div.footer-block 6 | h3 = SITE.name 7 | a href="/announcements/new" = "Announce" 8 | a href="/about" = "About" 9 | a href="/rss" = "RSS" 10 | a href="https://twitter.com/crystallang_ann" target="_blank" rel="noopener noreferrer" = "Twitter" 11 | a href="https://github.com/crystal-community/crystal-ann" target="_blank" rel="noopener noreferrer" = "GitHub" 12 | 13 | div.footer-block 14 | h3 = "Crystal Language" 15 | a href="https://crystal-lang.org/docs/" target="_blank" rel="noopener noreferrer" = "Learn" 16 | a href="https://github.com/crystal-lang/crystal" target="_blank" rel="noopener noreferrer" = "Source Code" 17 | a href="https://play.crystal-lang.org/#/cr" target="_blank" rel="noopener noreferrer" = "Play Online" 18 | a href="https://github.com/veelenga/awesome-crystal" target="_blank" rel="noopener noreferrer" = "Awesome Crystal List" 19 | a href="https://crystalshards.xyz" target="_blank" rel="noopener noreferrer" = "Crystal Shards" 20 | 21 | br style="clear:left;" 22 | -------------------------------------------------------------------------------- /src/views/layouts/_ga.slang: -------------------------------------------------------------------------------- 1 | javascript: 2 | (function(i,s,o,g,r,a,m){i['GoogleAnalyticsObject']=r;i[r]=i[r]||function(){ 3 | (i[r].q=i[r].q||[]).push(arguments)},i[r].l=1*new Date();a=s.createElement(o), 4 | m=s.getElementsByTagName(o)[0];a.async=1;a.src=g;m.parentNode.insertBefore(a,m) 5 | })(window,document,'script','https://www.google-analytics.com/analytics.js','ga'); 6 | 7 | ga('create', 'UA-102444498-1', 'auto'); 8 | ga('send', 'pageview'); 9 | -------------------------------------------------------------------------------- /src/views/layouts/_nav.slang: -------------------------------------------------------------------------------- 1 | nav#site-navigration.main-navigation 2 | ul.nav-menu 3 | li 4 | a href="/" 5 | | Home 6 | li 7 | a href="/announcements/new" 8 | | Announce 9 | li 10 | a href="/about" 11 | | About 12 | -------------------------------------------------------------------------------- /src/views/layouts/_profile.slang: -------------------------------------------------------------------------------- 1 | aside.widget.widget_meta 2 | h2.widget-title 3 | a href="/me" 4 | img.avatar src=current_user!.avatar_url(45) 5 | = current_user!.login 6 | 7 | ul 8 | li 9 | a href="/?user=#{current_user!.login}" 10 | | My announcements 11 | li 12 | a href="/sessions" id="sign-out-btn" csrf-token="#{csrf_token}" 13 | | Sign out 14 | -------------------------------------------------------------------------------- /src/views/layouts/_search.slang: -------------------------------------------------------------------------------- 1 | - query = params["query"]? 2 | - user = params["user"]? 3 | - type = params["type"]? 4 | 5 | aside.widget.search 6 | form name="search" action="/" 7 | input.search-input id="search-input" name="query" type="text" placeholder="Search..." value="#{query}" 8 | - if user 9 | input type="hidden" name="user" value="#{user}" 10 | - if type 11 | input type="hidden" name="type" value="#{type}" 12 | a href="/announcements/random" 13 | | I'm feeling lucky! 14 | -------------------------------------------------------------------------------- /src/views/layouts/_social_nav.slang: -------------------------------------------------------------------------------- 1 | nav#social-navigation.social-navigation 2 | div.menu-social-links-container 3 | ul#menu-social-links.menu 4 | li 5 | a href="https://twitter.com/crystallang_ann" target="_blank" rel="noopener noreferrer" 6 | i.fa.fa-twitter.fa-2x 7 | 8 | li 9 | a href="https://github.com/crystal-community/crystal-ann" target="_blank" rel="noopener noreferrer" 10 | i.fa.fa-github.fa-2x 11 | 12 | li 13 | a href="/rss" 14 | i.fa.fa-rss.fa-2x 15 | -------------------------------------------------------------------------------- /src/views/layouts/_tags.slang: -------------------------------------------------------------------------------- 1 | aside.widget.widget_meta 2 | h2.widget-title Popular tags 3 | ul.tags 4 | li 5 | - Announcement::TYPES.each do |type, name| 6 | a.tag-link href="/?#{to_query type: name, page: 1}" 7 | = Announcement.typename type 8 | -------------------------------------------------------------------------------- /src/views/layouts/application.slang: -------------------------------------------------------------------------------- 1 | doctype html 2 | html 3 | head 4 | title = page_title 5 | meta charset="utf-8" 6 | meta http-equiv="X-UA-Compatible" content="IE=edge" 7 | meta name="viewport" content="width=device-width, initial-scale=1.0" 8 | link rel="manifest" href="/manifest.json" 9 | link rel="stylesheet" href="/stylesheets/main.css" 10 | link rel="stylesheet" href="/stylesheets/monokai-sublime.min.css" 11 | link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.7.0/css/font-awesome.min.css" 12 | 13 | body.home 14 | div#page.hfeed.site 15 | div#sidebar.sidebar 16 | header#masthead.site-header role="banner" 17 | div.site-branding 18 | h1.site-title 19 | a href="/" = SITE.name 20 | p.site-description = SITE.description 21 | 22 | button.secondary-toggle 23 | 24 | div.secondary 25 | div 26 | == render partial: "layouts/_nav.slang" 27 | - if signed_in? 28 | == render partial: "layouts/_profile.slang" 29 | == render partial: "layouts/_search.slang" 30 | == render partial: "layouts/_tags.slang" 31 | == render partial: "layouts/_social_nav.slang" 32 | 33 | div#content.site-content 34 | div#primary.content-area 35 | main#main.site-main role="main" 36 | == content 37 | 38 | footer#colophon.site-footer role="contentinfo" 39 | == render partial: "layouts/_footer.slang" 40 | 41 | script src="/javascripts/main.js" 42 | script src="/javascripts/highlight.pack.js" 43 | script src="https://cdnjs.cloudflare.com/ajax/libs/mark.js/8.11.1/mark.min.js" 44 | 45 | == render partial: "layouts/_ga.slang" 46 | -------------------------------------------------------------------------------- /src/views/rss/show.slang: -------------------------------------------------------------------------------- 1 | rss version=2.0 2 | channel 3 | title = SITE.name 4 | description = SITE.description 5 | #{SITE.url} 6 | 7 | - announcements.each do |announcement| 8 | item 9 | title = announcement.title 10 | description = announcement.content 11 | | #{announcement.typename}. 12 | | Announced #{time_ago_in_words(announcement.created_at.not_nil!)} by 13 | - if user = announcement.user 14 | | #{user.login} 15 | pubDate = announcement.created_at.try &.to_s "%d %b %Y %T %z" 16 | #{SITE.url}/announcements/#{announcement.id} 17 | guid #{SITE.url}/announcements/#{announcement.id} 18 | -------------------------------------------------------------------------------- /src/views/shared/_pagination.slang: -------------------------------------------------------------------------------- 1 | - if total_pages > 1 2 | div.navigatation.pagination 3 | div.nav-links 4 | - if current_page > 1 5 | a.prev.page-numbers href="/?#{to_query page: current_page - 1}" 6 | i.fa.fa-chevron-left 7 | 8 | span.page-numbers 9 | = "#{ current_page } / #{ total_pages }" 10 | 11 | - if current_page < total_pages 12 | a.next.page-numbers href="/?#{to_query page: current_page + 1}" 13 | i.fa.fa-chevron-right 14 | -------------------------------------------------------------------------------- /src/views/static/about.slang: -------------------------------------------------------------------------------- 1 | div.page-content 2 | h2 What is Crystal [ANN] ? 3 | p 4 | | It is a resource where you can announce 5 | 6 | a href="https://crystal-lang.org" target="_blank" rel="noopener noreferrer" = "Crystal's programming language" 7 | 8 | | stuff you have created. A place where you can easily share with others: 9 | 10 | ul 11 | li 12 | | a new project or its release 13 | li 14 | | a blog post 15 | li 16 | | a conference or meetup 17 | li 18 | | a video, screencast or podcast 19 | li 20 | | or any other cool work that relates to Crystal programming language 21 | 22 | p 23 | | It is built with 24 | a href="https://amberframework.org/" target="_blank" rel="noopener noreferrer" = "Amber Web Framework" 25 | | and itself is a great example of Crystal's awesomeness. 26 | 27 | h2 Why should I post my stuff here ? 28 | p 29 | | Crystal [ANN] is intended to spread your announcement with community. 30 | | Your announcement will appear on this website, on 31 | 32 | a href="https://twitter.com/crystallang_ann" target="_blank" rel="noopener noreferrer" = "Twitter" 33 | 34 | | and distributed over 35 | 36 | a href="/rss" = "RSS." 37 | 38 | | People being interested will not miss it. 39 | 40 | h2 How can I help/contribute ? 41 | p 42 | | Crystal [ANN] is very young and grows together with Crystal programming language. 43 | | To make it more popular and fun you can: 44 | ol 45 | li 46 | a href="/announcements/new" = "Announce" 47 | | your work 48 | li 49 | | Follow it on 50 | a href="https://twitter.com/crystallang_ann" target="_blank" rel="noopener noreferrer" = "Twitter" 51 | li 52 | | Give it a star, report an issue or propose a change on 53 | a href="https://github.com/crystal-community/crystal-ann" target="_blank" rel="noopener noreferrer" = "GitHub" 54 | li 55 | | Love Crystal ;) 56 | -------------------------------------------------------------------------------- /src/views/user/show.slang: -------------------------------------------------------------------------------- 1 | - page_title user.name 2 | 3 | div.page-content 4 | div.author-info 5 | div.author-avatar 6 | img.avatar src=user.avatar_url 7 | 8 | div.author-description 9 | h3.author-title 10 | = user.name 11 | p 12 | i 13 | - if (total_announcements = user.total_announcements) > 0 14 | a href="/?user=#{user.login}" 15 | | An author of #{total_announcements} #{total_announcements == 1 ? "announcement" : "announcements"} 16 | - else 17 | | Is going to make an announcement. 18 | 19 | p 20 | i.fa.fa-github 21 | a href=user.github_url target="_blank" rel="noopener noreferrer" 22 | = user.login 23 | 24 | - if user.handle 25 | | / 26 | i.fa.fa-twitter 27 | a href=user.twitter_url target="_blank" rel="noopener noreferrer" 28 | = user.handle 29 | 30 | - if current_user.try &.me?(user) 31 | | ( 32 | a href="/users/remove_handle?_method=PUT&_csrf=#{csrf_token}" title="Disconnect Twitter" onclick="return confirm('Are you sure?');" 33 | | disconnect 34 | | ) 35 | 36 | - if !user.handle && current_user.try &.me?(user) 37 | 38 | div.connect-twitter 39 | form action="/oauth/new" method="get" 40 | input type="hidden" name="provider" value="twitter" 41 | button type="submit" 42 | i.fa.fa-twitter 43 | | Connect Twitter 44 | p 45 | small 46 | | You'll be mentioned alongside your announcement. 47 | 48 | div style="clear:left;" 49 | -------------------------------------------------------------------------------- /src/workers/tweet_announcement.cr: -------------------------------------------------------------------------------- 1 | require "sidekiq" 2 | 3 | require "twitter-crystal" 4 | require "../models/announcement" 5 | require "../models/user" 6 | 7 | module Workers 8 | class TweetAnnouncement 9 | include Sidekiq::Worker 10 | 11 | def initialize 12 | @twitter_client = 13 | Twitter::REST::Client.new ENV.fetch("TWITTER_CONSUMER_KEY", ""), 14 | ENV.fetch("TWITTER_CONSUMER_SECRET", ""), 15 | ENV.fetch("TWITTER_ACCESS_TOKEN", ""), 16 | ENV.fetch("TWITTER_ACCESS_TOKEN_SECRET", "") 17 | end 18 | 19 | def perform(id : Int64) 20 | if announcement = Announcement.find(id) 21 | tweet(announcement) 22 | end 23 | end 24 | 25 | def tweet(announcement) 26 | logger.info "Tweeting Announcement ##{announcement.id}" 27 | status = tweet_template announcement 28 | @twitter_client.update(status) 29 | rescue e 30 | logger.error "Unable to tweet Announcement ##{announcement.id} (#{status})" 31 | logger.error "Reason: #{e.message}" 32 | false 33 | end 34 | 35 | def tweet_template(announcement) 36 | String.build do |s| 37 | s << announcement.title 38 | s << " #{SITE.url}#{announcement.short_path}" 39 | s << " by @#{announcement.user.try &.handle}" if announcement.user.try &.handle 40 | s << " #crystallang" 41 | end 42 | end 43 | end 44 | end 45 | --------------------------------------------------------------------------------