create-grist-widget 0.2.12 → 0.2.14

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 CHANGED
@@ -46,6 +46,11 @@ simplest way to get from zero to a deployed widget:
46
46
  folder `/ (root)`. This can only be set once `gh-pages` exists, which is
47
47
  why it's a step *after* the first push, not before.
48
48
 
49
+ > ⚠️ **Not `main`.** Pointing Pages at `main` serves this repo's raw,
50
+ > unbuilt source instead of the built site — the symptom is a blank page
51
+ > with a `/src/main.tsx` 404 in the browser console, even though the
52
+ > workflow itself reports success.
53
+
49
54
  Once that's saved, your widget is live at two URLs:
50
55
 
51
56
  - `https://<owner>.github.io/<repo>/latest/` — mutable, always points at
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-grist-widget",
3
- "version": "0.2.12",
3
+ "version": "0.2.14",
4
4
  "description": "Scaffold a new Grist custom widget from the grist-widget-sdk Vite template",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -13,8 +13,11 @@ name: Deploy widget to GitHub Pages
13
13
  # One-time setup this workflow can't do for you: enable Pages (Settings ->
14
14
  # Pages -> Source: Deploy from a branch -> gh-pages -> / (root); this workflow
15
15
  # creates the gh-pages branch itself if missing, but Pages must be pointed at
16
- # it once) and make sure Actions has write access (Settings -> Actions ->
17
- # General -> Workflow permissions -> Read and write permissions).
16
+ # it once -- NOT main, which serves this repo's raw unbuilt source instead
17
+ # (symptom: a blank page with a 404 for /src/main.tsx in the browser
18
+ # console, even though this workflow reports success) and make sure Actions
19
+ # has write access (Settings -> Actions -> General -> Workflow permissions ->
20
+ # Read and write permissions).
18
21
 
19
22
  on:
20
23
  push:
@@ -56,10 +59,16 @@ jobs:
56
59
  uses: actions/setup-node@v4
57
60
  with:
58
61
  node-version: 22
59
- cache: pnpm
60
62
 
63
+ # Not --frozen-lockfile, and no `cache: pnpm` above: a freshly
64
+ # scaffolded repo never has a committed pnpm-lock.yaml (the CLI's
65
+ # auto-commit runs before anyone's first `pnpm install`), and
66
+ # --frozen-lockfile/cache: pnpm both hard-require one to exist —
67
+ # found live, the very first real deploy failed with "Dependencies
68
+ # lock file is not found". A plain install resolves fresh every run
69
+ # regardless of whether a lockfile was ever committed.
61
70
  - name: Install dependencies
62
- run: pnpm install --frozen-lockfile
71
+ run: pnpm install
63
72
 
64
73
  - name: Clone gh-pages into ./site
65
74
  run: |
@@ -116,10 +125,14 @@ jobs:
116
125
  if: steps.plan.outputs.skip == 'true'
117
126
  run: echo "== skip — v${{ steps.plan.outputs.version }} already published"
118
127
 
119
- - name: Finalize (commit + push)
128
+ - name: Finalize (versions.json + commit + push)
120
129
  run: |
130
+ UPDATE_VERSIONS=""
131
+ if [ "${{ steps.plan.outputs.context }}" = "release" ]; then
132
+ UPDATE_VERSIONS="--update-versions"
133
+ fi
121
134
  node scripts/deploy.mjs finalize \
122
- --site site --push \
135
+ --site site $UPDATE_VERSIONS --push \
123
136
  --commit-message "deploy: ${{ steps.plan.outputs.context }} @ ${{ github.sha }}"
124
137
 
125
138
  cleanup:
@@ -92,6 +92,12 @@ automatically.
92
92
  → `/ (root)`. The workflow creates the `gh-pages` branch itself the first
93
93
  time it runs (if it doesn't exist yet), but Pages needs to be pointed at
94
94
  it once.
95
+ > ⚠️ **Not `main`.** If Pages is left on (or accidentally set to) `main`,
96
+ > it serves this repo's raw, unbuilt source — including a script tag
97
+ > pointing at `/src/main.tsx` — instead of the built site. The symptom is
98
+ > a blank/black page with a 404 for `/src/main.tsx` in the browser
99
+ > console, even though the workflow itself reports success (it pushed the
100
+ > right build to `gh-pages`; Pages is just reading from the wrong place).
95
101
  2. **Settings → Actions → General → Workflow permissions** → "Read and write
96
102
  permissions". New repos sometimes default the workflow's token to
97
103
  read-only, which would fail the push to `gh-pages` with a 403.
@@ -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
 
@@ -107,6 +118,28 @@ export function injectSelfReload(html, sha) {
107
118
  return out + snippet
108
119
  }
109
120
 
121
+ /**
122
+ * Scan siteDir for previously-released v<version>/showcase-meta.json files
123
+ * and assemble the versions.json manifest, newest first. Read by the
124
+ * showcase hub (TemplateLanding) and per-channel notice (ChannelNotice) to
125
+ * show this widget's own release history -- not the grist-widget-sdk
126
+ * monorepo's (found live: every scaffolded widget's version index showed
127
+ * the monorepo's versions instead of its own, since showcase-versions.ts
128
+ * used to hardcode that URL).
129
+ */
130
+ export function buildVersionsManifest(siteDir) {
131
+ const entries = []
132
+ for (const name of existsSync(siteDir) ? readdirSync(siteDir) : []) {
133
+ if (!/^v\d+\.\d+\.\d+/.test(name)) continue
134
+ const metaPath = join(siteDir, name, "showcase-meta.json")
135
+ if (!existsSync(metaPath)) continue
136
+ const meta = JSON.parse(readFileSync(metaPath, "utf8"))
137
+ entries.push({ version: meta.version, publishedAt: meta.publishedAt })
138
+ }
139
+ entries.sort((a, b) => new Date(b.publishedAt) - new Date(a.publishedAt))
140
+ return entries
141
+ }
142
+
110
143
  // ----------------------------------------------------------------------------
111
144
  // Filesystem / git effects
112
145
  // ----------------------------------------------------------------------------
@@ -133,12 +166,26 @@ export function placeTarget({ siteDir, channel, version, distDir, sha, ref }) {
133
166
  }
134
167
  return { placed: "dev" }
135
168
  }
