Skip to content
Draft
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
1 change: 1 addition & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
**/.nuxt
**/.output
**/.svelte-kit
**/.react-router

# Prettier formatting of HTML is questionable, let's skip it for now
**/*.html
Expand Down
192 changes: 192 additions & 0 deletions bolt-cms-react-router/.bolt/admin.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
{
"queries": {
"listPosts": {
"sql": "select id, title, slug, status, author, featured_image, published_at, modified_at from public.cms_posts where (($1::text = 'all' and status is distinct from 'trash') or status = $1::text) and ($2::text = '' or title ilike '%' || $2::text || '%') order by published_at desc nulls last, id desc limit $3::int offset $4::int"
},
"countPosts": {
"sql": "select coalesce(status, 'publish') as status, count(*)::int as count from public.cms_posts group by 1"
},
"getPost": {
"sql": "select * from public.cms_posts where id = $1::bigint"
},
"listPostOptions": {
"sql": "select id, title from public.cms_posts where status is distinct from 'trash' order by title limit 500"
},
"insertPost": {
"readOnly": false,
"sql": "insert into public.cms_posts (id, title, slug, excerpt, body, content_html, status, author, featured_image, categories, tags, parent, menu_order, published_at, modified_at) values ((select coalesce(max(id), 0) + 1 from public.cms_posts), $1::text, $2::text, $3::text, $4::jsonb, $5::text, $6::text, $7::bigint, $8::text, $9::bigint[], $10::bigint[], $11::bigint, $12::bigint, coalesce($13::timestamptz, now()), now()) returning *"
},
"updatePost": {
"readOnly": false,
"sql": "update public.cms_posts set title = $2::text, slug = $3::text, excerpt = $4::text, body = $5::jsonb, content_html = $6::text, status = $7::text, author = $8::bigint, featured_image = $9::text, categories = $10::bigint[], tags = $11::bigint[], parent = $12::bigint, menu_order = $13::bigint, published_at = coalesce($14::timestamptz, published_at, now()), modified_at = now() where id = $1::bigint returning *"
},
"setPostStatus": {
"readOnly": false,
"sql": "update public.cms_posts set status = $2::text, modified_at = now() where id = $1::bigint returning id, status"
},
"deletePost": {
"readOnly": false,
"confirm": true,
"preview": "select title from public.cms_posts where id = $1::bigint",
"description": "Permanently delete the post \"{title}\"",
"sql": "delete from public.cms_posts where id = $1::bigint returning id"
},
"listPages": {
"sql": "select id, title, slug, status, author, featured_image, published_at, modified_at from public.cms_pages where (($1::text = 'all' and status is distinct from 'trash') or status = $1::text) and ($2::text = '' or title ilike '%' || $2::text || '%') order by published_at desc nulls last, id desc limit $3::int offset $4::int"
},
"countPages": {
"sql": "select coalesce(status, 'publish') as status, count(*)::int as count from public.cms_pages group by 1"
},
"getPage": {
"sql": "select * from public.cms_pages where id = $1::bigint"
},
"listPageOptions": {
"sql": "select id, title from public.cms_pages where status is distinct from 'trash' order by title limit 500"
},
"insertPage": {
"readOnly": false,
"sql": "insert into public.cms_pages (id, title, slug, excerpt, body, content_html, status, author, featured_image, categories, tags, parent, menu_order, published_at, modified_at) values ((select coalesce(max(id), 0) + 1 from public.cms_pages), $1::text, $2::text, $3::text, $4::jsonb, $5::text, $6::text, $7::bigint, $8::text, $9::bigint[], $10::bigint[], $11::bigint, $12::bigint, coalesce($13::timestamptz, now()), now()) returning *"
},
"updatePage": {
"readOnly": false,
"sql": "update public.cms_pages set title = $2::text, slug = $3::text, excerpt = $4::text, body = $5::jsonb, content_html = $6::text, status = $7::text, author = $8::bigint, featured_image = $9::text, categories = $10::bigint[], tags = $11::bigint[], parent = $12::bigint, menu_order = $13::bigint, published_at = coalesce($14::timestamptz, published_at, now()), modified_at = now() where id = $1::bigint returning *"
},
"setPageStatus": {
"readOnly": false,
"sql": "update public.cms_pages set status = $2::text, modified_at = now() where id = $1::bigint returning id, status"
},
"deletePage": {
"readOnly": false,
"confirm": true,
"preview": "select title from public.cms_pages where id = $1::bigint",
"description": "Permanently delete the page \"{title}\"",
"sql": "delete from public.cms_pages where id = $1::bigint returning id"
},
"listAuthors": {
"sql": "select id, name, slug, bio, avatar_url, url from public.cms_authors order by name limit 500"
},
"insertAuthor": {
"readOnly": false,
"sql": "insert into public.cms_authors (id, name, slug, bio, avatar_url, url) values ((select coalesce(max(id), 0) + 1 from public.cms_authors), $1::text, $2::text, $3::text, $4::text, $5::text) returning *"
},
"updateAuthor": {
"readOnly": false,
"sql": "update public.cms_authors set name = $2::text, slug = $3::text, bio = $4::text, avatar_url = $5::text, url = $6::text where id = $1::bigint returning *"
},
"deleteAuthor": {
"readOnly": false,
"confirm": true,
"preview": "select name from public.cms_authors where id = $1::bigint",
"description": "Delete the author \"{name}\" (their posts stay, without an author)",
"sql": "delete from public.cms_authors where id = $1::bigint returning id"
},
"listCategories": {
"sql": "select id, name, slug, description, parent from public.cms_categories order by name limit 500"
},
"insertCategory": {
"readOnly": false,
"sql": "insert into public.cms_categories (id, name, slug, description, parent) values ((select coalesce(max(id), 0) + 1 from public.cms_categories), $1::text, $2::text, $3::text, $4::bigint) returning *"
},
"updateCategory": {
"readOnly": false,
"sql": "update public.cms_categories set name = $2::text, slug = $3::text, description = $4::text, parent = $5::bigint where id = $1::bigint returning *"
},
"deleteCategory": {
"readOnly": false,
"confirm": true,
"preview": "select name from public.cms_categories where id = $1::bigint",
"description": "Delete the category \"{name}\" (posts keep their other categories)",
"sql": "delete from public.cms_categories where id = $1::bigint returning id"
},
"listTags": {
"sql": "select id, name, slug, description from public.cms_tags order by name limit 500"
},
"insertTag": {
"readOnly": false,
"sql": "insert into public.cms_tags (id, name, slug, description) values ((select coalesce(max(id), 0) + 1 from public.cms_tags), $1::text, $2::text, $3::text) returning *"
},
"updateTag": {
"readOnly": false,
"sql": "update public.cms_tags set name = $2::text, slug = $3::text, description = $4::text where id = $1::bigint returning *"
},
"deleteTag": {
"readOnly": false,
"confirm": true,
"preview": "select name from public.cms_tags where id = $1::bigint",
"description": "Delete the tag \"{name}\" (posts keep their other tags)",
"sql": "delete from public.cms_tags where id = $1::bigint returning id"
},
"listComments": {
"sql": "select c.id, c.post, c.parent, c.author_name, c.body, c.created_at, coalesce(p.title, g.title) as post_title, coalesce(p.slug, g.slug) as post_slug from public.cms_comments c left join public.cms_posts p on p.id = c.post left join public.cms_pages g on g.id = c.post order by c.created_at desc nulls last, c.id desc limit $1::int offset $2::int"
},
"countComments": {
"sql": "select count(*)::int as count from public.cms_comments"
},
"deleteComment": {
"readOnly": false,
"confirm": true,
"preview": "select coalesce(author_name, 'an anonymous visitor') as author_name from public.cms_comments where id = $1::bigint",
"description": "Delete the comment by {author_name}",
"sql": "delete from public.cms_comments where id = $1::bigint returning id"
},
"listAssets": {
"sql": "select id, kind, mime_type, filename, title, alt, caption, width, height, original_url, public_url, upload_error from public.cms_assets where $1::text = '' or filename ilike '%' || $1::text || '%' or title ilike '%' || $1::text || '%' order by imported_at desc, id limit $2::int offset $3::int"
},
"countAssets": {
"sql": "select count(*)::int as count from public.cms_assets where $1::text = '' or filename ilike '%' || $1::text || '%' or title ilike '%' || $1::text || '%'"
},
"getAsset": {
"sql": "select id, kind, mime_type, filename, title, alt, caption, width, height, original_url, public_url, upload_error from public.cms_assets where id = $1::text"
},
"updateAsset": {
"readOnly": false,
"sql": "update public.cms_assets set title = $2::text, alt = $3::text, caption = $4::text where id = $1::text returning id"
},
"listSettings": {
"sql": "select key, value from public.cms_settings"
},
"getSite": {
"sql": "select name, description, url, home_url, source from public.cms_site where id = 1"
},
"upsertSetting": {
"readOnly": false,
"sql": "insert into public.cms_settings (key, value) values ($1::text, $2::jsonb) on conflict (key) do update set value = excluded.value, updated_at = now() returning key"
},
"listMenus": {
"sql": "select id, slug, name, location from public.cms_menus order by name"
},
"insertMenu": {
"readOnly": false,
"sql": "insert into public.cms_menus (name, slug, location) values ($1::text, $2::text, $3::text) returning *"
},
"updateMenuLocation": {
"readOnly": false,
"sql": "update public.cms_menus set location = $2::text where id = $1::bigint returning *"
},
"deleteMenu": {
"readOnly": false,
"confirm": true,
"preview": "select name from public.cms_menus where id = $1::bigint",
"description": "Delete the \"{name}\" menu and all its links",
"sql": "delete from public.cms_menus where id = $1::bigint returning id"
},
"listMenuItems": {
"sql": "select * from public.cms_menu_items where menu_id = $1::bigint order by position, id"
},
"insertMenuItem": {
"readOnly": false,
"sql": "insert into public.cms_menu_items (menu_id, parent_id, position, title, url, object_type, object_id, target, classes, description) values ($1::bigint, $2::bigint, $3::int, $4::text, $5::text, $6::text, $7::bigint, $8::text, $9::text, $10::text) returning *"
},
"updateMenuItem": {
"readOnly": false,
"sql": "update public.cms_menu_items set parent_id = $2::bigint, position = $3::int, title = $4::text, url = $5::text, object_type = $6::text, object_id = $7::bigint, target = $8::text, classes = $9::text, description = $10::text where id = $1::bigint returning *"
},
"deleteMenuItem": {
"readOnly": false,
"sql": "delete from public.cms_menu_items where id = $1::bigint returning id"
},
"listFields": {
"sql": "select type_name, name, title, primitive, column_name, required, description, options, position from public.cms_fields where type_name = $1::text order by position"
}
}
}
7 changes: 7 additions & 0 deletions bolt-cms-react-router/.bolt/config.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"template": "bolt-cms-react-router",
"build": {
"command": "npm run build",
"output": "build/client"
}
}
11 changes: 11 additions & 0 deletions bolt-cms-react-router/.bolt/prompt
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
This is a Bolt CMS site: a React Router 8 (framework mode, `ssr: false`) website backed by Supabase, shaped like a WordPress site so content imported by Bolt's WordPress importer renders as-is. Content lives in `cms_*` tables (see `supabase/migrations/`); the public site reads them with the anon key; the `/bolt-admin` UI (rendered only inside Bolt's Admin tab) edits them through Bolt's bridge: every SQL statement it runs is declared by name in `.bolt/admin.json`.

Import project modules with the `@/` path alias, which maps to `app/` (e.g. `@/lib/cms` == `app/lib/cms`), instead of deep relative paths.

This template uses Vite 8 and React Router 8. `resolve.tsconfigPaths: true` is a valid built-in Vite option; preserve it for the `@/` alias and do not add `vite-tsconfig-paths`. Route modules receive `loaderData` (not `data`) in `meta()` and component props, and use `clientLoader` because the app is a static SPA.

Follow `.bolt/skills/bolt-cms/SKILL.md` before touching content, schema, or the admin. Use `.bolt/skills/bolt-cms-theme/SKILL.md` for visual changes (edit theme tokens, do not restyle components ad hoc) and `.bolt/skills/bolt-cms-extend/SKILL.md` to add a field, make another content type editable, or add a site-specific table.

The browser only has the anon key; there is no server and no secret in this project. Only `VITE_SUPABASE_URL` and `VITE_SUPABASE_ANON_KEY` are configured.

Schema changes follow Bolt's `bolt-database` skill; the admin follows Bolt's `bolt-admin-dashboard` skill, except that the admin already exists and its queries are authored in `app/admin/queries.ts` (`.bolt/admin.json` is generated) — see the `bolt-cms` skill.
115 changes: 115 additions & 0 deletions bolt-cms-react-router/.bolt/skills/bolt-cms-extend/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
---
name: bolt-cms-extend
description: >-
Extend a Bolt CMS site's content model: add an editable field to posts
or pages, make an imported custom post type editable in the admin, or
add a site-specific table. Use whenever the user wants the admin to
edit something it does not yet show.
---

# Extending the content model

Three places describe a field; all three must agree:

1. **The column** on the content table (`cms_posts`, `cms_pages`, …).
2. **The registry row** in `cms_fields` — the admin editor picks the
input from its `primitive` and `options`.
3. **The admin queries** in `app/admin/queries.ts` — Bolt only runs SQL
declared in `.bolt/admin.json`, which `npm run manifest` generates
from that file.

Apply schema changes as Bolt's `bolt-database` skill says, and write
queries by the manifest rules of its `bolt-admin-dashboard` skill — but
in `app/admin/queries.ts`, never in `.bolt/admin.json` (generated;
`npm run typecheck` fails with "`.bolt/admin.json` is stale — run
`npm run manifest`" when it doesn't match `queries.ts`).

## Add a field to posts (or pages)

1. Migration (idempotent):

```sql
alter table public.cms_posts add column if not exists subtitle text;
insert into public.cms_fields (type_name, name, title, primitive, column_name, required, description, options, position)
values ('post', 'subtitle', 'Subtitle', 'string', 'subtitle', false, null, '{}', 20)
on conflict (type_name, name) do nothing;
```

Repeat for `cms_pages` / `type_name = 'page'` if pages need it.
2. `app/admin/queries.ts` → `contentQueries(table, word)`: add the
column to `insert` (next `$n`, e.g. `$14::text`) and `update` (e.g.
`$15::text`). Both post and page queries come from this one function,
so the column must exist on both tables — otherwise give the new
column its own query instead.
3. `app/admin/api.ts`: add the property to `ContentInput` and append it
to `contentParams` in the same `$n` order.
4. `app/routes/admin/content-edit.tsx`: add the field name to
`FIELD_LAYOUT.main` or `FIELD_LAYOUT.sidebar`, and include it when
building the `ContentInput` on save.
5. `npm run manifest`, then publish — Bolt reads `.bolt/admin.json` from
the deployed revision.
6. To show it on the site, read it from the `Post` row (add it to the
`Post` interface in `app/lib/cms/types.ts`).

### `primitive` and `options`

`FieldInput` (`app/admin/components/fields/FieldInput.tsx`) renders:

| `primitive` | Input | `options` |
| -------------------- | --------------------------------------- | ------------------------------------------- |
| `string` | text (textarea for `excerpt`, `bio`, `description`) | — |
| `slug` | slug input that follows `title`/`name` | — |
| `number` | number input, empty → `null` | `{"integer": true}` |
| `boolean` | checkbox | — |
| `date` / `datetime` | date / datetime-local (stored ISO) | — |
| `image` / `file` | media picker storing `cms_assets.id` | — |
| `reference` | select over another type | `{"to": ["author"]}` (`author`, `category`, `tag`, `post`, `page`) |
| `array` of reference | checklist (`bigint[]` column) | `{"of": {"primitive": "reference", "to": ["category"]}}` |
| `array` of block | rich-text editor (Portable Text `jsonb`) | `{"of": {"primitive": "block"}}` |
| other `array`/`object` | JSON input | — |

Parameter casts in the SQL follow the client's conventions: scalars as
`$n::text|bigint|int|boolean`, `bigint[]` via `pgArray(ids)` →
`$n::bigint[]`, `jsonb` via `JSON.stringify(value)` → `$n::jsonb`,
timestamps as ISO strings → `$n::timestamptz`.

## Make an imported custom post type editable

The importer writes custom post types (e.g. `portfolio`) to their own
table (`select name, table_name from public.cms_types`) with
`cms_fields` rows; the site and admin ignore them until you wire them
up. For a table with the same columns as `cms_posts`:

1. `app/admin/queries.ts`: widen the `contentQueries` table/word
unions, then spread it into `ADMIN_QUERIES` under new keys:

```ts
const portfolio = contentQueries('cms_portfolio', 'project');
// in ADMIN_QUERIES:
listPortfolio: portfolio.list, countPortfolio: portfolio.count, getPortfolio: portfolio.get,
listPortfolioOptions: portfolio.options, insertPortfolio: portfolio.insert, updatePortfolio: portfolio.update,
setPortfolioStatus: portfolio.setStatus, deletePortfolio: portfolio.remove,
```
2. `app/admin/api.ts`: add the type to `ContentType`, `isContentType`,
and `CONTENT_TYPES` (label, singular, table, the eight query names);
add the table to `ContentTable` in `app/lib/cms/types.ts`.
3. Add a sidebar link to `content/<type>` in
`app/routes/admin/layout.tsx`.
4. Optional public route in `app/routes.ts` plus a read helper in
`app/lib/cms/queries.ts`.
5. `npm run manifest`, publish.

Different columns → write its eight queries by hand following the
`contentQueries` shape.

## Site-specific tables

- Prefix with `cms_`, create with `if not exists`, `enable row level
security`, and add a public read policy only if the site reads it
(wrap `create policy` in a `do $$ … if not exists (select 1 from
pg_policies …) … $$` block, as `0002_starter.sql` does).
- For admin editing, add named queries to `app/admin/queries.ts`
following the `bolt-admin-dashboard` manifest rules, then
`npm run manifest`, publish.
- `cms_*` tables the importer owns follow the `bolt-database` safety
rules.
Loading
Loading