@nuasite/cli 0.52.0 → 0.54.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.52.0",
3
+ "version": "0.54.0",
4
4
  "license": "Apache-2.0",
5
5
  "type": "module",
6
6
  "files": [
@@ -23,8 +23,10 @@
23
23
  "prepack": "bun run ../../scripts/workspace-deps/resolve-deps.ts"
24
24
  },
25
25
  "dependencies": {
26
- "@nuasite/cms": "0.52.0",
27
- "@nuasite/cms-core": "0.52.0"
26
+ "@astrojs/compiler": "^3.0.1",
27
+ "@jridgewell/trace-mapping": "^0.3.31",
28
+ "@nuasite/cms": "0.54.0",
29
+ "@nuasite/cms-core": "0.54.0"
28
30
  },
29
31
  "devDependencies": {
30
32
  "@types/bun": "1.3.11"
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Syntax check for the project's own code.
3
+ *
4
+ * A full build catches these, but it renders every page to get there. What actually fails is the
5
+ * parse: Astro compiles a `.astro` file to TypeScript and the bundler parses that, so compiling
6
+ * and parsing — without rendering anything — reproduces the same class of error in about a second.
7
+ *
8
+ * This is a *syntax* check, not a type check. A wrong prop type or a missing export still needs
9
+ * `astro check` or a build. What it does catch is the error that stops a build dead before a single
10
+ * page renders — an unescaped quote inside a Czech string being the one that prompted it.
11
+ */
12
+
13
+ import { transform } from '@astrojs/compiler'
14
+ import { originalPositionFor, TraceMap } from '@jridgewell/trace-mapping'
15
+ import type { CheckFinding } from '@nuasite/cms-core'
16
+ import { Glob } from 'bun'
17
+ import path from 'node:path'
18
+
19
+ /** Directories that are never the project's own source. */
20
+ const IGNORED = ['node_modules', 'dist', '.astro', '.git', '.output', '.vercel', '.netlify']
21
+
22
+ const isIgnored = (relative: string): boolean =>
23
+ // Declaration files are types only — nothing compiles them, and the TS loader rejects them.
24
+ /\.d\.[mc]?ts$/.test(relative) || relative.split(path.sep).some(segment => IGNORED.includes(segment))
25
+
26
+ /** Bun's parser reports a `BuildMessage` carrying the offending position in the code it was given. */
27
+ interface BuildPosition {
28
+ line?: number
29
+ column?: number
30
+ lineText?: string
31
+ }
32
+
33
+ function buildPosition(error: unknown): BuildPosition | null {
34
+ if (typeof error !== 'object' || error === null || !('position' in error)) return null
35
+ const position = (error as { position: unknown }).position
36
+ return typeof position === 'object' && position !== null ? position as BuildPosition : null
37
+ }
38
+
39
+ /** Bun's `BuildMessage` is not an `Error`, so `String(error)` would prefix the class name. */
40
+ function messageOf(error: unknown): string {
41
+ const message = typeof error === 'object' && error !== null && 'message' in error ? (error as { message: unknown }).message : undefined
42
+ return String(typeof message === 'string' ? message : error).split('\n')[0]!
43
+ }
44
+
45
+ /**
46
+ * Map a position in the emitted TypeScript back to the `.astro` source.
47
+ *
48
+ * Without this the position points into generated `$$render` code, which is worse than no position
49
+ * at all — it looks like a real line number and is not one.
50
+ */
51
+ function toSource(map: string | undefined, position: BuildPosition | null): { line?: number; column?: number } {
52
+ if (!map || position?.line === undefined) return {}
53
+ try {
54
+ const traced = originalPositionFor(new TraceMap(JSON.parse(map)), {
55
+ line: position.line,
56
+ column: position.column ?? 0,
57
+ })
58
+ if (traced.line === null) return {}
59
+ // trace-mapping columns are 0-based; editors and build output are not.
60
+ return { line: traced.line, column: traced.column + 1 }
61
+ } catch {
62
+ return {}
63
+ }
64
+ }
65
+
66
+ async function checkAstroFile(absolute: string, relative: string, source: string): Promise<CheckFinding | null> {
67
+ let emitted: { code: string; map: string }
68
+ try {
69
+ emitted = await transform(source, { filename: absolute, sourcemap: 'both' })
70
+ } catch (error) {
71
+ return { severity: 'error', code: 'code/astro-parse', file: relative, message: messageOf(error) }
72
+ }
73
+
74
+ try {
75
+ new Bun.Transpiler({ loader: 'ts' }).transformSync(emitted.code)
76
+ return null
77
+ } catch (error) {
78
+ const position = buildPosition(error)
79
+ return {
80
+ severity: 'error',
81
+ code: 'code/syntax',
82
+ file: relative,
83
+ ...toSource(emitted.map, position),
84
+ message: messageOf(error),
85
+ }
86
+ }
87
+ }
88
+
89
+ function checkScriptFile(relative: string, source: string, loader: 'ts' | 'tsx'): CheckFinding | null {
90
+ try {
91
+ new Bun.Transpiler({ loader }).transformSync(source)
92
+ return null
93
+ } catch (error) {
94
+ const position = buildPosition(error)
95
+ return {
96
+ severity: 'error',
97
+ code: 'code/syntax',
98
+ file: relative,
99
+ line: position?.line,
100
+ column: position?.column === undefined ? undefined : position.column + 1,
101
+ message: messageOf(error),
102
+ }
103
+ }
104
+ }
105
+
106
+ export interface CodeCheckResult {
107
+ findings: CheckFinding[]
108
+ files: number
109
+ }
110
+
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> {
113
+ const findings: CheckFinding[] = []
114
+ let files = 0
115
+
116
+ 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)
119
+ }
120
+ paths.sort()
121
+
122
+ for (const relative of paths) {
123
+ files++
124
+ const absolute = path.join(root, relative)
125
+ const source = await Bun.file(absolute).text()
126
+ const finding = relative.endsWith('.astro')
127
+ ? await checkAstroFile(absolute, relative, source)
128
+ : checkScriptFile(relative, source, relative.endsWith('.tsx') ? 'tsx' : 'ts')
129
+ if (finding) findings.push(finding)
130
+ }
131
+
132
+ return { findings, files }
133
+ }
package/src/check.ts CHANGED
@@ -1,12 +1,36 @@
1
- import { checkContent, type CheckReport, createNodeFs, formatCheckReport } from '@nuasite/cms-core'
1
+ import { checkContent, type CheckReport, createNodeFs, formatCheckReport, type LiveSchemas } from '@nuasite/cms-core'
2
2
  import { existsSync, readdirSync } from 'node:fs'
