@jaamd/jaad 0.11.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (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,185 @@
1
+ import type { DocsHeading } from '../types.ts'
2
+
3
+ /** What Astro's renderer leaves on a markdown entry. */
4
+ export interface RenderedMarkdown {
5
+ html: string
6
+ metadata?: { headings?: { depth: number; slug: string; text: string }[] }
7
+ }
8
+
9
+ /** Elements whose text is not prose: code, controls, icons, and in-page navigation. */
10
+ const DROPPED = /<(pre|button|svg|nav|script|style|template)\b[^>]*>[\s\S]*?<\/\1>|<!--[\s\S]*?-->/gi
11
+
12
+ const BLOCK =
13
+ /<\/?(address|article|aside|blockquote|br|dd|details|div|dl|dt|figcaption|figure|footer|h[1-6]|header|hr|li|ol|p|section|summary|table|tbody|td|tfoot|th|thead|tr|ul)\b[^>]*>/gi
14
+
15
+ const NAMED: Record<string, string> = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ' }
16
+
17
+ function decodeEntities(text: string): string {
18
+ return text.replace(/&(#x[\da-f]+|#\d+|[a-z]+);/gi, (entity, name: string) => {
19
+ if (name[0] !== '#') return NAMED[name.toLowerCase()] ?? entity
20
+ const code = name[1] === 'x' || name[1] === 'X' ? parseInt(name.slice(2), 16) : parseInt(name.slice(1), 10)
21
+ return Number.isNaN(code) ? entity : String.fromCodePoint(code)
22
+ })
23
+ }
24
+
25
+ /** The readable text of an HTML fragment, on one line. */
26
+ export function htmlText(html: string): string {
27
+ return decodeEntities(
28
+ html
29
+ .replace(DROPPED, ' ')
30
+ .replace(BLOCK, ' ')
31
+ .replace(/<[^>]*>/g, ''),
32
+ )
33
+ .replace(/\s+/g, ' ')
34
+ .trim()
35
+ }
36
+
37
+ const VOID = new Set([
38
+ 'area',
39
+ 'base',
40
+ 'br',
41
+ 'col',
42
+ 'embed',
43
+ 'hr',
44
+ 'img',
45
+ 'input',
46
+ 'link',
47
+ 'meta',
48
+ 'source',
49
+ 'track',
50
+ 'wbr',
51
+ ])
52
+
53
+ const TAG = /<(\/?)([a-zA-Z][\w-]*)([^>]*)>/g
54
+
55
+ interface HeadingTag {
56
+ depth: number
57
+ id: string
58
+ /** Inside an element with `data-toc-ignore`, or carrying it. */
59
+ ignored: boolean
60
+ /** Where the opening tag starts, and where the closing one ends. */
61
+ start: number
62
+ end: number
63
+ }
64
+
65
+ /** Every heading of the page, and whether a `data-toc-ignore` element holds it.
66
+ * Read from the HTML: an element written as raw HTML wraps what follows it
67
+ * only there, not in the syntax tree. */
68
+ function headingTags(html: string): HeadingTag[] {
69
+ const headings: HeadingTag[] = []
70
+ let ignored: { tag: string; depth: number } | null = null
71
+ let open: HeadingTag | null = null
72
+
73
+ for (const match of html.matchAll(TAG)) {
74
+ const [whole, closing, name, attrs] = match
75
+ const tag = name.toLowerCase()
76
+ const selfClosing = VOID.has(tag) || attrs.trimEnd().endsWith('/')
77
+
78
+ if (ignored && tag === ignored.tag && !selfClosing) {
79
+ ignored.depth += closing ? -1 : 1
80
+ if (ignored.depth === 0) ignored = null
81
+ } else if (!ignored && !closing && !selfClosing && /\sdata-toc-ignore\b/i.test(attrs)) {
82
+ ignored = { tag, depth: 1 }
83
+ }
84
+
85
+ const heading = /^h([1-6])$/.exec(tag)
86
+ if (!heading) continue
87
+ if (!closing) {
88
+ open = {
89
+ depth: Number(heading[1]),
90
+ id: /\sid="([^"]*)"/.exec(attrs)?.[1] ?? '',
91
+ ignored: ignored !== null || /\sdata-toc-ignore\b/i.test(attrs),
92
+ start: match.index,
93
+ end: match.index + whole.length,
94
+ }
95
+ } else if (open) {
96
+ open.end = match.index + whole.length
97
+ headings.push(open)
98
+ open = null
99
+ }
100
+ }
101
+
102
+ return headings
103
+ }
104
+
105
+ /** The first h1, as the renderer read it. */
106
+ export function renderedTitle(rendered: RenderedMarkdown | undefined): string | null {
107
+ return rendered?.metadata?.headings?.find((heading) => heading.depth === 1)?.text ?? null
108
+ }
109
+
110
+ const isOutline = (heading: HeadingTag) => !heading.ignored && (heading.depth === 2 || heading.depth === 3)
111
+
112
+ function headingOf(tag: HeadingTag, rendered: RenderedMarkdown): DocsHeading {
113
+ const known = rendered.metadata?.headings?.find((heading) => heading.slug === tag.id)
114
+ return {
115
+ depth: tag.depth as 2 | 3,
116
+ slug: tag.id,
117
+ text: known?.text ?? htmlText(rendered.html.slice(tag.start, tag.end)),
118
+ }
119
+ }
120
+
121
+ /** The h2 and h3 of the page outside `data-toc-ignore`: its table of contents. */
122
+ export function pageHeadings(rendered: RenderedMarkdown | undefined): DocsHeading[] {
123
+ if (!rendered) return []
124
+ return headingTags(rendered.html)
125
+ .filter(isOutline)
126
+ .map((tag) => headingOf(tag, rendered))
127
+ }
128
+
129
+ /** A stretch of a page: the text under one h2 or h3, or before the first. */
130
+ export interface DocsChunk {
131
+ heading: DocsHeading | null
132
+ text: string
133
+ }
134
+
135
+ /** The page cut at the headings of its table of contents. The h1 is left out;
136
+ * any other heading stays in the text of the stretch it sits in. */
137
+ export function pageChunks(rendered: RenderedMarkdown | undefined): DocsChunk[] {
138
+ if (!rendered) return [{ heading: null, text: '' }]
139
+ const { html } = rendered
140
+ const tags = headingTags(html)
141
+ const titles = tags.filter((tag) => tag.depth === 1)
142
+ const cuts = tags.filter(isOutline)
143
+
144
+ /** The text from `from` to `to`, without the h1. */
145
+ const between = (from: number, to: number) => {
146
+ let text = ''
147
+ let at = from
148
+ for (const title of titles) {
149
+ if (title.end <= from || title.start >= to) continue
150
+ text += html.slice(at, title.start) + ' '
151
+ at = title.end
152
+ }
153
+ return htmlText(text + html.slice(at, to))
154
+ }
155
+
156
+ return [
157
+ { heading: null, text: between(0, cuts[0]?.start ?? html.length) },
158
+ ...cuts.map((tag, at) => ({
159
+ heading: headingOf(tag, rendered),
160
+ text: between(tag.end, cuts[at + 1]?.start ?? html.length),
161
+ })),
162
+ ]
163
+ }
164
+
165
+ /** The first paragraph that is not the title, cut to `maxLength` at a word,
166
+ * for `<meta name="description">`. */
167
+ export function pageDescription(
168
+ rendered: RenderedMarkdown | undefined,
169
+ title: string,
170
+ maxLength: number = 155,
171
+ ): string | null {
172
+ if (!rendered) return null
173
+ const prose = rendered.html.replace(DROPPED, ' ')
174
+ const paragraphs = [...prose.matchAll(/<p\b([^>]*)>([\s\S]*?)<\/p>/gi)]
175
+ .filter(([, attrs]) => !/markdown-alert-title/.test(attrs))
176
+ .map(([, , inner]) => htmlText(inner))
177
+
178
+ const first = paragraphs.find((text) => text && text.toLowerCase() !== title.trim().toLowerCase())
179
+ if (!first) return null
180
+
181
+ if (first.length <= maxLength) return first
182
+ const truncated = first.slice(0, maxLength)
183
+ const lastSpace = truncated.lastIndexOf(' ')
184
+ return `${truncated.slice(0, lastSpace > 0 ? lastSpace : maxLength)}…`
185
+ }
@@ -0,0 +1,124 @@
1
+ import type { DocsPageData } from '../types.ts'
2
+ import type { DocsUrlOptions } from '../urls.ts'
3
+ import type { RenderedMarkdown } from './rendered.ts'
4
+ import { docsPageHref } from '../urls.ts'
5
+ import { namedSlug, parseDocPath, slugToTitle } from './ids.ts'
6
+ import { renderedTitle } from './rendered.ts'
7
+
8
+ /** A markdown file of one locale: a collection entry, or a file read from disk. */
9
+ export interface DocsSource {
10
+ /** Collection id. */
11
+ id: string
12
+ /** The id without its locale and its section. */
13
+ path: string
14
+ /** Set when the docs are localised. */
15
+ locale?: string
16
+ /** Set when the docs are split into sections. */
17
+ section?: string
18
+ data: Partial<DocsPageData>
19
+ rendered?: RenderedMarkdown
20
+ }
21
+
22
+ /** A page with everything its file, its frontmatter and its place in the docs decide. */
23
+ export interface DocsPage<S extends DocsSource = DocsSource> {
24
+ /** What the page was built from. */
25
+ source: S
26
+ id: string
27
+ path: string
28
+ locale?: string
29
+ section?: string
30
+ /** The chapter directory, without its order prefix. */
31
+ chapter?: string
32
+ data: S['data']
33
+ /** The named url below the docs mount: its section, then its clean path. */
34
+ slug: string
35
+ /** Where it is served: empty for the opening page, the section for the first
36
+ * page of a later one, `slug` otherwise. */
37
+ canonical: string
38
+ /** The other slugs it answers to, each redirecting to `canonical`. */
39
+ aliases: string[]
40
+ /** The url of `canonical`. */
41
+ href: string
42
+ /** Frontmatter title, then the first h1, then the file name. */
43
+ title: string
44
+ /** The name in navigation: frontmatter `label`, then `title`. */
45
+ label: string
46
+ /** What pairs it with its translations: frontmatter `translationKey`, then `slug`. */
47
+ translationKey: string
48
+ /** Position in reading order. */
49
+ index: number
50
+ }
51
+
52
+ /** The pages of one locale in reading order. */
53
+ export interface DocsTree<S extends DocsSource = DocsSource> {
54
+ pages: DocsPage<S>[]
55
+ byId: Map<string, DocsPage<S>>
56
+ byKey: Map<string, DocsPage<S>>
57
+ }
58
+
59
+ export interface DocsTreeOptions {
60
+ /** Section keys, in their order. */
61
+ sections?: string[]
62
+ /** For `href`. */
63
+ urls: DocsUrlOptions
64
+ }
65
+
66
+ /** The order of `sections` first, then chapter order, then file order within
67
+ * the chapter. Frontmatter `order` replaces a file's prefix number; root pages
68
+ * interleave with chapters by their own number. */
69
+ function sortSources<S extends DocsSource>(sources: S[], sections: string[]): S[] {
70
+ return sources
71
+ .map((source) => {
72
+ const parsed = parseDocPath(source.path)
73
+ const order = source.data.order ?? parsed.order
74
+ return {
75
+ source,
76
+ section: sections.indexOf(source.section ?? ''),
77
+ primary: parsed.orderChapter ?? order,
78
+ secondary: order,
79
+ }
80
+ })
81
+ .toSorted((a, b) => a.section - b.section || a.primary - b.primary || a.secondary - b.secondary)
82
+ .map((entry) => entry.source)
83
+ }
84
+
85
+ /** Resolves every page of one locale once. */
86
+ export function buildDocsTree<S extends DocsSource>(sources: S[], options: DocsTreeOptions): DocsTree<S> {
87
+ const sorted = sortSources(sources, options.sections ?? [])
88
+
89
+ const pages = sorted.map((source, index): DocsPage<S> => {
90
+ const parsed = parseDocPath(source.path)
91
+ const slug = namedSlug(source.path, source.section)
92
+ const canonical =
93
+ index === 0 ? '' : source.section && sorted[index - 1].section !== source.section ? source.section : slug
94
+ const title = source.data.title ?? renderedTitle(source.rendered) ?? slugToTitle(parsed.slug)
95
+
96
+ return {
97
+ source,
98
+ id: source.id,
99
+ path: source.path,
100
+ locale: source.locale,
101
+ section: source.section,
102
+ chapter: parsed.chapter,
103
+ data: source.data,
104
+ slug,
105
+ canonical,
106
+ aliases: [
107
+ ...(slug === canonical ? [] : [slug]),
108
+ // The first section's key leads to the opening page.
109
+ ...(index === 0 && source.section ? [source.section] : []),
110
+ ],
111
+ href: docsPageHref(canonical, options.urls),
112
+ title,
113
+ label: source.data.label ?? title,
114
+ translationKey: source.data.translationKey ?? slug,
115
+ index,
116
+ }
117
+ })
118
+
119
+ return {
120
+ pages,
121
+ byId: new Map(pages.map((page) => [page.id, page])),
122
+ byKey: new Map(pages.map((page) => [page.translationKey, page])),
123
+ }
124
+ }
@@ -0,0 +1,75 @@
1
+ import type { DocsSource } from './tree.ts'
2
+ import { getCleanSlug, namedSlug } from './ids.ts'
3
+
4
+ /** Within a section, like `id`; across them, the section is part of the name. */
5
+ const inSection = (page: DocsSource, id: string) => (page.section ? `${page.section}/${id}` : id)
6
+
7
+ /** Fail before route generation when the file tree cannot produce one clear
8
+ * navigation and URL for every page. */
9
+ export function validateDocsStructure(pages: DocsSource[], locale?: string): void {
10
+ const idsBySlug = new Map<string, string[]>()
11
+ const directoriesByChapter = new Map<string, Set<string>>()
12
+ const issues: string[] = []
13
+ // Messages name the file on disk, locale directory included.
14
+ const named = (id: string) => (locale ? `${locale}/${id}` : id)
15
+
16
+ const byId = pages.map((page) => ({ page, id: inSection(page, page.path) }))
17
+ for (const { page, id } of byId.toSorted((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))) {
18
+ const parts = page.path.split('/')
19
+ if (parts.length > 2) {
20
+ issues.push(`${named(id)}: only one chapter directory is supported inside docs/`)
21
+ }
22
+
23
+ const slug = namedSlug(page.path, page.section)
24
+ const ids = idsBySlug.get(slug) ?? []
25
+ ids.push(id)
26
+ idsBySlug.set(slug, ids)
27
+
28
+ if (parts.length === 2) {
29
+ const directory = inSection(page, parts[0])
30
+ const chapter = inSection(page, getCleanSlug(parts[0]))
31
+ const directories = directoriesByChapter.get(chapter) ?? new Set()
32
+ directories.add(directory)
33
+ directoriesByChapter.set(chapter, directories)
34
+ }
35
+ }
36
+
37
+ for (const [slug, ids] of idsBySlug) {
38
+ if (ids.length > 1) {
39
+ issues.push(`${ids.map(named).join(', ')} all resolve to /${slug}`)
40
+ }
41
+ }
42
+
43
+ // A page outside the sections cannot take a section's url.
44
+ const keys = new Set(pages.flatMap((page) => page.section ?? []))
45
+ for (const page of pages) {
46
+ const first = namedSlug(page.path, page.section).split('/')[0]
47
+ if (!page.section && keys.has(first)) {
48
+ issues.push(`${named(page.path)} resolves into section /${first}`)
49
+ }
50
+ }
51
+
52
+ const byKey = new Map<string, string[]>()
53
+ for (const page of pages) {
54
+ const key = page.data.translationKey
55
+ if (!key) continue
56
+ const ids = byKey.get(key) ?? []
57
+ ids.push(inSection(page, page.path))
58
+ byKey.set(key, ids)
59
+ }
60
+ for (const [key, ids] of byKey) {
61
+ if (ids.length > 1) {
62
+ issues.push(`${ids.map(named).join(', ')} all claim translationKey ${key}`)
63
+ }
64
+ }
65
+
66
+ for (const [chapter, directories] of directoriesByChapter) {
67
+ if (directories.size > 1) {
68
+ issues.push(`${[...directories].map(named).join(', ')} all resolve to chapter /${chapter}`)
69
+ }
70
+ }
71
+
72
+ if (issues.length > 0) {
73
+ throw new Error(`jaad: invalid documentation structure\n${issues.map((issue) => ` ${issue}`).join('\n')}`)
74
+ }
75
+ }
@@ -0,0 +1,75 @@
1
+ import type { DocsTree } from './model/tree.ts'
2
+ import type { DocsNavItem } from './types.ts'
3
+
4
+ /** A page, or a chapter and its pages. */
5
+ export type DocsNavEntry =
6
+ | { type: 'page'; item: DocsNavItem }
7
+ | { type: 'chapter'; chapter: string; items: DocsNavItem[] }
8
+
9
+ /** A top-level entry. With sections, the pages outside them come first, then
10
+ * every section and its entries. */
11
+ export type DocsNavGroup = DocsNavEntry | { type: 'section'; section: string; label: string; entries: DocsNavEntry[] }
12
+
13
+ export interface DocsNavigation {
14
+ /** The whole tree, in reading order. */
15
+ groups: DocsNavGroup[]
16
+ previousPage: DocsNavItem | null
17
+ nextPage: DocsNavItem | null
18
+ }
19
+
20
+ /** Each chapter sits where its first page does. */
21
+ function groupByChapter(items: DocsNavItem[]): DocsNavEntry[] {
22
+ const chapters = new Map<string, DocsNavItem[]>()
23
+ const groups: DocsNavEntry[] = []
24
+
25
+ for (const item of items) {
26
+ if (!item.chapter) {
27
+ groups.push({ type: 'page', item })
28
+ continue
29
+ }
30
+
31
+ let pages = chapters.get(item.chapter)
32
+ if (!pages) {
33
+ pages = []
34
+ chapters.set(item.chapter, pages)
35
+ groups.push({ type: 'chapter', chapter: item.chapter, items: pages })
36
+ }
37
+ pages.push(item)
38
+ }
39
+
40
+ return groups
41
+ }
42
+
43
+ /** Each section with pages, under its label, in the order of `sections`. */
44
+ function groupBySection(items: DocsNavItem[], sections: { key: string; label: string }[]): DocsNavGroup[] {
45
+ return sections.flatMap(({ key, label }) => {
46
+ const own = items.filter((item) => item.section === key)
47
+ return own.length > 0 ? [{ type: 'section' as const, section: key, label, entries: groupByChapter(own) }] : []
48
+ })
49
+ }
50
+
51
+ /** The navigation seen from one page of `tree`: every group, the page marked
52
+ * active, and its neighbours. */
53
+ export function buildDocsNavigation(
54
+ tree: DocsTree,
55
+ currentId: string,
56
+ sections: { key: string; label: string }[] = [],
57
+ ): DocsNavigation {
58
+ const position = tree.byId.get(currentId)?.index ?? -1
59
+ const items = tree.pages.map((page): DocsNavItem => ({
60
+ title: page.label,
61
+ section: page.section,
62
+ chapter: page.chapter,
63
+ href: page.href,
64
+ isActive: page.index === position,
65
+ }))
66
+
67
+ return {
68
+ groups:
69
+ sections.length > 0
70
+ ? [...groupByChapter(items.filter((item) => !item.section)), ...groupBySection(items, sections)]
71
+ : groupByChapter(items),
72
+ previousPage: position > 0 ? items[position - 1] : null,
73
+ nextPage: position >= 0 && position < items.length - 1 ? items[position + 1] : null,
74
+ }
75
+ }
@@ -1,6 +1,6 @@
1
1
  ---
