@reforma/project-tokens 0.0.1
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.
- package/README.md +174 -0
- package/dist/authoring.d.ts +42 -0
- package/dist/authoring.d.ts.map +1 -0
- package/dist/authoring.js +161 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +142 -0
- package/dist/color-value.d.ts +13 -0
- package/dist/color-value.d.ts.map +1 -0
- package/dist/color-value.js +71 -0
- package/dist/compilation/compiler.d.ts +61 -0
- package/dist/compilation/compiler.d.ts.map +1 -0
- package/dist/compilation/compiler.js +254 -0
- package/dist/compilation/definitions.d.ts +17 -0
- package/dist/compilation/definitions.d.ts.map +1 -0
- package/dist/compilation/definitions.js +103 -0
- package/dist/compilation/scope-validation.d.ts +12 -0
- package/dist/compilation/scope-validation.d.ts.map +1 -0
- package/dist/compilation/scope-validation.js +105 -0
- package/dist/compilation/validation.d.ts +4 -0
- package/dist/compilation/validation.d.ts.map +1 -0
- package/dist/compilation/validation.js +179 -0
- package/dist/compilation/web-projection.d.ts +31 -0
- package/dist/compilation/web-projection.d.ts.map +1 -0
- package/dist/compilation/web-projection.js +136 -0
- package/dist/generation.d.ts +6 -0
- package/dist/generation.d.ts.map +1 -0
- package/dist/generation.js +186 -0
- package/dist/json-pointer.d.ts +5 -0
- package/dist/json-pointer.d.ts.map +1 -0
- package/dist/json-pointer.js +36 -0
- package/dist/mutation/documents.d.ts +20 -0
- package/dist/mutation/documents.d.ts.map +1 -0
- package/dist/mutation/documents.js +77 -0
- package/dist/mutation/layout.d.ts +17 -0
- package/dist/mutation/layout.d.ts.map +1 -0
- package/dist/mutation/layout.js +43 -0
- package/dist/mutation/mode-operations.d.ts +15 -0
- package/dist/mutation/mode-operations.d.ts.map +1 -0
- package/dist/mutation/mode-operations.js +124 -0
- package/dist/mutation/mutations.d.ts +11 -0
- package/dist/mutation/mutations.d.ts.map +1 -0
- package/dist/mutation/mutations.js +41 -0
- package/dist/mutation/planner.d.ts +11 -0
- package/dist/mutation/planner.d.ts.map +1 -0
- package/dist/mutation/planner.js +67 -0
- package/dist/mutation/reference-renamer.d.ts +25 -0
- package/dist/mutation/reference-renamer.d.ts.map +1 -0
- package/dist/mutation/reference-renamer.js +186 -0
- package/dist/mutation/token-operations.d.ts +31 -0
- package/dist/mutation/token-operations.d.ts.map +1 -0
- package/dist/mutation/token-operations.js +309 -0
- package/dist/mutation/types.d.ts +78 -0
- package/dist/mutation/types.d.ts.map +1 -0
- package/dist/mutation/types.js +9 -0
- package/dist/mutation/validation.d.ts +4 -0
- package/dist/mutation/validation.d.ts.map +1 -0
- package/dist/mutation/validation.js +48 -0
- package/dist/source/composer.d.ts +17 -0
- package/dist/source/composer.d.ts.map +1 -0
- package/dist/source/composer.js +226 -0
- package/dist/source/documents.d.ts +18 -0
- package/dist/source/documents.d.ts.map +1 -0
- package/dist/source/documents.js +68 -0
- package/dist/source/load.d.ts +8 -0
- package/dist/source/load.d.ts.map +1 -0
- package/dist/source/load.js +13 -0
- package/dist/source/resolver.d.ts +14 -0
- package/dist/source/resolver.d.ts.map +1 -0
- package/dist/source/resolver.js +180 -0
- package/dist/source/values.d.ts +17 -0
- package/dist/source/values.d.ts.map +1 -0
- package/dist/source/values.js +21 -0
- package/package.json +66 -0
package/README.md
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
# @reforma/project-tokens
|
|
2
|
+
|
|
3
|
+
Compile project DTCG JSON into CSS, Tailwind theme bindings, and mode metadata.
|
|
4
|
+
The compiler runs locally on Node 24 or newer. It does not call a Reforma service.
|
|
5
|
+
|
|
6
|
+
## CLI
|
|
7
|
+
|
|
8
|
+
Install the package, then call the `reforma-tokens` binary from a script. The default
|
|
9
|
+
entry is `.reforma/tokens/tokens.resolver.json`.
|
|
10
|
+
|
|
11
|
+
```json
|
|
12
|
+
{
|
|
13
|
+
"scripts": {
|
|
14
|
+
"dev": "reforma-tokens dev -- next dev",
|
|
15
|
+
"build": "reforma-tokens build && next build"
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
`dev` compiles, then runs the command after `--`. That command starts the app and
|
|
21
|
+
must not call `reforma-tokens` again. `check`, `build`, and `watch` take no child
|
|
22
|
+
command.
|
|
23
|
+
|
|
24
|
+
| Command | Behavior |
|
|
25
|
+
| -------------------- | -------------------------------------------------------------------------------------- |
|
|
26
|
+
| `check` | Validate every permutation. Write nothing. Exit 1 on errors. |
|
|
27
|
+
| `build` | Compile and replace changed generated files. Exit 1 on errors. |
|
|
28
|
+
| `watch` | Build, then poll dependency hashes. Recover after invalid or missing files. |
|
|
29
|
+
| `dev -- command ...` | Build before starting the command. Watch sources. Forward signals and the exit status. |
|
|
30
|
+
|
|
31
|
+
`--cwd directory` selects the workspace. `--entry path` selects its resolver.
|
|
32
|
+
Repeat `--context axis=value` to supply contexts, including axes without defaults.
|
|
33
|
+
Every permutation is validated. The selected or default input supplies `:root`.
|
|
34
|
+
Names are case-sensitive. Compilation stops at 1000 permutations.
|
|
35
|
+
|
|
36
|
+
Ignore `.reforma/tokens/.generated/` in Git. A build writes `tokens.css`,
|
|
37
|
+
`tailwind.css`, and `modes.json` there, and may leave staging and lock files
|
|
38
|
+
while it runs. Import the generated CSS before application styles, and import
|
|
39
|
+
`tailwind.css` through the project's Tailwind entry.
|
|
40
|
+
|
|
41
|
+
A failed rebuild leaves the last successful output unchanged. Watch and dev print
|
|
42
|
+
one JSON diagnostic per line on stderr and `{ "status": "stale", "revision" }`
|
|
43
|
+
when input is invalid. A successful rebuild prints `{ "status": "ready", "revision" }`
|
|
44
|
+
on stdout. The app keeps running after a bad edit. An invalid first build does
|
|
45
|
+
not start the dev command.
|
|
46
|
+
|
|
47
|
+
## API
|
|
48
|
+
|
|
49
|
+
```ts
|
|
50
|
+
import { compileProjectTokens } from "@reforma/project-tokens";
|
|
51
|
+
|
|
52
|
+
const result = await compileProjectTokens({ workspaceRoot: process.cwd() });
|
|
53
|
+
if (result.ok) {
|
|
54
|
+
console.log(result.output?.tokensCss);
|
|
55
|
+
console.log(result.permutations);
|
|
56
|
+
}
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Compilation reads sources and returns diagnostics, dependency SHA-256 hashes,
|
|
60
|
+
an input revision, resolved tokens per permutation, and generated strings.
|
|
61
|
+
Each token keeps its canonical path, type, resolved value, CSS declarations, and
|
|
62
|
+
winning source file and JSON Pointer. A missing dependency has a null hash.
|
|
63
|
+
The API does not write files. Source extensions stay opaque.
|
|
64
|
+
|
|
65
|
+
Local file references and same-document JSON Pointers resolve. Remote URLs,
|
|
66
|
+
paths that leave the workspace, and symlinks that escape it are rejected.
|
|
67
|
+
Resolver `$ref` siblings replace referenced fields shallowly. Ordered composition
|
|
68
|
+
replaces whole tokens. Groups merge recursively. Aliases resolve after composition
|
|
69
|
+
for each context. `$root` stays in token identity and aliases, and is omitted
|
|
70
|
+
from CSS names.
|
|
71
|
+
|
|
72
|
+
A `mode` modifier selects `html[data-theme="context"]`. Any other axis selects
|
|
73
|
+
`html[data-token-AXIS="context"]`. Each selector constrains every active axis.
|
|
74
|
+
`:root` receives the full default. Context selectors receive only the values
|
|
75
|
+
that differ. CSS names join path segments with `-`, escape CSS characters, and
|
|
76
|
+
reject collisions. Recognized Tailwind namespaces become `@theme inline` bindings.
|
|
77
|
+
Other groups stay ordinary CSS variables. The namespace does not infer the type.
|
|
78
|
+
|
|
79
|
+
### Authoring data
|
|
80
|
+
|
|
81
|
+
The snapshot describes the sources an editor can show:
|
|
82
|
+
|
|
83
|
+
| Field | Meaning |
|
|
84
|
+
| ----------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
85
|
+
| `editing` | A supported layout with `defaultMode`, `baseFile`, and `modeFiles`, or why structured editing is unavailable. A null mode file is a sparse context. |
|
|
86
|
+
| `permutations[].groups` | Flat group hierarchy, including empty groups and the root at `""`. Each group has provenance, original metadata in `definition`, and effective `type`, `description`, and `deprecated`. |
|
|
87
|
+
| `tokens[].authoring.definition` | Original token properties, including unresolved `$value` aliases, JSON Pointers, and vendor extensions. `value` stays resolved. |
|
|
88
|
+
| `tokens[].description`, `deprecated` | Effective metadata after composition and group inheritance. Description belongs to the node. Type and deprecation can be inherited. |
|
|
89
|
+
| `tokens[].authoring.inheritance` | `base`, `inherited`, or `override` in the supported mode layout, or null when ownership is ambiguous. An explicit override stays an override when its value equals the base. |
|
|
90
|
+
| `tokens[].authoring.canReset`, `readOnlyReason` | Whether reset is allowed, and why the simple value editor cannot change this token. Composite values and tokens supplied through `$extends` stay readable. |
|
|
91
|
+
|
|
92
|
+
Group `definition` is metadata from the last contributing source, without children
|
|
93
|
+
or `$root`. Effective fields describe the composed group. Do not write that object
|
|
94
|
+
back as a document. Definitions and resolved values share the snapshot revision,
|
|
95
|
+
including candidates from `planTokenMutation`.
|
|
96
|
+
|
|
97
|
+
Eligibility describes source structure. A mutation still checks references,
|
|
98
|
+
usages, the revision, and the full candidate. Missing authoring fields do not
|
|
99
|
+
mean the token can be edited.
|
|
100
|
+
|
|
101
|
+
## Structured editing
|
|
102
|
+
|
|
103
|
+
`planTokenMutation` from `@reforma/project-tokens/mutations` plans source edits
|
|
104
|
+
and compiles the candidate in memory. It returns the original revision, changed
|
|
105
|
+
files with before and after text, and the candidate compilation. It writes nothing.
|
|
106
|
+
|
|
107
|
+
Create a top-level scope with `{ kind: 'scope.create', path, type }` and one of
|
|
108
|
+
the exported `DTCG_TOKEN_TYPES`. `group.create` adds an inherited subgroup inside
|
|
109
|
+
an existing scope. `token.create` sets `$value` and metadata in the default mode
|
|
110
|
+
and takes the type from that scope. `$type` is rejected on token create, ordinary
|
|
111
|
+
updates, and sparse mode files.
|
|
112
|
+
|
|
113
|
+
Token operations are update, delete, reset, and rename. Group operations are
|
|
114
|
+
delete and rename. A rename is `{ kind, path, to }`. A token destination must
|
|
115
|
+
share the scope type. Nested groups stay inside their scope. A scope can be
|
|
116
|
+
renamed at the top level. Aliases, JSON Pointers, and mode overrides move with
|
|
117
|
+
the node, and values keep their inherited type. A scope type cannot change.
|
|
118
|
+
Deleting a nonempty group requires `tokens`, the exact list of descendant paths.
|
|
119
|
+
|
|
120
|
+
Mode operations are create, delete, default, rename, and reset. Mode rename is
|
|
121
|
+
`{ kind: 'mode.rename', mode, to }`. Mode reset drops that mode's overrides.
|
|
122
|
+
The default mode cannot be reset.
|
|
123
|
+
|
|
124
|
+
The managed profile is one base set, then a `mode` modifier, with separate local
|
|
125
|
+
JSON files and an empty default context. Every top-level base group declares one
|
|
126
|
+
standard `$type`. Nested groups inherit it. Base owns every token path. A token
|
|
127
|
+
`$type`, a mode `$type`, a mode-only path, or a cross-type descendant fails with
|
|
128
|
+
a path-specific `SCOPE_*` diagnostic, including `expectedType` and `actualType`
|
|
129
|
+
on a mismatch. Invalid managed sources produce no output and block structured
|
|
130
|
+
writes. The compiler does not infer types or migrate documents.
|
|
131
|
+
|
|
132
|
+
A sparse context gets its own document on the first edit. Other resolver layouts
|
|
133
|
+
remain ordinary DTCG input, and structured editing reports `AMBIGUOUS_SOURCE`.
|
|
134
|
+
Changing the default preserves effective values and aliases. Detached mode
|
|
135
|
+
documents stay on disk. An existing file is not reused because its name matches.
|
|
136
|
+
|
|
137
|
+
Changing the default, including replacing a deleted default, does not yet keep
|
|
138
|
+
group-inherited `$deprecated` metadata across modes. CSS and values are preserved.
|
|
139
|
+
Deprecation metadata is not.
|
|
140
|
+
|
|
141
|
+
`buildProjectTokens` and `withTokenCompilationLock` from
|
|
142
|
+
`@reforma/project-tokens/generation` write the same output as the CLI.
|
|
143
|
+
|
|
144
|
+
## Compatibility
|
|
145
|
+
|
|
146
|
+
The target is [DTCG Format 2025.10](https://www.designtokens.org/tr/2025.10/format/)
|
|
147
|
+
and Resolver 2025.10. Terrazzo's parser and CSS tools are pinned to `2.7.1`.
|
|
148
|
+
The adapter covers tested upstream gaps: shallow resolver-reference overrides,
|
|
149
|
+
set references inside contexts, escaped pointers, shared group inheritance,
|
|
150
|
+
JSON Pointer `$extends`, explicit `$root`, composite array aliases, and provenance.
|
|
151
|
+
Neutral parser IDs keep legal names such as `constructor` and `__proto__`.
|
|
152
|
+
A value gate rejects Terrazzo-only types and dimension units. Unknown vendor
|
|
153
|
+
extensions do not turn on Terrazzo's legacy modes. A modifier with one context
|
|
154
|
+
produces a nonfatal `SINGLE_CONTEXT` diagnostic.
|
|
155
|
+
|
|
156
|
+
CSS uses Terrazzo's web projection, including the `dashed` fallback for custom
|
|
157
|
+
stroke patterns and separate custom properties for typography components. Colors
|
|
158
|
+
and color-bearing composites are serialized with Color.js in their native space,
|
|
159
|
+
which keeps alpha and avoids Terrazzo's wide-gamut fallback IDs.
|
|
160
|
+
|
|
161
|
+
## Development
|
|
162
|
+
|
|
163
|
+
From this package:
|
|
164
|
+
|
|
165
|
+
```sh
|
|
166
|
+
bun run test
|
|
167
|
+
bun run build
|
|
168
|
+
bun run verify:project
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
`verify:project` packs the built artifact, installs it in a temporary consumer,
|
|
172
|
+
and checks the Node API, CLI, standard types, modes, provenance, and Tailwind
|
|
173
|
+
output. It needs registry access. If `node` is older than 24, set
|
|
174
|
+
`PROJECT_TOKENS_NODE` to a Node 24+ binary.
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { ProjectToken, TokenCompilationOptions, TokenSource } from './compilation/compiler.js';
|
|
2
|
+
export declare const DTCG_TOKEN_TYPES: readonly ['color', 'dimension', 'fontFamily', 'fontWeight', 'duration', 'cubicBezier', 'number', 'strokeStyle', 'border', 'transition', 'shadow', 'gradient', 'typography'];
|
|
3
|
+
export type DtcgTokenType = typeof DTCG_TOKEN_TYPES[number];
|
|
4
|
+
interface TokenEditingIssue {
|
|
5
|
+
code: 'AMBIGUOUS_SOURCE' | 'COMPOSITE_TOKEN' | 'INVALID_SCOPE' | 'INVALID_SOURCE';
|
|
6
|
+
message: string;
|
|
7
|
+
}
|
|
8
|
+
export type TokenEditingProfile = {
|
|
9
|
+
supported: true;
|
|
10
|
+
defaultMode: string;
|
|
11
|
+
baseFile: string;
|
|
12
|
+
/** Null contexts inherit the base and receive a file on their first edit. */
|
|
13
|
+
modeFiles: Record<string, string | null>;
|
|
14
|
+
} | {
|
|
15
|
+
supported: false;
|
|
16
|
+
reason: TokenEditingIssue;
|
|
17
|
+
};
|
|
18
|
+
export interface TokenAuthoring {
|
|
19
|
+
/** Original source properties, including unresolved aliases, pointers and vendor extensions. */
|
|
20
|
+
definition: Record<string, unknown>;
|
|
21
|
+
inheritance: 'base' | 'inherited' | 'override' | null;
|
|
22
|
+
canReset: boolean;
|
|
23
|
+
/** Eligibility for the simple value editor; mutations still validate the complete candidate. */
|
|
24
|
+
readOnlyReason: TokenEditingIssue | null;
|
|
25
|
+
}
|
|
26
|
+
export interface TokenGroup {
|
|
27
|
+
/** Empty path identifies the document root; other paths encode the group hierarchy. */
|
|
28
|
+
path: string;
|
|
29
|
+
source: TokenSource | null;
|
|
30
|
+
/** Metadata authored at source; effective inherited fields are reported separately. */
|
|
31
|
+
definition: Record<string, unknown>;
|
|
32
|
+
type?: DtcgTokenType;
|
|
33
|
+
description?: string;
|
|
34
|
+
deprecated: boolean | string;
|
|
35
|
+
}
|
|
36
|
+
/** The same structural editing boundary is used by authoring reads and mutations. */
|
|
37
|
+
export declare function getTokenEditingProfile(options: TokenCompilationOptions, documents: ReadonlyMap<string, Record<string, any>>): TokenEditingProfile;
|
|
38
|
+
export declare function readTokenDefinition({ file, pointer }: TokenSource, documents: ReadonlyMap<string, Record<string, any>>): Record<string, any>;
|
|
39
|
+
/** Read source facts without converting an alias or a composite into its resolved value. */
|
|
40
|
+
export declare function describeTokenAuthoring(token: ProjectToken, documents: ReadonlyMap<string, Record<string, any>>, mode: string | undefined, profile: TokenEditingProfile): TokenAuthoring;
|
|
41
|
+
export {};
|
|
42
|
+
//# sourceMappingURL=authoring.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"authoring.d.ts","sourceRoot":"","sources":["../src/authoring.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,YAAY,EAAE,uBAAuB,EAAE,WAAW,EAAE,MAAM,2BAA2B,CAAC;AAEpG,eAAO,MAAM,gBAAgB,YACzB,OAAO,EAAE,WAAW,EAAE,YAAY,EAAE,YAAY,EAAE,UAAU,EAAE,aAAa,EAC3E,QAAQ,EAAE,aAAa,EAAE,QAAQ,EAAE,YAAY,EAAE,QAAQ,EAAE,UAAU,EAAE,YAAY,CAC7E,CAAC;AAEX,MAAM,MAAM,aAAa,GAAG,OAAO,gBAAgB,CAAC,MAAM,CAAC,CAAC;AAE5D,UAAU,iBAAiB;IACvB,IAAI,EAAE,kBAAkB,GAAG,iBAAiB,GAAG,eAAe,GAAG,gBAAgB,CAAC;IAClF,OAAO,EAAE,MAAM,CAAC;CACnB;AAOD,MAAM,MAAM,mBAAmB,GAAG;IAC9B,SAAS,EAAE,IAAI,CAAC;IAChB,WAAW,EAAE,MAAM,CAAC;IACpB,QAAQ,EAAE,MAAM,CAAC;IACjB,6EAA6E;IAC7E,SAAS,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC,CAAC;CAC5C,GAAG;IACA,SAAS,EAAE,KAAK,CAAC;IACjB,MAAM,EAAE,iBAAiB,CAAC;CAC7B,CAAC;AAEF,MAAM,WAAW,cAAc;IAC3B,gGAAgG;IAChG,UAAU,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACpC,WAAW,EAAE,MAAM,GAAG,WAAW,GAAG,UAAU,GAAG,IAAI,CAAC;IACtD,QAAQ,EAAE,OAAO,CAAC;IAClB,gGAAgG;IAChG,cAAc,EAAE,iBAAiB,GAAG,IAAI,CAAC;CAC5C;AAED,MAAM,WAAW,UAAU;IACvB,uFAAuF;IACvF,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,WAAW,GAAG,IAAI,CAAC;IAC3B,uFAAuF;IACvF,UAAU,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACpC,IAAI,CAAC,EAAE,aAAa,CAAC;IACrB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,UAAU,EAAE,OAAO,GAAG,MAAM,CAAC;CAChC;AAcD,qFAAqF;AACrF,wBAAgB,sBAAsB,CAClC,OAAO,EAAE,uBAAuB,EAChC,SAAS,EAAE,WAAW,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAC,GACpD,mBAAmB,CAyDrB;AAED,wBAAgB,mBAAmB,CAC/B,EAAE,IAAI,EAAE,OAAO,EAAE,EAAE,WAAW,EAC9B,SAAS,EAAE,WAAW,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAC,GACpD,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAErB;AAED,4FAA4F;AAC5F,wBAAgB,sBAAsB,CAClC,KAAK,EAAE,YAAY,EACnB,SAAS,EAAE,WAAW,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAC,EACnD,IAAI,EAAE,MAAM,GAAG,SAAS,EACxB,OAAO,EAAE,mBAAmB,GAC7B,cAAc,CAuBhB"}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { isAbsolute, relative, resolve, sep } from 'node:path';
|
|
2
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
3
|
+
import { decodeJsonPointer, encodeJsonPointer, readJsonPointer } from './json-pointer.js';
|
|
4
|
+
export const DTCG_TOKEN_TYPES = [
|
|
5
|
+
'color', 'dimension', 'fontFamily', 'fontWeight', 'duration', 'cubicBezier',
|
|
6
|
+
'number', 'strokeStyle', 'border', 'transition', 'shadow', 'gradient', 'typography',
|
|
7
|
+
];
|
|
8
|
+
const SIMPLE_EDITABLE_TYPES = new Set([
|
|
9
|
+
'color',
|
|
10
|
+
'cubicBezier',
|
|
11
|
+
'dimension',
|
|
12
|
+
'duration',
|
|
13
|
+
'fontFamily',
|
|
14
|
+
'fontWeight',
|
|
15
|
+
'number',
|
|
16
|
+
]);
|
|
17
|
+
const AMBIGUOUS_SOURCE_MESSAGE = 'Structured editing requires one base set followed by one mode modifier, with independent local documents and an empty default context. Edit this layout in JSON.';
|
|
18
|
+
/** The same structural editing boundary is used by authoring reads and mutations. */
|
|
19
|
+
export function getTokenEditingProfile(options, documents) {
|
|
20
|
+
const workspaceRoot = resolve(options.workspaceRoot);
|
|
21
|
+
const entryPath = resolve(workspaceRoot, options.entry ?? '.reforma/tokens/tokens.resolver.json');
|
|
22
|
+
const entryFile = relative(workspaceRoot, entryPath).split(sep).join('/');
|
|
23
|
+
const resolver = documents.get(entryFile);
|
|
24
|
+
if (!resolver || resolver.resolutionOrder?.length !== 2) {
|
|
25
|
+
return unsupportedEditingProfile();
|
|
26
|
+
}
|
|
27
|
+
const base = resolveResolverItem(resolver, resolver.resolutionOrder[0], 'sets');
|
|
28
|
+
const mode = resolveResolverItem(resolver, resolver.resolutionOrder[1], 'modifiers');
|
|
29
|
+
if (!isEditableModeItem(mode)) {
|
|
30
|
+
return unsupportedEditingProfile();
|
|
31
|
+
}
|
|
32
|
+
const readSourceFile = (sources) => readSingleLocalSource(sources, entryPath, workspaceRoot, documents);
|
|
33
|
+
const baseFile = readSourceFile(base.item?.sources);
|
|
34
|
+
if (baseFile === null) {
|
|
35
|
+
return unsupportedEditingProfile();
|
|
36
|
+
}
|
|
37
|
+
const modeFiles = Object.create(null);
|
|
38
|
+
for (const [name, sources] of Object.entries(mode.item.contexts)) {
|
|
39
|
+
if (!Array.isArray(sources)) {
|
|
40
|
+
return unsupportedEditingProfile();
|
|
41
|
+
}
|
|
42
|
+
const file = sources.length > 0 ? readSourceFile(sources) : null;
|
|
43
|
+
if (sources.length > 0 && file === null) {
|
|
44
|
+
return unsupportedEditingProfile();
|
|
45
|
+
}
|
|
46
|
+
modeFiles[name] = name === mode.item.default ? baseFile : file;
|
|
47
|
+
}
|
|
48
|
+
const sourceFiles = Object.values(modeFiles).filter(file => file !== null);
|
|
49
|
+
if (new Set(sourceFiles).size !== sourceFiles.length) {
|
|
50
|
+
return unsupportedEditingProfile();
|
|
51
|
+
}
|
|
52
|
+
return {
|
|
53
|
+
supported: true,
|
|
54
|
+
defaultMode: mode.item.default,
|
|
55
|
+
baseFile,
|
|
56
|
+
modeFiles,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
export function readTokenDefinition({ file, pointer }, documents) {
|
|
60
|
+
return readJsonPointer(documents.get(file), decodeJsonPointer(pointer));
|
|
61
|
+
}
|
|
62
|
+
/** Read source facts without converting an alias or a composite into its resolved value. */
|
|
63
|
+
export function describeTokenAuthoring(token, documents, mode, profile) {
|
|
64
|
+
const sourcePointer = encodeJsonPointer(token.path.split('.'));
|
|
65
|
+
let readOnlyReason = getSourceEditingIssue(token, sourcePointer, profile);
|
|
66
|
+
let inheritance = null;
|
|
67
|
+
if (profile.supported && readOnlyReason === null) {
|
|
68
|
+
inheritance = getInheritance(token, mode, profile);
|
|
69
|
+
readOnlyReason = getParentEditingIssue(token, mode, documents, profile);
|
|
70
|
+
}
|
|
71
|
+
if (readOnlyReason === null && !SIMPLE_EDITABLE_TYPES.has(token.type)) {
|
|
72
|
+
readOnlyReason = {
|
|
73
|
+
code: 'COMPOSITE_TOKEN',
|
|
74
|
+
message: 'Inspect this token here and edit its structured value in JSON.',
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
return {
|
|
78
|
+
definition: readTokenDefinition(token.source, documents),
|
|
79
|
+
inheritance,
|
|
80
|
+
canReset: readOnlyReason === null && inheritance === 'override',
|
|
81
|
+
readOnlyReason,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
function unsupportedEditingProfile() {
|
|
85
|
+
return {
|
|
86
|
+
supported: false,
|
|
87
|
+
reason: {
|
|
88
|
+
code: 'AMBIGUOUS_SOURCE',
|
|
89
|
+
message: AMBIGUOUS_SOURCE_MESSAGE,
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
function resolveResolverItem(resolver, item, section) {
|
|
94
|
+
const sectionReference = `#/${section}/`;
|
|
95
|
+
const isDirectReference = typeof item?.$ref === 'string'
|
|
96
|
+
&& Object.keys(item).length === 1
|
|
97
|
+
&& item.$ref.startsWith(sectionReference);
|
|
98
|
+
if (!isDirectReference) {
|
|
99
|
+
return { item, name: item?.name };
|
|
100
|
+
}
|
|
101
|
+
const name = decodeJsonPointer(item.$ref)[1];
|
|
102
|
+
return { item: resolver[section]?.[name], name };
|
|
103
|
+
}
|
|
104
|
+
function isEditableModeItem(mode) {
|
|
105
|
+
return mode.name === 'mode'
|
|
106
|
+
&& mode.item?.contexts
|
|
107
|
+
&& typeof mode.item.default === 'string'
|
|
108
|
+
&& mode.item.contexts[mode.item.default]?.length === 0;
|
|
109
|
+
}
|
|
110
|
+
function readSingleLocalSource(sources, entryPath, workspaceRoot, documents) {
|
|
111
|
+
if (!Array.isArray(sources)
|
|
112
|
+
|| sources.length !== 1
|
|
113
|
+
|| !sources[0]
|
|
114
|
+
|| typeof sources[0] !== 'object'
|
|
115
|
+
|| Object.keys(sources[0]).length !== 1
|
|
116
|
+
|| typeof sources[0].$ref !== 'string') {
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
const sourceUrl = new URL(sources[0].$ref, pathToFileURL(entryPath));
|
|
120
|
+
if (sourceUrl.protocol !== 'file:' || sourceUrl.host || sourceUrl.hash || sourceUrl.search) {
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
const file = relative(workspaceRoot, fileURLToPath(sourceUrl)).split(sep).join('/');
|
|
124
|
+
if (isAbsolute(file) || file.startsWith('../') || !documents.has(file)) {
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
return file;
|
|
128
|
+
}
|
|
129
|
+
function getSourceEditingIssue(token, expectedPointer, profile) {
|
|
130
|
+
if (!profile.supported) {
|
|
131
|
+
return profile.reason;
|
|
132
|
+
}
|
|
133
|
+
const knownSource = Object.values(profile.modeFiles).includes(token.source.file);
|
|
134
|
+
if (token.source.pointer === expectedPointer && knownSource) {
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
return {
|
|
138
|
+
code: 'AMBIGUOUS_SOURCE',
|
|
139
|
+
message: 'This token is supplied through group inheritance or a shared source. Edit its definition in JSON.',
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
function getInheritance(token, mode, profile) {
|
|
143
|
+
if (mode === profile.defaultMode) {
|
|
144
|
+
return 'base';
|
|
145
|
+
}
|
|
146
|
+
return token.source.file === profile.baseFile ? 'inherited' : 'override';
|
|
147
|
+
}
|
|
148
|
+
function getParentEditingIssue(token, mode, documents, profile) {
|
|
149
|
+
const modeFile = mode ? profile.modeFiles[mode] : null;
|
|
150
|
+
let parent = modeFile ? documents.get(modeFile) : undefined;
|
|
151
|
+
for (const part of token.path.split('.').slice(0, -1)) {
|
|
152
|
+
parent = parent?.[part];
|
|
153
|
+
if (parent && ('$value' in parent || '$extends' in parent)) {
|
|
154
|
+
return {
|
|
155
|
+
code: 'AMBIGUOUS_SOURCE',
|
|
156
|
+
message: 'The mode source contains an inherited group at this path. Edit its definition in JSON.',
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return null;
|
|
161
|
+
}
|
package/dist/cli.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":""}
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { spawn } from 'node:child_process';
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
import { realpath } from 'node:fs/promises';
|
|
5
|
+
import { constants } from 'node:os';
|
|
6
|
+
import { resolve } from 'node:path';
|
|
7
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
8
|
+
import { parseArgs } from 'node:util';
|
|
9
|
+
import { compileProjectTokens } from './compilation/compiler.js';
|
|
10
|
+
import { buildProjectTokens, readTokenDependency } from './generation.js';
|
|
11
|
+
function report(result) {
|
|
12
|
+
for (const diagnostic of result.diagnostics)
|
|
13
|
+
process.stderr.write(`${JSON.stringify(diagnostic)}\n`);
|
|
14
|
+
if (result.ok)
|
|
15
|
+
process.stdout.write(`${JSON.stringify({ status: 'ready', revision: result.revision })}\n`);
|
|
16
|
+
else
|
|
17
|
+
process.stderr.write(`${JSON.stringify({ status: 'stale', revision: result.revision })}\n`);
|
|
18
|
+
}
|
|
19
|
+
async function watch(options, command) {
|
|
20
|
+
let stopped = false;
|
|
21
|
+
let exitCode = 0;
|
|
22
|
+
let child;
|
|
23
|
+
let childDone;
|
|
24
|
+
const signalChild = (signal) => {
|
|
25
|
+
if (!child?.pid)
|
|
26
|
+
return;
|
|
27
|
+
try {
|
|
28
|
+
if (process.platform === 'win32')
|
|
29
|
+
child.kill(signal);
|
|
30
|
+
else
|
|
31
|
+
process.kill(-child.pid, signal);
|
|
32
|
+
}
|
|
33
|
+
catch (error) {
|
|
34
|
+
if (error.code !== 'ESRCH')
|
|
35
|
+
throw error;
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
const stop = (signal) => {
|
|
39
|
+
stopped = true;
|
|
40
|
+
exitCode = 128 + constants.signals[signal];
|
|
41
|
+
signalChild(signal);
|
|
42
|
+
};
|
|
43
|
+
const onTerm = () => stop('SIGTERM');
|
|
44
|
+
const onInt = () => stop('SIGINT');
|
|
45
|
+
process.on('SIGTERM', onTerm);
|
|
46
|
+
process.on('SIGINT', onInt);
|
|
47
|
+
try {
|
|
48
|
+
let dependencies = new Map();
|
|
49
|
+
let dirty = true;
|
|
50
|
+
let first = true;
|
|
51
|
+
// One queue for initial compilation and edits; hash polling also catches atomic saves and checkout.
|
|
52
|
+
while (!stopped) {
|
|
53
|
+
if (dirty) {
|
|
54
|
+
const result = await buildProjectTokens(options);
|
|
55
|
+
report(result);
|
|
56
|
+
if (result.ok)
|
|
57
|
+
dependencies.clear();
|
|
58
|
+
for (const file of result.dependencies)
|
|
59
|
+
dependencies.set(file.path, file.hash);
|
|
60
|
+
if (first && command.length) {
|
|
61
|
+
if (!result.ok) {
|
|
62
|
+
exitCode = 1;
|
|
63
|
+
break;
|
|
64
|
+
}
|
|
65
|
+
if (stopped)
|
|
66
|
+
break;
|
|
67
|
+
child = spawn(command[0], command.slice(1), { cwd: options.workspaceRoot, stdio: 'inherit', detached: process.platform !== 'win32' });
|
|
68
|
+
childDone = new Promise((done) => {
|
|
69
|
+
child.once('error', (error) => {
|
|
70
|
+
process.stderr.write(`${JSON.stringify({ severity: 'error', code: 'DEV_COMMAND', message: error.message })}\n`);
|
|
71
|
+
exitCode = 1;
|
|
72
|
+
stopped = true;
|
|
73
|
+
done();
|
|
74
|
+
});
|
|
75
|
+
child.once('exit', (code, signal) => {
|
|
76
|
+
if (!stopped)
|
|
77
|
+
exitCode = code ?? (signal ? 128 + constants.signals[signal] : 1);
|
|
78
|
+
stopped = true;
|
|
79
|
+
done();
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
first = false;
|
|
84
|
+
dirty = false;
|
|
85
|
+
}
|
|
86
|
+
await delay(150);
|
|
87
|
+
for (const [path, previous] of dependencies) {
|
|
88
|
+
const text = await readTokenDependency(options.workspaceRoot, path);
|
|
89
|
+
const current = text === null ? null : createHash('sha256').update(text).digest('hex');
|
|
90
|
+
if (current !== previous)
|
|
91
|
+
dirty = true;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
finally {
|
|
96
|
+
signalChild('SIGTERM');
|
|
97
|
+
if (childDone) {
|
|
98
|
+
const killTimer = setTimeout(() => signalChild('SIGKILL'), 2000);
|
|
99
|
+
await childDone;
|
|
100
|
+
clearTimeout(killTimer);
|
|
101
|
+
// A command can exit while leaving descendants in its process group.
|
|
102
|
+
signalChild('SIGKILL');
|
|
103
|
+
}
|
|
104
|
+
process.off('SIGTERM', onTerm);
|
|
105
|
+
process.off('SIGINT', onInt);
|
|
106
|
+
}
|
|
107
|
+
process.exitCode = exitCode;
|
|
108
|
+
}
|
|
109
|
+
async function main() {
|
|
110
|
+
const args = process.argv.slice(2);
|
|
111
|
+
const separator = args.indexOf('--');
|
|
112
|
+
const childCommand = separator < 0 ? [] : args.slice(separator + 1);
|
|
113
|
+
const { values, positionals } = parseArgs({ args: separator < 0 ? args : args.slice(0, separator), allowPositionals: true, options: {
|
|
114
|
+
cwd: { type: 'string' }, entry: { type: 'string' }, context: { type: 'string', multiple: true }, help: { type: 'boolean' },
|
|
115
|
+
} });
|
|
116
|
+
const [command] = positionals;
|
|
117
|
+
if (values.help) {
|
|
118
|
+
process.stdout.write('reforma-tokens <build|check|watch|dev -- command...> [--cwd directory] [--entry resolver.json] [--context axis=value]\n');
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
if (!['build', 'check', 'watch', 'dev'].includes(command ?? '') || positionals.length !== 1)
|
|
122
|
+
throw new Error('Expected build, check, watch or dev.');
|
|
123
|
+
if (command === 'dev' && !childCommand.length || command !== 'dev' && separator >= 0)
|
|
124
|
+
throw new Error('Use dev -- command arguments.');
|
|
125
|
+
const input = Object.create(null);
|
|
126
|
+
for (const context of values.context ?? []) {
|
|
127
|
+
const separator = context.indexOf('=');
|
|
128
|
+
if (separator < 0)
|
|
129
|
+
throw new Error('Expected --context axis=value.');
|
|
130
|
+
input[context.slice(0, separator)] = context.slice(separator + 1);
|
|
131
|
+
}
|
|
132
|
+
const options = { workspaceRoot: await realpath(resolve(values.cwd ?? process.cwd())), entry: values.entry, input };
|
|
133
|
+
if (command === 'watch' || command === 'dev')
|
|
134
|
+
return watch(options, childCommand);
|
|
135
|
+
const result = await (command === 'check' ? compileProjectTokens(options) : buildProjectTokens(options));
|
|
136
|
+
report(result);
|
|
137
|
+
process.exitCode = result.ok ? 0 : 1;
|
|
138
|
+
}
|
|
139
|
+
main().catch((error) => {
|
|
140
|
+
process.stderr.write(`${JSON.stringify({ severity: 'error', code: 'CLI_FAILED', message: error.message })}\n`);
|
|
141
|
+
process.exitCode = 1;
|
|
142
|
+
});
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export type DtcgColorSpace = 'a98-rgb' | 'display-p3' | 'hsl' | 'hwb' | 'lab' | 'lch' | 'oklab' | 'oklch' | 'prophoto-rgb' | 'rec2020' | 'srgb' | 'srgb-linear' | 'xyz-d50' | 'xyz-d65';
|
|
2
|
+
export type DtcgColorComponent = number | 'none';
|
|
3
|
+
export interface DtcgColorValue {
|
|
4
|
+
colorSpace: DtcgColorSpace;
|
|
5
|
+
components: [DtcgColorComponent, DtcgColorComponent, DtcgColorComponent];
|
|
6
|
+
alpha?: number;
|
|
7
|
+
hex?: string;
|
|
8
|
+
}
|
|
9
|
+
/** Validate a complete structured color value from the final DTCG format. */
|
|
10
|
+
export declare function isDtcgColorValue(value: unknown): value is DtcgColorValue;
|
|
11
|
+
/** Validate a color-space identifier supported by the final DTCG format. */
|
|
12
|
+
export declare function isDtcgColorSpace(value: unknown): value is DtcgColorSpace;
|
|
13
|
+
//# sourceMappingURL=color-value.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"color-value.d.ts","sourceRoot":"","sources":["../src/color-value.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,cAAc,GAClB,SAAS,GACT,YAAY,GACZ,KAAK,GACL,KAAK,GACL,KAAK,GACL,KAAK,GACL,OAAO,GACP,OAAO,GACP,cAAc,GACd,SAAS,GACT,MAAM,GACN,aAAa,GACb,SAAS,GACT,SAAS,CAAC;AAElB,MAAM,MAAM,kBAAkB,GAAG,MAAM,GAAG,MAAM,CAAC;AAEjD,MAAM,WAAW,cAAc;IAC3B,UAAU,EAAE,cAAc,CAAC;IAC3B,UAAU,EAAE,CAAC,kBAAkB,EAAE,kBAAkB,EAAE,kBAAkB,CAAC,CAAC;IACzE,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,GAAG,CAAC,EAAE,MAAM,CAAC;CAChB;AA8BD,6EAA6E;AAC7E,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,OAAO,GAAG,KAAK,IAAI,cAAc,CAsBxE;AAED,4EAA4E;AAC5E,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,OAAO,GAAG,KAAK,IAAI,cAAc,CAExE"}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
const COLOR_SPACES = new Set([
|
|
2
|
+
'a98-rgb',
|
|
3
|
+
'display-p3',
|
|
4
|
+
'hsl',
|
|
5
|
+
'hwb',
|
|
6
|
+
'lab',
|
|
7
|
+
'lch',
|
|
8
|
+
'oklab',
|
|
9
|
+
'oklch',
|
|
10
|
+
'prophoto-rgb',
|
|
11
|
+
'rec2020',
|
|
12
|
+
'srgb',
|
|
13
|
+
'srgb-linear',
|
|
14
|
+
'xyz-d50',
|
|
15
|
+
'xyz-d65',
|
|
16
|
+
]);
|
|
17
|
+
const UNIT_COLOR_SPACES = new Set([
|
|
18
|
+
'a98-rgb',
|
|
19
|
+
'display-p3',
|
|
20
|
+
'prophoto-rgb',
|
|
21
|
+
'rec2020',
|
|
22
|
+
'srgb',
|
|
23
|
+
'srgb-linear',
|
|
24
|
+
'xyz-d50',
|
|
25
|
+
'xyz-d65',
|
|
26
|
+
]);
|
|
27
|
+
/** Validate a complete structured color value from the final DTCG format. */
|
|
28
|
+
export function isDtcgColorValue(value) {
|
|
29
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
30
|
+
return false;
|
|
31
|
+
const color = value;
|
|
32
|
+
return Object.keys(color).every(key => ['colorSpace', 'components', 'alpha', 'hex'].includes(key))
|
|
33
|
+
&& isDtcgColorSpace(color.colorSpace)
|
|
34
|
+
&& Array.isArray(color.components)
|
|
35
|
+
&& color.components.length === 3
|
|
36
|
+
&& color.components.every((component, index) => (component === 'none'
|
|
37
|
+
|| isFiniteNumber(component)
|
|
38
|
+
&& isColorComponentInRange(color.colorSpace, component, index)))
|
|
39
|
+
&& (color.alpha === undefined
|
|
40
|
+
|| isFiniteNumber(color.alpha) && color.alpha >= 0 && color.alpha <= 1)
|
|
41
|
+
&& (color.hex === undefined
|
|
42
|
+
|| typeof color.hex === 'string' && /^#[\da-f]{6}$/i.test(color.hex));
|
|
43
|
+
}
|
|
44
|
+
/** Validate a color-space identifier supported by the final DTCG format. */
|
|
45
|
+
export function isDtcgColorSpace(value) {
|
|
46
|
+
return typeof value === 'string' && COLOR_SPACES.has(value);
|
|
47
|
+
}
|
|
48
|
+
function isFiniteNumber(value) {
|
|
49
|
+
return typeof value === 'number' && Number.isFinite(value);
|
|
50
|
+
}
|
|
51
|
+
function isColorComponentInRange(colorSpace, component, index) {
|
|
52
|
+
if (UNIT_COLOR_SPACES.has(colorSpace))
|
|
53
|
+
return component >= 0 && component <= 1;
|
|
54
|
+
if (colorSpace === 'hsl' || colorSpace === 'hwb') {
|
|
55
|
+
return index === 0 ? component >= 0 && component < 360 : component >= 0 && component <= 100;
|
|
56
|
+
}
|
|
57
|
+
if (colorSpace === 'lab') {
|
|
58
|
+
return index !== 0 || component >= 0 && component <= 100;
|
|
59
|
+
}
|
|
60
|
+
if (colorSpace === 'lch') {
|
|
61
|
+
return index === 0 ? component >= 0 && component <= 100
|
|
62
|
+
: index === 1 ? component >= 0
|
|
63
|
+
: component >= 0 && component < 360;
|
|
64
|
+
}
|
|
65
|
+
if (colorSpace === 'oklab') {
|
|
66
|
+
return index !== 0 || component >= 0 && component <= 1;
|
|
67
|
+
}
|
|
68
|
+
return index === 0 ? component >= 0 && component <= 1
|
|
69
|
+
: index === 1 ? component >= 0
|
|
70
|
+
: component >= 0 && component < 360;
|
|
71
|
+
}
|