From b8851cb977cfdd608798d32bf5877f645b960831 Mon Sep 17 00:00:00 2001 From: N0zoM1z0 <161784452+N0zoM1z0@users.noreply.github.com> Date: Fri, 9 Oct 2026 21:46:05 +0800 Subject: [PATCH 1/2] feat(website): add search metadata and automatic sitemap Prefer rea.tools in page canonicals and search listings. Generate the sitemap from current HTML indexing directives and render a shared PNG preview from SVG before verification and manual deployment. Refs #1283 --- .github/workflows/website-check.yml | 22 +- .github/workflows/website-pages.yml | 17 +- .gitignore | 3 + scripts/prepare-website.py | 77 +++++- scripts/test-website.py | 130 ++++++++++ scripts/verify-website.py | 229 +++++++++++++++++- website/evidence/guide-examples.md | 21 +- website/public/assets/social-card.svg | 27 +++ website/public/examples/dino-lab/index.html | 42 +++- .../public/examples/notes-electron/index.html | 5 + website/public/examples/notes-web/index.html | 5 + website/public/faq/index.html | 42 +++- website/public/first-investigation/index.html | 42 +++- website/public/get-started/index.html | 42 +++- website/public/guides/browser/index.html | 36 ++- website/public/guides/index.html | 36 ++- website/public/guides/javascript/index.html | 42 +++- website/public/guides/native/index.html | 36 ++- website/public/index.html | 44 +++- website/public/robots.txt | 4 + website/public/showcase/aegis/index.html | 42 +++- website/public/showcase/ctf/index.html | 42 +++- website/public/showcase/dx-ball/index.html | 42 +++- website/public/showcase/index.html | 42 +++- website/public/showcase/notion/index.html | 42 +++- website/public/showcase/th04/index.html | 42 +++- website/requirements.txt | 2 + website/wrangler.toml | 5 + 28 files changed, 1123 insertions(+), 38 deletions(-) create mode 100644 scripts/test-website.py create mode 100644 website/public/assets/social-card.svg create mode 100644 website/public/robots.txt create mode 100644 website/requirements.txt 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/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/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 @@ + 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 @@ -