Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand All @@ -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

```
Expand All @@ -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 (<cms>/<group>: microcms, keystatic)
deploy/ # Deploy configs (wrangler.jsonc per target)
cicd/ # CI/CD workflows (github-actions/<target>/)
pm/ # Package manager config (npmrc, etc.)
Expand Down
9 changes: 8 additions & 1 deletion cmd/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand All @@ -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
Expand All @@ -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)")
Expand Down
25 changes: 25 additions & 0 deletions config/registry.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand Down
38 changes: 27 additions & 11 deletions config/templates/base/astro/astro.config.mjs.tmpl
Original file line number Diff line number Diff line change
@@ -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}}
Expand All @@ -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}}
});
2 changes: 1 addition & 1 deletion config/templates/base/astro/tsconfig.json.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -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 -}}
Expand Down
28 changes: 28 additions & 0 deletions config/templates/cms/keystatic/astro/keystatic.config.ts.tmpl
Original file line number Diff line number Diff line change
@@ -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' }),
},
}),
},
});
Empty file.
18 changes: 18 additions & 0 deletions pkg/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down Expand Up @@ -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{}
Expand Down
80 changes: 80 additions & 0 deletions pkg/packagejson_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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 {
Expand Down
11 changes: 3 additions & 8 deletions pkg/scaffold.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down