@jaamd/jaad 0.11.0 → 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 (138) hide show
  1. package/README.md +8 -2
  2. package/dist/jaad.css +232 -347
  3. package/docs/00-overview.md +25 -0
  4. package/docs/01-getting-started/01-installation.md +216 -0
  5. package/docs/01-getting-started/02-pages.md +109 -0
  6. package/docs/01-getting-started/03-deployment.md +100 -0
  7. package/docs/02-configurations/01-reference.md +215 -0
  8. package/docs/02-configurations/02-frontmatter.md +105 -0
  9. package/docs/02-configurations/03-i18n.md +210 -0
  10. package/docs/02-configurations/04-search.md +58 -0
  11. package/docs/02-configurations/05-themes.md +104 -0
  12. package/docs/02-configurations/06-styles.md +156 -0
  13. package/docs/02-configurations/07-custom-pages.md +119 -0
  14. package/docs/02-configurations/08-advanced-layout.md +139 -0
  15. package/docs/03-upgrading.md +208 -0
  16. package/index.ts +8 -22
  17. package/package.json +10 -9
  18. package/site.ts +18 -1
  19. package/src/config.ts +25 -176
  20. package/src/{advanced.ts → docs/advanced.ts} +6 -4
  21. package/src/docs/collection.ts +109 -0
  22. package/src/docs/components/Breadcrumbs.astro +29 -0
  23. package/src/docs/components/DocsMobileNav.astro +90 -0
  24. package/src/docs/components/DocsNavTree.astro +57 -0
  25. package/src/docs/components/DocsPage.astro +66 -0
  26. package/src/{components/docs → docs/components}/DocsPageNav.astro +4 -4
  27. package/src/docs/components/DocsSidebarLeft.astro +53 -0
  28. package/src/{components/docs → docs/components}/PageActions.astro +10 -8
  29. package/src/{components/docs → docs/components}/StructuredData.astro +3 -3
  30. package/src/{components/docs → docs/components}/TableOfContents.astro +20 -22
  31. package/src/docs/config.ts +167 -0
  32. package/src/docs/content.ts +36 -0
  33. package/src/docs/endpoints.ts +89 -0
  34. package/src/docs/integration.ts +249 -0
  35. package/src/{layouts → docs/layouts}/DefaultDocsFrame.astro +7 -5
  36. package/src/{layouts → docs/layouts}/Docs.astro +44 -43
  37. package/src/docs/layouts/Page.astro +18 -0
  38. package/src/docs/link-plugins.ts +52 -0
  39. package/src/docs/links.ts +42 -0
  40. package/src/{locale-tree.ts → docs/locale-tree.ts} +1 -1
  41. package/src/docs/model/ids.ts +87 -0
  42. package/src/docs/model/rendered.ts +185 -0
  43. package/src/docs/model/tree.ts +124 -0
  44. package/src/docs/model/validate.ts +75 -0
  45. package/src/docs/navigation.ts +75 -0
  46. package/src/{routes → docs/routes}/404.astro +1 -1
  47. package/src/docs/routes/docs-index.astro +19 -0
  48. package/src/docs/routes/docs-slug.astro +54 -0
  49. package/src/{routes → docs/routes}/docs-slug.md.ts +8 -10
  50. package/src/{routes → docs/routes}/llms-locale.txt.ts +1 -1
  51. package/src/{routes → docs/routes}/llms.txt.ts +1 -1
  52. package/src/{routes → docs/routes}/search-index-locale.json.ts +1 -1
  53. package/src/{routes → docs/routes}/search-index.json.ts +1 -1
  54. package/src/docs/runtime.ts +17 -0
  55. package/src/docs/search-index.ts +39 -0
  56. package/src/docs/styles/components/breadcrumbs.css +26 -0
  57. package/src/docs/styles/components/docs-mobile-nav.css +47 -0
  58. package/src/docs/styles/components/docs-page-nav.css +41 -0
  59. package/src/docs/styles/components/docs-sidebar.css +59 -0
  60. package/src/docs/styles/components/page-actions.css +51 -0
  61. package/src/docs/styles/components/toc.css +38 -0
  62. package/src/docs/styles/components.css +7 -0
  63. package/src/docs/styles/docs.css +81 -0
  64. package/src/{@types/docs.ts → docs/types.ts} +8 -16
  65. package/src/{urls.ts → docs/urls.ts} +2 -16
  66. package/src/env.d.ts +1 -1
  67. package/src/integration.ts +15 -249
  68. package/src/{components → site/components}/Footer.astro +1 -1
  69. package/src/{components → site/components}/Header.astro +14 -11
  70. package/src/{components → site/components}/LocaleSwitcher.astro +2 -2
  71. package/src/{components → site/components}/NavMobile.astro +1 -1
  72. package/src/{components → site/components}/SearchPalette.astro +160 -79
  73. package/src/{components → site/components}/ThemeSwitcher.astro +1 -1
  74. package/src/site/config.ts +149 -0
  75. package/src/{infer.ts → site/infer.ts} +3 -3
  76. package/src/site/integration.ts +59 -0
  77. package/src/{layouts/Page.astro → site/layouts/Shell.astro} +6 -3
  78. package/src/{locales.ts → site/locales.ts} +9 -1
  79. package/src/{docs-config.ts → site/runtime.ts} +1 -12
  80. package/src/site/search.ts +227 -0
  81. package/src/{social.ts → site/social.ts} +2 -2
  82. package/src/site/styles/components/header.css +31 -0
  83. package/src/{styles → site/styles}/components/search.css +21 -1
  84. package/src/{styles → site/styles}/components/shell.css +4 -0
  85. package/src/site/styles/components.css +10 -0
  86. package/src/{styles → site/styles}/global.css +4 -26
  87. package/src/{themes/index.ts → site/themes.ts} +1 -1
  88. package/src/{ui → site/ui}/en.ts +3 -5
  89. package/src/{ui → site/ui}/it.ts +2 -4
  90. package/src/site/urls.ts +14 -0
  91. package/src/{virtual.ts → site/virtual.ts} +12 -18
  92. package/src/styles.css +5 -0
  93. package/src/collection.ts +0 -97
  94. package/src/components/ScrollToTop.astro +0 -43
  95. package/src/components/docs/Breadcrumbs.astro +0 -36
  96. package/src/components/docs/DocsMobileNav.astro +0 -93
  97. package/src/components/docs/DocsPage.astro +0 -68
  98. package/src/components/docs/DocsSidebarLeft.astro +0 -82
  99. package/src/content.ts +0 -42
  100. package/src/navigation.ts +0 -97
  101. package/src/routes/docs-index.astro +0 -19
  102. package/src/routes/docs-slug.astro +0 -58
  103. package/src/styles/components/breadcrumbs.css +0 -13
  104. package/src/styles/components/docs-mobile-nav.css +0 -9
  105. package/src/styles/components/docs-page-nav.css +0 -31
  106. package/src/styles/components/docs-sidebar.css +0 -42
  107. package/src/styles/components/header.css +0 -20
  108. package/src/styles/components/page-actions.css +0 -13
  109. package/src/styles/components/scroll-to-top.css +0 -11
  110. package/src/styles/components/toc.css +0 -21
  111. package/src/styles/docs.css +0 -112
  112. package/src/utils/docs.ts +0 -300
  113. package/src/utils/endpoints.ts +0 -80
  114. package/src/utils/search.ts +0 -83
  115. /package/src/{styles → docs/styles}/components/docs-page.css +0 -0
  116. /package/src/{styles → docs/styles}/jaamd.css +0 -0
  117. /package/src/{components → site/components}/Head.astro +0 -0
  118. /package/src/{components → site/components}/Nav.astro +0 -0
  119. /package/src/{components → site/components}/SocialLinks.astro +0 -0
  120. /package/src/{components → site/components}/locale-switcher.ts +0 -0
  121. /package/src/{fonts → site/fonts}/OFL.txt +0 -0
  122. /package/src/{fonts → site/fonts}/inter.woff2 +0 -0
  123. /package/src/{fonts → site/fonts}/merriweather-italic.woff2 +0 -0
  124. /package/src/{fonts → site/fonts}/merriweather.woff2 +0 -0
  125. /package/src/{fonts.ts → site/fonts.ts} +0 -0
  126. /package/src/{forges.ts → site/forges.ts} +0 -0
  127. /package/src/{utils → site}/helpers.ts +0 -0
  128. /package/src/{styles → site/styles}/components/footer.css +0 -0
  129. /package/src/{styles → site/styles}/components/locale-switcher.css +0 -0
  130. /package/src/{styles → site/styles}/components/nav-mobile.css +0 -0
  131. /package/src/{styles → site/styles}/components/nav.css +0 -0
  132. /package/src/{styles → site/styles}/components/not-found.css +0 -0
  133. /package/src/{styles → site/styles}/components/social-links.css +0 -0
  134. /package/src/{styles → site/styles}/components/theme-switcher.css +0 -0
  135. /package/src/{styles → site/styles}/jaamd-forward.css +0 -0
  136. /package/src/{styles → site/styles}/jaamd-reverse.css +0 -0
  137. /package/src/{styles → site/styles}/tailwind.css +0 -0
  138. /package/src/{ui.ts → site/ui.ts} +0 -0
