idcmd 0.0.5 → 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 +87 -23
  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
package/src/cli/main.ts CHANGED
@@ -4,6 +4,7 @@ import type { ParsedArgs } from "./args";
4
4
 
5
5
  import { parseArgs } from "./args";
6
6
  import { buildCommand } from "./commands/build";
7
+ import { clientCommand } from "./commands/client";
7
8
  import { deployCommand } from "./commands/deploy";
8
9
  import { devCommand } from "./commands/dev";
9
10
  import { initCommand } from "./commands/init";
@@ -23,12 +24,16 @@ const usage = (): string =>
23
24
  " idcmd build",
24
25
  " idcmd preview [--port <port>]",
25
26
  " idcmd deploy",
27
+ " idcmd client <add|update> <layout|right-rail|search-page|runtime|all> [--dry-run] [--yes]",
26
28
  "",
27
29
  ].join("\n");
28
30
 
29
31
  const asStringFlag = (value: unknown): string | undefined =>
30
32
  typeof value === "string" ? value : undefined;
31
33
 
34
+ const asBooleanFlag = (value: unknown): boolean =>
35
+ value === true || value === "true";
36
+
32
37
  const handleInit = (parsed: ParsedArgs): Promise<number> => {
33
38
  const [dir] = parsed.positionals;
34
39
  return initCommand(dir, parsed.flags);
@@ -46,8 +51,15 @@ const handlePreview = (parsed: ParsedArgs): Promise<number> => {
46
51
 
47
52
  const handleDeploy = (): Promise<number> => deployCommand();
48
53
 
54
+ const handleClient = (parsed: ParsedArgs): Promise<number> =>
55
+ clientCommand(parsed.positionals, {
56
+ dryRun: asBooleanFlag(parsed.flags["dry-run"]),
57
+ yes: asBooleanFlag(parsed.flags.yes),
58
+ });
59
+
49
60
  const handlers: Record<string, (parsed: ParsedArgs) => Promise<number>> = {
50
61
  build: () => handleBuild(),
62
+ client: (parsed) => handleClient(parsed),
51
63
  deploy: () => handleDeploy(),
52
64
  dev: (parsed) => handleDev(parsed),
53
65
  init: (parsed) => handleInit(parsed),
@@ -0,0 +1,92 @@
1
+ import { joinPath } from "./path";
2
+
3
+ const RUNTIME_SOURCE_DIR = joinPath("site", "client", "runtime");
4
+ const RUNTIME_OUTPUT_DIR = joinPath("site", "public", "_idcmd");
5
+
6
+ const RUNTIME_ENTRY_FILES = [
7
+ "live-reload.ts",
8
+ "llm-menu.ts",
9
+ "nav-prefetch.ts",
10
+ "right-rail-scrollspy.ts",
11
+ ] as const;
12
+
13
+ const runtimeEntryPaths = (): string[] =>
14
+ RUNTIME_ENTRY_FILES.map((fileName) => joinPath(RUNTIME_SOURCE_DIR, fileName));
15
+
16
+ const scanRuntimeEntrypoints = async (
17
+ entries: readonly string[]
18
+ ): Promise<{
19
+ existing: string[];
20
+ missing: string[];
21
+ }> => {
22
+ const existing: string[] = [];
23
+ const missing: string[] = [];
24
+
25
+ for (const entry of entries) {
26
+ // eslint-disable-next-line no-await-in-loop
27
+ if (await Bun.file(entry).exists()) {
28
+ existing.push(entry);
29
+ continue;
30
+ }
31
+ missing.push(entry);
32
+ }
33
+
34
+ return { existing, missing };
35
+ };
36
+
37
+ const getRuntimeEntrypoints = async (): Promise<string[]> => {
38
+ const entries = runtimeEntryPaths();
39
+ const { existing, missing } = await scanRuntimeEntrypoints(entries);
40
+
41
+ if (existing.length === 0) {
42
+ return [];
43
+ }
44
+
45
+ if (missing.length > 0) {
46
+ throw new Error(
47
+ `Incomplete runtime scripts in ${RUNTIME_SOURCE_DIR}. Missing: ${missing.join(", ")}`
48
+ );
49
+ }
50
+
51
+ return existing;
52
+ };
53
+
54
+ const buildRuntimeArgs = (entrypoints: string[], watch: boolean): string[] => [
55
+ "bun",
56
+ "build",
57
+ ...entrypoints,
58
+ "--format",
59
+ "iife",
60
+ "--outdir",
61
+ RUNTIME_OUTPUT_DIR,
62
+ "--target",
63
+ "browser",
64
+ ...(watch ? ["--watch"] : []),
65
+ ];
66
+
67
+ export const compileRuntimeAssetsOnce = async (): Promise<number> => {
68
+ const entrypoints = await getRuntimeEntrypoints();
69
+ if (entrypoints.length === 0) {
70
+ return 0;
71
+ }
72
+
73
+ const proc = Bun.spawn(buildRuntimeArgs(entrypoints, false), {
74
+ stderr: "inherit",
75
+ stdout: "inherit",
76
+ });
77
+ return proc.exited;
78
+ };
79
+
80
+ export const watchRuntimeAssets = async (): Promise<ReturnType<
81
+ typeof Bun.spawn
82
+ > | null> => {
83
+ const entrypoints = await getRuntimeEntrypoints();
84
+ if (entrypoints.length === 0) {
85
+ return null;
86
+ }
87
+
88
+ return Bun.spawn(buildRuntimeArgs(entrypoints, true), {
89
+ stderr: "inherit",
90
+ stdout: "inherit",
91
+ });
92
+ };
@@ -1,7 +1,13 @@
1
- export type { LayoutProps } from "../render/layout";
1
+ export type { LayoutProps, RenderLayout } from "../render/layout";
2
2
  export { renderLayout } from "../render/layout";
3
3
 
4
4
  export type { TocItem } from "../render/toc";
5
+ export type { RightRailComponent, RightRailProps } from "../render/right-rail";
5
6
  export { RightRail } from "../render/right-rail";
6
7
 
8
+ export type {
9
+ RenderSearchPageContent,
10
+ SearchPageProps,
11
+ TopPageLink,
12
+ } from "../search/page";
7
13
  export { renderSearchPageContent } from "../search/page";
@@ -1,9 +1,7 @@
1
- import type { LayoutProps } from "./layout";
1
+ import type { RenderLayout } from "./layout";
2
2
 
3
3
  import { renderLayout as defaultRenderLayout } from "./layout";
4
4
 
5
- export type RenderLayout = (props: LayoutProps) => string;
6
-
7
5
  const USER_LAYOUT_PATH = "site/client/layout.tsx";
8
6
 
9
7
  const loadUserLayout = async (
@@ -44,3 +42,8 @@ export const getRenderLayout = async (): Promise<RenderLayout> => {
44
42
  cached = await tryLoadUserRenderLayout();
45
43
  return cached ?? defaultRenderLayout;
46
44
  };
45
+
46
+ export const resetLayoutLoaderForTests = (): void => {
47
+ cached = null;
48
+ attempted = false;
49
+ };
@@ -5,6 +5,7 @@ import { render } from "preact-render-to-string";
5
5
 
6
6
  import type { NavGroup, NavItem } from "../content/navigation";
7
7
  import type { ResolvedRightRailConfig } from "../site/config";
8
+ import type { RightRailComponent } from "./right-rail";
8
9
  import type { TocItem } from "./toc";
9
10
 
10
11
  import { RightRail } from "./right-rail";
@@ -22,10 +23,13 @@ export interface LayoutProps {
22
23
  scriptPaths?: string[];
23
24
  searchQuery?: string;
24
25
  showRightRail?: boolean;
26
+ rightRailComponent?: RightRailComponent;
25
27
  rightRail: ResolvedRightRailConfig;
26
28
  tocItems: TocItem[];
27
29
  }
28
30
 
31
+ export type RenderLayout = (props: LayoutProps) => string;
32
+
29
33
  const Icon = ({ svg }: { svg: string }): JSX.Element => (
30
34
  <span
31
35
  class="inline-flex w-[18px] h-[18px]"
@@ -231,6 +235,7 @@ interface DocumentBodyProps {
231
235
  shouldShowRightRail: boolean;
232
236
  siteName: string;
233
237
  tocItems: TocItem[];
238
+ rightRailComponent: RightRailComponent;
234
239
  }
235
240
 
236
241
  const DocumentBody = ({
@@ -245,6 +250,7 @@ const DocumentBody = ({
245
250
  shouldShowRightRail,
246
251
  siteName,
247
252
  tocItems,
253
+ rightRailComponent: RightRailComponent,
248
254
  }: DocumentBodyProps): JSX.Element => (
249
255
  <body
250
256
  class="bg-background text-foreground font-sans"
@@ -266,7 +272,7 @@ const DocumentBody = ({
266
272
  dangerouslySetInnerHTML={{ __html: content }}
267
273
  />
268
274
  {shouldShowRightRail ? (
269
- <RightRail
275
+ <RightRailComponent
270
276
  canonicalUrl={canonicalUrl}
271
277
  currentPath={currentPath}
272
278
  tocItems={tocItems}
@@ -299,6 +305,7 @@ const Layout = ({
299
305
  scriptPaths = [],
300
306
  searchQuery,
301
307
  showRightRail = true,
308
+ rightRailComponent = RightRail,
302
309
  rightRail,
303
310
  tocItems,
304
311
  }: LayoutProps): JSX.Element => {
@@ -330,10 +337,11 @@ const Layout = ({
330
337
  shouldShowRightRail={shouldShowRightRail}
331
338
  siteName={siteName}
332
339
  tocItems={tocItems}
340
+ rightRailComponent={rightRailComponent}
333
341
  />
334
342
  </html>
335
343
  );
336
344
  };
337
345
 
338
- export const renderLayout = (props: LayoutProps): string =>
346
+ export const renderLayout: RenderLayout = (props) =>
339
347
  `<!DOCTYPE html>${render(<Layout {...props} />)}`;
@@ -13,6 +13,7 @@ import { loadSiteConfig, resolveRightRailConfig } from "../site/config";
13
13
  import { resolveCanonicalUrl } from "../site/urls";
14
14
  import { getRenderLayout } from "./layout-loader";
15
15
  import { renderMarkdownToHtml } from "./markdown";
16
+ import { getRightRail } from "./right-rail-loader";
16
17
  import { extractTocFromHtml } from "./toc";
17
18
 
18
19
  const ASSET_PREFIX = "/_idcmd";
@@ -118,7 +119,14 @@ export const renderDocument = async (options: {
118
119
  title: string;
119
120
  tocItems: ReturnType<typeof extractTocFromHtml>;
120
121
  }): Promise<string> => {
121
- const renderLayout = await getRenderLayout();
122
+ const [renderLayout, rightRailComponent] = await Promise.all([
123
+ getRenderLayout(),
124
+ getRightRail(),
125
+ ]);
126
+ const scriptPaths = options.scriptPaths ?? [
127
+ `${ASSET_PREFIX}/nav-prefetch.js`,
128
+ ];
129
+
122
130
  return renderLayout({
123
131
  canonicalUrl: options.canonicalUrl,
124
132
  content: options.contentHtml,
@@ -128,7 +136,8 @@ export const renderDocument = async (options: {
128
136
  inlineCss: options.inlineCss,
129
137
  navigation: options.navigation,
130
138
  rightRail: options.rightRail,
131
- scriptPaths: options.scriptPaths,
139
+ rightRailComponent,
140
+ scriptPaths,
132
141
  searchQuery: options.searchQuery,
133
142
  showRightRail: options.showRightRail,
134
143
  siteName: options.siteName,
@@ -244,6 +253,7 @@ const computeScriptPaths = (options: {
244
253
  shouldShowRightRail: boolean;
245
254
  tocItems: readonly unknown[];
246
255
  }): string[] => [
256
+ `${ASSET_PREFIX}/nav-prefetch.js`,
247
257
  ...(options.isDev ? [`${ASSET_PREFIX}/live-reload.js`] : []),
248
258
  ...(options.shouldShowRightRail ? [`${ASSET_PREFIX}/llm-menu.js`] : []),
249
259
  ...(options.shouldShowRightRail &&
@@ -0,0 +1,49 @@
1
+ import type { RightRailComponent } from "./right-rail";
2
+
3
+ import { RightRail as defaultRightRail } from "./right-rail";
4
+
5
+ const USER_RIGHT_RAIL_PATH = "site/client/right-rail.tsx";
6
+
7
+ const loadUserRightRail = async (
8
+ filePath: string
9
+ ): Promise<RightRailComponent | null> => {
10
+ try {
11
+ const url = Bun.pathToFileURL(filePath);
12
+ const mod = (await import(url.toString())) as unknown as {
13
+ RightRail?: RightRailComponent;
14
+ };
15
+ return mod.RightRail ?? null;
16
+ } catch (error) {
17
+ const message = error instanceof Error ? error.message : String(error);
18
+ console.warn(
19
+ `[right-rail] Failed to load user right rail from ${filePath}: ${message}`
20
+ );
21
+ return null;
22
+ }
23
+ };
24
+
25
+ const tryLoadUserRightRail = async (): Promise<RightRailComponent | null> => {
26
+ const exists = await Bun.file(USER_RIGHT_RAIL_PATH).exists();
27
+ if (!exists) {
28
+ return null;
29
+ }
30
+ return loadUserRightRail(USER_RIGHT_RAIL_PATH);
31
+ };
32
+
33
+ let cached: RightRailComponent | null = null;
34
+ let attempted = false;
35
+
36
+ export const getRightRail = async (): Promise<RightRailComponent> => {
37
+ if (attempted) {
38
+ return cached ?? defaultRightRail;
39
+ }
40
+
41
+ attempted = true;
42
+ cached = await tryLoadUserRightRail();
43
+ return cached ?? defaultRightRail;
44
+ };
45
+
46
+ export const resetRightRailLoaderForTests = (): void => {
47
+ cached = null;
48
+ attempted = false;
49
+ };
@@ -216,17 +216,21 @@ const getPanelClass = (
216
216
  ? "fixed top-24 bottom-0 right-8 z-20 w-64 flex flex-col gap-6 min-h-0"
217
217
  : "sticky top-24 h-[calc(100vh-6rem)] flex flex-col gap-6 min-h-0";
218
218
 
219
+ export interface RightRailProps {
220
+ canonicalUrl?: string;
221
+ currentPath: string;
222
+ tocItems: TocItem[];
223
+ rightRailConfig: ResolvedRightRailConfig;
224
+ }
225
+
226
+ export type RightRailComponent = (props: RightRailProps) => JSX.Element | null;
227
+
219
228
  export const RightRail = ({
220
229
  canonicalUrl,
221
230
  currentPath,
222
231
  tocItems,
223
232
  rightRailConfig,
224
- }: {
225
- canonicalUrl?: string;
226
- currentPath: string;
227
- tocItems: TocItem[];
228
- rightRailConfig: ResolvedRightRailConfig;
229
- }): JSX.Element => {
233
+ }: RightRailProps): JSX.Element => {
230
234
  const { chatgptUrl, claudeUrl, markdownPath } = buildAskUrls({
231
235
  canonicalUrl,
232
236
  currentPath,
@@ -9,13 +9,15 @@ export interface TopPageLink {
9
9
  title: string;
10
10
  }
11
11
 
12
- interface SearchPageProps {
12
+ export interface SearchPageProps {
13
13
  query: string;
14
14
  minQueryLength: number;
15
15
  results: SearchResult[];
16
16
  topPages: TopPageLink[];
17
17
  }
18
18
 
19
+ export type RenderSearchPageContent = (props: SearchPageProps) => string;
20
+
19
21
  const ResultItem = ({ result }: { result: SearchResult }): JSX.Element => (
20
22
  <li class="rounded-md border border-border p-3">
21
23
  <a
@@ -92,5 +94,5 @@ const SearchPage = ({
92
94
  );
93
95
  };
94
96
 
95
- export const renderSearchPageContent = (props: SearchPageProps): string =>
97
+ export const renderSearchPageContent: RenderSearchPageContent = (props) =>
96
98
  renderToString(<SearchPage {...props} />);
@@ -0,0 +1,51 @@
1
+ import type { RenderSearchPageContent } from "./page";
2
+
3
+ import { renderSearchPageContent as defaultRenderSearchPageContent } from "./page";
4
+
5
+ const USER_SEARCH_PAGE_PATH = "site/client/search-page.tsx";
6
+
7
+ const loadUserSearchPage = async (
8
+ filePath: string
9
+ ): Promise<RenderSearchPageContent | null> => {
10
+ try {
11
+ const url = Bun.pathToFileURL(filePath);
12
+ const mod = (await import(url.toString())) as unknown as {
13
+ renderSearchPageContent?: RenderSearchPageContent;
14
+ };
15
+ return mod.renderSearchPageContent ?? null;
16
+ } catch (error) {
17
+ const message = error instanceof Error ? error.message : String(error);
18
+ console.warn(
19
+ `[search-page] Failed to load user search page from ${filePath}: ${message}`
20
+ );
21
+ return null;
22
+ }
23
+ };
24
+
25
+ const tryLoadUserSearchPage =
26
+ async (): Promise<RenderSearchPageContent | null> => {
27
+ const exists = await Bun.file(USER_SEARCH_PAGE_PATH).exists();
28
+ if (!exists) {
29
+ return null;
30
+ }
31
+ return loadUserSearchPage(USER_SEARCH_PAGE_PATH);
32
+ };
33
+
34
+ let cached: RenderSearchPageContent | null = null;
35
+ let attempted = false;
36
+
37
+ export const getRenderSearchPageContent =
38
+ async (): Promise<RenderSearchPageContent> => {
39
+ if (attempted) {
40
+ return cached ?? defaultRenderSearchPageContent;
41
+ }
42
+
43
+ attempted = true;
44
+ cached = await tryLoadUserSearchPage();
45
+ return cached ?? defaultRenderSearchPageContent;
46
+ };
47
+
48
+ export const resetSearchPageLoaderForTests = (): void => {
49
+ cached = null;
50
+ attempted = false;
51
+ };
@@ -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 { renderSearchPageContent } from "./page";
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.searchParams.get("q")?.trim() ?? "";
85
+ const query = getSearchQuery(url);
47
86
  const rightRail = resolveRightRailConfig(siteConfig.rightRail);
48
87
 
49
- const [navigation, index] = await Promise.all([
50
- getNavigation(env.isDev),
51
- loadSearchIndex({ forceRefresh: env.isDev, siteConfig }),
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,
@@ -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,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);