@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.
- package/README.md +8 -2
- package/dist/jaad.css +232 -347
- 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 +8 -22
- package/package.json +10 -9
- package/site.ts +18 -1
- package/src/config.ts +25 -176
- package/src/{advanced.ts → docs/advanced.ts} +6 -4
- 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/{components/docs → docs/components}/DocsPageNav.astro +4 -4
- package/src/docs/components/DocsSidebarLeft.astro +53 -0
- package/src/{components/docs → docs/components}/PageActions.astro +10 -8
- package/src/{components/docs → docs/components}/StructuredData.astro +3 -3
- package/src/{components/docs → docs/components}/TableOfContents.astro +20 -22
- 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/{layouts → docs/layouts}/DefaultDocsFrame.astro +7 -5
- package/src/{layouts → docs/layouts}/Docs.astro +44 -43
- 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/{locale-tree.ts → docs/locale-tree.ts} +1 -1
- 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/{routes → docs/routes}/404.astro +1 -1
- package/src/docs/routes/docs-index.astro +19 -0
- package/src/docs/routes/docs-slug.astro +54 -0
- package/src/{routes → docs/routes}/docs-slug.md.ts +8 -10
- package/src/{routes → docs/routes}/llms-locale.txt.ts +1 -1
- package/src/{routes → docs/routes}/llms.txt.ts +1 -1
- package/src/{routes → docs/routes}/search-index-locale.json.ts +1 -1
- package/src/{routes → docs/routes}/search-index.json.ts +1 -1
- 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-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/{@types/docs.ts → docs/types.ts} +8 -16
- package/src/{urls.ts → docs/urls.ts} +2 -16
- package/src/env.d.ts +1 -1
- package/src/integration.ts +15 -249
- package/src/{components → site/components}/Footer.astro +1 -1
- package/src/{components → site/components}/Header.astro +14 -11
- package/src/{components → site/components}/LocaleSwitcher.astro +2 -2
- package/src/{components → site/components}/NavMobile.astro +1 -1
- package/src/{components → site/components}/SearchPalette.astro +160 -79
- package/src/{components → site/components}/ThemeSwitcher.astro +1 -1
- package/src/site/config.ts +149 -0
- package/src/{infer.ts → site/infer.ts} +3 -3
- package/src/site/integration.ts +59 -0
- package/src/{layouts/Page.astro → site/layouts/Shell.astro} +6 -3
- package/src/{locales.ts → site/locales.ts} +9 -1
- package/src/{docs-config.ts → site/runtime.ts} +1 -12
- package/src/site/search.ts +227 -0
- package/src/{social.ts → site/social.ts} +2 -2
- package/src/site/styles/components/header.css +31 -0
- package/src/{styles → site/styles}/components/search.css +21 -1
- package/src/{styles → site/styles}/components/shell.css +4 -0
- package/src/site/styles/components.css +10 -0
- package/src/{styles → site/styles}/global.css +4 -26
- package/src/{themes/index.ts → site/themes.ts} +1 -1
- package/src/{ui → site/ui}/en.ts +3 -5
- package/src/{ui → site/ui}/it.ts +2 -4
- package/src/site/urls.ts +14 -0
- package/src/{virtual.ts → site/virtual.ts} +12 -18
- package/src/styles.css +5 -0
- package/src/collection.ts +0 -97
- package/src/components/ScrollToTop.astro +0 -43
- package/src/components/docs/Breadcrumbs.astro +0 -36
- package/src/components/docs/DocsMobileNav.astro +0 -93
- package/src/components/docs/DocsPage.astro +0 -68
- package/src/components/docs/DocsSidebarLeft.astro +0 -82
- package/src/content.ts +0 -42
- package/src/navigation.ts +0 -97
- package/src/routes/docs-index.astro +0 -19
- package/src/routes/docs-slug.astro +0 -58
- package/src/styles/components/breadcrumbs.css +0 -13
- package/src/styles/components/docs-mobile-nav.css +0 -9
- package/src/styles/components/docs-page-nav.css +0 -31
- package/src/styles/components/docs-sidebar.css +0 -42
- package/src/styles/components/header.css +0 -20
- package/src/styles/components/page-actions.css +0 -13
- package/src/styles/components/scroll-to-top.css +0 -11
- package/src/styles/components/toc.css +0 -21
- package/src/styles/docs.css +0 -112
- package/src/utils/docs.ts +0 -300
- package/src/utils/endpoints.ts +0 -80
- package/src/utils/search.ts +0 -83
- /package/src/{styles → docs/styles}/components/docs-page.css +0 -0
- /package/src/{styles → docs/styles}/jaamd.css +0 -0
- /package/src/{components → site/components}/Head.astro +0 -0
- /package/src/{components → site/components}/Nav.astro +0 -0
- /package/src/{components → site/components}/SocialLinks.astro +0 -0
- /package/src/{components → site/components}/locale-switcher.ts +0 -0
- /package/src/{fonts → site/fonts}/OFL.txt +0 -0
- /package/src/{fonts → site/fonts}/inter.woff2 +0 -0
- /package/src/{fonts → site/fonts}/merriweather-italic.woff2 +0 -0
- /package/src/{fonts → site/fonts}/merriweather.woff2 +0 -0
- /package/src/{fonts.ts → site/fonts.ts} +0 -0
- /package/src/{forges.ts → site/forges.ts} +0 -0
- /package/src/{utils → site}/helpers.ts +0 -0
- /package/src/{styles → site/styles}/components/footer.css +0 -0
- /package/src/{styles → site/styles}/components/locale-switcher.css +0 -0
- /package/src/{styles → site/styles}/components/nav-mobile.css +0 -0
- /package/src/{styles → site/styles}/components/nav.css +0 -0
- /package/src/{styles → site/styles}/components/not-found.css +0 -0
- /package/src/{styles → site/styles}/components/social-links.css +0 -0
- /package/src/{styles → site/styles}/components/theme-switcher.css +0 -0
- /package/src/{styles → site/styles}/jaamd-forward.css +0 -0
- /package/src/{styles → site/styles}/jaamd-reverse.css +0 -0
- /package/src/{styles → site/styles}/tailwind.css +0 -0
- /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
|
+
}
|
|
@@ -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 {
|
|
3
|
-
import {
|
|
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
|
|
13
|
+
const { pages } = await getDocsTree(locale)
|
|
15
14
|
const prefix = localePrefix(locale)
|
|
16
15
|
|
|
17
|
-
for (const page of
|
|
16
|
+
for (const page of pages) {
|
|
18
17
|
paths.push({
|
|
19
|
-
params: { slug: `${prefix}${
|
|
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
|
|
31
|
-
|
|
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(
|
|
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 '../
|
|
3
|
+
import { llmsResponse } from '../endpoints.ts'
|
|
4
4
|
|
|
5
5
|
export const getStaticPaths: GetStaticPaths = async () =>
|
|
6
6
|
(await getServedLocales()).map((locale) => ({ params: { locale } }))
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { APIRoute, GetStaticPaths } from 'astro'
|
|
2
2
|
import { getServedLocales } from '../collection.ts'
|
|
3
|
-
import { searchIndexResponse } from '../
|
|
3
|
+
import { searchIndexResponse } from '../endpoints.ts'
|
|
4
4
|
|
|
5
5
|
export const getStaticPaths: GetStaticPaths = async () =>
|
|
6
6
|
(await getServedLocales()).map((locale) => ({ params: { locale } }))
|
|
@@ -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
|
+
}
|