@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.
Files changed (74) hide show
  1. package/README.md +174 -0
  2. package/dist/authoring.d.ts +42 -0
  3. package/dist/authoring.d.ts.map +1 -0
  4. package/dist/authoring.js +161 -0
  5. package/dist/cli.d.ts +3 -0
  6. package/dist/cli.d.ts.map +1 -0
  7. package/dist/cli.js +142 -0
  8. package/dist/color-value.d.ts +13 -0
  9. package/dist/color-value.d.ts.map +1 -0
  10. package/dist/color-value.js +71 -0
  11. package/dist/compilation/compiler.d.ts +61 -0
  12. package/dist/compilation/compiler.d.ts.map +1 -0
  13. package/dist/compilation/compiler.js +254 -0
  14. package/dist/compilation/definitions.d.ts +17 -0
  15. package/dist/compilation/definitions.d.ts.map +1 -0
  16. package/dist/compilation/definitions.js +103 -0
  17. package/dist/compilation/scope-validation.d.ts +12 -0
  18. package/dist/compilation/scope-validation.d.ts.map +1 -0
  19. package/dist/compilation/scope-validation.js +105 -0
  20. package/dist/compilation/validation.d.ts +4 -0
  21. package/dist/compilation/validation.d.ts.map +1 -0
  22. package/dist/compilation/validation.js +179 -0
  23. package/dist/compilation/web-projection.d.ts +31 -0
  24. package/dist/compilation/web-projection.d.ts.map +1 -0
  25. package/dist/compilation/web-projection.js +136 -0
  26. package/dist/generation.d.ts +6 -0
  27. package/dist/generation.d.ts.map +1 -0
  28. package/dist/generation.js +186 -0
  29. package/dist/json-pointer.d.ts +5 -0
  30. package/dist/json-pointer.d.ts.map +1 -0
  31. package/dist/json-pointer.js +36 -0
  32. package/dist/mutation/documents.d.ts +20 -0
  33. package/dist/mutation/documents.d.ts.map +1 -0
  34. package/dist/mutation/documents.js +77 -0
  35. package/dist/mutation/layout.d.ts +17 -0
  36. package/dist/mutation/layout.d.ts.map +1 -0
  37. package/dist/mutation/layout.js +43 -0
  38. package/dist/mutation/mode-operations.d.ts +15 -0
  39. package/dist/mutation/mode-operations.d.ts.map +1 -0
  40. package/dist/mutation/mode-operations.js +124 -0
  41. package/dist/mutation/mutations.d.ts +11 -0
  42. package/dist/mutation/mutations.d.ts.map +1 -0
  43. package/dist/mutation/mutations.js +41 -0
  44. package/dist/mutation/planner.d.ts +11 -0
  45. package/dist/mutation/planner.d.ts.map +1 -0
  46. package/dist/mutation/planner.js +67 -0
  47. package/dist/mutation/reference-renamer.d.ts +25 -0
  48. package/dist/mutation/reference-renamer.d.ts.map +1 -0
  49. package/dist/mutation/reference-renamer.js +186 -0
  50. package/dist/mutation/token-operations.d.ts +31 -0
  51. package/dist/mutation/token-operations.d.ts.map +1 -0
  52. package/dist/mutation/token-operations.js +309 -0
  53. package/dist/mutation/types.d.ts +78 -0
  54. package/dist/mutation/types.d.ts.map +1 -0
  55. package/dist/mutation/types.js +9 -0
  56. package/dist/mutation/validation.d.ts +4 -0
  57. package/dist/mutation/validation.d.ts.map +1 -0
  58. package/dist/mutation/validation.js +48 -0
  59. package/dist/source/composer.d.ts +17 -0
  60. package/dist/source/composer.d.ts.map +1 -0
  61. package/dist/source/composer.js +226 -0
  62. package/dist/source/documents.d.ts +18 -0
  63. package/dist/source/documents.d.ts.map +1 -0
  64. package/dist/source/documents.js +68 -0
  65. package/dist/source/load.d.ts +8 -0
  66. package/dist/source/load.d.ts.map +1 -0
  67. package/dist/source/load.js +13 -0
  68. package/dist/source/resolver.d.ts +14 -0
  69. package/dist/source/resolver.d.ts.map +1 -0
  70. package/dist/source/resolver.js +180 -0
  71. package/dist/source/values.d.ts +17 -0
  72. package/dist/source/values.d.ts.map +1 -0
  73. package/dist/source/values.js +21 -0
  74. 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,3 @@
1
+ #!/usr/bin/env node
2
+ export {};
3
+ //# sourceMappingURL=cli.d.ts.map
@@ -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
+ }