idcmd 0.0.4 → 0.0.6

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.
Files changed (32) hide show
  1. package/README.md +23 -4
  2. package/package.json +2 -2
  3. package/src/build.ts +4 -3
  4. package/src/cli/commands/build.ts +7 -0
  5. package/src/cli/commands/client.ts +317 -0
  6. package/src/cli/commands/dev.ts +89 -24
  7. package/src/cli/commands/init.ts +93 -2
  8. package/src/cli/main.ts +12 -0
  9. package/src/cli/runtime-assets.ts +92 -0
  10. package/src/client/index.ts +7 -1
  11. package/src/render/layout-loader.ts +6 -3
  12. package/src/render/layout.tsx +10 -2
  13. package/src/render/page-renderer.ts +12 -2
  14. package/src/render/right-rail-loader.ts +49 -0
  15. package/src/render/right-rail.tsx +10 -6
  16. package/src/search/page.tsx +4 -2
  17. package/src/search/search-page-loader.ts +51 -0
  18. package/src/search/server-page.ts +52 -18
  19. package/templates/default/.github/workflows/ci.yml +24 -0
  20. package/templates/default/README.md +23 -0
  21. package/templates/default/package.json +2 -1
  22. package/templates/default/scripts/check-internal.ts +56 -0
  23. package/templates/default/scripts/check.ts +318 -0
  24. package/templates/default/scripts/smoke.ts +193 -0
  25. package/templates/default/site/client/layout.tsx +237 -2
  26. package/templates/default/site/client/right-rail.tsx +246 -1
  27. package/templates/default/site/{public/_idcmd/llm-menu.js → client/runtime/llm-menu.ts} +27 -18
  28. package/templates/default/site/{public/_idcmd/nav-prefetch.js → client/runtime/nav-prefetch.ts} +3 -3
  29. package/templates/default/site/{public/_idcmd/right-rail-scrollspy.js → client/runtime/right-rail-scrollspy.ts} +73 -32
  30. package/templates/default/site/client/search-page.tsx +87 -1
  31. package/templates/default/tsconfig.json +1 -1
  32. /package/templates/default/site/{public/_idcmd/live-reload.js → client/runtime/live-reload.ts} +0 -0
@@ -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,14 +11,35 @@ 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
23
  - `site/content/` markdown pages (`index.md` -> `/`, `about.md` -> `/about/`)
24
+ - `site/client/` local UI implementation (`layout.tsx`, `right-rail.tsx`, `search-page.tsx`)
25
+ - `site/client/runtime/` local browser runtime TS (`*_idcmd` scripts compile from here)
15
26
  - `site/styles/tailwind.css` Tailwind entrypoint (compiled to `site/public/styles.css`)
16
27
  - `site/public/` static assets
17
28
  - `site/server/routes/` file-based server routes (dev/server-host only)
18
29
  - `site/site.jsonc` site configuration
19
30
 
31
+ ## Sync Local Client Files
32
+
33
+ ```bash
34
+ idcmd client add all
35
+ idcmd client update all --dry-run
36
+ idcmd client update layout --yes
37
+ idcmd client update runtime --yes
38
+ ```
39
+
40
+ These commands copy the latest baseline implementations from `idcmd` into `site/client/`.
41
+ Runtime files in `site/client/runtime/` are compiled automatically by `idcmd dev` and `idcmd build`.
42
+
20
43
  ## Deploy (Vercel static)
21
44
 
