@nuasite/cli 0.65.2 → 0.66.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nuasite/cli",
3
- "version": "0.65.2",
3
+ "version": "0.66.0",
4
4
  "license": "Apache-2.0",
5
5
  "type": "module",
6
6
  "files": [
@@ -25,8 +25,9 @@
25
25
  "dependencies": {
26
26
  "@astrojs/compiler": "^4.0.0",
27
27
  "@jridgewell/trace-mapping": "^0.3.31",
28
- "@nuasite/cms": "0.65.2",
29
- "@nuasite/cms-core": "0.65.2"
28
+ "@nuasite/cms": "0.66.0",
29
+ "@nuasite/cms-core": "0.66.0",
30
+ "tailwindcss": "4.2.2"
30
31
  },
31
32
  "devDependencies": {
32
33
  "@types/bun": "1.3.11"
package/src/check-code.ts CHANGED
@@ -8,17 +8,29 @@
8
8
  * This is a *syntax* check, not a type check. A wrong prop type or a missing export still needs
9
9
  * `astro check` or a build. What it does catch is the error that stops a build dead before a single
10
10
  * page renders — an unescaped quote inside a Czech string being the one that prompted it.
11
+ *
12
+ * Stylesheets are parsed the way the build parses them. A Nua site builds with Tailwind's Vite
13
+ * plugin, which runs every stylesheet through Tailwind's CSS parser, Tailwind or not, and that
14
+ * parser refuses what a browser quietly repairs: a rule cut off before its closing brace failed a
15
+ * whole site's build while the editor preview, which concatenates the files, only lost its styles.
11
16
  */
12
17
 
13
18
  import { transform } from '@astrojs/compiler'
14
19
  import { originalPositionFor, TraceMap } from '@jridgewell/trace-mapping'
15
20
  import type { CheckFinding } from '@nuasite/cms-core'
16
21
  import { Glob } from 'bun'
22
+ import { createRequire } from 'node:module'
17
23
  import path from 'node:path'
24
+ import { compile as bundledCompile } from 'tailwindcss'
25
+
26
+ type Compile = typeof bundledCompile
18
27
 
19
28
  /** Directories that are never the project's own source. */
20
29
  const IGNORED = ['node_modules', 'dist', '.astro', '.git', '.output', '.vercel', '.netlify']
21
30
 
31
+ /** Copied to the output as they are: nothing parses them, so nothing in them can fail a build. */
32
+ const UNPROCESSED_STYLESHEETS = ['public']
33
+
22
34
  const isIgnored = (relative: string): boolean =>
23
35
  // Declaration files are types only — nothing compiles them, and the TS loader rejects them.
24
36
  /\.d\.[mc]?ts$/.test(relative) || relative.split(path.sep).some(segment => IGNORED.includes(segment))
@@ -103,31 +115,110 @@ function checkScriptFile(relative: string, source: string, loader: 'ts' | 'tsx')
103
115
  }
104
116
  }
105
117
 
