create-grist-widget 0.2.18 → 0.2.20

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.
@@ -50,6 +50,21 @@ for (const [file, subs] of edits) {
50
50
  writeFileSync(p, content)
51
51
  }
52
52
 
53
+ // Record which create-grist-widget release produced this scaffold -- purely
54
+ // informational (shown in a small landing-page footer), never read by the
55
+ // deploy pipeline's own version/release logic. Every scaffold gets this,
56
+ // including the ones the template-canary workflow generates for its `dev`
57
+ // and `canary/latest` branches -- so it survives even if that content is
58
+ // later promoted into a reference repo's `main` and copied again via
59
+ // GitHub's "Use this template".
60
+ const cliPkg = JSON.parse(
61
+ readFileSync(path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "package.json"), "utf8"),
62
+ )
63
+ const pkgJsonPath = path.join(target, "package.json")
64
+ const pkgJson = JSON.parse(readFileSync(pkgJsonPath, "utf8"))
65
+ pkgJson.createGristWidgetVersion = cliPkg.version
66
+ writeFileSync(pkgJsonPath, JSON.stringify(pkgJson, null, 2) + "\n")
67
+
53
68
  // Initialize git on `main` with the scaffold as its first commit, and a
54
69
  // `dev` branch ready to push for live-preview later. Doing this here (not
55
70
  // leaving it to whoever/whatever runs next) is what actually prevents the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-grist-widget",
3
- "version": "0.2.18",
3
+ "version": "0.2.20",
4
4
  "description": "Scaffold a new Grist custom widget from the grist-widget-sdk Vite template",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -101,6 +101,7 @@ jobs:
101
101
  fs.appendFileSync(out, `skip=${p.skip ? "true" : "false"}\n`);
102
102
  fs.appendFileSync(out, `version=${p.version || ""}\n`);
103
103
  fs.appendFileSync(out, `base=${p.base}\n`);
104
+ fs.appendFileSync(out, `firstRelease=${p.firstRelease ? "true" : "false"}\n`);
104
105
  ' "$PLAN"
105
106
 
106
107
  - name: Build
@@ -135,6 +136,18 @@ jobs:
135
136
  --site site --repo "$REPO_NAME" $UPDATE_VERSIONS --push \
136
137
  --commit-message "deploy: ${{ steps.plan.outputs.context }} @ ${{ github.sha }}"
137
138
 
139
+ # On this repo's first genuine release only: prune every branch except
140
+ # main/dev/gh-pages, and reset dev to main's current tip. Establishes
141
+ # the schema every widget should start from (main == dev, gh-pages is
142
+ # CI-managed only) regardless of how the repo was created -- a CLI
143
+ # scaffold already satisfies this trivially; a repo copied via GitHub's
144
+ # "Use this template" often doesn't (a stray inherited branch, or dev
145
+ # tracking the source template's own unrelated preview history).
146
+ # Placed after Finalize so it never runs if the release itself failed.
147
+ - name: Reset branches on first genuine release
148
+ if: steps.plan.outputs.context == 'release' && steps.plan.outputs.firstRelease == 'true'
149
+ run: node scripts/deploy.mjs reset-branches
150
+
138
151
  cleanup:
139
152
  # When the `dev` branch is deleted, remove /dev/ so the dev URL retires
140
153
  # (404) instead of serving a stale build forever.
@@ -4,7 +4,7 @@ Base template to build a Grist widget with `grist-widget-sdk`.
4
4
 
5
5
  `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
6
 
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.
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. Both render `ScaffoldFooter` (`src/components/scaffold-footer.tsx`) at the bottom — a small, purely informational note ("Scaffolded from `create-grist-widget@X.Y.Z`") built from `package.json`'s `createGristWidgetVersion` field, which the CLI stamps on every real scaffold (`vite.config.ts` injects it as `__CREATE_GRIST_WIDGET_VERSION__`). Renders nothing if that field is absent, e.g. this pristine template source itself.
8
8
 
9
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
 
@@ -89,18 +89,28 @@ automatically.
89
89
  > ⚠️ **Used GitHub's "Use this template" button instead of `npm create
