Commit Diff


commit - 1396fea0b2bcd64a9ce0209971ed4f1eade8d28e
commit + 97996b0baa16c6ac08479bab8300a2fdb394ec0b
blob - 5ae953572dda98e3229e9c58710e2d760e76ee88
blob + 2387032d4f53c3f645b75e10787fb5df3f2c7e90
--- .gitignore
+++ .gitignore
@@ -1,4 +1,4 @@
-wp
+wop
 doc/
 site/
 ports-dump.txt
blob - b2cffd8e3bd22a9389aa0b7af69c88328e6a4907
blob + 827fa4f66390322bb46986c2bf406c122ec34ddc
--- Makefile
+++ Makefile
@@ -1,8 +1,8 @@
 PREFIX = /usr/local
 MANDIR = ${PREFIX}/man
-BIN = wp
-SRC = wp.rs
-MAN = wp.1
+BIN = wop
+SRC = wop.rs
+MAN = wop.1
 
 all: ${BIN}
 
blob - a8ebfe98bfe3795581d748c9128fdeb0e9810fe1
blob + a820156b4f671b8124aa33a28229dd8447750fbf
--- README.md
+++ README.md
@@ -1,6 +1,6 @@
-wp - where OpenBSD ports
+wop - where OpenBSD ports
 ========================
-wp generates a static HTML site for browsing OpenBSD ports.
+wop generates a static HTML site for browsing OpenBSD ports.
 No JavaScript, works in lynx. Includes built-in CGI search.
 
 Requirements
@@ -9,18 +9,18 @@ Requirements
 
 Building
 --------
