├── .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 | (About us
30 | ''; 31 | blog.posts."0.html" = '' 32 |Welcome to my blog
33 | my asset 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 |
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 |
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 | about
44 | '';
45 | "about.html" = pkgs.writeText "about.html" ''
46 | About us
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 | --------------------------------------------------------------------------------