90
90
  > grist-widget`?** That path copies this repo's default branch verbatim —
91
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.
92
+ > titles, etc.), and whatever version happens to be checked into the
93
+ > source repo's `package.json` (which can be well past `0.0.1` — see below)
94
+ > comes along with it. You don't need to reset it by hand: your first real
95
+ > release always resolves to `v0.0.1` regardless of what `package.json`
96
+ > says, and that value stays untouched in the file — only the deploy
97
+ > pipeline's own idea of "which version is this" is overridden, once, for
98
+ > that first release. If you also checked **"Include all branches"** (to
99
+ > get `gh-pages`/Pages already set up, no manual Settings step), your first
100
+ > real release also automatically clears out every leftover `v<version>/`
101
+ > it finds — a `v<version>/` only counts as a genuine prior release once it
102
+ > carries a `showcase-meta.json` naming *this* repo (written by this
103
+ > pipeline's own `place` step), so on a repo with no genuine releases yet,
104
+ > everything else is provably inherited noise and gets safely wiped on your
105
+ > first push to `main`. Also on that same first release, every branch
106
+ > except `main`, `dev`, and `gh-pages` gets pruned, and `dev` gets
107
+ > force-reset to match `main`'s tip — a fresh widget always starts from
108
+ > `main == dev`, regardless of whether it came from the CLI (already true)
109
+ > or a template copy (may have inherited a stray branch, or a `dev` full of
110
+ > the source template's own unrelated preview history). All three of these
111
+ > only ever apply before your *first* genuine release — once one exists,
112
+ > they're permanently a no-op, so it's still safest to never manually
113
+ > re-seed `gh-pages` again after that point.
104
114
 
105
115
  **One-time setup** (the workflow can't do this part for you):
106
116
 
@@ -35,6 +35,11 @@
35
35
  // finalize --site <dir> [--update-versions] [--push] [--commit-message <msg>]
36
36
  // Regenerates versions.json (release runs only) and commits + pushes the
37
37
  // gh-pages tree with a rebase-and-retry loop.
38
+ //
39
+ // reset-branches [--repo-dir <dir>]
40
+ // On a repo's first genuine release only (caller gates on plan()'s
41
+ // firstRelease): prunes every branch except main/dev/gh-pages and
42
+ // force-resets dev to main's current tip.
38
43
 
39
44
  import {
40
45
  existsSync,
@@ -80,6 +85,16 @@ function isGenuineRelease(versionDir, repo) {
80
85
  }
81
86
  }
82
87
 
88
+ /**
89
+ * Whether this repo has ever genuinely published a release (a v<version>/
90
+ * dir whose showcase-meta.json names this repo). Shared by `plan()` (forces
91
+ * the first release to 0.0.1) and `cleanupForeignVersionsIfFirstRelease()`
92
+ * (clears inherited v<version>/ noise) -- both need the exact same signal.
93
+ */
94
+ function hasGenuineRelease(siteDir, repo) {
95
+ return buildVersionsManifest(siteDir, repo).length > 0
96
+ }
97
+
83
98
  /**
84
99
  * Build the release/dev plan from this repo's own package.json + the current
85
100
  * gh-pages tree. Release is skipped when its immutable v<version> dir was
@@ -92,7 +107,15 @@ export function plan(siteDir, repo, event, ref, { force = false } = {}) {
92
107
  return { context: "dev", base: basePathFor(repo, "dev") }
93
108
  }
94
109
  const pkg = JSON.parse(readFileSync("package.json", "utf8"))
95
- const version = pkg.version
110
+ // A repo's first genuine release always starts at 0.0.1, regardless of
111
+ // whatever package.json's version field says -- found live: a repo copied
112
+ // via GitHub's "Use this template" inherited "0.2.18" from the source
113
+ // template repo's own main branch (itself a canary-stamped scaffold that
114
+ // had been promoted there), instead of starting fresh. Once a repo has a
115
+ // genuine release, this no longer applies -- its own version bumps are
116
+ // its own responsibility from then on, same as `cleanupForeignVersionsIfFirstRelease`.
117
+ const firstRelease = !hasGenuineRelease(siteDir, repo)
118
+ const version = firstRelease ? "0.0.1" : pkg.version
96
119
  if (!version) throw new Error("package.json has no version")
97
120
  const versionDir = join(siteDir, `v${version}`)
98
121
  const exists = isGenuineRelease(versionDir, repo)
@@ -102,6 +125,10 @@ export function plan(siteDir, repo, event, ref, { force = false } = {}) {
102
125
  base: basePathFor(repo, "release", version),
103
126
  skip: exists && !force,
104
127
  reason: exists ? (force ? "force-rebuild" : "already-published") : "new-version",
128
+ // Exposed so the workflow can gate a one-time repo cleanup (prune stray
129
+ // branches, reset dev to main) on exactly this signal -- see
130
+ // `resetBranchesIfFirstRelease`.
131
+ firstRelease,
105
132
  }
106
133
  }
107
134
 
@@ -190,7 +217,7 @@ function replaceDir(dest, srcDist) {
190
217
  * permanently a no-op.
191
218
  */
192
219
  export function cleanupForeignVersionsIfFirstRelease(siteDir, repo) {
193
- if (buildVersionsManifest(siteDir, repo).length > 0) return { cleaned: [] }
220
+ if (hasGenuineRelease(siteDir, repo)) return { cleaned: [] }
194
221
  const cleaned = []
195
222
  for (const name of existsSync(siteDir) ? readdirSync(siteDir) : []) {
196
223
  if (!/^v\d+\.\d+\.\d+/.test(name)) continue
@@ -297,6 +324,34 @@ export function finalize({ siteDir, updateVersions, push, commitMessage, remoteB
297
324
  throw lastErr
298
325
  }
299
326
 
327
+ /**
328
+ * Establish the schema every widget repo should start from -- `main` and
329
+ * `dev` identical, `gh-pages` managed by CI only -- by pruning every other
330
+ * branch and force-resetting `dev` to `main`'s current tip (the commit
331
+ * already checked out at `repoDir`). Runs once, gated by the caller on
332
+ * `plan()`'s `firstRelease` flag, so it applies equally to a CLI scaffold
333
+ * (already satisfies this trivially -- nothing to prune, dev already equals
334
+ * main) and a repo copied via GitHub's "Use this template" (may have a
335
+ * stray inherited branch, or `dev` tracking the source template's own
336
+ * unrelated preview history). Operates on `repoDir`, the repo's own
337
+ * checkout -- a different directory than the gh-pages `siteDir` every other
338
+ * function here works with.
339
+ */
340
+ export function resetBranchesIfFirstRelease(repoDir, keep = ["main", "dev", "gh-pages"]) {
341
+ const raw = git(["ls-remote", "--heads", "origin"], repoDir)
342
+ const branches = raw
343
+ ? raw.split("\n").map((line) => line.split("refs/heads/")[1]).filter(Boolean)
344
+ : []
345
+ const deletedBranches = []
346
+ for (const b of branches) {
347
+ if (keep.includes(b)) continue
348
+ git(["push", "origin", "--delete", b], repoDir)
349
+ deletedBranches.push(b)
350
+ }
351
+ git(["push", "--force", "origin", "HEAD:refs/heads/dev"], repoDir)
352
+ return { deletedBranches, devReset: true }
353
+ }
354
+
300
355
  // ----------------------------------------------------------------------------
301
356
  // CLI
302
357
  // ----------------------------------------------------------------------------
@@ -354,6 +409,11 @@ function main() {
354
409
  console.log("finalize:", JSON.stringify(res))
355
410
  break
356
411
  }
412
+ case "reset-branches": {
413
+ const res = resetBranchesIfFirstRelease(args["repo-dir"] || ".")
414
+ console.log("reset-branches:", JSON.stringify(res))
415
+ break
416
+ }
357
417
  default:
358
418
  console.error(`unknown subcommand: ${cmd || "(none)"}`)
359
419
  process.exit(2)
@@ -1,5 +1,6 @@
1
1
  import { useState } from "react"
2
2
 
3
+ import { ScaffoldFooter } from "@/components/scaffold-footer"
3
4
  import { Button } from "@/components/ui/button"
4
5
  import {
5
6
  Card,
@@ -117,6 +118,8 @@ export function ChannelNotice({
117
118
  >
118
119
  ← Back to the template home
119
120
  </a>
121
+
122
+ <ScaffoldFooter />
120
123
  </div>
121
124
  )
122
125
  }
@@ -0,0 +1,17 @@
1
+ // Shown on the showcase hub and channel pages, never inside Grist (there's
2
+ // no room, and it's not relevant to using the widget). Purely informational:
3
+ // which create-grist-widget release this repo's package.json was scaffolded
4
+ // from -- distinct from the repo's own `version`, which the deploy pipeline
5
+ // always starts fresh at 0.0.1 regardless of this value (see
6
+ // scripts/deploy.mjs's `plan()`). Renders nothing for the pristine template
7
+ // source itself, or any copy predating this field.
8
+ export function ScaffoldFooter() {
9
+ if (!__CREATE_GRIST_WIDGET_VERSION__) return null
10
+
11
+ return (
12
+ <p className="mt-10 text-center text-xs text-muted-foreground">
13
+ Scaffolded from{" "}
14
+ <code className="text-foreground">create-grist-widget@{__CREATE_GRIST_WIDGET_VERSION__}</code>
15
+ </p>
16
+ )
17
+ }
@@ -1,5 +1,6 @@
1
1
  import { useState } from "react"
2
2
 
3
+ import { ScaffoldFooter } from "@/components/scaffold-footer"
3
4
  import { Button } from "@/components/ui/button"
4
5
  import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
5
6
  import { devUrl, formatDate, useVersions, versionUrl } from "@/lib/showcase-versions"
@@ -301,6 +302,8 @@ git push origin dev`}</code>
301
302
  </ul>
