# Hướng dẫn cho AI agent - viết và quản lý nội dung trinhleminhan.com

> **Đây là file tự chứa.** Không cần truy cập internet để đọc hiểu. Kéo cả file này
> vào ChatGPT / Claude / Gemini rồi giao việc, hoặc để agent trong terminal đọc.

## Bạn làm được gì với một khoá API

| Việc | Quyền cần | Đường dẫn |
|---|---|---|
| Viết bài blog (Markdown hoặc JSON) | `post:draft` | `POST /api/ingest/post` |
| Thêm ảnh vào bài | `post:draft` | gửi kèm file trong cùng request |
| Tra sách / sản phẩm / bài đã có | `post:draft` | `GET /api/ingest/lookup` |
| Thêm & sửa sách, sản phẩm | `content:write` | `POST/PATCH /api/manage/books` |
| Thêm & sửa công cụ phần mềm | `content:write` | `POST/PATCH /api/manage/tools` |
| Tạo trang tĩnh | `content:write` | `POST /api/manage/pages` |
| Đăng bài / gỡ bài | `content:publish` | `PATCH /api/manage/posts` |
| Sửa cài đặt site | `settings:write` | `PATCH /api/manage/settings` |
| Đổi quyền người dùng | `users:write` | `PATCH /api/manage/users` |

Chủ site tạo khoá ở `/admin` -> **Khoá API của tôi**.

## Ba luật cứng

1. **Bài và mục mới LUÔN là bản nháp / đang ẩn.** Không có tham số nào để đăng thẳng
   khi tạo. Xong việc thì báo *"đã tạo bản nháp"*, **không** nói *"đã đăng bài"*.
2. **Không đường nào nhận HTML thô.** Nội dung phải là Markdown hoặc `blog-json-v1`.
3. **API không bao giờ cấp được vai trò Admin**, kể cả bằng khoá của Admin.

## Quy trình chuẩn

```
1. GET /api/manage            -> xem khoá này có quyền gì (routes[].allowed)
2. GET /llms.txt              -> xem site đang có nội dung gì, để dẫn link nội bộ đúng
3. GET /api/ingest/lookup     -> tra slug thật của sách/sản phẩm/bài muốn nhắc tới
4. POST /api/ingest/post?dry_run=1  -> dựng thử, đọc lint.warnings, sửa
5. POST /api/ingest/post      -> lưu thật
6. Báo lại edit_url + nói rõ đây là BẢN NHÁP
```

Bước 2 và 3 là thứ phân biệt bài viết tốt với bài viết bịa. **Đừng đoán slug.**

## Bài về công cụ / phần mềm thì phân loại riêng

Site có mục riêng cho công cụ: **`/tools`**. Khi viết bài về một phần mềm, công cụ,
dịch vụ hay tiện ích, làm **hai** việc:

**1. Đặt bài vào đúng danh mục** - trong `meta` của bài:

```json
{ "meta": { "category": "cong-cu", "section": "goc-cua-an", "tags": ["cong-cu", "..."] } }
```

## Bỏ bài vào mục nào

| Trường | Bao nhiêu | Giá trị |
|---|---|---|
| `section` | **một** | `goc-cua-an` · `khai-tam` · `thu-vien` · `sach-hay` · `dang-doc` · `sach-moi` · `featured` · `ngau-nhien` |
| `category` | **một** | `cong-cu` · `goc-an` |
| `tags` | **nhiều, tối đa 15** | tự do, thẻ chưa có sẽ tự tạo |

Bài thuộc nhiều chủ đề thì **dùng `tags`**. Một bài không nằm được ở hai `section`
cùng lúc - cấu trúc dữ liệu chỉ có một chỗ cho nó. Đừng cố nhét mảng vào, sẽ bị bỏ.

Không chắc slug nào đang có thì tra `GET /api/ingest/lookup`, đừng đoán.

**2. Tạo mục trong danh sách công cụ** và nối ngược về bài (cần `content:write`):

```bash
curl -X POST "$H/api/manage/tools" -H "Authorization: Bearer $KEY" \
  -H 'content-type: application/json' -d '{
    "name": "Obsidian",
    "tagline": "Ghi chú markdown lưu ngay trên máy, không phụ thuộc dịch vụ nào.",
    "category": "productivity",
    "pricing": "freemium",
    "platforms": ["macos", "windows", "linux", "ios", "android"],
    "homepage_url": "https://obsidian.md",
    "docs_url": "https://help.obsidian.md",
    "post": "slug-bai-viet-vua-tao",
    "description": "## Vì sao tôi dùng\n\n..."
  }'
```

Trường `post` nối công cụ ↔ bài viết. Nhờ đó trang `/tools` tự hiện link *"Bài viết:
… ->"*, và người đọc đi được từ danh sách công cụ sang bài chi tiết.

`category` của công cụ: `dev` · `design` · `productivity` · `media` · `ai` · `khac`
`pricing`: `free` · `freemium` · `paid` · `opensource`
`platforms`: `macos` · `windows` · `linux` · `web` · `ios` · `android`

## Liên kết nội bộ - làm cho site dính vào nhau

Bài viết tốt trên site này **luôn** dẫn sang nội dung đã có. Cách làm:

| Muốn nhắc tới | Trong JSON | Trong Markdown |
|---|---|---|
| Một cuốn sách | `{"type":"ref","kind":"book","id":"slug"}` | `::book[slug]` |
| Một sản phẩm | `{"type":"ref","kind":"product","id":"slug"}` | `::product[slug]` |
| Bài viết khác | `{"type":"ref","kind":"post","id":"slug"}` | `::post[slug]` |
| Tác giả | `{"type":"ref","kind":"author","id":"slug"}` | `::author[slug]` |

Slug phải tra bằng `GET /api/ingest/lookup?type=book&q=...` trước. Tra không ra
nghĩa là **thứ đó chưa có trên site** - bỏ thẻ đi, viết bằng chữ thường, đừng bịa.

Đọc `/llms.txt` để biết site đang có gì mà dẫn - nó liệt kê mọi bài, sách, sản phẩm,
công cụ và trang đã xuất bản, kèm một dòng mô tả từng thứ.

## Ảnh

**Có ảnh trong máy** -> gửi `multipart/form-data`, tên trường trùng đúng đường dẫn
viết trong bài:

```bash
curl -X POST "$H/api/ingest/post?dry_run=1" -H "Authorization: Bearer $KEY" \
  -F "json=<bai.json" \
  -F "./anh/bia.jpg=@anh/bia.jpg;type=image/jpeg" \
  -F "./anh/buoc-1.jpg=@anh/buoc-1.jpg;type=image/jpeg"
```

Máy chủ nhận ảnh, đẩy lên kho, và tự thay đường dẫn trong bài. Tối đa 20 ảnh, mỗi
ảnh ≤4MB.

**Mọi ảnh phải có `caption` hoặc `alt`.** Thiếu là bị cảnh báo.

## Giọng văn - phần quan trọng nhất

Blog cá nhân tiếng Việt của **Trịnh Lê Minh An**, người **miền Nam**. Chủ đề: tự làm
(DIY), công cụ & phần mềm, công nghệ, xe cộ, du lịch, thương mại điện tử, văn hoá,
ngôn ngữ, tâm lý, sách.

> **Đặc tả giọng văn đầy đủ nằm ở PHẦN D bên dưới. Đọc trước khi viết một chữ nào.**
> Bài đúng kỹ thuật mà sai giọng thì An phải ngồi viết lại từ đầu.

Bốn thứ bị bắt lỗi tự động, nhớ ngay:

1. **Cấm dấu gạch dài `—`.** Đây là dấu hiệu số một của văn AI. Dùng `-` hoặc tách câu.
   Kể cả trong tiêu đề và tóm tắt.
2. **Cấm mũi tên `→`.** Gõ `->`. Tương tự `⇒` viết `=>`.
3. **Tiếng miền Nam.** hỏng->hư, vỡ->bể, vào->vô, thế->vậy, đắt->mắc, nhanh->lẹ,
   buồn cười->mắc cười, ô tô->xe hơi.
4. **Chê phải chê cho vui.** Đây là chỗ bài chết nhiều nhất. **Cấm** mấy từ nhạt:
   dở, kém, tệ, kém chất lượng, xấu xí, chưa hoàn thiện, chưa tối ưu, gây khó chịu.
   Dùng từ có hình ảnh: **cùi bắp** (rẻ tiền), **khùi như đầu bờ** (thiết kế não phẳng),
   **nhìn như mấy thằng beta** (làm cho có), **banh chành** (nát bét),
   **trớt quớt** (vô dụng), **thấy gớm** (nhìn ghê), **quạu** (bực).

Xưng **"An"** hoặc **"tôi"**, gọi người đọc là **"bạn"**. Không "chúng ta", không "mình".
Có emoji 😂 (2-4 cái một bài). Giọng phẳng lì là hỏng.

Ví dụ nhanh:

| Nghe như AI | Giọng An |
|---|---|
| `Rồi Ezviz cập nhật, và mọi thứ vỡ.` | `Rồi Ezviz cập nhật, và mọi thứ đờ mờ nó, đập đi xây lại 😂` |
| `Giao diện khá xấu xí.` | `Giao diện nhìn thấy gớm 😂` |
| `Phần mềm này chất lượng kém.` | `Cái phần mềm cùi bắp này 😂` |
| `Thiết kế chưa hợp lý.` | `Thiết kế khùi như đầu bờ, ai nghĩ ra vậy trời` |
| `Bản này chưa hoàn thiện.` | `Bản này nhìn như mấy thằng beta, chưa xong mà dám thả ra` |
| `Tính năng này không hữu ích.` | `Tính năng này trớt quớt, để cho có` |
| `Không phải tôi vụng.` | `Không phải An gà đâu nha.` |

Đọc vài bài mới nhất ở `/blog/` trước khi viết để bắt giọng.

## Không được làm

- Bịa tên, giá, ngày, số liệu, thông số không có trong nguồn.
- Bịa slug rồi hy vọng nó tồn tại.
- Đưa API key, token, mật khẩu, đường dẫn nội bộ vào bài - **ảnh chụp màn hình
  terminal là nơi rò rỉ nhiều nhất**, quét kỹ trước khi đính kèm.
- Nói với người dùng là "đã đăng bài". Kết quả luôn là **bản nháp**.

---

Phần dưới là đặc tả kỹ thuật đầy đủ.

## Ủng hộ (donate) - luật khi viết bài

Site có nút **"Ủng hộ cà phê cho An"** ở cuối mỗi bài, tự hiện, agent không phải
làm gì. Nhưng khi VIẾT nội dung có nhắc tới việc ủng hộ thì phải theo mấy luật này:

**Không được viết:**
- Bất kỳ câu nào ám chỉ ủng hộ sẽ mở khoá nội dung, hay ai ủng hộ được ưu tiên.
- Bất kỳ lời hứa đổi lấy tiền: "ủng hộ 500k An sẽ làm video", "đủ 10 triệu An sẽ thử thách X".
  An **không nhận** ủng hộ để làm theo yêu cầu - đây là điều khoản, không phải sở thích.
