Platform
Learn
Developer docs User guide Quickstart Blog
Company
Services About Contact Links Get started

Template recipes

Updated

Each recipe here is a snippet you can paste into a template, with a note on where it goes and what it assumes. They share one example content type, posts, with a category dropdown, a summary long text, a hero_image attachment and an author one-to-one relationship to another content type. Rename them to match your own.

Every snippet was parsed and rendered with Raytha's own template engine before it went on this page. Text is escaped, and links to site pages start with {{ PathBase }}, so they keep working when Raytha runs under a sub-path.

1. Latest posts on the home page

Put this in the web template of your home page (a site page template or a list view template). A widget cannot use PathBase, so for the same list inside a widget see Widget templates.

{% assign latest = get_content_items(ContentType="posts", OrderBy="CreationTime desc", PageSize=3) %}
{% if latest.Items.size > 0 %}
  <section>
    <h2>Latest posts</h2>
    {% for post in latest.Items %}
      <article>
        <h3><a href="{{ PathBase }}/{{ post.RoutePath }}">{{ post.PrimaryField | escape }}</a></h3>
        <p>{{ post.CreationTime | organization_time: "%B %e, %Y" }}</p>
        {% if post.PublishedContent.summary.HasValue %}
          <p>{{ post.PublishedContent.summary.Value | escape }}</p>
        {% endif %}
      </article>
    {% endfor %}
    <a href="{{ PathBase }}/blog">All posts</a>
  </section>
{% endif %}

The function returns published items only. If the content type does not exist, latest.Items.size is 0 and the section is skipped.

2. A blog list with page links

For a site page, or anywhere that is not a list view. (A list view already has Target.Items and pager variables; see Detail views vs list views.) The page number comes from ?page=:

{% assign per_page = 10 %}
{% assign page = QueryParams.page | default: 1 | plus: 0 | floor %}
{% if page < 1 %}{% assign page = 1 %}{% endif %}
{% assign result = get_content_items(ContentType="posts", OrderBy="CreationTime desc", PageNumber=page, PageSize=per_page) %}
{% assign total_pages = result.TotalCount | plus: per_page | minus: 1 | divided_by: per_page %}

{% for post in result.Items %}
  <h2><a href="{{ PathBase }}/{{ post.RoutePath }}">{{ post.PrimaryField | escape }}</a></h2>
{% endfor %}

<nav aria-label="Pages">
  {% if page > 1 %}<a href="?page={{ page | minus: 1 }}">Newer</a>{% endif %}
  <span>Page {{ page }} of {{ total_pages | at_least: 1 }}</span>
  {% if page < total_pages %}<a href="?page={{ page | plus: 1 }}">Older</a>{% endif %}
</nav>

TotalCount is the number of matches across all pages, so the page count is ceil(TotalCount / per_page) written with integer maths. A page that is not a number becomes 0 and then 1, and a number past the end gives an empty page, because PageNumber is not clamped.

3. Related items through a relationship

Three variations, each on a post's detail template.

The item the post points at. A one-to-one relationship field is the related content item itself, not a value object. When nothing is chosen it is an empty string, so test for .Id before you use it:

{% assign author = Target.PublishedContent.author %}
{% if author.Id and author.IsPublished %}
  <p>By <a href="{{ PathBase }}/{{ author.RoutePath }}">{{ author.PrimaryField | escape }}</a></p>
{% endif %}

Other posts in the same category. Build the filter with capture, and leave the current post out with Id ne:

{% assign category = Target.PublishedContent.category.Value | replace: "'", "''" %}
{% if category != blank %}
  {% capture filter %}category eq '{{ category }}' and Id ne '{{ Target.Id }}'{% endcapture %}
  {% assign more = get_content_items(ContentType="posts", Filter=filter, OrderBy="CreationTime desc", PageSize=3) %}
  {% if more.Items.size > 0 %}
    <h2>More in {{ category | escape }}</h2>
    <ul>
      {% for post in more.Items %}
        <li><a href="{{ PathBase }}/{{ post.RoutePath }}">{{ post.PrimaryField | escape }}</a></li>
      {% endfor %}
    </ul>
  {% endif %}
{% endif %}

The other direction. On the author's own detail template, list every post whose author is this item. A relationship field accepts an id in a filter:

