diff --git a/.github/workflows/website-check.yml b/.github/workflows/website-check.yml index 2fc7a6874..26270ce73 100644 --- a/.github/workflows/website-check.yml +++ b/.github/workflows/website-check.yml @@ -5,8 +5,7 @@ on: branches: [main, "release/*"] paths: - "website/**" - - "scripts/verify-website.py" - - "scripts/prepare-website.py" + - "scripts/*website*.py" - ".github/workflows/**" workflow_dispatch: @@ -21,11 +20,24 @@ jobs: check: name: Website checks runs-on: ubuntu-latest - timeout-minutes: 5 + timeout-minutes: 10 steps: - name: Check out the repository uses: actions/checkout@v4 - - name: Prepare the example download + - name: Set up Python for website assets + uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: pip + cache-dependency-path: website/requirements.txt + - name: Install sharing-image dependencies + run: | + sudo apt-get update + sudo apt-get install --no-install-recommends -y libcairo2 fonts-dejavu-core + python3 -m pip install -r website/requirements.txt + - name: Prepare website assets run: python3 scripts/prepare-website.py - - name: Check website links and publishing configuration + - name: Check website links, metadata and publishing configuration run: python3 scripts/verify-website.py + - name: Check sitemap discovery and metadata regressions + run: python3 scripts/test-website.py diff --git a/.github/workflows/website-pages.yml b/.github/workflows/website-pages.yml index ec2b7153e..7dec1ea47 100644 --- a/.github/workflows/website-pages.yml +++ b/.github/workflows/website-pages.yml @@ -23,7 +23,7 @@ jobs: prepare: if: github.ref == 'refs/heads/main' runs-on: ubuntu-latest - timeout-minutes: 5 + timeout-minutes: 10 permissions: contents: read pages: read @@ -48,9 +48,20 @@ jobs: fi - name: Check out the repository uses: actions/checkout@v4 - - name: Prepare the example download + - name: Set up Python for website assets + uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: pip + cache-dependency-path: website/requirements.txt + - name: Install sharing-image dependencies + run: | + sudo apt-get update + sudo apt-get install --no-install-recommends -y libcairo2 fonts-dejavu-core + python3 -m pip install -r website/requirements.txt + - name: Prepare website assets run: python3 scripts/prepare-website.py - - name: Check website links and publishing configuration + - name: Check website links, metadata and publishing configuration run: python3 scripts/verify-website.py - name: Record the published commit run: | diff --git a/.gitignore b/.gitignore index c0d65e4e7..e88c33a87 100644 --- a/.gitignore +++ b/.gitignore @@ -50,6 +50,9 @@ Thumbs.db # Generated website download /website/public/examples/notes-example.zip /website/public/deployment.json +/website/public/sitemap.xml +/website/public/assets/social-card.png +/website/.venv/ # Local Cloudflare development state /website/.wrangler/ diff --git a/scripts/prepare-website.py b/scripts/prepare-website.py index 1abc9c39d..af4f2d9b9 100644 --- a/scripts/prepare-website.py +++ b/scripts/prepare-website.py @@ -1,10 +1,16 @@ -"""Package the public Notes example for the static website.""" +"""Prepare downloads, the sitemap and the sharing image for the static website.""" +from html.parser import HTMLParser from io import BytesIO from pathlib import Path +import re +import sys +from urllib.parse import quote +import xml.etree.ElementTree as ET from zipfile import ZIP_DEFLATED, ZipFile, ZipInfo +SITE_ORIGIN = "https://rea.tools" EXAMPLE_FILES = ( "package.json", "main.js", @@ -15,6 +21,70 @@ EXAMPLE_FILES = ( ) +class IndexPolicy(HTMLParser): + """Read indexing directives from the authored HTML head.""" + + def __init__(self): + super().__init__() + self.in_head = False + self.noindex = False + + def handle_starttag(self, tag, attrs): + if tag == "head": + self.in_head = True + if tag == "meta" and self.in_head: + attributes = dict(attrs) + if (attributes.get("name") or "").lower() in ("robots", "googlebot"): + directives = re.split( + r"[\s,]+", (attributes.get("content") or "").lower() + ) + self.noindex |= bool(set(directives) & {"noindex", "none"}) + + def handle_endtag(self, tag): + if tag == "head": + self.in_head = False + + +def prepare_sitemap(site): + """Discover HTML routes automatically, excluding pages marked noindex.""" + namespace = "http://www.sitemaps.org/schemas/sitemap/0.9" + ET.register_namespace("", namespace) + sitemap = ET.Element(f"{{{namespace}}}urlset") + for path in sorted(site.rglob("*.html")): + policy = IndexPolicy() + policy.feed(path.read_text(encoding="utf-8")) + if policy.noindex: + continue + route = path.relative_to(site).as_posix() + if path.name == "index.html": + route = route.removesuffix("index.html") + entry = ET.SubElement(sitemap, f"{{{namespace}}}url") + ET.SubElement( + entry, f"{{{namespace}}}loc" + ).text = f"{SITE_ORIGIN}/{quote(route, safe='/')}" + ET.indent(sitemap, space=" ") + ET.ElementTree(sitemap).write( + site / "sitemap.xml", encoding="utf-8", xml_declaration=True + ) + print(f"Prepared sitemap.xml ({len(sitemap)} indexable pages).") + + +def prepare_sharing_image(site): + """Rasterize the maintained SVG for clients that need a PNG preview.""" + try: + import cairosvg + except (ImportError, OSError) as error: + sys.exit( + f"Cannot prepare sharing image: {error}. Install website/requirements.txt and native Cairo; see website/README.md." + ) + source = site / "assets/social-card.svg" + destination = site / "assets/social-card.png" + cairosvg.svg2png( + url=str(source), write_to=str(destination), output_width=1200, output_height=630 + ) + print("Prepared assets/social-card.png (1200 × 630).") + + def prepare_notes_example(site): """Create a ZIP with public source only and reproducible metadata.""" source = site / "examples/notes-electron" @@ -37,4 +107,7 @@ def prepare_notes_example(site): if __name__ == "__main__": root = Path(__file__).resolve().parent.parent - prepare_notes_example(root / "website/public") + site = root / "website/public" + prepare_sharing_image(site) + prepare_sitemap(site) + prepare_notes_example(site) diff --git a/scripts/test-website.py b/scripts/test-website.py new file mode 100644 index 000000000..121dec8ae --- /dev/null +++ b/scripts/test-website.py @@ -0,0 +1,130 @@ +"""Exercise automatic route discovery and mistakes that break search metadata.""" + +import contextlib +import importlib.util +import io +from pathlib import Path +import shutil +import tempfile +import unittest +import xml.etree.ElementTree as ET + + +ROOT = Path(__file__).resolve().parent.parent + + +def load_script(name): + """Load a repository script without executing its command-line entrypoint.""" + spec = importlib.util.spec_from_file_location(name, ROOT / "scripts" / f"{name}.py") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +PREPARE = load_script("prepare-website") +VERIFY = load_script("verify-website") + + +class WebsiteSearchChecks(unittest.TestCase): + """Use authored pages and temporary fixtures to check the publishing boundary.""" + + def test_sitemap_tracks_added_moved_removed_and_nonindexable_pages(self): + with tempfile.TemporaryDirectory() as directory: + site = Path(directory) + + def page(name, head="", body=""): + path = site / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(f"{head}{body}") + return path + + page("index.html") + nested = page("guides/new/index.html") + page( + "examples/fixture/index.html", + '', + ) + page("draft.html", '') + page("code & notes.html") + page("visible/index.html", body='') + (site / "asset.json").write_text("{}") + + def urls(): + with contextlib.redirect_stdout(io.StringIO()): + PREPARE.prepare_sitemap(site) + sitemap = ET.parse(site / "sitemap.xml") + return {node.text for node in sitemap.findall(".//{*}loc")} + + self.assertEqual( + urls(), + { + "https://rea.tools/", + "https://rea.tools/guides/new/", + "https://rea.tools/code%20%26%20notes.html", + "https://rea.tools/visible/", + }, + ) + nested.rename(site / "moved.html") + self.assertIn("https://rea.tools/moved.html", urls()) + self.assertNotIn("https://rea.tools/guides/new/", urls()) + (site / "moved.html").unlink() + self.assertNotIn("https://rea.tools/moved.html", urls()) + + def test_verifier_rejects_wrong_canonicals_stale_sitemaps_and_missing_previews( + self, + ): + cases = ( + ( + "index.html", + 'rel="canonical" href="https://rea.tools/"', + 'rel="canonical" href="https://morluto.github.io/rea/"', + "head canonical", + ), + ( + "showcase/aegis/index.html", + 'rel="canonical" href="https://rea.tools/showcase/aegis/"', + 'rel="canonical" href="https://rea.tools/"', + "head canonical", + ), + ( + "examples/notes-electron/index.html", + 'content="noindex"', + 'content="index"', + "must have a head noindex", + ), + ( + "sitemap.xml", + "https://rea.tools/faq/", + "https://rea.tools/deleted/", + "each indexable page once", + ), + ( + "faq/index.html", + 'name="twitter:card" content="summary_large_image"', + 'name="twitter:card" content="summary"', + "twitter:card", + ), + ) + for name, old, new, message in cases: + with self.subTest(name=name), tempfile.TemporaryDirectory() as directory: + site = Path(directory) / "public" + shutil.copytree(ROOT / "website/public", site) + path = site / name + source = path.read_text() + self.assertIn(old, source) + path.write_text(source.replace(old, new, 1)) + self.assertTrue( + any(message in error for error in VERIFY.check_site(site)[3]) + ) + with tempfile.TemporaryDirectory() as directory: + site = Path(directory) / "public" + shutil.copytree(ROOT / "website/public", site) + (site / "assets/social-card.png").unlink() + self.assertIn( + "Social preview PNG is missing; run scripts/prepare-website.py", + VERIFY.check_site(site)[3], + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/verify-website.py b/scripts/verify-website.py index d6f4a6be0..d67a89284 100644 --- a/scripts/verify-website.py +++ b/scripts/verify-website.py @@ -1,14 +1,19 @@ -"""Check local website references and the sole GitHub Pages publisher.""" +"""Check website references, search metadata and the sole production publisher.""" from html.parser import HTMLParser from pathlib import Path import re +import struct import sys -from urllib.parse import unquote, urlsplit +from urllib.parse import quote, unquote, urlsplit import xml.etree.ElementTree as ET from zipfile import BadZipFile, ZipFile +SITE_ORIGIN = "https://rea.tools" +NOTES_FIXTURES = {"examples/notes-electron/index.html", "examples/notes-web/index.html"} + + class HtmlReferences(HTMLParser): """Collect document IDs and references without fetching external URLs.""" @@ -17,9 +22,42 @@ class HtmlReferences(HTMLParser): self.ids = set() self.references = [] self.duplicate_ids = [] + self.in_head = False + self.in_title = False + self.titles = [] + self.meta = {} + self.properties = {} + self.canonicals = [] + self.lang = None + self.h1_count = 0 def handle_starttag(self, tag, attrs): attributes = dict(attrs) + if tag == "html": + self.lang = attributes.get("lang") + if tag == "h1": + self.h1_count += 1 + if tag == "head": + self.in_head = True + if self.in_head: + if tag == "title": + self.titles.append("") + self.in_title = True + if ( + tag == "link" + and "canonical" in (attributes.get("rel") or "").lower().split() + ): + self.canonicals.append(attributes.get("href") or "") + if tag == "meta": + for attribute, collection in ( + ("name", self.meta), + ("property", self.properties), + ): + key = attributes.get(attribute) + if key: + collection.setdefault(key.lower(), []).append( + attributes.get("content") or "" + ) identifier = attributes.get("id") if identifier is not None: if identifier in self.ids: @@ -30,6 +68,154 @@ class HtmlReferences(HTMLParser): if reference is not None: self.references.append(reference) + def handle_endtag(self, tag): + if tag == "head": + self.in_head = False + if tag == "title": + self.in_title = False + + def handle_data(self, data): + if self.in_title: + self.titles[-1] += data + + def noindex(self): + """Recognize directives that exclude this page from Google's index.""" + directives = ",".join( + self.meta.get("robots", []) + self.meta.get("googlebot", []) + ).lower() + return bool(set(re.split(r"[\s,]+", directives)) & {"noindex", "none"}) + + +def check_search_metadata(site, pages): + """Verify route-specific canonicals, sharing assets and the generated sitemap.""" + errors = [] + indexable = set() + titles = set() + for path, page in pages.items(): + label = path.relative_to(site).as_posix() + title = page.titles[0].strip() if len(page.titles) == 1 else "" + if not title: + errors.append(f"{label}: needs exactly one nonempty head title") + if page.lang != "en" or page.h1_count != 1: + errors.append(f"{label}: needs lang=en and exactly one h1") + if label in NOTES_FIXTURES and not page.noindex(): + errors.append( + f"{label}: example application must have a head noindex directive" + ) + if page.noindex(): + continue + route = ( + path.relative_to(site).parent.as_posix() + if path.name == "index.html" + else label + ) + route = "" if route == "." else route + url = f"{SITE_ORIGIN}/{quote(route, safe='/')}" + ( + "/" if route and path.name == "index.html" else "" + ) + indexable.add(url) + if page.canonicals != [url]: + errors.append(f"{label}: needs exactly one head canonical for {url}") + if title in titles: + errors.append(f"{label}: duplicate search title {title!r}") + titles.add(title) + descriptions = page.meta.get("description", []) + description = descriptions[0].strip() if len(descriptions) == 1 else "" + if not description: + errors.append(f"{label}: needs exactly one nonempty head description") + expected_og = { + "og:type": "website", + "og:site_name": "REA", + "og:title": title, + "og:description": description, + "og:url": url, + "og:image": f"{SITE_ORIGIN}/assets/social-card.png", + "og:image:type": "image/png", + "og:image:width": "1200", + "og:image:height": "630", + } + expected_twitter = { + "twitter:card": "summary_large_image", + "twitter:title": title, + "twitter:description": description, + "twitter:image": expected_og["og:image"], + } + for collection, expected in ( + (page.properties, expected_og), + (page.meta, expected_twitter), + ): + for key, value in expected.items(): + if collection.get(key) != [value]: + errors.append( + f"{label}: {key} must appear once and match {value!r}" + ) + for collection, key in ( + (page.properties, "og:image:alt"), + (page.meta, "twitter:image:alt"), + ): + values = collection.get(key, []) + if len(values) != 1 or not values[0].strip(): + errors.append(f"{label}: needs exactly one nonempty {key}") + + sitemap_path = site / "sitemap.xml" + try: + sitemap = ET.parse(sitemap_path).getroot() + namespace = "{http://www.sitemaps.org/schemas/sitemap/0.9}" + urls = [entry.findtext(f"{namespace}loc") for entry in sitemap] + if sitemap.tag != f"{namespace}urlset" or any( + entry.tag != f"{namespace}url" for entry in sitemap + ): + errors.append("sitemap.xml: needs a sitemap-protocol urlset") + if len(urls) != len(set(urls)) or set(urls) != indexable: + errors.append( + "sitemap.xml: must contain each indexable page once, using its rea.tools URL" + ) + if any( + len(entry) != 1 or entry[0].tag != f"{namespace}loc" for entry in sitemap + ): + errors.append( + "sitemap.xml: keep entries to loc; do not invent lastmod or add priority/changefreq" + ) + except (OSError, ET.ParseError) as error: + errors.append( + f"sitemap.xml: cannot read sitemap ({error}); run scripts/prepare-website.py" + ) + + robots_path = site / "robots.txt" + if not robots_path.is_file(): + errors.append("robots.txt: authored crawl rules are missing") + else: + directives = [] + for line in robots_path.read_text(encoding="utf-8").splitlines(): + value = line.split("#", 1)[0].strip() + if value: + key, separator, content = value.partition(":") + directives.append( + (key.lower(), content.strip()) if separator else (value, "") + ) + expected = [ + ("user-agent", "*"), + ("allow", "/"), + ("sitemap", f"{SITE_ORIGIN}/sitemap.xml"), + ] + if directives != expected: + errors.append( + "robots.txt: expected crawl access and one absolute rea.tools sitemap directive" + ) + + image = site / "assets/social-card.png" + if not image.is_file(): + errors.append("Social preview PNG is missing; run scripts/prepare-website.py") + else: + header = image.read_bytes()[:24] + if ( + len(header) != 24 + or header[:16] != b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR" + or struct.unpack(">II", header[16:24]) != (1200, 630) + ): + errors.append("Social preview must be a 1200 × 630 PNG") + return len(indexable), errors + def check_site(site): """Check assets, HTML fragment destinations and SVG syntax.""" @@ -63,7 +249,11 @@ def check_site(site): target /= "index.html" if not target.is_file(): errors.append(f"{label}: missing local target: {reference}") - elif target in pages and url.fragment and unquote(url.fragment) not in pages[target].ids: + elif ( + target in pages + and url.fragment + and unquote(url.fragment) not in pages[target].ids + ): errors.append(f"{label}: missing HTML fragment: {reference}") for path in sorted(site.rglob("*.svg")): @@ -71,18 +261,23 @@ def check_site(site): ET.parse(path) except ET.ParseError as error: errors.append(f"{path.relative_to(site)}: invalid SVG XML: {error}") - return len(pages), references, errors + indexable, metadata_errors = check_search_metadata(site, pages) + errors.extend(metadata_errors) + return len(pages), references, indexable, errors def check_publisher(root): """Detect a second Pages action that could replace the website.""" expected = root / ".github/workflows/website-pages.yml" workflows = root / ".github/workflows" - deploy_action = re.compile(r"^\s*-?\s*uses:\s*['\"]?actions/deploy-pages@", re.MULTILINE) + deploy_action = re.compile( + r"^\s*-?\s*uses:\s*['\"]?actions/deploy-pages@", re.MULTILINE + ) publishers = [ path for path in sorted(workflows.iterdir()) - if path.suffix in (".yml", ".yaml") and deploy_action.search(path.read_text(encoding="utf-8")) + if path.suffix in (".yml", ".yaml") + and deploy_action.search(path.read_text(encoding="utf-8")) ] if publishers != [expected]: actual = ", ".join(str(path.relative_to(root)) for path in publishers) or "none" @@ -95,7 +290,14 @@ def check_example_archive(site): source = site / "examples/notes-electron" expected = { f"notes-example/{name}": source / name - for name in ("package.json", "main.js", "preload.js", "renderer.js", "csv.js", "index.html") + for name in ( + "package.json", + "main.js", + "preload.js", + "renderer.js", + "csv.js", + "index.html", + ) } path = site / "examples/notes-example.zip" if not path.is_file(): @@ -103,7 +305,9 @@ def check_example_archive(site): try: with ZipFile(path) as archive: if sorted(archive.namelist()) != sorted(expected): - return ["Notes example ZIP must contain exactly the six files in notes-example/."] + return [ + "Notes example ZIP must contain exactly the six files in notes-example/." + ] return [ f"Notes example ZIP has stale source: {name}" for name, original in expected.items() @@ -116,13 +320,18 @@ def check_example_archive(site): def main(): """Run the same checks locally, on website PRs and before publishing.""" root = Path(__file__).resolve().parent.parent - pages, references, errors = check_site(root / "website/public") + pages, references, indexable, errors = check_site(root / "website/public") errors.extend(check_example_archive(root / "website/public")) errors.extend(check_publisher(root)) if errors: print("Website checks failed:\n" + "\n".join(errors), file=sys.stderr) return 1 - print(f"Verified {pages} HTML pages, {references} local references, SVG XML and the example ZIP.") + print( + f"Verified {pages} HTML pages, {references} local references, SVG XML and the example ZIP." + ) + print( + f"Verified search/sharing metadata, robots.txt and the sitemap for {indexable} indexable pages." + ) print("website-pages.yml is the sole GitHub Pages publisher.") return 0 diff --git a/website/AGENTS.md b/website/AGENTS.md index da4f38b27..d96ab2b62 100644 --- a/website/AGENTS.md +++ b/website/AGENTS.md @@ -16,9 +16,15 @@ for asset notes and the source of case-study claims. target identity and verification scope. - Keep personal paths, account data, credentials and raw captures outside the public assets. Use generic inputs and label shortened display paths. +- Follow the same clear writing standard in search titles and descriptions. + Give each content page its own absolute `https://rea.tools/` canonical and + matching sharing metadata; keep navigation relative. The sitemap is generated + from HTML automatically. Mark example applications `noindex` in the head and + keep them crawlable. Maintain the SVG sharing source, not the generated PNG. - Check both local root and `/rea/` paths, mobile layouts, expanded details, copying and downloads. Run `scripts/prepare-website.py` followed by - `scripts/verify-website.py`. + `scripts/verify-website.py` and `scripts/test-website.py`, using the website + Python environment described in the README. - Keep `website-pages.yml` as the sole, manual production publisher for Cloudflare and Pages. Default to `both`: both hosts receive the same verified artifact and pass the published-commit check. Use `pages` only when explicitly diff --git a/website/README.md b/website/README.md index 33c6e1fad..2005c633d 100644 --- a/website/README.md +++ b/website/README.md @@ -7,8 +7,8 @@ An English static website with explanatory figures, worked guides and Aegis, DX-Ball, Notion, TH04 and CTF investigations. The public files are in `website/public/`. The site uses HTML, CSS and small scripts for copying code, following code comparisons and playing the dinosaur -speed reconstruction. Python -packages the downloadable example; there is no frontend bundler or npm dependency. +speed reconstruction. Python prepares the example ZIP, sitemap and sharing +image; there is no frontend bundler or npm dependency. [style-guide.md](style-guide.md) explains the writing, page structure, figures, visual system and review process. Read it before adding or revising a page. @@ -18,11 +18,23 @@ visual system and review process. Read it before adding or revising a page. From the repository root: ```sh +python3 -m venv website/.venv +source website/.venv/bin/activate +python3 -m pip install -r website/requirements.txt python3 scripts/prepare-website.py python3 -m http.server 4173 --bind 127.0.0.1 --directory website/public ``` Open . Refresh the browser after editing a file. +Keep the virtual environment active when preparing assets. CairoSVG needs the +native Cairo library; the sharing card uses DejaVu Sans. On Debian or Ubuntu, +install them with `sudo apt-get install libcairo2 fonts-dejavu-core`. For other +systems, see [CairoSVG's installation instructions](https://cairosvg.org/documentation/#installation). +CI installs both packages explicitly before rendering the card. + +After adding, moving or removing a page, or changing its indexing policy, rerun +`python3 scripts/prepare-website.py`. It regenerates the sitemap from the current +HTML files. No URL list needs to be maintained. ## Pages @@ -44,6 +56,69 @@ Open . Refresh the browser after editing a file. Navigation and assets use relative paths, so the same files work at the local root and a GitHub Pages project path such as `/rea/`. +## Search and sharing metadata + +`https://rea.tools/` is the preferred public origin. Each content page has one +absolute canonical URL for its own route, including its trailing slash. The +same HTML on GitHub Pages points to the corresponding rea.tools page. Ordinary +navigation and asset references remain relative so both hosts and local previews +keep working. + +Maintain a descriptive `` and a short meta description in each page's +`<head>`. The title names the page's subject; visible headings keep the wording +that best explains it to a reader. Open Graph and Twitter titles/descriptions +match those fields. `og:url` matches the canonical, and sharing images use +absolute rea.tools URLs. Search results and link previews should follow the same +clear, concise writing standard as the page. + +`scripts/prepare-website.py` scans `public/**/*.html` and generates +`public/sitemap.xml` with absolute rea.tools URLs. An `index.html` maps to its +directory route; other HTML filenames retain their extension. Pages with +`noindex` or `none` in a head `robots` or `googlebot` meta directive are excluded. +The two Notes example applications use `noindex`; their guides and the dinosaur +lab remain indexable. Downloads, scripts and other non-HTML files are not listed. +The sitemap has no guessed modification dates, priority or change frequency. +It is ignored by Git and regenerated before checks and publication. + +`public/robots.txt` allows crawling and names the canonical sitemap. Keep example +applications crawlable so search engines can read their `noindex` directives. +Cloudflare may prepend managed rules or comments to the served file; inspect the +live response after publication. The mirrored `/rea/robots.txt` is not the +hostname-root robots file for `morluto.github.io`; canonical HTML tags establish +the mirror's preference. + +`public/assets/social-card.svg` is the maintained 1200 × 630 preview source. +CairoSVG generates `social-card.png` for sharing clients. Commit the SVG, not the +PNG. Both hosts receive the generated image in the same publishing artifact. +The preview uses the site's typography, palette and direct headline. + +Verification checks route-specific canonicals, unique titles, descriptions, +sharing fields, image dimensions, indexing policy and complete sitemap coverage. +`scripts/test-website.py` exercises automatic route changes and rejects incorrect +canonicals, lost fixture exclusions, stale sitemap entries and missing previews. +Run it after preparing assets: + +```sh +python3 scripts/verify-website.py +python3 scripts/test-website.py +``` + +### After publishing an SEO change + +1. Check the served `robots.txt`, `sitemap.xml`, HTML head and preview PNG at + rea.tools. Confirm that both hosts contain the same page-specific canonicals. +2. In the domain's Google Search Console property, submit + `https://rea.tools/sitemap.xml`. +3. Inspect the homepage, a guide and a case study. Check crawl access, indexing + and Google's selected canonical; inspect a GitHub Pages URL as well if that + property is available. Track indexing and search performance there afterward. + +These Search Console checks require the property owner's access. Local checks +verify the website inputs; Google's indexing reports show how they were used. +See Google's [canonical guidance](https://developers.google.com/search/docs/crawling-indexing/consolidate-duplicate-urls) +and [sitemap guidance](https://developers.google.com/search/docs/crawling-indexing/sitemaps/build-sitemap), +and Cloudflare's [managed robots behavior](https://developers.cloudflare.com/bots/additional-configurations/managed-robots-txt/). + ## Content Keep the copy direct and specific. Explain the task and the result before listing @@ -293,16 +368,18 @@ a release. Cloudflare deploys first; GitHub Pages deploys after that step succee Preparation adds `deployment.json` with the workflow's commit SHA. Cloudflare receives the extracted Pages artifact through Wrangler's explicit assets path; -the local custom-build output is not the uploaded directory. The workflow then +the production environment disables Wrangler's preview custom build, so deployment +uses the prepared artifact directly. The workflow then checks the selected public version markers and reports success only when they match that commit. A failed publication remains failed; fix its cause and rerun the workflow to complete both deployments. The two hosts can update at different times while a run is in progress. -Website checks run only for pull requests that change `website/`, the verification -script or workflow definitions. They check local links, HTML fragments, SVG XML -and the single Pages publisher without installing npm dependencies. The same -checks run before each manual deployment. You can also run them locally: +Website checks run only for pull requests that change `website/`, the website +scripts or workflow definitions. They check local links, HTML fragments, SVG XML, +search/sharing metadata and the single Pages publisher without installing npm +dependencies. The same asset preparation and verification run before each manual +deployment. With the preview environment active, run them locally: ```sh python3 scripts/prepare-website.py diff --git a/website/evidence/guide-examples.md b/website/evidence/guide-examples.md index 7a6626447..5be6e80e7 100644 --- a/website/evidence/guide-examples.md +++ b/website/evidence/guide-examples.md @@ -33,8 +33,8 @@ when the example files change. The guide's `examples/notes-example.zip` is generated from exactly these six source files, under the `notes-example/` folder. Packaging changes no file contents. Website verification compares every ZIP entry to its source file and -rejects extra entries. Existing source digests and static findings remain valid -for the unzipped example. +rejects extra entries. The recorded digests below identify the original analysis +checkpoint; subsequent metadata changes and checks are recorded separately. ## First-investigation formatter check @@ -82,6 +82,23 @@ The guide's follow-up CLI commands were also exercised against the real page: source. Discovery Evidence: `ev_31abb33e7707d47625ac909ab9256c705d03a31ee6bcd643fa96730e72469162`. Script capture Evidence: `ev_aeb777df405c09dc1bbdd4c185a5af2452abaa3cb493683c296c5afa97efbc11`. +## SEO metadata check — 9 October 2026 + +Both Notes HTML heads gained descriptions and `noindex` directives. The Electron +fixture was analyzed again with `rea-agents` 4.1.0 and Node.js 24.18.0 on Linux +x64. REA still reports four parsed JavaScript files, zero parse failures, one +exposed API member, one literal IPC channel, one renderer transmission, one main +handler and one paired transmission, with zero ambiguous transmissions. + +- Evidence ID: `ev_3958d870ecd27c202e5decc97161f2ea19db8cc88f9f39ed0449bf1581df2f2a`. +- Root artifact SHA-256: `0ee325d4eaf7f94b62b2962044995fe1efe9f1d31411eb035118b19b1f56f7bb`. +- Current Electron HTML SHA-256: `457b22543aa956fe2cd005fc6bda42d2798eb05040b75c4cb4957e3b66d0ea09`. +- Current browser HTML SHA-256: `3b24b3307ded48055ce5a48f907544195bfde3285f82df919c54a6415ac11070`. + +The JavaScript and data files retain their original digests. The generated ZIP +contains the updated HTML and the same JavaScript. Local browser checks exercise +the Notes CSV download at both root and `/rea/` paths. + Full local outputs and the browser verification harness are under `/tmp/rea-website-guides/` in the development environment, not website assets. diff --git a/website/figures.md b/website/figures.md index 5cf50ce29..72a32ac61 100644 --- a/website/figures.md +++ b/website/figures.md @@ -43,6 +43,15 @@ branches when changing these figures. Match progress claims to the pinned reconstruction checkpoint. Validate the SVG as XML and inspect its browser rendering at desktop and mobile widths. +## Sharing preview + +`public/assets/social-card.svg` is the 1200 × 630 preview source. It uses the +homepage's direct headline, the site's white/charcoal/slate/blue palette, and a +short agent → REA → evidence flow. CairoSVG renders the ignored PNG during asset +preparation. Check the rendered image for readable text and spacing before +publishing. The build uses DejaVu Sans for consistent typography; only the SVG +source is committed. Metadata and build instructions are in the website README. + ## Beginner homepage and dinosaur reconstruction The manual/agent comparison and Calculator percentage controls are semantic diff --git a/website/public/assets/social-card.svg b/website/public/assets/social-card.svg new file mode 100644 index 000000000..faab21bd2 --- /dev/null +++ b/website/public/assets/social-card.svg @@ -0,0 +1,27 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="630" viewBox="0 0 1200 630" role="img" aria-labelledby="title desc"> + <title id="title">REA — Find out how software works. + Reverse engineering with your coding agent. REA connects your agent to analysis tools and returns evidence. + + + REA + rea.tools + + Find out how + software works. + Reverse engineering with your coding agent. + + + + + + + Your agent + REA + analysis tools + Code & evidence + + + + + + + diff --git a/website/public/examples/dino-lab/index.html b/website/public/examples/dino-lab/index.html index feb3df2e7..bfae79460 100644 --- a/website/public/examples/dino-lab/index.html +++ b/website/public/examples/dino-lab/index.html @@ -3,11 +3,51 @@ - Dinosaur speed lab — REA + Chrome Dinosaur Game: Rebuild Its Speed Rule — REA + + + + + + + + + + + + + + + + diff --git a/website/public/examples/notes-electron/index.html b/website/public/examples/notes-electron/index.html index fc4e3a62d..244b08541 100644 --- a/website/public/examples/notes-electron/index.html +++ b/website/public/examples/notes-electron/index.html @@ -2,6 +2,11 @@ + + Notes — Electron analysis example diff --git a/website/public/examples/notes-web/index.html b/website/public/examples/notes-web/index.html index 7f420d16d..92980ef55 100644 --- a/website/public/examples/notes-web/index.html +++ b/website/public/examples/notes-web/index.html @@ -3,6 +3,11 @@ + + Notes — REA browser example diff --git a/website/public/faq/index.html b/website/public/faq/index.html index 6a551fa40..40e5f616a 100644 --- a/website/public/faq/index.html +++ b/website/public/faq/index.html @@ -3,11 +3,51 @@ - FAQ — REA + REA FAQ: Setup, Updates and Troubleshooting + + + + + + + + + + + + + + + + diff --git a/website/public/first-investigation/index.html b/website/public/first-investigation/index.html index 1ebbd474b..349ece913 100644 --- a/website/public/first-investigation/index.html +++ b/website/public/first-investigation/index.html @@ -3,11 +3,51 @@ - Your first investigation — REA + Your First Reverse Engineering Investigation — REA + + + + + + + + + + + + + + + + diff --git a/website/public/get-started/index.html b/website/public/get-started/index.html index fbb68dfcd..b86987e9e 100644 --- a/website/public/get-started/index.html +++ b/website/public/get-started/index.html @@ -3,11 +3,51 @@ - Get started — REA + Install REA and Set Up Your Coding Agent + + + + + + + + + + + + + + + + diff --git a/website/public/guides/browser/index.html b/website/public/guides/browser/index.html index 7b904f7ae..95a0aaaae 100644 --- a/website/public/guides/browser/index.html +++ b/website/public/guides/browser/index.html @@ -3,11 +3,45 @@ - Browser observation — REA + Browser and Network Analysis — REA + + + + + + + + + + + + + + + + diff --git a/website/public/guides/index.html b/website/public/guides/index.html index ee5c8c484..babd58bdf 100644 --- a/website/public/guides/index.html +++ b/website/public/guides/index.html @@ -3,11 +3,45 @@ - Guides — REA + Reverse Engineering Guides — REA + + + + + + + + + + + + + + + + diff --git a/website/public/guides/javascript/index.html b/website/public/guides/javascript/index.html index d5f865470..5212b579d 100644 --- a/website/public/guides/javascript/index.html +++ b/website/public/guides/javascript/index.html @@ -3,11 +3,51 @@ - JavaScript & Electron — REA + JavaScript and Electron Analysis — REA + + + + + + + + + + + + + + + + diff --git a/website/public/guides/native/index.html b/website/public/guides/native/index.html index d034f5cc7..a0e19caf5 100644 --- a/website/public/guides/native/index.html +++ b/website/public/guides/native/index.html @@ -3,11 +3,45 @@ - Native analysis — REA + Native Binary Analysis — REA + + + + + + + + + + + + + + + + diff --git a/website/public/index.html b/website/public/index.html index 8d895b266..0282d64de 100644 --- a/website/public/index.html +++ b/website/public/index.html @@ -3,10 +3,50 @@ - REA — Reverse Engineer Anything + REA — Reverse Engineering for Your Coding Agent + + + + + + + + + + + + + + + + diff --git a/website/public/robots.txt b/website/public/robots.txt new file mode 100644 index 000000000..4f2eb4ed8 --- /dev/null +++ b/website/public/robots.txt @@ -0,0 +1,4 @@ +User-agent: * +Allow: / + +Sitemap: https://rea.tools/sitemap.xml diff --git a/website/public/showcase/aegis/index.html b/website/public/showcase/aegis/index.html index 619eacc85..05a230501 100644 --- a/website/public/showcase/aegis/index.html +++ b/website/public/showcase/aegis/index.html @@ -3,11 +3,51 @@ - How an authenticator generates a login code — REA + Aegis APK Reverse Engineering: Login Codes — REA + + + + + + + + + + + + + + + + diff --git a/website/public/showcase/ctf/index.html b/website/public/showcase/ctf/index.html index 051697de5..ed2002924 100644 --- a/website/public/showcase/ctf/index.html +++ b/website/public/showcase/ctf/index.html @@ -3,11 +3,51 @@ - Solve a CTF flag checker — REA + CTF Reverse Engineering: DownUnderCTF Flag Checker — REA + + + + + + + + + + + + + + + + diff --git a/website/public/showcase/dx-ball/index.html b/website/public/showcase/dx-ball/index.html index 1b0d16a3f..d47449978 100644 --- a/website/public/showcase/dx-ball/index.html +++ b/website/public/showcase/dx-ball/index.html @@ -3,11 +3,51 @@ - Rebuilding DX-Ball — REA + DX-Ball Reverse Engineering: Sound Panning — REA + + + + + + + + + + + + + + + + diff --git a/website/public/showcase/index.html b/website/public/showcase/index.html index a993fa5a3..f0a537f69 100644 --- a/website/public/showcase/index.html +++ b/website/public/showcase/index.html @@ -3,11 +3,51 @@ - Case studies — REA + Reverse Engineering Case Studies — REA + + + + + + + + + + + + + + + + diff --git a/website/public/showcase/notion/index.html b/website/public/showcase/notion/index.html index 4e3a24b06..5120d8ca6 100644 --- a/website/public/showcase/notion/index.html +++ b/website/public/showcase/notion/index.html @@ -3,11 +3,51 @@ - Following Notion's clipboard through Electron — REA + Notion Electron Reverse Engineering: Clipboard — REA + + + + + + + + + + + + + + + + diff --git a/website/public/showcase/th04/index.html b/website/public/showcase/th04/index.html index fa4bce783..e08f8f2a3 100644 --- a/website/public/showcase/th04/index.html +++ b/website/public/showcase/th04/index.html @@ -3,11 +3,51 @@ - TH04 bullet patterns — REA + TH04 Reverse Engineering: DOS Bullet Patterns — REA + + + + + + + + + + + + + + + + diff --git a/website/requirements.txt b/website/requirements.txt new file mode 100644 index 000000000..cc77ae509 --- /dev/null +++ b/website/requirements.txt @@ -0,0 +1,2 @@ +# SVG-to-PNG conversion for the generated social preview. +CairoSVG==2.9.1 diff --git a/website/style-guide.md b/website/style-guide.md index a69683795..836e0f488 100644 --- a/website/style-guide.md +++ b/website/style-guide.md @@ -55,6 +55,13 @@ comes from, or what a check covers. Scope a claim positively and precisely: “The compiled function matches 63 bytes” is more useful than a broad claim followed by several qualifications. +Search titles and sharing previews follow this standard too. Name the software +or task so someone can understand the page before opening it. Keep titles and +descriptions accurate and concise; use technical terms when they identify the +actual topic. Preserve a clear visible headline rather than repeating search +terms throughout the page. See [README.md](README.md#search-and-sharing-metadata) +for canonical URLs, automatic sitemap generation and sharing assets. + ## Build a page around one question ### Introductions and first exercises diff --git a/website/wrangler.toml b/website/wrangler.toml index 06be8ad53..7ebf92a64 100644 --- a/website/wrangler.toml +++ b/website/wrangler.toml @@ -20,3 +20,8 @@ name = "rea-website" workers_dev = true preview_urls = false routes = [{ pattern = "rea.tools", custom_domain = true }] + +# Production receives the already prepared and verified shared Pages artifact. +# Override the preview build so deployment does not regenerate local assets. +[env.production.build] +command = ""