@chassis-ui/docs 0.3.5 → 0.3.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 (3) hide show
  1. package/README.md +12 -4
  2. package/package.json +2 -8
  3. package/src/js/search.js +158 -61
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  > Shared Astro layouts, components, and utilities for Chassis documentation sites.
4
4
 
5
- [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT) [![Version: 0.3.5](https://img.shields.io/badge/Version-0.3.5-blue.svg)](https://www.npmjs.com/package/@chassis-ui/docs) [![npm](https://img.shields.io/npm/v/@chassis-ui/docs.svg)](https://www.npmjs.com/package/@chassis-ui/docs)
5
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT) [![Version: 0.3.6](https://img.shields.io/badge/Version-0.3.6-blue.svg)](https://www.npmjs.com/package/@chassis-ui/docs) [![npm](https://img.shields.io/npm/v/@chassis-ui/docs.svg)](https://www.npmjs.com/package/@chassis-ui/docs)
6
6
 
7
7
  ## Overview
8
8
 
@@ -26,11 +26,19 @@ npm install @chassis-ui/docs
26
26
 
27
27
  ```json
28
28
  {
29
- "astro": "^5.0.0"
29
+ "@astrojs/markdown-remark": "^7.0.0",
30
+ "@chassis-ui/css": ">=0.3.2",
31
+ "@pagefind/component-ui": "^1.0.0",
32
+ "@shikijs/transformers": "^4.3.1",
33
+ "astro": "^7.0.0",
34
+ "htmlparser2": "^12.0.0",
35
+ "rehype-autolink-headings": "^7.0.0",
36
+ "sass": "^1.101.0",
37
+ "shiki": "^4.3.1"
30
38
  }
31
39
  ```
32
40
 
33
- In practice, sites also install the rest of the Chassis stack — `@chassis-ui/css`, `@chassis-ui/tokens`, `@chassis-ui/icons` — to render the layouts and styles correctly.
41
+ In practice, sites also install the rest of the Chassis stack — `@chassis-ui/tokens`, `@chassis-ui/icons` — to render the layouts and styles correctly.
34
42
 
35
43
  ## Usage
36
44
 
@@ -55,7 +63,7 @@ import { generateTOC, processImage } from '@chassis-ui/docs'
55
63
 
56
64
  | Subpath | Contents |
57
65
  | ------------------------------- | --------------------------------------------------------------------------------------------- |
58
- | `@chassis-ui/docs` | Library functions: `chassis`, `image`, `layout`, `rehype`, `toc`, `utils` |
66
+ | `@chassis-ui/docs` | Library functions: `highlight`, `image`, `layout`, `markdown`, `placeholder`, `rehype`, `shortcodes`, `site`, `toc`, `utils` |
59
67
  | `@chassis-ui/docs/layouts/*` | `BaseLayout`, `DocsLayout`, `RedirectLayout`, `SingleLayout` |
60
68
  | `@chassis-ui/docs/components/*` | `DocsSidebar`, `FeatureCard`, `NavLink`, `ResponsiveImage`, `TableOfContents`, `ThemeToggler` |
61
69
  | `@chassis-ui/docs/shortcodes/*` | MDX shortcode components |
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@chassis-ui/docs",
3
3
  "description": "Shared Astro components and layouts for Chassis documentation sites.",
4
- "version": "0.3.5",
4
+ "version": "0.3.6",
5
5
  "type": "module",
6
6
  "repository": {
7
7
  "type": "git",
@@ -54,11 +54,6 @@
54
54
  "sass": "^1.101.0",
55
55
  "shiki": "^4.3.1"
56
56
  },
57
- "peerDependenciesMeta": {
58
- "@pagefind/component-ui": {
59
- "optional": true
60
- }
61
- },
62
57
  "dependencies": {
63
58
  "github-slugger": "^2.0.0",
64
59
  "image-size": "^2.0.2",
@@ -71,7 +66,6 @@
71
66
  "devDependencies": {
72
67
  "@types/hast": "^3.0.4",
73
68
  "astro": "^7.0.6",
74
- "unified": "^11.0.5",
75
- "vite": "^8.1.3"
69
+ "unified": "^11.0.5"
76
70
  }
77
71
  }
package/src/js/search.js CHANGED
@@ -41,12 +41,10 @@ const CHASSIS_SITES = [
41
41
  { slug: 'figma', label: 'Figma' }
42
42
  ]
43
43
 
44
- // Hosts where every project's index is reachable at its root-relative bundle
45
- // path via Vercel's proxy rewrites. Everywhere else (local dev, direct
46
- // `*.vercel.app` preview deployments) only this site's own pages exist, so
47
- // merging would 404. Pagefind has no built-in resilience for a failed
48
- // `mergeIndex` call — it throws and takes the whole search down with it — so
49
- // merging is only attempted on these hosts.
44
+ // Hosts where every project's index is *expected* to be reachable at its
45
+ // root-relative bundle path via Vercel's proxy rewrites. Everywhere else
46
+ // (local dev, direct `*.vercel.app` preview deployments) only this site's own
47
+ // pages exist, so merging would always 404 — not attempted there at all.
50
48
  const CANONICAL_HOSTS = new Set(['chassis-ui.com', 'staging.chassis-ui.com'])
51
49
 
52
50
  const getBundlePath = (slug) => (slug === 'website' ? '/pagefind/' : `/${slug}/pagefind/`)
@@ -69,19 +67,17 @@ const currentSite = getCurrentSite()
69
67
  // `/tokens/tokens/docs/...`. Force it back to `/` for every index.
70
68
  const BASE_URL = '/'
71
69
 
72
- const mergeIndex = isCanonicalHost()
73
- ? CHASSIS_SITES.filter((site) => site.slug !== currentSite.slug).map((site) => ({
74
- bundlePath: getBundlePath(site.slug),
75
- baseUrl: BASE_URL,
76
- mergeFilter: { site: site.slug }
77
- }))
78
- : []
79
-
70
+ // NOTE: we deliberately do NOT pass `mergeIndex` here. `@pagefind/component-ui`
71
+ // awaits every entry in that array back-to-back with no try/catch — if a
72
+ // single sibling site's index 404s (e.g. mid-rollout, one repo deployed
73
+ // before another), the whole `__doLoad__` throws and search breaks entirely,
74
+ // including this site's own results. We instead merge additional sites
75
+ // ourselves afterwards, in `mergeAdditionalSites()` below, so a single
76
+ // unreachable sibling is skipped rather than taking everything down.
80
77
  const instance = getInstanceManager().getInstance('default', {
81
78
  bundlePath: getBundlePath(currentSite.slug),
82
79
  baseUrl: BASE_URL,
83
- mergeFilter: { site: currentSite.slug },
84
- mergeIndex
80
+ mergeFilter: { site: currentSite.slug }
85
81
  })
86
82
 
87
83
  // Default filter scope: the website (hub) defaults to searching everywhere;
@@ -91,6 +87,99 @@ if (currentSite.slug !== 'website') {
91
87
  instance.searchFilters = { site: currentSite.slug }
92
88
  }
93
89
 
90
+ let mergeAttempted = false
91
+
92
+ // How long to wait for a sibling's Pagefind entry file before assuming it's
93
+ // unreachable (bad deploy, rollout in progress, host down, etc.).
94
+ const PROBE_TIMEOUT_MS = 4000
95
+
96
+ // Mirrors Pagefind's own language selection in `PagefindInstance.findIndex`:
97
+ // exact `<html lang>` match, then its base subtag, then the language with the
98
+ // most pages. Needed so the probe below checks the same chunk `init()` will
99
+ // actually request.
100
+ const getPreferredLanguageHash = (entry) => {
101
+ const languages = entry?.languages
102
+ if (!languages) return null
103
+ const htmlLang = document.documentElement.getAttribute('lang')?.toLowerCase()
104
+ if (htmlLang && languages[htmlLang]) return languages[htmlLang].hash
105
+ const base = htmlLang?.split('-')[0]
106
+ if (base && languages[base]) return languages[base].hash
107
+ const [topLanguage] = Object.values(languages).sort((a, b) => b.page_count - a.page_count)
108
+ return topLanguage?.hash ?? null
109
+ }
110
+
111
+ /**
112
+ * Checks whether a sibling site's Pagefind bundle actually exists, without
113
+ * touching the shared `pf` instance. This matters because a *failed*
114
+ * `pf.mergeIndex()` call corrupts that instance beyond recovery — there is no
115
+ * public API to detach a bad merge, and every future search (including this
116
+ * site's own) throws the same error afterwards.
117
+ *
118
+ * `init()` performs two fetches, not one: `pagefind-entry.json`, then the
119
+ * language-specific `pagefind.<hash>.pf_meta` chunk it points to. Checking
120
+ * only the entry file misses exactly the failure this is meant to guard
121
+ * against — a deploy mid-rollout where the entry file has already updated to
122
+ * reference a hash whose chunk hasn't finished uploading (or was purged) yet.
123
+ * So we probe both before ever calling `mergeIndex`.
124
+ */
125
+ const isBundleReachable = async (bundlePath) => {
126
+ const controller = new AbortController()
127
+ const timer = setTimeout(() => controller.abort(), PROBE_TIMEOUT_MS)
128
+ try {
129
+ const entryResponse = await fetch(`${bundlePath}pagefind-entry.json`, {
130
+ signal: controller.signal
131
+ })
132
+ if (!entryResponse.ok) return false
133
+
134
+ const hash = getPreferredLanguageHash(await entryResponse.json())
135
+ if (!hash) return false
136
+
137
+ const metaResponse = await fetch(`${bundlePath}pagefind.${hash}.pf_meta`, {
138
+ signal: controller.signal
139
+ })
140
+ return metaResponse.ok
141
+ } catch {
142
+ return false
143
+ } finally {
144
+ clearTimeout(timer)
145
+ }
146
+ }
147
+
148
+ /**
149
+ * Loads this site's own Pagefind bundle, then merges every other Chassis
150
+ * site's index in, one at a time. Each sibling is probed first (see
151
+ * `isBundleReachable`) so an unreachable one — a bad deploy, a rollout still
152
+ * in progress across the (separate) Chassis repos, etc. — is silently skipped
153
+ * instead of breaking search for everyone. Safe to call multiple times; only
154
+ * does real work once.
155
+ */
156
+ const mergeAdditionalSites = async () => {
157
+ if (mergeAttempted) return
158
+ mergeAttempted = true
159
+
160
+ await instance.triggerLoad()
161
+ const pf = instance.__pagefind__
162
+ if (!pf || !isCanonicalHost()) return
163
+
164
+ const siblings = CHASSIS_SITES.filter((site) => site.slug !== currentSite.slug)
165
+ await Promise.allSettled(
166
+ siblings.map(async (site) => {
167
+ const bundlePath = getBundlePath(site.slug)
168
+ if (!(await isBundleReachable(bundlePath))) {
169
+ console.warn(
170
+ `Chassis search: "${site.slug}" index not reachable at ${bundlePath}, skipping`
171
+ )
172
+ return
173
+ }
174
+ try {
175
+ await pf.mergeIndex(bundlePath, { baseUrl: BASE_URL, mergeFilter: { site: site.slug } })
176
+ } catch (error) {
177
+ console.warn(`Chassis search: failed to merge the "${site.slug}" index`, error)
178
+ }
179
+ })
180
+ )
181
+ }
182
+
94
183
  // ─── Recent visits ────────────────────────────────────────────────────────────
95
184
  // Opt-out via Do Not Track; silently no-op when storage is unavailable.
96
185
 
@@ -137,20 +226,40 @@ const HTML_ESCAPES = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'
137
226
  const escapeHtml = (value) => String(value ?? '').replace(/[&<>"']/g, (char) => HTML_ESCAPES[char])
138
227
 
139
228
  // Single template for every clickable row: top-level results, sub-results, and
140
- // recently visited items. `title` and `excerpt` are inlined as-is so callers
141
- // can either escape them (recents, plain text) or pass Pagefind's pre-marked HTML.
142
- const renderItem = ({ url, title, excerpt = '', icon, sub = false }) => `
229
+ // recently visited items. `titleHtml`/`excerptHtml` name the contract, not just
230
+ // describe it: callers must pass already-safe HTML, either by running plain
231
+ // text through `escapeHtml` (recents) or by using Pagefind's own pre-marked
232
+ // excerpt (search results, which embed `<mark>` highlights).
233
+ const renderItem = ({ url, titleHtml, excerptHtml = '', icon, sub = false }) => `
143
234
  <a class="cxd-search-item${sub ? ' cxd-search-subitem' : ''}" href="${escapeHtml(url)}">
144
235
  <svg class="icon cxd-search-item-icon" width="16" height="16" aria-hidden="true">
145
- <use href="#${icon}"></use>
236
+ <use href="/static/icons/chassis-icons.svg#${icon}"></use>
146
237
  </svg>
147
238
  <div class="cxd-search-item-body">
148
- <div class="cxd-search-item-title">${title}</div>
149
- ${excerpt ? `<div class="cxd-search-item-excerpt">${excerpt}</div>` : ''}
239
+ <div class="cxd-search-item-title">${titleHtml}</div>
240
+ ${excerptHtml ? `<div class="cxd-search-item-excerpt">${excerptHtml}</div>` : ''}
150
241
  </div>
151
242
  </a>
152
243
  `
153
244
 
245
+ // Shared wrapper for every rendered list of rows (recents, top-level results).
246
+ const renderResultsList = (itemsHtml) =>
247
+ `<ol class="list flush cxd-search-results-list">${itemsHtml}</ol>`
248
+
249
+ // Wires `handlers` (eventType -> bound listener) onto `target` and returns a
250
+ // cleanup function that removes them all — the bind/track/remove dance every
251
+ // element below otherwise repeats by hand in connectedCallback/disconnectedCallback.
252
+ const addListeners = (target, handlers) => {
253
+ for (const [type, handler] of Object.entries(handlers)) {
254
+ target.addEventListener(type, handler)
255
+ }
256
+ return () => {
257
+ for (const [type, handler] of Object.entries(handlers)) {
258
+ target.removeEventListener(type, handler)
259
+ }
260
+ }
261
+ }
262
+
154
263
  // ─── Custom elements ──────────────────────────────────────────────────────────
155
264
 
156
265
  class CxdSearchInput extends HTMLElement {
@@ -161,23 +270,16 @@ class CxdSearchInput extends HTMLElement {
161
270
  this.inputEl = this.input
162
271
  instance.registerInput(this, { keyboardNavigation: true })
163
272
 
164
- this._onInput = this._onInput.bind(this)
165
- this._onKeydown = this._onKeydown.bind(this)
166
- this.input.addEventListener('input', this._onInput)
167
- this.input.addEventListener('keydown', this._onKeydown)
168
-
169
- this.input.addEventListener(
170
- 'focus',
171
- () => {
172
- instance.triggerLoad()
173
- },
174
- { once: true }
175
- )
273
+ this._removeListeners = addListeners(this.input, {
274
+ input: this._onInput.bind(this),
275
+ keydown: this._onKeydown.bind(this)
276
+ })
277
+
278
+ this.input.addEventListener('focus', () => mergeAdditionalSites(), { once: true })
176
279
  }
177
280
 
178
281
  disconnectedCallback() {
179
- this.input?.removeEventListener('input', this._onInput)
180
- this.input?.removeEventListener('keydown', this._onKeydown)
282
+ this._removeListeners?.()
181
283
  }
182
284
 
183
285
  _onInput(event) {
@@ -213,17 +315,16 @@ class CxdSearchResults extends HTMLElement {
213
315
  instance.on('results', (result) => this._renderResults(result), this)
214
316
  instance.on('error', (error) => this._renderError(error), this)
215
317
 
216
- this._onKeydown = this._onKeydown.bind(this)
217
- this._onClick = this._onClick.bind(this)
218
- this.addEventListener('keydown', this._onKeydown)
219
- this.addEventListener('click', this._onClick)
318
+ this._removeListeners = addListeners(this, {
319
+ keydown: this._onKeydown.bind(this),
320
+ click: this._onClick.bind(this)
321
+ })
220
322
 
221
323
  this._renderEmpty()
222
324
  }
223
325
 
224
326
  disconnectedCallback() {
225
- this.removeEventListener('keydown', this._onKeydown)
226
- this.removeEventListener('click', this._onClick)
327
+ this._removeListeners?.()
227
328
  this._clearLoadingTimer()
228
329
  }
229
330
 
@@ -317,24 +418,24 @@ class CxdSearchResults extends HTMLElement {
317
418
  <span class="cxd-search-recent-label">Recently visited</span>
318
419
  <button type="button" class="cxd-search-recent-clear" data-cxd-search-clear-recent>Clear</button>
319
420
  </div>
320
- <ol class="list flush cxd-search-results-list">
321
- ${recents
421
+ ${renderResultsList(
422
+ recents
322
423
  .map((visit) => {
323
424
  const isSubLink = visit.url.includes('#')
324
425
  return `
325
426
  <li class="cxd-search-result">
326
427
  ${renderItem({
327
428
  url: visit.url,
328
- title: escapeHtml(visit.title),
329
- excerpt: escapeHtml(visit.excerpt),
429
+ titleHtml: escapeHtml(visit.title),
430
+ excerptHtml: escapeHtml(visit.excerpt),
330
431
  icon: isSubLink ? 'hashtag-outline' : 'file-outline',
331
432
  sub: isSubLink
332
433
  })}
333
434
  </li>
334
435
  `
335
436
  })
336
- .join('')}
337
- </ol>
437
+ .join('')
438
+ )}
338
439
  </div>
339
440
  `
340
441
  }
@@ -398,11 +499,7 @@ class CxdSearchResults extends HTMLElement {
398
499
 
399
500
  if (instance.searchTerm !== term) return // newer search started
400
501
 
401
- this.innerHTML = `
402
- <ol class="list flush cxd-search-results-list">
403
- ${data.map((result) => this._renderResult(result)).join('')}
404
- </ol>
405
- `
502
+ this.innerHTML = renderResultsList(data.map((result) => this._renderResult(result)).join(''))
406
503
  }
407
504
 
408
505
  _renderResult(result) {
@@ -418,7 +515,7 @@ class CxdSearchResults extends HTMLElement {
418
515
  .map(
419
516
  (sub) => `
420
517
  <li class="list-item">
421
- ${renderItem({ url: sub.url, title: escapeHtml(sub.title), excerpt: sub.excerpt, icon: 'hashtag-outline', sub: true })}
518
+ ${renderItem({ url: sub.url, titleHtml: escapeHtml(sub.title), excerptHtml: sub.excerpt, icon: 'hashtag-outline', sub: true })}
422
519
  </li>
423
520
  `
424
521
  )
@@ -428,7 +525,7 @@ class CxdSearchResults extends HTMLElement {
428
525
 
429
526
  return `
430
527
  <li class="list-item cxd-search-result">
431
- ${renderItem({ url: result.url, title: escapeHtml(title), excerpt: result.excerpt, icon: 'file-outline' })}
528
+ ${renderItem({ url: result.url, titleHtml: escapeHtml(title), excerptHtml: result.excerpt, icon: 'file-outline' })}
432
529
  ${subResultsHtml}
433
530
  </li>
434
531
  `
@@ -437,9 +534,8 @@ class CxdSearchResults extends HTMLElement {
437
534
 
438
535
  // ─── Site filter ──────────────────────────────────────────────────────────────
439
536
  // Dropdown letting the visitor scope results to a single project, or back out
440
- // to "Everywhere". Only rendered when other sites were actually merged in
441
- // (see `mergeIndex` above) — with a single, un-merged index there's nothing
442
- // meaningful to filter.
537
+ // to "Everywhere". Always rendered (see `CxdSearchFilter` below) — filtering to
538
+ // a site that hasn't actually been merged in just yields no results.
443
539
 
444
540
  const FILTER_OPTIONS = [{ slug: '', label: 'Everywhere' }, ...CHASSIS_SITES]
445
541
 
@@ -459,12 +555,13 @@ class CxdSearchFilter extends HTMLElement {
459
555
  ).join('')
460
556
  this.select.value = currentSite.slug === 'website' ? '' : currentSite.slug
461
557
 
462
- this._onChange = this._onChange.bind(this)
463
- this.select.addEventListener('change', this._onChange)
558
+ this._removeListeners = addListeners(this.select, {
559
+ change: this._onChange.bind(this)
560
+ })
464
561
  }
465
562
 
466
563
  disconnectedCallback() {
467
- this.select?.removeEventListener('change', this._onChange)
564
+ this._removeListeners?.()
468
565
  }
469
566
 
470
567
  _onChange() {