|
| 1 | +import Capacitor |
| 2 | +import Foundation |
| 3 | + |
| 4 | +/// Resolves a URL path against the files Next.js's static export actually wrote. |
| 5 | +/// |
| 6 | +/// Capacitor's stock `CapacitorRouter` maps **every** extension-less path to `index.html` — correct |
| 7 | +/// for a single-page app, wrong for us. `scripts/build_web_view.sh` strips |
| 8 | +/// `getStaticProps`/`getStaticPaths` so the export emits one file per route, including literal |
| 9 | +/// bracketed files for the dynamic ones: `[username].html`, `blog/[slug].html`, `vote/[id].html`, |
| 10 | +/// `alerts/[id].html`, `messages/[channelId].html`. Each reads its own route from `router.query` at |
| 11 | +/// runtime. |
| 12 | +/// |
| 13 | +/// Under the stock router a *hard* navigation to `/someone` or `/blog/a-post` was served |
| 14 | +/// `index.html`, so the app silently showed the home page instead of the requested one. Client-side |
| 15 | +/// `next/link` navigation was unaffected, which is why sidebar links worked and profile and blog |
| 16 | +/// links did not — and why it looked like a crash rather than a routing bug. |
| 17 | +/// |
| 18 | +/// Android never had this: `WebViewLocalServer` already resolves extension-less paths against |
| 19 | +/// `.html` files itself. |
| 20 | +struct NextExportRouter: Router { |
| 21 | + var basePath: String = "" |
| 22 | + |
| 23 | + func route(for path: String) -> String { |
| 24 | + // Real assets (.js, .css, .png, .json, …) pass straight through. |
| 25 | + guard URL(fileURLWithPath: path).pathExtension.isEmpty else { |
| 26 | + return basePath + path |
| 27 | + } |
| 28 | + |
| 29 | + let trimmed = path.hasSuffix("/") ? String(path.dropLast()) : path |
| 30 | + guard !trimmed.isEmpty else { return basePath + "/index.html" } |
| 31 | + |
| 32 | + // /about -> /about.html |
| 33 | + if fileExists(trimmed + ".html") { |
| 34 | + return basePath + trimmed + ".html" |
| 35 | + } |
| 36 | + // /foo -> /foo/index.html |
| 37 | + if fileExists(trimmed + "/index.html") { |
| 38 | + return basePath + trimmed + "/index.html" |
| 39 | + } |
| 40 | + // /someone -> /[username].html ; /blog/a-post -> /blog/[slug].html |
| 41 | + if let bracketed = bracketedSibling(of: trimmed) { |
| 42 | + return basePath + bracketed |
| 43 | + } |
| 44 | + |
| 45 | + // Genuinely unknown. index.html keeps the app usable rather than showing a blank WebView, |
| 46 | + // and matches what the stock router would have done. |
| 47 | + return basePath + "/index.html" |
| 48 | + } |
| 49 | + |
| 50 | + private func fileExists(_ relativePath: String) -> Bool { |
| 51 | + FileManager.default.fileExists(atPath: basePath + relativePath) |
| 52 | + } |
| 53 | + |
| 54 | + /// The dynamic-route template living beside the requested path, e.g. `blog/[slug].html` for |
| 55 | + /// `/blog/a-post`. Next guarantees at most one dynamic segment per directory level, so the first |
| 56 | + /// match is unambiguous; sorting only keeps the choice stable across runs. |
| 57 | + private func bracketedSibling(of path: String) -> String? { |
| 58 | + let directory = (path as NSString).deletingLastPathComponent |
| 59 | + let searchPath = basePath + (directory.isEmpty ? "" : directory) |
| 60 | + |
| 61 | + guard let entries = try? FileManager.default.contentsOfDirectory(atPath: searchPath) else { |
| 62 | + return nil |
| 63 | + } |
| 64 | + |
| 65 | + guard let match = entries |
| 66 | + .filter({ $0.hasPrefix("[") && $0.hasSuffix("].html") }) |
| 67 | + .sorted() |
| 68 | + .first |
| 69 | + else { return nil } |
| 70 | + |
| 71 | + return (directory.isEmpty ? "" : directory) + "/" + match |
| 72 | + } |
| 73 | +} |
| 74 | + |
| 75 | +/// Exists only to install `NextExportRouter`. Referenced from `Base.lproj/Main.storyboard`, which |
| 76 | +/// otherwise instantiates `CAPBridgeViewController` directly. |
| 77 | +class NextExportViewController: CAPBridgeViewController { |
| 78 | + override func router() -> Router { |
| 79 | + return NextExportRouter() |
| 80 | + } |
| 81 | +} |
0 commit comments