@jaamd/jaad 0.11.0 → 1.0.0

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 (138) hide show
  1. package/README.md +8 -2
  2. package/dist/jaad.css +232 -347
  3. package/docs/00-overview.md +25 -0
  4. package/docs/01-getting-started/01-installation.md +216 -0
  5. package/docs/01-getting-started/02-pages.md +109 -0
  6. package/docs/01-getting-started/03-deployment.md +100 -0
  7. package/docs/02-configurations/01-reference.md +215 -0
  8. package/docs/02-configurations/02-frontmatter.md +105 -0
  9. package/docs/02-configurations/03-i18n.md +210 -0
  10. package/docs/02-configurations/04-search.md +58 -0
  11. package/docs/02-configurations/05-themes.md +104 -0
  12. package/docs/02-configurations/06-styles.md +156 -0
  13. package/docs/02-configurations/07-custom-pages.md +119 -0
  14. package/docs/02-configurations/08-advanced-layout.md +139 -0
  15. package/docs/03-upgrading.md +208 -0
  16. package/index.ts +8 -22
  17. package/package.json +10 -9
  18. package/site.ts +18 -1
  19. package/src/config.ts +25 -176
  20. package/src/{advanced.ts → docs/advanced.ts} +6 -4
  21. package/src/docs/collection.ts +109 -0
  22. package/src/docs/components/Breadcrumbs.astro +29 -0
  23. package/src/docs/components/DocsMobileNav.astro +90 -0
  24. package/src/docs/components/DocsNavTree.astro +57 -0
  25. package/src/docs/components/DocsPage.astro +66 -0
  26. package/src/{components/docs → docs/components}/DocsPageNav.astro +4 -4
  27. package/src/docs/components/DocsSidebarLeft.astro +53 -0
  28. package/src/{components/docs → docs/components}/PageActions.astro +10 -8
  29. package/src/{components/docs → docs/components}/StructuredData.astro +3 -3
  30. package/src/{components/docs → docs/components}/TableOfContents.astro +20 -22
  31. package/src/docs/config.ts +167 -0
  32. package/src/docs/content.ts +36 -0
  33. package/src/docs/endpoints.ts +89 -0
  34. package/src/docs/integration.ts +249 -0
  35. package/src/{layouts → docs/layouts}/DefaultDocsFrame.astro +7 -5
  36. package/src/{layouts → docs/layouts}/Docs.astro +44 -43
  37. package/src/docs/layouts/Page.astro +18 -0
  38. package/src/docs/link-plugins.ts +52 -0
  39. package/src/docs/links.ts +42 -0
  40. package/src/{locale-tree.ts → docs/locale-tree.ts} +1 -1
  41. package/src/docs/model/ids.ts +87 -0
  42. package/src/docs/model/rendered.ts +185 -0
  43. package/src/docs/model/tree.ts +124 -0
  44. package/src/docs/model/validate.ts +75 -0
  45. package/src/docs/navigation.ts +75 -0
  46. package/src/{routes → docs/routes}/404.astro +1 -1
  47. package/src/docs/routes/docs-index.astro +19 -0
  48. package/src/docs/routes/docs-slug.astro +54 -0
  49. package/src/{routes → docs/routes}/docs-slug.md.ts +8 -10
  50. package/src/{routes → docs/routes}/llms-locale.txt.ts +1 -1
  51. package/src/{routes → docs/routes}/llms.txt.ts +1 -1
  52. package/src/{routes → docs/routes}/search-index-locale.json.ts +1 -1
  53. package/src/{routes → docs/routes}/search-index.json.ts +1 -1
  54. package/src/docs/runtime.ts +17 -0
  55. package/src/docs/search-index.ts +39 -0
  56. package/src/docs/styles/components/breadcrumbs.css +26 -0
  57. package/src/docs/styles/components/docs-mobile-nav.css +47 -0
  58. package/src/docs/styles/components/docs-page-nav.css +41 -0
  59. package/src/docs/styles/components/docs-sidebar.css +59 -0
  60. package/src/docs/styles/components/page-actions.css +51 -0
  61. package/src/docs/styles/components/toc.css +38 -0
  62. package/src/docs/styles/components.css +7 -0
  63. package/src/docs/styles/docs.css +81 -0
  64. package/src/{@types/docs.ts → docs/types.ts} +8 -16
  65. package/src/{urls.ts → docs/urls.ts} +2 -16
  66. package/src/env.d.ts +1 -1
  67. package/src/integration.ts +15 -249
  68. package/src/{components → site/components}/Footer.astro +1 -1
  69. package/src/{components → site/components}/Header.astro +14 -11
  70. package/src/{components → site/components}/LocaleSwitcher.astro +2 -2
  71. package/src/{components → site/components}/NavMobile.astro +1 -1
  72. package/src/{components → site/components}/SearchPalette.astro +160 -79
  73. package/src/{components → site/components}/ThemeSwitcher.astro +1 -1
  74. package/src/site/config.ts +149 -0
  75. package/src/{infer.ts → site/infer.ts} +3 -3
  76. package/src/site/integration.ts +59 -0
  77. package/src/{layouts/Page.astro → site/layouts/Shell.astro} +6 -3
  78. package/src/{locales.ts → site/locales.ts} +9 -1
  79. package/src/{docs-config.ts → site/runtime.ts} +1 -12
  80. package/src/site/search.ts +227 -0
  81. package/src/{social.ts → site/social.ts} +2 -2
  82. package/src/site/styles/components/header.css +31 -0
  83. package/src/{styles → site/styles}/components/search.css +21 -1
  84. package/src/{styles → site/styles}/components/shell.css +4 -0
  85. package/src/site/styles/components.css +10 -0
  86. package/src/{styles → site/styles}/global.css +4 -26
  87. package/src/{themes/index.ts → site/themes.ts} +1 -1
  88. package/src/{ui → site/ui}/en.ts +3 -5
  89. package/src/{ui → site/ui}/it.ts +2 -4
  90. package/src/site/urls.ts +14 -0
  91. package/src/{virtual.ts → site/virtual.ts} +12 -18
  92. package/src/styles.css +5 -0
  93. package/src/collection.ts +0 -97
  94. package/src/components/ScrollToTop.astro +0 -43
  95. package/src/components/docs/Breadcrumbs.astro +0 -36
  96. package/src/components/docs/DocsMobileNav.astro +0 -93
  97. package/src/components/docs/DocsPage.astro +0 -68
  98. package/src/components/docs/DocsSidebarLeft.astro +0 -82
  99. package/src/content.ts +0 -42
  100. package/src/navigation.ts +0 -97
  101. package/src/routes/docs-index.astro +0 -19
  102. package/src/routes/docs-slug.astro +0 -58
  103. package/src/styles/components/breadcrumbs.css +0 -13
  104. package/src/styles/components/docs-mobile-nav.css +0 -9
  105. package/src/styles/components/docs-page-nav.css +0 -31
  106. package/src/styles/components/docs-sidebar.css +0 -42
  107. package/src/styles/components/header.css +0 -20
  108. package/src/styles/components/page-actions.css +0 -13
  109. package/src/styles/components/scroll-to-top.css +0 -11
  110. package/src/styles/components/toc.css +0 -21
  111. package/src/styles/docs.css +0 -112
  112. package/src/utils/docs.ts +0 -300
  113. package/src/utils/endpoints.ts +0 -80
  114. package/src/utils/search.ts +0 -83
  115. /package/src/{styles → docs/styles}/components/docs-page.css +0 -0
  116. /package/src/{styles → docs/styles}/jaamd.css +0 -0
  117. /package/src/{components → site/components}/Head.astro +0 -0
  118. /package/src/{components → site/components}/Nav.astro +0 -0
  119. /package/src/{components → site/components}/SocialLinks.astro +0 -0
  120. /package/src/{components → site/components}/locale-switcher.ts +0 -0
  121. /package/src/{fonts → site/fonts}/OFL.txt +0 -0
  122. /package/src/{fonts → site/fonts}/inter.woff2 +0 -0
  123. /package/src/{fonts → site/fonts}/merriweather-italic.woff2 +0 -0
  124. /package/src/{fonts → site/fonts}/merriweather.woff2 +0 -0
  125. /package/src/{fonts.ts → site/fonts.ts} +0 -0
  126. /package/src/{forges.ts → site/forges.ts} +0 -0
  127. /package/src/{utils → site}/helpers.ts +0 -0
  128. /package/src/{styles → site/styles}/components/footer.css +0 -0
  129. /package/src/{styles → site/styles}/components/locale-switcher.css +0 -0
  130. /package/src/{styles → site/styles}/components/nav-mobile.css +0 -0
  131. /package/src/{styles → site/styles}/components/nav.css +0 -0
  132. /package/src/{styles → site/styles}/components/not-found.css +0 -0
  133. /package/src/{styles → site/styles}/components/social-links.css +0 -0
  134. /package/src/{styles → site/styles}/components/theme-switcher.css +0 -0
  135. /package/src/{styles → site/styles}/jaamd-forward.css +0 -0
  136. /package/src/{styles → site/styles}/jaamd-reverse.css +0 -0
  137. /package/src/{styles → site/styles}/tailwind.css +0 -0
  138. /package/src/{ui.ts → site/ui.ts} +0 -0
