@rokkit/cli 1.3.12 → 1.3.13

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.
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Run all doctor checks against the given filesystem adapter.
3
+ * @param {{ exists: (p: string) => boolean, read: (p: string) => string, resolve: (p: string) => string }} fs
4
+ * @returns {Array<{ id: string, label: string, status: 'pass'|'fail', fixable: boolean, fix: string, autoFix?: string }>}
5
+ */
6
+ export function runChecks(fs: {
7
+ exists: (p: string) => boolean;
8
+ read: (p: string) => string;
9
+ resolve: (p: string) => string;
10
+ }): Array<{
11
+ id: string;
12
+ label: string;
13
+ status: "pass" | "fail";
14
+ fixable: boolean;
15
+ fix: string;
16
+ autoFix?: string;
17
+ }>;
@@ -1,54 +1,3 @@
1
- /**
2
- * Build the default named-token starter config source used by `doctor --fix`.
3
- * @returns {string}
4
- */
5
- export function defaultStarterSource(): string;
6
- /**
7
- * Run all doctor checks against the given filesystem adapter.
8
- * @param {{ exists: (p: string) => boolean, read: (p: string) => string, resolve: (p: string) => string }} fs
9
- * @returns {Array<{ id: string, label: string, status: 'pass'|'fail', fixable: boolean, fix: string, autoFix?: string }>}
10
- */
11
- export function runChecks(fs: {
12
- exists: (p: string) => boolean;
13
- read: (p: string) => string;
14
- resolve: (p: string) => string;
15
- }): Array<{
16
- id: string;
17
- label: string;
18
- status: "pass" | "fail";
19
- fixable: boolean;
20
- fix: string;
21
- autoFix?: string;
22
- }>;
23
- /**
24
- * Validate a parsed rokkit config for the named-token system. All advisory (warn).
25
- * @param {Record<string, unknown> | null} config
26
- * @returns {Array<{ id: string, label: string, status: 'warn', fixable: false, fix: string }>}
27
- */
28
- export function validateConfigShape(config: Record<string, unknown> | null): Array<{
29
- id: string;
30
- label: string;
31
- status: "warn";
32
- fixable: false;
33
- fix: string;
34
- }>;
35
- /**
36
- * Advisory lint: scan src/ for surface/border tokens used as text colour.
37
- * @param {{ list?: (dir: string) => string[], exists: (p: string) => boolean, read: (p: string) => string, resolve: (p: string) => string }} fs
38
- * @returns {Array<{ id: string, label: string, status: 'warn', fixable: false, fix: string }>}
39
- */
40
- export function checkTextTokenUsage(fs: {
41
- list?: (dir: string) => string[];
42
- exists: (p: string) => boolean;
43
- read: (p: string) => string;
44
- resolve: (p: string) => string;
45
- }): Array<{
46
- id: string;
47
- label: string;
48
- status: "warn";
49
- fixable: false;
50
- fix: string;
51
- }>;
52
1
  /**
53
2
  * Interactive doctor command — validates project setup.
54
3
  * @param {{ fix?: boolean }} [opts]
@@ -56,3 +5,6 @@ export function checkTextTokenUsage(fs: {
56
5
  export function doctor(opts?: {
57
6
  fix?: boolean;
58
7
  }): Promise<void>;
8
+ export { runChecks } from "./checks.js";
9
+ export { defaultStarterSource } from "./fix.js";
10
+ export { validateConfigShape, checkTextTokenUsage } from "./validate.js";
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Build the default named-token starter config source used by `doctor --fix`.
3
+ * @returns {string}
4
+ */
5
+ export function defaultStarterSource(): string;
6
+ /**
7
+ * Create a real filesystem adapter rooted at cwd.
8
+ * @param {string} cwd
9
+ */
10
+ export function createFsAdapter(cwd: string): {
11
+ exists: (p: any) => boolean;
12
+ read: (p: any) => string;
13
+ resolve: (p: any) => string;
14
+ list: (dir: any) => string[];
15
+ };
16
+ /**
17
+ * Auto-fix failed checks that are marked fixable.
18
+ * @param {Array<{ id: string, status: string, fixable: boolean, autoFix?: string, label: string }>} checks
19
+ * @param {string} cwd
20
+ * @returns {number} number of fixed items
21
+ */
22
+ export function autoFix(checks: Array<{
23
+ id: string;
24
+ status: string;
25
+ fixable: boolean;
26
+ autoFix?: string;
27
+ label: string;
28
+ }>, cwd: string): number;
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Count failures and print status for each check.
3
+ * @param {Array} checks
4
+ * @returns {number}
5
+ */
6
+ export function printChecks(checks: any[]): number;
7
+ /**
8
+ * @param {Array} checks
9
+ * @param {string} cwd
10
+ * @param {number} failures
11
+ * @param {boolean} fix
12
+ */
13
+ export function handleResults(checks: any[], cwd: string, failures: number, fix: boolean): void;
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Advisory validation run by `doctor`: config shape rules for the named-token
3
+ * system, and a scan for surface/border tokens misused as text colour.
4
+ */
5
+ /**
6
+ * Validate a parsed rokkit config for the named-token system. All advisory (warn).
7
+ * @param {Record<string, unknown> | null} config
8
+ * @returns {Array<{ id: string, label: string, status: 'warn', fixable: false, fix: string }>}
9
+ */
10
+ export function validateConfigShape(config: Record<string, unknown> | null): Array<{
11
+ id: string;
12
+ label: string;
13
+ status: "warn";
14
+ fixable: false;
15
+ fix: string;
16
+ }>;
17
+ /**
18
+ * Advisory lint: scan src/ for surface/border tokens used as text colour.
19
+ * @param {{ list?: (dir: string) => string[], exists: (p: string) => boolean, read: (p: string) => string, resolve: (p: string) => string }} fs
20
+ * @returns {Array<{ id: string, label: string, status: 'warn', fixable: false, fix: string }>}
21
+ */
22
+ export function checkTextTokenUsage(fs: {
23
+ list?: (dir: string) => string[];
24
+ exists: (p: string) => boolean;
25
+ read: (p: string) => string;
26
+ resolve: (p: string) => string;
27
+ }): Array<{
28
+ id: string;
29
+ label: string;
30
+ status: "warn";
31
+ fixable: false;
32
+ fix: string;
33
+ }>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rokkit/cli",
3
- "version": "1.3.12",
3
+ "version": "1.3.13",
4
4
  "description": "Command line utilities for icon bundling and conversion.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -27,7 +27,7 @@
