create-grist-widget 0.2.14 → 0.2.17

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-grist-widget",
3
- "version": "0.2.14",
3
+ "version": "0.2.17",
4
4
  "description": "Scaffold a new Grist custom widget from the grist-widget-sdk Vite template",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -132,7 +132,7 @@ jobs:
132
132
  UPDATE_VERSIONS="--update-versions"
133
133
  fi
134
134
  node scripts/deploy.mjs finalize \
135
- --site site $UPDATE_VERSIONS --push \
135
+ --site site --repo "$REPO_NAME" $UPDATE_VERSIONS --push \
136
136
  --commit-message "deploy: ${{ steps.plan.outputs.context }} @ ${{ github.sha }}"
137
137
 
138
138
  cleanup:
@@ -6,7 +6,7 @@ Base template to build a Grist widget with `grist-widget-sdk`.
6
6
 
7
7
  Opened outside a Grist iframe, `main.tsx` picks between two components purely by URL shape (`src/lib/showcase-routing.ts`, no router needed): a bare path with no recognized channel suffix renders `TemplateLanding` (its own hero, onboarding, and released-version index); `/latest/`, `/dev/`, or `/v<version>/` renders `ChannelNotice` instead — a distinct hero explaining which build this is, chips to jump to any other version/channel (`src/lib/showcase-versions.ts`), and a copy-this-URL helper for pasting into Grist's custom widget field.
8
8
 
9
- When actually embedded, `GristStatusChip` (`src/components/grist-status-chip.tsx`) shows a small pill with live handshake status — connecting, retrying with a countdown, connected, or unavailable — using `useGristHandshakeContext()` from `grist-widget-sdk/advanced` in its own `<GristHandshakeProvider>`. Safe to mount alongside `GristWidgetProvider`: both share the page's `ensureGristReady()` singleton (see `apps/docs/api/handshake.md`), so this is purely observational and never duplicates the real handshake.
9
+ When actually embedded, `GristStatusChip` (`src/components/grist-status-chip.tsx`) shows a small pill with live handshake status — connecting, retrying with a countdown, connected, or unavailable — using `useAmbientGristHandshake()` from `grist-widget-sdk/advanced`. It must be mounted *inside* `GristWidgetProvider` (see `src/main.tsx`): it only observes that provider's own handshake manager and never mounts a second one, so it's purely observational and can never duplicate or race the real handshake (see `apps/docs/api/handshake.md`).
10
10
 
11
11
  `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`. See `src/grist-types.example.ts` for typing patterns.
12
12
 
@@ -86,6 +86,22 @@ of changes — it's the permanent working/preview branch for this widget, not
86
86
  a one-off feature branch to delete and recreate. Deleting it retires `/dev/`
87
87
  automatically.
88
88
 
