File Coverage

lib/Yote/Spiderpup.pm
Criterion Covered Total %
statement 452 906 49.8
branch 102 350 29.1
condition 86 229 37.5
subroutine 31 47 65.9
pod 0 27 0.0
total 671 1559 43.0


line stmt bran cond sub pod time code
1             package Yote::Spiderpup;
2              
3 1     1   169076 use strict;
  1         3  
  1         39  
4 1     1   5 use warnings;
  1         2  
  1         80  
5              
6             our $VERSION = '0.07';
7              
8 1     1   957 use CSS::LESSp;
  1         13065  
  1         83  
9 1     1   1676 use Data::Dumper;
  1         15505  
  1         86  
10 1     1   696 use IO::Socket::INET;
  1         19844  
  1         11  
11 1     1   1545 use JSON::PP;
  1         25737  
  1         138  
12 1     1   12 use File::Basename;
  1         2  
  1         95  
13 1     1   9 use File::Path qw(make_path);
  1         2  
  1         56  
14 1     1   5 use File::Spec;
  1         2  
  1         23  
15 1     1   837 use YAML;
  1         10539  
  1         76  
16              
17 1     1   755 use Yote::Spiderpup::SFC qw(parse_sfc);
  1         4  
  1         92  
18              
19 1         19560 use Yote::Spiderpup::Transform qw(
20             transform_dollar_vars
21             transform_expression
22             extract_arrow_params
23             add_implicit_this
24             parse_html
25 1     1   742 );
  1         4  
