From b51b0cbfd08c7a47937f275fdca877b6844162ec Mon Sep 17 00:00:00 2001 From: shalanah Date: Sat, 14 Mar 2026 14:58:45 -0500 Subject: [PATCH] wip --- .gitignore | 2 + compat/compat.json | 981 ++++++++++++++++++++++++++++++++ compat/parser.ts | 364 ++++++++++++ compat/types.ts | 202 +++++++ compat/utils.ts | 128 +++++ index.html | 6 +- package.json | 5 + pnpm-lock.yaml | 156 +++++ src/Layout.tsx | 46 ++ src/base/GlobalStyles.ts | 5 + src/index.css | 20 + src/main.tsx | 7 +- src/pages/Compat.tsx | 5 + src/{App.tsx => pages/Home.tsx} | 19 +- src/router.tsx | 55 ++ src/sections/CompatTable.tsx | 278 +++++++++ src/sections/DeviceInfo.tsx | 26 +- src/utils/cn.ts | 4 + tailwind.config.js | 2 +- tsconfig.json | 2 +- 20 files changed, 2270 insertions(+), 43 deletions(-) create mode 100644 compat/compat.json create mode 100644 compat/parser.ts create mode 100644 compat/types.ts create mode 100644 compat/utils.ts create mode 100644 src/Layout.tsx create mode 100644 src/pages/Compat.tsx rename src/{App.tsx => pages/Home.tsx} (66%) create mode 100644 src/router.tsx create mode 100644 src/sections/CompatTable.tsx create mode 100644 src/utils/cn.ts diff --git a/.gitignore b/.gitignore index a547bf3..e871bfe 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,8 @@ dist dist-ssr *.local +.claude + # Editor directories and files .vscode/* !.vscode/extensions.json diff --git a/compat/compat.json b/compat/compat.json new file mode 100644 index 0000000..c412c3a --- /dev/null +++ b/compat/compat.json @@ -0,0 +1,981 @@ +{ + "title": "In-App Browser Compatibility Database", + "description": "Compatibility data for in-app browsers across mobile applications, inspired by caniuse format", + "lastUpdated": "2026-03-14", + "version": "1.0.0", + "platforms": { + "android": { + "description": "On Android, use the intent link to escape most in-app browsers.", + "escapeMethod": { + "name": "Intent link", + "pattern": "intent://example.com#Intent;scheme=https;end", + "description": "Opens link in default browser instead of in-app" + } + }, + "ios": { + "description": "There is no Apple-approved way to escape in-app browsers to the default browser in contrast to Android intent links.", + "notes": [ + "SFSVC (Safari View Controller) - similar to Tab View on Android except it doesn't honour default browser selection", + "Safari View Controller has a broken UX downloading experience compared to normal iOS Safari" + ], + "escapeMethods": { + "safari_scheme": { + "name": "Safari scheme", + "pattern": "x-safari-https://example.com" + }, + "browser_scheme": { + "name": "Browser scheme", + "pattern": "googlechromes://example.com", + "description": "Browser-specific schemes like Chrome" + } + } + } + }, + "features": { + "default_browser": { + "title": "Browser type", + "description": "Opens links in system default browser or tab view", + "category": "browser" + }, + "detect_inapp": { + "title": "Detectable", + "description": "Ability to detect if running in an in-app browser", + "category": "browser" + }, + "blob_download": { + "title": "Blob URLs", + "description": "Support for downloading files from blob URLs", + "category": "download" + }, + "static_download": { + "title": "Static assets", + "description": "Support for downloading static file assets", + "category": "download" + }, + "intent_escape": { + "title": "Intent link", + "description": "Android: Support for intent links to escape to default browser", + "platforms": ["android"], + "category": "escape" + }, + "safari_escape": { + "title": "Safari scheme", + "description": "iOS: Support for x-safari-https scheme to escape to Safari", + "platforms": ["ios"], + "category": "escape" + }, + "browser_escape": { + "title": "Browser scheme", + "description": "iOS: Support for browser-specific schemes to escape to that browser", + "platforms": ["ios"], + "category": "escape" + } + }, + "statuses": { + "y": { + "title": "Supported", + "description": "Feature works as expected" + }, + "n": { + "title": "Not supported", + "description": "Feature does not work" + }, + "p": { + "title": "Partial support", + "description": "Limited or conditional support", + "symbol": "\ud83d\udfe1" + }, + "u": { + "title": "Unknown/Untested", + "description": "Support status unknown or not applicable", + "symbol": "-" + }, + "experimental": { + "title": "Experimental", + "description": "Experimental or beta support", + "symbol": "\ud83e\uddea" + } + }, + "noteDefinitions": { + "silent_failure": "Fails silently", + "fails_with_note": "Shows error message", + "new_window_timeout": "Opens in new window and timesout", + "opens_in_browser": "Opens file in browser doesn't download", + "sfsvc_poor_ux": "Broken UX - SFSVC opens in asset view mode, several unclear steps to download", + "intent_blocked": "Intent links are blocked and do not escape to default browser", + "intent_unreliable": "Intent link escape works inconsistently or is not dependable", + "fails_to_load": "Content fails to load completely", + "dont_select_safari": "Don't select Safari icon, opens in SFSVC instead of native Safari otherwise opens in default browser", + "chrome_http_only": "Only works with Chrome for http URLs (not https)", + "does_not_work": "Not supported", + "tab_view_intent": "Stays within tab view", + "default_browser_intent": "Opens link in new tab" + }, + "apps": { + "wechat": { + "name": "WeChat", + "platforms": { + "android": { + "versions": { + "1": { + "appVersion": "8.0.69", + "testedDate": "2026-03-14", + "support": { + "default_browser": "n", + "detect_inapp": "y", + "blob_download": "n", + "static_download": "n", + "intent_escape": "n" + }, + "engine": { + "type": "android_webview" + }, + "notes": { + "blob_download": "fails_with_note", + "static_download": "fails_with_note", + "intent_escape": "does_not_work" + } + } + }, + "latest": "1" + }, + "ios": { + "versions": { + "1": { + "testedDate": "2026-03-07", + "support": { + "default_browser": "n", + "detect_inapp": "y", + "blob_download": "n", + "static_download": "n", + "safari_escape": "n", + "browser_escape": "n" + }, + "notes": { + "blob_download": "fails_with_note", + "static_download": "fails_with_note" + }, + "engine": { + "type": "wkwebview" + } + } + }, + "latest": "1" + } + } + }, + "telegram": { + "name": "Telegram", + "platforms": { + "android": { + "versions": { + "1": { + "appVersion": "12.5.1", + "testedDate": "2026-03-14", + "support": { + "default_browser": "n", + "detect_inapp": "y", + "blob_download": "n", + "static_download": "y", + "intent_escape": "y" + }, + "engine": { + "type": "android_webview" + }, + "notes": { + "blob_download": "silent_failure" + } + } + }, + "latest": "1" + }, + "ios": { + "versions": { + "1": { + "testedDate": "2026-03-07", + "support": { + "default_browser": "n", + "detect_inapp": "y", + "blob_download": "n", + "static_download": "y", + "safari_escape": "y", + "browser_escape": "y" + }, + "notes": { + "blob_download": "opens_in_browser" + }, + "engine": { + "type": "wkwebview" + } + } + }, + "latest": "1" + } + } + }, + "line": { + "name": "Line", + "platforms": { + "android": { + "versions": { + "1": { + "testedDate": "2026-03-14", + "appVersion": "26.3.1", + "support": { + "default_browser": "n", + "detect_inapp": "y", + "blob_download": "n", + "static_download": "n", + "intent_escape": "y" + }, + "engine": { + "type": "android_webview" + }, + "notes": { + "blob_download": "fails_with_note", + "static_download": "new_window_timeout" + } + } + }, + "latest": "1" + }, + "ios": { + "versions": { + "1": { + "testedDate": "2026-03-07", + "support": { + "default_browser": "n", + "detect_inapp": "y", + "blob_download": "n", + "static_download": "n", + "safari_escape": "y", + "browser_escape": "y" + }, + "notes": { + "blob_download": "silent_failure", + "static_download": "opens_in_browser" + }, + "engine": { + "type": "wkwebview" + } + } + }, + "latest": "1" + } + } + }, + "tiktok": { + "name": "TikTok", + "platforms": { + "android": { + "versions": { + "1": { + "appVersion": "44.2.3", + "testedDate": "2026-03-14", + "support": { + "default_browser": "n", + "detect_inapp": "y", + "blob_download": "n", + "static_download": "n", + "intent_escape": "y" + }, + "engine": { + "type": "android_webview" + }, + "notes": { + "blob_download": "silent_failure", + "static_download": "silent_failure" + } + } + }, + "latest": "1" + }, + "ios": { + "versions": { + "1": { + "testedDate": "2026-03-07", + "support": { + "default_browser": "n", + "detect_inapp": "y", + "blob_download": "n", + "static_download": "n", + "safari_escape": "n", + "browser_escape": "n" + }, + "notes": { + "blob_download": "opens_in_browser", + "static_download": "opens_in_browser" + }, + "engine": { + "type": "wkwebview" + } + } + }, + "latest": "1" + } + } + }, + "threads": { + "name": "Threads", + "platforms": { + "android": { + "versions": { + "1": { + "testedDate": "2026-03-14", + "appVersion": "420.0.0.58.72", + "support": { + "default_browser": "n", + "detect_inapp": "y", + "blob_download": "n", + "static_download": "n", + "intent_escape": "y" + }, + "engine": { + "type": "android_webview" + }, + "notes": { + "blob_download": "fails_with_note", + "static_download": "opens_in_browser" + } + } + }, + "latest": "1" + }, + "ios": { + "versions": { + "1": { + "testedDate": "2026-03-07", + "support": { + "default_browser": "n", + "detect_inapp": "y", + "blob_download": "n", + "static_download": "n", + "safari_escape": "n", + "browser_escape": "y" + }, + "notes": { + "blob_download": "opens_in_browser", + "static_download": "opens_in_browser" + }, + "engine": { + "type": "wkwebview" + } + } + }, + "latest": "1" + } + } + }, + "facebook": { + "name": "Facebook", + "platforms": { + "android": { + "versions": { + "1": { + "testedDate": "2026-03-14", + "appVersion": "552.0.0.31.68", + "support": { + "default_browser": "n", + "detect_inapp": "y", + "blob_download": "n", + "static_download": "n", + "intent_escape": "y" + }, + "notes": { + "blob_download": "fails_with_note", + "static_download": "opens_in_browser" + }, + "engine": { + "type": "android_webview" + } + } + }, + "latest": "1" + }, + "ios": { + "versions": { + "1": { + "testedDate": "2026-03-07", + "support": { + "default_browser": "n", + "detect_inapp": "y", + "blob_download": "n", + "static_download": "n", + "safari_escape": "n", + "browser_escape": "y" + }, + "notes": { + "blob_download": "opens_in_browser", + "static_download": "opens_in_browser" + }, + "engine": { + "type": "wkwebview" + } + } + }, + "latest": "1" + } + } + }, + "instagram": { + "name": "Instagram", + "platforms": { + "android": { + "versions": { + "1": { + "testedDate": "2026-03-14", + "appVersion": "420.0.0.55.74", + "support": { + "default_browser": "n", + "detect_inapp": "y", + "blob_download": "n", + "static_download": "n", + "intent_escape": "y" + }, + "notes": { + "blob_download": "fails_with_note", + "static_download": "opens_in_browser" + }, + "engine": { + "type": "android_webview" + } + } + }, + "latest": "1" + }, + "ios": { + "versions": { + "1": { + "testedDate": "2026-03-07", + "support": { + "default_browser": "n", + "detect_inapp": "y", + "blob_download": "n", + "static_download": "n", + "safari_escape": "n", + "browser_escape": "y" + }, + "notes": { + "blob_download": "opens_in_browser", + "static_download": "opens_in_browser" + }, + "engine": { + "type": "wkwebview" + } + } + }, + "latest": "1" + } + } + }, + "messenger": { + "name": "Messenger", + "platforms": { + "android": { + "versions": { + "1": { + "testedDate": "2026-03-14", + "appVersion": "551.0.0.48.62", + "support": { + "default_browser": "n", + "detect_inapp": "y", + "blob_download": "n", + "static_download": "n", + "intent_escape": "y" + }, + "engine": { + "type": "android_webview" + }, + "notes": { + "blob_download": "fails_with_note", + "static_download": "opens_in_browser" + } + } + }, + "latest": "1" + }, + "ios": { + "versions": { + "1": { + "testedDate": "2026-03-07", + "support": { + "default_browser": "n", + "detect_inapp": "y", + "blob_download": "n", + "static_download": "n", + "safari_escape": "y", + "browser_escape": "y" + }, + "notes": { + "blob_download": "opens_in_browser", + "static_download": "opens_in_browser" + }, + "engine": { + "type": "wkwebview" + } + } + }, + "latest": "1" + } + } + }, + "snapchat": { + "name": "SnapChat", + "platforms": { + "android": { + "versions": { + "1": { + "testedDate": "2026-03-14", + "appVersion": "13.83.0.51", + "support": { + "default_browser": "y", + "detect_inapp": "na", + "blob_download": "y", + "static_download": "y", + "intent_escape": "n" + }, + "notes": { + "intent_escape": "tab_view_intent" + }, + "engine": { + "type": "android_tab_view" + } + } + }, + "latest": "1" + }, + "ios": { + "versions": { + "1": { + "testedDate": "2026-03-07", + "support": { + "default_browser": "n", + "detect_inapp": "y", + "blob_download": "n", + "static_download": "n", + "safari_escape": "n", + "browser_escape": "p" + }, + "notes": { + "blob_download": "silent_failure", + "static_download": "opens_in_browser", + "browser_escape": "chrome_http_only" + }, + "engine": { + "type": "wkwebview" + } + } + }, + "latest": "1" + } + } + }, + "linkedin": { + "name": "LinkedIn", + "platforms": { + "android": { + "versions": { + "1": { + "testedDate": "2024-11-07", + "appVersion": "4.1.1179", + "support": { + "default_browser": "y", + "detect_inapp": "na", + "blob_download": "y", + "static_download": "y", + "intent_escape": "n" + }, + "notes": { + "intent_escape": "tab_view_intent" + }, + "engine": { + "type": "android_tab_view" + } + } + }, + "latest": "1" + }, + "ios": { + "versions": { + "1": { + "testedDate": "2026-03-07", + "support": { + "default_browser": "n", + "detect_inapp": "y", + "blob_download": "n", + "static_download": "n", + "safari_escape": "y", + "browser_escape": "y" + }, + "notes": { + "blob_download": "silent_failure", + "static_download": "opens_in_browser" + }, + "engine": { + "type": "wkwebview" + } + } + }, + "latest": "1" + } + } + }, + "twitter": { + "name": "Twitter", + "platforms": { + "android": { + "versions": { + "1": { + "testedDate": "2026-03-14", + "appVersion": "11.71.0-release.01", + "support": { + "default_browser": "y", + "detect_inapp": "na", + "blob_download": "y", + "static_download": "y", + "intent_escape": "n" + }, + "notes": { + "intent_escape": "tab_view_intent" + }, + "engine": { + "type": "android_tab_view" + } + } + }, + "latest": "1" + }, + "ios": { + "versions": { + "1": { + "testedDate": "2026-03-07", + "support": { + "default_browser": "n", + "detect_inapp": "y", + "blob_download": "n", + "static_download": "n", + "safari_escape": "n", + "browser_escape": "n" + }, + "notes": { + "blob_download": "opens_in_browser", + "static_download": "opens_in_browser" + }, + "engine": { + "type": "wkwebview" + } + } + }, + "latest": "1" + } + } + }, + "google_search": { + "name": "Google Search App", + "platforms": { + "android": { + "versions": { + "1": { + "appVersion": "17.9.53.sa.arm64", + "testedDate": "2026-03-14", + "support": { + "default_browser": "y", + "detect_inapp": "na", + "blob_download": "y", + "static_download": "y", + "intent_escape": "n" + }, + "engine": { + "type": "android_tab_view" + }, + "notes": { + "intent_escape": "tab_view_intent" + } + } + }, + "latest": "1" + }, + "ios": { + "versions": { + "1": { + "testedDate": "2026-03-07", + "support": { + "default_browser": "n", + "detect_inapp": "y", + "blob_download": "n", + "static_download": "y", + "safari_escape": "y", + "browser_escape": "y" + }, + "notes": { + "blob_download": "opens_in_browser" + }, + "engine": { + "type": "wkwebview" + } + } + }, + "latest": "1" + } + } + }, + + "slack": { + "name": "Slack", + "platforms": { + "android": { + "versions": { + "1": { + "testedDate": "2026-03-14", + "appVersion": "26.03.20.0", + "support": { + "default_browser": "y", + "detect_inapp": "na", + "blob_download": "y", + "static_download": "y", + "intent_escape": "n" + }, + "notes": { + "intent_escape": "tab_view_intent" + }, + "engine": { + "type": "android_tab_view" + } + } + }, + "latest": "1" + }, + "ios": { + "versions": { + "1": { + "testedDate": "2026-03-07", + "support": { + "default_browser": "n", + "detect_inapp": "experimental", + "blob_download": "n", + "static_download": "n", + "safari_escape": "y", + "browser_escape": "y" + }, + "notes": { + "blob_download": "sfsvc_poor_ux", + "static_download": "sfsvc_poor_ux" + }, + "engine": { + "type": "sfsafariviewcontroller" + } + } + }, + "latest": "1" + } + } + }, + "sfsafariviewcontroller": { + "name": "SFSafariViewController", + "description": "Selecting 'Safari' icon to open a link from an app like in Gmail or Twitter", + "platforms": { + "ios": { + "versions": { + "1": { + "testedDate": "2026-03-07", + "support": { + "default_browser": "n", + "detect_inapp": "experimental", + "blob_download": "n", + "static_download": "n", + "safari_escape": "y", + "browser_escape": "y" + }, + "notes": { + "default_browser": "sfsvc_poor_ux", + "blob_download": "sfsvc_poor_ux", + "static_download": "sfsvc_poor_ux" + }, + "engine": { + "type": "sfsafariviewcontroller" + } + } + }, + "latest": "1" + } + } + }, + "android_tab_view": { + "name": "Android Tab View", + "description": "Generic Android in-app browser using Tab View (not WebView) which respects default browser selection.", + "platforms": { + "android": { + "versions": { + "1": { + "testedDate": "2026-03-14", + "support": { + "default_browser": "y", + "detect_inapp": "na", + "blob_download": "y", + "static_download": "y", + "intent_escape": "n" + }, + "notes": { + "intent_escape": "tab_view_intent" + }, + "engine": { + "type": "android_tab_view" + } + } + }, + "latest": "1" + } + } + }, + "gmail": { + "name": "Gmail", + "platforms": { + "android": { + "versions": { + "1": { + "testedDate": "2026-03-14", + "appVersion": "2026.02.23.876771157.Release", + "support": { + "default_browser": "y", + "detect_inapp": "na", + "blob_download": "y", + "static_download": "y", + "intent_escape": "n" + }, + "notes": { + "intent_escape": "tab_view_intent" + }, + "engine": { + "type": "android_tab_view" + } + } + }, + "latest": "1" + }, + "ios": { + "versions": { + "1": { + "testedDate": "2026-03-07", + "support": { + "default_browser": "y", + "detect_inapp": "u", + "blob_download": "y", + "static_download": "y", + "safari_escape": "u", + "browser_escape": "u" + }, + "notes": { + "blob_download": ["dont_select_safari"], + "static_download": ["dont_select_safari"] + }, + "engine": { + "type": "default_browser" + } + } + }, + "latest": "1" + } + } + }, + "youtube": { + "name": "YouTube", + "platforms": { + "android": { + "versions": { + "1": { + "testedDate": "2026-03-14", + "appVersion": "21.10.496", + "support": { + "default_browser": "y", + "detect_inapp": "na", + "blob_download": "y", + "static_download": "y", + "intent_escape": "n" + }, + "notes": { + "intent_escape": "tab_view_intent" + }, + "engine": { + "type": "android_tab_view" + } + } + }, + "latest": "1" + }, + "ios": { + "versions": { + "1": { + "testedDate": "2026-03-07", + "support": { + "default_browser": "y", + "detect_inapp": "u", + "blob_download": "y", + "static_download": "y", + "safari_escape": "u", + "browser_escape": "u" + }, + "notes": { + "blob_download": ["dont_select_safari"], + "static_download": ["dont_select_safari"] + }, + "engine": { + "type": "default_browser" + } + } + }, + "latest": "1" + } + } + }, + "groupme": { + "name": "GroupMe", + "platforms": { + "android": { + "versions": { + "1": { + "testedDate": "2026-03-14", + "appVersion": "15.40.19", + "support": { + "default_browser": "y", + "detect_inapp": "na", + "blob_download": "y", + "static_download": "y", + "intent_escape": "y" + }, + "engine": { + "type": "default_browser" + }, + "notes": { + "intent_escape": "default_browser_intent" + } + } + }, + "latest": "1" + } + } + } + }, + "engineTypes": { + "wkwebview": { + "title": "WKWebView", + "versionSource": "osVersion" + }, + "sfsafariviewcontroller": { + "title": "SFSafariViewController", + "versionSource": "osVersion" + }, + "android_tab_view": { + "title": "Android Tab View" + }, + "android_webview": { + "title": "Android WebView" + }, + "default_browser": { + "title": "Default Browser" + }, + "custom": { + "title": "Custom", + "requiresName": true + } + } +} diff --git a/compat/parser.ts b/compat/parser.ts new file mode 100644 index 0000000..781c9f7 --- /dev/null +++ b/compat/parser.ts @@ -0,0 +1,364 @@ +import type { + CompatibilityData, + CompatibilityQuery, + CompatibilityResult, + FeatureCategory, + Platform, + Feature, + SupportStatus, + EscapeMethod, +} from "./types"; + +export class CompatibilityDatabase { + private data: CompatibilityData; + + constructor(data: CompatibilityData) { + this.data = data; + } + + // --------------------------------------------------------------------------- + // Private helpers + // --------------------------------------------------------------------------- + + /** + * Returns the latest version key for an app on a platform. + * + * Prefers the explicit `latest` pointer on the platform object. + * Falls back to date-sorting versions for data that predates the pointer + * (backwards compatibility during migration). + */ + private getLatestVersionId(appId: string, platform: Platform): string | null { + const platformData = this.data.apps[appId]?.platforms[platform]; + if (!platformData) return null; + + // Prefer the explicit pointer — O(1) and author-controlled + if (platformData.latest) return platformData.latest; + + // Fallback: pick highest testedDate (for pre-migration data) + let latestVersion: string | null = null; + let latestDate = ""; + for (const [versionId, versionData] of Object.entries(platformData.versions)) { + if (versionData.testedDate > latestDate) { + latestDate = versionData.testedDate; + latestVersion = versionId; + } + } + return latestVersion; + } + + /** + * Resolves note IDs (or arrays of IDs) to their full text from noteDefinitions. + * Falls back to the raw string if the ID isn't found (graceful degradation). + */ + private resolveNotes(notes: string | string[] | undefined): string | undefined { + if (!notes) return undefined; + + const resolve = (id: string) => this.data.noteDefinitions[id] ?? id; + + if (Array.isArray(notes)) { + return notes.map(resolve).join(" "); + } + return resolve(notes); + } + + /** + * Strips legacy compound status values (e.g. "n-browser", "n-silent") + * back to their clean base status. Provides tolerance for data that hasn't + * been fully migrated to the new schema yet. + */ + private normalizeStatus(raw: string): SupportStatus { + const valid: SupportStatus[] = ["y", "n", "p", "u", "experimental"]; + if (valid.includes(raw as SupportStatus)) return raw as SupportStatus; + + // Legacy compound format: "n-browser", "n-silent", "n-sfsvc", "n-note" + const base = raw.split("-")[0] as SupportStatus; + if (valid.includes(base)) return base; + + return "u"; + } + + // --------------------------------------------------------------------------- + // Public API + // --------------------------------------------------------------------------- + + /** + * Get support data for a specific app / platform / feature combination. + * + * When `version` is omitted (the common case), resolves via the platform's + * `latest` pointer so callers never need to know the current version key. + */ + getSupport(query: CompatibilityQuery): CompatibilityResult | null { + const { app, platform, feature } = query; + + const platformData = this.data.apps[app]?.platforms[platform]; + if (!platformData) return null; + + const versionId = query.version ?? this.getLatestVersionId(app, platform); + if (!versionId) return null; + + const versionData = platformData.versions[versionId]; + if (!versionData) return null; + + const rawStatus = versionData.support[feature]; + if (rawStatus === undefined) return null; + + const status = this.normalizeStatus(rawStatus); + const statusInfo = this.data.statuses[status]; + const resolvedNotes = this.resolveNotes(versionData.notes?.[feature]); + + return { + supported: status === "y", + status, + statusInfo, + testedDate: versionData.testedDate, + appVersion: versionData.appVersion, + osVersion: versionData.osVersion, + engine: versionData.engine, + notes: resolvedNotes, + }; + } + + /** + * Get all app IDs that fully support a feature on a platform. + */ + getAppsByFeature(feature: Feature, platform: Platform): string[] { + return Object.entries(this.data.apps) + .filter(([appId, appData]) => { + if (!appData.platforms[platform]) return false; + const versionId = this.getLatestVersionId(appId, platform); + if (!versionId) return false; + const raw = appData.platforms[platform].versions[versionId]?.support[feature]; + return this.normalizeStatus(raw ?? "u") === "y"; + }) + .map(([appId]) => appId); + } + + /** + * Get a complete support matrix for a platform, keyed by app ID then feature. + * Status values are normalized (clean SupportStatus, no compound strings). + */ + getSupportMatrix(platform: Platform): Record> { + const matrix: Record> = {}; + + for (const [appId, appData] of Object.entries(this.data.apps)) { + const platformData = appData.platforms[platform]; + if (!platformData) continue; + + const versionId = this.getLatestVersionId(appId, platform); + if (!versionId) continue; + + const versionData = platformData.versions[versionId]; + if (!versionData) continue; + + const normalized = Object.fromEntries( + Object.entries(versionData.support).map(([f, s]) => [ + f, + this.normalizeStatus(s), + ]) + ) as Record; + + matrix[appId] = normalized; + } + + return matrix; + } + + /** + * Get the most recent `testedDate` across all apps on a platform. + */ + getLatestTestDate(platform: Platform): string { + let latestDate = ""; + + for (const [appId, appData] of Object.entries(this.data.apps)) { + if (!appData.platforms[platform]) continue; + const versionId = this.getLatestVersionId(appId, platform); + if (!versionId) continue; + const testDate = appData.platforms[platform].versions[versionId]?.testedDate; + if (testDate && testDate > latestDate) latestDate = testDate; + } + + return latestDate; + } + + /** + * Get all configured escape methods for a platform. + */ + getEscapeMethods(platform: Platform): EscapeMethod[] { + const platformInfo = this.data.platforms[platform]; + if (!platformInfo) return []; + + const methods: EscapeMethod[] = []; + if (platformInfo.escapeMethod) methods.push(platformInfo.escapeMethod); + if (platformInfo.escapeMethods) methods.push(...Object.values(platformInfo.escapeMethods)); + return methods; + } + + /** + * Get all apps that have data for a given platform. + */ + getAppsForPlatform(platform: Platform): Array<{ id: string; name: string }> { + return Object.entries(this.data.apps) + .filter(([_, appData]) => appData.platforms[platform]) + .map(([appId, appData]) => ({ id: appId, name: appData.name })); + } + + /** + * Get feature definition (title, description, category, platforms). + */ + getFeature(featureId: Feature) { + return this.data.features[featureId]; + } + + /** + * Get status definition (title, description, symbol). + */ + getStatus(statusId: SupportStatus) { + return this.data.statuses[statusId]; + } + + /** + * Get all apps that have a note for a specific feature on a platform, + * with the note text resolved from noteDefinitions. + */ + getAppsWithNotes( + feature: Feature, + platform: Platform, + ): Array<{ app: string; note: string }> { + const results: Array<{ app: string; note: string }> = []; + + for (const [appId, appData] of Object.entries(this.data.apps)) { + const platformData = appData.platforms[platform]; + if (!platformData) continue; + + const versionId = this.getLatestVersionId(appId, platform); + if (!versionId) continue; + + const noteIds = platformData.versions[versionId]?.notes?.[feature]; + if (!noteIds) continue; + + const resolved = this.resolveNotes(noteIds); + if (resolved) results.push({ app: appId, note: resolved }); + } + + return results; + } + + /** + * Search apps by name or ID (case-insensitive substring match). + */ + searchApps(query: string): Array<{ id: string; name: string; platforms: string[] }> { + const term = query.toLowerCase(); + return Object.entries(this.data.apps) + .filter(([appId, appData]) => + appData.name.toLowerCase().includes(term) || appId.includes(term) + ) + .map(([appId, appData]) => ({ + id: appId, + name: appData.name, + platforms: Object.keys(appData.platforms), + })); + } + + /** + * Get summary statistics about the dataset. + */ + getStats() { + return { + totalApps: Object.keys(this.data.apps).length, + androidApps: this.getAppsForPlatform("android").length, + iosApps: this.getAppsForPlatform("ios").length, + totalFeatures: Object.keys(this.data.features).length, + totalNoteDefinitions: Object.keys(this.data.noteDefinitions).length, + lastUpdated: this.data.lastUpdated, + version: this.data.version, + }; + } + + /** + * Get all note definitions. + */ + getNoteDefinitions() { + return this.data.noteDefinitions; + } + + /** + * Get a single note definition by ID. + */ + getNoteDefinition(noteId: string): string | undefined { + return this.data.noteDefinitions[noteId]; + } + + /** + * Export rows suitable for a display table, with optional category filtering. + * Each row has `app`, `appId`, and one key per applicable feature. + * + * @param platform - The platform to export data for. + * @param category - Optional category filter (e.g. "download"). Omit for all. + */ + exportTableData( + platform: Platform, + category?: FeatureCategory, + ): Array> { + const apps = this.getAppsForPlatform(platform); + + const features = Object.entries(this.data.features) + .filter(([_, def]) => { + const platformMatch = !def.platforms || def.platforms.includes(platform); + const categoryMatch = !category || def.category === category; + return platformMatch && categoryMatch; + }) + .map(([id]) => id as Feature); + + return apps.map((app) => { + const row: Record = { + app: app.name, + appId: app.id, + }; + for (const feature of features) { + row[feature] = this.getSupport({ app: app.id, platform, feature })?.status ?? "u"; + } + return row; + }); + } + + /** + * Get all features grouped by category, optionally filtered to a platform. + * Useful for rendering grouped column headers in the compatibility table. + */ + getFeaturesByCategory(platform?: Platform): Record { + const grouped = {} as Record; + + for (const [featureId, def] of Object.entries(this.data.features)) { + if (platform && def.platforms && !def.platforms.includes(platform)) continue; + const cat = def.category; + if (!grouped[cat]) grouped[cat] = []; + grouped[cat].push(featureId as Feature); + } + + return grouped; + } +} + +// --------------------------------------------------------------------------- +// Factory & standalone utilities +// --------------------------------------------------------------------------- + +export function createCompatibilityDatabase(data: CompatibilityData): CompatibilityDatabase { + return new CompatibilityDatabase(data); +} + +export function isSupported( + data: CompatibilityData, + app: string, + platform: Platform, + feature: Feature, +): boolean { + return new CompatibilityDatabase(data).getSupport({ app, platform, feature })?.supported ?? false; +} + +export function getStatusSymbol( + status: SupportStatus, + statusData: Record, +): string { + return statusData[status]?.symbol ?? (status === "y" ? "✅" : "❌"); +} diff --git a/compat/types.ts b/compat/types.ts new file mode 100644 index 0000000..0715523 --- /dev/null +++ b/compat/types.ts @@ -0,0 +1,202 @@ +export interface CompatibilityData { + $schema: string; + title: string; + description: string; + lastUpdated: string; + version: string; + platforms: { + [platformId: string]: PlatformInfo; + }; + features: { + [featureId: string]: FeatureDefinition; + }; + statuses: { + [statusId: string]: StatusDefinition; + }; + noteDefinitions: { + [noteId: string]: string; + }; + /** + * Registry of known engine types for display and tooling. + * `requiresName: true` means the version entry should also supply `engine.name`. + * `versionSource` tells consumers where to find the engine version for display. + */ + engineTypes: { + [engineTypeId: string]: EngineTypeDefinition; + }; + apps: { + [appId: string]: AppData; + }; +} + +export interface EngineTypeDefinition { + title: string; + /** If true, the version entry must also provide engine.name (e.g. custom engines) */ + requiresName?: boolean; + /** + * Where to source the engine version from for display purposes. + * "osVersion" — engine version tracks the OS version (WKWebView, SFSafariViewController) + * "engineVersion" — engine has its own version, stored on engine.version (future use) + * Omit if version is not tracked. + */ + versionSource?: "osVersion" | "engineVersion"; +} + +export interface PlatformInfo { + description: string; + notes?: string[]; + escapeMethod?: EscapeMethod; + escapeMethods?: { + [methodId: string]: EscapeMethod; + }; +} + +export interface EscapeMethod { + name: string; + pattern: string; + description?: string; +} + +export type FeatureCategory = + | "browser" + | "download" + | "escape" + | "storage" + | "share" + | "forms" + | "other"; + +export interface FeatureDefinition { + title: string; + description: string; + /** Which platforms this feature applies to. Omit if universal. */ + platforms?: string[]; + /** Groups features for column display in the compatibility table */ + category: FeatureCategory; +} + +export interface StatusDefinition { + title: string; + description: string; + symbol?: string; +} + +export interface AppData { + name: string; + description?: string; + platforms: { + [platformId: string]: PlatformSupport; + }; +} + +export interface PlatformSupport { + /** + * Points to the version key in `versions` that should be shown by default. + * Consumers read from versions[latest] without walking all entries. + * When adding a new test entry, increment the key and update this pointer. + */ + latest: string; + versions: { + /** Integer string keys — increment per new test entry, e.g. "1", "2", "3" */ + [versionId: string]: VersionSupport; + }; +} + +export interface EngineInfo { + /** References a key in `engineTypes` */ + type: string; + /** For custom engines where type alone is insufficient, e.g. "X5", "Blink fork" */ + name?: string; +} + +export interface VersionSupport { + testedDate: string; + /** App version at time of testing, e.g. "38.2.1" */ + appVersion?: string; + /** OS version at time of testing, e.g. "18.3.2" */ + osVersion?: string; + /** + * The WebView/browser engine running inside the app. + * Omit if unknown or not relevant. + */ + engine?: EngineInfo; + support: { + /** + * Values must be clean SupportStatus keys only: y | n | p | u | experimental + * Qualifiers (e.g. "opens in browser", "silent fail") belong in `notes`, not here. + */ + [featureId: string]: SupportStatus; + }; + /** + * Maps feature IDs to noteDefinition keys (or arrays of keys). + * Always a sibling of `support`, never nested inside it. + */ + notes?: { + [featureId: string]: string | string[]; + }; +} + +// --------------------------------------------------------------------------- +// Utility types +// --------------------------------------------------------------------------- + +/** + * Clean status values only. Do not extend with qualifiers like "n-browser". + * Use the `notes` field on VersionSupport for qualifiers. + */ +export type SupportStatus = "y" | "n" | "p" | "u" | "experimental"; + +export type Platform = "android" | "ios"; + +export type Feature = + | "default_browser" + | "detect_inapp" + | "blob_download" + | "static_download" + | "intent_escape" + | "safari_escape" + | "browser_escape" + // Storage (future) + | "local_storage" + | "session_storage" + | "indexeddb" + // Share / interaction (future) + | "web_share" + | "clipboard_read" + | "clipboard_write" + // Forms (future) + | "autofill" + | "form_submit"; + +export interface CompatibilityQuery { + app: string; + platform: Platform; + feature: Feature; + /** + * Defaults to the platform's `latest` pointer when omitted. + * Pass an explicit integer string to query historical data, e.g. "1". + */ + version?: string; +} + +export interface CompatibilityResult { + supported: boolean; + status: SupportStatus; + statusInfo: StatusDefinition; + testedDate: string; + appVersion?: string; + osVersion?: string; + engine?: EngineInfo; + /** Resolved note text. Multiple notes are joined with a space. */ + notes?: string; +} + +export interface CompatibilityAPI { + getSupport(query: CompatibilityQuery): CompatibilityResult | null; + getAppsByFeature(feature: Feature, platform: Platform): string[]; + getSupportMatrix( + platform: Platform + ): Record>; + getLatestTestDate(platform: Platform): string; + getEscapeMethods(platform: Platform): EscapeMethod[]; +} diff --git a/compat/utils.ts b/compat/utils.ts new file mode 100644 index 0000000..0b7d5fd --- /dev/null +++ b/compat/utils.ts @@ -0,0 +1,128 @@ +import compatJson from "./compat.json"; + +export type Platform = "android" | "ios"; +export type StatusValue = "y" | "n" | "p" | "u" | "experimental"; + +export interface CompatRow { + appId: string; + appName: string; + testedDate: string; + support: Record; + notes: Record; + engine?: { type: string }; +} + +// Sort weight: lower = better +export const STATUS_SORT_ORDER: Record = { + y: 0, + p: 1, + experimental: 2, + n: 3, + u: 4, +}; + +export const STATUS_ICON: Record = { + y: "✅", + n: "❌", + p: "🟡", + u: "-", + experimental: "🧪", +}; + +/** Resolve one or more noteIds into a human-readable string */ +export function resolveNotes( + noteIds: string | string[] | undefined, + noteDefinitions: Record, +): string { + if (!noteIds) return ""; + const ids = Array.isArray(noteIds) ? noteIds : [noteIds]; + return ids.map((id) => noteDefinitions[id] ?? id).join(" "); +} + +/** Features visible for a given platform */ +export function getFeaturesForPlatform(platform: Platform) { + return Object.entries(compatJson.features) + .filter(([, feature]) => { + const platforms = (feature as any).platforms as string[] | undefined; + return !platforms || platforms.includes(platform); + }) + .map(([id, feature]) => ({ id, ...(feature as any) })) as Array<{ + id: string; + title: string; + description: string; + category: string; + platforms?: string[]; + }>; +} + +/** Flatten compat.json apps into rows for the given platform */ +export function getCompatRows(platform: Platform): CompatRow[] { + const rows: CompatRow[] = []; + + for (const [appId, appData] of Object.entries(compatJson.apps)) { + const platformData = (appData as any).platforms?.[platform]; + if (!platformData) continue; + + const latestId: string = platformData.latest ?? "1"; + const versionData = platformData.versions?.[latestId]; + if (!versionData) continue; + + rows.push({ + appId, + appName: (appData as any).name, + testedDate: versionData.testedDate ?? "", + support: versionData.support ?? {}, + notes: versionData.notes ?? {}, + engine: versionData.engine, + }); + } + + return rows; +} + +export interface PlatformData { + support: Record; + notes: Record; + testedDate: string; +} + +export interface CombinedCompatRow { + appId: string; + appName: string; + android: PlatformData | null; + ios: PlatformData | null; +} + +function getPlatformData( + appData: any, + platform: Platform, +): PlatformData | null { + const platformData = appData.platforms?.[platform]; + if (!platformData) return null; + const latestId: string = platformData.latest ?? "1"; + const versionData = platformData.versions?.[latestId]; + if (!versionData) return null; + return { + support: versionData.support ?? {}, + notes: versionData.notes ?? {}, + testedDate: versionData.testedDate ?? "", + }; +} + +/** Flatten both platforms into one row per app */ +export function getCombinedCompatRows(): CombinedCompatRow[] { + return Object.entries(compatJson.apps).map(([appId, appData]) => ({ + appId, + appName: (appData as any).name, + android: getPlatformData(appData as any, "android"), + ios: getPlatformData(appData as any, "ios"), + })); +} + +/** All features keyed by platform */ +export function getAllFeaturesByPlatform() { + return { + android: getFeaturesForPlatform("android"), + ios: getFeaturesForPlatform("ios"), + }; +} diff --git a/index.html b/index.html index c7ead4b..618e16f 100644 --- a/index.html +++ b/index.html @@ -12,10 +12,6 @@ --white: #fff; --border-radius: 20px; } - html, - body { - background-color: var(--light-blue); - } ul, li { all: unset; @@ -43,7 +39,6 @@ body { font-family: Arial; color: var(--med-blue); - margin-bottom: 15px; } *, *:before, @@ -57,6 +52,7 @@
+