sparkle-design-cli 1.5.0 → 1.6.0
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 +11 -2
- package/bin/sparkle-design.js +21 -1
- package/lib/anti-pattern-rules.js +216 -1
- package/lib/check.js +132 -26
- package/lib/constants.js +6 -0
- package/lib/font-manager.js +5 -8
- package/lib/generate-css.js +84 -6
- package/lib/setup.js +29 -14
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -111,6 +111,14 @@ sparkle-design-cli check --help
|
|
|
111
111
|
- children なしの Button に prefixIcon / suffixIcon を使わない
|
|
112
112
|
- Material Symbols を className 直書きで使わない
|
|
113
113
|
- shadcn/ui 既定 token を Sparkle Design 内へ持ち込まない
|
|
114
|
+
- Tailwind デフォルト typography(text-sm, font-medium 等)を使わない
|
|
115
|
+
- CardTitle に typography を上書きしない
|
|
116
|
+
- CardControl に Button / IconButton 以外を入れない
|
|
117
|
+
- CardHeader / CardContent の padding を上書きしない
|
|
118
|
+
- asChild と prefixIcon / suffixIcon / isLoading を併用しない
|
|
119
|
+
- disabled を isDisabled の代わりに使わない
|
|
120
|
+
- Button の prefixIcon に JSX を渡さない
|
|
121
|
+
- Icon の children にテキストを渡さない
|
|
114
122
|
|
|
115
123
|
#### Manual Review Reminders
|
|
116
124
|
|
|
@@ -119,6 +127,7 @@ sparkle-design-cli check --help
|
|
|
119
127
|
- Badge と Tag の意味的な使い分け
|
|
120
128
|
- CardDescription の typography / color token 明示
|
|
121
129
|
- Dialog と Modal の UX 上の使い分け
|
|
130
|
+
- 手書き span が Tag で置き換え可能か
|
|
122
131
|
|
|
123
132
|
#### 導入先での推奨設定
|
|
124
133
|
|
|
@@ -181,8 +190,8 @@ npx --yes sparkle-design-cli setup --assistant generic
|
|
|
181
190
|
#### Core(プラグインが出力)
|
|
182
191
|
|
|
183
192
|
- `primary`: プライマリカラー(blue, red, orange など)
|
|
184
|
-
- `font-pro`:
|
|
185
|
-
- `font-mono`:
|
|
193
|
+
- `font-pro`: プロポーショナルフォント([Google Fonts](https://fonts.google.com/) の名前)
|
|
194
|
+
- `font-mono`: モノスペースフォント([Google Fonts](https://fonts.google.com/) の名前)
|
|
186
195
|
- `radius`: 角丸設定(sm, md, lg など)
|
|
187
196
|
|
|
188
197
|
### extend セクション(拡張設定)
|
package/bin/sparkle-design.js
CHANGED
|
@@ -76,6 +76,7 @@ function parseSetupOptions(args) {
|
|
|
76
76
|
const options = {
|
|
77
77
|
assistant: 'generic',
|
|
78
78
|
target: null,
|
|
79
|
+
instructionsPath: null,
|
|
79
80
|
dryRun: false,
|
|
80
81
|
forceScriptUpdate: false,
|
|
81
82
|
help: false,
|
|
@@ -92,6 +93,9 @@ function parseSetupOptions(args) {
|
|
|
92
93
|
} else if (arg === '--target') {
|
|
93
94
|
options.target = requireOptionValue(args, i, '--target');
|
|
94
95
|
i += 1;
|
|
96
|
+
} else if (arg === '--instructions-path') {
|
|
97
|
+
options.instructionsPath = requireOptionValue(args, i, '--instructions-path');
|
|
98
|
+
i += 1;
|
|
95
99
|
} else if (arg === '--dry-run') {
|
|
96
100
|
options.dryRun = true;
|
|
97
101
|
} else if (arg === '--force-script-update') {
|
|
@@ -138,6 +142,7 @@ Setup:
|
|
|
138
142
|
sparkle-design-cli setup --assistant codex --target src
|
|
139
143
|
sparkle-design-cli setup --assistant cursor --dry-run
|
|
140
144
|
sparkle-design-cli setup --assistant claude --force-script-update
|
|
145
|
+
sparkle-design-cli setup --assistant claude --instructions-path app/admin/CLAUDE.md
|
|
141
146
|
|
|
142
147
|
Generate options:
|
|
143
148
|
-h, --help このヘルプメッセージを表示
|
|
@@ -179,6 +184,7 @@ Setup options:
|
|
|
179
184
|
--assistant <name> claude / codex / cursor / generic (default: generic)
|
|
180
185
|
--target <path> lint 対象パス (default: auto-detect)
|
|
181
186
|
--dry-run ファイルを変更せず結果だけ表示
|
|
187
|
+
--instructions-path <path> AI 指示ファイルの出力先パス (default: アシスタント別のデフォルト)
|
|
182
188
|
--force-script-update 既存の lint:sparkle 系 script も上書きする
|
|
183
189
|
|
|
184
190
|
Check で検出する主なパターン:
|
|
@@ -186,9 +192,23 @@ Check で検出する主なパターン:
|
|
|
186
192
|
- DialogCancel / DialogAction を <Button> で二重ラップしている
|
|
187
193
|
- children なしの Button に prefixIcon / suffixIcon を使っている
|
|
188
194
|
- Material Symbols を className 直書きしている
|
|
189
|
-
- text-muted-foreground
|
|
195
|
+
- shadcn/ui 既定 token(text-muted-foreground 等)を持ち込んでいる
|
|
196
|
+
- Tailwind デフォルト typography(text-sm, font-medium 等)を使っている
|
|
197
|
+
- CardTitle に typography を上書きしている
|
|
198
|
+
- CardControl に Button / IconButton 以外を入れている
|
|
199
|
+
- CardHeader / CardContent の padding を上書きしている
|
|
200
|
+
- asChild と prefixIcon / suffixIcon / isLoading を併用している
|
|
201
|
+
- disabled を isDisabled の代わりに使っている
|
|
202
|
+
- Button の prefixIcon に JSX を渡している
|
|
203
|
+
- Icon の children にテキストを渡している
|
|
204
|
+
- globals.css にフォント @import が残っている(SparkleHead への移行が必要)
|
|
205
|
+
- CSP ヘッダーで Google Fonts がブロックされている可能性
|
|
190
206
|
|
|
191
207
|
JSON 出力では manualReviewReminders も返すため、AI から実行する場合は --format json を推奨
|
|
208
|
+
|
|
209
|
+
generate で生成されるファイル:
|
|
210
|
+
sparkle-design.css デザイントークン CSS
|
|
211
|
+
SparkleHead.tsx フォント読み込み用 React コンポーネント(layout.tsx の <head> に配置)
|
|
192
212
|
`);
|
|
193
213
|
}
|
|
194
214
|
|
|
@@ -40,6 +40,11 @@ const MANUAL_REVIEW_REMINDERS = [
|
|
|
40
40
|
message:
|
|
41
41
|
'Dialog が確認用途、Modal が入力・詳細表示用途になっているか確認してください。文脈判断が必要なケースは機械検出されません。',
|
|
42
42
|
},
|
|
43
|
+
{
|
|
44
|
+
id: 'tag-vs-handwritten-span',
|
|
45
|
+
message:
|
|
46
|
+
'ステータスバッジ風の手書き span(bg-*-100 text-*-700 rounded-full 等)が Tag コンポーネントで置き換え可能か確認してください。',
|
|
47
|
+
},
|
|
43
48
|
];
|
|
44
49
|
|
|
45
50
|
const ANTI_PATTERN_GROUPS = [
|
|
@@ -859,10 +864,220 @@ const ANTI_PATTERN_GROUPS = [
|
|
|
859
864
|
},
|
|
860
865
|
],
|
|
861
866
|
},
|
|
867
|
+
{
|
|
868
|
+
id: 'tailwind-typography',
|
|
869
|
+
check: {
|
|
870
|
+
description: 'Tailwind デフォルト typography を Sparkle Design コンポーネント内で使わない',
|
|
871
|
+
recommendation:
|
|
872
|
+
'text-sm / text-xs / text-base / font-medium / font-semibold / font-bold は character-* utility に置き換えてください。',
|
|
873
|
+
pattern: /\b(text-(?:xs|sm|base|lg|xl|2xl)|font-(?:medium|semibold|bold|normal|light))\b/g,
|
|
874
|
+
},
|
|
875
|
+
featureSection: lines([
|
|
876
|
+
'### Tailwind デフォルト typography を使わない',
|
|
877
|
+
'',
|
|
878
|
+
'```tsx',
|
|
879
|
+
'// ✅ Correct — character-* utility を使う',
|
|
880
|
+
'<span className="character-3-regular-pro">テキスト</span>',
|
|
881
|
+
'',
|
|
882
|
+
'// ❌ Wrong — Tailwind デフォルトの typography を使わない',
|
|
883
|
+
'<span className="text-sm font-medium">テキスト</span>',
|
|
884
|
+
'```',
|
|
885
|
+
'',
|
|
886
|
+
'Sparkle Design コンポーネント内では `character-*-pro` / `character-*-mono` を使用する。Tailwind の `text-sm` / `font-medium` 等は使わない。',
|
|
887
|
+
]),
|
|
888
|
+
jsdocTargets: [],
|
|
889
|
+
},
|
|
890
|
+
{
|
|
891
|
+
id: 'card-title-typography',
|
|
892
|
+
check: {
|
|
893
|
+
description: 'CardTitle に typography 系クラスを付与しない',
|
|
894
|
+
recommendation:
|
|
895
|
+
'CardTitle は character-4-bold-pro を内蔵しています。className で typography を上書きしないでください。',
|
|
896
|
+
pattern: /<CardTitle\b[^>]*\bclassName\s*=\s*(?:"[^"]*\bcharacter-[^"]*"|'[^']*\bcharacter-[^']*'|\{[^}]*\bcharacter-[^}]*\})[^>]*>/g,
|
|
897
|
+
},
|
|
898
|
+
featureSection: lines([
|
|
899
|
+
'### CardTitle に typography を上書きしない',
|
|
900
|
+
'',
|
|
901
|
+
'```tsx',
|
|
902
|
+
'// ✅ Correct — CardTitle はデフォルトで character-4-bold-pro が適用される',
|
|
903
|
+
'<CardTitle>タイトル</CardTitle>',
|
|
904
|
+
'',
|
|
905
|
+
'// ❌ Wrong — typography を上書きしない',
|
|
906
|
+
'<CardTitle className="character-2-bold-pro">タイトル</CardTitle>',
|
|
907
|
+
'```',
|
|
908
|
+
]),
|
|
909
|
+
jsdocTargets: [],
|
|
910
|
+
},
|
|
911
|
+
{
|
|
912
|
+
id: 'card-control-non-button',
|
|
913
|
+
check: {
|
|
914
|
+
description: 'CardControl に Button / IconButton 以外を入れない',
|
|
915
|
+
recommendation:
|
|
916
|
+
'CardControl はアクションボタン用です。ステータス表示には CardDescription を使ってください。',
|
|
917
|
+
pattern: /<CardControl\b[^>]*>[\s\S]*?<(?!Button\b|IconButton\b|\/CardControl)\w+/g,
|
|
918
|
+
},
|
|
919
|
+
featureSection: lines([
|
|
920
|
+
'### CardControl にはアクションボタンのみを入れる',
|
|
921
|
+
'',
|
|
922
|
+
'```tsx',
|
|
923
|
+
'// ✅ Correct — Button / IconButton を CardControl に入れる',
|
|
924
|
+
'<CardControl>',
|
|
925
|
+
' <Button>保存</Button>',
|
|
926
|
+
'</CardControl>',
|
|
927
|
+
'',
|
|
928
|
+
'// ❌ Wrong — ステータス表示を CardControl に入れない(CardDescription を使う)',
|
|
929
|
+
'<CardControl>',
|
|
930
|
+
' <Tag status="negative">警告</Tag>',
|
|
931
|
+
'</CardControl>',
|
|
932
|
+
'```',
|
|
933
|
+
]),
|
|
934
|
+
jsdocTargets: [
|
|
935
|
+
{
|
|
936
|
+
file: 'src/components/ui/card/index.tsx',
|
|
937
|
+
targetName: 'CardControl',
|
|
938
|
+
section: {
|
|
939
|
+
bullets: [
|
|
940
|
+
{
|
|
941
|
+
ja: 'CardControl にはアクション用の Button / IconButton のみを入れてください。ステータス表示(Tag 等)は CardDescription に入れてください。',
|
|
942
|
+
en: 'CardControl is for action buttons (Button / IconButton) only. Place status displays (Tag, etc.) in CardDescription.',
|
|
943
|
+
},
|
|
944
|
+
],
|
|
945
|
+
example: lines([
|
|
946
|
+
'// ✅ Correct',
|
|
947
|
+
'<CardControl>',
|
|
948
|
+
' <Button theme="neutral" variant="outline">キャンセル</Button>',
|
|
949
|
+
' <Button>保存</Button>',
|
|
950
|
+
'</CardControl>',
|
|
951
|
+
'',
|
|
952
|
+
'// ❌ Wrong - ステータス表示を CardControl に入れない',
|
|
953
|
+
'<CardControl>',
|
|
954
|
+
' <Tag status="negative">警告</Tag>',
|
|
955
|
+
'</CardControl>',
|
|
956
|
+
]),
|
|
957
|
+
},
|
|
958
|
+
},
|
|
959
|
+
],
|
|
960
|
+
},
|
|
961
|
+
{
|
|
962
|
+
id: 'card-padding-override',
|
|
963
|
+
check: {
|
|
964
|
+
description: 'Card 系コンポーネントのデフォルト padding を安易に上書きしない',
|
|
965
|
+
recommendation:
|
|
966
|
+
'CardHeader / CardContent のデフォルト padding(px-6 py-2)をそのまま使ってください。上書きは本当に必要な場合のみ。',
|
|
967
|
+
pattern: /<Card(?:Header|Content)\b[^>]*\bclassName\s*=\s*(?:"[^"]*\b(?:p-|px-|py-|pt-|pb-|pl-|pr-)[^"]*"|'[^']*\b(?:p-|px-|py-|pt-|pb-|pl-|pr-)[^']*'|\{[^}]*\b(?:p-|px-|py-|pt-|pb-|pl-|pr-)[^}]*\})[^>]*>/g,
|
|
968
|
+
},
|
|
969
|
+
featureSection: lines([
|
|
970
|
+
'### Card 系コンポーネントの padding を上書きしない',
|
|
971
|
+
'',
|
|
972
|
+
'```tsx',
|
|
973
|
+
'// ✅ Correct — デフォルトの padding をそのまま使う',
|
|
974
|
+
'<CardHeader>',
|
|
975
|
+
' <CardTitle>タイトル</CardTitle>',
|
|
976
|
+
'</CardHeader>',
|
|
977
|
+
'',
|
|
978
|
+
'// ❌ Wrong — padding を安易に上書きしない',
|
|
979
|
+
'<CardHeader className="p-4 pb-2">',
|
|
980
|
+
' <CardTitle>タイトル</CardTitle>',
|
|
981
|
+
'</CardHeader>',
|
|
982
|
+
'```',
|
|
983
|
+
]),
|
|
984
|
+
jsdocTargets: [],
|
|
985
|
+
},
|
|
986
|
+
{
|
|
987
|
+
id: 'aschild-with-icon-props',
|
|
988
|
+
check: {
|
|
989
|
+
description: 'asChild 使用時に prefixIcon / suffixIcon / isLoading を使わない',
|
|
990
|
+
recommendation:
|
|
991
|
+
'asChild モードでは prefixIcon / suffixIcon / isLoading は無視されます。アイコン付きの Link が必要なら asChild を外してください。',
|
|
992
|
+
pattern: /<Button\b(?=[^>]*\basChild\b)(?=[^>]*\b(?:prefixIcon|suffixIcon|isLoading)\b)[^>]*>/g,
|
|
993
|
+
},
|
|
994
|
+
featureSection: lines([
|
|
995
|
+
'### asChild と prefixIcon / suffixIcon / isLoading を併用しない',
|
|
996
|
+
'',
|
|
997
|
+
'```tsx',
|
|
998
|
+
'// ✅ Correct',
|
|
999
|
+
'<Button asChild>',
|
|
1000
|
+
' <Link href="/items/new">新規作成</Link>',
|
|
1001
|
+
'</Button>',
|
|
1002
|
+
'',
|
|
1003
|
+
'// ❌ Wrong — asChild では prefixIcon は反映されない',
|
|
1004
|
+
'<Button asChild prefixIcon="add">',
|
|
1005
|
+
' <Link href="/items/new">新規作成</Link>',
|
|
1006
|
+
'</Button>',
|
|
1007
|
+
'```',
|
|
1008
|
+
]),
|
|
1009
|
+
jsdocTargets: [],
|
|
1010
|
+
},
|
|
1011
|
+
{
|
|
1012
|
+
id: 'disabled-vs-is-disabled',
|
|
1013
|
+
check: {
|
|
1014
|
+
description: 'Sparkle Design コンポーネントでは isDisabled を使う',
|
|
1015
|
+
recommendation:
|
|
1016
|
+
'Button / Input / Checkbox / IconButton では disabled ではなく isDisabled を使ってください。',
|
|
1017
|
+
pattern: /<(Button|Input|Checkbox|IconButton)\b[^>]*\sdisabled(?!=\s*\{)\b/g,
|
|
1018
|
+
},
|
|
1019
|
+
featureSection: lines([
|
|
1020
|
+
'### disabled ではなく isDisabled を使う',
|
|
1021
|
+
'',
|
|
1022
|
+
'```tsx',
|
|
1023
|
+
'// ✅ Correct',
|
|
1024
|
+
'<Button isDisabled>確定</Button>',
|
|
1025
|
+
'<Input isDisabled placeholder="無効状態" />',
|
|
1026
|
+
'',
|
|
1027
|
+
'// ❌ Wrong — disabled 属性を直接使わない',
|
|
1028
|
+
'<Button disabled>確定</Button>',
|
|
1029
|
+
'```',
|
|
1030
|
+
'',
|
|
1031
|
+
'HTML 標準の `disabled` も互換のため受け付けますが、Sparkle Design のコードでは `isDisabled` に統一します。',
|
|
1032
|
+
]),
|
|
1033
|
+
jsdocTargets: [],
|
|
1034
|
+
},
|
|
1035
|
+
{
|
|
1036
|
+
id: 'button-prefixicon-jsx',
|
|
1037
|
+
check: {
|
|
1038
|
+
description: 'Button の prefixIcon / suffixIcon に JSX を渡さない',
|
|
1039
|
+
recommendation:
|
|
1040
|
+
'prefixIcon / suffixIcon には Material Symbols のアイコン名(文字列)を渡してください。JSX(<Icon ... />)は渡さないでください。',
|
|
1041
|
+
pattern: /<Button\b[^>]*\b(?:prefixIcon|suffixIcon)\s*=\s*\{[^}]*</g,
|
|
1042
|
+
},
|
|
1043
|
+
featureSection: lines([
|
|
1044
|
+
'### Button の prefixIcon / suffixIcon に JSX を渡さない',
|
|
1045
|
+
'',
|
|
1046
|
+
'```tsx',
|
|
1047
|
+
'// ✅ Correct — 文字列でアイコン名を渡す',
|
|
1048
|
+
'<Button prefixIcon="bolt">アクション</Button>',
|
|
1049
|
+
'',
|
|
1050
|
+
'// ❌ Wrong — JSX を渡さない',
|
|
1051
|
+
'<Button prefixIcon={<Icon icon="bolt" size={4} />}>アクション</Button>',
|
|
1052
|
+
'```',
|
|
1053
|
+
]),
|
|
1054
|
+
jsdocTargets: [],
|
|
1055
|
+
},
|
|
1056
|
+
{
|
|
1057
|
+
id: 'icon-children-text',
|
|
1058
|
+
check: {
|
|
1059
|
+
description: 'Icon の children にテキストを渡さない',
|
|
1060
|
+
recommendation:
|
|
1061
|
+
'Icon には icon prop でアイコン名を渡してください。children にテキストを渡すのは旧 Material Icons の書き方です。',
|
|
1062
|
+
pattern: /<Icon\b(?:[^>]|\/(?!>))*>[^<]+<\/Icon>/g,
|
|
1063
|
+
},
|
|
1064
|
+
featureSection: lines([
|
|
1065
|
+
'### Icon の children にテキストを渡さない',
|
|
1066
|
+
'',
|
|
1067
|
+
'```tsx',
|
|
1068
|
+
'// ✅ Correct — icon prop を使う',
|
|
1069
|
+
'<Icon icon="chevron_left" size={4} />',
|
|
1070
|
+
'',
|
|
1071
|
+
'// ❌ Wrong — children にテキストを渡さない',
|
|
1072
|
+
'<Icon size={4}>chevron_left</Icon>',
|
|
1073
|
+
'```',
|
|
1074
|
+
]),
|
|
1075
|
+
jsdocTargets: [],
|
|
1076
|
+
},
|
|
862
1077
|
];
|
|
863
1078
|
|
|
864
1079
|
function getCheckRules() {
|
|
865
|
-
const order = ['dialog-form', 'dialog-button-wrap', 'button-icon-only', 'material-symbols-direct', 'shadcn-token'];
|
|
1080
|
+
const order = ['dialog-form', 'dialog-button-wrap', 'button-icon-only', 'material-symbols-direct', 'shadcn-token', 'tailwind-typography', 'card-title-typography', 'card-control-non-button', 'card-padding-override', 'aschild-with-icon-props', 'disabled-vs-is-disabled', 'button-prefixicon-jsx', 'icon-children-text'];
|
|
866
1081
|
|
|
867
1082
|
return ANTI_PATTERN_GROUPS.filter((group) => group.check)
|
|
868
1083
|
.map((group) => ({
|
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
|
|
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
|
-
|
|
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
|
-
|
|
30
|
-
|
|
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),
|
|
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
|
|
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),
|
|
172
|
+
collectFiles(path.resolve(process.cwd(), target), textFiles, cssFiles, visited);
|
|
83
173
|
}
|
|
84
174
|
|
|
85
|
-
const checkedFiles = [...
|
|
175
|
+
const checkedFiles = [...textFiles]
|
|
86
176
|
.map((filePath) => toRelativeReportPath(filePath))
|
|
87
177
|
.sort((left, right) => left.localeCompare(right));
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
|
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],
|
package/lib/font-manager.js
CHANGED
|
@@ -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,
|
|
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
|
-
//
|
|
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
|
);
|
package/lib/generate-css.js
CHANGED
|
@@ -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テンプレート
|
|
@@ -212,7 +286,7 @@ function processTemplate(template, config, grayMapping, radiusMapping, colors) {
|
|
|
212
286
|
processedCSS = processedCSS.replace(/\{\{FONT_FAMILY_PRO\}\}/g, proFamilyValue);
|
|
213
287
|
processedCSS = processedCSS.replace(/\{\{FONT_FAMILY_MONO\}\}/g, monoFamilyValue);
|
|
214
288
|
|
|
215
|
-
// 5.
|
|
289
|
+
// 5. 基本的な設定値による置換(オブジェクト・配列・拡張フィールドはスキップ)
|
|
216
290
|
Object.entries(config).forEach(([key, value]) => {
|
|
217
291
|
// 配列・オブジェクト・拡張フィールドはスキップ
|
|
218
292
|
if (Array.isArray(value) || (typeof value === 'object' && value !== null) || key === 'custom-css' || key === 'fonts' || key === 'extend' || key === 'source-packages') {
|
|
@@ -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
|
/**
|
|
@@ -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,7 +390,11 @@ 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;
|
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
|
-
|
|
47
|
-
|
|
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(
|
|
58
|
+
throw new Error(`${label} is empty`);
|
|
51
59
|
}
|
|
52
60
|
|
|
53
61
|
if (path.isAbsolute(trimmed)) {
|
|
54
|
-
throw new Error(
|
|
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
|
-
|
|
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(
|
|
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(
|
|
78
|
+
throw new Error(`${label} must stay inside the project root: ${trimmed}`);
|
|
71
79
|
}
|
|
72
80
|
|
|
73
|
-
const
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
?
|
|
225
|
-
:
|
|
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 =
|
|
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);
|