From 4b0190f7139bc35d1e53b0b6d3e4134214286be7 Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Thu, 25 Jun 2026 01:40:12 +0000 Subject: [PATCH 1/6] Initial commit for the issue #73 From 9ec3861a9fdc5cb609f13fa5742b95a9cf1302a8 Mon Sep 17 00:00:00 2001 From: Bruce <60092964+VibratingKoala@users.noreply.github.com> Date: Thu, 25 Jun 2026 10:24:31 +0800 Subject: [PATCH 2/6] feat: add Keystatic CMS integration for Astro Add Keystatic as a CMS option alongside microCMS, scoped to Astro bases (astro, astro-react, astro-vue) and excluded from Nuxt/Vite. - Generate keystatic.config.ts with local storage (commented GitHub block) - Mount the keystatic() integration in dev only; production build sets SKIP_KEYSTATIC=true so the site stays fully static and needs no adapter - Auto-inject the React renderer (Keystatic's admin UI is React-based) - Re-key CMS templates by provider: templates/cms/{cms}/{group} - Replace the hardcoded vite CMS guard with a registry-driven excludeGroups check; add CMS.IsValidForBase and create-command validation/fallback --- README.md | 10 ++- cmd/create.go | 9 ++- config/registry.json | 17 ++++ .../base/astro/astro.config.mjs.tmpl | 38 ++++++--- .../keystatic/astro/keystatic.config.ts.tmpl | 28 +++++++ .../astro/src/content/posts/.gitkeep | 0 .../{ => microcms}/astro/src/lib/microcms.ts | 0 .../nuxt/server/utils/microcms.ts | 0 pkg/config.go | 15 ++++ pkg/packagejson.go | 12 +++ pkg/packagejson_test.go | 80 +++++++++++++++++++ pkg/scaffold.go | 10 +-- 12 files changed, 200 insertions(+), 19 deletions(-) create mode 100644 config/templates/cms/keystatic/astro/keystatic.config.ts.tmpl create mode 100644 config/templates/cms/keystatic/astro/src/content/posts/.gitkeep rename config/templates/cms/{ => microcms}/astro/src/lib/microcms.ts (100%) rename config/templates/cms/{ => microcms}/nuxt/server/utils/microcms.ts (100%) diff --git a/README.md b/README.md index 641d1d7..d5362ec 100644 --- a/README.md +++ b/README.md @@ -70,7 +70,7 @@ bungkus-cli create my-app -t nuxt --pm bun | `--state` | `none` | `none`, `jotai`, `zustand`, `pinia`, `nanostores` | | `--test` | `none` | `none`, `playwright` | | `--audit` | `none` | `none`, `lhci` | -| `--cms` | `none` | `none`, `microcms` | +| `--cms` | `none` | `none`, `microcms`, `keystatic` (Astro only) | | `--deploy` | `none` | `none`, `cloudflare-pages`, `cloudflare-workers` | | `--cicd` | `none` | `none`, `github-actions` | | `--pm` | `pnpm` | `pnpm`, `bun`, `npm`, `yarn` | @@ -80,6 +80,12 @@ bungkus-cli create my-app -t nuxt --pm bun Flags take precedence over template presets, so `-t nuxt --pm bun` uses the Nuxt preset but overrides the package manager. +### Keystatic + +`--cms keystatic` is available on Astro bases only (`astro`, `astro-react`, `astro-vue`); it is skipped on Nuxt/Vite. It generates a `keystatic.config.ts` using local storage — content is stored as files in the repo and the admin UI is served at `/keystatic` while running `dev`. The React renderer is added automatically (Keystatic's admin UI is React-based). + +The production build (`build`) sets `SKIP_KEYSTATIC=true`, which leaves the admin out of the bundle so the site stays fully static and deploys anywhere — no server adapter required. To edit content from a deployed site instead, switch the `storage` block in `keystatic.config.ts` to GitHub mode (see the commented example in that file). + ## Project Structure ``` @@ -97,7 +103,7 @@ config/ linter/ # Linter configs (biome, eslint, oxlint) form/ # Form-library snippets integration/ # Per-integration snippets (react, vue) - cms/ # CMS integration snippets (microcms) + cms/ # CMS integration snippets (/: microcms, keystatic) deploy/ # Deploy configs (wrangler.jsonc per target) cicd/ # CI/CD workflows (github-actions//) pm/ # Package manager config (npmrc, etc.) diff --git a/cmd/create.go b/cmd/create.go index 2ba51a0..4cc3551 100644 --- a/cmd/create.go +++ b/cmd/create.go @@ -200,6 +200,9 @@ var createCmd = &cobra.Command{ if !cfg.State.IsValid() { return fmt.Errorf("invalid state library: %s", cfg.State) } + if !cfg.CMS.IsValid() { + return fmt.Errorf("invalid cms: %s", cfg.CMS) + } if !cfg.Deployment.IsValid() { return fmt.Errorf("invalid deployment target: %s", cfg.Deployment) } @@ -222,6 +225,10 @@ var createCmd = &cobra.Command{ tui.PrintSkippedIntegration(string(cfg.State), string(cfg.Base)) cfg.State = "none" } + if cfg.CMS != "none" && !cfg.CMS.IsValidForBase(string(cfg.Base)) { + tui.PrintSkippedIntegration(string(cfg.CMS), string(cfg.Base)) + cfg.CMS = "none" + } destDir := cfg.ProjectName if cfg.DestDir != "" { destDir = cfg.DestDir @@ -247,7 +254,7 @@ func init() { createCmd.Flags().String("query", "none", "Query library (none, tanstack-query)") createCmd.Flags().String("state", "none", "State management library (none, jotai, zustand, pinia, nanostores)") createCmd.Flags().String("pm", "pnpm", "Package manager (bun, npm, yarn, pnpm)") - createCmd.Flags().String("cms", "none", "CMS (none, microcms)") + createCmd.Flags().String("cms", "none", "CMS (none, microcms, keystatic)") createCmd.Flags().String("test", "none", "Testing library (none, playwright)") createCmd.Flags().String("audit", "none", "Audit / performance tool (none, lhci)") createCmd.Flags().StringP("template", "t", "", "Predefined template (astro, astro-react, astro-vue, nuxt, vite, vite-react, vite-vue)") diff --git a/config/registry.json b/config/registry.json index cf6c9ae..61cb541 100644 --- a/config/registry.json +++ b/config/registry.json @@ -465,6 +465,23 @@ "microcms-js-sdk": "^3.2.0" } } + }, + { + "value": "keystatic", + "label": "Keystatic", + "excludeGroups": [ + "nuxt", + "vite" + ], + "packages": { + "dependencies": { + "@keystatic/core": "0.5.50", + "@keystatic/astro": "5.1.0" + }, + "devDependencies": { + "cross-env": "^10.1.0" + } + } } ], "test": [ diff --git a/config/templates/base/astro/astro.config.mjs.tmpl b/config/templates/base/astro/astro.config.mjs.tmpl index ddb0345..57af3ce 100644 --- a/config/templates/base/astro/astro.config.mjs.tmpl +++ b/config/templates/base/astro/astro.config.mjs.tmpl @@ -1,11 +1,14 @@ // @ts-check -import { defineConfig {{if eq .CMS "microcms"}}, envField{{end}} } from 'astro/config'; +import { defineConfig{{if eq .CMS "microcms"}}, envField{{end}} } from 'astro/config'; {{- if eq .Base "astro-vue"}} import vue from '@astrojs/vue'; {{- end}} -{{- if eq .Base "astro-react"}} +{{- if or (eq .Base "astro-react") (eq .CMS "keystatic")}} import react from '@astrojs/react'; {{- end}} +{{- if eq .CMS "keystatic"}} +import keystatic from '@keystatic/astro'; +{{- end}} {{- if eq .CSS "tailwindcss"}} import tailwindcss from "@tailwindcss/vite"; {{- end}} @@ -15,17 +18,30 @@ export default defineConfig({ env: { schema: { {{- if eq .CMS "microcms"}} - MICROCMS_API_KEY: envField.string({ context: 'server', access: 'secret'}), - MICROCMS_SERVICE_DOMAIN: envField.string({ context: 'server', access: 'secret'}), + MICROCMS_API_KEY: envField.string({ context: 'server', access: 'secret' }), + MICROCMS_SERVICE_DOMAIN: envField.string({ context: 'server', access: 'secret' }), {{- end}} - } - } -{{- if or (eq .Base "astro-vue") (eq .Base "astro-react")}} - integrations: [{{if eq .Base "astro-vue"}}vue(){{end}}{{if eq .Base "astro-react"}}react(){{end}}], -{{- end}} -{{- if eq .CSS "tailwindcss"}}, + }, + }, + {{- if or (eq .Base "astro-vue") (eq .Base "astro-react") (eq .CMS "keystatic")}} + integrations: [ + {{- if eq .Base "astro-vue"}} + vue(), + {{- end}} + {{- if or (eq .Base "astro-react") (eq .CMS "keystatic")}} + react(), + {{- end}} + {{- if eq .CMS "keystatic"}} + // Keystatic's admin UI is server-rendered, so it is only mounted outside + // production builds. Run `dev` to edit content at /keystatic; production + // builds stay fully static (set SKIP_KEYSTATIC=true — see package.json). + ...(process.env.SKIP_KEYSTATIC ? [] : [keystatic()]), + {{- end}} + ], + {{- end}} + {{- if eq .CSS "tailwindcss"}} vite: { plugins: [tailwindcss()], }, -{{- end}} + {{- end}} }); diff --git a/config/templates/cms/keystatic/astro/keystatic.config.ts.tmpl b/config/templates/cms/keystatic/astro/keystatic.config.ts.tmpl new file mode 100644 index 0000000..2a60bd6 --- /dev/null +++ b/config/templates/cms/keystatic/astro/keystatic.config.ts.tmpl @@ -0,0 +1,28 @@ +import { config, fields, collection } from '@keystatic/core'; + +export default config({ + // Local storage keeps content as files in this repo — works offline with + // zero setup. The admin UI is available at /keystatic while running `dev`. + storage: { + kind: 'local', + }, + // To edit content through GitHub instead (e.g. from a deployed site), swap + // the storage above for the block below and follow the GitHub App setup at + // https://keystatic.com/docs/github-mode + // storage: { + // kind: 'github', + // repo: 'your-org/{{.ProjectName}}', + // }, + collections: { + posts: collection({ + label: 'Posts', + slugField: 'title', + path: 'src/content/posts/*', + format: { contentField: 'content' }, + schema: { + title: fields.slug({ name: { label: 'Title' } }), + content: fields.markdoc({ label: 'Content' }), + }, + }), + }, +}); diff --git a/config/templates/cms/keystatic/astro/src/content/posts/.gitkeep b/config/templates/cms/keystatic/astro/src/content/posts/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/config/templates/cms/astro/src/lib/microcms.ts b/config/templates/cms/microcms/astro/src/lib/microcms.ts similarity index 100% rename from config/templates/cms/astro/src/lib/microcms.ts rename to config/templates/cms/microcms/astro/src/lib/microcms.ts diff --git a/config/templates/cms/nuxt/server/utils/microcms.ts b/config/templates/cms/microcms/nuxt/server/utils/microcms.ts similarity index 100% rename from config/templates/cms/nuxt/server/utils/microcms.ts rename to config/templates/cms/microcms/nuxt/server/utils/microcms.ts diff --git a/pkg/config.go b/pkg/config.go index b0a34bd..f149186 100644 --- a/pkg/config.go +++ b/pkg/config.go @@ -356,6 +356,21 @@ func (c CMS) IsValid() bool { return globalRegistry != nil && globalRegistry.HasCMS(string(c)) } +// IsValidForBase reports whether the CMS is allowed for the given base +// framework, per the registry's excludeGroups (e.g. Keystatic excludes the +// nuxt and vite groups). +func (c CMS) IsValidForBase(base string) bool { + if globalRegistry == nil { + return false + } + entry := globalRegistry.GetCMS(string(c)) + baseEntry := globalRegistry.GetBase(base) + if entry == nil || baseEntry == nil { + return false + } + return !entry.ExcludesGroup(baseEntry.Group) +} + func (c CMS) GetDependencies() AllDependencies { if globalRegistry == nil { return AllDependencies{} diff --git a/pkg/packagejson.go b/pkg/packagejson.go index a9f2503..5fe065e 100644 --- a/pkg/packagejson.go +++ b/pkg/packagejson.go @@ -198,4 +198,16 @@ func applyCrossCuttingRules(pkg *packageJSON, cfg ProjectConfig) { pkg.DevDependencies["vite"] = "^6.3.5" } + // keystatic (astro) → React renderer for the admin UI (even on non-React + // bases), and a production build that skips the server-rendered admin so + // the site stays fully static. + if cfg.CMS == "keystatic" && cfg.Base.IsAstro() { + pkg.Dependencies["@astrojs/react"] = "^5.0.3" + pkg.Dependencies["react"] = "^19.2.4" + pkg.Dependencies["react-dom"] = "^19.2.4" + pkg.DevDependencies["@types/react"] = "^19.2.14" + pkg.DevDependencies["@types/react-dom"] = "^19.2.3" + pkg.Scripts["build"] = "cross-env SKIP_KEYSTATIC=true astro build" + } + } diff --git a/pkg/packagejson_test.go b/pkg/packagejson_test.go index 4a4f82b..e20a621 100644 --- a/pkg/packagejson_test.go +++ b/pkg/packagejson_test.go @@ -28,6 +28,57 @@ func has(m map[string]string, key string) bool { return ok } +// TestKeystaticBuildScript verifies the production build skips the +// server-rendered Keystatic admin so the site stays static. +func TestKeystaticBuildScript(t *testing.T) { + setupRegistry(t) + + cfg := NewProjectConfig() + cfg.ProjectName = "test-app" + cfg.Base = "astro" + cfg.CMS = "keystatic" + cfg.PM = "pnpm" + + data, err := BuildPackageJSON(cfg) + if err != nil { + t.Fatalf("BuildPackageJSON: %v", err) + } + var parsed struct { + Scripts map[string]string `json:"scripts"` + } + if err := json.Unmarshal(data, &parsed); err != nil { + t.Fatalf("unmarshal: %v", err) + } + want := "cross-env SKIP_KEYSTATIC=true astro build" + if parsed.Scripts["build"] != want { + t.Errorf("build script = %q, want %q", parsed.Scripts["build"], want) + } +} + +func TestCMSIsValidForBase(t *testing.T) { + setupRegistry(t) + + cases := []struct { + cms CMS + base string + want bool + }{ + {"keystatic", "astro", true}, + {"keystatic", "astro-react", true}, + {"keystatic", "astro-vue", true}, + {"keystatic", "nuxt", false}, + {"keystatic", "vite", false}, + {"microcms", "astro", true}, + {"microcms", "nuxt", true}, + {"microcms", "vite", false}, + } + for _, c := range cases { + if got := c.cms.IsValidForBase(c.base); got != c.want { + t.Errorf("%s.IsValidForBase(%s) = %v, want %v", c.cms, c.base, got, c.want) + } + } +} + func TestBuildPackageJSON(t *testing.T) { setupRegistry(t) @@ -200,6 +251,35 @@ func TestBuildPackageJSON(t *testing.T) { }, forbidDep: []string{"@tanstack/react-query", "@tanstack/vue-query"}, }, + { + name: "keystatic on plain astro injects react renderer", + cfg: func() ProjectConfig { + c := baseCfg("astro") + c.CMS = "keystatic" + return c + }, + wantDep: []string{"@keystatic/core", "@keystatic/astro", "@astrojs/react", "react", "react-dom"}, + wantDev: []string{"cross-env", "@types/react", "@types/react-dom"}, + }, + { + name: "keystatic on astro-vue injects react alongside vue", + cfg: func() ProjectConfig { + c := baseCfg("astro-vue") + c.CMS = "keystatic" + return c + }, + wantDep: []string{"@keystatic/astro", "@astrojs/react", "react", "vue"}, + wantDev: []string{"cross-env"}, + }, + { + name: "keystatic on astro-react does not duplicate react deps", + cfg: func() ProjectConfig { + c := baseCfg("astro-react") + c.CMS = "keystatic" + return c + }, + wantDep: []string{"@keystatic/core", "@keystatic/astro", "@astrojs/react", "react"}, + }, } for _, tt := range tests { diff --git a/pkg/scaffold.go b/pkg/scaffold.go index 8b71138..7a80068 100644 --- a/pkg/scaffold.go +++ b/pkg/scaffold.go @@ -134,16 +134,16 @@ func Scaffold(destDir string, templates fs.FS, cfg ProjectConfig) error { } if cfg.CMS != "none" { - if cfg.Base.IsVite() { - return fmt.Errorf("cms integration is currently not supported in vite project") - } - group, err := cfg.Base.GetGroup(string(cfg.Base)) if err != nil { return err } - cmsDir := "templates/cms/" + string(group) + if cmsEntry := globalRegistry.GetCMS(string(cfg.CMS)); cmsEntry != nil && cmsEntry.ExcludesGroup(string(group)) { + return fmt.Errorf("%s CMS is not supported for %s projects", cfg.CMS, group) + } + + cmsDir := "templates/cms/" + string(cfg.CMS) + "/" + string(group) cmsFS, err := fs.Sub(templates, cmsDir) if err != nil { return fmt.Errorf("failed to read CMS templates: %w", err) From bf0cf6c856163aec15f3448d9cfb62f520ac5114 Mon Sep 17 00:00:00 2001 From: Bruce <60092964+VibratingKoala@users.noreply.github.com> Date: Thu, 25 Jun 2026 10:46:55 +0800 Subject: [PATCH 3/6] refactor: drive Keystatic packages from the registry Move the React renderer packages and the SKIP_KEYSTATIC build-script override into the keystatic registry entry, and delete the Go cross-cutting rule. The cms merge runs after the base, so the build override applies via the registry. Keeps package versions in the registry single source of truth instead of duplicated in Go. --- config/registry.json | 12 ++++++++++-- pkg/packagejson.go | 12 ------------ 2 files changed, 10 insertions(+), 14 deletions(-) diff --git a/config/registry.json b/config/registry.json index 61cb541..e11a005 100644 --- a/config/registry.json +++ b/config/registry.json @@ -474,12 +474,20 @@ "vite" ], "packages": { + "scripts": { + "build": "cross-env SKIP_KEYSTATIC=true astro build" + }, "dependencies": { "@keystatic/core": "0.5.50", - "@keystatic/astro": "5.1.0" + "@keystatic/astro": "5.1.0", + "@astrojs/react": "^5.0.3", + "react": "^19.2.4", + "react-dom": "^19.2.4" }, "devDependencies": { - "cross-env": "^10.1.0" + "cross-env": "^10.1.0", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3" } } } diff --git a/pkg/packagejson.go b/pkg/packagejson.go index 5fe065e..a9f2503 100644 --- a/pkg/packagejson.go +++ b/pkg/packagejson.go @@ -198,16 +198,4 @@ func applyCrossCuttingRules(pkg *packageJSON, cfg ProjectConfig) { pkg.DevDependencies["vite"] = "^6.3.5" } - // keystatic (astro) → React renderer for the admin UI (even on non-React - // bases), and a production build that skips the server-rendered admin so - // the site stays fully static. - if cfg.CMS == "keystatic" && cfg.Base.IsAstro() { - pkg.Dependencies["@astrojs/react"] = "^5.0.3" - pkg.Dependencies["react"] = "^19.2.4" - pkg.Dependencies["react-dom"] = "^19.2.4" - pkg.DevDependencies["@types/react"] = "^19.2.14" - pkg.DevDependencies["@types/react-dom"] = "^19.2.3" - pkg.Scripts["build"] = "cross-env SKIP_KEYSTATIC=true astro build" - } - } From 9c5350b330d7b767bc7daea96c252854ed661e32 Mon Sep 17 00:00:00 2001 From: Bruce <60092964+VibratingKoala@users.noreply.github.com> Date: Thu, 25 Jun 2026 10:47:23 +0800 Subject: [PATCH 4/6] fix: guard GetGroup against unknown base framework GetBase returns nil for an unrecognized base; dereferencing entry.Group would panic. Return an error instead, matching the nil-registry guard already in this method and the entry-nil checks in IsAstro/IsVite. --- pkg/config.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pkg/config.go b/pkg/config.go index f149186..9d35be2 100644 --- a/pkg/config.go +++ b/pkg/config.go @@ -41,6 +41,9 @@ func (b BaseFramework) GetGroup(base string) (BaseGroup, error) { } entry := globalRegistry.GetBase(base) + if entry == nil { + return "", errors.New("unknown base framework: " + base) + } return BaseGroup(entry.Group), nil } From e81260ec6d73b33e10f3594939e0433f8f75670d Mon Sep 17 00:00:00 2001 From: Bruce <60092964+VibratingKoala@users.noreply.github.com> Date: Thu, 25 Jun 2026 11:05:13 +0800 Subject: [PATCH 5/6] fix: add JSX tsconfig options for Keystatic on non-React Astro bases The jsx/jsxImportSource compiler options were gated on Base==astro-react, but Keystatic pulls in the React renderer on plain astro and astro-vue too. Gate on React being present (astro-react OR keystatic) so .tsx and astro check type-check correctly. Mirrors the astro.config integration condition. --- config/templates/base/astro/tsconfig.json.tmpl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/templates/base/astro/tsconfig.json.tmpl b/config/templates/base/astro/tsconfig.json.tmpl index 255a845..0fcaa52 100644 --- a/config/templates/base/astro/tsconfig.json.tmpl +++ b/config/templates/base/astro/tsconfig.json.tmpl @@ -7,7 +7,7 @@ "paths": { "@/*": ["src/*"] } - {{- if eq .Base "astro-react" -}} + {{- if or (eq .Base "astro-react") (eq .CMS "keystatic") -}} , "jsx": "react-jsx", "jsxImportSource": "react" {{- end -}} From c5db3aabaa175f20eb18f1789f6c634b06e76b32 Mon Sep 17 00:00:00 2001 From: Bruce <60092964+VibratingKoala@users.noreply.github.com> Date: Thu, 25 Jun 2026 11:16:04 +0800 Subject: [PATCH 6/6] refactor: reuse base entry group in CMS scaffold block MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scaffold already fetches and nil-guards the base entry at the top, so the CMS block can use entry.Group directly instead of re-deriving it via GetGroup — removing a redundant registry scan and an unreachable error path. --- pkg/scaffold.go | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/pkg/scaffold.go b/pkg/scaffold.go index 7a80068..95f2457 100644 --- a/pkg/scaffold.go +++ b/pkg/scaffold.go @@ -134,16 +134,11 @@ func Scaffold(destDir string, templates fs.FS, cfg ProjectConfig) error { } if cfg.CMS != "none" { - group, err := cfg.Base.GetGroup(string(cfg.Base)) - if err != nil { - return err - } - - if cmsEntry := globalRegistry.GetCMS(string(cfg.CMS)); cmsEntry != nil && cmsEntry.ExcludesGroup(string(group)) { - return fmt.Errorf("%s CMS is not supported for %s projects", cfg.CMS, group) + if cmsEntry := globalRegistry.GetCMS(string(cfg.CMS)); cmsEntry != nil && cmsEntry.ExcludesGroup(entry.Group) { + return fmt.Errorf("%s CMS is not supported for %s projects", cfg.CMS, entry.Group) } - cmsDir := "templates/cms/" + string(cfg.CMS) + "/" + string(group) + cmsDir := "templates/cms/" + string(cfg.CMS) + "/" + entry.Group cmsFS, err := fs.Sub(templates, cmsDir) if err != nil { return fmt.Errorf("failed to read CMS templates: %w", err)