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