sparkle-design-cli 2.5.0-beta.3 → 2.5.0-beta.4
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 +18 -0
- package/bin/sparkle-design.js +73 -0
- package/docs/anti-patterns.md +21 -0
- package/lib/anti-pattern-rules.js +344 -3
- package/lib/check.js +2 -0
- package/lib/migrate.js +460 -0
- package/lib/token-migration.js +3 -1
- package/package.json +1 -1
- package/templates/sparkle-variables/README.md +6 -2
- package/templates/sparkle-variables/scripts/validate-tokens.mjs +161 -0
- package/templates/sparkle-variables/sparkle-design.template.css +3 -25
package/lib/migrate.js
ADDED
|
@@ -0,0 +1,460 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
|
|
4
|
+
import { collectDeclaredCustomProperties, maskBlockComments } from './anti-pattern-rules.js';
|
|
5
|
+
import { DEFAULT_TARGET, collectFiles, isSuppressed } from './check.js';
|
|
6
|
+
import {
|
|
7
|
+
MIGRATION_CLASS,
|
|
8
|
+
buildDeprecatedAliasPattern,
|
|
9
|
+
buildLegacyCssVarPattern,
|
|
10
|
+
buildLegacyScalePattern,
|
|
11
|
+
buildLegacyTokenPattern,
|
|
12
|
+
resolveLegacyCssVar,
|
|
13
|
+
resolveLegacyUtility,
|
|
14
|
+
} from './token-migration.js';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* `migrate` サブコマンド本体(goodpatch/sparkle-design-cli#73)。
|
|
18
|
+
*
|
|
19
|
+
* 旧セマンティックトークンを新トークンへ書き換える。対応表は持たず、
|
|
20
|
+
* `lib/token-migration.js` の検出パターンと resolver をそのまま使う
|
|
21
|
+
* (`check` の移行ルールと同じ入口なので、check が案内する移行先と
|
|
22
|
+
* migrate が書き換える移行先が食い違うことがない)。
|
|
23
|
+
*
|
|
24
|
+
* 分類ごとの扱い:
|
|
25
|
+
* - `auto` … `--write` のときだけ置換する
|
|
26
|
+
* - `review` … 候補を列挙して報告するだけ。置換しない
|
|
27
|
+
* - `structural` … 該当箇所を指摘するだけ。置換しない
|
|
28
|
+
*
|
|
29
|
+
* 既定は dry-run(不可逆操作をデフォルトにしない)。
|
|
30
|
+
* en: The `migrate` subcommand. Reuses token-migration.js patterns/resolvers
|
|
31
|
+
* (no mapping table of its own). Only `auto` hits are rewritten, and only with
|
|
32
|
+
* `--write`; `review` / `structural` are reported and never touched.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* 検出パターン × resolver の組。`ruleId` は対応する `check` のルール ID で、
|
|
37
|
+
* `sparkle-disable-line <ruleId>` の抑制コメントを migrate でも尊重するために使う
|
|
38
|
+
* (check の誤検出を抑制した箇所を migrate が黙って書き換えると、抑制した意味が無くなる)。
|
|
39
|
+
* en: Pattern/resolver pairs. `ruleId` is the matching `check` rule so that
|
|
40
|
+
* suppression comments written for `check` are honoured by `migrate` too.
|
|
41
|
+
*/
|
|
42
|
+
export const MIGRATION_SOURCES = [
|
|
43
|
+
{
|
|
44
|
+
ruleId: 'legacy-color-token',
|
|
45
|
+
pattern: buildLegacyScalePattern(),
|
|
46
|
+
resolve: resolveLegacyUtility,
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
ruleId: 'legacy-color-token',
|
|
50
|
+
pattern: buildLegacyTokenPattern(),
|
|
51
|
+
resolve: resolveLegacyUtility,
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
ruleId: 'legacy-color-var',
|
|
55
|
+
pattern: buildLegacyCssVarPattern(),
|
|
56
|
+
resolve: resolveLegacyCssVar,
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
ruleId: 'deprecated-radius-alias',
|
|
60
|
+
pattern: buildDeprecatedAliasPattern(),
|
|
61
|
+
resolve: resolveLegacyCssVar,
|
|
62
|
+
},
|
|
63
|
+
];
|
|
64
|
+
|
|
65
|
+
const CLASSIFICATION_LABELS = {
|
|
66
|
+
[MIGRATION_CLASS.AUTO]: '自動変換可',
|
|
67
|
+
[MIGRATION_CLASS.REVIEW]: '要判断',
|
|
68
|
+
[MIGRATION_CLASS.STRUCTURAL]: '構造変更あり',
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
function lineNumberAt(content, index) {
|
|
72
|
+
return content.slice(0, index).split(/\r?\n/).length;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* resolver の戻り値を migrate の分類に正規化する。
|
|
77
|
+
*
|
|
78
|
+
* 書き換えてよいのは「`auto` かつ置換先が文字列で得られている」ものだけ。
|
|
79
|
+
* それ以外(未知の分類・置換先が無い auto・解決できなかった hit)は、黙って
|
|
80
|
+
* 捨てずに `review` として表に出す。check の migrationMatcher と同じ fail-loud 方針。
|
|
81
|
+
* en: Only `auto` with a string replacement may be rewritten. Anything else is
|
|
82
|
+
* surfaced as `review` rather than dropped — same fail-loud policy as `check`.
|
|
83
|
+
*/
|
|
84
|
+
function normalizeResolution(text, resolved) {
|
|
85
|
+
if (!resolved) {
|
|
86
|
+
return {
|
|
87
|
+
classification: MIGRATION_CLASS.REVIEW,
|
|
88
|
+
reason:
|
|
89
|
+
'旧トークンとして検出しましたが移行先を解決できませんでした。' +
|
|
90
|
+
'sparkle-design-cli の検出パターンと対応表がずれている可能性があります(issue 報告をお願いします)。',
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
const { classification, replacement, candidates, reason } = resolved;
|
|
94
|
+
if (classification === MIGRATION_CLASS.AUTO) {
|
|
95
|
+
if (typeof replacement === 'string' && replacement.length > 0 && replacement !== text) {
|
|
96
|
+
return { classification, replacement, reason };
|
|
97
|
+
}
|
|
98
|
+
return {
|
|
99
|
+
classification: MIGRATION_CLASS.REVIEW,
|
|
100
|
+
candidates,
|
|
101
|
+
reason: reason ?? '自動変換可と判定されましたが置換先が得られませんでした。',
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
if (classification === MIGRATION_CLASS.STRUCTURAL) {
|
|
105
|
+
return { classification, candidates, replacement, reason };
|
|
106
|
+
}
|
|
107
|
+
return { classification: MIGRATION_CLASS.REVIEW, candidates, replacement, reason };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* マッチ位置がクラス名ではなく、パス・import specifier・`url()` の一部に見えるか。
|
|
112
|
+
*
|
|
113
|
+
* 検出パターンはファイル全体への正規表現なので、`url('/img/fill-primary-600.svg')`
|
|
114
|
+
* や `import './bg-primary-600.js'` のようなクラス以外の文字列にもマッチする。
|
|
115
|
+
* check では warning として報告するだけなので害は小さいが、migrate の `--write` で
|
|
116
|
+
* 置換すると資産パスや import が壊れる。書き換えを「クラス名として現れているもの」
|
|
117
|
+
* に限定するための、migrate だけの安全側ガード(check の検出挙動は変えない)。
|
|
118
|
+
* 該当したものは黙って捨てず、review として報告に残す。
|
|
119
|
+
* en: Detection is a whole-file regex, so it also hits paths, import specifiers
|
|
120
|
+
* and url() contents. Rewriting those with --write would break assets/imports,
|
|
121
|
+
* so migrate demotes such hits to `review` (check's detection is unchanged).
|
|
122
|
+
*
|
|
123
|
+
* @returns {string | null} 該当する場合はその理由
|
|
124
|
+
*/
|
|
125
|
+
function nonClassContext(content, index, end) {
|
|
126
|
+
const before = content[index - 1] ?? '';
|
|
127
|
+
const after = content[end] ?? '';
|
|
128
|
+
const afterNext = content[end + 1] ?? '';
|
|
129
|
+
if (before === '/' || before === '.' || before === '\\') {
|
|
130
|
+
return 'パスや識別子の一部に見えます';
|
|
131
|
+
}
|
|
132
|
+
if ((after === '.' && /\w/.test(afterNext)) || after === '/' || after === '\\') {
|
|
133
|
+
return 'ファイルパスや識別子の一部に見えます';
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const lineStart = content.lastIndexOf('\n', index - 1) + 1;
|
|
137
|
+
const lineBefore = content.slice(lineStart, index);
|
|
138
|
+
|
|
139
|
+
// `url(` が開いたまま閉じていない
|
|
140
|
+
const urlOpen = lineBefore.toLowerCase().lastIndexOf('url(');
|
|
141
|
+
if (urlOpen !== -1 && !lineBefore.slice(urlOpen).includes(')')) {
|
|
142
|
+
return '`url()` の中にあります';
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// 囲んでいる文字列リテラルが import / require / @import の specifier か
|
|
146
|
+
let quoteIndex = -1;
|
|
147
|
+
for (let i = lineBefore.length - 1; i >= 0; i -= 1) {
|
|
148
|
+
if (lineBefore[i] === "'" || lineBefore[i] === '"' || lineBefore[i] === '`') {
|
|
149
|
+
quoteIndex = i;
|
|
150
|
+
break;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
if (quoteIndex !== -1) {
|
|
154
|
+
const lead = lineBefore.slice(0, quoteIndex);
|
|
155
|
+
if (/(?:\bfrom|\bimport|@import|\brequire\s*\(|\bimport\s*\()\s*$/.test(lead)) {
|
|
156
|
+
return 'import / require の specifier の中にあります';
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* マッチに付いた modifier(`/…`)が Tailwind の opacity modifier らしい形か。
|
|
164
|
+
*
|
|
165
|
+
* token-migration.js の MODIFIER_BODY は `[A-Za-z0-9._%-]+` を許すため、
|
|
166
|
+
* `"text-neutral-900/data.json"` や `"bg-primary-600/icon.svg"` のようなパスの
|
|
167
|
+
* 先頭セグメントでは `/data.json` まで modifier として飲み込み、直後が `"` に
|
|
168
|
+
* なって nonClassContext のパス判定を抜ける。migrate では数値(`/50` `/12.5`)と
|
|
169
|
+
* 角括弧(`/[0.6]` `/[var(--x)]`)以外の modifier を review に落とす。
|
|
170
|
+
* check の検出には影響しない。
|
|
171
|
+
* en: The shared modifier body also swallows path tails like `/data.json`.
|
|
172
|
+
* Only numeric or bracketed modifiers are trusted for auto rewrites.
|
|
173
|
+
*
|
|
174
|
+
* @returns {string | null} 該当する場合はその理由
|
|
175
|
+
*/
|
|
176
|
+
function nonClassModifier(text) {
|
|
177
|
+
if (text.startsWith('--')) return null;
|
|
178
|
+
const bare = text.slice(text.lastIndexOf(':') + 1);
|
|
179
|
+
const slash = bare.indexOf('/');
|
|
180
|
+
if (slash === -1) return null;
|
|
181
|
+
const modifier = bare.slice(slash + 1).replace(/!$/, '');
|
|
182
|
+
if (/^\d+(?:\.\d+)?$/.test(modifier) || /^\[[^\]\s]*\]$/.test(modifier)) return null;
|
|
183
|
+
return `\`/${modifier}\` は opacity modifier ではなくパスの一部に見えます`;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* 1 ファイル分の移行計画を立てる(ファイルには触らない)。
|
|
188
|
+
*
|
|
189
|
+
* - 行頭から始まるブロックコメントの中身は対象外(check と同じ)
|
|
190
|
+
* - そのファイル自身が宣言している CSS 変数への参照は対象外(生成物の定義側を書き換えない)
|
|
191
|
+
* - `sparkle-disable-line` / `sparkle-disable-next-line` で抑制された箇所は対象外
|
|
192
|
+
* - 複数パターンが同じ範囲に重なった場合は先に見つかった方だけを採る
|
|
193
|
+
* - パス・import specifier・`url()` の中の auto は review に落とす(`nonClassContext`)
|
|
194
|
+
*
|
|
195
|
+
* @param {string} content
|
|
196
|
+
* @param {{ sources?: typeof MIGRATION_SOURCES }} [options]
|
|
197
|
+
* @returns {Array<{ index: number, end: number, line: number, text: string, ruleId: string,
|
|
198
|
+
* classification: string, replacement?: string, candidates?: string[], reason?: string }>}
|
|
199
|
+
*/
|
|
200
|
+
export function planFileMigration(content, options = {}) {
|
|
201
|
+
const sources = options.sources ?? MIGRATION_SOURCES;
|
|
202
|
+
const masked = maskBlockComments(content);
|
|
203
|
+
const declaredHere = collectDeclaredCustomProperties(masked);
|
|
204
|
+
const contentLines = content.split(/\r?\n/);
|
|
205
|
+
|
|
206
|
+
const items = [];
|
|
207
|
+
for (const source of sources) {
|
|
208
|
+
for (const match of masked.matchAll(source.pattern)) {
|
|
209
|
+
const text = match[0];
|
|
210
|
+
const index = match.index ?? 0;
|
|
211
|
+
if (text.startsWith('--') && declaredHere.has(text)) continue;
|
|
212
|
+
const line = lineNumberAt(content, index);
|
|
213
|
+
if (isSuppressed(source.ruleId, contentLines, line)) continue;
|
|
214
|
+
const end = index + text.length;
|
|
215
|
+
let resolution = normalizeResolution(text, source.resolve(text));
|
|
216
|
+
if (resolution.classification === MIGRATION_CLASS.AUTO) {
|
|
217
|
+
const context = nonClassModifier(text) ?? nonClassContext(content, index, end);
|
|
218
|
+
if (context) {
|
|
219
|
+
resolution = {
|
|
220
|
+
classification: MIGRATION_CLASS.REVIEW,
|
|
221
|
+
candidates: [resolution.replacement],
|
|
222
|
+
reason:
|
|
223
|
+
`${context}。クラス名ではない可能性があるため自動変換しません。` +
|
|
224
|
+
'クラス名として使っている箇所なら手で置き換えてください。',
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
items.push({ index, end, line, text, ruleId: source.ruleId, ...resolution });
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
items.sort((left, right) => left.index - right.index);
|
|
233
|
+
const accepted = [];
|
|
234
|
+
for (const item of items) {
|
|
235
|
+
const previous = accepted[accepted.length - 1];
|
|
236
|
+
if (previous && item.index < previous.end) continue;
|
|
237
|
+
accepted.push(item);
|
|
238
|
+
}
|
|
239
|
+
return accepted;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* 計画のうち `auto` だけを適用した内容を返す。`review` / `structural` は触らない。
|
|
244
|
+
* 置換先は改行を含まないので、行番号は変わらない。
|
|
245
|
+
* en: Apply only `auto` items. Replacements never contain newlines, so line
|
|
246
|
+
* numbers are preserved.
|
|
247
|
+
*/
|
|
248
|
+
export function applyFileMigration(content, items) {
|
|
249
|
+
let output = content;
|
|
250
|
+
const autos = items
|
|
251
|
+
.filter((item) => item.classification === MIGRATION_CLASS.AUTO)
|
|
252
|
+
.sort((left, right) => right.index - left.index);
|
|
253
|
+
for (const item of autos) {
|
|
254
|
+
output = output.slice(0, item.index) + item.replacement + output.slice(item.end);
|
|
255
|
+
}
|
|
256
|
+
return output;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* `--include` の glob を正規表現に変換する。対応する記法は `**` / `*` / `?` / `{a,b}`。
|
|
261
|
+
* Node の `path.matchesGlob` はバージョンによって使えないため自前で持つ。
|
|
262
|
+
* en: Minimal glob support (`**`, `*`, `?`, `{a,b}`) — `path.matchesGlob`
|
|
263
|
+
* is not available on every supported Node version.
|
|
264
|
+
*/
|
|
265
|
+
export function globToRegExp(glob) {
|
|
266
|
+
let pattern = '';
|
|
267
|
+
let braceDepth = 0;
|
|
268
|
+
const normalized = glob.replace(/\\/g, '/').replace(/^\.\//, '');
|
|
269
|
+
for (let i = 0; i < normalized.length; i += 1) {
|
|
270
|
+
const char = normalized[i];
|
|
271
|
+
if (char === '*') {
|
|
272
|
+
if (normalized[i + 1] === '*') {
|
|
273
|
+
const followedBySlash = normalized[i + 2] === '/';
|
|
274
|
+
pattern += followedBySlash ? '(?:.*/)?' : '.*';
|
|
275
|
+
i += followedBySlash ? 2 : 1;
|
|
276
|
+
} else {
|
|
277
|
+
pattern += '[^/]*';
|
|
278
|
+
}
|
|
279
|
+
} else if (char === '?') {
|
|
280
|
+
pattern += '[^/]';
|
|
281
|
+
} else if (char === '{') {
|
|
282
|
+
braceDepth += 1;
|
|
283
|
+
pattern += '(?:';
|
|
284
|
+
} else if (char === '}' && braceDepth > 0) {
|
|
285
|
+
braceDepth -= 1;
|
|
286
|
+
pattern += ')';
|
|
287
|
+
} else if (char === ',' && braceDepth > 0) {
|
|
288
|
+
pattern += '|';
|
|
289
|
+
} else {
|
|
290
|
+
pattern += char.replace(/[.+^$()|[\]\\]/g, '\\$&');
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
if (braceDepth > 0) {
|
|
294
|
+
throw new Error(`--include の { が閉じていません: ${glob}`);
|
|
295
|
+
}
|
|
296
|
+
return new RegExp(`^${pattern}$`);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// cwd の外を指定したときに `../../../..` が延々と並ぶと読めないので、
|
|
300
|
+
// その場合だけ絶対パスで出す。
|
|
301
|
+
// en: Fall back to the absolute path for targets outside cwd.
|
|
302
|
+
function toReportPath(filePath, cwd) {
|
|
303
|
+
const relative = path.relative(cwd, filePath);
|
|
304
|
+
const shown = relative.startsWith('..') || path.isAbsolute(relative) ? filePath : relative;
|
|
305
|
+
return shown.split(path.sep).join('/');
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* 対象ファイルを集める。探索は `check` と同じ `collectFiles` を使う(既定 `src`、
|
|
310
|
+
* `node_modules` / `.git` は除外、`.js` `.jsx` `.ts` `.tsx` `.css`)。
|
|
311
|
+
* `--include` があれば、cwd からの相対パスがどれかの glob にマッチするものだけに絞る。
|
|
312
|
+
*/
|
|
313
|
+
function discoverFiles(targets, includes, cwd) {
|
|
314
|
+
const resolvedTargets = targets.length > 0 ? targets : [DEFAULT_TARGET];
|
|
315
|
+
const textFiles = new Set();
|
|
316
|
+
const cssFiles = new Set();
|
|
317
|
+
const visited = new Set();
|
|
318
|
+
for (const target of resolvedTargets) {
|
|
319
|
+
collectFiles(path.resolve(cwd, target), textFiles, cssFiles, visited, true);
|
|
320
|
+
}
|
|
321
|
+
const matchers = includes.map(globToRegExp);
|
|
322
|
+
const allFiles = [...textFiles, ...cssFiles];
|
|
323
|
+
const files = allFiles.filter(
|
|
324
|
+
(filePath) =>
|
|
325
|
+
matchers.length === 0 || matchers.some((re) => re.test(toReportPath(filePath, cwd)))
|
|
326
|
+
);
|
|
327
|
+
if (matchers.length > 0 && allFiles.length > 0 && files.length === 0) {
|
|
328
|
+
// 探索したファイルが全部 --include で落ちた = 照合の基準を取り違えている可能性が高い
|
|
329
|
+
// en: Every discovered file was filtered out — most likely a cwd-relative mismatch.
|
|
330
|
+
console.warn(
|
|
331
|
+
`⚠️ --include にマッチするファイルがありません(探索したファイル: ${allFiles.length})。` +
|
|
332
|
+
'--include は cwd からの相対パスで照合します(例: src/**/*.tsx)。'
|
|
333
|
+
);
|
|
334
|
+
}
|
|
335
|
+
return files.sort((left, right) =>
|
|
336
|
+
toReportPath(left, cwd).localeCompare(toReportPath(right, cwd))
|
|
337
|
+
);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* 移行レポートを作る。`write: true` のときだけ auto を書き換える。
|
|
342
|
+
*
|
|
343
|
+
* @param {{ targets?: string[], includes?: string[], write?: boolean, cwd?: string }} options
|
|
344
|
+
*/
|
|
345
|
+
export function createMigrationReport(options = {}) {
|
|
346
|
+
const cwd = options.cwd ?? process.cwd();
|
|
347
|
+
const files = discoverFiles(options.targets ?? [], options.includes ?? [], cwd);
|
|
348
|
+
const write = options.write === true;
|
|
349
|
+
|
|
350
|
+
const results = [];
|
|
351
|
+
for (const filePath of files) {
|
|
352
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
353
|
+
const items = planFileMigration(content);
|
|
354
|
+
if (items.length === 0) continue;
|
|
355
|
+
const migrated = applyFileMigration(content, items);
|
|
356
|
+
const changed = migrated !== content;
|
|
357
|
+
if (write && changed) {
|
|
358
|
+
fs.writeFileSync(filePath, migrated, 'utf8');
|
|
359
|
+
}
|
|
360
|
+
results.push({
|
|
361
|
+
filePath: toReportPath(filePath, cwd),
|
|
362
|
+
items,
|
|
363
|
+
diff: changed ? lineDiff(content, migrated) : [],
|
|
364
|
+
written: write && changed,
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
const count = (classification) =>
|
|
369
|
+
results.reduce(
|
|
370
|
+
(sum, result) =>
|
|
371
|
+
sum + result.items.filter((item) => item.classification === classification).length,
|
|
372
|
+
0
|
|
373
|
+
);
|
|
374
|
+
|
|
375
|
+
return {
|
|
376
|
+
write,
|
|
377
|
+
scannedFileCount: files.length,
|
|
378
|
+
results,
|
|
379
|
+
summary: {
|
|
380
|
+
auto: count(MIGRATION_CLASS.AUTO),
|
|
381
|
+
review: count(MIGRATION_CLASS.REVIEW),
|
|
382
|
+
structural: count(MIGRATION_CLASS.STRUCTURAL),
|
|
383
|
+
autoFileCount: results.filter((result) => result.diff.length > 0).length,
|
|
384
|
+
writtenFileCount: results.filter((result) => result.written).length,
|
|
385
|
+
},
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function lineDiff(before, after) {
|
|
390
|
+
const beforeLines = before.split(/\r?\n/);
|
|
391
|
+
const afterLines = after.split(/\r?\n/);
|
|
392
|
+
const diff = [];
|
|
393
|
+
for (let i = 0; i < beforeLines.length; i += 1) {
|
|
394
|
+
if (beforeLines[i] !== afterLines[i]) {
|
|
395
|
+
diff.push({ line: i + 1, before: beforeLines[i], after: afterLines[i] });
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
return diff;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function describeItem(item) {
|
|
402
|
+
const label = CLASSIFICATION_LABELS[item.classification] ?? item.classification;
|
|
403
|
+
const parts = [`[${label}] ${item.line}: ${item.text}`];
|
|
404
|
+
if (item.candidates?.length) {
|
|
405
|
+
parts.push(`候補: ${item.candidates.join(' / ')}`);
|
|
406
|
+
} else if (item.replacement) {
|
|
407
|
+
parts.push(`→ ${item.replacement}`);
|
|
408
|
+
}
|
|
409
|
+
const head = parts.join(' ');
|
|
410
|
+
return item.reason ? `${head}\n ${item.reason}` : head;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
export function printMigrationReport(report, log = console.log) {
|
|
414
|
+
const mode = report.write ? '--write' : 'dry-run';
|
|
415
|
+
log(`Sparkle Design migrate(${mode})`);
|
|
416
|
+
if (!report.write) {
|
|
417
|
+
log(
|
|
418
|
+
'ファイルは変更しません。[自動変換可] の箇所だけを書き換えるには --write を付けて実行してください。'
|
|
419
|
+
);
|
|
420
|
+
}
|
|
421
|
+
log('');
|
|
422
|
+
|
|
423
|
+
for (const result of report.results) {
|
|
424
|
+
log(result.filePath);
|
|
425
|
+
for (const entry of result.diff) {
|
|
426
|
+
log(` @@ ${entry.line}`);
|
|
427
|
+
log(` - ${entry.before.trim()}`);
|
|
428
|
+
log(` + ${entry.after.trim()}`);
|
|
429
|
+
}
|
|
430
|
+
for (const item of result.items) {
|
|
431
|
+
if (item.classification === MIGRATION_CLASS.AUTO) continue;
|
|
432
|
+
log(` ${describeItem(item)}`);
|
|
433
|
+
}
|
|
434
|
+
log('');
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
const { summary } = report;
|
|
438
|
+
log(
|
|
439
|
+
`検査したファイル: ${report.scannedFileCount} / ` +
|
|
440
|
+
`自動変換可: ${summary.auto} 件(${summary.autoFileCount} ファイル) / ` +
|
|
441
|
+
`要判断: ${summary.review} 件 / 構造変更あり: ${summary.structural} 件`
|
|
442
|
+
);
|
|
443
|
+
if (report.write) {
|
|
444
|
+
log(`書き換えたファイル: ${summary.writtenFileCount}`);
|
|
445
|
+
}
|
|
446
|
+
if (summary.review + summary.structural > 0) {
|
|
447
|
+
log(
|
|
448
|
+
'[要判断] と [構造変更あり] は書き換えていません。候補と Figma の該当箇所を見て手で直してください。'
|
|
449
|
+
);
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* @param {{ targets?: string[], includes?: string[], write?: boolean }} options
|
|
455
|
+
*/
|
|
456
|
+
export function runMigrate(options = {}) {
|
|
457
|
+
const report = createMigrationReport(options);
|
|
458
|
+
printMigrationReport(report);
|
|
459
|
+
return report;
|
|
460
|
+
}
|
package/lib/token-migration.js
CHANGED
|
@@ -172,7 +172,9 @@ const SCALE_MAP = {
|
|
|
172
172
|
},
|
|
173
173
|
info: { 300: 'border-info' },
|
|
174
174
|
success: { 300: 'border-success' },
|
|
175
|
-
|
|
175
|
+
// Figma 2026-10-01 再同期で border/warning は yellow/400 を参照する(旧 300)
|
|
176
|
+
// en: border/warning points at yellow/400 since the 2026-10-01 Figma resync (was 300)
|
|
177
|
+
warning: { 400: 'border-warning' },
|
|
176
178
|
},
|
|
177
179
|
object: {
|
|
178
180
|
primary: {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sparkle-design-cli",
|
|
3
|
-
"version": "2.5.0-beta.
|
|
3
|
+
"version": "2.5.0-beta.4",
|
|
4
4
|
"description": "Sparkle Design CLI — プロジェクトセットアップ、CSS・フォント生成、アンチパターン検査、AI エージェント(Claude Code / Cursor / Codex)向けのガードと hook 設定まで一括で行う sparkle-design 公式 CLI。",
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"registry": "https://registry.npmjs.org",
|
|
@@ -14,7 +14,7 @@ Sparkle Design のトークン定義。`sparkle-design-cli` と `sparkle-design-
|
|
|
14
14
|
| プリミティブカラー(red / orange / yellow / green / blue / purple / pink、black / white、alpha、`ring`) | `colors.json` | **`gray` はここに置かない**。`ring.normal` は旧 `--color-ring-normal` 用の beta 互換値で、移行先は セマンティックの `border/ring` |
|
|
15
15
|
| グレースケール | `gray.json` | primary ごとに 10 段(0–9)。CLI が `--color-gray-50`〜`900` に展開する |
|
|
16
16
|
| 角丸のプロファイル | `radius.csv` | 行 = プロファイル、列 = セマンティック名 |
|
|
17
|
-
| フォント一覧 | `fontdata.csv` | 消費側は Figma プラグイン `sparkle-design-theme-settings` のみ(CLI
|
|
17
|
+
| フォント一覧 | `fontdata.csv` | 消費側は Figma プラグイン `sparkle-design-theme-settings` のみ(CLI は読まない)。CLI 経由の検証が効かないぶん、`validate-tokens.mjs` が形(列名・`proportion` の値・一意性)を検査する |
|
|
18
18
|
| セマンティックトークン(色 / 角丸 / シャドウ / タイポグラフィ)と CSS 構造 | `sparkle-design.template.css` | |
|
|
19
19
|
| 最大幅(`--container-*`) | `sparkle-design.template.css` | Figma の `Sizing: Primitives` と 1:1。**余白(`Spacing: Primitives`)はここに置かない** — 理由は下記 |
|
|
20
20
|
| 上記すべての一次情報 | **Figma `Sparkle Design`** | 齟齬があれば Figma が正 |
|
|
@@ -143,9 +143,13 @@ node scripts/validate-tokens.mjs
|
|
|
143
143
|
- 同一ブロック内に重複した変数宣言が無いこと(後勝ちでサイレント上書きされるため)
|
|
144
144
|
- `character-*` / `icon-*` クラスが **自分の変数だけを** 参照していること(クラス間の取り違えを検出する)。貼り替え漏れ(未参照の変数 / 未定義の参照)も含む
|
|
145
145
|
- テンプレートの波括弧の釣り合いと、プレースホルダーが CLI の期待どおりの字面であること(大文字小文字のタイポと、`/* {{COLOR_TOKENS}} */` の体裁崩れによる置換 no-op を検出する)
|
|
146
|
+
- `@font-face` 専用の記述子(`src` / `font-display` / `unicode-range` / `*-override` / `size-adjust`)がスタイルルールに書かれていないこと。これらはプロパティではないので、スタイルルールに置くと**パース時に宣言ごと捨てられます**。CSS は無効な宣言を黙って飛ばす仕様のため、ビルドも lint も警告を出しません
|
|
147
|
+
- `fontdata.csv` が消費側(Figma プラグイン `sparkle-design-theme-settings`)の期待する形をしていること。列名・`proportion` の取りうる値(`pro` / `mono` / `both`)・`name` と `variable` の一意性・`variable` が `font-*` のクラス名の形であること
|
|
146
148
|
|
|
147
149
|
### 検査設計の前提
|
|
148
150
|
|
|
149
151
|
**「0 件マッチ = 成功」を作らない。** 集合比較だけの検査は、命名変更などで対象を 1 件も拾えなくなったときに黙って合格します。各検査は「最低これだけの件数を実際に見た」ことを表明してから判定するので、検査が空振りしていること自体が失敗になります。
|
|
150
152
|
|
|
151
|
-
**検査できないこと。** これは構造の検査であって、値の妥当性は見ていません。たとえば `--color-text-high` の参照先を別のグレーに書き換えてもコントラストが壊れるだけで検査は通ります。色の値そのものの正解は Figma
|
|
153
|
+
**検査できないこと。** これは構造の検査であって、値の妥当性は見ていません。たとえば `--color-text-high` の参照先を別のグレーに書き換えてもコントラストが壊れるだけで検査は通ります。色の値そのものの正解は Figma です。
|
|
154
|
+
|
|
155
|
+
`fontdata.csv` についても同じで、見ているのは形だけです。`variable` の値が消費側のプラグインに実在するクラスかどうかは、そのクラスが別リポジトリ(`sparkle-design-theme-settings` の `src/ui/styles/main.css`)にあるため、このリポジトリの CI では確かめられません。
|