@@ -1,32 +1,28 @@
1
1
  ---
2
- import { DEFAULT_LOCALE, docsUrls, ui } from '../docs-config.ts'
3
- import { docsPageHref } from '../urls.ts'
2
+ import { DEFAULT_LOCALE, ui } from '../runtime.ts'
4
3
 
5
4
  interface Props {
6
- /** Whether this page is part of the docs. Off them the palette keeps its
7
- * icon trigger and its shortcut, and searches the default locale. */
8
- inDocs?: boolean
9
- /** Search covers one locale: the one the reader is in. */
5
+ /** The search index, of the reader's locale. */
6
+ indexUrl: string
7
+ /** The full field on wide screens. Otherwise the palette keeps its icon
8
+ * trigger and its shortcut. */
9
+ prominent?: boolean
10
10
  locale?: string
11
11
  }
12
12
 
13
- const { inDocs = false, locale = DEFAULT_LOCALE } = Astro.props
14
- const urls = docsUrls(locale)
13
+ const { indexUrl, prominent = false, locale = DEFAULT_LOCALE } = Astro.props
15
14
  const t = ui(locale)
16
- const base = docsPageHref('', urls)
17
- const indexUrl = docsPageHref('search-index.json', urls)
18
15
 
19
16
  const MAGNIFIER = 'M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z'
