@jaamd/jaad 0.11.0 → 1.0.0-rc.1

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,11 +0,0 @@
1
- @layer components {
2
- .jaad-scroll-top-button {
3
- @apply bg-background text-foreground hover:bg-background/80 border-foreground/20 pointer-events-none fixed right-6 bottom-6 z-50 flex h-12 w-12 items-center justify-center rounded-full border opacity-0 shadow-lg transition-opacity duration-300 lg:hidden;
4
- }
5
- .jaad-scroll-top-button[data-visible] {
6
- @apply pointer-events-auto opacity-100;
7
- }
8
- .jaad-scroll-top-icon {
9
- @apply h-6 w-6;
10
- }
11
- }
@@ -1,21 +0,0 @@
1
- @layer components {
2
- .docs-toc-title {
3
- @apply text-primary/60 mb-4 text-xs font-semibold tracking-wider uppercase;
4
- }
5
-
6
- .docs-toc-list {
7
- @apply space-y-2;
8
- }
9
-
10
- .docs-toc-link {
11
- @apply text-foreground/60 hover:text-primary block border-l-2 border-transparent py-1 pl-3 text-xs no-underline transition-colors;
12
- }
13
-
14
- .docs-toc-link-nested {
15
- @apply pl-6;
16
- }
17
-
18
- .docs-toc-link[aria-current='location'] {
19
- @apply border-primary;
20
- }
21
- }
@@ -1,112 +0,0 @@
1
- /* Layered, so a page's own utilities still win. */
2
- @layer components {
3
- .docs-frame {
4
- display: contents;
5
- }
6
-
7
- .docs-container {
8
- position: relative;
9
- width: 100%;
10
- min-height: calc(100vh - 6.5rem);
11
- display: flex;
12
- justify-content: center;
13
- }
14
-
15
- /* The inline margin is deliberate and survives every breakpoint: it sits
16
- outside the page padding, which only starts at 48rem. */
17
- .docs-page-main {
18
- flex: 1;
19
- margin-block: 2rem;
20
- margin-inline: 1rem;
21
- }
22
-
23
- @media (min-width: 48rem) {
24
- .docs-page-main {
25
- margin-block: 4rem;
26
- padding-inline: var(--jaad-page-padding);
27
- }
28
- }
29
-
30
- @media (min-width: 64rem) {
31
- .docs-page-main {
32
- margin-bottom: 8rem;
33
- padding-inline: calc(var(--jaad-page-padding) * 2);
34
- }
35
- }
36
-
37
- .docs-main {
38
- width: 100%;
39
- max-width: var(--jaad-content-width);
40
- position: relative;
41
- }
42
-
43
- /* The room the sidebars need, so widening them widens the gutters too. */
44
- @media (min-width: 64rem) {
45
- .docs-main-gutters {
46
- margin-inline: var(--jaad-sidebar-width);
47
- }
48
- }
49
-
50
- @media (min-width: 64rem) {
51
- [data-jaad-default-frame] .docs-sidebar-left,
52
- [data-jaad-default-frame] .docs-sidebar-right {
53
- position: fixed;
54
- top: 0;
55
- height: 100vh;
56
- padding: 6rem 1.5rem 2rem;
57
- }
58
-
59
- /* max() keeps the sidebar on screen when the centred offset would be
60
- negative, whatever the content and sidebar tokens are set to. */
61
- [data-jaad-default-frame] .docs-sidebar-left {
62
- left: max(1rem, var(--jaad-sidebar-offset));
63
- }
64
-
65
- [data-jaad-default-frame] .docs-sidebar-right {
66
- right: max(1rem, var(--jaad-sidebar-offset));
67
- }
68
- }
69
-
70
- @media (width < 64rem) {
71
- [data-jaad-default-frame] .docs-sidebar-left,
72
- [data-jaad-default-frame] .docs-sidebar-right {
73
- display: none;
74
- }
75
- }
76
-
77
- /* A mask, so the colour comes from the token and follows the theme.
78
- Override --jaad-select-arrow for a different shape. */
79
- .docs-select {
80
- position: relative;
81
- }
82
-
83
- .docs-select::after {
84
- content: '';
85
- position: absolute;
86
- top: 50%;
87
- right: 0.75rem;
88
- width: 1.25rem;
89
- height: 1.25rem;
90
- transform: translateY(-50%);
91
- pointer-events: none;
92
- background-color: var(--color-foreground);
93
- mask: var(
94
- --jaad-select-arrow,
95
- url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' stroke='%23000' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' viewBox='0 0 20 20'%3E%3Cpath d='M6 8l4 4 4-4'/%3E%3C/svg%3E")
96
- )
97
- center / contain no-repeat;
98
- }
99
-
100
- .docs-mobile-nav optgroup {
101
- font-weight: 600;
102
- color: var(--color-primary);
103
- background: var(--color-surface);
104
- }
105
-
106
- .docs-mobile-nav option {
107
- font-weight: 400;
108
- color: var(--color-foreground);
109
- background: var(--color-surface);
110
- padding: 0.5rem;
111
- }
112
- }
package/src/utils/docs.ts DELETED
@@ -1,300 +0,0 @@
1
- import type { DocsHeadings, DocsPageData, ParsedDocsCollectionId } from '../@types/docs.ts'
2
- import GithubSlugger, { slug as githubSlug } from 'github-slugger'
3
- import { ucfirst } from './helpers.ts'
4
-
5
- /** The id without its locale directory: what routing and ordering read. */
6
- export function routedId(page: DocsPageLike): string {
7
- return page.localeId ?? page.id
8
- }
9
-
10
- /** "it/02-guides/01-setup" becomes locale "it" and "02-guides/01-setup". */
11
- export function splitDocLocale(id: string, locales: string[]): { locale: string | null; id: string } {
12
- const slash = id.indexOf('/')
13
- if (slash === -1) return { locale: null, id }
14
-
15
- const first = id.slice(0, slash)
16
- if (!locales.includes(first)) return { locale: null, id }
17
- return { locale: first, id: id.slice(slash + 1) }
18
- }
19
-
20
- /** Matches a numeric order prefix, e.g. "01-" or "123-" */
21
- const NUMBERED_PREFIX_RE = /^(\d+)-(.+)$/
22
-
23
- export function parseDocCollectionId(id: string): ParsedDocsCollectionId {
24
- let order = 999
25
- let orderChapter: number | undefined
26
- let chapter: string | undefined
27
-
28
- const parts = id.split('/')
29
- let slug: string = parts[parts.length - 1]
30
-
31
- if (parts.length >= 2) {
32
- const chapterPart = parts[parts.length - 2]
33
- const chapterMatch = NUMBERED_PREFIX_RE.exec(chapterPart)
34
- if (chapterMatch) {
35
- orderChapter = parseInt(chapterMatch[1], 10)
36
- chapter = chapterMatch[2]
37
- } else {
38
- chapter = chapterPart
39
- }
40
- }
41
-
42
- const match = NUMBERED_PREFIX_RE.exec(slug)
43
- if (match) {
44
- order = parseInt(match[1], 10)
45
- slug = match[2]
46
- }
47
-
48
- return { order, orderChapter, chapter, slug, title: slugToTitle(slug) }
49
- }
50
-
51
- /** Chapter order first, then file order within it. Root pages interleave with
52
- * chapters by their own number. */
53
- export function sortDocPages<T extends DocsPageLike>(pages: T[]): T[] {
54
- return pages
55
- .map((page) => ({ page, ...docOrder(page) }))
56
- .toSorted((a, b) => a.primary - b.primary || a.secondary - b.secondary)
57
- .map((entry) => entry.page)
58
- }
59
-
60
- /** Frontmatter `order` replaces the filename prefix number. A chapter keeps
61
- * its own. */
62
- export function docOrder(page: DocsPageLike): {
63
- primary: number
64
- secondary: number
65
- } {
66
- const parsed = parseDocCollectionId(routedId(page))
67
- const order = page.data?.order ?? parsed.order
68
- return { primary: parsed.orderChapter ?? order, secondary: order }
69
- }
70
-
71
- /** "01-chapter/02-section" → "chapter/section" */
72
- export function getCleanSlug(id: string): string {
73
- return id
74
- .split('/')
75
- .map((part) => part.replace(NUMBERED_PREFIX_RE, '$2'))
76
- .join('/')
77
- }
78
-
79
- /** Match the ids produced by Astro's glob loader, independently of the OS. */
80
- export function getDocCollectionId(filePath: string): string {
81
- return filePath
82
- .replace(/\.md$/, '')
83
- .split(/[\\/]/)
84
- .map((part) => githubSlug(part))
85
- .join('/')
86
- }
87
-
88
- /** Fail before route generation when the file tree cannot produce one clear
89
- * navigation and URL for every page. */
90
- export function validateDocsStructure(pages: DocsPageLike[], locale?: string): void {
91
- const idsBySlug = new Map<string, string[]>()
92
- const directoriesByChapter = new Map<string, Set<string>>()
93
- const issues: string[] = []
94
- // Messages name the file on disk, locale directory included.
95
- const named = (id: string) => (locale ? `${locale}/${id}` : id)
96
-
97
- for (const id of pages.map(routedId).toSorted()) {
98
- const parts = id.split('/')
99
- if (parts.length > 2) {
100
- issues.push(`${named(id)}: only one chapter directory is supported inside docs/`)
101
- }
102
-
103
- const slug = getCleanSlug(id)
104
- const ids = idsBySlug.get(slug) ?? []
105
- ids.push(id)
106
- idsBySlug.set(slug, ids)
107
-
108
- if (parts.length === 2) {
109
- const directory = parts[0]
110
- const chapter = getCleanSlug(directory)
111
- const directories = directoriesByChapter.get(chapter) ?? new Set()
112
- directories.add(directory)
113
- directoriesByChapter.set(chapter, directories)
114
- }
115
- }
116
-
117
- for (const [slug, ids] of idsBySlug) {
118
- if (ids.length > 1) {
119
- issues.push(`${ids.map(named).join(', ')} all resolve to /${slug}`)
120
- }
121
- }
122
-
123
- const byKey = new Map<string, string[]>()
124
- for (const page of pages) {
125
- const key = page.data?.translationKey
126
- if (!key) continue
127
- const ids = byKey.get(key) ?? []
128
- ids.push(routedId(page))
129
- byKey.set(key, ids)
130
- }
131
- for (const [key, ids] of byKey) {
132
- if (ids.length > 1) {
133
- issues.push(`${ids.map(named).join(', ')} all claim translationKey ${key}`)
134
- }
135
- }
136
-
137
- for (const [chapter, directories] of directoriesByChapter) {
138
- if (directories.size > 1) {
139
- issues.push(`${[...directories].map(named).join(', ')} all resolve to chapter /${chapter}`)
140
- }
141
- }
142
-
143
- if (issues.length > 0) {
144
- throw new Error(`jaad: invalid documentation structure\n${issues.map((issue) => ` ${issue}`).join('\n')}`)
145
- }
146
- }
147
-
148
- /** The opening page owns routeBase; every later page keeps its named slug. */
149
- export function getCanonicalDocSlug(page: DocsPageLike, index: number): string {
150
- return index === 0 ? '' : getCleanSlug(routedId(page))
151
- }
152
-
153
- /** Decodes percent-escapes, so "detail_%26_summary" → "Detail & Summary". */
154
- export function slugToTitle(slug: string): string {
155
- return slug
156
- .split(/[-_]+/)
157
- .map((word) => ucfirst(decodeURIComponent(word)))
158
- .join(' ')
159
- }
160
-
161
- export function formatChapterTitle(chapter: string | undefined): string | null {
162
- if (!chapter) return null
163
- return slugToTitle(chapter)
164
- }
165
-
166
- /** The authoritative title: Astro's glob loader strips special characters from
167
- * filenames, so a slug-derived title is lossy. */
168
- export function extractTitleFromMarkdown(body: string): string | null {
169
- for (const line of body.split('\n')) {
170
- const match = line.match(/^#\s+(.+)$/)
171
- if (match) return match[1].trim()
172
- }
173
- return null
174
- }
175
-
176
- /** Skips fenced blocks, so headings written as code samples stay out of the TOC. */
177
- /** Opening minus closing `tag` elements on one line. */
178
- function tagBalance(line: string, tag: string): number {
179
- const count = (re: RegExp) => line.match(re)?.length ?? 0
180
- return count(new RegExp(`<${tag}[\\s>]`, 'gi')) - count(new RegExp(`</${tag}>`, 'gi'))
181
- }
182
-
183
- export function extractHeadingsFromMarkdown(markdown: string): DocsHeadings[] {
184
- const headings: DocsHeadings[] = []
185
- // Per page, like the renderer: repeated headings get -1, -2 suffixes.
186
- const slugger = new GithubSlugger()
187
- let insideFence = false
188
- // The open `data-toc-ignore` element, if any.
189
- let ignored: { tag: string; depth: number } | null = null
190
-
191
- for (const line of markdown.split('\n')) {
192
- if (line.match(/^(`{3,}|~{3,})/)) {
193
- insideFence = !insideFence
194
- continue
195
- }
196
- if (insideFence) continue
197
-
198
- if (ignored) {
199
- ignored.depth += tagBalance(line, ignored.tag)
200
- if (ignored.depth <= 0) ignored = null
201
- } else {
202
- const tag = line.match(/<([a-z][\w-]*)[^>]*\sdata-toc-ignore\b/i)?.[1]
203
- const depth = tag ? tagBalance(line, tag) : 0
204
- if (tag && depth > 0) ignored = { tag, depth }
205
- }
206
-
207
- const match = line.match(/^(#{2,3})\s+(.+)$/)
208
- if (!match) continue
209
- const depth = match[1].length as 2 | 3
210
- const text = match[2].trim()
211
- // Slugged even when skipped, so later duplicates keep the renderer's ids.
212
- const slug = slugger.slug(text)
213
- if (!ignored) headings.push({ depth, text, slug })
214
- }
215
-
216
- return headings
217
- }
218
-
219
- export interface DocsPageLike {
220
- id: string
221
- /** Set when the docs are localised; the id without its locale directory. */
222
- localeId?: string
223
- body?: string
224
- data?: Partial<DocsPageData>
225
- }
226
-
227
- /** Frontmatter title, then the first H1, then the slug. */
228
- export function docTitle(page: DocsPageLike): string {
229
- return (
230
- page.data?.title ??
231
- (page.body ? extractTitleFromMarkdown(page.body) : null) ??
232
- parseDocCollectionId(routedId(page)).title
233
- )
234
- }
235
-
236
- /** What pairs a page with its translations: the declared key, or the slug. */
237
- export function docTranslationKey(page: DocsPageLike): string {
238
- return page.data?.translationKey ?? getCleanSlug(routedId(page))
239
- }
240
-
241
- /** The name navigation uses. */
242
- export function docLabel(page: DocsPageLike): string {
243
- return page.data?.label ?? docTitle(page)
244
- }
245
-
246
- // ── Search index ────────────────────────────────────────────────────────────
247
-
248
- export interface DocsSearchEntry {
249
- title: string
250
- slug: string
251
- chapter: string | null
252
- keywords: string[]
253
- body: string
254
- }
255
-
256
- /** Strip markdown (and inline HTML) syntax to plain text for search indexing. */
257
- export function stripMarkdown(text: string): string {
258
- return text
259
- .replace(/^#{1,6}\s+/gm, '')
260
- .replace(/\*\*(.+?)\*\*/g, '$1')
261
- .replace(/\*(.+?)\*/g, '$1')
262
- .replace(/`{3,}[\s\S]*?`{3,}/g, '')
263
- .replace(/(?<!`)<[^>]+>(?!`)/g, '')
264
- .replace(/`(.+?)`/g, '$1')
265
- .replace(/!\[([^\]]*)\]\([^)]+\)/g, '')
266
- .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
267
- .replace(/^>\s?/gm, '')
268
- .replace(/^[-*+]\s+/gm, '')
269
- .replace(/^\d+\.\s+/gm, '')
270
- .replace(/^:::.*$/gm, '')
271
- .replace(/\n{3,}/g, '\n\n')
272
- .trim()
273
- }
274
-
275
- /** Excerpt for <meta description>, when the page has no explicit one. */
276
- export function extractDescription(body: string, title: string, maxLength: number = 155): string | null {
277
- const stripped = stripMarkdown(body)
278
- const paragraphs = stripped
279
- .split(/\n{2,}/)
280
- .map((p) => p.replace(/\s+/g, ' ').trim())
281
- .filter(Boolean)
282
-
283
- const firstParagraph = paragraphs.find((p) => p.toLowerCase() !== title.trim().toLowerCase())
284
- if (!firstParagraph) return null
285
-
286
- if (firstParagraph.length <= maxLength) return firstParagraph
287
- const truncated = firstParagraph.slice(0, maxLength)
288
- const lastSpace = truncated.lastIndexOf(' ')
289
- return `${truncated.slice(0, lastSpace > 0 ? lastSpace : maxLength)}…`
290
- }
291
-
292
- export function buildSearchIndex(sortedPages: DocsPageLike[]): DocsSearchEntry[] {
293
- return sortedPages.map((page, index) => ({
294
- title: docTitle(page),
295
- slug: getCanonicalDocSlug(page, index),
296
- chapter: formatChapterTitle(parseDocCollectionId(routedId(page)).chapter),
297
- keywords: page.data?.keywords ?? [],
298
- body: stripMarkdown(page.body || ''),
299
- }))
300
- }
@@ -1,80 +0,0 @@
1
- import config from 'virtual:jaad/config'
2
- import { getSortedDocsPages } from '../collection.ts'
3
- import { docsUrls } from '../docs-config.ts'
4
- import { docsMarkdownHref } from '../urls.ts'
5
- import {
6
- buildSearchIndex,
7
- docTitle,
8
- extractDescription,
9
- formatChapterTitle,
10
- getCleanSlug,
11
- parseDocCollectionId,
12
- routedId,
13
- } from './docs.ts'
14
-
15
- export async function searchIndexResponse(locale?: string): Promise<Response> {
16
- const index = buildSearchIndex(await getSortedDocsPages(locale))
17
-
18
- return new Response(JSON.stringify(index), {
19
- headers: { 'Content-Type': 'application/json; charset=utf-8' },
20
- })
21
- }
22
-
23
- interface Row {
24
- title: string
25
- slug: string
26
- description: string | null
27
- }
28
-
29
- /** Plain-text index of every page, per the llms.txt convention. */
30
- export async function llmsResponse(site: URL | undefined, locale?: string): Promise<Response> {
31
- const sortedPages = await getSortedDocsPages(locale)
32
-
33
- const markdownHref = (slug: string): string => {
34
- const href = docsMarkdownHref(slug, docsUrls(locale))
35
- return site ? new URL(href, site.origin).href : href
36
- }
37
-
38
- const standalone: Row[] = []
39
- const chapters = new Map<string, Row[]>()
40
-
41
- for (const page of sortedPages) {
42
- const id = routedId(page)
43
- const { chapter } = parseDocCollectionId(id)
44
- const title = docTitle(page)
45
- const row: Row = {
46
- title,
47
- slug: getCleanSlug(id),
48
- description: page.data.description ?? extractDescription(page.body || '', title),
49
- }
50
-
51
- if (!chapter) {
52
- standalone.push(row)
53
- continue
54
- }
55
-
56
- const rows = chapters.get(chapter) ?? []
57
- if (rows.length === 0) chapters.set(chapter, rows)
58
- rows.push(row)
59
- }
60
-
61
- const toLine = (row: Row): string =>
62
- `- [${row.title}](${markdownHref(row.slug)})${row.description ? `: ${row.description}` : ''}`
63
-
64
- const sections: string[] = []
65
- if (standalone.length > 0) {
66
- sections.push(standalone.map(toLine).join('\n'))
67
- }
68
- for (const [chapter, rows] of chapters) {
69
- const heading = formatChapterTitle(chapter) ?? chapter
70
- sections.push(`## ${heading}\n\n${rows.map(toLine).join('\n')}`)
71
- }
72
-
73
- const content = [`# ${config.title}`, ...(config.description ? [`> ${config.description}`] : []), ...sections].join(
74
- '\n\n',
75
- )
76
-
77
- return new Response(content + '\n', {
78
- headers: { 'Content-Type': 'text/plain; charset=utf-8' },
79
- })
80
- }
@@ -1,83 +0,0 @@
1
- export interface SearchItem {
2
- title: string
3
- slug: string
4
- chapter: string | null
5
- keywords?: string[]
6
- body: string
7
- }
8
-
9
- const MAX_RESULTS = 10
10
- const EMPTY_QUERY_RESULTS = 8
11
-
12
- interface Folded {
13
- title: string
14
- chapter: string
15
- keywords: string
16
- body: string
17
- }
18
-
19
- /** Lowercased once per item, not once per keystroke. */
20
- const folded = new WeakMap<SearchItem, Folded>()
21
-
22
- function fold(item: SearchItem): Folded {
23
- let f = folded.get(item)
24
- if (!f) {
25
- f = {
26
- title: item.title.toLowerCase(),
27
- chapter: item.chapter?.toLowerCase() ?? '',
28
- keywords: (item.keywords ?? []).join(' ').toLowerCase(),
29
- body: item.body.toLowerCase(),
30
- }
31
- folded.set(item, f)
32
- }
33
- return f
34
- }
35
-
36
- /** Rank by where the query appears: title, chapter, keywords, then body. */
37
- export function scoreItems(index: SearchItem[], query: string): SearchItem[] {
38
- const q = query.trim().toLowerCase()
39
- if (!q) return index.slice(0, EMPTY_QUERY_RESULTS)
40
-
41
- return index
42
- .map((item) => {
43
- const f = fold(item)
44
- let score = 0
45
- if (f.title.includes(q)) score += 10
46
- if (f.title.startsWith(q)) score += 5
47
- if (f.chapter.includes(q)) score += 3
48
- if (f.keywords.includes(q)) score += 2
49
- if (f.body.includes(q)) score += 1
50
- return { item, score }
51
- })
52
- .filter((s) => s.score > 0)
53
- .toSorted((a, b) => b.score - a.score)
54
- .slice(0, MAX_RESULTS)
55
- .map((s) => s.item)
56
- }
57
-
58
- /** A window of body text around the first match, ellipsised at both ends. */
59
- export function getSnippet(body: string, query: string): string {
60
- const q = query.trim()
61
- if (!q) return ''
62
- const idx = body.toLowerCase().indexOf(q.toLowerCase())
63
- if (idx === -1) return ''
64
-
65
- const start = Math.max(0, idx - 40)
66
- const end = Math.min(body.length, idx + q.length + 80)
67
- return (start > 0 ? '...' : '') + body.slice(start, end) + (end < body.length ? '...' : '')
68
- }
69
-
70
- /** Split around every case-insensitive occurrence, marking the matches. */
71
- export function splitOnMatches(text: string, query: string): { text: string; match: boolean }[] {
72
- const q = query.trim()
73
- if (!q) return [{ text, match: false }]
74
-
75
- const escaped = q.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
76
- return text
77
- .split(new RegExp(`(${escaped})`, 'gi'))
78
- .filter((part) => part !== '')
79
- .map((part) => ({
80
- text: part,
81
- match: part.toLowerCase() === q.toLowerCase(),
82
- }))
83
- }
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes