@jaamd/jaad 0.0.0-stage → 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.
- package/LICENSE +21 -0
- package/README.md +58 -2
- package/dist/jaad.css +1331 -0
- package/docs/00-overview.md +25 -0
- package/docs/01-getting-started/01-installation.md +216 -0
- package/docs/01-getting-started/02-pages.md +109 -0
- package/docs/01-getting-started/03-deployment.md +100 -0
- package/docs/02-configurations/01-reference.md +215 -0
- package/docs/02-configurations/02-frontmatter.md +105 -0
- package/docs/02-configurations/03-i18n.md +210 -0
- package/docs/02-configurations/04-search.md +58 -0
- package/docs/02-configurations/05-themes.md +104 -0
- package/docs/02-configurations/06-styles.md +156 -0
- package/docs/02-configurations/07-custom-pages.md +119 -0
- package/docs/02-configurations/08-advanced-layout.md +139 -0
- package/docs/03-upgrading.md +208 -0
- package/index.ts +31 -0
- package/package.json +71 -3
- package/site.ts +36 -0
- package/src/config.ts +85 -0
- package/src/docs/advanced.ts +23 -0
- package/src/docs/collection.ts +109 -0
- package/src/docs/components/Breadcrumbs.astro +29 -0
- package/src/docs/components/DocsMobileNav.astro +90 -0
- package/src/docs/components/DocsNavTree.astro +57 -0
- package/src/docs/components/DocsPage.astro +66 -0
- package/src/docs/components/DocsPageNav.astro +53 -0
- package/src/docs/components/DocsSidebarLeft.astro +53 -0
- package/src/docs/components/PageActions.astro +83 -0
- package/src/docs/components/StructuredData.astro +55 -0
- package/src/docs/components/TableOfContents.astro +71 -0
- package/src/docs/config.ts +167 -0
- package/src/docs/content.ts +36 -0
- package/src/docs/endpoints.ts +89 -0
- package/src/docs/integration.ts +249 -0
- package/src/docs/layouts/DefaultDocsFrame.astro +33 -0
- package/src/docs/layouts/Docs.astro +169 -0
- package/src/docs/layouts/Page.astro +18 -0
- package/src/docs/link-plugins.ts +52 -0
- package/src/docs/links.ts +42 -0
- package/src/docs/locale-tree.ts +51 -0
- package/src/docs/model/ids.ts +87 -0
- package/src/docs/model/rendered.ts +185 -0
- package/src/docs/model/tree.ts +124 -0
- package/src/docs/model/validate.ts +75 -0
- package/src/docs/navigation.ts +75 -0
- package/src/docs/routes/404.astro +16 -0
- package/src/docs/routes/docs-index.astro +19 -0
- package/src/docs/routes/docs-slug.astro +54 -0
- package/src/docs/routes/docs-slug.md.ts +35 -0
- package/src/docs/routes/llms-locale.txt.ts +8 -0
- package/src/docs/routes/llms.txt.ts +4 -0
- package/src/docs/routes/search-index-locale.json.ts +8 -0
- package/src/docs/routes/search-index.json.ts +4 -0
- package/src/docs/runtime.ts +17 -0
- package/src/docs/search-index.ts +39 -0
- package/src/docs/styles/components/breadcrumbs.css +26 -0
- package/src/docs/styles/components/docs-mobile-nav.css +47 -0
- package/src/docs/styles/components/docs-page-nav.css +41 -0
- package/src/docs/styles/components/docs-page.css +9 -0
- package/src/docs/styles/components/docs-sidebar.css +59 -0
- package/src/docs/styles/components/page-actions.css +51 -0
- package/src/docs/styles/components/toc.css +38 -0
- package/src/docs/styles/components.css +7 -0
- package/src/docs/styles/docs.css +81 -0
- package/src/docs/styles/jaamd.css +11 -0
- package/src/docs/types.ts +39 -0
- package/src/docs/urls.ts +35 -0
- package/src/env.d.ts +13 -0
- package/src/integration.ts +36 -0
- package/src/site/components/Footer.astro +18 -0
- package/src/site/components/Head.astro +119 -0
- package/src/site/components/Header.astro +50 -0
- package/src/site/components/LocaleSwitcher.astro +111 -0
- package/src/site/components/Nav.astro +22 -0
- package/src/site/components/NavMobile.astro +105 -0
- package/src/site/components/SearchPalette.astro +409 -0
- package/src/site/components/SocialLinks.astro +27 -0
- package/src/site/components/ThemeSwitcher.astro +55 -0
- package/src/site/components/locale-switcher.ts +6 -0
- package/src/site/config.ts +149 -0
- package/src/site/fonts/OFL.txt +94 -0
- package/src/site/fonts/inter.woff2 +0 -0
- package/src/site/fonts/merriweather-italic.woff2 +0 -0
- package/src/site/fonts/merriweather.woff2 +0 -0
- package/src/site/fonts.ts +29 -0
- package/src/site/forges.ts +60 -0
- package/src/site/helpers.ts +4 -0
- package/src/site/infer.ts +75 -0
- package/src/site/integration.ts +59 -0
- package/src/site/layouts/Shell.astro +41 -0
- package/src/site/locales.ts +199 -0
- package/src/site/runtime.ts +19 -0
- package/src/site/search.ts +227 -0
- package/src/site/social.ts +44 -0
- package/src/site/styles/components/footer.css +8 -0
- package/src/site/styles/components/header.css +31 -0
- package/src/site/styles/components/locale-switcher.css +20 -0
- package/src/site/styles/components/nav-mobile.css +15 -0
- package/src/site/styles/components/nav.css +11 -0
- package/src/site/styles/components/not-found.css +11 -0
- package/src/site/styles/components/search.css +118 -0
- package/src/site/styles/components/shell.css +77 -0
- package/src/site/styles/components/social-links.css +8 -0
- package/src/site/styles/components/theme-switcher.css +14 -0
- package/src/site/styles/components.css +10 -0
- package/src/site/styles/global.css +140 -0
- package/src/site/styles/jaamd-forward.css +16 -0
- package/src/site/styles/jaamd-reverse.css +18 -0
- package/src/site/styles/tailwind.css +30 -0
- package/src/site/themes.ts +26 -0
- package/src/site/ui/en.ts +27 -0
- package/src/site/ui/it.ts +28 -0
- package/src/site/ui.ts +49 -0
- package/src/site/urls.ts +14 -0
- package/src/site/virtual.ts +42 -0
- package/src/styles.css +5 -0
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { ucfirst } from './helpers.ts'
|
|
2
|
+
|
|
3
|
+
/** ISO 639-1. A three-letter directory such as `api` is never a locale. */
|
|
4
|
+
const LANGUAGES = new Set(
|
|
5
|
+
(
|
|
6
|
+
'aa ab ae af ak am an ar as av ay az ba be bg bi bm bn bo br bs ca ce ch co ' +
|
|
7
|
+
'cr cs cu cv cy da de dv dz ee el en eo es et eu fa ff fi fj fo fr fy ga gd ' +
|
|
8
|
+
'gl gn gu gv ha he hi ho hr ht hu hy hz ia id ie ig ii ik io is it iu ja jv ' +
|
|
9
|
+
'ka kg ki kj kk kl km kn ko kr ks ku kv kw ky la lb lg li ln lo lt lu lv mg ' +
|
|
10
|
+
'mh mi mk ml mn mr ms mt my na nb nd ne ng nl nn no nr nv ny oc oj om or os ' +
|
|
11
|
+
'pa pi pl ps pt qu rm rn ro ru rw sa sc sd se sg si sk sl sm sn so sq sr ss ' +
|
|
12
|
+
'st su sv sw ta te tg th ti tk tl tn to tr ts tt tw ty ug uk ur uz ve vi vo ' +
|
|
13
|
+
'wa wo xh yi yo za zh zu'
|
|
14
|
+
).split(' '),
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
/** Region for a language that does not name one. Listed, never derived. */
|
|
18
|
+
const LANGUAGE_REGION: Record<string, string> = {
|
|
19
|
+
af: 'ZA',
|
|
20
|
+
am: 'ET',
|
|
21
|
+
ar: 'SA',
|
|
22
|
+
az: 'AZ',
|
|
23
|
+
be: 'BY',
|
|
24
|
+
bg: 'BG',
|
|
25
|
+
bn: 'BD',
|
|
26
|
+
bs: 'BA',
|
|
27
|
+
ca: 'ES',
|
|
28
|
+
cs: 'CZ',
|
|
29
|
+
cy: 'GB',
|
|
30
|
+
da: 'DK',
|
|
31
|
+
de: 'DE',
|
|
32
|
+
el: 'GR',
|
|
33
|
+
en: 'GB',
|
|
34
|
+
es: 'ES',
|
|
35
|
+
et: 'EE',
|
|
36
|
+
eu: 'ES',
|
|
37
|
+
fa: 'IR',
|
|
38
|
+
fi: 'FI',
|
|
39
|
+
fr: 'FR',
|
|
40
|
+
ga: 'IE',
|
|
41
|
+
gd: 'GB',
|
|
42
|
+
gl: 'ES',
|
|
43
|
+
gu: 'IN',
|
|
44
|
+
ha: 'NG',
|
|
45
|
+
he: 'IL',
|
|
46
|
+
hi: 'IN',
|
|
47
|
+
hr: 'HR',
|
|
48
|
+
hu: 'HU',
|
|
49
|
+
hy: 'AM',
|
|
50
|
+
id: 'ID',
|
|
51
|
+
ig: 'NG',
|
|
52
|
+
is: 'IS',
|
|
53
|
+
it: 'IT',
|
|
54
|
+
ja: 'JP',
|
|
55
|
+
jv: 'ID',
|
|
56
|
+
ka: 'GE',
|
|
57
|
+
kk: 'KZ',
|
|
58
|
+
km: 'KH',
|
|
59
|
+
kn: 'IN',
|
|
60
|
+
ko: 'KR',
|
|
61
|
+
ky: 'KG',
|
|
62
|
+
lo: 'LA',
|
|
63
|
+
lt: 'LT',
|
|
64
|
+
lv: 'LV',
|
|
65
|
+
mk: 'MK',
|
|
66
|
+
ml: 'IN',
|
|
67
|
+
mn: 'MN',
|
|
68
|
+
mr: 'IN',
|
|
69
|
+
ms: 'MY',
|
|
70
|
+
mt: 'MT',
|
|
71
|
+
my: 'MM',
|
|
72
|
+
nb: 'NO',
|
|
73
|
+
ne: 'NP',
|
|
74
|
+
nl: 'NL',
|
|
75
|
+
nn: 'NO',
|
|
76
|
+
no: 'NO',
|
|
77
|
+
pa: 'IN',
|
|
78
|
+
pl: 'PL',
|
|
79
|
+
ps: 'AF',
|
|
80
|
+
pt: 'PT',
|
|
81
|
+
ro: 'RO',
|
|
82
|
+
ru: 'RU',
|
|
83
|
+
si: 'LK',
|
|
84
|
+
sk: 'SK',
|
|
85
|
+
sl: 'SI',
|
|
86
|
+
so: 'SO',
|
|
87
|
+
sq: 'AL',
|
|
88
|
+
sr: 'RS',
|
|
89
|
+
su: 'ID',
|
|
90
|
+
sv: 'SE',
|
|
91
|
+
sw: 'TZ',
|
|
92
|
+
ta: 'IN',
|
|
93
|
+
te: 'IN',
|
|
94
|
+
tg: 'TJ',
|
|
95
|
+
th: 'TH',
|
|
96
|
+
tk: 'TM',
|
|
97
|
+
tl: 'PH',
|
|
98
|
+
tr: 'TR',
|
|
99
|
+
uk: 'UA',
|
|
100
|
+
ur: 'PK',
|
|
101
|
+
uz: 'UZ',
|
|
102
|
+
vi: 'VN',
|
|
103
|
+
yo: 'NG',
|
|
104
|
+
zh: 'CN',
|
|
105
|
+
zu: 'ZA',
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** language[-Script][-REGION], lowercased: ids arrive slugged from the loader. */
|
|
109
|
+
const LOCALE_RE = /^([a-z]{2})(?:-([a-z]{4}))?(?:-([a-z]{2}))?$/
|
|
110
|
+
|
|
111
|
+
export interface Locale {
|
|
112
|
+
/** Lowercased directory name, and the URL segment. */
|
|
113
|
+
code: string
|
|
114
|
+
/** BCP-47, for `lang` and `hreflang`. */
|
|
115
|
+
tag: string
|
|
116
|
+
/** Native name, for the switcher menu. */
|
|
117
|
+
name: string
|
|
118
|
+
/** Emoji, or null for a language with no listed region. */
|
|
119
|
+
flag: string | null
|
|
120
|
+
}
|
|
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
|
+
|
|
130
|
+
export function isLocale(segment: string): boolean {
|
|
131
|
+
const match = LOCALE_RE.exec(segment.toLowerCase())
|
|
132
|
+
return match !== null && LANGUAGES.has(match[1])
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** "pt-br" becomes "pt-BR", "zh-hans" becomes "zh-Hans". */
|
|
136
|
+
export function localeTag(code: string): string {
|
|
137
|
+
const match = LOCALE_RE.exec(code.toLowerCase())
|
|
138
|
+
if (!match) return code
|
|
139
|
+
const [, language, script, region] = match
|
|
140
|
+
return [language, script && ucfirst(script), region && region.toUpperCase()].filter(Boolean).join('-')
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function localeName(tag: string): string {
|
|
144
|
+
try {
|
|
145
|
+
const names = new Intl.DisplayNames([tag], { type: 'language' })
|
|
146
|
+
return ucfirst(names.of(tag) ?? tag)
|
|
147
|
+
} catch {
|
|
148
|
+
return tag
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Regional indicators, A at U+1F1E6. */
|
|
153
|
+
function localeFlag(code: string): string | null {
|
|
154
|
+
const match = LOCALE_RE.exec(code.toLowerCase())
|
|
155
|
+
if (!match) return null
|
|
156
|
+
// A group that did not participate is undefined, whatever the type says.
|
|
157
|
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
|
158
|
+
const region = match[3]?.toUpperCase() ?? LANGUAGE_REGION[match[1]]
|
|
159
|
+
if (!region) return null
|
|
160
|
+
return String.fromCodePoint(...Array.from(region, (letter) => 0x1f1e6 + letter.charCodeAt(0) - 65))
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Open Graph wants language_TERRITORY, not a BCP-47 tag. The territory comes
|
|
164
|
+
* from the code when it names one, and from LANGUAGE_REGION otherwise. */
|
|
165
|
+
export function openGraphLocale(code: string): string {
|
|
166
|
+
const match = LOCALE_RE.exec(code.toLowerCase())
|
|
167
|
+
if (!match) return code
|
|
168
|
+
|
|
169
|
+
const language = match[1]
|
|
170
|
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
|
171
|
+
const region = match[3]?.toUpperCase() ?? LANGUAGE_REGION[language]
|
|
172
|
+
return region ? `${language}_${region}` : language
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function describeLocale(code: string): Locale {
|
|
176
|
+
const tag = localeTag(code)
|
|
177
|
+
return { code, tag, name: localeName(tag), flag: localeFlag(code) }
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Empty for the locale that owns the unprefixed urls. */
|
|
181
|
+
export function localeSegment(locale?: string, defaultLocale?: string): string {
|
|
182
|
+
return !locale || locale === defaultLocale ? '' : locale
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** The segment a generated slug carries, with its separator. */
|
|
186
|
+
export function localePrefix(locale?: string, defaultLocale?: string): string {
|
|
187
|
+
const segment = localeSegment(locale, defaultLocale)
|
|
188
|
+
return segment ? `${segment}/` : ''
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** What to iterate when building: every locale, or one unlocalised pass. */
|
|
192
|
+
export function routedLocales(locales: Locale[]): (string | undefined)[] {
|
|
193
|
+
return locales.length > 0 ? locales.map((l) => l.code) : [undefined]
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** The locale codes whose urls carry a prefix. */
|
|
197
|
+
export function prefixedLocales(locales: Locale[], defaultLocale: string): string[] {
|
|
198
|
+
return locales.map((locale) => locale.code).filter((code) => localeSegment(code, defaultLocale) !== '')
|
|
199
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { Locale } from './locales.ts'
|
|
2
|
+
import config from 'virtual:jaad/config'
|
|
3
|
+
import * as locales from './locales.ts'
|
|
4
|
+
import { uiString, type UiKey } from './ui.ts'
|
|
5
|
+
|
|
6
|
+
/** The virtual config, read once. */
|
|
7
|
+
export const LOCALES: Locale[] = config.locales
|
|
8
|
+
export const DEFAULT_LOCALE: string = config.defaultLocale
|
|
9
|
+
|
|
10
|
+
export const localePrefix = (locale?: string) => locales.localePrefix(locale, DEFAULT_LOCALE)
|
|
11
|
+
|
|
12
|
+
export const buildLocales = () => locales.routedLocales(LOCALES)
|
|
13
|
+
|
|
14
|
+
export const prefixedCodes = () => locales.prefixedLocales(LOCALES, DEFAULT_LOCALE)
|
|
15
|
+
|
|
16
|
+
/** The interface strings for a page, already resolved. */
|
|
17
|
+
export function ui(locale: string = DEFAULT_LOCALE) {
|
|
18
|
+
return (key: UiKey) => uiString(config.ui, locale, key)
|
|
19
|
+
}
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
/** The search index of one locale, as `search-index.json` serves it. */
|
|
2
|
+
export interface SearchIndex {
|
|
3
|
+
/** Empty when the docs are not split into sections. */
|
|
4
|
+
sections: { key: string; label: string }[]
|
|
5
|
+
pages: SearchPage[]
|
|
6
|
+
entries: SearchEntry[]
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface SearchPage {
|
|
10
|
+
title: string
|
|
11
|
+
href: string
|
|
12
|
+
section?: string
|
|
13
|
+
/** Display title of the chapter. */
|
|
14
|
+
chapter?: string
|
|
15
|
+
keywords?: string[]
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** The text under one heading, or before the first one. */
|
|
19
|
+
export interface SearchEntry {
|
|
20
|
+
/** Position in `pages`. */
|
|
21
|
+
page: number
|
|
22
|
+
heading?: string
|
|
23
|
+
anchor?: string
|
|
24
|
+
text: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface SearchHit {
|
|
28
|
+
entry: SearchEntry
|
|
29
|
+
href: string
|
|
30
|
+
score: number
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** A page and the headings inside it that matched. */
|
|
34
|
+
export interface SearchGroup {
|
|
35
|
+
page: SearchPage
|
|
36
|
+
score: number
|
|
37
|
+
/** Set when the text before the first heading matched. */
|
|
38
|
+
intro?: SearchEntry
|
|
39
|
+
hits: SearchHit[]
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const MAX_GROUPS = 8
|
|
43
|
+
const MAX_HITS = 3
|
|
44
|
+
const PAGES_PER_SECTION = 4
|
|
45
|
+
|
|
46
|
+
/** Page fields count fully on the text before the first heading, and as
|
|
47
|
+
* context everywhere else. */
|
|
48
|
+
const WEIGHTS = {
|
|
49
|
+
title: { intro: 8, heading: 2 },
|
|
50
|
+
keywords: { intro: 4, heading: 1 },
|
|
51
|
+
chapter: { intro: 2, heading: 1 },
|
|
52
|
+
heading: 6,
|
|
53
|
+
text: 1,
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const WORD = /[\p{L}\p{M}\p{N}]+/gu
|
|
57
|
+
|
|
58
|
+
/** Lowercase, without accents. */
|
|
59
|
+
export function fold(text: string): string {
|
|
60
|
+
return text.normalize('NFD').replace(/\p{M}/gu, '').toLowerCase()
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The words of a query, folded. */
|
|
64
|
+
export function queryTerms(query: string): string[] {
|
|
65
|
+
return fold(query).match(WORD) ?? []
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const words = (text: string | undefined) => (text ? (fold(text).match(WORD) ?? []) : [])
|
|
69
|
+
|
|
70
|
+
interface Prepared {
|
|
71
|
+
title: string[]
|
|
72
|
+
keywords: string[]
|
|
73
|
+
chapter: string[]
|
|
74
|
+
heading: string[]
|
|
75
|
+
text: string[]
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** The folded words of each entry, computed on first use. */
|
|
79
|
+
const prepared = new WeakMap<SearchEntry, Prepared>()
|
|
80
|
+
|
|
81
|
+
function prepare(entry: SearchEntry, page: SearchPage): Prepared {
|
|
82
|
+
let found = prepared.get(entry)
|
|
83
|
+
if (!found) {
|
|
84
|
+
found = {
|
|
85
|
+
title: words(page.title),
|
|
86
|
+
keywords: words(page.keywords?.join(' ')),
|
|
87
|
+
chapter: words(page.chapter),
|
|
88
|
+
heading: words(entry.heading),
|
|
89
|
+
text: words(entry.text),
|
|
90
|
+
}
|
|
91
|
+
prepared.set(entry, found)
|
|
92
|
+
}
|
|
93
|
+
return found
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** 2 for a whole word, 1 for a word it starts, 0 for none. */
|
|
97
|
+
function match(fieldWords: string[], term: string): number {
|
|
98
|
+
let best = 0
|
|
99
|
+
for (const word of fieldWords) {
|
|
100
|
+
if (word === term) return 2
|
|
101
|
+
if (best === 0 && word.startsWith(term)) best = 1
|
|
102
|
+
}
|
|
103
|
+
return best
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** The score of an entry, null unless every term is in it or in its page.
|
|
107
|
+
* `own` tells whether the heading or its text matched, not just the page. */
|
|
108
|
+
function scoreEntry(fields: Prepared, terms: string[], intro: boolean): { score: number; own: boolean } | null {
|
|
109
|
+
const place = intro ? 'intro' : 'heading'
|
|
110
|
+
let score = 0
|
|
111
|
+
let own = false
|
|
112
|
+
|
|
113
|
+
for (const term of terms) {
|
|
114
|
+
const candidates: [number, number, boolean][] = [
|
|
115
|
+
[match(fields.title, term), WEIGHTS.title[place], false],
|
|
116
|
+
[match(fields.keywords, term), WEIGHTS.keywords[place], false],
|
|
117
|
+
[match(fields.chapter, term), WEIGHTS.chapter[place], false],
|
|
118
|
+
[match(fields.heading, term), WEIGHTS.heading, true],
|
|
119
|
+
[match(fields.text, term), WEIGHTS.text, true],
|
|
120
|
+
]
|
|
121
|
+
let best = 0
|
|
122
|
+
for (const [quality, weight, isOwn] of candidates) {
|
|
123
|
+
if (quality === 0) continue
|
|
124
|
+
best = Math.max(best, quality * weight)
|
|
125
|
+
if (isOwn) own = true
|
|
126
|
+
}
|
|
127
|
+
if (best === 0) return null
|
|
128
|
+
score += best
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return { score, own }
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const entryHref = (page: SearchPage, entry: SearchEntry) => (entry.anchor ? `${page.href}#${entry.anchor}` : page.href)
|
|
135
|
+
|
|
136
|
+
/** Pages ranked by their best entry, each with the headings that matched.
|
|
137
|
+
* Every word must appear; a word also matches the words it starts. */
|
|
138
|
+
export function search(index: SearchIndex, query: string, section?: string): SearchGroup[] {
|
|
139
|
+
const terms = queryTerms(query)
|
|
140
|
+
if (terms.length === 0) return []
|
|
141
|
+
|
|
142
|
+
const groups = new Map<number, SearchGroup>()
|
|
143
|
+
for (const entry of index.entries) {
|
|
144
|
+
const page = index.pages[entry.page]
|
|
145
|
+
if (section && page.section !== section) continue
|
|
146
|
+
|
|
147
|
+
const intro = entry.heading === undefined
|
|
148
|
+
const scored = scoreEntry(prepare(entry, page), terms, intro)
|
|
149
|
+
if (!scored) continue
|
|
150
|
+
|
|
151
|
+
let group = groups.get(entry.page)
|
|
152
|
+
if (!group) {
|
|
153
|
+
group = { page, score: 0, hits: [] }
|
|
154
|
+
groups.set(entry.page, group)
|
|
155
|
+
}
|
|
156
|
+
group.score = Math.max(group.score, scored.score)
|
|
157
|
+
if (!scored.own) continue
|
|
158
|
+
if (intro) group.intro = entry
|
|
159
|
+
else group.hits.push({ entry, href: entryHref(page, entry), score: scored.score })
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return [...groups.values()]
|
|
163
|
+
.toSorted((a, b) => b.score - a.score)
|
|
164
|
+
.slice(0, MAX_GROUPS)
|
|
165
|
+
.map((group) => ({ ...group, hits: group.hits.toSorted((a, b) => b.score - a.score).slice(0, MAX_HITS) }))
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** What an empty query shows: the pages outside the sections, then the first
|
|
169
|
+
* pages of each section; without sections, the first pages of the docs. */
|
|
170
|
+
export function openingPages(index: SearchIndex, section?: string): { label?: string; pages: SearchPage[] }[] {
|
|
171
|
+
if (index.sections.length === 0) return [{ pages: index.pages.slice(0, MAX_GROUPS) }]
|
|
172
|
+
|
|
173
|
+
const loose = section ? [] : index.pages.filter((page) => !page.section).slice(0, PAGES_PER_SECTION)
|
|
174
|
+
return [
|
|
175
|
+
{ pages: loose },
|
|
176
|
+
...index.sections
|
|
177
|
+
.filter(({ key }) => !section || key === section)
|
|
178
|
+
.map(({ key, label }) => ({
|
|
179
|
+
label,
|
|
180
|
+
pages: index.pages.filter((page) => page.section === key).slice(0, PAGES_PER_SECTION),
|
|
181
|
+
})),
|
|
182
|
+
].filter((group) => group.pages.length > 0)
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** The text around the first matching word, or its start when none matches,
|
|
186
|
+
* ellipsised where cut. */
|
|
187
|
+
export function getSnippet(text: string, terms: string[], before = 40, after = 100): string {
|
|
188
|
+
const parts = markWords(text, terms)
|
|
189
|
+
const first = parts.findIndex((part) => part.match)
|
|
190
|
+
const start = parts.slice(0, Math.max(first, 0)).reduce((length, part) => length + part.text.length, 0)
|
|
191
|
+
const from = Math.max(0, start - before)
|
|
192
|
+
const to = Math.min(text.length, start + after)
|
|
193
|
+
return (from > 0 ? '…' : '') + text.slice(from, to).trim() + (to < text.length ? '…' : '')
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** How many characters of `word` fold into the first `length` folded ones,
|
|
197
|
+
* accents on the last of them included. */
|
|
198
|
+
function foldedPrefix(word: string, length: number): number {
|
|
199
|
+
let folded = 0
|
|
200
|
+
let at = 0
|
|
201
|
+
for (const char of word) {
|
|
202
|
+
const size = fold(char).length
|
|
203
|
+
if (folded >= length && size > 0) break
|
|
204
|
+
folded += size
|
|
205
|
+
at += char.length
|
|
206
|
+
}
|
|
207
|
+
return at
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Splits text around the words the terms start, marking the matched starts.
|
|
211
|
+
* Case and accents are ignored. */
|
|
212
|
+
export function markWords(text: string, terms: string[]): { text: string; match: boolean }[] {
|
|
213
|
+
const parts: { text: string; match: boolean }[] = []
|
|
214
|
+
let last = 0
|
|
215
|
+
for (const word of text.matchAll(WORD)) {
|
|
216
|
+
const folded = fold(word[0])
|
|
217
|
+
const term = terms.find((candidate) => folded.startsWith(candidate))
|
|
218
|
+
if (!term) continue
|
|
219
|
+
|
|
220
|
+
const end = word.index + foldedPrefix(word[0], term.length)
|
|
221
|
+
if (word.index > last) parts.push({ text: text.slice(last, word.index), match: false })
|
|
222
|
+
parts.push({ text: text.slice(word.index, end), match: true })
|
|
223
|
+
last = end
|
|
224
|
+
}
|
|
225
|
+
if (last < text.length) parts.push({ text: text.slice(last), match: false })
|
|
226
|
+
return parts
|
|
227
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { SiteResolvedConfig } from './config.ts'
|
|
2
|
+
import { FORGES, forgeForUrl } from './forges.ts'
|
|
3
|
+
|
|
4
|
+
export interface SocialLink {
|
|
5
|
+
href: string
|
|
6
|
+
label: string
|
|
7
|
+
/** Bundled forge icon. */
|
|
8
|
+
path?: string
|
|
9
|
+
/** User-supplied markup, which wins over the bundled icon. */
|
|
10
|
+
svg?: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** The configured links, plus the inferred repository unless already listed. */
|
|
14
|
+
export function buildSocialLinks(config: SiteResolvedConfig): SocialLink[] {
|
|
15
|
+
const links: SocialLink[] = []
|
|
16
|
+
|
|
17
|
+
for (const [key, value] of Object.entries(config.social)) {
|
|
18
|
+
const entry = typeof value === 'string' ? { href: value } : value
|
|
19
|
+
const forge = FORGES[key]
|
|
20
|
+
links.push({
|
|
21
|
+
href: entry.href,
|
|
22
|
+
label:
|
|
23
|
+
// FORGES has no index signature guarantee: a missing key is undefined.
|
|
24
|
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
|
25
|
+
('label' in entry ? entry.label : undefined) ?? forge?.label ?? key,
|
|
26
|
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
|
27
|
+
path: forge?.path,
|
|
28
|
+
svg: 'svg' in entry ? entry.svg : undefined,
|
|
29
|
+
})
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const namedAForge = Object.keys(config.social).some((key) => key in FORGES)
|
|
33
|
+
|
|
34
|
+
if (config.repoUrl && !namedAForge && !links.some((l) => l.href === config.repoUrl)) {
|
|
35
|
+
const forge = forgeForUrl(config.repoUrl)
|
|
36
|
+
links.push({
|
|
37
|
+
href: config.repoUrl,
|
|
38
|
+
label: forge ? FORGES[forge].label : 'Repository',
|
|
39
|
+
path: forge ? FORGES[forge].path : undefined,
|
|
40
|
+
})
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return links
|
|
44
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
@layer components {
|
|
2
|
+
/* Fixed to the top of the window: only the page scrolls below it. */
|
|
3
|
+
.jaad-header {
|
|
4
|
+
position: fixed;
|
|
5
|
+
inset: 0 0 auto;
|
|
6
|
+
z-index: 40;
|
|
7
|
+
height: var(--jaad-header-height);
|
|
8
|
+
background: var(--color-background);
|
|
9
|
+
}
|
|
10
|
+
.jaad-header-inner {
|
|
11
|
+
height: 100%;
|
|
12
|
+
max-width: var(--jaad-chrome-width);
|
|
13
|
+
margin-inline: auto;
|
|
14
|
+
padding-inline: 1rem;
|
|
15
|
+
}
|
|
16
|
+
.jaad-header-row {
|
|
17
|
+
@apply flex h-full items-center gap-4;
|
|
18
|
+
}
|
|
19
|
+
.jaad-brand {
|
|
20
|
+
@apply text-foreground-bright text-xl;
|
|
21
|
+
}
|
|
22
|
+
.jaad-brand-link {
|
|
23
|
+
@apply text-primary no-underline;
|
|
24
|
+
}
|
|
25
|
+
.jaad-logo {
|
|
26
|
+
@apply h-7;
|
|
27
|
+
}
|
|
28
|
+
.jaad-header-actions {
|
|
29
|
+
@apply ml-auto flex items-center gap-2;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
@layer components {
|
|
2
|
+
.jaad-locale-switcher {
|
|
3
|
+
@apply relative;
|
|
4
|
+
}
|
|
5
|
+
.jaad-locale-trigger {
|
|
6
|
+
@apply w-auto gap-1.5 px-2;
|
|
7
|
+
}
|
|
8
|
+
.jaad-locale-code {
|
|
9
|
+
@apply text-xs font-medium uppercase;
|
|
10
|
+
}
|
|
11
|
+
.jaad-locale-chevron {
|
|
12
|
+
@apply h-3 w-3 shrink-0;
|
|
13
|
+
}
|
|
14
|
+
.jaad-locale-menu {
|
|
15
|
+
@apply p-1;
|
|
16
|
+
}
|
|
17
|
+
.jaad-locale-item {
|
|
18
|
+
@apply flex items-center gap-2 aria-[current]:font-semibold;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
@layer components {
|
|
2
|
+
.jaad-nav-mobile {
|
|
3
|
+
@apply relative md:hidden;
|
|
4
|
+
}
|
|
5
|
+
.jaad-nav-mobile [aria-expanded='true'] [data-icon-open],
|
|
6
|
+
.jaad-nav-mobile [aria-expanded='false'] [data-icon-close] {
|
|
7
|
+
@apply hidden;
|
|
8
|
+
}
|
|
9
|
+
.jaad-nav-mobile-links {
|
|
10
|
+
@apply p-2;
|
|
11
|
+
}
|
|
12
|
+
.jaad-nav-mobile-list {
|
|
13
|
+
@apply flex flex-col gap-1;
|
|
14
|
+
}
|
|
15
|
+
}
|