sitelines 1.0.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.
Files changed (36) hide show
  1. package/DESIGN.md +129 -0
  2. package/LICENSE +21 -0
  3. package/README.md +336 -0
  4. package/bin/sitelines.mjs +155 -0
  5. package/examples/demo-site/about/index.html +30 -0
  6. package/examples/demo-site/assets/auth.js +3 -0
  7. package/examples/demo-site/assets/site.css +27 -0
  8. package/examples/demo-site/blog/index.html +31 -0
  9. package/examples/demo-site/blog/introducing-meridian/index.html +30 -0
  10. package/examples/demo-site/changelog/index.html +30 -0
  11. package/examples/demo-site/contact/index.html +33 -0
  12. package/examples/demo-site/docs/api/index.html +31 -0
  13. package/examples/demo-site/docs/api/webhooks/index.html +30 -0
  14. package/examples/demo-site/docs/api/webhooks/retries/index.html +30 -0
  15. package/examples/demo-site/docs/index.html +32 -0
  16. package/examples/demo-site/docs/quickstart/index.html +31 -0
  17. package/examples/demo-site/index.html +37 -0
  18. package/examples/demo-site/legal/privacy/index.html +29 -0
  19. package/examples/demo-site/legal/terms/index.html +29 -0
  20. package/examples/demo-site/login/index.html +35 -0
  21. package/examples/demo-site/pricing/index.html +33 -0
  22. package/examples/demo-site/product/index.html +31 -0
  23. package/examples/demo-site/product/integrations/index.html +31 -0
  24. package/examples/demo-site/signup/index.html +35 -0
  25. package/examples/demo-site/status/index.html +15 -0
  26. package/examples/demo-site/thanks/index.html +21 -0
  27. package/package.json +53 -0
  28. package/scripts/install-skill.mjs +145 -0
  29. package/scripts/scan.mjs +399 -0
  30. package/scripts/serve.mjs +207 -0
  31. package/skill/AGENTS.md +61 -0
  32. package/skill/SKILL.md +170 -0
  33. package/skill/references/edit-protocol.md +57 -0
  34. package/viewer/app.js +1373 -0
  35. package/viewer/index.html +202 -0
  36. package/viewer/style.css +616 -0
