@jaamd/jaad 0.0.0-stage → 0.11.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 (89) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +52 -2
  3. package/dist/jaad.css +1446 -0
  4. package/index.ts +45 -0
  5. package/package.json +70 -3
  6. package/site.ts +19 -0
  7. package/src/@types/docs.ts +47 -0
  8. package/src/advanced.ts +21 -0
  9. package/src/collection.ts +97 -0
  10. package/src/components/Footer.astro +18 -0
  11. package/src/components/Head.astro +119 -0
  12. package/src/components/Header.astro +47 -0
  13. package/src/components/LocaleSwitcher.astro +111 -0
  14. package/src/components/Nav.astro +22 -0
  15. package/src/components/NavMobile.astro +105 -0
  16. package/src/components/ScrollToTop.astro +43 -0
  17. package/src/components/SearchPalette.astro +328 -0
  18. package/src/components/SocialLinks.astro +27 -0
  19. package/src/components/ThemeSwitcher.astro +55 -0
  20. package/src/components/docs/Breadcrumbs.astro +36 -0
  21. package/src/components/docs/DocsMobileNav.astro +93 -0
  22. package/src/components/docs/DocsPage.astro +68 -0
  23. package/src/components/docs/DocsPageNav.astro +53 -0
  24. package/src/components/docs/DocsSidebarLeft.astro +82 -0
  25. package/src/components/docs/PageActions.astro +81 -0
  26. package/src/components/docs/StructuredData.astro +55 -0
  27. package/src/components/docs/TableOfContents.astro +73 -0
  28. package/src/components/locale-switcher.ts +6 -0
  29. package/src/config.ts +236 -0
  30. package/src/content.ts +42 -0
  31. package/src/docs-config.ts +30 -0
  32. package/src/env.d.ts +13 -0
  33. package/src/fonts/OFL.txt +94 -0
  34. package/src/fonts/inter.woff2 +0 -0
  35. package/src/fonts/merriweather-italic.woff2 +0 -0
  36. package/src/fonts/merriweather.woff2 +0 -0
  37. package/src/fonts.ts +29 -0
  38. package/src/forges.ts +60 -0
  39. package/src/infer.ts +75 -0
  40. package/src/integration.ts +270 -0
  41. package/src/layouts/DefaultDocsFrame.astro +31 -0
  42. package/src/layouts/Docs.astro +168 -0
  43. package/src/layouts/Page.astro +38 -0
  44. package/src/locale-tree.ts +51 -0
  45. package/src/locales.ts +191 -0
  46. package/src/navigation.ts +97 -0
  47. package/src/routes/404.astro +16 -0
  48. package/src/routes/docs-index.astro +19 -0
  49. package/src/routes/docs-slug.astro +58 -0
  50. package/src/routes/docs-slug.md.ts +37 -0
  51. package/src/routes/llms-locale.txt.ts +8 -0
  52. package/src/routes/llms.txt.ts +4 -0
  53. package/src/routes/search-index-locale.json.ts +8 -0
  54. package/src/routes/search-index.json.ts +4 -0
  55. package/src/social.ts +44 -0
  56. package/src/styles/components/breadcrumbs.css +13 -0
  57. package/src/styles/components/docs-mobile-nav.css +9 -0
  58. package/src/styles/components/docs-page-nav.css +31 -0
  59. package/src/styles/components/docs-page.css +9 -0
  60. package/src/styles/components/docs-sidebar.css +42 -0
  61. package/src/styles/components/footer.css +8 -0
  62. package/src/styles/components/header.css +20 -0
  63. package/src/styles/components/locale-switcher.css +20 -0
  64. package/src/styles/components/nav-mobile.css +15 -0
  65. package/src/styles/components/nav.css +11 -0
  66. package/src/styles/components/not-found.css +11 -0
  67. package/src/styles/components/page-actions.css +13 -0
  68. package/src/styles/components/scroll-to-top.css +11 -0
  69. package/src/styles/components/search.css +98 -0
  70. package/src/styles/components/shell.css +73 -0
  71. package/src/styles/components/social-links.css +8 -0
  72. package/src/styles/components/theme-switcher.css +14 -0
  73. package/src/styles/components/toc.css +21 -0
  74. package/src/styles/docs.css +112 -0
  75. package/src/styles/global.css +162 -0
  76. package/src/styles/jaamd-forward.css +16 -0
  77. package/src/styles/jaamd-reverse.css +18 -0
  78. package/src/styles/jaamd.css +11 -0
  79. package/src/styles/tailwind.css +30 -0
  80. package/src/themes/index.ts +26 -0
  81. package/src/ui/en.ts +29 -0
  82. package/src/ui/it.ts +30 -0
  83. package/src/ui.ts +49 -0
  84. package/src/urls.ts +49 -0
  85. package/src/utils/docs.ts +300 -0
  86. package/src/utils/endpoints.ts +80 -0
  87. package/src/utils/helpers.ts +4 -0
  88. package/src/utils/search.ts +83 -0
  89. package/src/virtual.ts +48 -0