{% capture filter %}author eq '{{ Target.Id }}'{% endcapture %}
{% assign written = get_content_items(ContentType="posts", Filter=filter, OrderBy="CreationTime desc", PageSize=20) %}
{% for post in written.Items %}
  <p><a href="{{ PathBase }}/{{ post.RoutePath }}">{{ post.PrimaryField | escape }}</a></p>
{% endfor %}

A relationship can point at an unpublished item. Test IsPublished before you link, as in the first example. The filter rules are in Filtering and sorting in templates.

4. Group by category

{% assign posts = get_content_items(ContentType="posts", OrderBy="PrimaryField asc", PageSize=200) %}
{% assign groups = posts.Items | groupby: "PublishedContent.category" %}
{% for group in groups %}
  <h2>{{ group.key | default: "Uncategorized" | capitalize | escape }}</h2>
  <ul>
    {% for post in group.items %}
      <li><a href="{{ PathBase }}/{{ post.RoutePath }}">{{ post.PrimaryField | escape }}</a></li>
    {% endfor %}
  </ul>
{% endfor %}

Groups come out in the order each category first appears in the sorted list. The key is the dropdown's stored developer name, not its label; see Functions and filters to turn it into the label. Only the fetched page of items is grouped, so raise PageSize (the limit is 1000) or accept that the page is a sample.

5. A sidebar menu with the current page marked

Create a menu in the admin with the developer name sidebar (see Manage navigation menus), then:

{% assign menu = get_menu("sidebar") %}
{% if menu %}
  {% assign here = PathBase | append: "/" | append: Target.RoutePath %}
  <nav aria-label="Section">
    <ul>
      {% for item in menu.MenuItems %}
        {% unless item.IsDisabled %}
          <li>
            <a href="{{ item.Url }}"{% if item.Url == here %} aria-current="page"{% endif %}{% if item.OpenInNewTab %} target="_blank" rel="noopener"{% endif %}>{{ item.Label | escape }}</a>
            {% if item.MenuItems.size > 0 %}
              <ul>
                {% for child in item.MenuItems %}
                  <li><a href="{{ child.Url }}"{% if child.Url == here %} aria-current="page"{% endif %}>{{ child.Label | escape }}</a></li>
                {% endfor %}
              </ul>
            {% endif %}
          </li>
        {% endunless %}
      {% endfor %}
    </ul>
  </nav>
{% endif %}

Use get_menu, not get_main_menu, for secondary menus: it returns nothing for an unknown name instead of failing the page. The comparison works when the menu item URL is entered the way the template builds it (for example /blog/hello). Use aria-current, then style it in your CSS.

6. Images and files from attachment fields

{% assign image = Target.PublishedContent.hero_image %}
{% if image.HasValue %}
  <img src="{{ image.Value | attachment_url }}" alt="{{ Target.PrimaryField | escape }}" loading="lazy">
{% endif %}

<link rel="icon" href="{{ "favicon.ico" | attachment_public_url }}">

An attachment field stores the object key of the uploaded file. attachment_url turns it into a link that never expires and redirects to wherever the file lives now, which makes it the right choice for content. attachment_public_url returns the storage provider's own URL, and fits theme files that you reference by file name from a layout. See File storage.

7. SEO meta tags in the layout

Put this in the <head> of your base layout. It works for content items (which have PrimaryField and PublishedContent), site pages (which have Title) and list views.

{% assign site_name = CurrentOrganization.OrganizationName %}
{% assign page_title = Target.PrimaryField | default: Target.Title | default: Target.Label %}
{% assign description = Target.PublishedContent.summary.Value | strip_html | strip_newlines | truncate: 155 %}
{% assign site_url = CurrentOrganization.WebsiteUrl %}
{% assign last_char = site_url | slice: -1 %}
{% if last_char == "/" %}
  {% assign keep = site_url.size | minus: 1 %}
  {% assign site_url = site_url | slice: 0, keep %}
{% endif %}

