Skip to main content

forge_codegen/
lower.rs

1//! Lower a Forge token stream into Askama template syntax (the default) or
2//! into a MiniJinja-compatible runtime form (for Spark component bodies and
3//! other dynamic templates).
4
5use crate::parser::Token;
6
7/// Which template engine the lowered output targets.
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
9pub enum LowerTarget {
10    /// Compile-time Askama. Rust expressions inside directives are valid.
11    #[default]
12    Askama,
13    /// Runtime MiniJinja. Spark directives lower to function calls that the
14    /// runtime registers on the `Environment`; other Rust-flavored directives
15    /// pass through unchanged (and will likely fail at render time if used).
16    MiniJinja,
17}
18
19pub fn lower(tokens: &[Token]) -> String {
20    lower_with_target(tokens, LowerTarget::Askama)
21}
22
23pub fn lower_with_target(tokens: &[Token], target: LowerTarget) -> String {
24    let mut out = String::new();
25    let mut stack_pushes: Vec<String> = Vec::new();
26    let mut forelse_stack: Vec<String> = Vec::new(); // collection identifier per @forelse
27    let mut switch_started = false; // whether we've emitted the first {% if %} for a @switch
28
29    for tok in tokens {
30        match tok {
31            Token::Text(s) => out.push_str(s),
32
33            Token::EscapedExpr(expr) => {
34                out.push_str("{{ ");
35                out.push_str(expr);
36                out.push_str(" }}");
37            }
38
39            Token::RawExpr(expr) => {
40                out.push_str("{{ ");
41                out.push_str(expr);
42                out.push_str("|safe }}");
43            }
44
45            Token::Directive { name, args } => {
46                let args_inner = args.as_deref().unwrap_or("").trim();
47                let args_stripped_quotes = args_inner.trim_matches(|c| c == '"' || c == '\'');
48
49                match name.as_str() {
50                    // ─── Control flow ──────────────────────────────────────
51                    "if" => emit_if(&mut out, args_inner),
52                    "elseif" => emit_elif(&mut out, args_inner),
53                    "else" => out.push_str("{% else %}"),
54                    "endif" => out.push_str("{% endif %}"),
55
56                    "unless" => {
57                        out.push_str("{% if !(");
58                        out.push_str(args_inner);
59                        out.push_str(") %}");
60                    }
61                    "endunless" => out.push_str("{% endif %}"),
62
63                    "isset" => {
64                        // Askama doesn't have isset; treat Option types via .is_some()
65                        out.push_str("{% if (");
66                        out.push_str(args_inner);
67                        out.push_str(").is_some() %}");
68                    }
69                    "endisset" => out.push_str("{% endif %}"),
70
71                    "empty" if !forelse_stack.is_empty() => {
72                        // Inside @forelse: end the for and emit else branch.
73                        out.push_str("{% endfor %}");
74                        let _ = forelse_stack.pop();
75                        out.push_str("{% if true %}");
76                        // Track the @empty branch as a pseudo-if so @endforelse closes it.
77                        forelse_stack.push("__empty_branch__".to_string());
78                    }
79                    "empty" => {
80                        // Standalone @empty: shorthand for @if(value.is_empty())
81                        out.push_str("{% if (");
82                        out.push_str(args_inner);
83                        out.push_str(").is_empty() %}");
84                    }
85                    "endempty" => out.push_str("{% endif %}"),
86
87                    // ─── Loops ─────────────────────────────────────────────
88                    "foreach" => {
89                        let (lhs, rhs) = split_foreach(args_inner);
90                        out.push_str("{% for ");
91                        out.push_str(&rhs);
92                        out.push_str(" in ");
93                        out.push_str(&lhs);
94                        out.push_str(" %}");
95                    }
96                    "endforeach" => out.push_str("{% endfor %}"),
97
98                    "forelse" => {
99                        let (lhs, rhs) = split_foreach(args_inner);
100                        forelse_stack.push(lhs.clone());
101                        out.push_str("{% for ");
102                        out.push_str(&rhs);
103                        out.push_str(" in ");
104                        out.push_str(&lhs);
105                        out.push_str(" %}");
106                    }
107                    "endforelse" => {
108                        // Close whichever branch we're in.
109                        if let Some(top) = forelse_stack.pop() {
110                            if top == "__empty_branch__" {
111                                out.push_str("{% endif %}");
112                            } else {
113                                out.push_str("{% endfor %}");
114                            }
115                        }
116                    }
117
118                    "for" => {
119                        out.push_str("{% for ");
120                        out.push_str(args_inner);
121                        out.push_str(" %}");
122                    }
123                    "endfor" => out.push_str("{% endfor %}"),
124
125                    "continue" => {
126                        if args_inner.is_empty() {
127                            out.push_str("{% continue %}");
128                        } else {
129                            out.push_str("{% if ");
130                            out.push_str(args_inner);
131                            out.push_str(" %}{% continue %}{% endif %}");
132                        }
133                    }
134                    "break" => {
135                        if args_inner.is_empty() {
136                            out.push_str("{% break %}");
137                        } else {
138                            out.push_str("{% if ");
139                            out.push_str(args_inner);
140                            out.push_str(" %}{% break %}{% endif %}");
141                        }
142                    }
143
144                    "while" => {
145                        out.push_str("{# @while not supported, use @foreach: ");
146                        out.push_str(args_inner);
147                        out.push_str(" #}");
148                    }
149                    "endwhile" => {}
150
151                    // ─── Switch ────────────────────────────────────────────
152                    "switch" => {
153                        out.push_str("{# @switch(");
154                        out.push_str(args_inner);
155                        out.push_str(") #}");
156                        switch_started = false;
157                        // Save the switch expression for case matching.
158                        // We rely on the user repeating it in @case for clarity.
159                        out.push_str("<!--SWITCH:");
160                        out.push_str(args_inner);
161                        out.push_str("-->");
162                    }
163                    "case" => {
164                        // Translate @case(value) to either {% if switch_expr == value %} or {% elif ... %}
165                        // We don't track the switch expr here; use a sentinel `__switch__` that downstream
166                        // tooling can patch. For practical use, prefer @if/@elseif explicitly.
167                        if !switch_started {
168                            out.push_str("{% if __switch__ == ");
169                            switch_started = true;
170                        } else {
171                            out.push_str("{% elif __switch__ == ");
172                        }
173                        out.push_str(args_inner);
174                        out.push_str(" %}");
175                    }
176                    "default" => {
177                        if switch_started {
178                            out.push_str("{% else %}");
179                        }
180                    }
181                    "endswitch" => {
182                        if switch_started {
183                            out.push_str("{% endif %}");
184                            switch_started = false;
185                        }
186                    }
187
188                    // ─── Layout / inheritance ──────────────────────────────
189                    "extends" => {
190                        out.push_str("{% extends \"");
191                        out.push_str(&path_for(args_stripped_quotes));
192                        out.push_str("\" %}");
193                    }
194                    "section" => {
195                        // Support both block form (@section/@endsection) and inline (@section('title', 'My title'))
196                        if let Some((name, value)) = parse_inline_section(args_inner) {
197                            out.push_str("{% block ");
198                            out.push_str(&name);
199                            out.push_str(" %}");
200                            out.push_str(&value);
201                            out.push_str("{% endblock %}");
202                        } else {
203                            out.push_str("{% block ");
204                            out.push_str(args_stripped_quotes);
205                            out.push_str(" %}");
206                        }
207                    }
208                    "endsection" | "show" | "stop" | "overwrite" => out.push_str("{% endblock %}"),
209                    "yield" => {
210                        // @yield('name') or @yield('name', 'default') — Askama doesn't have a one-liner
211                        // default; use {% block %}default{% endblock %} so subtemplates can override.
212                        let (n, d) = parse_yield_args(args_inner);
213                        out.push_str("{% block ");
214                        out.push_str(&n);
215                        out.push_str(" %}");
216                        out.push_str(&d);
217                        out.push_str("{% endblock %}");
218                    }
219                    "parent" => out.push_str("{{ super() }}"),
220                    "include" => {
221                        out.push_str("{% include \"");
222                        out.push_str(&path_for(args_stripped_quotes));
223                        out.push_str("\" %}");
224                    }
225                    "includeIf" | "includeif" | "includeWhen" | "includewhen" => {
226                        out.push_str("{% include \"");
227                        out.push_str(&path_for(args_stripped_quotes));
228                        out.push_str("\" %}");
229                    }
230                    "hasSection" | "hassection" => {
231                        out.push_str("{% if true %}{# @hasSection placeholder for '");
232                        out.push_str(args_stripped_quotes);
233                        out.push_str("' #}");
234                    }
235                    "endhasSection" | "endhassection" | "sectionMissing" | "sectionmissing" => {
236                        out.push_str("{% endif %}");
237                    }
238
239                    // ─── Stacks ────────────────────────────────────────────
240                    "stack" => {
241                        out.push_str("<!--FORGE-STACK:");
242                        out.push_str(args_stripped_quotes);
243                        out.push_str("-->");
244                    }
245                    "push" => {
246                        stack_pushes.push(args_stripped_quotes.to_string());
247                        out.push_str("<!--FORGE-PUSH-START:");
248                        out.push_str(args_stripped_quotes);
249                        out.push_str("-->");
250                    }
251                    "endpush" => {
252                        let name = stack_pushes.pop().unwrap_or_default();
253                        out.push_str("<!--FORGE-PUSH-END:");
254                        out.push_str(&name);
255                        out.push_str("-->");
256                    }
257                    "pushOnce" | "pushonce" => {
258                        stack_pushes.push(args_stripped_quotes.to_string());
259                        out.push_str("<!--FORGE-PUSHONCE-START:");
260                        out.push_str(args_stripped_quotes);
261                        out.push_str("-->");
262                    }
263                    "endPushOnce" | "endpushonce" => {
264                        let name = stack_pushes.pop().unwrap_or_default();
265                        out.push_str("<!--FORGE-PUSHONCE-END:");
266                        out.push_str(&name);
267                        out.push_str("-->");
268                    }
269                    "prepend" => {
270                        stack_pushes.push(args_stripped_quotes.to_string());
271                        out.push_str("<!--FORGE-PREPEND-START:");
272                        out.push_str(args_stripped_quotes);
273                        out.push_str("-->");
274                    }
275                    "endprepend" => {
276                        let name = stack_pushes.pop().unwrap_or_default();
277                        out.push_str("<!--FORGE-PREPEND-END:");
278                        out.push_str(&name);
279                        out.push_str("-->");
280                    }
281                    "once" => out.push_str("<!--FORGE-ONCE-START-->"),
282                    "endonce" => out.push_str("<!--FORGE-ONCE-END-->"),
283
284                    // ─── Assets ────────────────────────────────────────────
285                    "vite" => {
286                        out.push_str("{{ ::forge::vite::render(&[");
287                        out.push_str(args_inner);
288                        out.push_str("])|safe }}");
289                    }
290
291                    // ─── Auth / authorization ──────────────────────────────
292                    "auth" => out.push_str("{% if auth_user.is_some() %}"),
293                    "endauth" => out.push_str("{% endif %}"),
294                    "guest" => out.push_str("{% if auth_user.is_none() %}"),
295                    "endguest" => out.push_str("{% endif %}"),
296                    "can" => {
297                        out.push_str("{% if can(");
298                        out.push_str(args_inner);
299                        out.push_str(") %}");
300                    }
301                    "endcan" => out.push_str("{% endif %}"),
302                    "cannot" => {
303                        out.push_str("{% if !can(");
304                        out.push_str(args_inner);
305                        out.push_str(") %}");
306                    }
307                    "endcannot" => out.push_str("{% endif %}"),
308                    "role" => {
309                        out.push_str("{% if has_role(auth_user, ");
310                        out.push_str(args_inner);
311                        out.push_str(") %}");
312                    }
313                    "endrole" => out.push_str("{% endif %}"),
314
315                    // ─── Form attribute helpers ────────────────────────────
316                    "checked" => emit_attr_helper(&mut out, "checked", args_inner),
317                    "selected" => emit_attr_helper(&mut out, "selected", args_inner),
318                    "disabled" => emit_attr_helper(&mut out, "disabled", args_inner),
319                    "readonly" => emit_attr_helper(&mut out, "readonly", args_inner),
320                    "required" => emit_attr_helper(&mut out, "required", args_inner),
321
322                    // ─── Validation feedback ───────────────────────────────
323                    "error" => {
324                        // @error('field') ... @enderror — exposes `message` inside the block.
325                        out.push_str("{% if let Some(message) = errors.get(\"");
326                        out.push_str(args_stripped_quotes);
327                        out.push_str("\").and_then(|v| v.first()) %}");
328                    }
329                    "enderror" => out.push_str("{% endif %}"),
330                    "old" => {
331                        // @old('field', 'default') — renders the old input value via runtime helper.
332                        let (field, default) = parse_old_args(args_inner);
333                        out.push_str("{{ ::forge::escape::html(&old_input.get(\"");
334                        out.push_str(&field);
335                        out.push_str("\").cloned().unwrap_or_else(|| ");
336                        out.push_str(&default);
337                        out.push_str(".to_string())) }}");
338                    }
339
340                    // ─── Conditional class & style ─────────────────────────
341                    "class" => {
342                        // @class([("active", is_active), ("muted", !is_active)])
343                        out.push_str("class=\"{{ ::forge::escape::html(&::forge::class_list(&[");
344                        out.push_str(args_inner);
345                        out.push_str("])) }}\"");
346                    }
347                    "style" => {
348                        out.push_str("style=\"{{ ::forge::escape::html(&::forge::style_list(&[");
349                        out.push_str(args_inner);
350                        out.push_str("])) }}\"");
351                    }
352
353                    // ─── Debug helpers ─────────────────────────────────────
354                    "dump" | "dd" => {
355                        out.push_str("<pre>{{ format!(\"{:#?}\", ");
356                        out.push_str(args_inner);
357                        out.push_str(")|escape }}</pre>");
358                    }
359                    "json" => {
360                        // @json($var) — emit as JSON inside <script> safely.
361                        out.push_str("{{ ::serde_json::to_string(&");
362                        out.push_str(args_inner);
363                        out.push_str(").unwrap_or_default()|safe }}");
364                    }
365
366                    // ─── Verbatim ──────────────────────────────────────────
367                    "verbatim" | "endverbatim" => {}
368
369                    // ─── CSRF / method spoofing ────────────────────────────
370                    "csrf" => {
371                        out.push_str(
372                            "<input type=\"hidden\" name=\"_token\" value=\"{{ csrf_token }}\">",
373                        );
374                    }
375                    "method" => {
376                        out.push_str("<input type=\"hidden\" name=\"_method\" value=\"");
377                        out.push_str(args_stripped_quotes);
378                        out.push_str("\">");
379                    }
380
381                    // ─── i18n placeholders (real impl in v0.2) ─────────────
382                    "lang" | "trans" => {
383                        out.push_str("{{ ::forge::escape::html(&::forge::lang(");
384                        out.push_str(args_inner);
385                        out.push_str(")) }}");
386                    }
387                    "choice" => {
388                        out.push_str("{{ ::forge::escape::html(&::forge::lang_choice(");
389                        out.push_str(args_inner);
390                        out.push_str(")) }}");
391                    }
392
393                    // ─── Component prop declaration ────────────────────────
394                    "props" => {
395                        // @props([...]) — for now, emitted as a comment marker;
396                        // real prop-default handling is done at lowering of component templates.
397                        out.push_str("{# props: ");
398                        out.push_str(args_inner);
399                        out.push_str(" #}");
400                    }
401
402                    // ─── Spark (Livewire-equivalent) ───────────────────────
403                    // @spark("counter", { initial: 5, label: "Clicks" })
404                    "spark" => match target {
405                        LowerTarget::Askama => {
406                            let (name, props_expr) = parse_spark_args(args_inner);
407                            out.push_str("{{ ::spark::render::render_mount(\"");
408                            out.push_str(&name.replace('"', "\\\""));
409                            out.push_str("\", &::spark::serde_json::json!(");
410                            if props_expr.is_empty() {
411                                out.push_str("null");
412                            } else {
413                                out.push_str(&props_expr);
414                            }
415                            out.push_str(")).unwrap_or_default()|safe }}");
416                        }
417                        LowerTarget::MiniJinja => {
418                            // MiniJinja: emit a call to the `spark_mount` runtime
419                            // function (registered on the Environment). Props
420                            // dict-literal keys are auto-quoted so JS-style
421                            // `{ initial: 5 }` becomes Jinja-style `{"initial": 5}`.
422                            let (name, props_expr) = parse_spark_args(args_inner);
423                            out.push_str("{{ spark_mount(\"");
424                            out.push_str(&name.replace('"', "\\\""));
425                            if props_expr.is_empty() {
426                                out.push_str("\")|safe }}");
427                            } else {
428                                out.push_str("\", ");
429                                out.push_str(&quote_dict_keys(&props_expr));
430                                out.push_str(")|safe }}");
431                            }
432                        }
433                    },
434
435                    // @sparkScripts  — emits the runtime <script> + boot JSON.
436                    "sparkScripts" | "sparkscripts" => match target {
437                        LowerTarget::Askama => {
438                            out.push_str("{{ ::spark::render::boot_script()|safe }}");
439                        }
440                        LowerTarget::MiniJinja => {
441                            out.push_str("{{ spark_scripts()|safe }}");
442                        }
443                    },
444
445                    // @sparkIsland("name") … @endSparkIsland — partial-render region.
446                    "sparkIsland" | "sparkisland" => {
447                        let name = args_stripped_quotes;
448                        out.push_str("<div spark:island=\"");
449                        out.push_str(name);
450                        out.push_str("\">");
451                    }
452                    "endSparkIsland" | "endsparkisland" => {
453                        out.push_str("</div>");
454                    }
455
456                    // ─── Fallback ──────────────────────────────────────────
457                    _ => {
458                        out.push_str("{# unknown directive @");
459                        out.push_str(name);
460                        if !args_inner.is_empty() {
461                            out.push('(');
462                            out.push_str(args_inner);
463                            out.push(')');
464                        }
465                        out.push_str(" #}");
466                    }
467                }
468            }
469
470            Token::ComponentOpen {
471                name,
472                attrs,
473                self_closing,
474            } => {
475                // Lower <x-alert type="error"> → {% call alert(type="error") %}
476                let component_macro = component_macro_name(name);
477                out.push_str("{% call ");
478                out.push_str(&component_macro);
479                out.push('(');
480                let mut first = true;
481                for (k, v) in attrs {
482                    if !first {
483                        out.push_str(", ");
484                    }
485                    first = false;
486                    out.push_str(k);
487                    out.push_str("=\"");
488                    out.push_str(v);
489                    out.push('"');
490                }
491                out.push_str(") %}");
492                if *self_closing {
493                    out.push_str("{% endcall %}");
494                }
495            }
496
497            Token::ComponentClose { .. } => {
498                out.push_str("{% endcall %}");
499            }
500        }
501    }
502
503    out
504}
505
506// ─── helpers ────────────────────────────────────────────────────────────────
507
508fn emit_if(out: &mut String, expr: &str) {
509    out.push_str("{% if ");
510    out.push_str(expr);
511    out.push_str(" %}");
512}
513
514fn emit_elif(out: &mut String, expr: &str) {
515    out.push_str("{% elif ");
516    out.push_str(expr);
517    out.push_str(" %}");
518}
519
520fn emit_attr_helper(out: &mut String, attr: &str, condition: &str) {
521    out.push_str("{% if (");
522    out.push_str(condition);
523    out.push_str(") %}");
524    out.push_str(attr);
525    out.push_str("{% endif %}");
526}
527
528fn parse_inline_section(args: &str) -> Option<(String, String)> {
529    // @section('title', 'My title') → ("title", "My title")
530    let args = args.trim();
531    if let Some(comma) = find_top_level_comma(args) {
532        let name = args[..comma]
533            .trim()
534            .trim_matches(|c| c == '"' || c == '\'')
535            .to_string();
536        let value = args[comma + 1..].trim().to_string();
537        if !name.is_empty() {
538            return Some((name, value));
539        }
540    }
541    None
542}
543
544fn parse_yield_args(args: &str) -> (String, String) {
545    if let Some(comma) = find_top_level_comma(args) {
546        let n = args[..comma]
547            .trim()
548            .trim_matches(|c| c == '"' || c == '\'')
549            .to_string();
550        let d_raw = args[comma + 1..].trim();
551        let d = if d_raw.starts_with('"') || d_raw.starts_with('\'') {
552            // String literal default — emit as is.
553            d_raw.trim_matches(|c| c == '"' || c == '\'').to_string()
554        } else {
555            // Expression default — wrap in {{ }}.
556            format!("{{{{ {d_raw} }}}}")
557        };
558        return (n, d);
559    }
560    (
561        args.trim()
562            .trim_matches(|c| c == '"' || c == '\'')
563            .to_string(),
564        String::new(),
565    )
566}
567
568fn parse_old_args(args: &str) -> (String, String) {
569    if let Some(comma) = find_top_level_comma(args) {
570        let f = args[..comma]
571            .trim()
572            .trim_matches(|c| c == '"' || c == '\'')
573            .to_string();
574        let d = args[comma + 1..].trim().to_string();
575        return (f, d);
576    }
577    (
578        args.trim()
579            .trim_matches(|c| c == '"' || c == '\'')
580            .to_string(),
581        "\"\"".to_string(),
582    )
583}
584
585/// Rewrite a JS-style dict literal so identifier-shaped keys are quoted,
586/// producing output that both `serde_json::json!` (Askama path) and MiniJinja
587/// can parse. `{ initial: 5, label: "x" }` → `{ "initial": 5, "label": "x" }`.
588///
589/// Skips substrings inside string literals so colons inside `"foo:bar"` aren't
590/// misclassified as key separators.
591fn quote_dict_keys(input: &str) -> String {
592    let bytes = input.as_bytes();
593    let mut out = String::with_capacity(input.len() + 16);
594    let mut i = 0;
595    let mut in_str: Option<u8> = None;
596
597    while i < bytes.len() {
598        let b = bytes[i];
599
600        // String passthrough.
601        if let Some(q) = in_str {
602            out.push(b as char);
603            if b == q && bytes.get(i.saturating_sub(1)) != Some(&b'\\') {
604                in_str = None;
605            }
606            i += 1;
607            continue;
608        }
609        if b == b'"' || b == b'\'' {
610            in_str = Some(b);
611            out.push(b as char);
612            i += 1;
613            continue;
614        }
615
616        // Look for an identifier-shaped key immediately after `{` or `,`.
617        if b == b'{' || b == b',' {
618            out.push(b as char);
619            i += 1;
620            // Skip whitespace.
621            while i < bytes.len() && (bytes[i] as char).is_whitespace() {
622                out.push(bytes[i] as char);
623                i += 1;
624            }
625            // Identifier start?
626            if i < bytes.len() && (bytes[i].is_ascii_alphabetic() || bytes[i] == b'_') {
627                let start = i;
628                while i < bytes.len() && (bytes[i].is_ascii_alphanumeric() || bytes[i] == b'_') {
629                    i += 1;
630                }
631                let ident = &input[start..i];
632                // Peek for ':' (with optional whitespace) to confirm it's a key.
633                let mut j = i;
634                while j < bytes.len() && (bytes[j] as char).is_whitespace() {
635                    j += 1;
636                }
637                if j < bytes.len() && bytes[j] == b':' {
638                    out.push('"');
639                    out.push_str(ident);
640                    out.push('"');
641                    // Skip captured whitespace (already in `out` from inside
642                    // the identifier loop? no — we didn't push during the
643                    // peek). Push the whitespace from i..j.
644                    out.push_str(&input[i..j]);
645                    out.push(':');
646                    i = j + 1;
647                    continue;
648                } else {
649                    // Not a key — pass the identifier through unchanged.
650                    out.push_str(ident);
651                    continue;
652                }
653            }
654            continue;
655        }
656
657        out.push(b as char);
658        i += 1;
659    }
660    out
661}
662
663/// Parse `@spark("name", { ... })` arguments.
664///
665/// Returns (component_name, props_expression). `props_expression` is the
666/// untouched substring between the first comma and the end of args, which the
667/// caller wraps in `serde_json::json!(...)`. Identifier-keyed object literals
668/// (`{ initial: 5, label: "x" }`) work because `serde_json::json!` accepts them.
669fn parse_spark_args(args: &str) -> (String, String) {
670    let args = args.trim();
671    if args.is_empty() {
672        return (String::new(), String::new());
673    }
674    if let Some(comma) = find_top_level_comma(args) {
675        let name = args[..comma]
676            .trim()
677            .trim_matches(|c| c == '"' || c == '\'')
678            .to_string();
679        let props = args[comma + 1..].trim().to_string();
680        return (name, props);
681    }
682    (
683        args.trim_matches(|c: char| c == '"' || c == '\'')
684            .to_string(),
685        String::new(),
686    )
687}
688
689fn find_top_level_comma(s: &str) -> Option<usize> {
690    let mut depth_paren = 0;
691    let mut depth_bracket = 0;
692    let mut in_string = None::<char>;
693    for (i, ch) in s.char_indices() {
694        if let Some(q) = in_string {
695            if ch == q {
696                in_string = None;
697            }
698            continue;
699        }
700        match ch {
701            '"' | '\'' => in_string = Some(ch),
702            '(' => depth_paren += 1,
703            ')' => depth_paren -= 1,
704            '[' => depth_bracket += 1,
705            ']' => depth_bracket -= 1,
706            ',' if depth_paren == 0 && depth_bracket == 0 => return Some(i),
707            _ => {}
708        }
709    }
710    None
711}
712
713fn path_for(spec: &str) -> String {
714    let s = spec.replace('.', "/");
715    if s.ends_with(".html") {
716        s
717    } else {
718        format!("{s}.html")
719    }
720}
721
722fn component_macro_name(name: &str) -> String {
723    name.replace('-', "_")
724}
725
726fn split_foreach(args: &str) -> (String, String) {
727    let args = args.trim();
728    if let Some((lhs, rhs)) = args.split_once(" as ") {
729        let lhs = lhs.trim().trim_start_matches('$').to_string();
730        let rhs = rhs.trim().trim_start_matches('$').to_string();
731        if let Some((_k, v)) = rhs.split_once("=>") {
732            return (lhs, v.trim().trim_start_matches('$').to_string());
733        }
734        (lhs, rhs)
735    } else {
736        (args.to_string(), "item".to_string())
737    }
738}