mikser-io 9.70.0 → 9.72.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/CLAUDE.md CHANGED
@@ -214,6 +214,35 @@ brevity.
214
214
  when it looks like one (`'hash' in format`); without that, `asset 'web'
215
215
  '/x.jpg'` built `/assets/web/x.[object Object]` — the bug the finalize
216
216
  check found on its first run, present since the helper took a format.
217
+ - `matchesLibrary` (utils.js) — a resources library key is a REGEX source
218
+ (`escapeStringRegexp(url)`), and it has two consumers: the plugin's
219
+ discovery walk, which decides what to DOWNLOAD, and the `resource` render
220
+ helper, which builds the url. They read the same string with two matchers
221
+ — discovery used `matchEntity`, a GLOB demanding a full match, so a key
222
+ derived from `url` (a bare prefix, no trailing wildcard) matched nothing
223
+ and NO url-declared library was ever fetched, while the helper kept
224
+ building links to the missing files. Green build, missing images; found
225
+ when the reference check read the output back. Both now call one function
226
+ so they cannot drift.
227
+ - `references.js` — the OTHER half of the broken-link answer: reads the
228
+ EMITTED output (html + css) and resolves every `src` / `href` / `poster`
229
+ / `srcset` / `url()` the way a browser does. Complements
230
+ `reportMissingAssets`, which reads the render track — that one sees urls
231
+ that never reach an html file (a feed, a sitemap) and knows the entity;
232
+ this one sees paths written by hand and can tell BROKEN (resolves to no
233
+ file) from OVER-DEEP (resolves only because a `..` run was floored at the
234
+ site root — loads today, breaks one nesting level deeper). Where both can
235
+ see a file the scan wins and the track check is skipped, so one problem
236
+ is one warning. Codes: `reference-broken` / `reference-over-deep` plus
237
+ summaries. `runtime.config.siteRoots` declares which subtrees deploy as
238
+ their own domain root; it CANNOT be derived — it is a fact about
239
+ deployment, not about the bytes — and resolving a per-language build
240
+ against the output root reports every working url as broken. Skips other
241
+ origins, `data:`, fragments, percent-encoded externals
242
+ (`https%3A%2F%2F...` in a query param) and unrendered template syntax.
243
+ Decodes `"` first: a CSS custom property in an inline style is
244
+ `url("../x.svg")`, and left encoded the entity text becomes the
245
+ path. 434 references over a real site in ~20ms.
217
246
  - Postprocess failures are RENDER ERRORS. The dispatcher's catch used to
218
247
  log one uncoded `Postprocess error:` line and stop there — exit code 0,
219
248
  nothing in `--json` `errors`, `🟢 Mikser completed`. A build missing
@@ -64,6 +64,31 @@ These options are part of `runtime.options` and apply to the engine itself.
64
64
  | `server.requestTimeout` | — | number | node default (`300000`) | Milliseconds a single request may take, set on the underlying `http.Server`. Node's 5-minute default is effectively an **upload size limit expressed in seconds** — a large file over a slow link is indistinguishable from a stalled request, so it is cut off and the caller sees a truncated write rather than a readable error. Raised to two hours automatically when any route registers as `streaming` (an upload surface such as `mikser-io-drive`), because Node's default mismeasures exactly those; set this explicitly to override. `0` disables the cap: reasonable on a trusted-network build server, bad facing the internet, where it removes the only bound on how long a client can hold a connection open doing nothing. `headersTimeout` is clamped to stay at or below it. The server is also exposed as `runtime.options.httpServer`. |
65
65
  | `url` | `-u, --url <url>` | string | — | Public URL where this mikser is reachable (e.g. `https://blog.me.com`). Validated, trailing slash stripped, stamped on `runtime.options.url`. Read by webhook-capable plugins for push-vs-poll gating (`url.startsWith('https://')`); used by anything that surfaces absolute URLs externally — MCP preview URLs returned to agents, forms share links, email tracking pixels. Plugins that just need internal URLs keep using `runtime.options.port`. |
66
66
 
