Liquid is a template language used by Jekyll, the static site generator behind this website and my theme, Chulapa (link).
Some time ago, @cargocultprogramming reported a broken theme component in dieghernan/chulapa#29. The problem appeared with Jekyll 4.1.0 and was also reported in jekyll/jekyll#8214.
The affected code used group_by to count repeated strings in an array:
{% assign alldocs = site.exercises %}
{% assign grouptag = alldocs | map: 'tags' | join: ',' | split: ',' | group_by: tag %}
{{ grouptag }}
In that example, tag is an undefined variable, rather than the name of a property on each item. Jekyll 4.0.1 produced separate groups for the strings, but 4.1.0 placed them in a single group with an empty name. This specific usage changed. Grouping objects by a named property remains supported.
The workaround below counts string occurrences without custom plugins. It uses Liquid templates with Jekyll-specific filters, including where_exp and push, so it is not portable to every Liquid environment. where_exp requires Jekyll 3.2.0 or later. The live examples run with the version used to build this site.
The algorithm is implemented in Chulapa. The expandable tables use this site's documents and change as posts are added. You can also see the tags on the /tags page.
Counting tag occurrences
For a small example, an input array of ['ruby', 'jekyll', 'ruby'] gives these two parallel arrays:
single_tags = ['ruby', 'jekyll']
count_tags = [2, 1]
There are two distinct tags. Each position in count_tags holds the number of occurrences of the tag at the same position in single_tags.
First, collect the tags from site.documents. Then keep the unique tags and count how often each appears in the full array:
{% assign alldocs = site.documents %}
{% assign alltags = alldocs | map: 'tags' | join: ',' | split: ',' %}
{% assign single_tags = alltags | uniq %}
{% assign count_tags = '' | split: ',' %}
{% for tag in single_tags %}
{% assign count_this_tag = alltags | where_exp: "item", "item == tag" | size %}
{% assign count_tags = count_tags | push: count_this_tag %}
{% endfor %}
The join and split filters flatten the tag arrays using a comma as a separator. This assumes that tag names contain no commas. The algorithm counts occurrences, so a repeated tag within one document would be counted more than once. Tag comparisons are case-sensitive.
To display the counts, iterate over the unique tags and use forloop.index0 to retrieve the corresponding count. An empty input produces no table rows.
<table>
<caption>Tag counts on this site</caption>
<thead>
<tr>
<th scope="col">Tag</th>
<th scope="col">Count</th>
</tr>
</thead>
<tbody>
{% for tag in single_tags %}
<tr>
<td>{{ tag | escape }}</td>
<td>{{ count_tags[forloop.index0] }}</td>
</tr>
{% endfor %}
</tbody>
</table>
| Tag | Count |
|---|---|
| r_bloggers | 24 |
| rstats | 33 |
| rspatial | 29 |
| sf | 17 |
| maps | 31 |
| rnaturalearth | 3 |
| music | 13 |
| function | 4 |
| leaflet | 6 |
| jekyll | 4 |
| html | 4 |
| beautiful_maps | 9 |
| giscoR | 13 |
| raster | 1 |
| flags | 4 |
| mapSpain | 4 |
| inset | 3 |
| r_package | 6 |
| classInt | 1 |
| terra | 6 |
| rasterpic | 1 |
| ggplot2 | 12 |
| tmap | 2 |
| mapsf | 1 |
| discontinued | 8 |
| project | 13 |
| python | 2 |
| guest-author | 1 |
| COVID19 | 3 |
| ggridges | 1 |
| tidyterra | 4 |
| maptiles | 1 |
| hillshading | 2 |
| s2 | 1 |
| astronomy | 2 |
| celestial | 2 |
| geojson | 3 |
| gpkg | 3 |
| resmush | 1 |
| liquid | 1 |
| chulapa | 3 |
| geobounds | 1 |
| dataset | 6 |
| indieweb | 1 |
| pebble | 4 |
| watchface | 4 |
| javascript | 5 |
| C | 4 |
| csv | 3 |
| 1 |
Sorting
To sort by descending frequency, find the largest count and work down to one. For each frequency, append the matching tags and their counts to two new arrays. This approach is also used in Minimal Mistakes.
{% assign sorted_tags = '' | split: ',' %}
{% assign sorted_count_tags = '' | split: ',' %}
{% if single_tags.size > 0 %}
{% assign n_tags = single_tags | size | minus: 1 %}
{% assign items_max = count_tags | sort | last %}
{% for i in (1..items_max) reversed %}
{% for j in (0..n_tags) %}
{% if count_tags[j] == i %}
{% assign sorted_tags = sorted_tags | push: single_tags[j] %}
{% assign sorted_count_tags = sorted_count_tags | push: i %}
{% endif %}
{% endfor %}
{% endfor %}
{% endif %}
Tags with equal counts retain their order of first appearance in alltags. The empty-input check avoids constructing index ranges or finding a maximum when there are no tags. Each unique tag is appended once, so a final uniq filter is unnecessary.
<table>
<caption>Tag counts in descending order</caption>
<thead>
<tr>
<th scope="col">Tag</th>
<th scope="col">Count</th>
</tr>
</thead>
<tbody>
{% for tag in sorted_tags %}
<tr>
<td>{{ tag | escape }}</td>
<td>{{ sorted_count_tags[forloop.index0] }}</td>
</tr>
{% endfor %}
</tbody>
</table>
| Tag | Count |
|---|---|
| rstats | 33 |
| maps | 31 |
| rspatial | 29 |
| r_bloggers | 24 |
| sf | 17 |
| music | 13 |
| giscoR | 13 |
| project | 13 |
| ggplot2 | 12 |
| beautiful_maps | 9 |
| discontinued | 8 |
| leaflet | 6 |
| r_package | 6 |
| terra | 6 |
| dataset | 6 |
| javascript | 5 |
| function | 4 |
| jekyll | 4 |
| html | 4 |
| flags | 4 |
| mapSpain | 4 |
| tidyterra | 4 |
| pebble | 4 |
| watchface | 4 |
| C | 4 |
| rnaturalearth | 3 |
| inset | 3 |
| COVID19 | 3 |
| geojson | 3 |
| gpkg | 3 |
| chulapa | 3 |
| csv | 3 |
| tmap | 2 |
| python | 2 |
| hillshading | 2 |
| astronomy | 2 |
| celestial | 2 |
| raster | 1 |
| classInt | 1 |
| rasterpic | 1 |
| mapsf | 1 |
| guest-author | 1 |
| ggridges | 1 |
| maptiles | 1 |
| s2 | 1 |
| resmush | 1 |
| liquid | 1 |
| geobounds | 1 |
| indieweb | 1 |
| 1 |
The counting step scans all tag occurrences for each unique tag, requiring roughly O(U * N) comparisons, where U is the number of unique tags and N is the total number of occurrences. The nested sorting loops perform O(U * M) comparisons, where M is the largest count. Array copying by push adds overhead. This is practical for small blogs, but large sites may benefit from computing tag counts outside the template.
Complete example
Here is the combined counting and sorting code. The resulting sorted_tags and sorted_count_tags arrays can be displayed with the table above.
{% assign alldocs = site.documents %}
{% assign alltags = alldocs | map: 'tags' | join: ',' | split: ',' %}
{% assign single_tags = alltags | uniq %}
{% assign count_tags = '' | split: ',' %}
{% for tag in single_tags %}
{% assign count_this_tag = alltags | where_exp: "item", "item == tag" | size %}
{% assign count_tags = count_tags | push: count_this_tag %}
{% endfor %}
{% assign sorted_tags = '' | split: ',' %}
{% assign sorted_count_tags = '' | split: ',' %}
{% if single_tags.size > 0 %}
{% assign n_tags = single_tags | size | minus: 1 %}
{% assign items_max = count_tags | sort | last %}
{% for i in (1..items_max) reversed %}
{% for j in (0..n_tags) %}
{% if count_tags[j] == i %}
{% assign sorted_tags = sorted_tags | push: single_tags[j] %}
{% assign sorted_count_tags = sorted_count_tags | push: i %}
{% endif %}
{% endfor %}
{% endfor %}
{% endif %}
Top comments (0)