@openlaboratory/open-doc 0.1.3 → 0.1.4

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.
package/README.md CHANGED
@@ -83,8 +83,8 @@ Set these in `open-doc.config.ts`:
83
83
  The full guide and a live example live in the
84
84
  [project repository](https://github.com/openlaboratory-org/open-doc).
85
85
 
86
- > MDX pages and custom React components require `react` and `react-dom` in your
87
- > project. Plain Markdown needs nothing else.
86
+ > `open-doc` provides the React runtime for MDX components. TSX authoring and
87
+ > `open-doc check` still need `@types/react` and `@types/react-dom` in your project.
88
88
 
89
89
  ## License
90
90
 
@@ -1,5 +1,6 @@
1
1
  import { useEffect, useState } from 'react'
2
2
  import type { DocSection } from '../lib/navigation'
3
+ import { tagReactComponent } from '../lib/reactRenderer'
3
4
  import { withBase } from '../lib/withBase'
4
5
 
5
6
  interface Props {
@@ -122,3 +123,5 @@ export function DocsMobileNav({ navigation, currentSlug }: Props) {
122
123
  </>
123
124
  )
124
125
  }
126
+
127
+ tagReactComponent(DocsMobileNav)
@@ -1,6 +1,8 @@
1
1
  import { useEffect, useMemo, useRef, useState } from 'react'
2
+ import { createPortal } from 'react-dom'
2
3
  import Fuse from 'fuse.js'
3
4
  import type { DocSection, DocPage } from '../lib/navigation'
5
+ import { tagReactComponent } from '../lib/reactRenderer'
4
6
  import { withBase } from '../lib/withBase'
5
7
 
6
8
  interface Props {
@@ -26,6 +28,10 @@ function isEditableTarget(target: EventTarget | null): boolean {
26
28
  )
27
29
  }
28
30
 
31
+ function isVisibleElement(element: HTMLElement | null): boolean {
32
+ return !!element && element.getClientRects().length > 0
33
+ }
34
+
29
35
  /**
30
36
  * ⌘K search modal. Uses Pagefind full-text search against the built site, and
31
37
  * gracefully falls back to a lightweight title/description search (Fuse.js) when
@@ -34,6 +40,10 @@ function isEditableTarget(target: EventTarget | null): boolean {
34
40
  */
35
41
  export function DocsSearch({ navigation }: Props) {
36
42
  const allPages = useMemo<DocPage[]>(() => navigation.flatMap((s) => s.pages), [navigation])
43
+ const indexedSections = useMemo(
44
+ () => navigation.filter((section) => section.pages.length > 0).slice(0, 3),
45
+ [navigation],
46
+ )
37
47
  const fuse = useMemo(
38
48
  () => new Fuse(allPages, { keys: ['title', 'description'], threshold: 0.4 }),
39
49
  [allPages],
@@ -55,10 +65,13 @@ export function DocsSearch({ navigation }: Props) {
55
65
 
56
66
  useEffect(() => {
57
67
  const handleKey = (e: KeyboardEvent) => {
58
- if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
68
+ const key = e.key.toLowerCase()
69
+ if ((e.metaKey || e.ctrlKey) && key === 'k') {
70
+ if (!isVisibleElement(triggerRef.current)) return
59
71
  e.preventDefault()
60
72
  setOpen(true)
61
73
  } else if (e.key === '/' && !isEditableTarget(e.target)) {
74
+ if (!isVisibleElement(triggerRef.current)) return
62
75
  e.preventDefault()
63
76
  setOpen(true)
64
77
  } else if (e.key === 'Escape') {
@@ -152,6 +165,195 @@ export function DocsSearch({ navigation }: Props) {
152
165
  }
153
166
  }
154
167
 
168
+ const hasQuery = query.trim().length > 0
169
+ const pageCountLabel = `${allPages.length} ${allPages.length === 1 ? 'page' : 'pages'}`
170
+
171
+ const searchDialog = open ? (
172
+ <div
173
+ className="fixed inset-0 z-[1000] flex items-start justify-center px-4 pt-[15vh]"
174
+ onClick={(e) => {
175
+ if (!dialogRef.current?.contains(e.target as Node)) setOpen(false)
176
+ }}
177
+ >
178
+ <div className="absolute inset-0 bg-black/60 backdrop-blur-sm" />
179
+
180
+ <div
181
+ ref={dialogRef}
182
+ className="relative w-full max-w-xl overflow-hidden rounded-xl border border-foreground/[0.12] bg-surface-raised shadow-2xl"
183
+ role="dialog"
184
+ aria-modal="true"
185
+ aria-label="Search documentation"
186
+ >
187
+ <div className="flex items-center gap-3 border-b border-foreground/[0.08] px-4 py-3">
188
+ <svg
189
+ className="h-4 w-4 shrink-0 text-foreground/40"
190
+ fill="none"
191
+ viewBox="0 0 24 24"
192
+ stroke="currentColor"
193
+ strokeWidth={2}
194
+ >
195
+ <path
196
+ strokeLinecap="round"
197
+ strokeLinejoin="round"
198
+ d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
199
+ />
200
+ </svg>
201
+ <input
202
+ ref={inputRef}
203
+ type="text"
204
+ placeholder="Search documentation…"
205
+ value={query}
206
+ onChange={(e) => setQuery(e.target.value)}
207
+ onKeyDown={handleKeyDown}
208
+ role="combobox"
209
+ aria-expanded={hasQuery && results.length > 0}
210
+ aria-controls="od-search-results"
211
+ aria-activedescendant={
212
+ hasQuery && results[selectedIndex] ? `od-search-option-${selectedIndex}` : undefined
213
+ }
214
+ className="flex-1 bg-transparent text-sm text-foreground placeholder-foreground/30 outline-none"
215
+ />
216
+ <button
217
+ onClick={() => setOpen(false)}
218
+ className="rounded border border-foreground/[0.1] px-1.5 py-0.5 text-[11px] text-foreground/40 transition-colors hover:text-foreground/70"
219
+ >
220
+ Esc
221
+ </button>
222
+ </div>
223
+
224
+ <div
225
+ className="max-h-[360px] overflow-y-auto py-2"
226
+ id="od-search-results"
227
+ role={hasQuery ? 'listbox' : undefined}
228
+ >
229
+ {!hasQuery ? (
230
+ <div className="px-4 py-3">
231
+ <div className="flex items-center gap-3 rounded-lg border border-foreground/[0.08] bg-foreground/[0.025] px-3 py-2.5">
232
+ <div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md border border-link/15 bg-link/10 text-link">
233
+ <svg
234
+ className="h-4 w-4"
235
+ fill="none"
236
+ viewBox="0 0 24 24"
237
+ stroke="currentColor"
238
+ strokeWidth={1.8}
239
+ >
240
+ <path
241
+ strokeLinecap="round"
242
+ strokeLinejoin="round"
243
+ d="M7 7h6M7 11h4m2 8H6a2 2 0 01-2-2V5a2 2 0 012-2h8l4 4v3.5"
244
+ />
245
+ <path
246
+ strokeLinecap="round"
247
+ strokeLinejoin="round"
248
+ d="M15.5 17.5l3 3m1-5a4 4 0 11-8 0 4 4 0 018 0z"
249
+ />
250
+ </svg>
251
+ </div>
252
+ <div className="min-w-0 flex-1">
253
+ <div className="flex flex-wrap items-center gap-x-2 gap-y-1">
254
+ <span className="text-sm font-medium text-foreground/75">{pageCountLabel}</span>
255
+ <span className="text-xs text-foreground/35">available</span>
256
+ </div>
257
+ {indexedSections.length > 0 && (
258
+ <div className="mt-1.5 flex flex-wrap gap-1.5">
259
+ {indexedSections.map((section) => (
260
+ <span
261
+ key={section.label}
262
+ className="rounded border border-foreground/[0.08] bg-surface-raised px-1.5 py-0.5 text-[11px] font-medium text-foreground/50"
263
+ >
264
+ {section.label}
265
+ </span>
266
+ ))}
267
+ </div>
268
+ )}
269
+ </div>
270
+ </div>
271
+ </div>
272
+ ) : results.length === 0 ? (
273
+ <p className="px-4 py-8 text-center text-sm text-foreground/40">
274
+ No results for &ldquo;{query}&rdquo;
275
+ </p>
276
+ ) : (
277
+ results.map((result, i) => {
278
+ const isSelected = i === selectedIndex
279
+ return (
280
+ <a
281
+ key={result.url + i}
282
+ id={`od-search-option-${i}`}
283
+ role="option"
284
+ aria-selected={isSelected}
285
+ href={result.url}
286
+ onClick={() => setOpen(false)}
287
+ onMouseEnter={() => setSelectedIndex(i)}
288
+ className={[
289
+ 'flex items-start gap-3 px-4 py-2.5 transition-colors',
290
+ isSelected ? 'bg-foreground/[0.08]' : 'hover:bg-foreground/[0.04]',
291
+ ].join(' ')}
292
+ >
293
+ <svg
294
+ className="mt-0.5 h-4 w-4 shrink-0 text-foreground/30"
295
+ fill="none"
296
+ viewBox="0 0 24 24"
297
+ stroke="currentColor"
298
+ strokeWidth={2}
299
+ >
300
+ <path
301
+ strokeLinecap="round"
302
+ strokeLinejoin="round"
303
+ d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
304
+ />
305
+ </svg>
306
+ <div className="min-w-0">
307
+ <div className="text-sm font-medium leading-snug text-foreground/90">
308
+ {result.title}
309
+ </div>
310
+ {result.excerpt &&
311
+ (result.html ? (
312
+ <div
313
+ className="search-excerpt mt-0.5 line-clamp-2 text-xs text-foreground/45"
314
+ dangerouslySetInnerHTML={{ __html: result.excerpt }}
315
+ />
316
+ ) : (
317
+ <div className="mt-0.5 truncate text-xs text-foreground/45">
318
+ {result.excerpt}
319
+ </div>
320
+ ))}
321
+ </div>
322
+ </a>
323
+ )
324
+ })
325
+ )}
326
+ </div>
327
+
328
+ <div className="flex flex-wrap items-center gap-x-4 gap-y-2 border-t border-foreground/[0.06] px-4 py-3 text-[13px] text-foreground/50">
329
+ <span className="flex items-center gap-1.5 whitespace-nowrap">
330
+ <kbd className="inline-flex h-6 min-w-6 items-center justify-center rounded border border-foreground/[0.14] bg-foreground/[0.04] px-2 font-mono text-xs leading-none text-foreground/65">
331
+ ↑↓
332
+ </kbd>
333
+ navigate
334
+ </span>
335
+ <span className="flex items-center gap-1.5 whitespace-nowrap">
336
+ <kbd className="inline-flex h-6 min-w-6 items-center justify-center rounded border border-foreground/[0.14] bg-foreground/[0.04] px-2 font-mono text-xs leading-none text-foreground/65">
337
+
338
+ </kbd>
339
+ open
340
+ </span>
341
+ <span className="flex items-center gap-1.5 whitespace-nowrap">
342
+ <kbd className="inline-flex h-6 min-w-6 items-center justify-center rounded border border-foreground/[0.14] bg-foreground/[0.04] px-2 font-mono text-xs leading-none text-foreground/65">
343
+ Esc
344
+ </kbd>
345
+ close
346
+ </span>
347
+ {!fullText && (
348
+ <span className="text-foreground/40 sm:ml-auto">
349
+ Title search · run a build for full-text
350
+ </span>
351
+ )}
352
+ </div>
353
+ </div>
354
+ </div>
355
+ ) : null
356
+
155
357
  return (
156
358
  <>
157
359
  <button
@@ -179,137 +381,9 @@ export function DocsSearch({ navigation }: Props) {
179
381
  </kbd>
180
382
  </button>
181
383
 
182
- {open && (
183
- <div
184
- className="fixed inset-0 z-[100] flex items-start justify-center px-4 pt-[15vh]"
185
- onClick={(e) => {
186
- if (!dialogRef.current?.contains(e.target as Node)) setOpen(false)
187
- }}
188
- >
189
- <div className="absolute inset-0 bg-black/60 backdrop-blur-sm" />
190
-
191
- <div
192
- ref={dialogRef}
193
- className="relative w-full max-w-xl overflow-hidden rounded-xl border border-foreground/[0.12] bg-surface-raised shadow-2xl"
194
- role="dialog"
195
- aria-modal="true"
196
- aria-label="Search documentation"
197
- >
198
- <div className="flex items-center gap-3 border-b border-foreground/[0.08] px-4 py-3">
199
- <svg
200
- className="h-4 w-4 shrink-0 text-foreground/40"
201
- fill="none"
202
- viewBox="0 0 24 24"
203
- stroke="currentColor"
204
- strokeWidth={2}
205
- >
206
- <path
207
- strokeLinecap="round"
208
- strokeLinejoin="round"
209
- d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
210
- />
211
- </svg>
212
- <input
213
- ref={inputRef}
214
- type="text"
215
- placeholder="Search documentation…"
216
- value={query}
217
- onChange={(e) => setQuery(e.target.value)}
218
- onKeyDown={handleKeyDown}
219
- role="combobox"
220
- aria-expanded={results.length > 0}
221
- aria-controls="od-search-results"
222
- aria-activedescendant={
223
- results[selectedIndex] ? `od-search-option-${selectedIndex}` : undefined
224
- }
225
- className="flex-1 bg-transparent text-sm text-foreground placeholder-foreground/30 outline-none"
226
- />
227
- <button
228
- onClick={() => setOpen(false)}
229
- className="rounded border border-foreground/[0.1] px-1.5 py-0.5 text-[11px] text-foreground/40 transition-colors hover:text-foreground/70"
230
- >
231
- Esc
232
- </button>
233
- </div>
234
-
235
- <div
236
- className="max-h-[360px] overflow-y-auto py-2"
237
- id="od-search-results"
238
- role="listbox"
239
- >
240
- {query.trim() && results.length === 0 ? (
241
- <p className="px-4 py-8 text-center text-sm text-foreground/40">
242
- No results for &ldquo;{query}&rdquo;
243
- </p>
244
- ) : (
245
- results.map((result, i) => {
246
- const isSelected = i === selectedIndex
247
- return (
248
- <a
249
- key={result.url + i}
250
- id={`od-search-option-${i}`}
251
- role="option"
252
- aria-selected={isSelected}
253
- href={result.url}
254
- onClick={() => setOpen(false)}
255
- onMouseEnter={() => setSelectedIndex(i)}
256
- className={[
257
- 'flex items-start gap-3 px-4 py-2.5 transition-colors',
258
- isSelected ? 'bg-foreground/[0.08]' : 'hover:bg-foreground/[0.04]',
259
- ].join(' ')}
260
- >
261
- <svg
262
- className="mt-0.5 h-4 w-4 shrink-0 text-foreground/30"
263
- fill="none"
264
- viewBox="0 0 24 24"
265
- stroke="currentColor"
266
- strokeWidth={2}
267
- >
268
- <path
269
- strokeLinecap="round"
270
- strokeLinejoin="round"
271
- d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
272
- />
273
- </svg>
274
- <div className="min-w-0">
275
- <div className="text-sm font-medium leading-snug text-foreground/90">
276
- {result.title}
277
- </div>
278
- {result.excerpt &&
279
- (result.html ? (
280
- <div
281
- className="search-excerpt mt-0.5 line-clamp-2 text-xs text-foreground/45"
282
- dangerouslySetInnerHTML={{ __html: result.excerpt }}
283
- />
284
- ) : (
285
- <div className="mt-0.5 truncate text-xs text-foreground/45">
286
- {result.excerpt}
287
- </div>
288
- ))}
289
- </div>
290
- </a>
291
- )
292
- })
293
- )}
294
- </div>
295
-
296
- <div className="flex items-center gap-4 border-t border-foreground/[0.06] px-4 py-2 text-[10px] text-foreground/30">
297
- <span className="flex items-center gap-1">
298
- <kbd className="font-mono">↑↓</kbd> navigate
299
- </span>
300
- <span className="flex items-center gap-1">
301
- <kbd className="font-mono">↵</kbd> open
302
- </span>
303
- <span className="flex items-center gap-1">
304
- <kbd className="font-mono">esc</kbd> close
305
- </span>
306
- {!fullText && (
307
- <span className="ml-auto">Title search · run a build for full-text</span>
308
- )}
309
- </div>
310
- </div>
311
- </div>
312
- )}
384
+ {searchDialog && createPortal(searchDialog, document.body)}
313
385
  </>
314
386
  )
315
387
  }
388
+
389
+ tagReactComponent(DocsSearch)
@@ -1,4 +1,5 @@
1
1
  import { useEffect, useRef, useState } from 'react'
2
+ import { tagReactComponent } from '../lib/reactRenderer'
2
3
 
3
4
  interface Heading {
4
5
  depth: number
@@ -90,3 +91,5 @@ export function DocsTableOfContents({ headings }: Props) {
90
91
  </nav>
91
92
  )
92
93
  }
94
+
95
+ tagReactComponent(DocsTableOfContents)
@@ -1,4 +1,5 @@
1
1
  import { useEffect, useState } from 'react'
2
+ import { tagReactComponent } from '../lib/reactRenderer'
2
3
 
3
4
  const STORAGE_KEY = 'open-doc-theme'
4
5
 
@@ -60,3 +61,5 @@ export function ThemeToggle() {
60
61
  </button>
61
62
  )
62
63
  }
64
+
65
+ tagReactComponent(ThemeToggle)
@@ -1,5 +1,6 @@
1
1
  import { fileURLToPath } from 'node:url'
2
2
  import { createRequire } from 'node:module'
3
+ import { dirname } from 'node:path'
3
4
 
4
5
  // The bundled Astro app this integration ships inside, e.g.
5
6
  // `…/node_modules/@openlaboratory/open-doc/app`. Its `src/` holds the React
@@ -8,6 +9,7 @@ const APP_ROOT = fileURLToPath(new URL('../../', import.meta.url))
8
9
 
9
10
  // Resolve specifiers from open-doc's own install (not the consumer's).
10
11
  const require = createRequire(import.meta.url)
12
+ const ASTRO_ROOT = dirname(require.resolve('astro/package.json'))
11
13
 
12
14
  /**
13
15
  * Astro's static build emits an SSR server bundle into the consumer's `dist/`
@@ -69,8 +71,9 @@ export function openDocConfig({ contentDir, publicDir } = {}) {
69
71
  const configJson = process.env.OPEN_DOC_CONFIG_JSON || '{}'
70
72
  // The app lives in node_modules (under pnpm, behind a symlinked .pnpm path),
71
73
  // so its island sources fall outside Vite's default fs.allow and 404 in dev.
72
- // Allow the app root explicitly alongside the consumer's content/public dirs.
73
- const allow = [APP_ROOT, contentDir, publicDir].filter(Boolean)
74
+ // Allow the app root, Astro's dev-toolbar client, and the consumer's
75
+ // content/public dirs explicitly.
76
+ const allow = [APP_ROOT, ASTRO_ROOT, contentDir, publicDir].filter(Boolean)
74
77
 
75
78
  return {
76
79
  name: 'open-doc:config',
@@ -335,9 +335,21 @@ const darkVars = toVars(config.theme.dark)
335
335
  const prose = document.querySelector('.docs-prose')
336
336
  if (!prose) return
337
337
  prose.querySelectorAll('pre').forEach((pre) => {
338
- if (pre.querySelector('.code-copy-btn')) return
338
+ const existingWrapper = pre.parentElement?.classList.contains('code-block')
339
+ ? pre.parentElement
340
+ : null
341
+ if (existingWrapper?.querySelector('.code-copy-btn')) return
342
+
343
+ const wrapper = existingWrapper ?? document.createElement('div')
344
+ if (!existingWrapper) {
345
+ wrapper.className = 'code-block'
346
+ pre.parentNode?.insertBefore(wrapper, pre)
347
+ wrapper.appendChild(pre)
348
+ }
349
+
339
350
  const btn = document.createElement('button')
340
351
  btn.className = 'code-copy-btn'
352
+ btn.type = 'button'
341
353
  btn.setAttribute('aria-label', 'Copy code')
342
354
  btn.innerHTML = clipboardIcon
343
355
  btn.addEventListener('click', () => {
@@ -352,7 +364,7 @@ const darkVars = toVars(config.theme.dark)
352
364
  }, 2000)
353
365
  })
354
366
  })
355
- pre.appendChild(btn)
367
+ wrapper.appendChild(btn)
356
368
  })
357
369
  }
358
370
 
@@ -0,0 +1,12 @@
1
+ const ASTRO_RENDERER = Symbol.for('astro:renderer')
2
+
3
+ export function tagReactComponent<T extends (...args: any[]) => unknown>(Component: T): T {
4
+ if (!(ASTRO_RENDERER in Component)) {
5
+ Object.defineProperty(Component, ASTRO_RENDERER, {
6
+ value: '@astrojs/react',
7
+ enumerable: false,
8
+ writable: false,
9
+ })
10
+ }
11
+ return Component
12
+ }
@@ -155,12 +155,33 @@
155
155
  content: none;
156
156
  }
157
157
 
158
+ .docs-prose kbd {
159
+ border: 1px solid hsl(var(--foreground) / 0.16);
160
+ border-radius: 0.375rem;
161
+ background: hsl(var(--surface-raised));
162
+ box-shadow:
163
+ 0 1px 0 hsl(var(--foreground) / 0.12),
164
+ inset 0 -1px 0 hsl(var(--foreground) / 0.08);
165
+ color: hsl(var(--foreground) / 0.88);
166
+ }
167
+
158
168
  .docs-prose pre {
159
169
  border: 1px solid hsl(var(--code-border));
160
170
  border-radius: 0.625rem;
161
171
  position: relative;
162
172
  }
163
173
 
174
+ .docs-prose .code-block {
175
+ position: relative;
176
+ margin-top: 1.7142857em;
177
+ margin-bottom: 1.7142857em;
178
+ }
179
+
180
+ .docs-prose .code-block pre {
181
+ margin-top: 0;
182
+ margin-bottom: 0;
183
+ }
184
+
164
185
  .docs-prose blockquote {
165
186
  border-left: 2px solid hsl(var(--border));
166
187
  background: hsl(var(--foreground) / 0.03);
@@ -240,6 +261,13 @@
240
261
  position: relative;
241
262
  }
242
263
 
264
+ .docs-prose h1[id],
265
+ .docs-prose h2[id],
266
+ .docs-prose h3[id],
267
+ .docs-prose h4[id] {
268
+ scroll-margin-top: calc(57px + 1rem);
269
+ }
270
+
243
271
  .docs-prose .heading-anchor {
244
272
  position: absolute;
245
273
  right: 100%;
@@ -289,7 +317,8 @@
289
317
  background 0.15s;
290
318
  }
291
319
 
292
- .docs-prose pre:hover .code-copy-btn {
320
+ .docs-prose .code-block:hover .code-copy-btn,
321
+ .code-copy-btn:focus-visible {
293
322
  opacity: 1;
294
323
  }
295
324
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openlaboratory/open-doc",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "description": "A polished, GitBook-style documentation-site framework. Write Markdown, run one command, get a beautiful static site.",
5
5
  "keywords": [
6
6
  "documentation",
@@ -25,7 +25,7 @@
25
25
  },
26
26
  "type": "module",
27
27
  "bin": {
28
- "open-doc": "./bin/open-doc.js"
28
+ "open-doc": "bin/open-doc.js"
29
29
  },
30
30
  "main": "./dist/index.js",
31
31
  "types": "./dist/index.d.ts",