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
package/package.json
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "sitelines",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Map, audit, and edit a website's navigation. Scans every page and every link between them, serves an interactive map with live page previews, flags dead links and orphans, and queues your edits for an agent to implement.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "Jkaos725",
|
|
8
|
+
"homepage": "https://github.com/Jkaos725/sitelines#readme",
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/Jkaos725/sitelines.git"
|
|
12
|
+
},
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/Jkaos725/sitelines/issues"
|
|
15
|
+
},
|
|
16
|
+
"bin": {
|
|
17
|
+
"sitelines": "bin/sitelines.mjs"
|
|
18
|
+
},
|
|
19
|
+
"engines": {
|
|
20
|
+
"node": ">=18"
|
|
21
|
+
},
|
|
22
|
+
"files": [
|
|
23
|
+
"bin",
|
|
24
|
+
"scripts",
|
|
25
|
+
"viewer",
|
|
26
|
+
"skill",
|
|
27
|
+
"examples",
|
|
28
|
+
"DESIGN.md",
|
|
29
|
+
"README.md",
|
|
30
|
+
"LICENSE"
|
|
31
|
+
],
|
|
32
|
+
"scripts": {
|
|
33
|
+
"scan": "node scripts/scan.mjs",
|
|
34
|
+
"serve": "node scripts/serve.mjs",
|
|
35
|
+
"start": "node bin/sitelines.mjs open",
|
|
36
|
+
"demo": "node bin/sitelines.mjs demo",
|
|
37
|
+
"skill:install": "node scripts/install-skill.mjs"
|
|
38
|
+
},
|
|
39
|
+
"keywords": [
|
|
40
|
+
"sitemap",
|
|
41
|
+
"site-map",
|
|
42
|
+
"navigation",
|
|
43
|
+
"information-architecture",
|
|
44
|
+
"dead-links",
|
|
45
|
+
"link-checker",
|
|
46
|
+
"user-flow",
|
|
47
|
+
"static-analysis",
|
|
48
|
+
"claude-code",
|
|
49
|
+
"claude-skill"
|
|
50
|
+
],
|
|
51
|
+
"dependencies": {},
|
|
52
|
+
"devDependencies": {}
|
|
53
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Installs sitelines as a Claude Code skill by copying the skill payload into a
|
|
3
|
+
// skills directory. Nothing here is sitelines-specific magic: a Claude Code skill
|
|
4
|
+
// is just a folder containing SKILL.md, so this is a copy with a manifest check.
|
|
5
|
+
//
|
|
6
|
+
// Usage: node install-skill.mjs [--global|--project] [--dir <path>] [--force] [--uninstall]
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import os from 'node:os';
|
|
10
|
+
import { fileURLToPath } from 'node:url';
|
|
11
|
+
|
|
12
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
13
|
+
const pkgRoot = path.join(here, '..');
|
|
14
|
+
|
|
15
|
+
// what a working skill folder needs: the manifest, the docs it links to, and the
|
|
16
|
+
// two runtimes SKILL.md tells the agent to run
|
|
17
|
+
const PAYLOAD = [
|
|
18
|
+
['skill/SKILL.md', 'SKILL.md'],
|
|
19
|
+
['skill/references', 'references'],
|
|
20
|
+
['scripts', 'scripts'],
|
|
21
|
+
['viewer', 'viewer'],
|
|
22
|
+
['DESIGN.md', 'DESIGN.md'],
|
|
23
|
+
['LICENSE', 'LICENSE'],
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
export function skillDir(scope, custom) {
|
|
27
|
+
if (custom) return path.resolve(process.cwd(), custom);
|
|
28
|
+
if (scope === 'project') return path.resolve(process.cwd(), '.claude', 'skills', 'sitelines');
|
|
29
|
+
return path.join(os.homedir(), '.claude', 'skills', 'sitelines');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function installSkill({ scope = 'global', dir = null, force = false } = {}) {
|
|
33
|
+
const dest = skillDir(scope, dir);
|
|
34
|
+
const manifest = path.join(pkgRoot, 'skill', 'SKILL.md');
|
|
35
|
+
if (!fs.existsSync(manifest)) {
|
|
36
|
+
throw new Error(`skill payload missing at ${manifest} - is this a complete checkout?`);
|
|
37
|
+
}
|
|
38
|
+
if (fs.existsSync(dest) && !force) {
|
|
39
|
+
const existing = path.join(dest, 'SKILL.md');
|
|
40
|
+
if (fs.existsSync(existing)) {
|
|
41
|
+
console.log(`sitelines: skill already installed at ${dest}`);
|
|
42
|
+
console.log('sitelines: pass --force to overwrite it');
|
|
43
|
+
return { dest, changed: false };
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
fs.mkdirSync(dest, { recursive: true });
|
|
47
|
+
for (const [from, to] of PAYLOAD) {
|
|
48
|
+
const src = path.join(pkgRoot, from);
|
|
49
|
+
if (!fs.existsSync(src)) continue;
|
|
50
|
+
const target = path.join(dest, to);
|
|
51
|
+
fs.rmSync(target, { recursive: true, force: true });
|
|
52
|
+
fs.cpSync(src, target, { recursive: true });
|
|
53
|
+
}
|
|
54
|
+
return { dest, changed: true };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/* ---------- AGENTS.md, for every agent that is not Claude Code ---------- */
|
|
58
|
+
// Codex, opencode, Cursor, Zed and Gemini CLI read AGENTS.md from the project
|
|
59
|
+
// root. Most projects already have one, so this appends a fenced block rather
|
|
60
|
+
// than overwriting, and replaces that block in place on a re-install.
|
|
61
|
+
const BEGIN = '<!-- sitelines:begin -->';
|
|
62
|
+
const END = '<!-- sitelines:end -->';
|
|
63
|
+
|
|
64
|
+
export function installAgents({ dir = null } = {}) {
|
|
65
|
+
const target = path.resolve(process.cwd(), dir || '.', 'AGENTS.md');
|
|
66
|
+
const src = path.join(pkgRoot, 'skill', 'AGENTS.md');
|
|
67
|
+
if (!fs.existsSync(src)) throw new Error(`AGENTS.md template missing at ${src}`);
|
|
68
|
+
const block = fs.readFileSync(src, 'utf8').trim();
|
|
69
|
+
|
|
70
|
+
if (!fs.existsSync(target)) {
|
|
71
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
72
|
+
fs.writeFileSync(target, block + '\n');
|
|
73
|
+
return { target, action: 'created' };
|
|
74
|
+
}
|
|
75
|
+
const cur = fs.readFileSync(target, 'utf8');
|
|
76
|
+
const i = cur.indexOf(BEGIN), j = cur.indexOf(END);
|
|
77
|
+
if (i > -1 && j > i) {
|
|
78
|
+
fs.writeFileSync(target, cur.slice(0, i) + block + cur.slice(j + END.length));
|
|
79
|
+
return { target, action: 'updated' };
|
|
80
|
+
}
|
|
81
|
+
fs.writeFileSync(target, cur.replace(/\s*$/, '') + '\n\n' + block + '\n');
|
|
82
|
+
return { target, action: 'appended' };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function uninstallAgents({ dir = null } = {}) {
|
|
86
|
+
const target = path.resolve(process.cwd(), dir || '.', 'AGENTS.md');
|
|
87
|
+
if (!fs.existsSync(target)) return { target, action: 'absent' };
|
|
88
|
+
const cur = fs.readFileSync(target, 'utf8');
|
|
89
|
+
const i = cur.indexOf(BEGIN), j = cur.indexOf(END);
|
|
90
|
+
if (i < 0 || j < i) return { target, action: 'absent' };
|
|
91
|
+
const next = (cur.slice(0, i) + cur.slice(j + END.length)).replace(/\n{3,}/g, '\n\n').trim();
|
|
92
|
+
if (next) fs.writeFileSync(target, next + '\n');
|
|
93
|
+
else fs.rmSync(target);
|
|
94
|
+
return { target, action: next ? 'removed the block' : 'removed the file' };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function uninstallSkill({ scope = 'global', dir = null } = {}) {
|
|
98
|
+
const dest = skillDir(scope, dir);
|
|
99
|
+
if (!fs.existsSync(dest)) return { dest, changed: false };
|
|
100
|
+
fs.rmSync(dest, { recursive: true, force: true });
|
|
101
|
+
return { dest, changed: true };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Run directly: node scripts/install-skill.mjs [flags]
|
|
105
|
+
// Compare resolved paths rather than string-matching a file:// URL. The naive
|
|
106
|
+
// form breaks on Windows drive letters, and under bun or a pnpm .bin shim
|
|
107
|
+
// argv[1] is not always spelled the way import.meta.url is.
|
|
108
|
+
const runDirectly = (() => {
|
|
109
|
+
try {
|
|
110
|
+
return !!process.argv[1] && path.resolve(process.argv[1]) === path.resolve(fileURLToPath(import.meta.url));
|
|
111
|
+
} catch { return false; }
|
|
112
|
+
})();
|
|
113
|
+
|
|
114
|
+
if (runDirectly) {
|
|
115
|
+
const a = process.argv.slice(2);
|
|
116
|
+
const flag = (n) => a.includes(`--${n}`);
|
|
117
|
+
const val = (n) => { const i = a.indexOf(`--${n}`); return i > -1 ? a[i + 1] : null; };
|
|
118
|
+
const scope = flag('project') ? 'project' : 'global';
|
|
119
|
+
const opts = { scope, dir: val('dir'), force: flag('force') };
|
|
120
|
+
try {
|
|
121
|
+
if (flag('agents')) {
|
|
122
|
+
// every agent that is not Claude Code
|
|
123
|
+
if (flag('uninstall')) {
|
|
124
|
+
const r = uninstallAgents(opts);
|
|
125
|
+
console.log(`sitelines: ${r.action} in ${r.target}`);
|
|
126
|
+
} else {
|
|
127
|
+
const r = installAgents(opts);
|
|
128
|
+
console.log(`sitelines: ${r.action} ${r.target}`);
|
|
129
|
+
console.log('sitelines: Codex, opencode, Cursor, Zed and Gemini CLI read this file automatically');
|
|
130
|
+
}
|
|
131
|
+
} else if (flag('uninstall')) {
|
|
132
|
+
const r = uninstallSkill(opts);
|
|
133
|
+
console.log(r.changed ? `sitelines: removed ${r.dest}` : `sitelines: nothing installed at ${r.dest}`);
|
|
134
|
+
} else {
|
|
135
|
+
const r = installSkill(opts);
|
|
136
|
+
if (r.changed) {
|
|
137
|
+
console.log(`sitelines: skill installed -> ${r.dest}`);
|
|
138
|
+
console.log('sitelines: restart Claude Code, then use /sitelines');
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
} catch (e) {
|
|
142
|
+
console.error(`sitelines: ${e.message}`);
|
|
143
|
+
process.exit(1);
|
|
144
|
+
}
|
|
145
|
+
}
|
package/scripts/scan.mjs
ADDED
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// sitelines scanner - builds .sitelines/flow.json (pages, navigation links, issues)
|
|
3
|
+
// Usage: node scan.mjs [--root public] [--out .sitelines] [--base http://localhost:8788]
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
|
|
7
|
+
const SKIP_DIRS = new Set([
|
|
8
|
+
'node_modules', '.git', '.sitelines', 'dist', 'build', 'out', 'coverage', 'vendor',
|
|
9
|
+
'.next', '.nuxt', '.output', '.svelte-kit', '.astro', '.cache', '.turbo', '.parcel-cache',
|
|
10
|
+
'.vercel', '.netlify', '.wrangler', 'target', 'tmp',
|
|
11
|
+
]);
|
|
12
|
+
const ROOT_CANDIDATES = ['public', 'site', 'www', 'static', 'src/pages', 'src/routes', 'src/app', 'app/pages', 'pages', 'app', 'docs', '.'];
|
|
13
|
+
|
|
14
|
+
const args = parseArgs(process.argv.slice(2));
|
|
15
|
+
const cwd = process.cwd();
|
|
16
|
+
const root = path.resolve(cwd, args.root || detectRoot(cwd));
|
|
17
|
+
const outDir = path.resolve(cwd, args.out || '.sitelines');
|
|
18
|
+
const flowPath = path.join(outDir, 'flow.json');
|
|
19
|
+
const base = args.base || '';
|
|
20
|
+
let ROUTES = null;
|
|
21
|
+
|
|
22
|
+
function main() {
|
|
23
|
+
if (!fs.existsSync(root)) die(`root not found: ${root}`);
|
|
24
|
+
const files = walk(root);
|
|
25
|
+
const htmlFiles = files.filter((f) => /\.html?$/i.test(f));
|
|
26
|
+
const jsFiles = files.filter((f) => /\.(m|c)?jsx?$|\.tsx?$/i.test(f));
|
|
27
|
+
|
|
28
|
+
const pages = htmlFiles.length
|
|
29
|
+
? htmlFiles.map((f) => makeNode(f, 'page'))
|
|
30
|
+
: jsFiles.filter(isFrameworkPage).map((f) => makeNode(f, 'page'));
|
|
31
|
+
|
|
32
|
+
const byRoute = new Map(pages.map((n) => [n.id, n]));
|
|
33
|
+
ROUTES = new Set(pages.map((n) => n.id));
|
|
34
|
+
|
|
35
|
+
// which pages include which scripts
|
|
36
|
+
const includedBy = new Map(); // absFile -> Set(route)
|
|
37
|
+
const edges = [];
|
|
38
|
+
const extra = new Map(); // synthetic nodes (api/external/missing)
|
|
39
|
+
|
|
40
|
+
for (const page of pages) {
|
|
41
|
+
const abs = path.join(root, page.file);
|
|
42
|
+
let text = '';
|
|
43
|
+
try { text = fs.readFileSync(abs, 'utf8'); } catch { continue; }
|
|
44
|
+
page.bytes = Buffer.byteLength(text);
|
|
45
|
+
page.title = titleOf(text) || page.label;
|
|
46
|
+
|
|
47
|
+
for (const raw of scanHtml(text)) push(page.id, raw);
|
|
48
|
+
|
|
49
|
+
// inline <script> bodies
|
|
50
|
+
for (const m of text.matchAll(/<script\b([^>]*)>([\s\S]*?)<\/script>/gi)) {
|
|
51
|
+
const src = attr(m[1], 'src');
|
|
52
|
+
if (src) {
|
|
53
|
+
const target = resolveFile(src, page.id);
|
|
54
|
+
if (target) {
|
|
55
|
+
if (!includedBy.has(target)) includedBy.set(target, new Set());
|
|
56
|
+
includedBy.get(target).add(page.id);
|
|
57
|
+
}
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
const off = m.index + m[0].indexOf(m[2]);
|
|
61
|
+
for (const raw of scanJs(m[2], page.file)) {
|
|
62
|
+
push(page.id, { ...raw, line: lineAt(text, off + raw.offset), kind: 'js-inline' });
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// shared / per-page JS files
|
|
68
|
+
for (const [absFile, routes] of includedBy) {
|
|
69
|
+
let text = '';
|
|
70
|
+
try { text = fs.readFileSync(absFile, 'utf8'); } catch { continue; }
|
|
71
|
+
const rel = posix(path.relative(root, absFile));
|
|
72
|
+
const shared = routes.size > 4;
|
|
73
|
+
for (const raw of scanJs(text, rel)) {
|
|
74
|
+
const line = lineAt(text, raw.offset);
|
|
75
|
+
for (const route of routes) push(route, { ...raw, file: rel, line, kind: shared ? 'js-shared' : 'js' });
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function push(from, raw) {
|
|
80
|
+
const t = resolveTarget(raw.href, from);
|
|
81
|
+
if (!t) return;
|
|
82
|
+
if (t.type !== 'page' && !extra.has(t.id) && !byRoute.has(t.id)) {
|
|
83
|
+
extra.set(t.id, { id: t.id, label: t.label, title: t.label, file: null, group: t.type, type: t.type, bytes: 0 });
|
|
84
|
+
}
|
|
85
|
+
edges.push({
|
|
86
|
+
id: `${from}|${t.id}|${raw.trigger}|${edges.length}`,
|
|
87
|
+
from, to: t.id,
|
|
88
|
+
label: raw.trigger || 'link',
|
|
89
|
+
via: raw.via || 'link',
|
|
90
|
+
kind: raw.kind || 'html',
|
|
91
|
+
file: raw.file || byRoute.get(from)?.file || null,
|
|
92
|
+
line: raw.line || null,
|
|
93
|
+
status: 'code',
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const nodes = [...pages, ...extra.values()];
|
|
98
|
+
dedupeEdges(edges);
|
|
99
|
+
markRepeated(edges);
|
|
100
|
+
const flow = {
|
|
101
|
+
version: 1,
|
|
102
|
+
generatedAt: new Date().toISOString(),
|
|
103
|
+
root: posix(path.relative(cwd, root)) || '.',
|
|
104
|
+
base,
|
|
105
|
+
nodes, edges,
|
|
106
|
+
layout: {},
|
|
107
|
+
notes: {},
|
|
108
|
+
};
|
|
109
|
+
analyze(flow);
|
|
110
|
+
mergePrevious(flow);
|
|
111
|
+
|
|
112
|
+
fs.mkdirSync(outDir, { recursive: true });
|
|
113
|
+
fs.writeFileSync(flowPath, JSON.stringify(flow, null, 2));
|
|
114
|
+
const issues = flow.issues;
|
|
115
|
+
console.log(`sitelines: ${flow.nodes.length} nodes, ${flow.edges.length} links -> ${posix(path.relative(cwd, flowPath))}`);
|
|
116
|
+
console.log(`issues: ${issues.deadLinks.length} dead links, ${issues.orphans.length} orphans, ${issues.deadEnds.length} dead ends, ${issues.deep.length} deep (>3), ${issues.hubs.length} hubs`);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/* ---------- discovery ---------- */
|
|
120
|
+
|
|
121
|
+
function walk(dir, acc = []) {
|
|
122
|
+
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
123
|
+
if (e.name.startsWith('.') && e.name !== '.well-known') continue;
|
|
124
|
+
const p = path.join(dir, e.name);
|
|
125
|
+
if (e.isDirectory()) { if (!SKIP_DIRS.has(e.name)) walk(p, acc); }
|
|
126
|
+
else acc.push(p);
|
|
127
|
+
}
|
|
128
|
+
return acc;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function detectRoot(base_) {
|
|
132
|
+
for (const c of ROOT_CANDIDATES) {
|
|
133
|
+
const p = path.join(base_, c);
|
|
134
|
+
if (fs.existsSync(p) && fs.statSync(p).isDirectory()) return c;
|
|
135
|
+
}
|
|
136
|
+
return '.';
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function isFrameworkPage(f) {
|
|
140
|
+
const r = posix(f);
|
|
141
|
+
return /\/(pages|routes|app)\//.test(r) && !/\.(test|spec|d)\./.test(r) && !/\/(components|lib|utils)\//.test(r);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function makeNode(abs, type) {
|
|
145
|
+
const rel = posix(path.relative(root, abs));
|
|
146
|
+
const id = routeOf(rel);
|
|
147
|
+
const seg = id.split('/').filter(Boolean);
|
|
148
|
+
return {
|
|
149
|
+
id,
|
|
150
|
+
label: seg.length ? seg[seg.length - 1] : 'home',
|
|
151
|
+
title: '',
|
|
152
|
+
file: rel,
|
|
153
|
+
group: seg[0] || 'root',
|
|
154
|
+
type,
|
|
155
|
+
bytes: 0,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function routeOf(rel) {
|
|
160
|
+
const b = path.posix.basename(rel);
|
|
161
|
+
const d = path.posix.dirname(rel);
|
|
162
|
+
if (/^index\.html?$/i.test(b) || /^(index|page|\+page)\.(jsx?|tsx?|svelte|vue)$/i.test(b)) {
|
|
163
|
+
return d === '.' ? '/' : `/${d}/`;
|
|
164
|
+
}
|
|
165
|
+
return `/${rel}`;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/* ---------- HTML link extraction ---------- */
|
|
169
|
+
|
|
170
|
+
function scanHtml(text) {
|
|
171
|
+
const out = [];
|
|
172
|
+
for (const m of text.matchAll(/<a\b([^>]*)>([\s\S]*?)<\/a>/gi)) {
|
|
173
|
+
const href = attr(m[1], 'href');
|
|
174
|
+
if (!href) continue;
|
|
175
|
+
out.push({ href, trigger: label(m[2]) || href, via: 'link', kind: 'html', line: lineAt(text, m.index) });
|
|
176
|
+
}
|
|
177
|
+
for (const m of text.matchAll(/<(button|div|li|span|img)\b([^>]*)>([\s\S]*?)<\/\1>/gi)) {
|
|
178
|
+
const a = m[2];
|
|
179
|
+
const href = attr(a, 'data-href') || attr(a, 'data-nav') || attr(a, 'formaction') || hrefFromOnclick(attr(a, 'onclick'));
|
|
180
|
+
if (!href) continue;
|
|
181
|
+
out.push({ href, trigger: label(m[3]) || attr(a, 'aria-label') || attr(a, 'id') || 'button', via: 'button', kind: 'html', line: lineAt(text, m.index) });
|
|
182
|
+
}
|
|
183
|
+
for (const m of text.matchAll(/<form\b([^>]*)>/gi)) {
|
|
184
|
+
const action = attr(m[1], 'action');
|
|
185
|
+
if (!action) continue;
|
|
186
|
+
out.push({ href: action, trigger: `${(attr(m[1], 'method') || 'get').toUpperCase()} form`, via: 'form', kind: 'html', line: lineAt(text, m.index) });
|
|
187
|
+
}
|
|
188
|
+
for (const m of text.matchAll(/<meta\b[^>]*http-equiv=["']refresh["'][^>]*content=["'][^;]*;\s*url=([^"']+)["']/gi)) {
|
|
189
|
+
out.push({ href: m[1], trigger: 'meta refresh', via: 'redirect', kind: 'html', line: lineAt(text, m.index) });
|
|
190
|
+
}
|
|
191
|
+
return out;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function hrefFromOnclick(v) {
|
|
195
|
+
if (!v) return null;
|
|
196
|
+
const m = v.match(/location\.(?:href|assign|replace)\s*(?:=|\()\s*['"]([^'"]+)/);
|
|
197
|
+
return m ? m[1] : null;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/* ---------- JS link extraction ---------- */
|
|
201
|
+
|
|
202
|
+
const JS_PATTERNS = [
|
|
203
|
+
[/location\s*\.\s*(?:href|assign|replace)\s*(?:=|\(\s*)\s*(['"`])([^'"`\n]{1,120})\1/g, 'redirect'],
|
|
204
|
+
[/window\.open\(\s*(['"`])([^'"`\n]{1,120})\1/g, 'new tab'],
|
|
205
|
+
[/(?:router\.(?:push|replace)|navigate|goto|redirect)\(\s*(['"`])([^'"`\n]{1,120})\1/g, 'route'],
|
|
206
|
+
[/href\s*=\s*\\?["'`]([^"'`\\\n$]{1,120})["'`]/g, 'link'],
|
|
207
|
+
];
|
|
208
|
+
|
|
209
|
+
function scanJs(text, file) {
|
|
210
|
+
const out = [];
|
|
211
|
+
for (const [re, via] of JS_PATTERNS) {
|
|
212
|
+
const capture = re.source.startsWith('href') ? 1 : 2;
|
|
213
|
+
for (const m of text.matchAll(re)) {
|
|
214
|
+
const href = m[capture];
|
|
215
|
+
if (!href || href.includes('${')) continue;
|
|
216
|
+
if (!/^([./#]|https?:|\w[\w-]*\/)/.test(href) && !href.startsWith('/')) continue;
|
|
217
|
+
out.push({ href, trigger: triggerNear(text, m.index) || via, via, offset: m.index, file });
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
return out;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// look backwards for the control that fires this navigation
|
|
224
|
+
function triggerNear(text, idx) {
|
|
225
|
+
const win = text.slice(Math.max(0, idx - 600), idx);
|
|
226
|
+
const pats = [
|
|
227
|
+
/(?:getElementById|querySelector(?:All)?)\(\s*['"`]([^'"`]{1,40})['"`]/g,
|
|
228
|
+
/\bid\s*[:=]\s*['"`]([^'"`]{1,40})['"`]/g,
|
|
229
|
+
/textContent\s*=\s*['"`]([^'"`]{1,40})['"`]/g,
|
|
230
|
+
];
|
|
231
|
+
let best = null;
|
|
232
|
+
for (const p of pats) for (const m of win.matchAll(p)) best = m[1];
|
|
233
|
+
if (!best) return null;
|
|
234
|
+
return best.replace(/^[#.]/, '').slice(0, 40);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/* ---------- target resolution ---------- */
|
|
238
|
+
|
|
239
|
+
function resolveTarget(href, fromRoute) {
|
|
240
|
+
let h = String(href).trim();
|
|
241
|
+
if (!h || h.startsWith('#') || h.startsWith('javascript:')) return null;
|
|
242
|
+
if (/^(mailto|tel|sms):/i.test(h)) return { id: h, label: h.split(':')[0], type: 'external' };
|
|
243
|
+
if (/^https?:\/\//i.test(h)) {
|
|
244
|
+
try {
|
|
245
|
+
const u = new URL(h);
|
|
246
|
+
return { id: `${u.origin}${u.pathname}`.replace(/\/$/, '') || u.origin, label: u.host, type: 'external' };
|
|
247
|
+
} catch { return null; }
|
|
248
|
+
}
|
|
249
|
+
h = h.split('#')[0].split('?')[0];
|
|
250
|
+
if (!h) return null;
|
|
251
|
+
if (!h.startsWith('/')) {
|
|
252
|
+
const dir = fromRoute.endsWith('/') ? fromRoute : path.posix.dirname(fromRoute) + '/';
|
|
253
|
+
h = path.posix.resolve(dir, h);
|
|
254
|
+
}
|
|
255
|
+
if (/\.(css|js|mjs|png|jpe?g|svg|ico|webp|woff2?|json|xml|txt|map|webmanifest|pdf)$/i.test(h)) return null;
|
|
256
|
+
if (h.startsWith('/api/')) return { id: h, label: h, type: 'api' };
|
|
257
|
+
|
|
258
|
+
const known = knownRoute(h);
|
|
259
|
+
if (known) return { id: known, label: known, type: 'page' };
|
|
260
|
+
return { id: h, label: h, type: 'missing' };
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function knownRoute(h) {
|
|
264
|
+
if (!ROUTES) ROUTES = new Set();
|
|
265
|
+
if (ROUTES.has(h)) return h;
|
|
266
|
+
const withSlash = h.endsWith('/') ? h : h + '/';
|
|
267
|
+
if (ROUTES.has(withSlash)) return withSlash;
|
|
268
|
+
const noSlash = h.replace(/\/$/, '');
|
|
269
|
+
if (ROUTES.has(noSlash)) return noSlash;
|
|
270
|
+
// fall back to disk check
|
|
271
|
+
const candidates = [
|
|
272
|
+
path.join(root, h, 'index.html'),
|
|
273
|
+
path.join(root, h.replace(/\/$/, '') + '.html'),
|
|
274
|
+
path.join(root, h),
|
|
275
|
+
];
|
|
276
|
+
for (const c of candidates) {
|
|
277
|
+
if (fs.existsSync(c) && fs.statSync(c).isFile()) {
|
|
278
|
+
const rel = posix(path.relative(root, c));
|
|
279
|
+
const r = routeOf(rel);
|
|
280
|
+
ROUTES.add(r);
|
|
281
|
+
return r;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
return null;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/* ---------- analysis ---------- */
|
|
288
|
+
|
|
289
|
+
function dedupeEdges(edges) {
|
|
290
|
+
const seen = new Set();
|
|
291
|
+
for (let i = edges.length - 1; i >= 0; i--) {
|
|
292
|
+
const k = `${edges[i].from}>${edges[i].to}>${edges[i].label}>${edges[i].kind}`;
|
|
293
|
+
if (seen.has(k)) edges.splice(i, 1); else seen.add(k);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// A header or footer repeated on every page emits one link per page, and those
|
|
298
|
+
// hundreds of identical wires bury the handful of links that are specific to a
|
|
299
|
+
// page. Tag anything that appears from more than REPEAT_MIN pages so the viewer
|
|
300
|
+
// can fold it away, the same way it already folds links wired by shared scripts.
|
|
301
|
+
const REPEAT_MIN = 4;
|
|
302
|
+
function markRepeated(edges) {
|
|
303
|
+
const sources = new Map(); // "label\0target" -> Set(page it appears on)
|
|
304
|
+
for (const e of edges) {
|
|
305
|
+
const k = `${e.label}${e.to}`;
|
|
306
|
+
if (!sources.has(k)) sources.set(k, new Set());
|
|
307
|
+
sources.get(k).add(e.from);
|
|
308
|
+
}
|
|
309
|
+
for (const e of edges) {
|
|
310
|
+
if (sources.get(`${e.label}${e.to}`).size > REPEAT_MIN) e.repeated = true;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function analyze(flow) {
|
|
315
|
+
const byId = new Map(flow.nodes.map((n) => [n.id, n]));
|
|
316
|
+
const out = new Map(), inn = new Map();
|
|
317
|
+
for (const n of flow.nodes) { out.set(n.id, []); inn.set(n.id, []); }
|
|
318
|
+
for (const e of flow.edges) {
|
|
319
|
+
out.get(e.from)?.push(e);
|
|
320
|
+
inn.get(e.to)?.push(e);
|
|
321
|
+
}
|
|
322
|
+
const entry = byId.get('/') || flow.nodes[0];
|
|
323
|
+
const depth = new Map();
|
|
324
|
+
if (entry) {
|
|
325
|
+
depth.set(entry.id, 0);
|
|
326
|
+
const q = [entry.id];
|
|
327
|
+
while (q.length) {
|
|
328
|
+
const cur = q.shift();
|
|
329
|
+
for (const e of out.get(cur) || []) {
|
|
330
|
+
if (!depth.has(e.to)) { depth.set(e.to, depth.get(cur) + 1); q.push(e.to); }
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
for (const n of flow.nodes) {
|
|
335
|
+
n.inbound = (inn.get(n.id) || []).length;
|
|
336
|
+
n.outbound = (out.get(n.id) || []).length;
|
|
337
|
+
n.depth = depth.has(n.id) ? depth.get(n.id) : -1;
|
|
338
|
+
}
|
|
339
|
+
const pages = flow.nodes.filter((n) => n.type === 'page');
|
|
340
|
+
flow.issues = {
|
|
341
|
+
deadLinks: flow.edges.filter((e) => byId.get(e.to)?.type === 'missing')
|
|
342
|
+
.map((e) => ({ from: e.from, to: e.to, label: e.label, file: e.file, line: e.line })),
|
|
343
|
+
orphans: pages.filter((n) => n.inbound === 0 && n.id !== '/').map((n) => n.id),
|
|
344
|
+
deadEnds: pages.filter((n) => n.outbound === 0).map((n) => n.id),
|
|
345
|
+
deep: pages.filter((n) => n.depth > 3).map((n) => ({ id: n.id, depth: n.depth })),
|
|
346
|
+
unreachable: pages.filter((n) => n.depth === -1 && n.id !== '/').map((n) => n.id),
|
|
347
|
+
hubs: pages.filter((n) => n.outbound > 12).map((n) => ({ id: n.id, outbound: n.outbound })),
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function mergePrevious(flow) {
|
|
352
|
+
if (!fs.existsSync(flowPath)) return;
|
|
353
|
+
try {
|
|
354
|
+
const prev = JSON.parse(fs.readFileSync(flowPath, 'utf8'));
|
|
355
|
+
flow.layout = prev.layout || {};
|
|
356
|
+
flow.notes = prev.notes || {};
|
|
357
|
+
if (!flow.base && prev.base) flow.base = prev.base;
|
|
358
|
+
} catch { /* ignore */ }
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/* ---------- utils ---------- */
|
|
362
|
+
|
|
363
|
+
function attr(s, name) {
|
|
364
|
+
const m = new RegExp(`\\b${name}\\s*=\\s*("([^"]*)"|'([^']*)'|([^\\s>]+))`, 'i').exec(s || '');
|
|
365
|
+
return m ? (m[2] ?? m[3] ?? m[4] ?? '').trim() : null;
|
|
366
|
+
}
|
|
367
|
+
function label(html) {
|
|
368
|
+
return String(html || '').replace(/<[^>]*>/g, ' ').replace(/&[a-z]+;/gi, ' ').replace(/\s+/g, ' ').trim().slice(0, 44);
|
|
369
|
+
}
|
|
370
|
+
function titleOf(t) {
|
|
371
|
+
const m = /<title[^>]*>([\s\S]*?)<\/title>/i.exec(t) || /<h1[^>]*>([\s\S]*?)<\/h1>/i.exec(t);
|
|
372
|
+
return m ? label(m[1]) : '';
|
|
373
|
+
}
|
|
374
|
+
function lineAt(text, idx) {
|
|
375
|
+
let line = 1;
|
|
376
|
+
for (let i = 0; i < idx && i < text.length; i++) if (text.charCodeAt(i) === 10) line++;
|
|
377
|
+
return line;
|
|
378
|
+
}
|
|
379
|
+
function resolveFile(src, fromRoute) {
|
|
380
|
+
let s = src.split('?')[0];
|
|
381
|
+
if (/^https?:/i.test(s)) return null;
|
|
382
|
+
if (!s.startsWith('/')) {
|
|
383
|
+
const dir = fromRoute.endsWith('/') ? fromRoute : path.posix.dirname(fromRoute) + '/';
|
|
384
|
+
s = path.posix.resolve(dir, s);
|
|
385
|
+
}
|
|
386
|
+
const abs = path.join(root, s);
|
|
387
|
+
return fs.existsSync(abs) ? abs : null;
|
|
388
|
+
}
|
|
389
|
+
function posix(p) { return p.split(path.sep).join('/'); }
|
|
390
|
+
function parseArgs(a) {
|
|
391
|
+
const o = {};
|
|
392
|
+
for (let i = 0; i < a.length; i++) {
|
|
393
|
+
if (a[i].startsWith('--')) { const k = a[i].slice(2); o[k] = a[i + 1] && !a[i + 1].startsWith('--') ? a[++i] : true; }
|
|
394
|
+
}
|
|
395
|
+
return o;
|
|
396
|
+
}
|
|
397
|
+
function die(m) { console.error('sitelines: ' + m); process.exit(1); }
|
|
398
|
+
|
|
399
|
+
main();
|