@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.
Files changed (117) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +58 -2
  3. package/dist/jaad.css +1331 -0
  4. package/docs/00-overview.md +25 -0
  5. package/docs/01-getting-started/01-installation.md +216 -0
  6. package/docs/01-getting-started/02-pages.md +109 -0
  7. package/docs/01-getting-started/03-deployment.md +100 -0
  8. package/docs/02-configurations/01-reference.md +215 -0
  9. package/docs/02-configurations/02-frontmatter.md +105 -0
  10. package/docs/02-configurations/03-i18n.md +210 -0
  11. package/docs/02-configurations/04-search.md +58 -0
  12. package/docs/02-configurations/05-themes.md +104 -0
  13. package/docs/02-configurations/06-styles.md +156 -0
  14. package/docs/02-configurations/07-custom-pages.md +119 -0
  15. package/docs/02-configurations/08-advanced-layout.md +139 -0
  16. package/docs/03-upgrading.md +208 -0
  17. package/index.ts +31 -0
  18. package/package.json +71 -3
  19. package/site.ts +36 -0
  20. package/src/config.ts +85 -0
  21. package/src/docs/advanced.ts +23 -0
  22. package/src/docs/collection.ts +109 -0
  23. package/src/docs/components/Breadcrumbs.astro +29 -0
  24. package/src/docs/components/DocsMobileNav.astro +90 -0
  25. package/src/docs/components/DocsNavTree.astro +57 -0
  26. package/src/docs/components/DocsPage.astro +66 -0
  27. package/src/docs/components/DocsPageNav.astro +53 -0
  28. package/src/docs/components/DocsSidebarLeft.astro +53 -0
  29. package/src/docs/components/PageActions.astro +83 -0
  30. package/src/docs/components/StructuredData.astro +55 -0
  31. package/src/docs/components/TableOfContents.astro +71 -0
  32. package/src/docs/config.ts +167 -0
  33. package/src/docs/content.ts +36 -0
  34. package/src/docs/endpoints.ts +89 -0
  35. package/src/docs/integration.ts +249 -0
  36. package/src/docs/layouts/DefaultDocsFrame.astro +33 -0
  37. package/src/docs/layouts/Docs.astro +169 -0
  38. package/src/docs/layouts/Page.astro +18 -0
  39. package/src/docs/link-plugins.ts +52 -0
  40. package/src/docs/links.ts +42 -0
  41. package/src/docs/locale-tree.ts +51 -0
  42. package/src/docs/model/ids.ts +87 -0
  43. package/src/docs/model/rendered.ts +185 -0
  44. package/src/docs/model/tree.ts +124 -0
  45. package/src/docs/model/validate.ts +75 -0
  46. package/src/docs/navigation.ts +75 -0
  47. package/src/docs/routes/404.astro +16 -0
  48. package/src/docs/routes/docs-index.astro +19 -0
  49. package/src/docs/routes/docs-slug.astro +54 -0
  50. package/src/docs/routes/docs-slug.md.ts +35 -0
  51. package/src/docs/routes/llms-locale.txt.ts +8 -0
  52. package/src/docs/routes/llms.txt.ts +4 -0
  53. package/src/docs/routes/search-index-locale.json.ts +8 -0
  54. package/src/docs/routes/search-index.json.ts +4 -0
  55. package/src/docs/runtime.ts +17 -0
  56. package/src/docs/search-index.ts +39 -0
  57. package/src/docs/styles/components/breadcrumbs.css +26 -0
  58. package/src/docs/styles/components/docs-mobile-nav.css +47 -0
  59. package/src/docs/styles/components/docs-page-nav.css +41 -0
  60. package/src/docs/styles/components/docs-page.css +9 -0
  61. package/src/docs/styles/components/docs-sidebar.css +59 -0
  62. package/src/docs/styles/components/page-actions.css +51 -0
  63. package/src/docs/styles/components/toc.css +38 -0
  64. package/src/docs/styles/components.css +7 -0
  65. package/src/docs/styles/docs.css +81 -0
  66. package/src/docs/styles/jaamd.css +11 -0
  67. package/src/docs/types.ts +39 -0
  68. package/src/docs/urls.ts +35 -0
  69. package/src/env.d.ts +13 -0
  70. package/src/integration.ts +36 -0
  71. package/src/site/components/Footer.astro +18 -0
  72. package/src/site/components/Head.astro +119 -0
  73. package/src/site/components/Header.astro +50 -0
  74. package/src/site/components/LocaleSwitcher.astro +111 -0
  75. package/src/site/components/Nav.astro +22 -0
  76. package/src/site/components/NavMobile.astro +105 -0
  77. package/src/site/components/SearchPalette.astro +409 -0
  78. package/src/site/components/SocialLinks.astro +27 -0
  79. package/src/site/components/ThemeSwitcher.astro +55 -0
  80. package/src/site/components/locale-switcher.ts +6 -0
  81. package/src/site/config.ts +149 -0
  82. package/src/site/fonts/OFL.txt +94 -0
  83. package/src/site/fonts/inter.woff2 +0 -0
  84. package/src/site/fonts/merriweather-italic.woff2 +0 -0
  85. package/src/site/fonts/merriweather.woff2 +0 -0
  86. package/src/site/fonts.ts +29 -0
  87. package/src/site/forges.ts +60 -0
  88. package/src/site/helpers.ts +4 -0
  89. package/src/site/infer.ts +75 -0
  90. package/src/site/integration.ts +59 -0
  91. package/src/site/layouts/Shell.astro +41 -0
  92. package/src/site/locales.ts +199 -0
  93. package/src/site/runtime.ts +19 -0
  94. package/src/site/search.ts +227 -0
  95. package/src/site/social.ts +44 -0
  96. package/src/site/styles/components/footer.css +8 -0
  97. package/src/site/styles/components/header.css +31 -0
  98. package/src/site/styles/components/locale-switcher.css +20 -0
  99. package/src/site/styles/components/nav-mobile.css +15 -0
  100. package/src/site/styles/components/nav.css +11 -0
  101. package/src/site/styles/components/not-found.css +11 -0
  102. package/src/site/styles/components/search.css +118 -0
  103. package/src/site/styles/components/shell.css +77 -0
  104. package/src/site/styles/components/social-links.css +8 -0
  105. package/src/site/styles/components/theme-switcher.css +14 -0
  106. package/src/site/styles/components.css +10 -0
  107. package/src/site/styles/global.css +140 -0
  108. package/src/site/styles/jaamd-forward.css +16 -0
  109. package/src/site/styles/jaamd-reverse.css +18 -0
  110. package/src/site/styles/tailwind.css +30 -0
  111. package/src/site/themes.ts +26 -0
  112. package/src/site/ui/en.ts +27 -0
  113. package/src/site/ui/it.ts +28 -0
  114. package/src/site/ui.ts +49 -0
  115. package/src/site/urls.ts +14 -0
  116. package/src/site/virtual.ts +42 -0
  117. 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,8 @@
1
+ @layer components {
2
+ .jaad-footer {
3
+ @apply border-border-dark mt-4 border-t pt-2;
4
+ }
5
+ .jaad-footer-credit {
6
+ @apply text-foreground-muted mb-1 text-center text-[0.95rem] leading-[1.6] italic;
7
+ }
8
+ }
@@ -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
+ }
@@ -0,0 +1,11 @@
1
+ @layer components {
2
+ .jaad-nav {
3
+ @apply hidden md:block;
4
+ }
5
+ .jaad-nav-list {
6
+ @apply flex space-x-4;
7
+ }
8
+ .jaad-nav-link {
9
+ @apply text-foreground-secondary hover:text-foreground-bright no-underline hover:underline;
10
+ }
11
+ }
@@ -0,0 +1,11 @@
1
+ @layer components {
2
+ .jaad-not-found {
3
+ @apply m-auto flex h-full w-full flex-col items-center justify-center px-16 text-center;
4
+ }
5
+ .jaad-not-found-title {
6
+ @apply mb-16 text-6xl font-bold;
7
+ }
8
+ .jaad-not-found-text {
9
+ @apply mb-32 text-xl;
10
+ }
11
+ }