create-grist-widget 0.2.13 → 0.2.16

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.13",
3
+ "version": "0.2.16",
4
4
  "description": "Scaffold a new Grist custom widget from the grist-widget-sdk Vite template",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -125,10 +125,14 @@ jobs:
125
125
  if: steps.plan.outputs.skip == 'true'
126
126
  run: echo "== skip — v${{ steps.plan.outputs.version }} already published"
127
127
 
128
- - name: Finalize (commit + push)
128
+ - name: Finalize (versions.json + commit + push)
129
129
  run: |
130
+ UPDATE_VERSIONS=""
131
+ if [ "${{ steps.plan.outputs.context }}" = "release" ]; then
132
+ UPDATE_VERSIONS="--update-versions"
133
+ fi
130
134
  node scripts/deploy.mjs finalize \
131
- --site site --push \
135
+ --site site --repo "$REPO_NAME" $UPDATE_VERSIONS --push \
132
136
  --commit-message "deploy: ${{ steps.plan.outputs.context }} @ ${{ github.sha }}"
133
137
 
134
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,19 @@ 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
+ > Also **never manually seed/copy `gh-pages` content** from another repo
95
+ > (e.g. to get Pages showing something immediately) — each `v<version>/`
96
+ > is only recognized as a genuine prior release if it carries a
97
+ > `showcase-meta.json` naming *this* repo (written by this pipeline's own
98
+ > `place` step); anything else is treated as unpublished and safely
99
+ > rebuilt over, but it's simpler to just let the workflow create
100
+ > `gh-pages` itself (it does, as an orphan branch, on its first real run).
101
+
89
102
  **One-time setup** (the workflow can't do this part for you):
90
103
 
91
104
  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",
@@ -8,6 +8,8 @@
8
8
  // release (push to main / workflow_dispatch)
9
9
  // - immutable /<repo>/v<version>/ (built once, never overwritten)
10
10
  // - mutable /<repo>/latest/ (alias to newest release)
11
+ // - /<repo>/versions.json (released-version index, newest first
12
+ // -- read by the showcase hub/notice)
11
13
  //
12
14
  // dev (push to dev)
13
15
  // - mutable /<repo>/dev/ (+ version.json + self-reload)
@@ -30,10 +32,19 @@
30
32
  // Removes dev/ from the gh-pages tree (retire the dev URL when the `dev`
31
33
  // branch is deleted). Never touches a release or latest/.
32
34
  //
33
- // finalize --site <dir> [--push] [--commit-message <msg>]
34
- // Commits + pushes the gh-pages tree with a rebase-and-retry loop.
35
+ // finalize --site <dir> [--update-versions] [--push] [--commit-message <msg>]
36
+ // Regenerates versions.json (release runs only) and commits + pushes the
37
+ // gh-pages tree with a rebase-and-retry loop.
35
38
 
36
- import { existsSync, readFileSync, writeFileSync, rmSync, mkdirSync, cpSync } from "node:fs"
39
+ import {
40
+ existsSync,
41
+ readFileSync,
42
+ readdirSync,
43
+ writeFileSync,
44
+ rmSync,
45
+ mkdirSync,
46
+ cpSync,
47
+ } from "node:fs"
37
48
  import { join, dirname } from "node:path"
38
49
  import { execFileSync } from "node:child_process"
39
50
 
@@ -47,10 +58,33 @@ export function basePathFor(repo, channel, version) {
47
58
  return `/${repo}/${leaf}/`
48
59
  }
49
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
+
50
83
  /**
51
84
  * Build the release/dev plan from this repo's own package.json + the current
52
- * gh-pages tree. Release is skipped when its immutable v<version> dir already
53
- * 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.
54
88
  */
55
89
  export function plan(siteDir, repo, event, ref, { force = false } = {}) {
56
90
  const isRelease = event === "workflow_dispatch" || ref === "refs/heads/main" || ref === "main"
@@ -61,7 +95,7 @@ export function plan(siteDir, repo, event, ref, { force = false } = {}) {
61
95
  const version = pkg.version
62
96
  if (!version) throw new Error("package.json has no version")
63
97
  const versionDir = join(siteDir, `v${version}`)
64
- const exists = existsSync(versionDir)
98
+ const exists = isGenuineRelease(versionDir, repo)
65
99
  return {
66
100
  context: "release",
67
101
  version,
@@ -107,6 +141,32 @@ export function injectSelfReload(html, sha) {
107
141
  return out + snippet
108
142
  }
109
143
 
144
+ /**
145
+ * Scan siteDir for previously-released v<version>/showcase-meta.json files
146
+ * and assemble the versions.json manifest, newest first. Read by the
147
+ * showcase hub (TemplateLanding) and per-channel notice (ChannelNotice) to
148
+ * show this widget's own release history -- not the grist-widget-sdk
149
+ * monorepo's (found live: every scaffolded widget's version index showed
150
+ * the monorepo's versions instead of its own, since showcase-versions.ts
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.
155
+ */
156
+ export function buildVersionsManifest(siteDir, repo) {
157
+ const entries = []
158
+ for (const name of existsSync(siteDir) ? readdirSync(siteDir) : []) {
159
+ if (!/^v\d+\.\d+\.\d+/.test(name)) continue
160
+ const metaPath = join(siteDir, name, "showcase-meta.json")
161
+ if (!existsSync(metaPath)) continue
162
+ const meta = JSON.parse(readFileSync(metaPath, "utf8"))
163
+ if (meta.repo !== repo) continue
164
+ entries.push({ version: meta.version, publishedAt: meta.publishedAt })
165
+ }
166
+ entries.sort((a, b) => new Date(b.publishedAt) - new Date(a.publishedAt))
167
+ return entries
168
+ }
169
+
110
170
  // ----------------------------------------------------------------------------
111
171
  // Filesystem / git effects
112
172
  // ----------------------------------------------------------------------------
@@ -118,7 +178,7 @@ function replaceDir(dest, srcDist) {
118
178
  }
119
179
 
120
180
  /** Place a freshly built dist into the gh-pages tree. */
121
- export function placeTarget({ siteDir, channel, version, distDir, sha, ref }) {
181
+ export function placeTarget({ siteDir, channel, version, distDir, sha, ref, repo }) {
122
182
  if (!existsSync(distDir)) throw new Error(`dist not found: ${distDir}`)
123
183
  if (channel === "dev") {
124
184
  const devDir = join(siteDir, "dev")
@@ -144,6 +204,10 @@ export function placeTarget({ siteDir, channel, version, distDir, sha, ref }) {
144
204
  // so nothing 404s.
145
205
  const versionDir = join(siteDir, `v${version}`)
146
206
  replaceDir(versionDir, distDir)
207
+ writeFileSync(
208
+ join(versionDir, "showcase-meta.json"),
209
+ JSON.stringify({ version, publishedAt: new Date().toISOString(), sha, repo }, null, 2) + "\n",
210
+ )
147
211
  const latestDir = join(siteDir, "latest")
148
212
  replaceDir(latestDir, distDir)
149
213
  mkdirSync(siteDir, { recursive: true })
@@ -173,8 +237,12 @@ function sleep(ms) {
173
237
  while (Date.now() < end) {}
174
238
  }
175
239
 
176
- /** Commit + push the gh-pages tree with a rebase-and-retry loop. */
177
- export function finalize({ siteDir, push, commitMessage, remoteBranch = "gh-pages" }) {
240
+ /** Regenerate versions.json (release runs only), then commit + push with rebase-retry. */
241
+ export function finalize({ siteDir, updateVersions, push, commitMessage, remoteBranch = "gh-pages", repo }) {
242
+ if (updateVersions) {
243
+ const versions = buildVersionsManifest(siteDir, repo)
244
+ writeFileSync(join(siteDir, "versions.json"), JSON.stringify(versions, null, 2) + "\n")
245
+ }
178
246
  git(["add", "-A"], siteDir)
179
247
  const status = git(["status", "--porcelain"], siteDir)
180
248
  if (!status) {
@@ -241,6 +309,7 @@ function main() {
241
309
  distDir: args.dist,
242
310
  sha: args.sha,
243
311
  ref: args.ref,
312
+ repo: args.repo,
244
313
  })
245
314
  console.log("placed:", JSON.stringify(res))
246
315
  break
@@ -253,8 +322,10 @@ function main() {
253
322
  case "finalize": {
254
323
  const res = finalize({
255
324
  siteDir: args.site,
325
+ updateVersions: !!args["update-versions"],
256
326
  push: !!args.push,
257
327
  commitMessage: args["commit-message"],
328
+ repo: args.repo,
258
329
  })
259
330
  console.log("finalize:", JSON.stringify(res))
260
331
  break
@@ -10,7 +10,7 @@ import {
10
10
  } from "@/components/ui/card"
11
11
  import { Input } from "@/components/ui/input"
12
12
  import { channelLabel, type Channel } from "@/lib/showcase-routing"
13
- import { DEV_URL, useVersions, versionUrl } from "@/lib/showcase-versions"
13
+ import { devUrl, useVersions, versionUrl } from "@/lib/showcase-versions"
14
14
  import { cn } from "@/lib/utils"
15
15
 
16
16
  // Shown instead of the widget tree on a specific channel build
@@ -44,7 +44,7 @@ function VersionChips({ current }: { current: Channel }) {
44
44
  latest
45
45
  </a>
46
46
  )}
47
- <a href={DEV_URL} className={chipClass(current.kind === "dev")}>
47
+ <a href={devUrl()} className={chipClass(current.kind === "dev")}>
48
48
  dev
49
49
  </a>
50
50
  {versions?.map((v) => (
@@ -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
  }
@@ -2,7 +2,7 @@ import { useState } from "react"
2
2
 
3
3
  import { Button } from "@/components/ui/button"
4
4
  import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
5
- import { DEV_URL, formatDate, useVersions, versionUrl } from "@/lib/showcase-versions"
5
+ import { devUrl, formatDate, useVersions, versionUrl } from "@/lib/showcase-versions"
6
6
 
7
7
  // The showcase "hub" page: rendered whenever this deploy's URL has no
8
8
  // recognized channel suffix (see src/lib/showcase-routing.ts) -- this repo's
@@ -178,7 +178,7 @@ git push origin dev`}</code>
178
178
  Released template versions
179
179
  </h2>
180
180
  <a
181
- href={DEV_URL}
181
+ href={devUrl()}
182
182
  className="text-sm text-muted-foreground underline underline-offset-4 hover:text-foreground"
183
183
  >
184
184
  Live dev preview →
@@ -1,21 +1,35 @@
1
1
  import { useEffect, useState } from "react"
2
2
 
3
+ import { parseShowcasePath } from "@/lib/showcase-routing"
4
+
3
5
  // Shared by the showcase hub (TemplateLanding) and the per-channel notice
4
- // (ChannelNotice) -- both need the same released-version list, fetched from
5
- // a fixed absolute URL rather than a path relative to wherever this happens
6
- // to be deployed (see src/components/template-landing.tsx for why).
7
- export const VERSIONS_URL =
8
- "https://arthurblanchon.github.io/grist-widget-sdk/template/versions.json"
9
- export const DEV_URL = "https://arthurblanchon.github.io/grist-widget-sdk/template/dev/"
10
- export const HUB_URL = "https://arthurblanchon.github.io/grist-widget-sdk/template/"
6
+ // (ChannelNotice) -- both need the same released-version list. URLs are
7
+ // root-relative to wherever THIS deploy actually lives (derived from
8
+ // window.location.pathname via parseShowcasePath's hubPath), not hardcoded
9
+ // to the grist-widget-sdk monorepo's own showcase: a real scaffolded
10
+ // widget has its own versions.json and its own v<version>/ + dev/ dirs,
11
+ // and previously showed the monorepo's version history and links instead
12
+ // of its own (found live on a real scaffold's landing page).
13
+ function hubPath(): string {
14
+ const path = parseShowcasePath(window.location.pathname).hubPath
15
+ return path.endsWith("/") ? path : `${path}/`
16
+ }
11
17
 
12
18
  export type ReleasedVersion = {
13
19
  version: string
14
20
  publishedAt: string
15
21
  }
16
22
 
23
+ export function versionsUrl(): string {
24
+ return `${hubPath()}versions.json`
25
+ }
26
+
27
+ export function devUrl(): string {
28
+ return `${hubPath()}dev/`
29
+ }
30
+
17
31
  export function versionUrl(version: string): string {
18
- return `https://arthurblanchon.github.io/grist-widget-sdk/template/v${version}/`
32
+ return `${hubPath()}v${version}/`
19
33
  }
20
34
 
21
35
  export function useVersions() {
@@ -24,7 +38,7 @@ export function useVersions() {
24
38
 
25
39
  useEffect(() => {
26
40
  let cancelled = false
27
- fetch(VERSIONS_URL, { cache: "no-store" })
41
+ fetch(versionsUrl(), { cache: "no-store" })
28
42
  .then((res) => (res.ok ? res.json() : Promise.reject(res)))
29
43
  .then((data: ReleasedVersion[]) => {
30
44
  if (!cancelled) setVersions(data)
@@ -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} />