mikser-io 9.72.0 → 9.74.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/app.js +3 -6
- package/docs/configuration.md +27 -12
- package/docs/diagnostics.md +14 -9
- package/package.json +12 -1
- package/src/engine.js +46 -12
- package/src/instance.js +7 -52
- package/src/plugins/render/asset.js +5 -3
- package/src/plugins/render/file.js +1 -1
- package/src/plugins/render/href.js +3 -4
- package/src/plugins/render/resource.js +2 -3
- package/src/references.js +15 -25
- package/src/utils.js +47 -0
- package/10.0-PLAN.md +0 -182
- package/CLAUDE.md +0 -787
- package/CLOUD-PLAN.md +0 -147
- package/runtime/mikser.sqlite +0 -0
- package/runtime/mikser.sqlite-shm +0 -0
- package/runtime/mikser.sqlite-wal +0 -0
package/app.js
CHANGED
|
@@ -49,8 +49,6 @@ function locate(argv) {
|
|
|
49
49
|
longRunning,
|
|
50
50
|
workingFolder: value('--working-folder', '-i') ?? '.',
|
|
51
51
|
config: value('--config', '-c') ?? 'mikser.config.js',
|
|
52
|
-
// Commander's negated form: `attach` is true unless --no-attach said so.
|
|
53
|
-
attach: has('--no-attach') ? false : true,
|
|
54
52
|
request,
|
|
55
53
|
}
|
|
56
54
|
}
|
|
@@ -62,18 +60,17 @@ async function main() {
|
|
|
62
60
|
// A second server or watcher is the hazard this whole surface exists to
|
|
63
61
|
// remove, and it is the one shape that cannot be answered by forwarding.
|
|
64
62
|
// So it stops, rather than silently doing something else.
|
|
65
|
-
if (where.
|
|
63
|
+
if (where.longRunning) {
|
|
66
64
|
if (await isInstanceLive(workingFolder)) {
|
|
67
65
|
process.stderr.write(
|
|
68
66
|
'mikser: another mikser is already running in this folder, and a server or watcher cannot be '
|
|
69
67
|
+ 'forwarded to it — it would have to open a port on your behalf.\n'
|
|
70
|
-
+ 'Stop that one
|
|
71
|
-
+ 'catalogue and the output tree, with no lock between them).\n')
|
|
68
|
+
+ 'Stop that one first.\n')
|
|
72
69
|
process.exit(1)
|
|
73
70
|
}
|
|
74
71
|
}
|
|
75
72
|
|
|
76
|
-
if (
|
|
73
|
+
if (!where.longRunning) {
|
|
77
74
|
const code = await forward({
|
|
78
75
|
workingFolder,
|
|
79
76
|
config: path.resolve(where.workingFolder, where.config),
|
package/docs/configuration.md
CHANGED
|
@@ -70,24 +70,39 @@ Which subtrees of the output folder are deployed as their own domain root.
|
|
|
70
70
|
|
|
71
71
|
```js
|
|
72
72
|
export default {
|
|
73
|
-
// out/bg
|
|
73
|
+
// out/bg, out/en and out/mk each deploy to their own domain
|
|
74
74
|
siteRoots: ['bg', 'en', 'mk'],
|
|
75
75
|
}
|
|
76
76
|
```
|
|
77
77
|
|
|
78
|
-
Read
|
|
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.
|
|
78
|
+
Read by the url helpers and by the broken-reference check.
|
|
83
79
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
80
|
+
**It changes the output.** `asset`, `href` and `resource` build a path from the
|
|
81
|
+
page to the target, and without this they measure from the output folder. When
|
|
82
|
+
`out/bg` is what gets deployed, that is one directory too far — every url
|
|
83
|
+
carries an extra `..` for the site segment. A browser floors a climb above the
|
|
84
|
+
origin root rather than failing, so those urls load and nothing reports them.
|
|
85
|
+
Declaring the roots makes the helpers measure from the site instead, and the
|
|
86
|
+
urls become what they should have been.
|
|
87
|
+
|
|
88
|
+
The check reads it for the same reason, and resolves urls the way a browser
|
|
89
|
+
does. Where the site root is decides whether `../../x.svg` on a given page is
|
|
90
|
+
correct, merely over-deep, or broken.
|
|
88
91
|
|
|
89
|
-
|
|
90
|
-
|
|
92
|
+
Default is the output folder itself, which is right for the ordinary case of
|
|
93
|
+
one site per build — and with nothing declared every url is byte-identical to
|
|
94
|
+
what it was. Only a build that emits several sites moves, and it moves from
|
|
95
|
+
working-by-flooring to correct.
|
|
96
|
+
|
|
97
|
+
Declaring it also asserts something: **a site root is a deployable unit.** It
|
|
98
|
+
is served alone, so anything its pages reference has to exist beneath it —
|
|
99
|
+
share a common assets folder into each root rather than beside them. A url to
|
|
100
|
+
a target in a *different* root is left as it was, because on a per-domain
|
|
101
|
+
deploy no relative path reaches another origin; the check will report it, which
|
|
102
|
+
is the honest answer.
|
|
103
|
+
|
|
104
|
+
Nothing can infer any of this: it is a fact about where the bytes get deployed,
|
|
105
|
+
not about the bytes.
|
|
91
106
|
|
|
92
107
|
## Engine Substrate
|
|
93
108
|
|
package/docs/diagnostics.md
CHANGED
|
@@ -231,7 +231,7 @@ the whole question:
|
|
|
231
231
|
```json
|
|
232
232
|
{ "destination": "/index.html", "reason": "query-matched",
|
|
233
233
|
"matched": { "filter": { "id": { "$regex": "^/documents/devices/" } },
|
|
234
|
-
"by": "/documents/devices/
|
|
234
|
+
"by": "/documents/devices/model-a.md" } }
|
|
235
235
|
```
|
|
236
236
|
|
|
237
237
|
A `matched.filter` of `null` is a different statement: the page's predicate
|
|
@@ -760,8 +760,9 @@ Three things it refuses or reports rather than guessing:
|
|
|
760
760
|
to *become* the instance, which is not something a running one can do for
|
|
761
761
|
you — it would have to open a port in your process. They exit 1 and say so,
|
|
762
762
|
rather than building and leaving nothing on the port.
|
|
763
|
-
-
|
|
764
|
-
|
|
763
|
+
There is no opt-out. A flag for running a second engine on a held folder
|
|
764
|
+
only ever enabled the accident this surface prevents, and stopping the
|
|
765
|
+
instance serves every case it was reached for.
|
|
765
766
|
|
|
766
767
|
`--tool`, `--tools`, `--verify` and `--explain` forward as well, and for a
|
|
767
768
|
different reason than builds do. They only read, so running one locally never
|
|
@@ -887,12 +888,16 @@ surfaces that turn silence into a statement:
|
|
|
887
888
|
same file, the output scan reports it and this one stays quiet.
|
|
888
889
|
- **A link that works only by accident** — a url with one `..` too many
|
|
889
890
|
still loads, because a browser discards a climb above the origin root
|
|
890
|
-
rather than failing.
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
891
|
+
rather than failing. Reported under `reference-over-deep`, separately
|
|
892
|
+
from the outright failures, and grouped by how far each climbed:
|
|
893
|
+
- **One url, or several climbing different distances** — each is a
|
|
894
|
+
latent 404, working today and broken as soon as the same markup
|
|
895
|
+
renders one level deeper.
|
|
896
|
+
- **Every url climbing the same distance** — not N problems but one
|
|
897
|
+
base that is off by a constant, reported once and flagged
|
|
898
|
+
`structural` in `--json`. The urls work at every depth. Usually it
|
|
899
|
+
means `siteRoots` is undeclared for a build that emits several
|
|
900
|
+
sites; see [configuration](./configuration.md#siteroots).
|
|
896
901
|
|
|
897
902
|
## See also
|
|
898
903
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,17 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mikser-io",
|
|
3
|
-
"version": "9.
|
|
3
|
+
"version": "9.74.0",
|
|
4
|
+
"files": [
|
|
5
|
+
"app.js",
|
|
6
|
+
"index.js",
|
|
7
|
+
"src/",
|
|
8
|
+
"testing/",
|
|
9
|
+
"docs/",
|
|
10
|
+
"favicon.ico",
|
|
11
|
+
"favicon.svg",
|
|
12
|
+
"mikser-mark.svg",
|
|
13
|
+
"mikser-lockup-stacked.svg"
|
|
14
|
+
],
|
|
4
15
|
"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
16
|
"main": "index.js",
|
|
6
17
|
"exports": {
|
package/src/engine.js
CHANGED
|
@@ -127,19 +127,45 @@ async function reportBrokenReferences(logger) {
|
|
|
127
127
|
broken.length, checked, broken.length > SHOWN ? `, ${SHOWN} shown` : '')
|
|
128
128
|
}
|
|
129
129
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
130
|
+
// Grouped by how FAR each climbed, because a site whose every over-deep url
|
|
131
|
+
// climbs the same distance does not have N problems — it has one base that
|
|
132
|
+
// is off by a constant. Printing it N times is precisely how a real signal
|
|
133
|
+
// gets filtered out, which is the failure this check exists to prevent.
|
|
134
|
+
const byClimb = new Map()
|
|
135
|
+
for (const entry of overDeep) {
|
|
136
|
+
if (!byClimb.has(entry.floored)) byClimb.set(entry.floored, [])
|
|
137
|
+
byClimb.get(entry.floored).push(entry)
|
|
134
138
|
}
|
|
139
|
+
// One distance, many urls: structural. The helper's base is wrong, the urls
|
|
140
|
+
// are not — they load at every depth, because the climb is always floored.
|
|
141
|
+
// That wants a different reaction than a hand-written `../..` that happens
|
|
142
|
+
// to be right on one page and is a 404 waiting on the next.
|
|
143
|
+
const structural = byClimb.size === 1 && overDeep.length > 1
|
|
144
|
+
|
|
145
|
+
for (const [climb, entries] of [...byClimb].sort(([a], [b]) => a - b)) {
|
|
146
|
+
const examples = entries.slice(0, 3).map(e => e.url)
|
|
147
|
+
logger.warn(
|
|
148
|
+
{
|
|
149
|
+
code: 'reference-over-deep', climbs: climb, count: entries.length,
|
|
150
|
+
structural, urls: examples,
|
|
151
|
+
files: [...new Set(entries.flatMap(e => e.files))].slice(0, 3),
|
|
152
|
+
},
|
|
153
|
+
structural
|
|
154
|
+
? '%d references climb %d level(s) above the site root — every one of them, by the '
|
|
155
|
+
+ 'same amount. They load: a browser discards the extra `..`. What is wrong is the '
|
|
156
|
+
+ 'base they were built from, not the links. Examples: %s'
|
|
157
|
+
: '%d reference(s) climb %d level(s) above the site root and load only because a '
|
|
158
|
+
+ 'browser discards the extra `..`. Each breaks if the same markup renders one '
|
|
159
|
+
+ 'level deeper. Examples: %s',
|
|
160
|
+
entries.length, climb, examples.join(', '))
|
|
161
|
+
}
|
|
162
|
+
|
|
135
163
|
if (overDeep.length) {
|
|
136
|
-
logger.warn({ code: 'reference-over-deep-summary', overDeep: overDeep.length, checked },
|
|
137
|
-
'%d of %d reference(s)
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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.')
|
|
164
|
+
logger.warn({ code: 'reference-over-deep-summary', overDeep: overDeep.length, checked, structural },
|
|
165
|
+
'%d of %d reference(s) resolve above the site root.%s',
|
|
166
|
+
overDeep.length, checked,
|
|
167
|
+
siteRoots.length ? '' : ' No siteRoots are declared, so this resolved against the output '
|
|
168
|
+
+ 'root — declare siteRoots if a subtree is deployed as its own domain.')
|
|
143
169
|
}
|
|
144
170
|
|
|
145
171
|
return new Set(broken.map(b => b.target))
|
|
@@ -360,7 +386,6 @@ export async function setup(options) {
|
|
|
360
386
|
runtime.engine.commander?.version(packageInfo.version)
|
|
361
387
|
.option('-i --working-folder <folder>', 'set mikser working folder', './')
|
|
362
388
|
.option('-c --config <file>', 'set mikser mikser.config.js location', './mikser.config.js')
|
|
363
|
-
.option('--no-attach', 'start a private engine instead of forwarding to the one already running here')
|
|
364
389
|
.option('-m --mode <mode>', 'set mikser runtime mode', 'development')
|
|
365
390
|
.option('-r --clear', 'clear current state before execution', false)
|
|
366
391
|
.option('-o --output-folder <folder>', 'set mikser output folder relative to working folder', 'out')
|
|
@@ -472,6 +497,15 @@ export async function setup(options) {
|
|
|
472
497
|
// runtime.config) and before any plugin's onLoaded.
|
|
473
498
|
onLoad(async () => {
|
|
474
499
|
const logger = useLogger()
|
|
500
|
+
|
|
501
|
+
// Onto OPTIONS, not left on config: the url helpers read it, and they
|
|
502
|
+
// run in render workers, which receive the worker-safe options and
|
|
503
|
+
// never see runtime.config.
|
|
504
|
+
runtime.options.siteRoots = runtime.config?.siteRoots ?? []
|
|
505
|
+
if (runtime.options.siteRoots.length) {
|
|
506
|
+
logger.info('Site roots: %s', runtime.options.siteRoots.join(', '))
|
|
507
|
+
}
|
|
508
|
+
|
|
475
509
|
const cli = runtime.options.url
|
|
476
510
|
const cfg = runtime.config?.url
|
|
477
511
|
const raw = cli ?? cfg
|
package/src/instance.js
CHANGED
|
@@ -19,10 +19,12 @@
|
|
|
19
19
|
// new to learn and no watermark to reason about — which matters, because a
|
|
20
20
|
// design that needs discipline from the caller is the one that gets violated.
|
|
21
21
|
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
22
|
+
// There is no opt-out. A flag for "run a second engine here anyway" only ever
|
|
23
|
+
// enabled the accident this file exists to prevent — two engines sharing one
|
|
24
|
+
// catalogue and one output tree with no lock between them — and no caller had
|
|
25
|
+
// a reason to want it that a stopped instance would not serve better. An
|
|
26
|
+
// option whose only use is the wrong one is not an escape hatch, it is a trap
|
|
27
|
+
// with a name.
|
|
26
28
|
|
|
27
29
|
import net from 'node:net'
|
|
28
30
|
import { createHash } from 'node:crypto'
|
|
@@ -280,7 +282,7 @@ function refuseConfig(socket, request, wrongConfig) {
|
|
|
280
282
|
type: 'refused',
|
|
281
283
|
reason: `this instance is running ${wrongConfig}, and you asked for ${path.resolve(request.config)}.`,
|
|
282
284
|
detail: 'Answering would use the wrong config — the accident this refusal exists to prevent. '
|
|
283
|
-
+ 'Stop that instance
|
|
285
|
+
+ 'Stop that instance and run this again.',
|
|
284
286
|
})
|
|
285
287
|
}
|
|
286
288
|
|
|
@@ -341,7 +343,6 @@ async function serveBuild(socket, request, logger) {
|
|
|
341
343
|
export function serveInstance() {
|
|
342
344
|
onLoaded(async () => {
|
|
343
345
|
if (!runtime.options.watch && !runtime.options.server) return
|
|
344
|
-
if (runtime.options.attach === false) return
|
|
345
346
|
const logger = runtime.engine?.logger
|
|
346
347
|
const endpoint = socketPath(runtime.options.workingFolder)
|
|
347
348
|
|
|
@@ -402,53 +403,7 @@ export function serveInstance() {
|
|
|
402
403
|
|
|
403
404
|
// Say so when a private engine is starting in a folder someone else holds.
|
|
404
405
|
//
|
|
405
|
-
// The local counterpart of the rule already written down for deployments —
|
|
406
|
-
// "never run a one-shot mikser command against the deployment" — which existed
|
|
407
|
-
// because there was no alternative. There is one now, so this covers what is
|
|
408
|
-
// left: --no-attach, and the report-only runs that stay local by design.
|
|
409
|
-
//
|
|
410
|
-
// A warning rather than a refusal. --no-attach is how you deliberately check
|
|
411
|
-
// that a cold start works, and refusing it would take away the escape hatch
|
|
412
|
-
// this design depends on having.
|
|
413
|
-
export async function warnIfHeld({ workingFolder, attached }) {
|
|
414
|
-
const endpoint = socketPath(workingFolder)
|
|
415
|
-
if (process.platform !== 'win32' && !existsSync(endpoint)) return false
|
|
416
|
-
|
|
417
|
-
const live = await new Promise((resolve) => {
|
|
418
|
-
const probe = net.connect(endpoint)
|
|
419
|
-
const done = (answer) => { try { probe.destroy() } catch { /* already gone */ } resolve(answer) }
|
|
420
|
-
probe.on('connect', () => done(true))
|
|
421
|
-
probe.on('error', () => done(false))
|
|
422
|
-
setTimeout(() => done(false), 250).unref?.()
|
|
423
|
-
})
|
|
424
|
-
if (!live) return false
|
|
425
|
-
|
|
426
|
-
const logger = runtime.engine?.logger
|
|
427
|
-
const message = attached === false
|
|
428
|
-
? 'Another mikser is already running in this folder, and --no-attach means this one will not talk to '
|
|
429
|
-
+ 'it. Two engines share the catalogue and the output tree with no lock between them; a --clear from '
|
|
430
|
-
+ 'either is what produces a cold rebuild that renders nothing.'
|
|
431
|
-
: 'Another mikser is already running in this folder. This command reads and does not write, so it is '
|
|
432
|
-
+ 'safe — but it may see a catalogue mid-cycle.'
|
|
433
|
-
logger?.warn?.({ code: 'instance-already-running' }, message)
|
|
434
|
-
return true
|
|
435
|
-
}
|
|
436
|
-
|
|
437
406
|
// Registered at setup so both halves are wired from one call.
|
|
438
407
|
export function instanceControl() {
|
|
439
408
|
serveInstance()
|
|
440
|
-
onLoaded(async () => {
|
|
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
|
|
449
|
-
await warnIfHeld({
|
|
450
|
-
workingFolder: runtime.options.workingFolder,
|
|
451
|
-
attached: false,
|
|
452
|
-
})
|
|
453
|
-
})
|
|
454
409
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import path from 'node:path'
|
|
2
2
|
|
|
3
|
-
import { changeExtension } from '../../utils.js'
|
|
3
|
+
import { changeExtension, siteRelativeUrl } from '../../utils.js'
|
|
4
4
|
|
|
5
5
|
// `{{asset 'web' '/media/hero.jpg'}}` — the deployed URL of a preset
|
|
6
6
|
// derivative, relative to the page asking for it.
|
|
@@ -71,8 +71,10 @@ export function load({ runtime, entity, state, options, logger, track }) {
|
|
|
71
71
|
// itself — it takes a path, not an entity, so there is nothing to look
|
|
72
72
|
// up and the URL is well-formed whether or not anything produced it.
|
|
73
73
|
track?.asset?.(destination)
|
|
74
|
-
|
|
75
|
-
|
|
74
|
+
// Resolved within the site this page belongs to, not against the
|
|
75
|
+
// output root — see siteRelativeUrl. With no siteRoots declared the two
|
|
76
|
+
// are the same folder and the url is byte-identical.
|
|
77
|
+
return { url: siteRelativeUrl(entity.destination, destination, options?.siteRoots) }
|
|
76
78
|
}
|
|
77
79
|
}
|
|
78
80
|
|
|
@@ -72,7 +72,7 @@ function warnIfUntrackable(options, resolved, logger) {
|
|
|
72
72
|
//
|
|
73
73
|
// The first version of this hardcoded five content folders, and a project
|
|
74
74
|
// registering its own collections through sources() has more than five. On
|
|
75
|
-
//
|
|
75
|
+
// one real site that meant 63 warnings per build, one for every stylesheet and
|
|
76
76
|
// script, all of them tracked correctly and every one of them saying the
|
|
77
77
|
// opposite. Which is worse than not warning: 63 spurious lines a build
|
|
78
78
|
// teaches you to filter the channel, and the filtered-out line is the real
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import path from 'node:path'
|
|
2
|
+
import { siteRelativeUrl } from '../../utils.js'
|
|
2
3
|
|
|
3
4
|
export function load({ entity, runtime, options }) {
|
|
4
5
|
const { clear } = options
|
|
@@ -19,16 +20,14 @@ export function load({ entity, runtime, options }) {
|
|
|
19
20
|
|
|
20
21
|
let found = runtime.hrefLang(href)
|
|
21
22
|
if (!found) {
|
|
22
|
-
|
|
23
|
-
return { url: path.relative(from, href) }
|
|
23
|
+
return { url: siteRelativeUrl(entity.destination, href, options?.siteRoots) }
|
|
24
24
|
} else {
|
|
25
25
|
if (!found.id) {
|
|
26
26
|
found = found[lang]
|
|
27
27
|
}
|
|
28
28
|
if (found?.destination) {
|
|
29
29
|
const destination = clear ? found.destination.replace('index.html', '') : found.destination
|
|
30
|
-
|
|
31
|
-
found.url = path.relative(from, destination)
|
|
30
|
+
found.url = siteRelativeUrl(entity.destination, destination, options?.siteRoots)
|
|
32
31
|
}
|
|
33
32
|
return found
|
|
34
33
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import path from 'node:path'
|
|
2
|
-
import { matchesLibrary } from '../../utils.js'
|
|
2
|
+
import { matchesLibrary, siteRelativeUrl } from '../../utils.js'
|
|
3
3
|
|
|
4
4
|
export function load({ runtime, entity, state, options, track }) {
|
|
5
5
|
runtime.resource = (url) => {
|
|
@@ -17,8 +17,7 @@ export function load({ runtime, entity, state, options, track }) {
|
|
|
17
17
|
// than resolving one, so a library that was never copied
|
|
18
18
|
// yields a link to nothing on a green build.
|
|
19
19
|
track?.asset?.(destination)
|
|
20
|
-
|
|
21
|
-
return { url: path.relative(from, destination), name }
|
|
20
|
+
return { url: siteRelativeUrl(entity.destination, destination, options?.siteRoots), name }
|
|
22
21
|
}
|
|
23
22
|
}
|
|
24
23
|
}
|
package/src/references.js
CHANGED
|
@@ -25,6 +25,7 @@ import path from 'node:path'
|
|
|
25
25
|
import { existsSync } from 'node:fs'
|
|
26
26
|
import { readFile } from 'node:fs/promises'
|
|
27
27
|
import { globby } from 'globby'
|
|
28
|
+
import { siteRootFor } from './utils.js'
|
|
28
29
|
|
|
29
30
|
// Documents that can carry a reference. Anything else in the output is either
|
|
30
31
|
// an asset itself or something whose internal structure this has no business
|
|
@@ -82,6 +83,8 @@ function decodeEntities(source) {
|
|
|
82
83
|
}
|
|
83
84
|
|
|
84
85
|
// Everything a page points at, as raw url strings.
|
|
86
|
+
export { siteRootFor }
|
|
87
|
+
|
|
85
88
|
export function extractReferences(rawSource) {
|
|
86
89
|
const source = decodeEntities(rawSource)
|
|
87
90
|
const found = new Set()
|
|
@@ -111,30 +114,17 @@ export function resolveUrl(pageDir, url, { root = '' } = {}) {
|
|
|
111
114
|
const segments = clean.split('/').filter(s => s !== '' && s !== '.')
|
|
112
115
|
|
|
113
116
|
const parts = absolute ? [] : pageDir.split('/').filter(Boolean)
|
|
114
|
-
|
|
117
|
+
// How FAR above the root it climbed, not merely that it did. When every
|
|
118
|
+
// over-deep url on a site climbs the same distance, that is one base
|
|
119
|
+
// mismatch reported once — not N findings, which is how a real signal gets
|
|
120
|
+
// filtered.
|
|
121
|
+
let floored = 0
|
|
115
122
|
for (const segment of segments) {
|
|
116
123
|
if (segment !== '..') { parts.push(segment); continue }
|
|
117
124
|
if (parts.length) parts.pop()
|
|
118
|
-
else
|
|
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
|
|
125
|
+
else floored++ // a climb above the root, discarded
|
|
136
126
|
}
|
|
137
|
-
return
|
|
127
|
+
return { target: path.join(root, ...parts), overDeep: floored > 0, floored }
|
|
138
128
|
}
|
|
139
129
|
|
|
140
130
|
// Everything the output points at that is not there.
|
|
@@ -150,7 +140,7 @@ export async function checkReferences(outputFolder, { siteRoots = [] } = {}) {
|
|
|
150
140
|
})
|
|
151
141
|
|
|
152
142
|
const broken = new Map()
|
|
153
|
-
const
|
|
143
|
+
const overDeepRefs = new Map()
|
|
154
144
|
let checked = 0
|
|
155
145
|
// Existence is the expensive part and the same target repeats across a
|
|
156
146
|
// site — one lookup each.
|
|
@@ -166,7 +156,7 @@ export async function checkReferences(outputFolder, { siteRoots = [] } = {}) {
|
|
|
166
156
|
const pageDir = path.dirname(file).slice(root.length).replace(/^\/+/, '')
|
|
167
157
|
|
|
168
158
|
for (const url of extractReferences(source)) {
|
|
169
|
-
const { target, overDeep
|
|
159
|
+
const { target, overDeep, floored } = resolveUrl(pageDir, url, { root })
|
|
170
160
|
checked++
|
|
171
161
|
|
|
172
162
|
if (!exists.has(target)) {
|
|
@@ -174,14 +164,14 @@ export async function checkReferences(outputFolder, { siteRoots = [] } = {}) {
|
|
|
174
164
|
}
|
|
175
165
|
// Broken outranks over-deep: a url that resolves nowhere is the
|
|
176
166
|
// failure, and adding that it is also one level too deep is noise.
|
|
177
|
-
const bucket = !exists.get(target) ? broken : (
|
|
167
|
+
const bucket = !exists.get(target) ? broken : (overDeep ? overDeepRefs : null)
|
|
178
168
|
if (!bucket) continue
|
|
179
169
|
|
|
180
170
|
const key = `${target} ${url}`
|
|
181
|
-
if (!bucket.has(key)) bucket.set(key, { url, target, files: [] })
|
|
171
|
+
if (!bucket.has(key)) bucket.set(key, { url, target, floored, files: [] })
|
|
182
172
|
bucket.get(key).files.push(file)
|
|
183
173
|
}
|
|
184
174
|
}
|
|
185
175
|
|
|
186
|
-
return { broken: [...broken.values()], overDeep: [...
|
|
176
|
+
return { broken: [...broken.values()], overDeep: [...overDeepRefs.values()], checked }
|
|
187
177
|
}
|
package/src/utils.js
CHANGED
|
@@ -1343,3 +1343,50 @@ export function matchesLibrary(value, pattern) {
|
|
|
1343
1343
|
}
|
|
1344
1344
|
return libraryPatterns.get(pattern).test(value)
|
|
1345
1345
|
}
|
|
1346
|
+
|
|
1347
|
+
// Which declared site root a path belongs to.
|
|
1348
|
+
//
|
|
1349
|
+
// A build can emit one subtree per language and deploy each as its own domain
|
|
1350
|
+
// root, which puts the site root at out/<lang>/ rather than at out/. Nothing
|
|
1351
|
+
// can derive that — it is a fact about where the bytes get deployed, not about
|
|
1352
|
+
// the bytes — so it is declared as `siteRoots` and the default is the output
|
|
1353
|
+
// root itself. Accepts a path with or without a leading slash, because an
|
|
1354
|
+
// entity destination has one and an output-relative file path does not.
|
|
1355
|
+
export function siteRootFor(file, roots = []) {
|
|
1356
|
+
const relative = String(file ?? '').replace(/^\/+/, '')
|
|
1357
|
+
let best = ''
|
|
1358
|
+
for (const root of roots) {
|
|
1359
|
+
if (!root) continue
|
|
1360
|
+
if (relative.startsWith(`${root}/`) && root.length > best.length) best = root
|
|
1361
|
+
}
|
|
1362
|
+
return best
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
// A page-relative url from one output destination to another, addressed within
|
|
1366
|
+
// the site the page belongs to.
|
|
1367
|
+
//
|
|
1368
|
+
// Both are output-root absolute (`/bg/aparati/index.html`, `/derived/x.webp`),
|
|
1369
|
+
// which is the only shape the engine has. With one site per build that is also
|
|
1370
|
+
// the deployed root and this is a plain path.relative. With several, it is not:
|
|
1371
|
+
// out/bg IS the domain root, so a url computed against out/ carries one extra
|
|
1372
|
+
// `..` for the language segment. The browser floors that rather than failing,
|
|
1373
|
+
// which is why it worked and why nothing said so.
|
|
1374
|
+
//
|
|
1375
|
+
// Three cases, and the middle one is the reason this is not a one-liner:
|
|
1376
|
+
//
|
|
1377
|
+
// target outside every root a shared asset. It has to be reachable from
|
|
1378
|
+
// inside this page's site, so it is addressed
|
|
1379
|
+
// there — this is the case that was wrong.
|
|
1380
|
+
// target in the same root already correct; a plain relative path.
|
|
1381
|
+
// target in a DIFFERENT root a cross-site link. On a per-domain deploy the
|
|
1382
|
+
// other site is another origin and no relative
|
|
1383
|
+
// path reaches it. Left as it was, so the
|
|
1384
|
+
// reference check reports it broken instead of
|
|
1385
|
+
// this silently inventing a path that is not.
|
|
1386
|
+
export function siteRelativeUrl(pageDestination, target, siteRoots = []) {
|
|
1387
|
+
const from = path.dirname(pageDestination || '/')
|
|
1388
|
+
const pageRoot = siteRootFor(pageDestination, siteRoots)
|
|
1389
|
+
if (!pageRoot) return path.relative(from, target)
|
|
1390
|
+
if (siteRootFor(target, siteRoots)) return path.relative(from, target)
|
|
1391
|
+
return path.relative(from, path.join('/', pageRoot, target))
|
|
1392
|
+
}
|