@eqtylab/docs 0.3.5 → 0.4.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 (50) hide show
  1. package/README.md +45 -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 +295 -28
  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/loaders.d.ts +5 -5
  19. package/dist/loaders.js +18 -7
  20. package/dist/loaders.js.map +1 -1
  21. package/dist/nav.d.ts +4 -0
  22. package/dist/nav.js +21 -7
  23. package/dist/nav.js.map +1 -1
  24. package/dist/paths.d.ts +25 -3
  25. package/dist/paths.js +1 -1
  26. package/dist/runtime/chrome/GlobalSearch.tsx +7 -2
  27. package/dist/runtime/chrome/Header.astro +8 -3
  28. package/dist/runtime/chrome/NavDrawer.astro +44 -1
  29. package/dist/runtime/chrome/NavTree.astro +1 -11
  30. package/dist/runtime/chrome/Prose.astro +17 -3
  31. package/dist/runtime/chrome/VersionBanner.astro +19 -0
  32. package/dist/runtime/chrome/VersionSwitcher.tsx +105 -0
  33. package/dist/runtime/layouts/DocsPage.astro +36 -4
  34. package/dist/runtime/layouts/DocsShell.astro +4 -2
  35. package/dist/runtime/layouts/RedirectPage.astro +19 -0
  36. package/dist/runtime/lib/nav-data.ts +44 -15
  37. package/dist/runtime/lib/versions-ui.ts +12 -0
  38. package/dist/runtime/routes/docs-md.ts +50 -11
  39. package/dist/runtime/routes/docs.astro +182 -73
  40. package/dist/runtime/routes/llms-txt.ts +2 -1
  41. package/dist/runtime/routes/not-found.astro +27 -0
  42. package/dist/schema.d.ts +1 -0
  43. package/dist/schema.js +1 -1
  44. package/dist/versions.d.ts +37 -0
  45. package/package.json +2 -2
  46. package/dist/chunk-GSSSQTEV.js +0 -51
  47. package/dist/chunk-GSSSQTEV.js.map +0 -1
  48. package/dist/chunk-IWKZQ4CW.js.map +0 -1
  49. package/dist/chunk-K7PJRTK3.js.map +0 -1
  50. package/dist/chunk-VJN3HXKM.js.map +0 -1
@@ -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
@@ -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;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@eqtylab/docs",
3
3
  "description": "Astro documentation framework built on the Equality design system",
