@olomonhelpdesk2/helpdesk 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/README.md +25 -0
- package/helpdesk-capture.mjs +3275 -0
- package/helpdesk-seed.mjs +1373 -0
- package/helpdesk.mjs +1097 -0
- package/package.json +22 -0
package/helpdesk.mjs
ADDED
|
@@ -0,0 +1,1097 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// adapters/map-cli.ts
|
|
4
|
+
import { execFileSync } from "node:child_process";
|
|
5
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
6
|
+
import { join as join2 } from "node:path";
|
|
7
|
+
|
|
8
|
+
// core/discovery/next-app-router.ts
|
|
9
|
+
var PAGE_FILE = /(^|\/)page\.(tsx|ts|jsx|js)$/;
|
|
10
|
+
var LAYOUT_FILE = /(^|\/)layout\.(tsx|ts|jsx|js)$/;
|
|
11
|
+
var STATE_FILE = /(^|\/)(error|global-error|loading|not-found|template|default)\.(tsx|ts|jsx|js)$/;
|
|
12
|
+
var ROUTE_GROUP = /^\((.+)\)$/;
|
|
13
|
+
var DYNAMIC = /^\[{1,2}(\.{3})?(.+?)\]{1,2}$/;
|
|
14
|
+
var PARALLEL = /^@/;
|
|
15
|
+
var INTERCEPTING = /^\(\.+\)/;
|
|
16
|
+
function classify(path) {
|
|
17
|
+
if (path === "/api" || path.startsWith("/api/")) {
|
|
18
|
+
return { skipped: "not-html", note: "an API route, not a screen" };
|
|
19
|
+
}
|
|
20
|
+
if (/(^|\/)(admin|internal|debug|_dev)(\/|$)/.test(path)) {
|
|
21
|
+
return { skipped: "internal-tooling", note: "internal tooling" };
|
|
22
|
+
}
|
|
23
|
+
if (/(^|\/)(login|logout|signin|sign-in|signup|sign-up|auth|verify|reset-password)(\/|$)/.test(path)) {
|
|
24
|
+
return { skipped: "unauthenticated-flow", note: "an authentication step" };
|
|
25
|
+
}
|
|
26
|
+
return void 0;
|
|
27
|
+
}
|
|
28
|
+
function detectRouterRoot(files) {
|
|
29
|
+
for (const candidate2 of ["src/app", "app"]) {
|
|
30
|
+
if (files.some((file) => file.startsWith(`${candidate2}/`) && PAGE_FILE.test(file))) {
|
|
31
|
+
return candidate2;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return void 0;
|
|
35
|
+
}
|
|
36
|
+
function readNextAppRouter(files, root = "src/app") {
|
|
37
|
+
const prefix = root.endsWith("/") ? root : `${root}/`;
|
|
38
|
+
const inRouter = [...new Set(files)].filter((file) => file.startsWith(prefix));
|
|
39
|
+
const layouts = inRouter.filter((file) => LAYOUT_FILE.test(file));
|
|
40
|
+
const routes = [];
|
|
41
|
+
const ignored = [];
|
|
42
|
+
for (const file of inRouter) {
|
|
43
|
+
if (!PAGE_FILE.test(file)) continue;
|
|
44
|
+
const segments = file.slice(prefix.length).split("/").slice(0, -1);
|
|
45
|
+
const parsed = toPath(segments);
|
|
46
|
+
if ("reason" in parsed) {
|
|
47
|
+
ignored.push({ file, reason: parsed.reason });
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
const classification = classify(parsed.path);
|
|
51
|
+
routes.push({
|
|
52
|
+
path: parsed.path,
|
|
53
|
+
// The page, then what sits beside it, then the layouts that wrap it.
|
|
54
|
+
//
|
|
55
|
+
// Co-located files matter as much as the page: a route's server actions
|
|
56
|
+
// and client components are where its behaviour actually lives, so an
|
|
57
|
+
// article about the screen goes stale when THEY change. Taking only the
|
|
58
|
+
// page would leave change detection blind to the part most likely to move.
|
|
59
|
+
sourceFiles: [file, ...siblings(file, inRouter), ...enclosingLayouts(file, layouts)],
|
|
60
|
+
params: parsed.params,
|
|
61
|
+
groups: parsed.groups,
|
|
62
|
+
states: declaredStates(file, inRouter, prefix),
|
|
63
|
+
...classification ?? {}
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
routes.sort((a, b) => depth(a.path) - depth(b.path) || a.path.localeCompare(b.path));
|
|
67
|
+
return { framework: "next-app-router", routes, ignored };
|
|
68
|
+
}
|
|
69
|
+
function declaredStates(pageFile, files, prefix) {
|
|
70
|
+
const directory = pageFile.slice(0, pageFile.lastIndexOf("/"));
|
|
71
|
+
const own = new Set(
|
|
72
|
+
files.filter((file) => file.slice(0, file.lastIndexOf("/")) === directory)
|
|
73
|
+
);
|
|
74
|
+
const found = /* @__PURE__ */ new Map();
|
|
75
|
+
for (let at = directory; at.length >= prefix.length - 1; ) {
|
|
76
|
+
for (const file of files) {
|
|
77
|
+
if (file.slice(0, file.lastIndexOf("/")) !== at) continue;
|
|
78
|
+
const kind = stateKindOf(file);
|
|
79
|
+
if (!kind || found.has(kind)) continue;
|
|
80
|
+
found.set(kind, {
|
|
81
|
+
kind,
|
|
82
|
+
file,
|
|
83
|
+
segment: at.slice(prefix.length - 1).replace(/^\//, ""),
|
|
84
|
+
inherited: !own.has(file)
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
const up = at.lastIndexOf("/");
|
|
88
|
+
if (up < 0 || at.length <= prefix.length - 1) break;
|
|
89
|
+
at = at.slice(0, up);
|
|
90
|
+
}
|
|
91
|
+
return [...found.values()].sort((a, b) => a.kind.localeCompare(b.kind));
|
|
92
|
+
}
|
|
93
|
+
function stateKindOf(file) {
|
|
94
|
+
const match = /(^|\/)(error|global-error|loading|not-found)\.(tsx|ts|jsx|js)$/.exec(file);
|
|
95
|
+
return match ? match[2] : void 0;
|
|
96
|
+
}
|
|
97
|
+
function toPath(segments) {
|
|
98
|
+
const parts = [];
|
|
99
|
+
const params = [];
|
|
100
|
+
const groups = [];
|
|
101
|
+
for (const segment of segments) {
|
|
102
|
+
if (INTERCEPTING.test(segment)) return { reason: "an intercepting route, not its own URL" };
|
|
103
|
+
if (PARALLEL.test(segment)) return { reason: "a parallel route slot, not its own URL" };
|
|
104
|
+
const group = ROUTE_GROUP.exec(segment);
|
|
105
|
+
if (group) {
|
|
106
|
+
groups.push(group[1]);
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
const dynamic = DYNAMIC.exec(segment);
|
|
110
|
+
if (dynamic) {
|
|
111
|
+
params.push(dynamic[2]);
|
|
112
|
+
parts.push(segment);
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
parts.push(segment);
|
|
116
|
+
}
|
|
117
|
+
return { path: `/${parts.join("/")}`.replace(/\/+$/, "") || "/", params, groups };
|
|
118
|
+
}
|
|
119
|
+
function siblings(pageFile, files) {
|
|
120
|
+
const dir = pageFile.slice(0, pageFile.lastIndexOf("/") + 1);
|
|
121
|
+
return files.filter((file) => file !== pageFile && file.startsWith(dir) && !file.slice(dir.length).includes("/")).filter((file) => !/(^|\/)(opengraph-image|twitter-image|icon|apple-icon|sitemap|robots)\./.test(file)).filter((file) => !STATE_FILE.test(file)).filter((file) => !LAYOUT_FILE.test(file)).sort();
|
|
122
|
+
}
|
|
123
|
+
function enclosingLayouts(pageFile, layouts) {
|
|
124
|
+
const dir = pageFile.slice(0, pageFile.lastIndexOf("/"));
|
|
125
|
+
return layouts.filter((layout) => dir.startsWith(layout.slice(0, layout.lastIndexOf("/")))).sort((a, b) => a.length - b.length);
|
|
126
|
+
}
|
|
127
|
+
function depth(path) {
|
|
128
|
+
return path === "/" ? 0 : path.split("/").filter(Boolean).length;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// core/discovery/article-plan.ts
|
|
132
|
+
var MONEY_OR_ACCESS = /* @__PURE__ */ new Set([
|
|
133
|
+
"billing",
|
|
134
|
+
"invoice",
|
|
135
|
+
"invoices",
|
|
136
|
+
"payment",
|
|
137
|
+
"payments",
|
|
138
|
+
"subscription",
|
|
139
|
+
"subscriptions",
|
|
140
|
+
"plan",
|
|
141
|
+
"plans",
|
|
142
|
+
"pricing",
|
|
143
|
+
"seat",
|
|
144
|
+
"seats",
|
|
145
|
+
"permission",
|
|
146
|
+
"permissions",
|
|
147
|
+
"role",
|
|
148
|
+
"roles",
|
|
149
|
+
"member",
|
|
150
|
+
"members"
|
|
151
|
+
]);
|
|
152
|
+
var ENTRY_POINT = /* @__PURE__ */ new Set([
|
|
153
|
+
"onboard",
|
|
154
|
+
"onboarding",
|
|
155
|
+
"setup",
|
|
156
|
+
"get-started",
|
|
157
|
+
"getting-started",
|
|
158
|
+
"welcome",
|
|
159
|
+
"install",
|
|
160
|
+
"import",
|
|
161
|
+
"connect",
|
|
162
|
+
"integration",
|
|
163
|
+
"integrations"
|
|
164
|
+
]);
|
|
165
|
+
function hasSegment(path, vocabulary) {
|
|
166
|
+
return path.split("/").filter(Boolean).some((segment) => vocabulary.has(segment.toLowerCase()));
|
|
167
|
+
}
|
|
168
|
+
function score(route, signals) {
|
|
169
|
+
const because = [];
|
|
170
|
+
const segments = route.path.split("/").filter(Boolean);
|
|
171
|
+
let total = 0;
|
|
172
|
+
const depthPenalty = Math.min(segments.length, 5) * 8;
|
|
173
|
+
total += 40 - depthPenalty;
|
|
174
|
+
if (segments.length <= 1) because.push("a top-level destination");
|
|
175
|
+
if (route.params.length > 0) {
|
|
176
|
+
total += 10;
|
|
177
|
+
because.push("a record detail screen");
|
|
178
|
+
}
|
|
179
|
+
if (signals.writes && signals.writes > 0) {
|
|
180
|
+
total += Math.min(signals.writes, 4) * 7;
|
|
181
|
+
because.push(
|
|
182
|
+
signals.writes === 1 ? "somewhere a user changes something" : `${signals.writes} actions a user can take here`
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
if (signals.fanIn && signals.fanIn > 2) {
|
|
186
|
+
total += Math.min(signals.fanIn, 8) * 2;
|
|
187
|
+
because.push("linked to from across the app");
|
|
188
|
+
}
|
|
189
|
+
if (hasSegment(route.path, MONEY_OR_ACCESS)) {
|
|
190
|
+
total += 18;
|
|
191
|
+
because.push("money or access \u2014 asked about out of proportion to its traffic");
|
|
192
|
+
}
|
|
193
|
+
if (hasSegment(route.path, ENTRY_POINT)) {
|
|
194
|
+
total += 16;
|
|
195
|
+
because.push("one of the first things a new customer does");
|
|
196
|
+
}
|
|
197
|
+
return { score: total, because };
|
|
198
|
+
}
|
|
199
|
+
function tierFor(rank, count) {
|
|
200
|
+
if (rank <= Math.max(3, Math.ceil(count * 0.1))) return "critical";
|
|
201
|
+
if (rank <= Math.max(8, Math.ceil(count * 0.3))) return "core";
|
|
202
|
+
if (rank <= Math.max(16, Math.ceil(count * 0.6))) return "supporting";
|
|
203
|
+
return "minor";
|
|
204
|
+
}
|
|
205
|
+
function slugFor(path) {
|
|
206
|
+
const segments = path.split("/").filter(Boolean).filter((segment) => !segment.startsWith("["));
|
|
207
|
+
return segments.length ? segments.join("/") : "overview";
|
|
208
|
+
}
|
|
209
|
+
function titleFor(route) {
|
|
210
|
+
const segments = route.path.split("/").filter(Boolean).filter((s) => !s.startsWith("["));
|
|
211
|
+
if (!segments.length) return "Getting around";
|
|
212
|
+
const words = segments[segments.length - 1].replace(/[-_]/g, " ");
|
|
213
|
+
const subject = words.charAt(0).toUpperCase() + words.slice(1);
|
|
214
|
+
if (!route.params.length) return subject;
|
|
215
|
+
return `Working with ${article(singular(subject.toLowerCase()))}`;
|
|
216
|
+
}
|
|
217
|
+
function singular(word) {
|
|
218
|
+
if (/(sis|ss|us|is)$/.test(word)) return word;
|
|
219
|
+
if (/(ches|shes|xes|zes)$/.test(word)) return word.slice(0, -2);
|
|
220
|
+
if (/[^aeiou]ies$/.test(word)) return `${word.slice(0, -3)}y`;
|
|
221
|
+
if (/s$/.test(word)) return word.slice(0, -1);
|
|
222
|
+
return word;
|
|
223
|
+
}
|
|
224
|
+
function article(word) {
|
|
225
|
+
return /^[aeiou]/.test(word) ? `an ${word}` : `a ${word}`;
|
|
226
|
+
}
|
|
227
|
+
function buildArticlePlan(options) {
|
|
228
|
+
const existing = new Set(options.existingSlugs ?? []);
|
|
229
|
+
const excluded = [];
|
|
230
|
+
const scored = [];
|
|
231
|
+
for (const route of options.routes) {
|
|
232
|
+
if (route.skipped) {
|
|
233
|
+
excluded.push({ route: route.path, reason: route.note ?? route.skipped });
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
scored.push({ route, ...score(route, options.signals?.[route.path] ?? {}) });
|
|
237
|
+
}
|
|
238
|
+
scored.sort((a, b) => b.score - a.score || a.route.path.localeCompare(b.route.path));
|
|
239
|
+
const merged = /* @__PURE__ */ new Map();
|
|
240
|
+
for (const entry of scored) {
|
|
241
|
+
const slug = slugFor(entry.route.path);
|
|
242
|
+
const existingNeed = merged.get(slug);
|
|
243
|
+
if (!existingNeed) {
|
|
244
|
+
merged.set(slug, {
|
|
245
|
+
entry,
|
|
246
|
+
routes: [entry.route.path],
|
|
247
|
+
files: [...entry.route.sourceFiles],
|
|
248
|
+
because: [...entry.because],
|
|
249
|
+
...entry.route.title ? { title: entry.route.title } : {}
|
|
250
|
+
});
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
253
|
+
if (!existingNeed.title && entry.route.title) existingNeed.title = entry.route.title;
|
|
254
|
+
existingNeed.routes.push(entry.route.path);
|
|
255
|
+
for (const file of entry.route.sourceFiles) {
|
|
256
|
+
if (!existingNeed.files.includes(file)) existingNeed.files.push(file);
|
|
257
|
+
}
|
|
258
|
+
for (const reason of entry.because) {
|
|
259
|
+
if (!existingNeed.because.includes(reason)) existingNeed.because.push(reason);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
let covered = 0;
|
|
263
|
+
const needs = [...merged.entries()].map(([slug, group], index) => {
|
|
264
|
+
const rank = index + 1;
|
|
265
|
+
const claimed = [group.entry.route.path, ...group.routes.slice(1) ?? []].map((path) => options.documented?.[path]).find(Boolean);
|
|
266
|
+
const already = existing.has(slug) || Boolean(claimed);
|
|
267
|
+
if (already) covered += 1;
|
|
268
|
+
return {
|
|
269
|
+
slug,
|
|
270
|
+
title: group.title ?? titleFor(group.entry.route),
|
|
271
|
+
route: group.entry.route.path,
|
|
272
|
+
...group.routes.length > 1 ? { alsoCovers: group.routes.slice(1) } : {},
|
|
273
|
+
tier: tierFor(rank, merged.size),
|
|
274
|
+
rank,
|
|
275
|
+
sourceRefs: group.files,
|
|
276
|
+
because: group.because,
|
|
277
|
+
...already ? { covered: claimed ?? slug } : {}
|
|
278
|
+
};
|
|
279
|
+
});
|
|
280
|
+
return { needs, excluded, covered };
|
|
281
|
+
}
|
|
282
|
+
function formatArticlePlan(plan, limit = 12) {
|
|
283
|
+
const missing = plan.needs.filter((need) => !need.covered);
|
|
284
|
+
const lines = [];
|
|
285
|
+
lines.push(
|
|
286
|
+
`${missing.length} article${missing.length === 1 ? "" : "s"} to write \xB7 ${plan.covered} already covered`,
|
|
287
|
+
""
|
|
288
|
+
);
|
|
289
|
+
let tier;
|
|
290
|
+
for (const need of missing.slice(0, limit)) {
|
|
291
|
+
if (need.tier !== tier) {
|
|
292
|
+
tier = need.tier;
|
|
293
|
+
lines.push(` ${tier.toUpperCase()}`);
|
|
294
|
+
}
|
|
295
|
+
lines.push(` ${need.slug.padEnd(34)} ${need.route}`);
|
|
296
|
+
if (need.because.length) lines.push(` ${need.because.join("; ")}`);
|
|
297
|
+
}
|
|
298
|
+
if (missing.length > limit) {
|
|
299
|
+
lines.push("", ` \u2026and ${missing.length - limit} more, in priority order.`);
|
|
300
|
+
}
|
|
301
|
+
if (plan.excluded.length) {
|
|
302
|
+
lines.push("", ` ${plan.excluded.length} route(s) excluded:`);
|
|
303
|
+
for (const item of plan.excluded.slice(0, 5)) {
|
|
304
|
+
lines.push(` ${item.route.padEnd(34)} ${item.reason}`);
|
|
305
|
+
}
|
|
306
|
+
if (plan.excluded.length > 5) lines.push(` \u2026and ${plan.excluded.length - 5} more.`);
|
|
307
|
+
}
|
|
308
|
+
return lines.join("\n");
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// core/discovery/screen-modules.ts
|
|
312
|
+
var SOURCE = /\.(tsx|ts|jsx|js|mjs|cjs)$/;
|
|
313
|
+
var EVERYTHING = "*";
|
|
314
|
+
function screenModules(entries, options) {
|
|
315
|
+
const known = new Set(options.files);
|
|
316
|
+
const aliases = options.aliases ?? {};
|
|
317
|
+
const baseUrl = options.baseUrl;
|
|
318
|
+
const maxDepth = options.maxDepth ?? 4;
|
|
319
|
+
const maxFiles = options.maxFiles ?? 40;
|
|
320
|
+
const kept = [];
|
|
321
|
+
const visited = /* @__PURE__ */ new Map();
|
|
322
|
+
let frontier = entries.filter((entry) => known.has(entry)).map((entry) => ({ path: entry, wanted: /* @__PURE__ */ new Set([EVERYTHING]) }));
|
|
323
|
+
for (let depth2 = 0; depth2 <= maxDepth && frontier.length && kept.length < maxFiles; depth2 += 1) {
|
|
324
|
+
const next = /* @__PURE__ */ new Map();
|
|
325
|
+
for (const { path, wanted } of frontier) {
|
|
326
|
+
if (kept.length >= maxFiles) break;
|
|
327
|
+
const already = visited.get(path);
|
|
328
|
+
if (already && [...wanted].every((name) => already.has(name) || already.has(EVERYTHING))) {
|
|
329
|
+
continue;
|
|
330
|
+
}
|
|
331
|
+
visited.set(path, /* @__PURE__ */ new Set([...already ?? [], ...wanted]));
|
|
332
|
+
if (!already) kept.push({ path, depth: depth2 });
|
|
333
|
+
if (!SOURCE.test(path)) continue;
|
|
334
|
+
const contents = options.read(path);
|
|
335
|
+
if (!contents) continue;
|
|
336
|
+
for (const edge of edgesFrom(contents, wanted)) {
|
|
337
|
+
const resolved = resolve(edge.specifier, path, known, aliases, baseUrl);
|
|
338
|
+
if (!resolved) continue;
|
|
339
|
+
next.set(resolved, /* @__PURE__ */ new Set([...next.get(resolved) ?? [], ...edge.wanted]));
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
frontier = [...next].map(([path, wanted]) => ({ path, wanted }));
|
|
343
|
+
}
|
|
344
|
+
return kept;
|
|
345
|
+
}
|
|
346
|
+
function edgesFrom(contents, wanted) {
|
|
347
|
+
const reExports = reExportsIn(contents);
|
|
348
|
+
const edges = [];
|
|
349
|
+
const everything = wanted.has(EVERYTHING);
|
|
350
|
+
for (const reExport of reExports.named) {
|
|
351
|
+
if (everything || wanted.has(reExport.exposedAs)) {
|
|
352
|
+
edges.push({ specifier: reExport.specifier, wanted: /* @__PURE__ */ new Set([reExport.localName]) });
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
for (const specifier of reExports.stars) {
|
|
356
|
+
edges.push({ specifier, wanted: /* @__PURE__ */ new Set([EVERYTHING]) });
|
|
357
|
+
}
|
|
358
|
+
if (isPureBarrel(contents, reExports)) return edges;
|
|
359
|
+
for (const imported of importsIn(contents)) {
|
|
360
|
+
edges.push({ specifier: imported.specifier, wanted: imported.names });
|
|
361
|
+
}
|
|
362
|
+
return edges;
|
|
363
|
+
}
|
|
364
|
+
var RE_EXPORT_NAMED = /(?:^|[\n;])\s*export\s*\{([^}]*)\}\s*from\s*["']([^"']+)["']/g;
|
|
365
|
+
var RE_EXPORT_STAR = /(?:^|[\n;])\s*export\s*\*(?:\s+as\s+[\w$]+)?\s*from\s*["']([^"']+)["']/g;
|
|
366
|
+
function reExportsIn(contents) {
|
|
367
|
+
const named = [];
|
|
368
|
+
const stars = [];
|
|
369
|
+
for (const match of contents.matchAll(RE_EXPORT_NAMED)) {
|
|
370
|
+
for (const clause of match[1].split(",")) {
|
|
371
|
+
const parts = clause.trim().replace(/^type\s+/, "").split(/\s+as\s+/);
|
|
372
|
+
if (!parts[0]) continue;
|
|
373
|
+
named.push({
|
|
374
|
+
localName: parts[0].trim(),
|
|
375
|
+
exposedAs: (parts[1] ?? parts[0]).trim(),
|
|
376
|
+
specifier: match[2]
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
for (const match of contents.matchAll(RE_EXPORT_STAR)) stars.push(match[1]);
|
|
381
|
+
return { named, stars };
|
|
382
|
+
}
|
|
383
|
+
function isPureBarrel(contents, reExports) {
|
|
384
|
+
if (!reExports.named.length && !reExports.stars.length) return false;
|
|
385
|
+
const remainder = contents.replace(RE_EXPORT_NAMED, " ").replace(RE_EXPORT_STAR, " ").replace(/\/\*[\s\S]*?\*\//g, " ").replace(/^[ \t]*\/\/.*$/gm, " ").replace(/(?:^|[\n;])\s*import\b[^;\n]*(?:;|\n)/g, " ").trim();
|
|
386
|
+
return remainder.length < 40;
|
|
387
|
+
}
|
|
388
|
+
var IMPORT = /(?:^|[\n;])\s*import\s+([^;'"]*?)\s*from\s*["']([^"']+)["']/g;
|
|
389
|
+
var SIDE_EFFECT_IMPORT = /(?:^|[\n;])\s*import\s*["']([^"']+)["']/g;
|
|
390
|
+
var DYNAMIC_IMPORT = /\bimport\s*\(\s*["']([^"']+)["']\s*\)/g;
|
|
391
|
+
function importsIn(contents) {
|
|
392
|
+
const found = [];
|
|
393
|
+
for (const match of contents.matchAll(IMPORT)) {
|
|
394
|
+
found.push({ specifier: match[2], names: namesIn(match[1]) });
|
|
395
|
+
}
|
|
396
|
+
for (const match of contents.matchAll(SIDE_EFFECT_IMPORT)) {
|
|
397
|
+
found.push({ specifier: match[1], names: /* @__PURE__ */ new Set([EVERYTHING]) });
|
|
398
|
+
}
|
|
399
|
+
for (const match of contents.matchAll(DYNAMIC_IMPORT)) {
|
|
400
|
+
found.push({ specifier: match[1], names: /* @__PURE__ */ new Set([EVERYTHING]) });
|
|
401
|
+
}
|
|
402
|
+
return found;
|
|
403
|
+
}
|
|
404
|
+
function namesIn(clause) {
|
|
405
|
+
const names = /* @__PURE__ */ new Set();
|
|
406
|
+
const braces = /\{([^}]*)\}/.exec(clause);
|
|
407
|
+
if (/\*\s+as\s+/.test(clause)) names.add(EVERYTHING);
|
|
408
|
+
const beforeBraces = (braces ? clause.slice(0, braces.index) : clause).replace(/,\s*$/, "").trim();
|
|
409
|
+
if (beforeBraces && !beforeBraces.startsWith("*")) names.add("default");
|
|
410
|
+
for (const clauseName of braces?.[1]?.split(",") ?? []) {
|
|
411
|
+
const local = clauseName.trim().replace(/^type\s+/, "").split(/\s+as\s+/)[0]?.trim();
|
|
412
|
+
if (local) names.add(local);
|
|
413
|
+
}
|
|
414
|
+
return names.size ? names : /* @__PURE__ */ new Set([EVERYTHING]);
|
|
415
|
+
}
|
|
416
|
+
function resolve(specifier, importer, known, aliases, baseUrl) {
|
|
417
|
+
if (specifier.startsWith(".")) {
|
|
418
|
+
return candidate(join(dirname(importer), specifier), known);
|
|
419
|
+
}
|
|
420
|
+
for (const [from, to] of Object.entries(aliases)) {
|
|
421
|
+
if (!specifier.startsWith(from)) continue;
|
|
422
|
+
const mapped = candidate(normalise(to + specifier.slice(from.length)), known);
|
|
423
|
+
if (mapped) return mapped;
|
|
424
|
+
}
|
|
425
|
+
if (baseUrl !== void 0) {
|
|
426
|
+
return candidate(join(baseUrl, specifier), known);
|
|
427
|
+
}
|
|
428
|
+
return void 0;
|
|
429
|
+
}
|
|
430
|
+
function candidate(base, known) {
|
|
431
|
+
if (known.has(base) && SOURCE.test(base)) return base;
|
|
432
|
+
const stem = base.replace(/\.(js|jsx|mjs|cjs)$/, "");
|
|
433
|
+
for (const extension of ["tsx", "ts", "jsx", "js"]) {
|
|
434
|
+
for (const path of [`${stem}.${extension}`, `${stem}/index.${extension}`]) {
|
|
435
|
+
if (known.has(path)) return path;
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
return void 0;
|
|
439
|
+
}
|
|
440
|
+
function dirname(path) {
|
|
441
|
+
const cut = path.lastIndexOf("/");
|
|
442
|
+
return cut < 0 ? "" : path.slice(0, cut);
|
|
443
|
+
}
|
|
444
|
+
function join(dir, relative) {
|
|
445
|
+
return normalise(dir ? `${dir}/${relative}` : relative);
|
|
446
|
+
}
|
|
447
|
+
function normalise(path) {
|
|
448
|
+
const out = [];
|
|
449
|
+
for (const segment of path.split("/")) {
|
|
450
|
+
if (!segment || segment === ".") continue;
|
|
451
|
+
if (segment === "..") {
|
|
452
|
+
out.pop();
|
|
453
|
+
continue;
|
|
454
|
+
}
|
|
455
|
+
out.push(segment);
|
|
456
|
+
}
|
|
457
|
+
return out.join("/");
|
|
458
|
+
}
|
|
459
|
+
function parseTsconfig(source) {
|
|
460
|
+
if (source === void 0) return {};
|
|
461
|
+
let out = "";
|
|
462
|
+
let inString = false;
|
|
463
|
+
for (let index = 0; index < source.length; index += 1) {
|
|
464
|
+
const character = source[index];
|
|
465
|
+
const next = source[index + 1];
|
|
466
|
+
if (inString) {
|
|
467
|
+
out += character;
|
|
468
|
+
if (character === "\\") {
|
|
469
|
+
out += next ?? "";
|
|
470
|
+
index += 1;
|
|
471
|
+
} else if (character === '"') {
|
|
472
|
+
inString = false;
|
|
473
|
+
}
|
|
474
|
+
continue;
|
|
475
|
+
}
|
|
476
|
+
if (character === '"') {
|
|
477
|
+
inString = true;
|
|
478
|
+
out += character;
|
|
479
|
+
continue;
|
|
480
|
+
}
|
|
481
|
+
if (character === "/" && next === "/") {
|
|
482
|
+
while (index < source.length && source[index] !== "\n") index += 1;
|
|
483
|
+
out += "\n";
|
|
484
|
+
continue;
|
|
485
|
+
}
|
|
486
|
+
if (character === "/" && next === "*") {
|
|
487
|
+
const close = source.indexOf("*/", index + 2);
|
|
488
|
+
index = close < 0 ? source.length : close + 1;
|
|
489
|
+
out += " ";
|
|
490
|
+
continue;
|
|
491
|
+
}
|
|
492
|
+
out += character;
|
|
493
|
+
}
|
|
494
|
+
try {
|
|
495
|
+
return JSON.parse(out.replace(/,(\s*[}\]])/g, "$1"));
|
|
496
|
+
} catch {
|
|
497
|
+
return {};
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
function aliasesFromTsconfig(tsconfig) {
|
|
501
|
+
const paths = tsconfig?.compilerOptions?.paths;
|
|
502
|
+
if (!paths || typeof paths !== "object") return {};
|
|
503
|
+
const aliases = {};
|
|
504
|
+
for (const [pattern, targets] of Object.entries(paths)) {
|
|
505
|
+
if (!pattern.endsWith("/*") || !Array.isArray(targets)) continue;
|
|
506
|
+
const target = targets.find(
|
|
507
|
+
(value) => typeof value === "string" && value.endsWith("/*")
|
|
508
|
+
);
|
|
509
|
+
if (!target) continue;
|
|
510
|
+
aliases[pattern.slice(0, -1)] = `${normalise(target.slice(0, -1))}/`;
|
|
511
|
+
}
|
|
512
|
+
return aliases;
|
|
513
|
+
}
|
|
514
|
+
function baseUrlFromTsconfig(tsconfig) {
|
|
515
|
+
const baseUrl = tsconfig?.compilerOptions?.baseUrl;
|
|
516
|
+
if (typeof baseUrl !== "string") return void 0;
|
|
517
|
+
const cleaned = normalise(baseUrl.replace(/^\.\/+/, "").replace(/\/+$/, ""));
|
|
518
|
+
return cleaned === "." ? "" : cleaned;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// core/discovery/screen-facts.ts
|
|
522
|
+
function noScreenFacts() {
|
|
523
|
+
return { headings: [], actions: [], links: [], fields: [], notices: [], warnings: [], readFrom: [] };
|
|
524
|
+
}
|
|
525
|
+
var LIMITS = {
|
|
526
|
+
headings: 12,
|
|
527
|
+
actions: 24,
|
|
528
|
+
links: 24,
|
|
529
|
+
fields: 24,
|
|
530
|
+
notices: 20,
|
|
531
|
+
warnings: 12,
|
|
532
|
+
choices: 12,
|
|
533
|
+
/** Longer than this is a paragraph of copy, not a label. */
|
|
534
|
+
label: 120,
|
|
535
|
+
sentence: 300
|
|
536
|
+
};
|
|
537
|
+
var IRREVERSIBLE = /\b(cannot be undone|can't be undone|permanently|permanent|irreversible|no way back|cannot be recovered|unrecoverable|this cannot be reversed)\b/i;
|
|
538
|
+
var DESTRUCTIVE = /\b(delete|deletes|deleting|remove|removes|revoke|revokes|erase|wipe|discard|destroy|overwrite|overwrites|reset|disconnect|unpublish|cancel subscription)\b/i;
|
|
539
|
+
var PERMISSION = /\b(only (?:an?\s+)?(?:owner|admin|administrator)s?\b|your role|not allowed|do(?:es)? not have permission|don't have permission|permission to|requires? (?:an? )?(?:owner|admin|editing|editor)|admins? only|read[- ]only)\b/i;
|
|
540
|
+
function readScreenFacts(files, options = {}) {
|
|
541
|
+
const facts = noScreenFacts();
|
|
542
|
+
const headings = [];
|
|
543
|
+
const actions = [];
|
|
544
|
+
const links = [];
|
|
545
|
+
const fields = [];
|
|
546
|
+
const notices = [];
|
|
547
|
+
const warnings = [];
|
|
548
|
+
const messageDepth = options.messageDepth ?? 2;
|
|
549
|
+
for (const file of files) {
|
|
550
|
+
if (options.skip?.(file.path)) continue;
|
|
551
|
+
const source = withoutComments(file.contents);
|
|
552
|
+
const before = headings.length + actions.length + links.length + fields.length + notices.length;
|
|
553
|
+
readElements(source, { headings, actions, links, fields });
|
|
554
|
+
if ((file.depth ?? 0) <= messageDepth) readMessages(source, notices);
|
|
555
|
+
const after = headings.length + actions.length + links.length + fields.length + notices.length;
|
|
556
|
+
if (after > before) facts.readFrom.push(file.path);
|
|
557
|
+
}
|
|
558
|
+
return finishScreenFacts({ headings, actions, links, fields, notices, readFrom: facts.readFrom });
|
|
559
|
+
}
|
|
560
|
+
function finishScreenFacts(parts) {
|
|
561
|
+
const warnings = [];
|
|
562
|
+
for (const sentence of parts.notices) {
|
|
563
|
+
if (IRREVERSIBLE.test(sentence) || PERMISSION.test(sentence)) warnings.push(sentence);
|
|
564
|
+
}
|
|
565
|
+
for (const label of parts.actions) {
|
|
566
|
+
if (DESTRUCTIVE.test(label)) warnings.push(`"${label}" destroys or replaces something.`);
|
|
567
|
+
}
|
|
568
|
+
return {
|
|
569
|
+
headings: capped(parts.headings, LIMITS.headings),
|
|
570
|
+
actions: capped(parts.actions, LIMITS.actions),
|
|
571
|
+
links: cappedBy(parts.links, (link) => `${link.label ?? ""}|${link.href}`, LIMITS.links),
|
|
572
|
+
fields: cappedBy(parts.fields, (field) => `${field.label}|${field.kind}`, LIMITS.fields),
|
|
573
|
+
notices: capped(parts.notices, LIMITS.notices),
|
|
574
|
+
warnings: capped(warnings, LIMITS.warnings),
|
|
575
|
+
readFrom: parts.readFrom
|
|
576
|
+
};
|
|
577
|
+
}
|
|
578
|
+
var HEADING_TAG = /^h[1-4]$/;
|
|
579
|
+
var BUTTON_TAG = /^(button|Button|MenuItem|Tab)$/;
|
|
580
|
+
var LINK_TAG = /^(a|Link|NavLink)$/;
|
|
581
|
+
var FIELD_TAG = /^(input|Input|textarea|Textarea|select|Select|Checkbox|Radio|Switch|Toggle)$/;
|
|
582
|
+
function readElements(source, into) {
|
|
583
|
+
for (const tag of tagsIn(source)) {
|
|
584
|
+
const attributes = readAttributes(tag.attributes);
|
|
585
|
+
if (HEADING_TAG.test(tag.name)) {
|
|
586
|
+
push(into.headings, visibleText(childrenOf(source, tag)));
|
|
587
|
+
continue;
|
|
588
|
+
}
|
|
589
|
+
push(into.headings, attributes["heading"]);
|
|
590
|
+
if (BUTTON_TAG.test(tag.name)) {
|
|
591
|
+
push(into.actions, visibleText(childrenOf(source, tag)) || attributes["label"] || attributes["aria-label"]);
|
|
592
|
+
continue;
|
|
593
|
+
}
|
|
594
|
+
if (LINK_TAG.test(tag.name)) {
|
|
595
|
+
const href = attributes["href"];
|
|
596
|
+
if (href && !href.startsWith("{")) {
|
|
597
|
+
const label = visibleText(childrenOf(source, tag)) || attributes["aria-label"];
|
|
598
|
+
into.links.push(label ? { label, href } : { href });
|
|
599
|
+
}
|
|
600
|
+
continue;
|
|
601
|
+
}
|
|
602
|
+
if (FIELD_TAG.test(tag.name)) {
|
|
603
|
+
const field = fieldFrom(tag.name, attributes, source, tag);
|
|
604
|
+
if (field) into.fields.push(field);
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
for (const tag of tagsIn(source)) {
|
|
608
|
+
if (tag.name !== "label") continue;
|
|
609
|
+
const text = visibleText(childrenOf(source, tag));
|
|
610
|
+
if (text && !into.fields.some((field) => field.label === text)) {
|
|
611
|
+
into.fields.push({ label: text, kind: "text" });
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
for (const match of source.matchAll(NAVIGATION)) {
|
|
615
|
+
const href = match[1];
|
|
616
|
+
if (href.startsWith("/") && !into.links.some((link) => link.href === href)) {
|
|
617
|
+
into.links.push({ href });
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
var NAVIGATION = /\b(?:router\.(?:push|replace)|redirect|navigate)\s*\(\s*["'`]([^"'`${}]+)["'`]/g;
|
|
622
|
+
function fieldFrom(name, attributes, source, tag) {
|
|
623
|
+
const label = attributes["label"] ?? attributes["aria-label"] ?? attributes["placeholder"] ?? humanise(attributes["name"] ?? attributes["id"] ?? "");
|
|
624
|
+
if (!label) return void 0;
|
|
625
|
+
const kind = /select/i.test(name) ? "select" : /textarea/i.test(name) ? "textarea" : /checkbox|switch|toggle/i.test(name) ? "checkbox" : /radio/i.test(name) ? "radio" : attributes["type"] ?? "text";
|
|
626
|
+
if (kind !== "select") return { label: trim(label, LIMITS.label), kind };
|
|
627
|
+
const choices = [];
|
|
628
|
+
for (const option of tagsIn(childrenOf(source, tag))) {
|
|
629
|
+
if (option.name !== "option") continue;
|
|
630
|
+
const text = visibleText(childrenOf(childrenOf(source, tag), option));
|
|
631
|
+
if (text) choices.push(text);
|
|
632
|
+
}
|
|
633
|
+
return choices.length ? { label: trim(label, LIMITS.label), kind, choices: capped(choices, LIMITS.choices) } : { label: trim(label, LIMITS.label), kind };
|
|
634
|
+
}
|
|
635
|
+
var MESSAGE_CALL = /\b(?:setError|setMessage|setStatus|setWarning|toast(?:\.\w+)?|confirm|alert|notify)\s*\(\s*(["'`])((?:(?!\1)[\s\S]){12,}?)\1/g;
|
|
636
|
+
var MESSAGE_THROW = /\bthrow new (?:\w*Error)\s*\(\s*(["'`])((?:(?!\1)[\s\S]){12,}?)\1/g;
|
|
637
|
+
var MESSAGE_PROPERTY = /(?:^|[\s,{(])(?:error|message|reason|warning|hint|emptyState|helpText)\s*:\s*(["'`])((?:(?!\1)[\s\S]){12,}?)\1/g;
|
|
638
|
+
function isCustomerFacing(text) {
|
|
639
|
+
if (!/[a-z]/i.test(text)) return false;
|
|
640
|
+
const words = text.split(/\s+/).filter(Boolean);
|
|
641
|
+
if (words.length < 4) return false;
|
|
642
|
+
const codey = words.filter((word) => /[._]|[a-z][A-Z]/.test(word)).length;
|
|
643
|
+
if (codey * 3 >= words.length) return false;
|
|
644
|
+
return /[.!?]$/.test(text) || words.length >= 8;
|
|
645
|
+
}
|
|
646
|
+
function readMessages(source, into) {
|
|
647
|
+
for (const pattern of [MESSAGE_CALL, MESSAGE_THROW, MESSAGE_PROPERTY]) {
|
|
648
|
+
for (const match of source.matchAll(pattern)) {
|
|
649
|
+
const text = match[2].replace(/\$\{[^}]*\}/g, "\u2026").replace(/\s+/g, " ").trim();
|
|
650
|
+
if (isCustomerFacing(text)) push(into, trim(text, LIMITS.sentence));
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
function* tagsIn(source) {
|
|
655
|
+
for (let index = 0; index < source.length; index += 1) {
|
|
656
|
+
if (source[index] !== "<") continue;
|
|
657
|
+
const nameMatch = /^<([A-Za-z][\w.-]*)/.exec(source.slice(index, index + 64));
|
|
658
|
+
if (!nameMatch) continue;
|
|
659
|
+
const name = nameMatch[1];
|
|
660
|
+
let cursor = index + nameMatch[0].length;
|
|
661
|
+
let depth2 = 0;
|
|
662
|
+
let quote;
|
|
663
|
+
while (cursor < source.length) {
|
|
664
|
+
const character = source[cursor];
|
|
665
|
+
if (quote) {
|
|
666
|
+
if (character === quote) quote = void 0;
|
|
667
|
+
else if (character === "\\") cursor += 1;
|
|
668
|
+
} else if (character === '"' || character === "'" || character === "`") {
|
|
669
|
+
quote = character;
|
|
670
|
+
} else if (character === "{") {
|
|
671
|
+
depth2 += 1;
|
|
672
|
+
} else if (character === "}") {
|
|
673
|
+
depth2 -= 1;
|
|
674
|
+
} else if (character === ">" && depth2 <= 0) {
|
|
675
|
+
break;
|
|
676
|
+
}
|
|
677
|
+
cursor += 1;
|
|
678
|
+
}
|
|
679
|
+
if (cursor >= source.length) return;
|
|
680
|
+
const raw = source.slice(index + nameMatch[0].length, cursor);
|
|
681
|
+
yield {
|
|
682
|
+
name,
|
|
683
|
+
attributes: raw,
|
|
684
|
+
selfClosing: raw.trimEnd().endsWith("/"),
|
|
685
|
+
end: cursor + 1
|
|
686
|
+
};
|
|
687
|
+
index = cursor;
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
function childrenOf(source, tag) {
|
|
691
|
+
if (tag.selfClosing) return "";
|
|
692
|
+
const open = new RegExp(`<${escapeName(tag.name)}[\\s/>]`, "g");
|
|
693
|
+
const close = `</${tag.name}>`;
|
|
694
|
+
let depth2 = 1;
|
|
695
|
+
let cursor = tag.end;
|
|
696
|
+
while (cursor < source.length) {
|
|
697
|
+
const closeAt = source.indexOf(close, cursor);
|
|
698
|
+
if (closeAt < 0) return "";
|
|
699
|
+
open.lastIndex = cursor;
|
|
700
|
+
let openAt = -1;
|
|
701
|
+
const nested = open.exec(source);
|
|
702
|
+
if (nested && nested.index < closeAt) openAt = nested.index;
|
|
703
|
+
if (openAt >= 0) {
|
|
704
|
+
depth2 += 1;
|
|
705
|
+
cursor = openAt + 1;
|
|
706
|
+
continue;
|
|
707
|
+
}
|
|
708
|
+
depth2 -= 1;
|
|
709
|
+
if (depth2 === 0) return source.slice(tag.end, closeAt);
|
|
710
|
+
cursor = closeAt + close.length;
|
|
711
|
+
}
|
|
712
|
+
return "";
|
|
713
|
+
}
|
|
714
|
+
function escapeName(name) {
|
|
715
|
+
return name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
716
|
+
}
|
|
717
|
+
function visibleText(fragment) {
|
|
718
|
+
if (!fragment) return "";
|
|
719
|
+
let text = fragment;
|
|
720
|
+
let previous = "";
|
|
721
|
+
while (text !== previous) {
|
|
722
|
+
previous = text;
|
|
723
|
+
text = text.replace(/\{[^{}]*\}/g, " ");
|
|
724
|
+
}
|
|
725
|
+
text = text.replace(/<[^>]*>/g, " ").replace(/ /g, " ").replace(/&/g, "&").replace(/—/g, "\u2014").replace(/–/g, "\u2013").replace(/&(?:l|r)squo;/g, "'").replace(/&(?:l|r)dquo;/g, '"').replace(/&#\d+;/g, " ").replace(/\s+/g, " ").trim();
|
|
726
|
+
return /[A-Za-z]/.test(text) ? trim(text, LIMITS.label) : "";
|
|
727
|
+
}
|
|
728
|
+
function readAttributes(raw) {
|
|
729
|
+
const attributes = {};
|
|
730
|
+
for (const match of raw.matchAll(/([A-Za-z_:][\w:.-]*)\s*=\s*(?:"([^"]*)"|'([^']*)'|\{\s*["'`]([^"'`]*)["'`]\s*\})/g)) {
|
|
731
|
+
const value = match[2] ?? match[3] ?? match[4];
|
|
732
|
+
if (value !== void 0 && value.trim()) attributes[match[1]] = value.trim();
|
|
733
|
+
}
|
|
734
|
+
return attributes;
|
|
735
|
+
}
|
|
736
|
+
function withoutComments(source) {
|
|
737
|
+
return source.replace(/\/\*[\s\S]*?\*\//g, " ").replace(/^[ \t]*\/\/.*$/gm, "");
|
|
738
|
+
}
|
|
739
|
+
function humanise(name) {
|
|
740
|
+
const words = name.replace(/[_-]+/g, " ").replace(/([a-z\d])([A-Z])/g, "$1 $2").replace(/\s+/g, " ").trim();
|
|
741
|
+
return words ? words.charAt(0).toUpperCase() + words.slice(1).toLowerCase() : "";
|
|
742
|
+
}
|
|
743
|
+
function scrubExpressions(value) {
|
|
744
|
+
return value.replace(/\$\{[^}]*\}/g, "\u2026").replace(/\s+/g, " ").trim();
|
|
745
|
+
}
|
|
746
|
+
function push(into, value) {
|
|
747
|
+
const text = value === void 0 ? "" : scrubExpressions(value);
|
|
748
|
+
if (!text || !/[A-Za-z]/.test(text)) return;
|
|
749
|
+
if ((text.match(/…/g)?.length ?? 0) > 1) return;
|
|
750
|
+
into.push(trim(text, LIMITS.label));
|
|
751
|
+
}
|
|
752
|
+
function trim(value, limit) {
|
|
753
|
+
return value.length <= limit ? value : `${value.slice(0, limit - 1).trimEnd()}\u2026`;
|
|
754
|
+
}
|
|
755
|
+
function capped(values, limit) {
|
|
756
|
+
return [...new Set(values)].slice(0, limit);
|
|
757
|
+
}
|
|
758
|
+
function cappedBy(values, key, limit) {
|
|
759
|
+
const seen = /* @__PURE__ */ new Set();
|
|
760
|
+
const kept = [];
|
|
761
|
+
for (const value of values) {
|
|
762
|
+
const identity = key(value);
|
|
763
|
+
if (seen.has(identity)) continue;
|
|
764
|
+
seen.add(identity);
|
|
765
|
+
kept.push(value);
|
|
766
|
+
if (kept.length >= limit) break;
|
|
767
|
+
}
|
|
768
|
+
return kept;
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
// core/discovery/read-product.ts
|
|
772
|
+
function readProduct(routes, options) {
|
|
773
|
+
const maxScreens = options.maxScreens ?? 200;
|
|
774
|
+
const opened = /* @__PURE__ */ new Set();
|
|
775
|
+
const contents = /* @__PURE__ */ new Map();
|
|
776
|
+
const read = (path) => {
|
|
777
|
+
if (contents.has(path)) return contents.get(path);
|
|
778
|
+
const found = options.read(path);
|
|
779
|
+
if (found !== void 0) {
|
|
780
|
+
contents.set(path, found);
|
|
781
|
+
opened.add(path);
|
|
782
|
+
}
|
|
783
|
+
return found;
|
|
784
|
+
};
|
|
785
|
+
const documentable = routes.filter((route) => !route.skipped);
|
|
786
|
+
const total = Math.min(documentable.length, maxScreens);
|
|
787
|
+
let done = 0;
|
|
788
|
+
let withFacts = 0;
|
|
789
|
+
const out = routes.map((route) => {
|
|
790
|
+
if (route.skipped || done >= maxScreens) return route;
|
|
791
|
+
const modules = screenModules(route.sourceFiles, { ...options, read });
|
|
792
|
+
const files = [];
|
|
793
|
+
for (const reached of modules) {
|
|
794
|
+
const source = read(reached.path);
|
|
795
|
+
if (source !== void 0) files.push({ path: reached.path, contents: source, depth: reached.depth });
|
|
796
|
+
}
|
|
797
|
+
const facts = readScreenFacts(files);
|
|
798
|
+
done += 1;
|
|
799
|
+
options.onScreen?.(route, done, total);
|
|
800
|
+
if (!hasAnything(facts)) return route;
|
|
801
|
+
withFacts += 1;
|
|
802
|
+
return { ...route, facts };
|
|
803
|
+
});
|
|
804
|
+
return { routes: out, read: withFacts, filesOpened: opened.size };
|
|
805
|
+
}
|
|
806
|
+
function hasAnything(facts) {
|
|
807
|
+
return Boolean(
|
|
808
|
+
facts.headings.length || facts.actions.length || facts.links.length || facts.fields.length || facts.notices.length
|
|
809
|
+
);
|
|
810
|
+
}
|
|
811
|
+
function describeReading(routes) {
|
|
812
|
+
const documentable = routes.filter((route) => !route.skipped);
|
|
813
|
+
const read = documentable.filter((route) => route.facts && hasAnything(route.facts)).length;
|
|
814
|
+
return { screens: documentable.length, read, pathsOnly: read === 0 };
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
// core/markers/propose.ts
|
|
818
|
+
function toLines(contents) {
|
|
819
|
+
const lines = contents.split("\n");
|
|
820
|
+
if (lines.length > 1 && lines[lines.length - 1] === "") lines.pop();
|
|
821
|
+
return lines;
|
|
822
|
+
}
|
|
823
|
+
var COMMENT = {
|
|
824
|
+
ts: "//",
|
|
825
|
+
tsx: "//",
|
|
826
|
+
js: "//",
|
|
827
|
+
jsx: "//",
|
|
828
|
+
mjs: "//",
|
|
829
|
+
cjs: "//",
|
|
830
|
+
go: "//",
|
|
831
|
+
rs: "//",
|
|
832
|
+
java: "//",
|
|
833
|
+
kt: "//",
|
|
834
|
+
swift: "//",
|
|
835
|
+
cs: "//",
|
|
836
|
+
php: "//",
|
|
837
|
+
scala: "//",
|
|
838
|
+
dart: "//",
|
|
839
|
+
rb: "#",
|
|
840
|
+
py: "#",
|
|
841
|
+
sh: "#",
|
|
842
|
+
bash: "#",
|
|
843
|
+
yml: "#",
|
|
844
|
+
yaml: "#",
|
|
845
|
+
ex: "#",
|
|
846
|
+
exs: "#",
|
|
847
|
+
erb: "#"
|
|
848
|
+
};
|
|
849
|
+
var DIRECTIVE = /^\s*["'](use (client|server|strict))["'];?\s*$/;
|
|
850
|
+
var SHEBANG = /^#!/;
|
|
851
|
+
function commentFor(path) {
|
|
852
|
+
const extension = path.slice(path.lastIndexOf(".") + 1).toLowerCase();
|
|
853
|
+
return COMMENT[extension];
|
|
854
|
+
}
|
|
855
|
+
function insertionLine(lines) {
|
|
856
|
+
let index = 0;
|
|
857
|
+
if (lines[0] && SHEBANG.test(lines[0])) index += 1;
|
|
858
|
+
while (index < lines.length && (lines[index].trim() === "" || DIRECTIVE.test(lines[index]))) {
|
|
859
|
+
index += 1;
|
|
860
|
+
}
|
|
861
|
+
if (lines[index]?.trimStart().startsWith("/*")) {
|
|
862
|
+
while (index < lines.length && !lines[index].includes("*/")) index += 1;
|
|
863
|
+
index += 1;
|
|
864
|
+
}
|
|
865
|
+
return index + 1;
|
|
866
|
+
}
|
|
867
|
+
function proposeMarkers(options) {
|
|
868
|
+
const proposals = [];
|
|
869
|
+
const matched = /* @__PURE__ */ new Set();
|
|
870
|
+
const allSlugs = new Set(Object.values(options.slugsByFile));
|
|
871
|
+
let alreadyMarked = 0;
|
|
872
|
+
for (const file of options.files) {
|
|
873
|
+
const slug = options.slugsByFile[file.path];
|
|
874
|
+
if (!slug) continue;
|
|
875
|
+
const comment = commentFor(file.path);
|
|
876
|
+
if (!comment) continue;
|
|
877
|
+
matched.add(slug);
|
|
878
|
+
if (new RegExp(`@helpdoc[:\\s]+${escapeRegExp(slug)}(?![A-Za-z0-9._/-])`).test(file.contents)) {
|
|
879
|
+
alreadyMarked += 1;
|
|
880
|
+
continue;
|
|
881
|
+
}
|
|
882
|
+
const lines = toLines(file.contents);
|
|
883
|
+
proposals.push({
|
|
884
|
+
file: file.path,
|
|
885
|
+
line: insertionLine(lines),
|
|
886
|
+
slug,
|
|
887
|
+
text: `${comment} @helpdoc ${slug}`,
|
|
888
|
+
because: `this file is part of the screen "${slug}" documents`
|
|
889
|
+
});
|
|
890
|
+
}
|
|
891
|
+
return {
|
|
892
|
+
proposals,
|
|
893
|
+
alreadyMarked,
|
|
894
|
+
// Named rather than hidden: an article nothing points at can never be
|
|
895
|
+
// flagged, and the gate should be honest about its own coverage.
|
|
896
|
+
unmatchedSlugs: [...allSlugs].filter((slug) => !matched.has(slug)).sort()
|
|
897
|
+
};
|
|
898
|
+
}
|
|
899
|
+
function markersToDiff(files, proposals) {
|
|
900
|
+
const byFile = /* @__PURE__ */ new Map();
|
|
901
|
+
for (const proposal of proposals) {
|
|
902
|
+
const list = byFile.get(proposal.file);
|
|
903
|
+
if (list) list.push(proposal);
|
|
904
|
+
else byFile.set(proposal.file, [proposal]);
|
|
905
|
+
}
|
|
906
|
+
const contents = new Map(files.map((file) => [file.path, file.contents]));
|
|
907
|
+
const chunks = [];
|
|
908
|
+
for (const [path, fileProposals] of [...byFile.entries()].sort(([a], [b]) => a.localeCompare(b))) {
|
|
909
|
+
const source = contents.get(path);
|
|
910
|
+
if (source === void 0) continue;
|
|
911
|
+
const lines = toLines(source);
|
|
912
|
+
chunks.push(`--- a/${path}`, `+++ b/${path}`);
|
|
913
|
+
for (const proposal of [...fileProposals].sort((a, b) => a.line - b.line)) {
|
|
914
|
+
const start = Math.max(1, proposal.line - 3);
|
|
915
|
+
const before = lines.slice(start - 1, proposal.line - 1);
|
|
916
|
+
const after = lines.slice(proposal.line - 1, proposal.line - 1 + 3);
|
|
917
|
+
chunks.push(
|
|
918
|
+
`@@ -${start},${before.length + after.length} +${start},${before.length + after.length + 1} @@`,
|
|
919
|
+
...before.map((line) => ` ${line}`),
|
|
920
|
+
`+${proposal.text}`,
|
|
921
|
+
...after.map((line) => ` ${line}`)
|
|
922
|
+
);
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
return chunks.join("\n");
|
|
926
|
+
}
|
|
927
|
+
function markersPullRequestBody(result) {
|
|
928
|
+
const lines = [
|
|
929
|
+
"This adds one-line comments that link screens in this repository to the help articles that describe them.",
|
|
930
|
+
"",
|
|
931
|
+
"**Nothing here changes behaviour.** Each line is a comment. Once merged, a change to one of these files tells our help desk which article may now be out of date, so somebody can check it before a customer does.",
|
|
932
|
+
"",
|
|
933
|
+
`${result.proposals.length} marker${result.proposals.length === 1 ? "" : "s"} proposed.`
|
|
934
|
+
];
|
|
935
|
+
if (result.alreadyMarked > 0) {
|
|
936
|
+
lines.push(`${result.alreadyMarked} file${result.alreadyMarked === 1 ? " already carries" : "s already carry"} the right marker and ${result.alreadyMarked === 1 ? "was" : "were"} left alone.`);
|
|
937
|
+
}
|
|
938
|
+
lines.push(
|
|
939
|
+
"",
|
|
940
|
+
"**A wrong one costs nothing.** Delete the line and the article stops being linked to that file. If we missed one, the screen is still matched by its route \u2014 this only makes the match more precise."
|
|
941
|
+
);
|
|
942
|
+
if (result.unmatchedSlugs.length > 0) {
|
|
943
|
+
lines.push(
|
|
944
|
+
"",
|
|
945
|
+
`We could not find code for ${result.unmatchedSlugs.length} article${result.unmatchedSlugs.length === 1 ? "" : "s"}: ${result.unmatchedSlugs.join(", ")}. Those will not be flagged when the code behind them changes.`
|
|
946
|
+
);
|
|
947
|
+
}
|
|
948
|
+
return lines.join("\n");
|
|
949
|
+
}
|
|
950
|
+
function escapeRegExp(value) {
|
|
951
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
952
|
+
}
|
|
953
|
+
function slugsByFileFromPlan(needs) {
|
|
954
|
+
const mapping = {};
|
|
955
|
+
const rankOf = {};
|
|
956
|
+
for (const need of needs) {
|
|
957
|
+
for (const file of need.sourceRefs) {
|
|
958
|
+
const incumbent = rankOf[file];
|
|
959
|
+
if (incumbent === void 0 || need.rank < incumbent) {
|
|
960
|
+
mapping[file] = need.slug;
|
|
961
|
+
rankOf[file] = need.rank;
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
return mapping;
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
// adapters/map-cli.ts
|
|
969
|
+
function repositoryFiles(cwd) {
|
|
970
|
+
return execFileSync("git", ["ls-files"], { cwd, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 }).split("\n").filter(Boolean);
|
|
971
|
+
}
|
|
972
|
+
async function runMap(options) {
|
|
973
|
+
const files = repositoryFiles(options.cwd);
|
|
974
|
+
const inventory = readNextAppRouter(files, options.root);
|
|
975
|
+
if (!inventory.routes.length) {
|
|
976
|
+
throw new Error(
|
|
977
|
+
`No routes found under ${options.root}. Run this from your repository root, or pass --root.`
|
|
978
|
+
);
|
|
979
|
+
}
|
|
980
|
+
const lines = [`Found ${inventory.routes.length} screens under ${options.root}.`];
|
|
981
|
+
let routes = inventory.routes;
|
|
982
|
+
if (options.facts !== false) {
|
|
983
|
+
const tsconfig = readTsconfig(options.cwd);
|
|
984
|
+
const read = readProduct(inventory.routes, {
|
|
985
|
+
files,
|
|
986
|
+
read: (path) => {
|
|
987
|
+
try {
|
|
988
|
+
return readFileSync(join2(options.cwd, path), "utf8");
|
|
989
|
+
} catch {
|
|
990
|
+
return void 0;
|
|
991
|
+
}
|
|
992
|
+
},
|
|
993
|
+
aliases: aliasesFromTsconfig(tsconfig),
|
|
994
|
+
baseUrl: baseUrlFromTsconfig(tsconfig)
|
|
995
|
+
});
|
|
996
|
+
routes = read.routes;
|
|
997
|
+
const reading = describeReading(routes);
|
|
998
|
+
lines.push(
|
|
999
|
+
reading.pathsOnly ? "Could not read what any of them show. The help desk will have their addresses and nothing else, so its articles will be written from URLs. Check --root points at your app." : `Read what ${reading.read} of ${reading.screens} of them show, from ${read.filesOpened} files.`
|
|
1000
|
+
);
|
|
1001
|
+
}
|
|
1002
|
+
const plan = buildArticlePlan({ routes });
|
|
1003
|
+
lines.push("", formatArticlePlan(plan, 10));
|
|
1004
|
+
if (options.markersOut) {
|
|
1005
|
+
const mapping = slugsByFileFromPlan(plan.needs);
|
|
1006
|
+
const sources = Object.keys(mapping).map((path) => ({
|
|
1007
|
+
path,
|
|
1008
|
+
contents: readFileSync(join2(options.cwd, path), "utf8")
|
|
1009
|
+
}));
|
|
1010
|
+
const proposed = proposeMarkers({ files: sources, slugsByFile: mapping });
|
|
1011
|
+
const diff = markersToDiff(sources, proposed.proposals);
|
|
1012
|
+
writeFileSync(options.markersOut, diff ? `${diff}
|
|
1013
|
+
` : "");
|
|
1014
|
+
writeFileSync(`${options.markersOut}.md`, markersPullRequestBody(proposed));
|
|
1015
|
+
lines.push(
|
|
1016
|
+
"",
|
|
1017
|
+
proposed.proposals.length ? `Wrote ${proposed.proposals.length} marker(s) to ${options.markersOut}. Apply with:
|
|
1018
|
+
git apply ${options.markersOut}` : "Every file already carries its marker \u2014 nothing to propose."
|
|
1019
|
+
);
|
|
1020
|
+
}
|
|
1021
|
+
let sent = false;
|
|
1022
|
+
if (options.token && options.to) {
|
|
1023
|
+
const response = await fetch(new URL("/api/helpdesk/map", options.to).toString(), {
|
|
1024
|
+
method: "POST",
|
|
1025
|
+
headers: { "content-type": "application/json", authorization: `Bearer ${options.token}` },
|
|
1026
|
+
body: JSON.stringify({
|
|
1027
|
+
framework: inventory.framework,
|
|
1028
|
+
repository: originOf(options.cwd),
|
|
1029
|
+
// Where these were read from, so the workflow the founder installs
|
|
1030
|
+
// later watches this folder rather than guessing `src/app`.
|
|
1031
|
+
routerRoot: options.root,
|
|
1032
|
+
// Paths, filenames, and the words printed on each screen. Never source.
|
|
1033
|
+
routes
|
|
1034
|
+
})
|
|
1035
|
+
});
|
|
1036
|
+
if (!response.ok) {
|
|
1037
|
+
const body = await response.text().catch(() => "");
|
|
1038
|
+
throw new Error(`The help desk refused the map (${response.status}). ${body}`);
|
|
1039
|
+
}
|
|
1040
|
+
sent = true;
|
|
1041
|
+
lines.push("", "Sent. Whoever asked you to run this can see it now \u2014 you are done.");
|
|
1042
|
+
} else if (options.token) {
|
|
1043
|
+
lines.push("", "No destination given. Pass --to https://your-helpdesk.example to send this.");
|
|
1044
|
+
}
|
|
1045
|
+
return {
|
|
1046
|
+
routes: routes.length,
|
|
1047
|
+
read: describeReading(routes).read,
|
|
1048
|
+
sent,
|
|
1049
|
+
report: lines.join("\n")
|
|
1050
|
+
};
|
|
1051
|
+
}
|
|
1052
|
+
function readTsconfig(cwd) {
|
|
1053
|
+
for (const name of ["tsconfig.json", "jsconfig.json"]) {
|
|
1054
|
+
try {
|
|
1055
|
+
return parseTsconfig(readFileSync(join2(cwd, name), "utf8"));
|
|
1056
|
+
} catch {
|
|
1057
|
+
continue;
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
return {};
|
|
1061
|
+
}
|
|
1062
|
+
function originOf(cwd) {
|
|
1063
|
+
try {
|
|
1064
|
+
return execFileSync("git", ["remote", "get-url", "origin"], { cwd, encoding: "utf8" }).trim();
|
|
1065
|
+
} catch {
|
|
1066
|
+
return void 0;
|
|
1067
|
+
}
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
// adapters/map-bin.ts
|
|
1071
|
+
function arg(name) {
|
|
1072
|
+
const index = process.argv.indexOf(`--${name}`);
|
|
1073
|
+
return index === -1 ? void 0 : process.argv[index + 1];
|
|
1074
|
+
}
|
|
1075
|
+
async function main() {
|
|
1076
|
+
const cwd = process.cwd();
|
|
1077
|
+
const root = arg("root") ?? detectRouterRoot(repositoryFiles(cwd));
|
|
1078
|
+
if (!root) {
|
|
1079
|
+
console.error(
|
|
1080
|
+
"Could not find a Next.js app router here. Run this from your repository root, or pass --root <dir>."
|
|
1081
|
+
);
|
|
1082
|
+
process.exit(1);
|
|
1083
|
+
}
|
|
1084
|
+
const result = await runMap({
|
|
1085
|
+
cwd,
|
|
1086
|
+
root,
|
|
1087
|
+
...arg("token") === void 0 ? {} : { token: arg("token") },
|
|
1088
|
+
...arg("to") === void 0 ? {} : { to: arg("to") },
|
|
1089
|
+
...arg("markers") === void 0 ? {} : { markersOut: arg("markers") },
|
|
1090
|
+
...arg("facts") === "off" ? { facts: false } : {}
|
|
1091
|
+
});
|
|
1092
|
+
console.log(result.report);
|
|
1093
|
+
}
|
|
1094
|
+
main().catch((error) => {
|
|
1095
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
1096
|
+
process.exit(1);
|
|
1097
|
+
});
|