typvend 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- typvend/__init__.py +7 -0
- typvend/__main__.py +6 -0
- typvend/cli.py +202 -0
- typvend/downloader.py +88 -0
- typvend/index.py +98 -0
- typvend/scanner.py +62 -0
- typvend-0.1.0.dist-info/METADATA +79 -0
- typvend-0.1.0.dist-info/RECORD +11 -0
- typvend-0.1.0.dist-info/WHEEL +4 -0
- typvend-0.1.0.dist-info/entry_points.txt +2 -0
- typvend-0.1.0.dist-info/licenses/LICENSE +21 -0
typvend/__init__.py
ADDED
typvend/__main__.py
ADDED
typvend/cli.py
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"""Command-line interface for the typvend tool.
|
|
2
|
+
|
|
3
|
+
This module sets up the argument parser, handles the subcommands 'add' and 'scan',
|
|
4
|
+
and configures logging.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import argparse
|
|
8
|
+
import logging
|
|
9
|
+
import re
|
|
10
|
+
import sys
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
import niquests
|
|
14
|
+
import platformdirs
|
|
15
|
+
|
|
16
|
+
from typvend.downloader import download_package
|
|
17
|
+
from typvend.index import resolve_latest_version
|
|
18
|
+
from typvend.scanner import scan_path
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger("typvend")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def get_default_output() -> Path:
|
|
24
|
+
"""Returns the platform-specific default Typst package directory.
|
|
25
|
+
|
|
26
|
+
Returns:
|
|
27
|
+
A Path object pointing to the system package directory.
|
|
28
|
+
"""
|
|
29
|
+
return platformdirs.user_data_path("typst") / "packages"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def parse_package_arg(pkg: str) -> tuple[str, str]:
|
|
33
|
+
"""Parses a package argument in format name[@version].
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
pkg: A string of the form "name" or "name@version".
|
|
37
|
+
|
|
38
|
+
Returns:
|
|
39
|
+
A tuple (name, version) where version is "latest" if not specified.
|
|
40
|
+
|
|
41
|
+
Raises:
|
|
42
|
+
ValueError: If the package name is empty or contains invalid characters.
|
|
43
|
+
"""
|
|
44
|
+
if "@" in pkg:
|
|
45
|
+
parts = pkg.split("@", 1)
|
|
46
|
+
name = parts[0]
|
|
47
|
+
version = parts[1] or "latest"
|
|
48
|
+
else:
|
|
49
|
+
name = pkg
|
|
50
|
+
version = "latest"
|
|
51
|
+
|
|
52
|
+
if not name or not re.fullmatch(r"[a-zA-Z0-9_-]+", name):
|
|
53
|
+
msg = f"Invalid package name: '{name}'. Only alphanumeric, hyphens, underscores allowed."
|
|
54
|
+
raise ValueError(msg)
|
|
55
|
+
|
|
56
|
+
return name, version
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def handle_add(args: argparse.Namespace) -> int:
|
|
60
|
+
"""Handles the 'add' subcommand.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
args: Parsed command-line arguments.
|
|
64
|
+
|
|
65
|
+
Returns:
|
|
66
|
+
0 if all packages were successfully vendored, 1 otherwise.
|
|
67
|
+
"""
|
|
68
|
+
namespace: str = args.namespace
|
|
69
|
+
output_dir = Path(args.output) if args.output else get_default_output()
|
|
70
|
+
force: bool = args.force
|
|
71
|
+
|
|
72
|
+
failed = False
|
|
73
|
+
|
|
74
|
+
# Type refinement
|
|
75
|
+
packages: list[str] = args.packages
|
|
76
|
+
|
|
77
|
+
for pkg_arg in packages:
|
|
78
|
+
name, version = parse_package_arg(pkg_arg)
|
|
79
|
+
try:
|
|
80
|
+
if version == "latest":
|
|
81
|
+
logger.info("Resolving latest version for %s...", name)
|
|
82
|
+
version = resolve_latest_version(name, namespace)
|
|
83
|
+
logger.info("Latest version resolved to %s", version)
|
|
84
|
+
|
|
85
|
+
download_package(
|
|
86
|
+
name=name,
|
|
87
|
+
version=version,
|
|
88
|
+
output_dir=output_dir,
|
|
89
|
+
namespace=namespace,
|
|
90
|
+
force=force,
|
|
91
|
+
)
|
|
92
|
+
except (ValueError, TypeError, niquests.RequestException, OSError):
|
|
93
|
+
failed = True
|
|
94
|
+
logger.error("Error vendoring package '%s'", pkg_arg, exc_info=args.verbose)
|
|
95
|
+
|
|
96
|
+
return 1 if failed else 0
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def handle_scan(args: argparse.Namespace) -> int:
|
|
100
|
+
"""Handles the 'scan' subcommand.
|
|
101
|
+
|
|
102
|
+
Args:
|
|
103
|
+
args: Parsed command-line arguments.
|
|
104
|
+
|
|
105
|
+
Returns:
|
|
106
|
+
0 if all discovered packages were successfully vendored, 1 otherwise.
|
|
107
|
+
"""
|
|
108
|
+
namespace: str = args.namespace
|
|
109
|
+
output_dir = Path(args.output) if args.output else get_default_output()
|
|
110
|
+
force: bool = args.force
|
|
111
|
+
scan_target = Path(args.path)
|
|
112
|
+
|
|
113
|
+
if not scan_target.exists():
|
|
114
|
+
logger.error("Scan path does not exist: %s", scan_target)
|
|
115
|
+
return 1
|
|
116
|
+
|
|
117
|
+
logger.info("Scanning %s for package imports...", scan_target)
|
|
118
|
+
packages = scan_path(scan_target, namespace)
|
|
119
|
+
logger.info("Discovered %d package(s): %s", len(packages), packages)
|
|
120
|
+
|
|
121
|
+
if not packages:
|
|
122
|
+
logger.info("No packages found to vendor.")
|
|
123
|
+
return 0
|
|
124
|
+
|
|
125
|
+
failed = False
|
|
126
|
+
for name, version in sorted(packages):
|
|
127
|
+
try:
|
|
128
|
+
download_package(
|
|
129
|
+
name=name,
|
|
130
|
+
version=version,
|
|
131
|
+
output_dir=output_dir,
|
|
132
|
+
namespace=namespace,
|
|
133
|
+
force=force,
|
|
134
|
+
)
|
|
135
|
+
except (ValueError, TypeError, niquests.RequestException, OSError):
|
|
136
|
+
failed = True
|
|
137
|
+
logger.error("Error vendoring package '%s:%s'", name, version, exc_info=args.verbose)
|
|
138
|
+
|
|
139
|
+
return 1 if failed else 0
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def main() -> None:
|
|
143
|
+
"""Main entry point for the CLI."""
|
|
144
|
+
parent_parser = argparse.ArgumentParser(add_help=False)
|
|
145
|
+
parent_parser.add_argument(
|
|
146
|
+
"-o", "--output", help="Custom output directory for vendored packages"
|
|
147
|
+
)
|
|
148
|
+
parent_parser.add_argument(
|
|
149
|
+
"--namespace",
|
|
150
|
+
default="preview",
|
|
151
|
+
help="Package namespace (default: preview)",
|
|
152
|
+
)
|
|
153
|
+
parent_parser.add_argument(
|
|
154
|
+
"-f",
|
|
155
|
+
"--force",
|
|
156
|
+
action="store_true",
|
|
157
|
+
help="Re-download package even if destination already exists",
|
|
158
|
+
)
|
|
159
|
+
parent_parser.add_argument(
|
|
160
|
+
"-v",
|
|
161
|
+
"--verbose",
|
|
162
|
+
action="store_true",
|
|
163
|
+
help="Enable verbose output logging",
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
parser = argparse.ArgumentParser(description="typvend — Typst Package Vendoring CLI")
|
|
167
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
168
|
+
|
|
169
|
+
add_parser = subparsers.add_parser(
|
|
170
|
+
"add", parents=[parent_parser], help="Add explicit package(s) by name"
|
|
171
|
+
)
|
|
172
|
+
add_parser.add_argument(
|
|
173
|
+
"packages",
|
|
174
|
+
nargs="+",
|
|
175
|
+
help="Package name(s) optionally with version (e.g. fontawesome or fontawesome@0.6.0)",
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
scan_parser = subparsers.add_parser(
|
|
179
|
+
"scan",
|
|
180
|
+
parents=[parent_parser],
|
|
181
|
+
help="Scan files/directories and vendor all discovered package imports",
|
|
182
|
+
)
|
|
183
|
+
scan_parser.add_argument("path", help="Path to file or directory to scan for imports")
|
|
184
|
+
|
|
185
|
+
args = parser.parse_args()
|
|
186
|
+
|
|
187
|
+
# Configure logging
|
|
188
|
+
log_level = logging.INFO if args.verbose else logging.WARNING
|
|
189
|
+
logging.basicConfig(
|
|
190
|
+
level=log_level,
|
|
191
|
+
format="%(levelname)s: %(message)s",
|
|
192
|
+
)
|
|
193
|
+
logger.setLevel(log_level)
|
|
194
|
+
|
|
195
|
+
if args.command == "add":
|
|
196
|
+
code = handle_add(args)
|
|
197
|
+
elif args.command == "scan":
|
|
198
|
+
code = handle_scan(args)
|
|
199
|
+
else:
|
|
200
|
+
code = 1
|
|
201
|
+
|
|
202
|
+
sys.exit(code)
|
typvend/downloader.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Module for downloading and extracting Typst packages.
|
|
2
|
+
|
|
3
|
+
This module provides functions to fetch a package tarball from the Typst
|
|
4
|
+
repository, verify its paths to prevent directory traversal, and extract
|
|
5
|
+
its contents to the local vendor directory.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import io
|
|
9
|
+
import logging
|
|
10
|
+
import sys
|
|
11
|
+
import tarfile
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
import niquests
|
|
15
|
+
|
|
16
|
+
from typvend import __version__
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def download_package(
|
|
22
|
+
name: str,
|
|
23
|
+
version: str,
|
|
24
|
+
output_dir: Path,
|
|
25
|
+
namespace: str = "preview",
|
|
26
|
+
*,
|
|
27
|
+
force: bool = False,
|
|
28
|
+
) -> bool:
|
|
29
|
+
"""Downloads and extracts a Typst package to the local directory.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
name: The name of the package.
|
|
33
|
+
version: The package version (e.g. "0.6.0").
|
|
34
|
+
output_dir: The base vendor output directory.
|
|
35
|
+
namespace: The namespace of the package (e.g. "preview").
|
|
36
|
+
force: If True, overwrite the package even if it already exists.
|
|
37
|
+
|
|
38
|
+
Returns:
|
|
39
|
+
True if the package was downloaded and extracted, False if it was
|
|
40
|
+
skipped because it already exists and force was False.
|
|
41
|
+
|
|
42
|
+
Raises:
|
|
43
|
+
ValueError: If a directory traversal is detected in the tarball.
|
|
44
|
+
niquests.RequestException: If the package download fails.
|
|
45
|
+
"""
|
|
46
|
+
dest_dir = output_dir / namespace / name / version
|
|
47
|
+
if dest_dir.exists() and not force:
|
|
48
|
+
logger.info(
|
|
49
|
+
"Skipping package %s:%s (already exists at %s)",
|
|
50
|
+
name,
|
|
51
|
+
version,
|
|
52
|
+
dest_dir,
|
|
53
|
+
)
|
|
54
|
+
return False
|
|
55
|
+
|
|
56
|
+
url = f"https://packages.typst.org/{namespace}/{name}-{version}.tar.gz"
|
|
57
|
+
logger.info("Downloading %s...", url)
|
|
58
|
+
headers = {"User-Agent": f"typvend/{__version__}"}
|
|
59
|
+
|
|
60
|
+
try:
|
|
61
|
+
response = niquests.get(url, headers=headers, timeout=30)
|
|
62
|
+
response.raise_for_status()
|
|
63
|
+
content = response.content
|
|
64
|
+
if not content:
|
|
65
|
+
msg = f"Failed to download package: no content received from {url}"
|
|
66
|
+
raise ValueError(msg)
|
|
67
|
+
except niquests.RequestException as e:
|
|
68
|
+
logger.error("Failed to download package %s:%s: %s", name, version, e)
|
|
69
|
+
raise
|
|
70
|
+
|
|
71
|
+
logger.info("Extracting %s to %s...", name, dest_dir)
|
|
72
|
+
dest_dir.mkdir(parents=True, exist_ok=True)
|
|
73
|
+
dest_abs = dest_dir.resolve()
|
|
74
|
+
|
|
75
|
+
tar_data = io.BytesIO(content)
|
|
76
|
+
with tarfile.open(fileobj=tar_data, mode="r:gz") as tar:
|
|
77
|
+
if sys.version_info >= (3, 12):
|
|
78
|
+
tar.extractall(path=dest_dir, filter="data")
|
|
79
|
+
else:
|
|
80
|
+
for member in tar.getmembers():
|
|
81
|
+
member_path = (dest_dir / member.name).resolve()
|
|
82
|
+
if dest_abs not in member_path.parents and member_path != dest_abs:
|
|
83
|
+
msg = f"Attempted directory traversal in tarball: {member.name}"
|
|
84
|
+
raise ValueError(msg)
|
|
85
|
+
tar.extractall(path=dest_dir)
|
|
86
|
+
|
|
87
|
+
logger.info("Successfully vendored %s:%s", name, version)
|
|
88
|
+
return True
|
typvend/index.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Module for fetching and parsing the Typst packages index.
|
|
2
|
+
|
|
3
|
+
This module provides functions to fetch the official package list and resolve
|
|
4
|
+
the latest version for any given package name.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import niquests
|
|
10
|
+
|
|
11
|
+
from typvend import __version__
|
|
12
|
+
|
|
13
|
+
# In-memory cache for index.json requests. Key is namespace.
|
|
14
|
+
_INDEX_CACHE: dict[str, list[dict[str, Any]]] = {}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def fetch_index(namespace: str = "preview") -> list[dict[str, Any]]:
|
|
18
|
+
"""Fetches the Typst package index for the given namespace.
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
namespace: The package namespace, e.g. "preview".
|
|
22
|
+
|
|
23
|
+
Returns:
|
|
24
|
+
A list of dictionaries containing package metadata.
|
|
25
|
+
|
|
26
|
+
Raises:
|
|
27
|
+
ValueError: If the response cannot be parsed or is invalid.
|
|
28
|
+
"""
|
|
29
|
+
if namespace in _INDEX_CACHE:
|
|
30
|
+
return _INDEX_CACHE[namespace]
|
|
31
|
+
|
|
32
|
+
url = f"https://packages.typst.org/{namespace}/index.json"
|
|
33
|
+
headers = {"User-Agent": f"typvend/{__version__}"}
|
|
34
|
+
|
|
35
|
+
try:
|
|
36
|
+
response = niquests.get(url, headers=headers, timeout=15)
|
|
37
|
+
response.raise_for_status()
|
|
38
|
+
data = response.json()
|
|
39
|
+
except niquests.RequestException as e:
|
|
40
|
+
msg = f"Failed to fetch package index from {url}: {e}"
|
|
41
|
+
raise ValueError(msg) from e
|
|
42
|
+
|
|
43
|
+
if not isinstance(data, list):
|
|
44
|
+
msg = f"Expected index.json to be a list, got {type(data)}"
|
|
45
|
+
raise TypeError(msg)
|
|
46
|
+
|
|
47
|
+
_INDEX_CACHE[namespace] = data
|
|
48
|
+
return data
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def parse_semver(version_str: str) -> tuple[int, int, int] | None:
|
|
52
|
+
"""Parses a semver version string into a tuple of integers.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
version_str: A string of the form "major.minor.patch".
|
|
56
|
+
|
|
57
|
+
Returns:
|
|
58
|
+
A tuple (major, minor, patch), or None if the version is invalid.
|
|
59
|
+
"""
|
|
60
|
+
parts = version_str.split(".")
|
|
61
|
+
if len(parts) != 3: # noqa: PLR2004
|
|
62
|
+
return None
|
|
63
|
+
try:
|
|
64
|
+
return int(parts[0]), int(parts[1]), int(parts[2])
|
|
65
|
+
except ValueError:
|
|
66
|
+
return None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def resolve_latest_version(pkg_name: str, namespace: str = "preview") -> str:
|
|
70
|
+
"""Resolves the latest version of a package from the Typst packages index.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
pkg_name: The name of the package.
|
|
74
|
+
namespace: The namespace to search.
|
|
75
|
+
|
|
76
|
+
Returns:
|
|
77
|
+
The latest version string (e.g. "0.6.0").
|
|
78
|
+
|
|
79
|
+
Raises:
|
|
80
|
+
ValueError: If the package is not found in the index.
|
|
81
|
+
TypeError: If the upstream index response has an unexpected type.
|
|
82
|
+
"""
|
|
83
|
+
index_data = fetch_index(namespace)
|
|
84
|
+
versions: list[tuple[tuple[int, int, int], str]] = []
|
|
85
|
+
for pkg in index_data:
|
|
86
|
+
if pkg.get("name") == pkg_name:
|
|
87
|
+
version = pkg.get("version")
|
|
88
|
+
if isinstance(version, str):
|
|
89
|
+
parsed = parse_semver(version)
|
|
90
|
+
if parsed is not None:
|
|
91
|
+
versions.append((parsed, version))
|
|
92
|
+
|
|
93
|
+
if not versions:
|
|
94
|
+
msg = f"Package '{pkg_name}' not found in namespace '{namespace}'"
|
|
95
|
+
raise ValueError(msg)
|
|
96
|
+
|
|
97
|
+
versions.sort(key=lambda v: v[0])
|
|
98
|
+
return versions[-1][1]
|
typvend/scanner.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Module for scanning Typst files to find imported packages.
|
|
2
|
+
|
|
3
|
+
This module provides functionality to scan single .typ files or recursively
|
|
4
|
+
scan directories to identify all referenced packages in a given namespace.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
import re
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def scan_file(file_path: Path, namespace: str = "preview") -> set[tuple[str, str]]:
|
|
15
|
+
"""Scans a single file for package imports in the given namespace.
|
|
16
|
+
|
|
17
|
+
Matches imports like `@<namespace>/<pkg>:<version>`.
|
|
18
|
+
|
|
19
|
+
Args:
|
|
20
|
+
file_path: The path of the file to scan.
|
|
21
|
+
namespace: The namespace to search for (e.g. "preview").
|
|
22
|
+
|
|
23
|
+
Returns:
|
|
24
|
+
A set of tuples (package_name, version).
|
|
25
|
+
"""
|
|
26
|
+
found: set[tuple[str, str]] = set()
|
|
27
|
+
pattern = re.compile(rf"@{re.escape(namespace)}/([a-zA-Z0-9_-]+):(\d+\.\d+\.\d+)")
|
|
28
|
+
|
|
29
|
+
try:
|
|
30
|
+
content = file_path.read_text(encoding="utf-8", errors="ignore")
|
|
31
|
+
except OSError as e:
|
|
32
|
+
logger.warning("Could not read file %s: %s", file_path, e)
|
|
33
|
+
return found
|
|
34
|
+
|
|
35
|
+
for match in pattern.finditer(content):
|
|
36
|
+
pkg_name = match.group(1)
|
|
37
|
+
version = match.group(2)
|
|
38
|
+
found.add((pkg_name, version))
|
|
39
|
+
|
|
40
|
+
return found
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def scan_path(path: Path, namespace: str = "preview") -> set[tuple[str, str]]:
|
|
44
|
+
"""Scans a file or recursively scans a directory for package imports.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
path: Path to a file or directory.
|
|
48
|
+
namespace: The namespace to search for (e.g. "preview").
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
A set of tuples (package_name, version).
|
|
52
|
+
"""
|
|
53
|
+
found: set[tuple[str, str]] = set()
|
|
54
|
+
|
|
55
|
+
if path.is_file():
|
|
56
|
+
return scan_file(path, namespace)
|
|
57
|
+
|
|
58
|
+
if path.is_dir():
|
|
59
|
+
for file in path.rglob("*.typ"):
|
|
60
|
+
found.update(scan_file(file, namespace))
|
|
61
|
+
|
|
62
|
+
return found
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: typvend
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Typst Package Vendoring CLI
|
|
5
|
+
License: MIT License
|
|
6
|
+
|
|
7
|
+
Copyright (c) 2026 Diego Alvarez S.
|
|
8
|
+
|
|
9
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
10
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
11
|
+
in the Software without restriction, including without limitation the rights
|
|
12
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
13
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
14
|
+
furnished to do so, subject to the following conditions:
|
|
15
|
+
|
|
16
|
+
The above copyright notice and this permission notice shall be included in all
|
|
17
|
+
copies or substantial portions of the Software.
|
|
18
|
+
|
|
19
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
20
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
21
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
22
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
23
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
24
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
25
|
+
SOFTWARE.
|
|
26
|
+
License-File: LICENSE
|
|
27
|
+
Requires-Python: >=3.11
|
|
28
|
+
Requires-Dist: niquests>=3.18.0
|
|
29
|
+
Requires-Dist: platformdirs>=4.9
|
|
30
|
+
Description-Content-Type: text/markdown
|
|
31
|
+
|
|
32
|
+
# typvend — Typst Package Vendoring CLI
|
|
33
|
+
|
|
34
|
+
`typvend` is a lightweight Python CLI utility designed to vendor official Typst packages locally for offline development, sandboxed builds, or containerized production CI/CD workflows.
|
|
35
|
+
|
|
36
|
+
## Why?
|
|
37
|
+
|
|
38
|
+
Typst downloads packages on the fly at compile time with no official way to pre-download them.
|
|
39
|
+
This can be problematic for offline compilation and read-only production environments (like containers).
|
|
40
|
+
The solution is to either run the compilation once to fetch the packages or download them manually.
|
|
41
|
+
|
|
42
|
+
`typvend` simplifies this, downloading packages to the default Typst cache path or any directory you choose (then point Typst to it via `--package-cache-path`), in two ways:
|
|
43
|
+
- **Explicit:** `add <pkg>[@<version>]` — download specific packages by name, with a version or `@latest`.
|
|
44
|
+
- **Scan:** recursively find all `@preview/<pkg>:<version>` imports in `.typ` files and vendor them in one go.
|
|
45
|
+
|
|
46
|
+
## Usage
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
# Install and run instantly using uvx / pipx
|
|
50
|
+
uvx typvend --help
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Global options:
|
|
54
|
+
- `-o`, `--output DIR` — Custom directory to extract packages (defaults to native OS Typst search path).
|
|
55
|
+
- `--namespace NS` — Custom namespace (defaults to `preview`).
|
|
56
|
+
- `-f`, `--force` — Re-download package even if it already exists.
|
|
57
|
+
- `-v`, `--verbose` — Enable verbose output logs.
|
|
58
|
+
|
|
59
|
+
### 1. Adding Packages Explicitly
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
# Download latest version of fontawesome
|
|
63
|
+
uvx typvend add fontawesome
|
|
64
|
+
|
|
65
|
+
# Download specific versions
|
|
66
|
+
uvx typvend add fontawesome@0.5.0 cetz
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
### 2. Scanning Project Directories
|
|
70
|
+
|
|
71
|
+
Recursively searches a file or directory for package imports and vendors all discovered packages in one command:
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
# Scan a templates directory and output packages to typst cache folder
|
|
75
|
+
uvx typvend scan ./templates
|
|
76
|
+
|
|
77
|
+
# Scan and output to a custom directory (e.g. for Docker cache stages)
|
|
78
|
+
uvx typvend scan ./templates --output /typst-packages
|
|
79
|
+
```
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
typvend/__init__.py,sha256=RPIBAOSE283x37HKTTCYw5j99UkxQu5Ct7ODMiwNdtA,178
|
|
2
|
+
typvend/__main__.py,sha256=k60UlPZkYvsMv1yayg6Cn6_iBUHL5FAkmCel_PtHH9k,137
|
|
3
|
+
typvend/cli.py,sha256=059GD-lVh3n1Pn3YGvacTvj3J5COUy5vGOoSd3y2jrw,5888
|
|
4
|
+
typvend/downloader.py,sha256=zx_OsybLergWRYLrjNpgDan899sQB5cc0IZ_uLFqUmc,2902
|
|
5
|
+
typvend/index.py,sha256=rai9B873qYTpd-wS_Mv0mK6WJcZSGMLkI03lNzW0P2w,2977
|
|
6
|
+
typvend/scanner.py,sha256=osFxJvqGNkyOv-qwQQChLAieo3IRv3R8M5O63gl8Nio,1796
|
|
7
|
+
typvend-0.1.0.dist-info/METADATA,sha256=KfJ6RFuxB9f2HqsdU22xMjMUFbc17lQcIHODMLPZSbo,3388
|
|
8
|
+
typvend-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
9
|
+
typvend-0.1.0.dist-info/entry_points.txt,sha256=7n2Xt5tKAGDXJ1Z2qoocCrINRroJ9MhNkYK77q8Tyks,45
|
|
10
|
+
typvend-0.1.0.dist-info/licenses/LICENSE,sha256=Y4U8gNrx2iAql71iNTZWdpNzYWBvoF0KZKVTM_cCfHQ,1073
|
|
11
|
+
typvend-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Diego Alvarez S.
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|