@eqtylab/docs 0.3.5 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/README.md +77 -12
  2. package/dist/{chunk-VJN3HXKM.js → chunk-LNQV7TVH.js} +18 -6
  3. package/dist/chunk-LNQV7TVH.js.map +1 -0
  4. package/dist/chunk-OM7745ZQ.js +77 -0
  5. package/dist/chunk-OM7745ZQ.js.map +1 -0
  6. package/dist/{chunk-IWKZQ4CW.js → chunk-VNQZJMDO.js} +4 -2
  7. package/dist/chunk-VNQZJMDO.js.map +1 -0
  8. package/dist/{chunk-K7PJRTK3.js → chunk-X4LT7ASQ.js} +31 -4
  9. package/dist/chunk-X4LT7ASQ.js.map +1 -0
  10. package/dist/config.d.ts +10 -1
  11. package/dist/config.js +1 -1
  12. package/dist/index.js +366 -29
  13. package/dist/index.js.map +1 -1
  14. package/dist/internal/extract-versions.d.ts +33 -0
  15. package/dist/internal/rehype-base-url.d.ts +9 -2
  16. package/dist/internal/rehype-base-url.js +1 -1
  17. package/dist/internal/remark-archive-document.d.ts +6 -0
  18. package/dist/internal/run-plugins.d.ts +22 -0
  19. package/dist/internal/virtual-config.d.ts +2 -0
  20. package/dist/loaders.d.ts +5 -5
  21. package/dist/loaders.js +18 -7
  22. package/dist/loaders.js.map +1 -1
  23. package/dist/nav.d.ts +4 -0
  24. package/dist/nav.js +21 -7
  25. package/dist/nav.js.map +1 -1
  26. package/dist/paths.d.ts +25 -3
  27. package/dist/paths.js +1 -1
  28. package/dist/runtime/chrome/GlobalSearch.tsx +7 -2
  29. package/dist/runtime/chrome/Header.astro +8 -3
  30. package/dist/runtime/chrome/NavDrawer.astro +44 -1
  31. package/dist/runtime/chrome/NavTree.astro +1 -11
  32. package/dist/runtime/chrome/Prose.astro +17 -3
  33. package/dist/runtime/chrome/VersionBanner.astro +19 -0
  34. package/dist/runtime/chrome/VersionSwitcher.tsx +105 -0
  35. package/dist/runtime/layouts/DocsPage.astro +36 -4
  36. package/dist/runtime/layouts/DocsShell.astro +4 -2
  37. package/dist/runtime/layouts/RedirectPage.astro +19 -0
  38. package/dist/runtime/lib/mdx-components.ts +4 -1
  39. package/dist/runtime/lib/nav-data.ts +44 -15
  40. package/dist/runtime/lib/versions-ui.ts +12 -0
  41. package/dist/runtime/routes/docs-md.ts +50 -11
  42. package/dist/runtime/routes/docs.astro +182 -73
  43. package/dist/runtime/routes/llms-txt.ts +2 -1
  44. package/dist/runtime/routes/not-found.astro +27 -0
  45. package/dist/schema.d.ts +1 -0
  46. package/dist/schema.js +1 -1
  47. package/dist/types.d.ts +14 -5
  48. package/dist/versions.d.ts +37 -0
  49. package/package.json +16 -16
  50. package/dist/chunk-GSSSQTEV.js +0 -51
  51. package/dist/chunk-GSSSQTEV.js.map +0 -1
  52. package/dist/chunk-IWKZQ4CW.js.map +0 -1
  53. package/dist/chunk-K7PJRTK3.js.map +0 -1
  54. package/dist/chunk-VJN3HXKM.js.map +0 -1
@@ -0,0 +1,19 @@
1
+ ---
2
+ /** A one-line page for a release URL that has no copy of its own. Static hosting has no server redirects. */
3
+ import DocsShell from './DocsShell.astro';
4
+
5
+ interface Props {
6
+ to: string;
7
+ version: string | null;
8
+ }
9
+
10
+ const { to, version } = Astro.props;
11
+ const label = version ?? 'the latest docs';
12
+ ---
13
+
14
+ <DocsShell title="Redirecting" noIndex canonical={to} version={version ?? undefined}>
15
+ <meta slot="head" http-equiv="refresh" content={`0; url=${to}`} />
16
+ <main class="text-text-secondary mx-auto max-w-prose px-6 py-24 text-sm">
17
+ <p>This release's docs live at <a href={to}>{label}</a>.</p>
18
+ </main>
19
+ </DocsShell>
@@ -12,6 +12,7 @@ import {
12
12
  TableRow,
13
13
  } from '@eqtylab/equality';
