Skip to content
Back to skills

Stacks Cms

ASecurity

Use when working with the CMS in a Stacks application - posts, authors, pages, categories, tags, comments, blog configuration, RSS feeds, or sitemaps. Covers @stacksjs/cms, CMS models, routes, and actions.

  • 631 stars
  • 0 votes
  • 0 copies
  • 1 view
  • Added June 5, 2026
developmenttypescriptgobashreactgitapidatabase

Works with

  • cli
  • api

Security analysis

A100/100

Scanned October 1, 2026

npx -y skills add stacksjs/stacks --skill stacks-cms --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Stacks Cms?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Stacks Cms
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/stacksjs-stacks-cms/badge)](https://www.skillsdirectory.com/skills/stacksjs-stacks-cms)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: stacks-cms
description: Use when working with the CMS in a Stacks application - posts, authors, pages, categories, tags, comments, blog configuration, RSS feeds, or sitemaps. Covers @stacksjs/cms, CMS models, routes, and actions.
license: MIT
compatibility: Bun >= 1.3.0, TypeScript
allowed-tools: Read Edit Write Bash Grep Glob
---

# Stacks CMS

## Key Paths
- Core package: `storage/framework/core/cms/src/`
- Models: `storage/framework/defaults/app/Models/Content/` (Post, Author, Page)
- Models: `storage/framework/defaults/app/Models/` (Comment, Tag)
- Actions: `storage/framework/defaults/app/Actions/Cms/`
- Routes: `routes/api.ts` (CMS and blog endpoints)
- Config: `config/blog.ts`

## Source Files
```
cms/src/
├── index.ts              # CmsNamespace — top-level API
├── posts/
│   ├── fetch.ts          # fetchById, fetchAll, fetchByStatus, fetchByCategory, fetchByAuthor
│   ├── store.ts          # store, attach, detach, sync (pivot tables)
│   ├── update.ts         # update
│   └── destroy.ts        # destroy, bulkDestroy
├── authors/
│   ├── fetch.ts          # fetchAll, fetchById, findByEmail, findByName, findByUserId
│   ├── store.ts          # store, findOrCreate
│   ├── update.ts         # update
│   └── destroy.ts        # destroy
├── pages/
│   ├── fetch.ts          # fetchById, fetchAll, fetchByTemplate, fetchByAuthor
│   └── store.ts          # store
├── categorizables/       # Categories with polymorphic pivot
├── taggables/            # Tags with polymorphic pivot
├── commentables/         # Comments with polymorphic support
└── build.ts              # Blog static site generation
```

## CMS Namespace

```typescript
interface CmsNamespace {
  posts: PostsModule
  postCategories: PostCategoriesModule
  tags: TagsModule
  comments: CommentsModule
  authors: AuthorsModule
  pages: PagesModule
}
```

## Models

### Post
- **Table**: `posts`
- **Relationships**: `belongsTo(['Author'])`
- **Traits**: UUID, Timestamps, Search, Seeder (20), Categorizable, Taggable, Commentable, API routes

| Field | Type | Validation | Default |
|-------|------|------------|---------|
| title | string | required, 3-255 chars | — |
| content | string | required, 10-1000 chars | — |
| excerpt | string | optional, 10-500 chars | — |
| poster | string | optional, valid URL | — |
| views | number | — | 0 |
| publishedAt | timestamp | optional | — |
| status | enum | published/draft/archived | draft |
| isFeatured | number | 0 or 1 | — |

### Author
- **Table**: `authors`, **Relationships**: `hasMany(['Post'])`, `belongsTo(['User'])`
- **Attributes**: `name` (5-255), `email` (unique, valid email)
- **Indexes**: Composite on `(email, name)`

### Page
- **Table**: `pages`, **Relationships**: `belongsTo(['Author'])`
- **Attributes**: `title`, `template` (enum: default/landing/blog/contact), `views`, `conversions`, `publishedAt`

### Comment
- **Table**: `comments`, **Relationships**: `belongsTo(['Post', 'User'])`
- **Attributes**: `authorName`, `authorEmail`, `content` (1-2000), `status` (pending/approved/spam/trash), `ipAddress`, `userAgent`, `isApproved`

### Tag
- **Table**: `tags`
- **Attributes**: `name` (unique, 2-50), `slug` (unique), `description`, `postCount`, `color`

## Posts API

```typescript
// Fetch
cms.posts.fetchById(id: number): Promise<PostJsonResponse | undefined>
cms.posts.fetchAll(): Promise<PostJsonResponse[]>
cms.posts.fetchByStatus(status: 'published' | 'draft' | 'archived'): Promise<PostJsonResponse[]>
cms.posts.fetchByCategory(category: string): Promise<PostJsonResponse[]>
cms.posts.fetchByAuthor(author: string): Promise<PostJsonResponse[]>
cms.posts.fetchByMinViews(minViews: number): Promise<PostJsonResponse[]>
cms.posts.fetchPublishedAfter(timestamp: number): Promise<PostJsonResponse[]>

// Store with pivot auto-attach
cms.posts.store(data: NewPost & { body?: string, category?: string }): Promise<PostJsonResponse>
cms.posts.attach(postId, tableName: 'categorizable_models' | 'taggable_models', ids: number[]): Promise<void>
cms.posts.detach(postId, tableName, ids?: number[]): Promise<void>
cms.posts.sync(postId, tableName, ids: number[]): Promise<void>

// Update & Destroy
cms.posts.update(id: number, data: Partial<PostUpdate>): Promise<PostJsonResponse>
cms.posts.destroy(id: number): Promise<boolean>
cms.posts.bulkDestroy(ids: number[]): Promise<number>
```

## Authors API

```typescript
cms.authors.findOrCreate(data: AuthorData): Promise<AuthorJsonResponse>
cms.authors.store(data: NewAuthor): Promise<AuthorJsonResponse>
cms.authors.findByEmail(email: string): Promise<AuthorJsonResponse | undefined>
cms.authors.findByName(name: string): Promise<AuthorJsonResponse | undefined>
cms.authors.findByUserId(userId: number): Promise<AuthorJsonResponse | undefined>
cms.authors.fetchAll(): Promise<AuthorJsonResponse[]>
cms.authors.fetchById(id: number): Promise<AuthorJsonResponse | undefined>
cms.authors.update(id, data): Promise<AuthorJsonResponse>
cms.authors.destroy(id): Promise<boolean>
```

## Tags API

```typescript
cms.tags.findOrCreateMany(names: string[], taggableType: string): Promise<number[]>
cms.tags.firstOrCreate(name: string, taggableType: string): Promise<TaggableTable>
cms.tags.fetchTags(): Promise<TaggableTable[]>
cms.tags.fetchTagById(id: number): Promise<TaggableTable | undefined>
cms.tags.countTaggedPosts(taggableType: string): Promise<number>
cms.tags.findMostUsedTag(taggableType?: string): Promise<{ name: string, count: number } | null>
cms.tags.fetchTagsWithPostCounts(): Promise<Array<{ name: string, postCount: number }>>
cms.tags.fetchTagDistribution(): Promise<Array<{ name: string, count: number, percentage: number }>>
```

## Comments API

```typescript
cms.comments.createComment(data: CreateCommentInput): Promise<CommentablesTable>
cms.comments.updateComment(id: number, input: UpdateCommentInput): Promise<CommentablesTable>
cms.comments.approveComment(id: number): Promise<CommentablesTable>
cms.comments.rejectComment(id: number): Promise<CommentablesTable>
cms.comments.deleteComment(id: number): Promise<void>
```

### The `commentable` trait
A model with `traits: { commentable: true }` (singular; `commentables` is inert) gains instance methods
keyed to the record's own id:

```typescript
await post.addComment({ body, author_name?, author_email?, user_id?, title? }, { status: 'pending' | 'approved' })
await post.comments() / post.approvedComments() / post.pendingComments() / post.rejectedComments() // oldest first
await post.commentCount()
```

Only those five fields are written, so a validated form body can go straight in; status and owner come
from the trait. `author_name` / `author_email` are for guest commenters (no account); never render the
email. Moderate without a dashboard with `buddy comments:list [--status] [--type]`,
`comments:approve <id>`, `comments:reject <id>`, `comments:delete <id>`.

## Routes

### CMS Admin Routes
| Method | Path | Action |
|--------|------|--------|
| GET | `/cms/posts` | PostIndexAction |
| GET | `/cms/posts/{id}` | PostShowAction |
| POST | `/cms/posts` | PostStoreAction |
| PATCH | `/cms/posts/{id}` | PostUpdateAction |
| DELETE | `/cms/posts/{id}` | PostDestroyAction |
| PATCH | `/cms/posts/{id}/views` | PostViewsUpdateAction |
| CRUD | `/cms/authors/*` | Author CRUD |
| CRUD | `/cms/categories/*` | Categorizable CRUD |
| CRUD | `/cms/tags/*` | Taggable CRUD |
| CRUD | `/cms/comments/*` | Comment CRUD |
| CRUD | `/cms/pages/*` | Page CRUD |

### Public Blog Routes
| Method | Path | Description |
|--------|------|-------------|
| GET | `/blog/posts` | Public post listing |
| GET | `/blog/posts/{id}` | Public post detail |
| GET | `/blog/categories` | Public categories |
| GET | `/blog/tags` | Public tags |
| GET | `/blog/feed.xml` | RSS 2.0 feed (20 recent) |
| GET | `/blog/sitemap.xml` | XML sitemap for SEO |

The site-wide `/sitemap.xml` merges the published posts in, so crawlers find them from the root sitemap too (see `stacks-server`, "Sitemap and robots.txt").

## Post Bodies: Images, Grids, Zoom and Embeds

Blog post HTML goes through `renderPostHtml` from `@stacksjs/cms/article`, built on ts-medium-editor's article renderer. The BunPress blog (`core/actions/src/blog.ts`) does this already; an stx-native blog calls it from its post view:

```ts
const { renderPostHtml, articleHead, articleScript } = require('@stacksjs/cms/article')
post.html = renderPostHtml(markdownHtml) // then {!! articleHead() !!} in @push('head'), {!! articleScript() !!} in @push('scripts')
```

What authors get from plain markdown:

- `![alt](/images/a.jpg "Caption")` alone in a paragraph becomes a figure with a caption.
- Images in consecutive paragraphs become a grid: two side by side, three with the tallest on the left and two stacked on the right, four as 2x2, more in rows of three and two. Sizes are read from the files in `public/`, so columns are proportioned and nothing crops or shifts.
- Lazy loading after the first image, click-to-zoom on every unlinked image.
- A bare YouTube, Vimeo or `https://hq.training/a/<id>` link on its own line becomes an embed.

Tune the look with custom properties (`--me-radius`, `--me-gap`, `--me-block-space`, `--me-caption-color`, `--me-zoom-backdrop`) on the post body. `imageSize(bytes)` and `publicImageResolver(dir)` are exported for other uses.

## Blog Configuration (config/blog.ts)

```typescript
{
  subdomain: 'blog',
  title: 'Stacks Blog',
  description: 'The official Stacks.js blog',
  postsPerPage: 10,
  enableComments: true,   // declared, NOT yet honoured by the static builder
  enableRss: true,        // generates feed.xml
  enableSitemap: true,    // generates sitemap.xml
  enableSearch: true,     // declared, NOT yet honoured by the static builder
  social: { twitter: '@stacksjs', github: 'stacksjs/stacks' },
  theme: { primaryColor: '#3451b2', logo: '/images/logos/logo-transparent.svg' },
}
```

## Database Tables

| Table | Purpose |
|-------|---------|
| `posts` | Blog posts |
| `authors` | Content creators |
| `pages` | Static pages |
| `comments` | Comments (polymorphic) |
| `tags` | Tag definitions |
| `taggable_models` | Polymorphic pivot: tag ↔ model |
| `categorizables` | Category definitions |
| `categorizable_models` | Polymorphic pivot: category ↔ model |

## Gotchas
- **Polymorphic relations** — categories, tags, and comments use `*_type` fields to support multiple model types
- **Post `content` maps from `body`** — the model attribute is `content` but the DB column mapping comes from `body`
- **Author uses findOrCreate** — `PostStoreAction` auto-creates authors if they don't exist
- **Post store auto-attaches** — creating a post with category/tag data automatically calls `attach()` on pivot tables
- **Post update uses sync** — updating categories/tags uses `sync()` (detaches removed, attaches new)
- **Views increment is atomic** — `PATCH /cms/posts/{id}/views` increments by 1
- **RSS returns 20 items** — hardcoded to 20 most recent published posts
- **Sitemap priorities** — posts 0.8, categories 0.6, blog homepage 0.9
- **Comment status flow** — starts `pending`, can be approved, rejected (→ spam), or trashed

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.