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
@@ -1,10 +1,10 @@
1
- import { copyDir, isDirEmpty, replaceInFile } from "../fs";
1
+ import { copyDir, ensureDir, isDirEmpty, replaceInFile } from "../fs";
2
2
  import {
3
3
  normalizeOptionalString,
4
4
  parsePort,
5
5
  toPackageName,
6
6
  } from "../normalize";
7
- import { basename, joinPath } from "../path";
7
+ import { basename, dirname, joinPath } from "../path";
8
8
  import { promptOptionalText, promptText } from "../prompt";
9
9
  import { run } from "../run";
10
10
  import { readPackageVersion } from "../version";
@@ -23,6 +23,60 @@ export interface InitFlags {
23
23
  const resolveTemplateDir = (): string =>
24
24
  joinPath(import.meta.dir, "..", "..", "..", "templates", "default");
25
25
 
26
+ const TEMPLATE_DOTPATHS = [".gitignore", ".github/workflows/ci.yml"];
27
+ const DEFAULT_OXLINT_CONFIG = `{
28
+ "$schema": "./node_modules/oxlint/configuration_schema.json",
29
+ "extends": [
30
+ "./node_modules/ultracite/config/oxlint/core/.oxlintrc.json",
31
+ "./node_modules/ultracite/config/oxlint/react/.oxlintrc.json"
32
+ ],
33
+ "rules": {
34
+ "jest/require-hook": "off"
35
+ },
36
+ "overrides": [
37
+ {
38
+ "files": [
39
+ "**/*.test.ts",
40
+ "**/*.test.tsx",
41
+ "**/*.test.js",
42
+ "**/*.test.jsx",
43
+ "**/*.spec.ts",
44
+ "**/*.spec.tsx",
45
+ "**/*.spec.js",
46
+ "**/*.spec.jsx"
47
+ ],
48
+ "rules": {
49
+ "jest/require-hook": "error"
50
+ }
51
+ }
52
+ ]
53
+ }
54
+ `;
55
+ const DEFAULT_OXFMT_CONFIG = `// Ultracite oxfmt Configuration
56
+ // https://oxc.rs/docs/guide/usage/formatter/config-file-reference.html
57
+ {
58
+ "$schema": "./node_modules/oxfmt/configuration_schema.json",
59
+ "printWidth": 80,
60
+ "tabWidth": 2,
61
+ "useTabs": false,
62
+ "semi": true,
63
+ "singleQuote": false,
64
+ "quoteProps": "as-needed",
65
+ "jsxSingleQuote": false,
66
+ "trailingComma": "es5",
67
+ "bracketSpacing": true,
68
+ "bracketSameLine": false,
69
+ "arrowParens": "always",
70
+ "endOfLine": "lf",
71
+ "experimentalSortPackageJson": true,
72
+ "experimentalSortImports": {
73
+ "ignoreCase": true,
74
+ "newlinesBetween": true,
75
+ "order": "asc",
76
+ },
77
+ }
78
+ `;
79
+
26
80
  const commentOutBaseUrl = (text: string): string =>
27
81
  text.replace(
28
82
  '"baseUrl": "__IDCMD_SITE_BASE_URL__",',
@@ -56,6 +110,11 @@ const fillPackageJson = (args: {
56
110
  .replaceAll("__IDCMD_IDCMD_VERSION__", `^${args.idcmdVersion}`)
57
111
  .replaceAll("__IDCMD_DEV_PORT__", String(args.port));
58
112
 
113
+ const fillReadme = (args: { siteName: string; text: string }): string =>
114
+ args.text
115
+ .replaceAll("__IDCMD_SITE_NAME__", args.siteName)
116
+ .replaceAll("IDCMD_SITE_NAME", args.siteName);
117
+
59
118
  interface InitDefaults {
60
119
  defaultPort: number;
61
120
  defaultSiteName: string;
@@ -105,6 +164,31 @@ const readInitInputs = async (
105
164
  const scaffoldFromTemplate = async (targetDir: string): Promise<void> => {
106
165
  const templateDir = resolveTemplateDir();
107
166
  await copyDir(templateDir, targetDir);
167
+ await copyTemplateDotpaths({ targetDir, templateDir });
168
+ await writeDefaultLintConfigs(targetDir);
169
+ };
170
+
171
+ const copyTemplateDotpaths = async (args: {
172
+ targetDir: string;
173
+ templateDir: string;
174
+ }): Promise<void> => {
175
+ for (const relativePath of TEMPLATE_DOTPATHS) {
176
+ const srcPath = joinPath(args.templateDir, relativePath);
177
+ if (!(await Bun.file(srcPath).exists())) {
178
+ continue;
179
+ }
180
+ const dstPath = joinPath(args.targetDir, relativePath);
181
+ // Hidden paths are not copied by glob scan; explicitly create parent dirs.
182
+ // eslint-disable-next-line no-await-in-loop
183
+ await ensureDir(dirname(dstPath));
184
+ // eslint-disable-next-line no-await-in-loop
185
+ await Bun.write(dstPath, Bun.file(srcPath));
186
+ }
187
+ };
188
+
189
+ const writeDefaultLintConfigs = async (targetDir: string): Promise<void> => {
190
+ await Bun.write(joinPath(targetDir, ".oxlintrc.json"), DEFAULT_OXLINT_CONFIG);
191
+ await Bun.write(joinPath(targetDir, ".oxfmtrc.jsonc"), DEFAULT_OXFMT_CONFIG);
108
192
  };
109
193
 
110
194
  const applySubstitutions = async (args: {
@@ -133,6 +217,13 @@ const applySubstitutions = async (args: {
133
217
  text,
134
218
  })
135
219
  );
220
+
221
+ await replaceInFile(joinPath(args.targetDir, "README.md"), (text) =>
222
+ fillReadme({
223
+ siteName: args.siteName,
224
+ text,
225
+ })
226
+ );
136
227
  };
137
228
 
138
229
  const maybeInitGit = async (
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