chore: remove proprietary agent framework (agents/ + astroagent) from seed
The Node content pipeline (agents/) and the astroagent in-site authoring console are a proprietary, CLI-strategy agent system that shouldn't ship in the cloneable SeedProject base. Remove both and all their wiring: - Delete agents/ (pipeline scripts, prompts, admin server, libs) + runtime - Delete astroagent config, app/.astroagent/ skill, DevConsole component; unhook it from BaseLayout.astro and astro.config.mjs preview env logic - Delete api/cli/resources.php (DB->pipeline resource bridge) - Drop agent blocks from configure.mjs, agent runtime rules from .gitignore, topic/audience from site.config.json, agent prompts from new-site.sh - Strip agent sections from AGENTS.md / README.md / api/.memory/foundation.md Kept: api/app/LLM/* (API-key/REST multi-provider layer — distinct from the CLI agents), api/cli/rebuild.php, and the mde_resources schema. Frontend build verified (npm run build → 14 pages). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DfzaSFv5okUxCCdvq5RXu1
This commit is contained in:
parent
2c969c0753
commit
e13c0b9e19
45 changed files with 24 additions and 4114 deletions
21
.gitignore
vendored
21
.gitignore
vendored
|
|
@ -1,6 +1,6 @@
|
|||
# ============================================================================
|
||||
# SeedProject base — cloneable Astro + PHP foundation
|
||||
# Tracked: engine code (app/src, api framework, agents, astroagent).
|
||||
# Tracked: engine code (app/src, api framework).
|
||||
# Ignored: secrets, dependencies, build output, and per-site runtime state.
|
||||
# ============================================================================
|
||||
|
||||
|
|
@ -23,22 +23,3 @@ app/.astro/
|
|||
|
||||
# --- logs ---
|
||||
*.log
|
||||
|
||||
# --- per-site runtime state (regenerated; dirs kept via .gitkeep) ---
|
||||
agents/logs/*
|
||||
!agents/logs/.gitkeep
|
||||
agents/drafts/*
|
||||
!agents/drafts/.gitkeep
|
||||
agents/state/*
|
||||
!agents/state/.gitkeep
|
||||
agents/calendar.json
|
||||
agents/messages.json
|
||||
agents/news-queue.json
|
||||
agents/news-scan.json
|
||||
agents/research-scan.json
|
||||
|
||||
# --- astroagent runtime workspaces ---
|
||||
.astroagent/jobs/
|
||||
.astroagent/work/
|
||||
app/.astroagent/jobs/
|
||||
app/.astroagent/work/
|
||||
|
|
|
|||
33
AGENTS.md
33
AGENTS.md
|
|
@ -29,13 +29,12 @@ on the site afterward.
|
|||
## The golden rule: one identity source
|
||||
|
||||
`site.config.json` (repo root) is the **single source of truth** for site identity —
|
||||
name, URL, description, tagline, author, social, topic, audience, timezone.
|
||||
name, URL, description, tagline, author, social, timezone.
|
||||
|
||||
- **Never hardcode** the site name, URL, author, or niche in components, pages, or engine
|
||||
- **Never hardcode** the site name, URL, or author in components, pages, or engine
|
||||
configs.
|
||||
- To change identity: edit `site.config.json`, then run `node scripts/configure.mjs`.
|
||||
That regenerates `app/src/config/site.json` (the theme reads it) and updates
|
||||
`agents/config.json` and `astroagent.config.json`.
|
||||
That regenerates `app/src/config/site.json` (the theme reads it).
|
||||
- In the Astro theme, read identity from `SITE`, `authors`, etc. exported by
|
||||
`app/src/lib/blog-data.js` (which imports `app/src/config/site.json`).
|
||||
|
||||
|
|
@ -48,7 +47,6 @@ name, URL, description, tagline, author, social, topic, audience, timezone.
|
|||
| `scripts/configure.mjs` | Stamp `site.config.json` into every engine |
|
||||
| `app/` | Astro frontend ("theme"). Build → `../public`. See `app/AGENTS.md` for coding standards. |
|
||||
| `api/` | SeedProject PHP backend, served at `/api` (see below) |
|
||||
| `agents/` | LLM agents: orchestrators + prompts + config. Catalog: `agents/AGENTS.md` (generated) |
|
||||
| `public/` | Build output (git-ignored) |
|
||||
|
||||
## Backend (`api/`)
|
||||
|
|
@ -70,24 +68,9 @@ php console db:migrate --status
|
|||
- Web-server setup (aliasing `/api` → `api/` with PHP-FPM + front-controller rewrite):
|
||||
see `api/.memory/foundation.md`.
|
||||
|
||||
## Content pipeline & authoring
|
||||
## Authoring content
|
||||
|
||||
- `agents/` runs the autonomous content system. Each agent script carries an
|
||||
`@agent-manifest` header (class, trigger, model, prompts, skills, tools, tables);
|
||||
`node agents/catalog.mjs` regenerates the catalog (`agents/AGENTS.md` +
|
||||
`agents/agents.json` — the machine manifest a clone can diff to find missing deps).
|
||||
`configure.mjs` stamps the site name/topic/audience/author into `agents/config.json`
|
||||
and regenerates the catalog; runtime state (`drafts/`, `state/`, `logs/`) is
|
||||
git-ignored and regenerates per site.
|
||||
- **⚠️ NOT yet niche-generic — do not trust the pipeline output as-is.** The prompts and
|
||||
some scripts (`agents/prompts/*.system.md`, `agents/scripts/*.mjs`)
|
||||
still contain wording from the original site (a Medellín restaurant publication) and do
|
||||
**not** read the niche from config. Running the pipeline before genericizing it will
|
||||
produce off-niche content. Fix: make those files read `config.site.{name,topic,audience}`.
|
||||
See [Status & known gaps](#status--known-gaps).
|
||||
- **astroagent** is the in-site authoring console; its identity is in
|
||||
`astroagent.config.json`.
|
||||
- To add a post by hand: create `app/src/content/blog/<slug>/index.mdx` following the
|
||||
- To add a post: create `app/src/content/blog/<slug>/index.mdx` following the
|
||||
schema in `app/src/content.config.js` (copy the sample `welcome` post as a template).
|
||||
|
||||
## Local development
|
||||
|
|
@ -106,12 +89,6 @@ Read this before relying on any subsystem:
|
|||
- ⚠️ **`/api` Foundation** — code is written and code-verified (composer, `php console`,
|
||||
autoload, graceful CLI failures), but the **live DB + HTTP round-trip is UNVERIFIED**
|
||||
(never run on a served instance with a real database).
|
||||
- ⚠️ **Content pipeline** — prompts/scripts are **still niche-specific** (Medellín
|
||||
restaurants) AND the whole pipeline is **JSON-file-based** (`config.json`, `calendar.json`,
|
||||
the `*-queue`/`*-scan` files, `state/`, `drafts/`). It is **slated for a DB-managed
|
||||
rewrite** (that state moves into MySQL via the `/api` backend). **Do NOT invest in
|
||||
genericizing the current JSON pipeline** — genericize the prompts as part of the DB
|
||||
rewrite. Treat the pipeline as niche-specific placeholder scaffolding until then.
|
||||
|
||||
## Guardrails
|
||||
|
||||
|
|
|
|||
26
README.md
26
README.md
|
|
@ -1,19 +1,16 @@
|
|||
# SeedProject base
|
||||
|
||||
A cloneable foundation for building websites — a static [Astro](https://astro.build)
|
||||
frontend with a PHP backend, an AI authoring console, and an autonomous content
|
||||
pipeline. Clone it, set one config file, and build out a new site (a lawyer's
|
||||
office, a roofing company, a niche publication) the way you'd spin up a new
|
||||
WordPress install — but faster to host and safer to run.
|
||||
frontend with a PHP backend. Clone it, set one config file, and build out a new
|
||||
site (a lawyer's office, a roofing company, a niche publication) the way you'd spin
|
||||
up a new WordPress install — but faster to host and safer to run.
|
||||
|
||||
## What's inside
|
||||
|
||||
| Path | Role | "WordPress equivalent" |
|
||||
|------|------|------------------------|
|
||||
| `app/` | Astro theme — layouts, components, content collections, sample post | theme + posts |
|
||||
| `api/` | [SeedProject](api/.memory/documentation.md) PHP framework — the dynamic backend (DB, forms, metrics, agents) | PHP core |
|
||||
| `agents/` | Autonomous content engine — research, write, review, publish | (no equivalent) |
|
||||
| `astroagent.config.json` + `app/.astroagent/` | AI authoring console — change the site in plain English | wp-admin |
|
||||
| `api/` | [SeedProject](api/.memory/documentation.md) PHP framework — the dynamic backend (DB, forms, metrics) | PHP core |
|
||||
| `site.config.json` | **Single source of site identity** — the one file you edit per site | wp-config + Site Settings |
|
||||
|
||||
## Quick start — spin up a new site
|
||||
|
|
@ -54,9 +51,8 @@ After editing it, run:
|
|||
node scripts/configure.mjs
|
||||
```
|
||||
|
||||
This stamps the values into the Astro theme (`app/src/config/site.json`),
|
||||
`agents/config.json`, and `astroagent.config.json` so all three engines
|
||||
share one identity. `new-site.sh` runs it for you.
|
||||
This stamps the values into the Astro theme (`app/src/config/site.json`).
|
||||
`new-site.sh` runs it for you.
|
||||
|
||||
## Backend (`api/`)
|
||||
|
||||
|
|
@ -76,13 +72,10 @@ The `api/` directory is the SeedProject PHP framework, meant to be served at
|
|||
> round-trip. Design + implementation plan: [`api/.memory/foundation.md`](api/.memory/foundation.md)
|
||||
> and [`api/.memory/foundation-plan.md`](api/.memory/foundation-plan.md).
|
||||
|
||||
## Content pipeline & authoring
|
||||
## Authoring content
|
||||
|
||||
- **`agents/`** — autonomous research/write/review/publish scripts driven
|
||||
by `agents/config.json` (populated from `site.config.json`). Runtime
|
||||
state (`drafts/`, `state/`, `logs/`, queues) is git-ignored and regenerates per site.
|
||||
- **astroagent** — the in-site console for making changes in plain English; its
|
||||
identity comes from `astroagent.config.json`.
|
||||
Add a post by creating `app/src/content/blog/<slug>/index.mdx` following the
|
||||
schema in `app/src/content.config.js` (copy the sample `welcome` post as a template).
|
||||
|
||||
## Directory layout
|
||||
|
||||
|
|
@ -94,7 +87,6 @@ my-site/
|
|||
│ └── configure.mjs ← stamp site.config.json into every engine
|
||||
├── app/ ← Astro frontend (build → ../public)
|
||||
├── api/ ← SeedProject PHP backend (served at /api)
|
||||
├── agents/ ← autonomous content engine
|
||||
└── public/ ← build output (git-ignored)
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -1,32 +0,0 @@
|
|||
# Agent catalog
|
||||
|
||||
> **GENERATED — do not edit.** Regenerate with `node agents/catalog.mjs`
|
||||
> (source of truth: the `@agent-manifest` header in each script).
|
||||
|
||||
An **agent** = an LLM orchestrator script + its role prompt (`prompt:`) + its skills
|
||||
(MCP servers, `skills:`) + its tools (`api/cli/*.php` bridges and `lib/*.mjs`, `tools:`).
|
||||
To port an agent to another SeedProject clone, satisfy its manifest: copy its prompts, ensure
|
||||
its tools exist, migrate each table in `reads:`/`writes:`, configure its MCP servers.
|
||||
`agents.json` is the machine-readable version — diff it against a clone to find gaps.
|
||||
|
||||
## content
|
||||
|
||||
Cron/manual-triggered producers of gated drafts (events auto-publishes).
|
||||
|
||||
| agent | trigger | model | skills | tools | reads → writes | description |
|
||||
|---|---|---|---|---|---|---|
|
||||
| **news-radar** ([agents/scripts/news-radar.mjs](../agents/scripts/news-radar.mjs)) | manual / cron-capable (maintains news-queue.json for the writer) | research=claude-opus-4-8 | — | lib/claude.mjs | — → — | Discover timely niche news and maintain news-queue.json; the daily writer drains it first. |
|
||||
| **research** ([agents/scripts/research.mjs](../agents/scripts/research.mjs)) | manual (auto-invoked by writer when the evergreen backlog runs low) | research=claude-opus-4-8 | — | lib/claude.mjs, lib/calendar.mjs | — → — | Top up the evergreen backlog in calendar.json (additive, deduped); timely news is the news radar's job. |
|
||||
| **reviser** ([agents/scripts/revise.mjs](../agents/scripts/revise.mjs)) | manual <slug> (auto-invoked when the SEO gate fails and autoRevise is on) | writer=claude-sonnet-4-6 | — | lib/claude.mjs | — → — | Feed the SEO audit's fixes back to the writer and re-audit until the draft passes or attempts run out. |
|
||||
| **seo-review** ([agents/scripts/seo-review.mjs](../agents/scripts/seo-review.mjs)) | manual <slug> (auto-invoked by writer/approve gates) | reviewer=claude-opus-4-8 | — | lib/claude.mjs | — → — | Audit one article for EEAT / spam-policy / on-page SEO / AEO / readability → seo-review.json. |
|
||||
| **writer** ([agents/scripts/write-daily.mjs](../agents/scripts/write-daily.mjs)) | cron:run.sh write-daily.mjs(daily) | writer=claude-sonnet-4-6 | mcp__claude_ai_Higgsfield | lib/claude.mjs, lib/calendar.mjs | — → — | Draft one article (MDX + cover + sources) — today's news first, else next evergreen calendar topic. |
|
||||
|
||||
## plumbing
|
||||
|
||||
Deterministic helpers — not agents (no LLM), catalogued for completeness.
|
||||
|
||||
| agent | trigger | model | skills | tools | reads → writes | description |
|
||||
|---|---|---|---|---|---|---|
|
||||
| **approve** ([agents/scripts/approve.mjs](../agents/scripts/approve.mjs)) | manual <slug> [--force] | - | — | lib/publish.mjs | — → — | Approve a draft: SEO gate, then promote it into the blog, build, go live. No LLM. |
|
||||
| **list-drafts** ([agents/scripts/list-drafts.mjs](../agents/scripts/list-drafts.mjs)) | manual | - | — | — | — → — | List pending drafts awaiting review. No LLM. |
|
||||
| **publish-tick** ([agents/scripts/publish-tick.mjs](../agents/scripts/publish-tick.mjs)) | cron:run.sh publish-tick.mjs(15m) | - | — | lib/publish.mjs | — → — | Once per day after the morning floor, publish ONE eligible draft (SEO-gated) with a randomized earlier-today timestamp. No LLM. |
|
||||
134
agents/README.md
134
agents/README.md
|
|
@ -1,134 +0,0 @@
|
|||
# Comiida Content Pipeline
|
||||
|
||||
An agentic pipeline that plans a 3-month editorial calendar and drafts one SEO/EEAT
|
||||
restaurant article per day (English, for Medellín expats/tourists) with an AI cover image,
|
||||
queued for human approval before going live on the Astro site.
|
||||
|
||||
Engine: **headless Claude Code** (`claude -p`) + **Higgsfield** image MCP (authenticated at
|
||||
the claude.ai level — reachable headless, confirmed). See the design at
|
||||
`/root/.claude/plans/init-elegant-giraffe.md`.
|
||||
|
||||
## Layout
|
||||
- `config.json` — site, author, models, image, editorial mix/word-counts.
|
||||
- `.env` — optional secrets (git-ignored). None required today (Higgsfield auth is global).
|
||||
- `prompts/` — system prompts: `research.system.md`, `writer.system.md`, `image.md`.
|
||||
- `scripts/` — `research.mjs`, `write-daily.mjs`, `list-drafts.mjs`, `approve.mjs`, `lib/`.
|
||||
- `calendar.json` — the generated editorial calendar (created by `research.mjs`).
|
||||
- `drafts/<slug>/` — pending drafts (`index.mdx`, `cover.jpg`, `sources.json`).
|
||||
- `run.sh` — cron-safe wrapper (sets PATH, loads `.env`).
|
||||
|
||||
## Setup (one-time)
|
||||
1. Add the real author to `app/src/lib/blog-data.js` and set `author.slug`/`name` in
|
||||
`config.json` to match (EEAT requires a real byline).
|
||||
2. Image generation needs no key — it uses the Higgsfield claude.ai MCP. Check credits with
|
||||
the `balance` tool if generations start failing.
|
||||
|
||||
## Usage
|
||||
```bash
|
||||
# Plan ~3 months (writes calendar.json). Default days = config.editorial.calendarDays.
|
||||
./run.sh research.mjs [days]
|
||||
|
||||
# Draft the next due article into drafts/<slug>/ (auto-runs the SEO audit after).
|
||||
./run.sh write-daily.mjs [slug]
|
||||
|
||||
# SEO Specialist audit (EEAT / spam-policy / on-page / AEO / readability).
|
||||
# Writes <postDir>/seo-review.json. Default audits the draft; --published audits the live post.
|
||||
./run.sh seo-review.mjs <slug> [--published]
|
||||
|
||||
# Auto-revise a failing draft until it passes the SEO gate (or maxReviseAttempts).
|
||||
./run.sh revise.mjs <slug>
|
||||
|
||||
# News radar — discover timely Medellín food news/events → news-queue.json.
|
||||
# (also runs automatically inside write-daily, once per day)
|
||||
./run.sh news-radar.mjs
|
||||
|
||||
# Research — top up the evergreen backlog (additive merge into calendar.json).
|
||||
# (also runs automatically inside write-daily when the planned backlog is low)
|
||||
./run.sh research.mjs [count]
|
||||
|
||||
# Review the queue, then publish one manually (SEO-gated; --force overrides).
|
||||
node scripts/list-drafts.mjs
|
||||
./run.sh approve.mjs <slug> [--force] # moves into the blog, builds, goes live
|
||||
|
||||
# Randomized auto-publisher (cron). Publishes ONE SEO-passing draft per day at a random time.
|
||||
./run.sh publish-tick.mjs [--now] # --now ignores the time gate (for testing)
|
||||
```
|
||||
|
||||
## Scheduling (managed in the aaPanel Cron UI; server clock is UTC)
|
||||
Only **two** jobs are scheduled — research is now demand-driven (see below), not a cron.
|
||||
```
|
||||
0 11 * * * run.sh write-daily.mjs # daily 11:00 UTC (06:00 Bogotá) — news scan + refill + draft + SEO audit
|
||||
*/15 * * * * run.sh publish-tick.mjs # every 15m — publish 1 passing draft in the morning (random timestamp)
|
||||
```
|
||||
(aaPanel runs these in UTC; `publish-tick` computes its Bogotá floor time internally.)
|
||||
|
||||
## Evergreen backlog is demand-driven (no monthly cron)
|
||||
`research.mjs` is **additive** — it proposes evergreen topics and merges new ones into
|
||||
`calendar.json` (deduped, dates auto-assigned). `write-daily` **auto-refills** when the
|
||||
`planned` backlog drops below `editorial.refillThreshold` (guarded once/day), so the calendar
|
||||
stays stocked without thinking in "months." Run manually any time: `./run.sh research.mjs [count]`.
|
||||
|
||||
## Concurrency safety
|
||||
All `calendar.json` writes go through `lib/calendar.mjs` — an exclusive **lockfile** plus
|
||||
atomic read-modify-write (`updateEntry`/`addEntry`/`mergeEntries`). This prevents the
|
||||
lost-update race where a long `write-daily` run could clobber concurrent `publish-tick` /
|
||||
research writes.
|
||||
|
||||
## Auto-publish: morning go-live, randomized timestamp (`config.json` → `publish`)
|
||||
Posts go **live in the morning** (not a random hour all day), but each is stamped with a
|
||||
**random earlier-today timestamp** so published times aren't a fixed-minute fingerprint:
|
||||
- **06:00** `write-daily.mjs` drafts the day's topic, runs the SEO audit, and auto-revises.
|
||||
- **`publish-tick.mjs`** (every 15 min) publishes **one** SEO-passing draft on the first tick
|
||||
after the **`publish.notBefore`** floor (default `07:00` Bogotá — a buffer so the morning's
|
||||
fresh/news article is the one that goes live). The post's `date` is set to a random time
|
||||
between midnight and the publish moment (`randomTimestampTodaySoFar` — varied but never
|
||||
future). News drafts fast-track ahead of evergreen. One publish/day; idempotent; SEO-failing
|
||||
drafts wait for manual review. State in `state/publish-YYYY-MM-DD.json`.
|
||||
- Set `publish.auto=false` to disable auto-publish and use manual `approve.mjs`.
|
||||
|
||||
## Admin dashboard (read-only)
|
||||
A small Node service (`admin/server.mjs`, no deps) shows the week's scheduled topics, the
|
||||
draft queue (with cover + preview), and the full calendar.
|
||||
- Served at **`https://comiida.com/admin?key=<ADMIN_TOKEN>`** (Apache proxies `/admin` →
|
||||
`127.0.0.1:3010`; token is in `.env`). A valid `?key=` sets a 30-day cookie.
|
||||
- Kept alive by **supervisor**: program `comiidaAdmin`
|
||||
(`/www/server/panel/plugin/supervisor/profile/comiidaAdmin.ini`).
|
||||
Manage with: `supervisorctl -c /etc/supervisor/supervisord.conf {status|restart|stop} comiidaAdmin:comiidaAdmin_00`.
|
||||
- Read-only by design — approving still happens via `./run.sh approve.mjs <slug>`.
|
||||
|
||||
## Daily news radar (timely news/events)
|
||||
`scripts/news-radar.mjs` + `prompts/news-radar.system.md` discover **time-sensitive** Medellín
|
||||
food items (openings/closings, festivals/events, awards, press) via WebSearch → `news-queue.json`
|
||||
(deduped vs published + calendar; stale fresh items pruned past `news.recencyDays`).
|
||||
- Runs **automatically at the start of `write-daily.mjs`**, guarded once/day via
|
||||
`state/news-YYYY-MM-DD.json` (so the hourly test cron doesn't re-scan).
|
||||
- **News-first selection:** the writer prefers the freshest queue item (by `freshnessScore`),
|
||||
injects it into `calendar.json` as a `news:true` entry, and drafts it; on a quiet day it falls
|
||||
back to the next evergreen calendar topic.
|
||||
- **Fast-track publishing:** `publish-tick.mjs` releases `news:true` drafts before evergreen
|
||||
ones (still 1/day, still SEO-gated, still randomized timestamp) — so timely pieces go live the
|
||||
same day.
|
||||
- Config: `config.json → news { enabled, recencyDays, maxPerScan }`.
|
||||
|
||||
## Auto-revise loop
|
||||
When a fresh draft fails the SEO gate, `write-daily` automatically runs `scripts/revise.mjs`:
|
||||
it feeds the audit's `blocking` + `topFixes` back to the writer (`prompts/reviser.system.md`),
|
||||
which edits the draft in place (finding real sources via WebSearch for uncited claims — never
|
||||
fabricating), then **re-audits**. Repeats until `pass` or `seo.maxReviseAttempts` (config).
|
||||
Config: `seo.autoRevise`, `seo.maxReviseAttempts`. Verified: a draft at fail/82 → pass/90 in one pass.
|
||||
|
||||
## SEO Specialist agent
|
||||
`scripts/seo-review.mjs` + `prompts/seo-review.system.md` audit an article across five
|
||||
weighted dimensions — EEAT (30), spam-policy compliance (20), on-page SEO (25), AEO/answer-
|
||||
engine (15), readability (10) — and write `seo-review.json` with per-dimension scores, a
|
||||
verdict (`pass`/`revise`/`fail`), blocking issues, and a ranked fix list.
|
||||
- Runs **automatically** at the end of `write-daily.mjs` (verdict also stored on the calendar
|
||||
entry).
|
||||
- **Gates `approve.mjs`**: approval is blocked when `verdict==="fail"` or `overall <
|
||||
seo.minScore` (config; default 85). Override with `approve.mjs <slug> --force`.
|
||||
- Verdict + scores + fixes show in the `/admin` dashboard (draft cards and the draft preview).
|
||||
- Reviewer model: `models.reviewer` in `config.json`.
|
||||
|
||||
## EEAT guardrails (enforced in prompts)
|
||||
Data-driven only; every claim cited; no fabricated first-person dining; real author byline;
|
||||
honest "AI-generated illustration" image credit.
|
||||
|
|
@ -1,456 +0,0 @@
|
|||
#!/usr/bin/env node
|
||||
// Read-only admin dashboard for the Comiida content pipeline.
|
||||
// Serves at /admin (proxied by Apache). Auth via secret token (?key= or cookie).
|
||||
// Node built-ins only — no external deps.
|
||||
import { createServer } from "node:http";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { readFileSync, writeFileSync, existsSync, readdirSync } from "node:fs";
|
||||
import { dirname, resolve, join, extname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { readFrontmatter, fmString, fmArray, loadJson, todayInTz, addDays, slugify } from "../scripts/lib/util.mjs";
|
||||
import { addEntry } from "../scripts/lib/calendar.mjs";
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const PIPELINE = resolve(HERE, "..");
|
||||
|
||||
// --- env (.env) ---
|
||||
function loadEnv() {
|
||||
const p = join(PIPELINE, ".env");
|
||||
if (!existsSync(p)) return;
|
||||
for (const line of readFileSync(p, "utf8").split("\n")) {
|
||||
const m = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*)\s*$/);
|
||||
if (m && !(m[1] in process.env)) process.env[m[1]] = m[2];
|
||||
}
|
||||
}
|
||||
loadEnv();
|
||||
|
||||
const cfg = loadJson(join(PIPELINE, "config.json"));
|
||||
const root = cfg.paths.projectRoot;
|
||||
const TOKEN = process.env.ADMIN_TOKEN || "";
|
||||
const PORT = parseInt(process.env.ADMIN_PORT, 10) || 3010;
|
||||
|
||||
if (!TOKEN) {
|
||||
console.error("[admin] ADMIN_TOKEN not set in .env — refusing to start.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// --- data gathering ---
|
||||
const draftsDir = () => join(root, cfg.paths.draftsDir);
|
||||
const blogDir = () => join(root, cfg.paths.blogContentDir);
|
||||
|
||||
function calendar() {
|
||||
const p = join(root, cfg.paths.calendar);
|
||||
return existsSync(p) ? loadJson(p) : [];
|
||||
}
|
||||
function isPublished(slug) {
|
||||
return existsSync(join(blogDir(), slug, "index.mdx"));
|
||||
}
|
||||
function draftSlugs() {
|
||||
const d = draftsDir();
|
||||
return existsSync(d)
|
||||
? readdirSync(d, { withFileTypes: true }).filter((x) => x.isDirectory()).map((x) => x.name)
|
||||
: [];
|
||||
}
|
||||
function draftMeta(slug) {
|
||||
const mdx = join(draftsDir(), slug, "index.mdx");
|
||||
if (!existsSync(mdx)) return null;
|
||||
const fm = readFrontmatter(readFileSync(mdx, "utf8"));
|
||||
let seo = null;
|
||||
const seoPath = join(draftsDir(), slug, "seo-review.json");
|
||||
if (existsSync(seoPath)) {
|
||||
try {
|
||||
seo = JSON.parse(readFileSync(seoPath, "utf8"));
|
||||
} catch {
|
||||
seo = null;
|
||||
}
|
||||
}
|
||||
return {
|
||||
slug,
|
||||
title: fmString(fm, "title") || slug,
|
||||
category: fmString(fm, "category") || "",
|
||||
tags: fmArray(fm, "tags"),
|
||||
date: fmString(fm, "date") || "",
|
||||
hasCover: existsSync(join(draftsDir(), slug, "cover.jpg")),
|
||||
seo,
|
||||
};
|
||||
}
|
||||
|
||||
// --- helpers ---
|
||||
const esc = (s) =>
|
||||
String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
|
||||
|
||||
const STATUS_COLORS = {
|
||||
planned: "#8a8f98",
|
||||
drafting: "#d98324",
|
||||
drafted: "#2f6feb",
|
||||
"drafted-no-image": "#b58900",
|
||||
"draft-failed": "#cb2431",
|
||||
published: "#1a7f37",
|
||||
};
|
||||
const badge = (status) =>
|
||||
`<span style="background:${STATUS_COLORS[status] || "#666"};color:#fff;border-radius:999px;padding:2px 10px;font-size:12px;white-space:nowrap">${esc(status)}</span>`;
|
||||
|
||||
const newsBadge = `<span style="background:#b5179e;color:#fff;border-radius:999px;padding:2px 10px;font-size:12px">NEWS</span>`;
|
||||
const suggestedBadge = `<span style="background:#7048e8;color:#fff;border-radius:999px;padding:2px 10px;font-size:12px">SUGGESTED</span>`;
|
||||
function freshNewsCount() {
|
||||
const p = join(root, "agents", "news-queue.json");
|
||||
if (!existsSync(p)) return 0;
|
||||
try {
|
||||
return JSON.parse(readFileSync(p, "utf8")).filter((i) => i.status === "fresh").length;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
const SEO_COLORS = { pass: "#1a7f37", revise: "#d98324", fail: "#cb2431" };
|
||||
const seoBadge = (seo) =>
|
||||
seo
|
||||
? `<span style="background:${SEO_COLORS[seo.verdict] || "#666"};color:#fff;border-radius:999px;padding:2px 10px;font-size:12px;white-space:nowrap">SEO ${esc(seo.verdict)} ${esc(seo.overall)}</span>`
|
||||
: `<span style="background:#aaa;color:#fff;border-radius:999px;padding:2px 10px;font-size:12px">SEO —</span>`;
|
||||
|
||||
const messagesPath = () => join(root, "agents", "messages.json");
|
||||
function messages() {
|
||||
const p = messagesPath();
|
||||
if (!existsSync(p)) return [];
|
||||
try {
|
||||
return JSON.parse(readFileSync(p, "utf8"));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function authed(req) {
|
||||
const url = new URL(req.url, "http://x");
|
||||
const qkey = url.searchParams.get("key");
|
||||
if (qkey && qkey === TOKEN) return { ok: true, setCookie: true };
|
||||
const cookie = (req.headers.cookie || "").match(/(?:^|;\s*)admin_key=([^;]+)/);
|
||||
if (cookie && decodeURIComponent(cookie[1]) === TOKEN) return { ok: true };
|
||||
return { ok: false };
|
||||
}
|
||||
|
||||
// --- pages ---
|
||||
function dashboardHtml() {
|
||||
const today = todayInTz(cfg.editorial.timezone);
|
||||
const weekEnd = addDays(today, 6);
|
||||
const cal = calendar().slice().sort((a, b) => (a.date || "").localeCompare(b.date || ""));
|
||||
const drafts = draftSlugs().map(draftMeta).filter(Boolean);
|
||||
|
||||
const inWeek = cal.filter((e) => e.date >= today && e.date <= weekEnd);
|
||||
const counts = cal.reduce((m, e) => ((m[e.status] = (m[e.status] || 0) + 1), m), {});
|
||||
const newsSlugs = new Set(cal.filter((e) => e.news).map((e) => e.slug));
|
||||
drafts.forEach((d) => (d.news = newsSlugs.has(d.slug)));
|
||||
|
||||
const row = (e) => `
|
||||
<tr>
|
||||
<td style="white-space:nowrap;color:#555">${esc(e.date)}</td>
|
||||
<td><strong>${esc(e.workingTitle || e.slug)}</strong> ${e.suggested ? suggestedBadge : ""}${e.news ? " " + newsBadge : ""}<br><span style="color:#888;font-size:12px">${esc(e.primaryKeyword || "")}</span></td>
|
||||
<td><span style="color:#555;font-size:13px">${esc(e.type)}</span></td>
|
||||
<td>${badge(isPublished(e.slug) ? "published" : e.status)}</td>
|
||||
<td>${
|
||||
existsSync(join(draftsDir(), e.slug, "index.mdx"))
|
||||
? `<a href="/admin/draft/${esc(e.slug)}">preview</a>`
|
||||
: isPublished(e.slug)
|
||||
? `<a href="${esc(cfg.site.url)}/blog/${esc(e.slug)}/" target="_blank">live ↗</a>`
|
||||
: "—"
|
||||
}</td>
|
||||
</tr>`;
|
||||
|
||||
const draftCards = drafts.length
|
||||
? drafts
|
||||
.map(
|
||||
(d) => `
|
||||
<div style="border:1px solid #e2e4e8;border-radius:10px;padding:14px;display:flex;gap:14px;align-items:center">
|
||||
${d.hasCover ? `<img src="/admin/cover/${esc(d.slug)}" style="width:96px;height:64px;object-fit:cover;border-radius:6px;flex:none">` : `<div style="width:96px;height:64px;background:#f0f1f3;border-radius:6px;flex:none"></div>`}
|
||||
<div style="flex:1">
|
||||
<div style="display:flex;align-items:center;gap:8px"><strong>${esc(d.title)}</strong> ${d.news ? newsBadge : ""} ${seoBadge(d.seo)}</div>
|
||||
<div style="color:#888;font-size:12px">${esc(d.date)} · ${esc(d.category)} · ${esc(d.tags.join(", "))}</div>
|
||||
${d.seo?.blocking?.length ? `<div style="color:#cb2431;font-size:12px;margin-top:4px">⚠ ${esc(d.seo.blocking.length)} blocking: ${esc(d.seo.blocking[0])}${d.seo.blocking.length > 1 ? " …" : ""}</div>` : ""}
|
||||
<div style="margin-top:6px"><a href="/admin/draft/${esc(d.slug)}">preview</a> · <code style="font-size:12px">approve.mjs ${esc(d.slug)}</code></div>
|
||||
</div>
|
||||
</div>`
|
||||
)
|
||||
.join("")
|
||||
: `<p style="color:#888">No drafts awaiting review.</p>`;
|
||||
|
||||
const countPills = Object.entries(counts)
|
||||
.map(([k, v]) => `${badge(k)} <span style="color:#555">${v}</span>`)
|
||||
.join(" ");
|
||||
|
||||
const msgs = messages().slice().reverse();
|
||||
const msgHtml = msgs.length
|
||||
? msgs
|
||||
.map(
|
||||
(m) => `
|
||||
<div style="border:1px solid #e2e4e8;border-radius:10px;padding:12px 14px;position:relative">
|
||||
<button onclick="deleteMsg('${esc(m.id || m.at)}')" title="Delete message" style="position:absolute;top:10px;right:10px;border:0;background:#f0f1f3;border-radius:6px;padding:3px 9px;cursor:pointer;color:#999;line-height:1">✕</button>
|
||||
<div style="font-size:13px;padding-right:36px"><strong>${esc(m.name)}</strong> <span style="color:#888"><${esc(m.email)}></span> <span style="color:#aaa">· ${esc((m.at || "").slice(0, 16).replace("T", " "))}</span></div>
|
||||
<div style="margin-top:6px;white-space:pre-wrap">${esc(m.message)}</div>
|
||||
</div>`
|
||||
)
|
||||
.join("")
|
||||
: `<p class="muted">No messages yet.</p>`;
|
||||
|
||||
return `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Comiida — Content Admin</title>
|
||||
<style>
|
||||
body{font:15px/1.5 -apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;color:#1a1a1a;margin:0;background:#fafbfc}
|
||||
.wrap{max-width:960px;margin:0 auto;padding:28px 20px 60px}
|
||||
h1{font-size:22px;margin:0 0 4px} h2{font-size:16px;margin:30px 0 12px}
|
||||
table{width:100%;border-collapse:collapse} td,th{text-align:left;padding:10px 8px;border-bottom:1px solid #eceef1;vertical-align:top}
|
||||
th{font-size:12px;text-transform:uppercase;letter-spacing:.04em;color:#888}
|
||||
a{color:#2f6feb;text-decoration:none} a:hover{text-decoration:underline}
|
||||
.muted{color:#888;font-size:13px} .grid{display:flex;flex-direction:column;gap:10px}
|
||||
.btn{display:inline-block;background:#7048e8;color:#fff;border:0;border-radius:8px;padding:9px 16px;font-size:14px;font-weight:600;cursor:pointer}
|
||||
.btn:hover{opacity:.92}
|
||||
.overlay{position:fixed;inset:0;background:rgba(0,0,0,.4);display:none;align-items:flex-start;justify-content:center;z-index:10}
|
||||
.overlay.open{display:flex}
|
||||
.modal{background:#fff;border-radius:12px;max-width:520px;width:calc(100% - 32px);margin-top:8vh;padding:22px;box-shadow:0 12px 40px rgba(0,0,0,.2)}
|
||||
.modal h3{margin:0 0 4px;font-size:18px} .modal label{display:block;font-size:13px;font-weight:600;margin:14px 0 5px}
|
||||
.modal input,.modal textarea{width:100%;box-sizing:border-box;border:1px solid #d6d9de;border-radius:8px;padding:9px 11px;font-size:14px;font-family:inherit}
|
||||
.modal .row2{display:flex;gap:10px;justify-content:flex-end;margin-top:18px}
|
||||
.modal .cancel{background:#eceef1;color:#333}
|
||||
</style></head><body><div class="wrap">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;gap:12px">
|
||||
<h1>Comiida — Content Admin</h1>
|
||||
<button class="btn" onclick="document.getElementById('suggestOverlay').classList.add('open')">+ Suggest an article</button>
|
||||
</div>
|
||||
<div class="muted">Today ${esc(today)} (${esc(cfg.editorial.timezone)}) · ${countPills} · 📰 news queue: ${freshNewsCount()} fresh</div>
|
||||
|
||||
<div class="overlay" id="suggestOverlay">
|
||||
<div class="modal">
|
||||
<h3>Suggest an article</h3>
|
||||
<div class="muted">Added as a top-priority topic — the writer researches & drafts it next.</div>
|
||||
<form id="suggestForm">
|
||||
<label>Title *</label>
|
||||
<input name="title" required maxlength="160" placeholder="e.g. The best late-night eats in Laureles" />
|
||||
<label>Brief description</label>
|
||||
<textarea name="description" rows="3" maxlength="600" placeholder="What angle / what to cover?"></textarea>
|
||||
<label>Prompt / instructions for the agent (optional)</label>
|
||||
<textarea name="instructions" rows="3" maxlength="4000" placeholder="e.g. Focus on vegan spots in Laureles; research the new Provenza openings; compare prices and hours"></textarea>
|
||||
<label>Draft (optional)</label>
|
||||
<textarea name="draft" rows="6" maxlength="20000" placeholder="Paste a draft or rough notes — the agent will build on, fact-check, and expand it."></textarea>
|
||||
<label>Source link (optional)</label>
|
||||
<input name="source" type="url" placeholder="https://… where you saw the idea" />
|
||||
<div class="row2">
|
||||
<button type="button" class="btn cancel" onclick="document.getElementById('suggestOverlay').classList.remove('open')">Cancel</button>
|
||||
<button type="submit" class="btn" id="suggestSubmit">Add suggestion</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
document.getElementById('suggestForm').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const btn = document.getElementById('suggestSubmit');
|
||||
const f = e.target;
|
||||
const payload = { title: f.title.value.trim(), description: f.description.value.trim(), instructions: f.instructions.value.trim(), draft: f.draft.value.trim(), source: f.source.value.trim() };
|
||||
if (!payload.title) return;
|
||||
btn.disabled = true; btn.textContent = 'Adding…';
|
||||
try {
|
||||
const r = await fetch('/admin/suggest', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) });
|
||||
if (!r.ok) throw new Error((await r.json()).error || 'failed');
|
||||
location.reload();
|
||||
} catch (err) {
|
||||
btn.disabled = false; btn.textContent = 'Add suggestion';
|
||||
alert('Could not add suggestion: ' + err.message);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<h2>📬 Messages (${msgs.length})</h2>
|
||||
<div class="grid">${msgHtml}</div>
|
||||
<script>
|
||||
window.deleteMsg = async (id) => {
|
||||
if (!confirm('Delete this message?')) return;
|
||||
try {
|
||||
const r = await fetch('/admin/message-delete', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id }) });
|
||||
if (!r.ok) throw new Error();
|
||||
location.reload();
|
||||
} catch { alert('Could not delete the message.'); }
|
||||
};
|
||||
</script>
|
||||
|
||||
<h2>This week (${esc(today)} → ${esc(weekEnd)})</h2>
|
||||
${inWeek.length ? `<table><tr><th>Date</th><th>Topic</th><th>Type</th><th>Status</th><th></th></tr>${inWeek.map(row).join("")}</table>` : `<p class="muted">Nothing scheduled this week. Run research.mjs to plan more.</p>`}
|
||||
|
||||
<h2>Drafts awaiting review (${drafts.length})</h2>
|
||||
<div class="grid">${draftCards}</div>
|
||||
|
||||
<h2>Full calendar (${cal.length})</h2>
|
||||
<table><tr><th>Date</th><th>Topic</th><th>Type</th><th>Status</th><th></th></tr>${cal.map(row).join("")}</table>
|
||||
</div></body></html>`;
|
||||
}
|
||||
|
||||
function draftDetailHtml(slug) {
|
||||
const dir = join(draftsDir(), slug);
|
||||
const mdxPath = join(dir, "index.mdx");
|
||||
if (!existsSync(mdxPath)) return null;
|
||||
const raw = readFileSync(mdxPath, "utf8");
|
||||
const fm = readFrontmatter(raw);
|
||||
const body = raw.replace(/^---\n[\s\S]*?\n---\n?/, "");
|
||||
const sources = existsSync(join(dir, "sources.json"))
|
||||
? readFileSync(join(dir, "sources.json"), "utf8")
|
||||
: "(none)";
|
||||
let seo = null;
|
||||
if (existsSync(join(dir, "seo-review.json"))) {
|
||||
try {
|
||||
seo = JSON.parse(readFileSync(join(dir, "seo-review.json"), "utf8"));
|
||||
} catch {
|
||||
seo = null;
|
||||
}
|
||||
}
|
||||
const seoSection = seo
|
||||
? `<h2>SEO audit ${seoBadge(seo)}</h2>
|
||||
<p class="muted">${esc(seo.summary || "")}</p>
|
||||
<ul>${Object.entries(seo.dimensions || {})
|
||||
.map(([k, v]) => `<li><strong>${esc(k)}</strong>: ${esc(v.score)}${v.issues?.length ? " — " + esc(v.issues.join("; ")) : ""}</li>`)
|
||||
.join("")}</ul>
|
||||
${seo.blocking?.length ? `<p style="color:#cb2431"><strong>Blocking:</strong></p><ul>${seo.blocking.map((b) => `<li>${esc(b)}</li>`).join("")}</ul>` : ""}
|
||||
${seo.topFixes?.length ? `<p><strong>Top fixes:</strong></p><ul>${seo.topFixes.map((f) => `<li>[${esc(f.severity)}/${esc(f.area)}] ${esc(f.fix)}</li>`).join("")}</ul>` : ""}`
|
||||
: `<h2>SEO audit ${seoBadge(null)}</h2><p class="muted">No audit yet. Run <code>./run.sh write-daily.mjs</code> (auto-audits) or <code>node scripts/seo-review.mjs ${esc(slug)}</code>.</p>`;
|
||||
const hasCover = existsSync(join(dir, "cover.jpg"));
|
||||
return `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>${esc(fmString(fm, "title") || slug)} — draft</title>
|
||||
<style>
|
||||
body{font:15px/1.6 -apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;color:#1a1a1a;margin:0;background:#fafbfc}
|
||||
.wrap{max-width:760px;margin:0 auto;padding:24px 20px 60px}
|
||||
pre{background:#f4f5f7;border:1px solid #e6e8eb;border-radius:8px;padding:12px;overflow:auto;font-size:12.5px;white-space:pre-wrap}
|
||||
a{color:#2f6feb} img{max-width:100%;border-radius:8px}
|
||||
h1{font-size:21px}
|
||||
</style></head><body><div class="wrap">
|
||||
<p><a href="/admin">← back to dashboard</a></p>
|
||||
${hasCover ? `<img src="/admin/cover/${esc(slug)}">` : ""}
|
||||
${seoSection}
|
||||
<h2>Frontmatter</h2><pre>${esc(fm)}</pre>
|
||||
<h2>Body (raw MDX)</h2><pre>${esc(body)}</pre>
|
||||
<h2>sources.json</h2><pre>${esc(sources)}</pre>
|
||||
<p class="muted">To publish: <code>./run.sh approve.mjs ${esc(slug)}</code></p>
|
||||
</div></body></html>`;
|
||||
}
|
||||
|
||||
// --- server ---
|
||||
const send = (res, code, body, type = "text/html; charset=utf-8", extra = {}) => {
|
||||
res.writeHead(code, { "content-type": type, "cache-control": "no-store", ...extra });
|
||||
res.end(body);
|
||||
};
|
||||
|
||||
const server = createServer((req, res) => {
|
||||
const url = new URL(req.url, "http://x");
|
||||
const path = url.pathname.replace(/\/+$/, "") || "/admin";
|
||||
|
||||
// PUBLIC (no auth): contact form submission → saved to messages.json, viewed in /admin.
|
||||
if (req.method === "POST" && path === "/contact-submit") {
|
||||
let body = "";
|
||||
req.on("data", (c) => {
|
||||
body += c;
|
||||
if (body.length > 20000) req.destroy(); // basic size guard
|
||||
});
|
||||
req.on("end", () => {
|
||||
try {
|
||||
const d = JSON.parse(body || "{}");
|
||||
if (d.website) return send(res, 200, JSON.stringify({ ok: true }), "application/json"); // honeypot → silently drop
|
||||
const name = String(d.name || "").trim().slice(0, 120);
|
||||
const email = String(d.email || "").trim().slice(0, 160);
|
||||
const message = String(d.message || "").trim().slice(0, 4000);
|
||||
if (!name || !message || !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) {
|
||||
return send(res, 400, JSON.stringify({ error: "Please fill in your name, a valid email, and a message." }), "application/json");
|
||||
}
|
||||
const arr = messages();
|
||||
arr.push({ id: randomUUID(), at: new Date().toISOString(), name, email, message });
|
||||
writeFileSync(messagesPath(), JSON.stringify(arr, null, 2));
|
||||
send(res, 200, JSON.stringify({ ok: true }), "application/json");
|
||||
} catch (e) {
|
||||
send(res, 500, JSON.stringify({ error: e.message }), "application/json");
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const auth = authed(req);
|
||||
if (!auth.ok) {
|
||||
return send(res, 401, "<h1>401</h1><p>Add ?key=YOUR_TOKEN to the URL.</p>");
|
||||
}
|
||||
const cookieHeader = auth.setCookie
|
||||
? { "set-cookie": `admin_key=${encodeURIComponent(TOKEN)}; Path=/admin; HttpOnly; SameSite=Lax; Max-Age=2592000` }
|
||||
: {};
|
||||
|
||||
// suggest an article (POST) — inject a top-priority calendar entry
|
||||
if (req.method === "POST" && path === "/admin/suggest") {
|
||||
let body = "";
|
||||
req.on("data", (c) => (body += c));
|
||||
req.on("end", async () => {
|
||||
try {
|
||||
const data = JSON.parse(body || "{}");
|
||||
const title = String(data.title || "").trim();
|
||||
if (!title) return send(res, 400, JSON.stringify({ error: "Title is required" }), "application/json");
|
||||
const description = String(data.description || "").trim();
|
||||
const source = String(data.source || "").trim();
|
||||
const instructions = String(data.instructions || "").trim().slice(0, 4000);
|
||||
const draft = String(data.draft || "").trim().slice(0, 20000);
|
||||
const today = todayInTz(cfg.editorial.timezone);
|
||||
const entry = {
|
||||
date: today,
|
||||
slug: slugify(title),
|
||||
workingTitle: title,
|
||||
type: "guide",
|
||||
primaryKeyword: title,
|
||||
secondaryKeywords: [],
|
||||
searchIntent: "informational",
|
||||
audienceAngle: description,
|
||||
sourceHints: source ? [source] : [],
|
||||
eeatAngle: "User-suggested topic; research thoroughly and cite every claim.",
|
||||
suggested: true,
|
||||
status: "planned",
|
||||
};
|
||||
if (instructions) entry.instructions = instructions;
|
||||
if (draft) entry.draft = draft;
|
||||
await addEntry(join(root, cfg.paths.calendar), entry);
|
||||
send(res, 200, JSON.stringify({ ok: true, slug: entry.slug }), "application/json", cookieHeader);
|
||||
} catch (e) {
|
||||
send(res, 500, JSON.stringify({ error: e.message }), "application/json");
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// delete a message (token-gated)
|
||||
if (req.method === "POST" && path === "/admin/message-delete") {
|
||||
let body = "";
|
||||
req.on("data", (c) => (body += c));
|
||||
req.on("end", () => {
|
||||
try {
|
||||
const { id } = JSON.parse(body || "{}");
|
||||
const arr = messages().filter((m) => (m.id || m.at) !== id);
|
||||
writeFileSync(messagesPath(), JSON.stringify(arr, null, 2));
|
||||
send(res, 200, JSON.stringify({ ok: true }), "application/json", cookieHeader);
|
||||
} catch (e) {
|
||||
send(res, 500, JSON.stringify({ error: e.message }), "application/json");
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// cover image
|
||||
const cover = path.match(/^\/admin\/cover\/([a-z0-9-]+)$/i);
|
||||
if (cover) {
|
||||
const f = join(draftsDir(), cover[1], "cover.jpg");
|
||||
if (!existsSync(f)) return send(res, 404, "not found", "text/plain");
|
||||
return send(res, 200, readFileSync(f), "image/jpeg", cookieHeader);
|
||||
}
|
||||
|
||||
// draft detail
|
||||
const draft = path.match(/^\/admin\/draft\/([a-z0-9-]+)$/i);
|
||||
if (draft) {
|
||||
const html = draftDetailHtml(draft[1]);
|
||||
return html ? send(res, 200, html, "text/html; charset=utf-8", cookieHeader) : send(res, 404, "<h1>404</h1>");
|
||||
}
|
||||
|
||||
// dashboard
|
||||
if (path === "/admin" || path === "/admin/") {
|
||||
return send(res, 200, dashboardHtml(), "text/html; charset=utf-8", cookieHeader);
|
||||
}
|
||||
|
||||
return send(res, 404, "<h1>404</h1>");
|
||||
});
|
||||
|
||||
server.listen(PORT, "127.0.0.1", () => console.log(`[admin] listening on 127.0.0.1:${PORT}`));
|
||||
|
|
@ -1,153 +0,0 @@
|
|||
{
|
||||
"agents": [
|
||||
{
|
||||
"file": "agents/scripts/approve.mjs",
|
||||
"name": "approve",
|
||||
"title": "Draft Approver",
|
||||
"class": "plumbing",
|
||||
"trigger": "manual <slug> [--force]",
|
||||
"description": "Approve a draft: SEO gate, then promote it into the blog, build, go live. No LLM.",
|
||||
"model": "-",
|
||||
"prompt": [],
|
||||
"skills": [],
|
||||
"tools": [
|
||||
"lib/publish.mjs"
|
||||
],
|
||||
"reads": [],
|
||||
"writes": [],
|
||||
"created": "2026-07-04"
|
||||
},
|
||||
{
|
||||
"file": "agents/scripts/list-drafts.mjs",
|
||||
"name": "list-drafts",
|
||||
"title": "Draft Lister",
|
||||
"class": "plumbing",
|
||||
"trigger": "manual",
|
||||
"description": "List pending drafts awaiting review. No LLM.",
|
||||
"model": "-",
|
||||
"prompt": [],
|
||||
"skills": [],
|
||||
"tools": [],
|
||||
"reads": [],
|
||||
"writes": [],
|
||||
"created": "2026-07-04"
|
||||
},
|
||||
{
|
||||
"file": "agents/scripts/news-radar.mjs",
|
||||
"name": "news-radar",
|
||||
"title": "News Radar",
|
||||
"class": "content",
|
||||
"trigger": "manual / cron-capable (maintains news-queue.json for the writer)",
|
||||
"description": "Discover timely niche news and maintain news-queue.json; the daily writer drains it first.",
|
||||
"model": "research=claude-opus-4-8",
|
||||
"prompt": [
|
||||
"prompts/news-radar.system.md"
|
||||
],
|
||||
"skills": [],
|
||||
"tools": [
|
||||
"lib/claude.mjs"
|
||||
],
|
||||
"reads": [],
|
||||
"writes": [],
|
||||
"created": "2026-07-04"
|
||||
},
|
||||
{
|
||||
"file": "agents/scripts/publish-tick.mjs",
|
||||
"name": "publish-tick",
|
||||
"title": "Auto-Publisher Tick",
|
||||
"class": "plumbing",
|
||||
"trigger": "cron:run.sh publish-tick.mjs(15m)",
|
||||
"description": "Once per day after the morning floor, publish ONE eligible draft (SEO-gated) with a randomized earlier-today timestamp. No LLM.",
|
||||
"model": "-",
|
||||
"prompt": [],
|
||||
"skills": [],
|
||||
"tools": [
|
||||
"lib/publish.mjs"
|
||||
],
|
||||
"reads": [],
|
||||
"writes": [],
|
||||
"created": "2026-07-04"
|
||||
},
|
||||
{
|
||||
"file": "agents/scripts/research.mjs",
|
||||
"name": "research",
|
||||
"title": "Editorial Calendar Researcher",
|
||||
"class": "content",
|
||||
"trigger": "manual (auto-invoked by writer when the evergreen backlog runs low)",
|
||||
"description": "Top up the evergreen backlog in calendar.json (additive, deduped); timely news is the news radar's job.",
|
||||
"model": "research=claude-opus-4-8",
|
||||
"prompt": [
|
||||
"prompts/research.system.md"
|
||||
],
|
||||
"skills": [],
|
||||
"tools": [
|
||||
"lib/claude.mjs",
|
||||
"lib/calendar.mjs"
|
||||
],
|
||||
"reads": [],
|
||||
"writes": [],
|
||||
"created": "2026-07-04"
|
||||
},
|
||||
{
|
||||
"file": "agents/scripts/revise.mjs",
|
||||
"name": "reviser",
|
||||
"title": "Draft Reviser",
|
||||
"class": "content",
|
||||
"trigger": "manual <slug> (auto-invoked when the SEO gate fails and autoRevise is on)",
|
||||
"description": "Feed the SEO audit's fixes back to the writer and re-audit until the draft passes or attempts run out.",
|
||||
"model": "writer=claude-sonnet-4-6",
|
||||
"prompt": [
|
||||
"prompts/reviser.system.md"
|
||||
],
|
||||
"skills": [],
|
||||
"tools": [
|
||||
"lib/claude.mjs"
|
||||
],
|
||||
"reads": [],
|
||||
"writes": [],
|
||||
"created": "2026-07-04"
|
||||
},
|
||||
{
|
||||
"file": "agents/scripts/seo-review.mjs",
|
||||
"name": "seo-review",
|
||||
"title": "SEO Reviewer",
|
||||
"class": "content",
|
||||
"trigger": "manual <slug> (auto-invoked by writer/approve gates)",
|
||||
"description": "Audit one article for EEAT / spam-policy / on-page SEO / AEO / readability → seo-review.json.",
|
||||
"model": "reviewer=claude-opus-4-8",
|
||||
"prompt": [
|
||||
"prompts/seo-review.system.md"
|
||||
],
|
||||
"skills": [],
|
||||
"tools": [
|
||||
"lib/claude.mjs"
|
||||
],
|
||||
"reads": [],
|
||||
"writes": [],
|
||||
"created": "2026-07-04"
|
||||
},
|
||||
{
|
||||
"file": "agents/scripts/write-daily.mjs",
|
||||
"name": "writer",
|
||||
"title": "Daily Blog Writer",
|
||||
"class": "content",
|
||||
"trigger": "cron:run.sh write-daily.mjs(daily)",
|
||||
"description": "Draft one article (MDX + cover + sources) — today's news first, else next evergreen calendar topic.",
|
||||
"model": "writer=claude-sonnet-4-6",
|
||||
"prompt": [
|
||||
"prompts/writer.system.md",
|
||||
"prompts/image.md"
|
||||
],
|
||||
"skills": [
|
||||
"mcp__claude_ai_Higgsfield"
|
||||
],
|
||||
"tools": [
|
||||
"lib/claude.mjs",
|
||||
"lib/calendar.mjs"
|
||||
],
|
||||
"reads": [],
|
||||
"writes": [],
|
||||
"created": "2026-07-04"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -1,98 +0,0 @@
|
|||
#!/usr/bin/env node
|
||||
// catalog.mjs — generate the agent catalog from @agent-manifest headers.
|
||||
//
|
||||
// Scans agents/scripts/**/*.mjs plus the runtime PHP agents listed below, extracts each
|
||||
// fenced `@agent-manifest … @end` block, and emits:
|
||||
// agents/AGENTS.md — human catalog, one table per class (GENERATED — do not edit)
|
||||
// agents/agents.json — machine manifest; clones diff this to find missing deps
|
||||
//
|
||||
// Run after adding/editing an agent: node agents/catalog.mjs
|
||||
// (also runs automatically at the end of scripts/configure.mjs)
|
||||
import { readFileSync, writeFileSync, readdirSync } from "node:fs";
|
||||
import { dirname, join, relative } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = join(HERE, "..");
|
||||
|
||||
// Runtime agents living outside agents/ (PHP, served over HTTP) — included explicitly.
|
||||
const RUNTIME_FILES = []; // no runtime PHP agents in the seed base yet
|
||||
|
||||
const CLASSES = ["content", "operational", "runtime", "plumbing"];
|
||||
const LIST_KEYS = ["prompt", "skills", "tools", "reads", "writes"];
|
||||
|
||||
function parseManifest(src) {
|
||||
const m = src.match(/@agent-manifest\s*\n([\s\S]*?)\n\s*\*?\s*@end/);
|
||||
if (!m) return null;
|
||||
const out = {};
|
||||
for (const raw of m[1].split("\n")) {
|
||||
const line = raw.replace(/^\s*\*\s?/, ""); // strip comment leader
|
||||
const kv = line.match(/^([a-z-]+):\s*(.*)$/);
|
||||
if (kv) out[kv[1]] = kv[2].trim();
|
||||
}
|
||||
for (const k of LIST_KEYS) {
|
||||
const v = out[k];
|
||||
out[k] = !v || v === "-" ? [] : v.split(",").map((s) => s.trim()).filter(Boolean);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const files = readdirSync(join(HERE, "scripts"))
|
||||
.filter((f) => f.endsWith(".mjs"))
|
||||
.map((f) => join("agents", "scripts", f))
|
||||
.concat(RUNTIME_FILES);
|
||||
|
||||
const agents = [];
|
||||
for (const rel of files.sort()) {
|
||||
const manifest = parseManifest(readFileSync(join(ROOT, rel), "utf8"));
|
||||
if (!manifest) continue; // no manifest → not an agent (e.g. future helpers)
|
||||
if (!manifest.name || !manifest.class) {
|
||||
console.error(`[catalog] ${rel}: manifest missing name/class — skipped`);
|
||||
continue;
|
||||
}
|
||||
if (!CLASSES.includes(manifest.class)) {
|
||||
console.error(`[catalog] ${rel}: unknown class "${manifest.class}" — skipped`);
|
||||
continue;
|
||||
}
|
||||
agents.push({ file: rel, ...manifest });
|
||||
}
|
||||
agents.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
// ---- agents.json (machine manifest) ----
|
||||
writeFileSync(join(HERE, "agents.json"), JSON.stringify({ agents }, null, 2) + "\n");
|
||||
|
||||
// ---- AGENTS.md (human catalog) ----
|
||||
const esc = (s) => String(s).replace(/\|/g, "\\|");
|
||||
const classBlurb = {
|
||||
content: "Cron/manual-triggered producers of gated drafts (events auto-publishes).",
|
||||
operational: "Event/queue-triggered reviewers and responders (gated + logged).",
|
||||
runtime: "HTTP-triggered agents answering live user requests.",
|
||||
plumbing: "Deterministic helpers — not agents (no LLM), catalogued for completeness.",
|
||||
};
|
||||
|
||||
let md = `# Agent catalog
|
||||
|
||||
> **GENERATED — do not edit.** Regenerate with \`node agents/catalog.mjs\`
|
||||
> (source of truth: the \`@agent-manifest\` header in each script).
|
||||
|
||||
An **agent** = an LLM orchestrator script + its role prompt (\`prompt:\`) + its skills
|
||||
(MCP servers, \`skills:\`) + its tools (\`api/cli/*.php\` bridges and \`lib/*.mjs\`, \`tools:\`).
|
||||
To port an agent to another SeedProject clone, satisfy its manifest: copy its prompts, ensure
|
||||
its tools exist, migrate each table in \`reads:\`/\`writes:\`, configure its MCP servers.
|
||||
\`agents.json\` is the machine-readable version — diff it against a clone to find gaps.
|
||||
`;
|
||||
|
||||
for (const cls of CLASSES) {
|
||||
const group = agents.filter((a) => a.class === cls);
|
||||
if (!group.length) continue;
|
||||
md += `\n## ${cls}\n\n${classBlurb[cls]}\n\n`;
|
||||
md += `| agent | trigger | model | skills | tools | reads → writes | description |\n`;
|
||||
md += `|---|---|---|---|---|---|---|\n`;
|
||||
for (const a of group) {
|
||||
const rw = `${a.reads.join(", ") || "—"} → ${a.writes.join(", ") || "—"}`;
|
||||
md += `| **${esc(a.name)}** ([${esc(a.file)}](../${a.file})) | ${esc(a.trigger || "—")} | ${esc(a.model || "—")} | ${esc(a.skills.join(", ") || "—")} | ${esc(a.tools.join(", ") || "—")} | ${esc(rw)} | ${esc(a.description || "")} |\n`;
|
||||
}
|
||||
}
|
||||
|
||||
writeFileSync(join(HERE, "AGENTS.md"), md);
|
||||
console.log(`[catalog] ${agents.length} manifests → agents/AGENTS.md + agents/agents.json`);
|
||||
|
|
@ -1,89 +0,0 @@
|
|||
{
|
||||
"site": {
|
||||
"url": "https://example.com",
|
||||
"name": "SeedProject Site",
|
||||
"topic": "your subject area",
|
||||
"audience": "your target audience",
|
||||
"language": "en"
|
||||
},
|
||||
"paths": {
|
||||
"projectRoot": "/www/wwwroot/seedproject-web",
|
||||
"appDir": "app",
|
||||
"blogContentDir": "app/src/content/blog",
|
||||
"blogDataFile": "app/src/lib/blog-data.js",
|
||||
"calendar": "agents/calendar.json",
|
||||
"draftsDir": "agents/drafts",
|
||||
"logsDir": "agents/logs",
|
||||
"stateDir": "agents/state",
|
||||
"newsDir": "agents"
|
||||
},
|
||||
"models": {
|
||||
"research": "claude-opus-4-8",
|
||||
"writer": "claude-sonnet-4-6",
|
||||
"reviewer": "claude-opus-4-8"
|
||||
},
|
||||
"seo": {
|
||||
"minScore": 85,
|
||||
"blockApproveOnFail": true,
|
||||
"autoRevise": true,
|
||||
"maxReviseAttempts": 3
|
||||
},
|
||||
"publish": {
|
||||
"auto": true,
|
||||
"notBefore": "07:00"
|
||||
},
|
||||
"news": {
|
||||
"enabled": true,
|
||||
"recencyDays": 5,
|
||||
"maxPerScan": 8
|
||||
},
|
||||
"author": {
|
||||
"slug": "site-author",
|
||||
"name": "Site Author"
|
||||
},
|
||||
"image": {
|
||||
"provider": "higgsfield",
|
||||
"preferredModel": "soul_2",
|
||||
"aspectRatio": "3:2",
|
||||
"outputFormat": "jpg",
|
||||
"credit": {
|
||||
"caption": "Illustrative cover image. Not a photograph of any specific establishment.",
|
||||
"author": "SeedProject Site"
|
||||
}
|
||||
},
|
||||
"editorial": {
|
||||
"calendarDays": 90,
|
||||
"refillThreshold": 7,
|
||||
"refillCount": 14,
|
||||
"timezone": "America/New_York",
|
||||
"contentMix": {
|
||||
"guide": 0.3,
|
||||
"list": 0.25,
|
||||
"news-roundup": 0.2,
|
||||
"trend": 0.15,
|
||||
"review-summary": 0.1
|
||||
},
|
||||
"wordCounts": {
|
||||
"news-roundup": [
|
||||
600,
|
||||
900
|
||||
],
|
||||
"trend": [
|
||||
800,
|
||||
1200
|
||||
],
|
||||
"review-summary": [
|
||||
900,
|
||||
1300
|
||||
],
|
||||
"list": [
|
||||
1000,
|
||||
1500
|
||||
],
|
||||
"guide": [
|
||||
1200,
|
||||
1800
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,37 +0,0 @@
|
|||
# Cover image generation (Higgsfield MCP)
|
||||
|
||||
The cover is an **illustrative editorial image** — appetizing and on-topic, but never
|
||||
implying a real photograph of a specific named restaurant, dish-as-served, or identifiable
|
||||
person.
|
||||
|
||||
## Flow (use the Higgsfield claude.ai MCP tools)
|
||||
1. **Pick a model:** call `mcp__claude_ai_Higgsfield__models_explore` with
|
||||
`action:"recommend"` and a goal like "editorial food/lifestyle photography cover image",
|
||||
to get a suitable `model` id and its valid `aspect_ratios`. If that fails, default to the
|
||||
`preferredModel` from config (`soul_2`).
|
||||
2. **Generate:** call `mcp__claude_ai_Higgsfield__generate_image` with
|
||||
`params: { model, prompt, aspect_ratio, count: 1 }`. Use a valid aspect ratio close to
|
||||
3:2 landscape. This returns a job id.
|
||||
3. **Wait for the result:** call `mcp__claude_ai_Higgsfield__job_status` with
|
||||
`{ jobId, sync: true }`; repeat (respecting `poll_after_seconds`) until the job is
|
||||
terminal. Read the resulting image URL from `results`.
|
||||
4. **Download:** use Bash `curl -fsSL "<image_url>" -o "<draftDir>/cover.jpg"` to save the
|
||||
image as `cover.jpg` in the draft directory. Verify the file exists and is non-empty.
|
||||
|
||||
If image generation fails after a reasonable retry, continue without it and record
|
||||
`imageGenerated: false` in `sources.json`.
|
||||
|
||||
## Style guardrails (put these in the prompt)
|
||||
- Editorial food/lifestyle photography aesthetic, natural light, shallow depth of field.
|
||||
- Medellín / Colombian context where relevant (tropical, warm, Paisa setting) but generic.
|
||||
- No real logos, no readable signage, no recognizable real people, no text overlays.
|
||||
- High detail, web-quality, landscape orientation.
|
||||
|
||||
## Prompt skeleton
|
||||
```
|
||||
Editorial food photography, {subject relevant to the article topic}, {Medellin/Colombian
|
||||
ambience if relevant}, natural window light, shallow depth of field, warm tones, appetizing,
|
||||
clean composition, no text, no logos, no people's faces. Photorealistic, high detail.
|
||||
```
|
||||
Fill `{subject}` from the article topic (e.g. "a vibrant brunch spread on a cafe table",
|
||||
"specialty coffee being poured", "a colorful arepa plate"). Keep it generic and illustrative.
|
||||
|
|
@ -1,53 +0,0 @@
|
|||
# Role: News radar for Comiida
|
||||
|
||||
You scan for **timely, time-sensitive** Medellín restaurant/food items worth publishing about
|
||||
**right now**, for an English-speaking expat/tourist audience. You do NOT write articles — you
|
||||
surface fresh story candidates the daily writer can turn into a post the same day.
|
||||
|
||||
## Inputs (from the user prompt)
|
||||
- Today's date, the path to write the news queue, paths to existing published posts and the
|
||||
current `calendar.json` (for dedupe), how many items to return, and the recency window.
|
||||
- **WebSearch** — your primary tool. Search aggressively for *recent* items.
|
||||
|
||||
## What counts as timely (look for these)
|
||||
- New restaurant / café / bar **openings** (and notable closings) in Medellín.
|
||||
- Food **events and festivals** with upcoming dates (e.g. gastronomy festivals, pop-ups,
|
||||
tasting events, market days).
|
||||
- **Awards / rankings / press** just published (e.g. a Medellín spot named in a list).
|
||||
- Seasonal or this-week happenings relevant to dining.
|
||||
- A genuinely current angle on a trend (something that changed recently).
|
||||
|
||||
Favor items that are **recent** (within the last ~2–3 weeks) and **specific** (named place,
|
||||
date, source). Skip evergreen "best of" ideas — those are handled by the editorial calendar.
|
||||
|
||||
## Hard rules
|
||||
- **Only real, verifiable items with sources.** Every candidate must have ≥1 working source
|
||||
URL from your search. If you can't verify it, don't include it.
|
||||
- **EEAT-safe**: news roundups, opening announcements, event previews, data-driven angles.
|
||||
No fabricated first-person experience.
|
||||
- **No duplicates**: exclude anything matching an existing published post slug or a slug
|
||||
already in `calendar.json`. Make slugs unique and descriptive.
|
||||
- Return at most the requested number of items; fewer is fine (even zero on a quiet day —
|
||||
return an empty array rather than padding with weak/evergreen ideas).
|
||||
|
||||
## Output contract
|
||||
Write a JSON array to the news-queue path. Each item:
|
||||
```json
|
||||
{
|
||||
"discovered": "<today's date>",
|
||||
"slug": "kebab-case",
|
||||
"workingTitle": "specific, compelling, includes the keyword",
|
||||
"type": "news-roundup | trend | review-summary",
|
||||
"primaryKeyword": "the search phrase",
|
||||
"secondaryKeywords": ["..."],
|
||||
"searchIntent": "informational",
|
||||
"newsHook": "1-2 sentences: what happened and why it's timely now",
|
||||
"eventDate": "YYYY-MM-DD or null",
|
||||
"sourceLinks": ["real url", "real url"],
|
||||
"freshnessScore": 5,
|
||||
"status": "fresh"
|
||||
}
|
||||
```
|
||||
`freshnessScore` 1–5 = how time-sensitive/hot (5 = publish today, 1 = mildly timely).
|
||||
Valid JSON only, UTF-8, no comments, no trailing commas. Write ONLY the file, then reply with a
|
||||
one-line summary: how many items and their titles. If nothing fresh, write `[]` and say so.
|
||||
|
|
@ -1,56 +0,0 @@
|
|||
# Role: Editorial strategist for Comiida
|
||||
|
||||
You plan a 3-month English-language content calendar for **Comiida**, a publication about
|
||||
**the restaurant industry in Medellín, Colombia**, written for **English-speaking expats,
|
||||
digital nomads, and tourists**.
|
||||
|
||||
Your job in this run: produce a `calendar.json` editorial calendar. You do NOT write
|
||||
articles here — only plan them.
|
||||
|
||||
## Inputs available to you
|
||||
- The user prompt will give you: today's date, the number of days to plan, the path to
|
||||
write `calendar.json`, the path to existing published posts, and the desired content-type
|
||||
mix and allowed types.
|
||||
- **WebSearch** — use it to ground topics in reality: what people actually search for,
|
||||
what is newsworthy in Medellín dining (neighborhoods like El Poblado, Laureles, Envigado,
|
||||
Provenza), seasonal events, food trends, and recurring evergreen questions.
|
||||
|
||||
## Hard rules
|
||||
- **EEAT-safe topics only.** Plan news roundups, guides, lists, trends, and *data-driven
|
||||
review summaries*. Never plan a topic that requires inventing a first-person dining
|
||||
experience. Review-type topics must be framed as data-driven summaries of real, citable
|
||||
signals (aggregate ratings, menus, prices, awards, press).
|
||||
- **English, for expats/tourists.** Topics and keywords must match how this audience
|
||||
searches (e.g. "best brunch in El Poblado", "vegan restaurants Medellín", "is it safe to
|
||||
eat street food in Medellín").
|
||||
- **No duplicates.** Read the slugs/titles of existing posts in the blog content dir and do
|
||||
not repeat them. Vary neighborhoods, cuisines, price points, and angles.
|
||||
- **Respect the content-type mix and per-type counts** given in the user prompt.
|
||||
- These are **evergreen** topics (the daily news radar handles timely news separately) — favor
|
||||
durable guides, lists, neighborhood deep-dives, and data-driven summaries.
|
||||
|
||||
## Output contract
|
||||
Write a JSON **array of topic proposals** to the scan path given in the user prompt. Do NOT
|
||||
assign dates — the pipeline schedules them. Each item:
|
||||
|
||||
```json
|
||||
{
|
||||
"slug": "kebab-case-from-keyword",
|
||||
"workingTitle": "Specific, compelling, includes the primary keyword",
|
||||
"type": "guide | list | news-roundup | trend | review-summary",
|
||||
"primaryKeyword": "the exact search phrase to target",
|
||||
"secondaryKeywords": ["2-5 related phrases"],
|
||||
"searchIntent": "informational | commercial | transactional",
|
||||
"audienceAngle": "why an expat/tourist cares, in one sentence",
|
||||
"sourceHints": ["concrete places to find real data: outlets, directories, datasets"],
|
||||
"eeatAngle": "how this piece demonstrates real expertise/data without faking experience"
|
||||
}
|
||||
```
|
||||
|
||||
Rules for the file:
|
||||
- The number of items requested (roughly matching the per-type counts).
|
||||
- Valid JSON, UTF-8, no comments, no trailing commas. Write ONLY the file — no prose.
|
||||
- Slugs unique within the file and not colliding with any slug in the provided exclude list.
|
||||
|
||||
When finished, write the file with the Write tool, then reply with a one-line summary:
|
||||
how many items and the type breakdown.
|
||||
|
|
@ -1,40 +0,0 @@
|
|||
# Role: Reviser for Comiida (close the SEO/EEAT gaps)
|
||||
|
||||
An SEO/EEAT audit found issues with a draft article. Your job is to **revise the existing
|
||||
draft in place** so it passes the audit — addressing each issue concretely — without breaking
|
||||
anything or lowering quality. You are editing a real article that will be re-audited
|
||||
immediately after you finish.
|
||||
|
||||
## Inputs (from the user prompt)
|
||||
- Paths to the draft `index.mdx`, its `sources.json`, and the audit `seo-review.json` (read
|
||||
all three), plus the target minimum score and the primary keyword.
|
||||
- Tools: **Read/Glob/Grep**, **Edit/Write** (modify the draft), **WebSearch** (to find real
|
||||
sources for any uncited claim).
|
||||
|
||||
## How to revise
|
||||
1. Read `seo-review.json`. Work through **every** item in `blocking` and `topFixes`, plus weak
|
||||
dimensions. Common fixes and how to handle them:
|
||||
- **Uncited claim (EEAT):** find a real, authoritative source with WebSearch and add an
|
||||
inline Markdown citation. If you cannot verify it, **soften or remove the claim** — never
|
||||
fabricate a source or a fact.
|
||||
- **Title too long / missing keyword:** rewrite the `title` (and `excerpt` if needed) to
|
||||
~50–60 chars including the primary keyword; keep it compelling.
|
||||
- **Keyword not in an H2 / first 100 words:** weave it in naturally (no stuffing).
|
||||
- **Too few internal links:** add links to other EXISTING Comiida posts (verify the slugs
|
||||
resolve in the blog content dir).
|
||||
- **Image credit / metadata mismatch:** correct it to match reality.
|
||||
- **Thin/spam risk or readability:** add specific, sourced detail; tighten structure.
|
||||
2. Keep the **frontmatter schema valid and unchanged in shape** (title, excerpt, date,
|
||||
readingTime, category, tags, author, thumbnail, imageCredit, featured). Update
|
||||
`readingTime` if the word count changed materially. Do NOT change `author` or `date`.
|
||||
3. Preserve the EEAT contract: no *invented* first-person experience, every objective claim
|
||||
cited, honest AI-image disclosure. **BUT if the user prompt flags this as an
|
||||
`authorFirsthand` piece, do NOT strip or neutralize the author's genuine first-person
|
||||
voice and opinions** — that is authentic Experience and must be kept. Only add citations
|
||||
for objective facts (addresses, prices, dates, hours) or frame them honestly; never
|
||||
rewrite the author's own subjective judgments into a neutral summary.
|
||||
4. Update `sources.json` to reflect any new citations and bump `wordCount` if it changed.
|
||||
|
||||
## Output
|
||||
Edit the files in place (do not create new ones, do not move anything). When done, reply with a
|
||||
one-line summary of what you changed. Do not output the article text in chat.
|
||||
|
|
@ -1,103 +0,0 @@
|
|||
# Role: SEO Specialist & EEAT auditor for Comiida
|
||||
|
||||
You are a rigorous, skeptical SEO/EEAT reviewer. You audit ONE Comiida article (a restaurant
|
||||
publication for English-speaking expats/tourists in Medellín) and produce a structured audit.
|
||||
You do not rewrite the article — you score it and list concrete fixes. Be exacting: it is
|
||||
better to flag a real problem than to wave a weak article through.
|
||||
|
||||
## Inputs (from the user prompt)
|
||||
- The path to the article `index.mdx`, its `sources.json` (citations), the post directory to
|
||||
write your audit into, the target primary keyword, and the site's allowed category slugs.
|
||||
- Tools: **Read/Glob/Grep** (read the article, sources, and sibling posts for internal-link
|
||||
checks), **WebSearch** (confirm search intent, check the claim/keyword landscape, sanity-
|
||||
check facts and competitiveness), and **Write** (write the audit JSON).
|
||||
|
||||
## What to evaluate — five dimensions
|
||||
|
||||
### 1. EEAT (Experience, Expertise, Authoritativeness, Trust) — weight 30
|
||||
- Real, named author byline present (not a generic/placeholder editorial name).
|
||||
- **No fabricated first-hand experience.** By default, review-like content must read as a
|
||||
*data-driven summary* of real signals, never an invented "I ate here" account.
|
||||
- **Author first-hand EXCEPTION:** if the user prompt tells you this is an `authorFirsthand`
|
||||
piece (a real, named author's own draft, written personally), first-person experience and
|
||||
subjective opinions are LEGITIMATE — that is authentic *Experience*, a POSITIVE E-E-A-T
|
||||
signal, NOT fabrication. Do not flag the personal voice as a violation and do not demand it
|
||||
be neutralized. The author's own opinions ("the food is mediocre") need no citation; only
|
||||
*objective, verifiable* facts (addresses, prices, dates, hours) still require a citation or
|
||||
honest "as of <date>" framing. Reserve fabrication findings for experience with no author
|
||||
basis or invented objective facts.
|
||||
- Every non-obvious factual claim (names, prices, dates, ratings, openings) carries an inline
|
||||
citation to a real, authoritative source. Cross-check against `sources.json`.
|
||||
- Sources are credible (official, press, primary data) — not circular or low-quality.
|
||||
- Trust signals: transparency about method, accurate "as of <date>" framing for volatile data.
|
||||
|
||||
### 2. Google spam-policy compliance — weight 20
|
||||
Judge against Google's spam policies and the helpful-content guidance:
|
||||
- **Scaled content abuse:** does the piece deliver genuine, specific value, or is it thin
|
||||
filler that exists only to rank? AI assistance is fine; low-value mass production is not.
|
||||
- No keyword stuffing, no hidden text, no doorway/cloaking patterns.
|
||||
- No fabricated reviews or fake experience (overlaps EEAT but score the policy risk here).
|
||||
NOTE: a real, named author's genuine first-person account in an `authorFirsthand` piece is
|
||||
NOT a fabricated review — do not penalize it here.
|
||||
- People-first: written to help a reader decide where/what to eat, not to game a query.
|
||||
This dimension is **gating**: a clear violation caps the verdict at "fail" regardless of score.
|
||||
|
||||
### 3. On-page SEO — weight 25
|
||||
- Title: contains the primary keyword, compelling, ~50–60 chars ideal.
|
||||
- Meta description (`excerpt`): ≤155 chars, contains the keyword, earns the click.
|
||||
- Slug: short, keyword-bearing.
|
||||
- Headings: exactly one implied H1 (the title — no H1 in body); logical H2/H3 hierarchy.
|
||||
- Primary keyword present in title, first 100 words, ≥1 H2 — natural, not stuffed.
|
||||
- Internal links: ≥2 links to other EXISTING Comiida posts (verify the slugs resolve under the
|
||||
blog content dir — flag any that 404).
|
||||
- Outbound citations to authoritative sources where claims are made.
|
||||
- Word count appropriate to search intent and content type.
|
||||
- Image has honest credit; alt/caption present.
|
||||
|
||||
### 4. AEO — AI-engine / answer-engine optimization — weight 15
|
||||
- Answers the core query directly and early (a clear, extractable answer near the top).
|
||||
- Structured for extraction: descriptive headings, lists, definitions, Q&A where natural.
|
||||
- Self-contained, factual, citable statements (good for LLM answer engines and featured
|
||||
snippets).
|
||||
- Schema readiness: is the content shaped so Article/FAQ/HowTo structured data would apply?
|
||||
- Entity clarity: places, neighborhoods, dishes named clearly and consistently.
|
||||
|
||||
### 5. Readability & UX — weight 10
|
||||
- Scannable: short paragraphs, useful subheads, lists where helpful.
|
||||
- Clear, concrete language; minimal fluff; logical flow; correct, consistent style.
|
||||
|
||||
## Scoring & verdict
|
||||
- Score each dimension 0–100. Compute `overall` as the weighted average (weights above).
|
||||
- `verdict`:
|
||||
- **fail** if any `blocking` issue exists OR overall < 70 OR a spam-policy violation.
|
||||
- **revise** if 70 ≤ overall < 85.
|
||||
- **pass** if overall ≥ 85 and no blocking issues.
|
||||
- `blocking` = must-fix-before-publish problems: fabricated experience, uncited factual
|
||||
claims, keyword stuffing, broken/missing internal links, missing real author, spam-policy
|
||||
violation, or missing/incorrect title/meta.
|
||||
|
||||
## Output — write EXACTLY this JSON to `<postDir>/seo-review.json`
|
||||
```json
|
||||
{
|
||||
"slug": "...",
|
||||
"title": "...",
|
||||
"primaryKeyword": "...",
|
||||
"overall": 0,
|
||||
"verdict": "pass | revise | fail",
|
||||
"dimensions": {
|
||||
"eeat": { "score": 0, "issues": ["..."], "notes": "" },
|
||||
"spam": { "score": 0, "issues": ["..."], "notes": "" },
|
||||
"onPageSeo": { "score": 0, "issues": ["..."], "notes": "" },
|
||||
"aeo": { "score": 0, "issues": ["..."], "notes": "" },
|
||||
"readability": { "score": 0, "issues": ["..."], "notes": "" }
|
||||
},
|
||||
"blocking": ["... must-fix items, empty array if none ..."],
|
||||
"topFixes": [
|
||||
{ "severity": "high|medium|low", "area": "eeat|spam|onPageSeo|aeo|readability", "fix": "specific, actionable" }
|
||||
],
|
||||
"summary": "2-3 sentence verdict in plain English"
|
||||
}
|
||||
```
|
||||
Rules: valid JSON only, UTF-8, no comments, no trailing commas. Write ONLY the file with the
|
||||
Write tool, then reply with a one-line summary: `verdict · overall · N blocking · M fixes`.
|
||||
Do not output the JSON in chat.
|
||||
|
|
@ -1,104 +0,0 @@
|
|||
# Role: Senior SEO/EEAT writer for Comiida
|
||||
|
||||
You write ONE article for **Comiida**, a publication about **the restaurant industry in
|
||||
Medellín, Colombia**, for **English-speaking expats, digital nomads, and tourists**. The
|
||||
output is a reviewable draft — it does not go live until a human approves it.
|
||||
|
||||
## Inputs (from the user prompt)
|
||||
- The chosen calendar entry (title, type, primary/secondary keywords, intent, angle).
|
||||
- Today's date, the author slug to byline, the draft output directory, the path to existing
|
||||
published posts (for internal links), and the image config (model, aspect ratio).
|
||||
- Tools: **WebSearch** (live research), **Read/Glob/Grep** (read existing posts), **Write**
|
||||
(create files), **Bash** (download the generated image), and the **Higgsfield MCP**
|
||||
(`mcp__claude_ai_Higgsfield__*`) for the cover image.
|
||||
|
||||
## Process
|
||||
1. **Research the topic live with WebSearch.** Collect current, specific, citable facts:
|
||||
names, neighborhoods, prices, dates, ratings, awards, sources. Capture the exact URLs.
|
||||
2. **Read 2+ existing published posts** in the blog content dir to link to internally.
|
||||
3. **Write the article body** as MDX following the EEAT/SEO contract below.
|
||||
4. **Generate the cover image** via the Higgsfield MCP, following `prompts/image.md`
|
||||
(recommend a model → generate_image → poll job_status(sync:true) → download the result
|
||||
URL to `cover.jpg` with Bash curl). The image is an *illustrative* food/scene image —
|
||||
never a depiction implying a real photo of a specific named restaurant. If image
|
||||
generation fails, continue and note it in `sources.json`.
|
||||
5. **Write the three output files** (see Output) into the draft directory.
|
||||
|
||||
## EEAT / SEO contract (non-negotiable)
|
||||
- **Honesty / EEAT:** Never *invent* a first-person dining experience. By default, anything
|
||||
review-like is a *data-driven summary* of real, cited signals and must say so. Every
|
||||
non-obvious factual claim has an inline citation as a Markdown link to its real source.
|
||||
- **Author first-hand pieces (EXCEPTION):** When the calendar entry is flagged
|
||||
`authorFirsthand: true` (or the editor instructions ask you to write "personally" / "as
|
||||
<author>" and provide the author's own draft), that draft IS the real, named author's
|
||||
genuine first-hand experience. Preserve it in the **first person** — their visits,
|
||||
preferences, and honest opinions are authentic *Experience* (the first "E" in E-E-A-T) and
|
||||
a POSITIVE signal, not fabrication. Do NOT flatten the personal voice into a neutral
|
||||
summary. In this mode:
|
||||
- Keep the author's subjective judgments as their own opinion ("I think the best burger
|
||||
here is…", "the food is mediocre") — these are the author's genuine assessment and need
|
||||
**no external citation**.
|
||||
- Still fact-check and cite *objective, verifiable* specifics a reader will act on —
|
||||
addresses, opening dates, prices, hours — or frame them honestly ("as of <date>",
|
||||
"opened around May 2026") when you can't confirm them. Never fabricate an address or fact.
|
||||
- Clean up spelling/grammar and tighten structure, but keep it sounding like the author.
|
||||
- **No hallucinated specifics.** If you cannot verify a name/price/fact via WebSearch, do
|
||||
not state it. Prefer ranges and "as of <date>" framing for volatile data (prices, hours).
|
||||
- **Keyword placement:** primary keyword in the title, the slug, the excerpt, the first 100
|
||||
words, and at least one H2 — naturally, never stuffed.
|
||||
- **Structure:** one H1 is implied by the title (do not add an H1 in the body); use H2/H3,
|
||||
short paragraphs, and lists. Scannable. Hit the word-count range for the content type.
|
||||
- **Internal links:** at least 2 links to other Comiida posts (use real slugs you read).
|
||||
- **Outbound links:** cite primary sources; open authority links where natural.
|
||||
- **Meta:** the `excerpt` is the meta description — compelling, ≤155 characters, contains
|
||||
the primary keyword.
|
||||
|
||||
## Output — write exactly these files into the draft directory
|
||||
The user prompt gives you the draft dir as `agents/drafts/<slug>/`.
|
||||
|
||||
**1. `index.mdx`** — frontmatter must match the Astro schema EXACTLY, then the body:
|
||||
```mdx
|
||||
---
|
||||
title: "..." # includes primary keyword
|
||||
excerpt: "..." # meta description, <=155 chars, includes primary keyword
|
||||
date: YYYY-MM-DD # today
|
||||
readingTime: <integer minutes> # ~ words / 220, rounded, min 1
|
||||
category: "..." # one of the site categories (see blog-data.js); lowercase slug
|
||||
tags: ["...", "..."] # 2-5 kebab-case tags
|
||||
author: "<author-slug>" # exactly the author slug given to you
|
||||
thumbnail: ./cover.jpg
|
||||
imageCredit:
|
||||
caption: "Illustrative cover image. Not a photograph of any specific establishment."
|
||||
author: "Comiida"
|
||||
authorUrl: "https://comiida.com/about"
|
||||
featured: false
|
||||
---
|
||||
|
||||
<article body in MDX>
|
||||
```
|
||||
- `category` must be one of the existing category slugs in `blog-data.js` (currently
|
||||
`guides`, `news`, `reviews`, `neighborhoods`). Map by content type: guide/list → `guides`
|
||||
(use `neighborhoods` if the piece is anchored to one barrio), news-roundup/trend → `news`,
|
||||
review-summary → `reviews`. If none fits, pick the closest and note a suggested new
|
||||
category in `sources.json` (the approver decides — do not invent silently).
|
||||
|
||||
**2. `cover.jpg`** — the generated image saved into the draft dir.
|
||||
|
||||
**3. `sources.json`** — the reviewer's fact-check sheet:
|
||||
```json
|
||||
{
|
||||
"slug": "...",
|
||||
"title": "...",
|
||||
"primaryKeyword": "...",
|
||||
"wordCount": 0,
|
||||
"citations": [{ "claim": "...", "url": "..." }],
|
||||
"internalLinks": ["/blog/other-post/"],
|
||||
"imageGenerated": true,
|
||||
"imagePrompt": "...",
|
||||
"notes": "anything the approver should know (unverified items, suggested new category, etc.)"
|
||||
}
|
||||
```
|
||||
|
||||
## Finish
|
||||
After writing all files, reply with a one-line summary: slug, type, word count, number of
|
||||
citations, and whether the image was generated. Do not output the article text in chat.
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
# Wrapper so cron has a sane PATH and the secrets from .env.
|
||||
# Usage: run.sh <script.mjs> [args...]
|
||||
# run.sh research.mjs
|
||||
# run.sh write-daily.mjs
|
||||
# run.sh approve.mjs <slug>
|
||||
set -euo pipefail
|
||||
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
# Tools (claude is under /root/.local/bin; node/npx under /usr/bin).
|
||||
export PATH="/root/.local/bin:/usr/bin:/usr/local/bin:$PATH"
|
||||
|
||||
# Secrets (REPLICATE_API_TOKEN, etc.)
|
||||
if [ -f "$DIR/.env" ]; then
|
||||
set -a
|
||||
. "$DIR/.env"
|
||||
set +a
|
||||
fi
|
||||
|
||||
exec node "$DIR/scripts/$1" "${@:2}"
|
||||
|
|
@ -1,78 +0,0 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* @agent-manifest
|
||||
* name: approve
|
||||
* title: Draft Approver
|
||||
* class: plumbing
|
||||
* trigger: manual <slug> [--force]
|
||||
* description: Approve a draft: SEO gate, then promote it into the blog, build, go live. No LLM.
|
||||
* model: -
|
||||
* prompt: -
|
||||
* skills: -
|
||||
* tools: lib/publish.mjs
|
||||
* reads: -
|
||||
* writes: -
|
||||
* created: 2026-07-04
|
||||
* @end
|
||||
*/
|
||||
// Manually approve a draft: SEO gate, then promote it into the blog, build, go live.
|
||||
// Usage: node agents/scripts/approve.mjs <slug> [--force]
|
||||
import { existsSync } from "node:fs";
|
||||
import { dirname, resolve, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { loadJson } from "./lib/util.mjs";
|
||||
import { promoteDraft } from "./lib/publish.mjs";
|
||||
import { runSeoReview } from "./seo-review.mjs";
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const PIPELINE = resolve(HERE, "..");
|
||||
const cfg = loadJson(join(PIPELINE, "config.json"));
|
||||
const root = cfg.paths.projectRoot;
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const force = args.includes("--force");
|
||||
const slug = args.find((a) => !a.startsWith("--"));
|
||||
if (!slug) {
|
||||
console.error("Usage: approve.mjs <slug> [--force]");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const draftDir = join(root, cfg.paths.draftsDir, slug);
|
||||
if (!existsSync(join(draftDir, "index.mdx"))) {
|
||||
console.error(`[approve] no draft at ${join(draftDir, "index.mdx")}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// SEO Specialist gate.
|
||||
if (cfg.seo?.blockApproveOnFail && !force) {
|
||||
const auditPath = join(draftDir, "seo-review.json");
|
||||
let audit = existsSync(auditPath) ? loadJson(auditPath) : null;
|
||||
if (!audit) {
|
||||
console.log("[approve] no SEO audit found — running the SEO Specialist now…");
|
||||
audit = await runSeoReview(slug, { published: false });
|
||||
}
|
||||
if (!audit) {
|
||||
console.error("[approve] could not obtain an SEO audit. Re-run, or use --force to override.");
|
||||
process.exit(1);
|
||||
}
|
||||
const minScore = cfg.seo.minScore ?? 85;
|
||||
if (audit.verdict === "fail" || (audit.overall ?? 0) < minScore) {
|
||||
console.error(
|
||||
`[approve] BLOCKED by SEO gate: ${audit.verdict} · ${audit.overall}/${minScore} min.\n` +
|
||||
(audit.blocking?.length ? " blocking:\n - " + audit.blocking.join("\n - ") + "\n" : "") +
|
||||
` Review ${auditPath}, revise the draft, then re-approve (or use --force to override).`
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`[approve] SEO gate passed: ${audit.verdict} · ${audit.overall}`);
|
||||
}
|
||||
|
||||
try {
|
||||
console.log("[approve] publishing…");
|
||||
const { url, taxonomyAdded } = await promoteDraft(cfg, slug);
|
||||
if (taxonomyAdded.length) console.log(`[approve] registered in blog-data.js: ${taxonomyAdded.join(", ")}`);
|
||||
console.log(`[approve] LIVE → ${url}`);
|
||||
} catch (e) {
|
||||
console.error(`[approve] ${e.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
|
@ -1,68 +0,0 @@
|
|||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
|
||||
/** Find the [ ... ] range of `export const <arrayName> = [ ... ]`. */
|
||||
function blockRange(src, arrayName) {
|
||||
const marker = `export const ${arrayName} = [`;
|
||||
const start = src.indexOf(marker);
|
||||
if (start === -1) return null;
|
||||
const open = src.indexOf("[", start);
|
||||
let depth = 0;
|
||||
let i = open;
|
||||
for (; i < src.length; i++) {
|
||||
if (src[i] === "[") depth++;
|
||||
else if (src[i] === "]") {
|
||||
depth--;
|
||||
if (depth === 0) break;
|
||||
}
|
||||
}
|
||||
return { open, close: i };
|
||||
}
|
||||
|
||||
function slugsIn(src, arrayName) {
|
||||
const r = blockRange(src, arrayName);
|
||||
if (!r) return new Set();
|
||||
const block = src.slice(r.open, r.close);
|
||||
const out = new Set();
|
||||
for (const m of block.matchAll(/slug:\s*["']([^"']+)["']/g)) out.add(m[1]);
|
||||
return out;
|
||||
}
|
||||
|
||||
export function readSlugs(file) {
|
||||
const src = readFileSync(file, "utf8");
|
||||
return {
|
||||
authors: slugsIn(src, "authors"),
|
||||
categories: slugsIn(src, "categories"),
|
||||
tags: slugsIn(src, "tags"),
|
||||
};
|
||||
}
|
||||
|
||||
/** Insert { slug, name } as the first element of the named array if slug is absent. */
|
||||
export function ensureEntry(file, arrayName, slug, name) {
|
||||
let src = readFileSync(file, "utf8");
|
||||
if (slugsIn(src, arrayName).has(slug)) return false;
|
||||
const r = blockRange(src, arrayName);
|
||||
if (!r) throw new Error(`Array ${arrayName} not found in ${file}`);
|
||||
const entry = `\n { slug: ${JSON.stringify(slug)}, name: ${JSON.stringify(name)} },`;
|
||||
src = src.slice(0, r.open + 1) + entry + src.slice(r.open + 1);
|
||||
writeFileSync(file, src);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Insert a full author object if the slug is absent. author = {slug,name,bio,longBio,avatar} */
|
||||
export function ensureAuthor(file, author) {
|
||||
let src = readFileSync(file, "utf8");
|
||||
if (slugsIn(src, "authors").has(author.slug)) return false;
|
||||
const r = blockRange(src, "authors");
|
||||
if (!r) throw new Error(`authors array not found in ${file}`);
|
||||
const obj =
|
||||
`\n {\n` +
|
||||
` slug: ${JSON.stringify(author.slug)},\n` +
|
||||
` name: ${JSON.stringify(author.name)},\n` +
|
||||
` bio: ${JSON.stringify(author.bio || "")},\n` +
|
||||
` longBio: ${JSON.stringify(author.longBio || author.bio || "")},\n` +
|
||||
` avatar: ${JSON.stringify(author.avatar || "")},\n` +
|
||||
` },`;
|
||||
src = src.slice(0, r.open + 1) + obj + src.slice(r.open + 1);
|
||||
writeFileSync(file, src);
|
||||
return true;
|
||||
}
|
||||
|
|
@ -1,38 +0,0 @@
|
|||
import { spawn } from "node:child_process";
|
||||
import { mkdirSync } from "node:fs";
|
||||
import { withLock } from "./lock.mjs";
|
||||
|
||||
function run(cmd, args, opts = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(cmd, args, { stdio: "inherit", ...opts });
|
||||
child.on("error", reject);
|
||||
child.on("close", (code) =>
|
||||
code === 0 ? resolve() : reject(new Error(`${cmd} ${args.join(" ")} exited ${code}`))
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/** Absolute path of the global build lock. Callers that build outside buildSite()
|
||||
* (e.g. console preview builds) must take THIS same lock to serialize. */
|
||||
export function buildLockPath(projectRoot) {
|
||||
return `${projectRoot}/agents/state/build.lock`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the Astro app and fix ownership so Apache can serve the output.
|
||||
* Serialized behind the global build lock so it never races the console's
|
||||
* preview/publish builds (concurrent astro builds would OOM this box).
|
||||
*/
|
||||
export async function buildSite({ projectRoot, appDir }) {
|
||||
const lockPath = buildLockPath(projectRoot);
|
||||
mkdirSync(`${projectRoot}/agents/state`, { recursive: true });
|
||||
await withLock(lockPath, async () => {
|
||||
await run("npm", ["run", "build"], { cwd: `${projectRoot}/${appDir}` });
|
||||
// Best-effort ownership fix (ignore failure if not running as root).
|
||||
try {
|
||||
await run("chown", ["-R", "www:www", `${projectRoot}/${appDir}`, `${projectRoot}/public`]);
|
||||
} catch (e) {
|
||||
console.warn(`[build] chown skipped: ${e.message}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -1,95 +0,0 @@
|
|||
// Atomic, locked access to calendar.json so concurrent runs (write-daily, publish-tick,
|
||||
// research refill) can't clobber each other. Every mutation re-reads under an exclusive
|
||||
// lockfile, mutates, and writes — no long-held in-memory copies.
|
||||
import { existsSync, readFileSync, writeFileSync, openSync, closeSync, unlinkSync, statSync } from "node:fs";
|
||||
|
||||
const LOCK_STALE_MS = 10 * 60 * 1000; // a lock older than this is presumed orphaned
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
async function acquire(calPath, timeoutMs = 120000) {
|
||||
const lp = `${calPath}.lock`;
|
||||
const start = Date.now();
|
||||
for (;;) {
|
||||
try {
|
||||
const fd = openSync(lp, "wx"); // exclusive create — fails if it exists
|
||||
writeFileSync(lp, `${process.pid} ${new Date().toISOString()}`);
|
||||
closeSync(fd);
|
||||
return lp;
|
||||
} catch {
|
||||
try {
|
||||
if (Date.now() - statSync(lp).mtimeMs > LOCK_STALE_MS) {
|
||||
unlinkSync(lp);
|
||||
continue;
|
||||
}
|
||||
} catch {
|
||||
/* lock vanished — retry */
|
||||
}
|
||||
if (Date.now() - start > timeoutMs) throw new Error(`calendar lock timeout: ${lp}`);
|
||||
await sleep(200);
|
||||
}
|
||||
}
|
||||
}
|
||||
const release = (lp) => {
|
||||
try {
|
||||
unlinkSync(lp);
|
||||
} catch {
|
||||
/* already gone */
|
||||
}
|
||||
};
|
||||
|
||||
export async function withCalendarLock(calPath, fn) {
|
||||
const lp = await acquire(calPath);
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
release(lp);
|
||||
}
|
||||
}
|
||||
|
||||
export const readCalendar = (calPath) =>
|
||||
existsSync(calPath) ? JSON.parse(readFileSync(calPath, "utf8")) : [];
|
||||
|
||||
const writeCalendar = (calPath, cal) => writeFileSync(calPath, JSON.stringify(cal, null, 2));
|
||||
|
||||
/** Locked read-modify-write of one entry (Object.assign patch). Returns the updated entry. */
|
||||
export async function updateEntry(calPath, slug, patch) {
|
||||
return withCalendarLock(calPath, () => {
|
||||
const cal = readCalendar(calPath);
|
||||
const e = cal.find((x) => x.slug === slug);
|
||||
if (e) {
|
||||
Object.assign(e, patch);
|
||||
writeCalendar(calPath, cal);
|
||||
}
|
||||
return e;
|
||||
});
|
||||
}
|
||||
|
||||
/** Locked append if the slug is not already present. */
|
||||
export async function addEntry(calPath, entry) {
|
||||
return withCalendarLock(calPath, () => {
|
||||
const cal = readCalendar(calPath);
|
||||
if (!cal.some((x) => x.slug === entry.slug)) {
|
||||
cal.push(entry);
|
||||
writeCalendar(calPath, cal);
|
||||
}
|
||||
return entry;
|
||||
});
|
||||
}
|
||||
|
||||
/** Locked merge: append each new entry whose slug isn't present. Returns count added. */
|
||||
export async function mergeEntries(calPath, entries) {
|
||||
return withCalendarLock(calPath, () => {
|
||||
const cal = readCalendar(calPath);
|
||||
const have = new Set(cal.map((x) => x.slug));
|
||||
let added = 0;
|
||||
for (const e of entries) {
|
||||
if (!have.has(e.slug)) {
|
||||
cal.push(e);
|
||||
have.add(e.slug);
|
||||
added++;
|
||||
}
|
||||
}
|
||||
writeCalendar(calPath, cal);
|
||||
return added;
|
||||
});
|
||||
}
|
||||
|
|
@ -1,71 +0,0 @@
|
|||
import { spawn } from "node:child_process";
|
||||
import { createWriteStream, readFileSync } from "node:fs";
|
||||
|
||||
const CLAUDE_BIN = process.env.CLAUDE_BIN || "claude";
|
||||
|
||||
/**
|
||||
* Run headless Claude Code and return the parsed JSON result.
|
||||
* The system prompt file is read and passed via --append-system-prompt so we don't
|
||||
* depend on the --append-system-prompt-file flag variant.
|
||||
*/
|
||||
export function runClaude({
|
||||
prompt,
|
||||
systemPromptFile,
|
||||
model,
|
||||
mcpConfig,
|
||||
allowedTools,
|
||||
addDirs = [],
|
||||
cwd,
|
||||
logFile,
|
||||
permissionMode = "acceptEdits", // bypassPermissions is blocked under root
|
||||
}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const args = [
|
||||
"-p",
|
||||
prompt,
|
||||
"--output-format",
|
||||
"json",
|
||||
"--permission-mode",
|
||||
permissionMode,
|
||||
];
|
||||
if (model) args.push("--model", model);
|
||||
if (systemPromptFile) {
|
||||
const sys = readFileSync(systemPromptFile, "utf8");
|
||||
args.push("--append-system-prompt", sys);
|
||||
}
|
||||
if (mcpConfig) args.push("--mcp-config", mcpConfig);
|
||||
if (allowedTools) args.push("--allowed-tools", allowedTools);
|
||||
for (const d of addDirs) args.push("--add-dir", d);
|
||||
|
||||
const log = logFile ? createWriteStream(logFile, { flags: "a" }) : null;
|
||||
if (log) log.write(`\n\n===== ${new Date().toISOString()} claude run =====\n`);
|
||||
|
||||
const child = spawn(CLAUDE_BIN, args, {
|
||||
cwd,
|
||||
env: process.env,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
child.stdout.on("data", (b) => {
|
||||
stdout += b;
|
||||
if (log) log.write(b);
|
||||
});
|
||||
child.stderr.on("data", (b) => {
|
||||
stderr += b;
|
||||
if (log) log.write(b);
|
||||
});
|
||||
child.on("error", reject);
|
||||
child.on("close", (code) => {
|
||||
if (log) log.end();
|
||||
if (code !== 0) {
|
||||
return reject(new Error(`claude exited ${code}: ${stderr.slice(-2000) || stdout.slice(-2000)}`));
|
||||
}
|
||||
try {
|
||||
resolve(JSON.parse(stdout));
|
||||
} catch {
|
||||
resolve({ result: stdout.trim(), raw: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -1,37 +0,0 @@
|
|||
import { spawn } from "node:child_process";
|
||||
|
||||
function run(cmd, args, opts = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let out = "";
|
||||
let err = "";
|
||||
const child = spawn(cmd, args, { stdio: ["ignore", "pipe", "pipe"], ...opts });
|
||||
child.stdout.on("data", (b) => (out += b));
|
||||
child.stderr.on("data", (b) => (err += b));
|
||||
child.on("error", reject);
|
||||
child.on("close", (code) =>
|
||||
code === 0
|
||||
? resolve(out)
|
||||
: reject(new Error(`git ${args.join(" ")} exited ${code}: ${err.trim()}`))
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Stage the given paths (relative to projectRoot) and commit if anything changed.
|
||||
* Best-effort: never throws, so a git hiccup can't block a live publish. Returns
|
||||
* the new commit hash, or null if nothing was staged / git failed.
|
||||
* Keeping the working tree committed is what lets the console's git worktree +
|
||||
* `merge --ff-only` operations run against a clean tree.
|
||||
*/
|
||||
export async function gitCommitPaths(projectRoot, paths, message) {
|
||||
try {
|
||||
await run("git", ["-C", projectRoot, "add", "--", ...paths]);
|
||||
const staged = await run("git", ["-C", projectRoot, "diff", "--cached", "--name-only"]);
|
||||
if (!staged.trim()) return null;
|
||||
await run("git", ["-C", projectRoot, "commit", "-q", "-m", message]);
|
||||
return (await run("git", ["-C", projectRoot, "rev-parse", "HEAD"])).trim();
|
||||
} catch (e) {
|
||||
console.warn(`[git] commit skipped: ${e.message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,46 +0,0 @@
|
|||
// Generic exclusive file lock, same idiom as calendar.mjs but reusable for any
|
||||
// critical section. Used for the global BUILD lock so cron builds (promoteDraft)
|
||||
// and Developer Console builds (preview + publish) never run npm/astro
|
||||
// concurrently — important on this memory-constrained box.
|
||||
import { writeFileSync, openSync, closeSync, unlinkSync, statSync } from "node:fs";
|
||||
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
/**
|
||||
* Acquire an exclusive lockfile, run fn, always release.
|
||||
* @param {string} lockPath absolute path to the lockfile
|
||||
* @param {() => Promise<T>|T} fn critical section
|
||||
* @param {{timeoutMs?: number, staleMs?: number}} [opts]
|
||||
* @returns {Promise<T>}
|
||||
*/
|
||||
export async function withLock(lockPath, fn, { timeoutMs = 300000, staleMs = 15 * 60 * 1000 } = {}) {
|
||||
const start = Date.now();
|
||||
for (;;) {
|
||||
try {
|
||||
const fd = openSync(lockPath, "wx"); // exclusive create — fails if it exists
|
||||
writeFileSync(lockPath, `${process.pid} ${new Date().toISOString()}`);
|
||||
closeSync(fd);
|
||||
break;
|
||||
} catch {
|
||||
try {
|
||||
if (Date.now() - statSync(lockPath).mtimeMs > staleMs) {
|
||||
unlinkSync(lockPath); // presumed orphaned
|
||||
continue;
|
||||
}
|
||||
} catch {
|
||||
/* lock vanished between failed create and stat — retry immediately */
|
||||
}
|
||||
if (Date.now() - start > timeoutMs) throw new Error(`lock timeout: ${lockPath}`);
|
||||
await sleep(200);
|
||||
}
|
||||
}
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
try {
|
||||
unlinkSync(lockPath);
|
||||
} catch {
|
||||
/* already gone */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,84 +0,0 @@
|
|||
import { existsSync, readFileSync, writeFileSync, renameSync, cpSync, rmSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { readFrontmatter, fmString, fmArray, titleCase } from "./util.mjs";
|
||||
import { readSlugs, ensureEntry } from "./blogData.mjs";
|
||||
import { updateEntry } from "./calendar.mjs";
|
||||
import { buildSite } from "./build.mjs";
|
||||
import { gitCommitPaths } from "./git.mjs";
|
||||
|
||||
/**
|
||||
* Promote a draft into the Astro blog and build the site.
|
||||
* Shared by approve.mjs (manual) and publish-tick.mjs (auto). Does NOT do the SEO gate —
|
||||
* callers decide whether the draft is allowed to publish.
|
||||
* @param {object} cfg parsed config.json
|
||||
* @param {string} slug
|
||||
* @param {{dateOverride?: string}} opts dateOverride = ISO timestamp to stamp as the post date
|
||||
* @returns {Promise<{dest:string,url:string,taxonomyAdded:string[]}>}
|
||||
*/
|
||||
export async function promoteDraft(cfg, slug, { dateOverride } = {}) {
|
||||
const root = cfg.paths.projectRoot;
|
||||
const draftDir = join(root, cfg.paths.draftsDir, slug);
|
||||
const mdxPath = join(draftDir, "index.mdx");
|
||||
if (!existsSync(mdxPath)) throw new Error(`no draft at ${mdxPath}`);
|
||||
if (!existsSync(join(draftDir, "cover.jpg"))) throw new Error("draft is missing cover.jpg");
|
||||
|
||||
// Stamp a specific published date/time into the frontmatter (randomized publish time).
|
||||
if (dateOverride) {
|
||||
const raw = readFileSync(mdxPath, "utf8");
|
||||
writeFileSync(mdxPath, raw.replace(/^date:.*$/m, `date: ${dateOverride}`));
|
||||
}
|
||||
|
||||
const fm = readFrontmatter(readFileSync(mdxPath, "utf8"));
|
||||
const author = fmString(fm, "author");
|
||||
const category = fmString(fm, "category");
|
||||
const tags = fmArray(fm, "tags");
|
||||
|
||||
const blogDataFile = join(root, cfg.paths.blogDataFile);
|
||||
const known = readSlugs(blogDataFile);
|
||||
if (author && !known.authors.has(author)) {
|
||||
throw new Error(`author "${author}" is not in blog-data.js — add it first`);
|
||||
}
|
||||
|
||||
const added = [];
|
||||
if (category && !known.categories.has(category)) {
|
||||
ensureEntry(blogDataFile, "categories", category, titleCase(category));
|
||||
added.push(`category:${category}`);
|
||||
}
|
||||
for (const t of tags) {
|
||||
if (!known.tags.has(t)) {
|
||||
ensureEntry(blogDataFile, "tags", t, titleCase(t));
|
||||
added.push(`tag:${t}`);
|
||||
}
|
||||
}
|
||||
|
||||
const dest = join(root, cfg.paths.blogContentDir, slug);
|
||||
if (existsSync(dest)) throw new Error(`destination already exists: ${dest}`);
|
||||
try {
|
||||
renameSync(draftDir, dest);
|
||||
} catch {
|
||||
cpSync(draftDir, dest, { recursive: true });
|
||||
rmSync(draftDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
const calendarPath = join(root, cfg.paths.calendar);
|
||||
await updateEntry(
|
||||
calendarPath,
|
||||
slug,
|
||||
dateOverride ? { status: "published", publishedAt: dateOverride } : { status: "published" }
|
||||
);
|
||||
|
||||
await buildSite({ projectRoot: root, appDir: cfg.paths.appDir });
|
||||
const out = join(root, "public", "blog", slug, "index.html");
|
||||
if (!existsSync(out)) throw new Error(`expected output not found: ${out}`);
|
||||
|
||||
// Version the published content so the working tree stays clean for the
|
||||
// Developer Console's git worktree/merge operations. Best-effort — a git
|
||||
// hiccup must never keep already-built content off the live site.
|
||||
const commit = await gitCommitPaths(
|
||||
root,
|
||||
[join(cfg.paths.blogContentDir, slug), cfg.paths.blogDataFile],
|
||||
`content: publish ${slug}`
|
||||
);
|
||||
|
||||
return { dest, url: `${cfg.site.url}/blog/${slug}/`, taxonomyAdded: added, commit };
|
||||
}
|
||||
|
|
@ -1,84 +0,0 @@
|
|||
import { readFileSync } from "node:fs";
|
||||
|
||||
export function slugify(s) {
|
||||
return String(s)
|
||||
.toLowerCase()
|
||||
.normalize("NFKD")
|
||||
.replace(/[̀-ͯ]/g, "")
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 80);
|
||||
}
|
||||
|
||||
export function titleCase(slug) {
|
||||
return String(slug)
|
||||
.split("-")
|
||||
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
export function todayInTz(tz) {
|
||||
const fmt = new Intl.DateTimeFormat("en-CA", {
|
||||
timeZone: tz,
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
});
|
||||
return fmt.format(new Date()); // en-CA => YYYY-MM-DD
|
||||
}
|
||||
|
||||
/** ISO timestamp with Medellín's fixed -05:00 offset, e.g. 2026-06-28T14:37:09-05:00. */
|
||||
export function isoInBogota(date = new Date()) {
|
||||
const parts = new Intl.DateTimeFormat("en-CA", {
|
||||
timeZone: "America/Bogota",
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
hourCycle: "h23",
|
||||
}).formatToParts(date);
|
||||
const g = (t) => parts.find((p) => p.type === t).value;
|
||||
return `${g("year")}-${g("month")}-${g("day")}T${g("hour")}:${g("minute")}:${g("second")}-05:00`;
|
||||
}
|
||||
|
||||
/** A random Bogotá timestamp earlier today (between 00:00 and now) — varied but never future. */
|
||||
export function randomTimestampTodaySoFar(tz = "America/Bogota") {
|
||||
const today = todayInTz(tz);
|
||||
const startMs = Date.parse(`${today}T00:00:00-05:00`);
|
||||
const nowMs = Date.now();
|
||||
const r = startMs + Math.floor(Math.random() * Math.max(1, nowMs - startMs));
|
||||
return isoInBogota(new Date(r));
|
||||
}
|
||||
|
||||
export function addDays(isoDate, n) {
|
||||
const d = new Date(isoDate + "T00:00:00Z");
|
||||
d.setUTCDate(d.getUTCDate() + n);
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
export function loadJson(path) {
|
||||
return JSON.parse(readFileSync(path, "utf8"));
|
||||
}
|
||||
|
||||
/** Extract the YAML frontmatter block (between the first two --- lines) as raw text. */
|
||||
export function readFrontmatter(mdx) {
|
||||
const m = mdx.match(/^---\n([\s\S]*?)\n---/);
|
||||
return m ? m[1] : "";
|
||||
}
|
||||
|
||||
/** Minimal frontmatter field readers (good enough for our controlled schema). */
|
||||
export function fmString(fm, key) {
|
||||
const m = fm.match(new RegExp(`^${key}:\\s*"?([^"\\n]+?)"?\\s*$`, "m"));
|
||||
return m ? m[1].trim() : null;
|
||||
}
|
||||
|
||||
export function fmArray(fm, key) {
|
||||
const m = fm.match(new RegExp(`^${key}:\\s*\\[([^\\]]*)\\]`, "m"));
|
||||
if (!m) return [];
|
||||
return m[1]
|
||||
.split(",")
|
||||
.map((s) => s.trim().replace(/^["']|["']$/g, ""))
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
|
@ -1,54 +0,0 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* @agent-manifest
|
||||
* name: list-drafts
|
||||
* title: Draft Lister
|
||||
* class: plumbing
|
||||
* trigger: manual
|
||||
* description: List pending drafts awaiting review. No LLM.
|
||||
* model: -
|
||||
* prompt: -
|
||||
* skills: -
|
||||
* tools: -
|
||||
* reads: -
|
||||
* writes: -
|
||||
* created: 2026-07-04
|
||||
* @end
|
||||
*/
|
||||
// List pending drafts awaiting review.
|
||||
// Usage: node agents/scripts/list-drafts.mjs
|
||||
import { readdirSync, existsSync, readFileSync } from "node:fs";
|
||||
import { dirname, resolve, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { loadJson, readFrontmatter, fmString } from "./lib/util.mjs";
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const PIPELINE = resolve(HERE, "..");
|
||||
const cfg = loadJson(join(PIPELINE, "config.json"));
|
||||
const draftsRoot = join(cfg.paths.projectRoot, cfg.paths.draftsDir);
|
||||
|
||||
if (!existsSync(draftsRoot)) {
|
||||
console.log("No drafts directory yet.");
|
||||
process.exit(0);
|
||||
}
|
||||
const dirs = readdirSync(draftsRoot, { withFileTypes: true }).filter((d) => d.isDirectory());
|
||||
if (!dirs.length) {
|
||||
console.log("No drafts pending.");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
console.log(`Pending drafts (${dirs.length}):\n`);
|
||||
for (const d of dirs) {
|
||||
const mdx = join(draftsRoot, d.name, "index.mdx");
|
||||
const hasCover = existsSync(join(draftsRoot, d.name, "cover.jpg"));
|
||||
let title = "(no index.mdx)";
|
||||
let cat = "";
|
||||
if (existsSync(mdx)) {
|
||||
const fm = readFrontmatter(readFileSync(mdx, "utf8"));
|
||||
title = fmString(fm, "title") || d.name;
|
||||
cat = fmString(fm, "category") || "";
|
||||
}
|
||||
console.log(` • ${d.name}`);
|
||||
console.log(` ${title}${cat ? ` [${cat}]` : ""} cover:${hasCover ? "yes" : "NO"}`);
|
||||
console.log(` approve: node agents/scripts/approve.mjs ${d.name}\n`);
|
||||
}
|
||||
|
|
@ -1,128 +0,0 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* @agent-manifest
|
||||
* name: news-radar
|
||||
* title: News Radar
|
||||
* class: content
|
||||
* trigger: manual / cron-capable (maintains news-queue.json for the writer)
|
||||
* description: Discover timely niche news and maintain news-queue.json; the daily writer drains it first.
|
||||
* model: research=claude-opus-4-8
|
||||
* prompt: prompts/news-radar.system.md
|
||||
* skills: -
|
||||
* tools: lib/claude.mjs
|
||||
* reads: -
|
||||
* writes: -
|
||||
* created: 2026-07-04
|
||||
* @end
|
||||
*/
|
||||
// News radar: discover timely Medellín food news/events and maintain news-queue.json.
|
||||
// Usage: node agents/scripts/news-radar.mjs
|
||||
import { existsSync, readFileSync, writeFileSync, readdirSync } from "node:fs";
|
||||
import { dirname, resolve, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { runClaude } from "./lib/claude.mjs";
|
||||
import { loadJson, todayInTz, addDays } from "./lib/util.mjs";
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const PIPELINE = resolve(HERE, "..");
|
||||
const cfg = loadJson(join(PIPELINE, "config.json"));
|
||||
const root = cfg.paths.projectRoot;
|
||||
|
||||
const queuePath = join(root, cfg.paths.newsDir, "news-queue.json");
|
||||
const scanPath = join(root, cfg.paths.newsDir, "news-scan.json");
|
||||
|
||||
const loadArr = (p) => (existsSync(p) ? loadJson(p) : []);
|
||||
const publishedSlugs = () => {
|
||||
const d = join(root, cfg.paths.blogContentDir);
|
||||
return existsSync(d)
|
||||
? readdirSync(d, { withFileTypes: true }).filter((x) => x.isDirectory()).map((x) => x.name)
|
||||
: [];
|
||||
};
|
||||
const calendarSlugs = () => {
|
||||
const p = join(root, cfg.paths.calendar);
|
||||
return existsSync(p) ? loadJson(p).map((e) => e.slug) : [];
|
||||
};
|
||||
|
||||
/** Scan for fresh news and merge into news-queue.json. Returns the merged queue. */
|
||||
export async function runNewsRadar() {
|
||||
if (!cfg.news?.enabled) {
|
||||
console.log("[news] news.enabled is false — skipping.");
|
||||
return loadArr(queuePath);
|
||||
}
|
||||
const today = todayInTz(cfg.editorial.timezone);
|
||||
const recencyDays = cfg.news.recencyDays ?? 5;
|
||||
const maxPerScan = cfg.news.maxPerScan ?? 8;
|
||||
|
||||
const existing = loadArr(queuePath);
|
||||
const known = new Set([...existing.map((i) => i.slug), ...publishedSlugs(), ...calendarSlugs()]);
|
||||
|
||||
const prompt = `Find timely Medellín food/restaurant news to publish about now.
|
||||
|
||||
- Today: ${today}
|
||||
- Prefer items from the last ~2-3 weeks. Return at most ${maxPerScan} items (fewer is fine; [] if nothing fresh).
|
||||
- Write the JSON array to: ${scanPath}
|
||||
- Do NOT reuse any of these existing slugs: ${[...known].join(", ") || "(none)"}
|
||||
- Existing published posts: ${join(root, cfg.paths.blogContentDir)}
|
||||
|
||||
Follow the output contract in your system prompt exactly.`;
|
||||
|
||||
await runClaude({
|
||||
prompt,
|
||||
systemPromptFile: join(PIPELINE, "prompts/news-radar.system.md"),
|
||||
model: cfg.models.research,
|
||||
allowedTools: "Read Glob Grep WebSearch Write",
|
||||
addDirs: [root],
|
||||
cwd: root,
|
||||
logFile: join(root, cfg.paths.logsDir, `news-${today}.log`),
|
||||
});
|
||||
|
||||
const scanned = loadArr(scanPath);
|
||||
|
||||
// Prune: drop stale FRESH items (older than recency window); keep used items for dedup history.
|
||||
const cutoff = addDays(today, -recencyDays);
|
||||
const kept = existing.filter((i) => i.status === "used" || (i.discovered || today) >= cutoff);
|
||||
const keptSlugs = new Set(kept.map((i) => i.slug));
|
||||
const dedupe = new Set([...keptSlugs, ...publishedSlugs(), ...calendarSlugs()]);
|
||||
|
||||
let added = 0;
|
||||
for (const item of scanned) {
|
||||
if (!item?.slug || dedupe.has(item.slug)) continue;
|
||||
kept.push({ ...item, discovered: item.discovered || today, status: "fresh" });
|
||||
dedupe.add(item.slug);
|
||||
added++;
|
||||
}
|
||||
// Newest + hottest first.
|
||||
kept.sort((a, b) => (b.freshnessScore ?? 0) - (a.freshnessScore ?? 0) || (b.discovered || "").localeCompare(a.discovered || ""));
|
||||
writeFileSync(queuePath, JSON.stringify(kept, null, 2));
|
||||
|
||||
const fresh = kept.filter((i) => i.status === "fresh").length;
|
||||
console.log(`[news] scanned ${scanned.length}, added ${added}, queue now ${kept.length} (${fresh} fresh).`);
|
||||
return kept;
|
||||
}
|
||||
|
||||
/** The freshest unused news item within the recency window, or null. */
|
||||
export function pickFreshNews(cfg2 = cfg) {
|
||||
if (!cfg2.news?.enabled || !existsSync(queuePath)) return null;
|
||||
const today = todayInTz(cfg2.editorial.timezone);
|
||||
const cutoff = addDays(today, -(cfg2.news.recencyDays ?? 5));
|
||||
const fresh = loadJson(queuePath)
|
||||
.filter((i) => i.status === "fresh" && (i.discovered || today) >= cutoff)
|
||||
.sort((a, b) => (b.freshnessScore ?? 0) - (a.freshnessScore ?? 0) || (b.discovered || "").localeCompare(a.discovered || ""));
|
||||
return fresh[0] || null;
|
||||
}
|
||||
|
||||
/** Mark a news item used (after it's been drafted). */
|
||||
export function markNewsUsed(slug) {
|
||||
if (!existsSync(queuePath)) return;
|
||||
const q = loadJson(queuePath);
|
||||
const item = q.find((i) => i.slug === slug);
|
||||
if (item) {
|
||||
item.status = "used";
|
||||
writeFileSync(queuePath, JSON.stringify(q, null, 2));
|
||||
}
|
||||
}
|
||||
|
||||
// CLI
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
await runNewsRadar();
|
||||
}
|
||||
|
|
@ -1,117 +0,0 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* @agent-manifest
|
||||
* name: publish-tick
|
||||
* title: Auto-Publisher Tick
|
||||
* class: plumbing
|
||||
* trigger: cron:run.sh publish-tick.mjs(15m)
|
||||
* description: Once per day after the morning floor, publish ONE eligible draft (SEO-gated) with a randomized earlier-today timestamp. No LLM.
|
||||
* model: -
|
||||
* prompt: -
|
||||
* skills: -
|
||||
* tools: lib/publish.mjs
|
||||
* reads: -
|
||||
* writes: -
|
||||
* created: 2026-07-04
|
||||
* @end
|
||||
*/
|
||||
// Auto-publisher. Run frequently by cron (e.g. every 15 min). Once per day, after a morning
|
||||
// floor time, it publishes ONE eligible draft (passing the SEO gate) so posts go live in the
|
||||
// morning — but stamps each with a RANDOM earlier-today timestamp so published times aren't a
|
||||
// fixed-minute metronome. News drafts are fast-tracked ahead of evergreen.
|
||||
// Usage: node agents/scripts/publish-tick.mjs [--now] (--now ignores the floor)
|
||||
import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync } from "node:fs";
|
||||
import { dirname, resolve, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { loadJson, todayInTz, randomTimestampTodaySoFar } from "./lib/util.mjs";
|
||||
import { promoteDraft } from "./lib/publish.mjs";
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const PIPELINE = resolve(HERE, "..");
|
||||
const cfg = loadJson(join(PIPELINE, "config.json"));
|
||||
const root = cfg.paths.projectRoot;
|
||||
const FORCE_NOW = process.argv.includes("--now");
|
||||
|
||||
if (!cfg.publish?.auto) {
|
||||
console.log("[publish-tick] publish.auto is false — nothing to do.");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const today = todayInTz(cfg.editorial.timezone);
|
||||
const stateDir = join(root, cfg.paths.stateDir);
|
||||
mkdirSync(stateDir, { recursive: true });
|
||||
const statePath = join(stateDir, `publish-${today}.json`);
|
||||
|
||||
let state = existsSync(statePath) ? loadJson(statePath) : { published: false, slug: null };
|
||||
|
||||
if (state.published) {
|
||||
console.log(`[publish-tick] already published today (${state.slug}).`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Morning floor: don't publish before this Bogotá time (gives the 06:00 writer time to draft +
|
||||
// audit + auto-revise so the day's freshest article is the one that goes live).
|
||||
const notBefore = cfg.publish.notBefore || "07:00";
|
||||
const floorMs = Date.parse(`${today}T${notBefore}:00-05:00`);
|
||||
if (!FORCE_NOW && Date.now() < floorMs) {
|
||||
console.log(`[publish-tick] waiting — floor ${notBefore} Bogotá (${new Date(floorMs).toISOString()}).`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Find an eligible draft: on disk, has cover, and passes the SEO gate.
|
||||
const minScore = cfg.seo?.minScore ?? 85;
|
||||
const draftsRoot = join(root, cfg.paths.draftsDir);
|
||||
const calPath = join(root, cfg.paths.calendar);
|
||||
const calendar = existsSync(calPath) ? loadJson(calPath) : [];
|
||||
const entryOf = (slug) => calendar.find((e) => e.slug === slug) || {};
|
||||
const dateOf = (slug) => entryOf(slug).date || "9999-12-31";
|
||||
|
||||
function eligible(slug) {
|
||||
const d = join(draftsRoot, slug);
|
||||
if (!existsSync(join(d, "index.mdx")) || !existsSync(join(d, "cover.jpg"))) return false;
|
||||
const auditPath = join(d, "seo-review.json");
|
||||
if (!existsSync(auditPath)) return false;
|
||||
try {
|
||||
const a = loadJson(auditPath);
|
||||
return a.verdict !== "fail" && (a.overall ?? 0) >= minScore;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const candidates = existsSync(draftsRoot)
|
||||
? readdirSync(draftsRoot, { withFileTypes: true })
|
||||
.filter((x) => x.isDirectory())
|
||||
.map((x) => x.name)
|
||||
.filter(eligible)
|
||||
// News drafts first (hottest, then by date); evergreen after, oldest first.
|
||||
.sort((a, b) => {
|
||||
const ea = entryOf(a);
|
||||
const eb = entryOf(b);
|
||||
const na = ea.news ? 1 : 0;
|
||||
const nb = eb.news ? 1 : 0;
|
||||
if (na !== nb) return nb - na;
|
||||
if (na) return (eb.freshnessScore ?? 0) - (ea.freshnessScore ?? 0) || dateOf(a).localeCompare(dateOf(b));
|
||||
return dateOf(a).localeCompare(dateOf(b));
|
||||
})
|
||||
: [];
|
||||
|
||||
if (!candidates.length) {
|
||||
console.log("[publish-tick] no SEO-passing draft ready to publish (leaving queue as-is).");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const slug = candidates[0];
|
||||
const stamp = randomTimestampTodaySoFar(cfg.editorial.timezone); // morning go-live, random timestamp
|
||||
console.log(`[publish-tick] auto-publishing "${slug}" with timestamp ${stamp}…`);
|
||||
|
||||
try {
|
||||
const { url, taxonomyAdded } = await promoteDraft(cfg, slug, { dateOverride: stamp });
|
||||
if (taxonomyAdded.length) console.log(`[publish-tick] registered: ${taxonomyAdded.join(", ")}`);
|
||||
state = { published: true, slug, publishedAt: stamp };
|
||||
writeFileSync(statePath, JSON.stringify(state, null, 2));
|
||||
console.log(`[publish-tick] LIVE → ${url}`);
|
||||
} catch (e) {
|
||||
console.error(`[publish-tick] publish failed: ${e.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
|
@ -1,110 +0,0 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* @agent-manifest
|
||||
* name: research
|
||||
* title: Editorial Calendar Researcher
|
||||
* class: content
|
||||
* trigger: manual (auto-invoked by writer when the evergreen backlog runs low)
|
||||
* description: Top up the evergreen backlog in calendar.json (additive, deduped); timely news is the news radar's job.
|
||||
* model: research=claude-opus-4-8
|
||||
* prompt: prompts/research.system.md
|
||||
* skills: -
|
||||
* tools: lib/claude.mjs, lib/calendar.mjs
|
||||
* reads: -
|
||||
* writes: -
|
||||
* created: 2026-07-04
|
||||
* @end
|
||||
*/
|
||||
// Editorial research: top up the EVERGREEN backlog in calendar.json (additive, deduped).
|
||||
// Timely news is handled separately by the news radar. Usage:
|
||||
// node agents/scripts/research.mjs [count]
|
||||
import { readdirSync, existsSync } from "node:fs";
|
||||
import { dirname, resolve, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { runClaude } from "./lib/claude.mjs";
|
||||
import { loadJson, todayInTz, addDays } from "./lib/util.mjs";
|
||||
import { readCalendar, mergeEntries } from "./lib/calendar.mjs";
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const PIPELINE = resolve(HERE, "..");
|
||||
const cfg = loadJson(join(PIPELINE, "config.json"));
|
||||
const root = cfg.paths.projectRoot;
|
||||
|
||||
const publishedSlugs = () => {
|
||||
const d = join(root, cfg.paths.blogContentDir);
|
||||
return existsSync(d)
|
||||
? readdirSync(d, { withFileTypes: true }).filter((x) => x.isDirectory()).map((x) => x.name)
|
||||
: [];
|
||||
};
|
||||
|
||||
/** Scan for evergreen topics and additively merge them into calendar.json. Returns count added. */
|
||||
export async function runResearch({ count } = {}) {
|
||||
const n = count || cfg.editorial.calendarDays;
|
||||
const today = todayInTz(cfg.editorial.timezone);
|
||||
const calendarPath = join(root, cfg.paths.calendar);
|
||||
const scanPath = join(root, "agents", "research-scan.json");
|
||||
|
||||
// Per-type target counts from the configured mix.
|
||||
const types = Object.keys(cfg.editorial.contentMix);
|
||||
const counts = {};
|
||||
let assigned = 0;
|
||||
for (const t of types) {
|
||||
counts[t] = Math.round(cfg.editorial.contentMix[t] * n);
|
||||
assigned += counts[t];
|
||||
}
|
||||
const biggest = types.reduce((a, b) => (counts[a] >= counts[b] ? a : b));
|
||||
counts[biggest] += n - assigned;
|
||||
|
||||
const exclude = [...new Set([...readCalendar(calendarPath).map((e) => e.slug), ...publishedSlugs()])];
|
||||
|
||||
const prompt = `Propose ${n} evergreen Comiida topics.
|
||||
|
||||
- Write a JSON array of ${n} topic proposals to: ${scanPath}
|
||||
- Do NOT assign dates (the pipeline schedules them).
|
||||
- Allowed content types: ${types.join(", ")}
|
||||
- Rough target counts per type: ${JSON.stringify(counts)}
|
||||
- DO NOT reuse any of these slugs: ${exclude.join(", ") || "(none yet)"}
|
||||
- Read existing posts if useful: ${join(root, cfg.paths.blogContentDir)}
|
||||
|
||||
Follow the output contract in your system prompt exactly.`;
|
||||
|
||||
console.log(`[research] proposing ${n} evergreen topics, mix ${JSON.stringify(counts)}`);
|
||||
|
||||
await runClaude({
|
||||
prompt,
|
||||
systemPromptFile: join(PIPELINE, "prompts/research.system.md"),
|
||||
model: cfg.models.research,
|
||||
allowedTools: "Read Glob Grep WebSearch Write",
|
||||
addDirs: [root],
|
||||
cwd: root,
|
||||
logFile: join(root, cfg.paths.logsDir, `research-${today}.log`),
|
||||
});
|
||||
|
||||
const proposals = existsSync(scanPath) ? loadJson(scanPath) : [];
|
||||
if (!proposals.length) {
|
||||
console.log("[research] no proposals produced.");
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Schedule new topics one per day, starting the day after the latest existing date.
|
||||
const dates = readCalendar(calendarPath).map((e) => (e.date || "").slice(0, 10)).filter(Boolean);
|
||||
const maxDate = dates.sort().pop();
|
||||
let next = maxDate && maxDate >= today ? addDays(maxDate, 1) : today;
|
||||
|
||||
const entries = proposals.map((p) => {
|
||||
const entry = { ...p, date: next, status: "planned" };
|
||||
next = addDays(next, 1);
|
||||
return entry;
|
||||
});
|
||||
|
||||
const added = await mergeEntries(calendarPath, entries);
|
||||
const planned = readCalendar(calendarPath).filter((e) => e.status === "planned").length;
|
||||
console.log(`[research] added ${added} evergreen topics. Planned backlog now: ${planned}.`);
|
||||
return added;
|
||||
}
|
||||
|
||||
// CLI
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
const count = parseInt(process.argv[2], 10) || undefined;
|
||||
await runResearch({ count });
|
||||
}
|
||||
|
|
@ -1,107 +0,0 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* @agent-manifest
|
||||
* name: reviser
|
||||
* title: Draft Reviser
|
||||
* class: content
|
||||
* trigger: manual <slug> (auto-invoked when the SEO gate fails and autoRevise is on)
|
||||
* description: Feed the SEO audit's fixes back to the writer and re-audit until the draft passes or attempts run out.
|
||||
* model: writer=claude-sonnet-4-6
|
||||
* prompt: prompts/reviser.system.md
|
||||
* skills: -
|
||||
* tools: lib/claude.mjs
|
||||
* reads: -
|
||||
* writes: -
|
||||
* created: 2026-07-04
|
||||
* @end
|
||||
*/
|
||||
// Auto-revise loop: feed the SEO audit's fixes back to the writer, re-audit, repeat until the
|
||||
// draft passes the gate or maxReviseAttempts is reached.
|
||||
// Usage: node agents/scripts/revise.mjs <slug>
|
||||
import { existsSync } from "node:fs";
|
||||
import { dirname, resolve, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { runClaude } from "./lib/claude.mjs";
|
||||
import { loadJson } from "./lib/util.mjs";
|
||||
import { runSeoReview } from "./seo-review.mjs";
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const PIPELINE = resolve(HERE, "..");
|
||||
const cfg = loadJson(join(PIPELINE, "config.json"));
|
||||
const root = cfg.paths.projectRoot;
|
||||
|
||||
const failing = (audit, minScore) =>
|
||||
!audit || audit.verdict === "fail" || (audit.overall ?? 0) < minScore;
|
||||
|
||||
/**
|
||||
* Revise a draft until it passes the SEO gate (or attempts run out).
|
||||
* @returns {Promise<{audit:object|null, attempts:number}>}
|
||||
*/
|
||||
export async function runRevise(slug, { maxAttempts } = {}) {
|
||||
const max = maxAttempts ?? cfg.seo?.maxReviseAttempts ?? 2;
|
||||
const minScore = cfg.seo?.minScore ?? 85;
|
||||
const draftDir = join(root, cfg.paths.draftsDir, slug);
|
||||
const mdxPath = join(draftDir, "index.mdx");
|
||||
const auditPath = join(draftDir, "seo-review.json");
|
||||
if (!existsSync(mdxPath)) {
|
||||
console.error(`[revise] no draft at ${mdxPath}`);
|
||||
return { audit: null, attempts: 0 };
|
||||
}
|
||||
|
||||
const calPathForEntry = join(root, cfg.paths.calendar);
|
||||
const calEntry = existsSync(calPathForEntry)
|
||||
? loadJson(calPathForEntry).find((e) => e.slug === slug)
|
||||
: null;
|
||||
const firsthandNote = calEntry?.authorFirsthand
|
||||
? `\n\nIMPORTANT — authorFirsthand piece: a real, named author (${cfg.author?.name || "the site author"}) wrote this personally. Do NOT strip or neutralize the first-person voice and subjective opinions — that is authentic Experience and must be kept. Only add citations for objective facts (addresses, prices, dates, hours) or frame them honestly.`
|
||||
: "";
|
||||
|
||||
let audit = existsSync(auditPath) ? loadJson(auditPath) : await runSeoReview(slug, { published: false });
|
||||
|
||||
let attempts = 0;
|
||||
while (failing(audit, minScore) && attempts < max) {
|
||||
attempts++;
|
||||
const keyword = audit?.primaryKeyword || slug.replace(/-/g, " ");
|
||||
console.log(`[revise] attempt ${attempts}/${max} on ${slug} (current: ${audit?.verdict} ${audit?.overall})`);
|
||||
|
||||
const prompt = `Revise this draft to pass the SEO/EEAT gate (target ≥ ${minScore}).
|
||||
|
||||
- Draft: ${mdxPath}
|
||||
- Citations file: ${join(draftDir, "sources.json")}
|
||||
- Audit to address (work through blocking + topFixes): ${auditPath}
|
||||
- Primary keyword: ${keyword}
|
||||
- Existing published posts (for internal links): ${join(root, cfg.paths.blogContentDir)}${firsthandNote}
|
||||
|
||||
Follow the reviser instructions in your system prompt exactly. Edit the files in place.`;
|
||||
|
||||
await runClaude({
|
||||
prompt,
|
||||
systemPromptFile: join(PIPELINE, "prompts/reviser.system.md"),
|
||||
model: cfg.models.writer,
|
||||
allowedTools: "Read Write Edit Glob Grep WebSearch",
|
||||
addDirs: [root],
|
||||
cwd: root,
|
||||
logFile: join(root, cfg.paths.logsDir, `revise-${slug}.log`),
|
||||
});
|
||||
|
||||
audit = await runSeoReview(slug, { published: false });
|
||||
}
|
||||
|
||||
const ok = !failing(audit, minScore);
|
||||
console.log(
|
||||
`[revise] ${slug}: ${ok ? "PASS" : "still failing"} after ${attempts} attempt(s) — ` +
|
||||
`${audit?.verdict} ${audit?.overall}`
|
||||
);
|
||||
return { audit, attempts };
|
||||
}
|
||||
|
||||
// CLI
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
const slug = process.argv[2];
|
||||
if (!slug) {
|
||||
console.error("Usage: revise.mjs <slug>");
|
||||
process.exit(1);
|
||||
}
|
||||
const { audit } = await runRevise(slug);
|
||||
process.exit(audit && audit.verdict === "pass" ? 0 : 2);
|
||||
}
|
||||
|
|
@ -1,128 +0,0 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* @agent-manifest
|
||||
* name: seo-review
|
||||
* title: SEO Reviewer
|
||||
* class: content
|
||||
* trigger: manual <slug> (auto-invoked by writer/approve gates)
|
||||
* description: Audit one article for EEAT / spam-policy / on-page SEO / AEO / readability → seo-review.json.
|
||||
* model: reviewer=claude-opus-4-8
|
||||
* prompt: prompts/seo-review.system.md
|
||||
* skills: -
|
||||
* tools: lib/claude.mjs
|
||||
* reads: -
|
||||
* writes: -
|
||||
* created: 2026-07-04
|
||||
* @end
|
||||
*/
|
||||
// SEO Specialist agent: audit one article for EEAT / spam-policy / on-page SEO / AEO / readability.
|
||||
// Writes <postDir>/seo-review.json. Usage:
|
||||
// node scripts/seo-review.mjs <slug> [--published]
|
||||
// (default: review the draft in agents/drafts/<slug>; --published reviews the
|
||||
// live post in app/src/content/blog/<slug>)
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { dirname, resolve, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { runClaude } from "./lib/claude.mjs";
|
||||
import { loadJson, readFrontmatter, fmString } from "./lib/util.mjs";
|
||||
import { readSlugs } from "./lib/blogData.mjs";
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const PIPELINE = resolve(HERE, "..");
|
||||
const cfg = loadJson(join(PIPELINE, "config.json"));
|
||||
const root = cfg.paths.projectRoot;
|
||||
|
||||
function postDirFor(slug, published) {
|
||||
const draftDir = join(root, cfg.paths.draftsDir, slug);
|
||||
const liveDir = join(root, cfg.paths.blogContentDir, slug);
|
||||
if (published) return liveDir;
|
||||
if (existsSync(join(draftDir, "index.mdx"))) return draftDir;
|
||||
if (existsSync(join(liveDir, "index.mdx"))) return liveDir;
|
||||
return draftDir; // will fail the existence check below
|
||||
}
|
||||
|
||||
function primaryKeywordFor(slug, mdxPath) {
|
||||
const calPath = join(root, cfg.paths.calendar);
|
||||
if (existsSync(calPath)) {
|
||||
const entry = loadJson(calPath).find((e) => e.slug === slug);
|
||||
if (entry?.primaryKeyword) return entry.primaryKeyword;
|
||||
}
|
||||
if (existsSync(mdxPath)) {
|
||||
const t = fmString(readFrontmatter(readFileSync(mdxPath, "utf8")), "title");
|
||||
if (t) return t;
|
||||
}
|
||||
return slug.replace(/-/g, " ");
|
||||
}
|
||||
|
||||
/** Run the SEO audit for one post. Returns the parsed audit object, or null on failure. */
|
||||
export async function runSeoReview(slug, { published = false } = {}) {
|
||||
const postDir = postDirFor(slug, published);
|
||||
const mdxPath = join(postDir, "index.mdx");
|
||||
if (!existsSync(mdxPath)) {
|
||||
console.error(`[seo] no article at ${mdxPath}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const keyword = primaryKeywordFor(slug, mdxPath);
|
||||
const calPathForEntry = join(root, cfg.paths.calendar);
|
||||
const calEntry = existsSync(calPathForEntry)
|
||||
? loadJson(calPathForEntry).find((e) => e.slug === slug)
|
||||
: null;
|
||||
const firsthandNote = calEntry?.authorFirsthand
|
||||
? `\n\nIMPORTANT — authorFirsthand piece: a real, named author (${cfg.author?.name || "the site author"}) wrote this personally from genuine first-hand experience. First-person voice and subjective opinions are LEGITIMATE Experience (positive E-E-A-T), NOT fabrication. Do not flag the personal voice as an EEAT/spam violation; only require citations for objective, verifiable facts (addresses, prices, dates, hours).`
|
||||
: "";
|
||||
const blogDir = join(root, cfg.paths.blogContentDir);
|
||||
const categories = [...readSlugs(join(root, cfg.paths.blogDataFile)).categories];
|
||||
const auditPath = join(postDir, "seo-review.json");
|
||||
const logFile = join(root, cfg.paths.logsDir, `seo-${slug}.log`);
|
||||
|
||||
const prompt = `Audit this Comiida article.
|
||||
|
||||
- Article: ${mdxPath}
|
||||
- Citations file (if present): ${join(postDir, "sources.json")}
|
||||
- Primary keyword to target: ${keyword}
|
||||
- Existing published posts (for internal-link validation): ${blogDir}
|
||||
- Allowed category slugs: ${categories.join(", ")}
|
||||
- Write your audit JSON to: ${auditPath}${firsthandNote}
|
||||
|
||||
Follow the rubric and output contract in your system prompt exactly.`;
|
||||
|
||||
await runClaude({
|
||||
prompt,
|
||||
systemPromptFile: join(PIPELINE, "prompts/seo-review.system.md"),
|
||||
model: cfg.models.reviewer,
|
||||
allowedTools: "Read Glob Grep WebSearch Write",
|
||||
addDirs: [root],
|
||||
cwd: root,
|
||||
logFile,
|
||||
});
|
||||
|
||||
if (!existsSync(auditPath)) {
|
||||
console.error(`[seo] audit not written: ${auditPath}`);
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return loadJson(auditPath);
|
||||
} catch (e) {
|
||||
console.error(`[seo] could not parse audit: ${e.message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// CLI
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
const slug = process.argv[2];
|
||||
const published = process.argv.includes("--published");
|
||||
if (!slug) {
|
||||
console.error("Usage: seo-review.mjs <slug> [--published]");
|
||||
process.exit(1);
|
||||
}
|
||||
const audit = await runSeoReview(slug, { published });
|
||||
if (!audit) process.exit(1);
|
||||
const nb = (audit.blocking || []).length;
|
||||
const nf = (audit.topFixes || []).length;
|
||||
console.log(`[seo] ${slug}: ${audit.verdict} · ${audit.overall} · ${nb} blocking · ${nf} fixes`);
|
||||
if (audit.summary) console.log(`[seo] ${audit.summary}`);
|
||||
if (nb) console.log("[seo] blocking:\n - " + audit.blocking.join("\n - "));
|
||||
process.exit(audit.verdict === "pass" ? 0 : audit.verdict === "revise" ? 2 : 3);
|
||||
}
|
||||
|
|
@ -1,215 +0,0 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* @agent-manifest
|
||||
* name: writer
|
||||
* title: Daily Blog Writer
|
||||
* class: content
|
||||
* trigger: cron:run.sh write-daily.mjs(daily)
|
||||
* description: Draft one article (MDX + cover + sources) — today's news first, else next evergreen calendar topic.
|
||||
* model: writer=claude-sonnet-4-6
|
||||
* prompt: prompts/writer.system.md, prompts/image.md
|
||||
* skills: mcp__claude_ai_Higgsfield
|
||||
* tools: lib/claude.mjs, lib/calendar.mjs
|
||||
* reads: -
|
||||
* writes: -
|
||||
* created: 2026-07-04
|
||||
* @end
|
||||
*/
|
||||
// Daily writer: draft one article (MDX + cover.jpg + sources.json) into drafts/<slug>/.
|
||||
// Selection order: today's news (news radar) first → else the next evergreen calendar topic.
|
||||
// Refills the evergreen backlog on demand when it runs low. All calendar writes are atomic.
|
||||
// Usage: node agents/scripts/write-daily.mjs [slug]
|
||||
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
||||
import { dirname, resolve, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { runClaude } from "./lib/claude.mjs";
|
||||
import { loadJson, todayInTz } from "./lib/util.mjs";
|
||||
import { readCalendar, updateEntry, addEntry } from "./lib/calendar.mjs";
|
||||
import { runSeoReview } from "./seo-review.mjs";
|
||||
import { runRevise } from "./revise.mjs";
|
||||
import { runNewsRadar, pickFreshNews, markNewsUsed } from "./news-radar.mjs";
|
||||
import { runResearch } from "./research.mjs";
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const PIPELINE = resolve(HERE, "..");
|
||||
const cfg = loadJson(join(PIPELINE, "config.json"));
|
||||
const root = cfg.paths.projectRoot;
|
||||
|
||||
if (cfg.author.slug === "REPLACE_ME") {
|
||||
console.error("[write-daily] config.author is not set. Add the real author first.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const calendarPath = join(root, cfg.paths.calendar);
|
||||
const today = todayInTz(cfg.editorial.timezone);
|
||||
const stateDir = join(root, cfg.paths.stateDir);
|
||||
|
||||
const argSlug = process.argv[2];
|
||||
let entry;
|
||||
|
||||
if (argSlug) {
|
||||
entry = readCalendar(calendarPath).find((e) => e.slug === argSlug);
|
||||
if (!entry) {
|
||||
console.error(`[write-daily] slug not found in calendar: ${argSlug}`);
|
||||
process.exit(1);
|
||||
}
|
||||
} else {
|
||||
// 0) User suggestions (from the admin dashboard) take top priority.
|
||||
const suggestion = readCalendar(calendarPath)
|
||||
.filter((e) => e.suggested && e.status === "planned")
|
||||
.sort((a, b) => (a.date || "").localeCompare(b.date || ""))[0];
|
||||
if (suggestion) {
|
||||
entry = suggestion;
|
||||
console.log(`[write-daily] suggestion-first: "${entry.workingTitle}"`);
|
||||
} else {
|
||||
mkdirSync(stateDir, { recursive: true });
|
||||
|
||||
// 1) Ensure today's news scan ran (guarded once/day so the cadence doesn't matter).
|
||||
if (cfg.news?.enabled) {
|
||||
const newsStatePath = join(stateDir, `news-${today}.json`);
|
||||
if (!existsSync(newsStatePath)) {
|
||||
try {
|
||||
await runNewsRadar();
|
||||
} catch (e) {
|
||||
console.log(`[write-daily] news radar error (continuing): ${e.message}`);
|
||||
}
|
||||
writeFileSync(newsStatePath, JSON.stringify({ scanned: today }, null, 2));
|
||||
}
|
||||
}
|
||||
|
||||
// 2) Demand-driven evergreen refill when the backlog is low (guarded once/day).
|
||||
const threshold = cfg.editorial.refillThreshold ?? 7;
|
||||
const plannedCount = readCalendar(calendarPath).filter((e) => e.status === "planned").length;
|
||||
const refillStatePath = join(stateDir, `research-${today}.json`);
|
||||
if (plannedCount < threshold && !existsSync(refillStatePath)) {
|
||||
console.log(`[write-daily] evergreen backlog low (${plannedCount} < ${threshold}) — refilling…`);
|
||||
try {
|
||||
const added = await runResearch({ count: cfg.editorial.refillCount ?? 14 });
|
||||
console.log(`[write-daily] refill added ${added} topics.`);
|
||||
} catch (e) {
|
||||
console.log(`[write-daily] refill error (continuing): ${e.message}`);
|
||||
}
|
||||
writeFileSync(refillStatePath, JSON.stringify({ refilled: today }, null, 2));
|
||||
}
|
||||
|
||||
// 3) News-first selection; inject the chosen news item as a calendar entry. Else evergreen.
|
||||
const news = pickFreshNews(cfg);
|
||||
if (news) {
|
||||
entry = {
|
||||
date: today,
|
||||
slug: news.slug,
|
||||
workingTitle: news.workingTitle,
|
||||
type: news.type || "news-roundup",
|
||||
primaryKeyword: news.primaryKeyword,
|
||||
secondaryKeywords: news.secondaryKeywords || [],
|
||||
searchIntent: news.searchIntent || "informational",
|
||||
audienceAngle: news.newsHook || "",
|
||||
sourceHints: news.sourceLinks || [],
|
||||
eeatAngle: "Timely, sourced news — cite every claim.",
|
||||
news: true,
|
||||
freshnessScore: news.freshnessScore ?? 3,
|
||||
status: "planned",
|
||||
};
|
||||
await addEntry(calendarPath, entry);
|
||||
markNewsUsed(news.slug);
|
||||
console.log(`[write-daily] news-first: "${entry.workingTitle}" (freshness ${entry.freshnessScore})`);
|
||||
} else {
|
||||
const cal = readCalendar(calendarPath);
|
||||
const planned = cal.filter((e) => e.status === "planned");
|
||||
const due = planned.filter((e) => e.date <= today).sort((a, b) => a.date.localeCompare(b.date));
|
||||
entry = due[0] || planned.sort((a, b) => a.date.localeCompare(b.date))[0];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!entry) {
|
||||
console.log("[write-daily] Nothing left to write — no fresh news and no planned topics.");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const draftDir = join(root, cfg.paths.draftsDir, entry.slug);
|
||||
mkdirSync(draftDir, { recursive: true });
|
||||
|
||||
const [wMin, wMax] = cfg.editorial.wordCounts[entry.type] || [800, 1200];
|
||||
const blogDir = join(root, cfg.paths.blogContentDir);
|
||||
const logFile = join(root, cfg.paths.logsDir, `write-${entry.slug}.log`);
|
||||
|
||||
const prompt = `Write today's Comiida article from this calendar entry:
|
||||
|
||||
${JSON.stringify({ ...entry, draft: undefined, instructions: undefined }, null, 2)}
|
||||
|
||||
- Today's date: ${today}
|
||||
- Author slug to byline (exact): ${cfg.author.slug}
|
||||
- Word count range for type "${entry.type}": ${wMin}–${wMax} words
|
||||
- Draft output directory (write index.mdx, cover.jpg, sources.json here): ${draftDir}
|
||||
- Existing published posts to read for internal links: ${blogDir}
|
||||
- Image: use the Higgsfield MCP (preferred model "${cfg.image.preferredModel}", aspect ratio
|
||||
~${cfg.image.aspectRatio}). Read agents/prompts/image.md and follow that flow
|
||||
(recommend model → generate_image → job_status sync → curl the result URL).
|
||||
Save the final image to: ${join(draftDir, "cover.jpg")}.
|
||||
${
|
||||
entry.authorFirsthand
|
||||
? `\nAUTHOR FIRST-HAND PIECE: this is ${cfg.author.name}'s own draft, written from genuine first-hand experience. Follow the "Author first-hand pieces" EXCEPTION in your system prompt — keep the FIRST-PERSON voice and the author's honest opinions (do not neutralize them), and only fact-check/cite objective specifics (addresses, opening dates, prices, hours). SCOPE: the article is about the venues and points the author actually names — cover those; do NOT pad the piece with generic restaurants the author didn't mention just to hit a word count. Aim for the LOWER end of the word-count range; a tight, genuine ~800–1000 words beats bloated filler.\n`
|
||||
: ""
|
||||
}${
|
||||
entry.instructions
|
||||
? `\nEDITOR INSTRUCTIONS (from the person who suggested this — follow them carefully):\n${entry.instructions}\n`
|
||||
: ""
|
||||
}${
|
||||
entry.draft
|
||||
? `\nEDITOR-PROVIDED DRAFT — use this as the basis for the article. Keep its intent and key points, but fact-check every claim, add real citations, improve structure/SEO, and expand it to meet the contract:\n"""\n${entry.draft}\n"""\n`
|
||||
: ""
|
||||
}
|
||||
Follow the EEAT/SEO contract in your system prompt exactly.`;
|
||||
|
||||
await updateEntry(calendarPath, entry.slug, { status: "drafting" });
|
||||
console.log(`[write-daily] drafting "${entry.workingTitle}" (${entry.type}) → ${draftDir}`);
|
||||
|
||||
const res = await runClaude({
|
||||
prompt,
|
||||
systemPromptFile: join(PIPELINE, "prompts/writer.system.md"),
|
||||
model: cfg.models.writer,
|
||||
// Higgsfield is configured globally via claude.ai (reachable headless) — no local --mcp-config.
|
||||
allowedTools: "Read Write Edit Glob Grep WebSearch Bash mcp__claude_ai_Higgsfield",
|
||||
addDirs: [root],
|
||||
cwd: root,
|
||||
logFile,
|
||||
});
|
||||
|
||||
console.log(`[write-daily] agent: ${res.result || "(no text)"}`);
|
||||
|
||||
const hasMdx = existsSync(join(draftDir, "index.mdx"));
|
||||
const hasCover = existsSync(join(draftDir, "cover.jpg"));
|
||||
const finalStatus = hasMdx ? (hasCover ? "drafted" : "drafted-no-image") : "draft-failed";
|
||||
await updateEntry(calendarPath, entry.slug, { status: finalStatus });
|
||||
|
||||
console.log(
|
||||
`[write-daily] status=${finalStatus} mdx=${hasMdx} cover=${hasCover}\n` +
|
||||
`[write-daily] review: ${draftDir} | log: ${logFile}`
|
||||
);
|
||||
if (!hasMdx) process.exit(1);
|
||||
|
||||
// SEO Specialist pass + auto-revise loop, so the approve/publish gate has a verdict.
|
||||
try {
|
||||
let audit = await runSeoReview(entry.slug, { published: false });
|
||||
const minScore = cfg.seo?.minScore ?? 85;
|
||||
const fails = (a) => !a || a.verdict === "fail" || (a.overall ?? 0) < minScore;
|
||||
|
||||
if (audit && cfg.seo?.autoRevise && fails(audit)) {
|
||||
console.log(`[write-daily] SEO ${audit.verdict} ${audit.overall} < ${minScore} — auto-revising…`);
|
||||
const { audit: revised, attempts } = await runRevise(entry.slug, {});
|
||||
if (revised) audit = revised;
|
||||
console.log(`[write-daily] auto-revise done after ${attempts} attempt(s): ${audit?.verdict} ${audit?.overall}`);
|
||||
}
|
||||
|
||||
if (audit) {
|
||||
await updateEntry(calendarPath, entry.slug, { seo: { verdict: audit.verdict, overall: audit.overall } });
|
||||
console.log(
|
||||
`[write-daily] SEO: ${audit.verdict} · ${audit.overall} · ${(audit.blocking || []).length} blocking`
|
||||
);
|
||||
} else {
|
||||
console.log("[write-daily] SEO: audit could not be generated (continuing).");
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(`[write-daily] SEO review error (continuing): ${e.message}`);
|
||||
}
|
||||
|
|
@ -113,8 +113,8 @@ should be deleted/rotated by the operator.
|
|||
Reuses the existing tables — no duplication.
|
||||
|
||||
- **`ApiController`** (base, privileged) — verifies a bearer token: either the
|
||||
`ADMIN_TOKEN` (config constant; same token pattern as `/devconsole` & `/admin`)
|
||||
for first-party/admin calls, **or** an `api_auth.apikey` for programmatic
|
||||
`ADMIN_TOKEN` (config constant) for first-party/admin calls, **or** an
|
||||
`api_auth.apikey` for programmatic
|
||||
consumers. On an API key: check `active`, enforce `api_plans.limit_minute` /
|
||||
`limit_monthly` against `api_usage`, and log to `api_requests`. Failures →
|
||||
`401` (missing/invalid) or `429` (over limit).
|
||||
|
|
|
|||
|
|
@ -1,38 +0,0 @@
|
|||
<?php
|
||||
/**
|
||||
* Resource registry bridge for the content agents. Reads curated sources (mde_resources)
|
||||
* per agent_type so the Node pipeline never needs DB creds.
|
||||
*
|
||||
* php api/cli/resources.php list <agent_type> -> JSON array of active resources
|
||||
* php api/cli/resources.php touch <id> [<id>...] -> stamp last_used_at
|
||||
*/
|
||||
require __DIR__ . '/../vendor/autoload.php';
|
||||
require __DIR__ . '/../config.php';
|
||||
|
||||
$cmd = $argv[1] ?? '';
|
||||
|
||||
if ($cmd === 'list') {
|
||||
$type = $argv[2] ?? '';
|
||||
if ($type === '') { fwrite(STDERR, "usage: resources.php list <agent_type>\n"); exit(1); }
|
||||
$rows = \Db::select(
|
||||
"SELECT resource_id, name, url, kind, notes, priority
|
||||
FROM mde_resources
|
||||
WHERE agent_type = ? AND active = 1
|
||||
ORDER BY priority DESC, resource_id ASC",
|
||||
[$type]
|
||||
);
|
||||
echo json_encode($rows, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($cmd === 'touch') {
|
||||
$ids = array_slice($argv, 2);
|
||||
foreach ($ids as $id) {
|
||||
\Db::execute("UPDATE mde_resources SET last_used_at = NOW() WHERE resource_id = ?", [(int) $id]);
|
||||
}
|
||||
echo json_encode(['ok' => true, 'touched' => count($ids)]);
|
||||
exit;
|
||||
}
|
||||
|
||||
fwrite(STDERR, "usage: resources.php list <agent_type> | touch <id...>\n");
|
||||
exit(1);
|
||||
|
|
@ -1,711 +0,0 @@
|
|||
---
|
||||
name: ui-ux-pro-max
|
||||
description: "UI/UX design intelligence for web and mobile. Includes 50+ styles, 161 color palettes, 57 font pairings, 161 product types, 99 UX guidelines, and 25 chart types across 10 stacks (React, Next.js, Vue, Svelte, SwiftUI, React Native, Flutter, Tailwind, shadcn/ui, and HTML/CSS). Actions: plan, build, create, design, implement, review, fix, improve, optimize, enhance, refactor, and check UI/UX code. Projects: website, landing page, dashboard, admin panel, e-commerce, SaaS, portfolio, blog, and mobile app. Elements: button, modal, navbar, sidebar, card, table, form, and chart. Styles: glassmorphism, claymorphism, minimalism, brutalism, neumorphism, bento grid, dark mode, responsive, skeuomorphism, and flat design. Topics: color systems, accessibility, animation, layout, typography, font pairing, spacing, interaction states, shadow, and gradient. Integrations: shadcn/ui MCP for component search and examples."
|
||||
---
|
||||
|
||||
> **astroagent environment note:** You run headless in a sandbox with **no shell/scripts**. Ignore any instruction here to run search scripts or `--domain` / `--design-system` / CLI queries — apply the written guidance directly. This project is an **Astro + Tailwind** website: prefer the Web/CSS/Tailwind rules and treat native iOS/Android-only items (haptics, VoiceOver, safe-area) as optional. Always match the project's existing design system first (read AGENTS.md / DESIGN.md and nearby components).
|
||||
|
||||
# UI/UX Pro Max - Design Intelligence
|
||||
|
||||
Comprehensive design guide for web and mobile applications. Contains 50+ styles, 161 color palettes, 57 font pairings, 161 product types with reasoning rules, 99 UX guidelines, and 25 chart types across 10 technology stacks. Searchable database with priority-based recommendations.
|
||||
|
||||
## When to Apply
|
||||
|
||||
This Skill should be used when the task involves **UI structure, visual design decisions, interaction patterns, or user experience quality control**.
|
||||
|
||||
### Must Use
|
||||
|
||||
This Skill must be invoked in the following situations:
|
||||
|
||||
- Designing new pages (Landing Page, Dashboard, Admin, SaaS, Mobile App)
|
||||
- Creating or refactoring UI components (buttons, modals, forms, tables, charts, etc.)
|
||||
- Choosing color schemes, typography systems, spacing standards, or layout systems
|
||||
- Reviewing UI code for user experience, accessibility, or visual consistency
|
||||
- Implementing navigation structures, animations, or responsive behavior
|
||||
- Making product-level design decisions (style, information hierarchy, brand expression)
|
||||
- Improving perceived quality, clarity, or usability of interfaces
|
||||
|
||||
### Recommended
|
||||
|
||||
This Skill is recommended in the following situations:
|
||||
|
||||
- UI looks "not professional enough" but the reason is unclear
|
||||
- Receiving feedback on usability or experience
|
||||
- Pre-launch UI quality optimization
|
||||
- Aligning cross-platform design (Web / iOS / Android)
|
||||
- Building design systems or reusable component libraries
|
||||
|
||||
### Skip
|
||||
|
||||
This Skill is not needed in the following situations:
|
||||
|
||||
- Pure backend logic development
|
||||
- Only involving API or database design
|
||||
- Performance optimization unrelated to the interface
|
||||
- Infrastructure or DevOps work
|
||||
- Non-visual scripts or automation tasks
|
||||
|
||||
**Decision criteria**: If the task will change how a feature **looks, feels, moves, or is interacted with**, this Skill should be used.
|
||||
|
||||
## Rule Categories by Priority
|
||||
|
||||
*For human/AI reference: follow priority 1→10 to decide which rule category to focus on first; use `--domain <Domain>` to query details when needed. Scripts do not read this table.*
|
||||
|
||||
| Priority | Category | Impact | Domain | Key Checks (Must Have) | Anti-Patterns (Avoid) |
|
||||
|----------|----------|--------|--------|------------------------|------------------------|
|
||||
| 1 | Accessibility | CRITICAL | `ux` | Contrast 4.5:1, Alt text, Keyboard nav, Aria-labels | Removing focus rings, Icon-only buttons without labels |
|
||||
| 2 | Touch & Interaction | CRITICAL | `ux` | Min size 44×44px, 8px+ spacing, Loading feedback | Reliance on hover only, Instant state changes (0ms) |
|
||||
| 3 | Performance | HIGH | `ux` | WebP/AVIF, Lazy loading, Reserve space (CLS < 0.1) | Layout thrashing, Cumulative Layout Shift |
|
||||
| 4 | Style Selection | HIGH | `style`, `product` | Match product type, Consistency, SVG icons (no emoji) | Mixing flat & skeuomorphic randomly, Emoji as icons |
|
||||
| 5 | Layout & Responsive | HIGH | `ux` | Mobile-first breakpoints, Viewport meta, No horizontal scroll | Horizontal scroll, Fixed px container widths, Disable zoom |
|
||||
| 6 | Typography & Color | MEDIUM | `typography`, `color` | Base 16px, Line-height 1.5, Semantic color tokens | Text < 12px body, Gray-on-gray, Raw hex in components |
|
||||
| 7 | Animation | MEDIUM | `ux` | Duration 150–300ms, Motion conveys meaning, Spatial continuity | Decorative-only animation, Animating width/height, No reduced-motion |
|
||||
| 8 | Forms & Feedback | MEDIUM | `ux` | Visible labels, Error near field, Helper text, Progressive disclosure | Placeholder-only label, Errors only at top, Overwhelm upfront |
|
||||
| 9 | Navigation Patterns | HIGH | `ux` | Predictable back, Bottom nav ≤5, Deep linking | Overloaded nav, Broken back behavior, No deep links |
|
||||
| 10 | Charts & Data | LOW | `chart` | Legends, Tooltips, Accessible colors | Relying on color alone to convey meaning |
|
||||
|
||||
## Quick Reference
|
||||
|
||||
### 1. Accessibility (CRITICAL)
|
||||
|
||||
- `color-contrast` - Minimum 4.5:1 ratio for normal text (large text 3:1); Material Design
|
||||
- `focus-states` - Visible focus rings on interactive elements (2–4px; Apple HIG, MD)
|
||||
- `alt-text` - Descriptive alt text for meaningful images
|
||||
- `aria-labels` - aria-label for icon-only buttons; accessibilityLabel in native (Apple HIG)
|
||||
- `keyboard-nav` - Tab order matches visual order; full keyboard support (Apple HIG)
|
||||
- `form-labels` - Use label with for attribute
|
||||
- `skip-links` - Skip to main content for keyboard users
|
||||
- `heading-hierarchy` - Sequential h1→h6, no level skip
|
||||
- `color-not-only` - Don't convey info by color alone (add icon/text)
|
||||
- `dynamic-type` - Support system text scaling; avoid truncation as text grows (Apple Dynamic Type, MD)
|
||||
- `reduced-motion` - Respect prefers-reduced-motion; reduce/disable animations when requested (Apple Reduced Motion API, MD)
|
||||
- `voiceover-sr` - Meaningful accessibilityLabel/accessibilityHint; logical reading order for VoiceOver/screen readers (Apple HIG, MD)
|
||||
- `escape-routes` - Provide cancel/back in modals and multi-step flows (Apple HIG)
|
||||
- `keyboard-shortcuts` - Preserve system and a11y shortcuts; offer keyboard alternatives for drag-and-drop (Apple HIG)
|
||||
|
||||
### 2. Touch & Interaction (CRITICAL)
|
||||
|
||||
- `touch-target-size` - Min 44×44pt (Apple) / 48×48dp (Material); extend hit area beyond visual bounds if needed
|
||||
- `touch-spacing` - Minimum 8px/8dp gap between touch targets (Apple HIG, MD)
|
||||
- `hover-vs-tap` - Use click/tap for primary interactions; don't rely on hover alone
|
||||
- `loading-buttons` - Disable button during async operations; show spinner or progress
|
||||
- `error-feedback` - Clear error messages near problem
|
||||
- `cursor-pointer` - Add cursor-pointer to clickable elements (Web)
|
||||
- `gesture-conflicts` - Avoid horizontal swipe on main content; prefer vertical scroll
|
||||
- `tap-delay` - Use touch-action: manipulation to reduce 300ms delay (Web)
|
||||
- `standard-gestures` - Use platform standard gestures consistently; don't redefine (e.g. swipe-back, pinch-zoom) (Apple HIG)
|
||||
- `system-gestures` - Don't block system gestures (Control Center, back swipe, etc.) (Apple HIG)
|
||||
- `press-feedback` - Visual feedback on press (ripple/highlight; MD state layers)
|
||||
- `haptic-feedback` - Use haptic for confirmations and important actions; avoid overuse (Apple HIG)
|
||||
- `gesture-alternative` - Don't rely on gesture-only interactions; always provide visible controls for critical actions
|
||||
- `safe-area-awareness` - Keep primary touch targets away from notch, Dynamic Island, gesture bar and screen edges
|
||||
- `no-precision-required` - Avoid requiring pixel-perfect taps on small icons or thin edges
|
||||
- `swipe-clarity` - Swipe actions must show clear affordance or hint (chevron, label, tutorial)
|
||||
- `drag-threshold` - Use a movement threshold before starting drag to avoid accidental drags
|
||||
|
||||
### 3. Performance (HIGH)
|
||||
|
||||
- `image-optimization` - Use WebP/AVIF, responsive images (srcset/sizes), lazy load non-critical assets
|
||||
- `image-dimension` - Declare width/height or use aspect-ratio to prevent layout shift (Core Web Vitals: CLS)
|
||||
- `font-loading` - Use font-display: swap/optional to avoid invisible text (FOIT); reserve space to reduce layout shift (MD)
|
||||
- `font-preload` - Preload only critical fonts; avoid overusing preload on every variant
|
||||
- `critical-css` - Prioritize above-the-fold CSS (inline critical CSS or early-loaded stylesheet)
|
||||
- `lazy-loading` - Lazy load non-hero components via dynamic import / route-level splitting
|
||||
- `bundle-splitting` - Split code by route/feature (React Suspense / Next.js dynamic) to reduce initial load and TTI
|
||||
- `third-party-scripts` - Load third-party scripts async/defer; audit and remove unnecessary ones (MD)
|
||||
- `reduce-reflows` - Avoid frequent layout reads/writes; batch DOM reads then writes
|
||||
- `content-jumping` - Reserve space for async content to avoid layout jumps (Core Web Vitals: CLS)
|
||||
- `lazy-load-below-fold` - Use loading="lazy" for below-the-fold images and heavy media
|
||||
- `virtualize-lists` - Virtualize lists with 50+ items to improve memory efficiency and scroll performance
|
||||
- `main-thread-budget` - Keep per-frame work under ~16ms for 60fps; move heavy tasks off main thread (HIG, MD)
|
||||
- `progressive-loading` - Use skeleton screens / shimmer instead of long blocking spinners for >1s operations (Apple HIG)
|
||||
- `input-latency` - Keep input latency under ~100ms for taps/scrolls (Material responsiveness standard)
|
||||
- `tap-feedback-speed` - Provide visual feedback within 100ms of tap (Apple HIG)
|
||||
- `debounce-throttle` - Use debounce/throttle for high-frequency events (scroll, resize, input)
|
||||
- `offline-support` - Provide offline state messaging and basic fallback (PWA / mobile)
|
||||
- `network-fallback` - Offer degraded modes for slow networks (lower-res images, fewer animations)
|
||||
|
||||
### 4. Style Selection (HIGH)
|
||||
|
||||
- `style-match` - Match style to product type (use `--design-system` for recommendations)
|
||||
- `consistency` - Use same style across all pages
|
||||
- `no-emoji-icons` - Use SVG icons (Heroicons, Lucide), not emojis
|
||||
- `color-palette-from-product` - Choose palette from product/industry (search `--domain color`)
|
||||
- `effects-match-style` - Shadows, blur, radius aligned with chosen style (glass / flat / clay etc.)
|
||||
- `platform-adaptive` - Respect platform idioms (iOS HIG vs Material): navigation, controls, typography, motion
|
||||
- `state-clarity` - Make hover/pressed/disabled states visually distinct while staying on-style (Material state layers)
|
||||
- `elevation-consistent` - Use a consistent elevation/shadow scale for cards, sheets, modals; avoid random shadow values
|
||||
- `dark-mode-pairing` - Design light/dark variants together to keep brand, contrast, and style consistent
|
||||
- `icon-style-consistent` - Use one icon set/visual language (stroke width, corner radius) across the product
|
||||
- `system-controls` - Prefer native/system controls over fully custom ones; only customize when branding requires it (Apple HIG)
|
||||
- `blur-purpose` - Use blur to indicate background dismissal (modals, sheets), not as decoration (Apple HIG)
|
||||
- `primary-action` - Each screen should have only one primary CTA; secondary actions visually subordinate (Apple HIG)
|
||||
|
||||
### 5. Layout & Responsive (HIGH)
|
||||
|
||||
- `viewport-meta` - width=device-width initial-scale=1 (never disable zoom)
|
||||
- `mobile-first` - Design mobile-first, then scale up to tablet and desktop
|
||||
- `breakpoint-consistency` - Use systematic breakpoints (e.g. 375 / 768 / 1024 / 1440)
|
||||
- `readable-font-size` - Minimum 16px body text on mobile (avoids iOS auto-zoom)
|
||||
- `line-length-control` - Mobile 35–60 chars per line; desktop 60–75 chars
|
||||
- `horizontal-scroll` - No horizontal scroll on mobile; ensure content fits viewport width
|
||||
- `spacing-scale` - Use 4pt/8dp incremental spacing system (Material Design)
|
||||
- `touch-density` - Keep component spacing comfortable for touch: not cramped, not causing mis-taps
|
||||
- `container-width` - Consistent max-width on desktop (max-w-6xl / 7xl)
|
||||
- `z-index-management` - Define layered z-index scale (e.g. 0 / 10 / 20 / 40 / 100 / 1000)
|
||||
- `fixed-element-offset` - Fixed navbar/bottom bar must reserve safe padding for underlying content
|
||||
- `scroll-behavior` - Avoid nested scroll regions that interfere with the main scroll experience
|
||||
- `viewport-units` - Prefer min-h-dvh over 100vh on mobile
|
||||
- `orientation-support` - Keep layout readable and operable in landscape mode
|
||||
- `content-priority` - Show core content first on mobile; fold or hide secondary content
|
||||
- `visual-hierarchy` - Establish hierarchy via size, spacing, contrast — not color alone
|
||||
|
||||
### 6. Typography & Color (MEDIUM)
|
||||
|
||||
- `line-height` - Use 1.5-1.75 for body text
|
||||
- `line-length` - Limit to 65-75 characters per line
|
||||
- `font-pairing` - Match heading/body font personalities
|
||||
- `font-scale` - Consistent type scale (e.g. 12 14 16 18 24 32)
|
||||
- `contrast-readability` - Darker text on light backgrounds (e.g. slate-900 on white)
|
||||
- `text-styles-system` - Use platform type system: iOS 11 Dynamic Type styles / Material 5 type roles (display, headline, title, body, label) (HIG, MD)
|
||||
- `weight-hierarchy` - Use font-weight to reinforce hierarchy: Bold headings (600–700), Regular body (400), Medium labels (500) (MD)
|
||||
- `color-semantic` - Define semantic color tokens (primary, secondary, error, surface, on-surface) not raw hex in components (Material color system)
|
||||
- `color-dark-mode` - Dark mode uses desaturated / lighter tonal variants, not inverted colors; test contrast separately (HIG, MD)
|
||||
- `color-accessible-pairs` - Foreground/background pairs must meet 4.5:1 (AA) or 7:1 (AAA); use tools to verify (WCAG, MD)
|
||||
- `color-not-decorative-only` - Functional color (error red, success green) must include icon/text; avoid color-only meaning (HIG, MD)
|
||||
- `truncation-strategy` - Prefer wrapping over truncation; when truncating use ellipsis and provide full text via tooltip/expand (Apple HIG)
|
||||
- `letter-spacing` - Respect default letter-spacing per platform; avoid tight tracking on body text (HIG, MD)
|
||||
- `number-tabular` - Use tabular/monospaced figures for data columns, prices, and timers to prevent layout shift
|
||||
- `whitespace-balance` - Use whitespace intentionally to group related items and separate sections; avoid visual clutter (Apple HIG)
|
||||
|
||||
### 7. Animation (MEDIUM)
|
||||
|
||||
- `duration-timing` - Use 150–300ms for micro-interactions; complex transitions ≤400ms; avoid >500ms (MD)
|
||||
- `transform-performance` - Use transform/opacity only; avoid animating width/height/top/left
|
||||
- `loading-states` - Show skeleton or progress indicator when loading exceeds 300ms
|
||||
- `excessive-motion` - Animate 1-2 key elements per view max
|
||||
- `easing` - Use ease-out for entering, ease-in for exiting; avoid linear for UI transitions
|
||||
- `motion-meaning` - Every animation must express a cause-effect relationship, not just be decorative (Apple HIG)
|
||||
- `state-transition` - State changes (hover / active / expanded / collapsed / modal) should animate smoothly, not snap
|
||||
- `continuity` - Page/screen transitions should maintain spatial continuity (shared element, directional slide) (Apple HIG)
|
||||
- `parallax-subtle` - Use parallax sparingly; must respect reduced-motion and not cause disorientation (Apple HIG)
|
||||
- `spring-physics` - Prefer spring/physics-based curves over linear or cubic-bezier for natural feel (Apple HIG fluid animations)
|
||||
- `exit-faster-than-enter` - Exit animations shorter than enter (~60–70% of enter duration) to feel responsive (MD motion)
|
||||
- `stagger-sequence` - Stagger list/grid item entrance by 30–50ms per item; avoid all-at-once or too-slow reveals (MD)
|
||||
- `shared-element-transition` - Use shared element / hero transitions for visual continuity between screens (MD, HIG)
|
||||
- `interruptible` - Animations must be interruptible; user tap/gesture cancels in-progress animation immediately (Apple HIG)
|
||||
- `no-blocking-animation` - Never block user input during an animation; UI must stay interactive (Apple HIG)
|
||||
- `fade-crossfade` - Use crossfade for content replacement within the same container (MD)
|
||||
- `scale-feedback` - Subtle scale (0.95–1.05) on press for tappable cards/buttons; restore on release (HIG, MD)
|
||||
- `gesture-feedback` - Drag, swipe, and pinch must provide real-time visual response tracking the finger (MD Motion)
|
||||
- `hierarchy-motion` - Use translate/scale direction to express hierarchy: enter from below = deeper, exit upward = back (MD)
|
||||
- `motion-consistency` - Unify duration/easing tokens globally; all animations share the same rhythm and feel
|
||||
- `opacity-threshold` - Fading elements should not linger below opacity 0.2; either fade fully or remain visible
|
||||
- `modal-motion` - Modals/sheets should animate from their trigger source (scale+fade or slide-in) for spatial context (HIG, MD)
|
||||
- `navigation-direction` - Forward navigation animates left/up; backward animates right/down — keep direction logically consistent (HIG)
|
||||
- `layout-shift-avoid` - Animations must not cause layout reflow or CLS; use transform for position changes
|
||||
|
||||
### 8. Forms & Feedback (MEDIUM)
|
||||
|
||||
- `input-labels` - Visible label per input (not placeholder-only)
|
||||
- `error-placement` - Show error below the related field
|
||||
- `submit-feedback` - Loading then success/error state on submit
|
||||
- `required-indicators` - Mark required fields (e.g. asterisk)
|
||||
- `empty-states` - Helpful message and action when no content
|
||||
- `toast-dismiss` - Auto-dismiss toasts in 3-5s
|
||||
- `confirmation-dialogs` - Confirm before destructive actions
|
||||
- `input-helper-text` - Provide persistent helper text below complex inputs, not just placeholder (Material Design)
|
||||
- `disabled-states` - Disabled elements use reduced opacity (0.38–0.5) + cursor change + semantic attribute (MD)
|
||||
- `progressive-disclosure` - Reveal complex options progressively; don't overwhelm users upfront (Apple HIG)
|
||||
- `inline-validation` - Validate on blur (not keystroke); show error only after user finishes input (MD)
|
||||
- `input-type-keyboard` - Use semantic input types (email, tel, number) to trigger the correct mobile keyboard (HIG, MD)
|
||||
- `password-toggle` - Provide show/hide toggle for password fields (MD)
|
||||
- `autofill-support` - Use autocomplete / textContentType attributes so the system can autofill (HIG, MD)
|
||||
- `undo-support` - Allow undo for destructive or bulk actions (e.g. "Undo delete" toast) (Apple HIG)
|
||||
- `success-feedback` - Confirm completed actions with brief visual feedback (checkmark, toast, color flash) (MD)
|
||||
- `error-recovery` - Error messages must include a clear recovery path (retry, edit, help link) (HIG, MD)
|
||||
- `multi-step-progress` - Multi-step flows show step indicator or progress bar; allow back navigation (MD)
|
||||
- `form-autosave` - Long forms should auto-save drafts to prevent data loss on accidental dismissal (Apple HIG)
|
||||
- `sheet-dismiss-confirm` - Confirm before dismissing a sheet/modal with unsaved changes (Apple HIG)
|
||||
- `error-clarity` - Error messages must state cause + how to fix (not just "Invalid input") (HIG, MD)
|
||||
- `field-grouping` - Group related fields logically (fieldset/legend or visual grouping) (MD)
|
||||
- `read-only-distinction` - Read-only state should be visually and semantically different from disabled (MD)
|
||||
- `focus-management` - After submit error, auto-focus the first invalid field (WCAG, MD)
|
||||
- `error-summary` - For multiple errors, show summary at top with anchor links to each field (WCAG)
|
||||
- `touch-friendly-input` - Mobile input height ≥44px to meet touch target requirements (Apple HIG)
|
||||
- `destructive-emphasis` - Destructive actions use semantic danger color (red) and are visually separated from primary actions (HIG, MD)
|
||||
- `toast-accessibility` - Toasts must not steal focus; use aria-live="polite" for screen reader announcement (WCAG)
|
||||
- `aria-live-errors` - Form errors use aria-live region or role="alert" to notify screen readers (WCAG)
|
||||
- `contrast-feedback` - Error and success state colors must meet 4.5:1 contrast ratio (WCAG, MD)
|
||||
- `timeout-feedback` - Request timeout must show clear feedback with retry option (MD)
|
||||
|
||||
### 9. Navigation Patterns (HIGH)
|
||||
|
||||
- `bottom-nav-limit` - Bottom navigation max 5 items; use labels with icons (Material Design)
|
||||
- `drawer-usage` - Use drawer/sidebar for secondary navigation, not primary actions (Material Design)
|
||||
- `back-behavior` - Back navigation must be predictable and consistent; preserve scroll/state (Apple HIG, MD)
|
||||
- `deep-linking` - All key screens must be reachable via deep link / URL for sharing and notifications (Apple HIG, MD)
|
||||
- `tab-bar-ios` - iOS: use bottom Tab Bar for top-level navigation (Apple HIG)
|
||||
- `top-app-bar-android` - Android: use Top App Bar with navigation icon for primary structure (Material Design)
|
||||
- `nav-label-icon` - Navigation items must have both icon and text label; icon-only nav harms discoverability (MD)
|
||||
- `nav-state-active` - Current location must be visually highlighted (color, weight, indicator) in navigation (HIG, MD)
|
||||
- `nav-hierarchy` - Primary nav (tabs/bottom bar) vs secondary nav (drawer/settings) must be clearly separated (MD)
|
||||
- `modal-escape` - Modals and sheets must offer a clear close/dismiss affordance; swipe-down to dismiss on mobile (Apple HIG)
|
||||
- `search-accessible` - Search must be easily reachable (top bar or tab); provide recent/suggested queries (MD)
|
||||
- `breadcrumb-web` - Web: use breadcrumbs for 3+ level deep hierarchies to aid orientation (MD)
|
||||
- `state-preservation` - Navigating back must restore previous scroll position, filter state, and input (HIG, MD)
|
||||
- `gesture-nav-support` - Support system gesture navigation (iOS swipe-back, Android predictive back) without conflict (HIG, MD)
|
||||
- `tab-badge` - Use badges on nav items sparingly to indicate unread/pending; clear after user visits (HIG, MD)
|
||||
- `overflow-menu` - When actions exceed available space, use overflow/more menu instead of cramming (MD)
|
||||
- `bottom-nav-top-level` - Bottom nav is for top-level screens only; never nest sub-navigation inside it (MD)
|
||||
- `adaptive-navigation` - Large screens (≥1024px) prefer sidebar; small screens use bottom/top nav (Material Adaptive)
|
||||
- `back-stack-integrity` - Never silently reset the navigation stack or unexpectedly jump to home (HIG, MD)
|
||||
- `navigation-consistency` - Navigation placement must stay the same across all pages; don't change by page type
|
||||
- `avoid-mixed-patterns` - Don't mix Tab + Sidebar + Bottom Nav at the same hierarchy level
|
||||
- `modal-vs-navigation` - Modals must not be used for primary navigation flows; they break the user's path (HIG)
|
||||
- `focus-on-route-change` - After page transition, move focus to main content region for screen reader users (WCAG)
|
||||
- `persistent-nav` - Core navigation must remain reachable from deep pages; don't hide it entirely in sub-flows (HIG, MD)
|
||||
- `destructive-nav-separation` - Dangerous actions (delete account, logout) must be visually and spatially separated from normal nav items (HIG, MD)
|
||||
- `empty-nav-state` - When a nav destination is unavailable, explain why instead of silently hiding it (MD)
|
||||
|
||||
### 10. Charts & Data (LOW)
|
||||
|
||||
- `chart-type` - Match chart type to data type (trend → line, comparison → bar, proportion → pie/donut)
|
||||
- `color-guidance` - Use accessible color palettes; avoid red/green only pairs for colorblind users (WCAG, MD)
|
||||
- `data-table` - Provide table alternative for accessibility; charts alone are not screen-reader friendly (WCAG)
|
||||
- `pattern-texture` - Supplement color with patterns, textures, or shapes so data is distinguishable without color (WCAG, MD)
|
||||
- `legend-visible` - Always show legend; position near the chart, not detached below a scroll fold (MD)
|
||||
- `tooltip-on-interact` - Provide tooltips/data labels on hover (Web) or tap (mobile) showing exact values (HIG, MD)
|
||||
- `axis-labels` - Label axes with units and readable scale; avoid truncated or rotated labels on mobile
|
||||
- `responsive-chart` - Charts must reflow or simplify on small screens (e.g. horizontal bar instead of vertical, fewer ticks)
|
||||
- `empty-data-state` - Show meaningful empty state when no data exists ("No data yet" + guidance), not a blank chart (MD)
|
||||
- `loading-chart` - Use skeleton or shimmer placeholder while chart data loads; don't show an empty axis frame
|
||||
- `animation-optional` - Chart entrance animations must respect prefers-reduced-motion; data should be readable immediately (HIG)
|
||||
- `large-dataset` - For 1000+ data points, aggregate or sample; provide drill-down for detail instead of rendering all (MD)
|
||||
- `number-formatting` - Use locale-aware formatting for numbers, dates, currencies on axes and labels (HIG, MD)
|
||||
- `touch-target-chart` - Interactive chart elements (points, segments) must have ≥44pt tap area or expand on touch (Apple HIG)
|
||||
- `no-pie-overuse` - Avoid pie/donut for >5 categories; switch to bar chart for clarity
|
||||
- `contrast-data` - Data lines/bars vs background ≥3:1; data text labels ≥4.5:1 (WCAG)
|
||||
- `legend-interactive` - Legends should be clickable to toggle series visibility (MD)
|
||||
- `direct-labeling` - For small datasets, label values directly on the chart to reduce eye travel
|
||||
- `tooltip-keyboard` - Tooltip content must be keyboard-reachable and not rely on hover alone (WCAG)
|
||||
- `sortable-table` - Data tables must support sorting with aria-sort indicating current sort state (WCAG)
|
||||
- `axis-readability` - Axis ticks must not be cramped; maintain readable spacing, auto-skip on small screens
|
||||
- `data-density` - Limit information density per chart to avoid cognitive overload; split into multiple charts if needed
|
||||
- `trend-emphasis` - Emphasize data trends over decoration; avoid heavy gradients/shadows that obscure the data
|
||||
- `gridline-subtle` - Grid lines should be low-contrast (e.g. gray-200) so they don't compete with data
|
||||
- `focusable-elements` - Interactive chart elements (points, bars, slices) must be keyboard-navigable (WCAG)
|
||||
- `screen-reader-summary` - Provide a text summary or aria-label describing the chart's key insight for screen readers (WCAG)
|
||||
- `error-state-chart` - Data load failure must show error message with retry action, not a broken/empty chart
|
||||
- `export-option` - For data-heavy products, offer CSV/image export of chart data
|
||||
- `drill-down-consistency` - Drill-down interactions must maintain a clear back-path and hierarchy breadcrumb
|
||||
- `time-scale-clarity` - Time series charts must clearly label time granularity (day/week/month) and allow switching
|
||||
|
||||
## How to Use
|
||||
|
||||
Search specific domains using the CLI tool below.
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Check if Python is installed:
|
||||
|
||||
```bash
|
||||
python3 --version || python --version
|
||||
```
|
||||
|
||||
If Python is not installed, install it based on user's OS:
|
||||
|
||||
**macOS:**
|
||||
```bash
|
||||
brew install python3
|
||||
```
|
||||
|
||||
**Ubuntu/Debian:**
|
||||
```bash
|
||||
sudo apt update && sudo apt install python3
|
||||
```
|
||||
|
||||
**Windows:**
|
||||
```powershell
|
||||
winget install Python.Python.3.12
|
||||
```
|
||||
|
||||
> **Note:** On Windows, use `python` instead of `python3` to run scripts (e.g., `python scripts/search.py` instead of `python3 scripts/search.py`).
|
||||
|
||||
---
|
||||
|
||||
## How to Use This Skill
|
||||
|
||||
Use this skill when the user requests any of the following:
|
||||
|
||||
| Scenario | Trigger Examples | Start From |
|
||||
|----------|-----------------|------------|
|
||||
| **New project / page** | "Build a landing page", "Build a dashboard" | Step 1 → Step 2 (design system) |
|
||||
| **New component** | "Create a pricing card", "Add a modal" | Step 3 (domain search: style, ux) |
|
||||
| **Choose style / color / font** | "What style fits a fintech app?", "Recommend a color palette" | Step 2 (design system) |
|
||||
| **Review existing UI** | "Review this page for UX issues", "Check accessibility" | Quick Reference checklist above |
|
||||
| **Fix a UI bug** | "Button hover is broken", "Layout shifts on load" | Quick Reference → relevant section |
|
||||
| **Improve / optimize** | "Make this faster", "Improve mobile experience" | Step 3 (domain search: ux, react) |
|
||||
| **Implement dark mode** | "Add dark mode support" | Step 3 (domain: style "dark mode") |
|
||||
| **Add charts / data viz** | "Add an analytics dashboard chart" | Step 3 (domain: chart) |
|
||||
| **Stack best practices** | "React performance tips"、"SwiftUI navigation" | Step 4 (stack search) |
|
||||
|
||||
Follow this workflow:
|
||||
|
||||
### Step 1: Analyze User Requirements
|
||||
|
||||
Extract key information from user request:
|
||||
- **Product type**: Entertainment (social, video, music, gaming), Tool (scanner, editor, converter), Productivity (task manager, notes, calendar), or hybrid
|
||||
- **Target audience**: C-end consumer users; consider age group, usage context (commute, leisure, work)
|
||||
- **Style keywords**: playful, vibrant, minimal, dark mode, content-first, immersive, etc.
|
||||
- **Stack**: Match the project's framework. The engine ships guidance for many stacks (see [Available Stacks](#available-stacks) below) — pass the matching `--stack` (e.g. `nextjs`, `react`, `shadcn`, `vue`, `svelte`, `astro`, `swiftui`, `flutter`, `react-native`).
|
||||
|
||||
### Step 2: Generate Design System (REQUIRED)
|
||||
|
||||
**Always start with `--design-system`** to get comprehensive recommendations with reasoning:
|
||||
|
||||
```bash
|
||||
python3 skills/ui-ux-pro-max/scripts/search.py "<product_type> <industry> <keywords>" --design-system [-p "Project Name"]
|
||||
```
|
||||
|
||||
This command:
|
||||
1. Searches domains in parallel (product, style, color, landing, typography)
|
||||
2. Applies reasoning rules from `ui-reasoning.csv` to select best matches
|
||||
3. Returns complete design system: pattern, style, colors, typography, effects
|
||||
4. Includes anti-patterns to avoid
|
||||
|
||||
**Example:**
|
||||
```bash
|
||||
python3 skills/ui-ux-pro-max/scripts/search.py "beauty spa wellness service" --design-system -p "Serenity Spa"
|
||||
```
|
||||
|
||||
### Step 2b: Persist Design System (Master + Overrides Pattern)
|
||||
|
||||
To save the design system for **hierarchical retrieval across sessions**, add `--persist`:
|
||||
|
||||
```bash
|
||||
python3 skills/ui-ux-pro-max/scripts/search.py "<query>" --design-system --persist -p "Project Name"
|
||||
```
|
||||
|
||||
This creates:
|
||||
- `design-system/MASTER.md` — Global Source of Truth with all design rules
|
||||
- `design-system/pages/` — Folder for page-specific overrides
|
||||
|
||||
**With page-specific override:**
|
||||
```bash
|
||||
python3 skills/ui-ux-pro-max/scripts/search.py "<query>" --design-system --persist -p "Project Name" --page "dashboard"
|
||||
```
|
||||
|
||||
This also creates:
|
||||
- `design-system/pages/dashboard.md` — Page-specific deviations from Master
|
||||
|
||||
**How hierarchical retrieval works:**
|
||||
1. When building a specific page (e.g., "Checkout"), first check `design-system/pages/checkout.md`
|
||||
2. If the page file exists, its rules **override** the Master file
|
||||
3. If not, use `design-system/MASTER.md` exclusively
|
||||
|
||||
**Context-aware retrieval prompt:**
|
||||
```
|
||||
I am building the [Page Name] page. Please read design-system/MASTER.md.
|
||||
Also check if design-system/pages/[page-name].md exists.
|
||||
If the page file exists, prioritize its rules.
|
||||
If not, use the Master rules exclusively.
|
||||
Now, generate the code...
|
||||
```
|
||||
|
||||
### Step 2c: Design Dials (optional)
|
||||
|
||||
Three optional 1-10 sliders that tune `--design-system` output without changing your query. Add any combination of them to the same command:
|
||||
|
||||
```bash
|
||||
python3 skills/ui-ux-pro-max/scripts/search.py "<query>" --design-system --variance <1-10> --motion <1-10> --density <1-10>
|
||||
```
|
||||
|
||||
| Dial | Low (1-3) | Mid (4-7) | High (8-10) |
|
||||
|------|-----------|-----------|-------------|
|
||||
| `--variance` | Centered / minimal (biases toward Minimalism-style categories) | Balanced / modern | Bold / asymmetric (biases toward Brutalism, Bento Grids) |
|
||||
| `--motion` | Subtle micro-interactions | Standard scroll/stagger motion | Complex choreography (pin, Flip, SplitText) |
|
||||
| `--density` | Spacious (24-96px spacing scale) | Standard (16-64px, current default) | Dense/dashboard (8-32px spacing scale) |
|
||||
|
||||
- `--motion` attaches a ready-to-use GSAP snippet (with framework notes, Do/Don't, and performance notes) pulled from `--domain gsap`, matched to the resolved tier (Subtle/Standard/Complex).
|
||||
- `--density` overrides the `--space-*` CSS variable table in the ASCII/markdown/MASTER.md output — use it for dashboards (high) vs. marketing pages (low) without hand-editing tokens.
|
||||
- Leaving a dial unset keeps that part of the output exactly as it was before (no behavior change).
|
||||
|
||||
**Example:**
|
||||
```bash
|
||||
python3 skills/ui-ux-pro-max/scripts/search.py "internal analytics dashboard" --design-system --variance 8 --motion 7 --density 8 -p "Ops Console"
|
||||
```
|
||||
|
||||
### Step 3: Supplement with Detailed Searches (as needed)
|
||||
|
||||
After getting the design system, use domain searches to get additional details:
|
||||
|
||||
```bash
|
||||
python3 skills/ui-ux-pro-max/scripts/search.py "<keyword>" --domain <domain> [-n <max_results>]
|
||||
```
|
||||
|
||||
**When to use detailed searches:**
|
||||
|
||||
| Need | Domain | Example |
|
||||
|------|--------|---------|
|
||||
| Product type patterns | `product` | `--domain product "entertainment social"` |
|
||||
| More style options | `style` | `--domain style "glassmorphism dark"` |
|
||||
| Color palettes | `color` | `--domain color "entertainment vibrant"` |
|
||||
| Font pairings | `typography` | `--domain typography "playful modern"` |
|
||||
| Chart recommendations | `chart` | `--domain chart "real-time dashboard"` |
|
||||
| UX best practices | `ux` | `--domain ux "animation accessibility"` |
|
||||
| Alternative fonts | `typography` | `--domain typography "elegant luxury"` |
|
||||
| Individual Google Fonts | `google-fonts` | `--domain google-fonts "sans serif popular variable"` |
|
||||
| Landing structure | `landing` | `--domain landing "hero social-proof"` |
|
||||
| React Native perf | `react` | `--domain react "rerender memo list"` |
|
||||
| App interface a11y | `web` | `--domain web "accessibilityLabel touch safe-areas"` |
|
||||
| AI prompt / CSS keywords | `prompt` | `--domain prompt "minimalism"` |
|
||||
|
||||
### Step 4: Stack Guidelines (match your framework)
|
||||
|
||||
Get implementation-specific best practices for the stack you're building in.
|
||||
Pass the `--stack` that matches the project's framework:
|
||||
|
||||
```bash
|
||||
python3 skills/ui-ux-pro-max/scripts/search.py "<keyword>" --stack <your-stack>
|
||||
# e.g. --stack nextjs | react | shadcn | vue | svelte | astro | swiftui | flutter | react-native
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Search Reference
|
||||
|
||||
### Available Domains
|
||||
|
||||
| Domain | Use For | Example Keywords |
|
||||
|--------|---------|------------------|
|
||||
| `product` | Product type recommendations | SaaS, e-commerce, portfolio, healthcare, beauty, service |
|
||||
| `style` | UI styles, colors, effects | glassmorphism, minimalism, dark mode, brutalism |
|
||||
| `typography` | Font pairings, Google Fonts | elegant, playful, professional, modern |
|
||||
| `color` | Color palettes by product type | saas, ecommerce, healthcare, beauty, fintech, service |
|
||||
| `landing` | Page structure, CTA strategies | hero, hero-centric, testimonial, pricing, social-proof |
|
||||
| `chart` | Chart types, library recommendations | trend, comparison, timeline, funnel, pie |
|
||||
| `ux` | Best practices, anti-patterns | animation, accessibility, z-index, loading |
|
||||
| `gsap` | GSAP animation skeletons by intensity tier | scroll reveal, stagger, magnetic cursor, page transition |
|
||||
| `google-fonts` | Individual Google Fonts lookup | sans serif, monospace, japanese, variable font, popular |
|
||||
| `react` | React/Next.js performance | waterfall, bundle, suspense, memo, rerender, cache |
|
||||
| `web` | App interface guidelines (iOS/Android/React Native) | accessibilityLabel, touch targets, safe areas, Dynamic Type |
|
||||
| `prompt` | AI prompts, CSS keywords | (style name) |
|
||||
|
||||
### Available Stacks
|
||||
|
||||
Run `ls <skill>/data/stacks/` to see the live set. Shipped stacks:
|
||||
|
||||
| Stack | Focus |
|
||||
|-------|-------|
|
||||
| `react` | Components, hooks, render performance |
|
||||
| `nextjs` | App Router, RSC, Server Actions, rendering |
|
||||
| `vue` | Components, Composition API, reactivity |
|
||||
| `nuxtjs` | Nuxt app patterns, SSR data fetching |
|
||||
| `nuxt-ui` | Nuxt UI component patterns |
|
||||
| `svelte` | Components, stores, transitions |
|
||||
| `astro` | Islands, content, partial hydration |
|
||||
| `shadcn` | shadcn/ui primitives, composition |
|
||||
| `html-tailwind` | Tailwind utility patterns |
|
||||
| `angular` | Components, signals, services |
|
||||
| `laravel` | Blade / server-rendered UI patterns |
|
||||
| `swiftui` | Views, state, navigation (iOS/macOS) |
|
||||
| `flutter` | Widgets, state, navigation |
|
||||
| `jetpack-compose` | Composables, state, navigation (Android) |
|
||||
| `react-native` | Components, Navigation, Lists |
|
||||
| `threejs` | 3D scenes, materials, performance |
|
||||
|
||||
---
|
||||
|
||||
## Example Workflow
|
||||
|
||||
**User request:** "Make an AI search homepage."
|
||||
|
||||
### Step 1: Analyze Requirements
|
||||
- Product type: Tool (AI search engine)
|
||||
- Target audience: C-end users looking for fast, intelligent search
|
||||
- Style keywords: modern, minimal, content-first, dark mode
|
||||
- Stack: Next.js (a homepage is a web surface; use a web `--stack`)
|
||||
|
||||
### Step 2: Generate Design System (REQUIRED)
|
||||
|
||||
```bash
|
||||
python3 skills/ui-ux-pro-max/scripts/search.py "AI search tool modern minimal" --design-system -p "AI Search"
|
||||
```
|
||||
|
||||
**Output:** Complete design system with pattern, style, colors, typography, effects, and anti-patterns.
|
||||
|
||||
### Step 3: Supplement with Detailed Searches (as needed)
|
||||
|
||||
```bash
|
||||
# Get style options for a modern tool product
|
||||
python3 skills/ui-ux-pro-max/scripts/search.py "minimalism dark mode" --domain style
|
||||
|
||||
# Get UX best practices for search interaction and loading
|
||||
python3 skills/ui-ux-pro-max/scripts/search.py "search loading animation" --domain ux
|
||||
```
|
||||
|
||||
### Step 4: Stack Guidelines
|
||||
|
||||
```bash
|
||||
python3 skills/ui-ux-pro-max/scripts/search.py "list performance navigation" --stack nextjs
|
||||
```
|
||||
|
||||
**Then:** Synthesize design system + detailed searches and implement the design.
|
||||
|
||||
---
|
||||
|
||||
## Output Formats
|
||||
|
||||
The `--design-system` flag supports two output formats:
|
||||
|
||||
```bash
|
||||
# ASCII box (default) - best for terminal display
|
||||
python3 skills/ui-ux-pro-max/scripts/search.py "fintech crypto" --design-system
|
||||
|
||||
# Markdown - best for documentation
|
||||
python3 skills/ui-ux-pro-max/scripts/search.py "fintech crypto" --design-system -f markdown
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tips for Better Results
|
||||
|
||||
### Query Strategy
|
||||
|
||||
- Use **multi-dimensional keywords** — combine product + industry + tone + density: `"entertainment social vibrant content-dense"` not just `"app"`
|
||||
- Try different keywords for the same need: `"playful neon"` → `"vibrant dark"` → `"content-first minimal"`
|
||||
- Use `--design-system` first for full recommendations, then `--domain` to deep-dive any dimension you're unsure about
|
||||
- Add the `--stack` that matches the project's framework for implementation-specific guidance
|
||||
|
||||
### Common Sticking Points
|
||||
|
||||
| Problem | What to Do |
|
||||
|---------|------------|
|
||||
| Can't decide on style/color | Re-run `--design-system` with different keywords |
|
||||
| Dark mode contrast issues | Quick Reference §6: `color-dark-mode` + `color-accessible-pairs` |
|
||||
| Animations feel unnatural | Quick Reference §7: `spring-physics` + `easing` + `exit-faster-than-enter` |
|
||||
| Form UX is poor | Quick Reference §8: `inline-validation` + `error-clarity` + `focus-management` |
|
||||
| Navigation feels confusing | Quick Reference §9: `nav-hierarchy` + `bottom-nav-limit` + `back-behavior` |
|
||||
| Layout breaks on small screens | Quick Reference §5: `mobile-first` + `breakpoint-consistency` |
|
||||
| Performance / jank | Quick Reference §3: `virtualize-lists` + `main-thread-budget` + `debounce-throttle` |
|
||||
|
||||
### Pre-Delivery Checklist
|
||||
|
||||
- Run `--domain ux "animation accessibility z-index loading"` as a UX validation pass before implementation
|
||||
- Run through Quick Reference **§1–§3** (CRITICAL + HIGH) as a final review
|
||||
- Test on 375px (small phone) and landscape orientation
|
||||
- Verify behavior with **reduced-motion** enabled and **Dynamic Type** at largest size
|
||||
- Check dark mode contrast independently (don't assume light mode values work)
|
||||
- Confirm all touch targets ≥44pt and no content hidden behind safe areas
|
||||
|
||||
---
|
||||
|
||||
## Common Rules for Professional UI
|
||||
|
||||
These are frequently overlooked issues that make UI look unprofessional:
|
||||
Scope notice: The rules below are for App UI (iOS/Android/React Native/Flutter), not desktop-web interaction patterns.
|
||||
|
||||
### Icons & Visual Elements
|
||||
|
||||
| Rule | Standard | Avoid | Why It Matters |
|
||||
|------|----------|--------|----------------|
|
||||
| **No Emoji as Structural Icons** | Use vector-based icons (e.g., Lucide, react-native-vector-icons, @expo/vector-icons). | Using emojis (🎨 🚀 ⚙️) for navigation, settings, or system controls. | Emojis are font-dependent, inconsistent across platforms, and cannot be controlled via design tokens. |
|
||||
| **Vector-Only Assets** | Use SVG or platform vector icons that scale cleanly and support theming. | Raster PNG icons that blur or pixelate. | Ensures scalability, crisp rendering, and dark/light mode adaptability. |
|
||||
| **Stable Interaction States** | Use color, opacity, or elevation transitions for press states without changing layout bounds. | Layout-shifting transforms that move surrounding content or trigger visual jitter. | Prevents unstable interactions and preserves smooth motion/perceived quality on mobile. |
|
||||
| **Correct Brand Logos** | Use official brand assets and follow their usage guidelines (spacing, color, clear space). | Guessing logo paths, recoloring unofficially, or modifying proportions. | Prevents brand misuse and ensures legal/platform compliance. |
|
||||
| **Consistent Icon Sizing** | Define icon sizes as design tokens (e.g., icon-sm, icon-md = 24pt, icon-lg). | Mixing arbitrary values like 20pt / 24pt / 28pt randomly. | Maintains rhythm and visual hierarchy across the interface. |
|
||||
| **Stroke Consistency** | Use a consistent stroke width within the same visual layer (e.g., 1.5px or 2px). | Mixing thick and thin stroke styles arbitrarily. | Inconsistent strokes reduce perceived polish and cohesion. |
|
||||
| **Filled vs Outline Discipline** | Use one icon style per hierarchy level. | Mixing filled and outline icons at the same hierarchy level. | Maintains semantic clarity and stylistic coherence. |
|
||||
| **Touch Target Minimum** | Minimum 44×44pt interactive area (use hitSlop if icon is smaller). | Small icons without expanded tap area. | Meets accessibility and platform usability standards. |
|
||||
| **Icon Alignment** | Align icons to text baseline and maintain consistent padding. | Misaligned icons or inconsistent spacing around them. | Prevents subtle visual imbalance that reduces perceived quality. |
|
||||
| **Icon Contrast** | Follow WCAG contrast standards: 4.5:1 for small elements, 3:1 minimum for larger UI glyphs. | Low-contrast icons that blend into the background. | Ensures accessibility in both light and dark modes. |
|
||||
|
||||
|
||||
### Interaction (App)
|
||||
|
||||
| Rule | Do | Don't |
|
||||
|------|----|----- |
|
||||
| **Tap feedback** | Provide clear pressed feedback (ripple/opacity/elevation) within 80-150ms | No visual response on tap |
|
||||
| **Animation timing** | Keep micro-interactions around 150-300ms with platform-native easing | Instant transitions or slow animations (>500ms) |
|
||||
| **Accessibility focus** | Ensure screen reader focus order matches visual order and labels are descriptive | Unlabeled controls or confusing focus traversal |
|
||||
| **Disabled state clarity** | Use disabled semantics (`disabled`/native disabled props), reduced emphasis, and no tap action | Controls that look tappable but do nothing |
|
||||
| **Touch target minimum** | Keep tap areas >=44x44pt (iOS) or >=48x48dp (Android), expand hit area when icon is smaller | Tiny tap targets or icon-only hit areas without padding |
|
||||
| **Gesture conflict prevention** | Keep one primary gesture per region and avoid nested tap/drag conflicts | Overlapping gestures causing accidental actions |
|
||||
| **Semantic native controls** | Prefer native interactive primitives (`Button`, `Pressable`, platform equivalents) with proper accessibility roles | Generic containers used as primary controls without semantics |
|
||||
|
||||
### Light/Dark Mode Contrast
|
||||
|
||||
| Rule | Do | Don't |
|
||||
|------|----|----- |
|
||||
| **Surface readability (light)** | Keep cards/surfaces clearly separated from background with sufficient opacity/elevation | Overly transparent surfaces that blur hierarchy |
|
||||
| **Text contrast (light)** | Maintain body text contrast >=4.5:1 against light surfaces | Low-contrast gray body text |
|
||||
| **Text contrast (dark)** | Maintain primary text contrast >=4.5:1 and secondary text >=3:1 on dark surfaces | Dark mode text that blends into background |
|
||||
| **Border and divider visibility** | Ensure separators are visible in both themes (not just light mode) | Theme-specific borders disappearing in one mode |
|
||||
| **State contrast parity** | Keep pressed/focused/disabled states equally distinguishable in light and dark themes | Defining interaction states for one theme only |
|
||||
| **Token-driven theming** | Use semantic color tokens mapped per theme across app surfaces/text/icons | Hardcoded per-screen hex values |
|
||||
| **Scrim and modal legibility** | Use a modal scrim strong enough to isolate foreground content (typically 40-60% black) | Weak scrim that leaves background visually competing |
|
||||
|
||||
### Layout & Spacing
|
||||
|
||||
| Rule | Do | Don't |
|
||||
|------|----|----- |
|
||||
| **Safe-area compliance** | Respect top/bottom safe areas for all fixed headers, tab bars, and CTA bars | Placing fixed UI under notch, status bar, or gesture area |
|
||||
| **System bar clearance** | Add spacing for status/navigation bars and gesture home indicator | Let tappable content collide with OS chrome |
|
||||
| **Consistent content width** | Keep predictable content width per device class (phone/tablet) | Mixing arbitrary widths between screens |
|
||||
| **8dp spacing rhythm** | Use a consistent 4/8dp spacing system for padding/gaps/section spacing | Random spacing increments with no rhythm |
|
||||
| **Readable text measure** | Keep long-form text readable on large devices (avoid edge-to-edge paragraphs on tablets) | Full-width long text that hurts readability |
|
||||
| **Section spacing hierarchy** | Define clear vertical rhythm tiers (e.g., 16/24/32/48) by hierarchy | Similar UI levels with inconsistent spacing |
|
||||
| **Adaptive gutters by breakpoint** | Increase horizontal insets on larger widths and in landscape | Same narrow gutter on all device sizes/orientations |
|
||||
| **Scroll and fixed element coexistence** | Add bottom/top content insets so lists are not hidden behind fixed bars | Scroll content obscured by sticky headers/footers |
|
||||
|
||||
---
|
||||
|
||||
## Pre-Delivery Checklist
|
||||
|
||||
Before delivering UI code, verify these items:
|
||||
Scope notice: This checklist is for App UI (iOS/Android/React Native/Flutter).
|
||||
|
||||
### Visual Quality
|
||||
- [ ] No emojis used as icons (use SVG instead)
|
||||
- [ ] All icons come from a consistent icon family and style
|
||||
- [ ] Official brand assets are used with correct proportions and clear space
|
||||
- [ ] Pressed-state visuals do not shift layout bounds or cause jitter
|
||||
- [ ] Semantic theme tokens are used consistently (no ad-hoc per-screen hardcoded colors)
|
||||
|
||||
### Interaction
|
||||
- [ ] All tappable elements provide clear pressed feedback (ripple/opacity/elevation)
|
||||
- [ ] Touch targets meet minimum size (>=44x44pt iOS, >=48x48dp Android)
|
||||
- [ ] Micro-interaction timing stays in the 150-300ms range with native-feeling easing
|
||||
- [ ] Disabled states are visually clear and non-interactive
|
||||
- [ ] Screen reader focus order matches visual order, and interactive labels are descriptive
|
||||
- [ ] Gesture regions avoid nested/conflicting interactions (tap/drag/back-swipe conflicts)
|
||||
|
||||
### Light/Dark Mode
|
||||
- [ ] Primary text contrast >=4.5:1 in both light and dark mode
|
||||
- [ ] Secondary text contrast >=3:1 in both light and dark mode
|
||||
- [ ] Dividers/borders and interaction states are distinguishable in both modes
|
||||
- [ ] Modal/drawer scrim opacity is strong enough to preserve foreground legibility (typically 40-60% black)
|
||||
- [ ] Both themes are tested before delivery (not inferred from a single theme)
|
||||
|
||||
### Layout
|
||||
- [ ] Safe areas are respected for headers, tab bars, and bottom CTA bars
|
||||
- [ ] Scroll content is not hidden behind fixed/sticky bars
|
||||
- [ ] Verified on small phone, large phone, and tablet (portrait + landscape)
|
||||
- [ ] Horizontal insets/gutters adapt correctly by device size and orientation
|
||||
- [ ] 4/8dp spacing rhythm is maintained across component, section, and page levels
|
||||
- [ ] Long-form text measure remains readable on larger devices (no edge-to-edge paragraphs)
|
||||
|
||||
### Accessibility
|
||||
- [ ] All meaningful images/icons have accessibility labels
|
||||
- [ ] Form fields have labels, hints, and clear error messages
|
||||
- [ ] Color is not the only indicator
|
||||
- [ ] Reduced motion and dynamic text size are supported without layout breakage
|
||||
- [ ] Accessibility traits/roles/states (selected, disabled, expanded) are announced correctly
|
||||
|
||||
---
|
||||
_Imported into astroagent from **ui-ux-pro-max-skill** by nextlevelbuilder_
|
||||
_(github.com/nextlevelbuilder/ui-ux-pro-max-skill). The searchable CSV/script_
|
||||
_engine is omitted (no shell in the sandbox); this is the guidance layer._
|
||||
|
|
@ -12,12 +12,7 @@ const site =
|
|||
|
||||
export default defineConfig({
|
||||
site,
|
||||
// astroagent overrides outDir for isolated preview builds (PREVIEW_OUT);
|
||||
// falls back to the live output for normal/cron builds.
|
||||
outDir: process.env.PREVIEW_OUT || "../public",
|
||||
// For previews, PREVIEW_BASE prefixes asset paths (/_preview/<id>) so the
|
||||
// preview is self-contained and doesn't pull _astro/fonts from the live site.
|
||||
base: process.env.PREVIEW_BASE || undefined,
|
||||
outDir: "../public",
|
||||
integrations: [mdx()],
|
||||
vite: {
|
||||
plugins: [tailwindcss()],
|
||||
|
|
|
|||
|
|
@ -1,384 +0,0 @@
|
|||
---
|
||||
/**
|
||||
* astroagent in-site console drawer — a multi-turn chat.
|
||||
* Inject once in the base layout (before </body>). Ships to every visitor but
|
||||
* stays hidden/inert unless /devconsole/ping reports the visitor is authed
|
||||
* (token cookie). Real security is server-side on every endpoint; the DOM
|
||||
* hiding is only UX. Unlock once by visiting any page with ?devkey=<TOKEN>.
|
||||
*/
|
||||
const route = "/devconsole";
|
||||
const previewRoute = "/_preview";
|
||||
---
|
||||
|
||||
<div id="aa-console" data-route={route} data-preview-route={previewRoute} hidden>
|
||||
<button id="aa-handle" type="button" aria-label="Open developer console">▲ astroagent</button>
|
||||
<section id="aa-panel" hidden aria-label="Developer console">
|
||||
<header>
|
||||
<span class="aa-title">astroagent</span>
|
||||
<span id="aa-state" class="aa-state"></span>
|
||||
<button id="aa-key" type="button" aria-label="Agent auth token" title="Set agent auth token">🔑</button>
|
||||
<button id="aa-new" type="button" aria-label="New conversation" title="New conversation (clear)">✚</button>
|
||||
<button id="aa-lock" type="button" aria-label="Lock console" title="Lock (hide until next devkey)">🔒</button>
|
||||
<button id="aa-close" type="button" aria-label="Collapse">▼</button>
|
||||
</header>
|
||||
<div id="aa-chat" aria-live="polite"></div>
|
||||
<div id="aa-actions" hidden>
|
||||
<a id="aa-preview" href="#" target="_blank" rel="noopener">Open preview ↗</a>
|
||||
<button id="aa-publish" type="button">Publish</button>
|
||||
</div>
|
||||
<div id="aa-picks" hidden></div>
|
||||
<form id="aa-form">
|
||||
<button id="aa-pick" type="button" aria-label="Select an element on the page" title="Select an element on the page (Esc to cancel)">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><circle cx="12" cy="12" r="6.5"></circle><path d="M12 1.5v4M12 18.5v4M1.5 12h4M18.5 12h4"></path></svg>
|
||||
</button>
|
||||
<textarea id="aa-prompt" rows="2" placeholder="Ask or change anything — “what pages do I have?”, “make the buttons green”, then “now make them bigger”"></textarea>
|
||||
<button id="aa-run" type="submit">Send</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<div id="aa-hl" hidden><span id="aa-hl-tag"></span></div>
|
||||
|
||||
<div id="aa-authmodal" hidden>
|
||||
<div class="aa-modal">
|
||||
<div class="aa-modal-h">Agent sign-in required</div>
|
||||
<p class="aa-modal-p">The agent's Claude session was lost. Paste a long-lived token — generate one on the server with <code>claude setup-token</code>.</p>
|
||||
<input id="aa-authinput" type="password" placeholder="Paste auth token" autocomplete="off" spellcheck="false" />
|
||||
<div id="aa-authmsg" class="aa-modal-msg"></div>
|
||||
<div class="aa-modal-btns">
|
||||
<button id="aa-authcancel" type="button">Cancel</button>
|
||||
<button id="aa-authsave" type="button">Save & verify</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
#aa-console { position: fixed; right: 16px; bottom: 16px; z-index: 2147483000; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
|
||||
#aa-handle { background:#111; color:#e6e6e6; border:1px solid #333; border-radius:8px; padding:8px 12px; font-size:12px; cursor:pointer; box-shadow:0 4px 16px rgba(0,0,0,.3); }
|
||||
#aa-handle:hover { background:#1b1b1b; }
|
||||
#aa-panel { width: min(700px, calc(100vw - 32px)); height: min(64vh, 560px); background:#0c0c0d; color:#d6d6d6; border:1px solid #2a2a2a; border-radius:10px; display:flex; flex-direction:column; box-shadow:0 12px 40px rgba(0,0,0,.5); overflow:hidden; }
|
||||
#aa-panel header { display:flex; align-items:center; gap:8px; padding:8px 10px; background:#141416; border-bottom:1px solid #2a2a2a; font-size:12px; }
|
||||
#aa-panel .aa-title { font-weight:600; color:#fff; }
|
||||
#aa-panel .aa-state { margin-left:auto; color:#8a8a8a; font-size:11px; }
|
||||
#aa-close, #aa-lock, #aa-key, #aa-new { background:none; border:none; color:#8a8a8a; cursor:pointer; font-size:12px; padding:2px 4px; }
|
||||
#aa-lock:hover, #aa-close:hover, #aa-key:hover, #aa-new:hover { color:#fff; }
|
||||
#aa-chat { flex:1; overflow:auto; padding:12px; display:flex; flex-direction:column; gap:8px; }
|
||||
.aa-msg { max-width:86%; padding:8px 11px; border-radius:9px; font-size:12px; line-height:1.5; white-space:pre-wrap; word-break:break-word; }
|
||||
.aa-user { align-self:flex-end; background:rgba(31,111,235,.22); border:1px solid rgba(31,111,235,.45); color:#dbeafe; }
|
||||
.aa-bot { align-self:flex-start; background:#161b22; border:1px solid #2a2a2a; color:#d6d6d6; }
|
||||
.aa-bot .aa-tool { display:block; color:#6cb6ff; opacity:.75; font-size:11px; }
|
||||
.aa-bot .aa-txt { display:block; margin:2px 0; }
|
||||
.aa-bot .aa-emsg { color:#ff7b72; }
|
||||
.aa-sys { align-self:center; color:#8a8a8a; font-size:11px; }
|
||||
.aa-sys.ok { color:#7ee787; }
|
||||
#aa-actions { display:flex; gap:8px; align-items:center; padding:8px 10px; border-top:1px solid #2a2a2a; }
|
||||
#aa-actions a { color:#6cb6ff; font-size:12px; text-decoration:none; margin-right:auto; }
|
||||
#aa-form { display:flex; gap:8px; padding:10px; border-top:1px solid #2a2a2a; }
|
||||
#aa-prompt { flex:1; resize:none; background:#141416; color:#e6e6e6; border:1px solid #2a2a2a; border-radius:6px; padding:8px; font:inherit; font-size:12px; }
|
||||
#aa-panel button:not(#aa-close):not(#aa-lock):not(#aa-key):not(#aa-new):not(#aa-pick):not(.aa-x) { background:#238636; color:#fff; border:none; border-radius:6px; padding:8px 14px; font-size:12px; cursor:pointer; }
|
||||
#aa-panel button:disabled { opacity:.5; cursor:default; }
|
||||
#aa-pick { display:flex; align-items:center; justify-content:center; background:#141416; color:#8a8a8a; border:1px solid #2a2a2a; border-radius:6px; padding:0 11px; cursor:pointer; }
|
||||
#aa-pick:hover { color:#fff; border-color:#3a3a3a; }
|
||||
#aa-pick.on { background:#1f6feb; color:#fff; border-color:#1f6feb; }
|
||||
#aa-handle.aa-picking { background:#1f6feb; border-color:#1f6feb; color:#fff; animation:aa-pulse 1.6s ease-in-out infinite; }
|
||||
@keyframes aa-pulse { 0%,100% { box-shadow:0 4px 16px rgba(31,111,235,.35); } 50% { box-shadow:0 4px 24px rgba(31,111,235,.8); } }
|
||||
@media (prefers-reduced-motion: reduce) { #aa-handle.aa-picking { animation:none; } }
|
||||
#aa-picks { display:flex; flex-direction:column; gap:6px; padding:8px 10px; border-top:1px solid #2a2a2a; max-height:130px; overflow:auto; }
|
||||
.aa-chip { display:flex; align-items:center; gap:8px; background:#161b22; border:1px solid #2a2a2a; border-radius:7px; padding:6px 8px; }
|
||||
.aa-chip-tag { color:#6cb6ff; font-size:11px; white-space:nowrap; max-width:150px; overflow:hidden; text-overflow:ellipsis; flex-shrink:0; }
|
||||
.aa-chip input { flex:1; background:#0c0c0d; color:#e6e6e6; border:1px solid #2a2a2a; border-radius:5px; padding:5px 7px; font:inherit; font-size:11px; min-width:0; }
|
||||
.aa-x { background:none; border:none; color:#8a8a8a; cursor:pointer; font-size:13px; padding:0 3px; flex-shrink:0; }
|
||||
.aa-x:hover { color:#ff7b72; }
|
||||
#aa-hl { position:fixed; pointer-events:none; z-index:2147482998; background:rgba(31,111,235,.14); border:1.5px solid rgba(31,111,235,.9); border-radius:3px; }
|
||||
#aa-hl-tag { position:absolute; top:-20px; left:-1.5px; background:#1f6feb; color:#fff; font-size:10px; font-family:ui-monospace, Menlo, monospace; padding:2px 6px; border-radius:3px; white-space:nowrap; }
|
||||
#aa-authmodal { position:fixed; inset:0; background:rgba(0,0,0,.55); display:flex; align-items:center; justify-content:center; z-index:2147483001; }
|
||||
#aa-authmodal .aa-modal { background:#141416; color:#e6e6e6; border:1px solid #2a2a2a; border-radius:10px; padding:18px; width:min(440px, calc(100vw - 32px)); box-shadow:0 12px 40px rgba(0,0,0,.6); }
|
||||
.aa-modal-h { font-weight:600; color:#fff; margin-bottom:6px; }
|
||||
.aa-modal-p { font-size:12px; color:#9a9a9a; margin:0 0 12px; line-height:1.5; }
|
||||
.aa-modal-p code { color:#6cb6ff; }
|
||||
#aa-authinput { width:100%; box-sizing:border-box; background:#0c0c0d; color:#e6e6e6; border:1px solid #2a2a2a; border-radius:6px; padding:9px; font:inherit; font-size:12px; }
|
||||
.aa-modal-msg { font-size:12px; min-height:16px; margin:8px 0; }
|
||||
.aa-modal-btns { display:flex; gap:8px; justify-content:flex-end; }
|
||||
#aa-authsave { background:#238636; color:#fff; border:none; border-radius:6px; padding:8px 14px; font-size:12px; cursor:pointer; }
|
||||
#aa-authcancel { background:#30363d !important; color:#fff; border:none; border-radius:6px; padding:8px 14px; font-size:12px; cursor:pointer; }
|
||||
</style>
|
||||
|
||||
<script>
|
||||
const root = document.getElementById("aa-console");
|
||||
const route = root.dataset.route;
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const api = (p) => route + p;
|
||||
|
||||
async function boot() {
|
||||
const devkey = new URLSearchParams(location.search).get("devkey");
|
||||
let authed = false;
|
||||
try {
|
||||
const r = await fetch(api("/ping") + (devkey ? "?key=" + encodeURIComponent(devkey) : ""), { credentials: "same-origin" });
|
||||
authed = (await r.json()).authed;
|
||||
} catch { authed = false; }
|
||||
if (!authed) { root.remove(); return; }
|
||||
root.hidden = false;
|
||||
wire();
|
||||
}
|
||||
|
||||
function wire() {
|
||||
const handle = $("aa-handle"), panel = $("aa-panel");
|
||||
const chat = $("aa-chat"), stateEl = $("aa-state"), actions = $("aa-actions");
|
||||
const previewLink = $("aa-preview"), runBtn = $("aa-run"), promptEl = $("aa-prompt");
|
||||
let convId = null, es = null, bot = null, running = false;
|
||||
|
||||
const open = () => { panel.hidden = false; handle.hidden = true; promptEl.focus(); };
|
||||
const close = () => { panel.hidden = true; handle.hidden = false; };
|
||||
handle.onclick = open; $("aa-close").onclick = close;
|
||||
$("aa-lock").onclick = async () => {
|
||||
try { await fetch(api("/logout"), { method:"POST", credentials:"same-origin" }); } catch {}
|
||||
root.remove();
|
||||
};
|
||||
|
||||
const scroll = () => { chat.scrollTop = chat.scrollHeight; };
|
||||
const setState = (s) => (stateEl.textContent = s || "");
|
||||
const busy = (b) => { running = b; runBtn.disabled = b; pickBtn.disabled = b; };
|
||||
|
||||
function bubble(cls) { const d = document.createElement("div"); d.className = "aa-msg " + cls; chat.appendChild(d); scroll(); return d; }
|
||||
function userMsg(t) { const d = bubble("aa-user"); d.textContent = t; }
|
||||
function sysMsg(t, ok) { const d = bubble("aa-sys" + (ok ? " ok" : "")); d.textContent = t; }
|
||||
function botStart() { bot = bubble("aa-bot"); return bot; }
|
||||
function botText(t) { if (!bot) botStart(); const s = document.createElement("span"); s.className = "aa-txt"; s.textContent = t; bot.appendChild(s); scroll(); }
|
||||
function botTool(t) { if (!bot) botStart(); const s = document.createElement("span"); s.className = "aa-tool"; s.textContent = "· " + t; bot.appendChild(s); scroll(); }
|
||||
function botErr(t) { if (!bot) botStart(); const s = document.createElement("span"); s.className = "aa-txt aa-emsg"; s.textContent = "✗ " + t; bot.appendChild(s); scroll(); }
|
||||
|
||||
function ensureStream() {
|
||||
if (es || !convId) return;
|
||||
es = new EventSource(api("/stream") + "?conversationId=" + encodeURIComponent(convId));
|
||||
es.onmessage = (m) => {
|
||||
let ev; try { ev = JSON.parse(m.data); } catch { return; }
|
||||
switch (ev.type) {
|
||||
case "turn_start": setState("working…"); break;
|
||||
case "progress":
|
||||
if (ev.kind === "text") botText(ev.text);
|
||||
else if (ev.kind === "tool") botTool(ev.text);
|
||||
else if (ev.kind === "log") setState("building preview…");
|
||||
break; // ignore 'done' (duplicate of final text)
|
||||
case "preview":
|
||||
previewLink.href = ev.url; actions.hidden = false;
|
||||
sysMsg("✓ preview updated", true);
|
||||
break;
|
||||
case "turn_end": setState(""); busy(false); bot = null; break;
|
||||
case "published":
|
||||
sysMsg("✓ published — " + (ev.commit || "").slice(0,8) + " (live)", true); actions.hidden = true;
|
||||
sysMsg("reloading to show the live site…");
|
||||
// The published event fires after the prod build, so the live site is
|
||||
// ready. From a preview page, go to the live equivalent of this route.
|
||||
setTimeout(() => {
|
||||
const live = stripPreview(location.pathname);
|
||||
if (live !== location.pathname) location.href = live;
|
||||
else location.reload();
|
||||
}, 1200);
|
||||
break;
|
||||
case "auth_required": setState(""); busy(false); botErr("agent not signed in"); openAuth(); break;
|
||||
case "error": setState(""); busy(false); botErr(ev.text || "error"); break;
|
||||
case "end": es && es.close(); es = null; convId = null; busy(false); setState(""); break;
|
||||
}
|
||||
};
|
||||
es.onerror = () => { /* keep-alive; browser auto-reconnects */ };
|
||||
}
|
||||
|
||||
$("aa-form").onsubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
if (running) return;
|
||||
const message = promptEl.value.trim();
|
||||
const selections = picks.map((p) => ({ ...p.data, comment: p.input.value.trim() }));
|
||||
if (!message && !selections.length) return;
|
||||
promptEl.value = "";
|
||||
const shown = [message, ...picks.map((p) => "⌖ " + p.label + (p.input.value.trim() ? " — " + p.input.value.trim() : ""))].filter(Boolean).join("\n");
|
||||
userMsg(shown); botStart(); busy(true); setState("working…");
|
||||
try {
|
||||
const body = convId ? { conversationId: convId, message } : { message };
|
||||
if (selections.length) body.selections = selections;
|
||||
const r = await fetch(api("/run"), {
|
||||
method:"POST", credentials:"same-origin", headers:{ "content-type":"application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const d = await r.json();
|
||||
if (!r.ok) throw new Error(d.error || "run failed");
|
||||
convId = d.conversationId;
|
||||
clearPicks();
|
||||
ensureStream();
|
||||
} catch (err) { botErr(err.message); busy(false); setState(""); }
|
||||
};
|
||||
|
||||
$("aa-publish").onclick = async () => {
|
||||
if (!convId || running) return;
|
||||
setState("publishing…");
|
||||
try {
|
||||
const r = await fetch(api("/publish"), { method:"POST", credentials:"same-origin", headers:{ "content-type":"application/json" }, body: JSON.stringify({ conversationId: convId }) });
|
||||
const d = await r.json();
|
||||
if (!r.ok) throw new Error(d.error || "publish failed");
|
||||
} catch (err) { sysMsg("✗ " + err.message); }
|
||||
setState("");
|
||||
};
|
||||
|
||||
$("aa-new").onclick = async () => {
|
||||
if (running) return;
|
||||
try { await fetch(api("/discard"), { method:"POST", credentials:"same-origin", headers:{ "content-type":"application/json" }, body: JSON.stringify({ conversationId: convId }) }); } catch {}
|
||||
if (es) { es.close(); es = null; }
|
||||
convId = null; bot = null; chat.textContent = ""; actions.hidden = true; setState("");
|
||||
clearPicks();
|
||||
sysMsg("new conversation");
|
||||
promptEl.focus();
|
||||
};
|
||||
|
||||
// Enter to send, Shift+Enter for newline.
|
||||
promptEl.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); $("aa-form").requestSubmit(); }
|
||||
});
|
||||
|
||||
// --- visual element picker: click an element on the page, attach a comment ---
|
||||
const pickBtn = $("aa-pick"), picksEl = $("aa-picks"), hl = $("aa-hl"), hlTag = $("aa-hl-tag");
|
||||
const prevRoute = root.dataset.previewRoute || "/_preview";
|
||||
const MAX_PICKS = 5;
|
||||
let picking = false, picks = [], lastTarget = null;
|
||||
|
||||
const isOurs = (t) => t instanceof Element && t.closest("#aa-console");
|
||||
// "/_preview/<jobId>/about/" -> "/about/" (no-op on live pages)
|
||||
const stripPreview = (p) => p.replace(new RegExp("^" + prevRoute.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + "/[^/]+"), "") || "/";
|
||||
|
||||
function hlOn(el) {
|
||||
const r = el.getBoundingClientRect();
|
||||
hl.style.left = r.left + "px"; hl.style.top = r.top + "px";
|
||||
hl.style.width = r.width + "px"; hl.style.height = r.height + "px";
|
||||
hlTag.textContent = el.tagName.toLowerCase() + (el.classList[0] ? "." + el.classList[0] : "");
|
||||
hl.hidden = false;
|
||||
}
|
||||
function onMove(e) {
|
||||
const t = e.target;
|
||||
if (!(t instanceof Element) || isOurs(t)) { hl.hidden = true; lastTarget = null; return; }
|
||||
lastTarget = t; hlOn(t);
|
||||
}
|
||||
function onScroll() { if (picking && lastTarget) hlOn(lastTarget); }
|
||||
function onKey(e) { if (e.key === "Escape") { exitPick(); open(); } }
|
||||
function onClick(e) {
|
||||
const t = e.target;
|
||||
if (isOurs(t)) { exitPick(); return; } // our own UI (the handle) — cancel, let the click through
|
||||
e.preventDefault(); e.stopImmediatePropagation();
|
||||
exitPick();
|
||||
addPick(t);
|
||||
open();
|
||||
}
|
||||
function enterPick() {
|
||||
if (picking || running) return;
|
||||
if (picks.length >= MAX_PICKS) { sysMsg("max " + MAX_PICKS + " selections per turn"); return; }
|
||||
picking = true; pickBtn.classList.add("on");
|
||||
close(); // collapse to the handle so the panel doesn't cover the page
|
||||
handle.textContent = "click any element — Esc cancels";
|
||||
handle.classList.add("aa-picking");
|
||||
document.body.style.cursor = "crosshair";
|
||||
document.addEventListener("mousemove", onMove, true);
|
||||
document.addEventListener("click", onClick, true);
|
||||
document.addEventListener("keydown", onKey, true);
|
||||
document.addEventListener("scroll", onScroll, { passive: true });
|
||||
}
|
||||
function exitPick() {
|
||||
if (!picking) return;
|
||||
picking = false; pickBtn.classList.remove("on");
|
||||
handle.textContent = "▲ astroagent";
|
||||
handle.classList.remove("aa-picking");
|
||||
hl.hidden = true; lastTarget = null;
|
||||
document.body.style.cursor = "";
|
||||
document.removeEventListener("mousemove", onMove, true);
|
||||
document.removeEventListener("click", onClick, true);
|
||||
document.removeEventListener("keydown", onKey, true);
|
||||
document.removeEventListener("scroll", onScroll);
|
||||
}
|
||||
pickBtn.onclick = () => { if (picking) { exitPick(); open(); } else enterPick(); };
|
||||
|
||||
// Serialized at pick time from the BUILT page DOM. Class attributes and visible
|
||||
// text survive Astro's production build verbatim — they're the agent's grep keys.
|
||||
function cssPath(el) {
|
||||
const parts = [];
|
||||
let n = el;
|
||||
for (let d = 0; n && n.nodeType === 1 && n.tagName !== "HTML" && d < 8; d++) {
|
||||
const tag = n.tagName.toLowerCase();
|
||||
if (n.id) { parts.unshift(tag + "#" + n.id); break; }
|
||||
const cls = [...n.classList].filter((c) => !c.startsWith("astro-")).slice(0, 2);
|
||||
let sel = tag + cls.map((c) => "." + c).join("");
|
||||
const sibs = n.parentElement ? [...n.parentElement.children].filter((s) => s.tagName === n.tagName) : [];
|
||||
if (sibs.length > 1) sel += ":nth-of-type(" + (sibs.indexOf(n) + 1) + ")";
|
||||
parts.unshift(sel);
|
||||
if (tag === "body") break;
|
||||
n = n.parentElement;
|
||||
}
|
||||
return parts.join(" > ");
|
||||
}
|
||||
function ancestorChain(el) {
|
||||
const out = [];
|
||||
for (let n = el.parentElement; n && n.tagName !== "HTML" && out.length < 6; n = n.parentElement) {
|
||||
out.unshift(n.tagName.toLowerCase() + [...n.classList].filter((c) => !c.startsWith("astro-")).slice(0, 2).map((c) => "." + c).join(""));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
function trimHtml(el) {
|
||||
const c = el.cloneNode(true);
|
||||
for (const x of c.querySelectorAll("#aa-console")) x.remove();
|
||||
for (const s of c.querySelectorAll("svg")) s.replaceChildren();
|
||||
for (const n of [c, ...c.querySelectorAll("*")]) {
|
||||
for (const a of [...n.attributes]) if (a.value.length > 150) n.setAttribute(a.name, a.value.slice(0, 150) + "…");
|
||||
}
|
||||
let h = c.outerHTML;
|
||||
if (h.length > 2000) h = h.slice(0, 2000) + "…";
|
||||
return h;
|
||||
}
|
||||
function capture(el) {
|
||||
const r = el.getBoundingClientRect();
|
||||
return {
|
||||
route: stripPreview(location.pathname),
|
||||
selector: cssPath(el),
|
||||
classes: el.getAttribute("class") || "",
|
||||
text: (el.innerText || "").trim().replace(/\s+/g, " ").slice(0, 200),
|
||||
html: trimHtml(el),
|
||||
ancestors: ancestorChain(el),
|
||||
rect: { x: Math.round(r.x), y: Math.round(r.y), w: Math.round(r.width), h: Math.round(r.height) },
|
||||
};
|
||||
}
|
||||
function addPick(el) {
|
||||
const data = capture(el);
|
||||
const label = el.tagName.toLowerCase() + (data.text ? " “" + data.text.slice(0, 24) + (data.text.length > 24 ? "…" : "") + "”" : (el.classList[0] ? "." + el.classList[0] : ""));
|
||||
const chip = document.createElement("div"); chip.className = "aa-chip";
|
||||
const tag = document.createElement("span"); tag.className = "aa-chip-tag"; tag.textContent = label; tag.title = data.selector;
|
||||
const input = document.createElement("input"); input.placeholder = "What should change here?"; input.maxLength = 500;
|
||||
const x = document.createElement("button"); x.type = "button"; x.className = "aa-x"; x.textContent = "×"; x.setAttribute("aria-label", "Remove selection");
|
||||
chip.append(tag, input, x);
|
||||
const pick = { el, data, label, input, chip };
|
||||
x.onclick = () => { picks = picks.filter((p) => p !== pick); chip.remove(); picksEl.hidden = !picks.length; };
|
||||
tag.onmouseenter = () => { if (pick.el && pick.el.isConnected) hlOn(pick.el); };
|
||||
tag.onmouseleave = () => { hl.hidden = true; };
|
||||
input.addEventListener("keydown", (e) => { if (e.key === "Enter") { e.preventDefault(); $("aa-form").requestSubmit(); } });
|
||||
picks.push(pick);
|
||||
picksEl.appendChild(chip); picksEl.hidden = false;
|
||||
input.focus();
|
||||
}
|
||||
function clearPicks() { picks = []; picksEl.textContent = ""; picksEl.hidden = true; }
|
||||
|
||||
// --- agent auth token modal (pops up on auth loss, or via the 🔑 button) ---
|
||||
const authModal = $("aa-authmodal"), authInput = $("aa-authinput"), authMsg = $("aa-authmsg");
|
||||
const openAuth = () => { authMsg.textContent = ""; authInput.value = ""; authModal.hidden = false; authInput.focus(); };
|
||||
const closeAuth = () => { authModal.hidden = true; };
|
||||
$("aa-key").onclick = openAuth;
|
||||
$("aa-authcancel").onclick = closeAuth;
|
||||
$("aa-authsave").onclick = async () => {
|
||||
const token = authInput.value.trim();
|
||||
if (!token) return;
|
||||
authMsg.style.color = "#9a9a9a"; authMsg.textContent = "verifying…";
|
||||
try {
|
||||
const r = await fetch(api("/auth"), { method:"POST", credentials:"same-origin", headers:{ "content-type":"application/json" }, body: JSON.stringify({ token }) });
|
||||
const d = await r.json();
|
||||
if (d.ok) { authMsg.style.color = "#7ee787"; authMsg.textContent = "✓ saved & verified — send your message again"; setTimeout(closeAuth, 1300); }
|
||||
else { authMsg.style.color = "#ff7b72"; authMsg.textContent = "✗ " + (d.error || "failed"); }
|
||||
} catch (err) { authMsg.style.color = "#ff7b72"; authMsg.textContent = "✗ " + err.message; }
|
||||
};
|
||||
// expose for the stream handler
|
||||
window.__aaOpenAuth = openAuth;
|
||||
}
|
||||
|
||||
boot();
|
||||
</script>
|
||||
|
|
@ -2,7 +2,6 @@
|
|||
import "../styles.css";
|
||||
import Header from "../components/Header.astro";
|
||||
import Footer from "../components/Footer.astro";
|
||||
import DevConsole from "../components/DevConsole.astro";
|
||||
import { SITE, imageSrc } from "../lib/blog-data.js";
|
||||
|
||||
const {
|
||||
|
|
@ -90,6 +89,5 @@ const ogImageUrl = absoluteUrl(ogImage);
|
|||
</main>
|
||||
<Footer flush={flushFooter} />
|
||||
</div>
|
||||
<DevConsole />
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -1,29 +0,0 @@
|
|||
{
|
||||
"name": "seedproject-site",
|
||||
"url": "https://example.com",
|
||||
"appDir": "app",
|
||||
"outDir": "public",
|
||||
"buildCommand": "npm run build",
|
||||
"contentDir": "app/src/content/blog",
|
||||
"deploy": {
|
||||
"type": "local-apache",
|
||||
"chown": "www:www"
|
||||
},
|
||||
"ai": {
|
||||
"model": "claude-sonnet-4-6",
|
||||
"tools": "Read Write Edit Glob Grep WebSearch",
|
||||
"confine": true,
|
||||
"agentUser": "seedproject-site-agent",
|
||||
"skills": [
|
||||
"brand",
|
||||
"ui-ux",
|
||||
"seo"
|
||||
]
|
||||
},
|
||||
"console": {
|
||||
"port": 3011,
|
||||
"route": "/devconsole",
|
||||
"tokenFile": "agents/.env",
|
||||
"tokenKey": "ADMIN_TOKEN"
|
||||
}
|
||||
}
|
||||
|
|
@ -7,12 +7,8 @@
|
|||
*
|
||||
* Writes:
|
||||
* - app/src/config/site.json (Astro theme reads this)
|
||||
* - agents/config.json (site identity + author + timezone)
|
||||
* - astroagent.config.json (name + url)
|
||||
*
|
||||
* Finishes by regenerating the agent catalog (agents/AGENTS.md + agents.json).
|
||||
*/
|
||||
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
|
||||
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
|
|
@ -24,7 +20,6 @@ const write = (p, obj) => {
|
|||
writeFileSync(full, JSON.stringify(obj, null, 2) + "\n");
|
||||
console.log(" wrote", p);
|
||||
};
|
||||
const slug = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
||||
|
||||
const site = read("site.config.json");
|
||||
console.log(`Configuring site: ${site.name} <${site.url}>`);
|
||||
|
|
@ -40,30 +35,4 @@ write("app/src/config/site.json", {
|
|||
social: site.social,
|
||||
});
|
||||
|
||||
// 2) agents
|
||||
if (existsSync(resolve(root, "agents/config.json"))) {
|
||||
const cp = read("agents/config.json");
|
||||
cp.site = { ...cp.site, url: site.url, name: site.name, topic: site.topic, audience: site.audience, language: site.language };
|
||||
cp.author = { ...(cp.author || {}), slug: site.author.slug, name: site.author.name };
|
||||
if (cp.paths) cp.paths.projectRoot = root; // this clone's absolute path, not comiida's
|
||||
if (cp.editorial) cp.editorial.timezone = site.timezone;
|
||||
if (cp.image && cp.image.credit) cp.image.credit.author = site.name;
|
||||
write("agents/config.json", cp);
|
||||
}
|
||||
|
||||
// 3) astroagent
|
||||
if (existsSync(resolve(root, "astroagent.config.json"))) {
|
||||
const aa = read("astroagent.config.json");
|
||||
aa.name = slug(site.name);
|
||||
aa.url = site.url;
|
||||
if (aa.ai) aa.ai.agentUser = `${slug(site.name)}-agent`;
|
||||
write("astroagent.config.json", aa);
|
||||
}
|
||||
|
||||
// 4) agent catalog (AGENTS.md + agents.json reflect this clone's identity)
|
||||
if (existsSync(resolve(root, "agents/catalog.mjs"))) {
|
||||
const { execFileSync } = await import("node:child_process");
|
||||
execFileSync("node", [resolve(root, "agents/catalog.mjs")], { stdio: "inherit" });
|
||||
}
|
||||
|
||||
console.log("Done. Rebuild the site: (cd app && npm run build)");
|
||||
|
|
|
|||
|
|
@ -5,9 +5,8 @@
|
|||
# Usage (from the repo root, after cloning):
|
||||
# ./scripts/new-site.sh
|
||||
#
|
||||
# Prompts for this site's identity, writes site.config.json, stamps it across
|
||||
# every engine (Astro theme, agents, astroagent), optionally resets
|
||||
# git history and installs dependencies.
|
||||
# Prompts for this site's identity, writes site.config.json, stamps it into
|
||||
# the Astro theme, optionally resets git history and installs dependencies.
|
||||
# ---------------------------------------------------------------------------
|
||||
set -euo pipefail
|
||||
|
||||
|
|
@ -28,8 +27,6 @@ NAME="$(ask 'Site name' "$(get .name)")"
|
|||
URL="$(ask 'Site URL' "$(get .url)")"
|
||||
DESC="$(ask 'One-line description' "$(get .description)")"
|
||||
TAG="$(ask 'Tagline' "$(get .tagline)")"
|
||||
TOPIC="$(ask 'Topic / niche' "$(get .topic)")"
|
||||
AUD="$(ask 'Audience' "$(get .audience)")"
|
||||
TZ="$(ask 'Timezone' "$(get .timezone)")"
|
||||
AUTHOR="$(ask 'Author name' "$(get '.author.name')")"
|
||||
TW="$(ask 'Twitter/X URL (optional)' "$(get '.social.twitter')")"
|
||||
|
|
@ -39,19 +36,19 @@ SLUG="$(printf '%s' "$AUTHOR" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9]
|
|||
|
||||
echo
|
||||
echo "Writing site.config.json ..."
|
||||
NAME="$NAME" URL="$URL" DESC="$DESC" TAG="$TAG" TOPIC="$TOPIC" AUD="$AUD" TZ="$TZ" AUTHOR="$AUTHOR" SLUG="$SLUG" TW="$TW" \
|
||||
NAME="$NAME" URL="$URL" DESC="$DESC" TAG="$TAG" TZ="$TZ" AUTHOR="$AUTHOR" SLUG="$SLUG" TW="$TW" \
|
||||
node -e '
|
||||
const fs = require("fs");
|
||||
const c = JSON.parse(fs.readFileSync("site.config.json", "utf8"));
|
||||
const e = process.env;
|
||||
c.name = e.NAME; c.url = e.URL; c.description = e.DESC; c.tagline = e.TAG;
|
||||
c.topic = e.TOPIC; c.audience = e.AUD; c.timezone = e.TZ;
|
||||
c.timezone = e.TZ;
|
||||
c.author = { ...c.author, name: e.AUTHOR, slug: e.SLUG };
|
||||
c.social = { ...c.social, twitter: e.TW };
|
||||
fs.writeFileSync("site.config.json", JSON.stringify(c, null, 2) + "\n");
|
||||
'
|
||||
|
||||
echo "Stamping identity across engines ..."
|
||||
echo "Stamping identity into the theme ..."
|
||||
node scripts/configure.mjs
|
||||
|
||||
read -r -p "Reset git history for this new site? [y/N]: " RESETGIT || true
|
||||
|
|
|
|||
|
|
@ -5,8 +5,6 @@
|
|||
"tagline": "A starter site built on SeedProject",
|
||||
"language": "en",
|
||||
"timezone": "America/New_York",
|
||||
"topic": "your subject area",
|
||||
"audience": "your target audience",
|
||||
"author": {
|
||||
"slug": "site-author",
|
||||
"name": "Site Author",
|
||||
|
|
|
|||
Loading…
Reference in a new issue