Implement package versions page

This commit is contained in:
visortelle 2022-01-18 22:47:18 +01:00
parent 00c6f192c0
commit ee5ad44c22
25 changed files with 740 additions and 289 deletions

View File

@ -12,7 +12,6 @@
background: var(--purple-color-2);
color: #fff;
font-size: 14rem;
font-weight: bold;
border-radius: 24rem;
line-height: 1;
}

View File

@ -0,0 +1,44 @@
.briefInfo {
background-color: #fff;
padding: 32rem 24rem 18rem 24rem;
}
.packageName {
margin: 0;
margin-bottom: 8rem;
line-height: 1.1;
font-size: 32rem;
font-weight: bold;
display: flex;
overflow: hidden;
text-overflow: ellipsis;
align-items: center;
}
.packageNameH1 {
display: inline;
font-size: inherit;
font-weight: inherit;
margin: 0;
text-overflow: ellipsis;
overflow: hidden;
}
.packageVersion {
font-size: 24rem;
margin-left: 8rem;
font-weight: bold;
opacity: 0.66;
position: relative;
top: 2rem;
}
.shortDescription {
font-size: 18rem;
opacity: 0.66;
margin-bottom: 18rem;
}
.shortDescription:last-child {
margin-bottom: 0;
}

View File

@ -0,0 +1,22 @@
import s from './BriefInfo.module.css';
export type BriefInfoProps = {
packageName: string | null,
packageVersion: string | null,
shortDescription: string | null
}
const BriefInfo = (props: BriefInfoProps) => {
return (
<div className={s.briefInfo}>
<div className={s.packageName}>
<small style={{ position: 'relative', top: '2rem' }}>📦</small>&nbsp;
{props.packageName && <h1 className={s.packageNameH1}>{props.packageName}</h1>}
{props.packageVersion && <span className={s.packageVersion}>{props.packageVersion}</span>}
</div>
{props.shortDescription && <div className={s.shortDescription}>{props.shortDescription}</div>}
</div>
);
}
export default BriefInfo;

View File

