Skip to content

typst: add initial support for typst packages #369283

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 5 commits into from
Apr 17, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions doc/languages-frameworks/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ scheme.section.md
swift.section.md
tcl.section.md
texlive.section.md
typst.section.md
vim.section.md
neovim.section.md
```
62 changes: 62 additions & 0 deletions doc/languages-frameworks/typst.section.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
# Typst {#typst}

Typst can be configured to include packages from [Typst Universe](https://typst.app/universe/) or custom packages.

## Custom Environment {#typst-custom-environment}

You can create a custom Typst environment with a selected set of packages from **Typst Universe** using the following code. It is also possible to specify a Typst package with a specific version (e.g., `cetz_0_3_0`). A package without a version number will always refer to its latest version.

```nix
typst.withPackages (p: with p; [
polylux_0_4_0
cetz_0_3_0
])
```

### Handling Outdated Package Hashes {#typst-handling-outdated-package-hashes}

Since **Typst Universe** does not provide a way to fetch a package with a specific hash, the package hashes in `nixpkgs` can sometimes be outdated. To resolve this issue, you can manually override the package source using the following approach:

```nix
typst.withPackages.override (old: {
typstPackages = old.typstPackages.extend (_: previous: {
polylux_0_4_0 = previous.polylux_0_4_0.overrideAttrs (oldPolylux: {
src = oldPolylux.src.overrideAttrs {
outputHash = YourUpToDatePolyluxHash;
};
});
});
}) (p: with p; [
polylux_0_4_0
cetz_0_3_0
])
```

## Custom Packages {#typst-custom-packages}

`Nixpkgs` provides a helper function, `buildTypstPackage`, to build custom Typst packages that can be used within the Typst environment. However, all dependencies of the custom package must be explicitly specified in `typstDeps`.

Here's how to define a custom Typst package:

```nix
{ buildTypstPackage, typstPackages, fetchzip }:

