@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,71 @@
1
+ ---
2
+ import type { DocsHeading } from '../types.ts'
3
+ import { ui } from '../../site/runtime.ts'
4
+
5
+ interface Props {
6
+ headings: DocsHeading[]
7
+ locale?: string
8
+ }
9
+
10
+ const { headings, locale } = Astro.props
11
+ const t = ui(locale)
12
+ ---
13
+
14
+ {headings.length > 0 && (
15
+ <nav id="docs-toc" aria-label={t('toc.label')} class="docs-toc">
16
+ <h2 class="docs-toc-title">{t('toc.title')}</h2>
17
+ <ul class="docs-toc-list">
18
+ {headings.map((heading) => (
19
+ <li>
20
+ <a
21
+ href={`#${heading.slug}`}
22
+ data-toc-link
23
+ class:list={['docs-toc-link', heading.depth === 3 && 'docs-toc-link-nested']}
24
+ >
25
+ {heading.text}
26
+ </a>
27
+ </li>
28
+ ))}
29
+ </ul>
30
+ </nav>
31
+ )}
32
+
33
+ <script>
34
+ function initToc(): void {
35
+ const sidebar = document.getElementById('docs-toc')
36
+ // astro:page-load also fires on the first load, which would observe twice.
37
+ if (!sidebar || sidebar.dataset.tocInit) return
38
+ sidebar.dataset.tocInit = '1'
39
+
40
+ const links = Array.from(sidebar.querySelectorAll<HTMLAnchorElement>('[data-toc-link]'))
41
+ const headingEls = links
42
+ .map((link) => document.getElementById(link.getAttribute('href')!.slice(1)))
43
+ .filter(Boolean) as HTMLElement[]
44
+
45
+ if (!headingEls.length) return
46
+
47
+ const setActive = (id: string): void => {
48
+ links.forEach((link) => {
49
+ const isActive = link.getAttribute('href') === `#${id}`
50
+ if (isActive) link.setAttribute('aria-current', 'location')
51
+ else link.removeAttribute('aria-current')
52
+ })
53
+ }
54
+
55
+ const observer = new IntersectionObserver(
56
+ (entries) => {
57
+ entries.forEach((entry) => {
58
+ if (entry.isIntersecting) setActive(entry.target.id)
59
+ })
60
+ },
61
+ { rootMargin: '-100px 0px -66%', threshold: 0 },
62
+ )
63
+
64
+ headingEls.forEach((el) => observer.observe(el))
65
+
66
+ document.addEventListener('astro:before-preparation', () => observer.disconnect(), { once: true })
67
+ }
68
+
69
+ initToc()
70
+ document.addEventListener('astro:page-load', initToc)
71
+ </script>
@@ -0,0 +1,167 @@
1
+ import { existsSync } from 'node:fs'
2
+ import { relative, resolve, sep } from 'node:path'
3
+ import { z } from 'astro/zod'
4
+ import { editBaseFrom, type RepoInfo } from '../site/infer.ts'
5
+ import { isLocale } from '../site/locales.ts'
6
+ import { normaliseBasePath } from '../site/urls.ts'
7
+ import { detectLocales, validateLocaleTree } from './locale-tree.ts'
8
+ import { slugToTitle } from './model/ids.ts'
9
+
10
+ /** The options of the documentation: where the markdown is and where it is served. */
11
+ export const docsConfigShape = {
12
+ docsDir: z.string().default('./docs'),
13
+ routeBase: z.string().default('/'),
14
+ /** One directory per section, keyed by its url segment. Replaces `docsDir`. */
15
+ sections: z
16
+ .record(z.string(), z.object({ dir: z.string(), label: z.string().optional() }))
17
+ .superRefine((sections, context) => {
18
+ for (const key of Object.keys(sections)) {
19
+ if (/^[a-z0-9]+(-[a-z0-9]+)*$/.test(key)) continue
20
+ context.addIssue({
21
+ code: 'custom',
22
+ path: [key],
23
+ message: 'a section key is its url segment: lowercase letters, digits and dashes',
24
+ })
25
+ }
26
+ })
27
+ .optional(),
28
+ editLink: z.union([z.boolean(), z.string()]).default(true),
29
+ /** Only when detection guesses wrong: pin the set, or false to switch it off. */
30
+ locales: z
31
+ .union([z.array(z.string().refine(isLocale, { message: 'not a locale code' })), z.literal(false)])
32
+ .optional(),
33
+ }
34
+
35
+ const docsConfigSchema = z.object(docsConfigShape)
36
+
37
+ export type DocsConfig = z.output<typeof docsConfigSchema>
38
+
39
+ /** Where the docs are and where they are served. */
40
+ export interface DocsResolvedConfig {
41
+ /** Where the docs are mounted, without a trailing slash. Empty at the root. */
42
+ base: string
43
+ /** `docsDir` as configured, relative to the project. */
44
+ dir: string
45
+ /** Whether `dir` holds pages: always without sections; with them, only
46
+ * when set, for the pages outside every section. */
47
+ dirInUse: boolean
48
+ /** Empty when the docs are not split into sections. */
49
+ sections: DocsSection[]
50
+ /** Edit url of the pages in `dir`, `:path` standing for the file. */
51
+ editBase: string | null
52
+ }
53
+
54
+ export interface DocsSection {
55
+ /** The url segment. */
56
+ key: string
57
+ label: string
58
+ /** As configured, relative to the project. */
59
+ dir: string
60
+ editBase: string | null
61
+ }
62
+
63
+ /** A directory of markdown, and the section it is when the docs have sections. */
64
+ export interface DocsRoot {
65
+ /** Absolute. */
66
+ dir: string
67
+ section?: string
68
+ }
69
+
70
+ /** Empty at the root, otherwise one leading slash and no trailing one. */
71
+ function resolveEditBase(editLink: DocsConfig['editLink'], repo: RepoInfo | null, docsDir: string): string | null {
72
+ if (typeof editLink === 'string') return editLink
73
+ if (!editLink || !repo) return null
74
+ return editBaseFrom(repo, docsDir)
75
+ }
76
+
77
+ function resolveSections(config: DocsConfig, repo: RepoInfo | null): DocsSection[] {
78
+ return Object.entries(config.sections ?? {}).map(([key, section]) => ({
79
+ key,
80
+ label: section.label ?? slugToTitle(key),
81
+ dir: section.dir,
82
+ editBase: resolveEditBase(config.editLink, repo, section.dir),
83
+ }))
84
+ }
85
+
86
+ /** No directory may be, or be inside, another. */
87
+ function assertSeparateRoots(roots: DocsRoot[], cwd: string): void {
88
+ const paths = roots.map((root) => root.dir + sep)
89
+ const nested = roots.filter((_, at) => paths.some((other, from) => from !== at && paths[at].startsWith(other)))
90
+ if (nested.length === 0) return
91
+
92
+ throw new Error(
93
+ 'jaad: sections overlap\n' +
94
+ nested
95
+ .map((root) => ` ${root.section ?? 'docsDir'}: ${relative(cwd, root.dir)} is, or is inside, another`)
96
+ .join('\n') +
97
+ '\nGive every section, and docsDir, a directory of its own.',
98
+ )
99
+ }
100
+
101
+ /**
102
+ * The docs, and the locale codes their directories are written in.
103
+ * `docsDirSet` tells whether the user set `docsDir`: alongside sections, only
104
+ * then does it hold pages.
105
+ */
106
+ export function resolveDocsConfig(
107
+ config: DocsConfig,
108
+ lang: string,
109
+ docsDirSet: boolean,
110
+ repo: RepoInfo | null,
111
+ cwd: string,
112
+ ): { docs: DocsResolvedConfig; locales: string[] } {
113
+ const sections = resolveSections(config, repo)
114
+ const docs: DocsResolvedConfig = {
115
+ base: normaliseBasePath(config.routeBase),
116
+ dir: config.docsDir,
117
+ dirInUse: sections.length === 0 || docsDirSet,
118
+ sections,
119
+ editBase: resolveEditBase(config.editLink, repo, config.docsDir),
120
+ }
121
+
122
+ const roots = docsRoots(docs, cwd)
123
+ assertSeparateRoots(roots, cwd)
124
+ const locales = resolveLocales(config, lang, roots)
125
+
126
+ const ambiguous = sections.find((section) => locales.includes(section.key))
127
+ if (ambiguous) {
128
+ throw new Error(`jaad: section "${ambiguous.key}" has the name of a language the docs are written in`)
129
+ }
130
+
131
+ return { docs, locales }
132
+ }
133
+
134
+ /** Where the markdown lives: `dir` when in use, then the directory of every section. */
135
+ export function docsRoots(docs: Pick<DocsResolvedConfig, 'dir' | 'dirInUse' | 'sections'>, cwd: string): DocsRoot[] {
136
+ return [
137
+ ...(docs.dirInUse ? [{ dir: resolve(cwd, docs.dir) }] : []),
138
+ ...docs.sections.map((section) => ({ dir: resolve(cwd, section.dir), section: section.key })),
139
+ ]
140
+ }
141
+
142
+ /** Directory names win; `locales` only overrides what detection found. */
143
+ function resolveLocales(config: DocsConfig, lang: string, roots: DocsRoot[]): string[] {
144
+ if (config.locales === false) return []
145
+
146
+ const perRoot = roots.map((root) => resolveLocalesIn(root.dir, config, lang))
147
+ const [first, ...rest] = perRoot
148
+ const differing = rest.find((locales) => locales.join() !== first.join())
149
+ if (differing) {
150
+ throw new Error(
151
+ 'jaad: sections disagree on their languages\n' +
152
+ ` found ${first.join(', ') || 'none'} and ${differing.join(', ') || 'none'}\n` +
153
+ 'Give every section the same locale directories, or none.',
154
+ )
155
+ }
156
+ return first
157
+ }
158
+
159
+ function resolveLocalesIn(docsDir: string, config: DocsConfig, lang: string): string[] {
160
+ const locales = Array.isArray(config.locales) ? config.locales : detectLocales(docsDir, lang)
161
+ // A pinned list skips detection, and with it the missing-directory guard;
162
+ // the integration warns about that case with better words than readdir does.
163
+ if (locales.length === 0 || !existsSync(docsDir)) return []
164
+
165
+ validateLocaleTree(docsDir, locales, lang)
166
+ return locales.map((locale) => locale.toLowerCase()).toSorted()
167
+ }
@@ -0,0 +1,36 @@
1
+ import { frontmatterFields } from '@jaamd/astro/frontmatter'
2
+ import { glob } from 'astro/loaders'
3
+ import { z } from 'astro/zod'
4
+ import { defineCollection } from 'astro:content'
5
+ import config from 'virtual:jaad/config'
6
+ import { collectionIdFor, collectionName } from './model/ids.ts'
7
+
8
+ const LOCALES = config.locales.map((locale) => locale.code)
9
+
10
+ /** The markdown of one directory, as the pages of `section` when there is one. */
11
+ const docs = (base: string, section?: string) =>
12
+ defineCollection({
13
+ loader: glob({
14
+ pattern: '**/*.md',
15
+ base,
16
+ // Keep URLs file-driven even if markdown contains an unrelated slug field.
17
+ generateId: ({ entry }) => collectionIdFor(entry, section, LOCALES),
18
+ }),
19
+ // Every field is optional: markdown without frontmatter works as-is.
20
+ schema: z.object({
21
+ ...frontmatterFields(z),
22
+ /** Shorter label, in navigation only. */
23
+ label: z.string().optional(),
24
+ /** Pairs this page with its translations when the slugs differ. */
25
+ translationKey: z.string().optional(),
26
+ /** Replaces the filename prefix number. */
27
+ order: z.number().int().optional(),
28
+ }),
29
+ })
30
+
31
+ /** `docsPages` for `docsDir`, and one collection per section: each watches its
32
+ * own directory. */
33
+ export const collections = Object.fromEntries([
34
+ ...(config.docs.dirInUse ? [[collectionName(), docs(config.docs.dir)]] : []),
35
+ ...config.docs.sections.map((section) => [collectionName(section.key), docs(section.dir, section.key)]),
36
+ ])
@@ -0,0 +1,89 @@
1
+ import type { DocsPage } from './model/tree.ts'
2
+ import config from 'virtual:jaad/config'
3
+ import { getDocsTree } from './collection.ts'
4
+ import { formatChapterTitle } from './model/ids.ts'
5
+ import { pageDescription } from './model/rendered.ts'
6
+ import { SECTIONS, docsUrls } from './runtime.ts'
7
+ import { buildSearchIndex } from './search-index.ts'
8
+ import { docsMarkdownHref } from './urls.ts'
9
+
10
+ export async function searchIndexResponse(locale?: string): Promise<Response> {
11
+ const index = buildSearchIndex(await getDocsTree(locale), SECTIONS)
12
+
13
+ return new Response(JSON.stringify(index), {
14
+ headers: { 'Content-Type': 'application/json; charset=utf-8' },
15
+ })
16
+ }
17
+
18
+ interface Row {
19
+ title: string
20
+ slug: string
21
+ description: string | null
22
+ }
23
+
24
+ /** Plain-text index of every page, per the llms.txt convention. */
25
+ export async function llmsResponse(site: URL | undefined, locale?: string): Promise<Response> {
26
+ const { pages: sortedPages } = await getDocsTree(locale)
27
+
28
+ const markdownHref = (slug: string): string => {
29
+ const href = docsMarkdownHref(slug, docsUrls(locale))
30
+ return site ? new URL(href, site.origin).href : href
31
+ }
32
+
33
+ const toLine = (row: Row): string =>
34
+ `- [${row.title}](${markdownHref(row.slug)})${row.description ? `: ${row.description}` : ''}`
35
+
36
+ /** Standalone pages first, then one heading per chapter. */
37
+ const outline = (pages: DocsPage[], heading: string): string[] => {
38
+ const standalone: Row[] = []
39
+ const chapters = new Map<string, Row[]>()
40
+
41
+ for (const page of pages) {
42
+ const { chapter, title } = page
43
+ const row: Row = {
44
+ title,
45
+ slug: page.slug,
46
+ description: page.data.description ?? pageDescription(page.source.rendered, title),
47
+ }
48
+
49
+ if (!chapter) {
50
+ standalone.push(row)
51
+ continue
52
+ }
53
+
54
+ const rows = chapters.get(chapter) ?? []
55
+ if (rows.length === 0) chapters.set(chapter, rows)
56
+ rows.push(row)
57
+ }
58
+
59
+ return [
60
+ ...(standalone.length > 0 ? [standalone.map(toLine).join('\n')] : []),
61
+ ...[...chapters].map(
62
+ ([chapter, rows]) =>
63
+ `${heading} ${formatChapterTitle(chapter) ?? chapter}\n\n${rows.map(toLine).join('\n')}`,
64
+ ),
65
+ ]
66
+ }
67
+
68
+ // With sections, the pages outside them come first; each section is a
69
+ // heading, and its chapters sit one level below.
70
+ const loose = sortedPages.filter((page) => !page.section)
71
+ const sections: string[] =
72
+ SECTIONS.length === 0
73
+ ? outline(sortedPages, '##')
74
+ : [
75
+ ...(loose.length > 0 ? outline(loose, '##') : []),
76
+ ...SECTIONS.flatMap((section) => {
77
+ const pages = sortedPages.filter((page) => page.section === section.key)
78
+ return pages.length > 0 ? [`## ${section.label}`, ...outline(pages, '###')] : []
79
+ }),
80
+ ]
81
+
82
+ const content = [`# ${config.title}`, ...(config.description ? [`> ${config.description}`] : []), ...sections].join(
83
+ '\n\n',
84
+ )
85
+
86
+ return new Response(content + '\n', {
87
+ headers: { 'Content-Type': 'text/plain; charset=utf-8' },
88
+ })
89
+ }
@@ -0,0 +1,249 @@
1
+ import type { JaadResolvedConfig } from '../config.ts'
2
+ import type { DocsRoot } from './config.ts'
3
+ import type { AstroIntegration } from 'astro'
4
+ import { existsSync, readFileSync, readdirSync } from 'node:fs'
5
+ import { join, relative } from 'node:path'
6
+ import { fileURLToPath, pathToFileURL } from 'node:url'
7
+ import { prefixedLocales } from '../site/locales.ts'
8
+ import { normaliseBasePath } from '../site/urls.ts'
9
+ import { docsRoots } from './config.ts'
10
+ import { rehypeDocLinks, satteriDocLinks } from './link-plugins.ts'
11
+ import { getDocCollectionId, splitDocLocale } from './model/ids.ts'
12
+ import { buildDocsTree, type DocsSource } from './model/tree.ts'
13
+ import { docsPageHref, type DocsUrlOptions } from './urls.ts'
14
+
15
+ type SetupParams = Parameters<NonNullable<AstroIntegration['hooks']['astro:config:setup']>>[0]
16
+
17
+ const DOCS_FRAME_ID = 'virtual:jaad/docs-frame'
18
+ const RESOLVED_DOCS_FRAME = '\0' + DOCS_FRAME_ID
19
+
20
+ /** The frame around every docs page: the project's own, or JAAD's. */
21
+ function docsFrameVirtualPlugin(docsFrame: () => string) {
22
+ return {
23
+ name: 'jaad:docs-frame',
24
+ resolveId: (source: string) => (source === DOCS_FRAME_ID ? RESOLVED_DOCS_FRAME : null),
25
+ load: (id: string) =>
26
+ id === RESOLVED_DOCS_FRAME ? `export { default } from ${JSON.stringify(docsFrame())};` : null,
27
+ }
28
+ }
29
+
30
+ /** A named url that redirects to its page's canonical one. */
31
+ export interface DocsAlias {
32
+ locale?: string
33
+ slug: string
34
+ }
35
+
36
+ export interface IntegrationState {
37
+ deploymentBase: string
38
+ aliases: DocsAlias[]
39
+ docsFrame: string
40
+ }
41
+
42
+ const INDEX_PAGE_NAMES = ['index.astro', 'index.md', 'index.mdx', 'index.html', 'index.js', 'index.ts']
43
+
44
+ const NOT_FOUND_PAGE_NAMES = ['404.astro', '404.md', '404.mdx', '404.html', '404.js', '404.ts']
45
+
46
+ /** A plugin this integration registered on an earlier setup run. */
47
+ const isDocLinks = (plugin: unknown): boolean =>
48
+ (typeof plugin === 'function' || (typeof plugin === 'object' && plugin !== null)) &&
49
+ ['jaad-doc-links', 'jaadDocLinks'].includes((plugin as { name?: string }).name ?? '')
50
+
51
+ const routeEntrypoint = (file: string) => fileURLToPath(new URL(`./routes/${file}`, import.meta.url))
52
+
53
+ export function shouldInjectNotFound(docsBase: string, srcDir: URL): boolean {
54
+ return docsBase === '' && !NOT_FOUND_PAGE_NAMES.some((name) => existsSync(new URL(`pages/${name}`, srcDir)))
55
+ }
56
+
57
+ /** The two fields that move the first page of the docs or of a section. */
58
+ function readFrontmatter(source: string): { order?: number; draft?: boolean } {
59
+ const block = /^---\r?\n([\s\S]*?)\r?\n---/.exec(source)
60
+ if (!block) return {}
61
+
62
+ const order = /^order:\s*(-?\d+)\s*$/m.exec(block[1])
63
+ const draft = /^draft:\s*(true|false)\s*$/m.exec(block[1])
64
+ return {
65
+ order: order ? Number(order[1]) : undefined,
66
+ draft: draft ? draft[1] === 'true' : undefined,
67
+ }
68
+ }
69
+
70
+ /** The aliases of every locale. The content collection is not available this
71
+ * early, so the pages are read from disk. */
72
+ export function findAliases(roots: DocsRoot[], locales: string[] = []): DocsAlias[] {
73
+ const sources: DocsSource[] = []
74
+
75
+ const visit = (root: DocsRoot, current: string) => {
76
+ for (const entry of readdirSync(current, { withFileTypes: true }).toSorted((a, b) =>
77
+ a.name.localeCompare(b.name),
78
+ )) {
79
+ const file = join(current, entry.name)
80
+ if (entry.isDirectory()) visit(root, file)
81
+ else if (entry.isFile() && entry.name.endsWith('.md')) {
82
+ const front = readFrontmatter(readFileSync(file, 'utf8'))
83
+ if (front.draft) continue
84
+
85
+ const { locale, id } = splitDocLocale(getDocCollectionId(relative(root.dir, file)), locales)
86
+ sources.push({
87
+ id,
88
+ path: id,
89
+ locale: locale ?? undefined,
90
+ section: root.section,
91
+ data: { order: front.order },
92
+ })
93
+ }
94
+ }
95
+ }
96
+ for (const root of roots) {
97
+ if (existsSync(root.dir)) visit(root, root.dir)
98
+ }
99
+
100
+ const sections = roots.flatMap((root) => root.section ?? [])
101
+ return (locales.length > 0 ? locales : [undefined]).flatMap((locale) => {
102
+ // The slugs are all that is read: the urls need no mount.
103
+ const tree = buildDocsTree(
104
+ sources.filter((source) => source.locale === locale),
105
+ { sections, urls: { docsBase: '' } },
106
+ )
107
+ return tree.pages.flatMap((page) => page.aliases.map((slug) => ({ locale, slug })))
108
+ })
109
+ }
110
+
111
+ export function getInjectedRoutes(docsBase: string, locales: string[] = []): [string, string][] {
112
+ // `[...slug]` already absorbs "it/guide" and "it". Only the two fixed
113
+ // endpoints need a route of their own, and `[locale]` hands them the code.
114
+ const perLocale: [string, string][] =
115
+ locales.length > 0
116
+ ? [
117
+ [`${docsBase}/[locale]/search-index.json`, 'search-index-locale.json.ts'],
118
+ [`${docsBase}/[locale]/llms.txt`, 'llms-locale.txt.ts'],
119
+ ]
120
+ : []
121
+
122
+ return [
123
+ [docsBase, 'docs-index.astro'],
124
+ [`${docsBase}/search-index.json`, 'search-index.json.ts'],
125
+ [`${docsBase}/llms.txt`, 'llms.txt.ts'],
126
+ ...perLocale,
127
+ [`${docsBase}/[...slug]`, 'docs-slug.astro'],
128
+ [`${docsBase}/[...slug].md`, 'docs-slug.md.ts'],
129
+ ]
130
+ }
131
+
132
+ export function createIntegrationState(): IntegrationState {
133
+ return {
134
+ deploymentBase: '',
135
+ aliases: [],
136
+ docsFrame: fileURLToPath(new URL('./layouts/DefaultDocsFrame.astro', import.meta.url)),
137
+ }
138
+ }
139
+
140
+ /** Routes, the docs frame, the 404 page, aliases and the link plugins. */
141
+ export function setupDocs(
142
+ { config: astroConfig, updateConfig, injectRoute, addWatchFile, logger }: SetupParams,
143
+ config: JaadResolvedConfig,
144
+ state: IntegrationState,
145
+ discoverAliases: boolean,
146
+ ): void {
147
+ updateConfig({ vite: { plugins: [docsFrameVirtualPlugin(() => state.docsFrame)] } })
148
+
149
+ for (const [pattern, entrypoint] of getInjectedRoutes(
150
+ config.docs.base,
151
+ prefixedLocales(config.locales, config.defaultLocale),
152
+ )) {
153
+ injectRoute({
154
+ pattern,
155
+ entrypoint: routeEntrypoint(entrypoint),
156
+ prerender: true,
157
+ })
158
+ }
159
+
160
+ const customDocsFrame = new URL('jaad/DocsFrame.astro', astroConfig.srcDir)
161
+ if (existsSync(customDocsFrame)) {
162
+ state.docsFrame = fileURLToPath(customDocsFrame)
163
+ addWatchFile(customDocsFrame)
164
+ }
165
+
166
+ if (shouldInjectNotFound(config.docs.base, astroConfig.srcDir)) {
167
+ injectRoute({
168
+ pattern: '/404',
169
+ entrypoint: routeEntrypoint('404.astro'),
170
+ prerender: true,
171
+ })
172
+ }
173
+
174
+ const root = fileURLToPath(astroConfig.root)
175
+ const roots = docsRoots(config.docs, root)
176
+ const locales = config.locales.map((locale) => locale.code)
177
+
178
+ const missing = roots.filter(({ dir }) => !existsSync(dir))
179
+ for (const { dir } of missing) {
180
+ logger.warn(
181
+ `${relative(root, dir)} does not exist, so it adds no pages. ` +
182
+ 'Create it, or point docsDir or the section at your markdown.',
183
+ )
184
+ }
185
+ if (discoverAliases && missing.length < roots.length) {
186
+ state.aliases = findAliases(roots, locales)
187
+ }
188
+ for (const { dir } of roots) addWatchFile(pathToFileURL(dir))
189
+
190
+ const links = {
191
+ roots,
192
+ locales,
193
+ urls: (locale?: string): DocsUrlOptions => ({
194
+ docsBase: config.docs.base,
195
+ deploymentBase: normaliseBasePath(astroConfig.base),
196
+ locale,
197
+ defaultLocale: config.defaultLocale,
198
+ }),
199
+ }
200
+ // New arrays: a shared processor would stack duplicates across setup runs.
201
+ const processor = astroConfig.markdown.processor as {
202
+ name: string
203
+ options: { hastPlugins?: unknown[]; rehypePlugins?: unknown[] }
204
+ }
205
+ if (processor.name === 'satteri') {
206
+ processor.options.hastPlugins = [
207
+ ...(processor.options.hastPlugins ?? []).filter((plugin) => !isDocLinks(plugin)),
208
+ satteriDocLinks(links),
209
+ ]
210
+ } else if (processor.name === 'unified') {
211
+ processor.options.rehypePlugins = [
212
+ ...(processor.options.rehypePlugins ?? []).filter((plugin) => !isDocLinks(plugin)),
213
+ rehypeDocLinks(links),
214
+ ]
215
+ }
216
+
217
+ if (config.docs.base === '') {
218
+ const ownIndex = INDEX_PAGE_NAMES.find((name) => existsSync(new URL(`pages/${name}`, astroConfig.srcDir)))
219
+ if (ownIndex) {
220
+ logger.warn(
221
+ `${ownIndex} owns the site root while JAAD docs are mounted there. ` +
222
+ 'Set routeBase: "/docs" to keep a landing page and mount the docs below it.',
223
+ )
224
+ }
225
+ }
226
+ }
227
+
228
+ const withoutTrailingSlash = (value: string) => (value.length > 1 ? value.replace(/\/$/, '') : value)
229
+
230
+ export function createSitemapFilter(
231
+ docsBase: string,
232
+ state: Pick<IntegrationState, 'deploymentBase' | 'aliases'>,
233
+ defaultLocale?: string,
234
+ ): (page: string) => boolean {
235
+ return (page) => {
236
+ if (state.aliases.length === 0) return true
237
+
238
+ const pathname = withoutTrailingSlash(new URL(page).pathname)
239
+ return !state.aliases.some((alias) => {
240
+ const urls: DocsUrlOptions = {
241
+ docsBase,
242
+ deploymentBase: state.deploymentBase,
243
+ locale: alias.locale,
244
+ defaultLocale,
245
+ }
246
+ return pathname === withoutTrailingSlash(docsPageHref(alias.slug, urls))
247
+ })
248
+ }
249
+ }
@@ -0,0 +1,33 @@
1
+ ---
2
+ import type { DocsFrameProps } from '../advanced.ts'
3
+
4
+ type Props = DocsFrameProps
5
+ ---
6
+
7
+ <div data-jaad-default-frame class="docs-frame">
8
+ <slot name="header" />
9
+
10
+ <div class="docs-container">
11
+ <slot name="sidebar" />
12
+
13
+ <div class="docs-main">
14
+ <main class="docs-page-main">
15
+ <slot name="mobile-navigation" />
16
+
17
+ <article>
18
+ <slot name="breadcrumbs" />
19
+ <slot name="content" />
20
+ </article>
21
+
22
+ <slot name="page-navigation" />
23
+ </main>
24
+ </div>
25
+
26
+ <aside class="docs-sidebar-right jaad-sidebar">
27
+ <slot name="table-of-contents" />
28
+ <slot name="page-actions" />
29
+ </aside>
30
+
31
+ <slot name="footer" />
32
+ </div>
33
+ </div>