forje 0.1.0__tar.gz
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.
- forje-0.1.0/.gitignore +224 -0
- forje-0.1.0/PKG-INFO +20 -0
- forje-0.1.0/README.md +4 -0
- forje-0.1.0/pyproject.toml +39 -0
- forje-0.1.0/src/forje/__init__.py +1 -0
- forje-0.1.0/src/forje/backend/__init__.py +3 -0
- forje-0.1.0/src/forje/backend/android.py +49 -0
- forje-0.1.0/src/forje/backend/apple.py +55 -0
- forje-0.1.0/src/forje/backend/backend.py +22 -0
- forje-0.1.0/src/forje/backend/css.py +96 -0
- forje-0.1.0/src/forje/cli/__init__.py +0 -0
- forje-0.1.0/src/forje/cli/main.py +125 -0
- forje-0.1.0/src/forje/cli/ui.py +13 -0
- forje-0.1.0/src/forje/cli/utils.py +12 -0
- forje-0.1.0/src/forje/core/__init__.py +0 -0
- forje-0.1.0/src/forje/core/context.py +51 -0
- forje-0.1.0/src/forje/core/driver.py +40 -0
- forje-0.1.0/src/forje/core/environment.py +22 -0
- forje-0.1.0/src/forje/core/errors.py +27 -0
- forje-0.1.0/src/forje/core/frontend.py +118 -0
- forje-0.1.0/src/forje/core/loader.py +66 -0
- forje-0.1.0/src/forje/core/pass_.py +18 -0
- forje-0.1.0/src/forje/dsl/__init__.py +3 -0
- forje-0.1.0/src/forje/dsl/core.py +31 -0
- forje-0.1.0/src/forje/dsl/core.star +126 -0
- forje-0.1.0/src/forje/dsl/module.py +87 -0
- forje-0.1.0/src/forje/dsl/stdlib.py +58 -0
- forje-0.1.0/src/forje/dsl/stdlib.star +20 -0
- forje-0.1.0/src/forje/ir/__init__.py +3 -0
- forje-0.1.0/src/forje/ir/models.py +66 -0
- forje-0.1.0/src/forje/passes/__init__.py +0 -0
- forje-0.1.0/src/forje/passes/codegen.py +21 -0
- forje-0.1.0/src/forje/passes/color_norm.py +51 -0
- forje-0.1.0/src/forje/passes/validation.py +70 -0
- forje-0.1.0/src/forje/py.typed +0 -0
- forje-0.1.0/src/forje/wcag/__init__.py +0 -0
- forje-0.1.0/src/forje/wcag/dsl.py +5 -0
- forje-0.1.0/src/forje/wcag/dsl.star +39 -0
- forje-0.1.0/src/forje/wcag/models.py +23 -0
- forje-0.1.0/src/forje/wcag/passes.py +108 -0
forje-0.1.0/.gitignore
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
# Byte-compiled / optimized / DLL files
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[codz]
|
|
4
|
+
*$py.class
|
|
5
|
+
|
|
6
|
+
# C extensions
|
|
7
|
+
*.so
|
|
8
|
+
|
|
9
|
+
# Distribution / packaging
|
|
10
|
+
.Python
|
|
11
|
+
build/
|
|
12
|
+
develop-eggs/
|
|
13
|
+
dist/
|
|
14
|
+
downloads/
|
|
15
|
+
eggs/
|
|
16
|
+
.eggs/
|
|
17
|
+
lib/
|
|
18
|
+
lib64/
|
|
19
|
+
parts/
|
|
20
|
+
sdist/
|
|
21
|
+
var/
|
|
22
|
+
wheels/
|
|
23
|
+
share/python-wheels/
|
|
24
|
+
*.egg-info/
|
|
25
|
+
.installed.cfg
|
|
26
|
+
*.egg
|
|
27
|
+
MANIFEST
|
|
28
|
+
|
|
29
|
+
# PyInstaller
|
|
30
|
+
# Usually these files are written by a python script from a template
|
|
31
|
+
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
|
32
|
+
*.manifest
|
|
33
|
+
*.spec
|
|
34
|
+
|
|
35
|
+
# Installer logs
|
|
36
|
+
pip-log.txt
|
|
37
|
+
pip-delete-this-directory.txt
|
|
38
|
+
|
|
39
|
+
# Unit test / coverage reports
|
|
40
|
+
htmlcov/
|
|
41
|
+
.tox/
|
|
42
|
+
.nox/
|
|
43
|
+
.coverage
|
|
44
|
+
.coverage.*
|
|
45
|
+
.cache
|
|
46
|
+
nosetests.xml
|
|
47
|
+
coverage.xml
|
|
48
|
+
*.cover
|
|
49
|
+
*.py.cover
|
|
50
|
+
.hypothesis/
|
|
51
|
+
.pytest_cache/
|
|
52
|
+
cover/
|
|
53
|
+
|
|
54
|
+
# Translations
|
|
55
|
+
*.mo
|
|
56
|
+
*.pot
|
|
57
|
+
|
|
58
|
+
# Django stuff:
|
|
59
|
+
*.log
|
|
60
|
+
local_settings.py
|
|
61
|
+
db.sqlite3
|
|
62
|
+
db.sqlite3-journal
|
|
63
|
+
|
|
64
|
+
# Flask stuff:
|
|
65
|
+
instance/
|
|
66
|
+
.webassets-cache
|
|
67
|
+
|
|
68
|
+
# Scrapy stuff:
|
|
69
|
+
.scrapy
|
|
70
|
+
|
|
71
|
+
# Sphinx documentation
|
|
72
|
+
docs/_build/
|
|
73
|
+
|
|
74
|
+
# PyBuilder
|
|
75
|
+
.pybuilder/
|
|
76
|
+
target/
|
|
77
|
+
|
|
78
|
+
# Jupyter Notebook
|
|
79
|
+
.ipynb_checkpoints
|
|
80
|
+
|
|
81
|
+
# IPython
|
|
82
|
+
profile_default/
|
|
83
|
+
ipython_config.py
|
|
84
|
+
|
|
85
|
+
# pyenv
|
|
86
|
+
# For a library or package, you might want to ignore these files since the code is
|
|
87
|
+
# intended to run in multiple environments; otherwise, check them in:
|
|
88
|
+
# .python-version
|
|
89
|
+
|
|
90
|
+
# pipenv
|
|
91
|
+
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
|
92
|
+
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
|
93
|
+
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
|
94
|
+
# install all needed dependencies.
|
|
95
|
+
# Pipfile.lock
|
|
96
|
+
|
|
97
|
+
# UV
|
|
98
|
+
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
|
|
99
|
+
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
|
100
|
+
# commonly ignored for libraries.
|
|
101
|
+
# uv.lock
|
|
102
|
+
|
|
103
|
+
# poetry
|
|
104
|
+
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
|
105
|
+
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
|
106
|
+
# commonly ignored for libraries.
|
|
107
|
+
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
|
108
|
+
# poetry.lock
|
|
109
|
+
# poetry.toml
|
|
110
|
+
|
|
111
|
+
# pdm
|
|
112
|
+
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
|
113
|
+
# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
|
|
114
|
+
# https://pdm-project.org/en/latest/usage/project/#working-with-version-control
|
|
115
|
+
# pdm.lock
|
|
116
|
+
# pdm.toml
|
|
117
|
+
.pdm-python
|
|
118
|
+
.pdm-build/
|
|
119
|
+
|
|
120
|
+
# pixi
|
|
121
|
+
# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
|
|
122
|
+
# pixi.lock
|
|
123
|
+
# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
|
|
124
|
+
# in the .venv directory. It is recommended not to include this directory in version control.
|
|
125
|
+
.pixi
|
|
126
|
+
|
|
127
|
+
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
|
128
|
+
__pypackages__/
|
|
129
|
+
|
|
130
|
+
# Celery stuff
|
|
131
|
+
celerybeat-schedule
|
|
132
|
+
celerybeat.pid
|
|
133
|
+
|
|
134
|
+
# Redis
|
|
135
|
+
*.rdb
|
|
136
|
+
*.aof
|
|
137
|
+
*.pid
|
|
138
|
+
|
|
139
|
+
# RabbitMQ
|
|
140
|
+
mnesia/
|
|
141
|
+
rabbitmq/
|
|
142
|
+
rabbitmq-data/
|
|
143
|
+
|
|
144
|
+
# ActiveMQ
|
|
145
|
+
activemq-data/
|
|
146
|
+
|
|
147
|
+
# SageMath parsed files
|
|
148
|
+
*.sage.py
|
|
149
|
+
|
|
150
|
+
# Environments
|
|
151
|
+
.env
|
|
152
|
+
.envrc
|
|
153
|
+
.venv
|
|
154
|
+
env/
|
|
155
|
+
venv/
|
|
156
|
+
ENV/
|
|
157
|
+
env.bak/
|
|
158
|
+
venv.bak/
|
|
159
|
+
|
|
160
|
+
# Spyder project settings
|
|
161
|
+
.spyderproject
|
|
162
|
+
.spyproject
|
|
163
|
+
|
|
164
|
+
# Rope project settings
|
|
165
|
+
.ropeproject
|
|
166
|
+
|
|
167
|
+
# mkdocs documentation
|
|
168
|
+
/site
|
|
169
|
+
|
|
170
|
+
# mypy
|
|
171
|
+
.mypy_cache/
|
|
172
|
+
.dmypy.json
|
|
173
|
+
dmypy.json
|
|
174
|
+
|
|
175
|
+
# Pyre type checker
|
|
176
|
+
.pyre/
|
|
177
|
+
|
|
178
|
+
# pytype static type analyzer
|
|
179
|
+
.pytype/
|
|
180
|
+
|
|
181
|
+
# Cython debug symbols
|
|
182
|
+
cython_debug/
|
|
183
|
+
|
|
184
|
+
# PyCharm
|
|
185
|
+
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
|
186
|
+
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
|
187
|
+
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
|
188
|
+
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
|
189
|
+
# .idea/
|
|
190
|
+
|
|
191
|
+
# Abstra
|
|
192
|
+
# Abstra is an AI-powered process automation framework.
|
|
193
|
+
# Ignore directories containing user credentials, local state, and settings.
|
|
194
|
+
# Learn more at https://abstra.io/docs
|
|
195
|
+
.abstra/
|
|
196
|
+
|
|
197
|
+
# Visual Studio Code
|
|
198
|
+
# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
|
|
199
|
+
# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
|
|
200
|
+
# and can be added to the global gitignore or merged into this file. However, if you prefer,
|
|
201
|
+
# you could uncomment the following to ignore the entire vscode folder
|
|
202
|
+
# .vscode/
|
|
203
|
+
# Temporary file for partial code execution
|
|
204
|
+
tempCodeRunnerFile.py
|
|
205
|
+
|
|
206
|
+
# Ruff stuff:
|
|
207
|
+
.ruff_cache/
|
|
208
|
+
|
|
209
|
+
# PyPI configuration file
|
|
210
|
+
.pypirc
|
|
211
|
+
|
|
212
|
+
# Marimo
|
|
213
|
+
marimo/_static/
|
|
214
|
+
marimo/_lsp/
|
|
215
|
+
__marimo__/
|
|
216
|
+
|
|
217
|
+
# Streamlit
|
|
218
|
+
.streamlit/secrets.toml
|
|
219
|
+
|
|
220
|
+
# Forje build files
|
|
221
|
+
*.forje
|
|
222
|
+
|
|
223
|
+
# macOS
|
|
224
|
+
.DS_Store
|
forje-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: forje
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: The build system for your design system
|
|
5
|
+
Project-URL: Homepage, https://kipila.dev
|
|
6
|
+
Project-URL: Repository, https://github.com/kipila-dev/forje
|
|
7
|
+
Author-email: Kipila Ltd <hello@kipila.dev>
|
|
8
|
+
License: MIT
|
|
9
|
+
Requires-Python: >=3.12
|
|
10
|
+
Requires-Dist: coloraide
|
|
11
|
+
Requires-Dist: pydantic
|
|
12
|
+
Requires-Dist: resforge
|
|
13
|
+
Requires-Dist: starlark-pyo3
|
|
14
|
+
Requires-Dist: typer
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
|
|
17
|
+
# Forje
|
|
18
|
+
|
|
19
|
+
For full documentation, see the
|
|
20
|
+
[main repository README](https://github.com/kipila-dev/forje/blob/main/README.md).
|
forje-0.1.0/README.md
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "forje"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "The build system for your design system"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.12"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "Kipila Ltd", email = "hello@kipila.dev" }]
|
|
13
|
+
dependencies = ["coloraide", "pydantic", "resforge", "starlark-pyo3", "typer"]
|
|
14
|
+
|
|
15
|
+
[project.urls]
|
|
16
|
+
Homepage = "https://kipila.dev"
|
|
17
|
+
Repository = "https://github.com/kipila-dev/forje"
|
|
18
|
+
|
|
19
|
+
[project.scripts]
|
|
20
|
+
forje = "forje.cli.main:app"
|
|
21
|
+
|
|
22
|
+
[project.entry-points."forje.dsl"]
|
|
23
|
+
core = "forje.dsl.core:module"
|
|
24
|
+
stdlib = "forje.dsl.stdlib:module"
|
|
25
|
+
wcag = "forje.wcag.dsl:module"
|
|
26
|
+
|
|
27
|
+
[project.entry-points."forje.pass"]
|
|
28
|
+
wcag = "forje.wcag.passes:WCAGValidation"
|
|
29
|
+
|
|
30
|
+
[project.entry-points."forje.backend"]
|
|
31
|
+
android = "forje.backend.android:Android"
|
|
32
|
+
apple = "forje.backend.apple:Apple"
|
|
33
|
+
css = "forje.backend.css:CSS"
|
|
34
|
+
|
|
35
|
+
[tool.hatch.version]
|
|
36
|
+
path = "src/forje/__init__.py"
|
|
37
|
+
|
|
38
|
+
[tool.uv.sources]
|
|
39
|
+
resforge = { workspace = true }
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from typing import final, override
|
|
3
|
+
|
|
4
|
+
from resforge import Color
|
|
5
|
+
from resforge.android import ValuesWriter
|
|
6
|
+
from resforge.io import MemorySink, WriteSink
|
|
7
|
+
|
|
8
|
+
from forje.backend import Backend
|
|
9
|
+
from forje.ir import ArtifactNode, TargetNode, TokenMapping
|
|
10
|
+
from forje.ir.models import ColorNode
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _to_color(node: ColorNode) -> Color:
|
|
14
|
+
return Color(
|
|
15
|
+
x=node.coords[0],
|
|
16
|
+
y=node.coords[1],
|
|
17
|
+
z=node.coords[2],
|
|
18
|
+
alpha=node.alpha,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _write_tokens(sink: WriteSink, path: Path, nodes: dict[str, ColorNode]) -> None:
|
|
23
|
+
with ValuesWriter(path, sink=sink) as res:
|
|
24
|
+
colors = {name: _to_color(node) for name, node in nodes.items()}
|
|
25
|
+
res.color(**colors)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@final
|
|
29
|
+
class Android(Backend):
|
|
30
|
+
"""Generates Android XML resource files for colors from design tokens."""
|
|
31
|
+
|
|
32
|
+
@override
|
|
33
|
+
def codegen(self, target: TargetNode, artifact: ArtifactNode) -> dict[str, bytes]:
|
|
34
|
+
colors = [t for t in target.tokens.values() if t.kind == "color"]
|
|
35
|
+
|
|
36
|
+
def get_mapping(mode: TokenMapping) -> dict[str, ColorNode]:
|
|
37
|
+
return {t.name: t.mapping[mode] for t in colors if mode in t.mapping}
|
|
38
|
+
|
|
39
|
+
light = get_mapping("light")
|
|
40
|
+
dark = get_mapping("dark")
|
|
41
|
+
|
|
42
|
+
sink = MemorySink()
|
|
43
|
+
base_path = Path(artifact.path)
|
|
44
|
+
stem = artifact.stem or "colors"
|
|
45
|
+
|
|
46
|
+
_write_tokens(sink, base_path / "values" / f"{stem}.xml", light)
|
|
47
|
+
_write_tokens(sink, base_path / "values-night" / f"{stem}.xml", dark)
|
|
48
|
+
|
|
49
|
+
return sink.files
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from typing import final, override
|
|
3
|
+
|
|
4
|
+
from resforge import Color
|
|
5
|
+
from resforge.apple import Appearance, AppleColor, AssetCatalog
|
|
6
|
+
from resforge.io import MemorySink
|
|
7
|
+
|
|
8
|
+
from forje.backend import Backend
|
|
9
|
+
from forje.ir import ArtifactNode, ColorNode, TargetNode, TokenMapping
|
|
10
|
+
|
|
11
|
+
_APPEARANCE_MAP: dict[TokenMapping, list[Appearance]] = {
|
|
12
|
+
"light": [],
|
|
13
|
+
"dark": [Appearance.Dark],
|
|
14
|
+
"high_contrast_light": [Appearance.HighContrast, Appearance.Light],
|
|
15
|
+
"high_contrast_dark": [Appearance.HighContrast, Appearance.Dark],
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _to_pascal_case(value: str) -> str:
|
|
20
|
+
words = re.split(r"[-_]+", value)
|
|
21
|
+
return "".join(w.capitalize() for w in words if w)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _to_color(node: ColorNode) -> Color:
|
|
25
|
+
return Color(
|
|
26
|
+
x=node.coords[0],
|
|
27
|
+
y=node.coords[1],
|
|
28
|
+
z=node.coords[2],
|
|
29
|
+
alpha=node.alpha,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@final
|
|
34
|
+
class Apple(Backend):
|
|
35
|
+
"""Generates Apple Asset Catalogs from design tokens."""
|
|
36
|
+
|
|
37
|
+
@override
|
|
38
|
+
def codegen(self, target: TargetNode, artifact: ArtifactNode) -> dict[str, bytes]:
|
|
39
|
+
color_tokens = (t for t in target.tokens.values() if t.kind == "color")
|
|
40
|
+
sink = MemorySink()
|
|
41
|
+
|
|
42
|
+
with AssetCatalog(
|
|
43
|
+
artifact.path,
|
|
44
|
+
artifact.stem or "Assets",
|
|
45
|
+
sink=sink,
|
|
46
|
+
) as catalog:
|
|
47
|
+
for token in color_tokens:
|
|
48
|
+
name = _to_pascal_case(token.name)
|
|
49
|
+
apple_colors = [
|
|
50
|
+
AppleColor(_to_color(node), appearances=_APPEARANCE_MAP[mode])
|
|
51
|
+
for mode, node in token.mapping.items()
|
|
52
|
+
]
|
|
53
|
+
catalog.colorset(name, *apple_colors)
|
|
54
|
+
|
|
55
|
+
return sink.files
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from typing import Protocol, runtime_checkable
|
|
2
|
+
|
|
3
|
+
from forje.ir import ArtifactNode, TargetNode
|
|
4
|
+
|
|
5
|
+
__all__ = ["Backend"]
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@runtime_checkable
|
|
9
|
+
class Backend(Protocol):
|
|
10
|
+
"""Interface for platform-specific code generators."""
|
|
11
|
+
|
|
12
|
+
def codegen(
|
|
13
|
+
self,
|
|
14
|
+
target: TargetNode,
|
|
15
|
+
artifact: ArtifactNode,
|
|
16
|
+
) -> dict[str, bytes]:
|
|
17
|
+
"""Generates platform assets for a target.
|
|
18
|
+
|
|
19
|
+
Returns:
|
|
20
|
+
File paths relative to the base directory mapped to their contents.
|
|
21
|
+
"""
|
|
22
|
+
...
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import math
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import final, override
|
|
4
|
+
|
|
5
|
+
from resforge import Color
|
|
6
|
+
from resforge.io import MemorySink
|
|
7
|
+
|
|
8
|
+
from forje.backend import Backend
|
|
9
|
+
from forje.ir import ArtifactNode, ColorNode, TargetNode, TokenMapping
|
|
10
|
+
|
|
11
|
+
__all__ = ["CSS"]
|
|
12
|
+
|
|
13
|
+
_QUERY_CONTRAST = "(prefers-contrast: more)"
|
|
14
|
+
_QUERY_LIGHT = "(prefers-color-scheme: light)"
|
|
15
|
+
_QUERY_DARK = "(prefers-color-scheme: dark)"
|
|
16
|
+
_QUERIES: dict[TokenMapping, str | None] = {
|
|
17
|
+
"light": None,
|
|
18
|
+
"dark": f"@media {_QUERY_DARK}",
|
|
19
|
+
"high_contrast_light": f"@media {_QUERY_CONTRAST} and {_QUERY_LIGHT}",
|
|
20
|
+
"high_contrast_dark": f"@media {_QUERY_CONTRAST} and {_QUERY_DARK}",
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _to_color(node: ColorNode) -> Color:
|
|
25
|
+
return Color(
|
|
26
|
+
x=node.coords[0],
|
|
27
|
+
y=node.coords[1],
|
|
28
|
+
z=node.coords[2],
|
|
29
|
+
alpha=node.alpha,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _to_css_vars(name: str, node: ColorNode) -> tuple[str, str]:
|
|
34
|
+
name = f"--color-{name.lower().strip().replace('_', '-')}"
|
|
35
|
+
color = _to_color(node)
|
|
36
|
+
|
|
37
|
+
r, g, b, a_srgb = color.to_srgb_components()
|
|
38
|
+
r = round(r * 255)
|
|
39
|
+
g = round(g * 255)
|
|
40
|
+
b = round(b * 255)
|
|
41
|
+
|
|
42
|
+
l, c, h, a_oklch = color.to_oklch_components()
|
|
43
|
+
h = "none" if math.isnan(h) else round(h, 3)
|
|
44
|
+
|
|
45
|
+
return (
|
|
46
|
+
f"{name}: rgb({r} {g} {b} / {a_srgb:.3f});",
|
|
47
|
+
f"{name}: oklch({l:.3f} {c:.3f} {h} / {a_oklch:.3f});",
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _add_indent(lines: list[str], level: int = 1) -> list[str]:
|
|
52
|
+
return [f"{' ' * level}{l}" for l in lines]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _to_css_block(vars_: list[str], query: str | None = None) -> str:
|
|
56
|
+
query = (query or "").strip()
|
|
57
|
+
|
|
58
|
+
block = [":root {", *_add_indent(vars_), "}"]
|
|
59
|
+
|
|
60
|
+
if query:
|
|
61
|
+
block = [f"{query} {{", *_add_indent(block), "}"]
|
|
62
|
+
|
|
63
|
+
return "\n".join(block)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@final
|
|
67
|
+
class CSS(Backend):
|
|
68
|
+
"""Generates CSS custom properties from design tokens."""
|
|
69
|
+
|
|
70
|
+
@override
|
|
71
|
+
def codegen(self, target: TargetNode, artifact: ArtifactNode) -> dict[str, bytes]:
|
|
72
|
+
vars_: dict[TokenMapping, list[str]] = {
|
|
73
|
+
"light": [],
|
|
74
|
+
"dark": [],
|
|
75
|
+
"high_contrast_light": [],
|
|
76
|
+
"high_contrast_dark": [],
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
color_tokens = (t for t in target.tokens.values() if t.kind == "color")
|
|
80
|
+
|
|
81
|
+
for token in color_tokens:
|
|
82
|
+
for mode, node in token.mapping.items():
|
|
83
|
+
vars_[mode].extend(v for v in _to_css_vars(token.name, node))
|
|
84
|
+
|
|
85
|
+
blocks: list[str] = [
|
|
86
|
+
_to_css_block(v, _QUERIES[m]) for m, v in vars_.items() if v
|
|
87
|
+
]
|
|
88
|
+
|
|
89
|
+
css = "\n\n".join(blocks) + "\n"
|
|
90
|
+
|
|
91
|
+
sink = MemorySink()
|
|
92
|
+
sink.write(
|
|
93
|
+
Path(artifact.path) / f"{artifact.stem or 'tokens'}.css",
|
|
94
|
+
css.encode(),
|
|
95
|
+
)
|
|
96
|
+
return sink.files
|
|
File without changes
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import time
|
|
3
|
+
from collections.abc import Generator
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import TYPE_CHECKING, Annotated
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
from resforge.io import atomic_write
|
|
9
|
+
from rich import print # noqa: A004
|
|
10
|
+
from rich.logging import RichHandler
|
|
11
|
+
|
|
12
|
+
from forje import __version__
|
|
13
|
+
from forje.cli.ui import error, success
|
|
14
|
+
from forje.cli.utils import format_elapsed
|
|
15
|
+
from forje.core.driver import Driver
|
|
16
|
+
from forje.core.environment import Environment
|
|
17
|
+
from forje.core.errors import ForjeError
|
|
18
|
+
from forje.core.loader import load_plugins
|
|
19
|
+
from forje.passes.codegen import Codegen
|
|
20
|
+
from forje.passes.color_norm import ColorCanonicalizer
|
|
21
|
+
from forje.passes.validation import PlatformSupport, TargetFilter, TargetValidation
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
from forje.core.pass_ import Pass
|
|
25
|
+
|
|
26
|
+
logging.basicConfig(
|
|
27
|
+
level=logging.INFO,
|
|
28
|
+
format="%(message)s",
|
|
29
|
+
datefmt="[%X]",
|
|
30
|
+
handlers=[RichHandler(rich_tracebacks=True)],
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
app = typer.Typer(
|
|
34
|
+
name="forje",
|
|
35
|
+
no_args_is_help=True,
|
|
36
|
+
add_completion=False,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _find_build_file() -> Path | None:
|
|
41
|
+
candidate = Path.cwd() / "build.forje"
|
|
42
|
+
return candidate if candidate.exists() else None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _version_callback(*, value: bool) -> None:
|
|
46
|
+
if value:
|
|
47
|
+
print(f"forje {__version__}")
|
|
48
|
+
raise typer.Exit
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@app.callback()
|
|
52
|
+
def main(
|
|
53
|
+
_: Annotated[
|
|
54
|
+
bool | None,
|
|
55
|
+
typer.Option(
|
|
56
|
+
"--version",
|
|
57
|
+
callback=_version_callback,
|
|
58
|
+
is_eager=True,
|
|
59
|
+
help="Show version and exit.",
|
|
60
|
+
),
|
|
61
|
+
] = None,
|
|
62
|
+
) -> None:
|
|
63
|
+
"""Build design system resources from Starlark definitions."""
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@app.command()
|
|
67
|
+
def build(
|
|
68
|
+
targets: Annotated[
|
|
69
|
+
list[str] | None,
|
|
70
|
+
typer.Option("--target", help="Target to build."),
|
|
71
|
+
] = None,
|
|
72
|
+
) -> None:
|
|
73
|
+
"""Build design system resources."""
|
|
74
|
+
build_file = _find_build_file()
|
|
75
|
+
|
|
76
|
+
if build_file is None:
|
|
77
|
+
error("build.forje not found in current directory.")
|
|
78
|
+
raise typer.Exit(code=1)
|
|
79
|
+
|
|
80
|
+
try:
|
|
81
|
+
source = build_file.read_text(encoding="utf-8")
|
|
82
|
+
except OSError as e:
|
|
83
|
+
error(f"Could not read build.forje: {e.strerror}")
|
|
84
|
+
raise typer.Exit(code=1) from e
|
|
85
|
+
|
|
86
|
+
start = time.perf_counter()
|
|
87
|
+
|
|
88
|
+
try:
|
|
89
|
+
env = Environment(*load_plugins())
|
|
90
|
+
|
|
91
|
+
pipeline: list[Pass] = [
|
|
92
|
+
TargetFilter(targets),
|
|
93
|
+
TargetValidation(),
|
|
94
|
+
PlatformSupport(env),
|
|
95
|
+
ColorCanonicalizer(),
|
|
96
|
+
*env.passes,
|
|
97
|
+
Codegen(env),
|
|
98
|
+
]
|
|
99
|
+
|
|
100
|
+
outputs = Driver(env).build(source, pipeline)
|
|
101
|
+
|
|
102
|
+
for _, _, file_path, file_bytes in _walk_files(outputs):
|
|
103
|
+
with atomic_write(file_path) as f:
|
|
104
|
+
_ = f.write(file_bytes)
|
|
105
|
+
except* ForjeError as eg:
|
|
106
|
+
for e in eg.exceptions:
|
|
107
|
+
notes = " ".join(getattr(e, "__notes__", []))
|
|
108
|
+
error(f"{e} {notes}".strip())
|
|
109
|
+
raise typer.Exit(code=1) from eg
|
|
110
|
+
|
|
111
|
+
elapsed = time.perf_counter() - start
|
|
112
|
+
success(f"Build succeeded in {format_elapsed(elapsed)}")
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _walk_files(
|
|
116
|
+
results: dict[str, dict[str, dict[str, bytes]]],
|
|
117
|
+
) -> Generator[tuple[str, str, str, bytes]]:
|
|
118
|
+
for target, platforms in results.items():
|
|
119
|
+
for platform, files in platforms.items():
|
|
120
|
+
for file_path, file_bytes in files.items():
|
|
121
|
+
yield target, platform, file_path, file_bytes
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
if __name__ == "__main__":
|
|
125
|
+
app()
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import typer
|
|
2
|
+
|
|
3
|
+
__all__ = ["error", "success"]
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def error(message: str) -> None:
|
|
7
|
+
"""Prints a formatted error message."""
|
|
8
|
+
typer.secho(f"Error: {message}", fg=typer.colors.RED, err=True)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def success(message: str) -> None:
|
|
12
|
+
"""Prints a formatted success message."""
|
|
13
|
+
typer.secho(message, fg=typer.colors.GREEN)
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
__all__ = ["format_elapsed"]
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def format_elapsed(seconds: float) -> str:
|
|
5
|
+
"""Formats a duration in seconds into a human-readable string."""
|
|
6
|
+
if seconds >= 60:
|
|
7
|
+
m = int(seconds // 60)
|
|
8
|
+
s = int(seconds % 60)
|
|
9
|
+
return f"{m}m {s}s"
|
|
10
|
+
if seconds >= 1:
|
|
11
|
+
return f"{seconds:.1f}s"
|
|
12
|
+
return f"{seconds:.3f}s"
|
|
File without changes
|