@nuasite/cli 0.51.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/README.md +25 -3
- package/dist/index.js +1290 -473
- package/dist/types/check-code.d.ts +19 -0
- package/dist/types/check-code.d.ts.map +1 -0
- package/dist/types/check.d.ts +11 -0
- package/dist/types/check.d.ts.map +1 -1
- package/dist/types/tsconfig.tsbuildinfo +1 -1
- package/package.json +5 -3
- package/src/check-code.ts +133 -0
- package/src/check.ts +72 -9
- package/src/index.ts +2 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nuasite/cli",
|
|
3
|
-
"version": "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
|
-
"@
|
|
27
|
-
"@
|
|
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,28 +1,91 @@
|
|
|
1
|
-
import { checkContent, createNodeFs, formatCheckReport } from '@nuasite/cms-core'
|
|
1
|
+
import { checkContent, type CheckReport, createNodeFs, formatCheckReport } from '@nuasite/cms-core'
|
|
2
|
+
import { existsSync, readdirSync } from 'node:fs'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import { checkCode } from './check-code'
|
|
2
5
|
|
|
3
6
|
export interface CheckOptions {
|
|
4
7
|
cwd: string
|
|
5
8
|
json: boolean
|
|
6
9
|
/** Treat warnings as failures too. Off by default: a dangling reference builds fine. */
|
|
7
10
|
strict: boolean
|
|
11
|
+
/** Skip the code syntax pass — content only. */
|
|
12
|
+
contentOnly: boolean
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const ASTRO_CONFIG_NAMES = ['astro.config.ts', 'astro.config.mts', 'astro.config.mjs', 'astro.config.js']
|
|
16
|
+
|
|
17
|
+
const hasAstroConfig = (dir: string): boolean => ASTRO_CONFIG_NAMES.some(name => existsSync(path.join(dir, name)))
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The Astro apps to check, relative to `cwd`.
|
|
21
|
+
*
|
|
22
|
+
* Running this at a monorepo root has to work: that is where an agent's workspace and a
|
|
23
|
+
* developer's shell both start, and `bun run build` there builds something else entirely
|
|
24
|
+
* (admin, api, worker). Every `packages/*` app is checked, not just the first — a repo with
|
|
25
|
+
* two sites must not have one of them silently skipped.
|
|
26
|
+
*/
|
|
27
|
+
export function astroRoots(cwd: string): string[] {
|
|
28
|
+
if (hasAstroConfig(cwd)) return ['.']
|
|
29
|
+
|
|
30
|
+
const packagesDir = path.join(cwd, 'packages')
|
|
31
|
+
if (!existsSync(packagesDir)) return []
|
|
32
|
+
|
|
33
|
+
return readdirSync(packagesDir, { withFileTypes: true })
|
|
34
|
+
.filter(entry => entry.isDirectory())
|
|
35
|
+
.map(entry => entry.name)
|
|
36
|
+
.sort()
|
|
37
|
+
.filter(name => hasAstroConfig(path.join(packagesDir, name)))
|
|
38
|
+
.map(name => path.join('packages', name))
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Re-root a sub-project's findings on the directory the command was run from. */
|
|
42
|
+
function prefixed(report: CheckReport, root: string): CheckReport {
|
|
43
|
+
if (root === '.') return report
|
|
44
|
+
return {
|
|
45
|
+
...report,
|
|
46
|
+
findings: report.findings.map(finding => ({ ...finding, file: path.join(root, finding.file) })),
|
|
47
|
+
}
|
|
8
48
|
}
|
|
9
49
|
|
|
10
50
|
/** Validate the content collections and return the process exit code. */
|
|
11
51
|
export async function check(options: CheckOptions): Promise<number> {
|
|
12
|
-
const
|
|
52
|
+
const roots = astroRoots(options.cwd)
|
|
13
53
|
|
|
14
|
-
if (
|
|
15
|
-
console.
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
54
|
+
if (roots.length === 0) {
|
|
55
|
+
console.error(`No astro.config.* found in ${options.cwd} or its packages/*. Run this in an Astro project.`)
|
|
56
|
+
return 1
|
|
57
|
+
}
|
|
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
|
+
}))
|
|
70
|
+
|
|
71
|
+
const report: CheckReport = {
|
|
72
|
+
findings: reports.flatMap(one => one.findings),
|
|
73
|
+
collections: reports.reduce((total, one) => total + one.collections, 0),
|
|
74
|
+
entries: reports.reduce((total, one) => total + one.entries, 0),
|
|
19
75
|
}
|
|
76
|
+
const sourceFiles = reports.reduce((total, one) => total + one.files, 0)
|
|
20
77
|
|
|
21
78
|
const errors = report.findings.filter(finding => finding.severity === 'error').length
|
|
22
79
|
const warnings = report.findings.length - errors
|
|
23
80
|
|
|
24
|
-
if (
|
|
25
|
-
|
|
81
|
+
if (options.json) {
|
|
82
|
+
console.log(JSON.stringify({ ...report, roots }, null, 2))
|
|
83
|
+
} else {
|
|
84
|
+
const formatted = formatCheckReport(report)
|
|
85
|
+
if (formatted) console.log(formatted + '\n')
|
|
86
|
+
const where = roots.length === 1 && roots[0] === '.' ? '' : ` in ${roots.join(', ')}`
|
|
87
|
+
const code = options.contentOnly ? '' : `, ${sourceFiles} source file(s)`
|
|
88
|
+
const counts = `${report.collections} collection(s), ${report.entries} entries${code}${where}`
|
|
26
89
|
console.log(errors === 0 && warnings === 0 ? `${counts} — no problems found` : `${counts} — ${errors} error(s), ${warnings} warning(s)`)
|
|
27
90
|
}
|
|
28
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
|
|
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
|