118
+ /**
119
+ * The parser's own syntax errors. Compiling a stylesheet alone also reports what only the whole
120
+ * project could settle (`@apply` of a utility its theme defines, a `@reference` it needs), and those
121
+ * are left to the build.
122
+ */
123
+ const CSS_SYNTAX_ERROR = /^(Missing closing|Missing opening|Unterminated string|Invalid declaration)/
124
+
125
+ /** A Tailwind `CssSyntaxError`: its message is `<from>:<line>:<column>: <what>`, its `loc` the offsets into the source. */
126
+ function cssSyntaxError(error: unknown): { message: string; offset: number | null } | null {
127
+ if (!(error instanceof Error)) return null
128
+ const message = error.message.replace(/^.*?:\d+:\d+: /, '')
129
+ if (!CSS_SYNTAX_ERROR.test(message)) return null
130
+ const loc = 'loc' in error && Array.isArray(error.loc) ? error.loc : null
131
+ const offset = loc && typeof loc[1] === 'number' ? loc[1] : null
132
+ return { message, offset }
133
+ }
134
+
135
+ /** 1-based line and column of an offset, the way editors and build output count them. */
136
+ function lineAndColumn(source: string, offset: number): { line: number; column: number } {
137
+ const before = source.slice(0, offset)
138
+ const line = before.split('\n').length
139
+ return { line, column: offset - before.lastIndexOf('\n') }
140
+ }
141
+
142
+ /**
143
+ * The site's own Tailwind when it has one, else the copy this CLI ships. Its parser's strictness moved between
144
+ * versions (4.0 accepts `color red;`, 4.1 refuses it), and the check has to refuse what the site's build refuses.
145
+ */
146
+ async function compileFor(root: string): Promise<Compile> {
147
+ try {
148
+ const module: unknown = await import(createRequire(path.join(root, 'package.json')).resolve('tailwindcss'))
149
+ if (typeof module === 'object' && module !== null && 'compile' in module && typeof module.compile === 'function') {
150
+ return module.compile as Compile
151
+ }
152
+ } catch {
153
+ // Not installed in the site, or not loadable from here: the bundled parser is the next best judge.
154
+ }
155
+ return bundledCompile
156
+ }
157
+
158
+ async function checkCssFile(compile: Compile, absolute: string, relative: string, source: string): Promise<CheckFinding | null> {
159
+ try {
160
+ // Imports and plugins resolve to nothing: each stylesheet is parsed on its own, and loading the
161
+ // project's would only add errors that are not syntax.
162
+ await compile(source, {
163
+ base: path.dirname(absolute),
164
+ from: relative,
165
+ loadStylesheet: async (id, base) => ({ path: id, base, content: '' }),
166
+ loadModule: async (id, base) => ({ path: id, base, module: () => {} }),
167
+ })
168
+ return null
169
+ } catch (error) {
170
+ const syntax = cssSyntaxError(error)
171
+ if (!syntax) return null
172
+ return {
173
+ severity: 'error',
174
+ code: 'code/css-syntax',
175
+ file: relative,
176
+ ...(syntax.offset === null ? {} : lineAndColumn(source, syntax.offset)),
177
+ message: syntax.message,
178
+ }
179
+ }
180
+ }
181
+
106
182
  export interface CodeCheckResult {
107
183
  findings: CheckFinding[]
108
184
  files: number
185
+ /** The files parsed, relative to the root: what `--fail-on` can vouch for. */
186
+ paths: string[]
187
+ }
188
+
189
+ export interface CodeCheckOptions {
190
+ /**
191
+ * May the check load the project's own packages? Its `tailwindcss` is the site's real parser, but loading it runs
192
+ * the project's installed code, which `--no-live` promises not to do; the bundled parser stands in then.
193
+ */
194
+ projectCode: boolean
109
195
  }
110
196
 
