tosijs-ui 1.9.0-beta.2 → 1.9.0-rc.2
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 +168 -0
- package/README.md +3 -38
- package/bin/deploy-preview.ts +247 -0
- package/bin/docs.ts +1 -0
- package/bin/make-icon-data.js +20 -4
- package/bin/resolve-site-config.ts +126 -0
- package/bin/tunnel.ts +305 -0
- package/deploy/Caddyfile +204 -0
- package/deploy/build-index.sh +133 -0
- package/dist/doc-browser.js +56 -8
- package/dist/doc-system/site/dev-auth.d.ts +13 -0
- package/dist/doc-system/site/dev-auth.js +64 -0
- package/dist/doc-system/site/dev-server.js +323 -214
- package/dist/doc-system/site/orchestrator.js +534 -434
- package/dist/doc-system/site/site-config.d.ts +33 -7
- package/dist/icon-svg.d.ts +3 -0
- package/dist/icon-svg.js +33 -0
- package/dist/icons.d.ts +1 -0
- package/dist/icons.js +2 -0
- package/dist/iife.js +77 -71
- package/dist/iife.js.map +8 -7
- package/dist/live-example/component.js +40 -6
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/llms.txt +3 -1
- package/package.json +15 -4
- package/dist/hydrate/ajs-language-xvpytqfm.js +0 -11
- package/dist/hydrate/ajs-language-xvpytqfm.js.map +0 -10
- package/dist/hydrate/code-editor-cm-59je4d8g.js +0 -10
- package/dist/hydrate/code-editor-cm-59je4d8g.js.map +0 -20
- package/dist/hydrate/hydrate.js +0 -215
- package/dist/hydrate/hydrate.js.map +0 -77
- package/dist/hydrate/index-iife-2q1gndgz.js +0 -27
- package/dist/hydrate/index-iife-2q1gndgz.js.map +0 -22
- package/dist/hydrate/index-iife-415dypjp.js +0 -5
- package/dist/hydrate/index-iife-415dypjp.js.map +0 -9
- package/dist/hydrate/index-iife-xv69gy4m.js +0 -14
- package/dist/hydrate/index-iife-xv69gy4m.js.map +0 -12
- package/dist/hydrate/index-vk8ek4jm.js +0 -6
- package/dist/hydrate/index-vk8ek4jm.js.map +0 -10
- package/dist/hydrate/tjs-import-resolver-s4z0w296.js +0 -4
- package/dist/hydrate/tjs-import-resolver-s4z0w296.js.map +0 -10
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,173 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.9.0-rc.2
|
|
4
|
+
|
|
5
|
+
Fixes everything both consumers hit within minutes of `rc.1`. Three were regressions
|
|
6
|
+
introduced by rc.1 itself.
|
|
7
|
+
|
|
8
|
+
- **The `tosijs-tunnel` / `tosijs-deploy` bins had no shebang** (#35, #36 — reported
|
|
9
|
+
independently by both consumers), so `node_modules/.bin` shims handed them to the
|
|
10
|
+
shell, which ran TypeScript as a shell script. Added to every shipped bin.
|
|
11
|
+
- **`/version.json`'s `generator` reported the CONSUMER's version** (#37). rc.1 replaced
|
|
12
|
+
an import of the generated `src/version.ts` (which caused a `bun --watch` rebuild loop)
|
|
13
|
+
with a read of `package.json` — but that resolves against the **cwd**, i.e. the
|
|
14
|
+
adopter's repo. So the one field you consult to answer "which tosijs-ui built this?"
|
|
15
|
+
answered with the wrong package entirely. Now resolved relative to the module.
|
|
16
|
+
- **Two dev servers could not coexist.** The tunnel listener bound a fixed port
|
|
17
|
+
regardless of `PORT`, so a second server — or this repo's own Playwright lane beside a
|
|
18
|
+
`bun start` — died with `EADDRINUSE`. It now defaults to `PORT + 1`, and a busy tunnel
|
|
19
|
+
port warns instead of taking the whole server down: it is an optional extra, and
|
|
20
|
+
refusing to start over it turns "another instance is running" into "my dev server is
|
|
21
|
+
broken".
|
|
22
|
+
- **New lane: `bun run test-consumer`** — packs the tarball, installs it into a scratch
|
|
23
|
+
project, runs every bin through the `node_modules/.bin` shims, and builds a site from
|
|
24
|
+
that project's cwd. Every other lane runs *in this repo, from this repo, with one dev
|
|
25
|
+
server*, and all four rc.1-era regressions lived outside that envelope. It found a real
|
|
26
|
+
bug on its first run.
|
|
27
|
+
- **A failed "save to source" now says why** (#34). The endpoint answers 501 with an
|
|
28
|
+
actionable sentence ("editableSources is not enabled…") and the client threw it away
|
|
29
|
+
for a generic "Save failed.", so an unconfigured server was indistinguishable from a
|
|
30
|
+
broken one and the edit appeared to vanish. The server's reason is surfaced, and every
|
|
31
|
+
failure path now states that the edit is still in the editor.
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
## 1.9.0-rc.1
|
|
35
|
+
|
|
36
|
+
Release candidate. Everything the nine-lens review and the first adopters raised is
|
|
37
|
+
addressed; no features are pending. Install with `tosijs-ui@rc`.
|
|
38
|
+
|
|
39
|
+
**Edit and view are now separate hostnames**, which makes the security posture legible
|
|
40
|
+
without reading config:
|
|
41
|
+
|
|
42
|
+
| host | what it is | gate |
|
|
43
|
+
| --- | --- | --- |
|
|
44
|
+
| `<project>.dev.example.com` | read-only static preview — shareable | invite cookie |
|
|
45
|
+
| `<project>.edit.dev.example.com` | live editable workspace — yours | session, always |
|
|
46
|
+
|
|
47
|
+
- **`tunnel.requireToken` now defaults to `true`.** The old default (read-open) was
|
|
48
|
+
justified by "an expired link should degrade to a readable page when you open a second
|
|
49
|
+
window" — but a second window **shares the session cookie**, so a holder is never
|
|
50
|
+
walled. The wall only appears for someone genuinely unauthenticated, which is the right
|
|
51
|
+
answer for a workspace mirroring an uncommitted tree. Note the hostname is not a
|
|
52
|
+
secret: Let's Encrypt publishes every certificate to public CT logs. Set `false`
|
|
53
|
+
deliberately if you want a live read-only audience.
|
|
54
|
+
- **An edit host with no tunnel behind it explains itself** — a 503 saying the workspace
|
|
55
|
+
is offline, with a link to the static preview, instead of a bare 502. It deliberately
|
|
56
|
+
does *not* fall back to serving the snapshot: you would think you were looking at live
|
|
57
|
+
work when you were not.
|
|
58
|
+
- **A spent invite link says so.** The likely culprit is a chat-app link-preview bot,
|
|
59
|
+
whose GET *is* the first use; previously you got a silent redirect and discovered it
|
|
60
|
+
when a save failed.
|
|
61
|
+
|
|
62
|
+
**Security fixes** (all found by review or adopters, none reported in the wild):
|
|
63
|
+
|
|
64
|
+
- `POST /report` was unauthenticated and reachable through the tunnel — a stranger could
|
|
65
|
+
fabricate `{passed:N, failed:0}` and make the test lane **exit green on a suite that
|
|
66
|
+
never ran**. Now local-only.
|
|
67
|
+
- Build-error text (absolute paths from your machine) was injected into every served page
|
|
68
|
+
with no auth check. The label stays public; the detail needs a session.
|
|
69
|
+
- `rsync --delete` accepted any absolute path two segments deep — including `/usr/lib`
|
|
70
|
+
and `/etc/caddy`, which it would have *mirrored*, i.e. emptied. Now an allowlist of
|
|
71
|
+
preview roots, extracted and tested.
|
|
72
|
+
|
|
73
|
+
**Adopter-reported fixes** (all from tosijs-3d, all consumer-facing):
|
|
74
|
+
|
|
75
|
+
- **`tosijs-make-icons` stripped `fill-rule`** (#30), so a compound path using `evenodd`
|
|
76
|
+
for holes — a keyboard drawn as one path — rendered the holes solid. `fill-rule` is
|
|
77
|
+
fill *topology*, not colour, and takes no part in the tinting the strip exists for.
|
|
78
|
+
It turned out to be stripped in **three** places, not one; the `stroked/` branch is the
|
|
79
|
+
one that bit. This ships as a bin, so it affects anyone generating their own icon data.
|
|
80
|
+
- **The doc-site hydrate bundle was written into the library `dist/`** (#31) and shipped
|
|
81
|
+
to every consumer, though nothing references it — one adopter's package went from
|
|
82
|
+
0.62 MB / 398 files to **10.2 MB / 2888 files**, caught only by reading `npm pack`
|
|
83
|
+
output. It now builds in a temp dir; tosijs-ui's own package drops 16 files and 5.2 MB
|
|
84
|
+
of uncompressed payload.
|
|
85
|
+
- **New: `iconSvg(name)` and `iconNames()`** (#33), from `tosijs-ui/icon-svg` — raw SVG
|
|
86
|
+
markup with **no DOM required**. `defineIcons()` could write to the icon map but
|
|
87
|
+
nothing could read it, and `icons.foo()` returns an `SVGElement`, so a build script,
|
|
88
|
+
ePub pass or server-rendered template had no way to get markup except parsing the
|
|
89
|
+
package's source. Deliberately a separate module: `icons.ts` imports tosijs and throws
|
|
90
|
+
on `HTMLElement` outside a browser, which is the exact context that wanted it.
|
|
91
|
+
|
|
92
|
+
**Correctness:**
|
|
93
|
+
|
|
94
|
+
- **A 403 could destroy uncommitted work.** An unauthorized read fell through to GitHub
|
|
95
|
+
`main`, so you edited the *published* file, and the save then handed you a download of
|
|
96
|
+
it — applying which silently reverts your working copy. 401/403 is now its own case in
|
|
97
|
+
both editors.
|
|
98
|
+
- **The last-good build protection moved to where the wipe is.** It lived in the dev
|
|
99
|
+
server's watch branch, so `bun run build`, CI, adopters, and `bun start`'s own initial
|
|
100
|
+
build had none of it. Now in `buildSite`, in a `finally` covering both a throw and a
|
|
101
|
+
`return false`, with exit codes checked (the old restore could fail while still logging
|
|
102
|
+
that it had succeeded).
|
|
103
|
+
- "Build failed" is no longer overwritten by test results seconds later.
|
|
104
|
+
- `?t=` is only intercepted when a tunnel is configured, and only on GET — `t` is the
|
|
105
|
+
classic cache-buster name, and a 302'd POST loses its body.
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
## 1.9.0-beta.3
|
|
109
|
+
|
|
110
|
+
Fixes three blockers found by the nine-lens review of beta.2, all in the remote-editing
|
|
111
|
+
feature, plus a rebuild loop the review did not catch because nothing automated runs
|
|
112
|
+
`bun start`.
|
|
113
|
+
|
|
114
|
+
- **`bun run tunnel --link` never worked.** It ran `pgrep -f 'bun bin/dev.ts'`, which does
|
|
115
|
+
not match the documented start command (`bun --watch bin/dev.ts`) — so the headline flow
|
|
116
|
+
was unreachable. Worse, it *did* match `bun bin/dev.ts --build-only`, whose process exits
|
|
117
|
+
before the signal handler is registered, so the default SIGUSR2 disposition **killed
|
|
118
|
+
in-flight builds**. Now it asks the dev server over a loopback-only `/__devlink` endpoint:
|
|
119
|
+
no process guessing, no signals, and it cannot touch a sibling project. (`/__devlink` is
|
|
120
|
+
refused over the tunnel — a read-only visitor minting a write session would be privilege
|
|
121
|
+
escalation.)
|
|
122
|
+
- **Write authorization failed OPEN.** It inferred "this is local" from the *absence* of
|
|
123
|
+
`X-Forwarded-*`, so any forwarder that doesn't set them — `ssh -R` with `GatewayPorts yes`,
|
|
124
|
+
`ngrok tcp`, `socat`, iptables DNAT, nginx without `forwardfor` — delivered an off-machine
|
|
125
|
+
request as `{peer: 127.0.0.1, no headers}` and got an **arbitrary repo write**, which the
|
|
126
|
+
watcher rebuilds and runs. The dev server now binds a **separate loopback-only port** for
|
|
127
|
+
tunnel traffic; arriving there always requires a session. Which socket you connected to is
|
|
128
|
+
not something a client can forge. `bun run tunnel` also verifies the remote port is bound
|
|
129
|
+
to loopback and says so loudly if it isn't.
|
|
130
|
+
- **The decision guarding that RCE had no tests**, while its own comment claimed a
|
|
131
|
+
regression test that did not exist. It is now `mayWriteSource()` — pure, exported, and
|
|
132
|
+
covered including a named regression case for the fail-open above.
|
|
133
|
+
- **The remote-access tooling now ships** as `tosijs-tunnel` and `tosijs-deploy` bins
|
|
134
|
+
(tosijs-ui#27, and independently the review's ecosystem lens). Previously `SiteConfig`
|
|
135
|
+
exposed `preview`/`tunnel` as typed public API while the ~390 lines of
|
|
136
|
+
security-sensitive glue that implement them lived only in this repo — so every adopter
|
|
137
|
+
hand-copied them and owned the drift. They resolve the consumer's site config
|
|
138
|
+
(`--config=`, `TOSIJS_SITE_CONFIG`, then `./tosijs-site.config.ts` / `./site.config.ts`)
|
|
139
|
+
and deliberately do **not** walk up the tree: these commands deploy and expose a
|
|
140
|
+
workspace, so silently adopting a neighbour's config would publish the wrong project.
|
|
141
|
+
- **Fix: the tunnel probed the wrong port**, so forwarding to a dead listener failed
|
|
142
|
+
silently with a 502 and a healthy-looking dev server (tosijs-ui#28). `ssh -R` can't
|
|
143
|
+
catch it — the *remote* bind succeeds, so `ExitOnForwardFailure` stays quiet; it's the
|
|
144
|
+
local end that connects to nothing. It now probes the port it actually forwards to and
|
|
145
|
+
**refuses to open**, naming the likely cause (including "your tosijs-ui predates the
|
|
146
|
+
tunnel listener").
|
|
147
|
+
- **Tunnelled workspaces self-register too** (tosijs-ui#29). Static previews have
|
|
148
|
+
self-registered since beta.1, but a tunnelled host still needed a block hand-added to
|
|
149
|
+
the *shared* Caddyfile — so the one remaining manual edit was the **riskier** one: a
|
|
150
|
+
typo there breaks routing for every project on the box, which is exactly what the
|
|
151
|
+
fragment design set out to abolish. `tosijs-tunnel` now writes its own fragment with
|
|
152
|
+
the same validate-before-reload discipline, and the shared file names no hostnames at
|
|
153
|
+
all.
|
|
154
|
+
- **`tunnel.remotePort` is allocated per project** instead of defaulting to 9787 for
|
|
155
|
+
everyone (tosijs-ui#29). Two projects taking the default collided, quietly and in the
|
|
156
|
+
direction that matters. The default is now derived from the project name — stable, and
|
|
157
|
+
nobody has to allocate it — while an explicit `remotePort` still wins.
|
|
158
|
+
- **Fix: `chokidar` is no longer a hard runtime dependency of the build** (tosijs-ui#32).
|
|
159
|
+
`dev-server.js` had a top-level `import { watch } from 'chokidar'`, and a consumer's
|
|
160
|
+
`bin/site.ts` loads that module even in `--build` mode — so adopters hit
|
|
161
|
+
`Cannot find package 'chokidar'` on a plain build. It is now imported lazily inside the
|
|
162
|
+
watcher path and declared an **optional peer**, so building needs nothing extra and the
|
|
163
|
+
watching dev server explains itself if it is missing.
|
|
164
|
+
- **Fix: `bun start` was in a rebuild loop** (measured at 899 restarts in ~40s), which made
|
|
165
|
+
the documented dev command unusable. The build stamp imported the *generated*
|
|
166
|
+
`src/version.ts`, putting it in `bun --watch`'s module graph while prebuild rewrote it every
|
|
167
|
+
build. The import is gone, and generated files are now written **only when the content
|
|
168
|
+
changes**, which makes the whole class of self-write loops impossible.
|
|
169
|
+
|
|
170
|
+
|
|
3
171
|
## 1.9.0-beta.2
|
|
4
172
|
|
|
5
173
|
> Prerelease — install with `tosijs-ui@beta`. `latest` stays on 1.8.0.
|
package/README.md
CHANGED
|
@@ -16,15 +16,9 @@
|
|
|
16
16
|
|
|
17
17
|
Copyright ©2023-2025 Tonio Loewald
|
|
18
18
|
|
|
19
|
-
>
|
|
20
|
-
>
|
|
21
|
-
>
|
|
22
|
-
> moved from ACE to CodeMirror 6. `value`, `mode`, the `change` event, `disabled`, and
|
|
23
|
-
> `undo`/`redo` are unchanged; the ACE-era **`theme`** and **`options`** props are **removed** with
|
|
24
|
-
> no shim. Dark mode is now automatic (`body.darkmode`) and styling comes from `--code-bg` /
|
|
25
|
-
> `--text-color`. The `2.0` name is reserved for the tjs-native tosijs port, so this ships as
|
|
26
|
-
> 1.7.0 — **pin `tosijs-ui@1.6` to defer.** Full detail and rationale in
|
|
27
|
-
[CHANGELOG.md](./CHANGELOG.md).
|
|
19
|
+
> **Upgrading from an older version?** `<tosi-code>` changed editors in 1.7.0, and
|
|
20
|
+
> 1.3.0 renamed everything from `xinjs-ui`. Both are covered in
|
|
21
|
+
> [Migrating from older versions](https://ui.tosijs.net/migrating/) — new projects can skip it.
|
|
28
22
|
|
|
29
23
|
## the tosijs-ui library
|
|
30
24
|
|
|
@@ -34,35 +28,6 @@ for free rather than replace it.
|
|
|
34
28
|
|
|
35
29
|
It works beautifully with other web-component libraries, such as [shoelace.style](https://shoelace.style/).
|
|
36
30
|
|
|
37
|
-
## Migrating to v1.3.0
|
|
38
|
-
|
|
39
|
-
v1.3.0 completes the rename from `xinjs-ui` to `tosijs-ui`. All custom element
|
|
40
|
-
tags now use the `tosi-` prefix and all exports use `Tosi*`/`tosi*` names.
|
|
41
|
-
|
|
42
|
-
### Breaking changes
|
|
43
|
-
|
|
44
|
-
- **Custom element tags** have changed from `<xin-*>` to `<tosi-*>`.
|
|
45
|
-
For example: `<xin-select>` is now `<tosi-select>`, `<xin-icon>` is now
|
|
46
|
-
`<tosi-icon>`, `<xin-example>` is now `<tosi-example>`, etc.
|
|
47
|
-
- **CSS selectors** targeting old tag names (e.g. `xin-select { ... }`) must
|
|
48
|
-
be updated.
|
|
49
|
-
- **CSS custom properties** in component `styleSpec` objects retain `--xin-*`
|
|
50
|
-
fallbacks for backward compatibility, but new code should use `--tosi-*`.
|
|
51
|
-
|
|
52
|
-
### Deprecated exports still work
|
|
53
|
-
|
|
54
|
-
The old `xin*` JavaScript exports (`xinSelect`, `xinTabs`, `xinTable`, etc.)
|
|
55
|
-
remain available and will continue to work. Most log a runtime deprecation
|
|
56
|
-
warning; a few are silent aliases marked with JSDoc `@deprecated`. They will
|
|
57
|
-
be removed in a future major version.
|
|
58
|
-
|
|
59
|
-
### Migration checklist
|
|
60
|
-
|
|
61
|
-
1. Search your HTML for `<xin-` and replace with `<tosi-`
|
|
62
|
-
2. Search your CSS for `xin-` selectors and update to `tosi-`
|
|
63
|
-
3. Search your JS/TS for `xinSelect`, `xinTabs`, etc. and switch to `tosiSelect`, `tosiTabs`, etc.
|
|
64
|
-
4. Search for `--xin-` CSS variable overrides and switch to `--tosi-`
|
|
65
|
-
|
|
66
31
|
## Quick Start
|
|
67
32
|
|
|
68
33
|
### Using npm and a bundler
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/*
|
|
3
|
+
Deploy the built site to a preview host.
|
|
4
|
+
|
|
5
|
+
bun bin/deploy-preview.ts # DRY RUN — shows what would change
|
|
6
|
+
bun bin/deploy-preview.ts --go # actually sync
|
|
7
|
+
bun bin/deploy-preview.ts --host=me@1.2.3.4 --path=/srv/preview/tosijs-ui --go
|
|
8
|
+
|
|
9
|
+
Phase 1 of REMOTE-ACCESS-PLAN.md. The whole artifact is ~9MB / ~95 files and a typical
|
|
10
|
+
doc change touches 3, so this is a copy, not a deployment pipeline — rsync has done
|
|
11
|
+
delta transfer since 1996 and there is no protocol to invent.
|
|
12
|
+
|
|
13
|
+
Two deliberate safety properties:
|
|
14
|
+
|
|
15
|
+
DRY RUN BY DEFAULT. This runs `rsync --delete`, which removes files on the remote
|
|
16
|
+
that aren't present locally. That is correct (the remote should mirror the build)
|
|
17
|
+
and it is destructive if pointed somewhere wrong, so the destructive form must be
|
|
18
|
+
typed on purpose. `--go` is the whole ceremony.
|
|
19
|
+
|
|
20
|
+
IT WARNS WHEN THE TREE IS DIRTY. `/version.json` deliberately carries no `dirty`
|
|
21
|
+
flag: it records the last COMMIT, and a build made from a dirty tree may not match
|
|
22
|
+
that commit. Rather than bake a permanently-wrong flag into a committed file, the
|
|
23
|
+
warning lives here — at the moment a human is deciding to publish something, and can
|
|
24
|
+
act on it. This is the other half of that decision (see build-stamp.ts).
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { $ } from 'bun'
|
|
28
|
+
import { existsSync } from 'fs'
|
|
29
|
+
import { tmpdir } from 'os'
|
|
30
|
+
import {
|
|
31
|
+
resolveSiteConfig,
|
|
32
|
+
isSafeRemotePath,
|
|
33
|
+
safeRemoteRoots,
|
|
34
|
+
} from './resolve-site-config'
|
|
35
|
+
|
|
36
|
+
const siteConfig = await resolveSiteConfig()
|
|
37
|
+
|
|
38
|
+
const args = process.argv.slice(2)
|
|
39
|
+
const flag = (name: string): string | undefined => {
|
|
40
|
+
const hit = args.find((a) => a.startsWith(`--${name}=`))
|
|
41
|
+
return hit ? hit.slice(name.length + 3) : undefined
|
|
42
|
+
}
|
|
43
|
+
const has = (name: string): boolean => args.includes(`--${name}`)
|
|
44
|
+
|
|
45
|
+
const OUT = siteConfig.outputDir ?? 'docs'
|
|
46
|
+
const PROJECT = siteConfig.name ?? 'site'
|
|
47
|
+
const preview = (
|
|
48
|
+
siteConfig as { preview?: { host?: string; path?: string; url?: string } }
|
|
49
|
+
).preview
|
|
50
|
+
|
|
51
|
+
// Precedence: explicit flag > env > site config. The config is the "just works"
|
|
52
|
+
// default; the flag exists so you can push a one-off somewhere else without editing
|
|
53
|
+
// (and committing) a config file.
|
|
54
|
+
const host = flag('host') ?? process.env.PREVIEW_HOST ?? preview?.host
|
|
55
|
+
const remotePath =
|
|
56
|
+
flag('path') ??
|
|
57
|
+
process.env.PREVIEW_PATH ??
|
|
58
|
+
preview?.path ??
|
|
59
|
+
`/srv/preview/${PROJECT}`
|
|
60
|
+
const publicUrl = flag('url') ?? preview?.url
|
|
61
|
+
const go = has('go')
|
|
62
|
+
|
|
63
|
+
if (!host) {
|
|
64
|
+
console.error(
|
|
65
|
+
`\nNo preview host.\n\n` +
|
|
66
|
+
` Set one once in your site config:\n` +
|
|
67
|
+
` preview: { host: 'user@example.com' }\n\n` +
|
|
68
|
+
` ...or pass it per-run:\n` +
|
|
69
|
+
` bun bin/deploy-preview.ts --host=user@example.com [--path=/srv/preview/x] [--go]\n` +
|
|
70
|
+
` PREVIEW_HOST=user@example.com bun bin/deploy-preview.ts\n`
|
|
71
|
+
)
|
|
72
|
+
process.exit(1)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Never let a typo'd path become `rsync --delete` against something important.
|
|
76
|
+
// An allowlist of preview roots, not a depth heuristic — "two segments deep" admits
|
|
77
|
+
// /usr/lib and /etc/caddy, and mirroring a doc-site build into either would empty it.
|
|
78
|
+
if (!isSafeRemotePath(remotePath)) {
|
|
79
|
+
console.error(
|
|
80
|
+
`\n🛑 Refusing to deploy to "${remotePath}".\n\n` +
|
|
81
|
+
` This runs \`rsync --delete\`, which mirrors — anything already at the target\n` +
|
|
82
|
+
` that isn't in the build gets removed. Targets must live under one of:\n` +
|
|
83
|
+
safeRemoteRoots()
|
|
84
|
+
.map((r) => ` ${r}/…`)
|
|
85
|
+
.join('\n') +
|
|
86
|
+
`\n`
|
|
87
|
+
)
|
|
88
|
+
process.exit(1)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (!existsSync(OUT) || !existsSync(`${OUT}/index.html`)) {
|
|
92
|
+
console.error(
|
|
93
|
+
`\n🛑 No build at ./${OUT} (or it has no index.html). Run \`bun run build\` first.\n`
|
|
94
|
+
)
|
|
95
|
+
process.exit(1)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// What are we about to publish? /version.json is the build's identity; printing it
|
|
99
|
+
// here closes the loop the stamp exists for — you see the commit BEFORE it ships,
|
|
100
|
+
// not only when someone asks "what am I looking at".
|
|
101
|
+
let stamp = '(no version.json — build with a current tosijs-ui)'
|
|
102
|
+
try {
|
|
103
|
+
const v = JSON.parse(await Bun.file(`${OUT}/version.json`).text())
|
|
104
|
+
stamp = `${v.commit ?? '?'} — ${v.commitTime ?? '?'} (generator ${
|
|
105
|
+
v.generator
|
|
106
|
+
})`
|
|
107
|
+
} catch {
|
|
108
|
+
// best effort; never block a deploy on the stamp
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const dirty = (await $`git status --porcelain`.nothrow().quiet().text()).trim()
|
|
112
|
+
|
|
113
|
+
console.log(`\n📦 ${PROJECT} → ${host}:${remotePath}`)
|
|
114
|
+
console.log(` building from: ${stamp}`)
|
|
115
|
+
|
|
116
|
+
if (dirty) {
|
|
117
|
+
const n = dirty.split('\n').length
|
|
118
|
+
console.warn(
|
|
119
|
+
`\n⚠️ Working tree has ${n} uncommitted change${n === 1 ? '' : 's'}.\n` +
|
|
120
|
+
` /version.json records the last COMMIT, so the preview will claim a commit\n` +
|
|
121
|
+
` that does not describe what you are about to publish. Commit first if the\n` +
|
|
122
|
+
` identity matters (it usually does when someone else is reviewing).\n`
|
|
123
|
+
)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// --delete so the remote mirrors the build exactly (stale pages must not linger);
|
|
127
|
+
// -z compresses; --checksum is NOT used — mtime+size is enough and much faster.
|
|
128
|
+
const rsyncArgs = [
|
|
129
|
+
'-az',
|
|
130
|
+
'--delete',
|
|
131
|
+
'--human-readable',
|
|
132
|
+
...(go ? [] : ['--dry-run']),
|
|
133
|
+
'--itemize-changes',
|
|
134
|
+
`${OUT}/`,
|
|
135
|
+
`${host}:${remotePath}/`,
|
|
136
|
+
]
|
|
137
|
+
|
|
138
|
+
if (!go) console.log(`\n🔍 DRY RUN — nothing will change. Add --go to sync.\n`)
|
|
139
|
+
|
|
140
|
+
const result = await $`rsync ${rsyncArgs}`.nothrow()
|
|
141
|
+
if (result.exitCode !== 0) {
|
|
142
|
+
console.error(`\n🛑 rsync failed (exit ${result.exitCode}).`)
|
|
143
|
+
process.exit(1)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/*
|
|
147
|
+
Self-registration.
|
|
148
|
+
|
|
149
|
+
When `preview.url` is set, this project publishes its own Caddy fragment naming the
|
|
150
|
+
hostname and the directory it serves. The central Caddyfile glob-imports
|
|
151
|
+
/srv/preview/_sites/*.caddy, so a new project needs no edit to any shared file and no
|
|
152
|
+
DNS change — deploying IS registering.
|
|
153
|
+
|
|
154
|
+
The fragment is the single source of truth for hostname→directory: the index page
|
|
155
|
+
reads these same files, so the listing cannot drift from the router. (It did, once:
|
|
156
|
+
the index derived hostnames from directory names and linked
|
|
157
|
+
tosijs-ui.dev.tosijs.net at a site actually served from ui.dev.tosijs.net.)
|
|
158
|
+
|
|
159
|
+
Validate before reloading, and do not reload if invalid. One malformed fragment would
|
|
160
|
+
otherwise fail the reload for EVERY project on the box — so a bad config leaves the
|
|
161
|
+
previous good one serving, exactly as a failed build leaves the last good site up.
|
|
162
|
+
*/
|
|
163
|
+
async function registerSite(): Promise<void> {
|
|
164
|
+
if (!publicUrl) return
|
|
165
|
+
let hostname: string
|
|
166
|
+
try {
|
|
167
|
+
hostname = new URL(publicUrl).hostname
|
|
168
|
+
} catch {
|
|
169
|
+
console.warn(
|
|
170
|
+
`⚠️ preview.url is not a URL (${publicUrl}) — skipping registration.`
|
|
171
|
+
)
|
|
172
|
+
return
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Staged through a local temp file and scp'd: Bun's shell has no herestring, and
|
|
176
|
+
// building a remote `cat > file` out of an interpolated string is how you end up
|
|
177
|
+
// with a quoting bug that writes something surprising as root.
|
|
178
|
+
const local = `${tmpdir()}/${PROJECT}.caddy`
|
|
179
|
+
await Bun.write(
|
|
180
|
+
local,
|
|
181
|
+
`# Generated by \`bun run deploy\` for ${PROJECT}. Do not hand-edit.\n` +
|
|
182
|
+
`${hostname} {\n\troot * ${remotePath}\n\timport preview_site\n}\n`
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
const mk = await $`ssh ${host} mkdir -p /srv/preview/_sites`.nothrow().quiet()
|
|
186
|
+
const put =
|
|
187
|
+
await $`scp -q ${local} ${`${host}:/srv/preview/_sites/${PROJECT}.caddy`}`
|
|
188
|
+
.nothrow()
|
|
189
|
+
.quiet()
|
|
190
|
+
if (mk.exitCode !== 0 || put.exitCode !== 0) {
|
|
191
|
+
console.warn(
|
|
192
|
+
`⚠️ could not register ${hostname} — site deployed, routing unchanged.`
|
|
193
|
+
)
|
|
194
|
+
return
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Validate with the SAME environment systemd gives Caddy. The basicauth hash comes
|
|
198
|
+
// from an EnvironmentFile (kept out of this public repo), and a bare `caddy validate`
|
|
199
|
+
// does not load it — so {env.PREVIEW_HASH} resolves empty and validation fails with
|
|
200
|
+
// "username and password are required" on a config that is actually fine. That would
|
|
201
|
+
// fail CLOSED forever: every deploy would decline to reload and routing would freeze.
|
|
202
|
+
const check =
|
|
203
|
+
await $`ssh ${host} ${'set -a; . /etc/caddy/preview.env 2>/dev/null; set +a; caddy validate --config /etc/caddy/Caddyfile'}`
|
|
204
|
+
.nothrow()
|
|
205
|
+
.quiet()
|
|
206
|
+
if (check.exitCode !== 0) {
|
|
207
|
+
// Leave the fragment so the error is inspectable, but do NOT reload: one bad
|
|
208
|
+
// fragment would fail the reload for EVERY project on the box.
|
|
209
|
+
console.error(
|
|
210
|
+
`\n🛑 Caddy config invalid after registering ${hostname} — NOT reloading.\n` +
|
|
211
|
+
` The previous config keeps serving. Fix or remove\n` +
|
|
212
|
+
` /srv/preview/_sites/${PROJECT}.caddy on the host.\n` +
|
|
213
|
+
(check.stderr.toString() || check.stdout.toString())
|
|
214
|
+
.trim()
|
|
215
|
+
.split('\n')
|
|
216
|
+
.slice(-4)
|
|
217
|
+
.join('\n')
|
|
218
|
+
)
|
|
219
|
+
return
|
|
220
|
+
}
|
|
221
|
+
await $`ssh ${host} systemctl reload caddy`.nothrow().quiet()
|
|
222
|
+
console.log(` registered ${hostname} → ${remotePath}`)
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Refresh the host's index so this deploy shows up immediately. */
|
|
226
|
+
async function refreshIndex(): Promise<void> {
|
|
227
|
+
const remote = `/tmp/tosi-build-index-${process.pid}.sh`
|
|
228
|
+
const put = await $`scp -q ${'deploy/build-index.sh'} ${`${host}:${remote}`}`
|
|
229
|
+
.nothrow()
|
|
230
|
+
.quiet()
|
|
231
|
+
if (put.exitCode !== 0) return
|
|
232
|
+
const run = await $`ssh ${host} bash ${remote}`.nothrow().quiet()
|
|
233
|
+
await $`ssh ${host} rm -f ${remote}`.nothrow().quiet()
|
|
234
|
+
const out = run.stdout.toString().trim()
|
|
235
|
+
if (out) console.log(` ${out}`)
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if (go) {
|
|
239
|
+
await registerSite()
|
|
240
|
+
await refreshIndex()
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
console.log(
|
|
244
|
+
go
|
|
245
|
+
? `\n✅ Deployed. ${stamp}` + (publicUrl ? `\n ${publicUrl}\n` : '\n')
|
|
246
|
+
: `\nDry run complete — re-run with --go to apply.\n`
|
|
247
|
+
)
|
package/bin/docs.ts
CHANGED
package/bin/make-icon-data.js
CHANGED
|
@@ -249,16 +249,32 @@ function findIcons(dirs, ignore = []) {
|
|
|
249
249
|
if (dir.includes('color')) {
|
|
250
250
|
classes.push('color')
|
|
251
251
|
} else {
|
|
252
|
-
|
|
253
|
-
|
|
252
|
+
/*
|
|
253
|
+
Strip fill/stroke COLOUR so a monochrome icon can be tinted — but keep
|
|
254
|
+
`fill-rule` and `clip-rule`, which are fill TOPOLOGY and take no part in
|
|
255
|
+
tinting.
|
|
256
|
+
|
|
257
|
+
Removing them reverts a compound path to `nonzero`, so an icon that punches
|
|
258
|
+
holes with `evenodd` — a keyboard drawn as one path, body with key-shaped
|
|
259
|
+
holes — renders the holes SOLID. The workaround was to move such icons into
|
|
260
|
+
`color/` with `fill:currentColor`, which keeps them themeable but opts them
|
|
261
|
+
out of the tinting path for an unrelated reason (tosijs-ui#30).
|
|
262
|
+
*/
|
|
263
|
+
svgSource = svgSource.replace(
|
|
264
|
+
/(fill|stroke)(?!-rule)(-\w+)?:[^;]+;?/g,
|
|
265
|
+
''
|
|
266
|
+
)
|
|
254
267
|
}
|
|
268
|
+
// Same rule as above, and the reason it took two goes to fix: `fill-rule` was
|
|
269
|
+
// stripped in THREE places, not one. `-rule` is topology, never colour, so it
|
|
270
|
+
// survives every strip. `clip-rule` likewise.
|
|
255
271
|
if (dir.includes('stroked')) {
|
|
256
272
|
classes.push('stroked')
|
|
257
|
-
svgSource = svgSource.replace(/(fill)(-\w+)?:[^;]+;?/g, '')
|
|
273
|
+
svgSource = svgSource.replace(/(fill)(?!-rule)(-\w+)?:[^;]+;?/g, '')
|
|
258
274
|
}
|
|
259
275
|
if (dir.includes('filled')) {
|
|
260
276
|
classes.push('filled')
|
|
261
|
-
svgSource = svgSource.replace(/(stroke)(-\w+)?:[^;]+;?/g, '')
|
|
277
|
+
svgSource = svgSource.replace(/(stroke)(?!-rule)(-\w+)?:[^;]+;?/g, '')
|
|
262
278
|
}
|
|
263
279
|
if (classes.length) {
|
|
264
280
|
svgSource = svgSource.replace(
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Find and load the CONSUMER's site config.
|
|
3
|
+
|
|
4
|
+
`bin/tunnel.ts` and `bin/deploy-preview.ts` ship as package bins (`tosijs-tunnel`,
|
|
5
|
+
`tosijs-deploy`), so they run from an adopter's repo, not this one. They used to
|
|
6
|
+
`import siteConfig from '../tosijs-site.config'` — a path that only resolves inside
|
|
7
|
+
tosijs-ui, which is exactly why every adopter had to hand-copy ~390 lines of
|
|
8
|
+
security-sensitive glue and then own the drift forever (tosijs-ui#27).
|
|
9
|
+
|
|
10
|
+
Resolution order, first hit wins:
|
|
11
|
+
1. `--config=<path>` explicit beats clever
|
|
12
|
+
2. `TOSIJS_SITE_CONFIG` for CI and one-offs
|
|
13
|
+
3. ./tosijs-site.config.ts what this repo and its siblings use
|
|
14
|
+
4. ./site.config.ts what doc-site-system.md's quick-start suggests
|
|
15
|
+
5. ./tosijs-site.config.js / ./site.config.js
|
|
16
|
+
|
|
17
|
+
Deliberately does NOT walk up the tree. These commands deploy and expose a workspace;
|
|
18
|
+
silently picking up a parent directory's config would mean publishing the wrong project,
|
|
19
|
+
and "it found something" is not the same as "it found yours".
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { existsSync } from 'fs'
|
|
23
|
+
import * as path from 'path'
|
|
24
|
+
|
|
25
|
+
export interface PreviewConfig {
|
|
26
|
+
host?: string
|
|
27
|
+
path?: string
|
|
28
|
+
url?: string
|
|
29
|
+
tunnel?: {
|
|
30
|
+
remotePort?: number
|
|
31
|
+
localPort?: number
|
|
32
|
+
url?: string
|
|
33
|
+
requireToken?: boolean
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface ResolvedSiteConfig {
|
|
38
|
+
name?: string
|
|
39
|
+
outputDir?: string
|
|
40
|
+
port?: number
|
|
41
|
+
preview?: PreviewConfig
|
|
42
|
+
/** absolute path of the file this came from, for error messages */
|
|
43
|
+
__configPath: string
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const CANDIDATES = [
|
|
47
|
+
'tosijs-site.config.ts',
|
|
48
|
+
'site.config.ts',
|
|
49
|
+
'tosijs-site.config.js',
|
|
50
|
+
'site.config.js',
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
/** Where would we look? Exported so a failure message can list it. */
|
|
54
|
+
export function candidatePaths(cwd = process.cwd()): string[] {
|
|
55
|
+
return CANDIDATES.map((c) => path.resolve(cwd, c))
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export async function resolveSiteConfig(
|
|
59
|
+
argv: string[] = process.argv.slice(2),
|
|
60
|
+
cwd = process.cwd()
|
|
61
|
+
): Promise<ResolvedSiteConfig> {
|
|
62
|
+
const flag = argv.find((a) => a.startsWith('--config='))?.slice(9)
|
|
63
|
+
const explicit = flag ?? process.env.TOSIJS_SITE_CONFIG
|
|
64
|
+
|
|
65
|
+
let file: string | undefined
|
|
66
|
+
if (explicit) {
|
|
67
|
+
file = path.resolve(cwd, explicit)
|
|
68
|
+
if (!existsSync(file)) {
|
|
69
|
+
console.error(`\n🛑 No site config at ${file}\n`)
|
|
70
|
+
process.exit(1)
|
|
71
|
+
}
|
|
72
|
+
} else {
|
|
73
|
+
file = candidatePaths(cwd).find((p) => existsSync(p))
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (!file) {
|
|
77
|
+
console.error(
|
|
78
|
+
`\n🛑 No site config found in ${cwd}\n\n` +
|
|
79
|
+
` Looked for:\n` +
|
|
80
|
+
CANDIDATES.map((c) => ` ${c}`).join('\n') +
|
|
81
|
+
`\n\n Pass one explicitly with --config=<path>, or set TOSIJS_SITE_CONFIG.\n` +
|
|
82
|
+
` (Parent directories are deliberately NOT searched — these commands deploy\n` +
|
|
83
|
+
` and expose a workspace, and picking up a neighbour's config silently would\n` +
|
|
84
|
+
` publish the wrong project.)\n`
|
|
85
|
+
)
|
|
86
|
+
process.exit(1)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
try {
|
|
90
|
+
const mod = await import(file)
|
|
91
|
+
const cfg = mod.default ?? mod
|
|
92
|
+
return { ...cfg, __configPath: file }
|
|
93
|
+
} catch (e) {
|
|
94
|
+
console.error(`\n🛑 Could not load ${file}:\n ${String(e)}\n`)
|
|
95
|
+
process.exit(1)
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/*
|
|
100
|
+
Is this a safe target for `rsync --delete`?
|
|
101
|
+
|
|
102
|
+
The original guard was "absolute and at least two segments deep", which happily admits
|
|
103
|
+
/usr/lib, /var/www, /etc/caddy and /home/deploy — all of which would be MIRRORED to a
|
|
104
|
+
doc-site build, i.e. emptied of everything else. A depth heuristic cannot express
|
|
105
|
+
"somewhere set aside for previews"; an allowlist of roots can.
|
|
106
|
+
|
|
107
|
+
Exported and pure so it is testable — the previous version was top-level script code
|
|
108
|
+
inside the deploy, which is exactly why nothing pinned it.
|
|
109
|
+
*/
|
|
110
|
+
const SAFE_ROOTS = [
|
|
111
|
+
'/srv/preview',
|
|
112
|
+
'/srv/www',
|
|
113
|
+
'/var/www/preview',
|
|
114
|
+
'/opt/preview',
|
|
115
|
+
]
|
|
116
|
+
|
|
117
|
+
export function isSafeRemotePath(p: string): boolean {
|
|
118
|
+
if (!p.startsWith('/')) return false
|
|
119
|
+
// No traversal, no trailing weirdness that could re-root the path.
|
|
120
|
+
if (p.includes('..')) return false
|
|
121
|
+
const norm = p.replace(/\/+$/, '')
|
|
122
|
+
if (norm.split('/').filter(Boolean).length < 2) return false
|
|
123
|
+
return SAFE_ROOTS.some((root) => norm === root || norm.startsWith(root + '/'))
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export const safeRemoteRoots = (): string[] => [...SAFE_ROOTS]
|