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 +61 -0
- package/bin/resolve-site-config.ts +101 -0
- package/bin/tunnel.ts +103 -16
- package/deploy/Caddyfile +37 -4
- package/deploy/build-index.sh +27 -19
- package/dist/doc-browser.js +34 -16
- package/dist/doc-system/book-html.js +5 -2
- package/dist/doc-system/render.d.ts +2 -0
- package/dist/doc-system/render.js +13 -8
- package/dist/doc-system/routing.d.ts +12 -0
- package/dist/doc-system/routing.js +30 -0
- package/dist/doc-system/site/build-warnings.d.ts +8 -0
- package/dist/doc-system/site/build-warnings.js +27 -0
- package/dist/doc-system/site/dev-auth.d.ts +43 -0
- package/dist/doc-system/site/dev-auth.js +67 -5
- package/dist/doc-system/site/dev-server.js +85 -7
- package/dist/doc-system/site/epub.js +3 -2
- package/dist/doc-system/site/generate-site.js +22 -6
- package/dist/doc-system/site/invite-page.d.ts +7 -1
- package/dist/doc-system/site/invite-page.js +15 -2
- package/dist/doc-system/site/site-config.d.ts +4 -3
- package/dist/iife.js +105 -105
- package/dist/iife.js.map +7 -7
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/llms.txt +1 -1
- package/package.json +1 -1
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
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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🔗 ${
|
|
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
|
-
|
|
423
|
-
|
|
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 *
|
|
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
|
|
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=
|
|
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 *
|
|
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
|
|
package/deploy/build-index.sh
CHANGED
|
@@ -75,9 +75,10 @@ for dir in "$ROOT"/*/; do
|
|
|
75
75
|
</li>"
|
|
76
76
|
done
|
|
77
77
|
|
|
78
|
-
# Tunnelled workspaces: every registered `-tunnel` fragment
|
|
79
|
-
#
|
|
80
|
-
# (see the script below)
|
|
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\"
|
|
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\"
|
|
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">
|
|
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
|
|
166
|
-
//
|
|
167
|
-
//
|
|
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.
|
|
180
|
+
.then((r) => (r.status === 200 ? r.json() : null))
|
|
171
181
|
.then((info) => {
|
|
172
|
-
if (!info
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
package/dist/doc-browser.js
CHANGED
|
@@ -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
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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))
|
|
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'}">
|