@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 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.1.1",
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",
@@ -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
  }
@@ -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
- if (redirects.get(otherSpelling) === target) continue
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
  }