create-grist-widget 0.3.22 → 0.3.24
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/README.md +4 -2
- package/bin/create-grist-widget.mjs +74 -26
- package/package.json +1 -1
- package/src/age-exceptions.mjs +61 -0
- package/src/package-manager.mjs +19 -0
- package/template/.github/workflows/ci.yml +3 -1
- package/template/.github/workflows/deploy.yml +124 -43
- package/template/AGENTS.md +3 -0
- package/template/README.md +16 -1
- package/template/scripts/deploy.mjs +66 -5
- package/template/src/main.tsx +0 -2
- package/template/src/components/grist-status-chip.tsx +0 -116
- package/template/src/lib/grist-handshake-status.ts +0 -26
package/README.md
CHANGED
|
@@ -31,8 +31,10 @@ prompt that asks for both.
|
|
|
31
31
|
| `list [--json]` | Print the available starting examples |
|
|
32
32
|
|
|
33
33
|
Common flags for `create`/`init`: `--yes` skips interactive prompts and uses
|
|
34
|
-
flags/defaults only; `--no-install` skips the automatic dependency install
|
|
35
|
-
|
|
34
|
+
flags/defaults only; `--no-install` skips the automatic dependency install
|
|
35
|
+
(which uses pnpm, whichever package manager runs the CLI: the widget's
|
|
36
|
+
workflows only read `pnpm-lock.yaml`); `--pm <name>` installs with another
|
|
37
|
+
package manager instead.
|
|
36
38
|
`init` additionally takes `--name <name>` (default: the current directory's
|
|
37
39
|
name) and `--force` (overwrite files that already exist and differ from the
|
|
38
40
|
template — see below).
|
|
@@ -26,11 +26,13 @@ import {
|
|
|
26
26
|
} from "node:fs"
|
|
27
27
|
import path from "node:path"
|
|
28
28
|
import { fileURLToPath } from "node:url"
|
|
29
|
-
import { execFileSync } from "node:child_process"
|
|
29
|
+
import { execFileSync, execSync } from "node:child_process"
|
|
30
30
|
import * as clack from "@clack/prompts"
|
|
31
31
|
|
|
32
32
|
import { createComposer } from "../vendor/compose-example.mjs"
|
|
33
33
|
import { checkGitGate } from "../src/git-gate.mjs"
|
|
34
|
+
import { addedAgeExceptions } from "../src/age-exceptions.mjs"
|
|
35
|
+
import { installPlan } from "../src/package-manager.mjs"
|
|
34
36
|
|
|
35
37
|
const PKG_ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), "..")
|
|
36
38
|
const TEMPLATE_DIR = path.join(PKG_ROOT, "template")
|
|
@@ -61,14 +63,6 @@ function titleCase(name) {
|
|
|
61
63
|
.join(" ")
|
|
62
64
|
}
|
|
63
65
|
|
|
64
|
-
function detectPackageManager() {
|
|
65
|
-
// e.g. "pnpm/9.1.0 npm-node/..." -> "pnpm". Set by every package manager's
|
|
66
|
-
// own `npm create`-equivalent invocation. Falls back to npm, matching
|
|
67
|
-
// what a bare `node bin/create-grist-widget.mjs` (no wrapping pm) should do.
|
|
68
|
-
const ua = process.env.npm_config_user_agent || ""
|
|
69
|
-
return ua.split("/")[0] || "npm"
|
|
70
|
-
}
|
|
71
|
-
|
|
72
66
|
function parseArgs(argv) {
|
|
73
67
|
const out = { _: [] }
|
|
74
68
|
for (let i = 0; i < argv.length; i++) {
|
|
@@ -101,8 +95,8 @@ Options (create / init):
|
|
|
101
95
|
(see "list" for ids)
|
|
102
96
|
--yes Skip interactive prompts, use defaults/flags only
|
|
103
97
|
--no-install Don't run the package manager install automatically
|
|
104
|
-
--pm <name>
|
|
105
|
-
|
|
98
|
+
--pm <name> Install with this package manager instead of pnpm (the
|
|
99
|
+
widget's workflows use pnpm and only read its lockfile)
|
|
106
100
|
--name <name> (init only) Widget name — default: the current directory's name
|
|
107
101
|
--force (init only) Overwrite files that already exist and differ
|
|
108
102
|
from the template
|
|
@@ -168,18 +162,71 @@ function gitInitCommit(target) {
|
|
|
168
162
|
}
|
|
169
163
|
}
|
|
170
164
|
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
165
|
+
// Package managers are .cmd shims on Windows, which only a shell starts.
|
|
166
|
+
function runCommand(cmd, args, options) {
|
|
167
|
+
if (process.platform === "win32") return execSync([cmd, ...args].join(" "), options)
|
|
168
|
+
return execFileSync(cmd, args, options)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function hasCommand(cmd) {
|
|
174
172
|
try {
|
|
175
|
-
|
|
173
|
+
runCommand(cmd, ["--version"], { stdio: "ignore" })
|
|
176
174
|
return true
|
|
177
175
|
} catch {
|
|
178
|
-
console.warn(`\nCould not run "${manager} install" automatically — run it yourself.`)
|
|
179
176
|
return false
|
|
180
177
|
}
|
|
181
178
|
}
|
|
182
179
|
|
|
180
|
+
/** Returns whether the install ran, and why not when it was skipped. */
|
|
181
|
+
function maybeInstall(target, { noInstall, pm }) {
|
|
182
|
+
const plan = installPlan({ noInstall, pm, hasPnpm: () => hasCommand("pnpm") })
|
|
183
|
+
if (plan.skip) return { installed: false, skipped: plan.skip }
|
|
184
|
+
try {
|
|
185
|
+
runCommand(plan.manager, ["install"], { cwd: target, stdio: "inherit" })
|
|
186
|
+
return { installed: true }
|
|
187
|
+
} catch {
|
|
188
|
+
console.warn(`\nCould not run "${plan.manager} install" automatically — run it yourself.`)
|
|
189
|
+
return { installed: false }
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Install (unless --no-install, or pnpm is missing), returning which
|
|
195
|
+
* `minimumReleaseAgeExclude` entries pnpm added to pnpm-workspace.yaml along
|
|
196
|
+
* the way. Runs before the first commit, so the lockfile -- and those
|
|
197
|
+
* entries, without which every later install rejects that lockfile -- are
|
|
198
|
+
* part of it.
|
|
199
|
+
*/
|
|
200
|
+
function installBeforeFirstCommit(target, args) {
|
|
201
|
+
const workspaceFile = path.join(target, "pnpm-workspace.yaml")
|
|
202
|
+
const read = () => (existsSync(workspaceFile) ? readFileSync(workspaceFile, "utf8") : "")
|
|
203
|
+
const before = read()
|
|
204
|
+
const { installed, skipped } = maybeInstall(target, {
|
|
205
|
+
noInstall: Boolean(args["no-install"]),
|
|
206
|
+
pm: typeof args.pm === "string" ? args.pm : null,
|
|
207
|
+
})
|
|
208
|
+
return { installed, skipped, ageExceptions: installed ? addedAgeExceptions(before, read()) : [] }
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function printNoPnpmNote(skipped) {
|
|
212
|
+
if (skipped !== "no-pnpm") return
|
|
213
|
+
console.log(
|
|
214
|
+
"\nNote: dependencies were not installed. This widget uses pnpm (its workflows install with it," +
|
|
215
|
+
"\nand only read pnpm-lock.yaml), and pnpm isn't installed. Install it (https://pnpm.io/installation)," +
|
|
216
|
+
"\nthen run `pnpm install` and commit pnpm-lock.yaml."
|
|
217
|
+
)
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function printAgeExceptionsNote(ageExceptions) {
|
|
221
|
+
if (ageExceptions.length === 0) return
|
|
222
|
+
console.log(
|
|
223
|
+
`\nNote: ${ageExceptions.join(", ")} came out less than 24 hours ago. pnpm only installs versions` +
|
|
224
|
+
"\nthat recent when told to, so it listed them under minimumReleaseAgeExclude in pnpm-workspace.yaml." +
|
|
225
|
+
"\nThat entry is committed: without it, every later install (yours and CI's) rejects the lockfile." +
|
|
226
|
+
"\nIt only covers the SDK released together with this CLI, and is moot once the version is a day old."
|
|
227
|
+
)
|
|
228
|
+
}
|
|
229
|
+
|
|
183
230
|
function listFilesRecursive(dir, base = dir) {
|
|
184
231
|
const out = []
|
|
185
232
|
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
@@ -282,15 +329,16 @@ async function cmdCreate(argv) {
|
|
|
282
329
|
|
|
283
330
|
stampCreateGristWidgetVersion(target)
|
|
284
331
|
|
|
332
|
+
// Install first, so the first commit includes the lockfile (pinned, with
|
|
333
|
+
// integrity hashes; pnpm then freezes installs in CI by itself).
|
|
334
|
+
const { installed, skipped, ageExceptions } = installBeforeFirstCommit(target, args)
|
|
285
335
|
const committed = gitInitCommit(target)
|
|
286
|
-
const installed = maybeInstall(target, {
|
|
287
|
-
noInstall: Boolean(args["no-install"]),
|
|
288
|
-
pm: typeof args.pm === "string" ? args.pm : null,
|
|
289
|
-
})
|
|
290
336
|
|
|
337
|
+
printAgeExceptionsNote(ageExceptions)
|
|
338
|
+
printNoPnpmNote(skipped)
|
|
291
339
|
console.log(`\nCreated ${rawName}/${exampleId ? ` (starting from "${exampleId}")` : ""}\n`)
|
|
292
340
|
console.log(` cd ${rawName}`)
|
|
293
|
-
if (!installed) console.log(" pnpm install
|
|
341
|
+
if (!installed) console.log(" pnpm install")
|
|
294
342
|
console.log(" pnpm dev")
|
|
295
343
|
if (committed) {
|
|
296
344
|
console.log("\nGit is initialized on main (first commit done) with a dev branch ready.")
|
|
@@ -384,14 +432,14 @@ async function cmdInit(argv) {
|
|
|
384
432
|
// git entirely when cwd had none to begin with (a fresh, non-git directory,
|
|
385
433
|
// or a real git clone where .git already carries whatever history it has).
|
|
386
434
|
const gitAlreadyExists = existsSync(path.join(cwd, ".git"))
|
|
435
|
+
// Install first, so a first commit includes the lockfile (see cmdCreate).
|
|
436
|
+
const { installed, skipped, ageExceptions } = installBeforeFirstCommit(cwd, args)
|
|
387
437
|
const committed = gitAlreadyExists ? false : gitInitCommit(cwd)
|
|
388
|
-
const installed = maybeInstall(cwd, {
|
|
389
|
-
noInstall: Boolean(args["no-install"]),
|
|
390
|
-
pm: typeof args.pm === "string" ? args.pm : null,
|
|
391
|
-
})
|
|
392
438
|
|
|
439
|
+
printAgeExceptionsNote(ageExceptions)
|
|
440
|
+
printNoPnpmNote(skipped)
|
|
393
441
|
console.log(`\nInitialized ${rawName}/${exampleId ? ` (starting from "${exampleId}")` : ""} in the current directory.\n`)
|
|
394
|
-
if (!installed) console.log(" pnpm install
|
|
442
|
+
if (!installed) console.log(" pnpm install")
|
|
395
443
|
console.log(" pnpm dev")
|
|
396
444
|
if (gitAlreadyExists) {
|
|
397
445
|
console.log("\nExisting git repository detected — no commit was made automatically.")
|
package/package.json
CHANGED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// pnpm 11 won't install a dependency version published less than 24 h ago
|
|
2
|
+
// (`minimumReleaseAge`). When nothing older satisfies a range, its loose mode
|
|
3
|
+
// installs that version anyway and records an exception under
|
|
4
|
+
// `minimumReleaseAgeExclude` in pnpm-workspace.yaml. In practice that is only
|
|
5
|
+
// grist-widget-sdk, which a fresh scaffold pins to the version released with
|
|
6
|
+
// this CLI; every other dependency has an older version in range.
|
|
7
|
+
//
|
|
8
|
+
// A lockfile pinning that version is then rejected by every later install,
|
|
9
|
+
// local or CI, unless the exception stays. So the scaffold's first commit
|
|
10
|
+
// keeps it (docs/planning/2026-11-01-widget-lifecycle-plan.md, D3), and these
|
|
11
|
+
// helpers find the entries pnpm added so the CLI can say why.
|
|
12
|
+
|
|
13
|
+
const KEY = "minimumReleaseAgeExclude"
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Entries listed under `minimumReleaseAgeExclude` in a pnpm-workspace.yaml,
|
|
17
|
+
* in block (`- name@version`) or flow (`[a, b]`) style.
|
|
18
|
+
*
|
|
19
|
+
* @param {string} workspaceYaml
|
|
20
|
+
* @returns {string[]}
|
|
21
|
+
*/
|
|
22
|
+
export function ageExceptions(workspaceYaml) {
|
|
23
|
+
const out = []
|
|
24
|
+
let inBlock = false
|
|
25
|
+
for (const line of workspaceYaml.split(/\r?\n/)) {
|
|
26
|
+
const flow = line.match(new RegExp(`^${KEY}:\\s*\\[(.*)\\]\\s*(#.*)?$`))
|
|
27
|
+
if (flow) {
|
|
28
|
+
for (const item of flow[1].split(",")) {
|
|
29
|
+
const entry = item.trim().replace(/^["']|["']$/g, "")
|
|
30
|
+
if (entry) out.push(entry)
|
|
31
|
+
}
|
|
32
|
+
inBlock = false
|
|
33
|
+
continue
|
|
34
|
+
}
|
|
35
|
+
if (new RegExp(`^${KEY}:\\s*(#.*)?$`).test(line)) {
|
|
36
|
+
inBlock = true
|
|
37
|
+
continue
|
|
38
|
+
}
|
|
39
|
+
if (!inBlock) continue
|
|
40
|
+
const item = line.match(/^\s+-\s+["']?([^"'\s#]+)["']?/)
|
|
41
|
+
if (item) {
|
|
42
|
+
out.push(item[1])
|
|
43
|
+
continue
|
|
44
|
+
}
|
|
45
|
+
if (/^\s*(#.*)?$/.test(line)) continue // blank line or comment inside the block
|
|
46
|
+
inBlock = false
|
|
47
|
+
}
|
|
48
|
+
return out
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Exceptions present in `after` but not in `before`.
|
|
53
|
+
*
|
|
54
|
+
* @param {string} before pnpm-workspace.yaml before the install
|
|
55
|
+
* @param {string} after pnpm-workspace.yaml after it
|
|
56
|
+
* @returns {string[]}
|
|
57
|
+
*/
|
|
58
|
+
export function addedAgeExceptions(before, after) {
|
|
59
|
+
const had = new Set(ageExceptions(before))
|
|
60
|
+
return ageExceptions(after).filter((entry) => !had.has(entry))
|
|
61
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// Which package manager installs a new widget's dependencies.
|
|
2
|
+
//
|
|
3
|
+
// The scaffold is a pnpm project: its workflows install with pnpm, and
|
|
4
|
+
// pnpm-workspace.yaml holds settings only pnpm reads (build-script
|
|
5
|
+
// approvals, release-age exceptions). So the CLI installs with pnpm
|
|
6
|
+
// whichever package manager runs it -- `npm create grist-widget` runs it
|
|
7
|
+
// under npm. Installing with npm would commit a package-lock.json that the
|
|
8
|
+
// workflows never read, and skip pnpm's 24 h release-age check.
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* @param {{ noInstall?: boolean, pm?: string | null, hasPnpm: () => boolean }} options
|
|
12
|
+
* `pm` is an explicit `--pm`; `hasPnpm` is only called when pnpm is the pick.
|
|
13
|
+
* @returns {{ manager: string } | { skip: "no-install" | "no-pnpm" }}
|
|
14
|
+
*/
|
|
15
|
+
export function installPlan({ noInstall, pm, hasPnpm }) {
|
|
16
|
+
if (noInstall) return { skip: "no-install" }
|
|
17
|
+
if (pm) return { manager: pm }
|
|
18
|
+
return hasPnpm() ? { manager: "pnpm" } : { skip: "no-pnpm" }
|
|
19
|
+
}
|
|
@@ -24,7 +24,9 @@ jobs:
|
|
|
24
24
|
with:
|
|
25
25
|
node-version: 22
|
|
26
26
|
# Same as deploy.yml: pnpm caching and a frozen install both need a
|
|
27
|
-
# committed pnpm-lock.yaml, which a
|
|
27
|
+
# committed pnpm-lock.yaml, which a scaffold made with --no-install
|
|
28
|
+
# doesn't have. With one committed, pnpm freezes the install in CI
|
|
29
|
+
# by itself.
|
|
28
30
|
package-manager-cache: false
|
|
29
31
|
- run: pnpm install
|
|
30
32
|
- run: pnpm lint
|
|
@@ -23,6 +23,11 @@ name: Deploy widget to GitHub Pages
|
|
|
23
23
|
#
|
|
24
24
|
# All logic lives in scripts/deploy.mjs (node builtins only).
|
|
25
25
|
#
|
|
26
|
+
# Two jobs, so no dependency code ever runs next to a write token. `build`
|
|
27
|
+
# installs and builds (Vite and its plugins are third-party code) with a
|
|
28
|
+
# read-only token, and hands dist/ over as an artifact. `publish` holds
|
|
29
|
+
# `contents: write` but installs nothing: it only runs scripts/deploy.mjs.
|
|
30
|
+
#
|
|
26
31
|
# One-time setup this workflow can't do for you: enable Pages (Settings ->
|
|
27
32
|
# Pages -> Source: Deploy from a branch -> gh-pages -> / (root); this workflow
|
|
28
33
|
# creates the gh-pages branch itself if missing, but Pages must be pointed at
|
|
@@ -45,8 +50,8 @@ on:
|
|
|
45
50
|
required: false
|
|
46
51
|
default: false
|
|
47
52
|
|
|
48
|
-
|
|
49
|
-
|
|
53
|
+
# Nothing at workflow level: each job states what it needs.
|
|
54
|
+
permissions: {}
|
|
50
55
|
|
|
51
56
|
# Serialize deploys onto the single shared gh-pages branch. deploy.mjs
|
|
52
57
|
# additionally rebases-and-retries on push, so interleaving runs never clobber.
|
|
@@ -55,18 +60,27 @@ concurrency:
|
|
|
55
60
|
cancel-in-progress: false
|
|
56
61
|
|
|
57
62
|
jobs:
|
|
58
|
-
|
|
63
|
+
build:
|
|
59
64
|
# Build/publish on push + manual dispatch. The delete event is handled by `cleanup`.
|
|
60
65
|
if: github.event_name != 'delete'
|
|
61
66
|
runs-on: ubuntu-latest
|
|
67
|
+
permissions:
|
|
68
|
+
contents: read
|
|
62
69
|
env:
|
|
63
70
|
REPO_NAME: ${{ github.event.repository.name }}
|
|
71
|
+
outputs:
|
|
72
|
+
context: ${{ steps.plan.outputs.context }}
|
|
73
|
+
skip: ${{ steps.plan.outputs.skip }}
|
|
74
|
+
version: ${{ steps.plan.outputs.version }}
|
|
75
|
+
firstRelease: ${{ steps.plan.outputs.firstRelease }}
|
|
64
76
|
steps:
|
|
65
77
|
# Action majors track the monorepo's own workflows (Dependabot watches
|
|
66
78
|
# this file via the template's directory in .github/dependabot.yml);
|
|
67
79
|
# the v4s here ran on the deprecated Node 20 action runtime.
|
|
68
80
|
- name: Checkout
|
|
69
81
|
uses: actions/checkout@v7
|
|
82
|
+
with:
|
|
83
|
+
persist-credentials: false
|
|
70
84
|
|
|
71
85
|
- name: Setup pnpm
|
|
72
86
|
uses: pnpm/action-setup@v6
|
|
@@ -75,28 +89,33 @@ jobs:
|
|
|
75
89
|
uses: actions/setup-node@v7
|
|
76
90
|
with:
|
|
77
91
|
node-version: 22
|
|
78
|
-
# Never cache: a
|
|
79
|
-
#
|
|
80
|
-
# Explicit rather than relying
|
|
92
|
+
# Never cache: a pnpm cache hard-requires a committed
|
|
93
|
+
# pnpm-lock.yaml, which a scaffold made with --no-install doesn't
|
|
94
|
+
# have (see the install step below). Explicit rather than relying
|
|
95
|
+
# on setup-node's npm-only default.
|
|
81
96
|
package-manager-cache: false
|
|
82
97
|
|
|
83
|
-
#
|
|
84
|
-
#
|
|
85
|
-
#
|
|
86
|
-
#
|
|
87
|
-
#
|
|
88
|
-
#
|
|
89
|
-
#
|
|
98
|
+
# A plain `pnpm install`, not --frozen-lockfile. A scaffold commits
|
|
99
|
+
# pnpm-lock.yaml in its first commit, and with one committed pnpm
|
|
100
|
+
# freezes the install in CI by itself: the versions and integrity
|
|
101
|
+
# hashes in the lockfile are what gets installed. A scaffold made
|
|
102
|
+
# with --no-install has no lockfile until its author commits one, and
|
|
103
|
+
# there --frozen-lockfile (or `cache: pnpm`) fails outright with
|
|
104
|
+
# "Dependencies lock file is not found" (found live, on the very
|
|
105
|
+
# first real deploy); a plain install resolves fresh instead.
|
|
90
106
|
- name: Install dependencies
|
|
91
107
|
run: pnpm install
|
|
92
108
|
|
|
93
|
-
- name: Clone gh-pages into ./site
|
|
109
|
+
- name: Clone gh-pages into ./site (read-only, to plan against)
|
|
110
|
+
env:
|
|
111
|
+
GH_TOKEN: ${{ github.token }}
|
|
112
|
+
REPO: ${{ github.repository }}
|
|
94
113
|
run: |
|
|
95
114
|
git clone --depth 1 --branch gh-pages \
|
|
96
|
-
"https://x-access-token:${
|
|
115
|
+
"https://x-access-token:${GH_TOKEN}@github.com/${REPO}.git" site \
|
|
97
116
|
|| {
|
|
98
117
|
echo "gh-pages branch not found; creating an orphan."
|
|
99
|
-
git clone --depth 1 "https://x-access-token:${
|
|
118
|
+
git clone --depth 1 "https://x-access-token:${GH_TOKEN}@github.com/${REPO}.git" site
|
|
100
119
|
git -C site checkout --orphan gh-pages
|
|
101
120
|
git -C site rm -rf . >/dev/null 2>&1 || true
|
|
102
121
|
touch site/.nojekyll
|
|
@@ -106,12 +125,16 @@ jobs:
|
|
|
106
125
|
|
|
107
126
|
- name: Resolve deploy plan
|
|
108
127
|
id: plan
|
|
128
|
+
env:
|
|
129
|
+
EVENT_NAME: ${{ github.event_name }}
|
|
130
|
+
REF: ${{ github.ref }}
|
|
131
|
+
FORCE: ${{ github.event.inputs.force == 'true' && '--force' || '' }}
|
|
109
132
|
run: |
|
|
110
133
|
PLAN=$(node scripts/deploy.mjs plan \
|
|
111
134
|
--site site --repo "$REPO_NAME" \
|
|
112
|
-
--event "$
|
|
113
|
-
--ref "$
|
|
114
|
-
$
|
|
135
|
+
--event "$EVENT_NAME" \
|
|
136
|
+
--ref "$REF" \
|
|
137
|
+
$FORCE)
|
|
115
138
|
echo "Plan: $PLAN"
|
|
116
139
|
node -e '
|
|
117
140
|
const p = JSON.parse(process.argv[1]);
|
|
@@ -126,49 +149,100 @@ jobs:
|
|
|
126
149
|
|
|
127
150
|
- name: Build
|
|
128
151
|
if: steps.plan.outputs.skip != 'true'
|
|
152
|
+
env:
|
|
153
|
+
BASE: ${{ steps.plan.outputs.base }}
|
|
129
154
|
run: |
|
|
130
155
|
pnpm exec tsc -b
|
|
131
|
-
pnpm exec vite build --base "$
|
|
156
|
+
pnpm exec vite build --base "$BASE"
|
|
132
157
|
|
|
133
|
-
- name:
|
|
158
|
+
- name: Hand the build over to the publish job
|
|
134
159
|
if: steps.plan.outputs.skip != 'true'
|
|
160
|
+
uses: actions/upload-artifact@v7
|
|
161
|
+
with:
|
|
162
|
+
name: dist
|
|
163
|
+
path: dist
|
|
164
|
+
if-no-files-found: error
|
|
165
|
+
# Same files as before the split: upload-artifact skips dotfiles by
|
|
166
|
+
# default, and a widget's public/ may well ship some.
|
|
167
|
+
include-hidden-files: true
|
|
168
|
+
retention-days: 1
|
|
169
|
+
|
|
170
|
+
publish:
|
|
171
|
+
needs: build
|
|
172
|
+
runs-on: ubuntu-latest
|
|
173
|
+
permissions:
|
|
174
|
+
contents: write
|
|
175
|
+
env:
|
|
176
|
+
REPO_NAME: ${{ github.event.repository.name }}
|
|
177
|
+
CONTEXT: ${{ needs.build.outputs.context }}
|
|
178
|
+
VERSION: ${{ needs.build.outputs.version }}
|
|
179
|
+
REF: ${{ github.ref }}
|
|
180
|
+
steps:
|
|
181
|
+
# Credentials stay persisted here: the first-release reset below pushes
|
|
182
|
+
# with them. Nothing in this job installs or runs dependency code.
|
|
183
|
+
- name: Checkout
|
|
184
|
+
uses: actions/checkout@v7
|
|
185
|
+
|
|
186
|
+
- name: Setup Node
|
|
187
|
+
uses: actions/setup-node@v7
|
|
188
|
+
with:
|
|
189
|
+
node-version: 22
|
|
190
|
+
package-manager-cache: false
|
|
191
|
+
|
|
192
|
+
- name: Clone gh-pages into ./site
|
|
193
|
+
env:
|
|
194
|
+
GH_TOKEN: ${{ github.token }}
|
|
195
|
+
REPO: ${{ github.repository }}
|
|
196
|
+
run: |
|
|
197
|
+
git clone --depth 1 --branch gh-pages \
|
|
198
|
+
"https://x-access-token:${GH_TOKEN}@github.com/${REPO}.git" site \
|
|
199
|
+
|| {
|
|
200
|
+
echo "gh-pages branch not found; creating an orphan."
|
|
201
|
+
git clone --depth 1 "https://x-access-token:${GH_TOKEN}@github.com/${REPO}.git" site
|
|
202
|
+
git -C site checkout --orphan gh-pages
|
|
203
|
+
git -C site rm -rf . >/dev/null 2>&1 || true
|
|
204
|
+
touch site/.nojekyll
|
|
205
|
+
}
|
|
206
|
+
git -C site config user.name "github-actions[bot]"
|
|
207
|
+
git -C site config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
|
208
|
+
|
|
209
|
+
- name: Download the build
|
|
210
|
+
if: needs.build.outputs.skip != 'true'
|
|
211
|
+
uses: actions/download-artifact@v8
|
|
212
|
+
with:
|
|
213
|
+
name: dist
|
|
214
|
+
path: dist
|
|
215
|
+
|
|
216
|
+
- name: Place build in gh-pages tree
|
|
217
|
+
if: needs.build.outputs.skip != 'true'
|
|
135
218
|
run: |
|
|
136
219
|
SHA="$(git rev-parse --short HEAD)"
|
|
137
220
|
VERSION_ARG=""
|
|
138
|
-
[ -n "$
|
|
221
|
+
[ -n "$VERSION" ] && VERSION_ARG="--version $VERSION"
|
|
139
222
|
node scripts/deploy.mjs place \
|
|
140
223
|
--site site --repo "$REPO_NAME" \
|
|
141
|
-
--channel "$
|
|
224
|
+
--channel "$CONTEXT" $VERSION_ARG \
|
|
142
225
|
--dist dist \
|
|
143
|
-
--sha "$SHA" --ref "$
|
|
226
|
+
--sha "$SHA" --ref "$REF"
|
|
144
227
|
|
|
145
228
|
- name: Skip (already published)
|
|
146
|
-
if:
|
|
147
|
-
run: echo "== skip — v${
|
|
229
|
+
if: needs.build.outputs.skip == 'true'
|
|
230
|
+
run: echo "== skip — v${VERSION} already published"
|
|
148
231
|
|
|
149
232
|
- name: Finalize (versions.json + commit + push)
|
|
233
|
+
env:
|
|
234
|
+
SHA_FULL: ${{ github.sha }}
|
|
150
235
|
run: |
|
|
151
236
|
UPDATE_VERSIONS=""
|
|
152
|
-
if [ "$
|
|
237
|
+
if [ "$CONTEXT" = "release" ]; then
|
|
153
238
|
UPDATE_VERSIONS="--update-versions"
|
|
154
239
|
fi
|
|
155
240
|
node scripts/deploy.mjs finalize \
|
|
156
241
|
--site site --repo "$REPO_NAME" $UPDATE_VERSIONS --push \
|
|
157
|
-
--commit-message "deploy: ${
|
|
158
|
-
|
|
159
|
-
# On this repo's first genuine release only: reset package.json's version
|
|
160
|
-
# to 0.0.1 (so the next release bumps to 0.0.2 rather than any inherited
|
|
161
|
-
# canary version), prune every branch except main/dev/gh-pages, and
|
|
162
|
-
# reset dev to main's current tip. Establishes the state every widget
|
|
163
|
-
# should start from regardless of how the repo was created -- a CLI
|
|
164
|
-
# scaffold already satisfies it trivially; a repo copied via GitHub's
|
|
165
|
-
# "Use this template" often doesn't (an inherited version, a stray
|
|
166
|
-
# branch, or dev tracking the source template's own preview history).
|
|
167
|
-
# Placed after Finalize so it never runs if the release itself failed.
|
|
168
|
-
# The version-reset commit re-triggers this workflow on main, but that
|
|
169
|
-
# run sees v0.0.1 already published (firstRelease=false) and no-ops.
|
|
242
|
+
--commit-message "deploy: ${CONTEXT} @ ${SHA_FULL}"
|
|
243
|
+
|
|
170
244
|
- name: Reset repo to a clean first-release state
|
|
171
|
-
if:
|
|
245
|
+
if: needs.build.outputs.context == 'release' && needs.build.outputs.firstRelease == 'true'
|
|
172
246
|
run: node scripts/deploy.mjs reset-branches --repo "$REPO_NAME"
|
|
173
247
|
|
|
174
248
|
cleanup:
|
|
@@ -176,6 +250,8 @@ jobs:
|
|
|
176
250
|
# (404) instead of serving a stale build forever.
|
|
177
251
|
if: github.event_name == 'delete' && github.event.ref_type == 'branch' && github.event.ref == 'dev'
|
|
178
252
|
runs-on: ubuntu-latest
|
|
253
|
+
permissions:
|
|
254
|
+
contents: write
|
|
179
255
|
steps:
|
|
180
256
|
- name: Checkout (default branch — for scripts/deploy.mjs)
|
|
181
257
|
uses: actions/checkout@v7
|
|
@@ -188,9 +264,12 @@ jobs:
|
|
|
188
264
|
|
|
189
265
|
- name: Clone gh-pages into ./site (skip if it does not exist)
|
|
190
266
|
id: clone
|
|
267
|
+
env:
|
|
268
|
+
GH_TOKEN: ${{ github.token }}
|
|
269
|
+
REPO: ${{ github.repository }}
|
|
191
270
|
run: |
|
|
192
271
|
if git clone --depth 1 --branch gh-pages \
|
|
193
|
-
"https://x-access-token:${
|
|
272
|
+
"https://x-access-token:${GH_TOKEN}@github.com/${REPO}.git" site; then
|
|
194
273
|
git -C site config user.name "github-actions[bot]"
|
|
195
274
|
git -C site config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
|
196
275
|
echo "exists=true" >> "$GITHUB_OUTPUT"
|
|
@@ -201,8 +280,10 @@ jobs:
|
|
|
201
280
|
|
|
202
281
|
- name: Remove dev dir + push
|
|
203
282
|
if: steps.clone.outputs.exists == 'true'
|
|
283
|
+
env:
|
|
284
|
+
DELETED_REF: ${{ github.event.ref }}
|
|
204
285
|
run: |
|
|
205
286
|
node scripts/deploy.mjs remove --site site
|
|
206
287
|
node scripts/deploy.mjs finalize \
|
|
207
288
|
--site site --push \
|
|
208
|
-
--commit-message "cleanup: retire dev URL for ${
|
|
289
|
+
--commit-message "cleanup: retire dev URL for ${DELETED_REF}"
|
package/template/AGENTS.md
CHANGED
|
@@ -94,6 +94,9 @@ Full detail, including the two live URLs (`/latest/` and `/v<version>/`): `READM
|
|
|
94
94
|
template works (see above); `pnpm test` is the trusted local loop, real Grist via `/dev/`
|
|
95
95
|
is the trusted real one
|
|
96
96
|
- Merging `dev` into `main` without bumping `package.json`'s `version` first
|
|
97
|
+
- Adding `minimumReleaseAgeExclude` entries to `pnpm-workspace.yaml` to get an install or a
|
|
98
|
+
check green, or deleting the one pnpm wrote for `grist-widget-sdk` on release day (see
|
|
99
|
+
`README.md`): pnpm's 24 h age check protects the widget's supply chain
|
|
97
100
|
- Merging a Dependabot pull request whose `ci.yml` checks are red, or retargeting one at
|
|
98
101
|
`main`: updates go through `dev` like any other change. Template-file updates (this
|
|
99
102
|
pipeline, the build config) are applied by hand —
|
package/template/README.md
CHANGED
|
@@ -2,11 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
Base template to build a Grist widget with `grist-widget-sdk`.
|
|
4
4
|
|
|
5
|
+
<!-- install-url:start -->
|
|
6
|
+
**Install URL**: written here by this widget's first release (its GitHub Pages `/latest/` URL).
|
|
7
|
+
<!-- install-url:end -->
|
|
8
|
+
|
|
5
9
|
`src/main.tsx` wraps the app with `GristWidgetProvider`, `GristBoundary`, and `GristSdkAlerts`. The latter maps `getGristSdkAlertDescriptors()` from the SDK to shadcn `Alert` (`src/components/grist-sdk-alerts.tsx` + `src/components/ui/alert.tsx`); keep them in sync with the playground when you change alert styling.
|
|
6
10
|
|
|
7
11
|
Opened outside a Grist iframe, `main.tsx` picks between two components — never a live preview of the widget itself, since nothing here proves anything about real Grist behavior: local `pnpm dev` renders `LocalDevNotice`, a bare shell pointing at the real verification loop below (push to `dev`, open it in a real Grist document); any built/deployed URL (root, `/latest/`, `/dev/`, `/v<version>/` — all carrying the same build, see Deployment below) renders `ReleaseInfo` instead — which build this is, chips to jump to any other version/channel (hover one to see when it shipped; `src/lib/showcase-versions.ts`), and a copy-this-URL helper for pasting into Grist's custom widget field.
|
|
8
12
|
|
|
9
|
-
When actually embedded,
|
|
13
|
+
When actually embedded, the widget shows connection state only when something is wrong: until Grist is connected, or if the connection is lost or fails, `GristBoundary` replaces the widget with a connecting / unavailable / error screen (the error one with a retry button); once the widget is showing, `GristSdkAlerts` reports what needs attention (column mapping, access level, a failed write…). Nothing is drawn over the widget while all is well.
|
|
10
14
|
|
|
11
15
|
`src/App.tsx` uses `useGrist<TaskRow, TaskMapped>()` for the selected row (`w.record`, `w.mode`) and remounts the UI with `key={rowKey}` when the row changes — same pattern as `widgets/create-email-draft`. It ships with two mapped columns (`title`, `done`) and an editable form that saves back via `w.table.update(...)`/`w.mapBack(...)` — the smallest real starting point, not a placeholder. See `src/grist-types.example.ts` for typing patterns.
|
|
12
16
|
|
|
@@ -34,6 +38,17 @@ This is the **source** template. It is consumed two ways:
|
|
|
34
38
|
standalone `pnpm install` (pnpm 11) exits cleanly; inside the monorepo it's
|
|
35
39
|
ignored (the root workspace governs).
|
|
36
40
|
|
|
41
|
+
A scaffold's first commit includes `pnpm-lock.yaml`: the CLI installs with
|
|
42
|
+
pnpm, even under `npm create`, unless it was run with `--no-install` or
|
|
43
|
+
without pnpm installed. Versions are pinned with their integrity hashes, CI
|
|
44
|
+
installs exactly that, and Dependabot updates it. A `package-lock.json` isn't
|
|
45
|
+
used: the workflows install with pnpm. pnpm 11 won't install a version
|
|
46
|
+
published less than 24 hours ago. So a widget scaffolded on the day of an SDK
|
|
47
|
+
release also gets one `minimumReleaseAgeExclude` entry in
|
|
48
|
+
`pnpm-workspace.yaml`, for that SDK version only, written by pnpm itself.
|
|
49
|
+
Keep it: without it, every install (yours and CI's) rejects the lockfile
|
|
50
|
+
until the version is a day old. After that the entry is harmless.
|
|
51
|
+
|
|
37
52
|
### Live preview
|
|
38
53
|
|
|
39
54
|
This template has no live-preview deploy of its own inside this monorepo —
|
|
@@ -39,11 +39,13 @@
|
|
|
39
39
|
// Regenerates versions.json (release runs only) and commits + pushes the
|
|
40
40
|
// gh-pages tree with a rebase-and-retry loop.
|
|
41
41
|
//
|
|
42
|
-
// reset-branches [--repo-dir <dir>] [--repo <name>]
|
|
42
|
+
// reset-branches [--repo-dir <dir>] [--repo <name>] [--owner-repo <owner/name>]
|
|
43
43
|
// On a repo's first genuine release only (caller gates on plan()'s
|
|
44
44
|
// firstRelease): renames the four placeholder-identity spots (only if
|
|
45
45
|
// --repo is given and they still carry the template's own placeholder
|
|
46
|
-
// text -- a no-op for a CLI scaffold that already renamed itself),
|
|
46
|
+
// text -- a no-op for a CLI scaffold that already renamed itself), writes
|
|
47
|
+
// the /latest/ install URL into README.md's install-url block (owner/name
|
|
48
|
+
// from --owner-repo, else $GITHUB_REPOSITORY; a no-op without it), resets
|
|
47
49
|
// package.json's version to 0.0.1, prunes every branch except
|
|
48
50
|
// main/dev/gh-pages, and force-resets dev to main's current tip.
|
|
49
51
|
|
|
@@ -407,6 +409,40 @@ export function deriveIdentity(repoName) {
|
|
|
407
409
|
return { packageName, title }
|
|
408
410
|
}
|
|
409
411
|
|
|
412
|
+
// README.md's install-URL block. awesome-grist-widgets only registers a
|
|
413
|
+
// widget whose install URL its own repo states explicitly, so the first
|
|
414
|
+
// release writes it there (docs/planning/2026-11-01-widget-lifecycle-plan.md,
|
|
415
|
+
// R1). A README without these markers is left alone.
|
|
416
|
+
const INSTALL_URL_START = "<!-- install-url:start -->"
|
|
417
|
+
const INSTALL_URL_END = "<!-- install-url:end -->"
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* The GitHub Pages URL of the `/latest/` channel for `owner/repo`, the same
|
|
421
|
+
* path plan() builds the release under. A `<owner>.github.io` repo is a
|
|
422
|
+
* user/org site, served at the domain root. A custom Pages domain isn't
|
|
423
|
+
* known here: edit the README line if the widget uses one.
|
|
424
|
+
*/
|
|
425
|
+
export function latestPagesUrl(ownerRepo) {
|
|
426
|
+
const [owner, repo] = String(ownerRepo || "").split("/")
|
|
427
|
+
if (!owner || !repo) return null
|
|
428
|
+
const host = `${owner.toLowerCase()}.github.io`
|
|
429
|
+
const base = repo.toLowerCase() === host ? `https://${host}/` : `https://${host}/${repo}/`
|
|
430
|
+
return `${base}latest/`
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/** README text with its install-URL block set to `url`; unchanged without the markers. */
|
|
434
|
+
export function withInstallUrl(readme, url) {
|
|
435
|
+
const start = readme.indexOf(INSTALL_URL_START)
|
|
436
|
+
const end = readme.indexOf(INSTALL_URL_END, start)
|
|
437
|
+
if (start < 0 || end < 0) return readme
|
|
438
|
+
const block = [
|
|
439
|
+
INSTALL_URL_START,
|
|
440
|
+
`**Install URL**, to paste into a Grist custom widget's URL field: <${url}>`,
|
|
441
|
+
INSTALL_URL_END,
|
|
442
|
+
].join("\n")
|
|
443
|
+
return readme.slice(0, start) + block + readme.slice(end + INSTALL_URL_END.length)
|
|
444
|
+
}
|
|
445
|
+
|
|
410
446
|
/**
|
|
411
447
|
* The same four placeholder-identity edits
|
|
412
448
|
* `packages/create-grist-widget/bin/create-grist-widget.mjs` applies at
|
|
@@ -454,7 +490,12 @@ function identityEdits({ packageName, title }) {
|
|
|
454
490
|
* a genuine release already published (firstRelease=false) and skips both
|
|
455
491
|
* the release and this reset, so there's no loop.
|
|
456
492
|
*/
|
|
457
|
-
export function resetBranchesIfFirstRelease(
|
|
493
|
+
export function resetBranchesIfFirstRelease(
|
|
494
|
+
repoDir,
|
|
495
|
+
repo,
|
|
496
|
+
keep = ["main", "dev", "gh-pages"],
|
|
497
|
+
ownerRepo = process.env.GITHUB_REPOSITORY,
|
|
498
|
+
) {
|
|
458
499
|
const changedFiles = new Set()
|
|
459
500
|
const renamedFiles = []
|
|
460
501
|
|
|
@@ -473,6 +514,20 @@ export function resetBranchesIfFirstRelease(repoDir, repo, keep = ["main", "dev"
|
|
|
473
514
|
}
|
|
474
515
|
const identityReset = renamedFiles.length > 0
|
|
475
516
|
|
|
517
|
+
// The install URL, for registries and for whoever reads the README.
|
|
518
|
+
let installUrl = null
|
|
519
|
+
const readmePath = join(repoDir, "README.md")
|
|
520
|
+
const url = latestPagesUrl(ownerRepo)
|
|
521
|
+
if (url && existsSync(readmePath)) {
|
|
522
|
+
const before = readFileSync(readmePath, "utf8")
|
|
523
|
+
const after = withInstallUrl(before, url)
|
|
524
|
+
if (after !== before) {
|
|
525
|
+
writeFileSync(readmePath, after)
|
|
526
|
+
changedFiles.add("README.md")
|
|
527
|
+
installUrl = url
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
476
531
|
let versionReset = false
|
|
477
532
|
const pkgPath = join(repoDir, "package.json")
|
|
478
533
|
if (existsSync(pkgPath)) {
|
|
@@ -490,6 +545,7 @@ export function resetBranchesIfFirstRelease(repoDir, repo, keep = ["main", "dev"
|
|
|
490
545
|
if (changedFiles.size > 0) {
|
|
491
546
|
const parts = []
|
|
492
547
|
if (identityReset) parts.push("personalize scaffold")
|
|
548
|
+
if (installUrl) parts.push("write install URL")
|
|
493
549
|
if (versionReset) parts.push("reset version to 0.0.1")
|
|
494
550
|
git(["add", ...changedFiles], repoDir)
|
|
495
551
|
git(
|
|
@@ -514,7 +570,7 @@ export function resetBranchesIfFirstRelease(repoDir, repo, keep = ["main", "dev"
|
|
|
514
570
|
deletedBranches.push(b)
|
|
515
571
|
}
|
|
516
572
|
git(["push", "--force", "origin", "HEAD:refs/heads/dev"], repoDir)
|
|
517
|
-
return { deletedBranches, devReset: true, versionReset, identityReset, renamedFiles }
|
|
573
|
+
return { deletedBranches, devReset: true, versionReset, identityReset, renamedFiles, installUrl }
|
|
518
574
|
}
|
|
519
575
|
|
|
520
576
|
// ----------------------------------------------------------------------------
|
|
@@ -577,7 +633,12 @@ function main() {
|
|
|
577
633
|
break
|
|
578
634
|
}
|
|
579
635
|
case "reset-branches": {
|
|
580
|
-
const res = resetBranchesIfFirstRelease(
|
|
636
|
+
const res = resetBranchesIfFirstRelease(
|
|
637
|
+
args["repo-dir"] || ".",
|
|
638
|
+
args.repo,
|
|
639
|
+
undefined,
|
|
640
|
+
args["owner-repo"] || process.env.GITHUB_REPOSITORY,
|
|
641
|
+
)
|
|
581
642
|
console.log("reset-branches:", JSON.stringify(res))
|
|
582
643
|
break
|
|
583
644
|
}
|
package/template/src/main.tsx
CHANGED
|
@@ -3,7 +3,6 @@ import { createRoot } from "react-dom/client"
|
|
|
3
3
|
import { GristBoundary, GristWidgetProvider } from "grist-widget-sdk"
|
|
4
4
|
|
|
5
5
|
import { GristSdkAlerts } from "@/components/grist-sdk-alerts"
|
|
6
|
-
import { GristStatusChip } from "@/components/grist-status-chip"
|
|
7
6
|
import { LocalDevNotice } from "@/components/local-dev-notice"
|
|
8
7
|
import { ReleaseInfo } from "@/components/release-info"
|
|
9
8
|
import { ThemeProvider } from "@/components/theme-provider.tsx"
|
|
@@ -34,7 +33,6 @@ createRoot(document.getElementById("root")!).render(
|
|
|
34
33
|
<ThemeProvider>
|
|
35
34
|
{isEmbedded ? (
|
|
36
35
|
<GristWidgetProvider options={GRIST_OPTIONS}>
|
|
37
|
-
<GristStatusChip />
|
|
38
36
|
<GristBoundary
|
|
39
37
|
gate={GRIST_OPTIONS.columns?.length ? "canRender" : "ready"}
|
|
40
38
|
>
|
|
@@ -1,116 +0,0 @@
|
|
|
1
|
-
import { useEffect, useState } from "react"
|
|
2
|
-
import { useAmbientGristHandshake, type GristWidgetSnapshot } from "grist-widget-sdk/advanced"
|
|
3
|
-
|
|
4
|
-
import { deriveDisplayStatus, type HandshakeDisplayStatus } from "@/lib/grist-handshake-status"
|
|
5
|
-
import { cn } from "@/lib/utils"
|
|
6
|
-
|
|
7
|
-
// Fixed, approximate countdown shown while retrying -- not a mirror of the
|
|
8
|
-
// SDK's actual internal poll backoff (an implementation detail, not part of
|
|
9
|
-
// the public API surface, which ramps up from ~200ms before settling into a
|
|
10
|
-
// steady cadence). Most production widgets connect in under a second per the
|
|
11
|
-
// SDK's own docs, so a visibly "retrying" chip is already in that settled
|
|
12
|
-
// long tail -- this just picks the same round number as a display countdown.
|
|
13
|
-
const RETRY_COUNTDOWN_SECONDS = 5
|
|
14
|
-
|
|
15
|
-
function useRetryCountdown(attempts: number, active: boolean) {
|
|
16
|
-
const [state, setState] = useState({ attempts, secondsLeft: RETRY_COUNTDOWN_SECONDS })
|
|
17
|
-
|
|
18
|
-
// Reset the countdown the moment a new retry attempt begins -- an
|
|
19
|
-
// "adjust state during render" pattern, not a setState-in-effect (which
|
|
20
|
-
// the linter flags): https://react.dev/learn/you-might-not-need-an-effect
|
|
21
|
-
if (active && state.attempts !== attempts) {
|
|
22
|
-
setState({ attempts, secondsLeft: RETRY_COUNTDOWN_SECONDS })
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
useEffect(() => {
|
|
26
|
-
if (!active) return
|
|
27
|
-
const id = setInterval(() => {
|
|
28
|
-
setState((s) => ({ ...s, secondsLeft: s.secondsLeft > 0 ? s.secondsLeft - 1 : 0 }))
|
|
29
|
-
}, 1000)
|
|
30
|
-
return () => clearInterval(id)
|
|
31
|
-
}, [attempts, active])
|
|
32
|
-
|
|
33
|
-
return state.secondsLeft
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
function StatusDot({ pulsing, className }: { pulsing: boolean; className: string }) {
|
|
37
|
-
return (
|
|
38
|
-
<span className="relative flex size-2">
|
|
39
|
-
{pulsing && (
|
|
40
|
-
<span
|
|
41
|
-
className={cn(
|
|
42
|
-
"absolute inline-flex size-full animate-ping rounded-full opacity-75",
|
|
43
|
-
className,
|
|
44
|
-
)}
|
|
45
|
-
/>
|
|
46
|
-
)}
|
|
47
|
-
<span className={cn("relative inline-flex size-2 rounded-full", className)} />
|
|
48
|
-
</span>
|
|
49
|
-
)
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
/** Pure render of a display status. */
|
|
53
|
-
function ChipVisual({ display, secondsLeft }: { display: HandshakeDisplayStatus; secondsLeft: number }) {
|
|
54
|
-
switch (display.kind) {
|
|
55
|
-
case "connecting":
|
|
56
|
-
return (
|
|
57
|
-
<>
|
|
58
|
-
<StatusDot pulsing className="bg-amber-500" />
|
|
59
|
-
Connecting to Grist
|
|
60
|
-
</>
|
|
61
|
-
)
|
|
62
|
-
case "retrying":
|
|
63
|
-
return (
|
|
64
|
-
<>
|
|
65
|
-
<StatusDot pulsing className="bg-amber-500" />
|
|
66
|
-
Retry Grist connection in {secondsLeft}s
|
|
67
|
-
</>
|
|
68
|
-
)
|
|
69
|
-
case "connected":
|
|
70
|
-
return (
|
|
71
|
-
<>
|
|
72
|
-
<StatusDot pulsing={false} className="bg-emerald-500" />
|
|
73
|
-
Grist connected
|
|
74
|
-
</>
|
|
75
|
-
)
|
|
76
|
-
case "unavailable":
|
|
77
|
-
return (
|
|
78
|
-
<>
|
|
79
|
-
<StatusDot pulsing={false} className="bg-destructive" />
|
|
80
|
-
Grist unavailable
|
|
81
|
-
</>
|
|
82
|
-
)
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
function RealChipContents({ snapshot }: { snapshot: GristWidgetSnapshot }) {
|
|
87
|
-
const display = deriveDisplayStatus(snapshot.lifecycle)
|
|
88
|
-
const retryAttempts = display.kind === "retrying" ? display.attempts : 0
|
|
89
|
-
const secondsLeft = useRetryCountdown(retryAttempts, display.kind === "retrying")
|
|
90
|
-
return <ChipVisual display={display} secondsLeft={secondsLeft} />
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
/**
|
|
94
|
-
* Small pill showing live Grist handshake status while actually embedded in
|
|
95
|
-
* Grist. Must be mounted *inside* `<GristWidgetProvider>` -- it only observes
|
|
96
|
-
* that provider's own handshake manager (`useAmbientGristHandshake()`,
|
|
97
|
-
* `grist-widget-sdk/advanced`) and never mounts its own, so it can't ever run
|
|
98
|
-
* a second, competing `grist.ready()` call. Renders nothing outside a
|
|
99
|
-
* provider.
|
|
100
|
-
*/
|
|
101
|
-
export function GristStatusChip({ className }: { className?: string }) {
|
|
102
|
-
const handshake = useAmbientGristHandshake()
|
|
103
|
-
if (!handshake) return null
|
|
104
|
-
return (
|
|
105
|
-
<div className="pointer-events-none fixed top-2 right-2 z-50">
|
|
106
|
-
<div
|
|
107
|
-
className={cn(
|
|
108
|
-
"flex items-center gap-1.5 rounded-full border bg-background/90 px-2.5 py-1 text-xs text-muted-foreground shadow-sm backdrop-blur",
|
|
109
|
-
className,
|
|
110
|
-
)}
|
|
111
|
-
>
|
|
112
|
-
<RealChipContents snapshot={handshake.snapshot} />
|
|
113
|
-
</div>
|
|
114
|
-
</div>
|
|
115
|
-
)
|
|
116
|
-
}
|
|
@@ -1,26 +0,0 @@
|
|
|
1
|
-
import type { GristLifecycle } from "grist-widget-sdk/advanced"
|
|
2
|
-
|
|
3
|
-
// Projects the SDK's full GristLifecycle (advanced API, grist-widget-sdk/advanced)
|
|
4
|
-
// down to the handful of states a small status chip needs to show.
|
|
5
|
-
export type HandshakeDisplayStatus =
|
|
6
|
-
| { kind: "connecting" }
|
|
7
|
-
| { kind: "retrying"; attempts: number }
|
|
8
|
-
| { kind: "connected" }
|
|
9
|
-
| { kind: "unavailable" }
|
|
10
|
-
|
|
11
|
-
export function deriveDisplayStatus(lifecycle: GristLifecycle): HandshakeDisplayStatus {
|
|
12
|
-
switch (lifecycle.phase) {
|
|
13
|
-
case "idle":
|
|
14
|
-
return { kind: "connecting" }
|
|
15
|
-
case "detecting":
|
|
16
|
-
return lifecycle.attempts > 0
|
|
17
|
-
? { kind: "retrying", attempts: lifecycle.attempts }
|
|
18
|
-
: { kind: "connecting" }
|
|
19
|
-
case "negotiating":
|
|
20
|
-
return { kind: "connecting" }
|
|
21
|
-
case "online":
|
|
22
|
-
return { kind: "connected" }
|
|
23
|
-
case "terminated":
|
|
24
|
-
return { kind: "unavailable" }
|
|
25
|
-
}
|
|
26
|
-
}
|