(url: string)
| 125 | * trigger Chromium's directory listing, which is a different product surface. |
| 126 | */ |
| 127 | export function normalizeFileUrl(url: string): string { |
| 128 | if (!url.toLowerCase().startsWith('file:')) return url; |
| 129 | |
| 130 | // Split off query + fragment BEFORE touching the path — SPAs + fixture URLs rely |
| 131 | // on these. path.resolve would URL-encode `?` and `#` as `%3F`/`%23` (and |
| 132 | // pathToFileURL drops them entirely), silently routing preview URLs to the |
| 133 | // wrong fixture. Extract, normalize the path, reattach at the end. |
| 134 | // |
| 135 | // Parse order: `?` before `#` per RFC 3986 — '?' in a fragment is literal. |
| 136 | // Find the FIRST `?` or `#`, whichever comes first, and take everything |
| 137 | // after (including the delimiter) as the trailing segment. |
| 138 | const qIdx = url.indexOf('?'); |
| 139 | const hIdx = url.indexOf('#'); |
| 140 | let delimIdx = -1; |
| 141 | if (qIdx >= 0 && hIdx >= 0) delimIdx = Math.min(qIdx, hIdx); |
| 142 | else if (qIdx >= 0) delimIdx = qIdx; |
| 143 | else if (hIdx >= 0) delimIdx = hIdx; |
| 144 | |
| 145 | const pathPart = delimIdx >= 0 ? url.slice(0, delimIdx) : url; |
| 146 | const trailing = delimIdx >= 0 ? url.slice(delimIdx) : ''; |
| 147 | |
| 148 | const rest = pathPart.slice('file:'.length); |
| 149 | |
| 150 | // file:/// or longer → standard absolute; pass through unchanged (caller validates path). |
| 151 | if (rest.startsWith('///')) { |
| 152 | // Reject bare root-only (file:/// with nothing after) |
| 153 | if (rest === '///' || rest === '////') { |
| 154 | throw new Error('Invalid file URL: file:/// has no path. Use file:///<absolute-path>.'); |
| 155 | } |
| 156 | return pathPart + trailing; |
| 157 | } |
| 158 | |
| 159 | // Everything else: must start with // (we accept file://... only) |
| 160 | if (!rest.startsWith('//')) { |
| 161 | throw new Error(`Invalid file URL: ${url}. Use file:///<absolute-path> or file://./<rel> or file://~/<rel>.`); |
| 162 | } |
| 163 | |
| 164 | const afterDoubleSlash = rest.slice(2); |
| 165 | |
| 166 | // Reject empty (file://) and trailing-slash-only (file://./ listing cwd). |
| 167 | if (afterDoubleSlash === '') { |
| 168 | throw new Error('Invalid file URL: file:// is empty. Use file:///<absolute-path>.'); |
| 169 | } |
| 170 | if (afterDoubleSlash === '.' || afterDoubleSlash === './') { |
| 171 | throw new Error('Invalid file URL: file://./ would list the current directory. Use file://./<filename> to render a specific file.'); |
| 172 | } |
| 173 | if (afterDoubleSlash === '~' || afterDoubleSlash === '~/') { |
| 174 | throw new Error('Invalid file URL: file://~/ would list the home directory. Use file://~/<filename> to render a specific file.'); |
| 175 | } |
| 176 | |
| 177 | // Home-relative: file://~/<rel> |
| 178 | if (afterDoubleSlash.startsWith('~/')) { |
| 179 | const rel = afterDoubleSlash.slice(2); |
| 180 | const absPath = path.join(os.homedir(), rel); |
| 181 | return pathToFileURL(absPath).href + trailing; |
| 182 | } |
| 183 | |
| 184 | // cwd-relative with explicit ./ : file://./<rel> |
no outgoing calls
no test coverage detected