14
14
  import CONFIG from 'virtual:eqty-docs/config';
15
+ import pluginComponents from 'virtual:eqty-docs/plugin-components';
15
16
 
16
17
  import AlertBridge from '../components/AlertBridge.astro';
17
18
  import CodeFenceBridge from '../components/CodeFenceBridge.astro';
@@ -46,4 +47,6 @@ if (CONFIG.code?.highlighter === 'codeblock') {
46
47
  base.pre = CodeFenceBridge;
47
48
  }
48
49
 
49
- export const mdxComponents = base;
50
+ // Plugin-contributed components (`docs({ plugins })`) land last, so a plugin can add a name or
51
+ // replace one of the defaults.
52
+ export const mdxComponents: Record<string, unknown> = { ...base, ...pluginComponents };
@@ -1,42 +1,64 @@
1
1
  /** Bridges the content collections into the pure nav builder. Needs `astro:content`, so it lives in the runtime tree. */
2
- import { breadcrumbsFor, buildNavTree, buildTocTree, prevNextFor } from '@eqtylab/docs/nav';
2
+ import {
3
+ breadcrumbsFor,
4
+ buildNavTree,
5
+ buildTocTree,
6
+ flattenNav,
7
+ prevNextFor,
8
+ } from '@eqtylab/docs/nav';
3
9
  import type { GroupConfig } from '@eqtylab/docs/nav';
10
+ import { docsHref } from '@eqtylab/docs/paths';
4
11
  import type { DocsNavEntry, NavNode } from '@eqtylab/docs/types';
5
12
  import { getCollection } from 'astro:content';
6
13
  import CONFIG from 'virtual:eqty-docs/config';
7
14
 
8
15
  export { buildTocTree, breadcrumbsFor, prevNextFor };
9
16
 