- Gọi đó là "quyên góp", "gây quỹ", "từ thiện". Nó là ủng hộ cá nhân cho người viết.
- Hứa hoàn tiền, xuất hoá đơn, hay bất kỳ chứng từ nào.

**Được viết:** lời mời nhẹ nhàng, không nài. Kiểu "bài này miễn phí, thấy có ích thì
mời An ly cà phê cho vui, không thì cũng không sao 😄".

**Chỗ nào có mã QR thì chỗ đó có điều khoản.** Giao diện tự lo phần này - đừng viết
lại điều khoản trong thân bài, vì hai bản lệch nhau thì rắc rối.

Chi tiết: https://trinhleminhan.com/policies/terms#ung-ho


---

# PHẦN D: GIỌNG VĂN (đọc phần này trước tiên)

# Giọng văn của An

> **Đọc file này TRƯỚC khi viết một chữ nào.** Bài đúng kỹ thuật mà sai giọng thì
> An phải ngồi viết lại từ đầu - còn mệt hơn tự viết.

An là người **miền Nam**, viết blog cá nhân, không phải viết tài liệu công ty.
Bài phải đọc ra như An đang ngồi kể cho bạn nghe, không phải như một bản báo cáo.

---

## 1. Ba lỗi khiến bài "nghe như AI viết"

Đây là ba thứ An nhận ra ngay từ dòng đầu.

### 1.1 Dấu gạch dài `—` (em dash)

**Cấm dùng.** Đây là dấu hiệu số một của văn AI. Thay bằng dấu gạch ngang thường `-`,
hoặc tốt hơn: tách câu ra.

| Sai | Đúng |
|---|---|
| `Xem thì được — nhưng dùng lâu thì mệt.` | `Xem thì được, nhưng dùng lâu thì mệt.` |
| `Bản tôi đang dùng — 2.16.5` | `Bản An đang dùng là 2.16.5` |
| `kể cả những khúc tôi đi nhầm — vì phần đó mới hiếm` | `kể cả mấy khúc An đi nhầm, vì phần đó mới hiếm` |

Dấu gạch nối trong từ ghép thì vẫn bình thường: `dưới-phải`, `2.16.5`.

### 1.2 Mũi tên `→`

**Cấm dùng.** Gõ `->` thay vào đó. Tương tự: `⇒` viết `=>`, `←` viết `<-`.

```
Sai:  Bấm Cài đặt → Âm thanh → Tắt
Đúng: Bấm Cài đặt -> Âm thanh -> Tắt
```

### 1.3 Giọng phẳng, không có cảm xúc

Văn AI hay tả sự việc một cách trung tính. An thì có ý kiến, và nói ra.

| Nghe như AI | Giọng An |
|---|---|
| `Rồi Ezviz cập nhật, và mọi thứ vỡ.` | `Rồi Ezviz cập nhật, và mọi thứ đờ mờ nó, đập đi xây lại 😂` |
| `Giao diện khá xấu xí.` | `Giao diện nhìn thấy gớm 😂` |
| `Điều này gây khó chịu cho người dùng.` | `Cái này làm An quạu thiệt sự.` |

---

## 2. Xưng hô

An xưng **"An"** hoặc **"tôi"**. Cả hai đều được, trộn trong cùng một bài cũng không sao.

Gọi người đọc là **"bạn"**, hoặc không gọi gì cả.

**Không dùng:** "chúng ta", "chúng tôi", "mình" (nghe ra Bắc), "quý độc giả",
"các bạn độc giả".

```
Sai:  Trong bài viết này, chúng ta sẽ cùng tìm hiểu cách...
Đúng: Bài này An kể lại cách...
Đúng: Tôi mò ba tuần mới ra, kể lại đây cho ai cần.
```

---

## 3. Tiếng miền Nam

Ưu tiên từ miền Nam. Đây là mấy cặp hay lọt nhất trong bài kỹ thuật:

| Đừng dùng (Bắc) | Dùng (Nam) |
|---|---|
| hỏng | hư |
| vỡ | bể |
| vào | vô |
| thế, như thế | vậy, như vậy |
| buồn cười | mắc cười |
| đùa | giỡn |
| nhanh | lẹ |
| đắt | mắc |
| vứt | quăng, liệng |
| kính | kiếng |
| ô tô | xe hơi |
| bát, thìa, cốc | chén, muỗng, ly |
| ngã | té |
| gầy | ốm |
| ốm (bệnh) | bệnh |

Từ kỹ thuật (WS_THICKFRAME, PowerShell, pixel...) thì giữ nguyên tiếng Anh, đừng dịch.

### 3b. Chê thì phải chê cho vui

Đây là chỗ hay bị nhạt nhất. AI dịch "bad quality" thành "kém chất lượng" là bài
chết ngay tại đó. An chê bằng từ có hình ảnh, nghe là bật cười.

**Đừng dùng mấy từ nhạt này:** dở, kém, tệ, không tốt, chưa tối ưu, xấu xí,
kém chất lượng, chưa hoàn thiện, gây khó chịu.

**Dùng mấy từ này:**

| Ý muốn nói | Từ An hay dùng |
|---|---|
| Chất lượng thấp, rẻ tiền | **cùi bắp**, cùi, đồ bỏ, hàng chợ, dởm |
| Ngu, vô lý, thiết kế não phẳng | **khùi như đầu bờ**, não phẳng, óc chó, tưng tửng, khùng |
| Làm dở, nghiệp dư, chưa xong mà dám ra | **nhìn như mấy thằng beta**, làm cho có, lụi, chắp vá |
| Hỏng banh, nát bét | **banh chành**, tanh bành, banh xác, nát như tương |
| Vô dụng, trớt quớt | **trớt quớt**, lãng xẹt, tào lao, xàm xí, vô tri |
| Nhìn ghê | **thấy gớm**, nhìn dị, nhìn phát ghét, quê một cục |
| Bực | **quạu**, nổi khùng, điên tiết, tức cành hông |
| Hết cứu | **hết thuốc chữa**, hết cứu, thua luôn, bó tay chấm com |
| Chán | **chán như con gián**, chán ngắt |
| Người vụng, gà | **gà**, gà mờ, hậu đậu, cà chớn |

Ví dụ chuyển giọng:

| Nhạt | Đúng giọng An |
|---|---|
| `Giao diện khá xấu xí.` | `Giao diện nhìn thấy gớm 😂` |
| `Phần mềm này chất lượng kém.` | `Cái phần mềm cùi bắp này 😂` |
| `Thiết kế chưa hợp lý.` | `Thiết kế khùi như đầu bờ, ai nghĩ ra vậy trời` |
| `Bản này chưa hoàn thiện.` | `Bản này nhìn như mấy thằng beta, chưa xong mà dám thả ra` |
| `Sau khi cập nhật thì lỗi hết.` | `Cập nhật xong banh chành hết trơn 😂` |
| `Tính năng này không hữu ích.` | `Tính năng này trớt quớt, để cho có` |
| `Không phải tôi vụng.` | `Không phải An gà đâu nha.` |

> ⚠️ Trong bài Ezviz đã đăng có câu **"Không phải tôi vụng"** và **"giao diện xấu xí"**.
> Vừa sai giọng miền Nam, vừa nhạt. Viết lại: **"Không phải An gà đâu nha"**,
> **"nhìn thấy gớm 😂"**.

**Liều lượng:** mỗi bài chê đậm chừng **2-3 chỗ** thôi, đặt đúng lúc bực nhất.
Rải khắp bài thì thành ra người viết đang cố tỏ ra vui tính, đọc mệt.

---

## 4. Hài hước và chửi

Đây là phần làm nên chất riêng. Làm đúng thì bài sống, làm sai thì thành thô thiển.

### 4.1 Chửi cái gì

**Chửi ĐỒ VẬT và PHẦN MỀM khó chịu.** Không chửi người, không chửi người đọc,
không chửi một công ty theo kiểu công kích thật sự.

```
Được:   Cái Ezviz ngôn lù này cứ 5 phút lại chặn màn hình đòi bấm Continue 😂
Được:   Rồi nó cập nhật, và mọi thứ đờ mờ nó, đập đi xây lại
Không:  Thằng lập trình viên viết cái này chắc ngu
Không:  Ai xài cái này chắc cũng ngu như nó
```

### 4.2 Nói lái

An hay nói lái mấy từ tục cho nó vừa hả giận vừa không thô. Ví dụ chính An hay dùng:

- **"ngôn lù"** (nói lái của "ngu l...") - dùng khi phần mềm làm cái gì đó vô lý
- **"đờ mờ nó"** (đọc trại) - dùng khi mọi thứ hỏng hết

Cách dùng: **một, nhiều lắm là hai lần trong một bài**, đặt đúng lúc bực nhất.
Rải khắp bài thì mất duyên, thành ra người viết đang cố tỏ ra bụi.

Đặt ngay chỗ vừa kể xong một hành vi vô lý của phần mềm - đó là lúc người đọc
cũng đang bực, câu chửi mới thành ra đồng cảm chứ không thành ra văng bậy.

### 4.3 Emoji

Dùng 😂 sau câu tếu hoặc câu chửi, để người đọc biết là đang giỡn chứ không phải
đang cáu thật. Khoảng **2-4 emoji trong một bài**, đừng nhiều hơn.

Emoji An hay dùng: 😂 🤣 😅 🙃

---

## 5. Cách kể chuyện

- **Kể theo thời gian thật**, kể cả khúc đi nhầm. Phần "An làm sai chỗ này, mất
  hai ngày" mới là phần người ta không tìm được ở đâu khác.
- **Số liệu cụ thể**: giá tiền, số ngày, số dòng code, bao nhiêu lần thử.
  "Ba tuần lễ mò mẫm" tốt hơn "sau một thời gian nghiên cứu".
- **Câu ngắn.** Câu dài quá hai dòng thì tách ra.
- **Mở bài đi thẳng vào chuyện**, đừng dạo đầu kiểu "Trong thời đại công nghệ 4.0...".

### Ví dụ viết lại từ chính bài Ezviz

| Bản đã đăng | Viết lại đúng giọng |
|---|---|
| `Xem thì được, nhưng dùng lâu thì ba thứ làm tôi phát cáu` | `Xem thì được, mà xài lâu có ba thứ làm An quạu` |
| `Cứ vài phút nó chặn màn hình lại, hỏi có muốn xem tiếp không.` | `Cứ vài phút cái Ezviz ngôn lù này lại chặn màn hình, hỏi có xem tiếp hông 😂` |
| `Không phải tôi vụng — nó thật sự chỉ có ba pixel.` | `Không phải An dở đâu nha, nó đúng là chỉ có ba pixel thiệt.` |
| `Ba tuần lễ mò mẫm sau, tôi có một cái tool...` | `Mò ba tuần, An ra được cái tool...` |
| `Ai định viết móc chuột thì nhớ giùm điều này` | `Ai tính viết móc chuột thì nhớ giùm cái này nha` |
| `Kết quả: chuột cả máy giật cục mỗi khi tôi ấn ALT.` | `Kết quả: nguyên con chuột cả máy giật đùng đùng mỗi lần bấm ALT 😅` |

