diff --git a/.prettierignore b/.prettierignore index 788b8769f..0d26a3ab5 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,4 +1,5 @@ **/*.min.js +edge-apps/cap-alerting/ edge-apps/edge-apps-library/ edge-apps/powerbi-legacy/ edge-apps/powerbi-legacy/** diff --git a/edge-apps/cap-alerting/.gitignore b/edge-apps/cap-alerting/.gitignore new file mode 100644 index 000000000..50ae8ce57 --- /dev/null +++ b/edge-apps/cap-alerting/.gitignore @@ -0,0 +1,10 @@ +node_modules/ +dist/ +*.log +.DS_Store +mock-data.yml +instance.yml +bun.lockb +static/js/*.js +static/js/*.js.map +static/style.css diff --git a/edge-apps/cap-alerting/.ignore b/edge-apps/cap-alerting/.ignore new file mode 100644 index 000000000..c2658d7d1 --- /dev/null +++ b/edge-apps/cap-alerting/.ignore @@ -0,0 +1 @@ +node_modules/ diff --git a/edge-apps/cap-alerting/.prettierrc b/edge-apps/cap-alerting/.prettierrc new file mode 100644 index 000000000..2924079e5 --- /dev/null +++ b/edge-apps/cap-alerting/.prettierrc @@ -0,0 +1,6 @@ +{ + "semi": false, + "singleQuote": true, + "printWidth": 100, + "trailingComma": "es5" +} diff --git a/edge-apps/cap-alerting/README.md b/edge-apps/cap-alerting/README.md new file mode 100644 index 000000000..aed9ae5a3 --- /dev/null +++ b/edge-apps/cap-alerting/README.md @@ -0,0 +1,35 @@ +# CAP Alerting Edge App + +Display Common Alerting Protocol (CAP) emergency alerts on Screenly digital signage screens. Designed to work with [Override Playlist](https://developer.screenly.io/api-reference/v4/#tag/Playlists/operation/override_playlist) to automatically interrupt regular content when alerts are active. + +## Settings + +- **CAP Feed URL**: URL to your CAP XML feed +- **Refresh Interval**: Minutes between feed updates (default: 5) +- **Default Language**: Preferred language code (default: en) +- **Maximum Alerts**: Max alerts to display (default: 3) +- **Play Audio Alerts**: Enable audio playback from CAP `` elements with audio MIME types (default: false) +- **Offline Mode**: Use cached data when network unavailable +- **Test Mode**: Load bundled test CAP file +- **Demo Mode**: Show random demo alerts + +## Audio Support + +The app automatically detects and plays audio resources from CAP alerts when the "Play Audio Alerts" setting is enabled. Audio resources are identified by MIME types starting with `audio/` (e.g., `audio/mpeg`, `audio/wav`, `audio/ogg`). The audio player will attempt to autoplay when an alert is displayed, though browser autoplay policies may require user interaction in some cases. + +## Nearest Exit Tags + +Add tags to your Screenly screens (e.g., `exit:North Lobby`) to provide location-aware exit directions. The app substitutes `{{closest_exit}}` or `[[closest_exit]]` placeholders in alert instructions. + +## Override Playlist Integration + +This app is designed to use Screenly's [Override Playlist API](https://developer.screenly.io/api-reference/v4/#tag/Playlists/operation/override_playlist) to automatically interrupt regular content when alerts are active. Configure your backend to call the API when new CAP alerts are detected. + +## Development + +```bash +cd edge-apps/cap-alerting +bun install +bun run dev +bun test +``` diff --git a/edge-apps/cap-alerting/bun.lock b/edge-apps/cap-alerting/bun.lock new file mode 100644 index 000000000..0789acb0d --- /dev/null +++ b/edge-apps/cap-alerting/bun.lock @@ -0,0 +1,709 @@ +{ + "lockfileVersion": 1, + "configVersion": 0, + "workspaces": { + "": { + "name": "cap-alerting", + "dependencies": { + "@photostructure/tz-lookup": "^11.3.0", + "country-locale-map": "^1.9.11", + "fast-xml-parser": "^5.3.2", + "offline-geocode-city": "^1.0.2", + }, + "devDependencies": { + "@eslint/js": "^9.39.1", + "@screenly/edge-apps": "workspace:../edge-apps-library", + "@tailwindcss/cli": "^4.0.0", + "@types/bun": "^1.3.3", + "@types/node": "^24.10.1", + "autoprefixer": "^10.4.22", + "bun-types": "^1.3.3", + "eslint": "^9.39.1", + "jiti": "^2.6.1", + "npm-run-all2": "^8.0.4", + "prettier": "^3.7.4", + "tailwindcss": "^4.1.17", + "typescript": "^5.9.3", + "typescript-eslint": "^8.48.1", + }, + }, + "../edge-apps-library": { + "name": "@screenly/edge-apps", + "dependencies": { + "@photostructure/tz-lookup": "^11.3.0", + "country-locale-map": "^1.9.11", + "offline-geocode-city": "^1.0.2", + }, + "devDependencies": { + "@types/bun": "^1.3.3", + "@types/jsdom": "^27.0.0", + "@types/node": "^24.10.1", + "bun-types": "^1.3.3", + "jsdom": "^27.2.0", + "prettier": "^3.7.4", + "typescript": "^5.9.3", + }, + }, + }, + "packages": { + "@acemir/cssom": ["@acemir/cssom@0.9.26", "", {}, "sha512-UMFbL3EnWH/eTvl21dz9s7Td4wYDMtxz/56zD8sL9IZGYyi48RxmdgPMiyT7R6Vn3rjMTwYZ42bqKa7ex74GEQ=="], + + "@asamuzakjp/css-color": ["@asamuzakjp/css-color@4.1.0", "", { "dependencies": { "@csstools/css-calc": "^2.1.4", "@csstools/css-color-parser": "^3.1.0", "@csstools/css-parser-algorithms": "^3.0.5", "@csstools/css-tokenizer": "^3.0.4", "lru-cache": "^11.2.2" } }, "sha512-9xiBAtLn4aNsa4mDnpovJvBn72tNEIACyvlqaNJ+ADemR+yeMJWnBudOi2qGDviJa7SwcDOU/TRh5dnET7qk0w=="], + + "@asamuzakjp/dom-selector": ["@asamuzakjp/dom-selector@6.7.6", "", { "dependencies": { "@asamuzakjp/nwsapi": "^2.3.9", "bidi-js": "^1.0.3", "css-tree": "^3.1.0", "is-potential-custom-element-name": "^1.0.1", "lru-cache": "^11.2.4" } }, "sha512-hBaJER6A9MpdG3WgdlOolHmbOYvSk46y7IQN/1+iqiCuUu6iWdQrs9DGKF8ocqsEqWujWf/V7b7vaDgiUmIvUg=="], + + "@asamuzakjp/nwsapi": ["@asamuzakjp/nwsapi@2.3.9", "", {}, "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q=="], + + "@csstools/color-helpers": ["@csstools/color-helpers@5.1.0", "", {}, "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA=="], + + "@csstools/css-calc": ["@csstools/css-calc@2.1.4", "", { "peerDependencies": { "@csstools/css-parser-algorithms": "^3.0.5", "@csstools/css-tokenizer": "^3.0.4" } }, "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ=="], + + "@csstools/css-color-parser": ["@csstools/css-color-parser@3.1.0", "", { "dependencies": { "@csstools/color-helpers": "^5.1.0", "@csstools/css-calc": "^2.1.4" }, "peerDependencies": { "@csstools/css-parser-algorithms": "^3.0.5", "@csstools/css-tokenizer": "^3.0.4" } }, "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA=="], + + "@csstools/css-parser-algorithms": ["@csstools/css-parser-algorithms@3.0.5", "", { "peerDependencies": { "@csstools/css-tokenizer": "^3.0.4" } }, "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ=="], + + "@csstools/css-syntax-patches-for-csstree": ["@csstools/css-syntax-patches-for-csstree@1.0.20", "", {}, "sha512-8BHsjXfSciZxjmHQOuVdW2b8WLUPts9a+mfL13/PzEviufUEW2xnvQuOlKs9dRBHgRqJ53SF/DUoK9+MZk72oQ=="], + + "@csstools/css-tokenizer": ["@csstools/css-tokenizer@3.0.4", "", {}, "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw=="], + + "@esbuild/android-arm": ["@esbuild/android-arm@0.17.19", "", { "os": "android", "cpu": "arm" }, "sha512-rIKddzqhmav7MSmoFCmDIb6e2W57geRsM94gV2l38fzhXMwq7hZoClug9USI2pFRGL06f4IOPHHpFNOkWieR8A=="], + + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.17.19", "", { "os": "android", "cpu": "arm64" }, "sha512-KBMWvEZooR7+kzY0BtbTQn0OAYY7CsiydT63pVEaPtVYF0hXbUaOyZog37DKxK7NF3XacBJOpYT4adIJh+avxA=="], + + "@esbuild/android-x64": ["@esbuild/android-x64@0.17.19", "", { "os": "android", "cpu": "x64" }, "sha512-uUTTc4xGNDT7YSArp/zbtmbhO0uEEK9/ETW29Wk1thYUJBz3IVnvgEiEwEa9IeLyvnpKrWK64Utw2bgUmDveww=="], + + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.17.19", "", { "os": "darwin", "cpu": "arm64" }, "sha512-80wEoCfF/hFKM6WE1FyBHc9SfUblloAWx6FJkFWTWiCoht9Mc0ARGEM47e67W9rI09YoUxJL68WHfDRYEAvOhg=="], + + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.17.19", "", { "os": "darwin", "cpu": "x64" }, "sha512-IJM4JJsLhRYr9xdtLytPLSH9k/oxR3boaUIYiHkAawtwNOXKE8KoU8tMvryogdcT8AU+Bflmh81Xn6Q0vTZbQw=="], + + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.17.19", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-pBwbc7DufluUeGdjSU5Si+P3SoMF5DQ/F/UmTSb8HXO80ZEAJmrykPyzo1IfNbAoaqw48YRpv8shwd1NoI0jcQ=="], + + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.17.19", "", { "os": "freebsd", "cpu": "x64" }, "sha512-4lu+n8Wk0XlajEhbEffdy2xy53dpR06SlzvhGByyg36qJw6Kpfk7cp45DR/62aPH9mtJRmIyrXAS5UWBrJT6TQ=="], + + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.17.19", "", { "os": "linux", "cpu": "arm" }, "sha512-cdmT3KxjlOQ/gZ2cjfrQOtmhG4HJs6hhvm3mWSRDPtZ/lP5oe8FWceS10JaSJC13GBd4eH/haHnqf7hhGNLerA=="], + + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.17.19", "", { "os": "linux", "cpu": "arm64" }, "sha512-ct1Tg3WGwd3P+oZYqic+YZF4snNl2bsnMKRkb3ozHmnM0dGWuxcPTTntAF6bOP0Sp4x0PjSF+4uHQ1xvxfRKqg=="], + + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.17.19", "", { "os": "linux", "cpu": "ia32" }, "sha512-w4IRhSy1VbsNxHRQpeGCHEmibqdTUx61Vc38APcsRbuVgK0OPEnQ0YD39Brymn96mOx48Y2laBQGqgZ0j9w6SQ=="], + + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.17.19", "", { "os": "linux", "cpu": "none" }, "sha512-2iAngUbBPMq439a+z//gE+9WBldoMp1s5GWsUSgqHLzLJ9WoZLZhpwWuym0u0u/4XmZ3gpHmzV84PonE+9IIdQ=="], + + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.17.19", "", { "os": "linux", "cpu": "none" }, "sha512-LKJltc4LVdMKHsrFe4MGNPp0hqDFA1Wpt3jE1gEyM3nKUvOiO//9PheZZHfYRfYl6AwdTH4aTcXSqBerX0ml4A=="], + + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.17.19", "", { "os": "linux", "cpu": "ppc64" }, "sha512-/c/DGybs95WXNS8y3Ti/ytqETiW7EU44MEKuCAcpPto3YjQbyK3IQVKfF6nbghD7EcLUGl0NbiL5Rt5DMhn5tg=="], + + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.17.19", "", { "os": "linux", "cpu": "none" }, "sha512-FC3nUAWhvFoutlhAkgHf8f5HwFWUL6bYdvLc/TTuxKlvLi3+pPzdZiFKSWz/PF30TB1K19SuCxDTI5KcqASJqA=="], + + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.17.19", "", { "os": "linux", "cpu": "s390x" }, "sha512-IbFsFbxMWLuKEbH+7sTkKzL6NJmG2vRyy6K7JJo55w+8xDk7RElYn6xvXtDW8HCfoKBFK69f3pgBJSUSQPr+4Q=="], + + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.17.19", "", { "os": "linux", "cpu": "x64" }, "sha512-68ngA9lg2H6zkZcyp22tsVt38mlhWde8l3eJLWkyLrp4HwMUr3c1s/M2t7+kHIhvMjglIBrFpncX1SzMckomGw=="], + + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.17.19", "", { "os": "none", "cpu": "x64" }, "sha512-CwFq42rXCR8TYIjIfpXCbRX0rp1jo6cPIUPSaWwzbVI4aOfX96OXY8M6KNmtPcg7QjYeDmN+DD0Wp3LaBOLf4Q=="], + + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.17.19", "", { "os": "openbsd", "cpu": "x64" }, "sha512-cnq5brJYrSZ2CF6c35eCmviIN3k3RczmHz8eYaVlNasVqsNY+JKohZU5MKmaOI+KkllCdzOKKdPs762VCPC20g=="], + + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.17.19", "", { "os": "sunos", "cpu": "x64" }, "sha512-vCRT7yP3zX+bKWFeP/zdS6SqdWB8OIpaRq/mbXQxTGHnIxspRtigpkUcDMlSCOejlHowLqII7K2JKevwyRP2rg=="], + + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.17.19", "", { "os": "win32", "cpu": "arm64" }, "sha512-yYx+8jwowUstVdorcMdNlzklLYhPxjniHWFKgRqH7IFlUEa0Umu3KuYplf1HUZZ422e3NU9F4LGb+4O0Kdcaag=="], + + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.17.19", "", { "os": "win32", "cpu": "ia32" }, "sha512-eggDKanJszUtCdlVs0RB+h35wNlb5v4TWEkq4vZcmVt5u/HiDZrTXe2bWFQUez3RgNHwx/x4sk5++4NSSicKkw=="], + + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.17.19", "", { "os": "win32", "cpu": "x64" }, "sha512-lAhycmKnVOuRYNtRtatQR1LPQf2oYCkRGkSFnseDAKPl8lu5SOsK/e1sXe5a0Pc5kHIHe6P2I/ilntNv2xf3cA=="], + + "@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.9.0", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g=="], + + "@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.2", "", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="], + + "@eslint/config-array": ["@eslint/config-array@0.21.1", "", { "dependencies": { "@eslint/object-schema": "^2.1.7", "debug": "^4.3.1", "minimatch": "^3.1.2" } }, "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA=="], + + "@eslint/config-helpers": ["@eslint/config-helpers@0.4.2", "", { "dependencies": { "@eslint/core": "^0.17.0" } }, "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw=="], + + "@eslint/core": ["@eslint/core@0.17.0", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ=="], + + "@eslint/eslintrc": ["@eslint/eslintrc@3.3.3", "", { "dependencies": { "ajv": "^6.12.4", "debug": "^4.3.2", "espree": "^10.0.1", "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", "js-yaml": "^4.1.1", "minimatch": "^3.1.2", "strip-json-comments": "^3.1.1" } }, "sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ=="], + + "@eslint/js": ["@eslint/js@9.39.1", "", {}, "sha512-S26Stp4zCy88tH94QbBv3XCuzRQiZ9yXofEILmglYTh/Ug/a9/umqvgFtYBAo3Lp0nsI/5/qH1CCrbdK3AP1Tw=="], + + "@eslint/object-schema": ["@eslint/object-schema@2.1.7", "", {}, "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA=="], + + "@eslint/plugin-kit": ["@eslint/plugin-kit@0.4.1", "", { "dependencies": { "@eslint/core": "^0.17.0", "levn": "^0.4.1" } }, "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA=="], + + "@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="], + + "@humanfs/node": ["@humanfs/node@0.16.7", "", { "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ=="], + + "@humanwhocodes/module-importer": ["@humanwhocodes/module-importer@1.0.1", "", {}, "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA=="], + + "@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="], + + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], + + "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], + + "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], + + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], + + "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + + "@jsheaven/easybuild": ["@jsheaven/easybuild@1.2.9", "", { "dependencies": { "@jsheaven/status-message": "^1.1.2", "brotli-size": "^4.0.0", "dts-bundle-generator": "^7.2.0", "esbuild": "^0.17.6", "fast-glob": "^3.2.12", "gzip-size": "^7.0.0", "pretty-bytes": "^6.1.0", "typescript": "^4.9.5" }, "bin": { "easybuild": "dist/cli.esm.js", "easybuild-cjs": "dist/cli.iife.js" } }, "sha512-IJsaER05bGZKEuvBJ+JOQ7YW+RYHryYoO9z67TxpP7fAji8Oq+wJF8eFPEZabIcUbXqe20/Pfhx6P4g7SNP8kQ=="], + + "@jsheaven/status-message": ["@jsheaven/status-message@1.1.2", "", { "dependencies": { "kleur": "^4.1.5" } }, "sha512-a9ye8kre8pBBtL7zKxDVoaVO+PJjnPLcim71IX0fVpV8OkBk6rvL97c2E8outOZgs3sKBqFfY44kx5wal3DRpA=="], + + "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="], + + "@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="], + + "@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="], + + "@parcel/watcher": ["@parcel/watcher@2.5.1", "", { "dependencies": { "detect-libc": "^1.0.3", "is-glob": "^4.0.3", "micromatch": "^4.0.5", "node-addon-api": "^7.0.0" }, "optionalDependencies": { "@parcel/watcher-android-arm64": "2.5.1", "@parcel/watcher-darwin-arm64": "2.5.1", "@parcel/watcher-darwin-x64": "2.5.1", "@parcel/watcher-freebsd-x64": "2.5.1", "@parcel/watcher-linux-arm-glibc": "2.5.1", "@parcel/watcher-linux-arm-musl": "2.5.1", "@parcel/watcher-linux-arm64-glibc": "2.5.1", "@parcel/watcher-linux-arm64-musl": "2.5.1", "@parcel/watcher-linux-x64-glibc": "2.5.1", "@parcel/watcher-linux-x64-musl": "2.5.1", "@parcel/watcher-win32-arm64": "2.5.1", "@parcel/watcher-win32-ia32": "2.5.1", "@parcel/watcher-win32-x64": "2.5.1" } }, "sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg=="], + + "@parcel/watcher-android-arm64": ["@parcel/watcher-android-arm64@2.5.1", "", { "os": "android", "cpu": "arm64" }, "sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA=="], + + "@parcel/watcher-darwin-arm64": ["@parcel/watcher-darwin-arm64@2.5.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-eAzPv5osDmZyBhou8PoF4i6RQXAfeKL9tjb3QzYuccXFMQU0ruIc/POh30ePnaOyD1UXdlKguHBmsTs53tVoPw=="], + + "@parcel/watcher-darwin-x64": ["@parcel/watcher-darwin-x64@2.5.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-1ZXDthrnNmwv10A0/3AJNZ9JGlzrF82i3gNQcWOzd7nJ8aj+ILyW1MTxVk35Db0u91oD5Nlk9MBiujMlwmeXZg=="], + + "@parcel/watcher-freebsd-x64": ["@parcel/watcher-freebsd-x64@2.5.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-SI4eljM7Flp9yPuKi8W0ird8TI/JK6CSxju3NojVI6BjHsTyK7zxA9urjVjEKJ5MBYC+bLmMcbAWlZ+rFkLpJQ=="], + + "@parcel/watcher-linux-arm-glibc": ["@parcel/watcher-linux-arm-glibc@2.5.1", "", { "os": "linux", "cpu": "arm" }, "sha512-RCdZlEyTs8geyBkkcnPWvtXLY44BCeZKmGYRtSgtwwnHR4dxfHRG3gR99XdMEdQ7KeiDdasJwwvNSF5jKtDwdA=="], + + "@parcel/watcher-linux-arm-musl": ["@parcel/watcher-linux-arm-musl@2.5.1", "", { "os": "linux", "cpu": "arm" }, "sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q=="], + + "@parcel/watcher-linux-arm64-glibc": ["@parcel/watcher-linux-arm64-glibc@2.5.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w=="], + + "@parcel/watcher-linux-arm64-musl": ["@parcel/watcher-linux-arm64-musl@2.5.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg=="], + + "@parcel/watcher-linux-x64-glibc": ["@parcel/watcher-linux-x64-glibc@2.5.1", "", { "os": "linux", "cpu": "x64" }, "sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A=="], + + "@parcel/watcher-linux-x64-musl": ["@parcel/watcher-linux-x64-musl@2.5.1", "", { "os": "linux", "cpu": "x64" }, "sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg=="], + + "@parcel/watcher-win32-arm64": ["@parcel/watcher-win32-arm64@2.5.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw=="], + + "@parcel/watcher-win32-ia32": ["@parcel/watcher-win32-ia32@2.5.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-c2KkcVN+NJmuA7CGlaGD1qJh1cLfDnQsHjE89E60vUEMlqduHGCdCLJCID5geFVM0dOtA3ZiIO8BoEQmzQVfpQ=="], + + "@parcel/watcher-win32-x64": ["@parcel/watcher-win32-x64@2.5.1", "", { "os": "win32", "cpu": "x64" }, "sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA=="], + + "@photostructure/tz-lookup": ["@photostructure/tz-lookup@11.3.0", "", {}, "sha512-rYGy7ETBHTnXrwbzm47e3LJPKJmzpY7zXnbZhdosNU0lTGWVqzxptSjK4qZkJ1G+Kwy4F6XStNR9ZqMsXAoASQ=="], + + "@screenly/edge-apps": ["@screenly/edge-apps@workspace:../edge-apps-library"], + + "@tailwindcss/cli": ["@tailwindcss/cli@4.0.0", "", { "dependencies": { "@parcel/watcher": "^2.5.0", "@tailwindcss/node": "^4.0.0", "@tailwindcss/oxide": "^4.0.0", "enhanced-resolve": "^5.18.0", "lightningcss": "^1.29.1", "mri": "^1.2.0", "picocolors": "^1.1.1", "tailwindcss": "4.0.0" }, "bin": { "tailwindcss": "dist/index.mjs" } }, "sha512-nh6kzSTalHf9yk6WNsS4MMZakSINsncNQXsSJthvcPI4x+yajEaNQvS2uUti3PGLbsmlGoUvjhnGTBpzh7H0bA=="], + + "@tailwindcss/node": ["@tailwindcss/node@4.1.17", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "enhanced-resolve": "^5.18.3", "jiti": "^2.6.1", "lightningcss": "1.30.2", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.1.17" } }, "sha512-csIkHIgLb3JisEFQ0vxr2Y57GUNYh447C8xzwj89U/8fdW8LhProdxvnVH6U8M2Y73QKiTIH+LWbK3V2BBZsAg=="], + + "@tailwindcss/oxide": ["@tailwindcss/oxide@4.1.17", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.1.17", "@tailwindcss/oxide-darwin-arm64": "4.1.17", "@tailwindcss/oxide-darwin-x64": "4.1.17", "@tailwindcss/oxide-freebsd-x64": "4.1.17", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.17", "@tailwindcss/oxide-linux-arm64-gnu": "4.1.17", "@tailwindcss/oxide-linux-arm64-musl": "4.1.17", "@tailwindcss/oxide-linux-x64-gnu": "4.1.17", "@tailwindcss/oxide-linux-x64-musl": "4.1.17", "@tailwindcss/oxide-wasm32-wasi": "4.1.17", "@tailwindcss/oxide-win32-arm64-msvc": "4.1.17", "@tailwindcss/oxide-win32-x64-msvc": "4.1.17" } }, "sha512-F0F7d01fmkQhsTjXezGBLdrl1KresJTcI3DB8EkScCldyKp3Msz4hub4uyYaVnk88BAS1g5DQjjF6F5qczheLA=="], + + "@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.1.17", "", { "os": "android", "cpu": "arm64" }, "sha512-BMqpkJHgOZ5z78qqiGE6ZIRExyaHyuxjgrJ6eBO5+hfrfGkuya0lYfw8fRHG77gdTjWkNWEEm+qeG2cDMxArLQ=="], + + "@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.1.17", "", { "os": "darwin", "cpu": "arm64" }, "sha512-EquyumkQweUBNk1zGEU/wfZo2qkp/nQKRZM8bUYO0J+Lums5+wl2CcG1f9BgAjn/u9pJzdYddHWBiFXJTcxmOg=="], + + "@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.1.17", "", { "os": "darwin", "cpu": "x64" }, "sha512-gdhEPLzke2Pog8s12oADwYu0IAw04Y2tlmgVzIN0+046ytcgx8uZmCzEg4VcQh+AHKiS7xaL8kGo/QTiNEGRog=="], + + "@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.1.17", "", { "os": "freebsd", "cpu": "x64" }, "sha512-hxGS81KskMxML9DXsaXT1H0DyA+ZBIbyG/sSAjWNe2EDl7TkPOBI42GBV3u38itzGUOmFfCzk1iAjDXds8Oh0g=="], + + "@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.1.17", "", { "os": "linux", "cpu": "arm" }, "sha512-k7jWk5E3ldAdw0cNglhjSgv501u7yrMf8oeZ0cElhxU6Y2o7f8yqelOp3fhf7evjIS6ujTI3U8pKUXV2I4iXHQ=="], + + "@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.1.17", "", { "os": "linux", "cpu": "arm64" }, "sha512-HVDOm/mxK6+TbARwdW17WrgDYEGzmoYayrCgmLEw7FxTPLcp/glBisuyWkFz/jb7ZfiAXAXUACfyItn+nTgsdQ=="], + + "@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.1.17", "", { "os": "linux", "cpu": "arm64" }, "sha512-HvZLfGr42i5anKtIeQzxdkw/wPqIbpeZqe7vd3V9vI3RQxe3xU1fLjss0TjyhxWcBaipk7NYwSrwTwK1hJARMg=="], + + "@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.1.17", "", { "os": "linux", "cpu": "x64" }, "sha512-M3XZuORCGB7VPOEDH+nzpJ21XPvK5PyjlkSFkFziNHGLc5d6g3di2McAAblmaSUNl8IOmzYwLx9NsE7bplNkwQ=="], + + "@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.1.17", "", { "os": "linux", "cpu": "x64" }, "sha512-k7f+pf9eXLEey4pBlw+8dgfJHY4PZ5qOUFDyNf7SI6lHjQ9Zt7+NcscjpwdCEbYi6FI5c2KDTDWyf2iHcCSyyQ=="], + + "@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.1.17", "", { "dependencies": { "@emnapi/core": "^1.6.0", "@emnapi/runtime": "^1.6.0", "@emnapi/wasi-threads": "^1.1.0", "@napi-rs/wasm-runtime": "^1.0.7", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.4.0" }, "cpu": "none" }, "sha512-cEytGqSSoy7zK4JRWiTCx43FsKP/zGr0CsuMawhH67ONlH+T79VteQeJQRO/X7L0juEUA8ZyuYikcRBf0vsxhg=="], + + "@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.1.17", "", { "os": "win32", "cpu": "arm64" }, "sha512-JU5AHr7gKbZlOGvMdb4722/0aYbU+tN6lv1kONx0JK2cGsh7g148zVWLM0IKR3NeKLv+L90chBVYcJ8uJWbC9A=="], + + "@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.1.17", "", { "os": "win32", "cpu": "x64" }, "sha512-SKWM4waLuqx0IH+FMDUw6R66Hu4OuTALFgnleKbqhgGU30DY20NORZMZUKgLRjQXNN2TLzKvh48QXTig4h4bGw=="], + + "@types/bun": ["@types/bun@1.3.3", "", { "dependencies": { "bun-types": "1.3.3" } }, "sha512-ogrKbJ2X5N0kWLLFKeytG0eHDleBYtngtlbu9cyBKFtNL3cnpDZkNdQj8flVf6WTZUX5ulI9AY1oa7ljhSrp+g=="], + + "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], + + "@types/jsdom": ["@types/jsdom@27.0.0", "", { "dependencies": { "@types/node": "*", "@types/tough-cookie": "*", "parse5": "^7.0.0" } }, "sha512-NZyFl/PViwKzdEkQg96gtnB8wm+1ljhdDay9ahn4hgb+SfVtPCbm3TlmDUFXTA+MGN3CijicnMhG18SI5H3rFw=="], + + "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], + + "@types/node": ["@types/node@24.10.1", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ=="], + + "@types/tough-cookie": ["@types/tough-cookie@4.0.5", "", {}, "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA=="], + + "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.48.1", "", { "dependencies": { "@eslint-community/regexpp": "^4.10.0", "@typescript-eslint/scope-manager": "8.48.1", "@typescript-eslint/type-utils": "8.48.1", "@typescript-eslint/utils": "8.48.1", "@typescript-eslint/visitor-keys": "8.48.1", "graphemer": "^1.4.0", "ignore": "^7.0.0", "natural-compare": "^1.4.0", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.48.1", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-X63hI1bxl5ohelzr0LY5coufyl0LJNthld+abwxpCoo6Gq+hSqhKwci7MUWkXo67mzgUK6YFByhmaHmUcuBJmA=="], + + "@typescript-eslint/parser": ["@typescript-eslint/parser@8.48.1", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.48.1", "@typescript-eslint/types": "8.48.1", "@typescript-eslint/typescript-estree": "8.48.1", "@typescript-eslint/visitor-keys": "8.48.1", "debug": "^4.3.4" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-PC0PDZfJg8sP7cmKe6L3QIL8GZwU5aRvUFedqSIpw3B+QjRSUZeeITC2M5XKeMXEzL6wccN196iy3JLwKNvDVA=="], + + "@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.48.1", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.48.1", "@typescript-eslint/types": "^8.48.1", "debug": "^4.3.4" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-HQWSicah4s9z2/HifRPQ6b6R7G+SBx64JlFQpgSSHWPKdvCZX57XCbszg/bapbRsOEv42q5tayTYcEFpACcX1w=="], + + "@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.48.1", "", { "dependencies": { "@typescript-eslint/types": "8.48.1", "@typescript-eslint/visitor-keys": "8.48.1" } }, "sha512-rj4vWQsytQbLxC5Bf4XwZ0/CKd362DkWMUkviT7DCS057SK64D5lH74sSGzhI6PDD2HCEq02xAP9cX68dYyg1w=="], + + "@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.48.1", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-k0Jhs4CpEffIBm6wPaCXBAD7jxBtrHjrSgtfCjUvPp9AZ78lXKdTR8fxyZO5y4vWNlOvYXRtngSZNSn+H53Jkw=="], + + "@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.48.1", "", { "dependencies": { "@typescript-eslint/types": "8.48.1", "@typescript-eslint/typescript-estree": "8.48.1", "@typescript-eslint/utils": "8.48.1", "debug": "^4.3.4", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-1jEop81a3LrJQLTf/1VfPQdhIY4PlGDBc/i67EVWObrtvcziysbLN3oReexHOM6N3jyXgCrkBsZpqwH0hiDOQg=="], + + "@typescript-eslint/types": ["@typescript-eslint/types@8.48.1", "", {}, "sha512-+fZ3LZNeiELGmimrujsDCT4CRIbq5oXdHe7chLiW8qzqyPMnn1puNstCrMNVAqwcl2FdIxkuJ4tOs/RFDBVc/Q=="], + + "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.48.1", "", { "dependencies": { "@typescript-eslint/project-service": "8.48.1", "@typescript-eslint/tsconfig-utils": "8.48.1", "@typescript-eslint/types": "8.48.1", "@typescript-eslint/visitor-keys": "8.48.1", "debug": "^4.3.4", "minimatch": "^9.0.4", "semver": "^7.6.0", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-/9wQ4PqaefTK6POVTjJaYS0bynCgzh6ClJHGSBj06XEHjkfylzB+A3qvyaXnErEZSaxhIo4YdyBgq6j4RysxDg=="], + + "@typescript-eslint/utils": ["@typescript-eslint/utils@8.48.1", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.7.0", "@typescript-eslint/scope-manager": "8.48.1", "@typescript-eslint/types": "8.48.1", "@typescript-eslint/typescript-estree": "8.48.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-fAnhLrDjiVfey5wwFRwrweyRlCmdz5ZxXz2G/4cLn0YDLjTapmN4gcCsTBR1N2rWnZSDeWpYtgLDsJt+FpmcwA=="], + + "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.48.1", "", { "dependencies": { "@typescript-eslint/types": "8.48.1", "eslint-visitor-keys": "^4.2.1" } }, "sha512-BmxxndzEWhE4TIEEMBs8lP3MBWN3jFPs/p6gPm/wkv02o41hI6cq9AuSmGAaTTHPtA1FTi2jBre4A9rm5ZmX+Q=="], + + "acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="], + + "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], + + "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], + + "ajv": ["ajv@6.12.6", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="], + + "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + + "ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], + + "anymatch": ["anymatch@3.1.3", "", { "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" } }, "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw=="], + + "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], + + "autoprefixer": ["autoprefixer@10.4.22", "", { "dependencies": { "browserslist": "^4.27.0", "caniuse-lite": "^1.0.30001754", "fraction.js": "^5.3.4", "normalize-range": "^0.1.2", "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "postcss": "^8.1.0" }, "bin": { "autoprefixer": "bin/autoprefixer" } }, "sha512-ARe0v/t9gO28Bznv6GgqARmVqcWOV3mfgUPn9becPHMiD3o9BwlRgaeccZnwTpZ7Zwqrm+c1sUSsMxIzQzc8Xg=="], + + "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + + "baseline-browser-mapping": ["baseline-browser-mapping@2.9.4", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-ZCQ9GEWl73BVm8bu5Fts8nt7MHdbt5vY9bP6WGnUh+r3l8M7CgfyTlwsgCbMC66BNxPr6Xoce3j66Ms5YUQTNA=="], + + "bidi-js": ["bidi-js@1.0.3", "", { "dependencies": { "require-from-string": "^2.0.2" } }, "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw=="], + + "binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="], + + "brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="], + + "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], + + "brotli-size": ["brotli-size@4.0.0", "", { "dependencies": { "duplexer": "0.1.1" } }, "sha512-uA9fOtlTRC0iqKfzff1W34DXUA3GyVqbUaeo3Rw3d4gd1eavKVCETXrn3NzO74W+UVkG3UHu8WxUi+XvKI/huA=="], + + "browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="], + + "bun-types": ["bun-types@1.3.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-z3Xwlg7j2l9JY27x5Qn3Wlyos8YAp0kKRlrePAOjgjMGS5IG6E7Jnlx736vH9UVI4wUICwwhC9anYL++XeOgTQ=="], + + "callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="], + + "caniuse-lite": ["caniuse-lite@1.0.30001759", "", {}, "sha512-Pzfx9fOKoKvevQf8oCXoyNRQ5QyxJj+3O0Rqx2V5oxT61KGx8+n6hV/IUyJeifUci2clnmmKVpvtiqRzgiWjSw=="], + + "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], + + "chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="], + + "cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="], + + "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], + + "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], + + "concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="], + + "country-locale-map": ["country-locale-map@1.9.11", "", { "dependencies": { "fuzzball": "^2.1.2" } }, "sha512-Nrj31H/BmHFLzh2CYZkExQFUIZmqBSJ+nrdSRSjIqh4FMs6VRXOboDPIp7NqXBUoOTJi6Urf2cypPQez0rFYBQ=="], + + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + + "css-tree": ["css-tree@3.1.0", "", { "dependencies": { "mdn-data": "2.12.2", "source-map-js": "^1.0.1" } }, "sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w=="], + + "cssstyle": ["cssstyle@5.3.3", "", { "dependencies": { "@asamuzakjp/css-color": "^4.0.3", "@csstools/css-syntax-patches-for-csstree": "^1.0.14", "css-tree": "^3.1.0" } }, "sha512-OytmFH+13/QXONJcC75QNdMtKpceNk3u8ThBjyyYjkEcy/ekBwR1mMAuNvi3gdBPW3N5TlCzQ0WZw8H0lN/bDw=="], + + "csv-parse": ["csv-parse@5.6.0", "", {}, "sha512-l3nz3euub2QMg5ouu5U09Ew9Wf6/wQ8I++ch1loQ0ljmzhmfZYrH9fflS22i/PQEvsPvxCwxgz5q7UB8K1JO4Q=="], + + "data-urls": ["data-urls@6.0.0", "", { "dependencies": { "whatwg-mimetype": "^4.0.0", "whatwg-url": "^15.0.0" } }, "sha512-BnBS08aLUM+DKamupXs3w2tJJoqU+AkaE/+6vQxi/G/DPmIZFJJp9Dkb1kM03AZx8ADehDUZgsNxju3mPXZYIA=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "decimal.js": ["decimal.js@10.6.0", "", {}, "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="], + + "deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="], + + "detect-libc": ["detect-libc@1.0.3", "", { "bin": { "detect-libc": "./bin/detect-libc.js" } }, "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg=="], + + "dts-bundle-generator": ["dts-bundle-generator@7.2.0", "", { "dependencies": { "typescript": ">=4.5.2", "yargs": "^17.6.0" }, "bin": { "dts-bundle-generator": "dist/bin/dts-bundle-generator.js" } }, "sha512-pHjRo52hvvLDRijzIYRTS9eJR7vAOs3gd/7jx+7YVnLU8ay3yPUWGtHXPtuMBSlJYk/s4nq1SvXObDCZVguYMg=="], + + "duplexer": ["duplexer@0.1.1", "", {}, "sha512-sxNZ+ljy+RA1maXoUReeqBBpBC6RLKmg5ewzV+x+mSETmWNoKdZN6vcQjpFROemza23hGFskJtFNoUWUaQ+R4Q=="], + + "electron-to-chromium": ["electron-to-chromium@1.5.266", "", {}, "sha512-kgWEglXvkEfMH7rxP5OSZZwnaDWT7J9EoZCujhnpLbfi0bbNtRkgdX2E3gt0Uer11c61qCYktB3hwkAS325sJg=="], + + "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + + "enhanced-resolve": ["enhanced-resolve@5.18.3", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" } }, "sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww=="], + + "entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], + + "esbuild": ["esbuild@0.17.19", "", { "optionalDependencies": { "@esbuild/android-arm": "0.17.19", "@esbuild/android-arm64": "0.17.19", "@esbuild/android-x64": "0.17.19", "@esbuild/darwin-arm64": "0.17.19", "@esbuild/darwin-x64": "0.17.19", "@esbuild/freebsd-arm64": "0.17.19", "@esbuild/freebsd-x64": "0.17.19", "@esbuild/linux-arm": "0.17.19", "@esbuild/linux-arm64": "0.17.19", "@esbuild/linux-ia32": "0.17.19", "@esbuild/linux-loong64": "0.17.19", "@esbuild/linux-mips64el": "0.17.19", "@esbuild/linux-ppc64": "0.17.19", "@esbuild/linux-riscv64": "0.17.19", "@esbuild/linux-s390x": "0.17.19", "@esbuild/linux-x64": "0.17.19", "@esbuild/netbsd-x64": "0.17.19", "@esbuild/openbsd-x64": "0.17.19", "@esbuild/sunos-x64": "0.17.19", "@esbuild/win32-arm64": "0.17.19", "@esbuild/win32-ia32": "0.17.19", "@esbuild/win32-x64": "0.17.19" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-XQ0jAPFkK/u3LcVRcvVHQcTIqD6E2H1fvZMA5dQPSOWb3suUbWbfbRf94pjc0bNzRYLfIrDRQXr7X+LHIm5oHw=="], + + "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], + + "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], + + "eslint": ["eslint@9.39.1", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.21.1", "@eslint/config-helpers": "^0.4.2", "@eslint/core": "^0.17.0", "@eslint/eslintrc": "^3.3.1", "@eslint/js": "9.39.1", "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.12.4", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^8.4.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.2", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g=="], + + "eslint-scope": ["eslint-scope@8.4.0", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg=="], + + "eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="], + + "espree": ["espree@10.4.0", "", { "dependencies": { "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^4.2.1" } }, "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ=="], + + "esquery": ["esquery@1.6.0", "", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg=="], + + "esrecurse": ["esrecurse@4.3.0", "", { "dependencies": { "estraverse": "^5.2.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="], + + "estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="], + + "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], + + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], + + "fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="], + + "fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="], + + "fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="], + + "fast-xml-parser": ["fast-xml-parser@5.3.2", "", { "dependencies": { "strnum": "^2.1.0" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-n8v8b6p4Z1sMgqRmqLJm3awW4NX7NkaKPfb3uJIBTSH7Pdvufi3PQ3/lJLQrvxcMYl7JI2jnDO90siPEpD8JBA=="], + + "fastq": ["fastq@1.19.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ=="], + + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + + "file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="], + + "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], + + "find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="], + + "flat-cache": ["flat-cache@4.0.1", "", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" } }, "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw=="], + + "flatted": ["flatted@3.3.3", "", {}, "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg=="], + + "fraction.js": ["fraction.js@5.3.4", "", {}, "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ=="], + + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "fuzzball": ["fuzzball@2.2.3", "", { "dependencies": { "heap": ">=0.2.0", "lodash": "^4.17.21", "setimmediate": "^1.0.5" } }, "sha512-sQDb3kjI7auA4YyE1YgEW85MTparcSgRgcCweUK06Cn0niY5lN+uhFiRUZKN4MQVGGiHxlbrYCA4nL1QjOXBLQ=="], + + "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], + + "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], + + "globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="], + + "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + + "graphemer": ["graphemer@1.4.0", "", {}, "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag=="], + + "gzip-size": ["gzip-size@7.0.0", "", { "dependencies": { "duplexer": "^0.1.2" } }, "sha512-O1Ld7Dr+nqPnmGpdhzLmMTQ4vAsD+rHwMm1NLUmoUFFymBOMKxCCrtDxqdBRYXdeEPEi3SyoR4TizJLQrnKBNA=="], + + "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], + + "heap": ["heap@0.2.7", "", {}, "sha512-2bsegYkkHO+h/9MGbn6KWcE45cHZgPANo5LXF7EvWdT0yT2EguSVO1nDgU5c8+ZOPwp2vMNa7YFsJhVcDR9Sdg=="], + + "html-encoding-sniffer": ["html-encoding-sniffer@4.0.0", "", { "dependencies": { "whatwg-encoding": "^3.1.1" } }, "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ=="], + + "http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="], + + "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], + + "iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], + + "ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], + + "import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="], + + "imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="], + + "is-binary-path": ["is-binary-path@2.1.0", "", { "dependencies": { "binary-extensions": "^2.0.0" } }, "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw=="], + + "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], + + "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], + + "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], + + "is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="], + + "is-potential-custom-element-name": ["is-potential-custom-element-name@1.0.1", "", {}, "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ=="], + + "isexe": ["isexe@3.1.1", "", {}, "sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ=="], + + "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], + + "js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], + + "jsdom": ["jsdom@27.2.0", "", { "dependencies": { "@acemir/cssom": "^0.9.23", "@asamuzakjp/dom-selector": "^6.7.4", "cssstyle": "^5.3.3", "data-urls": "^6.0.0", "decimal.js": "^10.6.0", "html-encoding-sniffer": "^4.0.0", "http-proxy-agent": "^7.0.2", "https-proxy-agent": "^7.0.6", "is-potential-custom-element-name": "^1.0.1", "parse5": "^8.0.0", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", "tough-cookie": "^6.0.0", "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^8.0.0", "whatwg-encoding": "^3.1.1", "whatwg-mimetype": "^4.0.0", "whatwg-url": "^15.1.0", "ws": "^8.18.3", "xml-name-validator": "^5.0.0" }, "peerDependencies": { "canvas": "^3.0.0" }, "optionalPeers": ["canvas"] }, "sha512-454TI39PeRDW1LgpyLPyURtB4Zx1tklSr6+OFOipsxGUH1WMTvk6C65JQdrj455+DP2uJ1+veBEHTGFKWVLFoA=="], + + "json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="], + + "json-parse-even-better-errors": ["json-parse-even-better-errors@4.0.0", "", {}, "sha512-lR4MXjGNgkJc7tkQ97kb2nuEMnNCyU//XYVH0MKTGcXEiSudQ5MKGKen3C5QubYy0vmq+JGitUg92uuywGEwIA=="], + + "json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], + + "json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="], + + "keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="], + + "kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="], + + "levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="], + + "lightningcss": ["lightningcss@1.30.2", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.30.2", "lightningcss-darwin-arm64": "1.30.2", "lightningcss-darwin-x64": "1.30.2", "lightningcss-freebsd-x64": "1.30.2", "lightningcss-linux-arm-gnueabihf": "1.30.2", "lightningcss-linux-arm64-gnu": "1.30.2", "lightningcss-linux-arm64-musl": "1.30.2", "lightningcss-linux-x64-gnu": "1.30.2", "lightningcss-linux-x64-musl": "1.30.2", "lightningcss-win32-arm64-msvc": "1.30.2", "lightningcss-win32-x64-msvc": "1.30.2" } }, "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ=="], + + "lightningcss-android-arm64": ["lightningcss-android-arm64@1.30.2", "", { "os": "android", "cpu": "arm64" }, "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A=="], + + "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.30.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA=="], + + "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.30.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ=="], + + "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.30.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA=="], + + "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.30.2", "", { "os": "linux", "cpu": "arm" }, "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA=="], + + "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.30.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A=="], + + "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.30.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA=="], + + "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.30.2", "", { "os": "linux", "cpu": "x64" }, "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w=="], + + "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.30.2", "", { "os": "linux", "cpu": "x64" }, "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA=="], + + "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.30.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ=="], + + "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.30.2", "", { "os": "win32", "cpu": "x64" }, "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw=="], + + "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], + + "lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="], + + "lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="], + + "long": ["long@3.2.0", "", {}, "sha512-ZYvPPOMqUwPoDsbJaR10iQJYnMuZhRTvHYl62ErLIEX7RgFlziSBUUvrt3OVfc47QlHHpzPZYP17g3Fv7oeJkg=="], + + "lru-cache": ["lru-cache@11.2.4", "", {}, "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg=="], + + "lz-ts": ["lz-ts@1.1.2", "", {}, "sha512-ye8sVndmvzs46cPgX1Yjlk3o/Sueu0VHn253rKpsWiK2/bAbsVkD7DEJiaueiPfbZTi17GLRPkv3W5O3BUNd2g=="], + + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + + "mdn-data": ["mdn-data@2.12.2", "", {}, "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA=="], + + "memorystream": ["memorystream@0.3.1", "", {}, "sha512-S3UwM3yj5mtUSEfP41UZmt/0SCoVYUcU1rkXv+BQ5Ig8ndL4sPoJNBUJERafdPb5jjHJGuMgytgKvKIf58XNBw=="], + + "merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="], + + "micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="], + + "minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="], + + "mri": ["mri@1.2.0", "", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], + + "natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="], + + "node-addon-api": ["node-addon-api@7.1.1", "", {}, "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ=="], + + "node-releases": ["node-releases@2.0.27", "", {}, "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA=="], + + "normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="], + + "normalize-range": ["normalize-range@0.1.2", "", {}, "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA=="], + + "npm-normalize-package-bin": ["npm-normalize-package-bin@4.0.0", "", {}, "sha512-TZKxPvItzai9kN9H/TkmCtx/ZN/hvr3vUycjlfmH0ootY9yFBzNOpiXAdIn1Iteqsvk4lQn6B5PTrt+n6h8k/w=="], + + "npm-run-all2": ["npm-run-all2@8.0.4", "", { "dependencies": { "ansi-styles": "^6.2.1", "cross-spawn": "^7.0.6", "memorystream": "^0.3.1", "picomatch": "^4.0.2", "pidtree": "^0.6.0", "read-package-json-fast": "^4.0.0", "shell-quote": "^1.7.3", "which": "^5.0.0" }, "bin": { "run-p": "bin/run-p/index.js", "run-s": "bin/run-s/index.js", "npm-run-all": "bin/npm-run-all/index.js", "npm-run-all2": "bin/npm-run-all/index.js" } }, "sha512-wdbB5My48XKp2ZfJUlhnLVihzeuA1hgBnqB2J9ahV77wLS+/YAJAlN8I+X3DIFIPZ3m5L7nplmlbhNiFDmXRDA=="], + + "offline-geocode-city": ["offline-geocode-city@1.0.2", "", { "dependencies": { "@jsheaven/easybuild": "^1.2.9", "chokidar": "^3.5.3", "csv-parse": "^5.3.10", "lz-ts": "^1.1.2", "s2-geometry": "^1.2.10" } }, "sha512-6q9XvgYpvOr7kLzi/K2P1GZ36FajNHEI4cFphNcZ6tPxR0kBROzy6CorTn+yU7en3wrDkDTfcn1sPCAKA569xA=="], + + "optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="], + + "p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], + + "p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="], + + "parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="], + + "parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="], + + "path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="], + + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + + "picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], + + "pidtree": ["pidtree@0.6.0", "", { "bin": { "pidtree": "bin/pidtree.js" } }, "sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g=="], + + "postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="], + + "postcss-value-parser": ["postcss-value-parser@4.2.0", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="], + + "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], + + "prettier": ["prettier@3.7.4", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA=="], + + "pretty-bytes": ["pretty-bytes@6.1.1", "", {}, "sha512-mQUvGU6aUFQ+rNvTIAcZuWGRT9a6f6Yrg9bHs4ImKF+HZCEK+plBvnAZYSIQztknZF2qnzNtr6F8s0+IuptdlQ=="], + + "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], + + "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="], + + "read-package-json-fast": ["read-package-json-fast@4.0.0", "", { "dependencies": { "json-parse-even-better-errors": "^4.0.0", "npm-normalize-package-bin": "^4.0.0" } }, "sha512-qpt8EwugBWDw2cgE2W+/3oxC+KTez2uSVR8JU9Q36TXPAGCaozfQUs59v4j4GFpWTaw0i6hAZSvOmu1J0uOEUg=="], + + "readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="], + + "require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="], + + "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], + + "resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], + + "reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="], + + "run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="], + + "s2-geometry": ["s2-geometry@1.2.10", "", { "dependencies": { "long": "^3.2.0" } }, "sha512-5WejfQu1XZ25ZerW8uL6xP1sM2krcOYKhI6TbfybGRf+vTQLrm3E+4n0+1lWg+MYqFjPzoe51zKhn2sBRMCt5g=="], + + "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + + "saxes": ["saxes@6.0.0", "", { "dependencies": { "xmlchars": "^2.2.0" } }, "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA=="], + + "semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], + + "setimmediate": ["setimmediate@1.0.5", "", {}, "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA=="], + + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], + + "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + + "shell-quote": ["shell-quote@1.8.3", "", {}, "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw=="], + + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + + "string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + + "strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + + "strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="], + + "strnum": ["strnum@2.1.1", "", {}, "sha512-7ZvoFTiCnGxBtDqJ//Cu6fWtZtc7Y3x+QOirG15wztbdngGSkht27o2pyGWrVy0b4WAy3jbKmnoK6g5VlVNUUw=="], + + "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], + + "symbol-tree": ["symbol-tree@3.2.4", "", {}, "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw=="], + + "tailwindcss": ["tailwindcss@4.1.17", "", {}, "sha512-j9Ee2YjuQqYT9bbRTfTZht9W/ytp5H+jJpZKiYdP/bpnXARAuELt9ofP0lPnmHjbga7SNQIxdTAXCmtKVYjN+Q=="], + + "tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="], + + "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], + + "tldts": ["tldts@7.0.19", "", { "dependencies": { "tldts-core": "^7.0.19" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-8PWx8tvC4jDB39BQw1m4x8y5MH1BcQ5xHeL2n7UVFulMPH/3Q0uiamahFJ3lXA0zO2SUyRXuVVbWSDmstlt9YA=="], + + "tldts-core": ["tldts-core@7.0.19", "", {}, "sha512-lJX2dEWx0SGH4O6p+7FPwYmJ/bu1JbcGJ8RLaG9b7liIgZ85itUVEPbMtWRVrde/0fnDPEPHW10ZsKW3kVsE9A=="], + + "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], + + "tough-cookie": ["tough-cookie@6.0.0", "", { "dependencies": { "tldts": "^7.0.5" } }, "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w=="], + + "tr46": ["tr46@6.0.0", "", { "dependencies": { "punycode": "^2.3.1" } }, "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw=="], + + "ts-api-utils": ["ts-api-utils@2.1.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ=="], + + "type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "typescript-eslint": ["typescript-eslint@8.48.1", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.48.1", "@typescript-eslint/parser": "8.48.1", "@typescript-eslint/typescript-estree": "8.48.1", "@typescript-eslint/utils": "8.48.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-FbOKN1fqNoXp1hIl5KYpObVrp0mCn+CLgn479nmu2IsRMrx2vyv74MmsBLVlhg8qVwNFGbXSp8fh1zp8pEoC2A=="], + + "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + + "update-browserslist-db": ["update-browserslist-db@1.2.2", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-E85pfNzMQ9jpKkA7+TJAi4TJN+tBCuWh5rUcS/sv6cFi+1q9LYDwDI5dpUL0u/73EElyQ8d3TEaeW4sPedBqYA=="], + + "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], + + "w3c-xmlserializer": ["w3c-xmlserializer@5.0.0", "", { "dependencies": { "xml-name-validator": "^5.0.0" } }, "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA=="], + + "webidl-conversions": ["webidl-conversions@8.0.0", "", {}, "sha512-n4W4YFyz5JzOfQeA8oN7dUYpR+MBP3PIUsn2jLjWXwK5ASUzt0Jc/A5sAUZoCYFJRGF0FBKJ+1JjN43rNdsQzA=="], + + "whatwg-encoding": ["whatwg-encoding@3.1.1", "", { "dependencies": { "iconv-lite": "0.6.3" } }, "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ=="], + + "whatwg-mimetype": ["whatwg-mimetype@4.0.0", "", {}, "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg=="], + + "whatwg-url": ["whatwg-url@15.1.0", "", { "dependencies": { "tr46": "^6.0.0", "webidl-conversions": "^8.0.0" } }, "sha512-2ytDk0kiEj/yu90JOAp44PVPUkO9+jVhyf+SybKlRHSDlvOOZhdPIrr7xTH64l4WixO2cP+wQIcgujkGBPPz6g=="], + + "which": ["which@5.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ=="], + + "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="], + + "wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], + + "ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="], + + "xml-name-validator": ["xml-name-validator@5.0.0", "", {}, "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg=="], + + "xmlchars": ["xmlchars@2.2.0", "", {}, "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw=="], + + "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], + + "yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="], + + "yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="], + + "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], + + "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], + + "@jsheaven/easybuild/typescript": ["typescript@4.9.5", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g=="], + + "@tailwindcss/cli/tailwindcss": ["tailwindcss@4.0.0", "", {}, "sha512-ULRPI3A+e39T7pSaf1xoi58AqqJxVCLg8F/uM5A3FadUbnyDTgltVnXJvdkTjwCOGA6NazqHVcwPJC5h2vRYVQ=="], + + "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.7.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-o1uhUASyo921r2XtHYOHy7gdkGLge8ghBEQHMWmyJFoXlpU58kIrhhN3w26lpQb6dspetweapMn2CSNwQ8I4wg=="], + + "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.7.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA=="], + + "@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.1.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ=="], + + "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.0", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" }, "bundled": true }, "sha512-Fq6DJW+Bb5jaWE69/qOE0D1TUN9+6uWhCeZpdnSBk14pjLcCWR7Q8n49PTSPHazM37JqrsdpEthXy2xn6jWWiA=="], + + "@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], + + "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], + + "@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], + + "anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], + + "chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + + "chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], + + "cross-spawn/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + + "fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], + + "gzip-size/duplexer": ["duplexer@0.1.2", "", {}, "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg=="], + + "jsdom/parse5": ["parse5@8.0.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA=="], + + "lightningcss/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + + "micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], + + "readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], + + "wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + + "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], + + "cross-spawn/which/isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + } +} diff --git a/edge-apps/cap-alerting/eslint.config.ts b/edge-apps/cap-alerting/eslint.config.ts new file mode 100644 index 000000000..24bc787e6 --- /dev/null +++ b/edge-apps/cap-alerting/eslint.config.ts @@ -0,0 +1,18 @@ +import eslint from '@eslint/js' +import tseslint from 'typescript-eslint' + +export default tseslint.config( + eslint.configs.recommended, + ...tseslint.configs.recommended, + { + languageOptions: { + parserOptions: { + project: true, + tsconfigRootDir: import.meta.dirname, + }, + }, + }, + { + ignores: ['dist/', 'node_modules/'] + } +) diff --git a/edge-apps/cap-alerting/index.html b/edge-apps/cap-alerting/index.html new file mode 100644 index 000000000..3ea1df1d4 --- /dev/null +++ b/edge-apps/cap-alerting/index.html @@ -0,0 +1,16 @@ + + + + + CAP Alerting - Screenly Edge App + + + + +
+
+
+ + + + diff --git a/edge-apps/cap-alerting/package.json b/edge-apps/cap-alerting/package.json new file mode 100644 index 000000000..97d22de1b --- /dev/null +++ b/edge-apps/cap-alerting/package.json @@ -0,0 +1,53 @@ +{ + "name": "cap-alerting", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "generate-mock-data": "screenly edge-app run --generate-mock-data", + "predev": "bun run generate-mock-data", + "dev": "run-p build:css:dev build:js:dev edge-app-server cors-proxy-server", + "cors-proxy-server": "bun ../blueprint/scripts/cors-proxy-server.ts", + "edge-app-server": "screenly edge-app run", + "build": "run-p build:css:prod build:js:prod", + "build:css:common": "bunx @tailwindcss/cli -i ./src/input.css -o ./static/style.css", + "build:css:prod": "bun run build:css:common -- --minify", + "build:css:dev": "bun run build:css:common -- --watch", + "build:js:common": "bun build src/main.ts --outdir static/js --target browser --format iife", + "build:js:prod": "bun run build:js:common", + "build:js:dev": "bun run build:js:common -- --watch", + "test": "bun test", + "test:unit": "bun test", + "test:watch": "bun test --watch", + "type-check": "tsc --noEmit", + "lint": "edge-apps-scripts lint --fix", + "format:common": "prettier src/ README.md index.html", + "format": "bun run format:common --write", + "format:check": "bun run format:common --check", + "deploy": "bun run build && screenly edge-app deploy", + "prepare": "cd ../edge-apps-library && bun install && bun run build" + }, + "dependencies": { + "@photostructure/tz-lookup": "^11.3.0", + "country-locale-map": "^1.9.11", + "fast-xml-parser": "^5.3.2", + "offline-geocode-city": "^1.0.2" + }, + "prettier": "../edge-apps-library/.prettierrc.json", + "devDependencies": { + "@eslint/js": "^9.39.1", + "@screenly/edge-apps": "workspace:../edge-apps-library", + "@tailwindcss/cli": "^4.0.0", + "@types/bun": "^1.3.3", + "@types/node": "^24.10.1", + "autoprefixer": "^10.4.22", + "bun-types": "^1.3.3", + "eslint": "^9.39.1", + "jiti": "^2.6.1", + "npm-run-all2": "^8.0.4", + "prettier": "^3.7.4", + "tailwindcss": "^4.1.17", + "typescript": "^5.9.3", + "typescript-eslint": "^8.48.1" + } +} diff --git a/edge-apps/cap-alerting/screenly.yml b/edge-apps/cap-alerting/screenly.yml new file mode 100644 index 000000000..93c60385c --- /dev/null +++ b/edge-apps/cap-alerting/screenly.yml @@ -0,0 +1,86 @@ +--- +syntax: manifest_v1 +description: Display CAP emergency alerts on Screenly screens. Supports offline mode and uses screen tags (e.g., exit:North Lobby) to highlight the nearest exit. +icon: https://playground.srly.io/edge-apps/cap-alerting/static/cap-icon.svg +author: Screenly, Inc. +entrypoint: + type: file +ready_signal: true +settings: + cap_feed_url: + type: string + default_value: '' + title: CAP Feed URL + optional: true + help_text: + properties: + help_text: URL or relative path to a CAP XML feed. Leave blank to use demo mode. + type: string + schema_version: 1 + language: + type: string + default_value: en + title: Default Language + optional: true + help_text: + properties: + help_text: Choose the preferred language when multiple info blocks exist (e.g., en, es, fr). + type: string + schema_version: 1 + max_alerts: + type: string + default_value: Infinity + title: Maximum Alerts + optional: true + help_text: + properties: + help_text: Maximum number of alerts to display simultaneously. + type: number + schema_version: 1 + mode: + type: string + default_value: production + title: Mode + optional: true + help_text: + properties: + help_text: Select the operation mode for the app. + options: + - label: Production + value: production + - label: Demo + value: demo + - label: Test + value: test + type: select + schema_version: 1 + mute_sound: + type: string + default_value: 'false' + title: Mute Sound + optional: true + help_text: + properties: + help_text: Mute audio alerts from CAP resources. Not available for Screenly Anywhere. + type: boolean + schema_version: 1 + offline_mode: + type: string + default_value: 'false' + title: Offline Mode + optional: true + help_text: + properties: + help_text: When enabled, avoid network fetches and use cached data. + type: boolean + schema_version: 1 + refresh_interval: + type: string + default_value: '5' + title: Refresh Interval (minutes) + optional: true + help_text: + properties: + help_text: Time in minutes between feed updates. + type: number + schema_version: 1 diff --git a/edge-apps/cap-alerting/screenly_qc.yml b/edge-apps/cap-alerting/screenly_qc.yml new file mode 100644 index 000000000..93c60385c --- /dev/null +++ b/edge-apps/cap-alerting/screenly_qc.yml @@ -0,0 +1,86 @@ +--- +syntax: manifest_v1 +description: Display CAP emergency alerts on Screenly screens. Supports offline mode and uses screen tags (e.g., exit:North Lobby) to highlight the nearest exit. +icon: https://playground.srly.io/edge-apps/cap-alerting/static/cap-icon.svg +author: Screenly, Inc. +entrypoint: + type: file +ready_signal: true +settings: + cap_feed_url: + type: string + default_value: '' + title: CAP Feed URL + optional: true + help_text: + properties: + help_text: URL or relative path to a CAP XML feed. Leave blank to use demo mode. + type: string + schema_version: 1 + language: + type: string + default_value: en + title: Default Language + optional: true + help_text: + properties: + help_text: Choose the preferred language when multiple info blocks exist (e.g., en, es, fr). + type: string + schema_version: 1 + max_alerts: + type: string + default_value: Infinity + title: Maximum Alerts + optional: true + help_text: + properties: + help_text: Maximum number of alerts to display simultaneously. + type: number + schema_version: 1 + mode: + type: string + default_value: production + title: Mode + optional: true + help_text: + properties: + help_text: Select the operation mode for the app. + options: + - label: Production + value: production + - label: Demo + value: demo + - label: Test + value: test + type: select + schema_version: 1 + mute_sound: + type: string + default_value: 'false' + title: Mute Sound + optional: true + help_text: + properties: + help_text: Mute audio alerts from CAP resources. Not available for Screenly Anywhere. + type: boolean + schema_version: 1 + offline_mode: + type: string + default_value: 'false' + title: Offline Mode + optional: true + help_text: + properties: + help_text: When enabled, avoid network fetches and use cached data. + type: boolean + schema_version: 1 + refresh_interval: + type: string + default_value: '5' + title: Refresh Interval (minutes) + optional: true + help_text: + properties: + help_text: Time in minutes between feed updates. + type: number + schema_version: 1 diff --git a/edge-apps/cap-alerting/src/fetcher.test.ts b/edge-apps/cap-alerting/src/fetcher.test.ts new file mode 100644 index 000000000..0b1383055 --- /dev/null +++ b/edge-apps/cap-alerting/src/fetcher.test.ts @@ -0,0 +1,425 @@ +import { describe, it, expect, beforeEach, mock } from 'bun:test' +import { CAPFetcher } from './fetcher' + +// Mock the @screenly/edge-apps module +const mockGetCorsProxyUrl = mock() +const mockIsAnywhereScreen = mock() + +mock.module('@screenly/edge-apps', () => ({ + getCorsProxyUrl: () => mockGetCorsProxyUrl(), + isAnywhereScreen: () => mockIsAnywhereScreen(), + setupTheme: () => {}, + signalReady: () => {}, + getMetadata: () => ({}), + getTags: () => [], + getSettings: () => ({}), +})) + +// Mock localStorage +const localStorageMock = (() => { + let store: Record = {} + + return { + getItem: (key: string) => store[key] || null, + setItem: (key: string, value: string) => { + store[key] = value + }, + removeItem: (key: string) => { + delete store[key] + }, + clear: () => { + store = {} + }, + } +})() + +// Mock fetch +const mockFetch = mock() + +describe('CAPFetcher', () => { + beforeEach(() => { + // Setup mocks + global.localStorage = localStorageMock as unknown + global.fetch = mockFetch as unknown + + // Clear localStorage + localStorageMock.clear() + + // Reset mocks + mockFetch.mockReset() + mockGetCorsProxyUrl.mockReset() + mockIsAnywhereScreen.mockReset() + + // Default mock implementations + mockGetCorsProxyUrl.mockReturnValue('https://cors-proxy.example.com') + mockIsAnywhereScreen.mockReturnValue(false) + }) + + describe('Test Mode', () => { + it('should fetch test data from static/test.cap', async () => { + const testData = + 'TEST' + + mockFetch.mockResolvedValueOnce({ + ok: true, + text: async () => testData, + }) + + const fetcher = new CAPFetcher({ + testMode: true, + demoMode: false, + feedUrl: 'https://example.com/feed.xml', + offlineMode: false, + }) + + const result = await fetcher.fetch() + + expect(result).toBe(testData) + expect(mockFetch.mock.calls[0][0]).toBe('static/test.cap') + }) + + it('should return null if test file not found', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 404, + }) + + const fetcher = new CAPFetcher({ + testMode: true, + demoMode: false, + feedUrl: 'https://example.com/feed.xml', + offlineMode: false, + }) + + const result = await fetcher.fetch() + + expect(result).toBeNull() + }) + + it('should handle fetch errors in test mode', async () => { + mockFetch.mockRejectedValueOnce(new Error('Network error')) + + const fetcher = new CAPFetcher({ + testMode: true, + demoMode: false, + feedUrl: 'https://example.com/feed.xml', + offlineMode: false, + }) + + const result = await fetcher.fetch() + + expect(result).toBeNull() + }) + }) + + describe('Demo Mode', () => { + it('should fetch random demo file on local screen', async () => { + const demoData = + 'DEMO' + + mockFetch.mockResolvedValueOnce({ + ok: true, + text: async () => demoData, + }) + + mockIsAnywhereScreen.mockReturnValueOnce(false) + + const fetcher = new CAPFetcher({ + testMode: false, + demoMode: true, + feedUrl: '', + offlineMode: false, + }) + + const result = await fetcher.fetch() + + expect(result).toBe(demoData) + // Should fetch from static directory + const url = mockFetch.mock.calls[0][0] as string + expect(url).toMatch(/^static\/demo-/) + }) + + it('should fetch from remote URL on Anywhere screen', async () => { + const demoData = + 'REMOTE-DEMO' + + mockFetch.mockResolvedValueOnce({ + ok: true, + text: async () => demoData, + }) + + mockIsAnywhereScreen.mockReturnValueOnce(true) + + const fetcher = new CAPFetcher({ + testMode: false, + demoMode: true, + feedUrl: '', + offlineMode: false, + }) + + const result = await fetcher.fetch() + + expect(result).toBe(demoData) + // Should fetch from GitHub remote + const url = mockFetch.mock.calls[0][0] as string + expect(url).toContain( + 'https://raw.githubusercontent.com/Screenly/Playground', + ) + }) + + it('should return null if demo file not found', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 404, + }) + + mockIsAnywhereScreen.mockReturnValueOnce(false) + + const fetcher = new CAPFetcher({ + testMode: false, + demoMode: true, + feedUrl: '', + offlineMode: false, + }) + + const result = await fetcher.fetch() + + expect(result).toBeNull() + }) + + it('should not enter demo mode if feed URL is provided', async () => { + const liveData = + 'LIVE' + + mockFetch.mockResolvedValueOnce({ + ok: true, + text: async () => liveData, + }) + + const fetcher = new CAPFetcher({ + testMode: false, + demoMode: true, + feedUrl: 'https://example.com/feed.xml', + offlineMode: false, + }) + + const result = await fetcher.fetch() + + expect(result).toBe(liveData) + // Should not fetch from static demo files + const url = mockFetch.mock.calls[0][0] as string + expect(url).not.toMatch(/^static\/demo-/) + expect(url).toContain('https://cors-proxy.example.com') + }) + }) + + describe('Live Mode', () => { + it('should fetch live data with CORS proxy', async () => { + const liveData = + 'LIVE' + + mockFetch.mockResolvedValueOnce({ + ok: true, + text: async () => liveData, + }) + + const fetcher = new CAPFetcher({ + testMode: false, + demoMode: false, + feedUrl: 'https://example.com/feed.xml', + offlineMode: false, + }) + + const result = await fetcher.fetch() + + expect(result).toBe(liveData) + expect(mockFetch.mock.calls[0][0]).toBe( + 'https://cors-proxy.example.com/https://example.com/feed.xml', + ) + }) + + it('should cache successful fetches', async () => { + const liveData = + 'CACHED' + + mockFetch.mockResolvedValueOnce({ + ok: true, + text: async () => liveData, + }) + + const fetcher = new CAPFetcher({ + testMode: false, + demoMode: false, + feedUrl: 'https://example.com/feed.xml', + offlineMode: false, + }) + + await fetcher.fetch() + + expect(localStorageMock.getItem('cap_last')).toBe(liveData) + }) + + it('should return cached data on fetch failure', async () => { + const cachedData = + 'CACHED' + + // Set up cache + localStorageMock.setItem('cap_last', cachedData) + + // Mock fetch to fail + mockFetch.mockRejectedValueOnce(new Error('Network error')) + + const fetcher = new CAPFetcher({ + testMode: false, + demoMode: false, + feedUrl: 'https://example.com/feed.xml', + offlineMode: false, + }) + + const result = await fetcher.fetch() + + expect(result).toBe(cachedData) + }) + + it('should return null if fetch fails and no cache exists', async () => { + mockFetch.mockRejectedValueOnce(new Error('Network error')) + + const fetcher = new CAPFetcher({ + testMode: false, + demoMode: false, + feedUrl: 'https://example.com/feed.xml', + offlineMode: false, + }) + + const result = await fetcher.fetch() + + expect(result).toBeNull() + }) + + it('should handle HTTP errors', async () => { + const cachedData = + 'CACHED' + + // Set up cache + localStorageMock.setItem('cap_last', cachedData) + + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 404, + }) + + const fetcher = new CAPFetcher({ + testMode: false, + demoMode: false, + feedUrl: 'https://example.com/feed.xml', + offlineMode: false, + }) + + const result = await fetcher.fetch() + + // Should return cached data + expect(result).toBe(cachedData) + }) + + it('should not use CORS proxy for non-HTTP URLs', async () => { + const liveData = + 'LOCAL' + + mockFetch.mockResolvedValueOnce({ + ok: true, + text: async () => liveData, + }) + + const fetcher = new CAPFetcher({ + testMode: false, + demoMode: false, + feedUrl: 'file:///local/path/feed.xml', + offlineMode: false, + }) + + await fetcher.fetch() + + // Should not add CORS proxy for non-HTTP URLs + expect(mockFetch.mock.calls[0][0]).toBe('file:///local/path/feed.xml') + }) + }) + + describe('Offline Mode', () => { + it('should return cached data in offline mode', async () => { + const cachedData = + 'OFFLINE' + + // Set up cache + localStorageMock.setItem('cap_last', cachedData) + + const fetcher = new CAPFetcher({ + testMode: false, + demoMode: false, + feedUrl: 'https://example.com/feed.xml', + offlineMode: true, + }) + + const result = await fetcher.fetch() + + expect(result).toBe(cachedData) + // Should not attempt any fetch + expect(mockFetch.mock.calls.length).toBe(0) + }) + + it('should return null in offline mode with no cache', async () => { + const fetcher = new CAPFetcher({ + testMode: false, + demoMode: false, + feedUrl: 'https://example.com/feed.xml', + offlineMode: true, + }) + + const result = await fetcher.fetch() + + expect(result).toBeNull() + }) + }) + + describe('Edge Cases', () => { + it('should handle missing feed URL in live mode', async () => { + const cachedData = '' + + localStorageMock.setItem('cap_last', cachedData) + + const fetcher = new CAPFetcher({ + testMode: false, + demoMode: false, + feedUrl: '', + offlineMode: false, + }) + + const result = await fetcher.fetch() + + expect(result).toBe(cachedData) + }) + + it('should prioritize testMode over demoMode', async () => { + const testData = + 'TEST' + + mockFetch.mockResolvedValueOnce({ + ok: true, + text: async () => testData, + }) + + const fetcher = new CAPFetcher({ + testMode: true, + demoMode: true, + feedUrl: 'https://example.com/feed.xml', + offlineMode: false, + }) + + const result = await fetcher.fetch() + + expect(result).toBe(testData) + // Should fetch from test file, not demo file + const url = mockFetch.mock.calls[0][0] as string + expect(url).toBe('static/test.cap') + }) + }) +}) diff --git a/edge-apps/cap-alerting/src/fetcher.ts b/edge-apps/cap-alerting/src/fetcher.ts new file mode 100644 index 000000000..52968673a --- /dev/null +++ b/edge-apps/cap-alerting/src/fetcher.ts @@ -0,0 +1,121 @@ +import { getCorsProxyUrl, isAnywhereScreen } from '@screenly/edge-apps' + +const DEMO_BASE_URL = + 'https://raw.githubusercontent.com/Screenly/Playground/refs/heads/master/edge-apps/cap-alerting' + +export interface FetcherConfig { + testMode: boolean + demoMode: boolean + feedUrl: string + offlineMode: boolean +} + +/** + * Fetches CAP (Common Alerting Protocol) data based on the app mode. + * Supports test mode (static test file), demo mode (rotating demo files), + * and production mode (live feed with fallback to localStorage cache). + */ +export class CAPFetcher { + private config: FetcherConfig + + constructor(config: FetcherConfig) { + this.config = config + } + + /** + * Fetch CAP data based on configured mode + */ + async fetch(): Promise { + if (this.config.testMode) { + return this.fetchTestData() + } + + if (this.config.demoMode && !this.config.feedUrl) { + return this.fetchDemoData() + } + + return this.fetchLiveData() + } + + /** + * Fetch test data from static test file + */ + private async fetchTestData(): Promise { + try { + const resp = await fetch('static/test.cap') + return resp.ok ? await resp.text() : null + } catch (err) { + console.warn('Failed to load test data:', err) + return null + } + } + + /** + * Fetch demo data - randomly selects from available demo files + */ + private async fetchDemoData(): Promise { + const localDemoFiles = [ + 'static/demo-1-tornado.cap', + 'static/demo-2-fire.cap', + 'static/demo-3-flood.cap', + 'static/demo-4-earthquake.cap', + 'static/demo-5-hazmat.cap', + 'static/demo-6-shooter.cap', + ] + + const remoteDemoFiles = localDemoFiles.map( + (file) => `${DEMO_BASE_URL}/${file}`, + ) + + const demoFiles = isAnywhereScreen() ? remoteDemoFiles : localDemoFiles + const randomFile = demoFiles[Math.floor(Math.random() * demoFiles.length)] + + try { + const resp = await fetch(randomFile) + return resp.ok ? await resp.text() : null + } catch (err) { + console.warn('Failed to load demo data:', err) + return null + } + } + + /** + * Fetch live CAP data with fallback to localStorage cache + */ + private async fetchLiveData(): Promise { + // If in offline mode, return cached data + if (this.config.offlineMode) { + return localStorage.getItem('cap_last') + } + + // No feed URL configured + if (!this.config.feedUrl) { + console.warn('No feed URL configured') + return localStorage.getItem('cap_last') + } + + try { + const cors = getCorsProxyUrl() + let url = this.config.feedUrl + + // Add CORS proxy for HTTP(S) URLs + if (this.config.feedUrl.match(/^https?:/)) { + url = `${cors}/${this.config.feedUrl}` + } + + const response = await fetch(url) + if (!response.ok) { + throw new Error(`HTTP ${response.status}`) + } + + const text = await response.text() + // Cache the successful fetch + localStorage.setItem('cap_last', text) + return text + } catch (err) { + console.warn('CAP fetch failed, falling back to cache:', err) + // Return cached data on failure + return localStorage.getItem('cap_last') + } + } +} diff --git a/edge-apps/cap-alerting/src/input.css b/edge-apps/cap-alerting/src/input.css new file mode 100644 index 000000000..1458625cc --- /dev/null +++ b/edge-apps/cap-alerting/src/input.css @@ -0,0 +1,160 @@ +@import 'tailwindcss'; + +/* Modern Digital Signage Design - Viewport-based sizing */ + +/* Custom animations */ +@keyframes pulse-banner { + 0%, + 100% { + opacity: 1; + transform: scale(1); + } + 50% { + opacity: 0.95; + transform: scale(1.005); + } +} + +.status-actual-pulse { + animation: pulse-banner 1.5s ease-in-out infinite; +} + +@media (prefers-reduced-motion: reduce) { + .status-actual-pulse { + animation: none; + } +} + +.status-stripe-pattern { + background-image: repeating-linear-gradient( + 135deg, + transparent, + transparent 2rem, + rgba(255, 255, 255, 0.08) 2rem, + rgba(255, 255, 255, 0.08) 4rem + ); +} + +/* Viewport-based typography optimized for digital signage readability */ +/* Balanced for visibility and fitting content in viewport */ + +.status-banner-text { + font-size: clamp(1.25rem, 3.5vmin, 6rem); + line-height: 1.1; + text-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); +} + +.event-title-text { + font-size: clamp(2rem, 5vmin, 10rem); + line-height: 1; + text-shadow: 0 2px 8px rgba(220, 38, 38, 0.15); + letter-spacing: -0.02em; +} + +.severity-badge-text { + font-size: clamp(1rem, 2vmin, 4rem); + line-height: 1.1; + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.15); +} + +.headline-text { + font-size: clamp(1.5rem, 3.5vmin, 7rem); + line-height: 1.2; + letter-spacing: -0.01em; +} + +.body-text { + font-size: clamp(1.125rem, 2.5vmin, 5rem); + line-height: 1.35; +} + +.instruction-text { + font-size: clamp(1.25rem, 3vmin, 6rem); + line-height: 1.4; +} + +/* Enhanced keyword highlighting for quick scanning */ +.instruction-text strong { + font-weight: 900; + color: rgb(153, 27, 27); +} + +/* Modern card styling */ +.alert-card { + box-shadow: + 0 8px 32px rgba(0, 0, 0, 0.12), + 0 2px 8px rgba(0, 0, 0, 0.08); +} + +/* Modern instruction box */ +.instruction-box { + box-shadow: + inset 4px 0 0 0 rgb(234, 179, 8), + 0 4px 12px rgba(0, 0, 0, 0.05); + backdrop-filter: blur(10px); + background: linear-gradient( + 135deg, + rgb(254, 249, 195) 0%, + rgb(254, 243, 199) 100% + ); +} + +/* Severity badge modern styling */ +.severity-badge { + box-shadow: 0 4px 12px rgba(249, 115, 22, 0.25); + background: linear-gradient( + 135deg, + rgb(249, 115, 22) 0%, + rgb(234, 88, 12) 100% + ); +} + +/* Status banner gradients */ +.status-banner-blue { + background: linear-gradient( + 135deg, + rgb(37, 99, 235) 0%, + rgb(29, 78, 216) 100% + ); +} + +.status-banner-red { + background: linear-gradient( + 135deg, + rgb(220, 38, 38) 0%, + rgb(185, 28, 28) 100% + ); +} + +.status-banner-orange { + background: linear-gradient( + 135deg, + rgb(249, 115, 22) 0%, + rgb(234, 88, 12) 100% + ); +} + +.status-banner-gray { + background: linear-gradient(135deg, rgb(75, 85, 99) 0%, rgb(55, 65, 81) 100%); +} + +/* Responsive adjustments for common signage resolutions */ +@media (min-width: 1920px) and (min-height: 1080px) { + .instruction-text { + font-size: clamp(2rem, 3.5vmin, 7rem); + } + + .event-title-text { + font-size: clamp(3rem, 6vmin, 12rem); + } +} + +@media (min-width: 3840px) and (min-height: 2160px) { + .instruction-text { + font-size: clamp(2.5rem, 3.5vmin, 7rem); + } + + .event-title-text { + font-size: clamp(4rem, 6vmin, 12rem); + } +} diff --git a/edge-apps/cap-alerting/src/main.ts b/edge-apps/cap-alerting/src/main.ts new file mode 100644 index 000000000..e2f91c242 --- /dev/null +++ b/edge-apps/cap-alerting/src/main.ts @@ -0,0 +1,273 @@ +import { + setupTheme, + signalReady, + getMetadata, + getTags, + getSettings, + getSettingWithDefault, +} from '@screenly/edge-apps' + +import { CAPAlert, CAPMode } from './types/cap' +import { parseCap } from './parser' +import { CAPFetcher } from './fetcher' +import { getNearestExit, splitIntoSentences, proxyUrl } from './utils' +import { highlightKeywords } from './render' + +function renderAlerts( + alerts: CAPAlert[], + nearestExit: string | undefined, + lang: string, + maxAlerts: number, + playAudio: boolean, +): void { + const container = document.getElementById('alerts') + if (!container) return + + container.innerHTML = '' + const slice = maxAlerts === Infinity ? alerts : alerts.slice(0, maxAlerts) + + slice.forEach((alert) => { + const info = alert.infos.find((i) => i.language === lang) ?? alert.infos[0] + if (!info) return + + const card = document.createElement('div') + card.className = + 'alert-card w-full h-full bg-white flex flex-col overflow-y-auto' + + if (alert.status) { + const statusBanner = document.createElement('div') + + let baseClasses = + 'w-full text-center font-black uppercase tracking-[0.15em] flex-shrink-0 status-stripe-pattern status-banner-text py-[2.5vh] px-[4vw] text-white ' + + let statusText = alert.status.toUpperCase() + if (alert.status === 'Exercise') { + statusText = 'EXERCISE - THIS IS A DRILL' + baseClasses += 'status-banner-blue' + } else if (alert.status === 'Test') { + statusText = 'TEST - NOT A REAL EMERGENCY' + baseClasses += 'status-banner-gray' + } else if (alert.status === 'System') { + statusText = 'SYSTEM TEST' + baseClasses += 'status-banner-gray' + } else if (alert.status === 'Draft') { + statusText = 'DRAFT - NOT ACTIVE' + baseClasses += 'status-banner-orange' + } else if (alert.status === 'Actual') { + statusText = 'ACTUAL EMERGENCY' + baseClasses += 'status-banner-red status-actual-pulse' + } + + statusBanner.className = baseClasses + statusBanner.textContent = statusText + card.appendChild(statusBanner) + } + + const headerRow = document.createElement('div') + headerRow.className = + 'flex items-center justify-between gap-[2vw] mx-[5vw] mt-[2vh] mb-[1.5vh]' + + const header = document.createElement('h2') + header.className = + 'text-red-600 font-black uppercase leading-none event-title-text' + header.textContent = info.event || alert.identifier + headerRow.appendChild(header) + + const meta = document.createElement('div') + meta.className = + 'severity-badge inline-block text-white rounded-xl font-black uppercase tracking-wider flex-shrink-0 severity-badge-text px-[4vw] py-[2vh]' + meta.textContent = + `${info.urgency || ''} ${info.severity || ''} ${info.certainty || ''}`.trim() + headerRow.appendChild(meta) + + card.appendChild(headerRow) + + if (info.headline) { + const headline = document.createElement('h3') + headline.className = + 'font-extrabold leading-tight flex-shrink-0 headline-text text-gray-900 ' + headline.className += 'mx-[5vw] mb-[1.5vh]' + headline.textContent = info.headline + card.appendChild(headline) + } + + if (info.description) { + const desc = document.createElement('p') + desc.className = 'font-semibold leading-snug body-text text-gray-800 ' + desc.className += 'mx-[5vw] mb-[2vh]' + desc.textContent = info.description + card.appendChild(desc) + } + + if (info.instruction) { + let instr = info.instruction + if (nearestExit) { + if ( + instr.includes('{{closest_exit}}') || + instr.includes('[[closest_exit]]') + ) { + instr = instr + .replace(/\{\{closest_exit\}\}/g, nearestExit) + .replace(/\[\[closest_exit\]\]/g, nearestExit) + } else { + instr += `\n\nNearest exit: ${nearestExit}` + } + } + + const instructionBox = document.createElement('div') + instructionBox.className = + 'instruction-box rounded-xl flex-shrink-0 px-[4vw] py-[2.5vh] mx-[5vw] mb-[2vh]' + + const sentences = splitIntoSentences(instr) + + if (sentences.length > 2) { + const ul = document.createElement('ul') + ul.className = 'instruction-text leading-snug text-gray-900' + sentences.forEach((sentence) => { + const li = document.createElement('li') + li.className = 'mb-[1vh] flex items-start' + const bullet = document.createElement('span') + bullet.className = 'mr-[2vw] flex-shrink-0 font-black text-orange-600' + bullet.textContent = '•' + const content = document.createElement('span') + content.className = 'flex-1 font-extrabold' + content.innerHTML = highlightKeywords(sentence) + li.appendChild(bullet) + li.appendChild(content) + ul.appendChild(li) + }) + instructionBox.appendChild(ul) + } else { + const instP = document.createElement('p') + instP.className = + 'instruction-text font-extrabold leading-snug whitespace-pre-line text-gray-900' + instP.innerHTML = highlightKeywords(instr) + instructionBox.appendChild(instP) + } + + card.appendChild(instructionBox) + } + + info.resources.forEach((res) => { + if (res.mimeType && res.mimeType.startsWith('image')) { + const imgWrapper = document.createElement('div') + imgWrapper.className = 'mx-[5vw] my-[2vh]' + const img = document.createElement('img') + img.className = + 'max-w-full max-h-[20vh] rounded-2xl object-contain shadow-lg' + img.src = proxyUrl(res.url) + imgWrapper.appendChild(img) + card.appendChild(imgWrapper) + } else if ( + res.mimeType && + res.mimeType.startsWith('audio') && + playAudio + ) { + const proxiedUrl = proxyUrl(res.url) + console.log( + 'Creating audio player for:', + res.url, + '-> proxied:', + proxiedUrl, + 'MIME type:', + res.mimeType, + ) + const audio = document.createElement('audio') + audio.className = 'w-[90vw] flex-shrink-0 mx-[5vw] my-[2vh] rounded-xl' + audio.style.height = 'clamp(3rem, 5vh, 10rem)' + audio.src = proxiedUrl + audio.controls = true + audio.autoplay = true + audio.preload = 'auto' + audio.crossOrigin = 'anonymous' + card.appendChild(audio) + + audio.addEventListener('loadeddata', () => { + console.log('Audio loaded successfully:', proxiedUrl) + }) + + audio.addEventListener('error', (e) => { + console.error('Audio load error:', proxiedUrl, e) + }) + + audio.play().catch((err) => { + console.warn( + 'Autoplay blocked or failed:', + err.message, + '- Click play button to start audio', + ) + }) + } + }) + + container.appendChild(card) + }) +} + +export async function startApp(): Promise { + setupTheme() + + let settings: Partial> = {} + let metadata: Partial> = {} + + try { + settings = getSettings() + localStorage.setItem('screenly_settings', JSON.stringify(settings)) + } catch { + const cached = localStorage.getItem('screenly_settings') + settings = cached + ? (JSON.parse(cached) as Partial>) + : {} + } + + try { + metadata = getMetadata() + localStorage.setItem('screenly_metadata', JSON.stringify(metadata)) + } catch { + const cachedMeta = localStorage.getItem('screenly_metadata') + metadata = cachedMeta + ? (JSON.parse(cachedMeta) as Partial>) + : {} + } + + const feedUrl = getSettingWithDefault('cap_feed_url', '') + const interval = getSettingWithDefault('refresh_interval', 5) + const lang = getSettingWithDefault('language', 'en') + const maxAlerts = getSettingWithDefault('max_alerts', Infinity) + const playAudio = !getSettingWithDefault('mute_sound', false) + const offlineMode = getSettingWithDefault('offline_mode', false) + const mode = getSettingWithDefault('mode', 'production') + const testMode = mode === 'test' + const demoMode = mode === 'demo' + + const tags: string[] = getTags() + const nearestExit = getNearestExit(tags) + + const fetcher = new CAPFetcher({ + testMode, + demoMode, + feedUrl, + offlineMode, + }) + + async function update() { + const xml = await fetcher.fetch() + if (xml) { + const alerts = parseCap(xml) + renderAlerts(alerts, nearestExit, lang, maxAlerts, playAudio) + } else { + console.warn('No CAP data available') + } + } + + await update() + signalReady() + + setInterval(update, interval * 60 * 1000) +} + +if (typeof window !== 'undefined' && typeof document !== 'undefined') { + window.onload = function () { + startApp() + } +} diff --git a/edge-apps/cap-alerting/src/parser.test.ts b/edge-apps/cap-alerting/src/parser.test.ts new file mode 100644 index 000000000..bb3df531a --- /dev/null +++ b/edge-apps/cap-alerting/src/parser.test.ts @@ -0,0 +1,1840 @@ +import { describe, it, expect, mock } from 'bun:test' + +const mockGetSettings = mock() +const mockGetMetadata = mock() +const mockGetCorsProxyUrl = mock() +const mockSetupTheme = mock() +const mockSignalReady = mock() +const mockGetTags = mock() + +mock.module('@screenly/edge-apps', () => ({ + getSettings: () => mockGetSettings(), + getMetadata: () => mockGetMetadata(), + getCorsProxyUrl: () => mockGetCorsProxyUrl(), + setupTheme: () => mockSetupTheme(), + signalReady: () => mockSignalReady(), + getTags: () => mockGetTags(), +})) + +import { parseCap } from './parser' +import { XMLParser } from 'fast-xml-parser' + +describe('CAP v1.2 Parser', () => { + describe('Basic Alert Structure', () => { + it('should parse a minimal valid CAP alert', () => { + const xml = ` + + 43b080713727 + hsas@dhs.gov + 2003-04-02T14:39:01-05:00 + Actual + Alert + Public +` + + const alerts = parseCap(xml) + expect(alerts).toHaveLength(1) + expect(alerts[0].identifier).toBe('43b080713727') + expect(alerts[0].sender).toBe('hsas@dhs.gov') + expect(alerts[0].sent).toBe('2003-04-02T14:39:01-05:00') + expect(alerts[0].status).toBe('Actual') + expect(alerts[0].msgType).toBe('Alert') + expect(alerts[0].scope).toBe('Public') + }) + + it('should parse all alert-level required fields', () => { + const xml = ` + + KSTO1055887203 + KSTO@NWS.NOAA.GOV + 2003-06-17T14:57:00-07:00 + Actual + Alert + Public +` + + const alerts = parseCap(xml) + expect(alerts[0]).toMatchObject({ + identifier: 'KSTO1055887203', + sender: 'KSTO@NWS.NOAA.GOV', + sent: '2003-06-17T14:57:00-07:00', + status: 'Actual', + msgType: 'Alert', + scope: 'Public', + }) + }) + + it('should parse alert with optional fields', () => { + const xml = ` + + KSTO1055887203 + KSTO@NWS.NOAA.GOV + 2003-06-17T14:57:00-07:00 + Actual + Update + Weather Service + Public + This is a test note + KSTO@NWS.NOAA.GOV,KSTO1055887200,2003-06-17T14:00:00-07:00 + incident1,incident2 +` + + const alerts = parseCap(xml) + expect(alerts[0].source).toBe('Weather Service') + expect(alerts[0].note).toBe('This is a test note') + expect(alerts[0].references).toBe( + 'KSTO@NWS.NOAA.GOV,KSTO1055887200,2003-06-17T14:00:00-07:00', + ) + expect(alerts[0].incidents).toBe('incident1,incident2') + }) + + it('should parse alert with Restricted scope', () => { + const xml = ` + + TEST123 + test@example.com + 2024-01-15T10:00:00-00:00 + Actual + Alert + Restricted + For emergency services only +` + + const alerts = parseCap(xml) + expect(alerts[0].scope).toBe('Restricted') + expect(alerts[0].restriction).toBe('For emergency services only') + }) + + it('should parse alert with Private scope and addresses', () => { + const xml = ` + + TEST124 + test@example.com + 2024-01-15T10:00:00-00:00 + Actual + Alert + Private + user1@example.com user2@example.com +` + + const alerts = parseCap(xml) + expect(alerts[0].scope).toBe('Private') + expect(alerts[0].addresses).toBe('user1@example.com user2@example.com') + }) + }) + + describe('Alert Status Values', () => { + const statuses = ['Actual', 'Exercise', 'System', 'Test', 'Draft'] + + statuses.forEach((status) => { + it(`should parse alert with status: ${status}`, () => { + const xml = ` + + TEST-${status} + test@example.com + 2024-01-15T10:00:00-00:00 + ${status} + Alert + Public +` + + const alerts = parseCap(xml) + expect(alerts[0].status).toBe(status) + }) + }) + }) + + describe('Message Type Values', () => { + const msgTypes = ['Alert', 'Update', 'Cancel', 'Ack', 'Error'] + + msgTypes.forEach((msgType) => { + it(`should parse alert with msgType: ${msgType}`, () => { + const xml = ` + + TEST-${msgType} + test@example.com + 2024-01-15T10:00:00-00:00 + Actual + ${msgType} + Public +` + + const alerts = parseCap(xml) + expect(alerts[0].msgType).toBe(msgType) + }) + }) + }) + + describe('Info Element Structure', () => { + it('should parse info with all required fields', () => { + const xml = ` + + KSTO1055887203 + KSTO@NWS.NOAA.GOV + 2003-06-17T14:57:00-07:00 + Actual + Alert + Public + + Met + SEVERE THUNDERSTORM + Immediate + Severe + Observed + +` + + const alerts = parseCap(xml) + expect(alerts[0].infos).toHaveLength(1) + expect(alerts[0].infos[0]).toMatchObject({ + category: 'Met', + event: 'SEVERE THUNDERSTORM', + urgency: 'Immediate', + severity: 'Severe', + certainty: 'Observed', + }) + }) + + it('should parse info with all optional fields', () => { + const xml = ` + + TEST125 + test@example.com + 2024-01-15T10:00:00-00:00 + Actual + Alert + Public + + en-US + Fire + WILDFIRE + Evacuate + Immediate + Extreme + Observed + General public in affected areas + 2024-01-15T10:00:00-00:00 + 2024-01-15T10:30:00-00:00 + 2024-01-15T22:00:00-00:00 + National Weather Service + Wildfire Warning Issued + A rapidly spreading wildfire has been detected. + Evacuate immediately to designated shelters. + http://www.example.com/wildfire + 1-800-EMERGENCY + +` + + const alerts = parseCap(xml) + const info = alerts[0].infos[0] + expect(info.language).toBe('en-US') + expect(info.audience).toBe('General public in affected areas') + expect(info.effective).toBe('2024-01-15T10:00:00-00:00') + expect(info.onset).toBe('2024-01-15T10:30:00-00:00') + expect(info.expires).toBe('2024-01-15T22:00:00-00:00') + expect(info.senderName).toBe('National Weather Service') + expect(info.headline).toBe('Wildfire Warning Issued') + expect(info.description).toBe( + 'A rapidly spreading wildfire has been detected.', + ) + expect(info.instruction).toBe( + 'Evacuate immediately to designated shelters.', + ) + expect(info.web).toBe('http://www.example.com/wildfire') + expect(info.contact).toBe('1-800-EMERGENCY') + }) + + it('should parse multiple categories', () => { + const xml = ` + + TEST126 + test@example.com + 2024-01-15T10:00:00-00:00 + Actual + Alert + Public + + Met + Geo + STORM SURGE + Immediate + Severe + Likely + +` + + const alerts = parseCap(xml) + const category = alerts[0].infos[0].category + expect(Array.isArray(category) ? category : [category]).toContain('Met') + expect(Array.isArray(category) ? category : [category]).toContain('Geo') + }) + }) + + describe('Category Values', () => { + const categories = [ + 'Geo', + 'Met', + 'Safety', + 'Security', + 'Rescue', + 'Fire', + 'Health', + 'Env', + 'Transport', + 'Infra', + 'CBRNE', + 'Other', + ] + + categories.forEach((category) => { + it(`should parse category: ${category}`, () => { + const xml = ` + + TEST-CAT-${category} + test@example.com + 2024-01-15T10:00:00-00:00 + Actual + Alert + Public + + ${category} + Test Event + Immediate + Moderate + Possible + +` + + const alerts = parseCap(xml) + expect(alerts[0].infos[0].category).toBe(category) + }) + }) + }) + + describe('ResponseType Values', () => { + const responseTypes = [ + 'Shelter', + 'Evacuate', + 'Prepare', + 'Execute', + 'Avoid', + 'Monitor', + 'Assess', + 'AllClear', + 'None', + ] + + responseTypes.forEach((responseType) => { + it(`should parse responseType: ${responseType}`, () => { + const xml = ` + + TEST-RT-${responseType} + test@example.com + 2024-01-15T10:00:00-00:00 + Actual + Alert + Public + + Safety + Test Event + ${responseType} + Immediate + Moderate + Possible + +` + + const alerts = parseCap(xml) + expect(alerts[0].infos[0].responseType).toBe(responseType) + }) + }) + }) + + describe('Urgency Values', () => { + const urgencies = ['Immediate', 'Expected', 'Future', 'Past', 'Unknown'] + + urgencies.forEach((urgency) => { + it(`should parse urgency: ${urgency}`, () => { + const xml = ` + + TEST-URG-${urgency} + test@example.com + 2024-01-15T10:00:00-00:00 + Actual + Alert + Public + + Safety + Test Event + ${urgency} + Moderate + Possible + +` + + const alerts = parseCap(xml) + expect(alerts[0].infos[0].urgency).toBe(urgency) + }) + }) + }) + + describe('Severity Values', () => { + const severities = ['Extreme', 'Severe', 'Moderate', 'Minor', 'Unknown'] + + severities.forEach((severity) => { + it(`should parse severity: ${severity}`, () => { + const xml = ` + + TEST-SEV-${severity} + test@example.com + 2024-01-15T10:00:00-00:00 + Actual + Alert + Public + + Safety + Test Event + Immediate + ${severity} + Possible + +` + + const alerts = parseCap(xml) + expect(alerts[0].infos[0].severity).toBe(severity) + }) + }) + }) + + describe('Certainty Values', () => { + const certainties = [ + 'Observed', + 'Likely', + 'Possible', + 'Unlikely', + 'Unknown', + ] + + certainties.forEach((certainty) => { + it(`should parse certainty: ${certainty}`, () => { + const xml = ` + + TEST-CERT-${certainty} + test@example.com + 2024-01-15T10:00:00-00:00 + Actual + Alert + Public + + Safety + Test Event + Immediate + Moderate + ${certainty} + +` + + const alerts = parseCap(xml) + expect(alerts[0].infos[0].certainty).toBe(certainty) + }) + }) + }) + + describe('Resource Element', () => { + it('should parse resource with required fields', () => { + const xml = ` + + TEST127 + test@example.com + 2024-01-15T10:00:00-00:00 + Actual + Alert + Public + + Safety + Test Event + Immediate + Moderate + Possible + + Evacuation Map + image/png + + +` + + const alerts = parseCap(xml) + expect(alerts[0].infos[0].resources).toHaveLength(1) + expect(alerts[0].infos[0].resources[0]).toMatchObject({ + resourceDesc: 'Evacuation Map', + mimeType: 'image/png', + }) + }) + + it('should parse resource with all optional fields', () => { + const xml = ` + + TEST128 + test@example.com + 2024-01-15T10:00:00-00:00 + Actual + Alert + Public + + Safety + Test Event + Immediate + Moderate + Possible + + Evacuation Map + image/png + 12345 + http://example.com/map.png + base64encodeddata + SHA-256HASH + + +` + + const alerts = parseCap(xml) + const resource = alerts[0].infos[0].resources[0] + expect(resource.resourceDesc).toBe('Evacuation Map') + expect(resource.mimeType).toBe('image/png') + expect(resource.size).toBe(12345) + expect(resource.uri).toBe('http://example.com/map.png') + expect(resource.derefUri).toBe('base64encodeddata') + expect(resource.digest).toBe('SHA-256HASH') + }) + + it('should parse multiple resources', () => { + const xml = ` + + TEST129 + test@example.com + 2024-01-15T10:00:00-00:00 + Actual + Alert + Public + + Safety + Test Event + Immediate + Moderate + Possible + + Map + image/png + http://example.com/map.png + + + Audio Alert + audio/mp3 + http://example.com/alert.mp3 + + +` + + const alerts = parseCap(xml) + expect(alerts[0].infos[0].resources).toHaveLength(2) + expect(alerts[0].infos[0].resources[0].mimeType).toBe('image/png') + expect(alerts[0].infos[0].resources[1].mimeType).toBe('audio/mp3') + }) + }) + + describe('Area Element', () => { + it('should parse area with required fields', () => { + const xml = ` + + TEST130 + test@example.com + 2024-01-15T10:00:00-00:00 + Actual + Alert + Public + + Met + Severe Storm + Immediate + Severe + Observed + + Downtown Metropolitan Area + + +` + + const alerts = parseCap(xml) + expect(alerts[0].infos[0].areas).toHaveLength(1) + expect(alerts[0].infos[0].areas[0].areaDesc).toBe( + 'Downtown Metropolitan Area', + ) + }) + + it('should parse area with polygon', () => { + const xml = ` + + TEST131 + test@example.com + 2024-01-15T10:00:00-00:00 + Actual + Alert + Public + + Met + Tornado Warning + Immediate + Extreme + Observed + + Storm Path + 38.47,-120.14 38.34,-119.95 38.52,-119.74 38.62,-119.89 38.47,-120.14 + + +` + + const alerts = parseCap(xml) + const area = alerts[0].infos[0].areas[0] + expect(area.areaDesc).toBe('Storm Path') + expect(area.polygon).toBe( + '38.47,-120.14 38.34,-119.95 38.52,-119.74 38.62,-119.89 38.47,-120.14', + ) + }) + + it('should parse area with multiple polygons', () => { + const xml = ` + + TEST132 + test@example.com + 2024-01-15T10:00:00-00:00 + Actual + Alert + Public + + Fire + Fire Zone + Immediate + Extreme + Observed + + Multiple Fire Zones + 38.47,-120.14 38.34,-119.95 38.52,-119.74 38.47,-120.14 + 39.00,-121.00 39.10,-120.90 39.20,-121.10 39.00,-121.00 + + +` + + const alerts = parseCap(xml) + const area = alerts[0].infos[0].areas[0] + const polygons = Array.isArray(area.polygon) + ? area.polygon + : [area.polygon] + expect(polygons).toHaveLength(2) + }) + + it('should parse area with circle', () => { + const xml = ` + + TEST133 + test@example.com + 2024-01-15T10:00:00-00:00 + Actual + Alert + Public + + CBRNE + Chemical Spill + Immediate + Severe + Observed + + Evacuation Zone + 38.5,-120.5 5.0 + + +` + + const alerts = parseCap(xml) + const area = alerts[0].infos[0].areas[0] + expect(area.circle).toBe('38.5,-120.5 5.0') + }) + + it('should parse area with geocode', () => { + const xml = ` + + TEST134 + test@example.com + 2024-01-15T10:00:00-00:00 + Actual + Alert + Public + + Met + Flood Warning + Expected + Moderate + Likely + + County Area + + FIPS6 + 006017 + + + +` + + const alerts = parseCap(xml) + const area = alerts[0].infos[0].areas[0] + expect(area.geocode).toBeDefined() + expect(area.geocode.valueName).toBe('FIPS6') + expect(area.geocode.value).toBe(6017) + }) + + it('should parse area with altitude and ceiling', () => { + const xml = ` + + TEST135 + test@example.com + 2024-01-15T10:00:00-00:00 + Actual + Alert + Public + + Met + Aviation Alert + Immediate + Moderate + Observed + + Flight Restriction Zone + 1000 + 5000 + + +` + + const alerts = parseCap(xml) + const area = alerts[0].infos[0].areas[0] + expect(area.altitude).toBe(1000) + expect(area.ceiling).toBe(5000) + }) + + it('should parse multiple areas', () => { + const xml = ` + + TEST136 + test@example.com + 2024-01-15T10:00:00-00:00 + Actual + Alert + Public + + Met + Multi-Area Warning + Immediate + Severe + Observed + + Area 1 + 38.47,-120.14 38.34,-119.95 38.52,-119.74 38.47,-120.14 + + + Area 2 + 39.0,-121.0 10.0 + + +` + + const alerts = parseCap(xml) + expect(alerts[0].infos[0].areas).toHaveLength(2) + expect(alerts[0].infos[0].areas[0].areaDesc).toBe('Area 1') + expect(alerts[0].infos[0].areas[1].areaDesc).toBe('Area 2') + }) + }) + + describe('Multiple Info Blocks', () => { + it('should parse multiple info blocks with different languages', () => { + const xml = ` + + TEST137 + test@example.com + 2024-01-15T10:00:00-00:00 + Actual + Alert + Public + + en-US + Safety + Emergency Alert + Immediate + Severe + Observed + Emergency Alert + This is an emergency alert in English. + + + es-US + Safety + Alerta de Emergencia + Immediate + Severe + Observed + Alerta de Emergencia + Esta es una alerta de emergencia en español. + +` + + const alerts = parseCap(xml) + expect(alerts[0].infos).toHaveLength(2) + expect(alerts[0].infos[0].language).toBe('en-US') + expect(alerts[0].infos[1].language).toBe('es-US') + expect(alerts[0].infos[0].description).toContain('English') + expect(alerts[0].infos[1].description).toContain('español') + }) + }) + + describe('EventCode and Parameter', () => { + it('should parse eventCode', () => { + const xml = ` + + TEST138 + test@example.com + 2024-01-15T10:00:00-00:00 + Actual + Alert + Public + + Met + Severe Thunderstorm + Immediate + Severe + Observed + + SAME + SVR + + +` + + const alerts = parseCap(xml) + expect(alerts[0].infos[0].eventCode).toBeDefined() + expect(alerts[0].infos[0].eventCode.valueName).toBe('SAME') + expect(alerts[0].infos[0].eventCode.value).toBe('SVR') + }) + + it('should parse parameter', () => { + const xml = ` + + TEST139 + test@example.com + 2024-01-15T10:00:00-00:00 + Actual + Alert + Public + + Met + Hurricane + Expected + Extreme + Likely + + WindSpeed + 120mph + + +` + + const alerts = parseCap(xml) + expect(alerts[0].infos[0].parameter).toBeDefined() + expect(alerts[0].infos[0].parameter.valueName).toBe('WindSpeed') + expect(alerts[0].infos[0].parameter.value).toBe('120mph') + }) + }) + + describe('Multiple Alerts', () => { + it('should parse multiple alerts in a single feed', () => { + const xml = ` + + + ALERT001 + sender1@example.com + 2024-01-15T10:00:00-00:00 + Actual + Alert + Public + + + ALERT002 + sender2@example.com + 2024-01-15T11:00:00-00:00 + Actual + Update + Public + +` + + const parser = new XMLParser({ + ignoreAttributes: false, + attributeNamePrefix: '@_', + }) + const json = parser.parse(xml) + const alerts = json.feed?.alert + ? Array.isArray(json.feed.alert) + ? json.feed.alert + : [json.feed.alert] + : [] + expect(alerts).toHaveLength(2) + expect(alerts[0].identifier).toBe('ALERT001') + expect(alerts[1].identifier).toBe('ALERT002') + }) + }) + + describe('Edge Cases', () => { + it('should handle empty alert', () => { + const xml = ` + +` + + const alerts = parseCap(xml) + expect(alerts).toHaveLength(1) + expect(alerts[0].identifier).toBe('') + expect(alerts[0].sender).toBe('') + }) + + it('should handle alert with no info blocks', () => { + const xml = ` + + TEST140 + test@example.com + 2024-01-15T10:00:00-00:00 + Actual + Alert + Public +` + + const alerts = parseCap(xml) + expect(alerts[0].infos).toHaveLength(0) + }) + + it('should handle info with no resources', () => { + const xml = ` + + TEST141 + test@example.com + 2024-01-15T10:00:00-00:00 + Actual + Alert + Public + + Safety + Test + Immediate + Moderate + Possible + +` + + const alerts = parseCap(xml) + expect(alerts[0].infos[0].resources).toHaveLength(0) + }) + + it('should handle info with no areas', () => { + const xml = ` + + TEST142 + test@example.com + 2024-01-15T10:00:00-00:00 + Actual + Alert + Public + + Safety + Test + Immediate + Moderate + Possible + +` + + const alerts = parseCap(xml) + expect(alerts[0].infos[0].areas).toHaveLength(0) + }) + }) + + describe('DateTime Formats', () => { + it('should parse ISO 8601 datetime with timezone offset', () => { + const xml = ` + + TEST-DT-001 + test@example.com + 2003-04-02T14:39:01-05:00 + Actual + Alert + Public +` + + const alerts = parseCap(xml) + expect(alerts[0].sent).toBe('2003-04-02T14:39:01-05:00') + }) + + it('should parse ISO 8601 datetime with positive timezone offset', () => { + const xml = ` + + TEST-DT-002 + test@example.com + 2024-01-15T18:30:00+08:00 + Actual + Alert + Public +` + + const alerts = parseCap(xml) + expect(alerts[0].sent).toBe('2024-01-15T18:30:00+08:00') + }) + + it('should parse ISO 8601 datetime with UTC timezone (Z)', () => { + const xml = ` + + TEST-DT-003 + test@example.com + 2024-01-15T12:00:00Z + Actual + Alert + Public +` + + const alerts = parseCap(xml) + expect(alerts[0].sent).toBe('2024-01-15T12:00:00Z') + }) + + it('should parse datetime without seconds', () => { + const xml = ` + + TEST-DT-004 + test@example.com + 2024-01-15T12:00-00:00 + Actual + Alert + Public +` + + const alerts = parseCap(xml) + expect(alerts[0].sent).toBe('2024-01-15T12:00-00:00') + }) + }) + + describe('Multiple ResponseType Values', () => { + it('should parse multiple responseType values', () => { + const xml = ` + + TEST-MRT-001 + test@example.com + 2024-01-15T10:00:00-00:00 + Actual + Alert + Public + + Fire + Wildfire Warning + Evacuate + Shelter + Monitor + Immediate + Extreme + Observed + +` + + const alerts = parseCap(xml) + const responseTypes = Array.isArray(alerts[0].infos[0].responseType) + ? alerts[0].infos[0].responseType + : [alerts[0].infos[0].responseType] + expect(responseTypes).toContain('Evacuate') + expect(responseTypes).toContain('Shelter') + expect(responseTypes).toContain('Monitor') + }) + }) + + describe('Multiple EventCode and Parameter', () => { + it('should parse multiple eventCode values', () => { + const xml = ` + + TEST-MEC-001 + test@example.com + 2024-01-15T10:00:00-00:00 + Actual + Alert + Public + + Met + Severe Weather + Immediate + Severe + Observed + + SAME + SVR + + + NWS + SEVERE.TSTORM + + +` + + const alerts = parseCap(xml) + const eventCodes = Array.isArray(alerts[0].infos[0].eventCode) + ? alerts[0].infos[0].eventCode + : [alerts[0].infos[0].eventCode] + expect(eventCodes.length).toBeGreaterThanOrEqual(1) + }) + + it('should parse multiple parameter values', () => { + const xml = ` + + TEST-MP-001 + test@example.com + 2024-01-15T10:00:00-00:00 + Actual + Alert + Public + + Met + Hurricane + Expected + Extreme + Likely + + WindSpeed + 120mph + + + StormSurge + 15ft + + + Rainfall + 12inches + + +` + + const alerts = parseCap(xml) + const parameters = Array.isArray(alerts[0].infos[0].parameter) + ? alerts[0].infos[0].parameter + : [alerts[0].infos[0].parameter] + expect(parameters.length).toBeGreaterThanOrEqual(1) + }) + }) + + describe('Multiple Geocode Values', () => { + it('should parse multiple geocode values in a single area', () => { + const xml = ` + + TEST-MGC-001 + test@example.com + 2024-01-15T10:00:00-00:00 + Actual + Alert + Public + + Met + Flood Warning + Expected + Moderate + Likely + + Multiple Counties + + FIPS6 + 006037 + + + FIPS6 + 006059 + + + UGC + CAZ041 + + + +` + + const alerts = parseCap(xml) + const geocodes = Array.isArray(alerts[0].infos[0].areas[0].geocode) + ? alerts[0].infos[0].areas[0].geocode + : [alerts[0].infos[0].areas[0].geocode] + expect(geocodes.length).toBeGreaterThanOrEqual(1) + }) + }) + + describe('Code Element', () => { + it('should parse single code value', () => { + const xml = ` + + TEST-CODE-001 + test@example.com + 2024-01-15T10:00:00-00:00 + Actual + Alert + Public + IPAWSv1.0 +` + + const alerts = parseCap(xml) + expect(alerts[0].code).toBeDefined() + }) + + it('should parse multiple code values', () => { + const xml = ` + + TEST-CODE-002 + test@example.com + 2024-01-15T10:00:00-00:00 + Actual + Alert + Public + IPAWSv1.0 + PROFILE:CAP-CP:0.4 +` + + const alerts = parseCap(xml) + expect(alerts[0].code).toBeDefined() + }) + }) + + describe('Polygon Validation', () => { + it('should parse closed polygon (first and last coordinates match)', () => { + const xml = ` + + TEST-POLY-001 + test@example.com + 2024-01-15T10:00:00-00:00 + Actual + Alert + Public + + Geo + Earthquake + Immediate + Extreme + Observed + + Affected Region + 38.47,-120.14 38.34,-119.95 38.52,-119.74 38.62,-119.89 38.47,-120.14 + + +` + + const alerts = parseCap(xml) + const polygon = alerts[0].infos[0].areas[0].polygon + expect(polygon).toBeDefined() + expect(typeof polygon).toBe('string') + const coords = (polygon as string).split(' ') + expect(coords[0]).toBe(coords[coords.length - 1]) + }) + + it('should parse polygon with minimum 4 coordinate pairs (triangle + closure)', () => { + const xml = ` + + TEST-POLY-002 + test@example.com + 2024-01-15T10:00:00-00:00 + Actual + Alert + Public + + Geo + Test Event + Immediate + Moderate + Observed + + Triangle Area + 0.0,0.0 1.0,0.0 0.5,1.0 0.0,0.0 + + +` + + const alerts = parseCap(xml) + const polygon = alerts[0].infos[0].areas[0].polygon + expect(polygon).toBeDefined() + expect(typeof polygon).toBe('string') + const coords = (polygon as string).split(' ') + expect(coords.length).toBeGreaterThanOrEqual(4) + }) + + it('should parse polygon with WGS-84 valid latitude/longitude ranges', () => { + const xml = ` + + TEST-POLY-003 + test@example.com + 2024-01-15T10:00:00-00:00 + Actual + Alert + Public + + Geo + Test Event + Immediate + Moderate + Observed + + Valid Coordinates + -90.0,-180.0 -90.0,180.0 90.0,180.0 90.0,-180.0 -90.0,-180.0 + + +` + + const alerts = parseCap(xml) + const polygon = alerts[0].infos[0].areas[0].polygon + expect(polygon).toBeDefined() + }) + }) + + describe('Circle Format Validation', () => { + it('should parse circle with valid format (lat,lon radius)', () => { + const xml = ` + + TEST-CIRCLE-001 + test@example.com + 2024-01-15T10:00:00-00:00 + Actual + Alert + Public + + CBRNE + Chemical Spill + Immediate + Severe + Observed + + Contamination Zone + 38.5,-120.5 5.0 + + +` + + const alerts = parseCap(xml) + const circle = alerts[0].infos[0].areas[0].circle + expect(circle).toBeDefined() + expect(circle).toMatch(/^-?\d+\.?\d*,-?\d+\.?\d* \d+\.?\d*$/) + }) + + it('should parse multiple circles in a single area', () => { + const xml = ` + + TEST-CIRCLE-002 + test@example.com + 2024-01-15T10:00:00-00:00 + Actual + Alert + Public + + CBRNE + Multiple Hazard Zones + Immediate + Severe + Observed + + Multiple Contamination Zones + 38.5,-120.5 5.0 + 39.0,-121.0 3.5 + + +` + + const alerts = parseCap(xml) + const circles = Array.isArray(alerts[0].infos[0].areas[0].circle) + ? alerts[0].infos[0].areas[0].circle + : [alerts[0].infos[0].areas[0].circle] + expect(circles.length).toBeGreaterThanOrEqual(1) + }) + }) + + describe('Message Type Relationships', () => { + it('should parse Update msgType with references to original alert', () => { + const xml = ` + + UPDATE-001 + nws@noaa.gov + 2024-01-15T12:00:00-00:00 + Actual + Update + Public + nws@noaa.gov,ALERT-001,2024-01-15T10:00:00-00:00 + + Met + Severe Thunderstorm + Immediate + Severe + Observed + Updated: Storm intensifying + +` + + const alerts = parseCap(xml) + expect(alerts[0].msgType).toBe('Update') + expect(alerts[0].references).toBeDefined() + expect(alerts[0].references).toContain('nws@noaa.gov') + expect(alerts[0].references).toContain('ALERT-001') + }) + + it('should parse Cancel msgType with references', () => { + const xml = ` + + CANCEL-001 + nws@noaa.gov + 2024-01-15T14:00:00-00:00 + Actual + Cancel + Public + nws@noaa.gov,ALERT-001,2024-01-15T10:00:00-00:00 nws@noaa.gov,UPDATE-001,2024-01-15T12:00:00-00:00 + + Met + Severe Thunderstorm + Past + Unknown + Unknown + Alert cancelled: Threat has passed + +` + + const alerts = parseCap(xml) + expect(alerts[0].msgType).toBe('Cancel') + expect(alerts[0].references).toBeDefined() + }) + + it('should parse Ack msgType acknowledging receipt', () => { + const xml = ` + + ACK-001 + local-ema@example.com + 2024-01-15T10:05:00-00:00 + Actual + Ack + Public + nws@noaa.gov,ALERT-001,2024-01-15T10:00:00-00:00 +` + + const alerts = parseCap(xml) + expect(alerts[0].msgType).toBe('Ack') + expect(alerts[0].references).toBeDefined() + }) + + it('should parse Error msgType for error notification', () => { + const xml = ` + + ERROR-001 + system@example.com + 2024-01-15T10:10:00-00:00 + Actual + Error + Public + Previous alert contained formatting errors + nws@noaa.gov,ALERT-BAD,2024-01-15T10:00:00-00:00 +` + + const alerts = parseCap(xml) + expect(alerts[0].msgType).toBe('Error') + expect(alerts[0].note).toBeDefined() + }) + }) + + describe('Character Entity References', () => { + it('should handle special characters in text fields', () => { + const xml = ` + + TEST-CHAR-001 + test@example.com + 2024-01-15T10:00:00-00:00 + Actual + Alert + Public + + Safety + Emergency Alert + Immediate + Moderate + Observed + Alert & Warning: "Stay Safe" + Temperature < 32°F. Wind > 40mph. + Don't go outside. Stay "indoors". + +` + + const alerts = parseCap(xml) + expect(alerts[0].infos[0].headline).toContain('&') + expect(alerts[0].infos[0].description).toBeDefined() + expect(alerts[0].infos[0].instruction).toBeDefined() + }) + }) + + describe('Temporal Relationships', () => { + it('should parse effective, onset, and expires times', () => { + const xml = ` + + TEST-TIME-001 + test@example.com + 2024-01-15T10:00:00-00:00 + Actual + Alert + Public + + Met + Winter Storm + Expected + Moderate + Likely + 2024-01-15T10:00:00-00:00 + 2024-01-15T18:00:00-00:00 + 2024-01-16T06:00:00-00:00 + Winter Storm Expected Tonight + Heavy snow expected starting this evening. + +` + + const alerts = parseCap(xml) + const info = alerts[0].infos[0] + expect(info.effective).toBe('2024-01-15T10:00:00-00:00') + expect(info.onset).toBe('2024-01-15T18:00:00-00:00') + expect(info.expires).toBe('2024-01-16T06:00:00-00:00') + }) + + it('should handle alert without onset (immediate effective time)', () => { + const xml = ` + + TEST-TIME-002 + test@example.com + 2024-01-15T10:00:00-00:00 + Actual + Alert + Public + + Safety + Immediate Threat + Immediate + Extreme + Observed + 2024-01-15T10:00:00-00:00 + 2024-01-15T12:00:00-00:00 + +` + + const alerts = parseCap(xml) + const info = alerts[0].infos[0] + expect(info.effective).toBeDefined() + expect(info.onset).toBeUndefined() + expect(info.expires).toBeDefined() + }) + }) + + describe('Language Support', () => { + it('should parse language code in RFC 3066 format', () => { + const xml = ` + + TEST-LANG-001 + test@example.com + 2024-01-15T10:00:00-00:00 + Actual + Alert + Public + + fr-CA + Safety + Alerte d'urgence + Immediate + Severe + Observed + Alerte d'urgence en français canadien + +` + + const alerts = parseCap(xml) + expect(alerts[0].infos[0].language).toBe('fr-CA') + }) + + it('should parse three or more info blocks with different languages', () => { + const xml = ` + + TEST-LANG-002 + test@example.com + 2024-01-15T10:00:00-00:00 + Actual + Alert + Public + + en-US + Safety + Emergency Alert + Immediate + Severe + Observed + Emergency Alert + + + es-US + Safety + Alerta de Emergencia + Immediate + Severe + Observed + Alerta de Emergencia + + + zh-CN + Safety + 紧急警报 + Immediate + Severe + Observed + 紧急警报 + +` + + const alerts = parseCap(xml) + expect(alerts[0].infos).toHaveLength(3) + expect(alerts[0].infos[0].language).toBe('en-US') + expect(alerts[0].infos[1].language).toBe('es-US') + expect(alerts[0].infos[2].language).toBe('zh-CN') + }) + }) + + describe('Boundary Conditions', () => { + it('should parse resource with large size value', () => { + const xml = ` + + TEST-BC-001 + test@example.com + 2024-01-15T10:00:00-00:00 + Actual + Alert + Public + + Safety + Test + Immediate + Moderate + Possible + + Large Video File + video/mp4 + 1073741824 + + +` + + const alerts = parseCap(xml) + expect(alerts[0].infos[0].resources[0].size).toBe(1073741824) + }) + + it('should parse area with extreme altitude and ceiling values', () => { + const xml = ` + + TEST-BC-002 + test@example.com + 2024-01-15T10:00:00-00:00 + Actual + Alert + Public + + Met + High Altitude Warning + Immediate + Moderate + Observed + + High Altitude Zone + 10000 + 50000 + + +` + + const alerts = parseCap(xml) + const area = alerts[0].infos[0].areas[0] + expect(area.altitude).toBe(10000) + expect(area.ceiling).toBe(50000) + }) + + it('should parse area with negative altitude (below sea level)', () => { + const xml = ` + + TEST-BC-003 + test@example.com + 2024-01-15T10:00:00-00:00 + Actual + Alert + Public + + Env + Below Sea Level Alert + Immediate + Moderate + Observed + + Below Sea Level Zone + -100 + 0 + + +` + + const alerts = parseCap(xml) + const area = alerts[0].infos[0].areas[0] + expect(area.altitude).toBe(-100) + expect(area.ceiling).toBe(0) + }) + }) + + describe('Complex Real-World Scenarios', () => { + it('should parse comprehensive NOAA-style severe weather alert', () => { + const xml = ` + + NOAA-NWS-ALERTS-CA125ABC123456 + w-nws.webmaster@noaa.gov + 2024-01-15T10:47:00-08:00 + Actual + Alert + NWS National Weather Service + Public + IPAWSv1.0 + + en-US + Met + Severe Thunderstorm Warning + Shelter + Monitor + Immediate + Severe + Observed + 2024-01-15T10:47:00-08:00 + 2024-01-15T10:47:00-08:00 + 2024-01-15T11:30:00-08:00 + NWS Sacramento CA + Severe Thunderstorm Warning issued January 15 at 10:47AM PST until January 15 at 11:30AM PST by NWS Sacramento CA + The National Weather Service in Sacramento has issued a Severe Thunderstorm Warning for Central Sacramento County until 1130 AM PST. At 1047 AM PST, a severe thunderstorm was located near Sacramento, moving northeast at 25 mph. Hazard: 60 mph wind gusts and quarter size hail. Source: Radar indicated. Impact: Hail damage to vehicles is expected. Expect wind damage to roofs, siding, and trees. + For your protection move to an interior room on the lowest floor of a building. Large hail and damaging winds and continuous cloud to ground lightning is occurring with this storm. Move indoors immediately. + http://www.weather.gov + w-nws.webmaster@noaa.gov + + VTEC + /O.NEW.KSTO.SV.W.0001.240115T1847Z-240115T1930Z/ + + + TIME...MOT...LOC + 1847Z 239DEG 22KT 3850 12120 + + + SAME + SVR + + + NWS-IDP-SOURCE + RADAR + + + Radar Image + image/png + 45678 + http://www.weather.gov/radar/image.png + + + Central Sacramento County + 38.47,-121.50 38.51,-121.35 38.56,-121.35 38.60,-121.45 38.55,-121.55 38.47,-121.50 + + FIPS6 + 006067 + + + UGC + CAC067 + + + +` + + const alerts = parseCap(xml) + expect(alerts).toHaveLength(1) + expect(alerts[0].identifier).toBe('NOAA-NWS-ALERTS-CA125ABC123456') + expect(alerts[0].source).toBe('NWS National Weather Service') + expect(alerts[0].infos[0].event).toBe('Severe Thunderstorm Warning') + expect(alerts[0].infos[0].resources).toHaveLength(1) + expect(alerts[0].infos[0].areas[0].polygon).toBeDefined() + }) + + it('should parse AMBER Alert with all required fields', () => { + const xml = ` + + AMBER-CA-2024-001 + chp@doj.ca.gov + 2024-01-15T15:30:00-08:00 + Actual + Alert + California Highway Patrol + Public + AMBER + + en-US + Security + Rescue + Child Abduction Emergency + Monitor + Immediate + Severe + Observed + 2024-01-15T15:30:00-08:00 + 2024-01-16T03:30:00-08:00 + California Highway Patrol + AMBER Alert for Missing Child + The California Highway Patrol has issued an AMBER Alert for a missing 5-year-old child. Suspect is believed to be driving a blue 2015 Honda Civic, license plate 7ABC123. Child was last seen wearing a red jacket and blue jeans. + If you have any information about this abduction, call the California Highway Patrol immediately at 1-800-TELL-CHP (1-800-835-5247). Do not approach the suspect. + http://www.chp.ca.gov/amber + 1-800-835-5247 + + VehicleYear + 2015 + + + VehicleMake + Honda + + + VehicleModel + Civic + + + VehicleColor + Blue + + + LicensePlate + 7ABC123 + + + Statewide California + + FIPS6 + 006000 + + + +` + + const alerts = parseCap(xml) + expect(alerts).toHaveLength(1) + expect(alerts[0].code).toBe('AMBER') + const categories = Array.isArray(alerts[0].infos[0].category) + ? alerts[0].infos[0].category + : [alerts[0].infos[0].category] + expect(categories).toContain('Security') + expect(categories).toContain('Rescue') + const parameters = Array.isArray(alerts[0].infos[0].parameter) + ? alerts[0].infos[0].parameter + : [alerts[0].infos[0].parameter] + expect(parameters.length).toBeGreaterThanOrEqual(1) + }) + }) +}) diff --git a/edge-apps/cap-alerting/src/parser.ts b/edge-apps/cap-alerting/src/parser.ts new file mode 100644 index 000000000..8340f9649 --- /dev/null +++ b/edge-apps/cap-alerting/src/parser.ts @@ -0,0 +1,94 @@ +import { CAPInfo, CAPAlert } from './types/cap.js' +import { XMLParser } from 'fast-xml-parser' + +export function parseCap(xml: string): CAPAlert[] { + const parser = new XMLParser({ + ignoreAttributes: false, + attributeNamePrefix: '@_', + }) + const json = parser.parse(xml) + const alertsJson = json.alert + ? Array.isArray(json.alert) + ? json.alert + : [json.alert] + : [] + + const alerts: CAPAlert[] = [] + + alertsJson.forEach((a: CAPAlert) => { + const infosJson = a.info ? (Array.isArray(a.info) ? a.info : [a.info]) : [] + + const infos: CAPInfo[] = infosJson.map((info: CAPInfo) => { + const resourcesJson = info.resource + ? Array.isArray(info.resource) + ? info.resource + : [info.resource] + : [] + const areasJson = info.area + ? Array.isArray(info.area) + ? info.area + : [info.area] + : [] + + return { + language: info.language || '', + category: info.category, + event: info.event, + responseType: info.responseType, + urgency: info.urgency, + severity: info.severity, + certainty: info.certainty, + audience: info.audience, + effective: info.effective, + onset: info.onset, + expires: info.expires, + senderName: info.senderName, + headline: info.headline, + description: info.description, + instruction: info.instruction, + web: info.web, + contact: info.contact, + parameter: info.parameter, + eventCode: info.eventCode, + resources: resourcesJson.map((res: Record) => { + return { + resourceDesc: res.resourceDesc, + mimeType: res.mimeType || res['mimeType'], + size: res.size, + uri: res.uri, + derefUri: res.derefUri, + digest: res.digest, + url: res.uri || res.resourceDesc || '', + } + }), + areas: areasJson.map((area: Record) => ({ + areaDesc: area.areaDesc || '', + polygon: area.polygon, + circle: area.circle, + geocode: area.geocode, + altitude: area.altitude, + ceiling: area.ceiling, + })), + } + }) + + alerts.push({ + identifier: a.identifier || '', + sender: a.sender || '', + sent: a.sent || '', + status: a.status, + msgType: a.msgType, + source: a.source, + scope: a.scope, + restriction: a.restriction, + addresses: a.addresses, + code: a.code, + note: a.note, + references: a.references, + incidents: a.incidents, + infos, + }) + }) + + return alerts +} diff --git a/edge-apps/cap-alerting/src/render.ts b/edge-apps/cap-alerting/src/render.ts new file mode 100644 index 000000000..bb1e487dc --- /dev/null +++ b/edge-apps/cap-alerting/src/render.ts @@ -0,0 +1,33 @@ +export function highlightKeywords(text: string): string { + const keywords = [ + 'DO NOT', + "DON'T", + 'IMMEDIATELY', + 'IMMEDIATE', + 'NOW', + 'MOVE TO', + 'EVACUATE', + 'CALL', + 'WARNING', + 'DANGER', + 'SHELTER', + 'TAKE COVER', + 'AVOID', + 'STAY', + 'SEEK', + 'TURN AROUND', + 'GET TO', + 'LEAVE', + ] + + let result = text + keywords.forEach((keyword) => { + const regex = new RegExp(`\\b(${keyword})\\b`, 'gi') + result = result.replace( + regex, + '$1', + ) + }) + + return result +} diff --git a/edge-apps/cap-alerting/src/types/cap.ts b/edge-apps/cap-alerting/src/types/cap.ts new file mode 100644 index 000000000..9769f4c24 --- /dev/null +++ b/edge-apps/cap-alerting/src/types/cap.ts @@ -0,0 +1,61 @@ +export interface CAPResource { + resourceDesc?: string + mimeType: string + size?: number + uri?: string + derefUri?: string + digest?: string + url: string +} + +export interface CAPArea { + areaDesc: string + polygon?: string | string[] + circle?: string | string[] + geocode?: unknown + altitude?: number + ceiling?: number +} + +export interface CAPInfo { + language: string + category?: string | string[] + event?: string + responseType?: string | string[] + urgency?: string + severity?: string + certainty?: string + audience?: string + effective?: string + onset?: string + expires?: string + senderName?: string + headline?: string + description?: string + instruction?: string + web?: string + contact?: string + parameter?: unknown + eventCode?: unknown + resources: CAPResource[] + areas: CAPArea[] +} + +export interface CAPAlert { + identifier: string + sender: string + sent: string + status?: string + msgType?: string + source?: string + scope?: string + restriction?: string + addresses?: string + code?: string | string[] + note?: string + references?: string + incidents?: string + infos: CAPInfo[] +} + +export type CAPMode = 'test' | 'demo' | 'production' diff --git a/edge-apps/cap-alerting/src/utils.test.ts b/edge-apps/cap-alerting/src/utils.test.ts new file mode 100644 index 000000000..349150cd0 --- /dev/null +++ b/edge-apps/cap-alerting/src/utils.test.ts @@ -0,0 +1,149 @@ +import { describe, it, expect, mock } from 'bun:test' + +// Mock the @screenly/edge-apps module +mock.module('@screenly/edge-apps', () => ({ + getCorsProxyUrl: () => 'http://localhost:8080', + isAnywhereScreen: () => false, +})) + +import { getNearestExit, splitIntoSentences, proxyUrl } from './utils' + +describe('Utils', () => { + describe('Nearest Exit Functionality', () => { + it('should extract exit from tag with colon', () => { + const tags = ['exit:North Lobby', 'location:Building A'] + const exit = getNearestExit(tags) + expect(exit).toBe('North Lobby') + }) + + it('should extract exit from tag with dash', () => { + const tags = ['exit-South Stairwell', 'floor:3'] + const exit = getNearestExit(tags) + expect(exit).toBe('South Stairwell') + }) + + it('should be case-insensitive', () => { + const tags = ['EXIT:West Door', 'Exit-East Door'] + const exit = getNearestExit(tags) + expect(exit).toBe('West Door') + }) + + it('should return first exit tag found', () => { + const tags = ['exit:First Exit', 'exit:Second Exit'] + const exit = getNearestExit(tags) + expect(exit).toBe('First Exit') + }) + + it('should return undefined if no exit tag found', () => { + const tags = ['location:Building A', 'floor:3'] + const exit = getNearestExit(tags) + expect(exit).toBeUndefined() + }) + + it('should trim whitespace from exit description', () => { + const tags = ['exit: Main Entrance '] + const exit = getNearestExit(tags) + expect(exit).toBe('Main Entrance') + }) + }) + + describe('Split Into Sentences', () => { + it('should split text on periods', () => { + const text = 'First sentence. Second sentence.' + const sentences = splitIntoSentences(text) + expect(sentences).toEqual(['First sentence.', 'Second sentence.']) + }) + + it('should split text on exclamation marks', () => { + const text = 'First sentence! Second sentence!' + const sentences = splitIntoSentences(text) + expect(sentences).toEqual(['First sentence!', 'Second sentence!']) + }) + + it('should split text on question marks', () => { + const text = 'First question? Second question?' + const sentences = splitIntoSentences(text) + expect(sentences).toEqual(['First question?', 'Second question?']) + }) + + it('should split on mixed punctuation', () => { + const text = 'Sentence one. Question two? Exclamation three!' + const sentences = splitIntoSentences(text) + expect(sentences).toEqual([ + 'Sentence one.', + 'Question two?', + 'Exclamation three!', + ]) + }) + + it('should handle single sentence without punctuation', () => { + const text = 'Single sentence' + const sentences = splitIntoSentences(text) + expect(sentences).toEqual(['Single sentence']) + }) + + it('should handle empty string', () => { + const text = '' + const sentences = splitIntoSentences(text) + expect(sentences).toEqual([]) + }) + + it('should handle string with only whitespace', () => { + const text = ' ' + const sentences = splitIntoSentences(text) + expect(sentences).toEqual([]) + }) + + it('should trim whitespace from sentences', () => { + const text = ' First sentence. Second sentence. ' + const sentences = splitIntoSentences(text) + expect(sentences).toEqual(['First sentence.', 'Second sentence.']) + }) + + it('should handle multiple spaces between sentences', () => { + const text = 'First. Second. Third.' + const sentences = splitIntoSentences(text) + expect(sentences).toEqual(['First.', 'Second.', 'Third.']) + }) + + it('should handle newlines and tabs', () => { + const text = 'First.\n\nSecond.\tThird.' + const sentences = splitIntoSentences(text) + expect(sentences).toEqual(['First.', 'Second.', 'Third.']) + }) + + it('should preserve periods in abbreviations within a sentence', () => { + const text = 'Dr. Smith said hello. Then he left.' + const sentences = splitIntoSentences(text) + expect(sentences).toEqual(['Dr. Smith said hello.', 'Then he left.']) + }) + + it('should handle trailing punctuation', () => { + const text = 'Only one sentence.' + const sentences = splitIntoSentences(text) + expect(sentences).toEqual(['Only one sentence.']) + }) + }) + + describe('Proxy URL', () => { + it('should proxy URLs with http protocol', () => { + const url = 'http://example.com/image.png' + const result = proxyUrl(url) + expect(result).toBe('http://localhost:8080/http://example.com/image.png') + }) + + it('should proxy URLs with https protocol', () => { + const url = 'https://example.com/video.mp4' + const result = proxyUrl(url) + expect(result).toBe('http://localhost:8080/https://example.com/video.mp4') + }) + + it('should proxy a complete link with path and query parameters', () => { + const url = 'https://api.example.com/v1/media?id=12345&format=json' + const result = proxyUrl(url) + expect(result).toBe( + 'http://localhost:8080/https://api.example.com/v1/media?id=12345&format=json', + ) + }) + }) +}) diff --git a/edge-apps/cap-alerting/src/utils.ts b/edge-apps/cap-alerting/src/utils.ts new file mode 100644 index 000000000..5aec087a5 --- /dev/null +++ b/edge-apps/cap-alerting/src/utils.ts @@ -0,0 +1,74 @@ +import { getCorsProxyUrl } from '@screenly/edge-apps' + +export function getNearestExit(tags: string[]): string | undefined { + for (const tag of tags) { + const lower = tag.toLowerCase() + if (lower.startsWith('exit:')) { + return tag.slice(5).trim() + } + if (lower.startsWith('exit-')) { + return tag.slice(5).trim() + } + } + return undefined +} + +export function splitIntoSentences(text: string): string[] { + // Replace common abbreviations with placeholders to protect them from splitting + let processed = text + const abbreviations = [ + 'Dr.', + 'Mr.', + 'Mrs.', + 'Ms.', + 'Prof.', + 'Sr.', + 'Jr.', + 'Inc.', + 'Ltd.', + 'Corp.', + 'Co.', + 'St.', + 'Ave.', + 'Blvd.', + 'etc.', + 'vs.', + 'e.g.', + 'i.e.', + 'U.S.', + 'U.K.', + ] + + const placeholders = new Map() + abbreviations.forEach((abbr, index) => { + const placeholder = `__ABBR_${index}__` + placeholders.set(placeholder, abbr) + processed = processed.replace( + new RegExp(abbr.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), + placeholder, + ) + }) + + // Split sentences + const sentences = processed + .split(/(?<=[.!?])\s+/) + .map((s) => s.trim()) + .filter((s) => s.length > 0) + + // Restore abbreviations + return sentences.map((s) => { + let result = s + placeholders.forEach((abbr, placeholder) => { + result = result.replace(new RegExp(placeholder, 'g'), abbr) + }) + return result + }) +} + +export function proxyUrl(url: string): string { + if (url && url.match(/^https?:/)) { + const cors = getCorsProxyUrl() + return `${cors}/${url}` + } + return url +} diff --git a/edge-apps/cap-alerting/static/cap-icon.svg b/edge-apps/cap-alerting/static/cap-icon.svg new file mode 100644 index 000000000..4c6b7982a --- /dev/null +++ b/edge-apps/cap-alerting/static/cap-icon.svg @@ -0,0 +1,5 @@ + + + + ! + diff --git a/edge-apps/cap-alerting/static/demo-1-tornado.cap b/edge-apps/cap-alerting/static/demo-1-tornado.cap new file mode 100644 index 000000000..524bd9625 --- /dev/null +++ b/edge-apps/cap-alerting/static/demo-1-tornado.cap @@ -0,0 +1,38 @@ + + + DEMO-TORNADO-EMERGENCY-001 + demo@screenly.io + 2024-01-15T15:45:00-00:00 + Exercise + Alert + Public + THIS IS A DEMONSTRATION EXERCISE - NOT A REAL EMERGENCY + + en-US + Met + Tornado Emergency + Shelter + Immediate + Extreme + Observed + 2024-01-15T15:45:00-00:00 + 2024-01-15T15:45:00-00:00 + 2024-01-15T16:30:00-00:00 + National Weather Service (DEMO) + TORNADO EMERGENCY - TAKE COVER NOW! + THIS IS A PARTICULARLY DANGEROUS SITUATION. A large and extremely dangerous tornado has been confirmed on the ground. This is a life-threatening situation. Flying debris will be deadly to those caught without shelter. Mobile homes will be destroyed. + TAKE COVER NOW! Move to a basement or an interior room on the lowest floor of a sturdy building. Avoid windows. If you are outdoors, in a mobile home, or in a vehicle, move to the closest substantial shelter and protect yourself from flying debris. Use {{closest_exit}} if evacuation is necessary. + https://weather.gov/tornado-emergency + Emergency Services: 911 + + Tornado Warning Siren + audio/mpeg + https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3 + + + Tornado Path - Northern Suburbs + 39.00,-121.00 39.10,-120.90 39.15,-120.85 39.20,-121.10 39.00,-121.00 + + + + diff --git a/edge-apps/cap-alerting/static/demo-2-fire.cap b/edge-apps/cap-alerting/static/demo-2-fire.cap new file mode 100644 index 000000000..ef04cf533 --- /dev/null +++ b/edge-apps/cap-alerting/static/demo-2-fire.cap @@ -0,0 +1,36 @@ + + + DEMO-FIRE-DRILL-001 + demo@screenly.io + 2024-01-15T14:30:00-00:00 + Exercise + Alert + Public + THIS IS A DEMONSTRATION EXERCISE - NOT A REAL EMERGENCY + + en-US + Fire + Fire Drill + Evacuate + Immediate + Minor + Observed + 2024-01-15T14:30:00-00:00 + 2024-01-15T15:30:00-00:00 + Building Safety Systems (DEMO) + Scheduled Fire Drill in Progress + This is a scheduled fire drill. All occupants must evacuate the building immediately using the nearest available exit. This is not an actual emergency. + Please proceed calmly to {{closest_exit}} and gather at the designated assembly point in the parking lot. Do not use elevators. Wait for the all-clear signal before re-entering the building. + https://example.com/safety/fire-drill + Building Security: (555) 123-4567 + + Fire Alarm Evacuation Alert + audio/mpeg + https://archive.org/download/fire-alarm-sound/fire-alarm-1.mp3 + + + Main Building - All Floors + + + + diff --git a/edge-apps/cap-alerting/static/demo-3-flood.cap b/edge-apps/cap-alerting/static/demo-3-flood.cap new file mode 100644 index 000000000..1e494b0f7 --- /dev/null +++ b/edge-apps/cap-alerting/static/demo-3-flood.cap @@ -0,0 +1,43 @@ + + + DEMO-FLASH-FLOOD-001 + demo@screenly.io + 2024-06-20T16:15:00-00:00 + Exercise + Alert + Public + THIS IS A DEMONSTRATION EXERCISE - NOT A REAL EMERGENCY + + en-US + Met + Flash Flood Warning + Evacuate + Avoid + Immediate + Severe + Likely + 2024-06-20T16:15:00-00:00 + 2024-06-20T16:30:00-00:00 + 2024-06-20T21:00:00-00:00 + National Weather Service (DEMO) + Flash Flood Warning - Move to Higher Ground + Flash Flood Warning for urban areas and small streams. Heavy rainfall has caused rapid flooding in low-lying areas. Water levels are rising quickly and roads are becoming impassable. DO NOT attempt to drive through flooded roadways. + Move to higher ground immediately. Do not walk or drive through flood waters - just 6 inches of moving water can knock you down, and 1 foot of water can sweep your vehicle away. Use {{closest_exit}} to reach higher floors. If trapped, move to the highest point and call for help. + https://weather.gov/flood-safety + Emergency Services: 911 + + ExpectedRainfall + 3-5 inches in 2 hours + + + Flood Warning Alert Tone + audio/mpeg + https://archive.org/download/EASAlertTones/EAS_Alert_Tones/Flood_Warning.mp3 + + + Downtown and River Valley Areas + 40.71,-74.01 40.73,-73.99 40.75,-74.00 40.74,-74.02 40.71,-74.01 + + + + diff --git a/edge-apps/cap-alerting/static/demo-4-earthquake.cap b/edge-apps/cap-alerting/static/demo-4-earthquake.cap new file mode 100644 index 000000000..3c8cdd521 --- /dev/null +++ b/edge-apps/cap-alerting/static/demo-4-earthquake.cap @@ -0,0 +1,47 @@ + + + DEMO-EARTHQUAKE-001 + demo@screenly.io + 2024-03-10T08:42:00-00:00 + Exercise + Alert + Public + THIS IS A DEMONSTRATION EXERCISE - NOT A REAL EMERGENCY + + en-US + Geo + Earthquake Alert + Monitor + Assess + Past + Moderate + Observed + 2024-03-10T08:42:00-00:00 + 2024-03-10T08:41:30-00:00 + 2024-03-10T12:00:00-00:00 + US Geological Survey (DEMO) + Magnitude 5.4 Earthquake Detected + A magnitude 5.4 earthquake occurred at 8:41 AM local time. The epicenter was located 15 miles northeast of downtown. Moderate shaking was felt across the metropolitan area. Aftershocks are possible in the coming hours. + Check yourself and others for injuries. Inspect your surroundings for hazards such as damaged buildings, gas leaks, or downed power lines. Be prepared for aftershocks. Use {{closest_exit}} if structural damage is visible. Do not use elevators. Monitor emergency channels for updates. + https://earthquake.usgs.gov + Emergency Services: 911 + + Magnitude + 5.4 + + + Depth + 8.2 km + + + Earthquake Alert Notification + audio/mpeg + https://archive.org/download/earthquake-alert-sound/earthquake-warning.mp3 + + + Metropolitan Area and Surrounding Regions + 34.05,-118.25 50 + + + + diff --git a/edge-apps/cap-alerting/static/demo-5-hazmat.cap b/edge-apps/cap-alerting/static/demo-5-hazmat.cap new file mode 100644 index 000000000..438e57a32 --- /dev/null +++ b/edge-apps/cap-alerting/static/demo-5-hazmat.cap @@ -0,0 +1,48 @@ + + + DEMO-HAZMAT-SHELTER-001 + demo@screenly.io + 2024-09-05T19:20:00-00:00 + Exercise + Alert + Public + THIS IS A DEMONSTRATION EXERCISE - NOT A REAL EMERGENCY + + en-US + CBRNE + Safety + Hazardous Materials Incident + Shelter + Avoid + Immediate + Severe + Observed + 2024-09-05T19:20:00-00:00 + 2024-09-05T19:20:00-00:00 + 2024-09-05T23:00:00-00:00 + Emergency Management (DEMO) + Shelter in Place - Hazardous Materials Release + A chemical spill at an industrial facility has released hazardous vapors into the air. The affected area is downwind of the release. Residents in the evacuation zone should shelter in place immediately until the all-clear is given. + SHELTER IN PLACE IMMEDIATELY. Go indoors and close all windows and doors. Turn off ventilation systems, air conditioning, and fans. Seal gaps under doors with wet towels. Move to an interior room away from windows. Do NOT go outside. Do NOT attempt to evacuate unless instructed by authorities. Use {{closest_exit}} only if ordered to evacuate. + https://emergency.example.com/hazmat + Emergency Information: 1-800-555-SAFE + + Chemical + Ammonia vapor + + + EvacuationRadius + 1 mile + + + Hazmat Shelter-in-Place Alert + audio/mpeg + https://archive.org/download/EASAlertTones/EAS_Alert_Tones/Civil_Emergency_Message.mp3 + + + Industrial Zone and Downwind Areas + 41.88,-87.63 1.5 + + + + diff --git a/edge-apps/cap-alerting/static/demo-6-shooter.cap b/edge-apps/cap-alerting/static/demo-6-shooter.cap new file mode 100644 index 000000000..a82caf8bf --- /dev/null +++ b/edge-apps/cap-alerting/static/demo-6-shooter.cap @@ -0,0 +1,47 @@ + + + DEMO-ACTIVE-SHOOTER-001 + demo@screenly.io + 2024-06-20T14:30:00-00:00 + Exercise + Alert + Public + THIS IS A DEMONSTRATION EXERCISE - NOT A REAL EMERGENCY + + en-US + Security + Active Shooter Alert + Shelter + Execute + Immediate + Extreme + Observed + 2024-06-20T14:30:00-00:00 + 2024-06-20T14:30:00-00:00 + 2024-06-20T18:00:00-00:00 + Campus Security (DEMO) + Active Shooter on Campus - Lockdown in Effect + Active shooter reported on campus. Law enforcement is on scene. This is an active and dangerous situation. All personnel must take immediate protective action. + Run, Hide, Fight. If safe to evacuate, run to {{closest_exit}} immediately. If evacuation is not possible, hide in a secure location, lock and barricade doors, silence phones, and stay quiet. Do not open doors for anyone except law enforcement. Call 911 when safe. As a last resort, if confronted, take action to defend yourself. + https://campus-security.edu/emergency + Emergency: 911 | Campus Security: (555) 123-4567 + + Location + Main Campus - Building C, 3rd Floor + + + Action + Lockdown + + + Emergency Lockdown Alert + audio/mpeg + https://archive.org/download/EASAlertTones/EAS_Alert_Tones/Civil_Emergency_Message.mp3 + + + University Campus and Surrounding Area + 40.75,-74.00 40.76,-73.98 40.77,-73.99 40.76,-74.01 40.75,-74.00 + + + + diff --git a/edge-apps/cap-alerting/static/test.cap b/edge-apps/cap-alerting/static/test.cap new file mode 100644 index 000000000..1e1400f10 --- /dev/null +++ b/edge-apps/cap-alerting/static/test.cap @@ -0,0 +1,29 @@ + + + TEST-ALERT-001 + test@screenly.io + 2024-01-15T10:00:00-00:00 + Test + Alert + Public + + en + Safety + Fire Drill + Immediate + Minor + Observed + Scheduled Fire Drill in Progress + This is a test alert for a scheduled fire drill. Please proceed to {{closest_exit}} in an orderly fashion. + Exit the building via {{closest_exit}}. Do not use elevators. Gather at the designated assembly point. + 2024-01-15T11:00:00-00:00 + + Fire Drill Alert Tone + audio/mpeg + https://archive.org/download/fire-alarm-sound/fire-alarm-1.mp3 + + + Main Building - All Floors + + + diff --git a/edge-apps/cap-alerting/tailwind.config.js b/edge-apps/cap-alerting/tailwind.config.js new file mode 100644 index 000000000..82729d7b2 --- /dev/null +++ b/edge-apps/cap-alerting/tailwind.config.js @@ -0,0 +1,26 @@ +/** @type {import('tailwindcss').Config} */ +module.exports = { + content: [ + "./index.html", + "./src/**/*.{js,ts,jsx,tsx}", + ], + theme: { + extend: { + screens: { + 'xs': '480px', + 'sm': '640px', + 'md': '768px', + 'lg': '1024px', + 'xl': '1280px', + '720p': '1280px', + '1080p': '1920px', + '2xl': '1536px', + '2k': '2560px', + '4k': '3840px', + '8k': '7680px', + }, + }, + }, + plugins: [], +} + diff --git a/edge-apps/cap-alerting/tsconfig.json b/edge-apps/cap-alerting/tsconfig.json new file mode 100644 index 000000000..ed69f9960 --- /dev/null +++ b/edge-apps/cap-alerting/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "lib": ["ES2020", "DOM"], + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "types": ["bun-types"] + }, + "exclude": ["node_modules", "dist", "../edge-apps-library/src"] +} diff --git a/edge-apps/edge-apps-library/eslint.config.ts b/edge-apps/edge-apps-library/eslint.config.ts index 3fae6ac5d..57c5c231c 100644 --- a/edge-apps/edge-apps-library/eslint.config.ts +++ b/edge-apps/edge-apps-library/eslint.config.ts @@ -5,5 +5,11 @@ import tseslint from 'typescript-eslint' export default defineConfig( eslint.configs.recommended, tseslint.configs.recommended, - globalIgnores(['dist/', 'node_modules/', 'static/js/', 'build/']), + globalIgnores([ + 'dist/', + 'node_modules/', + 'static/js/', + 'build/', + 'tailwind.config.js', + ]), ) diff --git a/edge-apps/edge-apps-library/src/utils/metadata.test.ts b/edge-apps/edge-apps-library/src/utils/metadata.test.ts index 0b2880076..d6c81f88f 100644 --- a/edge-apps/edge-apps-library/src/utils/metadata.test.ts +++ b/edge-apps/edge-apps-library/src/utils/metadata.test.ts @@ -9,6 +9,7 @@ import { getScreenlyVersion, getTags, hasTag, + isAnywhereScreen, } from './metadata' import { Hardware } from '../types/index.js' import { setupScreenlyMock, resetScreenlyMock } from '../test/mock' @@ -142,4 +143,23 @@ describe('metadata utilities', () => { expect(hasTag('other')).toBe(false) }) }) + + describe('isAnywhereScreen', () => { + test('should return false when hardware is not empty', () => { + expect(isAnywhereScreen()).toBe(false) + }) + + test('should return true when hardware is empty string', () => { + setupScreenlyMock({ + coordinates: [37.3861, -122.0839], + hostname: 'test-host', + location: 'Test Location', + hardware: '', + screenly_version: '1.2.3', + screen_name: 'Main Screen', + tags: [], + }) + expect(isAnywhereScreen()).toBe(true) + }) + }) }) diff --git a/edge-apps/edge-apps-library/src/utils/metadata.ts b/edge-apps/edge-apps-library/src/utils/metadata.ts index 09480dbbb..7df228a0a 100644 --- a/edge-apps/edge-apps-library/src/utils/metadata.ts +++ b/edge-apps/edge-apps-library/src/utils/metadata.ts @@ -78,3 +78,13 @@ export function getTags(): string[] { export function hasTag(tag: string): boolean { return screenly.metadata.tags.includes(tag) } + +/** + * Check if the device is an Anywhere screen + */ +export function isAnywhereScreen(): boolean { + return ( + screenly.metadata.hardware === '' || + screenly.metadata.hardware === undefined + ) +}