4
- "version": "0.3.5",
4
+ "version": "0.4.0",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://equality.eqtylab.io/",
7
7
  "repository": {
@@ -99,7 +99,7 @@
99
99
  "dependencies": {
100
100
  "@tailwindcss/vite": "^4.1.16",
101
101
  "github-slugger": "^2.0.0",
102
- "pagefind": "^1.5.2",
102
+ "pagefind": "1.5.2",
103
103
  "tailwindcss": "^4.1.16",
104
104
  "tinyglobby": "^0.2.15",
105
105
  "unist-util-visit": "^5.1.0",
@@ -1,51 +0,0 @@
1
- import { visit } from 'unist-util-visit';
2
-
3
- // src/internal/rehype-base-url.ts
4
- var URL_ATTRS = ["href", "src", "poster"];
5
- var SRCSET_ATTRS = ["srcSet", "srcset"];
6
- function shouldRewrite(value, base) {
7
- if (!value.startsWith("/")) return false;
8
- if (value.startsWith("//")) return false;
9
- if (base !== "/" && (value === base.replace(/\/$/, "") || value.startsWith(base))) return false;
10
- return true;
11
- }
12
- function rewrite(value, base) {
13
- return shouldRewrite(value, base) ? base.replace(/\/$/, "") + value : value;
14
- }
15
- function rewriteSrcset(value, base) {
16
- return value.split(",").map((candidate) => {
17
- const trimmed = candidate.trim();
18
- if (!trimmed) return candidate;
19
- const [url, ...descriptors] = trimmed.split(/\s+/);
20
- return [rewrite(url, base), ...descriptors].join(" ");
21
- }).join(", ");
22
- }
23
- function rehypeBaseUrl(options) {
24
- const base = options.base || "/";
25
- return function transformer(tree) {
26
- if (base === "/") return;
27
- visit(tree, "element", (node) => {
28
- const props = node.properties;
29
- if (!props) return;
30
- for (const attr of URL_ATTRS) {
31
- const value = props[attr];
32
- if (typeof value === "string") props[attr] = rewrite(value, base);
33
- }
34
- for (const attr of SRCSET_ATTRS) {
35
- const value = props[attr];
36
- if (typeof value === "string") props[attr] = rewriteSrcset(value, base);
37
- }
38
- });
39
- };
40
- }
41
- function rewriteHtmlBase(html, base) {
42
- if (!base || base === "/") return html;
43
- return html.replace(
44
- /\b(href|src)=("|')(\/(?!\/)[^"']*)\2/g,
45
- (_match, attr, quote, url) => `${attr}=${quote}${rewrite(url, base)}${quote}`
46
- );
47
- }
48
-
49
- export { rehypeBaseUrl, rewriteHtmlBase };
50
- //# sourceMappingURL=chunk-GSSSQTEV.js.map
51
- //# sourceMappingURL=chunk-GSSSQTEV.js.map
@@ -1 +0,0 @@
1
- {"version":3,"sources":["../src/internal/rehype-base-url.ts"],"names":[],"mappings":";;;AAQA,IAAM,SAAA,GAAY,CAAC,MAAA,EAAQ,KAAA,EAAO,QAAQ,CAAA;AAC1C,IAAM,YAAA,GAAe,CAAC,QAAA,EAAU,QAAQ,CAAA;AAExC,SAAS,aAAA,CAAc,OAAe,IAAA,EAAuB;AAC3D,EAAA,IAAI,CAAC,KAAA,CAAM,UAAA,CAAW,GAAG,GAAG,OAAO,KAAA;AACnC,EAAA,IAAI,KAAA,CAAM,UAAA,CAAW,IAAI,CAAA,EAAG,OAAO,KAAA;AACnC,EAAA,IAAI,IAAA,KAAS,GAAA,KAAQ,KAAA,KAAU,IAAA,CAAK,OAAA,CAAQ,KAAA,EAAO,EAAE,CAAA,IAAK,KAAA,CAAM,UAAA,CAAW,IAAI,CAAA,CAAA,EAAI,OAAO,KAAA;AAC1F,EAAA,OAAO,IAAA;AACT;AAEA,SAAS,OAAA,CAAQ,OAAe,IAAA,EAAsB;AACpD,EAAA,OAAO,aAAA,CAAc,OAAO,IAAI,CAAA,GAAI,KAAK,OAAA,CAAQ,KAAA,EAAO,EAAE,CAAA,GAAI,KAAA,GAAQ,KAAA;AACxE;AAEA,SAAS,aAAA,CAAc,OAAe,IAAA,EAAsB;AAC1D,EAAA,OAAO,MACJ,KAAA,CAAM,GAAG,CAAA,CACT,GAAA,CAAI,CAAC,SAAA,KAAc;AAClB,IAAA,MAAM,OAAA,GAAU,UAAU,IAAA,EAAK;AAC/B,IAAA,IAAI,CAAC,SAAS,OAAO,SAAA;AACrB,IAAA,MAAM,CAAC,GAAA,EAAK,GAAG,WAAW,CAAA,GAAI,OAAA,CAAQ,MAAM,KAAK,CAAA;AACjD,IAAA,OAAO,CAAC,QAAQ,GAAA,EAAe,IAAI,GAAG,GAAG,WAAW,CAAA,CAAE,IAAA,CAAK,GAAG,CAAA;AAAA,EAChE,CAAC,CAAA,CACA,IAAA,CAAK,IAAI,CAAA;AACd;AAEO,SAAS,cAAc,OAAA,EAA2B;AACvD,EAAA,MAAM,IAAA,GAAO,QAAQ,IAAA,IAAQ,GAAA;AAE7B,EAAA,OAAO,SAAS,YAAY,IAAA,EAAe;AACzC,IAAA,IAAI,SAAS,GAAA,EAAK;AAElB,IAAA,KAAA,CAAM,IAAA,EAAe,SAAA,EAAW,CAAC,IAAA,KAAsB;AACrD,MAAA,MAAM,QAAQ,IAAA,CAAK,UAAA;AACnB,MAAA,IAAI,CAAC,KAAA,EAAO;AAEZ,MAAA,KAAA,MAAW,QAAQ,SAAA,EAAW;AAC5B,QAAA,MAAM,KAAA,GAAQ,MAAM,IAAI,CAAA;AACxB,QAAA,IAAI,OAAO,UAAU,QAAA,EAAU,KAAA,CAAM,IAAI,CAAA,GAAI,OAAA,CAAQ,OAAO,IAAI,CAAA;AAAA,MAClE;AAEA,MAAA,KAAA,MAAW,QAAQ,YAAA,EAAc;AAC/B,QAAA,MAAM,KAAA,GAAQ,MAAM,IAAI,CAAA;AACxB,QAAA,IAAI,OAAO,UAAU,QAAA,EAAU,KAAA,CAAM,IAAI,CAAA,GAAI,aAAA,CAAc,OAAO,IAAI,CAAA;AAAA,MACxE;AAAA,IACF,CAAC,CAAA;AAAA,EACH,CAAA;AACF;AAGO,SAAS,eAAA,CAAgB,MAAc,IAAA,EAAsB;AAClE,EAAA,IAAI,CAAC,IAAA,IAAQ,IAAA,KAAS,GAAA,EAAK,OAAO,IAAA;AAClC,EAAA,OAAO,IAAA,CAAK,OAAA;AAAA,IACV,uCAAA;AAAA,IACA,CAAC,MAAA,EAAQ,IAAA,EAAc,KAAA,EAAe,QACpC,CAAA,EAAG,IAAI,CAAA,CAAA,EAAI,KAAK,GAAG,OAAA,CAAQ,GAAA,EAAK,IAAI,CAAC,GAAG,KAAK,CAAA;AAAA,GACjD;AACF","file":"chunk-GSSSQTEV.js","sourcesContent":["/** Prefixes root-relative URLs with Astro's `base`, which Astro does not do for authored markdown links. */\nimport { visit } from 'unist-util-visit';\n\ninterface ElementNode {\n type: string;\n properties?: Record<string, unknown>;\n}\n\nconst URL_ATTRS = ['href', 'src', 'poster'];\nconst SRCSET_ATTRS = ['srcSet', 'srcset'];\n\nfunction shouldRewrite(value: string, base: string): boolean {\n if (!value.startsWith('/')) return false;\n if (value.startsWith('//')) return false; // protocol-relative\n if (base !== '/' && (value === base.replace(/\\/$/, '') || value.startsWith(base))) return false;\n return true;\n}\n\nfunction rewrite(value: string, base: string): string {\n return shouldRewrite(value, base) ? base.replace(/\\/$/, '') + value : value;\n}\n\nfunction rewriteSrcset(value: string, base: string): string {\n return value\n .split(',')\n .map((candidate) => {\n const trimmed = candidate.trim();\n if (!trimmed) return candidate;\n const [url, ...descriptors] = trimmed.split(/\\s+/);\n return [rewrite(url as string, base), ...descriptors].join(' ');\n })\n .join(', ');\n}\n\nexport function rehypeBaseUrl(options: { base: string }) {\n const base = options.base || '/';\n\n return function transformer(tree: unknown) {\n if (base === '/') return;\n\n visit(tree as never, 'element', (node: ElementNode) => {\n const props = node.properties;\n if (!props) return;\n\n for (const attr of URL_ATTRS) {\n const value = props[attr];\n if (typeof value === 'string') props[attr] = rewrite(value, base);\n }\n\n for (const attr of SRCSET_ATTRS) {\n const value = props[attr];\n if (typeof value === 'string') props[attr] = rewriteSrcset(value, base);\n }\n });\n };\n}\n\n/** Same, for authored HTML strings in frontmatter. */\nexport function rewriteHtmlBase(html: string, base: string): string {\n if (!base || base === '/') return html;\n return html.replace(\n /\\b(href|src)=(\"|')(\\/(?!\\/)[^\"']*)\\2/g,\n (_match, attr: string, quote: string, url: string) =>\n `${attr}=${quote}${rewrite(url, base)}${quote}`\n );\n}\n"]}