2
+ import { withDeploymentBase } from '../../site/urls.ts'
2
3
  import Layout from '../layouts/Page.astro'
3
- import { withDeploymentBase } from '../urls.ts'
4
4
 
5
5
  const homeHref = withDeploymentBase('/', import.meta.env.BASE_URL)
6
6
  ---
@@ -0,0 +1,19 @@
1
+ ---
2
+ import { render } from 'astro:content'
3
+ import { withDeploymentBase } from '../../site/urls.ts'
4
+ import { getDocsTree } from '../collection.ts'
5
+ import DocsPage from '../components/DocsPage.astro'
6
+
7
+ const tree = await getDocsTree()
8
+
9
+ const page = tree.pages[0]
10
+ if (!page) {
11
+ return Astro.redirect(withDeploymentBase('/404', import.meta.env.BASE_URL))
12
+ }
13
+
14
+ const { Content } = await render(page.source)
15
+ ---
16
+
17
+ <DocsPage {page} {tree}>
18
+ <Content />
19
+ </DocsPage>
@@ -0,0 +1,54 @@
1
+ ---
2
+ import { render } from 'astro:content'
3
+ import { buildLocales, localePrefix } from '../../site/runtime.ts'
4
+ import { withDeploymentBase } from '../../site/urls.ts'
5
+ import { getDocsTree } from '../collection.ts'
6
+ import DocsPage from '../components/DocsPage.astro'
7
+ import { docsUrls } from '../runtime.ts'
8
+ import { docsPageHref } from '../urls.ts'
9
+
10
+ interface DocsPath {
11
+ params: { slug: string }
12
+ /** `redirect` is the canonical slug of a page reached by another name. */
13
+ props: { id: string; locale?: string; redirect?: string }
14
+ }
15
+
16
+ export async function getStaticPaths() {
17
+ const paths: DocsPath[] = []
18
+
19
+ for (const locale of buildLocales()) {
20
+ const { pages } = await getDocsTree(locale)
21
+ const prefix = localePrefix(locale)
22
+
23
+ for (const page of pages) {
24
+ const add = (slug: string, redirect?: string) =>
25
+ paths.push({ params: { slug }, props: { id: page.id, locale, redirect } })
26
+
27
+ // Empty is the index route's; a prefixed locale has none, so /it is this page.
28
+ const own = `${prefix}${page.canonical}`.replace(/\/$/, '')
29
+ if (own) add(own)
30
+ for (const alias of page.aliases) add(`${prefix}${alias}`, page.canonical)
31
+ }
32
+ }
33
+
34
+ return paths
35
+ }
36
+
37
+ const { id, locale, redirect } = Astro.props
38
+
39
+ if (redirect !== undefined) {
40
+ return Astro.redirect(docsPageHref(redirect, docsUrls(locale)), 301)
41
+ }
42
+
43
+ const tree = await getDocsTree(locale)
44
+ const page = tree.byId.get(id)
45
+ if (!page) {
46
+ return Astro.redirect(withDeploymentBase('/404', import.meta.env.BASE_URL))
47
+ }
48
+
49
+ const { Content } = await render(page.source)
50
+ ---
51
+
52
+ <DocsPage {page} {tree} headTitle={page.title}>
53
+ <Content />
54
+ </DocsPage>
@@ -1,7 +1,6 @@
1
1
  import type { APIRoute, GetStaticPaths } from 'astro'
