sparkle-design-cli 2.0.7-beta.0 → 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.
@@ -5,7 +5,7 @@
5
5
 
6
6
  import fs from 'fs';
7
7
  import path from 'path';
8
- import { REGEX, COMMENTS, IMPORTS, MESSAGES } from './constants.js';
8
+ import { REGEX, COMMENTS, IMPORTS, MESSAGES, GLOBALS_CSS_CANDIDATES } from './constants.js';
9
9
 
10
10
  /**
11
11
  * sparkle-design.css からフォントの@import文を抽出する
@@ -58,8 +58,8 @@ function resolveNodeModulesRelPath(globalsPath) {
58
58
  */
59
59
  function createSourceBlock(sourcePackages = [], nodeModulesRel = '../node_modules') {
60
60
  const defaultPackage = 'sparkle-design';
61
- const allPackages = [defaultPackage, ...sourcePackages.filter(p => p !== defaultPackage)];
62
- const sourceLines = allPackages.map(pkg => `@source "${nodeModulesRel}/${pkg}/dist";`);
61
+ const allPackages = [defaultPackage, ...sourcePackages.filter((p) => p !== defaultPackage)];
62
+ const sourceLines = allPackages.map((pkg) => `@source "${nodeModulesRel}/${pkg}/dist";`);
63
63
  return [COMMENTS.SOURCE_DIRECTIVE, ...sourceLines].join('\n');
64
64
  }
65
65
 
@@ -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
  }
@@ -126,8 +173,14 @@ function removeExistingImports(globalsContent) {
126
173
  // 既存のフォントimportを削除
127
174
  cleaned = cleaned.replace(REGEX.EXISTING_FONT_IMPORT_BLOCK, '');
128
175
 
129
- // 既存の @source ディレクティブを削除
130
- cleaned = cleaned.replace(REGEX.SOURCE_DIRECTIVE, '');
176
+ // CLI が過去に書き込んだ「コメント + @source 連続行」ブロックのみを削除する。
177
+ // ユーザーが手書きした `@source "..."` は保持する(以前は SOURCE_DIRECTIVE
178
+ // を単体で全削除していたため、手書き @source が消える退行があった)。
179
+ // en: Remove only the managed comment + @source block that the CLI itself
180
+ // wrote in the past. User-authored @source lines must survive this step.
181
+ cleaned = cleaned.replace(REGEX.MANAGED_SOURCE_BLOCK, '');
182
+ // 念のため CLI コメント単独で孤立している残骸も掃除。
183
+ // en: Sweep up any orphan comment lines left over from historical writes.
131
184
  cleaned = cleaned.replace(REGEX.SOURCE_COMMENT, '');
132
185
 
133
186
  // 既存のsparkle-design.css importを削除
@@ -174,28 +227,47 @@ function reconstructGlobalsCss(globalsContent, sparkleImportBlock, tailwindInfo)
174
227
  const afterTailwind = globalsContent.substring(tailwindInfo.afterIndex);
175
228
 
176
229
  // Tailwind + sparkle-design.css + 残りのコンテンツ(フォント @import なし)
177
- return (
178
- beforeTailwind +
179
- tailwindInfo.match +
180
- sparkleImportBlock +
181
- afterTailwind.trimStart()
182
- );
230
+ return beforeTailwind + tailwindInfo.match + sparkleImportBlock + afterTailwind.trimStart();
183
231
  }
184
232
 
185
233
  /**
186
234
  * globals.css を構造化して管理する
187
- * - フォントimportを先頭に配置
235
+ * - フォントimportを先頭に配置(v2.0.0 以降は SparkleHead.tsx に移行したため空配列が渡る)
188
236
  * - Tailwind importを確保
189
237
  * - sparkle-design.css importをTailwindの後に配置
238
+ * - 自動検出した sourcePackages から @source ディレクティブを挿入
239
+ *
240
+ * **v2.0.7-beta.2**: 戻り値を真偽値から `{ status, reason }` の状態付きに変更。
241
+ * 呼び出し側が「作業不要でスキップ」「失敗したがデフォルトは warn 継続」を
242
+ * 区別できるようにし、`--strict` モードで失敗を exit code に反映できるよう
243
+ * にした(#31)。`skipped` は後方互換のため `false` に、`updated` は `true`
244
+ * に toBoolean で扱える想定。
245
+ *
190
246
  * @param {Array<string>} fontImports フォントimport文の配列
191
247
  * @param {string} globalsPath globals.cssのパス
192
248
  * @param {Array<string>|null} sourcePackages 追加パッケージ名の配列(null の場合は @source を生成しない)
193
249
  * @param {string|null} customCssPath custom-css ファイルの相対パス
194
- * @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 失敗等)
195
254
  */
