@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,68 @@
1
+ ---
2
+ import type { DocsEntry } from '../../@types/docs.ts'
3
+ import MarkdownContent from '@jaamd/jaamd/components'
4
+ import config from 'virtual:jaad/config'
5
+ import { DEFAULT_LOCALE, LOCALES, docsUrls, ui } from '../../docs-config.ts'
6
+ import DocsLayout from '../../layouts/Docs.astro'
7
+ import { buildDocsNavigation } from '../../navigation.ts'
8
+ import {
9
+ getCleanSlug,
10
+ parseDocCollectionId,
11
+ docTitle,
12
+ extractHeadingsFromMarkdown,
13
+ routedId,
14
+ } from '../../utils/docs.ts'
15
+ import Breadcrumbs from './Breadcrumbs.astro'
16
+ import DocsMobileNav from './DocsMobileNav.astro'
17
+ import DocsPageNav from './DocsPageNav.astro'
18
+ import PageActions from './PageActions.astro'
19
+
20
+ interface Props {
21
+ entry: DocsEntry
22
+ sortedPages: DocsEntry[]
23
+ /** Left out on the opening page, whose title is the site name alone. */
24
+ headTitle?: string
25
+ }
26
+
27
+ const { entry, sortedPages, headTitle } = Astro.props
28
+
29
+ const cleanSlug = getCleanSlug(routedId(entry))
30
+ const { chapter } = parseDocCollectionId(routedId(entry))
31
+ const title = docTitle(entry)
32
+ const headings = extractHeadingsFromMarkdown(entry.body || '')
33
+ const urls = docsUrls(entry.locale)
34
+ const t = ui(entry.locale)
35
+ const language = LOCALES.find((known) => known.code === (entry.locale ?? DEFAULT_LOCALE))?.tag ?? config.lang
36
+ const updated = entry.data.lastUpdated
37
+ const navigation = buildDocsNavigation(sortedPages, entry.id, urls)
38
+ const contentAttrs = { id: 'content' }
39
+ ---
40
+
41
+ <DocsLayout title={headTitle} chapter={chapter} entry={entry} headings={headings} {navigation}>
42
+ <DocsMobileNav slot="mobile-navigation" sections={navigation.sections} headings={headings} locale={entry.locale} />
43
+
44
+ <Breadcrumbs slot="breadcrumbs" chapter={chapter} title={title} locale={entry.locale} />
45
+ <PageActions slot="page-actions" id={entry.id} cleanSlug={cleanSlug} locale={entry.locale} />
46
+
47
+ <Fragment slot="content">
48
+ <MarkdownContent {...contentAttrs} class="docs-content">
49
+ <slot />
50
+ </MarkdownContent>
51
+
52
+ {updated && (
53
+ <p class="docs-updated">
54
+ {t('page.lastUpdated')}{' '}
55
+ <time datetime={updated.toISOString()}>
56
+ {new Intl.DateTimeFormat(language, { dateStyle: 'long' }).format(updated)}
57
+ </time>
58
+ </p>
59
+ )}
60
+ </Fragment>
61
+
62
+ <DocsPageNav
63
+ slot="page-navigation"
64
+ previousPage={navigation.previousPage}
65
+ nextPage={navigation.nextPage}
66
+ locale={entry.locale}
67
+ />
68
+ </DocsLayout>
@@ -0,0 +1,53 @@
1
+ ---
2
+ import type { DocsNavItem } from '../../@types/docs.ts'
3
+ import { ui } from '../../docs-config.ts'
4
+
5
+ interface Props {
6
+ locale?: string
7
+ previousPage: DocsNavItem | null
8
+ nextPage: DocsNavItem | null
9
+ }
10
+
11
+ const { previousPage, nextPage, locale } = Astro.props
12
+ const t = ui(locale)
13
+ ---
14
+
15
+ {(previousPage || nextPage) && (
16
+ <nav class="docs-page-nav" aria-label={t('nav.page')}>
17
+ <div class="docs-page-nav-row">
18
+ {previousPage && previousPage.title ? (
19
+ <a href={previousPage.href} rel="prev" class="docs-page-nav-link">
20
+ <svg
21
+ class="docs-page-nav-icon docs-page-nav-icon-prev"
22
+ fill="none"
23
+ stroke="currentColor"
24
+ viewBox="0 0 24 24"
25
+ aria-hidden="true"
26
+ >
27
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
28
+ </svg>
29
+ <span class="docs-page-nav-label">{previousPage.title}</span>
30
+ </a>
31
+ ) : (
32
+ <div />
33
+ )}
34
+
35
+ {nextPage && nextPage.title ? (
36
+ <a href={nextPage.href} rel="next" class="docs-page-nav-link">
37
+ <span class="docs-page-nav-label">{nextPage.title}</span>
38
+ <svg
39
+ class="docs-page-nav-icon docs-page-nav-icon-next"
40
+ fill="none"
41
+ stroke="currentColor"
42
+ viewBox="0 0 24 24"
43
+ aria-hidden="true"
44
+ >
45
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
46
+ </svg>
47
+ </a>
48
+ ) : (
49
+ <div />
50
+ )}
51
+ </div>
52
+ </nav>
53
+ )}
@@ -0,0 +1,82 @@
1
+ ---
2
+ import type { NavSection } from '../../navigation.ts'
3
+ import { ui } from '../../docs-config.ts'
4
+ import { formatChapterTitle } from '../../utils/docs.ts'
5
+
6
+ interface Props {
7
+ sections: NavSection[]
8
+ locale?: string
9
+ }
10
+
11
+ const { sections, locale } = Astro.props
12
+ const t = ui(locale)
13
+ ---
14
+
15
+ <aside class="docs-sidebar-left jaad-sidebar">
16
+ <nav aria-label={t('nav.documentation')}>
17
+ {sections.map((section) =>
18
+ section.type === 'page' ? (
19
+ <div class="docs-sidebar-page">
20
+ <a
21
+ href={section.item.href}
22
+ class="docs-sidebar-link"
23
+ aria-current={section.item.isActive ? 'page' : undefined}
24
+ >
25
+ {section.item.title}
26
+ </a>
27
+ </div>
28
+ ) : (
29
+ <div class="docs-sidebar-chapter">
30
+ <h3 class="docs-sidebar-chapter-title">{formatChapterTitle(section.chapter)}</h3>
31
+ <ul class="docs-sidebar-chapter-list">
32
+ {section.items.map((item) => (
33
+ <li>
34
+ <a
35
+ href={item.href}
36
+ class="docs-sidebar-sublink"
37
+ aria-current={item.isActive ? 'page' : undefined}
38
+ >
39
+ {item.title}
40
+ </a>
41
+ </li>
42
+ ))}
43
+ </ul>
44
+ </div>
45
+ ),
46
+ )}
47
+ </nav>
48
+ </aside>
49
+
50
+ <script>
51
+ const SELECTOR = '.docs-sidebar-left'
52
+ let scrollTop = 0
53
+
54
+ const sidebar = () => document.querySelector<HTMLElement>(SELECTOR)
55
+
56
+ /** Scrolls the sidebar only, never the document, and only when needed. */
57
+ function revealCurrent() {
58
+ const el = sidebar()
59
+ const current = el?.querySelector('[aria-current="page"]')
60
+ if (!el || !current) return
61
+
62
+ const box = el.getBoundingClientRect()
63
+ const item = current.getBoundingClientRect()
64
+ if (item.top >= box.top && item.bottom <= box.bottom) return
65
+
66
+ el.scrollTop += item.top - box.top - (box.height - item.height) / 2
67
+ }
68
+
69
+ document.addEventListener('astro:before-swap', () => {
70
+ scrollTop = sidebar()?.scrollTop ?? 0
71
+ })
72
+
73
+ document.addEventListener('astro:after-swap', () => {
74
+ const el = sidebar()
75
+ if (!el) return
76
+ el.scrollTop = scrollTop
77
+ // A deep link, or a page whose entry sits outside the restored window.
78
+ revealCurrent()
79
+ })
80
+
81
+ revealCurrent()
82
+ </script>
@@ -0,0 +1,81 @@
1
+ ---
2
+ import config from 'virtual:jaad/config'
3
+ import { docsUrls, ui } from '../../docs-config.ts'
4
+ import { editUrl } from '../../infer.ts'
5
+ import { docsMarkdownHref } from '../../urls.ts'
6
+
7
+ interface Props {
8
+ /** Content collection entry id, e.g. "03-markdown/12-inner-html". */
9
+ id: string
10
+ /** URL-safe slug, e.g. "markdown/inner-html". */
11
+ cleanSlug: string
12
+ locale?: string
13
+ }
14
+
15
+ const { id, cleanSlug, locale } = Astro.props
16
+ const t = ui(locale)
17
+
18
+ const mdHref = docsMarkdownHref(cleanSlug, docsUrls(locale))
19
+ const editHref = config.editBase ? editUrl(config.editBase, `${id}.md`) : null
20
+ ---
21
+
22
+ <div class="docs-page-actions">
23
+ <button type="button" data-copy-page data-md-href={mdHref} data-copied={t('page.copied')} class="docs-page-action">
24
+ <svg class="docs-page-action-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
25
+ <path
26
+ stroke-linecap="round"
27
+ stroke-linejoin="round"
28
+ stroke-width="2"
29
+ d="M16 1H4a2 2 0 00-2 2v14h2V3h12V1zm3 4H8a2 2 0 00-2 2v14a2 2 0 002 2h11a2 2 0 002-2V7a2 2 0 00-2-2z"
30
+ ></path>
31
+ </svg>
32
+ <span data-copy-label>{t('page.copy')}</span>
33
+ </button>
34
+
35
+ {editHref && (
36
+ <a href={editHref} target="_blank" rel="noopener noreferrer" class="docs-page-action">
37
+ <svg class="docs-page-action-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
38
+ <path
39
+ stroke-linecap="round"
40
+ stroke-linejoin="round"
41
+ stroke-width="2"
42
+ d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
43
+ />
44
+ </svg>
45
+ <span>{t('page.edit')}</span>
46
+ </a>
47
+ )}
48
+ </div>
49
+
50
+ <script>
51
+ function initCopyPageButtons(): void {
52
+ document.querySelectorAll<HTMLButtonElement>('[data-copy-page]').forEach((btn) => {
53
+ if (btn.dataset.copyInit) return
54
+ btn.dataset.copyInit = '1'
55
+
56
+ const label = btn.querySelector<HTMLElement>('[data-copy-label]')
57
+
58
+ btn.addEventListener('click', async () => {
59
+ const href = btn.dataset.mdHref
60
+ if (!href) return
61
+ try {
62
+ const res = await fetch(href)
63
+ const text = await res.text()
64
+ await navigator.clipboard.writeText(text)
65
+ if (label) {
66
+ const original = label.textContent
67
+ label.textContent = btn.dataset.copied ?? 'Copied!'
68
+ setTimeout(() => {
69
+ label.textContent = original
70
+ }, 2000)
71
+ }
72
+ } catch {
73
+ // Network or clipboard unavailable.
74
+ }
75
+ })
76
+ })
77
+ }
78
+
79
+ initCopyPageButtons()
80
+ document.addEventListener('astro:page-load', initCopyPageButtons)
81
+ </script>
@@ -0,0 +1,55 @@
1
+ ---
2
+ import config from 'virtual:jaad/config'
3
+ import { docsUrls } from '../../docs-config.ts'
4
+ import { docsPageHref } from '../../urls.ts'
5
+ import { formatChapterTitle } from '../../utils/docs.ts'
6
+
7
+ interface Props {
8
+ title: string
9
+ description: string
10
+ chapter?: string
11
+ keywords?: string[]
12
+ author?: string
13
+ modified?: Date
14
+ /** BCP-47 tag of this page's locale. */
15
+ language?: string
16
+ locale?: string
17
+ }
18
+
19
+ const { title, description, chapter, keywords = [], author, modified, language = config.lang, locale } = Astro.props
20
+
21
+ // Relative ids are worthless to a crawler, so without `site` emit nothing.
22
+ const site = Astro.site
23
+ const base = site ? docsPageHref('', docsUrls(locale)) : null
24
+
25
+ const crumbs = base
26
+ ? [
27
+ { name: config.title, url: new URL(base, Astro.url.origin).href },
28
+ ...(chapter ? [{ name: formatChapterTitle(chapter)!, url: null }] : []),
29
+ { name: title, url: Astro.url.href },
30
+ ]
31
+ : []
32
+
33
+ const data = base && {
34
+ '@context': 'https://schema.org',
35
+ '@type': 'TechArticle',
36
+ headline: title,
37
+ description,
38
+ inLanguage: language,
39
+ ...(keywords.length > 0 ? { keywords } : {}),
40
+ ...(author ? { author: { '@type': 'Person', name: author } } : {}),
41
+ ...(modified ? { dateModified: modified.toISOString() } : {}),
42
+ isPartOf: { '@type': 'WebSite', name: config.title },
43
+ breadcrumb: {
44
+ '@type': 'BreadcrumbList',
45
+ itemListElement: crumbs.map((crumb, i) => ({
46
+ '@type': 'ListItem',
47
+ position: i + 1,
48
+ name: crumb.name,
49
+ ...(crumb.url ? { item: crumb.url } : {}),
50
+ })),
51
+ },
52
+ }
53
+ ---
54
+
55
+ {data && <script type="application/ld+json" set:html={JSON.stringify(data)} is:inline />}
@@ -0,0 +1,73 @@
1
+ ---
2
+ import type { DocsHeadings } from '../../@types/docs.ts'
3
+ import { ui } from '../../docs-config.ts'
4
+
5
+ interface Props {
6
+ headings: DocsHeadings[]
7
+ locale?: string
8
+ }
9
+
10
+ const { headings, locale } = Astro.props
11
+ const t = ui(locale)
12
+ ---
13
+
14
+ {headings.length > 0 && (
15
+ <aside id="toc-sidebar" class="docs-sidebar-right jaad-sidebar">
16
+ <nav aria-label={t('toc.label')}>
17
+ <h2 class="docs-toc-title">{t('toc.title')}</h2>
18
+ <ul class="docs-toc-list">
19
+ {headings.map((heading) => (
20
+ <li>
21
+ <a
22
+ href={`#${heading.slug}`}
23
+ data-toc-link
24
+ class:list={['docs-toc-link', heading.depth === 3 && 'docs-toc-link-nested']}
25
+ >
26
+ {heading.text}
27
+ </a>
28
+ </li>
29
+ ))}
30
+ </ul>
31
+ </nav>
32
+ </aside>
33
+ )}
34
+
35
+ <script>
36
+ function initToc(): void {
37
+ const sidebar = document.getElementById('toc-sidebar')
38
+ // astro:page-load also fires on the first load, which would observe twice.
39
+ if (!sidebar || sidebar.dataset.tocInit) return
40
+ sidebar.dataset.tocInit = '1'
41
+
42
+ const links = Array.from(sidebar.querySelectorAll<HTMLAnchorElement>('[data-toc-link]'))
43
+ const headingEls = links
44
+ .map((link) => document.getElementById(link.getAttribute('href')!.slice(1)))
45
+ .filter(Boolean) as HTMLElement[]
46
+
47
+ if (!headingEls.length) return
48
+
49
+ const setActive = (id: string): void => {
50
+ links.forEach((link) => {
51
+ const isActive = link.getAttribute('href') === `#${id}`
52
+ if (isActive) link.setAttribute('aria-current', 'location')
53
+ else link.removeAttribute('aria-current')
54
+ })
55
+ }
56
+
57
+ const observer = new IntersectionObserver(
58
+ (entries) => {
59
+ entries.forEach((entry) => {
60
+ if (entry.isIntersecting) setActive(entry.target.id)
61
+ })
62
+ },
63
+ { rootMargin: '-100px 0px -66%', threshold: 0 },
64
+ )
65
+
66
+ headingEls.forEach((el) => observer.observe(el))
67
+
68
+ document.addEventListener('astro:before-preparation', () => observer.disconnect(), { once: true })
69
+ }
70
+
71
+ initToc()
72
+ document.addEventListener('astro:page-load', initToc)
73
+ </script>
@@ -0,0 +1,6 @@
1
+ /** The markup the persisted switcher should adopt from an incoming document,
2
+ * or null when there is nothing to adopt. */
3
+ export function nextSwitcherMarkup(incoming: Document): string | null {
4
+ const next = incoming.querySelector('jaad-locale-switcher')
5
+ return next ? next.innerHTML : null
6
+ }
package/src/config.ts ADDED
@@ -0,0 +1,236 @@
1
+ import type { AstroUserConfig, FontProvider, Locales, SessionDriverConfig } from 'astro'
2
+ import { existsSync } from 'node:fs'
3
+ import { createRequire } from 'node:module'
4
+ import { join } from 'node:path'
5
+ import { fileURLToPath } from 'node:url'
6
+ import { z } from 'astro/zod'
7
+ import { inferRepo, inferDescription, editBaseFrom, type RepoInfo } from './infer.ts'
8
+ import { detectLocales, validateLocaleTree } from './locale-tree.ts'
9
+ import { describeLocale, isLocale, type Locale } from './locales.ts'
10
+ import { PRESETS, PRESET_NAMES, isPreset } from './themes/index.ts'
11
+ import { normaliseBasePath } from './urls.ts'
12
+
13
+ export const jaadConfigSchema = z.object({
14
+ title: z.string(),
15
+ description: z.string().optional(),
16
+ lang: z.string().default('en'),
17
+ logo: z
18
+ .string()
19
+ .refine((v) => v.startsWith('/') || /^https?:\/\//.test(v), {
20
+ message: 'logo must be a public url, such as "/logo.svg"',
21
+ })
22
+ .optional(),
23
+
24
+ docsDir: z.string().default('./docs'),
25
+ routeBase: z.string().default('/'),
26
+
27
+ social: z
28
+ .record(
29
+ z.string(),
30
+ z.union([
31
+ z.string(),
32
+ z.object({
33
+ href: z.string(),
34
+ label: z.string().optional(),
35
+ svg: z.string().optional(),
36
+ }),
37
+ ]),
38
+ )
39
+ .default({}),
40
+ nav: z.array(z.object({ label: z.string(), href: z.string() })).default([]),
41
+ footer: z
42
+ .union([z.string(), z.literal(false)], {
43
+ error:
44
+ 'footer is one line of text, or false to remove it. ' +
45
+ 'The message/copyright pair is gone: write them as a single string, ' +
46
+ 'e.g. "MIT Licensed · © 2026 Me · :credit".',
47
+ })
48
+ .optional(),
49
+
50
+ editLink: z.union([z.boolean(), z.string()]).default(true),
51
+
52
+ head: z
53
+ .array(
54
+ z.object({
55
+ tag: z.string(),
56
+ attrs: z.record(z.string(), z.union([z.string(), z.boolean()])).optional(),
57
+ content: z.string().optional(),
58
+ }),
59
+ )
60
+ .default([]),
61
+ ogImage: z
62
+ .union([
63
+ z.string().refine((v) => v.startsWith('/') || /^https?:\/\//.test(v), {
64
+ message: 'ogImage must be a public url, such as "/og-image.png"',
65
+ }),
66
+ z.literal(false),
67
+ ])
68
+ .optional(),
69
+
70
+ theme: z
71
+ .union([
72
+ z.string().refine(isPreset, {
73
+ message: `unknown theme; available: ${PRESET_NAMES.join(', ')}`,
74
+ }),
75
+ z.object({ light: z.string(), dark: z.string() }),
76
+ ])
77
+ .default('default'),
78
+
79
+ appearance: z
80
+ .enum(['auto', 'light', 'dark'], {
81
+ error: 'unknown appearance; available: auto, light, dark',
82
+ })
83
+ .default('auto'),
84
+
85
+ /** Registers Tailwind (the site's own install) and exposes JAAD's tokens to it. */
86
+ tailwind: z.boolean().default(false),
87
+
88
+ /** Per-locale overrides for JAAD's bundled interface strings. */
89
+ ui: z.record(z.string(), z.record(z.string(), z.string())).default({}),
90
+
91
+ /** Only when detection guesses wrong: pin the set, or false to switch it off. */
92
+ locales: z
93
+ .union([z.array(z.string().refine(isLocale, { message: 'not a locale code' })), z.literal(false)])
94
+ .optional(),
95
+ })
96
+
97
+ export type JaadConfig = z.output<typeof jaadConfigSchema>
98
+
99
+ /** Out of the schema, so `parse` strips them: the resolved config is
100
+ * serialised and an integration is not. */
101
+ type AstroPassthroughConfig = AstroUserConfig<Locales, string | SessionDriverConfig | undefined, FontProvider[]>
102
+
103
+ export type JaadUserConfig = z.input<typeof jaadConfigSchema> & {
104
+ site?: string
105
+ base?: string
106
+ astro?: AstroPassthroughConfig
107
+ }
108
+
109
+ /** What the components actually read: the user's options plus whatever the
110
+ * repository could tell us. Inferred fields are null when unavailable. */
111
+ export interface JaadResolvedConfig extends JaadConfig {
112
+ /** Where the docs are mounted, without a trailing slash. Empty at the root. */
113
+ docsBase: string
114
+ /** What jaamd gets for code blocks. */
115
+ shiki: string | { light: string; dark: string }
116
+ repoUrl: string | null
117
+ editBase: string | null
118
+ favicon: string | null
119
+ /** Explicit, inferred from public/, or false when no valid image exists. */
120
+ ogImage: string | false
121
+ /** Empty when the docs are not localised. */
122
+ docsLocales: Locale[]
123
+ /** `lang` when the docs are not localised. */
124
+ defaultLocale: string
125
+ }
126
+
127
+ const FAVICONS = ['favicon.svg', 'favicon.ico', 'favicon.png']
128
+ const OG_IMAGES = ['og-image.png', 'og-image.jpg', 'og-image.jpeg', 'og-image.webp']
129
+
130
+ function findPublicAsset(cwd: string, candidates: string[]): string | null {
131
+ for (const name of candidates) {
132
+ if (existsSync(join(cwd, 'public', name))) return `/${name}`
133
+ }
134
+ return null
135
+ }
136
+
137
+ /** Empty at the root, otherwise one leading slash and no trailing one. */
138
+ function resolveEditBase(editLink: JaadConfig['editLink'], repo: RepoInfo | null, docsDir: string): string | null {
139
+ if (typeof editLink === 'string') return editLink
140
+ if (!editLink || !repo) return null
141
+ return editBaseFrom(repo, docsDir)
142
+ }
143
+
144
+ const OPTIONS = Object.keys(jaadConfigSchema.shape)
145
+ const FORWARDED = ['site', 'base', 'astro']
146
+
147
+ /** Unknown keys are rejected here: zod would strip a misspelt option in
148
+ * silence. Both messages are read in a terminal, not parsed. */
149
+ function parseConfig(options: JaadUserConfig): JaadConfig {
150
+ const known = new Set([...OPTIONS, ...FORWARDED])
151
+ const unknown = Object.keys(options).filter((key) => !known.has(key))
152
+
153
+ if (unknown.length > 0) {
154
+ const named = unknown.map((key) => {
155
+ const close = OPTIONS.find((o) => o.toLowerCase() === key.toLowerCase())
156
+ return close ? `${key} (did you mean ${close}?)` : key
157
+ })
158
+ throw new Error(
159
+ `jaad: unknown option${unknown.length > 1 ? 's' : ''} ${named.join(', ')}\n` +
160
+ ` available: ${OPTIONS.join(', ')}`,
161
+ )
162
+ }
163
+
164
+ const parsed = jaadConfigSchema.safeParse(options)
165
+ if (parsed.success) return parsed.data
166
+
167
+ throw new Error(
168
+ 'jaad: invalid configuration\n' +
169
+ parsed.error.issues.map((issue) => ` ${issue.path.join('.') || 'config'}: ${issue.message}`).join('\n'),
170
+ )
171
+ }
172
+
173
+ export function resolveConfig(options: JaadUserConfig, cwd: string = process.cwd()): JaadResolvedConfig {
174
+ const config = parseConfig(options)
175
+ const repo = inferRepo(cwd)
176
+ const locales = resolveLocales(config, cwd)
177
+
178
+ return {
179
+ ...config,
180
+ description: config.description ?? inferDescription(cwd) ?? undefined,
181
+ repoUrl: repo?.url ?? null,
182
+ favicon: findPublicAsset(cwd, FAVICONS),
183
+ ogImage: config.ogImage ?? findPublicAsset(cwd, OG_IMAGES) ?? false,
184
+ docsBase: normaliseBasePath(config.routeBase),
185
+ shiki: typeof config.theme === 'string' ? PRESETS[config.theme].shiki : config.theme,
186
+ editBase: resolveEditBase(config.editLink, repo, config.docsDir),
187
+ docsLocales: locales.map(describeLocale),
188
+ defaultLocale: locales.length > 0 ? config.lang.toLowerCase() : config.lang,
189
+ }
190
+ }
191
+
192
+ /** Directory names win; `locales` only overrides what detection found. */
193
+ function resolveLocales(config: JaadConfig, cwd: string): string[] {
194
+ if (config.locales === false) return []
195
+
196
+ const docsDir = join(cwd, config.docsDir)
197
+ const locales = config.locales ?? detectLocales(docsDir, config.lang)
198
+ // A pinned list skips detection, and with it the missing-directory guard;
199
+ // the integration warns about that case with better words than readdir does.
200
+ if (locales.length === 0 || !existsSync(docsDir)) return []
201
+
202
+ validateLocaleTree(docsDir, locales, config.lang)
203
+ return locales.map((locale) => locale.toLowerCase()).toSorted()
204
+ }
205
+
206
+ const requireFrom = createRequire(import.meta.url)
207
+
208
+ const styleInPackage = (name: string) => fileURLToPath(new URL(`./styles/${name}`, import.meta.url))
209
+
210
+ /** Absolute paths, kept off the resolved config, which gets serialised. */
211
+ export function resolveStylesheets(
212
+ config: JaadConfig,
213
+ cwd: string = process.cwd(),
214
+ ): {
215
+ user: string | null
216
+ theme: string | null
217
+ bridge: string
218
+ tailwind: string | null
219
+ } {
220
+ const userCss = join(cwd, 'src', 'jaad.css')
221
+ const slug = typeof config.theme === 'string' ? PRESETS[config.theme].theme : null
222
+
223
+ return {
224
+ user: existsSync(userCss) ? userCss : null,
225
+ // Resolved through JAAMD's export map rather than by path.
226
+ theme: slug ? requireFrom.resolve(`@jaamd/jaamd/themes/${slug}.css`) : null,
227
+ // A preset supplies the palette, so the colours travel the other way.
228
+ bridge: styleInPackage(slug ? 'jaamd-reverse.css' : 'jaamd-forward.css'),
229
+ tailwind: config.tailwind ? styleInPackage('tailwind.css') : null,
230
+ }
231
+ }
232
+
233
+ /** Identity helper, for a typed `jaad.config.ts`. */
234
+ export function defineJaadConfig(config: JaadUserConfig): JaadUserConfig {
235
+ return config
236
+ }