on master help sign in
Remove @each pagination

The [page N] suffix sliced the list by a hidden $_page variable and injected $_total_pages and friends; it's the last special case in @each. It now reports an error, and htmlang upgrade flags it.

commit71762671a8c50d846c6d1c0fd8ce70771d865278 authoriko <mail@iko.soy> dateSat 26 Sep 2026, 02:35 (2 weeks ago) parent2995912
7 files changed+20−98
DESIGN.md+1 crates/htmlang-core/src/parser.rs+8−63 src/upgrade.rs+8 tests/regressions.rs−12 tests/snapshots.rs+3−19 tests/snapshots/pagination_each.hl−3 tests/snapshots/pagination_each.html−1
DESIGN.md+1
@@ -488,6 +488,7 @@ removed form with its replacement.
488488| `@let x $a + 1` (computed without `=`) | `@let x = $a + 1` |
489489| `COND ? A : B` | `if(COND, A, B)` |
490490| `$a ~ " " ~ $b` | `"$a $b"` |
491+| `@each $x in LIST [page N]` | Split the list, or filter it with `@if` |
491492| `...$bundle` | `$bundle` |
492493| `animate`, `inset-area` | `animation`, `position-area` |
493494| `$x\|uppercase`, `$c\|darken:10` (filters) | `${uppercase($x)}`, `${darken($c, 10)}` |
crates/htmlang-core/src/parser.rs+8−63
@@ -1325,14 +1325,14 @@ impl Parser {
13251325 let var_name = var_names[0].clone();
13261326 let index_var = var_names.get(1).cloned();
13271327
1328− // Strip a trailing `[page N]` pagination suffix so it doesn't end
1329− // up in the last item; it's parsed from the raw line below.
1330− let list_str = match list_str.rfind("[page ") {
1331− Some(pos) if list_str.trim_end().ends_with(']') => {
1332− list_str[..pos].trim_end().to_string()
1333− }
1334− _ => list_str,
1335− };
1328+ if list_str.trim_end().ends_with(']') && list_str.contains("[page ") {
1329+ return Err(ParseError {
1330+ line: line_num,
1331+ message: "@each pagination (`[page N]`) was removed: split the list or \
1332+ filter it with @if"
1333+ .to_string(),
1334+ });
1335+ }
13361336 track_var_refs(&list_str, &mut ctx.used_variables);
13371337 let list_str = substitute_vars(&list_str, &ctx.variables);
13381338 // Support range syntax: @each $i in 1..5 or @each $i in 0..100 step 10
@@ -1360,50 +1360,6 @@ impl Parser {
13601360 .collect()
13611361 };
13621362
1363− // Pagination: check for [page N] or [page N per P] suffix
1364− // Parse from the original rest before substitution
1365− let (items, pagination_info) = {
1366− // Check if list_str ended with a page directive (already consumed in items)
1367− // Instead, check the raw rest for [page ...] syntax
1368− let raw_rest = rest;
1369− let page_size = if let Some(page_pos) = raw_rest.find("[page ") {
1370− let after = &raw_rest[page_pos + 6..];
1371− if let Some(close) = after.find(']') {
1372− let page_spec = after[..close].trim();
1373− page_spec.parse::<usize>().ok()
1374− } else {
1375− None
1376− }
1377− } else {
1378− None
1379− };
1380−
1381− if let Some(size) = page_size {
1382− let current_page: usize = ctx
1383− .variables
1384− .get("_page")
1385− .and_then(|v| v.parse().ok())
1386− .unwrap_or(1)
1387− .max(1);
1388− let size = size.max(1);
1389− let total_items = items.len();
1390− let total_pages = total_items.div_ceil(size);
1391− let start = (current_page - 1).saturating_mul(size);
1392− let end = start.saturating_add(size).min(total_items);
1393− let page_items: Vec<String> = if start < total_items {
1394− items[start..end].to_vec()
1395− } else {
1396− Vec::new()
1397− };
1398− (
1399− page_items,
1400− Some((current_page, total_pages, size, total_items)),
1401− )
1402− } else {
1403− (items, None)
1404− }
1405− };
1406−
14071363 // Collect body lines
14081364 let mut body_lines = Vec::new();
14091365 while self.pos < self.lines.len() && self.lines[self.pos].indent > current_indent {
@@ -1466,17 +1422,6 @@ impl Parser {
14661422 let saved_vars = ctx.variables.clone();
14671423 let mut all_nodes = Vec::new();
14681424
1469− // Inject pagination variables if pagination is active
1470− if let Some((page, total_pages, page_size, total_items)) = pagination_info {
1471− ctx.variables.insert("_page".to_string(), page.to_string());
1472− ctx.variables
1473− .insert("_total_pages".to_string(), total_pages.to_string());
1474− ctx.variables
1475− .insert("_page_size".to_string(), page_size.to_string());
1476− ctx.variables
1477− .insert("_total_items".to_string(), total_items.to_string());
1478− }
1479−
14801425 let has_extra_vars = var_names.len() > 2
14811426 || (var_names.len() == 2 && items.first().is_some_and(|it| it.contains(' ')));
14821427
src/upgrade.rs+8
@@ -653,6 +653,14 @@ fn rewrite_directive_line(
653653 return format!("{pad}@data ${} {}", name, pattern);
654654 }
655655 }
656+ if trimmed.starts_with("@each ") && trimmed.trim_end().ends_with(']') && trimmed.contains("[page ") {
657+ manual.push((
658+ idx + 1,
659+ "@each pagination ([page N]) was removed: split the list or filter it with @if"
660+ .to_string(),
661+ ));
662+ return line.to_string();
663+ }
656664 if trimmed.starts_with("@fetch ") {
657665 manual.push((
658666 idx + 1,
tests/regressions.rs−12
@@ -69,12 +69,6 @@ fn json_unicode_escapes_are_decoded() {
6969 assert!(out.contains("Café 😀"), "{}", out);
7070}
7171
72−#[test]
73−fn page_zero_does_not_underflow() {
74− let out = compile("@let _page 0\n@each $x in a, b, c [page 2]\n @text $x");
75− assert!(out.contains(">a<") && out.contains(">b<"), "{}", out);
76−}
77−
7872#[test]
7973fn range_at_integer_limit_terminates() {
8074 let out = compile("@each $i in 9223372036854775806..9223372036854775807\n @text $i");
@@ -185,12 +179,6 @@ fn triple_quoted_let_is_a_string_not_a_function() {
185179 assert!(!out.contains("$msg"), "{}", out);
186180}
187181
188−#[test]
189−fn pagination_suffix_is_not_an_item() {
190− let out = compile("@each $x in a, b [page 5]\n @text $x");
191− assert!(!out.contains("[page"), "{}", out);
192−}
193−
194182#[test]
195183fn each_else_does_not_leak_variables() {
196184 let out = compile(
tests/snapshots.rs+3−19
@@ -4458,8 +4458,9 @@ fn snapshot_translations_i18n() {
44584458}
44594459
44604460#[test]
4461−fn snapshot_pagination_each() {
4462− snapshot_test("pagination_each");
4461+fn each_pagination_was_removed() {
4462+ let diags = parse_diagnostics("@each $item in a,b,c [page 2]\n @text $item");
4463+ assert!(diags.iter().any(|d| d.message.contains("pagination")), "{:?}", diags);
44634464}
44644465
44654466#[test]
@@ -4489,23 +4490,6 @@ fn translations_point_to_data_files() {
44894490 );
44904491}
44914492
4492−#[test]
4493−fn pagination_limits_items() {
4494− let output = compile("@let _page 1\n@each $item in a,b,c,d,e [page 2]\n @text $item");
4495− // Page 1 with page size 2 should show items a,b only
4496− assert!(output.contains(">a<"), "page 1 should contain item a");
4497− assert!(output.contains(">b<"), "page 1 should contain item b");
4498− assert!(!output.contains(">c<"), "page 1 should NOT contain item c");
4499−}
4500−
4501−#[test]
4502−fn pagination_page_2() {
4503− let output = compile("@let _page 2\n@each $item in a,b,c,d,e [page 2]\n @text $item");
4504− assert!(output.contains(">c<"), "page 2 should contain item c");
4505− assert!(output.contains(">d<"), "page 2 should contain item d");
4506− assert!(!output.contains(">a<"), "page 2 should NOT contain item a");
4507−}
4508−
45094493#[test]
45104494fn image_auto_preload() {
45114495 let output = compile("@page Test\n@image logo.png\n@image hero.jpg");
tests/snapshots/pagination_each.hldeleted−3
@@ -1,3 +0,0 @@
1−@let _page 1
2−@each $item in a,b,c,d,e,f [page 2]
3− @text $item
tests/snapshots/pagination_each.htmldeleted−1
@@ -1 +0,0 @@
1−<style>@layer htmlang{}</style><span>a</span><span>b</span>