improve: position & filter navigation

This commit is contained in:
Johannes Kirschbauer 2024-01-02 13:46:51 +01:00 committed by Johannes Kirschbauer
parent 4ebffe7cf3
commit f954db4fbb
8 changed files with 447 additions and 206 deletions

View File

@ -1,16 +1,16 @@
import { HighlightBaseline } from "@/components/HighlightBaseline";
import { ShareButton } from "@/components/ShareButton";
import { BackButton } from "@/components/BackButton";
import { Doc, FilePosition, data } from "@/models/data";
import { Doc, data } from "@/models/data";
import { getPrimopDescription } from "@/models/primop";
import { extractHeadings, mdxRenderOptions } from "@/utils";
import { Edit } from "@mui/icons-material";
import { Box, Button, Divider, Typography, Link, Chip } from "@mui/material";
import { Box, Divider, Typography, Link, Chip } from "@mui/material";
import { MDXRemote } from "next-mdx-remote/rsc";
import { findType, interpretType } from "@/models/nix";
import LinkIcon from "@mui/icons-material/Link";
import { FilterProvider } from "@/components/layout/filterContext";
import { Suspense } from "react";
import { PositionLink } from "@/components/PositionLink";
import { SearchNav } from "@/components/SearchNav";
// Important the key ("path") in the returned dict MUST match the dynamic path segment ([...path])
export async function generateStaticParams(): Promise<{ path: string[] }[]> {
@ -22,17 +22,6 @@ export async function generateStaticParams(): Promise<{ path: string[] }[]> {
return paths;
}
const getSourcePosition = (baseUrl: string, position: FilePosition): string => {
const filename = position?.file.split("/").slice(4).join("/");
const line = position?.line;
const column = position?.column;
let res = `${baseUrl}`;
if (filename && line && column) {
res += `/${filename}#L${line}:C${column}`;
}
return res;
};
interface TocProps {
mdxSource: string;
}
@ -58,6 +47,11 @@ const Toc = async (props: TocProps) => {
>
<Typography variant="subtitle1">On this page</Typography>
<Box sx={{ display: "flex", flexDirection: "column" }}>
{!headings.length && (
<Typography variant="body2" sx={{ color: "text.secondary", py: 1 }}>
No content
</Typography>
)}
{headings.map((h, idx) => (
<Link key={idx} href={`#${h.id}`}>
<Typography
@ -79,6 +73,51 @@ const Toc = async (props: TocProps) => {
);
};
// TODO: figure out why this causes hydration errors
const MDX = ({ source }: { source: string }) => (
<MDXRemote
options={{
parseFrontmatter: true,
mdxOptions: mdxRenderOptions,
}}
source={source}
components={{
a: (p) => (
// @ts-ignore
<Box
sx={{
color: "inherit",
textDecoration: "none",
}}
component="a"
{...p}
/>
),
// @ts-ignore
h1: (p) => (
// @ts-ignore
<Typography variant="h3" component={"h2"} {...p} />
),
// @ts-ignore
h2: (p) => <Typography variant="h4" component={"h3"} {...p} />,
// @ts-ignore
h3: (p) => <Typography variant="h5" component={"h4"} {...p} />,
// @ts-ignore
h4: (p) => <Typography variant="h6" component={"h5"} {...p} />,
// @ts-ignore
h5: (p) => (
// @ts-ignore
<Typography variant="subtitle1" component={"h6"} {...p} />
),
// @ts-ignore
h6: (p) => (
// @ts-ignore
<Typography variant="subtitle2" component={"h6"} {...p} />
),
}}
/>
);
// Multiple versions of this page will be statically generated
// using the `params` returned by `generateStaticParams`
export default async function Page(props: { params: { path: string[] } }) {
@ -95,20 +134,7 @@ export default async function Page(props: { params: { path: string[] } }) {
signature
);
const position =
meta?.content_meta?.position ||
meta?.attr_position ||
(meta?.count_applied == 0 && meta?.lambda_position);
const raw_position =
meta?.content_meta?.position ||
meta?.attr_position ||
meta?.lambda_position;
const source =
meta?.is_primop && meta?.primop_meta
? getPrimopDescription(meta.primop_meta) + mdxSource
: mdxSource;
const source = mdxSource;
// Skip generating this builtin.
// It is internal information of noogle.
if (meta?.title === "builtins.lambdaMeta") {
@ -117,7 +143,7 @@ export default async function Page(props: { params: { path: string[] } }) {
return (
<>
<Toc mdxSource={mdxSource} />
<Toc mdxSource={source} />
<Box
component="main"
data-pagefind-body
@ -146,7 +172,7 @@ export default async function Page(props: { params: { path: string[] } }) {
flexWrap: "wrap",
}}
>
<Suspense fallback={<BackButton />}>
<Suspense fallback={""}>
<FilterProvider>
<BackButton />
</FilterProvider>
@ -177,6 +203,7 @@ export default async function Page(props: { params: { path: string[] } }) {
<ShareButton />
</Box>
<Divider flexItem sx={{ mt: 2 }} />
<Box sx={{ display: "block" }}>
{argTypes.map((t, i) => (
<meta key={i} data-pagefind-filter={`from:${t}`} />
@ -195,147 +222,20 @@ export default async function Page(props: { params: { path: string[] } }) {
No documentation found yet.
</Typography>
{!position && (
<div data-pagefind-ignore="all">
<Typography variant="h5" sx={{ pt: 2 }}>
{"Noogle's tip"}
</Typography>
<Typography
variant="body1"
component={"span"}
gutterBottom
sx={{ py: 2 }}
>
<div>
Position of the source could not be detected
automatically.
</div>
<div>
Sometimes the documentation is missing or the extraction
of the documentation fails. In these cases, it is
advisable to look for the recognized position in the
source code
</div>
{raw_position && (
<Link
target="_blank"
href={getSourcePosition(
"https://github.com/hsjobeki/nixpkgs/tree/migrate-doc-comments",
raw_position
)}
>
<Button
data-pagefind-ignore="all"
variant="text"
sx={{
textTransform: "none",
my: 1,
placeSelf: "start",
}}
startIcon={<LinkIcon />}
>
Original/underlying function
</Button>
</Link>
)}
<div>You may find further instructions there</div>
</Typography>
</div>
)}
{position && (
<Link
target="_blank"
href={getSourcePosition(
"https://github.com/hsjobeki/nixpkgs/tree/migrate-doc-comments",
position
)}
>
<Button
data-pagefind-ignore="all"
variant="text"
sx={{
textTransform: "none",
my: 1,
placeSelf: "start",
}}
startIcon={<LinkIcon />}
>
Original/underlying function
</Button>
</Link>
)}
<Typography
variant="body1"
sx={{ color: "text.secondary", py: 2 }}
>
Contribute now!
</Typography>
</Box>
)}
<MDXRemote
options={{
parseFrontmatter: true,
mdxOptions: mdxRenderOptions,
}}
source={source}
components={{
a: (p) => (
// @ts-ignore
<Box
sx={{
color: "inherit",
textDecoration: "none",
}}
component="a"
{...p}
/>
),
// @ts-ignore
h1: (p) => (
// @ts-ignore
<Typography variant="h3" component={"h2"} {...p} />
),
// @ts-ignore
h2: (p) => <Typography variant="h4" component={"h3"} {...p} />,
// @ts-ignore
h3: (p) => <Typography variant="h5" component={"h4"} {...p} />,
// @ts-ignore
h4: (p) => <Typography variant="h6" component={"h5"} {...p} />,
// @ts-ignore
h5: (p) => (
// @ts-ignore
<Typography variant="subtitle1" component={"h6"} {...p} />
),
// @ts-ignore
h6: (p) => (
// @ts-ignore
<Typography variant="subtitle2" component={"h6"} {...p} />
),
}}
/>
{position && (
<div data-pagefind-ignore="all">
{!source && (
<Typography
variant="body1"
sx={{ color: "text.secondary", py: 2 }}
>
Contribute now!
</Typography>
)}
<Typography variant="subtitle2" sx={{ color: "text.secondary" }}>
<Link
target="_blank"
href={getSourcePosition(
"https://github.com/hsjobeki/nixpkgs/tree/migrate-doc-comments",
position
)}
>
<Button
variant="text"
sx={{ textTransform: "none", my: 1, placeSelf: "start" }}
startIcon={<Edit />}
>
Edit source
</Button>
</Link>
</Typography>
</div>
{meta?.primop_meta && (
<MDX source={getPrimopDescription(meta?.primop_meta)} />
)}
<Divider />
<MDX source={source} />
{meta && <PositionLink meta={meta} />}
<div data-pagefind-ignore="all">
{(!!meta?.aliases?.length || !!signature) && (
<>
@ -380,17 +280,17 @@ export default async function Page(props: { params: { path: string[] } }) {
>
Detected Type
</Typography>
<MDXRemote
options={{
mdxOptions: mdxRenderOptions,
}}
source={`\`\`\`haskell\n${signature.trim()}\n\`\`\`\n`}
/>
<MDX source={`\`\`\`haskell\n${signature.trim()}\n\`\`\`\n`} />
</>
)}
</div>
</Box>
<Divider flexItem sx={{ mt: 2 }} />
<Suspense fallback={""}>
<FilterProvider>
<SearchNav />
</FilterProvider>
</Suspense>
</Box>
</>
);

View File

@ -64,7 +64,6 @@ export const usePagefindSearch = (): PagefindHooks => {
}, []);
// @ts-ignore
console.log("usePagefind", { pagefind });
return {
// @ts-ignore
search: pagefind?.search,

View File

@ -21,7 +21,8 @@ import { useSearchParams } from "next/navigation";
import { useRouter } from "next/navigation";
import { PagefindResult, RawResult, usePagefindSearch } from "./Pagefind";
import { Clear } from "@mui/icons-material";
import { useFilter } from "./layout/filterContext";
import { FilterOptions, useFilter } from "./layout/filterContext";
import { useSessionStorage } from "usehooks-ts";
// import d from "./example.json";
@ -36,6 +37,11 @@ export function PagefindResults() {
const params = useSearchParams();
const router = useRouter();
const [, persistFilterOptions] = useSessionStorage<FilterOptions>(
"currentFilterOptions",
{}
);
const query = useMemo(() => new URLSearchParams(params), [params]);
const page = +params.get("page")! || 1;
@ -67,6 +73,11 @@ export function PagefindResults() {
) => {
query.set("limit", event.target.value);
query.set("page", "1");
persistFilterOptions((s) => ({
...s,
limit: +event.target.value,
page: 1,
}));
router.push(`?${query.toString()}`);
};
@ -81,7 +92,6 @@ export function PagefindResults() {
const loadData = async () => {
let items = await Promise.all(pageItems.map(async (r) => await r.data()));
setItems(items);
// setItems(d);
};
loadData();
}, [pageItems]);
@ -91,6 +101,10 @@ export function PagefindResults() {
value: number
) => {
query.set("page", (value + 1).toString());
persistFilterOptions((s) => ({
...s,
page: value + 1,
}));
router.push(`?${query.toString()}`);
};

View File

@ -0,0 +1,31 @@
"use client";
import { DocMeta } from "@/models/data";
import { Box, Button, Collapse, Typography } from "@mui/material";
import { useState } from "react";
import CodeIcon from "@mui/icons-material/Code";
export const PositionInsights = ({ meta }: { meta: DocMeta }) => {
// const { attr_position, lambda_position, count_applied, content_meta } = meta;
const [open, setOpen] = useState(true);
// const is_inherited =
// JSON.stringify(content_meta?.path) !== JSON.stringify(meta.path);
return (
<>
<Box sx={{ width: "100%", display: "flex" }}>
<Button
onClick={() => setOpen((s) => !s)}
sx={{ ml: "auto" }}
startIcon={<CodeIcon />}
>
Position Infos
</Button>
</Box>
<Collapse in={open}>
<Typography>is_inherited</Typography>
</Collapse>
</>
);
};

View File

@ -0,0 +1,136 @@
import { DocMeta, FilePosition } from "@/models/data";
import {
Box,
Button,
Link,
List,
ListItem,
ListItemButton,
ListItemText,
Typography,
} from "@mui/material";
import LinkIcon from "@mui/icons-material/Link";
import EditIcon from "@mui/icons-material/Edit";
const getSourcePosition = (baseUrl: string, position: FilePosition): string => {
const filename = position?.file.split("/").slice(4).join("/");
const line = position?.line;
const column = position?.column;
let res = `${baseUrl}`;
if (filename && line && column) {
res += `/${filename}#L${line}:C${column}`;
}
return res;
};
export const PositionLink = ({ meta }: { meta: DocMeta }) => {
const { attr_position, lambda_position, count_applied, content_meta } = meta;
const contentPosition = content_meta?.position;
const position = attr_position || lambda_position;
const is_inherited =
JSON.stringify(content_meta?.path) !== JSON.stringify(meta.path);
return (
<div data-pagefind-ignore="all">
{!position && (
<Typography variant="subtitle1" sx={{ color: "text.secondary", pb: 2 }}>
This function is not declared in a .nix file
</Typography>
)}
<Box sx={{ display: "flex", alignItems: "center" }}>
<Typography variant="subtitle2" sx={{ color: "text.secondary" }}>
{contentPosition && (
<Link
target="_blank"
href={getSourcePosition(
"https://github.com/hsjobeki/nixpkgs/tree/migrate-doc-comments",
contentPosition
)}
>
<Button
variant="text"
sx={{ textTransform: "none", my: 1, placeSelf: "start" }}
startIcon={<EditIcon />}
>
Edit source
</Button>
</Link>
)}
{!contentPosition && position && (
<Box sx={{ display: "flex", alignItems: "center" }}>
<Link
target="_blank"
href={getSourcePosition(
"https://github.com/hsjobeki/nixpkgs/tree/migrate-doc-comments",
position
)}
>
<Button
data-pagefind-ignore="all"
variant="text"
sx={{
textTransform: "none",
my: 1,
placeSelf: "start",
}}
startIcon={<LinkIcon />}
>
Underlying function
</Button>
</Link>
{count_applied && count_applied > 0 && (
<Typography
variant="subtitle2"
sx={{ color: "text.secondary" }}
>
{`Applied ${count_applied} times`}
</Typography>
)}
</Box>
)}
</Typography>
<Typography variant="subtitle2" sx={{ color: "text.secondary" }}>
{contentPosition &&
is_inherited &&
`(${content_meta?.path?.join(".")})`}
</Typography>
</Box>
{!contentPosition && (
<>
<Typography variant="h5" sx={{ pt: 2 }}>
{"Contribute"}
</Typography>
<Typography variant="body1" gutterBottom sx={{ py: 2 }}>
<Box>
Sometimes documentation is missing or tooling support from noogle
is missing.
</Box>
<List sx={{ width: "100%" }}>
<ListItem>
<Link href="/tutorials/documentation" target="_blank">
<ListItemButton>
<ListItemText
primary="Write API documentation for this function"
secondary="Learn how to write documentation"
/>
</ListItemButton>
</Link>
</ListItem>
<ListItem>
{/* <Link href={"/tutorials/noogle"} target="_blank"> */}
{/* <ListItemButton> */}
<ListItemText
primary="Improve position tracking"
secondary="Contribute to Noogle"
/>
{/* </ListItemButton> */}
{/* </Link> */}
</ListItem>
</List>
</Typography>
</>
)}
</div>
);
};

View File

@ -0,0 +1,141 @@
"use client";
import { ChevronLeft, ChevronRight } from "@mui/icons-material";
import { Box, Button, Link, Typography } from "@mui/material";
import { useSessionStorage } from "usehooks-ts";
import { FilterOptions } from "./layout/filterContext";
// import { useRouter } from "next/navigation";
// import d from "./example.json";
import { PagefindResult, usePagefindSearch } from "./Pagefind";
import { useEffect, useMemo, useState } from "react";
import { usePathname } from "next/navigation";
export const SearchNav = () => {
const [filterOptions] = useSessionStorage<FilterOptions | null>(
"currentFilterOptions",
null
);
if (!filterOptions) {
return null;
}
return <Navigation filterOptions={filterOptions} />;
};
// const prev = {
// url: "/f/builtins/genList.html",
// content:
// "builtins.genList Primop. Takes 2 arguments. generator, length. Generate list of size length, with each element i equal to the value returned by generator i. For example, builtins.genList (x: x * x) 5 returns the list [ 0 1 4 9 16 ]. Noogle also knows. Aliases. lib.genList. lib.lists.genList. lib.strings.genList.",
// word_count: 49,
// filters: {
// "type-to": ["Any"],
// "type-from": ["Any"],
// },
// meta: {
// title: "builtins.genList Primop.",
// },
// anchors: [],
// weighted_locations: [],
// locations: [],
// raw_content:
// "builtins.genList Primop. Takes 2 arguments. generator, length. Generate list of size length, with each element i equal to the value returned by generator i. For example, builtins.genList (x: x * x) 5 returns the list [ 0 1 4 9 16 ]. Noogle also knows. Aliases. lib.genList. lib.lists.genList. lib.strings.genList.",
// raw_url: "/f/builtins/genList.html",
// excerpt:
// "builtins.genList Primop. Takes 2 arguments. generator, length. Generate list of size length, with each element i equal to the value returned by generator i. For example, builtins.genList (x: x *",
// sub_results: [],
// };
// const next = prev;
export const Navigation = ({
filterOptions,
}: {
filterOptions: FilterOptions;
}) => {
const { filter, input } = filterOptions;
const term = input || null;
const from = filter?.from || null;
const to = filter?.to || null;
const { search } = usePagefindSearch();
const pathname = usePathname();
const [items, setItems] = useState<PagefindResult[] | null>(null);
useEffect(() => {
const init = async () => {
console.log({ search, term, filters: { from, to } });
if (search) {
let raw =
(await search(term, { filters: { from, to } }))?.results || [];
let items = await Promise.all(raw.map(async (r) => await r.data()));
setItems(items);
}
};
init();
}, [term, from, to, search]);
// TODO: show skeleton
const { prev, next } = useMemo(() => {
if (items == null) {
return {
loading: true,
prev: undefined,
next: undefined,
};
}
const currIdx = items.findIndex((item) => item.url.includes(pathname));
return {
prev: currIdx - 1 >= 0 ? items[currIdx - 1] : null,
next: currIdx <= items.length ? items[currIdx + 1] : null,
loading: false,
};
}, [pathname, items]);
return (
<Box sx={{ display: "flex", alignItems: "center" }}>
{prev && (
<Link
href={prev.url}
sx={{
my: 2,
py: 1,
px: 2,
display: "block",
borderStyle: "solid",
borderColor: "primary.main",
borderWidth: 1,
borderRadius: 3,
}}
>
<Button aria-label="Prev result" startIcon={<ChevronLeft />}>
Previous
</Button>
<Typography variant="subtitle1">{prev.meta.title}</Typography>
</Link>
)}
{next && (
<Link
href={next.url}
sx={{
ml: "auto",
my: 2,
py: 1,
px: 2,
display: "block",
borderStyle: "solid",
borderColor: "primary.main",
borderWidth: 1,
borderRadius: 3,
}}
>
<Button aria-label="Prev result" endIcon={<ChevronRight />}>
Next
</Button>
<Typography variant="subtitle1">{next.meta.title}</Typography>
</Link>
)}
</Box>
);
};

View File

@ -12,7 +12,7 @@ import {
} from "react";
import { useSessionStorage } from "usehooks-ts";
export const FilterContext = createContext<UseFilter>({} as UseFilter);
export const FilterContext = createContext<UseFilter | null>(null);
export type UseFilter = {
showFilter: boolean;
@ -29,6 +29,8 @@ export type UseFilter = {
export type FilterOptions = {
input?: string;
filter?: { from: string; to: string };
page?: number;
limit?: number;
};
export const FilterProvider = ({ children }: { children: ReactNode }) => {
@ -47,12 +49,11 @@ export const FilterProvider = ({ children }: { children: ReactNode }) => {
const [to, setTo] = useState(params.get("to") || "any");
const [term, setTerm] = useState(params.get("term") || "");
const submit = ({ input, filter }: FilterOptions) => {
const submit = ({ input, filter, page, limit }: FilterOptions) => {
const _term = input !== undefined ? input : term;
const _from = filter?.from || from;
const _to = filter?.to || to;
console.log({ _term });
if (_term && _term.trim() !== "") {
query.set("term", _term);
} else {
@ -69,7 +70,14 @@ export const FilterProvider = ({ children }: { children: ReactNode }) => {
} else {
query.delete("to");
}
persistFilterOptions({ input: _term, filter: { from: _from, to: _to } });
persistFilterOptions({
input: _term,
filter: { from: _from, to: _to },
page: +params.get("page")! || undefined,
limit: +params.get("limit")! || undefined,
});
if (page) query.set("page", page.toString());
if (limit) query.set("page", limit.toString());
router.push(`/q?${query.toString()}`);
};
return (
@ -91,4 +99,10 @@ export const FilterProvider = ({ children }: { children: ReactNode }) => {
);
};
export const useFilter = () => useContext(FilterContext);
export const useFilter = () => {
const filter = useContext(FilterContext);
if (filter === null) {
throw "UseFilter used outside of FilterContext!";
}
return filter;
};

View File

@ -1,7 +1,7 @@
"use client";
import ClearIcon from "@mui/icons-material/Clear";
import SearchIcon from "@mui/icons-material/Search";
import { Autocomplete, Badge, Divider, Input } from "@mui/material";
import { Autocomplete, Badge, Divider, TextField } from "@mui/material";
import IconButton from "@mui/material/IconButton";
import Paper from "@mui/material/Paper";
import React from "react";
@ -9,6 +9,12 @@ import { data } from "@/models/data";
import TuneIcon from "@mui/icons-material/Tune";
import { useFilter } from "../layout/filterContext";
// import dynamic from "next/dynamic";
// const data = dynamic(() => import("@/models/data"), {
// loading: () => <p>Loading...</p>,
// });
export interface SearchInputProps {
placeholder: string;
}
@ -80,28 +86,28 @@ export function SearchInput(props: SearchInputProps) {
} as React.ChangeEvent<HTMLInputElement>);
}}
value={term}
renderInput={(params) => {
return (
<Input
disableUnderline
sx={{
"& .MuiInputBase-root": {
ml: 1,
flex: 1,
backgroundColor: "paper.main",
px: 1,
py: 0,
},
}}
value={term}
onChange={(e) => handleType(e)}
placeholder={placeholder}
{...params}
{...params.InputProps}
endAdornment={undefined}
/>
);
}}
renderInput={(params) => (
<TextField
{...params}
InputProps={{
...params.InputProps,
type: "search",
disableUnderline: true,
endAdornment: undefined,
placeholder: placeholder,
}}
value={term}
onChange={(e) => handleType(e)}
variant="standard"
sx={{
"& .MuiInputBase-root": {
backgroundColor: "paper.main",
px: 1,
py: 0,
},
}}
/>
)}
/>
<IconButton
aria-haspopup="true"