create-grist-widget 0.1.0 → 0.2.2

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.1.0",
3
+ "version": "0.2.2",
4
4
  "description": "Scaffold a new Grist custom widget from the grist-widget-sdk Vite template",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -0,0 +1,158 @@
1
+ name: Deploy widget to GitHub Pages
2
+
3
+ # Two channels, one workflow:
4
+ # - push to main / manual dispatch -> release channel: immutable
5
+ # /<repo>/v<version>/ + mutable /<repo>/latest/.
6
+ # Rebuilt only when package.json's version has no v<version> dir yet, so
7
+ # re-pushing main without a version bump is a no-op deploy.
8
+ # - push to dev -> dev channel: mutable /<repo>/dev/
9
+ # with version.json + a self-reload snippet, for live review inside Grist.
10
+ #
11
+ # All logic lives in scripts/deploy.mjs (node builtins only).
12
+ #
13
+ # One-time setup this workflow can't do for you: enable Pages (Settings ->
14
+ # Pages -> Source: Deploy from a branch -> gh-pages -> / (root); this workflow
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).
18
+
19
+ on:
20
+ push:
21
+ branches: [main, dev]
22
+ # Retire the dev URL when the `dev` branch is deleted (see `cleanup` job).
23
+ delete:
24
+ workflow_dispatch:
25
+ inputs:
26
+ force:
27
+ description: "Rebuild even if v<version> already exists."
28
+ type: boolean
29
+ required: false
30
+ default: false
31
+
32
+ permissions:
33
+ contents: write
34
+
35
+ # Serialize deploys onto the single shared gh-pages branch. deploy.mjs
36
+ # additionally rebases-and-retries on push, so interleaving runs never clobber.
37
+ concurrency:
38
+ group: pages-gh-pages
39
+ cancel-in-progress: false
40
+
41
+ jobs:
42
+ deploy:
43
+ # Build/publish on push + manual dispatch. The delete event is handled by `cleanup`.
44
+ if: github.event_name != 'delete'
45
+ runs-on: ubuntu-latest
46
+ env:
47
+ REPO_NAME: ${{ github.event.repository.name }}
48
+ steps:
49
+ - name: Checkout
50
+ uses: actions/checkout@v4
51
+
52
+ - name: Setup pnpm
53
+ uses: pnpm/action-setup@v4
54
+
55
+ - name: Setup Node
56
+ uses: actions/setup-node@v4
57
+ with:
58
+ node-version: 22
59
+ cache: pnpm
60
+
61
+ - name: Install dependencies
62
+ run: pnpm install --frozen-lockfile
63
+
64
+ - name: Clone gh-pages into ./site
65
+ run: |
66
+ git clone --depth 1 --branch gh-pages \
67
+ "https://x-access-token:${{ secrets.GITHUB_TOKEN }}@github.com/${{ github.repository }}.git" site \
68
+ || {
69
+ echo "gh-pages branch not found; creating an orphan."
70
+ git clone --depth 1 "https://x-access-token:${{ secrets.GITHUB_TOKEN }}@github.com/${{ github.repository }}.git" site
71
+ git -C site checkout --orphan gh-pages
72
+ git -C site rm -rf . >/dev/null 2>&1 || true
73
+ touch site/.nojekyll
74
+ }
75
+ git -C site config user.name "github-actions[bot]"
76
+ git -C site config user.email "41898282+github-actions[bot]@users.noreply.github.com"
77
+
78
+ - name: Resolve deploy plan
79
+ id: plan
80
+ run: |
81
+ PLAN=$(node scripts/deploy.mjs plan \
82
+ --site site --repo "$REPO_NAME" \
83
+ --event "${{ github.event_name }}" \
84
+ --ref "${{ github.ref }}" \
85
+ ${{ github.event.inputs.force == 'true' && '--force' || '' }})
86
+ echo "Plan: $PLAN"
87
+ node -e '
88
+ const p = JSON.parse(process.argv[1]);
89
+ const fs = require("fs");
90
+ const out = process.env.GITHUB_OUTPUT;
91
+ fs.appendFileSync(out, `context=${p.context}\n`);
92
+ fs.appendFileSync(out, `skip=${p.skip ? "true" : "false"}\n`);
93
+ fs.appendFileSync(out, `version=${p.version || ""}\n`);
94
+ fs.appendFileSync(out, `base=${p.base}\n`);
95
+ ' "$PLAN"
96
+
97
+ - name: Build
98
+ if: steps.plan.outputs.skip != 'true'
99
+ run: |
100
+ pnpm exec tsc -b
101
+ pnpm exec vite build --base "${{ steps.plan.outputs.base }}"
102
+
103
+ - name: Place build in gh-pages tree
104
+ if: steps.plan.outputs.skip != 'true'
105
+ run: |
106
+ SHA="$(git rev-parse --short HEAD)"
107
+ VERSION_ARG=""
108
+ [ -n "${{ steps.plan.outputs.version }}" ] && VERSION_ARG="--version ${{ steps.plan.outputs.version }}"
109
+ node scripts/deploy.mjs place \
110
+ --site site --repo "$REPO_NAME" \
111
+ --channel "${{ steps.plan.outputs.context }}" $VERSION_ARG \
112
+ --dist dist \
113
+ --sha "$SHA" --ref "${{ github.ref }}"
114
+
115
+ - name: Skip (already published)
116
+ if: steps.plan.outputs.skip == 'true'
117
+ run: echo "== skip — v${{ steps.plan.outputs.version }} already published"
118
+
119
+ - name: Finalize (commit + push)
120
+ run: |
121
+ node scripts/deploy.mjs finalize \
122
+ --site site --push \
123
+ --commit-message "deploy: ${{ steps.plan.outputs.context }} @ ${{ github.sha }}"
124
+
125
+ cleanup:
126
+ # When the `dev` branch is deleted, remove /dev/ so the dev URL retires
127
+ # (404) instead of serving a stale build forever.
128
+ if: github.event_name == 'delete' && github.event.ref_type == 'branch' && github.event.ref == 'dev'
129
+ runs-on: ubuntu-latest
130
+ steps:
131
+ - name: Checkout (default branch — for scripts/deploy.mjs)
132
+ uses: actions/checkout@v4
133
+
134
+ - name: Setup Node
135
+ uses: actions/setup-node@v4
136
+ with:
137
+ node-version: 22
138
+
139
+ - name: Clone gh-pages into ./site (skip if it does not exist)
140
+ id: clone
141
+ run: |
142
+ if git clone --depth 1 --branch gh-pages \
143
+ "https://x-access-token:${{ secrets.GITHUB_TOKEN }}@github.com/${{ github.repository }}.git" site; then
144
+ git -C site config user.name "github-actions[bot]"
145
+ git -C site config user.email "41898282+github-actions[bot]@users.noreply.github.com"
146
+ echo "exists=true" >> "$GITHUB_OUTPUT"
147
+ else
148
+ echo "gh-pages branch not found; nothing to clean up."
149
+ echo "exists=false" >> "$GITHUB_OUTPUT"
150
+ fi
151
+
152
+ - name: Remove dev dir + push
153
+ if: steps.clone.outputs.exists == 'true'
154
+ run: |
155
+ node scripts/deploy.mjs remove --site site
156
+ node scripts/deploy.mjs finalize \
157
+ --site site --push \
158
+ --commit-message "cleanup: retire dev URL for ${{ github.event.ref }}"
@@ -32,12 +32,32 @@ ignored (the root workspace governs).
32
32
 
