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.
@@ -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
  // スクリプトが直接実行された場合のみメイン処理を実行