@akanjs/devkit 3.1.1-rc.18 → 3.1.1-rc.19
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/akanContext.ts +85 -2
- package/codeAgent/feedback/PreviewFeedback.ts +2 -2
- package/codeAgent/index.ts +2 -2
- package/codeAgent/skills/akan-debug/SKILL.md +37 -3
- package/codeAgent/tools/AkanCodePlugins.ts +1 -1
- package/codeAgent/tools/akanCodePlugins.test.ts +0 -28
- package/executors.ts +16 -0
- package/package.json +3 -2
- package/{codeAgent/feedback → preview}/PreviewChrome.ts +13 -7
- package/preview/PreviewView.ts +219 -0
- package/preview/index.ts +2 -0
- package/preview/previewView.test.ts +76 -0
- package/routeSourceValidator.test.ts +11 -2
- package/routeSourceValidator.ts +14 -2
- package/codeAgent/feedback/PreviewView.ts +0 -95
- /package/{codeAgent/feedback → preview}/previewChrome.test.ts +0 -0
package/akanContext.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { readdir } from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
|
-
import { capitalize, isRecord } from "akanjs/common";
|
|
3
|
+
import { capitalize, isRecord, parseRouteModuleKey } from "akanjs/common";
|
|
4
4
|
import { extractBlockVersion, readDevkitVersion } from "./agentsIndex";
|
|
5
5
|
import { AppExecutor, LibExecutor, type SysExecutor, type WorkspaceExecutor } from "./executors";
|
|
6
6
|
import { FileSys } from "./fileSys";
|
|
@@ -48,12 +48,25 @@ export interface AkanModuleContext {
|
|
|
48
48
|
files: string[];
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
+
export interface AkanRouteContext {
|
|
52
|
+
/** The URL pattern without the `/:lang` segment every route starts with. */
|
|
53
|
+
path: string;
|
|
54
|
+
file: string;
|
|
55
|
+
search: string[];
|
|
56
|
+
/** The page or a layout above it calls `.guard()`. */
|
|
57
|
+
guarded: boolean;
|
|
58
|
+
devOnly: boolean;
|
|
59
|
+
}
|
|
60
|
+
|
|
51
61
|
export interface AkanSysContext {
|
|
52
62
|
type: "app" | "lib";
|
|
53
63
|
name: string;
|
|
54
64
|
path: string;
|
|
55
65
|
hasConfig: boolean;
|
|
56
66
|
modules: AkanModuleContext[];
|
|
67
|
+
/** Listed for the app `--app` names only; reading them parses every route file. */
|
|
68
|
+
routes?: AkanRouteContext[];
|
|
69
|
+
routeError?: string;
|
|
57
70
|
}
|
|
58
71
|
|
|
59
72
|
export interface AkanPackageContext {
|
|
@@ -557,6 +570,34 @@ const getSysModules = async (
|
|
|
557
570
|
return modules.sort((a, b) => `${a.sysName}:${a.path}`.localeCompare(`${b.sysName}:${b.path}`));
|
|
558
571
|
};
|
|
559
572
|
|
|
573
|
+
const getAppRoutes = async (app: AppExecutor, workspaceRoot: string): Promise<AkanRouteContext[]> => {
|
|
574
|
+
const sources = await app.getRouteSources();
|
|
575
|
+
const guardedLayouts = new Set<string>();
|
|
576
|
+
const pages: { source: (typeof sources)[number]; parsed: ReturnType<typeof parseRouteModuleKey> }[] = [];
|
|
577
|
+
for (const source of sources) {
|
|
578
|
+
const parsed = parseRouteModuleKey(source.key);
|
|
579
|
+
if (parsed.kind === "layout" && source.info.chain?.guarded) guardedLayouts.add(parsed.routeSegments.join("/"));
|
|
580
|
+
else if (parsed.kind === "page") pages.push({ source, parsed });
|
|
581
|
+
}
|
|
582
|
+
return pages
|
|
583
|
+
.map(({ source, parsed }) => {
|
|
584
|
+
const segments = parsed.routeSegments;
|
|
585
|
+
//? The layouts that wrap a page, as the route seed index walks them: an `_index` sits inside its own folder.
|
|
586
|
+
const reach = parsed.leaf === "_index" ? segments.length : segments.length - 1;
|
|
587
|
+
const underGuard = Array.from({ length: reach + 1 }, (_, idx) => segments.slice(0, idx).join("/")).some(
|
|
588
|
+
(prefix) => guardedLayouts.has(prefix),
|
|
589
|
+
);
|
|
590
|
+
return {
|
|
591
|
+
path: parsed.pattern.replace(/^\/:lang/, "") || "/",
|
|
592
|
+
file: path.relative(workspaceRoot, source.absPath),
|
|
593
|
+
search: source.info.chain?.search ?? [],
|
|
594
|
+
guarded: !!source.info.chain?.guarded || underGuard,
|
|
595
|
+
devOnly: source.info.devOnly,
|
|
596
|
+
};
|
|
597
|
+
})
|
|
598
|
+
.sort((a, b) => a.path.localeCompare(b.path));
|
|
599
|
+
};
|
|
600
|
+
|
|
560
601
|
const getSysContext = async (
|
|
561
602
|
workspace: WorkspaceExecutor,
|
|
562
603
|
type: "app" | "lib",
|
|
@@ -564,7 +605,7 @@ const getSysContext = async (
|
|
|
564
605
|
options: AkanContextOptions,
|
|
565
606
|
): Promise<AkanSysContext> => {
|
|
566
607
|
const sys = type === "app" ? AppExecutor.from(workspace, name) : LibExecutor.from(workspace, name);
|
|
567
|
-
|
|
608
|
+
const context: AkanSysContext = {
|
|
568
609
|
type,
|
|
569
610
|
name,
|
|
570
611
|
path: `${type}s/${name}`,
|
|
@@ -574,6 +615,12 @@ const getSysContext = async (
|
|
|
574
615
|
module: options.module,
|
|
575
616
|
}),
|
|
576
617
|
};
|
|
618
|
+
if (sys instanceof AppExecutor && options.app === name)
|
|
619
|
+
await getAppRoutes(sys, workspace.workspaceRoot).then(
|
|
620
|
+
(routes) => (context.routes = routes),
|
|
621
|
+
(error: unknown) => (context.routeError = error instanceof Error ? error.message : String(error)),
|
|
622
|
+
);
|
|
623
|
+
return context;
|
|
577
624
|
};
|
|
578
625
|
|
|
579
626
|
export class AkanContextAnalyzer {
|
|
@@ -986,6 +1033,12 @@ export class AkanContextAnalyzer {
|
|
|
986
1033
|
...context.pkgs.map((pkg) => `- ${pkg.name}${pkg.version ? ` (${pkg.version})` : ""}`),
|
|
987
1034
|
);
|
|
988
1035
|
|
|
1036
|
+
for (const app of context.apps) {
|
|
1037
|
+
if (app.routeError) lines.push("", `## Routes: ${app.name}`, `- Not read: ${app.routeError}`);
|
|
1038
|
+
else if (app.routes)
|
|
1039
|
+
lines.push("", `## Routes: ${app.name}`, ...AkanContextAnalyzer.#routeLines(app.name, app.routes));
|
|
1040
|
+
}
|
|
1041
|
+
|
|
989
1042
|
const modules = AkanContextAnalyzer.findModules(context, moduleName);
|
|
990
1043
|
lines.push("", "## Modules");
|
|
991
1044
|
for (const module of modules) {
|
|
@@ -1000,4 +1053,34 @@ export class AkanContextAnalyzer {
|
|
|
1000
1053
|
lines.push("", "## Validation", ...context.validationCommands.map((command) => `- \`${command}\``));
|
|
1001
1054
|
return `${lines.join("\n")}\n`;
|
|
1002
1055
|
}
|
|
1056
|
+
|
|
1057
|
+
static readonly routeListLimit = 60;
|
|
1058
|
+
|
|
1059
|
+
//? Past the limit the biggest top folders fold into one line each: a docs section alone would fill the context.
|
|
1060
|
+
static #routeLines(appName: string, routes: AkanRouteContext[]) {
|
|
1061
|
+
const folderOf = (route: AkanRouteContext) => route.file.split("/").slice(0, 4).join("/");
|
|
1062
|
+
const groups = Map.groupBy(routes, folderOf);
|
|
1063
|
+
const folded = new Set<string>();
|
|
1064
|
+
let listed = routes.length;
|
|
1065
|
+
for (const [folder, members] of [...groups].sort((a, b) => b[1].length - a[1].length)) {
|
|
1066
|
+
if (listed <= AkanContextAnalyzer.routeListLimit) break;
|
|
1067
|
+
folded.add(folder);
|
|
1068
|
+
listed -= members.length;
|
|
1069
|
+
}
|
|
1070
|
+
const lines = [`Paths omit the \`/:lang\` prefix; \`akan preview ${appName} <path>\` opens one.`];
|
|
1071
|
+
for (const route of routes) {
|
|
1072
|
+
if (folded.has(folderOf(route))) continue;
|
|
1073
|
+
const search = route.search.length ? ` ?${route.search.join("&")}` : "";
|
|
1074
|
+
const marks = `${route.guarded ? " (guarded)" : ""}${route.devOnly ? " (dev only)" : ""}`;
|
|
1075
|
+
lines.push(`- ${route.path}${search}${marks} — ${route.file}`);
|
|
1076
|
+
}
|
|
1077
|
+
for (const folder of folded) {
|
|
1078
|
+
const members = groups.get(folder) ?? [];
|
|
1079
|
+
const examples = members.slice(0, 3).map((route) => route.path);
|
|
1080
|
+
lines.push(
|
|
1081
|
+
`- ${folder}/: ${members.length} routes folded (${examples.join(", ")}, …); \`--format json\` lists every route`,
|
|
1082
|
+
);
|
|
1083
|
+
}
|
|
1084
|
+
return lines;
|
|
1085
|
+
}
|
|
1003
1086
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
+
import { PreviewView } from "../../preview/PreviewView";
|
|
1
2
|
import { AkanEditScope } from "../tools/AkanEditScope";
|
|
2
|
-
import { PreviewView } from "./PreviewView";
|
|
3
3
|
import type { TurnFeedbackFinding, TurnFeedbackSource } from "./TurnFeedback";
|
|
4
4
|
|
|
5
5
|
export interface PreviewFeedbackOptions {
|
|
@@ -35,7 +35,7 @@ export class PreviewFeedback implements TurnFeedbackSource {
|
|
|
35
35
|
for (const route of this.#options.routes ?? ["/en/"]) {
|
|
36
36
|
const url = new URL(route, this.#options.previewUrl).toString();
|
|
37
37
|
const probe = await PreviewView.probe(url, { screenshot: !!this.#options.canSeeImages });
|
|
38
|
-
const problems = [...probe.pageErrors, ...probe.consoleErrors, ...probe.domFindings];
|
|
38
|
+
const problems = [...probe.pageErrors, ...probe.consoleErrors, ...probe.consoleWarnings, ...probe.domFindings];
|
|
39
39
|
if (!problems.length) continue;
|
|
40
40
|
findings.push(`**${url}**\n${problems.map((line) => `- ${line}`).join("\n")}`);
|
|
41
41
|
if (probe.screenshot) images.push({ data: probe.screenshot, mimeType: "image/png" });
|
package/codeAgent/index.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
// Importing this barrel loads the engine (~122MiB resident); a host needing cheap pieces imports the owning module.
|
|
2
2
|
// agent/consoleToStderr is deliberately absent: it takes over globalThis.console, so an RPC entry imports it first.
|
|
3
|
+
|
|
4
|
+
export * from "../preview";
|
|
3
5
|
export * from "./agent/AkanCodeCompaction";
|
|
4
6
|
export * from "./agent/AkanCodeServices";
|
|
5
7
|
export * from "./agent/akanCodeModel";
|
|
@@ -17,9 +19,7 @@ export * from "./agent/CodeSessionFork";
|
|
|
17
19
|
export * from "./agent/CodeSessionIndex";
|
|
18
20
|
export * from "./agent/SubagentPool";
|
|
19
21
|
export * from "./feedback/DevLogFeedback";
|
|
20
|
-
export * from "./feedback/PreviewChrome";
|
|
21
22
|
export * from "./feedback/PreviewFeedback";
|
|
22
|
-
export * from "./feedback/PreviewView";
|
|
23
23
|
export * from "./feedback/TurnFeedback";
|
|
24
24
|
export * from "./feedback/VerifyFeedback";
|
|
25
25
|
export * from "./tools/AkanCli";
|
|
@@ -1,10 +1,42 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: akan-debug
|
|
3
|
-
description: "
|
|
3
|
+
description: "Check and debug a running Akan app: akan preview for what a page shows and throws, where the dev-server log is, how to read it, and how to tell a runtime failure from a build one."
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Akan debug
|
|
7
7
|
|
|
8
|
+
## See the change run: `akan preview`
|
|
9
|
+
|
|
10
|
+
`akan preview <app> [paths]` opens pages of the app's dev server in a headless browser and prints, per page,
|
|
11
|
+
the HTTP status, the title, a slice of the visible text, the uncaught errors and rejections the page reported,
|
|
12
|
+
the server errors logged meanwhile, console errors and warnings, and layout smells. It exits 1 when a page failed.
|
|
13
|
+
|
|
14
|
+
- It uses the dev server that answers for this app and checkout. With none, it starts one for the run and
|
|
15
|
+
stops it after; `--keep` leaves it up for the next preview. It never starts a second server beside a
|
|
16
|
+
running one.
|
|
17
|
+
- Paths are comma-separated and get the default locale when they lack one: `akan preview <app> /board,/board/new`.
|
|
18
|
+
`--changed` adds the pages your uncommitted page and layout edits reach. A dynamic route needs a concrete
|
|
19
|
+
path (`/memo/1`); `akan context --app <app>` lists every route with its params and search names.
|
|
20
|
+
- The viewport is a 390x844 phone; `--desktop` is 1280x800. `--csr` loads each page in the CSR boot the native
|
|
21
|
+
shells load. `--screenshot` saves a PNG per page under `local/apps/<app>/runtime/preview/`, for a model
|
|
22
|
+
that reads images. `--format json` gives the same report as data.
|
|
23
|
+
|
|
24
|
+
### Which platforms that covers
|
|
25
|
+
|
|
26
|
+
- Every change: the server-rendered web page and, with `--csr`, the bundle the iOS, Android and desktop
|
|
27
|
+
shells run.
|
|
28
|
+
- A change to `native/`, `plugin/`, an `isNativeApp()` branch, `native` in `akan.config.ts` or an
|
|
29
|
+
`@akanjs/native` plugin also needs that shell: `akan start-ios`, `start-android` or `start-desktop` on top of
|
|
30
|
+
the running dev server. Errors thrown in the shell reach the same dev-server log as `ClientReport` lines
|
|
31
|
+
tagged with the platform and target. Capture the shell with `xcrun simctl io booted screenshot <file>.png` or
|
|
32
|
+
`adb exec-out screencap -p > <file>.png`. When you cannot run the shell, say so: "not checked on iOS".
|
|
33
|
+
- Real devices, memory, OTA updates and store builds are for whoever releases.
|
|
34
|
+
|
|
35
|
+
### What goes in your answer
|
|
36
|
+
|
|
37
|
+
The verdict lines of the pages you changed (`ok /en/board 200 "Board" 1.4s`) and every finding you left
|
|
38
|
+
unfixed. "Not opened in a browser" is not an answer once `akan preview` can run.
|
|
39
|
+
|
|
8
40
|
## The log is a file, and that file is the whole session
|
|
9
41
|
|
|
10
42
|
A supervised `akan start` writes **`local/apps/<app>/runtime/dev.log`** — no ANSI, nothing truncated, every
|
|
@@ -17,8 +49,10 @@ what survives in the scrollback is bordered and truncated to the pane.
|
|
|
17
49
|
- `tail -n 200 local/apps/<app>/runtime/dev.log` after an edit, to see what the reload produced.
|
|
18
50
|
- `akan logs <app>` tails a running server with `--level --grep --endpoint --trace --child --role --origin
|
|
19
51
|
--since` and `--json`.
|
|
20
|
-
- The dev server's URL is printed in that log
|
|
21
|
-
|
|
52
|
+
- The dev server's URL is printed in that log. A server `akan preview` started logs to
|
|
53
|
+
`local/apps/<app>/runtime/preview/server.log` instead.
|
|
54
|
+
- An uncaught error or unhandled rejection in a page reaches the server log as a `ClientReport` error line,
|
|
55
|
+
naming the page, the client (`ssr` or `csr`) and the native target.
|
|
22
56
|
|
|
23
57
|
## Which loop you are in
|
|
24
58
|
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import type { InlineExtension } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import type { CodeAgentMcpStatus, CodeAgentProfile, CodeAgentQuestion, CodeAgentSubagent } from "akanjs/common";
|
|
3
3
|
import type { Workspace } from "../../commandDecorators";
|
|
4
|
+
import { PreviewView } from "../../preview/PreviewView";
|
|
4
5
|
import type { CodeAgentModelRef } from "../agent/akanCodeModel";
|
|
5
6
|
import type { CodeMailbox } from "../agent/CodeMailbox";
|
|
6
7
|
import { SubagentPool, type SubagentSpend } from "../agent/SubagentPool";
|
|
7
8
|
import { DevLogFeedback } from "../feedback/DevLogFeedback";
|
|
8
9
|
import { PreviewFeedback } from "../feedback/PreviewFeedback";
|
|
9
|
-
import { PreviewView } from "../feedback/PreviewView";
|
|
10
10
|
import { TurnFeedback, type TurnFeedbackSource } from "../feedback/TurnFeedback";
|
|
11
11
|
import { VerifyFeedback } from "../feedback/VerifyFeedback";
|
|
12
12
|
import { AkanToolPack } from "./AkanToolPack";
|
|
@@ -5,7 +5,6 @@ import { makeCliTempWorkspace, tempRoots, writeText } from "../../testHelpers";
|
|
|
5
5
|
import { CodeAgent, type CodeAgentOptions } from "../agent/CodeAgent";
|
|
6
6
|
import { SubagentPool } from "../agent/SubagentPool";
|
|
7
7
|
import { DevLogFeedback } from "../feedback/DevLogFeedback";
|
|
8
|
-
import { PreviewView } from "../feedback/PreviewView";
|
|
9
8
|
import { TurnFeedback, type TurnFeedbackSource } from "../feedback/TurnFeedback";
|
|
10
9
|
import { AkanCodePlugins } from "./AkanCodePlugins";
|
|
11
10
|
import { AkanEditScope } from "./AkanEditScope";
|
|
@@ -225,33 +224,6 @@ describe("AkanEnvKeys", () => {
|
|
|
225
224
|
});
|
|
226
225
|
});
|
|
227
226
|
|
|
228
|
-
describe("PreviewView", () => {
|
|
229
|
-
test("reports whether this Bun build can drive a headless page at all", () => {
|
|
230
|
-
expect(typeof PreviewView.available).toBe("boolean");
|
|
231
|
-
});
|
|
232
|
-
|
|
233
|
-
test.skipIf(!PreviewView.available)(
|
|
234
|
-
"finds an empty body and a console error on a real page",
|
|
235
|
-
async () => {
|
|
236
|
-
const server = Bun.serve({
|
|
237
|
-
port: 0,
|
|
238
|
-
fetch: () =>
|
|
239
|
-
new Response('<!doctype html><html><body></body><script>console.error("boom")</script></html>', {
|
|
240
|
-
headers: { "content-type": "text/html" },
|
|
241
|
-
}),
|
|
242
|
-
});
|
|
243
|
-
try {
|
|
244
|
-
const probe = await PreviewView.probe(`http://localhost:${server.port}/`);
|
|
245
|
-
expect(probe.pageErrors.join(" ")).toContain("rendered empty");
|
|
246
|
-
expect(probe.consoleErrors.join(" ")).toContain("boom");
|
|
247
|
-
} finally {
|
|
248
|
-
server.stop(true);
|
|
249
|
-
}
|
|
250
|
-
},
|
|
251
|
-
30_000,
|
|
252
|
-
);
|
|
253
|
-
});
|
|
254
|
-
|
|
255
227
|
describe("SubagentPool", () => {
|
|
256
228
|
const workspace = { workspaceRoot: "/tmp/akan" } as never;
|
|
257
229
|
|
package/executors.ts
CHANGED
|
@@ -49,6 +49,7 @@ import { JsonFormatter } from "./jsonFormatter";
|
|
|
49
49
|
import { Linter } from "./linter";
|
|
50
50
|
import { ManifestDependencies } from "./manifestDependencies";
|
|
51
51
|
import { resolveRepoName } from "./repoIdentity";
|
|
52
|
+
import type { RouteSourceInfo } from "./routeSourceValidator";
|
|
52
53
|
import { AppInfo, isAkanFrameworkDependency, LibInfo, PkgInfo, WorkspaceInfo } from "./scanInfo";
|
|
53
54
|
import { Spinner } from "./spinner";
|
|
54
55
|
// Type-only: `getTypeChecker` loads it on demand to keep `typescript` out of the resident module graph.
|
|
@@ -66,6 +67,12 @@ export interface PageRoot {
|
|
|
66
67
|
keyPrefix: string;
|
|
67
68
|
}
|
|
68
69
|
|
|
70
|
+
export interface AppRouteSource {
|
|
71
|
+
key: string;
|
|
72
|
+
absPath: string;
|
|
73
|
+
info: RouteSourceInfo;
|
|
74
|
+
}
|
|
75
|
+
|
|
69
76
|
export interface WrittenFileContent extends FileContent {
|
|
70
77
|
written: boolean;
|
|
71
78
|
}
|
|
@@ -1278,6 +1285,7 @@ export class AppExecutor extends SysExecutor {
|
|
|
1278
1285
|
}
|
|
1279
1286
|
|
|
1280
1287
|
#pageKeys: string[] | null = null;
|
|
1288
|
+
#routeSources: AppRouteSource[] = [];
|
|
1281
1289
|
#excludeDevOnlyPages = false;
|
|
1282
1290
|
async getPageKeys({ refresh }: { refresh?: boolean } = {}): Promise<string[]> {
|
|
1283
1291
|
if (this.#pageKeys && !refresh) return this.#pageKeys;
|
|
@@ -1288,6 +1296,7 @@ export class AppExecutor extends SysExecutor {
|
|
|
1288
1296
|
const devOnlyKeys = new Set<string>();
|
|
1289
1297
|
const devOnlyDirs: string[] = [];
|
|
1290
1298
|
const pageRoots = await this.getPageRoots();
|
|
1299
|
+
const routeSources: AppRouteSource[] = [];
|
|
1291
1300
|
const libKeyPrefixes = pageRoots.map((root) => root.keyPrefix).filter(Boolean);
|
|
1292
1301
|
for (const root of pageRoots) {
|
|
1293
1302
|
if (!(await FileSys.dirExists(root.dir))) continue;
|
|
@@ -1333,6 +1342,7 @@ export class AppExecutor extends SysExecutor {
|
|
|
1333
1342
|
rootLayout: isRootLayout,
|
|
1334
1343
|
pattern: parsed.pattern,
|
|
1335
1344
|
});
|
|
1345
|
+
routeSources.push({ key, absPath, info });
|
|
1336
1346
|
if (info.devOnly) {
|
|
1337
1347
|
devOnlyKeys.add(key);
|
|
1338
1348
|
//* A dev-only layout takes its whole subtree, or its pages would ship without their chrome.
|
|
@@ -1343,9 +1353,15 @@ export class AppExecutor extends SysExecutor {
|
|
|
1343
1353
|
}
|
|
1344
1354
|
}
|
|
1345
1355
|
pageKeys.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
|
|
1356
|
+
this.#routeSources = routeSources.sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0));
|
|
1346
1357
|
this.#pageKeys = this.#excludeDevOnlyPages ? this.#dropDevOnlyPages(pageKeys, devOnlyKeys, devOnlyDirs) : pageKeys;
|
|
1347
1358
|
return this.#pageKeys;
|
|
1348
1359
|
}
|
|
1360
|
+
/** Each page and layout file `getPageKeys` read, with what its chain declares; overrides files are not listed. */
|
|
1361
|
+
async getRouteSources({ refresh }: { refresh?: boolean } = {}): Promise<AppRouteSource[]> {
|
|
1362
|
+
await this.getPageKeys({ refresh });
|
|
1363
|
+
return this.#routeSources;
|
|
1364
|
+
}
|
|
1349
1365
|
#dropDevOnlyPages(pageKeys: string[], devOnlyKeys: Set<string>, devOnlyDirs: string[]): string[] {
|
|
1350
1366
|
if (!devOnlyKeys.size) return pageKeys;
|
|
1351
1367
|
const isDevOnly = (key: string) => devOnlyKeys.has(key) || devOnlyDirs.some((dir) => key.startsWith(dir));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@akanjs/devkit",
|
|
3
|
-
"version": "3.1.1-rc.
|
|
3
|
+
"version": "3.1.1-rc.19",
|
|
4
4
|
"sourceType": "module",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"publishConfig": {
|
|
@@ -34,6 +34,7 @@
|
|
|
34
34
|
"./incrementalBuilder": "./incrementalBuilder/index.ts",
|
|
35
35
|
"./mobile": "./mobile/index.ts",
|
|
36
36
|
"./pipeline": "./pipeline/index.ts",
|
|
37
|
+
"./preview": "./preview/index.ts",
|
|
37
38
|
"./transforms": "./transforms/index.ts",
|
|
38
39
|
"./workflow": "./workflow/index.ts",
|
|
39
40
|
"./*.ts": "./*.ts",
|
|
@@ -48,7 +49,7 @@
|
|
|
48
49
|
"@earendil-works/pi-tui": "1.3.0",
|
|
49
50
|
"@inquirer/prompts": "^8.7.3",
|
|
50
51
|
"@tailwindcss/node": "^4.3.3",
|
|
51
|
-
"akanjs": "3.1.1-rc.
|
|
52
|
+
"akanjs": "3.1.1-rc.19",
|
|
52
53
|
"chalk": "^6.0.1",
|
|
53
54
|
"commander": "^15.0.0",
|
|
54
55
|
"dayjs": "^1.11.23",
|
|
@@ -2,6 +2,8 @@ import { existsSync } from "node:fs";
|
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
|
|
5
|
+
export type PreviewEngine = "webkit" | "chrome";
|
|
6
|
+
|
|
5
7
|
// Bun.WebView's `webkit` backend is the macOS system framework and throws on every other platform.
|
|
6
8
|
export class PreviewChrome {
|
|
7
9
|
/** Read by `Bun.WebView` itself; listed so the availability check agrees with what the launcher will do. */
|
|
@@ -30,7 +32,7 @@ export class PreviewChrome {
|
|
|
30
32
|
"Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
|
|
31
33
|
];
|
|
32
34
|
|
|
33
|
-
static get backend():
|
|
35
|
+
static get backend(): PreviewEngine {
|
|
34
36
|
const declared = process.env[PreviewChrome.backendEnvKey];
|
|
35
37
|
if (declared === "webkit" || declared === "chrome") return declared;
|
|
36
38
|
return process.platform === "darwin" ? "webkit" : "chrome";
|
|
@@ -53,11 +55,15 @@ export class PreviewChrome {
|
|
|
53
55
|
}
|
|
54
56
|
|
|
55
57
|
static get available() {
|
|
56
|
-
return PreviewChrome.
|
|
58
|
+
return PreviewChrome.isAvailable();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
static isAvailable(engine: PreviewEngine = PreviewChrome.backend) {
|
|
62
|
+
return engine === "webkit" ? process.platform === "darwin" : !!PreviewChrome.executable();
|
|
57
63
|
}
|
|
58
64
|
|
|
59
|
-
static backendOption() {
|
|
60
|
-
if (
|
|
65
|
+
static backendOption(engine: PreviewEngine = PreviewChrome.backend) {
|
|
66
|
+
if (engine === "webkit") return "webkit";
|
|
61
67
|
// Docker's default 64MB /dev/shm is below what Chromium's renderer allocates; the crash looks like a hung load.
|
|
62
68
|
const argv = ["--disable-dev-shm-usage"];
|
|
63
69
|
// Chromium refuses uid 0 with its sandbox on, and uid 0 is the ordinary uid in a container.
|
|
@@ -70,9 +76,9 @@ export class PreviewChrome {
|
|
|
70
76
|
"apt-get update && apt-get install -y --no-install-recommends chromium fonts-liberation fonts-noto-color-emoji fonts-noto-cjk && rm -rf /var/lib/apt/lists/*",
|
|
71
77
|
];
|
|
72
78
|
|
|
73
|
-
static unavailableReason() {
|
|
74
|
-
if (PreviewChrome.
|
|
75
|
-
if (
|
|
79
|
+
static unavailableReason(engine: PreviewEngine = PreviewChrome.backend) {
|
|
80
|
+
if (PreviewChrome.isAvailable(engine)) return undefined;
|
|
81
|
+
if (engine === "webkit")
|
|
76
82
|
return `The webkit WebView backend exists only on macOS. Set ${PreviewChrome.backendEnvKey}=chrome and install a browser.`;
|
|
77
83
|
return [
|
|
78
84
|
"The page preview needs a browser and this machine has none on PATH.",
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import { PreviewChrome, type PreviewEngine } from "./PreviewChrome";
|
|
2
|
+
|
|
3
|
+
export interface PreviewProbeOptions {
|
|
4
|
+
screenshot?: boolean;
|
|
5
|
+
width?: number;
|
|
6
|
+
height?: number;
|
|
7
|
+
engine?: PreviewEngine;
|
|
8
|
+
/** Bounds the whole probe: a dev server that is recovering takes the connection and never answers. */
|
|
9
|
+
timeoutMs?: number;
|
|
10
|
+
/** Waited after the load, so the errors effects throw land before the page is read. */
|
|
11
|
+
settleMs?: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface PreviewProbeResult {
|
|
15
|
+
url: string;
|
|
16
|
+
finalUrl: string;
|
|
17
|
+
status: number | null;
|
|
18
|
+
title: string;
|
|
19
|
+
text: string;
|
|
20
|
+
consoleErrors: string[];
|
|
21
|
+
consoleWarnings: string[];
|
|
22
|
+
pageErrors: string[];
|
|
23
|
+
/** Cheap structural smells that read as "the page is broken" without anyone looking at it. */
|
|
24
|
+
domFindings: string[];
|
|
25
|
+
screenshot?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface WebViewLike {
|
|
29
|
+
navigate(url: string): Promise<void>;
|
|
30
|
+
evaluate(expression: string): Promise<unknown>;
|
|
31
|
+
screenshot(options: { encoding: "base64" }): Promise<string>;
|
|
32
|
+
close(): void | Promise<void>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface PageRead {
|
|
36
|
+
href: string;
|
|
37
|
+
title: string;
|
|
38
|
+
text: string;
|
|
39
|
+
status: number | null;
|
|
40
|
+
empty: boolean;
|
|
41
|
+
pageErrors: string[];
|
|
42
|
+
domFindings: string[];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Bun.WebView rather than a downloaded browser: on macOS its webkit backend needs no Chromium at all.
|
|
46
|
+
// It is experimental, so its whole surface stays behind this adapter; a Bun minor that changes it breaks one file.
|
|
47
|
+
export class PreviewView {
|
|
48
|
+
static get available() {
|
|
49
|
+
return PreviewView.isAvailable();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
static isAvailable(engine: PreviewEngine = PreviewChrome.backend) {
|
|
53
|
+
return typeof (Bun as unknown as { WebView?: unknown }).WebView === "function" && PreviewChrome.isAvailable(engine);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
static unavailableReason(engine: PreviewEngine = PreviewChrome.backend) {
|
|
57
|
+
if (typeof (Bun as unknown as { WebView?: unknown }).WebView !== "function")
|
|
58
|
+
return "This Bun build has no WebView, so the page preview is off.";
|
|
59
|
+
return PreviewChrome.unavailableReason(engine);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
static async probe(
|
|
63
|
+
url: string,
|
|
64
|
+
{
|
|
65
|
+
screenshot = false,
|
|
66
|
+
width = 1280,
|
|
67
|
+
height = 800,
|
|
68
|
+
engine = PreviewChrome.backend,
|
|
69
|
+
timeoutMs = 30_000,
|
|
70
|
+
settleMs = 0,
|
|
71
|
+
}: PreviewProbeOptions = {},
|
|
72
|
+
): Promise<PreviewProbeResult> {
|
|
73
|
+
const logs: string[] = [];
|
|
74
|
+
const deadline = Date.now() + timeoutMs;
|
|
75
|
+
const within = <T>(work: Promise<T>, what: string) => PreviewView.#within(work, deadline, `${what} ${url}`);
|
|
76
|
+
const view = PreviewView.#open({ width, height, engine, onConsole: (line) => logs.push(line) });
|
|
77
|
+
try {
|
|
78
|
+
let page = await PreviewView.#load(view, url, settleMs, within);
|
|
79
|
+
//? Right after a save the first load can render nothing (`[rscClient] module not loaded`); the next one has it.
|
|
80
|
+
if (page.empty) {
|
|
81
|
+
logs.length = 0;
|
|
82
|
+
page = await PreviewView.#load(view, url, settleMs, within);
|
|
83
|
+
}
|
|
84
|
+
const result: PreviewProbeResult = {
|
|
85
|
+
url,
|
|
86
|
+
finalUrl: page.href,
|
|
87
|
+
status: page.status ?? (await PreviewView.#statusOf(page.href, deadline)),
|
|
88
|
+
title: page.title,
|
|
89
|
+
text: page.text,
|
|
90
|
+
consoleErrors: logs.filter((line) => line.startsWith("error")),
|
|
91
|
+
consoleWarnings: logs.filter((line) => line.startsWith("warn")),
|
|
92
|
+
pageErrors: page.pageErrors,
|
|
93
|
+
domFindings: page.domFindings,
|
|
94
|
+
};
|
|
95
|
+
if (screenshot) result.screenshot = await within(view.screenshot({ encoding: "base64" }), "screenshotting");
|
|
96
|
+
return result;
|
|
97
|
+
} finally {
|
|
98
|
+
await view.close();
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
static async #load(
|
|
103
|
+
view: WebViewLike,
|
|
104
|
+
url: string,
|
|
105
|
+
settleMs: number,
|
|
106
|
+
within: <T>(work: Promise<T>, what: string) => Promise<T>,
|
|
107
|
+
): Promise<PageRead> {
|
|
108
|
+
await within(view.navigate(url), "loading");
|
|
109
|
+
if (settleMs > 0) await Bun.sleep(settleMs);
|
|
110
|
+
const read = (await within(view.evaluate(readPageExpression), "reading")) as Partial<PageRead> | null;
|
|
111
|
+
return {
|
|
112
|
+
href: typeof read?.href === "string" ? read.href : url,
|
|
113
|
+
title: typeof read?.title === "string" ? read.title : "",
|
|
114
|
+
text: typeof read?.text === "string" ? read.text : "",
|
|
115
|
+
status: typeof read?.status === "number" && read.status > 0 ? read.status : null,
|
|
116
|
+
empty: read?.empty === true,
|
|
117
|
+
pageErrors: PreviewView.#strings(read?.pageErrors),
|
|
118
|
+
domFindings: PreviewView.#strings(read?.domFindings),
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
//? webkit has no Navigation Timing `responseStatus`, so the status comes from asking the server again, unredirected.
|
|
123
|
+
static async #statusOf(href: string, deadline: number) {
|
|
124
|
+
const left = Math.max(1, deadline - Date.now());
|
|
125
|
+
const response = await fetch(href, { redirect: "manual", signal: AbortSignal.timeout(left) }).catch(() => null);
|
|
126
|
+
return response?.status ?? null;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
static async #within<T>(work: Promise<T>, deadline: number, what: string): Promise<T> {
|
|
130
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
131
|
+
const expired = new Promise<never>((_, reject) => {
|
|
132
|
+
timer = setTimeout(
|
|
133
|
+
() => reject(new Error(`Timed out ${what}; the dev server may be rebuilding or stuck.`)),
|
|
134
|
+
Math.max(0, deadline - Date.now()),
|
|
135
|
+
);
|
|
136
|
+
});
|
|
137
|
+
try {
|
|
138
|
+
return await Promise.race([work, expired]);
|
|
139
|
+
} finally {
|
|
140
|
+
clearTimeout(timer);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
static #open({
|
|
145
|
+
width,
|
|
146
|
+
height,
|
|
147
|
+
engine,
|
|
148
|
+
onConsole,
|
|
149
|
+
}: {
|
|
150
|
+
width: number;
|
|
151
|
+
height: number;
|
|
152
|
+
engine: PreviewEngine;
|
|
153
|
+
onConsole: (line: string) => void;
|
|
154
|
+
}) {
|
|
155
|
+
const Ctor = (Bun as unknown as { WebView: new (options: unknown) => WebViewLike }).WebView;
|
|
156
|
+
return new Ctor({
|
|
157
|
+
width,
|
|
158
|
+
height,
|
|
159
|
+
dataStore: "ephemeral",
|
|
160
|
+
backend: PreviewChrome.backendOption(engine),
|
|
161
|
+
// The console option is a callback taking `(type, ...args)`, not an object of console methods.
|
|
162
|
+
console: (type: string, ...args: unknown[]) => onConsole(`${type} ${PreviewView.formatConsole(args)}`),
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Substitutes `%s` as the console does, since React passes a warning's detail as arguments, and cuts it short. */
|
|
167
|
+
static formatConsole(args: unknown[]) {
|
|
168
|
+
const [first, ...rest] = args;
|
|
169
|
+
const head =
|
|
170
|
+
typeof first === "string"
|
|
171
|
+
? first.replace(/%[sdifoOc]/g, (token) => {
|
|
172
|
+
if (!rest.length) return token;
|
|
173
|
+
const value = rest.shift();
|
|
174
|
+
return token === "%c" ? "" : String(value);
|
|
175
|
+
})
|
|
176
|
+
: String(first);
|
|
177
|
+
return [head, ...rest.map(String)]
|
|
178
|
+
.join(" ")
|
|
179
|
+
.replace(/\n(\s*\n)+/g, "\n")
|
|
180
|
+
.slice(0, 1_500);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
static #strings(value: unknown) {
|
|
184
|
+
return Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === "string") : [];
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// evaluate() takes one expression (a statement list is a SyntaxError), hence the IIFE; one call in flight per view.
|
|
189
|
+
// `view.title` is only refreshed by a navigation, so the title is read from the document.
|
|
190
|
+
const readPageExpression = `(() => {
|
|
191
|
+
const pageErrors = [];
|
|
192
|
+
const domFindings = [];
|
|
193
|
+
const body = document.body;
|
|
194
|
+
const rendered = body ? Array.from(body.children).filter((el) => !["SCRIPT", "STYLE", "TEMPLATE", "LINK", "NOSCRIPT"].includes(el.tagName) && !el.classList.contains("__akan_hmr_overlay")) : [];
|
|
195
|
+
const empty = !rendered.length && !(body && body.innerText && body.innerText.trim());
|
|
196
|
+
if (empty) pageErrors.push("The page body rendered empty.");
|
|
197
|
+
if (self.__AKAN_HMR_SYSTEM_PAGE__) pageErrors.push("The server could not render this page and answered with its error page: " + (body ? body.innerText.trim().replace(/\\s+/g, " ").slice(0, 400) : ""));
|
|
198
|
+
const overlay = document.querySelector('.__akan_hmr_overlay[data-status="error"]');
|
|
199
|
+
if (overlay) pageErrors.push("The dev build failed: " + overlay.textContent.trim().slice(0, 400));
|
|
200
|
+
const all = Array.from(document.querySelectorAll("body *"));
|
|
201
|
+
const viewportWidth = document.documentElement.clientWidth;
|
|
202
|
+
const pageWidth = document.documentElement.scrollWidth;
|
|
203
|
+
if (pageWidth > viewportWidth + 8) {
|
|
204
|
+
const wide = all.find((el) => el.getBoundingClientRect().right > viewportWidth + 8);
|
|
205
|
+
domFindings.push("The page is " + pageWidth + "px wide in a " + viewportWidth + "px viewport" + (wide ? ", first wider element: " + (wide.tagName.toLowerCase() + "." + (typeof wide.className === "string" ? wide.className : "")).slice(0, 120) : "") + ".");
|
|
206
|
+
}
|
|
207
|
+
const invisibleText = all.filter((el) => {
|
|
208
|
+
if (!el.textContent || !el.textContent.trim()) return false;
|
|
209
|
+
const style = getComputedStyle(el);
|
|
210
|
+
return style.color === style.backgroundColor && style.backgroundColor !== "rgba(0, 0, 0, 0)";
|
|
211
|
+
});
|
|
212
|
+
if (invisibleText.length) domFindings.push(invisibleText.length + " element(s) render text in the same colour as their background.");
|
|
213
|
+
const unresolved = all.filter((el) => /(^|\\s)(bg|text|border)-(base-100|base-content|primary-content|error)(\\s|$)/.test(typeof el.className === "string" ? el.className : ""));
|
|
214
|
+
if (unresolved.length) domFindings.push(unresolved.length + " element(s) use a dropped daisyUI colour slot that renders no CSS.");
|
|
215
|
+
const navigation = performance.getEntriesByType("navigation")[0];
|
|
216
|
+
const status = navigation && typeof navigation.responseStatus === "number" ? navigation.responseStatus : null;
|
|
217
|
+
const text = body ? body.innerText.trim().replace(/\\s+/g, " ").slice(0, 600) : "";
|
|
218
|
+
return { href: location.href, title: document.title, text, status, empty, pageErrors, domFindings };
|
|
219
|
+
})()`;
|
package/preview/index.ts
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { PreviewView } from "./PreviewView";
|
|
3
|
+
|
|
4
|
+
const serve = (html: string, status = 200) =>
|
|
5
|
+
Bun.serve({ port: 0, fetch: () => new Response(html, { status, headers: { "content-type": "text/html" } }) });
|
|
6
|
+
|
|
7
|
+
describe("PreviewView", () => {
|
|
8
|
+
test("reports whether this Bun build can drive a headless page at all", () => {
|
|
9
|
+
expect(typeof PreviewView.available).toBe("boolean");
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
test("formats a console call the way the console prints it", () => {
|
|
13
|
+
expect(PreviewView.formatConsole(["%cstyled%c %s of %d", "color:red", "", "two", 3, "extra"])).toBe(
|
|
14
|
+
"styled two of 3 extra",
|
|
15
|
+
);
|
|
16
|
+
expect(PreviewView.formatConsole(["a\n\n\n \nb"])).toBe("a\nb");
|
|
17
|
+
expect(PreviewView.formatConsole(["x".repeat(2_000)])).toHaveLength(1_500);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test.skipIf(!PreviewView.available)(
|
|
21
|
+
"finds an empty body and a console error on a real page",
|
|
22
|
+
async () => {
|
|
23
|
+
const server = serve('<!doctype html><html><body></body><script>console.error("boom")</script></html>');
|
|
24
|
+
try {
|
|
25
|
+
const probe = await PreviewView.probe(`http://localhost:${server.port}/`);
|
|
26
|
+
expect(probe.pageErrors.join(" ")).toContain("rendered empty");
|
|
27
|
+
expect(probe.consoleErrors.join(" ")).toContain("boom");
|
|
28
|
+
} finally {
|
|
29
|
+
server.stop(true);
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
30_000,
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
test.skipIf(!PreviewView.available)(
|
|
36
|
+
"reads the status, the live title, a failed dev build and a page wider than the viewport",
|
|
37
|
+
async () => {
|
|
38
|
+
const server = serve(
|
|
39
|
+
[
|
|
40
|
+
"<!doctype html><html><head><title>Before</title></head><body>",
|
|
41
|
+
'<div style="width:900px">wide</div>',
|
|
42
|
+
'<div class="__akan_hmr_overlay" data-status="error">Build failed: page.tsx</div>',
|
|
43
|
+
'<script>document.title = "After";</script>',
|
|
44
|
+
"</body></html>",
|
|
45
|
+
].join(""),
|
|
46
|
+
404,
|
|
47
|
+
);
|
|
48
|
+
try {
|
|
49
|
+
const probe = await PreviewView.probe(`http://localhost:${server.port}/missing`, { width: 390, height: 844 });
|
|
50
|
+
expect(probe.status).toBe(404);
|
|
51
|
+
expect(probe.title).toBe("After");
|
|
52
|
+
expect(probe.text).toBe("wide Build failed: page.tsx");
|
|
53
|
+
expect(probe.pageErrors).toEqual(["The dev build failed: Build failed: page.tsx"]);
|
|
54
|
+
expect(probe.domFindings.join(" ")).toContain("first wider element: div");
|
|
55
|
+
} finally {
|
|
56
|
+
server.stop(true);
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
30_000,
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
test.skipIf(!PreviewView.available)(
|
|
63
|
+
"gives up on a server that takes the connection and never answers",
|
|
64
|
+
async () => {
|
|
65
|
+
const server = Bun.serve({ port: 0, fetch: () => new Promise<Response>(() => undefined) });
|
|
66
|
+
try {
|
|
67
|
+
await expect(PreviewView.probe(`http://localhost:${server.port}/`, { timeoutMs: 1_500 })).rejects.toThrow(
|
|
68
|
+
"Timed out loading",
|
|
69
|
+
);
|
|
70
|
+
} finally {
|
|
71
|
+
server.stop(true);
|
|
72
|
+
}
|
|
73
|
+
},
|
|
74
|
+
30_000,
|
|
75
|
+
);
|
|
76
|
+
});
|
|
@@ -43,12 +43,14 @@ describe("RouteSourceValidator route chains", () => {
|
|
|
43
43
|
const chain = (source: string, kind: "page" | "layout", pattern: string) =>
|
|
44
44
|
RouteSourceValidator.validateRouteSourceExports(source, `page/${kind}.tsx`, kind, { pattern });
|
|
45
45
|
|
|
46
|
-
test("reads params, prompt, apps and devOnly off a page() chain without evaluating it", () => {
|
|
46
|
+
test("reads params, search names, prompt, apps, guard and devOnly off a page() chain without evaluating it", () => {
|
|
47
47
|
const source = [
|
|
48
48
|
'import { page } from "akanjs/client";',
|
|
49
49
|
"export default page()",
|
|
50
50
|
' .param("projectId", ID, { desc: "The project." })',
|
|
51
51
|
' .search("status", String)',
|
|
52
|
+
" .search(dynamicName, String)",
|
|
53
|
+
" .guard(() => signedIn())",
|
|
52
54
|
" .config({ transition: 'stack', devOnly: true })",
|
|
53
55
|
' .prompt("briefProject", "Brief one project.")',
|
|
54
56
|
' .app("showProject", "Show one project.", ({ projectId }) => null)',
|
|
@@ -57,7 +59,14 @@ describe("RouteSourceValidator route chains", () => {
|
|
|
57
59
|
].join("\n");
|
|
58
60
|
expect(chain(source, "page", "/:lang/project/:projectId")).toEqual({
|
|
59
61
|
devOnly: true,
|
|
60
|
-
chain: {
|
|
62
|
+
chain: {
|
|
63
|
+
kind: "page",
|
|
64
|
+
params: ["projectId"],
|
|
65
|
+
prompt: "briefProject",
|
|
66
|
+
apps: ["showProject", "showBoard"],
|
|
67
|
+
search: ["status"],
|
|
68
|
+
guarded: true,
|
|
69
|
+
},
|
|
61
70
|
});
|
|
62
71
|
});
|
|
63
72
|
|
package/routeSourceValidator.ts
CHANGED
|
@@ -17,6 +17,10 @@ export interface RouteSourceChain {
|
|
|
17
17
|
prompt?: string;
|
|
18
18
|
/** The `.app()` names, each one MCP App the build gives a view of its own. */
|
|
19
19
|
apps: string[];
|
|
20
|
+
/** The `.search()` names, in chain order; a name that is not a literal is left out. */
|
|
21
|
+
search: string[];
|
|
22
|
+
/** The chain calls `.guard()`, which gates this route and, on a layout, every route under it. */
|
|
23
|
+
guarded: boolean;
|
|
20
24
|
}
|
|
21
25
|
|
|
22
26
|
interface ChainStage {
|
|
@@ -104,6 +108,8 @@ export class RouteSourceValidator {
|
|
|
104
108
|
params: chain.params,
|
|
105
109
|
...(chain.prompt ? { prompt: chain.prompt } : {}),
|
|
106
110
|
apps: chain.apps,
|
|
111
|
+
search: chain.search,
|
|
112
|
+
guarded: chain.guarded,
|
|
107
113
|
},
|
|
108
114
|
};
|
|
109
115
|
}
|
|
@@ -168,8 +174,10 @@ export class RouteSourceValidator {
|
|
|
168
174
|
static #chainOf(kind: RouteSourceChain["kind"], stages: ChainStage[], filePath: string) {
|
|
169
175
|
const params: string[] = [];
|
|
170
176
|
const apps: string[] = [];
|
|
177
|
+
const search: string[] = [];
|
|
171
178
|
let prompt: string | undefined;
|
|
172
179
|
let devOnly = false;
|
|
180
|
+
let guarded = false;
|
|
173
181
|
const literal = (stage: ChainStage) => {
|
|
174
182
|
const first = stage.args[0];
|
|
175
183
|
if (!first || !ts.isStringLiteralLike(first))
|
|
@@ -182,10 +190,14 @@ export class RouteSourceValidator {
|
|
|
182
190
|
if (stage.name === "param") params.push(literal(stage));
|
|
183
191
|
else if (stage.name === "prompt") prompt = literal(stage);
|
|
184
192
|
else if (stage.name === "app") apps.push(literal(stage));
|
|
185
|
-
else if (stage.name === "
|
|
193
|
+
else if (stage.name === "guard") guarded = true;
|
|
194
|
+
else if (stage.name === "search") {
|
|
195
|
+
const first = stage.args[0];
|
|
196
|
+
if (first && ts.isStringLiteralLike(first)) search.push(first.text);
|
|
197
|
+
} else if (stage.name === "config")
|
|
186
198
|
devOnly = RouteSourceValidator.#devOnlyOf(RouteSourceValidator.#unwrapExpression(stage.args[0]), filePath);
|
|
187
199
|
}
|
|
188
|
-
return { kind, params, prompt, apps, devOnly };
|
|
200
|
+
return { kind, params, prompt, apps, search, guarded, devOnly };
|
|
189
201
|
}
|
|
190
202
|
|
|
191
203
|
/** The same rule the loader applies: a page names every `[x]` it sits under, a layout may name a subset. */
|
|
@@ -1,95 +0,0 @@
|
|
|
1
|
-
import { PreviewChrome } from "./PreviewChrome";
|
|
2
|
-
|
|
3
|
-
export interface PreviewProbeResult {
|
|
4
|
-
url: string;
|
|
5
|
-
title: string;
|
|
6
|
-
consoleErrors: string[];
|
|
7
|
-
pageErrors: string[];
|
|
8
|
-
/** Cheap structural smells that read as "the page is broken" without anyone looking at it. */
|
|
9
|
-
domFindings: string[];
|
|
10
|
-
screenshot?: string;
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
interface WebViewLike {
|
|
14
|
-
navigate(url: string): Promise<void>;
|
|
15
|
-
evaluate(expression: string): Promise<unknown>;
|
|
16
|
-
screenshot(options: { encoding: "base64" }): Promise<string>;
|
|
17
|
-
close(): void | Promise<void>;
|
|
18
|
-
readonly title: string;
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
// Bun.WebView rather than a downloaded browser: on macOS its webkit backend needs no Chromium at all.
|
|
22
|
-
// It is experimental, so its whole surface stays behind this adapter; a Bun minor that changes it breaks one file.
|
|
23
|
-
export class PreviewView {
|
|
24
|
-
static get available() {
|
|
25
|
-
return typeof (Bun as unknown as { WebView?: unknown }).WebView === "function" && PreviewChrome.available;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
static unavailableReason() {
|
|
29
|
-
if (typeof (Bun as unknown as { WebView?: unknown }).WebView !== "function")
|
|
30
|
-
return "This Bun build has no WebView, so the page preview is off.";
|
|
31
|
-
return PreviewChrome.unavailableReason();
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
static async probe(url: string, { screenshot = false, width = 1280, height = 800 } = {}) {
|
|
35
|
-
const logs: string[] = [];
|
|
36
|
-
const view = PreviewView.#open({ width, height, onConsole: (line) => logs.push(line) });
|
|
37
|
-
try {
|
|
38
|
-
await view.navigate(url);
|
|
39
|
-
const result: PreviewProbeResult = {
|
|
40
|
-
url,
|
|
41
|
-
title: view.title,
|
|
42
|
-
consoleErrors: logs.filter((line) => line.startsWith("error") || line.startsWith("warn")),
|
|
43
|
-
pageErrors: PreviewView.#strings(await view.evaluate(pageErrorsExpression)),
|
|
44
|
-
domFindings: PreviewView.#strings(await view.evaluate(domFindingsExpression)),
|
|
45
|
-
};
|
|
46
|
-
if (screenshot) result.screenshot = await view.screenshot({ encoding: "base64" });
|
|
47
|
-
return result;
|
|
48
|
-
} finally {
|
|
49
|
-
await view.close();
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
static #open({ width, height, onConsole }: { width: number; height: number; onConsole: (line: string) => void }) {
|
|
54
|
-
const Ctor = (Bun as unknown as { WebView: new (options: unknown) => WebViewLike }).WebView;
|
|
55
|
-
return new Ctor({
|
|
56
|
-
width,
|
|
57
|
-
height,
|
|
58
|
-
dataStore: "ephemeral",
|
|
59
|
-
backend: PreviewChrome.backendOption(),
|
|
60
|
-
// The console option is a callback taking `(type, ...args)`, not an object of console methods.
|
|
61
|
-
console: (type: string, ...args: unknown[]) => onConsole(`${type} ${args.map(String).join(" ")}`),
|
|
62
|
-
});
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
static #strings(value: unknown) {
|
|
66
|
-
return Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === "string") : [];
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
// evaluate() takes one expression (a statement list is a SyntaxError), hence the IIFEs; one call in flight per view.
|
|
71
|
-
const pageErrorsExpression = `(() => {
|
|
72
|
-
const found = [];
|
|
73
|
-
const root = document.getElementById("__next") || document.body;
|
|
74
|
-
const rendered = root ? Array.from(root.children).filter((el) => !["SCRIPT", "STYLE", "TEMPLATE", "LINK", "NOSCRIPT"].includes(el.tagName)) : [];
|
|
75
|
-
if (!rendered.length && !(root && root.innerText && root.innerText.trim())) found.push("The page body rendered empty.");
|
|
76
|
-
const overlay = document.querySelector("[data-nextjs-dialog], .error-overlay, #akan-error");
|
|
77
|
-
if (overlay) found.push("An error overlay is on the page: " + overlay.textContent.slice(0, 400));
|
|
78
|
-
return found;
|
|
79
|
-
})()`;
|
|
80
|
-
|
|
81
|
-
const domFindingsExpression = `(() => {
|
|
82
|
-
const found = [];
|
|
83
|
-
const all = Array.from(document.querySelectorAll("body *"));
|
|
84
|
-
const overflowing = all.filter((el) => el.scrollWidth > document.documentElement.clientWidth + 8);
|
|
85
|
-
if (overflowing.length) found.push(overflowing.length + " element(s) overflow the viewport horizontally, first: " + (overflowing[0].tagName + "." + overflowing[0].className).slice(0, 120));
|
|
86
|
-
const invisibleText = all.filter((el) => {
|
|
87
|
-
if (!el.textContent || !el.textContent.trim()) return false;
|
|
88
|
-
const style = getComputedStyle(el);
|
|
89
|
-
return style.color === style.backgroundColor && style.backgroundColor !== "rgba(0, 0, 0, 0)";
|
|
90
|
-
});
|
|
91
|
-
if (invisibleText.length) found.push(invisibleText.length + " element(s) render text in the same colour as their background.");
|
|
92
|
-
const unresolved = all.filter((el) => /(^|\\s)(bg|text|border)-(base-100|base-content|primary-content|error)(\\s|$)/.test(el.className || ""));
|
|
93
|
-
if (unresolved.length) found.push(unresolved.length + " element(s) use a dropped daisyUI colour slot that renders no CSS.");
|
|
94
|
-
return found;
|
|
95
|
-
})()`;
|
|
File without changes
|