sparkle-design-cli 2.0.7-beta.1 → 2.0.7-beta.10

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.
@@ -88,22 +88,64 @@ function createCustomCssImportBlock(customCssPath, globalsPath) {
88
88
  return [COMMENTS.CUSTOM_CSS, `@import "${relativePath}";`].join('\n');
89
89
  }
90
90
 
91
+ /**
92
+ * entry CSS (globals.css / index.css) から sparkle-design.css への相対 path を
93
+ * 計算し、`@import` 文を組み立てる。同一ディレクトリなら `./sparkle-design.css`、
94
+ * 異なるなら `./app/sparkle-design.css` のような相対 path になる。Vite の
95
+ * `src/index.css` → `src/app/sparkle-design.css` レイアウトで正しい path が
96
+ * 出るようにするために追加された関数(beta.7 まではハードコード)。
97
+ * en: Compute the relative path from the entry CSS to sparkle-design.css.
98
+ * Handles layouts like Vite's `src/index.css` → `src/app/sparkle-design.css`,
99
+ * where the two files live in different directories.
100
+ */
101
+ function buildSparkleImportStatement(globalsPath, sparkleDesignPath) {
102
+ if (!globalsPath || !sparkleDesignPath) {
103
+ return IMPORTS.SPARKLE_DESIGN;
104
+ }
105
+ const globalsDir = path.dirname(globalsPath);
106
+ const rel = path.relative(globalsDir, sparkleDesignPath).split(path.sep).join('/');
107
+ // path.relative が空文字を返すのは globalsPath === sparkleDesignPath のときのみ
108
+ // (想定外)。念のため fallback として同一 dir 前提の default import を返す。
109
+ // en: path.relative returns '' only when paths match; use the same-dir default.
110
+ if (!rel) return IMPORTS.SPARKLE_DESIGN;
111
+ const importPath = rel.startsWith('.') ? rel : `./${rel}`;
112
+ return `@import "${importPath}";`;
113
+ }
114
+
91
115
  /**
92
116
  * sparkle-design.css importブロックを生成する
93
117
  * @param {Array<string>} sourcePackages 追加パッケージ名の配列
94
118
  * @param {string} globalsPath globals.css の絶対パス(@source パス計算用)
95
119
  * @param {string|null} customCssPath custom-css ファイルの相対パス
120
+ * @param {string|null} sparkleDesignPath sparkle-design.css の絶対パス(相対 import 計算用)
96
121
  * @returns {string} sparkle-design.css importブロック
97
122
  */
