samey-cli 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +43 -0
- package/LICENSE +21 -0
- package/README.md +224 -2
- package/dist/ai/local-model.d.ts +26 -0
- package/dist/ai/local-model.js +91 -0
- package/dist/audit.d.ts +18 -0
- package/dist/audit.js +250 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +166 -0
- package/dist/config.d.ts +34 -0
- package/dist/config.js +40 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +5 -0
- package/dist/render/crawl.d.ts +34 -0
- package/dist/render/crawl.js +337 -0
- package/dist/render/extract.d.ts +8 -0
- package/dist/render/extract.js +441 -0
- package/dist/render/interact.d.ts +8 -0
- package/dist/render/interact.js +76 -0
- package/dist/render/network.d.ts +33 -0
- package/dist/render/network.js +92 -0
- package/dist/render/robots.d.ts +8 -0
- package/dist/render/robots.js +61 -0
- package/dist/render/server.d.ts +15 -0
- package/dist/render/server.js +174 -0
- package/dist/report/html.d.ts +2 -0
- package/dist/report/html.js +368 -0
- package/dist/report/prompt.d.ts +12 -0
- package/dist/report/prompt.js +62 -0
- package/dist/report/terminal.d.ts +6 -0
- package/dist/report/terminal.js +73 -0
- package/dist/rules/content.d.ts +8 -0
- package/dist/rules/content.js +227 -0
- package/dist/rules/functional.d.ts +17 -0
- package/dist/rules/functional.js +467 -0
- package/dist/rules/helpers.d.ts +11 -0
- package/dist/rules/helpers.js +29 -0
- package/dist/rules/index.d.ts +13 -0
- package/dist/rules/index.js +100 -0
- package/dist/rules/normalize.d.ts +18 -0
- package/dist/rules/normalize.js +114 -0
- package/dist/rules/static.d.ts +9 -0
- package/dist/rules/static.js +314 -0
- package/dist/rules/types.d.ts +39 -0
- package/dist/rules/types.js +16 -0
- package/dist/rules/visual.d.ts +15 -0
- package/dist/rules/visual.js +463 -0
- package/dist/score.d.ts +31 -0
- package/dist/score.js +66 -0
- package/dist/security/fetch.d.ts +26 -0
- package/dist/security/fetch.js +86 -0
- package/dist/security/net-policy.d.ts +46 -0
- package/dist/security/net-policy.js +178 -0
- package/dist/static/scan.d.ts +15 -0
- package/dist/static/scan.js +191 -0
- package/dist/types.d.ts +316 -0
- package/dist/types.js +1 -0
- package/dist/util/color.d.ts +25 -0
- package/dist/util/color.js +180 -0
- package/dist/util/text.d.ts +22 -0
- package/dist/util/text.js +106 -0
- package/package.json +69 -4
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Network policy for everything samey fetches, in the browser or from Node.
|
|
3
|
+
*
|
|
4
|
+
* Remote audits may only reach public addresses. Loopback and private ranges need an
|
|
5
|
+
* explicit development mode. Link-local, reserved, and cloud metadata addresses are never
|
|
6
|
+
* reachable. Every hostname is resolved before connecting, every resolved address must be
|
|
7
|
+
* allowed, and the connection is pinned to the address that was checked, so a DNS answer
|
|
8
|
+
* cannot change between validation and use (DNS rebinding).
|
|
9
|
+
*/
|
|
10
|
+
export type AddressKind = 'public' | 'loopback' | 'private' | 'link-local' | 'reserved' | 'metadata';
|
|
11
|
+
export interface NetworkPolicy {
|
|
12
|
+
allowLoopback: boolean;
|
|
13
|
+
allowPrivate: boolean;
|
|
14
|
+
/** Test seam: replaces DNS resolution. */
|
|
15
|
+
resolver?: (hostname: string) => Promise<{
|
|
16
|
+
address: string;
|
|
17
|
+
family: 4 | 6;
|
|
18
|
+
}[]>;
|
|
19
|
+
/** Test seam: overrides address classification. */
|
|
20
|
+
classify?: (ip: string) => AddressKind;
|
|
21
|
+
}
|
|
22
|
+
export declare const REMOTE_POLICY: NetworkPolicy;
|
|
23
|
+
export declare const DEV_POLICY: NetworkPolicy;
|
|
24
|
+
export declare class BlockedError extends Error {
|
|
25
|
+
readonly url: string;
|
|
26
|
+
constructor(message: string, url: string);
|
|
27
|
+
}
|
|
28
|
+
/** Expands an IPv6 address to eight 16-bit groups, handling `::` and an embedded dotted IPv4 tail. */
|
|
29
|
+
export declare function expandV6(ip: string): number[];
|
|
30
|
+
export declare function classifyIp(ip: string): AddressKind;
|
|
31
|
+
export declare function kindAllowed(kind: AddressKind, policy: NetworkPolicy): boolean;
|
|
32
|
+
export interface ResolvedTarget {
|
|
33
|
+
url: URL;
|
|
34
|
+
address: string;
|
|
35
|
+
family: 4 | 6;
|
|
36
|
+
}
|
|
37
|
+
/** Syntax checks that need no network: scheme, credentials, literal addresses, localhost names. */
|
|
38
|
+
export declare function checkUrlSyntax(raw: string | URL, policy: NetworkPolicy): URL;
|
|
39
|
+
/**
|
|
40
|
+
* Resolves the URL's host and checks every address. All addresses must be allowed: a name
|
|
41
|
+
* that resolves to both a public and a private address is rejected, because the client
|
|
42
|
+
* cannot control which one a later lookup returns.
|
|
43
|
+
*/
|
|
44
|
+
export declare function resolveTarget(raw: string | URL, policy: NetworkPolicy): Promise<ResolvedTarget>;
|
|
45
|
+
/** Removes credentials, query, and fragment so a URL is safe to show in reports and logs. */
|
|
46
|
+
export declare function displayUrl(raw: string): string;
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { lookup as dnsLookup } from 'node:dns/promises';
|
|
2
|
+
import net from 'node:net';
|
|
3
|
+
export const REMOTE_POLICY = { allowLoopback: false, allowPrivate: false };
|
|
4
|
+
export const DEV_POLICY = { allowLoopback: true, allowPrivate: true };
|
|
5
|
+
export class BlockedError extends Error {
|
|
6
|
+
url;
|
|
7
|
+
constructor(message, url) {
|
|
8
|
+
super(message);
|
|
9
|
+
this.url = url;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
const METADATA = new Set(['169.254.169.254', '100.100.100.200', '168.63.129.16', 'fd00:ec2::254']);
|
|
13
|
+
function v4ToInt(ip) {
|
|
14
|
+
return ip.split('.').reduce((acc, o) => (acc << 8) + Number(o), 0) >>> 0;
|
|
15
|
+
}
|
|
16
|
+
const V4_RANGES = [
|
|
17
|
+
['0.0.0.0', 8, 'reserved'],
|
|
18
|
+
['10.0.0.0', 8, 'private'],
|
|
19
|
+
['100.64.0.0', 10, 'private'], // carrier-grade NAT
|
|
20
|
+
['127.0.0.0', 8, 'loopback'],
|
|
21
|
+
['169.254.0.0', 16, 'link-local'],
|
|
22
|
+
['172.16.0.0', 12, 'private'],
|
|
23
|
+
['192.0.0.0', 24, 'reserved'],
|
|
24
|
+
['192.0.2.0', 24, 'reserved'],
|
|
25
|
+
['192.88.99.0', 24, 'reserved'],
|
|
26
|
+
['192.168.0.0', 16, 'private'],
|
|
27
|
+
['198.18.0.0', 15, 'reserved'],
|
|
28
|
+
['198.51.100.0', 24, 'reserved'],
|
|
29
|
+
['203.0.113.0', 24, 'reserved'],
|
|
30
|
+
['224.0.0.0', 4, 'reserved'], // multicast
|
|
31
|
+
['240.0.0.0', 4, 'reserved'], // includes 255.255.255.255
|
|
32
|
+
];
|
|
33
|
+
function classifyV4(ip) {
|
|
34
|
+
if (METADATA.has(ip))
|
|
35
|
+
return 'metadata';
|
|
36
|
+
const n = v4ToInt(ip);
|
|
37
|
+
for (const [base, bits, kind] of V4_RANGES) {
|
|
38
|
+
const mask = bits === 0 ? 0 : (~0 << (32 - bits)) >>> 0;
|
|
39
|
+
if ((n & mask) === (v4ToInt(base) & mask))
|
|
40
|
+
return kind;
|
|
41
|
+
}
|
|
42
|
+
return 'public';
|
|
43
|
+
}
|
|
44
|
+
/** Expands an IPv6 address to eight 16-bit groups, handling `::` and an embedded dotted IPv4 tail. */
|
|
45
|
+
export function expandV6(ip) {
|
|
46
|
+
let s = ip.toLowerCase().replace(/%.*$/, '');
|
|
47
|
+
const dotted = s.match(/(\d+\.\d+\.\d+\.\d+)$/);
|
|
48
|
+
if (dotted) {
|
|
49
|
+
const n = v4ToInt(dotted[1]);
|
|
50
|
+
s = s.slice(0, -dotted[1].length) + `${(n >>> 16).toString(16)}:${(n & 0xffff).toString(16)}`;
|
|
51
|
+
}
|
|
52
|
+
const [head, tail] = s.includes('::') ? s.split('::') : [s, undefined];
|
|
53
|
+
const h = head ? head.split(':') : [];
|
|
54
|
+
const t = tail !== undefined && tail !== '' ? tail.split(':') : [];
|
|
55
|
+
const fill = tail !== undefined ? 8 - h.length - t.length : 0;
|
|
56
|
+
return [...h, ...Array(fill).fill('0'), ...t].map((g) => parseInt(g, 16) || 0);
|
|
57
|
+
}
|
|
58
|
+
function classifyV6(ip) {
|
|
59
|
+
const g = expandV6(ip);
|
|
60
|
+
const compact = g.map((x) => x.toString(16)).join(':');
|
|
61
|
+
if (METADATA.has(ip.toLowerCase()) || compact === 'fd00:ec2:0:0:0:0:0:254')
|
|
62
|
+
return 'metadata';
|
|
63
|
+
const embedded = (hi, lo) => `${hi >>> 8}.${hi & 0xff}.${lo >>> 8}.${lo & 0xff}`;
|
|
64
|
+
const zeroPrefix = (n) => g.slice(0, n).every((x) => x === 0);
|
|
65
|
+
if (g.every((x) => x === 0))
|
|
66
|
+
return 'reserved'; // ::
|
|
67
|
+
if (zeroPrefix(7) && g[7] === 1)
|
|
68
|
+
return 'loopback'; // ::1
|
|
69
|
+
if (zeroPrefix(5) && g[5] === 0xffff)
|
|
70
|
+
return classifyV4(embedded(g[6], g[7])); // ::ffff:a.b.c.d mapped
|
|
71
|
+
if (zeroPrefix(6))
|
|
72
|
+
return 'reserved'; // deprecated IPv4-compatible
|
|
73
|
+
if (g[0] === 0x64 && g[1] === 0xff9b && g.slice(2, 6).every((x) => x === 0))
|
|
74
|
+
return classifyV4(embedded(g[6], g[7])); // NAT64
|
|
75
|
+
if (g[0] === 0x2002)
|
|
76
|
+
return classifyV4(embedded(g[1], g[2])); // 6to4
|
|
77
|
+
if (g[0] === 0x2001 && g[1] === 0)
|
|
78
|
+
return 'reserved'; // Teredo
|
|
79
|
+
if (g[0] === 0x2001 && g[1] === 0xdb8)
|
|
80
|
+
return 'reserved'; // documentation
|
|
81
|
+
if (g[0] === 0x100 && g.slice(1, 4).every((x) => x === 0))
|
|
82
|
+
return 'reserved'; // discard
|
|
83
|
+
if ((g[0] & 0xfe00) === 0xfc00)
|
|
84
|
+
return 'private'; // unique local
|
|
85
|
+
if ((g[0] & 0xffc0) === 0xfe80)
|
|
86
|
+
return 'link-local';
|
|
87
|
+
if ((g[0] & 0xffc0) === 0xfec0)
|
|
88
|
+
return 'private'; // deprecated site-local
|
|
89
|
+
if ((g[0] & 0xff00) === 0xff00)
|
|
90
|
+
return 'reserved'; // multicast
|
|
91
|
+
return 'public';
|
|
92
|
+
}
|
|
93
|
+
export function classifyIp(ip) {
|
|
94
|
+
const bare = ip.replace(/^\[|\]$/g, '');
|
|
95
|
+
const v = net.isIP(bare);
|
|
96
|
+
if (v === 4)
|
|
97
|
+
return classifyV4(bare);
|
|
98
|
+
if (v === 6)
|
|
99
|
+
return classifyV6(bare);
|
|
100
|
+
return 'reserved';
|
|
101
|
+
}
|
|
102
|
+
export function kindAllowed(kind, policy) {
|
|
103
|
+
if (kind === 'public')
|
|
104
|
+
return true;
|
|
105
|
+
if (kind === 'loopback')
|
|
106
|
+
return policy.allowLoopback;
|
|
107
|
+
if (kind === 'private')
|
|
108
|
+
return policy.allowPrivate;
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
111
|
+
/** Syntax checks that need no network: scheme, credentials, literal addresses, localhost names. */
|
|
112
|
+
export function checkUrlSyntax(raw, policy) {
|
|
113
|
+
let u;
|
|
114
|
+
try {
|
|
115
|
+
u = typeof raw === 'string' ? new URL(raw) : raw;
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
throw new BlockedError(`Not a valid URL: ${String(raw)}`, String(raw));
|
|
119
|
+
}
|
|
120
|
+
if (u.protocol !== 'http:' && u.protocol !== 'https:')
|
|
121
|
+
throw new BlockedError(`Only http and https URLs are allowed (got ${u.protocol})`, u.href);
|
|
122
|
+
if (u.username || u.password)
|
|
123
|
+
throw new BlockedError('URLs with embedded credentials are not allowed', u.origin);
|
|
124
|
+
const host = u.hostname.replace(/^\[|\]$/g, '');
|
|
125
|
+
if (host === 'localhost' || host.endsWith('.localhost')) {
|
|
126
|
+
if (!policy.allowLoopback)
|
|
127
|
+
throw new BlockedError(`${host} is a loopback address`, u.href);
|
|
128
|
+
}
|
|
129
|
+
if (net.isIP(host)) {
|
|
130
|
+
const kind = (policy.classify ?? classifyIp)(host);
|
|
131
|
+
if (!kindAllowed(kind, policy))
|
|
132
|
+
throw new BlockedError(`${host} is a ${kind} address`, u.href);
|
|
133
|
+
}
|
|
134
|
+
return u;
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Resolves the URL's host and checks every address. All addresses must be allowed: a name
|
|
138
|
+
* that resolves to both a public and a private address is rejected, because the client
|
|
139
|
+
* cannot control which one a later lookup returns.
|
|
140
|
+
*/
|
|
141
|
+
export async function resolveTarget(raw, policy) {
|
|
142
|
+
const u = checkUrlSyntax(raw, policy);
|
|
143
|
+
const host = u.hostname.replace(/^\[|\]$/g, '');
|
|
144
|
+
const classify = policy.classify ?? classifyIp;
|
|
145
|
+
let addrs;
|
|
146
|
+
if (net.isIP(host))
|
|
147
|
+
addrs = [{ address: host, family: net.isIP(host) }];
|
|
148
|
+
else {
|
|
149
|
+
try {
|
|
150
|
+
addrs = policy.resolver ? await policy.resolver(host) : (await dnsLookup(host, { all: true, verbatim: true }));
|
|
151
|
+
}
|
|
152
|
+
catch (err) {
|
|
153
|
+
throw new BlockedError(`Could not resolve ${host}: ${err.code ?? err.message}`, u.href);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
if (!addrs.length)
|
|
157
|
+
throw new BlockedError(`${host} did not resolve to any address`, u.href);
|
|
158
|
+
for (const a of addrs) {
|
|
159
|
+
const kind = classify(a.address);
|
|
160
|
+
if (!kindAllowed(kind, policy))
|
|
161
|
+
throw new BlockedError(`${host} resolves to ${a.address}, a ${kind} address`, u.href);
|
|
162
|
+
}
|
|
163
|
+
return { url: u, address: addrs[0].address, family: addrs[0].family };
|
|
164
|
+
}
|
|
165
|
+
/** Removes credentials, query, and fragment so a URL is safe to show in reports and logs. */
|
|
166
|
+
export function displayUrl(raw) {
|
|
167
|
+
try {
|
|
168
|
+
const u = new URL(raw);
|
|
169
|
+
u.username = '';
|
|
170
|
+
u.password = '';
|
|
171
|
+
u.search = '';
|
|
172
|
+
u.hash = '';
|
|
173
|
+
return u.href;
|
|
174
|
+
}
|
|
175
|
+
catch {
|
|
176
|
+
return raw.replace(/\?.*$/, '');
|
|
177
|
+
}
|
|
178
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { RepoFacts, SourceFile } from '../types.js';
|
|
2
|
+
/** Directory that can be served as a static site without running any project code. */
|
|
3
|
+
export declare function findStaticRoot(root: string, pkg: Record<string, unknown> | null): string | undefined;
|
|
4
|
+
export declare function routeHints(files: SourceFile[]): string[];
|
|
5
|
+
export declare function scanRepo(root: string): RepoFacts;
|
|
6
|
+
/** Finds the 1-based line of the first occurrence of `needle` in `content`. */
|
|
7
|
+
export declare function lineOf(content: string, index: number): number;
|
|
8
|
+
/**
|
|
9
|
+
* Locates rendered text in the source tree. Only exact, unique-enough matches are
|
|
10
|
+
* returned so that reported source locations are trustworthy.
|
|
11
|
+
*/
|
|
12
|
+
export declare function locateText(files: SourceFile[], text: string, max?: number, pagePath?: string): {
|
|
13
|
+
file: string;
|
|
14
|
+
line: number;
|
|
15
|
+
}[];
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
const IGNORED_DIRS = new Set([
|
|
4
|
+
'node_modules', '.git', '.hg', '.svn', 'dist', 'build', 'out', '.next', '.nuxt', '.svelte-kit', '.astro', '.output',
|
|
5
|
+
'.vercel', '.netlify', '.turbo', '.cache', '.parcel-cache', 'coverage', '.samey', 'vendor', '__pycache__', '.venv', 'venv', 'storybook-static',
|
|
6
|
+
]);
|
|
7
|
+
const SOURCE_EXT = new Set(['.html', '.htm', '.jsx', '.tsx', '.js', '.ts', '.mjs', '.vue', '.svelte', '.astro', '.css', '.scss', '.sass', '.less', '.mdx']);
|
|
8
|
+
const MAX_FILES = 3000;
|
|
9
|
+
const MAX_BYTES = 400_000;
|
|
10
|
+
const FRAMEWORKS = [
|
|
11
|
+
['next', 'Next.js'], ['nuxt', 'Nuxt'], ['@sveltejs/kit', 'SvelteKit'], ['svelte', 'Svelte'], ['astro', 'Astro'], ['@remix-run/react', 'Remix'],
|
|
12
|
+
['react-router', 'React Router'], ['react', 'React'], ['vue', 'Vue'], ['solid-js', 'Solid'], ['@angular/core', 'Angular'], ['gatsby', 'Gatsby'],
|
|
13
|
+
['vite', 'Vite'], ['tailwindcss', 'Tailwind CSS'], ['@shadcn/ui', 'shadcn/ui'], ['framer-motion', 'Framer Motion'], ['motion', 'Motion'],
|
|
14
|
+
['lucide-react', 'Lucide'], ['@radix-ui/react-slot', 'Radix UI'], ['styled-components', 'styled-components'], ['@emotion/react', 'Emotion'],
|
|
15
|
+
];
|
|
16
|
+
function walk(root, rel, out, state) {
|
|
17
|
+
let entries;
|
|
18
|
+
try {
|
|
19
|
+
entries = readdirSync(path.join(root, rel));
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
for (const name of entries.sort()) {
|
|
25
|
+
if (state.count >= MAX_FILES) {
|
|
26
|
+
state.truncated = true;
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
const relPath = rel ? `${rel}/${name}` : name;
|
|
30
|
+
const abs = path.join(root, relPath);
|
|
31
|
+
let st;
|
|
32
|
+
try {
|
|
33
|
+
st = statSync(abs);
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
if (st.isDirectory()) {
|
|
39
|
+
if (IGNORED_DIRS.has(name) || (name.startsWith('.') && name !== '.storybook'))
|
|
40
|
+
continue;
|
|
41
|
+
walk(root, relPath, out, state);
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
const ext = path.extname(name).toLowerCase();
|
|
45
|
+
if (!SOURCE_EXT.has(ext) || st.size > MAX_BYTES)
|
|
46
|
+
continue;
|
|
47
|
+
if (/\.(?:min|bundle)\.(?:js|css)$/.test(name) || /\.d\.ts$/.test(name))
|
|
48
|
+
continue;
|
|
49
|
+
if (/(?:^|\/)(?:test|tests|__tests__|e2e|fixtures?)\//.test(relPath) || /\.(?:test|spec|stories)\.[jt]sx?$/.test(name))
|
|
50
|
+
continue;
|
|
51
|
+
state.count++;
|
|
52
|
+
out.push({ path: relPath, ext, content: readFileSync(abs, 'utf8') });
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
/** Directory that can be served as a static site without running any project code. */
|
|
56
|
+
export function findStaticRoot(root, pkg) {
|
|
57
|
+
for (const dir of ['out', 'dist', 'build', '_site', 'public_html', 'site']) {
|
|
58
|
+
if (existsSync(path.join(root, dir, 'index.html')))
|
|
59
|
+
return path.join(root, dir);
|
|
60
|
+
}
|
|
61
|
+
if (existsSync(path.join(root, 'index.html'))) {
|
|
62
|
+
// A Vite-style index.html points at uncompiled modules and cannot be served as-is.
|
|
63
|
+
const html = readFileSync(path.join(root, 'index.html'), 'utf8');
|
|
64
|
+
const needsBundler = /<script[^>]+src=["'][^"']+\.(?:tsx?|jsx)["']/.test(html);
|
|
65
|
+
const deps = { ...(pkg?.dependencies ?? {}), ...(pkg?.devDependencies ?? {}) };
|
|
66
|
+
if (!needsBundler && !deps.next && !deps.astro && !deps['@sveltejs/kit'])
|
|
67
|
+
return root;
|
|
68
|
+
}
|
|
69
|
+
return undefined;
|
|
70
|
+
}
|
|
71
|
+
export function routeHints(files) {
|
|
72
|
+
const routes = new Set();
|
|
73
|
+
for (const f of files) {
|
|
74
|
+
const p = f.path;
|
|
75
|
+
let m;
|
|
76
|
+
if ((m = p.match(/^(?:src\/)?app\/(.*?)\/?page\.(?:tsx|jsx|ts|js|mdx)$/))) {
|
|
77
|
+
const segs = m[1].split('/').filter((s) => s && !/^\(.*\)$/.test(s) && !s.startsWith('@'));
|
|
78
|
+
if (!segs.some((s) => s.startsWith('[') || s.startsWith('_')))
|
|
79
|
+
routes.add('/' + segs.join('/'));
|
|
80
|
+
}
|
|
81
|
+
else if ((m = p.match(/^(?:src\/)?pages\/(.*)\.(?:tsx|jsx|ts|js|vue|astro|md|mdx|html)$/))) {
|
|
82
|
+
const r = m[1].replace(/(?:^|\/)index$/, '');
|
|
83
|
+
if (!/\[|^_|\/_|^api(?:\/|$)/.test(r))
|
|
84
|
+
routes.add('/' + r);
|
|
85
|
+
}
|
|
86
|
+
else if ((m = p.match(/^(?:src\/)?routes\/(.*?)\/?\+page\.svelte$/))) {
|
|
87
|
+
const r = m[1].split('/').filter((s) => s && !/^\(.*\)$/.test(s));
|
|
88
|
+
if (!r.some((s) => s.startsWith('[')))
|
|
89
|
+
routes.add('/' + r.join('/'));
|
|
90
|
+
}
|
|
91
|
+
else if (/\.html?$/.test(p) && !p.includes('/') && p !== '404.html') {
|
|
92
|
+
routes.add(p === 'index.html' ? '/' : '/' + p.replace(/\.html?$/, ''));
|
|
93
|
+
}
|
|
94
|
+
if (/\.(?:jsx|tsx|js|ts)$/.test(p)) {
|
|
95
|
+
for (const r of f.content.matchAll(/\bpath\s*[:=]\s*["'](\/[a-z0-9/_-]*)["']/gi)) {
|
|
96
|
+
if (!r[1].includes(':') && !r[1].includes('*'))
|
|
97
|
+
routes.add(r[1]);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return [...routes].map((r) => r.replace(/\/+$/, '') || '/').filter((r, i, a) => a.indexOf(r) === i).slice(0, 50);
|
|
102
|
+
}
|
|
103
|
+
export function scanRepo(root) {
|
|
104
|
+
const abs = path.resolve(root);
|
|
105
|
+
if (!existsSync(abs) || !statSync(abs).isDirectory())
|
|
106
|
+
throw new Error(`Not a directory: ${root}`);
|
|
107
|
+
let pkg = null;
|
|
108
|
+
try {
|
|
109
|
+
pkg = JSON.parse(readFileSync(path.join(abs, 'package.json'), 'utf8'));
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
pkg = null;
|
|
113
|
+
}
|
|
114
|
+
const deps = { ...(pkg?.dependencies ?? {}), ...(pkg?.devDependencies ?? {}) };
|
|
115
|
+
const frameworks = FRAMEWORKS.filter(([dep]) => dep in deps).map(([, label]) => label);
|
|
116
|
+
const packageManager = existsSync(path.join(abs, 'pnpm-lock.yaml'))
|
|
117
|
+
? 'pnpm'
|
|
118
|
+
: existsSync(path.join(abs, 'yarn.lock'))
|
|
119
|
+
? 'yarn'
|
|
120
|
+
: existsSync(path.join(abs, 'bun.lockb')) || existsSync(path.join(abs, 'bun.lock'))
|
|
121
|
+
? 'bun'
|
|
122
|
+
: existsSync(path.join(abs, 'package-lock.json'))
|
|
123
|
+
? 'npm'
|
|
124
|
+
: undefined;
|
|
125
|
+
const files = [];
|
|
126
|
+
const state = { count: 0, truncated: false };
|
|
127
|
+
walk(abs, '', files, state);
|
|
128
|
+
if (frameworks.length === 0 && files.some((f) => f.ext === '.html'))
|
|
129
|
+
frameworks.push('Static HTML');
|
|
130
|
+
return {
|
|
131
|
+
root: abs,
|
|
132
|
+
name: pkg?.name ?? path.basename(abs),
|
|
133
|
+
frameworks,
|
|
134
|
+
packageManager,
|
|
135
|
+
scripts: pkg?.scripts ?? {},
|
|
136
|
+
files,
|
|
137
|
+
fileCount: files.length,
|
|
138
|
+
truncated: state.truncated,
|
|
139
|
+
routeHints: routeHints(files),
|
|
140
|
+
staticRoot: findStaticRoot(abs, pkg),
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
/** Finds the 1-based line of the first occurrence of `needle` in `content`. */
|
|
144
|
+
export function lineOf(content, index) {
|
|
145
|
+
let line = 1;
|
|
146
|
+
for (let i = 0; i < index && i < content.length; i++)
|
|
147
|
+
if (content.charCodeAt(i) === 10)
|
|
148
|
+
line++;
|
|
149
|
+
return line;
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Locates rendered text in the source tree. Only exact, unique-enough matches are
|
|
153
|
+
* returned so that reported source locations are trustworthy.
|
|
154
|
+
*/
|
|
155
|
+
export function locateText(files, text, max = 3, pagePath) {
|
|
156
|
+
const needle = text.replace(/\s+/g, ' ').trim().slice(0, 48);
|
|
157
|
+
if (needle.length < 12)
|
|
158
|
+
return [];
|
|
159
|
+
const out = [];
|
|
160
|
+
// When the same text exists in several files, prefer the one named after the page it was seen on.
|
|
161
|
+
const page = (pagePath ?? '').replace(/^\/+|\/+$/g, '').split('/').pop() || 'index';
|
|
162
|
+
const rank = (f) => (path.basename(f.path).replace(/\.[^.]+$/, '') === page || f.path.includes(`/${page}/`) ? 0 : 1);
|
|
163
|
+
for (const f of [...files].sort((a, b) => rank(a) - rank(b))) {
|
|
164
|
+
if (f.ext === '.css' || f.ext === '.scss')
|
|
165
|
+
continue;
|
|
166
|
+
const { flat, map } = collapse(f.content);
|
|
167
|
+
const idx = flat.indexOf(needle);
|
|
168
|
+
if (idx < 0)
|
|
169
|
+
continue;
|
|
170
|
+
out.push({ file: f.path, line: lineOf(f.content, map[idx]) });
|
|
171
|
+
if (out.length >= max)
|
|
172
|
+
break;
|
|
173
|
+
}
|
|
174
|
+
return out;
|
|
175
|
+
}
|
|
176
|
+
/** Collapses whitespace runs to single spaces, keeping a map back to original offsets. */
|
|
177
|
+
function collapse(content) {
|
|
178
|
+
const map = [];
|
|
179
|
+
let flat = '';
|
|
180
|
+
let prevSpace = false;
|
|
181
|
+
for (let i = 0; i < content.length; i++) {
|
|
182
|
+
const ch = content[i];
|
|
183
|
+
const space = ch === ' ' || ch === '\n' || ch === '\t' || ch === '\r';
|
|
184
|
+
if (space && prevSpace)
|
|
185
|
+
continue;
|
|
186
|
+
flat += space ? ' ' : ch;
|
|
187
|
+
map.push(i);
|
|
188
|
+
prevSpace = space;
|
|
189
|
+
}
|
|
190
|
+
return { flat, map };
|
|
191
|
+
}
|