typst-forge 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.
- typst_forge/__init__.py +5 -0
- typst_forge/__main__.py +5 -0
- typst_forge/cli.py +162 -0
- typst_forge/config.py +34 -0
- typst_forge/git.py +131 -0
- typst_forge/manifest.py +50 -0
- typst_forge/registry.py +427 -0
- typst_forge/scaffold.py +70 -0
- typst_forge/source.py +77 -0
- typst_forge-0.1.0.dist-info/METADATA +73 -0
- typst_forge-0.1.0.dist-info/RECORD +14 -0
- typst_forge-0.1.0.dist-info/WHEEL +5 -0
- typst_forge-0.1.0.dist-info/entry_points.txt +2 -0
- typst_forge-0.1.0.dist-info/top_level.txt +1 -0
typst_forge/__init__.py
ADDED
typst_forge/__main__.py
ADDED
typst_forge/cli.py
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from .registry import (
|
|
8
|
+
install_source,
|
|
9
|
+
installation_status,
|
|
10
|
+
list_installed_packages,
|
|
11
|
+
remove_installation,
|
|
12
|
+
update_installation,
|
|
13
|
+
)
|
|
14
|
+
from .scaffold import scaffold_project
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
18
|
+
parser = argparse.ArgumentParser(prog="typst-forge")
|
|
19
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
20
|
+
|
|
21
|
+
init_parser = subparsers.add_parser("init", help="Create a new Typst project")
|
|
22
|
+
init_parser.add_argument("path", nargs="?", default=".")
|
|
23
|
+
init_parser.add_argument("--kind", choices=["template", "package"], default="package")
|
|
24
|
+
init_parser.add_argument("--name")
|
|
25
|
+
init_parser.add_argument("--version", default="0.1.0")
|
|
26
|
+
init_parser.set_defaults(handler=_cmd_init)
|
|
27
|
+
|
|
28
|
+
install_parser = subparsers.add_parser(
|
|
29
|
+
"install", help="Install a local path or GitHub source"
|
|
30
|
+
)
|
|
31
|
+
install_parser.add_argument("source")
|
|
32
|
+
install_parser.add_argument("--namespace", default="local")
|
|
33
|
+
install_parser.add_argument("--version")
|
|
34
|
+
install_parser.add_argument("--registry-root", help="Override the Typst packages root")
|
|
35
|
+
install_parser.add_argument("--cache-root", help="Override the Typst Forge cache root")
|
|
36
|
+
install_parser.add_argument(
|
|
37
|
+
"--link-mode",
|
|
38
|
+
choices=["auto", "symlink", "copy"],
|
|
39
|
+
default="auto",
|
|
40
|
+
help="How installed projects should be materialized",
|
|
41
|
+
)
|
|
42
|
+
install_parser.add_argument("--force", action="store_true")
|
|
43
|
+
install_parser.set_defaults(handler=_cmd_install)
|
|
44
|
+
|
|
45
|
+
update_parser = subparsers.add_parser(
|
|
46
|
+
"update", help="Reinstall an installed package or source"
|
|
47
|
+
)
|
|
48
|
+
update_parser.add_argument("target")
|
|
49
|
+
update_parser.add_argument("--namespace", default="local")
|
|
50
|
+
update_parser.add_argument("--version")
|
|
51
|
+
update_parser.add_argument("--registry-root", help="Override the Typst packages root")
|
|
52
|
+
update_parser.add_argument("--cache-root", help="Override the Typst Forge cache root")
|
|
53
|
+
update_parser.add_argument(
|
|
54
|
+
"--link-mode",
|
|
55
|
+
choices=["auto", "symlink", "copy"],
|
|
56
|
+
default=None,
|
|
57
|
+
help="How installed projects should be materialized",
|
|
58
|
+
)
|
|
59
|
+
update_parser.set_defaults(handler=_cmd_update)
|
|
60
|
+
|
|
61
|
+
remove_parser = subparsers.add_parser(
|
|
62
|
+
"remove", help="Remove an installed package or source"
|
|
63
|
+
)
|
|
64
|
+
remove_parser.add_argument("target")
|
|
65
|
+
remove_parser.add_argument("--namespace", default="local")
|
|
66
|
+
remove_parser.add_argument("--version")
|
|
67
|
+
remove_parser.add_argument("--registry-root", help="Override the Typst packages root")
|
|
68
|
+
remove_parser.set_defaults(handler=_cmd_remove)
|
|
69
|
+
|
|
70
|
+
list_parser = subparsers.add_parser("list", help="List installed packages and templates")
|
|
71
|
+
list_parser.add_argument("--json", action="store_true")
|
|
72
|
+
list_parser.add_argument("--registry-root", help="Override the Typst packages root")
|
|
73
|
+
list_parser.set_defaults(handler=_cmd_list)
|
|
74
|
+
|
|
75
|
+
status_parser = subparsers.add_parser(
|
|
76
|
+
"status", help="Show status for an installed package or source"
|
|
77
|
+
)
|
|
78
|
+
status_parser.add_argument("target")
|
|
79
|
+
status_parser.add_argument("--namespace", default="local")
|
|
80
|
+
status_parser.add_argument("--version")
|
|
81
|
+
status_parser.add_argument("--registry-root", help="Override the Typst packages root")
|
|
82
|
+
status_parser.set_defaults(handler=_cmd_status)
|
|
83
|
+
|
|
84
|
+
return parser
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def main(argv: list[str] | None = None) -> int:
|
|
88
|
+
args = build_parser().parse_args(argv)
|
|
89
|
+
return args.handler(args)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _cmd_init(args: argparse.Namespace) -> int:
|
|
93
|
+
root = Path(args.path)
|
|
94
|
+
name = args.name or root.resolve().name.replace("_", "-")
|
|
95
|
+
scaffold_project(root, args.kind, name, args.version)
|
|
96
|
+
print(f"initialized {args.kind} project at {root.resolve()}")
|
|
97
|
+
return 0
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _cmd_install(args: argparse.Namespace) -> int:
|
|
101
|
+
record = install_source(
|
|
102
|
+
args.source,
|
|
103
|
+
namespace=args.namespace,
|
|
104
|
+
version=args.version,
|
|
105
|
+
link_mode=args.link_mode,
|
|
106
|
+
registry_root_override=args.registry_root,
|
|
107
|
+
cache_root_override=args.cache_root,
|
|
108
|
+
force=args.force,
|
|
109
|
+
)
|
|
110
|
+
print(f"installed {record.package} {record.version} -> {record.installed_root}")
|
|
111
|
+
return 0
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _cmd_update(args: argparse.Namespace) -> int:
|
|
115
|
+
records = update_installation(
|
|
116
|
+
args.target,
|
|
117
|
+
namespace=args.namespace,
|
|
118
|
+
version=args.version,
|
|
119
|
+
link_mode=args.link_mode,
|
|
120
|
+
registry_root_override=args.registry_root,
|
|
121
|
+
cache_root_override=args.cache_root,
|
|
122
|
+
)
|
|
123
|
+
for record in records:
|
|
124
|
+
print(f"updated {record.package} {record.version} -> {record.installed_root}")
|
|
125
|
+
return 0
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _cmd_remove(args: argparse.Namespace) -> int:
|
|
129
|
+
removed = remove_installation(
|
|
130
|
+
args.target,
|
|
131
|
+
namespace=args.namespace,
|
|
132
|
+
version=args.version,
|
|
133
|
+
registry_root_override=args.registry_root,
|
|
134
|
+
)
|
|
135
|
+
for path in removed:
|
|
136
|
+
print(f"removed {path}")
|
|
137
|
+
return 0
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _cmd_list(args: argparse.Namespace) -> int:
|
|
141
|
+
items = list_installed_packages(args.registry_root)
|
|
142
|
+
if args.json:
|
|
143
|
+
print(json.dumps(items, indent=2, sort_keys=True))
|
|
144
|
+
return 0
|
|
145
|
+
for item in items:
|
|
146
|
+
print(
|
|
147
|
+
f'{item["namespace"]}/{item["package"]}/{item["version"]} '
|
|
148
|
+
f'[{item["link_mode"]}] <- {item["source"]}'
|
|
149
|
+
)
|
|
150
|
+
return 0
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _cmd_status(args: argparse.Namespace) -> int:
|
|
154
|
+
status = installation_status(
|
|
155
|
+
args.target,
|
|
156
|
+
namespace=args.namespace,
|
|
157
|
+
version=args.version,
|
|
158
|
+
registry_root_override=args.registry_root,
|
|
159
|
+
)
|
|
160
|
+
for key in sorted(status):
|
|
161
|
+
print(f"{key}: {status[key]}")
|
|
162
|
+
return 0
|
typst_forge/config.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def default_registry_root() -> Path:
|
|
8
|
+
xdg_data_home = os.environ.get("XDG_DATA_HOME")
|
|
9
|
+
if xdg_data_home:
|
|
10
|
+
return Path(xdg_data_home).expanduser() / "typst" / "packages"
|
|
11
|
+
return Path.home() / ".local" / "share" / "typst" / "packages"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def default_cache_root() -> Path:
|
|
15
|
+
xdg_cache_home = os.environ.get("XDG_CACHE_HOME")
|
|
16
|
+
if xdg_cache_home:
|
|
17
|
+
return Path(xdg_cache_home).expanduser() / "typst-forge"
|
|
18
|
+
return Path.home() / ".cache" / "typst-forge"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def registry_root(override: str | None = None) -> Path:
|
|
22
|
+
if override:
|
|
23
|
+
return Path(override).expanduser()
|
|
24
|
+
return default_registry_root()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def metadata_root(override: str | None = None) -> Path:
|
|
28
|
+
return registry_root(override) / ".forge"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def cache_root(override: str | None = None) -> Path:
|
|
32
|
+
if override:
|
|
33
|
+
return Path(override).expanduser()
|
|
34
|
+
return default_cache_root()
|
typst_forge/git.py
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
import shutil
|
|
5
|
+
import subprocess
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
VERSION_TAG = re.compile(r"^v?(?P<version>\d+(?:\.\d+){1,2}(?:-[0-9A-Za-z.-]+)?)$")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class GitState:
|
|
15
|
+
root: Path
|
|
16
|
+
commit: str | None
|
|
17
|
+
tags: tuple[str, ...]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def run_git(args: list[str], cwd: Path | None = None) -> str:
|
|
21
|
+
result = subprocess.run(
|
|
22
|
+
["git", *args],
|
|
23
|
+
cwd=cwd,
|
|
24
|
+
check=True,
|
|
25
|
+
text=True,
|
|
26
|
+
capture_output=True,
|
|
27
|
+
)
|
|
28
|
+
return result.stdout.strip()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def git_available() -> bool:
|
|
32
|
+
return shutil.which("git") is not None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def repo_root(start: Path) -> Path:
|
|
36
|
+
output = run_git(["rev-parse", "--show-toplevel"], cwd=start)
|
|
37
|
+
return Path(output)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def git_state(project_root: Path) -> GitState:
|
|
41
|
+
root = repo_root(project_root)
|
|
42
|
+
try:
|
|
43
|
+
commit = run_git(["rev-parse", "HEAD"], cwd=root)
|
|
44
|
+
except subprocess.CalledProcessError:
|
|
45
|
+
commit = None
|
|
46
|
+
tags_output = ""
|
|
47
|
+
if commit is not None:
|
|
48
|
+
tags_output = run_git(["tag", "--points-at", "HEAD"], cwd=root)
|
|
49
|
+
tags = tuple(sorted(filter(None, tags_output.splitlines())))
|
|
50
|
+
return GitState(root=root, commit=commit, tags=tags)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def normalize_version(tag: str) -> str:
|
|
54
|
+
match = VERSION_TAG.fullmatch(tag)
|
|
55
|
+
if not match:
|
|
56
|
+
raise ValueError(f"tag {tag!r} is not a version-like tag")
|
|
57
|
+
return match.group("version")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def version_key(version: str) -> tuple[object, ...]:
|
|
61
|
+
normalized = normalize_version(version)
|
|
62
|
+
core, *suffix = normalized.split("-", 1)
|
|
63
|
+
parts = tuple(int(part) for part in core.split("."))
|
|
64
|
+
prerelease = suffix[0] if suffix else ""
|
|
65
|
+
return (*parts, 1 if not prerelease else 0, prerelease)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def highest_version(tags: list[str]) -> str | None:
|
|
69
|
+
normalized = []
|
|
70
|
+
for tag in tags:
|
|
71
|
+
try:
|
|
72
|
+
normalized.append((version_key(tag), normalize_version(tag)))
|
|
73
|
+
except ValueError:
|
|
74
|
+
continue
|
|
75
|
+
if not normalized:
|
|
76
|
+
return None
|
|
77
|
+
return max(normalized, key=lambda item: item[0])[1]
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def local_version(project_root: Path, requested: str | None = None) -> str:
|
|
81
|
+
manifest_version = None
|
|
82
|
+
if requested is not None:
|
|
83
|
+
return normalize_version(requested)
|
|
84
|
+
|
|
85
|
+
try:
|
|
86
|
+
state = git_state(project_root)
|
|
87
|
+
except subprocess.CalledProcessError:
|
|
88
|
+
state = GitState(root=project_root, commit=None, tags=())
|
|
89
|
+
|
|
90
|
+
resolved = highest_version(list(state.tags))
|
|
91
|
+
if resolved is not None:
|
|
92
|
+
return resolved
|
|
93
|
+
|
|
94
|
+
from .manifest import load_manifest
|
|
95
|
+
|
|
96
|
+
manifest_version = load_manifest(project_root).version
|
|
97
|
+
return normalize_version(manifest_version)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def remote_tags(remote_url: str) -> list[str]:
|
|
101
|
+
try:
|
|
102
|
+
output = run_git(["ls-remote", "--tags", "--refs", remote_url])
|
|
103
|
+
except subprocess.CalledProcessError as exc:
|
|
104
|
+
raise ValueError(f"failed to inspect remote tags for {remote_url!r}") from exc
|
|
105
|
+
tags: list[str] = []
|
|
106
|
+
for line in output.splitlines():
|
|
107
|
+
_, ref = line.split("\t", 1)
|
|
108
|
+
if ref.startswith("refs/tags/"):
|
|
109
|
+
tags.append(ref.removeprefix("refs/tags/"))
|
|
110
|
+
return tags
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def remote_version(remote_url: str, requested: str | None = None) -> str:
|
|
114
|
+
if requested is not None:
|
|
115
|
+
return normalize_version(requested)
|
|
116
|
+
tag = highest_version(remote_tags(remote_url))
|
|
117
|
+
if tag is None:
|
|
118
|
+
raise ValueError(f"no version-like tags found for {remote_url!r}")
|
|
119
|
+
return tag
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def clone_release(remote_url: str, ref: str, destination: Path) -> Path:
|
|
123
|
+
if destination.exists():
|
|
124
|
+
shutil.rmtree(destination)
|
|
125
|
+
subprocess.run(
|
|
126
|
+
["git", "clone", "--quiet", "--depth", "1", "--branch", ref, remote_url, str(destination)],
|
|
127
|
+
check=True,
|
|
128
|
+
text=True,
|
|
129
|
+
capture_output=True,
|
|
130
|
+
)
|
|
131
|
+
return destination
|
typst_forge/manifest.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import tomllib
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from .git import normalize_version
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True)
|
|
11
|
+
class ProjectManifest:
|
|
12
|
+
name: str
|
|
13
|
+
version: str
|
|
14
|
+
entrypoint: str
|
|
15
|
+
kind: str
|
|
16
|
+
raw: dict[str, object]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def load_manifest(project_root: Path) -> ProjectManifest:
|
|
20
|
+
path = project_root / "typst.toml"
|
|
21
|
+
if not path.is_file():
|
|
22
|
+
raise FileNotFoundError(f"missing manifest: {path}")
|
|
23
|
+
data = tomllib.loads(path.read_text(encoding="utf-8"))
|
|
24
|
+
package = data.get("package")
|
|
25
|
+
if not isinstance(package, dict):
|
|
26
|
+
raise ValueError("typst.toml is missing a [package] table")
|
|
27
|
+
|
|
28
|
+
name = _require_string(package, "name")
|
|
29
|
+
version = normalize_version(_require_string(package, "version"))
|
|
30
|
+
entrypoint = _require_string(package, "entrypoint", default="src/lib.typ")
|
|
31
|
+
|
|
32
|
+
tool = data.get("tool")
|
|
33
|
+
kind = "package"
|
|
34
|
+
if isinstance(tool, dict):
|
|
35
|
+
forge = tool.get("typst_forge")
|
|
36
|
+
if isinstance(forge, dict):
|
|
37
|
+
maybe_kind = forge.get("kind", "package")
|
|
38
|
+
if isinstance(maybe_kind, str) and maybe_kind:
|
|
39
|
+
kind = maybe_kind
|
|
40
|
+
if entrypoint.startswith("template/"):
|
|
41
|
+
kind = "template"
|
|
42
|
+
|
|
43
|
+
return ProjectManifest(name=name, version=version, entrypoint=entrypoint, kind=kind, raw=data)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _require_string(values: dict[str, object], key: str, default: str | None = None) -> str:
|
|
47
|
+
value = values.get(key, default)
|
|
48
|
+
if not isinstance(value, str) or not value:
|
|
49
|
+
raise ValueError(f"typst.toml [{key}] must be a non-empty string")
|
|
50
|
+
return value
|
typst_forge/registry.py
ADDED
|
@@ -0,0 +1,427 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import shutil
|
|
6
|
+
from dataclasses import asdict, dataclass
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from .config import cache_root, metadata_root, registry_root
|
|
11
|
+
from .git import clone_release, local_version, remote_version
|
|
12
|
+
from .manifest import ProjectManifest, load_manifest
|
|
13
|
+
from .source import SourceSpec, parse_source, source_label
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class InstallRecord:
|
|
18
|
+
namespace: str
|
|
19
|
+
package: str
|
|
20
|
+
version: str
|
|
21
|
+
source_raw: str
|
|
22
|
+
source_kind: str
|
|
23
|
+
source_location: str
|
|
24
|
+
link_mode: str
|
|
25
|
+
installed_root: str
|
|
26
|
+
installed_at: str
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def install_path(self) -> Path:
|
|
30
|
+
return Path(self.installed_root)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def package_registry_path(root: str | None, namespace: str, package_name: str, version: str) -> Path:
|
|
34
|
+
return registry_root(root) / namespace / package_name / version
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def metadata_path(root: str | None, namespace: str, package_name: str, version: str) -> Path:
|
|
38
|
+
return metadata_root(root) / namespace / package_name / f"{version}.json"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def install_source(
|
|
42
|
+
source: str | Path,
|
|
43
|
+
namespace: str = "local",
|
|
44
|
+
version: str | None = None,
|
|
45
|
+
link_mode: str = "auto",
|
|
46
|
+
registry_root_override: str | None = None,
|
|
47
|
+
cache_root_override: str | None = None,
|
|
48
|
+
force: bool = False,
|
|
49
|
+
) -> InstallRecord:
|
|
50
|
+
spec = _normalize_source(source)
|
|
51
|
+
source_id = source_label(spec)
|
|
52
|
+
install_root, manifest, resolved_version, source_location = _resolve_source(
|
|
53
|
+
spec,
|
|
54
|
+
requested_version=version,
|
|
55
|
+
cache_root_override=cache_root_override,
|
|
56
|
+
)
|
|
57
|
+
target = package_registry_path(registry_root_override, namespace, manifest.name, resolved_version)
|
|
58
|
+
_install_tree(install_root, target, link_mode=link_mode, force=force)
|
|
59
|
+
record = InstallRecord(
|
|
60
|
+
namespace=namespace,
|
|
61
|
+
package=manifest.name,
|
|
62
|
+
version=resolved_version,
|
|
63
|
+
source_raw=source_id,
|
|
64
|
+
source_kind=spec.kind,
|
|
65
|
+
source_location=source_location,
|
|
66
|
+
link_mode=link_mode,
|
|
67
|
+
installed_root=str(target),
|
|
68
|
+
installed_at=datetime.now(timezone.utc).isoformat(),
|
|
69
|
+
)
|
|
70
|
+
_write_record(record, registry_root_override)
|
|
71
|
+
return record
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def update_installation(
|
|
75
|
+
target: str | Path,
|
|
76
|
+
namespace: str = "local",
|
|
77
|
+
version: str | None = None,
|
|
78
|
+
link_mode: str | None = None,
|
|
79
|
+
registry_root_override: str | None = None,
|
|
80
|
+
cache_root_override: str | None = None,
|
|
81
|
+
) -> list[InstallRecord]:
|
|
82
|
+
records = resolve_records(target, namespace=namespace, version=version, registry_root_override=registry_root_override)
|
|
83
|
+
updated: list[InstallRecord] = []
|
|
84
|
+
for record in records:
|
|
85
|
+
source = _source_from_record(record)
|
|
86
|
+
updated.append(
|
|
87
|
+
install_source(
|
|
88
|
+
source,
|
|
89
|
+
namespace=record.namespace,
|
|
90
|
+
version=record.version,
|
|
91
|
+
link_mode=link_mode or record.link_mode,
|
|
92
|
+
registry_root_override=registry_root_override,
|
|
93
|
+
cache_root_override=cache_root_override,
|
|
94
|
+
force=True,
|
|
95
|
+
)
|
|
96
|
+
)
|
|
97
|
+
return updated
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def remove_installation(
|
|
101
|
+
target: str | Path,
|
|
102
|
+
namespace: str = "local",
|
|
103
|
+
version: str | None = None,
|
|
104
|
+
registry_root_override: str | None = None,
|
|
105
|
+
) -> list[Path]:
|
|
106
|
+
removed: list[Path] = []
|
|
107
|
+
for record in resolve_records(target, namespace=namespace, version=version, registry_root_override=registry_root_override):
|
|
108
|
+
install_path = record.install_path
|
|
109
|
+
if install_path.is_symlink() or install_path.is_file():
|
|
110
|
+
install_path.unlink()
|
|
111
|
+
elif install_path.exists():
|
|
112
|
+
shutil.rmtree(install_path)
|
|
113
|
+
_remove_record(record, registry_root_override)
|
|
114
|
+
_prune_empty_dirs(install_path.parent)
|
|
115
|
+
removed.append(install_path)
|
|
116
|
+
return removed
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def list_installed_packages(registry_root_override: str | None = None) -> list[dict[str, str]]:
|
|
120
|
+
records = _load_records(registry_root_override)
|
|
121
|
+
if not records:
|
|
122
|
+
records = _scan_registry(registry_root_override)
|
|
123
|
+
return [
|
|
124
|
+
{
|
|
125
|
+
"namespace": record.namespace,
|
|
126
|
+
"package": record.package,
|
|
127
|
+
"version": record.version,
|
|
128
|
+
"source": record.source_raw,
|
|
129
|
+
"source_kind": record.source_kind,
|
|
130
|
+
"link_mode": record.link_mode,
|
|
131
|
+
"target": record.installed_root,
|
|
132
|
+
}
|
|
133
|
+
for record in sorted(records, key=lambda item: (item.namespace, item.package, item.version))
|
|
134
|
+
]
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def installation_status(
|
|
138
|
+
target: str | Path,
|
|
139
|
+
namespace: str = "local",
|
|
140
|
+
version: str | None = None,
|
|
141
|
+
registry_root_override: str | None = None,
|
|
142
|
+
) -> dict[str, str]:
|
|
143
|
+
records = resolve_records(target, namespace=namespace, version=version, registry_root_override=registry_root_override)
|
|
144
|
+
if not records:
|
|
145
|
+
return {"installed": "no"}
|
|
146
|
+
if len(records) == 1:
|
|
147
|
+
record = records[0]
|
|
148
|
+
return {
|
|
149
|
+
"installed": "yes",
|
|
150
|
+
"namespace": record.namespace,
|
|
151
|
+
"package": record.package,
|
|
152
|
+
"version": record.version,
|
|
153
|
+
"source": record.source_raw,
|
|
154
|
+
"source_kind": record.source_kind,
|
|
155
|
+
"link_mode": record.link_mode,
|
|
156
|
+
"target": record.installed_root,
|
|
157
|
+
}
|
|
158
|
+
return {
|
|
159
|
+
"installed": "yes",
|
|
160
|
+
"count": str(len(records)),
|
|
161
|
+
"packages": ",".join(f"{record.package}:{record.version}" for record in records),
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def resolve_records(
|
|
166
|
+
target: str | Path,
|
|
167
|
+
namespace: str = "local",
|
|
168
|
+
version: str | None = None,
|
|
169
|
+
registry_root_override: str | None = None,
|
|
170
|
+
) -> list[InstallRecord]:
|
|
171
|
+
try:
|
|
172
|
+
spec = _normalize_source(target)
|
|
173
|
+
except (ValueError, TypeError):
|
|
174
|
+
spec = None
|
|
175
|
+
|
|
176
|
+
records = _load_records(registry_root_override)
|
|
177
|
+
if spec is not None:
|
|
178
|
+
source_id = source_label(spec)
|
|
179
|
+
matching = [record for record in records if record.source_raw == source_id]
|
|
180
|
+
if version is not None:
|
|
181
|
+
matching = [record for record in matching if record.version == version]
|
|
182
|
+
return matching
|
|
183
|
+
|
|
184
|
+
package_name = str(target)
|
|
185
|
+
matching = [record for record in records if record.namespace == namespace and record.package == package_name]
|
|
186
|
+
if version is not None:
|
|
187
|
+
matching = [record for record in matching if record.version == version]
|
|
188
|
+
if matching:
|
|
189
|
+
return matching
|
|
190
|
+
|
|
191
|
+
package_dir = registry_root(registry_root_override) / namespace / package_name
|
|
192
|
+
if package_dir.exists():
|
|
193
|
+
if version is not None:
|
|
194
|
+
child = package_dir / version
|
|
195
|
+
if child.exists() or child.is_symlink():
|
|
196
|
+
return [_record_from_path(child, namespace, package_name)]
|
|
197
|
+
else:
|
|
198
|
+
return [_record_from_path(child, namespace, package_name) for child in package_dir.iterdir() if child.name and not child.name.startswith(".")]
|
|
199
|
+
return []
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _normalize_source(source: str | Path) -> SourceSpec:
|
|
203
|
+
if isinstance(source, Path):
|
|
204
|
+
return parse_source(str(source))
|
|
205
|
+
return parse_source(str(source))
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _resolve_source(
|
|
209
|
+
spec: SourceSpec,
|
|
210
|
+
requested_version: str | None,
|
|
211
|
+
cache_root_override: str | None,
|
|
212
|
+
) -> tuple[Path, ProjectManifest, str, str]:
|
|
213
|
+
if spec.kind == "local":
|
|
214
|
+
if spec.path is None:
|
|
215
|
+
raise ValueError("local source is missing a path")
|
|
216
|
+
manifest = load_manifest(spec.path)
|
|
217
|
+
resolved_version = local_version(spec.path, requested_version)
|
|
218
|
+
if requested_version is not None and manifest.version != resolved_version:
|
|
219
|
+
raise ValueError(
|
|
220
|
+
f"manifest version {manifest.version!r} does not match requested version {resolved_version!r}"
|
|
221
|
+
)
|
|
222
|
+
if manifest.version != resolved_version:
|
|
223
|
+
raise ValueError(
|
|
224
|
+
f"manifest version {manifest.version!r} does not match resolved version {resolved_version!r}"
|
|
225
|
+
)
|
|
226
|
+
return spec.path, manifest, resolved_version, str(spec.path)
|
|
227
|
+
|
|
228
|
+
if spec.kind == "github":
|
|
229
|
+
remote_url = _github_url(spec)
|
|
230
|
+
release = _resolve_remote_release(remote_url, spec.ref or requested_version)
|
|
231
|
+
checkout = _checkout_cache_path(cache_root_override, spec, release.version)
|
|
232
|
+
clone_release(remote_url, release.ref, checkout)
|
|
233
|
+
manifest = load_manifest(checkout)
|
|
234
|
+
if manifest.version != release.version:
|
|
235
|
+
raise ValueError(
|
|
236
|
+
f"manifest version {manifest.version!r} does not match release {release.version!r}"
|
|
237
|
+
)
|
|
238
|
+
return checkout, manifest, release.version, remote_url
|
|
239
|
+
|
|
240
|
+
if spec.kind == "remote":
|
|
241
|
+
if spec.url is None:
|
|
242
|
+
raise ValueError("remote source is missing a url")
|
|
243
|
+
release = _resolve_remote_release(spec.url, requested_version)
|
|
244
|
+
checkout = _checkout_cache_path(cache_root_override, spec, release.version)
|
|
245
|
+
clone_release(spec.url, release.ref, checkout)
|
|
246
|
+
manifest = load_manifest(checkout)
|
|
247
|
+
if manifest.version != release.version:
|
|
248
|
+
raise ValueError(
|
|
249
|
+
f"manifest version {manifest.version!r} does not match release {release.version!r}"
|
|
250
|
+
)
|
|
251
|
+
return checkout, manifest, release.version, spec.url
|
|
252
|
+
|
|
253
|
+
raise ValueError(f"unsupported source kind: {spec.kind}")
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
@dataclass(frozen=True)
|
|
257
|
+
class _RemoteRelease:
|
|
258
|
+
ref: str
|
|
259
|
+
version: str
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _resolve_remote_release(remote_url: str, requested_version: str | None) -> _RemoteRelease:
|
|
263
|
+
version = remote_version(remote_url, requested_version)
|
|
264
|
+
return _RemoteRelease(ref=_version_ref(remote_url, version), version=version)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _version_ref(remote_url: str, version: str) -> str:
|
|
268
|
+
tags = _remote_tag_map(remote_url)
|
|
269
|
+
if version in tags:
|
|
270
|
+
return tags[version]
|
|
271
|
+
prefixed = f"v{version}"
|
|
272
|
+
if prefixed in tags:
|
|
273
|
+
return tags[prefixed]
|
|
274
|
+
raise ValueError(f"version {version!r} is not available on {remote_url!r}")
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _remote_tag_map(remote_url: str) -> dict[str, str]:
|
|
278
|
+
from .git import remote_tags
|
|
279
|
+
|
|
280
|
+
tags = remote_tags(remote_url)
|
|
281
|
+
mapping: dict[str, str] = {}
|
|
282
|
+
for tag in tags:
|
|
283
|
+
try:
|
|
284
|
+
mapping.setdefault(tag, tag)
|
|
285
|
+
mapping.setdefault(tag.removeprefix("v"), tag)
|
|
286
|
+
except Exception:
|
|
287
|
+
continue
|
|
288
|
+
return mapping
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _github_url(spec: SourceSpec) -> str:
|
|
292
|
+
if not spec.owner or not spec.repo:
|
|
293
|
+
raise ValueError("github source is missing owner or repo")
|
|
294
|
+
return f"https://github.com/{spec.owner}/{spec.repo}.git"
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _checkout_cache_path(cache_root_override: str | None, spec: SourceSpec, version: str) -> Path:
|
|
298
|
+
root = cache_root(cache_root_override) / "checkouts"
|
|
299
|
+
source_id = spec.owner or spec.url or spec.raw
|
|
300
|
+
safe = source_id.replace(":", "_").replace("/", "_")
|
|
301
|
+
return root / safe / version
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _install_tree(source_root: Path, target: Path, link_mode: str, force: bool) -> None:
|
|
305
|
+
if target.exists() or target.is_symlink():
|
|
306
|
+
if not force:
|
|
307
|
+
if target.is_symlink() and target.resolve(strict=False) == source_root.resolve(strict=False):
|
|
308
|
+
return
|
|
309
|
+
raise FileExistsError(f"registry entry already exists: {target}")
|
|
310
|
+
if target.is_symlink() or target.is_file():
|
|
311
|
+
target.unlink()
|
|
312
|
+
else:
|
|
313
|
+
shutil.rmtree(target)
|
|
314
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
315
|
+
if link_mode == "symlink":
|
|
316
|
+
target.symlink_to(source_root, target_is_directory=True)
|
|
317
|
+
return
|
|
318
|
+
if link_mode == "copy":
|
|
319
|
+
shutil.copytree(source_root, target, symlinks=True, ignore=_copy_ignore)
|
|
320
|
+
return
|
|
321
|
+
try:
|
|
322
|
+
target.symlink_to(source_root, target_is_directory=True)
|
|
323
|
+
except OSError:
|
|
324
|
+
shutil.copytree(source_root, target, symlinks=True, ignore=_copy_ignore)
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def _copy_ignore(_: str, names: list[str]) -> set[str]:
|
|
328
|
+
ignored = {".git", "__pycache__", ".typst-forge"}
|
|
329
|
+
return {name for name in names if name in ignored}
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def _write_record(record: InstallRecord, registry_root_override: str | None) -> None:
|
|
333
|
+
path = metadata_path(registry_root_override, record.namespace, record.package, record.version)
|
|
334
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
335
|
+
payload = asdict(record)
|
|
336
|
+
path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def _remove_record(record: InstallRecord, registry_root_override: str | None) -> None:
|
|
340
|
+
path = metadata_path(registry_root_override, record.namespace, record.package, record.version)
|
|
341
|
+
if path.exists():
|
|
342
|
+
path.unlink()
|
|
343
|
+
_prune_metadata_dirs(path.parent)
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def _load_records(registry_root_override: str | None) -> list[InstallRecord]:
|
|
347
|
+
root = metadata_root(registry_root_override)
|
|
348
|
+
if not root.exists():
|
|
349
|
+
return []
|
|
350
|
+
records: list[InstallRecord] = []
|
|
351
|
+
for meta in root.rglob("*.json"):
|
|
352
|
+
try:
|
|
353
|
+
payload = json.loads(meta.read_text(encoding="utf-8"))
|
|
354
|
+
records.append(InstallRecord(**payload))
|
|
355
|
+
except Exception:
|
|
356
|
+
continue
|
|
357
|
+
return records
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def _scan_registry(registry_root_override: str | None) -> list[InstallRecord]:
|
|
361
|
+
root = registry_root(registry_root_override)
|
|
362
|
+
if not root.exists():
|
|
363
|
+
return []
|
|
364
|
+
records: list[InstallRecord] = []
|
|
365
|
+
for namespace_dir in root.iterdir():
|
|
366
|
+
if not namespace_dir.is_dir() or namespace_dir.name.startswith("."):
|
|
367
|
+
continue
|
|
368
|
+
for package_dir in namespace_dir.iterdir():
|
|
369
|
+
if not package_dir.is_dir() or package_dir.name.startswith("."):
|
|
370
|
+
continue
|
|
371
|
+
for version_dir in package_dir.iterdir():
|
|
372
|
+
if version_dir.name.startswith("."):
|
|
373
|
+
continue
|
|
374
|
+
records.append(_record_from_path(version_dir, namespace_dir.name, package_dir.name))
|
|
375
|
+
return records
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def _record_from_path(path: Path, namespace: str, package: str) -> InstallRecord:
|
|
379
|
+
if path.is_symlink():
|
|
380
|
+
source_location = str(path.resolve(strict=False))
|
|
381
|
+
source_kind = "local"
|
|
382
|
+
else:
|
|
383
|
+
source_location = str(path)
|
|
384
|
+
source_kind = "copy"
|
|
385
|
+
return InstallRecord(
|
|
386
|
+
namespace=namespace,
|
|
387
|
+
package=package,
|
|
388
|
+
version=path.name,
|
|
389
|
+
source_raw=source_location,
|
|
390
|
+
source_kind=source_kind,
|
|
391
|
+
source_location=source_location,
|
|
392
|
+
link_mode="copy" if source_kind == "copy" else "symlink",
|
|
393
|
+
installed_root=str(path),
|
|
394
|
+
installed_at="",
|
|
395
|
+
)
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def _source_from_record(record: InstallRecord) -> str | Path:
|
|
399
|
+
if record.source_kind == "local":
|
|
400
|
+
return Path(record.source_location)
|
|
401
|
+
if record.source_kind in {"github", "remote"}:
|
|
402
|
+
return record.source_raw
|
|
403
|
+
return record.source_location
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def _prune_empty_dirs(path: Path) -> None:
|
|
407
|
+
current = path
|
|
408
|
+
while True:
|
|
409
|
+
if current.name in {"packages", ".forge"}:
|
|
410
|
+
break
|
|
411
|
+
try:
|
|
412
|
+
current.rmdir()
|
|
413
|
+
except OSError:
|
|
414
|
+
break
|
|
415
|
+
current = current.parent
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def _prune_metadata_dirs(path: Path) -> None:
|
|
419
|
+
current = path
|
|
420
|
+
while True:
|
|
421
|
+
if current.name == ".forge":
|
|
422
|
+
break
|
|
423
|
+
try:
|
|
424
|
+
current.rmdir()
|
|
425
|
+
except OSError:
|
|
426
|
+
break
|
|
427
|
+
current = current.parent
|
typst_forge/scaffold.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import subprocess
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from .git import git_available
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def scaffold_project(root: Path, kind: str, name: str, version: str) -> None:
|
|
10
|
+
root = root.resolve()
|
|
11
|
+
root.mkdir(parents=True, exist_ok=True)
|
|
12
|
+
|
|
13
|
+
(root / "src").mkdir(exist_ok=True)
|
|
14
|
+
if kind == "template":
|
|
15
|
+
(root / "template").mkdir(exist_ok=True)
|
|
16
|
+
|
|
17
|
+
(root / "typst.toml").write_text(_typst_toml(kind, name, version), encoding="utf-8")
|
|
18
|
+
(root / "src" / "lib.typ").write_text(_lib_typ(kind), encoding="utf-8")
|
|
19
|
+
if kind == "template":
|
|
20
|
+
(root / "template" / "main.typ").write_text(_template_main_typ(), encoding="utf-8")
|
|
21
|
+
|
|
22
|
+
(root / ".gitignore").write_text(
|
|
23
|
+
"\n".join(
|
|
24
|
+
[
|
|
25
|
+
".typst-forge",
|
|
26
|
+
"build",
|
|
27
|
+
"__pycache__",
|
|
28
|
+
"",
|
|
29
|
+
]
|
|
30
|
+
),
|
|
31
|
+
encoding="utf-8",
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
if not git_available():
|
|
35
|
+
raise RuntimeError("git is required to initialize Typst Forge projects")
|
|
36
|
+
subprocess.run(["git", "init"], cwd=root, check=True, text=True, capture_output=True)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _typst_toml(kind: str, name: str, version: str) -> str:
|
|
40
|
+
entrypoint = "template/main.typ" if kind == "template" else "src/lib.typ"
|
|
41
|
+
return "\n".join(
|
|
42
|
+
[
|
|
43
|
+
"[package]",
|
|
44
|
+
f'name = "{name}"',
|
|
45
|
+
f'version = "{version}"',
|
|
46
|
+
f'entrypoint = "{entrypoint}"',
|
|
47
|
+
"",
|
|
48
|
+
"[tool.typst_forge]",
|
|
49
|
+
f'kind = "{kind}"',
|
|
50
|
+
"",
|
|
51
|
+
]
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _lib_typ(kind: str) -> str:
|
|
56
|
+
if kind == "template":
|
|
57
|
+
return (
|
|
58
|
+
"#let render(title, body) = [\n"
|
|
59
|
+
"= #title\n\n"
|
|
60
|
+
"#body\n"
|
|
61
|
+
"]\n"
|
|
62
|
+
)
|
|
63
|
+
return '#let hello(name) = "Hello, " + name\n'
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _template_main_typ() -> str:
|
|
67
|
+
return (
|
|
68
|
+
'#import "../src/lib.typ": render\n\n'
|
|
69
|
+
'#render("Typst Forge", "template scaffold")\n'
|
|
70
|
+
)
|
typst_forge/source.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from urllib.parse import urlparse
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True)
|
|
9
|
+
class SourceSpec:
|
|
10
|
+
raw: str
|
|
11
|
+
kind: str
|
|
12
|
+
path: Path | None = None
|
|
13
|
+
url: str | None = None
|
|
14
|
+
owner: str | None = None
|
|
15
|
+
repo: str | None = None
|
|
16
|
+
ref: str | None = None
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def parse_source(raw: str, cwd: Path | None = None) -> SourceSpec:
|
|
20
|
+
if not raw:
|
|
21
|
+
raise ValueError("source cannot be empty")
|
|
22
|
+
|
|
23
|
+
cwd = cwd or Path.cwd()
|
|
24
|
+
candidate = Path(raw).expanduser()
|
|
25
|
+
if candidate.exists():
|
|
26
|
+
return SourceSpec(raw=raw, kind="local", path=candidate.resolve())
|
|
27
|
+
|
|
28
|
+
if raw.startswith("github:"):
|
|
29
|
+
return _parse_github(raw[len("github:") :], raw)
|
|
30
|
+
|
|
31
|
+
parsed = urlparse(raw)
|
|
32
|
+
if parsed.scheme:
|
|
33
|
+
if parsed.scheme in {"http", "https", "ssh", "git", "file"}:
|
|
34
|
+
return SourceSpec(raw=raw, kind="remote", url=raw)
|
|
35
|
+
raise ValueError(f"unsupported source url scheme: {parsed.scheme}")
|
|
36
|
+
|
|
37
|
+
if raw.startswith("git@") or raw.endswith(".git"):
|
|
38
|
+
return SourceSpec(raw=raw, kind="remote", url=raw)
|
|
39
|
+
|
|
40
|
+
if "/" in raw:
|
|
41
|
+
return _parse_github(raw, raw)
|
|
42
|
+
|
|
43
|
+
maybe_path = (cwd / raw).expanduser()
|
|
44
|
+
if maybe_path.exists():
|
|
45
|
+
return SourceSpec(raw=raw, kind="local", path=maybe_path.resolve())
|
|
46
|
+
|
|
47
|
+
raise ValueError(f"cannot parse source: {raw!r}")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _parse_github(value: str, raw: str) -> SourceSpec:
|
|
51
|
+
ref: str | None = None
|
|
52
|
+
repo_part = value
|
|
53
|
+
if "@" in value:
|
|
54
|
+
repo_part, ref = value.rsplit("@", 1)
|
|
55
|
+
if repo_part.startswith("https://github.com/"):
|
|
56
|
+
repo_part = repo_part.removeprefix("https://github.com/")
|
|
57
|
+
if repo_part.startswith("http://github.com/"):
|
|
58
|
+
repo_part = repo_part.removeprefix("http://github.com/")
|
|
59
|
+
if repo_part.endswith(".git"):
|
|
60
|
+
repo_part = repo_part[:-4]
|
|
61
|
+
owner, _, repo = repo_part.partition("/")
|
|
62
|
+
if not owner or not repo:
|
|
63
|
+
raise ValueError(f"invalid github source: {raw!r}")
|
|
64
|
+
return SourceSpec(raw=raw, kind="github", owner=owner, repo=repo, ref=ref)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def source_label(spec: SourceSpec) -> str:
|
|
68
|
+
if spec.kind == "local" and spec.path is not None:
|
|
69
|
+
return str(spec.path)
|
|
70
|
+
if spec.kind == "github" and spec.owner and spec.repo:
|
|
71
|
+
label = f"github:{spec.owner}/{spec.repo}"
|
|
72
|
+
if spec.ref:
|
|
73
|
+
label += f"@{spec.ref}"
|
|
74
|
+
return label
|
|
75
|
+
if spec.url:
|
|
76
|
+
return spec.url
|
|
77
|
+
return spec.raw
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: typst-forge
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A local manager for Typst templates and packages
|
|
5
|
+
Requires-Python: >=3.11
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
|
|
8
|
+
# Typst Forge
|
|
9
|
+
|
|
10
|
+
Typst Forge is a small local manager for Typst packages and templates.
|
|
11
|
+
|
|
12
|
+
It can:
|
|
13
|
+
|
|
14
|
+
- scaffold a new Typst repo with `git`, `typst.toml`, `src`, and `template/` when needed
|
|
15
|
+
- install local development repos into Typst's package directory
|
|
16
|
+
- install release repos from GitHub or any git URL
|
|
17
|
+
- manage installed packages/templates with versioned registry entries
|
|
18
|
+
- fall back between symlink and copy modes for platform-friendly installs
|
|
19
|
+
|
|
20
|
+
## Install
|
|
21
|
+
|
|
22
|
+
This repo is meant to be run with `uv`:
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
uv run typst-forge --help
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
To run it without installing from a checkout, use `uvx`:
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
uvx --from . typst-forge --help
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
You can also install directly from GitHub:
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
uvx --from git+https://github.com/<owner>/typst-forge typst-forge --help
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Commands
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
typst-forge init ./my-template --kind template --name my-template --version 0.1.0
|
|
44
|
+
typst-forge init ./my-package --kind package --name my-package --version 0.1.0
|
|
45
|
+
typst-forge install ./my-package --link-mode copy
|
|
46
|
+
typst-forge install github:owner/repo@v1.2.3
|
|
47
|
+
typst-forge list
|
|
48
|
+
typst-forge status my-package
|
|
49
|
+
typst-forge update my-package
|
|
50
|
+
typst-forge remove my-package
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Versioning
|
|
54
|
+
|
|
55
|
+
- local repos use the `typst.toml` package version unless a matching git tag is present at `HEAD`
|
|
56
|
+
- GitHub and other git URLs install from release tags
|
|
57
|
+
- tags can be written as `v1.2.3` or `1.2.3`; the stored Typst version is normalized to `1.2.3`
|
|
58
|
+
- installed entries live under `~/.local/share/typst/packages/<namespace>/<package>/<version>`
|
|
59
|
+
|
|
60
|
+
## Tests
|
|
61
|
+
|
|
62
|
+
Run the in-repo test suite with:
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
PYTHONPATH=src python3 -m unittest discover -s tests -v
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## Publishing
|
|
69
|
+
|
|
70
|
+
The GitHub Actions workflow in [.github/workflows/publish.yml](./.github/workflows/publish.yml)
|
|
71
|
+
publishes tagged releases to PyPI with trusted publishing.
|
|
72
|
+
|
|
73
|
+
Create and push a version tag like `v0.1.0` to trigger it.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
typst_forge/__init__.py,sha256=pXVB6NgsHhS3va0WYGMBwKUR_IgWhUFIy_XzFQ05P58,112
|
|
2
|
+
typst_forge/__main__.py,sha256=PSQ4rpL0dG6f-qH4N7H-gD9igQkdHzH4yVZDcW8lfZo,80
|
|
3
|
+
typst_forge/cli.py,sha256=2AGqnj9esc9coAXmEBT9dsl7_7IIWhvCG5g7Hn7tCG8,5676
|
|
4
|
+
typst_forge/config.py,sha256=Qs4Evi7LYRrgrSN1txns0kmKgHzozIUG6C_eN3PUjpQ,954
|
|
5
|
+
typst_forge/git.py,sha256=frTA9N5lgel5eG8rOt0_f2StVC7kYY9-NdLyFV2Unjc,3798
|
|
6
|
+
typst_forge/manifest.py,sha256=nRQ7esNDL1Ra4JOdHnXLAXjrb0s-sSX17RESqPzCDWw,1611
|
|
7
|
+
typst_forge/registry.py,sha256=eWykQOt6TMkmUjtBQTaDko90PjbcK8JePOd02vWBR3s,15096
|
|
8
|
+
typst_forge/scaffold.py,sha256=Jch5-9O2mJtbX4YXHdr6FPu7FVm-MPadYz1LUcEnsQU,1933
|
|
9
|
+
typst_forge/source.py,sha256=ODoLAZBF8HoZ1irXon27se2fhSnya0P2oY7JwjmWRw0,2443
|
|
10
|
+
typst_forge-0.1.0.dist-info/METADATA,sha256=1vFtmx5bKkf3W5GDsFy52HufoJ2ynmNs1MpIzgIOQlw,2040
|
|
11
|
+
typst_forge-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
12
|
+
typst_forge-0.1.0.dist-info/entry_points.txt,sha256=m-LizqfxNf05XRdG2io0Zp963Nu9LmRjGztokBBoubU,53
|
|
13
|
+
typst_forge-0.1.0.dist-info/top_level.txt,sha256=cf3_K99qEyLSWo82G_q6EE_--CkmobLqIBf2El7NEaA,12
|
|
14
|
+
typst_forge-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
typst_forge
|