| 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/</g; | ||||
| 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 | # |
||||||
| 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 " |
||||
| 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 |
||||||
| 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 |
||||||
| 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>$tag>"; | ||||
| 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$tag>"; | ||||
| 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 | |
||||||
| 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/</g; | |||||
| 1181 | 0 | $escaped_error =~ s/>/>/g; | |||||
| 1182 | 0 | $escaped_error =~ s/\n/ /g; |
|||||
| 1183 | |||||||
| 1184 | 0 | my $error_html = <<"ERRORHTML"; | |||||
| 1185 | |||||||
| 1186 | |||||||
| 1187 | |||||||
| 1188 | |
||||||
| 1189 | |||||||
| 1217 | |||||||
| 1218 | |||||||
| 1219 | |||||||
| 1220 | Spiderpup Compilation Error |
||||||
| 1221 | |||||||
| 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/</g; | |||||
| 1244 | 0 | $escaped_error =~ s/>/>/g; | |||||
| 1245 | 0 | $escaped_error =~ s/\n/ /g; |
|||||
| 1246 | |||||||
| 1247 | 0 | $body = <<"ERRORHTML"; | |||||
| 1248 | |||||||
| 1249 | |||||||
| 1250 | |||||||
| 1251 | |
||||||
| 1252 | |||||||
| 1280 | |||||||
| 1281 | |||||||
| 1282 | |||||||
| 1283 | Spiderpup Build Error |
||||||
| 1284 | |||||||
| 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; |