@konce-pt/theme 0.9.0
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/LICENSE +21 -0
- package/README.md +65 -0
- package/dist/audit.d.ts +59 -0
- package/dist/audit.js +102 -0
- package/dist/dtcg.d.ts +80 -0
- package/dist/dtcg.js +624 -0
- package/dist/emit.d.ts +76 -0
- package/dist/emit.js +276 -0
- package/dist/generated/tokens.d.ts +24 -0
- package/dist/generated/tokens.js +348 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.js +9 -0
- package/dist/ramp.d.ts +41 -0
- package/dist/ramp.js +66 -0
- package/dist/resolve.d.ts +56 -0
- package/dist/resolve.js +192 -0
- package/dist/roles.d.ts +28 -0
- package/dist/roles.js +77 -0
- package/dist/runtime.d.ts +32 -0
- package/dist/runtime.js +41 -0
- package/dist/schema.d.ts +128 -0
- package/dist/schema.js +129 -0
- package/package.json +52 -0
package/dist/dtcg.js
ADDED
|
@@ -0,0 +1,624 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Import z formatu W3C Design Tokens (DTCG) — jednokierunkowy, plikowy.
|
|
3
|
+
*
|
|
4
|
+
* To jest import, nie synchronizacja, i tak trzeba go nazywać. Odczyt zmiennych z narzędzia
|
|
5
|
+
* projektowego przez API bywa zarezerwowany dla planów firmowych, więc realną drogą jest plik
|
|
6
|
+
* wyeksportowany wtyczką. Nie ma tu OAuth, nie ma odpytywania, nie ma śledzenia zmian.
|
|
7
|
+
*
|
|
8
|
+
* Najtrudniejsze nie jest samo czytanie tokenów, tylko **tryby**: DTCG ich nie standaryzuje.
|
|
9
|
+
* Eksportery kodują je na trzy sposoby i każdy trzeba obsłużyć osobno — patrz `splitModes`.
|
|
10
|
+
*
|
|
11
|
+
* Zasada, od której nie ma odstępstwa: **importer nigdy niczego nie gubi po cichu.** Token, którego
|
|
12
|
+
* nie umiemy przypisać, trafia do raportu z powodem. Lepiej pokazać listę stu niedopasowań niż
|
|
13
|
+
* wczytać trzydzieści i nie powiedzieć o siedemdziesięciu.
|
|
14
|
+
*/
|
|
15
|
+
import { formatOklch, parseHexColor, parseOklch, srgbToOklch } from '@konce-pt/color';
|
|
16
|
+
import { KPT_SEMANTIC_TOKENS } from "./generated/tokens.js";
|
|
17
|
+
import { isPrimitiveToken, isSemanticToken, KPT_ALL_TOKENS, scaleLength, stockTheme } from "./resolve.js";
|
|
18
|
+
// ── Czytanie drzewa ──────────────────────────────────────────────────────────────────────────
|
|
19
|
+
const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
20
|
+
/** Węzeł jest tokenem, gdy niesie wartość — w zapisie nowym (`$value`) albo starym (`value`). */
|
|
21
|
+
const tokenValue = (node) => node['$value'] !== undefined ? node['$value'] : node['value'];
|
|
22
|
+
const hasValue = (node) => node['$value'] !== undefined || node['value'] !== undefined;
|
|
23
|
+
const tokenType = (node, inherited) => (typeof node['$type'] === 'string' && node['$type']) ||
|
|
24
|
+
(typeof node['type'] === 'string' && node['type']) ||
|
|
25
|
+
inherited;
|
|
26
|
+
/**
|
|
27
|
+
* Drzewo DTCG na płaską listę. Typ dziedziczy się z grupy — tak mówi specyfikacja i tak robią
|
|
28
|
+
* eksportery, więc token bez własnego `$type` nie jest tokenem bez typu, tylko tokenem, którego
|
|
29
|
+
* typ trzeba wziąć z rodzica.
|
|
30
|
+
*/
|
|
31
|
+
function flatten(node, prefix, inherited, out) {
|
|
32
|
+
if (!isRecord(node))
|
|
33
|
+
return;
|
|
34
|
+
const groupType = tokenType(node, inherited);
|
|
35
|
+
if (hasValue(node)) {
|
|
36
|
+
out.push({ path: prefix.join('.'), type: groupType, value: tokenValue(node) });
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
for (const [key, child] of Object.entries(node)) {
|
|
40
|
+
// `$`-owe pola to metadane grupy ($type, $description, $extensions), nie tokeny.
|
|
41
|
+
if (key.startsWith('$'))
|
|
42
|
+
continue;
|
|
43
|
+
flatten(child, [...prefix, key], groupType, out);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
const ALIAS = /^\{([^}]+)\}$/;
|
|
47
|
+
/**
|
|
48
|
+
* Rozwiązuje aliasy `{grupa.token}`. Alias wskazujący na nieistniejącą ścieżkę **nie znika** —
|
|
49
|
+
* wraca jako `null` i ląduje w ostrzeżeniach, bo zepsuty alias w pliku źródłowym to informacja
|
|
50
|
+
* o stanie tego pliku, a nie nasz problem do zamiecenia.
|
|
51
|
+
*/
|
|
52
|
+
function resolveAlias(value, byPath, seen) {
|
|
53
|
+
if (typeof value !== 'string')
|
|
54
|
+
return value;
|
|
55
|
+
const match = ALIAS.exec(value.trim());
|
|
56
|
+
if (!match)
|
|
57
|
+
return value;
|
|
58
|
+
const path = match[1];
|
|
59
|
+
if (seen.has(path))
|
|
60
|
+
return null;
|
|
61
|
+
const target = byPath.get(path);
|
|
62
|
+
return target ? resolveAlias(target.value, byPath, new Set([...seen, path])) : null;
|
|
63
|
+
}
|
|
64
|
+
// ── Odtwarzanie przepisu ─────────────────────────────────────────────────────────────────────
|
|
65
|
+
const NUMBER = /^(-?(?:\d*\.\d+|\d+))[a-z%]*$/i;
|
|
66
|
+
const magnitude = (value) => {
|
|
67
|
+
const match = NUMBER.exec(String(value ?? '').trim());
|
|
68
|
+
return match ? Math.abs(Number(match[1])) : null;
|
|
69
|
+
};
|
|
70
|
+
/**
|
|
71
|
+
* Próbuje odczytać z zaimportowanych prymitywów **współczynnik skali** całej rodziny.
|
|
72
|
+
*
|
|
73
|
+
* Po co: eksport niesie wartości już przeliczone, więc import bez tego kroku daje motyw
|
|
74
|
+
* *wyglądający* tak samo, ale **nieedytowalny** — zamiast jednego suwaka „gęstość interfejsu"
|
|
75
|
+
* użytkownik dostaje jedenaście zamrożonych odstępów, a ruszenie suwaka nie robi nic, bo nadpisania
|
|
76
|
+
* wchodzą po przepisie i go przykrywają. Motyw, który wraca z narzędzia projektowego, ma być dalej
|
|
77
|
+
* motywem, a nie zrzutem.
|
|
78
|
+
*
|
|
79
|
+
* Sprawdzenie jest **dosłowne, nie przybliżone**: bierzemy kandydata ze stopnia o największej
|
|
80
|
+
* wartości stockowej (tam zaokrąglenie waży najmniej) i pytamy, czy nasz własny `scaleLength`
|
|
81
|
+
* odtworzyłby z niego każdy stopień rodziny co do znaku. To jedyny test, który naprawdę znaczy
|
|
82
|
+
* „nasz generator mógł to wyprodukować"; dobieranie epsilona byłoby zgadywaniem.
|
|
83
|
+
*
|
|
84
|
+
* Stopnie nieobecne w imporcie też muszą się zgadzać — ze swoją wartością stockową. Inaczej
|
|
85
|
+
* przypisanie skali po cichu ruszyłoby tokeny, których w pliku w ogóle nie było.
|
|
86
|
+
*/
|
|
87
|
+
function recoverScale(prefix, primitive) {
|
|
88
|
+
const stock = stockTheme().light;
|
|
89
|
+
const family = KPT_ALL_TOKENS.filter((name) => name.startsWith(prefix));
|
|
90
|
+
const given = family.filter((name) => primitive[name] !== undefined);
|
|
91
|
+
// Dwa stopnie to za mało, żeby mówić o skali — równie dobrze mogą być ręcznym nadpisaniem.
|
|
92
|
+
if (given.length < 3)
|
|
93
|
+
return null;
|
|
94
|
+
const pick = given.reduce((a, b) => ((magnitude(stock[b]) ?? 0) > (magnitude(stock[a]) ?? 0) ? b : a));
|
|
95
|
+
const base = magnitude(stock[pick]);
|
|
96
|
+
const got = magnitude(primitive[pick]);
|
|
97
|
+
if (!base || got === null)
|
|
98
|
+
return null;
|
|
99
|
+
const fits = (ratio) => family.every((name) => scaleLength(stock[name], ratio) === (primitive[name] ?? stock[name]));
|
|
100
|
+
/*
|
|
101
|
+
* Od zaokrąglenia najgrubszego do najdrobniejszego. Wartości w pliku są już przycięte do trzech
|
|
102
|
+
* miejsc, więc iloraz wychodzi z szumem: `2.344 / 1.875` to `1.25013`, a nie `1.25`. Obie liczby
|
|
103
|
+
* odtwarzają plik co do znaku, ale tylko jedna wygląda jak coś, co człowiek ustawił suwakiem.
|
|
104
|
+
*/
|
|
105
|
+
const raw = got / base;
|
|
106
|
+
for (const places of [1, 2, 3, 4]) {
|
|
107
|
+
const ratio = Number(raw.toFixed(places));
|
|
108
|
+
if (ratio !== 1 && fits(ratio))
|
|
109
|
+
return ratio;
|
|
110
|
+
}
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
// ── Tryby ────────────────────────────────────────────────────────────────────────────────────
|
|
114
|
+
const DARK_HINT = /(^|[^a-z])(dark|ciemn|night|nocn)/i;
|
|
115
|
+
const LIGHT_HINT = /(^|[^a-z])(light|jasn|day|dzien)/i;
|
|
116
|
+
/**
|
|
117
|
+
* Rozbicie treści jednego pliku na tryby po grupach najwyższego poziomu (`Light/`, `Dark/`).
|
|
118
|
+
* `null` znaczy „ten plik nie niesie obu trybów" — wtedy decyduje nazwa pliku.
|
|
119
|
+
*/
|
|
120
|
+
function groupSplit(content, named) {
|
|
121
|
+
if (!isRecord(content))
|
|
122
|
+
return null;
|
|
123
|
+
const groups = Object.keys(content).filter((key) => !key.startsWith('$'));
|
|
124
|
+
const light = named?.light ?? groups.find((key) => LIGHT_HINT.test(key));
|
|
125
|
+
const dark = named?.dark ?? groups.find((key) => DARK_HINT.test(key));
|
|
126
|
+
if (!light || !dark || light === dark)
|
|
127
|
+
return null;
|
|
128
|
+
if (content[light] === undefined || content[dark] === undefined)
|
|
129
|
+
return null;
|
|
130
|
+
return { light: content[light], dark: content[dark] };
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Rozbija wejście na tryb jasny i ciemny.
|
|
134
|
+
*
|
|
135
|
+
* DTCG **nie standaryzuje trybów**, więc nie ma tu jednej poprawnej odpowiedzi — są trzy kodowania,
|
|
136
|
+
* których używają eksportery, i każde trzeba rozpoznać osobno:
|
|
137
|
+
*
|
|
138
|
+
* 1. **osobne pliki** — `light.json` / `dark.json`;
|
|
139
|
+
* 2. **grupa najwyższego poziomu** — `{ "Light": {…}, "Dark": {…} }`;
|
|
140
|
+
* 3. **rozszerzenie** — tryby schowane w `$extensions` (tu tylko wykrywamy i ostrzegamy,
|
|
141
|
+
* bo kształt rozszerzenia jest własnością konkretnego narzędzia, nie formatu).
|
|
142
|
+
*
|
|
143
|
+
* Gdy żadne nie zadziała, plik idzie jako **jednotrybowy**: wszystko ląduje w jasnym, a ciemny
|
|
144
|
+
* zostaje stockowy. To jest uczciwsze niż wygenerowanie ciemnego z niczego i nazwanie go importem.
|
|
145
|
+
*/
|
|
146
|
+
function splitModes(sources, options, warnings) {
|
|
147
|
+
const out = { light: [], dark: [] };
|
|
148
|
+
const named = options.modes;
|
|
149
|
+
/*
|
|
150
|
+
* 1. Kilka plików.
|
|
151
|
+
*
|
|
152
|
+
* Nazwa pliku jest tu **ostatnią** przesłanką, nie pierwszą. Plik potrafi nieść oba tryby sam,
|
|
153
|
+
* jako grupy najwyższego poziomu, i wtedy nazwa nie znaczy nic — a wczytanie takiego pliku
|
|
154
|
+
* w całości jako „jasny" wsypuje do ścieżek przedrostek `light.`/`dark.` i psuje każde
|
|
155
|
+
* dopasowanie. Złapane na dwóch prawdziwych plikach z edytora: osobno działały, razem dawały
|
|
156
|
+
* zero trafień.
|
|
157
|
+
*
|
|
158
|
+
* Przy kilku plikach `options.modes` znaczy nazwy plików, więc do rozbicia po grupach idzie
|
|
159
|
+
* samo rozpoznanie automatyczne.
|
|
160
|
+
*/
|
|
161
|
+
if (sources.length > 1) {
|
|
162
|
+
for (const source of sources) {
|
|
163
|
+
const before = out.light.length + out.dark.length;
|
|
164
|
+
const split = groupSplit(source.content, undefined);
|
|
165
|
+
if (split) {
|
|
166
|
+
flatten(split.light, [], '', out.light);
|
|
167
|
+
flatten(split.dark, [], '', out.dark);
|
|
168
|
+
}
|
|
169
|
+
else {
|
|
170
|
+
const mode = pickMode(source.name, named);
|
|
171
|
+
if (!mode) {
|
|
172
|
+
warnings.push({ code: 'mode-guess', detail: `Nie wiadomo, którym trybem jest „${source.name}" — wczytano jako jasny.` });
|
|
173
|
+
}
|
|
174
|
+
flatten(source.content, [], '', out[mode ?? 'light']);
|
|
175
|
+
}
|
|
176
|
+
/*
|
|
177
|
+
* Plik, z którego nie wyszedł ani jeden token, ma się o tym dowiedzieć. W zestawie kilku
|
|
178
|
+
* plików reszta zasłania taki przypadek: raport pokazuje osiemdziesiąt trafień i wygląda
|
|
179
|
+
* dobrze, a jeden z plików przeszedł bez śladu. Zasada „nic nie ginie po cichu" dotyczy
|
|
180
|
+
* także całego pliku, nie tylko pojedynczego tokenu.
|
|
181
|
+
*/
|
|
182
|
+
if (out.light.length + out.dark.length === before) {
|
|
183
|
+
warnings.push({
|
|
184
|
+
code: 'empty',
|
|
185
|
+
detail: `Z pliku „${source.name}" nie wyszedł ani jeden token — żaden węzeł nie ma pola \`$value\` ani \`value\`.`,
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return out;
|
|
190
|
+
}
|
|
191
|
+
const only = sources[0];
|
|
192
|
+
if (!only || !isRecord(only.content))
|
|
193
|
+
return out;
|
|
194
|
+
// 2. Grupy najwyższego poziomu.
|
|
195
|
+
const split = groupSplit(only.content, named);
|
|
196
|
+
if (split) {
|
|
197
|
+
flatten(split.light, [], '', out.light);
|
|
198
|
+
flatten(split.dark, [], '', out.dark);
|
|
199
|
+
return out;
|
|
200
|
+
}
|
|
201
|
+
// 3. Rozszerzenie z trybami — rozpoznajemy, ale nie zgadujemy jego kształtu.
|
|
202
|
+
if (JSON.stringify(only.content).includes('$extensions')) {
|
|
203
|
+
warnings.push({
|
|
204
|
+
code: 'mode-guess',
|
|
205
|
+
detail: 'Plik ma pole `$extensions` — część eksporterów trzyma w nim tryby. Kształt tego pola ' +
|
|
206
|
+
'należy do narzędzia, nie do formatu, więc wczytano tylko warstwę podstawową. ' +
|
|
207
|
+
'Jeśli w pliku są dwa tryby, wskaż je ręcznie.',
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
flatten(only.content, [], '', out.light);
|
|
211
|
+
warnings.push({
|
|
212
|
+
code: 'no-modes',
|
|
213
|
+
detail: 'Plik jest jednotrybowy — motyw ciemny zostaje biblioteczny. Możesz go policzyć z ziarna.',
|
|
214
|
+
});
|
|
215
|
+
return out;
|
|
216
|
+
}
|
|
217
|
+
const pickMode = (name, named) => {
|
|
218
|
+
if (named?.dark && name.includes(named.dark))
|
|
219
|
+
return 'dark';
|
|
220
|
+
if (named?.light && name.includes(named.light))
|
|
221
|
+
return 'light';
|
|
222
|
+
if (DARK_HINT.test(name))
|
|
223
|
+
return 'dark';
|
|
224
|
+
if (LIGHT_HINT.test(name))
|
|
225
|
+
return 'light';
|
|
226
|
+
return null;
|
|
227
|
+
};
|
|
228
|
+
// ── Dopasowanie nazw ─────────────────────────────────────────────────────────────────────────
|
|
229
|
+
/**
|
|
230
|
+
* Tablica synonimów — ręczna i celowo ręczna.
|
|
231
|
+
*
|
|
232
|
+
* Nazwy ról w systemach projektowych są skończonym, powtarzalnym słownikiem: `accent`, `error`,
|
|
233
|
+
* `bg`, `fg`, `stroke`. Czterdzieści wpisów wpisanych raz trafia lepiej niż dowolna miara
|
|
234
|
+
* podobieństwa napisów, która na dodatek myli się w sposób nieprzewidywalny — a niedopasowanie
|
|
235
|
+
* trafia tu do raportu, więc koszt pomyłki jest widoczny, nie cichy.
|
|
236
|
+
*/
|
|
237
|
+
const SYNONYMS = {
|
|
238
|
+
accent: 'primary',
|
|
239
|
+
brand: 'primary',
|
|
240
|
+
main: 'primary',
|
|
241
|
+
error: 'danger',
|
|
242
|
+
negative: 'danger',
|
|
243
|
+
destructive: 'danger',
|
|
244
|
+
critical: 'danger',
|
|
245
|
+
positive: 'success',
|
|
246
|
+
ok: 'success',
|
|
247
|
+
confirm: 'success',
|
|
248
|
+
caution: 'warning',
|
|
249
|
+
warn: 'warning',
|
|
250
|
+
informational: 'info',
|
|
251
|
+
note: 'info',
|
|
252
|
+
bg: 'surface',
|
|
253
|
+
background: 'surface',
|
|
254
|
+
canvas: 'surface',
|
|
255
|
+
paper: 'surface',
|
|
256
|
+
fg: 'on-surface',
|
|
257
|
+
foreground: 'on-surface',
|
|
258
|
+
content: 'on-surface',
|
|
259
|
+
ink: 'on-surface',
|
|
260
|
+
stroke: 'border',
|
|
261
|
+
outline: 'border',
|
|
262
|
+
divider: 'border',
|
|
263
|
+
separator: 'border',
|
|
264
|
+
line: 'border',
|
|
265
|
+
hovered: 'hover',
|
|
266
|
+
pressed: 'active',
|
|
267
|
+
focus: 'focus-ring',
|
|
268
|
+
ring: 'focus-ring',
|
|
269
|
+
disabled: 'text-subtle',
|
|
270
|
+
placeholder: 'text-subtle',
|
|
271
|
+
subdued: 'muted',
|
|
272
|
+
faint: 'subtle',
|
|
273
|
+
weak: 'subtle',
|
|
274
|
+
raised: 'surface-raised',
|
|
275
|
+
elevated: 'surface-raised',
|
|
276
|
+
sunken: 'surface-sunken',
|
|
277
|
+
};
|
|
278
|
+
/** Nazwa ze ścieżki DTCG w kształcie, w którym da się ją porównać z nazwą roli. */
|
|
279
|
+
export function normalizeDtcgPath(path) {
|
|
280
|
+
return path
|
|
281
|
+
.replace(/[./\s_]+/g, '-')
|
|
282
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1-$2')
|
|
283
|
+
.toLowerCase()
|
|
284
|
+
.replace(/^(colou?r|kpt|token|global|semantic|theme)-/, '')
|
|
285
|
+
.replace(/-(default|base|value)$/, '')
|
|
286
|
+
.replace(/-{2,}/g, '-')
|
|
287
|
+
.replace(/^-|-$/g, '');
|
|
288
|
+
}
|
|
289
|
+
const applySynonyms = (name) => name
|
|
290
|
+
.split('-')
|
|
291
|
+
.map((word) => SYNONYMS[word] ?? word)
|
|
292
|
+
.join('-')
|
|
293
|
+
.replace(/-{2,}/g, '-');
|
|
294
|
+
/**
|
|
295
|
+
* Dwa indeksy celu, bo dopasowanie idzie dwoma różnymi drogami.
|
|
296
|
+
*
|
|
297
|
+
* `BY_NAME` to **wszystkie** tokeny biblioteki po nazwie bez prefiksu. Trafia w niego plik, który
|
|
298
|
+
* naszych nazw używa wprost — przede wszystkim nasz własny eksport DTCG, ale też każdy plik
|
|
299
|
+
* zbudowany na naszych tokenach. Dopasowanie dokładne nie zgaduje, więc objęcie nim całej
|
|
300
|
+
* biblioteki nic nie ryzykuje: obca nazwa po prostu w niego nie trafi.
|
|
301
|
+
*
|
|
302
|
+
* `BY_ROLE` to same role semantyczne koloru i tylko na nim wolno puścić synonimy — bo tylko tam
|
|
303
|
+
* zgadywanie ma sens. Nikt nie nazwie w swoim pliku tokenu `button-filled-bg` przypadkiem.
|
|
304
|
+
*/
|
|
305
|
+
const BY_NAME = new Map(KPT_ALL_TOKENS.map((name) => [name.replace('--kpt-', ''), name]));
|
|
306
|
+
const BY_ROLE = new Map(KPT_SEMANTIC_TOKENS.filter((name) => name.startsWith('--kpt-color-')).map((name) => [
|
|
307
|
+
name.replace('--kpt-color-', ''),
|
|
308
|
+
name,
|
|
309
|
+
]));
|
|
310
|
+
/** Ścieżka sprowadzona do jednego separatora i małych liter, bez obcinania członów. */
|
|
311
|
+
const flatName = (path) => path
|
|
312
|
+
.replace(/[./\s_]+/g, '-')
|
|
313
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1-$2')
|
|
314
|
+
.toLowerCase()
|
|
315
|
+
.replace(/-{2,}/g, '-')
|
|
316
|
+
.replace(/^-|-$/g, '');
|
|
317
|
+
/**
|
|
318
|
+
* Zwija powtórzone sąsiadujące człony: `surface-surface-raised` → `surface-raised`.
|
|
319
|
+
*
|
|
320
|
+
* Powtórzenie bierze się z podstawienia synonimu, którego cel sam jest wielowyrazowy. `elevated`
|
|
321
|
+
* znaczy `surface-raised`, więc `surface-elevated` rozwijało się do `surface-surface-raised`
|
|
322
|
+
* i nie trafiało w nic — tak samo `text-disabled` → `text-text-subtle`. To defekt mechanizmu,
|
|
323
|
+
* nie brak wpisu w tablicy: dotyczy każdego synonimu o wielowyrazowym celu.
|
|
324
|
+
*
|
|
325
|
+
* Bezpieczne, bo **żadna rola biblioteki nie powtarza sąsiadującego członu** — sprawdzane testem.
|
|
326
|
+
*/
|
|
327
|
+
const dedupe = (name) => name.split('-').filter((word, i, all) => word !== all[i - 1]).join('-');
|
|
328
|
+
/**
|
|
329
|
+
* Konwencje przyrostków `-bg` / `-fg` na rodzinie statusu.
|
|
330
|
+
*
|
|
331
|
+
* To nie jest synonim pojedynczego słowa, tylko **kształt nazwy**: `error-bg` znaczy „wypełnienie
|
|
332
|
+
* rodziny błędu", a `error-fg` „treść na tym wypełnieniu". Po podstawieniu synonimów wyglądają jak
|
|
333
|
+
* `danger-surface` i `danger-on-surface`, więc rozpoznajemy je tutaj, a nie w tablicy słów.
|
|
334
|
+
*
|
|
335
|
+
* Partner dla treści nie jest u nas jednolity: `primary`/`danger`/`muted` mają `on-*`, a pozostałe
|
|
336
|
+
* rodziny `*-contrast`. Próbujemy obu, zamiast zakładać jeden kształt.
|
|
337
|
+
*/
|
|
338
|
+
function conventionRole(name) {
|
|
339
|
+
const fill = /^(.+)-surface$/.exec(name);
|
|
340
|
+
if (fill && BY_ROLE.has(fill[1]))
|
|
341
|
+
return fill[1];
|
|
342
|
+
const on = /^(.+)-on-surface$/.exec(name);
|
|
343
|
+
if (on) {
|
|
344
|
+
for (const candidate of [`on-${on[1]}`, `${on[1]}-contrast`]) {
|
|
345
|
+
if (BY_ROLE.has(candidate))
|
|
346
|
+
return candidate;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
return null;
|
|
350
|
+
}
|
|
351
|
+
function matchTarget(path, map) {
|
|
352
|
+
const manual = map?.[path];
|
|
353
|
+
if (manual)
|
|
354
|
+
return { to: manual, confidence: 'manual' };
|
|
355
|
+
const byName = BY_NAME.get(flatName(path));
|
|
356
|
+
if (byName)
|
|
357
|
+
return { to: byName, confidence: 'exact' };
|
|
358
|
+
const role = normalizeDtcgPath(path);
|
|
359
|
+
const exact = BY_ROLE.get(role);
|
|
360
|
+
if (exact)
|
|
361
|
+
return { to: exact, confidence: 'exact' };
|
|
362
|
+
const swapped = applySynonyms(role);
|
|
363
|
+
const collapsed = dedupe(swapped);
|
|
364
|
+
const synonym = BY_ROLE.get(swapped) ?? BY_ROLE.get(collapsed) ?? BY_ROLE.get(conventionRole(collapsed) ?? '');
|
|
365
|
+
return synonym ? { to: synonym, confidence: 'synonym' } : null;
|
|
366
|
+
}
|
|
367
|
+
// ── Wartości ─────────────────────────────────────────────────────────────────────────────────
|
|
368
|
+
/**
|
|
369
|
+
* Kolor DTCG na zapis OKLCH.
|
|
370
|
+
*
|
|
371
|
+
* Zapisujemy w OKLCH, a nie w tym, co przyszło, bo paleta biblioteki jest w OKLCH i plik motywu
|
|
372
|
+
* ma się czytać razem z `tokens.css`, a nie obok niego. Hex jest ośmiobitowy, więc konwersja
|
|
373
|
+
* niczego nie gubi; zapis, którego nie umiemy sprowadzić do składowych, zostaje **bez zmiany** —
|
|
374
|
+
* lepiej przepuścić `rgb(…)` dalej niż zgubić token, bo nie mieliśmy na niego parsera.
|
|
375
|
+
*/
|
|
376
|
+
function colorValue(raw) {
|
|
377
|
+
if (typeof raw !== 'string')
|
|
378
|
+
return null;
|
|
379
|
+
const value = raw.trim();
|
|
380
|
+
if (!value)
|
|
381
|
+
return null;
|
|
382
|
+
/*
|
|
383
|
+
* Zapis `oklch()` przepuszczamy **verbatim**, tak jak robi to cały łańcuch tokenów.
|
|
384
|
+
*
|
|
385
|
+
* Przepuszczenie go przez `formatOklch` niczego nie poprawia, a psuje jedną rzecz: wyrównuje
|
|
386
|
+
* zapis do trzech miejsc po przecinku, więc `oklch(0.985 0 0)` wraca jako `oklch(0.985 0.000 0)`.
|
|
387
|
+
* Ten sam kolor, inny napis — a `themeDiff` porównuje napisy. Po obiegu przez DTCG kilkanaście
|
|
388
|
+
* tokenów udawałoby zmienione, licznik „zmienionych ról" kłamałby, a eksport niósłby linie,
|
|
389
|
+
* które niczego nie zmieniają. Normalizujemy wyłącznie to, co naprawdę trzeba przeliczyć.
|
|
390
|
+
*/
|
|
391
|
+
if (parseOklch(value))
|
|
392
|
+
return value;
|
|
393
|
+
const rgb = parseHexColor(value);
|
|
394
|
+
return rgb ? formatOklch(srgbToOklch(rgb)) : value;
|
|
395
|
+
}
|
|
396
|
+
/** Wymiar: nowszy zapis `{ value, unit }` i starszy `"16px"` — oba występują w realnych plikach. */
|
|
397
|
+
function dimensionValue(raw) {
|
|
398
|
+
if (typeof raw === 'number')
|
|
399
|
+
return `${raw}px`;
|
|
400
|
+
if (typeof raw === 'string')
|
|
401
|
+
return raw.trim() || null;
|
|
402
|
+
if (isRecord(raw) && typeof raw['value'] === 'number' && typeof raw['unit'] === 'string') {
|
|
403
|
+
return `${raw['value']}${raw['unit']}`;
|
|
404
|
+
}
|
|
405
|
+
return null;
|
|
406
|
+
}
|
|
407
|
+
const COMPOSITE = new Set(['typography', 'border', 'shadow', 'transition', 'gradient', 'strokeStyle']);
|
|
408
|
+
// ── Rampy ────────────────────────────────────────────────────────────────────────────────────
|
|
409
|
+
/** Stopień rampy poznajemy po tym, że ostatni człon ścieżki jest liczbą. */
|
|
410
|
+
const STEP = /^(\d{1,4})$/;
|
|
411
|
+
/**
|
|
412
|
+
* Grupy, które wyglądają na rampy barw: co najmniej pięć numerycznych stopni.
|
|
413
|
+
*
|
|
414
|
+
* Rampa jest w pliku najcenniejszą rzeczą, bo z niej bierze się **ziarno** — jedna liczba, z której
|
|
415
|
+
* generator odtwarza całą rodzinę ról. Dlatego szukamy jej osobno, zamiast zostawiać jej stopnie
|
|
416
|
+
* jako pięćdziesiąt niedopasowanych tokenów w raporcie.
|
|
417
|
+
*/
|
|
418
|
+
function findRamps(tokens) {
|
|
419
|
+
const groups = new Map();
|
|
420
|
+
for (const token of tokens) {
|
|
421
|
+
if (token.type !== 'color')
|
|
422
|
+
continue;
|
|
423
|
+
const parts = token.path.split('.');
|
|
424
|
+
const step = STEP.exec(parts[parts.length - 1] ?? '');
|
|
425
|
+
if (!step)
|
|
426
|
+
continue;
|
|
427
|
+
const value = colorValue(token.value);
|
|
428
|
+
if (!value)
|
|
429
|
+
continue;
|
|
430
|
+
const group = parts.slice(0, -1).join('.');
|
|
431
|
+
const steps = groups.get(group) ?? new Map();
|
|
432
|
+
steps.set(Number(step[1]), value);
|
|
433
|
+
groups.set(group, steps);
|
|
434
|
+
}
|
|
435
|
+
for (const [group, steps] of groups)
|
|
436
|
+
if (steps.size < 5)
|
|
437
|
+
groups.delete(group);
|
|
438
|
+
return groups;
|
|
439
|
+
}
|
|
440
|
+
const BRAND_RAMP = /(brand|primary|accent|main)/i;
|
|
441
|
+
/** Stopień najbliższy temu, na którym biblioteka trzyma bazę akcentu w motywie jasnym. */
|
|
442
|
+
const SEED_STEP = 600;
|
|
443
|
+
const seedFrom = (steps) => {
|
|
444
|
+
const keys = [...steps.keys()].sort((a, b) => Math.abs(a - SEED_STEP) - Math.abs(b - SEED_STEP));
|
|
445
|
+
return keys.length > 0 ? steps.get(keys[0]) : undefined;
|
|
446
|
+
};
|
|
447
|
+
// ── Wejście główne ───────────────────────────────────────────────────────────────────────────
|
|
448
|
+
/**
|
|
449
|
+
* Czyta pliki DTCG i buduje z nich dokument motywu.
|
|
450
|
+
*
|
|
451
|
+
* Zwraca **parę**: motyw i raport. Raport nie jest dodatkiem — to on decyduje, czy import da się
|
|
452
|
+
* przyjąć, i to on pokazuje, czego plik nie pokrył. Interfejs ma go wyświetlić, a nie schować.
|
|
453
|
+
*/
|
|
454
|
+
export function fromDtcg(sources, options = {}) {
|
|
455
|
+
const report = { matched: [], unmapped: [], missing: [], warnings: [], ramps: [] };
|
|
456
|
+
/*
|
|
457
|
+
* Wyjście bez wyniku też musi być uczciwe. Import, który niczego nie pokrył, zostawia **wszystkie**
|
|
458
|
+
* role na wartościach bibliotecznych — a pusta lista `missing` mówiła coś wprost przeciwnego:
|
|
459
|
+
* „ról bez pokrycia: 0". Raport jest tu całym produktem, więc nie ma prawa kłamać także wtedy,
|
|
460
|
+
* gdy nie ma o czym mówić.
|
|
461
|
+
*/
|
|
462
|
+
const nothing = () => {
|
|
463
|
+
report.missing = [...KPT_SEMANTIC_TOKENS];
|
|
464
|
+
return { theme: { kptTheme: 1 }, report };
|
|
465
|
+
};
|
|
466
|
+
if (sources.length === 0) {
|
|
467
|
+
report.warnings.push({ code: 'empty', detail: 'Nie podano żadnego pliku.' });
|
|
468
|
+
return nothing();
|
|
469
|
+
}
|
|
470
|
+
const byMode = splitModes(sources, options, report.warnings);
|
|
471
|
+
if (byMode.light.length === 0 && byMode.dark.length === 0) {
|
|
472
|
+
report.warnings.push({
|
|
473
|
+
code: 'empty',
|
|
474
|
+
// „Nie znaleziono tokenu" samo w sobie nie mówi, gdzie szukać winy. Najczęstszy powód jest
|
|
475
|
+
// banalny: to po prostu nie jest plik DTCG.
|
|
476
|
+
detail: 'W plikach nie znaleziono żadnego tokenu — żaden węzeł nie ma pola `$value` ani `value`. ' +
|
|
477
|
+
'Najpewniej to nie jest eksport DTCG.',
|
|
478
|
+
});
|
|
479
|
+
return nothing();
|
|
480
|
+
}
|
|
481
|
+
if (sources.some((s) => JSON.stringify(s.content).includes('"value"') && !JSON.stringify(s.content).includes('"$value"'))) {
|
|
482
|
+
report.warnings.push({
|
|
483
|
+
code: 'legacy-format',
|
|
484
|
+
detail: 'Plik używa starszego zapisu `value`/`type` zamiast `$value`/`$type`. Wczytany, ale warto odświeżyć eksport.',
|
|
485
|
+
});
|
|
486
|
+
}
|
|
487
|
+
const semantic = { light: {}, dark: {} };
|
|
488
|
+
const primitive = {};
|
|
489
|
+
const importMap = { ...(options.map ?? {}) };
|
|
490
|
+
let seed;
|
|
491
|
+
for (const mode of ['light', 'dark']) {
|
|
492
|
+
const tokens = byMode[mode];
|
|
493
|
+
if (tokens.length === 0)
|
|
494
|
+
continue;
|
|
495
|
+
const byPath = new Map(tokens.map((token) => [token.path, token]));
|
|
496
|
+
const ramps = findRamps(tokens);
|
|
497
|
+
const rampPaths = new Set([...ramps.keys()]);
|
|
498
|
+
if (mode === 'light') {
|
|
499
|
+
for (const [path, steps] of ramps) {
|
|
500
|
+
const entry = { path, steps: steps.size, seed: BRAND_RAMP.test(path) ? seedFrom(steps) : undefined };
|
|
501
|
+
report.ramps.push(entry);
|
|
502
|
+
if (entry.seed && !seed)
|
|
503
|
+
seed = entry.seed;
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
for (const token of tokens) {
|
|
507
|
+
// Stopnie rozpoznanej rampy nie są niedopasowaniem — obsłużyliśmy je jako rampę.
|
|
508
|
+
if (rampPaths.has(token.path.split('.').slice(0, -1).join('.')))
|
|
509
|
+
continue;
|
|
510
|
+
if (COMPOSITE.has(token.type)) {
|
|
511
|
+
report.unmapped.push({ path: token.path, type: token.type, value: '—', reason: 'composite' });
|
|
512
|
+
continue;
|
|
513
|
+
}
|
|
514
|
+
const resolved = resolveAlias(token.value, byPath, new Set());
|
|
515
|
+
if (resolved === null) {
|
|
516
|
+
report.warnings.push({ code: 'alias-broken', detail: `„${token.path}" wskazuje na token, którego w pliku nie ma.` });
|
|
517
|
+
report.unmapped.push({ path: token.path, type: token.type, value: String(token.value), reason: 'unreadable-value' });
|
|
518
|
+
continue;
|
|
519
|
+
}
|
|
520
|
+
const target = matchTarget(token.path, options.map);
|
|
521
|
+
if (!target) {
|
|
522
|
+
report.unmapped.push({
|
|
523
|
+
path: token.path,
|
|
524
|
+
type: token.type,
|
|
525
|
+
value: String(dimensionValue(resolved) ?? colorValue(resolved) ?? resolved),
|
|
526
|
+
reason: token.type === 'color' || token.type === 'dimension' ? 'no-target' : 'unsupported-type',
|
|
527
|
+
});
|
|
528
|
+
continue;
|
|
529
|
+
}
|
|
530
|
+
const value = token.type === 'color' ? colorValue(resolved) : dimensionValue(resolved);
|
|
531
|
+
if (!value) {
|
|
532
|
+
report.unmapped.push({ path: token.path, type: token.type, value: String(resolved), reason: 'unreadable-value' });
|
|
533
|
+
continue;
|
|
534
|
+
}
|
|
535
|
+
/*
|
|
536
|
+
* Warstwa decyduje, gdzie wartość ląduje — i czy w ogóle.
|
|
537
|
+
*
|
|
538
|
+
* Semantyka idzie osobno w każdym motywie. Prymitywy są wspólne dla obu, bo tak są
|
|
539
|
+
* zdefiniowane w bibliotece; różne wartości w dwóch trybach to konflikt, o którym trzeba
|
|
540
|
+
* powiedzieć, a nie po cichu wziąć drugiej. Tokeny komponentowe **odrzucamy świadomie**:
|
|
541
|
+
* podążają za semantyką przez `var()`, więc zaimportowanie ich jako nadpisań zamroziłoby je
|
|
542
|
+
* na dzisiejszej wartości i późniejsza zmiana koloru wiodącego przestałaby ruszać przycisk.
|
|
543
|
+
*/
|
|
544
|
+
if (isSemanticToken(target.to)) {
|
|
545
|
+
semantic[mode][target.to] = value;
|
|
546
|
+
}
|
|
547
|
+
else if (isPrimitiveToken(target.to)) {
|
|
548
|
+
const clash = primitive[target.to];
|
|
549
|
+
if (clash !== undefined && clash !== value) {
|
|
550
|
+
report.warnings.push({
|
|
551
|
+
code: 'mode-guess',
|
|
552
|
+
detail: `Prymityw „${target.to}" ma inną wartość w każdym trybie; prymitywy są wspólne, więc została pierwsza.`,
|
|
553
|
+
});
|
|
554
|
+
}
|
|
555
|
+
else {
|
|
556
|
+
primitive[target.to] = value;
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
else {
|
|
560
|
+
report.unmapped.push({ path: token.path, type: token.type, value, reason: 'derived' });
|
|
561
|
+
continue;
|
|
562
|
+
}
|
|
563
|
+
importMap[token.path] = target.to;
|
|
564
|
+
report.matched.push({ from: token.path, to: target.to, mode, confidence: target.confidence });
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
const covered = new Set(report.matched.map((m) => m.to));
|
|
568
|
+
report.missing = KPT_SEMANTIC_TOKENS.filter((name) => !covered.has(name));
|
|
569
|
+
const theme = { kptTheme: 1, meta: { name: 'Import DTCG', importMap } };
|
|
570
|
+
const recipe = {};
|
|
571
|
+
if (seed)
|
|
572
|
+
recipe.color = { seed: { primary: seed } };
|
|
573
|
+
/*
|
|
574
|
+
* Skale wracają do przepisu, a prymitywy, z których je odczytaliśmy, wypadają z nadpisań.
|
|
575
|
+
* Zostawienie obu naraz byłoby gorsze niż każde z osobna: nadpisania wchodzą **po** przepisie,
|
|
576
|
+
* więc przykryłyby go i suwak dalej byłby martwy — tyle że teraz wyglądałby na ustawiony.
|
|
577
|
+
*/
|
|
578
|
+
const radiusScale = recoverScale('--kpt-radius-', primitive);
|
|
579
|
+
if (radiusScale !== null)
|
|
580
|
+
recipe.radius = { scale: radiusScale };
|
|
581
|
+
const spacingScale = recoverScale('--kpt-space-', primitive);
|
|
582
|
+
if (spacingScale !== null)
|
|
583
|
+
recipe.spacing = { scale: spacingScale };
|
|
584
|
+
const sizeScale = recoverScale('--kpt-font-size-', primitive);
|
|
585
|
+
const sans = primitive['--kpt-font-family-sans'];
|
|
586
|
+
const mono = primitive['--kpt-font-family-mono'];
|
|
587
|
+
if (sizeScale !== null || sans || mono) {
|
|
588
|
+
recipe.typography = {};
|
|
589
|
+
if (sizeScale !== null)
|
|
590
|
+
recipe.typography.sizeScale = sizeScale;
|
|
591
|
+
if (sans)
|
|
592
|
+
recipe.typography.sans = sans;
|
|
593
|
+
if (mono)
|
|
594
|
+
recipe.typography.mono = mono;
|
|
595
|
+
}
|
|
596
|
+
const recovered = [
|
|
597
|
+
...(radiusScale !== null ? ['--kpt-radius-'] : []),
|
|
598
|
+
...(spacingScale !== null ? ['--kpt-space-'] : []),
|
|
599
|
+
...(sizeScale !== null ? ['--kpt-font-size-'] : []),
|
|
600
|
+
];
|
|
601
|
+
for (const name of Object.keys(primitive)) {
|
|
602
|
+
if (recovered.some((prefix) => name.startsWith(prefix)))
|
|
603
|
+
delete primitive[name];
|
|
604
|
+
}
|
|
605
|
+
if (sans)
|
|
606
|
+
delete primitive['--kpt-font-family-sans'];
|
|
607
|
+
if (mono)
|
|
608
|
+
delete primitive['--kpt-font-family-mono'];
|
|
609
|
+
if (Object.keys(recipe).length > 0)
|
|
610
|
+
theme.recipe = recipe;
|
|
611
|
+
const overrides = {};
|
|
612
|
+
if (Object.keys(primitive).length > 0)
|
|
613
|
+
overrides.primitive = primitive;
|
|
614
|
+
if (Object.keys(semantic.light).length > 0 || Object.keys(semantic.dark).length > 0) {
|
|
615
|
+
overrides.semantic = {};
|
|
616
|
+
if (Object.keys(semantic.light).length > 0)
|
|
617
|
+
overrides.semantic.light = semantic.light;
|
|
618
|
+
if (Object.keys(semantic.dark).length > 0)
|
|
619
|
+
overrides.semantic.dark = semantic.dark;
|
|
620
|
+
}
|
|
621
|
+
if (Object.keys(overrides).length > 0)
|
|
622
|
+
theme.overrides = overrides;
|
|
623
|
+
return { theme, report };
|
|
624
|
+
}
|