Skip to content
Y
YAS.SH
Docs

17_CONTENT_EXPANSION

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 in app/api/v1/tools/[slug]/route.ts + contract in lib/tools-api-ref.ts pages, sitemap, search, nav
Add a browser-only tool the 3 above (as CLIENT_ONLY) + 1 component + 1 case in tool-client.tsx same
Add an English post blog/content/<slug>/index.mdx + 3 images any .tsx
Add an Arabic post same, slug ends -ar, locale: ar any .tsx

Scaffolders do the mechanical parts: pnpm new:tool and pnpm 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>/*.webppublic/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 — loops TOOLS + scans blog/content/ + scans docs/*.md.
  • app/api/search/index/route.ts — serves TOOLS + 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 — renders TOOL_CONTRACTS.
  • Nav/footer — category pages are linked, individual items intentionally are not.

2. Invariants (read once, obey always)

  1. 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).
  2. 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 emit TODO markers precisely so you can grep -rn "TODO" blog/content/<slug> lib/tools-api-ref.ts before committing.
  3. 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. Your case code gets these for free.
  4. Zero-trust in runners. Server logic must be deterministic and bounded: no eval, no new Function, no outbound fetch (SSRF), no filesystem access, allow-list every option value (see the HASH_ALGOS pattern), clamp every number (see clampInt), keep CPU/memory linear in input size.
  5. Markdown is rendered through lib/markdown.ts = marked + sanitize-html allow-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.
  6. Slug discipline. ^[a-z0-9-]+$, unique, permanent. Arabic posts end in -ar and pair with their English sibling (topictopic-ar). The -ar suffix is a project convention, not a renderer requirement — locale: ar in frontmatter is what actually switches RTL.
  7. Posts never touch code. A blog post must require zero .tsx changes. If it seems to, that's a bug — report it, don't work around it.
  8. 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 against lib/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_CATEGORIES and the /tools grouping 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:

  1. lib/tools-def.ts — inserts one row, grouped with its category:
    { slug: "yaml-formatter", title: "YAML Formatter", category: "Formatting", desc: "Beautify and validate YAML.", icon: "❏" },
    
    From this moment /tools/yaml-formatter is a real page (SSG), listed in /tools, sitemap.xml, and ⌘K.
  2. lib/tools-api-ref.ts — appends a TODO contract skeleton into TOOL_CONTRACTS. Fill it in (it powers /docs/tools). If the tool is client-only, also add it to CLIENT_ONLY_TOOLS at 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 * 4 is the established cap).
  • Randomness: randomBytes/randomUUID from crypto (already imported), never Math.random.
  • Structured results are fine — return objects in result; GenericTool pretty-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

  1. Add to the CLIENT_ONLY map in app/api/v1/tools/[slug]/route.ts with a truthful reason (this exact string is shown in the UI and docs), and mirror it in CLIENT_ONLY_TOOLS in lib/tools-api-ref.ts.
  2. Write a real client component. New components go in app/tools/[slug]/tool-client.tsx today; when you add more than ~5, extract per-tool files under components/tools/<slug>.tsx and import them — one file per tool, named exports, default-export nothing.
  3. Add one line to the ToolClient switch: case "yaml-formatter": return <YamlTool />;
  4. Non-negotiables for client components (enforced by review):
    • Hooks are unconditional. Follow the existing pattern — ToolClient switches between components, each with stable hook order. Never put hooks after early returns inside the same component without keeping order stable across renders (GenericTool's if (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".

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.md per 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:

  1. Replace every TODO (grep -rn "TODO" blog/content/ip-allowlisting-guide).
  2. Write the body in Markdown only (no raw HTML — it gets stripped; see §2.5).
  3. Verify related: slugs exist: ls blog/content | grep -E "slug1|slug2".
  4. Add the three .webp images (see B4), run bash scripts/copy-blog-images.sh.
  5. 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> renders dir="rtl" containers, Arabic TOC labels ("في هذه المقالة", "مشاركة"), og:locale = ar_SA, lang-aware titles.
  • /blog lists it in the Arabic section (posts with locale: ar are grouped separately from EN).
  • Sitemap/search index it like any post.

Conventions that keep the corpus coherent:

  1. Slug = English sibling + -ar. Paired posts cross-link via related: (EN post lists the -ar slug and vice-versa). If there is no EN sibling, still use an English slug with -ar — Arabic in URLs is bad SEO hygiene.
  2. Frontmatter in Arabic: title, description, excerpt, category, tags, heroAlt, faq — all Arabic. Slugs/dates/author stay as-is.
  3. 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 run bash scripts/copy-blog-images.sh (copies to public/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; updatedAt bumps on real edits).
  • Interlink: every new post lists 2–4 related posts 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 /blog chips 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)

  1. Don't add external font/CDN links for Arabic text (CSP is locked down — font-src 'self', style-src allows 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 in next.config.mjs, re-run security-smoke.
  2. Don't bypass lib/markdown.ts or 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".
  3. Don't set dynamicParams = false on new SSG dynamic routes (that was the NoFallbackError log noise, v3.1.2). Follow blog/tools: generateStaticParams() + notFound().
  4. Don't mutate the cmdk anatomy in components/site-header.tsx when adding searchable content — search reads /api/search/index; the shouldFilter={false} prop is load-bearing (v3.1.1 crash).
  5. Don't edit shared fetch/validation helpers per tool (lib/api-helpers.ts, lib/validators.ts). Extend allow-lists inside your own case.
  6. Don't add DB tables or API routes for tools/posts — both are intentionally stateless/fs-driven.
  7. Don't rename existing slugs. Redirects require an explicit plan; content slugs have none.
  8. Don't commit generated artifacts.next/, dev.db, .env (.gitignore covers these; the zip pipeline excludes them).
  9. Don't run npm/yarn — pnpm 9.12.3 only, lockfile is frozen in CI/deploy.
  10. 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_ONLY entries, + bespoke component/case if any). pnpm build — the page returns 404 like any unknown tool. No data to clean.
  • Post: delete blog/content/<slug>/ and public/blog-img/<slug>/. Rebuild. Remove it from any related: 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.

🍪 Cookies & privacy. yas.sh uses only essential cookies to keep you signed in and remember your preferences. We do not run third-party trackers. See our cookie policy and privacy policy.
Settings