89
+ > ⚠️ **Used GitHub's "Use this template" button instead of `npm create
90
+ > grist-widget`?** That path copies this repo's default branch verbatim —
91
+ > it doesn't run any of the CLI's own setup (renaming `package.json`,
92
+ > titles, etc.), and it won't reset `package.json`'s `version` back to a
93
+ > fresh `0.0.1` for you. Do both by hand before your first real release.
94
+ > If you also checked **"Include all branches"** (to get `gh-pages`/Pages
95
+ > already set up, no manual Settings step), your first real release
96
+ > automatically clears out every leftover `v<version>/` it finds — a
97
+ > `v<version>/` only counts as a genuine prior release once it carries a
98
+ > `showcase-meta.json` naming *this* repo (written by this pipeline's own
99
+ > `place` step), so on a repo with no genuine releases yet, everything
100
+ > else is provably inherited noise and gets safely wiped on your first
101
+ > push to `main`. That cleanup only ever runs before your *first* genuine
102
+ > release — once one exists, it's permanently a no-op, so it's still safest
103
+ > to never manually re-seed `gh-pages` again after that point.
104
+
89
105
  **One-time setup** (the workflow can't do this part for you):
90
106
 
91
107
  1. **Settings → Pages** → Source: "Deploy from a branch" → branch `gh-pages`
@@ -13,7 +13,7 @@
13
13
  },
14
14
  "dependencies": {
15
15
  "@fontsource-variable/geist": "^5.2.8",
16
- "grist-widget-sdk": "^0.2.1",
16
+ "grist-widget-sdk": "^0.2.15",
17
17
  "@tailwindcss/vite": "^4.2.1",
18
18
  "class-variance-authority": "^0.7.1",
19
19
  "clsx": "^2.1.1",
@@ -58,10 +58,33 @@ export function basePathFor(repo, channel, version) {
58
58
  return `/${repo}/${leaf}/`
59
59
  }
60
60
 
61
+ /**
62
+ * Was `versionDir` actually published by *this* repo's own pipeline? Checks
63
+ * for a `showcase-meta.json` whose `repo` field matches -- not just that the
64
+ * directory exists. A `gh-pages` branch manually seeded from another repo's
65
+ * export (found live: a freshly-created widget repo's `gh-pages` already
66
+ * contained `v<version>/` directories copied from the template repo it came
67
+ * from, some with no meta file at all, one with a foreign `showcase-meta.json`
68
+ * whose `sha` didn't belong to this repo's own history) would otherwise look
69
+ * indistinguishable from a real prior release and get silently treated as
70
+ * "already published" forever, permanently squatting on that version path.
71
+ */
72
+ function isGenuineRelease(versionDir, repo) {
73
+ const metaPath = join(versionDir, "showcase-meta.json")
74
+ if (!existsSync(metaPath)) return false
75
+ try {
76
+ const meta = JSON.parse(readFileSync(metaPath, "utf8"))
77
+ return meta.repo === repo
78
+ } catch {
79
+ return false
80
+ }
81
+ }
82
+
61
83
  /**
62
84
  * Build the release/dev plan from this repo's own package.json + the current
63
- * gh-pages tree. Release is skipped when its immutable v<version> dir already
64
- * exists (unless force), so re-pushing main without a version bump is a no-op.
85
+ * gh-pages tree. Release is skipped when its immutable v<version> dir was
86
+ * genuinely published by this repo before (unless force), so re-pushing main
87
+ * without a version bump is a no-op.
65
88
  */
66
89
  export function plan(siteDir, repo, event, ref, { force = false } = {}) {
67
90
  const isRelease = event === "workflow_dispatch" || ref === "refs/heads/main" || ref === "main"
@@ -72,7 +95,7 @@ export function plan(siteDir, repo, event, ref, { force = false } = {}) {
72
95
  const version = pkg.version
73
96
  if (!version) throw new Error("package.json has no version")
74
97
  const versionDir = join(siteDir, `v${version}`)
75
- const exists = existsSync(versionDir)
98
+ const exists = isGenuineRelease(versionDir, repo)
76
99
  return {
77
100
  context: "release",
78
101
  version,
@@ -125,15 +148,19 @@ export function injectSelfReload(html, sha) {
125
148
  * show this widget's own release history -- not the grist-widget-sdk
126
149
  * monorepo's (found live: every scaffolded widget's version index showed
127
150
  * the monorepo's versions instead of its own, since showcase-versions.ts
128
- * used to hardcode that URL).
151
+ * used to hardcode that URL). Entries whose meta has no `repo` field, or a
152
+ * `repo` that doesn't match this repo, are skipped -- a `gh-pages` branch
153
+ * manually seeded from another repo's export (found live) would otherwise
154
+ * leak that repo's own version history into this one's index.
129
155
  */
130
- export function buildVersionsManifest(siteDir) {
156
+ export function buildVersionsManifest(siteDir, repo) {
131
157
  const entries = []
132
158
  for (const name of existsSync(siteDir) ? readdirSync(siteDir) : []) {
133
159
  if (!/^v\d+\.\d+\.\d+/.test(name)) continue
134
160
  const metaPath = join(siteDir, name, "showcase-meta.json")
135
161
  if (!existsSync(metaPath)) continue
136
162
  const meta = JSON.parse(readFileSync(metaPath, "utf8"))
163
+ if (meta.repo !== repo) continue
137
164
  entries.push({ version: meta.version, publishedAt: meta.publishedAt })
138
165
  }
139
166
  entries.sort((a, b) => new Date(b.publishedAt) - new Date(a.publishedAt))
@@ -150,8 +177,31 @@ function replaceDir(dest, srcDist) {
150
177
  cpSync(srcDist, dest, { recursive: true })
151
178
  }
152
179
 
180
+ /**
181
+ * If this repo has never genuinely published a release before (no
182
+ * v<version>/ directory whose showcase-meta.json matches this repo), any
183
+ * v<version>/ directories present are provably inherited noise -- e.g. from
184
+ * copying this template repo including its gh-pages branch (GitHub's "Use
185
+ * this template" -> "Include all branches"). Safe to clear them all before
186
+ * placing this repo's actual first release: there is no ambiguity, since a
187
+ * repo with zero genuine releases can't have any of its own history to lose.
188
+ * Never touches dev/, latest/, or site-root files. Once a genuine release
189
+ * exists for this repo, buildVersionsManifest is non-empty and this is
190
+ * permanently a no-op.
191
+ */
192
+ export function cleanupForeignVersionsIfFirstRelease(siteDir, repo) {
193
+ if (buildVersionsManifest(siteDir, repo).length > 0) return { cleaned: [] }
194
+ const cleaned = []
195
+ for (const name of existsSync(siteDir) ? readdirSync(siteDir) : []) {
196
+ if (!/^v\d+\.\d+\.\d+/.test(name)) continue
197
+ rmSync(join(siteDir, name), { recursive: true, force: true })
198
+ cleaned.push(name)
199
+ }
200
+ return { cleaned }
201
+ }
202
+
153
203
  /** Place a freshly built dist into the gh-pages tree. */
154
- export function placeTarget({ siteDir, channel, version, distDir, sha, ref }) {
204
+ export function placeTarget({ siteDir, channel, version, distDir, sha, ref, repo }) {
155
205
  if (!existsSync(distDir)) throw new Error(`dist not found: ${distDir}`)
156
206
  if (channel === "dev") {
157
207
  const devDir = join(siteDir, "dev")
@@ -175,11 +225,12 @@ export function placeTarget({ siteDir, channel, version, distDir, sha, ref }) {
175
225
  // exactly like latest/ reusing it: the asset URLs it references (under
176
226
  // v<version>/assets/) already exist from the versionDir placement above,
177
227
  // so nothing 404s.
228
+ cleanupForeignVersionsIfFirstRelease(siteDir, repo)
178
229
  const versionDir = join(siteDir, `v${version}`)
179
230
  replaceDir(versionDir, distDir)
180
231
  writeFileSync(
181
232
  join(versionDir, "showcase-meta.json"),
182
- JSON.stringify({ version, publishedAt: new Date().toISOString(), sha }, null, 2) + "\n",
233
+ JSON.stringify({ version, publishedAt: new Date().toISOString(), sha, repo }, null, 2) + "\n",
183
234
  )
184
235
  const latestDir = join(siteDir, "latest")
185
236
  replaceDir(latestDir, distDir)
@@ -211,9 +262,9 @@ function sleep(ms) {
211
262
  }
212
263
 
213
264
  /** Regenerate versions.json (release runs only), then commit + push with rebase-retry. */
214
- export function finalize({ siteDir, updateVersions, push, commitMessage, remoteBranch = "gh-pages" }) {
265
+ export function finalize({ siteDir, updateVersions, push, commitMessage, remoteBranch = "gh-pages", repo }) {
215
266
  if (updateVersions) {
216
- const versions = buildVersionsManifest(siteDir)
267
+ const versions = buildVersionsManifest(siteDir, repo)
217
268
  writeFileSync(join(siteDir, "versions.json"), JSON.stringify(versions, null, 2) + "\n")
218
269
  }
219
270
  git(["add", "-A"], siteDir)
@@ -282,6 +333,7 @@ function main() {
282
333
  distDir: args.dist,
283
334
  sha: args.sha,
284
335
  ref: args.ref,
336
+ repo: args.repo,
285
337
  })
286
338
  console.log("placed:", JSON.stringify(res))
287
339
  break
@@ -297,6 +349,7 @@ function main() {
297
349
  updateVersions: !!args["update-versions"],
298
350
  push: !!args.push,
299
351
  commitMessage: args["commit-message"],
352
+ repo: args.repo,
300
353
  })
301
354
  console.log("finalize:", JSON.stringify(res))
302
355
  break
@@ -1,5 +1,5 @@
1
1
  import { useEffect, useState } from "react"
2
- import { GristHandshakeProvider, useGristHandshakeContext } from "grist-widget-sdk/advanced"
2
+ import { useAmbientGristHandshake, type GristWidgetSnapshot } from "grist-widget-sdk/advanced"
3
3
 
4
4
  import { deriveDisplayStatus, type HandshakeDisplayStatus } from "@/lib/grist-handshake-status"
5
5
  import { cn } from "@/lib/utils"
@@ -83,8 +83,7 @@ function ChipVisual({ display, secondsLeft }: { display: HandshakeDisplayStatus;
83
83
  }
84
84
  }
85
85
 
86
- function RealChipContents() {
87
- const { snapshot } = useGristHandshakeContext()
86
+ function RealChipContents({ snapshot }: { snapshot: GristWidgetSnapshot }) {
88
87
  const display = deriveDisplayStatus(snapshot.lifecycle)
89
88
  const retryAttempts = display.kind === "retrying" ? display.attempts : 0
90
89
  const secondsLeft = useRetryCountdown(retryAttempts, display.kind === "retrying")
@@ -93,25 +92,25 @@ function RealChipContents() {
93
92
 
94
93
  /**
95
94
  * Small pill showing live Grist handshake status while actually embedded in
96
- * Grist. Mounts its own `<GristHandshakeProvider>` alongside
97
- * `<GristWidgetProvider>` -- documented (grist-widget-sdk/advanced) as safe
98
- * to coexist, since both share the page's `ensureGristReady()` singleton.
99
- * Purely observational: it never drives the real widget's connection, just
100
- * reflects it.
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.
101
100
  */
102
101
  export function GristStatusChip({ className }: { className?: string }) {
102
+ const handshake = useAmbientGristHandshake()
103
+ if (!handshake) return null
103
104
  return (
104
- <GristHandshakeProvider>
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 />
113
- </div>
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} />
114
113
  </div>
115
- </GristHandshakeProvider>
114
+ </div>
116
115
  )
117
116
  }
@@ -35,20 +35,18 @@ createRoot(document.getElementById("root")!).render(
35
35
  <TemplateLanding />
36
36
  </div>
37
37
  ) : isEmbedded ? (
38
- <>
38
+ <GristWidgetProvider options={GRIST_OPTIONS}>
39
39
  <GristStatusChip />
40
- <GristWidgetProvider options={GRIST_OPTIONS}>
41
- <GristBoundary
42
- gate={GRIST_OPTIONS.columns?.length ? "canRender" : "ready"}
43
- >
44
- <div className="min-h-full w-full bg-background text-foreground">
45
- <GristSdkAlerts>
46
- <App />
47
- </GristSdkAlerts>
48
- </div>
49
- </GristBoundary>
50
- </GristWidgetProvider>
51
- </>
40
+ <GristBoundary
41
+ gate={GRIST_OPTIONS.columns?.length ? "canRender" : "ready"}
42
+ >
43
+ <div className="min-h-full w-full bg-background text-foreground">
44
+ <GristSdkAlerts>
45
+ <App />
46
+ </GristSdkAlerts>
47
+ </div>
48
+ </GristBoundary>
49
+ </GristWidgetProvider>
52
50
  ) : (
53
51
  <div className="min-h-full w-full bg-background text-foreground">
54
52
  <ChannelNotice channel={channel} hubPath={hubPath} />