# 13 | CMS help and sample data

## Goal

Publish sanitized help articles and show them through read-only public endpoints.

## What you are building

CMS uses staff authentication. Help articles have title, summary, body and draft/published status. Public endpoints whitelist fields and expose only published items. Rich text is sanitized before the article component displays it with v-html; ordinary customer text remains escaped. seed is a project custom command exposed by flaxon_cli.py. It creates a sample project for the first registered customer and a published help article. Stop the server before seeding, then restart it so in-process CMS state reloads. Publishing permission remains distinct from drafting permission.

## Build this chapter

Stop the server. Work in project_manager. Follow each CREATE, whole-file replacement, or marked-block edit below in order.

## Step 1: CREATE - make parent folders, then create this file - flaxon_cli.py

This file owns the feature named by its module or component. Read the route/form flow before continuing.

Create the parent directories, then save this complete file.

```python
"""Expose commands owned by feature modules to the Flaxon CLI.

Run these commands from the project directory. Add another module's
install_cli_commands(globals()) call here when you add its commands.
"""

from modules.welcome.module import welcome

welcome.install_cli_commands(globals())


from flaxon.cli.base import Command


def seed_command(args, console):
    import asyncio
    from seed import seed
    asyncio.run(seed())
    return 0


seed = Command("seed", seed_command, help_text="Create sample projects and published help content")
```

**Say:** "This file owns the feature named by its module or component. Read the route/form flow before continuing."

**Type:** Follow the action above in `flaxon_cli.py`; explain each new function or binding as you add it.

**Viewers should see:** The saved file matches this step. After all steps, run the chapter checks below and show the expected result.

## Step 2: CREATE - make parent folders, then create this file - modules/content/__init__.py

This file owns the feature named by its module or component. Read the route/form flow before continuing.

Create the parent directories, then save this complete file.

```python
"""Module-owned APIs and interface files."""
```

**Say:** "This file owns the feature named by its module or component. Read the route/form flow before continuing."

**Type:** Follow the action above in `modules/content/__init__.py`; explain each new function or binding as you add it.

**Viewers should see:** The saved file matches this step. After all steps, run the chapter checks below and show the expected result.

## Step 3: CREATE - make parent folders, then create this file - modules/content/module.py

Create the public content API. Return only published CMS help articles. Staff draft and publishing operations remain in Admin.

Create the parent directories, then save this complete file.

```python
"""Public read-only content endpoints; never expose the authenticated CMS API to readers."""

from pathlib import Path
from flaxon.modules import FlaxonModule
from flaxon.exceptions import NotFound

content = FlaxonModule(
    "content",
    ui_dir=Path(__file__).parent / "ui",
    ui_routes={"Help.js": "/help", "Article/[slug].js": "/help/:slug"},
)


async def published_articles(request):
    cms = request.app.cms
    await cms._load_database()
    articles = cms.content_types["help_article"]
    return [
        item for item in articles.items.values() if item.get("status") == "published"
    ]


@content.get("/articles")
async def list_articles(request):
    articles = await published_articles(request)
    return {
        "data": [
            {key: item.get(key) for key in ("id", "title", "slug", "summary")}
            for item in articles
        ]
    }


@content.get("/articles/<slug>")
async def get_article(request, slug):
    for article in await published_articles(request):
        if article["slug"] == slug:
            return {
                "data": {
                    key: article.get(key)
                    for key in ("id", "title", "slug", "summary", "body")
                }
            }
    raise NotFound("Article not found.")
```

**Say:** "Create the public content API. Return only published CMS help articles. Staff draft and publishing operations remain in Admin."

**Type:** Follow the action above in `modules/content/module.py`; explain each new function or binding as you add it.

**Viewers should see:** The saved file matches this step. After all steps, run the chapter checks below and show the expected result.

## Step 4: CREATE - make parent folders, then create this file - modules/content/ui/pages/Help.html

This file owns the feature named by its module or component. Read the route/form flow before continuing.

Create the parent directories, then save this complete file.

