mikser-io 9.67.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 +55 -1
- package/app.js +26 -3
- package/docs/configuration.md +25 -0
- package/docs/diagnostics.md +40 -4
- package/docs/rendering.md +37 -0
- package/package.json +1 -1
- package/src/engine.js +132 -1
- package/src/instance.js +38 -2
- package/src/plugins/assets.js +7 -0
- package/src/plugins/render/asset.js +15 -1
- package/src/plugins/render/resource.js +10 -2
- package/src/plugins/resources.js +15 -2
- package/src/references.js +187 -0
- package/src/report.js +36 -0
- package/src/track.js +23 -1
- package/src/utils.js +27 -0
package/CLAUDE.md
CHANGED
|
@@ -200,6 +200,57 @@ brevity.
|
|
|
200
200
|
of folder names written in the plugin. A hardcoded list misses every
|
|
201
201
|
collection a project registers through `sources()`, which produced 63
|
|
202
202
|
false warnings per build on a real site.
|
|
203
|
+
- `src/plugins/render/asset.js` / `resource.js` — URL helpers that BUILD
|
|
204
|
+
a path from a naming convention instead of looking an entity up, so
|
|
205
|
+
they cannot fail: a preset that never ran yields a well-formed link to
|
|
206
|
+
nothing on a green build. Each records its destination via
|
|
207
|
+
`track.asset()`; `engine.js` checks the collected set against
|
|
208
|
+
`outputFolder` in `onFinalized` (`reportAssetUse` / `assetUse` in
|
|
209
|
+
report.js, `reportMissingAssets` in engine.js), warning under
|
|
210
|
+
`asset-missing` / `asset-missing-summary`. `existsSync` FOLLOWS
|
|
211
|
+
symlinks, which is required — the assets folder is deployed into the
|
|
212
|
+
output as a symlink. Handlebars appends an options object to every
|
|
213
|
+
helper call, so a trailing optional arg (`format`) must be discarded
|
|
214
|
+
when it looks like one (`'hash' in format`); without that, `asset 'web'
|
|
215
|
+
'/x.jpg'` built `/assets/web/x.[object Object]` — the bug the finalize
|
|
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.
|
|
246
|
+
- Postprocess failures are RENDER ERRORS. The dispatcher's catch used to
|
|
247
|
+
log one uncoded `Postprocess error:` line and stop there — exit code 0,
|
|
248
|
+
nothing in `--json` `errors`, `🟢 Mikser completed`. A build missing
|
|
249
|
+
every PDF it was asked for reported success. It now calls `reportError`
|
|
250
|
+
with the failing `postprocessor`, so a stage that wrote no file counts
|
|
251
|
+
the same as a render that threw. `entity.origin` is deliberately NOT
|
|
252
|
+
unlinked on failure (it is on success): a retry needs it as input, and
|
|
253
|
+
for a converter it is real content.
|
|
203
254
|
- `render.js` / `postprocess.js` — Piscina worker entry points AND the
|
|
204
255
|
default-export functions the INLINE/SERIAL dispatcher calls directly.
|
|
205
256
|
Each receives entity + options + config + state; the WORKER path also
|
|
@@ -246,7 +297,10 @@ brevity.
|
|
|
246
297
|
`--explain`) forward too — they read, so a local run damaged nothing,
|
|
247
298
|
but a catalogue another process is mid-write in is not one anyone can
|
|
248
299
|
answer from. `runReportOnly()` in engine.js is the one implementation
|
|
249
|
-
both paths call.
|
|
300
|
+
both paths call. `--server` / `--watch` are NOT forwardable — they ask
|
|
301
|
+
to BECOME the instance, and a running engine cannot open a port on
|
|
302
|
+
someone's behalf — so they exit 1 with a message when one is already
|
|
303
|
+
there. Exit code comes from `renderErrorCount()`, not
|
|
250
304
|
`process.exitCode` — the engine suppresses that in watch mode by
|
|
251
305
|
design. Config mismatch is refused by resolved PATH; config drift
|
|
252
306
|
under a running instance is detected by stat over `configCoverage`.
|
package/app.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import path from 'node:path'
|
|
3
3
|
|
|
4
4
|
import { setup } from './index.js'
|
|
5
|
-
import { forward } from './src/instance.js'
|
|
5
|
+
import { forward, isInstanceLive } from './src/instance.js'
|
|
6
6
|
|
|
7
7
|
// Before anything is imported or read.
|
|
8
8
|
//
|
|
@@ -26,6 +26,12 @@ function locate(argv) {
|
|
|
26
26
|
}
|
|
27
27
|
const has = (...names) => names.some(n => argv.includes(n) || argv.some(a => names.some(x => a.startsWith(`${x}=`))))
|
|
28
28
|
|
|
29
|
+
// Asking to BECOME the instance, rather than asking one for something.
|
|
30
|
+
// Not forwardable in principle: a running engine cannot open a listener on
|
|
31
|
+
// someone else's behalf, and forwarding it built, printed "completed" and
|
|
32
|
+
// exited with nothing on the port.
|
|
33
|
+
const longRunning = has('--watch', '-w', '--server', '-s')
|
|
34
|
+
|
|
29
35
|
// What to ask the instance for. Report-only commands go over the same
|
|
30
36
|
// socket as a build: they read, so running them locally never damaged
|
|
31
37
|
// anything, but a catalogue being written by another process is not a
|
|
@@ -40,6 +46,7 @@ function locate(argv) {
|
|
|
40
46
|
: { type: 'build', clear: has('--clear') }
|
|
41
47
|
|
|
42
48
|
return {
|
|
49
|
+
longRunning,
|
|
43
50
|
workingFolder: value('--working-folder', '-i') ?? '.',
|
|
44
51
|
config: value('--config', '-c') ?? 'mikser.config.js',
|
|
45
52
|
// Commander's negated form: `attach` is true unless --no-attach said so.
|
|
@@ -50,9 +57,25 @@ function locate(argv) {
|
|
|
50
57
|
|
|
51
58
|
async function main() {
|
|
52
59
|
const where = locate(process.argv.slice(2))
|
|
53
|
-
|
|
60
|
+
const workingFolder = path.resolve(where.workingFolder)
|
|
61
|
+
|
|
62
|
+
// A second server or watcher is the hazard this whole surface exists to
|
|
63
|
+
// remove, and it is the one shape that cannot be answered by forwarding.
|
|
64
|
+
// So it stops, rather than silently doing something else.
|
|
65
|
+
if (where.attach !== false && where.longRunning) {
|
|
66
|
+
if (await isInstanceLive(workingFolder)) {
|
|
67
|
+
process.stderr.write(
|
|
68
|
+
'mikser: another mikser is already running in this folder, and a server or watcher cannot be '
|
|
69
|
+
+ 'forwarded to it — it would have to open a port on your behalf.\n'
|
|
70
|
+
+ 'Stop that one, or pass --no-attach to run a second engine here (two engines share the '
|
|
71
|
+
+ 'catalogue and the output tree, with no lock between them).\n')
|
|
72
|
+
process.exit(1)
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (where.attach !== false && !where.longRunning) {
|
|
54
77
|
const code = await forward({
|
|
55
|
-
workingFolder
|
|
78
|
+
workingFolder,
|
|
56
79
|
config: path.resolve(where.workingFolder, where.config),
|
|
57
80
|
request: where.request,
|
|
58
81
|
})
|
package/docs/configuration.md
CHANGED
|
@@ -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
|
package/docs/diagnostics.md
CHANGED
|
@@ -756,6 +756,10 @@ Three things it refuses or reports rather than guessing:
|
|
|
756
756
|
- **A config that moved under the instance.** It stats every file in
|
|
757
757
|
`config.files` and tells you to restart rather than building with a config
|
|
758
758
|
you have since edited.
|
|
759
|
+
- **A second server or watcher.** `mikser --server` and `mikser --watch` ask
|
|
760
|
+
to *become* the instance, which is not something a running one can do for
|
|
761
|
+
you — it would have to open a port in your process. They exit 1 and say so,
|
|
762
|
+
rather than building and leaving nothing on the port.
|
|
759
763
|
- **A folder held by someone else.** `--no-attach` runs a private engine
|
|
760
764
|
anyway — for checking that a cold start works — and says the folder is held.
|
|
761
765
|
|
|
@@ -846,10 +850,11 @@ surfaces that turn silence into a statement:
|
|
|
846
850
|
- **A pattern or query that matched nothing** — several plugins warn on
|
|
847
851
|
this now (layout patterns, preset matching, null filters). The warnings
|
|
848
852
|
carry stable `code`s in `--json`.
|
|
849
|
-
- **Output that does not match the config** — the config's
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
+
- **Output that does not match the config** — the config's checksum
|
|
854
|
+
covers the local module graph it imports, not just its own bytes, so
|
|
855
|
+
editing a helper the config pulls in forces a rebuild the same way
|
|
856
|
+
editing the config does. A change *outside* that graph — inside an npm
|
|
857
|
+
dependency — still does not, so use `--force` after upgrading one.
|
|
853
858
|
- **A tool that answers emptily because it is broken** — check
|
|
854
859
|
[`faults`](#faults) before reading an empty result as a fact about the
|
|
855
860
|
site. This is the one case where the answer and the failure are the same
|
|
@@ -857,6 +862,37 @@ surfaces that turn silence into a statement:
|
|
|
857
862
|
- **A plugin that appears to do nothing** — `No plugins loaded` with a
|
|
858
863
|
config present is a warning naming the file. A config that fails to
|
|
859
864
|
load now exits non-zero rather than loading as empty.
|
|
865
|
+
- **A postprocess that could not run** — a stage whose external
|
|
866
|
+
dependency is absent (no chrome for a PDF, no binary for a conversion)
|
|
867
|
+
reports a fault naming the subsystem, and each page that wanted that
|
|
868
|
+
output fails as an ordinary render error. The fault says why once; the
|
|
869
|
+
errors say who, and set the exit code. A missing dependency does not
|
|
870
|
+
fail the whole build — the HTML is already written and correct — but it
|
|
871
|
+
does not pass as clean either.
|
|
872
|
+
- **A link to a file nothing produced** — helpers like `asset()` and
|
|
873
|
+
`resource()` *build* a URL from a naming convention rather than looking
|
|
874
|
+
an entity up, so they cannot fail: a preset that never ran, or a
|
|
875
|
+
template naming an extension the preset no longer emits, yields a
|
|
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).
|
|
860
896
|
|
|
861
897
|
## See also
|
|
862
898
|
|
package/docs/rendering.md
CHANGED
|
@@ -375,6 +375,33 @@ Generates relative URLs to transformed assets (output of the assets plugin). Ret
|
|
|
375
375
|
|
|
376
376
|
The underlying path is: `/{assetsFolder}/{preset}/{path}[.{format}]`
|
|
377
377
|
|
|
378
|
+
#### Checked against the output
|
|
379
|
+
|
|
380
|
+
The path is *built* from that convention, not looked up, so the helper
|
|
381
|
+
cannot fail — it returns a well-formed URL whether or not anything ever
|
|
382
|
+
produced the file. A preset that did not run, a source that is not
|
|
383
|
+
matched by the preset's patterns, or a template naming a format the
|
|
384
|
+
preset no longer emits all yield a link to nothing, on a build that
|
|
385
|
+
reports success.
|
|
386
|
+
|
|
387
|
+
So every `asset()` and `resource()` call records its destination on the
|
|
388
|
+
render track, and at finalize the engine checks each one against the
|
|
389
|
+
output folder. Anything missing is warned under the `asset-missing` code,
|
|
390
|
+
naming the path and the pages that linked it, with a summary under
|
|
391
|
+
`asset-missing-summary`. The check follows symlinks, so an assets folder
|
|
392
|
+
deployed into the output as a link resolves normally.
|
|
393
|
+
|
|
394
|
+
It is a warning, not an error: a build can legitimately link a file that
|
|
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).
|
|
404
|
+
|
|
378
405
|
---
|
|
379
406
|
|
|
380
407
|
### `render-resource` — CDN Resource Mapping
|
|
@@ -542,6 +569,16 @@ Return shapes the dispatcher accepts:
|
|
|
542
569
|
|
|
543
570
|
A chain is all-or-nothing. The first stage to throw or return `{success: false}` fails the entry; the dispatcher unlinks every intermediate it produced and the final destination, then re-throws. No partial output leaks.
|
|
544
571
|
|
|
572
|
+
The renderer's own output (`entity.origin`) is the exception: on success the
|
|
573
|
+
dispatcher unlinks it, on failure it stays. It is the input a retry needs, and
|
|
574
|
+
for a converter it is content in its own right — the HTML of a report whose PDF
|
|
575
|
+
could not be produced.
|
|
576
|
+
|
|
577
|
+
A failed entry counts as a **render error**: it lands in `errors` under `--json`
|
|
578
|
+
with the failing `postprocessor` named, and sets the exit code. It wrote no
|
|
579
|
+
file, so it has to count the same as a render that threw — otherwise a build
|
|
580
|
+
missing every PDF it was asked for reports success.
|
|
581
|
+
|
|
545
582
|
### Execution mode
|
|
546
583
|
|
|
547
584
|
Postprocess inherits the layout's `task:` setting. `task: worker` routes both render and postprocess through the lazy Piscina pool (right for CPU-heavy stages: PDF, image compose, big MJML compilations). Default INLINE is right for everything else.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mikser-io",
|
|
3
|
-
"version": "9.
|
|
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
|
@@ -11,7 +11,8 @@ import { useJournal, updateEntry } from './journal.js'
|
|
|
11
11
|
import { globby } from 'globby'
|
|
12
12
|
import { OPERATION, TASKS } from './constants.js'
|
|
13
13
|
import { changeExtension, formatErrorContext, projectMeta, lookupKeys } from './utils.js'
|
|
14
|
-
import { reportRendered, reportSkipped, reportError, renderErrorCount, emitReport, finishCycle } from './report.js'
|
|
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,102 @@ 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
|
+
|
|
148
|
+
// Warn for anything a render linked to that is not in the output.
|
|
149
|
+
//
|
|
150
|
+
// Deliberately phrased as what was OBSERVED. Only entities that rendered this
|
|
151
|
+
// cycle recorded anything, so an incremental build checks the pages it built
|
|
152
|
+
// and says nothing about the rest — the same reasoning the assets plugin
|
|
153
|
+
// already applies to its preset warning, and for the same reason: a warning
|
|
154
|
+
// that overclaims gets filtered, and the filtered-out line is the real one.
|
|
155
|
+
async function reportMissingAssets(logger, alreadyReported = new Set()) {
|
|
156
|
+
const used = assetUse()
|
|
157
|
+
if (!used.length) return
|
|
158
|
+
const outputFolder = runtime.options.outputFolder
|
|
159
|
+
if (!outputFolder) return
|
|
160
|
+
|
|
161
|
+
const missing = []
|
|
162
|
+
for (const [destination, ids] of used) {
|
|
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
|
|
168
|
+
if (!existsSync(file)) missing.push([destination, ids])
|
|
169
|
+
}
|
|
170
|
+
if (!missing.length) return
|
|
171
|
+
|
|
172
|
+
// Capped, with the total alongside. One broken preset can be referenced by
|
|
173
|
+
// every page on the site, and a thousand lines of it buries whatever else
|
|
174
|
+
// the build said.
|
|
175
|
+
const SHOWN = 10
|
|
176
|
+
for (const [destination, ids] of missing.slice(0, SHOWN)) {
|
|
177
|
+
logger.warn({ code: 'asset-missing', destination, referencedBy: ids },
|
|
178
|
+
'Linked but not in the output: %s — referenced by %s', destination,
|
|
179
|
+
ids.slice(0, 3).join(', ') + (ids.length > 3 ? ` and ${ids.length - 3} more` : ''))
|
|
180
|
+
}
|
|
181
|
+
logger.warn({ code: 'asset-missing-summary', missing: missing.length, checked: used.length },
|
|
182
|
+
'%d of %d linked file(s) are not in the output%s. A URL helper builds the path rather than looking it '
|
|
183
|
+
+ 'up, so this is a link to something nothing produced — usually a preset that did not run, or a '
|
|
184
|
+
+ 'template naming an extension the preset no longer emits.',
|
|
185
|
+
missing.length, used.length,
|
|
186
|
+
missing.length > SHOWN ? `, ${SHOWN} shown` : '')
|
|
187
|
+
}
|
|
188
|
+
|
|
92
189
|
// The report-only commands, as functions that RETURN their exit code.
|
|
93
190
|
//
|
|
94
191
|
// They used to be inline here and call process.exit, which is fine for a
|
|
@@ -677,6 +774,15 @@ export async function setup(options) {
|
|
|
677
774
|
result = rendered?.output
|
|
678
775
|
if (rendered?.track) mergeTrack(track, rendered.track)
|
|
679
776
|
|
|
777
|
+
// Which files this entity's template linked to. Harvested
|
|
778
|
+
// here because this is the one place that has both the
|
|
779
|
+
// track and the entity that produced it — a worker's track
|
|
780
|
+
// has just been folded in, so a worker render is covered
|
|
781
|
+
// the same as an inline one.
|
|
782
|
+
for (const destination of track.assets ?? []) {
|
|
783
|
+
reportAssetUse(destination, renderEntity.id)
|
|
784
|
+
}
|
|
785
|
+
|
|
680
786
|
if (!signal.aborted) {
|
|
681
787
|
// Meta reads ride on `output`, which is already
|
|
682
788
|
// free-form JSON on the journal row, rather than in
|
|
@@ -1014,6 +1120,17 @@ export async function setup(options) {
|
|
|
1014
1120
|
if (!signal.aborted) {
|
|
1015
1121
|
await updateEntry({ id, output: { success: false } })
|
|
1016
1122
|
logger.error('Postprocess error: %s%s %s', entity.id, formatErrorContext(entity, err, runtime.options), err.message)
|
|
1123
|
+
// A failed postprocess wrote no file, so it has to
|
|
1124
|
+
// count the same as a failed render. It did not:
|
|
1125
|
+
// this line was the whole of it, the exit code
|
|
1126
|
+
// stayed 0, and `errors` in --json never mentioned
|
|
1127
|
+
// it — a build with output missing reporting
|
|
1128
|
+
// success, which is the exact shape of bug the
|
|
1129
|
+
// rest of this file exists to make impossible.
|
|
1130
|
+
reportError(entity, err, {
|
|
1131
|
+
postprocessor: options.postprocessor ?? null,
|
|
1132
|
+
layout: entity.layout?.id ?? null,
|
|
1133
|
+
})
|
|
1017
1134
|
}
|
|
1018
1135
|
logger.debug('Postprocess canceled')
|
|
1019
1136
|
}
|
|
@@ -1070,6 +1187,20 @@ export async function setup(options) {
|
|
|
1070
1187
|
if (failed) logger.error('Mikser completed with %d render error%s', failed, failed === 1 ? '' : 's')
|
|
1071
1188
|
else logger.notice('Mikser completed')
|
|
1072
1189
|
|
|
1190
|
+
// Is everything the templates linked to actually there?
|
|
1191
|
+
//
|
|
1192
|
+
// The URL helpers build paths; they do not resolve them. So a preset
|
|
1193
|
+
// that never ran, a library that was not copied, or a template naming
|
|
1194
|
+
// an extension the preset stopped producing all yield a well-formed
|
|
1195
|
+
// link to a file that does not exist — and the only symptom is a
|
|
1196
|
+
// missing image on the deployed site, found by a person.
|
|
1197
|
+
//
|
|
1198
|
+
// Checked at the end of the cycle because that is the first moment the
|
|
1199
|
+
// answer is stable: derivatives are produced during the cycle, so
|
|
1200
|
+
// asking any earlier would report files that were about to appear.
|
|
1201
|
+
const brokenTargets = await reportBrokenReferences(useLogger())
|
|
1202
|
+
await reportMissingAssets(useLogger(), brokenTargets)
|
|
1203
|
+
|
|
1073
1204
|
// After the cycle, and only under --json. stdout has been kept clear
|
|
1074
1205
|
// for exactly this (the logger writes to stderr under --json), so the
|
|
1075
1206
|
// document is the only thing on it and can be piped to jq.
|
package/src/instance.js
CHANGED
|
@@ -149,6 +149,35 @@ export function forward({ workingFolder, config, request }) {
|
|
|
149
149
|
})
|
|
150
150
|
}
|
|
151
151
|
|
|
152
|
+
// Is somebody already holding this folder?
|
|
153
|
+
//
|
|
154
|
+
// Used by the commands that cannot be forwarded because they are not requests
|
|
155
|
+
// at all — `--server` and `--watch` ask to BECOME the instance. Forwarding one
|
|
156
|
+
// builds and exits, and the port never opens: the caller asked for a server on
|
|
157
|
+
// 3010, got "completed", and has nothing listening.
|
|
158
|
+
//
|
|
159
|
+
// Same stale-socket rule as forward(): connect-and-fail means nobody is there,
|
|
160
|
+
// never a wait.
|
|
161
|
+
export function isInstanceLive(workingFolder) {
|
|
162
|
+
const endpoint = socketPath(workingFolder)
|
|
163
|
+
return new Promise((resolve) => {
|
|
164
|
+
const probe = net.connect(endpoint)
|
|
165
|
+
let settled = false
|
|
166
|
+
const answer = (live) => {
|
|
167
|
+
if (settled) return
|
|
168
|
+
settled = true
|
|
169
|
+
try { probe.destroy() } catch { /* already gone */ }
|
|
170
|
+
if (!live && process.platform !== 'win32' && existsSync(endpoint)) {
|
|
171
|
+
try { unlinkSync(endpoint) } catch { /* another client won the race */ }
|
|
172
|
+
}
|
|
173
|
+
resolve(live)
|
|
174
|
+
}
|
|
175
|
+
probe.on('connect', () => answer(true))
|
|
176
|
+
probe.on('error', () => answer(false))
|
|
177
|
+
setTimeout(() => answer(false), 1000).unref?.()
|
|
178
|
+
})
|
|
179
|
+
}
|
|
180
|
+
|
|
152
181
|
// ── server ──────────────────────────────────────────────────────────────
|
|
153
182
|
|
|
154
183
|
// Requests run one at a time, to completion.
|
|
@@ -409,10 +438,17 @@ export async function warnIfHeld({ workingFolder, attached }) {
|
|
|
409
438
|
export function instanceControl() {
|
|
410
439
|
serveInstance()
|
|
411
440
|
onLoaded(async () => {
|
|
412
|
-
|
|
441
|
+
// Only --no-attach reaches this now. Everything else either forwarded
|
|
442
|
+
// (a build, a report) or refused before setup ran (a second server or
|
|
443
|
+
// watcher), so a process that is still here and not attaching is one
|
|
444
|
+
// that deliberately opted out — and that is exactly the case worth
|
|
445
|
+
// saying something about, long-running or not. The earlier gate
|
|
446
|
+
// skipped watch and server, which excluded `--no-attach --server`:
|
|
447
|
+
// the very command that puts two engines on one folder.
|
|
448
|
+
if (runtime.options.attach !== false) return
|
|
413
449
|
await warnIfHeld({
|
|
414
450
|
workingFolder: runtime.options.workingFolder,
|
|
415
|
-
attached:
|
|
451
|
+
attached: false,
|
|
416
452
|
})
|
|
417
453
|
})
|
|
418
454
|
}
|
package/src/plugins/assets.js
CHANGED
|
@@ -231,6 +231,13 @@ export function assets(options = {}) {
|
|
|
231
231
|
for (let entityPreset of assetsMap[entityToRender.id] || []) {
|
|
232
232
|
const entity = _.cloneDeep(entityToRender)
|
|
233
233
|
entity.preset = presets[entityPreset]
|
|
234
|
+
// Named in config but with no module in presets/ and no
|
|
235
|
+
// mikser-io-preset-* package: there is nothing to render with.
|
|
236
|
+
// Loading already reported this preset ('Preset not found'),
|
|
237
|
+
// and the finalize check names the links left dangling. A line
|
|
238
|
+
// per entity here would just be that fact a third time, once
|
|
239
|
+
// per matched file.
|
|
240
|
+
if (!entity.preset) continue
|
|
234
241
|
// Per-preset config options override the preset module's
|
|
235
242
|
// defaults. Looked up at render time so config edits are
|
|
236
243
|
// picked up on the next cycle without rebuilding the
|
|
@@ -15,7 +15,7 @@ import { changeExtension } from '../../utils.js'
|
|
|
15
15
|
// derivative may simply not have been generated, and this cannot tell.
|
|
16
16
|
// `meta.presets` (ADR-0011) is the looked-up answer where a caller has the
|
|
17
17
|
// entity; this helper exists for the case where they have a path.
|
|
18
|
-
export function load({ runtime, entity, state, options, logger }) {
|
|
18
|
+
export function load({ runtime, entity, state, options, logger, track }) {
|
|
19
19
|
const presets = state?.assets?.presets ?? {}
|
|
20
20
|
const warned = new Set()
|
|
21
21
|
const warnOnce = (key, code, message, ...args) => {
|
|
@@ -27,6 +27,15 @@ export function load({ runtime, entity, state, options, logger }) {
|
|
|
27
27
|
runtime.asset = (preset, url, format) => {
|
|
28
28
|
if (url[0] != '/') url = `/${url}`
|
|
29
29
|
|
|
30
|
+
// Handlebars appends its own options object to every helper call, so a
|
|
31
|
+
// two-argument `{{asset 'web' '/media/hero.jpg'}}` arrives here with a
|
|
32
|
+
// third. Taken as a format it stringifies, and the helper cheerfully
|
|
33
|
+
// built `hero.[object Object]` — a well-formed link to a file that
|
|
34
|
+
// could never exist. file.js strips it for the same reason; this one
|
|
35
|
+
// never did, and nothing noticed until the output check started
|
|
36
|
+
// looking at what these URLs point at.
|
|
37
|
+
if (format && typeof format === 'object' && 'hash' in format) format = undefined
|
|
38
|
+
|
|
30
39
|
const declared = presets[preset]?.format
|
|
31
40
|
|
|
32
41
|
// A preset name nothing declares. The URL still gets built — it is a
|
|
@@ -57,6 +66,11 @@ export function load({ runtime, entity, state, options, logger }) {
|
|
|
57
66
|
// happened to be rendering.
|
|
58
67
|
const relative = `${state.assets.assetsFolder}/${preset}${effective ? changeExtension(url, effective) : url}`
|
|
59
68
|
const destination = '/' + relative
|
|
69
|
+
// Recorded so the engine can check at the end of the cycle whether
|
|
70
|
+
// this file exists. It is the one thing this helper cannot answer for
|
|
71
|
+
// itself — it takes a path, not an entity, so there is nothing to look
|
|
72
|
+
// up and the URL is well-formed whether or not anything produced it.
|
|
73
|
+
track?.asset?.(destination)
|
|
60
74
|
const from = path.dirname(entity.destination || '/')
|
|
61
75
|
return { url: path.relative(from, destination) }
|
|
62
76
|
}
|
|
@@ -1,14 +1,22 @@
|
|
|
1
1
|
import path from 'node:path'
|
|
2
|
+
import { matchesLibrary } from '../../utils.js'
|
|
2
3
|
|
|
3
|
-
export function load({ runtime, entity, state, options }) {
|
|
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
|
-
|
|
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]}`)
|
|
11
15
|
const destination = '/' + relative
|
|
16
|
+
// Same reason as the asset helper: this builds a URL rather
|
|
17
|
+
// than resolving one, so a library that was never copied
|
|
18
|
+
// yields a link to nothing on a green build.
|
|
19
|
+
track?.asset?.(destination)
|
|
12
20
|
const from = path.dirname(entity.destination || '/')
|
|
13
21
|
return { url: path.relative(from, destination), name }
|
|
14
22
|
}
|
package/src/plugins/resources.js
CHANGED
|
@@ -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
|
-
|
|
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("../media/raw/icons/x.svg")"
|
|
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 < back
|
|
76
|
+
// into a bracket could invent markup that was deliberately escaped.
|
|
77
|
+
function decodeEntities(source) {
|
|
78
|
+
return source
|
|
79
|
+
.replace(/"|"/g, '"')
|
|
80
|
+
.replace(/'|'/g, "'")
|
|
81
|
+
.replace(/&/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/report.js
CHANGED
|
@@ -115,6 +115,10 @@ export function resetReport() {
|
|
|
115
115
|
runtime.state.renderErrors = []
|
|
116
116
|
// Per cycle, unlike the wipe: what changed is a fact about THIS build.
|
|
117
117
|
runtime.state.changed = { ids: [], count: 0 }
|
|
118
|
+
// Per cycle too: an asset referenced by a page that did not re-render this
|
|
119
|
+
// time was not re-checked, and claiming otherwise would be the kind of
|
|
120
|
+
// completeness this codebase keeps having to walk back.
|
|
121
|
+
runtime.state.assetUse = new Map()
|
|
118
122
|
}
|
|
119
123
|
|
|
120
124
|
// Published on the runtime so runtime.js can start a fresh cycle for a
|
|
@@ -209,6 +213,38 @@ export function reportEvaluated(scope, { evaluated, of } = {}) {
|
|
|
209
213
|
store().evaluated[scope] = { evaluated: evaluated ?? 0, ...(Number.isFinite(of) ? { of } : {}) }
|
|
210
214
|
}
|
|
211
215
|
|
|
216
|
+
// Files a render linked to, and who linked to them.
|
|
217
|
+
//
|
|
218
|
+
// The URL helpers build paths rather than resolving them, so nothing they hand
|
|
219
|
+
// a template has been checked against a file: a preset that never ran, or one
|
|
220
|
+
// whose format changed under a template still naming the old extension,
|
|
221
|
+
// produces a well-formed link to nothing. The page renders, the build is
|
|
222
|
+
// green, and the image is missing until somebody looks at the site.
|
|
223
|
+
//
|
|
224
|
+
// Keyed by destination with the referencing entities attached, because "this
|
|
225
|
+
// file is missing" is only actionable next to "and these pages point at it".
|
|
226
|
+
//
|
|
227
|
+
// NOT gated on reportWanted(): the check that reads this runs on every build,
|
|
228
|
+
// and gating it would make a broken link visible only to someone who already
|
|
229
|
+
// suspected one.
|
|
230
|
+
export function reportAssetUse(destination, entityId) {
|
|
231
|
+
if (!destination) return
|
|
232
|
+
const store = assetUseStore()
|
|
233
|
+
if (!store.has(destination)) store.set(destination, new Set())
|
|
234
|
+
if (entityId) store.get(destination).add(entityId)
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function assetUseStore() {
|
|
238
|
+
runtime.state ??= {}
|
|
239
|
+
runtime.state.assetUse ??= new Map()
|
|
240
|
+
return runtime.state.assetUse
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Everything referenced this cycle, as [destination, [entity ids]].
|
|
244
|
+
export function assetUse() {
|
|
245
|
+
return [...assetUseStore()].map(([destination, ids]) => [destination, [...ids]])
|
|
246
|
+
}
|
|
247
|
+
|
|
212
248
|
// An entity whose SOURCE did not change is gated at import and never becomes
|
|
213
249
|
// a render task at all — so it appears in neither `rendered` nor `skipped`,
|
|
214
250
|
// and the two lists would not reconcile with the corpus size without saying
|
package/src/track.js
CHANGED
|
@@ -40,7 +40,7 @@ export function filterKey(filter) {
|
|
|
40
40
|
// The returned object exposes `partials: Set<string>` and
|
|
41
41
|
// `queries: Array<filter | null>` directly. Consumers iterate either
|
|
42
42
|
// shape; both are owned by the track for the lifetime of the run.
|
|
43
|
-
export function createTrack({ partial = true, query = true, lookup = true, meta = false, consumed = false } = {}) {
|
|
43
|
+
export function createTrack({ partial = true, query = true, lookup = true, meta = false, consumed = false, assets = true } = {}) {
|
|
44
44
|
const track = {}
|
|
45
45
|
if (lookup) {
|
|
46
46
|
// Lookups a TEMPLATE made by name: runtime.href('/contacts'),
|
|
@@ -122,6 +122,26 @@ export function createTrack({ partial = true, query = true, lookup = true, meta
|
|
|
122
122
|
paths.add(path)
|
|
123
123
|
}
|
|
124
124
|
}
|
|
125
|
+
if (assets) {
|
|
126
|
+
// Files a template asked for by URL: a preset derivative, a resource
|
|
127
|
+
// from a library. Recorded so the engine can check at the end of the
|
|
128
|
+
// cycle whether the thing being linked to is actually in the output.
|
|
129
|
+
//
|
|
130
|
+
// The helpers BUILD these paths — nothing they return has been checked
|
|
131
|
+
// against a file — so a preset that never ran, or one whose format
|
|
132
|
+
// changed, produces a perfectly well-formed link to nothing. The page
|
|
133
|
+
// renders, the build is green, and the image is missing until a person
|
|
134
|
+
// notices.
|
|
135
|
+
//
|
|
136
|
+
// The output-relative destination, not the page-relative url the
|
|
137
|
+
// helper returns: `../../assets/web/hero.webp` cannot be resolved
|
|
138
|
+
// without knowing which page asked, and this is the form that maps
|
|
139
|
+
// straight onto a path under the output folder.
|
|
140
|
+
const assetRefs = new Set()
|
|
141
|
+
track.assets = assetRefs
|
|
142
|
+
track.asset = (destination) => { if (destination) assetRefs.add(destination) }
|
|
143
|
+
}
|
|
144
|
+
|
|
125
145
|
if (query) {
|
|
126
146
|
const queries = []
|
|
127
147
|
const queryKeys = new Set()
|
|
@@ -282,6 +302,7 @@ export function serializeTrack(track) {
|
|
|
282
302
|
partials: track.partials ? [...track.partials] : undefined,
|
|
283
303
|
queries: track.queries ? [...track.queries] : undefined,
|
|
284
304
|
metaReads: track.metaReads ? [...track.metaReads] : undefined,
|
|
305
|
+
assets: track.assets ? [...track.assets] : undefined,
|
|
285
306
|
lookups: track.lookups ? [...track.lookups].map(([k, v]) => [k, [...v]]) : undefined,
|
|
286
307
|
consumedReads: track.consumedReads
|
|
287
308
|
? [...track.consumedReads].map(([k, v]) => [k, [...v]]) : undefined,
|
|
@@ -296,6 +317,7 @@ export function mergeTrack(target, data) {
|
|
|
296
317
|
for (const p of data.partials ?? []) target.partial?.(p)
|
|
297
318
|
for (const q of data.queries ?? []) target.query?.(q)
|
|
298
319
|
for (const m of data.metaReads ?? []) target.metaRead?.(m)
|
|
320
|
+
for (const a of data.assets ?? []) target.asset?.(a)
|
|
299
321
|
for (const [name, ids] of data.lookups ?? []) target.lookup?.(name, ids)
|
|
300
322
|
for (const [id, paths] of data.consumedReads ?? []) {
|
|
301
323
|
for (const path of paths) target.consumedRead?.(id, path)
|
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
|
+
}
|