-
-
Notifications
You must be signed in to change notification settings - Fork 16.3k
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
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
5e43ed5
typst: add customized builder for typst packages
cherrypiejam 8149e60
maintainers: add cherrypiejam
cherrypiejam d976d61
typst: add typst packages from typst universe
cherrypiejam c47a2a8
typst: add support to instantiate typst with a set of typst packages
cherrypiejam 6b1d984
doc/languages-frameworks/typst: Add doc for typst env and packages
cherrypiejam File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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: { | ||
cherrypiejam marked this conversation as resolved.
Show resolved
Hide resolved
|
||
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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
cherrypiejam marked this conversation as resolved.
Show resolved
Hide resolved
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
}; | ||
}; | ||
} |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.