```html
<template>
  <section>
    <h1>Help centre</h1>
    <p v-if="loading" role="status">Loading articles…</p>
    <p v-if="message" role="alert">{{ message }}</p>
    <p v-if="!loading && articles.length === 0">No published articles yet.</p>
    <div class="cards">
      <a
        v-for="article in articles"
        :key="article.id"
        :href="'/help/' + article.slug"
        data-teloce-link
        class="card"
        ><h2>{{ article.title }}</h2>
        <p>{{ article.summary }}</p></a
      >
    </div>
  </section>
</template>
<script lang="ts">
import { api } from "../../../../ui/api.ts";
export default {
  data() {
    return { articles: [], loading: true, message: "" };
  },
  async mounted() {
    try {
      this.articles = await api("/api/content/articles");
    } catch (error) {
      this.message = error.message;
    } finally {
      this.loading = false;
    }
  },
};
</script>
<style scoped>
* {
  box-sizing: border-box;
}
a {
  color: #1d4ed8;
}
h1 {
  font-size: clamp(1.8rem, 5vw, 3rem);
  line-height: 1.15;
}
h2 {
  font-size: 1.2rem;
}
p {
  line-height: 1.6;
}
[role="alert"] {
  color: #a21c2b;
  padding: 0.8rem;
  border-left: 4px solid;
}
:focus-visible {
  outline: 3px solid #f59e0b;
  outline-offset: 3px;
}
.card {
  display: block;
  background: white;
  border: 1px solid #d8e0ed;
  border-radius: 12px;
  padding: 1.2rem;
  margin: 1rem 0;
  color: inherit;
  text-decoration: none;
}
.cards {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(min(100%, 240px), 1fr));
  gap: 1rem;
}
</style>
```

**Say:** "This file owns the feature named by its module or component. Read the route/form flow before continuing."

**Type:** Follow the action above in `modules/content/ui/pages/Help.html`; explain each new function or binding as you add it.

**Viewers should see:** The saved file matches this step. After all steps, run the chapter checks below and show the expected result.

## Step 5: CREATE - make parent folders, then create this file - modules/content/ui/pages/Article/[slug].html

This file owns the feature named by its module or component. Read the route/form flow before continuing.

Create the parent directories, then save this complete file.

```html
<template>
  <article>
    <a href="/help" data-teloce-link>← Help centre</a>
    <p v-if="loading" role="status">Loading article…</p>
    <p v-if="message" role="alert">{{ message }}</p>
    <div v-if="article">
      <h1>{{ article.title }}</h1>
      <p>{{ article.summary }}</p>
      <div class="article-body" v-html="article.body"></div>
    </div>
  </article>
</template>
<script lang="ts">
import { api } from "../../../../../ui/api.ts";
export default {
  props: ["slug"],
  data() {
    return { article: null, loading: true, message: "" };
  },
  async mounted() {
    try {
      this.article = await api(
        "/api/content/articles/" + encodeURIComponent(this.slug),
      );
    } catch (error) {
      this.message = error.message;
    } finally {
      this.loading = false;
    }
  },
};
</script>
<style scoped>
* {
  box-sizing: border-box;
}
a {
  color: #1d4ed8;
}
h1 {
  font-size: clamp(1.8rem, 5vw, 3rem);
  line-height: 1.15;
}
h2 {
  font-size: 1.2rem;
}
p {
  line-height: 1.6;
}
[role="alert"] {
  color: #a21c2b;
  padding: 0.8rem;
  border-left: 4px solid;
}
:focus-visible {
  outline: 3px solid #f59e0b;
  outline-offset: 3px;
}
.article-body {
  line-height: 1.6;
  overflow-wrap: anywhere;
}
</style>
```

**Say:** "This file owns the feature named by its module or component. Read the route/form flow before continuing."

**Type:** Follow the action above in `modules/content/ui/pages/Article/[slug].html`; explain each new function or binding as you add it.

**Viewers should see:** The saved file matches this step. After all steps, run the chapter checks below and show the expected result.

## Step 6: CREATE - make parent folders, then create this file - seed.py

Populate sample data explicitly after a customer exists; do not seed on every server start.

Create the parent directories, then save this complete file.

```python
"""Idempotent sample content. Register your own user before running this command."""

from app import create_app
from models import User, Project, Task


async def seed():
    app = create_app()
    async with app.db:
        user = await User.all().order_by("id").first()
        if user is None:
            raise ValueError("Register an account before seeding sample projects.")
        if not await Project.filter(owner_id=user.id).exists():
            project = await Project.create(
                owner_id=user.id,
                name="Launch my portfolio",
                description="A small project to practise planning.",
            )
            for title, status in [
                ("Choose a design", "done"),
                ("Build the homepage", "doing"),
                ("Deploy the website", "todo"),
            ]:
                await Task.create(project_id=project.id, title=title, status=status)
    articles = app.cms.content_types["help_article"]
    if not articles.items:
        articles.create(
            {
                "title": "Getting started",
                "summary": "Create a project, add tasks, and track progress.",
                "body": "<p>Open Projects, create a project, and add your first task. Move tasks from To do to Doing to Done.</p>",
                "status": "published",
            }
        )
        app.cms._save(articles)
    print("Sample projects, tasks, and help content are ready.")
```

**Say:** "Populate sample data explicitly after a customer exists; do not seed on every server start."

