create-grist-widget 0.3.6 → 0.3.9

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.
Files changed (34) hide show
  1. package/README.md +79 -7
  2. package/bin/create-grist-widget.mjs +485 -90
  3. package/examples/attachment-gallery/WidgetApp.tsx +202 -0
  4. package/examples/bulk-mark-done/WidgetApp.tsx +43 -0
  5. package/examples/declared-columns/WidgetApp.tsx +42 -0
  6. package/examples/form-edit/WidgetApp.tsx +225 -0
  7. package/examples/hello-world/WidgetApp.tsx +15 -0
  8. package/examples/mark-done/WidgetApp.tsx +44 -0
  9. package/examples/reads-demo/WidgetApp.tsx +69 -0
  10. package/examples/rest-fetch/WidgetApp.tsx +49 -0
  11. package/examples/safe-parse/WidgetApp.tsx +61 -0
  12. package/examples/schema-migration/WidgetApp.tsx +67 -0
  13. package/examples/schema-preview/WidgetApp.tsx +26 -0
  14. package/examples/slice-hooks/WidgetApp.tsx +47 -0
  15. package/examples/spreadsheet-grid/GristDataSheetGrid.tsx +376 -0
  16. package/examples/spreadsheet-grid/GristRdgGrid.tsx +189 -0
  17. package/examples/spreadsheet-grid/GristSchemaToolbar.tsx +293 -0
  18. package/examples/spreadsheet-grid/WidgetApp.tsx +133 -0
  19. package/examples/spreadsheet-grid/grist-rdg-choice-column.tsx +616 -0
  20. package/examples/spreadsheet-grid/grist-rdg-columns.ts +397 -0
  21. package/examples/task-board/WidgetApp.tsx +229 -0
  22. package/examples/theme-demo/WidgetApp.tsx +26 -0
  23. package/examples/widget-options/WidgetApp.tsx +34 -0
  24. package/examples/writes-demo/WidgetApp.tsx +75 -0
  25. package/manifest.json +304 -0
  26. package/package.json +14 -2
  27. package/src/git-gate.mjs +30 -0
  28. package/template/.github/workflows/deploy.yml +7 -3
  29. package/template/README.md +3 -2
  30. package/template/scripts/deploy.mjs +128 -18
  31. package/ui-source/ShadcnDataSheetGrid.css +117 -0
  32. package/ui-source/ShadcnDataSheetGrid.tsx +46 -0
  33. package/ui-source/badge.tsx +49 -0
  34. package/vendor/compose-example.mjs +252 -0
