Skip to content

Commit 58cd675

Browse files
Rationalise URLs (#970)
The previous scheme assumed that we'd maintain independent state for the tabs, but we decided against that. So now we can go for a more traditional URL routing scheme.
1 parent e3b00ec commit 58cd675

File tree

15 files changed

+73
-183
lines changed

15 files changed

+73
-183
lines changed

src/base.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
export const baseUrl = (() => {
2+
let base = process.env.PUBLIC_URL || "/";
3+
if (!base.endsWith("/")) {
4+
base += "/";
5+
}
6+
return base;
7+
})();

src/documentation/api/ApiDocumentation.tsx

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,7 @@ import { docStyles } from "../../common/documentation-styles";
1313
import HeadedScrollablePanel from "../../common/HeadedScrollablePanel";
1414
import { splitDocString } from "../../editor/codemirror/language-server/docstrings";
1515
import { ApiDocsEntry, ApiDocsResponse } from "../../language-server/apidocs";
16-
import {
17-
Anchor,
18-
RouterParam,
19-
useRouterParam,
20-
useRouterState,
21-
} from "../../router-hooks";
16+
import { Anchor, useRouterTabSlug, useRouterState } from "../../router-hooks";
2217
import DocString from "../common/DocString";
2318
import { useAnimationDirection } from "../common/documentation-animation-hooks";
2419
import DocumentationBreadcrumbHeading from "../common/DocumentationBreadcrumbHeading";
@@ -32,7 +27,7 @@ interface ApiDocumentationProps {
3227
}
3328

3429
export const ApiDocumentation = ({ docs }: ApiDocumentationProps) => {
35-
const [anchor, setAnchor] = useRouterParam(RouterParam.api);
30+
const [anchor, setAnchor] = useRouterTabSlug("api");
3631
const handleNavigate = useCallback(
3732
(id: string | undefined) => {
3833
setAnchor(id ? { id } : undefined, "documentation-user");

src/documentation/api/apidocs-util.test.ts

Lines changed: 1 addition & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,7 @@
44
* SPDX-License-Identifier: MIT
55
*/
66
import { ApiDocsResponse } from "../../language-server/apidocs";
7-
import {
8-
moduleAndApiFromId,
9-
pullModulesToTop,
10-
resolveDottedName,
11-
} from "./apidocs-util";
7+
import { moduleAndApiFromId, pullModulesToTop } from "./apidocs-util";
128

139
describe("pullModulesToTop", () => {
1410
it("pulls modules up", () => {
@@ -72,68 +68,6 @@ describe("pullModulesToTop", () => {
7268
});
7369
});
7470

75-
describe("resolveDottedName", () => {
76-
const docs: ApiDocsResponse = {
77-
microbit: {
78-
fullName: "microbit",
79-
id: "microbit",
80-
name: "microbit",
81-
kind: "module",
82-
children: [
83-
{
84-
fullName: "microbit.Button",
85-
name: "Button",
86-
kind: "class",
87-
id: "microbit.Button",
88-
children: [
89-
{
90-
fullName: "microbit.Button.is_pressed",
91-
kind: "function",
92-
id: "microbit.Button.is_pressed",
93-
name: "is_pressed",
94-
},
95-
],
96-
},
97-
],
98-
},
99-
"microbit.compass": {
100-
fullName: "microbit.compass",
101-
id: "microbit.compass",
102-
name: "compass",
103-
kind: "module",
104-
children: [
105-
{
106-
fullName: "microbit.compass.get_x",
107-
kind: "function",
108-
id: "microbit.compass.get_x",
109-
name: "get_x",
110-
},
111-
],
112-
},
113-
};
114-
it("finds modules", () => {
115-
expect(resolveDottedName(docs, "microbit.compass")).toEqual(
116-
docs["microbit.compass"]
117-
);
118-
expect(resolveDottedName(docs, "microbit")).toEqual(docs["microbit"]);
119-
});
120-
it("finds classes", () => {
121-
expect(resolveDottedName(docs, "microbit.Button")?.fullName).toEqual(
122-
"microbit.Button"
123-
);
124-
});
125-
it("finds functions", () => {
126-
expect(resolveDottedName(docs, "microbit.compass.get_x")?.fullName).toEqual(
127-
"microbit.compass.get_x"
128-
);
129-
});
130-
it("finds class members", () => {
131-
expect(
132-
resolveDottedName(docs, "microbit.Button.is_pressed")?.fullName
133-
).toEqual("microbit.Button.is_pressed");
134-
});
135-
});
136-
13771
describe("moduleAndApiFromId", () => {
13872
it("correctly splits module and apiId from id with three segments", () => {
13973
const id = "microbit.display.scroll";

src/documentation/api/apidocs-util.ts

Lines changed: 0 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -39,24 +39,6 @@ export const resolveModule = (
3939
);
4040
};
4141

42-
export const resolveDottedName = (docs: ApiDocsResponse, name: string) => {
43-
let entry = resolveModule(docs, name);
44-
if (!entry) {
45-
return undefined;
46-
}
47-
const remainder = name.substring(entry.fullName.length + 1);
48-
if (remainder.length > 0) {
49-
for (const part of remainder.split(".")) {
50-
if (entry && entry.children) {
51-
entry = entry.children.find((e) => e.name === part);
52-
} else {
53-
return undefined;
54-
}
55-
}
56-
}
57-
return entry;
58-
};
59-
6042
export const moduleAndApiFromId = (id: string) => {
6143
const idSegments = id.split(".");
6244
const pythonModuleName = idSegments[0];

src/documentation/common/DocumentationContent.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ const DocumentationApiLinkMark = (
7474
e.preventDefault();
7575
setState({
7676
tab: "api",
77-
api: { id: props.mark.name },
77+
slug: { id: props.mark.name },
7878
});
7979
}}
8080
>
@@ -96,7 +96,7 @@ const DocumentationInternalLinkMark = (
9696
{
9797
...state,
9898
tab: "reference",
99-
reference: {
99+
slug: {
100100
id: props.mark.slug.current,
101101
},
102102
},

src/documentation/ideas/IdeasDocumentation.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import { docStyles } from "../../common/documentation-styles";
1212
import HeadedScrollablePanel from "../../common/HeadedScrollablePanel";
1313
import { getAspectRatio, imageUrlBuilder } from "../../common/imageUrlBuilder";
1414
import { useResizeObserverContentRect } from "../../common/use-resize-observer";
15-
import { Anchor, RouterParam, useRouterParam } from "../../router-hooks";
15+
import { Anchor, useRouterTabSlug } from "../../router-hooks";
1616
import { useAnimationDirection } from "../common/documentation-animation-hooks";
1717
import DocumentationBreadcrumbHeading from "../common/DocumentationBreadcrumbHeading";
1818
import DocumentationContent, {
@@ -27,7 +27,7 @@ interface IdeasDocumentationProps {
2727
}
2828

2929
const IdeasDocumentation = ({ ideas }: IdeasDocumentationProps) => {
30-
const [anchor, setAnchor] = useRouterParam(RouterParam.idea);
30+
const [anchor, setAnchor] = useRouterTabSlug("ideas");
3131
const direction = useAnimationDirection(anchor);
3232
const ideaId = anchor?.id;
3333
const handleNavigate = useCallback(

src/documentation/reference/ReferenceDocumentation.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { useCallback } from "react";
88
import { useIntl } from "react-intl";
99
import { docStyles } from "../../common/documentation-styles";
1010
import HeadedScrollablePanel from "../../common/HeadedScrollablePanel";
11-
import { Anchor, RouterParam, useRouterParam } from "../../router-hooks";
11+
import { Anchor, useRouterTabSlug } from "../../router-hooks";
1212
import { useAnimationDirection } from "../common/documentation-animation-hooks";
1313
import DocumentationBreadcrumbHeading from "../common/DocumentationBreadcrumbHeading";
1414
import DocumentationContent from "../common/DocumentationContent";
@@ -29,7 +29,7 @@ interface ReferenceDocumentationProps {
2929
* generate the API documentation.
3030
*/
3131
const ReferenceToolkit = ({ toolkit }: ReferenceDocumentationProps) => {
32-
const [anchor, setAnchor] = useRouterParam(RouterParam.reference);
32+
const [anchor, setAnchor] = useRouterTabSlug("reference");
3333
const direction = useAnimationDirection(anchor);
3434
const topicOrEntryId = anchor?.id.split("/")[0];
3535
const handleNavigate = useCallback(

src/documentation/search/search.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ describe("Search", () => {
6565
id: "indentations",
6666
navigation: {
6767
tab: "reference",
68-
reference: { id: "indentations" },
68+
slug: { id: "indentations" },
6969
},
7070
extract: {
7171
title: [

src/documentation/search/search.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ export class SearchIndex {
9393
containerTitle: content.containerTitle,
9494
navigation: {
9595
tab: this.tab,
96-
[this.tab]: { id: content.id },
96+
slug: { id: content.id },
9797
},
9898
extract: extracts,
9999
};

src/editor/codemirror/CodeMirror.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -240,7 +240,7 @@ const CodeMirror = ({
240240
setRouterState(
241241
{
242242
tab,
243-
[tab]: { id },
243+
slug: { id },
244244
},
245245
"documentation-from-code"
246246
);

src/editor/codemirror/language-server/documentation.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,7 @@ export const wrapWithDocumentationButton = (
159159
apiAnchor.textContent = intl.formatMessage({ id: "api-tab" });
160160
apiAnchor.onclick = (e) => {
161161
e.preventDefault();
162+
// Could we instead interact with the history API here?
162163
document.dispatchEvent(
163164
new CustomEvent("cm/openDocs", {
164165
detail: {

src/language-server/pyright.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
BrowserMessageReader,
99
BrowserMessageWriter,
1010
} from "vscode-jsonrpc/browser";
11+
import { baseUrl } from "../base";
1112
import { createUri, LanguageServerClient } from "./client";
1213

1314
// This is modified by bin/update-pyright.sh
@@ -24,9 +25,7 @@ export const pyright = (language: string): LanguageServerClient | undefined => {
2425
return undefined;
2526
}
2627
// Needed to support review branches that use a path location.
27-
const { origin, pathname } = window.location;
28-
const base = `${origin}${pathname}${pathname.endsWith("/") ? "" : "/"}`;
29-
const workerScript = `${base}workers/${workerScriptName}`;
28+
const workerScript = `${baseUrl}workers/${workerScriptName}`;
3029
const foreground = new Worker(workerScript, {
3130
name: "Pyright-foreground",
3231
});

src/router-hooks.tsx

Lines changed: 37 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,11 @@ import {
1919
useMemo,
2020
useState,
2121
} from "react";
22+
import { baseUrl } from "./base";
2223
import { useLogging } from "./logging/logging-hooks";
2324

25+
export type TabName = "api" | "ideas" | "reference" | "project";
26+
2427
/**
2528
* An anchor-like navigation used for scroll positions.
2629
*
@@ -33,25 +36,9 @@ export interface Anchor {
3336
const anchorForParam = (param: string | null): Anchor | undefined =>
3437
param ? { id: param } : undefined;
3538

36-
export class RouterParam<T> {
37-
static tab: RouterParam<string> = new RouterParam("tab");
38-
static api: RouterParam<Anchor> = new RouterParam("api");
39-
static reference: RouterParam<Anchor> = new RouterParam("reference");
40-
static idea: RouterParam<Anchor> = new RouterParam("idea");
41-
42-
private constructor(public id: keyof RouterState) {}
43-
44-
get(state: RouterState): T | undefined {
45-
// Constructor is private so this is safe.
46-
return state[this.id] as unknown as T | undefined;
47-
}
48-
}
49-
5039
export interface RouterState {
51-
tab?: string;
52-
reference?: Anchor;
53-
api?: Anchor;
54-
idea?: Anchor;
40+
tab?: TabName;
41+
slug?: Anchor;
5542
focus?: boolean;
5643
}
5744

@@ -68,14 +55,21 @@ type RouterContextValue = [
6855

6956
const RouterContext = createContext<RouterContextValue | undefined>(undefined);
7057

71-
const parse = (search: string): RouterState => {
72-
const params = new URLSearchParams(search);
73-
return {
74-
tab: params.get("tab") ?? undefined,
75-
api: anchorForParam(params.get("api")),
76-
reference: anchorForParam(params.get("reference")),
77-
idea: anchorForParam(params.get("idea")),
78-
};
58+
const parse = (pathname: string): RouterState => {
59+
pathname = pathname.slice(baseUrl.length);
60+
if (pathname) {
61+
const parts = pathname.split("/");
62+
const tab = parts[0];
63+
if (
64+
tab === "api" ||
65+
tab === "reference" ||
66+
tab === "ideas" ||
67+
tab === "project"
68+
) {
69+
return { tab, slug: anchorForParam(parts[1]) };
70+
}
71+
}
72+
return {};
7973
};
8074

8175
/**
@@ -95,28 +89,19 @@ export const useRouterState = (): RouterContextValue => {
9589
};
9690

9791
export const toUrl = (state: RouterState): string => {
98-
const query = Object.entries(state)
99-
.filter(([k, v]) => k !== "focus" && !!v)
100-
.map(([k, v]) => {
101-
return `${encodeURIComponent(k)}=${encodeURIComponent(
102-
serializeValue(v)
103-
)}`;
104-
})
105-
.join("&");
106-
return window.location.toString().split("?")[0] + (query ? "?" + query : "");
92+
const parts = [state.tab, state.slug?.id];
93+
const pathname = baseUrl + parts.filter((x): x is string => !!x).join("/");
94+
return window.location.toString().split("/", 1)[0] + pathname;
10795
};
10896

109-
const serializeValue = (value: Anchor | string) =>
110-
typeof value === "string" ? value : value.id;
111-
11297
export const RouterProvider = ({ children }: { children: ReactNode }) => {
11398
const logging = useLogging();
114-
const [state, setState] = useState(parse(window.location.search));
99+
const [state, setState] = useState(parse(window.location.pathname));
115100
useEffect(() => {
116101
// This detects browser navigation but not our programatic changes,
117102
// so we need to update state there ourselves.
118103
const listener = (_: PopStateEvent) => {
119-
const newState = parse(window.location.search);
104+
const newState = parse(window.location.pathname);
120105
setState(newState);
121106
};
122107
window.addEventListener("popstate", listener);
@@ -129,10 +114,7 @@ export const RouterProvider = ({ children }: { children: ReactNode }) => {
129114
if (source) {
130115
logging.event({
131116
type: source,
132-
message:
133-
(newState.reference?.id && `reference-${newState.reference?.id}`) ||
134-
(newState.api?.id && `api-${newState.api?.id}`) ||
135-
(newState.idea?.id && `ideas-${newState.idea?.id}`),
117+
message: toUrl(newState),
136118
});
137119
}
138120
const url = toUrl(newState);
@@ -151,24 +133,23 @@ export const RouterProvider = ({ children }: { children: ReactNode }) => {
151133
};
152134

153135
/**
154-
* Access a single parameter of the router state.
155-
* All other parameters will remain unchanged if you set this parameter.
136+
* Access the slug for a particular tab.
156137
*
157-
* @param param The parameter name.
158-
* @returns A [state, setState] pair for the parameter.
138+
* @param tab The tab name.
139+
* @returns A [state, setState] pair for the tab.
159140
*/
160-
export const useRouterParam = <T,>(
161-
param: RouterParam<T>
141+
export const useRouterTabSlug = (
142+
tab: TabName
162143
): [
163-
T | undefined,
164-
(param: T | undefined, source?: NavigationSource) => void
144+
Anchor | undefined,
145+
(param: Anchor | undefined, source?: NavigationSource) => void
165146
] => {
166147
const [state, setState] = useRouterState();
167148
const navigateParam = useCallback(
168-
(value: T | undefined, source?: NavigationSource) => {
169-
setState({ ...state, [param.id]: value }, source);
149+
(value: Anchor | undefined, source?: NavigationSource) => {
150+
setState({ ...state, tab, slug: value }, source);
170151
},
171-
[param, setState, state]
152+
[tab, setState, state]
172153
);
173-
return [param.get(state), navigateParam];
154+
return [state.tab === tab ? state.slug : undefined, navigateParam];
174155
};

0 commit comments

Comments
 (0)