2
- import { getSortedDocsPages } from '../collection.ts'
3
- import { buildLocales, localePrefix } from '../docs-config.ts'
4
- import { getCleanSlug, routedId } from '../utils/docs.ts'
2
+ import { buildLocales, localePrefix } from '../../site/runtime.ts'
3
+ import { getDocsTree } from '../collection.ts'
5
4
 
6
5
  /** Raw markdown at the page's own url plus `.md`, for readers and for LLMs. */
7
6
  export const getStaticPaths: GetStaticPaths = async () => {
@@ -11,12 +10,12 @@ export const getStaticPaths: GetStaticPaths = async () => {
11
10
  }[] = []
12
11
 
13
12
  for (const locale of buildLocales()) {
14
- const sortedPages = await getSortedDocsPages(locale)
13
+ const { pages } = await getDocsTree(locale)
15
14
  const prefix = localePrefix(locale)
16
15
 
17
- for (const page of sortedPages) {
16
+ for (const page of pages) {
18
17
  paths.push({
19
- params: { slug: `${prefix}${getCleanSlug(routedId(page))}` },
18
+ params: { slug: `${prefix}${page.slug}` },
20
19
  props: { id: page.id, locale },
21
20
  })
22
21
  }
@@ -27,11 +26,10 @@ export const getStaticPaths: GetStaticPaths = async () => {
27
26
 
28
27
  export const GET: APIRoute = async ({ props }) => {
29
28
  const { id, locale } = props as { id: string; locale?: string }
30
- const pages = await getSortedDocsPages(locale)
31
- const entry = pages.find((page) => page.id === id)
32
- if (!entry) return new Response('Not found', { status: 404 })
29
+ const page = (await getDocsTree(locale)).byId.get(id)
30
+ if (!page) return new Response('Not found', { status: 404 })
33
31
 
34
- return new Response(entry.body ?? '', {
32
+ return new Response(page.source.body ?? '', {
35
33
  headers: { 'Content-Type': 'text/markdown; charset=utf-8' },
36
34
  })
37
35
  }
@@ -1,6 +1,6 @@
1
1
  import type { APIRoute, GetStaticPaths } from 'astro'
2
2
  import { getServedLocales } from '../collection.ts'
3
- import { llmsResponse } from '../utils/endpoints.ts'
3
+ import { llmsResponse } from '../endpoints.ts'
4
4
 
5
5
  export const getStaticPaths: GetStaticPaths = async () =>
6
6
  (await getServedLocales()).map((locale) => ({ params: { locale } }))
@@ -1,4 +1,4 @@
1
1
  import type { APIRoute } from 'astro'
2
- import { llmsResponse } from '../utils/endpoints.ts'
2
+ import { llmsResponse } from '../endpoints.ts'
3
3
 
4
4
  export const GET: APIRoute = ({ site }) => llmsResponse(site)
@@ -1,6 +1,6 @@
1
1
  import type { APIRoute, GetStaticPaths } from 'astro'
2
2
  import { getServedLocales } from '../collection.ts'
3
- import { searchIndexResponse } from '../utils/endpoints.ts'
3
+ import { searchIndexResponse } from '../endpoints.ts'
4
4
 
5
5
  export const getStaticPaths: GetStaticPaths = async () =>
6
6
  (await getServedLocales()).map((locale) => ({ params: { locale } }))
@@ -1,4 +1,4 @@
1
1
  import type { APIRoute } from 'astro'
2
- import { searchIndexResponse } from '../utils/endpoints.ts'
2
+ import { searchIndexResponse } from '../endpoints.ts'
3
3
 
4
4
  export const GET: APIRoute = () => searchIndexResponse()
@@ -0,0 +1,17 @@
1
+ import type { DocsSection } from './config.ts'
2
+ import type { DocsUrlOptions } from './urls.ts'
3
+ import config from 'virtual:jaad/config'
4
+ import { DEFAULT_LOCALE } from '../site/runtime.ts'
5
+
6
+ /** The virtual config, read once. */
7
+ export const SECTIONS: DocsSection[] = config.docs.sections
8
+
9
+ /** The four fields every href builder takes, in one place. */
10
+ export function docsUrls(locale?: string): DocsUrlOptions {
11
+ return {
12
+ docsBase: config.docs.base,
13
+ deploymentBase: import.meta.env.BASE_URL,
14
+ locale,
15
+ defaultLocale: DEFAULT_LOCALE,
16
+ }
17
+ }
@@ -0,0 +1,39 @@
1
+ import type { SearchIndex } from '../site/search.ts'
2
+ import type { DocsSection } from './config.ts'
3
+ import type { DocsTree } from './model/tree.ts'
4
+ import { formatChapterTitle } from './model/ids.ts'
5
+ import { pageChunks } from './model/rendered.ts'
6
+
7
+ /** One entry per h2 and h3 of every page, plus one for the text before them. */
8
+ export function buildSearchIndex(tree: DocsTree, sections: Pick<DocsSection, 'key' | 'label'>[]): SearchIndex {
9
+ const index: SearchIndex = {
10
+ sections: sections
11
+ .filter(({ key }) => tree.pages.some((page) => page.section === key))
12
+ .map(({ key, label }) => ({ key, label })),
13
+ pages: [],
14
+ entries: [],
15
+ }
16
+
17
+ for (const page of tree.pages) {
18
+ const chapter = formatChapterTitle(page.chapter)
19
+ const keywords = page.data.keywords ?? []
20
+ index.pages.push({
21
+ title: page.title,
22
+ href: page.href,
23
+ ...(page.section ? { section: page.section } : {}),
24
+ ...(chapter ? { chapter } : {}),
25
+ ...(keywords.length > 0 ? { keywords } : {}),
26
+ })
27
+
28
+ for (const { heading, text } of pageChunks(page.source.rendered)) {
29
+ if (!heading && !text) continue
30
+ index.entries.push({
31
+ page: page.index,
32
+ ...(heading ? { heading: heading.text, anchor: heading.slug } : {}),
33
+ text,
34
+ })
35
+ }
36
+ }
37
+
38
+ return index
39
+ }