compounding-loop 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +93 -3
- package/bin/loop.js +2 -0
- package/dist/cli/dashboard.js +52 -0
- package/dist/cli/dashboard.js.map +1 -0
- package/dist/cli/gh.js +29 -0
- package/dist/cli/gh.js.map +1 -0
- package/dist/cli/index.js +144 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/cli/init.js +72 -0
- package/dist/cli/init.js.map +1 -0
- package/dist/cli/loops.js +72 -0
- package/dist/cli/loops.js.map +1 -0
- package/dist/cli/new.js +54 -0
- package/dist/cli/new.js.map +1 -0
- package/dist/cli/run.js +81 -0
- package/dist/cli/run.js.map +1 -0
- package/dist/core/blocked.js +42 -0
- package/dist/core/blocked.js.map +1 -0
- package/dist/core/brief.js +91 -0
- package/dist/core/brief.js.map +1 -0
- package/dist/core/gh.js +2 -0
- package/dist/core/gh.js.map +1 -0
- package/dist/core/init.js +59 -0
- package/dist/core/init.js.map +1 -0
- package/dist/core/repo.js +81 -0
- package/dist/core/repo.js.map +1 -0
- package/dist/core/rounds.js +50 -0
- package/dist/core/rounds.js.map +1 -0
- package/dist/core/runPrompt.js +31 -0
- package/dist/core/runPrompt.js.map +1 -0
- package/dist/core/status.js +21 -0
- package/dist/core/status.js.map +1 -0
- package/dist/core/table.js +24 -0
- package/dist/core/table.js.map +1 -0
- package/dist/core/template.js +29 -0
- package/dist/core/template.js.map +1 -0
- package/dist/core/types.js +3 -0
- package/dist/core/types.js.map +1 -0
- package/dist/server/data.js +36 -0
- package/dist/server/data.js.map +1 -0
- package/dist/server/events.js +105 -0
- package/dist/server/events.js.map +1 -0
- package/dist/server/five-sites.json +3071 -0
- package/dist/server/health.js +133 -0
- package/dist/server/health.js.map +1 -0
- package/dist/server/index.js +134 -0
- package/dist/server/index.js.map +1 -0
- package/dist/server/loops.js +142 -0
- package/dist/server/loops.js.map +1 -0
- package/dist/server/settings.js +25 -0
- package/dist/server/settings.js.map +1 -0
- package/dist/server/static-export.js +28 -0
- package/dist/server/static-export.js.map +1 -0
- package/dist/server/static.js +80 -0
- package/dist/server/static.js.map +1 -0
- package/dist/web/assets/index-BDdtnG33.js +49 -0
- package/dist/web/assets/index-D3-N8DN4.css +2 -0
- package/dist/web/assets/inter-cyrillic-ext-wght-normal-BOeWTOD4.woff2 +0 -0
- package/dist/web/assets/inter-cyrillic-wght-normal-DqGufNeO.woff2 +0 -0
- package/dist/web/assets/inter-greek-ext-wght-normal-DlzME5K_.woff2 +0 -0
- package/dist/web/assets/inter-greek-wght-normal-CkhJZR-_.woff2 +0 -0
- package/dist/web/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2 +0 -0
- package/dist/web/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
- package/dist/web/assets/inter-vietnamese-wght-normal-CBcvBZtf.woff2 +0 -0
- package/dist/web/assets/jetbrains-mono-cyrillic-wght-normal-D73BlboJ.woff2 +0 -0
- package/dist/web/assets/jetbrains-mono-greek-wght-normal-Bw9x6K1M.woff2 +0 -0
- package/dist/web/assets/jetbrains-mono-latin-ext-wght-normal-DBQx-q_a.woff2 +0 -0
- package/dist/web/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
- package/dist/web/assets/jetbrains-mono-vietnamese-wght-normal-Bt-aOZkq.woff2 +0 -0
- package/dist/web/favicon.svg +1 -0
- package/dist/web/index.html +14 -0
- package/dist/web/logo.svg +1 -0
- package/method/COMPOUNDING_LOOP.md +569 -0
- package/method/example/K-0001.md +16 -0
- package/method/knowledge-index.md +5 -0
- package/method/operating-card.md +40 -0
- package/method/retro.md +10 -0
- package/method/reviewer-prompt.md +13 -0
- package/method/stuck-protocol.md +12 -0
- package/method/task-template.md +41 -0
- package/package.json +71 -4
- package/plugin/.claude-plugin/plugin.json +7 -0
- package/plugin/commands/handoff.md +10 -0
- package/plugin/commands/loop-init.md +13 -0
- package/plugin/commands/retro.md +11 -0
- package/plugin/commands/stuck.md +10 -0
- package/runners/github-actions/loop.yml +51 -0
- package/runners/local/README.md +8 -0
- package/runners/local/crontab.txt +6 -0
- package/runners/local/task-scheduler.xml +32 -0
- package/runners/routine/README.md +19 -0
- package/runners/routine/prompt.md +13 -0
- package/templates/chrome-extension/CLAUDE.md +18 -0
- package/templates/chrome-extension/IDEA.md +22 -0
- package/templates/chrome-extension/extension/background.js +5 -0
- package/templates/chrome-extension/extension/manifest.json +9 -0
- package/templates/chrome-extension/extension/popup.css +3 -0
- package/templates/chrome-extension/extension/popup.html +15 -0
- package/templates/chrome-extension/extension/popup.js +16 -0
- package/templates/chrome-extension/gitignore +6 -0
- package/templates/chrome-extension/package.json +12 -0
- package/templates/chrome-extension/playwright.config.js +22 -0
- package/templates/chrome-extension/scripts/check.mjs +51 -0
- package/templates/chrome-extension/scripts/test.mjs +59 -0
- package/templates/chrome-extension/tests/popup.spec.js +37 -0
- package/templates/static-site/.ai/index.md +5 -0
- package/templates/static-site/.ai/log.md +3 -0
- package/templates/static-site/.ai/loop-prompt.md +10 -0
- package/templates/static-site/.ai/task.md +27 -0
- package/templates/static-site/.gitattributes +1 -0
- package/templates/static-site/CLAUDE.md +113 -0
- package/templates/static-site/IDEA.md +36 -0
- package/templates/static-site/gitignore +6 -0
- package/templates/static-site/netlify.toml +14 -0
- package/templates/static-site/package.json +14 -0
- package/templates/static-site/playwright.config.js +27 -0
- package/templates/static-site/scripts/check.mjs +188 -0
- package/templates/static-site/scripts/serve.mjs +44 -0
- package/templates/static-site/scripts/test.mjs +59 -0
- package/templates/static-site/site/index.html +23 -0
- package/templates/static-site/site/robots.txt +4 -0
- package/templates/static-site/site/sitemap.xml +4 -0
- package/templates/static-site/tests/pages.spec.js +21 -0
- package/templates/static-site/vercel.json +19 -0
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
You are one run of an unattended build loop for this repository. Nobody is watching. Follow CLAUDE.md exactly, especially "Unattended mode".
|
|
2
|
+
|
|
3
|
+
0. Run `git checkout main && git pull --ff-only`. Every commit in this run goes to `main` and is pushed with `git push origin main`. Never push to a `claude/` branch: the next run starts from `main` and would never see it.
|
|
4
|
+
1. If `.ai/session.lock` holds a UTC time less than 90 minutes old, another session owns the repo: reply "Nothing to do" and stop. If `DONE.md` or `BLOCKED.md` exists, reply "Nothing to do" and stop.
|
|
5
|
+
2. Run `npm install`, then `npx playwright install chromium`. If the download is blocked, see "Commands" in CLAUDE.md.
|
|
6
|
+
3. If `.ai/task.md` exists, run the resume protocol (.ai/method.md §9.2) and add 1 to its `Run: N / 30` counter. If the counter has reached its limit, follow "Run budget" in CLAUDE.md. Otherwise start the task from IDEA.md.
|
|
7
|
+
4. Do one unit of work, then run `npm test`.
|
|
8
|
+
5. Green: commit, have a reviewer subagent check the diff, fix any blockers, run `npm test` again, then push to `main`. Red: write the attempt and the reason under Tried in `.ai/task.md`, then discard the unit's uncommitted changes to the site and tests.
|
|
9
|
+
6. Repeat 4–5 with the next unit while your context is under ~50% full.
|
|
10
|
+
7. Then run the handoff (.ai/method.md §9.1): update `.ai/task.md` so a fresh run with zero memory of this one can continue at full quality. Commit it, push it to `main`, and stop.
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# TASK: Build v1 of the site described in IDEA.md (window 1)
|
|
2
|
+
|
|
3
|
+
Run: 0 / 30
|
|
4
|
+
|
|
5
|
+
## Contract
|
|
6
|
+
<Window 1 writes this from IDEA.md before writing any site code.>
|
|
7
|
+
- Goal:
|
|
8
|
+
- Done when: `npm test` passes, including a feature test in `tests/` for every "Must have" in IDEA.md, unit tests for every file in `site/js/lib/`, and one page per keyword cluster in IDEA.md.
|
|
9
|
+
- Constraints: see CLAUDE.md hard rules.
|
|
10
|
+
- Out of scope: everything under "Nice to have" in IDEA.md.
|
|
11
|
+
|
|
12
|
+
## Current state (rewrite every window)
|
|
13
|
+
- Fresh repo from the starter kit. Nothing built yet.
|
|
14
|
+
- Next step: confirm `npm install` and `npm test` work in this environment (GUESS: Playwright's browser download may be blocked in cloud sandboxes; the config then falls back to a system Chrome. If neither works, record it under Decisions and use `npm run check && npm run unit` as the gate). Then write the contract and a plan in `.ai/plans/v1.md` (have a reviewer subagent attack the plan), then build page by page, the main tool first.
|
|
15
|
+
- Read first: IDEA.md, CLAUDE.md.
|
|
16
|
+
|
|
17
|
+
## Decisions (made without the owner: decision → reason)
|
|
18
|
+
|
|
19
|
+
## Confirmed (with how we know)
|
|
20
|
+
|
|
21
|
+
## Guesses (test before building on them)
|
|
22
|
+
|
|
23
|
+
## Tried (append-only: approach → result → why)
|
|
24
|
+
|
|
25
|
+
## Don't
|
|
26
|
+
|
|
27
|
+
## promote? (for the retro)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
* text=auto eol=lf
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# CLAUDE.md
|
|
2
|
+
|
|
3
|
+
## Project core
|
|
4
|
+
|
|
5
|
+
**What this is:** a static niche website built by an unattended AI loop. The goal, audience and keywords are in [IDEA.md](IDEA.md). The owner's aim is organic search traffic.
|
|
6
|
+
|
|
7
|
+
**Why this site can win:** in 2026 every tool niche already has 5–10 AI-built sites, most with a handful of thin, unchecked tools, abandoned after launch. This site wins on three things they skip: **depth** (the complete set of tools and answers for the niche, cross-linked), **verified correctness** (every number unit-tested against a cited source) and **honest, useful pages**. Every rule below serves one of those three.
|
|
8
|
+
|
|
9
|
+
**Commands**
|
|
10
|
+
- Install: `npm install`, then `npx playwright install chromium`. If Chrome then fails to launch over missing system libraries, run `npx playwright install-deps chromium`. If the browser download is blocked (it is in Claude's cloud sandbox), `playwright.config.js` automatically uses the preinstalled Chromium in `/opt/pw-browsers` or a system Chrome, so just run `npm test`. If neither works, record it under Decisions and use `npm run check && npm run unit` as the gate.
|
|
11
|
+
- Run locally: `npm start` (serves `site/` at http://localhost:4173 with the production headers, read from `vercel.json`)
|
|
12
|
+
- Test (the only judge of "done"): `npm test` = `check` + `unit` + browser tests
|
|
13
|
+
- Fast checks only: `npm run check` (SEO, quality, security), `npm run unit` (calculation tests)
|
|
14
|
+
|
|
15
|
+
**Hard rules**
|
|
16
|
+
- Everything the public sees lives in `site/`. The site is hosted on Netlify, which publishes only that folder (`publish = "site"` in `netlify.toml`; `vercel.json` says the same with `"outputDirectory": "site"`), so `CLAUDE.md`, `.ai/` and `IDEA.md` stay private. Never move them into `site/` and never change `publish` or `outputDirectory`; `npm run check` fails if `outputDirectory` changes.
|
|
17
|
+
- Plain HTML, CSS and ES modules, with no build step, unless IDEA.md needs something more. If it does, record the decision and the reason in `.ai/task.md` under Decisions.
|
|
18
|
+
- One page per search intent: each target keyword cluster gets its own URL, `<title>`, meta description, `<h1>` and JSON-LD. Cross-link the pages. Update `site/sitemap.xml` whenever a page is added.
|
|
19
|
+
- Mobile first. No horizontal scroll at 375px wide. The main tool must be usable without scrolling on a 633px-tall laptop screen.
|
|
20
|
+
- Pushing to `main` publishes the site (Netlify deploys every push to `main`), and the owner has approved auto-publishing. Push only when `npm test` passes and the reviewer found no blockers (see Unattended mode).
|
|
21
|
+
|
|
22
|
+
**URLs**
|
|
23
|
+
- The home page is `site/index.html`. Every other page is `site/<slug>/index.html`, linked as `/<slug>/`, with canonical `https://<domain>/<slug>/`. Slugs are lowercase, hyphenated and keyword-first.
|
|
24
|
+
- Use the address from IDEA.md's Domain section in every canonical, the sitemap and robots.txt. For now it's a free `<name>.netlify.app` address, because the owner hasn't bought the domain yet. Never use any other address, such as a `<hash>--<name>.netlify.app` deploy URL, a `vercel.app` address or the unbought domain. `npm run check` fails if a canonical uses a different host. Only the owner changes the address.
|
|
25
|
+
- Keep any `<meta name="google-site-verification">` tag on the home page. It proves ownership to Google Search Console.
|
|
26
|
+
- Never change a published URL. If you must, add a permanent (301) redirect to both `netlify.toml` (`[[redirects]]`) and `redirects` in `vercel.json`.
|
|
27
|
+
|
|
28
|
+
**Correctness** (the main edge over competitors)
|
|
29
|
+
- Every formula, conversion or lookup is a pure function in `site/js/lib/<topic>.js`, with no DOM access. Page scripts import it.
|
|
30
|
+
- Each lib file has `tests/unit/<topic>.test.mjs` (`node:test`), and `npm run check` fails without it. Each function needs at least two reference cases taken from an authoritative source: a manufacturer, a standards body, a textbook or a well-known reference chart. Put the source URL in a comment above each case. A test that only re-derives your own formula proves nothing.
|
|
31
|
+
- Also test the edges: zero, empty input, unit conversion both ways, impossible inputs. The page shows a clear message for bad input, never `NaN`, `Infinity` or `undefined`.
|
|
32
|
+
- Show units everywhere. Support imperial and metric when the audience uses both. Round only for display.
|
|
33
|
+
|
|
34
|
+
**Facts and data**
|
|
35
|
+
- Product specs, constants and reference tables go in `site/data/*.json`. Every entry has `source` (URL) and `checked` (YYYY-MM-DD). Find primary sources with WebSearch and WebFetch. If you can't verify a value, leave the entry out rather than guess.
|
|
36
|
+
- In the cloud sandbox, WebFetch is blocked for most websites by the network allowlist (CONFIRMED, October 2026), but WebSearch works and its results quote the source pages. A value counts as verified when a search result from the authoritative source itself states it, or when two independent reputable sources agree. Give the entry the source's URL and `"via": "search"`. Never use a value only because it seems plausible, and don't stop the build over one unverifiable value: leave it out and move on.
|
|
37
|
+
- Where real-world values vary (appliance wattage, fish adult size), say so on the page and give the typical range.
|
|
38
|
+
|
|
39
|
+
**Content quality** (Google's spam policies)
|
|
40
|
+
- Each page fully answers its search intent with original writing: the tool, how to use it, how it's calculated, a worked example and a short FAQ. `npm run check` requires 300+ visible words and fails pages that share more than 60% of their phrasing.
|
|
41
|
+
- No doorway pages: never make pages that differ only by a swapped word. A page earns its URL with its own data, examples or advice.
|
|
42
|
+
- No fake authority: no invented reviews, testimonials, user counts, "as seen on" badges or author bios, and no claims of hands-on testing.
|
|
43
|
+
- Where the topic touches safety (chemicals, electricity, animal health), add the relevant safety note and point to the authoritative source.
|
|
44
|
+
|
|
45
|
+
**Security**
|
|
46
|
+
- `netlify.toml` and `vercel.json` send the same strict Content-Security-Policy (keep their headers identical): same-origin scripts only, no inline scripts, no inline event handlers and no iframes. `npm start` sends the same headers, so the browser tests catch violations, and `npm run check` flags them earlier.
|
|
47
|
+
- Never put user input or URL parameters into `innerHTML`. Use `textContent` or build elements.
|
|
48
|
+
- No runtime npm packages and no CDN scripts. The only dependencies are the dev-only test tools in `package.json`.
|
|
49
|
+
|
|
50
|
+
**Design**
|
|
51
|
+
- Fast: system font stack, images under 100 KB each, no layout shift.
|
|
52
|
+
- Accessible: a label on every input, visible focus, AA contrast, fully usable by keyboard.
|
|
53
|
+
- Remember the visitor's last inputs and units in `localStorage` (wrapped in try/catch).
|
|
54
|
+
- Results print cleanly (a print stylesheet): hobby visitors print their numbers.
|
|
55
|
+
|
|
56
|
+
---
|
|
57
|
+
|
|
58
|
+
## Operating Card (the Compounding Loop)
|
|
59
|
+
|
|
60
|
+
> The full method is in `.ai/method.md`. The § numbers point to Part B there.
|
|
61
|
+
|
|
62
|
+
**Objective:** the most verified-correct results per minute of the owner's attention. Tokens are the cheapest resource. A wrong result that looks right is the most expensive one.
|
|
63
|
+
|
|
64
|
+
**Start of every session**
|
|
65
|
+
1. Read `.ai/index.md`. Open a knowledge entry only when it's relevant to what you're doing.
|
|
66
|
+
2. If `.ai/task.md` exists, run the resume protocol (§9.2) before anything else.
|
|
67
|
+
|
|
68
|
+
**Before work**
|
|
69
|
+
3. Size the task. Tiny: just do it. Medium: contract + checks. Large or unclear: plan, then loop.
|
|
70
|
+
4. For medium and large tasks, write the contract (§5): goal, done-when (checkable by a command), constraints, out of scope.
|
|
71
|
+
|
|
72
|
+
**During work**
|
|
73
|
+
5. One unit of work at a time. Run the checks after each unit. You never declare done; the checks do.
|
|
74
|
+
6. Write a note the moment you learn something, marked CONFIRMED or GUESS. Don't save it for the end.
|
|
75
|
+
7. After any error, search `.ai/index.md` for the error text before trying a fix.
|
|
76
|
+
8. Before editing a file, check the index for entries about that path.
|
|
77
|
+
9. Send reading-heavy work (search, docs, logs, review) to subagents that report back in 10 lines or fewer. Keep code-writing in one thread.
|
|
78
|
+
10. Same failure twice: stop and run the stuck protocol (§9.3). Don't retry a variant.
|
|
79
|
+
11. If reality contradicts the plan or contract, stop and record it (see Unattended mode). Don't silently work around it.
|
|
80
|
+
12. Point to code and docs (`path:line`). Never copy them into notes.
|
|
81
|
+
|
|
82
|
+
**Context**
|
|
83
|
+
13. At ~50–60% context, or when a unit is done and the next one is big: run the handoff (§9.1) and end the run.
|
|
84
|
+
|
|
85
|
+
**Finishing**
|
|
86
|
+
14. When the checks pass, run the retro (§12), log the task and delete `.ai/task.md`.
|
|
87
|
+
|
|
88
|
+
**Never**
|
|
89
|
+
- Mark your own work done without the checks passing.
|
|
90
|
+
- Treat a GUESS from an earlier window as fact.
|
|
91
|
+
- Delete or rewrite CONFIRMED knowledge without new evidence.
|
|
92
|
+
- Change this card.
|
|
93
|
+
|
|
94
|
+
---
|
|
95
|
+
|
|
96
|
+
## Unattended mode
|
|
97
|
+
|
|
98
|
+
No human is watching. The owner reads the results later. These rules replace "ask the human":
|
|
99
|
+
|
|
100
|
+
- **Git:** work on `main` and push to `main`, never to a `claude/` branch. The next run starts from `main` and would never see work left anywhere else. Start every run with `git checkout main && git pull --ff-only`. If a push is rejected, run `git pull --rebase` once and push again. If that conflicts, run `git rebase --abort`, record it under Tried, and stop.
|
|
101
|
+
- **Questions:** decide using IDEA.md and common sense, write the decision and the reason under **Decisions** in `.ai/task.md`, then keep going. Pick the option that's easiest to reverse.
|
|
102
|
+
- **Plan approval:** for large tasks, write the plan in `.ai/plans/`, have a reviewer subagent attack it ("what are the 3 most likely ways this plan fails?"), fix the plan, then build.
|
|
103
|
+
- **Review before every push to `main`:** `npm test` must pass. Then a reviewer subagent that didn't write the code checks the diff with the brief: "Find the three most likely ways this is wrong for a real visitor, and check each one." Fix any blocker first.
|
|
104
|
+
- **Stuck protocol, last step:** there's no human to ask. Write `BLOCKED.md` (what you tried, the specific question, your best guess), commit it, push it, and stop.
|
|
105
|
+
- **Run budget:** `.ai/task.md` holds `Run: N / LIMIT`. Add 1 at the start of every run. When N reaches LIMIT, write `BLOCKED.md` saying "run budget reached", push it, and stop.
|
|
106
|
+
- **Finished:** when every done-when in the contract passes and the reviewer finds no blockers, run the retro, write `DONE.md`, delete `.ai/task.md`, push and stop. `DONE.md` holds: what was built (each page and its target keyword), test results, anything you were unsure of, and the owner's launch checklist: check that the Netlify site serves the address in IDEA.md, add that address to Search Console as a URL-prefix property, submit `/sitemap.xml`, and buy the domain named in IDEA.md once there's budget for it. End with the 10 best next pages to build, ranked by search demand and how weak the current results are.
|
|
107
|
+
- **If `DONE.md` or `BLOCKED.md` exists at the start of a run:** do nothing and stop.
|
|
108
|
+
|
|
109
|
+
**Never, even if it seems helpful**
|
|
110
|
+
- Spend money, sign up for anything, or create accounts.
|
|
111
|
+
- Add API keys, secrets, ads, analytics or tracking code. The owner adds those later.
|
|
112
|
+
- Copy text, images or code from competitor sites. Write original content and use original or properly licensed assets.
|
|
113
|
+
- Force-push, rewrite git history, or touch anything outside this repository.
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# IDEA
|
|
2
|
+
|
|
3
|
+
> The owner fills this in. It's the only input the loop gets, so the more specific, the better.
|
|
4
|
+
|
|
5
|
+
## The site in one sentence
|
|
6
|
+
<e.g. "A free online unit converter for cooks, with printable charts.">
|
|
7
|
+
|
|
8
|
+
## Domain
|
|
9
|
+
{{name}}.netlify.app
|
|
10
|
+
|
|
11
|
+
<Free until you buy a domain. Replace it with e.g. "example.com" before launch if you can. Canonical URLs use it, and moving domains later costs rankings.>
|
|
12
|
+
|
|
13
|
+
## Who it's for
|
|
14
|
+
<e.g. "Home cooks who want quick, reliable conversions while following a recipe.">
|
|
15
|
+
|
|
16
|
+
## Target keywords (from my research)
|
|
17
|
+
| Keyword | Monthly searches | Difficulty | Page it belongs on |
|
|
18
|
+
|---|---|---|---|
|
|
19
|
+
| <unit converter> | <…> | <…> | / |
|
|
20
|
+
| <…> | | | |
|
|
21
|
+
|
|
22
|
+
## Must have (v1)
|
|
23
|
+
- <the main tool or feature>
|
|
24
|
+
- <…>
|
|
25
|
+
|
|
26
|
+
## Nice to have (only after v1 is live)
|
|
27
|
+
- <…>
|
|
28
|
+
|
|
29
|
+
## Look and feel
|
|
30
|
+
<e.g. "Dark, fast, minimal. Like a clean reference sheet.">
|
|
31
|
+
|
|
32
|
+
## Competitors to beat (for reference only, never copy them)
|
|
33
|
+
- <url>: <what they do badly>
|
|
34
|
+
|
|
35
|
+
## Run budget
|
|
36
|
+
30
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# Netlify hosting: publish only site/ (CLAUDE.md, IDEA.md and .ai/ stay private).
|
|
2
|
+
# Headers must match vercel.json, which scripts/serve.mjs uses for local tests.
|
|
3
|
+
[build]
|
|
4
|
+
publish = "site"
|
|
5
|
+
command = ""
|
|
6
|
+
|
|
7
|
+
[[headers]]
|
|
8
|
+
for = "/*"
|
|
9
|
+
[headers.values]
|
|
10
|
+
Content-Security-Policy = "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'"
|
|
11
|
+
X-Content-Type-Options = "nosniff"
|
|
12
|
+
Referrer-Policy = "strict-origin-when-cross-origin"
|
|
13
|
+
Permissions-Policy = "camera=(), microphone=(), geolocation=(), payment=(), usb=()"
|
|
14
|
+
X-Frame-Options = "DENY"
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "{{name}}",
|
|
3
|
+
"private": true,
|
|
4
|
+
"type": "module",
|
|
5
|
+
"scripts": {
|
|
6
|
+
"start": "node scripts/serve.mjs",
|
|
7
|
+
"check": "node scripts/check.mjs",
|
|
8
|
+
"unit": "node --test \"tests/unit/**/*.test.mjs\"",
|
|
9
|
+
"test": "node scripts/test.mjs"
|
|
10
|
+
},
|
|
11
|
+
"devDependencies": {
|
|
12
|
+
"@playwright/test": "^1.48.0"
|
|
13
|
+
}
|
|
14
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { defineConfig, devices, chromium } from '@playwright/test';
|
|
2
|
+
import { existsSync, readdirSync } from 'node:fs';
|
|
3
|
+
|
|
4
|
+
// Cloud sandboxes can block Playwright's own browser download. If that browser is missing, use a preinstalled
|
|
5
|
+
// Playwright Chromium (Claude's cloud VMs have one in /opt/pw-browsers) or a system Chrome.
|
|
6
|
+
const bundled = (() => { try { return chromium.executablePath(); } catch { return ''; } })();
|
|
7
|
+
const preinstalled = ['/opt/pw-browsers', `${process.env.HOME}/.cache/ms-playwright`].flatMap((dir) => {
|
|
8
|
+
try {
|
|
9
|
+
return readdirSync(dir).filter((d) => /^chromium-\d+$/.test(d)).sort((a, b) => b.localeCompare(a, 'en', { numeric: true }))
|
|
10
|
+
.flatMap((d) => [`${dir}/${d}/chrome-linux64/chrome`, `${dir}/${d}/chrome-linux/chrome`]);
|
|
11
|
+
} catch { return []; }
|
|
12
|
+
});
|
|
13
|
+
const systemChrome = [process.env.CHROME_PATH, ...preinstalled, '/usr/bin/google-chrome', '/usr/bin/google-chrome-stable', '/usr/bin/chromium', '/usr/bin/chromium-browser', '/opt/google/chrome/chrome']
|
|
14
|
+
.find((p) => p && existsSync(p));
|
|
15
|
+
const launchOptions = bundled && existsSync(bundled) ? {} : systemChrome ? { executablePath: systemChrome } : {};
|
|
16
|
+
|
|
17
|
+
export default defineConfig({
|
|
18
|
+
testDir: 'tests',
|
|
19
|
+
testIgnore: 'unit/**',
|
|
20
|
+
timeout: 20_000,
|
|
21
|
+
use: { baseURL: 'http://localhost:4173', launchOptions },
|
|
22
|
+
webServer: { command: 'npm start', url: 'http://localhost:4173', reuseExistingServer: true },
|
|
23
|
+
projects: [
|
|
24
|
+
{ name: 'laptop', use: { ...devices['Desktop Chrome'], viewport: { width: 1366, height: 633 } } },
|
|
25
|
+
{ name: 'phone', use: { ...devices['Pixel 7'] } },
|
|
26
|
+
],
|
|
27
|
+
});
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
// Fast structural, SEO, quality and security checks for everything in site/. No dependencies, no browser.
|
|
2
|
+
// Every failure message says how to fix it, because the agent reads them at exactly the right moment.
|
|
3
|
+
import { readFileSync, readdirSync, statSync, existsSync } from 'node:fs';
|
|
4
|
+
import { join, relative, dirname, resolve, sep, basename } from 'node:path';
|
|
5
|
+
|
|
6
|
+
const SITE = resolve(process.argv[2] ?? 'site');
|
|
7
|
+
const ROOT = dirname(SITE);
|
|
8
|
+
const MIN_WORDS = 300; // visible words per page; thin pages don't rank and invite "scaled content" penalties
|
|
9
|
+
const MAX_SIMILARITY = 0.6; // share of 5-word phrases two pages may have in common
|
|
10
|
+
const errors = [];
|
|
11
|
+
const fail = (file, msg) => errors.push(`${file}: ${msg}`);
|
|
12
|
+
|
|
13
|
+
function walk(dir) {
|
|
14
|
+
return readdirSync(dir).flatMap((name) => {
|
|
15
|
+
const p = join(dir, name);
|
|
16
|
+
return statSync(p).isDirectory() ? walk(p) : [p];
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
if (!existsSync(SITE)) {
|
|
21
|
+
console.error(`No ${SITE} folder. Put every public file in site/ (Vercel publishes only that folder).`);
|
|
22
|
+
process.exit(1);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const files = walk(SITE);
|
|
26
|
+
const rel = (p) => relative(SITE, p).split(sep).join('/');
|
|
27
|
+
const pages = files.filter((f) => f.endsWith('.html'));
|
|
28
|
+
|
|
29
|
+
// Private files must never be published.
|
|
30
|
+
for (const f of files) {
|
|
31
|
+
if (/(^|\/)(CLAUDE\.md|IDEA\.md|DONE\.md|BLOCKED\.md|\.ai\/|\.env)/.test(rel(f))) {
|
|
32
|
+
fail(rel(f), 'private file inside site/. Move it out: site/ is served publicly.');
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Secrets must never be committed anywhere public.
|
|
37
|
+
const SECRET = /(\bsk-(ant-)?[A-Za-z0-9_-]{20,}|\bAKIA[0-9A-Z]{16}|\bAIza[0-9A-Za-z_-]{35}|\bgh[pousr]_[A-Za-z0-9]{30,}|\bxox[baprs]-[A-Za-z0-9-]{10,}|-----BEGIN [A-Z ]*PRIVATE KEY-----)/;
|
|
38
|
+
for (const f of files) {
|
|
39
|
+
if (!/\.(html|js|mjs|css|json|txt|xml|svg|webmanifest)$/.test(f)) continue;
|
|
40
|
+
if (SECRET.test(readFileSync(f, 'utf8'))) fail(rel(f), 'looks like it contains an API key or private key. Remove it; secrets never go in site/.');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (pages.length === 0) fail('site/', 'no HTML pages. Build site/index.html first.');
|
|
44
|
+
|
|
45
|
+
const canonicals = [];
|
|
46
|
+
const titles = new Map();
|
|
47
|
+
const shingles = new Map();
|
|
48
|
+
|
|
49
|
+
const visibleText = (html) =>
|
|
50
|
+
html
|
|
51
|
+
.replace(/<(script|style|noscript|svg|template)[\s\S]*?<\/\1>/gi, ' ')
|
|
52
|
+
.replace(/<[^>]+>/g, ' ')
|
|
53
|
+
.replace(/&[a-z#0-9]+;/gi, ' ')
|
|
54
|
+
.toLowerCase()
|
|
55
|
+
.split(/[^a-z0-9%°]+/)
|
|
56
|
+
.filter(Boolean);
|
|
57
|
+
|
|
58
|
+
for (const page of pages) {
|
|
59
|
+
const name = rel(page);
|
|
60
|
+
const html = readFileSync(page, 'utf8');
|
|
61
|
+
const one = (re) => html.match(re)?.[1]?.trim();
|
|
62
|
+
const isErrorPage = /^404\.html$/.test(name);
|
|
63
|
+
|
|
64
|
+
if (!/<html[^>]*\slang="[a-z-]+"/i.test(html)) fail(name, 'add a lang attribute: <html lang="en">.');
|
|
65
|
+
if (!/<meta[^>]+name="viewport"/i.test(html)) fail(name, 'add <meta name="viewport" content="width=device-width, initial-scale=1">.');
|
|
66
|
+
|
|
67
|
+
const title = one(/<title>([^<]*)<\/title>/i);
|
|
68
|
+
if (!title) fail(name, 'add a <title> containing the page\'s main keyword.');
|
|
69
|
+
else if (title.length < 10 || title.length > 65) fail(name, `<title> is ${title.length} chars; keep it 10–65 so Google shows it whole.`);
|
|
70
|
+
else if (titles.has(title)) fail(name, `<title> duplicates ${titles.get(title)}. Each page needs its own.`);
|
|
71
|
+
else titles.set(title, name);
|
|
72
|
+
|
|
73
|
+
if (/lorem ipsum|REPLACE_ME|\bTODO\b/i.test(html)) fail(name, 'contains placeholder text (lorem ipsum, REPLACE_ME or TODO). Write the real content.');
|
|
74
|
+
|
|
75
|
+
// Security: the CSP in vercel.json only allows same-origin scripts, so these would break in production anyway.
|
|
76
|
+
for (const [tag] of html.matchAll(/<script\b[^>]*>/gi)) {
|
|
77
|
+
if (/type="application\/ld\+json"/i.test(tag)) continue;
|
|
78
|
+
const src = tag.match(/\ssrc="([^"]+)"/i)?.[1];
|
|
79
|
+
if (!src) fail(name, 'inline <script> code. Move it to a .js file in site/ and load it with <script type="module" src="...">; the CSP blocks inline scripts.');
|
|
80
|
+
else if (/^(https?:)?\/\//i.test(src)) fail(name, `third-party script "${src}". Self-host it in site/ (or leave it out); ads and analytics are the owner's call.`);
|
|
81
|
+
}
|
|
82
|
+
if (/<iframe\b/i.test(html)) fail(name, 'contains an <iframe>. Embeds load third-party code; leave them out.');
|
|
83
|
+
if (/\son[a-z]+\s*=\s*["']/i.test(html.replace(/<script[\s\S]*?<\/script>/gi, ''))) fail(name, 'inline event handler (onclick="..." etc.). Use addEventListener in a .js file; the CSP blocks inline handlers.');
|
|
84
|
+
|
|
85
|
+
// Internal links and assets must exist.
|
|
86
|
+
for (const [, url] of html.matchAll(/(?:href|src)="([^"#?]+)[^"]*"/gi)) {
|
|
87
|
+
if (/^(https?:|mailto:|tel:|data:|\/\/)/i.test(url)) continue;
|
|
88
|
+
let target = url.startsWith('/') ? join(SITE, url) : join(dirname(page), url);
|
|
89
|
+
if (url.endsWith('/')) target = join(target, 'index.html');
|
|
90
|
+
if (!existsSync(target) && !existsSync(`${target}.html`)) fail(name, `broken link or asset "${url}". Create the file or fix the path.`);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (isErrorPage) continue; // 404 needs no SEO tags, canonical or sitemap entry
|
|
94
|
+
|
|
95
|
+
const desc = one(/<meta[^>]+name="description"[^>]+content="([^"]*)"/i);
|
|
96
|
+
if (!desc) fail(name, 'add <meta name="description" content="..."> (70–160 chars, written for searchers).');
|
|
97
|
+
else if (desc.length < 70 || desc.length > 160) fail(name, `meta description is ${desc.length} chars; keep it 70–160.`);
|
|
98
|
+
|
|
99
|
+
const h1s = html.match(/<h1[\s>]/gi)?.length ?? 0;
|
|
100
|
+
if (h1s !== 1) fail(name, `has ${h1s} <h1> elements; use exactly one, containing the main keyword.`);
|
|
101
|
+
|
|
102
|
+
const canonical = one(/<link[^>]+rel="canonical"[^>]+href="([^"]+)"/i);
|
|
103
|
+
if (!canonical) fail(name, 'add <link rel="canonical" href="https://<domain>/<path>"> with the full URL.');
|
|
104
|
+
else if (!/^https:\/\//.test(canonical)) fail(name, `canonical "${canonical}" must be an absolute https:// URL.`);
|
|
105
|
+
else canonicals.push(canonical);
|
|
106
|
+
|
|
107
|
+
if (!/<script[^>]+type="application\/ld\+json"/i.test(html)) fail(name, 'add JSON-LD structured data (WebApplication, FAQPage or BreadcrumbList).');
|
|
108
|
+
|
|
109
|
+
// Quality: enough original text, and not a near-copy of another page.
|
|
110
|
+
const words = visibleText(html);
|
|
111
|
+
if (words.length < MIN_WORDS) fail(name, `only ${words.length} visible words; write at least ${MIN_WORDS} (how to use it, how it's calculated, worked example, FAQ).`);
|
|
112
|
+
const set = new Set();
|
|
113
|
+
for (let i = 0; i + 5 <= words.length; i++) set.add(words.slice(i, i + 5).join(' '));
|
|
114
|
+
shingles.set(name, set);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Near-duplicate pages (the classic programmatic-SEO failure).
|
|
118
|
+
const named = [...shingles.entries()].filter(([, s]) => s.size > 50);
|
|
119
|
+
for (let i = 0; i < named.length; i++) {
|
|
120
|
+
for (let j = i + 1; j < named.length; j++) {
|
|
121
|
+
const [a, sa] = named[i];
|
|
122
|
+
const [b, sb] = named[j];
|
|
123
|
+
let shared = 0;
|
|
124
|
+
for (const s of sa) if (sb.has(s)) shared++;
|
|
125
|
+
const similarity = shared / Math.min(sa.size, sb.size);
|
|
126
|
+
if (similarity > MAX_SIMILARITY) {
|
|
127
|
+
fail(a, `${Math.round(similarity * 100)}% of its 5-word phrases also appear in ${b}. Rewrite one of them with page-specific content, or merge them into one page. Keep shared nav and footer text short so it does not dominate.`);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// One domain everywhere.
|
|
133
|
+
const origins = new Set(canonicals.map((c) => new URL(c).origin));
|
|
134
|
+
if (origins.size > 1) fail('site/', `canonicals use ${origins.size} different domains (${[...origins].join(', ')}). Use the one domain from IDEA.md everywhere.`);
|
|
135
|
+
// ...and it is the address in IDEA.md's Domain section (the first word under the heading).
|
|
136
|
+
const ideaPath = join(ROOT, 'IDEA.md');
|
|
137
|
+
const host = existsSync(ideaPath) ? readFileSync(ideaPath, 'utf8').match(/^## Domain[^\n]*\n+([a-z0-9_-]+(?:\.[a-z0-9_-]+)+)/im)?.[1].toLowerCase() : null;
|
|
138
|
+
if (host) {
|
|
139
|
+
if (!host.split('.').every((l) => /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/.test(l))) fail('IDEA.md', `the Domain "${host}" is not a valid hostname (letters, digits and hyphens only, no leading or trailing hyphen, 63 characters per part). Fix it here and in every canonical.`);
|
|
140
|
+
for (const o of origins) if (o !== `https://${host}`) fail('site/', `canonicals use ${o}, but IDEA.md's Domain section says https://${host}. Use that address in every canonical, sitemap.xml and robots.txt.`);
|
|
141
|
+
const robots = existsSync(join(SITE, 'robots.txt')) ? readFileSync(join(SITE, 'robots.txt'), 'utf8') : '';
|
|
142
|
+
if (robots && !robots.includes(`Sitemap: https://${host}/sitemap.xml`)) fail('robots.txt', `its Sitemap line must be "Sitemap: https://${host}/sitemap.xml".`);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Hosting: vercel.json publishes only site/ and sends the security headers.
|
|
146
|
+
let vercel = null;
|
|
147
|
+
try { vercel = JSON.parse(readFileSync(join(ROOT, 'vercel.json'), 'utf8')); } catch { /* reported below */ }
|
|
148
|
+
if (!vercel) fail('vercel.json', 'missing or not valid JSON. It must set "outputDirectory": "site" and send the security headers (copy it from the starter kit).');
|
|
149
|
+
else {
|
|
150
|
+
if (vercel.outputDirectory !== 'site') fail('vercel.json', '"outputDirectory" must be "site". Anything else publishes private files such as CLAUDE.md and IDEA.md.');
|
|
151
|
+
const sent = (vercel.headers ?? []).find((r) => r.source === '/(.*)')?.headers?.map((h) => h.key) ?? [];
|
|
152
|
+
for (const h of ['Content-Security-Policy', 'X-Content-Type-Options', 'Referrer-Policy', 'X-Frame-Options']) {
|
|
153
|
+
if (!sent.includes(h)) fail('vercel.json', `the "/(.*)" headers rule must send ${h}.`);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Netlify is the live host: netlify.toml must publish only site/ and send the same CSP.
|
|
158
|
+
const toml = existsSync(join(ROOT, 'netlify.toml')) ? readFileSync(join(ROOT, 'netlify.toml'), 'utf8') : '';
|
|
159
|
+
if (!/^\s*publish\s*=\s*"site\/?"\s*$/m.test(toml)) fail('netlify.toml', 'missing, or does not set publish = "site". Anything else publishes private files such as CLAUDE.md and IDEA.md.');
|
|
160
|
+
else if (!toml.includes('Content-Security-Policy')) fail('netlify.toml', 'must send the same security headers as vercel.json (copy it from the starter kit).');
|
|
161
|
+
|
|
162
|
+
// Sitemap and robots.
|
|
163
|
+
const sitemapPath = join(SITE, 'sitemap.xml');
|
|
164
|
+
if (!existsSync(sitemapPath)) fail('sitemap.xml', 'missing. List every page\'s canonical URL in site/sitemap.xml.');
|
|
165
|
+
else {
|
|
166
|
+
const sitemap = readFileSync(sitemapPath, 'utf8');
|
|
167
|
+
for (const c of canonicals) if (!sitemap.includes(`<loc>${c}</loc>`)) fail('sitemap.xml', `missing <loc>${c}</loc>. Every page must be listed.`);
|
|
168
|
+
for (const [, loc] of sitemap.matchAll(/<loc>([^<]+)<\/loc>/g)) if (!canonicals.includes(loc)) fail('sitemap.xml', `lists ${loc}, but no page has that canonical. Remove it or fix the page's canonical.`);
|
|
169
|
+
}
|
|
170
|
+
const robotsPath = join(SITE, 'robots.txt');
|
|
171
|
+
if (!existsSync(robotsPath) || !/^Sitemap:\s*https:\/\//im.test(readFileSync(robotsPath, 'utf8'))) {
|
|
172
|
+
fail('robots.txt', 'missing, or has no "Sitemap: https://<domain>/sitemap.xml" line.');
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Correctness: every calculation module has unit tests.
|
|
176
|
+
const libDir = join(SITE, 'js', 'lib');
|
|
177
|
+
if (existsSync(libDir)) {
|
|
178
|
+
for (const f of walk(libDir).filter((p) => /\.m?js$/.test(p))) {
|
|
179
|
+
const test = join(ROOT, 'tests', 'unit', basename(f).replace(/\.m?js$/, '.test.mjs'));
|
|
180
|
+
if (!existsSync(test)) fail(rel(f), `has no unit tests. Create tests/unit/${basename(test)} with reference values from a cited source (see CLAUDE.md "Correctness").`);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (errors.length) {
|
|
185
|
+
console.error(`check: ${errors.length} problem(s)\n- ${errors.join('\n- ')}`);
|
|
186
|
+
process.exit(1);
|
|
187
|
+
}
|
|
188
|
+
console.log(`check: OK (${pages.length} page(s))`);
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// Serves site/ the way Vercel does (folder URLs, trailing-slash redirects, 404.html, the headers from vercel.json), so the browser
|
|
2
|
+
// tests see what production sends. No dependencies.
|
|
3
|
+
import { createServer } from 'node:http';
|
|
4
|
+
import { readFileSync, existsSync, statSync } from 'node:fs';
|
|
5
|
+
import { join, extname, resolve, sep } from 'node:path';
|
|
6
|
+
|
|
7
|
+
const SITE = resolve('site');
|
|
8
|
+
const PORT = Number(process.env.PORT ?? 4173);
|
|
9
|
+
const TYPES = {
|
|
10
|
+
'.html': 'text/html; charset=utf-8', '.css': 'text/css; charset=utf-8', '.js': 'text/javascript; charset=utf-8',
|
|
11
|
+
'.mjs': 'text/javascript; charset=utf-8', '.json': 'application/json', '.svg': 'image/svg+xml', '.png': 'image/png',
|
|
12
|
+
'.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.webp': 'image/webp', '.avif': 'image/avif', '.ico': 'image/x-icon',
|
|
13
|
+
'.xml': 'application/xml', '.txt': 'text/plain; charset=utf-8', '.webmanifest': 'application/manifest+json',
|
|
14
|
+
'.woff2': 'font/woff2',
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
// Reads the headers vercel.json sends on every path (the single `"source": "/(.*)"` rule the starter uses).
|
|
18
|
+
function vercelHeaders() {
|
|
19
|
+
if (!existsSync('vercel.json')) return {};
|
|
20
|
+
const rule = (JSON.parse(readFileSync('vercel.json', 'utf8')).headers ?? []).find((r) => r.source === '/(.*)');
|
|
21
|
+
return Object.fromEntries((rule?.headers ?? []).map((h) => [h.key, h.value]));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const isFile = (p) => existsSync(p) && statSync(p).isFile();
|
|
25
|
+
const isDir = (p) => existsSync(p) && statSync(p).isDirectory();
|
|
26
|
+
|
|
27
|
+
createServer((req, res) => {
|
|
28
|
+
const headers = { ...vercelHeaders(), 'Cache-Control': 'no-cache' };
|
|
29
|
+
let path;
|
|
30
|
+
try { path = decodeURIComponent(new URL(req.url, 'http://x').pathname); } catch { path = '/'; }
|
|
31
|
+
const target = resolve(join(SITE, path));
|
|
32
|
+
if (target !== SITE && !target.startsWith(SITE + sep)) { res.writeHead(400, headers).end('Bad path'); return; }
|
|
33
|
+
|
|
34
|
+
if (isDir(target) && !path.endsWith('/')) { res.writeHead(308, { ...headers, Location: `${path}/` }).end(); return; }
|
|
35
|
+
const file = [target, join(target, 'index.html'), `${target}.html`].find(isFile);
|
|
36
|
+
if (file) {
|
|
37
|
+
res.writeHead(200, { ...headers, 'Content-Type': TYPES[extname(file)] ?? 'application/octet-stream' });
|
|
38
|
+
res.end(readFileSync(file));
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
const notFound = join(SITE, '404.html');
|
|
42
|
+
res.writeHead(404, { ...headers, 'Content-Type': 'text/html; charset=utf-8' });
|
|
43
|
+
res.end(isFile(notFound) ? readFileSync(notFound) : 'Not found');
|
|
44
|
+
}).listen(PORT, () => console.log(`Serving site/ at http://localhost:${PORT}`));
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// `npm test`: runs each test step in order (stopping at the first failure, like `&&`) and writes the
|
|
2
|
+
// totals to .ai/last-test.json, which the Compounding Loop dashboard's "failing tests" health check reads.
|
|
3
|
+
// Commit that file with your work so the dashboard sees it whichever machine ran the tests.
|
|
4
|
+
import { spawn } from 'node:child_process';
|
|
5
|
+
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
|
|
9
|
+
/** Totals from test-runner output: node:test (`ℹ pass 3` or TAP `# pass 3`) and Playwright (`12 passed`, `1 failed`). */
|
|
10
|
+
export function countResults(output) {
|
|
11
|
+
const plain = output.replace(/\x1b\[[0-9;]*m/g, '');
|
|
12
|
+
const sum = (re) => [...plain.matchAll(re)].reduce((n, m) => n + Number(m[1]), 0);
|
|
13
|
+
const passed = sum(/^\s*(?:ℹ|#) pass (\d+)\s*$/gm) + sum(/^\s*(\d+) passed\b/gm);
|
|
14
|
+
const failed = sum(/^\s*(?:ℹ|#) fail (\d+)\s*$/gm) + sum(/^\s*(\d+) failed\b/gm);
|
|
15
|
+
return passed + failed > 0 ? { passed, failed } : undefined;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Steps this repo has, as [label, node arguments]. Node runs them directly, so no shell or .cmd shim is involved. */
|
|
19
|
+
function steps() {
|
|
20
|
+
const list = [['check', ['scripts/check.mjs']]];
|
|
21
|
+
if (existsSync('tests/unit')) list.push(['unit', ['--test', 'tests/unit/**/*.test.mjs']]);
|
|
22
|
+
if (existsSync('playwright.config.js')) list.push(['browser', [path.join('node_modules', '@playwright', 'test', 'cli.js'), 'test', ...process.argv.slice(2)]]);
|
|
23
|
+
return list;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function run(args) {
|
|
27
|
+
return new Promise((resolve) => {
|
|
28
|
+
let output = '';
|
|
29
|
+
const child = spawn(process.execPath, args, { stdio: ['inherit', 'pipe', 'pipe'] });
|
|
30
|
+
child.stdout.on('data', (chunk) => { output += chunk; process.stdout.write(chunk); });
|
|
31
|
+
child.stderr.on('data', (chunk) => { output += chunk; process.stderr.write(chunk); });
|
|
32
|
+
child.on('error', (error) => { output += String(error); resolve({ code: 1, output }); });
|
|
33
|
+
child.on('close', (code) => resolve({ code: code ?? 1, output }));
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function main() {
|
|
38
|
+
const total = { passed: 0, failed: 0 };
|
|
39
|
+
let code = 0;
|
|
40
|
+
for (const [label, args] of steps()) {
|
|
41
|
+
const result = await run(args);
|
|
42
|
+
// A step without countable output (the check script) counts as one test.
|
|
43
|
+
const counts = countResults(result.output) ?? (result.code === 0 ? { passed: 1, failed: 0 } : { passed: 0, failed: 1 });
|
|
44
|
+
if (result.code !== 0 && counts.failed === 0) counts.failed = 1;
|
|
45
|
+
total.passed += counts.passed;
|
|
46
|
+
total.failed += counts.failed;
|
|
47
|
+
if (result.code !== 0) {
|
|
48
|
+
console.error(`\n${label} failed (exit ${result.code}); later steps skipped.`);
|
|
49
|
+
code = result.code;
|
|
50
|
+
break;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
mkdirSync('.ai', { recursive: true });
|
|
54
|
+
writeFileSync(path.join('.ai', 'last-test.json'), `${JSON.stringify({ ...total, at: new Date().toISOString() })}\n`);
|
|
55
|
+
console.log(`\n${total.passed} passed, ${total.failed} failed (written to .ai/last-test.json)`);
|
|
56
|
+
process.exitCode = code;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) await main();
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>{{name}} | Free online tools</title>
|
|
7
|
+
<meta name="description" content="{{name}} is a fast, free, privacy-friendly website built and verified by an unattended Compounding Loop.">
|
|
8
|
+
<link rel="icon" href="data:,">
|
|
9
|
+
<link rel="canonical" href="https://{{name}}.netlify.app/">
|
|
10
|
+
<script type="application/ld+json">{"@context":"https://schema.org","@type":"WebSite","name":"{{name}}","url":"https://{{name}}.netlify.app/"}</script>
|
|
11
|
+
</head>
|
|
12
|
+
<body>
|
|
13
|
+
<main>
|
|
14
|
+
<h1>{{name}}</h1>
|
|
15
|
+
<p>This is the starting page for {{name}}. It is a small, fast, static website: plain HTML, one stylesheet and no third-party code. Every page you add lives in the site folder, and the checks that ship with this project refuse to pass until each page is complete, original and safe to publish.</p>
|
|
16
|
+
<p>The loop that builds this site works in short, verified units. It reads the brief in IDEA.md, writes a contract for the next unit of work, builds it, and runs the full test suite. Only when the checks pass does it commit and push. If a check fails, the attempt is recorded so the next run does not repeat the same mistake. Nothing counts as finished because the agent says so; it counts when a test proves it.</p>
|
|
17
|
+
<p>What the checks look at: every page needs a title, a description written for searchers, exactly one main heading, a canonical address on the domain named in the brief, structured data, and enough real text to be useful. Pages that copy each other are rejected. Links and assets must exist. Scripts must come from the same origin, because the security policy blocks everything else. Private files such as the brief and the notes folder must never end up inside the published folder.</p>
|
|
18
|
+
<p>How to use this starter: open IDEA.md and describe the site in one sentence, name the domain, list the audience and the keywords, and say what the first version must do. Then run the loop. The first run replaces this page with the real home page, adds the pages for each keyword cluster, and writes a browser test for every must-have feature, so a broken tool is caught before a visitor sees it.</p>
|
|
19
|
+
<p>Frequently asked questions. Can I edit pages by hand? Yes, the checks run the same way for people and for the loop. Does the site track visitors? No analytics, ads or tracking code are added unless the owner decides to. Where is it hosted? Any static host works; the configuration for Netlify and Vercel is included and publishes only the site folder. How do I see it locally? Run npm start and open the address it prints.</p>
|
|
20
|
+
<p>When the brief is complete and every check passes, the loop writes a short report listing what was built, what was tested, and the ten most valuable pages to build next. Replace this text with your own content as soon as the first real page exists, and keep each page focused on one question that a searcher would actually ask.</p>
|
|
21
|
+
</main>
|
|
22
|
+
</body>
|
|
23
|
+
</html>
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// Checks every page in the sitemap in a real browser. Feature tests for the site's tools go in their own files.
|
|
2
|
+
import { test, expect } from '@playwright/test';
|
|
3
|
+
import { readFileSync } from 'node:fs';
|
|
4
|
+
|
|
5
|
+
const paths = [...readFileSync('site/sitemap.xml', 'utf8').matchAll(/<loc>https:\/\/[^/]+(\/[^<]*)<\/loc>/g)].map((m) => m[1]);
|
|
6
|
+
|
|
7
|
+
for (const path of paths) {
|
|
8
|
+
test(`${path} loads cleanly`, async ({ page }) => {
|
|
9
|
+
const problems = [];
|
|
10
|
+
page.on('pageerror', (e) => problems.push(`JS error: ${e.message}`));
|
|
11
|
+
page.on('console', (m) => m.type() === 'error' && problems.push(`console error: ${m.text()}`));
|
|
12
|
+
|
|
13
|
+
const res = await page.goto(path);
|
|
14
|
+
expect(res.status(), `${path} should return 200`).toBe(200);
|
|
15
|
+
await expect(page.locator('h1')).toBeVisible();
|
|
16
|
+
|
|
17
|
+
const overflow = await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth);
|
|
18
|
+
expect(overflow, 'page scrolls sideways: find the element wider than the screen').toBeLessThanOrEqual(0);
|
|
19
|
+
expect(problems, 'fix these errors').toEqual([]);
|
|
20
|
+
});
|
|
21
|
+
}
|