196
- export function updateGlobalsWithFonts(fontImports, globalsPath, sourcePackages = null, customCssPath = null) {
197
- if (fontImports.length === 0) {
198
- return false;
255
+ export function updateGlobalsWithFonts(
256
+ fontImports,
257
+ globalsPath,
258
+ sourcePackages = null,
259
+ customCssPath = null,
260
+ sparkleDesignPath = null
261
+ ) {
262
+ const hasFonts = fontImports.length > 0;
263
+ const hasSourcePackages = sourcePackages !== null && sourcePackages !== undefined;
264
+ const hasCustomCss = Boolean(customCssPath);
265
+
266
+ // フォント import も @source も custom-css もないなら globals.css に触る
267
+ // 理由がないので skip。
268
+ // en: Nothing to inject, so leave globals.css alone.
269
+ if (!hasFonts && !hasSourcePackages && !hasCustomCss) {
270
+ return { status: 'skipped', reason: 'no-work' };
199
271
  }
200
272
 
201
273
  try {
@@ -205,15 +277,42 @@ export function updateGlobalsWithFonts(fontImports, globalsPath, sourcePackages
205
277
  // 2. 既存のimportを削除
206
278
  globalsContent = removeExistingImports(globalsContent);
207
279
 
208
- // 3. Tailwind import の位置を見つける
209
- 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);
210
292
  if (!tailwindInfo) {
211
- console.warn(MESSAGES.TAILWIND_NOT_FOUND);
212
- 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
+ }
213
307
  }
214
308
 
215
309
  // 4. importブロックを生成(フォント @import は SparkleHead に移行したため生成しない)
216
- const sparkleImportBlock = createSparkleImportBlock(sourcePackages, globalsPath, customCssPath);
310
+ const sparkleImportBlock = createSparkleImportBlock(
311
+ sourcePackages,
312
+ globalsPath,
313
+ customCssPath,
314
+ sparkleDesignPath
315
+ );
217
316
 
218
317
  // 5. globals.css を再構築(フォント @import なし)
219
318
  const reconstructedContent = reconstructGlobalsCss(
@@ -225,14 +324,45 @@ export function updateGlobalsWithFonts(fontImports, globalsPath, sourcePackages
225
324
  // 6. 更新したglobals.cssを書き込む
226
325
  fs.writeFileSync(globalsPath, reconstructedContent, 'utf8');
227
326
  console.log(MESSAGES.GLOBALS_UPDATED(globalsPath));
228
- return true;
327
+ return { status: 'updated' };
229
328
  } catch (error) {
230
329
  console.error(MESSAGES.GLOBALS_UPDATE_FAILED(error.message));
231
- // globals.cssの更新は必須ではないので、エラーでも処理を続行
232
- return false;
330
+ return { status: 'failed', reason: `write-error: ${error.code ?? error.message}` };
233
331
  }
234
332
  }
235
333
 
334
+ // Vite プロジェクト判定用の config 候補。setup.js 側の `VITE_CONFIG_FILES` と
335
+ // 同じ対象を見る必要がある(判定ロジックが分かれると scaffold と generate で
336
+ // 挙動が食い違う)。
337
+ // en: Vite config candidates used for project detection. Must stay in sync with
338
+ // setup.js's VITE_CONFIG_FILES so scaffold and generate agree on the layout.
339
+ const VITE_CONFIG_FILES = [
340
+ 'vite.config.ts',
341
+ 'vite.config.js',
342
+ 'vite.config.mjs',
343
+ 'vite.config.cjs',
344
+ 'vite.config.mts',
345
+ 'vite.config.cts',
346
+ ];
347
+
348
+ function isViteProject(cwd) {
349
+ return VITE_CONFIG_FILES.some((name) => fs.existsSync(path.join(cwd, name)));
350
+ }
351
+
352
+ // Vite プロジェクトで候補をソートするときの優先度。setup の
353
+ // `defaultGlobalsCssTarget` に合わせて `src/index.css` を `src/globals.css`
354
+ // より手前に引き上げる。それ以外は元の順序(数字昇順)を保つ。
355
+ // en: When the project is Vite, promote `src/index.css` ahead of
356
+ // `src/globals.css` so that `generate` patches the same file `setup` scaffolds.
357
+ function vitePriority(candidate) {
358
+ if (candidate === 'src/index.css') return 0;
359
+ if (candidate === 'src/globals.css') return 1;
360
+ // それ以外の候補は元の GLOBALS_CSS_CANDIDATES 順序を保つため、index を
361
+ // オフセット付きで返す。
362
+ // en: Preserve the original order for everything else.
363
+ return 10 + GLOBALS_CSS_CANDIDATES.indexOf(candidate);
364
+ }
365
+
236
366
  /**
237
367
  * sparkle-design.css と同じディレクトリで Tailwind のエントリポイント CSS を自動検出する
238
368
  * @param {string} dir 検索対象ディレクトリ
@@ -242,7 +372,16 @@ function detectTailwindEntrypoint(dir) {
242
372
  let entries;
243
373
  try {
244
374
  entries = fs.readdirSync(dir, { withFileTypes: true });
245
- } catch {
375
+ } catch (err) {
376
+ // ENOENT / ENOTDIR はよくある(dir 自体が無い / ファイルを渡された)ので
377
+ // 静かにスキップ。権限 (EACCES) などは debugging 価値があるので表に出す。
378
+ // en: ENOENT / ENOTDIR are expected (dir missing / path is a file). Surface
379
+ // permission-style failures so the user can debug.
380
+ if (err.code !== 'ENOENT' && err.code !== 'ENOTDIR') {
381
+ console.warn(
382
+ `⚠️ ${dir} の読み込みに失敗したため同階層 detection をスキップします (${err.code ?? err.message})`
383
+ );
384
+ }
246
385
  return null;
247
386
  }
248
387
 
@@ -251,9 +390,20 @@ function detectTailwindEntrypoint(dir) {
251
390
  if (entry.name === 'sparkle-design.css') continue;
252
391
 
253
392
  const filePath = path.join(dir, entry.name);
254
- const content = fs.readFileSync(filePath, 'utf8');
255
- if (REGEX.TAILWIND_IMPORT.test(content)) {
256
- return filePath;
393
+ try {
394
+ const content = fs.readFileSync(filePath, 'utf8');
395
+ if (REGEX.TAILWIND_IMPORT.test(content)) {
396
+ return filePath;
397
+ }
398
+ } catch (err) {
399
+ // 1 候補が読めなくても他の候補で続行できるようログを出してスキップする。
400
+ // 以前はここで throw させて外側の catch に落としていたため、同階層に
401
+ // 壊れた CSS が 1 つあると全部の detection が死んでいた。
402
+ // en: Keep scanning even if a single candidate file fails to read —
403
+ // previously a single unreadable CSS poisoned the whole detection.
404
+ console.warn(
405
+ `⚠️ ${entry.name} の読み込みに失敗したためスキップします (${err.code ?? err.message})`
406
+ );
257
407
  }
258
408
  }
259
409
 
@@ -262,10 +412,16 @@ function detectTailwindEntrypoint(dir) {
262
412
 
263
413
  /**
264
414
  * globals パスを解決する
265
- * 優先順位: 明示的指定 > 自動検出 > デフォルト(globals.css)
415
+ * 優先順位: 明示的指定 > sparkle-design.css 同階層で自動検出 > プロジェクト root の既知候補 > デフォルト(globals.css)
416
+ *
417
+ * **v2.0.7-beta.1**: Vite プロジェクトのように `sparkle-design.css` を `src/app/`
418
+ * に、entry CSS を `src/index.css` に置くレイアウトで `@source` が挿入されない
419
+ * 不具合があったため、同階層で見つからない場合はプロジェクト root からも
420
+ * 既知の候補を探すように拡張した(setup が scaffold に使う候補と同一)。
421
+ *
266
422
  * @param {string} sparkleDesignPath sparkle-design.css のパス
267
423
  * @param {string|null} explicitGlobalsPath 明示的に指定された globals パス
268
- * @returns {{ path: string, source: 'explicit' | 'detected' | 'default' } | null}
424
+ * @returns {{ path: string, source: 'explicit' | 'detected' | 'project' | 'default' } | null}
269
425
  */
270
426
  function resolveGlobalsPath(sparkleDesignPath, explicitGlobalsPath = null) {
271
427
  const dir = path.dirname(sparkleDesignPath);
@@ -275,11 +431,23 @@ function resolveGlobalsPath(sparkleDesignPath, explicitGlobalsPath = null) {
275
431
  if (fs.existsSync(resolved)) {
276
432
  return { path: resolved, source: 'explicit' };
277
433
  }
278
- console.warn(`⚠️ 指定された globals パスが見つかりません: ${explicitGlobalsPath}`);
279
- 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;
280
445
  }
281
446
 
282
- // 自動検出: @import "tailwindcss" を含む CSS ファイルを探す
447
+ // 1. 自動検出: sparkle-design.css と同じディレクトリで @import "tailwindcss" を含む
448
+ // CSS ファイルを探す。Next.js App Router のように両者が同階層に居るケース用。
449
+ // en: Look next to sparkle-design.css for a Tailwind entry CSS. Covers the
450
+ // Next.js App Router layout where both live under `src/app/`.
283
451
  const detected = detectTailwindEntrypoint(dir);
284
452
  if (detected) {
285
453
  const basename = path.basename(detected);
@@ -289,7 +457,64 @@ function resolveGlobalsPath(sparkleDesignPath, explicitGlobalsPath = null) {
289
457
  return { path: detected, source: 'detected' };
290
458
  }
291
459
 
292
- // デフォルト: globals.css
460
+ // 2. プロジェクト root から既知の候補を探す。Vite のように sparkle-design.css
461
+ // が src/app/ に、entry CSS が src/index.css にあるレイアウトに対応。
462
+ // Vite プロジェクトでは `setup` の scaffold が `src/index.css` を選ぶので、
463
+ // `src/globals.css` と両方存在していても index.css を優先する(scaffold
464
+ // した場所と generate が patch する場所が食い違わないように)。
465
+ // en: Fall back to project-wide candidates. For Vite projects, promote
466
+ // `src/index.css` above `src/globals.css` so that `generate` patches the same
467
+ // file `setup` would have scaffolded.
468
+ const cwd = process.cwd();
469
+ const candidates = isViteProject(cwd)
470
+ ? GLOBALS_CSS_CANDIDATES.slice().sort((a, b) => vitePriority(a) - vitePriority(b))
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;
485
+ for (const candidate of candidates) {
486
+ const absolute = path.resolve(cwd, candidate);
487
+ if (!fs.existsSync(absolute)) continue;
488
+ if (path.resolve(absolute) === path.resolve(sparkleDesignPath)) continue;
489
+ try {
490
+ const content = fs.readFileSync(absolute, 'utf8');
491
+ if (REGEX.TAILWIND_IMPORT.test(content)) {
492
+ console.log(`📝 Tailwind エントリポイントを検出しました(project root): ${candidate}`);
493
+ return { path: absolute, source: 'project' };
494
+ }
495
+ if (!fallbackExistingPath) {
496
+ fallbackExistingPath = { path: absolute, candidate };
497
+ }
498
+ } catch (err) {
499
+ // ENOENT 系は existsSync で既に弾いているので、ここに来るのは権限不足や
500
+ // ディレクトリ衝突などデバッグ価値のあるケース。silent に消さずに理由を出す。
501
+ // en: ENOENT is already filtered by existsSync above, so surfacing the
502
+ // error code here catches permission / type issues worth debugging.
503
+ console.warn(
504
+ `⚠️ 候補 ${candidate} の読み込みに失敗したためスキップします (${err.code ?? err.message})`
505
+ );
506
+ }
507
+ }
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
+
516
+ // 3. デフォルト: sparkle-design.css と同じディレクトリの globals.css
517
+ // en: Last resort — a globals.css next to sparkle-design.css.
293
518
  const defaultPath = path.join(dir, 'globals.css');
294
519
  if (fs.existsSync(defaultPath)) {
295
520
  return { path: defaultPath, source: 'default' };
@@ -301,19 +526,54 @@ function resolveGlobalsPath(sparkleDesignPath, explicitGlobalsPath = null) {
301
526
  /**
302
527
  * フォント管理の自動処理を実行する
303
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
+ *
304
536
  * @param {string} sparkleDesignPath sparkle-design.cssのパス
305
537
  * @param {Array<string>|null} sourcePackages 追加パッケージ名の配列(null の場合は @source を生成しない)
306
538
  * @param {string|null} customCssPath custom-css ファイルの相対パス
307
539
  * @param {string|null} globalsPathOverride 明示的に指定された globals パス
540
+ * @param {{ strict?: boolean }} [options] strict=true のとき失敗を throw する
541
+ * @returns {{ status: 'skipped' | 'updated' | 'failed', reason?: string }}
308
542
  */
309
- export function manageFontImports(sparkleDesignPath, sourcePackages = null, customCssPath = null, globalsPathOverride = null) {
543
+ export function manageFontImports(
544
+ sparkleDesignPath,
545
+ sourcePackages = null,
546
+ customCssPath = null,
547
+ globalsPathOverride = null,
548
+ options = {}
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
+
310
563
  try {
311
564
  // 1. globals パスを解決
312
565
  const resolved = resolveGlobalsPath(sparkleDesignPath, globalsPathOverride);
313
566
 
314
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
+ }
315
575
  console.log(MESSAGES.GLOBALS_NOT_FOUND);
316
- return;
576
+ return { status: 'skipped', reason: 'entry-css-not-found-no-work' };
317
577
  }
318
578
 
319
579
  const globalsPath = resolved.path;
@@ -322,27 +582,57 @@ export function manageFontImports(sparkleDesignPath, sourcePackages = null, cust
322
582
  const sparkleContent = fs.readFileSync(sparkleDesignPath, 'utf8');
323
583
  const fontImports = extractFontImports(sparkleContent);
324
584
 
325
- if (fontImports.length === 0) {
326
- console.log(MESSAGES.NO_FONT_IMPORTS);
327
- return;
585
+ // v2.0.0 以降はフォント @import が SparkleHead.tsx に移行したため、
586
+ // sparkle-design.css に font @import が残らないのが通常状態。それでも
587
+ // 既存 globals.css には `@source` / sparkle-design.css import /
588
+ // custom-css import を挿入する必要があるので、単に fonts が空だからと
589
+ // いって早期 return せず、下流の update 関数に判断を委ねる。
590
+ // en: Fonts moved to SparkleHead in v2.0.0, so "no font imports in
591
+ // sparkle-design.css" is now the normal case. Don't bail out here —
592
+ // `updateGlobalsWithFonts` still needs to inject `@source` and the
593
+ // sparkle-design.css import into existing globals.css.
594
+ if (fontImports.length > 0) {
595
+ console.log(MESSAGES.FONT_DETECTED(fontImports.length));
328
596
  }
329
597
 
330
- console.log(MESSAGES.FONT_DETECTED(fontImports.length));
331
-
332
- // 4. globals.css にフォントimportを追加
333
- const globalsUpdated = updateGlobalsWithFonts(fontImports, globalsPath, sourcePackages, customCssPath);
598
+ // 4. globals.css に import / @source を追加
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(
606
+ fontImports,
607
+ globalsPath,
608
+ sourcePackages,
609
+ customCssPath,
610
+ sparkleDesignPath
611
+ );
334
612
 
335
- if (!globalsUpdated) {
336
- console.log(MESSAGES.FONT_REMOVE_SKIPPED);
337
- return;
613
+ if (result.status === 'skipped') {
614
+ return result;
615
+ }
616
+ if (result.status === 'failed') {
617
+ return raiseOrReturn(result);
338
618
  }
339
619
 
340
- // 5. sparkle-design.css からフォントimportを削除
341
- const cleanedSparkleContent = removeFontImportsFromCSS(sparkleContent);
342
- fs.writeFileSync(sparkleDesignPath, cleanedSparkleContent, 'utf8');
343
- console.log(MESSAGES.FONT_REMOVED);
620
+ // 5. fonts が sparkle-design.css 側に残っていれば削除(globals 側に移動済みの前提)
621
+ // en: If there were fonts to move, strip them from sparkle-design.css.
622
+ if (fontImports.length > 0) {
623
+ const cleanedSparkleContent = removeFontImportsFromCSS(sparkleContent);
624
+ fs.writeFileSync(sparkleDesignPath, cleanedSparkleContent, 'utf8');
625
+ console.log(MESSAGES.FONT_REMOVED);
626
+ }
627
+ return result;
344
628
  } catch (error) {
345
629
  console.error(MESSAGES.FONT_MANAGEMENT_ERROR(error.message));
346
- // フォント管理は必須ではないので、エラーでも処理を続行
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}` };
347
637
  }
348
638
  }