Skip to content

Commit 14ed918

Browse files
committed
✨ Show experimental APIs on the API docs (#1200)
Surface symbols exported from the package's `./experimental` entrypoint in the API Reference section: - `/api` index lists a stable release's experimental symbols inline with an "experimental" badge (none for 4.0.3 today; appears when a stable release exports `./experimental`). The prerelease's experimental APIs are reached via the "next" toggle / its symbol pages. - Experimental symbol pages live at `/api/:series/experimental/:symbol` with an "Experimental" banner; the sidebar lists stable + experimental symbols with badges, resolving each link to the correct (flat vs namespaced) URL. - Version toggle gains a "next" entry for the prerelease when it's newer than its stable parent and documents the current symbol; the active entry is derived from the page's own series. The "experimental" signal is entrypoint membership (`DocPage.experimental`, set in `package.ts`). Stable pages, older series, and search are unaffected.
1 parent 3cecf91 commit 14ed918

8 files changed

Lines changed: 186 additions & 59 deletions

File tree

www/components/api/api-page.tsx

Lines changed: 61 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,39 @@
1-
import { all } from "effection";
1+
import { all, type Operation } from "effection";
22
import type { JSXElement } from "revolution";
33
import { useConfig } from "../../context/config.ts";
44
import { LocalDocPage } from "../../hooks/use-deno-doc.tsx";
55
import { ResolveLinkFunction, useMarkdown } from "../../hooks/use-markdown.tsx";
66
import { Package, usePackage } from "../../lib/package.ts";
7-
import { major } from "../../lib/semver.ts";
8-
import { createRootUrl, createSibling } from "../../lib/links-resolvers.ts";
7+
import { gt, major } from "../../lib/semver.ts";
8+
import { createRootUrl, useApiSeries } from "../../lib/links-resolvers.ts";
99
import { SourceCodeIcon } from "../icons/source-code.tsx";
1010
import { GithubPill } from "../package/source-link.tsx";
11-
import { Icon } from "../type/icon.tsx";
11+
import { ExperimentalBadge, Icon } from "../type/icon.tsx";
1212
import { Type } from "../type/jsx.tsx";
1313
import { Keyword } from "../type/tokens.tsx";
1414

15+
/**
16+
* Root-based URL for a symbol's doc page. Experimental symbols live under a
17+
* `/experimental` segment, so linking cannot be relative to the current page
18+
* (which would resolve into or out of the wrong namespace).
19+
*/
20+
function* pageHref(page: LocalDocPage): Operation<string> {
21+
let series = yield* useApiSeries();
22+
let base = page.experimental ? `api/${series}/experimental` : `api/${series}`;
23+
return yield* createRootUrl(base)(page.name);
24+
}
25+
1526
export function* ApiPage({
1627
pages,
1728
current,
1829
pkg,
19-
externalLinkResolver,
2030
banner,
2131
}: {
22-
current: string;
32+
current: LocalDocPage;
2333
pages: LocalDocPage[];
2434
pkg: Package;
2535
banner?: JSXElement;
26-
externalLinkResolver: ResolveLinkFunction;
2736
}) {
28-
const page = pages.find((node) => node.name === current);
29-
30-
if (!page) throw new Error(`Could not find a doc page for ${current}`);
31-
3237
const linkResolver: ResolveLinkFunction = function* resolve(
3338
symbol,
3439
connector,
@@ -38,11 +43,9 @@ export function* ApiPage({
3843
pages.find((page) => page.name === symbol);
3944

4045
if (target) {
41-
return `[${
42-
[symbol, connector, method].join(
43-
"",
44-
)
45-
}](${yield* externalLinkResolver(symbol, connector, method)})`;
46+
let href = yield* pageHref(target);
47+
if (connector && method) href = `${href}#${method}`;
48+
return `[${[symbol, connector, method].join("")}](${href})`;
4649
} else {
4750
return symbol;
4851
}
@@ -57,40 +60,60 @@ export function* ApiPage({
5760
content: (
5861
<>
5962
<>{banner}</>
60-
{yield* SymbolHeader({ pkg, page })}
61-
{yield* ApiBody({ page, linkResolver })}
63+
{yield* SymbolHeader({ pkg, page: current })}
64+
{yield* ApiBody({ page: current, linkResolver })}
6265
</>
6366
),
64-
linkResolver: createSibling,
6567
versionToggle: yield* (function* () {
66-
const { series } = yield* useConfig();
67-
// Only show stable series in version toggle (no prereleases)
68-
const stableSeries = series.filter((s) => !s.includePrerelease);
69-
const currentSeries = `v${major(pkg.version)}`;
68+
const { series: allSeries } = yield* useConfig();
69+
const series = yield* useApiSeries();
70+
const entrypoint = current.experimental ? "./experimental" : ".";
71+
const suffix = current.experimental ? "/experimental" : "";
7072

73+
// Toggle across every series that documents the current symbol:
74+
// stable releases show their version (e.g. 3.6.1, 4.0.3), and the
75+
// prerelease shows as "next" — but only when it's actually newer
76+
// than its stable parent (otherwise there's nothing to switch to).
7177
const links = yield* all(
72-
stableSeries.map(function* (s) {
78+
allSeries.map(function* (s) {
7379
const seriesPkg = yield* usePackage({
7480
type: "worktree",
7581
series: s.name,
7682
});
83+
84+
if (s.includePrerelease) {
85+
const parent = allSeries.find((p) => p.name === s.parent);
86+
if (parent) {
87+
const parentPkg = yield* usePackage({
88+
type: "worktree",
89+
series: parent.name,
90+
});
91+
if (!gt(seriesPkg.version, parentPkg.version)) return null;
92+
}
93+
}
94+
7795
const seriesDocs = yield* seriesPkg.docs();
78-
const hasSymbol = seriesDocs["."].some((node) =>
79-
node.name === current
96+
const hasSymbol = (seriesDocs[entrypoint] ?? []).some((node) =>
97+
node.name === current.name
8098
);
8199

82100
if (!hasSymbol) return null;
83101

102+
const isCurrent = s.name === series;
103+
const label = s.includePrerelease ? "next" : seriesPkg.version;
104+
84105
return (
85106
<a
86-
href={yield* createRootUrl(`api/${s.name}`)(current)}
107+
href={yield* createRootUrl(`api/${s.name}${suffix}`)(
108+
current.name,
109+
)}
87110
class={`text-base ${
88-
s.name === currentSeries
111+
isCurrent
89112
? "font-bold text-sky-500"
90113
: "text-gray-600 dark:text-gray-400 hover:text-sky-500"
91114
}`}
92115
>
93-
{seriesPkg.version}
116+
{label}
94117
</a>
95118
);
96119
}),
@@ -154,14 +177,12 @@ export function* ApiReference({
154177
content,
155178
current,
156179
pages,
157-
linkResolver,
158180
versionToggle,
159181
}: {
160182
pkg: Package;
161183
content: JSXElement;
162-
current: string;
184+
current: LocalDocPage;
163185
pages: LocalDocPage[];
164-
linkResolver: ResolveLinkFunction;
165186
versionToggle: JSXElement;
166187
}) {
167188
return (
@@ -172,7 +193,7 @@ export function* ApiReference({
172193
<span class="font-bold">API Reference</span>
173194
{versionToggle}
174195
</h3>
175-
{yield* Menu({ pages, current, linkResolver })}
196+
{yield* Menu({ pages, current })}
176197
</nav>
177198
</aside>
178199
<article
@@ -210,30 +231,32 @@ export function* SymbolHeader(
210231
function* Menu({
211232
pages,
212233
current,
213-
linkResolver,
214234
}: {
215-
current: string;
235+
current: LocalDocPage;
216236
pages: LocalDocPage[];
217-
linkResolver: ResolveLinkFunction;
218237
}) {
219238
const elements = [];
220239
for (const page of pages.sort((a, b) => a.name.localeCompare(b.name))) {
240+
const isCurrent = page.name === current.name &&
241+
!!page.experimental === !!current.experimental;
221242
elements.push(
222243
<li>
223-
{current === page.name
244+
{isCurrent
224245
? (
225246
<span class="rounded px-2 block w-full py-2 bg-gray-100 dark:bg-gray-700 cursor-default ">
226247
<Icon kind={page.kind} />
227248
{page.name}
249+
{page.experimental ? <ExperimentalBadge class="ml-1" /> : ""}
228250
</span>
229251
)
230252
: (
231253
<a
232254
class="rounded px-2 block w-full py-2 hover:bg-gray-100 dark:hover:bg-gray-800"
233-
href={yield* linkResolver(page.name)}
255+
href={yield* pageHref(page)}
234256
>
235257
<Icon kind={page.kind} />
236258
{page.name}
259+
{page.experimental ? <ExperimentalBadge class="ml-1" /> : ""}
237260
</a>
238261
)}
239262
</li>,

www/components/type/icon.tsx

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,3 +44,20 @@ export function Icon(props: { kind: string; class?: string }) {
4444
}
4545
return <></>;
4646
}
47+
48+
/**
49+
* A small pill marking a symbol that is exported from the package's
50+
* `./experimental` entrypoint. Rendered inline next to a symbol's name.
51+
*/
52+
export function ExperimentalBadge(props: { class?: string }) {
53+
return (
54+
<span
55+
class={`${
56+
props.class ? props.class : ""
57+
} inline-block align-middle rounded bg-amber-100 dark:bg-amber-900 px-1.5 text-xs font-medium text-amber-800 dark:text-amber-200`}
58+
title="Experimental API — may change or be removed in a future release"
59+
>
60+
experimental
61+
</span>
62+
);
63+
}

www/hooks/use-deno-doc.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,11 @@ export interface DocPage {
5151
description: string;
5252
kind: DocNode["kind"];
5353
dependencies: Dependency[];
54+
/**
55+
* True when this symbol is exported from the package's `./experimental`
56+
* entrypoint. Drives the "experimental" badge and the namespaced doc URL.
57+
*/
58+
experimental?: boolean;
5459
}
5560

5661
export interface DocPageSection {

www/lib/links-resolvers.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,16 @@ import { CurrentRequest } from "../context/request.ts";
33
import { dirname, join } from "@std/path";
44
import { ResolveLinkFunction } from "../hooks/use-markdown.tsx";
55

6+
/**
7+
* The API series being viewed, read from the current request. Symbol pages are
8+
* always `/api/<series>/…` (or `/api/<series>/experimental/…`), so the series
9+
* is the second path segment.
10+
*/
11+
export function* useApiSeries(): Operation<string> {
12+
let request = yield* CurrentRequest.expect();
13+
return new URL(request.url).pathname.split("/")[2];
14+
}
15+
616
export const createSibling: ResolveLinkFunction = function* (
717
pathname,
818
connector,

www/lib/package.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,7 @@ function* initPackage(
166166
docs[entrypoint] = pages[`${url}`].map((page) => {
167167
return {
168168
...page,
169+
experimental: entrypoint === "./experimental",
169170
sections: page.sections.map((section) => ({
170171
...section,
171172
node: {

www/main.tsx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,17 @@ if (import.meta.main) {
8686
apiReferenceRoute(s.name, { search: true }),
8787
)
8888
),
89+
// Experimental API docs (namespaced; empty for series without the
90+
// `./experimental` entrypoint)
91+
...series.map((s) =>
92+
route(
93+
`/api/${s.name}/experimental/:symbol`,
94+
apiReferenceRoute(s.name, {
95+
search: true,
96+
entrypoint: "./experimental",
97+
}),
98+
)
99+
),
89100
route("/blog", blogIndexRoute({ search: true })),
90101
route("/blog/feed.xml", blogFeedRoute()),
91102
route("/llms.txt", llmsTxtRoute()),

www/routes/api-index-route.tsx

Lines changed: 51 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { type JSXElement } from "revolution";
22

3-
import { Icon } from "../components/type/icon.tsx";
3+
import { ExperimentalBadge, Icon } from "../components/type/icon.tsx";
44
import { DocPage } from "../hooks/use-deno-doc.tsx";
55
import { ResolveLinkFunction } from "../hooks/use-markdown.tsx";
66
import { usePackage } from "../lib/package.ts";
@@ -35,8 +35,10 @@ export function apiIndexRoute(
3535
// Only show prerelease link if it's newer than stable
3636
let showV4Prerelease = gt(v4Next.version, v4.version);
3737

38-
// Get first symbol for prerelease link
39-
let v4NextDocs = showV4Prerelease ? yield* v4Next.docs() : null;
38+
// v4-next docs feed the "also available" prerelease link. The
39+
// prerelease's own experimental APIs are surfaced when you click through
40+
// to its symbol pages (their sidebar lists them, badged).
41+
let v4NextDocs = yield* v4Next.docs();
4042
let v4NextFirstSymbol = v4NextDocs?.["."]?.[0]?.name ?? "run";
4143

4244
let docs = {
@@ -77,8 +79,20 @@ export function apiIndexRoute(
7779
</h3>
7880
<ul class="columns-3 pl-0">
7981
{yield* listPages({
80-
pages: docs.v4["."],
81-
linkResolver: createChildURL("v4"),
82+
groups: [
83+
{
84+
pages: docs.v4["."],
85+
linkResolver: createChildURL("v4"),
86+
experimental: false,
87+
},
88+
{
89+
// Experimental APIs for the stable release itself (empty
90+
// until a stable version exports `./experimental`).
91+
pages: docs.v4["./experimental"] ?? [],
92+
linkResolver: createChildURL("v4/experimental"),
93+
experimental: true,
94+
},
95+
],
8296
})}
8397
</ul>
8498
</section>
@@ -95,8 +109,18 @@ export function apiIndexRoute(
95109
</h3>
96110
<ul class="columns-3 pl-0">
97111
{yield* listPages({
98-
pages: docs.v3["."],
99-
linkResolver: createChildURL("v3"),
112+
groups: [
113+
{
114+
pages: docs.v3["."],
115+
linkResolver: createChildURL("v3"),
116+
experimental: false,
117+
},
118+
{
119+
pages: docs.v3["./experimental"] ?? [],
120+
linkResolver: createChildURL("v3/experimental"),
121+
experimental: true,
122+
},
123+
],
100124
})}
101125
</ul>
102126
</section>
@@ -107,23 +131,35 @@ export function apiIndexRoute(
107131
};
108132
}
109133

110-
function* listPages({
111-
pages,
112-
linkResolver,
113-
}: {
134+
interface PageGroup {
114135
pages: DocPage[];
115136
linkResolver: ResolveLinkFunction;
116-
}) {
117-
let elements = [];
137+
experimental: boolean;
138+
}
139+
140+
function* listPages({ groups }: { groups: PageGroup[] }) {
141+
// Resolve links first (linkResolver is an Operation), then merge every
142+
// group into a single alphabetically-sorted list so experimental symbols
143+
// sit inline with stable ones, each carrying its own badge and link.
144+
let items: Array<{ page: DocPage; link: string; experimental: boolean }> = [];
145+
for (let group of groups) {
146+
for (let page of group.pages) {
147+
let link = yield* group.linkResolver(page.name);
148+
items.push({ page, link, experimental: group.experimental });
149+
}
150+
}
151+
152+
items.sort((a, b) => a.page.name.localeCompare(b.page.name));
118153

119-
for (let page of pages.sort((a, b) => a.name.localeCompare(b.name))) {
120-
let link = yield* linkResolver(page.name);
154+
let elements = [];
155+
for (let { page, link, experimental } of items) {
121156
elements.push(
122157
<li class="list-none pb-1">
123158
<a class="text-blue-700 dark:text-blue-400" href={link}>
124159
<Icon kind={page.kind} class="mr-2" />
125160
{page.name}
126161
</a>
162+
{experimental ? <ExperimentalBadge class="ml-2" /> : ""}
127163
</li>,
128164
);
129165
}

0 commit comments

Comments
 (0)