#tag building

content-tag builds an element with content and tag a self-closing one. Attribute values are escaped, a True value becomes a bare boolean attribute, and a False or undefined value is dropped. A data or aria hash expands to dasherized attributes, with structured values JSON encoded.

# <p class="lead">hi</p>
content-tag('p', 'hi', %( class => 'lead' ));

# <input disabled type="text" />
tag('input', %(
  type => 'text',
  disabled => True,
));

# <div data-toggle="modal" data-user-id="5">x</div>
content-tag('div', 'x', %(
  data => %( user_id => 5, toggle => 'modal' ),
));

A class attribute accepts an array of tokens or a hash of conditional tokens, and class-names builds the same token list on its own, dropping falsy and duplicate tokens.

# class="a b"
tag('span', %( class => ['a', 'b'] ));

# class="active" when $on
content-tag('div', 'x', %(
  class => %( active => $on ),
));

# "btn active"
class-names('btn', %(
  active => True,
  disabled => False,
));

javascript-tag wraps script content in a <script> element, time-tag builds a <time> with a datetime attribute, and auto-discovery-link-tag builds a feed link. atom-feed assembles a full Atom document from a feed builder.

# <script>alert(1)</script>
javascript-tag('alert(1)');

# <time datetime="2020-01-01">New Year</time>
time-tag(Date.new(2020, 1, 1), 'New Year');
auto-discovery-link-tag('atom', '/feed.atom');

#url helpers

url-for turns a target into a URL string, passing a string through and reading path from a hash. link-to builds an anchor, and button-to builds a small form that posts to a URL, adding a _method override for other verbs.

# <a class="nav" href="/posts">Posts</a>
link-to('Posts', '/posts', %( class => 'nav' ));

# <a href="/only">/only</a>
link-to('/only');
button-to('Delete', '/posts/1', %(
  method => 'delete',
));

#asset helpers

asset-path resolves a source to a URL: a bare source is prefixed with /assets/, an absolute path or external URL is left alone, and a :type adds an extension when the source has none. image-tag, stylesheet-link-tag, and javascript-include-tag build their elements, resolving sources through asset-path.

# /assets/logo.png
asset-path('logo.png');

# /assets/app.css
asset-path('app', :type('css'));

# <img alt="Logo" src="/assets/logo.png" />
image-tag('logo.png');
stylesheet-link-tag('app', 'admin');
javascript-include-tag('app');

The asset helpers accept a resolver, a routine of ($source, $type) that returns the final URL. It is the seam for fingerprinting or a CDN host, and a MVC::Keayl::View carries an asset-resolver its helpers use.

my $view = MVC::Keayl::View.new(
  :paths(['app/views']),
  asset-resolver => -> $source, $type {
    'https://cdn.example/' ~ $source
  },
);

#forms

form-with builds a form around a FormBuilder. The builder is passed to a content callback and scopes field names to the model (or an explicit scope), prefills values, and annotates errors. The form posts by default, adding a _method override for other verbs and an authenticity_token field when a csrf-token is given.

form-with(
  model      => $post,
  url        => '/posts/1',
  method     => 'patch',
  csrf-token => $token,
  content    => -> $f {
    $f.text-field('title') ~ $f.submit('Save')
  },
);

The builder provides text-field, password-field, hidden-field, text-area, check-box, radio-button, select, label, submit, and button, plus the HTML5 typed inputs (email-field, number-field, date-field, and the rest). Names are scoped (post[title]) and ids derived (post_title). A check-box renders a hidden companion, a password-field never emits a value, and file-field takes multiple and direct-upload options.

# type="email"
$f.email-field('email');

# type="date"
$f.date-field('born');

# name="post[photos][]"
$f.file-field('photos', %( multiple => True ));

$f.fields-for('author', block => -> $author {
  # name="post[author][name]"
  $author.text-field('name')
});

When the builder has a model, fields prefill from it by calling the attribute-named method, an explicit value overrides the model, and a field whose attribute reports errors gets a field-with-errors class. The builder reads errors through the model's errors-on($attribute) method, and errors-for renders the messages. Outside a builder, button-tag and image-submit-tag build a submit button and an image submit input.

#select and option helpers

