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
@@ -3,15 +3,16 @@ const TOC_LINK_SELECTOR = 'a[data-toc-link="1"][href^="#"]';
3
3
  const TOC_SCROLL_CONTAINER_SELECTOR = '[data-toc-scroll-container="1"]';
4
4
 
5
5
  const NAVBAR_GAP_PX = 16;
6
- const clamp = (value, min, max) => Math.min(Math.max(value, min), max);
6
+ const clamp = (value: number, min: number, max: number): number =>
7
+ Math.min(Math.max(value, min), max);
7
8
 
8
- const getTopOffset = () => {
9
+ const getTopOffset = (): number => {
9
10
  const header = document.querySelector("header");
10
11
  const headerHeight = header?.getBoundingClientRect().height ?? 0;
11
12
  return Math.ceil(headerHeight + NAVBAR_GAP_PX);
12
13
  };
13
14
 
14
- const decodeAnchorId = (href) => {
15
+ const decodeAnchorId = (href: string | null): string | null => {
15
16
  if (!href?.startsWith("#")) {
16
17
  return null;
17
18
  }
@@ -28,7 +29,13 @@ const decodeAnchorId = (href) => {
28
29
  }
29
30
  };
30
31
 
31
- const toEntry = (link) => {
32
+ interface TocEntry {
33
+ heading: HTMLElement;
34
+ link: HTMLAnchorElement;
35
+ y: number;
36
+ }
37
+
38
+ const toEntry = (link: HTMLAnchorElement): TocEntry | null => {
32
39
  const id = decodeAnchorId(link.getAttribute("href"));
33
40
  if (!id) {
34
41
  return null;
@@ -44,32 +51,39 @@ const toEntry = (link) => {
44
51
  return { heading, link, y: 0 };
45
52
  };
46
53
 
47
- const buildEntries = (tocRoot) =>
54
+ const buildEntries = (tocRoot: Element): TocEntry[] =>
48
55
  [...tocRoot.querySelectorAll(TOC_LINK_SELECTOR)]
56
+ .filter(
57
+ (link): link is HTMLAnchorElement => link instanceof HTMLAnchorElement
58
+ )
49
59
  .map(toEntry)
50
- .filter((entry) => entry !== null);
60
+ .filter((entry): entry is TocEntry => entry !== null);
51
61
 
52
- const measureEntries = (entries) => {
62
+ const measureEntries = (entries: TocEntry[]): void => {
53
63
  for (const entry of entries) {
54
64
  entry.y = entry.heading.getBoundingClientRect().top + window.scrollY;
55
65
  }
56
66
  };
57
67
 
58
- const setScrollMarginTop = (topOffset) => {
68
+ const setScrollMarginTop = (topOffset: number): void => {
59
69
  document.documentElement.style.setProperty(
60
70
  "--scroll-margin-top",
61
71
  `${topOffset}px`
62
72
  );
63
73
  };
64
74
 
65
- const binarySearchLastAtOrAbove = (entries, anchorLine) => {
75
+ const binarySearchLastAtOrAbove = (
76
+ entries: TocEntry[],
77
+ anchorLine: number
78
+ ): number => {
66
79
  let lo = 0;
67
80
  let hi = entries.length;
68
81
 
69
82
  // Find the first entry with y > anchorLine, then step back one.
70
83
  while (lo < hi) {
71
84
  const mid = Math.floor((lo + hi) / 2);
72
- if (entries[mid].y <= anchorLine) {
85
+ const midY = entries[mid]?.y ?? Number.POSITIVE_INFINITY;
86
+ if (midY <= anchorLine) {
73
87
  lo = mid + 1;
74
88
  } else {
75
89
  hi = mid;
@@ -79,28 +93,30 @@ const binarySearchLastAtOrAbove = (entries, anchorLine) => {
79
93
  return lo - 1;
80
94
  };
81
95
 
82
- const findActiveIndex = (entries, topOffset) => {
96
+ const findActiveIndex = (entries: TocEntry[], topOffset: number): number => {
83
97
  const anchorLine = window.scrollY + topOffset + 1;
84
98
  const best = binarySearchLastAtOrAbove(entries, anchorLine);
85
99
  return Math.max(0, best);
86
100
  };
87
101
 
88
- const parseTransformValues = (transform, prefix) =>
102
+ const parseTransformValues = (transform: string, prefix: string): number[] =>
89
103
  transform
90
104
  .slice(prefix.length, -1)
91
105
  .split(",")
92
106
  .map((value) => Number.parseFloat(value.trim()));
93
107
 
94
- const getNumberAtIndex = (values, index) => {
95
- const [value] = values.slice(index, index + 1);
108
+ const getNumberAtIndex = (values: number[], index: number): number => {
109
+ const value = values[index] ?? Number.NaN;
96
110
  return Number.isFinite(value) ? value : 0;
97
111
  };
98
112
 
99
- const getMatrix3dTranslateY = (values) => getNumberAtIndex(values, 13);
113
+ const getMatrix3dTranslateY = (values: number[]): number =>
114
+ getNumberAtIndex(values, 13);
100
115
 
101
- const getMatrixTranslateY = (values) => getNumberAtIndex(values, 5);
116
+ const getMatrixTranslateY = (values: number[]): number =>
117
+ getNumberAtIndex(values, 5);
102
118
 
103
- const getComputedTranslateY = (element) => {
119
+ const getComputedTranslateY = (element: Element): number => {
104
120
  const { transform } = window.getComputedStyle(element);
105
121
  if (!transform || transform === "none") {
106
122
  return 0;
@@ -119,16 +135,20 @@ const getComputedTranslateY = (element) => {
119
135
  return 0;
120
136
  };
121
137
 
122
- const setTranslateY = (element, next) => {
138
+ const setTranslateY = (element: HTMLElement, next: number): void => {
123
139
  element.style.transform = `translate3d(0, ${next}px, 0)`;
124
140
  };
125
141
 
126
- const getElementCenterY = (element) => {
142
+ const getElementCenterY = (element: Element): number => {
127
143
  const rect = element.getBoundingClientRect();
128
144
  return rect.top + rect.height / 2;
129
145
  };
130
146
 
131
- const getCenteredTranslateY = (scrollContainer, list, link) => {
147
+ const getCenteredTranslateY = (
148
+ scrollContainer: HTMLElement,
149
+ list: HTMLElement,
150
+ link: HTMLAnchorElement
151
+ ): number => {
132
152
  const containerHeight = scrollContainer.clientHeight;
133
153
  const listHeight = list.scrollHeight;
134
154
 
@@ -142,7 +162,20 @@ const getCenteredTranslateY = (scrollContainer, list, link) => {
142
162
  return clamp(currentTranslate + delta, minTranslate, 0);
143
163
  };
144
164
 
145
- const centerLinkIfNeeded = (state, link) => {
165
+ interface ScrollSpyState {
166
+ activeIndex: number;
167
+ centerActiveItem: boolean;
168
+ entries: TocEntry[];
169
+ isTicking: boolean;
170
+ scrollContainer: HTMLElement | null;
171
+ tocList: HTMLElement | null;
172
+ topOffset: number;
173
+ }
174
+
175
+ const centerLinkIfNeeded = (
176
+ state: ScrollSpyState,
177
+ link: HTMLAnchorElement
178
+ ): void => {
146
179
  const { scrollContainer, tocList } = state;
147
180
  if (!scrollContainer || !tocList) {
148
181
  return;
@@ -157,7 +190,14 @@ const centerLinkIfNeeded = (state, link) => {
157
190
  setTranslateY(tocList, next);
158
191
  };
159
192
 
160
- const setActiveLink = (state, index) => {
193
+ const setCurrentLocationAttr = (entry: TocEntry | undefined): void => {
194
+ if (!entry) {
195
+ return;
196
+ }
197
+ entry.link.setAttribute("aria-current", "location");
198
+ };
199
+
200
+ const setActiveLink = (state: ScrollSpyState, index: number): void => {
161
201
  if (index === state.activeIndex) {
162
202
  return;
163
203
  }
@@ -167,18 +207,18 @@ const setActiveLink = (state, index) => {
167
207
 
168
208
  state.activeIndex = index;
169
209
  const current = state.entries[state.activeIndex];
170
- current.link.setAttribute("aria-current", "location");
210
+ setCurrentLocationAttr(current);
171
211
 
172
- if (state.centerActiveItem) {
212
+ if (state.centerActiveItem && current) {
173
213
  centerLinkIfNeeded(state, current.link);
174
214
  }
175
215
  };
176
216
 
177
- const updateActive = (state) => {
217
+ const updateActive = (state: ScrollSpyState): void => {
178
218
  setActiveLink(state, findActiveIndex(state.entries, state.topOffset));
179
219
  };
180
220
 
181
- const scheduleUpdate = (state) => {
221
+ const scheduleUpdate = (state: ScrollSpyState): void => {
182
222
  if (state.isTicking) {
183
223
  return;
184
224
  }
@@ -190,7 +230,7 @@ const scheduleUpdate = (state) => {
190
230
  });
191
231
  };
192
232
 
193
- const refreshLayout = (state) => {
233
+ const refreshLayout = (state: ScrollSpyState): void => {
194
234
  state.topOffset = getTopOffset();
195
235
  setScrollMarginTop(state.topOffset);
196
236
  measureEntries(state.entries);
@@ -204,7 +244,7 @@ const refreshLayout = (state) => {
204
244
  }
205
245
  };
206
246
 
207
- const createState = () => {
247
+ const createState = (): ScrollSpyState | null => {
208
248
  const { body } = document;
209
249
  const tocRoot = document.querySelector(TOC_ROOT_SELECTOR);
210
250
  const entries = tocRoot ? buildEntries(tocRoot) : [];
@@ -226,13 +266,14 @@ const createState = () => {
226
266
  centerActiveItem,
227
267
  entries,
228
268
  isTicking: false,
229
- scrollContainer,
230
- tocList,
269
+ scrollContainer:
270
+ scrollContainer instanceof HTMLElement ? scrollContainer : null,
271
+ tocList: tocList instanceof HTMLElement ? tocList : null,
231
272
  topOffset: getTopOffset(),
232
273
  };
233
274
  };
234
275
 
235
- const start = (state) => {
276
+ const start = (state: ScrollSpyState): void => {
236
277
  // Disable independent TOC scrolling whenever scrollspy is active.
237
278
  // The TOC list position is controlled by JS (either centered or left as-is).
238
279
  document.body.dataset.tocFollow = "1";
@@ -250,7 +291,7 @@ const start = (state) => {
250
291
  refreshLayout(state);
251
292
  };
252
293
 
253
- const init = () => {
294
+ const init = (): void => {
254
295
  const state = createState();
255
296
  if (!state) {
256
297
  return;
@@ -1 +1,87 @@
1
- export { renderSearchPageContent } from "idcmd/client";
1
+ import type { SearchPageProps } from "idcmd/client";
2
+ import type { JSX } from "preact";
3
+
4
+ import { render as renderToString } from "preact-render-to-string";
5
+
6
+ const ResultItem = ({
7
+ result,
8
+ }: {
9
+ result: SearchPageProps["results"][number];
10
+ }): JSX.Element => (
11
+ <li class="rounded-md border border-border p-3">
12
+ <a
13
+ href={result.slug}
14
+ class="font-medium underline decoration-border underline-offset-4"
15
+ >
16
+ {result.title}
17
+ </a>
18
+ <p class="mt-1 text-sm text-muted-foreground">{result.description}</p>
19
+ </li>
20
+ );
21
+
22
+ const EmptyState = ({
23
+ minQueryLength,
24
+ topPages,
25
+ }: {
26
+ minQueryLength: number;
27
+ topPages: SearchPageProps["topPages"];
28
+ }): JSX.Element => (
29
+ <div class="text-sm text-muted-foreground">
30
+ <p>{`Type at least ${minQueryLength} characters to search.`}</p>
31
+ {topPages.length > 0 ? (
32
+ <>
33
+ <p class="mt-4 font-medium text-foreground">Popular pages</p>
34
+ <ul class="mt-2 space-y-1">
35
+ {topPages.map((page) => (
36
+ <li key={page.href}>
37
+ <a
38
+ href={page.href}
39
+ class="underline decoration-border underline-offset-4"
40
+ >
41
+ {page.title}
42
+ </a>
43
+ </li>
44
+ ))}
45
+ </ul>
46
+ </>
47
+ ) : null}
48
+ </div>
49
+ );
50
+
51
+ const SearchPage = ({
52
+ query,
53
+ minQueryLength,
54
+ results,
55
+ topPages,
56
+ }: SearchPageProps): JSX.Element => {
57
+ const trimmed = query.trim();
58
+ const showResults = trimmed.length >= minQueryLength;
59
+
60
+ return (
61
+ <section class="not-prose rounded-lg border border-border bg-card/30 p-4">
62
+ <h1 class="text-lg font-semibold">Search</h1>
63
+ {showResults ? (
64
+ <p class="mt-2 text-sm text-muted-foreground">
65
+ {results.length === 0
66
+ ? `No matches for "${trimmed}".`
67
+ : `Found ${results.length} result(s) for "${trimmed}".`}
68
+ </p>
69
+ ) : (
70
+ <div class="mt-2">
71
+ <EmptyState minQueryLength={minQueryLength} topPages={topPages} />
72
+ </div>
73
+ )}
74
+
75
+ {showResults ? (
76
+ <ul class="mt-4 space-y-2">
77
+ {results.map((result) => (
78
+ <ResultItem key={result.slug} result={result} />
79
+ ))}
80
+ </ul>
81
+ ) : null}
82
+ </section>
83
+ );
84
+ };
85
+
86
+ export const renderSearchPageContent = (props: SearchPageProps): string =>
87
+ renderToString(<SearchPage {...props} />);
@@ -2,7 +2,7 @@
2
2
  "compilerOptions": {
3
3
  "jsx": "react-jsx",
4
4
  "jsxImportSource": "preact",
5
- "lib": ["ESNext", "DOM"],
5
+ "lib": ["ESNext", "DOM", "DOM.Iterable"],
6
6
  "target": "ESNext",
7
7
  "module": "Preserve",
8
8
  "moduleDetection": "force",