├── .github └── workflows │ └── ci.yml ├── templates └── htmlNix │ ├── default.nix │ └── flake.nix ├── flake.lock ├── test.nix ├── flake.nix ├── README.md ├── bundle-tree.py ├── default.nix └── make-bundle.py /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Nix build 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: 7 | - master 8 | 9 | jobs: 10 | nix-build: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v4 14 | - uses: cachix/install-nix-action@v26 15 | with: 16 | github_access_token: ${{ secrets.GITHUB_TOKEN }} 17 | - name: Build web 18 | run: nix flake check -L 19 | -------------------------------------------------------------------------------- /templates/htmlNix/default.nix: -------------------------------------------------------------------------------- 1 | { htmlNix, kakapo, writeText }: 2 | let 3 | inherit (htmlNix) withDoctype esc __findFile; 4 | 5 | asset = writeText "asset.txt" '' 6 | Example asset. This could be any arbitrary derivation. 7 | ''; 8 | 9 | in 10 | kakapo.bundleTree "my-webroot" { } { 11 | "index.html" = ( 12 | withDoctype ( { } [ 13 | ( { } [ 14 | ( { charset = "utf-8"; } null) 15 | ( { } (esc "hello world")) 16 | ]) 17 | (<body> { } [ 18 | (<h1> { } (esc "hello world")) 19 | (<a> { href = asset; } (esc "example asset")) 20 | ]) 21 | ]) 22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "nixpkgs": { 4 | "locked": { 5 | "lastModified": 1709961763, 6 | "narHash": "sha256-6H95HGJHhEZtyYA3rIQpvamMKAGoa8Yh2rFV29QnuGw=", 7 | "owner": "nixos", 8 | "repo": "nixpkgs", 9 | "rev": "3030f185ba6a4bf4f18b87f345f104e6a6961f34", 10 | "type": "github" 11 | }, 12 | "original": { 13 | "owner": "nixos", 14 | "ref": "nixos-unstable", 15 | "repo": "nixpkgs", 16 | "type": "github" 17 | } 18 | }, 19 | "root": { 20 | "inputs": { 21 | "nixpkgs": "nixpkgs" 22 | } 23 | } 24 | }, 25 | "root": "root", 26 | "version": 7 27 | } 28 | -------------------------------------------------------------------------------- /test.nix: -------------------------------------------------------------------------------- 1 | { pkgs ? import <nixpkgs> { } }: 2 | 3 | let 4 | inherit (pkgs) callPackage writeText runCommand; 5 | kakapo = callPackage ./. { }; 6 | 7 | myAsset = writeText "my-asset.txt" '' 8 | hello world! 9 | ''; 10 | 11 | in { 12 | makeBundle = let 13 | indexHTML = writeText "index.html" '' 14 | <a href="${myAsset}">my-asset.txt</a> 15 | ''; 16 | 17 | webRoot = runCommand "webroot" { } '' 18 | mkdir $out 19 | cp ${indexHTML} $out/index.html 20 | ''; 21 | in kakapo.makeBundle webRoot; 22 | 23 | bundleTree = kakapo.bundleTree "my-webroot" { } { 24 | "index.html" = '' 25 | <a href="/about.html">about</a> 26 | <a href="/blog/posts/0.html">about</a> 27 | ''; 28 | "about.html" = pkgs.writeText "about.html" '' 29 | <p>About us</p> 30 | ''; 31 | blog.posts."0.html" = '' 32 | <p>Welcome to my blog</p> 33 | <a href="${myAsset}">my asset</a> 34 | ''; 35 | }; 36 | } 37 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | description = "A Nix web bundler"; 3 | 4 | inputs = { 5 | nixpkgs.url = "github:nixos/nixpkgs?ref=nixos-unstable"; 6 | }; 7 | 8 | outputs = { self, nixpkgs }: ( 9 | let 10 | inherit (nixpkgs) lib; 11 | forAllSystems = lib.genAttrs lib.systems.flakeExposed; 12 | in 13 | { 14 | checks = 15 | forAllSystems 16 | ( 17 | system: 18 | let 19 | pkgs = nixpkgs.legacyPackages.${system}; 20 | in 21 | pkgs.callPackages ./test.nix { } 22 | ); 23 | 24 | legacyPackages = 25 | forAllSystems 26 | ( 27 | system: 28 | let 29 | pkgs = nixpkgs.legacyPackages.${system}; 30 | in 31 | pkgs.callPackages ./default.nix { } 32 | ); 33 | 34 | templates = { 35 | htmlNix = { 36 | path = ./templates/htmlNix; 37 | description = "Usage with htmlNix"; 38 | }; 39 | default = self.templates.htmlNix; 40 | }; 41 | } 42 | ); 43 | } 44 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Kākāpō 2 | 3 | A web bundler for Nix strings with context. 4 | 5 | Named after the [flightless parrot](https://www.doc.govt.nz/nature/native-animals/birds/birds-a-z/kakapo/). 6 | 7 | ## Motivation 8 | 9 | Because I'm unwell and and think cursed hacks like [//users/sterni/nix/html/README.md](https://cs.tvl.fyi/depot/-/blob/users/sterni/nix/html/README.md) are cool and would like to make it's usage more practical. 10 | 11 | ## Basic usage 12 | 13 | - Writing a bundle from a derivation 14 | ``` nix 15 | let 16 | indexHTML = writeText "index.html" '' 17 | <h1>Welcome!</h1> 18 | <img src="${./banner.jpg}" /> 19 | ''; 20 | 21 | webRoot = runCommand "webroot" { } '' 22 | mkdir $out 23 | cp ${indexHTML} $out/index.html 24 | ''; 25 | in kakapo.makeBundle webRoot; 26 | ``` 27 | 28 | - Bundling a file tree from an attribute set 29 | ```nix 30 | kakapo.bundleTree "my-webroot" { } { 31 | "index.html" = '' 32 | <h1>Welcome!</h1> 33 | <img src="${./banner.jpg}" /> 34 | ''; 35 | } 36 | ``` 37 | 38 | ## Use with `htmlNix' 39 | 40 | Check out [./templates/htmlNix](./templates/htmlNix). 41 | -------------------------------------------------------------------------------- /templates/htmlNix/flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | description = "A basic website defined in Nix"; 3 | 4 | inputs = { 5 | nixpkgs.url = "github:nixos/nixpkgs?ref=nixos-unstable"; 6 | htmlNix = { 7 | type = "git"; 8 | url = "https://code.tvl.fyi/depot.git:workspace=users/sterni/nix/html.git"; 9 | flake = false; 10 | }; 11 | kakapo = { 12 | url = "github:adisbladis/kakapo"; 13 | inputs.nixpkgs.follows = "nixpkgs"; 14 | }; 15 | }; 16 | 17 | outputs = { self, nixpkgs, kakapo, ... }@inputs: ( 18 | let 19 | inherit (nixpkgs) lib; 20 | forAllSystems = lib.genAttrs lib.systems.flakeExposed; 21 | in 22 | { 23 | packages = 24 | forAllSystems 25 | ( 26 | system: 27 | let 28 | pkgs = nixpkgs.legacyPackages.${system}; 29 | in 30 | { 31 | default = pkgs.callPackage ./. { 32 | htmlNix = import inputs.htmlNix { }; 33 | kakapo = kakapo.legacyPackages.${system}; 34 | }; 35 | } 36 | ); 37 | } 38 | ); 39 | } 40 | -------------------------------------------------------------------------------- /bundle-tree.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import argparse 3 | import os.path 4 | import shutil 5 | import json 6 | import sys 7 | import os 8 | 9 | 10 | arg_parser = argparse.ArgumentParser() 11 | arg_parser.add_argument( 12 | "structured_attrs", help="Path to structured attrs file as exported by Nix __structuredAttrs" 13 | ) 14 | arg_parser.add_argument("output_dir", help="Output directory") 15 | 16 | 17 | def write_tree(output_dir: str, tree: dict) -> None: 18 | os.mkdir(output_dir) 19 | 20 | for name, node in tree.items(): 21 | output_file = os.path.join(output_dir, name) 22 | 23 | if node["type"] == "derivation": 24 | shutil.copy(node["value"], output_file) 25 | elif node["type"] == "string": 26 | with open(output_file, "w") as f: 27 | f.write(node["value"]) 28 | elif node["type"] == "set": 29 | write_tree(output_file, node["value"]) 30 | else: 31 | raise ValueError(node["type"]) 32 | 33 | 34 | if __name__ == "__main__": 35 | args = arg_parser.parse_args() 36 | 37 | with open(args.structured_attrs) as f: 38 | attrs = json.load(f) 39 | 40 | write_tree(args.output_dir, attrs["tree"]) 41 | -------------------------------------------------------------------------------- /default.nix: -------------------------------------------------------------------------------- 1 | { runCommand 2 | , python3 3 | , lib 4 | }: 5 | 6 | let 7 | inherit (lib) fix mapAttrs isDerivation isAttrs isString; 8 | in 9 | 10 | fix (self: { 11 | /* 12 | Make a bundle with all Nix store references of `root` substituted 13 | with a new internal store located at $out/_store. 14 | */ 15 | makeBundle = 16 | root: 17 | assert isDerivation root; 18 | runCommand "${root.name}-bundle" 19 | { 20 | dontUnpack = true; 21 | dontConfigure = true; 22 | nativeBuildInputs = [ python3 ]; 23 | exportReferencesGraph = [ "graph" root ]; 24 | inherit (root) meta; 25 | passthru = root.passthru or { }; 26 | } '' 27 | cp -r ${root} $out 28 | chmod +w -R $out 29 | python3 ${./make-bundle.py} graph $out 30 | ''; 31 | 32 | /* 33 | Create a file tree from an attribute set of strings & derivations. 34 | 35 | If a string is encountered it will be written to the output as-is. 36 | If a derivation is encountered it will be copied to the output. 37 | 38 | # Example 39 | 40 | ```nix 41 | bundleTree "my-webroot" { meta.license = lib.licenses.mit; } { 42 | "index.html" = '' 43 | <a href="/about.html">about</a> 44 | ''; 45 | "about.html" = pkgs.writeText "about.html" '' 46 | <p>About us</p> 47 | ''; 48 | } 49 | => 50 | «derivation /nix/store/190nn2hhqah4r3s3bxvfz0ms6r5i5v0j-my-webroot.drv» 51 | ``` 52 | */ 53 | bundleTree = name: attrs: tree: 54 | let 55 | mapTree = mapAttrs (name: value': rec { 56 | type = 57 | if isDerivation value' then "derivation" 58 | else if isAttrs value' then "set" 59 | else if isString value' then "string" 60 | else throw "Unsupported type in tree"; 61 | value = if type != "set" then value' else mapTree value'; 62 | }); 63 | drv = 64 | runCommand 65 | name 66 | (attrs // { 67 | __structuredAttrs = true; 68 | tree = mapTree tree; 69 | passthru = attrs.passthru or { } // { 70 | tree = tree; 71 | }; 72 | }) 73 | "${python3.interpreter} ${./bundle-tree.py} .attrs.json $out"; 74 | in 75 | self.makeBundle drv; 76 | 77 | }) 78 | -------------------------------------------------------------------------------- /make-bundle.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # 3 | # A web bundler that substitutes Nix store paths in an input directory for references to an internal store. 4 | # 5 | from typing import Sequence 6 | from collections.abc import Iterator 7 | import argparse 8 | import os.path 9 | import shutil 10 | import stat 11 | import os 12 | 13 | 14 | arg_parser = argparse.ArgumentParser() 15 | arg_parser.add_argument( 16 | "graph_path", help="Path to graph file as exported by Nix exportReferencesGraph" 17 | ) 18 | arg_parser.add_argument("directory", help="Directory to bundle") 19 | 20 | 21 | STORE_PREFIX = "/nix/store" 22 | INTERNAL_STORE_DIR = "_store" 23 | 24 | 25 | def make_dest(origin: str) -> str: 26 | return origin.replace(STORE_PREFIX, INTERNAL_STORE_DIR, 1) 27 | 28 | 29 | def find_files(store_path: str) -> Iterator[str]: 30 | """Find files in a given store path""" 31 | mode = os.stat(store_path).st_mode 32 | 33 | # If a file return a set with itself only 34 | if not stat.S_ISDIR(mode): 35 | yield store_path 36 | return 37 | 38 | # Recurse into store 39 | for file in os.listdir(store_path): 40 | yield from find_files(os.path.join(store_path, file)) 41 | 42 | 43 | def read_export_references_graph_store_paths(graph_path: str) -> Sequence[str]: 44 | """Store paths to scan for files as exported by Nix exportReferencesGraph""" 45 | store_paths: set[str] = set() 46 | 47 | with open(graph_path) as f: 48 | for line in f.readlines(): 49 | if line.startswith(STORE_PREFIX): 50 | store_paths.add(line.strip()) 51 | 52 | return list(store_paths) 53 | 54 | 55 | def find_files_recursive(store_paths: Sequence[str]) -> list[str]: 56 | """Find files from all store paths""" 57 | store_files: set[str] = set() 58 | 59 | for store_path in store_paths: 60 | for file in find_files(store_path): 61 | store_files.add(file) 62 | 63 | # Return list in reverse order so longer prefixes are processed first 64 | return sorted(store_files, reverse=True) 65 | 66 | 67 | def recurse_output( 68 | path: str, store_files: Sequence[str], _depth: int = 0 69 | ) -> Iterator[str]: 70 | mode = os.stat(path).st_mode 71 | 72 | # If a file return a set with itself only 73 | if stat.S_ISDIR(mode): 74 | for file in os.listdir(path): 75 | yield from recurse_output( 76 | os.path.join(path, file), store_files, _depth=_depth + 1 77 | ) 78 | return 79 | 80 | if not stat.S_ISREG(mode): 81 | raise ValueError( 82 | f"Invalid mode for directory '{path}', needs to be either directory or regular" 83 | ) 84 | 85 | # Get the number of ../../ to prefix with based on recursion depth 86 | store_prefix = "../" * (_depth - 1) if _depth > 1 else "./" 87 | 88 | with open(path, mode="rb") as f: 89 | data = f.read() 90 | 91 | for store_file in store_files: 92 | store_file_bytes = store_file.encode() 93 | if store_file_bytes in data: 94 | dest = store_prefix + make_dest(store_file) 95 | data = data.replace(store_file_bytes, dest.encode()) 96 | yield store_file 97 | 98 | with open(path, mode="wb") as f: 99 | f.write(data) 100 | 101 | 102 | def make_store(root: str, found_files: set[str]) -> None: 103 | """Copy found files to a local store""" 104 | for file in found_files: 105 | dest = os.path.join(root, make_dest(file)) 106 | os.makedirs(os.path.dirname(dest), exist_ok=True) 107 | shutil.copy(file, dest) 108 | 109 | 110 | if __name__ == "__main__": 111 | args = arg_parser.parse_args() 112 | 113 | # Find Nix store paths & files to work on 114 | store_paths = read_export_references_graph_store_paths(args.graph_path) 115 | store_files: list[str] = find_files_recursive(store_paths) 116 | 117 | # Files that were referenced in the bundle directory 118 | found_files: set[str] = set(recurse_output(args.directory, store_files)) 119 | 120 | # Write files to our local store 121 | make_store(args.directory, found_files) 122 | --------------------------------------------------------------------------------