98
- function createSparkleImportBlock(sourcePackages = [], globalsPath = null, customCssPath = null) {
123
+ function createSparkleImportBlock(
124
+ sourcePackages = [],
125
+ globalsPath = null,
126
+ customCssPath = null,
127
+ sparkleDesignPath = null
128
+ ) {
99
129
  const parts = [''];
100
130
 
101
- if (sourcePackages !== null && sourcePackages !== undefined) {
102
- const nodeModulesRel = globalsPath ? resolveNodeModulesRelPath(globalsPath) : '../node_modules';
103
- parts.push(createSourceBlock(sourcePackages, nodeModulesRel));
104
- }
105
-
106
- parts.push(COMMENTS.SPARKLE_IMPORT, IMPORTS.SPARKLE_DESIGN);
131
+ // CSS 仕様上 `@import` は `@charset` / `@layer` 以外の at-rule より前に
132
+ // 書く必要がある。`@source` を `@import` より前に置くと、PostCSS 処理系に
133
+ // よっては後続の `@import "./sparkle-design.css"` が silent に drop され、
134
+ // sparkle tokens が最終 CSS に出ない回帰になる(beta.6 以前で再現)。
135
+ // そのため順序は:
136
+ // 1. @import "./sparkle-design.css" (Sparkle 本体のトークン)
137
+ // 2. @import "./custom-tokens.css" (ユーザーのカスタムトークン、任意)
138
+ // 3. @source "../node_modules/..." (Tailwind スキャン対象、任意)
139
+ // の順で出力する。
140
+ // en: Per CSS spec, all `@import` rules must precede any other at-rule
141
+ // (except `@charset` / `@layer`). Placing `@source` between imports makes
142
+ // downstream `@import "./sparkle-design.css"` silently drop in some
143
+ // PostCSS toolchains, which removes Sparkle tokens from the output.
144
+ // We now emit @import blocks first, then any @source directives.
145
+ parts.push(
146
+ COMMENTS.SPARKLE_IMPORT,
147
+ buildSparkleImportStatement(globalsPath, sparkleDesignPath)
148
+ );
107
149
 
108
150
  // カスタムCSS import を sparkle-design.css の後に配置
109
151
  const customBlock = createCustomCssImportBlock(customCssPath, globalsPath);
@@ -111,6 +153,11 @@ function createSparkleImportBlock(sourcePackages = [], globalsPath = null, custo
111
153
  parts.push(customBlock);
112
154
  }
113
155
 
156
+ if (sourcePackages !== null && sourcePackages !== undefined) {
157
+ const nodeModulesRel = globalsPath ? resolveNodeModulesRelPath(globalsPath) : '../node_modules';
158
+ parts.push(createSourceBlock(sourcePackages, nodeModulesRel));
159
+ }
160
+
114
161
  parts.push('');
115
162
  return parts.join('\n');
116
163
  }
@@ -190,24 +237,27 @@ function reconstructGlobalsCss(globalsContent, sparkleImportBlock, tailwindInfo)
190
237
  * - sparkle-design.css importをTailwindの後に配置
191
238
  * - 自動検出した sourcePackages から @source ディレクティブを挿入
192
239
  *
193
- * **v2.0.7-beta.1 以前の不具合**: 早期 return が `fontImports.length === 0` 単独で
194
- * 行われていたため、フォントを SparkleHead に移行済みかつ既存 globals.css が
195
- * ある(Vite の `src/index.css` など)プロジェクトでは `@source` が一切挿入
196
- * されなかった。条件を「フォント・sourcePackages・customCssPath のすべてが
197
- * 空なら skip」に変更し、パッケージの自動検出結果だけでも globals.css が
198
- * patch されるようにした。
240
+ * **v2.0.7-beta.2**: 戻り値を真偽値から `{ status, reason }` の状態付きに変更。
241
+ * 呼び出し側が「作業不要でスキップ」「失敗したがデフォルトは warn 継続」を
242
+ * 区別できるようにし、`--strict` モードで失敗を exit code に反映できるよう
243
+ * にした(#31)。`skipped` は後方互換のため `false` に、`updated` は `true`
244
+ * に toBoolean で扱える想定。
199
245
  *
200
246
  * @param {Array<string>} fontImports フォントimport文の配列
201
247
  * @param {string} globalsPath globals.cssのパス
202
248
  * @param {Array<string>|null} sourcePackages 追加パッケージ名の配列(null の場合は @source を生成しない)
203
249
  * @param {string|null} customCssPath custom-css ファイルの相対パス
204
- * @returns {boolean} globals.css の更新に成功した場合は true
250
+ * @returns {{ status: 'skipped' | 'updated' | 'failed', reason?: string }}
251
+ * skipped: 触る理由がない (fonts/sourcePackages/customCss すべて空)
252
+ * updated: globals.css を正常に書き換えた
253
+ * failed: 書き換え対象だったが失敗した (TAILWIND_IMPORT 欠落、write 失敗等)
205
254
  */
206
255
  export function updateGlobalsWithFonts(
207
256
  fontImports,
208
257
  globalsPath,
209
258
  sourcePackages = null,
210
- customCssPath = null
259
+ customCssPath = null,
260
+ sparkleDesignPath = null
211
261
  ) {
212
262
  const hasFonts = fontImports.length > 0;
213
263
  const hasSourcePackages = sourcePackages !== null && sourcePackages !== undefined;
@@ -217,7 +267,7 @@ export function updateGlobalsWithFonts(
217
267
  // 理由がないので skip。
218
268
  // en: Nothing to inject, so leave globals.css alone.
219
269
  if (!hasFonts && !hasSourcePackages && !hasCustomCss) {
220
- return false;
270
+ return { status: 'skipped', reason: 'no-work' };
221
271
  }
222
272
 
223
273
  try {
@@ -227,15 +277,42 @@ export function updateGlobalsWithFonts(
227
277
  // 2. 既存のimportを削除
228
278
  globalsContent = removeExistingImports(globalsContent);
229
279
 
230
- // 3. Tailwind import の位置を見つける
231
- const tailwindInfo = findTailwindImport(globalsContent);
280
+ // 3. Tailwind import の位置を見つける。無ければ先頭に自動で prepend する。
281
+ // ここで silent に失敗すると `create-vite` 等が生成した既存 index.css に
282
+ // `@import "tailwindcss"` が無いプロジェクトで setup が no-op に終わり、
283
+ // AI が後から手書きで import を補うが `@source` を知らず落とす、という
284
+ // 連鎖的な崩れが起きていた(beta.8 試用で確認)。canonical な import を
285
+ // 自動投入することでその連鎖を根絶する。
286
+ // en: If `@import "tailwindcss"` is missing from an existing entry CSS
287
+ // (e.g. untouched `create-vite` default), auto-prepend it instead of
288
+ // silently warning. Without this recovery, the whole patch becomes a no-op
289
+ // and AI ends up hand-writing imports without `@source`, producing drifted
290
+ // output (observed in beta.8 user testing).
291
+ let tailwindInfo = findTailwindImport(globalsContent);
232
292
  if (!tailwindInfo) {
233
- console.warn(MESSAGES.TAILWIND_NOT_FOUND);
234
- return false;
293
+ const displayPath = path.relative(process.cwd(), globalsPath) || globalsPath;
294
+ console.warn(
295
+ `⚠️ ${displayPath} に \`@import "tailwindcss";\` が見つからなかったため先頭に追記します(canonical な Tailwind v4 entry point にするため)。`
296
+ );
297
+ globalsContent = `${IMPORTS.TAILWIND}\n${globalsContent.startsWith('\n') ? '' : ''}${globalsContent}`;
298
+ tailwindInfo = findTailwindImport(globalsContent);
299
+ if (!tailwindInfo) {
300
+ // prepend したのに正規表現で拾えないのは `REGEX.TAILWIND_IMPORT` が
301
+ // 想定と違う(= コードの不整合)。fail-fast する。
302
+ // en: We just wrote the canonical import but the regex doesn't see it —
303
+ // this is an internal inconsistency, not a user error. Fail loud.
304
+ console.error(MESSAGES.TAILWIND_NOT_FOUND(displayPath));
305
+ return { status: 'failed', reason: 'tailwind-import-missing-after-prepend' };
306
+ }
235
307
  }
236
308
 
237
309
  // 4. importブロックを生成(フォント @import は SparkleHead に移行したため生成しない)
238
- const sparkleImportBlock = createSparkleImportBlock(sourcePackages, globalsPath, customCssPath);
310
+ const sparkleImportBlock = createSparkleImportBlock(
311
+ sourcePackages,
312
+ globalsPath,
313
+ customCssPath,
314
+ sparkleDesignPath
315
+ );
239
316
 
240
317
  // 5. globals.css を再構築(フォント @import なし)
241
318
  const reconstructedContent = reconstructGlobalsCss(
@@ -247,11 +324,10 @@ export function updateGlobalsWithFonts(
247
324
  // 6. 更新したglobals.cssを書き込む
248
325
  fs.writeFileSync(globalsPath, reconstructedContent, 'utf8');
249
326
  console.log(MESSAGES.GLOBALS_UPDATED(globalsPath));
250
- return true;
327
+ return { status: 'updated' };
251
328
  } catch (error) {
252
329
  console.error(MESSAGES.GLOBALS_UPDATE_FAILED(error.message));
253
- // globals.cssの更新は必須ではないので、エラーでも処理を続行
254
- return false;
330
+ return { status: 'failed', reason: `write-error: ${error.code ?? error.message}` };
255
331
  }
256
332
  }
257
333
 
@@ -355,8 +431,17 @@ function resolveGlobalsPath(sparkleDesignPath, explicitGlobalsPath = null) {
355
431
  if (fs.existsSync(resolved)) {
356
432
  return { path: resolved, source: 'explicit' };
357
433
  }
358
- console.warn(`⚠️ 指定された globals パスが見つかりません: ${explicitGlobalsPath}`);
359
- return null;
434
+ // 明示指定は settings bug / typo なので silent warn ではなく throw に
435
+ // 昇格させる。--strict の有無に関わらず呼び出し元で伝播させたいので、
436
+ // error.code を付けて上流で識別できるようにする。
437
+ // en: An explicit --globals-path value that doesn't exist is almost
438
+ // always a typo. We tag the error so manageFontImports's catch knows
439
+ // to re-throw regardless of strict mode.
440
+ const err = new Error(
441
+ `指定された globals パスが見つかりません: ${explicitGlobalsPath} (cwd 基準で解決: ${resolved})`
442
+ );
443
+ err.code = 'E_EXPLICIT_GLOBALS_PATH_NOT_FOUND';
444
+ throw err;
360
445
  }
361
446
 
362
447
  // 1. 自動検出: sparkle-design.css と同じディレクトリで @import "tailwindcss" を含む
@@ -384,11 +469,22 @@ function resolveGlobalsPath(sparkleDesignPath, explicitGlobalsPath = null) {
384
469
  const candidates = isViteProject(cwd)
385
470
  ? GLOBALS_CSS_CANDIDATES.slice().sort((a, b) => vitePriority(a) - vitePriority(b))
386
471
  : GLOBALS_CSS_CANDIDATES;
472
+ // 2-a) まず既知候補の中で `@import "tailwindcss"` を持つファイルを探す。
473
+ // 持っているファイルが第一候補(canonical Tailwind entry CSS)。
474
+ // 2-b) 2-a で見つからなかった場合、`@import` 無しで既存の候補ファイルが
475
+ // あればそれを拾う(Vite の `create-vite` 直後のように entry CSS は
476
+ // あるが Tailwind 未設定のプロジェクト)。これで上流の
477
+ // `updateGlobalsWithFonts` が auto-prepend で tailwind import を
478
+ // 補えるため、silent に no-op 終了する回帰を避けられる(beta.8 で再現)。
479
+ // en: First pick candidates that already contain `@import "tailwindcss"`.
480
+ // If none match, fall back to the first existing candidate anyway so the
481
+ // upstream patcher can auto-prepend the canonical tailwind import. Without
482
+ // this fallback, a Vite project created by `create-vite` with an untouched
483
+ // `src/index.css` would cause setup to end as a silent no-op.
484
+ let fallbackExistingPath = null;
387
485
  for (const candidate of candidates) {
388
486
  const absolute = path.resolve(cwd, candidate);
389
487
  if (!fs.existsSync(absolute)) continue;
390
- // 自身(sparkle-design.css)は除外
391
- // en: Skip sparkle-design.css itself just in case a candidate points at it.
392
488
  if (path.resolve(absolute) === path.resolve(sparkleDesignPath)) continue;
393
489
  try {
394
490
  const content = fs.readFileSync(absolute, 'utf8');
@@ -396,6 +492,9 @@ function resolveGlobalsPath(sparkleDesignPath, explicitGlobalsPath = null) {
396
492
  console.log(`📝 Tailwind エントリポイントを検出しました(project root): ${candidate}`);
397
493
  return { path: absolute, source: 'project' };
398
494
  }
495
+ if (!fallbackExistingPath) {
496
+ fallbackExistingPath = { path: absolute, candidate };
497
+ }
399
498
  } catch (err) {
400
499
  // ENOENT 系は existsSync で既に弾いているので、ここに来るのは権限不足や
401
500
  // ディレクトリ衝突などデバッグ価値のあるケース。silent に消さずに理由を出す。
@@ -407,6 +506,13 @@ function resolveGlobalsPath(sparkleDesignPath, explicitGlobalsPath = null) {
407
506
  }
408
507
  }
409
508
 
509
+ if (fallbackExistingPath) {
510
+ console.log(
511
+ `📝 Tailwind import 未設定の既存 entry CSS を検出しました(project root): ${fallbackExistingPath.candidate}。後続処理で \`@import "tailwindcss";\` を自動追記します。`
512
+ );
513
+ return { path: fallbackExistingPath.path, source: 'existing-no-tailwind' };
514
+ }
515
+
410
516
  // 3. デフォルト: sparkle-design.css と同じディレクトリの globals.css
411
517
  // en: Last resort — a globals.css next to sparkle-design.css.
412
518
  const defaultPath = path.join(dir, 'globals.css');
@@ -420,24 +526,54 @@ function resolveGlobalsPath(sparkleDesignPath, explicitGlobalsPath = null) {
420
526
  /**
421
527
  * フォント管理の自動処理を実行する
422
528
  * sparkle-design.css からフォントimportを抽出し、Tailwind エントリポイント CSS に @source 等を挿入する
529
+ *
530
+ * **v2.0.7-beta.2**: 戻り値を `{ status, reason? }` に変更し、呼び出し側が
531
+ * 「作業不要のスキップ」「作業すべき状態だが失敗」を区別できるようにした
532
+ * (#31)。`options.strict` が true のとき、`status === 'failed'` になる状態は
533
+ * throw に昇格させて `bin/sparkle-design.js` の outer catch で exit 1 に
534
+ * 繋げる。
535
+ *
423
536
  * @param {string} sparkleDesignPath sparkle-design.cssのパス
424
537
  * @param {Array<string>|null} sourcePackages 追加パッケージ名の配列(null の場合は @source を生成しない)
425
538
  * @param {string|null} customCssPath custom-css ファイルの相対パス
426
539
  * @param {string|null} globalsPathOverride 明示的に指定された globals パス
540
+ * @param {{ strict?: boolean }} [options] strict=true のとき失敗を throw する
541
+ * @returns {{ status: 'skipped' | 'updated' | 'failed', reason?: string }}
427
542
  */
428
543
  export function manageFontImports(
429
544
  sparkleDesignPath,
430
545
  sourcePackages = null,
431
546
  customCssPath = null,
432
- globalsPathOverride = null
547
+ globalsPathOverride = null,
548
+ options = {}
433
549
  ) {
550
+ const strict = Boolean(options.strict);
551
+ const hasWork =
552
+ (sourcePackages !== null && sourcePackages !== undefined) || Boolean(customCssPath);
553
+
554
+ const raiseOrReturn = (result) => {
555
+ if (strict && result.status === 'failed') {
556
+ throw new Error(
557
+ `globals.css の更新に失敗しました (${result.reason ?? 'unknown'})。--strict モードでは exit 1 で終了します。`
558
+ );
559
+ }
560
+ return result;
561
+ };
562
+
434
563
  try {
435
564
  // 1. globals パスを解決
436
565
  const resolved = resolveGlobalsPath(sparkleDesignPath, globalsPathOverride);
437
566
 
438
567
  if (!resolved) {
568
+ // 作業すべき状態(sourcePackages 等あり)で entry CSS が見つからないのは
569
+ // 事実上の不具合。strict なら throw、非 strict なら従来どおり情報ログ。
570
+ // en: With work queued, missing entry CSS is effectively a misconfiguration.
571
+ if (hasWork) {
572
+ console.warn(MESSAGES.GLOBALS_NOT_FOUND);
573
+ return raiseOrReturn({ status: 'failed', reason: 'entry-css-not-found' });
574
+ }
439
575
  console.log(MESSAGES.GLOBALS_NOT_FOUND);
440
- return;
576
+ return { status: 'skipped', reason: 'entry-css-not-found-no-work' };
441
577
  }
442
578
 
443
579
  const globalsPath = resolved.path;
@@ -460,17 +596,25 @@ export function manageFontImports(
460
596
  }
461
597
 
462
598
  // 4. globals.css に import / @source を追加
463
- const globalsUpdated = updateGlobalsWithFonts(
599
+ // sparkle-design.css の絶対パスを渡すことで、globalsPath と異なる
600
+ // ディレクトリに置かれている場合でも相対 import が正しく計算される
601
+ // (Vite の src/index.css → src/app/sparkle-design.css 等)。
602
+ // en: Pass the absolute sparkle-design.css path so the import can be
603
+ // computed relative to the entry CSS, even in Vite-style layouts where
604
+ // the two files live in different directories.
605
+ const result = updateGlobalsWithFonts(
464
606
  fontImports,
465
607
  globalsPath,
466
608
  sourcePackages,
467
- customCssPath
609
+ customCssPath,
610
+ sparkleDesignPath
468
611
  );
469
612
 
470
- if (!globalsUpdated) {
471
- // fonts が元々無く sourcePackages / customCss も空なら更新不要。
472
- // en: Nothing to do — not an error.
473
- return;
613
+ if (result.status === 'skipped') {
614
+ return result;
615
+ }
616
+ if (result.status === 'failed') {
617
+ return raiseOrReturn(result);
474
618
  }
475
619
 
476
620
  // 5. fonts が sparkle-design.css 側に残っていれば削除(globals 側に移動済みの前提)
@@ -480,8 +624,15 @@ export function manageFontImports(
480
624
  fs.writeFileSync(sparkleDesignPath, cleanedSparkleContent, 'utf8');
481
625
  console.log(MESSAGES.FONT_REMOVED);
482
626
  }
627
+ return result;
483
628
  } catch (error) {
484
629
  console.error(MESSAGES.FONT_MANAGEMENT_ERROR(error.message));
485
- // フォント管理は必須ではないので、エラーでも処理を続行
630
+ // strict モード、または明示指定された globals path の not-found は
631
+ // ユーザーが必ず気付くべきなので非 strict でも再 throw する。
632
+ // en: Always re-throw explicit --globals-path typos; respect strict for the rest.
633
+ if (strict || error.code === 'E_EXPLICIT_GLOBALS_PATH_NOT_FOUND') {
634
+ throw error;
635
+ }
636
+ return { status: 'failed', reason: `manage-error: ${error.code ?? error.message}` };
486
637
  }
487
638
  }
@@ -68,7 +68,7 @@ function dedupeFontImports(cssContent) {
68
68
 
69
69
  return cssContent
70
70
  .split('\n')
71
- .filter(line => {
71
+ .filter((line) => {
72
72
  if (!line.includes('fonts.googleapis.com')) {
73
73
  return true;
74
74
  }
@@ -96,7 +96,7 @@ function normalizeFontsEntry(entry) {
96
96
  return [{ family: entry, weights: FONT_DEFAULTS.WEIGHTS }];
97
97
  }
98
98
  if (Array.isArray(entry)) {
99
- return entry.map(item => {
99
+ return entry.map((item) => {
100
100
  if (typeof item === 'string') {
101
101
  return { family: item, weights: FONT_DEFAULTS.WEIGHTS };
102
102
  }
@@ -132,7 +132,7 @@ function resolveFontConfig(config) {
132
132
  * @returns {string} CSS font-family 値(例: 'Montserrat', 'Noto Sans JP', sans-serif)
133
133
  */
134
134
  function generateFontFamilyValue(fonts, genericFamily) {
135
- const quoted = fonts.map(f => `'${f.family}'`);
135
+ const quoted = fonts.map((f) => `'${f.family}'`);
136
136
  return [...quoted, genericFamily].join(', ');
137
137
  }
138
138
 
@@ -169,9 +169,10 @@ function generateMergedFontImports(allFonts) {
169
169
  * @returns {string} フォント import ブロック
170
170
  */
171
171
  function generateFontImportsBlock(configOrResolved) {
172
- const { pro, mono } = configOrResolved.pro && configOrResolved.mono
173
- ? configOrResolved
174
- : resolveFontConfig(configOrResolved);
172
+ const { pro, mono } =
173
+ configOrResolved.pro && configOrResolved.mono
174
+ ? configOrResolved
175
+ : resolveFontConfig(configOrResolved);
175
176
 
176
177
  const imports = [
177
178
  FONT_DEFAULTS.MATERIAL_SYMBOLS_IMPORT,
@@ -206,7 +207,7 @@ function generateSparkleHeadContent(resolvedFonts) {
206
207
  ` <link rel="preconnect" href="${FONT_DOMAINS.GOOGLEAPIS}" />`,
207
208
  ` <link rel="preconnect" href="${FONT_DOMAINS.GSTATIC}" crossOrigin="anonymous" />`,
208
209
  ` <link rel="stylesheet" href="${materialSymbolsUrl}" />`,
209
- ...fontUrls.map(url => ` <link rel="stylesheet" href="${url}" />`),
210
+ ...fontUrls.map((url) => ` <link rel="stylesheet" href="${url}" />`),
210
211
  ];
211
212
 
212
213
  return `/**
@@ -255,6 +256,92 @@ function writeSparkleHead(content, sparkleDesignCssPath) {
255
256
  console.log(' → ルートレイアウトの <head> 内に <SparkleHead /> を追加してください');
256
257
  }
257
258
 
259
+ const VITE_INDEX_HTML_BLOCK_START = '<!-- sparkle-design-cli:fonts:start -->';
260
+ const VITE_INDEX_HTML_BLOCK_END = '<!-- sparkle-design-cli:fonts:end -->';
261
+
262
+ /**
263
+ * SparkleHead.tsx と同じフォント <link> タグ群を HTML 文字列として生成する。
264
+ * Vite のように `<head>` が `index.html` にある環境では、React コンポーネントを
265
+ * 挟めないため HTML に直接注入する必要がある。
266
+ * en: Return the font/link tags as HTML strings so we can inject them into
267
+ * Vite's `index.html`, where a React SparkleHead component can't reach the head.
268
+ */
269
+ function buildSparkleFontLinksHtml(resolvedFonts) {
270
+ const materialSymbolsUrl = extractUrlFromImport(FONT_DEFAULTS.MATERIAL_SYMBOLS_IMPORT);
271
+ const fontImportLines = generateMergedFontImports([...resolvedFonts.pro, ...resolvedFonts.mono]);
272
+ const fontUrls = fontImportLines.map(extractUrlFromImport).filter(Boolean);
273
+ return [
274
+ `<link rel="preconnect" href="${FONT_DOMAINS.GOOGLEAPIS}" />`,
275
+ `<link rel="preconnect" href="${FONT_DOMAINS.GSTATIC}" crossorigin />`,
276
+ `<link rel="stylesheet" href="${materialSymbolsUrl}" />`,
277
+ ...fontUrls.map((url) => `<link rel="stylesheet" href="${url}" />`),
278
+ ];
279
+ }
280
+
281
+ /**
282
+ * Vite プロジェクトの `index.html` に managed block で Sparkle のフォント
283
+ * `<link>` タグを upsert する。存在しない場合は `</head>` 直前に挿入、
284
+ * 既にマーカー付きブロックがあれば内容を置換する。
285
+ *
286
+ * - Vite 以外(Next.js の src/app など)は index.html を持たないので呼ばれない
287
+ * - 既存ユーザーの head 内に書いた自作の link は触らない(managed block の
288
+ * 範囲だけを管理するので non-destructive)
289
+ *
290
+ * en: Upsert a managed block of Sparkle font `<link>` tags into Vite's
291
+ * `index.html`, injecting before `</head>` on first run and replacing the
292
+ * block content on re-runs. Non-destructive against user-authored links
293
+ * outside the markers.
294
+ */
295
+ function upsertViteIndexHtmlFonts(cwd, resolvedFonts) {
296
+ const indexHtmlPath = path.resolve(cwd, 'index.html');
297
+ if (!fs.existsSync(indexHtmlPath)) {
298
+ return { status: 'skipped', reason: 'no-index-html' };
299
+ }
300
+ let html;
301
+ try {
302
+ html = fs.readFileSync(indexHtmlPath, 'utf8');
303
+ } catch (error) {
304
+ return { status: 'failed', reason: `read-error: ${error.code ?? error.message}` };
305
+ }
306
+
307
+ const links = buildSparkleFontLinksHtml(resolvedFonts);
308
+ // ブロック内部の各行は innerIndent で統一する。マーカー行自体も含める。
309
+ // en: Apply innerIndent to every line of the managed block, markers included.
310
+ const innerIndent = ' ';
311
+ const block = [VITE_INDEX_HTML_BLOCK_START, ...links, VITE_INDEX_HTML_BLOCK_END]
312
+ .map((line) => innerIndent + line)
313
+ .join('\n');
314
+
315
+ if (html.includes(VITE_INDEX_HTML_BLOCK_START) && html.includes(VITE_INDEX_HTML_BLOCK_END)) {
316
+ // 既存ブロックの各行インデントを尊重しつつ中身だけ置換すると複雑になるので、
317
+ // BLOCK_START の前にある空白を再利用してブロックごと丸ごと差し替える。
318
+ // en: Replace the entire managed block while preserving the leading
319
+ // whitespace that already sits in front of BLOCK_START.
320
+ const existingBlockRegex = new RegExp(
321
+ `([^\\S\\n]*)${VITE_INDEX_HTML_BLOCK_START}[\\s\\S]*?${VITE_INDEX_HTML_BLOCK_END}`
322
+ );
323
+ const replaced = html.replace(existingBlockRegex, (_match, leadingWs) => {
324
+ // 既存 leading を innerIndent に揃える。マーカー前の空白が行頭にある想定。
325
+ return [VITE_INDEX_HTML_BLOCK_START, ...links, VITE_INDEX_HTML_BLOCK_END]
326
+ .map((line, idx) => (idx === 0 ? leadingWs : innerIndent) + line)
327
+ .join('\n');
328
+ });
329
+ if (replaced === html) return { status: 'unchanged', path: indexHtmlPath };
330
+ fs.writeFileSync(indexHtmlPath, replaced, 'utf8');
331
+ return { status: 'updated', path: indexHtmlPath };
332
+ }
333
+
334
+ const headCloseRegex = /(\s*)<\/head>/i;
335
+ const match = html.match(headCloseRegex);
336
+ if (!match) {
337
+ return { status: 'failed', reason: 'no-head-close-tag' };
338
+ }
339
+ const leading = match[1]; // 通常 `\n ` (`</head>` 直前の改行とインデント)
340
+ const replaced = html.replace(headCloseRegex, `\n${block}${leading}</head>`);
341
+ fs.writeFileSync(indexHtmlPath, replaced, 'utf8');
342
+ return { status: 'created', path: indexHtmlPath };
343
+ }
344
+
258
345
  /**
259
346
  * テンプレート変数を設定値で置換する
260
347
  * @param {string} template CSSテンプレート
@@ -289,7 +376,15 @@ function processTemplate(template, config, grayMapping, radiusMapping, colors) {
289
376
  // 5. 基本的な設定値による置換(オブジェクト・配列・拡張フィールドはスキップ)
290
377
  Object.entries(config).forEach(([key, value]) => {
291
378
  // 配列・オブジェクト・拡張フィールドはスキップ
292
- if (Array.isArray(value) || (typeof value === 'object' && value !== null) || key === 'custom-css' || key === 'fonts' || key === 'extend' || key === 'source-packages' || key === 'globals-path') {
379
+ if (
380
+ Array.isArray(value) ||
381
+ (typeof value === 'object' && value !== null) ||
382
+ key === 'custom-css' ||
383
+ key === 'fonts' ||
384
+ key === 'extend' ||
385
+ key === 'source-packages' ||
386
+ key === 'globals-path'
387
+ ) {
293
388
  return;
294
389
  }
295
390
  // 通常のプレースホルダー(CSS用 - スペースはそのまま)
@@ -336,9 +431,7 @@ function processTemplate(template, config, grayMapping, radiusMapping, colors) {
336
431
  // `sparkle-design` は CLI 側のデフォルトパッケージなのでリストには加えず「存在判定」にだけ使う。
337
432
  // en: Known design-system packages to auto-detect. `sparkle-design` is the CLI's
338
433
  // default source package, so it only acts as an "enables @source" signal.
339
- const KNOWN_DESIGN_SYSTEM_PACKAGES = [
340
- '@goodpatch/sparkle-design-internal',
341
- ];
434
+ const KNOWN_DESIGN_SYSTEM_PACKAGES = ['@goodpatch/sparkle-design-internal'];
342
435
 
343
436
  /**
344
437
  * package.json の dependencies / devDependencies から既知のデザインシステムパッケージを検出する。
@@ -394,8 +487,17 @@ function writeCSS(cssContent, outputPath = null) {
394
487
  * メイン処理
395
488
  * @param {string|null} configPath カスタム設定ファイルのパス(オプション)
396
489
  * @param {string|null} outputPath カスタム出力パス(オプション)
490
+ * @param {string|null} globalsPath 明示指定の globals.css パス(オプション)
491
+ * @param {{ strict?: boolean }} [options] strict=true のとき、
492
+ * globals.css パッチ失敗などを throw に昇格させる(exit 1 に繋げるため)
493
+ * @returns {{ globalsResult: { status: 'skipped'|'updated'|'failed', reason?: string } }}
397
494
  */
398
- export function generateCSS(configPath = null, outputPath = null, globalsPath = null) {
495
+ export function generateCSS(
496
+ configPath = null,
497
+ outputPath = null,
498
+ globalsPath = null,
499
+ options = {}
500
+ ) {
399
501
  console.log(MESSAGES.START);
400
502
 
401
503
  // 1. 設定ファイルを読み込み
@@ -413,7 +515,13 @@ export function generateCSS(configPath = null, outputPath = null, globalsPath =
413
515
  const radiusMapping = loadRadiusMapping();
414
516
 
415
517
  // 5. テンプレートを設定値で処理(resolvedFonts も返す)
416
- const { css: processedCSS, resolvedFonts } = processTemplate(template, config, grayMapping, radiusMapping, colors);
518
+ const { css: processedCSS, resolvedFonts } = processTemplate(
519
+ template,
520
+ config,
521
+ grayMapping,
522
+ radiusMapping,
523
+ colors
524
+ );
417
525
 
418
526
  // 6. CSSファイルを書き出し
419
527
  const defaultOutputPath = path.resolve(
@@ -428,6 +536,27 @@ export function generateCSS(configPath = null, outputPath = null, globalsPath =
428
536
  const sparkleHeadContent = generateSparkleHeadContent(resolvedFonts);
429
537
  writeSparkleHead(sparkleHeadContent, resolvedOutputPath);
430
538
 
539
+ // 7.5 Vite プロジェクトの index.html に Sparkle のフォント <link> タグを upsert
540
+ // する。Vite は <head> が index.html 側にあるため、React コンポーネントの
541
+ // SparkleHead を挟めない。managed block でマーカー間だけを管理し、既存の
542
+ // head 内容は非破壊に保つ。
543
+ // en: Vite ships its <head> in index.html, outside React's reach, so mirror
544
+ // the SparkleHead link tags into a managed block there.
545
+ const cwdForHtml = process.cwd();
546
+ if (fs.existsSync(path.resolve(cwdForHtml, 'index.html'))) {
547
+ const htmlResult = upsertViteIndexHtmlFonts(cwdForHtml, resolvedFonts);
548
+ if (htmlResult.status === 'created' || htmlResult.status === 'updated') {
549
+ const displayPath = path.relative(cwdForHtml, htmlResult.path) || htmlResult.path;
550
+ console.log(
551
+ `✅ index.html の Sparkle フォント <link> ブロックを${htmlResult.status === 'created' ? '挿入' : '更新'}しました: ${displayPath}`
552
+ );
553
+ } else if (htmlResult.status === 'failed') {
554
+ console.warn(
555
+ `⚠️ index.html への Sparkle フォント <link> 挿入に失敗しました (${htmlResult.reason})。手動で <head> 内に SparkleHead.tsx 相当の <link> タグを追加してください。`
556
+ );
557
+ }
558
+ }
559
+
431
560
  // 8. フォント管理の自動処理を実行(globals.css にはフォント @import を差し込まない)
432
561
  // source-packages は以下の合成で決まる:
433
562
  // 1. package.json から既知のデザインシステムパッケージを自動検出(baseline)
@@ -437,16 +566,23 @@ export function generateCSS(configPath = null, outputPath = null, globalsPath =
437
566
  // with explicit config entries. If neither surfaces anything, @source is skipped.
438
567
  console.log(MESSAGES.FONT_MANAGEMENT_START);
439
568
  const detectedPackages = detectSourcePackagesFromPackageJson();
440
- const explicitPackages = 'source-packages' in config ? (config['source-packages'] || []) : null;
569
+ const explicitPackages = 'source-packages' in config ? config['source-packages'] || [] : null;
441
570
  const sourcePackages =
442
571
  detectedPackages !== null || explicitPackages !== null
443
572
  ? [...new Set([...(detectedPackages ?? []), ...(explicitPackages ?? [])])]
444
573
  : null;
445
574
  const customCssPath = config['custom-css'] || null;
446
575
  const globalsPathOverride = globalsPath || config['globals-path'] || null;
447
- manageFontImports(resolvedOutputPath, sourcePackages, customCssPath, globalsPathOverride);
576
+ const globalsResult = manageFontImports(
577
+ resolvedOutputPath,
578
+ sourcePackages,
579
+ customCssPath,
580
+ globalsPathOverride,
581
+ { strict: Boolean(options.strict) }
582
+ );
448
583
 
449
584
  console.log(MESSAGES.SUCCESS);
585
+ return { globalsResult };
450
586
  }
451
587
 
452
588
  // スクリプトが直接実行された場合のみメイン処理を実行