@ -9,6 +9,26 @@
width: var(--max-content-width);
max-width: calc(100vw - 24rem);
margin: 0 auto;
margin-top: 24rem;
}
.briefInfo {
border-radius: 12rem;
overflow: hidden;
box-shadow: 0rem 2rem 4rem rgb(0 0 0 / 27%);
}
.contentContainer {
display: flex;
flex-direction: column;
flex: 1 1;
}
.content {
border-radius: 12rem;
display: flex;
flex-direction: column;
position: relative;
}
.footer {
@ -16,11 +36,11 @@
width: 100%;
}
.sidebarContainer {
.sidebar {
width: 240rem;
margin: 24rem 0 24rem 18rem;
display: flex;
padding-top: calc(32rem + 1em);
padding-top: 18rem;
}
@media (max-width: 1000px) {
@ -33,7 +53,7 @@
flex-direction: column;
}
.sidebarContainer {
.sidebar {
width: 50%;
padding-top: 0;
margin: 24rem 18rem;
@ -41,7 +61,7 @@
}
@media (max-width: 600px) {
.sidebarContainer {
.sidebar {
width: auto;
}
}

View File

@ -0,0 +1,76 @@
import GlobalMenu, { defaultMenuProps } from "../../layout/GlobalMenu";
import Footer from "../../layout/Footer";
import s from './Layout.module.css';
import { PackageProps } from "./common";
import Sidebar from './Sidebar';
import Tabs, { Tab } from "./tabs/Tabs";
import BriefInfo from "./BriefInfo";
import { ReactNode } from "react";
type LayoutProps = {
analytics: {
screenName: string
},
package: PackageProps,
activeTab: string,
children: ReactNode,
}
const getTabs = (props: LayoutProps): Tab[] => {
return [
{
id: 'overview',
title: 'Overview',
href: `/package/${props.package.id}`
},
// {
// id: 'docs',
// title: '📘 Docs',
// href: `#`
// },
{
id: 'versions',
title: `${props.package.versionsCount} Versions`,
href: `/package/${props.package.id}/versions`
},
// {
// id: 'dependencies',
// title: 'Dependencies',
// href: '#'
// }
]
}
const Layout = (props: LayoutProps) => {
return (
<div className={s.page}>
<GlobalMenu {...defaultMenuProps} />
<div className={s.packageContainer}>
<div className={s.contentContainer}>
<div className={s.content}>
<div className={s.briefInfo}>
<BriefInfo packageName={props.package.name} packageVersion={props.package.currentVersion} shortDescription={props.package.shortDescription} />
<Tabs
tabs={getTabs(props)}
activeTab={props.activeTab}
/>
</div>
<div className={s.children}>
{props.children}
</div>
</div>
</div>
<div className={s.sidebar}>
<Sidebar package={props.package} analytics={{ screenName: props.analytics.screenName }} />
</div>
</div>
<div className={s.footer}>
<Footer />
</div>
</div>
);
}
export default Layout;

View File

@ -1,60 +1,25 @@
.package {
display: flex;
flex: 1 1 auto;
border-radius: 12rem;
overflow: hidden;
box-shadow: 0rem 2rem 4rem rgb(0 0 0 / 27%);
margin-top: 24rem;
}
.content {
margin: 24rem auto;
box-shadow: 0rem 2rem 4rem rgb(0 0 0 / 27%);
border-radius: 12rem;
overflow: hidden;
}
.packageName {
margin: 0;
font-size: 32rem;
font-weight: bold;
display: flex;
flex: 1 1;
margin: 0 auto;
overflow: hidden;
text-overflow: ellipsis;
align-items: center;
}
.packageNameH1 {
display: inline;
font-size: inherit;
font-weight: inherit;
margin: 0;
text-overflow: ellipsis;
overflow: hidden;
}
.briefInfo {
background-color: #fff;
padding: 32rem 24rem 24rem 24rem;
}
.packageVersion {
font-size: 24rem;
margin-left: 8rem;
font-weight: bold;
opacity: 0.66;
position: relative;
top: 2rem;
}
.shortDescription {
font-size: 18rem;
opacity: 0.66;
margin-bottom: 18rem;
}
.shortDescription:last-child {
margin-bottom: 0;
}
.longDescription {
max-width: var(--max-content-width);
margin: 0 auto;
padding: 32rem 24rem;
background-color: #fff;
flex: 1 1;
}
.longDescription p {

View File

@ -0,0 +1,19 @@
import s from './OverviewPage.module.css';
import { PackageProps } from "./common";
import Layout from "./Layout";
const screenName = 'PackageOverviewPage';
const OverviewPage = (props: PackageProps) => {
return (
<Layout analytics={{ screenName }} package={props} activeTab="overview">
<div className={s.package}>
<div className={s.content}>
{props.longDescriptionHtml && <div className={s.longDescription} dangerouslySetInnerHTML={{ __html: props.longDescriptionHtml }}></div>}
</div>
</div>
</Layout>
);
}
export default OverviewPage;

View File

@ -1,33 +0,0 @@
import GlobalMenu, { defaultMenuProps } from "../../layout/GlobalMenu";
import Footer from "../../layout/Footer";
import s from './PackagePage.module.css';
import { PackageProps } from "./common";
import Sidebar from './Sidebar';
import PackageOverview from "./tabs/PackageOverview";
const screenName = 'HackagePackagePage';
const Package = (props: PackageProps) => {
return (
<div className={s.page}>
<GlobalMenu {...defaultMenuProps} />
<div className={s.packageContainer}>
<Tabs {...props} />
<div className={s.sidebarContainer}>
<Sidebar package={props} analytics={{ screenName }} />
</div>
</div>
<div className={s.footer}>
<Footer />
</div>
</div>
);
}
const Tabs = (props: PackageProps) => {
return (
<PackageOverview {...props} />
);
}
export default Package;

View File

@ -23,7 +23,7 @@ type SidebarProps = {
export const Sidebar = (props: SidebarProps) => {
const appContext = useContext(lib.appContext.AppContext);
const repository = props.package.repositoryUrl ? parseRepositoryUrl(props.package.repositoryUrl) : null;
const copyToInstall = `${props.package.name} >= ${props.package.versions.current}`;
const copyToInstall = `${props.package.name} >= ${props.package.currentVersion}`;
const [isMounted, setIsMounted] = useState(false);
@ -76,7 +76,7 @@ export const Sidebar = (props: SidebarProps) => {
</div>
{
props.package.homepage &&
props.package.homepageUrl &&
<div className={s.sidebarSection}>
<h3 className={s.sidebarSectionHeader}>
Homepage
@ -85,10 +85,10 @@ export const Sidebar = (props: SidebarProps) => {
<div className={s.sidebarEntryIcon}><SvgIcon svg={homepageIcon} /></div>
<lib.links.ExtA
className={s.sidebarEntryLink}
href={props.package.homepage.url}
href={props.package.homepageUrl.url}
analytics={{ featureName: 'GoToPackageHomepage', eventParams: { screen_name: props.analytics.screenName } }}
>
{props.package.homepage.text.replace(/^https?\:\/\//, '').replace(/\/$/, '')}
{props.package.homepageUrl.text.replace(/^https?\:\/\//, '').replace(/\/$/, '')}
</lib.links.ExtA>
</div>
</div>

View File

@ -0,0 +1,45 @@
.versionsPage {
padding-bottom: 24rem;
}
.info {
padding: 24rem 0 8rem 0;
font-size: 14rem;
display: flex;
justify-content: space-between;
}
.version {
padding: 18rem 24rem;
background-color: #fff;
margin-bottom: 12rem;
box-shadow: 0rem 2rem 4rem rgb(0 0 0 / 27%);
border-radius: 12rem;
display: flex;
align-items: center;
position: relative;
color: var(--text-color);
}
.version:hover {
cursor: pointer;
opacity: 0.46;
}
.versionId {
font-weight: bold;
font-size: 16rem;
}
.versionKind {
width: 120rem;
height: 24rem;
border-radius: 8rem;
margin-right: 24rem;
color: #fff;
display: flex;
align-items: center;
justify-content: center;
background-color: var(--surface-color);
font-size: 12rem;
}

View File

@ -0,0 +1,118 @@
import s from './VersionsPage.module.css';
import { PackageProps, Versions } from './common';
import Layout from './Layout';
import * as lib from '@hackage-ui/react-lib';
const screenName = 'PackageVersionsPage';
export type VersionsPageProps = {
package: PackageProps,
}
const VersionsPage = (props: VersionsPageProps) => {
const versions = props.package.versions ? sortVersions(Array.from(new Set([
...props.package.versions?.normal,
...props.package.versions?.unpreferred,
...props.package.versions?.deprecated,
]))) : [];
return (
<Layout analytics={{ screenName }} package={props.package} activeTab="versions">
<div className={s.versionsPage}>
<div className={s.info}>
<span>All {versions.length} versions of <strong>{props.package.name}</strong></span>
<lib.links.ExtA
href="https://pvp.haskell.org/"
analytics={{ featureName: 'GoToPackageVersioningPolicy', eventParams: { screen_name: screenName } }}
>
📘 Versioning Policy
</lib.links.ExtA>
</div>
<div>
{versions.map(versionId => {
let kind: VersionKind = 'normal';
if (props.package.versions && props.package.versions?.unpreferred.includes(versionId)) {
kind = 'unpreferred'
} else if (props.package.versions && props.package.versions?.deprecated.includes(versionId)) {
kind = 'deprecated'
}
return (
<Version
key={versionId}
id={versionId}
kind={kind}
getHref={() => `/package/${props.package.name}-${versionId}`}
/>
);
})}
</div>
</div>
</Layout>
);
}
type VersionKind = 'normal' | 'unpreferred' | 'deprecated';
export type VersionProps = {
id: string,
kind: VersionKind,
getHref: () => string
}
const Version = (props: VersionProps) => {
let versionKindColor = 'var(--accent-color-green)';
let versionKindLabel = 'Recommended';
if (props.kind === 'deprecated') {
versionKindColor = 'var(--accent-color-red)';
versionKindLabel = 'Deprecated';
} else if (props.kind === 'unpreferred') {
versionKindColor = 'var(--text-color)';
versionKindLabel = 'Unpreferred';
}
return (
<lib.links.A
className={s.version}
href={props.getHref()}
analytics={{ featureName: 'ClickPackageVersion', eventParams: { event_label: props.id, screen_name: screenName } }}
>
<div
className={s.versionKind}
style={{
background: versionKindColor
}}
>
{versionKindLabel}
</div>
<div
className={s.versionId}
style={{ color: versionKindColor }}
>
{props.id}
</div>
</lib.links.A>
);
}
function sortVersions(versions: string[]): string[] {
return versions.sort((v1, v2) => {
const v1parts = v1.split('.');
const v2parts = v2.split('.');
const len = Math.min(v1parts.length, v2parts.length);
for (let i = 0; i < len; i++) {
const a2 = +v1parts[i] || 0;
const b2 = +v2parts[i] || 0;
if (a2 !== b2) {
return a2 > b2 ? 1 : -1;
}
}
return v1.length - v2.length;
}).reverse();
}
export default VersionsPage;

View File

@ -1,28 +1,31 @@
export type Versions = {
current: string,
available: string[],
}
normal: string[];
unpreferred: string[];
deprecated: string[];
};
export type License = {
name: string,
url: string | null
}
name: string;
url: string | null;
};
export type Homepage = {
text: string,
url: string
}
text: string;
url: string;
};
export type PackageProps = {
id: string,
name: string,
license: License | null,
homepage: Homepage | null,
repositoryUrl: string | null,
bugReportsUrl: string | null,
versions: Versions,
shortDescription: string | null,
longDescriptionHtml: string | null,
id: string;
name: string;
license: License | null;
homepageUrl: Homepage | null;
repositoryUrl: string | null;
bugReportsUrl: string | null;
versions: Versions | null;
currentVersion: string | null;
versionsCount: number,
shortDescription: string | null;
longDescriptionHtml: string | null;
// Date in ISO 8601
updatedAt: string | null
}
updatedAt: string | null;
};

View File

@ -1,20 +0,0 @@
import s from './PackageOverview.module.css';
import { PackageProps } from "../common";
const PackageOverview = (props: PackageProps) => {
return (
<div className={s.package}>
<div className={s.content}>
<div className={s.briefInfo}>
<div className={s.packageName}>
<small style={{ position: 'relative', top: '2rem' }}>📦</small>&nbsp;<h1 className={s.packageNameH1}>{props.name}</h1><span className={s.packageVersion}>{props.versions.current}</span>
</div>
{props.shortDescription && <div className={s.shortDescription}>{props.shortDescription}</div>}
{props.longDescriptionHtml && <div className={s.longDescription} dangerouslySetInnerHTML={{ __html: props.longDescriptionHtml }}></div>}
</div>
</div>
</div>
);
}
export default PackageOverview;

View File

@ -0,0 +1,38 @@
.tabs {
display: flex;
flex-direction: column;
background-color: var(--purple-color-2);
border-top: 2rem solid var(--purple-color-1);
}
.tabPicker {
display: flex;
}
.tabPickerItemContainer {
position: relative;
overflow: hidden;
}
.tabPickerItem {
display: inline-flex;
padding: 12rem 24rem;
font-size: 14rem;
position: relative;
white-space: nowrap;
line-height: 1;
overflow: hidden;
text-overflow: ellipsis;
color: #fff;
opacity: 0.66;
}
.tabPickerItem:hover {
cursor: pointer;
transition: var(--transition-short);
}
.tabPickerItem:hover,
.tabPickerItemActive {
opacity: 1;
}

View File

@ -0,0 +1,39 @@
import { ReactNode } from "react";
import s from './Tabs.module.css';
import * as lib from '@hackage-ui/react-lib';
export type Tab = {
id: string,
title: string,
href: string,
};
export type TabsProps = {
tabs: Tab[],
activeTab: string,
}
const Tabs = (props: TabsProps) => {
return (
<div className={s.tabs}>
<div className={s.tabPicker}>
{props.tabs.map(tab => {
const isActive = tab.id === props.activeTab;
return (
<div key={tab.id} className={s.tabPickerItemContainer}>
<lib.links.A
className={`${s.tabPickerItem} ${isActive ? s.tabPickerItemActive : ''}`}
href={tab.href}
analytics={{ featureName: `ClickTab-${tab.id}`, eventParams: {} }}
>
{tab.title}
</lib.links.A>
</div >
);
})}
</div >
</div >
);
}
export default Tabs;

View File

@ -0,0 +1,206 @@
import axios from 'axios';
import hljs from 'highlight.js';
import { License, Homepage, Versions } from '../components/pages/package/common';
import cheerio, { CheerioAPI } from 'cheerio';
export type Package = {
id: string,
name: string,
versions: Versions | null,
currentVersion: string | null,
versionsCount: number,
shortDescription: string | null,
longDescriptionHtml: string | null,
license: License | null,
homepageUrl: Homepage | null,
repositoryUrl: string | null,
bugReportsUrl: string | null,
updatedAt: string | null,
}
export async function getPackage(packageId: string): Promise<Package> {
let html = '';
try {
html = await getPackageRawHtml(packageId);
} catch (err) {
console.log(err);
}
const $ = cheerio.load(html);
monkeyPatchDocument($);
const docContent = $('#content');
const name = $('h1 a', docContent).html()?.trim() || '';
const shortDescription = $('h1 small', docContent).html()?.trim() || null;
const longDescriptionHtml = $('#description').html()?.trim() || null;
const versions = await getVersions(name);
const versionsCount = Array.from(new Set([
...versions.normal,
...versions.unpreferred,
...versions.deprecated,
])).length;
return {
id: packageId,
name,
currentVersion: getCurrentVersion($),
versions,
versionsCount,
bugReportsUrl: getBugReportsUrl($),
homepageUrl: getHomepageUrl($),
license: getLicense($),
shortDescription,
longDescriptionHtml,
repositoryUrl: getRepositoryUrl($),
updatedAt: getUpdatedAt($)
}
}
// highlight.js often recognize Haskell code as Erlang, OCaml and others.
// We decided to specify subset of popular languages here excluding from ML family.
// If you want to modify the list or know a better solution, please raise an issue or pull request at our issue tracker.
// Full list here https://github.com/highlightjs/highlight.js/blob/main/SUPPORTED_LANGUAGES.md
const languagesToHighlight = [
'Haskell',
'Shell',
'Bash',
'Diff',
'JSON',
'LaTeX',
'Protocol Buffers',
'TOML',
'XML',
'YAML',
'Nix'
];
export async function getPackageRawHtml(packageId: string): Promise<string> {
let html: string = '';
try {
html = await (await axios(`https://hackage.haskell.org/package/${encodeURIComponent(packageId)}`)).data;
} catch (err) {
console.log(err);
}
return html;
}
// XXX - We can get rid of most content of this function after the hackage-server will implement missing APIs.
export function monkeyPatchDocument($: CheerioAPI): void {
// Rewrite urls.
$('a').map((_, a) => {
$(a).attr('href', $(a).attr('href')?.replace('https://hackage.haskell.org/package/', '/package/') || '#')
});
// Highlight code blocks
$('code, pre').map((_, el) => {
/* Ignore blocks containing other HTML elements:
<code><a href="http://hackage.haskell.org/package/array">array</a></code>
*/
if ($(el).children().length) {
return el;
}
const highlightedHtml = hljs.highlightAuto(unescape($(el).html() as string), languagesToHighlight).value;
$(el).html(highlightedHtml);
$(el).addClass('hljs');
});
// Remove "Skip to Readme" links.
const description = $('#content #description');
const newDescriptionHtml = (description.html() || '').replace(`<hr>
[<a href="#readme">Skip to Readme</a>]`, '').trim();
description.html(newDescriptionHtml);
}
export function getCurrentVersion($: CheerioAPI): string | null {
const propertiesElement = $('#properties').get(0);
const tableTd = $(`th:contains('Version') + td`, propertiesElement).get(0);
if (!tableTd) {
return null;
}
return $(`strong`, tableTd).text().trim();
}
export function getLicense($: CheerioAPI): License | null {
const propertiesElement = $('#properties').get(0);
const tableTd = $(`th:contains('License') + td`, propertiesElement).get(0);
if (!tableTd) {
return null;
}
const licenseEl = $(`> *`, tableTd);
return { name: licenseEl.text(), url: licenseEl.attr('href') || null };
}
export function getHomepageUrl($: CheerioAPI): Homepage | null {
const propertiesElement = $('#properties').get(0);
const tableTd = $(`th:contains('Home page') + td`, propertiesElement).get(0);
if (!tableTd) {
return null;
}
const homepageLink = $(`a`, tableTd);
return {
text: homepageLink.text().trim(),
url: homepageLink.attr('href')?.trim() || '#'
};
}
export function getRepositoryUrl($: CheerioAPI): string | null {
const propertiesElement = $('#properties').get(0);
const tableTd = $(`th:contains('Source') + td`, propertiesElement).get(0);
if (!tableTd) {
return null;
}
const repositoryLink = $(`a`, tableTd);
return repositoryLink.attr('href')?.trim() || null;
}
export function getBugReportsUrl($: CheerioAPI): string | null {
const propertiesElement = $('#properties').get(0);
const tableTd = $(`th:contains('Bug') + td`, propertiesElement).get(0);
if (!tableTd) {
return null;
}
const repositoryLink = $(`a`, tableTd);
return repositoryLink.attr('href')?.trim() || null;
}
export function getUpdatedAt($: CheerioAPI): string | null {
const propertiesElement = $('#properties').get(0);
const tableTd = $(`th:contains('Uploaded') + td`, propertiesElement).get(0);
if (!tableTd) {
return null;
}
return $(tableTd).text().replace(/^by .* at /, '').trim();
}
export async function getVersions(packageName: string): Promise<Versions> {
let versions: Versions = { normal: [], unpreferred: [], deprecated: [] };
try {
const data = await (await axios.get(`https://hackage.haskell.org/package/${packageName}/preferred`, { headers: { accept: 'application/json' } })).data;
versions = {
normal: data['normal-version'] || [],
unpreferred: data['unpreferred-version'] || [],
deprecated: data['deprecated-version'] || [],
}
} catch (err) {
console.log(err);
} finally {
return versions;
}
}

View File

@ -15177,6 +15177,12 @@
"is-core-module": "^2.2.0",
"path-parse": "^1.0.6"
}
},
"semver": {
"version": "6.3.0",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz",
"integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==",
"dev": true
}
}
},
@ -16003,6 +16009,13 @@
"integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==",
"requires": {
"semver": "^6.0.0"
},
"dependencies": {
"semver": {
"version": "6.3.0",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz",
"integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw=="
}
}
},
"md5.js": {
@ -16784,11 +16797,6 @@
"ajv-keywords": "^3.5.2"
}
},
"semver": {
"version": "6.3.0",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz",
"integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw=="
},
"setimmediate": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",

View File

@ -1,11 +1,8 @@
import { NextPage, GetStaticPropsResult, GetStaticPropsContext } from 'next';
import Head from 'next/head';
import PackagePage from '../../components/pages/package/PackagePage';
import { PackageProps, Versions, License, Homepage } from '../../components/pages/package/common';
import axios from 'axios';
import hljs from 'highlight.js';
import cheerio, { CheerioAPI } from 'cheerio';
import unescape from 'lodash/unescape';
import OverviewPage from '../../components/pages/package/OverviewPage';
import { PackageProps } from '../../components/pages/package/common';
import * as pkgFetch from '../../fetch/package';
const Page: NextPage<PackageProps> = (props) => {
return (
@ -15,43 +12,18 @@ const Page: NextPage<PackageProps> = (props) => {
<meta name="description" content={props.shortDescription || props.name}></meta>
</Head>
<PackagePage {...props} />
<OverviewPage {...props} />
</>
);
}
export async function getStaticProps(props: GetStaticPropsContext): Promise<GetStaticPropsResult<PackageProps>> {
const packageId = props.params!.packageId as string;
let html = '';
try {
html = await (await axios(`https://hackage.haskell.org/package/${encodeURIComponent(packageId)}`)).data;
} catch (err) {
console.log(err);
}
const $ = cheerio.load(html);
monkeyPatchDocument($);
const docContent = $('#content');
const name = $('h1 a', docContent).html()?.trim() || '';
const shortDescription = $('h1 small', docContent).html()?.trim() || null;
const longDescriptionHtml = $('#description').html()?.trim() || null;
const pkg = await pkgFetch.getPackage(packageId);
return {
props: {
id: packageId,
name,
versions: getVersions($),
license: getLicense($),
homepage: getHomepage($),
repositoryUrl: getRepositoryUrl($),
bugReportsUrl: getBugReports($),
updatedAt: getUpdatedAt($),
shortDescription,
longDescriptionHtml
},
revalidate: 10
props: pkg,
revalidate: 60
}
}
@ -62,120 +34,4 @@ export async function getStaticPaths() {
};
}
// highlight.js often recognize Haskell code as Erlang, OCaml and others.
// We decided to specify subset of popular languages here excluding from ML family.
// If you want to modify the list or know a better solution, please raise an issue or pull request at our issue tracker.
// Full list here https://github.com/highlightjs/highlight.js/blob/main/SUPPORTED_LANGUAGES.md
const languagesToHighlight = [
'Haskell',
'Shell',
'Bash',
'Diff',
'JSON',
'LaTeX',
'Protocol Buffers',
'TOML',
'XML',
'YAML',
'Nix'
];
// XXX - We can get rid of most content of this function after the hackage-server will implement missing APIs.
function monkeyPatchDocument($: CheerioAPI): void {
// Rewrite urls.
$('a').map((_, a) => {
$(a).attr('href', $(a).attr('href')?.replace('https://hackage.haskell.org/package/', '/package/') || '#')
});
// Highlight code blocks
$('code, pre').map((_, el) => {
/* Ignore blocks containing other HTML elements:
<code><a href="http://hackage.haskell.org/package/array">array</a></code>
*/
if ($(el).children().length) {
return el;
}
const highlightedHtml = hljs.highlightAuto(unescape($(el).html() as string), languagesToHighlight).value;
$(el).html(highlightedHtml);
$(el).addClass('hljs');
});
// Remove "Skip to Readme" links.
const description = $('#content #description');
const newDescriptionHtml = (description.html() || '').replace(`<hr>
[<a href="#readme">Skip to Readme</a>]`, '').trim();
description.html(newDescriptionHtml);
}
function getVersions($: CheerioAPI): Versions {
const propertiesElement = $('#properties').get(0);
const tableTd = $(`th:contains('Version') + td`, propertiesElement).get(0);
const current = $(`strong`, tableTd).text().trim();
const available = $('*', tableTd).map((_, el) => $(el).text().trim()).toArray();
return { current, available };
}
function getLicense($: CheerioAPI): License | null {
const propertiesElement = $('#properties').get(0);
const tableTd = $(`th:contains('License') + td`, propertiesElement).get(0);
if (!tableTd) {
return null;
}
const licenseEl = $(`> *`, tableTd);
return { name: licenseEl.text(), url: licenseEl.attr('href') || null };
}
function getHomepage($: CheerioAPI): Homepage | null {
const propertiesElement = $('#properties').get(0);
const tableTd = $(`th:contains('Home page') + td`, propertiesElement).get(0);
if (!tableTd) {
return null;
}
const homepageLink = $(`a`, tableTd);
return {
text: homepageLink.text().trim(),
url: homepageLink.attr('href')?.trim() || '#'
};
}
function getRepositoryUrl($: CheerioAPI): string | null {
const propertiesElement = $('#properties').get(0);
const tableTd = $(`th:contains('Source') + td`, propertiesElement).get(0);
if (!tableTd) {
return null;
}
const repositoryLink = $(`a`, tableTd);
return repositoryLink.attr('href')?.trim() || null;
}
function getBugReports($: CheerioAPI): string | null {
const propertiesElement = $('#properties').get(0);
const tableTd = $(`th:contains('Bug') + td`, propertiesElement).get(0);
if (!tableTd) {
return null;
}
const repositoryLink = $(`a`, tableTd);
return repositoryLink.attr('href')?.trim() || null;
}
function getUpdatedAt($: CheerioAPI): string | null {
const propertiesElement = $('#properties').get(0);
const tableTd = $(`th:contains('Uploaded') + td`, propertiesElement).get(0);
if (!tableTd) {
return null;
}
return $(tableTd).text().replace(/^by .* at /, '').trim();
}
export default Page;

View File

@ -0,0 +1,38 @@
import { NextPage, GetStaticPropsResult, GetStaticPropsContext } from 'next';
import Head from 'next/head';
import VersionsPage, { VersionsPageProps } from '../../../components/pages/package/VersionsPage';
import * as pkgFetch from '../../../fetch/package';
const Page: NextPage<VersionsPageProps> = (props) => {
return (
<>
<Head>
<title>{props.package.name} - Hackage: The Haskell communitys package registry</title>
<meta name="description" content={props.package.shortDescription || props.package.name}></meta>
</Head>
<VersionsPage {...props} />
</>
);
}
export async function getStaticProps(props: GetStaticPropsContext): Promise<GetStaticPropsResult<VersionsPageProps>> {
const packageId = props.params!.packageId as string;
const pkg = await pkgFetch.getPackage(packageId);
return {
props: {
package: pkg,
},
revalidate: 60
}
}
export async function getStaticPaths() {
return {
paths: [],
fallback: 'blocking'
};
}
export default Page;

View File

@ -19,9 +19,9 @@
--purple-color-3: #8f4e8b;
--transition-short: all 180ms ease 0s;
--backdrop-filter-blur: blur(12px);
--accent-color-green: var(--purple-color-2);
--accent-color-green: #04c262;
--accent-color-red: #bf4646;
--accent-color-yellow: #ff9042;
--accent-color-yellow: #f8cf06;
--accent-color-blue: #5084ff;
--accent-color-purple: #9d50ff;
--max-content-width: 1000px;

View File

@ -19,9 +19,9 @@
--purple-color-3: #8f4e8b;
--transition-short: all 180ms ease 0s;
--backdrop-filter-blur: blur(12px);
--accent-color-green: var(--purple-color-2);
--accent-color-green: #04c262;
--accent-color-red: #bf4646;
--accent-color-yellow: #ff9042;
--accent-color-yellow: #f8cf06;
--accent-color-blue: #5084ff;
--accent-color-purple: #9d50ff;
--max-content-width: 1000px;

View File

@ -18,3 +18,4 @@ https://code.visualstudio.com/api/extension-guides/webview#inspecting-and-debugg
https://code.visualstudio.com/api/working-with-extensions/publishing-extension
Manage in the marketplace: https://marketplace.visualstudio.com/manage/publishers/visortelle

View File

@ -1,3 +1,10 @@
# Haskell Spotlight
Search on Hackage, Hoogle, and more soon.
`Alt + H` is a default hotkey.
<img width="1437" alt="Screen Shot 2022-01-18 at 10 18 26 PM" src="https://user-images.githubusercontent.com/9302460/150020345-0a0146a3-1f89-4d78-a099-b73c31c50f62.png">
https://user-images.githubusercontent.com/9302460/150015894-fe62ea7d-9a45-4e31-842a-e60ae8747970.mov

View File

@ -1,6 +1,6 @@
{
"name": "haskell-spotlight",
"version": "0.0.1",
"version": "0.0.3",
"lockfileVersion": 1,
"requires": true,
"dependencies": {

View File

@ -2,7 +2,7 @@
"name": "haskell-spotlight",
"description": "Search on Hackage, Hoogle, and more soon.",
"icon": "build/icon-192.png",
"version": "0.0.2",
"version": "0.0.3",
"publisher": "visortelle",
"displayName": "Haskell Spotlight",
"license": "MIT",