@@ -0,0 +1,207 @@
1
+ #!/usr/bin/env node
2
+ // sitelines server - serves the viewer, the site (for live previews), and the change queue.
3
+ // Usage: node serve.mjs [--root public] [--out .sitelines] [--port 4370]
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+ import http from 'node:http';
7
+ import { fileURLToPath } from 'node:url';
8
+
9
+ const here = path.dirname(fileURLToPath(import.meta.url));
10
+ const viewerDir = path.join(here, '..', 'viewer');
11
+ const args = parseArgs(process.argv.slice(2));
12
+ const cwd = process.cwd();
13
+ const outDir = path.resolve(cwd, args.out || '.sitelines');
14
+ const flowPath = path.join(outDir, 'flow.json');
15
+ const editsPath = path.join(outDir, 'edits.json');
16
+ const viewsPath = path.join(outDir, 'views.json');
17
+ const port = Number(args.port || 4370);
18
+
19
+ if (!fs.existsSync(flowPath)) {
20
+ console.error(`sitelines: no scan at ${flowPath}`);
21
+ console.error('sitelines: run `sitelines scan` first (or `node scripts/scan.mjs`)');
22
+ process.exit(1);
23
+ }
24
+ const flow0 = JSON.parse(fs.readFileSync(flowPath, 'utf8'));
25
+ const siteRoot = path.resolve(cwd, args.root || flow0.root || 'public');
26
+
27
+ const MIME = {
28
+ '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.mjs': 'text/javascript; charset=utf-8',
29
+ '.css': 'text/css; charset=utf-8', '.json': 'application/json; charset=utf-8', '.svg': 'image/svg+xml',
30
+ '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.webp': 'image/webp', '.avif': 'image/avif',
31
+ '.gif': 'image/gif', '.ico': 'image/x-icon',
32
+ '.woff': 'font/woff', '.woff2': 'font/woff2', '.txt': 'text/plain; charset=utf-8', '.xml': 'application/xml',
33
+ '.webmanifest': 'application/manifest+json', '.map': 'application/json',
34
+ };
35
+
36
+ const server = http.createServer(async (req, res) => {
37
+ const url = new URL(req.url, `http://localhost:${port}`);
38
+ const p = decodeURIComponent(url.pathname);
39
+ try {
40
+ if (p === '/api/flow' && req.method === 'GET') return json(res, read(flowPath));
41
+ if (p === '/api/edits' && req.method === 'GET') return json(res, readEdits());
42
+ if (p === '/api/views' && req.method === 'GET') return json(res, readViews());
43
+ if (p === '/api/views' && req.method === 'POST') {
44
+ const body = await readBody(req);
45
+ const next = withBase(body);
46
+ write(viewsPath, next);
47
+ return json(res, next);
48
+ }
49
+ if (p === '/api/edits' && req.method === 'POST') {
50
+ const body = await readBody(req);
51
+ const edits = readEdits();
52
+ const edit = { id: `e${Date.now().toString(36)}${Math.floor(performance.now() * 1000 % 999)}`, at: new Date().toISOString(), status: 'pending', ...body };
53
+ edits.push(edit);
54
+ write(editsPath, edits);
55
+ log(`queued ${edit.op}: ${edit.summary || ''}`);
56
+ return json(res, edit);
57
+ }
58
+ if (p === '/api/edits' && req.method === 'DELETE') {
59
+ const id = url.searchParams.get('id');
60
+ const edits = readEdits().filter((e) => (id ? e.id !== id : false));
61
+ write(editsPath, edits);
62
+ return json(res, { ok: true, remaining: edits.length });
63
+ }
64
+ if (p === '/api/layout' && req.method === 'POST') {
65
+ const body = await readBody(req);
66
+ const flow = read(flowPath);
67
+ flow.layout = { ...flow.layout, ...(body.layout || {}) };
68
+ if (body.notes) flow.notes = { ...flow.notes, ...body.notes };
69
+ write(flowPath, flow);
70
+ return json(res, { ok: true });
71
+ }
72
+ if (p === '/api/rescan' && req.method === 'POST') {
73
+ const { spawnSync } = await import('node:child_process');
74
+ const r = spawnSync(process.execPath, [path.join(here, 'scan.mjs'), '--root', siteRoot, '--out', outDir], { cwd, encoding: 'utf8' });
75
+ return json(res, { ok: r.status === 0, out: (r.stdout || '') + (r.stderr || '') });
76
+ }
77
+ if (p.startsWith('/site/') || p === '/site') {
78
+ const sub = p.replace(/^\/site/, '') || '/';
79
+ // a previewed page must never install a service worker: it would cache the
80
+ // preview responses and then serve them back for every later preview.
81
+ if (/\/(sw|service-worker)\.js$/i.test(sub)) {
82
+ res.writeHead(200, { 'content-type': 'text/javascript; charset=utf-8', 'cache-control': 'no-store' });
83
+ return res.end("self.addEventListener('install',()=>self.skipWaiting());try{self.registration.unregister()}catch(e){}\n");
84
+ }
85
+ return serveStatic(res, siteRoot, sub, true, req);
86
+ }
87
+ return serveStatic(res, viewerDir, p === '/' ? '/index.html' : p, false, req);
88
+ } catch (e) {
89
+ res.writeHead(500, { 'content-type': 'text/plain' });
90
+ res.end(String(e && e.stack || e));
91
+ }
92
+ });
93
+
94
+ server.on('error', (e) => {
95
+ if (e.code === 'EADDRINUSE') {
96
+ console.error(`sitelines: port ${port} is already in use - try \`sitelines serve --port ${port + 1}\``);
97
+ process.exit(1);
98
+ }
99
+ throw e;
100
+ });
101
+
102
+ // Loopback only. This server hands out every file under the scan root and has
103
+ // endpoints that write to disk and spawn a rescan, so it must not be reachable
104
+ // from the network just because you are on shared wifi. --host is opt-in.
105
+ const host = args.host === true ? '0.0.0.0' : (args.host || '127.0.0.1');
106
+
107
+ server.listen(port, host, () => {
108
+ console.log(`sitelines http://localhost:${port}`);
109
+ console.log(` previews from ${posix(path.relative(cwd, siteRoot)) || '.'} | changes -> ${posix(path.relative(cwd, editsPath))}`);
110
+ if (host !== '127.0.0.1') console.log(` WARNING: listening on ${host}, so anyone on this network can read your source`);
111
+ });
112
+
113
+ const SW_GUARD = `<script>(function(){try{var n=navigator;if(!n.serviceWorker)return;
114
+ n.serviceWorker.getRegistrations&&n.serviceWorker.getRegistrations().then(function(rs){rs.forEach(function(r){r.unregister()})}).catch(function(){});
115
+ Object.defineProperty(n,'serviceWorker',{configurable:true,get:function(){return{register:function(){return Promise.resolve({unregister:function(){return Promise.resolve(true)},addEventListener:function(){}})},getRegistration:function(){return Promise.resolve(undefined)},getRegistrations:function(){return Promise.resolve([])},ready:new Promise(function(){}),controller:null,addEventListener:function(){}}}});}catch(e){}})();</script>`;
116
+
117
+ // shown inside a preview frame when the route has no file on disk
118
+ const MISSING_PAGE = `<!doctype html><meta charset="utf-8"><title>Not on disk</title>
119
+ <style>
120
+ :root{color-scheme:light dark}
121
+ body{margin:0;height:100vh;display:grid;place-content:center;gap:.4rem;text-align:center;
122
+ font:13px/1.6 ui-sans-serif,-apple-system,"Segoe UI",Roboto,sans-serif;
123
+ background:#f6f7f9;color:#454c59}
124
+ b{color:#10131a;font-size:14px}
125
+ code{font-family:ui-monospace,"Cascadia Mono",Menlo,Consolas,monospace}
126
+ @media (prefers-color-scheme:dark){
127
+ body{background:#101317;color:#9aa1ad} b{color:#eceef2}
128
+ }
129
+ </style>
130
+ <b>No file on disk</b>
131
+ <span>This route is a dynamic route or a dead link.</span>`;
132
+
133
+ // `resolved.startsWith(base)` is not a containment check: with base /srv/public a
134
+ // request resolving to /srv/public-private/secret passes it. Compare the relative
135
+ // path instead, which is empty or a plain descendant only when truly inside.
136
+ function inside(base, file) {
137
+ const rel = path.relative(path.resolve(base), path.resolve(file));
138
+ return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
139
+ }
140
+
141
+ function serveStatic(res, base, p, isSite, req) {
142
+ let file = path.join(base, p);
143
+ if (!inside(base, file)) { res.writeHead(403); return res.end('forbidden'); }
144
+ if (fs.existsSync(file) && fs.statSync(file).isDirectory()) file = path.join(file, 'index.html');
145
+ if (!fs.existsSync(file)) {
146
+ const alt = file.replace(/\/$/, '') + '.html';
147
+ if (fs.existsSync(alt)) file = alt;
148
+ else { res.writeHead(404, { 'content-type': 'text/html; charset=utf-8' }); return res.end(MISSING_PAGE); }
149
+ }
150
+ // preview assets are hit once per page card and again every time the map reloads,
151
+ // so let the browser keep them: revalidate cheaply against mtime+size
152
+ const st = fs.statSync(file);
153
+ const etag = `W/"${st.size.toString(36)}-${st.mtimeMs.toString(36)}${isSite ? '-s' : ''}"`;
154
+ if (req && req.headers['if-none-match'] === etag) {
155
+ res.writeHead(304, { etag, 'cache-control': 'public, max-age=30, must-revalidate' });
156
+ return res.end();
157
+ }
158
+ let body = fs.readFileSync(file);
159
+ const type = MIME[path.extname(file).toLowerCase()] || 'application/octet-stream';
160
+ if (isSite && type.startsWith('text/html')) {
161
+ const html = body.toString('utf8');
162
+ body = Buffer.from(/<head[^>]*>/i.test(html) ? html.replace(/<head[^>]*>/i, (m) => m + SW_GUARD) : SW_GUARD + html, 'utf8');
163
+ }
164
+ res.writeHead(200, { 'content-type': type, etag, 'cache-control': 'public, max-age=30, must-revalidate' });
165
+ res.end(body);
166
+ }
167
+
168
+ function readEdits() { return fs.existsSync(editsPath) ? JSON.parse(fs.readFileSync(editsPath, 'utf8')) : []; }
169
+
170
+ // Views are user-editable include/exclude sets. Exactly one is seeded: the base
171
+ // view, which always shows every page and never carries rules. Users add their
172
+ // own from the viewer, and agents add them by appending to this file.
173
+ function readViews() {
174
+ if (fs.existsSync(viewsPath)) return withBase(read(viewsPath));
175
+ const v = withBase({ active: 'all', views: [] });
176
+ write(viewsPath, v);
177
+ return v;
178
+ }
179
+
180
+ // the base view is a guarantee, not a default: restore it if a hand-edit drops it
181
+ function withBase(cfg) {
182
+ const views = Array.isArray(cfg.views) ? cfg.views.filter((v) => v && v.id) : [];
183
+ const i = views.findIndex((v) => v.base || v.id === 'all');
184
+ const base = { ...(i > -1 ? views[i] : {}), id: 'all', label: 'everything', base: true, include: [], exclude: [] };
185
+ if (i > -1) views.splice(i, 1);
186
+ const out = { active: cfg.active || 'all', views: [base, ...views] };
187
+ if (!out.views.some((v) => v.id === out.active)) out.active = 'all';
188
+ return out;
189
+ }
190
+ function read(f) { return JSON.parse(fs.readFileSync(f, 'utf8')); }
191
+ function write(f, v) { fs.mkdirSync(path.dirname(f), { recursive: true }); fs.writeFileSync(f, JSON.stringify(v, null, 2)); }
192
+ function json(res, v) { res.writeHead(200, { 'content-type': 'application/json; charset=utf-8' }); res.end(JSON.stringify(v)); }
193
+ function readBody(req) {
194
+ return new Promise((resolve, reject) => {
195
+ let b = '';
196
+ req.on('data', (c) => { b += c; if (b.length > 1e6) req.destroy(); });
197
+ req.on('end', () => { try { resolve(b ? JSON.parse(b) : {}); } catch (e) { reject(e); } });
198
+ req.on('error', reject);
199
+ });
200
+ }
201
+ function log(m) { console.log(`sitelines: ${m}`); }
202
+ function posix(p) { return p.split(path.sep).join('/'); }
203
+ function parseArgs(a) {
204
+ const o = {};
205
+ for (let i = 0; i < a.length; i++) if (a[i].startsWith('--')) { const k = a[i].slice(2); o[k] = a[i + 1] && !a[i + 1].startsWith('--') ? a[++i] : true; }
206
+ return o;
207
+ }
@@ -0,0 +1,61 @@
1
+ <!-- sitelines:begin -->
2
+ ## sitelines: the site navigation map
3
+
4
+ This project uses [sitelines](https://github.com/Jkaos725/sitelines) to map its navigation. Every page and
5
+ every link, button, redirect, and form between them is scanned into `.sitelines/flow.json`, and changes the
6
+ user makes in the browser map are queued into `.sitelines/edits.json` for you to implement.
7
+
8
+ ### Show the map
9
+
10
+ ```bash
11
+ npx sitelines scan --root public # -> .sitelines/flow.json
12
+ npx sitelines serve --root public # -> http://localhost:4370
13
+ ```
14
+
15
+ Run `serve` in the background and give the user the URL. Never block on it. `--root` is the directory
16
+ holding the pages; it is auto-detected if omitted. The viewer never writes to the site.
17
+
18
+ ### Answer questions from flow.json, not from grep
19
+
20
+ `.sitelines/flow.json` already holds every navigation edge with its trigger text, `file`, and `line`. Read
21
+ it to answer "where does this button go", "what links to this page", or "what is unreachable" instead of
22
+ searching the codebase by hand.
23
+
24
+ `flow.json.issues` carries, in priority order: `deadLinks` (a control that goes nowhere, always a real
25
+ bug), `orphans` and `unreachable` (no inbound link, or not reachable from the entry by clicking), `deep`
26
+ (more than 3 clicks from the entry), `deadEnds` (no exits), and `hubs` (more than 12 exits). Report these
27
+ with `file:line` and let the user choose before changing anything.
28
+
29
+ ### Apply the user's queued changes
30
+
31
+ When the user says "apply my sitelines changes" or similar, read `.sitelines/edits.json` and implement
32
+ every entry whose `status` is `pending`:
33
+
34
+ | op | what to do in code |
35
+ | --- | --- |
36
+ | `add-link` | add the anchor or button on `from` (in the region named by `where`, matching sibling markup and the project's conventions) navigating to `to`, text = `label`; `via: redirect` means a JS `location.href`, `via: form` means a form `action` |
37
+ | `remove-link` | delete the control at `file:line`, and any handler left dangling |
38
+ | `relabel` | change the visible text `oldLabel` to `label` at `file:line`; keep `aria-label` and `title` in sync |
39
+ | `retarget` | change the destination `to` to `newTo` at `file:line` |
40
+ | `add-page` | create the page at `route` following the repo's existing page scaffold (copy the closest sibling page: same head, nav, footer, asset versioning) |
41
+ | `delete-page` | delete the page file and every link pointing at it (query the flow for edges whose `to` equals the route) |
42
+
43
+ Line numbers are from the last scan, so **match on the label text first** and treat the number as a hint.
44
+
45
+ After applying: re-run `sitelines scan`, confirm the intended links exist and no new dead link appeared,
46
+ then set `"status": "applied"` on each entry you implemented. Keep them as history; do not delete them.
47
+ Report what changed as `file:line`.
48
+
49
+ Obey this repo's own conventions when writing: read `CLAUDE.md`, `AGENTS.md`, or any project docs for
50
+ cache-busting query strings, nav partials, route registration, and tests that must run.
51
+
52
+ ### State
53
+
54
+ ```
55
+ .sitelines/flow.json the scan, plus the user's card positions and notes
56
+ .sitelines/views.json saved views; "everything" is the base view and always shows every page
57
+ .sitelines/edits.json the change queue, including applied history
58
+ ```
59
+
60
+ `layout` and `notes` in `flow.json` are user data. The scanner preserves them; never hand-edit them away.
61
+ <!-- sitelines:end -->
package/skill/SKILL.md ADDED
@@ -0,0 +1,170 @@
1
+ ---
2
+ name: sitelines
3
+ description: Map, audit, and edit a website's navigation. Scans the repo for pages and every link, button, redirect, and form that moves between them, then serves an interactive map where each card is a page with a live preview and each wire is a navigation. Flags dead links, orphans, dead ends, and pages buried too deep. Changes made in the browser are queued and implemented in the code by the agent. Use when the user says "show the site flow", "map the app", "user flow", "site map", "where does this button go", "add a page to the flow", "find dead links", "optimize navigation", or invokes /sitelines.
4
+ ---
5
+
6
+ # sitelines - a site's navigation as a map you can edit
7
+
8
+ Three verbs: **show** (scan and serve the map), **change** (queue navigation edits in the browser),
9
+ **improve** (act on the flagged issues). The map is the source of truth for what the code does; the change
10
+ queue is the source of truth for what the user wants different.
11
+
12
+ ## Show the map
13
+
14
+ ```bash
15
+ node <skill-dir>/scripts/scan.mjs --root public # -> .sitelines/flow.json
16
+ node <skill-dir>/scripts/serve.mjs --root public # -> http://localhost:4370
17
+ ```
18
+
19
+ Run `serve.mjs` with `run_in_background: true`, then give the user the URL. Never block on it.
20
+
21
+ - `--root` = the directory holding the site's pages. Auto-detected if omitted (`public`, `site`, `www`,
22
+ `static`, `src/pages`, `src/routes`, `src/app`, `app/pages`, `pages`, `app`, `docs`).
23
+ - `--out` = state directory, default `.sitelines/` in the repo. Suggest adding it to `.gitignore` unless
24
+ the user wants to share the map's layout and notes with their team.
25
+ - `--port` = default 4370.
26
+ - Node 18+, zero dependencies, no build step.
27
+
28
+ If the user installed the npm package instead, `sitelines scan` and `sitelines serve` do the same thing.
29
+
30
+ If they only want to see what the tool does, `sitelines demo` copies the bundled 20-page example site into
31
+ `./sitelines-demo` and maps that. The example carries deliberate faults (two dead links, three orphans, a
32
+ dead end, a page four clicks deep), so every part of the map has something to show.
33
+
34
+ Previews are served from disk by `serve.mjs` at `/site<route>`, so no dev server is needed. Two deliberate
35
+ defaults:
36
+
37
+ - **Page JavaScript is off** in previews (iframes get an empty `sandbox`). Real pages that poll, retry a
38
+ dead API, or animate will otherwise peg the renderer forty iframes over. Markup and CSS still render. The
39
+ **Run page JavaScript** toggle under Layers turns it back on.
40
+ - **Service workers are neutralised** for anything under `/site/`, because the site's own worker would
41
+ otherwise cache the preview responses and serve them back for every later preview.
42
+
43
+ Thumbnails mount two at a time and switch off past 60 pages. For pages that need real data (auth, an API),
44
+ run the user's own dev server and use the page's **Open** button against it instead.
45
+
46
+ ## What the scanner extracts
47
+
48
+ Cards are pages (`*.html`, or framework page files if there is no HTML). Wires are navigations, each
49
+ carrying the **trigger text** (the actual button or link label), the file, and the line:
50
+
51
+ | source | how it is found |
52
+ | --- | --- |
53
+ | `<a href>` | label = link text |
54
+ | `<button>`/`<div>` with `onclick="location.href=…"`, `data-href`, `data-nav`, `formaction` | label = element text |
55
+ | `<form action>` | label = `POST form` |
56
+ | `<meta http-equiv=refresh>` | label = `meta refresh` |
57
+ | JS: `location.href/assign/replace`, `window.open`, `router.push/replace`, `navigate(…)`, `href="…"` in templates | trigger inferred from the nearest `getElementById`/`querySelector` above the call |
58
+
59
+ Targets that resolve to no file become **dead-link** cards. `/api/*` and `http(s)://` become side cards,
60
+ hidden by default behind the API and external toggles.
61
+
62
+ **Site-wide links fold away by default.** A link appearing from more than 4 pages is tagged `repeated` by
63
+ the scanner (a header, a footer, a nav partial), as is any link wired by a script more than 4 pages include
64
+ (`js-shared`). Both hide behind **Layers → Site-wide nav and footer links**, and the sidebar reports the
65
+ count it folded, for example `Links drawn 27 of 174`. This is a drawing decision only: depth, orphans,
66
+ dead ends, and every other judgement are computed from the complete link set, so a page reachable only
67
+ through the footer is never reported as an orphan.
68
+
69
+ Re-running the scan preserves card positions and notes.
70
+
71
+ ## Improve
72
+
73
+ `flow.json.issues` and the **Issues** list carry, in priority order:
74
+
75
+ 1. **Dead links** - a control goes nowhere. Always a real bug: fix the href or create the page.
76
+ 2. **Orphan / unreachable** - no inbound link, or not reachable from the entry by clicking. Link it or delete it.
77
+ 3. **Deep** - more than 3 clicks from the entry. Propose a shortcut from a hub.
78
+ 4. **Dead ends** - no exits. Add a next step or a way back.
79
+ 5. **Hubs** - more than 12 exits on one page. Propose grouping.
80
+
81
+ Report these as findings with `file:line`, and only change code once the user picks.
82
+
83
+ ## Apply the user's changes
84
+
85
+ The browser queues changes to `.sitelines/edits.json`; the viewer never writes to the site. When the user
86
+ says "apply my sitelines changes" (or similar), read that file and implement each `pending` entry in the
87
+ real code:
88
+
89
+ ```bash
90
+ cat .sitelines/edits.json
91
+ ```
92
+
93
+ | op | what to do in code |
94
+ | --- | --- |
95
+ | `add-link` | add the anchor or button on `from` (in the region named by `where`, matching sibling markup and the project's design system) navigating to `to`, text = `label`; `via: redirect` means a JS `location.href` in that page's script, `via: form` means a form `action` |
96
+ | `remove-link` | delete the control at `file:line`, and any handler left dangling |
97
+ | `relabel` | change the visible text `oldLabel` to `label` at `file:line`; keep `aria-label` and `title` in sync |
98
+ | `retarget` | change the destination `to` to `newTo` at `file:line` |
99
+ | `add-page` | create the page at `route` following the repo's existing page scaffold (copy the closest sibling page: same head, nav, footer, asset versioning) |
100
+ | `delete-page` | delete the page file and every link pointing at it (search the flow for inbound edges first) |
101
+
102
+ `references/edit-protocol.md` has the exact JSON shape of every op.
103
+
104
+ After applying: re-run `scan.mjs`, confirm the intended links now exist and no new dead links appeared, then
105
+ mark each applied entry by setting `"status": "applied"` in `.sitelines/edits.json`. Keep them as history;
106
+ do not silently drop them. Report what changed as `file:line`.
107
+
108
+ Obey the repo's own conventions when writing. Read `CLAUDE.md`, `AGENTS.md`, or any project skill for
109
+ cache-busting query strings, nav partials, route registration, or tests that must run.
110
+
111
+ ## Views, sections, and state
112
+
113
+ **One view is seeded**: `everything`, the base view. It always shows every page and never carries a rule.
114
+ Filtering while it is active creates a new view instead of narrowing the one complete picture of the site.
115
+
116
+ To add a view, append to `.sitelines/views.json` and its tab appears. Do this rather than touching the
117
+ viewer code when the user asks for a different split:
118
+
119
+ ```json
120
+ {
121
+ "active": "all",
122
+ "views": [
123
+ { "id": "all", "label": "everything", "base": true, "include": [], "exclude": [] },
124
+ { "id": "docs", "label": "docs only", "include": ["/docs/**"], "exclude": [] }
125
+ ]
126
+ }
127
+ ```
128
+
129
+ Each view is `{id, label, include[], exclude[], collapsed[], entry?}`. Patterns: `/admin/**` (subtree),
130
+ `/admin/` (exact), `/admin/*` (one level), `*report*` (substring). A non-empty `include` means *only* those.
131
+ The base view is a guarantee: the server restores it if a hand-edit drops it, and strips any rule added to
132
+ it. Users also add views from the **+** on the tab bar, the rule box in the sidebar, and **Hide from this
133
+ view** in the inspector.
134
+
135
+ - Hiding a page removes it **and every wire touching it** from that view; the sidebar lists what is hidden.
136
+ - **Sections**: pages sharing a top-level directory get a labelled backdrop. Click the backdrop label (or
137
+ the card's **Expand**) to collapse the whole section into one card. Internal links fold away and the
138
+ collapsed section keeps **one wire per neighbour**, labelled `N links`, with every underlying control in
139
+ its hover tooltip.
140
+ - **Ports**: dots down each card's left (entrances) and right (exits) edge. Hover one, or any wire, for the
141
+ button text, both routes, the trigger kind, and `file:line`; the wire lights up. Click a port to jump to
142
+ the page at the other end.
143
+ - The sidebar's **Entry points** lists where a visit can start; **Leaves the site** lists where the site
144
+ sends people (API, external, dead).
145
+
146
+ ## Viewer controls (tell the user these)
147
+
148
+ - Drag the background to pan, wheel to zoom, `f` to fit, `/` to search, `Escape` to cancel, drag a card to
149
+ move it (the position is saved).
150
+ - **Layers**: site-wide nav and footer links, API endpoints, external links, live previews, run page JavaScript.
151
+ - Keys `1` `2` `3` switch view tabs; **+** on the tab bar makes a new view.
152
+ - Click a card to inspect it: full-size live preview at mobile, tablet, or desktop width, its exits, its
153
+ entrances, and notes.
154
+ - **+ Link** (or `e`), then click a destination, proposes a new control.
155
+ - Edit an exit's text, or change its destination in the dropdown, to queue a rename or retarget. The ✕
156
+ queues a removal.
157
+ - **+ Page** proposes a new page, optionally linked from the selected one.
158
+ - **Changes** is the queue; **Copy prompt** gives the user the exact sentence to paste back.
159
+ - The theme follows the OS and can be overridden with the sun/moon button.
160
+
161
+ ## Files
162
+
163
+ ```
164
+ DESIGN.md the viewer's visual system - read before touching viewer/style.css
165
+ scripts/scan.mjs static analysis -> .sitelines/flow.json
166
+ scripts/serve.mjs viewer + site preview server + change queue API (node:http, no deps)
167
+ scripts/install-skill.mjs copies this skill into a Claude Code skills directory
168
+ viewer/ the map UI (index.html, app.js, style.css)
169
+ references/edit-protocol.md exact JSON shape of every queued change
170
+ ```
@@ -0,0 +1,57 @@
1
+ # sitelines change protocol
2
+
3
+ `.sitelines/edits.json` is a JSON array, append-only from the viewer. Every entry:
4
+
5
+ ```json
6
+ { "id": "e1m2k3", "at": "2026-07-28T18:03:11.000Z", "status": "pending", "op": "...", "summary": "human sentence" }
7
+ ```
8
+
9
+ `status`: `pending`, then the agent implements it and sets `applied`, keeping the record. Use `rejected`
10
+ with a `reason` if the user decides against it after discussion.
11
+
12
+ ## Ops
13
+
14
+ ### add-link
15
+ ```json
16
+ { "op":"add-link", "from":"/home/", "to":"/pricing/", "label":"See pricing", "via":"link|button|redirect|form", "where":"hero CTA row" }
17
+ ```
18
+ Add a real control on the `from` page. `where` is a free-text hint from the user. If it is empty, place the
19
+ control next to the closest existing sibling control and say where you put it.
20
+
21
+ ### remove-link
22
+ ```json
23
+ { "op":"remove-link", "from":"/home/", "to":"/beta/", "label":"Try beta", "file":"home/index.html", "line":142 }
24
+ ```
25
+ `file` is relative to the scan root. The line comes from the last scan, so verify by matching the label
26
+ rather than trusting the number.
27
+
28
+ ### relabel
29
+ ```json
30
+ { "op":"relabel", "from":"/home/", "to":"/join/", "oldLabel":"Sign up", "label":"Get started", "file":"home/index.html", "line":88 }
31
+ ```
32
+
33
+ ### retarget
34
+ ```json
35
+ { "op":"retarget", "from":"/home/", "to":"/login/", "newTo":"/join/", "label":"Get started", "file":"home/index.html", "line":88 }
36
+ ```
37
+
38
+ ### add-page
39
+ ```json
40
+ { "op":"add-page", "route":"/pricing/", "label":"Pricing" }
41
+ ```
42
+ Scaffold from the closest sibling page in the same section, so head tags, nav, footer, asset versioning,
43
+ and auth guards match. Usually paired with an `add-link` entry.
44
+
45
+ ### delete-page
46
+ ```json
47
+ { "op":"delete-page", "route":"/beta/", "file":"beta/index.html" }
48
+ ```
49
+ Also remove every inbound link (query the flow for edges whose `to` equals the route).
50
+
51
+ ## Rules
52
+
53
+ 1. The viewer never writes to the site. Every source change comes from the agent, after the user asks.
54
+ 2. Re-run `scan.mjs` after applying. A fix that creates a new dead link is not done.
55
+ 3. `flow.json` fields `layout` (card positions) and `notes` (per-route text) are user data. The scanner
56
+ preserves them; never hand-edit them away.
57
+ 4. Line numbers and `file` paths refer to the state at scan time. Match on the label text first.