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(["resources/css/app.css", "resources/js/app.js"])
286                    "vite" => match target {
287                        LowerTarget::Askama => {
288                            out.push_str("{{ ::forge::vite::render(&[");
289                            out.push_str(args_inner);
290                            out.push_str("])|safe }}");
291                        }
292                        LowerTarget::MiniJinja => {
293                            // MiniJinja can't parse the Rust `&[...]` slice nor the
294                            // `::forge::vite::render` path. Emit a function call to
295                            // a runtime helper that the spark Environment registers
296                            // in `build_env()` — accepts the array via MiniJinja's
297                            // native list literal syntax (which happens to be the
298                            // same comma-separated `"a", "b"` we already have).
299                            out.push_str("{{ vite_render([");
300                            out.push_str(args_inner);
301                            out.push_str("])|safe }}");
302                        }
303                    },
304
305                    // ─── Auth / authorization ──────────────────────────────
306                    "auth" => out.push_str("{% if auth_user.is_some() %}"),
307                    "endauth" => out.push_str("{% endif %}"),
308                    "guest" => out.push_str("{% if auth_user.is_none() %}"),
309                    "endguest" => out.push_str("{% endif %}"),
310                    "can" => {
311                        out.push_str("{% if can(");
312                        out.push_str(args_inner);
313                        out.push_str(") %}");
314                    }
315                    "endcan" => out.push_str("{% endif %}"),
316                    "cannot" => {
317                        out.push_str("{% if !can(");
318                        out.push_str(args_inner);
319                        out.push_str(") %}");
320                    }
321                    "endcannot" => out.push_str("{% endif %}"),
322                    "role" => {
323                        out.push_str("{% if has_role(auth_user, ");
324                        out.push_str(args_inner);
325                        out.push_str(") %}");
326                    }
327                    "endrole" => out.push_str("{% endif %}"),
328
329                    // ─── Form attribute helpers ────────────────────────────
330                    "checked" => emit_attr_helper(&mut out, "checked", args_inner),
331                    "selected" => emit_attr_helper(&mut out, "selected", args_inner),
332                    "disabled" => emit_attr_helper(&mut out, "disabled", args_inner),
333                    "readonly" => emit_attr_helper(&mut out, "readonly", args_inner),
334                    "required" => emit_attr_helper(&mut out, "required", args_inner),
335
336                    // ─── Validation feedback ───────────────────────────────
337                    "error" => {
338                        // @error('field') ... @enderror — exposes `message` inside the block.
339                        out.push_str("{% if let Some(message) = errors.get(\"");
340                        out.push_str(args_stripped_quotes);
341                        out.push_str("\").and_then(|v| v.first()) %}");
342                    }
343                    "enderror" => out.push_str("{% endif %}"),
344                    "old" => {
345                        // @old('field', 'default') — renders the old input value via runtime helper.
346                        let (field, default) = parse_old_args(args_inner);
347                        out.push_str("{{ ::forge::escape::html(&old_input.get(\"");
348                        out.push_str(&field);
349                        out.push_str("\").cloned().unwrap_or_else(|| ");
350                        out.push_str(&default);
351                        out.push_str(".to_string())) }}");
352                    }
353
354                    // ─── Conditional class & style ─────────────────────────
355                    "class" => {
356                        // @class([("active", is_active), ("muted", !is_active)])
357                        out.push_str("class=\"{{ ::forge::escape::html(&::forge::class_list(&[");
358                        out.push_str(args_inner);
359                        out.push_str("])) }}\"");
360                    }
361                    "style" => {
362                        out.push_str("style=\"{{ ::forge::escape::html(&::forge::style_list(&[");
363                        out.push_str(args_inner);
364                        out.push_str("])) }}\"");
365                    }
366
367                    // ─── Debug helpers ─────────────────────────────────────
368                    "dump" | "dd" => {
369                        out.push_str("<pre>{{ format!(\"{:#?}\", ");
370                        out.push_str(args_inner);
371                        out.push_str(")|escape }}</pre>");
372                    }
373                    "json" => {
374                        // @json($var) — emit as JSON inside <script> safely.
375                        out.push_str("{{ ::serde_json::to_string(&");
376                        out.push_str(args_inner);
377                        out.push_str(").unwrap_or_default()|safe }}");
378                    }
379
380                    // ─── Verbatim ──────────────────────────────────────────
381                    "verbatim" | "endverbatim" => {}
382
383                    // ─── CSRF / method spoofing ────────────────────────────
384                    "csrf" => {
385                        out.push_str(
386                            "<input type=\"hidden\" name=\"_token\" value=\"{{ csrf_token }}\">",
387                        );
388                    }
389                    "method" => {
390                        out.push_str("<input type=\"hidden\" name=\"_method\" value=\"");
391                        out.push_str(args_stripped_quotes);
392                        out.push_str("\">");
393                    }
394
395                    // ─── i18n placeholders (real impl in v0.2) ─────────────
396                    "lang" | "trans" => {
397                        out.push_str("{{ ::forge::escape::html(&::forge::lang(");
398                        out.push_str(args_inner);
399                        out.push_str(")) }}");
400                    }
401                    "choice" => {
402                        out.push_str("{{ ::forge::escape::html(&::forge::lang_choice(");
403                        out.push_str(args_inner);
404                        out.push_str(")) }}");
405                    }
406
407                    // ─── Component prop declaration ────────────────────────
408                    "props" => {
409                        // @props([...]) — for now, emitted as a comment marker;
410                        // real prop-default handling is done at lowering of component templates.
411                        out.push_str("{# props: ");
412                        out.push_str(args_inner);
413                        out.push_str(" #}");
414                    }
415
416                    // ─── Spark (Livewire-equivalent) ───────────────────────
417                    // @spark("counter", { initial: 5, label: "Clicks" })
418                    "spark" => match target {
419                        LowerTarget::Askama => {
420                            let (name, props_expr) = parse_spark_args(args_inner);
421                            out.push_str("{{ ::spark::render::render_mount(\"");
422                            out.push_str(&name.replace('"', "\\\""));
423                            out.push_str("\", &::spark::serde_json::json!(");
424                            if props_expr.is_empty() {
425                                out.push_str("null");
426                            } else {
427                                out.push_str(&props_expr);
428                            }
429                            out.push_str(")).unwrap_or_default()|safe }}");
430                        }
431                        LowerTarget::MiniJinja => {
432                            // MiniJinja: emit a call to the `spark_mount` runtime
433                            // function (registered on the Environment). Props
434                            // dict-literal keys are auto-quoted so JS-style
435                            // `{ initial: 5 }` becomes Jinja-style `{"initial": 5}`.
436                            let (name, props_expr) = parse_spark_args(args_inner);
437                            out.push_str("{{ spark_mount(\"");
438                            out.push_str(&name.replace('"', "\\\""));
439                            if props_expr.is_empty() {
440                                out.push_str("\")|safe }}");
441                            } else {
442                                out.push_str("\", ");
443                                out.push_str(&quote_dict_keys(&props_expr));
444                                out.push_str(")|safe }}");
445                            }
446                        }
447                    },
448
449                    // @sparkScripts  — emits the runtime <script> + boot JSON.
450                    "sparkScripts" | "sparkscripts" => match target {
451                        LowerTarget::Askama => {
452                            out.push_str("{{ ::spark::render::boot_script()|safe }}");
453                        }
454                        LowerTarget::MiniJinja => {
455                            out.push_str("{{ spark_scripts()|safe }}");
456                        }
457                    },
458
459                    // @sparkIsland("name") … @endSparkIsland — partial-render region.
460                    "sparkIsland" | "sparkisland" => {
461                        let name = args_stripped_quotes;
462                        out.push_str("<div spark:island=\"");
463                        out.push_str(name);
464                        out.push_str("\">");
465                    }
466                    "endSparkIsland" | "endsparkisland" => {
467                        out.push_str("</div>");
468                    }
469
470                    // ─── Fallback ──────────────────────────────────────────
471                    _ => {
472                        out.push_str("{# unknown directive @");
473                        out.push_str(name);
474                        if !args_inner.is_empty() {
475                            out.push('(');
476                            out.push_str(args_inner);
477                            out.push(')');
478                        }
479                        out.push_str(" #}");
480                    }
481                }
482            }
483
484            Token::ComponentOpen {
485                name,
486                attrs,
487                self_closing,
488            } => {
489                // Lower <x-alert type="error"> → {% call alert(type="error") %}
490                let component_macro = component_macro_name(name);
491                out.push_str("{% call ");
492                out.push_str(&component_macro);
493                out.push('(');
494                let mut first = true;
495                for (k, v) in attrs {
496                    if !first {
497                        out.push_str(", ");
498                    }
499                    first = false;
500                    out.push_str(k);
501                    out.push_str("=\"");
502                    out.push_str(v);
503                    out.push('"');
504                }
505                out.push_str(") %}");
506                if *self_closing {
507                    out.push_str("{% endcall %}");
508                }
509            }
510
511            Token::ComponentClose { .. } => {
512                out.push_str("{% endcall %}");
513            }
514        }
515    }
516
517    out
518}
519
520// ─── helpers ────────────────────────────────────────────────────────────────
521
522fn emit_if(out: &mut String, expr: &str) {
523    out.push_str("{% if ");
524    out.push_str(expr);
525    out.push_str(" %}");
526}
527
528fn emit_elif(out: &mut String, expr: &str) {
529    out.push_str("{% elif ");
530    out.push_str(expr);
531    out.push_str(" %}");
532}
533
534fn emit_attr_helper(out: &mut String, attr: &str, condition: &str) {
535    out.push_str("{% if (");
536    out.push_str(condition);
537    out.push_str(") %}");
538    out.push_str(attr);
539    out.push_str("{% endif %}");
540}
541
542fn parse_inline_section(args: &str) -> Option<(String, String)> {
543    // @section('title', 'My title') → ("title", "My title")
544    let args = args.trim();
545    if let Some(comma) = find_top_level_comma(args) {
546        let name = args[..comma]
547            .trim()
548            .trim_matches(|c| c == '"' || c == '\'')
549            .to_string();
550        let value = args[comma + 1..].trim().to_string();
551        if !name.is_empty() {
552            return Some((name, value));
553        }
554    }
555    None
556}
557
558fn parse_yield_args(args: &str) -> (String, String) {
559    if let Some(comma) = find_top_level_comma(args) {
560        let n = args[..comma]
561            .trim()
562            .trim_matches(|c| c == '"' || c == '\'')
563            .to_string();
564        let d_raw = args[comma + 1..].trim();
565        let d = if d_raw.starts_with('"') || d_raw.starts_with('\'') {
566            // String literal default — emit as is.
567            d_raw.trim_matches(|c| c == '"' || c == '\'').to_string()
568        } else {
569            // Expression default — wrap in {{ }}.
570            format!("{{{{ {d_raw} }}}}")
571        };
572        return (n, d);
573    }
574    (
575        args.trim()
576            .trim_matches(|c| c == '"' || c == '\'')
577            .to_string(),
578        String::new(),
579    )
580}
581
582fn parse_old_args(args: &str) -> (String, String) {
583    if let Some(comma) = find_top_level_comma(args) {
584        let f = args[..comma]
585            .trim()
586            .trim_matches(|c| c == '"' || c == '\'')
587            .to_string();
588        let d = args[comma + 1..].trim().to_string();
589        return (f, d);
590    }
591    (
592        args.trim()
593            .trim_matches(|c| c == '"' || c == '\'')
594            .to_string(),
595        "\"\"".to_string(),
596    )
597}
598
599/// Rewrite a JS-style dict literal so identifier-shaped keys are quoted,
600/// producing output that both `serde_json::json!` (Askama path) and MiniJinja
601/// can parse. `{ initial: 5, label: "x" }` → `{ "initial": 5, "label": "x" }`.
602///
603/// Skips substrings inside string literals so colons inside `"foo:bar"` aren't
604/// misclassified as key separators.
605fn quote_dict_keys(input: &str) -> String {
606    let bytes = input.as_bytes();
607    let mut out = String::with_capacity(input.len() + 16);
608    let mut i = 0;
609    let mut in_str: Option<u8> = None;
610
611    while i < bytes.len() {
612        let b = bytes[i];
613
614        // String passthrough.
615        if let Some(q) = in_str {
616            out.push(b as char);
617            if b == q && bytes.get(i.saturating_sub(1)) != Some(&b'\\') {
618                in_str = None;
619            }
620            i += 1;
621            continue;
622        }
623        if b == b'"' || b == b'\'' {
624            in_str = Some(b);
625            out.push(b as char);
626            i += 1;
627            continue;
628        }
629
630        // Look for an identifier-shaped key immediately after `{` or `,`.
631        if b == b'{' || b == b',' {
632            out.push(b as char);
633            i += 1;
634            // Skip whitespace.
635            while i < bytes.len() && (bytes[i] as char).is_whitespace() {
636                out.push(bytes[i] as char);
637                i += 1;
638            }
639            // Identifier start?
640            if i < bytes.len() && (bytes[i].is_ascii_alphabetic() || bytes[i] == b'_') {
641                let start = i;
642                while i < bytes.len() && (bytes[i].is_ascii_alphanumeric() || bytes[i] == b'_') {
643                    i += 1;
644                }
645                let ident = &input[start..i];
646                // Peek for ':' (with optional whitespace) to confirm it's a key.
647                let mut j = i;
648                while j < bytes.len() && (bytes[j] as char).is_whitespace() {
649                    j += 1;
650                }
651                if j < bytes.len() && bytes[j] == b':' {
652                    out.push('"');
653                    out.push_str(ident);
654                    out.push('"');
655                    // Skip captured whitespace (already in `out` from inside
656                    // the identifier loop? no — we didn't push during the
657                    // peek). Push the whitespace from i..j.
658                    out.push_str(&input[i..j]);
659                    out.push(':');
660                    i = j + 1;
661                    continue;
662                } else {
663                    // Not a key — pass the identifier through unchanged.
664                    out.push_str(ident);
665                    continue;
666                }
667            }
668            continue;
669        }
670
671        out.push(b as char);
672        i += 1;
673    }
674    out
675}
676
677/// Parse `@spark("name", { ... })` arguments.
678///
679/// Returns (component_name, props_expression). `props_expression` is the
680/// untouched substring between the first comma and the end of args, which the
681/// caller wraps in `serde_json::json!(...)`. Identifier-keyed object literals
682/// (`{ initial: 5, label: "x" }`) work because `serde_json::json!` accepts them.
683fn parse_spark_args(args: &str) -> (String, String) {
684    let args = args.trim();
685    if args.is_empty() {
686        return (String::new(), String::new());
687    }
688    if let Some(comma) = find_top_level_comma(args) {
689        let name = args[..comma]
690            .trim()
691            .trim_matches(|c| c == '"' || c == '\'')
692            .to_string();
693        let props = args[comma + 1..].trim().to_string();
694        return (name, props);
695    }
696    (
697        args.trim_matches(|c: char| c == '"' || c == '\'')
698            .to_string(),
699        String::new(),
700    )
701}
702
703fn find_top_level_comma(s: &str) -> Option<usize> {
704    let mut depth_paren = 0;
705    let mut depth_bracket = 0;
706    let mut in_string = None::<char>;
707    for (i, ch) in s.char_indices() {
708        if let Some(q) = in_string {
709            if ch == q {
710                in_string = None;
711            }
712            continue;
713        }
714        match ch {
715            '"' | '\'' => in_string = Some(ch),
716            '(' => depth_paren += 1,
717            ')' => depth_paren -= 1,
718            '[' => depth_bracket += 1,
719            ']' => depth_bracket -= 1,
720            ',' if depth_paren == 0 && depth_bracket == 0 => return Some(i),
721            _ => {}
722        }
723    }
724    None
725}
726
727fn path_for(spec: &str) -> String {
728    let s = spec.replace('.', "/");
729    if s.ends_with(".html") {
730        s
731    } else {
732        format!("{s}.html")
733    }
734}
735
736fn component_macro_name(name: &str) -> String {
737    name.replace('-', "_")
738}
739
740fn split_foreach(args: &str) -> (String, String) {
741    let args = args.trim();
742    if let Some((lhs, rhs)) = args.split_once(" as ") {
743        let lhs = lhs.trim().trim_start_matches('$').to_string();
744        let rhs = rhs.trim().trim_start_matches('$').to_string();
745        if let Some((_k, v)) = rhs.split_once("=>") {
746            return (lhs, v.trim().trim_start_matches('$').to_string());
747        }
748        (lhs, rhs)
749    } else {
750        (args.to_string(), "item".to_string())
751    }
752}