Để ý: câu ngắn lại, thêm "nha/hông/thiệt", bỏ hết em dash, và có chỗ để cảm xúc thở.

---

## 6. Tiêu đề

Tiêu đề cũng phải theo giọng này, dưới 65 ký tự, **không có em dash**.

```
Sai:  Thuần hoá Ezviz Studio — kéo cửa sổ, tắt tiếng, tự bấm Continue
Đúng: Thuần hoá Ezviz Studio: kéo cửa sổ, tắt tiếng, tự bấm Continue
Đúng: Trị cái Ezviz Studio cứng đầu
```

Dùng dấu hai chấm `:` hoặc dấu phẩy thay cho em dash.

---

## 7. Bảng tự kiểm trước khi giao bài

- [ ] Không còn ký tự `—` nào trong bài, kể cả tiêu đề và tóm tắt
- [ ] Không còn `→` `←` `⇒` - đã đổi thành `->` `<-` `=>`
- [ ] Xưng "An" hoặc "tôi", không có "chúng ta" / "mình"
- [ ] Không còn từ miền Bắc trong bảng ở §3
- [ ] Có ít nhất một chỗ tếu hoặc một câu có cảm xúc thật
- [ ] Có 2-4 emoji, không rải khắp nơi
- [ ] Nếu có chửi: chửi phần mềm, không chửi người, tối đa hai lần
- [ ] Có số liệu cụ thể chứ không nói chung chung
- [ ] Đọc to lên một lượt - nghe có giống người Sài Gòn đang kể chuyện không?

Câu cuối là quan trọng nhất. Nếu đọc lên mà nghe như đang thuyết trình, viết lại.


---

# PHẦN A: Viết bài bằng JSON (blog-json-v1)

# blog-json-v1

> **Bạn là AI agent được giao viết bài cho trinhleminhan.com?** File này là hợp đồng
> đầy đủ. Làm đúng theo đây thì bài lên đúng định dạng ngay lần đầu.

**Đọc trước mọi thứ khác:** kết quả LUÔN là **bản nháp**. Không có tham số nào để
đăng thẳng. Đừng đi tìm `publish`, `auto_publish`, `status` - không tồn tại. Xong
việc thì báo với người dùng là *"đã tạo bản nháp, mời anh/chị xem lại"*, **không**
được nói *"đã đăng bài"*.

---

## 1. Khung tài liệu

```json
{
  "format": "blog-json-v1",
  "meta": { },
  "glossary": [ ],
  "blocks": [ ]
}
```

| Khoá | Bắt buộc | |
|---|---|---|
| `format` | nên có | Phải đúng `"blog-json-v1"`. Thiếu thì vẫn chạy nếu có `blocks`. |
| `meta` | **có** | Tiêu đề, tóm tắt, bìa, thẻ, SEO… |
| `glossary` | không | Danh sách từ cần giải nghĩa. |
| `blocks` | **có** | Thân bài, mảng các khối theo thứ tự. |

### meta

```json
{
  "title": "Tự làm giá sách từ gỗ pallet",
  "excerpt": "Ba buổi chiều và hai trăm nghìn tiền đinh vít, được cái giá sách vừa ý.",
  "cover": "./anh/bia.jpg",
  "slug": "tu-lam-gia-sach-go-pallet",
  "section": "goc-cua-an",
  "category": "diy",
  "book": "dac-nhan-tam",
  "tags": ["diy", "gỗ", "nội thất"],
  "related": ["ke-sach-treo-tuong", "chon-son-go"],
  "seo": {
    "title": "Tự làm giá sách gỗ pallet - hướng dẫn từng bước",
    "description": "Chi phí thật, lỗi thật, và cách tránh.",
    "og_image": "./anh/og.jpg",
    "canonical": "",
    "noindex": false
  }
}
```

| Khoá | Ghi chú |
|---|---|
| `title` | **Bắt buộc.** Thiếu là bị từ chối (422). Dưới 65 ký tự để Google không cắt. |
| `excerpt` | 1-2 câu, dưới 160 ký tự. Hiện ở trang chủ và trên Google. |
| `cover` | Đường dẫn local (`./anh/bia.jpg`), URL nội bộ (`/api/upload/…`), hoặc https. |
| `slug` | Bỏ trống thì sinh từ tiêu đề. **Ghi slug của một nháp đã có = cập nhật nháp đó.** |
| `section` | **Chỉ MỘT.** Slug của mục hiển thị. Có sẵn: `goc-cua-an`, `khai-tam`, `thu-vien`, `sach-hay`, `dang-doc`, `sach-moi`, `featured`, `ngau-nhien`. |
| `category` | **Chỉ MỘT.** Danh mục blog: `cong-cu` (công cụ & phần mềm), `goc-an`. Tra thêm bằng API ở §5, đừng đoán. |
| `book` | Sách gắn thẻ affiliate cho bài. |
| `tags` | **Nhiều được, tối đa 15.** Đây là cách duy nhất để một bài thuộc nhiều chủ đề - `section` và `category` mỗi thứ chỉ nhận một giá trị. Thẻ chưa có sẽ tự được tạo. |
| `related` | Slug bài đã đăng. Slug không tồn tại thì bị bỏ lặng lẽ. |
| `seo.*` | Bỏ trống thì lấy từ `title` / `excerpt` / `cover`. |

### glossary

Dùng cho từ chuyên môn, từ nước ngoài, từ nhiều nghĩa tuỳ ngữ cảnh.

```json
[
  {
    "term": "present bias",
    "definition": "Khuynh hướng ưu tiên cái sướng ngay bây giờ hơn hậu quả ở tương lai.",
    "ipa": "/ˈprez.ənt ˈbaɪ.əs/",
    "reading": "pre-zần bai-ợt",
    "speak": "en-US",
    "examples": [
      {
        "en": "Present bias is why people keep postponing their savings plan.",
        "vi": "Present bias là lý do người ta cứ dời hoài cái kế hoạch tiết kiệm."
      }
    ],
    "lang": { "en": "present bias", "ja": "現在バイアス" },
    "note": "Hay bị nhầm với lười. Không phải, nó là cách não tính toán."
  }
]
```

| Khoá | |
|---|---|
| `term` | **Bắt buộc.** Đúng chữ như trong bài. |
| `definition` | **Bắt buộc.** Giải nghĩa theo ĐÚNG nghĩa dùng trong bài này. |
| `ipa` | Phiên âm quốc tế, cho từ tiếng Anh và tiếng nước ngoài. Ghi cả hai dấu gạch chéo. |
| `reading` | Cách đọc ghi kiểu Việt cho người không biết đọc IPA. **Khác `ipa`, đừng gộp.** |
| `speak` | Mã ngôn ngữ để trình duyệt đọc thành tiếng: `en-US`, `en-GB`, `ja-JP`, `vi-VN`… Bỏ trống thì tự đoán. |
| `examples` | Tối đa **3** cặp câu. `en` là câu tiếng gốc **có ngữ cảnh thật**, `vi` là câu tiếng Việt tương ứng. Đừng viết câu mẫu khô khan kiểu sách giáo khoa. |
| `lang` | Từ tương đương ở ngôn ngữ khác. Tối đa 8. |
| `note` | Một câu lưu ý thêm. |

**Từ tiếng Anh thì nên có đủ `ipa` + `reading` + `examples`** - đó là chỗ người đọc
cần nhất. Từ tiếng Việt thì `definition` là đủ.

Cuối bài tự sinh khối "Giải nghĩa từ ngữ trong bài". Ở đó người đọc bấm vô chữ là
**chép** được từ, bấm 🔊 là **nghe đọc**, và có dãy số **1 2 3** dẫn thẳng tới từng
chỗ từ đó xuất hiện trong bài (quá 5 chỗ thì gom sau nút `+`). Agent không phải làm
gì thêm, chỉ cần khai đủ dữ liệu.

Nghĩa lưu **theo từng bài**, không dùng chung toàn site - cùng một từ có thể mang
nghĩa khác nhau tuỳ ngữ cảnh, và đó chính là lý do có trường này.

Khai báo ở đây rồi **phải dùng trong bài** bằng `{"text":"pallet","term":"pallet"}`.
Khai mà không dùng thì bị bỏ kèm cảnh báo. Dùng mà chưa khai thì mất đánh dấu.

---

## 2. Các loại khối

### paragraph

```json
{ "type": "paragraph", "align": "left", "content": [ ... ] }
```

`align`: `left` (mặc định) · `center` · `right` · `justify`.
`content` nhận mảng inline (xem §3) hoặc một chuỗi thường.

### heading

```json
{ "type": "heading", "level": 2, "content": "Chuẩn bị" }
```

`level` chỉ nhận **2, 3, 4**. Level 1 tự hạ xuống 2 vì tiêu đề bài đã là H1 của trang.

### list

```json
{ "type": "list", "ordered": false, "items": [
  "Chà nhám",
  { "content": [{ "text": "Sơn lót", "marks": ["bold"] }] },
  { "content": "Lắp khung", "blocks": [ { "type": "paragraph", "content": "Ghi chú thêm" } ] }
] }
```

### quote

```json
{ "type": "quote", "content": "Mua sẵn hai lưỡi cưa.", "cite": "Bài học sau khi gãy một cái" }
```

### code

```json
{ "type": "code", "lang": "bash", "code": "npm install" }
```

### divider

```json
{ "type": "divider" }
```

### image

```json
{
  "type": "image",
  "src": "./anh/cat-go.jpg",
  "alt": "Cắt gỗ theo dấu bút chì",
  "caption": "Cắt tới đâu kẻ tới đó, đừng kẻ hết một lượt rồi cắt",
  "credit": "Ảnh của An",
  "creditUrl": "",
  "size": "lg",
  "align": "center",
  "href": "https://shopee.vn/...",
  "blur": { "reason": "nsfw", "note": "Ảnh được làm mờ vì nội dung nhạy cảm - bấm để xem" }
}
```

| Khoá | |
|---|---|
| `src` | **Ba dạng đều được**, xem bảng dưới. |
| `alt` | Mô tả cho người dùng trình đọc màn hình. |
| `caption` | Lời chú thích hiện dưới ảnh. **Đây KHÔNG phải chỗ ghi nguồn.** |
| `credit` | **Nguồn ảnh.** Tên tác giả, tên site, hoặc "Ảnh của An". |
| `creditUrl` | Link tới nguồn. Tự thêm `nofollow`. |
| `size` | `sm` 320px · `md` 480px · `lg` 720px (mặc định) · `full` |
| `align` | `center` · `right` · `justify` |
| `href` | Bọc ảnh trong link. Link ngoài tự thêm `nofollow`. |
| `blur` | `true`, hoặc `"nsfw"` / `"spoiler"` / `"gore"`, hoặc object có `reason` + `note`. Ảnh hiện ra mờ, bấm mới rõ. |

#### `src` nhận ba dạng

