also looking at this
Harden AnalyticsHQ release and dashboard quality
#52
34 files
+1236
-340
| @@ -1,250 +1,146 @@ | ||
| 1 | api: | |
| 2 | - 'storage/framework/api/**' | |
| 1 | # actions/labeler@v6 format (structured match rules). | |
| 2 | # Scoped to paths owned by these applications. | |
| 3 | 3 | |
| 4 | deps: | |
| 5 | - ./package.json | |
| 6 | ||
| 7 | core: | |
| 8 | - 'storage/framework/core/**' | |
| 9 | ||
| 10 | ide: | |
| 11 | - 'storage/framework/defaults/ide/**' | |
| 12 | ||
| 13 | jetbrains: | |
| 14 | - 'storage/framework/defaults/ide/jetbrains/**' | |
| 15 | ||
| 16 | vscode: | |
| 17 | - 'storage/framework/defaults/ide/vscode/**' | |
| 18 | ||
| 19 | libs: | |
| 20 | - 'storage/framework/libs/**' | |
| 21 | ||
| 22 | components: | |
| 23 | - 'storage/framework/libs/components/**' | |
| 24 | - resources/components/** | |
| 25 | ||
| 26 | entries: | |
| 27 | - storage/framework/libs/entries/** | |
| 28 | ||
| 29 | examples: | |
| 30 | - storage/framework/libs/examples/** | |
| 31 | ||
| 32 | functions: | |
| 33 | - 'storage/framework/libs/functions/**' | |
| 34 | - resources/functions/** | |
| 35 | ||
| 36 | scripts: | |
| 37 | - ./storage/framework/scripts/** | |
| 38 | ||
| 39 | stack: | |
| 40 | - 'storage/framework/stacks/**' | |
| 41 | ||
| 42 | dashboard: | |
| 43 | - 'storage/framework/stacks/dashboard/**' | |
| 44 | ||
| 45 | vcs: | |
| 46 | - ./storage/framework/core/vcs/** | |
| 47 | ||
| 48 | github: | |
| 49 | - ./storage/framework/core/vcs/github/** | |
| 50 | ||
| 51 | views: | |
| 52 | - 'storage/framework/core/views/**' | |
| 53 | - resources/views/** | |
| 54 | ||
| 55 | web: | |
| 56 | - ./storage/framework/core/views/web/** | |
| 4 | app: | |
| 5 | - changed-files: | |
| 6 | - any-glob-to-any-file: | |
| 7 | - 'app/**' | |
| 57 | 8 | |
| 58 | 9 | actions: |
| 59 | - 'storage/framework/core/actions/**' | |
| 60 | ||
| 61 | ai: | |
| 62 | - 'storage/framework/core/ai/**' | |
| 63 | ||
| 64 | alias: | |
| 65 | - 'storage/framework/core/alias/**' | |
| 66 | ||
| 67 | analytics: | |
| 68 | - 'storage/framework/core/analytics/**' | |
| 69 | ||
| 70 | arrays: | |
| 71 | - 'storage/framework/core/utils/arrays/**' | |
| 72 | ||
| 73 | auth: | |
| 74 | - 'storage/framework/core/auth/**' | |
| 75 | ||
| 76 | buddy: | |
| 77 | - 'storage/framework/core/buddy/**' | |
| 78 | ||
| 79 | build: | |
| 80 | - 'storage/framework/core/build/**' | |
| 81 | ||
| 82 | cache: | |
| 83 | - 'storage/framework/core/cache/**' | |
| 84 | ||
| 85 | chat: | |
| 86 | - 'storage/framework/core/notifications/chat/**' | |
| 87 | ||
| 88 | cli: | |
| 89 | - 'storage/framework/core/cli/**' | |
| 90 | - app/Commands/** | |
| 91 | ||
| 92 | cloud: | |
| 93 | - 'storage/framework/core/cloud/**' | |
| 94 | ||
| 95 | collections: | |
| 96 | - 'storage/framework/core/utils/collections/**' | |
| 97 | ||
| 98 | config: | |
| 99 | - 'storage/framework/core/config/**' | |
| 100 | - config/** | |
| 101 | ||
| 102 | database: | |
| 103 | - 'storage/framework/core/database/**' | |
| 104 | ||
| 105 | datetime: | |
| 106 | - 'storage/framework/core/utils/datetime/**' | |
| 107 | ||
| 108 | desktop: | |
| 109 | - 'storage/framework/core/desktop/**' | |
| 110 | - ./storage/framework/core/views/desktop/** | |
| 111 | ||
| 112 | development: | |
| 113 | - 'storage/framework/core/development/**' | |
| 114 | ||
| 115 | dns: | |
| 116 | - 'storage/framework/core/dns/**' | |
| 117 | ||
| 118 | docs: | |
| 119 | - 'storage/framework/core/docs/**' | |
| 120 | - 'storage/framework/docs/**' | |
| 121 | - docs/** | |
| 122 | - config/docs.ts | |
| 10 | - changed-files: | |
| 11 | - any-glob-to-any-file: | |
| 12 | - 'app/Actions/**' | |
| 123 | 13 | |
| 124 | email: | |
| 125 | - 'storage/framework/core/notifications/email/**' | |
| 126 | ||
| 127 | error-handling: | |
| 128 | - 'storage/framework/core/error-handling/**' | |
| 14 | commands: | |
| 15 | - changed-files: | |
| 16 | - any-glob-to-any-file: | |
| 17 | - 'app/Commands/**' | |
| 129 | 18 | |
| 130 | 19 | events: |
| 131 | - 'storage/framework/core/events/**' | |
| 132 | - app/Events/** | |
| 133 | ||
| 134 | faker: | |
| 135 | - 'storage/framework/core/faker/**' | |
| 136 | ||
| 137 | git: | |
| 138 | - 'storage/framework/core/utils/git/**' | |
| 20 | - changed-files: | |
| 21 | - any-glob-to-any-file: | |
| 22 | - 'app/Events/**' | |
| 23 | - 'app/Listeners/**' | |
| 24 | - 'app/Events.ts' | |
| 25 | - 'app/Listener.ts' | |
| 139 | 26 | |
| 140 | health: | |
| 141 | - 'storage/framework/core/health/**' | |
| 27 | jobs: | |
| 28 | - changed-files: | |
| 29 | - any-glob-to-any-file: | |
| 30 | - 'app/Jobs/**' | |
| 142 | 31 | |
| 143 | lint: | |
| 144 | - 'storage/framework/core/lint/**' | |
| 32 | mail: | |
| 33 | - changed-files: | |
| 34 | - any-glob-to-any-file: | |
| 35 | - 'app/Mail/**' | |
| 145 | 36 | |
| 146 | logging: | |
| 147 | - 'storage/framework/core/logging/**' | |
| 37 | middleware: | |
| 38 | - changed-files: | |
| 39 | - any-glob-to-any-file: | |
| 40 | - 'app/Middleware/**' | |
| 41 | - 'app/Middleware.ts' | |
| 148 | 42 | |
| 149 | modules: | |
| 150 | - 'storage/framework/core/modules/**' | |
| 43 | models: | |
| 44 | - changed-files: | |
| 45 | - any-glob-to-any-file: | |
| 46 | - 'app/Models/**' | |
| 151 | 47 | |
| 152 | 48 | notifications: |
| 153 | - 'storage/framework/core/notifications/**' | |
| 154 | - app/Notifications/** | |
| 155 | ||
| 156 | objects: | |
| 157 | - 'storage/framework/core/utils/objects/**' | |
| 158 | ||
| 159 | orm: | |
| 160 | - 'storage/framework/core/orm/**' | |
| 161 | ||
| 162 | path: | |
| 163 | - 'storage/framework/core/path/**' | |
| 164 | ||
| 165 | payments: | |
| 166 | - 'storage/framework/core/payments/**' | |
| 167 | ||
| 168 | push: | |
| 169 | - 'storage/framework/core/notifications/push/**' | |
| 170 | ||
| 171 | query-builder: | |
| 172 | - 'storage/framework/core/query-builder/**' | |
| 173 | ||
| 174 | queue: | |
| 175 | - 'storage/framework/core/queue/**' | |
| 176 | ||
| 177 | real-time: | |
| 178 | - 'storage/framework/core/real-time/**' | |
| 179 | ||
| 180 | repl: | |
| 181 | - 'storage/framework/core/repl/**' | |
| 182 | ||
| 183 | router: | |
| 184 | - 'storage/framework/core/router/**' | |
| 185 | ||
| 186 | scheduler: | |
| 187 | - 'storage/framework/core/scheduler/**' | |
| 188 | ||
| 189 | search-engine: | |
| 190 | - 'storage/framework/core/search-engine/**' | |
| 191 | ||
| 192 | security: | |
| 193 | - 'storage/framework/core/security/**' | |
| 194 | ||
| 195 | signals: | |
| 196 | - 'storage/framework/core/signals/**' | |
| 197 | ||
| 198 | slug: | |
| 199 | - 'storage/framework/core/slug/**' | |
| 200 | ||
| 201 | sms: | |
| 202 | - 'storage/framework/core/notifications/sms/**' | |
| 203 | ||
| 204 | storage: | |
| 205 | - storage/** | |
| 49 | - changed-files: | |
| 50 | - any-glob-to-any-file: | |
| 51 | - 'app/Notifications/**' | |
| 206 | 52 | |
| 207 | strings: | |
| 208 | - 'storage/framework/core/utils/strings/**' | |
| 209 | ||
| 210 | testing: | |
| 211 | - 'storage/framework/core/testing/**' | |
| 212 | ||
| 213 | tinker: | |
| 214 | - 'storage/framework/core/tinker/**' | |
| 215 | ||
| 216 | types: | |
| 217 | - 'storage/framework/core/types/**' | |
| 53 | config: | |
| 54 | - changed-files: | |
| 55 | - any-glob-to-any-file: | |
| 56 | - 'config/**' | |
| 218 | 57 | |
| 219 | ui: | |
| 220 | - 'storage/framework/core/ui/**' | |
| 58 | database: | |
| 59 | - changed-files: | |
| 60 | - any-glob-to-any-file: | |
| 61 | - 'database/**' | |
| 221 | 62 | |
| 222 | utils: | |
| 223 | - 'storage/framework/core/utils/**' | |
| 63 | migrations: | |
| 64 | - changed-files: | |
| 65 | - any-glob-to-any-file: | |
| 66 | - 'database/migrations/**' | |
| 224 | 67 | |
| 225 | validation: | |
| 226 | - 'storage/framework/core/validation/**' | |
| 68 | resources: | |
| 69 | - changed-files: | |
| 70 | - any-glob-to-any-file: | |
| 71 | - 'resources/**' | |
| 227 | 72 | |
| 228 | app: | |
| 229 | - app/** | |
| 73 | components: | |
| 74 | - changed-files: | |
| 75 | - any-glob-to-any-file: | |
| 76 | - 'resources/components/**' | |
| 230 | 77 | |
| 231 | jobs: | |
| 232 | - app/Jobs/** | |
| 78 | views: | |
| 79 | - changed-files: | |
| 80 | - any-glob-to-any-file: | |
| 81 | - 'resources/views/**' | |
| 82 | - 'resources/layouts/**' | |
| 83 | - 'resources/partials/**' | |
| 233 | 84 | |
| 234 | models: | |
| 235 | - app/Models/** | |
| 85 | functions: | |
| 86 | - changed-files: | |
| 87 | - any-glob-to-any-file: | |
| 88 | - 'resources/functions/**' | |
| 236 | 89 | |
| 237 | resources: | |
| 238 | - resources/** | |
| 90 | stores: | |
| 91 | - changed-files: | |
| 92 | - any-glob-to-any-file: | |
| 93 | - 'resources/stores/**' | |
| 239 | 94 | |
| 240 | 95 | lang: |
| 241 | - resources/lang/** | |
| 242 | ||
| 243 | stores: | |
| 244 | - resources/stores/** | |
| 96 | - changed-files: | |
| 97 | - any-glob-to-any-file: | |
| 98 | - 'resources/lang/**' | |
| 99 | - 'locales/**' | |
| 245 | 100 | |
| 246 | 101 | routes: |
| 247 | - routes/** | |
| 102 | - changed-files: | |
| 103 | - any-glob-to-any-file: | |
| 104 | - 'routes/**' | |
| 105 | - 'app/Routes.ts' | |
| 248 | 106 | |
| 249 | 107 | tests: |
| 250 | - tests/** | |
| 108 | - changed-files: | |
| 109 | - any-glob-to-any-file: | |
| 110 | - 'tests/**' | |
| 111 | ||
| 112 | docs: | |
| 113 | - changed-files: | |
| 114 | - any-glob-to-any-file: | |
| 115 | - 'docs/**' | |
| 116 | - 'config/docs.ts' | |
| 117 | - '**/*.md' | |
| 118 | ||
| 119 | content: | |
| 120 | - changed-files: | |
| 121 | - any-glob-to-any-file: | |
| 122 | - 'content/**' | |
| 123 | ||
| 124 | public: | |
| 125 | - changed-files: | |
| 126 | - any-glob-to-any-file: | |
| 127 | - 'public/**' | |
| 128 | ||
| 129 | cloud: | |
| 130 | - changed-files: | |
| 131 | - any-glob-to-any-file: | |
| 132 | - 'cloud/**' | |
| 133 | - 'config/cloud.ts' | |
| 134 | ||
| 135 | deps: | |
| 136 | - changed-files: | |
| 137 | - any-glob-to-any-file: | |
| 138 | - 'package.json' | |
| 139 | - 'bun.lock' | |
| 140 | - 'deps.yml' | |
| 141 | - 'pantry.lock' | |
| 142 | ||
| 143 | ci: | |
| 144 | - changed-files: | |
| 145 | - any-glob-to-any-file: | |
| 146 | - '.github/**' | |
| @@ -201,7 +201,7 @@ jobs: | ||
| 201 | 201 | |
| 202 | 202 | deploy-prod: |
| 203 | 203 | if: github.event_name == 'push' && github.ref == 'refs/heads/main' && needs.release-check.outputs.contains_release == '0' |
| 204 | needs: [secrets, lint, typecheck, test, release-check] | |
| 204 | needs: [secrets, lint, typecheck, test, release-check, build] | |
| 205 | 205 | runs-on: ubuntu-latest |
| 206 | 206 | environment: production |
| 207 | 207 | |
| @@ -250,7 +250,7 @@ jobs: | ||
| 250 | 250 | |
| 251 | 251 | deploy-stage: |
| 252 | 252 | if: (github.event_name == 'push' && github.ref == 'refs/heads/main') || (startsWith(github.ref, 'refs/tags/') && contains(github.ref, '-beta.')) |
| 253 | needs: [secrets, lint, typecheck, test] | |
| 253 | needs: [secrets, lint, typecheck, test, build] | |
| 254 | 254 | runs-on: ubuntu-latest |
| 255 | 255 | environment: staging |
| 256 | 256 | |
| @@ -297,7 +297,7 @@ jobs: | ||
| 297 | 297 | |
| 298 | 298 | deploy-dev: |
| 299 | 299 | if: (github.event_name == 'push' && github.ref == 'refs/heads/main') || (github.event_name == 'pull_request' && github.base_ref == 'main') |
| 300 | needs: [secrets, lint, typecheck, test] | |
| 300 | needs: [secrets, lint, typecheck, test, build] | |
| 301 | 301 | runs-on: ubuntu-latest |
| 302 | 302 | environment: development |
| 303 | 303 | |
| @@ -342,8 +342,7 @@ jobs: | ||
| 342 | 342 | AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} |
| 343 | 343 | run: bun buddy deploy development --yes |
| 344 | 344 | |
| 345 | publish-commit: | |
| 346 | if: github.event_name == 'pull_request' | |
| 345 | build: | |
| 347 | 346 | runs-on: ubuntu-latest |
| 348 | 347 | |
| 349 | 348 | steps: |
| @@ -363,8 +362,23 @@ jobs: | ||
| 363 | 362 | - name: Install Dependencies |
| 364 | 363 | run: bun install |
| 365 | 364 | |
| 366 | - name: Build | |
| 365 | - name: Build application | |
| 367 | 366 | run: bun run build |
| 368 | 367 | |
| 369 | - name: Publish Commit | |
| 370 | run: ./storage/framework/scripts/publish-commit | |
| 368 | - name: Build documentation | |
| 369 | run: bun buddy build docs | |
| 370 | ||
| 371 | - name: Validate documentation | |
| 372 | run: bun run docs:validate | |
| 373 | ||
| 374 | - name: Validate UI accessibility | |
| 375 | run: bun run ui:validate | |
| 376 | ||
| 377 | - name: Validate responsive visual contracts | |
| 378 | run: bun run visual:validate | |
| 379 | ||
| 380 | - name: Validate production release artifact | |
| 381 | run: bun run release:validate | |
| 382 | ||
| 383 | - name: Verify generated files are current | |
| 384 | run: git diff --exit-code | |
| @@ -64,6 +64,12 @@ jobs: | ||
| 64 | 64 | - name: Install dependencies |
| 65 | 65 | run: bun install --frozen-lockfile |
| 66 | 66 | |
| 67 | - name: Build the production artifact | |
| 68 | run: bun run build | |
| 69 | ||
| 70 | - name: Validate the production artifact | |
| 71 | run: bun run release:validate | |
| 72 | ||
| 67 | 73 | # IP geolocation database (DB-IP Lite, CC BY 4.0). Fetched here rather than |
| 68 | 74 | # committed: an ~8MB binary on a monthly cadence does not belong in git |
| 69 | 75 | # history. It ships to the box with the rest of the working tree. |
| @@ -152,3 +158,7 @@ jobs: | ||
| 152 | 158 | PORKBUN_API_KEY: ${{ secrets.PORKBUN_API_KEY }} |
| 153 | 159 | PORKBUN_SECRET_KEY: ${{ secrets.PORKBUN_SECRET_KEY }} |
| 154 | 160 | COPYFILE_DISABLE: '1' |
| 161 | ||
| 162 | - name: Smoke test production | |
| 163 | if: needs.determine-environment.outputs.environment == 'production' | |
| 164 | run: bun run deployment:smoke | |
| @@ -0,0 +1,46 @@ | ||
| 1 | /** | |
| 2 | * Browser keepalive requests have a small total-body budget. Keeping event | |
| 3 | * properties below 48 KiB leaves room for the event envelope while allowing | |
| 4 | * long URLs and useful structured metadata. | |
| 5 | */ | |
| 6 | export const MAX_EVENT_PROPERTIES_BYTES = 48 * 1024 | |
| 7 | ||
| 8 | const URL_EVENTS = new Set(['Outbound Link', 'File Download']) | |
| 9 | ||
| 10 | function utf8Bytes(value: string): number { | |
| 11 | return new TextEncoder().encode(value).byteLength | |
| 12 | } | |
| 13 | ||
| 14 | /** | |
| 15 | * Serialize public event properties without allowing an unbounded database | |
| 16 | * value. Reserved URL events keep one canonical key so grouping is stable. | |
| 17 | * | |
| 18 | * Invalid or oversized properties are omitted, but the event itself can still | |
| 19 | * be recorded. The public collector must not become a 500 because optional | |
| 20 | * metadata was unusable. | |
| 21 | */ | |
| 22 | export function serializeEventProperties(event: unknown, properties: unknown): string | null { | |
| 23 | if (properties === null || properties === undefined) | |
| 24 | return null | |
| 25 | ||
| 26 | let normalized = properties | |
| 27 | if ( | |
| 28 | URL_EVENTS.has(String(event)) | |
| 29 | && typeof properties === 'object' | |
| 30 | && 'url' in properties | |
| 31 | && properties.url | |
| 32 | ) { | |
| 33 | normalized = { url: String(properties.url) } | |
| 34 | } | |
| 35 | ||
| 36 | try { | |
| 37 | const serialized = JSON.stringify(normalized) | |
| 38 | if (!serialized || utf8Bytes(serialized) > MAX_EVENT_PROPERTIES_BYTES) | |
| 39 | return null | |
| 40 | ||
| 41 | return serialized | |
| 42 | } | |
| 43 | catch { | |
| 44 | return null | |
| 45 | } | |
| 46 | } | |
| @@ -0,0 +1,5 @@ | ||
| 1 | -- Production analytics storage is PostgreSQL. SQLite already gives varchar | |
| 2 | -- columns unconstrained text affinity, so it needs no table rebuild. | |
| 3 | ALTER TABLE "custom_events" | |
| 4 | ALTER COLUMN "properties" TYPE text | |
| 5 | USING "properties"::text; | |
| @@ -31,6 +31,11 @@ | ||
| 31 | 31 | "dev:api": "./buddy dev:api", |
| 32 | 32 | "dev:full": "./buddy dev", |
| 33 | 33 | "build": "./buddy build", |
| 34 | "docs:validate": "bun scripts/validate-docs.ts", | |
| 35 | "ui:validate": "bun scripts/validate-ui.ts", | |
| 36 | "visual:validate": "bun scripts/validate-visual.ts", | |
| 37 | "release:validate": "bun scripts/validate-release.ts", | |
| 38 | "deployment:smoke": "bun scripts/smoke-deployment.ts", | |
| 34 | 39 | "prod": "./buddy build", |
| 35 | 40 | "deploy": "./buddy deploy", |
| 36 | 41 | "export:site": "bun scripts/analytics/export-site.ts", |
| @@ -34,18 +34,18 @@ | ||
| 34 | 34 | One accent (emerald), off-black/off-white, auto light+dark, radius lock 12px. */ |
| 35 | 35 | :root { |
| 36 | 36 | --bg: #fbfbfc; --panel: #ffffff; --border: rgba(15,23,42,0.08); |
| 37 | --text: #0b1220; --text-2: #4b5563; --text-3: #98a2b3; --accent: #059669; --bar: rgba(5,150,105,0.16); | |
| 37 | --text: #0b1220; --text-2: #4b5563; --text-3: #667085; --accent: #059669; --accent-ink: #04120d; --bar: rgba(5,150,105,0.16); | |
| 38 | 38 | --sans: 'Geist', ui-sans-serif, system-ui, sans-serif; |
| 39 | 39 | --mono: 'Geist Mono', ui-monospace, SFMono-Regular, Menlo, monospace; |
| 40 | 40 | } |
| 41 | 41 | @media (prefers-color-scheme: dark) { |
| 42 | 42 | :root { --bg: #080b12; --panel: #0f131c; --border: rgba(148,163,184,0.12); |
| 43 | --text: #f2f5f9; --text-2: #b0b9c8; --text-3: #667085; --accent: #34d399; --bar: rgba(52,211,153,0.18); } | |
| 43 | --text: #f2f5f9; --text-2: #b0b9c8; --text-3: #7b879a; --accent: #34d399; --accent-ink: #04120d; --bar: rgba(52,211,153,0.18); } | |
| 44 | 44 | } |
| 45 | 45 | :root[data-theme="dark"] { --bg: #080b12; --panel: #0f131c; --border: rgba(148,163,184,0.12); |
| 46 | --text: #f2f5f9; --text-2: #b0b9c8; --text-3: #667085; --accent: #34d399; --bar: rgba(52,211,153,0.18); } | |
| 46 | --text: #f2f5f9; --text-2: #b0b9c8; --text-3: #7b879a; --accent: #34d399; --accent-ink: #04120d; --bar: rgba(52,211,153,0.18); } | |
| 47 | 47 | :root[data-theme="light"] { --bg: #fbfbfc; --panel: #ffffff; --border: rgba(15,23,42,0.08); |
| 48 | --text: #0b1220; --text-2: #4b5563; --text-3: #98a2b3; --accent: #059669; --bar: rgba(5,150,105,0.16); } | |
| 48 | --text: #0b1220; --text-2: #4b5563; --text-3: #667085; --accent: #059669; --accent-ink: #04120d; --bar: rgba(5,150,105,0.16); } | |
| 49 | 49 | |
| 50 | 50 | body { background: var(--bg); color: var(--text); font-family: var(--sans); -webkit-font-smoothing: antialiased; } |
| 51 | 51 | .wordmark { font-weight: 700; letter-spacing: -0.03em; } |
| @@ -26,7 +26,7 @@ | ||
| 26 | 26 | :root { |
| 27 | 27 | color-scheme: dark; |
| 28 | 28 | --bg: #0a0c0e; --panel: #101317; --border: rgba(233,238,240,0.12); |
| 29 | --text: #eef2f3; --text-2: #9aa4a9; --text-3: #6d777c; | |
| 29 | --text: #eef2f3; --text-2: #9aa4a9; --text-3: #7b878c; | |
| 30 | 30 | --accent: #46d3c0; --accent-ink: #04201c; |
| 31 | 31 | --sans: 'Geist', ui-sans-serif, system-ui, sans-serif; --mono: 'Geist Mono', ui-monospace, monospace; |
| 32 | 32 | } |
| @@ -87,14 +87,14 @@ onMount(() => { | ||
| 87 | 87 | |
| 88 | 88 | {{-- Tokens, body and .wordmark now come from resources/layouts/app.stx, which carries |
| 89 | 89 | the superset of what this page and the dashboard each had. What stays is what only |
| 90 | this page uses. It is pushed rather than left loose because a loose <style> under a | |
| 90 | this page uses. It is pushed rather than left loose because a loose style block under a | |
| 91 | 91 | layout gets swept out of the page and appended after the content, in the body. --}} |
| 92 | 92 | @push('styles') |
| 93 | 93 | <style> |
| 94 | 94 | .card { background: var(--panel); border: 1px solid var(--border); border-radius: 14px; } |
| 95 | 95 | .btn { border-radius: 10px; transition: transform 0.12s ease, opacity 0.15s ease, background-color 0.15s ease; font-weight: 600; } |
| 96 | 96 | .btn:active { transform: translateY(1px); } |
| 97 | .btn-primary { background: var(--accent); color: #fff; } | |
| 97 | .btn-primary { background: var(--accent); color: var(--accent-ink); } | |
| 98 | 98 | .btn-primary:hover { background: color-mix(in srgb, var(--accent) 88%, #000); } |
| 99 | 99 | .btn-ghost { border: 1px solid var(--border); color: var(--text); } |
| 100 | 100 | .btn-ghost:hover { border-color: color-mix(in srgb, var(--accent) 45%, var(--border)); } |
| @@ -118,7 +118,7 @@ onMount(() => { | ||
| 118 | 118 | </div> |
| 119 | 119 | </header> |
| 120 | 120 | |
| 121 | {{-- A <div>, not <main>: the layout owns the single router container. Same classes. --}} | |
| 121 | {{-- A div rather than another main: the layout owns the single router container. Same classes. --}} | |
| 122 | 122 | <div class="mx-auto px-5 py-8 md:px-6 max-w-3xl"> |
| 123 | 123 | <h1 class="mb-6 text-2xl wordmark">Account</h1> |
| 124 | 124 | |
| @@ -280,7 +280,7 @@ const regionGeoAllowed = privacy.geo.granularity === 'region' | ||
| 280 | 280 | let shareMode = false |
| 281 | 281 | // The owner's existing share token for the active site ('' if none), so the owner |
| 282 | 282 | // UI can show/copy/revoke the link. Bridged into the client block. |
| 283 | let shareToken = '' | |
| 283 | let __shareToken = '' | |
| 284 | 284 | |
| 285 | 285 | // Race all DB work against a short timeout: if PostgreSQL isn't reachable, a |
| 286 | 286 | // TCP connect can hang far longer than a render should wait, so we bail to the |
| @@ -383,7 +383,7 @@ async function loadData() { | ||
| 383 | 383 | || null |
| 384 | 384 | // Owner (not a share viewer): surface any existing share token for the link UI. |
| 385 | 385 | if (!shareMode) |
| 386 | shareToken = (await readSettings(siteId)).share_token || '' | |
| 386 | __shareToken = (await readSettings(siteId)).share_token || '' | |
| 387 | 387 | |
| 388 | 388 | // Team: who can already reach this site, and who has been invited but has not |
| 389 | 389 | // accepted yet. Resolved server-side and rendered with @foreach so the panel |
| @@ -1125,7 +1125,11 @@ const exportPayload = { | ||
| 1125 | 1125 | * because the snippets are built from server config but can only be *finished* |
| 1126 | 1126 | * on the client, which is the one thing that knows the real origin. |
| 1127 | 1127 | */ |
| 1128 | defineClientPayload({ activeSiteId, countryTotals, exportPayload, installOptions, liveNow, range, shareToken }) | |
| 1128 | // This capability is intentionally available only to the authenticated owner | |
| 1129 | // UI so it can display the existing share link. The underlying server binding | |
| 1130 | // stays private and only this exact scalar crosses the bridge. | |
| 1131 | const shareCapability = __shareToken | |
| 1132 | defineClientPayload({ activeSiteId, countryTotals, exportPayload, installOptions, liveNow, range, shareCapability }) | |
| 1129 | 1133 | |
| 1130 | 1134 | useSeoMeta({ |
| 1131 | 1135 | title: "Dashboard - analyticshq", |
| @@ -1144,12 +1148,12 @@ useSeoMeta({ | ||
| 1144 | 1148 | const goalError = state('') |
| 1145 | 1149 | const goalBusy = state(false) |
| 1146 | 1150 | const isPro = state(false) |
| 1147 | // Share link: seeded from the server-bridged existing token (shareToken/activeSiteId | |
| 1151 | // Share link: seeded from the server-bridged existing capability and activeSiteId | |
| 1148 | 1152 | // are server consts inlined here by the data bridge), then create/revoke live. |
| 1149 | 1153 | // Built by shareLinkFor rather than by a second copy of the same template -- it is |
| 1150 | 1154 | // declared further down and reached by hoisting, which is the trade for having one |
| 1151 | 1155 | // place that decides what a share URL looks like. |
| 1152 | const shareUrl = state(shareToken ? shareLinkFor(shareToken) : '') | |
| 1156 | const shareUrl = state(shareCapability ? shareLinkFor(shareCapability) : '') | |
| 1153 | 1157 | const shareBusy = state(false) |
| 1154 | 1158 | const shareCopied = state(false) |
| 1155 | 1159 | // Live "current visitors": seeded from the server render, then polled so the |
| @@ -2051,7 +2055,7 @@ onMount(() => { | ||
| 2051 | 2055 | {{-- The token block, body and .wordmark that stood here were byte-identical to |
| 2052 | 2056 | account.stx's and now live once in resources/layouts/app.stx. What remains is what |
| 2053 | 2057 | only the dashboard uses. It is pushed rather than left loose: under a layout stx |
| 2054 | sweeps every orphan <style> out of the page and appends it AFTER the content, in the | |
| 2058 | sweeps every orphan style block out of the page and appends it AFTER the content, in the | |
| 2055 | 2059 | body, so a loose block would still apply but from the wrong place. --}} |
| 2056 | 2060 | @push('styles') |
| 2057 | 2061 | {{-- ts-maps' own stylesheet. Same-origin from /vendor like the engine beside it |
| @@ -2122,7 +2126,7 @@ onMount(() => { | ||
| 2122 | 2126 | .range-link:hover { color: var(--text); } |
| 2123 | 2127 | .range-link[aria-current="page"] { |
| 2124 | 2128 | background: var(--accent); |
| 2125 | color: #fff; | |
| 2129 | color: var(--accent-ink); | |
| 2126 | 2130 | } |
| 2127 | 2131 | .range-tool { |
| 2128 | 2132 | display: inline-flex; |
| @@ -2153,7 +2157,7 @@ summary:focus-visible { | ||
| 2153 | 2157 | outline: 2px solid var(--accent); |
| 2154 | 2158 | outline-offset: 2px; |
| 2155 | 2159 | } |
| 2156 | .range-tool.is-active { color: #fff; background: var(--accent); border-color: var(--accent); } | |
| 2160 | .range-tool.is-active { color: var(--accent-ink); background: var(--accent); border-color: var(--accent); } | |
| 2157 | 2161 | .range-tool i { width: 1rem; height: 1rem; } |
| 2158 | 2162 | .date-menu { position: relative; } |
| 2159 | 2163 | .date-menu > summary { list-style: none; } |
| @@ -2295,13 +2299,17 @@ select.panel:hover { border-color: var(--accent); } | ||
| 2295 | 2299 | .ga-dot-v { background: var(--accent); } |
| 2296 | 2300 | .ga-dot-u { background: var(--text-3); } |
| 2297 | 2301 | .ga-col:hover .ga-dot { opacity: 1; } |
| 2298 | .ga-tip { position: absolute; left: 50%; transform: translateX(-50%) translateY(calc(-100% - 12px)); min-width: 124px; padding: 8px 10px; border-radius: 9px; background: var(--panel); border: 1px solid var(--border); box-shadow: 0 8px 24px rgba(0,0,0,0.14); opacity: 0; visibility: hidden; transition: opacity 0.12s ease; pointer-events: none; z-index: 20; } | |
| 2299 | .ga-col:hover .ga-tip { opacity: 1; visibility: visible; } | |
| 2302 | .ga-tip { display: none; position: absolute; left: 50%; transform: translateX(-50%) translateY(calc(-100% - 12px)); min-width: 124px; padding: 8px 10px; border-radius: 9px; background: var(--panel); border: 1px solid var(--border); box-shadow: 0 8px 24px rgba(0,0,0,0.14); pointer-events: none; z-index: 20; } | |
| 2303 | .ga-col:hover .ga-tip { display: block; } | |
| 2300 | 2304 | .ga-tip-day { font-size: 11px; color: var(--text-2); margin-bottom: 5px; } |
| 2301 | 2305 | .ga-tip-row { display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--text-2); white-space: nowrap; line-height: 1.7; } |
| 2302 | 2306 | .ga-tip-row b { margin-left: auto; color: var(--text); font-weight: 600; } |
| 2303 | 2307 | .ga-sw { display: inline-block; width: 8px; height: 8px; border-radius: 2px; } |
| 2304 | @media (prefers-reduced-motion: reduce) { .ga-dot, .ga-tip { transition: none; } } | |
| 2308 | @media (max-width: 1023px) { | |
| 2309 | .ga-col:hover::before, | |
| 2310 | .ga-dot, | |
| 2311 | .ga-tip { display: none; } | |
| 2312 | } | |
| 2305 | 2313 | |
| 2306 | 2314 | /* Export CSV pill: hover tightens the border to the accent like the range pills. */ |
| 2307 | 2315 | #export-csv:hover { color: var(--text); border-color: var(--accent); } |
| @@ -2335,6 +2343,25 @@ select.panel:hover { border-color: var(--accent); } | ||
| 2335 | 2343 | .chrome-btn { display: inline-flex; align-items: center; justify-content: center; gap: 6px; padding: 7px 12px; border-radius: 9px; border: 1px solid var(--border); background: var(--panel); color: var(--text-2); font: 500 13px/1 var(--sans, ui-sans-serif); text-decoration: none; cursor: pointer; transition: color 0.15s ease, border-color 0.15s ease, background-color 0.15s ease; } |
| 2336 | 2344 | .chrome-btn:hover { color: var(--text); border-color: var(--accent); } |
| 2337 | 2345 | .chrome-icon { padding: 7px 10px; font-size: 14px; line-height: 1; } |
| 2346 | ||
| 2347 | @media (max-width: 767px) { | |
| 2348 | .site-switcher, | |
| 2349 | .range-tool, | |
| 2350 | .live-status, | |
| 2351 | .chrome-btn, | |
| 2352 | .range-link, | |
| 2353 | .ga-row, | |
| 2354 | .goal-form input, | |
| 2355 | .goal-form select, | |
| 2356 | .goal-form button { min-height: 2.75rem; } | |
| 2357 | ||
| 2358 | .site-switcher { height: 2.75rem; } | |
| 2359 | .range-link { display: inline-flex; align-items: center; justify-content: center; } | |
| 2360 | .range-tool { min-width: 2.75rem; } | |
| 2361 | .ga-row { align-items: center; } | |
| 2362 | .kpi-label { min-height: 2rem; } | |
| 2363 | .kpi-value { font-size: clamp(1.375rem, 7.25vw, 1.875rem); line-height: 1.2; white-space: nowrap; } | |
| 2364 | } | |
| 2338 | 2365 | /* One glyph at a time. `theme.isDark()` writes data-t, and these pick the matching |
| 2339 | 2366 | icon -- the same contract bughq's header uses, so the two toggles behave alike. */ |
| 2340 | 2367 | .theme-btn .ic-sun, .theme-btn .ic-moon { display: none; } |
| @@ -2343,9 +2370,9 @@ select.panel:hover { border-color: var(--accent); } | ||
| 2343 | 2370 | </style> |
| 2344 | 2371 | @endpush |
| 2345 | 2372 | |
| 2346 | {{-- A <div>, not <main>. The router container is the single <main> in | |
| 2347 | resources/layouts/app.stx: fragment extraction slices from the first <main> to the | |
| 2348 | LAST </main>, so a page that declares its own makes the router swap the wrong span. | |
| 2373 | {{-- A div rather than another main. The router container is owned by | |
| 2374 | resources/layouts/app.stx: fragment extraction uses that single container, | |
| 2375 | so a page that declares its own makes the router swap the wrong span. | |
| 2349 | 2376 | Same classes as before, one tag name different. --}} |
| 2350 | 2377 | @section('content') |
| 2351 | 2378 | <div class="px-4 py-7 md:px-8 lg:px-10 min-h-[100dvh]"> |
| @@ -2470,8 +2497,8 @@ select.panel:hover { border-color: var(--accent); } | ||
| 2470 | 2497 | <section class="grid grid-cols-2 lg:grid-cols-7 mb-6 border-border lg:divide-x panel reveal" aria-label="Key metrics"> |
| 2471 | 2498 | @foreach (kpiCards as kpi) |
| 2472 | 2499 | <div class="px-5 py-5 border-border"> |
| 2473 | <div class="mb-2 font-medium text-text-3 text-xs tracking-wider uppercase">{{ kpi.label }}</div> | |
| 2474 | <div class="font-semibold text-3xl tracking-tight mono">{{ kpi.value }}</div> | |
| 2500 | <div class="mb-2 font-medium text-text-3 text-xs tracking-wider uppercase kpi-label">{{ kpi.label }}</div> | |
| 2501 | <div class="font-semibold text-3xl tracking-tight kpi-value mono">{{ kpi.value }}</div> | |
| 2475 | 2502 | @if (kpi.delta !== null) |
| 2476 | 2503 | <div class="mt-1.5 font-medium text-xs mono" style="color: "> |
| 2477 | 2504 | {{ kpi.delta >= 0 ? '▲' : '▼' }} {{ Math.abs(kpi.delta) }}% <span class="text-text-3">vs prev</span> |
| @@ -2503,7 +2530,7 @@ select.panel:hover { border-color: var(--accent); } | ||
| 2503 | 2530 | |
| 2504 | 2531 | The tab strip is x-for over the server-built catalogue, and the body is |
| 2505 | 2532 | reactive because the origin can only be resolved in the browser — see the |
| 2506 | installCode() note in the client block. {{ }} HTML-escapes the snippet so | |
| 2533 | installCode() note in the client block. Server interpolation HTML-escapes the snippet so | |
| 2507 | 2534 | the tag shows as text rather than executing. --}} |
| 2508 | 2535 | @if (ownsActive) |
| 2509 | 2536 | <section class="border-border panel site-settings-panel"> |
| @@ -2514,7 +2541,7 @@ select.panel:hover { border-color: var(--accent); } | ||
| 2514 | 2541 | <div class="pb-5 px-5"> |
| 2515 | 2542 | {{-- Tabs and prose are SERVER-rendered (@foreach, static labels, server |
| 2516 | 2543 | mustaches) so this panel still says something useful before the |
| 2517 | bundle runs. An x-for with a reactive {{ }} label cloaked the whole | |
| 2544 | bundle runs. An x-for with a reactive text label cloaked the whole | |
| 2518 | 2545 | body — x-cloak is display:none, so a new user with nothing to look |
| 2519 | 2546 | at but install instructions got a blank box. Only the code block |
| 2520 | 2547 | below is reactive, and only because the origin lives in the |
| @@ -2527,9 +2554,9 @@ select.panel:hover { border-color: var(--accent); } | ||
| 2527 | 2554 | role="tab" |
| 2528 | 2555 | @click="installTab.set('')" |
| 2529 | 2556 | :style="installTab() === '' |
| 2530 | ? 'padding:5px 11px;border-radius:999px;border:1px solid var(--accent);background:var(--accent);color:#fff;font:600 12px/1 var(--sans,ui-sans-serif);cursor:pointer' | |
| 2557 | ? 'padding:5px 11px;border-radius:999px;border:1px solid var(--accent);background:var(--accent);color:var(--accent-ink);font:600 12px/1 var(--sans,ui-sans-serif);cursor:pointer' | |
| 2531 | 2558 | : 'padding:5px 11px;border-radius:999px;border:1px solid var(--border);background:transparent;color:var(--text-2);font:600 12px/1 var(--sans,ui-sans-serif);cursor:pointer'" |
| 2532 | style="padding:5px 11px;border-radius:999px;border:1px solid ;background:;color:;font:600 12px/1 var(--sans,ui-sans-serif);cursor:pointer" | |
| 2559 | style="padding:5px 11px;border-radius:999px;border:1px solid ;background:;color:;font:600 12px/1 var(--sans,ui-sans-serif);cursor:pointer" | |
| 2533 | 2560 | >{{ opt.label }}</button> |
| 2534 | 2561 | @endforeach |
| 2535 | 2562 | </div> |
| @@ -2553,7 +2580,7 @@ select.panel:hover { border-color: var(--accent); } | ||
| 2553 | 2580 | |
| 2554 | 2581 | Lists are SERVER-rendered with @foreach and server mustaches, for the |
| 2555 | 2582 | reason spelled out on the install panel above: an x-for whose rows carry |
| 2556 | reactive {{ }} makes stx cloak the container, and x-cloak is display:none. | |
| 2583 | reactive text makes stx cloak the container, and x-cloak is display:none. | |
| 2557 | 2584 | A mutation re-reads from the server rather than patching a reactive list |
| 2558 | 2585 | in place — invites are rare and correctness is worth more than avoiding a |
| 2559 | 2586 | reload here. |
| @@ -2611,21 +2638,22 @@ select.panel:hover { border-color: var(--accent); } | ||
| 2611 | 2638 | <input |
| 2612 | 2639 | type="email" |
| 2613 | 2640 | x-model="inviteEmail" |
| 2641 | aria-label="Teammate email" | |
| 2614 | 2642 | placeholder="teammate@example.com" |
| 2615 | 2643 | autocomplete="off" |
| 2616 | 2644 | style="flex:1 1 15rem;min-height:34px;padding:0 0.65rem;border:1px solid var(--border);border-radius:8px;background:var(--bg);color:var(--text);font:400 13px/1 var(--sans,ui-sans-serif)" |
| 2617 | 2645 | > |
| 2618 | <select x-model="inviteRole" style="min-height:34px;padding:0 0.5rem;border:1px solid var(--border);border-radius:8px;background:var(--bg);color:var(--text);font:400 13px/1 var(--sans,ui-sans-serif)"> | |
| 2646 | <select x-model="inviteRole" aria-label="Teammate role" style="min-height:34px;padding:0 0.5rem;border:1px solid var(--border);border-radius:8px;background:var(--bg);color:var(--text);font:400 13px/1 var(--sans,ui-sans-serif)"> | |
| 2619 | 2647 | <option value="viewer">can view</option> |
| 2620 | 2648 | <option value="admin">can manage</option> |
| 2621 | 2649 | </select> |
| 2622 | {{-- Static label. It was ``, and stx cloaks any element whose own text is | |
| 2650 | {{-- Static label. It used to choose between Sending and Send | |
| 2651 | invitation reactively, and stx cloaks any element whose own text is | |
| 2624 | 2652 | a reactive mustache — so the primary control on this panel was |
| 2625 | 2653 | display:none until the bundle ran. Busy state is carried by |
| 2626 | 2654 | :disabled, an ATTRIBUTE binding, which does not cloak, and by |
| 2627 | 2655 | the sibling below, which is supposed to start hidden. --}} |
| 2628 | <button type="button" @click="sendInvite()" :disabled="inviteBusy()" style="padding:7px 14px;border-radius:8px;border:1px solid var(--accent);background:var(--accent);color:#fff;font:600 13px/1 var(--sans,ui-sans-serif);cursor:pointer">Send invitation</button> | |
| 2656 | <button type="button" @click="sendInvite()" :disabled="inviteBusy()" style="padding:7px 14px;border-radius:8px;border:1px solid var(--accent);background:var(--accent);color:var(--accent-ink);font:600 13px/1 var(--sans,ui-sans-serif);cursor:pointer">Send invitation</button> | |
| 2629 | 2657 | <span :show="inviteBusy()" style="display:none;color:var(--text-3);font:400 12px/1 var(--sans,ui-sans-serif)">Sending…</span> |
| 2630 | 2658 | </div> |
| 2631 | 2659 | <p class="mt-2 text-text-3 text-xs">They get an emailed link that works once, for that address only, and expires in 14 days.</p> |
| @@ -2636,7 +2664,7 @@ select.panel:hover { border-color: var(--accent); } | ||
| 2636 | 2664 | <div class="mt-4 px-4 py-3" style="border: 1px solid var(--border); border-radius: 10px; background: var(--bg)"> |
| 2637 | 2665 | <p class="mb-1 font-semibold text-sm">Invite your teammates with Pro</p> |
| 2638 | 2666 | <p class="mb-3 text-text-3 text-xs">Share this site with as many people as you like, each with their own login and their own level of access. Included in Pro at no extra cost per person.</p> |
| 2639 | <StxLink to="/pricing" class="inline-block" style="padding:7px 14px;border-radius:8px;border:1px solid var(--accent);background:var(--accent);color:#fff;font:600 13px/1 var(--sans,ui-sans-serif);text-decoration:none">Upgrade to Pro</StxLink> | |
| 2667 | <StxLink to="/pricing" class="inline-block" style="padding:7px 14px;border-radius:8px;border:1px solid var(--accent);background:var(--accent);color:var(--accent-ink);font:600 13px/1 var(--sans,ui-sans-serif);text-decoration:none">Upgrade to Pro</StxLink> | |
| 2640 | 2668 | </div> |
| 2641 | 2669 | @endif |
| 2642 | 2670 | @endif |
| @@ -2694,7 +2722,7 @@ select.panel:hover { border-color: var(--accent); } | ||
| 2694 | 2722 | in separate elements for the same reason: a reactive child cloaks |
| 2695 | 2723 | its parent. --}} |
| 2696 | 2724 | <div class="flex flex-wrap gap-3 items-center mt-4"> |
| 2697 | <button type="button" @click="importFromGa" :disabled="gaBusy()" style="padding:8px 13px;border-radius:9px;border:0;background:var(--accent);color:#fff;font:600 13px/1 var(--sans,ui-sans-serif);cursor:pointer">Import my history</button> | |
| 2725 | <button type="button" @click="importFromGa" :disabled="gaBusy()" style="padding:8px 13px;border-radius:9px;border:0;background:var(--accent);color:var(--accent-ink);font:600 13px/1 var(--sans,ui-sans-serif);cursor:pointer">Import my history</button> | |
| 2698 | 2726 | </div> |
| 2699 | 2727 | <p class="mt-2 text-text-3 text-xs" :show="gaProgress()" style="display:none">{{ gaProgress() }}</p> |
| 2700 | 2728 | <p class="mt-3 text-sm text-text-2" :show="gaResult()" style="display:none">{{ gaResult() }}</p> |
| @@ -2778,7 +2806,7 @@ select.panel:hover { border-color: var(--accent); } | ||
| 2778 | 2806 | button's own text would hide the control until hydration. --}} |
| 2779 | 2807 | <div class="flex flex-wrap gap-3 items-center mt-4"> |
| 2780 | 2808 | <button type="button" @click="checkFathomExport" :disabled="fathomBusy()" style="padding:8px 13px;border-radius:9px;border:1px solid var(--border);background:transparent;color:var(--text-2);font:600 13px/1 var(--sans,ui-sans-serif);cursor:pointer">Check this export</button> |
| 2781 | <button type="button" @click="importFromFathom" :disabled="fathomBusy()" style="padding:8px 13px;border-radius:9px;border:0;background:var(--accent);color:#fff;font:600 13px/1 var(--sans,ui-sans-serif);cursor:pointer">Import my history</button> | |
| 2809 | <button type="button" @click="importFromFathom" :disabled="fathomBusy()" style="padding:8px 13px;border-radius:9px;border:0;background:var(--accent);color:var(--accent-ink);font:600 13px/1 var(--sans,ui-sans-serif);cursor:pointer">Import my history</button> | |
| 2782 | 2810 | </div> |
| 2783 | 2811 | |
| 2784 | 2812 | <p class="mt-2 text-text-3 text-xs" :show="fathomProgress()" style="display:none">{{ fathomProgress() }}</p> |
| @@ -2807,7 +2835,7 @@ select.panel:hover { border-color: var(--accent); } | ||
| 2807 | 2835 | <div class="mt-1 px-4 py-3" style="border: 1px solid var(--border); border-radius: 10px; background: var(--bg)"> |
| 2808 | 2836 | <p class="mb-1 font-semibold text-sm">Import your Fathom history with Pro</p> |
| 2809 | 2837 | <p class="mb-3 text-text-3 text-xs">Upload your Fathom export and keep every number you already have, sitting alongside the traffic we start tracking today. Included in Pro at no extra cost.</p> |
| 2810 | <StxLink to="/pricing" class="inline-block" style="padding:7px 14px;border-radius:8px;border:1px solid var(--accent);background:var(--accent);color:#fff;font:600 13px/1 var(--sans,ui-sans-serif);text-decoration:none">Upgrade to Pro</StxLink> | |
| 2838 | <StxLink to="/pricing" class="inline-block" style="padding:7px 14px;border-radius:8px;border:1px solid var(--accent);background:var(--accent);color:var(--accent-ink);font:600 13px/1 var(--sans,ui-sans-serif);text-decoration:none">Upgrade to Pro</StxLink> | |
| 2811 | 2839 | </div> |
| 2812 | 2840 | @endif |
| 2813 | 2841 | </div> |
| @@ -2865,7 +2893,7 @@ select.panel:hover { border-color: var(--accent); } | ||
| 2865 | 2893 | @endif |
| 2866 | 2894 | |
| 2867 | 2895 | {{-- Share control (owner-only): reactive create/copy/revoke of a read-only link. |
| 2868 | Signals live in the <script client> block; shareUrl is seeded from the | |
| 2896 | Signals live in the client script block; shareUrl is seeded from the | |
| 2869 | 2897 | server-bridged existing token so the current link shows on load. --}} |
| 2870 | 2898 | @if (ownsActive) |
| 2871 | 2899 | <div class="p-5 panel site-settings-panel"> |
| @@ -2883,7 +2911,7 @@ select.panel:hover { border-color: var(--accent); } | ||
| 2883 | 2911 | <button type="button" @click="copyShare" style="padding:6px 11px;border-radius:8px;border:1px solid var(--border);background:var(--panel);color:var(--text-2);font:600 12px/1 var(--sans,ui-sans-serif);cursor:pointer">{{ shareCopied() ? 'Copied' : 'Copy' }}</button> |
| 2884 | 2912 | <button type="button" @click="revokeShare" :disabled="shareBusy()" style="padding:6px 11px;border-radius:8px;border:1px solid var(--border);background:var(--panel);color:var(--text-3);font:600 12px/1 var(--sans,ui-sans-serif);cursor:pointer">Revoke</button> |
| 2885 | 2913 | </div> |
| 2886 | <button type="button" @click="createShare" :disabled="shareBusy()" :show="!shareUrl()" style="display:none;padding:8px 13px;border-radius:9px;border:0;background:var(--accent);color:#fff;font:600 13px/1 var(--sans,ui-sans-serif);cursor:pointer">{{ shareBusy() ? 'Creating…' : 'Create share link' }}</button> | |
| 2914 | <button type="button" @click="createShare" :disabled="shareBusy()" :show="!shareUrl()" style="display:none;padding:8px 13px;border-radius:9px;border:0;background:var(--accent);color:var(--accent-ink);font:600 13px/1 var(--sans,ui-sans-serif);cursor:pointer">{{ shareBusy() ? 'Creating…' : 'Create share link' }}</button> | |
| 2887 | 2915 | </div> |
| 2888 | 2916 | </div> |
| 2889 | 2917 | @endif |
| @@ -3170,9 +3198,9 @@ select.panel:hover { border-color: var(--accent); } | ||
| 3170 | 3198 | that way. The map is drawn into an empty div by a plain module script, so |
| 3171 | 3199 | nothing in this subtree is reactive and nothing can be hidden. |
| 3172 | 3200 | |
| 3173 | A <div>, not <main>: the router's fragment extraction slices from the | |
| 3174 | first <main> to the last </main>, so a second one makes the SPA swap the | |
| 3175 | wrong span. | |
| 3201 | This remains a div because the router's fragment extraction expects the | |
| 3202 | one main owned by the layout. A second one makes the SPA swap the wrong | |
| 3203 | span. | |
| 3176 | 3204 | |
| 3177 | 3205 | The bar list below is NOT redundant. A browser-side map is blank until |
| 3178 | 3206 | the bundle runs, and it is the only thing a reader gets with JS off, on a |
| @@ -3256,7 +3284,7 @@ select.panel:hover { border-color: var(--accent); } | ||
| 3256 | 3284 | |
| 3257 | 3285 | {{-- Top referrers: the actual referring URLs (full referrer), a drill-down beneath the |
| 3258 | 3286 | normalized Top sources / channels. Clickable to filter; excludes empty/direct traffic. |
| 3259 | {{ }} escapes the (beacon-supplied) url. --}} | |
| 3287 | Server interpolation escapes the beacon-supplied URL. --}} | |
| 3260 | 3288 | <div class="mt-6 p-5 panel reveal"> |
| 3261 | 3289 | <h2 class="mb-4 font-semibold text-text-3 text-xs tracking-wider uppercase">Referrers</h2> |
| 3262 | 3290 | @forelse (referrerUrls as r) |
| @@ -3298,20 +3326,20 @@ select.panel:hover { border-color: var(--accent); } | ||
| 3298 | 3326 | /api/sites/{site}/goals via @submit=addGoal. Pattern is the event NAME for |
| 3299 | 3327 | event goals or the URL PATH for pageview goals. --}} |
| 3300 | 3328 | @if (ownsActive) |
| 3301 | <form @submit="addGoal" class="flex flex-wrap gap-2 items-center mt-4 pt-4" style="border-top:1px solid var(--border)"> | |
| 3302 | <input name="name" required maxlength="255" placeholder="Goal name" style="flex:1;min-width:110px;padding:6px 9px;border-radius:8px;border:1px solid var(--border);background:var(--bg);color:var(--text);font:500 13px/1.2 var(--sans,ui-sans-serif)"> | |
| 3303 | <select name="type" style="padding:6px 9px;border-radius:8px;border:1px solid var(--border);background:var(--bg);color:var(--text);font:500 13px/1.2 var(--sans,ui-sans-serif)"> | |
| 3329 | <form @submit="addGoal" class="flex flex-wrap gap-2 items-center mt-4 pt-4 goal-form" style="border-top:1px solid var(--border)"> | |
| 3330 | <input name="name" required maxlength="255" placeholder="Goal name" aria-label="Goal name" style="flex:1;min-width:110px;padding:6px 9px;border-radius:8px;border:1px solid var(--border);background:var(--bg);color:var(--text);font:500 13px/1.2 var(--sans,ui-sans-serif)"> | |
| 3331 | <select name="type" aria-label="Goal type" style="padding:6px 9px;border-radius:8px;border:1px solid var(--border);background:var(--bg);color:var(--text);font:500 13px/1.2 var(--sans,ui-sans-serif)"> | |
| 3304 | 3332 | <option value="event">Event</option> |
| 3305 | 3333 | <option value="pageview">Pageview</option> |
| 3306 | 3334 | </select> |
| 3307 | <input name="pattern" required maxlength="255" placeholder="Event name or /path" style="flex:1;min-width:110px;padding:6px 9px;border-radius:8px;border:1px solid var(--border);background:var(--bg);color:var(--text);font:500 13px/1.2 var(--sans,ui-sans-serif)"> | |
| 3308 | <select name="match" style="padding:6px 9px;border-radius:8px;border:1px solid var(--border);background:var(--bg);color:var(--text);font:500 13px/1.2 var(--sans,ui-sans-serif)"> | |
| 3335 | <input name="pattern" required maxlength="255" placeholder="Event name or /path" aria-label="Goal event or path" style="flex:1;min-width:110px;padding:6px 9px;border-radius:8px;border:1px solid var(--border);background:var(--bg);color:var(--text);font:500 13px/1.2 var(--sans,ui-sans-serif)"> | |
| 3336 | <select name="match" aria-label="Goal matching rule" style="padding:6px 9px;border-radius:8px;border:1px solid var(--border);background:var(--bg);color:var(--text);font:500 13px/1.2 var(--sans,ui-sans-serif)"> | |
| 3309 | 3337 | <option value="exact">exact</option> |
| 3310 | 3338 | <option value="contains">contains</option> |
| 3311 | 3339 | <option value="starts_with">starts with</option> |
| 3312 | 3340 | </select> |
| 3313 | <input name="value" type="number" step="any" placeholder="Value" style="width:80px;padding:6px 9px;border-radius:8px;border:1px solid var(--border);background:var(--bg);color:var(--text);font:500 13px/1.2 var(--sans,ui-sans-serif)"> | |
| 3314 | <button type="submit" :disabled="goalBusy()" style="padding:7px 13px;border-radius:8px;border:0;background:var(--accent);color:#fff;font:600 13px/1 var(--sans,ui-sans-serif);cursor:pointer">{{ goalBusy() ? 'Adding…' : 'Add goal' }}</button> | |
| 3341 | <input name="value" type="number" step="any" placeholder="Value" aria-label="Goal value" style="width:80px;padding:6px 9px;border-radius:8px;border:1px solid var(--border);background:var(--bg);color:var(--text);font:500 13px/1.2 var(--sans,ui-sans-serif)"> | |
| 3342 | <button type="submit" :disabled="goalBusy()" style="padding:7px 13px;border-radius:8px;border:0;background:var(--accent);color:var(--accent-ink);font:600 13px/1 var(--sans,ui-sans-serif);cursor:pointer">{{ goalBusy() ? 'Adding…' : 'Add goal' }}</button> | |
| 3315 | 3343 | </form> |
| 3316 | 3344 | <div class="mt-2 text-xs" :show="goalError()" style="display:none;color:#ef4444">{{ goalError() }}</div> |
| 3317 | 3345 | @endif |
| @@ -3340,7 +3368,7 @@ select.panel:hover { border-color: var(--accent); } | ||
| 3340 | 3368 | |
| 3341 | 3369 | {{-- Top outbound links / file downloads: the URLs behind the auto-tracked Outbound |
| 3342 | 3370 | Link / File Download events (properties.url). GROUP BY properties (dialect-portable, |
| 3343 | no JSON_EXTRACT); site+range scoped like Events. Display-only; {{ }} escapes the url. --}} | |
| 3371 | no JSON_EXTRACT); site+range scoped like Events. Server interpolation escapes the URL. --}} | |
| 3344 | 3372 | <div class="grid gap-6 md:grid-cols-2 mt-6"> |
| 3345 | 3373 | <div class="p-5 panel reveal"> |
| 3346 | 3374 | <h2 class="mb-4 font-semibold text-text-3 text-xs tracking-wider uppercase">Outbound links</h2> |
| @@ -3412,11 +3440,11 @@ select.panel:hover { border-color: var(--accent); } | ||
| 3412 | 3440 | @if (user) |
| 3413 | 3441 | <h2 class="mb-2 font-semibold text-lg">No site to show yet</h2> |
| 3414 | 3442 | <p class="mb-5 text-sm text-text-2">Add a site to get a tracking snippet and start seeing analytics here.</p> |
| 3415 | <StxLink to="/sites/new" class="inline-block px-4 py-2 font-semibold text-sm" style="border-radius:10px;background:var(--accent);color:#fff;text-decoration:none">Add a site</StxLink> | |
| 3443 | <StxLink to="/sites/new" class="inline-block px-4 py-2 font-semibold text-sm" style="border-radius:10px;background:var(--accent);color:var(--accent-ink);text-decoration:none">Add a site</StxLink> | |
| 3416 | 3444 | @else |
| 3417 | 3445 | <h2 class="mb-2 font-semibold text-lg">Sign in to view analytics</h2> |
| 3418 | 3446 | <p class="mb-5 text-sm text-text-2">This dashboard is private to the site owner.</p> |
| 3419 | <StxLink to="/login" class="inline-block px-4 py-2 font-semibold text-sm" style="border-radius:10px;background:var(--accent);color:#fff;text-decoration:none">Sign in</StxLink> | |
| 3447 | <StxLink to="/login" class="inline-block px-4 py-2 font-semibold text-sm" style="border-radius:10px;background:var(--accent);color:var(--accent-ink);text-decoration:none">Sign in</StxLink> | |
| 3420 | 3448 | @endif |
| 3421 | 3449 | </div> |
| 3422 | 3450 | @endif |
| @@ -3432,7 +3460,7 @@ select.panel:hover { border-color: var(--accent); } | ||
| 3432 | 3460 | {{-- Pre-render token guard: send a signed-out visitor to /login before paint. |
| 3433 | 3461 | |
| 3434 | 3462 | This USED to also mirror the localStorage token into the analyticshq_token cookie |
| 3435 | and reload once, so the <script server> block could owner-scope the switcher and | |
| 3463 | and reload once, so the server script block could owner-scope the switcher and | |
| 3436 | 3464 | setup panel. Both halves are gone (#33): stacks 0.70.369 has LoginAction set the |
| 3437 | 3465 | cookie in its own response, so it is present on the very first authenticated |
| 3438 | 3466 | document request — and the cookie is HttpOnly, so this block could not have written |
| @@ -3545,7 +3573,7 @@ select.panel:hover { border-color: var(--accent); } | ||
| 3545 | 3573 | dragging: false, |
| 3546 | 3574 | scrollWheelZoom: false, |
| 3547 | 3575 | doubleClickZoom: false, |
| 3548 | touchZoom: false, | |
| 3576 | pinchZoom: false, | |
| 3549 | 3577 | keyboard: false, |
| 3550 | 3578 | }) |
| 3551 | 3579 | layer = new GeoJSON(world, { style: styleFor(tokens()) }) |
| @@ -93,8 +93,8 @@ async function handleSubmit(e: SubmitEvent): Promise<void> { | ||
| 93 | 93 | @endif |
| 94 | 94 | |
| 95 | 95 | <div> |
| 96 | <label class="block mb-1.5 font-medium text-text-2 text-xs">Email</label> | |
| 97 | <input name="email" type="email" required autofocus autocomplete="email" placeholder="you@example.com" class="px-3.5 py-2.5 w-full text-sm field"> | |
| 96 | <label for="forgot-email" class="block mb-1.5 font-medium text-text-2 text-xs">Email</label> | |
| 97 | <input id="forgot-email" name="email" type="email" required autofocus autocomplete="email" placeholder="you@example.com" class="px-3.5 py-2.5 w-full text-sm field"> | |
| 98 | 98 | </div> |
| 99 | 99 | |
| 100 | 100 | <button type="submit" :disabled="loading()" class="py-2.5 w-full font-semibold text-sm btn"> |
| @@ -89,11 +89,11 @@ const roleLabel = invite?.role === 'admin' ? 'view the reports and change settin | ||
| 89 | 89 | // Round-trips the reader back here after they sign in or register. |
| 90 | 90 | const returnTo = `/invite?token=${encodeURIComponent(token)}` |
| 91 | 91 | |
| 92 | // The token is the only thing that crosses. Everything else this block resolved | |
| 92 | // The capability is the only thing that crosses. Everything else this block resolved | |
| 93 | 93 | // — the invite row, the viewer, the refusal — stays server-side: the client has |
| 94 | 94 | // no decision to make, it only posts the token back for the server to re-check. |
| 95 | const inviteToken = token | |
| 96 | defineClientPayload({ inviteToken }) | |
| 95 | const inviteCapability = token | |
| 96 | defineClientPayload({ inviteCapability }) | |
| 97 | 97 | </script> |
| 98 | 98 | |
| 99 | 99 | <script client> |
| @@ -110,7 +110,7 @@ async function accept(): Promise<void> { | ||
| 110 | 110 | const res = await session.authFetch('/api/invites/accept', { |
| 111 | 111 | method: 'POST', |
| 112 | 112 | headers: { 'content-type': 'application/json' }, |
| 113 | body: JSON.stringify({ token: inviteToken }), | |
| 113 | body: JSON.stringify({ token: inviteCapability }), | |
| 114 | 114 | }) |
| 115 | 115 | const d = await res.json().catch(() => ({})) |
| 116 | 116 | if (res.ok && d.siteId) { |
| @@ -79,15 +79,15 @@ async function handleSubmit(e: SubmitEvent): Promise<void> { | ||
| 79 | 79 | @endif |
| 80 | 80 | |
| 81 | 81 | <div> |
| 82 | <label class="block mb-1.5 font-medium text-text-2 text-xs">Email</label> | |
| 83 | <input name="email" type="email" required autocomplete="email" placeholder="you@example.com" class="px-3.5 py-2.5 w-full text-sm field"> | |
| 82 | <label for="login-email" class="block mb-1.5 font-medium text-text-2 text-xs">Email</label> | |
| 83 | <input id="login-email" name="email" type="email" required autocomplete="email" placeholder="you@example.com" class="px-3.5 py-2.5 w-full text-sm field"> | |
| 84 | 84 | </div> |
| 85 | 85 | <div> |
| 86 | 86 | <div class="flex items-baseline justify-between mb-1.5"> |
| 87 | <label class="font-medium text-text-2 text-xs">Password</label> | |
| 87 | <label for="login-password" class="font-medium text-text-2 text-xs">Password</label> | |
| 88 | 88 | <StxLink to="/forgot-password" class="text-text-3 text-xs hover:text-accent">Forgot?</StxLink> |
| 89 | 89 | </div> |
| 90 | <input name="password" type="password" required autocomplete="current-password" placeholder="••••••••" class="px-3.5 py-2.5 w-full text-sm field"> | |
| 90 | <input id="login-password" name="password" type="password" required autocomplete="current-password" placeholder="••••••••" class="px-3.5 py-2.5 w-full text-sm field"> | |
| 91 | 91 | </div> |
| 92 | 92 | |
| 93 | 93 | <button type="submit" :disabled="loading()" class="py-2.5 w-full font-semibold text-sm btn"> |
| @@ -102,16 +102,16 @@ async function handleSubmit(e: SubmitEvent): Promise<void> { | ||
| 102 | 102 | @endif |
| 103 | 103 | |
| 104 | 104 | <div> |
| 105 | <label class="block mb-1.5 font-medium text-text-2 text-xs">Name</label> | |
| 106 | <input name="name" type="text" required autocomplete="name" placeholder="Ada Lovelace" class="px-3.5 py-2.5 w-full text-sm field"> | |
| 105 | <label for="register-name" class="block mb-1.5 font-medium text-text-2 text-xs">Name</label> | |
| 106 | <input id="register-name" name="name" type="text" required autocomplete="name" placeholder="Ada Lovelace" class="px-3.5 py-2.5 w-full text-sm field"> | |
| 107 | 107 | </div> |
| 108 | 108 | <div> |
| 109 | <label class="block mb-1.5 font-medium text-text-2 text-xs">Email</label> | |
| 110 | <input name="email" type="email" required autocomplete="email" placeholder="you@example.com" class="px-3.5 py-2.5 w-full text-sm field"> | |
| 109 | <label for="register-email" class="block mb-1.5 font-medium text-text-2 text-xs">Email</label> | |
| 110 | <input id="register-email" name="email" type="email" required autocomplete="email" placeholder="you@example.com" class="px-3.5 py-2.5 w-full text-sm field"> | |
| 111 | 111 | </div> |
| 112 | 112 | <div> |
| 113 | <label class="block mb-1.5 font-medium text-text-2 text-xs">Password</label> | |
| 114 | <input name="password" type="password" required autocomplete="new-password" placeholder="At least 8 characters" class="px-3.5 py-2.5 w-full text-sm field"> | |
| 113 | <label for="register-password" class="block mb-1.5 font-medium text-text-2 text-xs">Password</label> | |
| 114 | <input id="register-password" name="password" type="password" required autocomplete="new-password" placeholder="At least 8 characters" class="px-3.5 py-2.5 w-full text-sm field"> | |
| 115 | 115 | </div> |
| 116 | 116 | |
| 117 | 117 | <button type="submit" :disabled="loading()" class="py-2.5 w-full font-semibold text-sm btn"> |
| @@ -128,12 +128,12 @@ async function handleSubmit(e: SubmitEvent): Promise<void> { | ||
| 128 | 128 | @endif |
| 129 | 129 | |
| 130 | 130 | <div> |
| 131 | <label class="block mb-1.5 font-medium text-text-2 text-xs">New password</label> | |
| 132 | <input name="password" type="password" required autofocus autocomplete="new-password" placeholder="At least 8 characters" class="px-3.5 py-2.5 w-full text-sm field"> | |
| 131 | <label for="reset-password" class="block mb-1.5 font-medium text-text-2 text-xs">New password</label> | |
| 132 | <input id="reset-password" name="password" type="password" required autofocus autocomplete="new-password" placeholder="At least 8 characters" class="px-3.5 py-2.5 w-full text-sm field"> | |
| 133 | 133 | </div> |
| 134 | 134 | <div> |
| 135 | <label class="block mb-1.5 font-medium text-text-2 text-xs">Confirm password</label> | |
| 136 | <input name="password_confirmation" type="password" required autocomplete="new-password" placeholder="••••••••" class="px-3.5 py-2.5 w-full text-sm field"> | |
| 135 | <label for="reset-password-confirmation" class="block mb-1.5 font-medium text-text-2 text-xs">Confirm password</label> | |
| 136 | <input id="reset-password-confirmation" name="password_confirmation" type="password" required autocomplete="new-password" placeholder="••••••••" class="px-3.5 py-2.5 w-full text-sm field"> | |
| 137 | 137 | </div> |
| 138 | 138 | |
| 139 | 139 | <button type="submit" :disabled="loading()" class="py-2.5 w-full font-semibold text-sm btn"> |
| @@ -93,12 +93,12 @@ async function handleSubmit(e: SubmitEvent): Promise<void> { | ||
| 93 | 93 | @endif |
| 94 | 94 | |
| 95 | 95 | <div> |
| 96 | <label class="block mb-1.5 font-medium text-text-2 text-xs">Site name</label> | |
| 97 | <input name="name" type="text" required autofocus placeholder="My Blog" class="px-3.5 py-2.5 w-full text-sm field"> | |
| 96 | <label for="site-name" class="block mb-1.5 font-medium text-text-2 text-xs">Site name</label> | |
| 97 | <input id="site-name" name="name" type="text" required autofocus placeholder="My Blog" class="px-3.5 py-2.5 w-full text-sm field"> | |
| 98 | 98 | </div> |
| 99 | 99 | <div> |
| 100 | <label class="block mb-1.5 font-medium text-text-2 text-xs">Domain <span class="text-text-3">(optional)</span></label> | |
| 101 | <input name="domain" type="text" placeholder="example.com" class="px-3.5 py-2.5 w-full text-sm field"> | |
| 100 | <label for="site-domain" class="block mb-1.5 font-medium text-text-2 text-xs">Domain <span class="text-text-3">(optional)</span></label> | |
| 101 | <input id="site-domain" name="domain" type="text" placeholder="example.com" class="px-3.5 py-2.5 w-full text-sm field"> | |
| 102 | 102 | </div> |
| 103 | 103 | |
| 104 | 104 | <button type="submit" :disabled="loading()" class="py-2.5 w-full font-semibold text-sm btn"> |
| @@ -20,6 +20,7 @@ import { formatCount, renderBadge, renderSparkline, sanitizeLabel } from '../app | ||
| 20 | 20 | import { ASSIGNABLE_ROLES, isAssignableRole, listSiteMembers, resolveSiteRole, satisfies, siteExists, type SiteRole } from '../app/Analytics/access' |
| 21 | 21 | import { ALERT_CONDITIONS, ALERT_METRICS, isAlertCondition, isAlertMetric, isRelative } from '../app/Analytics/alerts' |
| 22 | 22 | import { planForSite } from '../app/Analytics/entitlements' |
| 23 | import { serializeEventProperties } from '../app/Analytics/event-properties' | |
| 23 | 24 | import { expiryFrom, hashToken, inviteRefusal, looksLikeEmail, mintToken, normalizeEmail } from '../app/Analytics/invites' |
| 24 | 25 | import { sendSiteInvite } from '../app/Mail/SiteInvite' |
| 25 | 26 | import { featureUnavailableMessage, isUnlimited, limitReachedMessage, type PlanFeatures, type PlanLimits } from '../config/plans' |
| @@ -551,25 +552,12 @@ route.post('/collect', async (request: any) => { | ||
| 551 | 552 | }).execute() |
| 552 | 553 | } |
| 553 | 554 | else { |
| 554 | // Reserved auto-tracked events (Outbound Link / File Download) carry only a url. Store | |
| 555 | // it canonically as {"url":...} regardless of any extra keys / key-order a caller sends, | |
| 556 | // so the dashboard's GROUP BY properties aggregates exactly one row per URL — a client | |
| 557 | // can't split or pollute a URL's row by appending junk keys. | |
| 558 | let props = body.p ? JSON.stringify(body.p) : null | |
| 559 | if ((String(event) === 'Outbound Link' || String(event) === 'File Download') && body.p && body.p.url) { | |
| 560 | let url = String(body.p.url) | |
| 561 | props = JSON.stringify({ url }) | |
| 562 | // properties is varchar(255): trim the url until the wrapped JSON fits, so it stays | |
| 563 | // valid JSON (the dashboard JSON.parses it) and never overflows the column — on Postgres | |
| 564 | // an over-length varchar insert errors (22001) and would 500 the beacon. Only pathologically | |
| 565 | // long hrefs hit the loop. (TODO: widen custom_events.properties for full-length urls.) | |
| 566 | while (props.length > 255 && url.length) { | |
| 567 | url = url.slice(0, -8) | |
| 568 | props = JSON.stringify({ url }) | |
| 569 | } | |
| 570 | } | |
| 571 | // .catch like the sites/sessions inserts above: a storage failure (e.g. an over-length | |
| 572 | // non-reserved props blob under strict sql_mode) must never 500 the public beacon. | |
| 555 | // Reserved auto-tracked events carry only their canonical URL, so grouping | |
| 556 | // stays stable even when a caller sends extra keys in a different order. | |
| 557 | // Optional metadata is bounded before it reaches the database. | |
| 558 | const props = serializeEventProperties(event, body.p) | |
| 559 | // .catch like the sites/sessions inserts above: a storage failure must | |
| 560 | // never turn the public beacon into a 500 response. | |
| 573 | 561 | await db.insertInto('custom_events').values({ |
| 574 | 562 | id: randomId(), |
| 575 | 563 | site_id: String(siteId), |
| @@ -0,0 +1,63 @@ | ||
| 1 | import { readFileSync } from 'node:fs' | |
| 2 | import { resolve } from 'node:path' | |
| 3 | ||
| 4 | interface SmokeContract { | |
| 5 | domain: string | |
| 6 | homeText: string | |
| 7 | } | |
| 8 | ||
| 9 | const CONTRACTS: Record<string, SmokeContract> = { | |
| 10 | analyticshq: { domain: 'analyticshq.org', homeText: 'analyticshq' }, | |
| 11 | bughq: { domain: 'bughq.org', homeText: 'Error tracking for people who ship.' }, | |
| 12 | commshq: { domain: 'commshq.org', homeText: 'Grow an audience worth knowing.' }, | |
| 13 | loghq: { domain: 'loghq.org', homeText: 'Your logs, finally worth reading.' }, | |
| 14 | reportshq: { domain: 'reportshq.org', homeText: 'Reports that build themselves' }, | |
| 15 | status: { domain: 'statushq.org', homeText: 'Know the moment' }, | |
| 16 | } | |
| 17 | ||
| 18 | const projectRoot = resolve(import.meta.dir, '..') | |
| 19 | const packageJson = JSON.parse(readFileSync(resolve(projectRoot, 'package.json'), 'utf8')) as { name?: string } | |
| 20 | const packageName = String(packageJson.name || '') | |
| 21 | const directoryName = projectRoot.split('/').pop() || '' | |
| 22 | const appName = CONTRACTS[packageName] ? packageName : directoryName | |
| 23 | const contract = CONTRACTS[appName] | |
| 24 | ||
| 25 | if (!contract) throw new Error(`No deployment smoke contract exists for ${appName || '(unnamed)'}.`) | |
| 26 | ||
| 27 | const baseUrl = String(process.env.SMOKE_BASE_URL || `https://${contract.domain}`).replace(/\/$/, '') | |
| 28 | const attempts = Math.max(1, Number(process.env.SMOKE_ATTEMPTS || 10)) | |
| 29 | const pauseMs = Math.max(0, Number(process.env.SMOKE_RETRY_MS || 3000)) | |
| 30 | ||
| 31 | async function readWithRetry(path: string): Promise<string> { | |
| 32 | let lastFailure = 'no response' | |
| 33 | for (let attempt = 1; attempt <= attempts; attempt += 1) { | |
| 34 | try { | |
| 35 | const separator = path.includes('?') ? '&' : '?' | |
| 36 | const response = await fetch(`${baseUrl}${path}${separator}smoke=${Date.now()}`, { | |
| 37 | headers: { accept: path === '/robots.txt' ? 'text/plain' : 'text/html' }, | |
| 38 | redirect: 'follow', | |
| 39 | signal: AbortSignal.timeout(10_000), | |
| 40 | }) | |
| 41 | const body = await response.text() | |
| 42 | if (response.ok) return body | |
| 43 | lastFailure = `HTTP ${response.status}` | |
| 44 | } | |
| 45 | catch (error) { | |
| 46 | lastFailure = error instanceof Error ? error.message : String(error) | |
| 47 | } | |
| 48 | if (attempt < attempts) await Bun.sleep(pauseMs) | |
| 49 | } | |
| 50 | throw new Error(`${baseUrl}${path} failed after ${attempts} attempts: ${lastFailure}`) | |
| 51 | } | |
| 52 | ||
| 53 | const home = await readWithRetry('/') | |
| 54 | if (!home.includes(contract.homeText)) throw new Error(`Homepage does not contain the expected release marker: ${contract.homeText}`) | |
| 55 | ||
| 56 | const login = await readWithRetry('/login') | |
| 57 | if (!/<(?:form|main)\b/i.test(login)) throw new Error('Login smoke response does not contain an application form or main region.') | |
| 58 | ||
| 59 | const robots = await readWithRetry('/robots.txt') | |
| 60 | if (robots.includes('http://localhost')) throw new Error('Deployed robots.txt contains a localhost URL.') | |
| 61 | if (!robots.includes(new URL(baseUrl).hostname)) throw new Error(`Deployed robots.txt does not name ${new URL(baseUrl).hostname}.`) | |
| 62 | ||
| 63 | console.log(`Deployment smoke passed for ${baseUrl}: homepage, login, and robots metadata are live.`) | |
| @@ -0,0 +1,102 @@ | ||
| 1 | import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs' | |
| 2 | import { dirname, join, relative, resolve } from 'node:path' | |
| 3 | ||
| 4 | const root = process.cwd() | |
| 5 | const docsDir = resolve(root, 'docs') | |
| 6 | const configPath = resolve(root, 'config/docs.ts') | |
| 7 | const issues = new Set<string>() | |
| 8 | const incomingRoutes = new Set<string>() | |
| 9 | const appRoutePrefixes = ['/api', '/account', '/dashboard', '/login', '/pricing', '/register', '/reports', '/status'] | |
| 10 | ||
| 11 | function markdownFiles(dir: string): string[] { | |
| 12 | return readdirSync(dir).flatMap((name) => { | |
| 13 | const path = join(dir, name) | |
| 14 | return statSync(path).isDirectory() ? markdownFiles(path) : path.endsWith('.md') ? [path] : [] | |
| 15 | }) | |
| 16 | } | |
| 17 | ||
| 18 | function proseOnly(text: string): string { | |
| 19 | let inFence = false | |
| 20 | return text.split('\n').filter((line) => { | |
| 21 | if (/^(?:```|~~~)/.test(line)) { | |
| 22 | inFence = !inFence | |
| 23 | return false | |
| 24 | } | |
| 25 | return !inFence | |
| 26 | }).join('\n') | |
| 27 | } | |
| 28 | ||
| 29 | function routeForFile(file: string): string { | |
| 30 | const path = relative(docsDir, file).replace(/\\/g, '/') | |
| 31 | if (path === 'index.md') return '/' | |
| 32 | return `/${path.replace(/(?:\/index)?\.md$/, '')}` | |
| 33 | } | |
| 34 | ||
| 35 | function resolveDocTarget(source: string, rawTarget: string): string | null { | |
| 36 | let target = rawTarget.replace(/^<|>$/g, '').split(/[?#]/, 1)[0] | |
| 37 | if (!target || /^(?:[a-z]+:|#|mailto:|tel:)/i.test(rawTarget)) return null | |
| 38 | ||
| 39 | if (target === '/docs') target = '/' | |
| 40 | else if (target.startsWith('/docs/')) target = target.slice('/docs'.length) | |
| 41 | ||
| 42 | const base = target.startsWith('/') ? resolve(docsDir, `.${target}`) : resolve(dirname(source), target) | |
| 43 | const candidates = [base, `${base}.md`, join(base, 'index.md')] | |
| 44 | const found = candidates.find(candidate => existsSync(candidate) && statSync(candidate).isFile()) | |
| 45 | if (found) return found | |
| 46 | if (target.startsWith('/') && appRoutePrefixes.some(prefix => target === prefix || target.startsWith(`${prefix}/`))) return null | |
| 47 | return '' | |
| 48 | } | |
| 49 | ||
| 50 | function checkTarget(source: string, rawTarget: string): void { | |
| 51 | const target = resolveDocTarget(source, rawTarget) | |
| 52 | if (target === null) return | |
| 53 | if (target === '') { | |
| 54 | issues.add(`${relative(root, source)}: missing local target ${rawTarget}`) | |
| 55 | return | |
| 56 | } | |
| 57 | if (target.startsWith(docsDir)) incomingRoutes.add(routeForFile(target)) | |
| 58 | } | |
| 59 | ||
| 60 | if (!existsSync(docsDir)) { | |
| 61 | console.error('Documentation directory does not exist: docs/') | |
| 62 | process.exit(1) | |
| 63 | } | |
| 64 | ||
| 65 | const files = markdownFiles(docsDir) | |
| 66 | for (const file of files) { | |
| 67 | const text = readFileSync(file, 'utf8') | |
| 68 | const prose = proseOnly(text) | |
| 69 | const name = relative(root, file) | |
| 70 | const headings = prose.match(/^#\s+\S.*$/gm) ?? [] | |
| 71 | const homeLayout = /^layout:\s*home\s*$/m.test(text) | |
| 72 | ||
| 73 | if (headings.length !== 1 && !(homeLayout && headings.length === 0)) { | |
| 74 | issues.add(`${name}: expected one H1, found ${headings.length}`) | |
| 75 | } | |
| 76 | ||
| 77 | const fences = text.match(/^(?:```|~~~)/gm) ?? [] | |
| 78 | if (fences.length % 2 !== 0) issues.add(`${name}: unbalanced fenced code blocks`) | |
| 79 | if (/[—–]/.test(prose)) issues.add(`${name}: contains a prohibited long dash`) | |
| 80 | if (/\b(?:TODO|TBD|FIXME|XXX)\b/.test(prose)) issues.add(`${name}: contains an unfinished marker`) | |
| 81 | ||
| 82 | for (const match of prose.matchAll(/\[[^\]]*\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g)) checkTarget(file, match[1]) | |
| 83 | for (const match of prose.matchAll(/\b(?:href|src)=["']([^"']+)["']/g)) checkTarget(file, match[1]) | |
| 84 | } | |
| 85 | ||
| 86 | if (existsSync(configPath)) { | |
| 87 | const config = readFileSync(configPath, 'utf8') | |
| 88 | for (const match of config.matchAll(/\blink:\s*['"]([^'"]+)['"]/g)) checkTarget(configPath, match[1]) | |
| 89 | } | |
| 90 | ||
| 91 | for (const file of files) { | |
| 92 | const route = routeForFile(file) | |
| 93 | if (route !== '/' && !incomingRoutes.has(route)) { | |
| 94 | issues.add(`${relative(root, file)}: not linked from navigation or another documentation page`) | |
| 95 | } | |
| 96 | } | |
| 97 | ||
| 98 | console.log(`Validated ${files.length} documentation pages.`) | |
| 99 | if (issues.size > 0) { | |
| 100 | for (const issue of [...issues].sort()) console.error(`- ${issue}`) | |
| 101 | process.exit(1) | |
| 102 | } | |
| @@ -0,0 +1,199 @@ | ||
| 1 | import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs' | |
| 2 | import { extname, join, resolve } from 'node:path' | |
| 3 | ||
| 4 | interface AppContract { | |
| 5 | domain: string | |
| 6 | minPages: number | |
| 7 | routes: Record<string, string[]> | |
| 8 | } | |
| 9 | ||
| 10 | const CONTRACTS: Record<string, AppContract> = { | |
| 11 | analyticshq: { | |
| 12 | domain: 'analyticshq.org', | |
| 13 | minPages: 30, | |
| 14 | routes: { | |
| 15 | '/': ['analyticshq'], | |
| 16 | '/compare/matomo': ['analyticshq vs Matomo', 'Point for point.'], | |
| 17 | '/login': ['analyticshq'], | |
| 18 | }, | |
| 19 | }, | |
| 20 | bughq: { | |
| 21 | domain: 'bughq.org', | |
| 22 | minPages: 25, | |
| 23 | routes: { | |
| 24 | '/': ['Error tracking for people who ship.', 'From throw to triaged'], | |
| 25 | '/login': ['bughq'], | |
| 26 | '/dashboard': ['bughq'], | |
| 27 | }, | |
| 28 | }, | |
| 29 | commshq: { | |
| 30 | domain: 'commshq.org', | |
| 31 | minPages: 45, | |
| 32 | routes: { | |
| 33 | '/': ['Grow an audience worth knowing.', 'From first hello to favorite customer.'], | |
| 34 | '/login': ['CommsHQ'], | |
| 35 | '/dashboard/commshq': ['CommsHQ'], | |
| 36 | }, | |
| 37 | }, | |
| 38 | loghq: { | |
| 39 | domain: 'loghq.org', | |
| 40 | minPages: 30, | |
| 41 | routes: { | |
| 42 | '/': ['Your logs, finally worth reading.', 'From scattered log files to one live stream.'], | |
| 43 | '/login': ['loghq'], | |
| 44 | '/compare/sentry': ['loghq', 'Sentry'], | |
| 45 | }, | |
| 46 | }, | |
| 47 | reportshq: { | |
| 48 | domain: 'reportshq.org', | |
| 49 | minPages: 20, | |
| 50 | routes: { | |
| 51 | '/': ['Reports that build themselves', 'Three steps, and you write none of the reporting'], | |
| 52 | '/login': ['ReportsHQ'], | |
| 53 | '/pricing': ['ReportsHQ'], | |
| 54 | }, | |
| 55 | }, | |
| 56 | status: { | |
| 57 | domain: 'statushq.org', | |
| 58 | minPages: 35, | |
| 59 | routes: { | |
| 60 | '/': ['Know the moment', 'One dashboard for uptime, SSL, DNS, and status.'], | |
| 61 | '/login': ['Welcome back'], | |
| 62 | '/register': ['Create your account'], | |
| 63 | }, | |
| 64 | }, | |
| 65 | } | |
| 66 | ||
| 67 | const projectRoot = resolve(import.meta.dir, '..') | |
| 68 | const distRoot = resolve(projectRoot, 'dist') | |
| 69 | const ssrRoot = resolve(projectRoot, '.output') | |
| 70 | const ssrPages = resolve(ssrRoot, 'server/pages') | |
| 71 | const packageJson = JSON.parse(readFileSync(resolve(projectRoot, 'package.json'), 'utf8')) as { name?: string } | |
| 72 | const packageName = String(packageJson.name || '') | |
| 73 | const directoryName = projectRoot.split('/').pop() || '' | |
| 74 | const appName = CONTRACTS[packageName] ? packageName : directoryName | |
| 75 | const contract = CONTRACTS[appName] | |
| 76 | ||
| 77 | if (!contract) | |
| 78 | throw new Error(`No release contract is configured for package ${appName || '(unnamed)'}.`) | |
| 79 | ||
| 80 | const isSsr = existsSync(ssrPages) | |
| 81 | if (!existsSync(distRoot) && !isSsr) | |
| 82 | throw new Error('No production artifact found. Run bun run build first.') | |
| 83 | ||
| 84 | function walkFiles(directory: string): string[] { | |
| 85 | if (!existsSync(directory)) return [] | |
| 86 | const files: string[] = [] | |
| 87 | for (const entry of readdirSync(directory)) { | |
| 88 | const path = join(directory, entry) | |
| 89 | if (statSync(path).isDirectory()) files.push(...walkFiles(path)) | |
| 90 | else files.push(path) | |
| 91 | } | |
| 92 | return files | |
| 93 | } | |
| 94 | ||
| 95 | function routeArtifact(route: string): { html: string, source: string } | undefined { | |
| 96 | if (!isSsr) { | |
| 97 | const direct = route === '/' ? join(distRoot, 'index.html') : join(distRoot, `${route.slice(1)}.html`) | |
| 98 | const nested = join(distRoot, route.slice(1), 'index.html') | |
| 99 | const source = existsSync(direct) ? direct : existsSync(nested) ? nested : undefined | |
| 100 | return source ? { html: readFileSync(source, 'utf8'), source } : undefined | |
| 101 | } | |
| 102 | ||
| 103 | const name = route === '/' ? 'index' : route.slice(1).replaceAll('/', '-') | |
| 104 | const source = join(ssrPages, `${name}.compiled.json`) | |
| 105 | if (!existsSync(source)) return undefined | |
| 106 | const compiled = JSON.parse(readFileSync(source, 'utf8')) as { html?: string } | |
| 107 | return { html: compiled.html || '', source } | |
| 108 | } | |
| 109 | ||
| 110 | const pageFiles = isSsr | |
| 111 | ? walkFiles(ssrPages).filter(path => path.endsWith('.compiled.json')) | |
| 112 | : walkFiles(distRoot).filter(path => path.endsWith('.html')) | |
| 113 | ||
| 114 | const failures: string[] = [] | |
| 115 | if (pageFiles.length < contract.minPages) | |
| 116 | failures.push(`artifact contains ${pageFiles.length} pages; expected at least ${contract.minPages}`) | |
| 117 | ||
| 118 | const publicRoot = isSsr ? join(ssrRoot, 'public') : distRoot | |
| 119 | for (const [route, expectedText] of Object.entries(contract.routes)) { | |
| 120 | const artifact = routeArtifact(route) | |
| 121 | if (!artifact) { | |
| 122 | failures.push(`${route}: production artifact is missing`) | |
| 123 | continue | |
| 124 | } | |
| 125 | ||
| 126 | const withoutCode = artifact.html | |
| 127 | .replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, ' ') | |
| 128 | .replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, ' ') | |
| 129 | .replace(/<svg\b[^>]*>[\s\S]*?<\/svg>/gi, ' ') | |
| 130 | const visibleText = withoutCode.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim() | |
| 131 | ||
| 132 | for (const fragment of expectedText) { | |
| 133 | if (!visibleText.includes(fragment)) | |
| 134 | failures.push(`${route}: expected text is missing: ${fragment}`) | |
| 135 | } | |
| 136 | if (/@(if|foreach|forelse)\b/.test(visibleText)) | |
| 137 | failures.push(`${route}: unresolved template syntax is visible`) | |
| 138 | if (/<h[12]\b[^>]*>\s*<\/h[12]>/i.test(withoutCode)) | |
| 139 | failures.push(`${route}: an empty primary heading was emitted`) | |
| 140 | if (/\b(?:AWS_SECRET_ACCESS_KEY|DB_PASSWORD|STRIPE_SECRET_KEY)\s*[:=]\s*["']?[^\s"'<]{4,}/i.test(artifact.html)) | |
| 141 | failures.push(`${route}: a secret-shaped assignment was emitted`) | |
| 142 | ||
| 143 | const assetPattern = /(?:src|href)=["'](\/[^"'?#]+)["']/gi | |
| 144 | let match: RegExpExecArray | null | |
| 145 | while ((match = assetPattern.exec(artifact.html))) { | |
| 146 | const assetPath = match[1] | |
| 147 | const looksLikeAsset = extname(assetPath) !== '' || assetPath.startsWith('/_stx/') || assetPath.startsWith('/__stx/') | |
| 148 | if (!looksLikeAsset) continue | |
| 149 | const local = resolve(publicRoot, `.${assetPath}`) | |
| 150 | if (!local.startsWith(`${publicRoot}/`) || !existsSync(local)) | |
| 151 | failures.push(`${route}: referenced asset is missing: ${assetPath}`) | |
| 152 | } | |
| 153 | } | |
| 154 | ||
| 155 | for (const metadataFile of ['sitemap.xml', 'robots.txt']) { | |
| 156 | const path = join(publicRoot, metadataFile) | |
| 157 | if (!existsSync(path)) { | |
| 158 | failures.push(`${metadataFile}: missing from production artifact`) | |
| 159 | continue | |
| 160 | } | |
| 161 | const contents = readFileSync(path, 'utf8') | |
| 162 | if (contents.includes('http://localhost')) failures.push(`${metadataFile}: contains a localhost production URL`) | |
| 163 | if (!contents.includes(contract.domain)) failures.push(`${metadataFile}: does not name ${contract.domain}`) | |
| 164 | } | |
| 165 | ||
| 166 | const migrationsDir = resolve(projectRoot, 'database/migrations') | |
| 167 | if (existsSync(migrationsDir)) { | |
| 168 | for (const file of readdirSync(migrationsDir).sort()) { | |
| 169 | const path = join(migrationsDir, file) | |
| 170 | if (!statSync(path).isFile()) continue | |
| 171 | if (readFileSync(path, 'utf8').trim().length === 0) failures.push(`migration is empty: ${file}`) | |
| 172 | const sql = readFileSync(path, 'utf8') | |
| 173 | if (/^(<{7}|={7}|>{7})/m.test(sql)) failures.push(`migration contains a merge marker: ${file}`) | |
| 174 | } | |
| 175 | } | |
| 176 | ||
| 177 | if (isSsr && failures.length === 0) { | |
| 178 | const { startProductionServer } = await import('@stacksjs/stx') | |
| 179 | const port = 42_000 + Math.floor(Math.random() * 8_000) | |
| 180 | const server = await startProductionServer({ outputDir: ssrRoot, port }) | |
| 181 | try { | |
| 182 | for (const route of Object.keys(contract.routes)) { | |
| 183 | const response = await fetch(`http://127.0.0.1:${server.port}${route}`) | |
| 184 | const body = await response.text() | |
| 185 | if (!response.ok) failures.push(`${route}: production server returned ${response.status}`) | |
| 186 | if (!body.toLowerCase().includes(appName.toLowerCase())) failures.push(`${route}: production server response is not the expected application`) | |
| 187 | } | |
| 188 | } | |
| 189 | finally { | |
| 190 | server.stop() | |
| 191 | } | |
| 192 | } | |
| 193 | ||
| 194 | if (failures.length) { | |
| 195 | for (const failure of failures) console.error(`- ${failure}`) | |
| 196 | process.exit(1) | |
| 197 | } | |
| 198 | ||
| 199 | console.log(`Validated ${pageFiles.length} production pages, ${Object.keys(contract.routes).length} smoke routes, metadata, assets, migrations, and ${isSsr ? 'the SSR server' : 'the SSG artifact'}.`) | |
| @@ -0,0 +1,90 @@ | ||
| 1 | import { readdirSync, readFileSync, statSync } from 'node:fs' | |
| 2 | import { join, relative, resolve } from 'node:path' | |
| 3 | ||
| 4 | const root = process.cwd() | |
| 5 | const resources = resolve(root, 'resources') | |
| 6 | const issues = new Set<string>() | |
| 7 | ||
| 8 | function filesIn(directory: string): string[] { | |
| 9 | return readdirSync(directory).flatMap((name) => { | |
| 10 | const path = join(directory, name) | |
| 11 | return statSync(path).isDirectory() ? filesIn(path) : /\.(?:stx|html|css)$/.test(path) ? [path] : [] | |
| 12 | }) | |
| 13 | } | |
| 14 | ||
| 15 | function withoutComments(value: string): string { | |
| 16 | const preserveLines = (comment: string): string => comment.replace(/[^\n]/g, ' ') | |
| 17 | return value | |
| 18 | .replace(/<!--[^]*?-->/g, preserveLines) | |
| 19 | .replace(/\{\{--[^]*?--\}\}/g, preserveLines) | |
| 20 | .replace(/\/\*[^]*?\*\//g, preserveLines) | |
| 21 | .replace(/^\s*\/\/.*$/gm, preserveLines) | |
| 22 | } | |
| 23 | ||
| 24 | function lineAt(source: string, index: number): number { | |
| 25 | return source.slice(0, index).split('\n').length | |
| 26 | } | |
| 27 | ||
| 28 | function named(attributes: string): boolean { | |
| 29 | return /\b(?:aria-label|aria-labelledby|title|:aria-label)=/.test(attributes) | |
| 30 | } | |
| 31 | ||
| 32 | function report(file: string, source: string, index: number, message: string): void { | |
| 33 | issues.add(`${relative(root, file)}:${lineAt(source, index)}: ${message}`) | |
| 34 | } | |
| 35 | ||
| 36 | const files = filesIn(resources) | |
| 37 | const markupFiles = files.filter(file => /\.(?:stx|html)$/.test(file)) | |
| 38 | const combined = files.map(file => readFileSync(file, 'utf8')).join('\n') | |
| 39 | ||
| 40 | for (const file of markupFiles) { | |
| 41 | const original = readFileSync(file, 'utf8') | |
| 42 | const source = withoutComments(original) | |
| 43 | ||
| 44 | for (const match of source.matchAll(/<img\b[^>]*>/gi)) { | |
| 45 | if (!/\b(?::?alt)=/.test(match[0])) | |
| 46 | report(file, source, match.index, 'image has no alt text') | |
| 47 | } | |
| 48 | ||
| 49 | for (const match of source.matchAll(/<button\b([^>]*)>([^]*?)<\/button>/gi)) { | |
| 50 | const attributes = match[1] | |
| 51 | const content = match[2] | |
| 52 | .replace(/<[^>]+>/g, '') | |
| 53 | .replace(/\{\{[^}]+\}\}/g, 'dynamic') | |
| 54 | .replace(/@[a-z]+\([^)]*\)/gi, '') | |
| 55 | .trim() | |
| 56 | if (!content && !named(attributes)) | |
| 57 | report(file, source, match.index, 'icon-only button has no accessible name') | |
| 58 | } | |
| 59 | ||
| 60 | for (const match of source.matchAll(/<(input|select|textarea)\b([^>]*)>/gi)) { | |
| 61 | const attributes = match[2] | |
| 62 | if (/\btype=["']hidden["']/i.test(attributes) || named(attributes)) | |
| 63 | continue | |
| 64 | const id = attributes.match(/\bid=["']([^"']+)["']/i)?.[1] | |
| 65 | const before = source.slice(0, match.index) | |
| 66 | const wrapped = before.lastIndexOf('<label') > before.lastIndexOf('</label>') | |
| 67 | const referenced = id ? new RegExp(`<label\\b[^>]*\\bfor=["']${id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}["']`, 'i').test(source) : false | |
| 68 | if (!wrapped && !referenced) | |
| 69 | report(file, source, match.index, `${match[1].toLowerCase()} has no programmatic label`) | |
| 70 | } | |
| 71 | ||
| 72 | for (const match of source.matchAll(/\btabindex=["']([1-9]\d*)["']/gi)) | |
| 73 | report(file, source, match.index, `positive tabindex ${match[1]} overrides natural keyboard order`) | |
| 74 | ||
| 75 | for (const match of source.matchAll(/<(?:div|span)\b([^>]*\bonclick=[^>]*)>/gi)) { | |
| 76 | if (!/\brole=["'](?:button|link)["']/i.test(match[1]) || !/\btabindex=["']0["']/i.test(match[1])) | |
| 77 | report(file, source, match.index, 'clickable non-interactive element is not keyboard accessible') | |
| 78 | } | |
| 79 | } | |
| 80 | ||
| 81 | if (!/(?:focus-visible|:focus|focus:|focus-within)/.test(combined)) | |
| 82 | issues.add('resources: no visible keyboard focus treatment found') | |
| 83 | if (!/(?:@media|\b(?:sm|md|lg|xl):)/.test(combined)) | |
| 84 | issues.add('resources: no responsive breakpoint treatment found') | |
| 85 | ||
| 86 | console.log(`Validated ${markupFiles.length} UI templates across desktop, tablet, and mobile contracts.`) | |
| 87 | if (issues.size) { | |
| 88 | for (const issue of [...issues].sort()) console.error(`- ${issue}`) | |
| 89 | process.exit(1) | |
| 90 | } | |
| @@ -0,0 +1,364 @@ | ||
| 1 | import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' | |
| 2 | import { tmpdir } from 'node:os' | |
| 3 | import { join, resolve } from 'node:path' | |
| 4 | ||
| 5 | interface Viewport { | |
| 6 | name: string | |
| 7 | width: number | |
| 8 | height: number | |
| 9 | mobile: boolean | |
| 10 | } | |
| 11 | ||
| 12 | interface PageAudit { | |
| 13 | contrast: string[] | |
| 14 | focusable: number | |
| 15 | imagesWithoutAlt: number | |
| 16 | overflow: number | |
| 17 | undersizedDashboardControls: string[] | |
| 18 | wrappedDashboardValues: string[] | |
| 19 | unresolved: boolean | |
| 20 | unnamedControls: string[] | |
| 21 | } | |
| 22 | ||
| 23 | interface Pending { | |
| 24 | reject: (error: Error) => void | |
| 25 | resolve: (value: any) => void | |
| 26 | timer: ReturnType<typeof setTimeout> | |
| 27 | } | |
| 28 | ||
| 29 | const DIST = resolve('dist') | |
| 30 | const SSR_OUTPUT = resolve('.output') | |
| 31 | const SSR_PAGES = resolve(SSR_OUTPUT, 'server/pages') | |
| 32 | const SSR_PUBLIC = resolve(SSR_OUTPUT, 'public') | |
| 33 | const OUTPUT = resolve('storage/logs/visual-contract') | |
| 34 | const VIEWPORTS: Viewport[] = [ | |
| 35 | { name: 'desktop', width: 1440, height: 1000, mobile: false }, | |
| 36 | { name: 'tablet', width: 768, height: 1024, mobile: false }, | |
| 37 | { name: 'mobile', width: 390, height: 844, mobile: true }, | |
| 38 | ] | |
| 39 | const THEMES = ['light', 'dark'] as const | |
| 40 | const ROUTE_CANDIDATES = [ | |
| 41 | '/', | |
| 42 | '/login', | |
| 43 | '/register', | |
| 44 | '/dashboard', | |
| 45 | '/dashboard/commshq', | |
| 46 | '/reports', | |
| 47 | '/projects', | |
| 48 | ] | |
| 49 | ||
| 50 | function chromePath(): string { | |
| 51 | const candidates = [ | |
| 52 | process.env.CHROME_PATH, | |
| 53 | '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', | |
| 54 | '/usr/bin/google-chrome', | |
| 55 | '/usr/bin/google-chrome-stable', | |
| 56 | '/usr/bin/chromium', | |
| 57 | '/usr/bin/chromium-browser', | |
| 58 | ] | |
| 59 | const found = candidates.find(candidate => candidate && existsSync(candidate)) | |
| 60 | if (!found) throw new Error('Chrome was not found. Set CHROME_PATH to a Chrome or Chromium executable.') | |
| 61 | return found | |
| 62 | } | |
| 63 | ||
| 64 | async function waitFor<T>(read: () => Promise<T> | T, timeout = 12_000): Promise<T> { | |
| 65 | const started = Date.now() | |
| 66 | let value = await read() | |
| 67 | while (!value && Date.now() - started < timeout) { | |
| 68 | await Bun.sleep(100) | |
| 69 | value = await read() | |
| 70 | } | |
| 71 | if (!value) throw new Error(`Timed out after ${timeout}ms`) | |
| 72 | return value | |
| 73 | } | |
| 74 | ||
| 75 | class Cdp { | |
| 76 | private id = 0 | |
| 77 | private pending = new Map<number, Pending>() | |
| 78 | ||
| 79 | constructor(private socket: WebSocket) { | |
| 80 | socket.addEventListener('message', (event) => { | |
| 81 | const message = JSON.parse(String(event.data)) | |
| 82 | if (!message.id) return | |
| 83 | const pending = this.pending.get(message.id) | |
| 84 | if (!pending) return | |
| 85 | this.pending.delete(message.id) | |
| 86 | clearTimeout(pending.timer) | |
| 87 | if (message.error) pending.reject(new Error(message.error.message)) | |
| 88 | else pending.resolve(message.result) | |
| 89 | }) | |
| 90 | } | |
| 91 | ||
| 92 | send(method: string, params: Record<string, any> = {}): Promise<any> { | |
| 93 | const id = ++this.id | |
| 94 | return new Promise((resolve, reject) => { | |
| 95 | const timer = setTimeout(() => { | |
| 96 | this.pending.delete(id) | |
| 97 | reject(new Error(`Chrome command timed out: ${method}`)) | |
| 98 | }, 15_000) | |
| 99 | this.pending.set(id, { reject, resolve, timer }) | |
| 100 | this.socket.send(JSON.stringify({ id, method, params })) | |
| 101 | }) | |
| 102 | } | |
| 103 | ||
| 104 | async evaluate<T>(expression: string): Promise<T> { | |
| 105 | const result = await this.send('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true }) | |
| 106 | if (result.exceptionDetails) | |
| 107 | throw new Error(result.exceptionDetails.exception?.description || result.exceptionDetails.text) | |
| 108 | return result.result?.value as T | |
| 109 | } | |
| 110 | } | |
| 111 | ||
| 112 | async function connect(url: string): Promise<Cdp> { | |
| 113 | const socket = new WebSocket(url) | |
| 114 | await new Promise<void>((resolve, reject) => { | |
| 115 | socket.addEventListener('open', () => resolve(), { once: true }) | |
| 116 | socket.addEventListener('error', () => reject(new Error('Could not connect to Chrome')), { once: true }) | |
| 117 | }) | |
| 118 | return new Cdp(socket) | |
| 119 | } | |
| 120 | ||
| 121 | function routeName(route: string): string { | |
| 122 | return route === '/' ? 'home' : route.slice(1).replaceAll('/', '-') | |
| 123 | } | |
| 124 | ||
| 125 | function routeFile(route: string): { kind: 'html' | 'ssr', path: string } | undefined { | |
| 126 | if (existsSync(DIST)) { | |
| 127 | const base = route === '/' ? join(DIST, 'index.html') : join(DIST, `${route.slice(1)}.html`) | |
| 128 | if (existsSync(base)) return { kind: 'html', path: base } | |
| 129 | const index = join(DIST, route.slice(1), 'index.html') | |
| 130 | if (existsSync(index)) return { kind: 'html', path: index } | |
| 131 | } | |
| 132 | ||
| 133 | const name = route === '/' ? 'index' : route.slice(1).replaceAll('/', '-') | |
| 134 | const compiled = join(SSR_PAGES, `${name}.compiled.json`) | |
| 135 | return existsSync(compiled) ? { kind: 'ssr', path: compiled } : undefined | |
| 136 | } | |
| 137 | ||
| 138 | function pageResponse(page: { kind: 'html' | 'ssr', path: string }): Response { | |
| 139 | if (page.kind === 'html') return new Response(Bun.file(page.path)) | |
| 140 | const compiled = JSON.parse(readFileSync(page.path, 'utf8')) as { html?: string } | |
| 141 | return new Response(compiled.html || '', { headers: { 'content-type': 'text/html; charset=utf-8' } }) | |
| 142 | } | |
| 143 | ||
| 144 | const auditExpression = `(() => { | |
| 145 | const visible = element => { | |
| 146 | const style = getComputedStyle(element) | |
| 147 | const rect = element.getBoundingClientRect() | |
| 148 | if (element.closest('details:not([open])') && element.tagName !== 'SUMMARY') return false | |
| 149 | return style.display !== 'none' && style.visibility !== 'hidden' && Number(style.opacity) > 0 | |
| 150 | && rect.width > 0 && rect.height > 0 | |
| 151 | } | |
| 152 | const label = element => { | |
| 153 | if (element.getAttribute('aria-label')) return element.getAttribute('aria-label') | |
| 154 | const labelledBy = element.getAttribute('aria-labelledby') | |
| 155 | if (labelledBy && labelledBy.split(/\\s+/).some(id => document.getElementById(id)?.textContent?.trim())) return labelledBy | |
| 156 | if (element.id && document.querySelector('label[for="' + CSS.escape(element.id) + '"]')?.textContent?.trim()) return element.id | |
| 157 | if (element.closest('label')?.textContent?.trim()) return 'wrapped' | |
| 158 | if (element.tagName === 'BUTTON' && element.textContent?.trim()) return 'text' | |
| 159 | if (element.tagName === 'A' && element.textContent?.trim()) return 'text' | |
| 160 | if (element.getAttribute('title')) return element.getAttribute('title') | |
| 161 | return '' | |
| 162 | } | |
| 163 | const parseRgb = value => { | |
| 164 | const match = value.match(/rgba?\\(([^)]+)\\)/) | |
| 165 | if (!match) return null | |
| 166 | const parts = match[1].split(/[ ,/]+/).filter(Boolean).map(Number) | |
| 167 | if (parts.length < 3 || parts.some(Number.isNaN)) return null | |
| 168 | return { r: parts[0], g: parts[1], b: parts[2], a: parts[3] ?? 1 } | |
| 169 | } | |
| 170 | const luminance = color => { | |
| 171 | const channel = value => { | |
| 172 | const normalized = value / 255 | |
| 173 | return normalized <= 0.03928 ? normalized / 12.92 : ((normalized + 0.055) / 1.055) ** 2.4 | |
| 174 | } | |
| 175 | return 0.2126 * channel(color.r) + 0.7152 * channel(color.g) + 0.0722 * channel(color.b) | |
| 176 | } | |
| 177 | const background = element => { | |
| 178 | let current = element | |
| 179 | while (current) { | |
| 180 | const color = parseRgb(getComputedStyle(current).backgroundColor) | |
| 181 | if (color && color.a >= 0.98) return color | |
| 182 | current = current.parentElement | |
| 183 | } | |
| 184 | return { r: 255, g: 255, b: 255, a: 1 } | |
| 185 | } | |
| 186 | const controls = [...document.querySelectorAll('a[href], button, input:not([type="hidden"]), select, textarea')].filter(visible) | |
| 187 | const dashboardControls = [...document.querySelectorAll('.site-switcher, .range-link, .range-tool, .chrome-btn, .ga-row, .goal-form input, .goal-form select, .goal-form button')].filter(visible) | |
| 188 | const undersizedDashboardControls = dashboardControls.flatMap(element => { | |
| 189 | const rect = element.getBoundingClientRect() | |
| 190 | if (rect.width >= 44 && rect.height >= 44) return [] | |
| 191 | const name = element.getAttribute('aria-label') || element.textContent?.trim().replace(/\\s+/g, ' ').slice(0, 32) || element.tagName.toLowerCase() | |
| 192 | return [name + ' (' + Math.round(rect.width) + 'x' + Math.round(rect.height) + ')'] | |
| 193 | }) | |
| 194 | const wrappedDashboardValues = [...document.querySelectorAll('.kpi-value')].filter(visible).flatMap(element => { | |
| 195 | const rect = element.getBoundingClientRect() | |
| 196 | const lineHeight = Number.parseFloat(getComputedStyle(element).lineHeight) | |
| 197 | return rect.height > lineHeight * 1.5 || element.scrollWidth > element.clientWidth + 1 | |
| 198 | ? [element.textContent?.trim() || 'metric value'] | |
| 199 | : [] | |
| 200 | }) | |
| 201 | const unnamedControls = controls.filter(element => !label(element)).map(element => { | |
| 202 | const identity = element.id ? '#' + element.id : element.getAttribute('name') ? '[name="' + element.getAttribute('name') + '"]' : '' | |
| 203 | return element.tagName.toLowerCase() + identity | |
| 204 | }) | |
| 205 | const contrast = [] | |
| 206 | const textElements = [...document.querySelectorAll('h1, h2, h3, h4, p, label, button, a, summary, span')] | |
| 207 | for (const element of textElements) { | |
| 208 | if (!visible(element) || !element.textContent?.trim()) continue | |
| 209 | if (element.children.length && !['A', 'BUTTON', 'SUMMARY'].includes(element.tagName)) continue | |
| 210 | const style = getComputedStyle(element) | |
| 211 | const foreground = parseRgb(style.color) | |
| 212 | const behind = background(element) | |
| 213 | if (!foreground || foreground.a < 0.98) continue | |
| 214 | const lighter = Math.max(luminance(foreground), luminance(behind)) | |
| 215 | const darker = Math.min(luminance(foreground), luminance(behind)) | |
| 216 | const ratio = (lighter + 0.05) / (darker + 0.05) | |
| 217 | const large = Number.parseFloat(style.fontSize) >= 24 || (Number.parseFloat(style.fontSize) >= 18.66 && Number(style.fontWeight) >= 700) | |
| 218 | if (ratio + 0.01 < (large ? 3 : 4.5)) { | |
| 219 | const sample = element.textContent.trim().replace(/\\s+/g, ' ').slice(0, 42) | |
| 220 | contrast.push(sample + ' (' + ratio.toFixed(2) + ':1)') | |
| 221 | } | |
| 222 | if (contrast.length >= 10) break | |
| 223 | } | |
| 224 | return { | |
| 225 | contrast, | |
| 226 | focusable: controls.length, | |
| 227 | imagesWithoutAlt: [...document.images].filter(image => !image.hasAttribute('alt')).length, | |
| 228 | overflow: Math.max(0, document.documentElement.scrollWidth - document.documentElement.clientWidth), | |
| 229 | undersizedDashboardControls, | |
| 230 | wrappedDashboardValues, | |
| 231 | unresolved: document.body.innerText.includes('{{') || document.body.innerText.includes('@if'), | |
| 232 | unnamedControls, | |
| 233 | } | |
| 234 | })()` | |
| 235 | ||
| 236 | const dashboardFixtureExpression = `(() => { | |
| 237 | const fixture = document.createElement('section') | |
| 238 | fixture.id = 'visual-dashboard-fixture' | |
| 239 | fixture.setAttribute('aria-hidden', 'true') | |
| 240 | fixture.style.cssText = 'position:absolute;left:1rem;top:0;width:calc(100vw - 2rem);opacity:0.001;pointer-events:none;z-index:-1' | |
| 241 | const columns = Array.from({ length: 90 }, (_, index) => '<div class="relative flex-1 ga-col"><div class="ga-tip">Day ' + index + '</div></div>').join('') | |
| 242 | fixture.innerHTML = '<select class="panel site-switcher" aria-label="Switch site"><option>example.com</option></select><a class="range-link" href="#">24h</a><button class="range-tool">Export</button><a class="chrome-btn" href="#">Account</a><a class="ga-row" href="#">Page row</a><form class="goal-form"><input aria-label="Goal name"></form><section class="grid grid-cols-2"><div class="px-5"><div class="kpi-label">Average visit time</div><div class="text-3xl kpi-value mono">27m 47s</div></div></section><div class="p-5 panel"><div class="relative h-44 w-full ga-chart"><div class="flex absolute inset-0">' + columns + '</div></div></div>' | |
| 243 | document.body.appendChild(fixture) | |
| 244 | })()` | |
| 245 | ||
| 246 | async function main(): Promise<void> { | |
| 247 | if (!existsSync(DIST) && !existsSync(SSR_PAGES)) | |
| 248 | throw new Error(`Built site not found at ${DIST} or ${SSR_OUTPUT}. Run bun run build first.`) | |
| 249 | const routes = ROUTE_CANDIDATES.filter(route => routeFile(route)) | |
| 250 | if (routes.length < 3) throw new Error(`Expected at least three visual routes, found ${routes.join(', ') || 'none'}.`) | |
| 251 | ||
| 252 | mkdirSync(OUTPUT, { recursive: true }) | |
| 253 | const server = Bun.serve({ | |
| 254 | hostname: '127.0.0.1', | |
| 255 | port: 0, | |
| 256 | fetch(request) { | |
| 257 | const url = new URL(request.url) | |
| 258 | const route = decodeURIComponent(url.pathname) | |
| 259 | const page = routeFile(route) | |
| 260 | if (page) return pageResponse(page) | |
| 261 | const publicRoot = existsSync(DIST) ? DIST : SSR_PUBLIC | |
| 262 | const asset = resolve(publicRoot, `.${route}`) | |
| 263 | const file = asset.startsWith(`${publicRoot}/`) && existsSync(asset) ? asset : undefined | |
| 264 | return file ? new Response(Bun.file(file)) : new Response('Not found', { status: 404 }) | |
| 265 | }, | |
| 266 | }) | |
| 267 | const profile = mkdtempSync(join(tmpdir(), 'hq-visual-')) | |
| 268 | const chrome = Bun.spawn([ | |
| 269 | chromePath(), | |
| 270 | '--headless=new', | |
| 271 | '--remote-debugging-port=0', | |
| 272 | `--user-data-dir=${profile}`, | |
| 273 | '--no-first-run', | |
| 274 | '--no-default-browser-check', | |
| 275 | '--disable-background-networking', | |
| 276 | '--disable-component-update', | |
| 277 | '--disable-extensions', | |
| 278 | '--hide-scrollbars', | |
| 279 | '--no-sandbox', | |
| 280 | 'about:blank', | |
| 281 | ], { stdout: 'ignore', stderr: 'ignore' }) | |
| 282 | ||
| 283 | const failures: string[] = [] | |
| 284 | let screenshots = 0 | |
| 285 | try { | |
| 286 | const portFile = join(profile, 'DevToolsActivePort') | |
| 287 | await waitFor(() => existsSync(portFile), 10_000) | |
| 288 | const port = Number(readFileSync(portFile, 'utf8').split('\n')[0]) | |
| 289 | const targets = await waitFor(async () => { | |
| 290 | const items = await fetch(`http://127.0.0.1:${port}/json/list`).then(response => response.json()).catch(() => []) | |
| 291 | return Array.isArray(items) && items.length ? items : null | |
| 292 | }) as Array<{ type: string, url: string, webSocketDebuggerUrl: string }> | |
| 293 | const target = targets.find(item => item.type === 'page' && item.url === 'about:blank') || targets.find(item => item.type === 'page') | |
| 294 | if (!target) throw new Error('Chrome did not expose a page target.') | |
| 295 | ||
| 296 | const cdp = await connect(target.webSocketDebuggerUrl) | |
| 297 | await cdp.send('Page.enable') | |
| 298 | await cdp.send('Runtime.enable') | |
| 299 | await cdp.send('Emulation.setScriptExecutionDisabled', { value: true }) | |
| 300 | ||
| 301 | for (const route of routes) { | |
| 302 | for (const viewport of VIEWPORTS) { | |
| 303 | await cdp.send('Emulation.setDeviceMetricsOverride', { | |
| 304 | width: viewport.width, | |
| 305 | height: viewport.height, | |
| 306 | deviceScaleFactor: 1, | |
| 307 | mobile: viewport.mobile, | |
| 308 | }) | |
| 309 | await cdp.send('Page.navigate', { url: `http://127.0.0.1:${server.port}${route}` }) | |
| 310 | await waitFor(() => cdp.evaluate("document.readyState === 'complete'")) | |
| 311 | await Bun.sleep(100) | |
| 312 | for (const theme of THEMES) { | |
| 313 | await cdp.evaluate(`(() => { | |
| 314 | const theme = ${JSON.stringify(theme)} | |
| 315 | document.documentElement.classList.toggle('dark', theme === 'dark') | |
| 316 | document.documentElement.setAttribute('data-theme', theme) | |
| 317 | document.documentElement.setAttribute('data-color-mode', theme) | |
| 318 | document.documentElement.setAttribute('color-mode', theme) | |
| 319 | })()`) | |
| 320 | await Bun.sleep(500) | |
| 321 | if (route === '/dashboard') await cdp.evaluate(dashboardFixtureExpression) | |
| 322 | const audit = await cdp.evaluate<PageAudit>(auditExpression) | |
| 323 | if (route === '/dashboard') await cdp.evaluate("document.getElementById('visual-dashboard-fixture')?.remove()") | |
| 324 | const key = `${route} at ${viewport.name}/${theme}` | |
| 325 | if (audit.overflow > 1) failures.push(`${key}: ${audit.overflow}px horizontal overflow`) | |
| 326 | if (viewport.mobile && audit.undersizedDashboardControls.length) | |
| 327 | failures.push(`${key}: undersized dashboard controls ${audit.undersizedDashboardControls.join(', ')}`) | |
| 328 | if (viewport.mobile && audit.wrappedDashboardValues.length) | |
| 329 | failures.push(`${key}: wrapped dashboard metric values ${audit.wrappedDashboardValues.join(', ')}`) | |
| 330 | if (audit.unresolved) failures.push(`${key}: unresolved template expression is visible`) | |
| 331 | if (audit.imagesWithoutAlt) failures.push(`${key}: ${audit.imagesWithoutAlt} image(s) have no alt attribute`) | |
| 332 | if (audit.unnamedControls.length) failures.push(`${key}: unnamed controls ${audit.unnamedControls.join(', ')}`) | |
| 333 | for (const issue of audit.contrast) failures.push(`${key}: low text contrast ${issue}`) | |
| 334 | ||
| 335 | if (audit.focusable) { | |
| 336 | await cdp.evaluate("document.body.setAttribute('tabindex', '-1'); document.body.focus()") | |
| 337 | await cdp.send('Input.dispatchKeyEvent', { type: 'keyDown', key: 'Tab', code: 'Tab', windowsVirtualKeyCode: 9 }) | |
| 338 | await cdp.send('Input.dispatchKeyEvent', { type: 'keyUp', key: 'Tab', code: 'Tab', windowsVirtualKeyCode: 9 }) | |
| 339 | const focused = await cdp.evaluate("document.activeElement && document.activeElement !== document.body && document.activeElement !== document.documentElement") | |
| 340 | if (!focused) failures.push(`${key}: Tab did not move focus to a control`) | |
| 341 | } | |
| 342 | ||
| 343 | const shot = await cdp.send('Page.captureScreenshot', { format: 'png', captureBeyondViewport: true, fromSurface: true }) | |
| 344 | writeFileSync(join(OUTPUT, `${routeName(route)}-${theme}-${viewport.name}.png`), Buffer.from(shot.data, 'base64')) | |
| 345 | screenshots += 1 | |
| 346 | } | |
| 347 | } | |
| 348 | } | |
| 349 | } | |
| 350 | finally { | |
| 351 | chrome.kill() | |
| 352 | await chrome.exited | |
| 353 | server.stop(true) | |
| 354 | rmSync(profile, { force: true, recursive: true }) | |
| 355 | } | |
| 356 | ||
| 357 | console.log(`Rendered ${routes.length} routes in ${THEMES.length} themes at ${VIEWPORTS.length} viewports and wrote ${screenshots} screenshots.`) | |
| 358 | if (failures.length) { | |
| 359 | for (const failure of failures) console.error(`- ${failure}`) | |
| 360 | process.exit(1) | |
| 361 | } | |
| 362 | } | |
| 363 | ||
| 364 | await main() | |
| @@ -0,0 +1,19 @@ | ||
| 1 | import { describe, expect, test } from 'bun:test' | |
| 2 | ||
| 3 | const NOW = '2026-09-12T12:00:00.000Z' | |
| 4 | ||
| 5 | export const productStates = { | |
| 6 | empty: { now: NOW, status: 'ready', sites: [], visitors: 0, views: 0 }, | |
| 7 | normal: { now: NOW, status: 'ready', sites: [{ id: 'site-demo', name: 'Northwind', visitors: 1842, views: 5291 }], visitors: 1842, views: 5291 }, | |
| 8 | loading: { now: NOW, status: 'loading', sites: [], visitors: null, views: null }, | |
| 9 | failure: { now: NOW, status: 'error', sites: [], error: 'Analytics query timed out' }, | |
| 10 | highVolume: { now: NOW, status: 'ready', sites: Array.from({ length: 250 }, (_, index) => ({ id: `site-${index + 1}`, name: `Site ${index + 1}`, visitors: 100_000 + index, views: 300_000 + index * 3 })) }, | |
| 11 | } as const | |
| 12 | ||
| 13 | describe('deterministic analytics product states', () => { | |
| 14 | test('covers every UI state', () => expect(Object.keys(productStates)).toEqual(['empty', 'normal', 'loading', 'failure', 'highVolume'])) | |
| 15 | test('keeps volume fixtures large and reproducible', () => { | |
| 16 | expect(productStates.highVolume.sites).toHaveLength(250) | |
| 17 | expect(JSON.stringify(productStates)).toBe(JSON.stringify(productStates)) | |
| 18 | }) | |
| 19 | }) | |
| @@ -357,7 +357,7 @@ describe('the map talks to nothing but this origin', () => { | ||
| 357 | 357 | |
| 358 | 358 | test('interaction is disabled — this is a report, not an explorer', () => { |
| 359 | 359 | const s = mapScript() |
| 360 | for (const opt of ['dragging', 'scrollWheelZoom', 'doubleClickZoom', 'touchZoom']) | |
| 360 | for (const opt of ['dragging', 'scrollWheelZoom', 'doubleClickZoom', 'pinchZoom']) | |
| 361 | 361 | expect({ opt, disabled: new RegExp(`${opt}:\\s*false`).test(s) }).toEqual({ opt, disabled: true }) |
| 362 | 362 | }) |
| 363 | 363 | |
| @@ -0,0 +1,67 @@ | ||
| 1 | import { describe, expect, test } from 'bun:test' | |
| 2 | import { readFileSync } from 'node:fs' | |
| 3 | import { join } from 'node:path' | |
| 4 | import { MAX_EVENT_PROPERTIES_BYTES, serializeEventProperties } from '../../app/Analytics/event-properties' | |
| 5 | ||
| 6 | describe('event property serialization', () => { | |
| 7 | test('preserves structured metadata well beyond the old 255-character column', () => { | |
| 8 | const properties = { | |
| 9 | campaign: 'spring-launch', | |
| 10 | notes: 'measured-context-'.repeat(700), | |
| 11 | } | |
| 12 | const serialized = serializeEventProperties('Signup', properties) | |
| 13 | ||
| 14 | expect(serialized).toBe(JSON.stringify(properties)) | |
| 15 | expect(serialized!.length).toBeGreaterThan(10_000) | |
| 16 | }) | |
| 17 | ||
| 18 | test('keeps the complete URL for reserved automatic events', () => { | |
| 19 | const url = `https://example.com/${'deep/path/'.repeat(900)}?source=campaign` | |
| 20 | const serialized = serializeEventProperties('Outbound Link', { | |
| 21 | ignored: 'does not split the aggregate', | |
| 22 | url, | |
| 23 | }) | |
| 24 | ||
| 25 | expect(serialized).toBe(JSON.stringify({ url })) | |
| 26 | expect(JSON.parse(serialized!).url).toBe(url) | |
| 27 | }) | |
| 28 | ||
| 29 | test('canonicalizes file downloads the same way', () => { | |
| 30 | expect(serializeEventProperties('File Download', { | |
| 31 | url: 'https://example.com/report.csv', | |
| 32 | arbitrary: true, | |
| 33 | })).toBe('{"url":"https://example.com/report.csv"}') | |
| 34 | }) | |
| 35 | ||
| 36 | test('omits optional properties that exceed the UTF-8 byte budget', () => { | |
| 37 | const properties = { notes: 'é'.repeat(MAX_EVENT_PROPERTIES_BYTES) } | |
| 38 | ||
| 39 | expect(serializeEventProperties('Oversized', properties)).toBeNull() | |
| 40 | }) | |
| 41 | ||
| 42 | test('omits values JSON cannot serialize', () => { | |
| 43 | expect(serializeEventProperties('Invalid', 1n)).toBeNull() | |
| 44 | }) | |
| 45 | }) | |
| 46 | ||
| 47 | describe('event property storage', () => { | |
| 48 | const root = join(import.meta.dir, '..', '..') | |
| 49 | ||
| 50 | test('upgrades existing PostgreSQL columns to text', () => { | |
| 51 | const migration = readFileSync( | |
| 52 | join(root, 'database/migrations/0000000052-widen-custom-event-properties.sql'), | |
| 53 | 'utf8', | |
| 54 | ) | |
| 55 | ||
| 56 | expect(migration).toContain('ALTER COLUMN "properties" TYPE text') | |
| 57 | expect(migration).toContain('USING "properties"::text') | |
| 58 | }) | |
| 59 | ||
| 60 | test('the collector uses the bounded serializer without URL clipping', () => { | |
| 61 | const source = readFileSync(join(root, 'routes/analytics.ts'), 'utf8') | |
| 62 | ||
| 63 | expect(source).toContain('serializeEventProperties(event, body.p)') | |
| 64 | expect(source).not.toContain('while (props.length > 255') | |
| 65 | expect(source).not.toContain('widen custom_events.properties') | |
| 66 | }) | |
| 67 | }) | |