mikser-io 9.83.0 → 9.84.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mikser-io",
3
- "version": "9.83.0",
3
+ "version": "9.84.0",
4
4
  "files": [
5
5
  "app.js",
6
6
  "index.js",
package/src/engine.js CHANGED
@@ -128,7 +128,15 @@ async function reportBrokenReferences(logger) {
128
128
  const explain = runtime.state?.assets?.explainMissing
129
129
  const reasons = new Map()
130
130
  if (explain) {
131
- for (const { target } of broken.slice(0, SHOWN)) {
131
+ // Every broken entry, not the ones that happen to sort first. The cap
132
+ // below is about how much gets PRINTED; asking only within it made the
133
+ // answer depend on the position of the entry that had one, so a site
134
+ // with ten unrelated broken urls reported "No derivative produced: 0"
135
+ // while holding the cause of the eleventh. Cheap to ask: anything not
136
+ // under the assets folder is rejected on the first path segment, and
137
+ // the catalog index behind it is built once and only if something gets
138
+ // past that.
139
+ for (const { target } of broken) {
132
140
  // Site-relative, because that is what the assets folder is named
133
141
  // relative to. A build that deploys out/<lang> as its own domain
134
142
  // root resolves this target to `a/derived/web/...`, and a plain
@@ -141,7 +149,14 @@ async function reportBrokenReferences(logger) {
141
149
  }
142
150
  }
143
151
 
144
- for (const { url, target, files, elsewhere } of broken.slice(0, SHOWN)) {
152
+ // A stated cause outranks a guess in what gets printed, not only in how it
153
+ // is worded. The cap exists so one broken preset cannot bury the rest of
154
+ // the build — it must not bury the answer instead.
155
+ const ordered = reasons.size
156
+ ? [...broken].sort((a, b) => (reasons.get(b.target) ? 1 : 0) - (reasons.get(a.target) ? 1 : 0))
157
+ : broken
158
+
159
+ for (const { url, target, files, elsewhere } of ordered.slice(0, SHOWN)) {
145
160
  const reason = reasons.get(target)
146
161
  // Two different problems wear the same symptom. A target whose file
147
162
  // exists elsewhere in the output is a base that is wrong, not an asset
@@ -161,7 +176,7 @@ async function reportBrokenReferences(logger) {
161
176
  }
162
177
  }
163
178
  if (broken.length) {
164
- const explained = broken.slice(0, SHOWN).filter(b => reasons.get(b.target)).length
179
+ const explained = broken.filter(b => reasons.get(b.target)).length
165
180
  const misplaced = broken.filter(b => b.elsewhere?.length && !reasons.get(b.target)).length
166
181
  logger.warn({
167
182
  code: 'reference-broken-summary',
package/src/references.js CHANGED
@@ -38,6 +38,9 @@ const ATTR = /(?:src|href|poster|data-bg)\s*=\s*["']([^"']*)["']/gi
38
38
  const SRCSET = /(?:img|image)?srcset\s*=\s*["']([^"']*)["']/gi
39
39
  // css url(), in a stylesheet and in an inline style attribute alike.
40
40
  const CSS_URL = /url\(\s*(['"]?)([^'")]*)\1\s*\)/gi
41
+ // The same, but inside a CUSTOM PROPERTY declaration — which resolves from a
42
+ // different place, see below.
43
+ const CUSTOM_PROP_URL = /--[\w-]+\s*:\s*[^;{}]*?url\(\s*(['"]?)([^'")]*)\1\s*\)/gi
41
44
 
42
45
  // A url this check has nothing to say about: another origin, an inline
43
46
  // payload, a fragment or an in-page action. `//host/path` is protocol-relative
@@ -88,6 +91,10 @@ export { siteRootFor }
88
91
  export function extractReferences(rawSource) {
89
92
  const source = decodeEntities(rawSource)
90
93
  const found = new Set()
94
+ // Collected first, so a url that appears in a custom property is known to
95
+ // be one however else it is matched — CSS_URL sees it too.
96
+ const custom = new Set()
97
+ for (const [, , url] of source.matchAll(CUSTOM_PROP_URL)) custom.add(url)
91
98
  for (const [, url] of source.matchAll(ATTR)) found.add(url)
92
99
  for (const [, , url] of source.matchAll(CSS_URL)) found.add(url)
93
100
  for (const [, list] of source.matchAll(SRCSET)) {
@@ -96,7 +103,9 @@ export function extractReferences(rawSource) {
96
103
  if (url) found.add(url)
97
104
  }
98
105
  }
99
- return [...found].filter(u => !isExternal(u))
106
+ return [...found]
107
+ .filter(u => !isExternal(u))
108
+ .map(url => ({ url, customProperty: custom.has(url) }))
100
109
  }
101
110
 
102
111
  // Resolve the way a browser does, which is the whole point.
@@ -133,9 +142,18 @@ export function resolveUrl(pageDir, url, { root = '' } = {}) {
133
142
  // { url, target, files } — the target with the pages that named it, because
134
143
  // "this is missing" is only actionable next to "and these link it".
135
144
  export async function checkReferences(outputFolder, { siteRoots = [] } = {}) {
145
+ // Following symlinks, because that is what gets served.
146
+ //
147
+ // files() emits by symlinking the source into the output, so a stylesheet
148
+ // is usually a link rather than a copy — and skipping links meant no
149
+ // symlinked html or css was ever read. Every `url()` inside a bundle went
150
+ // unchecked on any site built that way, silently, while the check reported
151
+ // a confident total. The same-name index below has always followed them,
152
+ // which is how a file could be FOUND elsewhere by a scan that would not
153
+ // READ it.
136
154
  const files = await globby(SCANNED, {
137
155
  cwd: outputFolder,
138
- followSymbolicLinks: false,
156
+ followSymbolicLinks: true,
139
157
  suppressErrors: true,
140
158
  })
141
159
 
@@ -146,6 +164,30 @@ export async function checkReferences(outputFolder, { siteRoots = [] } = {}) {
146
164
  // site — one lookup each.
147
165
  const exists = new Map()
148
166
 
167
+ // Where a `url()` inside a CUSTOM PROPERTY resolves from.
168
+ //
169
+ // Not the page. A custom property is substituted where it is USED, and the
170
+ // url resolves against the stylesheet doing the substituting — so
171
+ //
172
+ // <span style="--icon-btn-src:url(&quot;../media/icons/x.svg&quot;)">
173
+ //
174
+ // on a page three directories deep is CORRECT when the bundle that reads
175
+ // var(--icon-btn-src) sits at styles/. Resolving it from the page reports
176
+ // a base problem for markup that ships and works, and fifteen such lines
177
+ // are how the reader learns to skim past this check entirely.
178
+ //
179
+ // Which stylesheet substitutes it is not knowable from the bytes — any
180
+ // rule using the variable does — so every emitted stylesheet is a
181
+ // candidate base, and one that resolves is enough to stay quiet. A url
182
+ // that resolves from none of them is still reported: it is missing
183
+ // wherever it is read from.
184
+ const styleBases = files
185
+ .filter(file => file.endsWith('.css'))
186
+ .map((file) => {
187
+ const root = siteRootFor(file, siteRoots)
188
+ return { root, dir: path.dirname(file).slice(root.length).replace(/^\/+/, '') }
189
+ })
190
+
149
191
  for (const file of files) {
150
192
  let source
151
193
  try { source = await readFile(path.join(outputFolder, file), 'utf8') }
@@ -155,13 +197,23 @@ export async function checkReferences(outputFolder, { siteRoots = [] } = {}) {
155
197
  // The page's directory, relative to its own site root.
156
198
  const pageDir = path.dirname(file).slice(root.length).replace(/^\/+/, '')
157
199
 
158
- for (const url of extractReferences(source)) {
200
+ for (const { url, customProperty } of extractReferences(source)) {
159
201
  const { target, overDeep, floored } = resolveUrl(pageDir, url, { root })
160
202
  checked++
161
203
 
162
204
  if (!exists.has(target)) {
163
205
  exists.set(target, existsSync(path.join(outputFolder, target)))
164
206
  }
207
+
208
+ // Resolved from a stylesheet instead, and correct there.
209
+ if (!exists.get(target) && customProperty && styleBases.some((base) => {
210
+ const from = resolveUrl(base.dir, url, { root: base.root }).target
211
+ if (!exists.has(from)) {
212
+ exists.set(from, existsSync(path.join(outputFolder, from)))
213
+ }
214
+ return exists.get(from)
215
+ })) continue
216
+
165
217
  // Broken outranks over-deep: a url that resolves nowhere is the
166
218
  // failure, and adding that it is also one level too deep is noise.
167
219
  const bucket = !exists.get(target) ? broken : (overDeep ? overDeepRefs : null)