Skip to content
This repository was archived by the owner on Oct 18, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 7 additions & 4 deletions .github/workflows/site.yml
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
name: Site

# Checks the head of every page in site/: canonical, og:url, lang, description,
# robots, JSON-LD, hreflang, and that sitemap.xml lists every page with a lastmod.
# Grove is now part of Brow, so every page in site/, 404.html included, must
# send its visitor to the same path under efimov.page/brow/: a 0-second refresh,
# a canonical and a location.replace (keeping the #fragment) to that target, a
# link to it on the page, lang, no robots directive against it, no hreflang, and
# no sitemap entry. Scripts/check_seo.py still checks a real page in full.
on:
push:
branches: [main]
Expand Down Expand Up @@ -34,5 +37,5 @@ jobs:
with:
persist-credentials: false

- name: Check tags, JSON-LD and the sitemap
run: python3 Scripts/check_seo.py site https://efimov.page/grove/
- name: Check that every page leads to its Brow counterpart
run: python3 Scripts/check_seo.py site https://efimov.page/grove/ https://efimov.page/brow/
5 changes: 1 addition & 4 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,12 +1,10 @@
# Changelog

All notable changes to this project will be documented in this file.
Grove continues as Workspaces in [Brow 0.4.0](https://github.com/efim0v/brow/releases/tag/0.4.0), which carries Grove's code and its history. Its changes from there on are in [Brow's changelog](https://github.com/efim0v/brow/blob/main/CHANGELOG.md), and [Coming from Grove](https://github.com/efim0v/brow#coming-from-grove) says what changes for a Grove setup. 0.2.2 is Grove's last release as an app of its own.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

## [0.2.2] - 2026-10-14

The first prebuilt Grove since 0.2.0. It also contains everything listed under 0.2.1.
Expand Down Expand Up @@ -65,7 +63,6 @@ First public release: git-worktree workspaces for running parallel Claude Code s
- The `grove` command-line tool: `scan`, `workspaces`, `sessions`, `create`, `graph` and `doctor`, each with `--json`.
- Prebuilt downloads for macOS 26 or later on Apple silicon: `Grove.zip` with the app and `grove-cli.zip` with the command-line tool, plus `SHA256SUMS.txt` to verify them. The app is signed ad hoc and not notarized.

[Unreleased]: https://github.com/efim0v/grove/compare/0.2.2...HEAD
[0.2.2]: https://github.com/efim0v/grove/compare/0.2.1...0.2.2
[0.2.1]: https://github.com/efim0v/grove/compare/0.2.0...0.2.1
[0.2.0]: https://github.com/efim0v/grove/releases/tag/0.2.0
249 changes: 11 additions & 238 deletions README.md

Large diffs are not rendered by default.

249 changes: 11 additions & 238 deletions README.ru.md

Large diffs are not rendered by default.

249 changes: 11 additions & 238 deletions README.zh-CN.md

Large diffs are not rendered by default.

99 changes: 86 additions & 13 deletions Scripts/check_seo.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,26 @@
#!/usr/bin/env python3
"""Check the SEO head of every page in site/ (stdlib only).

Usage: python3 check_seo.py <site-dir> <base-url>
Example: python3 check_seo.py site https://efimov.page/brow/
Usage: python3 check_seo.py <site-dir> <base-url> [<moved-to-url>]
Example: python3 check_seo.py site https://efimov.page/grove/ https://efimov.page/brow/

A page with <meta http-equiv="refresh"> is a redirect. It needs a lang, a refresh to
its target in 0 seconds, a canonical naming the same target, a script that replaces
the location with it and keeps the hash, a link to it on the page, no hreflang, and no
robots directive that would keep the redirect from counting; the sitemap must not list
it. With <moved-to-url>, every page and 404.html must be such a redirect, each to the
same path under <moved-to-url> (404.html to <moved-to-url> itself).
"""
import json
import pathlib
import re
import sys
import xml.etree.ElementTree as ET
from html.parser import HTMLParser
from urllib.parse import urlparse

REFRESH = re.compile(r"^\s*0\s*;\s*url=(?P<url>\S+)\s*$", re.IGNORECASE)
REPLACE = re.compile(r"""location\.replace\(\s*(["'])(?P<url>[^"']+)\1\s*\+\s*location\.hash\s*\)""")


class Head(HTMLParser):
Expand All @@ -18,8 +30,11 @@ def __init__(self):
self.canonical = None
self.alt = {}
self.meta = {}
self.refresh = None
self.ld = []
self._in_ld = False
self.scripts = []
self.links = []
self._script = None

def handle_starttag(self, tag, attrs):
a = dict(attrs)
Expand All @@ -29,33 +44,87 @@ def handle_starttag(self, tag, attrs):
self.canonical = a.get("href")
elif tag == "link" and a.get("rel") == "alternate" and a.get("hreflang"):
self.alt[a["hreflang"]] = a.get("href")
elif tag == "meta" and (a.get("http-equiv") or "").lower() == "refresh":
self.refresh = a.get("content") or ""
elif tag == "meta":
self.meta[a.get("name") or a.get("property")] = a.get("content")
elif tag == "script" and a.get("type") == "application/ld+json":
self._in_ld = True
self._script = self.ld
self.ld.append("")
elif tag == "script" and "src" not in a:
self._script = self.scripts
self.scripts.append("")
elif tag == "a" and a.get("href"):
self.links.append(a["href"])

def handle_endtag(self, tag):
if tag == "script":
self._in_ld = False
self._script = None

def handle_data(self, data):
if self._in_ld:
self.ld[-1] += data
if self._script is not None:
self._script[-1] += data


def page_url(base, site, index_file):
rel = index_file.parent.relative_to(site).as_posix()
return base if rel == "." else f"{base}{rel}/"


def check_redirect(f, h, own_url, expected):
errors = []
match = REFRESH.match(h.refresh)
target = match.group("url") if match else None
if not target:
errors.append(f"{f}: refresh {h.refresh!r} is not '0; url=<target>'")
return errors
if urlparse(target).scheme != "https":
errors.append(f"{f}: redirect target {target!r} is not an absolute https URL")
if target == own_url:
errors.append(f"{f}: redirects to itself")
if expected and target != expected:
errors.append(f"{f}: redirects to {target!r}, not {expected!r}")
if h.canonical != target:
errors.append(f"{f}: canonical {h.canonical!r} != redirect target {target!r}")
replaced = [m.group("url") for script in h.scripts for m in REPLACE.finditer(script)]
if replaced != [target]:
errors.append(f"{f}: needs one location.replace({target!r} + location.hash), has {replaced!r}")
if target not in h.links:
errors.append(f"{f}: no link to {target!r} on the page")
if not h.lang:
errors.append(f"{f}: <html lang> missing")
robots = (h.meta.get("robots") or "").lower()
if any(word in robots for word in ("noindex", "nofollow", "none")):
errors.append(f"{f}: robots meta {robots!r} works against the redirect")
if h.alt:
errors.append(f"{f}: hreflang on a redirect")
return errors


def main():
if len(sys.argv) not in (3, 4):
print(__doc__.strip(), file=sys.stderr)
return 2
site, base = pathlib.Path(sys.argv[1]), sys.argv[2]
errors, pages = [], {}
for f in sorted(site.glob("**/index.html")):
url = page_url(base, site, f)
moved_to = sys.argv[3] if len(sys.argv) == 4 else None
errors, pages, redirects = [], {}, {}
not_found = site / "404.html"
files = sorted(site.glob("**/index.html")) + ([not_found] if not_found.exists() else [])
for f in files:
url = None if f == not_found else page_url(base, site, f)
h = Head()
h.feed(f.read_text(encoding="utf-8"))
if h.refresh is not None:
expected = None
if moved_to:
expected = moved_to if url is None else moved_to + url[len(base):]
errors += check_redirect(f, h, url, expected)
redirects[url or f.name] = h
continue
if moved_to:
errors.append(f"{f}: not a redirect to {moved_to}")
if url is None:
continue
pages[url] = h
if h.canonical != url:
errors.append(f"{f}: canonical {h.canonical!r} != {url!r}")
Expand All @@ -82,7 +151,9 @@ def main():
for lang, other in h.alt.items():
if lang == "x-default":
continue
if other not in pages:
if other in redirects:
errors.append(f"{url}: hreflang {lang} -> {other} is a redirect")
elif other not in pages:
errors.append(f"{url}: hreflang {lang} -> {other} is not a page in site/")
elif pages[other].alt != h.alt:
errors.append(f"{url}: hreflang set differs from the one on {other}")
Expand All @@ -98,9 +169,11 @@ def main():
elif not locs[url]:
errors.append(f"sitemap.xml: {url} has no lastmod")
for loc in locs:
if loc not in pages:
if loc in redirects:
errors.append(f"sitemap.xml: {loc} is a redirect, not a page")
elif loc not in pages:
errors.append(f"sitemap.xml: {loc} is not a page in site/")
print("\n".join(errors) or f"OK: {len(pages)} page(s)")
print("\n".join(errors) or f"OK: {len(pages)} page(s), {len(redirects)} redirect(s)")
return 1 if errors else 0


Expand Down
42 changes: 8 additions & 34 deletions site/404.html
Original file line number Diff line number Diff line change
Expand Up @@ -3,46 +3,20 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Page not found – Grove</title>
<title>Page not found – Grove is now part of Brow</title>
<!-- Grove is now part of Brow. The script goes first and keeps the #fragment; the refresh is for a browser without JavaScript. -->
<link rel="canonical" href="https://efimov.page/brow/">
<meta http-equiv="refresh" content="0; url=https://efimov.page/brow/">
<script>location.replace("https://efimov.page/brow/" + location.hash);</script>
<meta name="color-scheme" content="light dark">
<meta name="theme-color" content="#faf9f5" media="(prefers-color-scheme: light)">
<meta name="theme-color" content="#141514" media="(prefers-color-scheme: dark)">
<link rel="icon" type="image/png" sizes="64x64" href="/grove/favicon.png">
<!-- GitHub Pages serves this page at any depth, so every path is absolute. -->
<style>
:root {
--paper: #faf9f5;
--ink: #1b1d1a;
--forest: #2d6a40;
--serif: Charter, "Iowan Old Style", Georgia, serif;
--sans: system-ui, -apple-system, BlinkMacSystemFont, "Helvetica Neue", Arial, sans-serif;
}
@media (prefers-color-scheme: dark) {
:root { --paper: #141514; --ink: #e7e8e3; --forest: #8fc79e; }
}
body {
margin: 0;
background: var(--paper);
color: var(--ink);
font: 400 1.0625rem/1.6 var(--sans);
-webkit-font-smoothing: antialiased;
}
main { max-width: 40rem; margin: 15vh auto; padding: 0 24px; }
@media (max-width: 480px) { main { padding: 0 16px; } }
h1 { font: 700 clamp(2rem, 1.35rem + 2.4vw, 2.75rem)/1.08 var(--serif); margin: 0 0 20px; }
p { margin: 0 0 1rem; }
a { color: var(--forest); text-decoration-thickness: 1px; text-underline-offset: .18em; }
a:hover { text-decoration-thickness: 2px; }
body { margin: 0; font: 400 1.0625rem/1.6 system-ui, -apple-system, BlinkMacSystemFont, "Helvetica Neue", Arial, sans-serif; }
main { max-width: 40rem; margin: 15vh auto; padding: 0 16px; }
</style>
</head>
<body>
<main>
<h1>Page not found</h1>
<p>Go to <a href="/grove/">Grove — parallel Claude Code sessions across multiple repositories</a>.</p>
<p><a href="/grove/ru/" hreflang="ru" lang="ru">Grove по-русски</a> · <a href="/grove/zh-cn/" hreflang="zh-Hans" lang="zh-Hans">Grove 简体中文</a></p>
<p><a href="/grove/multiple-repositories/">Run Claude Code across multiple repositories</a></p>
<p><a href="/grove/move-session-to-another-account/">Continue a Claude Code session on another account</a></p>
<p><a href="https://github.com/efim0v/grove">Grove on GitHub</a> · <a href="/brow/">Brow — Claude Code usage limits in the MacBook notch</a></p>
<p>Grove is now part of Brow, at <a href="https://efimov.page/brow/">efimov.page/brow</a>.</p>
</main>
<!-- Cloudflare Web Analytics --><script defer src='https://static.cloudflareinsights.com/beacon.min.js' data-cf-beacon='{"token": "3374c5e48ad2466996fbc654cddff0d5"}'></script>
</body>
Expand Down
Binary file removed site/apple-touch-icon.png
Binary file not shown.
13 changes: 0 additions & 13 deletions site/brow-mark.svg

This file was deleted.

Binary file removed site/favicon.png
Binary file not shown.
Binary file removed site/img/grove-accounts.webp
Binary file not shown.
Binary file removed site/img/grove-branch-graph.webp
Binary file not shown.
Binary file removed site/img/grove-code-stats.webp
Binary file not shown.
Binary file removed site/img/grove-new-workspace.webp
Binary file not shown.
Binary file removed site/img/grove-project-settings.webp
Binary file not shown.
Binary file removed site/img/grove-projects.webp
Binary file not shown.
Binary file removed site/img/grove-sessions.webp
Binary file not shown.
Binary file removed site/img/grove-workspaces.webp
Binary file not shown.
Loading
Loading