diff --git a/README.md b/README.md new file mode 100644 index 0000000..9f8ee87 --- /dev/null +++ b/README.md @@ -0,0 +1,146 @@ +# EIPs Theme + +Zola theme used by the Ethereum Improvement Proposal (EIP) site, including EIPs and ERCs. This repo contains the shared templates, Sass, static assets, syntax definitions, and Zola configuration used when rendering proposal content. + +## Local Development + +### Minimum Requirements + +Local workspace commands require these tools on `PATH`: + +* Git +* `build-eips` +* Zola 0.22.1 + +Git must be installed separately. The setup script locates or installs `build-eips` and Zola, adds locally installed tool directories to `PATH` for the current shell session, and prints guidance for making those `PATH` changes permanent. + +### Bootstrap The Workspace + +Run the setup script once from this repo. + +Linux and macOS: + +```sh +./scripts/dev-setup +``` + +Windows PowerShell: + +```powershell +.\scripts\dev-setup.ps1 +``` + +The setup script initializes the workspace one directory above this repo, runs `build-eips doctor`, and prints the next local commands. + +After setup, the generated workspace guide is available at `../WORKSPACE.md`. Use that file for the full command reference and workspace details. + +The script expects the theme repo to live inside the surrounding multi-repo workspace. After setup, the workspace has this layout: + +```text +EIPs-project/ +├── .build-eips.toml +├── WORKSPACE.md +├── .local-build/ +├── EIPs/ +├── ERCs/ +└── theme/ +``` + +The script anchors setup through `../EIPs` by default and clones `https://github.com/ethereum/EIPs.git` there when that checkout is missing. Set `ACTIVE_REPO_ROOT` to use another proposal repo checkout: + +```sh +ACTIVE_REPO_ROOT=../ERCs ./scripts/dev-setup +``` + +Validate the workspace at any point with: + +```sh +build-eips -C ../EIPs doctor +``` + +### Advanced Setup Options + +Pass optional flags through the script when you want to work on the system tooling or an additional proposal repo: + +```sh +./scripts/dev-setup --platform-dev +./scripts/dev-setup --template +./scripts/dev-setup --template --platform-dev +``` + +`--template` adds the optional `template/` proposal template repo. `--platform-dev` adds local `preprocessor/` and `eipw/` checkouts for build system development. + +After setup, run local site commands against the active proposal repo: + +```sh +build-eips -C ../EIPs check +build-eips -C ../EIPs serve +build-eips -C ../EIPs doctor +``` + +### Serve And Preview + +Local serving keeps two distinct modes: + +* `build-eips serve` for the runtime dev loop +* `build-eips preview` for serving already-built static output (`build-eips build` must be run first) + +`build-eips serve` runs Zola's fast serve mode under the hood. It watches tracked edits in the active proposal repo and incrementally updates the local site for content changes. + +It also watches tracked edits under `theme/` in the workspace. Theme changes can take longer to apply because they affect the whole rendered site. During `serve`, staging a new theme file with `git add` triggers a theme rescan; no extra file edit or restart should be needed. + +`serve --clean` ignores active-repo dirty edits but still watches the local theme. + +`build-eips preview` serves the resolved output directory for the active repo without invoking Zola, preprocessing markdown, or rebuilding anything. If the output directory does not exist yet, it fails and tells you to run `build-eips build` first. + +### Local Server And Base URL + +The `[server]` table in `.build-eips.toml` controls the local bind address for both `serve` and `preview`; the default is `127.0.0.1:1111`. Per-command `--host` and `--port` flags override `.build-eips.toml` for one run. These settings do not change build base URLs. + +The `[site].base_url` value in `.build-eips.toml` is the default local rendered site URL. Starter `.build-eips.toml` files set it to `http://127.0.0.1:1111`. If you change `[server].port`, update `[site].base_url` too when generated links should match the local server. + +Per-command `--base-url` on `build` or `serve` is a one-run override and wins over `.build-eips.toml`, including with staging or parity commands. + +`preview` serves existing output, so build with `--base-url` first when previewed HTML should contain a different local link target. + +### Target Specific Proposals + +Full local `build` and `serve` runs can take time because they process every proposal file. Use targeted rendering when you only need to test a few proposals or theme changes against a small proposal set: + +```bash +build-eips serve --only 555 +build-eips build --only 555 +build-eips build --only 555 678 +``` + +You can also set a default target list in the workspace `.build-eips.toml`: + +```toml +[render] +only = [555, 678] +``` + +CLI `--only` replaces `[render].only` for that run. For edge cases and exact filtering behavior, see `../WORKSPACE.md`. + +### Source And Output Overrides + +Workspace-local sources come from the standard workspace layout. The local theme is `workspace/theme`, and local sibling repos are `workspace/` from the active repo manifest. + +Use `--remote-siblings` when you need to force remote sibling proposal sources for a single command. + +Use global `--build-root ` when you want a separate prepared repo and output directory, for example to compare two builds side by side. The path replaces the default `.local-build/` location for each command where you pass it, so use the same `--build-root` value when serving or previewing builds. + + +Example: + +```bash +build-eips -C /work/EIPs-project/EIPs --build-root /tmp/eips-local build --base-url http://127.0.0.1:1111 +build-eips -C /work/EIPs-project/EIPs --build-root /tmp/eips-staging --staging build --base-url http://127.0.0.1:1112 + +build-eips -C /work/EIPs-project/EIPs --build-root /tmp/eips-local preview --port 1111 +build-eips -C /work/EIPs-project/EIPs --build-root /tmp/eips-staging preview --port 1112 + +# Or using serve +build-eips -C /work/EIPs-project/EIPs --build-root /tmp/eips-local serve --port 1111 +build-eips -C /work/EIPs-project/EIPs --build-root /tmp/eips-staging --staging serve --port 1112 +``` diff --git a/config.toml b/config.toml index 81e0f5e..a160d5f 100644 --- a/config.toml +++ b/config.toml @@ -22,15 +22,19 @@ generate_feeds = true internal_level = "warn" [markdown] -# Whether to do syntax highlighting -# Theme can be customised by setting the `highlight_theme` variable to a theme supported by Zola -highlight_code = true +bottom_footnotes = true -highlight_theme = "css" +[markdown.highlighting] +error_on_missing_language = true -highlight_themes_css = [ - { theme = "base16-ocean-dark", filename = "assets/css/syntax-theme-dark.css" }, - { theme = "base16-ocean-light", filename = "assets/css/syntax-theme-light.css" }, +style = "class" + +light_theme = "github-light" +dark_theme = "github-dark" + +extra_grammars = [ + "syntaxes/abnf/ABNF.json", + "syntaxes/csl-json/CSL-JSON.json", ] [extra.github] diff --git a/content/_index.md b/content/_index.md index bbf4869..cdf17d9 100644 --- a/content/_index.md +++ b/content/_index.md @@ -1,13 +1,15 @@ +++ +[extra] +homepage_badges = [ + { href = "https://discord.gg/Nz6rtfJ8Cu", image = "https://dcbadge.limes.pink/api/server/Nz6rtfJ8Cu?style=flat", alt = "Badge for EIP Editor Discord channel" }, + { href = "https://discord.gg/EVTQ9crVgQ", image = "https://dcbadge.limes.pink/api/server/EVTQ9crVgQ?style=flat", alt = "Badge for Ethereum R&D Discord channel" }, + { href = "https://discord.gg/mRzPXmmYEA", image = "https://dcbadge.limes.pink/api/server/mRzPXmmYEA?style=flat", alt = "Badge for Ethereum Wallets Discord channel" }, + { href = "/atom.xml", image = "https://img.shields.io/badge/rss-Everything-red.svg", alt = "RSS feed for everything" }, + { href = "/status/last-call/atom.xml", image = "https://img.shields.io/badge/rss-Last%20Calls-red.svg", alt = "RSS feed for last calls" }, +] +++ -

EIPs - Discord channel for ECH eip-editer - Discord channel for Eth R&D eip-editing - Discord server for discussions about proposals that impact Ethereum wallets - RSS - RSS -

+# EIPs

Ethereum Improvement Proposals (EIPs) describe standards for the Ethereum platform, including core protocol specifications, client APIs, and contract standards. Network upgrades are discussed separately in the Ethereum Project Management repository.