@@ -0,0 +1,75 @@
1
+ import {
2
+ useGrist,
3
+ useWidgetMetadata,
4
+ type UseGristOptions,
5
+ } from "grist-widget-sdk"
6
+
7
+ import { Button } from "@/components/ui/button"
8
+
9
+ export const GRIST_OPTIONS: UseGristOptions = {
10
+ requiredAccess: "full",
11
+ columns: [{ name: "Title", type: "Text" }],
12
+ }
13
+
14
+ export const WIDGET_METADATA = {
15
+ title: "Table writes",
16
+ description: "create, update, upsert, destroy via w.table.",
17
+ } as const
18
+
19
+ export function WidgetApp() {
20
+ useWidgetMetadata(WIDGET_METADATA)
21
+ const w = useGrist()
22
+
23
+ if (!w.columnMappingStatus.ok) {
24
+ return <p className="p-4 text-sm">Map the Title column.</p>
25
+ }
26
+
27
+ return (
28
+ <div className="flex flex-wrap gap-2 p-4">
29
+ <Button
30
+ type="button"
31
+ onClick={() =>
32
+ void w.table.create({ fields: w.mapBack({ Title: "New" }) })
33
+ }
34
+ >
35
+ create
36
+ </Button>
37
+ <Button
38
+ type="button"
39
+ disabled={!w.record}
40
+ onClick={() => {
41
+ if (!w.record) return
42
+ void w.table.update({
43
+ id: w.record.id as number,
44
+ fields: w.mapBack({ Title: "Updated" }),
45
+ })
46
+ }}
47
+ >
48
+ update
49
+ </Button>
50
+ <Button
51
+ type="button"
52
+ onClick={() =>
53
+ void w.table.upsert({
54
+ require: w.mapBack({ Title: "Existing" }),
55
+ fields: w.mapBack({ Title: "Existing" }),
56
+ })
57
+ }
58
+ >
59
+ upsert
60
+ </Button>
61
+ <Button
62
+ type="button"
63
+ disabled={!w.records?.length}
64
+ onClick={() => {
65
+ const ids = (w.records ?? [])
66
+ .slice(0, 3)
67
+ .map((r) => r.id as number)
68
+ if (ids.length) void w.table.destroy(ids)
69
+ }}
70
+ >
71
+ destroy (first 3)
72
+ </Button>
73
+ </div>
74
+ )
75
+ }
package/manifest.json ADDED
@@ -0,0 +1,304 @@
1
+ [
2
+ {
3
+ "id": "attachment-gallery",
4
+ "title": "Attachment gallery",
5
+ "description": "Thumbnail grid of every attachment in the selected row. Showcases extractGristAttachmentIdsFromCell + useGrist().fetchAttachmentBlob.",
6
+ "requiredAccess": "full",
7
+ "columns": [
8
+ {
9
+ "name": "Attachments",
10
+ "type": "Attachments"
11
+ }
12
+ ],
13
+ "allowSelectBy": false,
14
+ "hasCustomOptions": false,
15
+ "hasExampleDocument": false,
16
+ "usesUi": [
17
+ "button"
18
+ ],
19
+ "sourcePath": "examples/attachment-gallery/WidgetApp.tsx"
20
+ },
21
+ {
22
+ "id": "bulk-mark-done",
23
+ "title": "Bulk mark done",
24
+ "description": "Bulk table.update with an array of row patches.",
25
+ "requiredAccess": "full",
26
+ "columns": [
27
+ {
28
+ "name": "Done",
29
+ "type": "Bool"
30
+ }
31
+ ],
32
+ "allowSelectBy": false,
33
+ "hasCustomOptions": false,
34
+ "hasExampleDocument": false,
35
+ "usesUi": [
36
+ "button"
37
+ ],
38
+ "sourcePath": "examples/bulk-mark-done/WidgetApp.tsx"
39
+ },
40
+ {
41
+ "id": "declared-columns",
42
+ "title": "Declared columns",
43
+ "description": "Shows GRIST_OPTIONS column declarations and mappedRecord after the user maps columns.",
44
+ "requiredAccess": "full",
45
+ "columns": [
46
+ {
47
+ "name": "Title",
48
+ "type": "Text"
49
+ },
50
+ {
51
+ "name": "Done",
52
+ "type": "Bool"
53
+ },
54
+ {
55
+ "name": "Tags",
56
+ "type": "ChoiceList",
57
+ "allowMultiple": true,
58
+ "optional": true
59
+ }
60
+ ],
61
+ "allowSelectBy": false,
62
+ "hasCustomOptions": false,
63
+ "hasExampleDocument": false,
64
+ "usesUi": [],
65
+ "sourcePath": "examples/declared-columns/WidgetApp.tsx"
66
+ },
67
+ {
68
+ "id": "form-edit",
69
+ "title": "Row editor",
70
+ "description": "Edit the selected row in a polished form. Showcases column mappings, write status, and error UI.",
71
+ "requiredAccess": "full",
72
+ "columns": [
73
+ {
74
+ "name": "Title",
75
+ "type": "Text"
76
+ },
77
+ {
78
+ "name": "Description",
79
+ "type": "Text",
80
+ "optional": true
81
+ },
82
+ {
83
+ "name": "Done",
84
+ "type": "Bool"
85
+ },
86
+ {
87
+ "name": "Priority",
88
+ "type": "Text",
89
+ "optional": true
90
+ }
91
+ ],
92
+ "allowSelectBy": false,
93
+ "hasCustomOptions": false,
94
+ "hasExampleDocument": false,
95
+ "usesUi": [
96
+ "alert",
97
+ "button",
98
+ "input"
99
+ ],
100
+ "sourcePath": "examples/form-edit/WidgetApp.tsx"
101
+ },
102
+ {
103
+ "id": "hello-world",
104
+ "title": "hello-world",
105
+ "description": null,
106
+ "requiredAccess": "read table",
107
+ "columns": [],
108
+ "allowSelectBy": false,
109
+ "hasCustomOptions": false,
110
+ "hasExampleDocument": false,
111
+ "usesUi": [],
112
+ "sourcePath": "examples/hello-world/WidgetApp.tsx"
113
+ },
114
+ {
115
+ "id": "mark-done",
116
+ "title": "Mark done",
117
+ "description": "Single-row write via table.update.",
118
+ "requiredAccess": "full",
119
+ "columns": [
120
+ {
121
+ "name": "Done",
122
+ "type": "Bool"
123
+ }
124
+ ],
125
+ "allowSelectBy": false,
126
+ "hasCustomOptions": false,
127
+ "hasExampleDocument": false,
128
+ "usesUi": [
129
+ "button"
130
+ ],
131
+ "sourcePath": "examples/mark-done/WidgetApp.tsx"
132
+ },
133
+ {
134
+ "id": "reads-demo",
135
+ "title": "Reads demo",
136
+ "description": "listTables, fetchTableRows, fetchRow, fetchSelectedRecord.",
137
+ "requiredAccess": "read table",
138
+ "columns": [],
139
+ "allowSelectBy": false,
140
+ "hasCustomOptions": false,
141
+ "hasExampleDocument": false,
142
+ "usesUi": [],
143
+ "sourcePath": "examples/reads-demo/WidgetApp.tsx"
144
+ },
145
+ {
146
+ "id": "rest-fetch",
147
+ "title": "REST fetch",
148
+ "description": "fetchWithAuth against the Grist REST API.",
149
+ "requiredAccess": "full",
150
+ "columns": [],
151
+ "allowSelectBy": false,
152
+ "hasCustomOptions": false,
153
+ "hasExampleDocument": false,
154
+ "usesUi": [
155
+ "button"
156
+ ],
157
+ "sourcePath": "examples/rest-fetch/WidgetApp.tsx"
158
+ },
159
+ {
160
+ "id": "safe-parse",
161
+ "title": "Safe parse",
162
+ "description": "safeParseGristTableData with per-cell issues.",
163
+ "requiredAccess": "read table",
164
+ "columns": [],
165
+ "allowSelectBy": false,
166
+ "hasCustomOptions": false,
167
+ "hasExampleDocument": false,
168
+ "usesUi": [],
169
+ "sourcePath": "examples/safe-parse/WidgetApp.tsx"
170
+ },
171
+ {
172
+ "id": "schema-migration",
173
+ "title": "Schema migration",
174
+ "description": "applyActions with gristAddVisibleColumnAction, gristRenameColumnAction, and gristRemoveColumnAction.",
175
+ "requiredAccess": "full",
176
+ "columns": [],
177
+ "allowSelectBy": false,
178
+ "hasCustomOptions": false,
179
+ "hasExampleDocument": false,
180
+ "usesUi": [
181
+ "button"
182
+ ],
183
+ "sourcePath": "examples/schema-migration/WidgetApp.tsx"
184
+ },
185
+ {
186
+ "id": "schema-preview",
187
+ "title": "Schema preview",
188
+ "description": "useGristSchema with replicaRowMode schema+samples for LLM context.",
189
+ "requiredAccess": "read table",
190
+ "columns": [],
191
+ "allowSelectBy": false,
192
+ "hasCustomOptions": false,
193
+ "hasExampleDocument": false,
194
+ "usesUi": [],
195
+ "sourcePath": "examples/schema-preview/WidgetApp.tsx"
196
+ },
197
+ {
198
+ "id": "slice-hooks",
199
+ "title": "Slice hooks",
200
+ "description": "useGristStatus, useGristSelection, useGristWrites, useGristTheme in separate child components.",
201
+ "requiredAccess": "full",
202
+ "columns": [
203
+ {
204
+ "name": "Title",
205
+ "type": "Text"
206
+ }
207
+ ],
208
+ "allowSelectBy": false,
209
+ "hasCustomOptions": false,
210
+ "hasExampleDocument": false,
211
+ "usesUi": [],
212
+ "sourcePath": "examples/slice-hooks/WidgetApp.tsx"
213
+ },
214
+ {
215
+ "id": "spreadsheet-grid",
216
+ "title": "Spreadsheet grid",
217
+ "description": "Selected Grist section rendered as a fully-synced, editable spreadsheet via react-datasheet-grid, plus a schema toolbar and replica-document export.",
218
+ "requiredAccess": "full",
219
+ "columns": [],
220
+ "allowSelectBy": true,
221
+ "hasCustomOptions": false,
222
+ "hasExampleDocument": false,
223
+ "usesUi": [
224
+ "button",
225
+ "input",
226
+ "ShadcnDataSheetGrid"
227
+ ],
228
+ "sourcePath": "examples/spreadsheet-grid/WidgetApp.tsx"
229
+ },
230
+ {
231
+ "id": "task-board",
232
+ "title": "Task board",
233
+ "description": "Mini Kanban board grouped by Status. Drag a card to update Status; click to focus the row in linked sections.",
234
+ "requiredAccess": "full",
235
+ "columns": [
236
+ {
237
+ "name": "Title",
238
+ "type": "Text"
239
+ },
240
+ {
241
+ "name": "Status",
242
+ "type": "Text"
243
+ },
244
+ {
245
+ "name": "Priority",
246
+ "type": "Text",
247
+ "optional": true
248
+ }
249
+ ],
250
+ "allowSelectBy": true,
251
+ "hasCustomOptions": false,
252
+ "hasExampleDocument": false,
253
+ "usesUi": [
254
+ "badge",
255
+ "card"
256
+ ],
257
+ "sourcePath": "examples/task-board/WidgetApp.tsx"
258
+ },
259
+ {
260
+ "id": "theme-demo",
261
+ "title": "Theme",
262
+ "description": "Read w.theme from the host document.",
263
+ "requiredAccess": "read table",
264
+ "columns": [],
265
+ "allowSelectBy": false,
266
+ "hasCustomOptions": false,
267
+ "hasExampleDocument": false,
268
+ "usesUi": [],
269
+ "sourcePath": "examples/theme-demo/WidgetApp.tsx"
270
+ },
271
+ {
272
+ "id": "widget-options",
273
+ "title": "Widget options",
274
+ "description": "Persist UI state with setWidgetOption / widgetOptions.",
275
+ "requiredAccess": "read table",
276
+ "columns": [],
277
+ "allowSelectBy": false,
278
+ "hasCustomOptions": false,
279
+ "hasExampleDocument": false,
280
+ "usesUi": [
281
+ "button"
282
+ ],
283
+ "sourcePath": "examples/widget-options/WidgetApp.tsx"
284
+ },
285
+ {
286
+ "id": "writes-demo",
287
+ "title": "Table writes",
288
+ "description": "create, update, upsert, destroy via w.table.",
289
+ "requiredAccess": "full",
290
+ "columns": [
291
+ {
292
+ "name": "Title",
293
+ "type": "Text"
294
+ }
295
+ ],
296
+ "allowSelectBy": false,
297
+ "hasCustomOptions": false,
298
+ "hasExampleDocument": false,
299
+ "usesUi": [
300
+ "button"
301
+ ],
302
+ "sourcePath": "examples/writes-demo/WidgetApp.tsx"
303
+ }
304
+ ]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-grist-widget",
3
- "version": "0.3.6",
3
+ "version": "0.3.9",
4
4
  "description": "Scaffold a new Grist custom widget from the grist-widget-sdk Vite template",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -12,7 +12,12 @@
12
12
  },
13
13
  "files": [
14
14
  "bin",
15
+ "src",
15
16
  "template",
17
+ "vendor",
18
+ "examples",
19
+ "ui-source",
20
+ "manifest.json",
16
21
  "LICENSE"
17
22
  ],
18
23
  "publishConfig": {
@@ -33,7 +38,14 @@
33
38
  "cli",
34
39
  "create-grist-widget"
35
40
  ],
41
+ "dependencies": {
42
+ "@clack/prompts": "^1.7.0"
43
+ },
44
+ "devDependencies": {
45
+ "vitest": "^4.1.4"
46
+ },
36
47
  "scripts": {
37
- "build": "node scripts/build-template.mjs"
48
+ "build": "node scripts/build-template.mjs",
49
+ "test": "vitest run"
38
50
  }
39
51
  }
@@ -0,0 +1,30 @@
1
+ // The safety gate `use <example-id>` rests on: overlayExampleSrc overwrites
2
+ // src/App.tsx (and any other example-specific files) in place, so the only
3
+ // thing that makes that recoverable is a clean git tree to diff/revert
4
+ // against. `create`/`init` always leave a repo behind today (see
5
+ // bin/create-grist-widget.mjs's gitInitCommit), but `use` checks for one
6
+ // explicitly rather than assuming it — the plan's own Done-when line calls
7
+ // this out: "it rests on the CLI's git init, which a future --no-git would
8
+ // silently remove." Checking `git rev-parse` (not just "does .git exist")
9
+ // is what keeps this correct independent of that future flag.
10
+ //
11
+ // Exported separately from bin/create-grist-widget.mjs so it has its own
12
+ // unit test (tests/git-gate.test.mjs) — see docs/planning/2026-08-execution-plan.md
13
+ // §105 ("the git gate has its own test").
14
+
15
+ import { execFileSync } from "node:child_process"
16
+
17
+ /**
18
+ * @param {string} cwd
19
+ * @returns {{ ok: true } | { ok: false, reason: "no-repo" | "dirty" }}
20
+ */
21
+ export function checkGitGate(cwd) {
22
+ try {
23
+ execFileSync("git", ["rev-parse", "--is-inside-work-tree"], { cwd, stdio: "pipe" })
24
+ } catch {
25
+ return { ok: false, reason: "no-repo" }
26
+ }
27
+ const status = execFileSync("git", ["status", "--porcelain"], { cwd, encoding: "utf8" })
28
+ if (status.trim()) return { ok: false, reason: "dirty" }
29
+ return { ok: true }
30
+ }
@@ -12,8 +12,12 @@ name: Deploy widget to GitHub Pages
12
12
  # v0.0.1 + /latest/ + root right away, discarding any inherited canary
13
13
  # version. On that same first release, the "Reset repo to a clean
14
14
  # first-release state" step below rewrites package.json's version back to
15
- # 0.0.1 in the repo itself, so the *next* release bumps to 0.0.2 rather
16
- # than the inherited number.
15
+ # 0.0.1 in the repo itself (so the *next* release bumps to 0.0.2 rather
16
+ # than the inherited number) and, since "Use this template" never runs
17
+ # the CLI's own rename step, also personalizes the four placeholder-
18
+ # identity spots (package.json's name, README.md's heading, index.html's
19
+ # <title>, App.tsx's title) to match this repo's real name -- a no-op
20
+ # wherever that placeholder text is already gone.
17
21
  # - push to dev -> dev channel: mutable /<repo>/dev/
18
22
  # with version.json + a self-reload snippet, for live review inside Grist.
19
23
  #
@@ -158,7 +162,7 @@ jobs:
158
162
  # run sees v0.0.1 already published (firstRelease=false) and no-ops.
159
163
  - name: Reset repo to a clean first-release state
160
164
  if: steps.plan.outputs.context == 'release' && steps.plan.outputs.firstRelease == 'true'
161
- run: node scripts/deploy.mjs reset-branches
165
+ run: node scripts/deploy.mjs reset-branches --repo "$REPO_NAME"
162
166
 
163
167
  cleanup:
164
168
  # When the `dev` branch is deleted, remove /dev/ so the dev URL retires
@@ -40,8 +40,9 @@ This template has no live-preview deploy of its own inside this monorepo —
40
40
  that used to exist (a `/template/` showcase on this repo's own GitHub
41
41
  Pages) but has been retired. The only live preview of this template's
42
42
  current source is external: `template-canary.yml` scaffolds it via the
43
- *published* `create-grist-widget` package and pushes to the `dev` and
44
- `canary/latest` branches of
43
+ *published* `create-grist-widget` package and pushes to the `dev` branch
44
+ (pre-release preview) and directly to `main` (stable `@latest`, promotion is
45
+ automatic) of
45
46
  [`grist-widget-template`](https://github.com/ArthurBlanchon/grist-widget-template)
46
47
  after every release — see https://gristwidgets.com/docs/contributing/releasing for the full
47
48
  mechanism. There is no live preview of *unreleased* changes to this
@@ -39,11 +39,13 @@
39
39
  // Regenerates versions.json (release runs only) and commits + pushes the
40
40
  // gh-pages tree with a rebase-and-retry loop.
41
41
  //
42
- // reset-branches [--repo-dir <dir>]
42
+ // reset-branches [--repo-dir <dir>] [--repo <name>]
43
43
  // On a repo's first genuine release only (caller gates on plan()'s
44
- // firstRelease): resets package.json's version to 0.0.1, prunes every
45
- // branch except main/dev/gh-pages, and
46
- // force-resets dev to main's current tip.
44
+ // firstRelease): renames the four placeholder-identity spots (only if
45
+ // --repo is given and they still carry the template's own placeholder
46
+ // text -- a no-op for a CLI scaffold that already renamed itself), resets
47
+ // package.json's version to 0.0.1, prunes every branch except
48
+ // main/dev/gh-pages, and force-resets dev to main's current tip.
47
49
 
48
50
  import {
49
51
  existsSync,
@@ -130,11 +132,45 @@ export function plan(siteDir, repo, event, ref, { force = false } = {}) {
130
132
  if (!version) throw new Error("package.json has no version")
131
133
  const versionDir = join(siteDir, `v${version}`)
132
134
  const exists = isGenuineRelease(versionDir, repo)
135
+ const skip = exists && !force
136
+ // T-07: "merging without bumping the version publishes nothing" was a
137
+ // silent no-op, documented in two places (this file's own header comment,
138
+ // and AGENTS.md's Anti-patterns) instead of made unrepresentable. Turn it
139
+ // loud for the one case that actually means "I meant to publish and
140
+ // didn't": a genuine `push` to main (not `workflow_dispatch`, which has
141
+ // its own explicit --force for intentional rebuilds) landed on a repo
142
+ // that's already released before, and the version it resolved to was
143
+ // already published. If HEAD's parent had the *same* version, nothing
144
+ // about this push looks like an intentional re-run -- fail the workflow
145
+ // step instead of quietly reporting success. Falls back to today's silent
146
+ // skip if HEAD~1 doesn't resolve (e.g. a squashed/rebased history, or
147
+ // this being the repo's very first commit) -- this is a backstop for the
148
+ // common case, not a guarantee for every git topology.
149
+ if (event === "push" && !firstRelease && skip) {
150
+ try {
151
+ const previousPkgJson = execFileSync("git", ["show", "HEAD~1:package.json"], {
152
+ encoding: "utf8",
153
+ })
154
+ const previousVersion = JSON.parse(previousPkgJson).version
155
+ if (previousVersion === version) {
156
+ throw new Error(
157
+ `deploy: this push to main would publish nothing -- v${version} was already released and ` +
158
+ `package.json's version is unchanged from the previous commit. Bump "version" in package.json ` +
159
+ `before merging if you meant to publish new content (see AGENTS.md's Anti-patterns).`
160
+ )
161
+ }
162
+ } catch (err) {
163
+ // Re-throw our own message; swallow only git/JSON failures (HEAD~1
164
+ // missing, package.json didn't exist at that commit, etc.) and fall
165
+ // through to the existing silent-skip behavior for those cases.
166
+ if (err instanceof Error && err.message.startsWith("deploy: this push")) throw err
167
+ }
168
+ }
133
169
  return {
134
170
  context: "release",
135
171
  version,
136
172
  base: basePathFor(repo, "release", version),
137
- skip: exists && !force,
173
+ skip,
138
174
  reason: exists ? (force ? "force-rebuild" : "already-published") : "new-version",
139
175
  // Exposed so the workflow can gate a one-time repo cleanup (prune stray
140
176
  // branches, reset dev to main) on exactly this signal -- see
@@ -348,10 +384,56 @@ export function finalize({ siteDir, updateVersions, push, commitMessage, remoteB
348
384
  throw lastErr
349
385
  }
350
386
 
387
+ /**
388
+ * Derive an npm-safe package name and a human title from a real GitHub repo
389
+ * name -- which allows characters (uppercase, underscores, periods) npm
390
+ * package names don't. Mirrors the title-casing
391
+ * `packages/create-grist-widget/bin/create-grist-widget.mjs` already applies
392
+ * to its own CLI-provided `rawName`, so a repo named e.g. `Ask-Genial_Widget`
393
+ * produces the same shape of package name / title a CLI user typing
394
+ * `ask-genial-widget` would have gotten.
395
+ */
396
+ export function deriveIdentity(repoName) {
397
+ const packageName =
398
+ (repoName || "")
399
+ .toLowerCase()
400
+ .replace(/[^a-z0-9-]+/g, "-")
401
+ .replace(/^-+|-+$/g, "") || "grist-widget"
402
+ const title = packageName
403
+ .split("-")
404
+ .filter(Boolean)
405
+ .map((w) => w[0].toUpperCase() + w.slice(1))
406
+ .join(" ")
407
+ return { packageName, title }
408
+ }
409
+
410
+ /**
411
+ * The same four placeholder-identity edits
412
+ * `packages/create-grist-widget/bin/create-grist-widget.mjs` applies at
413
+ * scaffold time, parameterized by a derived identity instead of CLI input.
414
+ * Each substitution is a plain string replace, so it's naturally a no-op
415
+ * wherever the placeholder text is already gone -- a CLI-scaffolded repo
416
+ * (whose name may legitimately differ from its GitHub repo name) is never
417
+ * touched by this.
418
+ */
419
+ function identityEdits({ packageName, title }) {
420
+ return [
421
+ ["package.json", [["grist-widget-template-vite", packageName]]],
422
+ ["README.md", [["grist-widget-template-vite", packageName]]],
423
+ ["index.html", [["<title>Grist Widget</title>", `<title>${title}</title>`]]],
424
+ ["src/App.tsx", [['title: "Grist Widget Template",', `title: "${title}",`]]],
425
+ ]
426
+ }
427
+
351
428
  /**
352
429
  * Establish the state every widget repo should start from, on its first
353
430
  * genuine release only (gated by the caller on `plan()`'s `firstRelease`):
354
431
  *
432
+ * 0. If `repo` is given, rewrite the four placeholder-identity spots (see
433
+ * `identityEdits`) to match the real repo name -- the piece "Use this
434
+ * template" skips entirely, since it copies the repo verbatim and never
435
+ * runs the CLI's own rename step. Folded into the same commit as the
436
+ * version reset below, not a separate one.
355
437
  * 1. Reset package.json's version to 0.0.1. The first release already
356
438
  * published v0.0.1 (forced by plan() regardless of package.json), but a
357
439
  * repo copied via GitHub's "Use this template" still carries the source
@@ -372,27 +454,55 @@ export function finalize({ siteDir, updateVersions, push, commitMessage, remoteB
372
454
  * a genuine release already published (firstRelease=false) and skips both
373
455
  * the release and this reset, so there's no loop.
374
456
  */
375
- export function resetBranchesIfFirstRelease(repoDir, keep = ["main", "dev", "gh-pages"]) {
457
+ export function resetBranchesIfFirstRelease(repoDir, repo, keep = ["main", "dev", "gh-pages"]) {
458
+ const changedFiles = new Set()
459
+ const renamedFiles = []
460
+
461
+ if (repo) {
462
+ for (const [file, subs] of identityEdits(deriveIdentity(repo))) {
463
+ const p = join(repoDir, file)
464
+ if (!existsSync(p)) continue
465
+ const before = readFileSync(p, "utf8")
466
+ const after = subs.reduce((text, [search, replace]) => text.split(search).join(replace), before)
467
+ if (after !== before) {
468
+ writeFileSync(p, after)
469
+ changedFiles.add(file)
470
+ renamedFiles.push(file)
471
+ }
472
+ }
473
+ }
474
+ const identityReset = renamedFiles.length > 0
475
+
376
476
  let versionReset = false
377
477
  const pkgPath = join(repoDir, "package.json")
378
478
  if (existsSync(pkgPath)) {
479
+ // Re-read after any identity edit above, so a rewritten `name` survives
480
+ // this JSON round-trip instead of being clobbered by it.
379
481
  const pkg = JSON.parse(readFileSync(pkgPath, "utf8"))
380
482
  if (pkg.version !== "0.0.1") {
381
483
  pkg.version = "0.0.1"
382
484
  writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + "\n")
383
- git(["add", "package.json"], repoDir)
384
- git(
385
- [
386
- "-c", "user.name=github-actions[bot]",
387
- "-c", "user.email=41898282+github-actions[bot]@users.noreply.github.com",
388
- "commit", "-m", "chore: reset version to 0.0.1 for a clean first release",
389
- ],
390
- repoDir,
391
- )
392
- git(["push", "origin", "HEAD:refs/heads/main"], repoDir)
485
+ changedFiles.add("package.json")
393
486
  versionReset = true
394
487
  }
395
488
  }
489
+
490
+ if (changedFiles.size > 0) {
491
+ const parts = []
492
+ if (identityReset) parts.push("personalize scaffold")
493
+ if (versionReset) parts.push("reset version to 0.0.1")
494
+ git(["add", ...changedFiles], repoDir)
495
+ git(
496
+ [
497
+ "-c", "user.name=github-actions[bot]",
498
+ "-c", "user.email=41898282+github-actions[bot]@users.noreply.github.com",
499
+ "commit", "-m", `chore: ${parts.join(" + ")} for a clean first release`,
500
+ ],
501
+ repoDir,
502
+ )
503
+ git(["push", "origin", "HEAD:refs/heads/main"], repoDir)
504
+ }
505
+
396
506
  const raw = git(["ls-remote", "--heads", "origin"], repoDir)
397
507
  const branches = raw
398
508
  ? raw.split("\n").map((line) => line.split("refs/heads/")[1]).filter(Boolean)
@@ -404,7 +514,7 @@ export function resetBranchesIfFirstRelease(repoDir, keep = ["main", "dev", "gh-
404
514
  deletedBranches.push(b)
405
515
  }
406
516
  git(["push", "--force", "origin", "HEAD:refs/heads/dev"], repoDir)
407
- return { deletedBranches, devReset: true, versionReset }
517
+ return { deletedBranches, devReset: true, versionReset, identityReset, renamedFiles }
408
518
  }
409
519
 
410
520
  // ----------------------------------------------------------------------------
@@ -467,7 +577,7 @@ function main() {
467
577
  break
468
578
  }
469
579
  case "reset-branches": {
470
- const res = resetBranchesIfFirstRelease(args["repo-dir"] || ".")
580
+ const res = resetBranchesIfFirstRelease(args["repo-dir"] || ".", args.repo)
471
581
  console.log("reset-branches:", JSON.stringify(res))
472
582
  break
473
583
  }