10
- /** No `versionPrefix`: the version already lives in `base`, and adding it here doubles the segment. */
11
- export function pathContext() {
17
+ /** The version is a route segment after the path prefix; latest has none. */
18
+ export function pathContext(versionId?: string) {
12
19
  return {
13
20
  base: import.meta.env.BASE_URL,
14
21
  pathPrefix: CONFIG.pathPrefix,
22
+ versionPrefix: versionId,
15
23
  };
16
24
  }
17
25
 
18
- /** All docs entries, with drafts filtered out unless we're in `astro dev`. */
19
- export async function docsEntries() {
20
- return getCollection('docs', ({ data }: { data: { draft?: boolean } }) =>
21
- import.meta.env.DEV ? true : !data.draft
26
+ /** Collection names for a version id, or for latest when undefined. */
27
+ export function collectionsFor(versionId?: string): { docs: string; groups: string } {
28
+ if (!versionId) return { docs: 'docs', groups: 'docsGroups' };
29
+ const entry = CONFIG.versionManifest.find((v) => v.id === versionId);
30
+ if (!entry) throw new Error(`[@eqtylab/docs] unknown version id "${versionId}"`);
31
+ return { docs: `docs_${entry.suffix}`, groups: `docsGroups_${entry.suffix}` };
32
+ }
33
+
34
+ // `getCollection` is typed against the generated DataEntryMap, which cannot know names built at runtime.
35
+ const anyCollection = getCollection as unknown as (
36
+ name: string,
37
+ filter?: (entry: { data: Record<string, unknown> }) => boolean
38
+ ) => Promise<never[]>;
39
+
40
+ /** All entries of a version, with drafts filtered out unless we're in `astro dev`. */
41
+ export async function docsEntries(versionId?: string) {
42
+ return anyCollection(collectionsFor(versionId).docs, ({ data }) =>
43
+ import.meta.env.DEV ? true : !(data as { draft?: boolean }).draft
22
44
  );
23
45
  }
24
46
 
25
- async function groupMap(): Promise<Map<string, GroupConfig>> {
47
+ async function groupMap(versionId?: string): Promise<Map<string, GroupConfig>> {
26
48
  const map = new Map<string, GroupConfig>();
27
49
  try {
28
- const groups = await getCollection('docsGroups');
29
- for (const group of groups) {
30
- map.set(group.id, group.data as GroupConfig);
50
+ const groups = await anyCollection(collectionsFor(versionId).groups);
51
+ for (const group of groups as Array<{ id: string; data: GroupConfig }>) {
52
+ map.set(group.id, group.data);
31
53
  }
32
54
  } catch {
33
- // docsGroups is optional; no _group.yaml means alphabetical ordering.
55
+ // The groups collection is optional; no _group.yaml means alphabetical ordering.
34
56
  }
35
57
  return map;
36
58
  }
37
59
 
38
- export async function docsNav(currentPath: string): Promise<NavNode[]> {
39
- const [entries, groups] = await Promise.all([docsEntries(), groupMap()]);
60
+ export async function docsNav(currentPath: string, versionId?: string): Promise<NavNode[]> {
61
+ const [entries, groups] = await Promise.all([docsEntries(versionId), groupMap(versionId)]);
40
62
 
41
63
  const navEntries: DocsNavEntry[] = entries.map((entry) => ({
42
64
  id: entry.id,
@@ -52,9 +74,10 @@ export async function docsNav(currentPath: string): Promise<NavNode[]> {
52
74
  entries: navEntries,
53
75
  groups,
54
76
  currentPath,
55
- paths: pathContext(),
77
+ paths: pathContext(versionId),
56
78
  defaultCollapsed: CONFIG.sidebar.collapsed,
57
79
  defaultSort: CONFIG.sidebar.sort,
80
+ defaultIndexLabel: CONFIG.sidebar.indexLabel,
58
81
  extra: (CONFIG.sidebar.extra ?? []) as NavNode[],
59
82
  onWarn: warnOnce,
60
83
  });
@@ -72,3 +95,9 @@ function deprecationBadge(deprecated: unknown) {
72
95
  if (!deprecated) return undefined;
73
96
  return { text: 'Deprecated', variant: 'warning' as const };
74
97
  }
98
+
99
+ /** The first linkable page of a version. Version roots and switcher fallbacks land here. */
100
+ export async function firstNavHref(versionId?: string): Promise<string> {
101
+ const tree = await docsNav('/', versionId);
102
+ return flattenNav(tree)[0]?.href ?? docsHref('', pathContext(versionId));
103
+ }
@@ -0,0 +1,12 @@
1
+ /** Shapes the chrome receives. Computed at build in the route; the components hold no logic. */
2
+ export interface SwitcherItem {
3
+ label: string;
4
+ href: string;
5
+ current: boolean;
6
+ }
7
+
8
+ export interface SwitcherData {
9
+ latest: SwitcherItem;
10
+ /** One group per major, newest first; items newest first. */
11
+ groups: Array<{ label: string; items: SwitcherItem[] }>;
12
+ }
@@ -4,30 +4,69 @@ import type { APIRoute } from 'astro';
4
4
  import { getCollection } from 'astro:content';
5
5
  import CONFIG from 'virtual:eqty-docs/config';
6
6
 
7
+ import { collectionsFor } from '../lib/nav-data.ts';
8
+
7
9
  export async function getStaticPaths() {
8
- const entries = await getCollection(
9
- 'docs',
10
- ({ data }: { data: { draft?: boolean; noIndex?: boolean } }) => !data.draft && !data.noIndex
11
- );
12
- return entries.map((entry) => ({
13
- // An empty slug would emit a file literally called ".md".
14
- params: { slug: idToPath(entry.id) || 'index' },
15
- props: { entry },
16
- }));
10
+ const visible = ({ data }: { data: { draft?: boolean; noIndex?: boolean } }) =>
11
+ !data.draft && !data.noIndex;
12
+ const anyCollection = getCollection as unknown as (
13
+ name: string,
14
+ f: typeof visible
15
+ ) => Promise<never[]>;
16
+ const versions: Array<string | undefined> = [
17
+ undefined,
18
+ ...CONFIG.versionManifest.map((v) => v.id),
19
+ ];
20
+ const out: Array<{ params: { slug: string }; props: { entry: unknown } }> = [];
21
+ for (const versionId of versions) {
22
+ const entries = (await anyCollection(collectionsFor(versionId).docs, visible)) as Array<{
23
+ id: string;
24
+ }>;
25
+ for (const entry of entries) {
26
+ // An empty slug would emit a file literally called ".md".
27
+ const page = idToPath(entry.id) || 'index';
28
+ out.push({ params: { slug: versionId ? `${versionId}/${page}` : page }, props: { entry } });
29
+ }
30
+ }
31
+ return out;
32
+ }
33
+
34
+ type Deprecated = boolean | { message?: string; replacedBy?: string } | undefined;
35
+
36
+ /** Agents read the twin, not the rendered banner, so the deprecation has to travel with it. */
37
+ function deprecationNotice(deprecated: Deprecated) {
38
+ if (!deprecated) return [];
39
+ const detail = typeof deprecated === 'object' ? deprecated : {};
40
+ const message = stripHtml(detail.message) ?? 'This page documents a deprecated feature.';
41
+ const replacedBy = detail.replacedBy ? ` Use ${stripHtml(detail.replacedBy)} instead.` : '';
42
+ return [`> **Deprecated** — ${message}${replacedBy}`, ''];
43
+ }
44
+
45
+ /** Frontmatter messages may carry authored HTML links, which are noise in plain Markdown. */
46
+ function stripHtml(value?: string) {
47
+ return value?.replace(/<[^>]*>/g, '').trim() || undefined;
17
48
  }
18
49
 
19
50
  export const GET: APIRoute = ({ props }) => {
20
51
  const { entry } = props as {
21
- entry: { body?: string; data: { title: string; description?: string } };
52
+ entry: {
53
+ body?: string;
54
+ data: { title: string; description?: string; deprecated?: Deprecated };
55
+ };
22
56
  };
23
57
 
24
58
  const frontmatter = ['---', `title: ${JSON.stringify(entry.data.title)}`];
25
59
  if (entry.data.description) {
26
60
  frontmatter.push(`description: ${JSON.stringify(entry.data.description)}`);
27
61
  }
62
+ if (entry.data.deprecated) {
63
+ frontmatter.push('deprecated: true');
64
+ }
28
65
  frontmatter.push(`source: ${JSON.stringify(CONFIG.title)}`, '---', '');
29
66
 
30
- return new Response(frontmatter.join('\n') + (entry.body ?? ''), {
67
+ const body = [...deprecationNotice(entry.data.deprecated), entry.body ?? ''].join('\n');
68
+
69
+ return new Response(frontmatter.join('\n') + body, {
31
70
  headers: { 'Content-Type': 'text/markdown; charset=utf-8' },
32
71
  });
33
72
  };
@@ -1,92 +1,201 @@
1
1
  ---
2
- import { getCollection, render } from 'astro:content';
2
+ import { render } from 'astro:content';
3
3
  import CONFIG from 'virtual:eqty-docs/config';
4
4
  import { docsHref, idToPath, joinPath, normalizePath } from '@eqtylab/docs/paths';
5
5
  import DocsPage from '../layouts/DocsPage.astro';
6
- import { breadcrumbsFor, buildTocTree, docsNav, pathContext, prevNextFor } from '../lib/nav-data.ts';
6
+ import RedirectPage from '../layouts/RedirectPage.astro';
7
+ import {
8
+ breadcrumbsFor,
9
+ buildTocTree,
10
+ docsEntries,
11
+ docsNav,
12
+ firstNavHref,
13
+ pathContext,
14
+ prevNextFor,
15
+ } from '../lib/nav-data.ts';
7
16
  import { firstParagraph } from '../lib/summary.ts';
8
17
  import { mdxComponents } from '../lib/mdx-components.ts';
18
+ import type { SwitcherData } from '../lib/versions-ui.ts';
9
19
  import PageFooter from '../chrome/PageFooter.astro';
10
20
  import Prose from '../chrome/Prose.astro';
11
21
 
22
+ type Entry = Awaited<ReturnType<typeof docsEntries>>[number] & {
23
+ id: string;
24
+ body?: string;
25
+ filePath?: string;
26
+ data: Record<string, any>;
27
+ };
28
+
12
29
  export async function getStaticPaths() {
13
- const entries = await getCollection('docs', ({ data }: { data: { draft?: boolean } }) =>
14
- import.meta.env.DEV ? true : !data.draft
30
+ const owned = new Set((CONFIG.ownedByConsumer ?? []).map(normalizePath));
31
+ // Astro does not de-duplicate injected routes against consumer pages. Latest only: a consumer cannot own a versioned path.
32
+ const latest = ((await docsEntries()) as Entry[]).filter(
33
+ (entry) => !owned.has(normalizePath(docsHref(entry.id, pathContext())))
34
+ );
35
+ const copies = await Promise.all(
36
+ CONFIG.versionManifest.map(async (v) => ({ v, entries: (await docsEntries(v.id)) as Entry[] }))
15
37
  );
16
38
 
17
- const owned = new Set((CONFIG.ownedByConsumer ?? []).map(normalizePath));
39
+ const idsOf = new Map<string | undefined, Set<string>>([
40
+ [undefined, new Set(latest.map((e) => e.id))],
41
+ ]);
42
+ for (const { v, entries } of copies) idsOf.set(v.id, new Set(entries.map((e) => e.id)));
43
+ const roots = new Map<string | undefined, string>();
44
+ for (const v of CONFIG.versionManifest) roots.set(v.id, await firstNavHref(v.id));
45
+
46
+ const latestLabel = CONFIG.currentVersion ? CONFIG.currentVersion.id : 'latest';
47
+ const hrefFor = (versionId: string | undefined, id: string) =>
48
+ idsOf.get(versionId)?.has(id)
49
+ ? docsHref(id, pathContext(versionId))
50
+ : versionId
51
+ ? docsHref('', pathContext(versionId))
52
+ : docsHref('', pathContext());
18
53
 
19
- return entries
20
- // Astro does not de-duplicate injected routes against consumer pages.
21
- .filter((entry) => !owned.has(normalizePath(docsHref(entry.id, pathContext()))))
22
- .map((entry) => ({
54
+ const switcherFor = (versionId: string | undefined, id: string): SwitcherData | undefined => {
55
+ // Nothing to switch to until a release is tagged: a picker offering only the page you are
56
+ // already on is noise, and it displaces the header's control cluster.
57
+ if (!CONFIG.currentVersion || !CONFIG.versionManifest.length) return undefined;
58
+ const byMajor = new Map<string, SwitcherData['groups'][number]>();
59
+ for (const v of CONFIG.versionManifest) {
60
+ const major = `v${v.group.split('.')[0]}`;
61
+ if (!byMajor.has(major)) byMajor.set(major, { label: major, items: [] });
62
+ byMajor
63
+ .get(major)!
64
+ .items.push({ label: v.id, href: hrefFor(v.id, id), current: v.id === versionId });
65
+ }
66
+ return {
67
+ latest: { label: latestLabel, href: hrefFor(undefined, id), current: versionId === undefined },
68
+ groups: [...byMajor.values()],
69
+ };
70
+ };
71
+
72
+ return [
73
+ ...latest.map((entry) => ({
23
74
  // `undefined` (not '') is how a rest param matches the empty path.
24
75
  params: { slug: idToPath(entry.id) || undefined },
25
- props: { entry },
26
- }));
76
+ props: { entry, switcher: switcherFor(undefined, entry.id) },
77
+ })),
78
+ ...copies.flatMap(({ v, entries }) => [
79
+ ...entries.map((entry) => ({
80
+ params: { slug: `${v.id}/${idToPath(entry.id)}`.replace(/\/$/, '') },
81
+ props: {
82
+ entry,
83
+ version: v.id,
84
+ switcher: switcherFor(v.id, entry.id),
85
+ canonical: idsOf.get(undefined)!.has(entry.id)
86
+ ? docsHref(entry.id, pathContext())
87
+ : undefined,
88
+ latestHref: hrefFor(undefined, entry.id),
89
+ latestLabel,
90
+ },
91
+ })),
92
+ // 3.9.1 had no index.mdx, so without this the version root and every stub pointing at it would 404.
93
+ ...(idsOf.get(v.id)!.has('index')
94
+ ? []
95
+ : [{ params: { slug: v.id }, props: { redirectTo: roots.get(v.id)!, version: v.id } }]),
96
+ ]),
97
+ // Every target goes through docsHref so base and pathPrefix apply.
98
+ ...CONFIG.versionRedirects.map((r) => ({
99
+ params: { slug: r.id },
100
+ props: {
101
+ redirectTo: r.to
102
+ ? (roots.get(r.to) ?? docsHref('', pathContext(r.to)))
103
+ : docsHref('', pathContext()),
104
+ version: r.to,
105
+ },
106
+ })),
107
+ ];
108
+ }
109
+
110
+ interface Props {
111
+ entry?: Entry;
112
+ version?: string | null;
113
+ switcher?: SwitcherData;
114
+ canonical?: string;
115
+ latestHref?: string;
116
+ latestLabel?: string;
117
+ redirectTo?: string;
27
118
  }
28
119
 
29
- const { entry } = Astro.props;
30
- const { Content, headings } = await render(entry);
31
-
32
- const tocConfig = entry.data.tableOfContents ?? CONFIG.tableOfContents;
33
- const showToc = tocConfig !== false;
34
- const toc = showToc
35
- ? buildTocTree(headings, {
36
- minLevel: (tocConfig as { minLevel?: number })?.minLevel ?? 2,
37
- maxLevel: (tocConfig as { maxLevel?: number })?.maxLevel ?? 3,
38
- })
39
- : [];
40
-
41
- const nav = await docsNav(Astro.url.pathname);
42
- const splash = entry.data.template === 'splash';
43
-
44
- // Trail is [group, ..., page]; a length of one means the page has no section above it.
45
- const trail = breadcrumbsFor(nav, Astro.url.pathname);
46
- const crumbs = trail.length > 1 ? trail.map((node) => node.label).join(' › ') : undefined;
47
-
48
- // Ancestors only. `crumbs` above keeps the page as well, because a search result row
49
- // needs the page name to identify itself. The two diverge on purpose.
50
- const renderedTrail = trail.slice(0, -1).map((node) => ({ label: node.label, href: node.href }));
51
-
52
- // Indexed only, never rendered: a page with no `description` still needs a result summary.
53
- const fallbackSummary = entry.data.description ? undefined : firstParagraph(entry.body);
54
-
55
- const { prev, next } = CONFIG.footer.showPrevNext ? prevNextFor(nav, Astro.url.pathname) : {};
56
-
57
- // `editUrl` is a base; the content path is appended. The slice is taken at the
58
- // consumer's own content directory, which is configurable.
59
- const contentPath = entry.filePath?.split(`${CONFIG.contentDir}/`).pop();
60
- const editHref =
61
- CONFIG.footer.editUrl && contentPath
62
- ? `${CONFIG.footer.editUrl.replace(/\/$/, '')}/${contentPath}`
63
- : undefined;
64
-
65
- // File-shaped (/x/label.md) beside the directory-shaped page; 'index' must match docs-md.ts.
66
- const markdownHref = CONFIG.routing.markdownTwins
67
- ? `${joinPath(import.meta.env.BASE_URL, CONFIG.pathPrefix, idToPath(entry.id) || 'index')}.md`
68
- : undefined;
120
+ const { entry, version, switcher, canonical, latestHref, latestLabel, redirectTo } = Astro.props;
69
121
  ---
70
122
 
71
- <DocsPage
72
- title={entry.data.title}
73
- description={entry.data.description}
74
- nav={nav}
75
- toc={toc}
76
- showToc={showToc}
77
- splash={splash}
78
- >
79
- <Prose
80
- title={entry.data.title}
81
- description={entry.data.description}
82
- deprecated={entry.data.deprecated}
83
- markdownHref={markdownHref}
84
- source={entry.body}
85
- crumbs={crumbs}
86
- trail={renderedTrail}
87
- fallbackSummary={fallbackSummary}
88
- >
89
- <Content components={mdxComponents} />
90
- </Prose>
91
- {!splash && <PageFooter prev={prev} next={next} editHref={editHref} text={CONFIG.footer.text} />}
92
- </DocsPage>
123
+ {redirectTo && <RedirectPage to={redirectTo} version={version ?? null} />}
124
+ {
125
+ !redirectTo &&
126
+ entry &&
127
+ (async () => {
128
+ const { Content, headings } = await render(entry as never);
129
+
130
+ const tocConfig = entry.data.tableOfContents ?? CONFIG.tableOfContents;
131
+ const showToc = tocConfig !== false;
132
+ const toc = showToc
133
+ ? buildTocTree(headings, {
134
+ minLevel: (tocConfig as { minLevel?: number })?.minLevel ?? 2,
135
+ maxLevel: (tocConfig as { maxLevel?: number })?.maxLevel ?? 3,
136
+ })
137
+ : [];
138
+
139
+ const nav = await docsNav(Astro.url.pathname, version ?? undefined);
140
+ const splash = entry.data.template === 'splash';
141
+
142
+ const trail = breadcrumbsFor(nav, Astro.url.pathname);
143
+ const crumbs = trail.length > 1 ? trail.map((node) => node.label).join(' › ') : undefined;
144
+
145
+ // Ancestors only. `crumbs` above keeps the page as well, because a search result row
146
+ // needs the page name to identify itself. The two diverge on purpose.
147
+ const renderedTrail = trail.slice(0, -1).map((node) => ({ label: node.label, href: node.href }));
148
+
149
+ // Indexed only, never rendered: a page with no `description` still needs a result summary.
150
+ const fallbackSummary = entry.data.description ? undefined : firstParagraph(entry.body);
151
+
152
+ const { prev, next } = CONFIG.footer.showPrevNext ? prevNextFor(nav, Astro.url.pathname) : {};
153
+
154
+ // Old versions are read-only and their files live in the cache, so the split below would
155
+ // return the whole cache path and produce a bogus link. Latest only.
156
+ const contentPath = version ? undefined : entry.filePath?.split(`${CONFIG.contentDir}/`).pop();
157
+ const editHref =
158
+ CONFIG.footer.editUrl && contentPath
159
+ ? `${CONFIG.footer.editUrl.replace(/\/$/, '')}/${contentPath}`
160
+ : undefined;
161
+
162
+ // File-shaped (/v3.9/x/label.md) beside the directory-shaped page; 'index' must match docs-md.ts.
163
+ const markdownHref = CONFIG.routing.markdownTwins
164
+ ? `${joinPath(import.meta.env.BASE_URL, CONFIG.pathPrefix, version ?? undefined, idToPath(entry.id) || 'index')}.md`
165
+ : undefined;
166
+
167
+ return (
168
+ <DocsPage
169
+ title={entry.data.title}
170
+ description={entry.data.description}
171
+ nav={nav}
172
+ toc={toc}
173
+ showToc={showToc}
174
+ splash={splash}
175
+ version={version ?? undefined}
176
+ switcher={switcher}
177
+ noIndex={Boolean(version)}
178
+ canonical={canonical}
179
+ latestHref={latestHref}
180
+ latestLabel={latestLabel}
181
+ >
182
+ <Prose
183
+ title={entry.data.title}
184
+ description={entry.data.description}
185
+ deprecated={entry.data.deprecated}
186
+ markdownHref={markdownHref}
187
+ source={entry.body}
188
+ crumbs={crumbs}
189
+ trail={renderedTrail}
190
+ fallbackSummary={fallbackSummary}
191
+ version={version ?? undefined}
192
+ >
193
+ <Content components={mdxComponents} />
194
+ </Prose>
195
+ {!splash && (
196
+ <PageFooter prev={prev} next={next} editHref={editHref} text={CONFIG.footer.text} />
197
+ )}
198
+ </DocsPage>
199
+ );
200
+ })()
201
+ }
@@ -26,8 +26,9 @@ export const GET: APIRoute = async (ctx) => {
26
26
  '',
27
27
  ...sorted.map((entry) => {
28
28
  const href = docsHref(entry.id, paths).replace(/\/$/, '');
29
+ const prefix = entry.data.deprecated ? ' (deprecated)' : '';
29
30
  const suffix = entry.data.description ? `: ${entry.data.description}` : '';
30
- return `- [${entry.data.title}](${origin}${href}.md)${suffix}`;
31
+ return `- [${entry.data.title}](${origin}${href}.md)${prefix}${suffix}`;
31
32
  }),
32
33
  '',
33
34
  ];
@@ -8,9 +8,36 @@ import NotFoundBody from '../chrome/NotFoundBody.tsx';
8
8
  const homeHref = withBase(CONFIG.pathPrefix ? `/${CONFIG.pathPrefix}/` : '/', {
9
9
  base: import.meta.env.BASE_URL,
10
10
  });
11
+
12
+ // One static file serves every miss, so the version has to be read from the URL in the browser.
13
+ // The tables ride in a JSON script rather than `define:vars`, which would force `is:inline` and
14
+ // a second copy of the matching logic that nothing tests.
15
+ const fallback = {
16
+ base: import.meta.env.BASE_URL,
17
+ pathPrefix: CONFIG.pathPrefix,
18
+ copyIds: CONFIG.versionManifest.map((v) => v.id),
19
+ redirects: CONFIG.versionRedirects,
20
+ };
11
21
  ---
12
22
 
13
23
  <DocsShell title="Page not found" noIndex>
14
24
  <Header />
25
+ <script
26
+ type="application/json"
27
+ data-eq-docs-fallback
28
+ is:inline
29
+ set:html={JSON.stringify(fallback)}
30
+ />
15
31
  <NotFoundBody homeHref={homeHref} client:load />
16
32
  </DocsShell>
33
+
34
+ <script>
35
+ import { resolveVersionedPath } from '@eqtylab/docs/paths';
36
+
37
+ const source = document.querySelector('[data-eq-docs-fallback]')?.textContent;
38
+ if (source) {
39
+ const target = resolveVersionedPath(location.pathname, JSON.parse(source));
40
+ // A reader who typed a real release URL should reach the page, not the 404 body.
41
+ if (target && target !== location.pathname) location.replace(target);
42
+ }
43
+ </script>
package/dist/schema.d.ts CHANGED
@@ -69,6 +69,7 @@ export declare function groupSchema(): z.ZodObject<{
69
69
  label: z.ZodOptional<z.ZodString>;
70
70
  icon: z.ZodOptional<z.ZodString>;
71
71
  order: z.ZodDefault<z.ZodArray<z.ZodString>>;
72
+ indexLabel: z.ZodOptional<z.ZodString>;
72
73
  sort: z.ZodOptional<z.ZodEnum<{
73
74
  alpha: "alpha";
74
75
  filename: "filename";
package/dist/schema.js CHANGED
@@ -1,3 +1,3 @@
1
- export { badgeSchema, docsSchema, groupLinkSchema, groupSchema } from './chunk-IWKZQ4CW.js';
1
+ export { badgeSchema, docsSchema, groupLinkSchema, groupSchema } from './chunk-VNQZJMDO.js';
2
2
  //# sourceMappingURL=schema.js.map
3
3
  //# sourceMappingURL=schema.js.map
package/dist/types.d.ts CHANGED
@@ -45,16 +45,25 @@ export interface DocsNavEntry {
45
45
  hidden?: boolean;
46
46
  draft?: boolean;
47
47
  }
48
- /** Extension point for generated sections (OpenAPI reference, changelogs, ...). */
48
+ /**
49
+ * Extension point for generated sections (OpenAPI reference, changelogs, ...). Passed to
50
+ * `docs({ plugins })`; `setup` runs inside `astro:config:setup`, after the framework has planned
51
+ * its own routes.
52
+ */
49
53
  export interface DocsPlugin {
50
54
  name: string;
51
55
  setup(ctx: DocsPluginContext): void | Promise<void>;
52
56
  }
53
57
  export interface DocsPluginContext {
54
- /** Push a top-level group into the sidebar. Appended after folder-derived nodes. */
58
+ /** Push a top-level group into the sidebar. Appended after folder-derived nodes and `sidebar.extra`. */
55
59
  addNavGroup(group: NavNode): void;
56
- /** Merge entries into the route-level MDX component map. */
57
- addMdxComponents(map: Record<string, unknown>): void;
58
- /** Raw `astro:config:setup` params: injectRoute, updateConfig, addWatchFile, logger. */
60
+ /**
61
+ * Add components to the route-level MDX component map, by name. Each value is a module
62
+ * specifier (a package export or an absolute path) whose default export is the component;
63
+ * the runtime imports it, so a component object cannot be passed here. A plugin's entry
64
+ * replaces a default of the same name.
65
+ */
66
+ addMdxComponents(map: Record<string, string>): void;
67
+ /** Raw `astro:config:setup` params: config, injectRoute, updateConfig, addWatchFile, logger. */
59
68
  astro: unknown;
60
69
  }
@@ -0,0 +1,37 @@
1
+ /** Pure. Turns a tag list into the copies to build and the release URLs that redirect to them. */
2
+ export type Granularity = 'major' | 'minor' | 'patch';
3
+ export interface ParsedTag {
4
+ tag: string;
5
+ major: number;
6
+ minor: number;
7
+ patch: number;
8
+ version: string;
9
+ }
10
+ export interface VersionCopy {
11
+ id: string;
12
+ suffix: string;
13
+ group: string;
14
+ tag: string;
15
+ }
16
+ /** `to: null` means latest, i.e. `/`. */
17
+ export interface VersionRedirect {
18
+ id: string;
19
+ to: string | null;
20
+ }
21
+ export interface VersionSelection {
22
+ /** Newest first. */
23
+ copies: VersionCopy[];
24
+ redirects: VersionRedirect[];
25
+ /** Tags that did not parse as MAJOR.MINOR.PATCH, prereleases included. */
26
+ skipped: string[];
27
+ /** Parsed tags above `current`. */
28
+ above: string[];
29
+ }
30
+ export declare function parseTag(tag: string): ParsedTag | null;
31
+ export declare function compareTags(a: ParsedTag, b: ParsedTag): number;
32
+ export declare function groupOf(v: ParsedTag, granularity: Granularity): string;
33
+ export declare function idOf(group: string): string;
34
+ /** Collection names are identifiers, so `v3.9` becomes `v3_9`. */
35
+ export declare function suffixOf(id: string): string;
36
+ export declare function highestTag(tags: string[]): ParsedTag | null;
37
+ export declare function selectVersions(tags: string[], current: string, granularity: Granularity, skippedGroups?: string[]): VersionSelection;