33
33
  ## Deployment
34
34
 
35
- A bundled GitHub Actions workflow that publishes a scaffolded widget to
36
- **your** GitHub Pages immutable versioned URLs (`/v<version>/` +
37
- `/latest/`) plus a push-to-`dev` preview channel, the same model the SDK's
38
- own widgets use — is still on the roadmap (ROADMAP Phase 2).
39
-
40
- > Until then, build with `pnpm build` and host the `dist/` folder on any
41
- > static host (GitHub Pages, Cloudflare Pages, …). `create-grist-widget`
42
- > already scaffolds this template via `npm create grist-widget`.
35
+ A bundled GitHub Actions workflow (`.github/workflows/deploy.yml` +
36
+ `scripts/deploy.mjs`) publishes this widget to **your own** GitHub Pages,
37
+ with the same two-channel model the SDK's own widgets use:
38
+
39
+ - **Push `main`** (after bumping `package.json`'s `version`) → immutable
40
+ `/v<version>/` + mutable `/latest/`. Re-pushing without a version bump is a
41
+ no-op a version is only ever built once.
42
+ - **Push a `dev` branch** mutable `/dev/`, which self-reloads inside an
43
+ open Grist document a few seconds after every new push — paste the `/dev/`
44
+ URL into a Grist doc once, then just keep pushing while you iterate.
45
+ - **Delete the `dev` branch** → `/dev/` is retired automatically.
46
+
47
+ **One-time setup** (the workflow can't do this part for you):
48
+
49
+ 1. **Settings → Pages** → Source: "Deploy from a branch" → branch `gh-pages`
50
+ → `/ (root)`. The workflow creates the `gh-pages` branch itself the first
51
+ time it runs (if it doesn't exist yet), but Pages needs to be pointed at
52
+ it once.
53
+ 2. **Settings → Actions → General → Workflow permissions** → "Read and write
54
+ permissions". New repos sometimes default the workflow's token to
55
+ read-only, which would fail the push to `gh-pages` with a 403.
56
+ 3. If the repo is private, set **Pages visibility to Public** — a widget
57
+ embeds inside a Grist iframe, which needs a publicly reachable URL.
58
+
59
+ No manifest/widget-catalog file is generated — that's a multi-widget,
60
+ Grist-widget-repository concept this single-widget template doesn't need.
61
+ Paste your `/latest/` or `/v<version>/` URL directly into Grist's custom
62
+ widget URL field.
43
63
 
@@ -0,0 +1,259 @@
1
+ #!/usr/bin/env node
2
+ // Deploy publisher for GitHub Pages (gh-pages branch), single-widget repo.
3
+ //
4
+ // Adapted from the grist-widget-sdk monorepo's scripts/deploy/publish.mjs —
5
+ // same two channels, minus the multi-widget folder loop and manifest.json
6
+ // (there's only one widget here, so nothing to catalog):
7
+ //
8
+ // release (push to main / workflow_dispatch)
9
+ // - immutable /<repo>/v<version>/ (built once, never overwritten)
10
+ // - mutable /<repo>/latest/ (alias to newest release)
11
+ //
12
+ // dev (push to dev)
13
+ // - mutable /<repo>/dev/ (+ version.json + self-reload)
14
+ //
15
+ // Design notes: dependency-free (node builtins only). Pure helpers are
16
+ // exported for testing; the CLI is a thin dispatch at the bottom.
17
+ //
18
+ // Subcommands:
19
+ // plan --site <dir> --repo <name> --event <push|workflow_dispatch>
20
+ // --ref <ref> [--force]
21
+ // Prints JSON { context: "release"|"dev", version?, base, skip }.
22
+ // Release is skipped when v<version> already exists on gh-pages
23
+ // (idempotence: re-pushing main without a version bump is a no-op).
24
+ //
25
+ // place --site <dir> --repo <name> --channel <release|dev>
26
+ // [--version <v>] --dist <dir> --sha <sha> [--ref <ref>]
27
+ // Copies a freshly built dist into the gh-pages tree.
28
+ //
29
+ // remove --site <dir>
30
+ // Removes dev/ from the gh-pages tree (retire the dev URL when the `dev`
31
+ // branch is deleted). Never touches a release or latest/.
32
+ //
33
+ // finalize --site <dir> [--push] [--commit-message <msg>]
34
+ // Commits + pushes the gh-pages tree with a rebase-and-retry loop.
35
+
36
+ import { existsSync, readFileSync, writeFileSync, rmSync, mkdirSync, cpSync } from "node:fs"
37
+ import { join, dirname } from "node:path"
38
+ import { execFileSync } from "node:child_process"
39
+
40
+ // ----------------------------------------------------------------------------
41
+ // Pure helpers (exported for tests)
42
+ // ----------------------------------------------------------------------------
43
+
44
+ /** Absolute Pages base path for a channel, e.g. "/my-widget/v1.2.0/". */
45
+ export function basePathFor(repo, channel, version) {
46
+ const leaf = channel === "dev" ? "dev" : `v${version}`
47
+ return `/${repo}/${leaf}/`
48
+ }
49
+
50
+ /**
51
+ * 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.
54
+ */
55
+ export function plan(siteDir, repo, event, ref, { force = false } = {}) {
56
+ const isRelease = event === "workflow_dispatch" || ref === "refs/heads/main" || ref === "main"
57
+ if (!isRelease) {
58
+ return { context: "dev", base: basePathFor(repo, "dev") }
59
+ }
60
+ const pkg = JSON.parse(readFileSync("package.json", "utf8"))
61
+ const version = pkg.version
62
+ if (!version) throw new Error("package.json has no version")
63
+ const versionDir = join(siteDir, `v${version}`)
64
+ const exists = existsSync(versionDir)
65
+ return {
66
+ context: "release",
67
+ version,
68
+ base: basePathFor(repo, "release", version),
69
+ skip: exists && !force,
70
+ reason: exists ? (force ? "force-rebuild" : "already-published") : "new-version",
71
+ }
72
+ }
73
+
74
+ /** The dev-only self-reload snippet, embedding this build's short SHA. */
75
+ export function selfReloadSnippet(sha) {
76
+ return `<script>
77
+ /* grist-widget-sdk dev channel: auto-reload when a newer build is published. */
78
+ (function () {
79
+ var CURRENT = ${JSON.stringify(sha)};
80
+ var POLL_MS = 5000;
81
+ async function check() {
82
+ try {
83
+ var res = await fetch("version.json?ts=" + Date.now(), { cache: "no-store" });
84
+ if (!res.ok) return;
85
+ var data = await res.json();
86
+ if (data && data.sha && data.sha !== CURRENT) {
87
+ var url = new URL(location.href);
88
+ url.searchParams.set("__dev", data.sha); // unique => busts Pages CDN + browser cache
89
+ location.replace(url.toString()); // preserves Grist's own iframe query params
90
+ }
91
+ } catch (e) { /* transient offline / 404 during publish: keep polling */ }
92
+ }
93
+ setInterval(check, POLL_MS);
94
+ })();
95
+ </script>`
96
+ }
97
+
98
+ /** Insert the snippet just before </body> (or append if none). Idempotent-ish: strips a prior block first. */
99
+ export function injectSelfReload(html, sha) {
100
+ const marker = "grist-widget-sdk dev channel"
101
+ let out = html
102
+ if (out.includes(marker)) {
103
+ out = out.replace(/<script>\s*\/\* grist-widget-sdk dev channel[\s\S]*?<\/script>\s*/g, "")
104
+ }
105
+ const snippet = selfReloadSnippet(sha) + "\n"
106
+ if (out.includes("</body>")) return out.replace("</body>", snippet + "</body>")
107
+ return out + snippet
108
+ }
109
+
110
+ // ----------------------------------------------------------------------------
111
+ // Filesystem / git effects
112
+ // ----------------------------------------------------------------------------
113
+
114
+ function replaceDir(dest, srcDist) {
115
+ if (existsSync(dest)) rmSync(dest, { recursive: true, force: true })
116
+ mkdirSync(dirname(dest), { recursive: true })
117
+ cpSync(srcDist, dest, { recursive: true })
118
+ }
119
+
120
+ /** Place a freshly built dist into the gh-pages tree. */
121
+ export function placeTarget({ siteDir, channel, version, distDir, sha, ref }) {
122
+ if (!existsSync(distDir)) throw new Error(`dist not found: ${distDir}`)
123
+ if (channel === "dev") {
124
+ const devDir = join(siteDir, "dev")
125
+ replaceDir(devDir, distDir)
126
+ writeFileSync(
127
+ join(devDir, "version.json"),
128
+ JSON.stringify({ sha, builtAt: new Date().toISOString(), ref: ref || null }, null, 2) + "\n",
129
+ )
130
+ const indexPath = join(devDir, "index.html")
131
+ if (existsSync(indexPath)) {
132
+ writeFileSync(indexPath, injectSelfReload(readFileSync(indexPath, "utf8"), sha))
133
+ }
134
+ return { placed: "dev" }
135
+ }
136
+ // release
137
+ const versionDir = join(siteDir, `v${version}`)
138
+ replaceDir(versionDir, distDir)
139
+ const latestDir = join(siteDir, "latest")
140
+ replaceDir(latestDir, distDir)
141
+ return { placed: `v${version}`, latest: "latest" }
142
+ }
143
+
144
+ /**
145
+ * Remove the mutable dev dir from the gh-pages tree (retire the dev URL when
146
+ * the `dev` branch is deleted). Only ever touches `dev/` — never a versioned
147
+ * release or `latest/`. No-op if absent.
148
+ */
149
+ export function removeDevDir(siteDir) {
150
+ const devDir = join(siteDir, "dev")
151
+ if (!existsSync(devDir)) return { removed: false }
152
+ rmSync(devDir, { recursive: true, force: true })
153
+ return { removed: true, path: "dev" }
154
+ }
155
+
156
+ function git(args, cwd) {
157
+ return execFileSync("git", args, { cwd, encoding: "utf8" }).trim()
158
+ }
159
+
160
+ function sleep(ms) {
161
+ // synchronous backoff (deploy step is not latency-sensitive)
162
+ const end = Date.now() + ms
163
+ while (Date.now() < end) {}
164
+ }
165
+
166
+ /** Commit + push the gh-pages tree with a rebase-and-retry loop. */
167
+ export function finalize({ siteDir, push, commitMessage, remoteBranch = "gh-pages" }) {
168
+ git(["add", "-A"], siteDir)
169
+ const status = git(["status", "--porcelain"], siteDir)
170
+ if (!status) {
171
+ console.log("deploy: nothing to commit (no-op)")
172
+ return { committed: false }
173
+ }
174
+ git(["commit", "-m", commitMessage || "deploy: publish widget"], siteDir)
175
+ if (!push) return { committed: true, pushed: false }
176
+
177
+ let lastErr
178
+ for (let attempt = 1; attempt <= 4; attempt++) {
179
+ try {
180
+ git(["push", "origin", `HEAD:${remoteBranch}`], siteDir)
181
+ return { committed: true, pushed: true, attempts: attempt }
182
+ } catch (err) {
183
+ lastErr = err
184
+ console.warn(`deploy: push attempt ${attempt} failed, rebasing on origin/${remoteBranch}`)
185
+ try {
186
+ git(["fetch", "origin", remoteBranch], siteDir)
187
+ git(["rebase", `origin/${remoteBranch}`], siteDir)
188
+ } catch (rebaseErr) {
189
+ git(["rebase", "--abort"], siteDir)
190
+ throw rebaseErr
191
+ }
192
+ if (attempt < 4) sleep(2000 * 2 ** (attempt - 1)) // 2s,4s,8s
193
+ }
194
+ }
195
+ throw lastErr
196
+ }
197
+
198
+ // ----------------------------------------------------------------------------
199
+ // CLI
200
+ // ----------------------------------------------------------------------------
201
+
202
+ function parseArgs(argv) {
203
+ const out = { _: [] }
204
+ for (let i = 0; i < argv.length; i++) {
205
+ const a = argv[i]
206
+ if (a.startsWith("--")) {
207
+ const key = a.slice(2)
208
+ const next = argv[i + 1]
209
+ if (next === undefined || next.startsWith("--")) out[key] = true
210
+ else { out[key] = next; i++ }
211
+ } else out._.push(a)
212
+ }
213
+ return out
214
+ }
215
+
216
+ function main() {
217
+ const [cmd, ...rest] = process.argv.slice(2)
218
+ const args = parseArgs(rest)
219
+
220
+ switch (cmd) {
221
+ case "plan": {
222
+ const result = plan(args.site, args.repo, args.event, args.ref, { force: !!args.force })
223
+ process.stdout.write(JSON.stringify(result))
224
+ break
225
+ }
226
+ case "place": {
227
+ const res = placeTarget({
228
+ siteDir: args.site,
229
+ channel: args.channel,
230
+ version: args.version,
231
+ distDir: args.dist,
232
+ sha: args.sha,
233
+ ref: args.ref,
234
+ })
235
+ console.log("placed:", JSON.stringify(res))
236
+ break
237
+ }
238
+ case "remove": {
239
+ const res = removeDevDir(args.site)
240
+ console.log("remove:", JSON.stringify(res))
241
+ break
242
+ }
243
+ case "finalize": {
244
+ const res = finalize({
245
+ siteDir: args.site,
246
+ push: !!args.push,
247
+ commitMessage: args["commit-message"],
248
+ })
249
+ console.log("finalize:", JSON.stringify(res))
250
+ break
251
+ }
252
+ default:
253
+ console.error(`unknown subcommand: ${cmd || "(none)"}`)
254
+ process.exit(2)
255
+ }
256
+ }
257
+
258
+ // Only run the CLI when executed directly (not when imported by tests).
259
+ if (import.meta.url === `file://${process.argv[1]}`) main()