buildTypstPackage (finalAttrs: {
pname = "my-typst-package";
version = "0.0.1";
src = fetchzip { ... };
typstDeps = with typstPackages; [ cetz_0_3_0 ];
})
```

### Package Scope and Usage {#typst-package-scope-and-usage}

By default, every custom package is scoped under `@preview`, as shown below:

```typst
#import "@preview/my-typst-package:0.0.1": *
```

Since `@preview` is intended for packages from **Typst Universe**, it is recommended to use this approach **only for temporary or experimental modifications over existing packages** from **Typst Universe**.

On the other hand, **local packages**, packages scoped under `@local`, are **not** considered part of the Typst environment. This means that local packages must be manually linked to the Typst compiler if needed.
18 changes: 18 additions & 0 deletions doc/redirects.json
Original file line number Diff line number Diff line change
Expand Up @@ -368,6 +368,24 @@
"tester-testEqualArrayOrMap-return": [
"index.html#tester-testEqualArrayOrMap-return"
],
"typst": [
"index.html#typst",
"doc/languages-frameworks/typst.section.md#typst"
],
"typst-custom-environment": [
"index.html#typst-custom-environment",
"doc/languages-frameworks/typst.section.md#typst-custom-environment"
],
"typst-custom-packages": [
"index.html#typst-custom-packages",
"doc/languages-frameworks/typst.section.md#typst-custom-packages"
],
"typst-handling-outdated-package-hashes": [
"index.html#typst-handling-outdated-package-hashes"
],
"typst-package-scope-and-usage": [
"index.html#typst-package-scope-and-usage"
],
"variables-specifying-dependencies": [
"index.html#variables-specifying-dependencies"
],
Expand Down
5 changes: 5 additions & 0 deletions maintainers/maintainer-list.nix
Original file line number Diff line number Diff line change
Expand Up @@ -4191,6 +4191,11 @@
name = "CherryKitten";
keys = [ { fingerprint = "264C FA1A 194C 585D F822 F673 C01A 7CBB A617 BD5F"; } ];
};
cherrypiejam = {
github = "cherrypiejam";
githubId = 46938348;
name = "Gongqi Huang";
};
chessai = {
email = "chessai1996@gmail.com";
github = "chessai";
Expand Down
226 changes: 226 additions & 0 deletions maintainers/scripts/update-typst-packages.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,226 @@
#!/usr/bin/env nix-shell
#!nix-shell -p "python3.withPackages (p: with p; [ tomli tomli-w packaging license-expression])" -i python3

# This file is formatted with `ruff format`.

import os
import re
import tomli
import tomli_w
import subprocess
import concurrent.futures
import argparse
import tempfile
import tarfile
from string import punctuation
from packaging.version import Version
from urllib import request
from collections import OrderedDict


class TypstPackage:
def __init__(self, **kwargs):
self.pname = kwargs["pname"]
self.version = kwargs["version"]
self.meta = kwargs["meta"]
self.path = kwargs["path"]
self.repo = (
None
if "repository" not in self.meta["package"]
else self.meta["package"]["repository"]
)
self.description = self.meta["package"]["description"].rstrip(punctuation)
self.license = self.meta["package"]["license"]
self.params = "" if "params" not in kwargs else kwargs["params"]
self.deps = [] if "deps" not in kwargs else kwargs["deps"]

@classmethod
def package_name_full(cls, package_name, version):
version_number = map(lambda x: int(x), version.split("."))
version_nix = "_".join(map(lambda x: str(x), version_number))
return "_".join((package_name, version_nix))

def license_tokens(self):
import license_expression as le

try:
# FIXME: ad hoc conversion
exception_list = [("EUPL-1.2+", "EUPL-1.2")]

def sanitize_license_string(license_string, lookups):
if not lookups:
return license_string
return sanitize_license_string(
license_string.replace(lookups[0][0], lookups[0][1]), lookups[1:]
)

sanitized = sanitize_license_string(self.license, exception_list)
licensing = le.get_spdx_licensing()
parsed = licensing.parse(sanitized, validate=True)
return [s.key for s in licensing.license_symbols(parsed)]
except le.ExpressionError as e:
print(
f'Failed to parse license string "{self.license}" because of {str(e)}'
)
exit(1)

def source(self):
url = f"https://packages.typst.org/preview/{self.pname}-{self.version}.tar.gz"
cmd = [
"nix",
"store",
"prefetch-file",
"--unpack",
"--hash-type",
"sha256",
"--refresh",
"--extra-experimental-features",
"nix-command",
]
result = subprocess.run(cmd + [url], capture_output=True, text=True)
hash = re.search(r"hash\s+\'(sha256-.{44})\'", result.stderr).groups()[0]
return url, hash

def to_name_full(self):
return self.package_name_full(self.pname, self.version)

def to_attrs(self):
deps = set()
excludes = list(map(
lambda e: os.path.join(self.path, e),
self.meta["package"]["exclude"] if "exclude" in self.meta["package"] else [],
))
for root, _, files in os.walk(self.path):
for file in filter(lambda f: f.split(".")[-1] == "typ", files):
file_path = os.path.join(root, file)
if file_path in excludes:
continue
with open(file_path, "r") as f:
deps.update(
set(
re.findall(
r"^\s*#import\s+\"@preview/([\w|-]+):(\d+.\d+.\d+)\"",
f.read(),
re.MULTILINE,
)
)
)
self.deps = list(
filter(lambda p: p[0] != self.pname or p[1] != self.version, deps)
)
source_url, source_hash = self.source()

return dict(
url=source_url,
hash=source_hash,
typstDeps=[
self.package_name_full(p, v)
for p, v in sorted(self.deps, key=lambda x: (x[0], Version(x[1])))
],
description=self.description,
license=self.license_tokens(),
) | (dict(homepage=self.repo) if self.repo else dict())


def generate_typst_packages(preview_dir, output_file):
package_tree = dict()

print("Parsing metadata... from", preview_dir)
for p in os.listdir(preview_dir):
package_dir = os.path.join(preview_dir, p)
for v in os.listdir(package_dir):
package_version_dir = os.path.join(package_dir, v)
with open(
os.path.join(package_version_dir, "typst.toml"), "rb"
) as meta_file:
try:
package = TypstPackage(
pname=p,
version=v,
meta=tomli.load(meta_file),
path=package_version_dir,
)
if package.pname in package_tree:
package_tree[package.pname][v] = package
else:
package_tree[package.pname] = dict({v: package})
except tomli.TOMLDecodeError:
print("Invalid typst.toml:", package_version_dir)

with open(output_file, "wb") as typst_packages:

def generate_package(pname, package_subtree):
sorted_keys = sorted(package_subtree.keys(), key=Version, reverse=True)
print(f"Generating metadata for {pname}")
return {
pname: OrderedDict(
(k, package_subtree[k].to_attrs()) for k in sorted_keys
)
}

with concurrent.futures.ThreadPoolExecutor(max_workers=100) as executor:
sorted_packages = sorted(package_tree.items(), key=lambda x: x[0])
futures = list()
for pname, psubtree in sorted_packages:
futures.append(executor.submit(generate_package, pname, psubtree))
packages = OrderedDict(
(package, subtree)
for future in futures
for package, subtree in future.result().items()
)
print(f"Writing metadata... to {output_file}")
tomli_w.dump(packages, typst_packages)


def main(args):
PREVIEW_DIR = "packages/preview"
TYPST_PACKAGE_TARBALL_URL = (
"https://github.com/typst/packages/archive/refs/heads/main.tar.gz"
)

directory = args.directory
if not directory:
tempdir = tempfile.mkdtemp()
print(tempdir)
typst_tarball = os.path.join(tempdir, "main.tar.gz")

print(
"Downloading Typst packages source from {} to {}".format(
TYPST_PACKAGE_TARBALL_URL, typst_tarball
)
)
with request.urlopen(
request.Request(TYPST_PACKAGE_TARBALL_URL), timeout=15.0
) as response:
if response.status == 200:
with open(typst_tarball, "wb+") as f:
f.write(response.read())
else:
print("Download failed")
exit(1)
with tarfile.open(typst_tarball) as tar:
tar.extractall(path=tempdir, filter="data")
directory = os.path.join(tempdir, "packages-main")
directory = os.path.abspath(directory)

generate_typst_packages(
os.path.join(directory, PREVIEW_DIR),
args.output,
)

exit(0)


if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument(
"-d", "--directory", help="Local Typst Universe repository", default=None
)
parser.add_argument(
"-o",
"--output",
help="Output file",
default=os.path.join(os.path.abspath("."), "typst-packages-from-universe.toml"),
)
args = parser.parse_args()
main(args)
60 changes: 60 additions & 0 deletions pkgs/build-support/build-typst-package.nix
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
{
lib,
stdenvNoCC,
}:

/**
`buildTypstPackage` is a helper builder for typst packages.

# Inputs

`attrs`
: attrs for stdenvNoCC.mkDerivation + typstDeps (a list of `buildTypstPackage` derivations)

# Example
```nix
{ buildTypstPackage, typstPackages }:

buildTypstPackage {
pname = "example";
version = "0.0.1";
src = ./.;
typstDeps = with typstPackages; [ oxifmt ];
}
```
*/

lib.extendMkDerivation {
constructDrv = stdenvNoCC.mkDerivation;

excludeDrvArgNames = [
"typstDeps"
];

extendDrvArgs =
finalAttrs:
{
typstDeps ? [ ],
...
}@attrs:
{
name = "typst-package-${finalAttrs.pname}-${finalAttrs.version}";

installPhase =
let
outDir = "$out/lib/typst-packages/${finalAttrs.pname}/${finalAttrs.version}";
in
''
runHook preInstall
mkdir -p ${outDir}
cp -r . ${outDir}
runHook postInstall
'';

propagatedBuildInputs = typstDeps;

passthru = {
inherit typstDeps;
};
};
}
Loading