Nice little directory browser :D
1<#
2 This file is part of Utatane.
3
4 Utatane is free software: you can redistribute it and/or modify it under the
5 terms of the GNU Affero General Public License as published by the Free
6 Software Foundation, either version 3 of the License, or (at your option)
7 any later version.
8
9 Utatane is distributed in the hope that it will be useful, but WITHOUT ANY
10 WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
11 FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for
12 more details.
13
14 You should have received a copy of the GNU Affero General Public License
15 along with Utatane. If not, see <http://www.gnu.org/licenses/>.
16#>
17
18Import-Module Pode
19Import-Module Mizumiya
20
21# WARNING: modifying this requires restarting the entire shell
22Add-Type -Namespace Native -Name LibC -MemberDefinition @'
23[DllImport("libc.so.6")]
24public static extern int euidaccess(string pathname, int mode);
25
26[DllImport("libc.so.6", SetLastError=true)]
27public static extern IntPtr readlink(string pathname, byte[] buf, UIntPtr bufsize);
28'@
29
30function _can_r_x ([IO.FileSystemInfo] $Path) {
31 return ([Native.LibC]::euidaccess($Path.FullName, 5 <# R_OK -bor X_OK #>) -eq 0)
32}
33
34function _can_r__ ([IO.FileSystemInfo] $Path) {
35 return ([Native.LibC]::euidaccess($Path.FullName, 4 <# R_OK #>) -eq 0)
36}
37
38function _readlink {
39 param (
40 [String] $PathName
41 )
42
43 if ([String]::IsNullOrWhitespace($PathName)) {
44 throw [ArgumentException]::new("PathName cannot be null!")
45 }
46
47 $BufSize = 1024
48 $Buf = [Array]::CreateInstance([byte], $BufSize)
49
50 $BufMax = [Native.LibC]::readlink($PathName, $Buf, [UIntPtr]$BufSize).ToInt64()
51
52 if ($BufMax -lt 0) {
53 $errno = [System.Runtime.InteropServices.Marshal]::GetLastPInvokeError()
54 $strerror = [System.Runtime.InteropServices.Marshal]::GetLastPInvokeErrorMessage()
55 throw [Exception]::new("readlink failed with errno $errno`: $strerror")
56 }
57
58 return [System.Text.Encoding]::Default.GetString($Buf, 0, $BufMax)
59}
60
61function _log {
62 param(
63 [Parameter(ValueFromPipeline)]
64 $Message,
65 [ValidateSet('Request', 'Information', 'Warning', 'Fatal')]
66 $Type
67 )
68
69 switch ($Type) {
70 'Request' {
71 $Style = $PSStyle.Foreground.BrightBlack
72 }
73
74 'Information' {
75 $Style = ''
76 }
77
78 'Warning' {
79 $Style = $PSStyle.Formatting.Warning
80 }
81
82 'Fatal' {
83 $Style = $PSStyle.Formatting.Error
84 }
85 }
86
87 Write-Host "$Style[Utatane/$Type] $Message$($PSStyle.Reset)"
88 if ($Type -eq 'Fatal') { throw $Message }
89}
90
91function _request {
92 param(
93 [Parameter(ValueFromPipeline)]
94 $Message
95 )
96
97 _log -Type Request -Message $Message
98}
99
100function _info {
101 param(
102 [Parameter(ValueFromPipeline)]
103 $Message
104 )
105
106 _log -Type Information -Message $Message
107}
108
109function _warn {
110 param(
111 [Parameter(ValueFromPipeline)]
112 $Message
113 )
114
115 _log -Type Warning -Message $Message
116}
117
118function _fatal {
119 param (
120 [Parameter(ValueFromPipeline)]
121 $Message
122 )
123
124 _log -Type Fatal $Message
125}
126
127function ConvertFrom-Markdown2 {
128 param (
129 $Path,
130 $Location,
131 $Root
132 )
133 $Location = (gi $Location).FullName
134
135 $eee = ($Location -replace "^$Root", '' -split '/')[1..1024] # whatever
136 $LinkRoot = $Root
137
138 # try to find .git in
139 for ($i=$eee.count ; $i -gt 0 ; $i--) {
140 $TestPath = $eee[0..($i-1)] -join '/'
141
142 if (Test-Path -LiteralPath "$Root/$TestPath/.git") {
143 $LinkRoot = "/$TestPath"
144 break;
145 }
146 }
147
148 $Markdown = gc -lit $Path
149
150 # fix naked roots that only work on github by defining the link root
151 # surely this will not break anything
152 $Markdown = $Markdown -replace '\]\(\/', "]($Root/"
153
154 # look for a link with no colon (using this a proxy for having a protocol AKA not being a domain-relative path)
155 $Markdown = $Markdown -replace '(?<=\]\().*?(?=\))', {
156 $_ -notlike '*:*' ? "$LinkRoot/$_" : $_
157 }
158
159 # look closer: some READMEs decide to use full html in them, so we have to detect that as well...
160 $Markdown = $Markdown -replace '(?<=href=\x22|src=\x22).*?(?=\x22)', {
161 $_ -like '*/*' ? "$LinkRoot/$_" : $_
162 }
163
164 return (ConvertFrom-Markdown -InputObject ($Markdown -join "`n")).Html
165}
166
167# check if path is a file or a symlink pointing to a file test-path seems to
168# have an issue with strange file names, like "[̸D̶A̴T̷A̸ ̴E̸X̷P̷U̴N̸G̶E̶D̸]̷"
169# this is also an order of magnitude faster than test-path
170function _is_file ([IO.FileSystemInfo] $Path) {
171 return (
172 # [IO.File]::Exists($Path) -or [IO.File]::Exists($Path.linktarget)
173 # $null -ne $Path -and $Path.GetType() -eq [IO.FileInfo]
174 $null -ne $Path -and -not $Path.PSIsContainer
175 )
176}
177
178function _is_readable ([IO.FileSystemInfo] $Path) {
179 # directories can only be "read" if we also have x permission
180 return ((_is_file $Path) ? (_can_r__ $Path) : (_can_r_x $Path))
181}
182
183# encode string as uri
184function _encode {
185 param (
186 [String] $part
187 )
188
189 return [URI]::EscapeDataString($part)
190}
191
192# seperately encode all parts of the url path to avoid encoding the slash or
193# something silly
194function _encode_path {
195 param (
196 [String] $Path,
197 [Switch] $NoEnd
198 )
199
200 if ($NoEnd) {
201 $FixedPath = (_encode_path (( $Path -split '/' | Select-Object -SkipLast 1) -Join '/'))
202 if ($FixedPath -eq [String]::Empty) {
203 return '/'
204 } else {
205 return $FixedPath
206 }
207 } else {
208 return ($Path -split '/' | % { _encode $_ }) -join '/'
209 }
210}
211
212function _get_splash {
213 (Get-PodeConfig).Utatane.Splashes | Get-Random | ConvertFrom-Markdown | % Html
214}
215
216function _format_size ([uint64] $size) {
217 switch ($Size) {
218 {$size -ge 1tb} { return '{0:n2} TiB' -f ($size/1tb) }
219 {$size -ge 1gb} { return '{0:n2} GiB' -f ($size/1gb) }
220 {$size -ge 1mb} { return '{0:n2} MiB' -f ($size/1mb) }
221 {$size -ge 1kb} { return '{0:n2} KiB' -f ($size/1kb) }
222 {$size -eq '-'} { return '0 B' }
223 default { return "$size B" }
224 }
225}
226
227filter _check_path_on_blacklist ($Path) {
228 ((Get-PodeConfig).Utatane.DoNotServe | ? { $Path -like $_ }).Count -gt 0
229}
230
231function _check_path ($Root, $FullPath) {
232 $Resolved = Get-Item -LiteralPath $FullPath -EA Ignore
233
234 return (
235 $null -ne $Resolved `
236 -and $Resolved.FullName.StartsWith($Root) `
237 -and !(_check_path_on_blacklist $Resolved.Name) `
238 )
239}
240
241function _script_hx_hs_jq {
242 script -Src /.nhnd/_hyperscript.js
243 script -Src /.nhnd/_hs_tailwind.js
244 script -Src /.nhnd/htmx.js
245 script -Src /.nhnd/jquery.js
246 meta -Name htmx-config -Content (@{ scrollIntoViewOnBoost=$false; defaultHideShowStrategy='twDisplay' } | ConvertTo-Json -Compress)
247}
248
249function _footer {
250 footer -Class 'pb-10 pt-5' {
251 span -Class 'justify-center w-full flex text-current/75 gap-[1ch]' {
252 '🌟🌟🌟'
253 a -Href '/about' { 'running helpimnotdrowning/Utatane' }
254 '🌟🌟🌟'
255 }
256 }
257}
258
259function _icon ($Label, $Icon) {
260 return abbr -Title $Label {
261 $Icon
262 }
263}
264
265$MapExtensionToAbbr = @{
266 '.avc' = ( _icon 'Video file' '🎞️' )
267 '.flv' = ( _icon 'Video file' '🎞️' )
268 '.mts' = ( _icon 'Video file' '🎞️' )
269 '.m2ts' = ( _icon 'Video file' '🎞️' )
270 '.m4v' = ( _icon 'Video file' '🎞️' )
271 '.mkv' = ( _icon 'Video file' '🎞️' )
272 '.mov' = ( _icon 'Video file' '🎞️' )
273 '.mp4' = ( _icon 'Video file' '🎞️' )
274 '.ts' = ( _icon 'Video file' '🎞️' )
275 '.webm' = ( _icon 'Video file' '🎞️' )
276 '.wmv' = ( _icon 'Video file' '🎞️' )
277
278 '.aac' = ( _icon 'Audio file' '🔊' )
279 '.alac' = ( _icon 'Audio file' '🔊' )
280 '.flac' = ( _icon 'Audio file' '🔊' )
281 '.m4a' = ( _icon 'Audio file' '🔊' )
282 '.mp3' = ( _icon 'Audio file' '🔊' )
283 '.opus' = ( _icon 'Audio file' '🔊' )
284 '.wav' = ( _icon 'Audio file' '🔊' )
285 '.ogg' = ( _icon 'Audio file' '🔊' )
286 '.mus' = ( _icon 'Audio file' '🔊' )
287
288 '.avif' = ( _icon 'Image file' '🖼️' )
289 '.bmp' = ( _icon 'Image file' '🖼️' )
290 '.gif' = ( _icon 'Image file' '🖼️' )
291 '.ico' = ( _icon 'Image file' '🖼️' )
292 '.heic' = ( _icon 'Image file' '🖼️' )
293 '.heif' = ( _icon 'Image file' '🖼️' )
294 '.jpe?g' = ( _icon 'Image file' '🖼️' )
295 '.jfif' = ( _icon 'Image file' '🖼️' )
296 '.jxl' = ( _icon 'Image file' '🖼️' )
297 '.j2c' = ( _icon 'Image file' '🖼️' )
298 '.jp2' = ( _icon 'Image file' '🖼️' )
299 '.a?png' = ( _icon 'Image file' '🖼️' )
300 '.svg' = ( _icon 'Image file' '🖼️' )
301 '.tiff?' = ( _icon 'Image file' '🖼️' )
302 '.webp' = ( _icon 'Image file' '🖼️' )
303 '.pdn' = ( _icon 'Image file' '🖼️' )
304 '.psd' = ( _icon 'Image file' '🖼️' )
305 '.xcf' = ( _icon 'Image file' '🖼️' )
306
307 '.ass' = ( _icon 'Subtitle file' '💬' )
308 '.lrc' = ( _icon 'Subtitle file' '💬' )
309 '.srt' = ( _icon 'Subtitle file' '💬' )
310 '.srv3' = ( _icon 'Subtitle file' '💬' )
311 '.ssa' = ( _icon 'Subtitle file' '💬' )
312 '.vtt' = ( _icon 'Subtitle file' '💬' )
313
314 '.bat' = ( _icon 'Windows script file' '📜' )
315 '.cmd' = ( _icon 'Windows script file' '📜' )
316 '.htm' = ( _icon 'HTML file' '📜' )
317 '.html' = ( _icon 'HTML file' '📜' )
318 '.xhtml' = ( _icon 'XHTML file' '📜' )
319 '.bash' = ( _icon 'Shell script' '📜' )
320 '.zsh' = ( _icon 'Shell script' '📜' )
321 '.sh' = ( _icon 'Shell script' '📜' )
322 '.cpp' = ( _icon 'C++ source file' '📜' )
323 '.cxx' = ( _icon 'C++ source file' '📜' )
324 '.cc' = ( _icon 'C++ source file' '📜' )
325 '.hpp' = ( _icon 'C++ header file' '📜' )
326 '.hxx' = ( _icon 'C++ header file' '📜' )
327 '.hh' = ( _icon 'C++ header file' '📜' )
328
329 '.py' = ( _icon 'Python script' '📜' )
330 '.pyc' = ( _icon 'Compiled Python bytecode' '📜' )
331 '.pyo' = ( _icon 'Compiled Python bytecode' '📜' )
332 '.psm1' = ( _icon 'PowerShell module file' '📜' )
333 '.psd1' = ( _icon 'PowerShell data file' '📜' )
334 '.ps1' = ( _icon 'PowerShell script' '📜' )
335 '.js' = ( _icon 'JavaScript source code' '📜' )
336 '.css' = ( _icon 'CSS style sheet' '📜' )
337 '.cs' = ( _icon 'C# source file' '📜' )
338 '.c' = ( _icon 'C source file' '📜' )
339 '.h' = ( _icon 'C header file' '📜' )
340 '.java' = ( _icon 'Java source file' '📜' )
341
342 '.json' = ( _icon 'Data/config file' '📜' )
343 '.json5' = ( _icon 'Data/config file' '📜' )
344 '.xml' = ( _icon 'Data/config file' '📜' )
345 '.yaml' = ( _icon 'Data/config file' '📜' )
346 '.yml' = ( _icon 'Data/config file' '📜' )
347 '.ini' = ( _icon 'Data/config file' '📜' )
348 '.toml' = ( _icon 'Data/config file' '📜' )
349 '.cfg' = ( _icon 'Data/config file' '📜' )
350 '.conf' = ( _icon 'Data/config file' '📜' )
351 '.plist' = ( _icon 'Data/config file' '📜' )
352 '.csv' = ( _icon 'Data/config file' '📜' )
353
354 '.tar' = ( _icon 'File archive' '📦' )
355 '.ar' = ( _icon 'File archive' '📦' )
356 '.7z' = ( _icon 'File archive' '📦' )
357 '.arc' = ( _icon 'File archive' '📦' )
358 '.cab' = ( _icon 'File archive' '📦' )
359 '.rar' = ( _icon 'File archive' '📦' )
360 '.zip' = ( _icon 'File archive' '📦' )
361 '.bz2' = ( _icon 'File archive' '📦' )
362 '.gz' = ( _icon 'File archive' '📦' )
363 '.lz' = ( _icon 'File archive' '📦' )
364 '.lzma' = ( _icon 'File archive' '📦' )
365 '.lzo' = ( _icon 'File archive' '📦' )
366 '.xz' = ( _icon 'File archive' '📦' )
367 '.Z' = ( _icon 'File archive' '📦' )
368 '.zst' = ( _icon 'File archive' '📦' )
369
370 '.apk' = ( _icon 'Android package' '📦' )
371 '.deb' = ( _icon 'Debian package' '📦' )
372 '.rpm' = ( _icon 'RPM package' '📦' )
373 '.ipa' = ( _icon 'iOS/iPadOS package' '📦' )
374 '.AppImage' = ( _icon 'AppImage bundle' '📦' )
375 '.jar' = ( _icon 'Java archive' '☕' )
376
377 '.dmg' = ( _icon 'Disk image' '💿' )
378 '.iso' = ( _icon 'Disk image' '💿' )
379 '.img' = ( _icon 'Disk image' '💿' )
380 '.wim' = ( _icon 'Disk image' '💿' )
381 '.esd' = ( _icon 'Disk image' '💿' )
382
383
384 '.docx' = ( _icon 'Document' '📃' )
385 '.doc' = ( _icon 'Document' '📃' )
386 '.odt' = ( _icon 'Document' '📃' )
387 '.pptx' = ( _icon 'Presentation' '📃' )
388 '.ppt' = ( _icon 'Presentation' '📃' )
389 '.odp' = ( _icon 'Presentation' '📃' )
390 '.xslx' = ( _icon 'Spreadsheet' '📃' )
391 '.xsl' = ( _icon 'Spreadsheet' '📃' )
392 '.ods' = ( _icon 'Spreadsheet' '📃' )
393 '.pdf' = ( _icon 'PDF' '📃' )
394 '.md' = ( _icon 'Markdown document' '📃' )
395 '.rst' = ( _icon 'reStructuredText document' '📃' )
396 '.epub' = ( _icon 'EPUB e-book file' '📃' )
397 '.log' = ( _icon 'Log file' '📃' )
398 '.txt' = ( _icon 'Text file' '📃' )
399
400 '.tff' = ( _icon 'Font file' '🗛' )
401 '.otf' = ( _icon 'Font file' '🗛' )
402 '.woff' = ( _icon 'Font file' '🗛' )
403 '.woff2' = ( _icon 'Font file' '🗛' )
404
405 '.mpls' = ( _icon 'Playlist file' '🎶' )
406 '.m3u' = ( _icon 'Playlist file' '🎶' )
407 '.m3u8' = ( _icon 'Playlist file' '🎶' )
408
409 '.exe' = ( _icon 'Generic executable' '🔳' )
410 '.elf' = ( _icon 'Generic executable' '🔳' )
411 '.msi' = ( _icon 'Generic executable' '🔳' )
412 '.msix' = ( _icon 'Generic executable' '🔳' )
413 '.msixbundle' = ( _icon 'Generic executable' '🔳' )
414 '.appx' = ( _icon 'Generic executable' '🔳' )
415 '.appxbundle' = ( _icon 'Generic executable' '🔳' )
416
417 '.dll' = ( _icon 'Dynamic library' '⚙️' )
418 '.so' = ( _icon 'Dynamic library' '⚙️' )
419 '.dylib' = ( _icon 'Dynamic library' '⚙️' )
420}
421function _get_symbol_for_file ([IO.FileSystemInfo] $FSO) {
422 if (-not (_is_file $FSO)) {
423 _icon 'Directory' '📁'
424 } else {
425 $Icon = $MapExtensionToAbbr[$FSO.Extension]
426
427 if ([String]::IsNullOrEmpty($Icon)) {
428 if (-not $IsWindows -and (_can_r_x $FSO)) {
429 return _icon 'Generic executable (+x)' '🔳'
430 } else {
431 return _icon 'File' '📄'
432 }
433 }
434 return $Icon
435 }
436}
437
438function _create_table_row ([IO.FileSystemInfo] $Item, [String] $CurrentPath) {
439 $IsFile = _is_file $Item
440
441 try {
442 # if Item is a symlink, canonicalize it
443 if ($null -ne $Item.LinkTarget) {
444 # ResolvedTarget is broken, see https://github.com/PowerShell/PowerShell/issues/25724
445 $LinkDestination = Get-Item -LiteralPath (readlink $Item.FullName -m) -ErrorAction Ignore
446
447 if ($null -eq $LinkDestination) {
448 throw [System.NullReferenceException]::new("Broken symbolic link at $($Item.FullName)")
449 }
450
451 # size of symlinks shows the size of the link, not the file iself!
452 $Size = $LinkDestination.Size
453 } else {
454 $Size = $Item.Size
455 }
456
457 if (-not (_is_readable $Item)) {
458 return (
459 tr {
460 td
461
462 td { _icon "Server does not have permission to read this $($IsFile ? 'file' : 'directory')" '⚠️' }
463
464 td -Class "file-name" -Attributes @{ "data-order" = $Item.Name } {
465 s -Class 'text-stone-500' { $IsFile ? $Item.Name : $Item.Name + '/' }
466 }
467
468 td -Class 'file-size' -Attributes @{ 'data-order' = ($IsFile ? $Size : 0) } {
469 if ($IsFile) { s { _format_size ([uint64] $Size) } }
470 }
471
472 td -Class 'file-date' -Attributes @{ 'data-order' = ( Get-Date $Item.LastWriteTime -UFormat "%s" -asutc ) } {
473 s {
474 time {
475 Get-Date $Item.LastWriteTime -Format "yyyy-MM-dd HH:mm" -AsUTC
476 }
477 }
478 }
479 }
480 )
481 }
482
483 # Mizumiya can be slow when tens of thousands of elements are needed
484 # (case: ~39,100 to generate ~3900 rows of file information), so
485 # templating is used here. this achieves a 2x speedup (12s -> 5s) in
486 # exchange for readability.
487
488 $FilePath = _encode_path (Join-Path ($CurrentPath ? $CurrentPath : '/') $Item.Name)
489 return (
490 @"
491<tr class="fsobject $( $IsFile ? "file" : "directory" )">
492 <td>$( if ($IsFile) { @"
493 <a
494 download
495 class="file-dl clickable"
496 href="$FilePath"
497 aria-label="Download file">⭳</a>
498"@ } )</td>
499 <td>$( _get_symbol_for_file $Item )</td>
500 <td class=file-name data-order="$( AttributeEncode $Item.Name )">
501 $( if ($IsFile) { @"
502 <a
503 href="$FilePath"
504 hx-boost=false
505 >$( HTMLEncode { $Item.Name } )</a>
506"@ } else { @"
507 <a
508 href="$FilePath/"
509 hx-boost=true
510 $(<# string are added bc using a ; seperator will join with a space #>)
511 $(<# this will not #>)
512 >$( (HTMLEncode $Item.Name) + (span -Class 'dir-slash' -InnerHTML '/') )</a>
513"@ } )
514 </td>
515 <td
516 class=file-size
517 data-order=$($IsFile ? $Size : -1)
518 >$( $IsFile ? (_format_size ([uint64] $Size)) : '' )</td>
519 <td
520 class=file-date
521 data-order=$( Get-Date $Item.LastWriteTime -UFormat "%s" -AsUTC )
522 >
523 <time>
524 $( Get-Date $Item.LastWriteTime -Format "yyyy-MM-dd HH:mm" -AsUTC )
525 </time>
526 </td>
527</tr>
528"@
529 )
530 } catch {
531 _warn "api/files.html: Row generation for $($Item.FullName) failed!!"
532 _warn $_.Exception
533
534 return (
535 tr {
536 td
537
538 td { _icon 'Error occured while generating this row' '⚠️' }
539
540 td -Class "file-name" -Attributes @{ "data-order" = $Item.Name } {
541 s { $Item.Name }
542 }
543
544 td
545
546 td
547 }
548 )
549 }
550}
551
552function Get-PathParent {
553 [CmdletBinding()]
554 param (
555 [String] $Path
556 )
557
558 $Parent = $Path | % split '/' | Select-Object -SkipLast 1 | Join-String -Separator '/'
559
560 return ($Parent ? '/' : $Parent)
561}
562
563function ConvertFrom-AcceptHeader {
564 param (
565 [Parameter(ValueFromPipeline)]
566 [String] $AcceptString
567 )
568
569 if ([String]::IsNullOrWhiteSpace($AcceptString)) {
570 return [PSCustomObject]@{
571 Quality = 1d
572 MediaType = '*/*'
573 }
574 }
575
576 return $AcceptString -split ',' | % {
577 try {
578 $Accept = [System.Net.Http.Headers.MediaTypeWithQualityHeaderValue]::Parse($_)
579 return [PSCustomObject]@{
580 Quality = ($null -eq $Accept.Quality) ? 1d : $Accept.Quality
581 MediaType = $Accept.MediaType
582 }
583 } catch {
584 return;
585 }
586 } | Sort-Object -Descending -Property Quality
587}
588
589function _mime_to_ext {
590 param(
591 [String] $Mime
592 )
593
594 switch ($Mime) {
595 'text/html' { 'html' }
596 'application/json' { 'json' }
597 'application/xml' { 'xml' }
598 'text/xml' { 'xml' }
599 'application/xhtml+xml' { 'xhtml' }
600 'application/atom+xml' { 'atom' }
601 default { 'html' }
602 }
603}
604
605function _render_view {
606 param(
607 [String] $Page,
608 $Data
609 )
610
611 :loop foreach ($Accept in $WebEvent.RequestAccept) {
612 # SEE `Set-PodeViewEngine -Type Mizumiya` for the ACTUAL work being done
613 switch ( _mime_to_ext $Accept.MediaType ) {
614 'json' {
615 Write-PodeViewResponse -Path "$Page.json.ps1" -Data $Data
616 break loop
617 }
618
619 'html' {
620 Write-PodeViewResponse -Path "$Page.html.ps1" -Data $Data
621 break loop
622 }
623
624 'xml' {
625 Write-PodeViewResponse -Path "$Page.xml.ps1" -Data $Data
626 break loop
627 }
628
629 'atom' {
630 Write-PodeViewResponse -Path "$Page.atom.ps1" -Data $Data
631 break loop
632 }
633 }
634 }
635}