26              
27             # HTML void tags (self-closing)
28             my %VOID_TAGS = map { $_ => 1 } qw(area base br col embed hr img input meta param source track wbr);
29              
30             sub new {
31 11     11 0 333198 my ($class, %args) = @_;
32 11   50     44 my $www_dir = $args{www_dir} // die "www_dir required";
33 11   66     104 my $webroot_dir = $args{webroot_dir} // File::Spec->catdir($www_dir, 'webroot');
34             my $self = bless {
35             www_dir => $www_dir,
36             pages_dir => File::Spec->catdir($www_dir, 'pages'),
37             recipes_dir => File::Spec->catdir($www_dir, 'recipes'),
38             webroot_dir => $webroot_dir,
39             js_dir => File::Spec->catdir($webroot_dir, 'js'),
40             static_dir => $www_dir,
41 11   100     272 base_url_path => $args{base_url_path} // '',
42             file_mtimes => {},
43             last_change_time => 0,
44             }, $class;
45 11 100       1139 make_path($self->{js_dir}) unless -d $self->{js_dir};
46 11         61 return $self;
47             }
48              
49             #----------------------------------------------------------------------
50             # File change tracking
51             #----------------------------------------------------------------------
52              
53             sub _scan_yaml_dir {
54 0     0   0 my ($self, $dir, $changed_ref) = @_;
55 0 0       0 opendir(my $dh, $dir) or return;
56 0         0 while (my $entry = readdir($dh)) {
57 0 0       0 next if $entry =~ /^\./;
58 0         0 my $path = File::Spec->catfile($dir, $entry);
59 0 0       0 if (-d $path) {
    0          
60 0         0 $self->_scan_yaml_dir($path, $changed_ref);
61             } elsif ($entry =~ /\.(yaml|pup)$/) {
62 0         0 my $mtime = (stat($path))[9];
63 0 0 0     0 if (!exists $self->{file_mtimes}{$path} || $self->{file_mtimes}{$path} != $mtime) {
64 0         0 $self->{file_mtimes}{$path} = $mtime;
65 0         0 $$changed_ref = 1;
66             }
67             }
68             }
69 0         0 closedir($dh);
70             }
71              
72             sub update_file_mtimes {
73 0     0 0 0 my ($self) = @_;
74 0         0 my $changed = 0;
75              
76 0         0 $self->_scan_yaml_dir($self->{recipes_dir}, \$changed);
77 0         0 $self->_scan_yaml_dir($self->{pages_dir}, \$changed);
78              
79 0 0       0 if ($changed) {
80 0         0 $self->{last_change_time} = time();
81             }
82 0         0 return $changed;
83             }
84              
85             sub is_dev_mode {
86 0 0   0 0 0 return $ENV{SPIDERPUP_DEV} ? 1 : 0;
87             }
88              
89             #----------------------------------------------------------------------
90             # Component file helpers (.yaml / .pup)
91             #----------------------------------------------------------------------
92              
93             sub _find_component_file {
94 23     23   64 my ($self, $dir, $name) = @_;
95 23         297 my $yaml = File::Spec->catfile($dir, "$name.yaml");
96 23 50       689 return $yaml if -f $yaml;
97 0         0 my $pup = File::Spec->catfile($dir, "$name.pup");
98 0 0       0 return $pup if -f $pup;
99 0         0 return undef;
100             }
101              
102             sub _load_component_data {
103 12     12   40 my ($self, $file_path) = @_;
104 12 50       54 if ($file_path =~ /\.pup$/) {
105 0 0       0 open my $fh, '<', $file_path or die "Cannot open $file_path: $!";
106 0         0 my $content = do { local $/; <$fh> };
  0         0  
  0         0  
107 0         0 close $fh;
108 0         0 return parse_sfc($content);
109             }
110 12         61 return YAML::LoadFile($file_path);
111             }
112              
113             #----------------------------------------------------------------------
114             # Path resolution
115             #----------------------------------------------------------------------
116              
117             sub parse_page_path {
118 0     0 0 0 my ($self, $path) = @_;
119              
120 0         0 $path =~ s|^/+||;
121 0         0 $path =~ s|\.html$||;
122 0         0 $path =~ s|\.yaml$||;
123 0         0 $path =~ s|\.pup$||;
124              
125 0 0 0     0 return 'index' if $path eq '' || $path eq 'index';
126              
127 0         0 my @segments = split(/\//, $path);
128 0 0       0 if (@segments == 1) {
129 0         0 my $subdir = File::Spec->catdir($self->{pages_dir}, $segments[0]);
130 0 0       0 if (-d $subdir) {
131 0         0 return "$segments[0]/index";
132             }
133             }
134              
135 0         0 return $path;
136             }
137              
138             sub load_page {
139 7     7 0 24 my ($self, $path) = @_;
140              
141 7         52 $path =~ s|^/+||;
142 7         31 $path =~ s|\.html$||;
143 7         37 $path =~ s|\.yaml$||;
144 7         18 $path =~ s|\.pup$||;
145 7 50 33     47 $path = 'index' if $path eq '' || $path eq 'index.html';
146              
147 7         14 my $component_file;
148              
149 7 100       33 if ($path =~ s|^recipes/||) {
150 5         23 $component_file = $self->_find_component_file($self->{recipes_dir}, $path);
151             } else {
152 2         9 $component_file = $self->_find_component_file($self->{pages_dir}, $path);
153             }
154              
155 7 50       30 return undef unless $component_file;
156              
157 7         24 my $page_data = $self->_load_component_data($component_file);
158 7         79316 my $relative_path = File::Spec->abs2rel($component_file, $self->{www_dir});
159 7         34 $page_data->{yaml_path} = $relative_path;
160              
161 7         27 return $page_data;
162             }
163              
164             sub resolve_recipe_name {
165 14     14 0 65 my ($self, $import_path) = @_;
166              
167 14         124 $import_path =~ s|^/+||;
168 14         83 $import_path =~ s|\.yaml$||;
169 14         33 $import_path =~ s|\.pup$||;
170              
171 14         31 my ($module_name, $is_page);
172 14 100       62 if ($import_path =~ m|^recipes/(.+)$|) {
173 10         31 $module_name = $1;
174 10         18 $is_page = 0;
175             } else {
176 4         10 $module_name = $import_path;
177 4         19 my $page_file = $self->_find_component_file($self->{pages_dir}, $module_name);
178 4 50       18 $is_page = $page_file ? 1 : 0;
179             }
180              
181 14         29 my $class_name = $module_name;
182 14         32 $class_name =~ s|/|_|g;
183 14         62 $class_name =~ s/-(.)/\U$1/g;
184 14         37 $class_name = ucfirst($class_name);
185              
186 14 100       49 my $js_path = $is_page ? "/js/pages/$module_name.js" : "/js/$module_name.js";
187              
188 14         71 return ($module_name, $class_name, $js_path, $is_page);
189             }
190              
191             #----------------------------------------------------------------------
192             # Caching
193             #----------------------------------------------------------------------
194              
195             sub get_cache_paths {
196 0     0 0 0 my ($self, $page_name) = @_;
197             return (
198             html => File::Spec->catfile($self->{webroot_dir}, "$page_name.html"),
199 0         0 meta => File::Spec->catfile($self->{webroot_dir}, "$page_name.meta"),
200             );
201             }
202              
203             sub get_recipe_cache_paths {
204 3     3 0 7 my ($self, $module_name) = @_;
205             return (
206             js => File::Spec->catfile($self->{js_dir}, "$module_name.js"),
207 3         77 meta => File::Spec->catfile($self->{js_dir}, "$module_name.js.meta"),
208             );
209             }
210              
211             sub collect_yaml_files {
212 0     0 0 0 my ($self, $page_data, $page_name, $collected) = @_;
213 0   0     0 $collected //= {};
214              
215 0         0 my $component_file;
216 0 0       0 if ($page_name =~ /^recipes\/(.+)$/) {
217 0         0 $component_file = $self->_find_component_file($self->{recipes_dir}, $1);
218             } else {
219 0         0 $component_file = $self->_find_component_file($self->{pages_dir}, $page_name);
220             }
221 0 0       0 if ($component_file) {
222 0         0 $collected->{$component_file} = (stat($component_file))[9];
223             }
224              
225 0   0     0 my $imports = $page_data->{import} // {};
226 0         0 for my $namespace (keys %$imports) {
227 0         0 my $import_path = $imports->{$namespace};
228 0         0 $import_path =~ s|^/+||;
229 0         0 $import_path =~ s|\.yaml$||;
230 0         0 $import_path =~ s|\.pup$||;
231              
232 0         0 my $import_file;
233 0 0       0 if ($import_path =~ /^recipes\/(.+)$/) {
234 0         0 $import_file = $self->_find_component_file($self->{recipes_dir}, $1);
235             } else {
236 0         0 $import_file = $self->_find_component_file($self->{pages_dir}, $import_path);
237             }
238 0 0       0 next unless $import_file;
239 0 0       0 next if exists $collected->{$import_file};
240              
241 0         0 my $imported_page = $self->load_page($import_path);
242 0 0       0 if ($imported_page) {
243 0         0 $self->collect_yaml_files($imported_page, $import_path, $collected);
244             }
245             }
246              
247 0         0 return $collected;
248             }
249              
250             sub is_cache_valid {
251 0     0 0 0 my ($self, $page_name, $page_data) = @_;
252              
253 0         0 my %paths = $self->get_cache_paths($page_name);
254              
255 0 0 0     0 return 0 unless -f $paths{html} && -f $paths{meta};
256              
257 0 0       0 open my $fh, '<', $paths{meta} or return 0;
258 0         0 my $meta_content = do { local $/; <$fh> };
  0         0  
  0         0  
259 0         0 close $fh;
260              
261 0         0 my $cached_mtimes;
262 0         0 eval { $cached_mtimes = decode_json($meta_content); };
  0         0  
263 0 0 0     0 return 0 if $@ || !$cached_mtimes;
264              
265 0         0 my $current_mtimes = $self->collect_yaml_files($page_data, $page_name);
266              
267 0 0       0 return 0 if keys %$cached_mtimes != keys %$current_mtimes;
268              
269 0         0 for my $file (keys %$cached_mtimes) {
270 0 0       0 return 0 unless exists $current_mtimes->{$file};
271 0 0       0 return 0 if $current_mtimes->{$file} != $cached_mtimes->{$file};
272             }
273              
274 0         0 return 1;
275             }
276              
277             sub get_cached_html {
278 0     0 0 0 my ($self, $page_data, $page_name) = @_;
279              
280 0 0       0 if ($ENV{SPIDERPUP_NO_CACHE}) {
281 0         0 return $self->build_html($page_data, $page_name);
282             }
283 0         0 my %paths = $self->get_cache_paths($page_name);
284              
285 0 0       0 if ($self->is_cache_valid($page_name, $page_data)) {
286 0         0 print "CACHED $page_name\n";
287 0 0       0 open my $fh, '<', $paths{html} or goto BUILD;
288 0         0 my $html = do { local $/; <$fh> };
  0         0  
  0         0  
289 0         0 close $fh;
290 0         0 return $html;
291             }
292 0         0 print "not cached $page_name\n";
293              
294 0         0 BUILD:
295             my $html = $self->build_html($page_data, $page_name);
296              
297 0         0 my $mtimes = $self->collect_yaml_files($page_data, $page_name);
298              
299 0         0 my $dir = dirname($paths{html});
300 0 0       0 make_path($dir) unless -d $dir;
301              
302 0         0 eval {
303 0 0       0 open my $html_fh, '>', $paths{html} or die "Cannot write $paths{html}: $!";
304 0         0 print $html_fh $html;
305 0         0 close $html_fh;
306              
307 0 0       0 open my $meta_fh, '>', $paths{meta} or die "Cannot write $paths{meta}: $!";
308 0         0 print $meta_fh encode_json($mtimes);
309 0         0 close $meta_fh;
310             };
311 0 0       0 warn "Cache write failed: $@" if $@;
312              
313 0         0 return $html;
314             }
315              
316             #----------------------------------------------------------------------
317             # Class generation
318             #----------------------------------------------------------------------
319              
320             sub _save_block {
321 0     0   0 my ($self, $result, $key, $mode, $multiline, $map, $array) = @_;
322 0 0       0 return unless defined $key;
323              
324 0 0       0 if ($mode eq 'multiline') {
    0          
    0          
325 0         0 $result->{$key} = join("\n", @$multiline);
326             } elsif ($mode eq 'array') {
327 0         0 $result->{$key} = [ @$array ];
328             } elsif ($mode eq 'map') {
329 0         0 $result->{$key} = { %$map };
330             }
331             }
332              
333             sub generate_single_class {
334 6     6 0 26 my ($self, $class_name, $page, $page_imports_obj) = @_;
335              
336 6 100       28 my @method_names = $page->{methods} ? keys %{$page->{methods}} : ();
  3         16  
337 6         17 my $known_methods = {map { $_ => 1 } @method_names};
  4         18  
338              
339 6   100     32 my $title = $page->{title} // '';
340 6   100     23 my $yaml_path = $page->{yaml_path} // '';
341 6   50     24 my $html_raw = $page->{html} // '';
342 6         14 my $html = $html_raw;
343 6         18 $title =~ s/\\/\\\\/g; $title =~ s/'/\\'/g;
  6         15  
344 6         16 $yaml_path =~ s/\\/\\\\/g; $yaml_path =~ s/'/\\'/g;
  6         15  
345 6         16 $html =~ s/\\/\\\\/g; $html =~ s/'/\\'/g;
  6         15  
346 6         83 $html =~ s/\n/\\n/g;
347              
348 6         43 my $structure = parse_html($html_raw, $known_methods);
349              
350 6         36 my $structure_json = encode_json($structure);
351              
352             # remove quotes around functions so they are interpreted as javascript functions
353             # handles both function() and arrow function () => styles
354 6         10703 $structure_json =~ s/"\*([a-z_]+":)"(function\([^"]+)"/"$1$2/g;
355 6         31 $structure_json =~ s/"\*([a-z_]+":)"(\([^"]*\)\s*=>[^"]*)"/"$1$2/g;
356              
357             # Build alternate structures for html_* variant keys
358 6         16 my @variant_entries;
359 6         55 for my $key (sort keys %$page) {
360 38 100       109 if ($key =~ /^html_(\w+)$/) {
361 2         7 my $variant_name = $1;
362 2         7 my $variant_html_raw = $page->{$key};
363 2         11 my $variant_structure = parse_html($variant_html_raw, $known_methods);
364 2         9 my $variant_json = encode_json($variant_structure);
365 2         750 $variant_json =~ s/"\*([a-z_]+":)"(function\([^"]+)"/"$1$2/g;
366 2         8 $variant_json =~ s/"\*([a-z_]+":)"(\([^"]*\)\s*=>[^"]*)"/"$1$2/g;
367 2         17 push @variant_entries, "$variant_name: $variant_json";
368             }
369             }
370 6 100       31 my $structures_js = @variant_entries ? '{ ' . join(', ', @variant_entries) . ' }' : '{}';
371              
372 6   50     20 my $imports_obj = $page_imports_obj // '{}';
373              
374 6         9 my $vars_json = '{}';
375 6         13 my @var_methods;
376 6 50 66     24 if ($page->{vars} && keys %{$page->{vars}}) {
  3         19  
377 3         7 my %vars_copy = %{$page->{vars}};
  3         22  
378 3         14 for my $var_name (keys %vars_copy) {
379 8         17 my $value = $vars_copy{$var_name};
380 8 100 66     74 if (defined $value && !ref($value) && $value =~ /^-?\d+\.?\d*$/) {
      100        
381 4         19 $vars_copy{$var_name} = $value + 0;
382             }
383             }
384 3         15 $vars_json = encode_json(\%vars_copy);
385 3         623 for my $var_name (sort keys %{$page->{vars}}) {
  3         19  
386 8         21 push @var_methods, " get_$var_name(defaultValue) { return this.get('$var_name', defaultValue); }";
387 8         25 push @var_methods, " set_$var_name(value) { return this.set('$var_name', value); }";
388             }
389             }
390              
391 6         14 my @custom_methods;
392 6 50 66     20 if ($page->{methods} && keys %{$page->{methods}}) {
  3         13  
393 3         7 for my $method_name (sort keys %{$page->{methods}}) {
  3         13  
394 4         19 my $method_code = transform_expression($page->{methods}{$method_name}, $known_methods);
395 4         20 push @custom_methods, " $method_name = $method_code;";
396             }
397             }
398              
399 6         15 my @computed_methods;
400 6 50 66     59 if ($page->{computed} && keys %{$page->{computed}}) {
  1         7  
401 1         3 for my $computed_name (sort keys %{$page->{computed}}) {
  1         6  
402 1         7 my $computed_code = transform_expression($page->{computed}{$computed_name}, $known_methods);
403 1         5 push @computed_methods, " get_$computed_name() { return ($computed_code).call(this); }";
404             }
405             }
406              
407 6         14 my @lifecycle_methods;
408 6 50 66     38 if ($page->{lifecycle} && keys %{$page->{lifecycle}}) {
  3         19  
409 3         7 for my $hook_name (sort keys %{$page->{lifecycle}}) {
  3         15  
410 4         21 my $hook_code = transform_expression($page->{lifecycle}{$hook_name}, $known_methods);
411 4         19 push @lifecycle_methods, " $hook_name = $hook_code;";
412             }
413             }
414              
415 6         15 my $initial_store_js = '';
416 6 0 33     22 if ($page->{'initial_store'} && keys %{$page->{'initial_store'}}) {
  0         0  
417 0         0 my $store_json = encode_json($page->{'initial_store'});
418 0         0 $initial_store_js = " _initialStore = $store_json;";
419              
420 0         0 my $has_onmount = 0;
421 0         0 my $existing_onmount_code = '';
422 0         0 for my $i (0 .. $#lifecycle_methods) {
423 0 0       0 if ($lifecycle_methods[$i] =~ /^\s*onMount\s*=\s*(.+);$/s) {
424 0         0 $has_onmount = 1;
425 0         0 $existing_onmount_code = $1;
426 0         0 splice(@lifecycle_methods, $i, 1);
427 0         0 last;
428             }
429             }
430              
431 0 0       0 if ($has_onmount) {
432 0         0 push @lifecycle_methods, " onMount = () => { store.init(this._initialStore); ($existing_onmount_code).call(this); };";
433             } else {
434 0         0 push @lifecycle_methods, " onMount = () => { store.init(this._initialStore); };";
435             }
436             }
437              
438 6         14 my $methods_str = '';
439 6 100 66     41 if (@var_methods || @custom_methods || @computed_methods || @lifecycle_methods) {
      66        
      66        
440 4         23 $methods_str = "\n" . join("\n", @var_methods, @custom_methods, @computed_methods, @lifecycle_methods) . "\n";
441             }
442              
443 6         12 my $routes_js = 'null';
444 6 50 66     23 if ($page->{routes} && keys %{$page->{routes}}) {
  1         7  
445 1         3 my @route_entries;
446 1         4 for my $route_path (sort keys %{$page->{routes}}) {
  1         6  
447 2         7 my $component_name = $page->{routes}{$route_path};
448 2         5 my $component_class = $component_name;
449 2         7 $component_class =~ s/-(.)/\U$1/g;
450 2         5 $component_class = ucfirst($component_class);
451 2         5 my $pattern = $route_path;
452 2         3 my @param_names;
453 2         10 while ($pattern =~ /:(\w+)/g) {
454 0         0 push @param_names, $1;
455             }
456 2         5 $pattern =~ s#:(\w+)#([^/]+)#g;
457 2         11 $pattern =~ s#/#\\/#g;
458 2         5 $pattern = "^$pattern\$";
459 2         8 my $params_js = '[' . join(', ', map { "'$_'" } @param_names) . ']';
  0         0  
460 2         8 push @route_entries, "{ path: '$route_path', pattern: /$pattern/, component: $component_class, params: $params_js }";
461             }
462 1         6 $routes_js = '[' . join(', ', @route_entries) . ']';
463             }
464              
465 6         16 my $css_str = '';
466 6   100     29 my $css = $page->{css} // '';
467 6         12 my $less = $page->{less};
468 6 100       48 if ($less) {
469 1         4 eval {
470 1         12 $css .= join("", CSS::LESSp->parse($less));
471             };
472 1 50       2742 warn "LESS compilation failed for $class_name: $@" if $@;
473             }
474 6 100       21 if ($css) {
475 5         22 $css =~ s/^\s+//;
476 5         67 $css =~ s/\s+$//;
477 5         16 $css =~ s/\\/\\\\/g;
478 5         46 $css =~ s/'/\\'/g;
479 5         28 $css =~ s/\n/\\n/g;
480 5         15 $css_str = "\n _css = '$css';";
481             }
482              
483 6 50       24 my $initial_store_line = $initial_store_js ? "\n$initial_store_js" : '';
484              
485 6         214 return <<"CLASS";
486             class $class_name extends Recipe {
487             title = '$title';
488             yamlPath = '$yaml_path';
489             structure = $structure_json;
490             structures = $structures_js;
491             vars = $vars_json;
492             imports = $imports_obj;
493             routes = $routes_js;$css_str$initial_store_line
494             $methods_str
495             }
496             CLASS
497             }
498              
499             #----------------------------------------------------------------------
500             # Recipe compilation
501             #----------------------------------------------------------------------
502              
503             sub compile_recipe {
504 2     2 0 7 my ($self, $module_name) = @_;
505              
506 2         9 my $component_file = $self->_find_component_file($self->{recipes_dir}, $module_name);
507 2 50       26 return unless $component_file;
508              
509 2         9 my $page_data = $self->_load_component_data($component_file);
510 2         25057 $page_data->{yaml_path} = File::Spec->abs2rel($component_file, $self->{www_dir});
511              
512 2         9 my $class_name = $module_name;
513 2         8 $class_name =~ s/-(.)/\U$1/g;
514 2         7 $class_name = ucfirst($class_name);
515              
516 2         4 my @import_lines;
517             my @import_pairs;
518 2   50     11 my $imports = $page_data->{import} // {};
519              
520 2         11 for my $namespace (sort keys %$imports) {
521 2         7 my $import_path = $imports->{$namespace};
522 2         12 my ($dep_mod_name, $dep_class_name, $dep_js_path) = $self->resolve_recipe_name($import_path);
523              
524 2         9 my $dep_data = $self->load_page($import_path);
525 2         8 my @exported_classes = ($dep_class_name);
526              
527 2 50 33     21 if ($dep_data && $dep_data->{recipes} && ref($dep_data->{recipes}) eq 'HASH') {
      33        
528 0         0 for my $recipe_name (sort keys %{$dep_data->{recipes}}) {
  0         0  
529 0         0 push @exported_classes, "${dep_class_name}_${recipe_name}";
530             }
531             }
532              
533 2         6 my $imports_list = join(', ', @exported_classes);
534 2         8 push @import_lines, "import { $imports_list } from './$dep_mod_name.js';";
535              
536 2         6 my $ns_lower = lc($namespace);
537 2         7 push @import_pairs, "$ns_lower: $dep_class_name";
538              
539 2 50 33     18 if ($dep_data && $dep_data->{recipes}) {
540 0         0 for my $recipe_name (sort keys %{$dep_data->{recipes}}) {
  0         0  
541 0         0 my $dot_name = "$ns_lower.$recipe_name";
542 0         0 my $sub_class = "${dep_class_name}_${recipe_name}";
543 0         0 push @import_pairs, "\"$dot_name\": $sub_class";
544             }
545             }
546             }
547              
548 2 100 66     16 if ($page_data->{recipes} && ref($page_data->{recipes}) eq 'HASH') {
549 1         3 for my $recipe_name (sort keys %{$page_data->{recipes}}) {
  1         6  
550 1         3 my $sub_class = "${class_name}_${recipe_name}";
551 1         4 $sub_class = ucfirst($sub_class);
552 1         4 push @import_pairs, "$recipe_name: $sub_class";
553             }
554             }
555              
556 2 50       17 my $imports_obj = @import_pairs ? '{ ' . join(', ', @import_pairs) . ' }' : '{}';
557              
558 2         3 my @classes;
559              
560 2 100 66     13 if ($page_data->{recipes} && ref($page_data->{recipes}) eq 'HASH') {
561 1         2 for my $recipe_name (sort keys %{$page_data->{recipes}}) {
  1         5  
562 1         4 my $sub_recipe = $page_data->{recipes}{$recipe_name};
563 1         2 my $sub_class_name = "${class_name}_${recipe_name}";
564 1         5 $sub_class_name = ucfirst($sub_class_name);
565 1         6 push @classes, "export " . $self->generate_single_class($sub_class_name, $sub_recipe, '{}');
566             }
567             }
568              
569 2         12 push @classes, "export " . $self->generate_single_class($class_name, $page_data, $imports_obj);
570              
571 2         6 my $js_content = '';
572 2 50       9 if (@import_lines) {
573 2         8 $js_content .= join("\n", @import_lines) . "\n\n";
574             }
575 2         47 $js_content .= join("\n", @classes);
576              
577 2         36 my $js_file = File::Spec->catfile($self->{js_dir}, "$module_name.js");
578 2 50       395 open my $fh, '>', $js_file or die "Cannot write $js_file: $!";
579 2         55 print $fh $js_content;
580 2         138 close $fh;
581              
582 2         55 my $meta_file = File::Spec->catfile($self->{js_dir}, "$module_name.js.meta");
583 2         8 my %mtimes;
584 2         57 $mtimes{$component_file} = (stat($component_file))[9];
585 2         12 for my $namespace (keys %$imports) {
586 2         5 my $import_path = $imports->{$namespace};
587 2         11 my ($dep_mod_name) = $self->resolve_recipe_name($import_path);
588 2         10 my $dep_file = $self->_find_component_file($self->{recipes_dir}, $dep_mod_name);
589 2 50       38 $mtimes{$dep_file} = (stat($dep_file))[9] if $dep_file;
590             }
591 2 50       390 open my $meta_fh, '>', $meta_file or die "Cannot write $meta_file: $!";
592 2         15 print $meta_fh encode_json(\%mtimes);
593 2         505 close $meta_fh;
594              
595 2         43 print " Compiled recipe: $module_name -> webroot/js/$module_name.js\n";
596 2         40 return $js_content;
597             }
598              
599             sub compile_recipe_if_stale {
600 3     3 0 45 my ($self, $module_name) = @_;
601 3         15 my %paths = $self->get_recipe_cache_paths($module_name);
602              
603 3 50 66     183 if (-f $paths{js} && -f $paths{meta} && !$ENV{SPIDERPUP_NO_CACHE}) {
      66        
604 1 50       50 open my $fh, '<', $paths{meta} or goto COMPILE;
605 1         3 my $meta = do { local $/; <$fh> };
  1         7  
  1         73  
606 1         13 close $fh;
607 1         5 my $cached = eval { decode_json($meta) };
  1         8  
608 1 50       2010 if ($cached) {
609 1         2 my $valid = 1;
610 1         6 for my $file (keys %$cached) {
611 2 50 33     83 if (!-f $file || (stat($file))[9] != $cached->{$file}) {
612 0         0 $valid = 0;
613 0         0 last;
614             }
615             }
616 1 50       14 return if $valid;
617             }
618             }
619              
620             COMPILE:
621 2         13 $self->compile_recipe($module_name);
622             }
623              
624             #----------------------------------------------------------------------
625             # Page JS compilation
626             #----------------------------------------------------------------------
627              
628             sub compile_page_js {
629 3     3 0 12 my ($self, $page_name) = @_;
630              
631 3         15 my $component_file = $self->_find_component_file($self->{pages_dir}, $page_name);
632 3 50       14 return unless $component_file;
633              
634 3         15 my $page_data = $self->_load_component_data($component_file);
635 3         54984 $page_data->{yaml_path} = File::Spec->abs2rel($component_file, $self->{www_dir});
636              
637 3         12 my $class_name = $page_name;
638 3         30 $class_name =~ s|/|_|g;
639 3         9 $class_name =~ s/-(.)/\U$1/g;
640 3         12 $class_name = ucfirst($class_name);
641              
642 3         10 my @segments = split(m|/|, $page_name);
643 3         7 my $depth = scalar(@segments);
644 3         10 my $prefix = '../' x $depth;
645              
646 3         7 my @import_lines;
647             my @import_pairs;
648 3   100     17 my $imports = $page_data->{import} // {};
649              
650 3         20 for my $namespace (sort keys %$imports) {
651 5         15 my $import_path = $imports->{$namespace};
652 5         30 my ($dep_name, $dep_class_name, $dep_js_path, $dep_is_page) = $self->resolve_recipe_name($import_path);
653              
654 5 100       18 if ($dep_is_page) {
655 2         13 $self->compile_page_js_if_stale($dep_name);
656             } else {
657 3         13 $self->compile_recipe_if_stale($dep_name);
658             }
659              
660 5         53 my $dep_data = $self->load_page($import_path);
661 5         18 my @exported_classes = ($dep_class_name);
662              
663 5 100 66     71 if ($dep_data && $dep_data->{recipes} && ref($dep_data->{recipes}) eq 'HASH') {
      66        
664 2         8 for my $recipe_name (sort keys %{$dep_data->{recipes}}) {
  2         17  
665 2         26 push @exported_classes, "${dep_class_name}_${recipe_name}";
666             }
667             }
668              
669 5         22 my $imports_list = join(', ', @exported_classes);
670              
671 5         8 my $rel_path;
672 5 100       21 if ($dep_is_page) {
673 2         8 $rel_path = "${prefix}pages/$dep_name.js";
674             } else {
675 3         11 $rel_path = "${prefix}$dep_name.js";
676             }
677 5         20 push @import_lines, "import { $imports_list } from './$rel_path';";
678              
679 5         16 my $ns_lower = lc($namespace);
680 5         20 push @import_pairs, "$ns_lower: $dep_class_name";
681              
682 5 100 66     58 if ($dep_data && $dep_data->{recipes}) {
683 2         6 for my $recipe_name (sort keys %{$dep_data->{recipes}}) {
  2         9  
684 2         7 my $dot_name = "$ns_lower.$recipe_name";
685 2         4 my $sub_class = "${dep_class_name}_${recipe_name}";
686 2         35 push @import_pairs, "\"$dot_name\": $sub_class";
687             }
688             }
689             }
690              
691 3 50 33     21 if ($page_data->{recipes} && ref($page_data->{recipes}) eq 'HASH') {
692 0         0 for my $recipe_name (sort keys %{$page_data->{recipes}}) {
  0         0  
693 0         0 my $sub_class = "${class_name}_${recipe_name}";
694 0         0 $sub_class = ucfirst($sub_class);
695 0         0 push @import_pairs, "$recipe_name: $sub_class";
696             }
697             }
698              
699 3 100       18 my $imports_obj = @import_pairs ? '{ ' . join(', ', @import_pairs) . ' }' : '{}';
700              
701 3         7 my @classes;
702              
703 3 50 33     71 if ($page_data->{recipes} && ref($page_data->{recipes}) eq 'HASH') {
704 0         0 for my $recipe_name (sort keys %{$page_data->{recipes}}) {
  0         0  
705 0         0 my $sub_recipe = $page_data->{recipes}{$recipe_name};
706 0         0 my $sub_class_name = "${class_name}_${recipe_name}";
707 0         0 $sub_class_name = ucfirst($sub_class_name);
708 0         0 push @classes, "export " . $self->generate_single_class($sub_class_name, $sub_recipe, '{}');
709             }
710             }
711              
712 3         26 push @classes, "export " . $self->generate_single_class($class_name, $page_data, $imports_obj);
713              
714 3         9 my $js_content = '';
715 3 100       12 if (@import_lines) {
716 2         10 $js_content .= join("\n", @import_lines) . "\n\n";
717             }
718 3         25 $js_content .= join("\n", @classes);
719              
720 3         55 my $js_file = File::Spec->catfile($self->{js_dir}, 'pages', "$page_name.js");
721 3         223 my $js_dir = dirname($js_file);
722 3 100       532 make_path($js_dir) unless -d $js_dir;
723              
724 3 50       796 open my $fh, '>', $js_file or die "Cannot write $js_file: $!";
725 3         114 print $fh $js_content;
726 3         246 close $fh;
727              
728 3         76 my $meta_file = File::Spec->catfile($self->{js_dir}, 'pages', "$page_name.js.meta");
729 3         37 my %mtimes;
730 3         101 $mtimes{$component_file} = (stat($component_file))[9];
731 3         19 for my $namespace (keys %$imports) {
732 5         19 my $import_path = $imports->{$namespace};
733 5         25 my ($dep_name, undef, undef, $dep_is_page) = $self->resolve_recipe_name($import_path);
734 5 100       20 my $dep_dir = $dep_is_page ? $self->{pages_dir} : $self->{recipes_dir};
735 5         18 my $dep_file = $self->_find_component_file($dep_dir, $dep_name);
736 5 50       92 $mtimes{$dep_file} = (stat($dep_file))[9] if $dep_file;
737             }
738 3 50       553 open my $meta_fh, '>', $meta_file or die "Cannot write $meta_file: $!";
739 3         26 print $meta_fh encode_json(\%mtimes);
740 3         850 close $meta_fh;
741              
742 3         89 print " Compiled page JS: $page_name -> webroot/js/pages/$page_name.js\n";
743 3         62 return $js_content;
744             }
745              
746             sub compile_page_js_if_stale {
747 4     4 0 12 my ($self, $page_name) = @_;
748              
749 4         75 my $js_file = File::Spec->catfile($self->{js_dir}, 'pages', "$page_name.js");
750 4         36 my $meta_file = File::Spec->catfile($self->{js_dir}, 'pages', "$page_name.js.meta");
751              
752 4 50 66     170 if (-f $js_file && -f $meta_file && !$ENV{SPIDERPUP_NO_CACHE}) {
      66        
753 1 50       35 open my $fh, '<', $meta_file or goto COMPILE;
754 1         2 my $meta = do { local $/; <$fh> };
  1         4  
  1         59  
755 1         10 close $fh;
756 1         2 my $cached = eval { decode_json($meta) };
  1         5  
757 1 50       1955 if ($cached) {
758 1         1 my $valid = 1;
759 1         4 for my $file (keys %$cached) {
760 4 50 33     72 if (!-f $file || (stat($file))[9] != $cached->{$file}) {
761 0         0 $valid = 0;
762 0         0 last;
763             }
764             }
765 1 50       8 return if $valid;
766             }
767             }
768              
769             COMPILE:
770 3         66 $self->compile_page_js($page_name);
771             }
772              
773             #----------------------------------------------------------------------
774             # SSR rendering
775             #----------------------------------------------------------------------
776              
777             sub ssr_substitute_vars {
778 0     0 0 0 my ($self, $func_str, $vars) = @_;
779 0         0 my $text = $func_str;
780 0         0 $text =~ s/^function\(\)\{return\s*`(.*)`\}$/$1/s;
781 0 0       0 $text =~ s/\$\{this\.get_(\w+)\(\)\}/defined $vars->{$1} ? $self->_html_escape($vars->{$1}) : ''/ge;
  0         0  
782 0         0 return $text;
783             }
784              
785             sub _html_escape {
786 8     8   16 my ($self, $text) = @_;
787 8 50       160 return '' unless defined $text;
788 8         17 $text =~ s/&/&/g;
789 8         11 $text =~ s/
790 8         7 $text =~ s/>/>/g;
791 8         10 $text =~ s/"/"/g;
792 8         25 return $text;
793             }
794              
795             sub render_ssr_body {
796 5     5 0 39 my ($self, $structure, $vars, $recipes_map, $slot_children, $slot_vars, $slot_recipes_map) = @_;
797 5         9 my $html = '';
798 5   50     10 my $children = $structure->{children} // [];
799 5         18 for my $i (0 .. $#$children) {
800 5         14 $html .= $self->render_ssr_node($children, $i, $vars, $recipes_map, $slot_children, $slot_vars, $slot_recipes_map, {}, undef);
801             }
802 5         12 return $html;
803             }
804              
805             sub render_ssr_node {
806             # $parent_component_vars: vars of the component that is doing the current rendering
807             # Used so nested components in slot content know their parent component's vars
808 10     10 0 24 my ($self, $siblings, $idx, $vars, $recipes_map, $slot_children, $slot_vars, $slot_recipes_map, $seen, $parent_component_vars) = @_;
809 10   50     17 $seen //= {};
810 10 50       21 return '' if $seen->{$idx};
811              
812 10         12 my $node = $siblings->[$idx];
813 10 50       19 return '' unless $node;
814              
815 10 100       20 if (exists $node->{content}) {
816 5         11 return $self->_html_escape($node->{content});
817             }
818              
819 5 50       8 if (exists $node->{'*content'}) {
820 0         0 return $self->ssr_substitute_vars($node->{'*content'}, $vars);
821             }
822              
823 5   50     11 my $tag = $node->{tag} // '';
824 5   50     9 my $attrs = $node->{attributes} // {};
825 5   50     8 my $children = $node->{children} // [];
826              
827 5 50       12 if ($tag eq 'if') {
828 0         0 for my $j (($idx + 1) .. $#$siblings) {
829 0         0 my $sib = $siblings->[$j];
830 0 0 0     0 last unless $sib && $sib->{tag} && ($sib->{tag} eq 'elseif' || $sib->{tag} eq 'else');
      0        
      0        
831 0         0 $seen->{$j} = 1;
832 0 0       0 last if $sib->{tag} eq 'else';
833             }
834 0         0 return '
';
835             }
836              
837 5 50 33     67 return '' if $tag eq 'elseif' || $tag eq 'else';
838              
839             # tag: render the slot children with the parent's vars
840             # Pass $vars (component's own vars) as parent_component_vars so nested
841             # components in slot content know their "parent component" for scoping
842             # Named slots only render children with matching slot="name" attribute;
843             # default (unnamed) slots only render children without a slot attribute.
844 5 50       11 if ($tag eq 'slot') {
845 0 0 0     0 if ($slot_children && @$slot_children) {
846 0         0 my $slot_name = $attrs->{name};
847 0         0 my $slot_html = '';
848 0         0 my $child_seen = {};
849 0         0 for my $i (0 .. $#$slot_children) {
850 0         0 my $child = $slot_children->[$i];
851 0 0       0 next unless $child;
852 0   0     0 my $child_slot = ($child->{attributes} // {})->{slot};
853 0 0       0 if ($slot_name) {
854 0 0 0     0 next unless defined $child_slot && $child_slot eq $slot_name;
855             } else {
856 0 0       0 next if defined $child_slot;
857             }
858 0   0     0 $slot_html .= $self->render_ssr_node(
      0        
859             $slot_children, $i,
860             $slot_vars // $vars,
861             $slot_recipes_map // $recipes_map,
862             undef, undef, undef, $child_seen,
863             $vars # parent_component_vars = slot-owning component's vars
864             );
865             }
866 0         0 return "$slot_html";
867             }
868 0         0 return '';
869             }
870              
871 5 50 33     16 if ($recipes_map && $recipes_map->{$tag}) {
872 0         0 my $mod_info = $recipes_map->{$tag};
873 0         0 my $mod_data = $mod_info->{data};
874 0         0 my $mod_vars = {};
875              
876 0 0       0 if ($mod_data->{vars}) {
877 0         0 %$mod_vars = %{$mod_data->{vars}};
  0         0  
878             }
879              
880 0         0 for my $attr (keys %$attrs) {
881 0 0       0 next if $attr =~ /^\*/;
882 0 0 0     0 next if $attr eq 'for' || $attr eq 'slot';
883 0 0       0 next if $attr =~ /^!/;
884 0         0 $mod_vars->{$attr} = $attrs->{$attr};
885             }
886              
887 0         0 my $variant = $node->{variant};
888 0         0 my $mod_html_raw;
889 0 0 0     0 if ($variant && $mod_data->{"html_$variant"}) {
890 0         0 $mod_html_raw = $mod_data->{"html_$variant"};
891             } else {
892 0   0     0 $mod_html_raw = $mod_data->{html} // '';
893             }
894 0         0 my $mod_structure = parse_html($mod_html_raw, {});
895              
896 0         0 my $sub_modules_map = $self->_build_recipes_map($mod_data);
897              
898             # Slot content scopes to the parent of the slot-owning component.
899             # $parent_component_vars reflects the component that created this component
900             # (like parentModule in the JS runtime). Falls back to $vars (current context).
901 0   0     0 my $parent_vars = $parent_component_vars // $vars;
902              
903             # Pass slot children and parent vars so tags can render them with correct scoping
904 0         0 my $component_html = $self->render_ssr_body(
905             $mod_structure, $mod_vars, $sub_modules_map,
906             $children, $parent_vars, $recipes_map
907             );
908              
909             # Render slot children after template if component has no explicit tag
910             # parent_component_vars = mod_vars (this component's vars) so nested components
911             # know their parent component for slot scoping
912 0 0 0     0 if ($children && @$children && !$self->_has_slot_tag($mod_structure)) {
      0        
913 0         0 my $child_seen = {};
914 0         0 for my $i (0 .. $#$children) {
915 0         0 $component_html .= $self->render_ssr_node(
916             $children, $i, $parent_vars, $recipes_map, undef, undef, undef, $child_seen, $mod_vars
917             );
918             }
919             }
920              
921 0         0 return $component_html;
922             }
923              
924 5 100       12 if ($tag eq 'link') {
925 3   50     7 my $to = $attrs->{to} // '/';
926 3   100     7 my $base = $self->{base_url_path} || '';
927 3 100       8 my $html_to = ($to eq '/') ? '/' : "$to.html";
928 3         9 my $href = $self->_html_escape($base . $html_to);
929 3         4 my $inner = '';
930 3         4 my $child_seen = {};
931 3         6 for my $i (0 .. $#$children) {
932 3         5 $inner .= $self->render_ssr_node($children, $i, $vars, $recipes_map, $slot_children, $slot_vars, $slot_recipes_map, $child_seen, $parent_component_vars);
933             }
934 3         13 return "$inner";
935             }
936              
937 2 50       5 if ($tag eq 'router') {
938 0         0 return '
';
939             }
940              
941 2 50       5 if ($attrs->{'*for'}) {
942 0         0 my $static_attrs = $self->_render_static_attrs($attrs);
943 0         0 return "<$tag$static_attrs data-sp-for>";
944             }
945              
946 2         8 my $static_attrs = $self->_render_static_attrs($attrs);
947              
948 2 50       30 if ($VOID_TAGS{$tag}) {
949 0         0 return "<$tag$static_attrs />";
950             }
951              
952 2         5 my $inner = '';
953 2         3 my $child_seen = {};
954 2         7 for my $i (0 .. $#$children) {
955 2         54 $inner .= $self->render_ssr_node($children, $i, $vars, $recipes_map, $slot_children, $slot_vars, $slot_recipes_map, $child_seen, $parent_component_vars);
956             }
957 2         10 return "<$tag$static_attrs>$inner";
958             }
959              
960             sub _render_static_attrs {
961 2     2   4 my ($self, $attrs) = @_;
962 2         4 my $result = '';
963 2         7 for my $attr (sort keys %$attrs) {
964 0 0       0 next if $attr =~ /^\*/;
965 0 0 0     0 next if $attr eq 'for' || $attr eq 'slot' || $attr eq 'condition';
      0        
966 0 0       0 next if $attr =~ /^!/;
967 0         0 my $val = $attrs->{$attr};
968 0 0       0 next if ref $val;
969 0         0 $result .= ' ' . $self->_html_escape($attr) . '="' . $self->_html_escape($val) . '"';
970             }
971 2         5 return $result;
972             }
973              
974             sub _has_slot_tag {
975 0     0   0 my ($self, $structure) = @_;
976 0   0     0 my $children = $structure->{children} // [];
977 0         0 for my $child (@$children) {
978 0 0 0     0 return 1 if ($child->{tag} // '') eq 'slot';
979 0 0       0 return 1 if $self->_has_slot_tag($child);
980             }
981 0         0 return 0;
982             }
983              
984             sub _build_recipes_map {
985 2     2   4 my ($self, $page_data) = @_;
986 2   50     8 my $imports = $page_data->{import} // {};
987 2         4 my $recipes_map = {};
988              
989 2         7 for my $namespace (keys %$imports) {
990 0         0 my $import_path = $imports->{$namespace};
991 0         0 my $mod_data = $self->load_page($import_path);
992 0 0       0 next unless $mod_data;
993              
994 0         0 my ($mod_name, $mod_class) = $self->resolve_recipe_name($import_path);
995 0         0 my $ns_lower = lc($namespace);
996 0         0 $recipes_map->{$ns_lower} = { data => $mod_data, class_name => $mod_class };
997              
998 0 0 0     0 if ($mod_data->{recipes} && ref($mod_data->{recipes}) eq 'HASH') {
999 0         0 for my $recipe_name (keys %{$mod_data->{recipes}}) {
  0         0  
1000             $recipes_map->{"$ns_lower.$recipe_name"} = {
1001 0         0 data => $mod_data->{recipes}{$recipe_name},
1002             class_name => "${mod_class}_${recipe_name}",
1003             };
1004             }
1005             }
1006             }
1007              
1008 2 50 33     7 if ($page_data->{recipes} && ref($page_data->{recipes}) eq 'HASH') {
1009 0         0 for my $recipe_name (keys %{$page_data->{recipes}}) {
  0         0  
1010             $recipes_map->{$recipe_name} = {
1011 0         0 data => $page_data->{recipes}{$recipe_name},
1012             class_name => $recipe_name,
1013             };
1014             }
1015             }
1016              
1017 2         5 return $recipes_map;
1018             }
1019              
1020             #----------------------------------------------------------------------
1021             # Page compilation (HTML with ES module imports)
1022             #----------------------------------------------------------------------
1023              
1024             sub collect_external_assets {
1025 2     2 0 4 my ($self, $page_data) = @_;
1026              
1027 2         4 my @css_files;
1028             my @js_files;
1029              
1030 2   50     9 my $import_css = $page_data->{'import-css'} // [];
1031 2         5 for my $css_file (@$import_css) {
1032 0         0 push @css_files, $css_file;
1033             }
1034              
1035 2   50     10 my $import_js = $page_data->{'import-js'} // [];
1036 2         5 for my $js_file (@$import_js) {
1037 0         0 push @js_files, $js_file;
1038             }
1039              
1040 2         7 return (\@css_files, \@js_files);
1041             }
1042              
1043             sub collect_inline_js {
1044 2     2 0 3 my ($self, $page_data) = @_;
1045              
1046 2         4 my @js_contents;
1047 2   50     9 my $include_js = $page_data->{'include-js'} // [];
1048 2         5 for my $js_file (@$include_js) {
1049 0         0 my $file_path = File::Spec->catfile($self->{static_dir}, $js_file);
1050 0 0       0 if (-f $file_path) {
1051 0 0       0 open my $fh, '<', $file_path or do {
1052 0         0 warn "Cannot open include-js file $file_path: $!";
1053 0         0 next;
1054             };
1055 0         0 my $content = do { local $/; <$fh> };
  0         0  
  0         0  
1056 0         0 close $fh;
1057 0         0 push @js_contents, "/* include-js: $js_file */\n$content";
1058             } else {
1059 0         0 warn "include-js file not found: $file_path";
1060             }
1061             }
1062              
1063 2         5 return \@js_contents;
1064             }
1065              
1066             sub build_html {
1067 2     2 0 21 my ($self, $page_data, $page_name) = @_;
1068 2   50     9 $page_name //= 'index';
1069              
1070 2   50     8 my $title = $page_data->{title} // 'Untitled';
1071              
1072 2         18 my $class_name = $page_name;
1073 2         8 $class_name =~ s|/|_|g;
1074 2         3 $class_name =~ s/-(.)/\U$1/g;
1075 2         42 $class_name = ucfirst($class_name);
1076              
1077 2         14 $self->compile_page_js_if_stale($page_name);
1078              
1079 2   50     23 my $css = $page_data->{css} // '';
1080 2         5 my $less = $page_data->{less};
1081 2 50       6 if ($less) {
1082 0         0 eval { $css .= join("", CSS::LESSp->parse($less)); };
  0         0  
1083 0 0       0 warn "LESS compilation failed for page $page_name: $@" if $@;
1084             }
1085 2         4 my $style = '';
1086 2 50       6 if ($css) {
1087 0         0 $css =~ s/^\s+//;
1088 0         0 $css =~ s/\s+$//;
1089 0         0 $style = "";
1090             }
1091              
1092 2         9 my ($external_css, $external_js) = $self->collect_external_assets($page_data);
1093 2         7 my $inline_js = $self->collect_inline_js($page_data);
1094              
1095 2         3 my $css_links = '';
1096 2         4 for my $css_file (@$external_css) {
1097 0         0 $css_links .= qq{ \n};
1098             }
1099              
1100 2         4 my $js_scripts = '';
1101 2         3 for my $js_file (@$external_js) {
1102 0         0 $js_scripts .= qq{ \n};
1103             }
1104              
1105 2         4 my $inline_js_script = '';
1106 2 50       7 if (@$inline_js) {
1107 0         0 my $inline_content = join("\n\n", @$inline_js);
1108 0         0 $inline_js_script = " \n";
1109             }
1110              
1111 2   100     8 my $base_url_path = $self->{base_url_path} || '';
1112 2 100       7 my $spiderpup_src = $base_url_path ? "$base_url_path/js/spiderpup.js" : "/js/spiderpup.js";
1113              
1114 2         8 my $recipes_map = $self->_build_recipes_map($page_data);
1115 2   50     7 my $page_html_raw = $page_data->{html} // '';
1116 2         8 my $page_structure = parse_html($page_html_raw, {});
1117 2   50     13 my $page_vars = $page_data->{vars} // {};
1118 2         10 my $ssr_body = $self->render_ssr_body($page_structure, $page_vars, $recipes_map);
1119              
1120             # Skip SSR hydration if body has no meaningful content (only empty placeholders)
1121 2         4 my $ssr_test = $ssr_body;
1122 2         20 $ssr_test =~ s/
<\/div>//g;
1123 2         5 $ssr_test =~ s/\s//g;
1124 2 50       7 my $ssr_attr = $ssr_test ne '' ? ' data-sp-ssr' : '';
1125              
1126 2         22 return <<"HTML";
1127            
1128            
1129            
1130            
1131             $title
1132             $css_links
1133             $style
1134             $js_scripts
1135             $inline_js_script
1136            
1137            
1152            
1153             $ssr_body
1154            
1155             HTML
1156             }
1157              
1158             #----------------------------------------------------------------------
1159             # Request handling
1160             #----------------------------------------------------------------------
1161              
1162             sub load_page_from_path {
1163 0     0 0   my ($self, $path) = @_;
1164              
1165 0           my $response = '';
1166              
1167 0           my $page_data;
1168             my $load_error;
1169 0           my $page_name = $self->parse_page_path($path);
1170              
1171 0           eval {
1172 0           $page_data = $self->load_page($page_name);
1173 0           print "Loaded page: $page_name\n";
1174             };
1175 0 0         $load_error = $@ if $@;
1176              
1177 0 0         if ($load_error) {
    0          
1178 0           my $escaped_error = $load_error;
1179 0           $escaped_error =~ s/&/&/g;
1180 0           $escaped_error =~ s/
1181 0           $escaped_error =~ s/>/>/g;
1182 0           $escaped_error =~ s/\n/
/g;
1183              
1184 0           my $error_html = <<"ERRORHTML";
1185            
1186            
1187            
1188             Spiderpup Error
1189            
1217            
1218            
1219            
1220            
Spiderpup Compilation Error
1221            
$escaped_error
1222            
1223            
1224            
1225             ERRORHTML
1226              
1227 0           my $content_length = length($error_html);
1228 0           $response = "HTTP/1.1 500 Internal Server Error\r\n";
1229 0           $response .= "Content-Type: text/html; charset=utf-8\r\n";
1230 0           $response .= "Content-Length: $content_length\r\n";
1231 0           $response .= "Connection: close\r\n";
1232 0           $response .= "\r\n";
1233 0           $response .= $error_html;
1234             } elsif (defined $page_data) {
1235 0           my $body;
1236 0           eval {
1237 0           print "GET ($path) -> page=$page_name\n";
1238 0           $body = $self->get_cached_html($page_data, $page_name);
1239             };
1240 0 0         if ($@) {
1241 0           my $escaped_error = $@;
1242 0           $escaped_error =~ s/&/&/g;
1243 0           $escaped_error =~ s/
1244 0           $escaped_error =~ s/>/>/g;
1245 0           $escaped_error =~ s/\n/
/g;
1246              
1247 0           $body = <<"ERRORHTML";
1248            
1249            
1250            
1251             Spiderpup Error
1252            
1280            
1281            
1282            
1283            
Spiderpup Build Error
1284            
$escaped_error
1285            
1286            
1287            
1288             ERRORHTML
1289             }
1290              
1291 0           my $content_length = length($body);
1292 0           $response = "HTTP/1.1 200 OK\r\n";
1293 0           $response .= "Content-Type: text/html; charset=utf-8\r\n";
1294 0           $response .= "Content-Length: $content_length\r\n";
1295 0           $response .= "Connection: close\r\n";
1296 0           $response .= "\r\n";
1297 0           $response .= $body;
1298             } else {
1299 0           my $not_found = "

404 Not Found

";
1300 0           my $content_length = length($not_found);
1301 0           $response = "HTTP/1.1 404 Not Found\r\n";
1302 0           $response .= "Content-Type: text/html; charset=utf-8\r\n";
1303 0           $response .= "Content-Length: $content_length\r\n";
1304 0           $response .= "Connection: close\r\n";
1305 0           $response .= "\r\n";
1306 0           $response .= $not_found;
1307             }
1308 0           return $response;
1309             }
1310              
1311             #----------------------------------------------------------------------
1312             # HTTP server
1313             #----------------------------------------------------------------------
1314              
1315             sub run_server {
1316 0     0 0   my ($self, $port, %opts) = @_;
1317 0   0       $port //= 5000;
1318              
1319 0           $SIG{CHLD} = 'IGNORE';
1320              
1321 0           my $listener;
1322 0   0       my $is_ssl = $opts{ssl_cert} && $opts{ssl_key};
1323              
1324 0 0         if ($is_ssl) {
1325 0           require IO::Socket::SSL;
1326             $listener = IO::Socket::SSL->new(
1327             LocalAddr => '0.0.0.0',
1328             LocalPort => $port,
1329             Proto => 'tcp',
1330             Listen => SOMAXCONN,
1331             Reuse => 1,
1332             SSL_cert_file => $opts{ssl_cert},
1333             SSL_key_file => $opts{ssl_key},
1334 0 0         ) or die "Cannot create SSL socket: $! ($IO::Socket::SSL::SSL_ERROR)";
1335             } else {
1336 0 0         $listener = IO::Socket::INET->new(
1337             LocalAddr => '0.0.0.0',
1338             LocalPort => $port,
1339             Proto => 'tcp',
1340             Listen => SOMAXCONN,
1341             Reuse => 1,
1342             ) or die "Cannot create socket: $!";
1343             }
1344              
1345 0 0         my $scheme = $is_ssl ? 'https' : 'http';
1346 0           print "Server running on $scheme://localhost:$port\n";
1347 0           print "Press Ctrl+C to stop.\n";
1348              
1349 0           my $base_url_path = $self->{base_url_path};
1350              
1351 0           while (my $conn = $listener->accept) {
1352 0           my $pid = fork();
1353              
1354 0 0         if (!defined $pid) {
1355 0           warn "Fork failed: $!";
1356 0           $conn->close;
1357 0           next;
1358             }
1359              
1360 0 0         if ($pid == 0) {
1361 0           $listener->close;
1362              
1363 0           eval {
1364 0           my $request = '';
1365 0           $conn->recv($request, 8192);
1366 0           print "RAW Request: $request\n";
1367 0           my ($request_line) = split(/\r?\n/, $request, 2);
1368 0   0       $request_line //= '';
1369 0           my ($method, $path, $version) = split(/\s+/, $request_line);
1370              
1371 0           print "Request: $method $path\n";
1372              
1373 0           $path =~ s~^/*$base_url_path~~;
1374              
1375 0           print "Path adjusted to $path\n";
1376              
1377 0           my $response;
1378              
1379             # Serve static files from webroot (JS, etc.)
1380 0 0         if ($path =~ /^\/?(.+\.js)$/) {
1381 0           my $js_file = File::Spec->catfile($self->{webroot_dir}, $1);
1382 0 0         if (-f $js_file) {
1383 0           print "FOUND $js_file\n";
1384 0 0         open my $fh, '<', $js_file or die "Cannot open $js_file: $!";
1385 0           my $body = do { local $/; <$fh> };
  0            
  0            
1386 0           close $fh;
1387 0           my $content_length = length($body);
1388 0           $response = "HTTP/1.1 200 OK\r\n";
1389 0           $response .= "Content-Type: application/javascript; charset=utf-8\r\n";
1390 0           $response .= "Content-Length: $content_length\r\n";
1391 0           $response .= "Connection: close\r\n";
1392 0           $response .= "\r\n";
1393 0           $response .= $body;
1394             } else {
1395 0           print "missing $js_file\n";
1396             }
1397             }
1398              
1399             # Serve raw YAML/PUP files
1400 0 0 0       if (!$response && $path =~ /\.(yaml|pup)$/) {
1401 0           my $raw_path = $path;
1402 0           $raw_path =~ s~^/~~;
1403 0           my $raw_file = File::Spec->catfile($self->{www_dir}, $raw_path);
1404 0 0         if (-f $raw_file) {
1405 0 0         open my $fh, '<', $raw_file or die "Cannot open $raw_file: $!";
1406 0           my $body = do { local $/; <$fh> };
  0            
  0            
1407 0           close $fh;
1408 0 0         my $content_type = $raw_file =~ /\.pup$/ ? 'text/plain' : 'text/yaml';
1409 0           my $content_length = length($body);
1410 0           $response = "HTTP/1.1 200 OK\r\n";
1411 0           $response .= "Content-Type: $content_type; charset=utf-8\r\n";
1412 0           $response .= "Content-Length: $content_length\r\n";
1413 0           $response .= "Connection: close\r\n";
1414 0           $response .= "\r\n";
1415 0           $response .= $body;
1416             }
1417             }
1418              
1419             # Load page from YAML file
1420 0 0         if (!$response) {
1421 0           $response = $self->load_page_from_path($path);
1422             }
1423              
1424 0           $conn->send($response);
1425             };
1426 0 0         if ($@) {
1427 0           warn "Error handling request: $@";
1428             }
1429              
1430 0           $conn->close;
1431 0           exit(0);
1432             }
1433              
1434 0           $conn->close;
1435             }
1436             }
1437              
1438             #----------------------------------------------------------------------
1439             # Batch compilation
1440             #----------------------------------------------------------------------
1441              
1442             sub find_yaml_files {
1443 0     0 0   my ($self, $dir, $prefix, $results) = @_;
1444              
1445 0 0         opendir(my $dh, $dir) or return;
1446 0           my @entries = readdir($dh);
1447 0           closedir($dh);
1448              
1449 0           for my $entry (@entries) {
1450 0 0         next if $entry =~ /^\./;
1451 0           my $path = File::Spec->catfile($dir, $entry);
1452              
1453 0 0         if (-d $path) {
    0          
1454 0 0         my $new_prefix = $prefix ? "$prefix/$entry" : $entry;
1455 0           $self->find_yaml_files($path, $new_prefix, $results);
1456             } elsif ($entry =~ /\.(yaml|pup)$/) {
1457 0           my $name = $entry;
1458 0           $name =~ s/\.(yaml|pup)$//;
1459 0 0         $name = $prefix ? "$prefix/$name" : $name;
1460 0           push @$results, $name;
1461             }
1462             }
1463             }
1464              
1465             sub compile_all {
1466 0     0 0   my ($self) = @_;
1467 0           print "Compiling all recipes and pages\n";
1468              
1469 0 0         make_path($self->{js_dir}) unless -d $self->{js_dir};
1470              
1471 0           my $count = 0;
1472 0           my @errors;
1473              
1474             # Phase 1: Compile all recipes to JS files
1475 0           print "--- Compiling recipes ---\n";
1476 0 0         opendir(my $mod_dh, $self->{recipes_dir}) or die "Cannot open $self->{recipes_dir}: $!";
1477 0           my @module_files = sort grep { /\.(yaml|pup)$/ } readdir($mod_dh);
  0            
1478 0           closedir($mod_dh);
1479              
1480 0           for my $mod_file (@module_files) {
1481 0           my $module_name = $mod_file;
1482 0           $module_name =~ s/\.(yaml|pup)$//;
1483              
1484 0           eval { $self->compile_recipe($module_name); };
  0            
1485 0 0         if ($@) {
1486 0           push @errors, "recipe $module_name: $@";
1487 0           warn " ERROR: $@";
1488             } else {
1489 0           $count++;
1490             }
1491             }
1492              
1493             # Phase 2: Compile all pages to HTML files
1494 0           print "--- Compiling pages ---\n";
1495 0           my @page_files;
1496 0           $self->find_yaml_files($self->{pages_dir}, '', \@page_files);
1497              
1498 0           for my $page_name (sort @page_files) {
1499 0           print " Compiling page: $page_name\n";
1500              
1501 0           eval {
1502 0           my $page_data = $self->load_page($page_name);
1503 0 0         if ($page_data) {
1504 0           my $html = $self->build_html($page_data, $page_name);
1505 0           my %paths = $self->get_cache_paths($page_name);
1506              
1507 0           my $dir = dirname($paths{html});
1508 0 0         make_path($dir) unless -d $dir;
1509              
1510 0 0         open my $html_fh, '>', $paths{html} or die "Cannot write $paths{html}: $!";
1511 0           print $html_fh $html;
1512 0           close $html_fh;
1513              
1514 0           my $mtimes = $self->collect_yaml_files($page_data, $page_name);
1515 0 0         open my $meta_fh, '>', $paths{meta} or die "Cannot write $paths{meta}: $!";
1516 0           print $meta_fh encode_json($mtimes);
1517 0           close $meta_fh;
1518              
1519 0           $count++;
1520             }
1521             };
1522 0 0         if ($@) {
1523 0           push @errors, "page $page_name: $@";
1524 0           warn " ERROR: $@";
1525             }
1526             }
1527              
1528 0           print "\nCompiled $count items\n";
1529 0 0         if (@errors) {
1530 0           print "Errors:\n";
1531 0           print " $_\n" for @errors;
1532             }
1533             }
1534              
1535             sub watch_and_compile {
1536 0     0 0   my ($self, $interval) = @_;
1537 0   0       $interval //= 5;
1538              
1539 0           print "Watching for changes every ${interval}s (Ctrl+C to stop)...\n";
1540              
1541 0           $self->compile_all();
1542 0           $self->update_file_mtimes();
1543              
1544 0           while (1) {
1545 0           sleep $interval;
1546              
1547 0 0         if ($self->update_file_mtimes()) {
1548 0           print "\n--- Changes detected at " . localtime() . " ---\n";
1549 0           $self->compile_all();
1550             }
1551             }
1552             }
1553              
1554             1;