diff --git a/sass/assets/css/override.scss b/sass/assets/css/override.scss index 15eed3c..07aa953 100644 --- a/sass/assets/css/override.scss +++ b/sass/assets/css/override.scss @@ -6,6 +6,34 @@ color: var(--bs-body-color); } +.proposal-popover { + max-width: min(28rem, calc(100vw - 2rem)); + overflow-wrap: anywhere; + pointer-events: none; +} + +.proposal-popover .popover-header { + font-weight: 600; +} + +.proposal-popover .popover-body { + line-height: 1.45; +} + +.proposal-popover-description { + margin: 0 0 0.5rem; +} + +.proposal-popover-meta { + display: flex; + flex-wrap: wrap; + gap: 0.25rem 0.75rem; +} + +.proposal-popover-meta-item { + white-space: normal; +} + @media (prefers-color-scheme: dark) { table th { background-color: #0f0f0f; diff --git a/sass/assets/css/style.scss b/sass/assets/css/style.scss index 4bf4052..a56bd86 100644 --- a/sass/assets/css/style.scss +++ b/sass/assets/css/style.scss @@ -33,6 +33,34 @@ $content-width: 1152px; vertical-align: baseline; } +.homepage-badges { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.5rem; + margin: -0.25rem 0 1.25rem; +} + +.homepage-badge-link { + display: inline-flex; + align-items: center; + line-height: 1; +} + +.homepage-badge-link:hover { + text-decoration: none; +} + +.homepage-badge-link img { + display: block; + max-width: 100%; + height: 20px; +} + +.homepage-badge-link .badge { + font-size: 0.875rem; +} + .footer-col-wrapper { color: #111; } diff --git a/scripts/dev-setup b/scripts/dev-setup new file mode 100755 index 0000000..50bd5ff --- /dev/null +++ b/scripts/dev-setup @@ -0,0 +1,646 @@ +#!/bin/sh + +set -eu + +DEFAULT_ACTIVE_REPO_URL=https://github.com/ethereum/EIPs.git + +say() { + printf '%s\n' "$*" +} + +die() { + printf 'error: %s\n' "$*" >&2 + exit 1 +} + +shell_quote() { + printf "'%s'" "$(printf '%s' "$1" | sed "s/'/'\\\\''/g")" +} + +absolute_path() { + case "$1" in + /*) + printf '%s\n' "$1" + ;; + *) + printf '%s/%s\n' "$INVOCATION_DIR" "$1" + ;; + esac +} + +ensure_active_repo_root() { + if [ "$ACTIVE_REPO_EXPLICIT" = true ]; then + if [ ! -d "$ACTIVE_REPO_ROOT" ]; then + die "configured ACTIVE_REPO_ROOT does not exist: $ACTIVE_REPO_ROOT. Fix ACTIVE_REPO_ROOT or unset it to let this script clone EIPs." + fi + if [ ! -e "$ACTIVE_REPO_ROOT/.git" ]; then + die "configured ACTIVE_REPO_ROOT is not a git checkout: $ACTIVE_REPO_ROOT. Fix ACTIVE_REPO_ROOT or unset it to let this script clone EIPs." + fi + else + if [ ! -e "$ACTIVE_REPO_ROOT" ]; then + command -v git >/dev/null 2>&1 || die "need git to clone default proposal repo from $DEFAULT_ACTIVE_REPO_URL" + + say "No active proposal repo found at $ACTIVE_REPO_ROOT." + say "Cloning default proposal repo from $DEFAULT_ACTIVE_REPO_URL" + say "This may take a few minutes..." + if ! git clone "$DEFAULT_ACTIVE_REPO_URL" "$ACTIVE_REPO_ROOT"; then + die "failed to clone default proposal repo from $DEFAULT_ACTIVE_REPO_URL to $ACTIVE_REPO_ROOT" + fi + say "Cloned default proposal repo to $ACTIVE_REPO_ROOT" + fi + + if [ ! -d "$ACTIVE_REPO_ROOT" ]; then + die "default active proposal repo path exists but is not a directory: $ACTIVE_REPO_ROOT. Remove it or set ACTIVE_REPO_ROOT." + fi + if [ ! -e "$ACTIVE_REPO_ROOT/.git" ]; then + die "default active proposal repo path exists but is not a git checkout: $ACTIVE_REPO_ROOT. Remove it or set ACTIVE_REPO_ROOT." + fi + fi + + ACTIVE_REPO_ROOT=$(CDPATH= cd -- "$ACTIVE_REPO_ROOT" && pwd) +} + +normalize_dir_for_path_compare() { + dir=$1 + [ -n "$dir" ] || return 1 + + while [ "$dir" != "/" ] && [ "${dir%/}" != "$dir" ]; do + dir=${dir%/} + done + + case "$dir" in + /*) + absolute_dir=$dir + ;; + *) + absolute_dir=$(pwd -P)/$dir + ;; + esac + + if [ -d "$absolute_dir" ]; then + (CDPATH= cd -P -- "$absolute_dir" 2>/dev/null && pwd -P) + else + printf '%s\n' "$absolute_dir" + fi +} + +path_contains_dir() { + target=$(normalize_dir_for_path_compare "$1") || return 1 + old_ifs=$IFS + IFS=: + for path_entry in ${PATH:-}; do + [ -n "$path_entry" ] || continue + candidate=$(normalize_dir_for_path_compare "$path_entry") || continue + if [ "$candidate" = "$target" ]; then + IFS=$old_ifs + return 0 + fi + done + IFS=$old_ifs + return 1 +} + +move_dir_to_front_of_script_path() { + dir=$1 + target=$(normalize_dir_for_path_compare "$dir") || return 1 + new_path= + old_ifs=$IFS + IFS=: + for path_entry in ${PATH:-}; do + [ -n "$path_entry" ] || continue + candidate=$(normalize_dir_for_path_compare "$path_entry") || continue + if [ "$candidate" = "$target" ]; then + continue + fi + + if [ -n "$new_path" ]; then + new_path=$new_path:$path_entry + else + new_path=$path_entry + fi + done + IFS=$old_ifs + + if [ -n "$new_path" ]; then + updated_path=$dir:$new_path + else + updated_path=$dir + fi + + if [ "${PATH:-}" != "$updated_path" ]; then + PATH=$updated_path + add_path_note "$dir" + fi +} + +add_path_note() { + dir=$1 + target=$(normalize_dir_for_path_compare "$dir") || return 1 + old_ifs=$IFS + IFS=' +' + for path_note in $PATH_NOTES; do + candidate=$(normalize_dir_for_path_compare "$path_note") || continue + if [ "$candidate" = "$target" ]; then + IFS=$old_ifs + return 0 + fi + done + IFS=$old_ifs + + case " +$PATH_NOTES +" in + *" +$dir +"*) + return 0 + ;; + esac + + if [ -n "$PATH_NOTES" ]; then + PATH_NOTES=$PATH_NOTES' +'$dir + else + PATH_NOTES=$dir + fi +} + +pick_home_install_dir() { + [ -n "${HOME:-}" ] || return 1 + dir=$HOME/.local/bin + mkdir -p "$dir" 2>/dev/null || return 1 + [ -d "$dir" ] || return 1 + [ -w "$dir" ] || return 1 + printf '%s\n' "$dir" +} + +pick_writable_path_dir() { + old_ifs=$IFS + IFS=: + for dir in ${PATH:-}; do + [ -n "$dir" ] || continue + [ -d "$dir" ] || continue + [ -w "$dir" ] || continue + printf '%s\n' "$dir" + IFS=$old_ifs + return 0 + done + IFS=$old_ifs + return 1 +} + +pick_install_dir() { + if dir=$(pick_home_install_dir); then + printf '%s\n' "$dir" + return 0 + fi + + pick_writable_path_dir +} + +download() { + url=$1 + destination=$2 + + if command -v curl >/dev/null 2>&1; then + curl -fsSL "$url" -o "$destination" + return 0 + fi + + if command -v wget >/dev/null 2>&1; then + wget -qO "$destination" "$url" + return 0 + fi + + die "need curl or wget to download release binaries" +} + +file_sha256() { + path=$1 + + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$path" | awk '{ print $1 }' | tr 'A-F' 'a-f' + return 0 + fi + + if command -v shasum >/dev/null 2>&1; then + shasum -a 256 "$path" | awk '{ print $1 }' | tr 'A-F' 'a-f' + return 0 + fi + + if command -v openssl >/dev/null 2>&1; then + openssl dgst -sha256 -r "$path" | awk '{ print $1 }' | tr 'A-F' 'a-f' + return 0 + fi + + die "need sha256sum, shasum, or openssl to verify release checksums" +} + +normalized_sidecar_text() { + sidecar_path=$1 + bom=$(printf '\357\273\277') + cr=$(printf '\r') + first_three=$(dd if="$sidecar_path" bs=3 count=1 2>/dev/null || true) + + if [ "$first_three" = "$bom" ]; then + dd if="$sidecar_path" bs=3 skip=1 2>/dev/null | tr -d "$cr" + else + tr -d "$cr" < "$sidecar_path" + fi +} + +verify_archive_checksum() { + archive_path=$1 + sidecar_path=$2 + archive_name=$3 + + [ -f "$sidecar_path" ] || die "missing checksum sidecar: $sidecar_path" + + checksum_lines=$(normalized_sidecar_text "$sidecar_path" | sed '/^[[:space:]]*$/d') + checksum_line_count=$(printf '%s\n' "$checksum_lines" | sed '/^[[:space:]]*$/d' | wc -l | tr -d ' ') + [ "$checksum_line_count" = "1" ] || die "checksum sidecar must contain exactly one checksum line" + + set -f + set -- $checksum_lines + set +f + [ "$#" -eq 2 ] || die "checksum sidecar must contain only a hash and archive filename" + + expected_hash=$(printf '%s' "$1" | tr 'A-F' 'a-f') + expected_name=$2 + + [ ${#expected_hash} -eq 64 ] || die "checksum sidecar hash must be 64 hex characters" + case "$expected_hash" in + *[!0123456789abcdef]*) + die "checksum sidecar hash must be hex" + ;; + esac + case "$expected_name" in + */*|*\\*) + die "checksum sidecar filename must be a basename" + ;; + esac + [ "$expected_name" = "$archive_name" ] || die "checksum sidecar filename '$expected_name' does not match '$archive_name'" + + actual_hash=$(file_sha256 "$archive_path") + [ "$actual_hash" = "$expected_hash" ] || die "checksum mismatch for $archive_name" +} + +verify_file_hash() { + archive_path=$1 + expected_hash=$2 + archive_name=$3 + + actual_hash=$(file_sha256 "$archive_path") + [ "$actual_hash" = "$expected_hash" ] || die "checksum mismatch for $archive_name" +} + +cleanup_tmpdir() { + if [ -n "${INSTALL_TMP_TO_CLEAN:-}" ]; then + rm -f "$INSTALL_TMP_TO_CLEAN" + INSTALL_TMP_TO_CLEAN= + fi + if [ -n "${TMPDIR_TO_CLEAN:-}" ]; then + rm -rf "$TMPDIR_TO_CLEAN" + TMPDIR_TO_CLEAN= + fi +} + +cleanup_for_signal() { + cleanup_tmpdir + trap - EXIT INT TERM HUP QUIT + exit 1 +} + +set_cleanup_traps() { + trap cleanup_tmpdir EXIT + trap cleanup_for_signal INT TERM HUP QUIT +} + +clear_cleanup_traps() { + trap - EXIT INT TERM HUP QUIT +} + +ensure_install_dir() { + [ -n "${INSTALL_DIR:-}" ] && return 0 + + INSTALL_DIR=$(pick_install_dir) || die "could not find a writable install directory" + move_dir_to_front_of_script_path "$INSTALL_DIR" +} + +install_build_eips() { + case "$(uname -s)" in + Linux) + archive_name=build-eips-ubuntu.tar.xz + ;; + Darwin) + archive_name=build-eips-macos.tar.xz + ;; + *) + die "unsupported operating system for automatic build-eips installation" + ;; + esac + + command -v tar >/dev/null 2>&1 || die "need tar to unpack the build-eips release archive" + ensure_install_dir + + tmpdir=$(mktemp -d) + TMPDIR_TO_CLEAN=$tmpdir + set_cleanup_traps + + archive_path=$tmpdir/$archive_name + sidecar_path=$tmpdir/$archive_name.sha256 + release_base_url=https://github.com/eips-wg/preprocessor/releases/latest/download + + say "Installing build-eips from $release_base_url/$archive_name" + download "$release_base_url/$archive_name" "$archive_path" + download "$release_base_url/$archive_name.sha256" "$sidecar_path" + verify_archive_checksum "$archive_path" "$sidecar_path" "$archive_name" + + tar -xf "$archive_path" -C "$tmpdir" + [ -f "$tmpdir/build-eips" ] || die "release archive did not contain build-eips" + + install_tmp=$(mktemp "$INSTALL_DIR/.build-eips.XXXXXX") || die "could not create temporary install file in $INSTALL_DIR" + INSTALL_TMP_TO_CLEAN=$install_tmp + cp "$tmpdir/build-eips" "$install_tmp" + chmod +x "$install_tmp" + mv "$install_tmp" "$INSTALL_DIR/build-eips" + INSTALL_TMP_TO_CLEAN= + + BUILD_EIPS=$INSTALL_DIR/build-eips + cleanup_tmpdir + clear_cleanup_traps +} + +unsupported_zola_platform() { + os=$1 + arch=$2 + die "Unsupported platform $os/$arch for automatic Zola install. Install Zola 0.22.1 manually from https://github.com/getzola/zola/releases and ensure it is on PATH." +} + +is_linux_musl() { + if command -v ldd >/dev/null 2>&1 && ldd --version 2>&1 | grep -qi musl; then + return 0 + fi + + set -- /lib/ld-musl-*.so.1 + [ -e "$1" ] +} + +select_zola_release() { + os=$(uname -s) + arch=$(uname -m) + + case "$os" in + Linux) + if is_linux_musl; then + case "$arch" in + x86_64) + ZOLA_ARCHIVE_NAME=zola-v0.22.1-x86_64-unknown-linux-musl.tar.gz + ZOLA_ARCHIVE_HASH=227df99b664421240a8ba77747067c51259b08159125d5603763b3b173b9a881 + ;; + *) + unsupported_zola_platform "$os" "$arch" + ;; + esac + else + case "$arch" in + x86_64) + ZOLA_ARCHIVE_NAME=zola-v0.22.1-x86_64-unknown-linux-gnu.tar.gz + ZOLA_ARCHIVE_HASH=0ca09aa40376aaa9ddfb512ff9ad963262ef95edb0d0f2d5ec6961b6f5cf22ef + ;; + aarch64|arm64) + ZOLA_ARCHIVE_NAME=zola-v0.22.1-aarch64-unknown-linux-gnu.tar.gz + ZOLA_ARCHIVE_HASH=8af437ec6352f33ccd24d7a1cfcb54a3db95d3ce376dc69525b4ef3fb6b8c1d1 + ;; + *) + unsupported_zola_platform "$os" "$arch" + ;; + esac + fi + ;; + Darwin) + case "$arch" in + x86_64) + ZOLA_ARCHIVE_NAME=zola-v0.22.1-x86_64-apple-darwin.tar.gz + ZOLA_ARCHIVE_HASH=3898709e154ae0593933264a540c869348bdb10d7f1b03a42dfb78d63703b3b5 + ;; + arm64|aarch64) + ZOLA_ARCHIVE_NAME=zola-v0.22.1-aarch64-apple-darwin.tar.gz + ZOLA_ARCHIVE_HASH=46ac45a9e7628dba8593b124ee8794f4f9aa1c6b569918ecd4bbc5d0be190515 + ;; + *) + unsupported_zola_platform "$os" "$arch" + ;; + esac + ;; + *) + unsupported_zola_platform "$os" "$arch" + ;; + esac +} + +parse_zola_version() { + output=$1 + + set -f + set -- $output + set +f + [ "$#" -ge 2 ] || return 1 + + ZOLA_FOUND_VERSION=$2 + version_core=$(printf '%s\n' "$ZOLA_FOUND_VERSION" | sed -n 's/^\([0-9][0-9]*\)\.\([0-9][0-9]*\)\.\([0-9][0-9]*\).*/\1 \2 \3/p') + [ -n "$version_core" ] || return 1 + + set -- $version_core + ZOLA_VERSION_MAJOR=$1 + ZOLA_VERSION_MINOR=$2 + ZOLA_VERSION_PATCH=$3 + ZOLA_VERSION_SUFFIX=$(printf '%s\n' "$ZOLA_FOUND_VERSION" | sed -n 's/^[0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*//p') + + return 0 +} + +zola_version_relation() { + if [ "$ZOLA_VERSION_MAJOR" -lt 0 ]; then + printf '%s\n' below + elif [ "$ZOLA_VERSION_MAJOR" -gt 0 ]; then + printf '%s\n' newer + elif [ "$ZOLA_VERSION_MINOR" -lt 22 ]; then + printf '%s\n' below + elif [ "$ZOLA_VERSION_MINOR" -gt 22 ]; then + printf '%s\n' newer + elif [ "$ZOLA_VERSION_PATCH" -lt 1 ]; then + printf '%s\n' below + elif [ "$ZOLA_VERSION_PATCH" -gt 1 ]; then + printf '%s\n' newer + elif [ -n "$ZOLA_VERSION_SUFFIX" ]; then + printf '%s\n' below + else + printf '%s\n' equal + fi +} + +install_zola() { + command -v tar >/dev/null 2>&1 || die "need tar to unpack the zola release archive" + select_zola_release + ensure_install_dir + + tmpdir=$(mktemp -d) + TMPDIR_TO_CLEAN=$tmpdir + set_cleanup_traps + + archive_path=$tmpdir/$ZOLA_ARCHIVE_NAME + release_base_url=https://github.com/getzola/zola/releases/download/v0.22.1 + + say "Installing zola 0.22.1 from $release_base_url/$ZOLA_ARCHIVE_NAME" + download "$release_base_url/$ZOLA_ARCHIVE_NAME" "$archive_path" + verify_file_hash "$archive_path" "$ZOLA_ARCHIVE_HASH" "$ZOLA_ARCHIVE_NAME" + + tar -xzf "$archive_path" -C "$tmpdir" + [ -f "$tmpdir/zola" ] || die "zola release archive did not contain zola" + + install_tmp=$(mktemp "$INSTALL_DIR/.zola.XXXXXX") || die "could not create temporary zola install file in $INSTALL_DIR" + INSTALL_TMP_TO_CLEAN=$install_tmp + cp "$tmpdir/zola" "$install_tmp" + chmod +x "$install_tmp" + mv "$install_tmp" "$INSTALL_DIR/zola" + INSTALL_TMP_TO_CLEAN= + + ZOLA=$INSTALL_DIR/zola + cleanup_tmpdir + clear_cleanup_traps +} + +ensure_zola() { + if command -v zola >/dev/null 2>&1; then + ZOLA=$(command -v zola) + elif [ -n "${HOME:-}" ] && [ -x "$HOME/.local/bin/zola" ]; then + INSTALL_DIR=$HOME/.local/bin + ZOLA=$INSTALL_DIR/zola + move_dir_to_front_of_script_path "$INSTALL_DIR" + else + install_zola + return 0 + fi + + if [ -n "${ZOLA:-}" ]; then + zola_output=$("$ZOLA" --version 2>/dev/null || true) + if parse_zola_version "$zola_output"; then + relation=$(zola_version_relation) + case "$relation" in + below) + say "Found zola $ZOLA_FOUND_VERSION below supported 0.22.1. Installing zola 0.22.1." + install_zola + ;; + equal) + say "Using existing zola $ZOLA_FOUND_VERSION at $ZOLA" + ;; + newer) + say "Found zola $ZOLA_FOUND_VERSION. build-eips is tested with zola 0.22.1 or newer. Continuing with the installed version." + ;; + esac + else + say "Found zola with unparseable version output. Installing zola 0.22.1." + install_zola + fi + fi +} + +WORKSPACE_FLAGS= + +while [ "$#" -gt 0 ]; do + case "$1" in + --template|--platform-dev) + WORKSPACE_FLAGS="$WORKSPACE_FLAGS $1" + ;; + -h|--help) + say "usage: $0 [--template] [--platform-dev]" + exit 0 + ;; + *) + die "unknown argument: $1" + ;; + esac + shift +done + +INVOCATION_DIR=$(pwd) +SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +THEME_ROOT=$(CDPATH= cd -- "$SCRIPT_DIR/.." && pwd) +WORKSPACE_ROOT=$(CDPATH= cd -- "$THEME_ROOT/.." && pwd) +INSTALL_DIR= +PATH_NOTES= +TMPDIR_TO_CLEAN= +INSTALL_TMP_TO_CLEAN= + +# Theme is not an active proposal repo, so build-eips init is anchored through a +# sibling proposal repo. Default to EIPs and allow ERCs/custom layouts via env. +if [ -n "${ACTIVE_REPO_ROOT:-}" ]; then + ACTIVE_REPO_EXPLICIT=true + ACTIVE_REPO_ROOT=$(absolute_path "$ACTIVE_REPO_ROOT") +else + ACTIVE_REPO_EXPLICIT=false + ACTIVE_REPO_ROOT=$WORKSPACE_ROOT/EIPs +fi + +ensure_active_repo_root + +if command -v build-eips >/dev/null 2>&1; then + BUILD_EIPS=$(command -v build-eips) + say "Using existing build-eips at $BUILD_EIPS" +elif [ -n "${HOME:-}" ] && [ -x "$HOME/.local/bin/build-eips" ]; then + BUILD_EIPS=$HOME/.local/bin/build-eips + INSTALL_DIR=$HOME/.local/bin + move_dir_to_front_of_script_path "$INSTALL_DIR" + say "Using existing build-eips at $BUILD_EIPS" +else + install_build_eips +fi + +ensure_zola + +say "Theme repo: $THEME_ROOT" +say "Workspace root: $WORKSPACE_ROOT" +say "Active proposal repo: $ACTIVE_REPO_ROOT" + +say "Bootstrapping workspace at $WORKSPACE_ROOT" +if ! "$BUILD_EIPS" -C "$ACTIVE_REPO_ROOT" init "$WORKSPACE_ROOT" $WORKSPACE_FLAGS; then + die "build-eips init failed" +fi + +say "Running build-eips doctor" +if ! "$BUILD_EIPS" -C "$ACTIVE_REPO_ROOT" doctor; then + say "Warning: build-eips doctor reported issues above. Fix them before relying on direct build-eips commands." +fi + +WORKSPACE_DOC=$WORKSPACE_ROOT/WORKSPACE.md +say "" +if [ -f "$WORKSPACE_DOC" ]; then + say "Workspace docs: $WORKSPACE_DOC (../WORKSPACE.md from this repo)" +else + say "Warning: workspace docs were not found at $WORKSPACE_DOC after build-eips init" +fi + +if [ -n "$PATH_NOTES" ]; then + say "" + say "Updated PATH for this script process only:" + old_ifs=$IFS + IFS=' +' + for path_note in $PATH_NOTES; do + say " $path_note" + done + say "To make this permanent in your shell, add:" + for path_note in $PATH_NOTES; do + say " export PATH=$(shell_quote "$path_note"):\$PATH" + done + IFS=$old_ifs +fi + +say "" +say "Next commands:" +say " cd $(shell_quote "$THEME_ROOT")" +say " build-eips -C $(shell_quote "$ACTIVE_REPO_ROOT") serve" +say " build-eips -C $(shell_quote "$ACTIVE_REPO_ROOT") check" +say " build-eips -C $(shell_quote "$ACTIVE_REPO_ROOT") doctor" diff --git a/scripts/dev-setup.ps1 b/scripts/dev-setup.ps1 new file mode 100644 index 0000000..1399417 --- /dev/null +++ b/scripts/dev-setup.ps1 @@ -0,0 +1,609 @@ +param( + [switch]$Template, + [switch]$PlatformDev +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" +$DefaultActiveRepoUrl = "https://github.com/ethereum/EIPs.git" + +function Say { + param([string]$Message) + + Write-Host $Message +} + +function Die { + param([string]$Message) + + throw "error: $Message" +} + +function ConvertTo-NormalizedDirectoryPath { + param([string]$Directory) + + $trimmed = $Directory.Trim('"') + $trimChars = [char[]]@([System.IO.Path]::DirectorySeparatorChar, [System.IO.Path]::AltDirectorySeparatorChar) + + try { + return ([System.IO.Path]::GetFullPath($trimmed)).TrimEnd($trimChars) + } catch { + return $trimmed.TrimEnd($trimChars) + } +} + +function Test-DirectoryOnPath { + param([string]$Directory) + + if ([string]::IsNullOrWhiteSpace($env:Path)) { + return $false + } + + $target = ConvertTo-NormalizedDirectoryPath -Directory $Directory + foreach ($entry in ($env:Path -split ";")) { + if ([string]::IsNullOrWhiteSpace($entry)) { + continue + } + + $candidate = ConvertTo-NormalizedDirectoryPath -Directory $entry + if ([string]::Equals($candidate, $target, [System.StringComparison]::OrdinalIgnoreCase)) { + return $true + } + } + + return $false +} + +function Add-PathNote { + param([string]$InstallDir) + + $target = ConvertTo-NormalizedDirectoryPath -Directory $InstallDir + foreach ($pathNote in $script:PathNotes) { + $candidate = ConvertTo-NormalizedDirectoryPath -Directory $pathNote + if ([string]::Equals($candidate, $target, [System.StringComparison]::OrdinalIgnoreCase)) { + return + } + } + + $script:PathNotes += $InstallDir +} + +function Move-DirectoryToFrontOfSessionPath { + param([string]$InstallDir) + + $target = ConvertTo-NormalizedDirectoryPath -Directory $InstallDir + $remainingEntries = @() + + if (-not [string]::IsNullOrWhiteSpace($env:Path)) { + foreach ($entry in ($env:Path -split ";")) { + if ([string]::IsNullOrWhiteSpace($entry)) { + continue + } + + $candidate = ConvertTo-NormalizedDirectoryPath -Directory $entry + if ([string]::Equals($candidate, $target, [System.StringComparison]::OrdinalIgnoreCase)) { + continue + } + + $remainingEntries += $entry + } + } + + $newEntries = @($InstallDir) + if ($remainingEntries.Count -gt 0) { + $newEntries += $remainingEntries + } + + $updatedPath = [string]::Join(";", $newEntries) + if (-not [string]::Equals($env:Path, $updatedPath, [System.StringComparison]::Ordinal)) { + $env:Path = $updatedPath + + Add-PathNote -InstallDir $InstallDir + } +} + +function Find-BuildEipsOnPath { + foreach ($commandName in @("build-eips", "build-eips.exe")) { + $commands = @(Get-Command -Name $commandName -CommandType Application -ErrorAction SilentlyContinue) + if ($commands.Count -gt 0) { + return $commands[0].Source + } + } + + return $null +} + +function Find-ZolaOnPath { + foreach ($commandName in @("zola", "zola.exe")) { + $commands = @(Get-Command -Name $commandName -CommandType Application -ErrorAction SilentlyContinue) + if ($commands.Count -gt 0) { + return $commands[0].Source + } + } + + return $null +} + +function Assert-InstallDirWritable { + param([string]$InstallDir) + + $probePath = $null + + try { + New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null + $probeName = ".build-eips-write-test-{0}.tmp" -f ([System.Guid]::NewGuid().ToString("N")) + $probePath = Join-Path -Path $InstallDir -ChildPath $probeName + [System.IO.File]::WriteAllText($probePath, "") + Remove-Item -LiteralPath $probePath -Force + $probePath = $null + } catch { + Die ("install directory cannot be created or written ({0}): {1}" -f $InstallDir, $_.Exception.Message) + } finally { + if (($null -ne $probePath) -and (Test-Path -LiteralPath $probePath)) { + Remove-Item -LiteralPath $probePath -Force -ErrorAction SilentlyContinue + } + } +} + +function Invoke-ReleaseDownload { + param( + [string]$Url, + [string]$Destination + ) + + $previousProgressPreference = $ProgressPreference + $ProgressPreference = "SilentlyContinue" + try { + Invoke-WebRequest -Uri $Url -OutFile $Destination -UseBasicParsing + } catch { + Die ("failed to download {0}: {1}" -f $Url, $_.Exception.Message) + } finally { + $ProgressPreference = $previousProgressPreference + } +} + +function Test-AsciiHexHash { + param([string]$Hash) + + return $Hash -match "^[0-9a-fA-F]{64}$" +} + +function Assert-ArchiveChecksum { + param( + [string]$ArchivePath, + [string]$SidecarPath, + [string]$ArchiveName + ) + + if (-not (Test-Path -LiteralPath $SidecarPath -PathType Leaf)) { + Die "missing checksum sidecar: $SidecarPath" + } + + $sidecarText = [System.IO.File]::ReadAllText($SidecarPath) + $checksumLines = @($sidecarText -split "\r?\n" | Where-Object { $_.Trim().Length -gt 0 }) + if ($checksumLines.Count -ne 1) { + Die "checksum sidecar must contain exactly one checksum line" + } + + $fields = @($checksumLines[0].Trim() -split "\s+") + if ($fields.Count -ne 2) { + Die "checksum sidecar must contain only a hash and archive filename" + } + + $expectedHash = $fields[0].ToLowerInvariant() + $expectedName = $fields[1] + + if (-not (Test-AsciiHexHash -Hash $expectedHash)) { + Die "checksum sidecar hash must be 64 hex characters" + } + if ($expectedName -match '[/\\]') { + Die "checksum sidecar filename must be a basename" + } + if ($expectedName -ne $ArchiveName) { + Die ("checksum sidecar filename '{0}' does not match '{1}'" -f $expectedName, $ArchiveName) + } + + $actualHash = (Get-FileHash -Algorithm SHA256 -Path $ArchivePath).Hash.ToLowerInvariant() + if ($actualHash -ne $expectedHash) { + Die "checksum mismatch for $ArchiveName" + } +} + +function Assert-FileSha256 { + param( + [string]$ArchivePath, + [string]$ExpectedHash, + [string]$ArchiveName + ) + + $actualHash = (Get-FileHash -Algorithm SHA256 -Path $ArchivePath).Hash.ToLowerInvariant() + if ($actualHash -ne $ExpectedHash.ToLowerInvariant()) { + Die "checksum mismatch for $ArchiveName" + } +} + +function Install-BuildEips { + param( + [string]$InstallDir, + [string]$BuildEipsPath + ) + + $archiveName = "build-eips-windows.zip" + $releaseBaseUrl = "https://github.com/eips-wg/preprocessor/releases/latest/download" + $tmpRoot = Join-Path -Path ([System.IO.Path]::GetTempPath()) -ChildPath ("build-eips-" + [System.Guid]::NewGuid().ToString("N")) + $archivePath = Join-Path -Path $tmpRoot -ChildPath $archiveName + $sidecarPath = Join-Path -Path $tmpRoot -ChildPath "$archiveName.sha256" + $extractDir = Join-Path -Path $tmpRoot -ChildPath "extract" + + try { + Assert-InstallDirWritable -InstallDir $InstallDir + + New-Item -ItemType Directory -Path $extractDir -Force | Out-Null + + [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 + + Say "Installing build-eips from $releaseBaseUrl/$archiveName" + Invoke-ReleaseDownload -Url "$releaseBaseUrl/$archiveName" -Destination $archivePath + Invoke-ReleaseDownload -Url "$releaseBaseUrl/$archiveName.sha256" -Destination $sidecarPath + Assert-ArchiveChecksum -ArchivePath $archivePath -SidecarPath $sidecarPath -ArchiveName $archiveName + + Expand-Archive -LiteralPath $archivePath -DestinationPath $extractDir -Force + + $extractedBuildEips = Join-Path -Path $extractDir -ChildPath "build-eips.exe" + if (-not (Test-Path -LiteralPath $extractedBuildEips -PathType Leaf)) { + Die "release archive did not contain expected build-eips.exe" + } + + try { + Move-Item -LiteralPath $extractedBuildEips -Destination $BuildEipsPath -Force + } catch { + Die ("build-eips.exe is in use. Close any running build-eips process and re-run this script. Details: {0}" -f $_.Exception.Message) + } + + return $BuildEipsPath + } catch { + Die ("failed to install build-eips: {0}" -f $_.Exception.Message) + } finally { + if (Test-Path -LiteralPath $tmpRoot) { + Remove-Item -LiteralPath $tmpRoot -Recurse -Force -ErrorAction SilentlyContinue + } + } +} + +function Get-ZolaReleaseAsset { + $architecture = $env:PROCESSOR_ARCHITECTURE + if ([string]::IsNullOrWhiteSpace($architecture)) { + $architecture = "unknown" + } + if (($architecture -eq "x86") -and (-not [string]::IsNullOrWhiteSpace($env:PROCESSOR_ARCHITEW6432))) { + $architecture = $env:PROCESSOR_ARCHITEW6432 + } + + switch ($architecture.ToUpperInvariant()) { + "AMD64" { + return @{ + ArchiveName = "zola-v0.22.1-x86_64-pc-windows-msvc.zip" + Hash = "2c8b368f5abdf2b2478748f9549a761fd6599238e18948eccb76a7cae51f5dc1" + } + } + default { + Die "Unsupported platform Windows/$architecture for automatic Zola install. Install Zola 0.22.1 manually from https://github.com/getzola/zola/releases and ensure it is on PATH." + } + } +} + +function Install-Zola { + param( + [string]$InstallDir, + [string]$ZolaPath + ) + + $asset = Get-ZolaReleaseAsset + $archiveName = $asset.ArchiveName + $archiveHash = $asset.Hash + $releaseBaseUrl = "https://github.com/getzola/zola/releases/download/v0.22.1" + $tmpRoot = Join-Path -Path ([System.IO.Path]::GetTempPath()) -ChildPath ("zola-" + [System.Guid]::NewGuid().ToString("N")) + $archivePath = Join-Path -Path $tmpRoot -ChildPath $archiveName + $extractDir = Join-Path -Path $tmpRoot -ChildPath "extract" + + try { + Assert-InstallDirWritable -InstallDir $InstallDir + + New-Item -ItemType Directory -Path $extractDir -Force | Out-Null + + [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 + + Say "Installing zola 0.22.1 from $releaseBaseUrl/$archiveName" + Invoke-ReleaseDownload -Url "$releaseBaseUrl/$archiveName" -Destination $archivePath + Assert-FileSha256 -ArchivePath $archivePath -ExpectedHash $archiveHash -ArchiveName $archiveName + + Expand-Archive -LiteralPath $archivePath -DestinationPath $extractDir -Force + + $extractedZola = Join-Path -Path $extractDir -ChildPath "zola.exe" + if (-not (Test-Path -LiteralPath $extractedZola -PathType Leaf)) { + Die "zola release archive did not contain expected zola.exe" + } + + try { + Move-Item -LiteralPath $extractedZola -Destination $ZolaPath -Force + } catch { + Die ("zola.exe is in use. Close any running zola process and re-run this script. Details: {0}" -f $_.Exception.Message) + } + + return $ZolaPath + } catch { + Die ("failed to install zola: {0}" -f $_.Exception.Message) + } finally { + if (Test-Path -LiteralPath $tmpRoot) { + Remove-Item -LiteralPath $tmpRoot -Recurse -Force -ErrorAction SilentlyContinue + } + } +} + +function Get-ZolaVersionInfo { + param([string]$ZolaPath) + + try { + $output = & $ZolaPath --version 2>$null + if ($LASTEXITCODE -ne 0) { + return $null + } + } catch { + return $null + } + + $fields = @(($output -join " ") -split "\s+" | Where-Object { $_.Length -gt 0 }) + if ($fields.Count -lt 2) { + return $null + } + + $versionToken = $fields[1] + if ($versionToken -notmatch "^([0-9]+)\.([0-9]+)\.([0-9]+)(.*)$") { + return $null + } + + return @{ + VersionToken = $versionToken + Version = [version]("{0}.{1}.{2}" -f $Matches[1], $Matches[2], $Matches[3]) + Suffix = $Matches[4] + } +} + +function Get-ZolaVersionRelation { + param([hashtable]$VersionInfo) + + $minimumVersion = [version]"0.22.1" + if ($VersionInfo.Version -lt $minimumVersion) { + return "below" + } + if ($VersionInfo.Version -gt $minimumVersion) { + return "newer" + } + if (-not [string]::IsNullOrEmpty($VersionInfo.Suffix)) { + return "below" + } + + return "equal" +} + +function Install-PinnedZola { + $defaultPaths = Get-DefaultInstallPaths + $installedZola = Install-Zola -InstallDir $defaultPaths.InstallDir -ZolaPath $defaultPaths.ZolaPath + Move-DirectoryToFrontOfSessionPath -InstallDir $defaultPaths.InstallDir + + return $installedZola +} + +function Initialize-Zola { + $zolaPath = Find-ZolaOnPath + if ($null -eq $zolaPath) { + $defaultPaths = Get-DefaultInstallPaths + if (Test-Path -LiteralPath $defaultPaths.ZolaPath -PathType Leaf) { + $zolaPath = $defaultPaths.ZolaPath + Move-DirectoryToFrontOfSessionPath -InstallDir $defaultPaths.InstallDir + } + } + + if ($null -eq $zolaPath) { + return (Install-PinnedZola) + } + + $versionInfo = Get-ZolaVersionInfo -ZolaPath $zolaPath + if ($null -eq $versionInfo) { + Say "Found zola with unparseable version output. Installing zola 0.22.1." + return (Install-PinnedZola) + } + + $relation = Get-ZolaVersionRelation -VersionInfo $versionInfo + switch ($relation) { + "below" { + Say ("Found zola {0} below supported 0.22.1. Installing zola 0.22.1." -f $versionInfo.VersionToken) + return (Install-PinnedZola) + } + "equal" { + Say ("Using existing zola {0} at {1}" -f $versionInfo.VersionToken, $zolaPath) + return $zolaPath + } + "newer" { + Say ("Found zola {0}. build-eips is tested with zola 0.22.1 or newer. Continuing with the installed version." -f $versionInfo.VersionToken) + return $zolaPath + } + } +} + +function Resolve-ActiveRepoRoot { + param( + [string]$InvocationDir, + [string]$WorkspaceRoot + ) + + $activeRepoExplicit = $false + if (-not [string]::IsNullOrWhiteSpace($env:ACTIVE_REPO_ROOT)) { + $activeRepoExplicit = $true + if ([System.IO.Path]::IsPathRooted($env:ACTIVE_REPO_ROOT)) { + $activeRepoCandidate = $env:ACTIVE_REPO_ROOT + } else { + $activeRepoCandidate = Join-Path -Path $InvocationDir -ChildPath $env:ACTIVE_REPO_ROOT + } + } else { + $activeRepoCandidate = Join-Path -Path $WorkspaceRoot -ChildPath "EIPs" + } + + if ($activeRepoExplicit) { + try { + $resolved = (Resolve-Path -LiteralPath $activeRepoCandidate).ProviderPath + } catch { + Die "configured ACTIVE_REPO_ROOT does not exist: $activeRepoCandidate. Fix ACTIVE_REPO_ROOT or unset it to let this script clone EIPs." + } + + if (-not (Test-Path -LiteralPath $resolved -PathType Container)) { + Die "configured ACTIVE_REPO_ROOT is not a directory: $resolved. Fix ACTIVE_REPO_ROOT or unset it to let this script clone EIPs." + } + if (-not (Test-Path -LiteralPath (Join-Path -Path $resolved -ChildPath ".git"))) { + Die "configured ACTIVE_REPO_ROOT is not a git checkout: $resolved. Fix ACTIVE_REPO_ROOT or unset it to let this script clone EIPs." + } + + return @{ + Path = $resolved + Explicit = $true + } + } + + if (-not (Test-Path -LiteralPath $activeRepoCandidate)) { + $gitCommand = Get-Command git -CommandType Application -ErrorAction SilentlyContinue + if ($null -eq $gitCommand) { + Die "need git to clone default proposal repo from $DefaultActiveRepoUrl" + } + + Say "No active proposal repo found at $activeRepoCandidate." + Say "Cloning default proposal repo from $DefaultActiveRepoUrl" + Say "This may take a few minutes..." + & git clone $DefaultActiveRepoUrl $activeRepoCandidate + $GitCloneExitCode = $LASTEXITCODE + if ($GitCloneExitCode -ne 0) { + Die "failed to clone default proposal repo from $DefaultActiveRepoUrl to $activeRepoCandidate" + } + Say "Cloned default proposal repo to $activeRepoCandidate" + } + + try { + $resolved = (Resolve-Path -LiteralPath $activeRepoCandidate).ProviderPath + } catch { + Die "default active proposal repo path does not exist after clone: $activeRepoCandidate" + } + + if (-not (Test-Path -LiteralPath $resolved -PathType Container)) { + Die "default active proposal repo path exists but is not a directory: $resolved. Remove it or set ACTIVE_REPO_ROOT." + } + if (-not (Test-Path -LiteralPath (Join-Path -Path $resolved -ChildPath ".git"))) { + Die "default active proposal repo path exists but is not a git checkout: $resolved. Remove it or set ACTIVE_REPO_ROOT." + } + + return @{ + Path = $resolved + Explicit = $false + } +} + +function ConvertTo-PowerShellQuotedPath { + param([string]$Path) + + return "'{0}'" -f ($Path -replace "'", "''") +} + +function Get-DefaultInstallPaths { + if ([string]::IsNullOrWhiteSpace($env:LOCALAPPDATA)) { + Die "LOCALAPPDATA is not set; cannot determine the user-local install directory" + } + + $installDir = Join-Path -Path (Join-Path -Path $env:LOCALAPPDATA -ChildPath "build-eips") -ChildPath "bin" + $buildEipsPath = Join-Path -Path $installDir -ChildPath "build-eips.exe" + $zolaPath = Join-Path -Path $installDir -ChildPath "zola.exe" + + return @{ + InstallDir = $installDir + BuildEipsPath = $buildEipsPath + ZolaPath = $zolaPath + } +} + +$PathNotes = @() +$WorkspaceFlags = @() +if ($Template) { + $WorkspaceFlags += "--template" +} +if ($PlatformDev) { + $WorkspaceFlags += "--platform-dev" +} + +$InvocationDir = (Get-Location).ProviderPath +$ScriptDir = (Resolve-Path -LiteralPath $PSScriptRoot).ProviderPath +$ThemeRoot = (Resolve-Path -LiteralPath (Split-Path -Path $ScriptDir -Parent)).ProviderPath +$WorkspaceRoot = (Resolve-Path -LiteralPath (Split-Path -Path $ThemeRoot -Parent)).ProviderPath +$ActiveRepoInfo = Resolve-ActiveRepoRoot -InvocationDir $InvocationDir -WorkspaceRoot $WorkspaceRoot +$ActiveRepoRoot = $ActiveRepoInfo["Path"] +$ActiveRepoExplicit = $ActiveRepoInfo["Explicit"] + +$BuildEipsPath = Find-BuildEipsOnPath +if ($null -ne $BuildEipsPath) { + Say "Using existing build-eips at $BuildEipsPath" +} else { + $defaultPaths = Get-DefaultInstallPaths + $DefaultInstallDir = $defaultPaths.InstallDir + $DefaultBuildEipsPath = $defaultPaths.BuildEipsPath + + if (Test-Path -LiteralPath $DefaultBuildEipsPath -PathType Leaf) { + $BuildEipsPath = $DefaultBuildEipsPath + Move-DirectoryToFrontOfSessionPath -InstallDir $DefaultInstallDir + Say "Using existing build-eips at $BuildEipsPath" + } else { + $BuildEipsPath = Install-BuildEips -InstallDir $DefaultInstallDir -BuildEipsPath $DefaultBuildEipsPath + Move-DirectoryToFrontOfSessionPath -InstallDir $DefaultInstallDir + } +} + +$ZolaPath = Initialize-Zola + +Say "Theme repo: $ThemeRoot" +Say "Workspace root: $WorkspaceRoot" +Say "Active proposal repo: $ActiveRepoRoot" +Say "If PowerShell blocks this script, run:" +Say " powershell -ExecutionPolicy Bypass -File .\scripts\dev-setup.ps1" + +Say "Bootstrapping workspace at $WorkspaceRoot" +& $BuildEipsPath -C $ActiveRepoRoot init $WorkspaceRoot @WorkspaceFlags +$WorkspaceInitExitCode = $LASTEXITCODE +if ($WorkspaceInitExitCode -ne 0) { + Die "build-eips init failed with exit code $WorkspaceInitExitCode" +} + +Say "Running build-eips doctor" +& $BuildEipsPath -C $ActiveRepoRoot doctor +$WorkspaceDoctorExitCode = $LASTEXITCODE +if ($WorkspaceDoctorExitCode -ne 0) { + Say "Warning: build-eips doctor reported issues above. Fix them before relying on direct build-eips commands." +} + +$WorkspaceDocPath = Join-Path -Path $WorkspaceRoot -ChildPath "WORKSPACE.md" +Say "" +if (Test-Path -LiteralPath $WorkspaceDocPath -PathType Leaf) { + Say "Workspace docs: $WorkspaceDocPath (../WORKSPACE.md from this repo)" +} else { + Say "Warning: workspace docs were not found at $WorkspaceDocPath after build-eips init" +} + +if ($PathNotes.Count -gt 0) { + Say "" + Say 'Updated PATH for this PowerShell session only:' + foreach ($pathNote in $PathNotes) { + Say " $pathNote" + } + Say "To make this permanent, add the listed directory or directories to your user Path in Windows Environment Variables." +} + +Say "" +Say "Next commands:" +Say (" cd {0}" -f (ConvertTo-PowerShellQuotedPath -Path $ThemeRoot)) +Say (" build-eips -C {0} serve" -f (ConvertTo-PowerShellQuotedPath -Path $ActiveRepoRoot)) +Say (" build-eips -C {0} check" -f (ConvertTo-PowerShellQuotedPath -Path $ActiveRepoRoot)) +Say (" build-eips -C {0} doctor" -f (ConvertTo-PowerShellQuotedPath -Path $ActiveRepoRoot)) diff --git a/static/assets/scripts/proposal-popovers.js b/static/assets/scripts/proposal-popovers.js new file mode 100644 index 0000000..97b0da1 --- /dev/null +++ b/static/assets/scripts/proposal-popovers.js @@ -0,0 +1,506 @@ +(function () { + "use strict"; + + const scriptElement = document.currentScript; + const metadataPathSuffix = "/assets/data/proposals.json"; + const contentAnchorSelector = ".page-content a[href]"; + const popoverClass = "proposal-popover"; + const hideDelayMs = 150; + const activeAnchors = new WeakMap(); + const anchorHideTimers = new WeakMap(); + const anchorTargets = new WeakMap(); + const anchorPopovers = new WeakMap(); + const popoverInputEnabled = + !window.matchMedia || + window.matchMedia("(hover: hover) and (pointer: fine)").matches; + + let disabled = false; + let metadataPromise = null; + let proposalMetadata = null; + + const metadataUrl = resolveMetadataUrl(scriptElement); + const localBasePath = metadataUrl ? deriveLocalBasePath(metadataUrl) : "/"; + const currentPageIdentity = pageIdentityFromUrl( + new URL(window.location.href, document.baseURI), + ); + + function resolveMetadataUrl(script) { + const metadataUrlValue = script?.getAttribute("data-proposals-url"); + if (!metadataUrlValue) { + return null; + } + + try { + return new URL(metadataUrlValue, document.baseURI); + } catch (_error) { + return null; + } + } + + function deriveLocalBasePath(url) { + if (!url.pathname.endsWith(metadataPathSuffix)) { + return "/"; + } + + const basePath = url.pathname.slice( + 0, + url.pathname.length - metadataPathSuffix.length, + ); + + if (!basePath || basePath === "/") { + return "/"; + } + + return basePath.endsWith("/") ? basePath : `${basePath}/`; + } + + function disablePopovers() { + disabled = true; + metadataPromise = Promise.resolve(null); + proposalMetadata = null; + } + + function onReady(callback) { + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", callback, { once: true }); + } else { + callback(); + } + } + + function scanProposalLinks() { + if (!metadataUrl || !window.bootstrap?.Popover || !popoverInputEnabled) { + disablePopovers(); + return; + } + + for (const anchor of document.querySelectorAll(contentAnchorSelector)) { + if (isSamePageAnchor(anchor)) { + continue; + } + + const target = targetFromAnchor(anchor); + if (!target) { + continue; + } + + anchorTargets.set(anchor, target); + anchor.addEventListener("pointerenter", handlePointerEnter); + anchor.addEventListener("pointerleave", handlePointerLeave); + anchor.addEventListener("focusin", handleFocusIn); + anchor.addEventListener("focusout", handleFocusOut); + } + } + + function isSamePageAnchor(anchor) { + let url; + try { + url = new URL(anchor.href, document.baseURI); + } catch (_error) { + return false; + } + + const anchorIdentity = pageIdentityFromUrl(url); + return ( + anchorIdentity.origin === currentPageIdentity.origin && + anchorIdentity.pathname === currentPageIdentity.pathname + ); + } + + function pageIdentityFromUrl(url) { + return { + origin: url.origin, + pathname: normalizedPagePath( + stripLocalBasePath(url.pathname, url.origin), + ), + }; + } + + function normalizedPagePath(pathname) { + let path = pathname || "/"; + path = path.replace(/\/index\.html$/i, "/"); + if (!path.startsWith("/")) { + path = `/${path}`; + } + if (path.length > 1 && path.endsWith("/")) { + path = path.slice(0, -1); + } + + return path; + } + + function targetFromAnchor(anchor) { + const href = anchor.getAttribute("href")?.trim(); + if (!href || href.startsWith("#") || anchor.classList.contains("anchor-link")) { + return null; + } + + let url; + try { + url = new URL(anchor.href, document.baseURI); + } catch (_error) { + return null; + } + + url.search = ""; + url.hash = ""; + return targetFromUrl(url); + } + + function targetFromUrl(url) { + const publicPrefix = proposalPrefixForPublicHost(url.hostname); + const path = publicPrefix + ? url.pathname + : stripLocalBasePath(url.pathname, url.origin); + const explicitRoute = explicitRouteTarget(path); + if (explicitRoute) { + return explicitRoute; + } + + const number = numericRouteNumber(path); + if (!number) { + return null; + } + + if (publicPrefix) { + return { key: metadataKey(publicPrefix, number) }; + } + + return { number }; + } + + function proposalPrefixForPublicHost(hostname) { + const normalizedHost = hostname.toLowerCase(); + if (normalizedHost === "eips.ethereum.org") { + return "EIP"; + } + if (normalizedHost === "ercs.ethereum.org") { + return "ERC"; + } + return null; + } + + function stripLocalBasePath(pathname, origin) { + if (!metadataUrl || origin !== metadataUrl.origin || localBasePath === "/") { + return pathname; + } + + if (pathname === localBasePath.slice(0, -1)) { + return "/"; + } + + if (pathname.startsWith(localBasePath)) { + return `/${pathname.slice(localBasePath.length)}`; + } + + return pathname; + } + + function explicitRouteTarget(pathname) { + const match = pathname.match(/^\/(EIPS|ERCS)\/(eip|erc)-0*([1-9]\d*)\/?$/i); + if (!match) { + return null; + } + + const routePrefix = match[1].toUpperCase() === "ERCS" ? "ERC" : "EIP"; + return { key: metadataKey(routePrefix, Number(match[3])) }; + } + + function numericRouteNumber(pathname) { + const match = pathname.match(/^\/0*([1-9]\d*)\/?$/); + if (!match) { + return null; + } + + return Number(match[1]); + } + + function metadataKey(prefix, number) { + return `${prefix.toLowerCase()}-${number}`; + } + + function handlePointerEnter(event) { + const anchor = event.currentTarget; + clearPendingHide(anchor); + stateFor(anchor).hovered = true; + activatePopover(anchor); + } + + function handlePointerLeave(event) { + const anchor = event.currentTarget; + if (event.relatedTarget && anchor.contains(event.relatedTarget)) { + return; + } + + stateFor(anchor).hovered = false; + scheduleInactiveHide(anchor); + } + + function handleFocusIn(event) { + const anchor = event.currentTarget; + clearPendingHide(anchor); + stateFor(anchor).focused = true; + activatePopover(anchor); + } + + function handleFocusOut(event) { + const anchor = event.currentTarget; + if (event.relatedTarget && anchor.contains(event.relatedTarget)) { + return; + } + + stateFor(anchor).focused = false; + scheduleInactiveHide(anchor); + } + + function stateFor(anchor) { + let state = activeAnchors.get(anchor); + if (!state) { + state = { hovered: false, focused: false }; + activeAnchors.set(anchor, state); + } + + return state; + } + + function isAnchorActive(anchor) { + const state = activeAnchors.get(anchor); + return Boolean(state?.hovered || state?.focused); + } + + function hideInactivePopover(anchor) { + if (!isAnchorActive(anchor)) { + anchorPopovers.get(anchor)?.hide(); + } + } + + function clearPendingHide(anchor) { + const timer = anchorHideTimers.get(anchor); + if (!timer) { + return; + } + + window.clearTimeout(timer); + anchorHideTimers.delete(anchor); + } + + function scheduleInactiveHide(anchor) { + clearPendingHide(anchor); + anchorHideTimers.set( + anchor, + window.setTimeout(() => { + anchorHideTimers.delete(anchor); + hideInactivePopover(anchor); + }, hideDelayMs), + ); + } + + function activatePopover(anchor) { + if (disabled) { + return; + } + + loadProposalMetadata().then((metadata) => { + if (!metadata || disabled) { + return; + } + + const target = anchorTargets.get(anchor) || targetFromAnchor(anchor); + const record = target ? recordForTarget(metadata, target) : null; + if (!record) { + return; + } + + const popover = popoverForAnchor(anchor, record); + if (isAnchorActive(anchor)) { + popover.show(); + } + }); + } + + function loadProposalMetadata() { + if (disabled) { + return Promise.resolve(null); + } + if (proposalMetadata) { + return Promise.resolve(proposalMetadata); + } + if (metadataPromise) { + return metadataPromise; + } + + metadataPromise = fetch(metadataUrl.href, { credentials: "same-origin" }) + .then((response) => { + if (!response.ok) { + throw new Error(`proposal metadata request failed: ${response.status}`); + } + return response.json(); + }) + .then((payload) => { + proposalMetadata = parseProposalMetadata(payload); + return proposalMetadata; + }) + .catch((_error) => { + disablePopovers(); + return null; + }); + + return metadataPromise; + } + + function parseProposalMetadata(payload) { + if ( + !payload || + payload.schema_version !== 1 || + !["EIP", "ERC"].includes(payload.active_prefix) || + !payload.proposals || + typeof payload.proposals !== "object" + ) { + throw new Error("unsupported proposal metadata schema"); + } + + const recordsByKey = new Map(); + const recordsByNumber = new Map(); + + for (const [key, record] of Object.entries(payload.proposals)) { + const normalizedRecord = normalizeRecord(key, record); + if (!normalizedRecord) { + continue; + } + + recordsByKey.set(normalizedRecord.key, normalizedRecord); + const recordsForNumber = + recordsByNumber.get(normalizedRecord.number) || []; + recordsForNumber.push(normalizedRecord); + recordsByNumber.set(normalizedRecord.number, recordsForNumber); + } + + return { + activePrefix: payload.active_prefix, + recordsByKey, + recordsByNumber, + }; + } + + function normalizeRecord(key, record) { + if (!record || typeof record !== "object") { + return null; + } + + const number = Number(record.number); + const prefix = typeof record.prefix === "string" ? record.prefix.toUpperCase() : ""; + if ( + !Number.isInteger(number) || + number <= 0 || + !["EIP", "ERC"].includes(prefix) || + !nonEmptyString(record.title) || + !nonEmptyString(record.status) || + !nonEmptyString(record.type) + ) { + return null; + } + + return { + key: key.toLowerCase(), + number, + prefix, + title: record.title.trim(), + description: optionalString(record.description), + status: record.status.trim(), + type: record.type.trim(), + category: optionalString(record.category), + }; + } + + function nonEmptyString(value) { + return typeof value === "string" && value.trim() !== ""; + } + + function optionalString(value) { + return typeof value === "string" && value.trim() !== "" + ? value.trim() + : null; + } + + function recordForTarget(metadata, target) { + if (target.key) { + return metadata.recordsByKey.get(target.key) || null; + } + + const recordsForNumber = metadata.recordsByNumber.get(target.number) || []; + if (recordsForNumber.length === 1) { + return recordsForNumber[0]; + } + + return ( + metadata.recordsByKey.get(metadataKey(metadata.activePrefix, target.number)) || + null + ); + } + + function popoverForAnchor(anchor, record) { + const existing = anchorPopovers.get(anchor); + if (existing) { + return existing; + } + + const popover = new window.bootstrap.Popover(anchor, { + trigger: "manual", + html: true, + customClass: popoverClass, + placement: "auto", + container: document.body, + boundary: "viewport", + offset: [0, 8], + fallbackPlacements: ["top", "bottom", "right", "left"], + title: () => popoverTitle(record), + content: () => popoverContent(record), + }); + + anchorPopovers.set(anchor, popover); + return popover; + } + + function popoverTitle(record) { + const title = document.createElement("span"); + title.textContent = `${record.prefix}-${record.number}: ${record.title}`; + return title; + } + + function popoverContent(record) { + const body = document.createElement("div"); + body.className = "proposal-popover-body"; + + if (record.description) { + const description = document.createElement("p"); + description.className = "proposal-popover-description"; + description.textContent = record.description; + body.appendChild(description); + } + + const meta = document.createElement("div"); + meta.className = "proposal-popover-meta"; + meta.appendChild(metaItem("Status", record.status)); + meta.appendChild(metaItem("Type", record.type)); + if (record.category) { + meta.appendChild(metaItem("Category", record.category)); + } + body.appendChild(meta); + + return body; + } + + function metaItem(label, value) { + const item = document.createElement("span"); + item.className = "proposal-popover-meta-item"; + + const labelElement = document.createElement("strong"); + labelElement.textContent = label; + item.appendChild(labelElement); + item.appendChild(document.createTextNode(`: ${value}`)); + + return item; + } + + onReady(scanProposalLinks); +})(); diff --git a/templates/base.html b/templates/base.html index 22155e5..cec0f46 100644 --- a/templates/base.html +++ b/templates/base.html @@ -106,6 +106,11 @@ ); }); +