create-grist-widget 0.3.22 → 0.3.23
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/bin/create-grist-widget.mjs +35 -8
- package/package.json +1 -1
- package/src/age-exceptions.mjs +61 -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 +14 -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
|
@@ -31,6 +31,7 @@ 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"
|
|
34
35
|
|
|
35
36
|
const PKG_ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), "..")
|
|
36
37
|
const TEMPLATE_DIR = path.join(PKG_ROOT, "template")
|
|
@@ -180,6 +181,33 @@ function maybeInstall(target, { noInstall, pm }) {
|
|
|
180
181
|
}
|
|
181
182
|
}
|
|
182
183
|
|
|
184
|
+
/**
|
|
185
|
+
* Install (unless --no-install), returning which `minimumReleaseAgeExclude`
|
|
186
|
+
* entries pnpm added to pnpm-workspace.yaml along the way. Runs before the
|
|
187
|
+
* first commit, so the lockfile -- and those entries, without which every
|
|
188
|
+
* later install rejects that lockfile -- are part of it.
|
|
189
|
+
*/
|
|
190
|
+
function installBeforeFirstCommit(target, args) {
|
|
191
|
+
const workspaceFile = path.join(target, "pnpm-workspace.yaml")
|
|
192
|
+
const read = () => (existsSync(workspaceFile) ? readFileSync(workspaceFile, "utf8") : "")
|
|
193
|
+
const before = read()
|
|
194
|
+
const installed = maybeInstall(target, {
|
|
195
|
+
noInstall: Boolean(args["no-install"]),
|
|
196
|
+
pm: typeof args.pm === "string" ? args.pm : null,
|
|
197
|
+
})
|
|
198
|
+
return { installed, ageExceptions: installed ? addedAgeExceptions(before, read()) : [] }
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function printAgeExceptionsNote(ageExceptions) {
|
|
202
|
+
if (ageExceptions.length === 0) return
|
|
203
|
+
console.log(
|
|
204
|
+
`\nNote: ${ageExceptions.join(", ")} came out less than 24 hours ago. pnpm only installs versions` +
|
|
205
|
+
"\nthat recent when told to, so it listed them under minimumReleaseAgeExclude in pnpm-workspace.yaml." +
|
|
206
|
+
"\nThat entry is committed: without it, every later install (yours and CI's) rejects the lockfile." +
|
|
207
|
+
"\nIt only covers the SDK released together with this CLI, and is moot once the version is a day old."
|
|
208
|
+
)
|
|
209
|
+
}
|
|
210
|
+
|
|
183
211
|
function listFilesRecursive(dir, base = dir) {
|
|
184
212
|
const out = []
|
|
185
213
|
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
@@ -282,12 +310,12 @@ async function cmdCreate(argv) {
|
|
|
282
310
|
|
|
283
311
|
stampCreateGristWidgetVersion(target)
|
|
284
312
|
|
|
313
|
+
// Install first, so the first commit includes the lockfile (pinned, with
|
|
314
|
+
// integrity hashes; pnpm then freezes installs in CI by itself).
|
|
315
|
+
const { installed, ageExceptions } = installBeforeFirstCommit(target, args)
|
|
285
316
|
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
317
|
|
|
318
|
+
printAgeExceptionsNote(ageExceptions)
|
|
291
319
|
console.log(`\nCreated ${rawName}/${exampleId ? ` (starting from "${exampleId}")` : ""}\n`)
|
|
292
320
|
console.log(` cd ${rawName}`)
|
|
293
321
|
if (!installed) console.log(" pnpm install # or npm/yarn")
|
|
@@ -384,12 +412,11 @@ async function cmdInit(argv) {
|
|
|
384
412
|
// git entirely when cwd had none to begin with (a fresh, non-git directory,
|
|
385
413
|
// or a real git clone where .git already carries whatever history it has).
|
|
386
414
|
const gitAlreadyExists = existsSync(path.join(cwd, ".git"))
|
|
415
|
+
// Install first, so a first commit includes the lockfile (see cmdCreate).
|
|
416
|
+
const { installed, ageExceptions } = installBeforeFirstCommit(cwd, args)
|
|
387
417
|
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
418
|
|
|
419
|
+
printAgeExceptionsNote(ageExceptions)
|
|
393
420
|
console.log(`\nInitialized ${rawName}/${exampleId ? ` (starting from "${exampleId}")` : ""} in the current directory.\n`)
|
|
394
421
|
if (!installed) console.log(" pnpm install # or npm/yarn")
|
|
395
422
|
console.log(" pnpm dev")
|
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
|
+
}
|
|
@@ -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,15 @@ 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` (unless it was made with
|
|
42
|
+
`--no-install`): versions are pinned with their integrity hashes, CI installs
|
|
43
|
+
exactly that, and Dependabot updates it. pnpm 11 won't install a version
|
|
44
|
+
published less than 24 hours ago. So a widget scaffolded on the day of an SDK
|
|
45
|
+
release also gets one `minimumReleaseAgeExclude` entry in
|
|
46
|
+
`pnpm-workspace.yaml`, for that SDK version only, written by pnpm itself.
|
|
47
|
+
Keep it: without it, every install (yours and CI's) rejects the lockfile
|
|
48
|
+
until the version is a day old. After that the entry is harmless.
|
|
49
|
+
|
|
37
50
|
### Live preview
|
|
38
51
|
|
|
39
52
|
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
|
-
}
|