20
17
  ---
21
18
 
22
19
  <jaad-search
23
- data-in-docs={inDocs ? '' : null}
24
- data-base={base}
20
+ data-prominent={prominent ? '' : null}
25
21
  data-index-url={indexUrl}
26
22
  data-loading={t('search.loading')}
27
23
  data-empty={t('search.empty')}
28
24
  >
29
- <button data-trigger-desktop hidden={!inDocs} class="jaad-search-trigger" aria-label={t('search.label')}>
25
+ <button data-trigger-desktop hidden={!prominent} class="jaad-search-trigger" aria-label={t('search.label')}>
30
26
  <svg class="jaad-search-trigger-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
31
27
  <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d={MAGNIFIER}></path>
32
28
  </svg>
@@ -38,7 +34,7 @@ const MAGNIFIER = 'M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z'
38
34
 
39
35
  <button
40
36
  data-trigger-icon
41
- class:list={['jaad-icon-button', inDocs && 'jaad-search-trigger-compact']}
37
+ class:list={['jaad-icon-button', prominent && 'jaad-search-trigger-compact']}
42
38
  aria-label={t('search.label')}
43
39
  >
44
40
  <svg class="jaad-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
@@ -78,6 +74,12 @@ const MAGNIFIER = 'M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z'
78
74
  </kbd>
79
75
  </div>
80
76
 
77
+ <div data-filters hidden class="jaad-search-filters" role="group" aria-label={t('search.sections')}>
78
+ <button type="button" data-filter="" aria-pressed="true" class="jaad-search-filter">
79
+ {t('search.all')}
80
+ </button>
81
+ </div>
82
+
81
83
  <ul
82
84
  data-results
83
85
  id="search-results"
@@ -106,16 +108,30 @@ const MAGNIFIER = 'M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z'
106
108
 
107
109
  <script>
108
110
  import { navigate } from 'astro:transitions/client'
109
- import { scoreItems, getSnippet, splitOnMatches, type SearchItem } from '../utils/search.ts'
111
+ import {
112
+ getSnippet,
113
+ markWords,
114
+ openingPages,
115
+ queryTerms,
116
+ search,
117
+ type SearchIndex,
118
+ type SearchPage,
119
+ } from '../search.ts'
120
+
121
+ const EMPTY_INDEX: SearchIndex = { sections: [], pages: [], entries: [] }
110
122
 