options-for-select builds <option> tags from strings, label-value pairs, or [label, value, attrs] triples, marking the selected value. options-from-collection-for-select reads value and text methods off a collection, and grouped-options-for-select wraps choices in <optgroup> elements. select-tag wraps option markup in a <select>.

# <option value="a">a</option><option selected value="b">b</option>
options-for-select(['a', 'b'], 'b');

options-from-collection-for-select(
  @cities,
  'id',
  'name',
  $selected,
);

select-tag(
  'city',
  options-for-select(['NY', 'LA']),
  %( include-blank => True ),
);

The date-part helpers select-year, select-month (with month names), select-day, select-hour, select-minute, and select-second build one select each, and select-date and select-time combine them. On the builder, collection-select, collection-radio-buttons, and collection-check-boxes build controls from a collection, and date-select, time-select, and datetime-select build multiparameter selects prefilled from a model.

# year, month, and day selects
select-date(Date.new(2020, 3, 15));

$f.collection-select(
  'city-id',
  @cities,
  'id',
  'name',
);

# post[born](1i), (2i), (3i)
$f.date-select('born');

#simple_form-style inputs

SimpleFormBuilder adds an input method that assembles a label, control, hint, and error into a wrapper, inferring the control type. simple-form-for wraps it in a form like form-with. The type comes from as, then the attribute name (password, email, the long-text names), then a boolean model value, defaulting to a string input. A required attribute marks the wrapper and stars the label.

simple-form-for(
  $post,
  url => '/posts',
  required => ['title'],
  content => -> $f {
    $f.input('title', hint => 'Keep it short')
      # textarea, inferred
      ~ $f.input('body')
      # checkbox, inferred from a boolean
      ~ $f.input('published')
      ~ $f.input('state',
          as => 'select',
          collection => ['draft', 'live'])
  });

#formatting helpers

text

truncate cuts a string to a length and can break at a separator, pluralize pairs a count with a singular or pluralized word, and simple-format wraps text in paragraphs. highlight wraps phrase matches, excerpt extracts a window around a phrase, and word-wrap breaks lines at a width. strip-tags, strip-links, and sanitize-css clean untrusted markup.

# This is a...
truncate('This is a long sentence', length => 12);

# 2 people
pluralize(2, 'person');

# <p>Para one</p>\n<p>Para two</p>
simple-format("Para one\n\nPara two");

# You searched for <mark>tiger</mark>
highlight('You searched for tiger', 'tiger');

# Visit here now
strip-links('Visit <a href="/x">here</a> now');

cycle rotates through values across calls, capture returns a block's output as a safe string, and provide accumulates content under a name for a layout yield.

numbers

# 1,234,567
number-with-delimiter(1234567);

# $1,234.50
number-to-currency(1234.5);

# 66.7%
number-to-percentage(66.666, precision => 1);

# 1.5 KB
number-to-human-size(1536);

# (123) 555-1234
number-to-phone(1235551234, area-code => True);

# 12.3 Million
number-to-human(12345678);

dates and times

distance-of-time-in-words describes the gap between two times in words, with an optional include-seconds for finer detail under a minute. time-ago-in-words is the same measured against the current time.

# about 5 hours
distance-of-time-in-words($from, $to);

# less than 5 seconds
distance-of-time-in-words(
  $from,
  $to,
  include-seconds => True,
);

# 3 days ago
time-ago-in-words($posted-at);

#helper modules

Beyond the built-in helpers, an application defines its own as our subs in modules under app/helpers/. Each sub becomes a bare template call. ApplicationHelper is global, available in every view.

-# app/helpers/ApplicationHelper.rakumod
unit module ApplicationHelper;

our sub nav-link($label, $href) {
  qq{<a href="$href">$label</a>}
}
%nav
  != nav-link('Home', '/')

A per-controller helper named after the controller (UsersControllerapp/helpers/UsersHelper.rakumod) is available only in that controller's views, and the chain follows controller inheritance: a controller sees its own helper, its ancestors' helpers, and ApplicationHelper. A helper is a plain function. For request state, read $*KEAYL-CONTROLLER.

our sub current-user-name {
  $*KEAYL-CONTROLLER.current-user.name
}

Helper modules reload on change in development, the same as templates. The controller and scaffold generators write a matching helper module, and keayl new writes ApplicationHelper.