@nuasite/cli 0.52.0 → 0.53.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.53.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.53.0",
29
+ "@nuasite/cms-core": "0.53.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,15 @@
1
1
  import { checkContent, type CheckReport, createNodeFs, formatCheckReport } 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'
4
5
 
5
6
  export interface CheckOptions {
6
7
  cwd: string
7
8
  json: boolean
8
9
  /** Treat warnings as failures too. Off by default: a dangling reference builds fine. */
9
10
  strict: boolean
11
+ /** Skip the code syntax pass — content only. */
12
+ contentOnly: boolean
10
13
  }
11
14
 
12
15
  const ASTRO_CONFIG_NAMES = ['astro.config.ts', 'astro.config.mts', 'astro.config.mjs', 'astro.config.js']
@@ -53,15 +56,24 @@ export async function check(options: CheckOptions): Promise<number> {
53
56
  return 1
54
57
  }
55
58
 
56
- const reports = await Promise.all(
57
- roots.map(async root => prefixed(await checkContent(createNodeFs(path.join(options.cwd, root))), root)),
58
- )
59
+ const reports = await Promise.all(roots.map(async root => {
60
+ const absolute = path.join(options.cwd, root)
61
+ const content = prefixed(await checkContent(createNodeFs(absolute)), root)
62
+ if (options.contentOnly) return { ...content, files: 0 }
63
+ const code = await checkCode(absolute)
64
+ return {
65
+ ...content,
66
+ findings: [...content.findings, ...code.findings.map(finding => ({ ...finding, file: path.join(root, finding.file) }))],
67
+ files: code.files,
68
+ }
69
+ }))
59
70
 
60
71
  const report: CheckReport = {
61
72
  findings: reports.flatMap(one => one.findings),
62
73
  collections: reports.reduce((total, one) => total + one.collections, 0),
63
74
  entries: reports.reduce((total, one) => total + one.entries, 0),
64
75
  }
76
+ const sourceFiles = reports.reduce((total, one) => total + one.files, 0)
65
77
 
66
78
  const errors = report.findings.filter(finding => finding.severity === 'error').length
67
79
  const warnings = report.findings.length - errors
@@ -72,7 +84,8 @@ export async function check(options: CheckOptions): Promise<number> {
72
84
  const formatted = formatCheckReport(report)
73
85
  if (formatted) console.log(formatted + '\n')
74
86
  const where = roots.length === 1 && roots[0] === '.' ? '' : ` in ${roots.join(', ')}`
75
- const counts = `${report.collections} collection(s), ${report.entries} entries${where}`
87
+ const code = options.contentOnly ? '' : `, ${sourceFiles} source file(s)`
88
+ const counts = `${report.collections} collection(s), ${report.entries} entries${code}${where}`
76
89
  console.log(errors === 0 && warnings === 0 ? `${counts} — no problems found` : `${counts} — ${errors} error(s), ${warnings} warning(s)`)
77
90
  }
78
91
 
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')
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')
@@ -44,6 +44,7 @@ if (command && ['build', 'dev', 'preview'].includes(command)) {
44
44
  cwd: process.cwd(),
45
45
  json: args.includes('--json'),
46
46
  strict: args.includes('--strict'),
47
+ contentOnly: args.includes('--content-only'),
47
48
  }),
48
49
  )
49
50
  break