Skip to content

Instantly share code, notes, and snippets.

@elorest
Created May 7, 2017 06:39
Show Gist options
  • Save elorest/71cc20ca190b4d7c9f3e55ae69fe8afc to your computer and use it in GitHub Desktop.
Save elorest/71cc20ca190b4d7c9f3e55ae69fe8afc to your computer and use it in GitHub Desktop.
- active = context.request.path == "/" ? "active" : ""
a class="nav-item #{active}" href="/" Home
- active = context.request.path == "/crystals" ? "active" : ""
a class="nav-item #{active}" href="/crystals" Crystals
- active = context.request.path == "/posts" ? "active" : ""
a class="nav-item #{active}" href="/posts" Posts
require "http"
require "logger"
require "json"
require "colorize"
require "secure_random"
require "kilt"
require "kilt/slang"
require "./amber/dsl/*"
require "./amber/support/*"
require "./amber/**"
module Amber
class Server
property port : Int32
property name : String
property env : String
property log : Logger
property secret : String
def self.instance
@@instance ||= new
end
def self.settings
instance
end
def initialize
@app_path = __FILE__
@name = "My Awesome App"
@port = 8080
@env = "development".colorize(:yellow).to_s
@log = ::Logger.new(STDOUT)
@log.level = ::Logger::INFO
@secret = SecureRandom.hex
end
def run
time = Time.now
host = "127.0.0.1"
str_host = "http://#{host}:#{port}".colorize(:light_cyan).underline
version = "[Amber #{Amber::VERSION}]".colorize(:light_cyan).to_s
log.info "#{version} serving application \"#{name}\" at #{str_host}".to_s
server = HTTP::Server.new(host, port, handler)
Signal::INT.trap do
puts "Shutting down Amber"
server.close
exit
end
log.info "Server started in #{env}.".to_s
log.info "Startup Time #{Time.now - time}\n\n".colorize(:white).to_s
server.listen
end
def config(&block)
with self yield self
end
macro routes(valve, scope = "")
router.draw {{valve}}, {{scope}} do
{{yield}}
end
end
macro pipeline(valve)
handler.build {{valve}} do
{{yield}}
end
end
def handler
Pipe::Pipeline.instance
end
private def router
Pipe::Router.instance
end
end
end
doctype html
html
head
title Amber_blog using Kemalyst
meta charset="utf-8"
meta http-equiv="X-UA-Compatible" content="IE=edge"
meta name="viewport" content="width=device-width, initial-scale=1"
link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.5/css/bootstrap.min.css"
link rel="stylesheet" href="/stylesheets/main.css"
body
div.masthead
div.container
nav.nav
== render "layouts/_nav.slang"
div.row
div.col-sm-12.main
== content
script src="https://ajax.googleapis.com/ajax/libs/jquery/1.11.3/jquery.min.js"
script src="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.5/js/bootstrap.min.js"
script src="/javascripts/main.js"
require "http"
module Amber
module Pipe
# The base class for Amber Pipes. This extension provides a singleton
# method and ability to configure each handler. All configurations should
# be maintained in the `/config` folder for consistency.
class Base
include HTTP::Handler
# Ability to configure the singleton instance from the class
def self.config
yield self.instance
end
# Ability to configure the instance
def config
yield self
end
# Execution of this handler.
def call(context : HTTP::Server::Context)
call_next context
end
end
end
end
require "../../../spec_helper"
module Amber::Controller
describe Base do
describe "#render" do
it "renders html from slang template" do
html_output = <<-HTML
<h1>Hello World</h1>\n<p>I am glad you came</p>
HTML
TestController.new.render_template_page.should eq html_output
end
it "renders html and layout from slang template" do
html_output = <<-HTML
<html>\n <body>\n <h1>Hello World</h1>\n<p>I am glad you came</p>\n </body>\n</html>
HTML
TestController.new.render_layout_too.should eq html_output
end
it "renders html and layout from slang template" do
html_output = <<-HTML
<html>\n <body>\n <h1>Hello World</h1>\n<p>I am glad you came</p>\n </body>\n</html>
HTML
TestController.new.render_both_inferred.should eq html_output
end
end
describe "#before_action" do
context "registering action filters" do
it "registers a before action" do
controller = build_controller("")
controller.before_filters
before_filters = controller.filters[:before]
before_filters.size.should eq 5
end
it "registers a after action" do
controller = build_controller("")
controller.after_filters
after_filters = controller.filters[:after]
after_filters.size.should eq 2
end
end
context "running filters" do
it "runs before filters" do
controller = build_controller("")
controller.run_before_filter(:index)
controller.total.should eq 4
end
it "runs after filters" do
controller = build_controller("")
controller.run_after_filter(:index)
controller.total.should eq 2
end
end
end
describe "#redirect_to" do
context "when location is a string" do
["www.amberio.com", "/world"].each do |location|
it "sets the correct response headers" do
hello_controller = build_controller("")
hello_controller.redirect_to location
response = hello_controller.response
response.headers["Location"].should eq location
end
end
end
context "when location is a Symbol" do
context "when is :back" do
context "and has a valid referer" do
it "sets the correct response headers" do
hello_controller = build_controller("/world")
hello_controller.redirect_to :back
response = hello_controller.response
response.headers["Location"].should eq "/world"
end
end
context "and does not have a referer" do
it "raisees an error" do
hello_controller = build_controller("")
expect_raises Exceptions::Controller::Redirect do
hello_controller.redirect_to :back
end
end
end
end
context "when is an action" do
hello_controller = build_controller("/world")
hello_controller.redirect_to :world
response = hello_controller.response
response.headers["Location"].should eq "/world"
end
end
end
end
end
module Amber::DSL
module Callbacks
macro before_action
def before_filters : Nil
filters.register :before do
{{yield}}
end
end
end
macro after_action
def after_filters : Nil
filters.register :after do
{{yield}}
end
end
end
end
end
# The Context holds the request and the response objects. The context is
# passed to each handler that will read from the request object and build a
# response object. Params and Session hash can be accessed from the Context.
class HTTP::Server::Context
alias ParamTypes = Nil | String | Int64 | Float64 | Bool | Hash(String, JSON::Type) | Array(JSON::Type)
# clear the params.
def clear_params
@params = HTTP::Params.new({} of String => Array(String))
end
# params hold all the parameters that may be passed in a request. The
# parameters come from either the url or the body via json or form posts.
def params
@params ||= HTTP::Params.new({} of String => Array(String))
end
def clear_files
@files = {} of String => UploadedFile
end
def files
@files ||= {} of String => UploadedFile
end
# clear the session. You can call this to logout a user.
def clear_session
@session = {} of String => String
end
# Holds a hash of session variables. This can be used to hold data between
# sessions. It's recommended to avoid holding any private data in the
# session since this is held in a cookie. Also avoid putting more than 4k
# worth of data in the session to avoid slow pageload times.
def session
@session ||= {} of String => String
end
# clear the flash messages.
def clear_flash
@flash = FlashHash.new
end
# Holds a hash of flash variables. This can be used to hold data between
# requests. Once a flash message is read, it is marked for removal.
def flash
@flash ||= FlashHash.new
end
# A hash that keeps track if its been accessed
class FlashHash < Hash(String, String)
def initialize
@read = [] of String
super
end
def fetch(key)
@read << key
super
end
def each
current = @first
while current
yield({current.key, current.value})
@read << current.key
current = current.fore
end
self
end
def unread
reject { |key, _| !@read.includes? key }
end
end
end
require "./*"
require "../support/locale_formats"
# We are patching the String class and Number struct to extend the predicates
# available this will allow to add friendlier methods for validation cases.
class String
include Amber::Extensions::StringExtension
end
abstract struct Number
include Amber::Extensions::NumberExtension
end
module Amber
module Pipe
# The CORS Handler adds support for Cross Origin Resource Sharing.
class CORS < Base
property allow_origin, allow_headers, allow_methods, allow_credentials,
max_age
def self.instance
@@instance ||= new
end
def initialize
@allow_origin = "*"
@allow_headers = "Accept, Content-Type"
@allow_methods = "GET, HEAD, POST, DELETE, OPTIONS, PUT, PATCH"
@allow_credentials = false
@max_age = 0
end
def call(context : HTTP::Server::Context)
begin
context.response.headers["Access-Control-Allow-Origin"] = allow_origin
# TODO: verify the actual origin matches allowed origins.
# if requested_origin = context.request.headers["Origin"]
# if allow_origins.includes? requested_origin
# end
# end
if allow_credentials
context.response.headers["Access-Control-Allow-credentials"] = "true"
end
if max_age > 0
context.response.headers["Access-Control-Max-Age"] = max_age.to_s
end
# if asking permission for request method or request headers
if context.request.method.downcase == "options"
context.response.status_code = 200
response = ""
if requested_method = context.request.headers["Access-Control-Request-Method"]
if allow_methods.includes? requested_method.strip
context.response.headers["Access-Control-Allow-Methods"] = allow_methods
else
context.response.status_code = 403
response = "Method #{requested_method} not allowed."
end
end
if requested_headers = context.request.headers["Access-Control-Request-Headers"]
requested_headers.split(",").each do |requested_header|
if allow_headers.includes? requested_header.strip
context.response.headers["Access-Control-Allow-Headers"] = allow_headers
else
context.response.status_code = 403
response = "Headers #{requested_headers} not allowed."
end
end
end
context.response.content_type = "text/html; charset=utf-8"
context.response.print(response)
else
call_next(context)
end
end
end
end
end
end
# Location for your initialization code
# {YourApp}/src/config/app.cr
# The config file that Amber generates, web/router.cr, will look something like
# this one:
# The first line requires the framework library.
require "amber"
require "./**"
require "./*"
MY_APP_SERVER = Amber::Server.instance
# This line represents how you will define your application configuration.
MY_APP_SERVER.config do |app|
# Server options
app_path = __FILE__ # Do not change unless you understand what you are doing.
app.name = "Hello World App" # A descriptive name for your app
app.port = 4000 # Port you wish your app to run
app.env = "development".colorize(:yellow).to_s
app.log = ::Logger.new(STDOUT)
app.log.level = ::Logger::INFO
# Every Amber application needs to define a pipeline set of pipes
# each pipeline allow a set of middleware transformations to be applied to
# different sets of route, this give you granular control and explicitness
# of which transformation to run for each of the app requests.
# All api scoped routes will run these transformations
pipeline :web do
# Plug is the method to use connect a pipe (middleware)
# A plug accepts an instance of HTTP::Handler
plug Amber::Pipe::Params.new
end
# All static content will run these transformations
pipeline :static do
plug HTTP::StaticFileHandler.new "../examples/public", false
plug HTTP::CompressHandler.new
end
routes :web do
resources "/posts", PostController, [:index, :show]
end
# This is how you define the routes for your application
# HTTP methods supported [GET, PATCH, POST, PUT, DELETE, OPTIONS]
# Read more about HTTP methods here
# (HTTP METHODS)[https://www.w3.org/Protocols/rfc2616/rfc2616-sec9.html]
# routes :static do
# # Each route is defined as follow
# # verb resource : String, controller : Symbol, action : Symbol
# get "/index.html", StaticController, :index
# end
#
# # Routes accepts a pipeline name and a scope a pipeline represents a stach of
# # http handlers that will process the current request
# routes :web, "/v2" do
# # You can also define all resources at once with the resources macro.
# # This will define the following routes
# # resources path, controller, actions
# # resources "/user", UserController, [:index, :show]
# # resources "/user", UserController, actions: [:index, :show]
# #
# # GET /users UserController :index
# # GET /users/:id/edit UserController :edit
# # GET /users/new UserController :new
# # GET /users/:id UserController :show
# # POST /users UserController :create
# # PATCH /users/:id UserController :update
# # PUT /users/:id UserController :update
# # DELETE /users/:id UserController :delete
#
# resources "/hello", HelloController, [:index, :show]
# get "/hello/world/:planet", HelloController, :world
# get "/hello/template", HelloController, :template
# end
end
# Run the server
MY_APP_SERVER.run
module Amber::Controller
module Callbacks
macro included
include Amber::DSL::Callbacks
property filters : Filters = Filters.new
def run_before_filter(action)
if self.responds_to? :before_filters
self.before_filters
@filters.run(:before, action)
@filters.run(:before, :all)
end
end
def run_after_filter(action)
if self.responds_to? :after_filters
self.after_filters
@filters.run(:after, action)
@filters.run(:after, :all)
end
end
end
end
record Filter, precedence : Symbol, action : Symbol, blk : -> Nil do
end
# Builds a BeforeAction filter.
#
# The yielded object has an `only` method that accepts two arguments,
# a key (`Symbol`) and a block ->`Nil`.
#
# ```
# FilterChainBuilder.build do |b|
# filter :index, :show { some_method }
# filter :delete { }
# end
# ```
record FilterBuilder, filters : Filters, precedence : Symbol do
def only(action : Symbol, &block : -> Nil)
add(action, &block)
end
def only(actions : Array(Symbol), &block : -> Nil)
actions.each { |action| add(action, &block) }
end
def all(&block : -> Nil)
filters.add Filter.new(precedence, :all, block)
end
def add(action, &block : -> Nil)
filters.add Filter.new(precedence, action, block)
end
end
class Filters
include Enumerable({Symbol, Array(Filter)})
property filters = {} of Symbol => Array(Filter)
def register(precedence : Symbol) : Nil
with FilterBuilder.new(self, precedence) yield
end
def add(filter : Filter)
filters[filter.precedence] ||= [] of Filter
filters[filter.precedence] << filter
end
def run(precedence : Symbol, action : Symbol)
filters[precedence].each do |filter|
filter.blk.call if filter.action == action
end
end
def [](name)
filters[name]
end
def []?(name)
fetch(name) { nil }
end
def fetch(name)
filters.fetch(name)
end
end
end
div.row
div.col-sm-11
h2 Posts
div.col-sm-1
a.btn.btn-success.btn-xs href="/posts/new" New
div.table-responsive
table.table.table-striped
thead
tr
th Title
th Content
th Actions
tbody
- posts.each do |post|
tr
td = post.title
td = post.content
td
span
a.btn.btn-primary.btn-xs href="/posts/#{ post.id }" read
a.btn.btn-success.btn-xs href="/posts/#{ post.id }/edit" edit
a.btn.btn-danger.btn-xs href="/posts/#{ post.id }?_method=delete" onclick="return confirm('Are you sure?');" delete
The MIT License (MIT)
Copyright (c) 2017 Elias Perez
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
module Amber
module Pipe
# This class picks the correct pipeline based on the request
# and executes it.
class Pipeline < Base
getter pipeline
getter valve : Symbol
def self.instance
@@instance ||= new
end
def initialize
@router = Pipe::Router.instance
@valve = :web
@pipeline = {} of Symbol => Array(HTTP::Handler)
@pipeline[@valve] = [] of HTTP::Handler
end
def call(context : HTTP::Server::Context)
valve = @router.match_by_request(context.request).payload.valve
pipe = proccess_pipeline(@pipeline[valve])
pipe.call(context)
context
end
# Connects pipes to a pipeline to process requests
def build(valve : Symbol, &block)
@valve = valve
@pipeline[@valve] = [] of HTTP::Handler unless @pipeline.key? @valve
with DSL::Pipeline.new(self) yield
end
def plug(pipe : HTTP::Handler)
@pipeline[@valve] << pipe
end
def proccess_pipeline(pipes, last_pipe : (Context ->)? = nil)
pipes << Router.instance unless pipes.includes? Router.instance
raise ArgumentError.new "You must specify at least one pipeline." if pipes.empty?
0.upto(pipes.size - 2) { |i| pipes[i].next = pipes[i + 1] }
pipes.last.next = last_pipe if last_pipe
pipes.first
end
end
end
end
require "../models/post"
class PostController < Amber::Controller::Base
def index
# posts = Post.all
# # context = context.not_nil!
# render("index.slang")
"sad too"
end
def show
# if post = Post.find params["id"]
# render("show.slang")
# else
# # context.flash["warning"] = "Post with ID #{params["id"]} Not Found"
# redirect "/posts"
# end
"what sad too"
end
# def new
# post = Post.new
# html render("post/new.slang", "main.slang")
# end
#
# def create
# post = Post.new(params.to_h.select(["title", "content"]))
#
# if post.valid? && post.save
# context.flash["success"] = "Created Post successfully."
# redirect "/posts"
# else
# context.flash["danger"] = "Could not create Post!"
# html render("post/new.slang", "main.slang")
# end
# end
#
# def edit
# if post = Post.find params["id"]
# html render("post/edit.slang", "main.slang")
# else
# context.flash["warning"] = "Post with ID #{params["id"]} Not Found"
# redirect "/posts"
# end
# end
#
# def update
# if post = Post.find(params["id"])
# post.set_attributes(params.to_h.select(["title", "content"]))
# if post.valid? && post.save
# context.flash["success"] = "Updated Post successfully."
# redirect "/posts"
# else
# context.flash["danger"] = "Could not update Post!"
# html render("post/edit.slang", "main.slang")
# end
# else
# context.flash["warning"] = "Post with ID #{params["id"]} Not Found"
# redirect "/posts"
# end
# end
#
# def delete
# if post = Post.find params["id"]
# post.destroy
# else
# context.flash["warning"] = "Post with ID #{params["id"]} Not Found"
# end
# redirect "/posts"
# end
end
module Amber::Controller
module Render
macro render_both(filename, layout)
content = render_template("{{filename.id}}")
render_template("{{layout.id}}")
end
# helper to render a template. The view name is relative to `src/views` directory.
macro render_template(filename, *args)
{% if filename.id.split("/").size > 2 %}
Kilt.render("{{filename.id}}", {{*args}})
{% else %}
Kilt.render("src/views/{{filename.id}}", {{*args}})
{% end %}
end
macro render(filename, layout = "layouts/application.slang", path = "src/views", folder = __FILE__)
render_both "#{{{path}}}/#{{{folder.split("/").last.gsub(/\_controller\.cr|\.cr/, "")}}}/#{{{filename}}}", "#{{{path}}}/#{{{layout}}}"
end
end
end
module Amber
class Route
property :controller, :handler, :action, :verb, :resource, :valve, :params,:scope
property wholeproc : Proc(HTTP::Server::Context, Symbol, String)
def initialize(@verb : String,
@resource : String,
@wholeproc,
@controller = Controller::Base.new,
@handler : Proc(String) = ->{ "500" },
@action : Symbol = :index,
@valve : Symbol = :web,
@scope : String = "")
end
def trail
"#{verb.to_s.downcase}#{scope}#{resource}"
end
def trail_head
"head#{scope}#{resource}"
end
def call(context)
# controller.set_context(context)
# controller.run_before_filter(:all)
# controller.run_before_filter(action)
# content = handler.call
# controller.run_after_filter(action)
# controller.run_after_filter(:all)
# content
wholeproc.call(context, action) unless wholeproc.is_a?(String)
end
end
end
require "radix"
module Amber
module Pipe
class Router < Base
property :routes
def self.instance
@@instance ||= new
end
def initialize
@routes = Radix::Tree(Route).new
end
def call(context : HTTP::Server::Context)
raise Exceptions::RouteNotFound.new(context.request) if !route_defined?(context.request)
route_node = match_by_request(context.request)
merge_params(route_node.params, context)
content = route_node.payload.call(context)
ensure
context.response.print(content)
context
end
# This registers all the routes for the application
def draw(valve : Symbol)
with DSL::Router.new(self, valve, "") yield
end
def draw(valve : Symbol, scope : String)
with DSL::Router.new(self, valve, scope) yield
end
def add(route : Route)
trail = build_node(route.verb, route.resource)
node = @routes.add(route.trail, route)
add_head(route) if route.verb == :GET
node
rescue Radix::Tree::DuplicateError
raise Amber::Exceptions::DuplicateRouteError.new(route)
end
def route_defined?(request)
match_by_request(request).found?
end
def match_by_request(request)
match(request.method, request.path)
end
private def merge_params(params, context)
params.each { |k, v| context.params.add(k.to_s, v) }
end
private def match(http_verb, resource) : Radix::Result(Amber::Route)
@routes.find build_node(http_verb, resource)
end
private def build_node(http_verb : Symbol | String, resource : String)
"#{http_verb.to_s.downcase}#{resource}"
end
private def add_head(route)
@routes.add(route.trail_head, route)
end
end
end
end
module Amber
module Extensions
module StringExtension
def str?
self.is_a? String
end
# email validation
def email?
!!self.match(/^[_]*([a-z0-9]+(\.|_*)?)+@([a-z][a-z0-9-]+(\.|-*\.))+[a-z]{2,6}$/)
end
# domain validation
def domain?
!!self.match(/^([a-z][a-z0-9-]+(\.|-*\.))+[a-z]{2,6}$/)
end
# url validation
def url?
!!self.match(/^(http(s)?(:\/\/))?(www\.)?[a-zA-Z0-9-_\.]+(\.[a-zA-Z0-9]{2,})([-a-zA-Z0-9:%_\+.~#?&\/\/=]*)/)
end
# ip v4 validation
def ipv4?
!!self.match(/^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/)
end
# ip v6 validation
def ipv6?
!!self.match(/^(?:4[0-9]{12}(?:[0-9]{3})?|5[1-5][0-9]{14}|6(?:011|5[0-9][0-9])[0-9]{12}|3[47][0-9]{13}|3(?:0[0-5]|[68][0-9])[0-9]{11}|(?:2131|1800|35\d{3})\d{11})$/)
end
# mac address validation
def mac_address?
!!self.match(/^([0-9a-fA-F][0-9a-fA-F]:){5}([0-9a-fA-F][0-9a-fA-F])$/)
end
# hex color validation
def hex_color?
!!self.match(/^#?([0-9A-F]{3}|[0-9A-F]{6})$/i)
end
# hexadecimal validation
def hex?
!!self.match(/^(0x)?[0-9A-F]+$/i)
end
# alpha characters validation
def alpha?(locale = "en-US")
!!self.match(Support::LocaleFormat::ALPHA[locale])
end
# numeric characters validation
def numeric?
!!self.match(/^([0-9]+)$/)
end
# alpha numeric characters validation
def alphanum?(locale = "en-US")
!!self.match(Support::LocaleFormat::ALPHA_NUM[locale])
end
# md5 validation
def md5?
!!self.match(/^[a-f0-9]{32}$/)
end
# base64 validation
def base64?
!!self.match(/^[a-zA-Z0-9+\/]+={0,2}$/) && (self.size % 4 === 0)
end
# slug validation
def slug?
!!self.match(/^([a-zA-Z0-9_-]+)$/)
end
# lower case validation
def lower?
self.downcase === self
end
# upper case validation
def upper?
self.upcase === self
end
# credit card validation
def credit_card?
!!self.match(/^(?:4[0-9]{12}(?:[0-9]{3})?|5[1-5][0-9]{14}|(222[1-9]|22[3-9][0-9]|2[3-6][0-9]{2}|27[01][0-9]|2720)[0-9]{12}|6(?:011|5[0-9][0-9])[0-9]{12}|3[47][0-9]{13}|3(?:0[0-5]|[68][0-9])[0-9]{11}|(?:2131|1800|35\d{3})\d{11})|62[0-9]{14}$/)
end
# phone number validation
def phone?(locale = "en-US")
!!self.match(Support::LocaleFormat::PHONE_FORMAT[locale])
end
def excludes?(value)
!!self.includes?(value)
end
# time string validation
def time_string?
!!self.match(/^(2[0-3]|[01]?[0-9]):([0-5]?[0-9]):([0-5]?[0-9])$/)
end
end
end
end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment