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.
- package/README.md +79 -7
- package/bin/create-grist-widget.mjs +485 -90
- package/examples/attachment-gallery/WidgetApp.tsx +202 -0
- package/examples/bulk-mark-done/WidgetApp.tsx +43 -0
- package/examples/declared-columns/WidgetApp.tsx +42 -0
- package/examples/form-edit/WidgetApp.tsx +225 -0
- package/examples/hello-world/WidgetApp.tsx +15 -0
- package/examples/mark-done/WidgetApp.tsx +44 -0
- package/examples/reads-demo/WidgetApp.tsx +69 -0
- package/examples/rest-fetch/WidgetApp.tsx +49 -0
- package/examples/safe-parse/WidgetApp.tsx +61 -0
- package/examples/schema-migration/WidgetApp.tsx +67 -0
- package/examples/schema-preview/WidgetApp.tsx +26 -0
- package/examples/slice-hooks/WidgetApp.tsx +47 -0
- package/examples/spreadsheet-grid/GristDataSheetGrid.tsx +376 -0
- package/examples/spreadsheet-grid/GristRdgGrid.tsx +189 -0
- package/examples/spreadsheet-grid/GristSchemaToolbar.tsx +293 -0
- package/examples/spreadsheet-grid/WidgetApp.tsx +133 -0
- package/examples/spreadsheet-grid/grist-rdg-choice-column.tsx +616 -0
- package/examples/spreadsheet-grid/grist-rdg-columns.ts +397 -0
- package/examples/task-board/WidgetApp.tsx +229 -0
- package/examples/theme-demo/WidgetApp.tsx +26 -0
- package/examples/widget-options/WidgetApp.tsx +34 -0
- package/examples/writes-demo/WidgetApp.tsx +75 -0
- package/manifest.json +304 -0
- package/package.json +14 -2
- package/src/git-gate.mjs +30 -0
- package/template/.github/workflows/deploy.yml +7 -3
- package/template/README.md +3 -2
- package/template/scripts/deploy.mjs +128 -18
- package/ui-source/ShadcnDataSheetGrid.css +117 -0
- package/ui-source/ShadcnDataSheetGrid.tsx +46 -0
- package/ui-source/badge.tsx +49 -0
- 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.
|
|
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
|
}
|
package/src/git-gate.mjs
ADDED
|
@@ -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
|
|
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
|
package/template/README.md
CHANGED
|
@@ -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`
|
|
44
|
-
`
|
|
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):
|
|
45
|
-
//
|
|
46
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
}
|