@elytracms/cli 0.0.80 → 0.0.82
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 +89 -1
- package/index.js +18 -1
- package/package.json +2 -2
- package/src/cli.js +17 -7
- package/src/config-harness.js +256 -0
- package/src/doctor.js +142 -10
- package/src/engine-build-failure.js +88 -0
- package/src/previews.js +141 -105
- package/src/project.js +6 -3
- package/src/routes-check.js +483 -0
|
@@ -0,0 +1,483 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { fileURLToPath, pathToFileURL } from 'node:url'
|
|
4
|
+
import {
|
|
5
|
+
createRouter,
|
|
6
|
+
declaredMounts,
|
|
7
|
+
hierarchyMountConflicts,
|
|
8
|
+
isHierarchyTemplate,
|
|
9
|
+
routeRecordSchema,
|
|
10
|
+
routeReachabilityIssues,
|
|
11
|
+
} from '@elytracms/core/cms-core'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* `elytra doctor`'s route check (EC-469) — the CODE side of route validation.
|
|
15
|
+
*
|
|
16
|
+
* `createRouter` computes real structural issues and, before this, no surface showed
|
|
17
|
+
* them: the studio has had no Routes screen since EC-303 (correctly — routes are
|
|
18
|
+
* code), and doctor deferred structure to "the studio boot and `elytra build`",
|
|
19
|
+
* neither of which checked routes. A route conflict shipped silently.
|
|
20
|
+
*
|
|
21
|
+
* **What lives here and why only this.** `createRouter` produces exactly two codes —
|
|
22
|
+
* `route-conflict` (from routes alone) and `redirect-loop` (from redirects). Since
|
|
23
|
+
* EC-303 redirects are editorial DATA in the backend, so a bundler-less, session-less
|
|
24
|
+
* CLI cannot see them; and `unknown-route-target` is not a router issue at all — the
|
|
25
|
+
* *resolver* raises it per URL against the documents that exist
|
|
26
|
+
* (`core/src/content/client.ts`). So the honest CLI-side answer is **route conflicts**,
|
|
27
|
+
* and the other two are the studio's (which holds both the routes and the redirects).
|
|
28
|
+
* The `redirect-loops` check below says exactly that, as an `unknown` — the same
|
|
29
|
+
* convention `config-valid` already uses for a fact the CLI cannot gather.
|
|
30
|
+
*
|
|
31
|
+
* EC-482 adds the half the router structurally cannot see: **hierarchy mounts**. They
|
|
32
|
+
* are filtered OUT of the router (below, and identically in delivery), so two mounts
|
|
33
|
+
* fighting over the same URLs were invisible to every surface. `hierarchyMountConflicts`
|
|
34
|
+
* judges them by declaration instead of by pattern shape; the second check row reports
|
|
35
|
+
* it. Both checks are code-side and mutually exclusive by construction — a mount never
|
|
36
|
+
* reaches the router, an ordinary route never reaches the mount detector.
|
|
37
|
+
*
|
|
38
|
+
* EC-484 adds the third row, `unreachable-routes`, for the case where nothing is in
|
|
39
|
+
* conflict and the declaration simply does not work: a `/:slug`-shaped route that
|
|
40
|
+
* matches every URL at a depth the mount serves (so the mount is never reached and
|
|
41
|
+
* existing pages 404), a mount whose collection cannot compose nested paths, and a
|
|
42
|
+
* catch-all with a dynamic prefix that every router drops and no mount is built from.
|
|
43
|
+
* All three were measured to resolve `notFound` with zero issues on every surface.
|
|
44
|
+
*
|
|
45
|
+
* **Loading the routes.** This check runs with NO bundler and NO installed deps: a
|
|
46
|
+
* project's `cms/` tree is React-free by convention — measured across `apps/demo`, the
|
|
47
|
+
* scaffold template, and both real consumer repos: every external import is
|
|
48
|
+
* `import type`, erased by Node's own TypeScript support. So doctor imports
|
|
49
|
+
* `cms/index.ts` natively and reads the same `collections` + `routes` that
|
|
50
|
+
* `elytra.config.ts` passes straight through. (`elytra.config.ts` itself is NOT
|
|
51
|
+
* loadable this way — it imports `.tsx` component barrels, which no Node version
|
|
52
|
+
* executes, EC-474. Where the consumer's deps ARE installed, EC-495's Vite harness
|
|
53
|
+
* executes the full config and `runDoctor` hands the collections it resolved to these
|
|
54
|
+
* checks in place of the `cms/index.ts` re-export; this loader stays the floor.)
|
|
55
|
+
*
|
|
56
|
+
* Two things stand in the way, and both are handled rather than assumed away:
|
|
57
|
+
*
|
|
58
|
+
* 1. **Node floor.** Native TS needs Node ≥22.18 (measured: 22.13 → `ERR_UNKNOWN_
|
|
59
|
+
* FILE_EXTENSION`, 22.21 → loads). Below the floor the check reports `unknown`
|
|
60
|
+
* with the version as its reason — the CLI's own `engines` promise (>=20) is not
|
|
61
|
+
* quietly broken.
|
|
62
|
+
* 2. **Bundler-style specifiers.** Real `cms/` trees write `from './collections'` —
|
|
63
|
+
* extensionless, and a directory at that. Plain Node ESM resolves neither, at any
|
|
64
|
+
* version. {@link withBundlerResolution} registers a *fallback* resolve hook that
|
|
65
|
+
* supplies the extension/index a bundler would, and only after Node's own
|
|
66
|
+
* resolution has already failed — so it can never hijack a specifier that already
|
|
67
|
+
* resolves.
|
|
68
|
+
*
|
|
69
|
+
* Executing consumer code in a tool that otherwise only does HTTP is a change in
|
|
70
|
+
* kind, so it is bounded: one conventional file, read-only, no writes, and every
|
|
71
|
+
* failure mode (missing file, throwing module, unresolvable import, non-erasable TS,
|
|
72
|
+
* a `routes` export of the wrong shape, a derive that throws) degrades to a reported
|
|
73
|
+
* `unknown` carrying its reason. There is no path here that crashes the doctor and
|
|
74
|
+
* none that reports a pass it did not establish.
|
|
75
|
+
*/
|
|
76
|
+
|
|
77
|
+
// --- Loading the project's routes (impure) -----------------------------------
|
|
78
|
+
|
|
79
|
+
/** Where the route set is read from, by convention (AD-11: structure is code, in-repo). */
|
|
80
|
+
export const CMS_ENTRY = join('cms', 'index.ts')
|
|
81
|
+
|
|
82
|
+
/** Relative-specifier suffixes a bundler would try, in the order it tries them. */
|
|
83
|
+
const RESOLVE_CANDIDATES = ['.ts', '.tsx', '/index.ts', '/index.tsx', '.js', '.mjs', '/index.js']
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Run `load` with a FALLBACK module-resolution hook that supplies the extension or
|
|
87
|
+
* `/index` a bundler would for a relative specifier — but only for specifiers Node
|
|
88
|
+
* itself could not resolve, so an already-valid specifier is never rewritten. The
|
|
89
|
+
* hook is deregistered before returning, in success and in failure alike: doctor
|
|
90
|
+
* never leaves process-wide resolution patched.
|
|
91
|
+
*
|
|
92
|
+
* `module.registerHooks` is Node ≥22.15. Where it is absent the load simply runs
|
|
93
|
+
* unhooked (a `cms/` tree with explicit extensions still works) — never an error.
|
|
94
|
+
*/
|
|
95
|
+
export async function withBundlerResolution(load) {
|
|
96
|
+
let handle
|
|
97
|
+
try {
|
|
98
|
+
const { registerHooks } = await import('node:module')
|
|
99
|
+
if (typeof registerHooks === 'function') {
|
|
100
|
+
handle = registerHooks({
|
|
101
|
+
resolve(specifier, context, nextResolve) {
|
|
102
|
+
try {
|
|
103
|
+
return nextResolve(specifier, context)
|
|
104
|
+
} catch (error) {
|
|
105
|
+
if (!specifier.startsWith('./') && !specifier.startsWith('../')) throw error
|
|
106
|
+
if (!context.parentURL) throw error
|
|
107
|
+
for (const suffix of RESOLVE_CANDIDATES) {
|
|
108
|
+
const url = new URL(specifier + suffix, context.parentURL)
|
|
109
|
+
if (url.protocol === 'file:' && existsSync(fileURLToPath(url))) {
|
|
110
|
+
return { url: url.href, shortCircuit: true }
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
throw error
|
|
114
|
+
}
|
|
115
|
+
},
|
|
116
|
+
})
|
|
117
|
+
}
|
|
118
|
+
} catch {
|
|
119
|
+
// No resolve-hook support in this runtime — load unhooked.
|
|
120
|
+
}
|
|
121
|
+
try {
|
|
122
|
+
return await load()
|
|
123
|
+
} finally {
|
|
124
|
+
handle?.deregister()
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Silence ONE Node warning for the duration of `run`:
|
|
130
|
+
* `MODULE_TYPELESS_PACKAGE_JSON`, which fires for every consumer repo whose root
|
|
131
|
+
* `package.json` omits `"type": "module"` (both real ones). It is unrelated to route
|
|
132
|
+
* validity and would print into the middle of the doctor report. Every other warning
|
|
133
|
+
* still prints, and the original listeners are restored unconditionally.
|
|
134
|
+
*/
|
|
135
|
+
async function withoutTypelessModuleWarning(run) {
|
|
136
|
+
const listeners = process.listeners('warning')
|
|
137
|
+
process.removeAllListeners('warning')
|
|
138
|
+
const passthrough = (warning) => {
|
|
139
|
+
if (warning?.code === 'MODULE_TYPELESS_PACKAGE_JSON') return
|
|
140
|
+
for (const listener of listeners) listener(warning)
|
|
141
|
+
}
|
|
142
|
+
process.on('warning', passthrough)
|
|
143
|
+
try {
|
|
144
|
+
return await run()
|
|
145
|
+
} finally {
|
|
146
|
+
process.off('warning', passthrough)
|
|
147
|
+
for (const listener of listeners) process.on('warning', listener)
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** The first line of an error, safe for a one-line report row. */
|
|
152
|
+
function reason(error) {
|
|
153
|
+
const text = error instanceof Error ? error.message : String(error)
|
|
154
|
+
return text.split('\n')[0].trim()
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Materialize a `routes` config value against the declared collections — the same
|
|
159
|
+
* rule `studio-core`'s `materializeRecords` applies (EC-207): a derive function runs
|
|
160
|
+
* once, a static array passes through. Both real consumer repos declare the array
|
|
161
|
+
* form; the demo and the scaffold declare the derive.
|
|
162
|
+
*/
|
|
163
|
+
function materializeRoutes(routes, collections) {
|
|
164
|
+
return typeof routes === 'function' ? routes(collections) : [...routes]
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Load the project's declared route set. Returns either `{ routes, routeCount }` or
|
|
169
|
+
* `{ unavailable, fix? }` — an honest reason the routes could not be read. Never
|
|
170
|
+
* throws.
|
|
171
|
+
*
|
|
172
|
+
* `importModule` is injectable for tests (the default is a native dynamic import).
|
|
173
|
+
*/
|
|
174
|
+
export async function loadProjectRoutes(root, { importModule } = {}) {
|
|
175
|
+
const entry = join(root, CMS_ENTRY)
|
|
176
|
+
if (!existsSync(entry)) {
|
|
177
|
+
return {
|
|
178
|
+
// EC-533: state what this FALLBACK looked for, not what a project must contain.
|
|
179
|
+
// Elytra requires one thing of a repo — an `elytra.config.ts` of the right shape
|
|
180
|
+
// — and no blessed module paths (EC-532). `cms/index.ts` is the scaffold's own
|
|
181
|
+
// convention, which is all this bundler-free loader can find on its own; where
|
|
182
|
+
// the project's deps are installed, EC-495's Vite harness executes the real
|
|
183
|
+
// config and this floor is never reached.
|
|
184
|
+
unavailable: `Routes not checked: no ${CMS_ENTRY} to read without a bundler (this is the scaffold's convention, not a requirement — your routes may be declared elsewhere).`,
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const load = importModule ?? ((path) => import(pathToFileURL(path).href))
|
|
189
|
+
let mod
|
|
190
|
+
try {
|
|
191
|
+
mod = await withoutTypelessModuleWarning(() => withBundlerResolution(() => load(entry)))
|
|
192
|
+
} catch (error) {
|
|
193
|
+
if (error?.code === 'ERR_UNKNOWN_FILE_EXTENSION') {
|
|
194
|
+
return {
|
|
195
|
+
unavailable: `${CMS_ENTRY} could not be read: this Node (${process.version}) does not run TypeScript natively.`,
|
|
196
|
+
fix: 'Use Node >=22.18 (the version the engine targets) to have `elytra doctor` check routes.',
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
if (error?.code === 'ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX') {
|
|
200
|
+
return {
|
|
201
|
+
unavailable: `${CMS_ENTRY} uses TypeScript syntax Node cannot strip (${reason(error)}).`,
|
|
202
|
+
fix: 'Keep cms/ to erasable TypeScript (no enum/namespace/parameter properties) so build-time tools can read your structure.',
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
return { unavailable: `${CMS_ENTRY} could not be loaded: ${reason(error)}` }
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (mod?.routes === undefined) {
|
|
209
|
+
return {
|
|
210
|
+
unavailable: `${CMS_ENTRY} exports no \`routes\` — the route set was not checked here.`,
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
if (typeof mod.routes !== 'function' && !Array.isArray(mod.routes)) {
|
|
214
|
+
return {
|
|
215
|
+
unavailable: `${CMS_ENTRY} exports \`routes\` that is neither a RouteRecord[] nor a (collections) => RouteRecord[].`,
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
// Distinguish "exports no collections" from "exports an empty array": the
|
|
219
|
+
// reachability check must report NOT CHECKED rather than a pass when it cannot see
|
|
220
|
+
// the collection set (a mount's target is judged against it).
|
|
221
|
+
const collections = Array.isArray(mod.collections) ? mod.collections : undefined
|
|
222
|
+
|
|
223
|
+
let records
|
|
224
|
+
try {
|
|
225
|
+
records = materializeRoutes(mod.routes, collections ?? [])
|
|
226
|
+
} catch (error) {
|
|
227
|
+
return { unavailable: `The \`routes\` derive in ${CMS_ENTRY} threw: ${reason(error)}` }
|
|
228
|
+
}
|
|
229
|
+
if (!Array.isArray(records)) {
|
|
230
|
+
return { unavailable: `The \`routes\` derive in ${CMS_ENTRY} did not return an array.` }
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Shape-validate before handing records to the router: a malformed record (no
|
|
234
|
+
// pattern) would otherwise fault inside pattern compilation. Config validity is
|
|
235
|
+
// the studio boot's job, so this reports rather than duplicating that check.
|
|
236
|
+
const invalid = records.filter((record) => !routeRecordSchema.safeParse(record).success)
|
|
237
|
+
if (invalid.length > 0) {
|
|
238
|
+
return {
|
|
239
|
+
unavailable: `${invalid.length} of ${records.length} route record(s) are not valid RouteRecords — config validation reports the details.`,
|
|
240
|
+
fix: 'Open the studio (or run your build) to see the config validation issues, then re-run `elytra doctor`.',
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return {
|
|
245
|
+
routes: records,
|
|
246
|
+
routeCount: records.length,
|
|
247
|
+
...(collections ? { collections } : {}),
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// --- Detection (pure) --------------------------------------------------------
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* The router issues of a declared route set — route conflicts, since a CLI passes
|
|
255
|
+
* no redirects.
|
|
256
|
+
*
|
|
257
|
+
* Hierarchy-mount templates (`/:path*`, EC-218) are filtered out first, exactly as
|
|
258
|
+
* the delivery resolver does (`core/src/content/client.ts:230`): a mount is resolved
|
|
259
|
+
* per-request against the parent chain, not by the router, so counting it as a route
|
|
260
|
+
* would report conflicts the runtime does not have. Running the same filter is what
|
|
261
|
+
* makes doctor unable to disagree with delivery.
|
|
262
|
+
*
|
|
263
|
+
* The filter is NOT widened to cover EC-482 — {@link mountIssuesOf} answers that
|
|
264
|
+
* question about the routes this one removes. Widening the router's signature
|
|
265
|
+
* comparison instead would reinstate the `/:slug` + `/:path*` false positive the
|
|
266
|
+
* filter exists to prevent (pinned in both this package's and cms-core's tests).
|
|
267
|
+
*/
|
|
268
|
+
export function routeIssuesOf(routes) {
|
|
269
|
+
return createRouter(routes.filter((route) => !isHierarchyTemplate(route)), []).issues
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* The conflicts BETWEEN hierarchy mounts (EC-482) — the routes {@link routeIssuesOf}
|
|
274
|
+
* filters away. `error` for two mounts on the same path (identical URL space, split
|
|
275
|
+
* only by declaration order), `warning` for a nested pair (a real shadow, but only
|
|
276
|
+
* where the outer collection's page tree actually reaches into the inner prefix — a
|
|
277
|
+
* content fact a CLI cannot see).
|
|
278
|
+
*/
|
|
279
|
+
export function mountIssuesOf(routes) {
|
|
280
|
+
return hierarchyMountConflicts(routes)
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* The declarations that can never serve content (EC-484) — the third question, after
|
|
285
|
+
* "do two routes fight" and "do two mounts fight": **does this declaration work at
|
|
286
|
+
* all?** An all-dynamic route (`/:slug`) matching every URL at a depth the mount
|
|
287
|
+
* serves, a mount whose collection has no `parent` self-relation (or is not declared),
|
|
288
|
+
* and a catch-all with a dynamic prefix (`/:lang/:path*`) that every router drops and
|
|
289
|
+
* no mount is built from. All three were measured to resolve `notFound` with ZERO
|
|
290
|
+
* issues anywhere before this.
|
|
291
|
+
*
|
|
292
|
+
* `collections` is passed through when the project exported them; without them the
|
|
293
|
+
* mount-target half is skipped and {@link routesDoctorChecks} reports `unknown`
|
|
294
|
+
* instead of a pass it did not establish.
|
|
295
|
+
*/
|
|
296
|
+
export function reachabilityIssuesOf(routes, collections) {
|
|
297
|
+
return routeReachabilityIssues(routes, collections)
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// --- Doctor checks (pure) ----------------------------------------------------
|
|
301
|
+
|
|
302
|
+
const CONFLICT_FIX =
|
|
303
|
+
'Two routes match the same URLs — give one a distinct pattern (or delete it) in cms/routes.ts.'
|
|
304
|
+
|
|
305
|
+
const MOUNT_CONFLICT_FIX =
|
|
306
|
+
'Two collections cannot be served from the same URL space — give one mount a distinct prefix (e.g. "/docs/:path*") in cms/routes.ts, or drop it.'
|
|
307
|
+
|
|
308
|
+
const MOUNT_SHADOW_FIX =
|
|
309
|
+
'Mounts resolve in declaration order, not by specificity — declare the more specific mount first, and keep the outer collection\'s page tree clear of that prefix.'
|
|
310
|
+
|
|
311
|
+
const UNREACHABLE_FIX =
|
|
312
|
+
'Give an all-dynamic route a static segment (e.g. "/blog/:slug"), mount only collections with a `parent` self-relation, and keep a mount prefix static — in cms/routes.ts.'
|
|
313
|
+
|
|
314
|
+
/** An `unknown` check row carrying the reason the routes could not be read. */
|
|
315
|
+
function unavailableCheck(id, facts) {
|
|
316
|
+
return {
|
|
317
|
+
id,
|
|
318
|
+
bucket: 'app',
|
|
319
|
+
status: 'unknown',
|
|
320
|
+
detail: facts?.unavailable ?? 'Routes were not evaluated here.',
|
|
321
|
+
...(facts?.fix ? { fix: facts.fix } : {}),
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* The doctor's route checks, for the `app` bucket. Consumes the already-loaded facts
|
|
327
|
+
* (the {@link loadProjectRoutes} result) so the reporting shape is pure and directly
|
|
328
|
+
* testable, like `formatReport` and `previewsDoctorChecks`.
|
|
329
|
+
*
|
|
330
|
+
* Four rows, in the order a reader needs them: the router's own conflicts, the
|
|
331
|
+
* mount-vs-mount conflicts the router cannot see (EC-482), the declarations that serve
|
|
332
|
+
* nothing at all (EC-484), then the redirects a CLI cannot see at all.
|
|
333
|
+
*
|
|
334
|
+
* `route-conflicts`, `mount-conflicts` and `unreachable-routes` are the checks here
|
|
335
|
+
* that can FAIL — all deterministic in code, so failing the exit code is safe for CI.
|
|
336
|
+
* The latter two can also `warn`, in both cases for the same reason: the finding only
|
|
337
|
+
* costs content where a collection's tree actually reaches that far, which is CONTENT
|
|
338
|
+
* a CLI cannot see, so naming it is honest where failing a build would not be.
|
|
339
|
+
* `redirect-loops` is always `unknown`: they are editorial data, and saying so is how
|
|
340
|
+
* the report stays honest about what a CLI can and cannot see.
|
|
341
|
+
*/
|
|
342
|
+
export function routesDoctorChecks(facts) {
|
|
343
|
+
const redirects = {
|
|
344
|
+
id: 'redirect-loops',
|
|
345
|
+
bucket: 'app',
|
|
346
|
+
status: 'unknown',
|
|
347
|
+
detail:
|
|
348
|
+
'Redirect loops were not evaluated here — redirects are editorial data in the backend; the studio checks them.',
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
if (!facts || facts.unavailable) {
|
|
352
|
+
return [
|
|
353
|
+
unavailableCheck('route-conflicts', facts),
|
|
354
|
+
unavailableCheck('mount-conflicts', facts),
|
|
355
|
+
unavailableCheck('unreachable-routes', facts),
|
|
356
|
+
redirects,
|
|
357
|
+
]
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
const count = facts.routeCount ?? facts.routes.length
|
|
361
|
+
const plural = count === 1 ? '' : 's'
|
|
362
|
+
const conflicts = routeIssuesOf(facts.routes).filter((issue) => issue.code === 'route-conflict')
|
|
363
|
+
|
|
364
|
+
const routeConflicts =
|
|
365
|
+
conflicts.length === 0
|
|
366
|
+
? {
|
|
367
|
+
id: 'route-conflicts',
|
|
368
|
+
bucket: 'app',
|
|
369
|
+
status: 'ok',
|
|
370
|
+
detail: `${count} declared route${plural}, no URL conflicts.`,
|
|
371
|
+
}
|
|
372
|
+
: {
|
|
373
|
+
id: 'route-conflicts',
|
|
374
|
+
bucket: 'app',
|
|
375
|
+
status: 'fail',
|
|
376
|
+
detail:
|
|
377
|
+
`${conflicts.length} route conflict(s) among ${count} declared route${plural}. ` +
|
|
378
|
+
conflicts.map((issue) => issue.message).join(' '),
|
|
379
|
+
fix: CONFLICT_FIX,
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
return [
|
|
383
|
+
routeConflicts,
|
|
384
|
+
mountConflictsCheck(facts.routes),
|
|
385
|
+
unreachableRoutesCheck(facts.routes, facts.collections),
|
|
386
|
+
redirects,
|
|
387
|
+
]
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* The `unreachable-routes` row (EC-484): declarations that resolve to nothing.
|
|
392
|
+
*
|
|
393
|
+
* Like `mount-conflicts` it states what it LOOKED at when it passes — a bare "ok"
|
|
394
|
+
* reads the same whether the check examined the mounts or skipped them. And when the
|
|
395
|
+
* project exported no collections it reports `unknown` rather than `ok`: the
|
|
396
|
+
* mount-target half could not run, and a pass it did not establish would be exactly
|
|
397
|
+
* the silent failure this ticket exists to remove.
|
|
398
|
+
*/
|
|
399
|
+
function unreachableRoutesCheck(routes, collections) {
|
|
400
|
+
const mounts = declaredMounts(routes)
|
|
401
|
+
const scope = `${routes.length} declared route${routes.length === 1 ? '' : 's'}, ${mounts.length} hierarchy mount${mounts.length === 1 ? '' : 's'}`
|
|
402
|
+
const base = { id: 'unreachable-routes', bucket: 'app' }
|
|
403
|
+
|
|
404
|
+
const issues = reachabilityIssuesOf(routes, collections)
|
|
405
|
+
const errors = issues.filter((issue) => issue.severity === 'error')
|
|
406
|
+
const warnings = issues.filter((issue) => issue.severity !== 'error')
|
|
407
|
+
|
|
408
|
+
if (errors.length > 0) {
|
|
409
|
+
return {
|
|
410
|
+
...base,
|
|
411
|
+
status: 'fail',
|
|
412
|
+
detail:
|
|
413
|
+
`${errors.length} declaration(s) that cannot serve content, among ${scope}. ` +
|
|
414
|
+
[...errors, ...warnings].map((issue) => issue.message).join(' '),
|
|
415
|
+
fix: UNREACHABLE_FIX,
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
if (warnings.length > 0) {
|
|
419
|
+
return {
|
|
420
|
+
...base,
|
|
421
|
+
status: 'warn',
|
|
422
|
+
detail:
|
|
423
|
+
`${warnings.length} partially shadowed declaration(s), among ${scope}. ` +
|
|
424
|
+
warnings.map((issue) => issue.message).join(' '),
|
|
425
|
+
fix: UNREACHABLE_FIX,
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
if (mounts.length > 0 && !collections) {
|
|
429
|
+
return {
|
|
430
|
+
...base,
|
|
431
|
+
status: 'unknown',
|
|
432
|
+
detail: `${scope}: no route shadows a mount, but mount targets were not checked — ${CMS_ENTRY} exports no \`collections\`.`,
|
|
433
|
+
fix: `Export \`collections\` from ${CMS_ENTRY} (the shape elytra.config.ts re-exports) so mounts can be checked against them.`,
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
return { ...base, status: 'ok', detail: `${scope} — every declaration can serve content.` }
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* The `mount-conflicts` row (EC-482). Reports the mount COUNT even when clean — a row
|
|
441
|
+
* that says "1 hierarchy mount, no overlap" proves the check looked at the mounts,
|
|
442
|
+
* where a bare "ok" would read the same whether it examined them or skipped them.
|
|
443
|
+
*/
|
|
444
|
+
function mountConflictsCheck(routes) {
|
|
445
|
+
const mounts = declaredMounts(routes)
|
|
446
|
+
const mountPlural = mounts.length === 1 ? '' : 's'
|
|
447
|
+
const base = { id: 'mount-conflicts', bucket: 'app' }
|
|
448
|
+
|
|
449
|
+
if (mounts.length === 0) {
|
|
450
|
+
return { ...base, status: 'ok', detail: 'No hierarchy mounts declared.' }
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
const issues = mountIssuesOf(routes)
|
|
454
|
+
const errors = issues.filter((issue) => issue.severity === 'error')
|
|
455
|
+
const warnings = issues.filter((issue) => issue.severity !== 'error')
|
|
456
|
+
const among = `among ${mounts.length} hierarchy mount${mountPlural}`
|
|
457
|
+
|
|
458
|
+
if (errors.length > 0) {
|
|
459
|
+
return {
|
|
460
|
+
...base,
|
|
461
|
+
status: 'fail',
|
|
462
|
+
detail:
|
|
463
|
+
`${errors.length} conflicting mount pair(s) ${among}. ` +
|
|
464
|
+
[...errors, ...warnings].map((issue) => issue.message).join(' '),
|
|
465
|
+
fix: MOUNT_CONFLICT_FIX,
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
if (warnings.length > 0) {
|
|
469
|
+
return {
|
|
470
|
+
...base,
|
|
471
|
+
status: 'warn',
|
|
472
|
+
detail:
|
|
473
|
+
`${warnings.length} overlapping mount pair(s) ${among}. ` +
|
|
474
|
+
warnings.map((issue) => issue.message).join(' '),
|
|
475
|
+
fix: MOUNT_SHADOW_FIX,
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
return {
|
|
479
|
+
...base,
|
|
480
|
+
status: 'ok',
|
|
481
|
+
detail: `${mounts.length} hierarchy mount${mountPlural}, no overlapping mounts.`,
|
|
482
|
+
}
|
|
483
|
+
}
|