idcmd 0.0.5 → 0.0.7
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 +29 -9
- package/package.json +2 -2
- package/src/build.ts +6 -5
- package/src/cli/commands/build.ts +11 -7
- package/src/cli/commands/client.ts +328 -0
- package/src/cli/commands/dev.ts +92 -34
- package/src/cli/commands/init.ts +93 -2
- package/src/cli/main.ts +12 -0
- package/src/cli/runtime-assets.ts +89 -0
- package/src/cli.ts +0 -0
- package/src/client/index.ts +7 -1
- package/src/content/icons.ts +1 -1
- package/src/content/paths.ts +1 -1
- package/src/project/paths.ts +26 -30
- package/src/render/layout-loader.ts +7 -4
- package/src/render/layout.tsx +10 -2
- package/src/render/page-renderer.ts +12 -2
- package/src/render/right-rail-loader.ts +49 -0
- package/src/render/right-rail.tsx +10 -6
- package/src/search/page.tsx +4 -2
- package/src/search/search-page-loader.ts +51 -0
- package/src/search/server-page.ts +52 -18
- package/src/server/live-reload.ts +2 -6
- package/src/server/static.ts +1 -1
- package/src/server.ts +0 -1
- package/src/site/config.ts +2 -10
- package/templates/default/.github/workflows/ci.yml +24 -0
- package/templates/default/README.md +31 -5
- package/templates/default/package.json +2 -1
- package/templates/default/scripts/check-internal.ts +56 -0
- package/templates/default/scripts/check.ts +332 -0
- package/templates/default/scripts/smoke.ts +223 -0
- package/templates/default/site/{public/_idcmd/llm-menu.js → code/runtime/llm-menu.ts} +27 -18
- package/templates/default/site/{public/_idcmd/nav-prefetch.js → code/runtime/nav-prefetch.ts} +3 -3
- package/templates/default/site/{public/_idcmd/right-rail-scrollspy.js → code/runtime/right-rail-scrollspy.ts} +73 -32
- package/templates/default/site/{server → code}/server.ts +1 -1
- package/templates/default/site/code/ui/layout.tsx +237 -0
- package/templates/default/site/code/ui/right-rail.tsx +246 -0
- package/templates/default/site/code/ui/search-page.tsx +87 -0
- package/templates/default/tsconfig.json +1 -1
- package/templates/default/site/client/layout.tsx +0 -2
- package/templates/default/site/client/right-rail.tsx +0 -1
- package/templates/default/site/client/search-page.tsx +0 -1
- /package/templates/default/site/{public → assets}/anthropic-white.svg +0 -0
- /package/templates/default/site/{public → assets}/favicon.svg +0 -0
- /package/templates/default/site/{icons → assets/icons}/file.svg +0 -0
- /package/templates/default/site/{icons → assets/icons}/home.svg +0 -0
- /package/templates/default/site/{icons → assets/icons}/info.svg +0 -0
- /package/templates/default/site/{public → assets}/openai-white.svg +0 -0
- /package/templates/default/site/{server → code}/routes/api/hello.ts +0 -0
- /package/templates/default/site/{public/_idcmd/live-reload.js → code/runtime/live-reload.ts} +0 -0
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
} from "../site/config";
|
|
11
11
|
import { resolveCanonicalUrl } from "../site/urls";
|
|
12
12
|
import { loadSearchIndex, search as runSearch } from "./index";
|
|
13
|
-
import {
|
|
13
|
+
import { getRenderSearchPageContent } from "./search-page-loader";
|
|
14
14
|
|
|
15
15
|
export interface SearchPageHandlerEnv {
|
|
16
16
|
cacheHeaders: HeadersInit;
|
|
@@ -37,40 +37,74 @@ const getResults = (
|
|
|
37
37
|
? runSearch(index, query, scope).slice(0, env.maxResults)
|
|
38
38
|
: [];
|
|
39
39
|
|
|
40
|
+
const getSearchQuery = (url: URL): string =>
|
|
41
|
+
url.searchParams.get("q")?.trim() ?? "";
|
|
42
|
+
|
|
43
|
+
const loadSearchPageDependencies = async (options: {
|
|
44
|
+
isDev: boolean;
|
|
45
|
+
siteConfig: Awaited<ReturnType<typeof loadSiteConfig>>;
|
|
46
|
+
}): Promise<{
|
|
47
|
+
navigation: Awaited<ReturnType<typeof getNavigation>>;
|
|
48
|
+
index: Awaited<ReturnType<typeof loadSearchIndex>>;
|
|
49
|
+
renderSearchPageContent: Awaited<
|
|
50
|
+
ReturnType<typeof getRenderSearchPageContent>
|
|
51
|
+
>;
|
|
52
|
+
}> => {
|
|
53
|
+
const [navigation, index, renderSearchPageContent] = await Promise.all([
|
|
54
|
+
getNavigation(options.isDev),
|
|
55
|
+
loadSearchIndex({
|
|
56
|
+
forceRefresh: options.isDev,
|
|
57
|
+
siteConfig: options.siteConfig,
|
|
58
|
+
}),
|
|
59
|
+
getRenderSearchPageContent(),
|
|
60
|
+
]);
|
|
61
|
+
|
|
62
|
+
return { index, navigation, renderSearchPageContent };
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const getCanonicalSearchPageUrl = (options: {
|
|
66
|
+
baseUrl?: string;
|
|
67
|
+
env: SearchPageHandlerEnv;
|
|
68
|
+
url: URL;
|
|
69
|
+
}): string | undefined =>
|
|
70
|
+
resolveCanonicalUrl(
|
|
71
|
+
{
|
|
72
|
+
configuredBaseUrl: options.baseUrl,
|
|
73
|
+
isDev: options.env.isDev,
|
|
74
|
+
requestOrigin: options.url.origin,
|
|
75
|
+
},
|
|
76
|
+
"/search/"
|
|
77
|
+
);
|
|
78
|
+
|
|
40
79
|
const buildSearchPageHtml = async (
|
|
41
80
|
url: URL,
|
|
42
81
|
env: SearchPageHandlerEnv
|
|
43
82
|
): Promise<string> => {
|
|
44
83
|
const siteConfig = await loadSiteConfig();
|
|
45
84
|
const scope = getSearchScope(siteConfig);
|
|
46
|
-
const query = url
|
|
85
|
+
const query = getSearchQuery(url);
|
|
47
86
|
const rightRail = resolveRightRailConfig(siteConfig.rightRail);
|
|
48
87
|
|
|
49
|
-
const
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
88
|
+
const { index, navigation, renderSearchPageContent } =
|
|
89
|
+
await loadSearchPageDependencies({
|
|
90
|
+
isDev: env.isDev,
|
|
91
|
+
siteConfig,
|
|
92
|
+
});
|
|
53
93
|
|
|
54
94
|
const results = getResults(index, query, scope, env);
|
|
55
|
-
const topPages = getTopPages(navigation);
|
|
56
95
|
const content = renderSearchPageContent({
|
|
57
96
|
minQueryLength: env.minQueryLength,
|
|
58
97
|
query,
|
|
59
98
|
results,
|
|
60
|
-
topPages,
|
|
99
|
+
topPages: getTopPages(navigation),
|
|
61
100
|
});
|
|
62
101
|
|
|
63
|
-
const canonicalUrl = resolveCanonicalUrl(
|
|
64
|
-
{
|
|
65
|
-
configuredBaseUrl: siteConfig.baseUrl,
|
|
66
|
-
isDev: env.isDev,
|
|
67
|
-
requestOrigin: url.origin,
|
|
68
|
-
},
|
|
69
|
-
"/search/"
|
|
70
|
-
);
|
|
71
|
-
|
|
72
102
|
return renderDocument({
|
|
73
|
-
canonicalUrl
|
|
103
|
+
canonicalUrl: getCanonicalSearchPageUrl({
|
|
104
|
+
baseUrl: siteConfig.baseUrl,
|
|
105
|
+
env,
|
|
106
|
+
url,
|
|
107
|
+
}),
|
|
74
108
|
contentHtml: content,
|
|
75
109
|
currentPath: "/search/",
|
|
76
110
|
description: siteConfig.description,
|
|
@@ -59,7 +59,7 @@ export const createLiveReload = (env: LiveReloadEnv): LiveReloadController => {
|
|
|
59
59
|
return;
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
-
console.log("Watching content/ for changes...");
|
|
62
|
+
console.log("Watching site/content/ for changes...");
|
|
63
63
|
let snapshot = await getContentSnapshot();
|
|
64
64
|
|
|
65
65
|
const poll = async (): Promise<void> => {
|
|
@@ -85,11 +85,7 @@ export const createLiveReload = (env: LiveReloadEnv): LiveReloadController => {
|
|
|
85
85
|
server: ServerInstance,
|
|
86
86
|
pathname: string
|
|
87
87
|
): "handled" | Response | undefined => {
|
|
88
|
-
|
|
89
|
-
if (
|
|
90
|
-
!env.isDev ||
|
|
91
|
-
(pathname !== env.websocketPath && pathname !== "/__live-reload")
|
|
92
|
-
) {
|
|
88
|
+
if (!env.isDev || pathname !== env.websocketPath) {
|
|
93
89
|
return undefined;
|
|
94
90
|
}
|
|
95
91
|
|
package/src/server/static.ts
CHANGED
|
@@ -46,7 +46,7 @@ export const serveStaticFile = async (
|
|
|
46
46
|
pathname: string,
|
|
47
47
|
env: ServeStaticEnv
|
|
48
48
|
): Promise<Response | null> => {
|
|
49
|
-
const roots =
|
|
49
|
+
const roots = [env.distDir, env.publicDir];
|
|
50
50
|
|
|
51
51
|
for (const root of roots) {
|
|
52
52
|
const served = await tryServeFileFromRoot(root, pathname, env);
|
package/src/server.ts
CHANGED
package/src/site/config.ts
CHANGED
|
@@ -91,8 +91,7 @@ export interface ResolvedRightRailConfig {
|
|
|
91
91
|
};
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
-
const
|
|
95
|
-
const NEW_SITE_CONFIG_PATH = "site/site.jsonc";
|
|
94
|
+
const SITE_CONFIG_PATH = "site/site.jsonc";
|
|
96
95
|
const LOCALHOST_HOSTNAMES = new Set(["localhost", "127.0.0.1", "::1"]);
|
|
97
96
|
|
|
98
97
|
const DEFAULT_RIGHT_RAIL_CONFIG: ResolvedRightRailConfig = {
|
|
@@ -220,15 +219,8 @@ const parseSiteConfigUnknown = (
|
|
|
220
219
|
}
|
|
221
220
|
};
|
|
222
221
|
|
|
223
|
-
const resolveSiteConfigPath = async (): Promise<string> => {
|
|
224
|
-
if (await Bun.file(NEW_SITE_CONFIG_PATH).exists()) {
|
|
225
|
-
return NEW_SITE_CONFIG_PATH;
|
|
226
|
-
}
|
|
227
|
-
return LEGACY_SITE_CONFIG_PATH;
|
|
228
|
-
};
|
|
229
|
-
|
|
230
222
|
export const loadSiteConfig = async (): Promise<SiteConfig> => {
|
|
231
|
-
const configPath =
|
|
223
|
+
const configPath = SITE_CONFIG_PATH;
|
|
232
224
|
const file = Bun.file(configPath);
|
|
233
225
|
if (!(await file.exists())) {
|
|
234
226
|
return DEFAULT_SITE_CONFIG;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
pull_request:
|
|
6
|
+
workflow_dispatch:
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
ci:
|
|
10
|
+
runs-on: ubuntu-22.04
|
|
11
|
+
env:
|
|
12
|
+
CI: true
|
|
13
|
+
steps:
|
|
14
|
+
- name: Checkout
|
|
15
|
+
uses: actions/checkout@v4
|
|
16
|
+
- name: Setup Bun
|
|
17
|
+
uses: oven-sh/setup-bun@v2
|
|
18
|
+
- name: Install dependencies
|
|
19
|
+
run: bun install
|
|
20
|
+
- name: Run checks
|
|
21
|
+
run: bun run check
|
|
22
|
+
- name: Run smoke
|
|
23
|
+
timeout-minutes: 15
|
|
24
|
+
run: bun run smoke
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
Everything you edit lives in `site/`.
|
|
4
4
|
|
|
5
|
+
This starter is intentionally opinionated for AI-friendly markdown sites.
|
|
6
|
+
|
|
5
7
|
## Quickstart
|
|
6
8
|
|
|
7
9
|
```bash
|
|
@@ -9,13 +11,37 @@ bun install
|
|
|
9
11
|
bun run dev
|
|
10
12
|
```
|
|
11
13
|
|
|
14
|
+
## CI Smoke
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
bun run check
|
|
18
|
+
bun run smoke
|
|
19
|
+
```
|
|
20
|
+
|
|
12
21
|
## Layout
|
|
13
22
|
|
|
14
|
-
- `site/content/` markdown pages (`index.md` -> `/`, `about.md` -> `/about/`)
|
|
15
|
-
- `site/
|
|
16
|
-
- `site/
|
|
17
|
-
- `site/
|
|
18
|
-
- `site/
|
|
23
|
+
- Content: `site/content/` markdown pages (`index.md` -> `/`, `about.md` -> `/about/`)
|
|
24
|
+
- Code: `site/code/ui/` (`layout.tsx`, `right-rail.tsx`, `search-page.tsx`)
|
|
25
|
+
- Code: `site/code/runtime/` browser runtime TS (`*_idcmd` scripts compile from here)
|
|
26
|
+
- Code: `site/code/routes/` file-based server routes (dev/server-host only)
|
|
27
|
+
- Assets: `site/assets/` static files you own (icons, images, favicon, etc.)
|
|
28
|
+
- Styles source: `site/styles/tailwind.css`
|
|
29
|
+
- Config: `site/site.jsonc`
|
|
30
|
+
- Generated output: `dist/` (`dist/styles.css`, `dist/_idcmd/*.js`, built pages)
|
|
31
|
+
|
|
32
|
+
The mental model is simple: edit `site/content` and `site/code`, treat `dist/` as generated output.
|
|
33
|
+
|
|
34
|
+
## Sync Local Client Files
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
idcmd client add all
|
|
38
|
+
idcmd client update all --dry-run
|
|
39
|
+
idcmd client update layout --yes
|
|
40
|
+
idcmd client update runtime --yes
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
These commands copy the latest baseline implementations from `idcmd` into `site/code/ui/` and `site/code/runtime/`.
|
|
44
|
+
Runtime files in `site/code/runtime/` are compiled automatically by `idcmd dev` and `idcmd build`.
|
|
19
45
|
|
|
20
46
|
## Deploy (Vercel static)
|
|
21
47
|
|
|
@@ -7,7 +7,8 @@
|
|
|
7
7
|
"build": "idcmd build",
|
|
8
8
|
"preview": "idcmd preview",
|
|
9
9
|
"deploy": "idcmd deploy",
|
|
10
|
-
"check": "
|
|
10
|
+
"check": "bun run scripts/check.ts",
|
|
11
|
+
"smoke": "bun run scripts/smoke.ts",
|
|
11
12
|
"test": "bun test",
|
|
12
13
|
"typecheck": "tsc --noEmit -p tsconfig.json",
|
|
13
14
|
"fix": "ultracite fix"
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
interface InternalCheck {
|
|
2
|
+
description: string;
|
|
3
|
+
run: () => Promise<boolean>;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
const fileExists = (path: string): Promise<boolean> => Bun.file(path).exists();
|
|
7
|
+
|
|
8
|
+
const checks: InternalCheck[] = [
|
|
9
|
+
{
|
|
10
|
+
description: "package.json must exist at the project root",
|
|
11
|
+
run: () => fileExists("package.json"),
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
description: "site config must exist (site/site.jsonc)",
|
|
15
|
+
run: () => fileExists("site/site.jsonc"),
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
description: "tailwind input must exist (site/styles/tailwind.css)",
|
|
19
|
+
run: () => fileExists("site/styles/tailwind.css"),
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
description: "site code UI entry must exist (site/code/ui/layout.tsx)",
|
|
23
|
+
run: () => fileExists("site/code/ui/layout.tsx"),
|
|
24
|
+
},
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
const runInternalChecks = async (): Promise<string[]> => {
|
|
28
|
+
const failures: string[] = [];
|
|
29
|
+
|
|
30
|
+
for (const check of checks) {
|
|
31
|
+
// eslint-disable-next-line no-await-in-loop
|
|
32
|
+
const ok = await check.run();
|
|
33
|
+
if (!ok) {
|
|
34
|
+
failures.push(check.description);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return failures;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const main = async (): Promise<number> => {
|
|
42
|
+
const failures = await runInternalChecks();
|
|
43
|
+
if (failures.length === 0) {
|
|
44
|
+
console.log("Internal checks passed.");
|
|
45
|
+
return 0;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
console.error("Internal checks failed:");
|
|
49
|
+
for (const failure of failures) {
|
|
50
|
+
console.error(`- ${failure}`);
|
|
51
|
+
}
|
|
52
|
+
return 1;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const code = await main();
|
|
56
|
+
process.exit(code);
|
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
type StepStatus = "pass" | "fail";
|
|
2
|
+
|
|
3
|
+
export interface CheckStep {
|
|
4
|
+
command: string[];
|
|
5
|
+
id: string;
|
|
6
|
+
name: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface StepResult {
|
|
10
|
+
durationMs: number;
|
|
11
|
+
output: string;
|
|
12
|
+
status: StepStatus;
|
|
13
|
+
step: CheckStep;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface TruncatedOutput {
|
|
17
|
+
omittedLines: number;
|
|
18
|
+
text: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface StepCounts {
|
|
22
|
+
failed: number;
|
|
23
|
+
passed: number;
|
|
24
|
+
status: StepStatus;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const LINT_CONFIG_ERROR =
|
|
28
|
+
"No linter configuration found. Run `bun x ultracite init` once in this project.";
|
|
29
|
+
const NO_TESTS_MESSAGE = "No tests found; skipping test step.";
|
|
30
|
+
const MAX_OUTPUT_LINES = 120;
|
|
31
|
+
const LINT_TARGETS = [
|
|
32
|
+
"README.md",
|
|
33
|
+
"package.json",
|
|
34
|
+
"tsconfig.json",
|
|
35
|
+
"vercel.json",
|
|
36
|
+
".oxlintrc.json",
|
|
37
|
+
".oxfmtrc.jsonc",
|
|
38
|
+
"scripts",
|
|
39
|
+
"site/code",
|
|
40
|
+
"site/content",
|
|
41
|
+
"site/assets",
|
|
42
|
+
"site/styles",
|
|
43
|
+
"site/site.jsonc",
|
|
44
|
+
];
|
|
45
|
+
const ROOT_TEST_FILE_PATTERNS = [
|
|
46
|
+
"*.test.ts",
|
|
47
|
+
"*.test.tsx",
|
|
48
|
+
"*.test.js",
|
|
49
|
+
"*.test.jsx",
|
|
50
|
+
"*_test_*.ts",
|
|
51
|
+
"*_test_*.tsx",
|
|
52
|
+
"*_test_*.js",
|
|
53
|
+
"*_test_*.jsx",
|
|
54
|
+
"*.spec.ts",
|
|
55
|
+
"*.spec.tsx",
|
|
56
|
+
"*.spec.js",
|
|
57
|
+
"*.spec.jsx",
|
|
58
|
+
"*_spec_*.ts",
|
|
59
|
+
"*_spec_*.tsx",
|
|
60
|
+
"*_spec_*.js",
|
|
61
|
+
"*_spec_*.jsx",
|
|
62
|
+
];
|
|
63
|
+
const NESTED_TEST_FILE_PATTERNS = [
|
|
64
|
+
"**/*.test.ts",
|
|
65
|
+
"**/*.test.tsx",
|
|
66
|
+
"**/*.test.js",
|
|
67
|
+
"**/*.test.jsx",
|
|
68
|
+
"**/*_test_*.ts",
|
|
69
|
+
"**/*_test_*.tsx",
|
|
70
|
+
"**/*_test_*.js",
|
|
71
|
+
"**/*_test_*.jsx",
|
|
72
|
+
"**/*.spec.ts",
|
|
73
|
+
"**/*.spec.tsx",
|
|
74
|
+
"**/*.spec.js",
|
|
75
|
+
"**/*.spec.jsx",
|
|
76
|
+
"**/*_spec_*.ts",
|
|
77
|
+
"**/*_spec_*.tsx",
|
|
78
|
+
"**/*_spec_*.js",
|
|
79
|
+
"**/*_spec_*.jsx",
|
|
80
|
+
];
|
|
81
|
+
const TEST_SOURCE_DIRS = ["site", "scripts", "src", "tests"];
|
|
82
|
+
|
|
83
|
+
const buildScopedTestFilePatterns = (): string[] => {
|
|
84
|
+
const patterns = [...ROOT_TEST_FILE_PATTERNS];
|
|
85
|
+
|
|
86
|
+
for (const dir of TEST_SOURCE_DIRS) {
|
|
87
|
+
for (const nestedPattern of NESTED_TEST_FILE_PATTERNS) {
|
|
88
|
+
patterns.push(`${dir}/${nestedPattern}`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return patterns;
|
|
93
|
+
};
|
|
94
|
+
const SCOPED_TEST_FILE_PATTERNS = buildScopedTestFilePatterns();
|
|
95
|
+
|
|
96
|
+
const steps: CheckStep[] = [
|
|
97
|
+
{
|
|
98
|
+
command: [process.execPath, "scripts/check-internal.ts"],
|
|
99
|
+
id: "internal",
|
|
100
|
+
name: "Internal",
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
command: [process.execPath, "x", "ultracite", "check", ...LINT_TARGETS],
|
|
104
|
+
id: "lint",
|
|
105
|
+
name: "Lint",
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
command: [process.execPath, "run", "typecheck"],
|
|
109
|
+
id: "typecheck",
|
|
110
|
+
name: "Typecheck",
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
command: [process.execPath, "run", "test"],
|
|
114
|
+
id: "tests",
|
|
115
|
+
name: "Tests",
|
|
116
|
+
},
|
|
117
|
+
];
|
|
118
|
+
|
|
119
|
+
const hasAnyLinterConfig = async (): Promise<boolean> => {
|
|
120
|
+
const candidates = [
|
|
121
|
+
".oxlintrc.json",
|
|
122
|
+
"eslint.config.js",
|
|
123
|
+
"eslint.config.cjs",
|
|
124
|
+
"eslint.config.mjs",
|
|
125
|
+
"eslint.config.ts",
|
|
126
|
+
"eslint.config.cts",
|
|
127
|
+
"eslint.config.mts",
|
|
128
|
+
];
|
|
129
|
+
|
|
130
|
+
for (const file of candidates) {
|
|
131
|
+
// eslint-disable-next-line no-await-in-loop
|
|
132
|
+
if (await Bun.file(file).exists()) {
|
|
133
|
+
return true;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return false;
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
const hasAnyFilesMatching = async (pattern: string): Promise<boolean> => {
|
|
141
|
+
const glob = new Bun.Glob(pattern);
|
|
142
|
+
for await (const _path of glob.scan(".")) {
|
|
143
|
+
return true;
|
|
144
|
+
}
|
|
145
|
+
return false;
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
const hasAnyTestFiles = async (): Promise<boolean> => {
|
|
149
|
+
for (const pattern of SCOPED_TEST_FILE_PATTERNS) {
|
|
150
|
+
// eslint-disable-next-line no-await-in-loop
|
|
151
|
+
if (await hasAnyFilesMatching(pattern)) {
|
|
152
|
+
return true;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return false;
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
const isFailure = (result: StepResult): boolean => result.status === "fail";
|
|
159
|
+
|
|
160
|
+
const runCommandStep = async (step: CheckStep): Promise<StepResult> => {
|
|
161
|
+
const startedAt = performance.now();
|
|
162
|
+
const child = Bun.spawn(step.command, {
|
|
163
|
+
cwd: process.cwd(),
|
|
164
|
+
stderr: "pipe",
|
|
165
|
+
stdout: "pipe",
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
const [stdout, stderr, exitCode] = await Promise.all([
|
|
169
|
+
new Response(child.stdout).text(),
|
|
170
|
+
new Response(child.stderr).text(),
|
|
171
|
+
child.exited,
|
|
172
|
+
]);
|
|
173
|
+
|
|
174
|
+
return {
|
|
175
|
+
durationMs: Math.round(performance.now() - startedAt),
|
|
176
|
+
output: `${stdout}${stderr}`.trim(),
|
|
177
|
+
status: exitCode === 0 ? "pass" : "fail",
|
|
178
|
+
step,
|
|
179
|
+
};
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
const lintConfigFailure = (step: CheckStep): StepResult => ({
|
|
183
|
+
durationMs: 0,
|
|
184
|
+
output: LINT_CONFIG_ERROR,
|
|
185
|
+
status: "fail",
|
|
186
|
+
step,
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
const skippedTestsResult = (step: CheckStep): StepResult => ({
|
|
190
|
+
durationMs: 0,
|
|
191
|
+
output: NO_TESTS_MESSAGE,
|
|
192
|
+
status: "pass",
|
|
193
|
+
step,
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
const runStep = async (step: CheckStep): Promise<StepResult> => {
|
|
197
|
+
if (step.id === "lint" && !(await hasAnyLinterConfig())) {
|
|
198
|
+
return lintConfigFailure(step);
|
|
199
|
+
}
|
|
200
|
+
if (step.id === "tests" && !(await hasAnyTestFiles())) {
|
|
201
|
+
return skippedTestsResult(step);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return runCommandStep(step);
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
const runSteps = async (): Promise<StepResult[]> => {
|
|
208
|
+
const results: StepResult[] = [];
|
|
209
|
+
|
|
210
|
+
for (const step of steps) {
|
|
211
|
+
// eslint-disable-next-line no-await-in-loop
|
|
212
|
+
const result = await runStep(step);
|
|
213
|
+
results.push(result);
|
|
214
|
+
|
|
215
|
+
if (isFailure(result)) {
|
|
216
|
+
break;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return results;
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
export const truncateOutput = (
|
|
224
|
+
output: string,
|
|
225
|
+
maxLines = MAX_OUTPUT_LINES
|
|
226
|
+
): TruncatedOutput => {
|
|
227
|
+
const normalized = output.trim();
|
|
228
|
+
if (normalized.length === 0) {
|
|
229
|
+
return { omittedLines: 0, text: "" };
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const lines = normalized.split("\n");
|
|
233
|
+
if (lines.length <= maxLines) {
|
|
234
|
+
return { omittedLines: 0, text: normalized };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
return {
|
|
238
|
+
omittedLines: lines.length - maxLines,
|
|
239
|
+
text: lines.slice(0, maxLines).join("\n"),
|
|
240
|
+
};
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
const getCounts = (results: StepResult[]): StepCounts => {
|
|
244
|
+
const failed = results.filter(isFailure).length;
|
|
245
|
+
const passed = results.length - failed;
|
|
246
|
+
return {
|
|
247
|
+
failed,
|
|
248
|
+
passed,
|
|
249
|
+
status: failed === 0 ? "pass" : "fail",
|
|
250
|
+
};
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
const buildHeaderLines = (results: StepResult[]): string[] => {
|
|
254
|
+
const counts = getCounts(results);
|
|
255
|
+
return [
|
|
256
|
+
"# Check Report",
|
|
257
|
+
"",
|
|
258
|
+
`- status: ${counts.status}`,
|
|
259
|
+
`- steps_total: ${results.length}`,
|
|
260
|
+
`- steps_passed: ${counts.passed}`,
|
|
261
|
+
`- steps_failed: ${counts.failed}`,
|
|
262
|
+
"",
|
|
263
|
+
];
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
const buildFailureOutputLines = (output: string): string[] => {
|
|
267
|
+
if (output.length === 0) {
|
|
268
|
+
return [];
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const truncated = truncateOutput(output);
|
|
272
|
+
const lines = ["", "### Output", "", "```text", truncated.text];
|
|
273
|
+
|
|
274
|
+
if (truncated.omittedLines > 0) {
|
|
275
|
+
lines.push(`[truncated: omitted ${truncated.omittedLines} lines]`);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
lines.push("```");
|
|
279
|
+
return lines;
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
const buildStepLines = (result: StepResult): string[] => {
|
|
283
|
+
const lines = [
|
|
284
|
+
`## ${result.step.name}`,
|
|
285
|
+
"",
|
|
286
|
+
`- status: ${result.status}`,
|
|
287
|
+
`- duration_ms: ${result.durationMs}`,
|
|
288
|
+
];
|
|
289
|
+
|
|
290
|
+
if (isFailure(result)) {
|
|
291
|
+
lines.push(...buildFailureOutputLines(result.output));
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
lines.push("");
|
|
295
|
+
return lines;
|
|
296
|
+
};
|
|
297
|
+
|
|
298
|
+
const buildSummaryRows = (results: StepResult[]): string[] =>
|
|
299
|
+
results.map(
|
|
300
|
+
(result) =>
|
|
301
|
+
`| ${result.step.name} | ${result.status.toUpperCase()} | ${String(
|
|
302
|
+
result.durationMs
|
|
303
|
+
)} |`
|
|
304
|
+
);
|
|
305
|
+
|
|
306
|
+
const buildSummaryLines = (results: StepResult[]): string[] => [
|
|
307
|
+
"## Summary",
|
|
308
|
+
"",
|
|
309
|
+
"| Step | Status | Duration (ms) |",
|
|
310
|
+
"| --- | --- | ---: |",
|
|
311
|
+
...buildSummaryRows(results),
|
|
312
|
+
];
|
|
313
|
+
|
|
314
|
+
export const renderReport = (results: StepResult[]): string => {
|
|
315
|
+
const lines = [
|
|
316
|
+
...buildHeaderLines(results),
|
|
317
|
+
...results.flatMap(buildStepLines),
|
|
318
|
+
...buildSummaryLines(results),
|
|
319
|
+
];
|
|
320
|
+
return `${lines.join("\n")}\n`;
|
|
321
|
+
};
|
|
322
|
+
|
|
323
|
+
const runCheck = async (): Promise<number> => {
|
|
324
|
+
const results = await runSteps();
|
|
325
|
+
process.stdout.write(renderReport(results));
|
|
326
|
+
return results.some(isFailure) ? 1 : 0;
|
|
327
|
+
};
|
|
328
|
+
|
|
329
|
+
if (import.meta.main) {
|
|
330
|
+
const code = await runCheck();
|
|
331
|
+
process.exit(code);
|
|
332
|
+
}
|