sparkle-design-cli 1.5.1 → 1.6.1

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.
@@ -18,6 +18,7 @@ function parseGenerateOptions(args) {
18
18
  const options = {
19
19
  configPath: null,
20
20
  outputPath: null,
21
+ globalsPath: null,
21
22
  help: false,
22
23
  };
23
24
 
@@ -32,6 +33,9 @@ function parseGenerateOptions(args) {
32
33
  } else if (arg === '-o' || arg === '--output') {
33
34
  options.outputPath = requireOptionValue(args, i, '-o/--output');
34
35
  i += 1;
36
+ } else if (arg === '--globals-path') {
37
+ options.globalsPath = requireOptionValue(args, i, '--globals-path');
38
+ i += 1;
35
39
  } else {
36
40
  throw new Error(`Unknown option for generate: ${arg}`);
37
41
  }
@@ -76,6 +80,7 @@ function parseSetupOptions(args) {
76
80
  const options = {
77
81
  assistant: 'generic',
78
82
  target: null,
83
+ instructionsPath: null,
79
84
  dryRun: false,
80
85
  forceScriptUpdate: false,
81
86
  help: false,
@@ -92,6 +97,9 @@ function parseSetupOptions(args) {
92
97
  } else if (arg === '--target') {
93
98
  options.target = requireOptionValue(args, i, '--target');
94
99
  i += 1;
100
+ } else if (arg === '--instructions-path') {
101
+ options.instructionsPath = requireOptionValue(args, i, '--instructions-path');
102
+ i += 1;
95
103
  } else if (arg === '--dry-run') {
96
104
  options.dryRun = true;
97
105
  } else if (arg === '--force-script-update') {
@@ -138,11 +146,13 @@ Setup:
138
146
  sparkle-design-cli setup --assistant codex --target src
139
147
  sparkle-design-cli setup --assistant cursor --dry-run
140
148
  sparkle-design-cli setup --assistant claude --force-script-update
149
+ sparkle-design-cli setup --assistant claude --instructions-path app/admin/CLAUDE.md
141
150
 
142
151
  Generate options:
143
152
  -h, --help このヘルプメッセージを表示
144
153
  -c, --config <path> 設定ファイルのパス (default: ./sparkle.config.json)
145
154
  -o, --output <path> 出力ファイルのパス (default: ./src/app/sparkle-design.css)
155
+ --globals-path <path> Tailwind エントリポイント CSS のパス (default: 自動検出)
146
156
 
147
157
  sparkle.config.json の設定フィールド:
148
158
 
@@ -162,6 +172,10 @@ sparkle.config.json の設定フィールド:
162
172
  extend.source-packages @source で追加スキャンする npm パッケージ名の配列
163
173
  extend.custom-css プロジェクト固有のカスタムトークン CSS ファイルパス
164
174
 
175
+ その他:
176
+ globals-path Tailwind エントリポイント CSS のパス(未指定時は自動検出)
177
+ Vite の場合: "src/index.css" など
178
+
165
179
  extend.fonts がある場合、font-pro / font-mono より優先される。
166
180
  ない場合は font-pro / font-mono + デフォルトウェイト [400, 700] が使われる。
167
181
  同じフォントファミリーが pro と mono で重複する場合、ウェイトはマージされ import は 1 行になる。
@@ -179,6 +193,7 @@ Setup options:
179
193
  --assistant <name> claude / codex / cursor / generic (default: generic)
180
194
  --target <path> lint 対象パス (default: auto-detect)
181
195
  --dry-run ファイルを変更せず結果だけ表示
196
+ --instructions-path <path> AI 指示ファイルの出力先パス (default: アシスタント別のデフォルト)
182
197
  --force-script-update 既存の lint:sparkle 系 script も上書きする
183
198
 
184
199
  Check で検出する主なパターン:
@@ -195,8 +210,14 @@ Check で検出する主なパターン:
195
210
  - disabled を isDisabled の代わりに使っている
196
211
  - Button の prefixIcon に JSX を渡している
197
212
  - Icon の children にテキストを渡している
213
+ - globals.css にフォント @import が残っている(SparkleHead への移行が必要)
214
+ - CSP ヘッダーで Google Fonts がブロックされている可能性
198
215
 
199
216
  JSON 出力では manualReviewReminders も返すため、AI から実行する場合は --format json を推奨
217
+
218
+ generate で生成されるファイル:
219
+ sparkle-design.css デザイントークン CSS
220
+ SparkleHead.tsx フォント読み込み用 React コンポーネント(layout.tsx の <head> に配置)
200
221
  `);
201
222
  }
202
223
 
@@ -230,7 +251,7 @@ function main() {
230
251
  process.exit(0);
231
252
  }
232
253
 
233
- generateCSS(options.configPath, options.outputPath);
254
+ generateCSS(options.configPath, options.outputPath, options.globalsPath);
234
255
  return;
235
256
  }
236
257
 
@@ -273,7 +294,7 @@ function main() {
273
294
  process.exit(0);
274
295
  }
275
296
 
276
- generateCSS(options.configPath, options.outputPath);
297
+ generateCSS(options.configPath, options.outputPath, options.globalsPath);
277
298
  return;
278
299
  }
279
300
 
package/lib/check.js CHANGED
@@ -2,17 +2,26 @@ import fs from 'fs';
2
2
  import path from 'path';
3
3
 
4
4
  import { getCheckRules, getManualReviewReminders } from './anti-pattern-rules.js';
5
+ import { REGEX, FONT_DOMAINS } from './constants.js';
5
6
 
6
7
  const DEFAULT_TARGET = 'src';
7
8
  const TEXT_EXTENSIONS = new Set(['.js', '.jsx', '.ts', '.tsx']);
9
+ const CSS_EXTENSIONS = new Set(['.css']);
8
10
  const RULES = getCheckRules();
9
- const MANUAL_REVIEW_REMINDERS = getManualReviewReminders();
11
+ const BASE_MANUAL_REVIEW_REMINDERS = getManualReviewReminders();
12
+
13
+ const SPARKLE_HEAD_JSX_PATTERN = /<SparkleHead\s*\/?\s*>/;
14
+ const CSP_PATTERN = /content-security-policy|contentSecurityPolicy|Content-Security-Policy/i;
15
+ const NEXT_CONFIG_CANDIDATES = ['next.config.js', 'next.config.ts', 'next.config.mjs', 'next.config.cjs'];
10
16
 
11
17
  function toRelativeReportPath(filePath) {
12
18
  return path.relative(process.cwd(), filePath).split(path.sep).join('/');
13
19
  }
14
20
 
15
- function collectFiles(targetPath, files, visited) {
21
+ /**
22
+ * ディレクトリを1回だけ走査し、テキストファイルと CSS ファイルを同時に収集する
23
+ */
24
+ function collectFiles(targetPath, textFiles, cssFiles, visited) {
16
25
  if (!fs.existsSync(targetPath)) {
17
26
  throw new Error(`Target path does not exist: ${targetPath}`);
18
27
  }
@@ -26,8 +35,11 @@ function collectFiles(targetPath, files, visited) {
26
35
  const stat = fs.statSync(realPath);
27
36
 
28
37
  if (stat.isFile()) {
29
- if (TEXT_EXTENSIONS.has(path.extname(realPath))) {
30
- files.add(realPath);
38
+ const ext = path.extname(realPath);
39
+ if (TEXT_EXTENSIONS.has(ext)) {
40
+ textFiles.add(realPath);
41
+ } else if (CSS_EXTENSIONS.has(ext)) {
42
+ cssFiles.add(realPath);
31
43
  }
32
44
  return;
33
45
  }
@@ -41,7 +53,7 @@ function collectFiles(targetPath, files, visited) {
41
53
  continue;
42
54
  }
43
55
 
44
- collectFiles(path.join(realPath, entry.name), files, visited);
56
+ collectFiles(path.join(realPath, entry.name), textFiles, cssFiles, visited);
45
57
  }
46
58
  }
47
59
 
@@ -53,8 +65,7 @@ function formatMatch(match) {
53
65
  return match[0].replace(/\s+/g, ' ').trim().slice(0, 120);
54
66
  }
55
67
 
56
- function collectFindings(filePath) {
57
- const content = fs.readFileSync(filePath, 'utf8');
68
+ function collectFindings(filePath, content) {
58
69
  const findings = [];
59
70
 
60
71
  for (const rule of RULES) {
@@ -73,42 +84,137 @@ function collectFindings(filePath) {
73
84
  return findings;
74
85
  }
75
86
 
87
+ /**
88
+ * CSS ファイルからフォント @import の残存を検出する
89
+ */
90
+ function collectFontImportFindings(cssFiles) {
91
+ const findings = [];
92
+
93
+ for (const filePath of cssFiles) {
94
+ const content = fs.readFileSync(filePath, 'utf8');
95
+ for (const match of content.matchAll(REGEX.FONT_IMPORT)) {
96
+ findings.push({
97
+ filePath,
98
+ id: 'font-import-in-css',
99
+ description: 'CSS にフォント @import が残っています。SparkleHead コンポーネントに移行してください。',
100
+ recommendation: 'sparkle-design-cli generate を再実行すると、フォント読み込みが SparkleHead.tsx に移行され、globals.css から @import が除去されます。',
101
+ line: getLineNumber(content, match.index ?? 0),
102
+ snippet: match[0].slice(0, 120),
103
+ });
104
+ }
105
+ }
106
+
107
+ return findings;
108
+ }
109
+
110
+ /**
111
+ * ソースコード内に <SparkleHead /> の JSX 使用があるか検出する
112
+ * SparkleHead.tsx 自体の定義は除外し、実際に JSX として使用している箇所のみ検出する
113
+ */
114
+ function hasSparkleHeadUsage(fileContents) {
115
+ for (const [filePath, content] of fileContents) {
116
+ if (path.basename(filePath) === 'SparkleHead.tsx') continue;
117
+ if (SPARKLE_HEAD_JSX_PATTERN.test(content)) {
118
+ return true;
119
+ }
120
+ }
121
+ return false;
122
+ }
123
+
124
+ /**
125
+ * Next.js の CSP ヘッダー設定を検出し、Google Fonts ドメインの許可を確認する
126
+ */
127
+ function collectNextjsCspFindings(cwd) {
128
+ const findings = [];
129
+
130
+ for (const candidate of NEXT_CONFIG_CANDIDATES) {
131
+ const configPath = path.resolve(cwd, candidate);
132
+
133
+ let content;
134
+ try {
135
+ content = fs.readFileSync(configPath, 'utf8');
136
+ } catch {
137
+ continue;
138
+ }
139
+
140
+ if (!CSP_PATTERN.test(content)) continue;
141
+
142
+ const missingDomains = [
143
+ { domain: 'fonts.googleapis.com', directive: 'style-src', pattern: new RegExp(FONT_DOMAINS.GOOGLEAPIS.replace('https://', '')) },
144
+ { domain: 'fonts.gstatic.com', directive: 'font-src', pattern: new RegExp(FONT_DOMAINS.GSTATIC.replace('https://', '')) },
145
+ ].filter(({ pattern }) => !pattern.test(content));
146
+
147
+ if (missingDomains.length > 0) {
148
+ const missing = missingDomains.map(d => `${d.domain} (${d.directive})`).join(', ');
149
+ findings.push({
150
+ filePath: configPath,
151
+ id: 'csp-font-block',
152
+ description: `CSP ヘッダーが設定されていますが、${missing} が許可されていない可能性があります。`,
153
+ recommendation: `style-src に ${FONT_DOMAINS.GOOGLEAPIS} を、font-src に ${FONT_DOMAINS.GSTATIC} を追加してください。`,
154
+ line: 1,
155
+ snippet: `Content-Security-Policy detected, missing: ${missing}`,
156
+ });
157
+ }
158
+ break;
159
+ }
160
+
161
+ return findings;
162
+ }
163
+
76
164
  function createCheckReport(targets = []) {
77
165
  const resolvedTargets = targets.length > 0 ? targets : [DEFAULT_TARGET];
78
- const files = new Set();
166
+ const textFiles = new Set();
167
+ const cssFiles = new Set();
79
168
  const visited = new Set();
80
169
 
170
+ // 1回のディレクトリ走査でテキストファイルと CSS ファイルを同時に収集
81
171
  for (const target of resolvedTargets) {
82
- collectFiles(path.resolve(process.cwd(), target), files, visited);
172
+ collectFiles(path.resolve(process.cwd(), target), textFiles, cssFiles, visited);
83
173
  }
84
174
 
85
- const checkedFiles = [...files]
175
+ const checkedFiles = [...textFiles]
86
176
  .map((filePath) => toRelativeReportPath(filePath))
87
177
  .sort((left, right) => left.localeCompare(right));
88
- const findings = [...files]
89
- .flatMap(collectFindings)
178
+
179
+ // ファイル内容を1回だけ読み込んでキャッシュ(collectFindings + hasSparkleHeadUsage で再利用)
180
+ const fileContents = new Map();
181
+ for (const filePath of textFiles) {
182
+ fileContents.set(filePath, fs.readFileSync(filePath, 'utf8'));
183
+ }
184
+
185
+ const findings = [...fileContents.entries()]
186
+ .flatMap(([filePath, content]) => collectFindings(filePath, content))
90
187
  .map((finding) => ({
91
188
  ...finding,
92
189
  filePath: toRelativeReportPath(finding.filePath),
93
- }))
94
- .sort((left, right) => {
95
- const fileComparison = left.filePath.localeCompare(right.filePath);
96
- if (fileComparison !== 0) {
97
- return fileComparison;
98
- }
99
-
100
- if (left.line !== right.line) {
101
- return left.line - right.line;
102
- }
103
-
104
- return left.id.localeCompare(right.id);
190
+ }));
191
+
192
+ const fontFindings = collectFontImportFindings(cssFiles);
193
+ findings.push(...fontFindings.map((f) => ({ ...f, filePath: toRelativeReportPath(f.filePath) })));
194
+
195
+ const cspFindings = collectNextjsCspFindings(process.cwd());
196
+ findings.push(...cspFindings.map((f) => ({ ...f, filePath: toRelativeReportPath(f.filePath) })));
197
+
198
+ findings.sort((left, right) => {
199
+ const fileComparison = left.filePath.localeCompare(right.filePath);
200
+ if (fileComparison !== 0) return fileComparison;
201
+ if (left.line !== right.line) return left.line - right.line;
202
+ return left.id.localeCompare(right.id);
203
+ });
204
+
205
+ const manualReviewReminders = [...BASE_MANUAL_REVIEW_REMINDERS];
206
+ if (!hasSparkleHeadUsage(fileContents)) {
207
+ manualReviewReminders.push({
208
+ id: 'sparkle-head-missing',
209
+ message: 'SparkleHead がソースコード内に見つかりませんでした。ルートレイアウトの <head> 内に <SparkleHead /> を配置してフォントの早期読み込みを有効にしてください。',
105
210
  });
211
+ }
106
212
 
107
213
  return {
108
214
  targets: resolvedTargets,
109
215
  checkedFiles,
110
216
  findings,
111
- manualReviewReminders: MANUAL_REVIEW_REMINDERS,
217
+ manualReviewReminders,
112
218
  };
113
219
  }
114
220
 
@@ -171,4 +277,4 @@ export function checkProject(targets = [], options = {}) {
171
277
  return report.findings.length > 0;
172
278
  }
173
279
 
174
- export { RULES, collectFindings, createCheckReport, MANUAL_REVIEW_REMINDERS };
280
+ export { RULES, collectFindings, createCheckReport, BASE_MANUAL_REVIEW_REMINDERS as MANUAL_REVIEW_REMINDERS };
package/lib/constants.js CHANGED
@@ -56,6 +56,12 @@ export const PLACEHOLDERS = {
56
56
  FONT_FAMILY_MONO: '{{FONT_FAMILY_MONO}}',
57
57
  };
58
58
 
59
+ // Google Fonts ドメイン
60
+ export const FONT_DOMAINS = {
61
+ GOOGLEAPIS: 'https://fonts.googleapis.com',
62
+ GSTATIC: 'https://fonts.gstatic.com',
63
+ };
64
+
59
65
  // フォントのデフォルト設定
60
66
  export const FONT_DEFAULTS = {
61
67
  WEIGHTS: [400, 700],
@@ -172,19 +172,18 @@ function findTailwindImport(globalsContent) {
172
172
 
173
173
  /**
174
174
  * globals.css を再構築する
175
+ * フォント @import は SparkleHead.tsx に移行したため、globals.css には差し込まない
175
176
  * @param {string} globalsContent 元のglobals.cssの内容
176
- * @param {string} fontImportBlock フォントimportブロック
177
177
  * @param {string} sparkleImportBlock sparkle-design.css importブロック
178
178
  * @param {Object} tailwindInfo Tailwind importの情報
179
179
  * @returns {string} 再構築されたglobals.css
180
180
  */
181
- function reconstructGlobalsCss(globalsContent, fontImportBlock, sparkleImportBlock, tailwindInfo) {
181
+ function reconstructGlobalsCss(globalsContent, sparkleImportBlock, tailwindInfo) {
182
182
  const beforeTailwind = globalsContent.substring(0, tailwindInfo.index);
183
183
  const afterTailwind = globalsContent.substring(tailwindInfo.afterIndex);
184
184
 
185
- // フォントimport + Tailwind + sparkle-design.css + 残りのコンテンツ
185
+ // Tailwind + sparkle-design.css + 残りのコンテンツ(フォント @import なし)
186
186
  return (
187
- fontImportBlock +
188
187
  beforeTailwind +
189
188
  tailwindInfo.match +
190
189
  sparkleImportBlock +
@@ -222,14 +221,12 @@ export function updateGlobalsWithFonts(fontImports, globalsPath, sourcePackages
222
221
  return false;
223
222
  }
224
223
 
225
- // 4. importブロックを生成
226
- const fontImportBlock = createFontImportBlock(fontImports);
224
+ // 4. importブロックを生成(フォント @import は SparkleHead に移行したため生成しない)
227
225
  const sparkleImportBlock = createSparkleImportBlock(sourcePackages, globalsPath, customCssPath);
228
226
 
229
- // 5. globals.css を再構築
227
+ // 5. globals.css を再構築(フォント @import なし)
230
228
  const reconstructedContent = reconstructGlobalsCss(
231
229
  globalsContent,
232
- fontImportBlock,
233
230
  sparkleImportBlock,
234
231
  tailwindInfo
235
232
  );
@@ -245,24 +242,91 @@ export function updateGlobalsWithFonts(fontImports, globalsPath, sourcePackages
245
242
  }
246
243
  }
247
244
 
245
+ /**
246
+ * sparkle-design.css と同じディレクトリで Tailwind のエントリポイント CSS を自動検出する
247
+ * @param {string} dir 検索対象ディレクトリ
248
+ * @returns {string|null} 検出されたファイルの絶対パス、見つからなければ null
249
+ */
250
+ function detectTailwindEntrypoint(dir) {
251
+ let entries;
252
+ try {
253
+ entries = fs.readdirSync(dir, { withFileTypes: true });
254
+ } catch {
255
+ return null;
256
+ }
257
+
258
+ for (const entry of entries) {
259
+ if (!entry.isFile() || path.extname(entry.name) !== '.css') continue;
260
+ if (entry.name === 'sparkle-design.css') continue;
261
+
262
+ const filePath = path.join(dir, entry.name);
263
+ const content = fs.readFileSync(filePath, 'utf8');
264
+ if (REGEX.TAILWIND_IMPORT.test(content)) {
265
+ return filePath;
266
+ }
267
+ }
268
+
269
+ return null;
270
+ }
271
+
272
+ /**
273
+ * globals パスを解決する
274
+ * 優先順位: 明示的指定 > 自動検出 > デフォルト(globals.css)
275
+ * @param {string} sparkleDesignPath sparkle-design.css のパス
276
+ * @param {string|null} explicitGlobalsPath 明示的に指定された globals パス
277
+ * @returns {{ path: string, source: 'explicit' | 'detected' | 'default' } | null}
278
+ */
279
+ function resolveGlobalsPath(sparkleDesignPath, explicitGlobalsPath = null) {
280
+ const dir = path.dirname(sparkleDesignPath);
281
+
282
+ if (explicitGlobalsPath) {
283
+ const resolved = path.resolve(process.cwd(), explicitGlobalsPath);
284
+ if (fs.existsSync(resolved)) {
285
+ return { path: resolved, source: 'explicit' };
286
+ }
287
+ console.warn(`⚠️ 指定された globals パスが見つかりません: ${explicitGlobalsPath}`);
288
+ return null;
289
+ }
290
+
291
+ // 自動検出: @import "tailwindcss" を含む CSS ファイルを探す
292
+ const detected = detectTailwindEntrypoint(dir);
293
+ if (detected) {
294
+ const basename = path.basename(detected);
295
+ if (basename !== 'globals.css') {
296
+ console.log(`📝 Tailwind エントリポイントを自動検出しました: ${basename}`);
297
+ }
298
+ return { path: detected, source: 'detected' };
299
+ }
300
+
301
+ // デフォルト: globals.css
302
+ const defaultPath = path.join(dir, 'globals.css');
303
+ if (fs.existsSync(defaultPath)) {
304
+ return { path: defaultPath, source: 'default' };
305
+ }
306
+
307
+ return null;
308
+ }
309
+
248
310
  /**
249
311
  * フォント管理の自動処理を実行する
250
- * sparkle-design.css からフォントimportを抽出し、globals.css に移動する
312
+ * sparkle-design.css からフォントimportを抽出し、Tailwind エントリポイント CSS に @source 等を挿入する
251
313
  * @param {string} sparkleDesignPath sparkle-design.cssのパス
252
314
  * @param {Array<string>|null} sourcePackages 追加パッケージ名の配列(null の場合は @source を生成しない)
253
315
  * @param {string|null} customCssPath custom-css ファイルの相対パス
316
+ * @param {string|null} globalsPathOverride 明示的に指定された globals パス
254
317
  */
255
- export function manageFontImports(sparkleDesignPath, sourcePackages = null, customCssPath = null) {
318
+ export function manageFontImports(sparkleDesignPath, sourcePackages = null, customCssPath = null, globalsPathOverride = null) {
256
319
  try {
257
- // 1. globals.cssのパスを推定(sparkle-design.cssと同じディレクトリ)
258
- const globalsPath = path.join(path.dirname(sparkleDesignPath), 'globals.css');
320
+ // 1. globals パスを解決
321
+ const resolved = resolveGlobalsPath(sparkleDesignPath, globalsPathOverride);
259
322
 
260
- // 2. globals.cssが存在しない場合はスキップ
261
- if (!fs.existsSync(globalsPath)) {
323
+ if (!resolved) {
262
324
  console.log(MESSAGES.GLOBALS_NOT_FOUND);
263
325
  return;
264
326
  }
265
327
 
328
+ const globalsPath = resolved.path;
329
+
266
330
  // 3. sparkle-design.css からフォントimportを抽出
267
331
  const sparkleContent = fs.readFileSync(sparkleDesignPath, 'utf8');
268
332
  const fontImports = extractFontImports(sparkleContent);
@@ -7,7 +7,7 @@
7
7
 
8
8
  import fs from 'fs';
9
9
  import path from 'path';
10
- import { REGEX, PATHS, MESSAGES, FONT_DEFAULTS } from './constants.js';
10
+ import { REGEX, PATHS, MESSAGES, FONT_DEFAULTS, FONT_DOMAINS } from './constants.js';
11
11
  import {
12
12
  loadConfig,
13
13
  loadTemplate,
@@ -181,6 +181,80 @@ function generateFontImportsBlock(configOrResolved) {
181
181
  return imports.join('\n');
182
182
  }
183
183
 
184
+ /**
185
+ * @import 文から Google Fonts の URL を抽出する
186
+ * @param {string} importLine @import 文
187
+ * @returns {string} URL
188
+ */
189
+ function extractUrlFromImport(importLine) {
190
+ const match = importLine.match(/@import\s+['"]([^'"]+)['"]/);
191
+ return match ? match[1] : '';
192
+ }
193
+
194
+ /**
195
+ * SparkleHead.tsx の内容を生成する
196
+ * sparkle.config.json のフォント設定に基づき、preconnect + link タグを含む React コンポーネントを生成
197
+ * @param {Object} resolvedFonts resolveFontConfig の結果
198
+ * @returns {string} SparkleHead.tsx のファイル内容
199
+ */
200
+ function generateSparkleHeadContent(resolvedFonts) {
201
+ const materialSymbolsUrl = extractUrlFromImport(FONT_DEFAULTS.MATERIAL_SYMBOLS_IMPORT);
202
+ const fontImportLines = generateMergedFontImports([...resolvedFonts.pro, ...resolvedFonts.mono]);
203
+ const fontUrls = fontImportLines.map(extractUrlFromImport).filter(Boolean);
204
+
205
+ const linkTags = [
206
+ ` <link rel="preconnect" href="${FONT_DOMAINS.GOOGLEAPIS}" />`,
207
+ ` <link rel="preconnect" href="${FONT_DOMAINS.GSTATIC}" crossOrigin="anonymous" />`,
208
+ ` <link rel="stylesheet" href="${materialSymbolsUrl}" />`,
209
+ ...fontUrls.map(url => ` <link rel="stylesheet" href="${url}" />`),
210
+ ];
211
+
212
+ return `/**
213
+ * Copyright 2026 Goodpatch Inc.
214
+ * SPDX-License-Identifier: Apache-2.0
215
+ */
216
+ /**
217
+ * SparkleHead - フォント読み込みコンポーネント
218
+ *
219
+ * sparkle-design-cli generate により sparkle.config.json から自動生成されます。
220
+ * ルートレイアウトの <head> 内に配置してください。
221
+ *
222
+ * @example
223
+ * import { SparkleHead } from "./SparkleHead";
224
+ *
225
+ * export default function RootLayout({ children }) {
226
+ * return (
227
+ * <html>
228
+ * <head>
229
+ * <SparkleHead />
230
+ * </head>
231
+ * <body>{children}</body>
232
+ * </html>
233
+ * );
234
+ * }
235
+ */
236
+ export function SparkleHead() {
237
+ return (
238
+ <>
239
+ ${linkTags.join('\n')}
240
+ </>
241
+ );
242
+ }
243
+ `;
244
+ }
245
+
246
+ /**
247
+ * SparkleHead.tsx を書き出す
248
+ * @param {string} content SparkleHead.tsx の内容
249
+ * @param {string} sparkleDesignCssPath sparkle-design.css の出力パス(同じディレクトリに配置)
250
+ */
251
+ function writeSparkleHead(content, sparkleDesignCssPath) {
252
+ const outputPath = path.join(path.dirname(sparkleDesignCssPath), 'SparkleHead.tsx');
253
+ fs.writeFileSync(outputPath, content, 'utf8');
254
+ console.log(`✅ SparkleHead.tsx を生成しました: ${outputPath}`);
255
+ console.log(' → ルートレイアウトの <head> 内に <SparkleHead /> を追加してください');
256
+ }
257
+
184
258
  /**
185
259
  * テンプレート変数を設定値で置換する
186
260
  * @param {string} template CSSテンプレート
@@ -215,7 +289,7 @@ function processTemplate(template, config, grayMapping, radiusMapping, colors) {
215
289
  // 5. 基本的な設定値による置換(オブジェクト・配列・拡張フィールドはスキップ)
216
290
  Object.entries(config).forEach(([key, value]) => {
217
291
  // 配列・オブジェクト・拡張フィールドはスキップ
218
- if (Array.isArray(value) || (typeof value === 'object' && value !== null) || key === 'custom-css' || key === 'fonts' || key === 'extend' || key === 'source-packages') {
292
+ if (Array.isArray(value) || (typeof value === 'object' && value !== null) || key === 'custom-css' || key === 'fonts' || key === 'extend' || key === 'source-packages' || key === 'globals-path') {
219
293
  return;
220
294
  }
221
295
  // 通常のプレースホルダー(CSS用 - スペースはそのまま)
@@ -250,7 +324,7 @@ function processTemplate(template, config, grayMapping, radiusMapping, colors) {
250
324
  processedCSS = dedupeFontImports(processedCSS);
251
325
 
252
326
  console.log(MESSAGES.TEMPLATE_PROCESSED);
253
- return processedCSS;
327
+ return { css: processedCSS, resolvedFonts };
254
328
  }
255
329
 
256
330
  /**
@@ -287,7 +361,7 @@ function writeCSS(cssContent, outputPath = null) {
287
361
  * @param {string|null} configPath カスタム設定ファイルのパス(オプション)
288
362
  * @param {string|null} outputPath カスタム出力パス(オプション)
289
363
  */
290
- export function generateCSS(configPath = null, outputPath = null) {
364
+ export function generateCSS(configPath = null, outputPath = null, globalsPath = null) {
291
365
  console.log(MESSAGES.START);
292
366
 
293
367
  // 1. 設定ファイルを読み込み
@@ -304,8 +378,8 @@ export function generateCSS(configPath = null, outputPath = null) {
304
378
  const grayMapping = loadGrayMapping();
305
379
  const radiusMapping = loadRadiusMapping();
306
380
 
307
- // 5. テンプレートを設定値で処理
308
- const processedCSS = processTemplate(template, config, grayMapping, radiusMapping, colors);
381
+ // 5. テンプレートを設定値で処理(resolvedFonts も返す)
382
+ const { css: processedCSS, resolvedFonts } = processTemplate(template, config, grayMapping, radiusMapping, colors);
309
383
 
310
384
  // 6. CSSファイルを書き出し
311
385
  const defaultOutputPath = path.resolve(
@@ -316,11 +390,16 @@ export function generateCSS(configPath = null, outputPath = null) {
316
390
  const resolvedOutputPath = outputPath ? path.resolve(outputPath) : defaultOutputPath;
317
391
  writeCSS(processedCSS, outputPath);
318
392
 
319
- // 7. フォント管理の自動処理を実行
393
+ // 7. SparkleHead.tsx を生成(processTemplate で解決済みの resolvedFonts を再利用)
394
+ const sparkleHeadContent = generateSparkleHeadContent(resolvedFonts);
395
+ writeSparkleHead(sparkleHeadContent, resolvedOutputPath);
396
+
397
+ // 8. フォント管理の自動処理を実行(globals.css にはフォント @import を差し込まない)
320
398
  console.log(MESSAGES.FONT_MANAGEMENT_START);
321
399
  const sourcePackages = 'source-packages' in config ? (config['source-packages'] || []) : null;
322
400
  const customCssPath = config['custom-css'] || null;
323
- manageFontImports(resolvedOutputPath, sourcePackages, customCssPath);
401
+ const globalsPathOverride = globalsPath || config['globals-path'] || null;
402
+ manageFontImports(resolvedOutputPath, sourcePackages, customCssPath, globalsPathOverride);
324
403
 
325
404
  console.log(MESSAGES.SUCCESS);
326
405
  }
package/lib/setup.js CHANGED
@@ -43,36 +43,49 @@ function writeJson(filePath, value) {
43
43
  fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
44
44
  }
45
45
 
46
- function normalizeTarget(cwd, target, sourceLabel) {
47
- const trimmed = target.trim();
46
+ /**
47
+ * 相対パスのバリデーションと正規化
48
+ * @param {string} cwd プロジェクトルート
49
+ * @param {string} inputPath 入力パス
50
+ * @param {string} label エラーメッセージ用ラベル
51
+ * @param {{ checkParentOnly?: boolean }} options
52
+ * checkParentOnly: true の場合、パス自体ではなく親ディレクトリの存在をチェック(新規ファイル作成用)
53
+ */
54
+ function normalizePath(cwd, inputPath, label, { checkParentOnly = false } = {}) {
55
+ const trimmed = inputPath.trim();
48
56
 
49
57
  if (!trimmed) {
50
- throw new Error(`Target path is empty (${sourceLabel})`);
58
+ throw new Error(`${label} is empty`);
51
59
  }
52
60
 
53
61
  if (path.isAbsolute(trimmed)) {
54
- throw new Error(`Target path must be relative to the project root: ${trimmed}`);
62
+ throw new Error(`${label} must be relative to the project root: ${trimmed}`);
55
63
  }
56
64
 
57
65
  if (UNSAFE_TARGET_PATTERN.test(trimmed)) {
58
66
  throw new Error(
59
- `Target path contains unsupported characters: ${trimmed}. Use a simple relative path without spaces or shell metacharacters.`
67
+ `${label} contains unsupported characters: ${trimmed}. Use a simple relative path.`
60
68
  );
61
69
  }
62
70
 
63
71
  const normalized = path.posix.normalize(trimmed.replace(/\\/g, '/'));
64
72
 
65
- if (normalized === '.' || normalized === '') {
66
- throw new Error(`Target path is empty (${sourceLabel})`);
73
+ if (!checkParentOnly && (normalized === '.' || normalized === '')) {
74
+ throw new Error(`${label} is empty`);
67
75
  }
68
76
 
69
77
  if (normalized.startsWith('../')) {
70
- throw new Error(`Target path must stay inside the project root: ${trimmed}`);
78
+ throw new Error(`${label} must stay inside the project root: ${trimmed}`);
71
79
  }
72
80
 
73
- const absolutePath = path.resolve(cwd, normalized);
74
- if (!fs.existsSync(absolutePath)) {
75
- throw new Error(`Target path does not exist: ${normalized}`);
81
+ const checkPath = checkParentOnly
82
+ ? path.resolve(cwd, path.dirname(normalized))
83
+ : path.resolve(cwd, normalized);
84
+
85
+ if (!fs.existsSync(checkPath)) {
86
+ const desc = checkParentOnly ? 'Parent directory' : 'Path';
87
+ const displayPath = checkParentOnly ? path.dirname(normalized) : normalized;
88
+ throw new Error(`${desc} does not exist: ${displayPath}`);
76
89
  }
77
90
 
78
91
  return normalized;
@@ -221,15 +234,17 @@ export function setupAssistant(options = {}) {
221
234
 
222
235
  const targetSource = options.target ? 'explicit' : 'auto';
223
236
  const target = options.target
224
- ? normalizeTarget(cwd, options.target, 'explicit')
225
- : normalizeTarget(cwd, detectTarget(cwd), 'auto');
237
+ ? normalizePath(cwd, options.target, 'Target path')
238
+ : normalizePath(cwd, detectTarget(cwd), 'Target path');
226
239
 
227
240
  const packageJsonPath = path.resolve(cwd, 'package.json');
228
241
  if (!fs.existsSync(packageJsonPath)) {
229
242
  throw new Error(`package.json not found in ${cwd}`);
230
243
  }
231
244
 
232
- const instructionPath = path.resolve(cwd, assistantConfig.path);
245
+ const instructionPath = options.instructionsPath
246
+ ? path.resolve(cwd, normalizePath(cwd, options.instructionsPath, 'Instructions path', { checkParentOnly: true }))
247
+ : path.resolve(cwd, assistantConfig.path);
233
248
  const packageResult = updatePackageJson(packageJsonPath, target, force);
234
249
  const instructionBlock = buildInstructionBlock(target, assistant);
235
250
  const instructionResult = updateInstructionFile(instructionPath, instructionBlock);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sparkle-design-cli",
3
- "version": "1.5.1",
3
+ "version": "1.6.1",
4
4
  "description": "Sparkle Design CSS Generator - デザインシステムCSSを設定ファイルから生成するツール",
5
5
  "main": "lib/generate-css.js",
6
6
  "type": "module",