| Dạng | Ví dụ | Khi nào dùng |
|---|---|---|
| File trong máy | `./anh/cat-go.jpg` | Gửi kèm file trong cùng request (`multipart/form-data`, tên trường **trùng đúng** đường dẫn này). Máy chủ tự đẩy lên kho và thay đường dẫn. |
| Ảnh đã có trên site | `/api/upload/up-abc123.png` | Ảnh đã tải lên trước đó. Tra ở thư viện ảnh trong `/admin`. |
| Link ngoài | `https://…/anh.jpg` | Ảnh trên mạng. **Bắt buộc ghi `credit`**, thiếu là bị cảnh báo. |

Không nhận đường dẫn tương đối kiểu `anh/x.jpg` hay `../x.jpg`, không nhận `data:`.

**Luôn viết `caption` hoặc `alt`.** Thiếu cả hai thì bị cảnh báo - ảnh không mô tả
là ảnh vô nghĩa với người dùng trình đọc màn hình.

**Ảnh lấy trên mạng thì phải ghi nguồn.** Đừng nhét nguồn vô `caption` - có trường
`credit` riêng, và nó hiện ra với kiểu chữ riêng nhỏ hơn.

### embed

```json
{ "type": "embed", "url": "https://youtu.be/abc123" }
```

Chỉ nhận YouTube, TikTok, Facebook.

### ref - thẻ trỏ tới nội dung đã có trên site

```json
{ "type": "ref", "kind": "book", "id": "dac-nhan-tam" }
```

`kind`: `book` · `product` · `author` · `translator` · `post` · `mod`.
`id` là **slug hoặc id**. Tra trước bằng API ở §5 - **đừng đoán slug**.

### collapse - thu gọn, bấm mới mở

```json
{ "type": "collapse", "summary": "Bảng chi phí chi tiết", "open": false,
  "blocks": [ { "type": "paragraph", "content": "…" } ] }
```

### callout - hộp nhấn mạnh

```json
{ "type": "callout", "variant": "warn", "title": "Cẩn thận",
  "blocks": [ { "type": "paragraph", "content": "Gỗ pallet hay có đinh gãy nằm sâu." } ] }
```

`variant`: `info` (mặc định) · `tip` · `note` · `warn` · `danger`.

### markdown - lối thoát

```json
{ "type": "markdown", "md": "## Tiêu đề\n\n- một\n- hai" }
```

Khi một đoạn viết bằng Markdown gọn hơn nhiều so với JSON. Cú pháp giống hệt
[`writing-api.md`](./writing-api.md).

---

## 3. Nội dung inline

`content` là mảng các node:

```json
[
  { "text": "chữ thường" },
  { "text": "đậm", "marks": ["bold"] },
  { "text": "nghiêng", "marks": ["italic"] },
  { "text": "VIẾT HOA", "marks": ["upper"] },
  { "text": "tô sáng", "marks": ["highlight"] },
  { "text": "tiết lộ tình tiết", "blur": { "note": "Bấm để xem" } },
  { "text": "pallet", "term": "pallet" },
  { "text": "trang chủ", "link": "/" },
  { "ref": { "kind": "book", "id": "dac-nhan-tam" } },
  { "br": true }
]
```

**marks** dùng được: `bold` `italic` `strike` `underline` `code` `highlight`
`sup` `sub` `upper` `lower` `smallcaps` `blur`.

Kết hợp được nhiều mark: `"marks": ["bold", "italic"]`.

---

## 4. Gửi lên

```
POST https://trinhleminhan.com/api/ingest/post
Authorization: Bearer an_xxxxxxxxxxxxxxxx
```

Thêm `?dry_run=1` để **chỉ kiểm tra, không lưu gì**. Trả về HTML đã dựng + toàn bộ
cảnh báo. **Luôn chạy dry_run trước.**

**Có ảnh local** -> `multipart/form-data`: trường `json` chứa tài liệu, mỗi ảnh là
một file có tên trường trùng đúng đường dẫn viết trong JSON (`./anh/cat-go.jpg`).

**Không có ảnh local** -> gửi thẳng JSON:

```bash
curl -X POST 'https://trinhleminhan.com/api/ingest/post?dry_run=1' \
  -H "Authorization: Bearer $AN_POST_TOKEN" \
  -H 'content-type: application/json' \
  --data @bai-viet.json
```

**Người dùng tự tay** -> vào `/blog/new.html`, kéo file `.json` vào hộp
*"Nhập bài từ file JSON"*, xem trước rồi bấm *Đổ vào bài*.

---

## 5. Tra cứu trước khi viết - đừng đoán slug

```
GET /api/ingest/lookup?type=book&q=đắc nhân tâm
Authorization: Bearer an_xxxx
```

`type`: `book` · `product` · `author` · `translator` · `post` · `section` ·
`category` · `tag` · `types` (liệt kê các loại).

Tìm không dấu cũng ra: `q=dac nhan tam` khớp *Đắc Nhân Tâm*.

Trả về:

```json
{ "type": "book", "count": 1, "results": [
  { "id": "bk-1", "slug": "dac-nhan-tam", "name": "Đắc Nhân Tâm",
    "url": "/sach/dac-nhan-tam",
    "directive": "::book[dac-nhan-tam]",
    "json_ref": { "type": "ref", "kind": "book", "id": "dac-nhan-tam" } }
] }
```

`json_ref` chép thẳng vào `blocks` là dùng được.

> ⚠️ Tra ra rỗng nghĩa là **thứ đó chưa có trên site**. Đừng bịa slug rồi hy vọng.
> Không có thì bỏ thẻ đi, viết bằng chữ thường.

---

## 6. Kết quả trả về

```json
{
  "ok": true, "created": true,
  "slug": "tu-lam-gia-sach-go-pallet", "status": "draft",
  "format": "blog-json-v1",
  "tags": ["diy", "gỗ"],
  "glossary": ["pallet"],
  "uploaded_images": 3,
  "edit_url": "/blog/new.html?slug=tu-lam-gia-sach-go-pallet",
  "lint": { "ok": true, "errors": [], "warnings": [],
            "stats": { "words": 412, "images": 3, "headings": 4 } }
}
```

| Mã | Nghĩa | Làm gì |
|---|---|---|
| 401 | Token sai / thu hồi | Dừng. Không thử lại. |
| 403 | Thiếu quyền `post:draft` | Dừng, báo người dùng cấp quyền trong /admin. |
| 409 | Slug trỏ vào bài **đã đăng** | Đổi `meta.slug` sang cái khác. |
| 413 | JSON > 400KB hoặc ảnh > 4MB | Tách bài, resize ảnh. |
| 422 | Thiếu tiêu đề / không có nội dung | Sửa theo `lint.errors`. |
| 429 | Quá 30 bài/giờ | Đợi theo `Retry-After`. |

`warnings` không chặn lưu, nhưng **phải đọc và sửa** trước khi báo là xong.

---

## 7. Prompt dán thẳng cho agent

Copy nguyên khối này khi giao việc cho một AI agent khác:

```text
Viết cho tôi một bài blog và xuất ra file JSON theo chuẩn blog-json-v1 của
trinhleminhan.com. Đọc đặc tả đầy đủ ở https://trinhleminhan.com/SPEC/blog-json.md
trước khi viết.

BỐI CẢNH BLOG
- Blog cá nhân của Trịnh Lê Minh An, người MIỀN NAM. Chủ đề: DIY, công cụ &
  phần mềm, công nghệ, xe cộ, du lịch, thương mại điện tử, văn hoá, ngôn ngữ,
  tâm lý, sách.
- Đọc kỹ https://trinhleminhan.com/SPEC/voice.md TRƯỚC KHI VIẾT. Đó là đặc tả
  giọng văn, không phải gợi ý.
- Đọc 2-3 bài mới nhất ở https://trinhleminhan.com/blog/ để bắt giọng.

GIỌNG VĂN - BỐN THỨ BỊ BẮT LỖI TỰ ĐỘNG
1. CẤM dấu gạch dài "—". Dấu hiệu số một của văn AI. Dùng "-" hoặc tách câu ra.
   Kể cả trong tiêu đề và tóm tắt.
2. CẤM mũi tên "→". Gõ "->". Tương tự "⇒" viết "=>".
3. TIẾNG MIỀN NAM: hỏng->hư, vỡ->bể, vào->vô, thế->vậy, đắt->mắc, nhanh->lẹ,
   vứt->quăng, buồn cười->mắc cười, ô tô->xe hơi, ngã->té.
4. CHÊ PHẢI CHÊ CHO VUI. Đây là chỗ bài chết nhiều nhất. CẤM mấy từ nhạt:
   dở, kém, tệ, kém chất lượng, xấu xí, chưa hoàn thiện, chưa tối ưu, chưa hợp lý,
   gây khó chịu, không hữu ích, tồi tệ.
   Dùng từ có hình ảnh, nghe là bật cười:
   - rẻ tiền, chất lượng thấp -> "cùi bắp", "đồ bỏ", "hàng chợ"
   - thiết kế ngu, vô lý -> "khùi như đầu bờ", "não phẳng", "tưng tửng"
   - làm cho có, nghiệp dư -> "nhìn như mấy thằng beta", "lụi", "chắp vá"
   - hỏng nát -> "banh chành", "tanh bành", "nát như tương"
   - vô dụng -> "trớt quớt", "lãng xẹt", "để cho có"
   - nhìn ghê -> "thấy gớm", "nhìn dị", "nhìn phát ghét"
   - bực -> "quạu", "nổi khùng", "tức cành hông"
   - hết cứu -> "hết thuốc chữa", "bó tay chấm com"
   - người vụng -> "gà", "gà mờ", "hậu đậu"
   Chê đậm 2-3 chỗ một bài thôi, đặt đúng lúc bực nhất.
5. PHẢI CÓ CẢM XÚC. An có ý kiến và nói ra. Có đùa, có emoji 😂 (2-4 cái một bài).
   Giọng trung tính phẳng lì là hỏng.

XƯNG HÔ
- An xưng "An" hoặc "tôi". Gọi người đọc là "bạn", hoặc không gọi gì.
- KHÔNG dùng: chúng ta, chúng tôi, mình, quý độc giả.

HÀI HƯỚC VÀ CHỬI
- An chửi ĐỒ VẬT và PHẦN MỀM khó chịu, KHÔNG chửi người, không chửi người đọc.
- An hay nói lái từ tục cho vừa hả giận vừa không thô, ví dụ "ezviz ngôn lù này",
  "đờ mờ nó". Dùng tối đa 1-2 lần một bài, đặt đúng lúc bực nhất, ngay sau khi
  vừa kể xong một hành vi vô lý của phần mềm.
- Ví dụ chuyển giọng:
  Sai:  "Rồi Ezviz cập nhật, và mọi thứ vỡ."
  Đúng: "Rồi Ezviz cập nhật, và mọi thứ đờ mờ nó, đập đi xây lại 😂"
  Sai:  "Giao diện khá xấu xí."
  Đúng: "Giao diện nhìn thấy gớm 😂"
  Sai:  "Phần mềm này chất lượng kém."
  Đúng: "Cái phần mềm cùi bắp này 😂"
  Sai:  "Thiết kế chưa hợp lý."
  Đúng: "Thiết kế khùi như đầu bờ, ai nghĩ ra vậy trời"
  Sai:  "Bản này chưa hoàn thiện."
  Đúng: "Bản này nhìn như mấy thằng beta, chưa xong mà dám thả ra"
  Sai:  "Sau khi cập nhật thì lỗi hết."
  Đúng: "Cập nhật xong banh chành hết trơn 😂"
  Sai:  "Tính năng này không hữu ích."
  Đúng: "Tính năng này trớt quớt, để cho có"
  Sai:  "Không phải tôi vụng."
  Đúng: "Không phải An gà đâu nha."

CÁCH KỂ
- Kể theo thời gian thật, kể cả khúc đi nhầm. Phần "An làm sai chỗ này, mất hai
  ngày" mới là phần không tìm được ở đâu khác.
- Số liệu cụ thể: giá tiền, số ngày, số dòng code. "Mò ba tuần" tốt hơn "sau một
  thời gian nghiên cứu".
- Câu ngắn. Vào thẳng chuyện, không dạo đầu.

LUẬT KHÔNG ĐƯỢC PHÁ
1. Không bịa. Tên, giá, ngày, số liệu, thông số - chỉ viết cái có trong nguồn
   tôi đưa. Không chắc thì viết mơ hồ hoặc bỏ, đừng đoán cho tròn câu.
2. Không bịa slug. Muốn chèn thẻ sách/sản phẩm/bài viết thì gọi
   GET /api/ingest/lookup?type=book&q=... để tra. Tra không ra thì bỏ thẻ,
   viết bằng chữ thường.
3. Mọi ảnh phải có "caption" hoặc "alt" mô tả đúng nội dung ảnh.
4. Từ chuyên môn / từ nước ngoài / từ dễ hiểu nhầm -> đưa vào "glossary" và
   đánh dấu trong bài bằng {"text":"...","term":"..."}.
5. Trước khi đưa code hay ảnh chụp màn hình vào bài: quét kỹ API key, token,
   mật khẩu, đường dẫn nội bộ, tên khách hàng. Ảnh chụp terminal là nơi rò rỉ
   nhiều nhất.
6. Kết quả chỉ là BẢN NHÁP. Đừng nói với tôi là "đã đăng bài".

CẤU TRÚC MONG MUỐN
- meta đủ: title (<65 ký tự), excerpt (1-2 câu, <160 ký tự), cover, tags (3-6 thẻ).
- Bài dài hơn 400 chữ thì phải có ít nhất 2 mục "heading" level 2.
- Dùng "callout" cho cảnh báo / mẹo, "collapse" cho bảng số liệu dài,
  "quote" cho bài học rút ra.
- Ảnh nhạy cảm thì đặt "blur": "nsfw" kèm note tiếng Việt.

TRƯỚC KHI GIAO CHO TÔI
Gọi POST /api/ingest/post?dry_run=1 với file JSON, đọc lint.warnings, sửa hết
những cảnh báo đáng sửa rồi mới gửi lại cho tôi. Báo cáo kèm link edit_url.
```

