@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.
Files changed (117) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +58 -2
  3. package/dist/jaad.css +1331 -0
  4. package/docs/00-overview.md +25 -0
  5. package/docs/01-getting-started/01-installation.md +216 -0
  6. package/docs/01-getting-started/02-pages.md +109 -0
  7. package/docs/01-getting-started/03-deployment.md +100 -0
  8. package/docs/02-configurations/01-reference.md +215 -0
  9. package/docs/02-configurations/02-frontmatter.md +105 -0
  10. package/docs/02-configurations/03-i18n.md +210 -0
  11. package/docs/02-configurations/04-search.md +58 -0
  12. package/docs/02-configurations/05-themes.md +104 -0
  13. package/docs/02-configurations/06-styles.md +156 -0
  14. package/docs/02-configurations/07-custom-pages.md +119 -0
  15. package/docs/02-configurations/08-advanced-layout.md +139 -0
  16. package/docs/03-upgrading.md +208 -0
  17. package/index.ts +31 -0
  18. package/package.json +71 -3
  19. package/site.ts +36 -0
  20. package/src/config.ts +85 -0
  21. package/src/docs/advanced.ts +23 -0
  22. package/src/docs/collection.ts +109 -0
  23. package/src/docs/components/Breadcrumbs.astro +29 -0
  24. package/src/docs/components/DocsMobileNav.astro +90 -0
  25. package/src/docs/components/DocsNavTree.astro +57 -0
  26. package/src/docs/components/DocsPage.astro +66 -0
  27. package/src/docs/components/DocsPageNav.astro +53 -0
  28. package/src/docs/components/DocsSidebarLeft.astro +53 -0
  29. package/src/docs/components/PageActions.astro +83 -0
  30. package/src/docs/components/StructuredData.astro +55 -0
  31. package/src/docs/components/TableOfContents.astro +71 -0
  32. package/src/docs/config.ts +167 -0
  33. package/src/docs/content.ts +36 -0
  34. package/src/docs/endpoints.ts +89 -0
  35. package/src/docs/integration.ts +249 -0
  36. package/src/docs/layouts/DefaultDocsFrame.astro +33 -0
  37. package/src/docs/layouts/Docs.astro +169 -0
  38. package/src/docs/layouts/Page.astro +18 -0
  39. package/src/docs/link-plugins.ts +52 -0
  40. package/src/docs/links.ts +42 -0
  41. package/src/docs/locale-tree.ts +51 -0
  42. package/src/docs/model/ids.ts +87 -0
  43. package/src/docs/model/rendered.ts +185 -0
  44. package/src/docs/model/tree.ts +124 -0
  45. package/src/docs/model/validate.ts +75 -0
  46. package/src/docs/navigation.ts +75 -0
  47. package/src/docs/routes/404.astro +16 -0
  48. package/src/docs/routes/docs-index.astro +19 -0
  49. package/src/docs/routes/docs-slug.astro +54 -0
  50. package/src/docs/routes/docs-slug.md.ts +35 -0
  51. package/src/docs/routes/llms-locale.txt.ts +8 -0
  52. package/src/docs/routes/llms.txt.ts +4 -0
  53. package/src/docs/routes/search-index-locale.json.ts +8 -0
  54. package/src/docs/routes/search-index.json.ts +4 -0
  55. package/src/docs/runtime.ts +17 -0
  56. package/src/docs/search-index.ts +39 -0
  57. package/src/docs/styles/components/breadcrumbs.css +26 -0
  58. package/src/docs/styles/components/docs-mobile-nav.css +47 -0
  59. package/src/docs/styles/components/docs-page-nav.css +41 -0
  60. package/src/docs/styles/components/docs-page.css +9 -0
  61. package/src/docs/styles/components/docs-sidebar.css +59 -0
  62. package/src/docs/styles/components/page-actions.css +51 -0
  63. package/src/docs/styles/components/toc.css +38 -0
  64. package/src/docs/styles/components.css +7 -0
  65. package/src/docs/styles/docs.css +81 -0
  66. package/src/docs/styles/jaamd.css +11 -0
  67. package/src/docs/types.ts +39 -0
  68. package/src/docs/urls.ts +35 -0
  69. package/src/env.d.ts +13 -0
  70. package/src/integration.ts +36 -0
  71. package/src/site/components/Footer.astro +18 -0
  72. package/src/site/components/Head.astro +119 -0
  73. package/src/site/components/Header.astro +50 -0
  74. package/src/site/components/LocaleSwitcher.astro +111 -0
  75. package/src/site/components/Nav.astro +22 -0
  76. package/src/site/components/NavMobile.astro +105 -0
  77. package/src/site/components/SearchPalette.astro +409 -0
  78. package/src/site/components/SocialLinks.astro +27 -0
  79. package/src/site/components/ThemeSwitcher.astro +55 -0
  80. package/src/site/components/locale-switcher.ts +6 -0
  81. package/src/site/config.ts +149 -0
  82. package/src/site/fonts/OFL.txt +94 -0
  83. package/src/site/fonts/inter.woff2 +0 -0
  84. package/src/site/fonts/merriweather-italic.woff2 +0 -0
  85. package/src/site/fonts/merriweather.woff2 +0 -0
  86. package/src/site/fonts.ts +29 -0
  87. package/src/site/forges.ts +60 -0
  88. package/src/site/helpers.ts +4 -0
  89. package/src/site/infer.ts +75 -0
  90. package/src/site/integration.ts +59 -0
  91. package/src/site/layouts/Shell.astro +41 -0
  92. package/src/site/locales.ts +199 -0
  93. package/src/site/runtime.ts +19 -0
  94. package/src/site/search.ts +227 -0
  95. package/src/site/social.ts +44 -0
  96. package/src/site/styles/components/footer.css +8 -0
  97. package/src/site/styles/components/header.css +31 -0
  98. package/src/site/styles/components/locale-switcher.css +20 -0
  99. package/src/site/styles/components/nav-mobile.css +15 -0
  100. package/src/site/styles/components/nav.css +11 -0
  101. package/src/site/styles/components/not-found.css +11 -0
  102. package/src/site/styles/components/search.css +118 -0
  103. package/src/site/styles/components/shell.css +77 -0
  104. package/src/site/styles/components/social-links.css +8 -0
  105. package/src/site/styles/components/theme-switcher.css +14 -0
  106. package/src/site/styles/components.css +10 -0
  107. package/src/site/styles/global.css +140 -0
  108. package/src/site/styles/jaamd-forward.css +16 -0
  109. package/src/site/styles/jaamd-reverse.css +18 -0
  110. package/src/site/styles/tailwind.css +30 -0
  111. package/src/site/themes.ts +26 -0
  112. package/src/site/ui/en.ts +27 -0
  113. package/src/site/ui/it.ts +28 -0
  114. package/src/site/ui.ts +49 -0
  115. package/src/site/urls.ts +14 -0
  116. package/src/site/virtual.ts +42 -0
  117. 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
+ }