-Edit Makefile to match your local setup (wp is installed
+Edit Makefile to match your local setup (wop is installed
 into the /usr/local/bin namespace by default).
 
 Afterwards enter the following command to build and install
-wp (if necessary as root):
+wop (if necessary as root):
 
     make
     make install
 
 Running
 -------
-    wp -p /usr/ports -o /var/www/htdocs/ports
+    wop -p /usr/ports -o /var/www/htdocs/ports
 
 Options:
   -p portsdir   path to ports tree (default: /usr/ports)
@@ -29,8 +29,8 @@ Options:
 
 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
+wop has built-in CGI support. When the QUERY_STRING environment
+variable is set, wop 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.
@@ -39,11 +39,11 @@ CGI setup with httpd(8)
 -----------------------
 Generate the site:
 
-    wp -p /usr/ports -o /var/www/htdocs/ports
+    wop -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 /usr/local/bin/wop /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
blob - db80dd8ec83f1da43955e0925013d38a9b854218 (mode 644)
blob + /dev/null
--- wp.1
+++ /dev/null
@@ -1,167 +0,0 @@
-.\"
-.\" 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 + c3408b284bc16e57dfacbb89ffdb29b765466a76 (mode 644)
--- /dev/null
+++ wop.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 WOP 1
+.Os
+.Sh NAME
+.Nm wop
+.Nd where OpenBSD ports
+.Sh SYNOPSIS
+.Nm wop
+.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/wop /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
+$ wop -p /usr/ports -o /var/www/htdocs/ports
+.Ed
+.Pp
+Generate a site from a dump-vars file:
+.Bd -literal -offset indent
+$ wop -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/wop
+.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 - a91c6ebf9951e77e06952f816d8ae6ce25b19524 (mode 644)
blob + /dev/null
--- wp.rs
+++ /dev/null
@@ -1,1219 +0,0 @@
-// 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},
-};
-
-#[cfg(target_os = "openbsd")]
-mod sandbox {
-    use std::ffi::CString;
-    use std::os::raw::c_char;
-    use std::path::Path;
-
-    extern "C" {
-        fn pledge(promises: *const c_char, execpromises: *const c_char) -> i32;
-        fn unveil(path: *const c_char, permissions: *const c_char) -> i32;
-    }
-
-    pub fn do_unveil(path: &Path, perms: &str) {
-        let p = CString::new(path.to_str().expect("unveil: invalid path"))
-            .expect("unveil: nul in path");
-        let f = CString::new(perms).expect("unveil: nul in perms");
-        if unsafe { unveil(p.as_ptr(), f.as_ptr()) } == -1 {
-            let e = std::io::Error::last_os_error();
-            super::err(&format!("unveil {}: {}", path.display(), e));
-        }
-    }
-
-    pub fn lock_unveil() {
-        if unsafe { unveil(std::ptr::null(), std::ptr::null()) } == -1 {
-            let e = std::io::Error::last_os_error();
-            super::err(&format!("unveil lock: {}", e));
-        }
-    }
-
-    pub fn do_pledge(promises: &str) {
-        let p = CString::new(promises).expect("pledge: nul in promises");
-        if unsafe { pledge(p.as_ptr(), std::ptr::null()) } == -1 {
-            let e = std::io::Error::last_os_error();
-            super::err(&format!("pledge: {}", e));
-        }
-    }
-}
-
-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() {
-        if module.contains("..") {
-            continue;
-        }
-        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 safe_name(s: &str) -> bool {
-    !s.is_empty()
-        && !s.contains('/')
-        && !s.contains('\0')
-        && s != "."
-        && s != ".."
-}
-
-fn port_from_makefile(
-    category: &str,
-    portname: &str,
-    portdir: &Path,
-    portsdir: &Path,
-    modcache: &mut ModCache,
-) -> Option<Port> {
-    if !safe_name(category) || !safe_name(portname) {
-        return None;
-    }
-    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('/')?;
-    if !safe_name(category) || !safe_name(name) {
-        return None;
-    }
-    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
-        // find the first '=' then look backwards for the '.'
-        let Some(eq_pos) = line.find('=') else {
-            continue;
-        };
-        let prefix = &line[..eq_pos];
-        let Some(dot) = prefix.rfind('.') else {
-            continue;
-        };
-        let portpath = &line[..dot];
-        let var = &line[dot + 1..eq_pos];
-        let val = &line[eq_pos + 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_str("&#x27;"),
-            _ => out.push(c),
-        }
-    }
-    out
-}
-
-fn local_date() -> String {
-    use std::os::raw::{c_int, c_long};
-
-    // struct tm layout per POSIX, with BSD extensions.
-    // over-sized _pad absorbs any platform-specific trailing
-    // fields so localtime_r never writes out of bounds.
-    #[repr(C)]
-    struct Tm {
-        tm_sec: c_int,
-        tm_min: c_int,
-        tm_hour: c_int,
-        tm_mday: c_int,
-        tm_mon: c_int,
-        tm_year: c_int,
-        tm_wday: c_int,
-        tm_yday: c_int,
-        tm_isdst: c_int,
-        _pad: [u8; 64],
-    }
-
-    extern "C" {
-        fn time(t: *mut c_long) -> c_long;
-        fn localtime_r(t: *const c_long, result: *mut Tm) -> *mut Tm;
-    }
-
-    unsafe {
-        let mut t: c_long = 0;
-        time(&mut t);
-        let mut tm = std::mem::zeroed::<Tm>();
-        let ret = localtime_r(&t, &mut tm);
-        if ret.is_null() {
-            return String::from("unknown");
-        }
-        format!(
-            "{:04}-{:02}-{:02}",
-            tm.tm_year + 1900,
-            tm.tm_mon + 1,
-            tm.tm_mday
-        )
-    }
-}
-
-fn footer(total: usize) -> String {
-    format!(
-        "<hr>\n<footer>{} {} | {} ports | {}</footer>\n",
-        NAME,
-        VERSION,
-        total,
-        local_date()
-    )
-}
-
-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()
-            && (p.homepage.starts_with("https://")
-                || p.homepage.starts_with("http://"))
-        {
-            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 sanitize_field(s: &str) -> String {
-    s.replace(['\t', '\n'], " ").replace('\r', "")
-}
-
-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(&sanitize_field(&p.pkgname));
-        out.push('\t');
-        out.push_str(&sanitize_field(&p.comment));
-        out.push('\t');
-        out.push_str(&sanitize_field(&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) {
-                    if n != 0 {
-                        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 mut qs = std::env::var("QUERY_STRING").unwrap_or_default();
-    qs.truncate(1024);
-    let mut query = qs
-        .split('&')
-        .find_map(|p| p.strip_prefix("q=").map(url_decode))
-        .unwrap_or_default()
-        .to_lowercase();
-    query.truncate(128);
-
-    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,
-        };
-
-        if count >= 200 {
-            break;
-        }
-        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");
-    if count >= 200 {
-        html.push_str("<p>Results limited to 200.</p>\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");
-        #[cfg(target_os = "openbsd")]
-        {
-            sandbox::do_unveil(&index, "r");
-            sandbox::lock_unveil();
-            sandbox::do_pledge("stdio rpath");
-        }
-        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;
-    }
-
-    fs::create_dir_all(&outdir).unwrap_or_else(|e| {
-        errio(&outdir, &e);
-    });
-
-    #[cfg(target_os = "openbsd")]
-    {
-        sandbox::do_unveil(&portsdir, "r");
-        if let Some(ref d) = dumpfile {
-            sandbox::do_unveil(d, "r");
-        }
-        sandbox::do_unveil(&outdir, "rwc");
-        sandbox::lock_unveil();
-        sandbox::do_pledge("stdio rpath wpath cpath");
-    }
-
-    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");
-}
blob - /dev/null
blob + bd0cb8133b25ae841efb9230ee35a15ecb3f66a6 (mode 644)
--- /dev/null
+++ wop.rs
@@ -0,0 +1,1219 @@
+// 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},
+};
+
+#[cfg(target_os = "openbsd")]
+mod sandbox {
+    use std::ffi::CString;
+    use std::os::raw::c_char;
+    use std::path::Path;
+
+    extern "C" {
+        fn pledge(promises: *const c_char, execpromises: *const c_char) -> i32;
+        fn unveil(path: *const c_char, permissions: *const c_char) -> i32;
+    }
+
+    pub fn do_unveil(path: &Path, perms: &str) {
+        let p = CString::new(path.to_str().expect("unveil: invalid path"))
+            .expect("unveil: nul in path");
+        let f = CString::new(perms).expect("unveil: nul in perms");
+        if unsafe { unveil(p.as_ptr(), f.as_ptr()) } == -1 {
+            let e = std::io::Error::last_os_error();
+            super::err(&format!("unveil {}: {}", path.display(), e));
+        }
+    }
+
+    pub fn lock_unveil() {
+        if unsafe { unveil(std::ptr::null(), std::ptr::null()) } == -1 {
+            let e = std::io::Error::last_os_error();
+            super::err(&format!("unveil lock: {}", e));
+        }
+    }
+
+    pub fn do_pledge(promises: &str) {
+        let p = CString::new(promises).expect("pledge: nul in promises");
+        if unsafe { pledge(p.as_ptr(), std::ptr::null()) } == -1 {
+            let e = std::io::Error::last_os_error();
+            super::err(&format!("pledge: {}", e));
+        }
+    }
+}
+
+const VERSION: &str = "0.1.0";
+const NAME: &str = "wop";
+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() {
+        if module.contains("..") {
+            continue;
+        }
+        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 safe_name(s: &str) -> bool {
+    !s.is_empty()
+        && !s.contains('/')
+        && !s.contains('\0')
+        && s != "."
+        && s != ".."
+}
+
+fn port_from_makefile(
+    category: &str,
+    portname: &str,
+    portdir: &Path,
+    portsdir: &Path,
+    modcache: &mut ModCache,
+) -> Option<Port> {
+    if !safe_name(category) || !safe_name(portname) {
+        return None;
+    }
+    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('/')?;
+    if !safe_name(category) || !safe_name(name) {
+        return None;
+    }
+    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
+        // find the first '=' then look backwards for the '.'
+        let Some(eq_pos) = line.find('=') else {
+            continue;
+        };
+        let prefix = &line[..eq_pos];
+        let Some(dot) = prefix.rfind('.') else {
+            continue;
+        };
+        let portpath = &line[..dot];
+        let var = &line[dot + 1..eq_pos];
+        let val = &line[eq_pos + 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_str("&#x27;"),
+            _ => out.push(c),
+        }
+    }
+    out
+}
+
+fn local_date() -> String {
+    use std::os::raw::{c_int, c_long};
+
+    // struct tm layout per POSIX, with BSD extensions.
+    // over-sized _pad absorbs any platform-specific trailing
+    // fields so localtime_r never writes out of bounds.
+    #[repr(C)]
+    struct Tm {
+        tm_sec: c_int,
+        tm_min: c_int,
+        tm_hour: c_int,
+        tm_mday: c_int,
+        tm_mon: c_int,
+        tm_year: c_int,
+        tm_wday: c_int,
+        tm_yday: c_int,
+        tm_isdst: c_int,
+        _pad: [u8; 64],
+    }
+
+    extern "C" {
+        fn time(t: *mut c_long) -> c_long;
+        fn localtime_r(t: *const c_long, result: *mut Tm) -> *mut Tm;
+    }
+
+    unsafe {
+        let mut t: c_long = 0;
+        time(&mut t);
+        let mut tm = std::mem::zeroed::<Tm>();
+        let ret = localtime_r(&t, &mut tm);
+        if ret.is_null() {
+            return String::from("unknown");
+        }
+        format!(
+            "{:04}-{:02}-{:02}",
+            tm.tm_year + 1900,
+            tm.tm_mon + 1,
+            tm.tm_mday
+        )
+    }
+}
+
+fn footer(total: usize) -> String {
+    format!(
+        "<hr>\n<footer>{} {} | {} ports | {}</footer>\n",
+        NAME,
+        VERSION,
+        total,
+        local_date()
+    )
+}
+
+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()
+            && (p.homepage.starts_with("https://")
+                || p.homepage.starts_with("http://"))
+        {
+            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 sanitize_field(s: &str) -> String {
+    s.replace(['\t', '\n'], " ").replace('\r', "")
+}
+
+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(&sanitize_field(&p.pkgname));
+        out.push('\t');
+        out.push_str(&sanitize_field(&p.comment));
+        out.push('\t');
+        out.push_str(&sanitize_field(&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) {
+                    if n != 0 {
+                        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 mut qs = std::env::var("QUERY_STRING").unwrap_or_default();
+    qs.truncate(1024);
+    let mut query = qs
+        .split('&')
+        .find_map(|p| p.strip_prefix("q=").map(url_decode))
+        .unwrap_or_default()
+        .to_lowercase();
+    query.truncate(128);
+
+    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,
+        };
+
+        if count >= 200 {
+            break;
+        }
+        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");
+    if count >= 200 {
+        html.push_str("<p>Results limited to 200.</p>\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");
+        #[cfg(target_os = "openbsd")]
+        {
+            sandbox::do_unveil(&index, "r");
+            sandbox::lock_unveil();
+            sandbox::do_pledge("stdio rpath");
+        }
+        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;
+    }
+
+    fs::create_dir_all(&outdir).unwrap_or_else(|e| {
+        errio(&outdir, &e);
+    });
+
+    #[cfg(target_os = "openbsd")]
+    {
+        sandbox::do_unveil(&portsdir, "r");
+        if let Some(ref d) = dumpfile {
+            sandbox::do_unveil(d, "r");
+        }
+        sandbox::do_unveil(&outdir, "rwc");
+        sandbox::lock_unveil();
+        sandbox::do_pledge("stdio rpath wpath cpath");
+    }
+
+    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");
+}