27
27
  },
28
28
  "dependencies": {
29
29
  "@mdi/svg": "^7.4.47",
30
- "@rokkit/unocss": "1.3.12",
30
+ "@rokkit/unocss": "1.3.13",
31
31
  "sade": "^1.8.1",
32
32
  "@iconify/tools": "5.0.3",
33
33
  "@iconify/types": "^2.0.0",
package/src/checks.js ADDED
@@ -0,0 +1,146 @@
1
+ /**
2
+ * The `doctor` checks: one function per project-setup requirement, plus
3
+ * `runChecks` which runs them all against a filesystem adapter.
4
+ */
5
+ import { generateUnoConfig } from './init.js'
6
+
7
+ const KNOWN_THEMES = [
8
+ 'rokkit',
9
+ 'minimal',
10
+ 'material',
11
+ 'frosted',
12
+ 'zen-sumi'
13
+ ]
14
+
15
+ /**
16
+ * Check config file existence
17
+ * @param {Object} fs
18
+ * @returns {{ id: string, label: string, status: string, fixable: boolean, fix: string, autoFix: string }}
19
+ */
20
+ function checkConfig(fs) {
21
+ const configExists = fs.exists(fs.resolve('rokkit.config.js'))
22
+ return {
23
+ id: 'config-exists',
24
+ label: 'rokkit.config.js exists',
25
+ status: configExists ? 'pass' : 'fail',
26
+ fixable: true,
27
+ fix: 'Run `rokkit init` to generate config',
28
+ autoFix: 'generate-config'
29
+ }
30
+ }
31
+
32
+ /**
33
+ * Check uno.config.js uses presetRokkit
34
+ * @param {Object} fs
35
+ * @returns {Object}
36
+ */
37
+ function checkUnoPreset(fs) {
38
+ const unoPath = fs.resolve('uno.config.js')
39
+ const unoExists = fs.exists(unoPath)
40
+ const unoContent = unoExists ? fs.read(unoPath) : ''
41
+ const unoUsesPreset = unoContent.includes('presetRokkit') && unoContent.includes('rokkit.config')
42
+ return {
43
+ id: 'uno-uses-preset',
44
+ label: 'uno.config.js uses presetRokkit(config)',
45
+ status: unoUsesPreset ? 'pass' : 'fail',
46
+ fixable: false,
47
+ fix: unoExists
48
+ ? `Replace uno.config.js contents with:\n${generateUnoConfig()}`
49
+ : `Create uno.config.js with:\n${generateUnoConfig()}`
50
+ }
51
+ }
52
+
53
+ /**
54
+ * Check app.css has theme imports
55
+ * @param {Object} fs
56
+ * @returns {Object}
57
+ */
58
+ function checkCssImports(fs) {
59
+ const cssPath = fs.resolve('src/app.css')
60
+ const cssExists = fs.exists(cssPath)
61
+ const cssHasBase = cssExists && fs.read(cssPath).includes('@rokkit/themes/base.css')
62
+ return {
63
+ id: 'css-imports',
64
+ label: 'app.css imports @rokkit/themes/base.css',
65
+ status: cssHasBase ? 'pass' : 'fail',
66
+ fixable: true,
67
+ fix: 'Append base theme import to src/app.css',
68
+ autoFix: 'patch-css'
69
+ }
70
+ }
71
+
72
+ /**
73
+ * Check app.css has at least one theme style
74
+ * @param {Object} fs
75
+ * @returns {Object}
76
+ */
77
+ function checkCssTheme(fs) {
78
+ const cssPath = fs.resolve('src/app.css')
79
+ const cssExists = fs.exists(cssPath)
80
+ const css = cssExists ? fs.read(cssPath) : ''
81
+ const hasTheme = KNOWN_THEMES.some((t) => css.includes(`@rokkit/themes/${t}.css`))
82
+ return {
83
+ id: 'css-theme',
84
+ label: 'app.css has a theme style',
85
+ status: hasTheme ? 'pass' : 'warn',
86
+ fixable: false,
87
+ fix: `Add a theme import to src/app.css, e.g. @import '@rokkit/themes/rokkit.css'\n Available: ${KNOWN_THEMES.join(', ')}, or use a custom theme`
88
+ }
89
+ }
90
+
91
+ /**
92
+ * Check rokkit.config.js has a chart section.
93
+ * Missing chart config is a warning (not failure) — defaults work without it.
94
+ * @param {Object} fs
95
+ * @returns {Object}
96
+ */
97
+ function checkChartConfig(fs) {
98
+ const configPath = fs.resolve('rokkit.config.js')
99
+ const configExists = fs.exists(configPath)
100
+ const hasChart = configExists && /\bchart\s*:/.test(fs.read(configPath))
101
+ return {
102
+ id: 'chart-config',
103
+ label: 'rokkit.config.js has chart configuration',
104
+ status: hasChart ? 'pass' : 'warn',
105
+ fixable: configExists,
106
+ fix: 'Add a chart section to rokkit.config.js (see docs/design/17-chart-preset.md)',
107
+ autoFix: 'patch-chart-config'
108
+ }
109
+ }
110
+
111
+ /**
112
+ * Check app.html has init script
113
+ * @param {Object} fs
114
+ * @returns {Object}
115
+ */
116
+ function checkHtmlScript(fs) {
117
+ const htmlPath = fs.resolve('src/app.html')
118
+ const htmlExists = fs.exists(htmlPath)
119
+ const htmlHasScript =
120
+ htmlExists &&
121
+ (fs.read(htmlPath).includes('rokkit-theme') || fs.read(htmlPath).includes('data-mode'))
122
+ return {
123
+ id: 'html-init-script',
124
+ label: 'app.html has theme init script',
125
+ status: htmlHasScript ? 'pass' : 'fail',
126
+ fixable: true,
127
+ fix: 'Add flash-prevention script to src/app.html',
128
+ autoFix: 'patch-html'
129
+ }
130
+ }
131
+
132
+ /**
133
+ * Run all doctor checks against the given filesystem adapter.
134
+ * @param {{ exists: (p: string) => boolean, read: (p: string) => string, resolve: (p: string) => string }} fs
135
+ * @returns {Array<{ id: string, label: string, status: 'pass'|'fail', fixable: boolean, fix: string, autoFix?: string }>}
136
+ */
137
+ export function runChecks(fs) {
138
+ return [
139
+ checkConfig(fs),
140
+ checkUnoPreset(fs),
141
+ checkCssImports(fs),
142
+ checkCssTheme(fs),
143
+ checkHtmlScript(fs),
144
+ checkChartConfig(fs)
145
+ ]
146
+ }
package/src/doctor.js CHANGED
@@ -1,477 +1,22 @@
1
1
  /* eslint-disable no-console */
2
- import { existsSync, readFileSync, writeFileSync, readdirSync } from 'fs'
3
- import { resolve, join } from 'path'
4
- import { loadConfig as loadParsedConfig } from './config.js'
5
- import {
6
- generateUnoConfig,
7
- generateAppCssImports,
8
- generateChartConfig,
9
- generateConfig,
10
- serializeRokkitConfig
11
- } from './init.js'
12
- import { themeInitScript } from '@rokkit/unocss/hooks'
13
- import { checkContrastTokens } from './contrast.js'
14
-
15
- const KNOWN_THEMES = [
16
- 'rokkit',
17
- 'minimal',
18
- 'material',
19
- 'frosted',
20
- 'zen-sumi'
21
- ]
22
-
23
- /**
24
- * Build the default named-token starter config source used by `doctor --fix`.
25
- * @returns {string}
26
- */
27
- export function defaultStarterSource() {
28
- const config = generateConfig({
29
- palette: 'default',
30
- icons: 'rokkit',
31
- themes: ['rokkit'],
32
- switcher: 'manual',
33
- includeChart: true,
34
- chartColors: 'default',
35
- chartShades: 'standard'
36
- })
37
- return serializeRokkitConfig(config)
38
- }
39
-
40
- /**
41
- * Check config file existence
42
- * @param {Object} fs
43
- * @returns {{ id: string, label: string, status: string, fixable: boolean, fix: string, autoFix: string }}
44
- */
45
- function checkConfig(fs) {
46
- const configExists = fs.exists(fs.resolve('rokkit.config.js'))
47
- return {
48
- id: 'config-exists',
49
- label: 'rokkit.config.js exists',
50
- status: configExists ? 'pass' : 'fail',
51
- fixable: true,
52
- fix: 'Run `rokkit init` to generate config',
53
- autoFix: 'generate-config'
54
- }
55
- }
56
-
57
- /**
58
- * Check uno.config.js uses presetRokkit
59
- * @param {Object} fs
60
- * @returns {Object}
61
- */
62
- function checkUnoPreset(fs) {
63
- const unoPath = fs.resolve('uno.config.js')
64
- const unoExists = fs.exists(unoPath)
65
- const unoContent = unoExists ? fs.read(unoPath) : ''
66
- const unoUsesPreset = unoContent.includes('presetRokkit') && unoContent.includes('rokkit.config')
67
- return {
68
- id: 'uno-uses-preset',
69
- label: 'uno.config.js uses presetRokkit(config)',
70
- status: unoUsesPreset ? 'pass' : 'fail',
71
- fixable: false,
72
- fix: unoExists
73
- ? `Replace uno.config.js contents with:\n${generateUnoConfig()}`
74
- : `Create uno.config.js with:\n${generateUnoConfig()}`
75
- }
76
- }
77
-
78
- /**
79
- * Check app.css has theme imports
80
- * @param {Object} fs
81
- * @returns {Object}
82
- */
83
- function checkCssImports(fs) {
84
- const cssPath = fs.resolve('src/app.css')
85
- const cssExists = fs.exists(cssPath)
86
- const cssHasBase = cssExists && fs.read(cssPath).includes('@rokkit/themes/base.css')
87
- return {
88
- id: 'css-imports',
89
- label: 'app.css imports @rokkit/themes/base.css',
90
- status: cssHasBase ? 'pass' : 'fail',
91
- fixable: true,
92
- fix: 'Append base theme import to src/app.css',
93
- autoFix: 'patch-css'
94
- }
95
- }
96
-
97
- /**
98
- * Check app.css has at least one theme style
99
- * @param {Object} fs
100
- * @returns {Object}
101
- */
102
- function checkCssTheme(fs) {
103
- const cssPath = fs.resolve('src/app.css')
104
- const cssExists = fs.exists(cssPath)
105
- const css = cssExists ? fs.read(cssPath) : ''
106
- const hasTheme = KNOWN_THEMES.some((t) => css.includes(`@rokkit/themes/${t}.css`))
107
- return {
108
- id: 'css-theme',
109
- label: 'app.css has a theme style',
110
- status: hasTheme ? 'pass' : 'warn',
111
- fixable: false,
112
- fix: `Add a theme import to src/app.css, e.g. @import '@rokkit/themes/rokkit.css'\n Available: ${KNOWN_THEMES.join(', ')}, or use a custom theme`
113
- }
114
- }
115
-
116
- /**
117
- * Check rokkit.config.js has a chart section.
118
- * Missing chart config is a warning (not failure) — defaults work without it.
119
- * @param {Object} fs
120
- * @returns {Object}
121
- */
122
- function checkChartConfig(fs) {
123
- const configPath = fs.resolve('rokkit.config.js')
124
- const configExists = fs.exists(configPath)
125
- const hasChart = configExists && /\bchart\s*:/.test(fs.read(configPath))
126
- return {
127
- id: 'chart-config',
128
- label: 'rokkit.config.js has chart configuration',
129
- status: hasChart ? 'pass' : 'warn',
130
- fixable: configExists,
131
- fix: 'Add a chart section to rokkit.config.js (see docs/design/17-chart-preset.md)',
132
- autoFix: 'patch-chart-config'
133
- }
134
- }
135
-
136
- /**
137
- * Check app.html has init script
138
- * @param {Object} fs
139
- * @returns {Object}
140
- */
141
- function checkHtmlScript(fs) {
142
- const htmlPath = fs.resolve('src/app.html')
143
- const htmlExists = fs.exists(htmlPath)
144
- const htmlHasScript =
145
- htmlExists &&
146
- (fs.read(htmlPath).includes('rokkit-theme') || fs.read(htmlPath).includes('data-mode'))
147
- return {
148
- id: 'html-init-script',
149
- label: 'app.html has theme init script',
150
- status: htmlHasScript ? 'pass' : 'fail',
151
- fixable: true,
152
- fix: 'Add flash-prevention script to src/app.html',
153
- autoFix: 'patch-html'
154
- }
155
- }
156
-
157
- /**
158
- * Run all doctor checks against the given filesystem adapter.
159
- * @param {{ exists: (p: string) => boolean, read: (p: string) => string, resolve: (p: string) => string }} fs
160
- * @returns {Array<{ id: string, label: string, status: 'pass'|'fail', fixable: boolean, fix: string, autoFix?: string }>}
161
- */
162
- export function runChecks(fs) {
163
- return [
164
- checkConfig(fs),
165
- checkUnoPreset(fs),
166
- checkCssImports(fs),
167
- checkCssTheme(fs),
168
- checkHtmlScript(fs),
169
- checkChartConfig(fs)
170
- ]
171
- }
172
-
173
- const SKIP_DIRS = new Set(['node_modules', '.svelte-kit', 'dist', '.git', 'coverage'])
174
-
175
- /**
176
- * Recursively list absolute file paths under `dir`, skipping vendored/build dirs.
177
- * @param {string} dir
178
- * @returns {string[]}
179
- */
180
- function listFiles(dir) {
181
- const out = []
182
- for (const entry of readdirSync(dir, { withFileTypes: true })) {
183
- if (entry.isDirectory()) {
184
- if (SKIP_DIRS.has(entry.name)) continue
185
- out.push(...listFiles(join(dir, entry.name)))
186
- } else {
187
- out.push(join(dir, entry.name))
188
- }
189
- }
190
- return out
191
- }
192
-
193
- /**
194
- * Create a real filesystem adapter rooted at cwd.
195
- * @param {string} cwd
196
- */
197
- function createFsAdapter(cwd) {
198
- return {
199
- exists: (p) => existsSync(p),
200
- read: (p) => readFileSync(p, 'utf-8'),
201
- resolve: (p) => resolve(cwd, p),
202
- list: (dir) => listFiles(dir)
203
- }
204
- }
205
-
206
- /**
207
- * Apply the generate-config fix
208
- * @param {string} cwd
209
- * @param {string} label
210
- */
211
- function applyGenerateConfig(cwd, label) {
212
- const configPath = resolve(cwd, 'rokkit.config.js')
213
- writeFileSync(configPath, defaultStarterSource())
214
- console.info(` Fixed: ${label}`)
215
- }
216
-
217
- /**
218
- * Apply the patch-css fix
219
- * @param {string} cwd
220
- * @param {string} label
221
- */
222
- function applyPatchCss(cwd, label) {
223
- const cssPath = resolve(cwd, 'src/app.css')
224
- const imports = generateAppCssImports([])
225
- if (existsSync(cssPath)) {
226
- const existing = readFileSync(cssPath, 'utf-8')
227
- const missing = imports.filter((line) => !existing.includes(line))
228
- if (missing.length > 0) writeFileSync(cssPath, `${missing.join('\n')}\n${existing}`)
229
- } else {
230
- writeFileSync(cssPath, `${imports.join('\n')}\n`)
231
- }
232
- console.info(` Fixed: ${label}`)
233
- }
234
-
235
2
  /**
236
- * Apply the patch-html fix
237
- * @param {string} cwd
238
- * @param {string} label
239
- * @returns {boolean} whether fix was applied
3
+ * The `rokkit doctor` command — validates project setup, then reports and
4
+ * auto-fixes issues.
5
+ *
6
+ * Check definitions live in `checks.js`, the fs adapter + auto-fix handlers in
7
+ * `fix.js`, console reporting in `report.js`, and advisory shape/lint checks in
8
+ * `validate.js`. This file is the command orchestration + public surface.
240
9
  */
241
- function applyPatchHtml(cwd, label) {
242
- const htmlPath = resolve(cwd, 'src/app.html')
243
- if (!existsSync(htmlPath)) return false
244
- const html = readFileSync(htmlPath, 'utf-8')
245
- // Use the project's configured storageKey so the injected script reads the
246
- // same localStorage slot the app persists to — don't hardcode a key.
247
- const configPath = resolve(cwd, 'rokkit.config.js')
248
- const storageKey = existsSync(configPath)
249
- ? readFileSync(configPath, 'utf-8').match(/storageKey:\s*['"]([^'"]+)['"]/)?.[1]
250
- : undefined
251
- const marker = storageKey || 'rokkit-theme'
252
- if (html.includes(marker)) return false
253
- const script = themeInitScript({ storageKey })
254
- const patched = html.replace(/(<body[^>]*>)/, `$1\n${script}`)
255
- writeFileSync(htmlPath, patched)
256
- console.info(` Fixed: ${label}`)
257
- return true
258
- }
259
-
260
- /**
261
- * Patch rokkit.config.js to add a default chart section.
262
- * @param {string} cwd
263
- * @param {string} label
264
- */
265
- /* v8 ignore next 16 -- chart-config check emits 'warn' (not 'fail'); applyCheckFix
266
- only processes 'fail' statuses, making this handler unreachable via the normal
267
- fix flow. Kept for potential future use if the check severity changes. */
268
- function applyPatchChartConfig(cwd, label) {
269
- const configPath = resolve(cwd, 'rokkit.config.js')
270
- if (!existsSync(configPath)) return false
271
- const content = readFileSync(configPath, 'utf-8')
272
- if (/\bchart\s*:/.test(content)) return false
273
- const chartConfig = generateChartConfig({ chartColors: 'default', chartShades: 'standard' })
274
- const chartJson = JSON.stringify(chartConfig, null, 2).replace(/\n/g, '\n ')
275
- // Inject before the last closing brace of the export default object
276
- const patched = content.replace(/(\n?}\s*\n?)$/, `,\n chart: ${chartJson}\n}\n`)
277
- writeFileSync(configPath, patched)
278
- console.info(` Fixed: ${label}`)
279
- return true
280
- }
281
-
282
- /** @type {Record<string, (cwd: string, label: string) => boolean|void>} */
283
- const FIX_HANDLERS = {
284
- 'generate-config': (cwd, label) => {
285
- applyGenerateConfig(cwd, label)
286
- return true
287
- },
288
- 'patch-css': (cwd, label) => {
289
- applyPatchCss(cwd, label)
290
- return true
291
- },
292
- 'patch-html': (cwd, label) => applyPatchHtml(cwd, label),
293
- 'patch-chart-config': (cwd, label) => applyPatchChartConfig(cwd, label)
294
- }
295
-
296
- /**
297
- * @param {number} fixed
298
- * @param {{ status: string, fixable: boolean, autoFix?: string, label: string }} check
299
- * @param {string} cwd
300
- * @returns {number}
301
- */
302
- function applyCheckFix(fixed, check, cwd) {
303
- if (check.status !== 'fail' || !check.fixable) return fixed
304
- const handler = FIX_HANDLERS[check.autoFix]
305
- return handler && handler(cwd, check.label) ? fixed + 1 : fixed
306
- }
307
-
308
- /**
309
- * Auto-fix failed checks that are marked fixable.
310
- * @param {Array<{ id: string, status: string, fixable: boolean, autoFix?: string, label: string }>} checks
311
- * @param {string} cwd
312
- * @returns {number} number of fixed items
313
- */
314
- function autoFix(checks, cwd) {
315
- return checks.reduce((fixed, check) => applyCheckFix(fixed, check, cwd), 0)
316
- }
317
-
318
- /**
319
- * Print fix hints for failing checks
320
- * @param {Array} checks
321
- */
322
- function printFixHints(checks) {
323
- for (const check of checks) {
324
- if (check.status === 'fail' || check.status === 'warn') {
325
- console.info(` ${check.fixable ? '(auto-fixable) ' : ''}${check.fix}`)
326
- }
327
- }
328
- }
329
-
330
- /**
331
- * Print remaining manual action items
332
- * @param {Array} checks
333
- */
334
- function printManualItems(checks) {
335
- for (const check of checks) {
336
- if (check.status === 'fail' && !check.fixable) {
337
- console.info(` - ${check.label}: ${check.fix}`)
338
- }
339
- }
340
- }
341
-
342
- /**
343
- * Count failures and print status for each check.
344
- * @param {Array} checks
345
- * @returns {number}
346
- */
347
- function printChecks(checks) {
348
- let failures = 0
349
- for (const check of checks) {
350
- const icon = check.status === 'pass' ? 'PASS' : check.status === 'warn' ? 'WARN' : 'FAIL'
351
- console.info(` ${icon} ${check.label}`)
352
- if (check.status === 'fail') failures++
353
- }
354
- return failures
355
- }
356
-
357
- /**
358
- * Handle auto-fix flow and print results.
359
- * @param {Array} checks
360
- * @param {string} cwd
361
- * @param {number} failures
362
- */
363
- function handleAutoFix(checks, cwd, failures) {
364
- console.info('\nAuto-fixing...\n')
365
- const fixed = autoFix(checks, cwd)
366
- const remaining = failures - fixed
367
- if (remaining > 0) {
368
- console.info(`\n${fixed} fixed, ${remaining} require manual action:`)
369
- printManualItems(checks)
370
- } else {
371
- console.info(`\nAll ${fixed} issues fixed!`)
372
- }
373
- }
374
-
375
- /**
376
- * @param {Array} checks
377
- * @param {string} cwd
378
- * @param {number} failures
379
- * @param {boolean} fix
380
- */
381
- function handleResults(checks, cwd, failures, fix) {
382
- if (failures === 0) return
383
- if (fix) {
384
- handleAutoFix(checks, cwd, failures)
385
- } else {
386
- printFixHints(checks)
387
- process.exitCode = 1
388
- }
389
- }
390
-
391
- /**
392
- * Validate a parsed rokkit config for the named-token system. All advisory (warn).
393
- * @param {Record<string, unknown> | null} config
394
- * @returns {Array<{ id: string, label: string, status: 'warn', fixable: false, fix: string }>}
395
- */
396
- export function validateConfigShape(config) {
397
- if (!config) return []
398
- const checks = []
399
- const usesColorsAlias =
400
- typeof config.colors === 'object' && config.colors !== null && !Array.isArray(config.colors) && !config.skin
401
- const colormap = config.skins?.default ?? config.skin ?? config.colors ?? {}
402
-
403
- if (!('ink' in colormap)) {
404
- checks.push({
405
- id: 'skin-ink-role',
406
- label: 'skin defines an `ink` role',
407
- status: 'warn',
408
- fixable: false,
409
- fix: "Add `ink: '<palette>'` (reusing the surface palette is fine) — without it, ink-* text tokens fall back to the surface palette."
410
- })
411
- }
412
- if (config.colorSpace === 'oklch' && Object.keys(config.palettes ?? {}).length === 0) {
413
- checks.push({
414
- id: 'oklch-needs-palettes',
415
- label: 'colorSpace `oklch` has a `palettes` block',
416
- status: 'warn',
417
- fixable: false,
418
- fix: 'oklch values need a `palettes` block of bare "L C H" components; Tailwind named colors are rgb.'
419
- })
420
- }
421
- if (usesColorsAlias) {
422
- checks.push({
423
- id: 'colors-alias',
424
- label: 'config uses `skin` (not the legacy `colors` alias)',
425
- status: 'warn',
426
- fixable: false,
427
- fix: 'Rename `colors:` to `skin:` — `colors` is a back-compat alias.'
428
- })
429
- }
430
- return checks
431
- }
432
-
433
- /**
434
- * Patterns that flag a surface/border token being used as a text colour.
435
- * paper-edge / paper-mute are border/surface tones — used as text they're faint
436
- * in light and invisible in dark. paper / paper-soft are legitimate on-colours.
437
- */
438
- const TEXT_MISUSE_PATTERNS = [
439
- /text-paper-edge\b/,
440
- /text-paper-mute\b/,
441
- /color:\s*var\(\s*--paper-edge\s*\)/,
442
- /color:\s*var\(\s*--paper-mute\s*\)/
443
- ]
444
-
445
- /**
446
- * Advisory lint: scan src/ for surface/border tokens used as text colour.
447
- * @param {{ list?: (dir: string) => string[], exists: (p: string) => boolean, read: (p: string) => string, resolve: (p: string) => string }} fs
448
- * @returns {Array<{ id: string, label: string, status: 'warn', fixable: false, fix: string }>}
449
- */
450
- export function checkTextTokenUsage(fs) {
451
- if (typeof fs.list !== 'function' || !fs.exists(fs.resolve('src'))) return []
452
-
453
- const cwd = process.cwd()
454
- const hits = []
455
- for (const file of fs.list(fs.resolve('src'))) {
456
- if (!file.endsWith('.css') && !file.endsWith('.svelte')) continue
457
- const lines = fs.read(file).split('\n')
458
- const display = file.startsWith(cwd) ? file.slice(cwd.length).replace(/^\//, '') : file
459
- lines.forEach((line, i) => {
460
- if (TEXT_MISUSE_PATTERNS.some((re) => re.test(line))) hits.push(`${display}:${i + 1}`)
461
- })
462
- }
463
-
464
- if (hits.length === 0) return []
465
- return [
466
- {
467
- id: 'surface-token-as-text',
468
- label: 'no surface/border token used as text colour',
469
- status: 'warn',
470
- fixable: false,
471
- fix: `paper-edge / paper-mute are border/surface tokens — never use them for text (faint in light, invisible in dark). Use the ink scale (ink-mute for readable secondary text). Found at:\n ${hits.join('\n ')}`
472
- }
473
- ]
474
- }
10
+ import { loadConfig as loadParsedConfig } from './config.js'
11
+ import { checkContrastTokens } from './contrast.js'
12
+ import { runChecks } from './checks.js'
13
+ import { createFsAdapter } from './fix.js'
14
+ import { handleResults, printChecks } from './report.js'
15
+ import { validateConfigShape, checkTextTokenUsage } from './validate.js'
16
+
17
+ export { runChecks } from './checks.js'
18
+ export { defaultStarterSource } from './fix.js'
19
+ export { validateConfigShape, checkTextTokenUsage } from './validate.js'
475
20
 
476
21
  /**
477
22
  * Interactive doctor command — validates project setup.
package/src/fix.js ADDED
@@ -0,0 +1,176 @@
1
+ /* eslint-disable no-console */
2
+ /**
3
+ * The filesystem adapter and auto-fix machinery behind `doctor --fix`:
4
+ * apply each fix handler for a failed check and count what was fixed.
5
+ */
6
+ import { existsSync, readFileSync, writeFileSync, readdirSync } from 'fs'
7
+ import { resolve, join } from 'path'
8
+ import {
9
+ generateAppCssImports,
10
+ generateChartConfig,
11
+ generateConfig,
12
+ serializeRokkitConfig
13
+ } from './init.js'
14
+ import { themeInitScript } from '@rokkit/unocss/hooks'
15
+
16
+ /**
17
+ * Build the default named-token starter config source used by `doctor --fix`.
18
+ * @returns {string}
19
+ */
20
+ export function defaultStarterSource() {
21
+ const config = generateConfig({
22
+ palette: 'default',
23
+ icons: 'rokkit',
24
+ themes: ['rokkit'],
25
+ switcher: 'manual',
26
+ includeChart: true,
27
+ chartColors: 'default',
28
+ chartShades: 'standard'
29
+ })
30
+ return serializeRokkitConfig(config)
31
+ }
32
+
33
+ const SKIP_DIRS = new Set(['node_modules', '.svelte-kit', 'dist', '.git', 'coverage'])
34
+
35
+ /**
36
+ * Recursively list absolute file paths under `dir`, skipping vendored/build dirs.
37
+ * @param {string} dir
38
+ * @returns {string[]}
39
+ */
40
+ function listFiles(dir) {
41
+ const out = []
42
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
43
+ if (entry.isDirectory()) {
44
+ if (SKIP_DIRS.has(entry.name)) continue
45
+ out.push(...listFiles(join(dir, entry.name)))
46
+ } else {
47
+ out.push(join(dir, entry.name))
48
+ }
49
+ }
50
+ return out
51
+ }
52
+
53
+ /**
54
+ * Create a real filesystem adapter rooted at cwd.
55
+ * @param {string} cwd
56
+ */
57
+ export function createFsAdapter(cwd) {
58
+ return {
59
+ exists: (p) => existsSync(p),
60
+ read: (p) => readFileSync(p, 'utf-8'),
61
+ resolve: (p) => resolve(cwd, p),
62
+ list: (dir) => listFiles(dir)
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Apply the generate-config fix
68
+ * @param {string} cwd
69
+ * @param {string} label
70
+ */
71
+ function applyGenerateConfig(cwd, label) {
72
+ const configPath = resolve(cwd, 'rokkit.config.js')
73
+ writeFileSync(configPath, defaultStarterSource())
74
+ console.info(` Fixed: ${label}`)
75
+ }
76
+
77
+ /**
78
+ * Apply the patch-css fix
79
+ * @param {string} cwd
80
+ * @param {string} label
81
+ */
82
+ function applyPatchCss(cwd, label) {
83
+ const cssPath = resolve(cwd, 'src/app.css')
84
+ const imports = generateAppCssImports([])
85
+ if (existsSync(cssPath)) {
86
+ const existing = readFileSync(cssPath, 'utf-8')
87
+ const missing = imports.filter((line) => !existing.includes(line))
88
+ if (missing.length > 0) writeFileSync(cssPath, `${missing.join('\n')}\n${existing}`)
89
+ } else {
90
+ writeFileSync(cssPath, `${imports.join('\n')}\n`)
91
+ }
92
+ console.info(` Fixed: ${label}`)
93
+ }
94
+
95
+ /**
96
+ * Apply the patch-html fix
97
+ * @param {string} cwd
98
+ * @param {string} label
99
+ * @returns {boolean} whether fix was applied
100
+ */
101
+ function applyPatchHtml(cwd, label) {
102
+ const htmlPath = resolve(cwd, 'src/app.html')
103
+ if (!existsSync(htmlPath)) return false
104
+ const html = readFileSync(htmlPath, 'utf-8')
105
+ // Use the project's configured storageKey so the injected script reads the
106
+ // same localStorage slot the app persists to — don't hardcode a key.
107
+ const configPath = resolve(cwd, 'rokkit.config.js')
108
+ const storageKey = existsSync(configPath)
109
+ ? readFileSync(configPath, 'utf-8').match(/storageKey:\s*['"]([^'"]+)['"]/)?.[1]
110
+ : undefined
111
+ const marker = storageKey || 'rokkit-theme'
112
+ if (html.includes(marker)) return false
113
+ const script = themeInitScript({ storageKey })
114
+ const patched = html.replace(/(<body[^>]*>)/, `$1\n${script}`)
115
+ writeFileSync(htmlPath, patched)
116
+ console.info(` Fixed: ${label}`)
117
+ return true
118
+ }
119
+
120
+ /**
121
+ * Patch rokkit.config.js to add a default chart section.
122
+ * @param {string} cwd
123
+ * @param {string} label
124
+ */
125
+ /* v8 ignore next 16 -- chart-config check emits 'warn' (not 'fail'); applyCheckFix
126
+ only processes 'fail' statuses, making this handler unreachable via the normal
127
+ fix flow. Kept for potential future use if the check severity changes. */
128
+ function applyPatchChartConfig(cwd, label) {
129
+ const configPath = resolve(cwd, 'rokkit.config.js')
130
+ if (!existsSync(configPath)) return false
131
+ const content = readFileSync(configPath, 'utf-8')
132
+ if (/\bchart\s*:/.test(content)) return false
133
+ const chartConfig = generateChartConfig({ chartColors: 'default', chartShades: 'standard' })
134
+ const chartJson = JSON.stringify(chartConfig, null, 2).replace(/\n/g, '\n ')
135
+ // Inject before the last closing brace of the export default object
136
+ const patched = content.replace(/(\n?}\s*\n?)$/, `,\n chart: ${chartJson}\n}\n`)
137
+ writeFileSync(configPath, patched)
138
+ console.info(` Fixed: ${label}`)
139
+ return true
140
+ }
141
+
142
+ /** @type {Record<string, (cwd: string, label: string) => boolean|void>} */
143
+ const FIX_HANDLERS = {
144
+ 'generate-config': (cwd, label) => {
145
+ applyGenerateConfig(cwd, label)
146
+ return true
147
+ },
148
+ 'patch-css': (cwd, label) => {
149
+ applyPatchCss(cwd, label)
150
+ return true
151
+ },
152
+ 'patch-html': (cwd, label) => applyPatchHtml(cwd, label),
153
+ 'patch-chart-config': (cwd, label) => applyPatchChartConfig(cwd, label)
154
+ }
155
+
156
+ /**
157
+ * @param {number} fixed
158
+ * @param {{ status: string, fixable: boolean, autoFix?: string, label: string }} check
159
+ * @param {string} cwd
160
+ * @returns {number}
161
+ */
162
+ function applyCheckFix(fixed, check, cwd) {
163
+ if (check.status !== 'fail' || !check.fixable) return fixed
164
+ const handler = FIX_HANDLERS[check.autoFix]
165
+ return handler && handler(cwd, check.label) ? fixed + 1 : fixed
166
+ }
167
+
168
+ /**
169
+ * Auto-fix failed checks that are marked fixable.
170
+ * @param {Array<{ id: string, status: string, fixable: boolean, autoFix?: string, label: string }>} checks
171
+ * @param {string} cwd
172
+ * @returns {number} number of fixed items
173
+ */
174
+ export function autoFix(checks, cwd) {
175
+ return checks.reduce((fixed, check) => applyCheckFix(fixed, check, cwd), 0)
176
+ }
package/src/report.js ADDED
@@ -0,0 +1,79 @@
1
+ /* eslint-disable no-console */
2
+ /**
3
+ * Console reporting for `doctor`: print per-check status lines, fix hints and
4
+ * manual action items, and orchestrate the auto-fix flow summary.
5
+ */
6
+ import { autoFix } from './fix.js'
7
+
8
+ /**
9
+ * Print fix hints for failing checks
10
+ * @param {Array} checks
11
+ */
12
+ function printFixHints(checks) {
13
+ for (const check of checks) {
14
+ if (check.status === 'fail' || check.status === 'warn') {
15
+ console.info(` ${check.fixable ? '(auto-fixable) ' : ''}${check.fix}`)
16
+ }
17
+ }
18
+ }
19
+
20
+ /**
21
+ * Print remaining manual action items
22
+ * @param {Array} checks
23
+ */
24
+ function printManualItems(checks) {
25
+ for (const check of checks) {
26
+ if (check.status === 'fail' && !check.fixable) {
27
+ console.info(` - ${check.label}: ${check.fix}`)
28
+ }
29
+ }
30
+ }
31
+
32
+ /**
33
+ * Count failures and print status for each check.
34
+ * @param {Array} checks
35
+ * @returns {number}
36
+ */
37
+ export function printChecks(checks) {
38
+ let failures = 0
39
+ for (const check of checks) {
40
+ const icon = check.status === 'pass' ? 'PASS' : check.status === 'warn' ? 'WARN' : 'FAIL'
41
+ console.info(` ${icon} ${check.label}`)
42
+ if (check.status === 'fail') failures++
43
+ }
44
+ return failures
45
+ }
46
+
47
+ /**
48
+ * Handle auto-fix flow and print results.
49
+ * @param {Array} checks
50
+ * @param {string} cwd
51
+ * @param {number} failures
52
+ */
53
+ function handleAutoFix(checks, cwd, failures) {
54
+ console.info('\nAuto-fixing...\n')
55
+ const fixed = autoFix(checks, cwd)
56
+ const remaining = failures - fixed
57
+ if (remaining > 0) {
58
+ console.info(`\n${fixed} fixed, ${remaining} require manual action:`)
59
+ printManualItems(checks)
60
+ } else {
61
+ console.info(`\nAll ${fixed} issues fixed!`)
62
+ }
63
+ }
64
+
65
+ /**
66
+ * @param {Array} checks
67
+ * @param {string} cwd
68
+ * @param {number} failures
69
+ * @param {boolean} fix
70
+ */
71
+ export function handleResults(checks, cwd, failures, fix) {
72
+ if (failures === 0) return
73
+ if (fix) {
74
+ handleAutoFix(checks, cwd, failures)
75
+ } else {
76
+ printFixHints(checks)
77
+ process.exitCode = 1
78
+ }
79
+ }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Advisory validation run by `doctor`: config shape rules for the named-token
3
+ * system, and a scan for surface/border tokens misused as text colour.
4
+ */
5
+
6
+ /**
7
+ * Validate a parsed rokkit config for the named-token system. All advisory (warn).
8
+ * @param {Record<string, unknown> | null} config
9
+ * @returns {Array<{ id: string, label: string, status: 'warn', fixable: false, fix: string }>}
10
+ */
11
+ export function validateConfigShape(config) {
12
+ if (!config) return []
13
+ const checks = []
14
+ const usesColorsAlias =
15
+ typeof config.colors === 'object' && config.colors !== null && !Array.isArray(config.colors) && !config.skin
16
+ const colormap = config.skins?.default ?? config.skin ?? config.colors ?? {}
17
+
18
+ if (!('ink' in colormap)) {
19
+ checks.push({
20
+ id: 'skin-ink-role',
21
+ label: 'skin defines an `ink` role',
22
+ status: 'warn',
23
+ fixable: false,
24
+ fix: "Add `ink: '<palette>'` (reusing the surface palette is fine) — without it, ink-* text tokens fall back to the surface palette."
25
+ })
26
+ }
27
+ if (config.colorSpace === 'oklch' && Object.keys(config.palettes ?? {}).length === 0) {
28
+ checks.push({
29
+ id: 'oklch-needs-palettes',
30
+ label: 'colorSpace `oklch` has a `palettes` block',
31
+ status: 'warn',
32
+ fixable: false,
33
+ fix: 'oklch values need a `palettes` block of bare "L C H" components; Tailwind named colors are rgb.'
34
+ })
35
+ }
36
+ if (usesColorsAlias) {
37
+ checks.push({
38
+ id: 'colors-alias',
39
+ label: 'config uses `skin` (not the legacy `colors` alias)',
40
+ status: 'warn',
41
+ fixable: false,
42
+ fix: 'Rename `colors:` to `skin:` — `colors` is a back-compat alias.'
43
+ })
44
+ }
45
+ return checks
46
+ }
47
+
48
+ /**
49
+ * Patterns that flag a surface/border token being used as a text colour.
50
+ * paper-edge / paper-mute are border/surface tones — used as text they're faint
51
+ * in light and invisible in dark. paper / paper-soft are legitimate on-colours.
52
+ */
53
+ const TEXT_MISUSE_PATTERNS = [
54
+ /text-paper-edge\b/,
55
+ /text-paper-mute\b/,
56
+ /color:\s*var\(\s*--paper-edge\s*\)/,
57
+ /color:\s*var\(\s*--paper-mute\s*\)/
58
+ ]
59
+
60
+ /**
61
+ * Advisory lint: scan src/ for surface/border tokens used as text colour.
62
+ * @param {{ list?: (dir: string) => string[], exists: (p: string) => boolean, read: (p: string) => string, resolve: (p: string) => string }} fs
63
+ * @returns {Array<{ id: string, label: string, status: 'warn', fixable: false, fix: string }>}
64
+ */
65
+ export function checkTextTokenUsage(fs) {
66
+ if (typeof fs.list !== 'function' || !fs.exists(fs.resolve('src'))) return []
67
+
68
+ const cwd = process.cwd()
69
+ const hits = []
70
+ for (const file of fs.list(fs.resolve('src'))) {
71
+ if (!file.endsWith('.css') && !file.endsWith('.svelte')) continue
72
+ const lines = fs.read(file).split('\n')
73
+ const display = file.startsWith(cwd) ? file.slice(cwd.length).replace(/^\//, '') : file
74
+ lines.forEach((line, i) => {
75
+ if (TEXT_MISUSE_PATTERNS.some((re) => re.test(line))) hits.push(`${display}:${i + 1}`)
76
+ })
77
+ }
78
+
79
+ if (hits.length === 0) return []
80
+ return [
81
+ {
82
+ id: 'surface-token-as-text',
83
+ label: 'no surface/border token used as text colour',
84
+ status: 'warn',
85
+ fixable: false,
86
+ fix: `paper-edge / paper-mute are border/surface tokens — never use them for text (faint in light, invisible in dark). Use the ink scale (ink-mute for readable secondary text). Found at:\n ${hits.join('\n ')}`
87
+ }
88
+ ]
89
+ }