136
- // release
169
+ // release: immutable v<version>/, mutable latest/, and the same build
170
+ // placed directly at the site root too. main.tsx renders the showcase hub
171
+ // (TemplateLanding) at any URL with no recognized channel suffix — the
172
+ // bare root included — so without this, a real scaffolded repo's own
173
+ // https://owner.github.io/repo/ has nothing deployed there at all and
174
+ // 404s instead of ever reaching that hub. Reusing the release dist here is
175
+ // exactly like latest/ reusing it: the asset URLs it references (under
176
+ // v<version>/assets/) already exist from the versionDir placement above,
177
+ // so nothing 404s.
137
178
  const versionDir = join(siteDir, `v${version}`)
138
179
  replaceDir(versionDir, distDir)
180
+ writeFileSync(
181
+ join(versionDir, "showcase-meta.json"),
182
+ JSON.stringify({ version, publishedAt: new Date().toISOString(), sha }, null, 2) + "\n",
183
+ )
139
184
  const latestDir = join(siteDir, "latest")
140
185
  replaceDir(latestDir, distDir)
141
- return { placed: `v${version}`, latest: "latest" }
186
+ mkdirSync(siteDir, { recursive: true })
187
+ cpSync(distDir, siteDir, { recursive: true })
188
+ return { placed: `v${version}`, latest: "latest", root: true }
142
189
  }
143
190
 
