From 142d783d11dbdc30d684d96d14f1f83b5853608d Mon Sep 17 00:00:00 2001 From: Mitchell Valine Date: Fri, 5 Nov 2021 13:12:42 -0700 Subject: [PATCH 1/6] feat: break up api rendering Break up the rendering of a packages documentation into multiple pages. The root package page now renders the readme, and each type within the packages API reference is rendered on its own page. Items within the readme and types are still linkable via hash links that are scrolled to by the PackageDocs component. The rendering is broken up by splitting the incoming markdown on the `API Reference` header and then on subsequent headers within the api reference markdown string. These are then parsed into menu items and a hash map of `typeid -> type markdown content`. Adds unit tests for markdown parsing logic to ensure outputs remain stable. fix: #560 --- .projen/deps.json | 4 + .projen/tasks.json | 254 ++++++++++++------------- .projenrc.js | 1 + package.json | 27 +-- src/__mocks__/remark-emoji.ts | 2 + src/components/NavTree/NavTree.tsx | 71 ++++--- src/views/Package/PackageDocs.tsx | 114 ++++------- src/views/Package/PackageLayout.tsx | 35 +--- src/views/Package/PackageReadme.tsx | 17 ++ src/views/Package/PackageState.test.ts | 169 ++++++++++++++++ src/views/Package/PackageState.tsx | 211 +++++++++++++++++++- src/views/Package/PackageTypeDocs.tsx | 42 ++++ yarn.lock | 18 +- 13 files changed, 685 insertions(+), 280 deletions(-) create mode 100644 src/__mocks__/remark-emoji.ts create mode 100644 src/views/Package/PackageReadme.tsx create mode 100644 src/views/Package/PackageState.test.ts create mode 100644 src/views/Package/PackageTypeDocs.tsx diff --git a/.projen/deps.json b/.projen/deps.json index 49c1bb61..b3c0c1aa 100644 --- a/.projen/deps.json +++ b/.projen/deps.json @@ -232,6 +232,10 @@ "name": "rehype-sanitize", "type": "runtime" }, + { + "name": "remark", + "type": "runtime" + }, { "name": "remark-emoji", "type": "runtime" diff --git a/.projen/tasks.json b/.projen/tasks.json index 4a838b97..74441221 100644 --- a/.projen/tasks.json +++ b/.projen/tasks.json @@ -1,10 +1,50 @@ { "tasks": { - "analyze-exports": { - "name": "analyze-exports", + "clobber": { + "name": "clobber", + "description": "hard resets to HEAD of origin and cleans the local repo", + "env": { + "BRANCH": "$(git branch --show-current)" + }, "steps": [ { - "exec": "node scripts/analyze-exports" + "exec": "git checkout -b scratch", + "name": "save current HEAD in \"scratch\" branch" + }, + { + "exec": "git checkout $BRANCH" + }, + { + "exec": "git fetch origin", + "name": "fetch latest changes from origin" + }, + { + "exec": "git reset --hard origin/$BRANCH", + "name": "hard reset to origin commit" + }, + { + "exec": "git clean -fdx", + "name": "clean all untracked files" + }, + { + "say": "ready to rock! (unpushed commits are under the \"scratch\" branch)" + } + ], + "condition": "git diff --exit-code > /dev/null" + }, + "compile": { + "name": "compile", + "description": "Only compile" + }, + "test": { + "name": "test", + "description": "Run tests", + "steps": [ + { + "spawn": "eslint" + }, + { + "exec": "react-app-rewired test --watchAll=false" } ] }, @@ -48,57 +88,48 @@ ], "condition": "! git log --oneline -1 | grep -q \"chore(release):\"" }, - "clobber": { - "name": "clobber", - "description": "hard resets to HEAD of origin and cleans the local repo", + "unbump": { + "name": "unbump", + "description": "Restores version to 0.0.0", "env": { - "BRANCH": "$(git branch --show-current)" + "OUTFILE": "package.json", + "CHANGELOG": "dist/changelog.md", + "BUMPFILE": "dist/version.txt", + "RELEASETAG": "dist/releasetag.txt" }, "steps": [ { - "exec": "git checkout -b scratch", - "name": "save current HEAD in \"scratch\" branch" - }, - { - "exec": "git checkout $BRANCH" - }, - { - "exec": "git fetch origin", - "name": "fetch latest changes from origin" - }, - { - "exec": "git reset --hard origin/$BRANCH", - "name": "hard reset to origin commit" - }, - { - "exec": "git clean -fdx", - "name": "clean all untracked files" - }, - { - "say": "ready to rock! (unpushed commits are under the \"scratch\" branch)" + "builtin": "release/reset-version" } - ], - "condition": "git diff --exit-code > /dev/null" - }, - "compile": { - "name": "compile", - "description": "Only compile" + ] }, - "cypress:open": { - "name": "cypress:open", - "description": "open the cypress test runner UI", + "publish:github": { + "name": "publish:github", + "description": "Publish this package to GitHub Releases", + "requiredEnv": [ + "GITHUB_TOKEN", + "GITHUB_REPOSITORY", + "GITHUB_REF" + ], "steps": [ { - "exec": "cypress open" + "exec": "errout=$(mktemp); gh release create $(cat dist/releasetag.txt) -R $GITHUB_REPOSITORY -F dist/changelog.md -t $(cat dist/releasetag.txt) --target $GITHUB_REF 2> $errout && true; exitcode=$?; if [ $exitcode -ne 0 ] && ! grep -q \"Release.tag_name already exists\" $errout; then cat $errout; exit $exitcode; fi" } ] }, - "cypress:run": { - "name": "cypress:run", - "description": "run the cypress suite in CLI", + "publish:npm": { + "name": "publish:npm", + "description": "Publish this package to npm", + "env": { + "NPM_DIST_TAG": "latest", + "NPM_REGISTRY": "registry.npmjs.org" + }, + "requiredEnv": [ + "NPM_TOKEN" + ], "steps": [ { - "exec": "cypress run" + "exec": "npx -p jsii-release@latest jsii-release-npm" } ] }, @@ -110,6 +141,39 @@ } ] }, + "watch": { + "name": "watch", + "description": "Watch & compile in the background", + "steps": [ + { + "exec": "tsc --build -w" + } + ] + }, + "package": { + "name": "package", + "description": "Create an npm tarball", + "steps": [ + { + "exec": "mkdir -p dist/js" + }, + { + "exec": "yarn pack" + }, + { + "exec": "mv *.tgz dist/js/" + } + ] + }, + "eslint": { + "name": "eslint", + "description": "Runs eslint against the codebase", + "steps": [ + { + "exec": "eslint --ext .ts,.tsx --fix --no-error-on-unmatched-pattern src test build-tools .projenrc.js" + } + ] + }, "dev": { "name": "dev", "description": "Starts the react application", @@ -128,27 +192,21 @@ } ] }, - "eslint": { - "name": "eslint", - "description": "Runs eslint against the codebase", + "cypress:open": { + "name": "cypress:open", + "description": "open the cypress test runner UI", "steps": [ { - "exec": "eslint --ext .ts,.tsx --fix --no-error-on-unmatched-pattern src test build-tools .projenrc.js" + "exec": "cypress open" } ] }, - "package": { - "name": "package", - "description": "Create an npm tarball", + "cypress:run": { + "name": "cypress:run", + "description": "run the cypress suite in CLI", "steps": [ { - "exec": "mkdir -p dist/js" - }, - { - "exec": "yarn pack" - }, - { - "exec": "mv *.tgz dist/js/" + "exec": "cypress run" } ] }, @@ -168,33 +226,27 @@ } ] }, - "publish:github": { - "name": "publish:github", - "description": "Publish this package to GitHub Releases", - "requiredEnv": [ - "GITHUB_TOKEN", - "GITHUB_REPOSITORY", - "GITHUB_REF" - ], + "test:unit": { + "name": "test:unit", "steps": [ { - "exec": "errout=$(mktemp); gh release create $(cat dist/releasetag.txt) -R $GITHUB_REPOSITORY -F dist/changelog.md -t $(cat dist/releasetag.txt) --target $GITHUB_REF 2> $errout && true; exitcode=$?; if [ $exitcode -ne 0 ] && ! grep -q \"Release.tag_name already exists\" $errout; then cat $errout; exit $exitcode; fi" + "exec": "npx react-app-rewired test" } ] }, - "publish:npm": { - "name": "publish:npm", - "description": "Publish this package to npm", - "env": { - "NPM_DIST_TAG": "latest", - "NPM_REGISTRY": "registry.npmjs.org" - }, - "requiredEnv": [ - "NPM_TOKEN" - ], + "test:update": { + "name": "test:update", "steps": [ { - "exec": "npx -p jsii-release@latest jsii-release-npm" + "exec": "npx react-app-rewired test -u" + } + ] + }, + "analyze-exports": { + "name": "analyze-exports", + "steps": [ + { + "exec": "node scripts/analyze-exports" } ] }, @@ -222,49 +274,6 @@ } ] }, - "test": { - "name": "test", - "description": "Run tests", - "steps": [ - { - "spawn": "eslint" - }, - { - "exec": "react-app-rewired test --watchAll=false" - } - ] - }, - "test:unit": { - "name": "test:unit", - "steps": [ - { - "exec": "npx react-app-rewired test" - } - ] - }, - "test:update": { - "name": "test:update", - "steps": [ - { - "exec": "npx react-app-rewired test -u" - } - ] - }, - "unbump": { - "name": "unbump", - "description": "Restores version to 0.0.0", - "env": { - "OUTFILE": "package.json", - "CHANGELOG": "dist/changelog.md", - "BUMPFILE": "dist/version.txt", - "RELEASETAG": "dist/releasetag.txt" - }, - "steps": [ - { - "builtin": "release/reset-version" - } - ] - }, "upgrade": { "name": "upgrade", "description": "upgrade dependencies", @@ -297,15 +306,6 @@ "exec": "npx projen" } ] - }, - "watch": { - "name": "watch", - "description": "Watch & compile in the background", - "steps": [ - { - "exec": "tsc --build -w" - } - ] } }, "env": { diff --git a/.projenrc.js b/.projenrc.js index 80f7bf2b..12f61876 100644 --- a/.projenrc.js +++ b/.projenrc.js @@ -49,6 +49,7 @@ const project = new web.ReactTypeScriptProject({ "react-router-dom", "rehype-raw", "rehype-sanitize", + "remark", "remark-emoji", "remark-gfm", // PWA Functionality diff --git a/package.json b/package.json index c9195dee..379ce007 100644 --- a/package.json +++ b/package.json @@ -5,29 +5,29 @@ "url": "https://github.com/cdklabs/construct-hub-webapp.git" }, "scripts": { - "analyze-exports": "npx projen analyze-exports", - "build": "npx projen build", - "bump": "npx projen bump", "clobber": "npx projen clobber", "compile": "npx projen compile", - "cypress:open": "npx projen cypress:open", - "cypress:run": "npx projen cypress:run", + "test": "npx projen test", + "build": "npx projen build", + "bump": "npx projen bump", + "unbump": "npx projen unbump", + "publish:github": "npx projen publish:github", + "publish:npm": "npx projen publish:npm", "default": "npx projen default", + "watch": "npx projen watch", + "package": "npx projen package", + "eslint": "npx projen eslint", "dev": "npx projen dev", "eject": "npx projen eject", - "eslint": "npx projen eslint", - "package": "npx projen package", + "cypress:open": "npx projen cypress:open", + "cypress:run": "npx projen cypress:run", "proxy-server": "npx projen proxy-server", "proxy-server:ci": "npx projen proxy-server:ci", - "publish:github": "npx projen publish:github", - "publish:npm": "npx projen publish:npm", - "release": "npx projen release", - "test": "npx projen test", "test:unit": "npx projen test:unit", "test:update": "npx projen test:update", - "unbump": "npx projen unbump", + "analyze-exports": "npx projen analyze-exports", + "release": "npx projen release", "upgrade": "npx projen upgrade", - "watch": "npx projen watch", "projen": "npx projen" }, "author": { @@ -94,6 +94,7 @@ "react-scripts": "^4.0.0", "rehype-raw": "^5.1.0", "rehype-sanitize": "^4.0.0", + "remark": "^13.0.0", "remark-emoji": "*", "remark-gfm": "^1.0.0", "web-vitals": "^1.1.2", diff --git a/src/__mocks__/remark-emoji.ts b/src/__mocks__/remark-emoji.ts new file mode 100644 index 00000000..b1ea52e2 --- /dev/null +++ b/src/__mocks__/remark-emoji.ts @@ -0,0 +1,2 @@ +// Don't process emoji for tests +export default (i: any) => i; diff --git a/src/components/NavTree/NavTree.tsx b/src/components/NavTree/NavTree.tsx index d5d286f9..7aa93e45 100644 --- a/src/components/NavTree/NavTree.tsx +++ b/src/components/NavTree/NavTree.tsx @@ -1,13 +1,12 @@ import { ChevronDownIcon, ChevronRightIcon } from "@chakra-ui/icons"; -import { Box, Flex, Link, IconButton, useDisclosure } from "@chakra-ui/react"; -import { FunctionComponent, useMemo } from "react"; -import { useLocation } from "react-router-dom"; +import { Box, Flex, IconButton, Text, useDisclosure } from "@chakra-ui/react"; +import { FunctionComponent, useMemo, ReactNode } from "react"; import { NavLink } from "../NavLink"; export interface NavItemConfig { - children?: NavItemConfig[]; - display: string; - url: string; + children: NavItemConfig[]; + title: string; + path?: string; } export interface NavItemProps extends NavItemConfig { @@ -28,22 +27,49 @@ const iconProps = { w: 4, }; -const NavItem: FunctionComponent = ({ +interface NavItemWrapperProps { + path?: string; + title: string; + showToggle: boolean; + children: ReactNode; +} + +const NavItemWrapper: FunctionComponent = ({ children, - display, - url, + path, + title, + showToggle, +}) => { + const sharedProps = { + _hover: { bg: "rgba(0, 124, 253, 0.05)" }, + overflow: "hidden", + pl: showToggle ? 1 : 2, + py: 1.5, + textOverflow: "ellipsis", + w: "100%", + }; + + return path ? ( + + {children} + + ) : ( + {children} + ); +}; + +export const NavItem: FunctionComponent = ({ + children, + title, + path, onOpen, }) => { - const { pathname, hash } = useLocation(); - const isHashUrl = url.startsWith("#"); - const linkIsActive = isHashUrl ? hash === url : pathname === url; + const linkIsActive = false; const disclosure = useDisclosure({ onOpen, defaultIsOpen: true }); const showToggle = (children?.length ?? 0) > 0; const showChildren = disclosure.isOpen && showToggle; - const LinkComponent = isHashUrl ? Link : NavLink; - const nestedItems = useMemo( () => children?.map((item, idx) => { @@ -74,20 +100,9 @@ const NavItem: FunctionComponent = ({ w={4} /> )} - - {display} - + + {title} + { - if (!(item instanceof HTMLElement)) { - return itemTree; - } - - const { headingId, headingLevel = "100" } = item.dataset; - const { innerText } = item; - const level = parseInt(headingLevel); - - // Don't create nav items for items with no title / url - if (level > 3 || !innerText || !headingId) { - return itemTree; - } - - const last = itemTree[itemTree.length - 1]; - - if (last == null || last.level >= level) { - return [ - ...itemTree, - { - display: innerText, - url: `#${headingId}`, - level, - children: [], - }, - ]; - } else { - last.children = appendItem(last.children, item); - return itemTree; - } -}; +import { PackageReadme } from "./PackageReadme"; +import { usePackageState } from "./PackageState"; +import { PackageTypeDocs } from "./PackageTypeDocs"; // We want the nav to be sticky, but it should account for the sticky heading as well, which is 72px const TOP_OFFSET = "4.5rem"; -export const PackageDocs: FunctionComponent = ({ - markdown: source, - assembly, -}) => { - const [navItems, setNavItems] = useState([]); - - useEffect(() => { - const tree = [ - ...document.querySelectorAll( - `[data-heading-id][data-heading-title][data-heading-level]` - ), - ].reduce(appendItem, []); +const SubmoduleSelector: FunctionComponent = () => { + const { + assembly: { data }, + } = usePackageState(); - setNavItems(tree); - }, [source]); + return Object.keys(data?.submodules ?? {}).length > 0 ? ( + + + + ) : null; +}; +export const PackageDocs: FunctionComponent = () => { + const { path } = useRouteMatch(); + const { menuItems } = usePackageState(); const { hash } = useLocation(); useEffect(() => { if (hash) { const target = document.querySelector(`${hash}`) as HTMLElement; target?.scrollIntoView(true); + } else { + window.scrollTo(0, 0); } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [source]); - - const markdown = useMemo( - () => {source}, - [assembly.repository, source] - ); + }); return ( = ({ pr={4} top={TOP_OFFSET} > - {Object.keys(assembly?.submodules ?? {}).length > 0 && ( - - - - )} + - + = ({ }, }} > - {markdown} + + + + + + + + ); diff --git a/src/views/Package/PackageLayout.tsx b/src/views/Package/PackageLayout.tsx index f5275a3e..df28cbb8 100644 --- a/src/views/Package/PackageLayout.tsx +++ b/src/views/Package/PackageLayout.tsx @@ -1,6 +1,4 @@ import { - Center, - Spinner, Grid, Tab, TabList, @@ -13,23 +11,12 @@ import { Page } from "../../components/Page"; import { DependenciesList } from "./DependenciesList"; import { FeedbackLinks } from "./FeedbackLinks"; import { PackageDocs } from "./PackageDocs"; -import { PackageDocsError } from "./PackageDocsError"; -import { PackageDocsUnsupported } from "./PackageDocsUnsupported"; import { PackageHeader } from "./PackageHeader"; import { usePackageState } from "./PackageState"; import testIds from "./testIds"; export const PackageLayout: FunctionComponent = () => { - const { - assembly, - hasDocs, - hasError, - isLoadingDocs, - isSupported, - markdown, - pageDescription, - pageTitle, - } = usePackageState(); + const { pageDescription, pageTitle } = usePackageState(); const [tabIndex, setTabIndex] = useState(0); @@ -58,25 +45,7 @@ export const PackageLayout: FunctionComponent = () => { - {/* Readme and Api Reference Area */} - {isSupported ? ( - hasError ? ( - - ) : isLoadingDocs ? ( -
- -
- ) : ( - hasDocs && ( - - ) - ) - ) : ( - - )} +
diff --git a/src/views/Package/PackageReadme.tsx b/src/views/Package/PackageReadme.tsx new file mode 100644 index 00000000..8618281a --- /dev/null +++ b/src/views/Package/PackageReadme.tsx @@ -0,0 +1,17 @@ +import { FunctionComponent } from "react"; +import { Markdown } from "../../components/Markdown"; +import { usePackageState } from "./PackageState"; + +export const PackageReadme: FunctionComponent = () => { + const { + isLoadingDocs, + readme, + assembly: { data: assembly }, + } = usePackageState(); + + if (isLoadingDocs || !readme || !assembly) { + return null; + } + + return {readme}; +}; diff --git a/src/views/Package/PackageState.test.ts b/src/views/Package/PackageState.test.ts new file mode 100644 index 00000000..36aa7131 --- /dev/null +++ b/src/views/Package/PackageState.test.ts @@ -0,0 +1,169 @@ +import { parseMarkdownStructure } from "./PackageState"; + +jest.mock("remark-emoji"); +const README_MARKDOWN = ` +# Title 1 + +SomeBodyText + +## Title 2 + +SomeBodyText +`; + +// Data types correspond to headings for `Constructs`, `Structs`, etc. +const DATA_TYPE_1 = "DATA_TYPE_1"; +const DATA_TYPE_2 = "DATA_TYPE_2"; + +// Instance of given data type, aka a name of a class, construct, etc. +const MY_DATA_TYPE_1 = "MY_DATA_TYPE_1"; +const MY_DATA_TYPE_2 = "MY_DATA_TYPE_2"; + +const MY_DATA_TYPE_22Body = `${MY_DATA_TYPE_2}-2Body + +#### HEADER4 + +This is not parsed out +This is another line not parsed out`; + +const MARKDOWN_INPUT = `${README_MARKDOWN} +# API Reference + +## ${DATA_TYPE_1} + +### ${MY_DATA_TYPE_1}-1 + +${MY_DATA_TYPE_1}-1Body + +### ${MY_DATA_TYPE_1}-2 + +${MY_DATA_TYPE_1}-2Body + +## ${DATA_TYPE_2} + +### ${MY_DATA_TYPE_2}-1 + +${MY_DATA_TYPE_2}-1Body + +### ${MY_DATA_TYPE_2}-2 +${MY_DATA_TYPE_22Body}`; + +const packageData = { + scope: "@packageScope", + name: "packageName", + version: "0.0.0", + language: "language", +}; + +describe("parseMarkdownStructure", () => { + it("separates readme", () => { + const { readme } = parseMarkdownStructure(MARKDOWN_INPUT, packageData); + expect(readme).toEqual(README_MARKDOWN); + }); + + it("parses api reference structure", () => { + const { apiReference } = parseMarkdownStructure( + MARKDOWN_INPUT, + packageData + ); + expect(apiReference).toEqual({ + [`${MY_DATA_TYPE_1}-1`]: { + title: `${MY_DATA_TYPE_1}-1`, + content: `${MY_DATA_TYPE_1}-1Body`, + }, + [`${MY_DATA_TYPE_1}-2`]: { + title: `${MY_DATA_TYPE_1}-2`, + content: `${MY_DATA_TYPE_1}-2Body`, + }, + [`${MY_DATA_TYPE_2}-1`]: { + title: `${MY_DATA_TYPE_2}-1`, + content: `${MY_DATA_TYPE_2}-1Body`, + }, + [`${MY_DATA_TYPE_2}-2`]: { + title: `${MY_DATA_TYPE_2}-2`, + content: MY_DATA_TYPE_22Body, + }, + }); + }); + + it("parses out menu items", () => { + const { menuItems } = parseMarkdownStructure(MARKDOWN_INPUT, packageData); + const basePath = "/packages/@packageScope/packageName/v/0.0.0"; + const langQuery = "?lang=language"; + const baseHashPath = `${basePath}${langQuery}`; + expect(menuItems).toEqual([ + { + level: 1, + id: "Readme", + title: "Readme", + path: baseHashPath, + children: [ + { + level: 1, + id: "title-1", + title: "Title 1", + path: `${baseHashPath}#title-1`, + children: [ + { + level: 2, + id: "title-2", + title: "Title 2", + path: `${baseHashPath}#title-2`, + children: [], + }, + ], + }, + ], + }, + { + level: 1, + id: "api-reference", + title: "API Reference", + children: [ + { + level: 2, + id: DATA_TYPE_1, + title: DATA_TYPE_1, + children: [ + { + level: 3, + id: `${MY_DATA_TYPE_1}-1`, + title: `${MY_DATA_TYPE_1}-1`, + path: `${basePath}/${MY_DATA_TYPE_1}-1${langQuery}`, + children: [], + }, + { + level: 3, + id: `${MY_DATA_TYPE_1}-2`, + title: `${MY_DATA_TYPE_1}-2`, + path: `${basePath}/${MY_DATA_TYPE_1}-2${langQuery}`, + children: [], + }, + ], + }, + { + level: 2, + id: DATA_TYPE_2, + title: DATA_TYPE_2, + children: [ + { + level: 3, + id: `${MY_DATA_TYPE_2}-1`, + title: `${MY_DATA_TYPE_2}-1`, + path: `${basePath}/${MY_DATA_TYPE_2}-1${langQuery}`, + children: [], + }, + { + level: 3, + id: `${MY_DATA_TYPE_2}-2`, + title: `${MY_DATA_TYPE_2}-2`, + path: `${basePath}/${MY_DATA_TYPE_2}-2${langQuery}`, + children: [], + }, + ], + }, + ], + }, + ]); + }); +}); diff --git a/src/views/Package/PackageState.tsx b/src/views/Package/PackageState.tsx index eb24be7f..d9069f23 100644 --- a/src/views/Package/PackageState.tsx +++ b/src/views/Package/PackageState.tsx @@ -1,6 +1,15 @@ import type { Assembly } from "@jsii/spec"; -import { createContext, FunctionComponent, useContext, useEffect } from "react"; +import { + createContext, + FunctionComponent, + useContext, + useEffect, + useMemo, + useState, +} from "react"; import { useParams } from "react-router-dom"; +import remark from "remark"; +import emoji from "remark-emoji"; import { fetchAssembly } from "../../api/package/assembly"; import { fetchMarkdown } from "../../api/package/docs"; import { fetchMetadata, Metadata } from "../../api/package/metadata"; @@ -9,6 +18,7 @@ import { QUERY_PARAMS } from "../../constants/url"; import { useLanguage } from "../../hooks/useLanguage"; import { useQueryParams } from "../../hooks/useQueryParams"; import { useRequest, UseRequestResponse } from "../../hooks/useRequest"; +import { sanitize } from "../../util/sanitize-anchor"; import { NotFound } from "../NotFound"; interface PathParams { @@ -17,7 +27,23 @@ interface PathParams { version: string; } +interface MenuItem { + id: string; + path?: string; + title: string; + children: MenuItem[]; + level: number; +} + +interface Types { + [id: string]: { + title: string; + content: string; + }; +} + interface PackageState { + apiReference?: Types; assembly: UseRequestResponse; hasDocs: boolean; hasError: boolean; @@ -29,8 +55,10 @@ interface PackageState { name: string; pageDescription: string; pageTitle: string; + readme?: string; scope?: string; version: string; + menuItems: MenuItem[]; } const PackageStateContext = createContext(undefined); @@ -50,6 +78,155 @@ export const usePackageState = () => { return state; }; +export const appendMenuItem = ( + items: MenuItem[], + item: MenuItem +): MenuItem[] => { + const last = items[items.length - 1]; + + if (last && last.level < item.level) { + last.children = appendMenuItem(last.children, item); + return items; + } + return [...items, item]; +}; + +export const splitOnHeaders = (md: string, level: number): string[] => { + if (!md) { + return []; + } + + const regex = new RegExp(`(^#{1,${level}}[^#].*)`, "gm"); + return ( + md + ?.split(regex) + // Trim lines and remove whitespace only entries + .reduce((accum: string[], str: string) => { + const newStr = str.trim(); + if (newStr === "") return accum; + return [...accum, newStr]; + }, []) + ); +}; + +const getHeaderAttributes = (hdr: string): { id: string; title: string } => { + const attrStrings = hdr.match(/(\S+)\s*=\s*(\"?)([^"]*)(\2|\s|$)/g) ?? []; + const attrs: { [key: string]: string } = attrStrings.reduce((accum, str) => { + const [key, value] = str.split("="); + const [_, parsedValue] = /['"](.*?)['"]/.exec(value) ?? []; + + return { + ...accum, + [key]: parsedValue, + }; + }, {}); + + const [_, rawTitle] = /^#*\s*([^<]+?)\s*(?:<|$)/.exec(hdr) ?? []; + const title: string = attrs["data-heading-title"] ?? rawTitle; + const id = attrs["data-heading-id"] ?? encodeURIComponent(sanitize(title)); + + return { id, title }; +}; + +/** + * Accept's markdown document from jsii-docgen with readme and api reference + * documentation and parses the content into a traversable map of menu items + * and types. This allows splitting the rendering of the readme and each item + * in the api reference. + */ +export const parseMarkdownStructure = ( + input: string, + { + scope, + language, + name, + version, + }: { scope?: string; language: string; name: string; version: string } +): { readme: string; apiReference: Types; menuItems: MenuItem[] } => { + const nameSegment = scope ? `${scope}/${name}` : `${name}`; + const basePath = `/packages/${nameSegment}/v/${version}`; + const langQuery = `?${QUERY_PARAMS.LANGUAGE}=${language}`; + const separator = + '\n# API Reference \n'; + + // split into readme and api reference + const segments = input.split(separator); + + // Take the last chunk after the separator + // segments.pop() always returns when length > 1; + const apiReferenceStr = segments.length > 1 ? segments.pop()! : ""; + + // Rejoin all the previous chunks in case the readme has the same Separator + const readmeStr = segments.join(separator); + + //split each on headers + const apiReferenceSplit = splitOnHeaders(apiReferenceStr, 3); + const readmeSplit = splitOnHeaders(readmeStr, 6); + + // Add back api reference title for use as menu item + const apiReferenceParsed = [separator.trim(), ...(apiReferenceSplit ?? [])]; + + const readmeMenuItems = [ + { + level: 1, + id: "Readme", + title: "Readme", + path: `${basePath}${langQuery}`, + children: readmeSplit.reduce((accum: MenuItem[], str: string) => { + if (str.startsWith("#")) { + const { id, title } = getHeaderAttributes(str); + const level = str.match(/(#)/gm)?.length ?? 1; + const menuItem = { + level, + id, + title, + // root package path plus hash for header on readme item + path: `${basePath}${langQuery}#${id}`, + children: [], + }; + return appendMenuItem(accum, menuItem); + } + return accum; + }, []), + }, + ]; + + let menuItems: MenuItem[] = [...readmeMenuItems]; + const types: Types = {}; + + let prevType: { id: string; title: string }; + apiReferenceParsed?.forEach((str) => { + // TODO get attributes off of embedded html + const isHeader = str.match(/(^#{1,3}[^#].*)/gm); + if (isHeader?.length) { + const { id, title } = getHeaderAttributes(str); + const level = str.match(/(#)/gm)?.length ?? 1; + + // root package path plus type id segment + // only level 3 headers are types in api reference + const path = level === 3 ? `${basePath}/${id}${langQuery}` : undefined; + const menuItem = { + level, + id, + title, + children: [], + ...(path ? { path } : {}), + }; + + menuItems = appendMenuItem(menuItems, menuItem); + prevType = { id, title }; + } else { + types[prevType.id] = { title: prevType.title, content: str }; + } + }); + + return { + readme: segments.join(separator), + apiReference: types, + menuItems, + }; +}; + /** * Provides shared page-level data to components on the Package page */ @@ -104,6 +281,37 @@ export const PackageStateProvider: FunctionComponent = ({ children }) => { (assemblyResponse.loading || markdownResponse.loading) ); + const [processedMd, setProcessMd] = useState(); + + useEffect(() => { + let isMounted = true; + if (markdownResponse.data) { + void remark() + .use(emoji) + .process(markdownResponse.data) + .then((file) => { + if (isMounted) { + setProcessMd(String(file)); + } + }); + } + + return () => { + isMounted = false; + }; + }, [markdownResponse]); + + const parsedMd = useMemo(() => { + if (!processedMd) return { menuItems: [] }; + + return parseMarkdownStructure(processedMd, { + scope, + name, + version, + language, + }); + }, [processedMd, name, scope, version, language]); + // Handle missing JSON for assembly if (assemblyResponse.error) { return ; @@ -125,6 +333,7 @@ export const PackageStateProvider: FunctionComponent = ({ children }) => { pageTitle, scope, version, + ...parsedMd, }} > {children} diff --git a/src/views/Package/PackageTypeDocs.tsx b/src/views/Package/PackageTypeDocs.tsx new file mode 100644 index 00000000..ffad521f --- /dev/null +++ b/src/views/Package/PackageTypeDocs.tsx @@ -0,0 +1,42 @@ +import { Heading } from "@chakra-ui/react"; +import { FunctionComponent } from "react"; +import { useParams } from "react-router-dom"; +import { Markdown } from "../../components/Markdown"; +import { PackageDocsError } from "./PackageDocsError"; +import { usePackageState } from "./PackageState"; + +const usePackageTypeDocs = () => { + const { typeId }: { typeId?: string } = useParams(); + const { apiReference } = usePackageState(); + + const content = apiReference?.[typeId ?? ""]; + if (!typeId || !content) { + return; + } + + return content; +}; + +export const PackageTypeDocs: FunctionComponent = () => { + const { + isLoadingDocs, + assembly: { data: assembly }, + } = usePackageState(); + const docs = usePackageTypeDocs(); + + if (isLoadingDocs) { + return null; + } else if (!docs || !assembly) { + return ; + } + const { title, content } = docs; + + return ( + <> + + {title} + + {content} + + ); +}; diff --git a/yarn.lock b/yarn.lock index ffce3fce..7ca2243b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -12720,6 +12720,22 @@ remark-rehype@^8.0.0: dependencies: mdast-util-to-hast "^10.2.0" +remark-stringify@^9.0.0: + version "9.0.1" + resolved "https://registry.yarnpkg.com/remark-stringify/-/remark-stringify-9.0.1.tgz#576d06e910548b0a7191a71f27b33f1218862894" + integrity sha512-mWmNg3ZtESvZS8fv5PTvaPckdL4iNlCHTt8/e/8oN08nArHRHjNZMKzA/YW3+p7/lYqIw4nx1XsjCBo/AxNChg== + dependencies: + mdast-util-to-markdown "^0.6.0" + +remark@^13.0.0: + version "13.0.0" + resolved "https://registry.yarnpkg.com/remark/-/remark-13.0.0.tgz#d15d9bf71a402f40287ebe36067b66d54868e425" + integrity sha512-HDz1+IKGtOyWN+QgBiAT0kn+2s6ovOxHyPAFGKVE81VSzJ+mq7RwHFledEvB5F1p4iJvOah/LOKdFuzvRnNLCA== + dependencies: + remark-parse "^9.0.0" + remark-stringify "^9.0.0" + unified "^9.1.0" + remote-git-tags@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/remote-git-tags/-/remote-git-tags-3.0.0.tgz#424f8ec2cdea00bb5af1784a49190f25e16983c3" @@ -14447,7 +14463,7 @@ unicode-property-aliases-ecmascript@^2.0.0: resolved "https://registry.yarnpkg.com/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.0.0.tgz#0a36cb9a585c4f6abd51ad1deddb285c165297c8" integrity sha512-5Zfuy9q/DFr4tfO7ZPeVXb1aPoeQSdeFMLpYuFebehDAhbuevLs5yxSZmIFN1tP5F9Wl4IpJrYojg85/zgyZHQ== -unified@^9.0.0: +unified@^9.0.0, unified@^9.1.0: version "9.2.2" resolved "https://registry.yarnpkg.com/unified/-/unified-9.2.2.tgz#67649a1abfc3ab85d2969502902775eb03146975" integrity sha512-Sg7j110mtefBD+qunSLO1lqOEKdrwBFBrR6Qd8f4uwkhWNlbkaqwHse6e7QvD3AP/MNoJdEDLaf8OxYyoWgorQ== From 5b47e402991d83dbdda516190142e58ac09bdc62 Mon Sep 17 00:00:00 2001 From: github-actions Date: Fri, 5 Nov 2021 21:11:02 +0000 Subject: [PATCH 2/6] chore: self mutation --- .projen/tasks.json | 254 ++++++++++++++++++++++----------------------- package.json | 26 ++--- 2 files changed, 140 insertions(+), 140 deletions(-) diff --git a/.projen/tasks.json b/.projen/tasks.json index 74441221..4a838b97 100644 --- a/.projen/tasks.json +++ b/.projen/tasks.json @@ -1,50 +1,10 @@ { "tasks": { - "clobber": { - "name": "clobber", - "description": "hard resets to HEAD of origin and cleans the local repo", - "env": { - "BRANCH": "$(git branch --show-current)" - }, - "steps": [ - { - "exec": "git checkout -b scratch", - "name": "save current HEAD in \"scratch\" branch" - }, - { - "exec": "git checkout $BRANCH" - }, - { - "exec": "git fetch origin", - "name": "fetch latest changes from origin" - }, - { - "exec": "git reset --hard origin/$BRANCH", - "name": "hard reset to origin commit" - }, - { - "exec": "git clean -fdx", - "name": "clean all untracked files" - }, - { - "say": "ready to rock! (unpushed commits are under the \"scratch\" branch)" - } - ], - "condition": "git diff --exit-code > /dev/null" - }, - "compile": { - "name": "compile", - "description": "Only compile" - }, - "test": { - "name": "test", - "description": "Run tests", + "analyze-exports": { + "name": "analyze-exports", "steps": [ { - "spawn": "eslint" - }, - { - "exec": "react-app-rewired test --watchAll=false" + "exec": "node scripts/analyze-exports" } ] }, @@ -88,89 +48,65 @@ ], "condition": "! git log --oneline -1 | grep -q \"chore(release):\"" }, - "unbump": { - "name": "unbump", - "description": "Restores version to 0.0.0", + "clobber": { + "name": "clobber", + "description": "hard resets to HEAD of origin and cleans the local repo", "env": { - "OUTFILE": "package.json", - "CHANGELOG": "dist/changelog.md", - "BUMPFILE": "dist/version.txt", - "RELEASETAG": "dist/releasetag.txt" + "BRANCH": "$(git branch --show-current)" }, "steps": [ { - "builtin": "release/reset-version" - } - ] - }, - "publish:github": { - "name": "publish:github", - "description": "Publish this package to GitHub Releases", - "requiredEnv": [ - "GITHUB_TOKEN", - "GITHUB_REPOSITORY", - "GITHUB_REF" - ], - "steps": [ + "exec": "git checkout -b scratch", + "name": "save current HEAD in \"scratch\" branch" + }, { - "exec": "errout=$(mktemp); gh release create $(cat dist/releasetag.txt) -R $GITHUB_REPOSITORY -F dist/changelog.md -t $(cat dist/releasetag.txt) --target $GITHUB_REF 2> $errout && true; exitcode=$?; if [ $exitcode -ne 0 ] && ! grep -q \"Release.tag_name already exists\" $errout; then cat $errout; exit $exitcode; fi" - } - ] - }, - "publish:npm": { - "name": "publish:npm", - "description": "Publish this package to npm", - "env": { - "NPM_DIST_TAG": "latest", - "NPM_REGISTRY": "registry.npmjs.org" - }, - "requiredEnv": [ - "NPM_TOKEN" - ], - "steps": [ + "exec": "git checkout $BRANCH" + }, { - "exec": "npx -p jsii-release@latest jsii-release-npm" - } - ] - }, - "default": { - "name": "default", - "steps": [ + "exec": "git fetch origin", + "name": "fetch latest changes from origin" + }, { - "exec": "node .projenrc.js" + "exec": "git reset --hard origin/$BRANCH", + "name": "hard reset to origin commit" + }, + { + "exec": "git clean -fdx", + "name": "clean all untracked files" + }, + { + "say": "ready to rock! (unpushed commits are under the \"scratch\" branch)" } - ] + ], + "condition": "git diff --exit-code > /dev/null" }, - "watch": { - "name": "watch", - "description": "Watch & compile in the background", + "compile": { + "name": "compile", + "description": "Only compile" + }, + "cypress:open": { + "name": "cypress:open", + "description": "open the cypress test runner UI", "steps": [ { - "exec": "tsc --build -w" + "exec": "cypress open" } ] }, - "package": { - "name": "package", - "description": "Create an npm tarball", + "cypress:run": { + "name": "cypress:run", + "description": "run the cypress suite in CLI", "steps": [ { - "exec": "mkdir -p dist/js" - }, - { - "exec": "yarn pack" - }, - { - "exec": "mv *.tgz dist/js/" + "exec": "cypress run" } ] }, - "eslint": { - "name": "eslint", - "description": "Runs eslint against the codebase", + "default": { + "name": "default", "steps": [ { - "exec": "eslint --ext .ts,.tsx --fix --no-error-on-unmatched-pattern src test build-tools .projenrc.js" + "exec": "node .projenrc.js" } ] }, @@ -192,21 +128,27 @@ } ] }, - "cypress:open": { - "name": "cypress:open", - "description": "open the cypress test runner UI", + "eslint": { + "name": "eslint", + "description": "Runs eslint against the codebase", "steps": [ { - "exec": "cypress open" + "exec": "eslint --ext .ts,.tsx --fix --no-error-on-unmatched-pattern src test build-tools .projenrc.js" } ] }, - "cypress:run": { - "name": "cypress:run", - "description": "run the cypress suite in CLI", + "package": { + "name": "package", + "description": "Create an npm tarball", "steps": [ { - "exec": "cypress run" + "exec": "mkdir -p dist/js" + }, + { + "exec": "yarn pack" + }, + { + "exec": "mv *.tgz dist/js/" } ] }, @@ -226,27 +168,33 @@ } ] }, - "test:unit": { - "name": "test:unit", - "steps": [ - { - "exec": "npx react-app-rewired test" - } - ] - }, - "test:update": { - "name": "test:update", + "publish:github": { + "name": "publish:github", + "description": "Publish this package to GitHub Releases", + "requiredEnv": [ + "GITHUB_TOKEN", + "GITHUB_REPOSITORY", + "GITHUB_REF" + ], "steps": [ { - "exec": "npx react-app-rewired test -u" + "exec": "errout=$(mktemp); gh release create $(cat dist/releasetag.txt) -R $GITHUB_REPOSITORY -F dist/changelog.md -t $(cat dist/releasetag.txt) --target $GITHUB_REF 2> $errout && true; exitcode=$?; if [ $exitcode -ne 0 ] && ! grep -q \"Release.tag_name already exists\" $errout; then cat $errout; exit $exitcode; fi" } ] }, - "analyze-exports": { - "name": "analyze-exports", + "publish:npm": { + "name": "publish:npm", + "description": "Publish this package to npm", + "env": { + "NPM_DIST_TAG": "latest", + "NPM_REGISTRY": "registry.npmjs.org" + }, + "requiredEnv": [ + "NPM_TOKEN" + ], "steps": [ { - "exec": "node scripts/analyze-exports" + "exec": "npx -p jsii-release@latest jsii-release-npm" } ] }, @@ -274,6 +222,49 @@ } ] }, + "test": { + "name": "test", + "description": "Run tests", + "steps": [ + { + "spawn": "eslint" + }, + { + "exec": "react-app-rewired test --watchAll=false" + } + ] + }, + "test:unit": { + "name": "test:unit", + "steps": [ + { + "exec": "npx react-app-rewired test" + } + ] + }, + "test:update": { + "name": "test:update", + "steps": [ + { + "exec": "npx react-app-rewired test -u" + } + ] + }, + "unbump": { + "name": "unbump", + "description": "Restores version to 0.0.0", + "env": { + "OUTFILE": "package.json", + "CHANGELOG": "dist/changelog.md", + "BUMPFILE": "dist/version.txt", + "RELEASETAG": "dist/releasetag.txt" + }, + "steps": [ + { + "builtin": "release/reset-version" + } + ] + }, "upgrade": { "name": "upgrade", "description": "upgrade dependencies", @@ -306,6 +297,15 @@ "exec": "npx projen" } ] + }, + "watch": { + "name": "watch", + "description": "Watch & compile in the background", + "steps": [ + { + "exec": "tsc --build -w" + } + ] } }, "env": { diff --git a/package.json b/package.json index 379ce007..f9040541 100644 --- a/package.json +++ b/package.json @@ -5,29 +5,29 @@ "url": "https://github.com/cdklabs/construct-hub-webapp.git" }, "scripts": { - "clobber": "npx projen clobber", - "compile": "npx projen compile", - "test": "npx projen test", + "analyze-exports": "npx projen analyze-exports", "build": "npx projen build", "bump": "npx projen bump", - "unbump": "npx projen unbump", - "publish:github": "npx projen publish:github", - "publish:npm": "npx projen publish:npm", + "clobber": "npx projen clobber", + "compile": "npx projen compile", + "cypress:open": "npx projen cypress:open", + "cypress:run": "npx projen cypress:run", "default": "npx projen default", - "watch": "npx projen watch", - "package": "npx projen package", - "eslint": "npx projen eslint", "dev": "npx projen dev", "eject": "npx projen eject", - "cypress:open": "npx projen cypress:open", - "cypress:run": "npx projen cypress:run", + "eslint": "npx projen eslint", + "package": "npx projen package", "proxy-server": "npx projen proxy-server", "proxy-server:ci": "npx projen proxy-server:ci", + "publish:github": "npx projen publish:github", + "publish:npm": "npx projen publish:npm", + "release": "npx projen release", + "test": "npx projen test", "test:unit": "npx projen test:unit", "test:update": "npx projen test:update", - "analyze-exports": "npx projen analyze-exports", - "release": "npx projen release", + "unbump": "npx projen unbump", "upgrade": "npx projen upgrade", + "watch": "npx projen watch", "projen": "npx projen" }, "author": { From ee91dd82c6d9b1b506940b1f4e4d51679ba07fb8 Mon Sep 17 00:00:00 2001 From: Mitchell Valine Date: Fri, 5 Nov 2021 14:21:48 -0700 Subject: [PATCH 3/6] fix: add `/api` to type paths --- .projen/tasks.json | 254 ++++++++++++------------- package.json | 26 +-- src/views/Package/PackageDocs.tsx | 2 +- src/views/Package/PackageState.test.ts | 9 +- src/views/Package/PackageState.tsx | 8 +- 5 files changed, 151 insertions(+), 148 deletions(-) diff --git a/.projen/tasks.json b/.projen/tasks.json index 74441221..4a838b97 100644 --- a/.projen/tasks.json +++ b/.projen/tasks.json @@ -1,50 +1,10 @@ { "tasks": { - "clobber": { - "name": "clobber", - "description": "hard resets to HEAD of origin and cleans the local repo", - "env": { - "BRANCH": "$(git branch --show-current)" - }, - "steps": [ - { - "exec": "git checkout -b scratch", - "name": "save current HEAD in \"scratch\" branch" - }, - { - "exec": "git checkout $BRANCH" - }, - { - "exec": "git fetch origin", - "name": "fetch latest changes from origin" - }, - { - "exec": "git reset --hard origin/$BRANCH", - "name": "hard reset to origin commit" - }, - { - "exec": "git clean -fdx", - "name": "clean all untracked files" - }, - { - "say": "ready to rock! (unpushed commits are under the \"scratch\" branch)" - } - ], - "condition": "git diff --exit-code > /dev/null" - }, - "compile": { - "name": "compile", - "description": "Only compile" - }, - "test": { - "name": "test", - "description": "Run tests", + "analyze-exports": { + "name": "analyze-exports", "steps": [ { - "spawn": "eslint" - }, - { - "exec": "react-app-rewired test --watchAll=false" + "exec": "node scripts/analyze-exports" } ] }, @@ -88,89 +48,65 @@ ], "condition": "! git log --oneline -1 | grep -q \"chore(release):\"" }, - "unbump": { - "name": "unbump", - "description": "Restores version to 0.0.0", + "clobber": { + "name": "clobber", + "description": "hard resets to HEAD of origin and cleans the local repo", "env": { - "OUTFILE": "package.json", - "CHANGELOG": "dist/changelog.md", - "BUMPFILE": "dist/version.txt", - "RELEASETAG": "dist/releasetag.txt" + "BRANCH": "$(git branch --show-current)" }, "steps": [ { - "builtin": "release/reset-version" - } - ] - }, - "publish:github": { - "name": "publish:github", - "description": "Publish this package to GitHub Releases", - "requiredEnv": [ - "GITHUB_TOKEN", - "GITHUB_REPOSITORY", - "GITHUB_REF" - ], - "steps": [ + "exec": "git checkout -b scratch", + "name": "save current HEAD in \"scratch\" branch" + }, { - "exec": "errout=$(mktemp); gh release create $(cat dist/releasetag.txt) -R $GITHUB_REPOSITORY -F dist/changelog.md -t $(cat dist/releasetag.txt) --target $GITHUB_REF 2> $errout && true; exitcode=$?; if [ $exitcode -ne 0 ] && ! grep -q \"Release.tag_name already exists\" $errout; then cat $errout; exit $exitcode; fi" - } - ] - }, - "publish:npm": { - "name": "publish:npm", - "description": "Publish this package to npm", - "env": { - "NPM_DIST_TAG": "latest", - "NPM_REGISTRY": "registry.npmjs.org" - }, - "requiredEnv": [ - "NPM_TOKEN" - ], - "steps": [ + "exec": "git checkout $BRANCH" + }, { - "exec": "npx -p jsii-release@latest jsii-release-npm" - } - ] - }, - "default": { - "name": "default", - "steps": [ + "exec": "git fetch origin", + "name": "fetch latest changes from origin" + }, { - "exec": "node .projenrc.js" + "exec": "git reset --hard origin/$BRANCH", + "name": "hard reset to origin commit" + }, + { + "exec": "git clean -fdx", + "name": "clean all untracked files" + }, + { + "say": "ready to rock! (unpushed commits are under the \"scratch\" branch)" } - ] + ], + "condition": "git diff --exit-code > /dev/null" }, - "watch": { - "name": "watch", - "description": "Watch & compile in the background", + "compile": { + "name": "compile", + "description": "Only compile" + }, + "cypress:open": { + "name": "cypress:open", + "description": "open the cypress test runner UI", "steps": [ { - "exec": "tsc --build -w" + "exec": "cypress open" } ] }, - "package": { - "name": "package", - "description": "Create an npm tarball", + "cypress:run": { + "name": "cypress:run", + "description": "run the cypress suite in CLI", "steps": [ { - "exec": "mkdir -p dist/js" - }, - { - "exec": "yarn pack" - }, - { - "exec": "mv *.tgz dist/js/" + "exec": "cypress run" } ] }, - "eslint": { - "name": "eslint", - "description": "Runs eslint against the codebase", + "default": { + "name": "default", "steps": [ { - "exec": "eslint --ext .ts,.tsx --fix --no-error-on-unmatched-pattern src test build-tools .projenrc.js" + "exec": "node .projenrc.js" } ] }, @@ -192,21 +128,27 @@ } ] }, - "cypress:open": { - "name": "cypress:open", - "description": "open the cypress test runner UI", + "eslint": { + "name": "eslint", + "description": "Runs eslint against the codebase", "steps": [ { - "exec": "cypress open" + "exec": "eslint --ext .ts,.tsx --fix --no-error-on-unmatched-pattern src test build-tools .projenrc.js" } ] }, - "cypress:run": { - "name": "cypress:run", - "description": "run the cypress suite in CLI", + "package": { + "name": "package", + "description": "Create an npm tarball", "steps": [ { - "exec": "cypress run" + "exec": "mkdir -p dist/js" + }, + { + "exec": "yarn pack" + }, + { + "exec": "mv *.tgz dist/js/" } ] }, @@ -226,27 +168,33 @@ } ] }, - "test:unit": { - "name": "test:unit", - "steps": [ - { - "exec": "npx react-app-rewired test" - } - ] - }, - "test:update": { - "name": "test:update", + "publish:github": { + "name": "publish:github", + "description": "Publish this package to GitHub Releases", + "requiredEnv": [ + "GITHUB_TOKEN", + "GITHUB_REPOSITORY", + "GITHUB_REF" + ], "steps": [ { - "exec": "npx react-app-rewired test -u" + "exec": "errout=$(mktemp); gh release create $(cat dist/releasetag.txt) -R $GITHUB_REPOSITORY -F dist/changelog.md -t $(cat dist/releasetag.txt) --target $GITHUB_REF 2> $errout && true; exitcode=$?; if [ $exitcode -ne 0 ] && ! grep -q \"Release.tag_name already exists\" $errout; then cat $errout; exit $exitcode; fi" } ] }, - "analyze-exports": { - "name": "analyze-exports", + "publish:npm": { + "name": "publish:npm", + "description": "Publish this package to npm", + "env": { + "NPM_DIST_TAG": "latest", + "NPM_REGISTRY": "registry.npmjs.org" + }, + "requiredEnv": [ + "NPM_TOKEN" + ], "steps": [ { - "exec": "node scripts/analyze-exports" + "exec": "npx -p jsii-release@latest jsii-release-npm" } ] }, @@ -274,6 +222,49 @@ } ] }, + "test": { + "name": "test", + "description": "Run tests", + "steps": [ + { + "spawn": "eslint" + }, + { + "exec": "react-app-rewired test --watchAll=false" + } + ] + }, + "test:unit": { + "name": "test:unit", + "steps": [ + { + "exec": "npx react-app-rewired test" + } + ] + }, + "test:update": { + "name": "test:update", + "steps": [ + { + "exec": "npx react-app-rewired test -u" + } + ] + }, + "unbump": { + "name": "unbump", + "description": "Restores version to 0.0.0", + "env": { + "OUTFILE": "package.json", + "CHANGELOG": "dist/changelog.md", + "BUMPFILE": "dist/version.txt", + "RELEASETAG": "dist/releasetag.txt" + }, + "steps": [ + { + "builtin": "release/reset-version" + } + ] + }, "upgrade": { "name": "upgrade", "description": "upgrade dependencies", @@ -306,6 +297,15 @@ "exec": "npx projen" } ] + }, + "watch": { + "name": "watch", + "description": "Watch & compile in the background", + "steps": [ + { + "exec": "tsc --build -w" + } + ] } }, "env": { diff --git a/package.json b/package.json index 379ce007..f9040541 100644 --- a/package.json +++ b/package.json @@ -5,29 +5,29 @@ "url": "https://github.com/cdklabs/construct-hub-webapp.git" }, "scripts": { - "clobber": "npx projen clobber", - "compile": "npx projen compile", - "test": "npx projen test", + "analyze-exports": "npx projen analyze-exports", "build": "npx projen build", "bump": "npx projen bump", - "unbump": "npx projen unbump", - "publish:github": "npx projen publish:github", - "publish:npm": "npx projen publish:npm", + "clobber": "npx projen clobber", + "compile": "npx projen compile", + "cypress:open": "npx projen cypress:open", + "cypress:run": "npx projen cypress:run", "default": "npx projen default", - "watch": "npx projen watch", - "package": "npx projen package", - "eslint": "npx projen eslint", "dev": "npx projen dev", "eject": "npx projen eject", - "cypress:open": "npx projen cypress:open", - "cypress:run": "npx projen cypress:run", + "eslint": "npx projen eslint", + "package": "npx projen package", "proxy-server": "npx projen proxy-server", "proxy-server:ci": "npx projen proxy-server:ci", + "publish:github": "npx projen publish:github", + "publish:npm": "npx projen publish:npm", + "release": "npx projen release", + "test": "npx projen test", "test:unit": "npx projen test:unit", "test:update": "npx projen test:update", - "analyze-exports": "npx projen analyze-exports", - "release": "npx projen release", + "unbump": "npx projen unbump", "upgrade": "npx projen upgrade", + "watch": "npx projen watch", "projen": "npx projen" }, "author": { diff --git a/src/views/Package/PackageDocs.tsx b/src/views/Package/PackageDocs.tsx index 036514e8..ab59e652 100644 --- a/src/views/Package/PackageDocs.tsx +++ b/src/views/Package/PackageDocs.tsx @@ -81,7 +81,7 @@ export const PackageDocs: FunctionComponent = () => { - + diff --git a/src/views/Package/PackageState.test.ts b/src/views/Package/PackageState.test.ts index 36aa7131..01f8e1a9 100644 --- a/src/views/Package/PackageState.test.ts +++ b/src/views/Package/PackageState.test.ts @@ -89,6 +89,7 @@ describe("parseMarkdownStructure", () => { it("parses out menu items", () => { const { menuItems } = parseMarkdownStructure(MARKDOWN_INPUT, packageData); const basePath = "/packages/@packageScope/packageName/v/0.0.0"; + const baseApiPath = `${basePath}/api`; const langQuery = "?lang=language"; const baseHashPath = `${basePath}${langQuery}`; expect(menuItems).toEqual([ @@ -129,14 +130,14 @@ describe("parseMarkdownStructure", () => { level: 3, id: `${MY_DATA_TYPE_1}-1`, title: `${MY_DATA_TYPE_1}-1`, - path: `${basePath}/${MY_DATA_TYPE_1}-1${langQuery}`, + path: `${baseApiPath}/${MY_DATA_TYPE_1}-1${langQuery}`, children: [], }, { level: 3, id: `${MY_DATA_TYPE_1}-2`, title: `${MY_DATA_TYPE_1}-2`, - path: `${basePath}/${MY_DATA_TYPE_1}-2${langQuery}`, + path: `${baseApiPath}/${MY_DATA_TYPE_1}-2${langQuery}`, children: [], }, ], @@ -150,14 +151,14 @@ describe("parseMarkdownStructure", () => { level: 3, id: `${MY_DATA_TYPE_2}-1`, title: `${MY_DATA_TYPE_2}-1`, - path: `${basePath}/${MY_DATA_TYPE_2}-1${langQuery}`, + path: `${baseApiPath}/${MY_DATA_TYPE_2}-1${langQuery}`, children: [], }, { level: 3, id: `${MY_DATA_TYPE_2}-2`, title: `${MY_DATA_TYPE_2}-2`, - path: `${basePath}/${MY_DATA_TYPE_2}-2${langQuery}`, + path: `${baseApiPath}/${MY_DATA_TYPE_2}-2${langQuery}`, children: [], }, ], diff --git a/src/views/Package/PackageState.tsx b/src/views/Package/PackageState.tsx index d9069f23..01b23ec2 100644 --- a/src/views/Package/PackageState.tsx +++ b/src/views/Package/PackageState.tsx @@ -166,12 +166,13 @@ export const parseMarkdownStructure = ( // Add back api reference title for use as menu item const apiReferenceParsed = [separator.trim(), ...(apiReferenceSplit ?? [])]; + const baseReadmePath = `${basePath}${langQuery}`; const readmeMenuItems = [ { level: 1, id: "Readme", title: "Readme", - path: `${basePath}${langQuery}`, + path: baseReadmePath, children: readmeSplit.reduce((accum: MenuItem[], str: string) => { if (str.startsWith("#")) { const { id, title } = getHeaderAttributes(str); @@ -181,7 +182,7 @@ export const parseMarkdownStructure = ( id, title, // root package path plus hash for header on readme item - path: `${basePath}${langQuery}#${id}`, + path: `${baseReadmePath}#${id}`, children: [], }; return appendMenuItem(accum, menuItem); @@ -194,6 +195,7 @@ export const parseMarkdownStructure = ( let menuItems: MenuItem[] = [...readmeMenuItems]; const types: Types = {}; + const getApiPath = (id: string) => `${basePath}/api/${id}${langQuery}`; let prevType: { id: string; title: string }; apiReferenceParsed?.forEach((str) => { // TODO get attributes off of embedded html @@ -204,7 +206,7 @@ export const parseMarkdownStructure = ( // root package path plus type id segment // only level 3 headers are types in api reference - const path = level === 3 ? `${basePath}/${id}${langQuery}` : undefined; + const path = level === 3 ? getApiPath(id) : undefined; const menuItem = { level, id, From e984607031d8a4f1fd4be44cebdf013e8a445e53 Mon Sep 17 00:00:00 2001 From: Mitchell Valine Date: Fri, 5 Nov 2021 17:57:31 -0700 Subject: [PATCH 4/6] fix: fix performance by removing remark parse --- .projen/deps.json | 12 ++++++--- .projenrc.js | 3 ++- package.json | 3 ++- src/views/Package/PackageState.tsx | 40 ++++++++++-------------------- yarn.lock | 23 +++++------------ 5 files changed, 31 insertions(+), 50 deletions(-) diff --git a/.projen/deps.json b/.projen/deps.json index b3c0c1aa..409bc084 100644 --- a/.projen/deps.json +++ b/.projen/deps.json @@ -24,6 +24,10 @@ "name": "@types/lunr", "type": "build" }, + { + "name": "@types/node-emoji", + "type": "build" + }, { "name": "@types/node", "version": "^14.17.0", @@ -195,6 +199,10 @@ "name": "lunr", "type": "runtime" }, + { + "name": "node-emoji", + "type": "runtime" + }, { "name": "prism-react-renderer", "type": "runtime" @@ -232,10 +240,6 @@ "name": "rehype-sanitize", "type": "runtime" }, - { - "name": "remark", - "type": "runtime" - }, { "name": "remark-emoji", "type": "runtime" diff --git a/.projenrc.js b/.projenrc.js index 12f61876..f971b6f8 100644 --- a/.projenrc.js +++ b/.projenrc.js @@ -43,13 +43,13 @@ const project = new web.ReactTypeScriptProject({ "framer-motion@^4", "jsii-reflect", "lunr", + "node-emoji", "prism-react-renderer", "react-helmet", "react-markdown", "react-router-dom", "rehype-raw", "rehype-sanitize", - "remark", "remark-emoji", "remark-gfm", // PWA Functionality @@ -62,6 +62,7 @@ const project = new web.ReactTypeScriptProject({ devDeps: [ "@types/lunr", + "@types/node-emoji", "@types/react-helmet", "@types/react-router-dom", "eslint-plugin-jsx-a11y", diff --git a/package.json b/package.json index f9040541..5445375c 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,7 @@ "@types/jest": "^26.0.24", "@types/lunr": "^2.3.4", "@types/node": "^14.17.0", + "@types/node-emoji": "^1.8.1", "@types/react": "^17.0.34", "@types/react-dom": "^17.0.11", "@types/react-helmet": "^6.1.4", @@ -85,6 +86,7 @@ "hast-util-sanitize": "^3.0.2", "jsii-reflect": "^1.42.0", "lunr": "^2.3.9", + "node-emoji": "^1.11.0", "prism-react-renderer": "^1.2.1", "react": "^17.0.2", "react-dom": "^17.0.2", @@ -94,7 +96,6 @@ "react-scripts": "^4.0.0", "rehype-raw": "^5.1.0", "rehype-sanitize": "^4.0.0", - "remark": "^13.0.0", "remark-emoji": "*", "remark-gfm": "^1.0.0", "web-vitals": "^1.1.2", diff --git a/src/views/Package/PackageState.tsx b/src/views/Package/PackageState.tsx index 01b23ec2..22bf14cd 100644 --- a/src/views/Package/PackageState.tsx +++ b/src/views/Package/PackageState.tsx @@ -1,15 +1,13 @@ import type { Assembly } from "@jsii/spec"; +import emoji from "node-emoji"; import { createContext, FunctionComponent, useContext, useEffect, useMemo, - useState, } from "react"; import { useParams } from "react-router-dom"; -import remark from "remark"; -import emoji from "remark-emoji"; import { fetchAssembly } from "../../api/package/assembly"; import { fetchMarkdown } from "../../api/package/docs"; import { fetchMetadata, Metadata } from "../../api/package/metadata"; @@ -121,8 +119,16 @@ const getHeaderAttributes = (hdr: string): { id: string; title: string } => { }; }, {}); + // Use raw title for items that don't specify data attributes, like readme + // headers. const [_, rawTitle] = /^#*\s*([^<]+?)\s*(?:<|$)/.exec(hdr) ?? []; - const title: string = attrs["data-heading-title"] ?? rawTitle; + const wEmoji = rawTitle.replace( + /:\+1:|:-1:|:[\w-]+:/g, + (match: string): string => { + return emoji.get(match) ?? match; + } + ); + const title: string = attrs["data-heading-title"] ?? wEmoji; const id = attrs["data-heading-id"] ?? encodeURIComponent(sanitize(title)); return { id, title }; @@ -283,36 +289,16 @@ export const PackageStateProvider: FunctionComponent = ({ children }) => { (assemblyResponse.loading || markdownResponse.loading) ); - const [processedMd, setProcessMd] = useState(); - - useEffect(() => { - let isMounted = true; - if (markdownResponse.data) { - void remark() - .use(emoji) - .process(markdownResponse.data) - .then((file) => { - if (isMounted) { - setProcessMd(String(file)); - } - }); - } - - return () => { - isMounted = false; - }; - }, [markdownResponse]); - const parsedMd = useMemo(() => { - if (!processedMd) return { menuItems: [] }; + if (!markdownResponse.data) return { menuItems: [] }; - return parseMarkdownStructure(processedMd, { + return parseMarkdownStructure(markdownResponse.data, { scope, name, version, language, }); - }, [processedMd, name, scope, version, language]); + }, [markdownResponse.data, name, scope, version, language]); // Handle missing JSON for assembly if (assemblyResponse.error) { diff --git a/yarn.lock b/yarn.lock index 7ca2243b..90c51efa 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2770,6 +2770,11 @@ resolved "https://registry.yarnpkg.com/@types/minimist/-/minimist-1.2.2.tgz#ee771e2ba4b3dc5b372935d549fd9617bf345b8c" integrity sha512-jhuKLIRrhvCPLqwPcx6INqmKeiA5EWrsCOPhrlFSrbrmU4ZMPjj5Ul/oLCMDO98XRUIwVm78xICz4EPCektzeQ== +"@types/node-emoji@^1.8.1": + version "1.8.1" + resolved "https://registry.yarnpkg.com/@types/node-emoji/-/node-emoji-1.8.1.tgz#689cb74fdf6e84309bcafce93a135dfecd01de3f" + integrity sha512-0fRfA90FWm6KJfw6P9QGyo0HDTCmthZ7cWaBQndITlaWLTZ6njRyKwrwpzpg+n6kBXBIGKeUHEQuBx7bphGJkA== + "@types/node@*": version "16.11.6" resolved "https://registry.yarnpkg.com/@types/node/-/node-16.11.6.tgz#6bef7a2a0ad684cf6e90fcfe31cecabd9ce0a3ae" @@ -12720,22 +12725,6 @@ remark-rehype@^8.0.0: dependencies: mdast-util-to-hast "^10.2.0" -remark-stringify@^9.0.0: - version "9.0.1" - resolved "https://registry.yarnpkg.com/remark-stringify/-/remark-stringify-9.0.1.tgz#576d06e910548b0a7191a71f27b33f1218862894" - integrity sha512-mWmNg3ZtESvZS8fv5PTvaPckdL4iNlCHTt8/e/8oN08nArHRHjNZMKzA/YW3+p7/lYqIw4nx1XsjCBo/AxNChg== - dependencies: - mdast-util-to-markdown "^0.6.0" - -remark@^13.0.0: - version "13.0.0" - resolved "https://registry.yarnpkg.com/remark/-/remark-13.0.0.tgz#d15d9bf71a402f40287ebe36067b66d54868e425" - integrity sha512-HDz1+IKGtOyWN+QgBiAT0kn+2s6ovOxHyPAFGKVE81VSzJ+mq7RwHFledEvB5F1p4iJvOah/LOKdFuzvRnNLCA== - dependencies: - remark-parse "^9.0.0" - remark-stringify "^9.0.0" - unified "^9.1.0" - remote-git-tags@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/remote-git-tags/-/remote-git-tags-3.0.0.tgz#424f8ec2cdea00bb5af1784a49190f25e16983c3" @@ -14463,7 +14452,7 @@ unicode-property-aliases-ecmascript@^2.0.0: resolved "https://registry.yarnpkg.com/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.0.0.tgz#0a36cb9a585c4f6abd51ad1deddb285c165297c8" integrity sha512-5Zfuy9q/DFr4tfO7ZPeVXb1aPoeQSdeFMLpYuFebehDAhbuevLs5yxSZmIFN1tP5F9Wl4IpJrYojg85/zgyZHQ== -unified@^9.0.0, unified@^9.1.0: +unified@^9.0.0: version "9.2.2" resolved "https://registry.yarnpkg.com/unified/-/unified-9.2.2.tgz#67649a1abfc3ab85d2969502902775eb03146975" integrity sha512-Sg7j110mtefBD+qunSLO1lqOEKdrwBFBrR6Qd8f4uwkhWNlbkaqwHse6e7QvD3AP/MNoJdEDLaf8OxYyoWgorQ== From daa992bc291e9f757ca0cbdbe2c65e56797f0192 Mon Sep 17 00:00:00 2001 From: Mitchell Valine Date: Mon, 8 Nov 2021 10:31:38 -0800 Subject: [PATCH 5/6] fix: remove 1MB rendering limit --- src/components/Markdown/Markdown.tsx | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/src/components/Markdown/Markdown.tsx b/src/components/Markdown/Markdown.tsx index bcd6f0f3..2bb3c6ca 100644 --- a/src/components/Markdown/Markdown.tsx +++ b/src/components/Markdown/Markdown.tsx @@ -11,7 +11,6 @@ import rehypeRaw from "rehype-raw"; import rehypeSanitize from "rehype-sanitize"; import remarkEmoji from "remark-emoji"; import remarkGfm from "remark-gfm"; -import { CONSTRUCT_HUB_REPO_URL } from "../../constants/links"; import { Code } from "./Code"; import { Headings } from "./Headings"; import { Hr } from "./Hr"; @@ -21,8 +20,6 @@ import { Table, Thead, Tbody, Tfoot, Tr, Th, Td, TableCaption } from "./Table"; import testIds from "./testIds"; import { A, Blockquote, Em, P, Pre, Sup } from "./Text"; -const ONE_MEGABYTE = 1024 * 1024; - const components: ReactMarkdownOptions["components"] = { a: A, blockquote: Blockquote, @@ -126,16 +123,6 @@ export const Markdown: FunctionComponent<{ return `https://${githubPrefix}/${owner}/${repo}/${githubSuffix}/${url}`; }; - const byteLength = Buffer.byteLength(children); - if (byteLength > ONE_MEGABYTE) { - children = children.substring(0, children.lastIndexOf("# API Reference")); - children = [ - children, - "# API Reference", - "The API Reference for this package could not be rendered.", - `If this issue persists, please let us know by creating an [issue](${CONSTRUCT_HUB_REPO_URL}/issues/new)`, - ].join("\n"); - } return ( Date: Tue, 9 Nov 2021 12:17:12 -0800 Subject: [PATCH 6/6] chore: PR feedback --- src/views/Package/PackageState.tsx | 177 +---------------- src/views/Package/PackageTypeDocs.tsx | 8 +- .../{PackageState.test.ts => util.test.ts} | 10 +- src/views/Package/util.ts | 188 ++++++++++++++++++ 4 files changed, 197 insertions(+), 186 deletions(-) rename src/views/Package/{PackageState.test.ts => util.test.ts} (93%) create mode 100644 src/views/Package/util.ts diff --git a/src/views/Package/PackageState.tsx b/src/views/Package/PackageState.tsx index 22bf14cd..178f206e 100644 --- a/src/views/Package/PackageState.tsx +++ b/src/views/Package/PackageState.tsx @@ -1,5 +1,4 @@ import type { Assembly } from "@jsii/spec"; -import emoji from "node-emoji"; import { createContext, FunctionComponent, @@ -16,8 +15,8 @@ import { QUERY_PARAMS } from "../../constants/url"; import { useLanguage } from "../../hooks/useLanguage"; import { useQueryParams } from "../../hooks/useQueryParams"; import { useRequest, UseRequestResponse } from "../../hooks/useRequest"; -import { sanitize } from "../../util/sanitize-anchor"; import { NotFound } from "../NotFound"; +import { Types, MenuItem, parseMarkdownStructure } from "./util"; interface PathParams { name: string; @@ -25,21 +24,6 @@ interface PathParams { version: string; } -interface MenuItem { - id: string; - path?: string; - title: string; - children: MenuItem[]; - level: number; -} - -interface Types { - [id: string]: { - title: string; - content: string; - }; -} - interface PackageState { apiReference?: Types; assembly: UseRequestResponse; @@ -76,165 +60,6 @@ export const usePackageState = () => { return state; }; -export const appendMenuItem = ( - items: MenuItem[], - item: MenuItem -): MenuItem[] => { - const last = items[items.length - 1]; - - if (last && last.level < item.level) { - last.children = appendMenuItem(last.children, item); - return items; - } - return [...items, item]; -}; - -export const splitOnHeaders = (md: string, level: number): string[] => { - if (!md) { - return []; - } - - const regex = new RegExp(`(^#{1,${level}}[^#].*)`, "gm"); - return ( - md - ?.split(regex) - // Trim lines and remove whitespace only entries - .reduce((accum: string[], str: string) => { - const newStr = str.trim(); - if (newStr === "") return accum; - return [...accum, newStr]; - }, []) - ); -}; - -const getHeaderAttributes = (hdr: string): { id: string; title: string } => { - const attrStrings = hdr.match(/(\S+)\s*=\s*(\"?)([^"]*)(\2|\s|$)/g) ?? []; - const attrs: { [key: string]: string } = attrStrings.reduce((accum, str) => { - const [key, value] = str.split("="); - const [_, parsedValue] = /['"](.*?)['"]/.exec(value) ?? []; - - return { - ...accum, - [key]: parsedValue, - }; - }, {}); - - // Use raw title for items that don't specify data attributes, like readme - // headers. - const [_, rawTitle] = /^#*\s*([^<]+?)\s*(?:<|$)/.exec(hdr) ?? []; - const wEmoji = rawTitle.replace( - /:\+1:|:-1:|:[\w-]+:/g, - (match: string): string => { - return emoji.get(match) ?? match; - } - ); - const title: string = attrs["data-heading-title"] ?? wEmoji; - const id = attrs["data-heading-id"] ?? encodeURIComponent(sanitize(title)); - - return { id, title }; -}; - -/** - * Accept's markdown document from jsii-docgen with readme and api reference - * documentation and parses the content into a traversable map of menu items - * and types. This allows splitting the rendering of the readme and each item - * in the api reference. - */ -export const parseMarkdownStructure = ( - input: string, - { - scope, - language, - name, - version, - }: { scope?: string; language: string; name: string; version: string } -): { readme: string; apiReference: Types; menuItems: MenuItem[] } => { - const nameSegment = scope ? `${scope}/${name}` : `${name}`; - const basePath = `/packages/${nameSegment}/v/${version}`; - const langQuery = `?${QUERY_PARAMS.LANGUAGE}=${language}`; - const separator = - '\n# API Reference \n'; - - // split into readme and api reference - const segments = input.split(separator); - - // Take the last chunk after the separator - // segments.pop() always returns when length > 1; - const apiReferenceStr = segments.length > 1 ? segments.pop()! : ""; - - // Rejoin all the previous chunks in case the readme has the same Separator - const readmeStr = segments.join(separator); - - //split each on headers - const apiReferenceSplit = splitOnHeaders(apiReferenceStr, 3); - const readmeSplit = splitOnHeaders(readmeStr, 6); - - // Add back api reference title for use as menu item - const apiReferenceParsed = [separator.trim(), ...(apiReferenceSplit ?? [])]; - - const baseReadmePath = `${basePath}${langQuery}`; - const readmeMenuItems = [ - { - level: 1, - id: "Readme", - title: "Readme", - path: baseReadmePath, - children: readmeSplit.reduce((accum: MenuItem[], str: string) => { - if (str.startsWith("#")) { - const { id, title } = getHeaderAttributes(str); - const level = str.match(/(#)/gm)?.length ?? 1; - const menuItem = { - level, - id, - title, - // root package path plus hash for header on readme item - path: `${baseReadmePath}#${id}`, - children: [], - }; - return appendMenuItem(accum, menuItem); - } - return accum; - }, []), - }, - ]; - - let menuItems: MenuItem[] = [...readmeMenuItems]; - const types: Types = {}; - - const getApiPath = (id: string) => `${basePath}/api/${id}${langQuery}`; - let prevType: { id: string; title: string }; - apiReferenceParsed?.forEach((str) => { - // TODO get attributes off of embedded html - const isHeader = str.match(/(^#{1,3}[^#].*)/gm); - if (isHeader?.length) { - const { id, title } = getHeaderAttributes(str); - const level = str.match(/(#)/gm)?.length ?? 1; - - // root package path plus type id segment - // only level 3 headers are types in api reference - const path = level === 3 ? getApiPath(id) : undefined; - const menuItem = { - level, - id, - title, - children: [], - ...(path ? { path } : {}), - }; - - menuItems = appendMenuItem(menuItems, menuItem); - prevType = { id, title }; - } else { - types[prevType.id] = { title: prevType.title, content: str }; - } - }); - - return { - readme: segments.join(separator), - apiReference: types, - menuItems, - }; -}; - /** * Provides shared page-level data to components on the Package page */ diff --git a/src/views/Package/PackageTypeDocs.tsx b/src/views/Package/PackageTypeDocs.tsx index ffad521f..2e1bfb0f 100644 --- a/src/views/Package/PackageTypeDocs.tsx +++ b/src/views/Package/PackageTypeDocs.tsx @@ -9,12 +9,10 @@ const usePackageTypeDocs = () => { const { typeId }: { typeId?: string } = useParams(); const { apiReference } = usePackageState(); - const content = apiReference?.[typeId ?? ""]; - if (!typeId || !content) { - return; + if (typeId) { + return apiReference?.[typeId]; } - - return content; + return; }; export const PackageTypeDocs: FunctionComponent = () => { diff --git a/src/views/Package/PackageState.test.ts b/src/views/Package/util.test.ts similarity index 93% rename from src/views/Package/PackageState.test.ts rename to src/views/Package/util.test.ts index 01f8e1a9..f8a00c3c 100644 --- a/src/views/Package/PackageState.test.ts +++ b/src/views/Package/util.test.ts @@ -1,4 +1,5 @@ -import { parseMarkdownStructure } from "./PackageState"; +import { Language } from "../../constants/languages"; +import { parseMarkdownStructure } from "./util"; jest.mock("remark-emoji"); const README_MARKDOWN = ` @@ -26,8 +27,7 @@ const MY_DATA_TYPE_22Body = `${MY_DATA_TYPE_2}-2Body This is not parsed out This is another line not parsed out`; -const MARKDOWN_INPUT = `${README_MARKDOWN} -# API Reference +const MARKDOWN_INPUT = `${README_MARKDOWN}# API Reference ## ${DATA_TYPE_1} @@ -52,7 +52,7 @@ const packageData = { scope: "@packageScope", name: "packageName", version: "0.0.0", - language: "language", + language: Language.TypeScript, }; describe("parseMarkdownStructure", () => { @@ -90,7 +90,7 @@ describe("parseMarkdownStructure", () => { const { menuItems } = parseMarkdownStructure(MARKDOWN_INPUT, packageData); const basePath = "/packages/@packageScope/packageName/v/0.0.0"; const baseApiPath = `${basePath}/api`; - const langQuery = "?lang=language"; + const langQuery = `?lang=${Language.TypeScript}`; const baseHashPath = `${basePath}${langQuery}`; expect(menuItems).toEqual([ { diff --git a/src/views/Package/util.ts b/src/views/Package/util.ts new file mode 100644 index 00000000..f577389a --- /dev/null +++ b/src/views/Package/util.ts @@ -0,0 +1,188 @@ +import emoji from "node-emoji"; +import { Language } from "../../constants/languages"; +import { QUERY_PARAMS } from "../../constants/url"; +import { sanitize } from "../../util/sanitize-anchor"; + +export interface MenuItem { + id: string; + path?: string; + title: string; + children: MenuItem[]; + level: number; +} + +export interface Types { + [id: string]: { + title: string; + content: string; + }; +} + +/** + * Recursively insert menu items into appropriate parent's `children` array. + */ +const appendMenuItem = (items: MenuItem[], item: MenuItem): MenuItem[] => { + const last = items[items.length - 1]; + + if (last && last.level < item.level) { + last.children = appendMenuItem(last.children, item); + return items; + } + + items.push(item); + return items; +}; + +/** + * Split markdown string on header lines. Accepts a `maxLevel` to only + * parse to a specified level. Defaults to markdown maximum of `6` + */ +const splitOnHeaders = (md: string, maxLevel: number = 6): string[] => { + const regex = new RegExp(`(^#{1,${maxLevel}}[^#].*)`, "gm"); + return ( + md + .split(regex) + // Trim lines and remove whitespace only entries + .reduce((accum: string[], str: string) => { + const newStr = str.trim(); + if (newStr === "") return accum; + return [...accum, newStr]; + }, []) + ); +}; + +/** + * Extract relevant data from markdown string for use as menu item. Attempts + * to parse known data attributes of `title` and `id` while defaulting to use + * the raw heading value as the default for both if data attributes are not + * present. + */ +const getHeaderAttributes = (hdr: string): { id: string; title: string } => { + const attrStrings = hdr.match(/(\S+)\s*=\s*(\"?)([^"]*)(\2|\s|$)/g) ?? []; + const attrs: { [key: string]: string } = attrStrings.reduce((accum, str) => { + const [key, value] = str.split("="); + const [_, parsedValue] = /['"](.*?)['"]/.exec(value) ?? []; + + return { + ...accum, + [key]: parsedValue, + }; + }, {}); + + // Use raw title for items that don't specify data attributes, like readme + // headers. + const [_, rawTitle] = /^#*\s*([^<]+?)\s*(?:<|$)/.exec(hdr) ?? []; + const wEmoji = rawTitle.replace( + /:\+1:|:-1:|:[\w-]+:/g, + (match: string): string => { + return emoji.get(match) ?? match; + } + ); + const title: string = attrs["data-heading-title"] ?? wEmoji; + const id = attrs["data-heading-id"] ?? encodeURIComponent(sanitize(title)); + + return { id, title }; +}; + +/** + * Accept's markdown document from jsii-docgen with readme and api reference + * documentation and parses the content into a traversable map of menu items + * and types. This allows splitting the rendering of the readme and each item + * in the api reference. + * + * NOTE: currently does not support setext style headings in readme documents. + */ +export const parseMarkdownStructure = ( + input: string, + { + scope, + language, + name, + version, + }: { scope?: string; language: Language; name: string; version: string } +): { readme: string; apiReference: Types; menuItems: MenuItem[] } => { + const nameSegment = scope ? `${scope}/${name}` : `${name}`; + const basePath = `/packages/${nameSegment}/v/${version}`; + const langQuery = `?${QUERY_PARAMS.LANGUAGE}=${language}`; + const separator = + '# API Reference \n'; + + // split into readme and api reference + const segments = input.split(separator); + + // Take the last chunk after the separator + // segments.pop() always returns when length > 1; + const apiReferenceStr = segments.length > 1 ? segments.pop()! : ""; + + // Rejoin all the previous chunks in case the readme has the same Separator + const readmeStr = segments.join(separator); + + //split each on headers + const apiReferenceSplit = splitOnHeaders(apiReferenceStr, 3); + const readmeSplit = splitOnHeaders(readmeStr); + + // Add back api reference title for use as menu item + const apiReferenceParsed = [separator.trim(), ...(apiReferenceSplit ?? [])]; + + const baseReadmePath = `${basePath}${langQuery}`; + const readmeMenuItems = [ + { + level: 1, + id: "Readme", + title: "Readme", + path: baseReadmePath, + children: readmeSplit.reduce((accum: MenuItem[], str: string) => { + if (str.startsWith("#")) { + const { id, title } = getHeaderAttributes(str); + const level = str.match(/(#)/gm)?.length ?? 1; + const menuItem = { + level, + id, + title, + // root package path plus hash for header on readme item + path: `${baseReadmePath}#${id}`, + children: [], + }; + return appendMenuItem(accum, menuItem); + } + return accum; + }, []), + }, + ]; + + let menuItems: MenuItem[] = [...readmeMenuItems]; + const types: Types = {}; + + const getApiPath = (id: string) => `${basePath}/api/${id}${langQuery}`; + let prevType: { id: string; title: string }; + apiReferenceParsed?.forEach((str) => { + // TODO get attributes off of embedded html + const isHeader = str.match(/(^#{1,3}[^#].*)/gm); + if (isHeader?.length) { + const { id, title } = getHeaderAttributes(str); + const level = str.match(/(#)/gm)?.length ?? 1; + + // root package path plus type id segment + // only level 3 headers are types in api reference + const path = level === 3 ? getApiPath(id) : undefined; + const menuItem = { + level, + id, + title, + children: [], + ...(path ? { path } : {}), + }; + + menuItems = appendMenuItem(menuItems, menuItem); + prevType = { id, title }; + } else { + types[prevType.id] = { title: prevType.title, content: str }; + } + }); + + return { + readme: readmeStr, + apiReference: types, + menuItems, + }; +};