@jaamd/jaad 0.0.0-stage → 1.0.0-rc.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +58 -2
- package/dist/jaad.css +1331 -0
- package/docs/00-overview.md +25 -0
- package/docs/01-getting-started/01-installation.md +216 -0
- package/docs/01-getting-started/02-pages.md +109 -0
- package/docs/01-getting-started/03-deployment.md +100 -0
- package/docs/02-configurations/01-reference.md +215 -0
- package/docs/02-configurations/02-frontmatter.md +105 -0
- package/docs/02-configurations/03-i18n.md +210 -0
- package/docs/02-configurations/04-search.md +58 -0
- package/docs/02-configurations/05-themes.md +104 -0
- package/docs/02-configurations/06-styles.md +156 -0
- package/docs/02-configurations/07-custom-pages.md +119 -0
- package/docs/02-configurations/08-advanced-layout.md +139 -0
- package/docs/03-upgrading.md +208 -0
- package/index.ts +31 -0
- package/package.json +71 -3
- package/site.ts +36 -0
- package/src/config.ts +85 -0
- package/src/docs/advanced.ts +23 -0
- package/src/docs/collection.ts +109 -0
- package/src/docs/components/Breadcrumbs.astro +29 -0
- package/src/docs/components/DocsMobileNav.astro +90 -0
- package/src/docs/components/DocsNavTree.astro +57 -0
- package/src/docs/components/DocsPage.astro +66 -0
- package/src/docs/components/DocsPageNav.astro +53 -0
- package/src/docs/components/DocsSidebarLeft.astro +53 -0
- package/src/docs/components/PageActions.astro +83 -0
- package/src/docs/components/StructuredData.astro +55 -0
- package/src/docs/components/TableOfContents.astro +71 -0
- package/src/docs/config.ts +167 -0
- package/src/docs/content.ts +36 -0
- package/src/docs/endpoints.ts +89 -0
- package/src/docs/integration.ts +249 -0
- package/src/docs/layouts/DefaultDocsFrame.astro +33 -0
- package/src/docs/layouts/Docs.astro +169 -0
- package/src/docs/layouts/Page.astro +18 -0
- package/src/docs/link-plugins.ts +52 -0
- package/src/docs/links.ts +42 -0
- package/src/docs/locale-tree.ts +51 -0
- package/src/docs/model/ids.ts +87 -0
- package/src/docs/model/rendered.ts +185 -0
- package/src/docs/model/tree.ts +124 -0
- package/src/docs/model/validate.ts +75 -0
- package/src/docs/navigation.ts +75 -0
- package/src/docs/routes/404.astro +16 -0
- package/src/docs/routes/docs-index.astro +19 -0
- package/src/docs/routes/docs-slug.astro +54 -0
- package/src/docs/routes/docs-slug.md.ts +35 -0
- package/src/docs/routes/llms-locale.txt.ts +8 -0
- package/src/docs/routes/llms.txt.ts +4 -0
- package/src/docs/routes/search-index-locale.json.ts +8 -0
- package/src/docs/routes/search-index.json.ts +4 -0
- package/src/docs/runtime.ts +17 -0
- package/src/docs/search-index.ts +39 -0
- package/src/docs/styles/components/breadcrumbs.css +26 -0
- package/src/docs/styles/components/docs-mobile-nav.css +47 -0
- package/src/docs/styles/components/docs-page-nav.css +41 -0
- package/src/docs/styles/components/docs-page.css +9 -0
- package/src/docs/styles/components/docs-sidebar.css +59 -0
- package/src/docs/styles/components/page-actions.css +51 -0
- package/src/docs/styles/components/toc.css +38 -0
- package/src/docs/styles/components.css +7 -0
- package/src/docs/styles/docs.css +81 -0
- package/src/docs/styles/jaamd.css +11 -0
- package/src/docs/types.ts +39 -0
- package/src/docs/urls.ts +35 -0
- package/src/env.d.ts +13 -0
- package/src/integration.ts +36 -0
- package/src/site/components/Footer.astro +18 -0
- package/src/site/components/Head.astro +119 -0
- package/src/site/components/Header.astro +50 -0
- package/src/site/components/LocaleSwitcher.astro +111 -0
- package/src/site/components/Nav.astro +22 -0
- package/src/site/components/NavMobile.astro +105 -0
- package/src/site/components/SearchPalette.astro +409 -0
- package/src/site/components/SocialLinks.astro +27 -0
- package/src/site/components/ThemeSwitcher.astro +55 -0
- package/src/site/components/locale-switcher.ts +6 -0
- package/src/site/config.ts +149 -0
- package/src/site/fonts/OFL.txt +94 -0
- package/src/site/fonts/inter.woff2 +0 -0
- package/src/site/fonts/merriweather-italic.woff2 +0 -0
- package/src/site/fonts/merriweather.woff2 +0 -0
- package/src/site/fonts.ts +29 -0
- package/src/site/forges.ts +60 -0
- package/src/site/helpers.ts +4 -0
- package/src/site/infer.ts +75 -0
- package/src/site/integration.ts +59 -0
- package/src/site/layouts/Shell.astro +41 -0
- package/src/site/locales.ts +199 -0
- package/src/site/runtime.ts +19 -0
- package/src/site/search.ts +227 -0
- package/src/site/social.ts +44 -0
- package/src/site/styles/components/footer.css +8 -0
- package/src/site/styles/components/header.css +31 -0
- package/src/site/styles/components/locale-switcher.css +20 -0
- package/src/site/styles/components/nav-mobile.css +15 -0
- package/src/site/styles/components/nav.css +11 -0
- package/src/site/styles/components/not-found.css +11 -0
- package/src/site/styles/components/search.css +118 -0
- package/src/site/styles/components/shell.css +77 -0
- package/src/site/styles/components/social-links.css +8 -0
- package/src/site/styles/components/theme-switcher.css +14 -0
- package/src/site/styles/components.css +10 -0
- package/src/site/styles/global.css +140 -0
- package/src/site/styles/jaamd-forward.css +16 -0
- package/src/site/styles/jaamd-reverse.css +18 -0
- package/src/site/styles/tailwind.css +30 -0
- package/src/site/themes.ts +26 -0
- package/src/site/ui/en.ts +27 -0
- package/src/site/ui/it.ts +28 -0
- package/src/site/ui.ts +49 -0
- package/src/site/urls.ts +14 -0
- package/src/site/virtual.ts +42 -0
- package/src/styles.css +5 -0
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
---
|
|
2
|
+
import type { DocsFrameProps } from '../advanced.ts'
|
|
3
|
+
import type { DocsSourceEntry } from '../collection.ts'
|
|
4
|
+
import type { DocsPage } from '../model/tree.ts'
|
|
5
|
+
import type { DocsNavigation } from '../navigation.ts'
|
|
6
|
+
import type { DocsHeading } from '../types.ts'
|
|
7
|
+
import { ClientRouter } from 'astro:transitions'
|
|
8
|
+
import config from 'virtual:jaad/config'
|
|
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'
|
|
14
|
+
import { getLocaleAlternates } from '../collection.ts'
|
|
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'
|
|
22
|
+
import '../styles/docs.css'
|
|
23
|
+
import '../styles/jaamd.css'
|
|
24
|
+
import '@jaamd/astro/styles.css'
|
|
25
|
+
import 'virtual:jaad/theme.css'
|
|
26
|
+
import 'virtual:jaad/bridge.css'
|
|
27
|
+
import 'virtual:jaad/user.css'
|
|
28
|
+
import 'virtual:jaad/tailwind.css'
|
|
29
|
+
|
|
30
|
+
interface Props {
|
|
31
|
+
/** The `<title>` before the site name. Left out on the opening page. */
|
|
32
|
+
title?: string
|
|
33
|
+
page: DocsPage<DocsSourceEntry>
|
|
34
|
+
headings?: DocsHeading[]
|
|
35
|
+
navigation: DocsNavigation
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const { title, page, headings = [], navigation } = Astro.props
|
|
39
|
+
|
|
40
|
+
const pageTitle = title ? `${title} | ${config.title}` : config.title
|
|
41
|
+
|
|
42
|
+
const description =
|
|
43
|
+
page.data.description ?? pageDescription(page.source.rendered, page.title) ?? config.description ?? config.title
|
|
44
|
+
|
|
45
|
+
const locale = page.locale ?? DEFAULT_LOCALE
|
|
46
|
+
const language = LOCALES.find((known) => known.code === locale)?.tag ?? config.lang
|
|
47
|
+
|
|
48
|
+
const indexUrl = docsPageHref('search-index.json', docsUrls(locale))
|
|
49
|
+
|
|
50
|
+
const switcherAlternates = await getLocaleAlternates(page.translationKey)
|
|
51
|
+
|
|
52
|
+
// x-default is a landing place, so it takes the fallback when this page has no
|
|
53
|
+
// translation into the default locale.
|
|
54
|
+
const xDefault = switcherAlternates.find((alternate) => alternate.locale.code === DEFAULT_LOCALE)?.href
|
|
55
|
+
|
|
56
|
+
// hreflang describes translations of this page, not the switcher's fallbacks.
|
|
57
|
+
const alternates = switcherAlternates
|
|
58
|
+
.filter((alternate) => alternate.translated)
|
|
59
|
+
.map((alternate) => ({
|
|
60
|
+
code: alternate.locale.code,
|
|
61
|
+
tag: alternate.locale.tag,
|
|
62
|
+
href: alternate.href,
|
|
63
|
+
}))
|
|
64
|
+
|
|
65
|
+
const frameProps: DocsFrameProps = {
|
|
66
|
+
page: {
|
|
67
|
+
id: page.id,
|
|
68
|
+
slug: page.slug,
|
|
69
|
+
title: page.title,
|
|
70
|
+
section: page.section,
|
|
71
|
+
chapter: page.chapter,
|
|
72
|
+
data: page.data,
|
|
73
|
+
},
|
|
74
|
+
headings,
|
|
75
|
+
navigation,
|
|
76
|
+
}
|
|
77
|
+
---
|
|
78
|
+
|
|
79
|
+
<!doctype html>
|
|
80
|
+
<html lang={language} class={config.appearance === 'dark' ? 'dark' : undefined}>
|
|
81
|
+
<head>
|
|
82
|
+
<Head
|
|
83
|
+
title={pageTitle}
|
|
84
|
+
description={description}
|
|
85
|
+
keywords={page.data.keywords}
|
|
86
|
+
author={page.data.author}
|
|
87
|
+
image={page.data.ogImage}
|
|
88
|
+
modified={page.data.lastUpdated}
|
|
89
|
+
language={language}
|
|
90
|
+
{alternates}
|
|
91
|
+
{xDefault}
|
|
92
|
+
/>
|
|
93
|
+
<StructuredData
|
|
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}
|
|
100
|
+
{language}
|
|
101
|
+
{locale}
|
|
102
|
+
/>
|
|
103
|
+
<ClientRouter />
|
|
104
|
+
</head>
|
|
105
|
+
<body data-docs class="jaad-body">
|
|
106
|
+
<DocsFrame {...frameProps}>
|
|
107
|
+
<Header slot="header" {locale} alternates={switcherAlternates} search={{ indexUrl, prominent: true }} />
|
|
108
|
+
<DocsSidebarLeft slot="sidebar" groups={navigation.groups} {locale} />
|
|
109
|
+
<slot name="mobile-navigation" slot="mobile-navigation" />
|
|
110
|
+
<slot name="breadcrumbs" slot="breadcrumbs" />
|
|
111
|
+
<slot name="page-actions" slot="page-actions" />
|
|
112
|
+
<slot name="content" slot="content" />
|
|
113
|
+
<slot name="page-navigation" slot="page-navigation" />
|
|
114
|
+
<TableOfContents slot="table-of-contents" headings={headings} {locale} />
|
|
115
|
+
<Footer slot="footer" />
|
|
116
|
+
</DocsFrame>
|
|
117
|
+
|
|
118
|
+
<script>
|
|
119
|
+
import { navigate } from 'astro:transitions/client'
|
|
120
|
+
|
|
121
|
+
/** Identified by `rel`. */
|
|
122
|
+
function getPageLinks(): {
|
|
123
|
+
prev: HTMLAnchorElement | null
|
|
124
|
+
next: HTMLAnchorElement | null
|
|
125
|
+
} {
|
|
126
|
+
const nav = document.querySelector('nav[aria-label="Page navigation"]')
|
|
127
|
+
if (!nav) return { prev: null, next: null }
|
|
128
|
+
return {
|
|
129
|
+
prev: nav.querySelector<HTMLAnchorElement>('a[rel~="prev"][href]'),
|
|
130
|
+
next: nav.querySelector<HTMLAnchorElement>('a[rel~="next"][href]'),
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** A wide table or code block: the arrow keys should scroll it instead. */
|
|
135
|
+
function inHorizontallyScrollable(el: Element | null): boolean {
|
|
136
|
+
for (let node = el; node && node !== document.body;) {
|
|
137
|
+
if (node instanceof HTMLElement) {
|
|
138
|
+
const overflowX = getComputedStyle(node).overflowX
|
|
139
|
+
if ((overflowX === 'auto' || overflowX === 'scroll') && node.scrollWidth > node.clientWidth)
|
|
140
|
+
return true
|
|
141
|
+
}
|
|
142
|
+
node = node.parentElement
|
|
143
|
+
}
|
|
144
|
+
return false
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function onKey(e: KeyboardEvent) {
|
|
148
|
+
// Leave modified chords alone, notably the browser's back/forward.
|
|
149
|
+
if (e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) return
|
|
150
|
+
if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return
|
|
151
|
+
|
|
152
|
+
const active = document.activeElement as HTMLElement | null
|
|
153
|
+
const tag = active?.tagName
|
|
154
|
+
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return
|
|
155
|
+
if (active?.isContentEditable) return
|
|
156
|
+
if (inHorizontallyScrollable(active)) return
|
|
157
|
+
|
|
158
|
+
const { prev, next } = getPageLinks()
|
|
159
|
+
const target = e.key === 'ArrowLeft' ? prev : next
|
|
160
|
+
if (!target) return
|
|
161
|
+
e.preventDefault()
|
|
162
|
+
navigate(target.href)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// `document` survives View Transitions, and this module runs once.
|
|
166
|
+
document.addEventListener('keydown', onKey)
|
|
167
|
+
</script>
|
|
168
|
+
</body>
|
|
169
|
+
</html>
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { existsSync, readdirSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { isLocale } from '../site/locales.ts'
|
|
4
|
+
|
|
5
|
+
function hasMarkdown(dir: string): boolean {
|
|
6
|
+
return readdirSync(dir, { withFileTypes: true }).some((entry) =>
|
|
7
|
+
entry.isDirectory() ? hasMarkdown(join(dir, entry.name)) : entry.name.endsWith('.md'),
|
|
8
|
+
)
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Locale directories under docs/, or none. Two mean the tree is organised by
|
|
12
|
+
* language; one counts only when `lang` names it. */
|
|
13
|
+
export function detectLocales(docsDir: string, lang: string): string[] {
|
|
14
|
+
if (!existsSync(docsDir)) return []
|
|
15
|
+
|
|
16
|
+
const candidates = readdirSync(docsDir, { withFileTypes: true })
|
|
17
|
+
.filter((entry) => entry.isDirectory() && isLocale(entry.name))
|
|
18
|
+
.map((entry) => entry.name.toLowerCase())
|
|
19
|
+
.toSorted()
|
|
20
|
+
|
|
21
|
+
const localised = candidates.length >= 2 || (candidates.length === 1 && candidates[0] === lang.toLowerCase())
|
|
22
|
+
if (!localised) return []
|
|
23
|
+
|
|
24
|
+
// An empty directory would reach the switcher and answer 404.
|
|
25
|
+
return candidates.filter((code) => hasMarkdown(join(docsDir, code)))
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Once localised, every top-level entry is a locale and `lang` names one. */
|
|
29
|
+
export function validateLocaleTree(docsDir: string, locales: string[], lang: string): void {
|
|
30
|
+
const issues: string[] = []
|
|
31
|
+
|
|
32
|
+
const strays = readdirSync(docsDir, { withFileTypes: true })
|
|
33
|
+
.filter((entry) => !(entry.isDirectory() && isLocale(entry.name)))
|
|
34
|
+
.map((entry) => entry.name)
|
|
35
|
+
.toSorted()
|
|
36
|
+
|
|
37
|
+
for (const stray of strays) {
|
|
38
|
+
issues.push(`docs/${stray} is not a locale, but ${locales.join(', ')} are`)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (!locales.includes(lang.toLowerCase())) {
|
|
42
|
+
issues.push(`lang is "${lang}", which has no directory; found ${locales.join(', ')}`)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (issues.length > 0) {
|
|
46
|
+
throw new Error(
|
|
47
|
+
`jaad: mixed documentation tree\n${issues.map((issue) => ` ${issue}`).join('\n')}\n` +
|
|
48
|
+
'Move every page under a locale directory, or set locales: false.',
|
|
49
|
+
)
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -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
|
+
}
|