3
3
  import path from 'node:path'
4
+ import { checkCode } from './check-code'
5
+ import { loadLiveSchemas } from './live-schema'
4
6
 
5
7
  export interface CheckOptions {
6
8
  cwd: string
7
9
  json: boolean
8
10
  /** Treat warnings as failures too. Off by default: a dangling reference builds fine. */
9
11
  strict: boolean
12
+ /** Skip the code syntax pass — content only. */
13
+ contentOnly: boolean
14
+ /** Validate against the project's real schemas, which means importing and running its content config. */
15
+ live: boolean
16
+ }
17
+
18
+ /**
19
+ * `nua check`'s argv, as options.
20
+ *
21
+ * Pure and exported so the flag combinations are testable: `index.ts` is a top-level-await
22
+ * script that reads `process.argv` and calls `process.exit`, so a test cannot import it.
23
+ */
24
+ export function parseCheckArgs(args: string[], cwd: string): CheckOptions {
25
+ const contentOnly = args.includes('--content-only')
26
+ return {
27
+ cwd,
28
+ json: args.includes('--json'),
29
+ strict: args.includes('--strict'),
30
+ contentOnly,
31
+ // Loading the real schemas executes the project's content config — the least content-only thing this command does.
32
+ live: !contentOnly && !args.includes('--no-live'),
33
+ }
10
34
  }
11
35
 
12
36
  const ASTRO_CONFIG_NAMES = ['astro.config.ts', 'astro.config.mts', 'astro.config.mjs', 'astro.config.js']
@@ -44,6 +68,25 @@ function prefixed(report: CheckReport, root: string): CheckReport {
44
68
  }
45
69
  }
46
70
 
