sparkle-design-cli 2.0.7-beta.8 → 2.0.7-rc.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +134 -113
- package/lib/anti-pattern-rules.js +34 -3
- package/lib/check.js +75 -7
- package/lib/constants.js +39 -10
- package/lib/file-loader.js +13 -1
- package/lib/font-manager.js +104 -29
- package/lib/generate-css.js +205 -37
- package/lib/path-utils.js +66 -0
- package/lib/setup.js +104 -27
- package/package.json +1 -1
package/lib/font-manager.js
CHANGED
|
@@ -5,7 +5,15 @@
|
|
|
5
5
|
|
|
6
6
|
import fs from 'fs';
|
|
7
7
|
import path from 'path';
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
REGEX,
|
|
10
|
+
COMMENTS,
|
|
11
|
+
IMPORTS,
|
|
12
|
+
MESSAGES,
|
|
13
|
+
GLOBALS_CSS_CANDIDATES,
|
|
14
|
+
VITE_CONFIG_FILES,
|
|
15
|
+
} from './constants.js';
|
|
16
|
+
import { assertSafeRelativePath } from './path-utils.js';
|
|
9
17
|
|
|
10
18
|
/**
|
|
11
19
|
* sparkle-design.css からフォントの@import文を抽出する
|
|
@@ -74,13 +82,17 @@ function createCustomCssImportBlock(customCssPath, globalsPath) {
|
|
|
74
82
|
return '';
|
|
75
83
|
}
|
|
76
84
|
|
|
77
|
-
// custom-css
|
|
78
|
-
// globals
|
|
85
|
+
// `custom-css` は `sparkle.config.json` の `extend.custom-css` から来る
|
|
86
|
+
// ユーザー入力。globals-path と同様に path traversal / shell metacharacter
|
|
87
|
+
// の混入を弾く。検証を通過した上で entry CSS からの相対 path に変換する。
|
|
88
|
+
// en: Validate the user-supplied custom-css path before embedding it in
|
|
89
|
+
// the generated `@import` statement.
|
|
90
|
+
assertSafeRelativePath(customCssPath, 'custom-css');
|
|
91
|
+
|
|
79
92
|
const globalsDir = path.dirname(globalsPath);
|
|
80
93
|
const absoluteCustomPath = path.resolve(process.cwd(), customCssPath);
|
|
81
94
|
let relativePath = path.relative(globalsDir, absoluteCustomPath).split(path.sep).join('/');
|
|
82
95
|
|
|
83
|
-
// 相対パスが ./ で始まらない場合は付与
|
|
84
96
|
if (!relativePath.startsWith('.')) {
|
|
85
97
|
relativePath = `./${relativePath}`;
|
|
86
98
|
}
|
|
@@ -277,15 +289,39 @@ export function updateGlobalsWithFonts(
|
|
|
277
289
|
// 2. 既存のimportを削除
|
|
278
290
|
globalsContent = removeExistingImports(globalsContent);
|
|
279
291
|
|
|
280
|
-
// 3. Tailwind import
|
|
281
|
-
|
|
292
|
+
// 3. Tailwind import の位置を見つける。無ければ先頭に自動で prepend する。
|
|
293
|
+
// ここで silent に失敗すると `create-vite` 等が生成した既存 index.css に
|
|
294
|
+
// `@import "tailwindcss"` が無いプロジェクトで setup が no-op に終わり、
|
|
295
|
+
// AI が後から手書きで import を補うが `@source` を知らず落とす、という
|
|
296
|
+
// 連鎖的な崩れが起きていた(beta.8 試用で確認)。canonical な import を
|
|
297
|
+
// 自動投入することでその連鎖を根絶する。
|
|
298
|
+
// en: If `@import "tailwindcss"` is missing from an existing entry CSS
|
|
299
|
+
// (e.g. untouched `create-vite` default), auto-prepend it instead of
|
|
300
|
+
// silently warning. Without this recovery, the whole patch becomes a no-op
|
|
301
|
+
// and AI ends up hand-writing imports without `@source`, producing drifted
|
|
302
|
+
// output (observed in beta.8 user testing).
|
|
303
|
+
let tailwindInfo = findTailwindImport(globalsContent);
|
|
282
304
|
if (!tailwindInfo) {
|
|
283
|
-
// 表示はプロジェクト相対パスにする。絶対パスだと「どのファイルをいじるのか」
|
|
284
|
-
// が一目で分かりにくく、リポジトリ間で絶対パスが変わるので diff も読みにくい。
|
|
285
|
-
// en: Show the project-relative path so it's obvious which file to edit.
|
|
286
305
|
const displayPath = path.relative(process.cwd(), globalsPath) || globalsPath;
|
|
287
|
-
console.warn(
|
|
288
|
-
|
|
306
|
+
console.warn(
|
|
307
|
+
`⚠️ ${displayPath} に \`@import "tailwindcss";\` が見つからなかったため先頭に追記します(canonical な Tailwind v4 entry point にするため)。`
|
|
308
|
+
);
|
|
309
|
+
globalsContent = `${IMPORTS.TAILWIND}\n${globalsContent}`;
|
|
310
|
+
tailwindInfo = findTailwindImport(globalsContent);
|
|
311
|
+
if (!tailwindInfo) {
|
|
312
|
+
// prepend したのに正規表現で拾えないのは `REGEX.TAILWIND_IMPORT` が
|
|
313
|
+
// 想定と違う(= コードの不整合)。コメントどおり fail-loud にする。
|
|
314
|
+
// warn 止まりだと generate 完了扱いで exit 0 になり、ユーザーは
|
|
315
|
+
// patch が壊れていることに気付けない。strict 以外でも throw する。
|
|
316
|
+
// en: Coded as fail-loud per the comment. A warn-only return lets
|
|
317
|
+
// generate finish with exit 0 which hides the breakage.
|
|
318
|
+
console.error(MESSAGES.TAILWIND_NOT_FOUND(displayPath));
|
|
319
|
+
const err = new Error(
|
|
320
|
+
`tailwind import を prepend 後も検出できませんでした。REGEX.TAILWIND_IMPORT の不整合か globals.css の書き込み失敗の可能性があります: ${displayPath}`
|
|
321
|
+
);
|
|
322
|
+
err.code = 'E_TAILWIND_IMPORT_PREPEND_FAILED';
|
|
323
|
+
throw err;
|
|
324
|
+
}
|
|
289
325
|
}
|
|
290
326
|
|
|
291
327
|
// 4. importブロックを生成(フォント @import は SparkleHead に移行したため生成しない)
|
|
@@ -308,26 +344,25 @@ export function updateGlobalsWithFonts(
|
|
|
308
344
|
console.log(MESSAGES.GLOBALS_UPDATED(globalsPath));
|
|
309
345
|
return { status: 'updated' };
|
|
310
346
|
} catch (error) {
|
|
347
|
+
// `E_UNSAFE_RELATIVE_PATH`(custom-css の path traversal 等)や
|
|
348
|
+
// `E_TAILWIND_IMPORT_PREPEND_FAILED`(内部不整合)は silent fallback
|
|
349
|
+
// させず上流に伝播させる。これらは設定や状態の問題なので、write-error
|
|
350
|
+
// として status: failed で丸めてしまうと manageFontImports の上流
|
|
351
|
+
// catch では普通の write-error と区別できなくなる。
|
|
352
|
+
// en: Propagate codes that must never be silently downgraded. Generic
|
|
353
|
+
// write errors still go through the failed-return path.
|
|
354
|
+
if (
|
|
355
|
+
error.code === 'E_UNSAFE_RELATIVE_PATH' ||
|
|
356
|
+
error.code === 'E_TAILWIND_IMPORT_PREPEND_FAILED'
|
|
357
|
+
) {
|
|
358
|
+
throw error;
|
|
359
|
+
}
|
|
311
360
|
console.error(MESSAGES.GLOBALS_UPDATE_FAILED(error.message));
|
|
312
361
|
return { status: 'failed', reason: `write-error: ${error.code ?? error.message}` };
|
|
313
362
|
}
|
|
314
363
|
}
|
|
315
364
|
|
|
316
|
-
|
|
317
|
-
// 同じ対象を見る必要がある(判定ロジックが分かれると scaffold と generate で
|
|
318
|
-
// 挙動が食い違う)。
|
|
319
|
-
// en: Vite config candidates used for project detection. Must stay in sync with
|
|
320
|
-
// setup.js's VITE_CONFIG_FILES so scaffold and generate agree on the layout.
|
|
321
|
-
const VITE_CONFIG_FILES = [
|
|
322
|
-
'vite.config.ts',
|
|
323
|
-
'vite.config.js',
|
|
324
|
-
'vite.config.mjs',
|
|
325
|
-
'vite.config.cjs',
|
|
326
|
-
'vite.config.mts',
|
|
327
|
-
'vite.config.cts',
|
|
328
|
-
];
|
|
329
|
-
|
|
330
|
-
function isViteProject(cwd) {
|
|
365
|
+
export function isViteProject(cwd) {
|
|
331
366
|
return VITE_CONFIG_FILES.some((name) => fs.existsSync(path.join(cwd, name)));
|
|
332
367
|
}
|
|
333
368
|
|
|
@@ -409,6 +444,15 @@ function resolveGlobalsPath(sparkleDesignPath, explicitGlobalsPath = null) {
|
|
|
409
444
|
const dir = path.dirname(sparkleDesignPath);
|
|
410
445
|
|
|
411
446
|
if (explicitGlobalsPath) {
|
|
447
|
+
// `--globals-path` CLI 引数 / `extend.globals-path` config 値はユーザー
|
|
448
|
+
// 由来のため、path traversal(`../../etc/passwd` 等)や shell メタ文字
|
|
449
|
+
// を弾く検証を通してから resolve する。共有リポジトリ経由で悪意ある
|
|
450
|
+
// config が入ったとき、teammate の fs 上で任意ファイルが書き換えられる
|
|
451
|
+
// supply-chain 型の attack を防ぐ。
|
|
452
|
+
// en: Validate user-supplied globals-path against traversal / shell
|
|
453
|
+
// metacharacters before resolving, to prevent config-injection abuse in
|
|
454
|
+
// shared repositories.
|
|
455
|
+
assertSafeRelativePath(explicitGlobalsPath, 'globals-path');
|
|
412
456
|
const resolved = path.resolve(process.cwd(), explicitGlobalsPath);
|
|
413
457
|
if (fs.existsSync(resolved)) {
|
|
414
458
|
return { path: resolved, source: 'explicit' };
|
|
@@ -451,11 +495,22 @@ function resolveGlobalsPath(sparkleDesignPath, explicitGlobalsPath = null) {
|
|
|
451
495
|
const candidates = isViteProject(cwd)
|
|
452
496
|
? GLOBALS_CSS_CANDIDATES.slice().sort((a, b) => vitePriority(a) - vitePriority(b))
|
|
453
497
|
: GLOBALS_CSS_CANDIDATES;
|
|
498
|
+
// 2-a) まず既知候補の中で `@import "tailwindcss"` を持つファイルを探す。
|
|
499
|
+
// 持っているファイルが第一候補(canonical Tailwind entry CSS)。
|
|
500
|
+
// 2-b) 2-a で見つからなかった場合、`@import` 無しで既存の候補ファイルが
|
|
501
|
+
// あればそれを拾う(Vite の `create-vite` 直後のように entry CSS は
|
|
502
|
+
// あるが Tailwind 未設定のプロジェクト)。これで上流の
|
|
503
|
+
// `updateGlobalsWithFonts` が auto-prepend で tailwind import を
|
|
504
|
+
// 補えるため、silent に no-op 終了する回帰を避けられる(beta.8 で再現)。
|
|
505
|
+
// en: First pick candidates that already contain `@import "tailwindcss"`.
|
|
506
|
+
// If none match, fall back to the first existing candidate anyway so the
|
|
507
|
+
// upstream patcher can auto-prepend the canonical tailwind import. Without
|
|
508
|
+
// this fallback, a Vite project created by `create-vite` with an untouched
|
|
509
|
+
// `src/index.css` would cause setup to end as a silent no-op.
|
|
510
|
+
let fallbackExistingPath = null;
|
|
454
511
|
for (const candidate of candidates) {
|
|
455
512
|
const absolute = path.resolve(cwd, candidate);
|
|
456
513
|
if (!fs.existsSync(absolute)) continue;
|
|
457
|
-
// 自身(sparkle-design.css)は除外
|
|
458
|
-
// en: Skip sparkle-design.css itself just in case a candidate points at it.
|
|
459
514
|
if (path.resolve(absolute) === path.resolve(sparkleDesignPath)) continue;
|
|
460
515
|
try {
|
|
461
516
|
const content = fs.readFileSync(absolute, 'utf8');
|
|
@@ -463,6 +518,9 @@ function resolveGlobalsPath(sparkleDesignPath, explicitGlobalsPath = null) {
|
|
|
463
518
|
console.log(`📝 Tailwind エントリポイントを検出しました(project root): ${candidate}`);
|
|
464
519
|
return { path: absolute, source: 'project' };
|
|
465
520
|
}
|
|
521
|
+
if (!fallbackExistingPath) {
|
|
522
|
+
fallbackExistingPath = { path: absolute, candidate };
|
|
523
|
+
}
|
|
466
524
|
} catch (err) {
|
|
467
525
|
// ENOENT 系は existsSync で既に弾いているので、ここに来るのは権限不足や
|
|
468
526
|
// ディレクトリ衝突などデバッグ価値のあるケース。silent に消さずに理由を出す。
|
|
@@ -474,6 +532,13 @@ function resolveGlobalsPath(sparkleDesignPath, explicitGlobalsPath = null) {
|
|
|
474
532
|
}
|
|
475
533
|
}
|
|
476
534
|
|
|
535
|
+
if (fallbackExistingPath) {
|
|
536
|
+
console.log(
|
|
537
|
+
`📝 Tailwind import 未設定の既存 entry CSS を検出しました(project root): ${fallbackExistingPath.candidate}。後続処理で \`@import "tailwindcss";\` を自動追記します。`
|
|
538
|
+
);
|
|
539
|
+
return { path: fallbackExistingPath.path, source: 'existing-no-tailwind' };
|
|
540
|
+
}
|
|
541
|
+
|
|
477
542
|
// 3. デフォルト: sparkle-design.css と同じディレクトリの globals.css
|
|
478
543
|
// en: Last resort — a globals.css next to sparkle-design.css.
|
|
479
544
|
const defaultPath = path.join(dir, 'globals.css');
|
|
@@ -591,7 +656,17 @@ export function manageFontImports(
|
|
|
591
656
|
// strict モード、または明示指定された globals path の not-found は
|
|
592
657
|
// ユーザーが必ず気付くべきなので非 strict でも再 throw する。
|
|
593
658
|
// en: Always re-throw explicit --globals-path typos; respect strict for the rest.
|
|
594
|
-
|
|
659
|
+
// 以下の code は strict 有無に関わらず常に throw:
|
|
660
|
+
// - E_EXPLICIT_GLOBALS_PATH_NOT_FOUND: 明示指定の typo
|
|
661
|
+
// - E_UNSAFE_RELATIVE_PATH: globals-path / custom-css の path traversal
|
|
662
|
+
// - E_TAILWIND_IMPORT_PREPEND_FAILED: 内部不整合(fail-loud)
|
|
663
|
+
// en: Always propagate these so silent misconfigurations never survive.
|
|
664
|
+
if (
|
|
665
|
+
strict ||
|
|
666
|
+
error.code === 'E_EXPLICIT_GLOBALS_PATH_NOT_FOUND' ||
|
|
667
|
+
error.code === 'E_UNSAFE_RELATIVE_PATH' ||
|
|
668
|
+
error.code === 'E_TAILWIND_IMPORT_PREPEND_FAILED'
|
|
669
|
+
) {
|
|
595
670
|
throw error;
|
|
596
671
|
}
|
|
597
672
|
return { status: 'failed', reason: `manage-error: ${error.code ?? error.message}` };
|
package/lib/generate-css.js
CHANGED
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
extractFontImports,
|
|
22
22
|
removeFontImportsFromCSS,
|
|
23
23
|
updateGlobalsWithFonts,
|
|
24
|
+
isViteProject,
|
|
24
25
|
} from './font-manager.js';
|
|
25
26
|
|
|
26
27
|
/**
|
|
@@ -42,15 +43,50 @@ function resolveExtend(config, configPath = null) {
|
|
|
42
43
|
const baseDir = configPath ? path.dirname(path.resolve(configPath)) : process.cwd();
|
|
43
44
|
const extendPath = path.resolve(baseDir, extend);
|
|
44
45
|
|
|
46
|
+
// 以前は try/catch で「読めなければ extend ごと無かったことにして続行」
|
|
47
|
+
// していたが、それだと `extend.globals-path` / `extend.source-packages`
|
|
48
|
+
// / `extend.fonts` のうち 1 つでも失敗すると全部が silent に無視される。
|
|
49
|
+
// 後段で Vite の entry CSS が想定と違う場所に patch される等の副作用に
|
|
50
|
+
// 繋がるため、必ず throw する。`bin/sparkle-design.js` の outer catch が
|
|
51
|
+
// 拾って exit 1 に昇格させる。
|
|
52
|
+
// en: Previously a failed extend file silently fell back to the base
|
|
53
|
+
// config, which hid typos / syntax errors and cascaded into wrong
|
|
54
|
+
// entry-CSS targets downstream. Always throw; the bin-level catch
|
|
55
|
+
// turns this into exit 1.
|
|
56
|
+
let content;
|
|
45
57
|
try {
|
|
46
|
-
|
|
47
|
-
extend = JSON.parse(content);
|
|
48
|
-
console.log(`✅ extend ファイルを読み込みました: ${extendPath}`);
|
|
58
|
+
content = fs.readFileSync(extendPath, 'utf8');
|
|
49
59
|
} catch (error) {
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
60
|
+
const err = new Error(
|
|
61
|
+
`extend ファイルを読み込めませんでした: ${extendPath} (${error.code ?? error.message})`
|
|
62
|
+
);
|
|
63
|
+
err.code = 'E_EXTEND_FILE_READ_FAILED';
|
|
64
|
+
throw err;
|
|
65
|
+
}
|
|
66
|
+
try {
|
|
67
|
+
extend = JSON.parse(content, (key, value) => {
|
|
68
|
+
// Prototype pollution 対策: `__proto__` / `constructor` / `prototype` を drop
|
|
69
|
+
// en: Strip prototype-pollution keys.
|
|
70
|
+
if (key === '__proto__' || key === 'constructor' || key === 'prototype') {
|
|
71
|
+
return undefined;
|
|
72
|
+
}
|
|
73
|
+
return value;
|
|
74
|
+
});
|
|
75
|
+
} catch (error) {
|
|
76
|
+
const err = new Error(
|
|
77
|
+
`extend ファイルの JSON parse に失敗しました: ${extendPath} (${error.message})`
|
|
78
|
+
);
|
|
79
|
+
err.code = 'E_EXTEND_FILE_PARSE_FAILED';
|
|
80
|
+
throw err;
|
|
53
81
|
}
|
|
82
|
+
if (typeof extend !== 'object' || extend === null || Array.isArray(extend)) {
|
|
83
|
+
const err = new Error(
|
|
84
|
+
`extend ファイルの中身は object である必要があります: ${extendPath}`
|
|
85
|
+
);
|
|
86
|
+
err.code = 'E_EXTEND_FILE_INVALID_SHAPE';
|
|
87
|
+
throw err;
|
|
88
|
+
}
|
|
89
|
+
console.log(`✅ extend ファイルを読み込みました: ${extendPath}`);
|
|
54
90
|
}
|
|
55
91
|
|
|
56
92
|
// extend の値を config にマージ(extend 内のフィールドが優先)
|
|
@@ -83,27 +119,81 @@ function dedupeFontImports(cssContent) {
|
|
|
83
119
|
.join('\n');
|
|
84
120
|
}
|
|
85
121
|
|
|
122
|
+
// Google Fonts に登録可能な family 名の安全な文字集合。英数字・スペース・
|
|
123
|
+
// ハイフン・アンダースコア(実際の Google Fonts ライブラリのほとんどを
|
|
124
|
+
// カバー)に限定。config 由来のため `'` / `;` / `}` / `*` / 改行が入ると
|
|
125
|
+
// 生成された CSS に `@import "http://attacker/"` のような injection が
|
|
126
|
+
// 通ってしまう。sparkle-variables で扱う Theme Settings プラグインの
|
|
127
|
+
// 選択肢集合を超える family 名はそもそも入らない想定なので、範囲を
|
|
128
|
+
// 狭く取ってよい(beyond-allowlist な family は後で緩和できる)。
|
|
129
|
+
// en: Restricted charset to prevent CSS injection via config-sourced font
|
|
130
|
+
// family names. Matches Google Fonts' actual naming conventions.
|
|
131
|
+
const FONT_FAMILY_SAFE = /^[A-Za-z0-9 _-]{1,64}$/;
|
|
132
|
+
const NPM_PACKAGE_SAFE = /^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/i;
|
|
133
|
+
|
|
134
|
+
function assertSafeFontFamily(family, label) {
|
|
135
|
+
if (typeof family !== 'string' || !FONT_FAMILY_SAFE.test(family)) {
|
|
136
|
+
const err = new Error(
|
|
137
|
+
`${label} に不正な文字が含まれています (${JSON.stringify(family)}). 英数字・スペース・ハイフン・アンダースコアのみ許可。`
|
|
138
|
+
);
|
|
139
|
+
err.code = 'E_UNSAFE_FONT_FAMILY';
|
|
140
|
+
throw err;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function assertSafeFontWeights(weights, label) {
|
|
145
|
+
if (!Array.isArray(weights)) {
|
|
146
|
+
const err = new Error(`${label} は配列である必要があります`);
|
|
147
|
+
err.code = 'E_UNSAFE_FONT_WEIGHTS';
|
|
148
|
+
throw err;
|
|
149
|
+
}
|
|
150
|
+
for (const w of weights) {
|
|
151
|
+
if (!Number.isInteger(w) || w < 1 || w > 1000) {
|
|
152
|
+
const err = new Error(
|
|
153
|
+
`${label} の値は 1..1000 の整数である必要があります (${JSON.stringify(w)})`
|
|
154
|
+
);
|
|
155
|
+
err.code = 'E_UNSAFE_FONT_WEIGHTS';
|
|
156
|
+
throw err;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function assertSafePackageName(name, label) {
|
|
162
|
+
if (typeof name !== 'string' || !NPM_PACKAGE_SAFE.test(name)) {
|
|
163
|
+
const err = new Error(`${label} は有効な npm パッケージ名ではありません (${JSON.stringify(name)})`);
|
|
164
|
+
err.code = 'E_UNSAFE_PACKAGE_NAME';
|
|
165
|
+
throw err;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
86
169
|
/**
|
|
87
|
-
* fonts
|
|
170
|
+
* fonts セクションのエントリを正規化する。検証失敗は上流に throw する(silent
|
|
171
|
+
* にデフォルト値にフォールバックしない — 無効値はユーザーに気付かせる)。
|
|
88
172
|
* @param {string|Array<string|{family:string, weights?:number[]}>} entry
|
|
173
|
+
* @param {string} label エラーメッセージ用ラベル(例: 'font-pro')
|
|
89
174
|
* @returns {{family:string, weights:number[]}[]}
|
|
90
175
|
*/
|
|
91
|
-
function normalizeFontsEntry(entry) {
|
|
176
|
+
function normalizeFontsEntry(entry, label = 'font') {
|
|
92
177
|
if (!entry) {
|
|
93
178
|
return [];
|
|
94
179
|
}
|
|
180
|
+
const validate = (family, weights) => {
|
|
181
|
+
assertSafeFontFamily(family, `${label}.family`);
|
|
182
|
+
assertSafeFontWeights(weights, `${label}.weights`);
|
|
183
|
+
return { family, weights };
|
|
184
|
+
};
|
|
95
185
|
if (typeof entry === 'string') {
|
|
96
|
-
return [
|
|
186
|
+
return [validate(entry, FONT_DEFAULTS.WEIGHTS)];
|
|
97
187
|
}
|
|
98
188
|
if (Array.isArray(entry)) {
|
|
99
189
|
return entry.map((item) => {
|
|
100
190
|
if (typeof item === 'string') {
|
|
101
|
-
return
|
|
191
|
+
return validate(item, FONT_DEFAULTS.WEIGHTS);
|
|
102
192
|
}
|
|
103
|
-
return
|
|
193
|
+
return validate(item.family, item.weights || FONT_DEFAULTS.WEIGHTS);
|
|
104
194
|
});
|
|
105
195
|
}
|
|
106
|
-
return [
|
|
196
|
+
return [validate(entry.family, entry.weights || FONT_DEFAULTS.WEIGHTS)];
|
|
107
197
|
}
|
|
108
198
|
|
|
109
199
|
/**
|
|
@@ -115,13 +205,13 @@ function normalizeFontsEntry(entry) {
|
|
|
115
205
|
function resolveFontConfig(config) {
|
|
116
206
|
if (config.fonts) {
|
|
117
207
|
return {
|
|
118
|
-
pro: normalizeFontsEntry(config.fonts.pro || config['font-pro']),
|
|
119
|
-
mono: normalizeFontsEntry(config.fonts.mono || config['font-mono']),
|
|
208
|
+
pro: normalizeFontsEntry(config.fonts.pro || config['font-pro'], 'fonts.pro'),
|
|
209
|
+
mono: normalizeFontsEntry(config.fonts.mono || config['font-mono'], 'fonts.mono'),
|
|
120
210
|
};
|
|
121
211
|
}
|
|
122
212
|
return {
|
|
123
|
-
pro: normalizeFontsEntry(config['font-pro']),
|
|
124
|
-
mono: normalizeFontsEntry(config['font-mono']),
|
|
213
|
+
pro: normalizeFontsEntry(config['font-pro'], 'font-pro'),
|
|
214
|
+
mono: normalizeFontsEntry(config['font-mono'], 'font-mono'),
|
|
125
215
|
};
|
|
126
216
|
}
|
|
127
217
|
|
|
@@ -374,6 +464,20 @@ function processTemplate(template, config, grayMapping, radiusMapping, colors) {
|
|
|
374
464
|
processedCSS = processedCSS.replace(/\{\{FONT_FAMILY_MONO\}\}/g, monoFamilyValue);
|
|
375
465
|
|
|
376
466
|
// 5. 基本的な設定値による置換(オブジェクト・配列・拡張フィールドはスキップ)
|
|
467
|
+
// 置換対象 key は想定される sparkle.config.json の一群(primary / radius /
|
|
468
|
+
// font-pro / font-mono 等)に限定しておくと、将来 config に追加された field
|
|
469
|
+
// が template に漏れ出すリスクと、`key.toUpperCase()` が regex メタ文字
|
|
470
|
+
// (`.` `*` `?` 等)を含んでいた場合に `new RegExp(placeholder, 'g')` が
|
|
471
|
+
// wildcard として広く置換してしまう L-2 型の regex injection 両方を防げる。
|
|
472
|
+
// en: Restrict replacement to a whitelist of known config keys so unknown /
|
|
473
|
+
// adversarial keys cannot smuggle regex metacharacters into the template.
|
|
474
|
+
const TEMPLATE_KEY_ALLOWLIST = new Set([
|
|
475
|
+
'primary',
|
|
476
|
+
'radius',
|
|
477
|
+
'font-pro',
|
|
478
|
+
'font-mono',
|
|
479
|
+
]);
|
|
480
|
+
const ALPHANUMERIC_KEY = /^[a-z0-9-]+$/;
|
|
377
481
|
Object.entries(config).forEach(([key, value]) => {
|
|
378
482
|
// 配列・オブジェクト・拡張フィールドはスキップ
|
|
379
483
|
if (
|
|
@@ -387,15 +491,27 @@ function processTemplate(template, config, grayMapping, radiusMapping, colors) {
|
|
|
387
491
|
) {
|
|
388
492
|
return;
|
|
389
493
|
}
|
|
390
|
-
//
|
|
494
|
+
// 未知 key は置換しない。また念のため英数 + ハイフン以外が含まれて
|
|
495
|
+
// いるキーも拒否(regex injection の 2 段目防御)。
|
|
496
|
+
// en: Reject unknown keys and keys with non-alphanumeric chars.
|
|
497
|
+
if (!TEMPLATE_KEY_ALLOWLIST.has(key) || !ALPHANUMERIC_KEY.test(key)) {
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
500
|
+
// `String.prototype.replace` は第 2 引数が string のとき `$&` / `$'` /
|
|
501
|
+
// `$\`` / `$1` などを特別扱いして展開するため、ユーザー値が `$&` を
|
|
502
|
+
// 含んでいるとテンプレート内の別箇所が意図せず重複挿入される。callback
|
|
503
|
+
// 形式で渡すことで特別展開を無効化する(M-1 defensive fix)。
|
|
504
|
+
// en: Use the callback form of replace() to disable `$`-special
|
|
505
|
+
// replacements and treat the value as a literal.
|
|
506
|
+
const literalValue = String(value);
|
|
391
507
|
const placeholder = `{{${key.toUpperCase().replace('-', '_')}}}`;
|
|
392
|
-
processedCSS = processedCSS.replace(new RegExp(placeholder, 'g'),
|
|
508
|
+
processedCSS = processedCSS.replace(new RegExp(placeholder, 'g'), () => literalValue);
|
|
393
509
|
|
|
394
510
|
// フォントファミリーの場合はURL用のプレースホルダーも生成(スペースを+に置き換え)
|
|
395
511
|
if (key.toLowerCase().includes('font')) {
|
|
396
512
|
const urlPlaceholder = `{{${key.toUpperCase().replace('-', '_')}_URL}}`;
|
|
397
|
-
const urlValue =
|
|
398
|
-
processedCSS = processedCSS.replace(new RegExp(urlPlaceholder, 'g'), urlValue);
|
|
513
|
+
const urlValue = literalValue.replace(/\s+/g, '+');
|
|
514
|
+
processedCSS = processedCSS.replace(new RegExp(urlPlaceholder, 'g'), () => urlValue);
|
|
399
515
|
}
|
|
400
516
|
});
|
|
401
517
|
|
|
@@ -441,22 +557,49 @@ const KNOWN_DESIGN_SYSTEM_PACKAGES = ['@goodpatch/sparkle-design-internal'];
|
|
|
441
557
|
* en: Scan package.json and surface source-packages when a design-system package is present.
|
|
442
558
|
*/
|
|
443
559
|
function detectSourcePackagesFromPackageJson(cwd = process.cwd()) {
|
|
560
|
+
// package.json が存在しないプロジェクト(稀だが想定はする)は null を返して
|
|
561
|
+
// 「design system パッケージ検出なし」として扱う。一方で「ファイルはあるが
|
|
562
|
+
// parse エラー / 権限エラー」は silent null にせず throw に昇格させる。
|
|
563
|
+
// 以前は全エラーを `catch {}` で握り潰していたため、package.json の JSON
|
|
564
|
+
// 破損で `@source` が silent に未生成になり、Tailwind スキャンが空になって
|
|
565
|
+
// class が効かないという silent 回帰に繋がっていた(beta.7 の silent drop
|
|
566
|
+
// と同系統)。
|
|
567
|
+
// en: ENOENT → null (no package.json; no sparkle detection). Other errors
|
|
568
|
+
// → throw so they surface instead of turning into `@source` drops.
|
|
569
|
+
const pkgPath = path.join(cwd, 'package.json');
|
|
570
|
+
let raw;
|
|
444
571
|
try {
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
Object.prototype.hasOwnProperty.call(deps, name)
|
|
572
|
+
raw = fs.readFileSync(pkgPath, 'utf8');
|
|
573
|
+
} catch (error) {
|
|
574
|
+
if (error.code === 'ENOENT') return null;
|
|
575
|
+
throw new Error(
|
|
576
|
+
`package.json の読み込みに失敗しました: ${pkgPath} (${error.code ?? error.message})`
|
|
451
577
|
);
|
|
578
|
+
}
|
|
579
|
+
let pkg;
|
|
580
|
+
try {
|
|
581
|
+
// Prototype pollution 対策(L-1): `__proto__` / `constructor` / `prototype` を drop
|
|
582
|
+
// en: Strip prototype-pollution keys at parse time.
|
|
583
|
+
pkg = JSON.parse(raw, (key, value) => {
|
|
584
|
+
if (key === '__proto__' || key === 'constructor' || key === 'prototype') {
|
|
585
|
+
return undefined;
|
|
586
|
+
}
|
|
587
|
+
return value;
|
|
588
|
+
});
|
|
589
|
+
} catch (error) {
|
|
590
|
+
throw new Error(`package.json の JSON parse に失敗しました: ${pkgPath} (${error.message})`);
|
|
591
|
+
}
|
|
592
|
+
const deps = { ...(pkg.dependencies ?? {}), ...(pkg.devDependencies ?? {}) };
|
|
452
593
|
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
594
|
+
const hasDefault = Object.prototype.hasOwnProperty.call(deps, 'sparkle-design');
|
|
595
|
+
const extras = KNOWN_DESIGN_SYSTEM_PACKAGES.filter((name) =>
|
|
596
|
+
Object.prototype.hasOwnProperty.call(deps, name)
|
|
597
|
+
);
|
|
598
|
+
|
|
599
|
+
if (hasDefault || extras.length > 0) {
|
|
600
|
+
return extras;
|
|
459
601
|
}
|
|
602
|
+
return null;
|
|
460
603
|
}
|
|
461
604
|
|
|
462
605
|
function writeCSS(cssContent, outputPath = null) {
|
|
@@ -540,10 +683,14 @@ export function generateCSS(
|
|
|
540
683
|
// する。Vite は <head> が index.html 側にあるため、React コンポーネントの
|
|
541
684
|
// SparkleHead を挟めない。managed block でマーカー間だけを管理し、既存の
|
|
542
685
|
// head 内容は非破壊に保つ。
|
|
543
|
-
// en:
|
|
544
|
-
//
|
|
686
|
+
// en: Inject only when confirmed Vite — other frameworks shipping an
|
|
687
|
+
// index.html (Astro / Remix / plain SPA) must not receive Sparkle's
|
|
688
|
+
// managed block.
|
|
545
689
|
const cwdForHtml = process.cwd();
|
|
546
|
-
if (
|
|
690
|
+
if (
|
|
691
|
+
isViteProject(cwdForHtml) &&
|
|
692
|
+
fs.existsSync(path.resolve(cwdForHtml, 'index.html'))
|
|
693
|
+
) {
|
|
547
694
|
const htmlResult = upsertViteIndexHtmlFonts(cwdForHtml, resolvedFonts);
|
|
548
695
|
if (htmlResult.status === 'created' || htmlResult.status === 'updated') {
|
|
549
696
|
const displayPath = path.relative(cwdForHtml, htmlResult.path) || htmlResult.path;
|
|
@@ -551,9 +698,19 @@ export function generateCSS(
|
|
|
551
698
|
`✅ index.html の Sparkle フォント <link> ブロックを${htmlResult.status === 'created' ? '挿入' : '更新'}しました: ${displayPath}`
|
|
552
699
|
);
|
|
553
700
|
} else if (htmlResult.status === 'failed') {
|
|
554
|
-
|
|
555
|
-
|
|
701
|
+
// `no-head-close-tag` は index.html が壊れている事実上の user error。
|
|
702
|
+
// silent warn ではなく error として出し、strict / no-head-close-tag
|
|
703
|
+
// のときは throw に昇格させる。read-error も同様。
|
|
704
|
+
// en: A failed injection signals either a malformed index.html or a
|
|
705
|
+
// permission problem — surface it loudly rather than no-op silently.
|
|
706
|
+
console.error(
|
|
707
|
+
`❌ index.html への Sparkle フォント <link> 挿入に失敗しました (reason: ${htmlResult.reason})。index.html の <head>...</head> が正しいか確認してください。対象: ${path.resolve(cwdForHtml, 'index.html')}`
|
|
556
708
|
);
|
|
709
|
+
if (Boolean(options.strict) || htmlResult.reason === 'no-head-close-tag') {
|
|
710
|
+
throw new Error(
|
|
711
|
+
`index.html への font link 挿入失敗 (${htmlResult.reason})。--strict または no-head-close-tag の場合は exit 1 で停止します。`
|
|
712
|
+
);
|
|
713
|
+
}
|
|
557
714
|
}
|
|
558
715
|
}
|
|
559
716
|
|
|
@@ -567,6 +724,17 @@ export function generateCSS(
|
|
|
567
724
|
console.log(MESSAGES.FONT_MANAGEMENT_START);
|
|
568
725
|
const detectedPackages = detectSourcePackagesFromPackageJson();
|
|
569
726
|
const explicitPackages = 'source-packages' in config ? config['source-packages'] || [] : null;
|
|
727
|
+
// 明示指定側はユーザー由来なので、npm package name の安全な charset に
|
|
728
|
+
// 限定する。不正値(`a"; body{...}` のような CSS injection 等)は吐かない。
|
|
729
|
+
// 自動検出側は `package.json` の `dependencies` key(npm 自身が validate
|
|
730
|
+
// 済み)なので追加 validation は不要。
|
|
731
|
+
// en: Validate user-supplied source-packages entries; detected ones come
|
|
732
|
+
// from npm-validated package.json keys.
|
|
733
|
+
if (Array.isArray(explicitPackages)) {
|
|
734
|
+
for (const pkg of explicitPackages) {
|
|
735
|
+
assertSafePackageName(pkg, 'extend.source-packages');
|
|
736
|
+
}
|
|
737
|
+
}
|
|
570
738
|
const sourcePackages =
|
|
571
739
|
detectedPackages !== null || explicitPackages !== null
|
|
572
740
|
? [...new Set([...(detectedPackages ?? []), ...(explicitPackages ?? [])])]
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import path from 'path';
|
|
2
|
+
|
|
3
|
+
// setup.js 側と共通の「unsafe な shell メタ文字や quote」を弾く pattern。
|
|
4
|
+
// config 由来や CLI 引数の相対 path に対して path traversal / shell injection
|
|
5
|
+
// 対策として使う。
|
|
6
|
+
// en: Shared unsafe-character pattern. Keeps in sync with setup.js's internal
|
|
7
|
+
// usage; any new call site should reuse this helper rather than re-implement.
|
|
8
|
+
const UNSAFE_CHARS = /[\s"'`;$&|<>()[\]{}\\]/;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* path の区切りを POSIX (`/`) に揃える。Windows / POSIX 両環境で相対 path の
|
|
12
|
+
* 表示や `@import` path 生成に使う。
|
|
13
|
+
* en: Normalize OS path separators to POSIX `/`.
|
|
14
|
+
*/
|
|
15
|
+
export function toPosixPath(p) {
|
|
16
|
+
return p.split(path.sep).join('/');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* ユーザー入力の相対 path を検証し、検証済みの POSIX 相対 path を返す。
|
|
21
|
+
*
|
|
22
|
+
* 検証内容:
|
|
23
|
+
* - 空文字 / 空白のみ → throw
|
|
24
|
+
* - 絶対パス → throw(user config に絶対パスを書かせるとプロジェクト外書き込みが許される)
|
|
25
|
+
* - shell メタ文字を含む → throw(hook command 文字列や script に埋まると injection になる)
|
|
26
|
+
* - 正規化後に `../` から始まる = プロジェクト root を escape → throw
|
|
27
|
+
*
|
|
28
|
+
* 既存の `setup.js:normalizePath` から「相対 path validation 部分だけ」抜き
|
|
29
|
+
* 出した軽量版。`fs.existsSync` 等のファイル I/O は行わない(呼び出し側が
|
|
30
|
+
* それぞれの context で存在チェックする)。
|
|
31
|
+
*
|
|
32
|
+
* en: Validate a user-supplied relative path and return the normalized POSIX
|
|
33
|
+
* form. Fails loud on traversal / shell metacharacters / absolute paths.
|
|
34
|
+
* Unlike setup.js:normalizePath, this does not touch the filesystem.
|
|
35
|
+
*
|
|
36
|
+
* @param {string} inputPath
|
|
37
|
+
* @param {string} label エラーメッセージ用ラベル
|
|
38
|
+
* @returns {string} 正規化された POSIX 相対 path
|
|
39
|
+
*/
|
|
40
|
+
export function assertSafeRelativePath(inputPath, label) {
|
|
41
|
+
const tagged = (message) => {
|
|
42
|
+
const err = new Error(message);
|
|
43
|
+
err.code = 'E_UNSAFE_RELATIVE_PATH';
|
|
44
|
+
return err;
|
|
45
|
+
};
|
|
46
|
+
if (typeof inputPath !== 'string') {
|
|
47
|
+
throw tagged(`${label} must be a string (got ${typeof inputPath})`);
|
|
48
|
+
}
|
|
49
|
+
const trimmed = inputPath.trim();
|
|
50
|
+
if (!trimmed) {
|
|
51
|
+
throw tagged(`${label} is empty`);
|
|
52
|
+
}
|
|
53
|
+
if (path.isAbsolute(trimmed)) {
|
|
54
|
+
throw tagged(`${label} must be relative to the project root: ${trimmed}`);
|
|
55
|
+
}
|
|
56
|
+
if (UNSAFE_CHARS.test(trimmed)) {
|
|
57
|
+
throw tagged(
|
|
58
|
+
`${label} contains unsupported characters: ${trimmed}. Use a simple relative path.`
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
const normalized = path.posix.normalize(trimmed.replace(/\\/g, '/'));
|
|
62
|
+
if (normalized.startsWith('../')) {
|
|
63
|
+
throw tagged(`${label} must stay inside the project root: ${trimmed}`);
|
|
64
|
+
}
|
|
65
|
+
return normalized;
|
|
66
|
+
}
|