diff --git a/.prettierignore b/.prettierignore
index 0c5a6b148..83e0abc12 100644
--- a/.prettierignore
+++ b/.prettierignore
@@ -1,4 +1,5 @@
**/*.min.js
+edge-apps/cap-alerting/
edge-apps/edge-apps-library/
edge-apps/grafana/
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..8f49ce5d5
--- /dev/null
+++ b/edge-apps/cap-alerting/README.md
@@ -0,0 +1,81 @@
+# 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 or relative path to your CAP XML feed (required)
+- **Display Errors**: Show errors on screen for debugging purposes (default: `false`, advanced setting)
+- **Default Language**: Preferred language code when multiple languages are available (default: `en`)
+- **Maximum Alerts**: Maximum number of alerts to display simultaneously (default: `Infinity`)
+- **Mode**: Operation mode - Production, Demo, or Test (default: `production`)
+- **Refresh Interval**: Minutes between feed updates (default: `5`)
+
+## Modes
+
+- **Production**: Fetches CAP data from the configured feed URL with offline caching support
+- **Demo**: Displays random demo alerts (ignores feed URL if left empty)
+- **Test**: Displays a static test alert for development and testing
+
+## 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.
+
+## NWS Text Product Formatting
+
+The app automatically detects and formats National Weather Service (NWS) CAP alerts that use legacy text formats. This improves readability by converting abbreviated markers into clean, readable text with proper spacing and line breaks.
+
+### Supported Formats
+
+**1. Period-based Forecasts** (marine forecasts, zone forecasts)
+
+Markers: `.TODAY...`, `.TONIGHT...`, `.MON...`, `.SUN NIGHT...`, etc.
+
+Example transformation:
+
+```text
+.TODAY...E wind 20 kt. Seas 11 ft. .TONIGHT...E wind 20 kt.
+```
+
+becomes:
+
+```text
+TODAY: E wind 20 kt. Seas 11 ft.
+
+TONIGHT: E wind 20 kt.
+```
+
+**2. Impact Based Warnings (WWWI format)**
+
+Markers: `* WHAT...`, `* WHERE...`, `* WHEN...`, `* IMPACTS...`
+
+Example transformation:
+
+```text
+* WHAT...North winds 25 to 30 kt. * WHERE...Coastal waters. * WHEN...Until 3 AM.
+```
+
+becomes:
+
+```text
+WHAT: North winds 25 to 30 kt.
+
+WHERE: Coastal waters.
+
+WHEN: Until 3 AM.
+```
+
+This formatting only applies to CAP alerts from the NWS sender (`w-nws.webmaster@noaa.gov`).
+
+## 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..344120a79
--- /dev/null
+++ b/edge-apps/cap-alerting/bun.lock
@@ -0,0 +1,386 @@
+{
+ "lockfileVersion": 1,
+ "configVersion": 0,
+ "workspaces": {
+ "": {
+ "name": "cap-alerting",
+ "dependencies": {
+ "fast-xml-parser": "^5.3.2",
+ },
+ "devDependencies": {
+ "@screenly/edge-apps": "workspace:../edge-apps-library",
+ "@types/bun": "^1.3.5",
+ "@types/jsdom": "^27.0.0",
+ "bun-types": "^1.3.5",
+ "jsdom": "^27.4.0",
+ "npm-run-all2": "^8.0.4",
+ "prettier": "^3.7.4",
+ "typescript": "^5.9.3",
+ },
+ },
+ "../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.31", "", {}, "sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA=="],
+
+ "@asamuzakjp/css-color": ["@asamuzakjp/css-color@4.1.1", "", { "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.4" } }, "sha512-B0Hv6G3gWGMn0xKJ0txEi/jM5iFpT3MfDxmhZFb4W047GvytCf1DHQ1D69W3zHI4yWe2aTZAA0JnbMZ7Xc8DuQ=="],
+
+ "@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.25", "", {}, "sha512-g0Kw9W3vjx5BEBAF8c5Fm2NcB/Fs8jJXh85aXqwEXiL+tqtOut07TWgyaGzAAfTM+gKckrrncyeGEZPcaRgm2Q=="],
+
+ "@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=="],
+
+ "@exodus/bytes": ["@exodus/bytes@1.8.0", "", { "peerDependencies": { "@exodus/crypto": "^1.0.0-rc.4" }, "optionalPeers": ["@exodus/crypto"] }, "sha512-8JPn18Bcp8Uo1T82gR8lh2guEOa5KKU/IEKvvdp0sgmi7coPBWf1Doi1EXsGZb2ehc8ym/StJCjffYV+ne7sXQ=="],
+
+ "@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=="],
+
+ "@photostructure/tz-lookup": ["@photostructure/tz-lookup@11.3.0", "", {}, "sha512-rYGy7ETBHTnXrwbzm47e3LJPKJmzpY7zXnbZhdosNU0lTGWVqzxptSjK4qZkJ1G+Kwy4F6XStNR9ZqMsXAoASQ=="],
+
+ "@screenly/edge-apps": ["@screenly/edge-apps@workspace:../edge-apps-library"],
+
+ "@types/bun": ["@types/bun@1.3.5", "", { "dependencies": { "bun-types": "1.3.5" } }, "sha512-RnygCqNrd3srIPEWBd5LFeUYG7plCoH2Yw9WaZGyNmdTEei+gWaHqydbaIRkIkcbXwhBT94q78QljxN0Sk838w=="],
+
+ "@types/jsdom": ["@types/jsdom@27.0.0", "", { "dependencies": { "@types/node": "*", "@types/tough-cookie": "*", "parse5": "^7.0.0" } }, "sha512-NZyFl/PViwKzdEkQg96gtnB8wm+1ljhdDay9ahn4hgb+SfVtPCbm3TlmDUFXTA+MGN3CijicnMhG18SI5H3rFw=="],
+
+ "@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=="],
+
+ "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
+
+ "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=="],
+
+ "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=="],
+
+ "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=="],
+
+ "bun-types": ["bun-types@1.3.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-OlFwHcnNV99r//9v5IIOgQ9Uk37gZqrNMCcqEaExdkVq3Avwqok1bJFmvGMCkCE0FqzdY8VMOZpfpR3lwI+CsQ=="],
+
+ "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=="],
+
+ "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.7", "", { "dependencies": { "@asamuzakjp/css-color": "^4.1.1", "@csstools/css-syntax-patches-for-csstree": "^1.0.21", "css-tree": "^3.1.0", "lru-cache": "^11.2.4" } }, "sha512-7D2EPVltRrsTkhpQmksIu+LxeWAIEk6wRDMJ1qljlv+CKHJM+cJLlfhWIzNA44eAsHXSNe3+vO6DW1yCYx8SuQ=="],
+
+ "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=="],
+
+ "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=="],
+
+ "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
+
+ "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=="],
+
+ "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-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=="],
+
+ "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
+
+ "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@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
+
+ "gzip-size": ["gzip-size@7.0.0", "", { "dependencies": { "duplexer": "^0.1.2" } }, "sha512-O1Ld7Dr+nqPnmGpdhzLmMTQ4vAsD+rHwMm1NLUmoUFFymBOMKxCCrtDxqdBRYXdeEPEi3SyoR4TizJLQrnKBNA=="],
+
+ "heap": ["heap@0.2.7", "", {}, "sha512-2bsegYkkHO+h/9MGbn6KWcE45cHZgPANo5LXF7EvWdT0yT2EguSVO1nDgU5c8+ZOPwp2vMNa7YFsJhVcDR9Sdg=="],
+
+ "html-encoding-sniffer": ["html-encoding-sniffer@6.0.0", "", { "dependencies": { "@exodus/bytes": "^1.6.0" } }, "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg=="],
+
+ "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=="],
+
+ "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=="],
+
+ "jsdom": ["jsdom@27.4.0", "", { "dependencies": { "@acemir/cssom": "^0.9.28", "@asamuzakjp/dom-selector": "^6.7.6", "@exodus/bytes": "^1.6.0", "cssstyle": "^5.3.4", "data-urls": "^6.0.0", "decimal.js": "^10.6.0", "html-encoding-sniffer": "^6.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-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-mjzqwWRD9Y1J1KUi7W97Gja1bwOOM5Ug0EZ6UDK3xS7j7mndrkwozHtSblfomlzyB4NepioNt+B2sOSzczVgtQ=="],
+
+ "json-parse-even-better-errors": ["json-parse-even-better-errors@4.0.0", "", {}, "sha512-lR4MXjGNgkJc7tkQ97kb2nuEMnNCyU//XYVH0MKTGcXEiSudQ5MKGKen3C5QubYy0vmq+JGitUg92uuywGEwIA=="],
+
+ "kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
+
+ "lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="],
+
+ "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=="],
+
+ "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=="],
+
+ "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
+
+ "normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="],
+
+ "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=="],
+
+ "parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="],
+
+ "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
+
+ "picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
+
+ "pidtree": ["pidtree@0.6.0", "", { "bin": { "pidtree": "bin/pidtree.js" } }, "sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g=="],
+
+ "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=="],
+
+ "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=="],
+
+ "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=="],
+
+ "strnum": ["strnum@2.1.1", "", {}, "sha512-7ZvoFTiCnGxBtDqJ//Cu6fWtZtc7Y3x+QOirG15wztbdngGSkht27o2pyGWrVy0b4WAy3jbKmnoK6g5VlVNUUw=="],
+
+ "symbol-tree": ["symbol-tree@3.2.4", "", {}, "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw=="],
+
+ "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=="],
+
+ "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
+
+ "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
+
+ "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=="],
+
+ "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=="],
+
+ "@jsheaven/easybuild/typescript": ["typescript@4.9.5", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g=="],
+
+ "@screenly/edge-apps/@types/bun": ["@types/bun@1.3.3", "", { "dependencies": { "bun-types": "1.3.3" } }, "sha512-ogrKbJ2X5N0kWLLFKeytG0eHDleBYtngtlbu9cyBKFtNL3cnpDZkNdQj8flVf6WTZUX5ulI9AY1oa7ljhSrp+g=="],
+
+ "@screenly/edge-apps/bun-types": ["bun-types@1.3.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-z3Xwlg7j2l9JY27x5Qn3Wlyos8YAp0kKRlrePAOjgjMGS5IG6E7Jnlx736vH9UVI4wUICwwhC9anYL++XeOgTQ=="],
+
+ "@screenly/edge-apps/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=="],
+
+ "@types/bun/bun-types": ["bun-types@1.3.5", "", { "dependencies": { "@types/node": "*" } }, "sha512-inmAYe2PFLs0SUbFOWSVD24sg1jFlMPxOjOSSCYqUgn4Hsc3rDc7dFvfVYjFPNHtov6kgUeulV4SxbuIV/stPw=="],
+
+ "anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
+
+ "cross-spawn/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
+
+ "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=="],
+
+ "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=="],
+
+ "@screenly/edge-apps/jsdom/@acemir/cssom": ["@acemir/cssom@0.9.26", "", {}, "sha512-UMFbL3EnWH/eTvl21dz9s7Td4wYDMtxz/56zD8sL9IZGYyi48RxmdgPMiyT7R6Vn3rjMTwYZ42bqKa7ex74GEQ=="],
+
+ "@screenly/edge-apps/jsdom/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=="],
+
+ "@screenly/edge-apps/jsdom/html-encoding-sniffer": ["html-encoding-sniffer@4.0.0", "", { "dependencies": { "whatwg-encoding": "^3.1.1" } }, "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ=="],
+
+ "@screenly/edge-apps/jsdom/parse5": ["parse5@8.0.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA=="],
+
+ "cross-spawn/which/isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
+
+ "@screenly/edge-apps/jsdom/cssstyle/@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=="],
+
+ "@screenly/edge-apps/jsdom/cssstyle/@csstools/css-syntax-patches-for-csstree": ["@csstools/css-syntax-patches-for-csstree@1.0.20", "", {}, "sha512-8BHsjXfSciZxjmHQOuVdW2b8WLUPts9a+mfL13/PzEviufUEW2xnvQuOlKs9dRBHgRqJ53SF/DUoK9+MZk72oQ=="],
+ }
+}
diff --git a/edge-apps/cap-alerting/index.html b/edge-apps/cap-alerting/index.html
new file mode 100644
index 000000000..602bbe184
--- /dev/null
+++ b/edge-apps/cap-alerting/index.html
@@ -0,0 +1,128 @@
+
+
+
+
+ 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..decaa13f5
--- /dev/null
+++ b/edge-apps/cap-alerting/package.json
@@ -0,0 +1,39 @@
+{
+ "name": "cap-alerting",
+ "version": "1.0.0",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "prebuild": "bun run type-check",
+ "generate-mock-data": "screenly edge-app run --generate-mock-data",
+ "predev": "bun run generate-mock-data && edge-apps-scripts build",
+ "dev": "run-p build: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": "edge-apps-scripts build",
+ "build:dev": "edge-apps-scripts build:dev",
+ "build:prod": "edge-apps-scripts build",
+ "test": "bun test",
+ "test:unit": "bun test",
+ "lint": "edge-apps-scripts lint --fix",
+ "format": "prettier --write src/ README.md index.html",
+ "format:check": "prettier --check src/ README.md index.html",
+ "deploy": "bun run build && screenly edge-app deploy",
+ "type-check": "edge-apps-scripts type-check",
+ "prepare": "cd ../edge-apps-library && bun install && bun run build"
+ },
+ "dependencies": {
+ "fast-xml-parser": "^5.3.2"
+ },
+ "prettier": "../edge-apps-library/.prettierrc.json",
+ "devDependencies": {
+ "@screenly/edge-apps": "workspace:../edge-apps-library",
+ "@types/bun": "^1.3.5",
+ "@types/jsdom": "^27.0.0",
+ "bun-types": "^1.3.5",
+ "jsdom": "^27.4.0",
+ "npm-run-all2": "^8.0.4",
+ "prettier": "^3.7.4",
+ "typescript": "^5.9.3"
+ }
+}
diff --git a/edge-apps/cap-alerting/screenly.yml b/edge-apps/cap-alerting/screenly.yml
new file mode 100644
index 000000000..a9a0344a9
--- /dev/null
+++ b/edge-apps/cap-alerting/screenly.yml
@@ -0,0 +1,77 @@
+---
+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: false
+ help_text:
+ properties:
+ help_text: URL or relative path to a CAP XML feed.
+ type: string
+ schema_version: 1
+ display_errors:
+ type: string
+ default_value: 'false'
+ title: Display Errors
+ optional: true
+ help_text:
+ properties:
+ advanced: true
+ help_text: For debugging purposes to display errors on the screen.
+ type: boolean
+ 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
+ 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..a9a0344a9
--- /dev/null
+++ b/edge-apps/cap-alerting/screenly_qc.yml
@@ -0,0 +1,77 @@
+---
+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: false
+ help_text:
+ properties:
+ help_text: URL or relative path to a CAP XML feed.
+ type: string
+ schema_version: 1
+ display_errors:
+ type: string
+ default_value: 'false'
+ title: Display Errors
+ optional: true
+ help_text:
+ properties:
+ advanced: true
+ help_text: For debugging purposes to display errors on the screen.
+ type: boolean
+ 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
+ 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..70e4d1847
--- /dev/null
+++ b/edge-apps/cap-alerting/src/fetcher.test.ts
@@ -0,0 +1,386 @@
+import '@screenly/edge-apps/test'
+import { describe, it, expect, beforeEach, mock } from 'bun:test'
+import { CAPFetcher } from './fetcher'
+
+// Mock the @screenly/edge-apps module
+const mockGetCorsProxyUrl = mock()
+const mockGetHardware = mock()
+
+// Hardware enum mock
+const Hardware = {
+ Anywhere: 'Anywhere',
+ RaspberryPi: 'RaspberryPi',
+ ScreenlyPlayerMax: 'ScreenlyPlayerMax',
+ Unknown: 'Unknown',
+}
+
+mock.module('@screenly/edge-apps', () => ({
+ getCorsProxyUrl: () => mockGetCorsProxyUrl(),
+ getHardware: () => mockGetHardware(),
+ Hardware,
+ 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()
+
+// eslint-disable-next-line max-lines-per-function
+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()
+ mockGetHardware.mockReset()
+
+ // Default mock implementations
+ mockGetCorsProxyUrl.mockReturnValue('https://cors-proxy.example.com')
+ })
+
+ 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',
+ })
+
+ 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',
+ })
+
+ 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',
+ })
+
+ const result = await fetcher.fetch()
+
+ expect(result).toBeNull()
+ })
+ })
+
+ // eslint-disable-next-line max-lines-per-function
+ describe('Demo Mode', () => {
+ it('should fetch random demo file on local screen', async () => {
+ const demoData =
+ 'DEMO'
+
+ mockFetch.mockResolvedValueOnce({
+ ok: true,
+ text: async () => demoData,
+ })
+
+ mockGetHardware.mockReturnValueOnce(Hardware.RaspberryPi)
+
+ const fetcher = new CAPFetcher({
+ testMode: false,
+ demoMode: true,
+ feedUrl: '',
+ })
+
+ 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,
+ })
+
+ mockGetHardware.mockReturnValueOnce(Hardware.Anywhere)
+
+ const fetcher = new CAPFetcher({
+ testMode: false,
+ demoMode: true,
+ feedUrl: '',
+ })
+
+ 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,
+ })
+
+ mockGetHardware.mockReturnValueOnce(Hardware.RaspberryPi)
+
+ const fetcher = new CAPFetcher({
+ testMode: false,
+ demoMode: true,
+ feedUrl: '',
+ })
+
+ 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',
+ })
+
+ 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')
+ })
+ })
+
+ // eslint-disable-next-line max-lines-per-function
+ 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',
+ })
+
+ 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',
+ })
+
+ 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',
+ })
+
+ 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',
+ })
+
+ 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',
+ })
+
+ 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',
+ })
+
+ 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('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: '',
+ })
+
+ 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',
+ })
+
+ 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..da8231033
--- /dev/null
+++ b/edge-apps/cap-alerting/src/fetcher.ts
@@ -0,0 +1,123 @@
+import { getCorsProxyUrl, getHardware, Hardware } 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
+}
+
+/**
+ * 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 automatic fallback to cached data).
+ * Always works offline using cached data when network is unavailable.
+ */
+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 hardware = getHardware()
+ const url =
+ hardware === Hardware.Anywhere
+ ? `${DEMO_BASE_URL}/static/test.cap`
+ : 'static/test.cap'
+ const resp = await fetch(url)
+ 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 hardware = getHardware()
+ const demoFiles =
+ hardware === Hardware.Anywhere ? 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 {
+ // 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..b6a7e3165
--- /dev/null
+++ b/edge-apps/cap-alerting/src/input.css
@@ -0,0 +1,161 @@
+@import 'tailwindcss';
+@import './nws.css';
+
+/* 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..07f6453c5
--- /dev/null
+++ b/edge-apps/cap-alerting/src/main.ts
@@ -0,0 +1,262 @@
+import './input.css'
+
+import {
+ setupTheme,
+ signalReady,
+ getTags,
+ getSettingWithDefault,
+ setupErrorHandling,
+} from '@screenly/edge-apps'
+
+import { CAPAlert, CAPInfo, CAPMode } from './types/cap'
+import { parseCap, isNwsAlert, parseNwsTextProduct } from './parser'
+import { CAPFetcher } from './fetcher'
+import { getNearestExit, splitIntoSentences, proxyUrl } from './utils'
+import {
+ highlightKeywords,
+ renderNwsWwwiContent,
+ renderNwsPeriodContent,
+} from './render'
+
+function getTemplate(id: string): HTMLTemplateElement {
+ const template = document.getElementById(id) as HTMLTemplateElement | null
+ if (!template) throw new Error(`Template ${id} not found`)
+ return template
+}
+
+function createStatusBanner(status: string): HTMLElement {
+ const template = getTemplate('status-banner-template')
+ const banner = template.content.cloneNode(true) as DocumentFragment
+ const bannerEl = banner.firstElementChild as HTMLDivElement
+
+ let baseClasses: string[] = []
+ let statusText = status.toUpperCase()
+ if (status === 'Exercise') {
+ statusText = 'EXERCISE - THIS IS A DRILL'
+ baseClasses = ['status-banner-blue']
+ } else if (status === 'Test') {
+ statusText = 'TEST - NOT A REAL EMERGENCY'
+ baseClasses = ['status-banner-gray']
+ } else if (status === 'System') {
+ statusText = 'SYSTEM TEST'
+ baseClasses = ['status-banner-gray']
+ } else if (status === 'Draft') {
+ statusText = 'DRAFT - NOT ACTIVE'
+ baseClasses = ['status-banner-orange']
+ } else if (status === 'Actual') {
+ statusText = 'ACTUAL EMERGENCY'
+ baseClasses = ['status-banner-red', 'status-actual-pulse']
+ }
+
+ bannerEl.classList.add(...baseClasses)
+ bannerEl.textContent = statusText
+ return bannerEl
+}
+
+function createHeaderRow(info: CAPInfo, identifier: string): HTMLElement {
+ const template = getTemplate('header-row-template')
+ const headerRow = template.content.cloneNode(true) as DocumentFragment
+ const headerRowEl = headerRow.firstElementChild as HTMLDivElement
+
+ const titleEl = headerRowEl.querySelector('h2') as HTMLHeadingElement
+ titleEl.textContent = info.event || identifier
+
+ const metaEl = headerRowEl.querySelector('.severity-badge') as HTMLDivElement
+ metaEl.textContent =
+ `${info.urgency || ''} ${info.severity || ''} ${info.certainty || ''}`.trim()
+
+ return headerRowEl
+}
+
+function createInstructionBox(
+ instruction: string,
+ nearestExit: string | undefined,
+): HTMLElement {
+ let instr = 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 template = getTemplate('instruction-box-template')
+ const instructionBox = template.content.cloneNode(true) as DocumentFragment
+ const instructionBoxEl = instructionBox.firstElementChild as HTMLDivElement
+
+ const sentences = splitIntoSentences(instr)
+
+ if (sentences.length > 2) {
+ const listTemplate = getTemplate('instruction-list-template')
+ const ul = (listTemplate.content.cloneNode(true) as DocumentFragment)
+ .firstElementChild as HTMLUListElement
+ sentences.forEach((sentence) => {
+ const itemTemplate = getTemplate('instruction-list-item-template')
+ const li = (itemTemplate.content.cloneNode(true) as DocumentFragment)
+ .firstElementChild as HTMLLIElement
+ const content = li.querySelector('span:last-child') as HTMLSpanElement
+ content.innerHTML = highlightKeywords(sentence)
+ ul.appendChild(li)
+ })
+ instructionBoxEl.appendChild(ul)
+ } else {
+ const paragraphTemplate = getTemplate('instruction-paragraph-template')
+ const p = (paragraphTemplate.content.cloneNode(true) as DocumentFragment)
+ .firstElementChild as HTMLParagraphElement
+ p.innerHTML = highlightKeywords(instr)
+ instructionBoxEl.appendChild(p)
+ }
+
+ return instructionBoxEl
+}
+
+function renderAlertCard(
+ alert: CAPAlert,
+ info: CAPInfo,
+ nearestExit: string | undefined,
+): HTMLElement {
+ const template = getTemplate('alert-card-template')
+ const card = (template.content.cloneNode(true) as DocumentFragment)
+ .firstElementChild as HTMLDivElement
+
+ const statusContainer = card.querySelector(
+ '#status-banner-container',
+ ) as HTMLDivElement
+ if (alert.status) {
+ statusContainer.appendChild(createStatusBanner(alert.status))
+ }
+
+ const headerContainer = card.querySelector(
+ '#header-row-container',
+ ) as HTMLDivElement
+ headerContainer.appendChild(createHeaderRow(info, alert.identifier))
+
+ const headlineEl = card.querySelector('#headline') as HTMLHeadingElement
+ if (info.headline) {
+ headlineEl.textContent = info.headline
+ } else {
+ headlineEl.style.display = 'none'
+ }
+
+ const descriptionEl = card.querySelector(
+ '#description',
+ ) as HTMLParagraphElement
+ if (info.description) {
+ // Try to parse and render NWS formatted content
+ if (isNwsAlert(alert.sender)) {
+ const nwsResult = parseNwsTextProduct(info.description)
+ if (nwsResult) {
+ // Replace the paragraph with the rendered NWS content
+ const nwsContent =
+ nwsResult.type === 'wwwi'
+ ? renderNwsWwwiContent(nwsResult)
+ : renderNwsPeriodContent(nwsResult)
+ descriptionEl.replaceWith(nwsContent)
+ } else {
+ descriptionEl.textContent = info.description
+ }
+ } else {
+ descriptionEl.textContent = info.description
+ }
+ } else {
+ descriptionEl.style.display = 'none'
+ }
+
+ const instructionContainer = card.querySelector(
+ '#instruction-container',
+ ) as HTMLDivElement
+ if (info.instruction) {
+ instructionContainer.appendChild(
+ createInstructionBox(info.instruction, nearestExit),
+ )
+ }
+
+ const resourcesContainer = card.querySelector(
+ '#resources-container',
+ ) as HTMLDivElement
+ info.resources.forEach((res) => {
+ if (res.mimeType && res.mimeType.startsWith('image')) {
+ const imgTemplate = getTemplate('image-resource-template')
+ const imgWrapper = (
+ imgTemplate.content.cloneNode(true) as DocumentFragment
+ ).firstElementChild as HTMLDivElement
+ const img = imgWrapper.querySelector('img') as HTMLImageElement
+ img.src = proxyUrl(res.url)
+ resourcesContainer.appendChild(imgWrapper)
+ }
+ })
+
+ return card
+}
+
+function renderAlerts(
+ alerts: CAPAlert[],
+ nearestExit: string | undefined,
+ lang: string,
+ maxAlerts: number,
+): 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 = renderAlertCard(alert, info, nearestExit)
+ container.appendChild(card)
+ })
+}
+
+export async function startApp(): Promise {
+ setupTheme()
+
+ const feedUrl = getSettingWithDefault('cap_feed_url', '')
+ const interval = getSettingWithDefault('refresh_interval', 5)
+ const lang = getSettingWithDefault('language', 'en')
+ const maxAlerts = getSettingWithDefault('max_alerts', Infinity)
+ 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,
+ })
+
+ async function update() {
+ const xml = await fetcher.fetch()
+ if (xml) {
+ const alerts = parseCap(xml)
+ renderAlerts(alerts, nearestExit, lang, maxAlerts)
+ } else {
+ throw new Error(
+ 'No CAP data available. Make sure to provide a valid feed URL.',
+ )
+ }
+ }
+
+ await update()
+ signalReady()
+
+ setInterval(update, interval * 60 * 1000)
+}
+
+if (typeof window !== 'undefined' && typeof document !== 'undefined') {
+ window.onload = function () {
+ setupErrorHandling()
+ startApp()
+ }
+}
diff --git a/edge-apps/cap-alerting/src/nws.css b/edge-apps/cap-alerting/src/nws.css
new file mode 100644
index 000000000..ea65ef238
--- /dev/null
+++ b/edge-apps/cap-alerting/src/nws.css
@@ -0,0 +1,34 @@
+/* NWS formatted content styling */
+.nws-preamble {
+ margin-bottom: 1.5vh;
+ line-height: 1.4;
+}
+
+.nws-forecast-list,
+.nws-wwwi-list {
+ list-style: none;
+ padding: 0;
+ margin: 0;
+}
+
+.nws-forecast-list li,
+.nws-wwwi-list li {
+ position: relative;
+ padding-left: 3vw;
+ margin-bottom: 1vh;
+}
+
+.nws-forecast-list li::before,
+.nws-wwwi-list li::before {
+ content: '•';
+ position: absolute;
+ left: 0;
+ color: rgb(234, 88, 12);
+ font-weight: 900;
+}
+
+.nws-forecast-list li strong,
+.nws-wwwi-list li strong {
+ color: rgb(30, 64, 175);
+ font-weight: 800;
+}
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..7efc0e9e0
--- /dev/null
+++ b/edge-apps/cap-alerting/src/parser.test.ts
@@ -0,0 +1,1842 @@
+/* eslint-disable max-lines-per-function */
+
+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..e6fd8349d
--- /dev/null
+++ b/edge-apps/cap-alerting/src/parser.ts
@@ -0,0 +1,304 @@
+import { CAPInfo, CAPAlert } from './types/cap.js'
+import { XMLParser } from 'fast-xml-parser'
+
+function getStringOrUndefined(value: unknown): string | undefined {
+ return typeof value === 'string' ? value : undefined
+}
+
+function getNumberOrUndefined(value: unknown): number | undefined {
+ return typeof value === 'number' ? value : undefined
+}
+
+function parseResource(res: Record) {
+ const resourceDesc = getStringOrUndefined(res.resourceDesc)
+ const mimeType =
+ getStringOrUndefined((res as Record).mimeType) ??
+ getStringOrUndefined((res as Record)['mimeType'])
+ const size = getNumberOrUndefined(res.size)
+ const uri = getStringOrUndefined(res.uri)
+ const derefUri = getStringOrUndefined(res.derefUri)
+ const digest = getStringOrUndefined(res.digest)
+
+ return {
+ resourceDesc,
+ mimeType,
+ size,
+ uri,
+ derefUri,
+ digest,
+ url: uri ?? resourceDesc ?? '',
+ }
+}
+
+function parseArea(area: Record) {
+ const areaDesc = getStringOrUndefined(area.areaDesc) ?? ''
+ const polygon = getStringOrUndefined(area.polygon) ?? area.polygon
+ const circle = getStringOrUndefined(area.circle) ?? area.circle
+ const geocode = area.geocode
+ const altitude = getNumberOrUndefined(area.altitude) ?? area.altitude
+ const ceiling = getNumberOrUndefined(area.ceiling) ?? area.ceiling
+
+ return {
+ areaDesc,
+ polygon,
+ circle,
+ geocode,
+ altitude,
+ ceiling,
+ }
+}
+
+function parseInfo(info: CAPInfo): 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(parseResource),
+ areas: areasJson.map(parseArea),
+ }
+}
+
+function parseAlert(a: CAPAlert): CAPAlert {
+ const infosJson = a.info ? (Array.isArray(a.info) ? a.info : [a.info]) : []
+ const infos: CAPInfo[] = infosJson.map(parseInfo)
+
+ return {
+ 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,
+ }
+}
+
+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]
+ : []
+
+ return alertsJson.map(parseAlert)
+}
+
+/** Represents a single section in NWS formatted content */
+export interface NwsSection {
+ label: string
+ content: string
+}
+
+/** Result of parsing NWS WWWI format (What/Where/When/Impacts) */
+export interface NwsWwwiResult {
+ type: 'wwwi'
+ sections: NwsSection[]
+}
+
+/** Result of parsing NWS period-based forecast format */
+export interface NwsPeriodResult {
+ type: 'period'
+ preamble: string
+ periods: NwsSection[]
+}
+
+/** Result of parsing NWS text - either WWWI, period format, or plain text */
+export type NwsParseResult = NwsWwwiResult | NwsPeriodResult | null
+
+/**
+ * Checks if the CAP alert is from the National Weather Service (NWS).
+ * NWS alerts use the sender email: w-nws.webmaster@noaa.gov
+ *
+ * @param sender - The sender field from the CAP alert
+ * @returns true if the alert is from NWS
+ */
+export function isNwsAlert(sender: string): boolean {
+ return sender === 'w-nws.webmaster@noaa.gov'
+}
+
+/**
+ * Parses NWS "What/Where/When/Impacts" (WWWI) format used in Impact Based Warnings.
+ * Returns structured data with sections for rendering.
+ *
+ * Example input:
+ * "* WHAT...North winds 25 to 30 kt. * WHERE...Pt St George to Cape Mendocino."
+ *
+ * @param text - The NWS text product description
+ * @returns Parsed WWWI result or null if format not detected
+ */
+export function parseNwsWwwiProduct(text: string): NwsWwwiResult | null {
+ if (!text) return null
+
+ // Check if this looks like an NWS WWWI format
+ const wwwiPattern =
+ /\*\s*(WHAT|WHERE|WHEN|IMPACTS?|ADDITIONAL DETAILS?)\.{3}/i
+ if (!wwwiPattern.test(text)) {
+ return null
+ }
+
+ // Split into sections
+ const sections: NwsSection[] = []
+ const sectionRegex =
+ /\*\s*(WHAT|WHERE|WHEN|IMPACTS?|ADDITIONAL DETAILS?)\.{3}\s*/gi
+ let match: RegExpExecArray | null
+
+ // Find all section markers and their positions
+ const matches: { label: string; index: number; length: number }[] = []
+ while ((match = sectionRegex.exec(text)) !== null) {
+ matches.push({
+ label: match[1],
+ index: match.index,
+ length: match[0].length,
+ })
+ }
+
+ // Extract content for each section
+ for (let i = 0; i < matches.length; i++) {
+ const current = matches[i]
+ const contentStart = current.index + current.length
+ const contentEnd =
+ i < matches.length - 1 ? matches[i + 1].index : text.length
+ const content = text.slice(contentStart, contentEnd).trim()
+
+ if (content) {
+ sections.push({ label: current.label, content })
+ }
+ }
+
+ if (sections.length === 0) {
+ return null
+ }
+
+ return { type: 'wwwi', sections }
+}
+
+/**
+ * Parses NWS text products that use the legacy .PERIOD... format
+ * commonly found in marine forecasts, zone forecasts, and CAP descriptions.
+ * Returns structured data with preamble and periods for rendering.
+ *
+ * Example input:
+ * "Coastal Waters Forecast... .TODAY...E wind 20 kt. .TONIGHT...E wind 20 kt."
+ *
+ * @param text - The NWS text product description
+ * @returns Parsed period result or null if format not detected
+ */
+export function parseNwsPeriodProduct(text: string): NwsPeriodResult | null {
+ if (!text) return null
+
+ // Pattern to match period markers including "AND" combinations like ".SUN AND SUN NIGHT..."
+ const periodPattern =
+ /\.(TODAY|TONIGHT|TOMORROW|(?:MON|TUES|WEDNES|THURS|FRI|SATUR|SUN)(?:DAY)?|(?:MON|TUE|WED|THU|FRI|SAT|SUN))(\s+(?:AND\s+)?(?:NIGHT|MORNING|AFTERNOON|(?:MON|TUE|WED|THU|FRI|SAT|SUN)(?:\s+NIGHT)?|THROUGH\s+\w+))?\.{3}/gi
+
+ // Check if this looks like an NWS text product with .PERIOD... format
+ if (!periodPattern.test(text)) {
+ return null
+ }
+
+ // Reset regex lastIndex after test
+ periodPattern.lastIndex = 0
+
+ // Find the first period marker to split preamble from forecasts
+ const firstMatch = periodPattern.exec(text)
+ if (!firstMatch) {
+ return null
+ }
+
+ const preamble = text.slice(0, firstMatch.index).trim()
+
+ // Reset and find all period markers
+ periodPattern.lastIndex = 0
+ const periods: NwsSection[] = []
+ const matches: { label: string; index: number; length: number }[] = []
+ let match: RegExpExecArray | null
+
+ while ((match = periodPattern.exec(text)) !== null) {
+ const day = match[1]
+ const modifier = match[2] || ''
+ matches.push({
+ label: `${day}${modifier}`.trim(),
+ index: match.index,
+ length: match[0].length,
+ })
+ }
+
+ // Extract content for each period
+ for (let i = 0; i < matches.length; i++) {
+ const current = matches[i]
+ const contentStart = current.index + current.length
+ const contentEnd =
+ i < matches.length - 1 ? matches[i + 1].index : text.length
+ const content = text.slice(contentStart, contentEnd).trim()
+
+ if (content) {
+ periods.push({ label: current.label, content })
+ }
+ }
+
+ if (periods.length === 0) {
+ return null
+ }
+
+ return { type: 'period', preamble, periods }
+}
+
+/**
+ * Parses NWS text products for improved readability on digital signage.
+ * Detects format type and returns structured data:
+ * - Period-based forecasts: .TODAY..., .TONIGHT..., .MON..., etc.
+ * - Impact Based Warnings (WWWI): * WHAT..., * WHERE..., * WHEN..., * IMPACTS...
+ *
+ * @param text - The NWS text product description
+ * @returns Parsed result or null if no NWS format detected
+ */
+export function parseNwsTextProduct(text: string): NwsParseResult {
+ if (!text) return null
+
+ // Try WWWI format first (Impact Based Warnings)
+ const wwwiResult = parseNwsWwwiProduct(text)
+ if (wwwiResult) {
+ return wwwiResult
+ }
+
+ // Try period-based format (marine forecasts, etc.)
+ return parseNwsPeriodProduct(text)
+}
diff --git a/edge-apps/cap-alerting/src/render.ts b/edge-apps/cap-alerting/src/render.ts
new file mode 100644
index 000000000..0f6e02ddf
--- /dev/null
+++ b/edge-apps/cap-alerting/src/render.ts
@@ -0,0 +1,97 @@
+import { NwsWwwiResult, NwsPeriodResult } from './parser'
+
+function getTemplate(id: string): HTMLTemplateElement {
+ const template = document.getElementById(id) as HTMLTemplateElement | null
+ if (!template) throw new Error(`Template ${id} not found`)
+ return template
+}
+
+export function renderNwsWwwiContent(result: NwsWwwiResult): HTMLElement {
+ const template = getTemplate('nws-wwwi-template')
+ const fragment = template.content.cloneNode(true) as DocumentFragment
+ const list = fragment.querySelector('.nws-wwwi-list') as HTMLUListElement
+
+ const itemTemplate = getTemplate('nws-wwwi-item-template')
+
+ for (const section of result.sections) {
+ const itemFragment = itemTemplate.content.cloneNode(
+ true,
+ ) as DocumentFragment
+ const label = itemFragment.querySelector('.wwwi-label') as HTMLElement
+ const content = itemFragment.querySelector('.wwwi-content') as HTMLElement
+
+ label.textContent = `${section.label}:`
+ content.textContent = ` ${section.content}`
+
+ list.appendChild(itemFragment)
+ }
+
+ return list
+}
+
+export function renderNwsPeriodContent(result: NwsPeriodResult): HTMLElement {
+ const template = getTemplate('nws-forecast-template')
+ const fragment = template.content.cloneNode(true) as DocumentFragment
+ const container = fragment.querySelector('.nws-content') as HTMLDivElement
+ const preambleEl = container.querySelector(
+ '.nws-preamble',
+ ) as HTMLParagraphElement
+ const list = container.querySelector('.nws-forecast-list') as HTMLUListElement
+
+ if (result.preamble) {
+ preambleEl.textContent = result.preamble
+ } else {
+ preambleEl.remove()
+ }
+
+ const itemTemplate = getTemplate('nws-forecast-item-template')
+
+ for (const period of result.periods) {
+ const itemFragment = itemTemplate.content.cloneNode(
+ true,
+ ) as DocumentFragment
+ const label = itemFragment.querySelector('.period-label') as HTMLElement
+ const content = itemFragment.querySelector('.period-content') as HTMLElement
+
+ label.textContent = `${period.label}:`
+ content.textContent = ` ${period.content}`
+
+ list.appendChild(itemFragment)
+ }
+
+ return container
+}
+
+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..1666b69f9
--- /dev/null
+++ b/edge-apps/cap-alerting/src/utils.test.ts
@@ -0,0 +1,433 @@
+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 '@screenly/edge-apps/test'
+import { getNearestExit, splitIntoSentences, proxyUrl } from './utils'
+import {
+ isNwsAlert,
+ parseNwsTextProduct,
+ parseNwsWwwiProduct,
+ parseNwsPeriodProduct,
+} from './parser'
+
+// eslint-disable-next-line max-lines-per-function
+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',
+ )
+ })
+ })
+
+ describe('NWS Alert Detection', () => {
+ it('should detect NWS alert from official sender email', () => {
+ expect(isNwsAlert('w-nws.webmaster@noaa.gov')).toBe(true)
+ })
+
+ it('should reject non-NWS sender emails', () => {
+ expect(isNwsAlert('alert@weather.com')).toBe(false)
+ expect(isNwsAlert('admin@example.com')).toBe(false)
+ expect(isNwsAlert('nws@weather.gov')).toBe(false)
+ })
+
+ it('should be case-sensitive for NWS email', () => {
+ expect(isNwsAlert('W-NWS.WEBMASTER@NOAA.GOV')).toBe(false)
+ })
+
+ it('should handle empty string', () => {
+ expect(isNwsAlert('')).toBe(false)
+ })
+ })
+
+ describe('NWS Text Product Formatting - Basic Patterns', () => {
+ it('should parse .TODAY... pattern', () => {
+ const text = '.TODAY...E wind 20 kt. Seas 11 ft.'
+ const result = parseNwsPeriodProduct(text)
+ expect(result).not.toBeNull()
+ expect(result?.type).toBe('period')
+ expect(result?.periods[0].label).toBe('TODAY')
+ expect(result?.periods[0].content).toBe('E wind 20 kt. Seas 11 ft.')
+ })
+
+ it('should parse .TONIGHT... pattern', () => {
+ const text = '.TONIGHT...E wind 20 kt. Seas 9 ft.'
+ const result = parseNwsPeriodProduct(text)
+ expect(result?.periods[0].label).toBe('TONIGHT')
+ expect(result?.periods[0].content).toBe('E wind 20 kt. Seas 9 ft.')
+ })
+
+ it('should parse multiple periods', () => {
+ const text =
+ '.TODAY...E wind 20 kt. Seas 11 ft. .TONIGHT...E wind 20 kt. Seas 9 ft.'
+ const result = parseNwsPeriodProduct(text)
+ expect(result?.periods.length).toBe(2)
+ expect(result?.periods[0].label).toBe('TODAY')
+ expect(result?.periods[1].label).toBe('TONIGHT')
+ })
+
+ it('should parse day abbreviations (MON, TUE, etc.)', () => {
+ const text = '.MON...Variable wind 10 kt. .TUE...Variable wind 10 kt.'
+ const result = parseNwsPeriodProduct(text)
+ expect(result?.periods[0].label).toBe('MON')
+ expect(result?.periods[1].label).toBe('TUE')
+ })
+
+ it('should handle full day names (MONDAY, TUESDAY, etc.)', () => {
+ const text = '.MONDAY...E wind 15 kt. .TUESDAY...SE wind 20 kt.'
+ const result = parseNwsPeriodProduct(text)
+ expect(result?.periods[0].label).toBe('MONDAY')
+ expect(result?.periods[1].label).toBe('TUESDAY')
+ })
+ })
+
+ describe('NWS Text Product Formatting - Qualifiers', () => {
+ it('should handle period qualifiers like NIGHT', () => {
+ const text = '.SUN NIGHT...Variable wind 10 kt. Seas 7 ft.'
+ const result = parseNwsPeriodProduct(text)
+ expect(result?.periods[0].label).toBe('SUN NIGHT')
+ })
+
+ it('should handle period qualifiers like MORNING and AFTERNOON', () => {
+ const text =
+ '.SAT MORNING...E wind 15 kt. .SAT AFTERNOON...SE wind 20 kt.'
+ const result = parseNwsPeriodProduct(text)
+ expect(result?.periods[0].label).toBe('SAT MORNING')
+ expect(result?.periods[1].label).toBe('SAT AFTERNOON')
+ })
+
+ it('should handle THROUGH period ranges', () => {
+ const text = '.SUN THROUGH MON...SE wind 15 kt. Seas 7 ft.'
+ const result = parseNwsPeriodProduct(text)
+ expect(result?.periods[0].label).toBe('SUN THROUGH MON')
+ })
+
+ it('should handle AND combinations like SUN AND SUN NIGHT', () => {
+ const text = '.SUN AND SUN NIGHT...NE wind 15 kt. Seas 6 ft.'
+ const result = parseNwsPeriodProduct(text)
+ expect(result?.periods[0].label).toBe('SUN AND SUN NIGHT')
+ })
+ })
+
+ describe('NWS Text Product Formatting - Real Examples', () => {
+ it('should format real NWS marine forecast with preamble', () => {
+ const text = `Coastal Waters Forecast for the Northern Gulf of Alaska Coast up to 100 nm out including Kodiak Island and Cook Inlet. Wind forecasts reflect the predominant speed and direction expected. Sea forecasts represent an average of the highest one-third of the combined wind wave and swell height. .TODAY...E wind 20 kt. Seas 11 ft. .TONIGHT...E wind 20 kt. Seas 9 ft. .SUN...E wind 20 kt. Seas 7 ft.`
+
+ const result = parseNwsPeriodProduct(text)
+ // Should have preamble
+ expect(result?.preamble).toContain('Coastal Waters Forecast')
+ // Should have forecast periods
+ expect(result?.periods.length).toBe(3)
+ expect(result?.periods[0].label).toBe('TODAY')
+ expect(result?.periods[1].label).toBe('TONIGHT')
+ expect(result?.periods[2].label).toBe('SUN')
+ })
+
+ it('should format complex example with AND pattern', () => {
+ const text = `Coastal Waters Forecast... .TONIGHT...Variable wind 10 kt becoming NE 15 kt after midnight. Seas 15 ft subsiding to 10 ft after midnight. .SAT...E wind 25 kt. Seas 10 ft. .SAT NIGHT...E wind 25 kt. Seas 9 ft. .SUN AND SUN NIGHT...NE wind 15 kt. Seas 6 ft. .MON...NE wind 15 kt. Seas 5 ft.`
+
+ const result = parseNwsPeriodProduct(text)
+ expect(result?.periods[0].label).toBe('TONIGHT')
+ expect(result?.periods[1].label).toBe('SAT')
+ expect(result?.periods[2].label).toBe('SAT NIGHT')
+ expect(result?.periods[3].label).toBe('SUN AND SUN NIGHT')
+ expect(result?.periods[4].label).toBe('MON')
+ })
+
+ it('should preserve preamble text before first period marker', () => {
+ const text = 'Synopsis: High pressure continues. .TODAY...E wind 20 kt.'
+ const result = parseNwsPeriodProduct(text)
+ expect(result?.preamble).toBe('Synopsis: High pressure continues.')
+ expect(result?.periods[0].label).toBe('TODAY')
+ })
+ })
+
+ describe('NWS Text Product Formatting - Edge Cases', () => {
+ it('should not modify text without NWS period format', () => {
+ const text = 'This is a regular weather alert without special formatting.'
+ const result = parseNwsTextProduct(text)
+ expect(result).toBeNull()
+ })
+
+ it('should handle case-insensitive period patterns', () => {
+ const text = '.today...Wind 10 kt. .Tonight...Wind 5 kt.'
+ const result = parseNwsPeriodProduct(text)
+ expect(result?.periods[0].label).toBe('today')
+ expect(result?.periods[1].label).toBe('Tonight')
+ })
+
+ it('should return null for empty input', () => {
+ expect(parseNwsTextProduct('')).toBeNull()
+ })
+ })
+
+ describe('NWS WWWI Format - Basic Patterns', () => {
+ it('should format * WHAT... pattern', () => {
+ const text = '* WHAT...North winds 25 to 30 kt with gusts up to 35 kt.'
+ const result = parseNwsWwwiProduct(text)
+ expect(result?.sections[0].label).toBe('WHAT')
+ expect(result?.sections[0].content).toBe(
+ 'North winds 25 to 30 kt with gusts up to 35 kt.',
+ )
+ })
+
+ it('should format * WHERE... pattern', () => {
+ const text = '* WHERE...Pt St George to Cape Mendocino 10 to 60 nm.'
+ const result = parseNwsWwwiProduct(text)
+ expect(result?.sections[0].label).toBe('WHERE')
+ expect(result?.sections[0].content).toBe(
+ 'Pt St George to Cape Mendocino 10 to 60 nm.',
+ )
+ })
+
+ it('should format * WHEN... pattern', () => {
+ const text = '* WHEN...Until 3 AM PST Saturday.'
+ const result = parseNwsWwwiProduct(text)
+ expect(result?.sections[0].label).toBe('WHEN')
+ expect(result?.sections[0].content).toBe('Until 3 AM PST Saturday.')
+ })
+
+ it('should format * IMPACTS... pattern', () => {
+ const text = '* IMPACTS...Strong winds will cause hazardous seas.'
+ const result = parseNwsWwwiProduct(text)
+ expect(result?.sections[0].label).toBe('IMPACTS')
+ expect(result?.sections[0].content).toBe(
+ 'Strong winds will cause hazardous seas.',
+ )
+ })
+
+ it('should format * IMPACT... pattern (singular)', () => {
+ const text = '* IMPACT...Flooding of low-lying areas.'
+ const result = parseNwsWwwiProduct(text)
+ expect(result?.sections[0].label).toBe('IMPACT')
+ expect(result?.sections[0].content).toBe('Flooding of low-lying areas.')
+ })
+ })
+
+ describe('NWS WWWI Format - Complete Examples', () => {
+ it('should format complete WWWI alert', () => {
+ const text =
+ '* WHAT...North winds 25 to 30 kt with gusts up to 35 kt and seas 9 to 12 feet. * WHERE...Pt St George to Cape Mendocino 10 to 60 nm and Cape Mendocino to Pt Arena 10 to 60 nm. * WHEN...Until 3 AM PST Saturday. * IMPACTS...Strong winds will cause hazardous seas which could capsize or damage vessels and reduce visibility. '
+ const result = parseNwsWwwiProduct(text)
+ expect(result?.sections.length).toBe(4)
+ expect(result?.sections[0].label).toBe('WHAT')
+ expect(result?.sections[0].content).toContain('North winds 25 to 30 kt')
+ expect(result?.sections[1].label).toBe('WHERE')
+ expect(result?.sections[1].content).toContain(
+ 'Pt St George to Cape Mendocino',
+ )
+ expect(result?.sections[2].label).toBe('WHEN')
+ expect(result?.sections[2].content).toBe('Until 3 AM PST Saturday.')
+ expect(result?.sections[3].label).toBe('IMPACTS')
+ expect(result?.sections[3].content).toContain(
+ 'Strong winds will cause hazardous seas',
+ )
+ })
+
+ it('should output list items for WWWI sections', () => {
+ const text =
+ '* WHAT...Heavy rain. * WHERE...Coastal areas. * WHEN...Tonight.'
+ const result = parseNwsWwwiProduct(text)
+ expect(result?.sections[0].content).toBe('Heavy rain.')
+ expect(result?.sections[1].content).toBe('Coastal areas.')
+ expect(result?.sections[2].content).toBe('Tonight.')
+ })
+ })
+
+ describe('NWS WWWI Format - Edge Cases', () => {
+ it('should not modify text without WWWI format', () => {
+ const text = 'Regular weather alert text without special markers.'
+ const result = parseNwsWwwiProduct(text)
+ expect(result).toBeNull()
+ })
+
+ it('should handle case-insensitive WWWI patterns', () => {
+ const text = '* what...Heavy rain. * Where...Coastal areas.'
+ const result = parseNwsWwwiProduct(text)
+ expect(result?.sections[0].label).toBe('what')
+ expect(result?.sections[1].label).toBe('Where')
+ })
+
+ it('should return null for empty input', () => {
+ expect(parseNwsWwwiProduct('')).toBeNull()
+ })
+
+ it('should handle ADDITIONAL DETAILS section', () => {
+ const text = '* WHAT...Snow. * ADDITIONAL DETAILS...Expect travel delays.'
+ const result = parseNwsWwwiProduct(text)
+ expect(result?.sections[0].label).toBe('WHAT')
+ expect(result?.sections[0].content).toBe('Snow.')
+ expect(result?.sections[1].label).toBe('ADDITIONAL DETAILS')
+ expect(result?.sections[1].content).toBe('Expect travel delays.')
+ })
+ })
+
+ describe('NWS Combined Format Detection', () => {
+ it('should detect and format WWWI format via parseNwsTextProduct', () => {
+ const text = '* WHAT...Heavy snow. * WHEN...Tonight through tomorrow.'
+ const result = parseNwsTextProduct(text)
+ expect(result?.type).toBe('wwwi')
+ if (result?.type === 'wwwi') {
+ expect(result.sections[0].label).toBe('WHAT')
+ expect(result.sections[0].content).toBe('Heavy snow.')
+ expect(result.sections[1].label).toBe('WHEN')
+ expect(result.sections[1].content).toBe('Tonight through tomorrow.')
+ }
+ })
+
+ it('should detect and format period format via parseNwsTextProduct', () => {
+ const text = '.TODAY...E wind 20 kt. .TONIGHT...E wind 15 kt.'
+ const result = parseNwsTextProduct(text)
+ expect(result?.type).toBe('period')
+ if (result?.type === 'period') {
+ expect(result.periods[0].label).toBe('TODAY')
+ expect(result.periods[0].content).toBe('E wind 20 kt.')
+ expect(result.periods[1].label).toBe('TONIGHT')
+ expect(result.periods[1].content).toBe('E wind 15 kt.')
+ }
+ })
+
+ it('should prefer WWWI format when both patterns present', () => {
+ // This is an edge case - in practice alerts use one format or the other
+ const text = '* WHAT...Storm. .TODAY...E wind.'
+ const result = parseNwsTextProduct(text)
+ // WWWI format should be detected and applied
+ expect(result?.type).toBe('wwwi')
+ })
+ })
+})
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/edge-apps-library/eslint.config.ts b/edge-apps/edge-apps-library/eslint.config.ts
index 692a0757a..0dab1c189 100644
--- a/edge-apps/edge-apps-library/eslint.config.ts
+++ b/edge-apps/edge-apps-library/eslint.config.ts
@@ -5,7 +5,13 @@ 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',
+ ]),
{
rules: {
'max-lines-per-function': ['error', {