Commit Diff


commit - /dev/null
commit + d04a1dedc45b2aabb2ec9bae57604939a196b9be
blob - /dev/null
blob + 5ae953572dda98e3229e9c58710e2d760e76ee88 (mode 644)
--- /dev/null
+++ .gitignore
@@ -0,0 +1,4 @@
+wp
+doc/
+site/
+ports-dump.txt
blob - /dev/null
blob + df99c69198f5813df5fc3eaa007a2af0e60a7bbd (mode 644)
--- /dev/null
+++ .rustfmt.toml
@@ -0,0 +1 @@
+max_width = 80
blob - /dev/null
blob + 2bab1c68d626762f3cb3a1c9c29d26c9e493626e (mode 644)
--- /dev/null
+++ LICENSE
@@ -0,0 +1,13 @@
+Copyright (c) 2024-2026 Murilo Ijanc' <murilo@ijanc.org>
+
+Permission to use, copy, modify, and/or distribute this software for any
+purpose with or without fee is hereby granted, provided that the above
+copyright notice and this permission notice appear in all copies.
+
+THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
blob - /dev/null
blob + b2cffd8e3bd22a9389aa0b7af69c88328e6a4907 (mode 644)
--- /dev/null
+++ Makefile
@@ -0,0 +1,53 @@
+PREFIX = /usr/local
+MANDIR = ${PREFIX}/man
+BIN = wp
+SRC = wp.rs
+MAN = wp.1
+
+all: ${BIN}
+
+${BIN}: ${SRC}
+	rustc -O -o ${BIN} ${SRC}
+	strip ${BIN}
+
+dev: ${SRC}
+	rustc -o ${BIN} ${SRC}
+
+ci:
+	rustfmt --check ${SRC}
+	clippy-driver ${SRC}
+	mandoc -T lint -W warning ${MAN}
+
+doc:
+	mandoc -T ascii ${MAN}
+
+rustdoc:
+	rustdoc ${SRC} --edition 2021 --document-private-items \
+		-o doc
+
+SITE = site
+PORT = 8080
+PORTSDIR = /usr/ports
+
+cgi: dev
+	./${BIN} -p ${PORTSDIR} -o ${SITE}
+	mkdir -p ${SITE}/cgi-bin
+	cp ${BIN} ${SITE}/cgi-bin/search.cgi
+	cp ${SITE}/INDEX.txt ${SITE}/cgi-bin/
+	sed -i 's|<!-- SEARCH -->|<h2>Search</h2>\n<form action="/cgi-bin/search.cgi" method="get">\n<input type="text" name="q" size="40">\n<input type="submit" value="Search">\n</form>|' ${SITE}/index.html
+	@echo "http://localhost:${PORT}"
+	cd ${SITE} && python3 -m http.server --cgi ${PORT}
+
+install: ${BIN}
+	install -m 755 ${BIN} ${PREFIX}/bin/${BIN}
+	install -m 644 ${MAN} ${MANDIR}/man1/${MAN}
+
+uninstall:
+	rm -f ${PREFIX}/bin/${BIN}
+	rm -f ${MANDIR}/man1/${MAN}
+
+clean:
+	rm -f ${BIN}
+	rm -rf doc
+
+.PHONY: all dev ci doc rustdoc cgi install uninstall clean
blob - /dev/null
blob + a8ebfe98bfe3795581d748c9128fdeb0e9810fe1 (mode 644)
--- /dev/null
+++ README.md
@@ -0,0 +1,78 @@
+wp - where OpenBSD ports
+========================
+wp generates a static HTML site for browsing OpenBSD ports.
+No JavaScript, works in lynx. Includes built-in CGI search.
+
+Requirements
+------------
+- rustc
+
+Building
+--------
+Edit Makefile to match your local setup (wp is installed
+into the /usr/local/bin namespace by default).
+
+Afterwards enter the following command to build and install
+wp (if necessary as root):
+
+    make
+    make install
+
+Running
+-------
+    wp -p /usr/ports -o /var/www/htdocs/ports
+
+Options:
+  -p portsdir   path to ports tree (default: /usr/ports)
+  -o outdir     output directory (default: ./site)
+  -d dump       use dump-vars output instead of parsing Makefiles
+
+Search
+------
+wp has built-in CGI support. When the QUERY_STRING environment
+variable is set, wp acts as a CGI program: it reads INDEX.txt
+from the current directory and returns search results as HTML.
+
+The search matches port names, descriptions, and maintainers.
+
+CGI setup with httpd(8)
+-----------------------
+Generate the site:
+
+    wp -p /usr/ports -o /var/www/htdocs/ports
+
+Copy the binary and INDEX.txt into the chroot:
+
+    cp /usr/local/bin/wp /var/www/cgi-bin/search.cgi
+    cp /var/www/htdocs/ports/INDEX.txt /var/www/cgi-bin/INDEX.txt
+
+OpenBSD does not support static binaries. Copy the required
+shared libraries into the chroot:
+
+    mkdir -p /var/www/usr/lib /var/www/usr/libexec
+    cp /usr/lib/libc.so.* /var/www/usr/lib/
+    cp /usr/lib/libm.so.* /var/www/usr/lib/
+    cp /usr/lib/libpthread.so.* /var/www/usr/lib/
+    cp /usr/lib/libc++abi.so.* /var/www/usr/lib/
+    cp /usr/libexec/ld.so /var/www/usr/libexec/
+
+Configure httpd.conf(5):
+
+    server "ports.example.com" {
+        listen on * port 80
+        root "/htdocs/ports"
+        location "/search.cgi" {
+            fastcgi socket "/run/slowcgi.sock"
+            root "/"
+        }
+    }
+
+Enable and start slowcgi(8):
+
+    rcctl enable slowcgi
+    rcctl start slowcgi
+    rcctl reload httpd
+
+License
+-------
+ISC - see LICENSE.
blob - /dev/null
blob + 3334a3d1e3c670b8e52e0c00cd96e8b42847e6e0 (mode 644)
Binary files /dev/null and assets/favicon.ico differ
blob - /dev/null
blob + b138bf5ca044cf2046d1f2fe7992e394653502c1 (mode 644)
Binary files /dev/null and assets/logo.png differ
blob - /dev/null
blob + db80dd8ec83f1da43955e0925013d38a9b854218 (mode 644)
--- /dev/null
+++ wp.1
@@ -0,0 +1,167 @@
+.\"
+.\" Copyright (c) 2024-2026 Murilo Ijanc' <murilo@ijanc.org>
+.\"
+.\" Permission to use, copy, modify, and/or distribute this software for any
+.\" purpose with or without fee is hereby granted, provided that the above
+.\" copyright notice and this permission notice appear in all copies.
+.\"
+.\" THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+.\" WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+.\" MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+.\" ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+.\" WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+.\" ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+.\" OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+.\"
+.Dd $Mdocdate: April 6 2026 $
+.Dt WP 1
+.Os
+.Sh NAME
+.Nm wp
+.Nd where OpenBSD ports
+.Sh SYNOPSIS
+.Nm wp
+.Op Fl d Ar dump
+.Op Fl o Ar outdir
+.Op Fl p Ar portsdir
+.Sh DESCRIPTION
+.Nm
+generates a static HTML site for browsing
+.Ox
+ports.
+The generated pages contain no JavaScript and work in text browsers
+such as
+.Xr lynx 1 .
+.Pp
+When the
+.Ev QUERY_STRING
+environment variable is set,
+.Nm
+operates as a CGI program: it reads
+.Pa INDEX.txt
+from the current directory, searches for the query term, and writes
+HTML results to standard output.
+.Pp
+The options are as follows:
+.Bl -tag -width Ds
+.It Fl d Ar dump
+Load port data from a
+.Xr make 1
+.Cm dump-vars
+output file instead of parsing Makefiles directly.
+.It Fl o Ar outdir
+Write generated files to
+.Ar outdir .
+The default is
+.Pa ./site .
+.It Fl p Ar portsdir
+Path to the ports tree.
+The default is
+.Pa /usr/ports .
+.El
+.Sh OUTPUT
+.Nm
+generates the following files in
+.Ar outdir :
+.Bl -tag -width "categories/"
+.It Pa index.html
+Main page with search form, alphabetical index, and category listing.
+.It Pa categories/
+One HTML file per category listing all ports in that category.
+.It Pa ports/
+One HTML file per port with description, homepage, maintainer,
+dependencies, and sub-packages.
+.It Pa search/
+Alphabetical index pages from A to Z.
+.It Pa INDEX.txt
+Tab-separated index used by CGI search.
+Format: path, pkgname, comment, maintainer.
+.It Pa favicon.ico , Pa logo.png
+Static assets embedded in the binary.
+.El
+.Sh CGI
+To use
+.Nm
+as a CGI program with
+.Xr httpd 8 ,
+copy the binary and
+.Pa INDEX.txt
+into the chroot:
+.Bd -literal -offset indent
+cp /usr/local/bin/wp /var/www/cgi-bin/search.cgi
+cp /var/www/htdocs/ports/INDEX.txt /var/www/cgi-bin/
+.Ed
+.Pp
+Since
+.Ox
+does not support static binaries, the required shared libraries
+must be copied into the chroot:
+.Bd -literal -offset indent
+mkdir -p /var/www/usr/lib /var/www/usr/libexec
+cp /usr/lib/libc.so.* /var/www/usr/lib/
+cp /usr/lib/libm.so.* /var/www/usr/lib/
+cp /usr/lib/libpthread.so.* /var/www/usr/lib/
+cp /usr/lib/libc++abi.so.* /var/www/usr/lib/
+cp /usr/libexec/ld.so /var/www/usr/libexec/
+.Ed
+.Pp
+Configure
+.Xr httpd.conf 5 :
+.Bd -literal -offset indent
+server "ports.example.com" {
+	listen on * port 80
+	root "/htdocs/ports"
+	location "/search.cgi" {
+		fastcgi socket "/run/slowcgi.sock"
+		root "/"
+	}
+}
+.Ed
+.Pp
+Enable
+.Xr slowcgi 8 :
+.Bd -literal -offset indent
+# rcctl enable slowcgi
+# rcctl start slowcgi
+# rcctl reload httpd
+.Ed
+.Sh ENVIRONMENT
+.Bl -tag -width "QUERY_STRING"
+.It Ev QUERY_STRING
+When set,
+.Nm
+operates in CGI mode.
+The query parameter
+.Cm q
+is used as the search term.
+.El
+.Sh FILES
+.Bl -tag -width "/usr/ports"
+.It Pa /usr/ports
+Default ports tree location.
+.El
+.Sh EXIT STATUS
+.Ex -std wp
+.Sh EXAMPLES
+Generate a site from the ports tree:
+.Bd -literal -offset indent
+$ wp -p /usr/ports -o /var/www/htdocs/ports
+.Ed
+.Pp
+Generate a site from a dump-vars file:
+.Bd -literal -offset indent
+$ wp -d ports-dump.txt -p /usr/ports -o /var/www/htdocs/ports
+.Ed
+.Pp
+Test CGI search locally:
+.Bd -literal -offset indent
+$ cd /var/www/htdocs/ports
+$ QUERY_STRING="q=curl" /usr/local/bin/wp
+.Ed
+.Sh SEE ALSO
+.Xr httpd.conf 5 ,
+.Xr ports 7 ,
+.Xr httpd 8 ,
+.Xr slowcgi 8
+.Sh AUTHORS
+.An Murilo Ijanc' Aq Mt murilo@ijanc.org
blob - /dev/null
blob + a636ec040dd42996f384f3d9894e6b126d0f2f53 (mode 644)
--- /dev/null
+++ wp.rs
@@ -0,0 +1,1106 @@
+// vim: set tw=79 cc=80 ts=4 sw=4 sts=4 et :
+//
+// Copyright (c) 2024-2026 Murilo Ijanc' <murilo@ijanc.org>
+//
+// Permission to use, copy, modify, and/or distribute this software for any
+// purpose with or without fee is hereby granted, provided that the above
+// copyright notice and this permission notice appear in all copies.
+//
+// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+// WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+// MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+// ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+// WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+// ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+// OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+//
+use std::{
+    collections::HashMap,
+    fs,
+    io::Write,
+    path::{Path, PathBuf},
+};
+
+const VERSION: &str = "0.1.0";
+const NAME: &str = "wp";
+const FAVICON: &[u8] = include_bytes!("assets/favicon.ico");
+const LOGO: &[u8] = include_bytes!("assets/logo.png");
+const CSS: &str = "\
+body{font-family:monospace;max-width:72em;margin:0 auto;padding:1em;\
+color:#222;background:#fff}\
+h1,h2{margin:.5em 0}\
+table{border-collapse:collapse;width:100%}\
+th,td{text-align:left;padding:.2em .5em;border-bottom:1px solid #ccc}\
+th{border-bottom:2px solid #333}\
+a{color:#00e}a:visited{color:#551a8b}\
+pre{overflow-x:auto}\
+footer{color:#666;font-size:.9em;margin-top:1em}\
+img.logo{width:4em;height:4em;vertical-align:middle;margin-right:.5em}\
+@media(prefers-color-scheme:dark){\
+body{color:#ddd;background:#1a1a1a}\
+th{border-bottom-color:#888}\
+th,td{border-bottom-color:#444}\
+a{color:#6bf}a:visited{color:#c9f}\
+footer{color:#888}\
+hr{border-color:#444}}\
+";
+
+struct Port {
+    name: String,
+    category: String,
+    comment: String,
+    descr: String,
+    homepage: String,
+    maintainer: String,
+    categories: Vec<String>,
+    distname: String,
+    pkgname: String,
+    lib_depends: Vec<String>,
+    run_depends: Vec<String>,
+    build_depends: Vec<String>,
+    multi_packages: Vec<String>,
+    sub_comments: HashMap<String, String>,
+}
+
+fn apply_modifier(val: &str, modifier: &str) -> String {
+    if modifier == "R" {
+        // :R - root (strip extension)
+        val.rsplit_once('.')
+            .map(|(r, _)| r.to_string())
+            .unwrap_or_else(|| val.to_string())
+    } else if modifier == "E" {
+        // :E - extension
+        val.rsplit_once('.')
+            .map(|(_, e)| e.to_string())
+            .unwrap_or_default()
+    } else if modifier == "L" {
+        // :L - lowercase
+        val.to_lowercase()
+    } else if modifier == "U" {
+        // :U - uppercase
+        val.to_uppercase()
+    } else if let Some(rest) = modifier.strip_prefix("S/") {
+        // :S/old/new/ - substitute first
+        if let Some((old, tail)) = rest.split_once('/') {
+            let new = tail.trim_end_matches('/');
+            val.replacen(old, new, 1)
+        } else {
+            val.to_string()
+        }
+    } else if let Some(rest) = modifier.strip_prefix("C/") {
+        // :C/pat/rep/ - substitute all (simplified, no regex)
+        if let Some((old, tail)) = rest.split_once('/') {
+            let new = tail.trim_end_matches('/');
+            val.replace(old, new)
+        } else {
+            val.to_string()
+        }
+    } else {
+        val.to_string()
+    }
+}
+
+fn expand_vars(s: &str, vars: &HashMap<String, String>) -> String {
+    let mut result = s.to_string();
+    for _ in 0..8 {
+        let prev = result.clone();
+        let mut out = String::new();
+        let mut chars = result.as_str();
+
+        while let Some(pos) = chars.find("${") {
+            out.push_str(&chars[..pos]);
+            let rest = &chars[pos + 2..];
+            let Some(end) = rest.find('}') else {
+                out.push_str(&chars[pos..]);
+                chars = "";
+                break;
+            };
+            let expr = &rest[..end];
+            let (varname, modifiers) =
+                if let Some((v, m)) = expr.split_once(':') {
+                    (v, Some(m))
+                } else {
+                    (expr, None)
+                };
+
+            if let Some(val) = vars.get(varname) {
+                let val = if let Some(mods) = modifiers {
+                    if val.contains("${") {
+                        out.push_str(&format!("${{{}}}", expr));
+                        chars = &rest[end + 1..];
+                        continue;
+                    }
+                    let mut v = val.clone();
+                    for m in mods.split(':') {
+                        v = apply_modifier(&v, m);
+                    }
+                    v
+                } else {
+                    val.clone()
+                };
+                out.push_str(&val);
+            } else {
+                out.push_str(&format!("${{{}}}", expr));
+            }
+            chars = &rest[end + 1..];
+        }
+        out.push_str(chars);
+        result = out;
+
+        if result == prev {
+            break;
+        }
+    }
+    result
+}
+
+type ModCache = HashMap<String, Vec<(String, String)>>;
+
+fn load_module(
+    module: &str,
+    portsdir: &Path,
+    cache: &mut ModCache,
+) -> Option<()> {
+    if cache.contains_key(module) {
+        return Some(());
+    }
+
+    let modname = module.split('/').next_back().unwrap_or(module);
+    let mk_path = portsdir.join(module).join(format!("{}.port.mk", modname));
+    let mk_path = if mk_path.exists() {
+        mk_path
+    } else {
+        let alt = portsdir
+            .join("infrastructure/mk")
+            .join(format!("{}.port.mk", modname));
+        if alt.exists() {
+            alt
+        } else {
+            cache.insert(module.to_string(), Vec::new());
+            return None;
+        }
+    };
+
+    let text = fs::read_to_string(&mk_path).ok()?;
+    let mut modvars = Vec::new();
+
+    for line in text.lines() {
+        let line = line.trim();
+        if line.starts_with('#')
+            || line.starts_with('.')
+            || !line.contains("MOD")
+        {
+            continue;
+        }
+        if let Some(pos) = line.find("?=") {
+            let k = line[..pos].trim();
+            if k.starts_with("MOD") {
+                let v = line[pos + 2..].trim();
+                modvars.push((k.to_string(), v.to_string()));
+            }
+        } else if let Some(pos) = line.find('=') {
+            if line[..pos].ends_with('+') {
+                continue;
+            }
+            let k = line[..pos].trim();
+            if k.starts_with("MOD") {
+                let v = line[pos + 1..].trim();
+                let v = v.trim_end_matches('\\').trim();
+                modvars.push((k.to_string(), v.to_string()));
+            }
+        }
+    }
+
+    cache.insert(module.to_string(), modvars);
+    Some(())
+}
+
+fn resolve_modules(
+    vars: &mut HashMap<String, String>,
+    portsdir: &Path,
+    cache: &mut ModCache,
+) {
+    let modules = vars.get("MODULES").cloned().unwrap_or_default();
+
+    for module in modules.split_whitespace() {
+        load_module(module, portsdir, cache);
+        if let Some(modvars) = cache.get(module) {
+            for (k, v) in modvars {
+                vars.entry(k.clone()).or_insert_with(|| v.clone());
+            }
+        }
+    }
+}
+
+fn parse_makefile(path: &Path) -> Option<HashMap<String, String>> {
+    let text = fs::read_to_string(path).ok()?;
+    let mut vars: HashMap<String, String> = HashMap::new();
+    let mut continued = String::new();
+    let mut cont_key = String::new();
+
+    for line in text.lines() {
+        let line = line.trim_end();
+
+        if !continued.is_empty() {
+            let part = line.trim_start();
+            if part.ends_with('\\') {
+                continued.push(' ');
+                continued.push_str(part.trim_end_matches('\\').trim());
+            } else {
+                continued.push(' ');
+                continued.push_str(part);
+                vars.insert(cont_key.clone(), continued.trim().to_string());
+                continued.clear();
+                cont_key.clear();
+            }
+            continue;
+        }
+
+        if line.starts_with('#')
+            || line.starts_with('.')
+            || line.starts_with('\t')
+        {
+            continue;
+        }
+
+        let (key, val) = if let Some(pos) = line.find("+=") {
+            let k = line[..pos].trim();
+            let v = line[pos + 2..].trim();
+            let prev = vars.get(k).cloned().unwrap_or_default();
+            (
+                k.to_string(),
+                format!("{} {}", prev, v.trim_end_matches('\\').trim()),
+            )
+        } else if let Some(pos) = line.find("?=") {
+            let k = line[..pos].trim();
+            if vars.contains_key(k) {
+                continue;
+            }
+            let v = line[pos + 2..].trim();
+            (k.to_string(), v.trim_end_matches('\\').trim().to_string())
+        } else if let Some(pos) = line.find('=') {
+            let k = line[..pos].trim();
+            let v = line[pos + 1..].trim();
+            (k.to_string(), v.trim_end_matches('\\').trim().to_string())
+        } else {
+            continue;
+        };
+
+        if line.ends_with('\\') {
+            continued = val;
+            cont_key = key;
+        } else {
+            vars.insert(key, val);
+        }
+    }
+
+    if !cont_key.is_empty() {
+        vars.insert(cont_key, continued.trim().to_string());
+    }
+
+    Some(vars)
+}
+
+fn port_from_makefile(
+    category: &str,
+    portname: &str,
+    portdir: &Path,
+    portsdir: &Path,
+    modcache: &mut ModCache,
+) -> Option<Port> {
+    let mf = portdir.join("Makefile");
+    let mut vars = parse_makefile(&mf)?;
+
+    resolve_modules(&mut vars, portsdir, modcache);
+
+    let get = |k: &str| -> String {
+        let raw = vars.get(k).cloned().unwrap_or_default();
+        expand_vars(&raw, &vars)
+    };
+
+    let mut comment = get("COMMENT");
+    if comment.is_empty() {
+        comment = get("COMMENT-main");
+    }
+    if comment.is_empty() {
+        return None;
+    }
+
+    let descr_path = portdir.join("pkg").join("DESCR");
+    let descr = fs::read_to_string(&descr_path).unwrap_or_default();
+
+    let categories: Vec<String> = get("CATEGORIES")
+        .split_whitespace()
+        .map(String::from)
+        .collect();
+
+    let split_deps = |val: &str| -> Vec<String> {
+        val.split_whitespace()
+            .map(|s| expand_vars(s, &vars))
+            .filter(|s| !s.is_empty() && !s.contains("${"))
+            .collect()
+    };
+
+    let multi_packages: Vec<String> = get("MULTI_PACKAGES")
+        .split_whitespace()
+        .map(String::from)
+        .collect();
+
+    let mut sub_comments = HashMap::new();
+    for sub in &multi_packages {
+        let key = format!("COMMENT{}", sub);
+        if let Some(val) = vars.get(&key) {
+            sub_comments.insert(sub.clone(), expand_vars(val, &vars));
+        }
+    }
+
+    Some(Port {
+        name: portname.to_string(),
+        category: category.to_string(),
+        comment,
+        descr,
+        homepage: get("HOMEPAGE"),
+        maintainer: get("MAINTAINER"),
+        categories,
+        distname: get("DISTNAME"),
+        pkgname: get("PKGNAME"),
+        lib_depends: split_deps(&get("LIB_DEPENDS")),
+        run_depends: split_deps(&get("RUN_DEPENDS")),
+        build_depends: split_deps(&get("BUILD_DEPENDS")),
+        multi_packages,
+        sub_comments,
+    })
+}
+
+fn port_from_vars(
+    path: &str,
+    vars: &HashMap<String, String>,
+    portsdir: Option<&Path>,
+) -> Option<Port> {
+    let (category, name) = path.split_once('/')?;
+    let comment = vars.get("COMMENT").filter(|s| !s.is_empty())?;
+
+    let descr = if let Some(pd) = portsdir {
+        let descr_path = pd.join(path).join("pkg").join("DESCR");
+        fs::read_to_string(&descr_path).unwrap_or_default()
+    } else {
+        String::new()
+    };
+
+    let split = |k: &str| -> Vec<String> {
+        vars.get(k)
+            .map(|v| v.split_whitespace().map(String::from).collect())
+            .unwrap_or_default()
+    };
+
+    Some(Port {
+        name: name.to_string(),
+        category: category.to_string(),
+        comment: comment.clone(),
+        descr,
+        homepage: vars.get("HOMEPAGE").cloned().unwrap_or_default(),
+        maintainer: vars.get("MAINTAINER").cloned().unwrap_or_default(),
+        categories: split("CATEGORIES"),
+        distname: String::new(),
+        pkgname: vars.get("FULLPKGNAME").cloned().unwrap_or_default(),
+        lib_depends: split("LIB_DEPENDS"),
+        run_depends: split("RUN_DEPENDS"),
+        build_depends: split("BUILD_DEPENDS"),
+        multi_packages: split("MULTI_PACKAGES"),
+        sub_comments: HashMap::new(),
+    })
+}
+
+fn load_dump(path: &Path, portsdir: Option<&Path>) -> Vec<Port> {
+    let text = fs::read_to_string(path).unwrap_or_else(|e| errio(path, &e));
+
+    let mut by_port: HashMap<String, HashMap<String, String>> = HashMap::new();
+
+    for line in text.lines() {
+        // format: category/port.VARIABLE=value
+        let Some(dot) = line.find('.') else {
+            continue;
+        };
+        let portpath = &line[..dot];
+        let rest = &line[dot + 1..];
+        let Some(eq) = rest.find('=') else {
+            continue;
+        };
+        let var = &rest[..eq];
+        let val = &rest[eq + 1..];
+        by_port
+            .entry(portpath.to_string())
+            .or_default()
+            .insert(var.to_string(), val.to_string());
+    }
+
+    let mut ports = Vec::new();
+    let mut paths: Vec<_> = by_port.keys().cloned().collect();
+    paths.sort();
+
+    for path in paths {
+        let vars = &by_port[&path];
+        if let Some(port) = port_from_vars(&path, vars, portsdir) {
+            ports.push(port);
+        }
+    }
+
+    ports
+}
+
+fn walk_ports(portsdir: &Path) -> Vec<Port> {
+    let mut ports = Vec::new();
+    let mut modcache = ModCache::new();
+    let skip = [
+        "infrastructure",
+        "tests",
+        ".git",
+        ".gitignore",
+        ".cvsignore",
+    ];
+
+    let mut cats: Vec<_> = fs::read_dir(portsdir)
+        .unwrap_or_else(|e| errio(portsdir, &e))
+        .filter_map(|e| e.ok())
+        .filter(|e| e.file_type().map(|t| t.is_dir()).unwrap_or(false))
+        .filter(|e| {
+            let n = e.file_name();
+            !skip.contains(&n.to_str().unwrap_or(""))
+        })
+        .collect();
+    cats.sort_by_key(|e| e.file_name());
+
+    for cat in cats {
+        let catname = cat.file_name().to_string_lossy().to_string();
+        let mut entries: Vec<_> = match fs::read_dir(cat.path()) {
+            Ok(rd) => rd
+                .filter_map(|e| e.ok())
+                .filter(|e| e.file_type().map(|t| t.is_dir()).unwrap_or(false))
+                .collect(),
+            Err(_) => continue,
+        };
+        entries.sort_by_key(|e| e.file_name());
+
+        for entry in entries {
+            let portname = entry.file_name().to_string_lossy().to_string();
+            if let Some(port) = port_from_makefile(
+                &catname,
+                &portname,
+                &entry.path(),
+                portsdir,
+                &mut modcache,
+            ) {
+                ports.push(port);
+            }
+        }
+    }
+
+    ports
+}
+
+fn head(title: &str, depth: usize, extra: &str) -> String {
+    let rel = "../".repeat(depth);
+    format!(
+        "<!DOCTYPE html>\n<html>\n<head>\
+         <meta charset=\"utf-8\">\
+         <meta name=\"viewport\" \
+         content=\"width=device-width,initial-scale=1\">\
+         <title>{}</title>\
+         <link rel=\"icon\" href=\"{}favicon.ico\">\
+         {}\
+         <style>{}</style></head>\n<body>\n",
+        html_escape(title),
+        rel,
+        extra,
+        CSS
+    )
+}
+
+fn port_meta(p: &Port, depth: usize) -> String {
+    let rel = "../".repeat(depth);
+    let keywords: Vec<&str> = p.categories.iter().map(|s| s.as_str()).collect();
+    format!(
+        "<meta name=\"description\" content=\"{}\">\
+         <meta name=\"author\" content=\"{}\">\
+         <meta name=\"keywords\" content=\"{}\">\
+         <meta property=\"og:title\" content=\"{}\">\
+         <meta property=\"og:description\" content=\"{}\">\
+         <meta property=\"og:type\" content=\"website\">\
+         <meta property=\"og:image\" content=\"{}logo.png\">",
+        html_escape(&p.comment),
+        html_escape(&p.maintainer),
+        html_escape(&keywords.join(", ")),
+        html_escape(&format!("{}/{}", p.category, p.name)),
+        html_escape(&p.comment),
+        rel,
+    )
+}
+
+fn html_escape(s: &str) -> String {
+    if !s.contains(&['&', '<', '>', '"'][..]) {
+        return s.to_string();
+    }
+    let mut out = String::with_capacity(s.len() + 8);
+    for c in s.chars() {
+        match c {
+            '&' => out.push_str("&amp;"),
+            '<' => out.push_str("&lt;"),
+            '>' => out.push_str("&gt;"),
+            '"' => out.push_str("&quot;"),
+            _ => out.push(c),
+        }
+    }
+    out
+}
+
+fn footer(total: usize) -> String {
+    let date = {
+        let now = std::time::SystemTime::now();
+        let dur = now
+            .duration_since(std::time::UNIX_EPOCH)
+            .unwrap_or_default();
+        let secs = dur.as_secs() as i64;
+        let days = secs / 86400;
+        // days since 1970-01-01, convert to y-m-d
+        let (y, m, d) = days_to_ymd(days);
+        format!("{:04}-{:02}-{:02}", y, m, d)
+    };
+    format!(
+        "<hr>\n<footer>{} {} | {} ports | {}</footer>\n",
+        NAME, VERSION, total, date
+    )
+}
+
+fn days_to_ymd(days: i64) -> (i64, u32, u32) {
+    // algorithm from http://howardhinnant.github.io/date_algorithms.html
+    let z = days + 719468;
+    let era = if z >= 0 { z } else { z - 146096 } / 146097;
+    let doe = (z - era * 146097) as u32;
+    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
+    let y = (yoe as i64) + era * 400;
+    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
+    let mp = (5 * doy + 2) / 153;
+    let d = doy - (153 * mp + 2) / 5 + 1;
+    let m = if mp < 10 { mp + 3 } else { mp - 9 };
+    let y = if m <= 2 { y + 1 } else { y };
+    (y, m, d)
+}
+
+fn dep_link(dep: &str) -> String {
+    // dependency format: "category/port" or "pkg:category/port"
+    // or "category/port,subpkg" or "category/port>=version"
+    let dep = dep.split(':').next_back().unwrap_or(dep);
+    let path = dep.split(['>', '<', '=']).next().unwrap_or(dep);
+    let path = path.trim_end_matches(',');
+
+    if let Some((cat, port)) = path.split_once('/') {
+        let port = port.split(',').next().unwrap_or(port);
+        format!(
+            "<a href=\"../{}/{}.html\">{}</a>",
+            html_escape(cat),
+            html_escape(port),
+            html_escape(dep)
+        )
+    } else {
+        html_escape(dep)
+    }
+}
+
+fn write_file(path: &Path, content: &str) {
+    let mut f = fs::File::create(path).unwrap_or_else(|e| errio(path, &e));
+    if let Err(e) = f.write_all(content.as_bytes()) {
+        errio(path, &e);
+    }
+}
+
+fn copy_assets(outdir: &Path) {
+    let fav = outdir.join("favicon.ico");
+    if let Err(e) = fs::write(&fav, FAVICON) {
+        errio(&fav, &e);
+    }
+    let logo = outdir.join("logo.png");
+    if let Err(e) = fs::write(&logo, LOGO) {
+        errio(&logo, &e);
+    }
+}
+
+fn write_index(ports: &[Port], outdir: &Path, ft: &str) {
+    let mut cats: HashMap<&str, usize> = HashMap::new();
+    for p in ports {
+        *cats.entry(&p.category).or_default() += 1;
+    }
+    let mut sorted: Vec<_> = cats.into_iter().collect();
+    sorted.sort_by_key(|&(k, _)| k);
+
+    let mut html = head("OpenBSD Ports", 0, "");
+    html.push_str(
+        "<h1><img class=\"logo\" src=\"logo.png\" \
+         alt=\"\">OpenBSD Ports</h1>\n",
+    );
+
+    html.push_str("<!-- SEARCH -->\n");
+
+    html.push_str("<h2>Alphabetical Index</h2>\n<p>\n");
+    for c in b'a'..=b'z' {
+        let c = c as char;
+        html.push_str(&format!(
+            "<a href=\"search/{}.html\">{}</a> \n",
+            c,
+            c.to_ascii_uppercase()
+        ));
+    }
+    html.push_str("</p>\n");
+
+    html.push_str("<h2>Categories</h2>\n<ul>\n");
+    for (cat, count) in &sorted {
+        html.push_str(&format!(
+            "<li><a href=\"categories/{}.html\">{}</a> \
+             ({})</li>\n",
+            cat, cat, count
+        ));
+    }
+    html.push_str("</ul>\n");
+
+    html.push_str(ft);
+    html.push_str("</body>\n</html>\n");
+
+    write_file(&outdir.join("index.html"), &html);
+}
+
+fn write_categories(ports: &[Port], outdir: &Path, ft: &str) {
+    let mut by_cat: HashMap<&str, Vec<&Port>> = HashMap::new();
+    for p in ports {
+        by_cat.entry(&p.category).or_default().push(p);
+    }
+
+    let catdir = outdir.join("categories");
+
+    for (cat, mut cat_ports) in by_cat {
+        cat_ports.sort_by(|a, b| a.name.cmp(&b.name));
+
+        let mut html = head(&format!("{} - OpenBSD Ports", cat), 1, "");
+        html.push_str(&format!(
+            "<h1>{}</h1>\n\
+             <p><a href=\"../index.html\">Index</a></p>\n\
+             <table>\n<tr><th>Port</th><th>Description</th>\
+             </tr>\n",
+            cat
+        ));
+
+        for p in &cat_ports {
+            html.push_str(&format!(
+                "<tr><td><a href=\"../ports/{}/{}.html\">\
+                 {}</a></td><td>{}</td></tr>\n",
+                html_escape(&p.category),
+                html_escape(&p.name),
+                html_escape(&p.name),
+                html_escape(&p.comment)
+            ));
+        }
+
+        html.push_str("</table>\n");
+        html.push_str(ft);
+        html.push_str("</body>\n</html>\n");
+
+        write_file(&catdir.join(format!("{}.html", cat)), &html);
+    }
+}
+
+fn write_port_pages(ports: &[Port], outdir: &Path, ft: &str) {
+    for p in ports {
+        let meta = port_meta(p, 2);
+        let mut html = head(
+            &format!("{}/{} - OpenBSD Ports", &p.category, &p.name),
+            2,
+            &meta,
+        );
+
+        let display_name = if !p.pkgname.is_empty() && !p.pkgname.contains("${")
+        {
+            &p.pkgname
+        } else if !p.distname.is_empty() && !p.distname.contains("${") {
+            &p.distname
+        } else {
+            &p.name
+        };
+        html.push_str(&format!("<h1>{}</h1>\n", html_escape(display_name)));
+
+        html.push_str(&format!("<p>{}</p>\n", html_escape(&p.comment)));
+
+        html.push_str(&format!(
+            "<p><a href=\"../../index.html\">Index</a> | \
+             <a href=\"../../categories/{}.html\">{}</a></p>\n",
+            html_escape(&p.category),
+            html_escape(&p.category)
+        ));
+
+        if !p.descr.is_empty() {
+            html.push_str(&format!(
+                "<h2>Description</h2>\n<pre>{}</pre>\n",
+                html_escape(&p.descr)
+            ));
+        }
+
+        if !p.homepage.is_empty() {
+            html.push_str(&format!(
+                "<p>Homepage: <a href=\"{}\">{}</a></p>\n",
+                html_escape(&p.homepage),
+                html_escape(&p.homepage)
+            ));
+        }
+
+        if !p.maintainer.is_empty() {
+            html.push_str(&format!(
+                "<p>Maintainer: {}</p>\n",
+                html_escape(&p.maintainer)
+            ));
+        }
+
+        if p.categories.len() > 1 {
+            html.push_str("<p>Categories: ");
+            for (i, cat) in p.categories.iter().enumerate() {
+                if i > 0 {
+                    html.push_str(", ");
+                }
+                html.push_str(&format!(
+                    "<a href=\"../../categories/{}.html\">\
+                     {}</a>",
+                    html_escape(cat),
+                    html_escape(cat)
+                ));
+            }
+            html.push_str("</p>\n");
+        }
+
+        if !p.multi_packages.is_empty() {
+            html.push_str("<h2>Sub-packages</h2>\n<ul>\n");
+            for sub in &p.multi_packages {
+                let comment =
+                    p.sub_comments.get(sub).map(|s| s.as_str()).unwrap_or("");
+                html.push_str(&format!(
+                    "<li>{} - {}</li>\n",
+                    html_escape(sub),
+                    html_escape(comment)
+                ));
+            }
+            html.push_str("</ul>\n");
+        }
+
+        let dep_section = |title: &str, deps: &[String]| -> String {
+            if deps.is_empty() {
+                return String::new();
+            }
+            let mut s = format!("<h2>{}</h2>\n<ul>\n", title);
+            for d in deps {
+                s.push_str(&format!("<li>{}</li>\n", dep_link(d)));
+            }
+            s.push_str("</ul>\n");
+            s
+        };
+
+        html.push_str(&dep_section("Library Dependencies", &p.lib_depends));
+        html.push_str(&dep_section("Run Dependencies", &p.run_depends));
+        html.push_str(&dep_section("Build Dependencies", &p.build_depends));
+
+        html.push_str(ft);
+        html.push_str("</body>\n</html>\n");
+
+        write_file(
+            &outdir
+                .join("ports")
+                .join(&p.category)
+                .join(format!("{}.html", p.name)),
+            &html,
+        );
+    }
+}
+
+fn write_alpha(ports: &[Port], outdir: &Path, ft: &str) {
+    let searchdir = outdir.join("search");
+
+    for c in b'a'..=b'z' {
+        let c = c as char;
+        let mut matching: Vec<&Port> = ports
+            .iter()
+            .filter(|p| {
+                p.name
+                    .chars()
+                    .next()
+                    .map(|ch| ch.to_ascii_lowercase() == c)
+                    .unwrap_or(false)
+            })
+            .collect();
+        matching.sort_by(|a, b| a.name.cmp(&b.name));
+
+        let mut html = head(
+            &format!("{} - OpenBSD Ports", c.to_ascii_uppercase()),
+            1,
+            "",
+        );
+        html.push_str(&format!(
+            "<h1>Ports: {}</h1>\n\
+             <p><a href=\"../index.html\">Index</a></p>\n\
+             <p>\n",
+            c.to_ascii_uppercase()
+        ));
+
+        for lc in b'a'..=b'z' {
+            let lc = lc as char;
+            if lc == c {
+                html.push_str(&format!(
+                    "<b>{}</b> \n",
+                    lc.to_ascii_uppercase()
+                ));
+            } else {
+                html.push_str(&format!(
+                    "<a href=\"{}.html\">{}</a> \n",
+                    lc,
+                    lc.to_ascii_uppercase()
+                ));
+            }
+        }
+        html.push_str("</p>\n");
+
+        html.push_str(
+            "<table>\n<tr><th>Port</th><th>Category</th>\
+             <th>Description</th></tr>\n",
+        );
+        for p in &matching {
+            html.push_str(&format!(
+                "<tr><td><a href=\"../ports/{}/{}.html\">\
+                 {}</a></td><td><a href=\"../categories/\
+                 {}.html\">{}</a></td><td>{}</td></tr>\n",
+                html_escape(&p.category),
+                html_escape(&p.name),
+                html_escape(&p.name),
+                html_escape(&p.category),
+                html_escape(&p.category),
+                html_escape(&p.comment)
+            ));
+        }
+
+        html.push_str("</table>\n");
+        html.push_str(ft);
+        html.push_str("</body>\n</html>\n");
+
+        write_file(&searchdir.join(format!("{}.html", c)), &html);
+    }
+}
+
+fn write_index_txt(ports: &[Port], outdir: &Path) {
+    let mut out = String::new();
+    for p in ports {
+        // format: category/name\tpkgname\tcomment\tmaintainer
+        out.push_str(&p.category);
+        out.push('/');
+        out.push_str(&p.name);
+        out.push('\t');
+        out.push_str(&p.pkgname);
+        out.push('\t');
+        out.push_str(&p.comment);
+        out.push('\t');
+        out.push_str(&p.maintainer);
+        out.push('\n');
+    }
+    write_file(&outdir.join("INDEX.txt"), &out);
+}
+
+fn url_decode(s: &str) -> String {
+    let mut out = Vec::with_capacity(s.len());
+    let mut bytes = s.as_bytes().iter();
+    while let Some(&b) = bytes.next() {
+        if b == b'%' {
+            let hi = bytes.next().copied().unwrap_or(0);
+            let lo = bytes.next().copied().unwrap_or(0);
+            let hex = [hi, lo];
+            if let Ok(s) = std::str::from_utf8(&hex) {
+                if let Ok(n) = u8::from_str_radix(s, 16) {
+                    out.push(n);
+                    continue;
+                }
+            }
+            out.push(b);
+        } else if b == b'+' {
+            out.push(b' ');
+        } else {
+            out.push(b);
+        }
+    }
+    String::from_utf8_lossy(&out).into_owned()
+}
+
+fn cgi_search(index_path: &Path) {
+    let qs = std::env::var("QUERY_STRING").unwrap_or_default();
+    let query = qs
+        .split('&')
+        .find_map(|p| p.strip_prefix("q=").map(url_decode))
+        .unwrap_or_default()
+        .to_lowercase();
+
+    print!("Content-Type: text/html\r\n\r\n");
+
+    let mut html = head("Search - OpenBSD Ports", 0, "");
+    html.push_str("<h1>Search Results</h1>\n");
+    html.push_str(
+        "<form action=\"search.cgi\" method=\"get\">\n\
+         <input type=\"text\" name=\"q\" size=\"40\"",
+    );
+    if !query.is_empty() {
+        html.push_str(&format!(" value=\"{}\"", html_escape(&query)));
+    }
+    html.push_str(
+        ">\n<input type=\"submit\" value=\"Search\">\n\
+         </form>\n",
+    );
+    html.push_str("<p><a href=\"/index.html\">Index</a></p>\n");
+
+    if query.is_empty() {
+        html.push_str("<p>Enter a search term.</p>\n");
+        html.push_str("</body>\n</html>\n");
+        print!("{}", html);
+        return;
+    }
+
+    let text = fs::read_to_string(index_path).unwrap_or_default();
+
+    html.push_str(
+        "<table>\n<tr><th>Port</th><th>Description</th>\
+         <th>Maintainer</th></tr>\n",
+    );
+
+    let mut count = 0;
+    for line in text.lines() {
+        let lower = line.to_lowercase();
+        if !lower.contains(&query) {
+            continue;
+        }
+        let fields: Vec<&str> = line.splitn(4, '\t').collect();
+        if fields.len() < 3 {
+            continue;
+        }
+        let path = fields[0];
+        let comment = fields[2];
+        let maintainer = if fields.len() > 3 { fields[3] } else { "" };
+
+        let (cat, name) = match path.split_once('/') {
+            Some(p) => p,
+            None => continue,
+        };
+
+        html.push_str(&format!(
+            "<tr><td><a href=\"/ports/{}/{}.html\">\
+             {}</a></td><td>{}</td><td>{}</td></tr>\n",
+            html_escape(cat),
+            html_escape(name),
+            html_escape(path),
+            html_escape(comment),
+            html_escape(maintainer),
+        ));
+        count += 1;
+    }
+
+    html.push_str("</table>\n");
+    html.push_str(&format!(
+        "<p>{} results for \"{}\"</p>\n",
+        count,
+        html_escape(&query)
+    ));
+    html.push_str("</body>\n</html>\n");
+    print!("{}", html);
+}
+
+fn err(msg: &str) -> ! {
+    eprintln!("{}: {}", NAME, msg);
+    std::process::exit(1);
+}
+
+fn errio(path: &Path, e: &std::io::Error) -> ! {
+    err(&format!(
+        "{}: {}",
+        path.display(),
+        e.kind()
+            .to_string()
+            .replace("entity not found", "No such file or directory")
+            .replace("permission denied", "Permission denied")
+    ))
+}
+
+fn usage() -> ! {
+    eprintln!("usage: {} [-d dump] [-p portsdir] [-o outdir]", NAME);
+    std::process::exit(1);
+}
+
+fn main() {
+    // CGI mode: QUERY_STRING is set by the HTTP server
+    if std::env::var("QUERY_STRING").is_ok() {
+        let index = PathBuf::from("INDEX.txt");
+        cgi_search(&index);
+        return;
+    }
+
+    let mut portsdir = PathBuf::from("/usr/ports");
+    let mut outdir = PathBuf::from("site");
+    let mut dumpfile: Option<PathBuf> = None;
+
+    let args: Vec<String> = std::env::args().collect();
+    let mut i = 1;
+    while i < args.len() {
+        match args[i].as_str() {
+            "-d" => {
+                i += 1;
+                if i >= args.len() {
+                    usage();
+                }
+                dumpfile = Some(PathBuf::from(&args[i]));
+            }
+            "-p" => {
+                i += 1;
+                if i >= args.len() {
+                    usage();
+                }
+                portsdir = PathBuf::from(&args[i]);
+            }
+            "-o" => {
+                i += 1;
+                if i >= args.len() {
+                    usage();
+                }
+                outdir = PathBuf::from(&args[i]);
+            }
+            _ => usage(),
+        }
+        i += 1;
+    }
+
+    let ports = if let Some(ref dump) = dumpfile {
+        eprintln!("loading dump from {}", dump.display());
+        let pd = if portsdir.is_dir() {
+            Some(portsdir.as_path())
+        } else {
+            None
+        };
+        load_dump(dump, pd)
+    } else {
+        if !portsdir.is_dir() {
+            err(&format!("{}: not a directory", portsdir.display()));
+        }
+        eprintln!("parsing ports from {}", portsdir.display());
+        walk_ports(&portsdir)
+    };
+    eprintln!("{} ports found", ports.len());
+
+    eprintln!("generating site in {}", outdir.display());
+    fs::create_dir_all(outdir.join("categories")).ok();
+    fs::create_dir_all(outdir.join("search")).ok();
+    for p in &ports {
+        fs::create_dir_all(outdir.join("ports").join(&p.category)).ok();
+    }
+    copy_assets(&outdir);
+    let ft = footer(ports.len());
+    write_index(&ports, &outdir, &ft);
+    write_categories(&ports, &outdir, &ft);
+    write_port_pages(&ports, &outdir, &ft);
+    write_alpha(&ports, &outdir, &ft);
+    write_index_txt(&ports, &outdir);
+    eprintln!("done");
+}