302
303
  )}
303
304
  </section>
305
+
306
+ <ScaffoldFooter />
304
307
  </div>
305
308
  )
306
309
  }
@@ -0,0 +1,6 @@
1
+ /// <reference types="vite/client" />
2
+
3
+ // Injected by vite.config.ts's `define` from package.json's
4
+ // createGristWidgetVersion field -- null for the pristine template source
5
+ // itself (that field only exists on scaffolded copies).
6
+ declare const __CREATE_GRIST_WIDGET_VERSION__: string | null
@@ -1,8 +1,14 @@
1
+ import { readFileSync } from "fs"
1
2
  import path from "path"
2
3
  import tailwindcss from "@tailwindcss/vite"
3
4
  import react from "@vitejs/plugin-react"
4
5
  import { defineConfig } from "vite"
5
6
 
7
+ // Read here (Node context) rather than importing package.json into src/ --
8
+ // keeps the whole file out of the bundle, exposing only the one field the
9
+ // landing page footer needs.
10
+ const pkg = JSON.parse(readFileSync(path.resolve(__dirname, "package.json"), "utf8"))
11
+
6
12
  // https://vite.dev/config/
7
13
  export default defineConfig({
8
14
  plugins: [react(), tailwindcss()],
@@ -11,4 +17,7 @@ export default defineConfig({
11
17
  "@": path.resolve(__dirname, "./src"),
12
18
  },
13
19
  },
20
+ define: {
21
+ __CREATE_GRIST_WIDGET_VERSION__: JSON.stringify(pkg.createGristWidgetVersion ?? null),
22
+ },
14
23
  })