67
+ ### `siteRoots`
68
+
69
+ Which subtrees of the output folder are deployed as their own domain root.
70
+
71
+ ```js
72
+ export default {
73
+ // out/bg becomes lmed.bg, out/en becomes lmed.info, out/mk becomes lmed.mk
74
+ siteRoots: ['bg', 'en', 'mk'],
75
+ }
76
+ ```
77
+
78
+ Read only by the broken-reference check. It resolves urls the way a browser
79
+ does, and a browser cannot climb above the origin root — it discards the
80
+ extra `..` and loads the file. Where the site root actually is therefore
81
+ decides whether `../../x.svg` on a given page is correct, merely over-deep,
82
+ or broken.
83
+
84
+ Default is the output folder itself, which is right for the ordinary case of
85
+ one site per build. Declare this only when a build emits several sites, as a
86
+ per-language deploy does — resolving those against the output root instead
87
+ misses the over-escape entirely and reports the working urls as broken.
88
+
89
+ Nothing can infer it: it is a fact about where the bytes get deployed, not
90
+ about the bytes.
91
+
67
92
  ## Engine Substrate
68
93
 
69
94
  The catalog, inverse-ref graph, render snapshot manifest, and per-cycle
@@ -873,10 +873,26 @@ surfaces that turn silence into a statement:
873
873
  `resource()` *build* a URL from a naming convention rather than looking
874
874
  an entity up, so they cannot fail: a preset that never ran, or a
875
875
  template naming an extension the preset no longer emits, yields a
