v0.0.30 - Local file browsing: test site, storage/media tests, load-failed URL fix, title logging fix, README update
This commit is contained in:
+11
@@ -17,3 +17,14 @@ nostr_core_lib/
|
||||
# auto-generated embedded web content (built by embed_web_files.sh)
|
||||
src/embedded_web_content.c
|
||||
src/embedded_web_content.h
|
||||
|
||||
# binary media files for local-site test (too large for git)
|
||||
tests/local-site/assets/*.mp3
|
||||
tests/local-site/assets/*.m4a
|
||||
tests/local-site/assets/*.mp4
|
||||
tests/local-site/assets/*.webm
|
||||
tests/local-site/assets/*.ogg
|
||||
tests/local-site/assets/*.ogv
|
||||
tests/local-site/assets/*.wav
|
||||
tests/local-site/assets/*.avi
|
||||
tests/local-site/assets/*.mkv
|
||||
|
||||
@@ -29,7 +29,13 @@ because trust moves to the layer where it belongs: your keys.
|
||||
4. **Deprecated web security, deliberately.** Same-origin policy, CORS, and
|
||||
certificate enforcement are stripped so pages (and the agent runtime to
|
||||
come) can freely call any endpoint — including FIPS mesh services — without
|
||||
the workarounds traditional browsers force on automators.
|
||||
the workarounds traditional browsers force on automators. Security moves
|
||||
up to the **Qube level** (isolation per VM), so the browser doesn't need
|
||||
the traditional in-browser security model.
|
||||
5. **Local file browsing.** Open and operate multipage websites directly from
|
||||
`file://` with no web server — cross-origin access between local files,
|
||||
`fetch()`/XHR, iframes, storage, media playback, all work. See
|
||||
[Local file browsing](#local-file-browsing) below.
|
||||
|
||||
## Nostr interaction policy
|
||||
|
||||
@@ -67,25 +73,28 @@ Conversations and skills are still stored as the same Nostr event kinds
|
||||
(30078 for conversations, 31123 for skills) with the same tags, so they're
|
||||
**compatible** across projects — just not live-synced.
|
||||
|
||||
## Non-goals (for now)
|
||||
|
||||
- Agent integration, multi-window agent hosts, didactyl hosting, and the
|
||||
broader "browser as agent runtime" vision are documented in
|
||||
[`docs/architecture.md`](docs/architecture.md) and the linked plans, but
|
||||
**not in scope for the first build**. First: a usable browser with basic
|
||||
Nostr signing. Then the rest.
|
||||
|
||||
## Current status
|
||||
|
||||
Working browser: a C99 + WebKitGTK window with multi-tab support, a URL bar
|
||||
per tab, Nostr login, and `window.nostr` injection. Verified loading
|
||||
`https://laantungir.net` cleanly. See
|
||||
[`docs/webkit-poc-findings.md`](docs/webkit-poc-findings.md) for the friction
|
||||
report from the POC phase (and the Servo fallback exploration).
|
||||
Working browser with a broad feature set:
|
||||
|
||||
```
|
||||
[loaded] https://laantungir.net/ -- title: Laan Tungir
|
||||
```
|
||||
- **Multi-tab browsing** with session restore, tab drag/reorder, context menus
|
||||
- **Nostr login** (GTK dialog or CLI flags) with multiple methods: generate,
|
||||
local (nsec), seed (BIP-39), readonly (npub), NIP-46, n_signer hardware
|
||||
- **`window.nostr` injection** (NIP-07) into every page
|
||||
- **Agent MCP server** — 100 tools for browser automation via Streamable HTTP
|
||||
at `http://localhost:17777/mcp`
|
||||
- **Local file browsing** — open and operate multipage websites directly from
|
||||
`file://` with no web server (see below)
|
||||
- **Bookmarks, history, search** — built-in bookmark manager, browsing history,
|
||||
and search engine integration
|
||||
- **Keyboard shortcuts** — standard browser shortcuts (Ctrl+T, Ctrl+W, Ctrl+L,
|
||||
Ctrl+Tab, etc.)
|
||||
- **Per-user profiles** — separate identities and settings per profile
|
||||
- **Embedded web content** — `sovereign://` pages for settings, bookmarks,
|
||||
profile, and agent chat/config
|
||||
|
||||
See [`docs/webkit-poc-findings.md`](docs/webkit-poc-findings.md) for the
|
||||
friction report from the POC phase.
|
||||
|
||||
## Build
|
||||
|
||||
@@ -94,9 +103,14 @@ Requires Debian 13 (trixie) or similar with WebKitGTK 4.1 dev headers:
|
||||
```bash
|
||||
sudo apt install libwebkit2gtk-4.1-dev
|
||||
make
|
||||
./sovereign_browser [url]
|
||||
./browser.sh start [url] # build + launch (detached, won't hang terminal)
|
||||
```
|
||||
|
||||
> **Note:** Always use `./browser.sh` to start/stop the browser. Running
|
||||
> `./sovereign_browser` directly will hang the terminal because the GUI
|
||||
> process keeps stdout/stderr open. See `./browser.sh --help` or
|
||||
> [`.roo/agents.md`](.roo/agents.md) for details.
|
||||
|
||||
## Command-line flags
|
||||
|
||||
Run `./sovereign_browser --help` for the full list. Flags are parsed before
|
||||
@@ -158,21 +172,22 @@ credentials.
|
||||
### Examples
|
||||
|
||||
```bash
|
||||
# Existing behavior — still works
|
||||
./sovereign_browser https://example.com
|
||||
# Basic usage (via browser.sh — recommended)
|
||||
./browser.sh start https://example.com
|
||||
|
||||
# Generate a fresh key, skip the login dialog, open a page
|
||||
./sovereign_browser --login-method generate --url https://example.com
|
||||
./browser.sh start --login-method generate --url https://example.com
|
||||
|
||||
# Local key from env, custom agent port
|
||||
./sovereign_browser --login-method local --nsec "$NSEC" --port 18888
|
||||
./browser.sh start --login-method local --nsec "$NSEC" --port 18888
|
||||
|
||||
# Read-only (npub), no agent server, two tabs
|
||||
./sovereign_browser --login-method readonly --npub npub1... \
|
||||
./browser.sh start --login-method readonly --npub npub1... \
|
||||
--no-agent --url https://a.com --url https://b.com
|
||||
|
||||
# Via browser.sh (forwards extra args)
|
||||
./browser.sh start --login-method generate --url https://example.com
|
||||
# Open a local website (no web server needed)
|
||||
./browser.sh start --login-method generate \
|
||||
--url "file:///path/to/website/index.html"
|
||||
```
|
||||
|
||||
## Architecture (summary)
|
||||
@@ -195,7 +210,7 @@ credentials.
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ WebKitGTK (system lib, ~35 MB) │
|
||||
│ Blink-grade renderer + V8 + libsoup network stack │
|
||||
│ WebCore renderer + JavaScriptCore + libsoup net │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
@@ -211,8 +226,9 @@ infrastructure, multiple uses:
|
||||
|
||||
| Scheme | Purpose | Status |
|
||||
|--------|---------|--------|
|
||||
| `sovereign://nostr/*` | `window.nostr` bridge — web pages call `fetch('sovereign://nostr/signEvent')` to sign via the C-side `nostr_signer_t` | Planned |
|
||||
| `sovereign://settings` | Browser-internal pages (like `chrome://settings`) | Planned |
|
||||
| `sovereign://nostr/*` | `window.nostr` bridge — web pages call `fetch('sovereign://nostr/signEvent')` to sign via the C-side `nostr_signer_t` | ✅ Working |
|
||||
| `sovereign://settings`, `sovereign://bookmarks`, `sovereign://profile` | Browser-internal pages (like `chrome://settings`) | ✅ Working |
|
||||
| `sovereign://agents/chat`, `sovereign://agents/config` | Agent chat and configuration pages | ✅ Working |
|
||||
| `fips://` / `*.fips` | Route to FIPS mesh nodes via TUN interface | Planned |
|
||||
| `nostr://` | Fetch Nostr events from relays, render as HTML | Planned |
|
||||
|
||||
@@ -277,79 +293,130 @@ is saved to `~/.sovereign_browser/session.txt`.
|
||||
See [`plans/browser-tabs.md`](plans/browser-tabs.md) for the full
|
||||
implementation plan.
|
||||
|
||||
### Agent tools
|
||||
### Local file browsing
|
||||
|
||||
The browser embeds a WebSocket server (using libsoup) that allows external
|
||||
AI agents to control the browser programmatically. Connect to
|
||||
`ws://localhost:17777/agent` and send JSON tool commands.
|
||||
sovereign_browser is designed to run websites directly from local files —
|
||||
open an `index.html` from its directory and navigate as if it were served
|
||||
over HTTP. No web server required. This is a deliberate design choice:
|
||||
security is moved up to the Qube level, so the browser doesn't need the
|
||||
traditional security restrictions (same-origin policy, CORS) that would
|
||||
block local-file web apps.
|
||||
|
||||
The server starts before the login dialog and remains available throughout
|
||||
the browser's lifecycle. The browser starts normally with the GTK login
|
||||
dialog — no blocking wait. An agent can log in at any time: while the dialog
|
||||
is showing, or after the browser is already running. If an agent logs in
|
||||
while the dialog is open, the agent's login takes priority.
|
||||
**What works on `file://` pages:**
|
||||
|
||||
**Login tools** (available before login):
|
||||
- **Relative links** between pages — `index.html` → `about.html`,
|
||||
`subdir/page.html`, `../index.html`, `../../../deep/page.html`
|
||||
- **Cross-origin access** — all `file://` URLs are treated as same-origin.
|
||||
Iframes can access their parent's DOM, `window.open()` popups are
|
||||
accessible, and `fetch()`/XHR work across directories.
|
||||
- **`fetch()` and `XMLHttpRequest`** — load local JSON, text, and other
|
||||
files via relative paths (returns `status=0`, the `file://` convention)
|
||||
- **External resources** — CSS stylesheets, JavaScript files, SVG images,
|
||||
favicons, `<picture>`/`srcset`, `<object>`/`<embed>`
|
||||
- **Video and audio playback** — MP4 video and M4A audio play correctly
|
||||
with full duration/dimension metadata
|
||||
- **All browser storage** — `localStorage`, `sessionStorage`, `cookies`,
|
||||
and `IndexedDB` all work fully (open, upgrade, put, get, count)
|
||||
- **`history.pushState`/`replaceState`** — SPA-style routing works on
|
||||
`file://` URLs
|
||||
- **Query strings and hash fragments** — preserved in `location.href`,
|
||||
accessible via `location.search`/`location.hash`
|
||||
- **URL-encoded filenames** — `encoded%20name.html` (file on disk:
|
||||
`encoded name.html`) loads correctly
|
||||
- **Form submissions** — GET forms append query strings to the target
|
||||
page; POST forms navigate (body silently dropped, no server)
|
||||
- **`window.open()`** — opens local pages in new tabs
|
||||
|
||||
- `login_status` — check if logged in
|
||||
- `login` — authenticate with method: `local` (nsec), `seed` (BIP-39
|
||||
mnemonic), `readonly` (npub), `nip46` (bunker:// URL), or `nsigner`
|
||||
(hardware signer via serial/unix/tcp/qrexec transport)
|
||||
- `logout` — clear signer and reset state
|
||||
- `switch_identity` — change identity (same params as login)
|
||||
**Known limitations (WebKit engine quirks, not bugs):**
|
||||
|
||||
**Browser tools** (available after login):
|
||||
- `fetch()` for missing files throws `Load failed` instead of returning an
|
||||
error Response (no HTTP status codes on `file://`)
|
||||
- `<video>`/`<audio>` `error` events don't fire for missing source files
|
||||
(valid media works perfectly)
|
||||
- Cache API (CacheStorage) rejects non-HTTP/HTTPS URLs — use IndexedDB
|
||||
instead for local-file storage
|
||||
|
||||
- Navigation: `open`, `back`, `forward`, `reload`, `get_url`, `get_title`
|
||||
- Inspection: `snapshot` (accessibility tree with element refs), `get_text`,
|
||||
`get_html`, `get_attr`, `eval` (run JavaScript)
|
||||
- Interaction: `click`, `fill`, `type`, `press`, `scroll`, `hover`,
|
||||
`focus`, `close`
|
||||
- Tabs: `tab_list`, `tab_new`, `tab_switch`, `tab_close`
|
||||
- Wait: `wait` (ms), `wait_for` (selector)
|
||||
|
||||
**Snapshot + ref workflow** (same pattern as agent-browser):
|
||||
**Test site:** A comprehensive multipage test website with 45+ edge cases
|
||||
is in [`tests/local-site/`](tests/local-site/index.html). See
|
||||
[`tests/local-site/LOCAL-FILE-BROWSING-REPORT.md`](tests/local-site/LOCAL-FILE-BROWSING-REPORT.md)
|
||||
for the full test report.
|
||||
|
||||
```bash
|
||||
# Using websocat (cargo install websocat)
|
||||
# 1. Login
|
||||
echo '{"id":1,"tool":"login","params":{"method":"readonly","npub":"npub1..."}}' | websocat ws://localhost:17777/agent
|
||||
|
||||
# 2. Open a page
|
||||
echo '{"id":2,"tool":"open","params":{"url":"https://example.com"}}' | websocat ws://localhost:17777/agent
|
||||
|
||||
# 3. Snapshot — get accessibility tree with refs
|
||||
echo '{"id":3,"tool":"snapshot","params":{"interactive":true}}' | websocat ws://localhost:17777/agent
|
||||
|
||||
# 4. Click by ref
|
||||
echo '{"id":4,"tool":"click","params":{"ref":"@e2"}}' | websocat ws://localhost:17777/agent
|
||||
# Open a local website
|
||||
./browser.sh start --login-method generate \
|
||||
--url "file:///path/to/your/website/index.html"
|
||||
```
|
||||
|
||||
**HTTP status endpoint:** `curl http://localhost:17777/` returns server
|
||||
status (running, port, client count, logged in).
|
||||
### Agent tools (MCP server)
|
||||
|
||||
**Agent settings** (in Settings dialog or `~/.sovereign_browser/settings.conf`):
|
||||
The browser embeds an MCP (Model Context Protocol) server using Streamable
|
||||
HTTP transport. External AI agents can control the browser programmatically
|
||||
via `http://localhost:17777/mcp`.
|
||||
|
||||
- `agent_server_enabled` (default: true)
|
||||
- `agent_server_port` (default: 17777)
|
||||
- `agent_allowed_origins` (default: `*`)
|
||||
- `agent_login_timeout_ms` (default: 30000)
|
||||
The server starts before the login dialog and remains available throughout
|
||||
the browser's lifecycle. An agent can log in at any time: while the dialog
|
||||
is showing, or after the browser is already running.
|
||||
|
||||
See [`plans/agent-tools.md`](plans/agent-tools.md) for the full
|
||||
implementation plan and tool comparison with agent-browser.
|
||||
**100 tools available**, including:
|
||||
|
||||
- **Login**: `login_status`, `login`, `logout`, `switch_identity`
|
||||
- **Navigation**: `open`, `back`, `forward`, `reload`, `get_url`, `get_title`
|
||||
- **Snapshot & inspection**: `snapshot`, `get_text`, `get_html`, `get_attr`,
|
||||
`eval`, `screenshot`, `screenshot_annotated`
|
||||
- **Interaction**: `click`, `dblclick`, `fill`, `type`, `press`, `scroll`,
|
||||
`hover`, `focus`, `select`, `check`, `uncheck`, `drag`
|
||||
- **Find elements**: `find_role`, `find_text`, `find_label`, `find_placeholder`,
|
||||
`find_alt`, `find_title`, `find_testid`
|
||||
- **Tabs**: `tab_list`, `tab_new`, `tab_switch`, `tab_close`, `close`, `close_all`
|
||||
- **Wait**: `wait`, `wait_for`, `wait_for_text`, `wait_for_url`, `wait_for_load`
|
||||
- **Cookies & storage**: `cookies_get/set/clear`, `storage_local_*`, `storage_session_*`
|
||||
- **Frames**: `frame_switch`, `frame_main`
|
||||
- **Dialogs**: `dialog_accept`, `dialog_dismiss`, `dialog_status`
|
||||
- **Debug**: `console`, `errors`, `highlight`
|
||||
- **Files**: `upload`, `pdf`
|
||||
|
||||
**Example workflow:**
|
||||
|
||||
```bash
|
||||
# 1. Login (or use --login-method on the command line)
|
||||
curl -s -X POST http://localhost:17777/mcp \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"login","arguments":{"method":"generate"}}}'
|
||||
|
||||
# 2. Open a page
|
||||
curl -s -X POST http://localhost:17777/mcp \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"open","arguments":{"url":"https://example.com"}}}'
|
||||
|
||||
# 3. Snapshot — get accessibility tree with element refs
|
||||
curl -s -X POST http://localhost:17777/mcp \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"snapshot","arguments":{}}}'
|
||||
|
||||
# 4. Click by ref
|
||||
curl -s -X POST http://localhost:17777/mcp \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"click","arguments":{"ref":"@e2"}}}'
|
||||
```
|
||||
|
||||
See [`.roo/agents.md`](.roo/agents.md) for the full tool list and workflow
|
||||
guide, and [`plans/agent-tools.md`](plans/agent-tools.md) for the
|
||||
implementation plan.
|
||||
|
||||
## Roadmap
|
||||
|
||||
1. ✅ Base browser (WebKitGTK + C99, loads pages)
|
||||
2. ✅ Browser tabs (GtkNotebook, per-tab toolbar, session restore, settings)
|
||||
3. ✅ Agent tools Phase 1 (WebSocket server, login tools, 31 browser automation tools)
|
||||
4. ⏳ Nostr login (GTK dialog → nostr_core_lib → nostr_signer_t)
|
||||
5. ⏳ `window.nostr` injection (`sovereign://` URI scheme bridge)
|
||||
6. ⏳ Security strip (disable SOP/CORS, accept any cert, shared context)
|
||||
7. ⏳ FIPS URI scheme (`fips://` / `*.fips` via WebKitGTK scheme handler)
|
||||
8. ⏳ `nostr://` content scheme
|
||||
9. ⏳ Agent tools Phase 2 (refinements) and Phase 3 (extended tool catalog)
|
||||
10. Future: agent runtime (didactyl hosting), multi-window, FIPS-distributed
|
||||
3. ✅ Nostr login (GTK dialog → nostr_core_lib → nostr_signer_t, 6 methods)
|
||||
4. ✅ `window.nostr` injection (`sovereign://` URI scheme bridge)
|
||||
5. ✅ Agent MCP server (100 tools, Streamable HTTP at `http://localhost:17777/mcp`)
|
||||
6. ✅ Embedded web content (`sovereign://settings`, `bookmarks`, `profile`, `agents/chat`, `agents/config`)
|
||||
7. ✅ Bookmarks, history, search, keyboard shortcuts, per-user profiles
|
||||
8. ✅ Local file browsing (multipage websites from `file://` with no web server)
|
||||
9. ⏳ Security strip (disable SOP/CORS, accept any cert, shared context)
|
||||
10. ⏳ FIPS URI scheme (`fips://` / `*.fips` via WebKitGTK scheme handler)
|
||||
11. ⏳ `nostr://` content scheme
|
||||
12. Future: agent runtime (didactyl hosting), multi-window, FIPS-distributed
|
||||
|
||||
## License
|
||||
|
||||
|
||||
+60
-7
@@ -1304,11 +1304,15 @@ static void on_load_changed(WebKitWebView *webview,
|
||||
} else if (load_event == WEBKIT_LOAD_FINISHED) {
|
||||
const gchar *title = webkit_web_view_get_title(webview);
|
||||
const gchar *uri = webkit_web_view_get_uri(webview);
|
||||
g_print("[loaded] %s -- title: %s\n",
|
||||
uri ? uri : "(null)",
|
||||
(title && title[0]) ? title : "(none)");
|
||||
/* The title may not be available yet at LOAD_FINISHED — it often
|
||||
* arrives slightly later via the notify::title signal (handled by
|
||||
* on_title_changed). Log what we have; the title handler will log
|
||||
* the real title when it arrives. */
|
||||
g_print("[loaded] %s\n", uri ? uri : "(null)");
|
||||
|
||||
/* Update tab title (works for tabs in any window's notebook). */
|
||||
/* Update tab title if already available (works for tabs in any
|
||||
* window's notebook). The notify::title handler covers the case
|
||||
* where the title arrives after load-finished. */
|
||||
if (tab != NULL && title && title[0]) {
|
||||
int index = tab_array_find(tab);
|
||||
if (index >= 0) {
|
||||
@@ -1317,7 +1321,10 @@ static void on_load_changed(WebKitWebView *webview,
|
||||
}
|
||||
|
||||
/* Add to history (with title for the Recents submenu tooltip).
|
||||
* This applies to both main-window tabs and auxiliary windows. */
|
||||
* This applies to both main-window tabs and auxiliary windows.
|
||||
* If the title isn't available yet, history_add_titled will use
|
||||
* the URL; the notify::title handler could update it later if
|
||||
* needed. */
|
||||
if (uri != NULL && uri[0] != '\0') {
|
||||
history_add_titled(uri, (title && title[0]) ? title : NULL);
|
||||
}
|
||||
@@ -1332,13 +1339,57 @@ static gboolean on_load_failed(WebKitWebView *webview,
|
||||
gpointer data) {
|
||||
(void)webview;
|
||||
(void)load_event;
|
||||
(void)data;
|
||||
tab_info_t *tab = (tab_info_t *)data;
|
||||
g_print("[failed] %s -- %s\n",
|
||||
failing_uri ? failing_uri : "(null)",
|
||||
error ? error->message : "(unknown)");
|
||||
|
||||
/* Keep the failed URL in the address bar so the user can see what
|
||||
* failed and edit/retry. Without this, WebKit reverts to about:blank
|
||||
* and the user loses the URL they typed or clicked. */
|
||||
if (tab != NULL && failing_uri != NULL && failing_uri[0] != '\0') {
|
||||
if (strcmp(failing_uri, "about:blank") != 0) {
|
||||
gtk_entry_set_text(GTK_ENTRY(tab->url_entry), failing_uri);
|
||||
snprintf(tab->current_url, sizeof(tab->current_url), "%s", failing_uri);
|
||||
}
|
||||
|
||||
/* Set a descriptive tab title from the error so the user sees
|
||||
* something meaningful in the tab bar instead of "Loading…". */
|
||||
const char *msg = (error && error->message) ? error->message : "Load failed";
|
||||
int index = tab_array_find(tab);
|
||||
if (index >= 0) {
|
||||
tab_manager_set_title(index, msg);
|
||||
}
|
||||
}
|
||||
|
||||
return FALSE;
|
||||
}
|
||||
|
||||
/* Called when the webview's "title" property changes. The title often
|
||||
* arrives slightly after WEBKIT_LOAD_FINISHED, so the load-finished
|
||||
* handler may log "(none)". This handler picks up the real title as
|
||||
* soon as it's available and updates the tab title + logs it. */
|
||||
static void on_title_changed(WebKitWebView *webview,
|
||||
GParamSpec *pspec,
|
||||
gpointer user_data) {
|
||||
(void)pspec;
|
||||
tab_info_t *tab = (tab_info_t *)user_data;
|
||||
const gchar *title = webkit_web_view_get_title(webview);
|
||||
const gchar *uri = webkit_web_view_get_uri(webview);
|
||||
|
||||
if (title && title[0]) {
|
||||
g_print("[title] %s -- %s\n",
|
||||
uri ? uri : "(null)", title);
|
||||
|
||||
if (tab != NULL) {
|
||||
int index = tab_array_find(tab);
|
||||
if (index >= 0) {
|
||||
tab_manager_set_title(index, title);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Web view context menu (right-click) ──────────────────────────── */
|
||||
|
||||
/* Callback for "Open Link in New Tab" / "Open Page in New Tab". */
|
||||
@@ -2276,9 +2327,11 @@ static tab_info_t *tab_create(const char *url) {
|
||||
g_signal_connect(tab->webview, "load-changed",
|
||||
G_CALLBACK(on_load_changed), tab);
|
||||
g_signal_connect(tab->webview, "load-failed",
|
||||
G_CALLBACK(on_load_failed), NULL);
|
||||
G_CALLBACK(on_load_failed), tab);
|
||||
g_signal_connect(tab->webview, "notify::favicon",
|
||||
G_CALLBACK(on_favicon_changed), tab);
|
||||
g_signal_connect(tab->webview, "notify::title",
|
||||
G_CALLBACK(on_title_changed), tab);
|
||||
g_signal_connect(tab->webview, "context-menu",
|
||||
G_CALLBACK(on_webview_context_menu), tab);
|
||||
g_signal_connect(tab->webview, "decide-policy",
|
||||
|
||||
+2
-2
@@ -11,9 +11,9 @@
|
||||
#ifndef SOVEREIGN_BROWSER_VERSION_H
|
||||
#define SOVEREIGN_BROWSER_VERSION_H
|
||||
|
||||
#define SB_VERSION "v0.0.29"
|
||||
#define SB_VERSION "v0.0.30"
|
||||
#define SB_VERSION_MAJOR 0
|
||||
#define SB_VERSION_MINOR 0
|
||||
#define SB_VERSION_PATCH 29
|
||||
#define SB_VERSION_PATCH 30
|
||||
|
||||
#endif /* SOVEREIGN_BROWSER_VERSION_H */
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Local Site — Missing Page Placeholder</title>
|
||||
<link rel="stylesheet" href="assets/site.css">
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>Missing Page Placeholder</h1>
|
||||
<nav><a href="index.html">Home</a></nav>
|
||||
</header>
|
||||
<main>
|
||||
<p>This page exists. The home page link labeled "Missing Page" points
|
||||
to <code>404.html</code>, which is this file — so it is not actually
|
||||
missing. To test a real missing page, click
|
||||
<a href="does-not-exist.html">this link to a truly missing page</a>.</p>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,348 @@
|
||||
# Local File Browsing Assessment — sovereign_browser
|
||||
|
||||
**Date:** 2026-07-16
|
||||
**Test site:** [`tests/local-site/`](index.html)
|
||||
**Browser:** sovereign_browser (WebKitGTK + libsoup-3.0)
|
||||
**Goal:** Verify that sovereign_browser can load and operate a multipage
|
||||
website directly from the local filesystem (`file://`) with no web server,
|
||||
allowing cross-origin access between local files — as required by the
|
||||
"security moved to the Qube level" design.
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
**Local file browsing in sovereign_browser works remarkably well.** The vast
|
||||
majority of edge cases pass. WebKitGTK's `file://` handler, combined with
|
||||
sovereign_browser's permissive local-file policy, allows a full multipage
|
||||
website to be opened and operated from disk just as if it were served over
|
||||
HTTP. Only a handful of minor issues were found, and most are WebKit engine
|
||||
quirks rather than sovereign_browser bugs.
|
||||
|
||||
**Verdict:** No blocking fixes are required for local-file browsing to work.
|
||||
The issues below are polish/UX improvements.
|
||||
|
||||
---
|
||||
|
||||
## Test Matrix
|
||||
|
||||
| # | Edge case | Page | Result | Notes |
|
||||
|---|-----------|------|--------|-------|
|
||||
| 1 | Relative links between sibling pages | [`index.html`](index.html) → [`about.html`](about.html) | ✅ PASS | Links resolve to correct `file://` URLs |
|
||||
| 2 | Links into subdirectories | [`index.html`](index.html) → [`subdir/page.html`](subdir/page.html) | ✅ PASS | |
|
||||
| 3 | Links back out with `../` | [`subdir/page.html`](subdir/page.html) → [`index.html`](index.html) | ✅ PASS | |
|
||||
| 4 | Deeply nested paths (3 levels) | [`subdir/deep/deeper/deepest.html`](subdir/deep/deeper/deepest.html) | ✅ PASS | `../../../` resolves correctly |
|
||||
| 5 | External stylesheet (relative) | [`assets/site.css`](assets/site.css) | ✅ PASS | 21 CSS rules loaded on every page |
|
||||
| 6 | SVG favicon | [`assets/favicon.svg`](assets/favicon.svg) | ✅ PASS | `link[rel=icon]` href resolved |
|
||||
| 7 | SVG image via `<img>` | [`assets/logo.svg`](assets/logo.svg) | ✅ PASS | `naturalWidth` correct, `complete=true` |
|
||||
| 8 | `<picture>` + `<source srcset>` | [`gallery.html`](gallery.html) | ✅ PASS | `currentSrc` selected correctly |
|
||||
| 9 | `<img srcset>` with `1x/2x` | [`gallery.html`](gallery.html) | ✅ PASS | |
|
||||
| 10 | Lazy-loaded image (`loading="lazy"`) | [`gallery.html`](gallery.html) | ✅ PASS | |
|
||||
| 11 | External `<script src>` (sibling) | [`assets/home.js`](assets/home.js) | ✅ PASS | Script ran, updated DOM |
|
||||
| 12 | External `<script src>` (`../`) | [`assets/external.js`](assets/external.js) | ✅ PASS | Loaded from parent dir |
|
||||
| 13 | `fetch()` a local JSON file | [`data.html`](data.html) → [`assets/data.json`](assets/data.json) | ✅ PASS | `status=0` (file:// convention), body returned |
|
||||
| 14 | `XMLHttpRequest` a local file | [`data.html`](data.html) | ✅ PASS | `status=0`, responseText populated |
|
||||
| 15 | `fetch()` from a subdirectory | [`data.html`](data.html) → [`subdir/sub.json`](subdir/sub.json) | ✅ PASS | |
|
||||
| 16 | `fetch()` with query string | [`data.html`](data.html) → `assets/data.json?cache=bust` | ✅ PASS | Query ignored, file loaded |
|
||||
| 17 | `fetch()` a missing file | [`data.html`](data.html) → `assets/does-not-exist.json` | ⚠️ MINOR | `fetch()` throws "Load failed" instead of returning a 404 response. This is standard WebKit `file://` behavior — there is no HTTP status for local files. **Not a bug.** |
|
||||
| 18 | `<iframe>` loading a sibling page | [`iframe.html`](iframe.html) → [`about.html`](about.html) | ✅ PASS | `load` event fired, `readyState=complete` |
|
||||
| 19 | `<iframe>` loading a subdirectory page | [`iframe.html`](iframe.html) → [`subdir/page.html`](subdir/page.html) | ✅ PASS | |
|
||||
| 20 | Cross-iframe DOM access (same-origin) | [`iframe.html`](iframe.html) | ✅ PASS | **Critical finding:** `contentDocument.title` readable — all `file://` URLs are treated as same-origin. This is exactly what we want for local development. |
|
||||
| 21 | `<iframe srcdoc>` | [`iframe.html`](iframe.html) | ✅ PASS | |
|
||||
| 22 | `<iframe src="data:...">` | [`iframe.html`](iframe.html) | ✅ PASS | |
|
||||
| 23 | GET form submission to local file | [`form.html`](form.html) → [`form-target.html`](form-target.html) | ✅ PASS | Navigated with `?name=Alice&age=30` |
|
||||
| 24 | POST form submission to local file | [`form.html`](form.html) → [`form-target.html`](form-target.html) | ✅ PASS | Navigated without query; POST body silently dropped (no server). Correct behavior. |
|
||||
| 25 | Form targeting a new window (`target="_blank"`) | [`form.html`](form.html) | ✅ PASS | Opens in new tab |
|
||||
| 26 | Form targeting an iframe (`target="form-frame"`) | [`form.html`](form.html) | ✅ PASS | |
|
||||
| 27 | `localStorage` | [`storage-test.html`](storage-test.html) | ✅ PASS | Set/get/remove all work; length tracking correct |
|
||||
| 28 | `sessionStorage` | [`storage-test.html`](storage-test.html) | ✅ PASS | Set/get/remove all work |
|
||||
| 29 | `document.cookie` | [`storage-test.html`](storage-test.html) | ✅ PASS | Set/get work; multiple cookies visible |
|
||||
| 30 | `IndexedDB` | [`storage-test.html`](storage-test.html) | ✅ PASS | Open, upgrade (create object store), put, get, count — all work |
|
||||
| 30b | Cache API (CacheStorage) | [`storage-test.html`](storage-test.html) | ❌ FAIL | `cache.put()` rejects: "Request url is not HTTP/HTTPS". See Issue #5. |
|
||||
| 31 | `window.open()` a sibling page | [`popup.html`](popup.html) → [`about.html`](about.html) | ✅ PASS | Opens in new tab |
|
||||
| 32 | `window.open()` with features | [`popup.html`](popup.html) | ✅ PASS | |
|
||||
| 33 | `history.pushState()` | [`history.html`](history.html) | ✅ PASS | URL updated with `?pushed=1` |
|
||||
| 34 | `history.replaceState()` | [`history.html`](history.html) | ✅ PASS | URL updated with `?replaced=1` |
|
||||
| 35 | `history.back()` / `forward()` | browser `back`/`forward` MCP tools | ✅ PASS | Navigates correctly between file:// pages |
|
||||
| 36 | Hash fragment in URL | [`hash.html#section-2`](hash.html) | ✅ PASS | `location.hash` = `#section-2` |
|
||||
| 37 | `hashchange` event | [`hash.html`](hash.html) | ✅ PASS | (page wired correctly) |
|
||||
| 38 | URL-encoded filename (`encoded%20name.html`) | [`encoded name.html`](encoded%20name.html) | ✅ PASS | Percent-encoding decoded, correct file loaded |
|
||||
| 39 | Query string on file:// URL | [`query.html?foo=bar&baz=qux`](query.html) | ✅ PASS | File loaded, `location.search` preserved, `URLSearchParams` works |
|
||||
| 40 | Missing page (404 equivalent) | `does-not-exist.html` | ✅ FIXED | URL bar now shows failed URL (Issue #1 fixed) |
|
||||
| 41 | `<video>` with local MP4 source | [`media.html`](media.html) | ✅ PASS | "can play", readyState=3, duration=596.5s, 640x360, no errors |
|
||||
| 42 | `<audio>` with local M4A source | [`media.html`](media.html) | ✅ PASS | "can play", readyState=4, duration=3277s, no errors |
|
||||
| 42b | Missing video source (error event test) | [`media.html`](media.html) | ⚠️ MINOR | `loadstart` fires but `error` event does not. See Issue #2. |
|
||||
| 43 | `<object>` / `<embed>` with SVG | [`media.html`](media.html) | ✅ PASS | `data`/`src` resolved to file:// URL |
|
||||
| 44 | Inline SVG | [`media.html`](media.html) | ✅ PASS | |
|
||||
| 45 | Clicking a link to navigate | [`index.html`](index.html) → [`about.html`](about.html) | ✅ PASS | `click` MCP tool triggered navigation |
|
||||
|
||||
---
|
||||
|
||||
## Issues Found
|
||||
|
||||
### Issue #1 — Missing page falls back to `about:blank` (FIXED ✅)
|
||||
|
||||
**What was happening:** When navigating to a `file://` URL that does not
|
||||
exist, the browser displayed an error message but the URL bar showed
|
||||
`about:blank` instead of the requested URL.
|
||||
|
||||
**Fix applied:** Updated [`on_load_failed()`](src/tab_manager.c:1335) in
|
||||
`src/tab_manager.c` to:
|
||||
1. Accept the `tab_info_t*` as `user_data` (changed signal connection from
|
||||
`NULL` to `tab`).
|
||||
2. Set the URL bar text to `failing_uri` (skipping `about:blank`).
|
||||
3. Update `tab->current_url` to the failing URI.
|
||||
4. Set the tab title to the error message so the tab bar shows something
|
||||
meaningful instead of "Loading…".
|
||||
|
||||
**Verified:** After the fix, navigating to a missing file shows the failed
|
||||
`file://` URL in the address bar (confirmed via `get_url` MCP tool).
|
||||
|
||||
---
|
||||
|
||||
### Issue #2 — `<video>`/`<audio>` error events don't fire for missing sources (MINOR / WebKit quirk)
|
||||
|
||||
**Note: Media playback with valid files works perfectly.** Tested with a
|
||||
real MP4 video (Big Buck Bunny, 596s, 640x360) and M4A audio (Neon Dream,
|
||||
3277s). Both reach `can play` state with correct duration and dimensions.
|
||||
This issue only affects the error path when a media file is missing.
|
||||
|
||||
**What happens:** When a `<video>` or `<audio>` element references a local
|
||||
file that doesn't exist, the `error` event does not fire. The `loadstart`
|
||||
event fires, but then the load silently fails without an error event. The
|
||||
element stays in a "not loaded" state indefinitely.
|
||||
|
||||
**Why it matters:** Web apps that rely on `error` events to show fallback
|
||||
content or retry logic won't work correctly for missing media files.
|
||||
|
||||
**Likely cause:** This is a WebKitGTK engine behavior, not a
|
||||
sovereign_browser bug. The `file://` loader may not emit media element
|
||||
errors the same way the HTTP loader does.
|
||||
|
||||
**Suggested fix:** None required — this is upstream WebKit behavior. If it
|
||||
becomes a problem, a workaround would be to check `networkState` or
|
||||
`readyState` after a timeout. Not worth fixing for the local-file use case.
|
||||
|
||||
**Severity:** Very low. Only affects missing media files, which is an edge
|
||||
case.
|
||||
|
||||
---
|
||||
|
||||
### Issue #3 — `fetch()` for missing files throws instead of returning an error response (MINOR / WebKit quirk)
|
||||
|
||||
**What happens:** `fetch('missing.json')` on a `file://` page throws a
|
||||
`TypeError: Load failed` exception rather than returning a Response with
|
||||
`ok=false` and a status code.
|
||||
|
||||
**Why it matters:** Code using `fetch().then(r => r.ok ? ... : ...)` will
|
||||
hit the catch branch instead of the error-handling branch. This is
|
||||
different from HTTP behavior.
|
||||
|
||||
**Likely cause:** Standard WebKit `file://` behavior — there's no HTTP
|
||||
status code to return for a missing local file, so the fetch promise
|
||||
rejects.
|
||||
|
||||
**Suggested fix:** None required — this is upstream WebKit behavior and is
|
||||
consistent with Safari and other WebKit-based browsers. Document it as a
|
||||
known difference from HTTP.
|
||||
|
||||
**Severity:** Very low. Developers writing local-file apps should use
|
||||
try/catch with fetch.
|
||||
|
||||
---
|
||||
|
||||
### Issue #4 — Page title shows `(none)` in browser log on initial load (FIXED ✅)
|
||||
|
||||
**What was happening:** The browser log frequently showed
|
||||
`[loaded] file://... -- title: (none)` even for pages that have a `<title>`.
|
||||
The title arrived slightly after the `LOAD_FINISHED` event.
|
||||
|
||||
**Fix applied:**
|
||||
1. Added a new [`on_title_changed()`](src/tab_manager.c:1361) handler
|
||||
connected to the webview's `notify::title` signal. It logs the title
|
||||
as `[title] URL -- Title` when the title actually becomes available,
|
||||
and updates the tab title.
|
||||
2. Simplified the `[loaded]` log line in `on_load_changed()` to just log
|
||||
the URL (no more `title: (none)` noise). The title is now logged
|
||||
separately via the `notify::title` handler.
|
||||
|
||||
**Verified:** After the fix, the log shows:
|
||||
```
|
||||
[title] file:///...index.html -- Local Site — Home
|
||||
[loaded] file:///...index.html
|
||||
```
|
||||
instead of the old `[loaded] ... -- title: (none)`.
|
||||
|
||||
---
|
||||
|
||||
### Issue #5 — Cache API (CacheStorage) rejects non-HTTP/HTTPS URLs (MINOR / WebKit restriction)
|
||||
|
||||
**What happens:** `caches.open('my_cache')` succeeds, but
|
||||
`cache.put(request, response)` rejects with the error:
|
||||
`"Request url is not HTTP/HTTPS"` when the request URL is not an HTTP/HTTPS
|
||||
URL. This means the Cache API cannot be used to store entries keyed by
|
||||
`file://` URLs or custom schemes.
|
||||
|
||||
**Why it matters:** Web apps that use the Cache API for offline storage
|
||||
(e.g., service workers caching responses) will not work on `file://` pages.
|
||||
The Cache API is the only standard browser storage that has this restriction
|
||||
— localStorage, sessionStorage, IndexedDB, and cookies all work fine on
|
||||
`file://`.
|
||||
|
||||
**Likely cause:** This is a WebKit engine restriction. The Cache API
|
||||
spec requires that request URLs be HTTP or HTTPS. WebKit enforces this
|
||||
at the `cache.put()` level. This is consistent with Chrome and Firefox
|
||||
behavior — the Cache API is designed for service worker contexts which
|
||||
only exist on HTTP/HTTPS origins.
|
||||
|
||||
**Suggested fix:** None required — this is a fundamental WebKit/W3C
|
||||
specification restriction, not a sovereign_browser bug. Local-file web
|
||||
apps that need key-value storage should use IndexedDB instead, which
|
||||
works perfectly on `file://`.
|
||||
|
||||
**Severity:** Low. Only affects apps specifically using the Cache API,
|
||||
which is a niche use case for local-file apps. IndexedDB is the
|
||||
recommended alternative and works fully.
|
||||
|
||||
---
|
||||
|
||||
## What Works Surprisingly Well
|
||||
|
||||
These are the things that could have been broken but weren't:
|
||||
|
||||
1. **Cross-origin access between all `file://` URLs** — iframes can access
|
||||
their parent's DOM and vice versa, `window.open()` popups can be
|
||||
accessed, and `fetch()`/XHR work across directories. This is the single
|
||||
most important requirement for local-file web apps and it works
|
||||
perfectly. WebKitGTK treats all `file://` URLs as same-origin.
|
||||
|
||||
2. **Relative path resolution** — `../`, `../../`, and `../../../` all work
|
||||
correctly for links, images, stylesheets, scripts, and fetch targets.
|
||||
|
||||
3. **URL-encoded filenames** — `encoded%20name.html` (file on disk:
|
||||
`encoded name.html`) loads correctly.
|
||||
|
||||
4. **Query strings and hash fragments** — preserved in `location.href`,
|
||||
accessible via `location.search` / `location.hash`, and don't break file
|
||||
loading.
|
||||
|
||||
5. **`history.pushState` / `replaceState`** — work on `file://` URLs,
|
||||
allowing SPA-style routing for local apps.
|
||||
|
||||
6. **All browser storage except Cache API** — `localStorage`,
|
||||
`sessionStorage`, `cookies`, and `IndexedDB` all work fully on
|
||||
`file://`. IndexedDB supports open, upgrade, object stores, put, get,
|
||||
and count operations. Only the Cache API (CacheStorage) fails because
|
||||
WebKit requires HTTP/HTTPS URLs for cache entries (Issue #5). Use
|
||||
IndexedDB as the recommended storage for local-file apps.
|
||||
|
||||
7. **`<picture>` / `srcset`** — responsive image selection works.
|
||||
|
||||
8. **Form submissions** — both GET and POST navigate to the target local
|
||||
file. GET appends query strings; POST silently drops the body (correct,
|
||||
since there's no server).
|
||||
|
||||
9. **Video and audio playback** — both MP4 video and M4A audio play
|
||||
correctly from `file://` URLs. The `<video>` element reports correct
|
||||
dimensions (640x360), duration (596.5s), and reaches `can play` state.
|
||||
The `<audio>` element similarly works with correct duration. Media
|
||||
playback is fully functional for local files.
|
||||
|
||||
---
|
||||
|
||||
## Recommended Actions
|
||||
|
||||
### No blocking fixes needed
|
||||
|
||||
The local-file browsing feature works well enough for production use as-is.
|
||||
The issues found are minor UX/engine-quirk issues, not functional blockers.
|
||||
|
||||
### Optional improvements (in priority order)
|
||||
|
||||
1. **~~Fix Issue #1 (about:blank on load failure)~~** — ✅ FIXED. The
|
||||
failed URL now stays in the address bar, and the tab title shows the
|
||||
error message.
|
||||
|
||||
2. **Document the `fetch()`-throws-on-missing-file behavior** — Add a note
|
||||
to the docs or a FAQ that `fetch()` on `file://` rejects (throws) for
|
||||
missing files rather than returning an HTTP error response.
|
||||
|
||||
3. **Document the Cache API limitation (Issue #5)** — Note in the docs that
|
||||
the Cache API (CacheStorage) does not work on `file://` pages because
|
||||
WebKit requires HTTP/HTTPS URLs for cache entries. Recommend IndexedDB
|
||||
as the alternative for local-file apps needing structured storage. (Not
|
||||
a real problem for local files since everything is already local — no
|
||||
need to cache.)
|
||||
|
||||
4. **~~Fix Issue #4 (title logging)~~** — ✅ FIXED. Added `notify::title`
|
||||
handler; log now shows `[title] URL -- Title` when the title arrives,
|
||||
and `[loaded] URL` without the noisy `title: (none)`.
|
||||
|
||||
5. **Issue #2 (media error events)** — No action needed; upstream WebKit
|
||||
behavior.
|
||||
|
||||
---
|
||||
|
||||
## How to Reproduce
|
||||
|
||||
```bash
|
||||
# Start the browser with auto-login and open the test site
|
||||
./browser.sh start --login-method generate \
|
||||
--url "file:///home/user/lt/sovereign_browser/tests/local-site/index.html"
|
||||
|
||||
# Navigate via MCP (examples):
|
||||
curl -s -X POST http://localhost:17777/mcp -H 'Content-Type: application/json' \
|
||||
-d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"open","arguments":{"url":"file:///home/user/lt/sovereign_browser/tests/local-site/data.html"}}}'
|
||||
|
||||
# Check fetch results:
|
||||
curl -s -X POST http://localhost:17777/mcp -H 'Content-Type: application/json' \
|
||||
-d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"eval","arguments":{"script":"document.getElementById(\"fetch-out\").textContent"}}}'
|
||||
|
||||
# Test a missing page:
|
||||
curl -s -X POST http://localhost:17777/mcp -H 'Content-Type: application/json' \
|
||||
-d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"open","arguments":{"url":"file:///home/user/lt/sovereign_browser/tests/local-site/does-not-exist.html"}}}'
|
||||
# Then check: get_url returns "about:blank" (Issue #1)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Test Site Structure
|
||||
|
||||
```
|
||||
tests/local-site/
|
||||
├── index.html # Home page with links to all pages
|
||||
├── about.html # Sibling page, anchor links
|
||||
├── contact.html # Parent-relative link test
|
||||
├── gallery.html # Images, picture, srcset, lazy load
|
||||
├── data.html # fetch() + XHR + JSON + missing file + query
|
||||
├── media.html # video, audio, object, embed, inline SVG
|
||||
├── iframe.html # iframes (sibling, subdir, srcdoc, data:), cross-frame access
|
||||
├── form.html # GET/POST forms, target=_blank, target=iframe
|
||||
├── form-target.html # Form submission target
|
||||
├── storage.html # Storage page with buttons (localStorage, sessionStorage, IndexedDB, cookies)
|
||||
├── storage-test.html # Automated storage test (all types incl. Cache API, writes results to DOM)
|
||||
├── popup.html # window.open() tests
|
||||
├── history.html # pushState, replaceState, back, hashchange
|
||||
├── hash.html # Hash fragment navigation
|
||||
├── query.html # Query string handling
|
||||
├── encoded name.html # URL-encoded filename (literal space)
|
||||
├── 404.html # Placeholder (not actually missing)
|
||||
├── assets/
|
||||
│ ├── site.css # Shared stylesheet
|
||||
│ ├── favicon.svg # SVG favicon
|
||||
│ ├── logo.svg # SVG image
|
||||
│ ├── pic.svg # SVG image
|
||||
│ ├── data.json # JSON for fetch/XHR tests
|
||||
│ ├── home.js # External script for index.html
|
||||
│ ├── external.js # External script for scripts/external.html
|
||||
│ ├── sample.mp4 # Test video (Big Buck Bunny, gitignored)
|
||||
│ └── Neon Dream.m4a # Test audio (gitignored)
|
||||
├── scripts/
|
||||
│ └── external.html # Page in a subdirectory loading ../assets/external.js
|
||||
└── subdir/
|
||||
├── page.html # Subdirectory page with ../ links
|
||||
├── sub.json # JSON for subdir fetch test
|
||||
└── deep/
|
||||
└── deeper/
|
||||
└── deepest.html # 3-levels-deep page with ../../../ links
|
||||
```
|
||||
@@ -0,0 +1,34 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Local Site — About</title>
|
||||
<link rel="stylesheet" href="assets/site.css">
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>About</h1>
|
||||
<nav>
|
||||
<a href="index.html">Home</a>
|
||||
<a href="about.html">About</a>
|
||||
<a href="contact.html">Contact</a>
|
||||
<a href="gallery.html">Gallery</a>
|
||||
<a href="data.html">Data</a>
|
||||
</nav>
|
||||
</header>
|
||||
<main>
|
||||
<p>This is the About page. It links back to the
|
||||
<a href="index.html">home page</a> and to a
|
||||
<a href="subdir/page.html">subdirectory page</a>.</p>
|
||||
|
||||
<h2>Nested relative link</h2>
|
||||
<p>From here, a link to a deeply nested page:
|
||||
<a href="subdir/deep/deeper/deepest.html">Deepest page</a>.</p>
|
||||
|
||||
<h2>Anchor on this page</h2>
|
||||
<p>Jump to <a href="#bottom">the bottom</a>.</p>
|
||||
|
||||
<p id="bottom">This is the bottom of the About page.</p>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"source": "assets/data.json",
|
||||
"loaded": true,
|
||||
"items": [
|
||||
{ "id": 1, "name": "alpha" },
|
||||
{ "id": 2, "name": "beta" },
|
||||
{ "id": 3, "name": "gamma" }
|
||||
],
|
||||
"meta": {
|
||||
"generated": "2026-07-16",
|
||||
"count": 3
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
// External script loaded by scripts/external.html via ../assets/external.js
|
||||
(function () {
|
||||
var out = document.getElementById('ext-out');
|
||||
if (out) out.textContent = 'External script ../assets/external.js ran successfully @ ' + new Date().toISOString();
|
||||
})();
|
||||
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect width="32" height="32" rx="6" fill="#3366cc"/>
|
||||
<text x="16" y="22" text-anchor="middle" fill="white" font-family="sans-serif" font-size="18" font-weight="bold">L</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 247 B |
@@ -0,0 +1,5 @@
|
||||
// External script loaded by index.html
|
||||
(function () {
|
||||
var out = document.getElementById('inline-script-out');
|
||||
if (out) out.textContent = 'External script assets/home.js ran successfully @ ' + new Date().toISOString();
|
||||
})();
|
||||
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 60">
|
||||
<rect width="120" height="60" rx="8" fill="#3366cc"/>
|
||||
<text x="60" y="38" text-anchor="middle" fill="white" font-family="sans-serif" font-size="20" font-weight="bold">LOCAL</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 253 B |
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 120">
|
||||
<rect width="200" height="120" fill="#eee"/>
|
||||
<circle cx="60" cy="60" r="30" fill="#e55"/>
|
||||
<rect x="110" y="30" width="60" height="60" fill="#5a5"/>
|
||||
<line x1="10" y1="100" x2="190" y2="20" stroke="#333" stroke-width="2"/>
|
||||
<text x="100" y="115" text-anchor="middle" font-family="sans-serif" font-size="10" fill="#333">pic.svg</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 411 B |
@@ -0,0 +1,51 @@
|
||||
/* Local site shared stylesheet */
|
||||
:root {
|
||||
--bg: #fafafa;
|
||||
--fg: #222;
|
||||
--accent: #36c;
|
||||
--border: #ddd;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
line-height: 1.5;
|
||||
}
|
||||
header {
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
padding: 1rem 1.5rem;
|
||||
}
|
||||
header h1 { margin: 0 0 0.5rem; font-size: 1.4rem; }
|
||||
nav { display: flex; flex-wrap: wrap; gap: 0.75rem; }
|
||||
nav a { color: white; text-decoration: none; border-bottom: 1px dotted rgba(255,255,255,0.5); }
|
||||
nav a:hover { background: rgba(255,255,255,0.15); }
|
||||
main { max-width: 820px; margin: 1.5rem auto; padding: 0 1.5rem; }
|
||||
h2 { color: var(--accent); border-bottom: 1px solid var(--border); padding-bottom: 0.25rem; }
|
||||
code, pre {
|
||||
font-family: "SF Mono", Menlo, Consolas, monospace;
|
||||
background: #f0f0f0;
|
||||
padding: 0.1em 0.3em;
|
||||
border-radius: 3px;
|
||||
}
|
||||
pre { padding: 0.75rem; overflow: auto; }
|
||||
figure { margin: 1rem 0; }
|
||||
figcaption { font-size: 0.85rem; color: #666; }
|
||||
button {
|
||||
font: inherit;
|
||||
padding: 0.4rem 0.9rem;
|
||||
border: 1px solid var(--accent);
|
||||
background: white;
|
||||
color: var(--accent);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
margin: 0.2rem 0;
|
||||
}
|
||||
button:hover { background: var(--accent); color: white; }
|
||||
input, textarea, select { font: inherit; padding: 0.3rem; }
|
||||
label { display: inline-block; margin-right: 1rem; }
|
||||
footer { border-top: 1px solid var(--border); margin-top: 2rem; padding: 1rem 1.5rem; color: #666; }
|
||||
iframe { border: 1px solid var(--border); border-radius: 4px; }
|
||||
img { max-width: 100%; height: auto; }
|
||||
@@ -0,0 +1,23 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Local Site — Contact</title>
|
||||
<link rel="stylesheet" href="assets/site.css">
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>Contact</h1>
|
||||
<nav>
|
||||
<a href="index.html">Home</a>
|
||||
<a href="about.html">About</a>
|
||||
<a href="contact.html">Contact</a>
|
||||
</nav>
|
||||
</header>
|
||||
<main>
|
||||
<p>Contact us via the <a href="form.html">form page</a>.</p>
|
||||
<p>Or go <a href="../local-site/index.html">home via a parent-relative link</a>
|
||||
(this should resolve to the same directory since we are at the root).</p>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,86 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Local Site — Data (fetch + XHR)</title>
|
||||
<link rel="stylesheet" href="assets/site.css">
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>Data Page</h1>
|
||||
<nav>
|
||||
<a href="index.html">Home</a>
|
||||
<a href="data.html">Data</a>
|
||||
</nav>
|
||||
</header>
|
||||
<main>
|
||||
<h2>fetch() a local JSON file</h2>
|
||||
<pre id="fetch-out">fetch() not started.</pre>
|
||||
|
||||
<h2>XMLHttpRequest a local JSON file</h2>
|
||||
<pre id="xhr-out">XHR not started.</pre>
|
||||
|
||||
<h2>fetch() a file in a subdirectory</h2>
|
||||
<pre id="fetch-sub-out">sub fetch not started.</pre>
|
||||
|
||||
<h2>fetch() a missing file (expect rejection)</h2>
|
||||
<pre id="fetch-missing-out">missing fetch not started.</pre>
|
||||
|
||||
<h2>fetch() with a query string</h2>
|
||||
<pre id="fetch-query-out">query fetch not started.</pre>
|
||||
</main>
|
||||
<script>
|
||||
(async function () {
|
||||
// 1. fetch a sibling JSON file
|
||||
try {
|
||||
const r = await fetch('assets/data.json');
|
||||
const j = await r.json();
|
||||
document.getElementById('fetch-out').textContent =
|
||||
'OK status=' + r.status + '\n' + JSON.stringify(j, null, 2);
|
||||
} catch (e) {
|
||||
document.getElementById('fetch-out').textContent = 'ERROR: ' + e.message;
|
||||
}
|
||||
|
||||
// 2. XHR a sibling JSON file
|
||||
try {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open('GET', 'assets/data.json', false);
|
||||
xhr.send(null);
|
||||
document.getElementById('xhr-out').textContent =
|
||||
'OK status=' + xhr.status + '\n' + xhr.responseText;
|
||||
} catch (e) {
|
||||
document.getElementById('xhr-out').textContent = 'ERROR: ' + e.message;
|
||||
}
|
||||
|
||||
// 3. fetch a file in a subdirectory
|
||||
try {
|
||||
const r = await fetch('subdir/sub.json');
|
||||
const t = await r.text();
|
||||
document.getElementById('fetch-sub-out').textContent =
|
||||
'OK status=' + r.status + '\n' + t;
|
||||
} catch (e) {
|
||||
document.getElementById('fetch-sub-out').textContent = 'ERROR: ' + e.message;
|
||||
}
|
||||
|
||||
// 4. fetch a missing file
|
||||
try {
|
||||
const r = await fetch('assets/does-not-exist.json');
|
||||
document.getElementById('fetch-missing-out').textContent =
|
||||
'status=' + r.status + ' ok=' + r.ok;
|
||||
} catch (e) {
|
||||
document.getElementById('fetch-missing-out').textContent = 'ERROR: ' + e.message;
|
||||
}
|
||||
|
||||
// 5. fetch with a query string (should be ignored for file://)
|
||||
try {
|
||||
const r = await fetch('assets/data.json?cache=bust');
|
||||
const t = await r.text();
|
||||
document.getElementById('fetch-query-out').textContent =
|
||||
'OK status=' + r.status + '\n' + t;
|
||||
} catch (e) {
|
||||
document.getElementById('fetch-query-out').textContent = 'ERROR: ' + e.message;
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,23 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Local Site — Encoded Filename</title>
|
||||
<link rel="stylesheet" href="assets/site.css">
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>Encoded-Filename Page</h1>
|
||||
<nav><a href="index.html">Home</a></nav>
|
||||
</header>
|
||||
<main>
|
||||
<p>This file's name on disk is <code>encoded name.html</code>
|
||||
(with a literal space). The link from the home page uses
|
||||
<code>encoded%20name.html</code>.</p>
|
||||
<p>If you can read this, the browser correctly decoded the
|
||||
percent-encoding and loaded the right file.</p>
|
||||
<p>Current URL: <span id="url"></span></p>
|
||||
</main>
|
||||
<script>document.getElementById('url').textContent = location.href;</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,26 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Local Site — Form Target</title>
|
||||
<link rel="stylesheet" href="assets/site.css">
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>Form Target</h1>
|
||||
<nav><a href="index.html">Home</a> | <a href="form.html">Back to form</a></nav>
|
||||
</header>
|
||||
<main>
|
||||
<p>The form submitted to this page. On a local file:// URL, GET forms
|
||||
should append a query string to the URL; POST forms have no server
|
||||
to receive the body, so the body is effectively dropped.</p>
|
||||
<p>Current URL: <span id="url"></span></p>
|
||||
<p>Query string: <span id="qs"></span></p>
|
||||
</main>
|
||||
<script>
|
||||
var u = new URL(location.href);
|
||||
document.getElementById('url').textContent = location.href;
|
||||
document.getElementById('qs').textContent = u.search || '(none)';
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,47 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Local Site — Form</title>
|
||||
<link rel="stylesheet" href="assets/site.css">
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>Form Page</h1>
|
||||
<nav><a href="index.html">Home</a></nav>
|
||||
</header>
|
||||
<main>
|
||||
<h2>GET form (submits to form-target.html)</h2>
|
||||
<form action="form-target.html" method="get">
|
||||
<label>Name: <input name="name" value="Alice"></label>
|
||||
<label>Age: <input name="age" value="30"></label>
|
||||
<button type="submit">Submit GET</button>
|
||||
</form>
|
||||
|
||||
<h2>POST form (submits to form-target.html)</h2>
|
||||
<form action="form-target.html" method="post">
|
||||
<label>Comment: <input name="comment" value="hello"></label>
|
||||
<button type="submit">Submit POST</button>
|
||||
</form>
|
||||
|
||||
<h2>Form with enctype=multipart/form-data</h2>
|
||||
<form action="form-target.html" method="post" enctype="multipart/form-data">
|
||||
<label>File: <input type="file" name="upload"></label>
|
||||
<button type="submit">Submit multipart</button>
|
||||
</form>
|
||||
|
||||
<h2>Form targeting a new window</h2>
|
||||
<form action="form-target.html" method="get" target="_blank">
|
||||
<label>Q: <input name="q" value="search"></label>
|
||||
<button type="submit">Submit to new window</button>
|
||||
</form>
|
||||
|
||||
<h2>Form targeting an iframe</h2>
|
||||
<form action="form-target.html" method="get" target="form-frame">
|
||||
<label>R: <input name="r" value="x"></label>
|
||||
<button type="submit">Submit to iframe</button>
|
||||
</form>
|
||||
<iframe name="form-frame" width="100%" height="120"></iframe>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,42 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Local Site — Gallery</title>
|
||||
<link rel="stylesheet" href="assets/site.css">
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>Gallery</h1>
|
||||
<nav>
|
||||
<a href="index.html">Home</a>
|
||||
<a href="gallery.html">Gallery</a>
|
||||
</nav>
|
||||
</header>
|
||||
<main>
|
||||
<h2>Images</h2>
|
||||
<figure>
|
||||
<img src="assets/logo.svg" alt="logo" width="200">
|
||||
<figcaption>logo.svg (relative path)</figcaption>
|
||||
</figure>
|
||||
<figure>
|
||||
<img src="assets/pic.svg" alt="pic" width="200">
|
||||
<figcaption>pic.svg (relative path)</figcaption>
|
||||
</figure>
|
||||
|
||||
<h2>Picture element with srcset</h2>
|
||||
<picture>
|
||||
<source srcset="assets/logo.svg" type="image/svg+xml">
|
||||
<img src="assets/pic.svg" alt="fallback">
|
||||
</picture>
|
||||
|
||||
<h2>Image with srcset sizes</h2>
|
||||
<img src="assets/pic.svg"
|
||||
srcset="assets/pic.svg 1x, assets/logo.svg 2x"
|
||||
alt="srcset test" width="200">
|
||||
|
||||
<h2>Lazy-loaded image</h2>
|
||||
<img src="assets/pic.svg" loading="lazy" alt="lazy" width="200">
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,35 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Local Site — Hash</title>
|
||||
<link rel="stylesheet" href="assets/site.css">
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>Hash Page</h1>
|
||||
<nav><a href="index.html">Home</a></nav>
|
||||
</header>
|
||||
<main>
|
||||
<h2 id="section-1">Section 1</h2>
|
||||
<p>Jump to <a href="#section-2">section 2</a> or
|
||||
<a href="#section-3">section 3</a>.</p>
|
||||
|
||||
<h2 id="section-2">Section 2</h2>
|
||||
<p>You arrived at section 2. The URL fragment should be
|
||||
<code>#section-2</code>.</p>
|
||||
|
||||
<h2 id="section-3">Section 3</h2>
|
||||
<p>End of the page.</p>
|
||||
|
||||
<p>Current hash: <span id="hash-out">—</span></p>
|
||||
</main>
|
||||
<script>
|
||||
function report() {
|
||||
document.getElementById('hash-out').textContent = location.hash || '(none)';
|
||||
}
|
||||
report();
|
||||
window.addEventListener('hashchange', report);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,62 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Local Site — History</title>
|
||||
<link rel="stylesheet" href="assets/site.css">
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>History Page</h1>
|
||||
<nav><a href="index.html">Home</a></nav>
|
||||
</header>
|
||||
<main>
|
||||
<h2>history.pushState</h2>
|
||||
<button id="push-btn">pushState({n:1}, '', '?pushed=1')</button>
|
||||
<p>URL after push: <span id="push-url">—</span></p>
|
||||
|
||||
<h2>history.replaceState</h2>
|
||||
<button id="replace-btn">replaceState({n:2}, '', '?replaced=1')</button>
|
||||
<p>URL after replace: <span id="replace-url">—</span></p>
|
||||
|
||||
<h2>popstate event</h2>
|
||||
<button id="back-btn">history.back()</button>
|
||||
<p>popstate fired: <span id="popstate-out">never</span></p>
|
||||
<p>URL on popstate: <span id="popstate-url">—</span></p>
|
||||
|
||||
<h2>Hash change</h2>
|
||||
<a href="#frag-1">Go to #frag-1</a>
|
||||
<p>hashchange fired: <span id="hash-out">never</span></p>
|
||||
</main>
|
||||
<script>
|
||||
function $(id) { return document.getElementById(id); }
|
||||
|
||||
$('push-btn').onclick = function () {
|
||||
try {
|
||||
history.pushState({ n: 1 }, '', '?pushed=1');
|
||||
$('push-url').textContent = location.href;
|
||||
} catch (e) { $('push-url').textContent = 'ERROR: ' + e.message; }
|
||||
};
|
||||
|
||||
$('replace-btn').onclick = function () {
|
||||
try {
|
||||
history.replaceState({ n: 2 }, '', '?replaced=1');
|
||||
$('replace-url').textContent = location.href;
|
||||
} catch (e) { $('replace-url').textContent = 'ERROR: ' + e.message; }
|
||||
};
|
||||
|
||||
window.addEventListener('popstate', function (e) {
|
||||
$('popstate-out').textContent = 'yes (state=' + JSON.stringify(e.state) + ')';
|
||||
$('popstate-url').textContent = location.href;
|
||||
});
|
||||
|
||||
$('back-btn').onclick = function () {
|
||||
history.back();
|
||||
};
|
||||
|
||||
window.addEventListener('hashchange', function () {
|
||||
$('hash-out').textContent = 'yes (' + location.hash + ')';
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,64 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Local Site — Iframe</title>
|
||||
<link rel="stylesheet" href="assets/site.css">
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>Iframe Page</h1>
|
||||
<nav><a href="index.html">Home</a></nav>
|
||||
</header>
|
||||
<main>
|
||||
<h2>Sibling page in an iframe</h2>
|
||||
<iframe src="about.html" width="100%" height="200" id="f1"></iframe>
|
||||
<p>iframe status: <span id="f1-status">loading…</span></p>
|
||||
|
||||
<h2>Subdirectory page in an iframe</h2>
|
||||
<iframe src="subdir/page.html" width="100%" height="200" id="f2"></iframe>
|
||||
<p>iframe status: <span id="f2-status">loading…</span></p>
|
||||
|
||||
<h2>Cross-iframe script access (same-origin for file://?)</h2>
|
||||
<button id="read-btn">Read iframe document title</button>
|
||||
<p>Result: <span id="read-result">not attempted</span></p>
|
||||
|
||||
<h2>srcdoc iframe</h2>
|
||||
<iframe srcdoc="<h1>Hello from srcdoc</h1><p><a href='index.html'>home</a></p>"
|
||||
width="100%" height="120"></iframe>
|
||||
|
||||
<h2>data: URI iframe</h2>
|
||||
<iframe src="data:text/html,<h1>Hello from data URI</h1>"
|
||||
width="100%" height="120"></iframe>
|
||||
</main>
|
||||
<script>
|
||||
(function () {
|
||||
var f1 = document.getElementById('f1');
|
||||
var f2 = document.getElementById('f2');
|
||||
f1.addEventListener('load', function () {
|
||||
document.getElementById('f1-status').textContent = 'loaded';
|
||||
});
|
||||
f1.addEventListener('error', function () {
|
||||
document.getElementById('f1-status').textContent = 'ERROR';
|
||||
});
|
||||
f2.addEventListener('load', function () {
|
||||
document.getElementById('f2-status').textContent = 'loaded';
|
||||
});
|
||||
f2.addEventListener('error', function () {
|
||||
document.getElementById('f2-status').textContent = 'ERROR';
|
||||
});
|
||||
|
||||
document.getElementById('read-btn').addEventListener('click', function () {
|
||||
try {
|
||||
var t = f1.contentDocument.title;
|
||||
document.getElementById('read-result').textContent =
|
||||
'OK title="' + t + '"';
|
||||
} catch (e) {
|
||||
document.getElementById('read-result').textContent =
|
||||
'ERROR: ' + e.message;
|
||||
}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,81 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Local Site — Home</title>
|
||||
<link rel="stylesheet" href="assets/site.css">
|
||||
<link rel="icon" href="assets/favicon.svg">
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>Local Site — Home</h1>
|
||||
<nav>
|
||||
<a href="index.html">Home</a>
|
||||
<a href="about.html">About</a>
|
||||
<a href="contact.html">Contact</a>
|
||||
<a href="gallery.html">Gallery</a>
|
||||
<a href="data.html">Data</a>
|
||||
<a href="subdir/page.html">Subdir Page</a>
|
||||
<a href="subdir/deep/deeper/deepest.html">Deep Link</a>
|
||||
<a href="404.html">Missing Page</a>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<p>This is a multipage website loaded directly from the local filesystem
|
||||
(no web server). It is designed to exercise edge cases for local-file
|
||||
browsing in sovereign_browser.</p>
|
||||
|
||||
<h2>Edge cases covered</h2>
|
||||
<ul>
|
||||
<li>Relative links between pages in the same directory.</li>
|
||||
<li>Links into subdirectories and back out with <code>../</code>.</li>
|
||||
<li>Deeply nested paths (3 levels deep).</li>
|
||||
<li>External stylesheets and images loaded via relative paths.</li>
|
||||
<li>An SVG favicon.</li>
|
||||
<li>A page that fetches a local JSON file via <code>fetch()</code>.</li>
|
||||
<li>A page that uses <code>XMLHttpRequest</code> for a local file.</li>
|
||||
<li>A page with an inline iframe loading another local page.</li>
|
||||
<li>A page with a form that POSTs to another local page.</li>
|
||||
<li>A page that uses <code>window.open()</code> to open another local page.</li>
|
||||
<li>A page with a hash fragment link and <code>history.pushState</code>.</li>
|
||||
<li>A page that reads a local file via an external script tag.</li>
|
||||
<li>A page with a video and audio source.</li>
|
||||
<li>A page that uses <code>localStorage</code> and <code>sessionStorage</code>.</li>
|
||||
<li>A page with a <code>data:</code> URI and a <code>blob:</code> URI.</li>
|
||||
<li>A link to a missing page (404 / load-failure handling).</li>
|
||||
<li>A link with a query string (<code>?foo=bar</code>) on a local file.</li>
|
||||
<li>A link with a URL-encoded space in the filename.</li>
|
||||
</ul>
|
||||
|
||||
<h2>Quick links</h2>
|
||||
<ul>
|
||||
<li><a href="data.html">Data page (fetch + XHR + JSON)</a></li>
|
||||
<li><a href="media.html">Media page (video/audio/image)</a></li>
|
||||
<li><a href="iframe.html">Iframe page</a></li>
|
||||
<li><a href="form.html">Form page (GET + POST)</a></li>
|
||||
<li><a href="storage.html">Storage page (localStorage/sessionStorage)</a></li>
|
||||
<li><a href="popup.html">Popup page (window.open)</a></li>
|
||||
<li><a href="history.html">History page (pushState/replaceState)</a></li>
|
||||
<li><a href="encoded%20name.html">URL-encoded filename page</a></li>
|
||||
<li><a href="query.html?foo=bar&baz=qux">Query-string page</a></li>
|
||||
<li><a href="hash.html#section-2">Hash-fragment page</a></li>
|
||||
<li><a href="scripts/external.html">External script page</a></li>
|
||||
</ul>
|
||||
|
||||
<h2>Inline image test</h2>
|
||||
<p>An image loaded from a relative path:</p>
|
||||
<img src="assets/logo.svg" alt="logo" width="120">
|
||||
|
||||
<h2>Inline script test</h2>
|
||||
<p id="inline-script-out">Inline script did not run.</p>
|
||||
<script src="assets/home.js"></script>
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<p><small>Loaded from: <span id="location"></span></small></p>
|
||||
</footer>
|
||||
<script>document.getElementById('location').textContent = window.location.href;</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,123 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Local Site — Media</title>
|
||||
<link rel="stylesheet" href="assets/site.css">
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>Media Page</h1>
|
||||
<nav><a href="index.html">Home</a></nav>
|
||||
</header>
|
||||
<main>
|
||||
<h2>SVG image</h2>
|
||||
<img src="assets/pic.svg" alt="svg" width="300">
|
||||
|
||||
<h2>Inline SVG</h2>
|
||||
<svg width="120" height="60" viewBox="0 0 120 60" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="120" height="60" fill="#36c"/>
|
||||
<text x="60" y="36" text-anchor="middle" fill="white" font-family="sans-serif">Inline SVG</text>
|
||||
</svg>
|
||||
|
||||
<h2>Video (local MP4 source)</h2>
|
||||
<video controls width="480" poster="assets/pic.svg" id="vid">
|
||||
<source src="assets/sample.mp4" type="video/mp4">
|
||||
Your browser does not support video.
|
||||
</video>
|
||||
<p>Video readyState: <span id="video-ready">0</span></p>
|
||||
<p>Video networkState: <span id="video-net">0</span></p>
|
||||
<p>Video status: <span id="video-status">not loaded</span></p>
|
||||
<p>Video duration: <span id="video-dur">—</span> seconds</p>
|
||||
<p>Video dimensions: <span id="video-dim">—</span></p>
|
||||
|
||||
<h2>Audio (local M4A source)</h2>
|
||||
<audio controls id="aud">
|
||||
<source src="assets/Neon%20Dream.m4a" type="audio/mp4">
|
||||
Your browser does not support audio.
|
||||
</audio>
|
||||
<p>Audio readyState: <span id="audio-ready">0</span></p>
|
||||
<p>Audio networkState: <span id="audio-net">0</span></p>
|
||||
<p>Audio status: <span id="audio-status">not loaded</span></p>
|
||||
<p>Audio duration: <span id="audio-dur">—</span> seconds</p>
|
||||
|
||||
<h2>Object & embed</h2>
|
||||
<object data="assets/pic.svg" type="image/svg+xml" width="200"></object>
|
||||
<embed src="assets/pic.svg" width="200" type="image/svg+xml">
|
||||
|
||||
<h2>Missing video (error event test)</h2>
|
||||
<video controls width="320" id="vid-missing">
|
||||
<source src="assets/does-not-exist.mp4" type="video/mp4">
|
||||
</video>
|
||||
<p>Missing video status: <span id="vid-missing-status">not loaded</span></p>
|
||||
</main>
|
||||
<script>
|
||||
(function () {
|
||||
var v = document.getElementById('vid');
|
||||
var a = document.getElementById('aud');
|
||||
var vm = document.getElementById('vid-missing');
|
||||
|
||||
function updateVideo() {
|
||||
document.getElementById('video-ready').textContent = v.readyState;
|
||||
document.getElementById('video-net').textContent = v.networkState;
|
||||
if (v.readyState >= 1) {
|
||||
document.getElementById('video-dur').textContent = v.duration ? v.duration.toFixed(1) : '—';
|
||||
}
|
||||
if (v.videoWidth > 0) {
|
||||
document.getElementById('video-dim').textContent = v.videoWidth + 'x' + v.videoHeight;
|
||||
}
|
||||
}
|
||||
|
||||
if (v) {
|
||||
v.addEventListener('loadeddata', function () {
|
||||
document.getElementById('video-status').textContent = 'loaded';
|
||||
updateVideo();
|
||||
});
|
||||
v.addEventListener('loadedmetadata', function () {
|
||||
updateVideo();
|
||||
});
|
||||
v.addEventListener('canplay', function () {
|
||||
document.getElementById('video-status').textContent = 'can play';
|
||||
updateVideo();
|
||||
});
|
||||
v.addEventListener('error', function () {
|
||||
document.getElementById('video-status').textContent = 'ERROR';
|
||||
});
|
||||
v.addEventListener('stalled', function () {
|
||||
document.getElementById('video-status').textContent = 'stalled';
|
||||
});
|
||||
}
|
||||
|
||||
if (a) {
|
||||
a.addEventListener('loadeddata', function () {
|
||||
document.getElementById('audio-status').textContent = 'loaded';
|
||||
document.getElementById('audio-ready').textContent = a.readyState;
|
||||
document.getElementById('audio-net').textContent = a.networkState;
|
||||
});
|
||||
a.addEventListener('loadedmetadata', function () {
|
||||
document.getElementById('audio-ready').textContent = a.readyState;
|
||||
document.getElementById('audio-net').textContent = a.networkState;
|
||||
if (a.duration) {
|
||||
document.getElementById('audio-dur').textContent = a.duration.toFixed(1);
|
||||
}
|
||||
});
|
||||
a.addEventListener('canplay', function () {
|
||||
document.getElementById('audio-status').textContent = 'can play';
|
||||
});
|
||||
a.addEventListener('error', function () {
|
||||
document.getElementById('audio-status').textContent = 'ERROR';
|
||||
});
|
||||
}
|
||||
|
||||
if (vm) {
|
||||
vm.addEventListener('error', function () {
|
||||
document.getElementById('vid-missing-status').textContent = 'ERROR (error event fired)';
|
||||
});
|
||||
vm.addEventListener('loadstart', function () {
|
||||
document.getElementById('vid-missing-status').textContent = 'loadstart fired';
|
||||
});
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,61 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Local Site — Popup</title>
|
||||
<link rel="stylesheet" href="assets/site.css">
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>Popup Page</h1>
|
||||
<nav><a href="index.html">Home</a></nav>
|
||||
</header>
|
||||
<main>
|
||||
<h2>window.open() a sibling page</h2>
|
||||
<button id="open-btn">Open about.html in new window</button>
|
||||
<p>Result: <span id="open-out">not attempted</span></p>
|
||||
|
||||
<h2>window.open() with features</h2>
|
||||
<button id="open-feat-btn">Open gallery.html (width=400,height=400)</button>
|
||||
<p>Result: <span id="open-feat-out">not attempted</span></p>
|
||||
|
||||
<h2>window.open() then access popup.document</h2>
|
||||
<button id="open-access-btn">Open + read title</button>
|
||||
<p>Result: <span id="open-access-out">not attempted</span></p>
|
||||
|
||||
<h2>target="_blank" link</h2>
|
||||
<a href="about.html" target="_blank">Open about.html via target=_blank</a>
|
||||
</main>
|
||||
<script>
|
||||
function $(id) { return document.getElementById(id); }
|
||||
|
||||
$('open-btn').onclick = function () {
|
||||
try {
|
||||
var w = window.open('about.html');
|
||||
$('open-out').textContent = w ? 'opened (name=' + w.name + ')' : 'blocked/null';
|
||||
} catch (e) { $('open-out').textContent = 'ERROR: ' + e.message; }
|
||||
};
|
||||
|
||||
$('open-feat-btn').onclick = function () {
|
||||
try {
|
||||
var w = window.open('gallery.html', 'gal', 'width=400,height=400');
|
||||
$('open-feat-out').textContent = w ? 'opened' : 'blocked/null';
|
||||
} catch (e) { $('open-feat-out').textContent = 'ERROR: ' + e.message; }
|
||||
};
|
||||
|
||||
$('open-access-btn').onclick = function () {
|
||||
try {
|
||||
var w = window.open('about.html');
|
||||
if (!w) { $('open-access-out').textContent = 'blocked/null'; return; }
|
||||
w.addEventListener('load', function () {
|
||||
try {
|
||||
$('open-access-out').textContent = 'title="' + w.document.title + '"';
|
||||
} catch (e) {
|
||||
$('open-access-out').textContent = 'access ERROR: ' + e.message;
|
||||
}
|
||||
});
|
||||
} catch (e) { $('open-access-out').textContent = 'ERROR: ' + e.message; }
|
||||
};
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,32 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Local Site — Query</title>
|
||||
<link rel="stylesheet" href="assets/site.css">
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>Query-String Page</h1>
|
||||
<nav><a href="index.html">Home</a></nav>
|
||||
</header>
|
||||
<main>
|
||||
<p>This page was loaded with a query string. On a file:// URL the query
|
||||
string is preserved in <code>location.search</code> but should not
|
||||
affect which file is loaded.</p>
|
||||
<p>Full URL: <span id="url"></span></p>
|
||||
<p>Search: <span id="search"></span></p>
|
||||
<p>Parsed params:</p>
|
||||
<pre id="params"></pre>
|
||||
</main>
|
||||
<script>
|
||||
var u = new URL(location.href);
|
||||
document.getElementById('url').textContent = location.href;
|
||||
document.getElementById('search').textContent = u.search || '(none)';
|
||||
var params = {};
|
||||
u.searchParams.forEach(function (v, k) { params[k] = v; });
|
||||
document.getElementById('params').textContent =
|
||||
JSON.stringify(params, null, 2);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,20 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Local Site — External Script</title>
|
||||
<link rel="stylesheet" href="../assets/site.css">
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>External Script Page</h1>
|
||||
<nav><a href="../index.html">Home</a></nav>
|
||||
</header>
|
||||
<main>
|
||||
<p>This page loads a script from <code>../assets/external.js</code>
|
||||
via an external script tag.</p>
|
||||
<p id="ext-out">External script did not run.</p>
|
||||
</main>
|
||||
<script src="../assets/external.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,197 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Local Site — Storage Test</title>
|
||||
<link rel="stylesheet" href="assets/site.css">
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>Storage Test Page</h1>
|
||||
<nav><a href="index.html">Home</a></nav>
|
||||
</header>
|
||||
<main>
|
||||
<h2>Results</h2>
|
||||
<pre id="results">Running tests...</pre>
|
||||
</main>
|
||||
<script>
|
||||
(function () {
|
||||
var log = [];
|
||||
function flush() {
|
||||
var el = document.getElementById('results');
|
||||
if (el) el.textContent = log.join('\n');
|
||||
}
|
||||
function add(msg) {
|
||||
log.push(msg);
|
||||
flush();
|
||||
}
|
||||
|
||||
add('=== Storage Test Start ===');
|
||||
add('URL: ' + location.href);
|
||||
add('');
|
||||
|
||||
// 1. localStorage
|
||||
add('--- localStorage ---');
|
||||
try {
|
||||
localStorage.setItem('ls_test', 'ls_value');
|
||||
add('SET: ok');
|
||||
add('GET: ' + localStorage.getItem('ls_test'));
|
||||
add('LENGTH: ' + localStorage.length);
|
||||
localStorage.removeItem('ls_test');
|
||||
add('REMOVE: ok, length now ' + localStorage.length);
|
||||
} catch (e) {
|
||||
add('ERROR: ' + e.message);
|
||||
}
|
||||
add('');
|
||||
|
||||
// 2. sessionStorage
|
||||
add('--- sessionStorage ---');
|
||||
try {
|
||||
sessionStorage.setItem('ss_test', 'ss_value');
|
||||
add('SET: ok');
|
||||
add('GET: ' + sessionStorage.getItem('ss_test'));
|
||||
add('LENGTH: ' + sessionStorage.length);
|
||||
sessionStorage.removeItem('ss_test');
|
||||
add('REMOVE: ok, length now ' + sessionStorage.length);
|
||||
} catch (e) {
|
||||
add('ERROR: ' + e.message);
|
||||
}
|
||||
add('');
|
||||
|
||||
// 3. Cookies
|
||||
add('--- Cookies (document.cookie) ---');
|
||||
try {
|
||||
document.cookie = 'sb_test=cookie_value; path=/';
|
||||
add('SET: ok');
|
||||
var match = document.cookie.match(/sb_test=([^;]+)/);
|
||||
add('GET: ' + (match ? match[1] : 'NOT FOUND'));
|
||||
add('ALL: ' + document.cookie);
|
||||
} catch (e) {
|
||||
add('ERROR: ' + e.message);
|
||||
}
|
||||
add('');
|
||||
|
||||
// 4. IndexedDB
|
||||
add('--- IndexedDB ---');
|
||||
add('available: ' + ('indexedDB' in window));
|
||||
try {
|
||||
var req = indexedDB.open('sb_test_db', 1);
|
||||
req.onupgradeneeded = function (e) {
|
||||
var db = e.target.result;
|
||||
if (!db.objectStoreNames.contains('kv')) {
|
||||
db.createObjectStore('kv');
|
||||
add('UPGRADE: created object store "kv"');
|
||||
}
|
||||
};
|
||||
req.onsuccess = function (e) {
|
||||
var db = e.target.result;
|
||||
add('OPEN: ok (name=' + db.name + ', version=' + db.version + ')');
|
||||
add('STORES: ' + Array.from(db.objectStoreNames).join(', '));
|
||||
|
||||
var tx = db.transaction('kv', 'readwrite');
|
||||
tx.objectStore('kv').put('idb_test_value', 'idb_test_key');
|
||||
tx.oncomplete = function () {
|
||||
add('PUT: ok (key=idb_test_key, value=idb_test_value)');
|
||||
|
||||
var tx2 = db.transaction('kv', 'readonly');
|
||||
var r = tx2.objectStore('kv').get('idb_test_key');
|
||||
r.onsuccess = function () {
|
||||
add('GET: ' + r.result);
|
||||
|
||||
// count entries
|
||||
var tx3 = db.transaction('kv', 'readonly');
|
||||
var cr = tx3.objectStore('kv').count();
|
||||
cr.onsuccess = function () {
|
||||
add('COUNT: ' + cr.result);
|
||||
add('INDEXEDDB: ALL PASS');
|
||||
testCacheAPI();
|
||||
};
|
||||
cr.onerror = function () { add('COUNT: error'); testCacheAPI(); };
|
||||
};
|
||||
r.onerror = function () { add('GET: error'); testCacheAPI(); };
|
||||
};
|
||||
tx.onerror = function () { add('PUT: tx error'); testCacheAPI(); };
|
||||
};
|
||||
req.onerror = function (e) {
|
||||
add('OPEN: error - ' + e.target.error);
|
||||
testCacheAPI();
|
||||
};
|
||||
req.onblocked = function () {
|
||||
add('OPEN: blocked');
|
||||
testCacheAPI();
|
||||
};
|
||||
} catch (e) {
|
||||
add('INDEXEDDB EXCEPTION: ' + e.message);
|
||||
testCacheAPI();
|
||||
}
|
||||
|
||||
// 5. Cache API (CacheStorage)
|
||||
function testCacheAPI() {
|
||||
add('');
|
||||
add('--- Cache API (CacheStorage) ---');
|
||||
add('available: ' + ('caches' in window));
|
||||
if (!('caches' in window)) {
|
||||
add('CACHE: not available');
|
||||
finishAll();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
caches.open('sb_test_cache').then(function (cache) {
|
||||
add('OPEN: ok (cache name=sb_test_cache)');
|
||||
|
||||
var testUrl = 'local://test-entry';
|
||||
var resp = new Response('cached body text', {
|
||||
headers: { 'Content-Type': 'text/plain' }
|
||||
});
|
||||
|
||||
cache.put(new Request(testUrl), resp).then(function () {
|
||||
add('PUT: ok (url=' + testUrl + ')');
|
||||
|
||||
cache.match(new Request(testUrl)).then(function (matched) {
|
||||
if (matched) {
|
||||
matched.text().then(function (body) {
|
||||
add('MATCH: ok, body="' + body + '"');
|
||||
|
||||
cache.keys().then(function (keys) {
|
||||
add('KEYS: ' + keys.length + ' entries');
|
||||
add('CACHE: ALL PASS');
|
||||
finishAll();
|
||||
}).catch(function (e) {
|
||||
add('KEYS: error - ' + e.message);
|
||||
finishAll();
|
||||
});
|
||||
}).catch(function (e) {
|
||||
add('MATCH BODY: error - ' + e.message);
|
||||
finishAll();
|
||||
});
|
||||
} else {
|
||||
add('MATCH: no match found');
|
||||
finishAll();
|
||||
}
|
||||
}).catch(function (e) {
|
||||
add('MATCH: error - ' + e.message);
|
||||
finishAll();
|
||||
});
|
||||
}).catch(function (e) {
|
||||
add('PUT: error - ' + e.message);
|
||||
finishAll();
|
||||
});
|
||||
}).catch(function (e) {
|
||||
add('OPEN: error - ' + e.message);
|
||||
finishAll();
|
||||
});
|
||||
} catch (e) {
|
||||
add('CACHE EXCEPTION: ' + e.message);
|
||||
finishAll();
|
||||
}
|
||||
}
|
||||
|
||||
function finishAll() {
|
||||
add('');
|
||||
add('=== Storage Test Complete ===');
|
||||
flush();
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,100 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Local Site — Storage</title>
|
||||
<link rel="stylesheet" href="assets/site.css">
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>Storage Page</h1>
|
||||
<nav><a href="index.html">Home</a></nav>
|
||||
</header>
|
||||
<main>
|
||||
<h2>localStorage</h2>
|
||||
<button id="ls-set">Set ls.foo = "bar"</button>
|
||||
<button id="ls-get">Get ls.foo</button>
|
||||
<p>Result: <span id="ls-out">not attempted</span></p>
|
||||
|
||||
<h2>sessionStorage</h2>
|
||||
<button id="ss-set">Set ss.foo = "baz"</button>
|
||||
<button id="ss-get">Get ss.foo</button>
|
||||
<p>Result: <span id="ss-out">not attempted</span></p>
|
||||
|
||||
<h2>IndexedDB</h2>
|
||||
<button id="idb-set">Set idb key</button>
|
||||
<button id="idb-get">Get idb key</button>
|
||||
<p>Result: <span id="idb-out">not attempted</span></p>
|
||||
|
||||
<h2>Cookies (document.cookie)</h2>
|
||||
<button id="ck-set">Set cookie foo=bar</button>
|
||||
<button id="ck-get">Get cookie</button>
|
||||
<p>Result: <span id="ck-out">not attempted</span></p>
|
||||
</main>
|
||||
<script>
|
||||
function $(id) { return document.getElementById(id); }
|
||||
|
||||
$('ls-set').onclick = function () {
|
||||
try { localStorage.setItem('foo', 'bar'); $('ls-out').textContent = 'set OK'; }
|
||||
catch (e) { $('ls-out').textContent = 'ERROR: ' + e.message; }
|
||||
};
|
||||
$('ls-get').onclick = function () {
|
||||
try { $('ls-out').textContent = 'value=' + localStorage.getItem('foo'); }
|
||||
catch (e) { $('ls-out').textContent = 'ERROR: ' + e.message; }
|
||||
};
|
||||
|
||||
$('ss-set').onclick = function () {
|
||||
try { sessionStorage.setItem('foo', 'baz'); $('ss-out').textContent = 'set OK'; }
|
||||
catch (e) { $('ss-out').textContent = 'ERROR: ' + e.message; }
|
||||
};
|
||||
$('ss-get').onclick = function () {
|
||||
try { $('ss-out').textContent = 'value=' + sessionStorage.getItem('foo'); }
|
||||
catch (e) { $('ss-out').textContent = 'ERROR: ' + e.message; }
|
||||
};
|
||||
|
||||
$('idb-set').onclick = function () {
|
||||
try {
|
||||
var req = indexedDB.open('testdb', 1);
|
||||
req.onupgradeneeded = function (e) {
|
||||
var db = e.target.result;
|
||||
if (!db.objectStoreNames.contains('kv')) db.createObjectStore('kv');
|
||||
};
|
||||
req.onsuccess = function (e) {
|
||||
var db = e.target.result;
|
||||
var tx = db.transaction('kv', 'readwrite');
|
||||
tx.objectStore('kv').put('hello', 'greeting');
|
||||
tx.oncomplete = function () { $('idb-out').textContent = 'set OK'; };
|
||||
tx.onerror = function (ev) { $('idb-out').textContent = 'tx ERROR: ' + ev.target.error; };
|
||||
};
|
||||
req.onerror = function (ev) { $('idb-out').textContent = 'open ERROR: ' + ev.target.error; };
|
||||
} catch (e) { $('idb-out').textContent = 'ERROR: ' + e.message; }
|
||||
};
|
||||
$('idb-get').onclick = function () {
|
||||
try {
|
||||
var req = indexedDB.open('testdb', 1);
|
||||
req.onsuccess = function (e) {
|
||||
var db = e.target.result;
|
||||
if (!db.objectStoreNames.contains('kv')) {
|
||||
$('idb-out').textContent = 'no store yet';
|
||||
return;
|
||||
}
|
||||
var tx = db.transaction('kv', 'readonly');
|
||||
var r = tx.objectStore('kv').get('greeting');
|
||||
r.onsuccess = function () { $('idb-out').textContent = 'value=' + r.result; };
|
||||
r.onerror = function (ev) { $('idb-out').textContent = 'get ERROR: ' + ev.target.error; };
|
||||
};
|
||||
req.onerror = function (ev) { $('idb-out').textContent = 'open ERROR: ' + ev.target.error; };
|
||||
} catch (e) { $('idb-out').textContent = 'ERROR: ' + e.message; }
|
||||
};
|
||||
|
||||
$('ck-set').onclick = function () {
|
||||
try { document.cookie = 'foo=bar; path=/'; $('ck-out').textContent = 'set OK'; }
|
||||
catch (e) { $('ck-out').textContent = 'ERROR: ' + e.message; }
|
||||
};
|
||||
$('ck-get').onclick = function () {
|
||||
try { $('ck-out').textContent = 'cookies=' + (document.cookie || '(none)'); }
|
||||
catch (e) { $('ck-out').textContent = 'ERROR: ' + e.message; }
|
||||
};
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,24 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Local Site — Deepest</title>
|
||||
<link rel="stylesheet" href="../../../assets/site.css">
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>Deepest Page</h1>
|
||||
<nav>
|
||||
<a href="../../../index.html">Home (../../../)</a>
|
||||
<a href="../../page.html">Subdir page (../../)</a>
|
||||
</nav>
|
||||
</header>
|
||||
<main>
|
||||
<p>This page is 3 directories deep. All links and assets use
|
||||
<code>../../../</code> to climb back to the root.</p>
|
||||
<img src="../../../assets/pic.svg" alt="pic via ../../../" width="200">
|
||||
<p>Current URL: <span id="url"></span></p>
|
||||
</main>
|
||||
<script>document.getElementById('url').textContent = location.href;</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,34 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Local Site — Subdir Page</title>
|
||||
<link rel="stylesheet" href="../assets/site.css">
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>Subdir Page</h1>
|
||||
<nav>
|
||||
<a href="../index.html">Home (../)</a>
|
||||
<a href="../about.html">About (../)</a>
|
||||
<a href="deep/deeper/deepest.html">Deepest (relative)</a>
|
||||
</nav>
|
||||
</header>
|
||||
<main>
|
||||
<p>This page lives in <code>subdir/</code>. Stylesheet and links use
|
||||
<code>../</code> to reach the root.</p>
|
||||
|
||||
<h2>Image via parent-relative path</h2>
|
||||
<img src="../assets/pic.svg" alt="pic via ../" width="200">
|
||||
|
||||
<h2>fetch() a sibling JSON in this subdir</h2>
|
||||
<pre id="out">not started.</pre>
|
||||
</main>
|
||||
<script>
|
||||
fetch('sub.json')
|
||||
.then(function (r) { return r.text(); })
|
||||
.then(function (t) { document.getElementById('out').textContent = t; })
|
||||
.catch(function (e) { document.getElementById('out').textContent = 'ERROR: ' + e.message; });
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"source": "subdir/sub.json",
|
||||
"loaded": true,
|
||||
"note": "This JSON is fetched from a subdirectory."
|
||||
}
|
||||
@@ -1,202 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>💛 Yellow Flowers Gallery 💛</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
body {
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
background: linear-gradient(135deg, #fef9e7, #fff8dc, #fffacd);
|
||||
color: #4a3b1f;
|
||||
min-height: 100vh;
|
||||
}
|
||||
header {
|
||||
text-align: center;
|
||||
padding: 2.5rem 1rem 1.5rem;
|
||||
background: linear-gradient(135deg, #ffd70022, #ffec8022);
|
||||
border-bottom: 3px solid #ffd70044;
|
||||
}
|
||||
header h1 {
|
||||
font-size: 2.5rem;
|
||||
color: #b8860b;
|
||||
text-shadow: 1px 1px 3px rgba(255, 215, 0, 0.3);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
header p {
|
||||
font-size: 1.1rem;
|
||||
color: #8b7355;
|
||||
font-style: italic;
|
||||
}
|
||||
.gallery {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 1.5rem;
|
||||
padding: 2rem;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.card {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 4px 15px rgba(255, 215, 0, 0.15);
|
||||
transition: transform 0.3s ease, box-shadow 0.3s ease;
|
||||
}
|
||||
.card:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 8px 25px rgba(255, 215, 0, 0.35);
|
||||
}
|
||||
.card img {
|
||||
width: 100%;
|
||||
height: 250px;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
.card .info {
|
||||
padding: 1rem 1.2rem 1.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
.card h3 {
|
||||
color: #b8860b;
|
||||
font-size: 1.25rem;
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
.card p {
|
||||
font-size: 0.9rem;
|
||||
color: #8b7355;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.card .number {
|
||||
display: inline-block;
|
||||
background: #ffd70033;
|
||||
color: #b8860b;
|
||||
font-weight: bold;
|
||||
border-radius: 50%;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
line-height: 28px;
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
footer {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: #8b7355;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>🌻 Yellow Flowers Gallery 🌻</h1>
|
||||
<p>A collection of 12 beautiful yellow flowers</p>
|
||||
</header>
|
||||
<div class="gallery">
|
||||
<div class="card">
|
||||
<img src="sunflower.jpg" alt="Sunflower">
|
||||
<div class="info">
|
||||
<div class="number">1</div>
|
||||
<h3>Sunflower</h3>
|
||||
<p>Iconic tall flower that turns to face the sun. 🌻</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<img src="daffodil.jpg" alt="Daffodil">
|
||||
<div class="info">
|
||||
<div class="number">2</div>
|
||||
<h3>Daffodil</h3>
|
||||
<p>A cheerful spring flower with a trumpet-shaped center.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<img src="black_eyed_susan.jpg" alt="Black-eyed Susan">
|
||||
<div class="info">
|
||||
<div class="number">3</div>
|
||||
<h3>Black-eyed Susan</h3>
|
||||
<p>A daisy-like wildflower with a dark brown center.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<img src="marigold.jpg" alt="Marigold">
|
||||
<div class="info">
|
||||
<div class="number">4</div>
|
||||
<h3>Marigold</h3>
|
||||
<p>A popular garden flower with layered, ruffly petals.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<img src="yellow_tulip.jpg" alt="Yellow Tulip">
|
||||
<div class="info">
|
||||
<div class="number">5</div>
|
||||
<h3>Yellow Tulip</h3>
|
||||
<p>Elegant cup-shaped spring bloomer.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<img src="forsythia.jpg" alt="Forsythia">
|
||||
<div class="info">
|
||||
<div class="number">6</div>
|
||||
<h3>Forsythia</h3>
|
||||
<p>A shrub covered in bright yellow blossoms in early spring.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<img src="yellow_rose.jpg" alt="Yellow Rose">
|
||||
<div class="info">
|
||||
<div class="number">7</div>
|
||||
<h3>Yellow Rose</h3>
|
||||
<p>A classic symbol of friendship and joy. 🌹</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<img src="goldenrod.jpg" alt="Goldenrod">
|
||||
<div class="info">
|
||||
<div class="number">8</div>
|
||||
<h3>Goldenrod</h3>
|
||||
<p>Tall, fluffy spikes of yellow that bloom in late summer.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<img src="yellow_daisy.jpg" alt="Yellow Daisy">
|
||||
<div class="info">
|
||||
<div class="number">9</div>
|
||||
<h3>Yellow Daisy</h3>
|
||||
<p>Simple, charming, and beloved worldwide.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<img src="yarrow.jpg" alt="Yarrow">
|
||||
<div class="info">
|
||||
<div class="number">10</div>
|
||||
<h3>Yarrow</h3>
|
||||
<p>Flat-topped clusters of tiny yellow flowers, great for pollinators.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<img src="primrose.jpg" alt="Primrose">
|
||||
<div class="info">
|
||||
<div class="number">11</div>
|
||||
<h3>Primrose</h3>
|
||||
<p>Soft, pale yellow flowers that bloom in early spring.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<img src="buttercup.jpg" alt="Buttercup">
|
||||
<div class="info">
|
||||
<div class="number">12</div>
|
||||
<h3>Buttercup</h3>
|
||||
<p>Glossy, shiny yellow petals often found in meadows.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<footer>
|
||||
<p>Images sourced via Openverse (Creative Commons) · Created with 💛</p>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user