<title>{% if page_title %}{{ page_title | escape }} | {% endif %}{{ site_name | escape }}</title>
{% if description != blank %}
  <meta name="description" content="{{ description | escape }}">
{% endif %}
<link rel="canonical" href="{{ site_url }}/{{ Target.RoutePath }}">
<meta property="og:site_name" content="{{ site_name | escape }}">
<meta property="og:title" content="{{ page_title | default: site_name | escape }}">
{% if description != blank %}
  <meta property="og:description" content="{{ description | escape }}">
{% endif %}
{% if Target.PublishedContent.hero_image.HasValue %}
  <meta property="og:image" content="{{ site_url }}{{ Target.PublishedContent.hero_image.Value | attachment_url }}">
{% endif %}

This needs care in two places. A site page has no PublishedContent, so description stays empty there; use the page title or a setting instead. And the Open Graph image has to be an absolute URL, so the snippet joins it to the site address. The first lines remove a trailing slash, because WebsiteUrl is whatever an administrator typed. If Raytha runs under a sub-path, attachment_url already includes it, so set WebsiteUrl to the host only.

For structured data on a detail page, build the JSON with the json filter, which quotes and escapes strings. Put this after the block above in the same template, because it reuses site_url:

{% if Target.PublishedContent %}
<script type="application/ld+json">
{ "@context": "https://schema.org", "@type": "Article",
  "headline": {{ Target.PrimaryField | json }},
  "datePublished": {{ Target.CreationTime | date: "%Y-%m-%d" | json }},
  "url": {{ site_url | append: "/" | append: Target.RoutePath | json }} }
</script>
{% endif %}

8. List the children of a route prefix

The filter engine cannot filter on RoutePath, so fetch a page of items and test the prefix in Liquid. This lists every pages item whose route starts with docs/, other than the current one:

{% assign prefix = "docs/" %}
{% assign everything = get_content_items(ContentType="pages", OrderBy="PrimaryField asc", PageSize=1000) %}
<ul>
  {% for item in everything.Items %}
    {% assign head = item.RoutePath | slice: 0, prefix.size %}
    {% if head == prefix and item.RoutePath != Target.RoutePath %}
      <li><a href="{{ PathBase }}/{{ item.RoutePath }}">{{ item.PrimaryField | escape }}</a></li>
    {% endif %}
  {% endfor %}
</ul>

The limit is 1000 items per call. If a content type is bigger, add a "section" dropdown field and filter on it instead: Filter="section eq 'docs'". That is cheaper too, because the database does the matching.

9. A search box on a list view

get_content_items has no search argument. Search lives on list views, where the search query parameter is applied for you. In the list view's web template:

<form method="get" action="{{ PathBase }}/{{ Target.RoutePath }}" role="search">
  <input type="search" name="search" value="{{ Target.Search | escape }}" placeholder="Search posts">
  <button type="submit">Search</button>
</form>

{% if Target.Search != blank %}
  <p>{{ Target.TotalCount }} results for "{{ Target.Search | escape }}"</p>
{% endif %}

{% for item in Target.Items %}
  <h3><a href="{{ PathBase }}/{{ item.RoutePath }}">{{ item.PrimaryField | escape }}</a></h3>
{% else %}
  <p>Nothing found.</p>
{% endfor %}

To search from another page, such as the home page, make the form's action the list view's address, as above, and keep the field named search. Which fields are searched is set on the view. Paging links must keep the term; see the pager in Detail views vs list views.

10. A members-only snippet

{% if CurrentUser.IsAuthenticated %}
  <p>Welcome back, {{ CurrentUser.FirstName | escape }}.</p>
  {% if CurrentUser.UserGroups contains "members" %}
    <a href="{{ Target.PublishedContent.download.Value | attachment_url }}">Download the member guide</a>
  {% endif %}
{% else %}
  <p><a href="{{ PathBase }}/account/login">Sign in</a> to see the member download.</p>
{% endif %}

The markup inside the false branch is never written to the page, so visitors cannot see it in the source. The page itself is still public, and the content item is still reachable at its address. Use this to personalise a page, not to protect a secret. Groups are the ones you manage under public users and user groups; see Public users and user groups.

Gotchas

  • A filter built from visitor input needs its quotes doubled, as in recipe 3. See Filtering and sorting in templates.
  • Calling get_content_items inside a loop runs a query per pass. Fetch once and group in Liquid, as in recipes 4 and 8.
  • Nothing here is cached. A busy home page that runs four calls runs four queries on every request.

Next steps