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 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.attach !== false && where.longRunning) {
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, 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')
68
+ + 'Stop that one first.\n')
72
69
  process.exit(1)
73
70
  }
74
71
  }
75
72
 
76
- if (where.attach !== false && !where.longRunning) {
73
+ if (!where.longRunning) {
77
74
  const code = await forward({
78
75
  workingFolder,
79
76
  config: path.resolve(where.workingFolder, where.config),
@@ -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 becomes lmed.bg, out/en becomes lmed.info, out/mk becomes lmed.mk
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 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.
78
+ Read by the url helpers and by the broken-reference check.
83
79
 
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.
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
- Nothing can infer it: it is a fact about where the bytes get deployed, not
90
- about the bytes.
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
 
@@ -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/hera.md" } }
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
- - **A folder held by someone else.** `--no-attach` runs a private engine
764
- anyway for checking that a cold start works — and says the folder is held.
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. 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).
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.72.0",
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
- 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)
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) 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.')
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
- // `--no-attach` opts out, for when a fresh process IS the point: checking that
23
- // a cold start works, that startup ordering hides nothing. Named for what it
24
- // switches off rather than for a property of the processthe default is to
25
- // attach, and the flag should say which behaviour is being declined.
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 themand 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, or pass --no-attach to run your own.',
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
- const from = path.dirname(entity.destination || '/')
75
- return { url: path.relative(from, destination) }
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
- // lmed that meant 63 warnings per build, one for every stylesheet and
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
- const from = path.dirname(entity.destination || '/')
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
- const from = path.dirname(entity.destination || '/')
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
- const from = path.dirname(entity.destination || '/')
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
- let overDeep = false
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 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
125
+ else floored++ // a climb above the root, discarded
136
126
  }
137
- return best
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 overDeep = new Map()
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: floored } = resolveUrl(pageDir, url, { root })
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 : (floored ? overDeep : null)
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: [...overDeep.values()], checked }
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
+ }