@@ -0,0 +1,168 @@
1
+ ---
2
+ import type { DocsEntry, DocsHeadings } from '../@types/docs.ts'
3
+ import type { DocsFrameProps } from '../advanced.ts'
4
+ import type { DocsNavigation } from '../navigation.ts'
5
+ import { ClientRouter } from 'astro:transitions'
6
+ import config from 'virtual:jaad/config'
7
+ import DocsFrame from 'virtual:jaad/docs-frame'
8
+ import { getLocaleAlternates } from '../collection.ts'
9
+ import DocsSidebarLeft from '../components/docs/DocsSidebarLeft.astro'
10
+ import StructuredData from '../components/docs/StructuredData.astro'
11
+ import TableOfContents from '../components/docs/TableOfContents.astro'
12
+ import Footer from '../components/Footer.astro'
13
+ import Head from '../components/Head.astro'
14
+ import Header from '../components/Header.astro'
15
+ import { DEFAULT_LOCALE, LOCALES } from '../docs-config.ts'
16
+ import { docTitle, docTranslationKey, extractDescription, getCleanSlug, routedId } from '../utils/docs.ts'
17
+ import '../../dist/jaad.css'
18
+ import '../styles/docs.css'
19
+ import '../styles/jaamd.css'
20
+ import '@jaamd/jaamd/styles.css'
21
+ import 'virtual:jaad/theme.css'
22
+ import 'virtual:jaad/bridge.css'
23
+ import 'virtual:jaad/user.css'
24
+ import 'virtual:jaad/tailwind.css'
25
+
26
+ interface Props {
27
+ title?: string
28
+ chapter?: string
29
+ entry?: DocsEntry
30
+ headings?: DocsHeadings[]
31
+ navigation: DocsNavigation
32
+ }
33
+
34
+ const { title, chapter, entry, headings = [], navigation } = Astro.props
35
+
36
+ const pageTitle = title ? `${title} | ${config.title}` : config.title
37
+
38
+ // The opening page gets no `title`; resolve it from the entry.
39
+ const entryTitle = entry ? docTitle(entry) : (title ?? '')
40
+
41
+ const pageDescription =
42
+ entry?.data.description ??
43
+ (entry ? extractDescription(entry.body || '', entryTitle) : null) ??
44
+ config.description ??
45
+ config.title
46
+
47
+ const locale = entry?.locale ?? DEFAULT_LOCALE
48
+ const language = LOCALES.find((known) => known.code === locale)?.tag ?? config.lang
49
+
50
+ const switcherAlternates = await getLocaleAlternates(entry ? docTranslationKey(entry) : '')
51
+
52
+ // x-default is a landing place, so it takes the fallback when this page has no
53
+ // translation into the default locale.
54
+ const xDefault = switcherAlternates.find((alternate) => alternate.locale.code === DEFAULT_LOCALE)?.href
55
+
56
+ // hreflang describes translations of this page, not the switcher's fallbacks.
57
+ const alternates = switcherAlternates
58
+ .filter((alternate) => alternate.translated)
59
+ .map((alternate) => ({
60
+ code: alternate.locale.code,
61
+ tag: alternate.locale.tag,
62
+ href: alternate.href,
63
+ }))
64
+
65
+ const frameProps: DocsFrameProps = {
66
+ page: {
67
+ id: entry?.id ?? '',
68
+ slug: entry ? getCleanSlug(routedId(entry)) : '',
69
+ title: entryTitle,
70
+ chapter,
71
+ data: entry?.data ?? { draft: false, keywords: [] },
72
+ },
73
+ headings,
74
+ navigation,
75
+ }
76
+ ---
77
+
78
+ <!doctype html>
79
+ <html lang={language} class={config.appearance === 'dark' ? 'dark' : undefined}>
80
+ <head>
81
+ <Head
82
+ title={pageTitle}
83
+ description={pageDescription}
84
+ keywords={entry?.data.keywords}
85
+ author={entry?.data.author}
86
+ image={entry?.data.ogImage}
87
+ modified={entry?.data.lastUpdated}
88
+ language={language}
89
+ {alternates}
90
+ {xDefault}
91
+ />
92
+ <StructuredData
93
+ title={entryTitle || config.title}
94
+ description={pageDescription}
95
+ chapter={chapter}
96
+ keywords={entry?.data.keywords}
97
+ author={entry?.data.author}
98
+ modified={entry?.data.lastUpdated}
99
+ {language}
100
+ {locale}
101
+ />
102
+ <ClientRouter />
103
+ </head>
104
+ <body data-docs class="jaad-body">
105
+ <DocsFrame {...frameProps}>
106
+ <Header slot="header" inDocs {locale} alternates={switcherAlternates} />
107
+ <DocsSidebarLeft slot="sidebar" sections={navigation.sections} {locale} />
108
+ <slot name="mobile-navigation" slot="mobile-navigation" />
109
+ <slot name="breadcrumbs" slot="breadcrumbs" />
110
+ <slot name="page-actions" slot="page-actions" />
111
+ <slot name="content" slot="content" />
112
+ <slot name="page-navigation" slot="page-navigation" />
113
+ <TableOfContents slot="table-of-contents" headings={headings} {locale} />
114
+ <Footer slot="footer" />
115
+ </DocsFrame>
116
+
117
+ <script>
118
+ import { navigate } from 'astro:transitions/client'
119
+
120
+ /** Identified by `rel`. */
121
+ function getPageLinks(): {
122
+ prev: HTMLAnchorElement | null
123
+ next: HTMLAnchorElement | null
124
+ } {
125
+ const nav = document.querySelector('nav[aria-label="Page navigation"]')
126
+ if (!nav) return { prev: null, next: null }
127
+ return {
128
+ prev: nav.querySelector<HTMLAnchorElement>('a[rel~="prev"][href]'),
129
+ next: nav.querySelector<HTMLAnchorElement>('a[rel~="next"][href]'),
130
+ }
131
+ }
132
+
133
+ /** A wide table or code block: the arrow keys should scroll it instead. */
134
+ function inHorizontallyScrollable(el: Element | null): boolean {
135
+ for (let node = el; node && node !== document.body;) {
136
+ if (node instanceof HTMLElement) {
137
+ const overflowX = getComputedStyle(node).overflowX
138
+ if ((overflowX === 'auto' || overflowX === 'scroll') && node.scrollWidth > node.clientWidth)
139
+ return true
140
+ }
141
+ node = node.parentElement
142
+ }
143
+ return false
144
+ }
145
+
146
+ function onKey(e: KeyboardEvent) {
147
+ // Leave modified chords alone, notably the browser's back/forward.
148
+ if (e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) return
149
+ if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return
150
+
151
+ const active = document.activeElement as HTMLElement | null
152
+ const tag = active?.tagName
153
+ if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return
154
+ if (active?.isContentEditable) return
155
+ if (inHorizontallyScrollable(active)) return
156
+
157
+ const { prev, next } = getPageLinks()
158
+ const target = e.key === 'ArrowLeft' ? prev : next
159
+ if (!target) return
160
+ e.preventDefault()
161
+ navigate(target.href)
162
+ }
163
+
164
+ // `document` survives View Transitions, and this module runs once.
165
+ document.addEventListener('keydown', onKey)
166
+ </script>
167
+ </body>
168
+ </html>
@@ -0,0 +1,38 @@
1
+ ---
2
+ import config from 'virtual:jaad/config'
3
+ import Footer from '../components/Footer.astro'
4
+ import Head from '../components/Head.astro'
5
+ import Header from '../components/Header.astro'
6
+ import '../../dist/jaad.css'
7
+ import 'virtual:jaad/theme.css'
8
+ import 'virtual:jaad/bridge.css'
9
+ import 'virtual:jaad/user.css'
10
+ import 'virtual:jaad/tailwind.css'
11
+
12
+ interface Props {
13
+ title?: string
14
+ description?: string
15
+ bare?: boolean
16
+ /** Added to the body. */
17
+ class?: string
18
+ }
19
+
20
+ const { title, description, bare = false, class: className } = Astro.props
21
+
22
+ const pageTitle = title ? `${title} | ${config.title}` : config.title
23
+ const pageDescription = description ?? config.description ?? config.title
24
+ ---
25
+
26
+ <!doctype html>
27
+ <html lang={config.lang} class={config.appearance === 'dark' ? 'dark' : undefined}>
28
+ <head>
29
+ <Head title={pageTitle} description={pageDescription} />
30
+ </head>
31
+ <body class:list={['jaad-body', className]}>
32
+ <Header />
33
+ <main class={bare ? 'jaad-main-bare' : 'jaad-main'}>
34
+ <slot />
35
+ </main>
36
+ <Footer />
37
+ </body>
38
+ </html>
@@ -0,0 +1,51 @@
1
+ import { existsSync, readdirSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+ import { isLocale } from './locales.ts'
4
+
5
+ function hasMarkdown(dir: string): boolean {
6
+ return readdirSync(dir, { withFileTypes: true }).some((entry) =>
7
+ entry.isDirectory() ? hasMarkdown(join(dir, entry.name)) : entry.name.endsWith('.md'),
8
+ )
9
+ }
10
+
11
+ /** Locale directories under docs/, or none. Two mean the tree is organised by
12
+ * language; one counts only when `lang` names it. */
13
+ export function detectLocales(docsDir: string, lang: string): string[] {
14
+ if (!existsSync(docsDir)) return []
15
+
16
+ const candidates = readdirSync(docsDir, { withFileTypes: true })
17
+ .filter((entry) => entry.isDirectory() && isLocale(entry.name))
18
+ .map((entry) => entry.name.toLowerCase())
19
+ .toSorted()
20
+
21
+ const localised = candidates.length >= 2 || (candidates.length === 1 && candidates[0] === lang.toLowerCase())
22
+ if (!localised) return []
23
+
24
+ // An empty directory would reach the switcher and answer 404.
25
+ return candidates.filter((code) => hasMarkdown(join(docsDir, code)))
26
+ }
27
+
28
+ /** Once localised, every top-level entry is a locale and `lang` names one. */
29
+ export function validateLocaleTree(docsDir: string, locales: string[], lang: string): void {
30
+ const issues: string[] = []
31
+
32
+ const strays = readdirSync(docsDir, { withFileTypes: true })
33
+ .filter((entry) => !(entry.isDirectory() && isLocale(entry.name)))
34
+ .map((entry) => entry.name)
35
+ .toSorted()
36
+
37
+ for (const stray of strays) {
38
+ issues.push(`docs/${stray} is not a locale, but ${locales.join(', ')} are`)
39
+ }
40
+
41
+ if (!locales.includes(lang.toLowerCase())) {
42
+ issues.push(`lang is "${lang}", which has no directory; found ${locales.join(', ')}`)
43
+ }
44
+
45
+ if (issues.length > 0) {
46
+ throw new Error(
47
+ `jaad: mixed documentation tree\n${issues.map((issue) => ` ${issue}`).join('\n')}\n` +
48
+ 'Move every page under a locale directory, or set locales: false.',
49
+ )
50
+ }
51
+ }
package/src/locales.ts ADDED
@@ -0,0 +1,191 @@
1
+ import { ucfirst } from './utils/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
+ export function isLocale(segment: string): boolean {
123
+ const match = LOCALE_RE.exec(segment.toLowerCase())
124
+ return match !== null && LANGUAGES.has(match[1])
125
+ }
126
+
127
+ /** "pt-br" becomes "pt-BR", "zh-hans" becomes "zh-Hans". */
128
+ export function localeTag(code: string): string {
129
+ const match = LOCALE_RE.exec(code.toLowerCase())
130
+ if (!match) return code
131
+ const [, language, script, region] = match
132
+ return [language, script && ucfirst(script), region && region.toUpperCase()].filter(Boolean).join('-')
133
+ }
134
+
135
+ function localeName(tag: string): string {
136
+ try {
137
+ const names = new Intl.DisplayNames([tag], { type: 'language' })
138
+ return ucfirst(names.of(tag) ?? tag)
139
+ } catch {
140
+ return tag
141
+ }
142
+ }
143
+
144
+ /** Regional indicators, A at U+1F1E6. */
145
+ function localeFlag(code: string): string | null {
146
+ const match = LOCALE_RE.exec(code.toLowerCase())
147
+ if (!match) return null
148
+ // A group that did not participate is undefined, whatever the type says.
149
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
150
+ const region = match[3]?.toUpperCase() ?? LANGUAGE_REGION[match[1]]
151
+ if (!region) return null
152
+ return String.fromCodePoint(...Array.from(region, (letter) => 0x1f1e6 + letter.charCodeAt(0) - 65))
153
+ }
154
+
155
+ /** Open Graph wants language_TERRITORY, not a BCP-47 tag. The territory comes
156
+ * from the code when it names one, and from LANGUAGE_REGION otherwise. */
157
+ export function openGraphLocale(code: string): string {
158
+ const match = LOCALE_RE.exec(code.toLowerCase())
159
+ if (!match) return code
160
+
161
+ const language = match[1]
162
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
163
+ const region = match[3]?.toUpperCase() ?? LANGUAGE_REGION[language]
164
+ return region ? `${language}_${region}` : language
165
+ }
166
+
167
+ export function describeLocale(code: string): Locale {
168
+ const tag = localeTag(code)
169
+ return { code, tag, name: localeName(tag), flag: localeFlag(code) }
170
+ }
171
+
172
+ /** Empty for the locale that owns the unprefixed urls. */
173
+ export function localeSegment(locale?: string, defaultLocale?: string): string {
174
+ return !locale || locale === defaultLocale ? '' : locale
175
+ }
176
+
177
+ /** The segment a generated slug carries, with its separator. */
178
+ export function localePrefix(locale?: string, defaultLocale?: string): string {
179
+ const segment = localeSegment(locale, defaultLocale)
180
+ return segment ? `${segment}/` : ''
181
+ }
182
+
183
+ /** What to iterate when building: every locale, or one unlocalised pass. */
184
+ export function routedLocales(locales: Locale[]): (string | undefined)[] {
185
+ return locales.length > 0 ? locales.map((l) => l.code) : [undefined]
186
+ }
187
+
188
+ /** The locale codes whose urls carry a prefix. */
189
+ export function prefixedLocales(locales: Locale[], defaultLocale: string): string[] {
190
+ return locales.map((locale) => locale.code).filter((code) => localeSegment(code, defaultLocale) !== '')
191
+ }
@@ -0,0 +1,97 @@
1
+ import type { DocsNavItem } from './@types/docs.ts'
2
+ import { docsPageHref, type DocsUrlOptions } from './urls.ts'
3
+ import { docLabel, getCanonicalDocSlug, parseDocCollectionId, routedId, type DocsPageLike } from './utils/docs.ts'
4
+
5
+ export type NavSection =
6
+ | { type: 'page'; order: number; item: DocsNavItem }
7
+ | { type: 'chapter'; order: number; chapter: string; items: DocsNavItem[] }
8
+
9
+ export interface DocsNavigation {
10
+ sections: NavSection[]
11
+ previousPage: DocsNavItem | null
12
+ nextPage: DocsNavItem | null
13
+ }
14
+
15
+ function groupByChapter(items: DocsNavItem[]): NavSection[] {
16
+ const chapters = new Map<string, Extract<NavSection, { type: 'chapter' }>>()
17
+ const sections: NavSection[] = []
18
+
19
+ for (const item of items) {
20
+ if (!item.chapter) {
21
+ sections.push({ type: 'page', order: item.primaryOrder, item })
22
+ continue
23
+ }
24
+
25
+ let chapter = chapters.get(item.chapter)
26
+ if (!chapter) {
27
+ chapter = {
28
+ type: 'chapter',
29
+ order: item.primaryOrder,
30
+ chapter: item.chapter,
31
+ items: [],
32
+ }
33
+ chapters.set(item.chapter, chapter)
34
+ sections.push(chapter)
35
+ }
36
+ chapter.items.push(item)
37
+ }
38
+
39
+ return sections.toSorted((a, b) => a.order - b.order)
40
+ }
41
+
42
+ /** Build every navigation view from one ordered collection traversal. */
43
+ interface Model {
44
+ items: DocsNavItem[]
45
+ positions: Map<string, number>
46
+ }
47
+
48
+ // Only isActive and the neighbours vary between the pages of one locale. The
49
+ // hrefs do not, as long as the url options are the same ones.
50
+ const models = new WeakMap<object, Map<string, Model>>()
51
+
52
+ function modelFor(sortedPages: DocsPageLike[], urls: DocsUrlOptions): Model {
53
+ const key = `${urls.docsBase}\u0000${urls.deploymentBase ?? ''}\u0000${urls.locale ?? ''}\u0000${urls.defaultLocale ?? ''}`
54
+ let byUrls = models.get(sortedPages)
55
+ if (!byUrls) {
56
+ byUrls = new Map()
57
+ models.set(sortedPages, byUrls)
58
+ }
59
+ const cached = byUrls.get(key)
60
+ if (cached) return cached
61
+
62
+ const model: Model = {
63
+ items: sortedPages.map((page, index): DocsNavItem => {
64
+ const parsed = parseDocCollectionId(routedId(page))
65
+ return {
66
+ title: docLabel(page),
67
+ chapter: parsed.chapter,
68
+ primaryOrder: parsed.orderChapter ?? page.data?.order ?? parsed.order,
69
+ href: docsPageHref(getCanonicalDocSlug(page, index), urls),
70
+ isActive: false,
71
+ }
72
+ }),
73
+ positions: new Map(sortedPages.map((page, index) => [page.id, index])),
74
+ }
75
+
76
+ byUrls.set(key, model)
77
+ return model
78
+ }
79
+
80
+ export function buildDocsNavigation(
81
+ sortedPages: DocsPageLike[],
82
+ currentId: string,
83
+ urls: DocsUrlOptions,
84
+ ): DocsNavigation {
85
+ const { items, positions } = modelFor(sortedPages, urls)
86
+ const currentIndex = positions.get(currentId) ?? -1
87
+ const marked = items.map((item, index) => ({
88
+ ...item,
89
+ isActive: index === currentIndex,
90
+ }))
91
+
92
+ return {
93
+ sections: groupByChapter(marked),
94
+ previousPage: currentIndex > 0 ? marked[currentIndex - 1] : null,
95
+ nextPage: currentIndex >= 0 && currentIndex < marked.length - 1 ? marked[currentIndex + 1] : null,
96
+ }
97
+ }
@@ -0,0 +1,16 @@
1
+ ---
2
+ import Layout from '../layouts/Page.astro'
3
+ import { withDeploymentBase } from '../urls.ts'
4
+
5
+ const homeHref = withDeploymentBase('/', import.meta.env.BASE_URL)
6
+ ---
7
+
8
+ <Layout title="404" description="The page you're looking for doesn't exist.">
9
+ <div class="jaad-not-found">
10
+ <h1 class="jaad-not-found-title">404</h1>
11
+ <p class="jaad-not-found-text">
12
+ <i>Oops!</i> The page you're looking for doesn't exist.
13
+ </p>
14
+ <a href={homeHref}>Go back to the homepage</a>
15
+ </div>
16
+ </Layout>
@@ -0,0 +1,19 @@
1
+ ---
2
+ import { render } from 'astro:content'
3
+ import { getSortedDocsPages } from '../collection.ts'
4
+ import DocsPage from '../components/docs/DocsPage.astro'
5
+ import { withDeploymentBase } from '../urls.ts'
6
+
7
+ const sortedPages = await getSortedDocsPages()
8
+
9
+ const firstPage = sortedPages[0]
10
+ if (!firstPage) {
11
+ return Astro.redirect(withDeploymentBase('/404', import.meta.env.BASE_URL))
12
+ }
13
+
14
+ const { Content } = await render(firstPage)
15
+ ---
16
+
17
+ <DocsPage entry={firstPage} {sortedPages}>
18
+ <Content />
19
+ </DocsPage>
@@ -0,0 +1,58 @@
1
+ ---
2
+ import { render } from 'astro:content'
3
+ import { getSortedDocsPages } from '../collection.ts'
4
+ import DocsPage from '../components/docs/DocsPage.astro'
5
+ import { buildLocales, docsUrls, localePrefix } from '../docs-config.ts'
6
+ import { docsPageHref, withDeploymentBase } from '../urls.ts'
7
+ import { getCanonicalDocSlug, getCleanSlug, docTitle, routedId } from '../utils/docs.ts'
8
+
9
+ interface DocsPath {
10
+ params: { slug: string }
11
+ props: { id: string; locale?: string; isOpening: boolean }
12
+ }
13
+
14
+ export async function getStaticPaths() {
15
+ const paths: DocsPath[] = []
16
+
17
+ for (const locale of buildLocales()) {
18
+ const sortedPages = await getSortedDocsPages(locale)
19
+ const prefix = localePrefix(locale)
20
+
21
+ sortedPages.forEach((page, index) => {
22
+ const isOpening = getCanonicalDocSlug(page, index) === ''
23
+ paths.push({
24
+ params: { slug: `${prefix}${getCleanSlug(routedId(page))}` },
25
+ props: { id: page.id, locale, isOpening },
26
+ })
27
+
28
+ // A prefixed locale has no index route of its own: /it is this page.
29
+ if (isOpening && prefix) {
30
+ paths.push({
31
+ params: { slug: locale! },
32
+ props: { id: page.id, locale, isOpening: false },
33
+ })
34
+ }
35
+ })
36
+ }
37
+
38
+ return paths
39
+ }
40
+
41
+ const { id, locale, isOpening } = Astro.props
42
+
43
+ if (isOpening) {
44
+ return Astro.redirect(docsPageHref('', docsUrls(locale)), 301)
45
+ }
46
+
47
+ const sortedPages = await getSortedDocsPages(locale)
48
+ const docPage = sortedPages.find((page) => page.id === id)
49
+ if (!docPage) {
50
+ return Astro.redirect(withDeploymentBase('/404', import.meta.env.BASE_URL))
51
+ }
52
+
53
+ const { Content } = await render(docPage)
54
+ ---
55
+
56
+ <DocsPage entry={docPage} {sortedPages} headTitle={docTitle(docPage)}>
57
+ <Content />
58
+ </DocsPage>
@@ -0,0 +1,37 @@
1
+ import type { APIRoute, GetStaticPaths } from 'astro'
2
+ import { getSortedDocsPages } from '../collection.ts'
3
+ import { buildLocales, localePrefix } from '../docs-config.ts'
4
+ import { getCleanSlug, routedId } from '../utils/docs.ts'
5
+
6
+ /** Raw markdown at the page's own url plus `.md`, for readers and for LLMs. */
7
+ export const getStaticPaths: GetStaticPaths = async () => {
8
+ const paths: {
9
+ params: { slug: string }
10
+ props: { id: string; locale?: string }
11
+ }[] = []
12
+
13
+ for (const locale of buildLocales()) {
14
+ const sortedPages = await getSortedDocsPages(locale)
15
+ const prefix = localePrefix(locale)
16
+
17
+ for (const page of sortedPages) {
18
+ paths.push({
19
+ params: { slug: `${prefix}${getCleanSlug(routedId(page))}` },
20
+ props: { id: page.id, locale },
21
+ })
22
+ }
23
+ }
24
+
25
+ return paths
26
+ }
27
+
28
+ export const GET: APIRoute = async ({ props }) => {
29
+ const { id, locale } = props as { id: string; locale?: string }
30
+ const pages = await getSortedDocsPages(locale)
31
+ const entry = pages.find((page) => page.id === id)
32
+ if (!entry) return new Response('Not found', { status: 404 })
33
+
34
+ return new Response(entry.body ?? '', {
35
+ headers: { 'Content-Type': 'text/markdown; charset=utf-8' },
36
+ })
37
+ }
@@ -0,0 +1,8 @@
1
+ import type { APIRoute, GetStaticPaths } from 'astro'
2
+ import { getServedLocales } from '../collection.ts'
3
+ import { llmsResponse } from '../utils/endpoints.ts'
4
+
5
+ export const getStaticPaths: GetStaticPaths = async () =>
6
+ (await getServedLocales()).map((locale) => ({ params: { locale } }))
7
+
8
+ export const GET: APIRoute = ({ site, params }) => llmsResponse(site, params.locale)