tosijs-ui 1.16.27 → 1.16.29

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/CHANGELOG.md CHANGED
@@ -1,5 +1,66 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.16.29
4
+
5
+ ### Doc system
6
+
7
+ - **A wikilink can name a page by its title.** `[[The feature registry]]` now reaches the page
8
+ at `/registry/`. Before, a target was only ever slugified, so it went to
9
+ `/the-feature-registry/` and 404'd. The slug (file name) still wins when a page has one, so
10
+ no link that worked changes target. A title two pages share reaches neither.
11
+ Reported by tosijs-3d-ensemble (#234).
12
+ - **The build reports a wikilink that reaches no doc page**, naming the file and the target,
13
+ and fails under `strict`. One site had nine of these, live 404s from as far back as its 0.3,
14
+ and no build had said a word. **If you build with `strict: true`:** a wikilink to something
15
+ that is not a doc page, such as a hand-written page in your static folder, now fails the
16
+ build even though the link works. Write it as an ordinary `[label](/path/)`.
17
+ - **The test-status widget sits in the header bar.** It was fixed at the bottom left over the
18
+ page, so a full-page component's last list row lost clicks to it (#233). A page with
19
+ `{"header": false}` still floats it there, having nowhere else to put it.
20
+
21
+ ### Tunnel
22
+
23
+ - **The "needs an invite link" page offers the lasting link too.** When the dev server takes
24
+ the preview box's global keys, it now names `tosijs-tunnel --link --global` beside the
25
+ per-visit `--link`. That link keeps the browser in for 30 days across restarts (#232).
26
+
27
+ ## 1.16.28
28
+
29
+ ### Tunnel
30
+
31
+ - **`tosijs-tunnel` prints a single-use link that keeps you signed in.** Once the route is
32
+ registered, it prints one link per global key, such as
33
+ `https://ui.edit.dev.tosijs.net/?t=KFMRPWQT`. The link carries a one-use code, never the key.
34
+ Opening it starts a session in that workspace and gives the browser the box's key as a
35
+ cookie across the preview domain for 30 days. From then on that browser opens every
36
+ workspace holding the key, and the box's root list, without another link, across dev-server
37
+ restarts. The edit link expires after the usual link window (5 minutes,
38
+ `tunnel.linkTtlMinutes`); the view link is read-only and lasts a day (`--share-ttl`).
39
+ `--link --global` and `--link --global --share` print fresh ones. Plain `--link` and
40
+ `--link --share` are unchanged.
41
+ - **It only offers a link that means what it says.** It reads the box's `PREVIEW_DOMAIN` and a
42
+ sha256 of each key over ssh (the keys stay on the box). It prints no link for a key this
43
+ workspace refuses or the box does not share, and says why instead.
44
+ - **The box's root page lists every workspace** and says what your key opens in each: `edit`,
45
+ `view`, or nothing. A workspace that is offline, or whose dev server is on a tosijs-ui before
46
+ 1.16.26, shows as not answering. In 1.16.26 those rows were hidden, so a workspace on an
47
+ older tosijs-ui vanished from the page meant for picking one. Workspaces are now listed above
48
+ the deployed builds.
49
+ - **`?t=<key>` on any preview host takes `&to=<label>`** and sends you on to
50
+ `<label>.<preview domain>`, and the edit key's cookie lasts 30 days there too, as the view
51
+ key's always has.
52
+
53
+ ### Security
54
+
55
+ - **The box's access log recorded every link credential** (affected: 1.9.0 to 1.16.27). Caddy
56
+ logs the request URI, so each `?t=<key>` visit wrote that key, and each link redemption its
57
+ code, to `/var/log/caddy/preview.log`. The template now redacts `t` and `token` there.
58
+ **Re-run `tosijs-caddy-install`, then scrub the old logs** (`/var/log/caddy/preview*.log*`) **or
59
+ rotate your keys.** Caddy already kept cookies out of the log.
60
+ - **The `to` redirect stays under the preview domain.** `to` must be DNS labels (lowercase
61
+ letters, digits, `-`, separated by `.`); anything else, including a path, a second `to` or
62
+ encoded line breaks, falls back to `/`. Checked against Caddy 2.6.2 and 2.11.7.
63
+
3
64
  ## 1.16.27
4
65
 
5
66
  ### Deploy
@@ -34,6 +34,8 @@ export interface PreviewConfig {
34
34
  localPort?: number
35
35
  url?: string
36
36
  requireToken?: boolean
37
+ /** see `tunnel.globalKeys` in site-config.ts */
38
+ globalKeys?: boolean | 'view'
37
39
  }
38
40
  }
39
41
 
@@ -156,6 +158,8 @@ export {
156
158
  server reads the global tunnel keys from the same file; it is re-exported here for the bins.
157
159
  */
158
160
  export { readLocalSecret } from '../dist/doc-system/site/local-secrets.js'
161
+ export { resolveGlobalKeys } from '../dist/doc-system/site/dev-auth.js'
162
+ import type { GlobalKeys } from '../dist/doc-system/site/dev-auth.js'
159
163
  import { readLocalSecret } from '../dist/doc-system/site/local-secrets.js'
160
164
 
161
165
  /**
@@ -443,3 +447,100 @@ export async function refreshHostIndex(host: string): Promise<void> {
443
447
  const out = run.stdout.toString().trim()
444
448
  if (out) console.log(` ${out}`)
445
449
  }
450
+
451
+ /** What the box says about its global keys: its domain, and a fingerprint of each key. */
452
+ export interface BoxKeys {
453
+ domain?: string
454
+ /** sha256 hex of the box's PREVIEW_EDIT_TOKEN / PREVIEW_TOKEN, '' when it has none */
455
+ editSha?: string
456
+ viewSha?: string
457
+ problem?: string
458
+ }
459
+
460
+ /**
461
+ * Read the box's preview domain, and fingerprints of its two keys, in one ssh call.
462
+ *
463
+ * The file is SOURCED, as the installer and Caddy's own install read it, so all three agree on
464
+ * quoting, `export` and comments. The keys never leave the box: only their sha256 does, which is
465
+ * enough to say "this machine's key is not the box's", the one mismatch that makes a link
466
+ * open this workspace and nothing else.
467
+ */
468
+ export async function previewBoxKeys(host: string): Promise<BoxKeys> {
469
+ const script =
470
+ // Checked first: bash as a login shell carries on past a failed `.`, which read as "sets
471
+ // no PREVIEW_DOMAIN" when the file was missing or unreadable.
472
+ '[ -r /etc/caddy/preview.env ] || { echo "/etc/caddy/preview.env is missing or unreadable" >&2; exit 1; }; ' +
473
+ 'set -a; . /etc/caddy/preview.env; set +a; ' +
474
+ 'printf "%s\\n" "${PREVIEW_DOMAIN:-}"; ' +
475
+ 'for k in "${PREVIEW_EDIT_TOKEN:-}" "${PREVIEW_TOKEN:-}"; do ' +
476
+ 'if [ -n "$k" ]; then printf %s "$k" | { sha256sum 2>/dev/null || shasum -a 256; } | cut -d" " -f1; else echo; fi; done'
477
+ // Never prompt or hang: `--link --global` waits on this.
478
+ const read =
479
+ await $`ssh -o BatchMode=yes -o ConnectTimeout=10 ${host} ${script}`
480
+ .nothrow()
481
+ .quiet()
482
+ if (read.exitCode !== 0) {
483
+ return {
484
+ problem: `could not read /etc/caddy/preview.env on ${host}: ${read.stderr
485
+ .toString()
486
+ .trim()
487
+ .slice(0, 160)}`,
488
+ }
489
+ }
490
+ const [domain = '', editSha = '', viewSha = ''] = read.stdout
491
+ .toString()
492
+ .split('\n')
493
+ .map((line) => line.trim())
494
+ if (!/^[a-z0-9.-]+$/i.test(domain)) {
495
+ return {
496
+ problem: `/etc/caddy/preview.env on ${host} sets no PREVIEW_DOMAIN`,
497
+ }
498
+ }
499
+ return { domain, editSha, viewSha }
500
+ }
501
+
502
+ const sha256 = (text: string) =>
503
+ new Bun.CryptoHasher('sha256').update(text).digest('hex')
504
+
505
+ /**
506
+ * Which single-use links `tosijs-tunnel` can honestly offer, and why not the rest.
507
+ *
508
+ * A link hands the browser this machine's key as a cookie across the box's domain, so it is
509
+ * only worth printing when this workspace accepts the key AND the box holds the same one; with a
510
+ * different key on the box, the cookie would open this workspace and not the box's root or any
511
+ * other workspace, which is not what the link says.
512
+ */
513
+ export function planGlobalLinks(
514
+ local: { keys: GlobalKeys; problems: string[] },
515
+ box: BoxKeys
516
+ ): { kinds: ('edit' | 'view')[]; domain?: string; notes: string[] } {
517
+ const notes = [...local.problems]
518
+ const { keys } = local
519
+ const wanted: ('edit' | 'view')[] = []
520
+ if (keys.edit && (keys.editGrants ?? 'edit') === 'edit') wanted.push('edit')
521
+ if (keys.view) wanted.push('view')
522
+ if (wanted.length === 0) {
523
+ if (notes.length === 0)
524
+ notes.push(
525
+ 'no global key on this machine (PREVIEW_EDIT_TOKEN / PREVIEW_TOKEN in ~/local-secrets/tosijs-preview.env), or tunnel.globalKeys turns them off'
526
+ )
527
+ return { kinds: [], notes }
528
+ }
529
+ if (!box.domain) {
530
+ notes.push(box.problem ?? 'the box did not say what its preview domain is')
531
+ return { kinds: [], notes }
532
+ }
533
+ const kinds = wanted.filter((kind) => {
534
+ const mine = kind === 'edit' ? keys.edit! : keys.view!
535
+ const theirs = kind === 'edit' ? box.editSha : box.viewSha
536
+ if (theirs === sha256(mine)) return true
537
+ const name = kind === 'edit' ? 'PREVIEW_EDIT_TOKEN' : 'PREVIEW_TOKEN'
538
+ notes.push(
539
+ theirs
540
+ ? `${name} here is not the box's, so no ${kind} link: put the same value in both files`
541
+ : `the box has no ${name}, so no ${kind} link: add it to /etc/caddy/preview.env and re-run tosijs-caddy-install`
542
+ )
543
+ return false
544
+ })
545
+ return { kinds, domain: box.domain, notes }
546
+ }
package/bin/tunnel.ts CHANGED
@@ -32,9 +32,13 @@ always requires a session.
32
32
 
33
33
  import { $ } from 'bun'
34
34
  import {
35
+ planGlobalLinks,
36
+ previewBoxKeys,
35
37
  previewRootFor,
38
+ readLocalSecret,
36
39
  registerCaddyFragment,
37
40
  refreshHostIndex,
41
+ resolveGlobalKeys,
38
42
  resolveDevPort,
39
43
  resolvePreviewHost,
40
44
  resolveSiteConfig,
@@ -45,7 +49,7 @@ import {
45
49
  const { has, flag } = parseArgv(process.argv.slice(2), {
46
50
  bin: 'tosijs-tunnel',
47
51
  summary: 'expose the local dev server through the preview host',
48
- flags: ['close', 'link', 'status', 'share'],
52
+ flags: ['close', 'link', 'status', 'share', 'global'],
49
53
  values: ['host', 'port', 'local-port', 'remote-port', 'url', 'share-ttl'],
50
54
  usage:
51
55
  ` tosijs-tunnel open the tunnel (foreground)\n` +
@@ -53,6 +57,8 @@ const { has, flag } = parseArgv(process.argv.slice(2), {
53
57
  ` --close close a running tunnel\n` +
54
58
  ` --link print a magic edit link\n` +
55
59
  ` --link --share print a READ-ONLY link to send to a person\n` +
60
+ ` --link --global [--share] a SINGLE-USE link that also gives that browser the\n` +
61
+ ` box's key for 30 days (every workspace, the root list)\n` +
56
62
  ` --share-ttl=<minutes> how long that share link lives (default 1440)\n` +
57
63
  ` --host=user@box override the configured preview host\n` +
58
64
  ` --port= --local-port= --remote-port= override the resolved ports`,
@@ -113,6 +119,60 @@ const publicUrl = flag('url') ?? preview?.tunnel?.url
113
119
  // Set by the registration step below; no public URL means there is no route to fail.
114
120
  let routed = true
115
121
 
122
+ /** Ask the dev server (loopback only) for a link; `query` selects the kind. */
123
+ async function askForLink(
124
+ query: string
125
+ ): Promise<{ url?: string; error?: string }> {
126
+ const res =
127
+ await $`curl -sk --max-time 5 https://localhost:${localPort}/__devlink${query}`
128
+ .nothrow()
129
+ .quiet()
130
+ try {
131
+ return JSON.parse(res.stdout.toString())
132
+ } catch {
133
+ return {}
134
+ }
135
+ }
136
+
137
+ /*
138
+ Single-use links that also give the browser the box's global key (see GlobalGrant in
139
+ dev-auth.ts): one click opens this workspace, and from then on that browser opens every
140
+ workspace holding the key, and the box's root list, for 30 days, across restarts. The link
141
+ carries a one-use code, never the key. Offered only for keys this workspace accepts and the box
142
+ holds too; `notes` says why any other is missing.
143
+ */
144
+ async function globalLinks(
145
+ kinds: ('edit' | 'view')[] = ['edit', 'view'],
146
+ extraQuery = ''
147
+ ): Promise<{
148
+ links: { kind: 'edit' | 'view'; url: string }[]
149
+ notes: string[]
150
+ }> {
151
+ const local = resolveGlobalKeys(preview?.tunnel?.globalKeys, {
152
+ PREVIEW_TOKEN:
153
+ process.env.PREVIEW_TOKEN ?? readLocalSecret('PREVIEW_TOKEN'),
154
+ PREVIEW_EDIT_TOKEN:
155
+ process.env.PREVIEW_EDIT_TOKEN ?? readLocalSecret('PREVIEW_EDIT_TOKEN'),
156
+ })
157
+ const box =
158
+ local.keys.edit || local.keys.view ? await previewBoxKeys(host!) : {}
159
+ const plan = planGlobalLinks(local, box)
160
+ const links: { kind: 'edit' | 'view'; url: string }[] = []
161
+ for (const kind of plan.kinds.filter((k) => kinds.includes(k))) {
162
+ const answer = await askForLink(
163
+ `?global=${kind}&domain=${encodeURIComponent(plan.domain!)}${extraQuery}`
164
+ )
165
+ if (answer.url) links.push({ kind, url: answer.url })
166
+ else
167
+ plan.notes.push(
168
+ answer.error ??
169
+ `the dev server on :${localPort} did not answer, so no ${kind} link: start it (\`bun start\`), then \`tosijs-tunnel --link\``
170
+ )
171
+ }
172
+ return { links, notes: plan.notes }
173
+ }
174
+ let startLinks: Awaited<ReturnType<typeof globalLinks>> | undefined
175
+
116
176
  /*
117
177
  Say where this is going, and where the address came from, BEFORE acting on it.
118
178
 
@@ -176,18 +236,25 @@ if (has('link')) {
176
236
  */
177
237
  const share = has('share')
178
238
  const shareTtl = flag('share-ttl')
179
- const query = share
180
- ? `?share=1${shareTtl ? `&ttl=${encodeURIComponent(shareTtl)}` : ''}`
181
- : ''
182
- const res =
183
- await $`curl -sk --max-time 5 https://localhost:${localPort}/__devlink${query}`
184
- .nothrow()
185
- .quiet()
186
- let url = ''
187
- try {
188
- url = JSON.parse(res.stdout.toString()).url
189
- } catch {
190
- /* fall through to the error below */
239
+ const ttl = shareTtl ? `&ttl=${encodeURIComponent(shareTtl)}` : ''
240
+ /*
241
+ `--global` asks for a link that also hands over the box's key (see globalLinks): single-use,
242
+ and the browser keeps every workspace for 30 days. Opt-in, because it is a different promise
243
+ from the two links above, which are reusable for their window and open this workspace only.
244
+ A share link spent by a chat app's link preview, or one that opens every workspace, would
245
+ break the promise `--share` makes to the person you send it to.
246
+ */
247
+ let url: string
248
+ if (has('global')) {
249
+ const global = await globalLinks([share ? 'view' : 'edit'], ttl)
250
+ for (const note of global.notes) console.log(`note: ${note}`)
251
+ url = global.links[0]?.url ?? ''
252
+ if (!url) {
253
+ console.error('\nNo single-use global link (see the note above).\n')
254
+ process.exit(1)
255
+ }
256
+ } else {
257
+ url = (await askForLink(share ? `?share=1${ttl}` : '')).url ?? ''
191
258
  }
192
259
  if (!url) {
193
260
  console.error(
@@ -210,7 +277,15 @@ if (has('link')) {
210
277
  */
211
278
  const code = new URL(url).searchParams.get('t')
212
279
  console.log(
213
- `\n🔗 ${share ? 'Share link (READ ONLY)' : 'Edit link'}:\n ${url}\n` +
280
+ `\n🔗 ${
281
+ has('global')
282
+ ? `Single-use ${
283
+ share ? 'READ-ONLY ' : ''
284
+ }link (that browser keeps every workspace for 30 days)`
285
+ : share
286
+ ? 'Share link (READ ONLY)'
287
+ : 'Edit link'
288
+ }:\n ${url}\n` +
214
289
  (code ? `\n code: ${code} (case-insensitive)\n` : '')
215
290
  )
216
291
 
@@ -419,8 +494,11 @@ if (publicUrl) {
419
494
  describe: `${hostname} → this workspace`,
420
495
  body: fragment,
421
496
  })
422
- // The root's list of live workspaces is read from these fragments.
423
- if (routed) await refreshHostIndex(host!)
497
+ if (routed) {
498
+ // The root's list of live workspaces is read from these fragments.
499
+ await refreshHostIndex(host!)
500
+ startLinks = await globalLinks()
501
+ }
424
502
  }
425
503
  }
426
504
 
@@ -438,6 +516,15 @@ if (publicUrl) {
438
516
  ? ` ${publicUrl}`
439
517
  : ` ${publicUrl} ⚠️ NOT routed — registration failed above; this URL will not reach here`
440
518
  )
519
+ if (startLinks?.links.length) {
520
+ console.log(
521
+ `\n Single-use links: each opens this workspace, then keeps that browser in every` +
522
+ `\n workspace on the box (and its root list) for 30 days:`
523
+ )
524
+ for (const { kind, url } of startLinks.links)
525
+ console.log(` ${kind}: ${url}${kind === 'view' ? ' (read-only)' : ''}`)
526
+ }
527
+ for (const note of startLinks?.notes ?? []) console.log(` note: ${note}`)
441
528
  }
442
529
  console.log(` Ctrl-C to close.\n`)
443
530
 
package/deploy/Caddyfile CHANGED
@@ -36,6 +36,15 @@
36
36
  # `root` is the only thing that varies, keeping it in the caller costs one line per
37
37
  # project and makes this file work on any Caddy 2.x.
38
38
  (preview_site) {
39
+ # Where a key link lands: `&to=<label>` sends it to `<label>.{{PREVIEW_DOMAIN}}` (so
40
+ # `tosijs-tunnel` can hand out ONE link that sets the cookie here and opens its workspace),
41
+ # anything else to this host's root. Only DNS labels match, and the target is always under
42
+ # the preview domain, so this cannot redirect off-site.
43
+ map {query.to} {tosi_link_target} {
44
+ ~^([a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*)$ "https://${1}.{{PREVIEW_DOMAIN}}/"
45
+ default "/"
46
+ }
47
+
39
48
  # THE PREVIEW HOST IS PRIVATE — but gated by a LINK, not a password dialog.
40
49
  #
41
50
  # basicauth used to guard these hosts, and it worked, but it makes the browser throw
@@ -63,11 +72,13 @@
63
72
  header Set-Cookie "tosi_preview=__PREVIEW_TOKEN__; Domain=.{{PREVIEW_DOMAIN}}; Path=/; Max-Age=2592000; HttpOnly; Secure; SameSite=Lax"
64
73
  header Referrer-Policy "no-referrer"
65
74
  header Cache-Control "no-store"
66
- redir * / 302
75
+ redir * {tosi_link_target} 302
67
76
  }
68
77
 
69
78
  # THE GLOBAL EDIT KEY: `?t=<edit key>` on any preview host sets `tosi_edit` across the
70
- # whole preview domain for 24 hours. Every tunnelled workspace that holds the same key
79
+ # whole preview domain for 30 days, the same cookie a workspace's single-use link from
80
+ # `tosijs-tunnel` sets (that link is the usual way in: it never puts the key in a URL).
81
+ # Every tunnelled workspace that holds the same key
71
82
  # (PREVIEW_EDIT_TOKEN in ~/local-secrets/tosijs-preview.env on the machine running it)
72
83
  # trades it for an editing session on the first page load, so the root's list opens them
73
84
  # all, across dev-server restarts. It also opens these static previews. The view key above
@@ -78,10 +89,10 @@
78
89
  # rotated (edit preview.env here and on each machine, re-run the installer).
79
90
  @editlink query t=__PREVIEW_EDIT_TOKEN__
80
91
  handle @editlink {
81
- header Set-Cookie "tosi_edit=__PREVIEW_EDIT_TOKEN__; Domain=.{{PREVIEW_DOMAIN}}; Path=/; Max-Age=86400; HttpOnly; Secure; SameSite=Lax"
92
+ header Set-Cookie "tosi_edit=__PREVIEW_EDIT_TOKEN__; Domain=.{{PREVIEW_DOMAIN}}; Path=/; Max-Age=2592000; HttpOnly; Secure; SameSite=Lax"
82
93
  header Referrer-Policy "no-referrer"
83
94
  header Cache-Control "no-store"
84
- redir * / 302
95
+ redir * {tosi_link_target} 302
85
96
  }
86
97
 
87
98
  # Anchored at both ends: unanchored, `tosi_edit=<key>x` matched too. That needed the key
@@ -151,6 +162,17 @@
151
162
  roll_size 10MiB
152
163
  roll_keep 3
153
164
  }
165
+ # Links carry credentials in the query: `t` (a global key, or a single-use code) and
166
+ # `token`. Caddy already keeps cookies out of this log; this keeps those out too.
167
+ format filter {
168
+ wrap json
169
+ fields {
170
+ request>uri query {
171
+ replace t REDACTED
172
+ replace token REDACTED
173
+ }
174
+ }
175
+ }
154
176
  }
155
177
  }
156
178
 
@@ -198,6 +220,17 @@
198
220
  roll_size 10MiB
199
221
  roll_keep 3
200
222
  }
223
+ # Links carry credentials in the query: `t` (a global key, or a single-use code) and
224
+ # `token`. Caddy already keeps cookies out of this log; this keeps those out too.
225
+ format filter {
226
+ wrap json
227
+ fields {
228
+ request>uri query {
229
+ replace t REDACTED
230
+ replace token REDACTED
231
+ }
232
+ }
233
+ }
201
234
  }
202
235
  }
203
236
 
@@ -75,9 +75,10 @@ for dir in "$ROOT"/*/; do
75
75
  </li>"
76
76
  done
77
77
 
78
- # Tunnelled workspaces: every registered `-tunnel` fragment. Whether one is UP, and what
79
- # the reader may do there, changes by the minute, so the page asks each one when it loads
80
- # (see the script below); this only lists where to ask.
78
+ # Tunnelled workspaces: every registered `-tunnel` fragment, ALWAYS listed, so this page is
79
+ # where you pick one. Whether it is up, and what the reader may do there, changes by the
80
+ # minute, so the page asks each one when it loads (see the script below). Hiding the rows
81
+ # that did not answer made a workspace on an older tosijs-ui simply vanish.
81
82
  live=""
82
83
  for frag in "$ROOT"/_sites/*-tunnel.caddy; do
83
84
  [ -f "$frag" ] || continue
@@ -85,10 +86,10 @@ for frag in "$ROOT"/_sites/*-tunnel.caddy; do
85
86
  host=$(sed -n 's/^\([A-Za-z0-9.-]*\) *{.*/\1/p' "$frag" | head -1)
86
87
  [ -n "$host" ] || continue
87
88
  live="$live
88
- <li data-workspace=\"$host\" hidden>
89
+ <li data-workspace=\"$host\">
89
90
  <a href=\"https://$host/\">
90
91
  <span class=\"name\">$name</span>
91
- <span class=\"meta\"><span class=\"access\"></span><code>$host</code></span>
92
+ <span class=\"meta\"><span class=\"access\">checking…</span><code>$host</code></span>
92
93
  </a>
93
94
  </li>"
94
95
  done
@@ -133,16 +134,18 @@ cat > "$OUT/index.html" <<HTML
133
134
  h2 { font-size:1rem; margin:2rem 0 .75rem; font-weight:600; }
134
135
  .access { font-weight:600; }
135
136
  .access.edit { color:#2a8a3e; }
137
+ .access.none { font-weight:400; }
136
138
  footer { color:var(--dim); font-size:.8rem; margin-top:2.5rem; }
137
139
  </style>
138
140
  <main>
139
141
  <header>$ICON<h1>tosijs previews</h1></header>
140
- <p class="sub">Deployed builds. Each shows the commit it was built from.</p>
142
+ <p class="sub">Live workspaces, and the builds deployed here.</p>
143
+ $(if [ -n "$live" ]; then echo "<section id=\"live\"><h2>Live workspaces</h2><ul>$live
144
+ </ul></section>"; fi)
145
+ <h2>Deployed builds</h2>
141
146
  $(if [ "$count" -eq 0 ]; then echo '<p class="empty">Nothing deployed yet.</p>'; else echo "<ul>$rows
142
147
  </ul>"; fi)
143
148
  $(if [ -n "$unlinked" ]; then echo "<p class=\"empty\">Deployed but not routed:$unlinked</p>"; fi)
144
- $(if [ -n "$live" ]; then echo "<section id=\"live\" hidden><h2>Live workspaces</h2><ul>$live
145
- </ul></section>"; fi)
146
149
  <footer>Previews only — not production. Each entry updates when that project deploys (<code>bun run deploy --go</code>), not when it builds.</footer>
147
150
  </main>
148
151
  <script>
@@ -162,21 +165,26 @@ cat > "$OUT/index.html" <<HTML
162
165
  }
163
166
 
164
167
  // Ask each tunnelled workspace whether it is up and what this reader may do there. Only
165
- // this origin may read the answer, and it reflects the key cookie this page set; a
166
- // workspace that is down (or older than this probe) shows nothing. Opening one trades the
167
- // key for a session there, so these are plain links.
168
+ // this origin may read the answer, and it reflects the key cookie this page set. Opening
169
+ // one trades the key for a session there, so these are plain links. A workspace that is
170
+ // down and one whose dev server predates this probe (tosijs-ui 1.16.26) look the same
171
+ // from here: neither gives an answer this page may read.
172
+ const silent = 'not answering (offline, or tosijs-ui before 1.16.26)'
173
+ const say = (li, text, kind) => {
174
+ const label = li.querySelector('.access')
175
+ label.textContent = text
176
+ label.className = 'access ' + kind
177
+ }
168
178
  for (const li of document.querySelectorAll('[data-workspace]')) {
169
179
  fetch('https://' + li.dataset.workspace + '/__alive', { credentials: 'include' })
170
- .then((r) => (r.ok && r.status === 200 ? r.json() : null))
180
+ .then((r) => (r.status === 200 ? r.json() : null))
171
181
  .then((info) => {
172
- if (!info || !info.access) return
173
- const label = li.querySelector('.access')
174
- label.textContent = info.access === 'edit' ? 'edit' : 'view'
175
- label.classList.add(info.access)
176
- li.hidden = false
177
- document.getElementById('live').hidden = false
182
+ if (!info) say(li, silent, 'none')
183
+ else if (info.access === 'edit') say(li, 'edit', 'edit')
184
+ else if (info.access === 'view') say(li, 'view', 'view')
185
+ else say(li, 'up, but your key does not open it', 'none')
178
186
  })
179
- .catch(() => {})
187
+ .catch(() => say(li, silent, 'none'))
180
188
  }
181
189
  </script>
182
190
  HTML
@@ -95,7 +95,7 @@ history, for a browser embedded in another page: drive it with `initialRoute`,
95
95
  */
96
96
  /*{ "parent": "Documentation System", "order": 50 }*/
97
97
  import { elements, vars, varDefault, bindings, touch, getListItem, debounce, tosi, StyleSheet, } from 'tosijs';
98
- import { buildSlugMap, pathForSlug, filenameForPath, homeDoc, } from './doc-system/routing.js';
98
+ import { buildSlugMap, pathForSlug, filenameForPath, homeDoc, wikilinkTargets, } from './doc-system/routing.js';
99
99
  import { buildNavTree } from './doc-system/nav-tree.js';
100
100
  import { renderDocMarkdown } from './doc-system/render.js';
101
101
  import { pageTitle } from './doc-system/doc-title.js';
@@ -202,21 +202,28 @@ const testIndicatorStyleSpec = {
202
202
  background: testColor.fail,
203
203
  animation: 'test-pulse 2s ease-in-out infinite',
204
204
  },
205
- // Floating widget - position and colors only, inherits button structure.
206
- // Bottom-LEFT: bottom-right is where the injected haltija dev widget sits, and it
207
- // was overlaying this.
205
+ /*
206
+ The widget sits in the header bar, not over the page (GitHub #233).
207
+
208
+ It used to be `position: fixed` at the bottom left, on top of whatever the page drew there:
209
+ a full-page component's list ran under it and a click on its last row landed on the widget
210
+ instead. Only a page with no header (`{"header": false}`) still floats it, because there is
211
+ nowhere else to put it. Bottom-LEFT, because the haltija dev widget takes bottom-right.
212
+ */
208
213
  '.test-widget': {
209
214
  _testBg: testColor.running,
210
215
  _testFg: testTextColor.running,
211
- position: 'fixed',
212
- bottom: vars.spacing,
213
- left: vars.spacing,
214
- zIndex: '1000',
215
216
  background: vars.testBg,
216
217
  // Paired with the background, not fixed to white — see testTextColor above.
217
218
  color: vars.testFg,
218
219
  gap: vars.spacing50,
219
220
  },
221
+ '.test-widget.-floating': {
222
+ position: 'fixed',
223
+ bottom: vars.spacing,
224
+ left: vars.spacing,
225
+ zIndex: '1000',
226
+ },
220
227
  '.test-widget[hidden]': { display: 'none' },
221
228
  '.test-widget.-running': {
222
229
  _testBg: testColor.running,
@@ -368,6 +375,7 @@ export function createDocBrowser(options) {
368
375
  // while letting <tosi-doc-system> drive clean `/slug/` URLs off the same docs.
369
376
  // Both path and memory routing key off slugs; only legacy query routing doesn't.
370
377
  const slugMap = routing === 'query' ? {} : buildSlugMap(docs);
378
+ const wikilinks = wikilinkTargets(docs);
371
379
  const slugFor = (filename) => slugMap[filename] ?? filename;
372
380
  const filenameForSlug = (slug) => {
373
381
  for (const d of docs)
@@ -974,6 +982,7 @@ export function createDocBrowser(options) {
974
982
  // without loading the tjs transpiler. See self-contained-examples-plan.md.
975
983
  docContent.innerHTML = renderDocMarkdown(doc.text, {
976
984
  bakes: doc.bakes ? new Map(doc.bakes) : undefined,
985
+ wikilinks,
977
986
  });
978
987
  }
979
988
  /*
@@ -1029,6 +1038,7 @@ export function createDocBrowser(options) {
1029
1038
  // to follow the doc, so this does not ride on that path succeeding.
1030
1039
  refreshNavToggle();
1031
1040
  }
1041
+ placeTestWidget(doc);
1032
1042
  rewriteContentLinks();
1033
1043
  // Stamp each example with its source file (for the source↔doc map). doc.path
1034
1044
  // is the extracted file (.md, or a source file with doc comments).
@@ -1262,7 +1272,7 @@ export function createDocBrowser(options) {
1262
1272
  return;
1263
1273
  editUI.view = view;
1264
1274
  if (view === 'preview') {
1265
- docContent.innerHTML = renderDocMarkdown(docMarkdownFromSource(editUI.editor.value, editUI.doc.path));
1275
+ docContent.innerHTML = renderDocMarkdown(docMarkdownFromSource(editUI.editor.value, editUI.doc.path), { wikilinks });
1266
1276
  rewriteContentLinks();
1267
1277
  LiveExample.insertExamples(docContent, context, editUI.doc.path || undefined);
1268
1278
  }
@@ -1615,6 +1625,21 @@ export function createDocBrowser(options) {
1615
1625
  height: '100%',
1616
1626
  },
1617
1627
  }, docContent)));
1628
+ // Test status widget
1629
+ const testWidget = button({
1630
+ class: 'test-widget',
1631
+ hidden: true,
1632
+ onClick: showTestMenu,
1633
+ }, span({ part: 'label' }, 'Tests'), span({ class: 'count', part: 'count' }, '0'));
1634
+ const headerBar = container.querySelector('header');
1635
+ // In the header unless this doc hides the header; see `.test-widget` in the styles.
1636
+ const placeTestWidget = (doc) => {
1637
+ const floating = doc.header === false;
1638
+ testWidget.classList.toggle('-floating', floating);
1639
+ const home = floating ? container : headerBar;
1640
+ if (testWidget.parentElement !== home)
1641
+ home.appendChild(testWidget);
1642
+ };
1618
1643
  // Render the landing doc (adopts pre-rendered HTML when hydrating).
1619
1644
  showDoc(currentDoc);
1620
1645
  // Inject test indicator styles
@@ -1622,13 +1647,6 @@ export function createDocBrowser(options) {
1622
1647
  // Header brand-mark sizing (icon / <img> / inline-<svg>), one CSS var each.
1623
1648
  StyleSheet('doc-logo-mark', logoMarkStyleSpec);
1624
1649
  StyleSheet('doc-nav-search', navSearchStyleSpec);
1625
- // Floating widget for test status
1626
- const testWidget = button({
1627
- class: 'test-widget',
1628
- hidden: true,
1629
- onClick: showTestMenu,
1630
- }, span({ part: 'label' }, 'Tests'), span({ class: 'count', part: 'count' }, '0'));
1631
- container.appendChild(testWidget);
1632
1650
  /*
1633
1651
  The far end has something to tell you.
1634
1652
 
@@ -7,7 +7,7 @@ Node-only APIs — so it bundles into the iife. Live examples are pretty-printed
7
7
  force-wrapped code listings, not executed.
8
8
  */
9
9
  import { renderDocMarkdown } from './render.js';
10
- import { buildSlugMap } from './routing.js';
10
+ import { buildSlugMap, wikilinkTargets } from './routing.js';
11
11
  import { buildNavTree } from './nav-tree.js';
12
12
  import { bookTextOf } from './book-target.js';
13
13
  // Clean book typography; force-wraps code so listings never overflow a page.
@@ -149,10 +149,13 @@ function tocHtml(nodes) {
149
149
  export function buildBookHtml(docs, opts) {
150
150
  const visible = docs.filter((d) => !d.hidden);
151
151
  const slugMap = buildSlugMap(visible);
152
+ const wikilinks = wikilinkTargets(visible);
152
153
  const roots = buildNavTree(visible, slugMap);
153
154
  const css = opts.css ?? `${DEFAULT_BOOK_CSS}\n${PRINT_CSS}`;
154
155
  const chapters = flatten(roots)
155
- .map((node) => `<section class="chapter" id="${slugify(node.doc.filename)}">\n${renderDocMarkdown(stripDocMeta(bookTextOf(node.doc)))}\n</section>`)
156
+ .map((node) => `<section class="chapter" id="${slugify(node.doc.filename)}">\n${renderDocMarkdown(stripDocMeta(bookTextOf(node.doc)), {
157
+ wikilinks,
158
+ })}\n</section>`)
156
159
  .join('\n');
157
160
  return `<!DOCTYPE html>
158
161
  <html lang="${opts.lang ?? 'en'}">