@nialto-services/astro-site-checks 0.1.1 → 0.2.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 +6 -0
- package/package.json +1 -1
- package/src/checks/links.mjs +4 -2
- package/src/checks/redirects.mjs +4 -2
- package/src/index.mjs +1 -0
- package/src/on-demand.mjs +58 -0
- package/src/redirects.mjs +6 -3
package/README.md
CHANGED
|
@@ -36,6 +36,9 @@ target is listed once, with up to five of the pages that link to it.
|
|
|
36
36
|
A link to the source of a redirect is a warning rather than a failure: it resolves, but through a 301, so it
|
|
37
37
|
is worth linking straight to the destination.
|
|
38
38
|
|
|
39
|
+
A page that renders on demand (a file in `src/pages` exporting `prerender = false`) counts as served, since
|
|
40
|
+
the build writes no file for it.
|
|
41
|
+
|
|
39
42
|
Links to other hosts, including protocol-relative ones (`//cdn.example.com/…`), `mailto:` and `tel:` links,
|
|
40
43
|
in-page anchors and absolute links back to the site's own host are not checked.
|
|
41
44
|
|
|
@@ -49,6 +52,9 @@ Checks every rule in the built `_redirects` for four faults:
|
|
|
49
52
|
- a page rule without its other trailing-slash spelling pointing at the same target. Cloudflare matches a
|
|
50
53
|
rule's source exactly, so without both spellings one of them lands on a 404.
|
|
51
54
|
|
|
55
|
+
A target served by a page that renders on demand counts as served, and a rule that only adds or removes the
|
|
56
|
+
trailing slash on a page's own URL is not asked for its other spelling.
|
|
57
|
+
|
|
52
58
|
A placeholder (`:name`, `*`) or a target on another host is skipped only by the faults that would have to
|
|
53
59
|
resolve it: a placeholder rule's concrete target is still checked, and so is the source of a rule pointing at
|
|
54
60
|
another host. A site with no `_redirects` passes.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nialto-services/astro-site-checks",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Post-build checks for Nialto's Astro sites: internal links, redirects, scoped CSS and the Content-Security-Policy.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
package/src/checks/links.mjs
CHANGED
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
isServable,
|
|
17
17
|
servedPathOf
|
|
18
18
|
} from '../built-site.mjs'
|
|
19
|
+
import { collectOnDemandRoutes, isOnDemand } from '../on-demand.mjs'
|
|
19
20
|
import { parseRedirects } from '../redirects.mjs'
|
|
20
21
|
import { readOptionalFile, requireBuiltSite } from './built.mjs'
|
|
21
22
|
|
|
@@ -49,11 +50,12 @@ function listFindings(grouped, describeTarget, write) {
|
|
|
49
50
|
}
|
|
50
51
|
}
|
|
51
52
|
|
|
52
|
-
export async function checkLinks({ clientDirectory, report }) {
|
|
53
|
+
export async function checkLinks({ clientDirectory, siteDirectory, report }) {
|
|
53
54
|
if (!(await requireBuiltSite(clientDirectory, report))) return false
|
|
54
55
|
|
|
55
56
|
const redirects = parseRedirects((await readOptionalFile(join(clientDirectory, '_redirects'))) ?? '')
|
|
56
57
|
const servablePaths = await collectServablePaths(clientDirectory)
|
|
58
|
+
const onDemandRoutes = await collectOnDemandRoutes(siteDirectory)
|
|
57
59
|
const pages = (await collectBuiltFiles(clientDirectory)).filter((file) => file.endsWith('.html'))
|
|
58
60
|
|
|
59
61
|
const broken = []
|
|
@@ -68,7 +70,7 @@ export async function checkLinks({ clientDirectory, report }) {
|
|
|
68
70
|
continue
|
|
69
71
|
}
|
|
70
72
|
|
|
71
|
-
if (isServable(servablePaths, path)) continue
|
|
73
|
+
if (isServable(servablePaths, path) || isOnDemand(onDemandRoutes, path)) continue
|
|
72
74
|
|
|
73
75
|
broken.push({ from, path })
|
|
74
76
|
}
|
package/src/checks/redirects.mjs
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import { join } from 'node:path'
|
|
9
9
|
import { collectServablePaths } from '../built-site.mjs'
|
|
10
|
+
import { collectOnDemandRoutes, isOnDemand } from '../on-demand.mjs'
|
|
10
11
|
import {
|
|
11
12
|
findBrokenTargets,
|
|
12
13
|
findMissingSpellings,
|
|
@@ -26,7 +27,7 @@ function reportFaults(report, heading, faults, describe, remedy) {
|
|
|
26
27
|
return true
|
|
27
28
|
}
|
|
28
29
|
|
|
29
|
-
export async function checkRedirects({ clientDirectory, report }) {
|
|
30
|
+
export async function checkRedirects({ clientDirectory, siteDirectory, report }) {
|
|
30
31
|
if (!(await requireBuiltSite(clientDirectory, report))) return false
|
|
31
32
|
|
|
32
33
|
const text = await readOptionalFile(join(clientDirectory, '_redirects'))
|
|
@@ -37,12 +38,13 @@ export async function checkRedirects({ clientDirectory, report }) {
|
|
|
37
38
|
|
|
38
39
|
const redirects = parseRedirects(text)
|
|
39
40
|
const servablePaths = await collectServablePaths(clientDirectory)
|
|
41
|
+
const onDemandRoutes = await collectOnDemandRoutes(siteDirectory)
|
|
40
42
|
|
|
41
43
|
const failures = [
|
|
42
44
|
reportFaults(
|
|
43
45
|
report,
|
|
44
46
|
'Redirects pointing at a path the build does not serve',
|
|
45
|
-
findBrokenTargets(redirects, servablePaths),
|
|
47
|
+
findBrokenTargets(redirects, servablePaths, (path) => isOnDemand(onDemandRoutes, path)),
|
|
46
48
|
({ source, target }) => `${source} -> ${target}`,
|
|
47
49
|
'Point the rule at a page that exists, or drop it if the destination has gone for good.'
|
|
48
50
|
),
|
package/src/index.mjs
CHANGED
|
@@ -11,6 +11,7 @@ export { checkHeaders } from './checks/headers.mjs'
|
|
|
11
11
|
export { checkLinks } from './checks/links.mjs'
|
|
12
12
|
export { checkRedirects } from './checks/redirects.mjs'
|
|
13
13
|
export { readConfig } from './config.mjs'
|
|
14
|
+
export { collectOnDemandRoutes, isOnDemand } from './on-demand.mjs'
|
|
14
15
|
export { extractLoadedOrigins, findPolicyViolations, originAllowedBy, parsePolicy } from './csp.mjs'
|
|
15
16
|
export {
|
|
16
17
|
findBrokenTargets,
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Routes a site renders on demand (`export const prerender = false`). The build writes no file for them,
|
|
3
|
+
* so without this a link or redirect to one looks broken. Read from `src/pages` because the Cloudflare
|
|
4
|
+
* adapter leaves no route list in the built output.
|
|
5
|
+
*/
|
|
6
|
+
import { readdir, readFile } from 'node:fs/promises'
|
|
7
|
+
import { join, relative } from 'node:path'
|
|
8
|
+
|
|
9
|
+
const PAGE_EXTENSION_PATTERN = /\.(astro|ts|js|mjs)$/
|
|
10
|
+
const ON_DEMAND_PATTERN = /export\s+const\s+prerender\s*=\s*false/
|
|
11
|
+
|
|
12
|
+
async function collectPageFiles(directory) {
|
|
13
|
+
const entries = await readdir(directory, { withFileTypes: true }).catch(() => [])
|
|
14
|
+
const files = await Promise.all(
|
|
15
|
+
entries.map((entry) => {
|
|
16
|
+
const path = join(directory, entry.name)
|
|
17
|
+
if (entry.isDirectory()) return collectPageFiles(path)
|
|
18
|
+
|
|
19
|
+
return PAGE_EXTENSION_PATTERN.test(entry.name) ? [path] : []
|
|
20
|
+
})
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
return files.flat()
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** A page file's route as a pattern matching its path with or without a trailing slash. */
|
|
27
|
+
function routePattern(pagesDirectory, file) {
|
|
28
|
+
const route = relative(pagesDirectory, file)
|
|
29
|
+
.replace(PAGE_EXTENSION_PATTERN, '')
|
|
30
|
+
.replace(/(^|\/)index$/, '')
|
|
31
|
+
.split('/')
|
|
32
|
+
.filter(Boolean)
|
|
33
|
+
.map((segment) => {
|
|
34
|
+
if (/^\[\.\.\..+\]$/.test(segment)) return '.*'
|
|
35
|
+
if (/^\[.+\]$/.test(segment)) return '[^/]+'
|
|
36
|
+
|
|
37
|
+
return segment.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
38
|
+
})
|
|
39
|
+
.join('/')
|
|
40
|
+
|
|
41
|
+
return new RegExp(`^/${route}${route ? '/?' : ''}$`)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** A pattern for every page under the site's `src/pages` that renders on demand. */
|
|
45
|
+
export async function collectOnDemandRoutes(siteDirectory) {
|
|
46
|
+
const pagesDirectory = join(siteDirectory, 'src', 'pages')
|
|
47
|
+
const routes = []
|
|
48
|
+
for (const file of await collectPageFiles(pagesDirectory)) {
|
|
49
|
+
if (ON_DEMAND_PATTERN.test(await readFile(file, 'utf-8'))) routes.push(routePattern(pagesDirectory, file))
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return routes
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Whether a path is served by a page rendered on demand. */
|
|
56
|
+
export function isOnDemand(routes, path) {
|
|
57
|
+
return routes.some((route) => route.test(path))
|
|
58
|
+
}
|
package/src/redirects.mjs
CHANGED
|
@@ -37,11 +37,11 @@ function isConcretePath(path) {
|
|
|
37
37
|
* A 301 to a missing page is worse than no rule at all: a crawler follows it, finds nothing, and drops the
|
|
38
38
|
* old URL without gaining a replacement.
|
|
39
39
|
*/
|
|
40
|
-
export function findBrokenTargets(redirects, servablePaths) {
|
|
40
|
+
export function findBrokenTargets(redirects, servablePaths, isAlsoServed = () => false) {
|
|
41
41
|
const broken = []
|
|
42
42
|
for (const [source, target] of redirects) {
|
|
43
43
|
if (!isConcretePath(target)) continue
|
|
44
|
-
if (isServable(servablePaths, target)) continue
|
|
44
|
+
if (isServable(servablePaths, target) || isAlsoServed(target)) continue
|
|
45
45
|
|
|
46
46
|
broken.push({ source, target })
|
|
47
47
|
}
|
|
@@ -104,7 +104,10 @@ export function findMissingSpellings(redirects) {
|
|
|
104
104
|
if (source === '/' || lastSegment.includes('.')) continue
|
|
105
105
|
|
|
106
106
|
const otherSpelling = source.endsWith('/') ? source.slice(0, -1) : `${source}/`
|
|
107
|
-
|
|
107
|
+
|
|
108
|
+
// A rule adding or removing the slash on a page's own URL has no other spelling to add: that one
|
|
109
|
+
// would redirect to itself.
|
|
110
|
+
if (otherSpelling === target || redirects.get(otherSpelling) === target) continue
|
|
108
111
|
|
|
109
112
|
missing.push({ source, missing: otherSpelling, target })
|
|
110
113
|
}
|