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..e11a005 100644 --- a/config/registry.json +++ b/config/registry.json @@ -465,6 +465,31 @@ "microcms-js-sdk": "^3.2.0" } } + }, + { + "value": "keystatic", + "label": "Keystatic", + "excludeGroups": [ + "nuxt", + "vite" + ], + "packages": { + "scripts": { + "build": "cross-env SKIP_KEYSTATIC=true astro build" + }, + "dependencies": { + "@keystatic/core": "0.5.50", + "@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", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3" + } + } } ], "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/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 -}} 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..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 } @@ -356,6 +359,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_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..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" { - if cfg.Base.IsVite() { - return fmt.Errorf("cms integration is currently not supported in vite project") + 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) } - group, err := cfg.Base.GetGroup(string(cfg.Base)) - if err != nil { - return err - } - - cmsDir := "templates/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)