commit 58b379d6e3abe37dbe37be1b5e73a60ccd9c8473
Author: Антон Трошин
Date: Thu Jul 23 02:41:15 2026 +0300
initial commit
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..a8966d7
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,7 @@
+dist/
+node_modules/
+static/data/
+.opencode/
+*.log
+.DS_Store
+Thumbs.db
diff --git a/index.html b/index.html
new file mode 100644
index 0000000..54edda0
--- /dev/null
+++ b/index.html
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
+
+
+
+
+ Ponomar
+
+
+
+
+
+
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..1068c7d
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,1948 @@
+{
+ "name": "ponomar-pwa",
+ "version": "0.1.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "ponomar-pwa",
+ "version": "0.1.0",
+ "devDependencies": {
+ "@tailwindcss/vite": "^4.3.3",
+ "fast-xml-parser": "^5.10.1",
+ "tailwindcss": "^4.3.3",
+ "tsx": "^4.23.1",
+ "typescript": "^6.0.3",
+ "vite": "^8.0.16"
+ }
+ },
+ "node_modules/@emnapi/core": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz",
+ "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/wasi-threads": "1.2.2",
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@emnapi/runtime": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz",
+ "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@emnapi/wasi-threads": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz",
+ "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
+ "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz",
+ "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz",
+ "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz",
+ "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz",
+ "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz",
+ "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz",
+ "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz",
+ "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz",
+ "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz",
+ "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz",
+ "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz",
+ "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz",
+ "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz",
+ "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz",
+ "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz",
+ "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz",
+ "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz",
+ "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz",
+ "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz",
+ "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz",
+ "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openharmony-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz",
+ "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz",
+ "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz",
+ "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz",
+ "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz",
+ "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@jridgewell/gen-mapping": {
+ "version": "0.3.13",
+ "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
+ "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.0",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/remapping": {
+ "version": "2.3.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
+ "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/gen-mapping": "^0.3.5",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/resolve-uri": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
+ "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.5.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+ "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@jridgewell/trace-mapping": {
+ "version": "0.3.31",
+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
+ "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/resolve-uri": "^3.1.0",
+ "@jridgewell/sourcemap-codec": "^1.4.14"
+ }
+ },
+ "node_modules/@napi-rs/wasm-runtime": {
+ "version": "1.1.6",
+ "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz",
+ "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@tybys/wasm-util": "^0.10.3"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/Brooooooklyn"
+ },
+ "peerDependencies": {
+ "@emnapi/core": "^1.7.1",
+ "@emnapi/runtime": "^1.7.1"
+ }
+ },
+ "node_modules/@nodable/entities": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-3.0.0.tgz",
+ "integrity": "sha512-8L9xFeTYKhm49xfIypoe2W5wV1m/3Z58kT+7kR9A8OyFxcPduI4VmxaUMQyKYrRjUoLLSXv6EKKID5Tvj9cUVw==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/nodable"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/@oxc-project/types": {
+ "version": "0.139.0",
+ "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz",
+ "integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/Boshen"
+ }
+ },
+ "node_modules/@rolldown/binding-android-arm64": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz",
+ "integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-darwin-arm64": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz",
+ "integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-darwin-x64": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz",
+ "integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-freebsd-x64": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz",
+ "integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm-gnueabihf": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz",
+ "integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm64-gnu": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz",
+ "integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm64-musl": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz",
+ "integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-ppc64-gnu": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz",
+ "integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-s390x-gnu": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz",
+ "integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-x64-gnu": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz",
+ "integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-x64-musl": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz",
+ "integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-openharmony-arm64": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz",
+ "integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-wasm32-wasi": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz",
+ "integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==",
+ "cpu": [
+ "wasm32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/core": "1.11.1",
+ "@emnapi/runtime": "1.11.1",
+ "@napi-rs/wasm-runtime": "^1.1.6"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-win32-arm64-msvc": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz",
+ "integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-win32-x64-msvc": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz",
+ "integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/pluginutils": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
+ "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@tailwindcss/node": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz",
+ "integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/remapping": "^2.3.5",
+ "enhanced-resolve": "^5.24.1",
+ "jiti": "^2.7.0",
+ "lightningcss": "1.32.0",
+ "magic-string": "^0.30.21",
+ "source-map-js": "^1.2.1",
+ "tailwindcss": "4.3.3"
+ }
+ },
+ "node_modules/@tailwindcss/oxide": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.3.tgz",
+ "integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 20"
+ },
+ "optionalDependencies": {
+ "@tailwindcss/oxide-android-arm64": "4.3.3",
+ "@tailwindcss/oxide-darwin-arm64": "4.3.3",
+ "@tailwindcss/oxide-darwin-x64": "4.3.3",
+ "@tailwindcss/oxide-freebsd-x64": "4.3.3",
+ "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3",
+ "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3",
+ "@tailwindcss/oxide-linux-arm64-musl": "4.3.3",
+ "@tailwindcss/oxide-linux-x64-gnu": "4.3.3",
+ "@tailwindcss/oxide-linux-x64-musl": "4.3.3",
+ "@tailwindcss/oxide-wasm32-wasi": "4.3.3",
+ "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3",
+ "@tailwindcss/oxide-win32-x64-msvc": "4.3.3"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-android-arm64": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz",
+ "integrity": "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-darwin-arm64": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz",
+ "integrity": "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-darwin-x64": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz",
+ "integrity": "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-freebsd-x64": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz",
+ "integrity": "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.3.tgz",
+ "integrity": "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-arm64-gnu": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.3.tgz",
+ "integrity": "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-arm64-musl": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.3.tgz",
+ "integrity": "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-x64-gnu": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz",
+ "integrity": "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-x64-musl": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz",
+ "integrity": "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-wasm32-wasi": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.3.tgz",
+ "integrity": "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==",
+ "bundleDependencies": [
+ "@napi-rs/wasm-runtime",
+ "@emnapi/core",
+ "@emnapi/runtime",
+ "@tybys/wasm-util",
+ "@emnapi/wasi-threads",
+ "tslib"
+ ],
+ "cpu": [
+ "wasm32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/core": "^1.11.1",
+ "@emnapi/runtime": "^1.11.1",
+ "@emnapi/wasi-threads": "^1.2.2",
+ "@napi-rs/wasm-runtime": "^1.1.4",
+ "@tybys/wasm-util": "^0.10.2",
+ "tslib": "^2.8.1"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz",
+ "integrity": "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-win32-x64-msvc": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.3.tgz",
+ "integrity": "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/vite": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.3.tgz",
+ "integrity": "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@tailwindcss/node": "4.3.3",
+ "@tailwindcss/oxide": "4.3.3",
+ "tailwindcss": "4.3.3"
+ },
+ "peerDependencies": {
+ "vite": "^5.2.0 || ^6 || ^7 || ^8"
+ }
+ },
+ "node_modules/@tybys/wasm-util": {
+ "version": "0.10.3",
+ "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz",
+ "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/anynum": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/anynum/-/anynum-1.0.1.tgz",
+ "integrity": "sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/NaturalIntelligence"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/detect-libc": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/enhanced-resolve": {
+ "version": "5.24.2",
+ "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.2.tgz",
+ "integrity": "sha512-rpsZEGT1jFuve6QlpyRp9ckQ+kN61hvF9BzCPyMdaKTm8UJce96KBn3sorXOFXlzjPrs3Vc4T1NsSroZ3PxlFw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "graceful-fs": "^4.2.4",
+ "tapable": "^2.3.3"
+ },
+ "engines": {
+ "node": ">=10.13.0"
+ }
+ },
+ "node_modules/esbuild": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
+ "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.28.1",
+ "@esbuild/android-arm": "0.28.1",
+ "@esbuild/android-arm64": "0.28.1",
+ "@esbuild/android-x64": "0.28.1",
+ "@esbuild/darwin-arm64": "0.28.1",
+ "@esbuild/darwin-x64": "0.28.1",
+ "@esbuild/freebsd-arm64": "0.28.1",
+ "@esbuild/freebsd-x64": "0.28.1",
+ "@esbuild/linux-arm": "0.28.1",
+ "@esbuild/linux-arm64": "0.28.1",
+ "@esbuild/linux-ia32": "0.28.1",
+ "@esbuild/linux-loong64": "0.28.1",
+ "@esbuild/linux-mips64el": "0.28.1",
+ "@esbuild/linux-ppc64": "0.28.1",
+ "@esbuild/linux-riscv64": "0.28.1",
+ "@esbuild/linux-s390x": "0.28.1",
+ "@esbuild/linux-x64": "0.28.1",
+ "@esbuild/netbsd-arm64": "0.28.1",
+ "@esbuild/netbsd-x64": "0.28.1",
+ "@esbuild/openbsd-arm64": "0.28.1",
+ "@esbuild/openbsd-x64": "0.28.1",
+ "@esbuild/openharmony-arm64": "0.28.1",
+ "@esbuild/sunos-x64": "0.28.1",
+ "@esbuild/win32-arm64": "0.28.1",
+ "@esbuild/win32-ia32": "0.28.1",
+ "@esbuild/win32-x64": "0.28.1"
+ }
+ },
+ "node_modules/fast-xml-builder": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.3.0.tgz",
+ "integrity": "sha512-F74cZEdCvuw9P41GAC3rod4X04jjWGM1JPEv/GWSqFTWLsdyMSBMBMlm9Hk3GLBgLBbdBNY8yee0pQh2RBVESQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/NaturalIntelligence"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "path-expression-matcher": "^1.6.2",
+ "xml-naming": "^0.3.0"
+ }
+ },
+ "node_modules/fast-xml-parser": {
+ "version": "5.10.1",
+ "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.10.1.tgz",
+ "integrity": "sha512-IEMIf7298kXuZSRFoGfMYrl7is8LpavODgbNz1cwIudv7KwVFnuU+UsMporfq6PD6aXSlawZlARiA3UywCTfMw==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/NaturalIntelligence"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "@nodable/entities": "^3.0.0",
+ "fast-xml-builder": "^1.2.0",
+ "is-unsafe": "^2.0.0",
+ "path-expression-matcher": "^1.6.2",
+ "strnum": "^2.4.1",
+ "xml-naming": "^0.3.0"
+ },
+ "bin": {
+ "fxparser": "src/cli/cli.js"
+ }
+ },
+ "node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/graceful-fs": {
+ "version": "4.2.11",
+ "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
+ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/is-unsafe": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/is-unsafe/-/is-unsafe-2.0.0.tgz",
+ "integrity": "sha512-2LdV822R+wmI86unXA93WCFpL6g+av8ynWk0nrHyJqGop5VoocYsSLFgN8jrfalT6iGeLNM4KXuVSsULP53kEA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/NaturalIntelligence"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/jiti": {
+ "version": "2.7.0",
+ "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
+ "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "jiti": "lib/jiti-cli.mjs"
+ }
+ },
+ "node_modules/lightningcss": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
+ "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
+ "dev": true,
+ "license": "MPL-2.0",
+ "dependencies": {
+ "detect-libc": "^2.0.3"
+ },
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ },
+ "optionalDependencies": {
+ "lightningcss-android-arm64": "1.32.0",
+ "lightningcss-darwin-arm64": "1.32.0",
+ "lightningcss-darwin-x64": "1.32.0",
+ "lightningcss-freebsd-x64": "1.32.0",
+ "lightningcss-linux-arm-gnueabihf": "1.32.0",
+ "lightningcss-linux-arm64-gnu": "1.32.0",
+ "lightningcss-linux-arm64-musl": "1.32.0",
+ "lightningcss-linux-x64-gnu": "1.32.0",
+ "lightningcss-linux-x64-musl": "1.32.0",
+ "lightningcss-win32-arm64-msvc": "1.32.0",
+ "lightningcss-win32-x64-msvc": "1.32.0"
+ }
+ },
+ "node_modules/lightningcss-android-arm64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz",
+ "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-arm64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz",
+ "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-x64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz",
+ "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-freebsd-x64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz",
+ "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm-gnueabihf": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz",
+ "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-gnu": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz",
+ "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-musl": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz",
+ "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-gnu": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz",
+ "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-musl": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz",
+ "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-arm64-msvc": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz",
+ "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-x64-msvc": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz",
+ "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/magic-string": {
+ "version": "0.30.21",
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
+ "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.5"
+ }
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.16",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
+ "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/path-expression-matcher": {
+ "version": "1.6.2",
+ "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.6.2.tgz",
+ "integrity": "sha512-enSlaiat05iasnzmgNxRj8reFdj3puY2QpNgP1aPIaVfT6nn9ICuPoFlKHk8EN22HcwewshO+mN2DGbkCEOtqQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/NaturalIntelligence"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/picomatch": {
+ "version": "4.0.5",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
+ "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/postcss": {
+ "version": "8.5.19",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.19.tgz",
+ "integrity": "sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.12",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/rolldown": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz",
+ "integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@oxc-project/types": "=0.139.0",
+ "@rolldown/pluginutils": "^1.0.0"
+ },
+ "bin": {
+ "rolldown": "bin/cli.mjs"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "optionalDependencies": {
+ "@rolldown/binding-android-arm64": "1.1.5",
+ "@rolldown/binding-darwin-arm64": "1.1.5",
+ "@rolldown/binding-darwin-x64": "1.1.5",
+ "@rolldown/binding-freebsd-x64": "1.1.5",
+ "@rolldown/binding-linux-arm-gnueabihf": "1.1.5",
+ "@rolldown/binding-linux-arm64-gnu": "1.1.5",
+ "@rolldown/binding-linux-arm64-musl": "1.1.5",
+ "@rolldown/binding-linux-ppc64-gnu": "1.1.5",
+ "@rolldown/binding-linux-s390x-gnu": "1.1.5",
+ "@rolldown/binding-linux-x64-gnu": "1.1.5",
+ "@rolldown/binding-linux-x64-musl": "1.1.5",
+ "@rolldown/binding-openharmony-arm64": "1.1.5",
+ "@rolldown/binding-wasm32-wasi": "1.1.5",
+ "@rolldown/binding-win32-arm64-msvc": "1.1.5",
+ "@rolldown/binding-win32-x64-msvc": "1.1.5"
+ }
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/strnum": {
+ "version": "2.4.1",
+ "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.4.1.tgz",
+ "integrity": "sha512-M9eUSMT2dCB2cTNPG7UYj6KuK7RJR2SN2+yCV/fTW3xzTCS6EaGZ5pSMgDIjB7r8zSfTGk+dvvn9rTjpVS9Mwg==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/NaturalIntelligence"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "anynum": "^1.0.1"
+ }
+ },
+ "node_modules/tailwindcss": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz",
+ "integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tapable": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz",
+ "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/webpack"
+ }
+ },
+ "node_modules/tinyglobby": {
+ "version": "0.2.17",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
+ "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.4"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "node_modules/tslib": {
+ "version": "2.8.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+ "dev": true,
+ "license": "0BSD",
+ "optional": true
+ },
+ "node_modules/tsx": {
+ "version": "4.23.1",
+ "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.1.tgz",
+ "integrity": "sha512-GQHnkIfxyx1wYCOS/wonik5MVRZU9hi1TEZmzGZSCJB1y9YgoZ8H6itNE/u4suE+yLmOzuE4E5S4TZ/ZX2wcWQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "esbuild": "~0.28.0"
+ },
+ "bin": {
+ "tsx": "dist/cli.mjs"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ }
+ },
+ "node_modules/typescript": {
+ "version": "6.0.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
+ "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/vite": {
+ "version": "8.1.5",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz",
+ "integrity": "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "lightningcss": "^1.32.0",
+ "picomatch": "^4.0.5",
+ "postcss": "^8.5.17",
+ "rolldown": "~1.1.5",
+ "tinyglobby": "^0.2.17"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^20.19.0 || >=22.12.0",
+ "@vitejs/devtools": "^0.3.0",
+ "esbuild": "^0.27.0 || ^0.28.0",
+ "jiti": ">=1.21.0",
+ "less": "^4.0.0",
+ "sass": "^1.70.0",
+ "sass-embedded": "^1.70.0",
+ "stylus": ">=0.54.8",
+ "sugarss": "^5.0.0",
+ "terser": "^5.16.0",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "@vitejs/devtools": {
+ "optional": true
+ },
+ "esbuild": {
+ "optional": true
+ },
+ "jiti": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/xml-naming": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.3.0.tgz",
+ "integrity": "sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/NaturalIntelligence"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=16.0.0"
+ }
+ }
+ }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..4f6a573
--- /dev/null
+++ b/package.json
@@ -0,0 +1,21 @@
+{
+ "name": "ponomar-pwa",
+ "private": true,
+ "version": "0.1.0",
+ "type": "module",
+ "scripts": {
+ "dev": "npm run convert && vite dev",
+ "build": "npm run convert && vite build",
+ "preview": "vite preview",
+ "convert": "tsx scripts/convert-all.ts",
+ "check": "svelte-kit sync 2>/dev/null; tsc --noEmit"
+ },
+ "devDependencies": {
+ "@tailwindcss/vite": "^4.3.3",
+ "fast-xml-parser": "^5.10.1",
+ "tailwindcss": "^4.3.3",
+ "tsx": "^4.23.1",
+ "typescript": "^6.0.3",
+ "vite": "^8.0.16"
+ }
+}
diff --git a/scripts/convert-all.ts b/scripts/convert-all.ts
new file mode 100644
index 0000000..84fe9f8
--- /dev/null
+++ b/scripts/convert-all.ts
@@ -0,0 +1,59 @@
+/**
+ * Master conversion script — converts all XML data to JSON at build time.
+ * Run with: tsx scripts/convert-all.ts
+ */
+
+import { convertCalendar } from './convert-calendar.ts';
+import { convertLives } from './convert-lives.ts';
+import { convertServices } from './convert-services.ts';
+import { convertBibleMeta } from './convert-bible-meta.ts';
+import { convertFasting } from './convert-fasting.ts';
+import { convertCommands } from './convert-commands.ts';
+import { convertServiceRules } from './convert-service-rules.ts';
+import { mkdirSync, existsSync, cpSync } from 'fs';
+import { join, dirname } from 'path';
+import { fileURLToPath } from 'url';
+
+const __dirname = dirname(fileURLToPath(import.meta.url));
+const SRC = join(__dirname, '..', '..', 'ponomar', 'Ponomar');
+const OUT = join(__dirname, '..', 'static', 'data');
+
+function ensureDir(dir: string) {
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
+}
+
+async function main() {
+ const start = Date.now();
+ console.log('Converting Ponomar XML data to JSON...\n');
+
+ ensureDir(OUT);
+
+ // Copy font
+ const fontSrc = join(SRC, 'Ponomar-Regular.woff');
+ const fontDst = join(__dirname, '..', 'static', 'fonts', 'Ponomar-Regular.woff');
+ ensureDir(dirname(fontDst));
+ if (existsSync(fontSrc)) {
+ cpSync(fontSrc, fontDst);
+ console.log('Copied Ponomar font.');
+ }
+
+ // Convert all data in parallel
+ await Promise.all([
+ convertCalendar(SRC, OUT),
+ convertLives(SRC, OUT),
+ convertServices(SRC, OUT),
+ convertBibleMeta(SRC, OUT),
+ convertFasting(SRC, OUT),
+ convertCommands(SRC, OUT),
+ convertServiceRules(SRC, OUT),
+ ]);
+
+ const elapsed = ((Date.now() - start) / 1000).toFixed(1);
+ console.log(`\nAll conversions complete in ${elapsed}s.`);
+ console.log(`Output: ${OUT}`);
+}
+
+main().catch((err) => {
+ console.error('Conversion failed:', err);
+ process.exit(1);
+});
diff --git a/scripts/convert-bible-meta.ts b/scripts/convert-bible-meta.ts
new file mode 100644
index 0000000..695c416
--- /dev/null
+++ b/scripts/convert-bible-meta.ts
@@ -0,0 +1,146 @@
+/**
+ * Convert bible.xml to JSON metadata for all translations.
+ * Bible text .text files are copied as-is.
+ */
+
+import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, cpSync } from 'fs';
+import { join } from 'path';
+import { XMLParser } from 'fast-xml-parser';
+
+const parser = new XMLParser({
+ ignoreAttributes: false,
+ attributeNamePrefix: '@_',
+ allowBooleanAttributes: true,
+ parseTagValue: false,
+ trimValues: true,
+});
+
+interface BibleBookMeta {
+ id: string;
+ name: string;
+ short: string;
+ chapters: number;
+ intro?: string;
+}
+
+interface BibleVersionMeta {
+ id: string;
+ name: string;
+ language: string;
+ books: BibleBookMeta[];
+}
+
+export async function convertBibleMeta(SRC: string, OUT: string) {
+ console.log('Converting Bible metadata...');
+
+ const bibleXml = join(SRC, 'languages', 'xml', 'bible.xml');
+ if (!existsSync(bibleXml)) {
+ console.log(' bible.xml not found, skipping');
+ return;
+ }
+
+ const data = parser.parse(readFileSync(bibleXml, 'utf-8'));
+ const versions: BibleVersionMeta[] = [];
+
+ // Root element is , elements are children
+ const bml = data['BMLFILE'] || data;
+ const bibleNodes = bml['BIBLE'];
+ if (!bibleNodes) {
+ console.log(' No BIBLE elements found');
+ return;
+ }
+
+ const bibleArr = Array.isArray(bibleNodes) ? bibleNodes : [bibleNodes];
+
+ for (const bible of bibleArr) {
+ // fast-xml-parser with attributeNamePrefix '@_' puts attributes as flat keys
+ const id = bible['@_Id'] || bible['@_ID'] || '';
+ const name = bible['@_Name'] || '';
+
+ // Handle special case: Russian Bibles are under cu/ru/ in XML but should be ru/
+ const isRussianBible = id.startsWith('cu/ru/bible/');
+ const effectiveId = isRussianBible ? id.replace('cu/ru/', 'ru/') : id;
+ const effectiveLang = isRussianBible ? 'ru' : effectiveId.split('/')[0];
+
+ const version: BibleVersionMeta = {
+ id: effectiveId,
+ name,
+ language: effectiveLang,
+ books: [],
+ };
+
+ // Parse INFO attributes for display formatting
+ if (bible['INFO']) {
+ const infoObj = bible['INFO'] as Record;
+ const formatting: Record = {};
+ for (const [k, v] of Object.entries(infoObj)) {
+ if (k.startsWith('@_')) {
+ formatting[k.slice(2)] = v;
+ }
+ }
+ if (Object.keys(formatting).length > 0) {
+ (version as Record).formatting = formatting;
+ }
+ }
+
+ // Parse books
+ if (bible['BOOK']) {
+ const bookArr = Array.isArray(bible['BOOK']) ? bible['BOOK'] : [bible['BOOK']];
+ for (const book of bookArr) {
+ const bookId = book['@_Id'] || book['@_ID'] || '';
+ const bookName = book['@_Name'] || '';
+ const bookShort = book['@_Short'] || book['@_Abbr'] || '';
+ const bookChapters = parseInt(book['@_Chapters'] || '0', 10);
+ const bookIntro = book['@_Intro'] || undefined;
+ const bookMeta: BibleBookMeta = {
+ id: bookId,
+ name: bookName,
+ short: bookShort,
+ chapters: bookChapters,
+ };
+ if (bookIntro) bookMeta.intro = bookIntro;
+ version.books.push(bookMeta);
+ }
+ }
+
+ versions.push(version);
+ }
+
+ // Write versions metadata
+ const outDir = join(OUT, 'bible');
+ mkdirSync(outDir, { recursive: true });
+ writeFileSync(join(outDir, 'versions.json'), JSON.stringify(versions, null, 2));
+ console.log(` ${versions.length} Bible versions`);
+
+// Copy .text files for each version
+ for (const ver of versions) {
+ // Use the version's id directly (already normalized)
+ const idParts = ver.id.split('/');
+ const lang = idParts[0];
+ const subPath = idParts.slice(1).join('/'); // "bible/kjv"
+ const textSrc = join(SRC, 'languages', lang, subPath);
+
+ if (!existsSync(textSrc)) continue;
+
+ const textFiles = readdirSync(textSrc).filter(f => f.endsWith('.text'));
+ if (textFiles.length === 0) continue;
+
+ // Fix: Russian Bibles are under cu/ru/ in source but should go to ru/ in output
+ let outLang: string;
+ let outSubPath: string;
+ if (ver.id.startsWith('cu/ru/')) {
+ outLang = 'ru';
+ outSubPath = idParts.slice(2).join('/'); // Skip "cu/ru" → "bible/synod"
+ } else {
+ outLang = lang;
+ outSubPath = subPath;
+ }
+ const textDst = join(OUT, outLang, outSubPath);
+ mkdirSync(textDst, { recursive: true });
+
+ for (const f of textFiles) {
+ cpSync(join(textSrc, f), join(textDst, f));
+ }
+ console.log(` ${ver.id}: ${textFiles.length} .text files copied to ${outLang}/`);
+ }
+}
diff --git a/scripts/convert-calendar.ts b/scripts/convert-calendar.ts
new file mode 100644
index 0000000..a713248
--- /dev/null
+++ b/scripts/convert-calendar.ts
@@ -0,0 +1,215 @@
+/**
+ * Convert calendar XML data (triodion, pentecostarion, menaion, commemorations) to JSON.
+ */
+
+import { readFileSync, writeFileSync, readdirSync, existsSync, mkdirSync } from 'fs';
+import { join } from 'path';
+import { XMLParser } from 'fast-xml-parser';
+
+const parser = new XMLParser({
+ ignoreAttributes: false,
+ attributeNamePrefix: '@_',
+ allowBooleanAttributes: true,
+ parseTagValue: false,
+ trimValues: true,
+});
+
+function parseFile(path: string): Record | null {
+ if (!existsSync(path)) return null;
+ const xml = readFileSync(path, 'utf-8');
+ try {
+ return parser.parse(xml);
+ } catch {
+ return null;
+ }
+}
+
+function extractDayData(data: Record | null): unknown[] {
+ if (!data) return [];
+ const result: unknown[] = [];
+
+ // Handle structure (menaion per-language files)
+ if (data['DAY']) {
+ const day = data['DAY'];
+ if (typeof day === 'object' && day !== null) {
+ const saints = day['SAINT'];
+ if (saints) {
+ const saintArr = Array.isArray(saints) ? saints : [saints];
+ for (const saint of saintArr) {
+ if (typeof saint === 'object' && saint !== null) {
+ // fast-xml-parser puts attributes at top level with @_ prefix
+ const sId = saint['@_SId'] || saint['@_SID'] || '';
+ const cId = saint['@_CId'] || saint['@_CID'] || '';
+ if (cId) {
+ result.push({ SId: sId, CId: cId });
+ }
+ }
+ }
+ }
+ }
+ }
+
+ // Handle ... structure (triodion/pentecostarion shared files)
+ if (data['LANGUAGE']) {
+ const lang = data['LANGUAGE'];
+ if (typeof lang === 'object' && lang !== null) {
+ for (const [key, value] of Object.entries(lang as Record)) {
+ if (key === '@_attributes') continue;
+ if (typeof value === 'object' && value !== null) {
+ const attrs = (value as Record)['@_attributes'] as Record | undefined;
+ if (attrs && Object.keys(attrs).length > 0) {
+ result.push(attrs);
+ }
+ }
+ }
+ }
+ }
+
+ return result;
+}
+
+function convertCommemorationFile(data: Record): Record | null {
+ if (!data) return null;
+ const comm = data['COMMEMORATION'];
+ if (!comm || typeof comm !== 'object') return null;
+ const result: Record = {};
+
+ function extractAttrs(obj: Record): Record {
+ const attrs: Record = {};
+ for (const [k, v] of Object.entries(obj)) {
+ if (k.startsWith('@_')) {
+ attrs[k.slice(2)] = v; // strip @_ prefix
+ }
+ }
+ return attrs;
+ }
+
+ function processElement(obj: Record): Record {
+ const attrs = extractAttrs(obj);
+ const text = obj['#text'];
+ const out = { ...attrs };
+ if (typeof text === 'string' && text.trim()) {
+ out.text = text.trim();
+ }
+ return out;
+ }
+
+ for (const [key, value] of Object.entries(comm as Record)) {
+ if (key.startsWith('@_')) continue; // skip root attributes
+ if (typeof value !== 'object' || value === null) continue;
+
+ if (Array.isArray(value)) {
+ result[key] = value.map(item =>
+ typeof item === 'object' && item !== null ? processElement(item as Record) : item
+ );
+ } else if (typeof value === 'object') {
+ result[key] = processElement(value as Record);
+ }
+ }
+
+ return Object.keys(result).length > 0 ? result : null;
+}
+
+export async function convertCalendar(SRC: string, OUT: string) {
+ console.log('Converting calendar data...');
+
+ // --- Triodion (shared, language-agnostic) ---
+ const triodionDir = join(SRC, 'languages', 'xml', 'triodion');
+ if (existsSync(triodionDir)) {
+ const outDir = join(OUT, 'shared', 'triodion');
+ mkdirSync(outDir, { recursive: true });
+ const files = readdirSync(triodionDir).filter(f => f.endsWith('.xml')).sort();
+ for (const file of files) {
+ const data = parseFile(join(triodionDir, file));
+ const dayData = extractDayData(data);
+ writeFileSync(join(outDir, file.replace('.xml', '.json')), JSON.stringify(dayData));
+ }
+ console.log(` triodion: ${files.length} files`);
+ }
+
+ // --- Pentecostarion (shared, language-agnostic) ---
+ const pentecostarionDir = join(SRC, 'languages', 'xml', 'pentecostarion');
+ if (existsSync(pentecostarionDir)) {
+ const outDir = join(OUT, 'shared', 'pentecostarion');
+ mkdirSync(outDir, { recursive: true });
+ const files = readdirSync(pentecostarionDir).filter(f => f.endsWith('.xml')).sort();
+ for (const file of files) {
+ const data = parseFile(join(pentecostarionDir, file));
+ const dayData = extractDayData(data);
+ writeFileSync(join(outDir, file.replace('.xml', '.json')), JSON.stringify(dayData));
+ }
+ console.log(` pentecostarion: ${files.length} files`);
+ }
+
+ // --- Commemorations (shared, indexed by CId) ---
+ const commBase = join(SRC, 'languages', 'xml', 'Commemorations');
+ if (existsSync(commBase)) {
+ const outDir = join(OUT, 'shared', 'commemorations');
+ mkdirSync(outDir, { recursive: true });
+ let count = 0;
+ const subdirs = readdirSync(commBase);
+ for (const sub of subdirs) {
+ const subDir = join(commBase, sub);
+ if (!existsSync(subDir)) continue;
+ const files = readdirSync(subDir).filter(f => f.endsWith('.xml'));
+ for (const file of files) {
+ const data = parseFile(join(subDir, file));
+ const commData = convertCommemorationFile(data);
+ if (commData) {
+ const cid = file.replace('.xml', '');
+ writeFileSync(join(outDir, `${cid}.json`), JSON.stringify(commData));
+ count++;
+ }
+ }
+ }
+ console.log(` commemorations: ${count} files`);
+ }
+
+ // --- Menaion (per-language monthly day files) ---
+ const MENAION_SOURCES = [
+ { id: 'en', prefix: 'en' },
+ { id: 'cu', prefix: 'cu' },
+ { id: 'el', prefix: 'el' },
+ { id: 'fr', prefix: 'fr' },
+ { id: 'zh', prefix: 'zh' },
+ ];
+
+ for (const { id, prefix } of MENAION_SOURCES) {
+ const xmlDir = join(SRC, 'languages', prefix, 'xml');
+ if (!existsSync(xmlDir)) continue;
+
+ const outDir = join(OUT, id, 'menaion');
+ mkdirSync(outDir, { recursive: true });
+
+ let count = 0;
+ const months = readdirSync(xmlDir).filter(f => /^\d{2}$/.test(f));
+ for (const month of months) {
+ const monthDir = join(xmlDir, month);
+ if (!existsSync(monthDir)) continue;
+ const days = readdirSync(monthDir).filter(f => f.endsWith('.xml'));
+ for (const day of days) {
+ const data = parseFile(join(monthDir, day));
+ const dayData = extractDayData(data);
+ if (dayData) {
+ const mm = month;
+ const dd = day.replace('.xml', '');
+ writeFileSync(join(outDir, `${mm}-${dd}.json`), JSON.stringify(dayData));
+ count++;
+ }
+ }
+ }
+ console.log(` ${id}: ${count} menaion day files`);
+
+ // Build menaion bundle (all days in one file for fast loading)
+ if (count > 0) {
+ const bundle: Record = {};
+ const files = readdirSync(outDir).filter(f => f.endsWith('.json'));
+ for (const f of files) {
+ try {
+ bundle[f.replace('.json', '')] = JSON.parse(readFileSync(join(outDir, f), 'utf-8'));
+ } catch {}
+ }
+ writeFileSync(join(outDir, '..', 'menaion-bundle.json'), JSON.stringify(bundle));
+ }
+ }
+}
diff --git a/scripts/convert-commands.ts b/scripts/convert-commands.ts
new file mode 100644
index 0000000..f9e871c
--- /dev/null
+++ b/scripts/convert-commands.ts
@@ -0,0 +1,59 @@
+/**
+ * Convert command XML files to JSON.
+ * Handles both per-language (en/xml/Commands/, cu/xml/Commands/)
+ * and shared (xml/Commands/).
+ */
+
+import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync } from 'fs';
+import { join } from 'path';
+import { XMLParser } from 'fast-xml-parser';
+
+const parser = new XMLParser({
+ ignoreAttributes: false,
+ attributeNamePrefix: '@_',
+ allowBooleanAttributes: true,
+ parseTagValue: false,
+ trimValues: true,
+});
+
+function convertCmdDir(srcDir: string, outDir: string): number {
+ if (!existsSync(srcDir)) return 0;
+ mkdirSync(outDir, { recursive: true });
+ const files = readdirSync(srcDir).filter(f => f.endsWith('.xml'));
+ for (const file of files) {
+ const data = parser.parse(readFileSync(join(srcDir, file), 'utf-8'));
+ const outName = file.replace('.xml', '.json');
+ writeFileSync(join(outDir, outName), JSON.stringify(data, null, 2));
+ }
+ return files.length;
+}
+
+export async function convertCommands(SRC: string, OUT: string) {
+ console.log('Converting command files...');
+
+ // Shared commands (Fasting.xml, ServiceRules.xml, DivineLiturgy.xml, ScriptureTransfers.xml)
+ const sharedCmdDir = join(SRC, 'languages', 'xml', 'Commands');
+ const sharedOut = join(OUT, 'shared', 'commands');
+ const sharedCount = convertCmdDir(sharedCmdDir, sharedOut);
+ console.log(` shared: ${sharedCount} command files`);
+
+ // Per-language commands (Times.xml, Podobni.xml, LanguagePacks.xml, etc.)
+ const CMD_SOURCES = [
+ { id: 'en', prefix: 'en' },
+ { id: 'cu', prefix: 'cu' },
+ { id: 'ru', prefix: 'cu/ru' },
+ { id: 'el', prefix: 'el' },
+ { id: 'el/mono', prefix: 'el/mono' },
+ { id: 'fr', prefix: 'fr' },
+ { id: 'zh/Hans', prefix: 'zh/Hans' },
+ { id: 'zh/Hant', prefix: 'zh/Hant' },
+ { id: 'zh', prefix: 'zh' },
+ ];
+
+ for (const { id, prefix } of CMD_SOURCES) {
+ const cmdDir = join(SRC, 'languages', prefix, 'xml', 'Commands');
+ const outDir = join(OUT, id, 'commands');
+ const count = convertCmdDir(cmdDir, outDir);
+ if (count > 0) console.log(` ${id}: ${count} command files`);
+ }
+}
diff --git a/scripts/convert-fasting.ts b/scripts/convert-fasting.ts
new file mode 100644
index 0000000..fe8aa62
--- /dev/null
+++ b/scripts/convert-fasting.ts
@@ -0,0 +1,61 @@
+/**
+ * Convert Fasting.xml to JSON.
+ * Fasting.xml is shared at languages/xml/Commands/Fasting.xml
+ */
+
+import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs';
+import { join } from 'path';
+import { XMLParser } from 'fast-xml-parser';
+
+const parser = new XMLParser({
+ ignoreAttributes: false,
+ attributeNamePrefix: '@_',
+ allowBooleanAttributes: true,
+ parseTagValue: false,
+ trimValues: true,
+});
+
+export async function convertFasting(SRC: string, OUT: string) {
+ console.log('Converting Fasting.xml...');
+
+ const filePath = join(SRC, 'languages', 'xml', 'Commands', 'Fasting.xml');
+ if (!existsSync(filePath)) {
+ console.log(' Fasting.xml not found, skipping');
+ return;
+ }
+
+ const data = parser.parse(readFileSync(filePath, 'utf-8'));
+ const periods: { cmd: string; rules: { case: string; cmd: string }[] }[] = [];
+
+ if (data['FASTING']) {
+ const fasting = data['FASTING'];
+ const periodArr = Array.isArray(fasting['PERIOD']) ? fasting['PERIOD'] : [fasting['PERIOD']];
+
+ for (const period of periodArr) {
+ if (!period) continue;
+ const periodCmd = period['@_Cmd'] || '';
+ const rules: { case: string; cmd: string }[] = [];
+
+ if (period['RULE']) {
+ const ruleArr = Array.isArray(period['RULE']) ? period['RULE'] : [period['RULE']];
+ for (const rule of ruleArr) {
+ if (!rule) continue;
+ rules.push({
+ case: rule['@_Case'] || '1111111',
+ cmd: rule['@_Cmd'] || '',
+ });
+ }
+ }
+
+ periods.push({
+ cmd: periodCmd,
+ rules,
+ });
+ }
+ }
+
+ const outDir = join(OUT, 'shared');
+ mkdirSync(outDir, { recursive: true });
+ writeFileSync(join(outDir, 'fasting.json'), JSON.stringify(periods, null, 2));
+ console.log(` ${periods.length} fasting periods`);
+}
diff --git a/scripts/convert-lives.ts b/scripts/convert-lives.ts
new file mode 100644
index 0000000..f081687
--- /dev/null
+++ b/scripts/convert-lives.ts
@@ -0,0 +1,261 @@
+/**
+ * Convert saint lives XML to JSON.
+ * Lives are per-language: languages/{lang}/xml/lives/
+ */
+
+import { readFileSync, writeFileSync, readdirSync, existsSync, mkdirSync, rmSync } from 'fs';
+import { join } from 'path';
+import { XMLParser } from 'fast-xml-parser';
+
+const parser = new XMLParser({
+ ignoreAttributes: false,
+ attributeNamePrefix: '@_',
+ allowBooleanAttributes: true,
+ parseTagValue: false,
+ trimValues: true,
+});
+
+function parseFile(path: string): Record | null {
+ if (!existsSync(path)) return null;
+ const xml = readFileSync(path, 'utf-8');
+ try {
+ return parser.parse(xml);
+ } catch {
+ return null;
+ }
+}
+
+function extractAttrs(obj: Record): Record {
+ const attrs: Record = {};
+ for (const [k, v] of Object.entries(obj)) {
+ if (k.startsWith('@_')) {
+ attrs[k.slice(2)] = v;
+ }
+ }
+ return attrs;
+}
+
+function simplifySaintData(data: Record): Record | null {
+ if (!data) return null;
+ const result: Record = {};
+
+ for (const [key, value] of Object.entries(data)) {
+ if (key === '@_attributes') continue;
+ if (typeof value !== 'object' || value === null) continue;
+
+ const attrs = (value as Record)['@_attributes'] as Record | undefined;
+ const k = key.toUpperCase();
+
+ if (k === 'GRAMMAR') {
+ const grammar: Record = {};
+ for (const [gKey, gValue] of Object.entries(value as Record)) {
+ if (gKey === '@_attributes') continue;
+ if (typeof gValue === 'string') grammar[gKey] = gValue;
+ else if (typeof gValue === 'object' && gValue !== null) {
+ const inner = (gValue as Record)['#text'];
+ if (typeof inner === 'string') grammar[gKey] = inner;
+ }
+ }
+ result.grammar = grammar;
+ } else if (k === 'NAME') {
+ const nameObj: Record = {};
+ const nameItems = Array.isArray(value) ? value : [value];
+ for (const nameItem of nameItems) {
+ if (typeof nameItem === 'object' && nameItem !== null) {
+ for (const [nKey, nValue] of Object.entries(nameItem as Record)) {
+ if (nKey === '@_attributes') continue;
+ if (typeof nValue === 'string') nameObj[nKey] = nValue;
+ }
+ }
+ }
+ result.name = nameObj;
+ } else if (k === 'LIFE') {
+ const text = (value as Record)['#text'];
+ const lifeAttrs = extractAttrs(value as Record);
+ result.life = {
+ id: lifeAttrs.ID as string || '',
+ copyright: lifeAttrs.Copyright as string || '',
+ text: typeof text === 'string' ? text : '',
+ };
+ } else if (k === 'RANK') {
+ const rankAttrs = extractAttrs(value as Record);
+ result.rank = parseInt(rankAttrs.ID as string || '0', 10);
+ } else if (k === 'SERVICE') {
+ const services: Record>> = {};
+ const scriptures: { type?: string; reading?: string; pericope?: string; cmd?: string }[] = [];
+
+ // Extract rank from SERVICE Type attribute (handles both single and array)
+ const svcItems = Array.isArray(value) ? value : [value];
+ for (const svcItem of svcItems) {
+ const svcType = (svcItem as Record)['@_Type'];
+ if (svcType) {
+ const t = parseInt(svcType as string, 10);
+ if (!isNaN(t) && t > (result.rank || 0)) {
+ result.rank = t;
+ }
+ }
+ }
+
+ function extractScriptures(obj: Record): void {
+ for (const [sk, sv] of Object.entries(obj)) {
+ if (sk === 'SCRIPTURE' && sv) {
+ const svArr = Array.isArray(sv) ? sv : [sv];
+ for (const item of svArr) {
+ if (typeof item === 'object' && item !== null) {
+ const attrs = extractAttrs(item as Record);
+ if (Object.keys(attrs).length > 0) {
+ scriptures.push(attrs);
+ }
+ }
+ }
+ } else if (typeof sv === 'object' && sv !== null) {
+ extractScriptures(sv as Record);
+ }
+ }
+ }
+
+ // Recursively extract nested service elements into {group/{element/Type}: {attrs+text}}
+ function extractServiceGroup(obj: Record): Record> {
+ const group: Record> = {};
+ for (const [gk, gv] of Object.entries(obj)) {
+ if (gk === '@_attributes' || gk === '#text') continue;
+ if (typeof gv !== 'object' || gv === null) continue;
+ const gArr = Array.isArray(gv) ? gv : [gv];
+ for (const item of gArr) {
+ if (typeof item !== 'object' || item === null) continue;
+ const attrs = extractAttrs(item as Record);
+ const text = (item as Record)['#text'];
+ const type = attrs.Type as string || '0';
+ const key = `${gk}/${type}`;
+ group[key] = { text: typeof text === 'string' ? text : '', ...attrs };
+ }
+ }
+ return group;
+ }
+
+ for (const [sKey, sValue] of Object.entries(value as Record)) {
+ if (sKey === '@_attributes') continue;
+ if (typeof sValue === 'object' && sValue !== null) {
+ const sObj = sValue as Record;
+ const sAttrs = sObj['@_attributes'] as Record | undefined;
+ const sText = sObj['#text'];
+
+ // Check if this element has nested children (e.g., ROYALHOURS contains IDIOMEL, VERSE, etc.)
+ const childKeys = Object.keys(sObj).filter(k => k !== '@_attributes' && k !== '#text');
+ if (childKeys.length > 0) {
+ // Has nested elements — extract recursively
+ services[sKey] = extractServiceGroup(sObj);
+ } else {
+ // Leaf element — store directly
+ services[sKey] = { text: typeof sText === 'string' ? sText : '', ...(sAttrs || {}) };
+ }
+
+ // Extract scriptures from this service (recursively)
+ extractScriptures(sValue as Record);
+ }
+ }
+ if (Object.keys(services).length > 0) result.services = services;
+ if (scriptures.length > 0) result.scripture = scriptures;
+ } else if (k === 'ICON') {
+ const iconAttrs = extractAttrs(value as Record);
+ result.icon = iconAttrs.ID as string || '';
+ } else if (k === 'SCRIPTURE') {
+ const scriptures: Record[] = [];
+ const scriptureArr = Array.isArray(value) ? value : [value];
+ for (const s of scriptureArr) {
+ if (typeof s === 'object' && s !== null) {
+ const attrs = extractAttrs(s as Record);
+ if (Object.keys(attrs).length > 0) {
+ scriptures.push(attrs);
+ }
+ }
+ }
+ if (scriptures.length > 0) {
+ result.scripture = scriptures;
+ }
+ }
+ }
+
+ return Object.keys(result).length > 0 ? result : null;
+}
+
+export async function convertLives(SRC: string, OUT: string) {
+ console.log('Converting saint lives...');
+
+ const LANG_CONFIGS = [
+ { id: 'en', prefix: 'en' },
+ { id: 'cu', prefix: 'cu' },
+ { id: 'ru', prefix: 'cu/ru' },
+ { id: 'el', prefix: 'el' },
+ { id: 'el/mono', prefix: 'el/mono' },
+ { id: 'fr', prefix: 'fr' },
+ { id: 'zh/Hans', prefix: 'zh/Hans' },
+ { id: 'zh/Hant', prefix: 'zh/Hant' },
+ { id: 'shared', prefix: '' },
+ ];
+
+ for (const { id, prefix } of LANG_CONFIGS) {
+ const livesDir = join(SRC, 'languages', prefix, 'xml', 'lives');
+ if (!existsSync(livesDir)) continue;
+
+ const outDir = join(OUT, id, 'lives');
+ mkdirSync(outDir, { recursive: true });
+ // Clean old individual files from previous conversions
+ for (const oldFile of readdirSync(outDir).filter(f => f.endsWith('.json'))) {
+ rmSync(join(outDir, oldFile), { force: true });
+ }
+
+ let count = 0;
+ const files = readdirSync(livesDir).filter(f => f.endsWith('.xml'));
+ const bundles: Record>> = {};
+
+ for (const file of files) {
+ const data = parseFile(join(livesDir, file));
+ if (!data) continue;
+
+ let saintData: Record | null = null;
+ for (const [key, value] of Object.entries(data)) {
+ if (typeof value === 'object' && value !== null && key !== '@_attributes') {
+ saintData = simplifySaintData(value as Record);
+ break;
+ }
+ }
+
+ if (saintData) {
+ const cid = file.replace('.xml', '');
+ const mm = cid.match(/^(0[1-9]|1[0-2])\d+/)?.[1];
+ const bundleName = mm ? `${mm}.json` : 'misc.json';
+ if (!bundles[bundleName]) bundles[bundleName] = {};
+ bundles[bundleName][cid] = saintData;
+ count++;
+ }
+ }
+
+ // Write bundles
+ for (const [bundleName, bundle] of Object.entries(bundles)) {
+ writeFileSync(join(outDir, bundleName), JSON.stringify(bundle));
+ }
+
+ console.log(` ${id}: ${count} saint lives in ${Object.keys(bundles).length} bundles`);
+ }
+
+ // Build shared lives index (CId → scripture for fast lookup)
+ const sharedOut = join(OUT, 'shared', 'lives');
+ const index: Record = {};
+ if (existsSync(sharedOut)) {
+ const files = readdirSync(sharedOut).filter(f => f.endsWith('.json'));
+ for (const f of files) {
+ if (f === 'lives-index.json') continue;
+ try {
+ const bundle = JSON.parse(readFileSync(join(sharedOut, f), 'utf-8'));
+ for (const [cid, data] of Object.entries(bundle as Record>)) {
+ if (data.scripture) {
+ index[cid] = data.scripture;
+ }
+ }
+ } catch {}
+ }
+ }
+ writeFileSync(join(sharedOut, '..', 'lives-index.json'), JSON.stringify(index));
+ console.log(` lives-index: ${Object.keys(index).length} entries`);
+}
diff --git a/scripts/convert-service-rules.ts b/scripts/convert-service-rules.ts
new file mode 100644
index 0000000..6d92461
--- /dev/null
+++ b/scripts/convert-service-rules.ts
@@ -0,0 +1,71 @@
+/**
+ * Convert ServiceRules.xml to JSON.
+ * ServiceRules.xml is shared at languages/xml/Commands/ServiceRules.xml
+ */
+
+import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs';
+import { join } from 'path';
+import { XMLParser } from 'fast-xml-parser';
+
+const parser = new XMLParser({
+ ignoreAttributes: false,
+ attributeNamePrefix: '@_',
+ allowBooleanAttributes: true,
+ parseTagValue: false,
+ trimValues: true,
+});
+
+export async function convertServiceRules(SRC: string, OUT: string) {
+ console.log('Converting ServiceRules.xml...');
+
+ const filePath = join(SRC, 'languages', 'xml', 'Commands', 'ServiceRules.xml');
+ if (!existsSync(filePath)) {
+ console.log(' ServiceRules.xml not found, skipping');
+ return;
+ }
+
+ const data = parser.parse(readFileSync(filePath, 'utf-8'));
+ const periods: { cmd: string; rules: Record[] }[] = [];
+
+ const root = data['DATA'] || data['SERVICES'];
+ if (root) {
+ const periodArr = Array.isArray(root['PERIOD']) ? root['PERIOD'] : [root['PERIOD']];
+
+ for (const period of periodArr) {
+ if (!period) continue;
+ const periodCmd = root['@_Cmd'] || period['@_Cmd'] || '';
+ const rules: Record[] = [];
+
+ for (const [key, value] of Object.entries(period)) {
+ if (key.startsWith('@_')) continue;
+ if (typeof value === 'object' && value !== null) {
+ const ruleArr = Array.isArray(value) ? value : [value];
+ for (const rule of ruleArr) {
+ if (typeof rule === 'object' && rule !== null) {
+ // Collect all @_-prefixed attributes (flat format)
+ const attrs: Record = {};
+ for (const [rKey, rValue] of Object.entries(rule as Record)) {
+ if (rKey.startsWith('@_') && typeof rValue === 'string') {
+ attrs[rKey.slice(2)] = rValue;
+ }
+ }
+ if (Object.keys(attrs).length > 0) {
+ rules.push({ type: key, ...attrs });
+ }
+ }
+ }
+ }
+ }
+
+ periods.push({
+ cmd: periodCmd,
+ rules,
+ });
+ }
+ }
+
+ const outDir = join(OUT, 'shared');
+ mkdirSync(outDir, { recursive: true });
+ writeFileSync(join(outDir, 'service-rules.json'), JSON.stringify(periods, null, 2));
+ console.log(` ${periods.length} service rule periods`);
+}
diff --git a/scripts/convert-services.ts b/scripts/convert-services.ts
new file mode 100644
index 0000000..1326fa1
--- /dev/null
+++ b/scripts/convert-services.ts
@@ -0,0 +1,199 @@
+/**
+ * Convert service template XML to JSON.
+ * Recursively converts all subdirectories under Services/.
+ * Unwraps COMMONPRAYER root wrappers, flattens children, lowercases attributes.
+ */
+
+import { readFileSync, writeFileSync, readdirSync, existsSync, mkdirSync } from 'fs';
+import { join } from 'path';
+import { XMLParser } from 'fast-xml-parser';
+
+const parser = new XMLParser({
+ ignoreAttributes: false,
+ attributeNamePrefix: '@_',
+ allowBooleanAttributes: true,
+ parseTagValue: false,
+ trimValues: true,
+ preserveOrder: true,
+});
+
+function parseFile(path: string): unknown[] | null {
+ if (!existsSync(path)) return null;
+ const xml = readFileSync(path, 'utf-8');
+ try {
+ return parser.parse(xml) as unknown[];
+ } catch {
+ return null;
+ }
+}
+
+/**
+ * Lowercase all @_-prefixed attribute keys and strip the prefix.
+ * e.g. { '@_Value': 'Wisdom.', '@_Who': 'Deacon' } → { value: 'Wisdom.', who: 'Deacon' }
+ */
+function lowerAttrs(obj: Record): Record {
+ const result: Record = {};
+ for (const [key, value] of Object.entries(obj)) {
+ if (key.startsWith('@_')) {
+ result[key.slice(2).toLowerCase()] = value;
+ } else {
+ result[key] = value;
+ }
+ }
+ return result;
+}
+
+/**
+ * Convert a parsed XML node (preserveOrder format) into a flat array of service nodes.
+ * Input is an array of ordered entries, each: { tagName: children[], ":@": { attrs } }
+ */
+function convertNodes(data: unknown[]): unknown[] {
+ const result: unknown[] = [];
+
+ for (const entry of data as Record[]) {
+ // Get the tag name (first key that isn't ':@')
+ const tagName = Object.keys(entry).find(k => k !== ':@');
+ if (!tagName) continue;
+
+ const attrs = (entry[':@'] ?? {}) as Record;
+ const children = (entry[tagName] ?? []) as unknown[];
+
+ // Unwrap wrapper elements (COMMONPRAYER, SERVICES, SERVICE)
+ if (tagName === 'COMMONPRAYER' || tagName === 'SERVICE' || tagName === 'SERVICES') {
+ result.push(...convertNodes(children));
+ continue;
+ }
+
+ // Convert element with its attributes
+ const node = convertNodeItem(tagName, attrs, children);
+ if (node) result.push(node);
+ }
+
+ return result;
+}
+
+/**
+ * Convert a single XML element into a service node object.
+ */
+function convertNodeItem(
+ tagName: string,
+ attrs: Record,
+ children: unknown[]
+): Record | null {
+ const lowercased = lowerAttrs(attrs);
+
+ // Convert boolean attribute values: "1"→true, "0"→false
+ const boolKeys = ['redfirst', 'newline', 'header', 'null', 'withmen'];
+ for (const k of boolKeys) {
+ if (lowercased[k] === '1') lowercased[k] = true;
+ else if (lowercased[k] === '0') lowercased[k] = false;
+ }
+ // Convert numeric attribute values: "3"→3
+ const numKeys = ['times'];
+ for (const k of numKeys) {
+ if (typeof lowercased[k] === 'string' && /^\d+$/.test(lowercased[k])) {
+ lowercased[k] = parseInt(lowercased[k], 10);
+ }
+ }
+ const node: Record = { type: tagName };
+
+ // Copy all attributes as lowercase keys
+ for (const [key, value] of Object.entries(lowercased)) {
+ if (key === 'type') continue;
+ node[key] = value;
+ }
+
+ // Process child elements recursively
+ if (children.length > 0) {
+ // Check for #text content
+ const textNode = children.find(c => (c as Record)['#text']);
+ if (textNode) {
+ node.text = (textNode as Record)['#text'] as string;
+ } else {
+ const childNodes = convertNodes(children);
+ if (childNodes.length > 0) {
+ node._children = childNodes;
+ }
+ }
+ }
+
+ return node;
+}
+
+function convertDirRecursive(srcDir: string, outDir: string): number {
+ let count = 0;
+ mkdirSync(outDir, { recursive: true });
+
+ const entries = readdirSync(srcDir);
+ for (const entry of entries) {
+ if (entry.startsWith('.')) continue;
+ const srcPath = join(srcDir, entry);
+
+ if (!existsSync(srcPath)) continue;
+
+ if (entry.endsWith('.xml')) {
+ const data = parseFile(srcPath);
+ if (!data) continue;
+
+ // Convert and flatten
+ const nodes = flattenNodes(convertNodes(data));
+ const outName = entry.replace('.xml', '.json');
+ writeFileSync(join(outDir, outName), JSON.stringify(nodes));
+ count++;
+ } else {
+ // Subdirectory (e.g. "Tone 1", "KONTAKION", "TROPARION")
+ const subOut = join(outDir, entry);
+ count += convertDirRecursive(srcPath, subOut);
+ }
+ }
+
+ return count;
+}
+
+/**
+ * Flatten nodes that have _children into a single flat array.
+ */
+function flattenNodes(nodes: unknown[]): unknown[] {
+ const result: unknown[] = [];
+ for (const node of nodes) {
+ if (typeof node !== 'object' || node === null) {
+ result.push(node);
+ continue;
+ }
+ const n = node as Record;
+ if (n._children && Array.isArray(n._children)) {
+ // This node has children — push the node itself (without _children) then flatten children
+ const { _children, ...rest } = n;
+ result.push(rest);
+ result.push(...flattenNodes(_children));
+ } else {
+ result.push(n);
+ }
+ }
+ return result;
+}
+
+export async function convertServices(SRC: string, OUT: string) {
+ console.log('Converting service templates...');
+
+ const SERVICE_SOURCES = [
+ { id: 'en', prefix: 'en' },
+ { id: 'cu', prefix: 'cu' },
+ { id: 'ru', prefix: 'cu/ru' },
+ { id: 'el', prefix: 'el' },
+ { id: 'fr', prefix: 'fr' },
+ { id: 'zh/Hans', prefix: 'zh/Hans' },
+ { id: 'zh/Hant', prefix: 'zh/Hant' },
+ { id: 'zh', prefix: 'zh' },
+ { id: 'shared', prefix: '' },
+ ];
+
+ for (const { id, prefix } of SERVICE_SOURCES) {
+ const servicesDir = join(SRC, 'languages', prefix, 'xml', 'Services');
+ if (!existsSync(servicesDir)) continue;
+
+ const outDir = join(OUT, id, 'services');
+ const count = convertDirRecursive(servicesDir, outDir);
+ console.log(` ${id}: ${count} service files`);
+ }
+}
diff --git a/scripts/generate-icons.js b/scripts/generate-icons.js
new file mode 100644
index 0000000..5708b2c
--- /dev/null
+++ b/scripts/generate-icons.js
@@ -0,0 +1,84 @@
+import { writeFileSync, mkdirSync } from 'fs';
+import { join, dirname } from 'path';
+import { fileURLToPath } from 'url';
+import zlib from 'zlib';
+
+const __dirname = dirname(fileURLToPath(import.meta.url));
+const iconsDir = join(__dirname, '..', 'static', 'icons');
+mkdirSync(iconsDir, { recursive: true });
+
+function crc32(buf) {
+ let crc = 0xffffffff;
+ for (let i = 0; i < buf.length; i++) {
+ crc ^= buf[i];
+ for (let j = 0; j < 8; j++) {
+ crc = (crc >>> 1) ^ (crc & 1 ? 0xedb88320 : 0);
+ }
+ }
+ return (crc ^ 0xffffffff) >>> 0;
+}
+
+function createChunk(type, data) {
+ const typeBytes = Buffer.from(type, 'ascii');
+ const len = Buffer.alloc(4);
+ len.writeUInt32BE(data.length);
+ const body = Buffer.concat([typeBytes, data]);
+ const crc = Buffer.alloc(4);
+ crc.writeUInt32BE(crc32(body));
+ return Buffer.concat([len, body, crc]);
+}
+
+function generatePNG(size) {
+ const bg = [0x1a, 0x1a, 0x2e];
+ const gold = [0xc5, 0xa5, 0x5a];
+
+ const pixels = [];
+ for (let y = 0; y < size; y++) {
+ pixels.push(0);
+ for (let x = 0; x < size; x++) {
+ if (isCross(x, y, size)) {
+ pixels.push(...gold);
+ } else {
+ pixels.push(...bg);
+ }
+ }
+ }
+
+ const raw = Buffer.from(pixels);
+ const compressed = zlib.deflateSync(raw);
+
+ const ihdr = Buffer.alloc(13);
+ ihdr.writeUInt32BE(size, 0);
+ ihdr.writeUInt32BE(size, 4);
+ ihdr[8] = 8;
+ ihdr[9] = 2;
+ ihdr[10] = 0;
+ ihdr[11] = 0;
+ ihdr[12] = 0;
+
+ const signature = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
+
+ return Buffer.concat([
+ signature,
+ createChunk('IHDR', ihdr),
+ createChunk('IDAT', compressed),
+ createChunk('IEND', Buffer.alloc(0)),
+ ]);
+}
+
+function isCross(x, y, size) {
+ const cx = Math.floor(size / 2);
+ const cy = Math.floor(size / 2);
+ const arm = Math.max(2, Math.floor(size * 0.06));
+ const halfLen = Math.floor(size * 0.32);
+ const topLen = Math.floor(size * 0.38);
+
+ const inVert = Math.abs(x - cx) <= arm && y >= cy - topLen && y <= cy + halfLen;
+ const inHoriz = Math.abs(y - cy) <= arm && x >= cx - halfLen && x <= cx + halfLen;
+
+ return inVert || inHoriz;
+}
+
+writeFileSync(join(iconsDir, 'icon-192.png'), generatePNG(192));
+writeFileSync(join(iconsDir, 'icon-512.png'), generatePNG(512));
+console.log('Icons generated.');
diff --git a/scripts/generate-icons.py b/scripts/generate-icons.py
new file mode 100644
index 0000000..9a66c70
--- /dev/null
+++ b/scripts/generate-icons.py
@@ -0,0 +1,45 @@
+#!/usr/bin/env python3
+"""Generate PWA icons from OrthodoxCross.svg using rsvg-convert + Pillow."""
+
+import os
+import subprocess
+import tempfile
+from PIL import Image
+
+SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
+PROJECT_DIR = os.path.join(SCRIPT_DIR, '..')
+SVG_PATH = os.path.join(PROJECT_DIR, 'static', 'OrthodoxCross.svg')
+ICONS_DIR = os.path.join(PROJECT_DIR, 'static', 'icons')
+os.makedirs(ICONS_DIR, exist_ok=True)
+
+GOLD = (0xC5, 0xA5, 0x5A)
+NAVY = (0x1A, 0x1A, 0x2E)
+RENDER_SIZE = 2048
+
+def generate_icon(size):
+ with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as tmp:
+ tmp_path = tmp.name
+ try:
+ subprocess.run([
+ 'rsvg-convert',
+ '-w', str(RENDER_SIZE),
+ '-h', str(RENDER_SIZE),
+ SVG_PATH,
+ '-o', tmp_path,
+ ], check=True)
+ img = Image.open(tmp_path).convert('RGBA')
+ result = Image.new('RGBA', (size, size), NAVY + (255,))
+ cross = img.resize((size, size), Image.LANCZOS)
+ gold_layer = Image.new('RGBA', (size, size), GOLD + (255,))
+ result.paste(gold_layer, mask=cross.split()[3])
+ return result
+ finally:
+ os.unlink(tmp_path)
+
+for size in [192, 512]:
+ icon = generate_icon(size)
+ out = os.path.join(ICONS_DIR, f'icon-{size}.png')
+ icon.save(out, 'PNG')
+ print(f'Saved {out} ({size}x{size})')
+
+print('Icons generated.')
diff --git a/src/core/bible-reader.ts b/src/core/bible-reader.ts
new file mode 100644
index 0000000..279ae44
--- /dev/null
+++ b/src/core/bible-reader.ts
@@ -0,0 +1,203 @@
+/**
+ * Bible Reader — parses .text files and manages Bible navigation.
+ * Port of key Bible.java functionality.
+ *
+ * Bible.java is part of the Ponomar program.
+ * Copyright 2007, 2008 Aleksandr Andreev.
+ * GPL v3 — see LICENSE.
+ */
+
+// Bible types used for version metadata
+
+export interface BiblePassage {
+ bookId: string;
+ chapter: number;
+ verses: { num: number; text: string }[];
+}
+
+export interface ParsedReading {
+ chapters: number[];
+ verses: number[];
+}
+
+/**
+ * Parse a passage string like "2:11-3:2, 5, 13-14" into structured data.
+ */
+export function parsePassage(passage: string): ParsedReading {
+ const chapters: number[] = [];
+ const verses: number[] = [];
+
+ if (!passage.includes(':')) {
+ // Just a chapter, e.g. "1"
+ const ch = parseInt(passage, 10);
+ chapters.push(ch);
+ verses.push(1);
+ return { chapters, verses };
+ }
+
+ // Composite passage: "2:11-3:2, 5, 13-14"
+ const parts = passage.split(',');
+
+ for (const part of parts) {
+ const trimmed = part.trim();
+ if (!trimmed.includes('-')) {
+ // Single verse or chapter:verse
+ if (trimmed.includes(':')) {
+ const [ch, v] = trimmed.split(':');
+ chapters.push(parseInt(ch, 10));
+ verses.push(parseInt(v, 10));
+ } else {
+ const v = parseInt(trimmed, 10);
+ chapters.push(0); // current chapter
+ verses.push(v);
+ }
+ } else {
+ // Range: "2:11-3:2" or "13-14"
+ const [start, end] = trimmed.split('-');
+ if (start.includes(':')) {
+ const [ch, v] = start.split(':');
+ chapters.push(parseInt(ch, 10));
+ verses.push(parseInt(v, 10));
+ } else {
+ chapters.push(0);
+ verses.push(parseInt(start, 10));
+ }
+ if (end.includes(':')) {
+ const [ch, v] = end.split(':');
+ chapters.push(parseInt(ch, 10));
+ verses.push(parseInt(v, 10));
+ } else {
+ chapters.push(0);
+ verses.push(parseInt(end, 10));
+ }
+ }
+ }
+
+ return { chapters, verses };
+}
+
+/**
+ * Parse a .text file's content into structured verses.
+ * Format: "#N" = chapter marker, "N|" = verse marker followed by text.
+ */
+export function parseTextFile(content: string): Map> {
+ const chapters = new Map>();
+ let currentChapter = 0;
+ let currentVerses = new Map();
+
+ const lines = content.split('\n');
+
+ for (const line of lines) {
+ const trimmed = line.trim();
+ if (!trimmed) continue;
+
+ if (trimmed.startsWith('#')) {
+ // Chapter marker
+ if (currentChapter > 0) {
+ chapters.set(currentChapter, currentVerses);
+ }
+ currentChapter = parseInt(trimmed.substring(1), 10);
+ currentVerses = new Map();
+ } else if (trimmed.includes('|')) {
+ // Verse marker
+ const pipeIdx = trimmed.indexOf('|');
+ const verseNum = parseInt(trimmed.substring(0, pipeIdx), 10);
+ const verseText = trimmed.substring(pipeIdx + 1).trim();
+ currentVerses.set(verseNum, verseText);
+ }
+ }
+
+ // Don't forget the last chapter
+ if (currentChapter > 0) {
+ chapters.set(currentChapter, currentVerses);
+ }
+
+ return chapters;
+}
+
+/**
+ * Extract a passage from parsed chapter data.
+ */
+export function extractPassage(
+ chapters: Map>,
+ reading: ParsedReading
+): { chapter: number; verse: number; text: string }[] {
+ const result: { chapter: number; verse: number; text: string }[] = [];
+
+ for (let i = 0; i < reading.chapters.length; i += 2) {
+ const startCh = reading.chapters[i];
+ const startV = reading.verses[i];
+ const endCh = reading.chapters[i + 1] ?? startCh;
+ const endV = reading.verses[i + 1] ?? -1;
+
+ for (const [chNum, verses] of chapters) {
+ if (chNum < startCh) continue;
+ if (chNum > endCh) break;
+
+ for (const [vNum, text] of verses) {
+ if (chNum === startCh && vNum < startV) continue;
+ if (chNum === endCh && endV > 0 && vNum > endV) continue;
+ result.push({ chapter: chNum, verse: vNum, text });
+ }
+ }
+ }
+
+ return result;
+}
+
+/**
+ * Format a passage for display.
+ */
+export function formatPassageHTML(
+ passages: { chapter: number; verse: number; text: string }[],
+ options?: { showVerseNumbers?: boolean; verseNewLine?: boolean }
+): string {
+ const showVerseNum = options?.showVerseNumbers ?? true;
+ const verseNewLine = options?.verseNewLine ?? false;
+
+ let html = '';
+ let lastChapter = 0;
+
+ for (const p of passages) {
+ if (p.chapter !== lastChapter) {
+ if (lastChapter > 0 && !verseNewLine) html += '';
+ if (!verseNewLine) html += '';
+ lastChapter = p.chapter;
+ }
+
+ let text = p.text;
+ text = text.replace(/\*\*(.*?)\*\*/g, '$1');
+ text = text.replace(/\*\*/g, '');
+
+ if (verseNewLine && p.verse > 0) {
+ const num = showVerseNum ? `${p.verse} ` : '';
+ html += `${num}${text.charAt(0).toUpperCase() + text.slice(1)}`;
+ } else {
+ if (showVerseNum && p.verse > 0) {
+ html += `${p.verse} `;
+ }
+ html += text + ' ';
+ }
+ }
+
+ if (!verseNewLine && lastChapter > 0) html += '
';
+
+ return html;
+}
+
+/**
+ * Create a hyperlink for a Bible reading reference.
+ */
+export function createReadingLink(
+ bookId: string,
+ passage: string,
+ abbrevFormat: string,
+ abbreviations: Record
+): string {
+ const abbrev = abbreviations[bookId] ?? bookId;
+ const display = abbrevFormat
+ .replace('^NAME', abbrev)
+ .replace('^CNN', passage);
+
+ return `${display}`;
+}
diff --git a/src/core/day-computer.ts b/src/core/day-computer.ts
new file mode 100644
index 0000000..69a27ef
--- /dev/null
+++ b/src/core/day-computer.ts
@@ -0,0 +1,147 @@
+/**
+ * Day Computer — computes all liturgical variables for a given Julian date.
+ * Replaces the core of Main.write() in the Java version.
+ */
+
+import { JDate } from './jdate';
+import { Paschalion } from './paschalion';
+import type { EvalContext, DayInfo } from './types';
+
+export interface ComputedDay {
+ jdate: JDate;
+ gregorian: Date;
+ evalContext: EvalContext;
+ dayInfo: DayInfo;
+}
+
+/**
+ * Compute all liturgical variables for a given Julian date.
+ * Returns both the EvalContext (for expression evaluator) and structured DayInfo.
+ */
+export function computeDay(date: JDate): ComputedDay {
+ const year = date.getYear();
+ const dow = date.getDayOfWeek();
+ const doy = date.getDoy();
+ const month = date.getMonth();
+ const day = date.getDay();
+
+ const gregorian = date.toGregorian();
+
+ // Paschal distances
+ const pascha = Paschalion.getPascha(year);
+ const nday = date.difference(pascha);
+ const ndayP = date.difference(Paschalion.getPascha(year - 1));
+ const ndayF = date.difference(Paschalion.getPascha(year + 1));
+
+ const pentecost = Paschalion.getPentecost(year);
+ const lentStart = Paschalion.getLentStart(year);
+
+ // Determine file indices for triodion/pentecostarion
+ let triodionFile: number | null = null;
+ let pentecostarionFile: number | null = null;
+
+ if (nday >= -70 && nday < 0) {
+ // Great Lent period — triodion
+ triodionFile = Math.abs(nday);
+ } else if (nday < -70) {
+ // Before Lent — pentecostarion from last year
+ const lastPascha = Paschalion.getPascha(year - 1);
+ pentecostarionFile = date.difference(lastPascha) + 1;
+ } else {
+ // After Pascha — pentecostarion
+ pentecostarionFile = nday + 1;
+ }
+
+ // Determine liturgical state
+ const isLent = nday >= -48 && nday < 0;
+ const isBrightWeek = nday >= 0 && nday <= 6;
+ const isApostlesFast = nday >= 57 && nday <= Paschalion.getApostlesFastLength(year);
+ const isDormitionFast = month === 8 && day >= 1 && day <= 14;
+ const isNativityFast = (month === 11 && day >= 15) || (month === 12 && day <= 24);
+
+ // Tone computation — 8-week cycle after Pentecost
+ // Matches Java pentecostarion XML: tone = (⌊(nday - 7) / 7⌋ % 8) + 1
+ // Exceptions: Ascension (nday 39-40) and Pentecost week (nday 48-55) have no tone
+ let tone = -1;
+ if (nday >= 7) {
+ if ((nday >= 39 && nday <= 40) || (nday >= 48 && nday <= 55)) {
+ tone = -1; // Ascension or Pentecost week — no octoechos tone
+ } else {
+ tone = (Math.floor((nday - 7) / 7) % 8) + 1;
+ }
+ }
+
+ // Rank computation
+ let dRank = 0;
+ // Major feasts get higher rank
+ if (month === 9 && day === 14) dRank = 6; // Exaltation
+ else if (month === 8 && day === 29) dRank = 6; // Beheading
+ else if (nday === 0) dRank = 8; // Pascha
+ else if (nday === 49) dRank = 8; // Pentecost
+ else if (nday === 39) dRank = 8; // Ascension
+ else if (nday === -7) dRank = 8; // Palm Sunday
+ else if (month === 12 && day === 25) dRank = 6; // Nativity
+ else if (month === 1 && day === 6) dRank = 6; // Theophany
+ else if (month === 8 && day === 15) dRank = 6; // Dormition
+ else if (month === 3 && day === 25) dRank = 6; // Annunciation
+ else if (nday >= -48 && nday < 0) dRank = 3; // Lent
+ else if (isBrightWeek || nday >= 49 && nday <= 56) dRank = 5; // Bright/Pentecost week
+
+ // Paschal flag
+ const PFlag = nday >= 0 ? 1 : 0;
+ const PFlag1 = 1; // Default service inclusion
+ const PFlag2 = isBrightWeek || (nday >= -7 && nday < 0) ? 2 : (isLent && dow !== 0 ? 1 : 0);
+
+ // Priest status (1 = priest required, 0 = reader)
+ const PS = (dRank >= 3 || dow === 0) ? 1 : 0;
+
+ const dayInfo: DayInfo = {
+ dow,
+ doy,
+ month,
+ day,
+ year,
+ nday,
+ ndayP,
+ ndayF,
+ Easter: pascha.jdn,
+ Pentecost: pentecost.jdn,
+ LentStart: lentStart.jdn,
+ Tone: tone,
+ dRank,
+ PFlag,
+ PFlag1,
+ PFlag2,
+ PS,
+ isLent,
+ isBrightWeek,
+ isApostlesFast,
+ isDormitionFast,
+ isNativityFast,
+ triodionFile,
+ pentecostarionFile,
+ };
+
+ // Build EvalContext from DayInfo (all values as numbers for the expression evaluator)
+ const evalContext: EvalContext = {
+ dow,
+ doy,
+ nday,
+ ndayP,
+ ndayF,
+ Year: year,
+ dRank,
+ PFlag,
+ PFlag1,
+ PFlag2,
+ PS,
+ Tone: tone,
+ Easter: pascha.jdn,
+ Pentecost: pentecost.jdn,
+ LentStart: lentStart.jdn,
+ LS: 1, // Default language
+ GS: 1, // Default gospel selector
+ };
+
+ return { jdate: date, gregorian, evalContext, dayInfo };
+}
diff --git a/src/core/evaluator.ts b/src/core/evaluator.ts
new file mode 100644
index 0000000..82042ef
--- /dev/null
+++ b/src/core/evaluator.ts
@@ -0,0 +1,294 @@
+/**
+ * Expression evaluator — recursive descent parser.
+ * Faithful port of Ponomar/StringOp.java to TypeScript.
+ * Supports: ||, &&, ==, !=, <, >, <=, >=, +, -, *, /, %, unary !, unary -, true/false, variables.
+ *
+ * StringOp.java is part of the Ponomar program.
+ * Copyright 2007 Aleksandr Andreev and Yuri Shardt.
+ * See LICENSE for redistribution terms.
+ */
+
+import type { EvalContext } from './types';
+
+function bool2double(expr: boolean): number {
+ return expr ? 1.0 : 0.0;
+}
+
+/**
+ * Evaluate a boolean expression using dayInfo variable context.
+ * Returns true if the expression evaluates to a non-zero number.
+ */
+export function evalBool(expression: string, ctx: EvalContext): boolean {
+ return evalNum(expression, ctx) !== 0;
+}
+
+/**
+ * Evaluate a numeric expression.
+ * Fully obeys correct order of operations (recursive descent).
+ */
+export function evalNum(expression: string, ctx: EvalContext): number {
+ const parser = new ExprParser(expression.trim(), ctx);
+ return parser.parseOr();
+}
+
+class ExprParser {
+ private pos = 0;
+ private readonly src: string;
+ private readonly ctx: EvalContext;
+
+ constructor(expression: string, ctx: EvalContext) {
+ // Strip all spaces for tokenization (matches Java behavior)
+ this.src = expression.replace(/\s/g, '');
+ this.ctx = ctx;
+ }
+
+ private peek(): string {
+ return this.pos < this.src.length ? this.src[this.pos] : '';
+ }
+
+ private advance(): string {
+ const ch = this.src[this.pos];
+ this.pos++;
+ return ch;
+ }
+
+ private peekTwo(): string {
+ return this.src.substring(this.pos, this.pos + 2);
+ }
+
+ /**
+ * Read a token: number, variable name, or keyword (true/false).
+ */
+ private readToken(): string {
+ // Number
+ if (/[0-9.]/.test(this.peek())) {
+ let num = '';
+ while (this.pos < this.src.length && /[0-9.eE+\-]/.test(this.peek())) {
+ num += this.advance();
+ }
+ return num;
+ }
+ // Variable or keyword
+ if (/[a-zA-Z_]/.test(this.peek())) {
+ let name = '';
+ while (this.pos < this.src.length && /[a-zA-Z_0-9]/.test(this.peek())) {
+ name += this.advance();
+ }
+ return name;
+ }
+ return '';
+ }
+
+ /**
+ * OR operator: lowest precedence.
+ * expr || expr
+ */
+ parseOr(): number {
+ let left = this.parseAnd();
+ while (this.pos < this.src.length && this.peekTwo() === '||') {
+ this.pos += 2;
+ const right = this.parseAnd();
+ left = bool2double(evalBool(String(left !== 0), this.ctx) || evalBool(String(right !== 0), this.ctx));
+ }
+ return left;
+ }
+
+ /**
+ * AND operator.
+ */
+ parseAnd(): number {
+ let left = this.parseEquality();
+ while (this.pos < this.src.length && this.peekTwo() === '&&') {
+ this.pos += 2;
+ const right = this.parseEquality();
+ left = bool2double(evalBool(String(left !== 0), this.ctx) && evalBool(String(right !== 0), this.ctx));
+ }
+ return left;
+ }
+
+ /**
+ * Equality: == and !=
+ */
+ parseEquality(): number {
+ let left = this.parseComparison();
+ while (this.pos < this.src.length) {
+ if (this.peekTwo() === '==') {
+ this.pos += 2;
+ const right = this.parseComparison();
+ left = bool2double(left === right);
+ } else if (this.peekTwo() === '!=') {
+ this.pos += 2;
+ const right = this.parseComparison();
+ left = bool2double(left !== right);
+ } else {
+ break;
+ }
+ }
+ return left;
+ }
+
+ /**
+ * Comparison: <, >, <=, >=
+ */
+ parseComparison(): number {
+ let left = this.parseAddSub();
+ while (this.pos < this.src.length) {
+ const ch = this.peek();
+ if (ch === '<' || ch === '>') {
+ const next = this.pos + 1 < this.src.length ? this.src[this.pos + 1] : '';
+ if (next === '=') {
+ // <= or >=
+ if (ch === '<') {
+ this.pos += 2;
+ const right = this.parseAddSub();
+ left = bool2double(left <= right);
+ } else {
+ this.pos += 2;
+ const right = this.parseAddSub();
+ left = bool2double(left >= right);
+ }
+ } else {
+ // < or >
+ if (ch === '<') {
+ this.pos += 1;
+ const right = this.parseAddSub();
+ left = bool2double(left < right);
+ } else {
+ this.pos += 1;
+ const right = this.parseAddSub();
+ left = bool2double(left > right);
+ }
+ }
+ } else {
+ break;
+ }
+ }
+ return left;
+ }
+
+ /**
+ * Addition and subtraction.
+ * Must distinguish binary + from unary +/-.
+ */
+ parseAddSub(): number {
+ let left = this.parseMulDiv();
+
+ while (this.pos < this.src.length) {
+ const ch = this.peek();
+ if (ch === '+') {
+ this.pos += 1;
+ const right = this.parseMulDiv();
+ left = left + right;
+ } else if (ch === '-') {
+ // Check if this is a binary minus (preceded by a value) or unary
+ // If preceded by operator or start, it's unary — handled by parseMulDiv → parseUnary
+ // Here we need to check if this is a binary minus
+ // A minus is binary if preceded by: number, ), variable, true, false
+ if (this.pos > 0) {
+ const prevCh = this.src[this.pos - 1];
+ if (/[0-9)TrueFals]/.test(prevCh) || prevCh === 'e' || prevCh === 'E') {
+ this.pos += 1;
+ const right = this.parseMulDiv();
+ left = left - right;
+ } else {
+ break;
+ }
+ } else {
+ break;
+ }
+ } else {
+ break;
+ }
+ }
+ return left;
+ }
+
+ /**
+ * Multiplication, division, modulus.
+ */
+ parseMulDiv(): number {
+ let left = this.parseUnary();
+
+ while (this.pos < this.src.length) {
+ const ch = this.peek();
+ if (ch === '*') {
+ this.pos += 1;
+ const right = this.parseUnary();
+ left = left * right;
+ } else if (ch === '/') {
+ this.pos += 1;
+ const right = this.parseUnary();
+ left = left / right;
+ } else if (ch === '%') {
+ this.pos += 1;
+ const right = this.parseUnary();
+ left = left % right;
+ } else {
+ break;
+ }
+ }
+ return left;
+ }
+
+ /**
+ * Unary operators: !, -
+ */
+ parseUnary(): number {
+ // Handle repeated unary operators (e.g., "!!", "---")
+ while (this.pos < this.src.length) {
+ const ch = this.peek();
+ if (ch === '!') {
+ this.pos += 1;
+ const val = this.parseUnary();
+ return bool2double(val === 0);
+ } else if (ch === '-') {
+ this.pos += 1;
+ const val = this.parseUnary();
+ return -val;
+ } else if (ch === '+') {
+ this.pos += 1;
+ continue;
+ } else {
+ break;
+ }
+ }
+ return this.parsePrimary();
+ }
+
+ /**
+ * Primary: number, variable, parenthesized expression, true/false.
+ */
+ parsePrimary(): number {
+ // Parenthesized expression
+ if (this.peek() === '(') {
+ this.pos++; // skip (
+ const val = this.parseOr();
+ if (this.peek() === ')') {
+ this.pos++; // skip )
+ }
+ return val;
+ }
+
+ const token = this.readToken();
+
+ if (token === 'true' || token === 'True') {
+ return 1.0;
+ }
+ if (token === 'false' || token === 'False') {
+ return 0.0;
+ }
+
+ // Try as number
+ const num = Number(token);
+ if (!isNaN(num)) {
+ return num;
+ }
+
+ // Variable lookup
+ if (token in this.ctx) {
+ return Number(this.ctx[token]);
+ }
+
+ throw new Error(`Unknown variable: ${token}`);
+ }
+}
diff --git a/src/core/fasting.ts b/src/core/fasting.ts
new file mode 100644
index 0000000..8465c25
--- /dev/null
+++ b/src/core/fasting.ts
@@ -0,0 +1,156 @@
+/**
+ * Fasting rules engine.
+ * Evaluates Fasting.xml rules against dayInfo to determine fasting level for a given day.
+ *
+ * Fasting.java is part of the Ponomar program.
+ * Copyright 2009 Yuri Shardt.
+ * GPL v3 — see LICENSE.
+ */
+
+import { evalBool } from './evaluator';
+import type { EvalContext } from './types';
+
+/** 7-bit fasting case string. Each bit: 1=permitted, 0=forbidden. */
+export type FastingCase = string;
+
+export interface FastingPeriod {
+ cmd: string;
+ rules: FastingRule[];
+}
+
+export interface FastingRule {
+ case: FastingCase;
+ cmd: string;
+}
+
+export interface FastingResult {
+ case: FastingCase;
+ level: number; // 0-6
+ description: string;
+ allowed: string[];
+ forbidden: string[];
+}
+
+const FOOD_NAMES_LOCALIZED: Record = {
+ en: ['meat', 'dairy products', 'fish', 'caviar', 'oil', 'wine', 'dry food'],
+ ru: ['мясо', 'молочные продукты', 'рыба', 'рыбная икра', 'растительное масло', 'вино', 'сухоядение'],
+ cu: ['мѧ́со', 'мла҆чные продꙋ́кты', 'ры́ба', 'ры́бнаѧ и҆́кра', 'є҆ле́й', 'ві́но̀', 'сухоѧденїе'],
+ fr: ['le viande', 'les produits lactières', 'le poisson', 'le caviar', "l'huile", 'le vin', 'des nourritures secs'],
+ el: ['meat', 'dairy products', 'fish', 'caviar', 'oil', 'wine', 'dry food'],
+ zh: ['肉类和动物制品', '乳制品', '鱼', '鱼子', '油', '酒', '干食'],
+ 'zh-Hant': ['肉類和動物製品', '乳製品', '魚', '魚子', '油', '酒', '幹食'],
+};
+
+/**
+ * Parse fasting XML data. The XML is pre-converted to JSON at build time.
+ */
+export function parseFastingData(data: FastingPeriod[]): FastingPeriod[] {
+ return data;
+}
+
+/**
+ * Evaluate fasting rules for a given day.
+ * Returns the fasting case (7-bit string) based on matching rules.
+ */
+export function evaluateFasting(
+ periods: FastingPeriod[],
+ ctx: EvalContext,
+ lang: string = 'en'
+): FastingResult {
+ let currentCase: FastingCase = '1111111'; // Default: no fast
+
+ // Process periods in order; later periods can override earlier ones
+ for (const period of periods) {
+ // Check if this period applies to today
+ if (period.cmd && !evalBool(period.cmd, ctx)) {
+ continue;
+ }
+
+ // Evaluate rules within this period
+ for (const rule of period.rules) {
+ if (rule.cmd && !evalBool(rule.cmd, ctx)) {
+ continue;
+ }
+ // This rule matches — apply it
+ currentCase = rule.case;
+ }
+ }
+
+ return formatFastingResult(currentCase, lang);
+}
+
+/**
+ * Format a fasting case into a human-readable result.
+ */
+function formatFastingResult(fastCase: FastingCase, lang: string = 'en'): FastingResult {
+ const names = FOOD_NAMES_LOCALIZED[lang] || FOOD_NAMES_LOCALIZED.en;
+ const allowed: string[] = [];
+ const forbidden: string[] = [];
+
+ // Case format: meat,cheese,fish,caviar,oil,wine,dryFood
+ // Index 0 = meat, 6 = dry food (leftmost = highest food, rightmost = lowest)
+ for (let i = 0; i < 7; i++) {
+ const permitted = fastCase[i] === '1';
+ if (permitted) {
+ allowed.push(names[i]);
+ } else {
+ forbidden.push(names[i]);
+ }
+ }
+
+ // Determine fasting level
+ let level = 0;
+ switch (fastCase) {
+ case '1111111': level = 0; break; // No fast
+ case '0111111': level = 1; break; // Meat excluded
+ case '0011111': level = 2; break; // Fish permitted
+ case '0001111': level = 3; break; // Caviar permitted
+ case '0000111': level = 4; break; // Wine and oil
+ case '0000011': level = 5; break; // Without oil
+ case '0000001': level = 6; break; // Dry food only (xerophagy)
+ case '0000000': level = 7; break; // Complete fast
+ default: level = 0;
+ }
+
+ // Generate description
+ const description = generateDescription(fastCase, allowed, forbidden, lang);
+
+ return {
+ case: fastCase,
+ level,
+ description,
+ allowed,
+ forbidden,
+ };
+}
+
+function generateDescription(
+ fastCase: FastingCase,
+ allowed: string[],
+ forbidden: string[],
+ lang: string
+): string {
+ if (fastCase === '1111111') {
+ return lang === 'ru' ? 'Поста нет' : lang === 'cu' ? 'Поста нѣтъ' : 'No fast';
+ }
+ if (fastCase === '0000000') {
+ return lang === 'ru' ? 'Полное воздержание от пищи' : lang === 'cu' ? 'Полное воздержаніе отъ пищи' : 'Complete fast (no food)';
+ }
+
+ const labels: Record = {
+ en: { permitted: 'Permitted', forbidden: 'Forbidden', is: 'is', are: 'are' },
+ ru: { permitted: 'Разрешено', forbidden: 'Запрещено', is: '', are: '' },
+ cu: { permitted: 'Позволено', forbidden: 'Запрещено', is: '', are: '' },
+ };
+ const L = labels[lang] || labels.en;
+
+ if (allowed.length === 0) {
+ return `${L.forbidden}: ${forbidden.join(', ')}`;
+ }
+ if (forbidden.length === 0) {
+ return `${L.permitted}: ${allowed.join(', ')}`;
+ }
+ const verb = allowed.length === 1 ? L.is : L.are;
+ const verbF = forbidden.length === 1 ? L.is : L.are;
+ return `${L.permitted} ${verb} ${allowed.join(', ')}. ${L.forbidden} ${verbF} ${forbidden.join(', ')}`;
+}
diff --git a/src/core/i18n.ts b/src/core/i18n.ts
new file mode 100644
index 0000000..b88bffe
--- /dev/null
+++ b/src/core/i18n.ts
@@ -0,0 +1,751 @@
+/**
+ * Internationalization (i18n) module for Ponomar PWA.
+ * Provides translations for UI strings in English, Russian, and Church Slavonic.
+ */
+
+export type LanguageCode = 'en' | 'ru' | 'cu';
+
+export interface Translations {
+ // General
+ appName: string;
+ loading: string;
+ error: string;
+ notAvailable: string;
+
+ // Navigation
+ nav: {
+ calendar: string;
+ service: string;
+ bible: string;
+ settings: string;
+ };
+
+ // Calendar
+ calendar: {
+ months: string[];
+ monthsGenitive: string[];
+ dayNames: string[];
+ dayNamesFull: string[];
+ rankLabels: Record;
+ rankIcons: Record;
+ pascha: string;
+ pentecost: string;
+ cleanMonday: string;
+ greatLent: string;
+ brightWeek: string;
+ weekAfterPentecost: string;
+ weekOrdSuffix: string;
+ apostlesFast: string;
+ dormitionFast: string;
+ nativityFast: string;
+ commemorations: string;
+ fastingRule: string;
+ readings: string;
+ noCommemorations: string;
+ noReadings: string;
+ readingTypes: Record;
+ defaultBibleVersion: string;
+ dayReading: string[];
+ readSep: string;
+ colon: string;
+ dayOfYear: string;
+ ofYear: string;
+ tone: string;
+ gregorianDate: string;
+ churchDate: string;
+ annoMundi: string;
+ yearOfWorld: string;
+ fromYear: string;
+ fromAdam: string;
+ };
+
+ // Services
+ services: {
+ title: string;
+ selectService: string;
+ serviceProducedNoOutput: string;
+ errorLoading: string;
+ nodesAssembled: string;
+ greatLent: string;
+ brightWeek: string;
+ apostlesFast: string;
+ dormitionFast: string;
+ nativityFast: string;
+ tone: string;
+ roleLabel: string;
+ rolePriest: string;
+ roleReader: string;
+ roleAuto: string;
+ serviceNames: {
+ primes: string;
+ third: string;
+ sixth: string;
+ ninth: string;
+ vespers: string;
+ liturgy: string;
+ royalhours: string;
+ compline: string;
+ };
+ serviceDescriptions: {
+ primes: string;
+ third: string;
+ sixth: string;
+ ninth: string;
+ vespers: string;
+ liturgy: string;
+ royalhours: string;
+ compline: string;
+ };
+ };
+
+ // Bible
+ bible: {
+ selectBook: string;
+ selectChapter: string;
+ read: string;
+ loading: string;
+ noTextFound: string;
+ notAvailableOffline: string;
+ ensureDataConverted: string;
+ };
+
+ // Settings
+ settings: {
+ title: string;
+ language: string;
+ fontSettings: string;
+ usePonomarFont: string;
+ usePonomarFontDesc: string;
+ fontPreview: string;
+ fontPreviewNoteActive: string;
+ fontPreviewNoteInactive: string;
+ fontSize: string;
+ systemFont: string;
+ bibleSettings: string;
+ defaultTranslation: string;
+ showVerseNumbers: string;
+ verseNewLine: string;
+ bibleComments: {
+ fullBible: string;
+ ntOnly: string;
+ otOnly: string;
+ otProphets: string;
+ };
+ calendarType: string;
+ julian: string;
+ gregorian: string;
+ about: string;
+ aboutText: string;
+ aboutLicense: string;
+ installPwa: string;
+ };
+
+ // Common
+ common: {
+ priest: string;
+ reader: string;
+ deacon: string;
+ choir: string;
+ all: string;
+ };
+}
+
+const translations: Record = {
+ en: {
+ appName: '☦ Ponomar',
+ loading: 'Loading...',
+ error: 'Error',
+ notAvailable: 'Not available',
+
+ nav: {
+ calendar: '📅 Calendar',
+ service: '📖 Service',
+ bible: '☦ Bible',
+ settings: '⚙ Settings',
+ },
+
+ calendar: {
+ months: [
+ 'January', 'February', 'March', 'April', 'May', 'June',
+ 'July', 'August', 'September', 'October', 'November', 'December'
+ ],
+ monthsGenitive: [
+ 'January', 'February', 'March', 'April', 'May', 'June',
+ 'July', 'August', 'September', 'October', 'November', 'December'
+ ],
+ dayNames: ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'],
+ dayNamesFull: ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'],
+ rankLabels: {
+ 1: 'Troparion',
+ 2: 'Antiphon',
+ 3: 'Hexahymn',
+ 4: 'Doxology',
+ 5: 'Polyeleos',
+ 6: 'Great Saint',
+ 7: 'Great Feast of the Theotokos',
+ 8: 'Great Feast of the Lord',
+ },
+ rankIcons: {
+ 2: '🕃',
+ 3: '🕃',
+ 4: '🕂',
+ 5: '🕁',
+ 6: '🕀',
+ },
+ pascha: 'Pascha: ',
+ pentecost: 'Pentecost: ',
+ cleanMonday: 'Clean Monday: ',
+ greatLent: ' · Great Lent',
+ brightWeek: ' · Bright Week',
+ weekAfterPentecost: 'week after Pentecost',
+ weekOrdSuffix: 'th',
+ apostlesFast: " · Apostles' Fast",
+ dormitionFast: ' · Dormition Fast',
+ nativityFast: ' · Nativity Fast',
+ commemorations: 'Commemorations',
+ fastingRule: 'Fasting Rule',
+ readings: 'Readings',
+ noCommemorations: 'No commemorations for this day',
+ noReadings: 'No readings assigned',
+ readingTypes: {
+ apostol: 'Apostol',
+ gospel: 'Gospel',
+ matins: 'Matins',
+ vespers: 'Vespers',
+ primes: '1st Hour',
+ terce: '3rd Hour',
+ sext: '6th Hour',
+ none: '9th Hour',
+ liturgy: 'Liturgy',
+ '0': 'Vespers',
+ '1': 'Reading',
+ '2': 'Reading',
+ '3': 'Reading',
+ '4': '4th Reading',
+ '5': '5th Reading',
+ '6': '6th Reading',
+ '7': '7th Reading',
+ '8': '8th Reading',
+ '9': '9th Reading',
+ '10': '10th Reading',
+ '11': '11th Reading',
+ '12': '12th Reading',
+ '13': '13th Reading',
+ '14': '14th Reading',
+ '15': '15th Reading',
+ A: 'Reading',
+ },
+ defaultBibleVersion: 'en/bible/kjv',
+ dayReading: ['for Sunday', 'for Monday', 'for Tuesday', 'for Wednesday', 'for Thursday', 'for Friday', 'for Saturday'],
+ readSep: '; ',
+ colon: ': ',
+ dayOfYear: ' · Day ',
+ ofYear: ' of year',
+ tone: 'Tone ',
+ gregorianDate: 'Civil (Gregorian) Date',
+ churchDate: 'Church (Julian) Date',
+ annoMundi: 'Year of the World',
+ yearOfWorld: 'year of the world',
+ fromYear: 'from Christ',
+ fromAdam: 'from Adam',
+ },
+
+ services: {
+ title: 'Services',
+ selectService: 'Select a service to view its content.',
+ serviceProducedNoOutput: 'Service produced no output.',
+ errorLoading: 'Error loading service.',
+ nodesAssembled: 'nodes assembled.',
+ greatLent: ' · Great Lent',
+ brightWeek: ' · Bright Week',
+ apostlesFast: " · Apostles' Fast",
+ dormitionFast: ' · Dormition Fast',
+ nativityFast: ' · Nativity Fast',
+ tone: ' · Tone ',
+ roleLabel: 'Role:',
+ rolePriest: 'Priest',
+ roleReader: 'Reader',
+ roleAuto: 'Auto',
+ serviceNames: {
+ primes: 'First Hour',
+ third: 'Third Hour',
+ sixth: 'Sixth Hour',
+ ninth: 'Ninth Hour',
+ vespers: 'Vespers',
+ liturgy: 'Divine Liturgy',
+ royalhours: 'Royal Hours',
+ compline: 'Compline',
+ },
+ serviceDescriptions: {
+ primes: 'Prime (First Hour)',
+ third: 'Third Hour',
+ sixth: 'Sixth Hour',
+ ninth: 'Ninth Hour',
+ vespers: 'Evening service (Vespers)',
+ liturgy: 'Principal service (Divine Liturgy)',
+ royalhours: 'Royal Hours',
+ compline: 'Night office (Compline)',
+ },
+ },
+
+ bible: {
+ selectBook: 'Select a book',
+ selectChapter: 'Select a chapter',
+ read: 'Read',
+ loading: 'Loading...',
+ noTextFound: 'No text found for this passage.',
+ notAvailableOffline: 'Text not available for offline viewing.',
+ ensureDataConverted: 'Bible text files are lazy-loaded on demand. Please ensure the data has been converted and is available.',
+ },
+
+ settings: {
+ title: 'Settings',
+ language: 'Interface Language',
+ fontSettings: 'Church Slavonic Font',
+ usePonomarFont: 'Use Ponomar font for Church Slavonic texts',
+ usePonomarFontDesc: 'Use Ponomar font for Church Slavonic texts',
+ fontPreview: 'Preview:',
+ fontPreviewNoteActive: 'Ponomar font active — liturgical rank icons will render correctly',
+ fontPreviewNoteInactive: 'System font active — rank icons will use Ponomar font regardless',
+ fontSize: 'Liturgical Text Size: ',
+ systemFont: 'System Default',
+ bibleSettings: 'Bible',
+ defaultTranslation: 'Default Translation',
+ showVerseNumbers: 'Show verse numbers',
+ verseNewLine: 'Each verse on a new line',
+ bibleComments: {
+ fullBible: 'Full Bible',
+ ntOnly: 'NT only',
+ otOnly: 'OT only',
+ otProphets: 'OT Prophets',
+ },
+ calendarType: 'Default Calendar',
+ julian: 'Julian',
+ gregorian: 'Gregorian',
+ about: 'About Ponomar',
+ aboutText: 'Ponomar is an Orthodox Church calendar application providing liturgical information, service texts, and Bible readings in multiple languages.',
+ aboutLicense: 'Based on the Ponomar Java desktop application by Aleksandr Andreev and Yuri Shardt. Licensed under GPL v3.',
+ installPwa: 'Install Ponomar',
+ },
+
+ common: {
+ priest: 'Priest',
+ reader: 'Reader',
+ deacon: 'Deacon',
+ choir: 'Choir',
+ all: 'All',
+ },
+ },
+
+ ru: {
+ appName: '☦ Понома́рь',
+ loading: 'Загрузка...',
+ error: 'Ошибка',
+ notAvailable: 'Недоступно',
+
+ nav: {
+ calendar: '📅 Календарь',
+ service: '📖 Устав',
+ bible: '☦ Библия',
+ settings: '⚙ Настройки',
+ },
+
+ calendar: {
+ months: [
+ 'Январь', 'Февраль', 'Март', 'Апрель', 'Май', 'Июнь',
+ 'Июль', 'Август', 'Сентябрь', 'Октябрь', 'Ноябрь', 'Декабрь'
+ ],
+ monthsGenitive: [
+ 'января', 'февраля', 'марта', 'апреля', 'мая', 'июня',
+ 'июля', 'августа', 'сентября', 'октября', 'ноября', 'декабря'
+ ],
+ dayNames: ['Вс', 'Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб'],
+ dayNamesFull: ['Воскресенье', 'Понедельник', 'Вторник', 'Среда', 'Четверг', 'Пятница', 'Суббота'],
+ rankLabels: {
+ 1: 'Тропарь',
+ 2: 'Антифон',
+ 3: 'Шестипеснец',
+ 4: 'Славословие',
+ 5: 'Полиелей',
+ 6: 'Великий святой',
+ 7: 'Великий праздник Богородичный',
+ 8: 'Великий праздник Господень',
+ },
+ rankIcons: {
+ 2: '🕃',
+ 3: '🕃',
+ 4: '🕂',
+ 5: '🕁',
+ 6: '🕀',
+ },
+ pascha: 'Пасха: ',
+ pentecost: 'Пятидесятница: ',
+ cleanMonday: 'Чистый понедельник: ',
+ greatLent: ' · Великий пост',
+ brightWeek: ' · Светлая седмица',
+ weekAfterPentecost: 'седмица по Пятидесятнице',
+ weekOrdSuffix: 'я',
+ apostlesFast: ' · Петров пост',
+ dormitionFast: ' · Успенский пост',
+ nativityFast: ' · Рождественский пост',
+ commemorations: 'Память святых',
+ fastingRule: 'Правило поста',
+ readings: 'Чтения',
+ noCommemorations: 'Нет памятей на этот день',
+ noReadings: 'Чтения не назначены',
+ readingTypes: {
+ apostol: 'Апостол',
+ gospel: 'Евангелие',
+ matins: 'Утреня',
+ vespers: 'Вечерня',
+ primes: '1-й час',
+ terce: '3-й час',
+ sext: '6-й час',
+ none: '9-й час',
+ liturgy: 'Литургия',
+ '0': 'Вечерня',
+ '1': 'Вечерня',
+ '2': 'Вечерня',
+ '3': 'Вечерня',
+ '4': '4-е чтение',
+ '5': '5-е чтение',
+ '6': '6-е чтение',
+ '7': '7-е чтение',
+ '8': '8-е чтение',
+ '9': '9-е чтение',
+ '10': '10-е чтение',
+ '11': '11-е чтение',
+ '12': '12-е чтение',
+ '13': '13-е чтение',
+ '14': '14-е чтение',
+ '15': '15-е чтение',
+ A: 'Чтение',
+ },
+ defaultBibleVersion: 'ru/bible/synod',
+ dayReading: ['для воскресенья', 'для понедельника', 'для вторника', 'для среды', 'для четверга', 'для пятницы', 'для субботы'],
+ readSep: '; ',
+ colon: ': ',
+ dayOfYear: ' · День ',
+ ofYear: ' года',
+ tone: 'Глас ',
+ gregorianDate: 'По гражданскому стилю',
+ churchDate: 'По церковному стилю',
+ annoMundi: 'Лета от сотворения мира',
+ yearOfWorld: 'год от сотворения мира',
+ fromYear: 'от РХ',
+ fromAdam: 'от Адама',
+ },
+
+ services: {
+ title: 'Богослужения',
+ selectService: 'Выберите богослужение для просмотра.',
+ serviceProducedNoOutput: 'Богослужение не дало текста.',
+ errorLoading: 'Ошибка загрузки богослужения.',
+ nodesAssembled: 'узлов собрано.',
+ greatLent: ' · Великий пост',
+ brightWeek: ' · Светлая седмица',
+ apostlesFast: ' · Петров пост',
+ dormitionFast: ' · Успенский пост',
+ nativityFast: ' · Рождественский пост',
+ tone: ' · Глас ',
+ roleLabel: 'Чин:',
+ rolePriest: 'Священник',
+ roleReader: 'Чтец',
+ roleAuto: 'Авто',
+ serviceNames: {
+ primes: 'Первый час',
+ third: 'Третий час',
+ sixth: 'Шестой час',
+ ninth: 'Девятый час',
+ vespers: 'Вечерня',
+ liturgy: 'Божественная Литургия',
+ royalhours: 'Царские часы',
+ compline: 'Повечерие',
+ },
+ serviceDescriptions: {
+ primes: 'Час первый',
+ third: 'Час третий',
+ sixth: 'Час шестой',
+ ninth: 'Час девятый',
+ vespers: 'Вечернее богослужение (Вечерня)',
+ liturgy: 'Главное богослужение (Божественная Литургия)',
+ royalhours: 'Царские часы',
+ compline: 'Ночное богослужение (Повечерие)',
+ },
+ },
+
+ bible: {
+ selectBook: 'Выберите книгу',
+ selectChapter: 'Выберите главу',
+ read: 'Читать',
+ loading: 'Загрузка...',
+ noTextFound: 'Текст не найден для этого отрывка.',
+ notAvailableOffline: 'Текст недоступен для просмотра в автономном режиме.',
+ ensureDataConverted: 'Файлы библейского текста загружаются по требованию. Убедитесь, что данные сконвертированы и доступны.',
+ },
+
+ settings: {
+ title: 'Настройки',
+ language: 'Язык интерфейса',
+ fontSettings: 'Шрифт церковнославянского',
+ usePonomarFont: 'Использовать шрифт Понома́рь для церковнославянских текстов',
+ usePonomarFontDesc: 'Использовать шрифт Понома́рь для церковнославянских текстов',
+ fontPreview: 'Предпросмотр:',
+ fontPreviewNoteActive: 'Шрифт Понома́рь активен — иконы чинов будут отображаться правильно',
+ fontPreviewNoteInactive: 'Системный шрифт активен — иконы чинов будут использовать Понома́рь в любом случае',
+ fontSize: 'Размер литургического текста: ',
+ systemFont: 'Системный шрифт',
+ bibleSettings: 'Библия',
+ defaultTranslation: 'Перевод по умолчанию',
+ showVerseNumbers: 'Показывать номера стихов',
+ verseNewLine: 'Каждый стих с новой строки',
+ bibleComments: {
+ fullBible: 'Полная Библия',
+ ntOnly: 'Новый Завет',
+ otOnly: 'Ветхий Завет',
+ otProphets: 'Пророки',
+ },
+ calendarType: 'Календарь по умолчанию',
+ julian: 'Юлианский',
+ gregorian: 'Григорианский',
+ about: 'О Понома́ре',
+ aboutText: 'Понома́рь — приложение православного календаря, предоставляющее литургическую информацию, тексты служб и библейские чтения на нескольких языках.',
+ aboutLicense: 'Основано на Java-приложении Понома́рь от Александра Андреева и Юрия Шардт. Лицензия GPL v3.',
+ installPwa: 'Установить Пономарь',
+ },
+
+ common: {
+ priest: 'Свящ.',
+ reader: 'Чтец',
+ deacon: 'Диакон',
+ choir: 'Хор',
+ all: 'Все',
+ },
+ },
+
+ cu: {
+ appName: '☦ Понома́рь',
+ loading: 'Загрузка...',
+ error: 'Ошибка',
+ notAvailable: 'Недоступно',
+
+ nav: {
+ calendar: '📅 Календарь',
+ service: '📖 Уставъ',
+ bible: '☦ Библия',
+ settings: '⚙ Настройки',
+ },
+
+ calendar: {
+ months: [
+ 'Январь', 'Февраль', 'Мартъ', 'Апрель', 'Май', 'Июнь',
+ 'Июль', 'Августъ', 'Сентябрь', 'Октябрь', 'Ноябрь', 'Декабрь'
+ ],
+ monthsGenitive: [
+ 'января', 'февраля', 'марта', 'апрѣля', 'мая', 'іюня',
+ 'іюля', 'августа', 'септемврія', 'октоврія', 'ноемврія', 'декаврія'
+ ],
+ dayNames: ['Нед', 'Пон', 'Вто', 'Сре', 'Чет', 'Пят', 'Суб'],
+ dayNamesFull: ['Неделя', 'Понедельникъ', 'Вторникъ', 'Среда', 'Четвергъ', 'Пятница', 'Суббота'],
+ rankLabels: {
+ 1: 'Тропарь',
+ 2: 'Антифонъ',
+ 3: 'Шестопѣснецъ',
+ 4: 'Славословіе',
+ 5: 'Полиелей',
+ 6: 'Великій святой',
+ 7: 'Великій праздникъ Богородичный',
+ 8: 'Великій праздникъ Господень',
+ },
+ rankIcons: {
+ 2: '🕃',
+ 3: '🕃',
+ 4: '🕂',
+ 5: '🕁',
+ 6: '🕀',
+ },
+ pascha: 'Пасха: ',
+ pentecost: 'Пятидесятница: ',
+ cleanMonday: 'Чистый понедельникъ: ',
+ greatLent: ' · Великій постъ',
+ brightWeek: ' · Светлая седмица',
+ weekAfterPentecost: 'седмица по Пѧтидесѧтницѣ',
+ weekOrdSuffix: 'ѧ',
+ apostlesFast: ' · Петровъ постъ',
+ dormitionFast: ' · Успенскій постъ',
+ nativityFast: ' · Рождественскій постъ',
+ commemorations: 'Память святыхъ',
+ fastingRule: 'Правило поста',
+ readings: 'Чтения',
+ noCommemorations: 'Нетъ памятей на сей день',
+ noReadings: 'Чтения не назначаются',
+ readingTypes: {
+ apostol: 'А҆пⷭ҇лъ',
+ gospel: 'Є҆ѵⷢ҇лїе',
+ matins: 'Оу҆́тренѧ',
+ vespers: 'Вече́рнѧ',
+ primes: 'Ча́съ пе́рвый',
+ terce: 'Ча́съ тре́тїй',
+ sext: 'Ча́съ шесты́й',
+ none: 'Ча́съ девѧ́тый',
+ liturgy: 'Лїтꙋргі́ѧ',
+ '0': 'Вече́рнѧ',
+ '1': 'Вече́рнѧ',
+ '2': 'Вече́рнѧ',
+ '3': 'Вече́рнѧ',
+ '4': '4-е чте́ніе',
+ '5': '5-е чте́ніе',
+ '6': '6-е чте́ніе',
+ '7': '7-е чте́ніе',
+ '8': '8-е чте́ніе',
+ '9': '9-е чте́ніе',
+ '10': '10-е чте́ніе',
+ '11': '11-е чте́ніе',
+ '12': '12-е чте́ніе',
+ '13': '13-е чте́ніе',
+ '14': '14-е чте́ніе',
+ '15': '15-е чте́ніе',
+ A: 'Чте́ніе',
+ },
+ defaultBibleVersion: 'cu/bible/elis',
+ dayReading: ['длѧ̀ недѣ́ли', 'длѧ̀ понедѣ́льника', 'длѧ̀ вто́рника', 'длѧ̀ сре́ды', 'длѧ̀ четвертка̀', 'длѧ̀ пѧткѝ', 'длѧ̀ сꙋббѡ́ты'],
+ readSep: ': ',
+ colon: ': ',
+ dayOfYear: ' · День ',
+ ofYear: ' года',
+ tone: 'Гласъ ',
+ gregorianDate: 'По гражданскому стилю',
+ churchDate: 'По церковному стилю',
+ annoMundi: 'Лѣта отъ сотворенiя мiра',
+ yearOfWorld: 'лѣта отъ сотворенiя мiра',
+ fromYear: 'от РХ',
+ fromAdam: 'от Адама',
+ },
+
+ services: {
+ title: 'Богослуженiя',
+ selectService: 'Изберете богослуженiе для просмотра.',
+ serviceProducedNoOutput: 'Богослуженiе не дало текста.',
+ errorLoading: 'Ошибка загрузки богослуженiя.',
+ nodesAssembled: 'узлов собрано.',
+ greatLent: ' · Великій постъ',
+ brightWeek: ' · Светлая седмица',
+ apostlesFast: ' · Петровъ постъ',
+ dormitionFast: ' · Успенскій постъ',
+ nativityFast: ' · Рождественскій постъ',
+ tone: ' · Гласъ ',
+ roleLabel: 'Чинъ:',
+ rolePriest: 'Священникъ',
+ roleReader: 'Чтецъ',
+ roleAuto: 'Авто',
+ serviceNames: {
+ primes: 'Первый часъ',
+ third: 'Третий часъ',
+ sixth: 'Шестой часъ',
+ ninth: 'Девятый часъ',
+ vespers: 'Вечерня',
+ liturgy: 'Божественная Литургия',
+ royalhours: 'Царскія часъ',
+ compline: 'Повечерiе',
+ },
+ serviceDescriptions: {
+ primes: 'Часъ первый',
+ third: 'Часъ третий',
+ sixth: 'Часъ шестой',
+ ninth: 'Часъ девятый',
+ vespers: 'Вечернее богослуженiе (Вечерня)',
+ liturgy: 'Главное богослуженiе (Божественнаꙗ Литургiꙗ)',
+ royalhours: 'Царскія часъ',
+ compline: 'Ночное богослуженiе (Повечерiе)',
+ },
+ },
+
+ bible: {
+ selectBook: 'Изберете книгу',
+ selectChapter: 'Изберете главу',
+ read: 'Читати',
+ loading: 'Загрузка...',
+ noTextFound: 'Текстъ не найденъ для сего отрывка.',
+ notAvailableOffline: 'Текстъ недоступенъ для просмотра въ автономномъ режиме.',
+ ensureDataConverted: 'Файлы библейскаго текста загружаются по требованiю. Убедитесь, что данные сконвертированы и доступны.',
+ },
+
+ settings: {
+ title: 'Настройки',
+ language: 'Языкъ интерфейса',
+ fontSettings: 'Шрифтъ церковнославянскаго',
+ usePonomarFont: 'Использовать шрифтъ Понома́рь для церковнославянскихъ текстовъ',
+ usePonomarFontDesc: 'Использовать шрифтъ Понома́рь для церковнославянскихъ текстовъ',
+ fontPreview: 'Предпросмотръ:',
+ fontPreviewNoteActive: 'Шрифтъ Понома́рь активенъ — иконы чинѣвъ будутъ отображатися правильно',
+ fontPreviewNoteInactive: 'Системный шрифтъ активенъ — иконы чинѣвъ будутъ использовать Понома́рь въ любомъ случаи',
+ fontSize: 'Размеръ литургическаго текста: ',
+ systemFont: 'Системный шрифтъ',
+ bibleSettings: 'Библия',
+ defaultTranslation: 'Переводъ по умолчанiю',
+ showVerseNumbers: 'Показывать номера стиховъ',
+ verseNewLine: 'Каждый стихъ съ новыя строки',
+ bibleComments: {
+ fullBible: 'Полная Библія',
+ ntOnly: 'Новый Завѣтъ',
+ otOnly: 'Ветхій Завѣтъ',
+ otProphets: 'Пророцы',
+ },
+ calendarType: 'Календарь по умолчанию',
+ julian: 'Юліанскій',
+ gregorian: 'Григоріанскій',
+ about: 'О Понома́ри',
+ aboutText: 'Понома́рь — приложенiе православнаго календаря, предоставляющее литургическiю информацию, тексты службъ и библейскія чтения на нескольких языкахъ.',
+ aboutLicense: 'Основано на Java-приложенiи Понома́рь отъ Александра Андреева и Юрия Шардт. Лицензия GPL v3.',
+ installPwa: 'Оустановити Понома́рь',
+ },
+
+ common: {
+ priest: 'Свящ.',
+ reader: 'Чтецъ',
+ deacon: 'Диаконъ',
+ choir: 'Хоръ',
+ all: 'Все',
+ },
+ },
+};
+
+/**
+ * Get translations for a specific language.
+ * Falls back to English if language not found.
+ */
+export function getTranslations(lang: LanguageCode): Translations {
+ return translations[lang] || translations.en;
+}
+
+/**
+ * Get a specific translation key with fallback.
+ */
+export function t(lang: LanguageCode, key: string, ...args: string[]): string {
+ const tr = getTranslations(lang);
+ const keys = key.split('.');
+ let value: unknown = tr;
+
+ for (const k of keys) {
+ if (value && typeof value === 'object' && k in value) {
+ value = (value as Record)[k];
+ } else {
+ // Fallback to English
+ value = translations.en;
+ for (const k2 of keys) {
+ if (value && typeof value === 'object' && k2 in value) {
+ value = (value as Record)[k2];
+ } else {
+ return key; // Return key if not found
+ }
+ }
+ break;
+ }
+ }
+
+ if (typeof value === 'string') {
+ return args.length > 0 ? value.replace(/{(\d+)}/g, (_, i) => args[i] ?? '') : value;
+ }
+
+ return key;
+}
\ No newline at end of file
diff --git a/src/core/jdate.ts b/src/core/jdate.ts
new file mode 100644
index 0000000..aa95076
--- /dev/null
+++ b/src/core/jdate.ts
@@ -0,0 +1,183 @@
+/**
+ * JDate — Julian calendar date engine.
+ * Faithful port of Ponomar/JDate.java to TypeScript.
+ * All dates are Julian calendar. Internally stored as Julian Day Number (JDN).
+ *
+ * JDate.java is part of the Ponomar program.
+ * Copyright 2006, 2007 Aleksandr Andreev.
+ * GPL v3 — see LICENSE.
+ */
+
+const DAYS_IN_MONTH = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
+const DAYS_IN_MONTH_LEAP = [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
+
+/**
+ * 1-based modulo. If result is 0, returns divisor.
+ * Matches Java JDate.mod() exactly.
+ */
+function mod1(divisor: number, modulo: number): number {
+ let temp = divisor % modulo;
+ if (temp === 0) temp = modulo;
+ return temp;
+}
+
+export class JDate {
+ readonly jdn: number;
+
+ /** Create from Julian month/day/year. */
+ constructor(monthOrJdn: number, day?: number, year?: number) {
+ if (day === undefined && year === undefined) {
+ // Constructor from JDN
+ this.jdn = monthOrJdn;
+ return;
+ }
+
+ const month = monthOrJdn;
+ if (month < 1 || month > 12) throw new Error(`Invalid month: ${month}`);
+ if (day! < 0) throw new Error(`Invalid day: ${day}`);
+
+ const leap = year! % 4 === 0;
+ const maxDays = leap ? DAYS_IN_MONTH_LEAP[month - 1] : DAYS_IN_MONTH[month - 1];
+ if (day! > maxDays) throw new Error(`Invalid day ${day} for month ${month} (year ${year})`);
+
+ // Fliegel-Van Flandern formula (Julian calendar)
+ const a = Math.floor((14 - month) / 12);
+ const y = year! + 4800 - a;
+ const m = month + 12 * a - 3;
+ this.jdn = day! + Math.floor((153 * m + 2) / 5) + 365 * y + Math.floor(y / 4) - 32083;
+ }
+
+ /** Create from JavaScript Date (Gregorian). Converts to Julian calendar. */
+ static fromGregorian(date: Date): JDate {
+ return JDate.fromGregorianParts(date.getFullYear(), date.getMonth() + 1, date.getDate());
+ }
+
+ /** Create from Gregorian year/month/day. */
+ static fromGregorianParts(gYear: number, gMonth: number, gDay: number): JDate {
+ const a = Math.floor((14 - gMonth) / 12);
+ const y = gYear + 4800 - a;
+ const m = gMonth + 12 * a - 3;
+ const jd = gDay + Math.floor((153 * m + 2) / 5) + 365 * y + Math.floor(y / 4) - Math.floor(y / 100) + Math.floor(y / 400) - 32045;
+ return new JDate(jd);
+ }
+
+ /** Create from today's date. */
+ static today(): JDate {
+ return JDate.fromGregorian(new Date());
+ }
+
+ /** Get year (Julian calendar). */
+ getYear(): number {
+ const jbar = this.jdn + 32083;
+ const n1 = Math.floor(jbar / 1461) * 4;
+ const n2 = Math.floor((jbar % 1461) / 365);
+ const da = jbar % 1461;
+ const adj = (da % 365) > 306 ? 1 : 0;
+ return Math.floor(n1 + n2) - 4800 + adj;
+ }
+
+ /** Get month (1=Jan ... 12=Dec, Julian calendar). */
+ getMonth(): number {
+ const jbar = this.jdn + 32083;
+ const da = jbar % 1461;
+ let m = mod1(da, 365);
+ let j = 2;
+ while (m > DAYS_IN_MONTH_LEAP[j]) {
+ m -= DAYS_IN_MONTH_LEAP[j];
+ j++;
+ if (j === 12) j = 0;
+ }
+ return j + 1;
+ }
+
+ /** Get day of month (Julian calendar). */
+ getDay(): number {
+ const jbar = this.jdn + 32083;
+ const da = mod1(jbar, 1461);
+ if (da === 1461) return 29; // Feb 29
+ let m = mod1(da, 365);
+ let k = 2;
+ while (m > DAYS_IN_MONTH[k]) {
+ m -= DAYS_IN_MONTH[k];
+ k++;
+ if (k === 12) k = 0;
+ }
+ return m;
+ }
+
+ /** Day of week: 0=Sunday, 1=Monday, ..., 6=Saturday. */
+ getDayOfWeek(): number {
+ let temp = (this.jdn % 7) + 1;
+ if (temp === 7) temp = 0;
+ return temp;
+ }
+
+ /** Day of year: 0=Jan 1, ..., 364/365=Dec 31. */
+ getDoy(): number {
+ const jbar = this.jdn + 32083;
+ const da = jbar % 1461;
+ return da === 1461 ? 366 : mod1(da + 59, 365) - 1;
+ }
+
+ /** Convert to JavaScript Date (Gregorian calendar). */
+ toGregorian(): Date {
+ let j1: number;
+ if (this.jdn >= 2299160.5) {
+ const tmp = Math.floor(((this.jdn - 1867216.0) - 0.25) / 36524.25);
+ j1 = this.jdn + 1 + tmp - Math.floor(0.25 * tmp);
+ } else {
+ j1 = this.jdn;
+ }
+ const j2 = j1 + 1524.0;
+ const j3 = Math.floor(6680.0 + ((j2 - 2439870.0) - 122.1) / 365.25);
+ const j4 = Math.floor(j3 * 365.25);
+ const j5 = Math.floor((j2 - j4) / 30.6001);
+ let d = Math.floor(j2 - j4 - Math.floor(j5 * 30.6001));
+ let m = Math.floor(j5 - 1.0);
+ if (m > 12) m -= 12;
+ let y = Math.floor(j3 - 4715.0);
+ if (m > 2) --y;
+ if (y <= 0) --y;
+ return new Date(y, m - 1, d);
+ }
+
+ /** Days to add to get to another date. Positive if this > other. */
+ difference(other: JDate): number {
+ return this.jdn - other.jdn;
+ }
+
+ /** Add n days, returning a new JDate. */
+ addDays(n: number): JDate {
+ return new JDate(this.jdn + n);
+ }
+
+ /** Subtract n days, returning a new JDate. */
+ subtractDays(n: number): JDate {
+ return new JDate(this.jdn - n);
+ }
+
+ /** Days in month for this date's month/year. */
+ static maxDaysInMonth(month: number, year: number): number {
+ return (year % 4 === 0) ? DAYS_IN_MONTH_LEAP[month - 1] : DAYS_IN_MONTH[month - 1];
+ }
+
+ equals(other: JDate): boolean {
+ return this.jdn === other.jdn;
+ }
+
+ compareTo(other: JDate): number {
+ return this.jdn - other.jdn;
+ }
+
+ toString(): string {
+ const y = this.getYear();
+ const m = this.getMonth();
+ const d = this.getDay();
+ return `${m < 10 ? '0' + m : m}/${d < 10 ? '0' + d : d}/${y}`;
+ }
+
+ /** Clone — returns a new JDate with the same JDN. */
+ clone(): JDate {
+ return new JDate(this.jdn);
+ }
+}
diff --git a/src/core/paschalion.ts b/src/core/paschalion.ts
new file mode 100644
index 0000000..656ebf0
--- /dev/null
+++ b/src/core/paschalion.ts
@@ -0,0 +1,257 @@
+/**
+ * Paschalion — Orthodox Church paschal and feast day computations.
+ * Faithful port of Ponomar/Paschalion.java to TypeScript.
+ *
+ * Paschalion.java is part of the Ponomar program.
+ * Copyright 2006, 2007 Aleksandr Andreev.
+ * GPL v3 — see LICENSE.
+ */
+
+import { JDate } from './jdate';
+
+const LUNAR_MONTH = 29.52916667;
+
+/** Age of the moon on March 1 for each year in the 19-year Metonic cycle (hours since midnight). */
+const FOUNDATION = [
+ 14.042016807, 25.462184874, 6.084033613, 17.966386555, 28.336134454,
+ 9.210084034, 20.504201681, 1.420168067, 12.294117647, 23.168067227,
+ 4.546218487, 15.042016807, 26.294117647, 7.630252101, 18.546218487,
+ 29.420168067, 11.756302521, 26.210084034, 3.042016807,
+];
+
+/** 1-based modulo. If result is 0, returns divisor. */
+function mod1(divisor: number, modulo: number): number {
+ let temp = divisor % modulo;
+ if (temp === 0) temp = modulo;
+ return temp;
+}
+
+/** Modulo for decimal numbers. */
+function modF(divisor: number, modulo: number): number {
+ return divisor - Math.floor(divisor / modulo) * modulo;
+}
+
+export const Paschalion = {
+ /**
+ * Get the date of Julian Pascha (Easter) for a given year.
+ * Uses Gaussian Easter formulae.
+ */
+ getPascha(year: number): JDate {
+ if (year < 33) throw new Error('Invalid year');
+ const a = year % 4;
+ const b = year % 7;
+ const c = year % 19;
+ const d = (19 * c + 15) % 30;
+ const e = (2 * a + 4 * b - d + 34) % 7;
+ const month = Math.floor((d + e + 114) / 31); // 3=March, 4=April
+ const day = ((d + e + 114) % 31) + 1;
+ return new JDate(month, day, year);
+ },
+
+ /** Pentecost: Pascha + 49 days. */
+ getPentecost(year: number): JDate {
+ return this.getPascha(year).addDays(49);
+ },
+
+ /** Clean Monday (Great Lent starts): Pascha - 48 days. */
+ getLentStart(year: number): JDate {
+ return this.getPascha(year).subtractDays(48);
+ },
+
+ /** Apostles' Fast starts: Pascha + 57 days (Monday after All Saints). */
+ getApostlesFastStart(year: number): JDate {
+ return this.getPascha(year).addDays(57);
+ },
+
+ /** Length of Apostles' Fast in days. Ends June 28 (Julian). */
+ getApostlesFastLength(year: number): number {
+ const start = this.getApostlesFastStart(year);
+ const end = new JDate(6, 28, year);
+ return end.difference(start);
+ },
+
+ /** Key of Boundaries (Ключ границ) — letter of the Paschal boundary. 1-30. */
+ getKeyOfBoundaries(year: number): number {
+ const date = this.getPascha(year);
+ const m = date.getMonth();
+ let g = date.getDay();
+ if (m === 3) {
+ g -= 21;
+ } else {
+ g += 10;
+ }
+ return g;
+ },
+
+ /** Indiction cycle. */
+ getIndiction(year: number): number {
+ if (year < 33) throw new Error('Invalid year');
+ return mod1(year - 312, 15);
+ },
+
+ /** Solar cycle. */
+ getSolarCycle(year: number): number {
+ if (year < 33) throw new Error('Invalid year');
+ return mod1(year + 5508, 28);
+ },
+
+ /** Lunar cycle (1-19). */
+ getLunarCycle(year: number): number {
+ if (year < 33) throw new Error('Invalid year');
+ let temp = (year + 1) % 19 - 3;
+ if (temp <= 0) temp += 19;
+ return temp;
+ },
+
+ /** Lunar phase for a date: 0=new moon, 0.5=full moon. */
+ getLunarPhase(date: JDate): number {
+ const year = date.getYear();
+ const cycle = this.getLunarCycle(year);
+ let diff = date.difference(new JDate(3, 1, year));
+ if (diff < 0) {
+ diff = date.difference(new JDate(3, 1, year - 1));
+ }
+ let remainder = modF(diff, LUNAR_MONTH);
+ remainder += FOUNDATION[cycle - 1];
+ while (remainder >= LUNAR_MONTH) {
+ remainder -= LUNAR_MONTH;
+ }
+ return remainder / LUNAR_MONTH;
+ },
+
+ /**
+ * Annual fasting array: 0=no fast, 1=fast day, 2=cheesefare.
+ * One entry per day of the year (index 0=Jan 1).
+ */
+ getFasts(year: number): number[] {
+ if (year < 33) throw new Error('Invalid year');
+
+ const pascha = this.getPascha(year);
+ const numDays = (year % 4 === 0) ? 366 : 365;
+
+ // Mandatory fast days (JDN → reason string)
+ const mustFast = new Set();
+ mustFast.add(new JDate(1, 5, year).jdn);
+ mustFast.add(new JDate(8, 29, year).jdn);
+ mustFast.add(new JDate(9, 14, year).jdn);
+
+ // Mandatory fast-free days
+ const cantFast = new Set();
+ cantFast.add(new JDate(1, 6, year).jdn);
+
+ // Period boundaries
+ const SVIATKI_START = new JDate(12, 25, year);
+ const SVIATKI_END = new JDate(1, 4, year);
+ const PUB_PHAR_START = pascha.subtractDays(70);
+ const PUB_PHAR_END = pascha.subtractDays(63);
+ const CHEESE_START = pascha.subtractDays(55);
+ const CHEESE_END = pascha.subtractDays(49);
+ const LENT_START = pascha.subtractDays(48);
+ const LENT_END = pascha.subtractDays(1);
+ const BRIGHT_START = pascha;
+ const BRIGHT_END = pascha.addDays(6);
+ const PENT_START = pascha.addDays(49);
+ const PENT_END = pascha.addDays(56);
+ const APOSTLES_START = pascha.addDays(57);
+ const APOSTLES_END = new JDate(6, 28, year);
+ const DORM_START = new JDate(8, 1, year);
+ const DORM_END = new JDate(8, 14, year);
+ const ADVENT_START = new JDate(11, 15, year);
+ const ADVENT_END = new JDate(12, 24, year);
+
+ const retval = new Array(numDays);
+ let dummy = new JDate(1, 1, year);
+
+ for (let i = 0; i < numDays; i++) {
+ let fast = 0;
+ const jdn = dummy.jdn;
+
+ if (mustFast.has(jdn)) {
+ fast = 1;
+ } else if (cantFast.has(jdn)) {
+ fast = 0;
+ } else if (dummy.compareTo(SVIATKI_START) >= 0 || dummy.compareTo(SVIATKI_END) <= 0) {
+ fast = 0;
+ } else if (dummy.compareTo(PUB_PHAR_START) >= 0 && dummy.compareTo(PUB_PHAR_END) <= 0) {
+ fast = 0;
+ } else if (dummy.compareTo(CHEESE_START) >= 0 && dummy.compareTo(CHEESE_END) <= 0) {
+ fast = 2;
+ } else if (dummy.compareTo(LENT_START) >= 0 && dummy.compareTo(LENT_END) <= 0) {
+ fast = 1;
+ } else if (dummy.compareTo(BRIGHT_START) >= 0 && dummy.compareTo(BRIGHT_END) <= 0) {
+ fast = 0;
+ } else if (dummy.compareTo(PENT_START) >= 0 && dummy.compareTo(PENT_END) <= 0) {
+ fast = 0;
+ } else if (dummy.compareTo(APOSTLES_START) >= 0 && dummy.compareTo(APOSTLES_END) <= 0) {
+ fast = 1;
+ } else if (dummy.compareTo(DORM_START) >= 0 && dummy.compareTo(DORM_END) <= 0) {
+ fast = 1;
+ } else if (dummy.compareTo(ADVENT_START) >= 0 && dummy.compareTo(ADVENT_END) <= 0) {
+ fast = 1;
+ } else {
+ const dow = dummy.getDayOfWeek();
+ if (dow === 3 || dow === 5) fast = 1;
+ }
+
+ retval[i] = fast;
+ dummy = dummy.addDays(1);
+ }
+
+ return retval;
+ },
+
+ /**
+ * Major feasts for the year.
+ * Returns Map< JDN, feastName >.
+ */
+ getFeasts(year: number): Map {
+ if (year < 33) throw new Error('Invalid year');
+
+ const feastNames = [
+ 'New Year', 'Theophany', 'Nativity of the Forerunner',
+ 'Peter and Paul', 'Transfiguration', 'Dormition',
+ 'Beheading of the Forerunner', 'Nativity of the Theotokos',
+ 'Exaltation of the Cross', 'Protection of the Theotokos',
+ 'Entrance of the Theotokos', 'Nativity of Christ',
+ 'Pascha and Annunciation', 'Pascha', 'Annunciation',
+ 'Pentecost', 'Ascension', 'Palm Sunday', 'Meeting of the Lord',
+ ];
+
+ const feasts = new Map();
+
+ // Fixed feasts
+ feasts.set(new JDate(1, 1, year).jdn, feastNames[0]);
+ feasts.set(new JDate(1, 6, year).jdn, feastNames[1]);
+ feasts.set(new JDate(6, 24, year).jdn, feastNames[2]);
+ feasts.set(new JDate(6, 29, year).jdn, feastNames[3]);
+ feasts.set(new JDate(8, 6, year).jdn, feastNames[4]);
+ feasts.set(new JDate(8, 15, year).jdn, feastNames[5]);
+ feasts.set(new JDate(8, 29, year).jdn, feastNames[6]);
+ feasts.set(new JDate(9, 8, year).jdn, feastNames[7]);
+ feasts.set(new JDate(9, 14, year).jdn, feastNames[8]);
+ feasts.set(new JDate(10, 1, year).jdn, feastNames[9]);
+ feasts.set(new JDate(11, 21, year).jdn, feastNames[10]);
+ feasts.set(new JDate(12, 25, year).jdn, feastNames[11]);
+
+ // Moveable feasts
+ const pascha = this.getPascha(year);
+ if (pascha.equals(new JDate(3, 25, year))) {
+ feasts.set(pascha.jdn, feastNames[12]);
+ } else {
+ feasts.set(pascha.jdn, feastNames[13]);
+ feasts.set(new JDate(3, 25, year).jdn, feastNames[14]);
+ }
+ feasts.set(pascha.addDays(49).jdn, feastNames[15]);
+ feasts.set(pascha.addDays(39).jdn, feastNames[16]);
+ feasts.set(pascha.subtractDays(7).jdn, feastNames[17]);
+
+ // Meeting of the Lord — Feb 2, but transferred to Forgiveness Sunday if it falls on Clean Monday
+ let meeting = new JDate(2, 2, year);
+ if (pascha.difference(meeting) === 48) {
+ meeting = meeting.subtractDays(1);
+ }
+ feasts.set(meeting.jdn, feastNames[18]);
+
+ return feasts;
+ },
+};
diff --git a/src/core/service-assembler.ts b/src/core/service-assembler.ts
new file mode 100644
index 0000000..3d35c14
--- /dev/null
+++ b/src/core/service-assembler.ts
@@ -0,0 +1,597 @@
+/**
+ * Service Assembler — recursive template processor for liturgical services.
+ * Processes service XML templates (pre-converted to JSON) and renders HTML.
+ *
+ * Service.java is part of the Ponomar program.
+ * Copyright 2008, 2009, 2011 Yuri Shardt.
+ * See LICENSE for redistribution terms.
+ */
+
+import { evalBool } from './evaluator';
+import type { EvalContext, ServiceNode } from './types';
+
+export interface CommonTranslations {
+ priest: string;
+ reader: string;
+ deacon: string;
+ choir: string;
+}
+
+export interface ServiceContext {
+ evalCtx: EvalContext;
+ lang: string;
+ fetchText: (path: string) => Promise;
+ fetchServiceNodes: (path: string) => Promise;
+ fetchBibleText: (book: string, passage: string, showVerseNumbers?: boolean, verseNewLine?: boolean) => Promise;
+ fetchLives: (id: string) => Promise>> | null>;
+ fetchCommandText: (name: string) => Promise;
+ resolveTimes: (times: number) => Promise;
+ t?: { common: CommonTranslations };
+}
+
+function localizeWho(who: string, ctx: ServiceContext): string {
+ const t = ctx.t?.common;
+ if (!t) return who;
+ const map: Record = {
+ P: t.priest,
+ Priest: t.priest,
+ R: t.reader,
+ Reader: t.reader,
+ SR: t.reader,
+ D: t.deacon,
+ Deacon: t.deacon,
+ C: t.choir,
+ Choir: t.choir,
+ };
+ return map[who] ?? who;
+}
+
+// Localized reading intro names (getreading case)
+// Values from Java bible.xml Intro field
+const READING_NAMES: Record> = {
+ en: {
+ Isa: 'A reading from the Prophecy of Isaiah',
+ Mica: 'A reading from the Prophecy of Micah',
+ Zech: 'A reading from the Prophecy of Zechariah',
+ Jerem: 'A reading from the Prophecy of Jeremiah',
+ Baruch: 'A reading from the Prophecy of Jeremiah',
+ Mal: 'A reading from the Prophecy of Malachi',
+ Zeph: 'A reading from the Prophecy of Zephaniah',
+ Mt: 'A reading from the Holy Gospel according to Matthew',
+ Mk: 'A reading from the Holy Gospel according to Mark',
+ Lk: 'A reading from the Holy Gospel according to Luke',
+ Jn: 'A reading from the Holy Gospel according to John',
+ Acts: 'A reading from the Acts of the Apostles',
+ Rom: 'A reading from the Epistle of Paul to the Romans',
+ Gal: 'A reading from the Epistle of Paul to the Galatians',
+ Tit: 'A reading from the Epistle to Titus',
+ Heb: 'A reading from the Epistle to the Hebrews',
+ },
+ cu: {
+ Isa: 'Прⷪ҇ро́чества и҆са́їина чте́нїе',
+ Mica: 'Прⷪ҇ро́чества мїхе́ина чте́нїе',
+ Zech: 'Прⷪ҇ро́чества заха́рїина чте́нїе',
+ Jerem: 'Прⷪ҇ро́чества і҆еремі́ина чте́нїе',
+ Baruch: 'Прⷪ҇ро́чества і҆еремі́ина чте́нїе',
+ Mal: 'Прⷪ҇ро́чества малахі́ина чте́нїе',
+ Zeph: 'Прⷪ҇ро́чества софо́нїева чте́нїе',
+ Mt: 'Ѿ матѳе́а ст҃а́гѡ є҆ѵⷢ҇лїа чте́нїе',
+ Mk: 'Ѿ ма́рка ст҃а́гѡ є҆ѵⷢ҇лїа чте́нїе',
+ Lk: 'Ѿ лꙋкѝ ст҃а́гѡ є҆ѵⷢ҇лїа чте́нїе',
+ Jn: 'Ѿ і҆ѡа́нна ст҃а́гѡ є҆ѵⷢ҇лїа чте́нїе',
+ Acts: 'Дѣѧ̑нїй ст҃ы́хъ а҆пⷭ҇лъ чте́нїе',
+ Rom: 'Къ ри́млѧнѡмъ посла́нїѧ ст҃гѡ а҆пⷭ҇ла па́ѵла чте́нїе',
+ Gal: 'Къ гала́тѡмъ посла́нїѧ ст҃гѡ а҆пⷭ҇ла па́ѵла чте́нїе',
+ Tit: 'Къ ті́тꙋ посла́нїѧ ст҃гѡ а҆пⷭ҇ла па́ѵла чте́нїе',
+ Heb: 'Ко є҆вре́ємъ посла́нїѧ ст҃гѡ а҆пⷭ҇ла па́ѵла чте́нїе',
+ },
+ fr: {
+ Isa: 'Une lection d\'Ésaïe',
+ Mica: 'Une lection de la prophétie de Michée',
+ Zech: 'Une lection de la prophétie de Zacharie',
+ Jerem: 'Une lection de la prophétie de Jérémie',
+ Baruch: 'Une lection de la prophétie de Jérémie',
+ Mal: 'Une lection de la prophétie de Malachie',
+ Zeph: 'Une lection de la prophétie de Sophonie',
+ Mt: 'Une lection du Saint Évangile selon Saint Matthieu',
+ Mk: 'Une lection du Saint Évangile selon Saint Marc',
+ Lk: 'Une lection du Saint Évangile selon Saint Luc',
+ Jn: 'Une lection du Saint Évangile selon Saint Jean',
+ Acts: 'Une lection des Actes des Apôtres',
+ Rom: 'Une lection de l\'Épître de Paul aux Romains',
+ Gal: 'Une lection de l\'Épître de Paul aux Galates',
+ Tit: 'Une lection de l\'Épître de Paul au Tite',
+ Heb: 'Une lection de l\'Épître de Paul aux Hébreux',
+ },
+ el: {
+ Isa: 'Προφητείας Ἡσαΐου τὸ Ἀνάγνωσμα',
+ Mica: 'Προφητείας Μιχαίου τὸ Ἀνάγνωσμα',
+ Zech: 'Προφητείας Ζαχαρίου τὸ Ἀνάγνωσμα',
+ Jerem: 'Προφητείας Ἱερεμίου τὸ Ἀνάγνωσμα',
+ Baruch: 'Προφητείας Ἱερεμίου τὸ Ἀνάγνωσμα',
+ Mal: 'Προφητείας Μαλαχίου τὸ ἀνάγνωσμα',
+ Zeph: 'Προφητείας Σοφονίου τὸ Ἀνάγνωσμα',
+ Mt: 'Εὐαγγέλιον ἐκ τοῦ κατὰ Ματθαῖον',
+ Mk: 'Εὐαγγέλιον ἐκ τοῦ κατὰ Μᾶρκον',
+ Lk: 'Εὐαγγέλιον ἐκ τοῦ κατὰ Λουκᾶν',
+ Jn: 'Εὐαγγέλιον ἐκ τοῦ κατὰ Ἰωάννην',
+ Acts: 'Πράξεων τῶν Ἀποστολων',
+ Rom: 'Πρὸς Ῥωμαίους Ἐπιστολῆς Παύλου τὸ Ἀνάγνωσμα',
+ Gal: 'Πρὸς Γαλάτας ἐπιστολῆς Παύλου τὸ Ἀνάγνωσμα',
+ Tit: 'Πρὸς Τίτον ἐπιστολῆς Παύλου τὸ Ἀνάγνωσμα',
+ Heb: 'Πρὸς Ἑβραίους Ἐπιστολῆς Παύλου τὸ Ἀνάγνωσμα',
+ },
+};
+
+// Localized book names for passage headers
+const HEADER_NAMES: Record> = {
+ en: {
+ Psalm: 'Psalm', Isa: 'Isaiah', Jr: 'Jeremiah', Jerem: 'Jeremiah',
+ Ez: 'Ezekiel', Dn: 'Daniel', Mic: 'Micah', Mica: 'Micah',
+ Na: 'Nahum', Hab: 'Habakkuk', Zeph: 'Zephaniah', Hag: 'Haggai',
+ Zech: 'Zechariah', Mal: 'Malachi', Mt: 'Matthew', Mk: 'Mark',
+ Lk: 'Luke', Jn: 'John', Rom: 'Romans', Heb: 'Hebrews',
+ Gal: 'Galatians', Acts: 'Acts', Baruch: 'Baruch',
+ },
+ cu: {
+ Psalm: 'Ѱалти́рь', Isa: 'И҆са́їи', Jr: 'І҆еремі́и', Jerem: 'І҆еремі́и',
+ Ez: 'І҆езекі́ль', Dn: 'Дані́илъ', Mic: 'Мїхе́й', Mica: 'Мїхе́й',
+ Na: 'Наꙋ́мъ', Hab: 'Аввакꙋ́мъ', Zeph: 'Софо́нїа', Hag: 'Аггѣ́й',
+ Zech: 'Заха́рїа', Mal: 'Малахі́а', Mt: 'Ѿ Матѳе́а', Mk: 'Ѿ Ма́рка',
+ Lk: 'Ѿ лꙋкѝ', Jn: 'Ѿ і҆ѡа́нна', Rom: 'Къ ри́млѧнѡмъ', Heb: 'Ко є҆вре́ємъ',
+ Gal: 'Къ гала́тѡмъ', Acts: 'Дѣѧ̑нїѧ ст҃ы́хъ а҆пⷭ҇лъ', Baruch: 'Варꙋ́хъ',
+ },
+ fr: {
+ Psalm: 'Psaumes', Isa: 'Isaïe / Ésaïe', Jr: 'Jérémie', Jerem: 'Jérémie',
+ Ez: 'Ézéchiel', Dn: 'Daniel', Mic: 'Michée', Mica: 'Michée',
+ Na: 'Nahum', Hab: 'Habacuc', Zeph: 'Sophonie', Hag: 'Aggée',
+ Zech: 'Zacharie', Mal: 'Malachie', Mt: 'Matthieu', Mk: 'Marc',
+ Lk: 'Luc', Jn: 'Jean', Rom: 'Romains', Heb: 'Hébreux',
+ Gal: 'Galates', Acts: 'Actes des Apôtres', Baruch: 'Baruch',
+ },
+ el: {
+ Psalm: 'Ψαλμοί', Isa: 'Ἡσαΐας', Jr: 'Ἱερεμίας', Jerem: 'Ἱερεμίας',
+ Ez: 'Ἰεζεκιήλ', Dn: 'Δανιήλ', Mic: 'Μιχαίας', Mica: 'Μιχαίας',
+ Na: 'Ναούμ', Hab: 'Ἀμβακούμ', Zeph: 'Σοφονίας', Hag: 'Ἀγγαῖος',
+ Zech: 'Ζαχαρίας', Mal: 'Μαλαχίας', Mt: 'Εὐαγγέλιον κατὰ Ματθαῖον', Mk: 'Εὐαγγέλιον κατὰ Μᾶρκον',
+ Lk: 'Εὐαγγέλιον κατὰ Λουκᾶν', Jn: 'Εὐαγγέλιον κατὰ Ἰωάννην',
+ Rom: 'Πρὸς Ῥωμαίους', Heb: 'Πρὸς Ἑβραίους',
+ Gal: 'Πρὸς Γαλάτας', Acts: 'Πράξεων τῶν Ἀποστολων', Baruch: 'Βαρούχ',
+ },
+};
+
+function getReadingName(bookId: string, lang: string): string {
+ const lookupLang = lang === 'ru' ? 'cu' : lang;
+ const map = READING_NAMES[lookupLang] ?? READING_NAMES.en;
+ return map[bookId] ?? bookId;
+}
+
+function getHeaderName(bookId: string, lang: string): string {
+ const lookupLang = lang === 'ru' ? 'cu' : lang;
+ const map = HEADER_NAMES[lookupLang] ?? HEADER_NAMES.en;
+ return map[bookId] ?? bookId;
+}
+
+interface ImplementOptions {
+ header?: string;
+ who: string;
+ command?: string;
+ commandB?: string;
+ commandText?: string;
+ commandBText?: string;
+ timesText?: string;
+ redFirst?: boolean;
+ newLine?: boolean;
+ headerLevel?: number;
+}
+
+function str(v: unknown): string | undefined {
+ return typeof v === 'string' ? v : undefined;
+}
+function bool(v: unknown): boolean | undefined {
+ if (typeof v === 'boolean') return v;
+ if (v === '1' || v === 'true') return true;
+ if (v === '0' || v === 'false') return false;
+ return undefined;
+}
+function num(v: unknown): number | undefined {
+ if (typeof v === 'number') return v;
+ if (typeof v === 'string' && /^\d+$/.test(v)) return parseInt(v, 10);
+ return undefined;
+}
+
+/**
+ * Assemble a service from pre-parsed JSON template nodes.
+ * Returns HTML string.
+ */
+export async function assembleService(
+ nodes: ServiceNode[],
+ ctx: ServiceContext,
+ depth: number = 0
+): Promise<{ html: string; whoLast: string }> {
+ if (depth > 10) return { html: '', whoLast: '\x00' };
+
+ let html = '';
+ let whoLast = '\x00';
+
+ // Buffer for grouping same-who lines into a single flex container
+ let groupWho: string | null = null;
+ let groupTexts: string[] = [];
+
+ function flushGroup() {
+ if (!groupWho || groupTexts.length === 0) return;
+ const linesHtml = groupTexts.map((t, i) => {
+ const redPart = extractFirstLetter(t);
+ const line = redPart
+ ? `${redPart}${t.substring(redPart.length)}`
+ : t;
+ return (i > 0 ? '
' : '') + line;
+ }).join('');
+ html += `${groupWho}:${linesHtml}
\n`;
+ whoLast = groupWho;
+ groupWho = null;
+ groupTexts = [];
+ }
+
+ for (const raw of nodes) {
+ const n = raw as Record;
+ const type = n.type as string;
+
+ // Evaluate Cmd attribute if present
+ const cmd = str(n.cmd);
+ if (cmd) {
+ if (!evalBool(cmd, ctx.evalCtx)) continue;
+ }
+
+ switch (type) {
+ case 'TITLE': {
+ flushGroup();
+ const value = str(n.value);
+ const source = str(n.source);
+ const comment = str(n.comment);
+ if (value) {
+ const titleText = await ctx.fetchText(`Services/Text/${value}.xml`);
+ if (titleText) html += `${titleText}
\n`;
+ }
+ if (source) {
+ const sourceText = await ctx.fetchText(`Services/Text/${source}.xml`);
+ if (sourceText) html += `${sourceText}
`;
+ }
+ if (comment) {
+ const commentText = await ctx.fetchText(`Services/Text/${comment}.xml`);
+ if (commentText) html += `${commentText}
`;
+ }
+ break;
+ }
+
+ case 'SUBTITLE': {
+ flushGroup();
+ const value = str(n.value);
+ const comment = str(n.comment);
+ if (value) {
+ const subText = await ctx.fetchText(`Services/Text/${value}.xml`);
+ if (subText) html += `${subText}
\n`;
+ }
+ if (comment) {
+ const commentText = await ctx.fetchText(`Services/Text/${comment}.xml`);
+ if (commentText) html += `${commentText}
`;
+ }
+ break;
+ }
+
+ case 'GET': {
+ flushGroup();
+ const file = str(n.file);
+ if (!file) break;
+ const subNodes = await ctx.fetchServiceNodes(`Services/${file}.xml`);
+ if (subNodes) {
+ const subResult = await assembleService(subNodes, ctx, depth + 1);
+ if (n.null && !subResult.html.trim()) {
+ // Null check: skip if empty
+ } else {
+ html += subResult.html;
+ whoLast = subResult.whoLast;
+ }
+ }
+ break;
+ }
+
+ case 'CREATE': {
+ const what = str(n.what) ?? '';
+ const whoRaw = str(n.who);
+ const who = whoRaw !== undefined ? localizeWho(whoRaw, ctx) : '';
+ let text = '';
+ let header = '';
+ if (what) {
+ text = await ctx.fetchText(`Services/CommonPrayers/${what}.xml`) ?? '';
+ // Fallback: if fetchText returns empty, try to use the 'what' field as a key
+ if (!text.trim()) {
+ text = what; // fallback to the key itself
+ }
+ header = await ctx.fetchText(`Services/CommonPrayers/${what}.xml.header`) ?? '';
+ }
+ const commandName = str(n.command);
+ const commandBName = str(n.commandB);
+ const timesVal = num(n.times);
+ const [commandText, commandBText, timesText] = await Promise.all([
+ commandName ? ctx.fetchCommandText(commandName) : Promise.resolve(''),
+ commandBName ? ctx.fetchCommandText(commandBName) : Promise.resolve(''),
+ timesVal ? ctx.resolveTimes(timesVal) : Promise.resolve(''),
+ ]);
+ const opts = {
+ who,
+ commandText: commandText || undefined,
+ commandBText: commandBText || undefined,
+ timesText: timesText || undefined,
+ redFirst: bool(n.redFirst),
+ newLine: bool(n.newLine),
+ headerLevel: n.header ? 1 : 0,
+ };
+ const fmt = formatServiceContent(text, opts);
+ const headerHtml = opts.headerLevel && header
+ ? `${header}
`
+ : '';
+ if (who) {
+ if (who !== groupWho || headerHtml) flushGroup();
+ if (headerHtml) html += headerHtml + '\n';
+ groupWho = who;
+ groupTexts.push(fmt);
+ } else {
+ flushGroup();
+ const br = opts.newLine ? '
' : '';
+ html += `${headerHtml}${br}${fmt}
`;
+ }
+ if (whoRaw !== undefined) whoLast = who;
+ break;
+ }
+
+ case 'TEXT': {
+ const whoRaw = str(n.who);
+ const who = whoRaw !== undefined ? localizeWho(whoRaw, ctx) : '';
+ // Support both 'what' (programmatic nodes) and 'value' (converted JSON)
+ const textContent = str(n.what) ?? str(n.value) ?? '';
+ const opts = {
+ who,
+ redFirst: bool(n.redFirst),
+ newLine: bool(n.newLine),
+ headerLevel: n.header ? 1 : 0,
+ };
+ const fmt = formatServiceContent(textContent, opts);
+ if (who) {
+ if (who !== groupWho) flushGroup();
+ groupWho = who;
+ groupTexts.push(fmt);
+ } else {
+ flushGroup();
+ html += `${fmt}
`;
+ }
+ if (whoRaw !== undefined) whoLast = who;
+ break;
+ }
+
+ case 'GETID': {
+ const whoRaw = str(n.who);
+ const who = whoRaw !== undefined ? localizeWho(whoRaw, ctx) : '';
+ let text = '';
+ let header = '';
+ const id = str(n.id);
+ const what = str(n.what);
+ const nodeType = str(n.type) ?? 'M';
+ if (id && what) {
+ const lifeId = nodeType === 'T' ? `98${id}` : id;
+ const lives = await ctx.fetchLives(lifeId);
+ if (lives) {
+ const lastSlash = what.lastIndexOf('/');
+ const nodePath = what.substring(0, lastSlash);
+ const typeKey = what.substring(lastSlash + 1);
+ const group = lives[nodePath];
+ if (group) {
+ const entry = group[typeKey];
+ if (entry) {
+ text = String(entry.text ?? '');
+ header = String(entry.Header ?? '');
+ }
+ }
+ }
+ }
+ const commandName = str(n.command);
+ const commandBName = str(n.commandB);
+ const timesVal = num(n.times);
+ const [commandText, commandBText, timesText] = await Promise.all([
+ commandName ? ctx.fetchCommandText(commandName) : Promise.resolve(''),
+ commandBName ? ctx.fetchCommandText(commandBName) : Promise.resolve(''),
+ timesVal ? ctx.resolveTimes(timesVal) : Promise.resolve(''),
+ ]);
+ const opts = {
+ who,
+ commandText: commandText || undefined,
+ commandBText: commandBText || undefined,
+ timesText: timesText || undefined,
+ redFirst: bool(n.redFirst),
+ newLine: bool(n.newLine),
+ headerLevel: n.header ? 1 : 0,
+ };
+ const fmt = formatServiceContent(text, opts);
+ const headerHtml = opts.headerLevel && header
+ ? `${header}
`
+ : '';
+ if (who) {
+ if (who !== groupWho || headerHtml) flushGroup();
+ groupWho = who;
+ if (headerHtml) html += headerHtml + '\n';
+ groupTexts.push(fmt);
+ } else {
+ flushGroup();
+ const br = opts.newLine ? '
' : '';
+ html += `${headerHtml}${br}${fmt}
`;
+ }
+ if (whoRaw !== undefined) whoLast = who;
+ break;
+ }
+
+ case 'BIBLE': {
+ const whoRaw = str(n.who);
+ const who = whoRaw !== undefined ? localizeWho(whoRaw, ctx) : '';
+ const verseNewLine = !who;
+
+ // getreading: display a reading intro header (e.g. "Prophet Isaiah")
+ const getreading = str(n.getreading);
+ if (getreading && !str(n.verses)) {
+ flushGroup();
+ const bookName = getReadingName(getreading, ctx.lang);
+ const readingHeader = `${bookName}
`;
+ html += readingHeader;
+ if (whoRaw !== undefined) whoLast = who;
+ break;
+ }
+ const verses = str(n.verses) ?? '';
+ const lastUnderscore = verses.lastIndexOf('_');
+ const book = lastUnderscore > 0 ? verses.substring(0, lastUnderscore) : verses;
+ const passage = lastUnderscore > 0 ? verses.substring(lastUnderscore + 1) : '';
+
+ // Generate header from book+passage when header=true and no twostars
+ let header = '';
+ if (n.header && !str(n.twostars)) {
+ const bookName = getHeaderName(book, ctx.lang);
+ header = passage ? `${bookName} ${passage}` : bookName;
+ }
+
+ let text = await ctx.fetchBibleText(book, passage, false, verseNewLine);
+
+ // Handle twostars: extract **-delimited instructions as header
+ const twostars = str(n.twostars);
+ if (twostars === '1' || twostars === '2') {
+ const starIdx = text.indexOf('**');
+ if (starIdx !== -1) {
+ const lastStar = text.lastIndexOf('**');
+ if (starIdx !== lastStar) {
+ const instruction = text.substring(starIdx + 2, lastStar);
+ if (twostars === '1') {
+ header = instruction;
+ } else if (twostars === '2') {
+ // Extract quoted text between ** markers
+ const quoteStart = instruction.indexOf('"');
+ if (quoteStart !== -1) {
+ const quoteEnd = instruction.indexOf('"', quoteStart + 1);
+ if (quoteEnd !== -1) {
+ header = instruction.substring(quoteStart + 1, quoteEnd).replace(/\.\.\./g, '');
+ }
+ }
+ }
+ // Strip ** markers from text
+ text = text.substring(0, starIdx) + text.substring(lastStar + 2);
+ }
+ }
+ }
+ // Strip all remaining ** markers
+ text = text.replace(/\*\*/g, '');
+
+ const commandName = str(n.command);
+ const commandBName = str(n.commandB);
+ const timesVal = num(n.times);
+ const [commandText, commandBText, timesText] = await Promise.all([
+ commandName ? ctx.fetchCommandText(commandName) : Promise.resolve(''),
+ commandBName ? ctx.fetchCommandText(commandBName) : Promise.resolve(''),
+ timesVal ? ctx.resolveTimes(timesVal) : Promise.resolve(''),
+ ]);
+ const opts = {
+ who,
+ commandText: commandText || undefined,
+ commandBText: commandBText || undefined,
+ timesText: timesText || undefined,
+ redFirst: verseNewLine ? false : bool(n.redFirst),
+ newLine: bool(n.newLine),
+ headerLevel: n.header ? 1 : 0,
+ };
+ const fmt = formatServiceContent(text, opts);
+ const headerHtml = opts.headerLevel && header
+ ? `${header}
`
+ : '';
+ if (who) {
+ if (who !== groupWho || headerHtml) flushGroup();
+ groupWho = who;
+ if (headerHtml) html += headerHtml + '\n';
+ groupTexts.push(fmt);
+ } else {
+ flushGroup();
+ if (verseNewLine) {
+ const verses = fmt.split('
').filter(Boolean);
+ const linesHtml = verses.map(v => {
+ const redPart = extractFirstLetter(v);
+ return redPart ? `${redPart}${v.substring(redPart.length)}` : v;
+ }).join('
');
+ if (headerHtml) html += headerHtml + '\n';
+ html += `${linesHtml}
`;
+ } else {
+ const br = opts.newLine ? '
' : '';
+ html += `${headerHtml}${br}${fmt}
`;
+ }
+ }
+ if (whoRaw !== undefined) whoLast = who;
+ break;
+ }
+
+ case 'TIMES': {
+ flushGroup();
+ break;
+ }
+
+ default: {
+ flushGroup();
+ // Skip wrapper nodes that aren't real service elements
+ if (type === 'SERVICES' || type === 'TONE') break;
+ const children = n.children;
+ if (Array.isArray(children) && children.length > 0) {
+ const subHtml = await assembleService(children, ctx, depth + 1);
+ html += subHtml;
+ }
+ break;
+ }
+ }
+ }
+
+ flushGroup();
+ return { html, whoLast };
+}
+
+/**
+ * Format service text content: redFirst, command rubrics, commandB, times.
+ * Returns formatted HTML string (no wrapping in ).
+ */
+function formatServiceContent(text: string, opts: ImplementOptions): string {
+ let result = text;
+
+ if (opts.redFirst && result.length > 0) {
+ const redPart = extractFirstLetter(result);
+ if (redPart) {
+ result = `${redPart}${result.substring(redPart.length)}`;
+ }
+ }
+
+ if (opts.commandText) {
+ if (opts.timesText) {
+ result += ` (${opts.timesText} ${opts.commandText})`;
+ } else {
+ result += ` (${opts.commandText})`;
+ }
+ } else if (opts.timesText) {
+ result += ` (${opts.timesText})`;
+ }
+
+ if (opts.commandBText) {
+ result = `${opts.commandBText} ${result}`;
+ }
+
+ return result;
+}
+
+function extractFirstLetter(text: string): string {
+ const combiningMarks = /[\u0300-\u036f\u037a\u0384-\u0385\u0483-\u0489\u1fbd-\u1ffe\u2de0-\u2dff\ua66f-\ua67d]/;
+ let result = text[0] || '';
+ let i = 1;
+ while (i < text.length && combiningMarks.test(text[i])) {
+ result += text[i];
+ i++;
+ }
+ return result;
+}
diff --git a/src/core/service-rules.ts b/src/core/service-rules.ts
new file mode 100644
index 0000000..e4172df
--- /dev/null
+++ b/src/core/service-rules.ts
@@ -0,0 +1,53 @@
+/**
+ * Service Rules — determines which service template to use for a given day.
+ * Port of Ponomar/ServiceInfo.java.
+ */
+
+import { evalBool } from './evaluator';
+import type { EvalContext } from './types';
+
+export interface ServiceRuleEntry {
+ type: string;
+ troparion?: string;
+ pickT?: number;
+ kontakion?: string;
+ pickK?: number;
+ lentenk?: number;
+ [key: string]: string | number | undefined;
+}
+
+export interface ServiceRulesData {
+ periods: {
+ cmd: string;
+ rules: {
+ type: string;
+ attrs: Record;
+ }[];
+ }[];
+}
+
+/**
+ * Evaluate service rules for a given day.
+ * Returns the matching service configuration.
+ */
+export function evaluateServiceRules(
+ data: ServiceRulesData,
+ serviceType: string, // e.g., "VESPERS", "LITURGY", "MATINS"
+ ctx: EvalContext
+): ServiceRuleEntry | null {
+ let result: ServiceRuleEntry | null = null;
+
+ for (const period of data.periods) {
+ if (period.cmd && !evalBool(period.cmd, ctx)) {
+ continue;
+ }
+
+ for (const rule of period.rules) {
+ if (rule.type === serviceType) {
+ result = { type: rule.type, ...rule.attrs };
+ }
+ }
+ }
+
+ return result;
+}
diff --git a/src/core/types.ts b/src/core/types.ts
new file mode 100644
index 0000000..afc550e
--- /dev/null
+++ b/src/core/types.ts
@@ -0,0 +1,188 @@
+/**
+ * Shared type definitions for the Ponomar liturgical engine.
+ */
+
+/** All liturgical variables computed for a given day. */
+export interface DayInfo {
+ // Date components
+ dow: number; // Day of week (0=Sun, 1=Mon, ..., 6=Sat)
+ doy: number; // Day of year (0=Jan 1)
+ month: number; // Julian calendar month (1-12)
+ day: number; // Day of month
+ year: number; // Julian calendar year
+
+ // Paschal cycle
+ nday: number; // Days from this year's Pascha (- before, 0 = Pascha, + after)
+ ndayP: number; // Days from last year's Pascha
+ ndayF: number; // Days to next year's Pascha (negative before, 0 at next Pascha)
+ Easter: number; // JDN of this year's Pascha
+ Pentecost: number; // JDN of this year's Pentecost
+ LentStart: number; // JDN of Clean Monday
+
+ // Liturgical state
+ Tone: number; // Current Octoechos tone (1-8, -1 = none)
+ dRank: number; // Liturgical rank (0-8, higher = more important)
+ PFlag: number; // Paschal flag
+ PFlag1: number; // Service inclusion flag
+ PFlag2: number; // Service type (0=Normal, 1=Lenten, 2=Holy Week/Bright Week)
+ PS: number; // Priest status (0=Reader, 1=Priest)
+
+ // Seasonal flags
+ isLent: boolean;
+ isBrightWeek: boolean;
+ isApostlesFast: boolean;
+ isDormitionFast: boolean;
+ isNativityFast: boolean;
+
+ // Data file references
+ triodionFile: number | null; // Index in triodion (1-70)
+ pentecostarionFile: number | null; // Index in pentecostarion (1-315)
+}
+
+/** Expression evaluator variable store. */
+export type EvalContext = Record;
+
+/** A saint/feast commemoration entry from monthly day XML. */
+export interface Commemoration {
+ sIds: string[]; // Saint IDs (comma-separated in source)
+ cId: string; // Commemoration ID (points to lives/{cId}.xml)
+ src?: string; // Source: G=Greek, R=Russian, U=Unsourced
+ tone?: string; // Tone expression (evaluated at runtime)
+ cmd?: string; // Conditional expression
+}
+
+/** A day's worth of commemoration data. */
+export interface DayData {
+ commemorations: Commemoration[];
+}
+
+/** Saint life data (from lives/{cId}.xml). */
+export interface SaintData {
+ name?: {
+ nominative: string;
+ short?: string;
+ genitive?: string;
+ };
+ grammar?: Record;
+ rank?: number;
+ life?: {
+ text: string;
+ id?: string;
+ copyright?: string;
+ };
+ services?: Record;
+ troparion?: { tone: number; text: string }[];
+ kontakion?: { tone: number; text: string }[];
+ scriptures?: { type: string; reading: string }[];
+ icon?: string;
+}
+
+/** Service data within a saint's life file. */
+export interface ServiceData {
+ [path: string]: Record;
+}
+
+/** Fasting rule for a day. */
+export interface FastingInfo {
+ level: FastingLevel;
+ description: string;
+ meatFree: boolean;
+ fishPermitted: boolean;
+ caviarPermitted: boolean;
+ oilPermitted: boolean;
+ winePermitted: boolean;
+ strictFast: boolean;
+ noFood: boolean;
+}
+
+export enum FastingLevel {
+ NoFast = 0,
+ WineAndOil = 1,
+ Xerophagy = 2,
+ StrictWithOil = 3,
+ Strict = 4,
+ CompleteFast = 5,
+}
+
+/** Service template node — dynamic JSON from converted XML. */
+export interface ServiceNode {
+ type: string;
+ [key: string]: unknown;
+}
+
+/** Service rules for a given liturgical period. */
+export interface ServiceRule {
+ cmd: string;
+ services: Record;
+}
+
+export interface ServiceTypeConfig {
+ type: string;
+ troparion?: string;
+ pickT?: number;
+ kontakion?: string;
+ pickK?: number;
+ lentenk?: number;
+}
+
+/** Bible translation metadata. */
+export interface BibleVersion {
+ id: string;
+ name: string;
+ language: string;
+ books: BibleBook[];
+ info: BibleInfo;
+}
+
+export interface BibleBook {
+ id: string;
+ name: string;
+ chapters: number;
+ short: string;
+ intro?: string;
+}
+
+export interface BibleInfo {
+ verseNumFormat: string;
+ verseNoNumFormat: string;
+ verseLink: string;
+ abbrevFormat: string;
+ chapterN: string;
+ headerFormat: string;
+ chapterNI: string;
+ verseNo: string[];
+ chapterNo: string[];
+ cvSep: string;
+ duration: string;
+ selectionSeparator: string;
+ bcSep: string;
+ fontFace: string;
+ fontSize: number;
+ parts: string;
+ orient: string;
+}
+
+/** Language info. */
+export type LanguageCode = 'en' | 'ru' | 'cu';
+
+export interface LanguageInfo {
+ code: LanguageCode;
+ name: string;
+ nameLocal: string;
+ dir: 'ltr' | 'rtl';
+ fontFace: string;
+ liturgicalFont: string;
+ liturgicalSize: number;
+}
+
+/** Fasting rule from Fasting.xml. */
+export interface FastingRule {
+ case: string; // 7-bit binary string
+ cmd: string; // boolean expression
+}
+
+/** Fasting period from Fasting.xml. */
+export interface FastingPeriod {
+ cmd: string;
+ rules: FastingRule[];
+}
diff --git a/src/main.ts b/src/main.ts
new file mode 100644
index 0000000..0820842
--- /dev/null
+++ b/src/main.ts
@@ -0,0 +1,5 @@
+import './styles/main.css';
+import { App } from './ui/app';
+
+const app = new App(document.getElementById('app')!);
+app.init();
diff --git a/src/styles/main.css b/src/styles/main.css
new file mode 100644
index 0000000..cc2b826
--- /dev/null
+++ b/src/styles/main.css
@@ -0,0 +1,149 @@
+@import "tailwindcss";
+
+@theme {
+ --color-parchment: #f5f0e8;
+ --color-parchment-dark: #e8dfd0;
+ --color-navy: #1a1a2e;
+ --color-navy-light: #2a2a4e;
+ --color-gold: #c5a55a;
+ --color-gold-light: #d4b96e;
+ --color-red: #8b0000;
+ --color-red-light: #a52a2a;
+ --color-purple: #4a0e4e;
+ --color-green: #2d5a27;
+ --color-blue: #1a3a6e;
+}
+
+@font-face {
+ font-family: 'Ponomar';
+ src: url('/fonts/Ponomar-Regular.woff') format('woff');
+ font-weight: normal;
+ font-style: normal;
+ font-display: swap;
+}
+@font-face {
+ font-family: 'Fedorovsk';
+ src: url('/fonts/Fedorovsk-Regular.woff') format('woff');
+ font-weight: normal;
+ font-style: normal;
+ font-display: swap;
+}
+@font-face {
+ font-family: 'Indiction';
+ src: url('/fonts/Indiction-Regular.woff') format('woff');
+ font-weight: normal;
+ font-style: normal;
+ font-display: swap;
+}
+@font-face {
+ font-family: 'Menaion';
+ src: url('/fonts/Menaion-Regular.woff') format('woff');
+ font-weight: normal;
+ font-style: normal;
+ font-display: swap;
+}
+@font-face {
+ font-family: 'Monomakh';
+ src: url('/fonts/Monomakh-Regular.woff') format('woff');
+ font-weight: normal;
+ font-style: normal;
+ font-display: swap;
+}
+@font-face {
+ font-family: 'Triodion';
+ src: url('/fonts/Triodion-Regular.woff') format('woff');
+ font-weight: normal;
+ font-style: normal;
+ font-display: swap;
+}
+@font-face {
+ font-family: 'Vilnius';
+ src: url('/fonts/Vilnius-Regular.woff') format('woff');
+ font-weight: normal;
+ font-style: normal;
+ font-display: swap;
+}
+@font-face {
+ font-family: 'Voskresensky';
+ src: url('/fonts/Voskresensky-Regular.woff') format('woff');
+ font-weight: normal;
+ font-style: normal;
+ font-display: swap;
+}
+
+html, body {
+ margin: 0;
+ padding: 0;
+ background-color: var(--color-parchment);
+ color: var(--color-navy);
+ font-family: 'Noto Sans', 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
+ -webkit-font-smoothing: antialiased;
+ overscroll-behavior: none;
+}
+
+.font-slavonic {
+ font-family: 'Ponomar', 'Noto Serif', Georgia, serif;
+}
+.font-ponomar { font-family: 'Ponomar', 'Noto Serif', Georgia, serif; }
+.font-fedrovsk { font-family: 'Fedorovsk', 'Noto Serif', Georgia, serif; }
+.font-indiction { font-family: 'Indiction', 'Noto Serif', Georgia, serif; }
+.font-menaion { font-family: 'Menaion', 'Noto Serif', Georgia, serif; }
+.font-monomakh { font-family: 'Monomakh', 'Noto Serif', Georgia, serif; }
+.font-triodion { font-family: 'Triodion', 'Noto Serif', Georgia, serif; }
+.font-vilnius { font-family: 'Vilnius', 'Noto Serif', Georgia, serif; }
+.font-voskresensky { font-family: 'Voskresensky', 'Noto Serif', Georgia, serif; }
+
+.font-system-slavonic {
+ font-family: 'Noto Serif', Georgia, serif;
+}
+
+.rank-icon {
+ font-family: 'Ponomar', serif;
+ font-size: 130%;
+}
+
+.liturgical-text {
+ font-size: var(--liturgical-font-size, 18px);
+ line-height: 1.6;
+}
+.liturgical-text > :first-child {
+ margin-top: 0;
+}
+
+.speaker-label {
+ color: var(--color-red);
+ font-weight: 700;
+}
+
+.rubric {
+ color: var(--color-red);
+ font-style: italic;
+}
+
+.drop-cap::first-letter {
+ color: var(--color-red);
+ font-weight: 700;
+ font-size: 1.5em;
+ float: left;
+ line-height: 1;
+ margin-right: 0.1em;
+}
+
+/* Scrollbar styling */
+::-webkit-scrollbar {
+ width: 6px;
+}
+::-webkit-scrollbar-track {
+ background: transparent;
+}
+::-webkit-scrollbar-thumb {
+ background: var(--color-gold);
+ border-radius: 3px;
+}
+
+/* PWA safe area insets */
+@supports (padding: env(safe-area-inset-bottom)) {
+ .nav-bar {
+ padding-bottom: calc(0.5rem + env(safe-area-inset-bottom));
+ }
+}
diff --git a/src/ui/app.ts b/src/ui/app.ts
new file mode 100644
index 0000000..dff0742
--- /dev/null
+++ b/src/ui/app.ts
@@ -0,0 +1,142 @@
+/**
+ * Main application shell — SPA router and layout.
+ */
+
+import { CalendarView } from './calendar-view';
+import { BibleView } from './bible-view';
+import { SettingsView } from './settings-view';
+import { JDate } from '../core/jdate';
+import { loadSettings, type Settings } from './settings-view';
+import { getTranslations, type LanguageCode } from '../core/i18n';
+
+type View = 'calendar' | 'bible' | 'settings';
+
+export class App {
+ private container: HTMLElement;
+ private currentView: View = 'calendar';
+ private currentDate: JDate;
+ private settings: Settings;
+
+ constructor(container: HTMLElement) {
+ this.container = container;
+ this.currentDate = JDate.today();
+ this.settings = loadSettings();
+ document.documentElement.style.setProperty('--liturgical-font-size', this.settings.fontSize + 'px');
+ }
+
+ init() {
+ this.container.innerHTML = '';
+ this.render();
+ this.handleRoute();
+ window.addEventListener('hashchange', () => this.handleRoute());
+
+ // Register service worker for PWA install
+ if ('serviceWorker' in navigator) {
+ navigator.serviceWorker.register('/sw.js').catch(() => {});
+ }
+ window.addEventListener('beforeinstallprompt', (e) => {
+ e.preventDefault();
+ (window as any).__deferredPrompt = e;
+ });
+ }
+
+ private handleRoute() {
+ const hash = window.location.hash.slice(1) || 'calendar';
+ const parts = hash.split('/');
+ this.currentView = parts[0] as View || 'calendar';
+ this.renderView();
+ }
+
+ private resolveBibleVersion(shortId: string, lang: string): string {
+ // Map short version IDs like "kjv" to full paths like "en/bible/kjv"
+ const langMap: Record = {
+ en: 'en/bible/kjv',
+ ru: 'ru/bible/synod',
+ cu: 'cu/bible/elis',
+ };
+ if (shortId === 'kjv' || shortId === 'brenton' || shortId === 'nonkjv') return `en/bible/${shortId}`;
+ if (shortId === 'synod' || shortId === 'kassian' || shortId === 'yungerov') return `ru/bible/${shortId}`;
+ if (shortId === 'elis') return 'cu/bible/elis';
+ if (shortId === 'ls') return 'fr/bible/ls';
+ if (shortId === 'spt') return 'el/bible/spt';
+ if (shortId === 'vulgate') return 'la/bible/vulgate';
+ if (shortId === 'cuv') return 'zh/Hans/bible/cuv';
+ if (shortId === 'cuv-hant') return 'zh/Hant/bible/cuv';
+ if (shortId === 'svd') return 'ar/bible/svd';
+ return langMap[lang] || 'en/bible/kjv';
+ }
+
+ private render() {
+ const t = getTranslations(this.settings.language as LanguageCode);
+ this.container.innerHTML = `
+
+ `;
+ }
+
+ private renderView() {
+ const viewContainer = document.getElementById('view-container')!;
+ viewContainer.innerHTML = '';
+
+ // Update active nav
+ document.querySelectorAll('.nav-link').forEach(el => {
+ const v = el.getAttribute('data-view');
+ el.classList.toggle('text-gold', v === this.currentView);
+ el.classList.toggle('font-bold', v === this.currentView);
+ });
+
+ // Re-read settings fresh on each navigation
+ this.settings = loadSettings();
+ const lang = this.settings.language as LanguageCode;
+ const calendarType = this.settings.calendarType || 'julian';
+
+ switch (this.currentView) {
+ case 'calendar':
+ new CalendarView(viewContainer, this.currentDate, (date) => {
+ this.currentDate = date;
+ }, lang, calendarType).render();
+ break;
+ case 'bible': {
+ // Parse route params: #bible/{lang~short}/{book}/{passage}
+ const hash = window.location.hash.slice(1);
+ const bp = hash.split('/');
+ const versionSpec = bp[1] || '';
+ const bibleBook = bp[2] || '';
+ const biblePassage = bp[3] || '';
+ let bibleVersion: string;
+ if (!versionSpec) {
+ bibleVersion = this.resolveBibleVersion(this.settings.defaultBibleVersion, lang);
+ } else if (versionSpec.includes('~')) {
+ // Format: lang~short e.g. "en~kjv" → "en/bible/kjv"
+ const [vl, vs] = versionSpec.split('~');
+ bibleVersion = `${vl}/bible/${vs}`;
+ } else if (versionSpec.includes('/')) {
+ bibleVersion = versionSpec; // legacy full path
+ } else {
+ bibleVersion = this.resolveBibleVersion(versionSpec, lang);
+ }
+ new BibleView(viewContainer, lang, bibleVersion, bibleBook, biblePassage).render();
+ break;
+ }
+ case 'settings':
+ new SettingsView(viewContainer, () => {
+ this.render();
+ this.renderView();
+ }).render();
+ break;
+ }
+ }
+
+ go(view: View) {
+ window.location.hash = view;
+ }
+}
diff --git a/src/ui/bible-view.ts b/src/ui/bible-view.ts
new file mode 100644
index 0000000..e027467
--- /dev/null
+++ b/src/ui/bible-view.ts
@@ -0,0 +1,285 @@
+/**
+ * Bible view — reader with all translation variants.
+ */
+
+import {
+ parsePassage,
+ parseTextFile,
+ extractPassage,
+ formatPassageHTML,
+} from '../core/bible-reader';
+import { getTranslations, type LanguageCode } from '../core/i18n';
+
+const SETTINGS_KEY = 'ponomar-settings';
+const BIBLE_STATE_KEY = 'ponomar-bible-state';
+
+function loadBibleState(version: string): { book: string; passage: string } | null {
+ try {
+ const stored = localStorage.getItem(BIBLE_STATE_KEY);
+ if (stored) {
+ const parsed = JSON.parse(stored);
+ const pos = parsed.positions?.[version];
+ if (pos?.book && pos?.passage) return pos;
+ }
+ } catch { /* ignore */ }
+ return null;
+}
+
+function saveBibleState(version: string, book: string, passage: string) {
+ try {
+ const stored = localStorage.getItem(BIBLE_STATE_KEY);
+ const state: { positions: Record } = stored ? JSON.parse(stored) : { positions: {} };
+ state.positions[version] = { book, passage };
+ localStorage.setItem(BIBLE_STATE_KEY, JSON.stringify(state));
+ } catch { /* ignore */ }
+}
+
+function loadSettings(): { cuFont: string; showVerseNumbers: boolean; verseNewLine: boolean } {
+ try {
+ const stored = localStorage.getItem(SETTINGS_KEY);
+ const defaults = { cuFont: 'Ponomar', showVerseNumbers: true, verseNewLine: false };
+ if (stored) {
+ const parsed = JSON.parse(stored);
+ if (parsed.usePonomarFont !== undefined) {
+ parsed.cuFont = parsed.usePonomarFont ? 'Ponomar' : '';
+ delete parsed.usePonomarFont;
+ }
+ return { ...defaults, ...parsed };
+ }
+ } catch { /* ignore */ }
+ return { cuFont: 'Ponomar', showVerseNumbers: true, verseNewLine: false };
+}
+
+const FONT_CSS_MAP: Record = {
+ Ponomar: 'font-ponomar',
+ Fedorovsk: 'font-fedrovsk',
+ Indiction: 'font-indiction',
+ Menaion: 'font-menaion',
+ Monomakh: 'font-monomakh',
+ Triodion: 'font-triodion',
+ Vilnius: 'font-vilnius',
+ Voskresensky: 'font-voskresensky',
+};
+
+function fontClass(fontName: string): string {
+ return FONT_CSS_MAP[fontName] || (fontName ? '' : 'font-system-slavonic');
+}
+
+interface BibleVersion {
+ id: string;
+ name: string;
+ language: string;
+ books: { id: string; name: string; short: string; chapters: number }[];
+}
+
+export class BibleView {
+ private container: HTMLElement;
+ private currentVersion: string;
+ private currentBook: string;
+ private currentPassage: string;
+ private versions: BibleVersion[] = [];
+ private loadedText: Map = new Map();
+ private t: ReturnType;
+
+ constructor(container: HTMLElement, language: LanguageCode = 'en', version = '', book = '', passage = '') {
+ this.container = container;
+ this.t = getTranslations(language);
+ this.currentVersion = version || 'en/bible/kjv';
+
+ if (!book && !passage) {
+ const saved = loadBibleState(this.currentVersion);
+ if (saved) { book = saved.book; passage = saved.passage; }
+ }
+
+ this.currentBook = book || 'Gen';
+ this.currentPassage = passage || '1';
+ this.loadVersions();
+ }
+
+ private async loadVersions() {
+ try {
+ const resp = await fetch('/data/bible/versions.json');
+ this.versions = await resp.json();
+ } catch {
+ this.versions = [
+ { id: 'en/bible/kjv', name: 'English (KJV)', language: 'en', books: [] },
+ { id: 'ru/bible/synod', name: 'Русский (Синодальный)', language: 'ru', books: [] },
+ { id: 'cu/bible/elis', name: 'Церковнославянская', language: 'cu', books: [] },
+ ];
+ }
+ }
+
+ async render() {
+ await this.loadVersions();
+
+ this.container.innerHTML = `
+
+
+
+
+
+
+
+
+
+
+
+
+
${this.t.bible.selectBook}
+
+
+ `;
+
+ this.updateChapterSelect();
+ this.loadReading();
+
+ document.getElementById('version-select')?.addEventListener('change', (e) => {
+ this.currentVersion = (e.target as HTMLSelectElement).value;
+ const books = this.getBooksForVersion();
+ const sameBook = books.find(b => b.id === this.currentBook);
+ if (sameBook) {
+ const ch = parseInt(this.currentPassage, 10);
+ if (ch > sameBook.chapters) this.currentPassage = '1';
+ } else {
+ this.currentBook = books[0]?.id || 'Gen';
+ this.currentPassage = '1';
+ }
+ this.updateBookSelect();
+ this.loadReading();
+ });
+
+ document.getElementById('book-select')?.addEventListener('change', (e) => {
+ this.currentBook = (e.target as HTMLSelectElement).value;
+ this.currentPassage = '1';
+ this.updateChapterSelect();
+ this.loadReading();
+ });
+
+ document.getElementById('chapter-select')?.addEventListener('change', (e) => {
+ this.currentPassage = (e.target as HTMLSelectElement).value;
+ this.loadReading();
+ });
+
+ document.getElementById('prev-chapter')?.addEventListener('click', () => {
+ const ch = parseInt(this.currentPassage, 10);
+ if (ch > 1) {
+ this.currentPassage = String(ch - 1);
+ this.updateChapterSelect();
+ this.loadReading();
+ } else {
+ const books = this.getBooksForVersion();
+ const idx = books.findIndex(b => b.id === this.currentBook);
+ if (idx > 0) {
+ const prevBook = books[idx - 1];
+ this.currentBook = prevBook.id;
+ this.currentPassage = String(prevBook.chapters);
+ this.updateBookSelect();
+ this.loadReading();
+ }
+ }
+ });
+
+ document.getElementById('next-chapter')?.addEventListener('click', () => {
+ const ch = parseInt(this.currentPassage, 10);
+ const books = this.getBooksForVersion();
+ const book = books.find(b => b.id === this.currentBook);
+ const maxCh = book?.chapters ?? 50;
+ if (ch < maxCh) {
+ this.currentPassage = String(ch + 1);
+ this.updateChapterSelect();
+ this.loadReading();
+ } else {
+ const idx = books.findIndex(b => b.id === this.currentBook);
+ if (idx < books.length - 1) {
+ const nextBook = books[idx + 1];
+ this.currentBook = nextBook.id;
+ this.currentPassage = '1';
+ this.updateBookSelect();
+ this.loadReading();
+ }
+ }
+ });
+ }
+
+ private getBooksForVersion(): { id: string; name: string; chapters: number }[] {
+ const version = this.versions.find(v => v.id === this.currentVersion);
+ return version?.books ?? [];
+ }
+
+ private updateBookSelect() {
+ const select = document.getElementById('book-select') as HTMLSelectElement;
+ if (!select) return;
+ const books = this.getBooksForVersion();
+ select.innerHTML = books.map(b =>
+ ``
+ ).join('');
+ this.updateChapterSelect();
+ }
+
+ private updateChapterSelect() {
+ const select = document.getElementById('chapter-select') as HTMLSelectElement;
+ if (!select) return;
+ const books = this.getBooksForVersion();
+ const book = books.find(b => b.id === this.currentBook);
+ const chapters = book?.chapters ?? 50;
+ select.innerHTML = Array.from({ length: chapters }, (_, i) =>
+ ``
+ ).join('');
+ // Sync passage to selected chapter
+ this.currentPassage = select.value;
+ }
+
+ private async loadReading() {
+ const textEl = document.getElementById('bible-text');
+ if (!textEl) return;
+
+ textEl.innerHTML = `${this.t.loading}
`;
+
+ try {
+ const cacheKey = `${this.currentVersion}/${this.currentBook}`;
+ let text = this.loadedText.get(cacheKey);
+
+ if (!text) {
+ // Version IDs are full paths like "en/bible/kjv"
+ const resp = await fetch(`/data/${this.currentVersion}/${this.currentBook}.text`);
+ if (!resp.ok) throw new Error('Not found');
+ text = await resp.text();
+ this.loadedText.set(cacheKey, text);
+ }
+
+ const chapters = parseTextFile(text!);
+ const reading = parsePassage(this.currentPassage);
+ const passages = extractPassage(chapters, reading);
+ const settings = loadSettings();
+ const html = formatPassageHTML(passages, { showVerseNumbers: settings.showVerseNumbers, verseNewLine: settings.verseNewLine });
+
+ // Apply Ponomar font for Church Slavonic texts
+ const version = this.versions.find(v => v.id === this.currentVersion);
+ const isChurchSlavonic = version?.language === 'cu';
+ const fnClass = isChurchSlavonic ? fontClass(settings.cuFont) : '';
+
+ textEl.className = `flex-1 overflow-auto p-2 max-w-3xl mx-auto liturgical-text ${fnClass}`;
+ textEl.innerHTML = html || `${this.t.bible.noTextFound}
`;
+ } catch {
+ textEl.innerHTML = `
+
+
${this.t.bible.notAvailableOffline}
+
+ ${this.t.bible.ensureDataConverted}
+
+
+ `;
+ }
+
+ saveBibleState(this.currentVersion, this.currentBook, this.currentPassage);
+ }
+}
\ No newline at end of file
diff --git a/src/ui/calendar-view.ts b/src/ui/calendar-view.ts
new file mode 100644
index 0000000..cdde05e
--- /dev/null
+++ b/src/ui/calendar-view.ts
@@ -0,0 +1,918 @@
+/**
+ * Calendar view — month grid + day detail panel with actual data loading.
+ */
+
+import { JDate } from '../core/jdate';
+import { Paschalion } from '../core/paschalion';
+import { computeDay } from '../core/day-computer';
+import { evaluateFasting } from '../core/fasting';
+import type { FastingPeriod } from '../core/fasting';
+import { getTranslations, type LanguageCode } from '../core/i18n';
+import { fontClass } from './settings-view';
+import { ServiceView } from './service-view';
+
+let cachedFastingPeriods: FastingPeriod[] | null = null;
+const lifeCache = new Map();
+const menaionBundles = new Map>();
+let sharedScriptureIndex: Record | null = null;
+let bookAbbrevMap: Record | null = null;
+let bookCVSep = ':';
+let bookAbbrevLang = '';
+
+function resolveDefaultBibleVersion(): string {
+ try {
+ const stored = localStorage.getItem('ponomar-settings');
+ if (stored) {
+ const s = JSON.parse(stored);
+ return s.defaultBibleVersion || 'kjv';
+ }
+ } catch {}
+ return 'kjv';
+}
+
+function shortToLang(shortId: string): string {
+ if (shortId === 'kjv' || shortId === 'brenton' || shortId === 'nonkjv') return 'en';
+ if (shortId === 'synod' || shortId === 'kassian' || shortId === 'yungerov') return 'ru';
+ if (shortId === 'elis') return 'cu';
+ if (shortId === 'ls') return 'fr';
+ if (shortId === 'spt') return 'el';
+ if (shortId === 'vulgate') return 'la';
+ if (shortId === 'cuv' || shortId === 'cuv-hant') return 'zh';
+ if (shortId === 'svd') return 'ar';
+ return 'en';
+}
+
+async function loadBookAbbrevs(lang: string): Promise {
+ if (bookAbbrevLang === lang && bookAbbrevMap) return;
+ bookAbbrevLang = lang;
+ bookAbbrevMap = null;
+ try {
+ const resp = await fetch('/data/bible/versions.json');
+ if (!resp.ok) return;
+ const ct = resp.headers.get('content-type');
+ if (ct && ct.includes('text/html')) return;
+ const versions = await resp.json() as any[];
+ const ver = versions.find((v: any) => v.id.startsWith(lang + '/')) || versions[0];
+ if (ver) {
+ const map: Record = {};
+ if (ver.books) {
+ for (const book of ver.books) {
+ map[book.id] = book.short || book.id;
+ }
+ }
+ bookAbbrevMap = map;
+ if (ver.formatting?.CVSep) {
+ bookCVSep = ver.formatting.CVSep;
+ }
+ }
+ } catch {}
+}
+
+function formatReadingRef(reading: string, _lang: string): string {
+ const idx = reading.indexOf('_');
+ if (idx === -1) return reading.replace(/_/g, ' ');
+ const bookId = reading.substring(0, idx);
+ const passage = reading.substring(idx + 1);
+ const lookupId = bookId.replace(/ /g, '_');
+ const abbrev = bookAbbrevMap?.[lookupId] || bookId;
+ const formatted = passage.replace(/:/g, bookCVSep === ',' ? ',' : bookCVSep === ':' ? ':' : ':');
+ const display = `${abbrev} ${formatted}`;
+ const chMatch = passage.match(/^(\d+)/);
+ const chapter = chMatch ? chMatch[1] : '1';
+ const defaultShort = resolveDefaultBibleVersion();
+ const langPrefix = shortToLang(defaultShort);
+ return `${display}`;
+}
+
+async function loadSharedScriptureIndex(): Promise | null> {
+ if (sharedScriptureIndex) return sharedScriptureIndex;
+ try {
+ const resp = await fetch('/data/shared/lives-index.json');
+ if (!resp.ok) return null;
+ const ct = resp.headers.get('content-type');
+ if (ct && ct.includes('text/html')) return null;
+ sharedScriptureIndex = await resp.json();
+ return sharedScriptureIndex;
+ } catch {
+ return null;
+ }
+}
+
+async function loadMenaionBundle(lang: string): Promise | null> {
+ const key = lang === 'cu' ? 'cu' : lang;
+ if (menaionBundles.has(key)) return menaionBundles.get(key)!;
+ try {
+ const resp = await fetch(`/data/${key}/menaion-bundle.json`);
+ if (!resp.ok) return null;
+ const ct = resp.headers.get('content-type');
+ if (ct && ct.includes('text/html')) return null;
+ const bundle = await resp.json() as Record;
+ menaionBundles.set(key, bundle);
+ return bundle;
+ } catch {
+ return null;
+ }
+}
+
+async function loadFastingPeriods(): Promise {
+ if (cachedFastingPeriods) return cachedFastingPeriods;
+ try {
+ const resp = await fetch('/data/shared/fasting.json');
+ if (!resp.ok) return [];
+ cachedFastingPeriods = await resp.json();
+ return cachedFastingPeriods!;
+ } catch {
+ return [];
+ }
+}
+
+interface LifeData {
+ name?: {
+ nominative?: string;
+ short?: string;
+ '@_Nominative'?: string;
+ '@_Short'?: string;
+ };
+ life?: {
+ text: string;
+ };
+ scripture?: {
+ type?: string;
+ Type?: string;
+ reading?: string;
+ Reading?: string;
+ pericope?: string;
+ Pericope?: string;
+ cmd?: string;
+ }[];
+ services?: Record;
+ [key: string]: unknown;
+}
+
+interface CommemorationData {
+ NAME?: { Long?: string; ShortN?: string; ShortF?: string } | { text: string };
+ LIFE?: { text: string };
+ CHURCH?: { Rank?: string };
+ SCRIPTURE?: { Type?: string; Reading?: string; Pericope?: string; Cmd?: string } | { Type?: string; Reading?: string; Pericope?: string; Cmd?: string }[];
+ SERVICE?: Record;
+ TROPARION?: { Tone?: string; text?: string } | { Tone?: string; text?: string }[];
+ [key: string]: unknown;
+}
+
+interface ReadingItem {
+ ref: string;
+ purpose: string;
+}
+
+interface ReadingsGroup {
+ service: string;
+ items: ReadingItem[];
+}
+
+/** Order of services for display (Vespers → Hours → Matins → Liturgy sub-types) */
+const SERVICE_ORDER = ['Вечерня', '1-й час', '3-й час', '6-й час', '9-й час', 'Утреня', 'Апостол', 'Евангелие'];
+
+/** Map Type/service name to display service label */
+function readingsTypeToService(type: string, t: ReturnType): string {
+ switch (type) {
+ case 'apostol': return t.calendar.readingTypes.apostol;
+ case 'gospel': return t.calendar.readingTypes.gospel;
+ case 'matins': return t.calendar.readingTypes.matins;
+ case 'vespers': return t.calendar.readingTypes.vespers;
+ case 'primes': return t.calendar.readingTypes.primes;
+ case 'terce': return t.calendar.readingTypes.terce;
+ case 'sext': return t.calendar.readingTypes.sext;
+ case 'none': return t.calendar.readingTypes.none;
+ case 'liturgy': return t.calendar.readingTypes.liturgy;
+ case 'A': return t.calendar.readings;
+ default:
+ if (/^[0-9]+$/.test(type)) {
+ const n = parseInt(type, 10);
+ if (n <= 3 || n === 0) return t.calendar.readingTypes.vespers;
+ return t.calendar.readingTypes.matins;
+ }
+ return t.calendar.readingTypes[type] || t.calendar.readings;
+ }
+}
+
+function svcNameToService(svcName: string, scType: string, t: ReturnType): string {
+ const upper = svcName.toUpperCase();
+ if (upper === 'LITURGY') {
+ return readingsTypeToService(scType, t);
+ }
+ return readingsTypeToService(upper.toLowerCase(), t);
+}
+
+function findOrCreateGroup(groups: ReadingsGroup[], service: string): ReadingsGroup {
+ let group = groups.find(g => g.service === service);
+ if (!group) {
+ group = { service, items: [] };
+ groups.push(group);
+ }
+ return group;
+}
+
+/** Map of menaion CId to shared commemoration ID (for feast days with special commemorations) */
+const CID_TO_SHARED_COMM: Record = {
+ '010101': '134', // Circumcision
+ '010601': '35', // Theophany
+ '011901': '373', // Theophany (Julian)
+ '020201': '373', // Meeting of the Lord
+ '032501': '543', // Annunciation
+ '080601': '543', // Transfiguration
+ '081501': '373', // Dormition
+ '091401': 'F0', // Elevation of Cross
+ '092101': 'F6', // Nativity of Theotokos
+ '010701': '373', // Synaxis of John the Baptist
+ '070701': '373', // Nativity of John the Baptist
+ '082901': '373', // Beheading of John the Baptist
+};
+
+async function loadLife(lang: LanguageCode, cid: string): Promise {
+ const cacheKey = `${lang}/${cid}`;
+ if (lifeCache.has(cacheKey)) return lifeCache.get(cacheKey)!;
+ try {
+ const bundleName = /^(0[1-9]|1[0-2])\d+/.test(cid) ? cid.substring(0, 2) : 'misc';
+ async function tryFetch(prefix: string): Promise {
+ const resp = await fetch(`/data/${prefix}/lives/${bundleName}.json`);
+ if (!resp.ok) return null;
+ const ct = resp.headers.get('content-type');
+ if (ct && ct.includes('text/html')) return null;
+ return resp;
+ }
+ let resp = await tryFetch(lang);
+ if (!resp && lang !== 'cu') {
+ resp = await tryFetch('cu');
+ }
+ if (!resp) {
+ lifeCache.set(cacheKey, null);
+ return null;
+ }
+ const bundle = await resp.json() as Record;
+ const life = (bundle[cid] || null) as LifeData | null;
+ if (!life) {
+ lifeCache.set(cacheKey, null);
+ return null;
+ }
+
+ // Merge rank from cu life if language-specific life lacks it
+ if (!life.rank && lang !== 'cu') {
+ try {
+ const cuResp = await fetch(`/data/cu/lives/${bundleName}.json`);
+ if (cuResp.ok) {
+ const ct = cuResp.headers.get('content-type');
+ if (!ct || !ct.includes('text/html')) {
+ const cuBundle = await cuResp.json() as Record;
+ const cuLife = cuBundle[cid] as LifeData | undefined;
+ if (cuLife?.rank) life.rank = cuLife.rank;
+ }
+ }
+ } catch {}
+ }
+
+ // Merge scripture from shared lives index (one HTTP request for all)
+ const index = await loadSharedScriptureIndex();
+ if (index && index[cid]) {
+ const raw = index[cid];
+ const sharedArr = (Array.isArray(raw) ? raw : [raw]) as LifeData['scripture'];
+ if (sharedArr && sharedArr.length > 0) {
+ if (life.scripture) {
+ const existing = Array.isArray(life.scripture) ? life.scripture : [life.scripture];
+ life.scripture = [...existing, ...sharedArr] as LifeData['scripture'];
+ } else {
+ life.scripture = sharedArr;
+ }
+ }
+ }
+
+ lifeCache.set(cacheKey, life);
+ return life;
+ } catch {
+ lifeCache.set(cacheKey, null);
+ return null;
+ }
+}
+
+async function loadCommemoration(sharedId: string): Promise {
+ try {
+ const resp = await fetch(`/data/shared/commemorations/${sharedId}.json`);
+ if (!resp.ok) return null;
+ const ct = resp.headers.get('content-type');
+ if (ct && ct.includes('text/html')) return null;
+ return await resp.json();
+ } catch {
+ return null;
+ }
+}
+
+function getPeriodName(life: LifeData | undefined): string {
+ if (!life?.name) return '';
+ return life.name['@_Nominative'] || life.name.nominative || life.name['@_Short'] || life.name.short || '';
+}
+
+function getShortFeastName(life: LifeData | undefined, shared: CommemorationData | null | undefined): string {
+ if (life?.name?.['@_Short']) return life.name['@_Short'];
+ if (life?.name?.short) return life.name.short;
+ return getCommsName(shared);
+}
+
+function getCommsName(shared: CommemorationData | null | undefined): string {
+ if (!shared?.NAME) return '';
+ if (typeof shared.NAME === 'object' && 'Long' in shared.NAME) {
+ return shared.NAME.Long || shared.NAME.ShortN || '';
+ }
+ if (typeof shared.NAME === 'object' && 'text' in shared.NAME) {
+ return (shared.NAME as { text: string }).text || '';
+ }
+ return '';
+}
+
+async function loadMenaion(lang: LanguageCode, month: number, day: number): Promise {
+ const mm = String(month).padStart(2, '0');
+ const dd = String(day).padStart(2, '0');
+ const dateKey = `${mm}-${dd}`;
+ try {
+ // Try lang-specific bundle first
+ let bundle = await loadMenaionBundle(lang);
+ if (!bundle && lang !== 'cu') {
+ bundle = await loadMenaionBundle('cu');
+ }
+ if (bundle && bundle[dateKey]) {
+ const arr = bundle[dateKey];
+ return Array.isArray(arr) ? arr : [];
+ }
+ return [];
+ } catch {
+ return [];
+ }
+}
+
+async function loadTriodionFile(index: number): Promise {
+ try {
+ const resp = await fetch(`/data/shared/triodion/${String(index).padStart(2, '0')}.json`);
+ if (!resp.ok) return [];
+ return await resp.json();
+ } catch {
+ return [];
+ }
+}
+
+async function loadPentecostarionFile(index: number): Promise {
+ try {
+ const resp = await fetch(`/data/shared/pentecostarion/${String(index).padStart(2, '0')}.json`);
+ if (!resp.ok) return [];
+ return await resp.json();
+ } catch {
+ return [];
+ }
+}
+
+export class CalendarView {
+ private container: HTMLElement;
+ private currentDate: JDate;
+ private onDateChange: (date: JDate) => void;
+ private skipCids: Set = new Set();
+ private language: LanguageCode;
+ private t: ReturnType;
+ private calendarType: 'julian' | 'gregorian';
+
+ constructor(container: HTMLElement, date: JDate, onDateChange: (d: JDate) => void, language: LanguageCode = 'en', calendarType: 'julian' | 'gregorian' = 'julian') {
+ this.container = container;
+ this.currentDate = date;
+ this.onDateChange = onDateChange;
+ this.language = language;
+ this.t = getTranslations(language);
+ this.calendarType = calendarType;
+ }
+
+ private getFontClass(): string {
+ try {
+ const stored = localStorage.getItem('ponomar-settings');
+ if (stored) {
+ const settings = JSON.parse(stored);
+ let cuFont = settings.cuFont || '';
+ // Migrate old usePonomarFont
+ if (settings.usePonomarFont !== undefined) {
+ cuFont = settings.usePonomarFont ? 'Ponomar' : '';
+ }
+ const useFont = cuFont !== '' || settings.usePonomarFont !== false;
+ return (this.language === 'cu') && useFont ? fontClass(cuFont) : '';
+ }
+ } catch {}
+ return this.language === 'cu' ? 'font-ponomar' : '';
+ }
+
+ /** Lightweight dRank computation for calendar grid highlighting */
+ private getDayRank(date: JDate, julianYear: number): number {
+ const month = date.getMonth();
+ const day = date.getDay();
+ const pascha = Paschalion.getPascha(julianYear);
+ const nday = date.difference(pascha);
+ const isBrightWeek = nday >= 0 && nday <= 6;
+
+ if (month === 9 && day === 14) return 6;
+ if (month === 8 && day === 29) return 6;
+ if (nday === 0) return 8;
+ if (nday === 49) return 8;
+ if (nday === 39) return 8;
+ if (nday === -7) return 8;
+ if (month === 12 && day === 25) return 6;
+ if (month === 1 && day === 6) return 6;
+ if (month === 8 && day === 15) return 6;
+ if (month === 3 && day === 25) return 6;
+ if (nday >= -48 && nday < 0) return 3;
+ if (isBrightWeek || (nday >= 49 && nday <= 56)) return 5;
+ return 0;
+ }
+
+ render() {
+ const useGregorian = this.calendarType === 'gregorian';
+ const fnClass = this.getFontClass();
+
+ function daysInGregorianMonth(m: number, y: number): number {
+ const dim = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
+ if (m === 2 && ((y % 4 === 0 && y % 100 !== 0) || y % 400 === 0)) return 29;
+ return dim[m - 1];
+ }
+
+ const julianYear = this.currentDate.getYear();
+ const julianMonth = this.currentDate.getMonth();
+ const julianDay = this.currentDate.getDay();
+
+ // Grid display params: Julian or Gregorian based on setting
+ let month: number, year: number, day: number;
+ if (useGregorian) {
+ const g = this.currentDate.toGregorian();
+ year = g.getFullYear();
+ month = g.getMonth() + 1;
+ day = g.getDate();
+ } else {
+ year = julianYear;
+ month = julianMonth;
+ day = julianDay;
+ }
+
+ let firstDow: number;
+ let daysInMonth: number;
+ if (useGregorian) {
+ const firstJulian = JDate.fromGregorianParts(year, month, 1);
+ firstDow = firstJulian.getDayOfWeek();
+ daysInMonth = daysInGregorianMonth(month, year);
+ } else {
+ const firstOfMonth = new JDate(month, 1, year);
+ firstDow = firstOfMonth.getDayOfWeek();
+ daysInMonth = JDate.maxDaysInMonth(month, year);
+ }
+
+ const fasts = Paschalion.getFasts(julianYear);
+
+ let gridHtml = '';
+ let col = 0;
+
+ gridHtml += this.t.calendar.dayNames.map(d =>
+ `${d}
`
+ ).join('');
+
+ for (let i = 0; i < firstDow; i++) {
+ gridHtml += '';
+ col++;
+ }
+
+ for (let d = 1; d <= daysInMonth; d++) {
+ let fast = 0;
+ let isSelected = false;
+ let isToday = false;
+ let dayRank = 0;
+ if (useGregorian) {
+ const jDate = JDate.fromGregorianParts(year, month, d);
+ const dayDoy = jDate.getDoy();
+ fast = fasts[dayDoy] ?? 0;
+ isSelected = d === day;
+ dayRank = this.getDayRank(jDate, julianYear);
+ const todayGreg = JDate.today().toGregorian();
+ isToday = todayGreg.getFullYear() === year && todayGreg.getMonth() + 1 === month && todayGreg.getDate() === d;
+ } else {
+ const date = new JDate(month, d, year);
+ const dayDoy = date.getDoy();
+ fast = fasts[dayDoy] ?? 0;
+ isSelected = d === day;
+ dayRank = this.getDayRank(date, julianYear);
+ isToday = date.equals(JDate.today());
+ }
+
+ let classes = 'day-cell p-1 text-center text-sm cursor-pointer rounded transition-colors ';
+ if (isSelected) classes += 'bg-navy text-parchment font-bold ';
+ else if (isToday) classes += 'bg-gold/20 font-bold ';
+ else if (dayRank >= 6) classes += 'bg-red-100 text-red-800 font-bold ';
+ else if (fast === 1) classes += 'bg-purple-50 ';
+ else if (fast === 2) classes += 'bg-blue-50 ';
+
+ gridHtml += `${d}
`;
+
+ col++;
+ }
+
+ const computed = computeDay(this.currentDate);
+
+
+
+ // Get Gregorian date for display
+ const gregorianDate = this.currentDate.toGregorian();
+ const gregorianDayName = this.t.calendar.dayNamesFull[gregorianDate.getDay()];
+ const gregorianStr = `${gregorianDayName}, ${gregorianDate.getDate()} ${this.t.calendar.monthsGenitive[gregorianDate.getMonth()]} ${gregorianDate.getFullYear()} г.`;
+ // Julian date (church calendar)
+ const julianDayName = this.t.calendar.dayNamesFull[this.currentDate.getDayOfWeek()];
+ // Anno Mundi year (Old Slavonic year = year + 5508)
+ const annoMundi = julianYear + 5508;
+ const julianStr = `${julianDayName}, ${this.currentDate.getDay()} ${this.t.calendar.monthsGenitive[this.currentDate.getMonth() - 1]}, год ${julianYear} ${this.t.calendar.fromYear}, ${annoMundi} ${this.t.calendar.fromAdam}`;
+
+ this.container.innerHTML = `
+
+
+
+
+ ${this.t.calendar.months[month - 1]} ${year}
+
+
+
+
+ ${gridHtml}
+
+
+
+
+
+
+
+
+
+ ${julianStr}
+
+
+ ${this.t.calendar.gregorianDate[0].toLowerCase() + this.t.calendar.gregorianDate.slice(1)}: ${gregorianStr}
+
+
+
+
+
+
${this.t.calendar.commemorations}
+
${this.t.loading}
+
+
+
+
${this.t.calendar.fastingRule}
+
${this.t.loading}
+
+
+
+
${this.t.calendar.readings}
+
${this.t.loading}
+
+
+
+
+
+
+ `;
+
+ // Load data asynchronously
+ this.loadData(computed);
+
+ // Event listeners
+ document.getElementById('prev-month')?.addEventListener('click', () => {
+ const prevMonth = month <= 1 ? 12 : month - 1;
+ const prevYear = month <= 1 ? year - 1 : year;
+ if (useGregorian) {
+ const prevDay = Math.min(day, daysInGregorianMonth(prevMonth, prevYear));
+ this.currentDate = JDate.fromGregorianParts(prevYear, prevMonth, prevDay);
+ } else {
+ const prevDay = Math.min(day, JDate.maxDaysInMonth(prevMonth, prevYear));
+ this.currentDate = new JDate(prevMonth, prevDay, prevYear);
+ }
+ this.onDateChange(this.currentDate);
+ this.render();
+ });
+
+ document.getElementById('next-month')?.addEventListener('click', () => {
+ const nextMonth = month >= 12 ? 1 : month + 1;
+ const nextYear = month >= 12 ? year + 1 : year;
+ if (useGregorian) {
+ const nextDay = Math.min(day, daysInGregorianMonth(nextMonth, nextYear));
+ this.currentDate = JDate.fromGregorianParts(nextYear, nextMonth, nextDay);
+ } else {
+ const nextDay = Math.min(day, JDate.maxDaysInMonth(nextMonth, nextYear));
+ this.currentDate = new JDate(nextMonth, nextDay, nextYear);
+ }
+ this.onDateChange(this.currentDate);
+ this.render();
+ });
+
+ this.container.querySelectorAll('.day-cell').forEach(el => {
+ el.addEventListener('click', () => {
+ const d = parseInt(el.getAttribute('data-day')!, 10);
+ if (useGregorian) {
+ this.currentDate = JDate.fromGregorianParts(year, month, d);
+ } else {
+ this.currentDate = new JDate(month, d, year);
+ }
+ this.onDateChange(this.currentDate);
+ this.render();
+ });
+ });
+ }
+
+ private async loadData(computed: { dayInfo: import('../core/types').DayInfo; evalContext: import('../core/types').EvalContext }) {
+ const { dayInfo, evalContext } = computed;
+ this.skipCids = new Set();
+
+ // Load fasting
+ const fastingPeriods = await loadFastingPeriods();
+ const fastingResult = evaluateFasting(fastingPeriods, evalContext, this.language);
+ const fastingPanel = document.getElementById('fasting-panel');
+ if (fastingPanel) {
+ fastingPanel.innerHTML = `
+ ${this.t.calendar.fastingRule}
+ ${fastingResult.description || this.t.notAvailable}
+ `;
+ }
+
+ // Load commemorations from menaion (language-specific)
+ const menaion = await loadMenaion(this.language, dayInfo.month, dayInfo.day);
+
+ // Load triodion/pentecostarion entries (shared, language-agnostic)
+ let calendarEntries: unknown[] = [];
+ if (dayInfo.triodionFile !== null) {
+ calendarEntries = await loadTriodionFile(dayInfo.triodionFile);
+ } else if (dayInfo.pentecostarionFile !== null) {
+ calendarEntries = await loadPentecostarionFile(dayInfo.pentecostarionFile);
+ }
+
+ // Collect CIds from all entries
+ const cids: string[] = [];
+ const allEntries = [...calendarEntries, ...menaion];
+ for (const entry of allEntries) {
+ if (typeof entry === 'object' && entry !== null) {
+ const e = entry as Record;
+ if (e.CId) cids.push(String(e.CId));
+ }
+ }
+
+ // Load commemorations from BOTH sources:
+ // 1. Lives (language-specific, for names & life text)
+ // 2. Shared commemorations (for readings, rank, troparion/kontakion)
+ // Use CID_TO_SHARED_COMM mapping for known feast days
+ const livesData: Map = new Map();
+ const sharedComms: Map = new Map();
+
+ for (const cid of cids.slice(0, 30)) {
+ const [life, shared] = await Promise.all([
+ loadLife(this.language, cid),
+ loadCommemoration(CID_TO_SHARED_COMM[cid] || cid),
+ ]);
+ if (life) livesData.set(cid, life);
+ if (shared) sharedComms.set(cid, shared);
+ }
+
+ // Build day title: period description + tone + great feast
+ const titleEl = document.getElementById('day-title');
+ if (titleEl) {
+ let periodName = '';
+ let periodCid = '';
+ let feastCid = '';
+ let feastName = '';
+ let feastRank = 0;
+
+ // Pass 1: Find period name from primary calendar entry (SId ''/'0' + CId 9[0-38]xxxx)
+ for (const entry of calendarEntries) {
+ const e = entry as Record;
+ const cid = String(e.CId || '');
+ const sid = String(e.SId ?? '');
+ if (!cid || !/^9[0-38]\d{2}$/.test(cid)) continue;
+ if (sid !== '' && sid !== '0') continue;
+ const name = getPeriodName(livesData.get(cid)) || getCommsName(sharedComms.get(cid));
+ if (!name) continue;
+ periodName = name;
+ periodCid = cid;
+ break;
+ }
+
+ // Pass 2: Find great feast (rank >= 6) from all entries
+ for (const entry of allEntries) {
+ const e = entry as Record;
+ const cid = String(e.CId || '');
+ if (!cid || this.skipCids.has(cid)) continue;
+ const life = livesData.get(cid);
+ const shared = sharedComms.get(cid);
+ const name = getShortFeastName(life, shared);
+ if (!name) continue;
+ const rank = parseInt(shared?.CHURCH?.Rank || '0', 10) || (life?.rank as number) || 0;
+ if (rank >= 6 && rank > feastRank) {
+ feastRank = rank;
+ feastName = name;
+ feastCid = cid;
+ }
+ }
+
+ // Skip period CId from commemorations if not a feast
+ if (periodCid && !feastCid) this.skipCids.add(periodCid);
+
+ if (!periodName) periodName = this.t.calendar.rankLabels[dayInfo.dRank] ?? '';
+
+ if (periodName) {
+ const tone = dayInfo.Tone;
+ const toneSuffix = tone > 0 ? `; ${this.t.calendar.tone}${tone}` : '';
+ const feastSuffix = feastName && feastCid !== periodCid ? `; ${feastName}` : '';
+ const titleColor = feastName ? 'text-red-700' : 'text-navy';
+ titleEl.innerHTML = `${periodName}${toneSuffix}${feastSuffix}`;
+ }
+
+ if (feastCid) this.skipCids.add(feastCid);
+ }
+
+ // Load commemorations for each CId
+ const commPanel = document.getElementById('commemorations-panel');
+ if (commPanel) {
+ if (cids.length === 0) {
+ commPanel.innerHTML = `
+ ${this.t.calendar.commemorations}
+ ${this.t.calendar.noCommemorations}
+ `;
+ } else {
+ const commHtmlParts: string[] = [];
+ for (const cid of cids.slice(0, 15)) {
+ if (this.skipCids.has(cid)) continue;
+ // Prefer shared commemoration for name (feast days), fallback to lives (saints)
+ const shared = sharedComms.get(cid);
+ const life = livesData.get(cid);
+
+ const lifeText = life?.life?.text || (shared?.LIFE as any)?.text || '';
+
+ let name = '';
+ if (life?.name) {
+ name = life.name['@_Nominative'] || life.name.nominative || life.name['@_Short'] || life.name.short || '';
+ }
+ if (!name && shared?.NAME) {
+ if (typeof shared.NAME === 'object' && 'Long' in shared.NAME) {
+ name = shared.NAME.Long || shared.NAME.ShortN || '';
+ } else if (typeof shared.NAME === 'object' && 'text' in shared.NAME) {
+ name = (shared.NAME as { text: string }).text || '';
+ }
+ }
+ if (name) {
+ // Determine rank: shared commemoration rank > day rank
+ const rank = parseInt(shared?.CHURCH?.Rank || '0', 10) || (life?.rank as number) || dayInfo.dRank || 0;
+ const icon = this.t.calendar.rankIcons[rank] || '';
+ const iconHtml = icon ? `${icon}` : '';
+ const lifeHtml = lifeText
+ ? `${lifeText}
`
+ : '';
+ commHtmlParts.push(
+ `
+
${iconHtml}${name}
+ ${lifeHtml}
+
`
+ );
+ }
+ }
+ if (commHtmlParts.length > 0) {
+ commPanel.innerHTML = `
+ ${this.t.calendar.commemorations}
+ ${commHtmlParts.join('')}
+ `;
+ commPanel.addEventListener('click', (e) => {
+ const target = e.target as HTMLElement;
+ const item = target.closest('.comm-item');
+ if (item) {
+ const lifeDiv = item.querySelector('.life-text') as HTMLElement;
+ if (lifeDiv) lifeDiv.classList.toggle('hidden');
+ }
+ });
+ } else {
+ commPanel.innerHTML = `
+ ${this.t.calendar.commemorations}
+ ${this.t.calendar.noCommemorations}
+ `;
+ }
+ }
+ }
+
+ // Collect readings grouped by service
+ const readingsGroups: ReadingsGroup[] = [];
+ const addReading = (service: string, reading: string, purpose: string) => {
+ const group = findOrCreateGroup(readingsGroups, service);
+ const ref = formatReadingRef(reading, this.language);
+ if (!group.items.some(i => i.ref === ref)) {
+ group.items.push({ ref, purpose });
+ }
+ };
+ const isPeriodCid = (cid: string): boolean => /^9[0-38]\d{2}$/.test(cid);
+ const readingContext = (shortName: string, cid?: string): string => {
+ if (!shortName) return '';
+ if (cid && isPeriodCid(cid)) return '';
+ return ` (${shortName})`;
+ };
+ loadBookAbbrevs(this.language);
+ for (const [cid, shared] of sharedComms) {
+ if (shared?.SCRIPTURE) {
+ const shortN = typeof shared.NAME === 'object' && 'ShortN' in shared.NAME ? (shared.NAME.ShortN || '') : '';
+ const tag = readingContext(shortN, cid);
+ const scripts = Array.isArray(shared.SCRIPTURE) ? shared.SCRIPTURE : [shared.SCRIPTURE];
+ for (const s of scripts) {
+ if (s && typeof s === 'object') {
+ const sr = s as Record;
+ if (sr.Reading) {
+ const svc = readingsTypeToService(sr.Type || '', this.t);
+ addReading(svc, sr.Reading, tag);
+ }
+ }
+ }
+ }
+ }
+ // Also load readings from saint lives (from services structure)
+ for (const [cid, life] of livesData) {
+ const shortName = life?.name?.['@_Short'] || life?.name?.short || '';
+ const tag = readingContext(shortName, cid);
+
+ if (life?.services) {
+ const svcMap = life.services as Record>>;
+ for (const [svcName, scriptures] of Object.entries(svcMap)) {
+ for (const [, sc] of Object.entries(scriptures)) {
+ const reading = sc.Reading || sc.reading;
+ if (!reading) continue;
+ const scType = sc.Type || sc.type || '';
+ const svc = svcNameToService(svcName, scType, this.t);
+ addReading(svc, reading, tag);
+ }
+ }
+ } else if (life?.scripture) {
+ for (const s of life.scripture) {
+ const sc = s as Record;
+ const reading = sc.Reading || sc.reading;
+ if (reading) {
+ const svc = readingsTypeToService(sc.Type || sc.type || '', this.t);
+ addReading(svc, reading, tag);
+ }
+ }
+ }
+ }
+ // Also check calendar entries for Scripture
+ for (const entry of allEntries) {
+ if (typeof entry === 'object' && entry !== null) {
+ const e = entry as Record;
+ if (e.Scripture && typeof e.Scripture === 'object') {
+ const s = e.Scripture as Record;
+ if (s.Reading) {
+ const svc = readingsTypeToService(s.Type || '', this.t);
+ addReading(svc, s.Reading, '');
+ }
+ }
+ }
+ }
+
+ const readingsPanel = document.getElementById('readings-panel');
+ if (readingsPanel) {
+ if (readingsGroups.length === 0) {
+ readingsPanel.innerHTML = `
+ ${this.t.calendar.readings}
+ ${this.t.calendar.noReadings}
+ `;
+ } else {
+ const sorted = [...readingsGroups].sort((a, b) => {
+ const ai = SERVICE_ORDER.indexOf(a.service);
+ const bi = SERVICE_ORDER.indexOf(b.service);
+ return (ai === -1 ? 999 : ai) - (bi === -1 ? 999 : bi);
+ });
+ const groupsHtml = sorted.map(g => {
+ // Group consecutive items with same purpose; tag only on last of each group
+ let refs = '';
+ for (let i = 0; i < g.items.length; i++) {
+ const item = g.items[i];
+ const next = g.items[i + 1];
+ if (i > 0) refs += this.t.calendar.readSep;
+ refs += item.ref;
+ if (!next || next.purpose !== item.purpose) {
+ refs += item.purpose;
+ }
+ }
+ return `${g.service}${this.t.calendar.colon}${refs}
`;
+ }).join('');
+ readingsPanel.innerHTML = `
+ ${this.t.calendar.readings}
+ ${groupsHtml}
+ `;
+ }
+ }
+
+ // Load services
+ const svcPanel = document.getElementById('services-panel');
+ if (svcPanel) {
+ svcPanel.innerHTML = '';
+ try {
+ const svcView = new ServiceView(svcPanel, this.currentDate, this.language);
+ await svcView.render(false);
+ } catch {
+ svcPanel.innerHTML = `${this.t.loading}
`;
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/ui/service-view.ts b/src/ui/service-view.ts
new file mode 100644
index 0000000..821afa7
--- /dev/null
+++ b/src/ui/service-view.ts
@@ -0,0 +1,712 @@
+/**
+ * Service view — displays assembled liturgical services.
+ * Loads XML templates (converted to JSON) and assembles them with
+ * dynamic Var nodes from Octoecheos tone data.
+ */
+
+import { JDate } from '../core/jdate';
+import { computeDay } from '../core/day-computer';
+import { assembleService, type ServiceContext } from '../core/service-assembler';
+import { evalBool } from '../core/evaluator';
+import type { EvalContext, ServiceNode } from '../core/types';
+import { getTranslations, type LanguageCode } from '../core/i18n';
+import { loadSettings, fontClass } from './settings-view';
+
+interface ServiceDef {
+ id: string;
+ name: string;
+ serviceType: string;
+ template: string;
+ description: string;
+ titleNode: ServiceNode;
+}
+
+function title(value: string, header?: string, source?: string, comment?: string): ServiceNode {
+ return { type: 'TITLE', value, header: header ?? value, source, comment };
+}
+
+const WEEKDAY_NAMES = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
+
+export class ServiceView {
+ private container: HTMLElement;
+ private currentDate: JDate;
+ private evalContext: EvalContext;
+ private language: LanguageCode;
+ private t: ReturnType;
+ private jsonCache = new Map[]>();
+ private nodeCache = new Map();
+ private notFound = new Set();
+ private bibleCache = new Map();
+ private livesCache = new Map>>>();
+ private commandCache = new Map();
+ private timesCache = new Map();
+ private activeService: string | null = null;
+
+ constructor(container: HTMLElement, date: JDate, language: LanguageCode = 'en') {
+ this.container = container;
+ this.currentDate = date;
+ this.evalContext = computeDay(date).evalContext;
+ this.language = language;
+ this.t = getTranslations(language);
+ }
+
+ private getFontClass(): string {
+ const settings = loadSettings();
+ return (this.language === 'cu' || this.language === 'ru') && settings.cuFont !== undefined ? fontClass(settings.cuFont) : '';
+ }
+
+ private get serviceLang(): string {
+ return this.language === 'ru' ? 'cu' : this.language;
+ }
+
+ async render(standalone = true) {
+ const computed = computeDay(this.currentDate);
+ const { dayInfo } = computed;
+
+ const availableServices = this.getAvailableServices(dayInfo);
+ const fontClass = this.getFontClass();
+
+ const contentClass = `bg-white/50 border border-gold/20 rounded-lg p-6 ${this.activeService ? 'min-h-[300px]' : ''} ${fontClass}`;
+
+ const tabHtml = `
+ ${this.t.services.title}
+
+ ${availableServices.map(s => `
+
+ `).join('')}
+
+
+
+
${this.t.services.selectService}
+
+ `;
+
+ this.container.innerHTML = standalone
+ ? `
+
${this.t.services.title}
+
+ ${this.currentDate.toString()}
+ ${dayInfo.isLent ? this.t.services.greatLent : ''}
+ ${dayInfo.isBrightWeek ? this.t.services.brightWeek : ''}
+ ${dayInfo.isApostlesFast ? this.t.services.apostlesFast : ''}
+ ${dayInfo.isDormitionFast ? this.t.services.dormitionFast : ''}
+ ${dayInfo.isNativityFast ? this.t.services.nativityFast : ''}
+ ${dayInfo.Tone > 0 ? `${this.t.services.tone}${dayInfo.Tone}` : ''}
+
+ ${tabHtml}
+
`
+ : tabHtml;
+
+ this.container.querySelectorAll('.service-tab').forEach(btn => {
+ btn.addEventListener('click', () => {
+ const serviceId = btn.getAttribute('data-service')!;
+ const service = availableServices.find(s => s.id === serviceId);
+ if (service) {
+ this.activeService = serviceId;
+ this.container.querySelectorAll('.service-tab').forEach(t => {
+ const id = t.getAttribute('data-service');
+ if (id === serviceId) {
+ t.className = 'service-tab px-4 py-2 text-sm border-b-2 transition-colors whitespace-nowrap border-gold text-navy font-bold';
+ } else {
+ t.className = 'service-tab px-4 py-2 text-sm border-b-2 transition-colors whitespace-nowrap border-transparent text-navy-light hover:text-navy';
+ }
+ });
+ this.loadService(service);
+ }
+ });
+ });
+ }
+
+ private getAvailableServices(dayInfo: import('../core/types').DayInfo): ServiceDef[] {
+ const services: ServiceDef[] = [];
+
+ services.push({
+ id: 'primes', name: this.t.services.serviceNames.primes, serviceType: 'PRIMES',
+ template: 'Prime',
+ description: this.t.services.serviceDescriptions.primes,
+ titleNode: title('Primes1', this.t.services.serviceNames.primes, 'PrimeSource'),
+ });
+
+ services.push({
+ id: 'third', name: this.t.services.serviceNames.third, serviceType: 'TERCE',
+ template: 'ThirdHour',
+ description: this.t.services.serviceDescriptions.third,
+ titleNode: title('Third1', this.t.services.serviceNames.third),
+ });
+
+ services.push({
+ id: 'sixth', name: this.t.services.serviceNames.sixth, serviceType: 'SEXTE',
+ template: 'SixthHour',
+ description: this.t.services.serviceDescriptions.sixth,
+ titleNode: title('Sixth1', this.t.services.serviceNames.sixth),
+ });
+
+ services.push({
+ id: 'ninth', name: this.t.services.serviceNames.ninth, serviceType: 'NONE',
+ template: 'NinthHour',
+ description: this.t.services.serviceDescriptions.ninth,
+ titleNode: title('Ninth1', this.t.services.serviceNames.ninth),
+ });
+
+ if (dayInfo.dRank >= 6) {
+ services.push({
+ id: 'royalhours', name: this.t.services.serviceNames.royalhours, serviceType: 'ROYALHOURS',
+ template: 'RoyalHours',
+ description: this.t.services.serviceDescriptions.royalhours,
+ titleNode: title('RoyalHours1', this.t.services.serviceNames.royalhours),
+ });
+ }
+
+ return services;
+ }
+
+ private async loadToneData(serviceType: string): Promise<{ troparion1?: string; kontakion1?: string } | null> {
+ const computed = computeDay(this.currentDate);
+ const { dayInfo } = computed;
+ const tone = dayInfo.Tone;
+ const dow = dayInfo.dow;
+ const mm = String(dayInfo.month).padStart(2, '0');
+ const dd = String(dayInfo.day).padStart(2, '0');
+ const dateKey = `${mm}-${dd}`;
+
+ let result: { troparion1?: string; kontakion1?: string } | null = null;
+
+ if (tone > 0 && tone <= 8) {
+ const toneNum = tone === 8 ? 0 : tone;
+ const weekday = WEEKDAY_NAMES[dow];
+
+ try {
+ const tryFetch = async (lang: string) => {
+ const url = `/data/${lang}/services/Octoecheos/Tone ${toneNum}/${weekday}.json`;
+ const resp = await fetch(url);
+ if (!resp.ok) return null;
+ const nodes: ServiceNode[] = await resp.json();
+ for (const node of nodes) {
+ if (node.type === serviceType && node.cmd) {
+ if (evalBool(node.cmd as string, this.evalContext)) {
+ return {
+ troparion1: node.troparion1 as string | undefined,
+ kontakion1: node.kontakion1 as string | undefined,
+ };
+ }
+ }
+ }
+ return null;
+ };
+
+ result = await tryFetch(this.serviceLang);
+ if (!result && this.serviceLang !== 'cu') result = await tryFetch('cu');
+ if (!result) result = await tryFetch('shared');
+ } catch { /* fall through to menologion */ }
+ }
+
+ // Menologion fallback when Octoecheos has no valid entry
+ if (!result) {
+ try {
+ const tryLoad = async (url: string): Promise<{ cid: string }[] | null> => {
+ try {
+ const resp = await fetch(url);
+ if (!resp.ok) return null;
+ const all = await resp.json();
+ if (Array.isArray(all)) return all.map((e: Record) => ({ cid: String(e.CId ?? '') }));
+ const entries = all[dateKey] as Record[] | undefined;
+ if (Array.isArray(entries)) return entries.map((e: Record) => ({ cid: String(e.CId ?? '') }));
+ return null;
+ } catch { return null; }
+ };
+
+ const allEntries: { cid: string }[] = [];
+
+ const menaionEntries = await tryLoad(`/data/${this.serviceLang}/menaion-bundle.json`);
+ if (menaionEntries) allEntries.push(...menaionEntries);
+
+ if (dayInfo.triodionFile !== null) {
+ const triodion = await tryLoad(`/data/shared/triodion/${dayInfo.triodionFile}.json`);
+ if (triodion) allEntries.push(...triodion);
+ }
+ if (dayInfo.pentecostarionFile !== null) {
+ const pent = await tryLoad(`/data/shared/pentecostarion/${dayInfo.pentecostarionFile}.json`);
+ if (pent) allEntries.push(...pent);
+ }
+
+ const TONE_NUM = ['', 'а҃', 'в҃', 'г҃', 'д҃', 'є҃', 'ѕ҃', 'з҃', 'и҃'];
+ for (const entry of allEntries) {
+ if (!entry.cid) continue;
+ try {
+ const shmResp = await fetch(`/data/shared/commemorations/${entry.cid}.json`);
+ if (!shmResp.ok) continue;
+ const shm = await shmResp.json();
+ const troparion = shm.TROPARION as { Tone?: string; text?: string } | undefined;
+ const kontakion = shm.KONTAKION as { Tone?: string; text?: string } | undefined;
+ if (troparion?.text || kontakion?.text) {
+ const key = `MEN_${entry.cid}`;
+
+ if (troparion?.text) {
+ const tn = TONE_NUM[parseInt(troparion.Tone ?? '1', 10)] ?? troparion.Tone ?? '1';
+ this.jsonCache.set(`/data/${this.serviceLang}/services/CommonPrayers/TROPARION/${key}.json`, [
+ { type: 'HEADER', value: `Тропа́рь, гла́съ ${tn}` },
+ { type: 'TEXT', value: troparion.text },
+ ]);
+ }
+
+ if (kontakion?.text) {
+ const tn = TONE_NUM[parseInt(kontakion.Tone ?? '1', 10)] ?? kontakion.Tone ?? '1';
+ this.jsonCache.set(`/data/${this.serviceLang}/services/CommonPrayers/KONTAKION/${key}.json`, [
+ { type: 'HEADER', value: `Конда́къ, гла́съ ${tn}` },
+ { type: 'TEXT', value: kontakion.text },
+ ]);
+ }
+
+ result = { troparion1: key, kontakion1: key };
+ break;
+ }
+ } catch { continue; }
+ }
+ } catch { /* fall through to tertiary fallback */ }
+ }
+
+ // Tertiary fallback: known feasts not yet in shared commemorations
+ if (!result) {
+ const MENAION_TROP: Record = {
+ '03-23': { troparion1: 'PROPHET', kontakion1: 'PROPHET' },
+ '03-22': { troparion1: 'PROPHET', kontakion1: 'PROPHET' },
+ };
+ const m = MENAION_TROP[dateKey];
+ if (m) result = m;
+ }
+
+ return result;
+ }
+
+ private generateVarNodes(toneData: { troparion1?: string; kontakion1?: string } | null, serviceType: string): Map {
+ const varNodes = new Map();
+
+ const prefix: Record = { PRIMES: '', TERCE: '3', SEXTE: '6', NONE: '9' };
+ const p = prefix[serviceType] ?? '';
+
+ if (toneData?.troparion1) {
+ const key1 = `PTrop${p}1`;
+ varNodes.set(key1, [{
+ type: 'CREATE',
+ what: `TROPARION/${toneData.troparion1}`,
+ who: '',
+ header: true,
+ redfirst: true,
+ newline: true,
+ }]);
+ }
+
+ if (toneData?.kontakion1) {
+ const key = p ? `PKont${p}` : 'PKont1';
+ varNodes.set(key, [{
+ type: 'CREATE',
+ what: `KONTAKION/${toneData.kontakion1}`,
+ who: '',
+ header: true,
+ redfirst: true,
+ newline: true,
+ }]);
+ }
+
+ return varNodes;
+ }
+
+ private async loadService(service: ServiceDef) {
+ const contentEl = document.getElementById('service-content');
+ if (!contentEl) return;
+
+ contentEl.innerHTML = `${this.t.loading}
`;
+
+ try {
+ const templateNodes = await this.fetchServiceNodes(`Services/${service.template}.json`);
+ if (!templateNodes) {
+ contentEl.innerHTML = `Service template not found
`;
+ return;
+ }
+
+ const toneData = await this.loadToneData(service.serviceType);
+ const varNodes = this.generateVarNodes(toneData, service.serviceType);
+
+ const nodes: ServiceNode[] = [...templateNodes];
+
+ const computed = computeDay(this.currentDate);
+ const isMenologionTone = toneData?.troparion1?.startsWith('PROPHET') || toneData?.troparion1?.startsWith('MEN_');
+ const dayPFlag2 = computed.dayInfo.PFlag2;
+ const evalCtx: EvalContext = {
+ ...this.evalContext,
+ PFlag1: 0,
+ PFlag2: isMenologionTone && dayPFlag2 !== 0 ? 0 : dayPFlag2,
+ PFlag3: 0,
+ PS: 1,
+ };
+
+ const ctx: ServiceContext = {
+ evalCtx,
+ lang: this.serviceLang,
+ t: this.t,
+ fetchBibleText: (book, passage, showVerseNumbers, verseNewLine) => this.fetchBibleText(book, passage, showVerseNumbers, verseNewLine),
+ fetchLives: (id) => this.fetchLives(id),
+ fetchCommandText: (name) => this.fetchCommandText(name),
+ resolveTimes: (times) => this.resolveTimes(times),
+ fetchText: this.createFetchText(),
+ fetchServiceNodes: async (path: string): Promise => {
+ const varMatch = path.match(/Services\/Var\/(\w+)\.xml$/);
+ if (varMatch) {
+ return varNodes.get(varMatch[1]) || null;
+ }
+ return this.fetchServiceNodes(path);
+ },
+ };
+
+ const { html } = await assembleService(nodes, ctx);
+
+ if (html.trim()) {
+ const fontClass = this.getFontClass();
+ contentEl.className = `prose prose-sm max-w-none liturgical-text ${fontClass}`;
+ contentEl.innerHTML = html;
+ } else {
+ contentEl.className = 'prose prose-sm max-w-none liturgical-text';
+ contentEl.innerHTML = `
+
+
${this.t.services.serviceProducedNoOutput}
+
${nodes.length} ${this.t.services.nodesAssembled}
+
+ `;
+ }
+ } catch (err) {
+ contentEl.innerHTML = `
+
+
${this.t.services.errorLoading}
+
${err instanceof Error ? err.message : this.t.error}
+
+ `;
+ }
+ }
+
+ private bibleVersion(lang: string): string {
+ const map: Record = {
+ en: 'kjv',
+ cu: 'elis',
+ fr: 'ls',
+ el: 'spt',
+ la: 'vulgate',
+ zh: 'cuv',
+ ar: 'svd',
+ };
+ return map[lang] || 'kjv';
+ }
+
+ private async fetchBibleText(book: string, passage: string, showVerseNumbers = true, verseNewLine = false): Promise {
+ const cacheKey = `${this.language}/${book}/${passage}/${showVerseNumbers ? 'vn' : 'nn'}/${verseNewLine ? 'vb' : 'nvb'}`;
+ const cached = this.bibleCache.get(cacheKey);
+ if (cached) return cached;
+
+ const version = this.bibleVersion(this.serviceLang);
+ const tryFetch = async (lang: string): Promise => {
+ const url = `/data/${lang}/bible/${version}/${book}.text`;
+ if (this.notFound.has(url)) return null;
+ const resp = await fetch(url);
+ if (!resp.ok) { this.notFound.add(url); return null; }
+ const ct = resp.headers.get('content-type');
+ if (ct && ct.includes('text/html')) { this.notFound.add(url); return null; }
+ return resp.text();
+ };
+
+ let text = await tryFetch(this.serviceLang);
+ if (!text && this.serviceLang !== 'cu') text = await tryFetch('cu');
+ if (!text) text = await tryFetch('shared');
+ if (!text) return '';
+
+ const lines = text.split('\n');
+ // Parse comma-separated passage parts, each potentially: Chapter[:VerseStart[-VerseEnd]]
+ const parts = passage.split(',');
+ const ranges: { chapter: number; vStart: number; vEnd: number }[] = [];
+
+ for (const part of parts) {
+ const trimmed = part.trim();
+ if (!trimmed) continue;
+
+ let chapter: number;
+ let verseSpec: string;
+
+ if (trimmed.includes(':')) {
+ const colonIdx = trimmed.indexOf(':');
+ chapter = parseInt(trimmed.substring(0, colonIdx), 10);
+ verseSpec = trimmed.substring(colonIdx + 1);
+ } else {
+ // Bare number = chapter number (e.g. "50" = Psalm 50, read all verses)
+ chapter = parseInt(trimmed, 10);
+ verseSpec = '1-';
+ }
+
+ let vStart: number;
+ let vEnd: number;
+
+ if (verseSpec.includes('-')) {
+ const dashIdx = verseSpec.indexOf('-');
+ vStart = parseInt(verseSpec.substring(0, dashIdx), 10);
+ const after = verseSpec.substring(dashIdx + 1);
+ // Check if after contains chapter:verse (cross-chapter range)
+ if (after.includes(':')) {
+ const afterColon = after.indexOf(':');
+ const nextChapter = parseInt(after.substring(0, afterColon), 10);
+ vEnd = parseInt(after.substring(afterColon + 1), 10);
+ ranges.push({ chapter, vStart, vEnd: -1 }); // all remaining verses in current chapter
+ chapter = nextChapter;
+ vStart = 1;
+ vEnd = vEnd;
+ } else {
+ vEnd = after ? parseInt(after, 10) : -1; // empty end = all remaining verses
+ }
+ } else {
+ vStart = parseInt(verseSpec, 10);
+ vEnd = vStart;
+ }
+
+ ranges.push({ chapter, vStart, vEnd });
+ }
+
+ // Scan file and collect matching verses
+ let result = '';
+ let curChapter = 0;
+
+ for (const line of lines) {
+ const trimmed = line.trim();
+ if (!trimmed) continue;
+
+ const chapterMatch = trimmed.match(/^#(\d+)/);
+ if (chapterMatch) {
+ curChapter = parseInt(chapterMatch[1], 10);
+ continue;
+ }
+
+ const verseMatch = trimmed.match(/^(\d+)\|(.*)/);
+ if (verseMatch) {
+ const curVerse = parseInt(verseMatch[1], 10);
+ const verseText = verseNewLine ? verseMatch[2].replace(/^ /, '') : verseMatch[2];
+
+ for (const range of ranges) {
+ if (range.chapter !== curChapter) continue;
+ if (curVerse < range.vStart) continue;
+ if (range.vEnd !== -1 && curVerse > range.vEnd) continue;
+ if (result) result += verseNewLine ? '
' : ' ';
+ result += showVerseNumbers ? `${curVerse}${verseText}` : verseText;
+ break;
+ }
+ } else if (trimmed.startsWith('*')) {
+ if (result) result += ' ';
+ result += trimmed;
+ }
+ }
+
+ this.bibleCache.set(cacheKey, result);
+ return result;
+ }
+
+ private async fetchLives(id: string): Promise>> | null> {
+ const cacheKey = `${this.language}/${id}`;
+ const cached = this.livesCache.get(cacheKey);
+ if (cached) return cached;
+
+ const bundleName = /^(0[1-9]|1[0-2])\d+/.test(id) ? id.substring(0, 2) : 'misc';
+ const tryFetch = async (lang: string): Promise>> | null> => {
+ const url = `/data/${lang}/lives/${bundleName}.json`;
+ if (this.notFound.has(url)) return null;
+ const resp = await fetch(url);
+ if (!resp.ok) { this.notFound.add(url); return null; }
+ const ct = resp.headers.get('content-type');
+ if (ct && ct.includes('text/html')) { this.notFound.add(url); return null; }
+ const bundle = await resp.json() as Record>;
+ const entry = bundle[id] as { services?: Record>> } | undefined;
+ return entry?.services || null;
+ };
+
+ let result = await tryFetch(this.serviceLang);
+ if (!result && this.serviceLang !== 'cu') result = await tryFetch('cu');
+ if (!result) result = await tryFetch('shared');
+ if (result) this.livesCache.set(cacheKey, result);
+ return result;
+ }
+
+ private async fetchCommandText(name: string): Promise {
+ const cacheKey = `${this.language}/Command/${name}`;
+ const cached = this.commandCache.get(cacheKey);
+ if (cached) return cached;
+
+ const tryFetch = async (lang: string): Promise => {
+ const url = `/data/${lang}/services/Command/${name}.json`;
+ if (this.notFound.has(url)) return null;
+ const resp = await fetch(url);
+ if (!resp.ok) { this.notFound.add(url); return null; }
+ const ct = resp.headers.get('content-type');
+ if (ct && ct.includes('text/html')) { this.notFound.add(url); return null; }
+ const json = await resp.json() as Record[];
+ return json
+ .filter(n => n.type === 'TEXT')
+ .map(n => String(n.value ?? ''))
+ .filter(Boolean)
+ .join(' ') || null;
+ };
+
+ let result = await tryFetch(this.serviceLang);
+ if (!result && this.serviceLang !== 'cu') result = await tryFetch('cu');
+ if (!result) result = await tryFetch('shared');
+ if (result) this.commandCache.set(cacheKey, result);
+ return result || '';
+ }
+
+ private async resolveTimes(times: number): Promise {
+ const cacheKey = this.language;
+ let rules = this.timesCache.get(cacheKey);
+ if (!rules) {
+ const tryFetch = async (lang: string): Promise<{ cmd: string; value: string }[] | null> => {
+ const url = `/data/${lang}/commands/Times.json`;
+ if (this.notFound.has(url)) return null;
+ const resp = await fetch(url);
+ if (!resp.ok) { this.notFound.add(url); return null; }
+ const ct = resp.headers.get('content-type');
+ if (ct && ct.includes('text/html')) { this.notFound.add(url); return null; }
+ const json = await resp.json() as { DATA: { TIMES: { '@_Value': string; '@_Cmd': string }[] } };
+ const arr = json?.DATA?.TIMES;
+ if (!Array.isArray(arr)) return null;
+ return arr.map(r => ({ value: r['@_Value'], cmd: r['@_Cmd'] }));
+ };
+ let loaded = await tryFetch(this.serviceLang);
+ if (!loaded && this.serviceLang !== 'cu') loaded = await tryFetch('cu');
+ if (!loaded) loaded = await tryFetch('shared');
+ if (loaded) {
+ this.timesCache.set(cacheKey, loaded);
+ rules = loaded;
+ }
+ }
+
+ if (!rules) return `${times}×`;
+
+ for (const rule of rules) {
+ if (rule.cmd) {
+ if (evalBool(rule.cmd, { Times: times } as EvalContext)) {
+ return rule.value.replace('^#', String(times));
+ }
+ } else {
+ return rule.value.replace('^#', String(times));
+ }
+ }
+ return `${times}×`;
+ }
+
+ private createFetchText() {
+ return async (path: string): Promise => {
+ const wantsHeader = path.endsWith('.header');
+ const basePath = wantsHeader ? path.slice(0, -'.header'.length) : path;
+
+ const resolvePath = (lang: string): string | null => {
+ let fetchPath = basePath;
+ if (fetchPath.startsWith('Services/')) {
+ fetchPath = fetchPath.replace('Services/', '').replace('.xml', '.json');
+ }
+ return `/data/${lang}/services/${fetchPath}`;
+ };
+
+ const fetchJson = async (url: string): Promise[] | null> => {
+ if (this.notFound.has(url)) return null;
+ const cached = this.jsonCache.get(url);
+ if (cached) return cached;
+ try {
+ const resp = await fetch(url);
+ if (!resp.ok) { this.notFound.add(url); return null; }
+ const ct = resp.headers.get('content-type');
+ if (ct && ct.includes('text/html')) { this.notFound.add(url); return null; }
+ const json = JSON.parse(await resp.text());
+ if (Array.isArray(json)) {
+ this.jsonCache.set(url, json);
+ return json;
+ }
+ this.notFound.add(url);
+ return null;
+ } catch {
+ this.notFound.add(url);
+ return null;
+ }
+ };
+
+ const filterNodes = (json: Record[], header: boolean): string | null => {
+ if (header) {
+ return json
+ .filter(n => n.type === 'HEADER')
+ .map(n => String(n.value ?? ''))
+ .filter(Boolean)
+ .join(' ') || null;
+ }
+ return json
+ .filter(n => n.type === 'TEXT')
+ .map(n => String(n.value ?? '').replace(/\n/g, ' '))
+ .filter(Boolean)
+ .join('
') || null;
+ };
+
+ try {
+ let url = resolvePath(this.serviceLang);
+ let json = url ? await fetchJson(url) : null;
+ if (!json && this.serviceLang !== 'cu') {
+ url = resolvePath('cu');
+ json = url ? await fetchJson(url) : null;
+ }
+ if (!json) {
+ url = resolvePath('shared');
+ json = url ? await fetchJson(url) : null;
+ }
+ return json ? filterNodes(json, wantsHeader) : null;
+ } catch (err) {
+ console.error('Service fetchText error:', err);
+ return null;
+ }
+ };
+ }
+
+ private async fetchServiceNodes(path: string): Promise {
+ const resolvePath = (lang: string): string | null => {
+ let fetchPath = path;
+ if (fetchPath.startsWith('Services/')) {
+ fetchPath = fetchPath.replace('Services/', '').replace('.xml', '.json');
+ }
+ return `/data/${lang}/services/${fetchPath}`;
+ };
+ const tryFetch = async (url: string): Promise => {
+ if (this.notFound.has(url)) return null;
+ const cached = this.nodeCache.get(url);
+ if (cached) return cached;
+ try {
+ const resp = await fetch(url);
+ if (!resp.ok) { this.notFound.add(url); return null; }
+ const ct = resp.headers.get('content-type') || '';
+ if (ct.includes('text/html')) { this.notFound.add(url); return null; }
+ const nodes = await resp.json();
+ this.nodeCache.set(url, nodes);
+ return nodes;
+ } catch { this.notFound.add(url); return null; }
+ };
+ let url = resolvePath(this.serviceLang);
+ if (url) {
+ const result = await tryFetch(url);
+ if (result) return result;
+ }
+ if (this.serviceLang !== 'cu') {
+ const fbUrl = resolvePath('cu');
+ if (fbUrl) {
+ const result = await tryFetch(fbUrl);
+ if (result) return result;
+ }
+ }
+ const sharedUrl = resolvePath('shared');
+ if (sharedUrl) {
+ return await tryFetch(sharedUrl);
+ }
+ return null;
+ }
+}
diff --git a/src/ui/settings-view.ts b/src/ui/settings-view.ts
new file mode 100644
index 0000000..0f5e4d0
--- /dev/null
+++ b/src/ui/settings-view.ts
@@ -0,0 +1,307 @@
+/**
+ * Settings view — language, font, and display preferences.
+ */
+
+import { getTranslations, type LanguageCode } from '../core/i18n';
+
+const LANGUAGES = [
+ { code: 'en', name: 'English', local: 'English' },
+ { code: 'ru', name: 'Russian', local: 'Русский' },
+ { code: 'cu', name: 'Church Slavonic', local: 'Церковнославянский' },
+];
+
+const CU_FONTS = [
+ { id: '', name: 'System Default' },
+ { id: 'Ponomar', name: 'Ponomar' },
+ { id: 'Fedorovsk', name: 'Fedorovsk' },
+ { id: 'Menaion', name: 'Menaion' },
+ { id: 'Monomakh', name: 'Monomakh' },
+ { id: 'Triodion', name: 'Triodion' },
+ { id: 'Vilnius', name: 'Vilnius' },
+ { id: 'Voskresensky', name: 'Voskresensky' },
+];
+
+const SETTINGS_KEY = 'ponomar-settings';
+
+interface Settings {
+ language: string;
+ cuFont: string;
+ fontSize: number;
+ defaultBibleVersion: string;
+ showVerseNumbers: boolean;
+ verseNewLine: boolean;
+ calendarType: 'julian' | 'gregorian';
+ serviceRole: 'priest' | 'reader' | 'auto';
+}
+
+const DEFAULT_SETTINGS: Settings = {
+ language: 'en',
+ cuFont: 'Ponomar',
+ fontSize: 16,
+ defaultBibleVersion: 'kjv',
+ showVerseNumbers: true,
+ verseNewLine: false,
+ calendarType: 'julian',
+ serviceRole: 'priest',
+};
+
+function detectBrowserLanguage(): LanguageCode {
+ const lang = navigator.language || (navigator as any).userLanguage || '';
+ if (lang.startsWith('ru')) return 'ru';
+ if (lang.startsWith('cu')) return 'cu';
+ return 'en';
+}
+
+function loadSettings(): Settings {
+ try {
+ const stored = localStorage.getItem(SETTINGS_KEY);
+ if (stored) {
+ const parsed = JSON.parse(stored);
+ // Migrate old usePonomarFont to cuFont
+ if (parsed.usePonomarFont !== undefined) {
+ parsed.cuFont = parsed.usePonomarFont ? 'Ponomar' : '';
+ delete parsed.usePonomarFont;
+ }
+ return { ...DEFAULT_SETTINGS, ...parsed };
+ }
+ } catch { /* ignore */ }
+ return { ...DEFAULT_SETTINGS, language: detectBrowserLanguage() };
+}
+
+function saveSettings(settings: Settings) {
+ localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings));
+}
+
+function fontClass(fontName: string): string {
+ if (!fontName) return 'font-system-slavonic';
+ const lower = fontName.toLowerCase();
+ const map: Record = {
+ ponomar: 'font-ponomar',
+ fedorovsk: 'font-fedrovsk',
+ indiction: 'font-indiction',
+ menaion: 'font-menaion',
+ monomakh: 'font-monomakh',
+ triodion: 'font-triodion',
+ vilnius: 'font-vilnius',
+ voskresensky: 'font-voskresensky',
+ };
+ return map[lower] || 'font-slavonic';
+}
+
+export { loadSettings, saveSettings, fontClass };
+export type { Settings };
+
+export class SettingsView {
+ private container: HTMLElement;
+ private settings: Settings;
+ private onLanguageChange?: () => void;
+
+ constructor(container: HTMLElement, onLanguageChange?: () => void) {
+ this.container = container;
+ this.settings = loadSettings();
+ this.onLanguageChange = onLanguageChange;
+ }
+
+ render() {
+ const t = getTranslations(this.settings.language as LanguageCode);
+
+ this.container.innerHTML = `
+
+
${t.settings.title}
+
+
+
+
+
+
+
+
+
+
${t.settings.fontSettings}
+
+
+
+
+
+
+
${t.settings.fontPreview}
+
+ Сохрани́ мѧ, гдⷭ҇и, ꙗ҆́кѡ на тѧ̀ ᲂу҆пова́хъ
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
${t.settings.bibleSettings}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `;
+
+ // Event listeners
+ document.getElementById('language')?.addEventListener('change', (e) => {
+ this.settings.language = (e.target as HTMLSelectElement).value;
+ saveSettings(this.settings);
+ this.onLanguageChange?.();
+ });
+
+ document.getElementById('cu-font')?.addEventListener('change', (e) => {
+ this.settings.cuFont = (e.target as HTMLSelectElement).value;
+ saveSettings(this.settings);
+ this.updateFontPreview();
+ });
+
+ document.getElementById('font-size')?.addEventListener('input', (e) => {
+ this.settings.fontSize = parseInt((e.target as HTMLInputElement).value, 10);
+ document.getElementById('font-size-value')!.textContent = `${this.settings.fontSize}px`;
+ document.documentElement.style.setProperty('--liturgical-font-size', this.settings.fontSize + 'px');
+ saveSettings(this.settings);
+ });
+
+ document.getElementById('default-bible')?.addEventListener('change', (e) => {
+ this.settings.defaultBibleVersion = (e.target as HTMLSelectElement).value;
+ saveSettings(this.settings);
+ });
+
+ document.getElementById('show-verses')?.addEventListener('change', (e) => {
+ this.settings.showVerseNumbers = (e.target as HTMLInputElement).checked;
+ saveSettings(this.settings);
+ });
+
+ document.getElementById('verse-newline')?.addEventListener('change', (e) => {
+ this.settings.verseNewLine = (e.target as HTMLInputElement).checked;
+ saveSettings(this.settings);
+ });
+
+ document.querySelectorAll('input[name="calendar-type"]').forEach(el => {
+ el.addEventListener('change', (e) => {
+ this.settings.calendarType = (e.target as HTMLInputElement).value as 'julian' | 'gregorian';
+ saveSettings(this.settings);
+ });
+ });
+ document.getElementById('install-pwa')?.addEventListener('click', async () => {
+ const prompt = (window as any).__deferredPrompt;
+ if (prompt) {
+ prompt.prompt();
+ await prompt.userChoice;
+ (window as any).__deferredPrompt = null;
+ document.getElementById('install-pwa')?.remove();
+ }
+ });
+ this.updateFontPreview();
+ }
+
+ private updateFontPreview() {
+ const preview = document.getElementById('font-preview');
+ if (preview) {
+ preview.className = `${fontClass(this.settings.cuFont)} text-lg`;
+ }
+ }
+}
diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts
new file mode 100644
index 0000000..11f02fe
--- /dev/null
+++ b/src/vite-env.d.ts
@@ -0,0 +1 @@
+///
diff --git a/static/OrthodoxCross.svg b/static/OrthodoxCross.svg
new file mode 100644
index 0000000..087c9c9
--- /dev/null
+++ b/static/OrthodoxCross.svg
@@ -0,0 +1,43 @@
+
+
+
+
diff --git a/static/fonts/Fedorovsk-Regular.woff b/static/fonts/Fedorovsk-Regular.woff
new file mode 100644
index 0000000..28d1336
Binary files /dev/null and b/static/fonts/Fedorovsk-Regular.woff differ
diff --git a/static/fonts/Indiction-Regular.woff b/static/fonts/Indiction-Regular.woff
new file mode 100644
index 0000000..e2a4143
Binary files /dev/null and b/static/fonts/Indiction-Regular.woff differ
diff --git a/static/fonts/Menaion-Regular.woff b/static/fonts/Menaion-Regular.woff
new file mode 100644
index 0000000..4365f75
Binary files /dev/null and b/static/fonts/Menaion-Regular.woff differ
diff --git a/static/fonts/Monomakh-Regular.woff b/static/fonts/Monomakh-Regular.woff
new file mode 100644
index 0000000..c79c2da
Binary files /dev/null and b/static/fonts/Monomakh-Regular.woff differ
diff --git a/static/fonts/Ponomar-Regular.woff b/static/fonts/Ponomar-Regular.woff
new file mode 100644
index 0000000..f4f1289
Binary files /dev/null and b/static/fonts/Ponomar-Regular.woff differ
diff --git a/static/fonts/Triodion-Regular.woff b/static/fonts/Triodion-Regular.woff
new file mode 100644
index 0000000..c68b85b
Binary files /dev/null and b/static/fonts/Triodion-Regular.woff differ
diff --git a/static/fonts/Vilnius-Regular.woff b/static/fonts/Vilnius-Regular.woff
new file mode 100644
index 0000000..a411144
Binary files /dev/null and b/static/fonts/Vilnius-Regular.woff differ
diff --git a/static/fonts/Voskresensky-Regular.woff b/static/fonts/Voskresensky-Regular.woff
new file mode 100644
index 0000000..fd3547c
Binary files /dev/null and b/static/fonts/Voskresensky-Regular.woff differ
diff --git a/static/icons/icon-192.png b/static/icons/icon-192.png
new file mode 100644
index 0000000..ee84491
Binary files /dev/null and b/static/icons/icon-192.png differ
diff --git a/static/icons/icon-512.png b/static/icons/icon-512.png
new file mode 100644
index 0000000..7b46ecd
Binary files /dev/null and b/static/icons/icon-512.png differ
diff --git a/static/manifest.json b/static/manifest.json
new file mode 100644
index 0000000..43c297b
--- /dev/null
+++ b/static/manifest.json
@@ -0,0 +1,22 @@
+{
+ "name": "Ponomar — Orthodox Calendar",
+ "short_name": "Ponomar",
+ "description": "Orthodox Church calendar and liturgical information",
+ "start_url": "/",
+ "display": "standalone",
+ "background_color": "#f5f0e8",
+ "theme_color": "#1a1a2e",
+ "orientation": "portrait",
+ "icons": [
+ {
+ "src": "/icons/icon-192.png",
+ "sizes": "192x192",
+ "type": "image/png"
+ },
+ {
+ "src": "/icons/icon-512.png",
+ "sizes": "512x512",
+ "type": "image/png"
+ }
+ ]
+}
diff --git a/static/sw.js b/static/sw.js
new file mode 100644
index 0000000..ec214b9
--- /dev/null
+++ b/static/sw.js
@@ -0,0 +1,16 @@
+const CACHE = 'ponomar-v1';
+
+self.addEventListener('install', (e) => {
+ e.waitUntil(caches.open(CACHE).then(c => c.addAll(['/index.html'])));
+ self.skipWaiting();
+});
+
+self.addEventListener('activate', (e) => {
+ e.waitUntil(clients.claim());
+});
+
+self.addEventListener('fetch', (e) => {
+ e.respondWith(
+ caches.match(e.request).then(r => r || fetch(e.request))
+ );
+});
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 0000000..021c290
--- /dev/null
+++ b/tsconfig.json
@@ -0,0 +1,25 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noFallthroughCasesInSwitch": true,
+ "isolatedModules": true,
+ "esModuleInterop": true,
+ "skipLibCheck": true,
+ "forceConsistentCasingInFileNames": true,
+ "resolveJsonModule": true,
+ "declaration": true,
+ "declarationMap": true,
+ "sourceMap": true,
+ "outDir": "./dist",
+ "rootDir": "./src",
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
+ "types": []
+ },
+ "include": ["src/**/*.ts", "src/**/*.css"],
+ "exclude": ["node_modules", "dist", "scripts"]
+}
diff --git a/vite.config.ts b/vite.config.ts
new file mode 100644
index 0000000..b43d6fc
--- /dev/null
+++ b/vite.config.ts
@@ -0,0 +1,16 @@
+import { defineConfig } from 'vite';
+import tailwindcss from '@tailwindcss/vite';
+import { resolve } from 'path';
+
+export default defineConfig({
+ plugins: [tailwindcss()],
+ server: {
+ port: 5173,
+ strictPort: false
+ },
+ build: {
+ outDir: 'dist',
+ sourcemap: true
+ },
+ publicDir: 'static'
+});