@jaamd/jaad 0.0.0-stage → 1.0.0-rc.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +58 -2
- package/dist/jaad.css +1331 -0
- package/docs/00-overview.md +25 -0
- package/docs/01-getting-started/01-installation.md +216 -0
- package/docs/01-getting-started/02-pages.md +109 -0
- package/docs/01-getting-started/03-deployment.md +100 -0
- package/docs/02-configurations/01-reference.md +215 -0
- package/docs/02-configurations/02-frontmatter.md +105 -0
- package/docs/02-configurations/03-i18n.md +210 -0
- package/docs/02-configurations/04-search.md +58 -0
- package/docs/02-configurations/05-themes.md +104 -0
- package/docs/02-configurations/06-styles.md +156 -0
- package/docs/02-configurations/07-custom-pages.md +119 -0
- package/docs/02-configurations/08-advanced-layout.md +139 -0
- package/docs/03-upgrading.md +208 -0
- package/index.ts +31 -0
- package/package.json +71 -3
- package/site.ts +36 -0
- package/src/config.ts +85 -0
- package/src/docs/advanced.ts +23 -0
- package/src/docs/collection.ts +109 -0
- package/src/docs/components/Breadcrumbs.astro +29 -0
- package/src/docs/components/DocsMobileNav.astro +90 -0
- package/src/docs/components/DocsNavTree.astro +57 -0
- package/src/docs/components/DocsPage.astro +66 -0
- package/src/docs/components/DocsPageNav.astro +53 -0
- package/src/docs/components/DocsSidebarLeft.astro +53 -0
- package/src/docs/components/PageActions.astro +83 -0
- package/src/docs/components/StructuredData.astro +55 -0
- package/src/docs/components/TableOfContents.astro +71 -0
- package/src/docs/config.ts +167 -0
- package/src/docs/content.ts +36 -0
- package/src/docs/endpoints.ts +89 -0
- package/src/docs/integration.ts +249 -0
- package/src/docs/layouts/DefaultDocsFrame.astro +33 -0
- package/src/docs/layouts/Docs.astro +169 -0
- package/src/docs/layouts/Page.astro +18 -0
- package/src/docs/link-plugins.ts +52 -0
- package/src/docs/links.ts +42 -0
- package/src/docs/locale-tree.ts +51 -0
- package/src/docs/model/ids.ts +87 -0
- package/src/docs/model/rendered.ts +185 -0
- package/src/docs/model/tree.ts +124 -0
- package/src/docs/model/validate.ts +75 -0
- package/src/docs/navigation.ts +75 -0
- package/src/docs/routes/404.astro +16 -0
- package/src/docs/routes/docs-index.astro +19 -0
- package/src/docs/routes/docs-slug.astro +54 -0
- package/src/docs/routes/docs-slug.md.ts +35 -0
- package/src/docs/routes/llms-locale.txt.ts +8 -0
- package/src/docs/routes/llms.txt.ts +4 -0
- package/src/docs/routes/search-index-locale.json.ts +8 -0
- package/src/docs/routes/search-index.json.ts +4 -0
- package/src/docs/runtime.ts +17 -0
- package/src/docs/search-index.ts +39 -0
- package/src/docs/styles/components/breadcrumbs.css +26 -0
- package/src/docs/styles/components/docs-mobile-nav.css +47 -0
- package/src/docs/styles/components/docs-page-nav.css +41 -0
- package/src/docs/styles/components/docs-page.css +9 -0
- package/src/docs/styles/components/docs-sidebar.css +59 -0
- package/src/docs/styles/components/page-actions.css +51 -0
- package/src/docs/styles/components/toc.css +38 -0
- package/src/docs/styles/components.css +7 -0
- package/src/docs/styles/docs.css +81 -0
- package/src/docs/styles/jaamd.css +11 -0
- package/src/docs/types.ts +39 -0
- package/src/docs/urls.ts +35 -0
- package/src/env.d.ts +13 -0
- package/src/integration.ts +36 -0
- package/src/site/components/Footer.astro +18 -0
- package/src/site/components/Head.astro +119 -0
- package/src/site/components/Header.astro +50 -0
- package/src/site/components/LocaleSwitcher.astro +111 -0
- package/src/site/components/Nav.astro +22 -0
- package/src/site/components/NavMobile.astro +105 -0
- package/src/site/components/SearchPalette.astro +409 -0
- package/src/site/components/SocialLinks.astro +27 -0
- package/src/site/components/ThemeSwitcher.astro +55 -0
- package/src/site/components/locale-switcher.ts +6 -0
- package/src/site/config.ts +149 -0
- package/src/site/fonts/OFL.txt +94 -0
- package/src/site/fonts/inter.woff2 +0 -0
- package/src/site/fonts/merriweather-italic.woff2 +0 -0
- package/src/site/fonts/merriweather.woff2 +0 -0
- package/src/site/fonts.ts +29 -0
- package/src/site/forges.ts +60 -0
- package/src/site/helpers.ts +4 -0
- package/src/site/infer.ts +75 -0
- package/src/site/integration.ts +59 -0
- package/src/site/layouts/Shell.astro +41 -0
- package/src/site/locales.ts +199 -0
- package/src/site/runtime.ts +19 -0
- package/src/site/search.ts +227 -0
- package/src/site/social.ts +44 -0
- package/src/site/styles/components/footer.css +8 -0
- package/src/site/styles/components/header.css +31 -0
- package/src/site/styles/components/locale-switcher.css +20 -0
- package/src/site/styles/components/nav-mobile.css +15 -0
- package/src/site/styles/components/nav.css +11 -0
- package/src/site/styles/components/not-found.css +11 -0
- package/src/site/styles/components/search.css +118 -0
- package/src/site/styles/components/shell.css +77 -0
- package/src/site/styles/components/social-links.css +8 -0
- package/src/site/styles/components/theme-switcher.css +14 -0
- package/src/site/styles/components.css +10 -0
- package/src/site/styles/global.css +140 -0
- package/src/site/styles/jaamd-forward.css +16 -0
- package/src/site/styles/jaamd-reverse.css +18 -0
- package/src/site/styles/tailwind.css +30 -0
- package/src/site/themes.ts +26 -0
- package/src/site/ui/en.ts +27 -0
- package/src/site/ui/it.ts +28 -0
- package/src/site/ui.ts +49 -0
- package/src/site/urls.ts +14 -0
- package/src/site/virtual.ts +42 -0
- package/src/styles.css +5 -0
|
@@ -0,0 +1,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>
|