17 — Content Expansion Guide
How to add 30 new tools and 40 new blog posts (30 English + 10 Arabic/Egyptian) without breaking anything. This is the canonical playbook. It exists because the platform was built registry-first: content surfaces (pages, listings, sitemap, search, API docs) all derive from two registries and the filesystem, so adding content is an append-only operation — you add rows and folders, you never rewire pages.
Cheat sheet:
I want to… Touch Don't touch Add a server tool lib/tools-def.ts+ 1 case inapp/api/v1/tools/[slug]/route.ts+ contract inlib/tools-api-ref.tspages, sitemap, search, nav Add a browser-only tool the 3 above (as CLIENT_ONLY) + 1 component + 1 case intool-client.tsxsame Add an English post blog/content/<slug>/index.mdx+ 3 imagesany .tsxAdd an Arabic post same, slug ends -ar,locale: arany .tsxScaffolders do the mechanical parts:
pnpm new:toolandpnpm new:post.
1. Architecture — where the truth lives
Everything about tools and posts derives from four sources of truth. Update the truth; the surfaces follow.
| Source of truth | File / location | What derives from it automatically |
|---|---|---|
| Tool registry | lib/tools-def.ts (TOOLS, TOOL_CATEGORIES) |
/tools grid (grouped by category), /tools/<slug> pages (SSG via generateStaticParams), sitemap.xml, ⌘K search index (/api/search/index), tool page metadata |
| Tool API contracts | lib/tools-api-ref.ts (TOOL_CONTRACTS, CLIENT_ONLY_TOOLS) |
/docs/tools reference page (matrix, params, curl examples) |
| Tool server runners | app/api/v1/tools/[slug]/route.ts (runTool() switch + CLIENT_ONLY map) |
POST /api/v1/tools/<slug> + the zero-UI-code GenericTool renderer |
| Blog filesystem | blog/content/<slug>/index.mdx (+ frontmatter) |
/blog index (EN and AR sections, featured, categories), /blog/<slug> (metadata, OG/Twitter cards, dir="rtl" for AR, TOC, prev/next, JSON-LD), sitemap.xml, ⌘K search |
| Blog images | blog/content/<slug>/*.webp → public/blog-img/<slug>/ via scripts/copy-blog-images.sh |
hero on cards/article/OG |
The golden rule: if you find yourself editing a page component to list new content, you're doing it wrong. Listings read the registry/filesystem at build time. The only legitimate component edits are (a) a bespoke UI for a browser-only tool, and (b) editorial copy on landing pages.
What you NEVER need to edit when adding tools or posts
app/sitemap.ts— loopsTOOLS+ scansblog/content/+ scansdocs/*.md.app/api/search/index/route.ts— servesTOOLS+ scanned posts to ⌘K (Fuse.js).app/tools/page.tsx— counts, categories, grid, icon fallback (Code2), example fallback — all derived.app/blog/page.tsx— EN/AR sections, featured rail, category chips — all derived.app/docs/tools/page.tsx— rendersTOOL_CONTRACTS.- Nav/footer — category pages are linked, individual items intentionally are not.
2. Invariants (read once, obey always)
- Append-only content. New tool = rows + (usually) one
case. New post = one folder. Never reorder/rename existing slugs — slugs are URLs, and URLs are contracts (SEO, bookmarks,related:references). - No placeholders. A tool that echoes input or a post with TODO frontmatter must not ship. The security audit classifies mocks/stubs as defects (
SECURITY_AUDIT_REPORT.md§3). The scaffolders emitTODOmarkers precisely so you cangrep -rn "TODO" blog/content/<slug> lib/tools-api-ref.tsbefore committing. - Inherit the security rails, don't rebuild them. Server tool runners live inside
runTool(), which already enforces: 60 req/min/IP rate limit (429 +Retry-After), 64 KiB input ceiling (413), RFC 9457 problem bodies, no error internals leaking. Yourcasecode gets these for free. - Zero-trust in runners. Server logic must be deterministic and bounded: no
eval, nonew Function, no outboundfetch(SSRF), no filesystem access, allow-list every option value (see theHASH_ALGOSpattern), clamp every number (seeclampInt), keep CPU/memory linear in input size. - Markdown is rendered through
lib/markdown.ts=marked+sanitize-htmlallow-list. Raw HTML in posts is stripped by design — that's your XSS protection from F-01. Don't try to sneak<script>/<iframe>into posts; write Markdown. - Slug discipline.
^[a-z0-9-]+$, unique, permanent. Arabic posts end in-arand pair with their English sibling (topic↔topic-ar). The-arsuffix is a project convention, not a renderer requirement —locale: arin frontmatter is what actually switches RTL. - Posts never touch code. A blog post must require zero
.tsxchanges. If it seems to, that's a bug — report it, don't work around it. - One change, one validation wave. Run the gates in §6 after every batch, not once at the end.
Part A — Adding a tool (the 30 new tools)
A1. Pick slug, title, category
- Slug: kebab-case, verb-noun is nice (
yaml-formatter,cron-parser). Check uniqueness againstlib/tools-def.ts. - Category: reuse an existing one when possible:
Encoding,Validation,Text,Conversion,Formatting,Generators,Web,IT Ops,Marketing. A new category name is fine —TOOL_CATEGORIESand the/toolsgrouping derive automatically. Keep it ≤ 12 chars (it's shown as a badge).
A2. Choose the execution mode (decision tree)
Does the tool need browser APIs (File, DOMParser, canvas)?
YES → CLIENT-ONLY
Could a server version require fetching arbitrary user URLs?
YES → CLIENT-ONLY (server version = SSRF risk — this is why link-checker is client-only)
Does running it server-side force users to upload sensitive data for no benefit (e.g. SQL queries)?
YES → CLIENT-ONLY
Otherwise → SERVER-IMPLEMENTED (preferred: works via API for customers AND via GenericTool in the UI)
A3. Scaffold the registry + contract
pnpm new:tool yaml-formatter --title "YAML Formatter" --category "Formatting" \
--desc "Beautify and validate YAML." --icon "❏"
This makes exactly two safe edits:
lib/tools-def.ts— inserts one row, grouped with its category:
From this moment{ slug: "yaml-formatter", title: "YAML Formatter", category: "Formatting", desc: "Beautify and validate YAML.", icon: "❏" },/tools/yaml-formatteris a real page (SSG), listed in/tools,sitemap.xml, and ⌘K.lib/tools-api-ref.ts— appends aTODOcontract skeleton intoTOOL_CONTRACTS. Fill it in (it powers/docs/tools). If the tool is client-only, also add it toCLIENT_ONLY_TOOLSat the bottom of the same file.
Do it manually instead (identical effect): add the row to TOOLS and the contract to TOOL_CONTRACTS.
A4a. Server-implemented tool: add one runner case
In app/api/v1/tools/[slug]/route.ts, inside runTool(), following the existing style:
case "yaml-formatter": {
// `input` is already a string capped at MAX_TOOL_INPUT (64 KiB).
// Rules: deterministic, bounded time/memory, no eval, no fetch, allow-list options.
const indent = clampInt(body.indent, 1, 8, 2);
const result = formatYaml(input, indent); // your real implementation
if (result.length > MAX_TOOL_INPUT * 4) return bad("Output too large");
return ok({ slug, result, indent });
}
Rules of the road (all demonstrated by existing cases):
- Use the helpers:
safeString(v, max),clampInt(v, min, max, dflt),bad(msg)(400),ok(payload). - Heavy output must be bounded (
MAX_TOOL_INPUT * 4is the established cap). - Randomness:
randomBytes/randomUUIDfromcrypto(already imported), neverMath.random. - Structured results are fine — return objects in
result;GenericToolpretty-prints JSON. - No new dependencies without a decision record (
docs/13_PACKAGE_SELECTION_POLICY.md). Everything you need (text, encoding, crypto, QR) is already in the box.
UI: you are done. With a runner in place, the tool page renders via GenericTool
(single textarea → result), including RFC 9457 error display, 429 handling, and
copy-to-clipboard. That's the modularity payoff: registry row + runner case +
contract = a shipped tool.
A4b. Browser-only tool: CLIENT_ONLY + one real component
- Add to the
CLIENT_ONLYmap inapp/api/v1/tools/[slug]/route.tswith a truthful reason (this exact string is shown in the UI and docs), and mirror it inCLIENT_ONLY_TOOLSinlib/tools-api-ref.ts. - Write a real client component. New components go in
app/tools/[slug]/tool-client.tsxtoday; when you add more than ~5, extract per-tool files undercomponents/tools/<slug>.tsxand import them — one file per tool, named exports, default-export nothing. - Add one line to the
ToolClientswitch:case "yaml-formatter": return <YamlTool />; - Non-negotiables for client components (enforced by review):
- Hooks are unconditional. Follow the existing pattern —
ToolClientswitches between components, each with stable hook order. Never put hooks after early returns inside the same component without keeping order stable across renders (GenericTool'sif (clientOnly) return …is safe because all hooks run before it, every render). - Bound everything client-side too (file size caps —
MAX_FILE_BYTES, output truncation). - Use the shared primitives in the file:
TextArea,ResultBox,copy(),Card,Button. Don't invent one-off styling. - Show errors in the established red banner pattern with
role="alert".
- Hooks are unconditional. Follow the existing pattern —
Three reference implementations were added in v3.1.3 — copy their shape:
| Component | Teaches you | Pattern |
|---|---|---|
Base64FileTool |
File API, chunked binary→base64, size caps, blob download | browser-only tool, two-card layout |
XmlValidatorTool |
DOMParser, Firefox-vs-WebKit parsererror handling |
client compute, no network |
DiffTool |
multi-input UI → existing server runner ({a, b}), colored result rendering |
bespoke UI over an API runner |
GenericTool (upgraded) |
the contract every runner must satisfy: {input} in → result out, problem bodies, 429, clientOnly sentinel |
zero-code UI for server tools |
A5. Per-tool verification (2 minutes each)
pnpm typecheck && pnpm lint
pnpm build # SSG: /tools/<slug> must appear
pnpm start & sleep 3
curl -s -o /dev/null -w "%{http_code}\n" http://localhost:3000/tools/<slug> # 200
curl -s -X POST http://localhost:3000/api/v1/tools/<slug> -H "Content-Type: application/json" \
-d '{"input":"hello"}' # real result, not echo
curl -s http://localhost:3000/api/search/index | grep -o '<slug>' # in ⌘K index
curl -s http://localhost:3000/sitemap.xml | grep -o 'tools/<slug>' # in sitemap
Negative tests (must keep working — they protect the platform):
curl -s -o /dev/null -w "%{http_code}\n" http://localhost:3000/tools/not-a-real-tool # 404, no server error logs
BIG=$(head -c 100000 /dev/zero | tr '\0' 'A')
curl -s -o /dev/null -w "%{http_code}\n" -X POST http://localhost:3000/api/v1/tools/<slug> \
-H "Content-Type: application/json" -d "{\"input\":\"$BIG\"}" # 413 or 400, never 500
A6. The 30-tool batch plan
Don't do 30 at once. Ship in waves of 5–6 by category with a full §6 gate between waves. Suggested roadmap (fill with your own picks — keep the per-wave shape):
| Wave | Theme | Suggested slugs (server unless noted) |
|---|---|---|
| 1 | Formatting | yaml-formatter, css-formatter, html-formatter, markdown-preview (client), json-to-ts |
| 2 | Encoding | hex-converter, url-parser, base32-text, html-entity-decoder, unicode-inspector |
| 3 | Generators | nanoid-generator, slug-generator, fake-data-generator, color-palette (client), cron-parser, chmod-calculator |
| 4 | Web/DevOps | http-status-lookup, headers-parser, csp-builder, robots-tester (client), serp-preview, og-validator (client) |
| 5 | Text/Numbers | word-counter, markdown-toc, duplicate-line-remover, list-sorter, number-base-converter, roman-numerals |
| 6 | Validators | email-validator, uuid-validator, semver-parser, cron-validator, iban-validator |
Rules for the plan:
- Every wave ends green: §6 gates + spot-check each new page in a browser (desktop and mobile width).
- Keep a running checklist in your PR/issue:
- [ ] slug — registry / runner / contract / verified. - Update
CHANGELOG.mdper wave (one "Added" line per wave is fine).
Part B — Adding blog posts (30 EN + 10 AR/Egyptian)
B1. Anatomy of a post (what the renderer understands)
blog/content/<slug>/
index.mdx ← frontmatter + Markdown body (rendered via marked + sanitize-html)
hero.webp ← co-located images…
cover.webp
thumb.webp
public/blog-img/<slug>/ ← same files, served at /blog-img/<slug>/<name>
(populate with: bash scripts/copy-blog-images.sh)
Frontmatter — every field matters:
---
title: "Post title" # H1 + fallback SEO title
slug: my-post-slug # must match the folder name
description: "135–160 chars." # meta description + search snippet
excerpt: "One-line card hook."
author: yas-team
publishedAt: 2026-08-07 # ISO date; controls ordering (newest first)
updatedAt: 2026-08-07
category: Engineering # free text; becomes a filter chip on /blog
tags: [links, api] # keywords + SEO
heroImage: ./hero.webp # keep these three as-is
coverImage: ./cover.webp
thumbImage: ./thumb.webp
readingTime: 4 # integer minutes
featured: false # true = featured rail on /blog (≤ 3 total)
locale: en # en (default) | ar
related: [other-slug, another-slug] # MUST be existing slugs — rendered as cards
faq: # drives FAQ JSON-LD — real Q&As only
- q: "Question?"
a: "Direct answer."
seo: # overrides; keep description == description
title: "Post title | yas.sh"
description: "135–160 chars."
keywords: [kw1, kw2]
---
Slug rules recap: ^[a-z0-9-]+$, unique forever, folder name == slug: field.
B2. Scaffold an English post
pnpm new:post ip-allowlisting-guide --title "IP Allowlisting for Short Links" --category Security
# → blog/content/ip-allowlisting-guide/index.mdx (full frontmatter, TODO markers)
# → public/blog-img/ip-allowlisting-guide/README.txt (what images to add)
Then:
- Replace every
TODO(grep -rn "TODO" blog/content/ip-allowlisting-guide). - Write the body in Markdown only (no raw HTML — it gets stripped; see §2.5).
- Verify
related:slugs exist:ls blog/content | grep -E "slug1|slug2". - Add the three
.webpimages (see B4), runbash scripts/copy-blog-images.sh. - Gate:
pnpm typecheck && pnpm lint && pnpm build→ post must appear in build output; check/blog/<slug>.
Your existing seed list: blog/content/_stubs.json already names ~27 planned posts
(audit-logs-compliance, abuse-detection, security-headers-csp, prisma-mariadb-tips,
accessibility-checklist, seo-sitemap-robots, …). Use it as the backbone of the 30 EN
posts so titles/categories stay consistent with prior planning; add ~3 new ideas to reach 30.
B3. Arabic posts (10, Egyptian flavor)
pnpm new:post ip-allowlisting-guide-ar --ar
# → frontmatter pre-set: locale: ar, direction: rtl, Arabic category/tags skeleton
The platform then does the rest automatically — no code changes:
/blog/<slug>rendersdir="rtl"containers, Arabic TOC labels ("في هذه المقالة", "مشاركة"),og:locale = ar_SA,lang-aware titles./bloglists it in the Arabic section (posts withlocale: arare grouped separately from EN).- Sitemap/search index it like any post.
Conventions that keep the corpus coherent:
- Slug = English sibling +
-ar. Paired posts cross-link viarelated:(EN post lists the-arslug and vice-versa). If there is no EN sibling, still use an English slug with-ar— Arabic in URLs is bad SEO hygiene. - Frontmatter in Arabic:
title,description,excerpt,category,tags,heroAlt,faq— all Arabic. Slugs/dates/author stay as-is. related:in an AR post points to AR posts. Don't strand an Arabic reader on English cards.
Egyptian-slang style guide (اعملها صح):
- Prose: colloquial Egyptian (عامية مصرية) — write like a sharp engineer explaining to a friend: «ده بيحصل إزاي؟», «الموضوع أبسط مما تتخيل», «إلخ». Avoid فصحى stiffness; avoid forced jokes.
- All code, commands, URLs, identifiers, product names, and technical terms stay English — untranslated and untransliterated:
curl,redirect,rate limit,301. Where a term needs explaining, explain it in Arabic: «الـ rate limit ده معناه إن السيرفر بيقولك: استنى شوية». - Code comments stay English too (copy-paste compatibility with every other doc).
- Numbers/latin units (ms, KB, %) stay Latin inside Arabic prose.
- Brand names never translated: yas.sh, Oracle Linux, Prisma.
- Watch out in Markdown:
**bold**markers hug Arabic words fine, but keep a space between a Latin word and an Arabic word; wrapped links[نص عربي](https://…)are OK — URLs never translated. - One post = one idea, same skeleton as EN: مقدمة → ليه مهم → خطوات عملية → غلطات شائعة → أسئلة شائعة → الخطوة الجاية.
B4. Images (required for every post)
| File | Size | Budget | Used for |
|---|---|---|---|
hero.webp |
1600×900 | ≤ 150 KB | /blog cards, article header, OG/Twitter image |
cover.webp |
1920×1080 | ≤ 200 KB | large cover/social |
thumb.webp |
640×360 | ≤ 60 KB | thumbnails |
- Dark, on-brand, minimal text. Always set
heroAlt(accessibility + SEO; Arabic alt text on AR posts). - Convert:
npx sharp-cli -i source.png -o hero.webp resize 1600 900 --webp(sharp is already a project dep family). - Workflow: drop the 3 files into
blog/content/<slug>/then runbash scripts/copy-blog-images.sh(copies topublic/blog-img/<slug>/). The build doesn't fail without images — you'll get broken<img>s instead, and that is what the wave checklist catches.
B5. The 40-post batch plan
- Wave structure: EN ×10 → AR ×5 → EN ×10 → AR ×5 → EN ×10. Gate every wave with §6.
- Stagger
publishedAt(e.g. 2–3 posts day apart, not 40 posts one date — the index sorts by date and SEO prefers a believable cadence;updatedAtbumps on real edits). - Interlink: every new post lists 2–4
relatedposts that already exist (or that shipped earlier in this program); revisit the last wave's posts to link forward where natural. featured: ≤ 3 posts total across the whole blog. It's currently used sparingly — keep it that way.- Categories: reuse the established set (Security, Developers, Marketing, Analytics, QR, Migration, Engineering, …) plus their Arabic counterparts (أمان, مطورين, تسويق, …). New categories appear as
/blogchips automatically. - Per-post checklist (copy into your tracker):
- [ ] pnpm new:post <slug> [--ar]
- [ ] frontmatter: 0 TODOs left (grep)
- [ ] related slugs all exist
- [ ] body: markdown only, code blocks tested, EN tech terms in AR posts
- [ ] 3 webp images + copy-blog-images.sh
- [ ] heroAlt set (Arabic on AR posts)
- [ ] pnpm build green; /blog/<slug> renders (RTL check for AR)
- [ ] post appears in /blog section + ⌘K + sitemap.xml
6. Wave gates — the "don't break anything" protocol
Run after every wave (5–6 tools or ~10 posts), not once at the end. Same on macOS and Oracle Linux 9.
pnpm typecheck # 0 errors
pnpm lint # 0 warnings
pnpm build # page count grows: tools pages +N, blog pages +N
pnpm start & sleep 3
pnpm smoke # 59/59 (baseline platform routes must stay green)
pnpm smoke:security # 18/18 (rails must stay green)
pnpm audit # 0 vulnerabilities (only if you added deps — you shouldn't)
Spot checks per wave:
| Surface | Expect |
|---|---|
/tools |
new cards under correct category chips, counts updated |
/tools/<new> (desktop + mobile) |
real behavior — never echo |
/docs/tools |
new row in the matrix, curl example works verbatim |
/blog |
new posts in the right EN/AR section, images load, no TODOs |
/blog/<new-ar> |
dir="rtl", Arabic TOC labels, og:locale ar_SA (view-source) |
⌘K (search) |
new items found by title; typing/clearing/no-results states stable (v3.1.1 fix) |
/sitemap.xml, /api/search/index |
new URLs present |
| Server logs | no NoFallbackError, no 500s while crawling new routes |
Then commit: CHANGELOG.md entry, and if you re-ship the zip, bump version and record the SHA-256 in PROJECT_MEMORY.md.
7. Do-NOT list (ways to actually break things)
- Don't add external font/CDN links for Arabic text (CSP is locked down —
font-src 'self',style-srcallows only hashed inline). System fonts render Arabic correctly on macOS/iOS/Android/Windows. If you ever self-host a font: files in/public, update CSP innext.config.mjs, re-run security-smoke. - Don't bypass
lib/markdown.tsor feed it pre-rendered HTML. F-01 was stored XSS in exactly this pipeline — the sanitizer allow-list is the fix, don't loosen it for "one embed". - Don't set
dynamicParams = falseon new SSG dynamic routes (that was theNoFallbackErrorlog noise, v3.1.2). Follow blog/tools:generateStaticParams()+notFound(). - Don't mutate the cmdk anatomy in
components/site-header.tsxwhen adding searchable content — search reads/api/search/index; theshouldFilter={false}prop is load-bearing (v3.1.1 crash). - Don't edit shared fetch/validation helpers per tool (
lib/api-helpers.ts,lib/validators.ts). Extend allow-lists inside your owncase. - Don't add DB tables or API routes for tools/posts — both are intentionally stateless/fs-driven.
- Don't rename existing slugs. Redirects require an explicit plan; content slugs have none.
- Don't commit generated artifacts —
.next/,dev.db,.env(.gitignore covers these; the zip pipeline excludes them). - Don't run
npm/yarn— pnpm 9.12.3 only, lockfile is frozen in CI/deploy. - Don't add dependencies for a tool without the policy check (
docs/13_PACKAGE_SELECTION_POLICY.md). 28 tools ship with zero tool-specific deps; expect the same.
8. Rollback (clean removal)
- Tool: delete its registry row, its runner
case, its contract (+CLIENT_ONLYentries, + bespoke component/case if any).pnpm build— the page returns 404 like any unknown tool. No data to clean. - Post: delete
blog/content/<slug>/andpublic/blog-img/<slug>/. Rebuild. Remove it from anyrelated:lists that mention it (they render as cards —grep -rn "<slug>" blog/content).
Because surfaces derive from the registry/filesystem, removal leaves no dangling references beyond related: frontmatter, which is why the wave checklist checks it.
9. Touch-point reference (for reviewers)
| Concern | File(s) | Edited per tool? | Per post? |
|---|---|---|---|
| Tool registry | lib/tools-def.ts |
✅ 1 row | — |
| Tool runner / client-only map | app/api/v1/tools/[slug]/route.ts |
✅ 1 case or 1 row | — |
| Tool API contract | lib/tools-api-ref.ts |
✅ 1 object | — |
| Tool UI (bespoke only) | app/tools/[slug]/tool-client.tsx (+ components/tools/ for scale) |
optional | — |
| Post content | blog/content/<slug>/index.mdx |
— | ✅ |
| Post images | blog/content/<slug>/*.webp + bash scripts/copy-blog-images.sh |
— | ✅ |
| Scaffolders | scripts/new-tool.mjs, scripts/new-post.mjs (pnpm new:tool, pnpm new:post) |
— | — |
| Pages/listings/sitemap/search | derived — untouched | ❌ | ❌ |
| Changelog / version | CHANGELOG.md, package.json |
per wave | per wave |
Cross-refs: authoring/deep blog conventions in docs/BLOG_GUIDE.md (+ /docs/blog-guide),
platform security rails in docs/08_SECURITY.md, API conventions in docs/04_API_PLATFORM.md,
testing protocol in docs/09_TESTING.md.