sparkle-design-cli 2.0.7-beta.9 → 2.0.7-rc.3
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 +42 -5
- package/lib/check.js +135 -23
- package/lib/constants.js +39 -10
- package/lib/file-loader.js +13 -1
- package/lib/font-manager.js +61 -25
- package/lib/generate-css.js +205 -37
- package/lib/path-utils.js +66 -0
- package/lib/setup.js +105 -28
- package/package.json +1 -1
package/lib/constants.js
CHANGED
|
@@ -33,6 +33,21 @@ export const GLOBALS_CSS_CANDIDATES = [
|
|
|
33
33
|
'src/styles/globals.css',
|
|
34
34
|
];
|
|
35
35
|
|
|
36
|
+
// Vite プロジェクト判定に使う config ファイル候補。setup.js(scaffold
|
|
37
|
+
// 判定)と font-manager.js(resolveGlobalsPath / index.html 注入判定)
|
|
38
|
+
// で同じリストを参照する。片方だけ変えると scaffold と generate が別
|
|
39
|
+
// 判定をしてしまうので、唯一の source of truth としてここに置く。
|
|
40
|
+
// en: Single source of truth for Vite project detection. Reused by
|
|
41
|
+
// setup.js's scaffold logic and font-manager.js's resolver.
|
|
42
|
+
export const VITE_CONFIG_FILES = [
|
|
43
|
+
'vite.config.ts',
|
|
44
|
+
'vite.config.js',
|
|
45
|
+
'vite.config.mjs',
|
|
46
|
+
'vite.config.cjs',
|
|
47
|
+
'vite.config.mts',
|
|
48
|
+
'vite.config.cts',
|
|
49
|
+
];
|
|
50
|
+
|
|
36
51
|
// 正規表現パターン
|
|
37
52
|
export const REGEX = {
|
|
38
53
|
// フォント関連
|
|
@@ -44,16 +59,20 @@ export const REGEX = {
|
|
|
44
59
|
/\/\*\s*フォントのインポート[^*]*\*\/\s*\n?(?:@import\s+(?:url\([^)]+fonts\.googleapis\.com[^)]+\)|['"][^"']*fonts\.googleapis\.com[^"']*['"]);?\s*\n?)*\n?/g,
|
|
45
60
|
|
|
46
61
|
// Sparkle Design関連
|
|
47
|
-
// `@import "sparkle-design.css"` / `@import "./sparkle-design.css"`
|
|
48
|
-
// `@import "./app/sparkle-design.css"`
|
|
49
|
-
//
|
|
50
|
-
//
|
|
51
|
-
//
|
|
52
|
-
//
|
|
53
|
-
//
|
|
54
|
-
//
|
|
62
|
+
// `@import "sparkle-design.css"` / `@import "./sparkle-design.css"` だけでなく、
|
|
63
|
+
// `@import "./app/sparkle-design.css"` のように entry CSS と sparkle-design.css
|
|
64
|
+
// が別ディレクトリにある Vite 系レイアウトの variant も除去対象にする。
|
|
65
|
+
// ただし **path prefix を `./` または `../` に限定**し、末尾の `sparkle-design.css`
|
|
66
|
+
// は path セパレータ直後であることを要求することで、ユーザーが独自命名した
|
|
67
|
+
// `custom-sparkle-design.css` / `my-sparkle-design.css` などを誤削除しないように
|
|
68
|
+
// している(beta.10 まではここが緩く、suffix match で user-authored ファイルを
|
|
69
|
+
// 巻き込む silent data loss リスクがあった)。
|
|
70
|
+
// en: Match paths ending in `sparkle-design.css` but require a `./` or `../`
|
|
71
|
+
// prefix and a path separator just before the filename. This preserves user-
|
|
72
|
+
// authored files like `custom-sparkle-design.css` that would otherwise be
|
|
73
|
+
// deleted by a loose suffix match.
|
|
55
74
|
SPARKLE_IMPORT:
|
|
56
|
-
/\/\*\s*Sparkle Design[^*]*\*\/\s*\n?|@import\s+['"][^"']
|
|
75
|
+
/\/\*\s*Sparkle Design[^*]*\*\/\s*\n?|@import\s+['"](?:(?:\.{1,2}\/)[^"']*\/)?sparkle-design\.css['"];?\s*\n?/g,
|
|
57
76
|
TAILWIND_IMPORT: /@import\s+['"]tailwindcss['"];?/,
|
|
58
77
|
|
|
59
78
|
// @source ディレクティブ関連
|
|
@@ -70,8 +89,18 @@ export const REGEX = {
|
|
|
70
89
|
/\/\*\s*npm パッケージのコンテンツスキャン[^*]*\*\/\s*\n?(?:@source\s+["'][^"']*["'];?\s*\n?)*/g,
|
|
71
90
|
|
|
72
91
|
// カスタムCSS関連
|
|
92
|
+
// 除去対象は「CLI が書き込んだ custom CSS の managed block」のみ。以前は
|
|
93
|
+
// 文字列 "custom" を含む任意の import をすべて削っていたため、
|
|
94
|
+
// `customer-module.css` / `my-customer-ui.css` / `customizer.css` など
|
|
95
|
+
// ユーザー命名の import まで巻き込んで silent data loss していた。
|
|
96
|
+
// CLI が書くのは `/* プロジェクト固有のカスタムトークン */` コメント直後の
|
|
97
|
+
// 1 行 `@import` だけなので、そのペアだけをマッチする。
|
|
98
|
+
// en: Match only the managed block the CLI writes (comment + one `@import`
|
|
99
|
+
// line). Previously any `@import` containing the substring "custom" was
|
|
100
|
+
// deleted, which would silently remove user-authored files like
|
|
101
|
+
// `customer-module.css`.
|
|
73
102
|
CUSTOM_CSS_IMPORT:
|
|
74
|
-
/\/\*\s*プロジェクト固有のカスタムトークン[^*]*\*\/\s*\n
|
|
103
|
+
/\/\*\s*プロジェクト固有のカスタムトークン[^*]*\*\/\s*\n?(?:@import\s+['"][^"']+['"];?\s*\n?)?/g,
|
|
75
104
|
|
|
76
105
|
// テンプレート関連
|
|
77
106
|
COLOR_TOKENS_PLACEHOLDER: /[ \t]*\/\* \{\{COLOR_TOKENS\}\} \*\/[ \t]*\n?/,
|
package/lib/file-loader.js
CHANGED
|
@@ -46,11 +46,23 @@ function readFile(filePath, successMessage, errorMessage, additionalErrorHandler
|
|
|
46
46
|
* @param {Function} additionalErrorHandler 追加のエラーハンドラ(オプション)
|
|
47
47
|
* @returns {Object} パース済みのJSONオブジェクト
|
|
48
48
|
*/
|
|
49
|
+
// Prototype pollution 対策: `__proto__` / `constructor` / `prototype` を
|
|
50
|
+
// JSON から drop する reviver。本プロセス内の spread は安全だが、書き戻し
|
|
51
|
+
// 先(hook JSON / package.json 等)経由で下流 consumer に流れたときに
|
|
52
|
+
// プロトタイプ汚染が起きる古典パターンを断つ。
|
|
53
|
+
// en: Strip prototype-pollution keys at parse time so they cannot travel
|
|
54
|
+
// through serialized files to downstream consumers.
|
|
55
|
+
const POLLUTION_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
|
|
56
|
+
function jsonPollutionReviver(key, value) {
|
|
57
|
+
if (POLLUTION_KEYS.has(key)) return undefined;
|
|
58
|
+
return value;
|
|
59
|
+
}
|
|
60
|
+
|
|
49
61
|
function readJsonFile(filePath, successMessage, errorMessage, additionalErrorHandler = null) {
|
|
50
62
|
const content = readFile(filePath, successMessage, errorMessage, additionalErrorHandler);
|
|
51
63
|
|
|
52
64
|
try {
|
|
53
|
-
return JSON.parse(content);
|
|
65
|
+
return JSON.parse(content, jsonPollutionReviver);
|
|
54
66
|
} catch (error) {
|
|
55
67
|
const message =
|
|
56
68
|
typeof errorMessage === 'function'
|
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
|
}
|
|
@@ -294,15 +306,21 @@ export function updateGlobalsWithFonts(
|
|
|
294
306
|
console.warn(
|
|
295
307
|
`⚠️ ${displayPath} に \`@import "tailwindcss";\` が見つからなかったため先頭に追記します(canonical な Tailwind v4 entry point にするため)。`
|
|
296
308
|
);
|
|
297
|
-
globalsContent = `${IMPORTS.TAILWIND}\n${globalsContent
|
|
309
|
+
globalsContent = `${IMPORTS.TAILWIND}\n${globalsContent}`;
|
|
298
310
|
tailwindInfo = findTailwindImport(globalsContent);
|
|
299
311
|
if (!tailwindInfo) {
|
|
300
312
|
// prepend したのに正規表現で拾えないのは `REGEX.TAILWIND_IMPORT` が
|
|
301
|
-
// 想定と違う(=
|
|
302
|
-
//
|
|
303
|
-
//
|
|
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.
|
|
304
318
|
console.error(MESSAGES.TAILWIND_NOT_FOUND(displayPath));
|
|
305
|
-
|
|
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;
|
|
306
324
|
}
|
|
307
325
|
}
|
|
308
326
|
|
|
@@ -326,26 +344,25 @@ export function updateGlobalsWithFonts(
|
|
|
326
344
|
console.log(MESSAGES.GLOBALS_UPDATED(globalsPath));
|
|
327
345
|
return { status: 'updated' };
|
|
328
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
|
+
}
|
|
329
360
|
console.error(MESSAGES.GLOBALS_UPDATE_FAILED(error.message));
|
|
330
361
|
return { status: 'failed', reason: `write-error: ${error.code ?? error.message}` };
|
|
331
362
|
}
|
|
332
363
|
}
|
|
333
364
|
|
|
334
|
-
|
|
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) {
|
|
365
|
+
export function isViteProject(cwd) {
|
|
349
366
|
return VITE_CONFIG_FILES.some((name) => fs.existsSync(path.join(cwd, name)));
|
|
350
367
|
}
|
|
351
368
|
|
|
@@ -427,6 +444,15 @@ function resolveGlobalsPath(sparkleDesignPath, explicitGlobalsPath = null) {
|
|
|
427
444
|
const dir = path.dirname(sparkleDesignPath);
|
|
428
445
|
|
|
429
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');
|
|
430
456
|
const resolved = path.resolve(process.cwd(), explicitGlobalsPath);
|
|
431
457
|
if (fs.existsSync(resolved)) {
|
|
432
458
|
return { path: resolved, source: 'explicit' };
|
|
@@ -630,7 +656,17 @@ export function manageFontImports(
|
|
|
630
656
|
// strict モード、または明示指定された globals path の not-found は
|
|
631
657
|
// ユーザーが必ず気付くべきなので非 strict でも再 throw する。
|
|
632
658
|
// en: Always re-throw explicit --globals-path typos; respect strict for the rest.
|
|
633
|
-
|
|
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
|
+
) {
|
|
634
670
|
throw error;
|
|
635
671
|
}
|
|
636
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
|
+
}
|