111
123
  class JaadSearch extends HTMLElement {
112
124
  #overlay!: HTMLElement
113
125
  #input!: HTMLInputElement
126
+ #filters!: HTMLElement
114
127
  #list!: HTMLUListElement
115
- #index: SearchItem[] | null = null
116
- #request: Promise<SearchItem[]> | null = null
117
- #results: SearchItem[] = []
128
+ #index: SearchIndex | null = null
129
+ #request: Promise<SearchIndex> | null = null
130
+ /** The selectable rows, in display order. */
131
+ #options: { li: HTMLLIElement; href: string }[] = []
118
132
  #selected = 0
133
+ /** The section key results are limited to; empty for all. */
134
+ #section = ''
119
135
  #restoreFocus: HTMLElement | null = null
120
136
  #previousOverflow = ''
121
137
 
@@ -126,6 +142,7 @@ const MAGNIFIER = 'M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z'
126
142
  connectedCallback() {
127
143
  this.#overlay = this.querySelector('[data-overlay]')!
128
144
  this.#input = this.querySelector('[data-input]')!
145
+ this.#filters = this.querySelector('[data-filters]')!
129
146
  this.#list = this.querySelector('[data-results]')!
130
147
 
131
148
  if (/Mac|iPod|iPhone|iPad/.test(navigator.userAgent)) {
@@ -141,6 +158,10 @@ const MAGNIFIER = 'M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z'
141
158
 
142
159
  this.querySelector('[data-backdrop]')!.addEventListener('click', () => this.#setOpen(false))
143
160
  this.querySelector('[data-close]')!.addEventListener('click', () => this.#setOpen(false))
161
+ this.#filters.addEventListener('click', (e) => {
162
+ const button = (e.target as Element).closest<HTMLButtonElement>('[data-filter]')
163
+ if (button) this.#filter(button.dataset.filter ?? '')
164
+ })
144
165
 
145
166
  this.#input.addEventListener('input', () => this.#render())
146
167
  this.#input.addEventListener('keydown', (e) => this.#onInputKey(e))
@@ -159,15 +180,13 @@ const MAGNIFIER = 'M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z'
159
180
  const next = (event as Event & { newDocument: Document }).newDocument.querySelector<HTMLElement>(
160
181
  'jaad-search',
161
182
  )
162
- if (!next) return
163
-
164
- const indexUrl = next.dataset.indexUrl
183
+ const indexUrl = next?.dataset.indexUrl
165
184
  if (indexUrl && indexUrl !== this.dataset.indexUrl) {
166
185
  this.dataset.indexUrl = indexUrl
167
186
  this.#index = null
168
187
  this.#request = null
188
+ this.#section = ''
169
189
  }
170
- if (next.dataset.base) this.dataset.base = next.dataset.base
171
190
  }
172
191
 
173
192
  #onGlobalKey = (e: KeyboardEvent) => {
@@ -183,26 +202,30 @@ const MAGNIFIER = 'M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z'
183
202
  #onInputKey(e: KeyboardEvent) {
184
203
  if (e.key === 'ArrowDown') {
185
204
  e.preventDefault()
186
- this.#select(Math.min(this.#selected + 1, this.#results.length - 1))
205
+ this.#select(Math.min(this.#selected + 1, this.#options.length - 1))
187
206
  } else if (e.key === 'ArrowUp') {
188
207
  e.preventDefault()
189
208
  this.#select(Math.max(this.#selected - 1, 0))
190
- } else if (e.key === 'Enter' && this.#results[this.#selected]) {
191
- this.#go(this.#results[this.#selected].slug)
209
+ } else if (e.key === 'Enter' && this.#options[this.#selected]) {
210
+ this.#go(this.#options[this.#selected].href)
192
211
  }
193
212
  }
194
213
 
195
- #loadIndex(): Promise<SearchItem[]> {
214
+ #loadIndex(): Promise<SearchIndex> {
196
215
  if (this.#index) return Promise.resolve(this.#index)
197
216
  if (!this.#request) {
198
217
  const url = this.dataset.indexUrl ?? '/search-index.json'
199
218
  this.#request = fetch(url)
200
- .then((res) => (res.ok ? res.json() : []))
201
- .then((data: SearchItem[]) => (this.#index = data))
219
+ .then((res) => (res.ok ? res.json() : EMPTY_INDEX))
220
+ .then((data: SearchIndex) => {
221
+ this.#index = data
222
+ this.#renderFilters(data)
223
+ return data
224
+ })
202
225
  .catch(() => {
203
226
  // Drop the failed promise so a later attempt can retry.
204
227
  this.#request = null
205
- return []
228
+ return EMPTY_INDEX
206
229
  })
207
230
  }
208
231
  return this.#request
@@ -226,28 +249,47 @@ const MAGNIFIER = 'M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z'
226
249
  }
227
250
  }
228
251
 
229
- #go(slug: string) {
252
+ #go(href: string) {
230
253
  this.#setOpen(false)
231
- const base = this.dataset.base ?? '/'
232
- const prefix = base === '/' ? '' : base.replace(/\/$/, '')
233
- navigate(slug ? `${prefix}/${slug}` : base)
254
+ navigate(href)
255
+ }
256
+
257
+ /** One button per section, after "All"; none with fewer than two. */
258
+ #renderFilters(index: SearchIndex) {
259
+ const [all] = this.#filters.querySelectorAll('[data-filter]')
260
+ this.#filters.replaceChildren(all)
261
+ this.#filters.hidden = index.sections.length < 2
262
+ for (const { key, label } of index.sections) {
263
+ const button = document.createElement('button')
264
+ button.type = 'button'
265
+ button.dataset.filter = key
266
+ button.className = 'jaad-search-filter'
267
+ button.textContent = label
268
+ this.#filters.append(button)
269
+ }
270
+ this.#filter(this.#section)
271
+ }
272
+
273
+ #filter(section: string) {
274
+ this.#section = section
275
+ for (const button of this.#filters.querySelectorAll<HTMLElement>('[data-filter]')) {
276
+ button.setAttribute('aria-pressed', String(button.dataset.filter === section))
277
+ }
278
+ this.#render()
279
+ this.#input.focus()
234
280
  }
235
281
 
236
282
  #select(i: number) {
237
283
  this.#selected = i