@@ -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
+ }
@@ -10,13 +10,12 @@ type Props = DocsFrameProps
10
10
  <div class="docs-container">
11
11
  <slot name="sidebar" />
12
12
 
13
- <div class="docs-main docs-main-gutters">
13
+ <div class="docs-main">
14
14
  <main class="docs-page-main">
15
15
  <slot name="mobile-navigation" />
16
16
 
17
17
  <article>
18
18
  <slot name="breadcrumbs" />
19
- <slot name="page-actions" />
20
19
  <slot name="content" />
21
20
  </article>
22
21
 
@@ -24,8 +23,11 @@ type Props = DocsFrameProps
24
23
  </main>
25
24
  </div>
26
25
 
27
- <slot name="table-of-contents" />
28
- </div>
26
+ <aside class="docs-sidebar-right jaad-sidebar">
27
+ <slot name="table-of-contents" />
28
+ <slot name="page-actions" />
29
+ </aside>
29
30
 
30
- <slot name="footer" />
31
+ <slot name="footer" />
32
+ </div>
31
33
  </div>
@@ -1,53 +1,53 @@
1
1
  ---
2
- import type { DocsEntry, DocsHeadings } from '../@types/docs.ts'
3
2
  import type { DocsFrameProps } from '../advanced.ts'