---

## 8. Ví dụ đầy đủ

Xem file chạy được: [`blog-json-example.json`](./blog-json-example.json) - dùng hết
mọi loại khối, copy về sửa là ra bài mới.

---

## 9. Danh sách tự kiểm

- [ ] `dry_run` trả `lint.errors` rỗng
- [ ] Có `meta.title`, `meta.excerpt`, `meta.cover`
- [ ] Mọi ảnh có `caption` hoặc `alt`
- [ ] Bài > 400 chữ và có ít nhất một `heading`
- [ ] Mọi `ref` đều tra được qua `/api/ingest/lookup` (không có cảnh báo "Không tìm thấy")
- [ ] Mọi mục `glossary` đều được dùng trong bài
- [ ] Không có token, key, đường dẫn nội bộ nào lọt vào bài hay ảnh
- [ ] Đã nói rõ với người dùng rằng bài đang là **nháp**

---

## 10. File liên quan

| File | Vai trò |
|---|---|
| `functions/_lib/post-json.js` | JSON -> khối HTML (**tầng chống XSS thứ nhất**) |
| `functions/_lib/ingest-post.js` | Điều phối: ảnh -> R2, tra ref, lưu nháp, thẻ, giải nghĩa |
| `functions/api/ingest/post.js` | Tầng HTTP |
| `functions/api/ingest/lookup.js` | Tra cứu nội dung đã có |
| `js/editor-blocks.js` | Extension TipTap - giữ khối khi mở bài ra sửa |
| `css/post-blocks.css` | Hiển thị khối, dùng chung blog + trình soạn thảo |
| `js/post-blocks.js` | Bấm để hiện ảnh mờ, popup giải nghĩa |

> **Thêm loại khối mới phải sửa đủ 4 nơi:** `post-json.js` -> `editor-blocks.js` ->
> `post-blocks.css` -> `purifyCfg` trong `blog/post.html`. Thiếu một nơi là khối
> đó biến mất lặng lẽ ở nơi tương ứng.


---

# PHẦN B: Viết bài bằng Markdown

# API viết bài bằng Markdown

> **Đọc file này nếu bạn là một AI agent được giao việc "viết bài blog cho
> trinhleminhan.com".** Đây là hợp đồng đầy đủ: viết đúng theo đây thì bài lên
> đúng định dạng ngay lần đầu, không cần ai sửa tay.

**Điều quan trọng nhất, đọc trước mọi thứ khác:** API này **chỉ tạo bản nháp**.
Không có tham số nào, không có cách nào, để đăng bài thẳng lên blog. Luôn có người
đọc lại và bấm Xuất bản. Đừng đi tìm cờ `auto_publish` - nó không tồn tại và sẽ
không được thêm vào.

---

## 1. Gửi bài như thế nào

```
POST https://trinhleminhan.com/api/ingest/post
Authorization: Bearer an_xxxxxxxxxxxxxxxx
```

Thêm `?dry_run=1` để **chỉ kiểm tra, không lưu gì**. Luôn chạy `dry_run` trước khi
gửi thật lần đầu - nó trả về HTML đã dựng và toàn bộ cảnh báo, không tốn gì cả.

### Cách 1 - multipart/form-data (dùng khi có ảnh trong máy)

| Trường | Nội dung |
|---|---|
| `markdown` | toàn bộ file `.md`, kèm frontmatter |
| *tên bất kỳ khác* | file ảnh. **Tên trường phải trùng đúng đường dẫn ảnh viết trong markdown** |

Ví dụ: bài có `![Cắt gỗ](./anh/cat-go.jpg)` thì gửi file với tên trường là
`./anh/cat-go.jpg`. Máy chủ nhận ảnh, đẩy lên R2, và tự thay đường dẫn trong bài.

### Cách 2 - application/json (khi ảnh đã có sẵn trên mạng)

```json
{ "markdown": "---\ntitle: ...\n---\n\nNội dung..." }
```

### Cách 3 - CLI có sẵn, không phải viết code

```bash
export AN_POST_TOKEN=an_xxxxxxxx
node scripts/an-post.mjs bai-viet.md --check   # xem trước
node scripts/an-post.mjs bai-viet.md --open    # gửi rồi mở trang sửa
```

CLI tự tìm mọi ảnh local mà bài tham chiếu và đính kèm giúp.

---

## 2. Frontmatter

Khối `---` ở đầu file. Chỉ nhận 3 dạng: `khoá: giá trị`, `khoá: [a, b]`, hoặc
`khoá:` rồi các dòng `- x` bên dưới. **Không phải YAML đầy đủ** - không có anchor,
không có kiểu lồng nhau. Cố dùng cú pháp YAML phức tạp sẽ bị bỏ qua kèm cảnh báo.

| Khoá | Bắt buộc | Ý nghĩa |
|---|---|---|
| `title` | **có** | Tiêu đề bài. Thiếu là bị từ chối. |
| `excerpt` | nên có | 1-2 câu hiện dưới tiêu đề ở trang chủ và trên Google. Thiếu thì hệ thống tự cắt câu đầu, thường không hay. |
| `cover` | nên có | Ảnh bìa. Có thể là đường dẫn local (`./anh/bia.jpg`), URL nội bộ (`/api/upload/...`), hoặc URL https. |
| `slug` | không | Đường dẫn bài. Bỏ trống thì sinh từ tiêu đề. **Ghi slug của một bài nháp đã có = cập nhật bài đó.** |
| `section` | không | Slug mục trang chủ, vd `goc-cua-an`. |
| `category` | không | Slug danh mục blog, vd `diy`. |
| `book` | không | Slug/id sách gắn thẻ affiliate cho bài. |

Ví dụ đầy đủ:

```markdown
---
title: Tự làm giá sách từ gỗ pallet
excerpt: Ba buổi chiều và hai trăm nghìn tiền đinh vít, được cái giá sách vừa ý.
cover: ./anh/bia.jpg
section: goc-cua-an
category: diy
book: dac-nhan-tam
---
```

---

## 3. Cú pháp thân bài

Markdown thường, **cộng thêm ba thứ riêng của site này**.

### Markdown thường