238
- this.#input.setAttribute('aria-activedescendant', `search-option-${i}`)
239
- ;[...this.#list.children].forEach((li, n) => {
240
- const active = n === i
241
- li.setAttribute('aria-selected', String(active))
242
- })
243
- ;(this.#list.children[i] as HTMLElement | undefined)?.scrollIntoView({
244
- block: 'nearest',
245
- })
284
+ const option = this.#options[i]
285
+ if (option) this.#input.setAttribute('aria-activedescendant', option.li.id)
286
+ this.#options.forEach(({ li }, n) => li.setAttribute('aria-selected', String(n === i)))
287
+ option?.li.scrollIntoView({ block: 'nearest' })
246
288
  }
247
289
 
248
- #marked(text: string, query: string): DocumentFragment {
290
+ #marked(text: string, terms: string[]): DocumentFragment {
249
291
  const frag = document.createDocumentFragment()
250
- for (const part of splitOnMatches(text, query)) {
292
+ for (const part of markWords(text, terms)) {
251
293
  if (part.match) {
252
294
  const mark = document.createElement('mark')
253
295
  mark.className = 'jaad-search-mark'
@@ -261,8 +303,8 @@ const MAGNIFIER = 'M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z'
261
303
  }
262
304
 
263
305
  #render() {
264
- const query = this.#input.value
265
306
  this.#list.replaceChildren()
307
+ this.#options = []
266
308
  this.#input.removeAttribute('aria-activedescendant')
267
309
 
268
310
  if (!this.#index) {
@@ -270,48 +312,87 @@ const MAGNIFIER = 'M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z'
270
312
  return
271
313
  }
272
314
 
273
- this.#results = scoreItems(this.#index, query)
315
+ const query = this.#input.value
316
+ const terms = queryTerms(query)
317
+ const section = this.#section || undefined
318
+
319
+ if (terms.length === 0) {
320
+ for (const group of openingPages(this.#index, section)) {
321
+ if (group.label) this.#list.append(this.#label(group.label))
322
+ for (const page of group.pages) this.#option(page.href, this.#pageRow(page, [], ''))
323
+ }
324
+ } else {
325
+ for (const group of search(this.#index, query, section)) {
326
+ const snippet = group.intro ? getSnippet(group.intro.text, terms) : ''
327
+ this.#option(group.page.href, this.#pageRow(group.page, terms, snippet))
328
+ for (const hit of group.hits) {
329
+ const li = this.#option(hit.href, this.#row(hit.entry.heading ?? '', terms))
330
+ li.classList.add('jaad-search-result-heading')
331
+ this.#snippet(li, getSnippet(hit.entry.text, terms), terms)
332
+ }
333
+ }
334
+ }
274
335
 
275
- if (this.#results.length === 0) {
336
+ if (this.#options.length === 0) {
276
337
  this.#list.append(this.#message(`${this.dataset.empty ?? 'No results for'} “${query}”`))
277
338
  return
278
339
  }
340
+ this.#select(0)
341
+ }
279
342
 
280
- this.#results.forEach((item, i) => {
281
- const li = document.createElement('li')
282
- li.id = `search-option-${i}`
283
- li.setAttribute('role', 'option')
284
- li.className = 'jaad-search-result'
285
- li.addEventListener('click', () => this.#go(item.slug))
286
- li.addEventListener('mouseenter', () => this.#select(i))
287
-
288
- const head = document.createElement('div')
289
- head.className = 'jaad-search-result-head'
290
- const title = document.createElement('span')
291
- title.className = 'jaad-search-result-title'
292
- title.append(this.#marked(item.title, query))
293
- head.append(title)
294
-
295
- if (item.chapter) {
296
- const badge = document.createElement('span')
297
- badge.className = 'jaad-search-result-badge'
298
- badge.textContent = item.chapter
299
- head.append(badge)
300
- }
301
- li.append(head)
302
-
303
- const snippet = getSnippet(item.body, query)
304
- if (snippet) {
305
- const p = document.createElement('p')
306
- p.className = 'jaad-search-result-snippet'
307
- p.append(this.#marked(snippet, query))
308
- li.append(p)
309
- }
343
+ /** A selectable row: the list item, given its content. */
344
+ #option(href: string, content: HTMLElement): HTMLLIElement {
345
+ const i = this.#options.length
346
+ const li = document.createElement('li')
347
+ li.id = `search-option-${i}`
348
+ li.setAttribute('role', 'option')
349
+ li.className = 'jaad-search-result'
350
+ li.addEventListener('click', () => this.#go(href))
351
+ li.addEventListener('mouseenter', () => this.#select(i))
352
+ li.append(content)
353
+ this.#list.append(li)
354
+ this.#options.push({ li, href })
355
+ return li
356
+ }
310
357
 
311
- this.#list.append(li)
312
- })
358
+ #row(title: string, terms: string[]): HTMLElement {
359
+ const head = document.createElement('div')
360
+ head.className = 'jaad-search-result-head'
361
+ const span = document.createElement('span')
362
+ span.className = 'jaad-search-result-title'
363
+ span.append(this.#marked(title, terms))
364
+ head.append(span)
365
+ return head
366
+ }
313
367
 
314
- this.#select(0)
368
+ #pageRow(page: SearchPage, terms: string[], snippet: string): HTMLElement {
369
+ const fragment = document.createElement('div')
370
+ const head = this.#row(page.title, terms)
371
+ if (page.chapter) {
372
+ const badge = document.createElement('span')
373
+ badge.className = 'jaad-search-result-badge'
374
+ badge.textContent = page.chapter
375
+ head.append(badge)
376
+ }
377
+ fragment.append(head)
378
+ this.#snippet(fragment, snippet, terms)
379
+ return fragment
380
+ }
381
+
382
+ #snippet(parent: HTMLElement, text: string, terms: string[]) {
383
+ if (!text) return
384
+ const p = document.createElement('p')
385
+ p.className = 'jaad-search-result-snippet'
386
+ p.append(this.#marked(text, terms))
387
+ parent.append(p)
388
+ }
389
+
390
+ #label(text: string): HTMLLIElement {
391
+ const li = document.createElement('li')
392
+ li.setAttribute('role', 'presentation')
393
+ li.className = 'jaad-search-group'
394
+ li.textContent = text
395
+ return li
315
396
  }
316
397
 
317
398
  #message(text: string): HTMLLIElement {
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  // The theme class and the icon are already set in <head>; only the click is JS.
3
- import { ui } from '../docs-config.ts'
3
+ import { ui } from '../runtime.ts'
4
4
 
5
5
  const t = ui(Astro.props.locale)
6
6
  ---
@@ -0,0 +1,149 @@
1
+ import type { Locale } from './locales.ts'
2
+ import { existsSync } from 'node:fs'
3
+ import { createRequire } from 'node:module'
4
+ import { join } from 'node:path'
5
+ import { fileURLToPath } from 'node:url'
6
+ import { z } from 'astro/zod'
7
+ import { inferDescription, type RepoInfo } from './infer.ts'
8
+ import { PRESETS, PRESET_NAMES, isPreset } from './themes.ts'
9
+
10
+ const publicUrl = (field: string, example: string) =>
11
+ z.string().refine((v) => v.startsWith('/') || /^https?:\/\//.test(v), {
12
+ message: `${field} must be a public url, such as "${example}"`,
13
+ })
14
+
15
+ /** The options of the site around the pages: identity, header, footer, head and theme. */
16
+ export const siteConfigShape = {
17
+ title: z.string(),
18
+ description: z.string().optional(),
19
+ lang: z.string().default('en'),
20
+ logo: publicUrl('logo', '/logo.svg').optional(),
21
+ /** Where the site name in the header leads: the site root, or another site. */
22
+ homeLink: z.string().default('/'),
23
+
24
+ social: z
25
+ .record(
26
+ z.string(),
27
+ z.union([
28
+ z.string(),
29
+ z.object({
30
+ href: z.string(),
31
+ label: z.string().optional(),
32
+ svg: z.string().optional(),
33
+ }),
34
+ ]),
35
+ )
36
+ .default({}),
37
+ nav: z.array(z.object({ label: z.string(), href: z.string() })).default([]),
38
+ footer: z
39
+ .union([z.string(), z.literal(false)], {
40
+ error:
41
+ 'footer is one line of text, or false to remove it. ' +
42
+ 'The message/copyright pair is gone: write them as a single string, ' +
43
+ 'e.g. "MIT Licensed · © 2026 Me · :credit".',
44
+ })
45
+ .optional(),
46
+
47
+ head: z
48
+ .array(
49
+ z.object({
50
+ tag: z.string(),
51
+ attrs: z.record(z.string(), z.union([z.string(), z.boolean()])).optional(),
52
+ content: z.string().optional(),
53
+ }),
54
+ )
55
+ .default([]),
56
+ ogImage: z.union([publicUrl('ogImage', '/og-image.png'), z.literal(false)]).optional(),
57
+
58
+ theme: z
59
+ .union([
60
+ z.string().refine(isPreset, {
61
+ message: `unknown theme; available: ${PRESET_NAMES.join(', ')}`,
62
+ }),
63
+ z.object({ light: z.string(), dark: z.string() }),
64
+ ])
65
+ .default('default'),
66
+
67
+ appearance: z
68
+ .enum(['auto', 'light', 'dark'], {
69
+ error: 'unknown appearance; available: auto, light, dark',
70
+ })
71
+ .default('auto'),
72
+
73
+ /** Registers Tailwind (the site's own install) and exposes JAAD's tokens to it. */
74
+ tailwind: z.boolean().default(false),
75
+
76
+ /** Per-locale overrides for JAAD's bundled interface strings. */
77
+ ui: z.record(z.string(), z.record(z.string(), z.string())).default({}),
78
+ }
79
+
80
+ const siteConfigSchema = z.object(siteConfigShape)
81
+
82
+ export type SiteConfig = z.output<typeof siteConfigSchema>
83
+
84
+ /** The site options plus what the project could tell. Inferred fields are null
85
+ * when unavailable. */
86
+ export interface SiteResolvedConfig extends SiteConfig {
87
+ /** What jaamd gets for code blocks. */
88
+ shiki: string | { light: string; dark: string }
89
+ repoUrl: string | null
90
+ favicon: string | null
91
+ /** Explicit, inferred from public/, or false when no valid image exists. */
92
+ ogImage: string | false
93
+ /** Empty when the site is not localised. */
94
+ locales: Locale[]
95
+ /** `lang` when the site is not localised. */
96
+ defaultLocale: string
97
+ }
98
+
99
+ const FAVICONS = ['favicon.svg', 'favicon.ico', 'favicon.png']
100
+ const OG_IMAGES = ['og-image.png', 'og-image.jpg', 'og-image.jpeg', 'og-image.webp']
101
+
102
+ function findPublicAsset(cwd: string, candidates: string[]): string | null {
103
+ for (const name of candidates) {
104
+ if (existsSync(join(cwd, 'public', name))) return `/${name}`
105
+ }
106
+ return null
107
+ }
108
+
109
+ /** The fields the site derives from its options and the project. */
110
+ export function resolveSiteConfig(
111
+ config: SiteConfig,
112
+ repo: RepoInfo | null,
113
+ cwd: string,
114
+ ): Pick<SiteResolvedConfig, 'description' | 'repoUrl' | 'favicon' | 'ogImage' | 'shiki'> {
115
+ return {
116
+ description: config.description ?? inferDescription(cwd) ?? undefined,
117
+ repoUrl: repo?.url ?? null,
118
+ favicon: findPublicAsset(cwd, FAVICONS),
119
+ ogImage: config.ogImage ?? findPublicAsset(cwd, OG_IMAGES) ?? false,
120
+ shiki: typeof config.theme === 'string' ? PRESETS[config.theme].shiki : config.theme,
121
+ }
122
+ }
123
+
124
+ const requireFrom = createRequire(import.meta.url)
125
+
126
+ const styleInPackage = (name: string) => fileURLToPath(new URL(`./styles/${name}`, import.meta.url))
127
+
128
+ /** Absolute paths, kept off the resolved config, which gets serialised. */
129
+ export function resolveStylesheets(
130
+ config: SiteConfig,
131
+ cwd: string = process.cwd(),
132
+ ): {
133
+ user: string | null
134
+ theme: string | null
135
+ bridge: string
136
+ tailwind: string | null
137
+ } {
138
+ const userCss = join(cwd, 'src', 'jaad.css')
139
+ const slug = typeof config.theme === 'string' ? PRESETS[config.theme].theme : null
140
+
141
+ return {
142
+ user: existsSync(userCss) ? userCss : null,
143
+ // Resolved through JAAMD's export map rather than by path.
144
+ theme: slug ? requireFrom.resolve(`@jaamd/astro/themes/${slug}.css`) : null,
145
+ // A preset supplies the palette, so the colours travel the other way.
146
+ bridge: styleInPackage(slug ? 'jaamd-reverse.css' : 'jaamd-forward.css'),
147
+ tailwind: config.tailwind ? styleInPackage('tailwind.css') : null,
148
+ }
149
+ }
@@ -1,6 +1,6 @@
1
1
  import { execFileSync } from 'node:child_process'
2
2
  import { readFileSync } from 'node:fs'
3
- import { join } from 'node:path'
3
+ import { join, posix } from 'node:path'
4
4
 
5
5
  const git = (args: string[], cwd: string): string | null => {
6
6
  try {
@@ -52,8 +52,8 @@ export function inferRepo(cwd: string = process.cwd()): RepoInfo | null {
52
52
  /** Ends in a slash. `docsDir` resolves against the repository root, not the
53
53
  * Astro project, so a site in a subdirectory still links correctly. */
54
54
  export function editBaseFrom(repo: RepoInfo, docsDir: string): string {
55
- const dir = docsDir.replace(/^\.\//, '').replace(/\/$/, '')
56
- const path = `${repo.prefix}${dir}`.replace(/^\/+/, '')
55
+ // Normalised: a section can sit outside the project, as `../packages/x/docs`.
56
+ const path = posix.normalize(`${repo.prefix}${docsDir}`).replace(/^\/+|\/+$/g, '')
57
57
  return `${repo.url}/edit/${repo.branch}/${path}/`
58
58
  }
59
59
 
@@ -0,0 +1,59 @@
1
+ import type { SiteResolvedConfig } from './config.ts'
2
+ import type { SiteStylesheets } from './virtual.ts'
3
+ import type { AstroIntegration, AstroUserConfig } from 'astro'
4
+ import { createRequire } from 'node:module'
5
+ import { join } from 'node:path'
6
+ import { fileURLToPath } from 'node:url'
7
+ import { fonts } from './fonts.ts'
8
+ import { siteVirtualPlugin } from './virtual.ts'
9
+
10
+ type SetupParams = Parameters<NonNullable<AstroIntegration['hooks']['astro:config:setup']>>[0]
11
+ type VitePlugins = NonNullable<NonNullable<AstroUserConfig['vite']>['plugins']>
12
+
13
+ const NOT_INSTALLED = new Set(['MODULE_NOT_FOUND', 'ERR_PACKAGE_PATH_NOT_EXPORTED'])
14
+
15
+ /** The site's own `@tailwindcss/vite`, unless it already registered one. */
16
+ export function loadTailwindPlugin(root: string, registered: VitePlugins | undefined): VitePlugins {
17
+ const plugins = ([registered] as unknown[]).flat(Infinity) as ({
18
+ name?: string
19
+ } | null)[]
20
+ if (plugins.some((p) => p?.name?.startsWith('@tailwindcss/vite'))) return []
21
+
22
+ const load = createRequire(join(root, 'package.json'))
23
+ let tailwindcss: () => VitePlugins
24
+ try {
25
+ load.resolve('tailwindcss/package.json')
26
+ ;({ default: tailwindcss } = load('@tailwindcss/vite') as {
27
+ default: () => VitePlugins
28
+ })
29
+ } catch (error) {
30
+ if (!NOT_INSTALLED.has((error as { code?: string }).code ?? '')) throw error
31
+ throw new Error(
32
+ 'jaad: `tailwind: true` needs Tailwind in your project:\n npm i -D tailwindcss @tailwindcss/vite',
33
+ { cause: error },
34
+ )
35
+ }
36
+ return tailwindcss()
37
+ }
38
+
39
+ /** The virtual modules, Tailwind when asked for, and the fonts. `config` is
40
+ * what `virtual:jaad/config` serves. */
41
+ export function setupSite(
42
+ { config: astroConfig, updateConfig }: SetupParams,
43
+ config: SiteResolvedConfig,
44
+ stylesheets: SiteStylesheets,
45
+ ): void {
46
+ updateConfig({
47
+ vite: {
48
+ plugins: [
49
+ siteVirtualPlugin(config, stylesheets),
50
+ ...(config.tailwind
51
+ ? [loadTailwindPlugin(fileURLToPath(astroConfig.root), astroConfig.vite.plugins)]
52
+ : []),
53
+ ],
54
+ // Package .astro sources must reach the Astro compiler.
55
+ ssr: { noExternal: ['@jaamd/jaad'] },
56
+ },
57
+ fonts,
58
+ })
59
+ }
@@ -1,9 +1,10 @@
1
1
  ---
2
+ import type { ComponentProps } from 'astro/types'
2
3
  import config from 'virtual:jaad/config'
3
4
  import Footer from '../components/Footer.astro'
4
5
  import Head from '../components/Head.astro'
5
6
  import Header from '../components/Header.astro'
6
- import '../../dist/jaad.css'
7
+ import '../../../dist/jaad.css'
7
8
  import 'virtual:jaad/theme.css'
8
9
  import 'virtual:jaad/bridge.css'
9
10
  import 'virtual:jaad/user.css'
@@ -15,9 +16,11 @@ interface Props {
15
16
  bare?: boolean
16
17
  /** Added to the body. */
17
18
  class?: string
19
+ /** What the header offers: translations and search. */
20
+ header: ComponentProps<typeof Header>
18
21
  }
19
22
 
20
- const { title, description, bare = false, class: className } = Astro.props
23
+ const { title, description, bare = false, class: className, header } = Astro.props
21
24
 
22
25
  const pageTitle = title ? `${title} | ${config.title}` : config.title
23
26
  const pageDescription = description ?? config.description ?? config.title
@@ -29,7 +32,7 @@ const pageDescription = description ?? config.description ?? config.title
29
32
  <Head title={pageTitle} description={pageDescription} />
30
33
  </head>
31
34
  <body class:list={['jaad-body', className]}>
32
- <Header />
35
+ <Header {...header} />
33
36
  <main class={bare ? 'jaad-main-bare' : 'jaad-main'}>
34
37
  <slot />
35
38
  </main>
@@ -1,4 +1,4 @@
1
- import { ucfirst } from './utils/helpers.ts'
1
+ import { ucfirst } from './helpers.ts'
2
2
 
3
3
  /** ISO 639-1. A three-letter directory such as `api` is never a locale. */
4
4
  const LANGUAGES = new Set(
@@ -119,6 +119,14 @@ export interface Locale {
119
119
  flag: string | null
120
120
  }
121
121
 
122
+ /** Where the page a reader is on lives in one locale. */
123
+ export interface LocaleAlternate {
124
+ locale: Locale
125
+ href: string
126
+ /** False when this locale has no such page and the href is its opening one. */
127
+ translated: boolean
128
+ }
129
+
122
130
  export function isLocale(segment: string): boolean {
123
131
  const match = LOCALE_RE.exec(segment.toLowerCase())
124
132
  return match !== null && LANGUAGES.has(match[1])