3
+ import type { DocsSourceEntry } from '../collection.ts'
4
+ import type { DocsPage } from '../model/tree.ts'
4
5
  import type { DocsNavigation } from '../navigation.ts'
6
+ import type { DocsHeading } from '../types.ts'
5
7
  import { ClientRouter } from 'astro:transitions'
6
8
  import config from 'virtual:jaad/config'
7
9
  import DocsFrame from 'virtual:jaad/docs-frame'
10
+ import Footer from '../../site/components/Footer.astro'
11
+ import Head from '../../site/components/Head.astro'
12
+ import Header from '../../site/components/Header.astro'
13
+ import { DEFAULT_LOCALE, LOCALES } from '../../site/runtime.ts'
8
14
  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'
15
+ import DocsSidebarLeft from '../components/DocsSidebarLeft.astro'
16
+ import StructuredData from '../components/StructuredData.astro'
17
+ import TableOfContents from '../components/TableOfContents.astro'
18
+ import { pageDescription } from '../model/rendered.ts'
19
+ import { docsUrls } from '../runtime.ts'
20
+ import { docsPageHref } from '../urls.ts'
21
+ import '../../../dist/jaad.css'
18
22
  import '../styles/docs.css'
19
23
  import '../styles/jaamd.css'
20
- import '@jaamd/jaamd/styles.css'
24
+ import '@jaamd/astro/styles.css'
21
25
  import 'virtual:jaad/theme.css'
22
26
  import 'virtual:jaad/bridge.css'
23
27
  import 'virtual:jaad/user.css'
24
28
  import 'virtual:jaad/tailwind.css'
25
29
 
26
30
  interface Props {
31
+ /** The `<title>` before the site name. Left out on the opening page. */
27
32
  title?: string
28
- chapter?: string
29
- entry?: DocsEntry
30
- headings?: DocsHeadings[]
33
+ page: DocsPage<DocsSourceEntry>
34
+ headings?: DocsHeading[]
31
35
  navigation: DocsNavigation
32
36
  }