71
+ /** The project's real schemas, or the sentence saying why the rules needing them did not run. */
72
+ type LiveResult = { schemas: LiveSchemas } | { skipped: string }
73
+
74
+ async function liveSchemasFor(options: CheckOptions, roots: string[]): Promise<LiveResult> {
75
+ if (options.contentOnly) {
76
+ return { skipped: "--content-only was passed, and reading the real schemas means executing the project's content config." }
77
+ }
78
+ if (!options.live) return { skipped: '--no-live was passed.' }
79
+
80
+ const first = roots[0]
81
+ // The `astro:content` stub is process-global and re-exports one project's zod, so a second
82
+ // project would be judged by the first project's schemas — quietly wrong is worse than skipped.
83
+ if (roots.length !== 1 || first === undefined) {
84
+ const where = roots.join(', ')
85
+ return { skipped: `They need exactly one Astro project; this run covers ${roots.length} (${where}). Check each one from its own directory.` }
86
+ }
87
+ return loadLiveSchemas(path.join(options.cwd, first))
88
+ }
89
+
47
90
  /** Validate the content collections and return the process exit code. */
48
91
  export async function check(options: CheckOptions): Promise<number> {
49
92
  const roots = astroRoots(options.cwd)
@@ -53,27 +96,43 @@ export async function check(options: CheckOptions): Promise<number> {
53
96
  return 1
54
97
  }
55
98
 
56
- const reports = await Promise.all(
57
- roots.map(async root => prefixed(await checkContent(createNodeFs(path.join(options.cwd, root))), root)),
58
- )
99
+ const live = await liveSchemasFor(options, roots)
100
+ const schemas = 'schemas' in live ? live.schemas : undefined
101
+
102
+ const reports = await Promise.all(roots.map(async root => {
103
+ const absolute = path.join(options.cwd, root)
104
+ const content = prefixed(await checkContent(createNodeFs(absolute), schemas ? { schemas } : {}), root)
105
+ if (options.contentOnly) return { ...content, files: 0 }
106
+ const code = await checkCode(absolute)
107
+ return {
108
+ ...content,
109
+ findings: [...content.findings, ...code.findings.map(finding => ({ ...finding, file: path.join(root, finding.file) }))],
110
+ files: code.files,
111
+ }
112
+ }))
59
113
 
60
114
  const report: CheckReport = {
61
115
  findings: reports.flatMap(one => one.findings),
62
116
  collections: reports.reduce((total, one) => total + one.collections, 0),
63
117
  entries: reports.reduce((total, one) => total + one.entries, 0),
64
118
  }
119
+ const sourceFiles = reports.reduce((total, one) => total + one.files, 0)
65
120
 
66
121
  const errors = report.findings.filter(finding => finding.severity === 'error').length
67
122
  const warnings = report.findings.length - errors
68
123
 
69
124
  if (options.json) {
70
- console.log(JSON.stringify({ ...report, roots }, null, 2))
125
+ // 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))
71
127
  } else {
72
128
  const formatted = formatCheckReport(report)
73
129
  if (formatted) console.log(formatted + '\n')
74
130
  const where = roots.length === 1 && roots[0] === '.' ? '' : ` in ${roots.join(', ')}`
75
- const counts = `${report.collections} collection(s), ${report.entries} entries${where}`
131
+ const code = options.contentOnly ? '' : `, ${sourceFiles} source file(s)`
132
+ const counts = `${report.collections} collection(s), ${report.entries} entries${code}${where}`
76
133
  console.log(errors === 0 && warnings === 0 ? `${counts} — no problems found` : `${counts} — ${errors} error(s), ${warnings} warning(s)`)
134
+ // A check that did not run must not read as a pass.
135
+ if ('skipped' in live) console.log(`Live schema rules did not run: ${live.skipped}`)
77
136
  }
78
137
 
79
138
  return errors > 0 || (options.strict && warnings > 0) ? 1 : 0
package/src/index.ts CHANGED
@@ -25,7 +25,7 @@ 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 the content collections (no build). --json, --strict')
28
+ console.log(' check Validate collections and parse the source (no build). --json, --strict, --content-only, --no-live')
29
29
  console.log(' init Convert a standard Astro project to use Nua')
30
30
  console.log(' clean Eject to a standard Astro project (remove @nuasite/* deps)')
31
31
  console.log(' migrate <target> Run a content migration. Targets: astro-image')
@@ -38,14 +38,8 @@ if (command && ['build', 'dev', 'preview'].includes(command)) {
38
38
  } else {
39
39
  switch (command) {
40
40
  case 'check': {
41
- const { check } = await import('./check')
42
- process.exit(
43
- await check({
44
- cwd: process.cwd(),
45
- json: args.includes('--json'),
46
- strict: args.includes('--strict'),
47
- }),
48
- )
41
+ const { check, parseCheckArgs } = await import('./check')
42
+ process.exit(await check(parseCheckArgs(args, process.cwd())))
49
43
  break
50
44
  }
51
45
  case 'init': {
Binary file