22
45
  ```bash
@@ -7,7 +7,8 @@
7
7
  "build": "idcmd build",
8
8
  "preview": "idcmd preview",
9
9
  "deploy": "idcmd deploy",
10
- "check": "ultracite check && bun run typecheck && bun run test",
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 or site.jsonc)",
15
+ run: async () =>
16
+ (await fileExists("site/site.jsonc")) || (await fileExists("site.jsonc")),
17
+ },
18
+ {
19
+ description:
20
+ "tailwind input must exist (site/styles/tailwind.css or content/styles.css)",
21
+ run: async () =>
22
+ (await fileExists("site/styles/tailwind.css")) ||
23
+ (await fileExists("content/styles.css")),
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,318 @@
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 ROOT_TEST_FILE_PATTERNS = [
32
+ "*.test.ts",
33
+ "*.test.tsx",
34
+ "*.test.js",
35
+ "*.test.jsx",
36
+ "*_test_*.ts",
37
+ "*_test_*.tsx",
38
+ "*_test_*.js",
39
+ "*_test_*.jsx",
40
+ "*.spec.ts",
41
+ "*.spec.tsx",
42
+ "*.spec.js",
43
+ "*.spec.jsx",
44
+ "*_spec_*.ts",
45
+ "*_spec_*.tsx",
46
+ "*_spec_*.js",
47
+ "*_spec_*.jsx",
48
+ ];
49
+ const NESTED_TEST_FILE_PATTERNS = [
50
+ "**/*.test.ts",
51
+ "**/*.test.tsx",
52
+ "**/*.test.js",
53
+ "**/*.test.jsx",
54
+ "**/*_test_*.ts",
55
+ "**/*_test_*.tsx",
56
+ "**/*_test_*.js",
57
+ "**/*_test_*.jsx",
58
+ "**/*.spec.ts",
59
+ "**/*.spec.tsx",
60
+ "**/*.spec.js",
61
+ "**/*.spec.jsx",
62
+ "**/*_spec_*.ts",
63
+ "**/*_spec_*.tsx",
64
+ "**/*_spec_*.js",
65
+ "**/*_spec_*.jsx",
66
+ ];
67
+ const TEST_SOURCE_DIRS = ["site", "scripts", "src", "tests"];
68
+
69
+ const buildScopedTestFilePatterns = (): string[] => {
70
+ const patterns = [...ROOT_TEST_FILE_PATTERNS];
71
+
72
+ for (const dir of TEST_SOURCE_DIRS) {
73
+ for (const nestedPattern of NESTED_TEST_FILE_PATTERNS) {
74
+ patterns.push(`${dir}/${nestedPattern}`);
75
+ }
76
+ }
77
+
78
+ return patterns;
79
+ };
80
+ const SCOPED_TEST_FILE_PATTERNS = buildScopedTestFilePatterns();
81
+
82
+ const steps: CheckStep[] = [
83
+ {
84
+ command: [process.execPath, "scripts/check-internal.ts"],
85
+ id: "internal",
86
+ name: "Internal",
87
+ },
88
+ {
89
+ command: [process.execPath, "x", "ultracite", "check"],
90
+ id: "lint",
91
+ name: "Lint",
92
+ },
93
+ {
94
+ command: [process.execPath, "run", "typecheck"],
95
+ id: "typecheck",
96
+ name: "Typecheck",
97
+ },
98
+ {
99
+ command: [process.execPath, "run", "test"],
100
+ id: "tests",
101
+ name: "Tests",
102
+ },
103
+ ];
104
+
105
+ const hasAnyLinterConfig = async (): Promise<boolean> => {
106
+ const candidates = [
107
+ ".oxlintrc.json",
108
+ "eslint.config.js",
109
+ "eslint.config.cjs",
110
+ "eslint.config.mjs",
111
+ "eslint.config.ts",
112
+ "eslint.config.cts",
113
+ "eslint.config.mts",
114
+ ];
115
+
116
+ for (const file of candidates) {
117
+ // eslint-disable-next-line no-await-in-loop
118
+ if (await Bun.file(file).exists()) {
119
+ return true;
120
+ }
121
+ }
122
+
123
+ return false;
124
+ };
125
+
126
+ const hasAnyFilesMatching = async (pattern: string): Promise<boolean> => {
127
+ const glob = new Bun.Glob(pattern);
128
+ for await (const _path of glob.scan(".")) {
129
+ return true;
130
+ }
131
+ return false;
132
+ };
133
+
134
+ const hasAnyTestFiles = async (): Promise<boolean> => {
135
+ for (const pattern of SCOPED_TEST_FILE_PATTERNS) {
136
+ // eslint-disable-next-line no-await-in-loop
137
+ if (await hasAnyFilesMatching(pattern)) {
138
+ return true;
139
+ }
140
+ }
141
+ return false;
142
+ };
143
+
144
+ const isFailure = (result: StepResult): boolean => result.status === "fail";
145
+
146
+ const runCommandStep = async (step: CheckStep): Promise<StepResult> => {
147
+ const startedAt = performance.now();
148
+ const child = Bun.spawn(step.command, {
149
+ cwd: process.cwd(),
150
+ stderr: "pipe",
151
+ stdout: "pipe",
152
+ });
153
+
154
+ const [stdout, stderr, exitCode] = await Promise.all([
155
+ new Response(child.stdout).text(),
156
+ new Response(child.stderr).text(),
157
+ child.exited,
158
+ ]);
159
+
160
+ return {
161
+ durationMs: Math.round(performance.now() - startedAt),
162
+ output: `${stdout}${stderr}`.trim(),
163
+ status: exitCode === 0 ? "pass" : "fail",
164
+ step,
165
+ };
166
+ };
167
+
168
+ const lintConfigFailure = (step: CheckStep): StepResult => ({
169
+ durationMs: 0,
170
+ output: LINT_CONFIG_ERROR,
171
+ status: "fail",
172
+ step,
173
+ });
174
+
175
+ const skippedTestsResult = (step: CheckStep): StepResult => ({
176
+ durationMs: 0,
177
+ output: NO_TESTS_MESSAGE,
178
+ status: "pass",
179
+ step,
180
+ });
181
+
182
+ const runStep = async (step: CheckStep): Promise<StepResult> => {
183
+ if (step.id === "lint" && !(await hasAnyLinterConfig())) {
184
+ return lintConfigFailure(step);
185
+ }
186
+ if (step.id === "tests" && !(await hasAnyTestFiles())) {
187
+ return skippedTestsResult(step);
188
+ }
189
+
190
+ return runCommandStep(step);
191
+ };
192
+
193
+ const runSteps = async (): Promise<StepResult[]> => {
194
+ const results: StepResult[] = [];
195
+
196
+ for (const step of steps) {
197
+ // eslint-disable-next-line no-await-in-loop
198
+ const result = await runStep(step);
199
+ results.push(result);
200
+
201
+ if (isFailure(result)) {
202
+ break;
203
+ }
204
+ }
205
+
206
+ return results;
207
+ };
208
+
209
+ export const truncateOutput = (
210
+ output: string,
211
+ maxLines = MAX_OUTPUT_LINES
212
+ ): TruncatedOutput => {
213
+ const normalized = output.trim();
214
+ if (normalized.length === 0) {
215
+ return { omittedLines: 0, text: "" };
216
+ }
217
+
218
+ const lines = normalized.split("\n");
219
+ if (lines.length <= maxLines) {
220
+ return { omittedLines: 0, text: normalized };
221
+ }
222
+
223
+ return {
224
+ omittedLines: lines.length - maxLines,
225
+ text: lines.slice(0, maxLines).join("\n"),
226
+ };
227
+ };
228
+
229
+ const getCounts = (results: StepResult[]): StepCounts => {
230
+ const failed = results.filter(isFailure).length;
231
+ const passed = results.length - failed;
232
+ return {
233
+ failed,
234
+ passed,
235
+ status: failed === 0 ? "pass" : "fail",
236
+ };
237
+ };
238
+
239
+ const buildHeaderLines = (results: StepResult[]): string[] => {
240
+ const counts = getCounts(results);
241
+ return [
242
+ "# Check Report",
243
+ "",
244
+ `- status: ${counts.status}`,
245
+ `- steps_total: ${results.length}`,
246
+ `- steps_passed: ${counts.passed}`,
247
+ `- steps_failed: ${counts.failed}`,
248
+ "",
249
+ ];
250
+ };
251
+
252
+ const buildFailureOutputLines = (output: string): string[] => {
253
+ if (output.length === 0) {
254
+ return [];
255
+ }
256
+
257
+ const truncated = truncateOutput(output);
258
+ const lines = ["", "### Output", "", "```text", truncated.text];
259
+
260
+ if (truncated.omittedLines > 0) {
261
+ lines.push(`[truncated: omitted ${truncated.omittedLines} lines]`);
262
+ }
263
+
264
+ lines.push("```");
265
+ return lines;
266
+ };
267
+
268
+ const buildStepLines = (result: StepResult): string[] => {
269
+ const lines = [
270
+ `## ${result.step.name}`,
271
+ "",
272
+ `- status: ${result.status}`,
273
+ `- duration_ms: ${result.durationMs}`,
274
+ ];
275
+
276
+ if (isFailure(result)) {
277
+ lines.push(...buildFailureOutputLines(result.output));
278
+ }
279
+
280
+ lines.push("");
281
+ return lines;
282
+ };
283
+
284
+ const buildSummaryRows = (results: StepResult[]): string[] =>
285
+ results.map(
286
+ (result) =>
287
+ `| ${result.step.name} | ${result.status.toUpperCase()} | ${String(
288
+ result.durationMs
289
+ )} |`
290
+ );
291
+
292
+ const buildSummaryLines = (results: StepResult[]): string[] => [
293
+ "## Summary",
294
+ "",
295
+ "| Step | Status | Duration (ms) |",
296
+ "| --- | --- | ---: |",
297
+ ...buildSummaryRows(results),
298
+ ];
299
+
300
+ export const renderReport = (results: StepResult[]): string => {
301
+ const lines = [
302
+ ...buildHeaderLines(results),
303
+ ...results.flatMap(buildStepLines),
304
+ ...buildSummaryLines(results),
305
+ ];
306
+ return `${lines.join("\n")}\n`;
307
+ };
308
+
309
+ const runCheck = async (): Promise<number> => {
310
+ const results = await runSteps();
311
+ process.stdout.write(renderReport(results));
312
+ return results.some(isFailure) ? 1 : 0;
313
+ };
314
+
315
+ if (import.meta.main) {
316
+ const code = await runCheck();
317
+ process.exit(code);
318
+ }
@@ -0,0 +1,193 @@
1
+ interface CommandResult {
2
+ code: number;
3
+ stderr: string;
4
+ stdout: string;
5
+ }
6
+
7
+ const BASE_URL = process.env.IDCMD_SMOKE_BASE_URL ?? "http://127.0.0.1:4000";
8
+ const CURL_MAX_TIME_SECONDS = "5";
9
+ const READY_TIMEOUT_MS = 60_000;
10
+ const READY_INTERVAL_MS = 500;
11
+ const SHUTDOWN_TIMEOUT_MS = 5000;
12
+
13
+ const delay = (ms: number): Promise<void> => Bun.sleep(ms);
14
+
15
+ const runCommand = async (command: string[]): Promise<CommandResult> => {
16
+ const proc = Bun.spawn(command, {
17
+ cwd: process.cwd(),
18
+ stderr: "pipe",
19
+ stdout: "pipe",
20
+ });
21
+ const [stdout, stderr, code] = await Promise.all([
22
+ new Response(proc.stdout).text(),
23
+ new Response(proc.stderr).text(),
24
+ proc.exited,
25
+ ]);
26
+ return { code, stderr, stdout };
27
+ };
28
+
29
+ const runCurl = (path: string): Promise<CommandResult> =>
30
+ runCommand([
31
+ "curl",
32
+ "-fsS",
33
+ "--max-time",
34
+ CURL_MAX_TIME_SECONDS,
35
+ `${BASE_URL}${path}`,
36
+ ]);
37
+
38
+ const assertCommandOk = (label: string, result: CommandResult): void => {
39
+ if (result.code === 0) {
40
+ return;
41
+ }
42
+ throw new Error(
43
+ [
44
+ `${label} failed with exit code ${String(result.code)}.`,
45
+ "stdout:",
46
+ result.stdout.trim() || "(empty)",
47
+ "stderr:",
48
+ result.stderr.trim() || "(empty)",
49
+ ].join("\n")
50
+ );
51
+ };
52
+
53
+ const expectIncludes = (args: {
54
+ haystack: string;
55
+ label: string;
56
+ needle: string;
57
+ }): void => {
58
+ if (args.haystack.includes(args.needle)) {
59
+ return;
60
+ }
61
+ throw new Error(`Expected ${args.label} to include ${args.needle}.`);
62
+ };
63
+
64
+ const waitForReady = async (): Promise<void> => {
65
+ const startedAt = Date.now();
66
+ let lastFailure = "(no attempts yet)";
67
+
68
+ while (Date.now() - startedAt < READY_TIMEOUT_MS) {
69
+ const ready = await runCurl("/");
70
+ if (ready.code === 0) {
71
+ return;
72
+ }
73
+ lastFailure = ready.stderr.trim() || ready.stdout.trim() || "curl failed";
74
+ await delay(READY_INTERVAL_MS);
75
+ }
76
+
77
+ throw new Error(
78
+ `dev server did not become ready within ${String(
79
+ READY_TIMEOUT_MS
80
+ )}ms. Last curl failure: ${lastFailure}`
81
+ );
82
+ };
83
+
84
+ const shutdownDev = async (
85
+ proc: ReturnType<typeof Bun.spawn>
86
+ ): Promise<void> => {
87
+ try {
88
+ proc.kill("SIGTERM");
89
+ } catch {
90
+ return;
91
+ }
92
+
93
+ const didExit = await Promise.race([
94
+ proc.exited.then(() => true),
95
+ delay(SHUTDOWN_TIMEOUT_MS).then(() => false),
96
+ ]);
97
+ if (!didExit) {
98
+ try {
99
+ proc.kill("SIGKILL");
100
+ } catch {
101
+ // ignore
102
+ }
103
+ await proc.exited;
104
+ }
105
+ };
106
+
107
+ const assertHomeResponse = async (): Promise<void> => {
108
+ const home = await runCurl("/");
109
+ assertCommandOk("curl /", home);
110
+ expectIncludes({ haystack: home.stdout, label: "/", needle: "<html" });
111
+ };
112
+
113
+ const assertAboutResponse = async (): Promise<void> => {
114
+ const about = await runCurl("/about/");
115
+ assertCommandOk("curl /about/", about);
116
+ if (!about.stdout.includes("# About") && !about.stdout.includes(">About<")) {
117
+ throw new Error("Expected /about/ response to include About heading.");
118
+ }
119
+ };
120
+
121
+ const assertLlmsResponse = async (): Promise<void> => {
122
+ const llms = await runCurl("/llms.txt");
123
+ assertCommandOk("curl /llms.txt", llms);
124
+ if (llms.stdout.trim().length === 0 || !llms.stdout.includes("about.md")) {
125
+ throw new Error("Expected /llms.txt to be non-empty and include about.md.");
126
+ }
127
+ };
128
+
129
+ const assertApiResponse = async (): Promise<void> => {
130
+ const api = await runCurl("/api/hello");
131
+ assertCommandOk("curl /api/hello", api);
132
+ const payload = JSON.parse(api.stdout) as { message?: string; ok?: boolean };
133
+ if (payload.ok !== true || payload.message !== "Hello from idcmd route!") {
134
+ throw new Error("Expected /api/hello payload to match template route.");
135
+ }
136
+ };
137
+
138
+ const runSmokeChecks = async (): Promise<void> => {
139
+ await assertHomeResponse();
140
+ await assertAboutResponse();
141
+ await assertLlmsResponse();
142
+ await assertApiResponse();
143
+ };
144
+
145
+ const startDev = (): {
146
+ devProc: ReturnType<typeof Bun.spawn>;
147
+ devStderr: Promise<string>;
148
+ devStdout: Promise<string>;
149
+ } => {
150
+ const devProc = Bun.spawn([process.execPath, "run", "dev"], {
151
+ cwd: process.cwd(),
152
+ stderr: "pipe",
153
+ stdout: "pipe",
154
+ });
155
+ return {
156
+ devProc,
157
+ devStderr: new Response(devProc.stderr).text(),
158
+ devStdout: new Response(devProc.stdout).text(),
159
+ };
160
+ };
161
+
162
+ const logDevFailure = async (args: {
163
+ error: unknown;
164
+ stderr: Promise<string>;
165
+ stdout: Promise<string>;
166
+ }): Promise<void> => {
167
+ const [stdout, stderr] = await Promise.all([args.stdout, args.stderr]);
168
+ const message =
169
+ args.error instanceof Error ? args.error.message : String(args.error);
170
+ console.error(message);
171
+ console.error("dev stdout:");
172
+ console.error(stdout.trim() || "(empty)");
173
+ console.error("dev stderr:");
174
+ console.error(stderr.trim() || "(empty)");
175
+ };
176
+
177
+ const main = async (): Promise<number> => {
178
+ const { devProc, devStderr, devStdout } = startDev();
179
+
180
+ try {
181
+ await waitForReady();
182
+ await runSmokeChecks();
183
+ return 0;
184
+ } catch (error) {
185
+ await logDevFailure({ error, stderr: devStderr, stdout: devStdout });
186
+ return 1;
187
+ } finally {
188
+ await shutdownDev(devProc);
189
+ }
190
+ };
191
+
192
+ const code = await main();
193
+ process.exit(code);