33
37
 
34
- const { title, chapter, entry, headings = [], navigation } = Astro.props
38
+ const { title, page, headings = [], navigation } = Astro.props
35
39
 
36
40
  const pageTitle = title ? `${title} | ${config.title}` : config.title
37
41
 
38
- // The opening page gets no `title`; resolve it from the entry.
39
- const entryTitle = entry ? docTitle(entry) : (title ?? '')
42
+ const description =
43
+ page.data.description ?? pageDescription(page.source.rendered, page.title) ?? config.description ?? config.title
40
44
 
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
45
+ const locale = page.locale ?? DEFAULT_LOCALE
48
46
  const language = LOCALES.find((known) => known.code === locale)?.tag ?? config.lang
49
47
 
50
- const switcherAlternates = await getLocaleAlternates(entry ? docTranslationKey(entry) : '')
48
+ const indexUrl = docsPageHref('search-index.json', docsUrls(locale))
49
+
50
+ const switcherAlternates = await getLocaleAlternates(page.translationKey)
51
51
 
52
52
  // x-default is a landing place, so it takes the fallback when this page has no
53
53
  // translation into the default locale.
@@ -64,11 +64,12 @@ const alternates = switcherAlternates
64
64
 
65
65
  const frameProps: DocsFrameProps = {
66
66
  page: {
67
- id: entry?.id ?? '',
68
- slug: entry ? getCleanSlug(routedId(entry)) : '',
69
- title: entryTitle,
70
- chapter,
71
- data: entry?.data ?? { draft: false, keywords: [] },
67
+ id: page.id,
68
+ slug: page.slug,
69
+ title: page.title,
70
+ section: page.section,
71
+ chapter: page.chapter,
72
+ data: page.data,
72
73
  },
73
74
  headings,
74
75
  navigation,
@@ -80,22 +81,22 @@ const frameProps: DocsFrameProps = {
80
81
  <head>
81
82
  <Head
82
83
  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}
84
+ description={description}
85
+ keywords={page.data.keywords}
86
+ author={page.data.author}
87
+ image={page.data.ogImage}
88
+ modified={page.data.lastUpdated}
88
89
  language={language}
89
90
  {alternates}
90
91
  {xDefault}
91
92
  />
92
93
  <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}
94
+ title={page.title || config.title}
95
+ description={description}
96
+ chapter={page.chapter}
97
+ keywords={page.data.keywords}
98
+ author={page.data.author}
99
+ modified={page.data.lastUpdated}
99
100
  {language}
100
101
  {locale}
101
102
  />
