idcmd 0.0.11 → 0.0.12

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.
@@ -11,19 +11,16 @@ import {
11
11
  slugFromContentFile,
12
12
  } from "../content/paths";
13
13
 
14
- export const SEARCH_INDEX_VERSION = 1 as const;
15
-
16
- export interface SearchIndexDocumentV1 {
14
+ export interface SearchIndexDocument {
17
15
  url: string;
18
16
  title: string;
19
17
  description: string;
20
18
  body: string;
21
19
  }
22
20
 
23
- export interface SearchIndexV1 {
24
- version: typeof SEARCH_INDEX_VERSION;
21
+ export interface SearchIndex {
25
22
  generatedAt: string;
26
- documents: SearchIndexDocumentV1[];
23
+ documents: SearchIndexDocument[];
27
24
  }
28
25
 
29
26
  const SEARCH_INDEX_PATH = "public/search-index.json";
@@ -67,7 +64,7 @@ const isEligibleDocument = (
67
64
  hidden: boolean | undefined
68
65
  ): boolean => !hidden && slug !== "404";
69
66
 
70
- const sortDocuments = (documents: SearchIndexDocumentV1[]): void => {
67
+ const sortDocuments = (documents: SearchIndexDocument[]): void => {
71
68
  documents.sort((a, b) => {
72
69
  if (a.url === "/") {
73
70
  return -1;
@@ -84,7 +81,7 @@ const buildDocumentFromFile = async (
84
81
  bodyMaxChars: number,
85
82
  contentDir: string,
86
83
  siteConfig: SiteConfig
87
- ): Promise<SearchIndexDocumentV1 | null> => {
84
+ ): Promise<SearchIndexDocument | null> => {
88
85
  const slug = slugFromContentFile(file);
89
86
  const markdown = await Bun.file(`${contentDir}/${file}`).text();
90
87
  const parsed = parseFrontmatter(markdown);
@@ -117,11 +114,11 @@ export interface GenerateSearchIndexOptions {
117
114
 
118
115
  export const generateSearchIndexFromContent = async (
119
116
  options: GenerateSearchIndexOptions
120
- ): Promise<SearchIndexV1> => {
117
+ ): Promise<SearchIndex> => {
121
118
  const { bodyMaxChars = DEFAULT_BODY_MAX_CHARS, siteConfig } = options;
122
119
  const generatedAt = options.generatedAt ?? new Date().toISOString();
123
120
 
124
- const documents: SearchIndexDocumentV1[] = [];
121
+ const documents: SearchIndexDocument[] = [];
125
122
  const contentDir = await getContentDir();
126
123
 
127
124
  for await (const file of scanContentFiles()) {
@@ -141,13 +138,12 @@ export const generateSearchIndexFromContent = async (
141
138
  return {
142
139
  documents,
143
140
  generatedAt,
144
- version: SEARCH_INDEX_VERSION,
145
141
  };
146
142
  };
147
143
 
148
- const isSearchIndexDocumentV1 = (
144
+ const isSearchIndexDocument = (
149
145
  value: unknown
150
- ): value is SearchIndexDocumentV1 => {
146
+ ): value is SearchIndexDocument => {
151
147
  if (!value || typeof value !== "object") {
152
148
  return false;
153
149
  }
@@ -161,28 +157,27 @@ const isSearchIndexDocumentV1 = (
161
157
  );
162
158
  };
163
159
 
164
- const isSearchIndexV1 = (value: unknown): value is SearchIndexV1 => {
160
+ const isSearchIndex = (value: unknown): value is SearchIndex => {
165
161
  if (!value || typeof value !== "object") {
166
162
  return false;
167
163
  }
168
164
 
169
165
  const record = value as Record<string, unknown>;
170
166
  return (
171
- record.version === SEARCH_INDEX_VERSION &&
172
167
  isNonEmptyString(record.generatedAt) &&
173
168
  Array.isArray(record.documents) &&
174
- record.documents.every((doc) => isSearchIndexDocumentV1(doc))
169
+ record.documents.every((doc) => isSearchIndexDocument(doc))
175
170
  );
176
171
  };
177
172
 
178
- let indexCache: SearchIndexV1 | null = null;
173
+ let indexCache: SearchIndex | null = null;
179
174
 
180
175
  export interface LoadSearchIndexOptions {
181
176
  forceRefresh?: boolean;
182
177
  siteConfig: SiteConfig;
183
178
  }
184
179
 
185
- const tryLoadSearchIndexFromDisk = async (): Promise<SearchIndexV1 | null> => {
180
+ const tryLoadSearchIndexFromDisk = async (): Promise<SearchIndex | null> => {
186
181
  const file = Bun.file(SEARCH_INDEX_PATH);
187
182
  if (!(await file.exists())) {
188
183
  return null;
@@ -190,7 +185,7 @@ const tryLoadSearchIndexFromDisk = async (): Promise<SearchIndexV1 | null> => {
190
185
 
191
186
  try {
192
187
  const parsed = (await file.json()) as unknown;
193
- return isSearchIndexV1(parsed) ? parsed : null;
188
+ return isSearchIndex(parsed) ? parsed : null;
194
189
  } catch {
195
190
  return null;
196
191
  }
@@ -198,7 +193,7 @@ const tryLoadSearchIndexFromDisk = async (): Promise<SearchIndexV1 | null> => {
198
193
 
199
194
  export const loadSearchIndex = async (
200
195
  options: LoadSearchIndexOptions
201
- ): Promise<SearchIndexV1> => {
196
+ ): Promise<SearchIndex> => {
202
197
  const { forceRefresh = false, siteConfig } = options;
203
198
  if (indexCache && !forceRefresh) {
204
199
  return indexCache;
@@ -216,7 +211,7 @@ export const loadSearchIndex = async (
216
211
  };
217
212
 
218
213
  const getScopeHaystack = (
219
- document: SearchIndexDocumentV1,
214
+ document: SearchIndexDocument,
220
215
  scope: SearchScope
221
216
  ): string => {
222
217
  if (scope === "title") {
@@ -231,7 +226,7 @@ const getScopeHaystack = (
231
226
  };
232
227
 
233
228
  const matchesAllTokens = (
234
- document: SearchIndexDocumentV1,
229
+ document: SearchIndexDocument,
235
230
  tokens: readonly string[],
236
231
  scope: SearchScope
237
232
  ): boolean => {
@@ -239,14 +234,14 @@ const matchesAllTokens = (
239
234
  return tokens.every((token) => haystack.includes(token));
240
235
  };
241
236
 
242
- const toSearchResult = (document: SearchIndexDocumentV1): SearchResult => ({
237
+ const toSearchResult = (document: SearchIndexDocument): SearchResult => ({
243
238
  description: document.description,
244
239
  slug: document.url,
245
240
  title: document.title,
246
241
  });
247
242
 
248
243
  export const search = (
249
- index: SearchIndexV1,
244
+ index: SearchIndex,
250
245
  query: string,
251
246
  scope: SearchScope
252
247
  ): SearchResult[] => {
@@ -1,9 +1,9 @@
1
- import type { JSX } from "preact";
2
-
3
- import { render as renderToString } from "preact-render-to-string";
1
+ /* eslint-disable react/jsx-key */
4
2
 
5
3
  import type { SearchResult } from "./contract";
6
4
 
5
+ const escapeText = (value: string): string => Bun.escapeHTML(value);
6
+
7
7
  export interface TopPageLink {
8
8
  href: string;
9
9
  title: string;
@@ -24,9 +24,11 @@ const ResultItem = ({ result }: { result: SearchResult }): JSX.Element => (
24
24
  href={result.slug}
25
25
  class="font-medium underline decoration-border underline-offset-4"
26
26
  >
27
- {result.title}
27
+ {escapeText(result.title)}
28
28
  </a>
29
- <p class="mt-1 text-sm text-muted-foreground">{result.description}</p>
29
+ <p class="mt-1 text-sm text-muted-foreground">
30
+ {escapeText(result.description)}
31
+ </p>
30
32
  </li>
31
33
  );
32
34
 
@@ -44,12 +46,12 @@ const EmptyState = ({
44
46
  <p class="mt-4 font-medium text-foreground">Popular pages</p>
45
47
  <ul class="mt-2 space-y-1">
46
48
  {topPages.map((page) => (
47
- <li key={page.href}>
49
+ <li>
48
50
  <a
49
51
  href={page.href}
50
52
  class="underline decoration-border underline-offset-4"
51
53
  >
52
- {page.title}
54
+ {escapeText(page.title)}
53
55
  </a>
54
56
  </li>
55
57
  ))}
@@ -74,8 +76,8 @@ const SearchPage = ({
74
76
  {showResults ? (
75
77
  <p class="mt-2 text-sm text-muted-foreground">
76
78
  {results.length === 0
77
- ? `No matches for "${trimmed}".`
78
- : `Found ${results.length} result(s) for "${trimmed}".`}
79
+ ? `No matches for "${escapeText(trimmed)}".`
80
+ : `Found ${results.length} result(s) for "${escapeText(trimmed)}".`}
79
81
  </p>
80
82
  ) : (
81
83
  <div class="mt-2">
@@ -86,7 +88,7 @@ const SearchPage = ({
86
88
  {showResults ? (
87
89
  <ul class="mt-4 space-y-2">
88
90
  {results.map((result) => (
89
- <ResultItem key={result.slug} result={result} />
91
+ <ResultItem result={result} />
90
92
  ))}
91
93
  </ul>
92
94
  ) : null}
@@ -95,4 +97,4 @@ const SearchPage = ({
95
97
  };
96
98
 
97
99
  export const renderSearchPageContent: RenderSearchPageContent = (props) =>
98
- renderToString(<SearchPage {...props} />);
100
+ `${<SearchPage {...props} />}`;
@@ -141,7 +141,7 @@ const loadOneRoute = async (
141
141
  const pathname = pathnameFromRouteRelativePath(relativeFile);
142
142
  if (hasUnsupportedDynamicSegment(pathname)) {
143
143
  throw new Error(
144
- `Unsupported dynamic route segment in ${routesDir}/${relativeFile} (computed pathname: ${pathname}). V1 does not support [param] or :param routes.`
144
+ `Unsupported dynamic route segment in ${routesDir}/${relativeFile} (computed pathname: ${pathname}). Dynamic [param] and :param routes are not supported.`
145
145
  );
146
146
  }
147
147
 
@@ -19,6 +19,3 @@ jobs:
19
19
  run: bun install
20
20
  - name: Run checks
21
21
  run: bun run check
22
- - name: Run smoke
23
- timeout-minutes: 15
24
- run: bun run smoke
@@ -11,11 +11,10 @@ bun install
11
11
  bun run dev
12
12
  ```
13
13
 
14
- ## CI Smoke
14
+ ## Validation
15
15
 
16
16
  ```bash
17
17
  bun run check
18
- bun run smoke
19
18
  ```
20
19
 
21
20
  ## Layout
@@ -8,7 +8,6 @@
8
8
  "preview": "idcmd preview",
9
9
  "deploy": "idcmd deploy",
10
10
  "check": "bun run scripts/check.ts",
11
- "smoke": "bun run scripts/smoke.ts",
12
11
  "test": "bun test",
13
12
  "typecheck": "tsc --noEmit -p tsconfig.json",
14
13
  "fix": "ultracite fix"
@@ -1,4 +1,4 @@
1
1
  // Optional: this file is here as the obvious place to put server-side code.
2
- // V1 runs the built-in idcmd server; add custom endpoints via `src/routes/**`.
2
+ // Runs the built-in idcmd server; add custom endpoints via `src/routes/**`.
3
3
 
4
4
  export const serverPlaceholder = true;
@@ -1,18 +1,15 @@
1
- import type { LayoutProps } from "idcmd/client";
2
- /* eslint-disable react/no-danger */
3
- import type { JSX } from "preact";
1
+ /* eslint-disable react/jsx-key */
4
2
 
5
- import { render } from "preact-render-to-string";
3
+ import type { LayoutProps } from "idcmd/client";
6
4
 
7
5
  import { RightRail } from "./right-rail";
8
6
 
9
7
  type NavItem = LayoutProps["navigation"][number]["items"][number];
10
8
 
9
+ const escapeText = (value: string): string => Bun.escapeHTML(value);
10
+
11
11
  const Icon = ({ svg }: { svg: string }): JSX.Element => (
12
- <span
13
- class="inline-flex h-[18px] w-[18px]"
14
- dangerouslySetInnerHTML={{ __html: svg }}
15
- />
12
+ <span class="inline-flex h-[18px] w-[18px]">{svg}</span>
16
13
  );
17
14
 
18
15
  const isActiveLink = (item: NavItem, currentPath: string): boolean =>
@@ -36,19 +33,18 @@ const Sidebar = ({
36
33
  data-prefetch="hover"
37
34
  >
38
35
  <span class="text-muted-foreground">~/</span>
39
- {siteName}
36
+ {escapeText(siteName)}
40
37
  </a>
41
38
  </div>
42
39
  <div class="sidebar-content">
43
40
  {navigation.map((group) => (
44
- <div key={group.id} class="py-2">
41
+ <div class="py-2">
45
42
  <p class="px-3 py-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
46
- {group.label}
43
+ {escapeText(group.label)}
47
44
  </p>
48
45
  <nav class="space-y-1">
49
46
  {group.items.map((item) => (
50
47
  <a
51
- key={item.href}
52
48
  href={item.href}
53
49
  data-prefetch="hover"
54
50
  class={`flex items-center gap-3 px-3 py-1.5 text-sm transition-colors hover:text-sidebar-foreground ${
@@ -58,7 +54,7 @@ const Sidebar = ({
58
54
  }`}
59
55
  >
60
56
  <Icon svg={item.iconSvg} />
61
- <span>{item.title}</span>
57
+ <span>{escapeText(item.title)}</span>
62
58
  </a>
63
59
  ))}
64
60
  </nav>
@@ -74,19 +70,20 @@ const SearchForm = ({ query }: { query?: string }): JSX.Element => (
74
70
  action="/search/"
75
71
  class="flex w-full items-center"
76
72
  role="search"
77
- noValidate
73
+ novalidate
78
74
  >
79
- <label htmlFor="site-search" class="sr-only">
75
+ {/* eslint-disable-next-line jsx-a11y/label-has-associated-control */}
76
+ <label for="site-search" class="sr-only">
80
77
  Search pages
81
78
  </label>
82
79
  <input
83
80
  id="site-search"
84
81
  name="q"
85
82
  type="search"
86
- autoComplete="off"
83
+ autocomplete="off"
87
84
  spellcheck={false}
88
85
  placeholder="Search..."
89
- defaultValue={query ?? ""}
86
+ value={escapeText(query ?? "")}
90
87
  class="w-full border-b border-input bg-transparent px-1 py-1.5 text-sm placeholder:text-muted-foreground focus:border-foreground focus:outline-none transition-colors"
91
88
  />
92
89
  </form>
@@ -108,7 +105,7 @@ const TopNavbar = ({
108
105
  data-prefetch="hover"
109
106
  >
110
107
  <span class="text-muted-foreground">~/</span>
111
- {siteName}
108
+ {escapeText(siteName)}
112
109
  </a>
113
110
  <div class="not-prose ml-auto w-full max-w-xs">
114
111
  <SearchForm query={query} />
@@ -170,14 +167,16 @@ const Layout = ({
170
167
  <head>
171
168
  <meta charset="utf-8" />
172
169
  <meta name="viewport" content="width=device-width, initial-scale=1" />
173
- <title>{title}</title>
174
- {description ? <meta name="description" content={description} /> : null}
170
+ <title>{escapeText(title)}</title>
171
+ {description ? (
172
+ <meta name="description" content={escapeText(description)} />
173
+ ) : null}
175
174
  {canonicalUrl ? <link rel="canonical" href={canonicalUrl} /> : null}
176
175
  <link rel="preconnect" href="https://fonts.googleapis.com" />
177
176
  <link
178
177
  rel="preconnect"
179
178
  href="https://fonts.gstatic.com"
180
- crossOrigin="anonymous"
179
+ crossorigin="anonymous"
181
180
  />
182
181
  <link
183
182
  href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:ital,wght@0,100..800;1,100..800&display=swap"
@@ -208,8 +207,10 @@ const Layout = ({
208
207
  class={`prose min-w-0 flex-1${
209
208
  currentPath === "/" ? " prose-home" : ""
210
209
  }`}
211
- dangerouslySetInnerHTML={{ __html: content }}
212
- />
210
+ >
211
+ {/* content is pre-rendered markdown HTML */}
212
+ {content}
213
+ </article>
213
214
  {shouldShowRightRail ? (
214
215
  <RightRail
215
216
  canonicalUrl={canonicalUrl}
@@ -221,12 +222,12 @@ const Layout = ({
221
222
  </div>
222
223
  </main>
223
224
  <footer class="site-footer">
224
- Built with Preact SSR + Tailwind &nbsp;|&nbsp; Zero JavaScript on
225
+ Built with idcmd SSR + Tailwind &nbsp;|&nbsp; Zero JavaScript on
225
226
  content pages
226
227
  </footer>
227
228
  </div>
228
229
  {scriptPaths.map((scriptPath) => (
229
- <script key={scriptPath} defer src={scriptPath} />
230
+ <script defer src={scriptPath} />
230
231
  ))}
231
232
  </body>
232
233
  </html>
@@ -234,4 +235,4 @@ const Layout = ({
234
235
  };
235
236
 
236
237
  export const renderLayout = (props: LayoutProps): string =>
237
- `<!DOCTYPE html>${render(<Layout {...props} />)}`;
238
+ `<!DOCTYPE html>${<Layout {...props} />}`;
@@ -1,5 +1,8 @@
1
+ /* eslint-disable react/jsx-key */
2
+
1
3
  import type { RightRailProps } from "idcmd/client";
2
- import type { JSX } from "preact";
4
+
5
+ const escapeText = (value: string): string => Bun.escapeHTML(value);
3
6
 
4
7
  const CaretDownIcon = (): JSX.Element => (
5
8
  <svg
@@ -173,13 +176,13 @@ const OnThisPage = ({
173
176
  <div class="toc-scroll min-h-0 flex-1" data-toc-scroll-container="1">
174
177
  <ul class="space-y-2 text-sm text-muted-foreground">
175
178
  {items.map((item) => (
176
- <li key={item.id} class={item.level >= 3 ? "pl-3" : ""}>
179
+ <li class={item.level >= 3 ? "pl-3" : ""}>
177
180
  <a
178
181
  href={`#${encodeURIComponent(item.id)}`}
179
182
  class="hover:text-foreground"
180
183
  data-toc-link="1"
181
184
  >
182
- {item.text}
185
+ {escapeText(item.text)}
183
186
  </a>
184
187
  </li>
185
188
  ))}
@@ -1,7 +1,8 @@
1
+ /* eslint-disable react/jsx-key */
2
+
1
3
  import type { SearchPageProps } from "idcmd/client";
2
- import type { JSX } from "preact";
3
4
 
4
- import { render as renderToString } from "preact-render-to-string";
5
+ const escapeText = (value: string): string => Bun.escapeHTML(value);
5
6
 
6
7
  const ResultItem = ({
7
8
  result,
@@ -13,9 +14,11 @@ const ResultItem = ({
13
14
  href={result.slug}
14
15
  class="font-medium underline decoration-border underline-offset-4"
15
16
  >
16
- {result.title}
17
+ {escapeText(result.title)}
17
18
  </a>
18
- <p class="mt-1 text-sm text-muted-foreground">{result.description}</p>
19
+ <p class="mt-1 text-sm text-muted-foreground">
20
+ {escapeText(result.description)}
21
+ </p>
19
22
  </li>
20
23
  );
21
24
 
@@ -33,12 +36,12 @@ const EmptyState = ({
33
36
  <p class="mt-4 font-medium text-foreground">Popular pages</p>
34
37
  <ul class="mt-2 space-y-1">
35
38
  {topPages.map((page) => (
36
- <li key={page.href}>
39
+ <li>
37
40
  <a
38
41
  href={page.href}
39
42
  class="underline decoration-border underline-offset-4"
40
43
  >
41
- {page.title}
44
+ {escapeText(page.title)}
42
45
  </a>
43
46
  </li>
44
47
  ))}
@@ -63,8 +66,8 @@ const SearchPage = ({
63
66
  {showResults ? (
64
67
  <p class="mt-2 text-sm text-muted-foreground">
65
68
  {results.length === 0
66
- ? `No matches for "${trimmed}".`
67
- : `Found ${results.length} result(s) for "${trimmed}".`}
69
+ ? `No matches for "${escapeText(trimmed)}".`
70
+ : `Found ${results.length} result(s) for "${escapeText(trimmed)}".`}
68
71
  </p>
69
72
  ) : (
70
73
  <div class="mt-2">
@@ -75,7 +78,7 @@ const SearchPage = ({
75
78
  {showResults ? (
76
79
  <ul class="mt-4 space-y-2">
77
80
  {results.map((result) => (
78
- <ResultItem key={result.slug} result={result} />
81
+ <ResultItem result={result} />
79
82
  ))}
80
83
  </ul>
81
84
  ) : null}
@@ -84,4 +87,4 @@ const SearchPage = ({
84
87
  };
85
88
 
86
89
  export const renderSearchPageContent = (props: SearchPageProps): string =>
87
- renderToString(<SearchPage {...props} />);
90
+ `${<SearchPage {...props} />}`;
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "compilerOptions": {
3
3
  "jsx": "react-jsx",
4
- "jsxImportSource": "preact",
4
+ "jsxImportSource": "@kitajs/html",
5
5
  "lib": ["ESNext", "DOM", "DOM.Iterable"],
6
6
  "target": "ESNext",
7
7
  "module": "Preserve",