fuzzyworld-tools / commit
Site-wide header and footer, card styling, apps index without commit lists
PLAN.md | 7 +- README.md | 8 +-- server/content/_how.html | 88 +++++++++++++++++++++++++ server/content/contribute.html | 35 ++++++++++ server/content/principles.html | 13 ++++ server/fw | 7 +- server/gen.py | 131 +++++++++++++++++--------------------- server/install.sh | 4 +- server/site/404.html | 17 ----- server/site/contribute/index.html | 52 --------------- server/site/how/index.html | 105 ------------------------------ server/site/principles/index.html | 30 --------- server/site/style.css | 57 +++++++++++------ 13 files changed, 244 insertions(+), 310 deletions(-) diff --git a/PLAN.md b/PLAN.md index 4aa51f8..e0bcb8a 100644 --- a/PLAN.md +++ b/PLAN.md @@ -22,7 +22,7 @@ never anything from a repo). All done: ✅ live since 2026-10-27 (v1.1.0), unless noted. -1. ✅ Apps index: newest deploy first, with what changed. Live since 2026-10-26. +1. ✅ Apps index: newest deploy first. (It once listed the commits of each deploy; removed 2026-10-27 as noise.) 2. ✅ **LICENSE file** (MIT) in this repo. Each repo's license on its summary page. 3. ✅ **Principles** in four places: `/why/`, `/how/`, a new `/principles/` page, and a "Philosophy" section in the README. `/how/` is still a draft that `install.sh` doesn't install (see Backlog); the principle is in it for @@ -91,9 +91,8 @@ The order goes from cheap to expensive, and each step reuses what the one before - CI `cache:` support (a per-repo volume on TrueNAS), and `services:` - Branch pages: link to the default branch's page when a file is identical, instead of a copy - Found in review (2026-10-27): discussed, not done - - Site pages in Markdown: `/why/` done (`server/content/why.md`, `gen.py content`). Still HTML: `/principles/`, - `/contribute/`, `/how/` draft, `404.html` - - `/how/` goes live: finish the draft, add it to `server/install.sh` (`/why/` already says it is coming) + - Site pages: all now in `server/content/` (`gen.py content`), including `404.html`; `/how/` is `_how.html` (draft, not built) + - `/how/` goes live: finish the draft and rename `server/content/_how.html` to `how.html` (`/why/` already says it is coming) - CI runner tags (`docker`, `chrome`, `android`) and the laptop as a second runner (`fw-runner.py --once`): designed, not built; there is one runner, and it takes every job - CI secrets: none are set, so jobs that deploy elsewhere fail (`dreamhost`'s rsync, engye's store uploads). diff --git a/README.md b/README.md index 837b14e..265d695 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,7 @@ Then `pnpm run deploy` / `npm run deploy`. **`fw deploy`**: `--dry-run` does everything except upload. -The apps index lists the most recently deployed app first. Under each app from a **public** repo it shows the commit subjects deployed since its previous deploy (up to 5, linked to their commit pages); apps from private repos show only the date, so their history stays private. `.fuzzyworld.json` holds only the latest deploy, so the index remembers earlier ones in `~/fw/state/apps.json`. +The apps index lists the most recently deployed app first, with its description, a screenshot if the runner has taken one, and a source link for apps from public repos. Apps hosted elsewhere are listed on the index from `server/site-apps/extra.json` (engye: Cloudflare Pages; `apps.fuzzyworld.net/engye/...` redirects there). @@ -79,7 +79,7 @@ On the laptop, `bin/fw` runs `deploy` locally and forwards repo commands to Drea | `fw private <name>` | unpublish right away: pages, clone URL and index entry are removed | | `fw describe <name> TEXT` | set the description shown on the index and summary page | | `fw publish <name>` | refresh one repo's public copy and pages (the push hook does this) | -| `fw publish-all` | refresh every repo, then the index | +| `fw publish-all` | refresh every repo (printing `[n/total] name (seconds)` as each finishes), then the index and the CI dashboard | | `fw ci <name> [branch]` | queue a CI run of a branch's latest commit (default: the default branch) | | `fw ci-list` | queued and running CI jobs | | `fw ci-watch` | follow CI in the terminal (private repos too): running jobs with each job's state, the queue, the last 5 results; ends when nothing is queued or running | @@ -104,7 +104,7 @@ Other settings in the repo config: `fuzzyworld.description`, `fuzzyworld.ci` (`o | `~/apps.fuzzyworld.net/<app>/` | deployed apps (`fw deploy`); `index.html` at the root is generated | | `~/fw/tools/server/` | this repo's `server/` directory, installed by `server/install.sh` | | `~/bin/fw` | symlink to `~/fw/tools/server/fw` | -| `~/fw/state/` | per-repo page manifests and locks; `apps.json`: recent deploys per app (for the apps index) | +| `~/fw/state/` | per-repo page manifests and locks | | `~/fw/logs/<name>.log` | output of each push's publish run | | `~/fw/pylib/` | Python-Markdown 3.7 (README rendering) and Pygments 2.21.0 (syntax highlighting), `pip install --target`; `install.sh` adds Pygments | | `~/fw/queue/pending/`, `running/` | CI jobs, one JSON file each (see [CI](#ci)) | @@ -133,7 +133,7 @@ Every repo uses the shared hook via `core.hooksPath=~/fw/tools/server/hooks`. `p | `/<name>/ci/badge.svg` | status of the latest pipeline on the default branch | | `/ci/` | CI dashboard: running pipelines (each job's state and time), the queue, the last 50 results, for public repos | | `/<name>/files.json`, `/<name>/objects/<sha>` | for VS Code in the browser: every file's blob SHA and size, and each file's contents (≤ 1 MB) | -| `/why/`, `/principles/`, `/contribute/` | about the site. `/why/` is written in Markdown (`server/content/why.md`, rendered by `fw content`); the other two are still hand-written HTML in `server/site/` | +| `/why/`, `/principles/`, `/contribute/` | about the site. All are files in `server/content/` (Markdown, or an HTML fragment with a leading `<!-- title: … -->`), rendered by `fw content` into the common page layout. A file starting with `_` (the `/how/` draft) is not built. `fw content` also writes `404.html` | **Branches:** the default branch, and every branch **not yet merged** into it, get browse pages. A merged branch adds nothing new to read and would duplicate every page, so the branches page lists it with a link to its last diff --git a/server/content/_how.html b/server/content/_how.html new file mode 100644 index 0000000..7a7be2d --- /dev/null +++ b/server/content/_how.html @@ -0,0 +1,88 @@ +<!-- title: How I moved off GitLab --> +<!-- DRAFT 2026-10-27 for code.fuzzyworld.net/how/ (plan Backlog item 3). Pavel edits before it goes live; on hold until the item 2 features. Not installed or linked. --> +<h1>How I moved off GitLab</h1> +<p><a href="/why/">Why</a> I left is short. <em>How</em> took about a day, 89 repositories and 35 web + apps, and turned up enough surprises that it seemed worth writing down for anyone thinking about the same + move.</p> + +<h2>What I ended up with</h2> +<ul> + <li><strong>Bare git repos on cheap shared hosting</strong> (DreamHost). I push over SSH; nothing else + runs there permanently.</li> + <li><strong>code.fuzzyworld.net</strong>: static browse pages, generated by a git hook when I push, plus + read-only <code>git clone https://…</code>. No app server, no accounts, no rate limits beyond what + Apache can serve.</li> + <li><strong>apps.fuzzyworld.net</strong>: every former GitLab Pages site, at the same relative path, built + and uploaded from my laptop with one command.</li> + <li><strong>A backup mirror at home</strong> that can read, but not write.</li> +</ul> +<p>What I gave up: issues, merge requests and search. Only the default branch can be browsed. Cloning over + plain HTTP is slower for big repos (a 244 MB repo takes about 50 seconds).</p> + +<h2>Getting everything out of GitLab</h2> +<p>The code is the easy part: <code>git clone --mirror</code> gets every branch and tag. The rest lives in + GitLab's settings and is easy to forget:</p> +<ul> + <li><strong>CI/CD variables.</strong> Signing keys, API tokens, deploy keys. Export them before anything + else; losing an Android signing key means you can't update that app in the Play Store again.</li> + <li><strong>Release files.</strong> APKs and tarballs attached to releases are often CI <em>job + artifacts</em>, and those expire. Five of mine already had.</li> + <li><strong>Pipeline schedules</strong>, issues, merge requests and wiki pages. Confidential issues in a + public project need separate handling, or the export makes them public.</li> + <li><strong>Merge-request commits</strong> live under <code>refs/merge-requests/*</code>, which a normal + clone doesn't fetch. I kept them as <code>refs/gitlab/merge-requests/*</code>.</li> +</ul> + +<h2>Traps I fell into</h2> +<ul> + <li><strong>The rate limit bit me during the export.</strong> The <code>glab</code> CLI's login is an + OAuth token, which has to be sent as <code>Authorization: Bearer</code>. Sent as + <code>PRIVATE-TOKEN</code>, every call counted as anonymous, and my home IP was blocked within + minutes. Web URLs for job artifacts and uploads count as anonymous even with a token; use the API + endpoints instead.</li> + <li><strong>My own git hook refused my own history.</strong> A global pre-push hook that rejects + lightweight tags (good for new releases) blocked mirroring old ones. Historical tags have to move + unchanged, so the migration pushes skip the hook.</li> + <li><strong>Shared hosting kills big git operations.</strong> Cloning a 284 MB repo over SSH failed with + "git-pack-objects died": the host's watchdog killed git's packing step. Limiting git's threads and + memory (<code>pack.threads 1</code>, <code>pack.windowMemory 64m</code>) fixed it for good.</li> + <li><strong>The host executes things you might not expect.</strong> DreamHost runs <code>.php</code> files + and server-side includes by default. A code browser that wrote repository files into the web root + with their real names would have run them. Browse pages are pre-generated HTML, raw copies are + images only, and every response says <code>script-src 'none'</code>.</li> + <li><strong>Node on a hardened kernel.</strong> The host's kernel refuses executable memory, so modern + Node crashes as soon as its JIT starts. <code>--jitless</code> works; native npm modules don't build, + so installs use <code>--ignore-scripts</code>.</li> +</ul> + +<h2>Moving the apps</h2> +<p>Every app moved to the same path under a new domain, and they all share one origin, exactly as they did on + <code>*.gitlab.io</code>. That means two apps can still clash over <code>localStorage</code> keys, a + service worker can claim more than its own folder, and two installed web apps can merge on Android if + their manifests look alike. So the deploy script refuses to upload anything whose URLs, manifest or + service worker reach outside the app's own path.</p> +<p>Small things broke in surprising places: GitLab-style links (<code>/-/blob/main/…</code>, + <code>/-/commit/…</code>) baked into apps, short commit IDs where the new site expected long ones, a + vite config whose root was a subfolder, Google sign-in that only works on domains Firebase knows about, + and OAuth redirect URIs registered with the old address.</p> + +<h2>Saying goodbye without breaking links</h2> +<p>Each GitLab project got one last commit: a README pointing to the new home, and, for sites, a redirect + page in place of the app. The redirect keeps the path, query and <code>#hash</code>, so deep links land on + the matching new page. It also retires any old service worker, which would otherwise keep serving the + cached old app to returning visitors forever.</p> + +<h2>Would I do it again?</h2> +<p>Yes. Everything is static, it costs nothing extra on hosting I already paid for, and nobody needs an + account to read my code. The one real loss is easy contributions: there are no merge requests here + yet.</p> + +<h2>Principles</h2> +<p>Things made with AI should be shared freely with the world. The code and apps here were made with + AI, so they are MIT-licensed: use them, change them, share them.</p> +<p>My conversations with the AI are different. My words and my way of working are mine. I publish a + conversation only when I choose to, I can take that permission back, and nobody may copy my words or + my style of work without my express permission.</p> +<p>More on the <a href="/principles/">principles</a> page.</p> + +<p><a href="/why/">← Why I left GitLab</a></p> diff --git a/server/content/contribute.html b/server/content/contribute.html new file mode 100644 index 0000000..f722966 --- /dev/null +++ b/server/content/contribute.html @@ -0,0 +1,35 @@ +<!-- title: How to contribute --> +<h1>How to contribute</h1> +<p>Thanks for wanting to help! This site has no accounts, issue tracker or merge requests, so contributions + arrive by email. Two ways work, both with plain git.</p> + +<h2>1. Share a branch</h2> +<p>Best for anything bigger than a small fix.</p> +<ol> + <li>Clone the repository: <code>git clone https://code.fuzzyworld.net/<repo>.git</code></li> + <li>Make your change on a branch, and push that branch to <strong>any</strong> public git host you like + (GitHub, Codeberg, GitLab, your own server).</li> + <li>Email the repository URL, the branch name and a short description to + <code>contrib-68uq+<repo>@fuzzyworld.net</code>, using the repository's name in place of + <code><repo></code>.</li> +</ol> +<p>I fetch the branch, review it locally, and reply by email.</p> + +<h2>2. Email a patch</h2> +<p>Good for small changes. Commit your change, then create a patch file:</p> +<pre>git format-patch origin/main</pre> +<p>and attach the <code>.patch</code> file(s) to an email to <code>contrib-68uq+<repo>@fuzzyworld.net</code>. + If you use <code>git send-email</code>, that address works there too.</p> + +<h2>Bug reports and ideas</h2> +<p>No git needed: email <code>bugs-k7q4+<repo>@fuzzyworld.net</code>. Many of the apps have a + <strong>report</strong> link that fills in the details for you.</p> + +<h2>Before you send</h2> +<ul> + <li>Check the repository's README (and CONTRIBUTING, if it has one) for how to build and test it.</li> + <li>One change per branch or patch, please; it makes review much quicker.</li> + <li>By contributing you agree that your change is shared under the repository's license.</li> +</ul> + +<p><a href="/why/">Why there are no merge requests here</a></p> diff --git a/server/content/principles.html b/server/content/principles.html new file mode 100644 index 0000000..1d3b3ae --- /dev/null +++ b/server/content/principles.html @@ -0,0 +1,13 @@ +<!-- title: Principles --> +<h1>Principles</h1> +<h2>What's made with AI is shared</h2> +<p>Things made with AI should be shared freely with the world. The code and apps here were made with + AI, so they are MIT-licensed: use them, change them, share them.</p> +<p>My conversations with the AI are different. My words and my way of working are mine. I publish a + conversation only when I choose to, I can take that permission back, and nobody may copy my words or + my style of work without my express permission.</p> +<p class="meta">Copyright protects my words. It generally doesn't protect a style or a way of working, so + the last part is a statement of my terms and wishes, not something a license can enforce.</p> +<h2>No sign-in, no limits</h2> +<p>Everything here is static files: no accounts, no rate limits, nothing that runs code from a + repository. See <a href="/why/">why this site exists</a>.</p> diff --git a/server/fw b/server/fw index 248b773..0ad433e 100755 --- a/server/fw +++ b/server/fw @@ -146,12 +146,17 @@ cmd_publish() { } cmd_publish_all() { - local r name + local r name n=0 total started + total=$(ls -d "$GIT_ROOT"/*.git | wc -l | tr -d ' ') for r in "$GIT_ROOT"/*.git; do name=$(basename "$r" .git) + n=$((n + 1)) + started=$SECONDS ( with_lock "$name" publish_one "$name" ) || echo "fw: publish failed for $name" >&2 + echo "fw: [$n/$total] $name ($((SECONDS - started))s)" done "${GEN[@]}" index + "${GEN[@]}" ci-dashboard } cmd_install_hooks() { diff --git a/server/gen.py b/server/gen.py index 096ad01..7c0b75a 100755 --- a/server/gen.py +++ b/server/gen.py @@ -41,7 +41,7 @@ BASE_URL = "https://code.fuzzyworld.net" APPS_URL = "https://apps.fuzzyworld.net" # Bump when page templates change, so every repo gets a full rebuild once. -GEN_VERSION = 7 +GEN_VERSION = 8 COMMIT_LIMIT = 2000 # commit pages per repo, across all branches; older commits: clone HISTORY_LIMIT = 200 # commits listed on a file's history page @@ -51,9 +51,6 @@ CI_REFRESH = 15 # seconds: pages showing something running reload themsel SHOTS = "_shots" # apps.fuzzyworld.net/_shots/<app>/: screenshots from CI runners (no app name starts with "_") SHOT_EXTENSIONS = (".png", ".jpg", ".jpeg", ".webp") VSCODE_APP = "vscode" # apps.fuzzyworld.net/<this>/: the static VS Code web build (vscode-web/) -APP_HISTORY = 20 # deploys remembered per app (~/fw/state/apps.json) -APP_CHANGES = 5 # commit subjects shown per app on the apps index -APP_FIRST_CHANGES = 3 # shown for an app's first recorded deploy, which has no previous one MAX_TEXT_BYTES = 1024 * 1024 MAX_DIFF_BYTES = 300 * 1024 MAX_RAW_BYTES = 2 * 1024 * 1024 @@ -143,15 +140,39 @@ def url(*parts): return "/" + "/".join(urllib.parse.quote(s) for s in path.split("/")) -def page(title, body, crumbs=None, view=None, site="code", refresh=None): - if site == "apps": - host, footer = "apps.fuzzyworld.net", (f'<a href="/">All apps</a> · <a href="{BASE_URL}/">Code</a> · ' - f'<a href="{BASE_URL}/why/">Why this site exists</a> · ' - f'<a href="{BASE_URL}/principles/">Principles</a>') - else: - host, footer = "code.fuzzyworld.net", (f'<a href="/">All repositories</a> · <a href="{APPS_URL}/">Apps</a> · ' - f'<a href="/contribute/">Contribute</a> · <a href="/ci/">CI</a> · <a href="/why/">Why this site exists</a> · ' - f'<a href="/principles/">Principles</a>') +HOME_URL = "https://fuzzyworld.net" +# Site-wide navigation, in the header of every page on both sites: (section, label, site, path). +NAV = [("code", "Code", "code", "/"), ("apps", "Apps", "apps", "/"), ("ci", "CI", "code", "/ci/"), + ("contribute", "Contribute", "code", "/contribute/"), ("why", "Why", "code", "/why/"), + ("principles", "Principles", "code", "/principles/")] +SITE_URLS = {"code": BASE_URL, "apps": APPS_URL} + + +def site_link(site, path, here): + """Links within a site stay relative; links to the other site are absolute.""" + return path if site == here else SITE_URLS[site] + path + + +def page(title, body, crumbs=None, view=None, site="code", refresh=None, section=None): + """`site` is the site being generated; `section` is the nav entry to highlight (default: the site's home).""" + host = "apps.fuzzyworld.net" if site == "apps" else "code.fuzzyworld.net" + section = section or site + links = "".join( + f'<a href="{site_link(st, path, site)}"' + (' aria-current="page"' if key == section else "") + f'>{label}</a>' + for key, label, st, path in NAV) + header = (f'<header class="site"><div class="bar"><a class="brand" href="{HOME_URL}/">fuzzyworld</a>' + f'<nav class="site-nav" aria-label="Site">{links}</nav></div></header>') + footer = ('<footer class="site"><div class="cols">' + f'<div><h2>fuzzyworld</h2><a href="{HOME_URL}/">fuzzyworld.net</a>' + f'<a href="{site_link("code", "/", site)}">Code: all repositories</a>' + f'<a href="{site_link("apps", "/", site)}">Apps</a>' + f'<a href="{site_link("code", "/ci/", site)}">CI: what is building</a></div>' + f'<div><h2>About this site</h2><a href="{site_link("code", "/why/", site)}">Why it exists</a>' + f'<a href="{site_link("code", "/principles/", site)}">Principles</a>' + f'<a href="{site_link("code", "/contribute/", site)}">Contribute and report bugs</a></div>' + f'<div><h2>Built with</h2><a href="{site_link("code", "/fuzzyworld-tools/", site)}">fuzzyworld-tools</a>' + '<span>Static files only: no accounts, no sign-in, no rate limits. MIT-licensed.</span></div>' + '</div></footer>') nav = "" if view: nav = (f'<nav class="repo-nav"><a href="{url(view.name)}/">Summary</a>' @@ -173,13 +194,13 @@ def page(title, body, crumbs=None, view=None, site="code", refresh=None): <link rel="stylesheet" href="/style.css"> </head> <body> -<header class="site"><a class="home" href="/">{host}</a></header> +{header} <main> {nav} {crumb_html} {body} </main> -<footer class="site">{footer}</footer> +{footer} </body> </html> """ @@ -428,7 +449,7 @@ def build_ci_dashboard(): + (f'<h2>Running</h2><table class="log">{run_rows}</table>' if run_rows else "") + (f'<h2>Queued</h2><table class="log">{queue_rows}</table>' if queue_rows else "") + (f'<h2>Recent</h2><table class="log">{done_rows}</table>' if done_rows else "<p>No pipelines yet.</p>")) - write(os.path.join(SITE, "ci", "index.html"), page("CI", body, refresh=CI_REFRESH if busy else None)) + write(os.path.join(SITE, "ci", "index.html"), page("CI", body, refresh=CI_REFRESH if busy else None, section="ci")) def ci_status_text(): @@ -513,19 +534,29 @@ CONTENT = os.environ.get("FW_CONTENT", os.path.join(os.path.dirname(os.path.absp def build_content(): - """Site pages written in Markdown: content/<name>.md becomes <site>/<name>/index.html. - A `title: ...` line at the top of the file is the page title (Python-Markdown's meta extension).""" + """Site pages: content/<name>.md (Markdown) or content/<name>.html (a fragment) becomes + <site>/<name>/index.html. The title is a `title: ...` line at the top of a Markdown file + (Python-Markdown's meta extension) or a leading `<!-- title: ... -->` in an HTML one. + Files starting with "_" are drafts and are skipped. Also writes 404.html.""" if not markdown: raise SystemExit("gen.py content needs Python-Markdown in " + PYLIB) for name in sorted(os.listdir(CONTENT)): stem, ext = os.path.splitext(name) - if ext != ".md": + if ext not in (".md", ".html") or stem.startswith("_"): continue with open(os.path.join(CONTENT, name), encoding="utf-8") as f: + text = f.read() + if ext == ".md": md = markdown.Markdown(extensions=["meta", "tables", "fenced_code", "sane_lists"], output_format="html") - body = md.convert(f.read()) - title = " ".join(md.Meta.get("title", [stem])) - write(os.path.join(SITE, stem, "index.html"), page(title, body)) + body = md.convert(text) + title = " ".join(md.Meta.get("title", [stem])) + else: + m = re.match(r"<!-- title: (.*?) -->\n", text) + title, body = (m.group(1), text[m.end():]) if m else (stem, text) + write(os.path.join(SITE, stem, "index.html"), page(title, body, section=stem)) + write(os.path.join(SITE, "404.html"), page("Not found", ( + "<h1>Not found</h1><p>There's nothing at this address. Private repositories aren't listed here, " + 'and only the default branch can be browsed.</p><p><a href="/">All repositories</a></p>'), section="-")) # ---------------------------------------------------------------- highlighting @@ -1153,7 +1184,7 @@ def build_index(): app = f' · <a href="{esc(a)}">app</a>' if a else "" desc = f'<p class="desc">{esc(d)}</p>' if d else "" items.append(f'<li><a class="name" href="{url(n)}/">{esc(n)}</a>' - f'<span class="meta"> · updated {esc(last or "never (empty)")}{app}</span>{desc}</li>') + f'<span class="meta">updated {esc(last or "never (empty)")}{app}</span>{desc}</li>') body = (f"<h1>Pavel's code</h1>" f"<p>Browse, or clone with <code>git clone {BASE_URL}/<repo>.git</code>. No account or sign-in needed. " f'Small web apps are at <a href="{APPS_URL}/">apps.fuzzyworld.net</a>. ' @@ -1163,51 +1194,6 @@ def build_index(): print(f"index: {len(rows)} public repos") -def app_history(apps): - """Deploys seen per app, newest first, in ~/fw/state/apps.json. - - .fuzzyworld.json holds only the latest deploy, so the previous one (needed to say - what changed) is remembered here each time the index is rebuilt. - """ - path = os.path.join(STATE, "apps.json") - try: - with open(path) as f: - hist = json.load(f) - except (FileNotFoundError, ValueError): - hist = {} - for a in apps: - deploys = hist.setdefault(a["app"], []) - if not deploys or deploys[0]["deployed"] != a["deployed"]: - deploys.insert(0, {"commit": a["commit"], "deployed": a["deployed"]}) - del deploys[APP_HISTORY:] - write(path, json.dumps(hist)) - return hist - - -def app_changes(repo, name, deploys): - """Commits in the latest deploy since the previous one with a different commit - (or the last few, the first time an app is seen), as list items.""" - cur = deploys[0]["commit"] - if not git_text(repo, "rev-parse", "-q", "--verify", cur + "^{commit}", check=False).strip(): - return "" # e.g. `fw deploy --here` from unpushed work - prev = next((d["commit"] for d in deploys[1:] if d["commit"] != cur), None) - if prev and git_text(repo, "rev-parse", "-q", "--verify", prev + "^{commit}", check=False).strip(): - rng, limit = f"{prev}..{cur}", APP_CHANGES + 1 - else: - rng, limit = cur, APP_FIRST_CHANGES - lines = git_text(repo, "log", "--format=%H%x09%s", "-n", str(limit), rng, check=False).splitlines() - items = [] - for line in lines[:APP_CHANGES]: - sha, _, subject = line.partition("\t") - page_ = os.path.join(SITE, name, "commit", sha + ".html") - subj = (f'<a href="{BASE_URL}{url(name, "commit", sha)}.html">{esc(subject)}</a>' - if os.path.exists(page_) else esc(subject)) - items.append(f"<li>{subj}</li>") - if len(lines) > APP_CHANGES: - items.append(f'<li><a href="{BASE_URL}{url(name, "log")}/">and more</a></li>') - return f'<ul class="changes">{"".join(items)}</ul>' if items else "" - - def build_apps_index(): """apps.fuzzyworld.net/index.html from each deployed app's .fuzzyworld.json, newest deploy first.""" apps = [] @@ -1223,7 +1209,6 @@ def build_apps_index(): apps.append({"app": entry, "href": f"/{entry}/", "title": meta.get("title") or entry, "description": meta.get("description", ""), "repo": meta.get("repo", entry), "commit": meta.get("commit", ""), "deployed": meta.get("deployed", "")}) - hist = app_history(apps) for a in apps: try: a["time"] = datetime.datetime.fromisoformat(a["deployed"].replace("Z", "+00:00")).timestamp() @@ -1243,17 +1228,15 @@ def build_apps_index(): for a in apps: repo = os.path.join(GIT_ROOT, a.get("repo", "") + ".git") public = a.get("repo") and os.path.isdir(repo) and is_public(repo) - src = f' · <a href="{BASE_URL}/{urllib.parse.quote(a["repo"])}/">source</a>' if public else "" - when = (" · updated " + time.strftime("%Y-%m-%d", time.gmtime(a["time"]))) if a["time"] else "" + src = f'{" · " if a["time"] else ""}<a href="{BASE_URL}/{urllib.parse.quote(a["repo"])}/">source</a>' if public else "" + when = ("updated " + time.strftime("%Y-%m-%d", time.gmtime(a["time"]))) if a["time"] else "" desc = f'<p class="desc">{esc(a["description"])}</p>' if a.get("description") else "" - # Commit messages only for public repos: a private repo's history stays private. - changes = app_changes(repo, a["repo"], hist[a["app"]]) if public and a.get("app") in hist else "" shots = app_shots(a["app"], a["repo"] if public else None) if a.get("app") else [] thumb = (f'<a class="thumb" href="{esc(a["href"])}"><img src="{esc(shots[0][0])}" alt="" loading="lazy"></a>' if shots else "") cls = ' class="has-shot"' if shots else "" items.append(f'<li{cls}>{thumb}<div><a class="name" href="{esc(a["href"])}">{esc(a["title"])}</a>' - f'<span class="meta">{when}{src}</span>{desc}{changes}</div></li>') + f'<span class="meta">{when}{src}</span>{desc}</div></li>') body = (f"<h1>Pavel's apps</h1>" f'<p>Small web apps and games. No accounts, no sign-in. Source code is at ' f'<a href="{BASE_URL}/">code.fuzzyworld.net</a>.</p>' diff --git a/server/install.sh b/server/install.sh index be55591..57fb2e2 100755 --- a/server/install.sh +++ b/server/install.sh @@ -25,9 +25,7 @@ chmod +x ~/fw/tools/server/runner-api ~/fw/tools/server/mirror-only ln -sfn ~/fw/tools/server/fw ~/bin/fw site=~/code.fuzzyworld.net cp ~/fw/tools/server/site/htaccess "$site/.htaccess" -cp ~/fw/tools/server/site/style.css ~/fw/tools/server/site/robots.txt ~/fw/tools/server/site/404.html "$site/" -mkdir -p "$site/contribute" && cp ~/fw/tools/server/site/contribute/index.html "$site/contribute/" -mkdir -p "$site/principles" && cp ~/fw/tools/server/site/principles/index.html "$site/principles/" +cp ~/fw/tools/server/site/style.css ~/fw/tools/server/site/robots.txt "$site/" apps=~/apps.fuzzyworld.net cp ~/fw/tools/server/site-apps/htaccess "$apps/.htaccess" cp ~/fw/tools/server/site/style.css "$apps/" diff --git a/server/site/404.html b/server/site/404.html deleted file mode 100644 index 610f289..0000000 --- a/server/site/404.html +++ /dev/null @@ -1,17 +0,0 @@ -<!doctype html> -<html lang="en"> -<head> -<meta charset="utf-8"> -<meta name="viewport" content="width=device-width, initial-scale=1"> -<title>Not found · code.fuzzyworld.net</title> -<link rel="stylesheet" href="/style.css"> -</head> -<body> -<header class="site"><a class="home" href="/">code.fuzzyworld.net</a></header> -<main> -<h1>Not found</h1> -<p>There's nothing at this address. Private repositories aren't listed here, and only the default branch can be browsed.</p> -<p><a href="/">All repositories</a></p> -</main> -</body> -</html> diff --git a/server/site/contribute/index.html b/server/site/contribute/index.html deleted file mode 100644 index cecb6d2..0000000 --- a/server/site/contribute/index.html +++ /dev/null @@ -1,52 +0,0 @@ -<!doctype html> -<html lang="en"> - -<head> - <meta charset="utf-8"> - <meta name="viewport" content="width=device-width, initial-scale=1"> - <title>How to contribute · code.fuzzyworld.net</title> - <link rel="stylesheet" href="/style.css"> -</head> - -<body> - <header class="site"><a class="home" href="/">code.fuzzyworld.net</a></header> - <main> - <h1>How to contribute</h1> - <p>Thanks for wanting to help! This site has no accounts, issue tracker or merge requests, so contributions - arrive by email. Two ways work, both with plain git.</p> - - <h2>1. Share a branch</h2> - <p>Best for anything bigger than a small fix.</p> - <ol> - <li>Clone the repository: <code>git clone https://code.fuzzyworld.net/<repo>.git</code></li> - <li>Make your change on a branch, and push that branch to <strong>any</strong> public git host you like - (GitHub, Codeberg, GitLab, your own server).</li> - <li>Email the repository URL, the branch name and a short description to - <code>contrib-68uq+<repo>@fuzzyworld.net</code>, using the repository's name in place of - <code><repo></code>.</li> - </ol> - <p>I fetch the branch, review it locally, and reply by email.</p> - - <h2>2. Email a patch</h2> - <p>Good for small changes. Commit your change, then create a patch file:</p> - <pre>git format-patch origin/main</pre> - <p>and attach the <code>.patch</code> file(s) to an email to <code>contrib-68uq+<repo>@fuzzyworld.net</code>. - If you use <code>git send-email</code>, that address works there too.</p> - - <h2>Bug reports and ideas</h2> - <p>No git needed: email <code>bugs-k7q4+<repo>@fuzzyworld.net</code>. Many of the apps have a - <strong>report</strong> link that fills in the details for you.</p> - - <h2>Before you send</h2> - <ul> - <li>Check the repository's README (and CONTRIBUTING, if it has one) for how to build and test it.</li> - <li>One change per branch or patch, please; it makes review much quicker.</li> - <li>By contributing you agree that your change is shared under the repository's license.</li> - </ul> - - <p><a href="/why/">Why there are no merge requests here</a></p> - </main> - <footer class="site"><a href="/">All repositories</a> · <a href="https://apps.fuzzyworld.net/">Apps</a> · <a href="/principles/">Principles</a></footer> -</body> - -</html> diff --git a/server/site/how/index.html b/server/site/how/index.html deleted file mode 100644 index 357384f..0000000 --- a/server/site/how/index.html +++ /dev/null @@ -1,105 +0,0 @@ -<!doctype html> -<html lang="en"> - -<head> - <meta charset="utf-8"> - <meta name="viewport" content="width=device-width, initial-scale=1"> - <title>How I moved off GitLab · code.fuzzyworld.net</title> - <link rel="stylesheet" href="/style.css"> -</head> - -<body> - <header class="site"><a class="home" href="/">code.fuzzyworld.net</a></header> - <main> - <!-- DRAFT 2026-10-27 for code.fuzzyworld.net/how/ (plan Backlog item 3). Pavel edits before it goes live; on hold until the item 2 features. Not installed or linked. --> - <h1>How I moved off GitLab</h1> - <p><a href="/why/">Why</a> I left is short. <em>How</em> took about a day, 89 repositories and 35 web - apps, and turned up enough surprises that it seemed worth writing down for anyone thinking about the same - move.</p> - - <h2>What I ended up with</h2> - <ul> - <li><strong>Bare git repos on cheap shared hosting</strong> (DreamHost). I push over SSH; nothing else - runs there permanently.</li> - <li><strong>code.fuzzyworld.net</strong>: static browse pages, generated by a git hook when I push, plus - read-only <code>git clone https://…</code>. No app server, no accounts, no rate limits beyond what - Apache can serve.</li> - <li><strong>apps.fuzzyworld.net</strong>: every former GitLab Pages site, at the same relative path, built - and uploaded from my laptop with one command.</li> - <li><strong>A backup mirror at home</strong> that can read, but not write.</li> - </ul> - <p>What I gave up: issues, merge requests and search. Only the default branch can be browsed. Cloning over - plain HTTP is slower for big repos (a 244 MB repo takes about 50 seconds).</p> - - <h2>Getting everything out of GitLab</h2> - <p>The code is the easy part: <code>git clone --mirror</code> gets every branch and tag. The rest lives in - GitLab's settings and is easy to forget:</p> - <ul> - <li><strong>CI/CD variables.</strong> Signing keys, API tokens, deploy keys. Export them before anything - else; losing an Android signing key means you can't update that app in the Play Store again.</li> - <li><strong>Release files.</strong> APKs and tarballs attached to releases are often CI <em>job - artifacts</em>, and those expire. Five of mine already had.</li> - <li><strong>Pipeline schedules</strong>, issues, merge requests and wiki pages. Confidential issues in a - public project need separate handling, or the export makes them public.</li> - <li><strong>Merge-request commits</strong> live under <code>refs/merge-requests/*</code>, which a normal - clone doesn't fetch. I kept them as <code>refs/gitlab/merge-requests/*</code>.</li> - </ul> - - <h2>Traps I fell into</h2> - <ul> - <li><strong>The rate limit bit me during the export.</strong> The <code>glab</code> CLI's login is an - OAuth token, which has to be sent as <code>Authorization: Bearer</code>. Sent as - <code>PRIVATE-TOKEN</code>, every call counted as anonymous, and my home IP was blocked within - minutes. Web URLs for job artifacts and uploads count as anonymous even with a token; use the API - endpoints instead.</li> - <li><strong>My own git hook refused my own history.</strong> A global pre-push hook that rejects - lightweight tags (good for new releases) blocked mirroring old ones. Historical tags have to move - unchanged, so the migration pushes skip the hook.</li> - <li><strong>Shared hosting kills big git operations.</strong> Cloning a 284 MB repo over SSH failed with - "git-pack-objects died": the host's watchdog killed git's packing step. Limiting git's threads and - memory (<code>pack.threads 1</code>, <code>pack.windowMemory 64m</code>) fixed it for good.</li> - <li><strong>The host executes things you might not expect.</strong> DreamHost runs <code>.php</code> files - and server-side includes by default. A code browser that wrote repository files into the web root - with their real names would have run them. Browse pages are pre-generated HTML, raw copies are - images only, and every response says <code>script-src 'none'</code>.</li> - <li><strong>Node on a hardened kernel.</strong> The host's kernel refuses executable memory, so modern - Node crashes as soon as its JIT starts. <code>--jitless</code> works; native npm modules don't build, - so installs use <code>--ignore-scripts</code>.</li> - </ul> - - <h2>Moving the apps</h2> - <p>Every app moved to the same path under a new domain, and they all share one origin, exactly as they did on - <code>*.gitlab.io</code>. That means two apps can still clash over <code>localStorage</code> keys, a - service worker can claim more than its own folder, and two installed web apps can merge on Android if - their manifests look alike. So the deploy script refuses to upload anything whose URLs, manifest or - service worker reach outside the app's own path.</p> - <p>Small things broke in surprising places: GitLab-style links (<code>/-/blob/main/…</code>, - <code>/-/commit/…</code>) baked into apps, short commit IDs where the new site expected long ones, a - vite config whose root was a subfolder, Google sign-in that only works on domains Firebase knows about, - and OAuth redirect URIs registered with the old address.</p> - - <h2>Saying goodbye without breaking links</h2> - <p>Each GitLab project got one last commit: a README pointing to the new home, and, for sites, a redirect - page in place of the app. The redirect keeps the path, query and <code>#hash</code>, so deep links land on - the matching new page. It also retires any old service worker, which would otherwise keep serving the - cached old app to returning visitors forever.</p> - - <h2>Would I do it again?</h2> - <p>Yes. Everything is static, it costs nothing extra on hosting I already paid for, and nobody needs an - account to read my code. The one real loss is easy contributions: there are no merge requests here - yet.</p> - - <h2>Principles</h2> - <p>Things made with AI should be shared freely with the world. The code and apps here were made with - AI, so they are MIT-licensed: use them, change them, share them.</p> - <p>My conversations with the AI are different. My words and my way of working are mine. I publish a - conversation only when I choose to, I can take that permission back, and nobody may copy my words or - my style of work without my express permission.</p> - <p>More on the <a href="/principles/">principles</a> page.</p> - - <p><a href="/why/">← Why I left GitLab</a></p> - </main> - <footer class="site"><a href="/">All repositories</a> · <a href="https://apps.fuzzyworld.net/">Apps</a> · <a href="/principles/">Principles</a></footer> -</body> - -</html> diff --git a/server/site/principles/index.html b/server/site/principles/index.html deleted file mode 100644 index bd2e956..0000000 --- a/server/site/principles/index.html +++ /dev/null @@ -1,30 +0,0 @@ -<!doctype html> -<html lang="en"> - -<head> - <meta charset="utf-8"> - <meta name="viewport" content="width=device-width, initial-scale=1"> - <title>Principles · code.fuzzyworld.net</title> - <link rel="stylesheet" href="/style.css"> -</head> - -<body> - <header class="site"><a class="home" href="/">code.fuzzyworld.net</a></header> - <main> - <h1>Principles</h1> - <h2>What's made with AI is shared</h2> - <p>Things made with AI should be shared freely with the world. The code and apps here were made with - AI, so they are MIT-licensed: use them, change them, share them.</p> - <p>My conversations with the AI are different. My words and my way of working are mine. I publish a - conversation only when I choose to, I can take that permission back, and nobody may copy my words or - my style of work without my express permission.</p> - <p class="meta">Copyright protects my words. It generally doesn't protect a style or a way of working, so - the last part is a statement of my terms and wishes, not something a license can enforce.</p> - <h2>No sign-in, no limits</h2> - <p>Everything here is static files: no accounts, no rate limits, nothing that runs code from a - repository. See <a href="/why/">why this site exists</a>.</p> - </main> - <footer class="site"><a href="/">All repositories</a> · <a href="https://apps.fuzzyworld.net/">Apps</a> · <a href="/why/">Why this site exists</a></footer> -</body> - -</html> diff --git a/server/site/style.css b/server/site/style.css index cb08d1e..cff2bec 100644 --- a/server/site/style.css +++ b/server/site/style.css @@ -1,10 +1,12 @@ :root { - --bg: #ffffff; --fg: #1d1d1f; --muted: #6b6b70; --line: #e4e4e8; --soft: #f5f5f7; + --bg: #ffffff; --fg: #1d1d1f; --muted: #62626a; --line: #e1e1e6; --soft: #f6f6f8; --card: #ffffff; --accent-soft: #e8f0fe; + --radius: 8px; --shadow: 0 1px 2px rgb(0 0 0 / 6%); --link: #0a58ca; --add: #e6f4ea; --add-fg: #116329; --del: #fdecea; --del-fg: #a40e26; --hunk: #6f42c1; } @media (prefers-color-scheme: dark) { :root { - --bg: #111113; --fg: #e8e8ea; --muted: #9a9aa2; --line: #2a2a2f; --soft: #1a1a1e; + --bg: #111113; --fg: #e8e8ea; --muted: #9a9aa2; --line: #2a2a2f; --soft: #1a1a1e; --card: #17171a; --accent-soft: #16243a; + --shadow: none; --link: #79b8ff; --add: #12261a; --add-fg: #7ee2a8; --del: #2d1517; --del-fg: #ff9492; --hunk: #c5a5ff; } } @@ -15,21 +17,37 @@ body { margin: 0; background: var(--bg); color: var(--fg); font: 16px/1.55 syste a { color: var(--link); text-decoration: none; } a:hover { text-decoration: underline; } main { max-width: 72rem; margin: 0 auto; padding: 0 16px 3rem; } -header.site, footer.site { max-width: 72rem; margin: 0 auto; padding: 1rem 16px; color: var(--muted); } -header.site .home { font-weight: 600; color: var(--fg); } -footer.site { border-top: 1px solid var(--line); font-size: 0.9rem; } -h1 { font-size: 1.6rem; margin: 1rem 0 0.5rem; overflow-wrap: anywhere; } -h2 { font-size: 1.2rem; margin: 1.5rem 0 0.5rem; } +main > p, main > ul:not(.repos), main > ol { max-width: 46rem; } +:focus-visible { outline: 2px solid var(--link); outline-offset: 2px; border-radius: 3px; } +/* Site header and footer: the same links on every page of both sites (gen.py NAV). */ +header.site { background: var(--soft); border-bottom: 1px solid var(--line); } +header.site .bar, footer.site .cols { max-width: 72rem; margin: 0 auto; padding: 0 16px; } +header.site .bar { display: flex; flex-wrap: wrap; align-items: center; gap: 0 1.5rem; } +header.site .brand { font-weight: 700; font-size: 1.1rem; color: var(--fg); padding: 0.75rem 0; letter-spacing: -0.01em; } +header.site .brand:hover { text-decoration: none; color: var(--link); } +.site-nav { display: flex; flex-wrap: wrap; gap: 0 1.25rem; } +.site-nav a { color: var(--muted); padding: 0.75rem 0 calc(0.75rem - 2px); border-bottom: 2px solid transparent; } +.site-nav a:hover { color: var(--fg); text-decoration: none; } +.site-nav a[aria-current] { color: var(--fg); font-weight: 600; border-bottom-color: var(--link); } +footer.site { margin-top: 2rem; border-top: 1px solid var(--line); background: var(--soft); color: var(--muted); font-size: 0.9rem; } +footer.site .cols { display: grid; grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr)); gap: 1.5rem; padding-top: 1.5rem; padding-bottom: 2rem; } +footer.site h2 { font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted); margin: 0 0 0.4rem; } +footer.site a, footer.site span { display: block; padding: 0.15rem 0; } +h1 { font-size: 1.75rem; line-height: 1.25; margin: 1.5rem 0 0.5rem; overflow-wrap: anywhere; letter-spacing: -0.01em; } +h2 { font-size: 1.2rem; margin: 1.75rem 0 0.5rem; } code, pre, .source, .diff { font: 13px/1.5 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; tab-size: 4; } -pre { background: var(--soft); border: 1px solid var(--line); border-radius: 6px; padding: 0.75rem; overflow-x: auto; } -.repo-nav { display: flex; flex-wrap: wrap; gap: 0.25rem 1rem; padding: 0.5rem 0; border-bottom: 1px solid var(--line); } +pre { background: var(--soft); border: 1px solid var(--line); border-radius: var(--radius); padding: 0.75rem; overflow-x: auto; } +.repo-nav { display: flex; flex-wrap: wrap; gap: 0 1.25rem; margin-top: 1rem; border-bottom: 1px solid var(--line); } +.repo-nav a { color: var(--muted); padding: 0.5rem 0 calc(0.5rem - 2px); border-bottom: 2px solid transparent; } +.repo-nav a:hover { color: var(--fg); text-decoration: none; border-bottom-color: var(--line); } .crumbs { color: var(--muted); overflow-wrap: anywhere; } .meta, .desc { color: var(--muted); overflow-wrap: anywhere; } pre.message { white-space: pre-wrap; overflow-wrap: anywhere; } .clone { user-select: all; } table { border-collapse: collapse; width: 100%; } td, th { padding: 0.3rem 0.5rem; border-bottom: 1px solid var(--line); text-align: left; vertical-align: top; } -th { color: var(--muted); font-weight: 500; } +th { color: var(--muted); font-weight: 500; font-size: 0.9rem; } +table:not(.source) tr:hover > td { background: var(--soft); } td.num, td.date { white-space: nowrap; color: var(--muted); } td.date { width: 7rem; } td.author { color: var(--muted); white-space: nowrap; } @@ -47,13 +65,12 @@ td.author { color: var(--muted); white-space: nowrap; } .markdown img, img.blob { max-width: 100%; height: auto; } .markdown table { display: block; overflow-x: auto; } .readme { border-top: 1px solid var(--line); margin-top: 1.5rem; } -ul.repos { list-style: none; padding: 0; margin: 1rem 0; } -ul.repos li { padding: 0.6rem 0; border-bottom: 1px solid var(--line); } -ul.repos .name { font-weight: 600; overflow-wrap: anywhere; } -ul.repos .meta { font-size: 0.9rem; white-space: nowrap; } -ul.repos .desc { margin: 0.2rem 0 0; } -ul.repos ul.changes { margin: 0.3rem 0 0; padding-left: 1.2rem; font-size: 0.9rem; color: var(--muted); } -ul.repos ul.changes li { padding: 0; border: 0; overflow-wrap: anywhere; } +ul.repos { list-style: none; padding: 0; margin: 1.25rem 0; display: grid; gap: 0.75rem; grid-template-columns: repeat(auto-fill, minmax(min(100%, 21rem), 1fr)); } +ul.repos li { padding: 0.9rem 1rem; background: var(--card); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow); } +ul.repos li:hover { border-color: var(--link); } +ul.repos .name { font-weight: 600; font-size: 1.05rem; overflow-wrap: anywhere; } +ul.repos .meta { display: block; font-size: 0.85rem; } +ul.repos .desc { margin: 0.35rem 0 0; font-size: 0.95rem; } details summary { cursor: pointer; color: var(--muted); margin: 1rem 0 0.5rem; } .branch-note { background: var(--soft); border: 1px solid var(--line); border-radius: 6px; padding: 0.4rem 0.75rem; overflow-wrap: anywhere; } /* Blame: who last changed each run of lines. */ @@ -77,7 +94,7 @@ pre.ci-log { max-height: 40rem; overflow: auto; white-space: pre-wrap; overflow- td.author, .log td.date { display: none; } } /* Screenshots: thumbnails on the apps index, a row on a repo's summary page. */ -ul.repos li.has-shot { display: grid; grid-template-columns: 4.5rem 1fr; gap: 0.75rem; align-items: start; } -ul.repos .thumb img { display: block; width: 4.5rem; height: 6rem; object-fit: contain; object-position: left top; border: 1px solid var(--line); border-radius: 6px; } +ul.repos li.has-shot { display: grid; grid-template-columns: 5.5rem 1fr; gap: 0.9rem; align-items: start; } +ul.repos .thumb img { display: block; width: 5.5rem; height: 7.5rem; object-fit: contain; object-position: left top; border: 1px solid var(--line); border-radius: 6px; background: var(--soft); } .shots { display: flex; gap: 0.75rem; overflow-x: auto; } -.shots img { display: block; height: 16rem; width: auto; border: 1px solid var(--line); border-radius: 6px; } +.shots img { display: block; height: 16rem; width: auto; border: 1px solid var(--line); border-radius: var(--radius); }