Compare commits
8 Commits
table-prev
...
19-markdow
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
adcafc7d6a | ||
|
|
e563782dc4 | ||
|
|
730e160951 | ||
|
|
418b4bfe52 | ||
|
|
ee6efc7431 | ||
|
|
a62addbfbb | ||
|
|
adb6d1bb0e | ||
|
|
2115a3fdb3 |
1
.gitignore
vendored
@@ -16,6 +16,7 @@ node_modules
|
||||
*.launch
|
||||
.settings/
|
||||
*.sublime-workspace
|
||||
.obsidian
|
||||
|
||||
# IDE - VSCode
|
||||
.vscode/*
|
||||
|
||||
@@ -1,45 +1,138 @@
|
||||
import { Octokit } from 'octokit';
|
||||
import { assert, expect, test } from 'vitest'
|
||||
import { getProjectDataPackage } from '../lib/octokit';
|
||||
import { expect, test } from 'vitest';
|
||||
import { getAllProjectsFromOrg, getProjectDataPackage } from '../lib/project';
|
||||
import { loadDataPackage } from '../lib/loader';
|
||||
import { getProjectMetadata } from '../lib/project';
|
||||
import { getCsv, parseCsv } from '../components/Table';
|
||||
|
||||
export async function getAllDataPackagesFromOrg(
|
||||
org: string,
|
||||
branch?: string,
|
||||
github_pat?: string
|
||||
) {
|
||||
const octokit = new Octokit({ auth: github_pat });
|
||||
const repos = await octokit.rest.repos.listForOrg({ org, type: 'public', per_page: 100 });
|
||||
let failedDataPackages = [];
|
||||
const datapackages = await Promise.all(
|
||||
repos.data.map(async (_repo) => {
|
||||
test(
|
||||
'Test OS-Data',
|
||||
async () => {
|
||||
const repos = await getAllProjectsFromOrg(
|
||||
'os-data',
|
||||
'main',
|
||||
process.env.VITE_GITHUB_PAT
|
||||
);
|
||||
if (repos.failed.length > 0) console.log(repos.failed);
|
||||
expect(repos.failed.length).toBe(0);
|
||||
},
|
||||
{ timeout: 100000 }
|
||||
);
|
||||
|
||||
test(
|
||||
'Test Gift-Data',
|
||||
async () => {
|
||||
const repos = await getAllProjectsFromOrg(
|
||||
'gift-data',
|
||||
'main',
|
||||
process.env.VITE_GITHUB_PAT
|
||||
);
|
||||
if (repos.failed.length > 0) console.log(repos.failed);
|
||||
expect(repos.failed.length).toBe(0);
|
||||
},
|
||||
{ timeout: 100000 }
|
||||
);
|
||||
|
||||
test(
|
||||
'Test getting one dataset from github',
|
||||
async () => {
|
||||
const datapackage = await getProjectDataPackage(
|
||||
org,
|
||||
_repo.name,
|
||||
branch ? branch : 'main',
|
||||
github_pat
|
||||
'os-data',
|
||||
'berlin-berlin',
|
||||
'main',
|
||||
process.env.VITE_GITHUB_PAT
|
||||
);
|
||||
if (!datapackage) {
|
||||
failedDataPackages.push(_repo.name)
|
||||
return null
|
||||
};
|
||||
return {...datapackage, repo: _repo.name};
|
||||
})
|
||||
const repo = await getProjectMetadata(
|
||||
'os-data',
|
||||
'berlin-berlin',
|
||||
process.env.VITE_GITHUB_PAT
|
||||
);
|
||||
return {
|
||||
datapackages: datapackages.filter((item) => item !== null),
|
||||
failedDataPackages,
|
||||
};
|
||||
}
|
||||
|
||||
test('Test OS-Data', async () => {
|
||||
const repos = await getAllDataPackagesFromOrg('os-data', 'main', process.env.VITE_GITHUB_PAT)
|
||||
if (repos.failedDataPackages.length > 0) console.log(repos.failedDataPackages)
|
||||
expect(repos.failedDataPackages.length).toBe(0)
|
||||
}, {timeout: 100000})
|
||||
|
||||
test('Test Gift-Data', async () => {
|
||||
const repos = await getAllDataPackagesFromOrg('gift-data', 'main', process.env.VITE_GITHUB_PAT)
|
||||
if (repos.failedDataPackages.length > 0) console.log(repos.failedDataPackages)
|
||||
expect(repos.failedDataPackages.length).toBe(0)
|
||||
}, {timeout: 100000})
|
||||
const project = loadDataPackage(datapackage, repo);
|
||||
delete project['datapackage'];
|
||||
delete project.files[0]['dialect'];
|
||||
delete project.files[0]['schema'];
|
||||
expect(project).toStrictEqual({
|
||||
name: 'berlin-berlin',
|
||||
title: 'Berlin-Berlin',
|
||||
description: null,
|
||||
owner: {
|
||||
name: 'os-data',
|
||||
logo: 'https://avatars.githubusercontent.com/u/13695166?v=4',
|
||||
title: 'os-data',
|
||||
},
|
||||
repo: {
|
||||
name: 'berlin-berlin',
|
||||
full_name: 'os-data/berlin-berlin',
|
||||
url: 'https://github.com/os-data/berlin-berlin',
|
||||
},
|
||||
files: [
|
||||
{
|
||||
name: 'berlin-gesamt',
|
||||
format: 'csv',
|
||||
path: 'https://storage.openspending.org/berlin-berlin/berlin-gesamt.csv',
|
||||
mediatype: 'text/csv',
|
||||
bytes: 81128743,
|
||||
encoding: 'utf-8',
|
||||
},
|
||||
],
|
||||
author: 'Michael Peters <michael.peters@okfn.de>',
|
||||
cityCode: 'Berlin',
|
||||
countryCode: 'DE',
|
||||
fiscalPeriod: { start: '2014-01-01', end: '2019-12-31' },
|
||||
readme: '',
|
||||
});
|
||||
},
|
||||
{ timeout: 100000 }
|
||||
);
|
||||
|
||||
test(
|
||||
'Test getting one section of csv from R2',
|
||||
async () => {
|
||||
const rawCsv = await getCsv(
|
||||
'https://storage.openspending.org/state-of-minas-gerais-brazil-planned-budget/__os_imported__br-mg-ppagloc.csv'
|
||||
);
|
||||
const parsedCsv = await parseCsv(rawCsv);
|
||||
expect(parsedCsv.errors.length).toBe(1);
|
||||
expect(parsedCsv.data.length).toBe(10165);
|
||||
expect(parsedCsv.meta.fields).toStrictEqual([
|
||||
'function_name',
|
||||
'function_label',
|
||||
'product_name',
|
||||
'product_label',
|
||||
'area_name',
|
||||
'area_label',
|
||||
'subaction_name',
|
||||
'subaction_label',
|
||||
'region_label_map',
|
||||
'region_reg_map',
|
||||
'region_name',
|
||||
'region_label',
|
||||
'municipality_map_id',
|
||||
'municipality_name',
|
||||
'municipality_map_code',
|
||||
'municipality_label',
|
||||
'municipality_map_name_simple',
|
||||
'municipality_map_name',
|
||||
'cofog1_label_en',
|
||||
'cofog1_name',
|
||||
'cofog1_label',
|
||||
'amount',
|
||||
'subprogramme_name',
|
||||
'subprogramme_label',
|
||||
'time_name',
|
||||
'time_year',
|
||||
'time_month',
|
||||
'time_day',
|
||||
'time_week',
|
||||
'time_yearmonth',
|
||||
'time_quarter',
|
||||
'time',
|
||||
'action_name',
|
||||
'action_label',
|
||||
'subfunction_name',
|
||||
'subfunction_label',
|
||||
'programme_name',
|
||||
'programme_label',
|
||||
]);
|
||||
},
|
||||
{ timeout: 100000 }
|
||||
);
|
||||
|
||||
@@ -9,7 +9,7 @@ export default function DatasetCard({ dataset }: { dataset: Project }) {
|
||||
className="overflow-hidden rounded-xl border border-gray-200"
|
||||
>
|
||||
<Link
|
||||
href=""
|
||||
href={`/@${dataset.owner.name}/${dataset.repo.name}`}
|
||||
className="flex items-center gap-x-4 border-b border-gray-900/5 bg-gray-50 p-6"
|
||||
>
|
||||
<img
|
||||
@@ -60,8 +60,8 @@ export default function DatasetCard({ dataset }: { dataset: Project }) {
|
||||
<dd className="flex items-start gap-x-2">
|
||||
<div className="font-medium text-gray-900">
|
||||
<Link
|
||||
// TODO: where do we get the info needed for this link?
|
||||
href=""
|
||||
// TODO: this link may be incorrect for some datasets
|
||||
href={`https://github.com/${dataset.owner.name}/${dataset.repo.name}/blob/main/datapackage.json`}
|
||||
target="_blank"
|
||||
className="flex items-center hover:text-gray-700"
|
||||
>
|
||||
|
||||
@@ -2,9 +2,26 @@ import { useForm } from 'react-hook-form';
|
||||
import DatasetsGrid from './DatasetsGrid';
|
||||
import { Project } from '../lib/project.interface';
|
||||
import { Index } from 'flexsearch';
|
||||
import {
|
||||
ChevronDoubleLeftIcon,
|
||||
ChevronDoubleRightIcon,
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
} from '@heroicons/react/24/solid';
|
||||
import { useState } from 'react';
|
||||
|
||||
export default function DatasetsSearch({
|
||||
datasets,
|
||||
availableCountries,
|
||||
}: {
|
||||
datasets: Project[];
|
||||
availableCountries;
|
||||
}) {
|
||||
const itemsPerPage = 6;
|
||||
const [page, setPage] = useState(1);
|
||||
|
||||
export default function DatasetsSearch({ datasets }: { datasets: Project[] }) {
|
||||
const index = new Index({ tokenize: 'full' });
|
||||
|
||||
datasets.forEach((dataset: Project) =>
|
||||
index.add(
|
||||
dataset.name,
|
||||
@@ -21,94 +38,7 @@ export default function DatasetsSearch({ datasets }: { datasets: Project[] }) {
|
||||
},
|
||||
});
|
||||
|
||||
const allCountries = datasets
|
||||
.map((item) => item.countryCode)
|
||||
.filter((v) => v) // Filters false values
|
||||
.filter((v, i, a) => a.indexOf(v) === i) // Remove duplicates
|
||||
// TODO: title should be the full name
|
||||
.map((code) => ({ code, title: code }));
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col gap-3 sm:flex-row">
|
||||
<div className="min-w-0 flex-auto">
|
||||
<br />
|
||||
<div className="relative">
|
||||
<input
|
||||
placeholder="Search datasets"
|
||||
aria-label="Search datasets"
|
||||
{...register('searchTerm')}
|
||||
className="h-[3em] relative w-full rounded-lg bg-white py-2 pl-3 pr-10 text-left shadow-md focus:outline-none focus-visible:border-emerald-500 focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-opacity-75 focus-visible:ring-offset-2 focus-visible:ring-offset-emerald-400 sm:text-sm"
|
||||
/>
|
||||
{watch().searchTerm !== '' && (
|
||||
<button
|
||||
onClick={() => resetField('searchTerm')}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500"
|
||||
>
|
||||
<CloseIcon />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="sm:basis-1/6">
|
||||
{/* TODO: nicer select e.g. headlessui example */}
|
||||
<label className="text-sm text-gray-600 font-medium">Country</label>
|
||||
<select
|
||||
className="h-[3em] w-full rounded-lg bg-white py-2 pl-3 pr-10 text-left shadow-md focus:outline-none focus-visible:border-emerald-500 focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-opacity-75 focus-visible:ring-offset-2 focus-visible:ring-offset-emerald-400 sm:text-sm"
|
||||
{...register('country')}
|
||||
>
|
||||
<option value="">All</option>
|
||||
{allCountries.map((country) => {
|
||||
return (
|
||||
<option key={country.code} value={country.code}>
|
||||
{country.title}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
</div>
|
||||
<div className="sm:basis-1/6">
|
||||
<label className="text-sm text-gray-600 font-medium">Min. date</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
aria-label="Min. date"
|
||||
type="date"
|
||||
{...register('minDate')}
|
||||
className="h-[3em] w-full rounded-lg bg-white py-2 pl-3 pr-10 text-left shadow-md focus:outline-none focus-visible:border-emerald-500 focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-opacity-75 focus-visible:ring-offset-2 focus-visible:ring-offset-emerald-400 sm:text-sm"
|
||||
/>
|
||||
{watch().minDate !== '' && (
|
||||
<button
|
||||
onClick={() => resetField('minDate')}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500"
|
||||
>
|
||||
<CloseIcon />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="sm:basis-1/6">
|
||||
<label className="text-sm text-gray-600 font-medium">Max. date</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
aria-label="Max. date"
|
||||
type="date"
|
||||
{...register('maxDate')}
|
||||
className="h-[3em] w-full rounded-lg bg-white py-2 pl-3 pr-10 text-left shadow-md focus:outline-none focus-visible:border-emerald-500 focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-opacity-75 focus-visible:ring-offset-2 focus-visible:ring-offset-emerald-400 sm:text-sm"
|
||||
/>
|
||||
{watch().maxDate !== '' && (
|
||||
<button
|
||||
onClick={() => resetField('maxDate')}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500"
|
||||
>
|
||||
<CloseIcon />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-w-full mt-10 align-middle">
|
||||
<DatasetsGrid
|
||||
datasets={datasets
|
||||
const filteredDatasets = datasets
|
||||
.filter((dataset: Project) =>
|
||||
watch().searchTerm && watch().searchTerm !== ''
|
||||
? index.search(watch().searchTerm).includes(dataset.name)
|
||||
@@ -132,8 +62,121 @@ export default function DatasetsSearch({ datasets }: { datasets: Project[] }) {
|
||||
watch().maxDate && watch().maxDate !== ''
|
||||
? dataset.fiscalPeriod?.end <= watch().maxDate
|
||||
: true
|
||||
)}
|
||||
);
|
||||
|
||||
const paginatedDatasets = filteredDatasets.slice(
|
||||
(page - 1) * itemsPerPage,
|
||||
(page - 1) * itemsPerPage + itemsPerPage
|
||||
);
|
||||
|
||||
const pageCount = Math.ceil(filteredDatasets.length / itemsPerPage) || 1;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col gap-3 sm:flex-row">
|
||||
<div className="min-w-0 flex-auto">
|
||||
<br />
|
||||
<div className="relative">
|
||||
<input
|
||||
placeholder="Search datasets"
|
||||
aria-label="Search datasets"
|
||||
{...register('searchTerm', { onChange: () => setPage(1) })}
|
||||
className="h-[3em] relative w-full rounded-lg bg-white py-2 pl-3 pr-10 text-left shadow-md focus:outline-none focus-visible:border-emerald-500 focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-opacity-75 focus-visible:ring-offset-2 focus-visible:ring-offset-emerald-400 sm:text-sm"
|
||||
/>
|
||||
{watch().searchTerm !== '' && (
|
||||
<button
|
||||
onClick={() => resetField('searchTerm')}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500"
|
||||
>
|
||||
<CloseIcon />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="sm:basis-1/6">
|
||||
{/* TODO: nicer select e.g. headlessui example */}
|
||||
<label className="text-sm text-gray-600 font-medium">Country</label>
|
||||
<select
|
||||
className="h-[3em] w-full rounded-lg bg-white py-2 pl-3 pr-10 text-left shadow-md focus:outline-none focus-visible:border-emerald-500 focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-opacity-75 focus-visible:ring-offset-2 focus-visible:ring-offset-emerald-400 sm:text-sm"
|
||||
{...register('country', { onChange: () => setPage(1) })}
|
||||
>
|
||||
<option value="">All</option>
|
||||
{availableCountries.map((country) => {
|
||||
return (
|
||||
<option key={country.code} value={country.code}>
|
||||
{country.title}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
</div>
|
||||
<div className="sm:basis-1/6">
|
||||
<label className="text-sm text-gray-600 font-medium">Min. date</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
aria-label="Min. date"
|
||||
type="date"
|
||||
{...register('minDate', { onChange: () => setPage(1) })}
|
||||
className="h-[3em] w-full rounded-lg bg-white py-2 pl-3 pr-10 text-left shadow-md focus:outline-none focus-visible:border-emerald-500 focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-opacity-75 focus-visible:ring-offset-2 focus-visible:ring-offset-emerald-400 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="sm:basis-1/6">
|
||||
<label className="text-sm text-gray-600 font-medium">Max. date</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
aria-label="Max. date"
|
||||
type="date"
|
||||
{...register('maxDate', { onChange: () => setPage(1) })}
|
||||
className="h-[3em] w-full rounded-lg bg-white py-2 pl-3 pr-10 text-left shadow-md focus:outline-none focus-visible:border-emerald-500 focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-opacity-75 focus-visible:ring-offset-2 focus-visible:ring-offset-emerald-400 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-5 mb-5">
|
||||
<span className="text-lg font-medium">
|
||||
{filteredDatasets.length} datasets found
|
||||
</span>
|
||||
</div>
|
||||
<div className="min-w-full align-middle">
|
||||
<DatasetsGrid datasets={paginatedDatasets} />
|
||||
<div className="w-full flex justify-center mt-10">
|
||||
<button
|
||||
onClick={() => setPage(1)}
|
||||
disabled={page <= 1}
|
||||
className="disabled:text-gray-400"
|
||||
>
|
||||
<ChevronDoubleLeftIcon className="w-6 h-6" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (page > 1) setPage((prev) => --prev);
|
||||
}}
|
||||
disabled={page <= 1}
|
||||
className="disabled:text-gray-400"
|
||||
>
|
||||
<ChevronLeftIcon className="w-6 h-6" />
|
||||
</button>
|
||||
<span className="mx-5">
|
||||
Page {page} of {pageCount}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (page < pageCount) setPage((prev) => ++prev);
|
||||
}}
|
||||
disabled={page >= pageCount}
|
||||
className="disabled:text-gray-400"
|
||||
>
|
||||
<ChevronRightIcon className="w-6 h-6" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setPage(pageCount)}
|
||||
disabled={page >= pageCount}
|
||||
className="disabled:text-gray-400"
|
||||
>
|
||||
<ChevronDoubleRightIcon className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,53 +1,82 @@
|
||||
import Image from 'next/image'
|
||||
import { Button } from './Button'
|
||||
import { Container } from './Container'
|
||||
import logo from "../public/logo.svg"
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/router'
|
||||
import Image from 'next/image';
|
||||
import { Container } from './Container';
|
||||
import logo from '../public/logo.svg';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/router';
|
||||
import { Bars3Icon } from '@heroicons/react/24/outline';
|
||||
import { useState } from 'react';
|
||||
|
||||
export function Header() {
|
||||
const [menuOpen, setMenuOpen] = useState<boolean>(false);
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const isActive = (navLink) => {
|
||||
return router.asPath.split("?")[0] == navLink.href;
|
||||
}
|
||||
return router.asPath.split('?')[0] == navLink.href;
|
||||
};
|
||||
|
||||
const navLinks = [
|
||||
{
|
||||
title: "Home",
|
||||
href: "/#header"
|
||||
title: 'Home',
|
||||
href: '/',
|
||||
},
|
||||
{
|
||||
title: "Datasets",
|
||||
href: "/#datasets"
|
||||
title: 'Datasets',
|
||||
href: '/#datasets',
|
||||
},
|
||||
{
|
||||
title: "Community",
|
||||
href: "https://community.openspending.org/"
|
||||
}
|
||||
]
|
||||
// {
|
||||
// title: "Community",
|
||||
// href: "https://community.openspending.org/"
|
||||
// }
|
||||
];
|
||||
|
||||
return (
|
||||
<header className="z-50 pb-5 lg:pt-11 sticky top-0 backdrop-blur" id="header">
|
||||
<Container className="flex flex-wrap items-center justify-center sm:justify-between lg:flex-nowrap">
|
||||
<div className="mt-10 lg:mt-0 lg:grow lg:basis-0 flex items-center">
|
||||
<header className="relative z-50 pb-11 lg:pt-11">
|
||||
<Container className="flex flex-wrap items-center justify-between lg:flex-nowrap mt-10 lg:mt-0">
|
||||
<Link href="/" className="lg:mt-0 lg:grow lg:basis-0 flex items-center">
|
||||
<Image src={logo} alt="OpenSpending" className="h-12 w-auto" />
|
||||
</div>
|
||||
<ul className='list-none flex gap-x-5 text-base font-medium'>
|
||||
</Link>
|
||||
<ul className="hidden list-none sm:flex gap-x-5 text-base font-medium">
|
||||
{navLinks.map((link, i) => (
|
||||
<li key={`nav-link-${i}`}>
|
||||
<Link
|
||||
className={`text-emerald-900 hover:text-emerald-600 ${isActive(link) ? "text-emerald-600" : ""}`}
|
||||
className={`text-emerald-900 hover:text-emerald-600 ${
|
||||
isActive(link) ? 'text-emerald-600' : ''
|
||||
}`}
|
||||
href={link.href}
|
||||
scroll={false}
|
||||
>
|
||||
{link.title}
|
||||
</Link>
|
||||
</li>))}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="hidden sm:mt-10 sm:flex lg:mt-0 lg:grow lg:basis-0 lg:justify-end">
|
||||
<div className="hidden xl:block xl:grow"></div>
|
||||
<div className="sm:hidden sm:mt-10 lg:mt-0 lg:grow lg:basis-0 lg:justify-end">
|
||||
<button onClick={() => setMenuOpen(!menuOpen)}>
|
||||
<Bars3Icon className="w-8 h-8" />
|
||||
</button>
|
||||
</div>
|
||||
{menuOpen && (
|
||||
<div className={`sm:hidden basis-full mt-5 text-center`}>
|
||||
<ul className="gap-x-5 text-base font-medium">
|
||||
{navLinks.map((link, i) => (
|
||||
<li key={`nav-link-${i}`}>
|
||||
<Link
|
||||
className={`text-emerald-900 hover:text-emerald-600 ${
|
||||
isActive(link) ? 'text-emerald-600' : ''
|
||||
}`}
|
||||
href={link.href}
|
||||
scroll={false}
|
||||
>
|
||||
{link.title}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</Container>
|
||||
</header >
|
||||
)
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Button } from './Button'
|
||||
import { Container } from './Container'
|
||||
import { Button } from './Button';
|
||||
import { Container } from './Container';
|
||||
|
||||
export function Hero() {
|
||||
export function Hero({ countriesCount, datasetsCount, filesCount }) {
|
||||
return (
|
||||
<div className="relative pb-20 pt-10 sm:py-40">
|
||||
<div className="relative pb-20 pt-10 sm:py-40" id="hero">
|
||||
<div className="absolute inset-x-0 -bottom-14 -top-48 overflow-hidden bg-green-50 bg-opacity-50">
|
||||
<div className="absolute inset-x-0 top-0 h-40 bg-gradient-to-b from-white" />
|
||||
<div className="absolute inset-x-0 bottom-0 h-40 bg-gradient-to-t from-white" />
|
||||
@@ -15,12 +15,13 @@ export function Hero() {
|
||||
</h1>
|
||||
<div className="mt-6 space-y-6 font-display text-2xl tracking-tight text-emerald-900">
|
||||
<p>
|
||||
By understanding how governments spend money in our name can we have a say
|
||||
in how that money will affect our own lives. The journey starts here.
|
||||
By understanding how governments spend money in our name can we
|
||||
have a say in how that money will affect our own lives. The
|
||||
journey starts here.
|
||||
</p>
|
||||
<p>
|
||||
OpenSpending is a free, open and global platform to search, visualise and analyse
|
||||
fiscal data in the public sphere.
|
||||
OpenSpending is a free, open and global platform to search,
|
||||
visualise and analyse fiscal data in the public sphere.
|
||||
</p>
|
||||
</div>
|
||||
<Button href="#datasets" className="mt-10">
|
||||
@@ -28,9 +29,11 @@ export function Hero() {
|
||||
</Button>
|
||||
<dl className="mt-10 grid grid-cols-2 gap-x-10 gap-y-6 sm:mt-16 sm:gap-x-16 sm:gap-y-10 sm:text-center lg:auto-cols-auto lg:grid-flow-col lg:grid-cols-none lg:justify-start lg:text-left">
|
||||
{[
|
||||
['Countries', '75'],
|
||||
['Datasets', '2091'],
|
||||
['Files', '9230'],
|
||||
// Added the plus sign because some datasets do not
|
||||
// contain defined countries
|
||||
['Countries', '+' + countriesCount],
|
||||
['Datasets', datasetsCount],
|
||||
['Files', filesCount],
|
||||
].map(([name, value]) => (
|
||||
<div key={name}>
|
||||
<dt className="font-mono text-sm text-emerald-600">{name}</dt>
|
||||
@@ -43,5 +46,5 @@ export function Hero() {
|
||||
</div>
|
||||
</Container>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
94
examples/openspending/components/Table.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
import {
|
||||
QueryClient,
|
||||
QueryClientProvider,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
} from 'react-query';
|
||||
import Papa from 'papaparse';
|
||||
import { Grid } from '@githubocto/flat-ui';
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
export async function getCsv(url: string) {
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
Range: 'bytes=0-5132288',
|
||||
},
|
||||
});
|
||||
const data = await response.text();
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function parseCsv(file: string): Promise<any> {
|
||||
return new Promise((resolve, reject) => {
|
||||
Papa.parse(file, {
|
||||
header: true,
|
||||
dynamicTyping: true,
|
||||
skipEmptyLines: true,
|
||||
transform: (value: string): string => {
|
||||
return value.trim();
|
||||
},
|
||||
complete: (results: any) => {
|
||||
return resolve(results);
|
||||
},
|
||||
error: (error: any) => {
|
||||
return reject(error);
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const Spinning = () => {
|
||||
return (
|
||||
<div role="status w-fit mx-auto">
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="w-8 h-8 mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-emerald-600"
|
||||
viewBox="0 0 100 101"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z"
|
||||
fill="currentFill"
|
||||
/>
|
||||
</svg>
|
||||
<span className="sr-only">Loading...</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const Table: React.FC<{ url: string }> = ({ url }) => {
|
||||
return (
|
||||
// Provide the client to your App
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<TableInner url={url} />
|
||||
</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
|
||||
const TableInner: React.FC<{ url: string }> = ({ url }) => {
|
||||
const { data: csvString, isLoading: isDownloadingCSV } = useQuery(
|
||||
['dataCsv', url],
|
||||
() => getCsv(url)
|
||||
);
|
||||
const { data: parsedData, isLoading: isParsing } = useQuery(
|
||||
['dataPreview', csvString],
|
||||
() => parseCsv(csvString),
|
||||
{ enabled: !!csvString }
|
||||
);
|
||||
if (isParsing || isDownloadingCSV)
|
||||
<div className="w-full">
|
||||
<Spinning />
|
||||
</div>;
|
||||
if (parsedData)
|
||||
return (
|
||||
<div className="h-[500px] overflow-scroll">
|
||||
<Grid data={parsedData.data} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
10
examples/openspending/components/_shared/Layout.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Header } from '../Header';
|
||||
|
||||
export default function Layout({ children }) {
|
||||
return (
|
||||
<div className="bg-white min-h-screen">
|
||||
<Header />
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
[
|
||||
{
|
||||
"owner": "os-data",
|
||||
"branch": "main",
|
||||
"name": "mongolia-budget-2016-2017"
|
||||
},
|
||||
{
|
||||
"owner": "os-data",
|
||||
"branch": "main",
|
||||
"name": "gb-country-regional-analysis"
|
||||
},
|
||||
{
|
||||
"owner": "os-data",
|
||||
"branch": "main",
|
||||
"name": "berlin-berlin"
|
||||
},
|
||||
{
|
||||
"owner": "os-data",
|
||||
"branch": "main",
|
||||
"name": "state-of-minas-gerais-brazil-planned-budget"
|
||||
},
|
||||
{
|
||||
"owner": "os-data",
|
||||
"branch": "main",
|
||||
"name": "wesel"
|
||||
}
|
||||
]
|
||||
@@ -98,6 +98,7 @@ export interface TabularDataResource {
|
||||
key?: string;
|
||||
path?: string;
|
||||
size?: number;
|
||||
bytes?: number;
|
||||
}
|
||||
|
||||
export interface Field {
|
||||
|
||||
@@ -5,13 +5,14 @@ export function loadDataPackage(datapackage: FiscalDataPackage, repo): Project {
|
||||
return {
|
||||
name: datapackage.name,
|
||||
title: datapackage.title,
|
||||
description: datapackage.description || null,
|
||||
owner: {
|
||||
name: repo.owner.login,
|
||||
logo: repo.owner.avatar_url,
|
||||
// TODO: make this title work
|
||||
title: repo.owner.login,
|
||||
},
|
||||
repo: { name: repo, full_name: repo.full_name },
|
||||
repo: { name: repo.name, full_name: repo.full_name, url: repo.html_url },
|
||||
files: datapackage.resources,
|
||||
author: datapackage.author ? datapackage.author : null,
|
||||
cityCode: datapackage.cityCode ? datapackage.cityCode : null,
|
||||
|
||||
@@ -5,10 +5,11 @@ import {
|
||||
|
||||
export interface Project {
|
||||
owner: { name: string; logo?: string; title?: string }; // Info about the owner of the data repo
|
||||
repo: { name: string; full_name: string }; // Info about the the data repo
|
||||
repo: { name: string; full_name: string; url: string }; // Info about the the data repo
|
||||
files: TabularDataResource[];
|
||||
name: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
author?: string;
|
||||
cityCode?: string;
|
||||
countryCode?: string;
|
||||
|
||||
@@ -13,8 +13,7 @@ export interface GithubProject {
|
||||
export async function getProjectReadme(
|
||||
owner: string,
|
||||
repo: string,
|
||||
branch: string,
|
||||
readme: string,
|
||||
branch: string = 'main',
|
||||
github_pat?: string
|
||||
) {
|
||||
const octokit = new Octokit({ auth: github_pat });
|
||||
@@ -22,7 +21,7 @@ export async function getProjectReadme(
|
||||
const response = await octokit.rest.repos.getContent({
|
||||
owner,
|
||||
repo,
|
||||
path: readme,
|
||||
path: 'README.md',
|
||||
ref: branch,
|
||||
});
|
||||
const data = response.data as { content?: string };
|
||||
@@ -125,7 +124,6 @@ export async function getProject(project: GithubProject, github_pat?: string) {
|
||||
project.owner,
|
||||
project.repo,
|
||||
project.branch,
|
||||
project.readme,
|
||||
github_pat
|
||||
);
|
||||
|
||||
@@ -185,8 +183,43 @@ export async function getProjectDataPackage(
|
||||
}
|
||||
const decodedContent = Buffer.from(fileContent, 'base64').toString();
|
||||
const datapackage = JSON.parse(decodedContent);
|
||||
return {...datapackage, repo };
|
||||
|
||||
return { ...datapackage, repo };
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAllProjectsFromOrg(
|
||||
org: string,
|
||||
branch?: string,
|
||||
github_pat?: string
|
||||
) {
|
||||
const octokit = new Octokit({ auth: github_pat });
|
||||
const repos = await octokit.rest.repos.listForOrg({
|
||||
org,
|
||||
type: 'public',
|
||||
per_page: 100,
|
||||
});
|
||||
let failedProjects = [];
|
||||
const projects = await Promise.all(
|
||||
repos.data.map(async (_repo) => {
|
||||
const project = await getProjectDataPackage(
|
||||
org,
|
||||
_repo.name,
|
||||
branch ? branch : 'main',
|
||||
github_pat
|
||||
);
|
||||
if (!project) {
|
||||
failedProjects.push(_repo.name);
|
||||
return null;
|
||||
}
|
||||
|
||||
return { datapackage: project, repo: _repo };
|
||||
})
|
||||
);
|
||||
return {
|
||||
results: projects.filter((item) => item !== null),
|
||||
failed: failedProjects,
|
||||
};
|
||||
}
|
||||
3
examples/openspending/orgs.json
Normal file
@@ -0,0 +1,3 @@
|
||||
[
|
||||
"os-data"
|
||||
]
|
||||
2444
examples/openspending/package-lock.json
generated
@@ -10,6 +10,8 @@
|
||||
"test": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@githubocto/flat-ui": "^0.14.1",
|
||||
"@heroicons/react": "^2.0.18",
|
||||
"@octokit/plugin-throttling": "^5.2.2",
|
||||
"@types/flexsearch": "^0.7.3",
|
||||
"@types/node": "18.16.0",
|
||||
@@ -23,17 +25,20 @@
|
||||
"next": "13.3.1",
|
||||
"next-seo": "^6.0.0",
|
||||
"octokit": "^2.0.14",
|
||||
"papaparse": "^5.4.1",
|
||||
"prettier": "^2.8.8",
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
"react-hook-form": "^7.43.9",
|
||||
"react-markdown": "^8.0.7",
|
||||
"react-query": "^3.39.3",
|
||||
"react-timeago": "^7.1.0",
|
||||
"remark-gfm": "^3.0.1",
|
||||
"typescript": "5.0.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/typography": "^0.5.9",
|
||||
"@types/papaparse": "^5.3.7",
|
||||
"autoprefixer": "^10.4.14",
|
||||
"postcss": "^8.4.23",
|
||||
"tailwindcss": "^3.3.1",
|
||||
|
||||
@@ -1,126 +0,0 @@
|
||||
import { NextSeo } from 'next-seo';
|
||||
import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
import getConfig from 'next/config';
|
||||
import { getProject, GithubProject } from '../../../lib/octokit';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import Breadcrumbs from '../../../components/_shared/Breadcrumbs';
|
||||
|
||||
export default function ProjectPage({ project }) {
|
||||
const repoId = `@${project.repo_config.owner}/${project.repo_config.repo}`
|
||||
|
||||
return (
|
||||
<>
|
||||
<NextSeo title={`${repoId}${project.base_path !== '/' ? '/' + project.base_path : ''} - GitHub Datasets`} />
|
||||
<main className="prose mx-auto my-8">
|
||||
<Breadcrumbs links={[{ title: repoId, href: "" }]} />
|
||||
<h1 className="mb-0 mt-16">{project.repo_config.name || repoId}</h1>
|
||||
<p className='mb-8'><span className='font-semibold'>Repository:</span> <a target="_blank" href={project.html_url}>{project.html_url}</a></p>
|
||||
|
||||
<h2 className="mb-0 mt-10">Files</h2>
|
||||
<div className="inline-block min-w-full py-2 align-middle">
|
||||
<table className="min-w-full divide-y divide-gray-300">
|
||||
<thead>
|
||||
<tr>
|
||||
<th
|
||||
scope="col"
|
||||
className="px-3 py-3.5 text-left text-sm font-semibold text-gray-900"
|
||||
>
|
||||
Name
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
className="px-3 py-3.5 text-left text-sm font-semibold text-gray-900"
|
||||
>
|
||||
Size
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
{project.files?.map((file) => (
|
||||
<tr key={file.download_url}>
|
||||
<td className="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
|
||||
<a href={file.download_url}>{file.name}</a>
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
|
||||
{file.size} Bytes
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{project.readmeContent && <>
|
||||
<hr />
|
||||
|
||||
<h2 className='uppercase font-black'>Readme</h2>
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||
{project.readmeContent}
|
||||
</ReactMarkdown>
|
||||
</>}
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Generates `/posts/1` and `/posts/2`
|
||||
export async function getStaticPaths() {
|
||||
const jsonDirectory = path.join(
|
||||
process.cwd(),
|
||||
'datasets.json'
|
||||
);
|
||||
const repos = await fs.readFile(jsonDirectory, 'utf8');
|
||||
|
||||
return {
|
||||
paths: JSON.parse(repos).map((repo) => {
|
||||
const projectPath =
|
||||
repo.readme && repo.readme.split('/').length > 1
|
||||
? repo.readme.split('/').slice(0, -1)
|
||||
: null;
|
||||
let path = [repo.name];
|
||||
if (projectPath) {
|
||||
projectPath.forEach((element) => {
|
||||
path.push(element);
|
||||
});
|
||||
}
|
||||
return {
|
||||
params: { org: repo.owner, path },
|
||||
};
|
||||
}),
|
||||
fallback: false, // can also be true or 'blocking'
|
||||
};
|
||||
}
|
||||
|
||||
export async function getStaticProps({ params }) {
|
||||
const jsonDirectory = path.join(
|
||||
process.cwd(),
|
||||
'datasets.json'
|
||||
);
|
||||
const reposFile = await fs.readFile(jsonDirectory, 'utf8');
|
||||
const repos: GithubProject[] = JSON.parse(reposFile);
|
||||
const repo = repos.find((_repo) => {
|
||||
const projectPath =
|
||||
_repo.readme && _repo.readme.split('/').length > 1
|
||||
? _repo.readme.split('/').slice(0, -1)
|
||||
: null;
|
||||
let path = [_repo.name];
|
||||
if (projectPath) {
|
||||
projectPath.forEach((element) => {
|
||||
path.push(element);
|
||||
});
|
||||
}
|
||||
return (
|
||||
_repo.owner == params.org &&
|
||||
JSON.stringify(path) === JSON.stringify(params.path)
|
||||
);
|
||||
});
|
||||
const github_pat = getConfig().serverRuntimeConfig.github_pat;
|
||||
const project = await getProject(repo, github_pat);
|
||||
return {
|
||||
props: {
|
||||
project: { ...project, repo_config: repo },
|
||||
},
|
||||
};
|
||||
}
|
||||
234
examples/openspending/pages/@org/[org]/[project].tsx
Normal file
@@ -0,0 +1,234 @@
|
||||
import { NextSeo } from 'next-seo';
|
||||
import getConfig from 'next/config';
|
||||
import {
|
||||
getAllProjectsFromOrg,
|
||||
getProjectDataPackage,
|
||||
getProjectMetadata,
|
||||
getProjectReadme,
|
||||
} from '../../../lib/project';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import { loadDataPackage } from '../../../lib/loader';
|
||||
import Layout from '../../../components/_shared/Layout';
|
||||
import Link from 'next/link';
|
||||
import { Project } from '../../../lib/project.interface';
|
||||
import ExternalLinkIcon from '../../../components/icons/ExternalLinkIcon';
|
||||
|
||||
export default function ProjectPage({
|
||||
project,
|
||||
readme,
|
||||
}: {
|
||||
project: Project;
|
||||
readme: string;
|
||||
}) {
|
||||
|
||||
// Get description from datapackage or calculate
|
||||
// excerpt from README by getting all the content
|
||||
// up to the first dot.
|
||||
const description =
|
||||
project.description || (readme && readme.slice(0, readme.indexOf('.') + 1));
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<NextSeo title={`${project.title} - OpenSpending`} />
|
||||
<main className="prose mx-auto my-8">
|
||||
<h1 className="mb-1 mt-16">{project.title || project.name}</h1>
|
||||
<Link target="_blank" href={project.repo.url}>
|
||||
@{project.repo.full_name}
|
||||
</Link>
|
||||
|
||||
{description && (
|
||||
<div className="inline-block min-w-full py-2 align-middle mt-5">
|
||||
{description}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="inline-block min-w-full py-2 align-middle">
|
||||
<table className="min-w-full divide-y divide-gray-300">
|
||||
<thead>
|
||||
<tr>
|
||||
<th
|
||||
scope="col"
|
||||
className="px-3 py-3.5 text-left text-sm font-semibold text-gray-900"
|
||||
>
|
||||
Name
|
||||
</th>
|
||||
{project.datapackage.countryCode && (
|
||||
<th
|
||||
scope="col"
|
||||
className="px-3 py-3.5 text-left text-sm font-semibold text-gray-900"
|
||||
>
|
||||
Country
|
||||
</th>
|
||||
)}
|
||||
<th
|
||||
scope="col"
|
||||
className="px-3 py-3.5 text-left text-sm font-semibold text-gray-900"
|
||||
>
|
||||
Metadata
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
<tr>
|
||||
<td className="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
|
||||
{project.name}
|
||||
</td>
|
||||
{project.datapackage.countryCode && (
|
||||
<td className="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
|
||||
{project.datapackage.countryCode}
|
||||
</td>
|
||||
)}
|
||||
|
||||
<td className="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
|
||||
<Link
|
||||
// TODO: this link may be incorrect for some datasets
|
||||
href={`https://github.com/${project.owner.name}/${project.repo.name}/blob/main/datapackage.json`}
|
||||
target="_blank"
|
||||
className="flex items-center hover:text-gray-700"
|
||||
>
|
||||
datapackage.json <ExternalLinkIcon className="ml-1" />
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<h3 className="mb-1 mt-10">Data files</h3>
|
||||
<p>
|
||||
This dataset contains {project.files.length} file
|
||||
{project.files.length != 1 ? '' : 's'}
|
||||
</p>
|
||||
<div className="inline-block min-w-full py-2 align-middle">
|
||||
<table className="mt-0 min-w-full divide-y divide-gray-300">
|
||||
<thead>
|
||||
<tr>
|
||||
<th
|
||||
scope="col"
|
||||
className="px-3 py-3.5 text-left text-sm font-semibold text-gray-900"
|
||||
>
|
||||
Name
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
className="px-3 py-3.5 text-left text-sm font-semibold text-gray-900"
|
||||
>
|
||||
Format
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
className="px-3 py-3.5 text-left text-sm font-semibold text-gray-900"
|
||||
>
|
||||
Size
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
{project.files?.map((file) => {
|
||||
let size: number | string = file.size;
|
||||
|
||||
if (!size) {
|
||||
if (file.bytes) {
|
||||
if (file.bytes > 1000000) {
|
||||
size = (file.bytes / 1000000).toFixed(2) + ' MB';
|
||||
} else {
|
||||
size = (file.bytes / 1000).toFixed(2) + ' kB';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<tr key={file.name}>
|
||||
<td className="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
|
||||
{file.name}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
|
||||
{file.format}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
|
||||
{size}
|
||||
</td>
|
||||
|
||||
<td className="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
|
||||
<Link
|
||||
target="_blank"
|
||||
href={
|
||||
file.path.startsWith('http')
|
||||
? file.path
|
||||
: `https://raw.githubusercontent.com/${project.owner.name}/${project.repo.name}/main/${file.path}`
|
||||
}
|
||||
>
|
||||
Download
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{readme && (
|
||||
<>
|
||||
<hr />
|
||||
|
||||
<h2 className="uppercase font-black">Readme</h2>
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{readme}</ReactMarkdown>
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
// Generates `/posts/1` and `/posts/2`
|
||||
export async function getStaticPaths() {
|
||||
const github_pat = getConfig().serverRuntimeConfig.github_pat;
|
||||
|
||||
const allProjects = await getAllProjectsFromOrg(
|
||||
'os-data',
|
||||
'main',
|
||||
github_pat
|
||||
);
|
||||
|
||||
console.log(allProjects)
|
||||
const paths = allProjects.results.map((project) => ({
|
||||
params: {
|
||||
// TODO: dynamize the org
|
||||
org: 'os-data',
|
||||
project: project.repo.name,
|
||||
},
|
||||
}));
|
||||
|
||||
return {
|
||||
paths,
|
||||
fallback: false, // can also be true or 'blocking'
|
||||
};
|
||||
}
|
||||
|
||||
export async function getStaticProps({ params }) {
|
||||
const { org: orgName, project: projectName } = params;
|
||||
|
||||
const github_pat = getConfig().serverRuntimeConfig.github_pat;
|
||||
const datapackage = await getProjectDataPackage(
|
||||
orgName,
|
||||
projectName,
|
||||
'main',
|
||||
github_pat
|
||||
);
|
||||
|
||||
const repo = await getProjectMetadata(orgName, projectName, github_pat);
|
||||
|
||||
const project = loadDataPackage(datapackage, repo);
|
||||
|
||||
// TODO: should this be moved to the loader?
|
||||
const readme = await getProjectReadme(orgName, projectName, 'main', github_pat);
|
||||
|
||||
return {
|
||||
props: {
|
||||
project,
|
||||
readme,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,13 +1,11 @@
|
||||
import { AppProps } from 'next/app';
|
||||
import Head from 'next/head';
|
||||
import './styles.css';
|
||||
import { NextSeo } from 'next-seo';
|
||||
|
||||
function CustomApp({ Component, pageProps }: AppProps) {
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>GitHub Datasets</title>
|
||||
</Head>
|
||||
<NextSeo title="OpenSpending" />
|
||||
<main className="app">
|
||||
<Component {...pageProps} />
|
||||
</main>
|
||||
|
||||
@@ -1,65 +1,58 @@
|
||||
import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
import {
|
||||
GithubProject,
|
||||
getProjectDataPackage,
|
||||
getProjectMetadata,
|
||||
} from '../lib/octokit';
|
||||
import { getAllProjectsFromOrg } from '../lib/project';
|
||||
import getConfig from 'next/config';
|
||||
import ExternalLinkIcon from '../components/icons/ExternalLinkIcon';
|
||||
import TimeAgo from 'react-timeago';
|
||||
import Link from 'next/link';
|
||||
import { Hero } from '../components/Hero';
|
||||
import { Header } from '../components/Header';
|
||||
import { Container } from '../components/Container';
|
||||
import { FiscalDataPackage } from '../lib/datapackage.interface';
|
||||
import { loadDataPackage } from '../lib/loader';
|
||||
import DatasetsSearch from '../components/DatasetsSearch';
|
||||
import Layout from '../components/_shared/Layout';
|
||||
|
||||
export async function getStaticProps() {
|
||||
const jsonDirectory = path.join(process.cwd(), '/datasets.json');
|
||||
const repos = await fs.readFile(jsonDirectory, 'utf8');
|
||||
// TODO: support other orgs
|
||||
// const orgsListPath = path.join(process.cwd(), '/orgs.json');
|
||||
// const orgs = await fs.readFile(orgsListPath, 'utf8');
|
||||
|
||||
const github_pat = getConfig().serverRuntimeConfig.github_pat;
|
||||
const datapackages = await Promise.all(
|
||||
JSON.parse(repos).map(async (_repo: GithubProject) => {
|
||||
const datapackage = await getProjectDataPackage(
|
||||
_repo.owner,
|
||||
_repo.name,
|
||||
|
||||
const allProjects = await getAllProjectsFromOrg(
|
||||
'os-data',
|
||||
'main',
|
||||
github_pat
|
||||
);
|
||||
const repo = await getProjectMetadata(
|
||||
_repo.owner,
|
||||
_repo.name,
|
||||
github_pat
|
||||
);
|
||||
|
||||
return {
|
||||
datapackage,
|
||||
repo,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
const projects = datapackages.map(
|
||||
const projects = allProjects.results.map(
|
||||
(item: { datapackage: FiscalDataPackage & { repo: string }; repo: any }) =>
|
||||
loadDataPackage(item.datapackage, item.repo)
|
||||
);
|
||||
|
||||
const availableCountries = projects
|
||||
.map((item) => item.countryCode)
|
||||
.filter((v) => v) // Filters false values
|
||||
.filter((v, i, a) => a.indexOf(v) === i) // Remove duplicates
|
||||
// TODO: title should be the full name
|
||||
.map((code) => ({ code, title: code }));
|
||||
|
||||
return {
|
||||
props: {
|
||||
projects: JSON.stringify(projects),
|
||||
availableCountries,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function Datasets({ projects }) {
|
||||
export function Home({ projects, availableCountries }) {
|
||||
projects = JSON.parse(projects);
|
||||
|
||||
return (
|
||||
<div className="bg-white min-h-screen">
|
||||
<Header />
|
||||
<Hero />
|
||||
<Layout>
|
||||
<Hero
|
||||
countriesCount={availableCountries.length}
|
||||
datasetsCount={projects.length}
|
||||
filesCount={projects.reduce(
|
||||
(partialSum, a) => partialSum + a.files.length,
|
||||
0
|
||||
)}
|
||||
/>
|
||||
<section className="py-20 sm:py-32">
|
||||
<Container>
|
||||
<div className="mx-auto max-w-2xl lg:mx-0">
|
||||
@@ -74,12 +67,15 @@ export function Datasets({ projects }) {
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-10">
|
||||
<DatasetsSearch datasets={projects} />
|
||||
<DatasetsSearch
|
||||
datasets={projects}
|
||||
availableCountries={availableCountries}
|
||||
/>
|
||||
</div>
|
||||
</Container>
|
||||
</section>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
export default Datasets;
|
||||
export default Home;
|
||||
|
||||
BIN
site/content/assets/blog/markdowndb.gif
Normal file
|
After Width: | Height: | Size: 1.4 MiB |
BIN
site/content/assets/blog/output.png
Normal file
|
After Width: | Height: | Size: 193 KiB |
BIN
site/content/assets/blog/sqlite-viewer.png
Normal file
|
After Width: | Height: | Size: 92 KiB |
BIN
site/content/assets/howto/catalog-example.png
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
site/content/assets/howto/catalog-facets-example.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
site/content/assets/howto/comments-example.png
Normal file
|
After Width: | Height: | Size: 114 KiB |
BIN
site/content/assets/howto/linechart-example.png
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
site/content/assets/howto/table-example.png
Normal file
|
After Width: | Height: | Size: 48 KiB |
270
site/content/blog/markdowndb-basics-tutorial-2023.md
Normal file
@@ -0,0 +1,270 @@
|
||||
---
|
||||
title: Learn how to use MarkdownDB with our First Tutorial!
|
||||
description: We've just released our first tutorial that covers the fundamentals of MarkdownDB - our new package for treating markdown files as a database. If you've been curious about how to manage your markdown files more effectively, check it out!
|
||||
date: 2023-05-26
|
||||
author: Ola Rubaj
|
||||
---
|
||||
|
||||
We've just released our first tutorial that covers the fundamentals of [MarkdownDB](https://github.com/datopian/markdowndb) - our new package for treating markdown files as a database. If you've been curious about how to manage your markdown files more effectively, this tutorial is an excellent starting point!
|
||||
|
||||

|
||||
|
||||
## What is MarkdownDB?
|
||||
|
||||
MarkdownDB can parse your markdown files, extract structured data (such as frontmatter, tags, back- and forward links and more), and create an index in a local SQLite database. It also provides a lightweight JavaScript API for querying the database and importing files into your application. With MarkdownDB, you have a powerful tool that allows you to efficiently query your markdown data.
|
||||
|
||||
[🔍 Click here to learn more!](https://github.com/datopian/markdowndb)
|
||||
|
||||
## What you're going to learn
|
||||
|
||||
This tutorial guides you through the steps of creating a small project catalog. In our case, we used our open-source projects on GitHub - but you could use anything! We use simple markdown files to describe each project and then display them with the MarkdownDB package.
|
||||
|
||||
## Step 1: Create a markdown file for each project
|
||||
|
||||
First, let's create a markdown file for each project. You can use real project details or make up some examples. For the sake of this tutorial, we'll create files for some of the projects we've built at Datopian.
|
||||
|
||||
```bash
|
||||
mkdir projects
|
||||
cd projects
|
||||
touch markdowndb.md portaljs.md flowershow.md datapipes.md giftless.md data-cli.md
|
||||
```
|
||||
|
||||
In each file we'll write some short info about a given project, like so:
|
||||
|
||||
```md
|
||||
# What is MarkdownDB
|
||||
|
||||
MarkdownDB is a javascript library for treating markdown files as a database -- as a "MarkdownDB". Specifically, it:
|
||||
|
||||
- Parses your markdown files to extract structured data (frontmatter, tags, etc) and creates an index in a local SQLite database
|
||||
- Provides a lightweight javascript API for querying the database and importing files into your application
|
||||
```
|
||||
|
||||
## Step 2: Index markdown files into SQLite database
|
||||
|
||||
Once we have prepared our markdown files, we can store them (or more precisely - their metadata) in a database, so that we can then query it later for specific project files.
|
||||
|
||||
```bash
|
||||
# npx @flowershow/markdowndb <path-to-folder-with-md-files>
|
||||
npx @flowershow/markdowndb ./projects
|
||||
```
|
||||
|
||||
The above command will output a `markdown.db` file in the directory where it was executed. So, in our case, the folder structure will look like this:
|
||||
|
||||
```
|
||||
.
|
||||
├── markdown.db
|
||||
└── projects
|
||||
├── data-cli.md
|
||||
├── ...
|
||||
└── portaljs.md
|
||||
```
|
||||
|
||||
## Step 3: Explore the SQLite database
|
||||
|
||||
Now, let's explore the database. We can do it with any SQLite viewer, e.g. https://sqlitebrowser.org/. When we open the `markdown.db` file in the viewer, we'll see a list of tables:
|
||||
|
||||
- `files`: containing metadata of our markdown,
|
||||
- `file_tags`: containing tags set in the frontmatter of our markdown files,
|
||||
- `links`: containing wiki links, i.e. links between our markdown files,
|
||||
- `tags`: containing all tags used in our markdown files.
|
||||
|
||||
In our case, the `files` table will look like this:
|
||||
|
||||
![[/assets/blog/sqlite-viewer.png]]
|
||||
|
||||
You can also explore the database from the command line, e.g.:
|
||||
|
||||
```bash
|
||||
sqlite3 markdown.db
|
||||
```
|
||||
|
||||
And then run SQL queries, e.g.:
|
||||
|
||||
```sql
|
||||
SELECT * FROM files;
|
||||
```
|
||||
|
||||
Which will output:
|
||||
|
||||
```bash
|
||||
27ce406aac24e59af7a9f3c0c0a437c1d024152b|projects/data-cli.md|md|data-cli||{}
|
||||
26b1b0b06a4f450646f9e22fc18ec069bf577d8c|projects/datapipes.md|md|datapipes||{}
|
||||
dafdd0daf71a1b06db1988c57848dc36947375f4|projects/flowershow.md|md|flowershow||{}
|
||||
32c8db33fb8758516bfefb6ab1f22d03b1e53a08|projects/giftless.md|md|giftless||{}
|
||||
7e01cae193f12f5a4a9be2b89f22b429761bd313|projects/markdowndb.md|md|markdowndb||{}
|
||||
5445349c6822704d6f531a83c2aca2e4f90de864|projects/portaljs.md|md|portaljs||{}
|
||||
```
|
||||
|
||||
## Step 4: Query the database in the Node.js app
|
||||
|
||||
Now, let's write a simple script that will query the database for our projects and display them on the terminal.
|
||||
|
||||
First, let's create a new Node.js project:
|
||||
|
||||
```bash
|
||||
mkdir projects-list
|
||||
cd projects-list
|
||||
npm init -y
|
||||
```
|
||||
|
||||
Then, let's install the `@flowershow/markdowndb` package:
|
||||
|
||||
```bash
|
||||
npm install @flowershow/markdowndb
|
||||
```
|
||||
|
||||
Now, let's create a new file `index.js` and add the following code:
|
||||
|
||||
```js
|
||||
import { MarkdownDB } from '@flowershow/markdowndb';
|
||||
|
||||
// change this to the path to your markdown.db file
|
||||
const dbPath = 'markdown.db';
|
||||
|
||||
const client = new MarkdownDB({
|
||||
client: 'sqlite3',
|
||||
connection: {
|
||||
filename: dbPath,
|
||||
},
|
||||
});
|
||||
|
||||
const mddb = await client.init();
|
||||
|
||||
// get all projects
|
||||
const projects = await mddb.getFiles();
|
||||
|
||||
console.log(JSON.stringify(projects, null, 2));
|
||||
|
||||
process.exit(0);
|
||||
```
|
||||
|
||||
Since we're using ES6 modules, we also need to add `"type": "module"` to our `package.json` file.
|
||||
|
||||
Before we run the above script, we need to make sure that the `dbPath` variable is pointing to our `markdown.db` file. If you want to store the database outside of your project folder, you can update the `dbPath` variable to point to the correct location. If you want to have it inside your project folder, you can copy it there, or simply re-run the `npx @flowershow/markdowndb <path-to-markdown-folder>` command from within your project folder.
|
||||
|
||||
Now, let's run the script:
|
||||
|
||||
```bash
|
||||
node index.js
|
||||
```
|
||||
|
||||
It should output the JSON with all our projects.
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"_id": "7e01cae193f12f5a4a9be2b89f22b429761bd313",
|
||||
"file_path": "projects/markdowndb.md",
|
||||
"extension": "md",
|
||||
"url_path": "markdowndb",
|
||||
"filetype": null,
|
||||
"metadata": {}
|
||||
},
|
||||
...
|
||||
]
|
||||
```
|
||||
|
||||
## Step 5: Add metadata to project files
|
||||
|
||||
Now, let's add some metadata to our project files. We'll use frontmatter for that. Since we're creating a catalog of our GitHub projects, we'll add the following frontmatter fields to each file:
|
||||
|
||||
```md
|
||||
---
|
||||
name: markdowndb
|
||||
description: Javascript library for treating markdown files as a database.
|
||||
stars: 6
|
||||
forks: 0
|
||||
---
|
||||
```
|
||||
|
||||
After adding the metadata, we need to re-index our markdown files into the database:
|
||||
|
||||
```bash
|
||||
npx @flowershow/markdowndb ../projects
|
||||
```
|
||||
|
||||
Now, if we run our script again, we'll see that the `metadata` field in the output contains the metadata we've added to our project files:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"_id": "7e01cae193f12f5a4a9be2b89f22b429761bd313",
|
||||
"file_path": "projects/markdowndb.md",
|
||||
"extension": "md",
|
||||
"url_path": "markdowndb",
|
||||
"metadata": {
|
||||
"name": "markdowndb",
|
||||
"description": "Javascript library for treating markdown files as a database",
|
||||
"stars": 6,
|
||||
"forks": 0
|
||||
}
|
||||
},
|
||||
...
|
||||
]
|
||||
```
|
||||
|
||||
## Step 6: Pretty print the output
|
||||
|
||||
Now, let's make the output a bit more readable. We'll use the `columnify` package for that:
|
||||
|
||||
```bash
|
||||
npm install columnify
|
||||
```
|
||||
|
||||
And then we'll update our `index.js` file:
|
||||
|
||||
```js {2,16-38}
|
||||
import { MarkdownDB } from '@flowershow/markdowndb';
|
||||
import columnify from 'columnify';
|
||||
|
||||
const dbPath = 'markdown.db';
|
||||
|
||||
const client = new MarkdownDB({
|
||||
client: 'sqlite3',
|
||||
connection: {
|
||||
filename: dbPath,
|
||||
},
|
||||
});
|
||||
|
||||
const mddb = await client.init();
|
||||
const projects = await mddb.getFiles();
|
||||
|
||||
// console.log(JSON.stringify(projects, null, 2));
|
||||
|
||||
const projects2 = projects.map((project) => {
|
||||
const { file_path, metadata } = project;
|
||||
return {
|
||||
file_path,
|
||||
...metadata,
|
||||
};
|
||||
});
|
||||
|
||||
const columns = columnify(projects2, {
|
||||
truncate: true,
|
||||
columnSplitter: ' | ',
|
||||
config: {
|
||||
description: {
|
||||
maxWidth: 80,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
console.log('\n');
|
||||
console.log(columns);
|
||||
console.log('\n');
|
||||
|
||||
process.exit(0);
|
||||
```
|
||||
|
||||
The above script will output the following to the terminal:
|
||||
|
||||
![[/assets/blog/output.png]]
|
||||
|
||||
## Done!
|
||||
|
||||
That's it! We've just created a simple catalog of our GitHub projects using markdown files and the MarkdownDB package. You can find the full code for this tutorial [here](https://github.com/datopian/markdowndb/tree/main/examples/basic-example).
|
||||
|
||||
We look forward to seeing the amazing applications you'll build with this tool!
|
||||
|
||||
Happy coding!
|
||||
@@ -20,7 +20,9 @@ const config = {
|
||||
// { name: "Components", href: "/docs/components" },
|
||||
{ name: "Blog", href: "/blog" },
|
||||
{ name: "Showcases", href: "/#showcases" },
|
||||
{ name: "Howtos", href: "/howto" },
|
||||
{ name: "Examples", href: "https://github.com/datopian/portaljs/tree/main/examples", target: "_blank" },
|
||||
{ name: "Components", href: "https://storybook.portaljs.org", target: "_blank" },
|
||||
// { name: "DL Demo", href: "/data-literate/demo" },
|
||||
// { name: "Excel Viewer", href: "/excel-viewer" },
|
||||
],
|
||||
|
||||
77
site/content/howto/analytics.md
Normal file
@@ -0,0 +1,77 @@
|
||||
# How to add Google Analytics?
|
||||
|
||||
>[!todo] Prerequisites
|
||||
>- [Google Analytics account](https://support.google.com/analytics/answer/9304153?hl=en)
|
||||
>- [Google tag ID](https://support.google.com/analytics/answer/12270356?hl=en#:~:text=A%20Measurement%20ID%20is%20an,same%20as%20your%20destination%20ID.)
|
||||
|
||||
The following piece of code adds pageview tracking functionality to the custom Next.js App in `/pages/_app.tsx`, but you can use it only in specific pages if you want.
|
||||
|
||||
```tsx
|
||||
// /pages/_app.tsx
|
||||
import { useRouter } from "next/router";
|
||||
import { useEffect } from "react";
|
||||
import Script from "next/script";
|
||||
|
||||
// Step 0:
|
||||
// Add your Google tag ID to your local .env.local file, e.g. under GA_TRACKING_ID
|
||||
|
||||
// Step 1: create the following pageview function
|
||||
const pageview = ({
|
||||
url,
|
||||
analyticsID,
|
||||
}: {
|
||||
url: string;
|
||||
analyticsID: string;
|
||||
}) => {
|
||||
if (typeof window.gtag !== undefined) {
|
||||
window.gtag("config", analyticsID, {
|
||||
page_path: url,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export default function MyApp({ Component, pageProps }) {
|
||||
const router = useRouter();
|
||||
|
||||
// ... your code
|
||||
|
||||
// Step 3: Add the following useEffect to trigger pageview on each route change
|
||||
useEffect(() => {
|
||||
const handleRouteChange = (url) => {
|
||||
pageview(url, GA_TRACKING_ID);
|
||||
};
|
||||
router.events.on("routeChangeComplete", handleRouteChange);
|
||||
return () => {
|
||||
router.events.off("routeChangeComplete", handleRouteChange);
|
||||
};
|
||||
}, [router.events]);
|
||||
|
||||
return (
|
||||
{/* Step 4: Install Google Analytics tag */}
|
||||
<>
|
||||
<Script
|
||||
strategy="afterInteractive"
|
||||
src={`https://www.googletagmanager.com/gtag/js?id=${GA_TRACKING_ID}`}
|
||||
/>
|
||||
<Script
|
||||
id="gtag-init"
|
||||
strategy="afterInteractive"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `
|
||||
window.dataLayer = window.dataLayer || [];
|
||||
function gtag(){dataLayer.push(arguments);}
|
||||
gtag('js', new Date());
|
||||
gtag('config', '${GA_TRACKING_ID}', {
|
||||
page_path: window.location.pathname,
|
||||
});
|
||||
`,
|
||||
}}
|
||||
/>
|
||||
<Component {...pageProps} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
>[!info]
|
||||
> For more info on measuring pageviews with Google Analytics see [Google Analytics documentation](https://developers.google.com/analytics/devguides/collection/gtagjs/pages).
|
||||
75
site/content/howto/blog.md
Normal file
@@ -0,0 +1,75 @@
|
||||
# How to add a simple blog?
|
||||
|
||||
## Setup
|
||||
|
||||
The following example uses components imported from the [`@flowershow/core` package](https://github.com/datopian/flowershow). If you want to follow along install it too:
|
||||
|
||||
```sh
|
||||
npm i @flowershow/core
|
||||
```
|
||||
|
||||
## Create home page for your blogs
|
||||
|
||||
Add the following code to the Next.js page that is going to be your blog home page, e.g. to `/pages/blog/index.tsx`:
|
||||
|
||||
```tsx
|
||||
import { BlogsList, SimpleLayout } from "@flowershow/core";
|
||||
|
||||
// pass a list of blogs, home page title and home page description, e.g. from `getStaticProps`
|
||||
export default function BlogIndex({ blogs, title, description }) {
|
||||
return (
|
||||
<SimpleLayout title={title} description={description}>
|
||||
<BlogsList blogs={blogs} />
|
||||
</SimpleLayout>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
`BlogsList` component has the following API:
|
||||
|
||||
```ts
|
||||
interface BlogsListProps {
|
||||
blogs: Blog;
|
||||
}
|
||||
|
||||
interface Blog {
|
||||
title: string;
|
||||
date: string;
|
||||
urlPath: string;
|
||||
description?: string;
|
||||
authors?: Array<string>;
|
||||
tags?: Array<string>;
|
||||
}
|
||||
```
|
||||
|
||||
## Create blog post pages
|
||||
|
||||
Add the following code to your blog pages, e.g. to `/pages/blog/[...slug].tsx`:
|
||||
|
||||
```tsx
|
||||
import { BlogLayout } from "@flowershow/core";
|
||||
|
||||
export default BlogPost({ content, title, date, authors }) {
|
||||
return (
|
||||
<BlogLayout title={title} date={date} authors={authors}
|
||||
{content}
|
||||
</BlogLayout>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
`BlogLayout` component has the following API:
|
||||
|
||||
```ts
|
||||
interface BlogLayoutProps {
|
||||
title?: string;
|
||||
date?: string;
|
||||
authors?: Array<Author>;
|
||||
}
|
||||
|
||||
interface Author {
|
||||
name: string;
|
||||
avatar: string; // avatar image path
|
||||
urlPath?: string; // author page
|
||||
}
|
||||
```
|
||||
106
site/content/howto/comments.md
Normal file
@@ -0,0 +1,106 @@
|
||||
# How to add user comments?
|
||||
|
||||
![[comments-example.png]]
|
||||
|
||||
## Setup
|
||||
|
||||
Page comments can be setup with any one of the following supported providers:
|
||||
|
||||
1. [giscus](https://giscus.app/)
|
||||
2. [utterances](https://utteranc.es/)
|
||||
3. [disqus](https://disqus.com/)
|
||||
|
||||
Each provider has it's own configuration options that you should add to your `.env` file.
|
||||
|
||||
>[!Info]
|
||||
>If you are hosting your website on hosting providers like Netlify, Vercel or Cloudflare, you will also need to add the environment variables there.
|
||||
|
||||
### Giscus
|
||||
|
||||
[Giscus](https://giscus.app/) uses your github repo's discussions feature to store your comments and display them on your site.
|
||||
|
||||
#### Prerequisites
|
||||
|
||||
1. You have a public [github repository](https://docs.github.com/en/get-started/quickstart/create-a-repo) with the [discussions](https://docs.github.com/en/github/administering-a-repository/managing-repository-settings/enabling-or-disabling-github-discussions-for-a-repository) feature activated which will be used to store your comments.
|
||||
2. The [giscus app](https://github.com/apps/giscus) installed in your repo by following their configuration setup at [https://giscus.app/](https://giscus.app/)
|
||||
|
||||
Once the above steps are completed, head over to [https://giscus.app](https://giscus.app/) and follow the steps there by filling out the fields to get your config values.
|
||||
|
||||
>[!important]
|
||||
> Make sure to choose `pathname` under page discussions mapping.
|
||||
|
||||
After filling out the fields, you will be provided with a script tag that contains your config values. Add them to your `.env` file, like so:
|
||||
|
||||
```
|
||||
NEXT_PUBLIC_GISCUS_REPO=
|
||||
NEXT_PUBLIC_GISCUS_REPOSITORY_ID=
|
||||
NEXT_PUBLIC_GISCUS_CATEGORY=
|
||||
NEXT_PUBLIC_GISCUS_CATEGORY_ID=
|
||||
```
|
||||
|
||||
When a user visits a web page, Giscus searches for a discussion whose title contains the page's pathname URL component. If there are no matches (i.e., no comments have been made about that page), then a new discussion is created for that page.
|
||||
|
||||
By using the pathname for mapping with a page, Giscus ensures that each discussion is unique to a particular page and that comments made on that page are kept together.
|
||||
|
||||
More info on how this connection is achieved can be found on their '**how it works**' section in their repo - [https://github.com/giscus/giscus#how-it-works](https://github.com/giscus/giscus#how-it-works) and also in the '**Page Discussions Mapping**' section at [https://giscus.app/](https://giscus.app/)
|
||||
|
||||
### Utterances
|
||||
|
||||
[Utterances](https://utteranc.es/) uses your github repo's issues to store comments for your pages and display them on your site. To setup comments with utterances, you would need to do the following:
|
||||
|
||||
#### Prerequisites
|
||||
|
||||
1. You have a public [github repository](https://docs.github.com/en/get-started/quickstart/create-a-repo)
|
||||
2. The utterances app installed in your repo by following their configuration setup at [https://utteranc.es/](https://utteranc.es/)
|
||||
|
||||
Once installed fill in the required fields and you will see a script tag with your repo value which you can add to your `.env` file, like so:
|
||||
|
||||
```
|
||||
NEXT_PUBLIC_UTTERANCES_REPO=
|
||||
```
|
||||
|
||||
### Disqus
|
||||
|
||||
[Disqus](https://disqus.com/) is a feature rich provider which can be used to add comments to your site. You can configure flowershow to use disqus by creating an account on their site and following their process. You will be asked to enter a shortname for your site. Once completed, we can use this shortname in our comments configuration as below.
|
||||
|
||||
#### Prerequisites
|
||||
|
||||
1. Create an account on [Disqus](https://disqus.com/) and follow their setup process.
|
||||
|
||||
You will be asked to enter a shortname for your site. Add it to your `.env` file as well, like so:
|
||||
|
||||
```
|
||||
NEXT_PUBLIC_DISQUS_SHORTNAME=
|
||||
```
|
||||
|
||||
## Add comments to your page layout
|
||||
|
||||
You can use `@flowershow/core` to import the comments section component. Install it with:
|
||||
|
||||
```sh
|
||||
npm i @flowershow/core
|
||||
```
|
||||
|
||||
Then, add the following to your custom layout (or directly to your pages):
|
||||
|
||||
```tsx
|
||||
import Navbar from './navbar';
|
||||
import Footer from './footer';
|
||||
|
||||
export default function Layout({ children }) {
|
||||
return (
|
||||
<>
|
||||
<Navbar />
|
||||
<main>{children}</main>
|
||||
<div>
|
||||
<Comments commentsConfig={commentsConfig} slug={urlPath} />
|
||||
</div>
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Add user comments
|
||||
|
||||
Learn how to add support for user comments in [[comments|this guide]].
|
||||
334
site/content/howto/drd.md
Normal file
@@ -0,0 +1,334 @@
|
||||
# How to create data-rich documents with charts and tables?
|
||||
|
||||
> [!info] Prerequisites
|
||||
> If you want to enrich your markdown content with charts and tables, you first need to add support for rendering markdown files in your PortalJS app. Follow [[markdown|this guide]] to learn how to do this.
|
||||
|
||||
PortalJS comes with a library of components that provides essential pieces for your data portal. The best way to explore the components is to look at our [Storybook](https://storybook.portaljs.org/) that contains all the details on how to use them. Below is an overview of available components.
|
||||
|
||||
You can install the library with:
|
||||
|
||||
```sh
|
||||
npm i @portaljs/components
|
||||
```
|
||||
|
||||
Now, in order to use these components in your markdown files, we need to pass them to our `MDXRemote` component (see [[markdown#Import, parse and render your markdown files|this section in our markdown guide]]):
|
||||
|
||||
```tsx
|
||||
// e.g. /blog/[[...slug]].tsx
|
||||
import fs from "fs";
|
||||
import { LineChart, Table, Catalog, Vega, VegaLite } from "@portaljs/components";
|
||||
|
||||
import { MdxRemote } from "next-mdx-remote";
|
||||
import clientPromise from "@/lib/mddb.mjs";
|
||||
import parse from "@/lib/markdown";
|
||||
|
||||
const components = {
|
||||
Table: Table,
|
||||
Vega: Vega,
|
||||
VegaLite: VegaLite,
|
||||
LineChart: LineChart,
|
||||
};
|
||||
|
||||
|
||||
export default function Page({ source }) {
|
||||
source = JSON.parse(source);
|
||||
|
||||
return (
|
||||
<>
|
||||
<MdxRemote source={source} components={components} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Import metadata of a file matching the static path and return its parsed source and frontmatter object
|
||||
export const getStaticProps = async ({ params }) => {
|
||||
const urlPath = params?.slug ? (params.slug as string[]).join("/") : "/";
|
||||
|
||||
const mddb = await clientPromise;
|
||||
const dbFile = await mddb.getFileByUrl(urlPath);
|
||||
const filePath = dbFile!.file_path;
|
||||
// const frontMatter = dbFile!.metadata ?? {};
|
||||
|
||||
const source = fs.readFileSync(filePath, { encoding: "utf-8" });
|
||||
const { mdxSource } = await parse(source, "mdx", {});
|
||||
|
||||
return {
|
||||
props: {
|
||||
source: JSON.stringify(mdxSource),
|
||||
// frontMatter
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
```
|
||||
|
||||
You can now use these components in your markdown, like so:
|
||||
|
||||
```md
|
||||
### My Data Rich Note
|
||||
|
||||
Some markdown here.
|
||||
|
||||
<Table csv={`
|
||||
Year,Temp Anomaly
|
||||
1850,-0.418
|
||||
2020,0.923
|
||||
`} />
|
||||
|
||||
Some markdown here.
|
||||
```
|
||||
|
||||
## PortalJS components
|
||||
|
||||
### Table
|
||||
|
||||
An easy-to-use table component with built-in pagination, search, and sorting. It can be used with raw data, raw CSV strings or with URLs to hosted CSV files.
|
||||
|
||||
![[table-example.png]]
|
||||
|
||||
Example usage:
|
||||
|
||||
```js
|
||||
<Table
|
||||
cols={[
|
||||
{
|
||||
key: 'id',
|
||||
name: 'ID'
|
||||
},
|
||||
{
|
||||
key: 'firstName',
|
||||
name: 'First name'
|
||||
},
|
||||
{
|
||||
key: 'lastName',
|
||||
name: 'Last name'
|
||||
},
|
||||
{
|
||||
key: 'age',
|
||||
name: 'Age'
|
||||
}
|
||||
]}
|
||||
data={[
|
||||
{
|
||||
age: 35,
|
||||
firstName: 'Jon',
|
||||
id: 1,
|
||||
lastName: 'Snow'
|
||||
},
|
||||
{
|
||||
age: 42,
|
||||
firstName: 'Cersei',
|
||||
id: 2,
|
||||
lastName: 'Lannister'
|
||||
}
|
||||
]}
|
||||
/>
|
||||
```
|
||||
|
||||
>[!info]
|
||||
> More info on the [storybook page](https://storybook.portaljs.org/?path=/docs/components-table--docs)
|
||||
|
||||
### Linechart
|
||||
|
||||
A simple component that allows the creation of an opinionated line chart without the need to go deep into charting standards. It can be used with raw data, raw CSV strings or with URLs to hosted CSV files.
|
||||
|
||||
![[linechart-example.png]]
|
||||
|
||||
Example usage:
|
||||
|
||||
```js
|
||||
<LineChart
|
||||
data={[
|
||||
[
|
||||
'1850',
|
||||
-0.41765878
|
||||
],
|
||||
[
|
||||
'1851',
|
||||
-0.2333498
|
||||
],
|
||||
[
|
||||
'1852',
|
||||
-0.22939907
|
||||
],
|
||||
[
|
||||
'1853',
|
||||
-0.27035445
|
||||
],
|
||||
[
|
||||
'1854',
|
||||
-0.29163003
|
||||
]
|
||||
]}
|
||||
/>
|
||||
```
|
||||
|
||||
>[!info]
|
||||
> More info on the [storybook page](https://storybook.portaljs.org/?path=/docs/components-linechart--docs)
|
||||
|
||||
### Vega chart
|
||||
|
||||
A wrapper around the [Vega specification](https://vega.github.io/vega/) that allows you to build pretty much any kind of chart imaginable.
|
||||
|
||||
Example usage:
|
||||

|
||||
|
||||
```js
|
||||
<Vega
|
||||
data={{
|
||||
table: [
|
||||
{
|
||||
x: 1850,
|
||||
y: -0.418
|
||||
},
|
||||
{
|
||||
x: 2020,
|
||||
y: 0.923
|
||||
}
|
||||
]
|
||||
}}
|
||||
spec={{
|
||||
$schema: 'https://vega.github.io/schema/vega-lite/v4.json',
|
||||
data: {
|
||||
name: 'table'
|
||||
},
|
||||
encoding: {
|
||||
x: {
|
||||
field: 'x',
|
||||
type: 'ordinal'
|
||||
},
|
||||
y: {
|
||||
field: 'y',
|
||||
type: 'quantitative'
|
||||
}
|
||||
},
|
||||
mark: 'bar'
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
> [!info]
|
||||
> More info on the [storybook page](https://storybook.portaljs.org/?path=/docs/components-vega--docs)
|
||||
|
||||
### VegaLite chart
|
||||
|
||||
A wrapper around the [Vega Lite specification](https://vega.github.io/vega-lite/) which allows for a more concise grammar than Vega around the building of charts.
|
||||
|
||||
Example usage:
|
||||
|
||||
```js
|
||||
<VegaLite
|
||||
data={{
|
||||
table: [
|
||||
{
|
||||
x: 1850,
|
||||
y: -0.418
|
||||
},
|
||||
{
|
||||
x: 2020,
|
||||
y: 0.923
|
||||
}
|
||||
]
|
||||
}}
|
||||
spec={{
|
||||
$schema: 'https://vega.github.io/schema/vega-lite/v4.json',
|
||||
data: {
|
||||
name: 'table'
|
||||
},
|
||||
encoding: {
|
||||
x: {
|
||||
field: 'x',
|
||||
type: 'ordinal'
|
||||
},
|
||||
y: {
|
||||
field: 'y',
|
||||
type: 'quantitative'
|
||||
}
|
||||
},
|
||||
mark: 'bar'
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
>[!info]
|
||||
> More info on the [storybook page](https://storybook.portaljs.org/?path=/docs/components-vegalite--docs)
|
||||
|
||||
### Catalog
|
||||
|
||||
A searchable catalog that will index a list of datasets and allow for contextual searching + filters.
|
||||
|
||||
![[catalog-example.png]]
|
||||
|
||||
Example usage:
|
||||
|
||||
```js
|
||||
<Catalog
|
||||
datasets={[
|
||||
{
|
||||
_id: '07026b22d49916754df1dc8ffb9ccd1c31878aae',
|
||||
metadata: {
|
||||
'details-of-task': 'Detect and categorise abusive language in social media data',
|
||||
language: 'Albanian',
|
||||
'level-of-annotation': [
|
||||
'Posts'
|
||||
],
|
||||
'link-to-data': 'https://doi.org/10.6084/m9.figshare.19333298.v1',
|
||||
'link-to-publication': 'https://arxiv.org/abs/2107.13592',
|
||||
medium: [
|
||||
'Text'
|
||||
],
|
||||
'percentage-abusive': 13.2,
|
||||
platform: [
|
||||
'Instagram',
|
||||
'Youtube'
|
||||
],
|
||||
reference: 'Nurce, E., Keci, J., Derczynski, L., 2021. Detecting Abusive Albanian. arXiv:2107.13592',
|
||||
'size-of-dataset': 11874,
|
||||
'task-description': 'Hierarchical (offensive/not; untargeted/targeted; person/group/other)',
|
||||
title: 'Detecting Abusive Albanian'
|
||||
},
|
||||
url_path: 'dataset-4'
|
||||
},
|
||||
]}
|
||||
/>
|
||||
```
|
||||
|
||||
You can also add facets that are going to act as filters for your metadata.
|
||||
|
||||
![[catalog-facets-example.png]]
|
||||
|
||||
```js
|
||||
<Catalog
|
||||
datasets={[
|
||||
{
|
||||
_id: '07026b22d49916754df1dc8ffb9ccd1c31878aae',
|
||||
metadata: {
|
||||
'details-of-task': 'Detect and categorise abusive language in social media data',
|
||||
language: 'Albanian',
|
||||
'level-of-annotation': [
|
||||
'Posts'
|
||||
],
|
||||
'link-to-data': 'https://doi.org/10.6084/m9.figshare.19333298.v1',
|
||||
'link-to-publication': 'https://arxiv.org/abs/2107.13592',
|
||||
medium: [
|
||||
'Text'
|
||||
],
|
||||
'percentage-abusive': 13.2,
|
||||
platform: [
|
||||
'Instagram',
|
||||
'Youtube'
|
||||
],
|
||||
reference: 'Nurce, E., Keci, J., Derczynski, L., 2021. Detecting Abusive Albanian. arXiv:2107.13592',
|
||||
'size-of-dataset': 11874,
|
||||
'task-description': 'Hierarchical (offensive/not; untargeted/targeted; person/group/other)',
|
||||
title: 'Detecting Abusive Albanian'
|
||||
},
|
||||
url_path: 'dataset-4'
|
||||
},
|
||||
]}
|
||||
facets={['platform', 'language']}
|
||||
/>
|
||||
```
|
||||
|
||||
>[!info]
|
||||
> More info on the [storybook page](https://storybook.portaljs.org/?path=/docs/components-catalog--docs)
|
||||
12
site/content/howto/index.md
Normal file
@@ -0,0 +1,12 @@
|
||||
# Guides and tutorials
|
||||
|
||||
- [[howto/analytics|How to add web analytics?]]
|
||||
- [[howto/seo|How to customize page metadata for SEO?]]
|
||||
- [[howto/sitemap|How to build a sitemap?]]
|
||||
- [[howto/markdown|How to add markdown-based content pages?]]
|
||||
- [[howto/blog|How to add a simple blog?]]
|
||||
- [[howto/drd|How to create data-rich documents with charts and tables?]]
|
||||
- [[howto/comments|How to add user comments?]]
|
||||
|
||||
If you have questions about anything related to PortalJS, you're always welcome to ask our community on [GitHub Discussions](https://github.com/datopian/portaljs/discussions) or on [our chat channel on Discord](https://discord.gg/EeyfGrGu4U).
|
||||
|
||||
169
site/content/howto/markdown.md
Normal file
@@ -0,0 +1,169 @@
|
||||
# How to add markdown-based content pages?
|
||||
|
||||
## Add content layer to your app
|
||||
|
||||
Create a folder where you'll keep your markdown files and add some markdown files to it.
|
||||
|
||||
```sh
|
||||
cd my-portaljs-project
|
||||
mkdir content
|
||||
# touch content/index.md ...
|
||||
```
|
||||
|
||||
Install [MarkdownDB](https://github.com/datopian/markdowndb) package:
|
||||
|
||||
```
|
||||
npm i @flowershow/markdowndb
|
||||
```
|
||||
|
||||
And add the following to your `package.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"scripts": {
|
||||
"mddb": "mddb <path-to-your-content-folder>",
|
||||
"prebuild": "npm run mddb"
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
You can give it a go by running `npm run mddb`. You should see a `markdown.db` file created in the root of your project. You can inspect it with any SQLite viewer or in the command line. In the `files` table you should see all your markdown files from your content folder.
|
||||
|
||||
Now, once the data is in the database, you can add the following script to your project (e.g. in `/lib` folder). It will allow you to establish a single connection to the database and use it across your app.
|
||||
|
||||
```ts
|
||||
// lib/mddb.ts
|
||||
import { MarkdownDB } from "@flowershow/markdowndb";
|
||||
|
||||
// path to the markdown.db file created by the mddb script
|
||||
const dbPath = "markdown.db";
|
||||
|
||||
const client = new MarkdownDB({
|
||||
client: "sqlite3",
|
||||
connection: {
|
||||
filename: dbPath,
|
||||
},
|
||||
});
|
||||
|
||||
const clientPromise = client.init();
|
||||
|
||||
export default clientPromise;
|
||||
```
|
||||
|
||||
Now you can import it across your project to query the database, e.g.:
|
||||
|
||||
```ts
|
||||
import clientPromise from "@/lib/mddb";
|
||||
|
||||
const mddb = await clientPromise;
|
||||
const blogs = await mddb.getFiles({
|
||||
folder: "blog",
|
||||
extensions: ["md", "mdx"],
|
||||
});
|
||||
```
|
||||
|
||||
## Write a markdown parser
|
||||
|
||||
Install [next-mdx-remote](https://github.com/hashicorp/next-mdx-remote) package, which we'll first use to parse markdown files and then to render them in Next.js app.
|
||||
|
||||
```sh
|
||||
npm i next-mdx-remote
|
||||
```
|
||||
|
||||
Create the following basic parser for your markdown files, e.g. in `/lib/markdown.ts`:
|
||||
|
||||
```ts
|
||||
import matter from "gray-matter";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { serialize } from "next-mdx-remote/serialize";
|
||||
|
||||
|
||||
const parse = async function (source) {
|
||||
const { content } = matter(source);
|
||||
|
||||
const mdxSource = await serialize(
|
||||
{ value: content },
|
||||
{
|
||||
mdxOptions: {
|
||||
remarkPlugins: [
|
||||
remarkGfm,
|
||||
// ... your remark plugins
|
||||
],
|
||||
rehypePlugins: [
|
||||
// ... your plugins
|
||||
],
|
||||
format,
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
return {
|
||||
mdxSource
|
||||
};
|
||||
};
|
||||
|
||||
export default parse;
|
||||
|
||||
```
|
||||
|
||||
## Import, parse and render your markdown files
|
||||
|
||||
Create a page in the `/pages` folder that will render your markdown content, e.g. `pages/blog/[[...slug]].tsx`:
|
||||
|
||||
```tsx
|
||||
import fs from "fs";
|
||||
|
||||
import { MdxRemote } from "next-mdx-remote";
|
||||
import clientPromise from "@/lib/mddb.mjs";
|
||||
import parse from "@/lib/markdown";
|
||||
|
||||
|
||||
export default function Page({ source }) {
|
||||
source = JSON.parse(source);
|
||||
|
||||
return (
|
||||
<>
|
||||
<MdxRemote source={source} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Import metadata of a file matching the static path and return its parsed source and frontmatter object
|
||||
export const getStaticProps = async ({ params }) => {
|
||||
const urlPath = params?.slug ? (params.slug as string[]).join("/") : "/";
|
||||
|
||||
const mddb = await clientPromise;
|
||||
const dbFile = await mddb.getFileByUrl(urlPath);
|
||||
const filePath = dbFile!.file_path;
|
||||
// const frontMatter = dbFile!.metadata ?? {};
|
||||
|
||||
const source = fs.readFileSync(filePath, { encoding: "utf-8" });
|
||||
const { mdxSource } = await parse(source, "mdx", {});
|
||||
|
||||
return {
|
||||
props: {
|
||||
source: JSON.stringify(mdxSource),
|
||||
// frontMatter
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
// Import metadata of your markdown files from MarkdownDB and return a list of static paths
|
||||
export const getStaticPaths = async () => {
|
||||
const mddb = await clientPromise;
|
||||
const allDocuments = await mddb.getFiles({ extensions: ["md", "mdx"] });
|
||||
const paths = allDocuments
|
||||
.map((page) => {
|
||||
const url = decodeURI(page.url_path);
|
||||
const parts = url.split("/");
|
||||
return { params: { slug: parts } };
|
||||
});
|
||||
|
||||
return {
|
||||
paths,
|
||||
fallback: false,
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
79
site/content/howto/seo.md
Normal file
@@ -0,0 +1,79 @@
|
||||
# How to customize page metadata for SEO?
|
||||
|
||||
>[!info]
|
||||
>See [`next-seo` documentation](https://github.com/garmeeh/next-seo) to learn more.
|
||||
|
||||
## Setup
|
||||
|
||||
Install `next-seo` package:
|
||||
|
||||
```sh
|
||||
npm i next-seo
|
||||
```
|
||||
|
||||
## Default SEO configuration
|
||||
|
||||
Create `next-seo.config` file (e.g. in the root of your project) and add default meta tags values you want to be set up for your pages. For example:
|
||||
|
||||
```ts
|
||||
export default {
|
||||
openGraph: {
|
||||
type: 'website',
|
||||
locale: 'en_IE',
|
||||
url: 'https://www.my.portaljs.app/',
|
||||
siteName: 'MyPortalJSApp',
|
||||
},
|
||||
twitter: {
|
||||
handle: '@handle',
|
||||
site: '@site',
|
||||
cardType: 'summary_large_image',
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
Then, in your custom Next.js App, add the following:
|
||||
|
||||
```tsx
|
||||
// /pages/_app.tsx
|
||||
import { DefaultSeo } from "next-seo";
|
||||
|
||||
// import your default seo configuration
|
||||
import SEO from '../next-seo.config';
|
||||
|
||||
|
||||
export default function MyApp({ Component, pageProps }) {
|
||||
// ... your code
|
||||
return (
|
||||
<>
|
||||
<DefaultSeo {...SEO} />
|
||||
<Component {...pageProps} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
```
|
||||
|
||||
>[!info]
|
||||
>To learn more on default SEO configuration with `next-seo`, see [this docs section](https://github.com/garmeeh/next-seo#default-seo-configuration).
|
||||
|
||||
## Per-page SEO configuration
|
||||
|
||||
To add page specific meta tag values:
|
||||
|
||||
```tsx
|
||||
import { NextSeo } from 'next-seo';
|
||||
|
||||
export default function Page() (
|
||||
// ...
|
||||
<>
|
||||
<NextSeo
|
||||
title="My Portal JS page"
|
||||
description="A short description."
|
||||
/>
|
||||
<p>My Portal JS page</p>
|
||||
</>
|
||||
);
|
||||
```
|
||||
|
||||
>[!info]
|
||||
> To learn more on per-page SEO configuration with `next-seo`, see [this docs section](https://github.com/garmeeh/next-seo#add-seo-to-page)
|
||||
94
site/content/howto/sitemap.md
Normal file
@@ -0,0 +1,94 @@
|
||||
# How to build a sitemap?
|
||||
|
||||
## Setup
|
||||
|
||||
Install the following packages:
|
||||
|
||||
```sh
|
||||
npm i globby
|
||||
npm i -D prettier
|
||||
```
|
||||
|
||||
## Add sitemap script
|
||||
|
||||
>[!info]
|
||||
>The following example assumes files list for dynamic Next.js routes is imported from the database created with [MarkdownDB](https://github.com/datopian/markdowndb) package. You need to adjust it to your specific use case.
|
||||
|
||||
Add the following script to e.g. `/scripts/sitemap.mjs` and adjust it to work with your app:
|
||||
|
||||
```js
|
||||
// /scripts/sitemap.mjs
|
||||
import { writeFileSync } from "fs";
|
||||
import { globby } from "globby";
|
||||
import prettier from "prettier";
|
||||
|
||||
// Change this to import data from your content layer (if needed)
|
||||
import clientPromise from "../lib/mddb.mjs";
|
||||
|
||||
export default async function sitemap() {
|
||||
const prettierConfig = await prettier.resolveConfig("path-to-your-prettier-config");
|
||||
|
||||
// Change this to create a list of page paths
|
||||
const mddb = await clientPromise;
|
||||
const allFiles = await mddb.getFiles({ extensions: ["mdx", "md"] });
|
||||
const contentPages = allFiles
|
||||
.filter((x) => !x.metadata?.isDraft)
|
||||
.map((x) => `/${x.url_path}`);
|
||||
const pages = await globby([
|
||||
"pages/*.(js|tsx)",
|
||||
"!pages/_*.(js|tsx)",
|
||||
"!pages/api",
|
||||
"!pages/404.(js|tsx)",
|
||||
"!pages/**/\\[\\[*\\]\\].(js|tsx)", // pages/[[...slug]].tsx
|
||||
]);
|
||||
const allPages = pages.concat(contentPages);
|
||||
// end
|
||||
|
||||
// Replace this with your site domain
|
||||
const siteDomain = "your-site-domain";
|
||||
|
||||
const sitemap = `
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||
${allPages
|
||||
.map((page) => {
|
||||
const path = page
|
||||
.replace("pages/", "/")
|
||||
.replace("public/", "/")
|
||||
.replace(/\.js|.tsx|.mdx|.md[^\/.]+$/, "")
|
||||
.replace("/feed.xml", "");
|
||||
const route = path === "/index" ? "" : path;
|
||||
return `
|
||||
<url>
|
||||
<loc>${siteDomain}${route}</loc>
|
||||
</url>
|
||||
`;
|
||||
})
|
||||
.join("")}
|
||||
</urlset>
|
||||
`;
|
||||
|
||||
const formatted = prettier.format(sitemap, {
|
||||
...prettierConfig,
|
||||
parser: "html",
|
||||
});
|
||||
|
||||
if (siteUrl) {
|
||||
writeFileSync("public/sitemap.xml", formatted);
|
||||
console.log("Sitemap generated...");
|
||||
}
|
||||
}
|
||||
|
||||
await sitemap();
|
||||
process.exit();
|
||||
```
|
||||
|
||||
## Execute script before each build
|
||||
|
||||
Add this to the scripts section in your `package.json` to regenerate the sitemap before each build.
|
||||
|
||||
```json
|
||||
|
||||
"prebuild": "node ./scripts/sitemap.mjs",
|
||||
|
||||
```
|
||||