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.
- package/DESIGN.md +129 -0
- package/LICENSE +21 -0
- package/README.md +336 -0
- package/bin/sitelines.mjs +155 -0
- package/examples/demo-site/about/index.html +30 -0
- package/examples/demo-site/assets/auth.js +3 -0
- package/examples/demo-site/assets/site.css +27 -0
- package/examples/demo-site/blog/index.html +31 -0
- package/examples/demo-site/blog/introducing-meridian/index.html +30 -0
- package/examples/demo-site/changelog/index.html +30 -0
- package/examples/demo-site/contact/index.html +33 -0
- package/examples/demo-site/docs/api/index.html +31 -0
- package/examples/demo-site/docs/api/webhooks/index.html +30 -0
- package/examples/demo-site/docs/api/webhooks/retries/index.html +30 -0
- package/examples/demo-site/docs/index.html +32 -0
- package/examples/demo-site/docs/quickstart/index.html +31 -0
- package/examples/demo-site/index.html +37 -0
- package/examples/demo-site/legal/privacy/index.html +29 -0
- package/examples/demo-site/legal/terms/index.html +29 -0
- package/examples/demo-site/login/index.html +35 -0
- package/examples/demo-site/pricing/index.html +33 -0
- package/examples/demo-site/product/index.html +31 -0
- package/examples/demo-site/product/integrations/index.html +31 -0
- package/examples/demo-site/signup/index.html +35 -0
- package/examples/demo-site/status/index.html +15 -0
- package/examples/demo-site/thanks/index.html +21 -0
- package/package.json +53 -0
- package/scripts/install-skill.mjs +145 -0
- package/scripts/scan.mjs +399 -0
- package/scripts/serve.mjs +207 -0
- package/skill/AGENTS.md +61 -0
- package/skill/SKILL.md +170 -0
- package/skill/references/edit-protocol.md +57 -0
- package/viewer/app.js +1373 -0
- package/viewer/index.html +202 -0
- 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
|
+
}
|
package/skill/AGENTS.md
ADDED
|
@@ -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.
|