create-grist-widget 0.3.27 → 0.3.29
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 +25 -7
- package/bin/create-grist-widget.mjs +124 -8
- package/package.json +1 -1
- package/src/deploy.mjs +3 -0
- package/src/git-gate.mjs +2 -2
- package/src/upgrade.mjs +562 -0
- package/template/AGENTS.md +43 -9
- package/template/README.md +23 -6
- package/template/package.json +1 -1
- package/template/src/{App.smoke.test.tsx → app/App.smoke.test.tsx} +4 -1
- package/template/src/main.tsx +1 -1
- package/template/vite.config.ts +1 -1
- package/vendor/compose-example.mjs +17 -19
- /package/template/src/{App.test.tsx → app/App.test.tsx} +0 -0
- /package/template/src/{App.tsx → app/App.tsx} +0 -0
- /package/template/src/{grist-types.example.ts → app/grist-types.example.ts} +0 -0
package/README.md
CHANGED
|
@@ -21,13 +21,14 @@ dependencies automatically, detecting whichever package manager you invoked this
|
|
|
21
21
|
what's available — or just run `npm create grist-widget` with no name for an interactive
|
|
22
22
|
prompt that asks for both.
|
|
23
23
|
|
|
24
|
-
## The
|
|
24
|
+
## The five commands
|
|
25
25
|
|
|
26
26
|
| Command | What it does |
|
|
27
27
|
| --- | --- |
|
|
28
28
|
| `create <name> [--example <id>]` | Scaffold a new widget into `./<name>` (the command above, or run explicitly) |
|
|
29
29
|
| `init [--example <id>]` | Scaffold into the **current** directory instead of a new one — for an already-cloned or agent-initialized repo |
|
|
30
30
|
| `use <example-id>` | Swap the current project's widget for a different example |
|
|
31
|
+
| `upgrade` | Bring an existing widget's template files up to this version's template |
|
|
31
32
|
| `list [--json]` | Print the available starting examples |
|
|
32
33
|
|
|
33
34
|
Common flags for `create`/`init`: `--yes` skips interactive prompts and uses
|
|
@@ -52,7 +53,7 @@ npx create-grist-widget init --example form-edit
|
|
|
52
53
|
```
|
|
53
54
|
|
|
54
55
|
Files that already exist and are identical to the template are left untouched; the four
|
|
55
|
-
identity-bearing files (`package.json`, `README.md`, `index.html`, `src/App.tsx`) get the
|
|
56
|
+
identity-bearing files (`package.json`, `README.md`, `index.html`, `src/app/App.tsx`) get the
|
|
56
57
|
usual name/title substitution even if present. Anything else that already exists **and**
|
|
57
58
|
differs from the template's own copy stops the run with a list of the conflicting paths —
|
|
58
59
|
pass `--force` to overwrite anyway. If `.git` already exists, `init` never auto-commits;
|
|
@@ -61,18 +62,35 @@ initial commit exactly like `create` does.
|
|
|
61
62
|
|
|
62
63
|
### `use` — swapping to a different example later
|
|
63
64
|
|
|
64
|
-
Changed your mind about which example to start from, or want to compare two? `use`
|
|
65
|
-
`src/
|
|
65
|
+
Changed your mind about which example to start from, or want to compare two? `use`
|
|
66
|
+
replaces the widget, `src/app/`, with a different example:
|
|
66
67
|
|
|
67
68
|
```bash
|
|
68
69
|
create-grist-widget use task-board
|
|
69
70
|
```
|
|
70
71
|
|
|
71
|
-
It
|
|
72
|
-
workflow — and it requires a clean git working tree first (`git status --porcelain`
|
|
72
|
+
It touches nothing else but the `src/components/ui/` primitives and dependencies the example
|
|
73
|
+
needs — never `main.tsx`, `vite.config.ts`, or the deploy workflow — and it requires a clean git working tree first (`git status --porcelain`
|
|
73
74
|
empty), so the swap is always a single reviewable, revertible diff. Commit or stash first
|
|
74
75
|
if you have uncommitted changes; `use` refuses to run otherwise.
|
|
75
76
|
|
|
77
|
+
### `upgrade` — picking up template changes
|
|
78
|
+
|
|
79
|
+
Inside a widget, on `dev` with nothing uncommitted, after updating its `create-grist-widget`
|
|
80
|
+
dependency:
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
pnpm exec create-grist-widget upgrade
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
It rewrites the template's files (`src/main.tsx`, the workflows, the build configuration) to
|
|
87
|
+
this version's copies, deletes the ones earlier templates wrote that are gone now, merges the
|
|
88
|
+
template's scripts and dependency versions into `package.json`, and drops pnpm age exceptions
|
|
89
|
+
that are old enough not to matter. Nothing of the widget's own is touched (`src/app/` and its
|
|
90
|
+
smoke test, `src/components/ui/`, `src/index.css`, `README.md`…). It refuses to
|
|
91
|
+
run on `main`, with uncommitted changes, or on a widget still in `src/App.tsx`. Details:
|
|
92
|
+
[Upgrading a widget](https://gristwidgets.com/docs/cli/upgrading).
|
|
93
|
+
|
|
76
94
|
### `list` — what's available
|
|
77
95
|
|
|
78
96
|
```bash
|
|
@@ -80,7 +98,7 @@ create-grist-widget list # id / title / description, one per line
|
|
|
80
98
|
create-grist-widget list --json # the same, as JSON
|
|
81
99
|
```
|
|
82
100
|
|
|
83
|
-
> **Note:** `create`, `init`, `use`, and `list` are themselves valid widget names — a bare
|
|
101
|
+
> **Note:** `create`, `init`, `use`, `upgrade`, and `list` are themselves valid widget names — a bare
|
|
84
102
|
> `create-grist-widget list` runs the `list` command, not "scaffold a widget named list."
|
|
85
103
|
> Use the explicit form (`create-grist-widget create list`) for a widget with one of these
|
|
86
104
|
> names.
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// create-grist-widget — scaffold a Grist custom widget, fully offline.
|
|
3
3
|
//
|
|
4
|
-
//
|
|
4
|
+
// Five verbs (batch H, docs/planning/2026-08-execution-plan.md §98-105, and
|
|
5
|
+
// T2 of docs/planning/2026-11-01-widget-lifecycle-plan.md):
|
|
5
6
|
// create <name> [--example <id>] scaffold a new widget into ./<name>
|
|
6
7
|
// init [--example <id>] scaffold into the current directory
|
|
7
|
-
// use <example-id> swap the current project's widget
|
|
8
|
+
// use <example-id> swap the current project's widget (src/app/)
|
|
9
|
+
// upgrade bring the template's files up to this CLI's
|
|
8
10
|
// list [--json] print the available starting examples
|
|
9
11
|
//
|
|
10
12
|
// `create`/`init`/`use` share one compose engine — see
|
|
@@ -13,7 +15,8 @@
|
|
|
13
15
|
// scripts/build-template.mjs alongside the template itself, along with
|
|
14
16
|
// examples/, ui-source/, and manifest.json — see that script for why).
|
|
15
17
|
// Nothing here ever hits the network: everything list/use/create --example
|
|
16
|
-
// need already shipped in the tarball.
|
|
18
|
+
// need already shipped in the tarball. The one exception is `upgrade`, which
|
|
19
|
+
// asks the npm registry when versions in pnpm's age exceptions were published.
|
|
17
20
|
|
|
18
21
|
import {
|
|
19
22
|
cpSync,
|
|
@@ -33,6 +36,7 @@ import { createComposer } from "../vendor/compose-example.mjs"
|
|
|
33
36
|
import { checkGitGate } from "../src/git-gate.mjs"
|
|
34
37
|
import { addedAgeExceptions } from "../src/age-exceptions.mjs"
|
|
35
38
|
import { installPlan } from "../src/package-manager.mjs"
|
|
39
|
+
import { runUpgrade, upgradeRefusal } from "../src/upgrade.mjs"
|
|
36
40
|
|
|
37
41
|
const PKG_ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), "..")
|
|
38
42
|
const TEMPLATE_DIR = path.join(PKG_ROOT, "template")
|
|
@@ -87,7 +91,9 @@ Usage:
|
|
|
87
91
|
npm create grist-widget <name> [options] Scaffold into ./<name>
|
|
88
92
|
create-grist-widget create <name> [options] Same as above, explicit
|
|
89
93
|
create-grist-widget init [options] Scaffold into the current directory
|
|
90
|
-
create-grist-widget use <example-id>
|
|
94
|
+
create-grist-widget use <example-id> Replace the current project's widget (src/app/)
|
|
95
|
+
create-grist-widget upgrade Bring the template's files up to this CLI's
|
|
96
|
+
(run it on dev, in a widget: pnpm exec create-grist-widget upgrade)
|
|
91
97
|
create-grist-widget list [--json] List available starting examples
|
|
92
98
|
|
|
93
99
|
Options (create / init):
|
|
@@ -104,7 +110,7 @@ Options (create / init):
|
|
|
104
110
|
--help, -h Show this help
|
|
105
111
|
--version, -v Show this CLI's version
|
|
106
112
|
|
|
107
|
-
Note: "create", "init", "use", and "list" are themselves valid widget names.
|
|
113
|
+
Note: "create", "init", "use", "upgrade", and "list" are themselves valid widget names.
|
|
108
114
|
A bare "create-grist-widget list" runs the list command, not "scaffold a
|
|
109
115
|
widget named list" — use "create-grist-widget create list" for that.`)
|
|
110
116
|
}
|
|
@@ -116,7 +122,7 @@ function applyIdentityEdits(target, rawName) {
|
|
|
116
122
|
["package.json", [["grist-widget-template-vite", rawName]]],
|
|
117
123
|
["README.md", [["grist-widget-template-vite", rawName]]],
|
|
118
124
|
["index.html", [["<title>Grist Widget</title>", `<title>${title}</title>`]]],
|
|
119
|
-
["src/App.tsx", [['title: "Grist Widget Template",', `title: "${title}",`]]],
|
|
125
|
+
["src/app/App.tsx", [['title: "Grist Widget Template",', `title: "${title}",`]]],
|
|
120
126
|
]
|
|
121
127
|
for (const [file, subs] of edits) {
|
|
122
128
|
const p = path.join(target, file)
|
|
@@ -393,7 +399,7 @@ async function cmdInit(argv) {
|
|
|
393
399
|
// common case, a pristine "Use this template" clone); anything else that
|
|
394
400
|
// already exists and differs from the template's own pristine copy blocks
|
|
395
401
|
// the run, listed, unless --force.
|
|
396
|
-
const IDENTITY_FILES = new Set(["package.json", "README.md", "index.html", "src/App.tsx"])
|
|
402
|
+
const IDENTITY_FILES = new Set(["package.json", "README.md", "index.html", "src/app/App.tsx"])
|
|
397
403
|
const templateFiles = listFilesRecursive(TEMPLATE_DIR)
|
|
398
404
|
if (!args.force) {
|
|
399
405
|
const conflicts = []
|
|
@@ -470,11 +476,21 @@ function cmdUse(argv) {
|
|
|
470
476
|
}
|
|
471
477
|
|
|
472
478
|
const cwd = process.cwd()
|
|
479
|
+
// Scaffolds older than the src/app/ layout keep their widget in src/, and
|
|
480
|
+
// their main.tsx imports ./App.tsx: an example written to src/app/ would
|
|
481
|
+
// never show.
|
|
482
|
+
if (existsSync(path.join(cwd, "src", "App.tsx"))) {
|
|
483
|
+
console.error(
|
|
484
|
+
"use: this project keeps its widget in src/App.tsx. Widgets now live in src/app/: move it there first" +
|
|
485
|
+
" (see https://gristwidgets.com/docs/cli/upgrading), then run use again."
|
|
486
|
+
)
|
|
487
|
+
process.exit(1)
|
|
488
|
+
}
|
|
473
489
|
const gate = checkGitGate(cwd)
|
|
474
490
|
if (!gate.ok) {
|
|
475
491
|
if (gate.reason === "no-repo") {
|
|
476
492
|
console.error(
|
|
477
|
-
'use: this command
|
|
493
|
+
'use: this command replaces src/app/ and must run inside a git repository so the change is easy to review and undo. Run "git init" first.'
|
|
478
494
|
)
|
|
479
495
|
} else {
|
|
480
496
|
console.error(
|
|
@@ -493,6 +509,105 @@ function cmdUse(argv) {
|
|
|
493
509
|
console.log(" pnpm dev")
|
|
494
510
|
}
|
|
495
511
|
|
|
512
|
+
// ---------------------------------------------------------------------------
|
|
513
|
+
// upgrade
|
|
514
|
+
// ---------------------------------------------------------------------------
|
|
515
|
+
|
|
516
|
+
const CLI_CHANGELOG_URL =
|
|
517
|
+
"https://github.com/ArthurBlanchon/grist-widget-sdk/blob/main/packages/create-grist-widget/CHANGELOG.md"
|
|
518
|
+
|
|
519
|
+
/** When `name@version` was published, from the registry `npm` is configured for in `cwd`; null if unknown. */
|
|
520
|
+
function npmPublishTimes(cwd) {
|
|
521
|
+
const cache = new Map()
|
|
522
|
+
return (name, version) => {
|
|
523
|
+
if (!cache.has(name)) {
|
|
524
|
+
let times = null
|
|
525
|
+
try {
|
|
526
|
+
const out = runCommand("npm", ["view", name, "time", "--json"], {
|
|
527
|
+
cwd,
|
|
528
|
+
encoding: "utf8",
|
|
529
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
530
|
+
timeout: 30_000,
|
|
531
|
+
})
|
|
532
|
+
times = JSON.parse(out)
|
|
533
|
+
} catch {
|
|
534
|
+
// Offline, or not on that registry: the entry stays.
|
|
535
|
+
}
|
|
536
|
+
cache.set(name, times)
|
|
537
|
+
}
|
|
538
|
+
const at = cache.get(name)?.[version]
|
|
539
|
+
return at ? new Date(at) : null
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
function printList(title, items) {
|
|
544
|
+
if (items.length === 0) return
|
|
545
|
+
console.log(`\n${title}`)
|
|
546
|
+
for (const item of items) console.log(` ${item}`)
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
function cmdUpgrade(argv) {
|
|
550
|
+
parseArgs(argv)
|
|
551
|
+
const cwd = process.cwd()
|
|
552
|
+
const refusal = upgradeRefusal(cwd, { cliVersion: cliPkg.version })
|
|
553
|
+
if (refusal) {
|
|
554
|
+
console.error(refusal.message)
|
|
555
|
+
process.exit(1)
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
const report = runUpgrade({
|
|
559
|
+
cwd,
|
|
560
|
+
templateDir: TEMPLATE_DIR,
|
|
561
|
+
cliVersion: cliPkg.version,
|
|
562
|
+
publishedAt: npmPublishTimes(cwd),
|
|
563
|
+
})
|
|
564
|
+
const changed =
|
|
565
|
+
report.written.length +
|
|
566
|
+
report.deleted.length +
|
|
567
|
+
report.tsconfigs.length +
|
|
568
|
+
report.packageChanges.length +
|
|
569
|
+
report.ageRemoved.length >
|
|
570
|
+
0
|
|
571
|
+
|
|
572
|
+
const from = report.fromVersion && report.fromVersion !== cliPkg.version ? ` (from ${report.fromVersion})` : ""
|
|
573
|
+
if (!changed) {
|
|
574
|
+
console.log(`Already on create-grist-widget ${cliPkg.version}'s template: nothing to change.`)
|
|
575
|
+
} else {
|
|
576
|
+
console.log(`Upgraded to create-grist-widget ${cliPkg.version}'s template${from}.`)
|
|
577
|
+
}
|
|
578
|
+
printList("Template files rewritten:", report.written)
|
|
579
|
+
printList("Deleted (earlier templates wrote them, nothing uses them now):", report.deleted)
|
|
580
|
+
printList(
|
|
581
|
+
"Kept: earlier templates wrote these, but a file of yours still refers to them (delete them once it doesn't):",
|
|
582
|
+
report.keptRetired.map(({ file, by }) => `${file} (by ${by.join(", ")})`)
|
|
583
|
+
)
|
|
584
|
+
printList(
|
|
585
|
+
"Switched to grist-widget-sdk's compiler settings, keeping these types:",
|
|
586
|
+
report.tsconfigs.map(({ file, types }) => `${file} (types: ${types.join(", ") || "none"})`)
|
|
587
|
+
)
|
|
588
|
+
printList(
|
|
589
|
+
"Not switched to grist-widget-sdk's compiler settings (could not be read; do it by hand):",
|
|
590
|
+
report.tsconfigsUnreadable
|
|
591
|
+
)
|
|
592
|
+
printList("package.json:", report.packageChanges)
|
|
593
|
+
printList("pnpm age exceptions removed (old enough now that pnpm no longer needs them):", report.ageRemoved)
|
|
594
|
+
printList("pnpm age exceptions kept (their publish time could not be looked up):", report.ageUnknown)
|
|
595
|
+
if (!changed) return
|
|
596
|
+
|
|
597
|
+
console.log("\nNext:")
|
|
598
|
+
// Only what pnpm-lock.yaml records: dependencies, packageManager, age exceptions.
|
|
599
|
+
const lockfileChanges = report.packageChanges.filter((c) => /^(dependencies|devDependencies|packageManager)\b/.test(c))
|
|
600
|
+
if (lockfileChanges.length || report.ageRemoved.length) {
|
|
601
|
+
console.log(" pnpm install # update pnpm-lock.yaml")
|
|
602
|
+
}
|
|
603
|
+
console.log(" pnpm typecheck && pnpm lint && pnpm test")
|
|
604
|
+
console.log(" git diff # review, then commit and push dev")
|
|
605
|
+
console.log(
|
|
606
|
+
`\nAnything a template change needs beyond these files is in the changelog entries${from ? ` after ${report.fromVersion}` : ""}:` +
|
|
607
|
+
`\n ${CLI_CHANGELOG_URL}`
|
|
608
|
+
)
|
|
609
|
+
}
|
|
610
|
+
|
|
496
611
|
// ---------------------------------------------------------------------------
|
|
497
612
|
// list
|
|
498
613
|
// ---------------------------------------------------------------------------
|
|
@@ -534,6 +649,7 @@ async function main() {
|
|
|
534
649
|
}
|
|
535
650
|
if (first === "list") return cmdList(rest)
|
|
536
651
|
if (first === "use") return cmdUse(rest)
|
|
652
|
+
if (first === "upgrade") return cmdUpgrade(rest)
|
|
537
653
|
if (first === "init") return cmdInit(rest)
|
|
538
654
|
if (first === "create") return cmdCreate(rest)
|
|
539
655
|
// Bare invocation — `npm create grist-widget my-widget` (npm's `create`
|
package/package.json
CHANGED
package/src/deploy.mjs
CHANGED
|
@@ -464,6 +464,9 @@ function identityEdits({ packageName, title }) {
|
|
|
464
464
|
["package.json", [["grist-widget-template-vite", packageName]]],
|
|
465
465
|
["README.md", [["grist-widget-template-vite", packageName]]],
|
|
466
466
|
["index.html", [["<title>Grist Widget</title>", `<title>${title}</title>`]]],
|
|
467
|
+
["src/app/App.tsx", [['title: "Grist Widget Template",', `title: "${title}",`]]],
|
|
468
|
+
// Where the widget lived before the src/app/ layout: a repo copied from
|
|
469
|
+
// an older template repository still has it there.
|
|
467
470
|
["src/App.tsx", [['title: "Grist Widget Template",', `title: "${title}",`]]],
|
|
468
471
|
]
|
|
469
472
|
}
|
package/src/git-gate.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
// The safety gate `use <example-id>` rests on: overlayExampleSrc
|
|
2
|
-
// src/
|
|
1
|
+
// The safety gate `use <example-id>` rests on: overlayExampleSrc replaces
|
|
2
|
+
// src/app/ (the widget's own folder) with the example's files, so the only
|
|
3
3
|
// thing that makes that recoverable is a clean git tree to diff/revert
|
|
4
4
|
// against. `create`/`init` always leave a repo behind today (see
|
|
5
5
|
// bin/create-grist-widget.mjs's gitInitCommit), but `use` checks for one
|
package/src/upgrade.mjs
ADDED
|
@@ -0,0 +1,562 @@
|
|
|
1
|
+
// `create-grist-widget upgrade`: bring a scaffolded widget's template files
|
|
2
|
+
// up to this CLI's template (plan T2, docs/planning/2026-11-01-widget-lifecycle-plan.md).
|
|
3
|
+
//
|
|
4
|
+
// The template's files are an explicit list, TEMPLATE_FILES. upgrade
|
|
5
|
+
// overwrites those, deletes the files earlier templates wrote that are gone
|
|
6
|
+
// now (RETIRED_FILES), switches a tsconfig.app.json / tsconfig.node.json that
|
|
7
|
+
// predates the SDK's base once (SDK_TSCONFIGS), merges package.json's
|
|
8
|
+
// template-set scripts and dependency versions, and prunes pnpm age
|
|
9
|
+
// exceptions that no longer matter. It never touches anything else: src/app/
|
|
10
|
+
// (smoke test included), src/components/ui/, src/index.css, README.md and
|
|
11
|
+
// every file the widget added are the widget's own. It runs on a clean `dev`,
|
|
12
|
+
// so what it changed is one `git diff` to review.
|
|
13
|
+
|
|
14
|
+
import { execFileSync } from "node:child_process"
|
|
15
|
+
import {
|
|
16
|
+
cpSync,
|
|
17
|
+
existsSync,
|
|
18
|
+
mkdirSync,
|
|
19
|
+
readdirSync,
|
|
20
|
+
readFileSync,
|
|
21
|
+
rmSync,
|
|
22
|
+
writeFileSync,
|
|
23
|
+
} from "node:fs"
|
|
24
|
+
import path from "node:path"
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The files the template writes and keeps identical in every widget. Listed
|
|
28
|
+
* in the template's AGENTS.md and on the Upgrading a widget page too
|
|
29
|
+
* (tests/upgrade.test.mjs checks all three agree with the template itself).
|
|
30
|
+
*/
|
|
31
|
+
export const TEMPLATE_FILES = [
|
|
32
|
+
".github/dependabot.yml",
|
|
33
|
+
".github/workflows/ci.yml",
|
|
34
|
+
".github/workflows/deploy.yml",
|
|
35
|
+
".prettierignore",
|
|
36
|
+
".prettierrc",
|
|
37
|
+
"AGENTS.md",
|
|
38
|
+
"CLAUDE.md",
|
|
39
|
+
"components.json",
|
|
40
|
+
"eslint.config.js",
|
|
41
|
+
"src/components/theme-provider.tsx",
|
|
42
|
+
"src/lib/utils.ts",
|
|
43
|
+
"src/main.tsx",
|
|
44
|
+
"src/vite-env.d.ts",
|
|
45
|
+
"tsconfig.json",
|
|
46
|
+
"vite.config.ts",
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Files earlier templates wrote that the current one doesn't: upgrade
|
|
51
|
+
* deletes each one still there, unless a file the widget keeps still refers
|
|
52
|
+
* to it (then it says which, and leaves both alone).
|
|
53
|
+
*/
|
|
54
|
+
export const RETIRED_FILES = [
|
|
55
|
+
"public/vite.svg",
|
|
56
|
+
"scripts/deploy.mjs",
|
|
57
|
+
"src/assets/react.svg",
|
|
58
|
+
"src/components/channel-notice.tsx",
|
|
59
|
+
"src/components/grist-sdk-alerts.tsx",
|
|
60
|
+
"src/components/grist-status-chip.tsx",
|
|
61
|
+
"src/components/local-dev-notice.tsx",
|
|
62
|
+
"src/components/release-info.tsx",
|
|
63
|
+
"src/components/scaffold-footer.tsx",
|
|
64
|
+
"src/components/template-landing.tsx",
|
|
65
|
+
"src/lib/grist-handshake-status.ts",
|
|
66
|
+
"src/lib/showcase-routing.ts",
|
|
67
|
+
"src/lib/showcase-versions.ts",
|
|
68
|
+
"vite-widget-meta.ts",
|
|
69
|
+
]
|
|
70
|
+
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
// Versions
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
/** `[major, minor, patch]` of a plain version, or null. */
|
|
76
|
+
export function parseVersion(version) {
|
|
77
|
+
const m = /^v?(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/.exec(String(version ?? "").trim())
|
|
78
|
+
return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Lowest version a `^x.y.z`, `~x.y.z` or `x.y.z` range allows, or null for any other range. */
|
|
82
|
+
export function rangeFloor(range) {
|
|
83
|
+
const m = /^[\^~]?(\d+\.\d+\.\d+(?:[-+][\w.-]+)?)$/.exec(String(range ?? "").trim())
|
|
84
|
+
return m ? parseVersion(m[1]) : null
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function compareVersions(a, b) {
|
|
88
|
+
for (let i = 0; i < 3; i++) if (a[i] !== b[i]) return a[i] < b[i] ? -1 : 1
|
|
89
|
+
return 0
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ---------------------------------------------------------------------------
|
|
93
|
+
// Refusals
|
|
94
|
+
// ---------------------------------------------------------------------------
|
|
95
|
+
|
|
96
|
+
function git(cwd, args) {
|
|
97
|
+
return execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim()
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function readJson(file) {
|
|
101
|
+
try {
|
|
102
|
+
return JSON.parse(readFileSync(file, "utf8"))
|
|
103
|
+
} catch {
|
|
104
|
+
return null
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const UPGRADING_URL = "https://gristwidgets.com/docs/cli/upgrading"
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Why `upgrade` must not run in `cwd`, or null when it may.
|
|
112
|
+
*
|
|
113
|
+
* @param {string} cwd
|
|
114
|
+
* @param {{ cliVersion: string }} options
|
|
115
|
+
* @returns {null | { reason: string, message: string }}
|
|
116
|
+
*/
|
|
117
|
+
export function upgradeRefusal(cwd, { cliVersion }) {
|
|
118
|
+
const refuse = (reason, message) => ({ reason, message: `upgrade: ${message}` })
|
|
119
|
+
|
|
120
|
+
try {
|
|
121
|
+
git(cwd, ["rev-parse", "--is-inside-work-tree"])
|
|
122
|
+
} catch {
|
|
123
|
+
return refuse(
|
|
124
|
+
"no-repo",
|
|
125
|
+
'this command rewrites the template\'s files and must run inside a git repository, so the change is easy to review and undo. Run "git init" first.'
|
|
126
|
+
)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const pkg = readJson(path.join(cwd, "package.json"))
|
|
130
|
+
const deps = { ...pkg?.dependencies, ...pkg?.devDependencies }
|
|
131
|
+
if (!pkg || !("grist-widget-sdk" in deps)) {
|
|
132
|
+
return refuse("not-a-widget", "run it at the root of a widget made with create-grist-widget (no package.json depending on grist-widget-sdk here).")
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
let branch = ""
|
|
136
|
+
try {
|
|
137
|
+
branch = git(cwd, ["rev-parse", "--abbrev-ref", "HEAD"])
|
|
138
|
+
} catch {
|
|
139
|
+
// No commit yet: not on main either.
|
|
140
|
+
}
|
|
141
|
+
if (branch === "main") {
|
|
142
|
+
return refuse("on-main", 'run it on "dev" (git switch dev): main only changes by merging dev into it, after the deploy preview there has been checked.')
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (git(cwd, ["status", "--porcelain"])) {
|
|
146
|
+
return refuse("dirty", "the working tree has uncommitted changes. Commit or stash them first, so what upgrade changes is one diff to review and undo.")
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (existsSync(path.join(cwd, "src", "App.tsx"))) {
|
|
150
|
+
return refuse(
|
|
151
|
+
"old-layout",
|
|
152
|
+
`this widget is still in src/App.tsx. Widgets now live in src/app/, which the template's src/main.tsx imports: move it there first (${UPGRADING_URL}#widgets-scaffolded-before-srcapp), then run upgrade again.`
|
|
153
|
+
)
|
|
154
|
+
}
|
|
155
|
+
if (!existsSync(path.join(cwd, "src", "app", "App.tsx"))) {
|
|
156
|
+
return refuse("no-entry", "src/app/App.tsx is missing. The template's src/main.tsx and smoke test import the widget from there.")
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const madeWith = parseVersion(pkg.createGristWidgetVersion)
|
|
160
|
+
const running = parseVersion(cliVersion)
|
|
161
|
+
if (madeWith && running && compareVersions(madeWith, running) > 0) {
|
|
162
|
+
return refuse(
|
|
163
|
+
"newer-widget",
|
|
164
|
+
`this widget is already on create-grist-widget ${pkg.createGristWidgetVersion}'s template, newer than this CLI (${cliVersion}). Run the version it depends on: pnpm exec create-grist-widget upgrade.`
|
|
165
|
+
)
|
|
166
|
+
}
|
|
167
|
+
return null
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ---------------------------------------------------------------------------
|
|
171
|
+
// package.json
|
|
172
|
+
// ---------------------------------------------------------------------------
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* The widget's package.json with the template's scripts and dependency
|
|
176
|
+
* versions applied: each template script replaces the widget's of the same
|
|
177
|
+
* name; each template dependency is added if missing and raised to the
|
|
178
|
+
* template's range if the widget's allows older versions. A range that isn't
|
|
179
|
+
* plain semver on either side (workspace:, file:, a tag...) and everything
|
|
180
|
+
* else the widget has are kept.
|
|
181
|
+
*
|
|
182
|
+
* @returns {{ pkg: object, changes: string[] }}
|
|
183
|
+
*/
|
|
184
|
+
export function mergePackageJson(widgetPkg, templatePkg, cliVersion) {
|
|
185
|
+
const pkg = structuredClone(widgetPkg)
|
|
186
|
+
const changes = []
|
|
187
|
+
|
|
188
|
+
for (const [name, command] of Object.entries(templatePkg.scripts ?? {})) {
|
|
189
|
+
pkg.scripts ??= {}
|
|
190
|
+
if (pkg.scripts[name] === command) continue
|
|
191
|
+
changes.push(
|
|
192
|
+
name in pkg.scripts
|
|
193
|
+
? `scripts.${name}: "${pkg.scripts[name]}" -> "${command}"`
|
|
194
|
+
: `scripts.${name}: added "${command}"`
|
|
195
|
+
)
|
|
196
|
+
pkg.scripts[name] = command
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
for (const section of ["dependencies", "devDependencies"]) {
|
|
200
|
+
for (const [name, range] of Object.entries(templatePkg[section] ?? {})) {
|
|
201
|
+
const owner = ["dependencies", "devDependencies"].find((s) => pkg[s] && name in pkg[s])
|
|
202
|
+
if (!owner) {
|
|
203
|
+
pkg[section] ??= {}
|
|
204
|
+
pkg[section][name] = range
|
|
205
|
+
changes.push(`${section}.${name}: added ${range}`)
|
|
206
|
+
continue
|
|
207
|
+
}
|
|
208
|
+
const current = pkg[owner][name]
|
|
209
|
+
const theirs = rangeFloor(current)
|
|
210
|
+
const ours = rangeFloor(range)
|
|
211
|
+
if (!theirs || !ours || compareVersions(ours, theirs) <= 0) continue
|
|
212
|
+
pkg[owner][name] = range
|
|
213
|
+
changes.push(`${owner}.${name}: ${current} -> ${range}`)
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const manager = /^([^@]+)@(.+)$/.exec(templatePkg.packageManager ?? "")
|
|
218
|
+
const theirManager = /^([^@]+)@(.+)$/.exec(pkg.packageManager ?? "")
|
|
219
|
+
if (manager && !theirManager) {
|
|
220
|
+
pkg.packageManager = templatePkg.packageManager
|
|
221
|
+
changes.push(`packageManager: added ${templatePkg.packageManager}`)
|
|
222
|
+
} else if (manager && theirManager && manager[1] === theirManager[1]) {
|
|
223
|
+
const ours = parseVersion(manager[2].split("+")[0])
|
|
224
|
+
const theirs = parseVersion(theirManager[2].split("+")[0])
|
|
225
|
+
if (ours && theirs && compareVersions(ours, theirs) > 0) {
|
|
226
|
+
changes.push(`packageManager: ${pkg.packageManager} -> ${templatePkg.packageManager}`)
|
|
227
|
+
pkg.packageManager = templatePkg.packageManager
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (pkg.createGristWidgetVersion !== cliVersion) {
|
|
232
|
+
changes.push(`createGristWidgetVersion: ${pkg.createGristWidgetVersion ?? "(none)"} -> ${cliVersion}`)
|
|
233
|
+
pkg.createGristWidgetVersion = cliVersion
|
|
234
|
+
}
|
|
235
|
+
return { pkg, changes }
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// ---------------------------------------------------------------------------
|
|
239
|
+
// pnpm age exceptions
|
|
240
|
+
// ---------------------------------------------------------------------------
|
|
241
|
+
|
|
242
|
+
// pnpm 11's default minimumReleaseAge, in minutes.
|
|
243
|
+
const DEFAULT_MIN_AGE_MINUTES = 24 * 60
|
|
244
|
+
|
|
245
|
+
/** The `minimumReleaseAge` a pnpm-workspace.yaml sets, in minutes, or pnpm's default. */
|
|
246
|
+
export function minimumReleaseAge(workspaceYaml) {
|
|
247
|
+
const m = /^minimumReleaseAge:\s*(\d+)\s*(#.*)?$/m.exec(workspaceYaml)
|
|
248
|
+
return m ? Number(m[1]) : DEFAULT_MIN_AGE_MINUTES
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** `{ name, versions }` for a `name@1.2.3` (or `name@1.2.3 || 1.2.4`) entry, or null for any other. */
|
|
252
|
+
export function parseExactEntry(entry) {
|
|
253
|
+
const m = /^(@?[^@\s]+)@(.+)$/.exec(entry)
|
|
254
|
+
if (!m) return null
|
|
255
|
+
const versions = m[2].split("||").map((v) => v.trim())
|
|
256
|
+
return versions.every((v) => parseVersion(v)) ? { name: m[1], versions } : null
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const KEY = "minimumReleaseAgeExclude"
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Walk the `minimumReleaseAgeExclude` list (block or flow style), passing
|
|
263
|
+
* each entry to `decide`, which returns it unchanged, a replacement, or null
|
|
264
|
+
* to drop it. The key goes too once no entry is left. Its own parser rather
|
|
265
|
+
* than age-exceptions.mjs's: pnpm writes several versions of a package as
|
|
266
|
+
* one `name@1.2.3 || 1.2.4` entry, with spaces in it.
|
|
267
|
+
*/
|
|
268
|
+
function rewriteAgeExceptions(workspaceYaml, decide) {
|
|
269
|
+
const flowRe = new RegExp(`^${KEY}:\\s*\\[(.*)\\]\\s*(#.*)?$`)
|
|
270
|
+
const blockRe = new RegExp(`^${KEY}:\\s*(#.*)?$`)
|
|
271
|
+
const out = []
|
|
272
|
+
let keyIndex = -1 // index in `out` of the block's key line, while in the block
|
|
273
|
+
let kept = 0
|
|
274
|
+
const closeBlock = () => {
|
|
275
|
+
if (keyIndex >= 0 && kept === 0) out.splice(keyIndex, 1)
|
|
276
|
+
keyIndex = -1
|
|
277
|
+
}
|
|
278
|
+
for (const line of workspaceYaml.split("\n")) {
|
|
279
|
+
const bare = line.replace(/\r$/, "")
|
|
280
|
+
const flow = flowRe.exec(bare)
|
|
281
|
+
if (flow) {
|
|
282
|
+
closeBlock()
|
|
283
|
+
const items = flow[1]
|
|
284
|
+
.split(",")
|
|
285
|
+
.map((item) => item.trim())
|
|
286
|
+
.filter(Boolean)
|
|
287
|
+
.flatMap((item) => {
|
|
288
|
+
const quote = /^["']/.exec(item)?.[0] ?? ""
|
|
289
|
+
const next = decide(item.replace(/^["']|["']$/g, ""))
|
|
290
|
+
return next === null ? [] : [`${quote}${next}${quote}`]
|
|
291
|
+
})
|
|
292
|
+
if (items.length) out.push(`${KEY}: [${items.join(", ")}]${flow[2] ? ` ${flow[2]}` : ""}`)
|
|
293
|
+
continue
|
|
294
|
+
}
|
|
295
|
+
if (blockRe.test(bare)) {
|
|
296
|
+
closeBlock()
|
|
297
|
+
keyIndex = out.length
|
|
298
|
+
kept = 0
|
|
299
|
+
out.push(line)
|
|
300
|
+
continue
|
|
301
|
+
}
|
|
302
|
+
if (keyIndex >= 0) {
|
|
303
|
+
const item = /^(\s+-\s+)(["']?)(.+?)\2(\s+#.*)?\s*$/.exec(bare)
|
|
304
|
+
if (item) {
|
|
305
|
+
const next = decide(item[3])
|
|
306
|
+
if (next !== null) {
|
|
307
|
+
out.push(next === item[3] ? line : `${item[1]}${item[2]}${next}${item[2]}${item[4] ?? ""}`)
|
|
308
|
+
kept++
|
|
309
|
+
}
|
|
310
|
+
continue
|
|
311
|
+
}
|
|
312
|
+
if (!/^\s*(#.*)?$/.test(bare)) closeBlock() // the next key: the list is over
|
|
313
|
+
}
|
|
314
|
+
out.push(line)
|
|
315
|
+
}
|
|
316
|
+
closeBlock()
|
|
317
|
+
return out.join("\n")
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* pnpm-workspace.yaml without the `minimumReleaseAgeExclude` versions that
|
|
322
|
+
* are now older than its `minimumReleaseAge`: those exceptions no longer
|
|
323
|
+
* change what pnpm installs. Entries naming no exact version, and versions
|
|
324
|
+
* whose publish time can't be looked up, are kept.
|
|
325
|
+
*
|
|
326
|
+
* @param {string} workspaceYaml
|
|
327
|
+
* @param {{ publishedAt: (name: string, version: string) => Date | null, now: Date }} options
|
|
328
|
+
* @returns {{ yaml: string, removed: string[], unknown: string[] }}
|
|
329
|
+
*/
|
|
330
|
+
export function pruneAgeExceptions(workspaceYaml, { publishedAt, now }) {
|
|
331
|
+
const minAgeMs = minimumReleaseAge(workspaceYaml) * 60 * 1000
|
|
332
|
+
const removed = []
|
|
333
|
+
const unknown = []
|
|
334
|
+
const yaml = rewriteAgeExceptions(workspaceYaml, (entry) => {
|
|
335
|
+
const parsed = parseExactEntry(entry)
|
|
336
|
+
if (!parsed) return entry
|
|
337
|
+
const keep = parsed.versions.filter((version) => {
|
|
338
|
+
const at = publishedAt(parsed.name, version)
|
|
339
|
+
if (!at) {
|
|
340
|
+
unknown.push(`${parsed.name}@${version}`)
|
|
341
|
+
return true
|
|
342
|
+
}
|
|
343
|
+
if (now.getTime() - at.getTime() < minAgeMs) return true
|
|
344
|
+
removed.push(`${parsed.name}@${version}`)
|
|
345
|
+
return false
|
|
346
|
+
})
|
|
347
|
+
if (keep.length === parsed.versions.length) return entry
|
|
348
|
+
return keep.length ? `${parsed.name}@${keep.join(" || ")}` : null
|
|
349
|
+
})
|
|
350
|
+
return { yaml: removed.length ? yaml : workspaceYaml, removed, unknown }
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// ---------------------------------------------------------------------------
|
|
354
|
+
// Retired files
|
|
355
|
+
// ---------------------------------------------------------------------------
|
|
356
|
+
|
|
357
|
+
const SKIP_DIRS = new Set(["node_modules", "dist", ".git", "coverage"])
|
|
358
|
+
const SCANNED = /\.(tsx?|jsx?|mjs|cjs|css|html|json|ya?ml)$/
|
|
359
|
+
|
|
360
|
+
function listFiles(root, dir = root) {
|
|
361
|
+
const out = []
|
|
362
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
363
|
+
if (SKIP_DIRS.has(entry.name)) continue
|
|
364
|
+
const full = path.join(dir, entry.name)
|
|
365
|
+
if (entry.isDirectory()) out.push(...listFiles(root, full))
|
|
366
|
+
else out.push(path.relative(root, full).split(path.sep).join("/"))
|
|
367
|
+
}
|
|
368
|
+
return out
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function escapeRegExp(text) {
|
|
372
|
+
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* How another file would refer to `file`: a TypeScript module by an import
|
|
377
|
+
* path ending in its name, with or without the extension (`@/lib/x`,
|
|
378
|
+
* `./x.tsx`); anything else (a script, an asset) by its full file name.
|
|
379
|
+
*/
|
|
380
|
+
function referencePattern(file) {
|
|
381
|
+
const base = path.posix.basename(file)
|
|
382
|
+
if (/\.tsx?$/.test(base)) {
|
|
383
|
+
const stem = base.replace(/\.tsx?$/, "")
|
|
384
|
+
return new RegExp(`/${escapeRegExp(stem)}(\\.(tsx?|jsx?))?["'\`]`)
|
|
385
|
+
}
|
|
386
|
+
return new RegExp(`(^|[/"'\`\\s(])${escapeRegExp(base)}`, "m")
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* Which retired files in `cwd` nothing refers to any more (`remove`), and
|
|
391
|
+
* which are still referred to, by what (`keep`). Run after the template's
|
|
392
|
+
* files are written, so their new content is what counts.
|
|
393
|
+
*/
|
|
394
|
+
export function retiredFilesPlan(cwd) {
|
|
395
|
+
const present = RETIRED_FILES.filter((file) => existsSync(path.join(cwd, file)))
|
|
396
|
+
const scanned = listFiles(cwd).filter(
|
|
397
|
+
(file) => SCANNED.test(file) && !present.includes(file) && file !== "pnpm-lock.yaml" && file !== "package-lock.json"
|
|
398
|
+
)
|
|
399
|
+
const contents = new Map(scanned.map((file) => [file, readFileSync(path.join(cwd, file), "utf8")]))
|
|
400
|
+
const remove = []
|
|
401
|
+
const keep = []
|
|
402
|
+
for (const file of present) {
|
|
403
|
+
const pattern = referencePattern(file)
|
|
404
|
+
const by = scanned.filter((other) => pattern.test(contents.get(other)))
|
|
405
|
+
if (by.length) keep.push({ file, by })
|
|
406
|
+
else remove.push(file)
|
|
407
|
+
}
|
|
408
|
+
return { remove, keep }
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function removeEmptyParents(cwd, file) {
|
|
412
|
+
let dir = path.dirname(path.join(cwd, file))
|
|
413
|
+
while (dir.startsWith(cwd + path.sep) && readdirSync(dir).length === 0) {
|
|
414
|
+
rmSync(dir, { recursive: true })
|
|
415
|
+
dir = path.dirname(dir)
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// ---------------------------------------------------------------------------
|
|
420
|
+
// tsconfig.app.json / tsconfig.node.json
|
|
421
|
+
// ---------------------------------------------------------------------------
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* The widget's TypeScript configs. They are the widget's (its paths and
|
|
425
|
+
* types): their compiler settings come from the grist-widget-sdk base they
|
|
426
|
+
* extend. One written before that base existed extends nothing and carries
|
|
427
|
+
* those settings itself, so upgrade switches it to the template's once.
|
|
428
|
+
*/
|
|
429
|
+
export const SDK_TSCONFIGS = ["tsconfig.app.json", "tsconfig.node.json"]
|
|
430
|
+
|
|
431
|
+
/** JSON with comments and trailing commas, as tsconfig files allow. */
|
|
432
|
+
export function parseJsonc(text) {
|
|
433
|
+
let out = ""
|
|
434
|
+
let inString = false
|
|
435
|
+
for (let i = 0; i < text.length; i++) {
|
|
436
|
+
const c = text[i]
|
|
437
|
+
if (inString) {
|
|
438
|
+
out += c
|
|
439
|
+
if (c === "\\") out += text[++i] ?? ""
|
|
440
|
+
else if (c === '"') inString = false
|
|
441
|
+
continue
|
|
442
|
+
}
|
|
443
|
+
if (c === '"') {
|
|
444
|
+
inString = true
|
|
445
|
+
out += c
|
|
446
|
+
} else if (c === "/" && text[i + 1] === "/") {
|
|
447
|
+
while (i < text.length && text[i] !== "\n") i++
|
|
448
|
+
out += "\n"
|
|
449
|
+
} else if (c === "/" && text[i + 1] === "*") {
|
|
450
|
+
const end = text.indexOf("*/", i + 2)
|
|
451
|
+
if (end < 0) break
|
|
452
|
+
i = end + 1
|
|
453
|
+
} else {
|
|
454
|
+
out += c
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
return JSON.parse(out.replace(/,(\s*[}\]])/g, "$1"))
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* The template's version of a widget tsconfig that extends nothing yet, with
|
|
462
|
+
* the widget's `compilerOptions.types` added to the template's. Null for one
|
|
463
|
+
* that extends a base already (the SDK's, or one the widget chose).
|
|
464
|
+
*
|
|
465
|
+
* @returns {null | { content: string, types: string[] } | { unreadable: true }}
|
|
466
|
+
*/
|
|
467
|
+
export function migratedTsconfig(widgetText, templateText) {
|
|
468
|
+
let widget
|
|
469
|
+
try {
|
|
470
|
+
widget = parseJsonc(widgetText)
|
|
471
|
+
} catch {
|
|
472
|
+
return { unreadable: true }
|
|
473
|
+
}
|
|
474
|
+
if (widget?.extends !== undefined) return null
|
|
475
|
+
const types = widget?.compilerOptions?.types ?? []
|
|
476
|
+
const templateTypes = parseJsonc(templateText)?.compilerOptions?.types ?? []
|
|
477
|
+
const merged = [...templateTypes, ...types.filter((t) => !templateTypes.includes(t))]
|
|
478
|
+
const content = templateText.replace(/"types":\s*\[[^\]]*\]/, `"types": ${JSON.stringify(merged).replace(/","/g, '", "')}`)
|
|
479
|
+
return { content, types: merged }
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// ---------------------------------------------------------------------------
|
|
483
|
+
// The upgrade
|
|
484
|
+
// ---------------------------------------------------------------------------
|
|
485
|
+
|
|
486
|
+
/**
|
|
487
|
+
* Apply `templateDir`'s template to the widget at `cwd`. Call
|
|
488
|
+
* {@link upgradeRefusal} first.
|
|
489
|
+
*
|
|
490
|
+
* @param {{
|
|
491
|
+
* cwd: string,
|
|
492
|
+
* templateDir: string,
|
|
493
|
+
* cliVersion: string,
|
|
494
|
+
* publishedAt: (name: string, version: string) => Date | null,
|
|
495
|
+
* now?: Date,
|
|
496
|
+
* }} options
|
|
497
|
+
*/
|
|
498
|
+
export function runUpgrade({ cwd, templateDir, cliVersion, publishedAt, now = new Date() }) {
|
|
499
|
+
const report = {
|
|
500
|
+
fromVersion: readJson(path.join(cwd, "package.json"))?.createGristWidgetVersion ?? null,
|
|
501
|
+
written: [],
|
|
502
|
+
deleted: [],
|
|
503
|
+
keptRetired: [],
|
|
504
|
+
tsconfigs: [],
|
|
505
|
+
tsconfigsUnreadable: [],
|
|
506
|
+
packageChanges: [],
|
|
507
|
+
ageRemoved: [],
|
|
508
|
+
ageUnknown: [],
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
for (const file of TEMPLATE_FILES) {
|
|
512
|
+
const from = path.join(templateDir, file)
|
|
513
|
+
const to = path.join(cwd, file)
|
|
514
|
+
const next = readFileSync(from)
|
|
515
|
+
if (existsSync(to) && readFileSync(to).equals(next)) continue
|
|
516
|
+
mkdirSync(path.dirname(to), { recursive: true })
|
|
517
|
+
cpSync(from, to)
|
|
518
|
+
report.written.push(file)
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
for (const file of SDK_TSCONFIGS) {
|
|
522
|
+
const to = path.join(cwd, file)
|
|
523
|
+
const templateText = readFileSync(path.join(templateDir, file), "utf8")
|
|
524
|
+
if (!existsSync(to)) {
|
|
525
|
+
writeFileSync(to, templateText)
|
|
526
|
+
report.tsconfigs.push({ file, types: parseJsonc(templateText)?.compilerOptions?.types ?? [] })
|
|
527
|
+
continue
|
|
528
|
+
}
|
|
529
|
+
const migrated = migratedTsconfig(readFileSync(to, "utf8"), templateText)
|
|
530
|
+
if (!migrated) continue
|
|
531
|
+
if (migrated.unreadable) {
|
|
532
|
+
report.tsconfigsUnreadable.push(file)
|
|
533
|
+
continue
|
|
534
|
+
}
|
|
535
|
+
writeFileSync(to, migrated.content)
|
|
536
|
+
report.tsconfigs.push({ file, types: migrated.types })
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
const { remove, keep } = retiredFilesPlan(cwd)
|
|
540
|
+
for (const file of remove) {
|
|
541
|
+
rmSync(path.join(cwd, file))
|
|
542
|
+
removeEmptyParents(cwd, file)
|
|
543
|
+
report.deleted.push(file)
|
|
544
|
+
}
|
|
545
|
+
report.keptRetired = keep
|
|
546
|
+
|
|
547
|
+
const pkgPath = path.join(cwd, "package.json")
|
|
548
|
+
const templatePkg = JSON.parse(readFileSync(path.join(templateDir, "package.json"), "utf8"))
|
|
549
|
+
const { pkg, changes } = mergePackageJson(JSON.parse(readFileSync(pkgPath, "utf8")), templatePkg, cliVersion)
|
|
550
|
+
if (changes.length) writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + "\n")
|
|
551
|
+
report.packageChanges = changes
|
|
552
|
+
|
|
553
|
+
const workspacePath = path.join(cwd, "pnpm-workspace.yaml")
|
|
554
|
+
if (existsSync(workspacePath)) {
|
|
555
|
+
const before = readFileSync(workspacePath, "utf8")
|
|
556
|
+
const { yaml, removed, unknown } = pruneAgeExceptions(before, { publishedAt, now })
|
|
557
|
+
if (yaml !== before) writeFileSync(workspacePath, yaml)
|
|
558
|
+
report.ageRemoved = removed
|
|
559
|
+
report.ageUnknown = unknown
|
|
560
|
+
}
|
|
561
|
+
return report
|
|
562
|
+
}
|
package/template/AGENTS.md
CHANGED
|
@@ -11,21 +11,42 @@
|
|
|
11
11
|
|
|
12
12
|
## The widget contract
|
|
13
13
|
|
|
14
|
-
`src/
|
|
14
|
+
The widget lives in `src/app/`. Its entry point, `src/app/App.tsx`, has three exports:
|
|
15
15
|
|
|
16
16
|
```tsx
|
|
17
17
|
export const GRIST_OPTIONS: UseGristOptions = { requiredAccess: "read table" }
|
|
18
18
|
export const WIDGET_METADATA = { title: "...", description: "..." } as const
|
|
19
|
-
export function App() {
|
|
19
|
+
export default function App() {
|
|
20
20
|
const w = useGrist()
|
|
21
21
|
// ...
|
|
22
22
|
}
|
|
23
23
|
```
|
|
24
24
|
|
|
25
|
+
`GRIST_OPTIONS` may live in a file of its own, as long as `App.tsx` re-exports it: the
|
|
26
|
+
template's `src/main.tsx` imports both from `./app/App`.
|
|
27
|
+
|
|
25
28
|
`src/main.tsx` is one call, `mountGristWidget({ App, options: GRIST_OPTIONS, dev })` from
|
|
26
29
|
`grist-widget-sdk/app`: inside Grist it renders `<App />` in `GristWidgetProvider` and
|
|
27
30
|
`GristBoundary`, with the SDK's alerts and Grist's light/dark theme. Access level, column
|
|
28
|
-
mapping, and rendering all live in `src/App.tsx` — that's the
|
|
31
|
+
mapping, and rendering all live in `src/app/App.tsx` — that's the file to edit.
|
|
32
|
+
|
|
33
|
+
### What is the template's, what is the widget's
|
|
34
|
+
|
|
35
|
+
- **The template's:** `src/main.tsx`, `src/components/theme-provider.tsx`,
|
|
36
|
+
`src/lib/utils.ts`, `src/vite-env.d.ts`, the workflows and Dependabot config,
|
|
37
|
+
`vite.config.ts`, `tsconfig.json`, `eslint.config.js`, the Prettier config,
|
|
38
|
+
`components.json`, `CLAUDE.md` and this file.
|
|
39
|
+
Leave them as they are: `pnpm exec create-grist-widget upgrade` rewrites them to the
|
|
40
|
+
template of the `create-grist-widget` version the widget depends on
|
|
41
|
+
(<https://gristwidgets.com/docs/cli/upgrading>), and a local edit would be lost.
|
|
42
|
+
- **The widget's:** everything else. `src/app/` holds its code and tests, the smoke test
|
|
43
|
+
included: put every new component, hook, helper and asset there. `src/components/ui/`
|
|
44
|
+
holds the shadcn primitives, where `shadcn add` writes (next to any other registry
|
|
45
|
+
folder it installs), and `src/index.css` the theme. `tsconfig.app.json` and
|
|
46
|
+
`tsconfig.node.json` keep the widget's own paths and types (their compiler settings come
|
|
47
|
+
from `grist-widget-sdk`). `README.md`, `index.html`, `public/` and
|
|
48
|
+
`pnpm-workspace.yaml` are the widget's too, and so is `package.json`, apart from the
|
|
49
|
+
scripts and dependency versions the template sets.
|
|
29
50
|
|
|
30
51
|
## Operating contract
|
|
31
52
|
|
|
@@ -37,7 +58,7 @@ mapping, and rendering all live in `src/App.tsx` — that's the one file to edit
|
|
|
37
58
|
real Grist, so a fake local preview would teach the wrong lesson. Trust `pnpm test`
|
|
38
59
|
(headless, real assertions) for local feedback, and the `dev`-branch loop below for real
|
|
39
60
|
behavior — don't build or suggest a seeded `pnpm dev` preview.
|
|
40
|
-
3. **Widget changes get a test.** `src/App.test.tsx` uses `renderWithGrist` + `presets` from
|
|
61
|
+
3. **Widget changes get a test.** `src/app/App.test.tsx` uses `renderWithGrist` + `presets` from
|
|
41
62
|
`grist-widget-sdk/emulator/testing` — no browser or real Grist doc needed. Run `pnpm test`.
|
|
42
63
|
4. **Always develop on `dev`, release by merging to `main`.** Commit and push to `dev` for
|
|
43
64
|
every change — it auto-deploys a live preview at `.../dev/` that self-reloads inside an
|
|
@@ -45,6 +66,10 @@ mapping, and rendering all live in `src/App.tsx` — that's the one file to edit
|
|
|
45
66
|
opening the `dev` → `main` PR: merging without a version bump publishes nothing (the
|
|
46
67
|
release build silently no-ops whenever that version's directory already exists — the PR
|
|
47
68
|
merges cleanly and CI reports success either way).
|
|
69
|
+
5. **Template updates arrive with `create-grist-widget`.** Dependabot opens a pull request of
|
|
70
|
+
its own for it. Once that is merged into `dev`, run `pnpm exec create-grist-widget upgrade`
|
|
71
|
+
on `dev` in a follow-up pull request, then `pnpm install`, `pnpm typecheck`, `pnpm lint` and
|
|
72
|
+
`pnpm test`. It rewrites only the template's files (listed above) and says what it changed.
|
|
48
73
|
|
|
49
74
|
## Commands
|
|
50
75
|
|
|
@@ -58,14 +83,16 @@ mapping, and rendering all live in `src/App.tsx` — that's the one file to edit
|
|
|
58
83
|
| Format | `pnpm format` |
|
|
59
84
|
| Type-check | `pnpm typecheck` |
|
|
60
85
|
| Preview a production build | `pnpm preview` |
|
|
86
|
+
| Bring the template's files up to date (on `dev`, after updating create-grist-widget) | `pnpm exec create-grist-widget upgrade` |
|
|
61
87
|
|
|
62
88
|
## Path map
|
|
63
89
|
|
|
64
90
|
| Concept | Path |
|
|
65
91
|
| --- | --- |
|
|
66
|
-
| The widget | `src/
|
|
67
|
-
|
|
|
68
|
-
|
|
|
92
|
+
| The widget (everything in it is the widget's own) | `src/app/` |
|
|
93
|
+
| Its entry point (GRIST_OPTIONS, WIDGET_METADATA, App) | `src/app/App.tsx` |
|
|
94
|
+
| Widget test (the pattern to copy for new tests) | `src/app/App.test.tsx` |
|
|
95
|
+
| Smoke test (one smokeTestWidget call; CI runs it on SDK updates) | `src/app/App.smoke.test.tsx` |
|
|
69
96
|
| Grist wiring (mountGristWidget, inside vs. outside Grist) | `src/main.tsx` |
|
|
70
97
|
| ui primitives | `src/components/ui/` |
|
|
71
98
|
| Deploy workflow (runs grist-widget-deploy from the create-grist-widget devDependency: version guard, channel logic) | `.github/workflows/deploy.yml` |
|
|
@@ -85,6 +112,11 @@ creates a `gh-pages` branch — these settings can't be applied before that exis
|
|
|
85
112
|
2. If that first workflow run fails with a permissions error pushing to `gh-pages`:
|
|
86
113
|
Settings → Actions → General → Workflow permissions → **Read and write permissions**.
|
|
87
114
|
Most repos don't need this; it depends on account/org defaults.
|
|
115
|
+
3. After the first release (which commits to `main` and resets `dev`), a branch ruleset:
|
|
116
|
+
`main` requires a pull request and the `check` status, and blocks force pushes and
|
|
117
|
+
deletion; `dev` blocks force pushes and deletion. An agent acts through the owner's
|
|
118
|
+
account, so these rules don't tell it apart: they keep red changes off `main` whoever
|
|
119
|
+
merges. Ask the owner to set them; they need admin access.
|
|
88
120
|
|
|
89
121
|
Full detail, including the two live URLs (`/latest/` and `/v<version>/`): `README.md`.
|
|
90
122
|
|
|
@@ -95,10 +127,12 @@ Full detail, including the two live URLs (`/latest/` and `/v<version>/`): `READM
|
|
|
95
127
|
template works (see above); `pnpm test` is the trusted local loop, real Grist via `/dev/`
|
|
96
128
|
is the trusted real one
|
|
97
129
|
- Merging `dev` into `main` without bumping `package.json`'s `version` first
|
|
130
|
+
- Adding widget code outside `src/app/`, or editing the template's files to change the
|
|
131
|
+
widget (see the widget contract above)
|
|
98
132
|
- Adding `minimumReleaseAgeExclude` entries to `pnpm-workspace.yaml` to get an install or a
|
|
99
133
|
check green, or deleting the ones pnpm wrote for `grist-widget-sdk` and `create-grist-widget` on release day (see
|
|
100
134
|
`README.md`): pnpm's 24 h age check protects the widget's supply chain
|
|
101
135
|
- Merging a Dependabot pull request whose `ci.yml` checks are red, or retargeting one at
|
|
102
|
-
`main`: updates go through `dev` like any other change
|
|
103
|
-
|
|
136
|
+
`main`: updates go through `dev` like any other change, template-file updates
|
|
137
|
+
(`pnpm exec create-grist-widget upgrade`) included —
|
|
104
138
|
<https://gristwidgets.com/docs/cli/upgrading>
|
package/template/README.md
CHANGED
|
@@ -12,12 +12,14 @@ Opened outside a Grist iframe, the page is never a live preview of the widget it
|
|
|
12
12
|
|
|
13
13
|
When actually embedded, the widget shows connection state only when something is wrong: until Grist is connected, or if the connection is lost or fails, `GristBoundary` replaces the widget with a connecting / unavailable / error screen (the error one with a retry button); once the widget is showing, the SDK's alerts report what needs attention (column mapping, access level, a failed write…) above it. Nothing is drawn over the widget while all is well. Pass your own `Alerts` component to `mountGristWidget()` to render them differently, or `Alerts: null` for none.
|
|
14
14
|
|
|
15
|
-
`src/App.tsx
|
|
15
|
+
The widget lives in `src/app/`: `App.tsx`, and any components, hooks, helpers or assets it needs. `src/components/ui/` (shadcn primitives) and `src/index.css` (the theme) are yours too. `src/main.tsx`, the workflows and the Vite and ESLint config are the template's (`AGENTS.md` lists them): leave them as they are: `pnpm exec create-grist-widget upgrade`, run on `dev`, rewrites them when `create-grist-widget` is updated ([Upgrading a widget](https://gristwidgets.com/docs/cli/upgrading)).
|
|
16
|
+
|
|
17
|
+
`src/app/App.tsx` uses `useGrist<TaskRow, TaskMapped>()` for the selected row (`w.record`, `w.mode`) and remounts the UI with `key={rowKey}` when the row changes. It ships with two mapped columns (`title`, `done`) and an editable form that saves back via `w.table.update(...)`/`w.mapBack(...)` — the smallest real starting point, not a placeholder. See `src/app/grist-types.example.ts` for typing patterns.
|
|
16
18
|
|
|
17
19
|
- **ESLint** blocks direct `grist` global usage in `src/` — use the SDK only.
|
|
18
|
-
- Edit `GRIST_OPTIONS.columns` in `App.tsx` to change the required columns (or remove the array entirely to read raw, unmapped records instead); `mountGristWidget()` gates the widget on `canRender` whenever columns are declared, so it renders once they're mapped.
|
|
20
|
+
- Edit `GRIST_OPTIONS.columns` in `src/app/App.tsx` to change the required columns (or remove the array entirely to read raw, unmapped records instead); `mountGristWidget()` gates the widget on `canRender` whenever columns are declared, so it renders once they're mapped.
|
|
19
21
|
|
|
20
|
-
Run `pnpm test`. `src/App.smoke.test.tsx` is the smoke test, one `smokeTestWidget()` call that checks the widget renders inside an emulated Grist with every declared column mapped, and that CI runs on every pull request; see `src/App.test.tsx` for the pattern (`renderWithGrist` + `presets` from `grist-widget-sdk/emulator/testing`, no browser or real Grist doc needed). Full guide: [Testing](https://gristwidgets.com/docs/emulator/testing).
|
|
22
|
+
Run `pnpm test`. `src/app/App.smoke.test.tsx` is the smoke test, one `smokeTestWidget()` call that checks the widget renders inside an emulated Grist with every declared column mapped, and that CI runs on every pull request; see `src/app/App.test.tsx` for the pattern (`renderWithGrist` + `presets` from `grist-widget-sdk/emulator/testing`, no browser or real Grist doc needed). Full guide: [Testing](https://gristwidgets.com/docs/emulator/testing).
|
|
21
23
|
|
|
22
24
|
**Monorepo dev:** this template resolves `grist-widget-sdk` from `packages/core/dist` (like the other widgets), not from SDK source. After changing the SDK, run `pnpm prebuild` or `pnpm --filter grist-widget-sdk build` before `pnpm dev`.
|
|
23
25
|
|
|
@@ -135,9 +137,10 @@ dependencies (`grist-widget-sdk` included) and the workflows' GitHub Actions,
|
|
|
135
137
|
and `.github/workflows/ci.yml` lints, tests and builds every pull request.
|
|
136
138
|
Merge a green one into `dev`, check `/dev/` inside Grist, then release it
|
|
137
139
|
like any change (bump the version, merge `dev` into `main`). Changes to the
|
|
138
|
-
template's own files (this pipeline, the build config)
|
|
139
|
-
|
|
140
|
-
|
|
140
|
+
template's own files (this pipeline, the build config) arrive with
|
|
141
|
+
`create-grist-widget`'s updates: after merging one, run
|
|
142
|
+
`pnpm exec create-grist-widget upgrade` on `dev` (see
|
|
143
|
+
[Upgrading a widget](https://gristwidgets.com/docs/cli/upgrading)).
|
|
141
144
|
|
|
142
145
|
**One-time setup** (the workflow can't do this part for you):
|
|
143
146
|
|
|
@@ -156,6 +159,20 @@ existing widget that way: see
|
|
|
156
159
|
read-only, which would fail the push to `gh-pages` with a 403.
|
|
157
160
|
3. If the repo is private, set **Pages visibility to Public** — a widget
|
|
158
161
|
embeds inside a Grist iframe, which needs a publicly reachable URL.
|
|
162
|
+
4. **After the first release**, protect the branches: Settings → Rules →
|
|
163
|
+
Rulesets → New branch ruleset. Not before: the first release itself
|
|
164
|
+
commits to `main` and resets `dev` (see above).
|
|
165
|
+
- `main`: **Require a pull request before merging**, **Require status
|
|
166
|
+
checks to pass** with the `check` status from `ci.yml`, **Block force
|
|
167
|
+
pushes**, **Restrict deletions**. A change then reaches `main` only
|
|
168
|
+
through a green pull request (your `dev` → `main` release PR included).
|
|
169
|
+
- `dev`: **Block force pushes** and **Restrict deletions**. Not the
|
|
170
|
+
pull-request rule: the version bump before a release is pushed straight
|
|
171
|
+
to `dev`.
|
|
172
|
+
|
|
173
|
+
An AI agent working on this repo acts through your GitHub account, so
|
|
174
|
+
these rules can't tell it from you. They guarantee the outcome (nothing
|
|
175
|
+
red reaches `main`), whoever merges.
|
|
159
176
|
|
|
160
177
|
No manifest/widget-catalog file is generated — that's a multi-widget,
|
|
161
178
|
Grist-widget-repository concept this single-widget template doesn't need.
|
package/template/package.json
CHANGED
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
"@types/react": "^19.3.0",
|
|
36
36
|
"@types/react-dom": "^19.3.0",
|
|
37
37
|
"@vitejs/plugin-react": "^6.1.2",
|
|
38
|
-
"create-grist-widget": "^0.3.
|
|
38
|
+
"create-grist-widget": "^0.3.29",
|
|
39
39
|
"eslint": "^10.13.0",
|
|
40
40
|
"eslint-plugin-react-hooks": "^7.0.1",
|
|
41
41
|
"eslint-plugin-react-refresh": "^0.5.7",
|
|
@@ -6,7 +6,10 @@
|
|
|
6
6
|
* and it improves with each SDK release. `.github/workflows/ci.yml` runs it
|
|
7
7
|
* on every pull request, Dependabot's `grist-widget-sdk` updates included:
|
|
8
8
|
* an SDK release that breaks this widget turns that PR red before the merge.
|
|
9
|
-
*
|
|
9
|
+
*
|
|
10
|
+
* It is the widget's own file: a widget using a browser API jsdom lacks
|
|
11
|
+
* (WebGL, say) stubs it here, with `vi.mock`. Widget-specific assertions go
|
|
12
|
+
* in their own tests (see `App.test.tsx`).
|
|
10
13
|
*/
|
|
11
14
|
import { it } from "vitest"
|
|
12
15
|
import { smokeTestWidget } from "grist-widget-sdk/emulator/testing"
|
package/template/src/main.tsx
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { mountGristWidget } from "grist-widget-sdk/app"
|
|
2
2
|
|
|
3
3
|
import "./index.css"
|
|
4
|
-
import App, { GRIST_OPTIONS } from "./App.tsx"
|
|
4
|
+
import App, { GRIST_OPTIONS } from "./app/App.tsx"
|
|
5
5
|
|
|
6
6
|
// Inside Grist: the widget, behind the SDK's connection screens and alerts,
|
|
7
7
|
// in Grist's light/dark theme. Opened anywhere else (`pnpm dev`, a deployed
|
package/template/vite.config.ts
CHANGED
|
@@ -28,7 +28,7 @@ export default defineConfig({
|
|
|
28
28
|
environment: "jsdom",
|
|
29
29
|
// Most examples/<id>/ don't ship their own WidgetApp.test.tsx yet (it's
|
|
30
30
|
// optional, per docs/planning/2026-08-execution-plan.md §3) --
|
|
31
|
-
// overlayExampleSrc correctly deletes the template's own App.test.tsx
|
|
31
|
+
// overlayExampleSrc correctly deletes the template's own app/App.test.tsx
|
|
32
32
|
// when swapping in one that has none (it would otherwise assert against
|
|
33
33
|
// content that's no longer there), so `create --example <id>` / `use
|
|
34
34
|
// <id>` routinely leave a project with zero test files. Without this,
|
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
import {
|
|
30
30
|
cpSync,
|
|
31
31
|
existsSync,
|
|
32
|
+
mkdirSync,
|
|
32
33
|
readFileSync,
|
|
33
34
|
readdirSync,
|
|
34
35
|
rmSync,
|
|
@@ -92,18 +93,20 @@ export function createComposer({ templateDir, examplesDir, uiDir, manifestPath }
|
|
|
92
93
|
}
|
|
93
94
|
const manifestEntry = loadManifestEntry(exampleId)
|
|
94
95
|
const srcDir = join(destDir, "src")
|
|
96
|
+
const appDir = join(srcDir, "app")
|
|
95
97
|
|
|
96
|
-
//
|
|
97
|
-
//
|
|
98
|
-
//
|
|
99
|
-
//
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
98
|
+
// src/app/ is the widget's own folder (see the template's AGENTS.md), so
|
|
99
|
+
// the example replaces it whole: nothing from the previous widget lingers,
|
|
100
|
+
// its test included, which would assert against content no longer there.
|
|
101
|
+
// The template's files around it (main.tsx, components/ui, lib,
|
|
102
|
+
// index.css) stay as they are.
|
|
103
|
+
rmSync(appDir, { recursive: true, force: true })
|
|
104
|
+
mkdirSync(appDir, { recursive: true })
|
|
105
|
+
|
|
106
|
+
// The template's smoke test comes back with any example: it only reads
|
|
107
|
+
// GRIST_OPTIONS, so a widget started from an example is smoke-tested too.
|
|
108
|
+
const smokeTest = join(templateDir, "src/app/App.smoke.test.tsx")
|
|
109
|
+
if (existsSync(smokeTest)) cpSync(smokeTest, join(appDir, "App.smoke.test.tsx"))
|
|
107
110
|
|
|
108
111
|
// The host contract's only mismatch: examples export `WidgetApp` (no
|
|
109
112
|
// default export), the template's main.tsx does a default-only import.
|
|
@@ -111,23 +114,18 @@ export function createComposer({ templateDir, examplesDir, uiDir, manifestPath }
|
|
|
111
114
|
// stays untouched under examples/) — same idiom as deploy.mjs's
|
|
112
115
|
// identityEdits.
|
|
113
116
|
const widgetAppSrc = readFileSync(join(exampleDir, "WidgetApp.tsx"), "utf8")
|
|
114
|
-
writeFileSync(join(
|
|
117
|
+
writeFileSync(join(appDir, "App.tsx"), `${widgetAppSrc}\nexport default WidgetApp\n`)
|
|
115
118
|
|
|
116
119
|
const testPath = join(exampleDir, "WidgetApp.test.tsx")
|
|
117
|
-
const destTestPath = join(srcDir, "App.test.tsx")
|
|
118
120
|
if (existsSync(testPath)) {
|
|
119
|
-
writeFileSync(
|
|
120
|
-
} else if (existsSync(destTestPath)) {
|
|
121
|
-
// The template's own test (or a previous example's) asserts against
|
|
122
|
-
// content that's no longer there once App.tsx is someone else's widget.
|
|
123
|
-
rmSync(destTestPath)
|
|
121
|
+
writeFileSync(join(appDir, "App.test.tsx"), readFileSync(testPath, "utf8"))
|
|
124
122
|
}
|
|
125
123
|
|
|
126
124
|
// Every other file living alongside WidgetApp.tsx (e.g. spreadsheet-grid's 5
|
|
127
125
|
// sibling components) travels with it, verbatim.
|
|
128
126
|
for (const entry of readdirSync(exampleDir, { withFileTypes: true })) {
|
|
129
127
|
if (entry.name === "WidgetApp.tsx" || entry.name === "WidgetApp.test.tsx") continue
|
|
130
|
-
cpSync(join(exampleDir, entry.name), join(
|
|
128
|
+
cpSync(join(exampleDir, entry.name), join(appDir, entry.name), { recursive: true })
|
|
131
129
|
}
|
|
132
130
|
|
|
133
131
|
// `@/components/ui/*` the template doesn't already vendor (per the
|
|
File without changes
|
|
File without changes
|
|
File without changes
|