@@ -103,8 +104,8 @@ const frameProps: DocsFrameProps = {
103
104
  </head>
104
105
  <body data-docs class="jaad-body">
105
106
  <DocsFrame {...frameProps}>
106
- <Header slot="header" inDocs {locale} alternates={switcherAlternates} />
107
- <DocsSidebarLeft slot="sidebar" sections={navigation.sections} {locale} />
107
+ <Header slot="header" {locale} alternates={switcherAlternates} search={{ indexUrl, prominent: true }} />
108
+ <DocsSidebarLeft slot="sidebar" groups={navigation.groups} {locale} />
108
109
  <slot name="mobile-navigation" slot="mobile-navigation" />
109
110
  <slot name="breadcrumbs" slot="breadcrumbs" />
110
111
  <slot name="page-actions" slot="page-actions" />
@@ -0,0 +1,18 @@
1
+ ---
2
+ import type { ComponentProps } from 'astro/types'
3
+ import Shell from '../../site/layouts/Shell.astro'
4
+ import { DEFAULT_LOCALE } from '../../site/runtime.ts'
5
+ import { getLocaleAlternates } from '../collection.ts'
6
+ import { docsUrls } from '../runtime.ts'
7
+ import { docsPageHref } from '../urls.ts'
8
+
9
+ type Props = Omit<ComponentProps<typeof Shell>, 'header'>
10
+
11
+ // A page of the project's own has no translations: offer each locale's opener.
12
+ const alternates = await getLocaleAlternates('')
13
+ const indexUrl = docsPageHref('search-index.json', docsUrls(DEFAULT_LOCALE))
14
+ ---
15
+
16
+ <Shell {...Astro.props} header={{ alternates, search: { indexUrl } }}>
17
+ <slot />
18
+ </Shell>
@@ -0,0 +1,52 @@
1
+ import type { DocLinkContext } from './links.ts'
2
+ import { fileURLToPath } from 'node:url'
3
+ import { resolveDocLink } from './links.ts'
4
+
5
+ interface HastNode {
6
+ type: string
7
+ tagName?: string
8
+ properties?: Record<string, unknown>
9
+ children?: HastNode[]
10
+ }
11
+
12
+ /** rehype: rewrites relative links to markdown files into page urls. */
13
+ export function rehypeDocLinks(context: DocLinkContext) {
14
+ return function jaadDocLinks() {
15
+ return (tree: HastNode, file: { path?: string }) => {
16
+ const from = file.path
17
+ if (!from) return
18
+
19
+ const visit = (node: HastNode) => {
20
+ const href = node.tagName === 'a' ? node.properties?.href : undefined
21
+ if (typeof href === 'string') {
22
+ const resolved = resolveDocLink(href, from, context)
23
+ if (resolved) node.properties!.href = resolved
24
+ }
25
+ node.children?.forEach(visit)
26
+ }
27
+ visit(tree)
28
+ }
29
+ }
30
+ }
31
+
32
+ interface SatteriContext {
33
+ fileURL: URL | undefined
34
+ setProperty(node: HastNode, name: string, value: string): void
35
+ }
36
+
37
+ /** Sätteri: rewrites relative links to markdown files into page urls. */
38
+ export function satteriDocLinks(context: DocLinkContext) {
39
+ return {
40
+ name: 'jaad-doc-links',
41
+ element: {
42
+ filter: ['a'],
43
+ visit(node: HastNode, ctx: SatteriContext) {
44
+ const href = node.properties?.href
45
+ if (typeof href !== 'string' || !ctx.fileURL) return
46
+
47
+ const resolved = resolveDocLink(href, fileURLToPath(ctx.fileURL), context)
48
+ if (resolved) ctx.setProperty(node, 'href', resolved)
49
+ },
50
+ },
51
+ }
52
+ }
@@ -0,0 +1,42 @@
1
+ import type { DocsRoot } from './config.ts'
2
+ import type { DocsUrlOptions } from './urls.ts'
3
+ import { existsSync } from 'node:fs'
4
+ import { dirname, relative, resolve, sep } from 'node:path'
5
+ import { getDocCollectionId, namedSlug, splitDocLocale } from './model/ids.ts'
6
+ import { docsPageHref } from './urls.ts'
7
+
8
+ export interface DocLinkContext {
9
+ roots: DocsRoot[]
10
+ locales: string[]
11
+ urls: (locale?: string) => DocsUrlOptions
12
+ }
13
+
14
+ /**
15
+ * The url of the page a relative link to a markdown file points at, or null
16
+ * when the link is not one: absolute, external, or to a file outside the docs.
17
+ */
18
+ export function resolveDocLink(href: string, fromFile: string, context: DocLinkContext): string | null {
19
+ if (/^([a-z][a-z\d+.-]*:|\/|#)/i.test(href)) return null
20
+
21
+ const [path, hash = ''] = href.split('#', 2)
22
+ if (!path.endsWith('.md')) return null
23
+
24
+ let decoded: string
25
+ try {
26
+ decoded = decodeURI(path)
27
+ } catch {
28
+ return null
29
+ }
30
+
31
+ const target = resolve(dirname(fromFile), decoded)
32
+ const root = context.roots.find(({ dir }) => target.startsWith(dir + sep))
33
+ if (!root) return null
34
+
35
+ if (!existsSync(target)) {
36
+ throw new Error(`jaad: ${fromFile} links to ${href}, which does not exist`)
37
+ }
38
+
39
+ const { locale, id } = splitDocLocale(getDocCollectionId(relative(root.dir, target)), context.locales)
40
+ const slug = namedSlug(id, root.section)
41
+ return docsPageHref(slug, context.urls(locale ?? undefined)) + (hash ? `#${hash}` : '')
42
+ }
@@ -1,6 +1,6 @@
1
1
  import { existsSync, readdirSync } from 'node:fs'
2
2
  import { join } from 'node:path'
3
- import { isLocale } from './locales.ts'
3
+ import { isLocale } from '../site/locales.ts'
4
4
 
5
5
  function hasMarkdown(dir: string): boolean {
6
6
  return readdirSync(dir, { withFileTypes: true }).some((entry) =>
@@ -0,0 +1,87 @@
1
+ import { slug as githubSlug } from 'github-slugger'
2
+ import { ucfirst } from '../../site/helpers.ts'
3
+
4
+ /** A numeric order prefix, e.g. "01-" or "123-". */
5
+ const NUMBERED_PREFIX_RE = /^(\d+)-(.+)$/
6
+
7
+ /** What a page's path says: its file order, its chapter and its own name. */
8
+ export interface ParsedDocPath {
9
+ /** The file name without its order prefix. */
10
+ slug: string
11
+ order: number
12
+ /** The chapter directory without its order prefix. */
13
+ chapter?: string
14
+ orderChapter?: number
15
+ }
16
+
17
+ export function parseDocPath(path: string): ParsedDocPath {
18
+ const parts = path.split('/')
19
+ const name = NUMBERED_PREFIX_RE.exec(parts[parts.length - 1])
20
+ const directory = parts.length >= 2 ? parts[parts.length - 2] : undefined
21
+ const chapter = directory ? NUMBERED_PREFIX_RE.exec(directory) : null
22
+
23
+ return {
24
+ slug: name ? name[2] : parts[parts.length - 1],
25
+ order: name ? parseInt(name[1], 10) : 999,
26
+ chapter: chapter ? chapter[2] : directory,
27
+ orderChapter: chapter ? parseInt(chapter[1], 10) : undefined,
28
+ }
29
+ }
30
+
31
+ /** "01-chapter/02-section" → "chapter/section" */
32
+ export function getCleanSlug(id: string): string {
33
+ return id
34
+ .split('/')
35
+ .map((part) => part.replace(NUMBERED_PREFIX_RE, '$2'))
36
+ .join('/')
37
+ }
38
+
39
+ /** A page's named url below the docs mount: its section, then its clean path. */
40
+ export function namedSlug(path: string, section?: string): string {
41
+ const slug = getCleanSlug(path)
42
+ return section ? `${section}/${slug}` : slug
43
+ }
44
+
45
+ /** "it/02-guides/01-setup" becomes locale "it" and "02-guides/01-setup". */
46
+ export function splitDocLocale(id: string, locales: string[]): { locale: string | null; id: string } {
47
+ const slash = id.indexOf('/')
48
+ if (slash === -1) return { locale: null, id }
49
+
50
+ const first = id.slice(0, slash)
51
+ if (!locales.includes(first)) return { locale: null, id }
52
+ return { locale: first, id: id.slice(slash + 1) }
53
+ }
54
+
55
+ /** Match the ids produced by Astro's glob loader, independently of the OS. */
56
+ export function getDocCollectionId(filePath: string): string {
57
+ return filePath
58
+ .replace(/\.md$/, '')
59
+ .split(/[\\/]/)
60
+ .map((part) => githubSlug(part))
61
+ .join('/')
62
+ }
63
+
64
+ /** The collection holding the docs, or the pages of one section. */
65
+ export function collectionName(section?: string): string {
66
+ return section ? `docsPages-${section}` : 'docsPages'
67
+ }
68
+
69
+ /** The id of a file given relative to its docs directory: its locale, the
70
+ * section that directory is, then its path. */
71
+ export function collectionIdFor(filePath: string, section: string | undefined, locales: string[]): string {
72
+ const { locale, id } = splitDocLocale(getDocCollectionId(filePath), locales)
73
+ return [locale, section, id].filter(Boolean).join('/')
74
+ }
75
+
76
+ /** Decodes percent-escapes, so "detail_%26_summary" → "Detail & Summary". */
77
+ export function slugToTitle(slug: string): string {
78
+ return slug
79
+ .split(/[-_]+/)
80
+ .map((word) => ucfirst(decodeURIComponent(word)))
81
+ .join(' ')
82
+ }
83
+
84
+ export function formatChapterTitle(chapter: string | undefined): string | null {
85
+ if (!chapter) return null
86
+ return slugToTitle(chapter)
87
+ }