876
- well-formed URL to nothing. Every such call is recorded on the render
877
- track and checked against the output folder at finalize; what is missing
878
- is warned under `asset-missing`, naming the path and the pages that
879
- linked it.
876
+ well-formed URL to nothing. Two checks run at finalize, from different
877
+ evidence:
878
+ - **The emitted output**, read back and resolved the way a browser
879
+ would — `src`, `href`, `poster`, `srcset` and CSS `url()` across
880
+ html and css. Anything resolving to no file warns under
881
+ `reference-broken`, naming the url and the pages carrying it. This
882
+ one sees paths written by hand, not just helper output.
883
+ - **The render track**, which records every `asset()` / `resource()`
884
+ call and tests the destination it built. This catches a url that
885
+ never reaches an html file at all — one emitted into a feed or a
886
+ sitemap — and warns under `asset-missing`. Where both can see the
887
+ same file, the output scan reports it and this one stays quiet.
888
+ - **A link that works only by accident** — a url with one `..` too many
889
+ still loads, because a browser discards a climb above the origin root
890
+ rather than failing. It is one level of nesting away from a 404, and
891
+ it means the emitted depth does not match the page. Reported under
892
+ `reference-over-deep`, separately from the outright failures. Which
893
+ root to floor at is deployment intent and cannot be derived, so
894
+ declare it — see `siteRoots` in
895
+ [configuration](./configuration.md#siteroots).
880
896
 
881
897
  ## See also
882
898
 
package/docs/rendering.md CHANGED
@@ -392,7 +392,15 @@ naming the path and the pages that linked it, with a summary under
392
392
  deployed into the output as a link resolves normally.
393
393
 
394
394
  It is a warning, not an error: a build can legitimately link a file that
395
- some later step supplies. What it removes is the silence.
395
+ some later step supplies, and a missing asset must not stop a dev server.
396
+ What it removes is the silence.
397
+
398
+ A second check reads the **emitted output** rather than the render track —
399
+ every `src` / `href` / `poster` / `srcset` / CSS `url()` in the html and
400
+ css that shipped, resolved the way a browser resolves it. It covers paths
401
+ written by hand, which no helper ever saw, and it is the only one that can
402
+ see a url which loads solely because the browser floored a `..` run at the
403
+ site root. See [diagnostics](./diagnostics.md#when-mikser-is-silent).
396
404
 
397
405
  ---
398
406
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mikser-io",
3
- "version": "9.70.0",
3
+ "version": "9.72.0",
4
4
  "description": "A mixer for content: entities in, configurable render pipelines, outputs of any kind. Static sites are the canonical recipe, not the definition — the same engine renders PDFs, emails and whatever a renderer plugin produces. Files are the source of truth, every lifecycle phase is observable, and the build graph is queryable by an agent.",
5
5
  "main": "index.js",
6
6
  "exports": {
package/src/engine.js CHANGED
@@ -12,6 +12,7 @@ import { globby } from 'globby'
12
12
  import { OPERATION, TASKS } from './constants.js'
13
13
  import { changeExtension, formatErrorContext, projectMeta, lookupKeys } from './utils.js'
14
14
  import { reportRendered, reportSkipped, reportError, renderErrorCount, emitReport, finishCycle, reportAssetUse, assetUse } from './report.js'
15
+ import { checkReferences } from './references.js'
15
16
  import { toolSchemas, invokeTool, toolResultText, toolResultFailed } from './tools.js'
16
17
  import { registerBuiltinTools } from './builtin-tools.js'
17
18
  import { useDatabase } from './database/index.js'
@@ -89,6 +90,61 @@ function workerSafeOptions(opts) {
89
90
  return result
90
91
  }
91
92
 
93
+ // Warn for anything the EMITTED output points at that is not there.
94
+ //
95
+ // Complements the helper-call check below rather than repeating it: this reads
96
+ // what shipped, so it also sees paths written by hand, and it resolves them the
97
+ // way a browser does, which is the only way to see a url that works solely
98
+ // because a `..` run was floored at the site root.
99
+ //
100
+ // A floored url is not broken today. It is the same markup one level deeper
101
+ // away from being broken, and it means the emitted depth does not match the
102
+ // page — so it is reported separately rather than folded in with the failures.
103
+ //
104
+ // Warn, never fail: a missing asset must not stop a dev server. Both lists
105
+ // carry stable codes into `--json` so a deploy script can decide for itself.
106
+ // Returns the set of broken targets so the helper-call check can skip them.
107
+ async function reportBrokenReferences(logger) {
108
+ const outputFolder = runtime.options.outputFolder
109
+ if (!outputFolder || !existsSync(outputFolder)) return new Set()
110
+
111
+ const siteRoots = runtime.config?.siteRoots ?? []
112
+ const { broken, overDeep, checked } = await checkReferences(outputFolder, { siteRoots })
113
+ if (!checked) return new Set()
114
+
115
+ const SHOWN = 10
116
+ const named = (files) =>
117
+ files.slice(0, 3).join(', ') + (files.length > 3 ? ` and ${files.length - 3} more` : '')
118
+
119
+ for (const { url, target, files } of broken.slice(0, SHOWN)) {
120
+ logger.warn({ code: 'reference-broken', url, target, files },
121
+ 'Resolves to nothing: %s (from %s) — %s', url, named(files), target)
122
+ }
123
+ if (broken.length) {
124
+ logger.warn({ code: 'reference-broken-summary', broken: broken.length, checked },
125
+ '%d of %d reference(s) in the output resolve to nothing%s. A URL helper builds the '
126
+ + 'path rather than looking it up, so these are links to files nothing produced.',
127
+ broken.length, checked, broken.length > SHOWN ? `, ${SHOWN} shown` : '')
128
+ }
129
+
130
+ for (const { url, target, files } of overDeep.slice(0, SHOWN)) {
131
+ logger.warn({ code: 'reference-over-deep', url, target, files },
132
+ 'Over-deep, loads only because the browser floors it: %s (from %s) — %s',
133
+ url, named(files), target)
134
+ }
135
+ if (overDeep.length) {
136
+ logger.warn({ code: 'reference-over-deep-summary', overDeep: overDeep.length, checked },
137
+ '%d of %d reference(s) climb above the site root and load only because a browser '
138
+ + 'discards the extra `..`%s. They break as soon as the same markup renders one '
139
+ + 'level deeper.%s',
140
+ overDeep.length, checked, overDeep.length > SHOWN ? `, ${SHOWN} shown` : '',
141
+ siteRoots.length ? '' : ' No siteRoots are declared, so this resolved against the '
142
+ + 'output root — declare runtime.config.siteRoots if a subtree is deployed as its own domain.')
143
+ }
144
+
145
+ return new Set(broken.map(b => b.target))
146
+ }
147
+
92
148
  // Warn for anything a render linked to that is not in the output.
93
149
  //
94
150
  // Deliberately phrased as what was OBSERVED. Only entities that rendered this
@@ -96,7 +152,7 @@ function workerSafeOptions(opts) {
96
152
  // and says nothing about the rest — the same reasoning the assets plugin
97
153
  // already applies to its preset warning, and for the same reason: a warning
98
154
  // that overclaims gets filtered, and the filtered-out line is the real one.
99
- async function reportMissingAssets(logger) {
155
+ async function reportMissingAssets(logger, alreadyReported = new Set()) {
100
156
  const used = assetUse()
101
157
  if (!used.length) return
102
158
  const outputFolder = runtime.options.outputFolder
@@ -105,6 +161,10 @@ async function reportMissingAssets(logger) {
105
161
  const missing = []
106
162
  for (const [destination, ids] of used) {
107
163
  const file = path.join(outputFolder, destination.replace(/^\//, ''))
164
+ // The output scan resolves the same file the way a browser does and
165
+ // names the pages that link it, which is strictly more useful. Where
166
+ // both would fire, one warning is enough.
167
+ if (alreadyReported.has(destination.replace(/^\//, ''))) continue
108
168
  if (!existsSync(file)) missing.push([destination, ids])
109
169
  }
110
170
  if (!missing.length) return
@@ -1138,7 +1198,8 @@ export async function setup(options) {
1138
1198
  // Checked at the end of the cycle because that is the first moment the
1139
1199
  // answer is stable: derivatives are produced during the cycle, so
1140
1200
  // asking any earlier would report files that were about to appear.
1141
- await reportMissingAssets(useLogger())
1201
+ const brokenTargets = await reportBrokenReferences(useLogger())
1202
+ await reportMissingAssets(useLogger(), brokenTargets)
1142
1203
 
1143
1204
  // After the cycle, and only under --json. stdout has been kept clear
1144
1205
  // for exactly this (the logger writes to stderr under --json), so the
@@ -1,10 +1,14 @@
1
1
  import path from 'node:path'
2
+ import { matchesLibrary } from '../../utils.js'
2
3
 
3
4
  export function load({ runtime, entity, state, options, track }) {
4
5
  runtime.resource = (url) => {
5
6
  const { resourceLib } = state.resources
6
7
  for (let library in resourceLib) {
7
- if (url.match(library)) {
8
+ // Same matcher the resources plugin uses to decide what to
9
+ // DOWNLOAD. When these disagreed, this built urls for files the
10
+ // plugin never fetched.
11
+ if (matchesLibrary(url, library)) {
8
12
  const { origin } = new URL(url)
9
13
  const name = url.replace(origin, `${resourceLib[library]}`)
10
14
  const relative = url.replace(origin, `${state.resources.resourcesFolder}/${resourceLib[library]}`)
@@ -10,6 +10,7 @@ import * as stream from 'stream'
10
10
  import { promisify } from 'util'
11
11
  import isUrl from 'is-url'
12
12
  import map from 'p-map'
13
+ import { matchesLibrary } from '../utils.js'
13
14
 
14
15
  export function resources(options = {}) {
15
16
  return ({
@@ -24,7 +25,6 @@ export function resources(options = {}) {
24
25
  checksum,
25
26
  trackProgress,
26
27
  updateProgress,
27
- matchEntity,
28
28
  constants: { OPERATION },
29
29
  }) => {
30
30
  const collection = 'resources'
@@ -52,6 +52,11 @@ export function resources(options = {}) {
52
52
 
53
53
  for (let library in (options.libraries || [])) {
54
54
  let resource = options.libraries[library]
55
+ // The key is a REGULAR EXPRESSION source, which is what the
56
+ // escapeStringRegexp call says: you only escape a string you are
57
+ // about to compile. The render helper has always read it that way
58
+ // (`url.match(library)`), so a library declared by `url` is a
59
+ // prefix pattern matching anything under it.
55
60
  runtime.state.resources.resourceLib[resource.match || escapeStringRegexp(resource.url)] = library
56
61
  }
57
62
  })
@@ -66,7 +71,15 @@ export function resources(options = {}) {
66
71
  _.eachDeep(entity.meta, resource => {
67
72
  if (typeof resource == 'string') {
68
73
  for (let library in resourceLib) {
69
- if (matchEntity(resource, library)) {
74
+ // Regex, matching the render helper. This used
75
+ // matchEntity, which is a GLOB demanding a full
76
+ // match — so a key derived from `url` (a bare
77
+ // prefix, no trailing wildcard) matched nothing and
78
+ // NO url-declared library was ever downloaded. The
79
+ // helper still built urls for them, so pages linked
80
+ // files nothing fetched and the build stayed green:
81
+ // one string read with two incompatible matchers.
82
+ if (matchesLibrary(resource, library)) {
70
83
  resourceMap[entity.id].push({ library, resource, entity })
71
84
  }
72
85
  }
@@ -0,0 +1,187 @@
1
+ // What the build actually shipped, checked against what it actually wrote.
2
+ //
3
+ // The URL helpers BUILD paths from a naming convention rather than resolving
4
+ // an entity, so they cannot fail — `asset` composes
5
+ // `<assetsFolder>/<preset>/<path>` with whatever extension it was handed and
6
+ // never asks whether that file exists. A wrong preset name, a wrong extension,
7
+ // or a source whose derivative silently failed to render all produce a
8
+ // well-formed url pointing at nothing, and every existing surface stays green:
9
+ // nothing threw, --verify compares snapshots against what was rendered rather
10
+ // than against what those renders point at, and mikser_refs_broken tracks
11
+ // document-to-document refs, not urls.
12
+ //
13
+ // This reads the emitted bytes instead. Everything it needs is on disk at the
14
+ // end of a cycle and nothing has to be inferred.
15
+ //
16
+ // It is deliberately NOT the same check as `asset-missing` in engine.js. That
17
+ // one records helper CALLS on the render track and tests the output-root
18
+ // absolute destination each one built; it knows the referencing entity, and it
19
+ // sees urls that never reach an html file at all (a sitemap, a feed). This one
20
+ // sees everything that shipped, including paths written by hand, and resolves
21
+ // them the way a browser would — which is the only way to catch a url that
22
+ // resolves solely because the browser floored a `..` run at the site root.
23
+
24
+ import path from 'node:path'
25
+ import { existsSync } from 'node:fs'
26
+ import { readFile } from 'node:fs/promises'
27
+ import { globby } from 'globby'
28
+
29
+ // Documents that can carry a reference. Anything else in the output is either
30
+ // an asset itself or something whose internal structure this has no business
31
+ // guessing at.
32
+ const SCANNED = ['**/*.html', '**/*.htm', '**/*.css']
33
+
34
+ // Attributes whose value is a single url.
35
+ const ATTR = /(?:src|href|poster|data-bg)\s*=\s*["']([^"']*)["']/gi
36
+ // srcset / imagesrcset: a comma-separated list of `url [descriptor]`.
37
+ const SRCSET = /(?:img|image)?srcset\s*=\s*["']([^"']*)["']/gi
38
+ // css url(), in a stylesheet and in an inline style attribute alike.
39
+ const CSS_URL = /url\(\s*(['"]?)([^'")]*)\1\s*\)/gi
40
+
41
+ // A url this check has nothing to say about: another origin, an inline
42
+ // payload, a fragment or an in-page action. `//host/path` is protocol-relative
43
+ // and therefore external too.
44
+ function isExternal(url) {
45
+ if (!url) return true
46
+ const u = url.trim()
47
+ if (!u) return true
48
+ if (u.startsWith('#') || u.startsWith('//')) return true
49
+ // Template syntax that reached the output unrendered — `{{link}}`,
50
+ // `${x}`, `<%= y %>`. It is not a path, so "resolves to nothing" says
51
+ // nothing useful about it; the real problem is that it did not render,
52
+ // which is a different question than this one is asking. Documentation
53
+ // pages showing escaped template syntax are the common source, and they
54
+ // are not broken at all.
55
+ if (/\{\{|\}\}|\$\{|<%/.test(u)) return true
56
+ // A scheme — http:, data:, mailto:, tel:, javascript:.
57
+ if (/^[a-z][a-z0-9+.-]*:/i.test(u)) return true
58
+ // The same thing percent-encoded, which is how an external url arrives
59
+ // when it was built as a query parameter — `https%3A%2F%2F...` in a maps
60
+ // link. It has no scheme until it is decoded, so the test above misses it
61
+ // and the whole encoded string gets resolved as a path segment.
62
+ try {
63
+ if (/^[a-z][a-z0-9+.-]*:/i.test(decodeURIComponent(u))) return true
64
+ } catch { /* malformed escape — treat as a path and let it resolve */ }
65
+ return false
66
+ }
67
+
68
+ // Quotes inside an attribute value arrive encoded, and a CSS custom property
69
+ // in an inline style is the common way that happens:
70
+ //
71
+ // style="--icon-src:url(&quot;../media/raw/icons/x.svg&quot;)"
72
+ //
73
+ // Without decoding, the captured url is the entity text itself, which resolves
74
+ // nowhere and reports as broken — a false positive that would have buried the
75
+ // real ones. Only the quote and ampersand forms are decoded; turning &lt; back
76
+ // into a bracket could invent markup that was deliberately escaped.
77
+ function decodeEntities(source) {
78
+ return source
79
+ .replace(/&quot;|&#34;/g, '"')
80
+ .replace(/&apos;|&#39;/g, "'")
81
+ .replace(/&amp;/g, '&')
82
+ }
83
+
84
+ // Everything a page points at, as raw url strings.
85
+ export function extractReferences(rawSource) {
86
+ const source = decodeEntities(rawSource)
87
+ const found = new Set()
88
+ for (const [, url] of source.matchAll(ATTR)) found.add(url)
89
+ for (const [, , url] of source.matchAll(CSS_URL)) found.add(url)
90
+ for (const [, list] of source.matchAll(SRCSET)) {
91
+ for (const candidate of list.split(',')) {
92
+ const url = candidate.trim().split(/\s+/)[0]
93
+ if (url) found.add(url)
94
+ }
95
+ }
96
+ return [...found].filter(u => !isExternal(u))
97
+ }
98
+
99
+ // Resolve the way a browser does, which is the whole point.
100
+ //
101
+ // A browser walks the page's directory segments, pops one per `..`, and
102
+ // DISCARDS a `..` that would climb above the origin root — it does not error
103
+ // and it does not escape. So a url with one `..` too many still loads, and the
104
+ // page looks correct while carrying a path that breaks the moment the same
105
+ // markup is used one level deeper. That flooring is what `overDeep` records.
106
+ //
107
+ // `pageDir` and the result are both relative to `root`.
108
+ export function resolveUrl(pageDir, url, { root = '' } = {}) {
109
+ const clean = url.split('#')[0].split('?')[0]
110
+ const absolute = clean.startsWith('/')
111
+ const segments = clean.split('/').filter(s => s !== '' && s !== '.')
112
+
113
+ const parts = absolute ? [] : pageDir.split('/').filter(Boolean)
114
+ let overDeep = false
115
+ for (const segment of segments) {
116
+ if (segment !== '..') { parts.push(segment); continue }
117
+ if (parts.length) parts.pop()
118
+ else overDeep = true // a climb above the root, floored
119
+ }
120
+ return { target: path.join(root, ...parts), overDeep }
121
+ }
122
+
123
+ // Which declared site root a file belongs to.
124
+ //
125
+ // lmed emits one subtree per language and deploys each as its own domain root
126
+ // (out/bg becomes lmed.bg), so the site root is out/<lang>/ and every url
127
+ // carries one extra `..` for the language segment that the browser then floors.
128
+ // Resolving against the output root instead would miss the over-escape entirely
129
+ // and report working urls as broken. Nothing can derive this — it is deployment
130
+ // intent — so it is declared, and the default is the output root itself.
131
+ export function siteRootFor(relativeFile, roots) {
132
+ let best = ''
133
+ for (const root of roots) {
134
+ if (!root) continue
135
+ if (relativeFile.startsWith(`${root}/`) && root.length > best.length) best = root
136
+ }
137
+ return best
138
+ }
139
+
140
+ // Everything the output points at that is not there.
141
+ //
142
+ // Returns { broken, overDeep, checked }, where each entry is
143
+ // { url, target, files } — the target with the pages that named it, because
144
+ // "this is missing" is only actionable next to "and these link it".
145
+ export async function checkReferences(outputFolder, { siteRoots = [] } = {}) {
146
+ const files = await globby(SCANNED, {
147
+ cwd: outputFolder,
148
+ followSymbolicLinks: false,
149
+ suppressErrors: true,
150
+ })
151
+
152
+ const broken = new Map()
153
+ const overDeep = new Map()
154
+ let checked = 0
155
+ // Existence is the expensive part and the same target repeats across a
156
+ // site — one lookup each.
157
+ const exists = new Map()
158
+
159
+ for (const file of files) {
160
+ let source
161
+ try { source = await readFile(path.join(outputFolder, file), 'utf8') }
162
+ catch { continue }
163
+
164
+ const root = siteRootFor(file, siteRoots)
165
+ // The page's directory, relative to its own site root.
166
+ const pageDir = path.dirname(file).slice(root.length).replace(/^\/+/, '')
167
+
168
+ for (const url of extractReferences(source)) {
169
+ const { target, overDeep: floored } = resolveUrl(pageDir, url, { root })
170
+ checked++
171
+
172
+ if (!exists.has(target)) {
173
+ exists.set(target, existsSync(path.join(outputFolder, target)))
174
+ }
175
+ // Broken outranks over-deep: a url that resolves nowhere is the
176
+ // failure, and adding that it is also one level too deep is noise.
177
+ const bucket = !exists.get(target) ? broken : (floored ? overDeep : null)
178
+ if (!bucket) continue
179
+
180
+ const key = `${target} ${url}`
181
+ if (!bucket.has(key)) bucket.set(key, { url, target, files: [] })
182
+ bucket.get(key).files.push(file)
183
+ }
184
+ }
185
+
186
+ return { broken: [...broken.values()], overDeep: [...overDeep.values()], checked }
187
+ }
package/src/utils.js CHANGED
@@ -1316,3 +1316,30 @@ export function junkFilter() {
1316
1316
  return registered.match.some(test => test(name))
1317
1317
  }
1318
1318
  }
1319
+
1320
+ // Does a value fall under a resources library?
1321
+ //
1322
+ // The library key is a REGULAR EXPRESSION source — `resources()` derives it
1323
+ // with escapeStringRegexp(url), and you only escape a string you are about to
1324
+ // compile. It has two consumers: the plugin's discovery walk, which decides
1325
+ // what to download, and the `resource` render helper, which builds the url.
1326
+ // They read the same string with two different matchers — discovery used a
1327
+ // GLOB, which demands a full match, so a key derived from `url` (a bare prefix
1328
+ // with no trailing wildcard) matched nothing. Nothing was ever downloaded for
1329
+ // a url-declared library, while the helper happily built links to the files
1330
+ // that were not fetched. Green build, missing images.
1331
+ //
1332
+ // One function, so the two cannot drift again.
1333
+ const libraryPatterns = new Map()
1334
+ export function matchesLibrary(value, pattern) {
1335
+ if (typeof value !== 'string' || !pattern) return false
1336
+ if (!libraryPatterns.has(pattern)) {
1337
+ let re
1338
+ try { re = new RegExp(pattern) }
1339
+ // A hand-written `match` that is not valid regex would otherwise throw
1340
+ // mid-walk and take the build down.
1341
+ catch { re = { test: () => false } }
1342
+ libraryPatterns.set(pattern, re)
1343
+ }
1344
+ return libraryPatterns.get(pattern).test(value)
1345
+ }