144
191
  /**
@@ -163,8 +210,12 @@ function sleep(ms) {
163
210
  while (Date.now() < end) {}
164
211
  }
165
212
 
166
- /** Commit + push the gh-pages tree with a rebase-and-retry loop. */
167
- export function finalize({ siteDir, push, commitMessage, remoteBranch = "gh-pages" }) {
213
+ /** Regenerate versions.json (release runs only), then commit + push with rebase-retry. */
214
+ export function finalize({ siteDir, updateVersions, push, commitMessage, remoteBranch = "gh-pages" }) {
215
+ if (updateVersions) {
216
+ const versions = buildVersionsManifest(siteDir)
217
+ writeFileSync(join(siteDir, "versions.json"), JSON.stringify(versions, null, 2) + "\n")
218
+ }
168
219
  git(["add", "-A"], siteDir)
169
220
  const status = git(["status", "--porcelain"], siteDir)
170
221
  if (!status) {
@@ -243,6 +294,7 @@ function main() {
243
294
  case "finalize": {
244
295
  const res = finalize({
245
296
  siteDir: args.site,
297
+ updateVersions: !!args["update-versions"],
246
298
  push: !!args.push,
247
299
  commitMessage: args["commit-message"],
248
300
  })
@@ -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) => (
@@ -49,7 +49,7 @@ function StatusDot({ pulsing, className }: { pulsing: boolean; className: string
49
49
  )
50
50
  }
51
51
 
52
- /** Pure render of a display status -- shared by the real (context-driven) and demo (cycling) chips. */
52
+ /** Pure render of a display status. */
53
53
  function ChipVisual({ display, secondsLeft }: { display: HandshakeDisplayStatus; secondsLeft: number }) {
54
54
  switch (display.kind) {
55
55
  case "connecting":
@@ -91,81 +91,27 @@ function RealChipContents() {
91
91
  return <ChipVisual display={display} secondsLeft={secondsLeft} />
92
92
  }
93
93
 
94
- // A fixed, looping sequence for the showcase hub -- there's no real Grist to
95
- // connect to on that page (it's shown precisely because a real widget isn't
96
- // embedded), so this is a cosmetic demo of the same visual states a real
97
- // widget's chip goes through, not a real handshake attempt.
98
- const DEMO_SEQUENCE: readonly { display: HandshakeDisplayStatus; ms: number }[] = [
99
- { display: { kind: "connecting" }, ms: 1500 },
100
- { display: { kind: "retrying", attempts: 1 }, ms: 3000 },
101
- { display: { kind: "connected" }, ms: 3500 },
102
- ]
103
-
104
- function DemoChipContents() {
105
- const [step, setStep] = useState(0)
106
- const [countdownState, setCountdownState] = useState({ step, secondsLeft: 3 })
107
- const current = DEMO_SEQUENCE[step]
108
-
109
- // Reset the mini countdown the moment the demo cycle enters "retrying" --
110
- // an "adjust state during render" pattern, not a setState-in-effect.
111
- if (current.display.kind === "retrying" && countdownState.step !== step) {
112
- setCountdownState({ step, secondsLeft: 3 })
113
- }
114
-
115
- useEffect(() => {
116
- const advance = setTimeout(() => {
117
- setStep((s) => (s + 1) % DEMO_SEQUENCE.length)
118
- }, current.ms)
119
- return () => clearTimeout(advance)
120
- }, [step, current.ms])
121
-
122
- useEffect(() => {
123
- if (current.display.kind !== "retrying") return
124
- const id = setInterval(() => {
125
- setCountdownState((s) => ({ ...s, secondsLeft: s.secondsLeft > 0 ? s.secondsLeft - 1 : 0 }))
126
- }, 1000)
127
- return () => clearInterval(id)
128
- }, [step, current.display.kind])
129
-
130
- return <ChipVisual display={current.display} secondsLeft={countdownState.secondsLeft} />
131
- }
132
-
133
94
  /**
134
- * Small pill showing Grist handshake status. Two modes:
135
- *
136
- * - Default: live status while actually embedded in Grist. Mounts its own
137
- * `<GristHandshakeProvider>` alongside `<GristWidgetProvider>` --
138
- * documented (grist-widget-sdk/advanced) as safe to coexist, since both
139
- * share the page's `ensureGristReady()` singleton. Purely observational:
140
- * it never drives the real widget's connection, just reflects it.
141
- * - `demo`: a cosmetic, looping cycle through the same visual states, used
142
- * on the showcase hub page where no real Grist connection is ever
143
- * attempted -- a reference to the tech behind the template, not a status
144
- * readout.
95
+ * 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.
145
101
  */
146
- export function GristStatusChip({
147
- demo = false,
148
- className,
149
- }: {
150
- demo?: boolean
151
- className?: string
152
- }) {
153
- const pill = (
154
- <div
155
- className={cn(
156
- "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",
157
- className,
158
- )}
159
- >
160
- {demo ? <DemoChipContents /> : <RealChipContents />}
161
- </div>
162
- )
163
-
164
- if (demo) return pill
165
-
102
+ export function GristStatusChip({ className }: { className?: string }) {
166
103
  return (
167
104
  <GristHandshakeProvider>
168
- <div className="pointer-events-none fixed top-2 right-2 z-50">{pill}</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 />
113
+ </div>
114
+ </div>
169
115
  </GristHandshakeProvider>
170
116
  )
171
117
  }
@@ -1,9 +1,8 @@
1
1
  import { useState } from "react"
2
2
 
3
- import { GristStatusChip } from "@/components/grist-status-chip"
4
3
  import { Button } from "@/components/ui/button"
5
4
  import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
6
- import { DEV_URL, formatDate, useVersions, versionUrl } from "@/lib/showcase-versions"
5
+ import { devUrl, formatDate, useVersions, versionUrl } from "@/lib/showcase-versions"
7
6
 
8
7
  // The showcase "hub" page: rendered whenever this deploy's URL has no
9
8
  // recognized channel suffix (see src/lib/showcase-routing.ts) -- this repo's
@@ -70,9 +69,6 @@ export function TemplateLanding() {
70
69
  return (
71
70
  <div className="mx-auto max-w-2xl px-6 py-16">
72
71
  <header className="mb-10 text-center">
73
- <div className="mb-4 flex justify-center">
74
- <GristStatusChip demo />
75
- </div>
76
72
  <p className="mb-2 text-sm font-medium text-muted-foreground">
77
73
  grist-widget-template
78
74
  </p>
@@ -157,6 +153,13 @@ git push origin dev`}</code>
157
153
  enable Pages: Settings → Pages → Source → "Deploy from a
158
154
  branch" → branch <code className="text-foreground">gh-pages</code>{" "}
159
155
  → <code className="text-foreground">/ (root)</code>.
156
+ <p className="mt-2 text-xs text-muted-foreground">
157
+ Not <code className="text-foreground">main</code> — that
158
+ serves this repo's raw, unbuilt source instead of the built
159
+ site (a blank page with a{" "}
160
+ <code className="text-foreground">/src/main.tsx</code> 404
161
+ in the console).
162
+ </p>
160
163
  <p className="mt-2 text-xs text-muted-foreground">
161
164
  If that first run fails with a permissions error pushing to{" "}
162
165
  <code className="text-foreground">gh-pages</code>, Settings
@@ -175,7 +178,7 @@ git push origin dev`}</code>
175
178
  Released template versions
176
179
  </h2>
177
180
  <a
178
- href={DEV_URL}
181
+ href={devUrl()}
179
182
  className="text-sm text-muted-foreground underline underline-offset-4 hover:text-foreground"
180
183
  >
181
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)