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.
Files changed (43) hide show
  1. package/CHANGELOG.md +168 -0
  2. package/README.md +3 -38
  3. package/bin/deploy-preview.ts +247 -0
  4. package/bin/docs.ts +1 -0
  5. package/bin/make-icon-data.js +20 -4
  6. package/bin/resolve-site-config.ts +126 -0
  7. package/bin/tunnel.ts +305 -0
  8. package/deploy/Caddyfile +204 -0
  9. package/deploy/build-index.sh +133 -0
  10. package/dist/doc-browser.js +56 -8
  11. package/dist/doc-system/site/dev-auth.d.ts +13 -0
  12. package/dist/doc-system/site/dev-auth.js +64 -0
  13. package/dist/doc-system/site/dev-server.js +323 -214
  14. package/dist/doc-system/site/orchestrator.js +534 -434
  15. package/dist/doc-system/site/site-config.d.ts +33 -7
  16. package/dist/icon-svg.d.ts +3 -0
  17. package/dist/icon-svg.js +33 -0
  18. package/dist/icons.d.ts +1 -0
  19. package/dist/icons.js +2 -0
  20. package/dist/iife.js +77 -71
  21. package/dist/iife.js.map +8 -7
  22. package/dist/live-example/component.js +40 -6
  23. package/dist/tsconfig.tsbuildinfo +1 -1
  24. package/dist/version.d.ts +1 -1
  25. package/dist/version.js +1 -1
  26. package/llms.txt +3 -1
  27. package/package.json +15 -4
  28. package/dist/hydrate/ajs-language-xvpytqfm.js +0 -11
  29. package/dist/hydrate/ajs-language-xvpytqfm.js.map +0 -10
  30. package/dist/hydrate/code-editor-cm-59je4d8g.js +0 -10
  31. package/dist/hydrate/code-editor-cm-59je4d8g.js.map +0 -20
  32. package/dist/hydrate/hydrate.js +0 -215
  33. package/dist/hydrate/hydrate.js.map +0 -77
  34. package/dist/hydrate/index-iife-2q1gndgz.js +0 -27
  35. package/dist/hydrate/index-iife-2q1gndgz.js.map +0 -22
  36. package/dist/hydrate/index-iife-415dypjp.js +0 -5
  37. package/dist/hydrate/index-iife-415dypjp.js.map +0 -9
  38. package/dist/hydrate/index-iife-xv69gy4m.js +0 -14
  39. package/dist/hydrate/index-iife-xv69gy4m.js.map +0 -12
  40. package/dist/hydrate/index-vk8ek4jm.js +0 -6
  41. package/dist/hydrate/index-vk8ek4jm.js.map +0 -10
  42. package/dist/hydrate/tjs-import-resolver-s4z0w296.js +0 -4
  43. 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
- > ## ⚠️ Breaking change in 1.7.0 — `<tosi-code>` (ACE → CodeMirror 6)
20
- >
21
- > **1.7.0 is a breaking release shipping under a minor version, deliberately.** `<tosi-code>`
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
@@ -1,3 +1,4 @@
1
+ #!/usr/bin/env bun
1
2
  // Back-compat shim. The doc-extraction implementation now lives in the
2
3
  // importable doc-site system at src/doc-system/site/docs.ts (shipped as
3
4
  // `tosijs-ui/site`). This path is kept (package.json#files lists /bin/docs.ts)
@@ -249,16 +249,32 @@ function findIcons(dirs, ignore = []) {
249
249
  if (dir.includes('color')) {
250
250
  classes.push('color')
251
251
  } else {
252
- // If not a color icon, remove fill/stroke styles
253
- svgSource = svgSource.replace(/(fill|stroke)(-\w+)?:[^;]+;?/g, '')
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]