**Type:** Follow the action above in `seed.py`; explain each new function or binding as you add it.

**Viewers should see:** The saved file matches this step. After all steps, run the chapter checks below and show the expected result.

## Step 7: EDIT - replace the marked blocks - ui/app.html

This is the SPA shell. Its internal anchors and router view cooperate with each module's page mappings.

### Edit 1: find this exact block

Open `ui/app.html`. Locate this block once. The unchanged surrounding lines identify its position; do not use line numbers from another revision.

```html
      <a href="/" data-teloce-link class="brand">Project Manager</a>
      <nav aria-label="Main navigation">
        <a href="/projects" data-teloce-link>Projects</a>
        <a href="/login" data-teloce-link>Account</a>
      </nav>
    </header>
```
### Replace that block with

```html
      <a href="/" data-teloce-link class="brand">Project Manager</a>
      <nav aria-label="Main navigation">
        <a href="/projects" data-teloce-link>Projects</a>
        <a href="/help" data-teloce-link>Help</a>
        <a href="/login" data-teloce-link>Account</a>
      </nav>
    </header>
```

Leave the rest of the file unchanged.

**Say:** "This is the SPA shell. Its internal anchors and router view cooperate with each module's page mappings."

**Type:** Follow the action above in `ui/app.html`; explain each new function or binding as you add it.

**Viewers should see:** The saved file matches this step. After all steps, run the chapter checks below and show the expected result.

## Step 8: EDIT - replace the marked blocks - app.py

The application factory mounts only the features already introduced. Follow the action label: a marked-block edit keeps the surrounding factory intact.

### Edit 1: find this exact block

Open `app.py`. Locate this block once. The unchanged surrounding lines identify its position; do not use line numbers from another revision.

```python
from modules.auth.module import auth
from modules.projects.module import projects
from modules.tasks.module import tasks
from modules.welcome.module import welcome
from settings import (
    ROOT,
```
### Replace that block with

```python
from modules.auth.module import auth
from modules.projects.module import projects
from modules.tasks.module import tasks
from modules.content.module import content
from modules.welcome.module import welcome
from settings import (
    ROOT,
```

Leave the rest of the file unchanged.

### Edit 2: find this exact block

Open `app.py`. Locate this block once. The unchanged surrounding lines identify its position; do not use line numbers from another revision.

```python
        (auth, "/api/auth"),
        (projects, "/api/projects"),
        (tasks, "/api/tasks"),
        (welcome, "/api/welcome"),
    ]:
        app.mount_module(module, prefix=prefix)
```
### Replace that block with

```python
        (auth, "/api/auth"),
        (projects, "/api/projects"),
        (tasks, "/api/tasks"),
        (content, "/api/content"),
        (welcome, "/api/welcome"),
    ]:
        app.mount_module(module, prefix=prefix)
```

Leave the rest of the file unchanged.

### Edit 3: find this exact block

Open `app.py`. Locate this block once. The unchanged surrounding lines identify its position; do not use line numbers from another revision.

```python
    @app.get("/login")
    @app.get("/projects")
    @app.get("/projects/<int:project_id>")
    async def spa(request: Request, project_id=None, slug=None):
        return await request.compile("app.html", {})
```
### Replace that block with

```python
    @app.get("/login")
    @app.get("/projects")
    @app.get("/projects/<int:project_id>")
    @app.get("/help")
    @app.get("/help/<slug>")
    async def spa(request: Request, project_id=None, slug=None):
        return await request.compile("app.html", {})
```

Leave the rest of the file unchanged.

**Say:** "The application factory mounts only the features already introduced. Follow the action label: a marked-block edit keeps the surrounding factory intact."

**Type:** Follow the action above in `app.py`; explain each new function or binding as you add it.

**Viewers should see:** The saved file matches this step. After all steps, run the chapter checks below and show the expected result.

## Run and check

```bash
python management.py seed
# Stop/restart the running server after seeding:
python management.py runserver
# Open /help and /admin/cms/.
```

## Expected result

Run seed after registering a customer. /help lists the sample published article and the article page survives a refresh. Drafts remain absent from public responses.

## Common errors

Do not call authenticated CMS editing endpoints from public reader pages. Do not display arbitrary HTML using v-html. A draft editor also needs publishing permission to modify live content.

## Short exercise

Create a draft in staff CMS, confirm it is hidden publicly, then publish it with a permitted staff account.

## What to say and show

Say: "Publish sanitized help articles and show them through read-only public endpoints. The server remains responsible for persistence and authorization; the browser presents the result."

Show the expected result above, then point to the file responsible for it. Run the chapter check before the next lesson.

## Save your checkpoint

```bash
git add .
git commit -m "Complete chapter 13: CMS help and sample data"
```