111
- /** Parse every `.astro`/`.ts`/`.tsx` under `root`, newest failure first is not meaningful here — order is by path. */
112
- export async function checkCode(root: string): Promise<CodeCheckResult> {
197
+ /** Parse every `.astro`/`.ts`/`.tsx`/`.css` under `root`; order is by path. */
198
+ export async function checkCode(root: string, { projectCode }: CodeCheckOptions = { projectCode: false }): Promise<CodeCheckResult> {
113
199
  const findings: CheckFinding[] = []
114
200
  let files = 0
115
201
 
116
202
  const paths: string[] = []
117
- for await (const relative of new Glob('**/*.{astro,ts,tsx,mts,cts}').scan({ cwd: root })) {
118
- if (!isIgnored(relative)) paths.push(relative)
203
+ for await (const relative of new Glob('**/*.{astro,ts,tsx,mts,cts,css}').scan({ cwd: root })) {
204
+ if (isIgnored(relative)) continue
205
+ if (relative.endsWith('.css') && UNPROCESSED_STYLESHEETS.includes(relative.split(path.sep)[0]!)) continue
206
+ paths.push(relative)
119
207
  }
120
208
  paths.sort()
121
209
 
210
+ const compile = projectCode && paths.some(relative => relative.endsWith('.css')) ? await compileFor(root) : bundledCompile
122
211
  for (const relative of paths) {
123
212
  files++
124
213
  const absolute = path.join(root, relative)
125
214
  const source = await Bun.file(absolute).text()
126
215
  const finding = relative.endsWith('.astro')
127
216
  ? await checkAstroFile(absolute, relative, source)
217
+ : relative.endsWith('.css')
218
+ ? await checkCssFile(compile, absolute, relative, source)
128
219
  : checkScriptFile(relative, source, relative.endsWith('.tsx') ? 'tsx' : 'ts')
129
220
  if (finding) findings.push(finding)
130
221
  }
131
222
 
132
- return { findings, files }
223
+ return { findings, files, paths }
133
224
  }
package/src/check.ts CHANGED
@@ -13,6 +13,11 @@ export interface CheckOptions {
13
13
  contentOnly: boolean
14
14
  /** Validate against the project's real schemas, which means importing and running its content config. */
15
15
  live: boolean
16
+ /**
17
+ * Fail only on errors in these files, relative to `cwd`; every finding is still reported. For a commit
18
+ * that must not add a broken file but should not be blocked by one it did not touch.
19
+ */
20
+ failOn: string[] | null
16
21
  }
17
22
 
18
23
  /**
@@ -24,6 +29,7 @@ export interface CheckOptions {
24
29
  export function parseCheckArgs(args: string[], cwd: string): CheckOptions {
25
30
  const contentOnly = args.includes('--content-only')
26
31
  return {
32
+ failOn: parseFailOn(args),
27
33
  cwd,
28
34
  json: args.includes('--json'),
29
35
  strict: args.includes('--strict'),
@@ -33,6 +39,15 @@ export function parseCheckArgs(args: string[], cwd: string): CheckOptions {
33
39
  }
34
40
  }
35
41
 
42
+ /** `--fail-on a.css,b.astro` or `--fail-on=a.css,b.astro`, as normalized relative paths; null without the flag. */
43
+ function parseFailOn(args: string[]): string[] | null {
44
+ const index = args.findIndex(arg => arg === '--fail-on' || arg.startsWith('--fail-on='))
45
+ if (index === -1) return null
46
+ const flag = args[index]!
47
+ const value = flag === '--fail-on' ? args[index + 1] ?? '' : flag.slice('--fail-on='.length)
48
+ return value.split(',').map(file => file.trim()).filter(Boolean).map(file => path.normalize(file))
49
+ }
50
+
36
51
  const ASTRO_CONFIG_NAMES = ['astro.config.ts', 'astro.config.mts', 'astro.config.mjs', 'astro.config.js']
37
52
 
38
53
  const hasAstroConfig = (dir: string): boolean => ASTRO_CONFIG_NAMES.some(name => existsSync(path.join(dir, name)))
@@ -102,12 +117,13 @@ export async function check(options: CheckOptions): Promise<number> {
102
117
  const reports = await Promise.all(roots.map(async root => {
103
118
  const absolute = path.join(options.cwd, root)
104
119
  const content = prefixed(await checkContent(createNodeFs(absolute), schemas ? { schemas } : {}), root)
105
- if (options.contentOnly) return { ...content, files: 0 }
106
- const code = await checkCode(absolute)
120
+ if (options.contentOnly) return { ...content, files: 0, paths: [] }
121
+ const code = await checkCode(absolute, { projectCode: options.live })
107
122
  return {
108
123
  ...content,
109
124
  findings: [...content.findings, ...code.findings.map(finding => ({ ...finding, file: path.join(root, finding.file) }))],
110
125
  files: code.files,
126
+ paths: code.paths.map(file => path.join(root, file)),
111
127
  }
112
128
  }))
113
129
 
@@ -120,10 +136,21 @@ export async function check(options: CheckOptions): Promise<number> {
120
136
 
121
137
  const errors = report.findings.filter(finding => finding.severity === 'error').length
122
138
  const warnings = report.findings.length - errors
139
+ // An agent may list a file by its absolute path; findings are relative to the cwd.
140
+ const failOn = options.failOn === null ? null : new Set(options.failOn.map(file => path.relative(options.cwd, path.resolve(options.cwd, file))))
141
+ // Under --strict a warning in a listed file fails as well, as it would without --fail-on.
142
+ const failing = failOn === null
143
+ ? errors
144
+ : report.findings.filter(finding => (finding.severity === 'error' || options.strict) && failOn.has(path.normalize(finding.file))).length
145
+ // A listed file the syntax pass did not read, e.g. a `.js`, one in `public/` or outside every Astro project:
146
+ // nothing here parsed it, so it is named rather than counted, and the summary cannot read as a pass for it.
147
+ const parsed = new Set(reports.flatMap(one => one.paths))
148
+ const unchecked = failOn === null ? [] : [...failOn].filter(file => !parsed.has(file))
123
149
 
124
150
  if (options.json) {
125
151
  // The reason belongs inside the payload: a stray line would break anything parsing this.
126
- console.log(JSON.stringify({ ...report, roots, liveSchemas: 'schemas' in live ? 'ran' : live.skipped }, null, 2))
152
+ const failOnErrors = failOn === null ? {} : { failOnErrors: failing, failOnUnchecked: unchecked }
153
+ console.log(JSON.stringify({ ...report, roots, liveSchemas: 'schemas' in live ? 'ran' : live.skipped, ...failOnErrors }, null, 2))
127
154
  } else {
128
155
  const formatted = formatCheckReport(report)
129
156
  if (formatted) console.log(formatted + '\n')
@@ -133,7 +160,19 @@ export async function check(options: CheckOptions): Promise<number> {
133
160
  console.log(errors === 0 && warnings === 0 ? `${counts} — no problems found` : `${counts} — ${errors} error(s), ${warnings} warning(s)`)
134
161
  // A check that did not run must not read as a pass.
135
162
  if ('skipped' in live) console.log(`Live schema rules did not run: ${live.skipped}`)
163
+ if (failOn !== null) {
164
+ const covered = failOn.size - unchecked.length
165
+ console.log(
166
+ failing === 0
167
+ ? `No errors in the ${covered} file(s) given to --fail-on that were checked.`
168
+ : `${failing} problem(s) in the files given to --fail-on. Fix them before going on.`,
169
+ )
170
+ if (unchecked.length > 0) {
171
+ console.log(`Not parsed by this run (a content entry is still checked with its collection): ${unchecked.join(', ')}`)
172
+ }
173
+ }
136
174
  }
137
175
 
176
+ if (failOn !== null) return failing > 0 ? 1 : 0
138
177
  return errors > 0 || (options.strict && warnings > 0) ? 1 : 0
139
178
  }
package/src/index.ts CHANGED
@@ -25,7 +25,9 @@ function printUsage() {
25
25
  console.log(' build Run astro build with the Nua defaults')
26
26
  console.log(' dev Run astro dev with the Nua defaults')
27
27
  console.log(' preview Run astro preview with the Nua defaults')
28
- console.log(' check Validate collections and parse the source (no build). --json, --strict, --content-only, --no-live')
28
+ console.log(' check Validate collections and parse the source (no build). --json, --strict, --content-only, --no-live,')
29
+ console.log(' --fail-on <file,...> (fail only on errors in these files). --no-live also keeps the')
30
+ console.log(" project's own tailwindcss unloaded; stylesheets are then parsed with the bundled one")
29
31
  console.log(' init Convert a standard Astro project to use Nua')
30
32
  console.log(' clean Eject to a standard Astro project (remove @nuasite/* deps)')
31
33
  console.log(' migrate <target> Run a content migration. Targets: astro-image')