| Viết | Ra |
|---|---|
| `## Mục lớn` / `### Mục nhỏ` | `<h2>` / `<h3>` |
| `**đậm**` `*nghiêng*` `~~gạch~~` | `<strong>` `<em>` `<s>` |
| `` `mã` `` và ```` ```js ```` | `<code>` và `<pre><code>` |
| `- mục` / `1. mục` | danh sách, lồng nhau bằng 2 dấu cách |
| `> câu` | trích dẫn |
| `---` | đường kẻ ngang |
| `[chữ](https://...)` | link (link ngoài tự thêm `nofollow`) |

`# Một dấu thăng` sẽ **tự hạ xuống H2**, vì tiêu đề bài đã là H1 của trang rồi.
Đừng cố dùng H1 trong thân bài.

### Ảnh - luôn viết chú thích

```markdown
![Chữ alt cho trình đọc màn hình](./anh/cat-go.jpg "Chú thích hiện dưới ảnh - Ảnh của mình")
```

Chú thích (chuỗi trong ngoặc kép) là thứ hiện ra dưới ảnh và là chỗ ghi nguồn.
Không có chú thích thì alt được dùng thay. **Không có cả hai thì bị cảnh báo** -
ảnh không mô tả là ảnh vô nghĩa với người dùng trình đọc màn hình.

Đổi cỡ ảnh bằng hậu tố: `{sm}` `{md}` `{lg}` (mặc định) `{full}`.

```markdown
![Toàn bộ giá sách](./anh/xong.jpg "Thành phẩm"){full}
```

### Thẻ tham chiếu - trỏ tới thứ đã có trên site

```markdown
::book[dac-nhan-tam]         -> thẻ sách, link /sach/dac-nhan-tam
::product[khoan-bosch]       -> thẻ sản phẩm
::author[dale-carnegie]      -> thẻ tác giả
::translator[nguyen-van-a]   -> thẻ dịch giả
::post[bai-viet-cu]          -> link tới bài blog khác đã đăng
::mod[ten-cong-tac-vien]     -> link hồ sơ cộng tác viên
```

Trong ngoặc là **slug hoặc id**. Nếu không tìm thấy trong cơ sở dữ liệu, thẻ bị bỏ,
chỉ còn lại chữ, và bạn nhận một cảnh báo nói rõ cái nào không tìm thấy.

> ⚠️ **Agent chú ý:** đừng đoán slug. Nếu không chắc sách/tác giả đó có tồn tại
> trên site không, chạy `dry_run` và đọc cảnh báo. Bịa ra một slug rồi để nó âm
> thầm biến thành chữ thường là cách tạo ra bài trông có vẻ đúng mà thật ra hỏng.

### Nhúng video

```markdown
::embed[https://youtu.be/abc123]
```

Chỉ nhận YouTube, TikTok, Facebook. Nền tảng khác bị bỏ kèm cảnh báo.

---

## 4. Kết quả trả về

```json
{
  "ok": true,
  "created": true,
  "id": "p-a1b2c3",
  "slug": "tu-lam-gia-sach-tu-go-pallet",
  "status": "draft",
  "title": "Tự làm giá sách từ gỗ pallet",
  "cover_url": "/api/upload/up-xxx.png",
  "uploaded_images": 2,
  "edit_url": "/blog/new.html?slug=tu-lam-gia-sach-tu-go-pallet",
  "lint": {
    "ok": true,
    "errors": [],
    "warnings": [{ "code": "cover_missing", "message": "Chưa có ảnh bìa..." }],
    "stats": { "chars": 1840, "words": 412, "images": 2, "headings": 3 }
  }
}
```

`errors` chặn việc lưu. `warnings` không chặn - bài vẫn thành nháp, nhưng **agent
nên đọc và sửa rồi gửi lại** trước khi báo với người dùng là đã xong.

### Mã lỗi HTTP

| Mã | Nghĩa | Xử lý |
|---|---|---|
| 401 | Token sai / đã thu hồi | Dừng. Không thử lại. |
| 403 | Token thiếu quyền `post:draft` | Dừng, báo người dùng vào /admin cấp quyền. |
| 409 | Slug đang trỏ vào bài **đã đăng** hoặc **chờ duyệt** | Đổi `slug` trong frontmatter thành cái khác. |
| 413 | Bài > 200KB hoặc ảnh > 4MB | Resize ảnh, tách bài. |
| 422 | Không lưu được (thiếu tiêu đề / không có nội dung) | Sửa theo `lint.errors` rồi gửi lại. |
| 429 | Quá 30 bài/giờ trên một token | Đợi theo header `Retry-After`. |

### Các mã cảnh báo

`excerpt_missing` `excerpt_short` `excerpt_seo` `cover_missing`
`title_short` `title_seo` `image_no_caption` `body_short` `no_headings`
`render` (ảnh/link/thẻ bị bỏ) `frontmatter` (dòng frontmatter sai cú pháp)

---

## 5. Mô hình bảo mật

Năm lớp, mỗi lớp giả định lớp trước đã thủng.

| Lớp | Chặn cái gì |
|---|---|
| **Bearer token băm SHA-256** | DB không giữ token gốc. Lộ DB không lấy được token. Token thu hồi/hết hạn được. |
| **Phạm vi quyền `post:draft`** | Token chụp ảnh (`capture:write`) không tạo được bài. Quyền cấp riêng từng thiết bị. |
| **Vai trò** | Token của tài khoản không phải admin/mod bị từ chối ngay ở tầng xác thực. |
| **Luôn là nháp** | Không có tham số status. Không ghi đè được bài `published`/`pending`. Không sửa được bài của người khác (trừ admin). Thiệt hại tối đa khi mất token = vài bản nháp rác. |
| **Giới hạn tần suất** | 30 bài/giờ/token. Thu hồi token là việc xảy ra *sau* khi phát hiện; giới hạn này giữ thiệt hại nhỏ trong lúc chưa ai biết gì. |

### Chống XSS

`_lib/markdown.js` **không cho bất kỳ HTML thô nào đi xuyên qua**. Toàn bộ văn bản
được escape, mọi thẻ phát ra đều do hàm sinh trong file đó tạo, theo danh sách trắng
đóng. Gửi `<script>alert(1)</script>` vào bài thì nó hiện ra đúng như chữ đó.

Mọi URL đi qua `safeUrl()`: chỉ nhận `http(s)`, đường dẫn nội bộ bắt đầu bằng `/`,
và `mailto:`/`#anchor` cho link. `javascript:`, `data:`, `vbscript:`, `//host` đều
bị loại. iframe chỉ được sinh cho 3 nhà cung cấp đã duyệt.

Đây là **lớp thứ nhất**. Trang blog vẫn chạy DOMPurify lúc hiển thị - hai lớp độc
lập, không lớp nào được coi là đủ một mình.

### Nơi cất token

Token là bí mật của **thiết bị**, không phải của người. Mỗi máy một token riêng để
mất máy nào thu hồi máy đó.

```bash
# ~/.zshrc - KHÔNG BAO GIỜ commit vào repo
export AN_POST_TOKEN=an_xxxxxxxx
```

> ⚠️ **Agent chú ý:** không bao giờ ghi token vào file trong repo, vào ví dụ code,
> vào log, hay vào nội dung bài viết. Đọc từ biến môi trường, dùng, quên đi.

---

## 6. Quy trình chuẩn cho agent

Khi được giao "viết bài blog từ tài liệu trong repo này":

1. **Đọc trước, viết sau.** Đọc tài liệu/README/screenshot trong repo. Đừng bịa
   chi tiết kỹ thuật không có trong nguồn.
2. **Quét bí mật.** Trước khi đưa bất cứ đoạn code hay ảnh chụp màn hình nào vào
   bài: tìm API key, token, chuỗi kết nối, đường dẫn nội bộ, tên khách hàng. Ảnh
   chụp màn hình terminal là nơi rò rỉ nhiều nhất.
3. **Viết file `.md`** theo đúng đặc tả trên. Đặt ảnh cạnh file, tham chiếu tương đối.
4. **Chạy `dry_run`.** Đọc `lint.warnings`. Sửa. Chạy lại tới khi sạch cảnh báo
   *đáng sửa* (một vài cảnh báo có thể chấp nhận được, nhưng phải là quyết định
   có ý thức, không phải bỏ qua).
5. **Gửi thật.** Báo lại cho người dùng `edit_url`.
6. **Nói rõ bài đang là nháp** và cần họ xem lại. Đừng nói "đã đăng bài".

### Giọng văn

**Đặc tả đầy đủ: [`voice.md`](./voice.md). Đọc trước khi viết một chữ nào.**

Bốn thứ bị bắt lỗi tự động:

1. **Cấm dấu gạch dài `—`** (kể cả trong tiêu đề). Dùng `-` hoặc tách câu.
2. **Cấm mũi tên `→`**. Gõ `->`.
3. **Tiếng miền Nam**: hỏng->hư, vỡ->bể, vào->vô, thế->vậy, buồn cười->mắc cười.
4. **Chê phải chê cho vui**. Cấm "dở", "kém chất lượng", "xấu xí", "chưa hoàn thiện",
   "gây khó chịu". Dùng "cùi bắp", "khùi như đầu bờ", "nhìn như mấy thằng beta",
   "banh chành", "trớt quớt", "thấy gớm", "quạu". Có emoji 😂.

Xưng "An" hoặc "tôi", gọi người đọc là "bạn". Kể chuyện thật, có số liệu cụ thể,
kể cả khúc đi nhầm. Vào thẳng chuyện, không dạo đầu.

Đọc 2-3 bài đã đăng gần nhất trên `/blog/` trước khi viết để bắt giọng.

---

## 7. Ví dụ hoàn chỉnh

```markdown
---
title: Tự làm giá sách từ gỗ pallet
excerpt: Ba buổi chiều và hai trăm nghìn tiền đinh vít, được cái giá sách vừa ý.
cover: ./anh/bia.jpg
section: goc-cua-an
category: diy
---

Cái giá sách cũ gãy một bên từ Tết. Mình định mua mới, ra tiệm hỏi giá xong
về nhà lục kho tìm pallet.

## Chuẩn bị

Pallet cũ mình xin ở kho gần nhà, không mất tiền. Chi phí thật nằm ở đinh vít
và giấy nhám:

- Đinh vít 4x40: 45.000đ
- Giấy nhám 3 loại độ mịn: 60.000đ
- Sơn lót + sơn phủ: 95.000đ

![Đống pallet trước khi tháo](./anh/pallet.jpg "Nhìn thì tệ, chà ra vẫn đẹp")

## Tháo và chà

Bước này lâu nhất, mất trọn một buổi chiều. Gỗ pallet hay có đinh gãy nằm sâu
bên trong, phải dò kỹ trước khi cưa.

> Bài học: mua sẵn hai lưỡi cưa. Mình gãy một cái vì tiếc, cưa vào đinh.

::embed[https://youtu.be/abc123]

## Thành phẩm

Sách đầu tiên mình để lên là ::book[dac-nhan-tam] - đọc từ hồi sinh viên,
theo mình qua bốn lần chuyển nhà.

![Giá sách đã lắp xong](./anh/xong.jpg "Ba buổi chiều, 200 nghìn"){full}
```

---

## 8. Danh sách tự kiểm

- [ ] `dry_run` trả `lint.errors` rỗng
- [ ] Có `title`, `excerpt`, `cover`
- [ ] Mọi ảnh đều có chú thích hoặc alt
- [ ] Bài dài hơn 400 ký tự và có ít nhất một `## Mục`
- [ ] Mọi `::book[...]` `::author[...]` đều tra được (không có cảnh báo `render`)
- [ ] Không có token, key, đường dẫn nội bộ nào lọt vào bài hay ảnh
- [ ] Đã báo với người dùng rằng bài đang là **nháp**

---

## 9. File liên quan trong repo

| File | Vai trò |
|---|---|
| `functions/api/ingest/post.js` | Tầng HTTP: quyền, giới hạn tần suất, đọc multipart |
| `functions/_lib/ingest-post.js` | Điều phối: ảnh -> R2, tra tham chiếu, lưu nháp |
| `functions/_lib/markdown.js` | Markdown -> khối HTML (**tầng chống XSS thứ nhất**) |
| `functions/_lib/frontmatter.js` | Đọc frontmatter |
| `functions/_lib/post-lint.js` | Bộ kiểm tra chất lượng |
| `functions/_lib/ratelimit.js` | Giới hạn tần suất trên KV |
| `scripts/an-post.mjs` | CLI đẩy bài |


---

# PHẦN C: Quản trị website bằng API

# API quản trị website

> Dùng khoá API để thêm/sửa sách, sản phẩm, bài viết, trang tĩnh, cài đặt và quyền
> người dùng - từ bất kỳ đâu, không cần mở trình duyệt.

**Đọc trước mọi thứ khác - ba luật cứng:**

1. **API không bao giờ cấp được vai trò Admin.** Kể cả bằng khoá của chính Admin.
   Muốn phong admin phải vào `/admin` qua Cloudflare Zero Trust.
2. **Không đường nào nhận HTML thô.** Nội dung bài và trang phải là Markdown hoặc
   `blog-json-v1`, đi qua tầng escape giống hệt nhau.
3. **Nội dung mới luôn ở trạng thái nháp/ẩn.** Đưa ra cho khách xem là quyền riêng
   (`content:publish`), tách khỏi quyền tạo/sửa.

Ba luật này không phải để làm khó. Chúng là lý do một khoá lọt ra ngoài không đồng
nghĩa với mất website.

---

## 1. Bắt đầu

Tạo khoá ở `/admin` -> **Ảnh & Ghi chú** -> **Thiết bị** -> *Thêm thiết bị*. Mở mục
**"Quyền quản trị website"** và tick những quyền cần. Mã chỉ hiện **một lần**.

Gọi thử - đây là đường **đầu tiên** nên gọi, nó tự mô tả khoá của bạn:

```bash
export KEY=an_xxxxxxxx
curl -s https://trinhleminhan.com/api/manage -H "Authorization: Bearer $KEY"
```

Trả về vai trò, danh sách quyền đang có, và **từng đường có được phép hay không**:

```json
{
  "via": "token",
  "actor": { "name": "Trịnh Lê Minh An", "role": "admin" },
  "scopes": ["content:read", "content:write"],
  "routes": [
    { "method": "POST", "path": "/api/manage/books", "scope": "content:write", "allowed": true },
    { "method": "PATCH", "path": "/api/manage/users", "scope": "users:write", "allowed": false }
  ]
}
```

> ⚠️ **Agent chú ý:** gọi `/api/manage` trước, đọc `routes[].allowed`, rồi mới làm.
> Đừng thử từng đường để đoán quyền qua lỗi 403 - vừa chậm vừa làm bẩn nhật ký.

---

## 2. Phạm vi quyền

Chia theo câu hỏi *"khoá này lọt ra ngoài thì mất gì"*.

### Nhóm an toàn - thiệt hại tối đa là vài bản nháp rác

| Quyền | Làm được gì |
|---|---|
| `capture:write` | Gửi ảnh / ghi chú từ điện thoại |
| `post:draft` | Đẩy bài `.md` hoặc `blog-json-v1` lên thành nháp |
| `ai:draft` | Gọi AI dựng bài (tốn tiền theo lượt) |

### Nhóm quản trị - sửa được nội dung thật

| Quyền | Làm được gì | Chỉ Admin |
|---|---|---|
| `content:read` | Đọc sách, sản phẩm, bài, trang - kể cả chưa đăng | |
| `content:write` | Tạo & sửa. **Không** đưa ra công chúng | |
| `content:publish` | Đăng / gỡ nội dung | ✓ |
| `content:delete` | Xoá hẳn | ✓ |
| `media:write` | Tải ảnh lên R2 | |
| `settings:write` | Sửa cài đặt site | ✓ |
| `users:read` | Xem danh sách tài khoản | ✓ |
| `users:write` | Đổi vai trò, chặn tài khoản | ✓ |

Cột **Chỉ Admin**: khoá do tài khoản cộng tác viên tạo không dùng được quyền đó, kể
cả khi đã tick lúc tạo. Lý do: hạ vai trò một người xuống mà khoá cũ của họ vẫn còn
quyền thì coi như chưa hạ.

---

## 3. Sách & sản phẩm

Dùng chung bảng, phân biệt bằng `kind`: `book` hoặc `product`.

```bash
# Liệt kê
curl "$H/api/manage/books?kind=product&q=khoan&limit=30" -H "Authorization: Bearer $KEY"

# Tạo - LUÔN vào trạng thái ẩn
curl -X POST "$H/api/manage/books" -H "Authorization: Bearer $KEY" \
  -H 'content-type: application/json' \
  -d '{"title":"Máy khoan Bosch GSB 550","kind":"product","author":"Bosch",
       "excerpt":"Máy khoan động lực cho việc nhà","status":"read","rating":4.5}'

# Sửa
curl -X PATCH "$H/api/manage/books/may-khoan-bosch-gsb-550" ... -d '{"rating":4}'

# Cho hiện ra ngoài - cần content:publish
curl -X PATCH "$H/api/manage/books/may-khoan-bosch-gsb-550" ... -d '{"is_hidden":false}'

# Xoá - cần content:delete, bị chặn nếu còn bài trỏ tới
curl -X DELETE "$H/api/manage/books/may-khoan-bosch-gsb-550" ...
```

Trường ghi được: `title` `author` `translator` `cover_url` `excerpt` `description`
`status` (`read`/`reading`/`wishlist`) `rating` (0-5) `kind` `accessory_type`
`is_featured`. Trường nào không có trong danh sách này thì API không đụng tới.

`cover_url` chỉ nhận `https://…` hoặc đường dẫn nội bộ `/api/upload/…`.

---

## 4. Bài viết

**Tạo và sửa nội dung bài đi qua `/api/ingest/post`**, không phải ở đây - bên đó có
sẵn bộ dựng Markdown/JSON, chống XSS và kiểm chất lượng. Xem
[`writing-api.md`](./writing-api.md) và [`blog-json.md`](./blog-json.md).

`/api/manage/posts` chỉ lo vòng đời:

```bash
# Liệt kê mọi trạng thái
curl "$H/api/manage/posts?status=draft" -H "Authorization: Bearer $KEY"

# Đăng nhiều bài một lúc - cần content:publish
curl -X PATCH "$H/api/manage/posts" ... \
  -d '{"ids":["bai-mot","bai-hai"],"status":"published"}'
```

`status`: `draft` · `pending` · `published` · `rejected`. Tối đa 50 bài mỗi lần.
`published_at` chỉ đặt lần đầu - đăng lại không làm bài nhảy lên đầu danh sách.

---

## 5. Trang tĩnh

Trang mới phục vụ ở `/trang/<slug>`, dựng HTML thẳng ở máy chủ nên Google và thẻ
chia sẻ mạng xã hội đọc được ngay.

```bash
curl -X POST "$H/api/manage/pages" -H "Authorization: Bearer $KEY" \
  -H 'content-type: application/json' \
  -d '{
    "title": "Chính sách đổi trả",
    "excerpt": "Điều kiện và thời hạn đổi trả.",
    "content": "## Trong 7 ngày\n\nGiữ nguyên tem niêm phong...",
    "show_in_nav": true,
    "seo_description": "Chính sách đổi trả của Trịnh Lê Minh An."
  }'
```

Nội dung nhận `content` (Markdown) **hoặc** `content_json` (blog-json-v1 - dùng được
mọi khối: căn lề, làm mờ, giải nghĩa, thu gọn, callout).

Gửi `content_html` sẽ bị **từ chối**. Trang tĩnh hiện cho mọi khách; nhận HTML thô
ở đây nghĩa là một khoá lọt ra ngoài là chèn được script vào mọi phiên truy cập.

Trang mới là `draft`, chỉ admin/mod xem được (khách nhận 404). Đăng:

```bash
curl -X PATCH "$H/api/manage/pages/chinh-sach-doi-tra" ... -d '{"status":"published"}'
```

Slug trùng route có sẵn của site (`admin`, `blog`, `sach`, …) sẽ bị từ chối.

---

## 5b. Công cụ & phần mềm (trang /tools)

Site có mục riêng cho công cụ. Bài viết về một phần mềm nên làm **hai** việc: đặt
bài vào danh mục `cong-cu`, và tạo mục trong danh sách công cụ nối ngược về bài.

```bash
curl -X POST "$H/api/manage/tools" -H "Authorization: Bearer $KEY" \
  -H 'content-type: application/json' -d '{
    "name": "Raycast",
    "tagline": "Thanh lệnh thay Spotlight trên Mac, chạy được script tự viết.",
    "category": "productivity",
    "pricing": "freemium",
    "price_note": "Bản Pro 8$/tháng",
    "platforms": ["macos"],
    "homepage_url": "https://raycast.com",
    "docs_url": "https://developers.raycast.com",
    "post": "raycast-thanh-lenh-thay-spotlight",
    "is_featured": true,
    "description": "## Vì sao đáng đổi\n\nDùng ba năm..."
  }'
```

| Trường | Giá trị |
|---|---|
| `category` | `dev` · `design` · `productivity` · `media` · `ai` · `khac` |
| `pricing` | `free` · `freemium` · `paid` · `opensource` |
| `platforms` | `macos` · `windows` · `linux` · `web` · `ios` · `android` |
| `post` | Slug bài viết chi tiết - tạo liên kết hai chiều |
| `description` | Markdown, hoặc `description_json` theo `blog-json-v1` |

Công cụ mới là `draft`. Đăng: `PATCH /api/manage/tools/:slug` với `{"status":"published"}`
(cần `content:publish`).

Trường `post` không tìm thấy thì bị bỏ **kèm cảnh báo** trong `warnings` - tra slug
trước bằng `GET /api/ingest/lookup?type=post&q=...`.

> Chủ site cũng thêm/sửa tay được ở `/admin` -> **Công cụ & Phần mềm**. Hai đường
> ghi vào cùng một chỗ, không lệch nhau.

---

## 6. Cài đặt

```bash
curl "$H/api/manage/settings" -H "Authorization: Bearer $KEY"
curl -X PATCH "$H/api/manage/settings" ... -d '{"site_tagline":"Tự tay làm · Hiểu sâu · Sống chất"}'
```

Khoá bí mật (`ai_key_*`, `maintenance_pass*`) **đọc ra chỉ thấy `"__SET__"`** và
**ghi vào bị từ chối**. Sửa chúng qua `/api/admin/ai-config` và
`/api/admin/maintenance`.

`maintenance_enabled` cũng không sửa được ở đây - phải qua
`/api/admin/maintenance`, nơi có chốt *"bật mà chưa đặt mật khẩu thì hỏi lại"*.

---

## 7. Người dùng

```bash
curl "$H/api/manage/users?role=mod" -H "Authorization: Bearer $KEY"

# Nâng lên cộng tác viên
curl -X PATCH "$H/api/manage/users" ... -d '{"email":"ai-do@gmail.com","role":"mod"}'

# Chặn tài khoản
curl -X PATCH "$H/api/manage/users" ... -d '{"email":"spam@x.com","is_blocked":true}'
```

`role` chỉ nhận `user` hoặc `mod`.

**Bốn chốt chặn, không nới:**

| Cố làm gì | Kết quả |
|---|---|
| Đặt `role: "admin"` | **403** - luôn luôn, kể cả bằng khoá của Admin |
| Đổi chính tài khoản đang dùng khoá | 400 - tránh tự khoá mình ra ngoài |
| Đụng vào một tài khoản Admin | 403 |
| Vai trò bịa (`superadmin`…) | 400 |

---

## 8. Chế độ bảo trì

Bật/tắt trong `/admin` -> **Chế độ bảo trì**, hoặc qua API (chỉ phiên đăng nhập Admin,
không dùng khoá API):

```bash
curl -X POST "$H/api/admin/maintenance" -H 'content-type: application/json' \
  -d '{"enabled":true,"message":"Đang nâng cấp, quay lại sau 2 tiếng nhé.","password":"mat-khau-xem-truoc"}'
```

Khi bật:

- Khách thấy trang **"Đang bảo trì"**, mã **503** kèm `Retry-After` - Google hiểu là
  tạm thời và **không gỡ site khỏi kết quả tìm kiếm**.
- Admin/mod **đang đăng nhập** vẫn xem site bình thường, không cần làm gì.
- Người có mật khẩu xem trước vào `/bao-tri`, nhập đúng -> xem được **24 giờ**
  (cookie đã ký HMAC, tự bịa không dùng được).
- **`/admin`, `/api/*`, `/login.html` và tài nguyên tĩnh KHÔNG bị chặn** - bật nhầm
  cũng không tự khoá mình ra ngoài.

Mật khẩu lưu dạng băm PBKDF2, **không xem lại được**, kể cả Admin. Quên thì đặt lại.

Chống dò: 10 lần thử mỗi giờ theo địa chỉ IP.

---

## 9. Mã lỗi

| Mã | Nghĩa | Làm gì |
|---|---|---|
| 401 | Không có khoá / khoá sai / đã thu hồi | Dừng. Không thử lại. |
| 403 | Thiếu quyền, hoặc chạm luật cứng | Đọc `error` - nó nói rõ thiếu quyền nào. Không thử lại kiểu khác. |
| 404 | Không tìm thấy | Kiểm tra id/slug. |
| 409 | Xung đột (slug trùng, còn ràng buộc) | Đổi slug, hoặc gỡ ràng buộc trước. |
| 413 | Nội dung quá lớn | Markdown ≤200KB, JSON ≤400KB, ảnh ≤4MB. |
| 429 | Quá tần suất | 300 ghi/giờ, 1200 đọc/giờ mỗi khoá. Đợi theo `Retry-After`. |

---

## 10. Nhật ký

Mọi thao tác ghi vào `audit_log`: `manage.book.create` `manage.book.update`
`manage.book.delete` `manage.page.*` `manage.post.status` `manage.settings.update`
`manage.user.update` `maintenance.update`.

Mỗi dòng ghi ai làm, qua đường nào (`token` hay `session`), đổi những trường nào.
**Giá trị cài đặt không bao giờ được ghi vào nhật ký** - chỉ tên khoá, vì nhật ký
hay được xuất ra để xem.

---

## 11. Giữ khoá cho an toàn

```bash
# ~/.zshrc - KHÔNG BAO GIỜ commit vào repo
export AN_MANAGE_KEY=an_xxxxxxxx
```

- **Mỗi công cụ một khoá riêng.** Hỏng cái nào thu hồi cái đó.
- **Cấp đúng quyền cần dùng.** Script chỉ thêm sản phẩm thì đừng cấp `users:write`.
- **Đặt hạn dùng** khi tạo khoá cho việc ngắn hạn.
- Xem `last_used_at` và `last_ip` trong `/admin` -> **Thiết bị** để phát hiện bất thường.

> ⚠️ **Agent chú ý:** không bao giờ ghi khoá vào file trong repo, vào ví dụ code,
> vào log, hay vào nội dung bài viết. Đọc từ biến môi trường, dùng, quên đi.

---

## 12. Danh sách tự kiểm cho agent

- [ ] Đã gọi `GET /api/manage` và đọc `routes[].allowed` trước khi làm gì
- [ ] Nội dung gửi lên là Markdown hoặc `blog-json-v1`, không phải HTML
- [ ] Hiểu rằng mục vừa tạo **đang ẩn** và đã báo lại cho người dùng
- [ ] Không cố cấp vai trò Admin
- [ ] Không có khoá nào lọt vào output
- [ ] Gặp 403 thì dừng và báo, không thử đường vòng

---

## 13. File liên quan

| File | Vai trò |
|---|---|
| `functions/_lib/manage-guard.js` | Kiểm quyền, giới hạn tần suất, nhật ký - mọi endpoint đi qua đây |
| `functions/_lib/token.js` | Định nghĩa phạm vi quyền |
| `functions/_lib/password.js` | Băm PBKDF2 + ký HMAC cho cookie |
| `functions/_lib/maintenance.js` | Chặn khách, trang bảo trì |
| `functions/_lib/page-content.js` | Dựng nội dung trang qua đúng bộ dựng của bài |
| `functions/api/manage/*` | Các endpoint |
| `functions/trang/[slug].js` | Hiển thị trang tĩnh |

---

# PHỤ LỤC: file JSON mẫu đầy đủ

Dùng hết mọi loại khối. Copy về sửa là ra bài mới.

```json
{
  "format": "blog-json-v1",

  "meta": {
    "title": "Tự làm giá sách từ gỗ pallet",
    "excerpt": "Ba buổi chiều và hai trăm nghìn tiền đinh vít, được cái giá sách vừa ý.",
    "cover": "./anh/bia.jpg",
    "slug": "tu-lam-gia-sach-go-pallet",
    "section": "goc-cua-an",
    "category": "diy",
    "tags": ["diy", "gỗ", "nội thất"],
    "related": [],
    "seo": {
      "title": "Tự làm giá sách gỗ pallet - chi phí thật và lỗi thật",
      "description": "Toàn bộ quá trình làm giá sách từ pallet cũ: chi phí, dụng cụ, và ba lỗi tôi đã mắc.",
      "og_image": "",
      "canonical": "",
      "noindex": false
    }
  },

  "glossary": [
    {
      "term": "pallet",
      "definition": "Kệ gỗ dùng để kê hàng trong kho, thường xin lại được miễn phí.",
      "reading": "pa-lét",
      "lang": { "en": "pallet", "ja": "パレット" },
      "note": "Chọn loại đóng dấu HT (xử lý nhiệt), tránh loại MB vì đã ngâm hoá chất."
    },
    {
      "term": "chà nhám",
      "definition": "Mài bề mặt gỗ bằng giấy nhám, đi từ độ thô đến độ mịn dần.",
      "lang": { "en": "sanding" }
    }
  ],

  "blocks": [
    {
      "type": "paragraph",
      "content": [
        { "text": "Cái giá sách cũ gãy một bên từ Tết. Mình định mua mới, ra tiệm hỏi giá xong về nhà lục kho tìm " },
        { "text": "pallet", "term": "pallet" },
        { "text": "." }
      ]
    },

    { "type": "heading", "level": 2, "content": "Chi phí thật" },

    {
      "type": "paragraph",
      "content": "Gỗ thì xin được, không mất tiền. Chi phí thật nằm ở phụ kiện:"
    },

    {
      "type": "list",
      "ordered": false,
      "items": [
        "Đinh vít 4x40 - 45.000đ",
        "Giấy nhám ba độ mịn - 60.000đ",
        [{ "text": "Sơn lót + sơn phủ", "marks": ["bold"] }, { "text": " - 95.000đ" }]
      ]
    },

    {
      "type": "collapse",
      "summary": "Bảng chi phí chi tiết từng món",
      "open": false,
      "blocks": [
        {
          "type": "list",
          "ordered": true,
          "items": [
            "Đinh vít 4x40, hộp 200 con: 45.000đ",
            "Giấy nhám P80 / P150 / P240, mỗi loại 2 tờ: 60.000đ",
            "Sơn lót gốc nước 0,5L: 40.000đ",
            "Sơn phủ mờ 0,5L: 55.000đ"
          ]
        },
        { "type": "paragraph", "content": [{ "text": "Tổng: 200.000đ", "marks": ["bold"] }] }
      ]
    },

    { "type": "heading", "level": 2, "content": "Tháo và chà" },

    {
      "type": "callout",
      "variant": "warn",
      "title": "Cẩn thận đinh gãy",
      "blocks": [
        {
          "type": "paragraph",
          "content": "Gỗ pallet hay có đinh gãy nằm sâu bên trong, nhìn ngoài không thấy. Phải dò kỹ trước khi cưa, nếu không là gãy lưỡi."
        }
      ]
    },

    {
      "type": "image",
      "src": "./anh/cat-go.jpg",
      "alt": "Tay cầm cưa cắt theo dấu bút chì trên tấm gỗ pallet",
      "caption": "Dò đinh trước, cưa sau - mình học được sau khi gãy một lưỡi",
      "size": "lg",
      "align": "center"
    },

    {
      "type": "paragraph",
      "content": [
        { "text": "Bước " },
        { "text": "chà nhám", "term": "chà nhám" },
        { "text": " là bước lâu nhất, mất trọn một buổi chiều. Đi từ P80 lên P240, " },
        { "text": "đừng nhảy cóc", "marks": ["bold", "italic"] },
        { "text": " - nhảy cóc thì bề mặt còn vết xước sâu, sơn lên nhìn ra ngay." }
      ]
    },

    {
      "type": "quote",
      "content": "Mua sẵn hai lưỡi cưa. Mình tiếc tiền, mua một cái, rồi mất buổi chiều đi mua cái thứ hai.",
      "cite": "Bài học đắt nhất của dự án này"
    },

    { "type": "embed", "url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ" },

    { "type": "divider" },

    { "type": "heading", "level": 2, "content": "Thành phẩm" },

    {
      "type": "paragraph",
      "align": "center",
      "content": [
        { "text": "Ba buổi chiều, hai trăm nghìn, và một cái giá sách ", "marks": [] },
        { "text": "vừa ý", "marks": ["highlight"] },
        { "text": "." }
      ]
    },

    {
      "type": "image",
      "src": "./anh/xong.jpg",
      "alt": "Giá sách gỗ pallet đã lắp xong, xếp đầy sách",
      "caption": "Sách đầu tiên mình để lên là cuốn theo mình qua bốn lần chuyển nhà",
      "size": "full"
    },

    {
      "type": "callout",
      "variant": "tip",
      "title": "Nếu bạn định làm theo",
      "blocks": [
        {
          "type": "list",
          "ordered": false,
          "items": [
            "Xin pallet ở kho hàng gần nhà, đa số họ cho không",
            "Kiểm tra dấu HT trên thanh gỗ trước khi mang về",
            "Mua dư một lưỡi cưa"
          ]
        }
      ]
    },

    {
      "type": "markdown",
      "md": "### Dụng cụ mượn được\n\nMình mượn được máy chà nhám của hàng xóm, đỡ 800 nghìn. Nếu không mượn được thì chà tay vẫn ra, chỉ là mất thêm một buổi."
    },

    {
      "type": "paragraph",
      "content": [
        { "text": "Chi tiết cách xử lý mối mọt mình để trong " },
        { "text": "phần này", "link": "/blog/" },
        { "text": ", đọc trước khi bắt tay vào làm." }
      ]
    }
  ]
}
```

---

*Bộ tài liệu này được sinh tự động từ đặc tả gốc của trinhleminhan.com.
Bản mới nhất luôn ở https://trinhleminhan.com/SPEC/agent-kit.md*
